6.6Практические SQL-задачи
Отработай повторяющиеся формы SQL на задачах: антиджойн, дедупликацию, ранжирование внутри группы и рекурсивный обход дерева. Сравнивай решение не только по синтаксису, но и по точной семантике: что считается дублем, как обрабатываются равные значения и как остановить цикл.
Памятка: пять шаблонов, из которых собирается почти всё
-- 1. Антиджойн: «те, у кого нет связанных строк»
SELECT c.* FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
-- эквивалент через LEFT JOIN:
SELECT c.* FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;
-- но не NOT IN (SELECT ...): ломается о NULL
-- 2. Дедупликация: найти и удалить лишние копии, оставив одну
SELECT email, count(*) FROM users GROUP BY email HAVING count(*) > 1;
DELETE FROM users u
USING (
SELECT id, row_number() OVER (PARTITION BY email ORDER BY id) AS rn
FROM users
) d
WHERE u.id = d.id AND d.rn > 1;
-- 3. Топ-N в группе: «три лучших в каждой категории»
WITH ranked AS (
SELECT category_id, product_id, revenue,
row_number() OVER (PARTITION BY category_id ORDER BY revenue DESC) AS rn
FROM sales
)
SELECT * FROM ranked WHERE rn <= 3;
-- 4. Рекурсивный обход: иерархия, граф, дерево
WITH RECURSIVE tree AS (
SELECT id, manager_id, name, 1 AS depth -- якорь
FROM employees WHERE id = $1
UNION ALL
SELECT e.id, e.manager_id, e.name, t.depth + 1 -- рекурсивная часть
FROM employees e JOIN tree t ON e.manager_id = t.id
WHERE t.depth < 20 -- страховка от цикла
)
SELECT * FROM tree;
-- 5. Окно вместо коррелированного подзапроса
-- было: подзапрос выполняется для каждой строки
SELECT * FROM employees e
WHERE salary > (SELECT avg(salary) FROM employees WHERE dept_id = e.dept_id);
-- стало: один проход, агрегат считается окном
SELECT * FROM (
SELECT e.*, avg(salary) OVER (PARTITION BY dept_id) AS dept_avg
FROM employees e
) t WHERE salary > dept_avg;
| Оконная функция | Что делает | На дубликатах значения |
|---|---|---|
row_number() | сквозная нумерация 1,2,3,4 | разные номера - детерминизм только при уникальном ORDER BY |
rank() | 1,2,2,4 | одинаковый ранг, следующий номер «прыгает» |
dense_rank() | 1,2,2,3 | одинаковый ранг, без пропусков - то, что нужно для «второй по величине» |
lag()/lead() | значение из предыдущей/следующей строки | дельты, «предыдущий статус», разрывы в последовательности |
sum() OVER (ORDER BY ...) | нарастающий итог | по умолчанию рамка RANGE UNBOUNDED PRECEDING |
first_value()/nth_value() | значение из позиции в рамке | внимательно с рамкой окна |
- Оконные функции нельзя использовать в
WHERE: окно вычисляется послеWHEREиGROUP BY. Поэтому фильтр поrow_number()всегда живёт во внешнем запросе или в CTE. Исключение -QUALIFY, но его в PostgreSQL нет (есть в ClickHouse, Snowflake, BigQuery). ORDER BYвнутри окна должен быть детерминированным. Если сортируешь по неуникальной колонке, добавьidтай-брейкером, иначе «второй заказ клиента» будет прыгать между запусками.- Проговаривай план вслух. На этих задачах интервьюер часто спрашивает «а какой индекс нужен», и ответ на него стоит на голову выше самого решения.
Вопросы
7NOT EXISTS и
LEFT JOIN ... WHERE o.id IS NULL; в PostgreSQL оба дают один и тот же
план Anti Join и работают одинаково быстро. NOT IN (SELECT ...) -
ловушка: если подзапрос вернёт хоть один NULL, результат будет
пустым всегда, и это молчаливый баг.-- Способ 1: NOT EXISTS, предпочтительный и самый читаемый
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- Способ 2: LEFT JOIN + IS NULL, классический антиджойн
SELECT c.id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL; -- фильтр только по NOT NULL-колонке правой таблицы
-- Способ 3: EXCEPT, когда нужны только идентификаторы
SELECT id FROM customers
EXCEPT
SELECT customer_id FROM orders;
-- Ловушка, так не писать:
SELECT * FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders); -- пусто, если есть NULL
Построчно
SELECT 1вEXISTSничего не вычисляет: планировщику важно только «есть ли хоть одна строка». Что писать,SELECT *илиSELECT 1, дело вкуса, разницы в плане нет.- Условие связи
o.customer_id = c.idделает подзапрос коррелированным, и как раз поэтому PostgreSQL может превратить его в Anti Join: один проход вместо подзапроса на каждую строку. - В варианте с
LEFT JOINфильтрWHERE o.id IS NULLдолжен указывать на колонку, которая в самой таблицеordersне бывает NULL, обычно на первичный ключ. Если взятьo.comment IS NULL, в выборку попадут ещё и клиенты с заказами без комментария.
Почему NOT IN ломается
SQL живёт в трёхзначной логике. id NOT IN (1, 2, NULL) раскрывается в
id <> 1 AND id <> 2 AND id <> NULL. Последнее сравнение
даёт UNKNOWN, а TRUE AND UNKNOWN = UNKNOWN, и строка не проходит
фильтр WHERE. Итог: если в подзапросе есть хотя бы один NULL, результат
пуст для всех строк, причём без ошибки и без предупреждения.
А колонка orders.customer_id вполне может быть nullable (гостевые заказы).
Второй минус известен меньше: NOT IN с подзапросом PostgreSQL в Anti Join не превращает вовсе - мешает семантика NULL - и выполняет как подплан: хешированный, если результат влез в work_mem, иначе построчный.
«Починить» можно через WHERE customer_id IS NOT NULL в подзапросе, но
правильный вывод другой: для антиджойна используем NOT EXISTS, а
NOT IN оставляем для списков констант.
| Вариант | NULL-безопасен | План в PostgreSQL | Комментарий |
|---|---|---|---|
NOT EXISTS | да | Hash Anti Join | дефолтный выбор |
LEFT JOIN + IS NULL | да | Hash Anti Join | то же самое; в MySQL исторически быстрее |
EXCEPT | да | HashSetOp | ещё и дедуплицирует; на выходе только сравниваемые колонки, остальное добирать джойном |
NOT IN | нет | hashed SubPlan или SubPlan | тихо возвращает пусто на NULL |
Нужен индекс по orders(customer_id): на внешний ключ он
не создаётся автоматически. С ним при небольшом числе клиентов план будет
Nested Loop Anti Join с Index Scan; при больших объёмах - Hash Anti Join, который
строит хеш по orders и один раз проходит customers.
Если нужно «клиенты без заказов за последний месяц», условие по дате идёт
внутрь EXISTS (или в ON при LEFT JOIN), но не во
внешний WHERE, иначе антиджойн превратится в обычный join и логика
сломается.
GROUP BY email HAVING count(*) > 1.
Удалить - пронумеровать строки внутри группы через
row_number() OVER (PARTITION BY email ORDER BY id) и снести всё, где
rn > 1. И обязательно добавить UNIQUE-индекс после чистки,
иначе дубликаты вернутся.-- 1. Найти: какие email задублированы и сколько раз
SELECT lower(email) AS email, count(*) AS n, min(id) AS keep_id, array_agg(id) AS all_ids
FROM users
GROUP BY lower(email)
HAVING count(*) > 1
ORDER BY n DESC;
-- 2. Посмотреть сами строки-дубликаты целиком
SELECT * FROM users
WHERE lower(email) IN (SELECT lower(email) FROM users GROUP BY lower(email) HAVING count(*) > 1)
ORDER BY lower(email), id;
-- 3. Удалить, оставив в каждой группе самую раннюю (по created_at, при равенстве по меньшему id)
DELETE FROM users u
USING (
SELECT id,
row_number() OVER (PARTITION BY lower(email) ORDER BY created_at, id) AS rn
FROM users
) d
WHERE u.id = d.id AND d.rn > 1;
-- 4. Закрепить результат, чтобы проблема не вернулась
CREATE UNIQUE INDEX CONCURRENTLY users_email_uniq ON users (lower(email));
Построчно
PARTITION BY lower(email)группирует по нормализованному значению. Дубликаты почти всегда выглядят какIvan@mail.ruиivan@mail.ru, и безlower()ты их не найдёшь.ORDER BY created_at, idвнутри окна задаёт, какая запись выживет. Решает тут бизнес, а не техника: оставить самую старую (сохранить исходный id) или самую свежую (сохранить актуальные данные). Тай-брейкерidобязателен, иначе при одинаковомcreated_atвыбор недетерминирован.DELETE ... USINGдаёт join в DELETE (синтаксис PostgreSQL). Подзапрос вычисляется один раз, а на больших объёмах это важно.- Уникальный индекс строим по
lower(email), чтобы он совпадал с логикой дедупликации, и сCONCURRENTLY, чтобы не блокировать запись на проде.
Альтернативы и почему они хуже
-- Через ctid: работает без суррогатного ключа, но некрасиво и небезопасно при UPDATE
DELETE FROM users a USING users b
WHERE a.email = b.email AND a.ctid > b.ctid;
-- ctid: физический адрес версии строки, он меняется при UPDATE и VACUUM FULL.
-- Годится только для одноразовой чистки, но не для хранения ссылок.
-- Через min(id): проще, но нельзя выбрать «оставить последнюю» и медленнее на больших таблицах
DELETE FROM users
WHERE id NOT IN (SELECT min(id) FROM users GROUP BY email);
-- Плюс здесь снова NOT IN: попади в список NULL, и не удалится ничего, причём без ошибки.
-- Самый безопасный вариант для очень больших таблиц: пересоздание
CREATE TABLE users_new AS
SELECT DISTINCT ON (lower(email)) * FROM users ORDER BY lower(email), created_at, id;
-- затем переключаем таблицы в одной транзакции
- Сначала
SELECTс тем же условием, потомDELETE. Всегда. ИлиBEGIN; DELETE ...; SELECT count(*); ROLLBACK;- так видно, сколько удалилось бы. - Скопировать удаляемое в архивную таблицу:
CREATE TABLE users_dupes_backup AS SELECT .... - У дубликатов часто есть потомки: заказы висят на разных id одного человека.
Прежде чем удалять, перевесь связанные строки на выживший id, иначе
ON DELETE CASCADEунесёт заказы вместе с дублем. - Миллионы строк одним
DELETEозначают долгую транзакцию, распухание и нагрузку на репликацию. Режь батчами по 10–50 тысяч в цикле, с паузами.
«А как сделать, чтобы дубликаты не появлялись?» Ответ: уникальный индекс в БД плюс
INSERT ... ON CONFLICT DO NOTHING в коде. Проверка «сначала SELECT,
потом INSERT» в приложении не работает при конкурентных запросах: два процесса
одновременно не найдут запись и оба вставят. Гарантию даёт только констрейнт.
ORDER BY ... LIMIT 3; если топ-3 внутри каждой группы (например,
по каждому региону) - нужна оконная функция row_number() в CTE и фильтр
во внешнем запросе.-- Вариант A: топ-3 категории всего, хватит LIMIT
SELECT c.id, c.name, sum(oi.price * oi.qty) AS revenue
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN categories c ON c.id = oi.category_id
WHERE o.status = 'paid'
AND o.created_at >= date_trunc('month', now()) - interval '1 month'
AND o.created_at < date_trunc('month', now())
GROUP BY c.id, c.name
ORDER BY revenue DESC
LIMIT 3;
-- Вариант B: топ-3 категории в каждом регионе, нужно окно
WITH revenue AS (
SELECT o.region_id,
c.id AS category_id,
c.name AS category_name,
sum(oi.price * oi.qty) AS revenue
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN categories c ON c.id = oi.category_id
WHERE o.status = 'paid'
AND o.created_at >= date_trunc('month', now()) - interval '1 month'
AND o.created_at < date_trunc('month', now())
GROUP BY o.region_id, c.id, c.name
),
ranked AS (
SELECT *,
row_number() OVER (PARTITION BY region_id ORDER BY revenue DESC, category_id) AS rn
FROM revenue
)
SELECT region_id, category_id, category_name, revenue
FROM ranked
WHERE rn <= 3
ORDER BY region_id, rn;
Построчно
- Границы периода задавай полуинтервалом
>= начало AND < следующее начало. Это единственный корректный способ:BETWEENс'2026-07-31 23:59:59'теряет последнюю секунду, а с'2026-08-01'захватывает лишнюю строку ровно в полночь. - Условие по дате ставь на колонку, а не на функцию от неё.
WHERE date_trunc('month', o.created_at) = ...убивает индекс и, если таблица партиционирована, ещё и pruning. o.status = 'paid'нужен почти всегда: выручку считают по оплаченным, а не по созданным заказам. Скажи это вслух: на этом держится половина впечатления от ответа.row_number()нумерует внутриPARTITION BY region_id, сортировка поrevenue DESCс тай-брейкеромcategory_idдля детерминизма.- Фильтр
WHERE rn <= 3живёт во внешнем запросе: оконные функции вычисляются послеWHERE, поэтому внутри того же уровня фильтровать поrnнельзя.
Альтернативы и почему они хуже
| Вариант | Оценка |
|---|---|
rank() вместо row_number() | не хуже, а другое: при равной выручке вернёт 4 строки вместо 3. Уточни у интервьюера, что делать с ничьёй - это ожидаемый вопрос |
| Коррелированный подзапрос «посчитай, сколько категорий с большей выручкой» | O(n²), на реальных данных неприемлемо |
LATERAL с LIMIT 3 на каждый регион | хорош, если регионов мало, а категорий в каждом очень много и есть индекс: читает только 3 строки на регион вместо сортировки всех |
| Считать в приложении | вытянуть все агрегаты и отсортировать в Go - приемлемо только если групп единицы; иначе лишний трафик |
Нужен индекс orders(created_at) WHERE status = 'paid' (частичный,
под предикат) или orders(status, created_at), плюс
order_items(order_id) для join. В плане ожидаем: Index Scan по датам,
Hash Join с позициями, HashAggregate, затем WindowAgg с сортировкой. Если сортировка
уходит на диск (Sort Method: external merge), поднимаем
work_mem для этой сессии. На больших объёмах вопрос «а если такой отчёт
нужен каждый час?» решается материализованным представлением с
REFRESH MATERIALIZED VIEW CONCURRENTLY.
row_number() OVER (PARTITION BY customer_id ORDER BY created_at DESC, id DESC)
и фильтр rn <= 5 снаружи. Но у задачи есть второе решение -
LATERAL, - и умение объяснить, когда какое быстрее, ценится выше самого SQL.-- Вариант A: оконная функция. Один проход по orders.
WITH ranked AS (
SELECT o.*,
row_number() OVER (PARTITION BY o.customer_id
ORDER BY o.created_at DESC, o.id DESC) AS rn
FROM orders o
)
SELECT id, customer_id, created_at, total
FROM ranked
WHERE rn <= 5
ORDER BY customer_id, rn;
-- Вариант B: LATERAL делает для каждого клиента точечный index scan на 5 строк.
SELECT c.id AS customer_id, o.id, o.created_at, o.total
FROM customers c
CROSS JOIN LATERAL (
SELECT o.id, o.created_at, o.total
FROM orders o
WHERE o.customer_id = c.id
ORDER BY o.created_at DESC, o.id DESC
LIMIT 5
) o
ORDER BY c.id, o.created_at DESC;
-- Индекс, без которого ни один вариант не работает хорошо:
CREATE INDEX orders_cust_created_idx ON orders (customer_id, created_at DESC, id DESC);
Построчно
PARTITION BY customer_idначинает нумерацию заново для каждого клиента.ORDER BY created_at DESC, id DESCставит свежие первыми;idнужен как тай-брейкер, иначе два заказа с одинаковой меткой времени будут нумероваться случайным образом и результат станет нестабильным между запусками.WHERE rn <= 5обязан быть уровнем выше: окно вычисляется послеWHERE.- В варианте B
CROSS JOIN LATERALпозволяет подзапросу ссылаться наc.idиз внешней таблицы, в этом и смыслLATERAL. Если нужны и клиенты без заказов, пишиLEFT JOIN LATERAL ... ON true.
Что выбрать: важная развилка
| Оконная функция | LATERAL | |
|---|---|---|
| Читает | ВСЕ заказы, нумерует, отбрасывает лишнее | ровно 5 строк на клиента через индекс |
| Когда быстрее | клиентов много, заказов у каждого мало | клиентов немного, заказов у каждого тысячи |
| План | Seq Scan + Sort + WindowAgg | Nested Loop + Index Scan с LIMIT внутри |
| Порядок величин | 10 млн заказов → 10 млн строк через сортировку | 1000 клиентов → 5000 строк |
В цифрах: если у 1000 клиентов по 10 000 заказов, оконный вариант отсортирует 10 млн строк,
чтобы выбросить 9 995 000. LATERAL с индексом
(customer_id, created_at DESC) сделает 1000 index scan-ов по 5 строк.
Выходят секунды против миллисекунд. Если же клиентов миллион и у каждого по 3 заказа,
выигрывает окно: один проход дешевле миллиона nested loop.
Написать GROUP BY customer_id ORDER BY created_at DESC LIMIT 5 значит
получить «5 строк всего», а не «5 на клиента». Вторая по частоте ошибка -
DISTINCT ON: SELECT DISTINCT ON (customer_id) * FROM orders
ORDER BY customer_id, created_at DESC отлично решает задачу для одного
последнего заказа, но для пяти не годится: DISTINCT ON оставляет ровно
одну строку на группу.
-- Правильно: оконная функция, один проход по таблице
SELECT id, name, dept_id, salary, dept_avg
FROM (
SELECT e.id, e.name, e.dept_id, e.salary,
avg(e.salary) OVER (PARTITION BY e.dept_id) AS dept_avg
FROM employees e
) t
WHERE salary > dept_avg
ORDER BY dept_id, salary DESC;
-- Так обычно пишут: коррелированный подзапрос
SELECT e.* FROM employees e
WHERE e.salary > (
SELECT avg(salary) FROM employees WHERE dept_id = e.dept_id
);
-- Третий вариант: JOIN с агрегатом. Читается лучше окна, работает почти так же.
SELECT e.id, e.name, e.dept_id, e.salary, a.dept_avg
FROM employees e
JOIN (
SELECT dept_id, avg(salary) AS dept_avg
FROM employees GROUP BY dept_id
) a ON a.dept_id = e.dept_id
WHERE e.salary > a.dept_avg;
Построчно
avg(salary) OVER (PARTITION BY dept_id)пишется безORDER BYвнутри окна. ДобавишьORDER BYи получишь не среднее по отделу, а нарастающее среднее: рамка по умолчанию станет «от начала раздела до текущей строки» (вместе с равными ей поORDER BY). Ошибка тихая и очень частая.- Оконная функция не схлопывает строки: каждая строка сохраняется, к ней просто
приписывается агрегат её раздела. Поэтому
salaryиdept_avgможно сравнить в одной строке. - Фильтр снова уходит во внешний запрос (окно вычисляется после
WHERE). - Подзапрос можно оформить как CTE: так читается лучше, а в PostgreSQL 12+ он инлайнится и плана не портит.
Почему коррелированный подзапрос хуже
Формально современный планировщик PostgreSQL иногда умеет его развернуть, но полагаться
на это нельзя: в общем случае подзапрос выполняется на каждую строку внешней таблицы.
Для 100 000 сотрудников в 50 отделах это 100 000 агрегаций вместо 50. В плане это видно
как SubPlan с большим loops, и время равно
actual time × loops, а не тому, что показано в узле.
- NULL в salary.
avg()игнорирует NULL, а сравнениеsalary > dept_avgс NULL-зарплатой даст UNKNOWN, и строка выпадет. Обычно это правильно, но проговорить стоит. - Отдел из одного человека. Его зарплата равна средней по отделу, значит
строгое
>его не вернёт. Уточни, ожидается ли это. - Медиана вместо среднего считается через
percentile_cont(0.5) WITHIN GROUP (ORDER BY salary). На зарплатах медиана честнее среднего, и это хороший бонусный ответ. - Индекс здесь не поможет: считается агрегат по всем строкам, будет Seq Scan -
и это нормально. Скорее пригодится
work_mem, чтобы сортировка для окна не ушла на диск.
UNION ALL и рекурсивной части, которая ссылается на сам CTE.
Выполняется итеративно: результат прошлого шага подаётся на вход следующему, пока шаг не
вернёт пустоту. Циклы в данных (A → B → A) дают бесконечный цикл, поэтому нужна защита:
ограничение глубины или массив пройденных узлов.-- Вниз по дереву: все подчинённые сотрудника $1, на любую глубину
WITH RECURSIVE subordinates AS (
-- Якорь: с кого начинаем
SELECT e.id, e.name, e.manager_id, 1 AS depth,
ARRAY[e.id] AS path
FROM employees e
WHERE e.id = $1
UNION ALL
-- Рекурсивная часть: те, чей менеджер уже найден на прошлом шаге
SELECT e.id, e.name, e.manager_id, s.depth + 1,
s.path || e.id
FROM employees e
JOIN subordinates s ON e.manager_id = s.id
WHERE NOT e.id = ANY(s.path) -- защита от цикла
AND s.depth < 50 -- страховка на всякий случай
)
SELECT repeat(' ', depth - 1) || name AS tree, id, depth, path
FROM subordinates
ORDER BY path;
-- Вверх по дереву: вся цепочка руководителей конкретного сотрудника
WITH RECURSIVE chain AS (
SELECT id, name, manager_id, 1 AS lvl FROM employees WHERE id = $1
UNION ALL
SELECT e.id, e.name, e.manager_id, c.lvl + 1
FROM employees e JOIN chain c ON e.id = c.manager_id
WHERE c.lvl < 50
)
SELECT * FROM chain ORDER BY lvl;
-- Число подчинённых на каждом уровне: тот же WITH RECURSIVE subordinates,
-- только финальный SELECT другой (отдельным запросом CTE не виден)
SELECT depth, count(*) FROM subordinates GROUP BY depth ORDER BY depth;
Как это выполняется на самом деле
- Выполняется якорь, результат кладётся в рабочую таблицу и в итоговую.
- Рекурсивная часть выполняется, но
subordinatesвнутри неё означает только результат предыдущей итерации, а не весь накопленный результат. На этой тонкости и ловят. - Новые строки добавляются к итогу и становятся рабочей таблицей следующего шага.
- Шаги повторяются, пока очередной не вернёт ноль строк.
Защита от циклов - три способа
- Массив пути
path+WHERE NOT e.id = ANY(s.path)точнее всех: отсекает именно повторное посещение и заодно даёт готовый путь для сортировки дерева. - Ограничение глубины
WHERE depth < 50- грубо, но надёжно, и защищает ещё и от «слишком глубокого» легального дерева. - Синтаксис
CYCLE(PostgreSQL 14+):... CYCLE id SET is_cycle USING path. Это стандартный SQL, он делает то же самое декларативно.
UNIONвместоUNION ALLсам по себе устраняет дубликаты и потому «спасает» от простых циклов, но ценой дедупликации на каждой итерации и потери строк, которые легально встречаются дважды (в DAG один узел достижим разными путями). Выбирать его надо осознанно, а не на автомате.- Без ограничения глубины и без path цикл в данных даёт запрос, который
выполняется, пока не съест диск под временные файлы. Один кривой
UPDATE employees SET manager_id = ...- и прод лежит. - Рекурсивная часть не может содержать
LEFT JOINс CTE справа, агрегаты иORDER BY/LIMITнапрямую: разрешена только простая ссылка на CTE. Лечится выносом во внешний запрос. А вот оконные функции там разрешены, только считаются они по строкам текущего шага.
Нужен индекс по employees(manager_id), иначе каждая итерация делает
Seq Scan, и на глубине 10 выйдет 10 полных проходов. Если дерево читается на порядки чаще,
чем меняется, рекурсию заменяют денормализацией: materialized path
('1.5.17.42' в текстовой колонке с индексом по префиксу), nested sets
(lft/rgt, читается за один диапазонный запрос, но любая
вставка перестраивает половину дерева) или расширение ltree.
Правило: рекурсивный CTE - когда дерево меняется часто; materialized path -
когда читается часто.
dense_rank() = 2) или снова 100 000 (вторая строка,
row_number() = 2)? Уточнить это - и есть половина ответа. Обычно имеют
в виду второе уникальное значение.-- Вариант 1: DENSE_RANK даёт второе по величине значение (правильный дефолт)
SELECT DISTINCT salary
FROM (SELECT salary, dense_rank() OVER (ORDER BY salary DESC) AS dr FROM employees) t
WHERE dr = 2;
-- Вариант 2: подзапрос с MAX работает без оконных функций, годится и для MySQL 5.x
SELECT max(salary) FROM employees
WHERE salary < (SELECT max(salary) FROM employees);
-- Вариант 3: коррелированный подзапрос ищет «значение, больше которого ровно одно значение»
SELECT DISTINCT e.salary FROM employees e
WHERE (SELECT count(DISTINCT e2.salary) FROM employees e2 WHERE e2.salary > e.salary) = 1;
-- Вариант 4: если нужна вторая строка, а не второе значение
SELECT id, name, salary
FROM (SELECT e.*, row_number() OVER (ORDER BY salary DESC, id) AS rn FROM employees e) t
WHERE rn = 2;
-- Для N-го места: dense_rank() = N. Второй вариант так не обобщается.
Разбор вариантов
| Вариант | Сложность | Обобщается на N-й | Комментарий |
|---|---|---|---|
dense_rank() | сортировка O(n log n) | да, dr = N | дефолтный правильный ответ |
вложенный max() | два прохода O(n) | нет - на третьем месте превращается в нечитаемую матрёшку | самый быстрый; хорош, если оконных функций «нельзя» |
NOT EXISTS/count | O(n²) в худшем случае | да, = N-1 | красиво звучит, медленно работает |
row_number() | O(n log n) | да | другой ответ на другой вопрос - вторая строка, а не второе значение |
Что проверяют на самом деле
- Уточняешь ли требования. Кандидат, который сразу пишет
LIMIT 1 OFFSET 1, не задумываясь о дубликатах, отвечает хуже того, кто спросил «а если максимум встречается дважды?». - Понимаешь ли разницу
rank/dense_rank/row_number. Ради неё вопрос и задают. - Что вернётся, если сотрудников меньше двух. Оконные варианты вернут
ноль строк, вариант с
max()- одну строку со значением NULL. Разница критична для кода:QueryRow().Scan()в первом случае отдастsql.ErrNoRows, во втором - успешно просканирует NULL и упадёт, если приёмникint, а неsql.NullInt64. - Зачем вообще запрет на LIMIT. Ограничение искусственное: его ставят, чтобы вытащить
из тебя оконные функции. Скажи это прямо: «в продакшене я бы написал
ORDER BY salary DESC LIMIT 1 OFFSET 1с индексом по salary, но раз нельзя - вот вариант через dense_rank».
Ровно тот же шаблон плюс PARTITION BY dept_id:
dense_rank() OVER (PARTITION BY dept_id ORDER BY salary DESC) и фильтр
= 2. Это стандартное продолжение вопроса, и оно же показывает, почему
LIMIT/OFFSET не универсален: «второй в каждой группе» через
LIMIT без LATERAL вообще не выражается.
Что стоит запомнить
- NOT EXISTS выражает anti-join без ловушки NULL из NOT IN.
- Оконные функции ранжируют строки внутри группы; row_number, rank и dense_rank по-разному обрабатывают равные значения.
- Рекурсивный CTE состоит из начального набора и шага рекурсии; путь и лимит глубины защищают обход от циклов и ошибочных данных.
Частые ошибки
- Перед DELETE дубликатов сначала покажи строки, которые будут удалены, и зафиксируй правило выбора сохраняемой строки.
- ctid меняется и годится только для одноразовой операции, а не как постоянный идентификатор.
- Не используй LIMIT для топ-N внутри каждой группы: он ограничивает весь результат.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Топ-3 в каждой категории
Что делает запрос и почему LIMIT 3 не заменяет условие по rn?
WITH ranked AS (
SELECT category_id, product_id, revenue,
row_number() OVER (PARTITION BY category_id ORDER BY revenue DESC, product_id) AS rn
FROM product_sales
)
SELECT * FROM ranked WHERE rn <= 3;
Ответ и разбор
Оконная функция независимо нумерует товары внутри каждой категории, затем фильтр оставляет до трёх на категорию. LIMIT 3 ограничил бы весь набор тремя строками. product_id даёт детерминированный порядок при одинаковой выручке; если нужно включать все товары с равной третьей позицией, выбери rank или dense_rank с подходящей семантикой.
Код-ревью
2. Проверь удаление дублей
Автор хочет оставить самую раннюю запись на email, но запрос оставляет запись с наибольшим id. Исправь сортировку и поясни, почему ctid нельзя использовать как долгосрочное правило.
DELETE FROM users u
USING (SELECT id, row_number() OVER (PARTITION BY lower(email) ORDER BY id DESC) rn FROM users) d
WHERE u.id = d.id AND d.rn > 1;
Ответ и разбор
Если id растёт со временем и цель именно самая ранняя запись, сортируй по created_at ASC, id ASC и удаляй rn > 1. Сначала выполни SELECT с тем же ранжированием и проверь кандидатов. ctid - физический адрес версии строки, который меняется при UPDATE и переписывании таблицы, поэтому он не выражает бизнес-правило.
Применение
3. Найди вторую зарплату
Требуется второе по величине уникальное значение salary, а не вторая строка. Как различаются dense_rank и row_number для двух сотрудников с одинаковой максимальной зарплатой?
Ответ и разбор
При одинаковых максимальных зарплатах row_number назначит им позиции 1 и 2, ошибочно считая вторую строку вторым значением. dense_rank присвоит обоим ранг 1, а следующему уникальному значению ранг 2. Поэтому для второго уникального значения отфильтруй dense_rank() OVER (ORDER BY salary DESC) = 2.
Применение
4. Защити рекурсивный обход
В таблице employees из-за испорченных данных образовался цикл ссылок менеджеров. Назови два условия остановки для рекурсивного CTE и что каждое защищает.
Ответ и разбор
Храни path пройденных id и не добавляй строку, если её id уже входит в path: это обнаруживает повтор узла в цикле. Добавь верхнюю границу depth как страховку от неожиданно глубоких или некорректных данных. Индекс по manager_id ускорит поиск детей на каждом шаге.