SQL anti-patterns на собеседовании Data Engineer

Проверь себя · 1/3разбор после ответа
Вы пишете 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, где неявное приведение строки к числу легко ломает использование индекса и заодно даёт неожиданные результаты сравнения. Правило простое: типы обеих сторон предиката должны совпадать, а параметры лучше передавать уже правильного типа.

Готовишься к собесу Data Engineer?
Spark, Airflow, ClickHouse, SQL для DE — вопросы с разборами в Telegram
Тренировать DE в Telegram

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.

Связанные темы

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+ вопросами для собесов.