open atlas
↑ К треку
SQL и PostgreSQL вглубь SQL · 04 · 04

Ранжирующие функции и top-N на группу

ROW_NUMBER, RANK и DENSE_RANK различаются только обработкой ничьих. ROW_NUMBER — рабочая лошадка для top-N на группу и дедупликации: ранжируй в подзапросе, затем фильтруй rn <= N — паттерн, заменяющий дюжину коррелированных подзапросов.

SQL Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

«Топ-3 продавца на регион». Джун тянется к GROUP BY и LIMIT и обнаруживает, что LIMIT применяется ко всему результату, а не на группу — нет LIMIT 3 PER region. Он пробует коррелированный подзапрос, считающий «сколько в этом регионе обошли меня», что работает и бежит за O(n²), плавясь на первой реальной таблице. Однострочный сеньорский ответ — ROW_NUMBER() OVER (PARTITION BY region ORDER BY total DESC) в подзапросе, затем WHERE rn <= 3. Ранжирующие функции — это как делать top-N на группу, дедупликацию и пагинацию без квадратичной боли.

Три ранжирующих функции, одно различие: ничьи

Все три назначают позицию внутри упорядоченной партиции. Они согласны во всём, кроме того, что происходит при ничьей двух строк по значению ORDER BY:

  • ROW_NUMBER() — строгие 1, 2, 3, 4… Ничьих нет; если две строки равны, он всё равно даёт им разные номера (порядок между ними произволен, если не добавить колонку-тайбрейкер). Используй, когда нужна уникальная последовательность: top-N, дедупликация, пагинация.
  • RANK() — ничьи получают одинаковый ранг, затем следующий ранг пропускается. Две строки в ничьей за 1-е место обе 1, а следующая — 3 (2 исчезает). Это «олимпийское» ранжирование: два золота, без серебра.
  • DENSE_RANK() — ничьи получают одинаковый ранг, но следующий ранг не пропускается. Две 1, затем 2. Используй для «сколько различных уровней есть», например различных ценовых тиров.

Все три дают одинаковый результат при отсутствии ничьих — различие важно только в граничном случае, и именно тогда требование обычно неоднозначно. Когда встречаешь ранжирующую функцию в запросе, спроси себя: «что должно произойти, если две строки делят первое место?» — ответ и выбирает функцию.

SELECT region, salesperson, total,
       ROW_NUMBER() OVER (PARTITION BY region ORDER BY total DESC) AS rn,
       RANK()       OVER (PARTITION BY region ORDER BY total DESC) AS rnk,
       DENSE_RANK() OVER (PARTITION BY region ORDER BY total DESC) AS drnk
FROM region_sales;

Top-N на группу: канонический паттерн

Нельзя фильтровать по оконной функции в WHERE того же SELECT — оконные функции вычисляются после WHERE, в более поздней логической фазе. Поэтому паттерн всегда двухуровневый: посчитай ранг в подзапросе (или CTE), затем отфильтруй ранг во внешнем запросе.

SELECT region, salesperson, total
FROM (
  SELECT region, salesperson, total,
         ROW_NUMBER() OVER (PARTITION BY region ORDER BY total DESC) AS rn
  FROM region_sales
) ranked
WHERE rn <= 3
ORDER BY region, rn;

Это ответ на «топ-3 на регион», «5 последних заказов на клиента», «самый высокооплачиваемый сотрудник в каждом отделе». Выбор ранжирующей функции тут важен: ROW_NUMBER даёт ровно N строк на группу (ничьи разрешены произвольно); RANK может вернуть больше N, если есть ничья на границе (трое в ничьей за 3-е место все имеют ранг 3 и все проходят rnk <= 3). Для «ровно топ-3» используй ROW_NUMBER с детерминированным тайбрейкером; для «все, кто достиг топ-3-результата» используй RANK.

Дедупликация: оставить одну строку на ключ

Та же механика дедуплицирует. Чтобы схлопнуть дублирующиеся строки до самой новой на ключ, ранжируй по свежести и оставь rn = 1:

-- оставить самое свежее событие на пользователя, отбросить более старые дубли
SELECT user_id, ts, payload
FROM (
  SELECT user_id, ts, payload,
         ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) AS rn
  FROM events
) e
WHERE rn = 1;

PARTITION BY user_id делает каждого пользователя группой; ORDER BY ts DESC ставит самое новое первым; rn = 1 — это самая новая строка. Это чище и куда быстрее, чем гимнастика с DISTINCT ON или self-join, и обобщается: rn <= k оставляет k самых новых.

NTILE(n) — двоюродный брат для разбиения на корзины: он делит упорядоченную партицию на n примерно равных корзин и метит каждую строку 1…n — основа квартилей и перцентильных полос. PERCENT_RANK() и CUME_DIST() дают относительное положение строки дробью в [0, 1] — «эта продажа в 92-м перцентиле своего региона».

Частая ошибка

Баг номер один в ранжировании: фильтровать оконную функцию напрямую, WHERE ROW_NUMBER() OVER (...) <= 3. Postgres это отвергает — window functions are not allowed in WHERE — потому что окна вычисляются после WHERE в логическом конвейере (FROMWHEREGROUP BY → окно → SELECTORDER BY). Окна ещё не существует, когда работает WHERE. Нужно материализовать ранг на уровень ниже (подзапрос или CTE) и фильтровать его во внешнем запросе. По той же причине нельзя положить алиас из SELECT в WHERE: фаза, что его создаёт, ещё не отработала.

Викторина

Две строки в ничьей за верхний результат в регионе. Какая функция даёт им РАЗНЫЕ номера, а какая — ОДИНАКОВЫЙ ранг, но затем пропускает следующий номер?

Викторина

Почему нельзя написать WHERE ROW_NUMBER() OVER (PARTITION BY region ORDER BY total DESC) <= 3 напрямую?

Расставь шаги по порядку

Расставь шаги паттерна «топ-3 продавца на регион»:

  1. 1 Внутренний запрос: SELECT region, salesperson, total FROM region_sales
  2. 2 Добавь ROW_NUMBER() OVER (PARTITION BY region ORDER BY total DESC) AS rn
  3. 3 Оберни как подзапрос (или CTE), чтобы rn стал реальной колонкой
  4. 4 Внешний запрос фильтрует WHERE rn <= 3
  5. 5 ORDER BY region, rn, чтобы подать топ-3 на регион
Вспомните перед уходом
  1. 01
    Чем различаются ROW_NUMBER, RANK и DENSE_RANK при ничьей двух строк?
  2. 02
    Напиши форму паттерна top-N на группу и скажи, почему нужны два уровня запроса.
  3. 03
    Нужно ровно по одной строке на user_id, оставив самую свежую. Какая функция и фильтр?
Итог

Ранжирующие функции назначают позицию внутри упорядоченной партиции и различаются лишь политикой ничьих: ROW_NUMBER строго различен (1, 2, 3, 4), RANK повторяет ничью и затем пропускает (1, 1, 3), DENSE_RANK повторяет без пропуска (1, 1, 2). Это различие и есть весь выбор — бери ту, чьё поведение на ничьих совпадает с твоим требованием. Доминирующее применение — ROW_NUMBER для top-N на группу и дедупликации: поскольку оконные функции вычисляются после WHERE, считаешь ранг в подзапросе или CTE и затем фильтруешь rn <= N (топ N) или rn = 1 (оставить новейшее) во внешнем запросе — паттерн, заменяющий O(n²) коррелированные подзапросы одним линейным проходом. RANK — верный выбор, когда каждая строка в ничьей на отсечке должна проходить; NTILE разбивает партицию на равные срезы, а PERCENT_RANK/CUME_DIST выражают относительное положение строки дробью. Теперь, когда тянешься к LIMIT 3 на группу и замечаешь, что это не работает, — стоп: пиши ROW_NUMBER() OVER (PARTITION BY группа ORDER BY метрика DESC) во внутреннем запросе, затем фильтруй rn <= 3 снаружи. Следующий урок переходит от позиции к накоплению: накопительные итоги, скользящие средние, дельты LAG/LEAD и ловушка фрейма LAST_VALUE.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.