Перейти к содержимому
Базы данных12 мин чтения

Как выбрать базу данных и оптимизировать SQL-запросы

Как выбрать базу данных под реальные требования системы и последовательно оптимизировать SQL-запросы: моделирование, индексы, EXPLAIN, JOIN, сортировки, пагинация, статистика и контроль нагрузки.

#базы данных #SQL #PostgreSQL #MySQL #ClickHouse #оптимизация запросов #индексы #EXPLAIN #highload

Выбор базы данных часто начинают с вопроса: «Что лучше — 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 — не самый хитрый и короткий запрос.

Это запрос, который заставляет СУБД сделать минимально необходимый объём работы для получения нужного результата.

Релевантные разделы