voidlearn
 

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 тай-брейкером, иначе «второй заказ клиента» будет прыгать между запусками.
  • Проговаривай план вслух. На этих задачах интервьюер часто спрашивает «а какой индекс нужен», и ответ на него стоит на голову выше самого решения.

Вопросы

7
Суть: два правильных способа - NOT 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» в приложении не работает при конкурентных запросах: два процесса одновременно не найдут запись и оба вставят. Гарантию даёт только констрейнт.

Суть: два шага - агрегация выручки по категориям за период, затем отбор трёх лучших. Если нужен просто топ-3 глобально, хватит 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.

Суть: каноническое решение - CTE с 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 + WindowAggNested 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 оставляет ровно одну строку на группу.

Суть: классика на замену коррелированного подзапроса оконной функцией. Коррелированный вариант выполняет агрегат для каждой строки - O(n·m); окно считает средние за один проход - O(n log n) на сортировку и всё. Отвечать надо оконным вариантом, а коррелированный упомянуть как «то, как это обычно пишут, и почему это плохо».
-- Правильно: оконная функция, один проход по таблице
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, чтобы сортировка для окна не ушла на диск.
Суть: рекурсивный CTE состоит из якоря (стартовая строка), 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;

Как это выполняется на самом деле

  1. Выполняется якорь, результат кладётся в рабочую таблицу и в итоговую.
  2. Рекурсивная часть выполняется, но subordinates внутри неё означает только результат предыдущей итерации, а не весь накопленный результат. На этой тонкости и ловят.
  3. Новые строки добавляются к итогу и становятся рабочей таблицей следующего шага.
  4. Шаги повторяются, пока очередной не вернёт ноль строк.

Защита от циклов - три способа

  • Массив пути path + WHERE NOT e.id = ANY(s.path) точнее всех: отсекает именно повторное посещение и заодно даёт готовый путь для сортировки дерева.
  • Ограничение глубины WHERE depth < 50 - грубо, но надёжно, и защищает ещё и от «слишком глубокого» легального дерева.
  • Синтаксис CYCLE (PostgreSQL 14+): ... CYCLE id SET is_cycle USING path. Это стандартный SQL, он делает то же самое декларативно.
Три ловушки рекурсивных CTE
  • 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 - когда читается часто.

Суть: ключевой вопрос - что считать «вторым» при дубликатах. Если 100 000 и 100 000 занимают первое место, второе - это 90 000 (второе значение, 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/countO(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 ускорит поиск детей на каждом шаге.