Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Гистограммы метрик и процентили p95/p99

Процентили латентности: гистограммы, p95/p99 и почему среднее врёт

Как правильно измерять латентность: бакеты гистограмм Prometheus, расчёт p95/p99, выбор границ бакетов, агрегация процентилей и SLO на процентили.

Прямой ответ

Средняя латентность — метрика-обманщик: 95% запросов по 20 мс + 5% по 3 с дают «среднее 169 мс» — цифру, не описывающую никого (быстрым всё равно, медленные страдают — а среднее «зелёное»); честная картина — процентили: p50 (медиана — «типичный запрос»), p95/p99 («почти все быстрее этого» — хвосты, где живёт боль пользователей), p99.9 (для критичных/высоконагруженных — SLO-практика оперирует именно хвостами). В Prometheus процентили считаются из гистограмм: инструмент объявляет границы бакетов (Histogram("http_request_duration_seconds", "...", buckets=(0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10))), клиент копит счётчики «≤ границы» (_bucket{le="0.1"}), плюс _sum/_count; процентиль — histogram_quantile(0.95, sum(rate(..._bucket[5m])) by (le)) — запрос интерполирует внутри бакета, содержащего 95-ю персентулю. Три следствия, которые нужно принять: (1) процентиль из гистограммы — оценка, точность ограничена шагом бакетов (p99 на бакетах {1, 5} с истинным 1.2 — покажет ~1.05 — границы проектируются под важный диапазон — «SLO p99 < 300 мс» требует плотных бакетов около 300 мс); (2) процентили не усредняются и не суммируются (среднее p95 по инстансам ≠ p95 системы — только пересчёт из суммарных бакетов — sum by (le) до quantile — порядок операций критичен; та же причина, по которой summary-квантили нельзя агрегировать); (3) окно rate определяет смысл (p95 за 5m — сглаженный тренд, за 30s — нервно и шумно — выбор под назначение: графики 5m, алерты 5–15m с for, разборы инцидентов — короче). Альтернатива для малых бюджетов — summary (квантили на клиенте — точные на инстанс, мёртвые для агрегации) — для одномодульных приложений допустим, для кластеров — только histogram.

Бакеты: проектирование границ

Границы бакетов — единственный «дизайн» гистограммы, и он решает всё: правило — плотность там, где границы SLO и интерес (порог 300 мс — бакеты 200/250/300/400/500 — p95 около SLO различим; дефолтные prometheus_client бакеты (.005…10) — разумный старт для веба, не догма); экспоненциальный шаг (×2–2.5 — равномерная относительная точность по диапазону — линейный шаг «каждые 100 мс» на диапазоне 10 мс–10 с — либо бесполезно плотен внизу, либо пуст вверху); количество — 10–15 бакетов компромисс (каждый бакет — отдельный временной ряд на каждый набор лейблов — кардинальность: 12 бакетов × 50 route × 5 инстансов — 3000 рядов на одну метрику — лейблы гистограмм держать узкими); специальные значения: +Inf обязателен (замыкающий — без него quantile ломается на «всё выше последнего»), нижний бакет по полу задержек (0.005 для быстрых API — p50 различим), «широкий последний» (10+ — медленные хвосты видны фактом, не величиной — для точности хвостов — плотнее вверху). Практика итеративная: стартовать дефолтом → посмотреть фактическое распределение (график бакетов — где масса) → сдвинуть границы к SLO/массе (изменение бакетов — разрыв совместимости рядов — релизная аккуратность: старые данные с новыми границами не смешиваются — менять осознанно); мульти-модальные распределения (кеш-попадание 5 мс/промах 800 мс — два горба) — процентили честнее среднего, но раздельные метрики (latency_cache_hit/miss — инструментация по исходам) информативнее одной гистограммы.

Расчёт и агрегация: формулы и грабли

Базовые формулы (копируй-вставь): p95 системы — histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le)); p95 по эндпоинтам — ... by (le, route) (le обязателен в by — теряется — мусор); p99.9 — histogram_quantile(0.999, ...) (требует плотности в хвосте — иначе «оценка по широкому бакету»); средняя длительность — rate(_sum[5m]) / rate(_count[5m]) (дешевле, но только как дополнение к квантилям); доля медленных (SLO-формула) — sum(rate(..._bucket{le="0.3"}[5m])) / sum(rate(..._count[5m])) — «доля запросов ≤ 300 мс» — ровно как SLO определяют («99% запросов быстрее 300 мс» — считается из гистограммы напрямую, без quantile — точнее и дешевле — burn-rate алерты строятся на этой доле). Грабли: порядок операций (сначала sum rate по инстансам, потом quantile — наоборот — математически неверно), малые объёмы (10 запросов за окно — p99 = максимум — статистически бессмысленно — панели с порогом минимума ... > 100 запросов/окно или сглаживание), сбросы счётчиков (перезапуск приложения — rate обрабатывает counter reset корректно — не баг, что «после деплоя p95 скакнул» — новые бакеты с нуля, окно переходно — деплой-артефакты на графиках нормальны — деплой-стратегии меняют картину на минуты), пропавшие ряды (инстанс умер — его бакеты выпали из sum — p95 «улучшился» — метрика-парадокс — алерт на absent()/число инстансов рядом). Алерты на процентили: на сам p95 (expr histogram_quantile(...) > 0.3 + for 10m — понятный, но запаздывающий) против burn-rate (доля медленных за 1h × множитель — раннее обнаружение съедания бюджета — SLO-механика — зрелый вариант; многослойность: page на быстром burn-rate, ticket на медленном).

Практический сценарий: SLO «99% быстрее 300 мс» на реальных данных

Исходник: API с гистограммой дефолтных бакетов, дашборд «средняя латентность 140 мс — зелёное» при растущих жалобах («иногда висит секундами»). Разбор: (1) распределение по бакетам в Explore — бимодальность: пик ≤50 мс (кеш) и хвост 0.8–2.5 с (~7% — промахи кеша на тяжёлых отчётах) — среднее 140 мс не описывало ни одну группу; (2) p50/p95/p99 панели — p50 45 мс, p95 1.1 с, p99 2.4 с — картина совпала с жалобами (жаловались «медленные 7%»); (3) SLO сформулировано с продуктом: «99% запросов ≤ 300 мс» (SLO-процесс); текущий факт — 93.2% — бюджет дефицитен — цель осмысленна; (4) бакеты перепроектированы под SLO-зону (0.05/0.1/0.2/0.25/0.3/0.35/0.5/0.75/1/2.5/5 — плотность около 300 мс — доля-формула точна в решающем месте); (5) SLO-панель — доля быстрых (формула выше) + burn-rate 1h/6h — алерты (Alertmanager); (6) исправление хвоста — отдельная работа: тяжёлые отчёты вынесены в асинхронный режим (экспорт-конвейер — «быстрый ответ + готовность позже») + кеш-хитрейт поднят (кеш-паттерны) — через 3 недели доля ≤300 мс — 99.1% (SLO держится, бюджет копится); (7) ретроспектива в runbook: «средняя латентность — не метрика здоровья; SLO-доля из гистограммы — канон». Стоимость инструментации — нулевая дополнительно (гистограмма уже собиралась — изменены границы и запросы); стоимость понимания — одна статья вроде этой.

FAQ

p95 или p99 — что мониторить? Оба, с разными ролями: p95 — здоровье основной массы (деградация видна рано), p99/p99.9 — хвостовая боль (на высоконагруженных системах 1% = тысячи пользователей в час — SLO-выбор по продукту); «только p95» — хвостовые инциденты невидимы; «только p99.9» — шум и поздняя реакция на массовые деградации; практический набор: p50+p95+p99 на графике, SLO/алерты — на долю под порогом (burn-rate) — процентили для людей, доли для автоматики.

Почему не summary? Summary даёт точные квантили на инстансе без интерполяции — но: не агрегируется между инстансами (глобальный p95 из summary невозможен математически — только по-инстансный), квантили выбраны при инструментировании (p90 не собирал — не посчитаешь потом), дороже на клиенте (скользящие окна квантилей); histogram — агрегируем, гибче постфактум, дешевле — стандарт для всего, что масштабируется; summary — ниша одномодульных приложений с фиксированными вопросами.

Как считать процентили не в Prometheus (в логах/БД)? Postgres — percentile_cont(0.95) WITHIN GROUP (ORDER BY duration) (точный по выборке — разборы/ретроспективы — SQL-практика); pandas/polars — quantile(0.95) по логам выгрузок (аналитика); jq-однострочники по JSON-логам (лог-обработка); разница подходов: точные процентили по массиву (ретроспектива, расследования) против гистограммных оценок в реальном времени (мониторинг/алерты) — дополняют друг друга; «считать p95 в приложении и отдавать метрикой» — антипаттерн (не агрегируется — те же грабли summary).

Нужны ли процентили для фоновых задач/очередей? Да, с адаптацией семантики: латентность задачи — от постановки до завершения (queue time + execution — раздельные гистограммы информативнее суммы — «очередь растёт» против «задачи тормозят» — разные диагнозы); интерпретация хвостов иная (фоновая задача 10 минут — нормально, если SLA часовой — SLO фоновых — «99% задач завершены за X» — Celery-метрики: возраст старейшей задачи часто честнее процентилей длительности); гистограммы длительностей + доли завершения в срок — набор тот же, пороги продуктовые.

Источники: документация Prometheus (histograms practice, metric types), SRE Book (SLO) — ссылки в шапке.