Распределения: почему хвосты важнее центра
Описательная статистика закончилась на неприятной ноте: среднее — это сжатие с потерями, и потерять оно может ровно то, ради чего вы считали. Эта глава про то, что именно теряется. Ответ короткий: форма. А в прикладных данных форма почти никогда не такая, какую подразумевают привычные формулы.
Начнём с примера, который повторяется в каждой второй компании. Аналитик приносит: «средняя выручка на пользователя за июнь — 480 рублей, в мае было 455, растём на 5,5 процента». На цифру опирается прогноз, потом план найма. Через месяц выясняется, что в июне один корпоративный клиент оплатил годовой тариф на 4 миллиона рублей. Пользователей 200 тысяч, значит один клиент дал 20 рублей к среднему — почти весь «рост». Никто не соврал: сумма верна, деление верно. Просто среднее по такой форме данных — это не характеристика пользователя, а характеристика того, попал ли в окно один конкретный платёж.
Главная мысль главы: у распределения есть центр и есть хвост, и почти всегда решение зависит от хвоста, а сводная цифра описывает центр. Плюс второй слой, сквозной для всего трека: хвост, который вы видите в таблице, — это не хвост реальности, а хвост, прошедший через ваш способ сбора данных. Таймауты, семплирование, фильтры «выбросов» и предагрегация срезают именно те наблюдения, ради которых всё затевалось.
1. Распределение — это ответ, а статистика — его сжатие
Полный ответ на вопрос «сколько времени отвечает наш API» — это не число, а функция: для каждого значения t доля запросов быстрее t. Всё остальное — среднее, медиана, p99, дисперсия — это сжатие этой функции в один скаляр, и как у любого сжатия с потерями, у него есть спецификация: что оно сохраняет, а что выбрасывает.
| Статистика | Что сохраняет | Что выбрасывает |
|---|---|---|
| Среднее | сумму, то есть общий объём ресурса | всё про форму: одно огромное значение неотличимо от тысячи средних |
| Медиана | «типичный» случай, устойчива к выбросам | полностью игнорирует хвост, каким бы он ни был |
| Дисперсия | разброс — если он вообще конечен | бессмысленна при асимметрии, у тяжёлых хвостов может не существовать |
| p99 | границу, за которой живёт 1 процент худших случаев | сколько именно там: 1,1 секунды или 40 секунд — одинаковый p99 |
| Максимум | худший наблюдавшийся случай | всё остальное, и сам растёт с размером выборки |
Отсюда практическое правило, к которому мы вернёмся в конце: сначала решение, потом статистика. Если решение — «сколько серверов купить», нужна сумма, значит среднее. Если решение — «переживёт ли пользователь наш чекаут», нужна доля пострадавших, значит перцентиль или доля выше порога. Если решение — «стоит ли верить прогнозу выручки», нужно знать, конечна ли вообще дисперсия. Это продолжение принципа из главы «Сначала вопрос».
2. Зоопарк форм, которые реально встречаются
Формы распределений не случайны — каждая порождается своим механизмом. Знать механизм полезнее, чем знать формулу: он подсказывает, чего ждать от данных, которые вы ещё не видели.
Нормальное. Возникает как сумма многих независимых сопоставимых слагаемых — центральная предельная теорема, разобранная в теории вероятностей. В продуктовых данных встречается редко: рост человека, ошибка измерения, а также — и это важно — средние по большим группам. Сама длительность запроса не нормальна никогда, а вот среднее по 10 тысячам запросов уже близко к нормальному, и на этом стоит вся классическая статистика.
Биномиальное. Каждый пользователь либо конвертировался, либо нет. Единственное место, где нормальное приближение работает почти без оговорок: при n·p ≥ 10 и n·(1-p) ≥ 10. Именно поэтому A/B-тесты на конверсию считаются легко, а A/B-тесты на выручку — тяжело: у выручки хвост.
Экспоненциальное и пуассоновское. Время до следующего события при постоянной интенсивности и число событий за интервал. У экспоненциального нет памяти: сколько ни ждал, ожидаемое оставшееся время то же; хвост лёгкий, убывает как $e^{-\lambda x}$. Пуассоновское часто оказывается слишком узким для реальных счётчиков — если дисперсия заметно больше среднего, это перерассеяние, и нужна отрицательная биномиальная модель.
Логнормальное. Если величина складывается не сложением, а умножением многих факторов, логарифм становится суммой, и по ЦПТ логарифм нормален. Это самая частая форма в продуктовых данных: длительность сессии, время отклика, размер заказа, зарплаты. Проверка занимает минуту: возьмите ln(x) и постройте гистограмму — если получился колокол, у вас логнормальное.
Степенное (Парето). Порождается положительной обратной связью: у кого больше подписчиков, тот получает ещё больше — «предпочтительное присоединение». Отсюда распределение размеров городов, числа подписчиков, размеров файлов, выручки на клиента в B2B. Про механику усиливающих петель — нелинейность в системном мышлении.
Смеси и избыток нулей. Данные двух разных режимов, слитые в одну колонку: попадание в кэш и промах, мобильные и десктоп, боты и люди. Гистограмма двугорбая, и любая сводная цифра описывает несуществующее среднее между двумя реальностями; лечение — не статистика, а разделение по колонке, которая различает режимы. Частный случай — выручка на пользователя: 97 процентов нулей и 3 процента платежей. Её раскладывают на две интерпретируемые части, ARPU = конверсия × ARPPU, потому что по одному среднему невозможно понять, какая из них сдвинулась.
3. Что значит «тяжёлый хвост» и почему это меняет всё
Интуиция: у распределения тяжёлый хвост, если редкие большие значения дают заметную долю суммы. Формально — хвост убывает медленнее любой экспоненты; в практически важном степенном случае
$$P(X > x) \approx C \cdot x^{-\alpha}, \qquad x \to \infty$$
где $\alpha$ — показатель хвоста. Он определяет, какие статистики вообще существуют:
| Диапазон $\alpha$ | Математический факт | Практическое следствие |
|---|---|---|
| $\alpha > 2$ | среднее и дисперсия конечны | обычная статистика работает, но сходимость медленная |
| $1 < \alpha \le 2$ | среднее конечно, дисперсия бесконечна | доверительные интервалы по ЦПТ врут, среднее скачет от выборки к выборке |
| $\alpha \le 1$ | среднее не существует | выборочное среднее растёт с объёмом выборки; «средний клиент» — фикция |
«Дисперсия бесконечна» звучит абстрактно, пока не увидишь это на своих данных: вы считаете среднюю выручку за месяц, и она прыгает на десятки процентов между соседними месяцами без всякой причины, кроме того, попал ли в окно один крупный клиент.
Картинка выше — рабочий инструмент, а не иллюстрация. По горизонтали значение в логарифмической шкале, по вертикали доля наблюдений больше этого значения (CCDF), тоже в логарифмической. В этих осях степенное распределение — прямая линия, наклон которой равен минус $\alpha$; логнормальное — вогнутая кривая; нормальное — обрыв. Построив такой график по своим данным, вы за десять секунд отвечаете на вопрос «насколько всё плохо».
Логнормальное: полезные точные формулы
Если $\ln X$ нормален со средним $\mu$ и стандартным отклонением $\sigma$, то:
$$\text{медиана} = e^{\mu}, \qquad \mathbb{E}\lbrack X \rbrack = e^{\mu + \sigma^2/2}, \qquad \frac{\text{среднее}}{\text{медиана}} = e^{\sigma^2/2}$$
Пусть $\mu = 5$, $\sigma = 1{,}2$ (типичные значения для времени отклика в миллисекундах). Тогда медиана $e^5 \approx 148$ мс, среднее $e^{5{,}72} \approx 305$ мс, а p99 равен $e^{\mu + 2{,}326\sigma} = e^{7{,}79} \approx 2417$ мс. Половина пользователей видит 148 миллисекунд, «средний пользователь» не существует вовсе, а один из ста ждёт две с половиной секунды.
Обратный ход полезнее прямого. Из двух чисел, которые у вас уже есть на дашборде, восстанавливается вся форма:
$$\sigma \approx \frac{\ln \left( p_{99} / p_{50} \right)}{2{,}326}$$
Для 148 и 2417: $\ln(16{,}3)/2{,}326 = 2{,}79/2{,}326 = 1{,}2$. Сошлось. Отношение $p_{99}/p_{50}$ — самый дешёвый индикатор тяжести хвоста: около 2–3 значит почти симметрию, 5–20 — логнормальность, больше 50 — вы в зоне, где сумма и среднее опасны.
Как вытащить $\alpha$ из доли, которую вы уже считаете
Для распределения Парето доля суммы, приходящаяся на верхнюю долю $p$ наблюдений, равна $p^{(\alpha-1)/\alpha}$. Формулу можно обратить: если верхние $p$ клиентов дают долю $s$ выручки, то
$$\alpha = \frac{1}{1 - \ln s / \ln p}$$
Проверим на классическом правиле «20 процентов клиентов дают 80 процентов выручки»: $\ln 0{,}8 / \ln 0{,}2 = 0{,}1386$, отсюда $\alpha = 1{,}16$ — дисперсия бесконечна, а среднее держится на волоске. Если топ-20 процентов дают 95 процентов, то $\alpha = 1{,}03$: прогнозировать среднюю выручку бессмысленно, надо прогнозировать крупных клиентов поимённо. Оговорка, без которой формула превращается во вредительство: реальные данные редко степенные на всём диапазоне, а подгонка прямой на лог-логе систематически смещена. Это диагностика «насколько всё плохо», а не оценка параметра для публикации; как делать правильно — Clauset, Shalizi, Newman.
4. Диагностика на своих данных: SQL и немного Python
Логарифмические корзины — правильный способ смотреть на скошенные данные: они дают одинаковую детализацию и в области 10 миллисекунд, и в области 10 секунд.
-- Одна строка = корзина шириной в одну степень двойки.
-- duration_ms > 0 обязательно: ln(0) не определён.
WITH bucketed AS (
SELECT floor(ln(duration_ms::double precision) / ln(2.0))::int AS bucket
FROM api_requests
WHERE received_at >= TIMESTAMPTZ '2026-06-01 00:00+03'
AND received_at < TIMESTAMPTZ '2026-07-01 00:00+03'
AND duration_ms > 0
)
SELECT bucket,
power(2, bucket)::bigint AS lo_ms, -- верхняя граница = 2 * lo_ms
count(*) AS n,
round(100.0 * count(*) / sum(count(*)) OVER (), 3) AS pct
FROM bucketed
GROUP BY bucket
ORDER BY bucket;
Тот же CCDF, что на картинке, — запросом. На миллионах строк считать cume_dist() построчно дорого, поэтому сначала схлопываем до уникальных значений:
WITH counted AS (
SELECT duration_ms, count(*) AS n
FROM api_requests
WHERE received_at >= TIMESTAMPTZ '2026-06-01 00:00+03'
AND received_at < TIMESTAMPTZ '2026-07-01 00:00+03'
GROUP BY duration_ms
)
SELECT duration_ms,
-- P(X > x): доля наблюдений строго больше текущего значения
1.0 - sum(n) OVER (ORDER BY duration_ms) / sum(n::numeric) OVER () AS ccdf
FROM counted
ORDER BY duration_ms;
Последняя строка получит ccdf = 0 — на логарифмической оси её просто не будет; это нормально и не повод «чинить» запрос.
Проверка на Парето — доли, которые держит верхушка:
WITH per_user AS (
SELECT user_id, sum(total_amount) AS revenue
FROM orders
WHERE status = 'paid'
AND created_at >= TIMESTAMPTZ '2026-01-01 00:00+03'
AND created_at < TIMESTAMPTZ '2026-07-01 00:00+03'
GROUP BY user_id
),
ranked AS (
SELECT revenue,
row_number() OVER (ORDER BY revenue DESC) AS rn,
count(*) OVER () AS n,
sum(revenue) OVER () AS total
FROM per_user
)
SELECT max(n) AS users,
round(100.0 * sum(revenue) FILTER (WHERE rn <= ceil(n * 0.01)) / max(total), 1) AS top_1pct_share,
round(100.0 * sum(revenue) FILTER (WHERE rn <= ceil(n * 0.20)) / max(total), 1) AS top_20pct_share
FROM ranked;
И разложение смеси с нулями на интерпретируемые части — вместо одного ARPU, который меняется по двум разным причинам сразу:
SELECT
round(100.0 * count(*) FILTER (WHERE revenue > 0) / count(*), 2) AS conversion_pct,
round(avg(revenue), 2) AS arpu,
round(avg(revenue) FILTER (WHERE revenue > 0), 2) AS arppu,
-- percentile_disc возвращает реально существовавшее значение,
-- а не интерполяцию: для денег это почти всегда правильный выбор
percentile_disc(0.5) WITHIN GROUP (ORDER BY revenue)
FILTER (WHERE revenue > 0) AS median_payer,
percentile_disc(0.99) WITHIN GROUP (ORDER BY revenue)
FILTER (WHERE revenue > 0) AS p99_payer
FROM user_revenue_monthly
WHERE month = DATE '2026-06-01';
Разбор оконных функций и FILTER — в главе «SQL для анализа».
Оценка показателя хвоста и демонстрация того, как ведёт себя среднее при $\alpha < 2$:
import numpy as np
rng = np.random.default_rng(42)
def hill_alpha(sample: np.ndarray, k: int) -> float:
"""Оценка Хилла показателя alpha в P(X > x) ~ C * x^(-alpha).
k — сколько верхних наблюдений считаем «хвостом»; разумный старт —
5 процентов выборки. Результат обязательно смотреть как функцию k:
если он «плывёт», степенной модели здесь нет.
Время O(n log n) из-за сортировки, память O(n).
"""
s = np.sort(sample)[::-1] # по убыванию
top, threshold = s[:k], s[k] # (k+1)-я по величине — порог
return 1.0 / np.mean(np.log(top / threshold))
# Классическое Парето: numpy даёт Ломакса, поэтому +1 и умножение на масштаб
alpha, x_m = 1.2, 100.0
sample = (rng.pareto(alpha, size=1_000_000) + 1.0) * x_m
print(round(hill_alpha(sample, k=50_000), 3)) # около 1.20 — оценка работает
# Теоретическое среднее существует: alpha * x_m / (alpha - 1) = 600
for n in (10**3, 10**4, 10**5, 10**6):
means = [((rng.pareto(alpha, n) + 1.0) * x_m).mean() for _ in range(20)]
print(n, round(min(means)), round(max(means)))
# n=1e3: ~370 .. ~2600
# n=1e6: ~520 .. ~1400 — разброс не схлопывается даже на миллионе
Последние строки — вся суть тяжёлых хвостов в одном эксперименте. Для нормальных данных разброс выборочного среднего падает как $1/\sqrt{n}$, и на миллионе наблюдений среднее известно с точностью в доли процента. Здесь на миллионе наблюдений оно всё ещё гуляет в разы. Никакой размер выборки это не лечит, потому что не хватает не данных, а конечной дисперсии.
5. Перцентили: определение и арифметика, которой не существует
Перцентиль уровня q — значение, ниже которого лежит доля q наблюдений. Самое понятное определение — по ближайшему рангу: отсортировать n значений и взять элемент с номером $\lceil q \cdot n \rceil$. Postgres предлагает два варианта: percentile_cont интерполирует между соседями, percentile_disc возвращает реально существовавшее наблюдение.
Среднее перцентилей не является перцентилем. Ни простое, ни взвешенное по количеству запросов. Никогда. Это правило нарушают чаще любого другого в аналитике.
Контрпример, который стоит держать в голове целиком. Два шарда за один час:
| Источник | Запросов | Длительности | p95 |
|---|---|---|---|
| Шард A | 1000 | все по 50 мс | 50 мс |
| Шард B | 10 | все по 2000 мс | 2000 мс |
| Вместе | 1010 | 1000 по 50 мс и 10 по 2000 мс | 50 мс |
Среднее двух p95 — 1025 мс. Взвешенное по числу запросов — 69 мс. Истинный p95 объединённой выборки: $\lceil 0{,}95 \cdot 1010 \rceil = 960$, а девятисотшестидесятое значение по возрастанию — это 50 мс. Ошибка простого усреднения — двадцатикратная, и она в сторону паники: дашборд покажет секунду там, где пользователи видят 50 миллисекунд.
Можно ли ошибиться в другую сторону? Да. Точное утверждение такое: квантиль смеси лежит между минимальным и максимальным квантилями компонент, но где именно — не определяется ими. Доказательство на одну строку: функция распределения смеси есть взвешенное среднее функций распределения компонент, поэтому она зажата между наименьшей и наибольшей из них, и то же верно для обратной функции. Пример недооценки: шард A — 96 запросов по 10 мс и 4 по 500 мс, шард B — 96 по 20 мс и 4 по 600 мс. Их p95 равны 10 и 20 мс, среднее 15 мс, а объединённый p95 равен 20 мс.
Отсюда прямое следствие для витрин: таблица с дневными перцентилями не позволяет получить недельный перцентиль. Никакой запрос поверх неё не даст верного ответа.
-- НЕВЕРНО: агрегат поверх агрегата, число получится, смысла у него нет
SELECT avg(p95_ms) AS "p95 за месяц"
FROM latency_daily
WHERE day >= DATE '2026-06-01' AND day < DATE '2026-07-01';
-- ВЕРНО, вариант 1: пересчитать по сырым данным за весь период
SELECT percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95_month_ms,
count(*) AS n
FROM api_requests
WHERE received_at >= TIMESTAMPTZ '2026-06-01 00:00+03'
AND received_at < TIMESTAMPTZ '2026-07-01 00:00+03';
Если сырые данные хранить дорого, храните гистограмму, а не перцентили: счётчики по корзинам складываются, поэтому перцентиль восстанавливается из склеенных корзин.
-- ВЕРНО, вариант 2: складываем корзины, а не перцентили
WITH merged AS (
SELECT bucket_lo_ms, sum(n) AS n
FROM latency_histogram_daily
WHERE day >= DATE '2026-06-01' AND day < DATE '2026-07-01'
GROUP BY bucket_lo_ms
),
cum AS (
SELECT bucket_lo_ms,
sum(n) OVER (ORDER BY bucket_lo_ms) AS running,
sum(n) OVER () AS total
FROM merged
)
SELECT min(bucket_lo_ms) AS p95_lower_bound_ms -- нижняя граница корзины, где накопилось 95%
FROM cum
WHERE running >= 0.95 * total;
Результат — не точное значение, а нижняя граница корзины; точность задаётся шириной корзин, и это честный компромисс, в отличие от усреднения перцентилей, где ошибка неограниченна.
Чем считать перцентили в проде
АЛГОРИТМ: перцентиль потока значений
ТОЧНО: накопить всё → отсортировать → взять элемент ceil(q*n)
ГИСТОГРАММА: инкремент счётчика корзины → скан накопленной суммы до q*total
T-DIGEST: сжатое представление, плотное у хвостов и редкое в центре;
слияние двух представлений = слияние центроидов
| Способ | Время на значение | Память | Слияние частей | Точность |
|---|---|---|---|---|
| Точная сортировка | $O(\log n)$ амортизированно, итог $O(n \log n)$ | $O(n)$ | только через сырые данные | точно |
| Гистограмма с фиксированными корзинами | $O(1)$ | $O(1)$, задана заранее | складыванием счётчиков | до ширины корзины |
| t-digest | $O(\log(1/\delta))$ | $O(1/\delta)$ | слиянием состояний | относительная, лучше у хвостов |
| Среднее перцентилей | $O(1)$ | $O(1)$ | «складыванием» | неверно |
Практика: в ClickHouse — quantileTDigestState в AggregatingMergeTree и quantileTDigestMerge при чтении, тогда перцентиль за любой период считается корректно (см. колоночные хранилища). В Prometheus — histogram_quantile поверх корзин, а не summary, именно потому, что корзины складываются между инстансами, а квантили нет; про это прямо написано в документации Prometheus по гистограммам. В Postgres — либо пересчёт по сырым, либо расширение вроде timescaledb-toolkit с накопительными состояниями. Идея сжатых сводок по потоку — та же, что в вероятностных структурах данных и потоковых алгоритмах.
6. Сколько данных нужно, чтобы увидеть хвост
Перцентиль в хвосте оценивается по горстке наблюдений, даже если выборка кажется большой. Число наблюдений выше $p_{99}$ распределено биномиально с параметрами $n$ и $0{,}01$, поэтому его стандартное отклонение равно $\sqrt{n \cdot 0{,}01 \cdot 0{,}99}$.
| $n$ | Наблюдений выше p99 | Разброс этого числа | Что это значит |
|---|---|---|---|
| 100 | 1 | ±1 | p99 = максимум, оценки нет |
| 1 000 | 10 | ±3,1 | ошибка в десятки процентов |
| 10 000 | 100 | ±9,9 | около 10 процентов — рабочий минимум |
| 1 000 000 | 10 000 | ±99,5 | 1 процент — можно сравнивать периоды |
Асимптотическая дисперсия выборочного квантиля даёт то же самое строже:
$$\operatorname{Var}(\hat{x}_q) \approx \frac{q(1-q)}{n \cdot f(x_q)^2}$$
Здесь $f(x_q)$ — плотность в точке квантиля. У тяжёлых хвостов плотность в хвосте мала, знаменатель мал, дисперсия велика — вот математическая причина, по которой p99 «шумит» сильнее медианы при том же объёме данных.
Рабочее правило: чтобы квантиль уровня $q$ имел относительную ошибку порядка 10 процентов, нужно примерно $n \ge 100/(1-q)$ наблюдений. Для p99 — 10 тысяч, для p99,9 — 100 тысяч, для p99,99 — миллион. Если на графике p99 по пятиминутным интервалам с сотней запросов в интервале — вы смотрите на шум и принимаете решения по нему. Как считать доверительный интервал для квантиля (обычно бутстрэпом, поскольку плотность неизвестна) — в следующей главе, «Неопределённость».
7. Почему хвост важнее центра: усиление при разветвлении
Есть системная причина, по которой редкий случай перестаёт быть редким. Пользовательский запрос к странице обычно порождает много внутренних вызовов и ждёт их все. Если каждый вызов независимо оказывается медленнее порога с вероятностью 1 процент, то вероятность, что медленным окажется хотя бы один из $k$:
$$P = 1 - 0{,}99^{k}$$
При $k = 10$ это 9,6 процента, при $k = 100$ — 63,4 процента. Иначе говоря, при сотне вызовов больше половины пользовательских запросов упираются в то, что для отдельного сервиса является «одним процентом худших случаев».
Тот же расчёт с другой стороны, ещё нагляднее. Если ответ страницы — максимум из $k$ независимых вызовов, то функция распределения максимума равна $F(t)^k$, и медиана максимума достигается при $F(t) = 0{,}5^{1/k}$:
| Разветвление $k$ | Медиана страницы = перцентиль одного вызова | p95 страницы = перцентиль одного вызова |
|---|---|---|
| 1 | p50 | p95 |
| 10 | p93,3 | p99,49 |
| 100 | p99,31 | p99,95 |
Читается так: при разветвлении в сто вызовов типичный пользователь получает то, что для бэкенда является 99,3-м перцентилем. Оптимизация медианы отдельного сервиса не улучшит опыт вообще; улучшит только работа с хвостом. Это ядро классической статьи Дина и Барросо «The Tail at Scale» и причина, по которой в проде живут хеджированные запросы и деградация вместо ожидания. Оговорка честности: расчёт предполагает независимость вызовов, а она нарушается в обе стороны — общая пауза сборщика мусора делает задержки коррелированными и картина хуже, кеширование и локальность — лучше. Смысл таблицы в порядке эффекта, а не в точных числах; подробнее — производительность: измерения и наблюдаемость в распределённых системах.
8. Способ сбора данных решает, какой хвост вы увидите
Это сквозная тема трека, и в распределениях она проявляется резче всего: механизмы сбора устроены так, что вырезают именно хвост.
Цензурирование таймаутом
Запрос, шедший 90 секунд, при таймауте в 30 запишется как 30 секунд — или не запишется вовсе. Хвост схлопывается в пик на границе, а метрика упирается в потолок и перестаёт различать «слегка плохо» и «катастрофа».
-- 1. Потолок виден как масса у границы и мода ровно на ней
SELECT count(*) FILTER (WHERE duration_ms >= 29900) AS at_the_wall,
round(100.0 * count(*) FILTER (WHERE duration_ms >= 29900) / count(*), 3) AS pct_at_wall,
mode() WITHIN GROUP (ORDER BY duration_ms) AS most_common_ms
FROM api_requests
WHERE received_at >= TIMESTAMPTZ '2026-06-01 00:00+03';
-- 2. Хвост, которого в данных нет вообще: строки без записи о завершении
SELECT date_trunc('hour', started_at) AS hour,
count(*) AS started,
count(*) FILTER (WHERE finished_at IS NULL) AS never_finished
FROM request_spans
WHERE started_at >= TIMESTAMPTZ '2026-06-01 00:00+03'
GROUP BY 1 ORDER BY 1;
Если pct_at_wall заметно больше нуля, любой перцентиль выше этой отметки — артефакт конфигурации, а не свойство системы. И главное: не всё цензурирование видно как пик — часто прерванный запрос просто не порождает записи, и искать надо не значения, а отсутствующие строки (второй запрос).
Согласованное упущение
Самый коварный случай, описанный Гилом Тене как coordinated omission. Нагрузочный генератор отправляет запрос, ждёт ответа и только потом шлёт следующий. Когда сервис зависает на 10 секунд, генератор в это время не создаёт нагрузки — и все запросы, которые должны были попасть в окно затыка, просто не существуют. В отчёте: небольшое число медленных ответов вместо тысяч. Ровно та же логика в клиентской аналитике: пользователь, у которого приложение зависло, не отправит событие о зависании.
их длительности отсутствуют в данных вообще U--xA: часть пользователей закрыла приложение Note over M: в метрике меньше медленных запросов, чем было на самом деле
Лекарство известно и простое: измерять время от запланированного момента отправки, а не от фактического, и записывать факт ухода пользователя. Разбор — в докладе «How NOT to Measure Latency» и в HdrHistogram, где коррекция встроена.
Остальные пять способов потерять хвост
Семплирование трейсов. Головное семплирование в 1 процент сохраняет медиану отлично, а хвост — плохо: редкие медленные трейсы попадают в выборку в среднем в 1 проценте случаев, и на пятиминутном окне их там просто нет. Решение — tail-based sampling: решать о сохранении трейса после завершения, оставляя все медленные и ошибочные.
Предагрегация на стороне сборщика. Агент, отправляющий среднее за минуту вместо сырых значений, уничтожает хвост необратимо: из средних перцентиль не восстанавливается никаким запросом. То же с витринами, где сохранили только avg — см. форматы хранения.
Фильтрация «выбросов». Правило «отбрасываем значения выше трёх сигм» на логнормальных данных отбрасывает вполне нормальные наблюдения, а на выручке — настоящих крупных клиентов. Симметрично: фильтр ботов по «слишком высокой активности» вырезает самых активных живых пользователей. Любой такой фильтр должен быть записан в определение метрики и отражён на графике долей отфильтрованного — иначе он становится невидимым допущением; про это же — качество данных.
Потолки в самих данных. Поле с ограничением «999+», шкала опроса от 1 до 5, лимит корзины. Распределение обрезано конструкцией формы, и никакая статистика этого не восстановит; максимум, что можно, — честно сказать «доля упёршихся в потолок равна X процентам».
Смещение по длине. Если выбирать сессии через события («возьмём сессии, в которых было событие X»), длинные сессии попадут в выборку чаще пропорционально своей длине. Наблюдаемое среднее тогда равно не $\mathbb{E}\lbrack L \rbrack$, а
$$\frac{\mathbb{E}\lbrack L^2 \rbrack}{\mathbb{E}\lbrack L \rbrack} = \mathbb{E}\lbrack L \rbrack \cdot (1 + \mathrm{CV}^2)$$
где $\mathrm{CV}$ — коэффициент вариации. При тяжёлом хвосте $\mathrm{CV}$ большой, и «средняя сессия» в отчёте оказывается в разы длиннее настоящей. Это тот же парадокс, из-за которого автобус, на который вы попали, почти всегда идёт с бо́льшим интервалом, чем средний. Правильная выборка — по единицам, о которых вы делаете вывод: по пользователям или по сессиям, а не по событиям. Родственные ловушки разобраны в главе «Откуда берутся данные».
Три вопроса перед тем, как назвать перцентиль вслух
таймаут, лимит поля, обрезка?"} B -- "да, и масса у границы" --> B1["Это не p99, это конфигурация.
Отчитываться долей упёршихся"] B -- "нет" --> C{"Достаточно ли наблюдений:
n не меньше 100 / (1 - q)?"} C -- "нет" --> C1["Укрупнить окно или
перейти на p90 / p95"] C -- "да" --> D{"Как получено:
из сырых или усреднением перцентилей?"} D -- "усреднением" --> D1["Пересчитать по сырым
или склеить гистограммы"] D -- "из сырых" --> E{"Одна ли это популяция:
нет ли смеси режимов?"} E -- "смесь" --> E1["Разрезать по режиму,
показать обе части"] E -- "одна" --> F["Можно называть вслух,
рядом указать n и окно"]
9. Как показывать распределение, чтобы оно не соврало
Полный разбор — в главе «Визуализация», здесь только то, что специфично для формы данных.
- Ширина корзины гистограммы — это параметр, который меняет вывод. Широкие корзины сливают два горба в один, узкие превращают данные в шум. Дефолт по умолчанию не нейтрален. Разумная отправная точка — правило Фридмана–Дьякониса: ширина $= 2 \cdot \mathrm{IQR} \cdot n^{-1/3}$. Правило Стёрджеса на скошенных данных даёт слишком мало корзин.
- Логарифмическая ось X для скошенных величин превращает нечитаемую «палку у нуля с пустотой справа» в осмысленную картинку. Обязательное условие — подписать, что шкала логарифмическая: на такой оси одинаковые расстояния означают одинаковые отношения, и глаз без подписи читает график неверно.
- ECDF — самый честный дефолт. У эмпирической функции распределения нет параметров, которые можно подкрутить: нет корзин, нет сглаживания. Она отвечает сразу на все вопросы вида «а какая доля быстрее 300 миллисекунд».
- Боксплот скрывает бимодальность. Смесь двух режимов и одно широкое унимодальное распределение дают почти одинаковые ящики. Подозреваете смесь — violin, страйп-плот или две ECDF рядом.
- «Среднее ± стандартное отклонение» на скошенных данных бессмысленно. Если нижняя граница отрицательна, а величина неотрицательна по природе, это прямое доказательство, что модель не подходит данным. И не усредняйте по группам разного размера, не показав сами размеры: агрегация, скрывающая распределение, — самый частый способ соврать честными числами.
10. Какую цифру докладывать под какое решение
| Решение | Правильная статистика | Почему не среднее |
|---|---|---|
| Сколько закупить мощности | сумма и среднее, плюс пик | закупка про суммарный объём, тут среднее уместно |
| Соблюдаем ли обязательство по скорости | доля запросов быстрее порога | доли складываются между шардами и днями, перцентили нет |
| Прогноз выручки | сумма плюс отдельный разбор крупных клиентов | среднее нестабильно при $\alpha < 2$ |
| Сравнение двух версий продукта | эффект на медиану и на долю выше порога | одно значение в хвосте перевешивает тысячи обычных |
| Ёмкость очереди и буферов | p99,9 и максимум | буфер, рассчитанный на среднее, переполняется гарантированно |
Отдельно про формулировку обязательств. Требование «p99 меньше 300 мс» хуже, чем эквивалентное на вид «не менее 99 процентов запросов быстрее 300 мс», по чисто технической причине: доли аддитивны, а перцентили нет. Долю — count(*) FILTER (WHERE duration_ms <= 300) / count(*) — можно посчитать за неделю, сложить по шардам, разложить по регионам и агрегировать в любом порядке; перцентиль нельзя ничего из этого. Поэтому в SLI фиксируют порог и считают долю; подробности — SLI и SLO и мониторинг. Ту же оптику стоит применять к продуктовым метрикам: метрики продукта и метрики интерфейса чаще всего строят на средних, хотя решения там принимаются про хвост — про тех, кому неудобно.
11. Типичные ошибки
- Считать среднее, не посмотрев на форму. Гистограмма занимает одну минуту и один запрос. Пропуск этого шага — источник большинства ошибок этой главы.
- Усреднять перцентили. Дневные p95 из витрины, сложенные в недельный, — почти обязательный баг любого дашборда, который делали быстро.
- Считать p99 на маленьких окнах. Пять минут и сто запросов — это шум, по которому кто-то поднимает алерт.
- Отбрасывать выбросы по правилу трёх сигм. На скошенных данных правило не имеет смысла, потому что выведено для нормального распределения.
- Путать отсутствие хвоста с его невидимостью. Пик на границе таймаута — это не «у нас всё в пределах 30 секунд».
- Строить доверительный интервал по ЦПТ на данных с $\alpha \le 2$. Формула отработает, число будет узким и неправдой; подробности — в главе «Неопределённость».
- Сравнивать средние в A/B-тесте на выручке без учёта хвоста. Один кит в одной из групп двигает результат сильнее, чем любой продуктовый эффект; способы борьбы — винзоризация, метрика на логарифме, ограничение сверху с явной фиксацией правила — в главе «A/B-тесты».
- Смешивать режимы и выбирать выборку не по той единице. Одно распределение вместо «мобильные и десктоп» или «боты и люди»; выборка по событиям вместо выборки по пользователям, из-за чего смещение по длине завышает всё, что связано с длительностью.
Мини-итог
- Полный ответ на вопрос о величине — это распределение; любая сводная цифра есть сжатие с потерями, и надо знать, что именно оно теряет.
- Формы порождаются механизмами: суммы дают нормальное, произведения — логнормальное, положительная обратная связь — степенное, разные режимы — смесь. Тяжесть хвоста измеряется показателем $\alpha$: при $\alpha \le 2$ дисперсия бесконечна и доверительные интервалы по ЦПТ лгут, при $\alpha \le 1$ среднее не существует. Дешёвые индикаторы — отношение $p_{99}/p_{50}$, доля топ-1 процента и прямая на графике лог-лог.
- Перцентили не складываются и не усредняются: складываются гистограммы, доли и мержимые состояния вроде t-digest. Чтобы квантиль уровня $q$ имел ошибку около 10 процентов, нужно порядка $100/(1-q)$ наблюдений.
- При разветвлении в сто вызовов типичный пользователь получает то, что для отдельного сервиса является 99,3-м перцентилем: хвост становится нормой.
- И главное: таймауты, семплирование, предагрегация, фильтры выбросов и выбор единицы наблюдения систематически срезают хвост. Прежде чем объяснять форму данных свойствами мира, проверьте, не объясняется ли она способом сбора.
Источники
- Jeffrey Dean, Luiz André Barroso. «The Tail at Scale», Communications of the ACM, 2013 — https://cacm.acm.org/research/the-tail-at-scale/
- Aaron Clauset, Cosma Shalizi, Mark Newman. «Power-law distributions in empirical data», SIAM Review, 2009 — https://arxiv.org/abs/0706.1062
- Nassim Nicholas Taleb. «Statistical Consequences of Fat Tails», 2020 — https://arxiv.org/abs/2001.10488
- Ted Dunning. «Computing Extremely Accurate Quantiles Using t-Digests» — https://arxiv.org/abs/1902.04023
- Gil Tene. «How NOT to Measure Latency» — https://www.infoq.com/presentations/latency-response-time/ и HdrHistogram — http://hdrhistogram.org/
- Prometheus: гистограммы и квантили — https://prometheus.io/docs/practices/histograms/ ; ClickHouse:
quantileTDigest— https://clickhouse.com/docs/en/sql-reference/aggregate-functions/reference/quantiletdigest - PostgreSQL: агрегатные и оконные функции — https://www.postgresql.org/docs/current/functions-aggregate.html ; правило Фридмана–Дьякониса — https://en.wikipedia.org/wiki/Freedman%E2%80%93Diaconis_rule
Что дальше
Мы научились видеть форму и поняли, что оценки в хвосте держатся на горстке наблюдений. Естественный следующий вопрос — насколько вообще можно доверять любому числу, полученному из выборки, и как честно записать эту неуверенность в отчёт: Неопределённость: доверительные интервалы и размер выборки.