Как выбрать базу данных и оптимизировать SQL-запросы
Как выбрать базу данных под реальные требования системы и последовательно оптимизировать SQL-запросы: моделирование, индексы, EXPLAIN, JOIN, сортировки, пагинация, статистика и контроль нагрузки.
Выбор базы данных часто начинают с вопроса: «Что лучше — PostgreSQL, MySQL или какая-нибудь NoSQL-база?».
Это не самый полезный вопрос.
У базы данных нет универсального показателя «лучше». СУБД выбирают под характер данных, тип запросов, требования к транзакциям, объём, нагрузку, схему масштабирования и ограничения самой системы.
Точно так же оптимизация SQL редко начинается с переписывания запроса.
В большинстве случаев проблема находится глубже: неудачная модель данных, отсутствие подходящего индекса, неправильный порядок соединений, слишком большой объём читаемых данных или неверные ожидания от самого хранилища.
Поэтому я разделяю задачу на две части:
Выбор СУБД
↓
Правильная модель данных
↓
Правильные запросы
↓
Индексы
↓
Анализ плана
↓
Измерение
И только после этого имеет смысл заниматься тонкой оптимизацией.
База выбирается по нагрузочному профилю
Первое, что нужно определить, — что именно будет происходить с данными.
Для прикладной системы полезно заранее ответить:
Сколько данных?
Сколько записей появляется в секунду?
Какова доля чтения и записи?
Нужны ли транзакции?
Какие запросы наиболее частые?
Нужны ли JOIN?
Нужна ли строгая согласованность?
Какой допустим latency?
Есть ли сложная аналитика?
Как долго хранятся данные?
Например, два проекта могут иметь одинаковый объём данных:
1 млрд записей
но совершенно разные требования.
Первый:
Много UPDATE
Много коротких SELECT
Транзакции
Связи между сущностями
Нужна консистентность
Второй:
Почти только INSERT
Большие объёмы событий
Агрегации
GROUP BY
Отчёты
Сканирование больших диапазонов
Для этих задач естественно рассматривать разные типы хранилищ.
Именно поэтому при выборе OLAP-систем ClickHouse рекомендует смотреть прежде всего на фактический workload: latency, модель загрузки, объём данных, способ развёртывания и конкурентность запросов. (clickhouse.com)
OLTP и OLAP — разные задачи
Условно базы данных можно разделить по характеру работы.
OLTP
Это рабочая база приложения:
Пользователь
↓
API
↓
Database
Типичные операции:
SELECT одного объекта
INSERT заказа
UPDATE статуса
JOIN нескольких таблиц
транзакция
Здесь важны:
- низкая задержка;
- транзакции;
- конкурентный доступ;
- индексы;
- точечные операции.
Для такого класса задач часто подходят PostgreSQL и MySQL.
OLAP
Здесь запрос выглядит иначе:
SELECT
date,
country,
sum(revenue),
count(*)
FROM events
GROUP BY
date,
country;
Нужно обработать огромное количество строк и агрегировать их.
Это уже аналитическая нагрузка.
ClickHouse прямо позиционирует себя как OLAP-систему, где производительность сильно зависит от того, сколько данных удаётся отсечь до начала фактического чтения, а выбор ORDER BY и ключа сортировки имеет фундаментальное значение. (clickhouse.com)
Поэтому пытаться решить все задачи одной БД не всегда рационально.
PostgreSQL и MySQL закрывают большую часть прикладных задач
Для большинства обычных веб-платформ нет необходимости искать экзотическую СУБД.
Реляционная база с хорошей транзакционной моделью, индексами и развитым SQL часто закрывает основную часть требований.
PostgreSQL предоставляет несколько типов индексов, включая B-tree, Hash, GiST, SP-GiST, GIN и BRIN, а также multicolumn, partial и covering indexes. (postgresql.org)
MySQL 8.4 также имеет развитую систему индексации, включая составные индексы, покрывающие индексы и средства анализа использования индексов. (dev.mysql.com)
Поэтому выбор между ними часто определяется не «скоростью базы вообще», а конкретными требованиями проекта, уже существующей инфраструктурой, компетенциями команды и особенностями данных.
Не выбирайте базу по одному benchmark
Бенчмарк может показать:
PostgreSQL → 100 000 QPS
MySQL → 120 000 QPS
и создать ощущение, что MySQL лучше.
Но что именно измерялось?
Размер таблицы?
Тип запроса?
Количество JOIN?
Уровень конкуренции?
Транзакции?
Размер результата?
Cache hit?
SSD?
RAM?
Версия СУБД?
Смена workload может полностью поменять результат.
Поэтому benchmark конкретного запроса на ваших данных обычно полезнее красивой таблицы из интернета.
Особенно это касается индексов и оптимизации: PostgreSQL прямо отмечает, что выбор индексов часто требует экспериментов на реальной рабочей нагрузке, а MySQL рекомендует анализировать фактический план выполнения запроса. (postgresql.org, dev.mysql.com)
Начинать оптимизацию нужно с измерения
Предположим, endpoint отвечает:
P95 = 900 ms
Просто переписывать SQL ещё рано.
Сначала нужно понять, сколько занимает каждый этап:
HTTP
↓
Application
↓
SQL
↓
External API
И только если видно:
SQL = 780 ms
имеет смысл исследовать базу.
А затем:
Query A = 700 ms
Query B = 50 ms
Query C = 10 ms
Оптимизируем Query A, а не все запросы подряд.
Это базовый принцип performance engineering:
Сначала измерение — потом изменение.
EXPLAIN — один из главных инструментов разработчика
Когда запрос стал подозрительным, следующий шаг — посмотреть execution plan.
В PostgreSQL:
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders
WHERE customer_id = 123;
EXPLAIN показывает дерево операций, которое выбрал planner: последовательное сканирование, index scan, bitmap scan, JOIN, сортировки и другие операции. EXPLAIN ANALYZE дополнительно выполняет запрос и показывает фактические показатели выполнения. PostgreSQL также позволяет смотреть информацию о буферах через BUFFERS. (postgresql.org)
В MySQL используется:
EXPLAIN
SELECT *
FROM orders
WHERE customer_id = 123;
MySQL также предоставляет дополнительные форматы EXPLAIN, включая JSON. Документация рекомендует использовать план выполнения для анализа выбранных индексов и порядка JOIN. (dev.mysql.com)
Главное — не просто посмотреть, что там есть index.
Нужно понять:
Сколько строк ожидал planner?
Сколько строк реально прочитано?
Какой индекс выбран?
Сколько раз выполнен node?
Где находится сортировка?
Есть ли sequential scan?
Как выполняется JOIN?
Есть ли лишнее чтение?
Индекс — это не волшебная кнопка
Самая распространённая рекомендация по медленному SQL:
«Добавь индекс».
Иногда это правильный ответ.
Но индекс тоже имеет стоимость.
PostgreSQL прямо указывает, что индексы ускоряют поиск, но увеличивают общий overhead системы. MySQL также отмечает, что лишние индексы занимают место и увеличивают стоимость INSERT, UPDATE и DELETE. (postgresql.org, dev.mysql.com)
Например:
100 SELECT/сек
10 INSERT/сек
может оправдывать достаточно агрессивную индексацию.
А если:
10 SELECT/сек
10 000 INSERT/сек
каждый дополнительный индекс уже необходимо оценивать гораздо осторожнее.
Индекс — это компромисс:
Быстрее чтение
+
Дороже запись
+
Дополнительное место
+
Дополнительная работа optimizer
Индекс должен соответствовать запросу
Допустим, есть:
SELECT *
FROM orders
WHERE customer_id = 100
AND status = 'paid'
ORDER BY created_at DESC
LIMIT 50;
Очевидный вариант:
CREATE INDEX idx_orders_customer
ON orders(customer_id);
Но он может быть далёк от оптимального для конкретного workload.
В PostgreSQL и MySQL составной индекс может использовать несколько колонок. В MySQL действует правило leftmost prefix: индекс (a, b, c) может использоваться для условий по (a), (a,b) и (a,b,c). (dev.mysql.com)
В PostgreSQL multicolumn indexes также позволяют строить индекс по нескольким полям. (postgresql.org)
Поэтому для конкретного workload может быть разумнее:
CREATE INDEX idx_orders_customer_status_created
ON orders(customer_id, status, created_at DESC);
Но и этот индекс нельзя считать автоматически правильным.
Его нужно проверить через реальный план.
Порядок колонок в составном индексе имеет значение
Например:
CREATE INDEX idx_orders
ON orders(customer_id, status, created_at);
и:
CREATE INDEX idx_orders
ON orders(status, customer_id, created_at);
— не одно и то же.
Предположим:
customer_id
→ очень селективный
а:
status
→ только 4 значения
Тогда для части запросов customer_id может быть более полезным первым полем.
Но если основной workload выглядит как:
WHERE status = 'paid'
ORDER BY created_at DESC
может потребоваться совсем другой индекс.
Поэтому порядок колонок определяется реальными запросами и распределением данных, а не универсальным правилом.
Не каждый WHERE требует индекса
Допустим:
SELECT *
FROM users
WHERE is_active = true;
Если 98% пользователей активны, индекс:
CREATE INDEX idx_users_active
ON users(is_active);
может оказаться практически бесполезным.
Почему?
Потому что нужно вернуть почти всю таблицу.
PostgreSQL прямо отмечает, что индекс не всегда выгоден при поиске значений, встречающихся у значительной доли строк. В таких случаях sequential scan может оказаться дешевле. (postgresql.org)
То есть наличие индекса ещё не означает его использование.
И это нормально.
Partial index может быть лучше обычного
Предположим, в таблице:
10 000 000 orders
но активно используются только:
200 000 orders
со статусом:
pending
Для PostgreSQL можно использовать partial index:
CREATE INDEX idx_orders_pending
ON orders(created_at)
WHERE status = 'pending';
Так индекс содержит только нужную часть строк.
PostgreSQL прямо приводит partial indexes как способ уменьшить размер индекса и стоимость его поддержания, когда запросы заинтересованы только в определённом подмножестве данных. (postgresql.org)
Но это специфический инструмент.
Partial index нельзя создавать только потому, что он «меньше». Его пригодность зависит от распределения данных и того, может ли planner сопоставить условие запроса с предикатом индекса. (postgresql.org)
Не используйте SELECT *
Одна из простых практик:
SELECT *
FROM users
WHERE id = 123;
Если приложению нужны:
id
name
email
лучше явно запросить их:
SELECT id, name, email
FROM users
WHERE id = 123;
Причина не только в размере ответа.
Чем больше колонок требуется получить, тем больше данных необходимо прочитать и передать дальше.
В некоторых сценариях это также влияет на возможность выполнить index-only или covering access.
MySQL прямо документирует covering indexes как случай, когда все нужные значения можно получить непосредственно из индекса без дополнительного чтения строк таблицы. (dev.mysql.com)
JOIN редко является проблемой сам по себе
Иногда встречается правило:
«JOIN — это медленно».
Это слишком грубое утверждение.
JOIN — нормальная операция реляционной базы.
Проблема возникает, когда JOIN приводит к обработке огромного количества строк.
Например:
SELECT o.id, c.name
FROM orders o
JOIN customers c
ON c.id = o.customer_id
WHERE o.created_at >= '2026-08-01';
Здесь важны:
Индекс по orders.created_at
Индекс / PK по customers.id
Количество строк после фильтра
Порядок выполнения
Оптимизатор может выбрать разные стратегии соединения.
В PostgreSQL plan отображает алгоритмы JOIN наряду с типами сканирования, а MySQL EXPLAIN позволяет посмотреть порядок соединения таблиц и используемые индексы. (postgresql.org, dev.mysql.com)
Поэтому вопрос не:
«Есть ли JOIN?»
а:
«Сколько строк система должна обработать до и после JOIN?»
Иногда проблема начинается с JOIN, который вообще не нужен
Например:
SELECT o.id
FROM orders o
JOIN customers c
ON c.id = o.customer_id
WHERE c.country = 'TR';
Если нам не нужны данные customers, а нужен только факт существования клиента, иногда логика может быть выражена через EXISTS:
SELECT o.id
FROM orders o
WHERE EXISTS (
SELECT 1
FROM customers c
WHERE c.id = o.customer_id
AND c.country = 'TR'
);
Но нельзя утверждать, что EXISTS автоматически быстрее JOIN.
Современный optimizer способен преобразовать разные формы SQL к близким планам.
Поэтому сначала:
EXPLAIN
а уже затем изменение синтаксиса.
OFFSET плохо масштабируется на глубоких страницах
Классическая пагинация:
SELECT id, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 50 OFFSET 500000;
может становиться дорогой на больших объёмах данных.
Вместо этого для некоторых сценариев лучше использовать keyset pagination:
SELECT id, created_at
FROM orders
WHERE created_at < :last_created_at
ORDER BY created_at DESC
LIMIT 50;
При наличии подходящего индекса база может сразу начать поиск с нужной точки.
Но и здесь нельзя использовать универсальный рецепт.
Если сортировка должна быть стабильной при одинаковом created_at, обычно нужен дополнительный tie-breaker:
ORDER BY created_at DESC, id DESC
и соответствующий составной индекс.
Пагинацию всегда следует рассматривать вместе с требуемым порядком сортировки и индексом.
ORDER BY может оказаться дороже самого WHERE
Запрос:
SELECT *
FROM orders
WHERE customer_id = 100
ORDER BY created_at DESC
LIMIT 50;
может быть быстрым, если индекс помогает сразу получить строки в нужном порядке.
Но если сначала выбираются тысячи или миллионы строк, а затем база сортирует их:
Scan
↓
Filter
↓
Sort
↓
Limit
стоимость может резко увеличиться.
Поэтому индексы иногда проектируются не только под фильтрацию, но и под порядок выдачи.
Это один из случаев, когда форма запроса и структура индекса должны проектироваться совместно.
Функция над колонкой может сломать ожидаемое использование индекса
Например:
WHERE LOWER(email) = 'user@example.com'
Если есть обычный индекс:
CREATE INDEX idx_users_email
ON users(email);
он не обязательно подходит для выражения над email.
В PostgreSQL для таких случаев существуют expression indexes:
CREATE INDEX idx_users_lower_email
ON users(LOWER(email));
PostgreSQL поддерживает индексы по выражениям именно для таких сценариев. (postgresql.org)
В других СУБД подход может отличаться.
Главный принцип:
Индекс должен соответствовать фактическому выражению, по которому выполняется поиск.
Статистика влияет на план
Оптимизатор принимает решение не на основе догадки разработчика, а на основании статистики о данных.
Предположим, planner ожидает:
100 строк
а реальный результат:
5 000 000 строк
Он может выбрать совершенно другой план, чем тот, который был бы оптимален для реального распределения данных.
Поэтому обновление статистики — часть эксплуатации базы.
PostgreSQL рекомендует ANALYZE для сбора статистики распределения значений, а MySQL предоставляет ANALYZE TABLE для обновления статистики, которая влияет на выбор плана. (postgresql.org, dev.mysql.com)
Особенно важно это после значительных изменений данных.
Индексировать всё подряд — плохая стратегия
Предположим, есть таблица:
orders
и вы создаёте индексы:
customer_id
status
created_at
updated_at
amount
currency
country
source
manager_id
type
Каждый из них может быть полезен для какого-то запроса.
Но вместе они создают дополнительную стоимость:
INSERT
UPDATE
DELETE
должны поддерживать множество индексов.
Кроме того, планировщик получает больше вариантов.
Поэтому я предпочитаю исходить не из структуры таблицы:
«Какие поля здесь индексировать?»
а из workload:
«Какие запросы система реально выполняет?»
PostgreSQL прямо отмечает необходимость проверять фактическое использование индексов в реальной нагрузке, а MySQL рекомендует создавать небольшой набор индексов, который ускоряет связанные запросы, вместо безусловной индексации всего подряд. (postgresql.org, dev.mysql.com)
Иногда запрос нужно менять, а не индекс
Рассмотрим:
SELECT *
FROM events
WHERE DATE(created_at) = '2026-08-15';
Вместо преобразования каждой строки можно использовать диапазон:
SELECT *
FROM events
WHERE created_at >= '2026-08-15 00:00:00'
AND created_at < '2026-08-16 00:00:00';
Теперь условие соответствует диапазону по самой колонке и лучше согласуется с обычным B-tree индексом по created_at.
Это хороший пример того, почему оптимизация SQL — это не только создание индексов.
Нужно смотреть на то, как запрос обращается к данным.
Лишние данные часто являются главным тормозом
Иногда разработчик оптимизирует SQL, когда правильнее изменить API.
Например:
SELECT 50 columns
↓
serialize JSON
↓
send 2 MB
↓
frontend uses 5 fields
Можно сделать:
SELECT 5 columns
↓
serialize
↓
send 20 KB
В результате ускоряется не только SQL.
Снижается:
DB I/O
Network I/O
Serialization
Memory usage
GC pressure
Поэтому оптимизация базы никогда не должна рассматриваться изолированно от приложения.
N+1 — один из самых дорогих прикладных анти-паттернов
Классический сценарий:
1 запрос → получить 100 заказов
100 запросов → получить клиента для каждого заказа
Получается:
101 SQL-запрос
Хотя данные можно получить иначе:
SELECT
o.id,
o.total,
c.name
FROM orders o
JOIN customers c
ON c.id = o.customer_id
WHERE o.user_id = ?;
N+1 особенно часто появляется через ORM.
Сам ORM не является проблемой.
Проблемой является ситуация, когда абстракция скрывает количество фактических запросов от разработчика.
Поэтому при проблемах производительности полезно смотреть не только на отдельный SQL, но и на количество запросов на один HTTP request.
ORM нужно понимать на уровне SQL
ORM повышает производительность разработки.
Но он не отменяет физику базы.
Например:
$orders = Order::query()
->where('status', 'paid')
->with('customer')
->get();
может выглядеть прекрасно.
Но разработчик всё равно должен понимать:
Сколько SQL запросов?
Какие JOIN?
Какие поля выбираются?
Какие индексы используются?
Какой объём результата?
На production-системе умение читать SQL и execution plan остаётся полезным независимо от языка и ORM.
Когда нужен ClickHouse
Если система начинает хранить огромное количество событий:
logs
events
clickstream
metrics
audit
analytics
и основной workload — аналитические запросы:
GROUP BY
COUNT
SUM
AVG
time ranges
может появиться смысл вынести аналитический контур отдельно.
Например:
PostgreSQL
↓
операционные данные
ClickHouse
↓
аналитика
При таком подходе реляционная OLTP-база не обязана одновременно обслуживать:
GET /orders/123
и:
SELECT
toDate(created_at),
country,
count(),
sum(amount)
FROM events
GROUP BY
toDate(created_at),
country;
ClickHouse подчёркивает, что в аналитических workloads огромную роль играет уменьшение объёма читаемых данных, а выбор ORDER BY может радикально влиять на производительность запросов. В опубликованном ими руководстве заявляется, что удачный дизайн ключа сортировки в некоторых сценариях может давать кратный эффект, вплоть до порядка 100×. (clickhouse.com)
Это не означает, что ClickHouse нужен каждому проекту.
Но он хорошо иллюстрирует принцип:
хранилище должно соответствовать характеру workload.
Не превращайте одну базу в универсальный комбайн
Частая архитектурная ошибка выглядит так:
PostgreSQL
├── транзакции
├── поиск
├── аналитика
├── логирование
├── очереди
└── кеш
Технически многое из этого можно реализовать.
Вопрос в другом:
Действительно ли одна технология хорошо решает все эти задачи?
Иногда лучше разделить ответственность:
PostgreSQL
→ transactional data
Redis
→ cache / ephemeral state
RabbitMQ
→ asynchronous jobs
ClickHouse
→ analytics
Object Storage
→ large files
Но здесь есть и обратная сторона.
Каждое новое хранилище добавляет:
deployment
monitoring
backup
failure modes
data synchronization
operational complexity
Поэтому polyglot persistence имеет смысл только тогда, когда выигрыш действительно оправдывает эту сложность.
Как я обычно ищу медленный запрос
Практический алгоритм достаточно простой.
1. Найти реальный медленный запрос
Не тот, который кажется подозрительным, а тот, который реально влияет на latency или нагрузку.
2. Посмотреть объём результата
Rows returned
Rows examined
Чем больше база читает лишнего, тем вероятнее проблема.
3. Выполнить EXPLAIN
EXPLAIN ...
или, где это уместно:
EXPLAIN ANALYZE ...
В PostgreSQL EXPLAIN ANALYZE показывает фактическое выполнение, а BUFFERS помогает увидеть характер I/O. (postgresql.org)
4. Проверить индексы
WHERE
JOIN
ORDER BY
GROUP BY
и соответствующий порядок колонок.
5. Проверить статистику
Насколько оценки planner соответствуют реальности?
6. Уменьшить объём работы
меньше строк
меньше колонок
меньше JOIN
меньше сортировки
меньше повторных запросов
7. Измерить результат ещё раз
Сравнивать нужно:
до
vs
после
а не ощущение разработчика.
Оптимизация должна измеряться не только миллисекундами
Допустим, запрос был:
250 ms
стал:
100 ms
Это хорошо.
Но ещё важнее:
CPU ↓
IOPS ↓
Rows read ↓
DB connections ↓
P95 latency ↓
Потому что быстрый запрос, который потребляет огромное количество CPU, всё равно может стать bottleneck при росте нагрузки.
Для высоконагруженной системы особенно важно смотреть на стоимость запроса как на ресурс:
CPU
RAM
Disk I/O
Network
Connections
Locks
В этом смысле оптимизация запроса — это не только «сделать 50 ms вместо 100 ms».
Это уменьшить количество работы, которую база должна выполнять на каждый запрос.
Я бы выбирал базу так
Не по рейтингу и не по benchmark.
Сначала определить workload:
Transactional?
Analytical?
Search?
Cache?
Events?
Documents?
Time-series?
Затем:
объём данных
+
скорость записи
+
паттерны чтения
+
требования к консистентности
+
latency
+
масштабирование
И только после этого выбирать технологию.
Для типичной бизнес-платформы:
PostgreSQL / MySQL
↓
основные данные
Для большого аналитического потока:
ClickHouse
↓
OLAP
Для временного состояния и кэша:
Redis
Для больших файлов:
Object Storage
При этом это не догма. Архитектура должна следовать реальным требованиям.
Итог
Правильный выбор базы данных начинается не с названия СУБД.
Он начинается с характера данных и запросов.
А оптимизация SQL начинается не с переписывания SELECT, а с измерения.
Практический порядок выглядит так:
Определить workload
↓
Выбрать тип хранилища
↓
Спроектировать данные
↓
Определить реальные запросы
↓
Посмотреть EXPLAIN
↓
Создать необходимые индексы
↓
Уменьшить объём читаемых данных
↓
Проверить план ещё раз
↓
Измерить под реальной нагрузкой
При этом несколько принципов работают практически всегда:
- не выбирайте СУБД только по benchmark;
- не создавайте индексы без понимания workload;
- не считайте
JOINпроблемой сам по себе; - не используйте
SELECT *без необходимости; - контролируйте N+1;
- анализируйте execution plan;
- следите за статистикой;
- учитывайте стоимость индексов для записи;
- отделяйте OLTP от OLAP, когда нагрузка действительно этого требует;
- оптимизируйте не отдельный запрос, а общую стоимость работы системы.
Главное правило можно сформулировать ещё проще:
Хорошая база — не та, которая теоретически самая быстрая. Это та, которая соответствует вашим данным, workload и способу масштабирования системы.
А хороший SQL — не самый хитрый и короткий запрос.
Это запрос, который заставляет СУБД сделать минимально необходимый объём работы для получения нужного результата.
Релевантные разделы
Читайте также
Архитектура веб-платформ: как проектировать систему, которая растёт
Как проектировать архитектуру веб-платформ, которая выдерживает рост функциональности, команды и нагрузки: модульность, границы ответственности, данные, интеграции, наблюдаемость и выбор между монолитом и микросервисами.
Высоконагруженные веб-системы: как проектировать архитектуру под рост нагрузки
Как проектировать высоконагруженные веб-системы: поиск узких мест, масштабирование backend и базы данных, кэширование, очереди, отказоустойчивость, нагрузочное тестирование и наблюдаемость.