Оптимизация SQL-запросов: шпаргалка
Зачем аналитику оптимизация
На позиции middle+ задача «этот запрос работает 10 минут, ускорь» — типична. Оптимизация — это не магия, а 5-6 повторяющихся приёмов.
Шаг 1. EXPLAIN — диагностика
Любая оптимизация начинается с EXPLAIN ANALYZE:
EXPLAIN ANALYZE SELECT ... ;Смотрим план:
- Seq Scan на большой таблице → нужен индекс.
- Nested Loop на 1M × 1M строк → скорее всего нужен Hash Join.
- Sort Method: external merge Disk → сортировка ушла на диск, нужен индекс или больше памяти.
Шаг 2. Индексы
Когда нужен
- WHERE на большом поле с высокой кардинальностью (user_id, email, order_id).
- JOIN по полю без PK.
- ORDER BY, который не вписывается в память.
CREATE INDEX idx_orders_user ON orders(user_id);
CREATE INDEX idx_orders_date ON orders(created_at);
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);
Композитные индексы
Порядок столбцов важен. Индекс (user_id, created_at):
- ✅ Работает:
WHERE user_id = 1 AND created_at > '2026-01-01' - ✅ Работает:
WHERE user_id = 1 - ❌ НЕ работает:
WHERE created_at > '2026-01-01'(нет user_id)
Шаг 3. Фильтруйте рано
WHERE до JOIN
-- ❌ Медленно — JOIN всех, потом фильтр
SELECT * FROM orders o
JOIN users u USING (user_id)
WHERE u.country = 'RU';
-- ✅ Быстрее — фильтр до JOIN
SELECT * FROM orders o
JOIN (SELECT * FROM users WHERE country = 'RU') u USING (user_id);PostgreSQL обычно умеет это сам, но в сложных запросах иногда нет.
WHERE + HAVING
-- ❌ HAVING фильтрует после GROUP BY
SELECT city, COUNT(*) FROM users
GROUP BY city
HAVING city = 'Moscow';
-- ✅ WHERE фильтрует до
SELECT city, COUNT(*) FROM users
WHERE city = 'Moscow'
GROUP BY city;Шаг 4. Избегайте функций на индексированных столбцах
-- ❌ Индекс на email не используется
WHERE LOWER(email) = 'x@y.com'
-- ✅ Индекс работает
WHERE email = 'x@y.com'
-- Или создать функциональный индекс:
CREATE INDEX idx_email_lower ON users (LOWER(email));Прокачать тему на реальных задачах удобно в боте @kariernik_bot — база вопросов собрана с собеседований в Яндексе, Авито, Ozon, Тинькофф.
Шаг 5. Замените коррелированные подзапросы на JOIN / оконные
-- ❌ Медленно — N подзапросов для N строк
SELECT o.*,
(SELECT COUNT(*) FROM orders o2 WHERE o2.user_id = o.user_id) AS user_count
FROM orders o;
-- ✅ Быстрее — один проход
SELECT o.*,
COUNT(*) OVER (PARTITION BY user_id) AS user_count
FROM orders o;Шаг 6. LIMIT + ORDER BY с индексом
-- Если есть индекс на created_at — моментальный top-10
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10;Без индекса — sort всей таблицы → медленно.
Шаг 7. EXISTS vs IN (с NULL)
-- NOT IN + NULL → 0 строк (багi)
WHERE id NOT IN (SELECT x FROM t) -- если x = NULL — ничего не вернётся
-- ✅ NOT EXISTS — безопасно и обычно быстрее
WHERE NOT EXISTS (SELECT 1 FROM t WHERE t.x = id)Шаг 8. Партиционирование таблицы
Для очень больших таблиц (10M+ строк) — делить на физические партиции по дате:
CREATE TABLE orders (
id SERIAL, created_at TIMESTAMP, amount NUMERIC
) PARTITION BY RANGE (created_at);
CREATE TABLE orders_2026_q1 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');Запросы с WHERE created_at >= '2026-01-01' читают только нужные партиции.
Шаг 9. Materialized view для тяжёлых агрегатов
Если агрегат считается каждые 5 минут, но данные меняются раз в день — materialized view + daily refresh.
CREATE MATERIALIZED VIEW daily_stats AS
SELECT DATE_TRUNC('day', created_at) AS day, SUM(amount) AS rev
FROM orders GROUP BY 1;
-- Обновлять через cron
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_stats;
Шаг 10. Уменьшите объём данных
-- ❌ Читает всё
SELECT * FROM big_table;
-- ✅ Только нужные столбцы
SELECT id, name FROM big_table;
-- ✅ Нужный период
SELECT * FROM big_table WHERE created_at >= '2026-01-01';
-- ✅ С LIMIT для предпросмотра
SELECT * FROM big_table LIMIT 100;На собесе такие штуки часто спрашивают. Быстрый способ довести до автоматизма — тренажёр в Telegram с задачами из реальных интервью.
Типичные антипаттерны
1. SELECT * на большой таблице
Читает все столбцы, даже если нужны 2. Особенно плохо на wide tables с TEXT/JSON полями.
2. OR на разных столбцах
-- Плохо — может не использовать индексы
WHERE a = 1 OR b = 2
-- Лучше — UNION ALL двух запросов
SELECT * FROM t WHERE a = 1
UNION ALL
SELECT * FROM t WHERE b = 2 AND a != 1;3. DISTINCT для удаления дубликатов после JOIN
Дубликаты после JOIN — часто признак m:n связи без агрегации. Вместо DISTINCT — используйте DISTINCT ON или сначала агрегируйте.
4. Сортировка в подзапросе
-- ❌ Бессмысленно — результат снова сортируется
SELECT * FROM (SELECT * FROM t ORDER BY x) t2 ORDER BY y;5. Использование скалярной функции в WHERE в циклу
Каждая итерация — вызов функции. Лучше материализовать результат.
Как читать EXPLAIN (быстро)
Смотреть сверху вниз, плохие знаки:
Seq Scan on— полный скан.Nested Loop (actual rows=1000000)— N×M где N велико.Sort Method: external merge— сортировка на диске.rows=100 … actual rows=1000000— плохие статистики.
Хорошие знаки:
Index Scan using idx_...Index Only ScanHash Joinдля больших таблиц.estimated rows ≈ actual rows.
10 задач на оптимизацию
1. Запрос работает 30 сек, возвращает 10 строк. Что делать?
EXPLAIN ANALYZE → искать Seq Scan / Nested Loop на большом. Добавить индекс.
2. SELECT * FROM big_table WHERE LOWER(email) = 'x' медленный
Использовать WHERE email = 'x' (если регистр фиксирован) или создать функциональный индекс.
3. COUNT(*) на 100M строк
Приближённый из pg_stats (SELECT reltuples FROM pg_class). Точный невозможно быстро.
4. JOIN 5 таблиц работает 5 минут
Проверить порядок JOIN через SET join_collapse_limit = 1. Разбить на CTE с intermediate tables.
5. GROUP BY по 10M строк
Предварительная материализация в таблицу или использование columnstore (ClickHouse).
6. Подзапрос в SELECT тормозит
Заменить на оконную функцию (SUM OVER, COUNT OVER).
7. ORDER BY + LIMIT на большой таблице
Создать индекс на ORDER BY столбцы.
8. DISTINCT на 100M строк
Редко нужен точный — попробуйте GROUP BY + индекс. Или approximate count в BigQuery/ClickHouse.
9. Запрос возвращает дубликаты
Проверить кардинальность JOIN. Обычно забыли, что одна таблица имеет n записей на ключ.
10. CTE тормозит в PostgreSQL 11
В PG ≤12 CTE материализуется всегда. Используйте WITH … AS NOT MATERIALIZED или инлайн подзапрос.
Как тренироваться
Оптимизация SQL — практика. Запустите EXPLAIN на каждом запросе, попробуйте 2-3 варианта, сравнивайте времена.
Совет: на собесе по оптимизации первое, что нужно сказать — «посмотрю EXPLAIN». Без плана запроса любая «оптимизация» — гадание.
Читайте также
- EXPLAIN SQL шпаргалка
- Индексы SQL шпаргалка
- Оптимизация SQL-запросов гайд
- SQL антипаттерны
- ClickHouse vs PostgreSQL
FAQ
Всегда ли индекс ускоряет?
Нет. На маленьких таблицах индекс не нужен — seq scan быстрее. На записях (INSERT/UPDATE) индексы замедляют — нужно обновлять индекс.
Что быстрее — JOIN или EXISTS?
Зависит от оптимизатора и данных. В современных СУБД обычно одинаково. EXISTS безопаснее при NULL. JOIN удобнее для получения колонок из правой таблицы.
PostgreSQL или ClickHouse для аналитики?
Postgres для OLTP + лёгкой аналитики. ClickHouse для тяжёлой аналитики (агрегаты на миллиардах). Подробнее.
Кэширование — это оптимизация?
Да, но на уровне приложения, не SQL. СУБД сама кэширует пахоты в памяти. Redis/Memcached — если запрос вызывается 100+ раз в секунду с одинаковыми параметрами.