Перейти к содержимому

Вопросы по SQL на собеседовании: JOIN, GROUP BY, оконные функции, индексы и задачи с решениями

Команда Interview Assistantsqlвопросы

На собеседовании по SQL проверяют не знание синтаксиса наизусть, а понимание того, как база выполняет запрос: в каком порядке применяются FROM, WHERE, GROUP BY и SELECT, что происходит с NULL при соединениях и почему запрос вдруг теряет строки. Большинство задач сводится к пяти конструкциям: JOIN, GROUP BY с HAVING, подзапросы и CTE, оконные функции и EXISTS. На middle и выше к ним добавляются индексы, план запроса и уровни изоляции транзакций. Ниже разобраны все эти темы и семь типовых задач с решениями на PostgreSQL. Почти все решения без изменений работают и в MySQL 8.

Что спрашивают на разных уровнях

УровеньТипичные вопросыЧто хотят увидеть
JuniorSELECT с фильтрами, виды JOIN, GROUP BY, разница WHERE и HAVING, первичный и внешний ключУверенное написание простых запросов без подсказок
MiddleОконные функции, подзапросы и CTE, NULL-ловушки, индексы, EXPLAIN, транзакцииКорректные запросы на неудобных данных и понимание производительности
SeniorПлан запроса, составные и частичные индексы, уровни изоляции и аномалии, блокировки, денормализацияРешения с учётом нагрузки, объёма данных и компромиссов

Для бэкенд-разработчика и аналитика SQL обычно идёт отдельным блоком технического интервью, для фронтенда встречается реже и на уровне основ. Общая структура такого интервью разобрана в статье про вопросы на техническом собеседовании. Для Java-разработчика SQL идёт рядом с JPA и транзакциями Spring, см. вопросы на собеседовании Java.

Учебная схема для всех примеров

Интервьюеры почти всегда берут похожую схему: сотрудники, отделы, пользователи, заказы. Все примеры ниже используют её:

CREATE TABLE departments (
    id   INT PRIMARY KEY,
    name TEXT NOT NULL
);

CREATE TABLE employees (
    id            INT PRIMARY KEY,
    name          TEXT NOT NULL,
    department_id INT REFERENCES departments (id),
    manager_id    INT REFERENCES employees (id),
    salary        NUMERIC(12, 2) NOT NULL
);

CREATE TABLE users (
    id    INT PRIMARY KEY,
    name  TEXT NOT NULL,
    email TEXT NOT NULL
);

CREATE TABLE orders (
    id         INT PRIMARY KEY,
    user_id    INT REFERENCES users (id),
    amount     NUMERIC(12, 2) NOT NULL,
    created_at TIMESTAMP NOT NULL
);

Порядок выполнения запроса

Это самый полезный факт для собеседования: запрос пишется в одном порядке, а логически выполняется в другом.

  1. FROM и JOIN — собрать строки из таблиц.
  2. WHERE — отфильтровать отдельные строки.
  3. GROUP BY — сгруппировать.
  4. HAVING — отфильтровать группы.
  5. Оконные функции.
  6. SELECT — вычислить выражения и псевдонимы.
  7. DISTINCT — убрать дубликаты.
  8. ORDER BY — отсортировать.
  9. LIMIT / OFFSET — обрезать результат.

Из этого порядка следуют ответы на несколько частых вопросов сразу:

  • Почему нельзя использовать псевдоним из SELECT в WHERE? WHERE выполняется раньше, чем SELECT вычисляет псевдоним. В ORDER BY псевдоним доступен, потому что сортировка идёт после SELECT.
  • Почему агрегат нельзя писать в WHERE? На этапе WHERE групп ещё нет. Условие на агрегат пишется в HAVING.
  • Почему оконную функцию нельзя отфильтровать в WHERE? Она вычисляется позже. Нужно обернуть запрос в подзапрос или CTE и фильтровать снаружи.

JOIN: виды и подводные камни

ВидЧто возвращает
INNER JOINТолько строки, для которых нашлась пара в обеих таблицах
LEFT JOINВсе строки левой таблицы; где пары нет, колонки правой таблицы равны NULL
RIGHT JOINЗеркально LEFT JOIN, на практике почти всегда переписывают через LEFT
FULL JOINВсе строки обеих таблиц, недостающие стороны заполнены NULL
CROSS JOINДекартово произведение: каждая строка с каждой
Self joinТаблица соединяется сама с собой, например сотрудник и его руководитель

Ловушка: условие на правую таблицу в WHERE

Задача: посчитать заказы каждого пользователя за 2026 год, включая тех, у кого заказов нет.

-- Неправильно: пользователи без заказов пропадут
SELECT u.name, COUNT(o.id) AS orders_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.created_at >= '2026-01-01'
GROUP BY u.id, u.name;

-- Правильно: условие переносится в ON
SELECT u.name, COUNT(o.id) AS orders_count
FROM users u
LEFT JOIN orders o
       ON o.user_id = u.id
      AND o.created_at >= '2026-01-01'
GROUP BY u.id, u.name;

В первом варианте у пользователя без заказов o.created_at равно NULL, сравнение с датой даёт не TRUE, и WHERE отбрасывает строку. LEFT JOIN фактически превращается в INNER JOIN. Это один из самых частых вопросов «найдите ошибку».

Ловушка: COUNT(*) против COUNT(колонка)

COUNT(*) считает строки, COUNT(o.id) считает только непустые значения. После LEFT JOIN пользователь без заказов даёт одну строку с NULL, поэтому COUNT(*) вернёт 1, а COUNT(o.id) вернёт 0. Нужен второй вариант.

Ловушка: размножение строк

Если соединить пользователей одновременно с заказами и, например, с обращениями в поддержку, строки перемножатся: три заказа и два обращения дадут шесть строк, и суммы окажутся завышены. Решение — агрегировать каждую таблицу отдельно в подзапросе или CTE и только потом соединять.

NULL: три значения вместо двух

В SQL логика трёхзначная: TRUE, FALSE и UNKNOWN. Любое сравнение с NULL даёт UNKNOWN, а WHERE пропускает только TRUE.

  • WHERE manager_id = NULL не вернёт ничего. Правильно: WHERE manager_id IS NULL.
  • NULL = NULL — это тоже UNKNOWN, а не TRUE.
  • Агрегаты SUM, AVG, MIN, MAX пропускают NULL. AVG по колонке с пропусками делит на число непустых значений, а не на число строк.
  • COALESCE(salary, 0) подставляет значение по умолчанию.

Ловушка: NOT IN и NULL

-- Опасно: если в orders есть строка с user_id = NULL, запрос вернёт пустой результат
SELECT name FROM users
WHERE id NOT IN (SELECT user_id FROM orders);

-- Безопасно
SELECT u.name FROM users u
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id
);

id NOT IN (1, 2, NULL) раскрывается в id <> 1 AND id <> 2 AND id <> NULL, а последнее всегда UNKNOWN. Если вы сами назовёте эту ловушку до того, как о ней спросят, это заметный плюс.

GROUP BY и HAVING

WHERE фильтрует строки до группировки, HAVING — группы после неё. Если условие можно написать в WHERE, лучше писать его там: в группировку попадёт меньше строк.

-- Отделы, где больше пяти сотрудников с зарплатой выше 100 000
SELECT department_id, COUNT(*) AS employees_count
FROM employees
WHERE salary > 100000          -- фильтр по строкам
GROUP BY department_id
HAVING COUNT(*) > 5;           -- фильтр по группам

Правило для SELECT с GROUP BY: каждая колонка должна либо входить в GROUP BY, либо стоять внутри агрегата. MySQL в старых режимах это прощал, PostgreSQL выдаёт ошибку.

Оконные функции

Оконная функция считает значение по группе строк, но, в отличие от GROUP BY, не схлопывает их: каждая строка остаётся в результате. Синтаксис: функция() OVER (PARTITION BY ... ORDER BY ...).

Главный вопрос — разница между тремя функциями ранжирования. Пусть зарплаты в отделе 300, 200, 200, 100:

salaryROW_NUMBERRANKDENSE_RANK
300111
200222
200322
100443
  • ROW_NUMBER всегда даёт уникальный номер, порядок среди равных не определён.
  • RANK даёт равным одинаковый ранг и оставляет пропуск после них.
  • DENSE_RANK даёт равным одинаковый ранг без пропусков.

Другие функции, которые встречаются в задачах:

  • LAG(col) и LEAD(col) — значение из предыдущей или следующей строки, для сравнения «день к дню».
  • SUM(col) OVER (ORDER BY ...) — нарастающий итог.
  • AVG(col) OVER (PARTITION BY ...) — среднее по группе рядом с каждой строкой, например чтобы сравнить зарплату сотрудника со средней по отделу.

Подробное объяснение с примерами есть в документации PostgreSQL по оконным функциям.

Семь типовых задач с решениями

1. Вторая по величине зарплата

SELECT MAX(salary) AS second_salary
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);

Если второй зарплаты нет, MAX вернёт NULL, а не пустой результат, и это обычно и ожидают. Для N-й зарплаты удобнее оконная функция:

SELECT DISTINCT salary
FROM (
    SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
    FROM employees
) ranked
WHERE rnk = 2;

Уточните у интервьюера, что считать второй зарплатой при одинаковых значениях: от ответа зависит выбор между DENSE_RANK и ROW_NUMBER.

2. Сотрудники, которые получают больше своего руководителя

SELECT e.name
FROM employees e
JOIN employees m ON m.id = e.manager_id
WHERE e.salary > m.salary;

Классический self join. Сотрудники без руководителя (manager_id IS NULL) отсеются INNER JOIN, и это правильно.

3. Найти и удалить дубликаты email

-- Найти
SELECT email, COUNT(*) AS cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;

-- Удалить, оставив запись с минимальным id (синтаксис PostgreSQL)
DELETE FROM users u
USING users d
WHERE u.email = d.email
  AND u.id > d.id;

Хорошо добавить, что после чистки стоит повесить на колонку уникальный индекс, иначе дубликаты появятся снова.

4. Топ-3 зарплаты в каждом отделе

WITH ranked AS (
    SELECT d.name AS department,
           e.name,
           e.salary,
           DENSE_RANK() OVER (
               PARTITION BY e.department_id
               ORDER BY e.salary DESC
           ) AS rnk
    FROM employees e
    JOIN departments d ON d.id = e.department_id
)
SELECT department, name, salary
FROM ranked
WHERE rnk <= 3
ORDER BY department, salary DESC;

Фильтр по rnk вынесен наружу, потому что оконная функция вычисляется после WHERE. С DENSE_RANK при равных зарплатах в выборку может попасть больше трёх человек, с ROW_NUMBER ровно три. Проговорите это вслух.

5. Пользователи без заказов

SELECT u.id, u.name
FROM users u
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id
);

Равноценный вариант: LEFT JOIN orders o ON o.user_id = u.id WHERE o.id IS NULL. Вариант с NOT IN лучше не использовать из-за ловушки с NULL.

6. Выручка по дням и нарастающий итог

WITH daily AS (
    SELECT created_at::date AS day, SUM(amount) AS revenue
    FROM orders
    GROUP BY created_at::date
)
SELECT day,
       revenue,
       SUM(revenue) OVER (ORDER BY day) AS running_total
FROM daily
ORDER BY day;

Сначала агрегируем по дню, потом считаем окно по уже сгруппированным строкам. Если считать окно по сырым заказам с ORDER BY created_at, у заказов с одинаковым временем итог окажется одинаковым: по умолчанию рамка окна RANGE включает все строки с тем же значением сортировки. Явная рамка ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW убирает этот эффект.

7. Изменение выручки к предыдущему дню

WITH daily AS (
    SELECT created_at::date AS day, SUM(amount) AS revenue
    FROM orders
    GROUP BY created_at::date
)
SELECT day,
       revenue,
       revenue - LAG(revenue) OVER (ORDER BY day) AS diff
FROM daily
ORDER BY day;

Для первого дня LAG вернёт NULL, и разница тоже будет NULL. Спросите, нужно ли показывать дни без заказов: их в daily нет, и для них придётся генерировать календарь через generate_series.

Индексы

Индекс — отдельная структура, обычно B-дерево, которая позволяет находить строки без полного перебора таблицы. Что спрашивают:

  • Зачем нужен индекс и чем он платит. Ускоряет поиск, сортировку и соединения, но замедляет INSERT, UPDATE и DELETE и занимает место. Индекс на каждую колонку — плохая идея.
  • Какие колонки индексировать. Те, по которым часто фильтруют, соединяют и сортируют, с высокой селективностью. Индекс на колонке со значениями «да/нет» почти бесполезен.
  • Составной индекс и порядок колонок. Индекс по (user_id, created_at) помогает запросам с условием на user_id и на user_id вместе с created_at. Запросу только по created_at он обычно не помогает: работает правило левого префикса.
  • Когда индекс не используется. Функция над колонкой (WHERE lower(email) = ... без индекса по выражению), LIKE '%текст' с ведущим процентом, несовпадение типов, маленькая таблица или условие, под которое попадает большая часть строк: тогда полный перебор дешевле.
  • Покрывающий индекс. Если индекс содержит все нужные запросу колонки, база может ответить только по нему, не обращаясь к таблице (index-only scan).

Как проверить, что происходит на самом деле: EXPLAIN показывает план, EXPLAIN ANALYZE выполняет запрос и показывает реальное время и число строк. Отличие Seq Scan от Index Scan в плане — минимум, который стоит уметь прочитать. Подробно в документации PostgreSQL по EXPLAIN.

Транзакции и уровни изоляции

Транзакция — группа операций, которая выполняется целиком или не выполняется вовсе. Свойства ACID: атомарность, согласованность, изолированность, долговечность.

Уровни изоляции по стандарту SQL и аномалии, которые они допускают:

УровеньГрязное чтениеНеповторяющееся чтениеФантомное чтение
Read UncommittedВозможноВозможноВозможно
Read CommittedНетВозможноВозможно
Repeatable ReadНетНетВозможно
SerializableНетНетНет
  • Грязное чтение — видим изменения чужой транзакции, которая ещё не зафиксирована и может откатиться.
  • Неповторяющееся чтение — повторно читаем ту же строку и получаем другое значение, потому что её успели изменить.
  • Фантом — повторяем запрос с условием и получаем новые строки, которые кто-то вставил.

Важные детали реализации, которые любят уточнять: в PostgreSQL уровень по умолчанию Read Committed, Read Uncommitted ведёт себя как Read Committed, а Repeatable Read не допускает и фантомов, потому что работает на снимке данных. В MySQL с InnoDB уровень по умолчанию Repeatable Read. Подробности в документации PostgreSQL по изоляции транзакций.

Короткие теоретические вопросы

  • DELETE, TRUNCATE и DROP. DELETE удаляет строки по условию и может срабатывать триггерами; TRUNCATE быстро очищает всю таблицу; DROP удаляет саму таблицу вместе со структурой.
  • UNION и UNION ALL. UNION убирает дубликаты и поэтому дороже, UNION ALL просто склеивает результаты. Если дубликатов быть не может, берите UNION ALL.
  • Первичный ключ и уникальный индекс. Первичный ключ один на таблицу и не допускает NULL; уникальных ограничений может быть несколько.
  • CTE и подзапрос. CTE (WITH) читается лучше и может использоваться в запросе несколько раз; рекурсивный CTE нужен для обхода деревьев, например иерархии руководителей. С PostgreSQL 12 простые CTE по умолчанию встраиваются в запрос и не мешают оптимизатору.
  • Нормализация. 1НФ — атомарные значения без повторяющихся групп; 2НФ — нет зависимости от части составного ключа; 3НФ — нет зависимостей между неключевыми колонками. Денормализацию применяют осознанно, ради скорости чтения, и платят за неё сложностью обновлений.
  • Проблема N+1. Код сначала выбирает список, а потом делает отдельный запрос на каждый элемент. Лечится JOIN-ом или одним запросом с IN / ANY. Часто всплывает в разговоре про ORM.

Как решать SQL-задачу вслух

Подход тот же, что и на live coding: интервьюер оценивает ход мысли не меньше, чем итоговый запрос.

  1. Уточните схему и данные. Может ли колонка быть NULL, бывают ли дубликаты, что делать с равными значениями, нужны ли строки без пары.
  2. Назовите форму результата. «На выходе одна строка на отдел с тремя колонками».
  3. Стройте запрос по шагам. Сначала FROM и JOIN, потом фильтры, потом группировка. CTE помогает разбить задачу на понятные куски.
  4. Проверьте на краевых случаях. Пустой отдел, NULL в колонке, одинаковые зарплаты.
  5. Скажите о производительности. Какой индекс поможет этому запросу и что будет на ста миллионах строк. Если разговор уходит в масштаб, пригодится статья про system design.

Типичные ошибки

  • Условие на правую таблицу LEFT JOIN в WHERE вместо ON.
  • = NULL вместо IS NULL и NOT IN с подзапросом, который может вернуть NULL.
  • COUNT(*) там, где нужен COUNT(колонка).
  • Колонки в SELECT, которых нет ни в GROUP BY, ни в агрегате.
  • Попытка фильтровать оконную функцию в WHERE.
  • Соединение нескольких таблиц «один ко многим» с завышенными суммами.
  • SELECT * в ответе на задачу: интервьюер хочет видеть, что вы понимаете, какие колонки нужны.
  • Молчаливое допущение там, где стоило задать вопрос про дубликаты и NULL.

Чек-лист перед собеседованием

  1. Порядок выполнения запроса — наизусть.
  2. Виды JOIN и ловушка с условием в WHERE.
  3. NULL: IS NULL, COALESCE, NOT IN против NOT EXISTS.
  4. WHERE против HAVING, правило колонок в GROUP BY.
  5. ROW_NUMBER, RANK, DENSE_RANK, LAG, нарастающий итог.
  6. Семь задач из этой статьи — написать самостоятельно, не подглядывая.
  7. Составной индекс, правило левого префикса, когда индекс не работает, чтение EXPLAIN.
  8. ACID, уровни изоляции и три аномалии.

Тренировать запросы удобно в любой локальной PostgreSQL или в онлайн-песочнице вроде DB Fiddle. Для задач на алгоритмы, которые часто идут в том же интервью, есть отдельная шпаргалка по алгоритмам и структурам данных.

Где здесь помогает Interview Assistant

На SQL-секции задачу обычно показывают в общем редакторе или зачитывают голосом вместе со схемой таблиц. Interview Assistant транскрибирует вопрос в реальном времени и распознаёт термины вроде DENSE_RANK, HAVING и Repeatable Read без искажений, а задачу со схемой на экране может разобрать по скриншоту: подсказать подход и краевые случаи, которые стоит проговорить. Первые 20 минут бесплатны — их хватит, чтобы прогнать пару задач из этой статьи и проверить, как ассистент слышит ваш звонок.

Частые вопросы

Какой диалект SQL учить к собеседованию?

Тот, что указан в вакансии; если он не указан, PostgreSQL. Базовый синтаксис SELECT, JOIN, GROUP BY и оконных функций у PostgreSQL, MySQL 8 и большинства других СУБД совпадает. Различия касаются в основном функций для дат и строк, синтаксиса DELETE с соединением и деталей изоляции транзакций.

Нужно ли знать оконные функции junior-разработчику?

Желательно знать хотя бы ROW_NUMBER и разницу между RANK и DENSE_RANK: задачи «топ-N в каждой группе» встречаются и на junior-позициях. Для middle и выше оконные функции — обязательный минимум.

Что делать, если не помню точный синтаксис?

Скажите об этом и опишите логику: «Здесь нужна оконная функция с разбиением по отделу и сортировкой по зарплате, синтаксис рамки уточню». Интервьюеру важнее понимание, чем память на ключевые слова. Неудобные моменты такого рода разобраны в статье про собеседование без опыта.

Чем подзапрос отличается от JOIN по производительности?

В современных СУБД оптимизатор часто переписывает одно в другое, поэтому правильный ответ — «смотреть план запроса». EXISTS и NOT EXISTS обычно эффективны для проверки наличия, JOIN удобнее, когда нужны колонки из обеих таблиц.

Сколько задач по SQL нужно решить для подготовки?

Важнее разнообразие, чем количество: 20–30 задач, которые покрывают JOIN, группировку, NULL, ранжирование и сравнение строк с соседними, дают больше, чем сотня однотипных. После каждой задачи проговорите вслух, где в ней ловушка.