Как читать чужой SQL запрос: пошаговый разбор
Вам достался запрос на триста строк без комментариев, и надо понять, что он считает. Разберём по шагам: где вход и выход, как читать слои и соединения, как найти ошибку и переписать код так, чтобы его понял следующий человек.
- С чего начать разбор: вход, выход, фильтры и период
- Порядок выполнения не совпадает с порядком написания
- Слои разбора: CTE, подзапросы и временные таблицы
- Соединения: что с чем и по какому ключу
- Агрегация: где считается метрика и в какой гранулярности
- Оконные функции: как читать OVER
- Как найти ошибку методом деления
- Читаемее и быстрее: рефакторинг и план запроса
С чего начать разбор: вход, выход, фильтры и период
Первый шаг — не читать код сверху вниз, а найти вход и выход. Вход — это таблицы и источники в FROM и JOIN: выпишите их и отметьте, какие из них большие, а какие справочники. Выход — последний SELECT: какие колонки идут в результат и по какой гранулярности — одна строка на заказ, на клиента или на день.
Дальше ищите фильтры и период. Самый важный фильтр часто спрятан в глубоком подзапросе или в условии соединения. Найдите все условия по датам и убедитесь, что период одинаковый у всех частей запроса: типичная ошибка — где-то год фильтруется, а где-то нет.
После этого сформулируйте смысл запроса одной фразой: «считает выручку по клиентам за месяц, кроме возвратов». Если фраза не складывается, вы ещё не поняли запрос, и переписывать рано.
- шаг: выпишите таблицы из FROM и JOIN и отметьте размер каждой
- шаг: найдите все фильтры по датам и сверьте период
Порядок выполнения не совпадает с порядком написания
SQL пишут в одном порядке, а выполняют в другом. Сервер сначала берёт таблицы (FROM, JOIN), затем фильтрует строки (WHERE), потом группирует (GROUP BY), фильтрует группы (HAVING), считает выражения в SELECT и только потом сортирует (ORDER BY) и ограничивает (LIMIT).
Отсюда практические следствия. Псевдоним из SELECT не работает в WHERE, но работает в ORDER BY. Условие в WHERE отбрасывает строки до агрегации, поэтому фильтр по результату агрегата уходит в HAVING или во внешний запрос.
Оконные функции считаются после WHERE и GROUP BY, но до ORDER BY и LIMIT. Значит, отфильтровать результат ROW_NUMBER() в том же уровне нельзя — нужен внешний запрос.
- шаг: читайте запрос в порядке выполнения, а не в порядке текста
- шаг: проверьте, не спрятан ли фильтр по агрегату в WHERE
Слои разбора: CTE, подзапросы и временные таблицы
Разбирайте запрос по слоям. Сначала верхний уровень, потом каждый подзапрос отдельно, как отдельную маленькую задачу. Полезно заменить подзапрос чёрным ящиком: понять, какие колонки он получает и что отдаёт, и не лезть внутрь, пока не понадобится.
Двигаться можно двумя способами. Сверху вниз — от результата к источникам: так быстрее понять, зачем нужен каждый блок. Снизу вверх — от таблиц к результату: так виднее, где теряются или размножаются строки. На практике удобно начать сверху, а спорный блок раскручивать снизу.
CTE (конструкция WITH) читаются легче вложенных подзапросов: у каждого блока есть имя, код идёт линейно, и любой шаг можно запустить отдельно. Но у CTE есть цена: в части баз они материализуются, и тогда запрос работает медленнее, чем с тем же подзапросом внутри. Временные таблицы выручают, когда промежуточный результат нужен несколько раз и он тяжёлый.
- шаг: нарисуйте схему блоков и подпишите, что каждый отдаёт
- шаг: каждый подзапрос запускайте отдельно и смотрите число строк
Соединения: что с чем и по какому ключу
Для каждого JOIN ответьте на три вопроса: что соединяется, по какому ключу и сколько строк ожидается на выходе. Ключ должен совпадать по смыслу и по гранулярности с обеих сторон. Классическая ошибка — соединить заказы с клиентами не по id клиента, а по городу или email: строк станет больше, а суммы вырастут.
Отдельно проверьте LEFT JOIN. Если по правой таблице в WHERE стоит условие, оно превратит левое соединение во внутреннее: строки без совпадения отбросятся. Такое условие переносят в ON, а если фильтр нужен по результату — считают его во внешнем запросе.
Чтобы поймать размножение строк, сравните количество строк до и после каждого соединения. Если до JOIN было 1000 строк, а после 3400, ключ не уникален с одной из сторон. Быстрая проверка: SELECT COUNT(*), COUNT(DISTINCT ключ) по каждой таблице и сравнение чисел.
- шаг: для каждого JOIN выпишите ключ и проверьте уникальность
- шаг: сравните число строк до и после соединения
Агрегация: где считается метрика и в какой гранулярности
Найдите, где считается метрика, и разложите её на числитель и знаменатель. COUNT(*) считает строки, COUNT(DISTINCT user_id) — уникальных пользователей, SUM(amount) — сумму. Путаница между ними — самая частая причина расхождения с отчётом.
Вторая ловушка — гранулярность. Если соединить заказы с позициями, средний чек начнёт считаться по строкам позиций, а не по заказам: один заказ с тремя товарами попадёт в среднее трижды. Лечится это агрегацией до соединения либо отдельным CTE с уникальными заказами.
Проверьте GROUP BY: все неагрегированные колонки из SELECT должны быть в группировке, иначе результат станет непредсказуемым. Заодно уберите из группировки лишние колонки — они дробят метрику сильнее, чем нужно.
- шаг: распишите метрику формулой — числитель и знаменатель
- шаг: сверьте колонки в GROUP BY и SELECT
Оконные функции: как читать OVER
OVER (PARTITION BY user_id ORDER BY created_at DESC) читается по частям: PARTITION BY задаёт группы, внутри которых считается функция, ORDER BY — порядок строк внутри группы. Без PARTITION BY окно охватывает весь результат запроса.
ROW_NUMBER() выдаёт уникальные номера 1, 2, 3 даже при одинаковых значениях сортировки. RANK() при равенстве даёт одинаковый номер и пропускает следующий (1, 1, 3), а DENSE_RANK() не пропускает (1, 1, 2). Нужен ровно один заказ на клиента — берите ROW_NUMBER, нужны все заказы с максимальной суммой — RANK.
Отфильтровать результат окна в том же уровне нельзя, поэтому логику кладут во внешний шаг: сначала нумеруем строки, потом оставляем только rn = 1. Ищите этот двухшаговый приём в чужом коде — он часто спрятан в самом низу и меняет смысл всего запроса.
- шаг: разберите OVER на PARTITION BY и ORDER BY
- шаг: фильтрацию по номеру строки вынесите во внешний запрос
Как найти ошибку методом деления
Метод деления простой: считайте промежуточный итог на каждом шаге и сравнивайте с ожиданием. Сколько строк читается из таблиц, сколько остаётся после фильтра, сколько после каждого соединения, сколько после группировки. Первый шаг, где число расходится с ожиданием, — место ошибки.
Начните с общего числа: SELECT COUNT(*) по базовой таблице за нужный период. Затем добавляйте по одному условию или соединению и смотрите, как меняется счётчик. Так вы за пару минут локализуете блок, а не будете читать триста строк подряд.
Проверяйте на маленьком периоде: один день или один клиент. На коротком срезе видно конкретные строки, и расхождение объясняется руками. Полезно смотреть суммы по датам: скачки и нули обычно указывают на соединение или на границы периода.
- шаг: посчитайте COUNT(*) на входе и после каждого шага
- шаг: сузьте период до одного дня и сверьте суммы по датам
Читаемее и быстрее: рефакторинг и план запроса
Переписывать стоит не всегда. Если запрос работает и его никто не будет править, добавьте комментарии к шагам и оставьте как есть. Браться за рефакторинг логично, когда логику нельзя объяснить коллеге, когда запрос ломается от любого изменения и когда в нём дублируются большие блоки.
Правила простые: единый стиль (ключевые слова заглавными, отступы, одна колонка на строку), осмысленные псевдонимы — o для orders и c для customers вместо a, b, c, отказ от SELECT * в пользу явных колонок, комментарий к каждому шагу и вынос повторяющейся логики в CTE с именем вроде orders_daily.
Про производительность: посмотрите план запроса через EXPLAIN и поищите полное сканирование большой таблицы, функции вокруг колонок в WHERE, лишние соединения и декартовы произведения. Часто помогает переписать условие диапазоном дат. Предлагайте это без ломки логики и сверяйте результат до и после правок.
- шаг: приведите запрос к единому стилю и переименуйте псевдонимы
- шаг: проверьте план запроса и сверьте результат до и после правок
Что сделать: короткий чеклист
- Выпишите все таблицы из FROM и JOIN и найдите выходной SELECT с его гранулярностью
- Соберите все фильтры по датам и убедитесь, что период одинаковый во всех блоках
- Прочитайте запрос в порядке выполнения: FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT
- Для каждого JOIN проверьте ключ, уникальность и число строк до и после соединения
- Разложите метрику на числитель и знаменатель и сверьте COUNT(*) с COUNT(DISTINCT …)
- Пройдите запрос методом деления: считайте промежуточные итоги и найдите первый расходящийся шаг
- Проверьте условия LEFT JOIN в WHERE — они незаметно превращают соединение во внутреннее
- Посмотрите план запроса и предложите правки производительности без изменения логики
Где тренировать
Темы из гайда разобраны в тестах портала — каждый вопрос с объяснением ответа:
- Задачи по SQL с разбором каждого ответа
- SQL-задачи на собеседовании: что спрашивают
- Проверка уровня Мидл: 20 вопросов — Тесты для уровня Мидл
- Ошибки в отчётах: как найти расхождение
- Как подготовиться к собеседованию аналитика
Разбор чужого запроса — это навык, который тренируется: чем больше запросов вы прочитали по шагам, тем быстрее видите вход, выход и слабое место. Возьмите свои рабочие SQL-запросы и прогоните их по этому методу, а для тренировки найдите задачи по SQL с объяснением каждого ответа.
Диагностика из 20 вопросов покажет ваш уровень и темы, которые стоит подтянуть. Вводная тема любого курса открыта бесплатно и без регистрации.
Создать аккаунт — вводная тема бесплатна Пройти демо-тест без регистрации