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

SQL на собеседовании: вопросы, задачи и типичные ошибки
Коротко
| Блок | Что спрашивают |
|---|---|
| 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' ложно. Это один из самых частых вопросов на собеседовании.
Решай алгоритмические задачи как профи

Блок 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:
| salary | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 100 | 1 | 1 | 1 |
| 90 | 2 | 2 | 2 |
| 90 | 3 | 2 | 2 |
| 80 | 4 | 4 | 3 |
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.
