Все материалы
Продуктовая аналитикагайдсредний

Оптимизация SQL-запросов: почему запрос медленный и как его ускорить

Оптимизация SQL-запросов для аналитика с замерами в PostgreSQL: функция в WHERE, EXISTS вместо JOIN и DISTINCT, NOT IN и NOT EXISTS, UNION ALL, ORDER BY с LIMIT, план запроса и чеклист.

КейсПрактика31 октября 2026 г.9 мин

Аналитик редко отвечает за индексы и настройки сервера, но почти каждый день пишет запросы, которые кто-то ждёт: дашборд, выгрузку, ответ менеджеру. Большая часть медленных запросов медленная не из-за объёма данных, а из-за формулировки. Тот же вопрос, заданный иначе, база отвечает в десятки раз быстрее. Ниже — приёмы, которые дают такой выигрыш, и у каждого — замер в PostgreSQL на трёх миллионах событий: сколько было, сколько стало и почему. В конце — чеклист перед тем, как отправить запрос на рабочую базу.

Коротко

  • Не оборачивайте столбец функцией в WHERE: event_time::date = … читает всю таблицу, диапазон по event_time идёт по индексу. В замере — 68 мс против 1,8 мс.
  • Проверку «есть ли у пользователя событие» пишите через EXISTS, а не JOIN с DISTINCT: 756 мс против 170 мс при том же ответе.
  • NOT IN (подзапрос) на большом подзапросе может работать часами. NOT EXISTS дал тот же ответ за четверть секунды.
  • UNION убирает дубли сортировкой. Если дублей нет или они нужны, UNION ALL вдвое быстрее.
  • Прежде чем ускорять, измерьте: EXPLAIN ANALYZE покажет, какой шаг занимает время.

Сначала измерьте

Ускорять наугад — значит переписывать то, что не тормозит. В PostgreSQL время запроса показывает команда \timing в psql, а разбивку по шагам — explain analyze. План отвечает на главный вопрос: какую таблицу база читает целиком (Seq Scan), где использует индекс и на каком шаге строк становится неожиданно много.

Помните, что explain analyze выполняет запрос по-настоящему. На рабочем сервере запускайте его на реплике или с ограничением по времени: set statement_timeout = '30s'; прервёт запрос, который пошёл не так. Как читать план узел за узлом — в отдельной статье про EXPLAIN ANALYZE.

На чём замеряли

Чтобы разница была видна, учебной базы мало: на 35 тысячах событий любой запрос выполняется за миллисекунды. Поэтому замеры сняты на синтетической копии в PostgreSQL 14: 3 миллиона событий за 92 дня, записанных в порядке времени, как в настоящем журнале событий, и 200 тысяч пользователей. Индекс есть по времени события и по id пользователя в справочнике. Настройки сервера — по умолчанию, в том числе work_mem 4 МБ.

Абсолютные миллисекунды на вашем сервере будут другими. Важно соотношение и то, какой узел плана поменялся.

Сводка замеров: PostgreSQL 14, 3 млн событий
ПриёмБылоСталоЧто изменилось в плане
диапазон вместо функции в WHERE68 мс1,8 мсSeq Scan → Index Only Scan
EXISTS вместо JOIN + DISTINCT756 мс170 мснет размножения строк и уникализации
NOT EXISTS вместо NOT INдольше 10 минут0,25 сподзапрос на каждую строку → Hash Anti Join
UNION ALL вместо UNION721 мс359 мсисчезли сортировка и удаление дублей
ORDER BY по индексу + LIMIT71 мс0,03 мссортировка всей таблицы → чтение индекса с конца

1. Не оборачивайте столбец функцией в WHERE

Условие where event_time::date = '2026-08-01' выглядит естественно, но индекс построен по event_time, а не по event_time::date. Базе приходится вычислить выражение для каждой из трёх миллионов строк. В замере — параллельное чтение всей таблицы за 67–69 мс.

То же условие диапазоном — от начала дня включительно до начала следующего не включительно — идёт по индексу и читает только строки нужного дня: 1,6–1,9 мс, почти в 40 раз быстрее. Правило общее: lower(email) = …, extract(month from …) = 8, amount * 100 > … выключают обычный индекс по столбцу. Переносите вычисление на сторону константы.

События 1 августа: диапазон, который использует индекс
select count(*) as events
from events
where event_time >= timestamp '2026-08-01 00:00'
  and event_time <  timestamp '2026-08-02 00:00';

2. Проверяйте наличие через EXISTS

Вопрос «сколько пользователей каждого канала были активны в августе» часто пишут через JOIN с событиями и count(distinct user_id). Соединение размножает пользователя на все его события, а потом DISTINCT схлопывает их обратно — база делает огромную работу, чтобы её же отменить. В замере 756 мс.

EXISTS отвечает ровно на заданный вопрос: есть ли хотя бы одно событие. База может остановиться на первом найденном и не держать копии строк. 170 мс, в 4,4 раза быстрее, и ответ совпал до единицы. На учебной базе этот же приём считает плательщиков августа по каналам.

Результат на учебной базе — тот же, что у варианта с JOIN и count(distinct)
channelpayers
organic283
referral198
partner90
paid_search71
Плательщики августа по каналам: EXISTS вместо JOIN и DISTINCT
select
  u.channel,
  count(*) as payers
from users u
where exists (
  select 1
  from payments p
  where p.user_id = u.user_id
    and p.paid_at >= date '2026-08-01'
)
group by u.channel
order by payers desc;

3. NOT IN с подзапросом — главная ловушка

Запрос «пользователи, которые ни разу не делали экспорт» через user_id not in (select user_id from events where event_name = 'export') в замере не закончился за десять минут — его пришлось отменить. Подзапрос вернул миллион строк, не поместился в память, отведённую под хеш-таблицу (work_mem), и PostgreSQL стал проверять его заново для каждого из 200 тысяч пользователей.

NOT EXISTS дал ответ за 240–248 мс: PostgreSQL выполнил его как Hash Anti Join — одно соединение вместо 200 тысяч повторов. С work_mem 256 МБ тот же NOT IN уложился в 497 мс, но на рабочем сервере память вам никто не поднимет. Вторая причина не писать NOT IN — NULL: если в подзапросе встретится хотя бы один NULL, NOT IN вернёт пустой результат.

Пользователи без платежей: NOT EXISTS вместо NOT IN
select count(*) as users_without_payments
from users u
where not exists (
  select 1
  from payments p
  where p.user_id = u.user_id
);

4. UNION ALL, когда дубли не мешают

UNION склеивает результаты и убирает повторяющиеся строки. Чтобы найти повторы, база сортирует или хеширует весь склеенный результат. В замере склейка двух выборок на полтора миллиона строк заняла 721 мс с UNION и 359 мс с UNION ALL.

Если части заведомо не пересекаются (события разных типов, разные месяцы) или дубли нужны для подсчёта, пишите UNION ALL. UNION оставьте для случаев, когда дубли действительно надо убрать — и тогда это осознанное решение, а не привычка.

5. ORDER BY с LIMIT по индексированному столбцу

«Последние 100 событий» через order by event_time desc limit 100 выполнились за 0,03 мс: база прочитала индекс с конца и остановилась на сотой строке. Тот же LIMIT с сортировкой по столбцу без индекса (order by event_name) занял 71 мс: чтобы найти первые сто, пришлось просмотреть все три миллиона строк.

LIMIT ускоряет запрос, только если базе не нужно сначала обработать всё. Сортировка по вычисляемому выражению, агрегат или DISTINCT перед LIMIT заставляют прочитать всю таблицу.

6. Читайте только нужное

select * в рабочем запросе тянет столбцы, которые не нужны, и мешает базе ответить из одного индекса. В колоночных хранилищах вроде ClickHouse каждый лишний столбец — это отдельный файл на диске, и разница между select * и тремя столбцами достигает порядков.

То же с периодом. Отладку запроса ведите на одном дне или неделе, а не на всей истории: ошибка в логике видна и на малом объёме. Если таблица разбита на секции по датам, условие по дате позволяет базе вообще не открывать лишние секции.

7. Агрегируйте до соединения, чтобы не размножить строки

Этот приём часто советуют как ускорение, но в нашем замере он был медленнее: агрегация трёх миллионов событий по пользователям до соединения заняла 535 мс против 282 мс у простого соединения с группировкой. Планировщик PostgreSQL неплохо справляется с соединением и сам.

Зачем тогда агрегировать заранее? Ради правильного ответа. Когда к пользователям присоединяют две таблицы «многие к одному» — платежи и события, — строки перемножаются, и сумма платежей вырастает во столько раз, сколько у пользователя событий. Свёртка каждой таблицы до одной строки на пользователя до соединения убирает это размножение. Подробнее — в статье о JOIN без потерь и дублей.

8. CTE не замедляют запрос — в PostgreSQL 12 и новее

До PostgreSQL 12 каждый шаг WITH выполнялся отдельно и целиком, и условие из внешнего запроса внутрь не проникало. Отсюда совет «не пишите CTE, пишите подзапросы», который до сих пор встречается в статьях. С 12-й версии PostgreSQL встраивает простой шаг WITH в основной запрос, как подзапрос, если на шаг ссылаются один раз. Старое поведение включается явно: with шаг as materialized (…).

Пишите CTE ради читаемости. Если шаг используется несколько раз и тяжёлый, materialized может, наоборот, ускорить запрос: результат посчитается один раз.

Чеклист перед запуском на рабочей базе

  • Запрос ограничен по периоду, а для отладки — ещё и по объёму.
  • В WHERE и JOIN нет функций над столбцами, по которым есть индекс.
  • Проверка наличия записана через EXISTS, отсутствия — через NOT EXISTS.
  • UNION оставлен только там, где дубли надо убрать.
  • Соединения не размножают строки: число строк после JOIN сверено с ожиданием.
  • Выбраны только нужные столбцы.
  • Для долгого запроса задан statement_timeout, и он идёт на реплику, а не на основной сервер.
  • Есть план запроса: понятно, какая таблица читается целиком и почему.

Частые вопросы

С чего начать оптимизацию медленного запроса?

С плана: explain analyze покажет шаг, который занимает больше всего времени, и сколько строк на нём. Ускорять нужно его.

Что быстрее — IN или EXISTS?

Для IN со списком констант разницы нет. Для подзапросов современный PostgreSQL часто строит одинаковый план. Существенная разница — у NOT IN и NOT EXISTS: NOT IN может деградировать на порядки и ломается на NULL.

Ускоряет ли индекс любой запрос?

Нет. Индекс помогает, когда запрос выбирает малую долю строк или читает их в порядке индекса. Если нужна треть таблицы, последовательное чтение быстрее.

Можно ли аналитику создавать индексы?

На рабочей базе — только по согласованию: индекс замедляет запись и занимает место. Обычно аналитик приходит к администратору с планом запроса и предложением, какой индекс поможет.

Почему запрос быстрый во второй раз?

Данные уже в памяти сервера. Замеряйте несколько запусков и сравнивайте устоявшееся время.

Логику запросов — EXISTS, соединения, оконные функции — удобно отработать на учебной базе в курсе «Продвинутый SQL», где каждое решение проверяется автоматически.

Продолжить чтение
Вся библиотека