SprintCode.pro

Подготовка к алгоритмическим задачам

Super

SQL на собеседовании: вопросы, задачи и типичные ошибки

14 мин чтения
собеседование
базы данных
подготовка

Коротко

БлокЧто спрашивают
JOINтипы соединений, разница INNER и LEFT
АгрегацияGROUP BY, HAVING против WHERE
Подзапросыкоррелированные, EXISTS против IN
Оконные функцииROW_NUMBER, RANK, разница между ними
Индексыкогда работают, когда нет
ТранзакцииACID, уровни изоляции

Блок 1. JOIN

Типы соединений

-- INNER: только совпадения в обеих таблицах SELECT u.name, o.total FROM users u INNER JOIN orders o ON u.id = o.user_id; -- LEFT: все строки слева, справа NULL при отсутствии SELECT u.name, o.total FROM users u LEFT JOIN orders o ON u.id = o.user_id; -- FULL: все строки с обеих сторон -- CROSS: декартово произведение, все пары

Классическая задача: пользователи без заказов

Спрашивают почти всегда. Два способа:

-- через LEFT JOIN SELECT u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.id IS NULL; -- через NOT EXISTS — обычно быстрее SELECT u.name FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id );

NOT EXISTS останавливается на первом найденном совпадении, тогда как LEFT JOIN строит полное соединение и потом фильтрует.

Главная ловушка: условие в WHERE вместо ON

-- LEFT JOIN превращается в INNER SELECT u.name, o.total FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.status = 'paid'; -- отсеет строки с NULL -- правильно: условие в ON SELECT u.name, o.total FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid';

В первом запросе пользователи без оплаченных заказов пропадут, потому что NULL = 'paid' ложно. Это один из самых частых вопросов на собеседовании.

Пройди собеседование в топ-компанию
Платформа для подготовки

Решай алгоритмические задачи как профи

✓ Популярные алгоритмы✓ Разбор решений✓ AI помощь
Начать сейчас
Программист за работой

Блок 2. Агрегация

WHERE против HAVING

WHERE фильтрует строки до группировки, HAVINGгруппы после.

SELECT department, AVG(salary) AS avg_salary FROM employees WHERE status = 'active' -- до группировки GROUP BY department HAVING AVG(salary) > 100000; -- после группировки

Правило: если условие про отдельную строку — WHERE; если про агрегат — HAVING. WHERE предпочтительнее, потому что отсеивает данные раньше.

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

Отличается от порядка написания, и это спрашивают:

FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT

Отсюда следствие: псевдоним из SELECT нельзя использовать в WHERE — на момент выполнения WHERE он ещё не существует. А в ORDER BY можно, потому что он идёт позже.

COUNT(*) против COUNT(столбец)

COUNT(*) -- все строки COUNT(column) -- строки, где column НЕ NULL COUNT(DISTINCT column) -- уникальные ненулевые значения

Разница проявляется только при наличии NULL, и на этом строят вопросы-ловушки.

Блок 3. Оконные функции

Тема, которая отличает уверенного кандидата от начинающего.

ROW_NUMBER, RANK и DENSE_RANK

SELECT name, salary, ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn, RANK() OVER (ORDER BY salary DESC) AS rnk, DENSE_RANK() OVER (ORDER BY salary DESC) AS dense FROM employees;

При зарплатах 100, 90, 90, 80:

salaryROW_NUMBERRANKDENSE_RANK
100111
90222
90322
80443

ROW_NUMBER всегда уникален. RANK даёт одинаковый ранг равным и пропускает следующие номера. DENSE_RANK не пропускает.

Классическая задача: второй по величине

-- через оконную функцию SELECT DISTINCT salary FROM ( SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS r FROM employees ) t WHERE r = 2; -- без оконных функций SELECT MAX(salary) FROM employees WHERE salary < (SELECT MAX(salary) FROM employees);

Топ-N внутри группы

Очень частая задача: по три самых дорогих товара в каждой категории.

SELECT category, name, price FROM ( SELECT category, name, price, ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn FROM products ) t WHERE rn <= 3;

PARTITION BY — ключевая часть: нумерация начинается заново внутри каждой группы.

Блок 4. Индексы

Когда индекс работает

-- работает WHERE email = 'a@b.com' WHERE age BETWEEN 20 AND 30 ORDER BY created_at -- НЕ работает WHERE LOWER(email) = 'a@b.com' -- функция над столбцом WHERE name LIKE '%текст' -- поиск по суффиксу WHERE age + 1 = 30 -- арифметика над столбцом

Правило: индекс перестаёт работать, если столбец обёрнут в функцию или выражение. Решение — вынести преобразование на другую сторону или создать функциональный индекс.

Составной индекс читается слева направо

Индекс по (city, age) помогает запросам:

  • по city;
  • по city и age вместе.

И не помогает запросу только по age. Причина в устройстве B+ дерева: данные отсортированы сначала по первому полю.

Почему много индексов — плохо

Каждая вставка и обновление перестраивает все индексы. Индексы ускоряют чтение и замедляют запись, поэтому их набор подбирают под реальные запросы, а не «на всякий случай».

Блок 5. Транзакции

ACID

Atomicity — либо всё, либо ничего. Consistency — данные остаются согласованными. Isolation — параллельные транзакции не мешают друг другу. Durability — зафиксированные изменения переживут сбой.

Уровни изоляции и аномалии

УровеньГрязное чтениеНеповторяющееся чтениеФантомы
READ UNCOMMITTEDвозможновозможновозможно
READ COMMITTEDнетвозможновозможно
REPEATABLE READнетнетвозможно
SERIALIZABLEнетнетнет

По умолчанию: PostgreSQL и Oracle — READ COMMITTED, MySQL (InnoDB) — REPEATABLE READ.

Чем строже уровень, тем меньше параллелизма. На собеседовании ждут понимания этого компромисса, а не заучивания таблицы.

Блок 6. Проблема N+1

Любимый практический вопрос.

# 1 запрос за списком + N запросов за деталями users = db.query("SELECT * FROM users") for user in users: orders = db.query(f"SELECT * FROM orders WHERE user_id = {user.id}")

При 1000 пользователей это 1001 запрос. Решения:

-- один запрос с JOIN SELECT u.*, o.* FROM users u LEFT JOIN orders o ON u.id = o.user_id; -- или два запроса с IN SELECT * FROM orders WHERE user_id IN (1, 2, 3, ...);

В ORM это select_related / prefetch_related в Django, JOIN FETCH в Hibernate, include в Prisma.

Задачи, которые дают

Найти дубликаты:

SELECT email, COUNT(*) FROM users GROUP BY email HAVING COUNT(*) > 1;

Удалить дубликаты, оставив по одной строке:

DELETE FROM users WHERE id NOT IN ( SELECT MIN(id) FROM users GROUP BY email );

Нарастающий итог:

SELECT date, amount, SUM(amount) OVER (ORDER BY date) AS running_total FROM payments;

Разница с предыдущей строкой:

SELECT date, amount, amount - LAG(amount) OVER (ORDER BY date) AS diff FROM payments;

Частые ошибки

Условие для LEFT JOIN в WHERE. Превращает его в INNER.

Путаница WHERE и HAVING.

Использование псевдонима из SELECT в WHERE. Не сработает из-за порядка выполнения.

Забытый DISTINCT при JOIN. Соединение «один ко многим» размножает строки левой таблицы.

Функция над индексированным столбцом. Индекс перестаёт использоваться.

Игнорирование NULL. NULL = NULL даёт не TRUE, а NULL. Сравнивать нужно через IS NULL.

Что запомнить

  • Условие для LEFT JOIN пишите в ON, а не в WHERE.
  • WHERE фильтрует строки до группировки, HAVING — группы после.
  • Порядок выполнения: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY.
  • ROW_NUMBER уникален, RANK пропускает номера, DENSE_RANK — нет.
  • Индекс не работает, если столбец обёрнут в функцию; составной индекс читается слева направо.
  • N+1 — самая частая проблема производительности, лечится JOIN или запросом с IN.