Ты наверняка не раз сталкивался с этой типичной картиной: на локальной машине с тестовой базой в сотню записей запрос «летает» за полторы миллисекунды. Разработчик радостно отправляет код на прод, но как только объем таблицы переваливает за пару миллионов строк, сервис внезапно захлебывается в тайм-аутах, а процессор базы данных упирается в 100%.
Я сам на заре карьеры не раз попадался в эту ловушку. Коварство языка SQL в том, что он декларативен: ты описываешь, что хочешь получить, но планировщик СУБД (Cost-Based Optimizer) сам решает, как физически извлекать данные. Мы уже разбирали, почему синтаксис запроса обманчив, когда говорили про порядок выполнения фаз SQL. Давай систематизируем 10 ключевых правил, которые помогут писать запросы быстро и осознанно читать вывод планов исполнения.
Правило саргабельности: не прячь колонки в функции
Главный грех медленного SQL — несаргабельные предикаты (Non-SARGable, Search Argument Able). Если ты оборачиваешь индексированную колонку в функцию вроде YEAR(created_at) = 2026 или DATE(created_at) = '2026-10-14', планировщик бессилен использовать обычный B-Tree индекс. Движку приходится запускать полное последовательное сканирование (Sequential Scan), вычисляя функцию для каждой строки таблицы.
Смотри, как вернуть условию саргабельность:
-- ПЛОХО: функция отключает B-Tree индекс, провоцируя Seq Scan
SELECT id, email, created_at
FROM users
WHERE YEAR(created_at) = 2026;
-- ХОРОШО: поиск по строгому диапазону значений подключает Index Scan
SELECT id, email, created_at
FROM users
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';
-- ХОРОШО: замена IN на EXISTS для полусоединений (semi-join)
SELECT c.id, c.name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id AND o.status = 'paid'
);
Конструкция EXISTS останавливает сканирование подзапроса на первом же совпадении, тогда как подзапрос с IN часто строит полный список значений в памяти и некорректно ведет себя при наличии NULL.
Девять прикладных ориентиров для схемы и запросов
- Точная проекция колонок: забудь про
SELECT *в продакшен-коде. Извлечение только нужных колонок бережет оперативную память буферного пула и сетевой стек, а также позволяет СУБД задействовать режим Index Only Scan, о котором мы говорили в разборе архитектуры и индексов PostgreSQL. - Селективные B-Tree индексы: стройте индексы под регулярные предикаты
WHERE, внешние ключиJOINи сортировкуORDER BY. - Ловушка LIMIT / OFFSET: команда
OFFSET 1000000 LIMIT 10заставляет базу прочитать и отбросить миллион строк. Для глубокой пагинации используйте курсорный поиск по ключу (WHERE id > :last_id). - Оценка кардинальности связей: случайное объединение «многие ко многим» в
JOINприводит к декартову произведению и взрывному размножению строк. - Ранняя фильтрация: отсекайте невалидные данные в блоке
WHEREдо того, как они попадут в тяжелые оконные функции или агрегацииGROUP BY. - Осторожно с DISTINCT: если в отчете появились дубликаты, ищите ошибку в связях
JOIN, а не глушите симптом тяжелымDISTINCT, требующим дорогой сортировки или хеширования всего набора. - UNION ALL вместо UNION: если бизнес-логика гарантирует, что выборки не пересекаются,
UNION ALLобъединяет потоки мгновенно, не тратя процессор на дедупликацию. - Контроль селективности: если запрос выбирает более 15–20% строк всей таблицы, планировщик PostgreSQL сознательно выберет Seq Scan, так как последовательное чтение блоков с упреждающей выборкой аппаратно быстрее сотен тысяч случайных прыжков по индексу.
- Актуализация статистики: если расчетные оценки планировщика расходятся с реальностью в разы, выполните регламентную команду
ANALYZE.
Читаем план выполнения через EXPLAIN (ANALYZE, BUFFERS)
Чтобы не гадать на кофейной гуще, загляни внутрь плана выполнения. В PostgreSQL золотым стандартом является команда EXPLAIN (ANALYZE, BUFFERS). Она показывает не просто гипотетическую стоимость, а реальное время выполнения узлов и работу с памятью: shared hit (блоки, прочитанные из кэша RAM) и shared read (чтения с диска).
-- Безопасный запуск профилирования мутирующего запроса в транзакции
BEGIN;
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
DELETE FROM audit_logs
WHERE created_at < '2025-01-01';
-- Обязательный откат, чтобы тестовое профилирование не удалило данные!
ROLLBACK;
Никогда не забывай: приставка ANALYZE заставляет базу данных реально выполнить запрос на сервере! Если ты запустишь EXPLAIN ANALYZE DELETE ... без транзакционного блока BEGIN ... ROLLBACK, все строки, подходящие под условие, будут безвозвратно удалены из продуктовой базы.
Оптимизация баз данных — это не магия, а аккуратная инженерная гигиена. Следи за саргабельностью условий, не заставляй сервер таскать лишние байты, и твоя база данных будет работать предсказуемо и стабильно.
