Оптимизация запросов на собеседовании Data Engineer
middle_name, в которой часто хранится NULL. Что вернёт выражение COUNT(middle_name)?Содержание:
Зачем оптимизировать запросы
Медленный запрос — одна из самых частых болей в работе DE: отчёт, который считается полчаса, дашборд, который таймаутит, ночной ETL, который не успевает до утра. Умение находить и чинить такие запросы — базовый навык, который проверяют почти на каждом собесе.
Важно понимать: оптимизация начинается не с интуиции, а с плана выполнения. Прежде чем что-то менять, смотрят EXPLAIN (а лучше EXPLAIN ANALYZE) и находят самый дорогой шаг — full scan большой таблицы, вложенный цикл по миллионам строк, сортировку, не влезающую в память. Дальше действуют по ситуации: обновить статистику, переписать запрос, добавить индекс или предпосчитать данные. На собесе ценят именно этот системный подход, а не заученный список приёмов.
Статистика и ANALYZE
Планировщик в реляционных БД — cost-based: он выбирает план по оценке стоимости, а стоимость считает по статистике таблицы. Оптимизатор опирается на:
- Число строк в таблице — от него зависит, дешевле ли пройти по индексу или просто прочитать всё.
- Число уникальных значений в колонке (кардинальность) — влияет на оценку селективности фильтров и джоинов.
- Гистограммы распределения — чтобы оценить, сколько строк попадёт под условие вроде
WHERE amount > 1000. - Наиболее частые значения (most common values) — для перекошенных распределений.
- Корреляцию колонки с физическим порядком строк — насколько выгоден index scan.
Проблема в том, что после массовых INSERT/UPDATE/DELETE статистика устаревает, и планировщик начинает выбирать плохие планы по неверным оценкам. Тогда её пересобирают вручную:
ANALYZE my_table; -- обновить статистику по всей таблице
ANALYZE VERBOSE my_table (col1, col2); -- только по нужным колонкам, с выводомВ PostgreSQL за этим обычно следит autovacuum и делает ANALYZE периодически, но после крупной заливки данных статистику стоит обновить руками — не дожидаясь автоматики.
Переписывание запросов
Часто самый быстрый выигрыш — переписать запрос так, чтобы он делал меньше работы. Классические приёмы.
EXISTS вместо JOIN с DISTINCT. Когда нужно проверить факт наличия связанных строк, а не вытащить их, JOIN размножает строки, и приходится схлопывать их DISTINCT. EXISTS останавливается на первом совпадении и не плодит дубликаты.
-- плохо: джоин размножает строки, DISTINCT потом их схлопывает
SELECT DISTINCT u.* FROM users u JOIN orders o ON o.user_id = u.id;
-- хорошо: EXISTS проверяет наличие и останавливается на первом совпадении
SELECT u.* FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);Оконные функции вместо self-join. Обращение к соседней строке через джоин таблицы саму на себя дорого и хрупко; оконная функция делает то же за один проход.
-- плохо: self-join, чтобы достать предыдущее значение
SELECT a.id, a.value, b.value AS prev
FROM events a LEFT JOIN events b ON b.id = a.id - 1;
-- хорошо: LAG за один проход по таблице
SELECT id, value, LAG(value) OVER (ORDER BY id) AS prev FROM events;Ранняя фильтрация (predicate pushdown). Чем раньше отсечь лишние строки, тем меньше данных протащит запрос через джоины и сортировки. Фильтр стоит ставить внутри CTE/подзапроса, а не поверх готового результата.
-- плохо: сначала прочитали все заказы, потом фильтруем
WITH all_orders AS (SELECT * FROM orders) ...
-- хорошо: фильтр внутри CTE, читаем только нужное
WITH recent AS (SELECT * FROM orders WHERE created_at > '2026-01-01') ...Не оборачивать индексированную колонку функцией в WHERE. Функция над колонкой (LOWER, DATE, приведение типа) мешает использовать обычный индекс — БД вынуждена вычислить функцию для каждой строки. Решение — функциональный (expression) индекс.
-- плохо: обычный индекс по email не сработает
WHERE LOWER(email) = 'a@b'
-- хорошо: функциональный индекс под ровно это выражение
CREATE INDEX idx_email_lower ON users(LOWER(email));Индексы и хинты
Правильный индекс — самый мощный рычаг: он превращает full scan в точечный доступ. Но индекс помогает выборкам и замедляет записи (каждый INSERT/UPDATE его обновляет), поэтому их не вешают на всё подряд. Для составных индексов важен порядок колонок и правило leftmost prefix — подробности в отдельном гайде по индексам ниже.
С «хинтами» (принудительным указанием плана) аккуратнее. В PostgreSQL прямых хинтов нет; есть отладочные флаги вроде SET enable_seqscan = off, но это инструмент диагностики, а не решение — на проде так не чинят.
-- только для диагностики в сессии: заставить планировщик избегать seq scan
SET enable_seqscan = off;В MySQL и Oracle физические хинты есть (USE INDEX, /*+ ... */), но их считают дурным тоном: они прибивают план гвоздями, и при изменении данных он перестаёт быть оптимальным. Правильнее чинить причину — обновить статистику, добавить нужный индекс или переписать запрос, чтобы планировщик сам выбрал верный путь.
Materialized views
Если тяжёлый запрос повторяется часто, а данные меняются не каждую секунду, результат имеет смысл предпосчитать. Materialized view хранит уже посчитанный результат на диске, и чтение из него — это чтение готовой таблицы.
CREATE MATERIALIZED VIEW daily_stats AS
SELECT day, country, COUNT(*) AS orders
FROM orders GROUP BY 1, 2;
REFRESH MATERIALIZED VIEW daily_stats; -- пересчитать по расписаниюЧтение из materialized view быстрое, но данные в нём ровно настолько свежие, насколько давно был REFRESH. Частоту обновления (раз в час, раз в день) выбирают по требованиям к актуальности. В PostgreSQL есть REFRESH ... CONCURRENTLY, чтобы не блокировать чтения во время пересчёта. В ClickHouse materialized view устроены иначе — они инкрементально досчитываются при вставке новых данных, а не пересобираются целиком.
Как это спрашивают на собесе
Формулировки, которые встречаются почти всегда:
- «Запрос стал медленным — что делаете первым?» Ожидаемый ответ: смотрю
EXPLAIN ANALYZE, нахожу самый дорогой узел (seq scan, nested loop, сортировку на диск), и уже от него решаю — индекс, переписать или статистика. - «Почему не используется индекс?» Частые причины: функция над колонкой в
WHERE, приведение типов, устаревшая статистика, низкая селективность (планировщик решил, что seq scan дешевле), или порядок колонок в составном индексе. - «В чём разница между view и materialized view?» Обычный view — это сохранённый запрос, он выполняется заново при каждом обращении. Materialized view хранит предпосчитанный результат, который надо освежать.
- «Как ускорить
COUNT(*)по огромной таблице?» Точный count всё равно требует прохода; варианты — приблизительная оценка из статистики, счётчик, поддерживаемый триггером, или предпосчёт в materialized view.
Интервьюер смотрит не на знание конкретной команды, а на диагностический ход мысли: сначала измерить, потом чинить.
Частые ошибки
Оптимизировать вслепую, без EXPLAIN. Добавлять индексы и переписывать запрос наугад — почти всегда потеря времени. Сначала план, потом изменения.
Вешать индексы на всё подряд. Лишние индексы замедляют записи и раздувают таблицу, а планировщик их всё равно может не использовать. Индекс должен закрывать реальный частый паттерн запросов.
Забывать про устаревшую статистику. После массовой заливки данных план может «поплыть» на ровном месте. ANALYZE часто чинит проблему быстрее любых переписываний.
Функции над колонкой в WHERE. WHERE DATE(created_at) = '2026-01-01' убивает индекс по created_at. Лучше WHERE created_at >= '2026-01-01' AND created_at < '2026-01-02' или функциональный индекс.
Костыли-хинты вместо причины. enable_seqscan = off или USE INDEX маскируют симптом. На проде проблему решают статистикой, индексом или переписыванием запроса.
Связанные темы
- EXPLAIN и план запроса для DE
- Индексы БД для DE
- Партиционирование таблиц для DE
- SCD типы для DE
- Подготовка к собесу Data Engineer
FAQ
С чего начинать оптимизацию запроса?
С EXPLAIN ANALYZE. Он показывает фактический план и время каждого шага. Находите самый дорогой узел — full scan, nested loop по большой таблице, сортировку на диске — и работаете именно с ним. Оптимизировать без плана — гадание.
Почему запрос игнорирует индекс?
Частые причины: колонка обёрнута функцией или приведением типа в WHERE; статистика устарела, и планировщик неверно оценил число строк; фильтр малоселективен, и seq scan реально дешевле; неверный порядок колонок в составном индексе. Смотреть надо EXPLAIN и оценки строк в нём.
Чем view отличается от materialized view?
Обычный view — это сохранённый текст запроса: при каждом обращении он выполняется заново и всегда возвращает свежие данные. Materialized view хранит предпосчитанный результат на диске: читается быстро, но данные актуальны только на момент последнего REFRESH.
Когда стоит делать materialized view?
Когда тяжёлый агрегирующий запрос выполняется часто, а требования к свежести позволяют небольшой лаг (минуты-часы). Тогда предпосчёт разгружает базу. Если данные нужны строго в реальном времени или запрос выполняется редко — овчинка выделки не стоит.
Это официальная информация?
Нет. Статья основана на стандартных подходах к оптимизации SQL (PostgreSQL, MySQL, ClickHouse) и типичных вопросах собеседований. Детали зависят от конкретной СУБД и версии.
Тренируйте Data Engineering — откройте тренажёр с 1500+ вопросами для собесов.