Ovaj članak trenutno je na ruskom — engleski prijevod je u izradi.
В SQL часто нужно не просто отсортировать строки, а разделить их на несколько примерно равных групп.
Например:
- выделить топ-25% клиентов по сумме покупок;
- разбить сотрудников на зарплатные квартили;
- разделить пользователей на 10 групп по активности;
- получить децили по выручке;
- построить ранговые сегменты для продуктовой аналитики.
Для таких задач есть оконная функция NTILE.
Она отвечает на вопрос:
Как разделить отсортированный список строк на n почти одинаковых по размеру групп?
Например:
NTILE(4)
делит строки на 4 группы.
Такие группы часто называют квартилями.
NTILE(10)
делит строки на 10 групп.
Такие группы называют децилями.
Важно: NTILE делит не диапазон значений, а сами строки.
То есть функция старается сделать так, чтобы в каждой корзине было примерно одинаковое количество строк.
Что делает NTILE простыми словами
Представим, что у нас есть 10 клиентов, отсортированных по сумме покупок от большего к меньшему.
client | spent
-------+------
A | 9000
B | 8000
C | 7000
D | 6000
E | 5000
F | 4000
G | 3000
H | 2000
I | 1000
J | 500
Если применить NTILE(4), SQL разделит эти 10 строк на 4 группы.
Так как 10 не делится на 4 ровно, группы будут такими:
bucket 1: 3 строки
bucket 2: 3 строки
bucket 3: 2 строки
bucket 4: 2 строки
Результат:
client | spent | quartile
-------+-------+---------
A | 9000 | 1
B | 8000 | 1
C | 7000 | 1
D | 6000 | 2
E | 5000 | 2
F | 4000 | 2
G | 3000 | 3
H | 2000 | 3
I | 1000 | 4
J | 500 | 4
Если сортировка идёт по убыванию:
ORDER BY spent DESC
то корзина 1 — это верхняя группа, то есть самые дорогие клиенты.
Если сортировка идёт по возрастанию:
ORDER BY spent ASC
то корзина 1 будет нижней группой, то есть самые маленькие значения.
Базовый синтаксис NTILE
NTILE — это оконная функция.
Базовый синтаксис:
NTILE(number_of_buckets) OVER (
ORDER BY column
)
Например:
NTILE(4) OVER (ORDER BY spent DESC)
означает:
отсортируй строки по spent от большего к меньшему и раздели их на 4 примерно равные группы.
Пример с таблицей клиентов:
SELECT
id,
email,
spent,
NTILE(4) OVER (ORDER BY spent DESC) AS quartile
FROM customers;
Результат:
id | email | spent | quartile
---+---------------+-------+---------
1 | anna@mail.com | 9000 | 1
2 | bob@mail.com | 8000 | 1
3 | kate@mail.com | 7000 | 1
4 | tom@mail.com | 6000 | 2
Здесь quartile показывает, в какую четверть отсортированного списка попала строка.
NTILE — это оконная функция
NTILE не схлопывает строки, как GROUP BY.
Он добавляет к каждой строке номер группы.
Например, обычный GROUP BY уменьшает количество строк:
SELECT
country,
SUM(amount) AS revenue
FROM orders
GROUP BY country;
А NTILE оставляет строки и добавляет к ним вычисленное значение:
SELECT
id,
email,
spent,
NTILE(4) OVER (ORDER BY spent DESC) AS quartile
FROM customers;
То есть NTILE работает «поверх» результата и присваивает каждой строке номер корзины.
Это удобно, когда нужно не потерять отдельные строки, а просто разметить их по сегментам.
Пример: квартили клиентов по сумме покупок
Допустим, у нас есть таблицы users и orders.
Нужно разбить пользователей на 4 группы по сумме оплаченных заказов.
Сначала посчитаем траты каждого пользователя, а потом применим NTILE.
WITH user_spending AS (
SELECT
u.id,
u.email,
SUM(o.amount) AS spent
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid'
GROUP BY u.id, u.email
)
SELECT
id,
email,
spent,
NTILE(4) OVER (ORDER BY spent DESC, id) AS quartile
FROM user_spending
ORDER BY quartile, spent DESC;
Здесь:
SUM(o.amount) AS spent
считает сумму оплаченных заказов пользователя.
А:
NTILE(4) OVER (ORDER BY spent DESC, id)
делит пользователей на 4 группы по убыванию трат.
Корзина 1 — самые дорогие клиенты.
Корзина 4 — клиенты с самыми маленькими тратами.
Почему в ORDER BY добавлен id?
ORDER BY spent DESC, id
Потому что если у двух пользователей одинаковая сумма, нужен стабильный дополнительный порядок. Без него база может расположить равные строки по-разному, и граница между корзинами начнёт плавать.
Почему важен ORDER BY
NTILE полностью зависит от сортировки.
Например:
NTILE(4) OVER (ORDER BY spent DESC)
и:
NTILE(4) OVER (ORDER BY spent ASC)
дают разные результаты.
В первом случае первая корзина — самые большие значения.
Во втором случае первая корзина — самые маленькие значения.
Сравним:
ORDER BY spent DESC
spent | quartile
------+---------
9000 | 1
8000 | 1
7000 | 1
...
500 | 4
А теперь:
ORDER BY spent ASC
spent | quartile
------+---------
500 | 1
1000 | 1
2000 | 1
...
9000 | 4
Поэтому перед использованием NTILE нужно честно ответить:
Верхняя корзина должна означать большие значения или маленькие?
Для топ-клиентов обычно пишут:
ORDER BY spent DESC
Для самых быстрых запросов по latency можно писать:
ORDER BY response_time_ms ASC
Потому что меньшее время ответа лучше.
Одинаковые значения и плавающие границы
Есть важная ловушка.
Если несколько строк имеют одинаковое значение в сортировке, база не обязана каждый раз располагать их в одном и том же порядке.
Например:
user_id | spent
--------+------
1 | 1000
2 | 1000
3 | 1000
4 | 900
Если написать:
NTILE(2) OVER (ORDER BY spent DESC)
пользователи с одинаковым spent = 1000 могут оказаться на границе корзин.
Чтобы результат был стабильным, добавляйте тай-брейкер — дополнительную колонку для сортировки.
Например:
NTILE(2) OVER (ORDER BY spent DESC, user_id)
Теперь при одинаковом spent строки будут упорядочены по user_id.
Главное правило:
В ORDER BY для NTILE лучше добавлять уникальный тай-брейкер, если результат должен быть стабильным.
Как NTILE распределяет остаток
Если количество строк не делится на количество корзин ровно, лишние строки попадают в корзины с меньшими номерами.
Например, 10 строк и NTILE(4):
10 / 4 = 2 строки в каждую корзину
остаток = 2 строки
Значит первые две корзины получат по одной дополнительной строке.
Распределение:
bucket 1: 3 строки
bucket 2: 3 строки
bucket 3: 2 строки
bucket 4: 2 строки
Другие примеры:
10 строк, NTILE(4) -> 3, 3, 2, 2
11 строк, NTILE(4) -> 3, 3, 3, 2
7 строк, NTILE(3) -> 3, 2, 2
5 строк, NTILE(2) -> 3, 2
Это поведение важно учитывать, если вы ожидаете строго равные группы.
NTILE делает группы максимально равными, но если строки не делятся без остатка, часть групп будет на одну строку больше.
Если корзин больше, чем строк
Допустим, у нас 7 строк, а мы пишем:
NTILE(10)
Что будет?
PostgreSQL не создаст пустые строки для корзин 8, 9 и 10.
Он просто присвоит существующим строкам номера с 1 по 7.
Пример:
row | ntile
----+------
1 | 1
2 | 2
3 | 3
4 | 4
5 | 5
6 | 6
7 | 7
Корзины 8, 9 и 10 в результате не появятся.
Это важная грабля для отчётов.
Не стоит писать код, который ожидает, что после NTILE(10) в результате обязательно будут все номера от 1 до 10.
Если строк меньше, чем корзин, часть корзин будет отсутствовать.
NTILE не ищет естественные границы
NTILE делит строки по количеству, а не по значениям.
Например, есть суммы:
100
101
102
103
10000
20000
30000
40000
Если применить NTILE(4), каждая корзина получит примерно одинаковое число строк.
Но диапазоны значений внутри корзин могут быть очень разными.
quartile 1: 40000, 30000
quartile 2: 20000, 10000
quartile 3: 103, 102
quartile 4: 101, 100
Это не значит, что SQL нашёл «естественные» денежные диапазоны.
Он просто разделил отсортированный список на равные части.
Главная мысль:
NTILE делит строки, а не шкалу значений.
Если вам нужны диапазоны вроде:
0-1000
1000-5000
5000-10000
10000+
то NTILE не подходит. Там лучше использовать CASE или WIDTH_BUCKET.
Пример: децили активности пользователей
Допустим, мы хотим разделить пользователей на 10 групп по количеству действий.
Есть таблица events:
id | user_id | event_name | created_at
---+---------+------------+---------------------
1 | 1 | click | 2026-06-01 10:00:00
2 | 1 | view | 2026-06-01 10:01:00
3 | 2 | click | 2026-06-01 10:02:00
Сначала считаем количество событий по пользователю, потом делим на децили.
WITH user_activity AS (
SELECT
user_id,
COUNT(*) AS events_count
FROM events
GROUP BY user_id
)
SELECT
user_id,
events_count,
NTILE(10) OVER (ORDER BY events_count DESC, user_id) AS activity_decile
FROM user_activity
ORDER BY activity_decile, events_count DESC;
Корзина 1 — самые активные пользователи.
Корзина 10 — наименее активные.
Такой подход полезен для продуктовой аналитики: можно сравнивать поведение верхних и нижних сегментов.
PARTITION BY: корзины внутри каждой группы
NTILE можно считать не по всей таблице, а отдельно внутри каждой группы.
Для этого используется PARTITION BY.
Например, нужно разбить клиентов на децили по сумме покупок внутри каждой страны.
Это важно, потому что страны могут сильно отличаться по уровню платежей.
WITH user_spending AS (
SELECT
u.id,
u.email,
u.country,
SUM(o.amount) AS spent
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid'
GROUP BY u.id, u.email, u.country
)
SELECT
country,
id,
email,
spent,
NTILE(10) OVER (
PARTITION BY country
ORDER BY spent DESC, id
) AS country_decile
FROM user_spending
ORDER BY country, country_decile, spent DESC;
Здесь PARTITION BY country означает:
для каждой страны отдельно отсортируй пользователей и раздели их на 10 групп.
Пользователи из Vietnam соревнуются только с пользователями из Vietnam.
Пользователи из Germany — только с пользователями из Germany.
Это честнее, чем смешивать рынки с разным масштабом в одну общую очередь.
Пример: зарплатные квартили внутри отдела
Ещё один классический пример — зарплаты внутри отдела.
SELECT
id,
name,
dept,
salary,
NTILE(4) OVER (
PARTITION BY dept
ORDER BY salary DESC, id
) AS pay_quartile
FROM employees
ORDER BY dept, pay_quartile, salary DESC;
Что делает запрос:
- отдельно берёт каждый отдел;
- сортирует сотрудников по зарплате;
- делит сотрудников отдела на 4 группы;
- присваивает каждому номер квартиля.
Корзина 1 — верхняя четверть зарплат внутри отдела.
Это важно: сотрудник из маленького отдела не сравнивается с сотрудником из другого отдела. Каждый отдел получает собственную шкалу.
NTILE против RANK
RANK и NTILE часто используют рядом, но они отвечают на разные вопросы.
RANK показывает место строки в сортировке.
RANK() OVER (ORDER BY spent DESC)
Например:
user | spent | rank
-----+-------+-----
A | 9000 | 1
B | 8000 | 2
C | 8000 | 2
D | 7000 | 4
NTILE показывает номер группы, в которую попала строка.
NTILE(4) OVER (ORDER BY spent DESC)
Пример:
user | spent | quartile
-----+-------+---------
A | 9000 | 1
B | 8000 | 1
C | 8000 | 2
D | 7000 | 2
RANK не пытается сделать группы одинакового размера.
NTILE как раз пытается разложить строки по равным корзинам.
Коротко:
RANK -- какое место у строки
NTILE -- в какую равную группу попала строка
NTILE против ROW_NUMBER
ROW_NUMBER просто нумерует строки по порядку.
ROW_NUMBER() OVER (ORDER BY spent DESC)
Результат:
user | spent | row_number
-----+-------+-----------
A | 9000 | 1
B | 8000 | 2
C | 7000 | 3
А NTILE не даёт уникальный номер каждой строке. Он даёт номер корзины.
user | spent | quartile
-----+-------+---------
A | 9000 | 1
B | 8000 | 1
C | 7000 | 1
D | 6000 | 2
То есть несколько строк могут иметь один и тот же quartile.
ROW_NUMBER полезен для топ-N и порядковой нумерации.
NTILE полезен для сегментации на равные группы.
NTILE против PERCENT_RANK и CUME_DIST
Есть ещё оконные функции, которые работают с относительной позицией строки:
PERCENT_RANK()
CUME_DIST()
Они возвращают не номер корзины, а относительное положение строки в распределении.
Например:
SELECT
id,
spent,
PERCENT_RANK() OVER (ORDER BY spent) AS percent_rank,
CUME_DIST() OVER (ORDER BY spent) AS cume_dist
FROM customers;
PERCENT_RANK показывает относительный ранг строки.
CUME_DIST показывает долю строк, которые меньше или равны текущей строке по сортировке.
А NTILE(4) просто делит строки на 4 группы.
Коротко:
NTILE -- номер корзины
PERCENT_RANK -- относительный ранг
CUME_DIST -- накопленная доля распределения
Если вам нужны понятные группы вроде «квартиль 1, 2, 3, 4», чаще берут NTILE.
NTILE против WIDTH_BUCKET
NTILE часто путают с WIDTH_BUCKET.
Обе функции как будто делят данные на корзины, но делают это по-разному.
NTILE(n) делит отсортированные строки на n примерно равных групп.
То есть выравнивает количество строк.
WIDTH_BUCKET(value, min, max, n) делит числовой диапазон от min до max на n равных интервалов.
То есть выравнивает ширину диапазонов.
Пример WIDTH_BUCKET:
SELECT
id,
amount,
WIDTH_BUCKET(amount, 0, 1000, 4) AS price_band
FROM orders;
Диапазон 0-1000 делится на 4 равные части:
1: 0-250
2: 250-500
3: 500-750
4: 750-1000
Количество строк внутри этих диапазонов может быть любым.
Например, почти все заказы могут попасть в первый диапазон, если большинство сумм маленькие.
А NTILE(4) постарается разложить строки по четырём группам примерно поровну.
Коротко:
NTILE -- равное количество строк
WIDTH_BUCKET -- равная ширина числовых интервалов
Когда использовать NTILE
NTILE подходит, когда вы хотите получить равные по размеру сегменты.
Например:
- топ-25% клиентов;
- нижние 25% пользователей по активности;
- 10 децилей по выручке;
- 4 квартиля по зарплате;
- равные когорты по количеству событий;
- ранговые группы для анализа.
Пример:
WITH user_spending AS (
SELECT
u.id,
u.email,
SUM(o.amount) AS spent
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid'
GROUP BY u.id, u.email
)
SELECT
*,
NTILE(4) OVER (ORDER BY spent DESC, id) AS quartile
FROM user_spending;
Так можно выделить верхнюю четверть клиентов для маркетинговой кампании.
Но важно помнить: верхняя четверть — это относительное понятие. В плохой месяц абсолютные суммы могут быть ниже, но кто-то всё равно окажется в первой корзине.
Когда NTILE не подходит
NTILE не подходит, если вам нужны фиксированные бизнес-пороги.
Например:
VIP: spent >= 10000
Middle: spent >= 1000 and spent < 10000
Low: spent < 1000
Для такой логики лучше использовать CASE.
SELECT
id,
email,
spent,
CASE
WHEN spent >= 10000 THEN 'vip'
WHEN spent >= 1000 THEN 'middle'
ELSE 'low'
END AS segment
FROM user_spending;
Почему не NTILE?
Потому что NTILE не знает ваших бизнес-порогов. Он просто делит строки на равные группы.
Если у всех пользователей маленькие суммы, NTILE(4) всё равно выделит верхнюю четверть. Но это не значит, что эти пользователи стали VIP по бизнес-смыслу.
NTILE и эксперименты
Иногда NTILE используют, чтобы разделить аудиторию на равные группы.
Например:
SELECT
user_id,
NTILE(2) OVER (ORDER BY user_id) AS group_id
FROM users;
Так можно получить две примерно равные группы.
Но важно: это не полноценная случайная рандомизация.
Если сортировка идёт по user_id, дате регистрации, выручке или активности, группы могут системно отличаться.
Например:
NTILE(2) OVER (ORDER BY spent DESC)
первая группа будет состоять из более дорогих клиентов, а вторая — из менее дорогих.
Такой результат нельзя использовать как честное A/B-разделение на контроль и тест.
Для экспериментов нужна случайная или специально контролируемая рандомизация.
NTILE создаёт ранговые сегменты, а не случайные группы.
Как подписать корзины
Числа 1, 2, 3, 4 не всегда удобно отдавать в отчёт.
Можно добавить понятные подписи.
Например, для квартилей:
WITH user_spending AS (
SELECT
u.id,
u.email,
SUM(o.amount) AS spent
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid'
GROUP BY u.id, u.email
),
ranked AS (
SELECT
*,
NTILE(4) OVER (ORDER BY spent DESC, id) AS quartile
FROM user_spending
)
SELECT
id,
email,
spent,
quartile,
CASE quartile
WHEN 1 THEN 'top 25%'
WHEN 2 THEN 'upper middle'
WHEN 3 THEN 'lower middle'
WHEN 4 THEN 'bottom 25%'
END AS quartile_label
FROM ranked;
Так результат становится понятнее для аналитика, менеджера или BI-отчёта.
Как посчитать размер каждой корзины
Иногда важно проверить, как именно NTILE разложил строки.
Можно сгруппировать результат по корзине.
WITH user_spending AS (
SELECT
u.id,
u.email,
SUM(o.amount) AS spent
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid'
GROUP BY u.id, u.email
),
ranked AS (
SELECT
*,
NTILE(4) OVER (ORDER BY spent DESC, id) AS quartile
FROM user_spending
)
SELECT
quartile,
COUNT(*) AS users_count,
MIN(spent) AS min_spent,
MAX(spent) AS max_spent
FROM ranked
GROUP BY quartile
ORDER BY quartile;
Результат может быть таким:
quartile | users_count | min_spent | max_spent
---------+-------------+-----------+----------
1 | 251 | 9000 | 50000
2 | 250 | 4000 | 8990
3 | 250 | 1500 | 3990
4 | 249 | 0 | 1490
Так вы видите:
- размер каждой группы;
- диапазон значений внутри группы;
- насколько сильно различаются корзины.
Это полезно, потому что номер корзины сам по себе не показывает распределение.
Корзина 1 не всегда сильно лучше корзины 2
Есть важная аналитическая ловушка.
Допустим, граница между квартилями прошла так:
quartile 1: spent от 1000 до 1100
quartile 2: spent от 900 до 999
Разница между нижней границей первой корзины и верхней границей второй может быть совсем небольшой.
Но если смотреть только на номер корзины, кажется, будто это разные классы клиентов.
Поэтому для решений с деньгами, доступами, бонусами или ограничениями лучше показывать не только quartile, но и саму метрику.
Хороший отчёт:
user_id | spent | quartile
--------+-------+---------
1 | 1000 | 1
2 | 999 | 2
Плохой отчёт:
user_id | quartile
--------+---------
1 | 1
2 | 2
Без spent нельзя понять, насколько реально отличаются пользователи.
Главная мысль:
NTILE помогает сегментировать, но не заменяет анализ распределения.
NTILE в PostgreSQL
В PostgreSQL NTILE используется как оконная функция.
Пример:
SELECT
id,
amount,
NTILE(4) OVER (ORDER BY amount DESC, id) AS amount_quartile
FROM orders;
Можно использовать PARTITION BY:
SELECT
country,
id,
spent,
NTILE(10) OVER (
PARTITION BY country
ORDER BY spent DESC, id
) AS country_decile
FROM user_spending;
Аргумент NTILE должен быть положительным числом.
То есть так можно:
NTILE(4)
А так нельзя:
NTILE(0)
Количество корзин должно быть больше нуля.
NTILE в MySQL
В MySQL оконная функция NTILE доступна в MySQL 8.0 и новее.
Синтаксис похожий:
SELECT
id,
amount,
NTILE(4) OVER (ORDER BY amount DESC) AS quartile
FROM orders;
Также можно использовать PARTITION BY:
SELECT
dept,
name,
salary,
NTILE(4) OVER (
PARTITION BY dept
ORDER BY salary DESC
) AS pay_quartile
FROM employees;
Если проект работает на старой версии MySQL без оконных функций, такую логику приходится имитировать через переменные, подзапросы или выполнять сегментацию вне SQL. Но для новых проектов лучше использовать оконные функции.
NTILE в ClickHouse
В ClickHouse подход зависит от версии и доступных функций.
Если прямого NTILE в вашей версии нет или он не подходит по поведению, похожую разметку можно собрать через нумерацию строк и подсчёт общего количества строк.
Идея такая:
- отсортировать строки;
- присвоить каждой строке номер;
- посчитать общее количество строк;
- вычислить номер корзины формулой.
Примерно:
WITH ranked AS (
SELECT
id,
amount,
row_number() OVER (ORDER BY amount DESC, id) AS rn,
count() OVER () AS total_rows
FROM orders
)
SELECT
id,
amount,
intDiv((rn - 1) * 4, total_rows) + 1 AS quartile
FROM ranked;
Идея похожа на NTILE(4): разделить строки на 4 группы по порядковому номеру.
Но при переносе между СУБД всегда проверяйте результат на тестовом наборе, особенно если важно точное распределение остатка по корзинам.
Практические шаблоны
Квартили заказов по сумме
SELECT
id,
amount,
NTILE(4) OVER (ORDER BY amount DESC, id) AS amount_quartile
FROM orders;
Децили клиентов по сумме покупок
WITH user_spending AS (
SELECT
u.id,
u.email,
SUM(o.amount) AS spent
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid'
GROUP BY u.id, u.email
)
SELECT
id,
email,
spent,
NTILE(10) OVER (ORDER BY spent DESC, id) AS spending_decile
FROM user_spending;
Квартили зарплат внутри отдела
SELECT
id,
name,
dept,
salary,
NTILE(4) OVER (
PARTITION BY dept
ORDER BY salary DESC, id
) AS pay_quartile
FROM employees;
Децили активности внутри страны
WITH user_activity AS (
SELECT
u.id,
u.country,
COUNT(e.id) AS events_count
FROM users u
JOIN events e ON e.user_id = u.id
GROUP BY u.id, u.country
)
SELECT
id,
country,
events_count,
NTILE(10) OVER (
PARTITION BY country
ORDER BY events_count DESC, id
) AS activity_decile
FROM user_activity;
Подписать квартиль словами
WITH ranked AS (
SELECT
id,
email,
spent,
NTILE(4) OVER (ORDER BY spent DESC, id) AS quartile
FROM user_spending
)
SELECT
id,
email,
spent,
quartile,
CASE quartile
WHEN 1 THEN 'top 25%'
WHEN 2 THEN 'upper middle'
WHEN 3 THEN 'lower middle'
WHEN 4 THEN 'bottom 25%'
END AS quartile_label
FROM ranked;
Проверить размер корзин
WITH ranked AS (
SELECT
id,
spent,
NTILE(4) OVER (ORDER BY spent DESC, id) AS quartile
FROM user_spending
)
SELECT
quartile,
COUNT(*) AS rows_count,
MIN(spent) AS min_spent,
MAX(spent) AS max_spent
FROM ranked
GROUP BY quartile
ORDER BY quartile;
Фиксированные диапазоны через WIDTH_BUCKET
SELECT
id,
amount,
WIDTH_BUCKET(amount, 0, 1000, 4) AS price_band
FROM orders;
Бизнес-сегменты через CASE
SELECT
id,
email,
spent,
CASE
WHEN spent >= 10000 THEN 'vip'
WHEN spent >= 1000 THEN 'middle'
ELSE 'low'
END AS segment
FROM user_spending;
Что важно запомнить
NTILE(n) делит отсортированные строки на n примерно равных по размеру групп.
Пример:
SELECT
id,
amount,
NTILE(4) OVER (ORDER BY amount DESC, id) AS quartile
FROM orders;
Главные правила:
NTILE(4) делит строки на 4 группы;
NTILE(10) делит строки на 10 групп;
- функция делит строки, а не диапазон значений;
- результат полностью зависит от
ORDER BY;
- при одинаковых значениях добавляйте тай-брейкер;
- если строки не делятся ровно, лишние строки попадают в корзины с меньшими номерами;
- если корзин больше, чем строк, часть номеров не появится;
PARTITION BY считает корзины отдельно внутри каждой группы;
NTILE не заменяет бизнес-пороги;
- для фиксированных диапазонов лучше использовать
WIDTH_BUCKET или CASE;
- для экспериментов
NTILE не является случайной рандомизацией.
Короткий вывод
NTILE нужен, когда вы хотите разделить отсортированный список на равные по размеру группы.
Например:
NTILE(4) OVER (ORDER BY spent DESC)
делит клиентов на 4 квартиля по сумме покупок.
Корзина 1 при сортировке DESC — верхний сегмент.
Главная мысль:
NTILE делит не шкалу значений, а строки в отсортированной очереди.
Это удобно для аналитики:
- топ-25% клиентов;
- децили активности;
- зарплатные квартили;
- ранговые сегменты;
- сравнение пользователей внутри страны или отдела.
Но номер корзины — это не абсолютная оценка качества. Он показывает место строки относительно других строк в выбранной сортировке.
Поэтому хороший отчёт с NTILE должен показывать не только номер корзины, но и саму метрику, правило сортировки и размер групп.
В SQL часто нужно не просто отсортировать строки, а разделить их на несколько примерно равных групп.
Например:
Для таких задач есть оконная функция
NTILE.Она отвечает на вопрос:
Например:
NTILE(4)делит строки на 4 группы.
Такие группы часто называют квартилями.
NTILE(10)делит строки на 10 групп.
Такие группы называют децилями.
Важно:
NTILEделит не диапазон значений, а сами строки.То есть функция старается сделать так, чтобы в каждой корзине было примерно одинаковое количество строк.
Что делает NTILE простыми словами
Представим, что у нас есть 10 клиентов, отсортированных по сумме покупок от большего к меньшему.
Если применить
NTILE(4), SQL разделит эти 10 строк на 4 группы.Так как 10 не делится на 4 ровно, группы будут такими:
Результат:
Если сортировка идёт по убыванию:
ORDER BY spent DESCто корзина
1— это верхняя группа, то есть самые дорогие клиенты.Если сортировка идёт по возрастанию:
ORDER BY spent ASCто корзина
1будет нижней группой, то есть самые маленькие значения.Базовый синтаксис NTILE
NTILE— это оконная функция.Базовый синтаксис:
NTILE(number_of_buckets) OVER ( ORDER BY column )Например:
NTILE(4) OVER (ORDER BY spent DESC)означает:
Пример с таблицей клиентов:
SELECT id, email, spent, NTILE(4) OVER (ORDER BY spent DESC) AS quartile FROM customers;Результат:
Здесь
quartileпоказывает, в какую четверть отсортированного списка попала строка.NTILE — это оконная функция
NTILEне схлопывает строки, какGROUP BY.Он добавляет к каждой строке номер группы.
Например, обычный
GROUP BYуменьшает количество строк:SELECT country, SUM(amount) AS revenue FROM orders GROUP BY country;А
NTILEоставляет строки и добавляет к ним вычисленное значение:SELECT id, email, spent, NTILE(4) OVER (ORDER BY spent DESC) AS quartile FROM customers;То есть
NTILEработает «поверх» результата и присваивает каждой строке номер корзины.Это удобно, когда нужно не потерять отдельные строки, а просто разметить их по сегментам.
Пример: квартили клиентов по сумме покупок
Допустим, у нас есть таблицы
usersиorders.Нужно разбить пользователей на 4 группы по сумме оплаченных заказов.
Сначала посчитаем траты каждого пользователя, а потом применим
NTILE.WITH user_spending AS ( SELECT u.id, u.email, SUM(o.amount) AS spent FROM users u JOIN orders o ON o.user_id = u.id WHERE o.status = 'paid' GROUP BY u.id, u.email ) SELECT id, email, spent, NTILE(4) OVER (ORDER BY spent DESC, id) AS quartile FROM user_spending ORDER BY quartile, spent DESC;Здесь:
SUM(o.amount) AS spentсчитает сумму оплаченных заказов пользователя.
А:
NTILE(4) OVER (ORDER BY spent DESC, id)делит пользователей на 4 группы по убыванию трат.
Корзина
1— самые дорогие клиенты.Корзина
4— клиенты с самыми маленькими тратами.Почему в
ORDER BYдобавленid?ORDER BY spent DESC, idПотому что если у двух пользователей одинаковая сумма, нужен стабильный дополнительный порядок. Без него база может расположить равные строки по-разному, и граница между корзинами начнёт плавать.
Почему важен ORDER BY
NTILEполностью зависит от сортировки.Например:
NTILE(4) OVER (ORDER BY spent DESC)и:
NTILE(4) OVER (ORDER BY spent ASC)дают разные результаты.
В первом случае первая корзина — самые большие значения.
Во втором случае первая корзина — самые маленькие значения.
Сравним:
А теперь:
Поэтому перед использованием
NTILEнужно честно ответить:Для топ-клиентов обычно пишут:
ORDER BY spent DESCДля самых быстрых запросов по latency можно писать:
ORDER BY response_time_ms ASCПотому что меньшее время ответа лучше.
Одинаковые значения и плавающие границы
Есть важная ловушка.
Если несколько строк имеют одинаковое значение в сортировке, база не обязана каждый раз располагать их в одном и том же порядке.
Например:
Если написать:
NTILE(2) OVER (ORDER BY spent DESC)пользователи с одинаковым
spent = 1000могут оказаться на границе корзин.Чтобы результат был стабильным, добавляйте тай-брейкер — дополнительную колонку для сортировки.
Например:
NTILE(2) OVER (ORDER BY spent DESC, user_id)Теперь при одинаковом
spentстроки будут упорядочены поuser_id.Главное правило:
Как NTILE распределяет остаток
Если количество строк не делится на количество корзин ровно, лишние строки попадают в корзины с меньшими номерами.
Например, 10 строк и
NTILE(4):Значит первые две корзины получат по одной дополнительной строке.
Распределение:
Другие примеры:
Это поведение важно учитывать, если вы ожидаете строго равные группы.
NTILEделает группы максимально равными, но если строки не делятся без остатка, часть групп будет на одну строку больше.Если корзин больше, чем строк
Допустим, у нас 7 строк, а мы пишем:
NTILE(10)Что будет?
PostgreSQL не создаст пустые строки для корзин 8, 9 и 10.
Он просто присвоит существующим строкам номера с 1 по 7.
Пример:
Корзины 8, 9 и 10 в результате не появятся.
Это важная грабля для отчётов.
Не стоит писать код, который ожидает, что после
NTILE(10)в результате обязательно будут все номера от 1 до 10.Если строк меньше, чем корзин, часть корзин будет отсутствовать.
NTILE не ищет естественные границы
NTILEделит строки по количеству, а не по значениям.Например, есть суммы:
Если применить
NTILE(4), каждая корзина получит примерно одинаковое число строк.Но диапазоны значений внутри корзин могут быть очень разными.
Это не значит, что SQL нашёл «естественные» денежные диапазоны.
Он просто разделил отсортированный список на равные части.
Главная мысль:
Если вам нужны диапазоны вроде:
то
NTILEне подходит. Там лучше использоватьCASEилиWIDTH_BUCKET.Пример: децили активности пользователей
Допустим, мы хотим разделить пользователей на 10 групп по количеству действий.
Есть таблица
events:Сначала считаем количество событий по пользователю, потом делим на децили.
WITH user_activity AS ( SELECT user_id, COUNT(*) AS events_count FROM events GROUP BY user_id ) SELECT user_id, events_count, NTILE(10) OVER (ORDER BY events_count DESC, user_id) AS activity_decile FROM user_activity ORDER BY activity_decile, events_count DESC;Корзина
1— самые активные пользователи.Корзина
10— наименее активные.Такой подход полезен для продуктовой аналитики: можно сравнивать поведение верхних и нижних сегментов.
PARTITION BY: корзины внутри каждой группы
NTILEможно считать не по всей таблице, а отдельно внутри каждой группы.Для этого используется
PARTITION BY.Например, нужно разбить клиентов на децили по сумме покупок внутри каждой страны.
Это важно, потому что страны могут сильно отличаться по уровню платежей.
WITH user_spending AS ( SELECT u.id, u.email, u.country, SUM(o.amount) AS spent FROM users u JOIN orders o ON o.user_id = u.id WHERE o.status = 'paid' GROUP BY u.id, u.email, u.country ) SELECT country, id, email, spent, NTILE(10) OVER ( PARTITION BY country ORDER BY spent DESC, id ) AS country_decile FROM user_spending ORDER BY country, country_decile, spent DESC;Здесь
PARTITION BY countryозначает:Пользователи из Vietnam соревнуются только с пользователями из Vietnam. Пользователи из Germany — только с пользователями из Germany.
Это честнее, чем смешивать рынки с разным масштабом в одну общую очередь.
Пример: зарплатные квартили внутри отдела
Ещё один классический пример — зарплаты внутри отдела.
SELECT id, name, dept, salary, NTILE(4) OVER ( PARTITION BY dept ORDER BY salary DESC, id ) AS pay_quartile FROM employees ORDER BY dept, pay_quartile, salary DESC;Что делает запрос:
Корзина
1— верхняя четверть зарплат внутри отдела.Это важно: сотрудник из маленького отдела не сравнивается с сотрудником из другого отдела. Каждый отдел получает собственную шкалу.
NTILE против RANK
RANKиNTILEчасто используют рядом, но они отвечают на разные вопросы.RANKпоказывает место строки в сортировке.RANK() OVER (ORDER BY spent DESC)Например:
NTILEпоказывает номер группы, в которую попала строка.NTILE(4) OVER (ORDER BY spent DESC)Пример:
RANKне пытается сделать группы одинакового размера.NTILEкак раз пытается разложить строки по равным корзинам.Коротко:
NTILE против ROW_NUMBER
ROW_NUMBERпросто нумерует строки по порядку.ROW_NUMBER() OVER (ORDER BY spent DESC)Результат:
А
NTILEне даёт уникальный номер каждой строке. Он даёт номер корзины.То есть несколько строк могут иметь один и тот же
quartile.ROW_NUMBERполезен для топ-N и порядковой нумерации.NTILEполезен для сегментации на равные группы.NTILE против PERCENT_RANK и CUME_DIST
Есть ещё оконные функции, которые работают с относительной позицией строки:
PERCENT_RANK() CUME_DIST()Они возвращают не номер корзины, а относительное положение строки в распределении.
Например:
SELECT id, spent, PERCENT_RANK() OVER (ORDER BY spent) AS percent_rank, CUME_DIST() OVER (ORDER BY spent) AS cume_dist FROM customers;PERCENT_RANKпоказывает относительный ранг строки.CUME_DISTпоказывает долю строк, которые меньше или равны текущей строке по сортировке.А
NTILE(4)просто делит строки на 4 группы.Коротко:
Если вам нужны понятные группы вроде «квартиль 1, 2, 3, 4», чаще берут
NTILE.NTILE против WIDTH_BUCKET
NTILEчасто путают сWIDTH_BUCKET.Обе функции как будто делят данные на корзины, но делают это по-разному.
NTILE(n)делит отсортированные строки наnпримерно равных групп.То есть выравнивает количество строк.
WIDTH_BUCKET(value, min, max, n)делит числовой диапазон отminдоmaxнаnравных интервалов.То есть выравнивает ширину диапазонов.
Пример
WIDTH_BUCKET:SELECT id, amount, WIDTH_BUCKET(amount, 0, 1000, 4) AS price_band FROM orders;Диапазон
0-1000делится на 4 равные части:Количество строк внутри этих диапазонов может быть любым.
Например, почти все заказы могут попасть в первый диапазон, если большинство сумм маленькие.
А
NTILE(4)постарается разложить строки по четырём группам примерно поровну.Коротко:
Когда использовать NTILE
NTILEподходит, когда вы хотите получить равные по размеру сегменты.Например:
Пример:
WITH user_spending AS ( SELECT u.id, u.email, SUM(o.amount) AS spent FROM users u JOIN orders o ON o.user_id = u.id WHERE o.status = 'paid' GROUP BY u.id, u.email ) SELECT *, NTILE(4) OVER (ORDER BY spent DESC, id) AS quartile FROM user_spending;Так можно выделить верхнюю четверть клиентов для маркетинговой кампании.
Но важно помнить: верхняя четверть — это относительное понятие. В плохой месяц абсолютные суммы могут быть ниже, но кто-то всё равно окажется в первой корзине.
Когда NTILE не подходит
NTILEне подходит, если вам нужны фиксированные бизнес-пороги.Например:
Для такой логики лучше использовать
CASE.SELECT id, email, spent, CASE WHEN spent >= 10000 THEN 'vip' WHEN spent >= 1000 THEN 'middle' ELSE 'low' END AS segment FROM user_spending;Почему не
NTILE?Потому что
NTILEне знает ваших бизнес-порогов. Он просто делит строки на равные группы.Если у всех пользователей маленькие суммы,
NTILE(4)всё равно выделит верхнюю четверть. Но это не значит, что эти пользователи стали VIP по бизнес-смыслу.NTILE и эксперименты
Иногда
NTILEиспользуют, чтобы разделить аудиторию на равные группы.Например:
SELECT user_id, NTILE(2) OVER (ORDER BY user_id) AS group_id FROM users;Так можно получить две примерно равные группы.
Но важно: это не полноценная случайная рандомизация.
Если сортировка идёт по
user_id, дате регистрации, выручке или активности, группы могут системно отличаться.Например:
NTILE(2) OVER (ORDER BY spent DESC)первая группа будет состоять из более дорогих клиентов, а вторая — из менее дорогих.
Такой результат нельзя использовать как честное A/B-разделение на контроль и тест.
Для экспериментов нужна случайная или специально контролируемая рандомизация.
NTILEсоздаёт ранговые сегменты, а не случайные группы.Как подписать корзины
Числа
1,2,3,4не всегда удобно отдавать в отчёт.Можно добавить понятные подписи.
Например, для квартилей:
WITH user_spending AS ( SELECT u.id, u.email, SUM(o.amount) AS spent FROM users u JOIN orders o ON o.user_id = u.id WHERE o.status = 'paid' GROUP BY u.id, u.email ), ranked AS ( SELECT *, NTILE(4) OVER (ORDER BY spent DESC, id) AS quartile FROM user_spending ) SELECT id, email, spent, quartile, CASE quartile WHEN 1 THEN 'top 25%' WHEN 2 THEN 'upper middle' WHEN 3 THEN 'lower middle' WHEN 4 THEN 'bottom 25%' END AS quartile_label FROM ranked;Так результат становится понятнее для аналитика, менеджера или BI-отчёта.
Как посчитать размер каждой корзины
Иногда важно проверить, как именно
NTILEразложил строки.Можно сгруппировать результат по корзине.
WITH user_spending AS ( SELECT u.id, u.email, SUM(o.amount) AS spent FROM users u JOIN orders o ON o.user_id = u.id WHERE o.status = 'paid' GROUP BY u.id, u.email ), ranked AS ( SELECT *, NTILE(4) OVER (ORDER BY spent DESC, id) AS quartile FROM user_spending ) SELECT quartile, COUNT(*) AS users_count, MIN(spent) AS min_spent, MAX(spent) AS max_spent FROM ranked GROUP BY quartile ORDER BY quartile;Результат может быть таким:
Так вы видите:
Это полезно, потому что номер корзины сам по себе не показывает распределение.
Корзина 1 не всегда сильно лучше корзины 2
Есть важная аналитическая ловушка.
Допустим, граница между квартилями прошла так:
Разница между нижней границей первой корзины и верхней границей второй может быть совсем небольшой.
Но если смотреть только на номер корзины, кажется, будто это разные классы клиентов.
Поэтому для решений с деньгами, доступами, бонусами или ограничениями лучше показывать не только
quartile, но и саму метрику.Хороший отчёт:
Плохой отчёт:
Без
spentнельзя понять, насколько реально отличаются пользователи.Главная мысль:
NTILE в PostgreSQL
В PostgreSQL
NTILEиспользуется как оконная функция.Пример:
SELECT id, amount, NTILE(4) OVER (ORDER BY amount DESC, id) AS amount_quartile FROM orders;Можно использовать
PARTITION BY:SELECT country, id, spent, NTILE(10) OVER ( PARTITION BY country ORDER BY spent DESC, id ) AS country_decile FROM user_spending;Аргумент
NTILEдолжен быть положительным числом.То есть так можно:
NTILE(4)А так нельзя:
NTILE(0)Количество корзин должно быть больше нуля.
NTILE в MySQL
В MySQL оконная функция
NTILEдоступна в MySQL 8.0 и новее.Синтаксис похожий:
SELECT id, amount, NTILE(4) OVER (ORDER BY amount DESC) AS quartile FROM orders;Также можно использовать
PARTITION BY:SELECT dept, name, salary, NTILE(4) OVER ( PARTITION BY dept ORDER BY salary DESC ) AS pay_quartile FROM employees;Если проект работает на старой версии MySQL без оконных функций, такую логику приходится имитировать через переменные, подзапросы или выполнять сегментацию вне SQL. Но для новых проектов лучше использовать оконные функции.
NTILE в ClickHouse
В ClickHouse подход зависит от версии и доступных функций.
Если прямого
NTILEв вашей версии нет или он не подходит по поведению, похожую разметку можно собрать через нумерацию строк и подсчёт общего количества строк.Идея такая:
Примерно:
WITH ranked AS ( SELECT id, amount, row_number() OVER (ORDER BY amount DESC, id) AS rn, count() OVER () AS total_rows FROM orders ) SELECT id, amount, intDiv((rn - 1) * 4, total_rows) + 1 AS quartile FROM ranked;Идея похожа на
NTILE(4): разделить строки на 4 группы по порядковому номеру.Но при переносе между СУБД всегда проверяйте результат на тестовом наборе, особенно если важно точное распределение остатка по корзинам.
Практические шаблоны
Квартили заказов по сумме
SELECT id, amount, NTILE(4) OVER (ORDER BY amount DESC, id) AS amount_quartile FROM orders;Децили клиентов по сумме покупок
WITH user_spending AS ( SELECT u.id, u.email, SUM(o.amount) AS spent FROM users u JOIN orders o ON o.user_id = u.id WHERE o.status = 'paid' GROUP BY u.id, u.email ) SELECT id, email, spent, NTILE(10) OVER (ORDER BY spent DESC, id) AS spending_decile FROM user_spending;Квартили зарплат внутри отдела
SELECT id, name, dept, salary, NTILE(4) OVER ( PARTITION BY dept ORDER BY salary DESC, id ) AS pay_quartile FROM employees;Децили активности внутри страны
WITH user_activity AS ( SELECT u.id, u.country, COUNT(e.id) AS events_count FROM users u JOIN events e ON e.user_id = u.id GROUP BY u.id, u.country ) SELECT id, country, events_count, NTILE(10) OVER ( PARTITION BY country ORDER BY events_count DESC, id ) AS activity_decile FROM user_activity;Подписать квартиль словами
WITH ranked AS ( SELECT id, email, spent, NTILE(4) OVER (ORDER BY spent DESC, id) AS quartile FROM user_spending ) SELECT id, email, spent, quartile, CASE quartile WHEN 1 THEN 'top 25%' WHEN 2 THEN 'upper middle' WHEN 3 THEN 'lower middle' WHEN 4 THEN 'bottom 25%' END AS quartile_label FROM ranked;Проверить размер корзин
WITH ranked AS ( SELECT id, spent, NTILE(4) OVER (ORDER BY spent DESC, id) AS quartile FROM user_spending ) SELECT quartile, COUNT(*) AS rows_count, MIN(spent) AS min_spent, MAX(spent) AS max_spent FROM ranked GROUP BY quartile ORDER BY quartile;Фиксированные диапазоны через WIDTH_BUCKET
SELECT id, amount, WIDTH_BUCKET(amount, 0, 1000, 4) AS price_band FROM orders;Бизнес-сегменты через CASE
SELECT id, email, spent, CASE WHEN spent >= 10000 THEN 'vip' WHEN spent >= 1000 THEN 'middle' ELSE 'low' END AS segment FROM user_spending;Что важно запомнить
NTILE(n)делит отсортированные строки наnпримерно равных по размеру групп.Пример:
SELECT id, amount, NTILE(4) OVER (ORDER BY amount DESC, id) AS quartile FROM orders;Главные правила:
NTILE(4)делит строки на 4 группы;NTILE(10)делит строки на 10 групп;ORDER BY;PARTITION BYсчитает корзины отдельно внутри каждой группы;NTILEне заменяет бизнес-пороги;WIDTH_BUCKETилиCASE;NTILEне является случайной рандомизацией.Короткий вывод
NTILEнужен, когда вы хотите разделить отсортированный список на равные по размеру группы.Например:
NTILE(4) OVER (ORDER BY spent DESC)делит клиентов на 4 квартиля по сумме покупок.
Корзина
1при сортировкеDESC— верхний сегмент.Главная мысль:
Это удобно для аналитики:
Но номер корзины — это не абсолютная оценка качества. Он показывает место строки относительно других строк в выбранной сортировке.
Поэтому хороший отчёт с
NTILEдолжен показывать не только номер корзины, но и саму метрику, правило сортировки и размер групп.