Чем ROW_NUMBER, RANK и DENSE_RANK отличаются на самом деле?
Коротко: все три раздают номера строкам по порядку, но по-разному ведут себя на одинаковых значениях (их называют ties, «ничьи»). ROW_NUMBER даёт каждой строке уникальный номер подряд — 1, 2, 3, — даже если значения равны, и разруливает ничью произвольно. RANK присваивает одинаковым значениям одинаковый ранг, но потом перепрыгивает через пропущенные места: 1, 1, 3. DENSE_RANK тоже даёт равным одинаковый ранг, но идёт плотно, без дыр: 1, 1, 2. Вот и вся суть. Дальше — как это выглядит на данных, когда какую брать и на чём чаще всего спотыкаются.
Я эти три функции использую чуть ли не каждый день: вытащить последний заказ пользователя, построить лидерборд менеджеров, разбить клиентов на квартили по выручке. И почти всегда вопрос сводится к одному: что делать с ничьими. Ответ на него и определяет выбор функции.
Как разница видна на одном примере?
Возьмём маленькую табличку sales с выручкой трёх менеджеров. Двое сделали одинаково — по 500, третий 300. Прогоним все три функции разом:
SELECT
manager,
revenue,
ROW_NUMBER() OVER (ORDER BY revenue DESC) AS rn,
RANK() OVER (ORDER BY revenue DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY revenue DESC) AS dns
FROM sales;
Результат:
| manager | revenue | rn | rnk | dns |
|---|---|---|---|---|
| Аня | 500 | 1 | 1 | 1 |
| Борис | 500 | 2 | 1 | 1 |
| Вера | 300 | 3 | 3 | 2 |
Смотрите на две строки по 500. ROW_NUMBER дал им 1 и 2 — просто потому что кому-то надо быть первым, и движок выбрал сам. RANK дал обоим 1, а потом отдал Вере не 2, а 3: два места «съедены» ничьёй, и второе место как будто пропущено. DENSE_RANK тоже дал обоим 1, но Вере досталось 2 — ранг идёт вплотную, дыр нет.
Отсюда и запоминающийся паттерн: на паре одинаковых значений RANK выдаёт 1, 1, 3, DENSE_RANK — 1, 1, 2, а ROW_NUMBER — 1, 2, 3. Если проговорить это вслух пару раз, на собеседовании уже не собьётесь. Хотите пощупать руками — забейте эти три строки в SQL-тренажёр и поменяйте значения: сделайте три одинаковых, четыре, добавьте ещё уровень. Разница въедается в память быстрее, чем от чтения.
Почему без правильного ORDER BY ранг превращается в лотерею?
ORDER BY внутри OVER (...) — это не украшение, а сердце ранжирования. Именно он задаёт, по какому признаку строки выстраиваются в очередь. Нет ORDER BY — нет и осмысленного ранга: в большинстве СУБД без него вы либо получите ошибку, либо всем строкам присвоится один и тот же номер.
Тонкий момент вот в чём. ROW_NUMBER обязан выдать уникальные номера, но если по ключу сортировки есть ничьи, порядок внутри ничьей движок выбирает на своё усмотрение. Сегодня Аня получит 1, а Борис 2; завтра после VACUUM, смены плана запроса или просто другого железа — наоборот. Запрос «дай последний заказ каждого пользователя» с сортировкой только по дате — классическая мина: если у человека два заказа в одну секунду, вы будете стабильно-нестабильно вытаскивать то один, то другой.
-- Ненадёжно: при равной дате порядок не определён
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC)
RANK и DENSE_RANK от этого не страдают в смысле значения ранга — равным они честно дают одинаковый ранг. Но если вы потом фильтруете WHERE rnk = 1, чтобы получить «одну строку на группу», а ничья реальная, вернётся не одна строка, а несколько. Тоже сюрприз, только с другой стороны.
Как сделать tie-break детерминированным?
Лечится добавлением второго (а если надо — третьего) ключа сортировки, который однозначно разводит ничьи. Обычно это уникальный или почти уникальный столбец — id, точный timestamp, суррогатный ключ.
-- Надёжно: при равной дате добит по order_id
SELECT *
FROM (
SELECT
order_id,
user_id,
amount,
created_at,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY created_at DESC, order_id DESC
) AS rn
FROM orders
) t
WHERE rn = 1;
Теперь при совпадении даты выигрывает больший order_id — правило явное, воспроизводимое, объяснимое. Я держу простое правило: если по функции ранжирования потом фильтрую и жду ровно одну строку на группу, ключ сортировки обязан быть уникальным. Не уверен, что уникален, — дописываю id в хвост ORDER BY. Стоит это ноль усилий, а от плавающих багов спасает намертво.
Для RANK и DENSE_RANK второй ключ работает иначе: он не разбивает ничью (равные значения по-прежнему получат равный ранг по первому ключу), а лишь упорядочивает строки внутри неё. Если вам нужны именно места с корректным дележом — это правильное поведение, ничего дописывать не надо.
Когда брать ROW_NUMBER для топ-N в группе?
ROW_NUMBER — ваш инструмент, когда нужно ровно N строк на каждую группу, без дублей из-за ничьих. «Три самых дорогих заказа каждого клиента», «последнее событие каждой сессии», «первый платёж каждого пользователя» — всё это про ROW_NUMBER.
SELECT user_id, order_id, amount
FROM (
SELECT
user_id,
order_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY amount DESC, order_id DESC
) AS rn
FROM orders
) t
WHERE rn <= 3;
Ключевое слово — PARTITION BY: он превращает единый рейтинг в отдельный рейтинг внутри каждой группы, счётчик сбрасывается на 1 для каждого user_id. Если возьмёте здесь RANK вместо ROW_NUMBER, при двух заказах с одинаковой суммой можете получить не три строки на клиента, а четыре или пять — потому что ничья раздувает выборку. Когда вам нужно строго «не больше трёх», это ошибка. Задачи ровно такого вида часто попадаются на реальных собеседованиях — их полно в разделе вопросов и разборов и в наборе практических заданий.
Когда RANK, а когда DENSE_RANK?
Оба нужны, когда ничьи должны делить одно место — как в спорте. Разница только в том, что происходит после дележа.
RANK берите, когда важна «позиция с учётом обгона»: сколько человек реально впереди тебя. Если два первых места заняты, третий по величине — это именно третье место, а не второе. Так считают призовые в турнирах, так честнее показывать «ты N-й из всех». Пример — «дай товары, которые входят в топ-3 по продажам, включая дележи мест»:
SELECT product, revenue, rnk
FROM (
SELECT
product,
revenue,
RANK() OVER (ORDER BY revenue DESC) AS rnk
FROM product_sales
) t
WHERE rnk <= 3; -- при ничьих может вернуться больше 3 строк — и это ожидаемо
DENSE_RANK берите, когда нужны уровни без дыр — количество различных значений, а не позиция. «Разбей зарплаты на грейды: топовая ставка — грейд 1, следующая по величине — грейд 2, сколько бы человек ни сидело на топовой». Или «покажи, к какому по порядку ценовому уровню относится товар». Дыры тут только мешают, поэтому плотный ранг подходит идеально.
SELECT
employee,
salary,
DENSE_RANK() OVER (ORDER BY salary DESC) AS grade
FROM employees;
Практическое правило, которым делюсь с джунами: спроси себя, что ты меришь — «сколько людей впереди» или «на каком по счёту уровне». Первое — RANK, второе — DENSE_RANK. А если ничьи вообще нужно проигнорировать и получить уникальную нумерацию — ROW_NUMBER.
Причём здесь NTILE и PERCENT_RANK?
Это соседи по семейству ранжирующих функций, просто отвечают на другой вопрос — не «какое место», а «в какой части распределения».
NTILE(n) режет упорядоченный набор на n примерно равных корзин и проставляет каждой строке номер корзины. Классика — квартили и децили: разбить клиентов на 4 группы по выручке, чтобы работать с верхними 25% отдельно.
SELECT
customer_id,
revenue,
NTILE(4) OVER (ORDER BY revenue DESC) AS quartile -- 1 = топ-25%, 4 = нижние 25%
FROM customer_revenue;
Важный нюанс: NTILE делит по количеству строк, а не по значению. Если строк не поровну на n, первые корзины получат на одну строку больше. И одинаковые значения NTILE может раскидать в разные корзины — на границе бакетов такое бывает. Для честного распределения это нормально, но если пугает, что два клиента с равной выручкой попали в разные квартили, вы теперь знаете почему.
PERCENT_RANK даёт относительную позицию от 0 до 1. Формула простая, словами: (ранг строки минус 1) делить на (число строк минус 1), где ранг считается как в RANK. У самого верхнего значения выходит 0, у самого нижнего — 1, остальные размазаны между ними. Удобно, когда нужен перцентиль без привязки к конкретному числу мест: «этот пользователь по активности выше 90% остальных». Рядом живёт CUME_DIST — кумулятивная доля, чуть другая арифметика, но идея та же.
Общая логика всего семейства: ROW_NUMBER/RANK/DENSE_RANK отвечают на «какое место», NTILE — «в какую группу из n», PERCENT_RANK/CUME_DIST — «в каком проценте распределения». Когда держишь это разделение в голове, выбор функции перестаёт быть гаданием. Разложенные определения и синтаксис удобно держать под рукой в SQL-справочнике.
Какие ошибки с ранжированием всплывают на собеседовании?
Самая частая — фильтровать по оконной функции прямо в WHERE. Так нельзя: оконные функции считаются после WHERE, поэтому WHERE rn <= 3 в том же запросе, где объявлен rn, упадёт с ошибкой. Оборачивайте в подзапрос или CTE и фильтруйте снаружи — как во всех примерах выше.
Вторая — путать «место» и «уникальный номер». Если задача звучит «топ-3 без дублей» — это ROW_NUMBER. Если «топ-3 мест, дележи допустимы» — RANK. Услышать разницу в формулировке важнее, чем помнить синтаксис.
Третья — забыть PARTITION BY, когда рейтинг нужен внутри групп. Без него счётчик не сбрасывается, и вы ранжируете всю таблицу как одну кучу вместо «по каждому пользователю отдельно».
Четвёртая — недетерминированный ROW_NUMBER из-за неуникального ORDER BY. Про добивание вторым ключом я уже говорил, но на собеседовании этот момент любят проверять отдельным вопросом «а что если даты совпадают?». Готовый ответ про order_id в хвосте сортировки экономит нервы.
И пятая, чисто из практики: NTILE на маленьких выборках ведёт себя неинтуитивно — на 7 строках и NTILE(4) корзины будут неравными, и это не баг. Ранжирующие функции почти всегда живут внутри более крупных задач — воронки, когорты, ретеншен, — так что тренировать их лучше на живых сценариях. Хорошая пачка таких сценариев есть в разборах кейсов, а базовые метрики вроде DAU как раз строятся на оконных подсчётах поверх событий.
Что забрать с собой
Три функции — один вопрос: что делать с одинаковыми значениями. ROW_NUMBER игнорирует ничьи и нумерует подряд (1, 2, 3) — берите для строгого топ-N. RANK даёт равным одно место и пропускает следующие (1, 1, 3) — берите, когда меряете «сколько впереди». DENSE_RANK даёт равным одно место и идёт плотно (1, 1, 2) — берите для уровней без дыр. Всегда задавайте осмысленный ORDER BY, а для ROW_NUMBER добивайте его уникальным ключом. NTILE и PERCENT_RANK — про долю в распределении, а не про место.
Всё это встаёт в голову только руками. Возьмите свой рабочий датасет с заказами или событиями, напишите все три функции в одном SELECT и посмотрите, где расходятся цифры — пройти путь от JOIN до оконных функций можно по шагам, а разбор паттерна «топ-N в группе» и десятки смежных задач с проверкой открываются в Pro, если захочется закрепить не на одном примере, а на потоке.