
При работе с данными в SQL рано или поздно возникает задача ранжирования: топ-5 продуктов по продажам, рейтинг сотрудников по KPI, распределение клиентов по категориям.
На первый взгляд RANK() и DENSE_RANK() делают почти одно и то же. На тестовых данных разница может быть вообще незаметна. Но в проде именно здесь часто начинаются ошибки: — топ-3 внезапно возвращает 5 строк; — дашборд "врёт"; — backend-логика начинает вести себя не так, как ожидалось; — запрос, который вчера работал быстро, сегодня уходит в disk spill.
Две самые популярные функции для ранжирования — RANK() и DENSE_RANK(). Ниже разберём, чем они отличаются, где именно ошибаются разработчики и аналитики, и что важно понимать: не только что делает оконная функция, но и сколько она стоит на больших объёмах данных.
1. Как работает RANK()
RANK() присваивает каждой строке ранг в пределах окна, но при появлении одинаковых значений пропускает последующие позиции.
Аналогия: представьте Олимпийские игры. Если два спортсмена финишировали одновременно и оба получили золото — серебра не будет. Следующий спортсмен получит бронзу (3-е место), хотя фактически стал вторым по счёту.
Пример:
Значения | Результат |
|---|---|
100, 90, 90, 80 | 1, 2, 2, 4 |
2. Как работает DENSE_RANK()
DENSE_RANK() также присваивает одинаковый ранг дублирующимся строкам, но без пропуска позиций. Ранги идут плотно: 1, 2, 2, 3, 4 — никаких "дыр".
Аналогия: школьная система оценок. Два ученика с одинаковым баллом получают одну и ту же позицию, но следующий ученик идёт сразу за ними — без пустых мест.
Пример:
Значения | Результат |
|---|---|
100, 90, 90, 80 | 1, 2, 2, 3 |
3. Практический пример
Таблица products (product_name, sales_amount):
product_name | sales_amount |
|---|---|
Ноутбук | 150000 |
Смартфон | 95000 |
Планшет | 95000 |
Наушники | 42000 |
Мышь | 18000 |
SQL-запрос:
SELECT product_name, sales_amount, RANK() OVER (ORDER BY sales_amount DESC) AS rank_sales, DENSE_RANK() OVER (ORDER BY sales_amount DESC) AS dense_rank_sales FROM products ORDER BY sales_amount DESC;
Результат:
product_name | sales_amount | rank_sales | dense_rank_sales | Комментарий |
|---|---|---|---|---|
Ноутбук | 150000 | 1 | 1 | Максимальные продажи |
Смартфон | 95000 | 2 | 2 | |
Планшет | 95000 | 2 | 2 | Оба получают 2 |
Наушники | 42000 | 4 | 3 |
|
Мышь | 18000 | 5 | 4 |
Ключевой момент: RANK() пропустил позицию 3, а DENSE_RANK() продолжил с 3.
4. А ещё есть ROW_NUMBER()
Для полноты картины стоит упомянуть третью функцию из этого семейства — ROW_NUMBER().
SELECT product_name, sales_amount, ROW_NUMBER() OVER (ORDER BY sales_amount DESC) AS row_num, RANK() OVER (ORDER BY sales_amount DESC) AS rank_sales, DENSE_RANK() OVER (ORDER BY sales_amount DESC) AS dense_rank_sales FROM products ORDER BY sales_amount DESC;
Результат:
product_name | sales_amount | row_num | rank_sales | dense_rank_sales |
|---|---|---|---|---|
Ноутбук | 150000 | 1 | 1 | 1 |
Смартфон | 95000 | 2 | 2 | 2 |
Планшет | 95000 | 3 | 2 | 2 |
Наушники | 42000 | 4 | 4 | 3 |
Мышь | 18000 | 5 | 5 | 4 |
ROW_NUMBER() всегда даёт уникальный номер — даже для дубликатов. Порядок внутри группы дубликатов при этом не определён и зависит от СУБД и плана выполнения. Поэтому при использовании ROW_NUMBER() в реальных запросах лучше задавать детерминированную сортировку, например по двум полям.
5. Когда использовать каждую функцию
RANK() — выбирайте, когда:
важно отразить реальный "пропуск" позиции;
вы строите спортивные рейтинги или турнирные таблицы;
пользователь должен видеть, что позиция была разделена несколькими участниками;
нужна совместимость с классической системой рангов.
DENSE_RANK() — выбирайте, когда:
нужны топ-N категории без дыр;
данные используются для группировки по уровням;
вы строите аналитические отчёты, сегменты, ценовые группы;
важно, чтобы количество уникальных рангов совпадало с количеством уникальных значений.
ROW_NUMBER() — выбирайте, когда:
нужна пагинация;
требуется дедупликация;
нужно выбрать ровно одну запись из группы;
каждой строке нужен гарантированно уникальный номер.
6. Типичная ловушка: топ-N фильтрация
Представьте: вам нужны топ-3 продукта. Вы пишете:
SELECT * FROM ( SELECT product_name, sales_amount, RANK() OVER (ORDER BY sales_amount DESC) AS rnk FROM products ) ranked WHERE rnk <= 3;
При наличии дубликатов на 3-й позиции этот запрос может вернуть больше 3 строк.
Это не баг — это корректное поведение RANK().
Именно здесь часто ломается прод. На тестовых данных всё может выглядеть нормально, потому что распределение значений маленькое и без дублей. Но в боевых таблицах, особенно в продажах, KPI, финансовых витринах, скорингах и аналитических отчётах, дубликаты — обычное дело.
Типичный кейс:
в backend нужен строгий top-5 для выдачи на UI;
разработчик использует
RANK();в проде два или три товара делят одну и ту же позицию;
фронт получает 7 элементов вместо 5;
дальше ломаются сетка, пагинация или бизнес-ограничения.
Если вам нужно:
ровно 3 строки → используйте
ROW_NUMBER();все строки, входящие в топ-3 уровня → используйте
DENSE_RANK();честное спортивное ранжирование → используйте
RANK().
7. Использование с PARTITION BY
Все три функции поддерживают PARTITION BY — это позволяет ранжировать данные независимо внутри каждой группы.
SELECT category, product_name, sales_amount, DENSE_RANK() OVER ( PARTITION BY category ORDER BY sales_amount DESC ) AS rank_within_category FROM products;
Это особенно полезно для отчётов типа "топ-3 продукта в каждой категории".
Пример логики:
category | product_name | sales_amount | rank_within_category |
|---|---|---|---|
Ноутбуки | Ноутбук Pro | 150000 | 1 |
Ноутбуки | Ноутбук Air | 120000 | 2 |
Смартфоны | Смартфон X | 95000 | 1 |
Смартфоны | Смартфон Y | 95000 | 1 |
Смартфоны | Смартфон Lite | 50000 | 2 |
Но именно с PARTITION BY начинаются дополнительные нюансы производительности:
если partitions слишком много — растёт overhead;
если partitions мало, но они огромные — сортировка становится дорогой;
если поле для
PARTITION BYвысококардинальное, можно получить очень тяжёлый план выполнения.
8. Анти-паттерны, которые часто встречаются в реальных запросах
Ниже — набор типичных ошибок, которые на small data почти незаметны, но на production объёмах быстро становятся проблемой.
1. Использовать RANK() там, где нужен strict top-N
Если бизнес хочет ровно 10 строк, RANK() — плохой выбор. Он может вернуть больше записей при дубликатах.
2. Использовать ROW_NUMBER() без детерминированного ORDER BY
Такой запрос формально работает, но может давать разный результат между запусками. Особенно неприятно это проявляется при дедупликации.
Плохо:
ROW_NUMBER() OVER (ORDER BY sales_amount DESC)
Лучше:
ROW_NUMBER() OVER ( ORDER BY sales_amount DESC, product_name ASC )
3. Делать PARTITION BY по высококардинальному полю без необходимости
Например, по user_id, если пользователей миллионы. Это не всегда ошибка логически, но очень часто — ошибка архитектурно и с точки зрения стоимости запроса.
4. Применять оконную функцию ко всему датасету, когда можно сначала сузить выборку
Если можно отфильтровать данные до последних 30 дней, это лучше сделать до оконной функции, а не после неё.
9. Что важно понимать (под капотом)
На уровне продакшена и больших данных поведение оконных функций — это не только логика, но и стоимость выполнения (cost).
Сортировка — ключевая операция
Все функции RANK(), DENSE_RANK(), ROW_NUMBER() требуют сортировки:
ORDER BY sales_amount DESC
Это означает:
O(N log N) по времени
потенциально большое потребление памяти
Если данных много → сортировка может:
не поместиться в memory
уйти в disk (spill)
В PostgreSQL это контролируется через work_mem.
На практике это значит простую вещь: запрос с оконной функцией — это уже не просто "ещё один SELECT". Это потенциально тяжёлая операция, особенно если она выполняется поверх миллионов строк, широких таблиц и сложных джоинов.
Spill to disk
Если объём данных превышает доступную память:
происходит external sort
данные пишутся во временные файлы на диск
Это резко увеличивает latency запроса.
Признаки:
увеличение execution time
появление
DiskвEXPLAIN ANALYZE
Типичный сигнал проблемы в PostgreSQL:
Sort Method: external merge Disk: 512MB Execution Time: 12.4s
То есть логика запроса может быть корректной, но стоимость — уже неприемлемой для продакшена.
PARTITION BY и большие данные
PARTITION BY логически разбивает данные, но физически это:
сортировка внутри каждой группы
либо перераспределение данных
На больших объёмах:
много partitions → overhead на сортировку
мало partitions → большие группы → дорого
Именно поэтому senior-подход — это не только "знаю синтаксис", но и вопрос: на каком объёме это будет работать, а на каком начнёт деградировать?
Распределённые системы (Greenplum, ClickHouse)
В MPP-системах ситуация усложняется.
Пример:
DENSE_RANK() OVER ( PARTITION BY category ORDER BY sales_amount DESC )
Что происходит:
Данные могут быть разбросаны по нодам
Нужно сгруппировать по
categoryЭто вызывает Shuffle / Redistribute Motion
Последствия:
network IO
serialization cost
возможный bottleneck
То есть оконная функция в распределённой системе — это уже не только CPU и memory, но ещё и сеть.
ClickHouse особенности
В ClickHouse:
оконные функции выполняются после aggregation stage
могут требовать полной сортировки набора
Если нет подходящего ORDER BY в таблице: → full scan + sort
На маленьких выборках это может быть незаметно. На аналитической витрине с большим количеством строк — уже очень заметно.
Как оптимизировать
1. Использовать правильные индексы (PostgreSQL):
CREATE INDEX idx_sales ON products (sales_amount DESC);
2. Минимизировать объём данных:
WHERE created_at >= now() - interval '30 days'
3. Уменьшать размер partition:
не делать
PARTITION BYпо высококардинальным полям без необходимости
4. Использовать pre-aggregation:
материализованные представления
витрины
5. Проверять EXPLAIN ANALYZE:
Sort MethodMemory vs DiskExecution time
Итог: шпаргалка
Функция | Что делает с дубликатами | Пропуск рангов | Уникальность |
|---|---|---|---|
| Даёт разные номера | Нет | Всегда |
| Даёт одинаковый ранг | Да | Только у уникальных значений |
| Даёт одинаковый ранг | Нет | Только у уникальных значений |
Правило выбора:
спорт, соревнования, "честный" рейтинг →
RANK();топ-N отчёты, уровни, категории →
DENSE_RANK();пагинация, дедупликация, нумерация →
ROW_NUMBER().
Вывод
На малых данных: → всё работает "из коробки"
На больших: → это тяжёлая операция: sort + memory + возможный spill + shuffle в MPP
