SQL anti-patterns на собеседовании Data Engineer
LAG(price) OVER (PARTITION BY product_id), чтобы получить «вчерашнюю цену» товара по дням. Почему результат может оказаться неожиданным?Содержание:
Зачем разбирать на собесе
SQL-антипаттерны — самая частая причина, по которой запрос тормозит на продакшене. Синтаксически всё корректно, запрос отдаёт правильный результат, но на объёме в десятки миллионов строк он вместо миллисекунд работает секунды и роняет базу под нагрузкой.
Поэтому на собесе Data Engineer любят давать код и спрашивать: «что здесь плохо и как переписать». Проверяют не знание синтаксиса, а понимание того, что происходит под капотом — использует ли планировщик индекс, сколько данных летит по сети, сколько запросов уходит в базу. Ниже — пять антипаттернов, которые всплывают чаще всего, с объяснением механики и как их починить.
SELECT *
-- плохо
SELECT * FROM users WHERE id = 42;
-- хорошо
SELECT id, name, email FROM users WHERE id = 42;SELECT * кажется безобидным, но тянет за собой три проблемы:
- Лишний трафик по сети. Вы вытаскиваете все колонки, включая тяжёлые (
bio,settings jsonb, blob-поля), даже если приложению нужны только три из них. На больших выборках это заметная нагрузка на сеть и память. - Хрупкость при изменении схемы. Добавили в таблицу колонку — и запрос молча начинает возвращать её тоже. Код, который распаковывает результат по позициям или ждёт фиксированный набор полей, ломается.
- Теряется Index Only Scan. Если есть покрывающий индекс по
(id, name, email), база могла бы ответить прямо из индекса, не заглядывая в таблицу. СSELECT *это невозможно — придётся идти в heap за остальными колонками.
На собесе стоит проговорить именно причину: дело не в «плохом стиле», а в конкретных издержках — сеть, стабильность, потеря index-only доступа.
Функция на индексированной колонке
-- плохо: обычный индекс по email не используется
WHERE LOWER(email) = 'a@b.com'
-- хорошо: функциональный индекс под тот же предикат
CREATE INDEX idx_users_lower_email ON users (LOWER(email));
WHERE LOWER(email) = 'a@b.com'Обычный B-tree индекс хранит значения колонки как есть. Как только вы оборачиваете колонку функцией (LOWER, CAST, арифметика), планировщик видит не email, а «результат функции от email» — и по обычному индексу искать уже не может, приходится делать полный скан. Чинится либо функциональным индексом ровно под этот предикат, либо нормализацией данных при вставке (хранить email уже в нижнем регистре).
Тот же антипаттерн с датами — очень частый:
-- плохо: date() убивает индекс по created_at
WHERE DATE(created_at) = '2026-05-07'
-- хорошо: диапазон по «сырой» колонке, индекс работает
WHERE created_at >= '2026-05-07' AND created_at < '2026-05-08'Диапазон по нетронутой колонке позволяет планировщику пройтись по индексу, а не считать date() для каждой строки таблицы.
Неявные приведения типов
-- плохо: id имеет тип INT, а сравнивается со строкой → неявное приведение
WHERE id = '42'
-- хорошо: сравнение одного типа
WHERE id = 42Когда типы двух сторон сравнения не совпадают, база достраивает приведение сама. Проблема в том, куда именно она вставляет CAST. Часто движок приводит колонку к типу литерала, а не наоборот — и это тот же случай «функция на колонке»: индекс перестаёт использоваться. Особенно болезненно в MySQL, где неявное приведение строки к числу легко ломает использование индекса и заодно даёт неожиданные результаты сравнения. Правило простое: типы обеих сторон предиката должны совпадать, а параметры лучше передавать уже правильного типа.
N+1 запросы
Это антипаттерн уровня приложения, а не одного SQL — но на собесе DE его спрашивают постоянно.
users = db.query("SELECT id, name FROM users") # 1 запрос
for user in users:
# ещё по одному запросу на каждого пользователя
orders = db.query("SELECT * FROM orders WHERE user_id = ?", user.id) # N запросовВместо одного запроса приложение делает 1 + N: сначала достаёт список, потом в цикле дёргает базу на каждый элемент. На 10 пользователях незаметно, на 10 000 — тысячи round-trip'ов к базе, и всё время уходит на сетевые задержки, а не на саму работу.
Чинится тем, что данные забираются одним запросом — через JOIN:
SELECT u.id, u.name, o.id AS order_id, o.amount
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;Либо, если JOIN раздувает результат дублями, — двумя запросами: сначала список id, потом один запрос WHERE user_id IN (...) и группировка на стороне приложения. Ключевая мысль для собеса: N+1 не виден в плане одного запроса, его нужно ловить по количеству обращений к базе.
OR против UNION
-- иногда плохо: OR по разным колонкам
SELECT * FROM t WHERE x = 1 OR y = 2;
-- часто быстрее для планировщика
SELECT * FROM t WHERE x = 1
UNION
SELECT * FROM t WHERE y = 2;OR по двум разным колонкам мешает планировщику: он не может одновременно опереться на индекс по x и индекс по y, поэтому нередко скатывается в полный скан таблицы. Перепись через UNION разбивает условие на два независимых запроса, каждый из которых берёт свой индекс, а потом результаты объединяются. UNION (без ALL) ещё и убирает дубли строк, попавших под оба условия; если дубли не важны или невозможны — берите UNION ALL, он дешевле.
Оговорка для честного ответа на собесе: это не универсальное правило. На маленьких таблицах или когда OR идёт по одной колонке (x = 1 OR x = 2, что эквивалентно IN) переписывать не нужно. Всегда смотрите EXPLAIN — он покажет, действительно ли планировщик ушёл в full scan.
Связанные темы
- EXPLAIN и план запроса для DE
- Query optimization для DE
- Индексы БД для DE
- Anti и semi joins для DE
- Подготовка к собесу Data Engineer
FAQ
Почему SELECT * плохо, если запрос всё равно возвращает нужное?
Он возвращает и всё лишнее: тяжёлые колонки грузят сеть и память, добавление колонки в схему может сломать код, а покрывающий индекс перестаёт спасать от похода в таблицу. Явный список колонок убирает все три проблемы.
Как понять, что запрос не использует индекс?
Через EXPLAIN (или EXPLAIN ANALYZE): если в плане видно Seq Scan / full table scan там, где вы ждали Index Scan, — индекс не подхватился. Частые причины: функция или приведение типа на колонке, OR по разным колонкам, слишком неселективный предикат.
Всегда ли OR надо переписывать на UNION?
Нет. Это помогает, когда OR идёт по разным колонкам и мешает использовать индексы. Для OR по одной колонке это по сути IN, а на маленьких таблицах разница незаметна. Решать нужно по EXPLAIN, а не по правилу «OR всегда плохо».
N+1 — это же проблема ORM, при чём тут Data Engineer?
Data Engineer постоянно видит N+1 в пайплайнах и в коде, который дёргает базу в цикле по строкам. Умение распознать паттерн (много одинаковых мелких запросов вместо одного пакетного) и переписать его на JOIN или IN — базовый навык оптимизации, поэтому его и спрашивают.
Это официальная информация?
Нет. Статья основана на документации СУБД и индустриальном опыте оптимизации запросов. Конкретное поведение зависит от движка (PostgreSQL, MySQL, ClickHouse) и версии — всегда проверяйте планом запроса.
Тренируйте Data Engineering — откройте тренажёр с 1500+ вопросами для собесов.