Чтобы выбрать топ-N записей в каждой группе на SQL (top-N per group), используют оконную функцию ROW_NUMBER (или RANK / DENSE_RANK) с PARTITION BY в подзапросе или CTE, а затем фильтруют по номеру строки во внешнем запросе. Прямой WHERE rn <= N рядом с оконной функцией не работает — её нельзя ставить в WHERE того же уровня, поэтому нужен второй уровень запроса. Базовый шаблон:
SELECT *
FROM (
SELECT
*,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn
FROM products
) t
WHERE rn <= 3;
Это вернёт по три самых дорогих товара в каждой категории. Дальше разберём пошагово: чем отличаются ROW_NUMBER, RANK и DENSE_RANK, как переписать через CTE, когда выручит LATERAL JOIN и что выбрать ради производительности.
Что такое задача «топ-N в группе»?
Top-N per group — это запрос, который для каждой группы (категория, пользователь, регион, день) возвращает не одну агрегатную цифру, а несколько лучших по какому-то критерию строк целиком. Примеры, которые реально спрашивают на собесах:
- 3 самых дорогих товара в каждой категории;
- последний заказ каждого клиента;
- топ-5 страниц по просмотрам за каждый день;
- 2 сотрудника с максимальной зарплатой в каждом отделе;
- первая и вторая покупка каждого пользователя.
Ключевое слово — «в каждой группе». Обычный GROUP BY тут не поможет: он схлопывает группу в одну строку и теряет остальные колонки. Нам нужны полные строки, отсортированные внутри группы. Именно для этого существуют оконные функции с PARTITION BY.
Чем ROW_NUMBER отличается от RANK и DENSE_RANK?
Все три — ранжирующие оконные функции. Они присваивают номер строкам внутри партиции по заданному ORDER BY. Разница только в том, как они ведут себя при одинаковых значениях (ties).
| Функция | Дубли значений | Пропуски номеров | Пример при значениях 100, 100, 90 |
|---|---|---|---|
| ROW_NUMBER | разные номера всегда | нет | 1, 2, 3 |
| RANK | одинаковый номер | есть | 1, 1, 3 |
| DENSE_RANK | одинаковый номер | нет | 1, 1, 2 |
Простое правило выбора:
- ROW_NUMBER — нужно ровно N строк, дубли при одинаковом значении не важны (берём любую). Подходит для «последний заказ каждого клиента».
- RANK — нужны все строки до N-го места включительно, и при равенстве пусть будет несколько на одном месте. «Топ-3 зарплаты, но если две одинаковых на 1-м месте — обе в выдачу».
- DENSE_RANK — нужны строки с N уникальными значениями метрики, без пропусков ранга. «3 разных уровня цены, а сколько товаров на каждом — неважно».
Сравнить поведение на живых данных можно прямо в SQL-тренажёре — там есть категория на оконные функции с автопроверкой.
Как решить топ-N через ROW_NUMBER (пошагово)?
Разберём классику: 3 самых дорогих товара в каждой категории. Решаем по шагам.
Шаг 1: пронумеровать строки внутри группы
Добавляем оконную функцию. PARTITION BY category делит таблицу на группы по категории, ORDER BY price DESC сортирует внутри каждой группы по убыванию цены. ROW_NUMBER присваивает номер 1 самому дорогому товару группы:
SELECT
id,
name,
category,
price,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn
FROM products;
На этом шаге строки ещё не отфильтрованы — мы только разметили их номерами. Каждая категория получила свою независимую нумерацию от 1.
Шаг 2: обернуть в подзапрос
Оконную функцию нельзя использовать в WHERE того же уровня, потому что окна вычисляются после WHERE. Поэтому заворачиваем запрос из шага 1 в подзапрос (производную таблицу) и даём ему алиас:
SELECT *
FROM (
SELECT
id, name, category, price,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn
FROM products
) ranked;
Теперь rn — обычная колонка внешнего запроса, по ней можно фильтровать.
Шаг 3: отфильтровать по номеру
Во внешнем запросе оставляем строки с номером не больше N:
SELECT id, name, category, price
FROM (
SELECT
id, name, category, price,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn
FROM products
) ranked
WHERE rn <= 3
ORDER BY category, price DESC;
Готово: по три самых дорогих товара в каждой категории, отсортированные читаемо. Финальный ORDER BY category, price DESC нужен, потому что после фильтрации порядок строк не гарантирован.
Когда использовать CTE вместо подзапроса?
CTE (Common Table Expression, выражение WITH) делает ровно то же самое, что подзапрос, но читается сверху вниз и не вкладывает запросы друг в друга. Тот же топ-3, переписанный через CTE:
WITH ranked AS (
SELECT
id, name, category, price,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn
FROM products
)
SELECT id, name, category, price
FROM ranked
WHERE rn <= 3
ORDER BY category, price DESC;
Что выбрать:
- CTE — когда запрос сложный, когда промежуточный результат нужен в нескольких местах, или просто ради читаемости. На собеседовании CTE часто выглядит аккуратнее и оценивается выше.
- Подзапрос — для коротких одноразовых запросов, когда вложенность всего на один уровень.
По производительности в PostgreSQL разницы практически нет: начиная с версии 12 CTE инлайнятся в план запроса так же, как подзапросы. В старых версиях (до 12) CTE были «оптимизационным барьером» и могли материализоваться в память — это важно помнить, если работаете на legacy-базе. Подробнее про оба приёма и их разницу — в курсе SQL с нуля.
Как взять только одну верхнюю строку на группу (top-1)?
Частный и очень частый случай — N = 1: последний заказ каждого клиента, последнее событие по сессии, самая свежая цена по товару. Логика та же, просто WHERE rn = 1:
WITH last_order AS (
SELECT
*,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
FROM orders
)
SELECT *
FROM last_order
WHERE rn = 1;
В PostgreSQL для top-1 есть более короткий и часто более быстрый приём — DISTINCT ON:
SELECT DISTINCT ON (customer_id) *
FROM orders
ORDER BY customer_id, created_at DESC;
DISTINCT ON (customer_id) оставляет первую строку для каждого customer_id в порядке, заданном ORDER BY. Колонки в DISTINCT ON обязаны идти первыми в ORDER BY. Это нестандартный синтаксис только для PostgreSQL — в MySQL, SQLite или ClickHouse он не сработает, там остаётся вариант с ROW_NUMBER.
Когда выручает LATERAL JOIN?
LATERAL JOIN (в PostgreSQL — CROSS JOIN LATERAL или LEFT JOIN LATERAL) позволяет для каждой строки левой таблицы выполнить отдельный коррелированный подзапрос. Это другой способ собрать top-N: вместо того чтобы ранжировать всю таблицу фактов и потом фильтровать, мы идём по списку групп и для каждой подтягиваем её топ.
Допустим, есть таблица categories и таблица products. Берём по 3 товара на категорию:
SELECT c.name AS category, p.name, p.price
FROM categories c
CROSS JOIN LATERAL (
SELECT name, price
FROM products
WHERE products.category_id = c.id
ORDER BY price DESC
LIMIT 3
) p;
Когда LATERAL выгоднее оконной функции:
- Групп мало, а строк в группе много. Если категорий 50, а товаров миллионы, LATERAL c индексом на
(category_id, price DESC)достанет ровно по 3 строки на категорию через быстрый index scan иLIMIT, не сортируя всю таблицу. - Нужен LIMIT по каждой группе с разными параметрами.
Когда LATERAL проигрывает:
- Групп очень много (миллионы пользователей) — тогда миллион вложенных подзапросов медленнее одного прохода с ROW_NUMBER.
Похожие приёмы с коррелированными подзапросами часто встречаются в тестовых заданиях на позицию аналитика.
Что выбрать ради производительности?
Универсального ответа нет — зависит от соотношения «число групп / число строк в группе» и наличия индексов. Ориентир:
| Сценарий | Лучший приём | Почему |
|---|---|---|
| top-1 на группу, PostgreSQL | DISTINCT ON | короче, использует индекс на (group, sort) |
| N строк, групп много, строк в группе мало | ROW_NUMBER в CTE | один проход, окно дешевле миллиона подзапросов |
| N строк, групп мало, строк в группе миллионы | LATERAL + LIMIT + индекс | index scan берёт ровно N, не сортирует всё |
| нужны ties (одинаковые на N-м месте) | RANK / DENSE_RANK | сохраняет все строки нужного ранга |
Главный рычаг производительности — индекс под ORDER BY оконной функции. Для запроса с PARTITION BY category ORDER BY price DESC индекс (category, price DESC) позволяет PostgreSQL читать строки уже в нужном порядке и не делать дорогой sort. Без индекса планировщик отсортирует всю таблицу — на больших данных это бутылочное горлышко.
Что проверить через EXPLAIN ANALYZE:
- есть ли
Sortс большимactual time— признак отсутствия подходящего индекса; WindowAgg— узел оконной функции, нормален;- для LATERAL — что внутренний подзапрос использует
Index Scan, а неSeq Scan.
Какие ошибки делают на собеседовании?
Типичные грабли, на которых заваливаются кандидаты:
- Фильтр оконной функции в WHERE того же уровня. Запрос
WHERE ROW_NUMBER() OVER (...) <= 3упадёт с ошибкой — окно нельзя ставить в WHERE. Нужен подзапрос или CTE. - ROW_NUMBER там, где нужен RANK. Если задача требует «все, кто на топ-3 местах, включая равных», ROW_NUMBER отрежет вторую строку с одинаковым значением. Уточняйте, как обрабатывать ties.
- Забыть финальный ORDER BY. После фильтрации
WHERE rn <= Nпорядок строк в результате не гарантирован, хотя rn уже посчитан. Без явногоORDER BYвывод может выглядеть перемешанным. - DISTINCT ON без согласованного ORDER BY. Колонки из
DISTINCT ONобязаны идти первыми вORDER BY, иначе ошибка или неверный результат. - Игнорировать NULL в сортировке.
ORDER BY price DESCв PostgreSQL ставит NULL первыми по умолчанию (NULLS FIRSTдля DESC) — топ может неожиданно начаться с NULL. ДобавляйтеNULLS LAST, если это не то, что нужно.
Отработать ровно эти ситуации с разбором можно в формате AI мок-интервью — интервьюер задаёт follow-up по ties и производительности, как на реальном собесе. А подборка похожих задач лежит в разделе вопросы с собесов.
Краткая шпаргалка
- top-N per group = оконная функция (ROW_NUMBER / RANK / DENSE_RANK) + PARTITION BY + фильтр во внешнем запросе.
- ROW_NUMBER — ровно N строк; RANK — с пропусками номеров при ties; DENSE_RANK — без пропусков.
- Оконную функцию нельзя фильтровать в WHERE того же уровня → подзапрос или CTE.
- top-1 в PostgreSQL короче через DISTINCT ON.
- Мало групп + много строк в группе → LATERAL + LIMIT + индекс.
- Производительность тянет индекс
(group_column, sort_column)под ORDER BY окна.
Потренируйся бесплатно: реши задачи на top-N per group в SQL-тренажёре — там настоящий PostgreSQL в браузере с автопроверкой и подсказками. Когда захочешь систематизировать оконные функции и CTE, пройди бесплатные уроки курса SQL с нуля, а перед собеседованием прогони себя через AI мок-интервью.