Dieser Artikel ist derzeit auf Russisch — die englische Übersetzung ist in Arbeit.
Среднее значение не всегда честно описывает данные.
Например, у нас есть суммы заказов:
100
120
130
150
10000
Если посчитать среднее, большой заказ на 10000 сильно сдвинет результат вверх.
AVG = 2100
Но типичный заказ явно не выглядит как 2100. Большинство заказов находятся около 100-150.
В таких ситуациях лучше смотреть не только на AVG, а на перцентили:
- медиана показывает середину распределения;
- p95 показывает значение, ниже которого находится 95% наблюдений;
- p99 показывает хвост распределения ещё строже.
В PostgreSQL для этого есть функция PERCENTILE_CONT.
Она помогает считать медиану, p95, p99 и любые другие перцентили прямо в SQL.
Что такое перцентиль простыми словами
Перцентиль отвечает на вопрос:
Ниже какого значения находится определённая доля данных?
Например, p95 по времени ответа API равен 800 ms.
Это значит:
95% запросов выполнились за 800 ms или быстрее.
А оставшиеся 5% были медленнее.
Медиана — это p50.
Если медиана чека равна 1200, это значит:
половина заказов была дешевле или равна примерно 1200, а половина — дороже или равна примерно 1200.
Перцентили особенно полезны там, где данные имеют длинный хвост:
- время ответа API;
- длительность выполнения задач;
- суммы заказов;
- зарплаты;
- размер платежей;
- количество событий на пользователя;
- время доставки;
- время обработки заявки.
В таких данных среднее может сильно врать из-за нескольких экстремальных значений.
Почему AVG иногда вводит в заблуждение
Возьмём простой пример.
Есть пять заказов:
amount
------
100
120
130
150
10000
Среднее:
(100 + 120 + 130 + 150 + 10000) / 5 = 2100
Но если мы скажем:
средний заказ — 2100
это будет странно. Почти все реальные заказы намного меньше.
Медиана для этих данных — 130.
100, 120, 130, 150, 10000
^
медиана
Она намного лучше показывает типичный заказ.
Среднее отвечает на вопрос:
какое значение получится, если равномерно распределить сумму по всем строкам?
Медиана отвечает на другой вопрос:
где находится середина отсортированного набора?
Для данных с выбросами медиана часто полезнее.
Что делает PERCENTILE_CONT
PERCENTILE_CONT считает значение на нужной доле отсортированного набора.
Например:
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount
FROM orders;
Здесь:
PERCENTILE_CONT(0.5)
означает: взять 50-й перцентиль, то есть медиану.
А:
WITHIN GROUP (ORDER BY amount)
говорит PostgreSQL:
отсортируй значения amount и найди нужную точку внутри этого распределения.
Важно: сортировка для PERCENTILE_CONT пишется не обычным ORDER BY в конце запроса, а внутри WITHIN GROUP.
Базовый синтаксис
Общий вид:
PERCENTILE_CONT(fraction) WITHIN GROUP (ORDER BY expression)
Где:
fraction — доля от 0 до 1;
expression — значение, по которому строится распределение.
Примеры:
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount)
медиана суммы заказа.
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY response_time_ms)
p95 времени ответа.
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY duration_seconds)
p99 длительности.
Частые значения:
0.5 -- медиана, p50
0.75 -- p75
0.9 -- p90
0.95 -- p95
0.99 -- p99
Пример: медиана суммы заказа
Допустим, есть таблица orders:
id | amount
---+--------
1 | 100
2 | 120
3 | 130
4 | 150
5 | 10000
Посчитаем среднее и медиану:
SELECT
AVG(amount) AS avg_amount,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount
FROM orders;
Результат:
avg_amount | median_amount
-----------+--------------
2100 | 130
Среднее сильно выросло из-за заказа на 10000.
А медиана показала более типичное значение.
Именно поэтому в аналитике часто смотрят не только AVG, но и медиану.
Что означает WITHIN GROUP
WITHIN GROUP нужен для агрегатов, которым важен порядок значений.
Обычному SUM порядок не нужен.
Например:
SUM(amount)
даст один и тот же результат независимо от того, как отсортированы строки.
А вот перцентиль без сортировки не имеет смысла.
Чтобы найти медиану или p95, PostgreSQL должен знать порядок значений:
100, 120, 130, 150, 10000
Именно поэтому PERCENTILE_CONT пишется так:
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount)
То есть:
найди 0.5-позицию внутри группы, отсортированной по amount.
Обычный ORDER BY в конце запроса сортирует итоговые строки результата.
А ORDER BY внутри WITHIN GROUP определяет распределение, по которому считается перцентиль.
Это разные вещи.
PERCENTILE_CONT — это агрегат
PERCENTILE_CONT работает как агрегатная функция.
То есть он возвращает:
- одно значение по всей таблице;
- или одно значение на каждую группу, если есть
GROUP BY.
Например, один p95 по всей таблице:
SELECT
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY amount) AS p95_amount
FROM orders;
А p95 по каждой стране:
SELECT
u.country,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY o.amount) AS p95_amount
FROM orders o
JOIN users u ON u.id = o.user_id
GROUP BY u.country
ORDER BY p95_amount DESC;
В этом запросе PostgreSQL отдельно сортирует суммы заказов внутри каждой страны и считает p95 для каждой группы.
NULL игнорируются
PERCENTILE_CONT игнорирует NULL.
Например, если в данных есть значения:
100
120
NULL
130
150
перцентиль будет считаться только по ненулевым значениям:
100
120
130
150
Это похоже на поведение многих агрегатов в SQL: AVG, SUM, MIN, MAX тоже не учитывают NULL.
Если в группе все значения NULL, результатом будет NULL.
Например:
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount
FROM orders
WHERE status = 'cancelled';
Если у отменённых заказов нет заполненных amount, медиана будет NULL.
Для отчёта иногда можно обернуть результат в COALESCE, но делать это нужно осознанно. Нулевая медиана и неизвестная медиана — не одно и то же.
CONT означает continuous
В названии PERCENTILE_CONT часть CONT означает continuous, то есть непрерывный.
Это значит, что функция может вернуть значение, которого не было в таблице.
Почему?
Потому что если нужная точка попадает между двумя строками, PostgreSQL интерполирует значение между ними.
Проще всего понять это на медиане.
Допустим, есть четыре суммы:
10
20
30
40
У набора чётное количество значений. У него нет одной центральной строки.
Центр находится между 20 и 30.
PERCENTILE_CONT(0.5) вернёт:
25
Запрос:
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount
FROM orders;
Результат:
median_amount
-------------
25
Значения 25 не было в таблице. Оно получилось как середина между 20 и 30.
Это и есть интерполяция.
PERCENTILE_CONT против PERCENTILE_DISC
В PostgreSQL есть ещё одна похожая функция:
PERCENTILE_DISC
DISC означает discrete, то есть дискретный.
Главное отличие:
PERCENTILE_CONT может вернуть промежуточное значение;
PERCENTILE_DISC всегда возвращает одно из реальных значений из набора.
Возьмём данные:
10
20
30
40
Сравним:
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS cont_median,
PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY amount) AS disc_median
FROM orders;
Результат может быть таким:
cont_median | disc_median
------------+------------
25 | 20
PERCENTILE_CONT вернул 25, потому что интерполировал между 20 и 30.
PERCENTILE_DISC вернул реальное значение из набора.
Когда использовать CONT, а когда DISC
PERCENTILE_CONT хорошо подходит для непрерывных величин:
- деньги;
- время ответа;
- длительность;
- зарплата;
- вес;
- расстояние;
- размер файла;
- количество миллисекунд;
- числовые метрики.
Например:
SELECT
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY response_time_ms) AS p95_latency
FROM api_requests;
PERCENTILE_DISC лучше использовать, когда результат должен быть существующим значением из набора.
Например:
- категория;
- уровень тарифа;
- оценка;
- дискретная шкала;
- конкретное значение, которое реально встречалось в строках.
Пример:
SELECT
PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY rating) AS median_rating
FROM reviews;
Если рейтинг может быть только 1, 2, 3, 4, 5, дискретный вариант часто логичнее.
Коротко:
PERCENTILE_CONT -- можно интерполировать
PERCENTILE_DISC -- нужно реальное значение из набора
Пример: p95 времени ответа API
Перцентили особенно часто используют для latency.
Допустим, есть таблица api_requests:
id | endpoint | response_time_ms
---+----------------+-----------------
1 | /login | 120
2 | /login | 140
3 | /login | 180
4 | /login | 900
5 | /profile | 80
6 | /profile | 110
Посчитаем p95 по каждому endpoint:
SELECT
endpoint,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY response_time_ms) AS p95_latency_ms,
AVG(response_time_ms) AS avg_latency_ms
FROM api_requests
GROUP BY endpoint
ORDER BY p95_latency_ms DESC;
Такой отчёт показывает не только среднее, но и хвост задержек.
Почему p95 важен?
Потому что пользовательский опыт часто портят именно медленные запросы.
Среднее может выглядеть нормально, но если p95 высокий, значит заметная часть пользователей сталкивается с задержками.
Например:
avg_latency_ms = 150
p95_latency_ms = 900
Это сигнал: большинство запросов быстрые, но хвост очень плохой.
p95, p99 и SLO
В инженерных метриках часто смотрят p95 и p99.
Например:
p95 latency = 800 ms
Это значит:
95% запросов быстрее или равны примерно 800 ms.
p99 latency = 3000 ms
Это значит:
99% запросов быстрее или равны примерно 3000 ms.
Если p99 намного больше p95, значит есть редкие, но очень тяжёлые выбросы.
Для SLO и мониторинга это важнее, чем просто среднее.
Например, среднее время ответа может быть 200 ms, но p99 — 5 s. В таком случае часть пользователей всё равно получает плохой опыт.
Несколько перцентилей одним запросом
Для дашборда часто нужны сразу p50, p95 и p99.
Можно написать три отдельных вызова:
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS p50,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY amount) AS p95,
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY amount) AS p99
FROM orders
WHERE status = 'paid';
Это понятно, но PostgreSQL также позволяет передать массив долей:
SELECT
PERCENTILE_CONT(ARRAY[0.5, 0.95, 0.99])
WITHIN GROUP (ORDER BY amount) AS percentiles
FROM orders
WHERE status = 'paid';
Результат будет массивом:
percentiles
-----------------
{130,8000,9800}
Здесь:
первый элемент -- p50
второй элемент -- p95
третий элемент -- p99
Важно не перепутать порядок. Результаты идут в том же порядке, что и доли в массиве.
Как разложить массив перцентилей по колонкам
Массив удобно считать, но в отчёте часто нужны отдельные колонки.
Можно сделать так:
SELECT
pct[1] AS p50,
pct[2] AS p95,
pct[3] AS p99
FROM (
SELECT
PERCENTILE_CONT(ARRAY[0.5, 0.95, 0.99])
WITHIN GROUP (ORDER BY amount) AS pct
FROM orders
WHERE status = 'paid'
) s;
Результат:
p50 | p95 | p99
----+------+-----
130 | 8000 | 9800
Здесь pct[1] — первый элемент массива.
В PostgreSQL массивы индексируются с 1, а не с 0.
Можно запомнить:
ARRAY[0.5, 0.95, 0.99]
| | |
pct[1] pct[2] pct[3]
Медиана по группам
PERCENTILE_CONT хорошо работает вместе с GROUP BY.
Например, посчитаем медианную зарплату по отделам.
SELECT
dept,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) AS median_salary,
AVG(salary) AS avg_salary
FROM employees
GROUP BY dept
ORDER BY median_salary DESC;
Результат может быть таким:
dept | median_salary | avg_salary
-----+---------------+-----------
eng | 180000 | 210000
hr | 120000 | 125000
sales| 150000 | 300000
Если в отделе sales есть один очень большой бонус или контракт, среднее может сильно вырасти.
Медиана при этом покажет более типичную зарплату.
Для зарплат, чеков и latency медиана часто полезнее среднего.
p95 суммы заказа по странам
Пример для продуктовой аналитики.
Хотим понять, в каких странах высокий хвост по суммам заказов.
SELECT
u.country,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY o.amount) AS p95_amount,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY o.amount) AS median_amount,
AVG(o.amount) AS avg_amount
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'paid'
GROUP BY u.country
ORDER BY p95_amount DESC;
Такой отчёт показывает:
- типичный чек через медиану;
- верхний хвост через p95;
- среднее для сравнения.
Если в стране медиана 1000, а p95 20000, значит большинство заказов обычные, но есть дорогой хвост.
Это может быть важно для:
- антифрода;
- премиум-сегмента;
- анализа крупных заказов;
- настройки лимитов;
- сегментации клиентов.
PERCENTILE_CONT с FILTER
PERCENTILE_CONT можно сочетать с FILTER, как и другие агрегаты.
Например, посчитать медиану только по оплаченным заказам и p95 только по возвратам в одном запросе:
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount)
FILTER (WHERE status = 'paid') AS median_paid_amount,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY amount)
FILTER (WHERE status = 'refunded') AS p95_refunded_amount
FROM orders;
Это удобно, когда нужно несколько метрик по разным сегментам из одной таблицы.
Но не стоит превращать один SELECT в огромную простыню из десятков перцентилей. Если метрик становится слишком много, лучше подумать о витрине или отдельной структуре отчёта.
Важная техническая деталь про типы
В PostgreSQL PERCENTILE_CONT работает с числовыми значениями, которые можно интерполировать, а также с интервалами.
На практике для денежных значений и других numeric-колонок иногда полезно явно привести выражение к double precision.
Например:
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount::double precision) AS median_amount
FROM orders;
Почему так?
Потому что PERCENTILE_CONT возвращает интерполированное значение. В PostgreSQL результат для числового варианта обычно получается как double precision.
Если вам нужно округление до денег, лучше сделать это явно:
SELECT
ROUND(
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount::double precision)::numeric,
2
) AS median_amount
FROM orders;
Так вы явно контролируете формат результата.
Для interval можно считать перцентили длительности:
SELECT
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY finished_at - started_at) AS p95_duration
FROM jobs
WHERE finished_at IS NOT NULL;
Здесь результатом будет интервал.
Почему PERCENTILE_CONT не пишут как обычный ORDER BY
Новички иногда пытаются написать что-то вроде:
SELECT
PERCENTILE_CONT(0.5) AS median_amount
FROM orders
ORDER BY amount;
Так нельзя.
PERCENTILE_CONT должен знать, по какому выражению строить распределение. Поэтому сортировка указывается внутри WITHIN GROUP:
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount
FROM orders;
Обычный ORDER BY в конце запроса нужен для сортировки строк результата.
Например:
SELECT
u.country,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY o.amount) AS median_amount
FROM orders o
JOIN users u ON u.id = o.user_id
GROUP BY u.country
ORDER BY median_amount DESC;
Здесь:
WITHIN GROUP (ORDER BY o.amount)
нужно для расчёта медианы внутри каждой страны.
А:
ORDER BY median_amount DESC
сортирует итоговые страны по медиане.
PERCENTILE_CONT и оконные функции
PERCENTILE_CONT — это ordered-set агрегат.
В обычной аналитике его чаще используют как агрегат:
SELECT
country,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY amount) AS p95_amount
FROM orders
GROUP BY country;
Если вам нужно получить p95 по группе и показать его рядом с каждой исходной строкой, часто проще посчитать перцентиль в подзапросе, а потом присоединить обратно.
Например:
WITH country_percentiles AS (
SELECT
u.country,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY o.amount) AS p95_amount
FROM orders o
JOIN users u ON u.id = o.user_id
GROUP BY u.country
)
SELECT
o.id,
u.country,
o.amount,
cp.p95_amount
FROM orders o
JOIN users u ON u.id = o.user_id
JOIN country_percentiles cp ON cp.country = u.country;
Так каждая строка заказа получит p95 своей страны.
Если же вам нужна позиция конкретной строки внутри распределения, смотрите в сторону оконных функций вроде:
PERCENT_RANK()
CUME_DIST()
NTILE()
Они отвечают на другие вопросы: не «какое значение у p95», а «где эта строка находится внутри отсортированного набора».
PERCENTILE_CONT и производительность
Чтобы посчитать перцентиль, базе нужно отсортировать значения.
Это дороже, чем простой AVG.
Например:
AVG(amount)
можно считать потоком: база просто суммирует значения и считает количество.
А для:
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY amount)
нужно понимать порядок значений.
На маленьких таблицах это обычно не проблема.
На больших таблицах перцентили могут быть дорогими, особенно если:
- много групп;
- много строк в каждой группе;
- считается много разных перцентилей;
- запрос работает по большой сырой таблице;
- нет предварительной агрегации или партиционирования.
Практические советы:
- фильтруйте лишние строки до расчёта;
- считайте несколько перцентилей массивом, если нужны p50, p95 и p99;
- проверяйте план через
EXPLAIN ANALYZE;
- для регулярных дашбордов подумайте о витрине;
- для мониторинга latency на огромных потоках иногда используют приближённые алгоритмы в специализированных системах.
PERCENTILE_CONT точный и удобный, но точность может стоить ресурсов.
MySQL: прямого PERCENTILE_CONT обычно нет
В MySQL нет такого же удобного стандартного PERCENTILE_CONT как в PostgreSQL.
Медиану и перцентили там часто считают через оконные функции:
ROW_NUMBER();
PERCENT_RANK();
CUME_DIST();
- ручной расчёт позиции.
Для медианы это может выглядеть заметно длиннее, чем в PostgreSQL.
И главное: встроенной удобной интерполяции как у PERCENTILE_CONT обычно нет, поэтому запросы приходится писать вручную и внимательно проверять.
Если вы пишете переносимый SQL, не рассчитывайте, что синтаксис PostgreSQL с WITHIN GROUP просто заработает в MySQL.
В ClickHouse для похожих задач используют семейство функций quantile.
Например, p95:
SELECT
quantile(0.95)(amount) AS p95_amount
FROM orders
WHERE status = 'paid';
Точный вариант:
SELECT
quantileExact(0.95)(amount) AS p95_amount
FROM orders
WHERE status = 'paid';
Медиана:
SELECT
median(amount) AS median_amount
FROM orders
WHERE status = 'paid';
В ClickHouse синтаксис другой: нет WITHIN GROUP, а уровень точности зависит от выбранной функции.
Для больших аналитических данных это нормально: ClickHouse часто использует быстрые агрегаты и приближённые варианты, если точность до последнего значения не нужна.
Практические шаблоны
Медиана суммы заказа
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount::double precision) AS median_amount
FROM orders;
p95 суммы заказа
SELECT
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY amount::double precision) AS p95_amount
FROM orders;
p50, p95 и p99 одним массивом
SELECT
PERCENTILE_CONT(ARRAY[0.5, 0.95, 0.99])
WITHIN GROUP (ORDER BY amount::double precision) AS percentiles
FROM orders
WHERE status = 'paid';
Разложить массив по колонкам
SELECT
pct[1] AS p50,
pct[2] AS p95,
pct[3] AS p99
FROM (
SELECT
PERCENTILE_CONT(ARRAY[0.5, 0.95, 0.99])
WITHIN GROUP (ORDER BY amount::double precision) AS pct
FROM orders
WHERE status = 'paid'
) s;
Медиана и среднее по отделам
SELECT
dept,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary::double precision) AS median_salary,
AVG(salary) AS avg_salary
FROM employees
GROUP BY dept
ORDER BY median_salary DESC;
p95 latency по endpoint
SELECT
endpoint,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY response_time_ms::double precision) AS p95_latency_ms,
AVG(response_time_ms) AS avg_latency_ms
FROM api_requests
GROUP BY endpoint
ORDER BY p95_latency_ms DESC;
p95 длительности через interval
SELECT
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY finished_at - started_at) AS p95_duration
FROM jobs
WHERE finished_at IS NOT NULL;
Перцентиль только по сегменту через FILTER
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount::double precision)
FILTER (WHERE status = 'paid') AS median_paid_amount,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY amount::double precision)
FILTER (WHERE status = 'paid') AS p95_paid_amount
FROM orders;
Дискретная медиана
SELECT
PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY rating) AS median_rating
FROM reviews;
Что важно запомнить
PERCENTILE_CONT считает перцентиль по отсортированному набору значений.
Пример:
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount
FROM orders;
Главные правила:
0.5 — медиана;
0.95 — p95;
0.99 — p99;
- сортировка пишется внутри
WITHIN GROUP;
PERCENTILE_CONT может вернуть значение, которого не было в таблице;
- это происходит из-за интерполяции;
NULL игнорируются;
- с
GROUP BY перцентиль считается отдельно по каждой группе;
- массив долей позволяет получить несколько перцентилей сразу;
PERCENTILE_DISC возвращает реальное значение из набора;
AVG чувствителен к выбросам, а медиана и p95 лучше описывают центр и хвост распределения.
Короткий вывод
PERCENTILE_CONT нужен, когда среднего уже недостаточно.
Если данные имеют выбросы и длинный хвост, AVG может показать красивое, но не очень честное число.
Для типичного значения лучше смотреть медиану:
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount
FROM orders;
Для хвоста распределения — p95 или p99:
SELECT
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY response_time_ms) AS p95_latency
FROM api_requests;
Главная мысль:
AVG показывает среднее,
медиана показывает типичную середину,
p95 и p99 показывают хвост, который часто важнее для пользователей.
В PostgreSQL PERCENTILE_CONT ... WITHIN GROUP даёт прямой и понятный способ считать эти метрики в SQL.
Среднее значение не всегда честно описывает данные.
Например, у нас есть суммы заказов:
Если посчитать среднее, большой заказ на
10000сильно сдвинет результат вверх.Но типичный заказ явно не выглядит как
2100. Большинство заказов находятся около100-150.В таких ситуациях лучше смотреть не только на
AVG, а на перцентили:В PostgreSQL для этого есть функция
PERCENTILE_CONT.Она помогает считать медиану, p95, p99 и любые другие перцентили прямо в SQL.
Что такое перцентиль простыми словами
Перцентиль отвечает на вопрос:
Например, p95 по времени ответа API равен
800 ms.Это значит:
А оставшиеся 5% были медленнее.
Медиана — это p50.
Если медиана чека равна
1200, это значит:Перцентили особенно полезны там, где данные имеют длинный хвост:
В таких данных среднее может сильно врать из-за нескольких экстремальных значений.
Почему AVG иногда вводит в заблуждение
Возьмём простой пример.
Есть пять заказов:
Среднее:
Но если мы скажем:
это будет странно. Почти все реальные заказы намного меньше.
Медиана для этих данных —
130.Она намного лучше показывает типичный заказ.
Среднее отвечает на вопрос:
Медиана отвечает на другой вопрос:
Для данных с выбросами медиана часто полезнее.
Что делает PERCENTILE_CONT
PERCENTILE_CONTсчитает значение на нужной доле отсортированного набора.Например:
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount FROM orders;Здесь:
PERCENTILE_CONT(0.5)означает: взять 50-й перцентиль, то есть медиану.
А:
WITHIN GROUP (ORDER BY amount)говорит PostgreSQL:
Важно: сортировка для
PERCENTILE_CONTпишется не обычнымORDER BYв конце запроса, а внутриWITHIN GROUP.Базовый синтаксис
Общий вид:
PERCENTILE_CONT(fraction) WITHIN GROUP (ORDER BY expression)Где:
fraction— доля от0до1;expression— значение, по которому строится распределение.Примеры:
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount)медиана суммы заказа.
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY response_time_ms)p95 времени ответа.
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY duration_seconds)p99 длительности.
Частые значения:
Пример: медиана суммы заказа
Допустим, есть таблица
orders:Посчитаем среднее и медиану:
SELECT AVG(amount) AS avg_amount, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount FROM orders;Результат:
Среднее сильно выросло из-за заказа на
10000.А медиана показала более типичное значение.
Именно поэтому в аналитике часто смотрят не только
AVG, но и медиану.Что означает WITHIN GROUP
WITHIN GROUPнужен для агрегатов, которым важен порядок значений.Обычному
SUMпорядок не нужен.Например:
SUM(amount)даст один и тот же результат независимо от того, как отсортированы строки.
А вот перцентиль без сортировки не имеет смысла.
Чтобы найти медиану или p95, PostgreSQL должен знать порядок значений:
Именно поэтому
PERCENTILE_CONTпишется так:PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount)То есть:
Обычный
ORDER BYв конце запроса сортирует итоговые строки результата.А
ORDER BYвнутриWITHIN GROUPопределяет распределение, по которому считается перцентиль.Это разные вещи.
PERCENTILE_CONT — это агрегат
PERCENTILE_CONTработает как агрегатная функция.То есть он возвращает:
GROUP BY.Например, один p95 по всей таблице:
SELECT PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY amount) AS p95_amount FROM orders;А p95 по каждой стране:
SELECT u.country, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY o.amount) AS p95_amount FROM orders o JOIN users u ON u.id = o.user_id GROUP BY u.country ORDER BY p95_amount DESC;В этом запросе PostgreSQL отдельно сортирует суммы заказов внутри каждой страны и считает p95 для каждой группы.
NULL игнорируются
PERCENTILE_CONTигнорируетNULL.Например, если в данных есть значения:
перцентиль будет считаться только по ненулевым значениям:
Это похоже на поведение многих агрегатов в SQL:
AVG,SUM,MIN,MAXтоже не учитываютNULL.Если в группе все значения
NULL, результатом будетNULL.Например:
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount FROM orders WHERE status = 'cancelled';Если у отменённых заказов нет заполненных
amount, медиана будетNULL.Для отчёта иногда можно обернуть результат в
COALESCE, но делать это нужно осознанно. Нулевая медиана и неизвестная медиана — не одно и то же.CONT означает continuous
В названии
PERCENTILE_CONTчастьCONTозначает continuous, то есть непрерывный.Это значит, что функция может вернуть значение, которого не было в таблице.
Почему?
Потому что если нужная точка попадает между двумя строками, PostgreSQL интерполирует значение между ними.
Проще всего понять это на медиане.
Допустим, есть четыре суммы:
У набора чётное количество значений. У него нет одной центральной строки.
Центр находится между
20и30.PERCENTILE_CONT(0.5)вернёт:Запрос:
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount FROM orders;Результат:
Значения
25не было в таблице. Оно получилось как середина между20и30.Это и есть интерполяция.
PERCENTILE_CONT против PERCENTILE_DISC
В PostgreSQL есть ещё одна похожая функция:
DISCозначает discrete, то есть дискретный.Главное отличие:
PERCENTILE_CONTможет вернуть промежуточное значение;PERCENTILE_DISCвсегда возвращает одно из реальных значений из набора.Возьмём данные:
Сравним:
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS cont_median, PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY amount) AS disc_median FROM orders;Результат может быть таким:
PERCENTILE_CONTвернул25, потому что интерполировал между20и30.PERCENTILE_DISCвернул реальное значение из набора.Когда использовать CONT, а когда DISC
PERCENTILE_CONTхорошо подходит для непрерывных величин:Например:
SELECT PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY response_time_ms) AS p95_latency FROM api_requests;PERCENTILE_DISCлучше использовать, когда результат должен быть существующим значением из набора.Например:
Пример:
SELECT PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY rating) AS median_rating FROM reviews;Если рейтинг может быть только
1,2,3,4,5, дискретный вариант часто логичнее.Коротко:
Пример: p95 времени ответа API
Перцентили особенно часто используют для latency.
Допустим, есть таблица
api_requests:Посчитаем p95 по каждому endpoint:
SELECT endpoint, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY response_time_ms) AS p95_latency_ms, AVG(response_time_ms) AS avg_latency_ms FROM api_requests GROUP BY endpoint ORDER BY p95_latency_ms DESC;Такой отчёт показывает не только среднее, но и хвост задержек.
Почему p95 важен?
Потому что пользовательский опыт часто портят именно медленные запросы.
Среднее может выглядеть нормально, но если p95 высокий, значит заметная часть пользователей сталкивается с задержками.
Например:
Это сигнал: большинство запросов быстрые, но хвост очень плохой.
p95, p99 и SLO
В инженерных метриках часто смотрят p95 и p99.
Например:
Это значит:
Это значит:
Если p99 намного больше p95, значит есть редкие, но очень тяжёлые выбросы.
Для SLO и мониторинга это важнее, чем просто среднее.
Например, среднее время ответа может быть
200 ms, но p99 —5 s. В таком случае часть пользователей всё равно получает плохой опыт.Несколько перцентилей одним запросом
Для дашборда часто нужны сразу p50, p95 и p99.
Можно написать три отдельных вызова:
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS p50, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY amount) AS p95, PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY amount) AS p99 FROM orders WHERE status = 'paid';Это понятно, но PostgreSQL также позволяет передать массив долей:
SELECT PERCENTILE_CONT(ARRAY[0.5, 0.95, 0.99]) WITHIN GROUP (ORDER BY amount) AS percentiles FROM orders WHERE status = 'paid';Результат будет массивом:
Здесь:
Важно не перепутать порядок. Результаты идут в том же порядке, что и доли в массиве.
Как разложить массив перцентилей по колонкам
Массив удобно считать, но в отчёте часто нужны отдельные колонки.
Можно сделать так:
SELECT pct[1] AS p50, pct[2] AS p95, pct[3] AS p99 FROM ( SELECT PERCENTILE_CONT(ARRAY[0.5, 0.95, 0.99]) WITHIN GROUP (ORDER BY amount) AS pct FROM orders WHERE status = 'paid' ) s;Результат:
Здесь
pct[1]— первый элемент массива.В PostgreSQL массивы индексируются с
1, а не с0.Можно запомнить:
Медиана по группам
PERCENTILE_CONTхорошо работает вместе сGROUP BY.Например, посчитаем медианную зарплату по отделам.
SELECT dept, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) AS median_salary, AVG(salary) AS avg_salary FROM employees GROUP BY dept ORDER BY median_salary DESC;Результат может быть таким:
Если в отделе
salesесть один очень большой бонус или контракт, среднее может сильно вырасти.Медиана при этом покажет более типичную зарплату.
Для зарплат, чеков и latency медиана часто полезнее среднего.
p95 суммы заказа по странам
Пример для продуктовой аналитики.
Хотим понять, в каких странах высокий хвост по суммам заказов.
SELECT u.country, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY o.amount) AS p95_amount, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY o.amount) AS median_amount, AVG(o.amount) AS avg_amount FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status = 'paid' GROUP BY u.country ORDER BY p95_amount DESC;Такой отчёт показывает:
Если в стране медиана
1000, а p9520000, значит большинство заказов обычные, но есть дорогой хвост.Это может быть важно для:
PERCENTILE_CONT с FILTER
PERCENTILE_CONTможно сочетать сFILTER, как и другие агрегаты.Например, посчитать медиану только по оплаченным заказам и p95 только по возвратам в одном запросе:
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) FILTER (WHERE status = 'paid') AS median_paid_amount, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY amount) FILTER (WHERE status = 'refunded') AS p95_refunded_amount FROM orders;Это удобно, когда нужно несколько метрик по разным сегментам из одной таблицы.
Но не стоит превращать один
SELECTв огромную простыню из десятков перцентилей. Если метрик становится слишком много, лучше подумать о витрине или отдельной структуре отчёта.Важная техническая деталь про типы
В PostgreSQL
PERCENTILE_CONTработает с числовыми значениями, которые можно интерполировать, а также с интервалами.На практике для денежных значений и других numeric-колонок иногда полезно явно привести выражение к
double precision.Например:
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount::double precision) AS median_amount FROM orders;Почему так?
Потому что
PERCENTILE_CONTвозвращает интерполированное значение. В PostgreSQL результат для числового варианта обычно получается какdouble precision.Если вам нужно округление до денег, лучше сделать это явно:
SELECT ROUND( PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount::double precision)::numeric, 2 ) AS median_amount FROM orders;Так вы явно контролируете формат результата.
Для
intervalможно считать перцентили длительности:SELECT PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY finished_at - started_at) AS p95_duration FROM jobs WHERE finished_at IS NOT NULL;Здесь результатом будет интервал.
Почему PERCENTILE_CONT не пишут как обычный ORDER BY
Новички иногда пытаются написать что-то вроде:
SELECT PERCENTILE_CONT(0.5) AS median_amount FROM orders ORDER BY amount;Так нельзя.
PERCENTILE_CONTдолжен знать, по какому выражению строить распределение. Поэтому сортировка указывается внутриWITHIN GROUP:SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount FROM orders;Обычный
ORDER BYв конце запроса нужен для сортировки строк результата.Например:
SELECT u.country, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY o.amount) AS median_amount FROM orders o JOIN users u ON u.id = o.user_id GROUP BY u.country ORDER BY median_amount DESC;Здесь:
WITHIN GROUP (ORDER BY o.amount)нужно для расчёта медианы внутри каждой страны.
А:
ORDER BY median_amount DESCсортирует итоговые страны по медиане.
PERCENTILE_CONT и оконные функции
PERCENTILE_CONT— это ordered-set агрегат.В обычной аналитике его чаще используют как агрегат:
SELECT country, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY amount) AS p95_amount FROM orders GROUP BY country;Если вам нужно получить p95 по группе и показать его рядом с каждой исходной строкой, часто проще посчитать перцентиль в подзапросе, а потом присоединить обратно.
Например:
WITH country_percentiles AS ( SELECT u.country, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY o.amount) AS p95_amount FROM orders o JOIN users u ON u.id = o.user_id GROUP BY u.country ) SELECT o.id, u.country, o.amount, cp.p95_amount FROM orders o JOIN users u ON u.id = o.user_id JOIN country_percentiles cp ON cp.country = u.country;Так каждая строка заказа получит p95 своей страны.
Если же вам нужна позиция конкретной строки внутри распределения, смотрите в сторону оконных функций вроде:
PERCENT_RANK() CUME_DIST() NTILE()Они отвечают на другие вопросы: не «какое значение у p95», а «где эта строка находится внутри отсортированного набора».
PERCENTILE_CONT и производительность
Чтобы посчитать перцентиль, базе нужно отсортировать значения.
Это дороже, чем простой
AVG.Например:
AVG(amount)можно считать потоком: база просто суммирует значения и считает количество.
А для:
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY amount)нужно понимать порядок значений.
На маленьких таблицах это обычно не проблема.
На больших таблицах перцентили могут быть дорогими, особенно если:
Практические советы:
EXPLAIN ANALYZE;PERCENTILE_CONTточный и удобный, но точность может стоить ресурсов.MySQL: прямого PERCENTILE_CONT обычно нет
В MySQL нет такого же удобного стандартного
PERCENTILE_CONTкак в PostgreSQL.Медиану и перцентили там часто считают через оконные функции:
ROW_NUMBER();PERCENT_RANK();CUME_DIST();Для медианы это может выглядеть заметно длиннее, чем в PostgreSQL.
И главное: встроенной удобной интерполяции как у
PERCENTILE_CONTобычно нет, поэтому запросы приходится писать вручную и внимательно проверять.Если вы пишете переносимый SQL, не рассчитывайте, что синтаксис PostgreSQL с
WITHIN GROUPпросто заработает в MySQL.ClickHouse: quantile и median
В ClickHouse для похожих задач используют семейство функций
quantile.Например, p95:
SELECT quantile(0.95)(amount) AS p95_amount FROM orders WHERE status = 'paid';Точный вариант:
SELECT quantileExact(0.95)(amount) AS p95_amount FROM orders WHERE status = 'paid';Медиана:
SELECT median(amount) AS median_amount FROM orders WHERE status = 'paid';В ClickHouse синтаксис другой: нет
WITHIN GROUP, а уровень точности зависит от выбранной функции.Для больших аналитических данных это нормально: ClickHouse часто использует быстрые агрегаты и приближённые варианты, если точность до последнего значения не нужна.
Практические шаблоны
Медиана суммы заказа
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount::double precision) AS median_amount FROM orders;p95 суммы заказа
SELECT PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY amount::double precision) AS p95_amount FROM orders;p50, p95 и p99 одним массивом
SELECT PERCENTILE_CONT(ARRAY[0.5, 0.95, 0.99]) WITHIN GROUP (ORDER BY amount::double precision) AS percentiles FROM orders WHERE status = 'paid';Разложить массив по колонкам
SELECT pct[1] AS p50, pct[2] AS p95, pct[3] AS p99 FROM ( SELECT PERCENTILE_CONT(ARRAY[0.5, 0.95, 0.99]) WITHIN GROUP (ORDER BY amount::double precision) AS pct FROM orders WHERE status = 'paid' ) s;Медиана и среднее по отделам
SELECT dept, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary::double precision) AS median_salary, AVG(salary) AS avg_salary FROM employees GROUP BY dept ORDER BY median_salary DESC;p95 latency по endpoint
SELECT endpoint, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY response_time_ms::double precision) AS p95_latency_ms, AVG(response_time_ms) AS avg_latency_ms FROM api_requests GROUP BY endpoint ORDER BY p95_latency_ms DESC;p95 длительности через interval
SELECT PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY finished_at - started_at) AS p95_duration FROM jobs WHERE finished_at IS NOT NULL;Перцентиль только по сегменту через FILTER
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount::double precision) FILTER (WHERE status = 'paid') AS median_paid_amount, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY amount::double precision) FILTER (WHERE status = 'paid') AS p95_paid_amount FROM orders;Дискретная медиана
SELECT PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY rating) AS median_rating FROM reviews;Что важно запомнить
PERCENTILE_CONTсчитает перцентиль по отсортированному набору значений.Пример:
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount FROM orders;Главные правила:
0.5— медиана;0.95— p95;0.99— p99;WITHIN GROUP;PERCENTILE_CONTможет вернуть значение, которого не было в таблице;NULLигнорируются;GROUP BYперцентиль считается отдельно по каждой группе;PERCENTILE_DISCвозвращает реальное значение из набора;AVGчувствителен к выбросам, а медиана и p95 лучше описывают центр и хвост распределения.Короткий вывод
PERCENTILE_CONTнужен, когда среднего уже недостаточно.Если данные имеют выбросы и длинный хвост,
AVGможет показать красивое, но не очень честное число.Для типичного значения лучше смотреть медиану:
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount FROM orders;Для хвоста распределения — p95 или p99:
SELECT PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY response_time_ms) AS p95_latency FROM api_requests;Главная мысль:
В PostgreSQL
PERCENTILE_CONT ... WITHIN GROUPдаёт прямой и понятный способ считать эти метрики в SQL.