p99 глазами разработчика: как измерять, считать, расследовать и реально снижать tail latency

Оглавление

  1. Введение
  2. Теория: перцентили и хвосты
  3. Измерение и инструментализация
  4. Расследование инцидентов: от p99 на графике к конкретному запросу
  5. Паттерны снижения p99 и защиты хвоста
  6. Чек-лист и FAQ
  7. Заключение

Введение

Перцентили latency – это не “красивая статистика”, а способ говорить про пользовательский опыт и предсказуемость системы там, где среднее время ответа на запрос (avg) системно врет. Практика крупных распределенных систем показывает: даже редкие (например, 1%) “медленные” ответы на уровне компонентов превращаются в массовые (десятки процентов) “медленные” ответы на уровне сервиса, если запросы имеют fan-out (параллельные подзапросы в БД/кэш/шарды/микросервисы). Это один из центральных выводов работы “The Tail at Scale” и основа того, почему p95/p99 часто важнее среднего. 

В аналитике перцентилей важно понимать два технических факта.

  • Во-первых, Prometheus-мир различает Summary (streaming квантиль на клиенте) и Histogram (бакеты + вычисление квантиль на сервере). Ключевое следствие Summary-квантили в общем случае не агрегируются между инстансами, а Histogram агрегируются через sum by (le, …) и histogram_квантиль. 
  • Во-вторых, любые перцентили, полученные из бакетов, имеют оценочную ошибку, зависящую от ширины бакетов и интерполяции внутри них. 

Наконец, расследование хвостов без связки “метрики – трейс” почти всегда превращается в гадание. Для этого в экосистеме существуют exemplars (корреляция метрики с trace/span id в формате OpenMetrics) и tail-based sampling в коллекторе, позволяющий сохранять именно медленные/ошибочные трейсы. 

Примеры ниже намеренно “не завязаны” на конкретный язык/фреймворк, протоколы – HTTP и/или gRPC.

Типичная прод-картина: средняя задержка “нормальная”, CPU не красный, ошибок мало – но пользователи жалуются на “иногда все висит”. Это не парадокс: среднее значение устойчиво к хвосту, а пользовательский опыт – нет.

В “The Tail at Scale” эта идея показана буквально: если отдельный сервер почти всегда отвечает быстро, но имеет редкие “выбросы”, то при fan-out (запрос зависит от множества параллельных подзапросов) вероятность увидеть медленный ответ на уровне сервиса резко растет с масштабом. На графике в статье показан кейс, где при вероятности “медленного” (например, > 1s) на уровне одного leaf-узла 1/100 и fan-out на ~100 узлов уже около 63% запросов сервиса становятся “медленными” (>1s). 

Рис.1 : график вероятности из статьи “The Tail at Scale”

Проблема усиливается тем, что современная backend-система редко “один процесс – одна база”. Обычно это цепочки зависимостей, очереди, пул соединений, кэш, шардирование, повторные запросы и таймауты. “The Tail at Scale” отдельно перечисляет источники вариативности: очереди, фоновые активности (компакции/обслуживание), сборка мусора, глобальное использование среды (сеть/файловые системы), лимиты мощности и т.д. 

Поэтому цель статьи – дать не “определения ради определений”, а рабочую инженерную модель: как перцентили устроены, где и как их измерять, как их правильно считать, как расследовать p99-инциденты, и какие паттерны действительно уменьшают “хвост”, а какие лишь “полируют среднее”.

Теория: перцентили и хвосты

Что означают p50/p95/p99/p999

В мониторинге обычно говорят про квантили (0 – 1). Определение в прометеевской практике формулируется так:

квантиль – это наблюдаемое значение, которое занимает ранг примерно N среди N наблюдений (пример: 0.5 – медиана, 0.95 – 95-й перцентиль). 

Практическая интерпретация:

  • p50 – “типичный” запрос (медиана).
  • p95 – 5% запросов хуже (дольше).
  • p99 – 1% запросов хуже.
  • p999 – 0.1% запросов хуже.

Ключевой нюанс: p99 – это не “редко”. При 10k RPS 1% – это 100 запросов в секунду, которые по определению лежат “в хвосте”.

Почему “хвост” важнее среднего: фан-аут и масштаб

Распределенный сервис часто ждет “последнего” из группы параллельных подзапросов: несколько шардов, несколько зависимостей, несколько реплик, несколько вызовов к БД. Тогда end-to-end latency сильно коррелирует с максимумом/квази-максимумом подзапросов.

В “The Tail at Scale” есть два очень показательных количественных фрагмента:

  1. Вероятность “service latency > 1s” растет с количеством серверов и частотой outlier’ов, и на графике отмечен кейс ~0.63 при fan-out на 100 серверов и outlier-частоте 1/100.
  2. Таблица “Individual-leaf-request finishing times” показывает, что если один leaf имеет 99-й перцентиль 10ms, то ожидание “100% leaf’ов завершились” дает уже 99-й перцентиль порядка 140ms – хвост усиливается эффектом “ждем всех”. 

Рис. 2: таблица “Individual-leaf-request finishing times”

Это фундаментальная причина, почему “p99 базы 50ms, а p99 сервиса 800ms” может быть не багом мониторинга, а закономерностью архитектуры.

Небольшая математическая модель fan-out (с числами)

Если вероятность, что один подзапрос медленный (>T), равна q, а запрос делает N независимых подзапросов и ждет всех, то вероятность, что хотя бы один будет медленным:

Pslow=1-1-qN

Кейс из “The Tail at Scale” иллюстрирует q = 0.01 (1/100) и N – 100 – 1-0.99 * 100 * 0.63. 

В проде независимость часто нарушается (общий диск, общий GC, общий сетевой инцидент), и тогда хвост еще хуже, чем модель независимых событий.

Как выглядит “распределение latency” в жизни: схематичный скетч

Ниже – не “данные”, а визуальная интуиция, почему среднее и p50 могут быть “зелеными”, а p99 – уже боль.

Latency (ms)   Count
  10- 20    | ##############################
  20- 40    | ########################
  40- 80    | #############
  80-160    | ######
  160-320   | ###
  320-640   | ##
  640-1280  | #
  1280-2560 | #

У такого распределения p50 может быть ~20–30ms, p95 ~150–250ms, а p99 – уже секунды (в зависимости от “массы хвоста”). Это и есть предмет управления: tail, а не “среднее”.

Измерение и инструментализация

Где измерять latency: client, edge, server, dependency

Одна и та же “задержка запроса” существует в нескольких “точках”. Ошибка многих команд – выбрать одну (обычно server-side) и считать, что это “пользовательский опыт”.

Практичная декомпозиция:

  • Client-observed latency: то, что видит клиент (DNS/TLS/сеть/ретраи/очереди на клиенте).
  • Edge latency: ingress/API-gateway (TLS termination, routing, rate limit, retries).
  • Server in-process latency: от входа в handler до отправки ответа.
  • Dependency latency: DB/cache/outbound RPC, включая ожидание пула.

SLO/SLI методологически рекомендуется строить вокруг измерений, которые отражают качество сервиса для пользователя. В SRE-подходе SLI часто задается как отношение good events / total events, что хорошо ложится на error budget (100% – SLO).
SRE Workbook прямо рекомендует начинать с архитектурной схемы и выбирать SLI, которые релевантны и измеримы, а затем итеративно улучшать. 

Практические рекомендации по выбору latency/SLI:

  • Для внешнего API часто разумно иметь два SLI:
    1) edge/client-latency как прокси пользовательского опыта,
    2) server-latency как управляемая “внутренняя” метрика.
  • Для внутренних RPC полезно мерить и client-side (outbound) и server-side (inbound), чтобы видеть асимметрию сети/балансировки.
  • Для dependency обязательно мерить ожидание пула отдельно, иначе вы будете “оптимизировать базу”, когда реальная проблема – “connection pool exhausted”.

В OpenTelemetry HTTP semantic conventions есть полезная привязка: http.server.request.duration и http.client.request.duration должны соответствовать длительности соответствующих спанов, когда метрика репортится рядом со спаном.
Также в HTTP-метриках встречаются отдельные измерения очередей (например, http.client.request.time_in_queue с рекомендуемыми бакетами), что помогает диагностировать именно pool/queue-задержки. 

Как системы считают перцентили: подходы и компромиссы

Основные стратегии можно свести к четырем семействам:

  1. Логирование сырых значений и офлайн-агрегация (ClickHouse/аналитика).
  2. Streaming quantiles / Summary (квантили считаются на агенте/клиенте).
  3. Histogram (explicit buckets) (бакеты на клиенте, квантили на сервере мониторинга).
  4. Экспоненциальные / native histograms (динамические схемы, более компактное хранение).

Prometheus “Histograms and summaries” дает наиболее практичную рамку: summaries считают streaming квантильs на клиенте и отдают готовые значения, histograms отдают бакеты, а квантили вычисляются сервером через histogram_квантиль().
Ключевое следствие из этой же документации: precomputed квантильs у Summary в общем случае не агрегируются между инстансами, попытка “усреднить p99” статистически бессмысленна. 

Сравнительная таблица подходов

ПодходЧто хранитсяТочность p99/p999Агрегация между инстансамиСтоимостьКогда выбирать
Логи + офлайнкаждое значение latencyвысокая (по данным)да (через SQL/агрегаторы)высокая по объему и I/Oготовые –квантили (на клиенте)
Summary / streaming quantilesда, как first-class типпотенциально высокая для одного процессаобычно нет (не агрегируем) CPU на клиенте выше редкие случаи, когда агрегация не нужна
Histogram (explicit buckets)бакеты _bucket + _sum/_countограничена шириной бакетов и интерполяцией да (через суммирование бакетов) больше time seriesдефолт для SLO, дашбордов, алертов
ExponentialHistogram (OTel)экспоненциальные бакеты (масштабируемые)хорошая относительная точность на wide range (зависит от scale) да (по модели данных OTel) обычно дешевле при wide rangelatency 1ms…10s+ без ручной настройки бакетов
Native histograms (Prometheus)“структурный” тип гистограммы, динамические бакетывыше, чем у грубых explicit buckets при схожей стоимости (в типичных кейсах)требует включения scrape/remote-write опций требует включения scrape/remote-write опций когда упираетесь в стоимость классических бакетов или нужна лучшая точность

Важно не перепутать: в OpenTelemetry “Summary” описан как legacy тип точки, тогда как Histogram и ExponentialHistogram – полноценные современные типы. 

Ошибка оценивания percentiles: почему “p99 прыгает” и что с этим делать

Prometheus подробно объясняет, что квантиль из гистограммы – это оценка: истинный квантиль гарантированно лежит в пределах бакета, а одиночное число получается интерполяцией (по умолчанию линейной).
Из этого следуют два инженерных правила:

  • бакеты должны быть плотнее вокруг ваших SLO-порогов (например, 200ms/300ms/500ms/1s), иначе p95/p99 могут выглядеть хуже/лучше реальности,
  • если распределение “острое” (почти все запросы около одной точки), грубый бакет может давать большую визуальную ошибку (Prometheus приводит мысленные эксперименты, где оценка 95-го резко сдвигается из-за смены бакета). 

Практические настройки: RED, семантика метрик, бакеты и low-cardinality

RED и Golden Signals

RED (Rate-Errors-Duration) – практический минимум для request-driven сервисов. Подход описан как микросервис-ориентированная философия мониторинга (Rate, Errors, Duration).
В SRE-подходе близкая рамка – “Four Golden Signals”: latency, traffic, errors, saturation

Рекомендуемые bucket boundaries для HTTP/RPC

OpenTelemetry semantic conventions для http.server.request.duration и http.client.request.duration рекомендуют explicit бакеты (в секундах):
[0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10].
Похожие рекомендации есть и для RPC-метрик. 

Таблица (те же границы, но “человечески”):

Граница (s)Граница (ms)Зачем полезно
0.0055“очень быстрые” cache-hit/локальные операции
0.0110быстрые запросы внутри DC
0.02525типичный p50 многих API при теплом кэше
0.0550“хороший UX” для интерактива
0.07575пограничные просадки
0.1100психологический порог “уже заметно”
0.25250частый SLO-таргет
0.5500еще ок для части сценариев
0.75750близко к 1s, важно для хвоста
11000серьезная деградация интерактива
2.52500“уже боль” и ретраи/таймауты рядом
55000часто близко к таймаутам клиентов
7.57500диагностика “залипаний”
1010000верхняя граница многих интерактивных бюджетов

Если ваши реальные таймауты 2s, а бакеты идут до 10s – это нормально: верхние бакеты помогают увидеть приближение к таймаутам и “уход” в деградацию.

Low-cardinality labels: где чаще всего “убивают” мониторинг

OpenTelemetry прямо говорит: http.route должен быть low-cardinality, содержать статические сегменты пути, а динамические части – плейсхолдерами.
То же касается ошибок: error.type должен быть предсказуемым и низкокардинальным. 

Это критично, потому что:

  • histograms и так создают несколько time series на одну метрику,
  • добавление “плохих” лейблов (например, url.path=/users/123456) умножает series и приводит к взрывному росту памяти/нагрузки на TSDB.

Полезная параллель из мира логов: Loki строится вокруг индексации лейблов, и выбор лейблов считается ключевым (каждый log stream должен иметь хотя бы один label, а значения должны быть осмысленными). 

PromQL: как правильно считать p95/p99 из histogram

Prometheus показывает канонический шаблон: агрегировать бакеты, затем histogram_quantile. 

Пример p99 по маршруту (схема):

histogram_quantile(
  0.99,
  sum by (le, http_route) (
    rate(http_request_duration_seconds_bucket[5m])
  )
)

Ключевая деталь – sum by (le, …): это сбор “общей гистограммы” из бакетов всех инстансов перед вычислением квантиля. 

Также Prometheus подчеркивает: бакеты кумулятивны. Это важно, например, при вычислении долей запросов “быстрее порога” или Apdex-подобных метрик. 

ExponentialHistogram и native histograms: когда они становятся “следующим шагом”

OpenTelemetry Metrics Data Model выделяет Histogram и ExponentialHistogram как разные типы точек метрик. ExponentialHistogram предназначен для экспоненциальных бакетов и поддерживает работу на широком диапазоне значений. Модель данных также описывает преобразования (в том числе уменьшение разрешения гистограмм и удаление атрибутов) как часть контроля стоимости сбора метрик. 

В Prometheus native histograms идея “гистограмма как first-class sample type” доведена до уровня data model: вместо десятков float-серий (_bucket) появляется структурный тип с динамическими бакетами. Спецификация подчеркивает свойства: sparse representation (почти нулевая стоимость пустых бакетов), отсутствие настройки бакетов на этапе инструментирования, динамическое разрешение и экспоненциальные схемы, обеспечивающие mergeability.
Жизненно важные практические детали: native histograms стали stable в Prometheus v3.8.0, но скрейпинг нужно включать scrape_native_histograms, а remote-write – send_native_histograms. 

Инструменты и пайплайн observability

Минимально “промышленный” пайплайн обычно выглядит так:

  • приложение формирует signals (metrics/traces/logs) через SDK,
  • коллектор принимает, процессит и экспортирует в бэкенды,
  • метрики уходят в масштабируемое хранилище (Prometheus-совместимое),
  • трейсы – в tracing backend,
  • логи – в log backend,
  • визуализация/алерты – в Grafana.

OpenTelemetry Collector описывается как исполняемый файл, который принимает телеметрию, обрабатывает и экспортирует в несколько целей, а сама обработка строится из pipeline: receivers/processors/exporters/connectors.
Для масштабного хранения метрик применяются системы класса Mimir (долговременное, горизонтально масштабируемое multi-tenant TSDB для Prometheus-метрик) или Thanos (глобальный query view + объектное хранилище для долгого retention).
Для трейсинга – Tempo (object storage как основа хранения, high-scale backend).
Для логов – Loki (индексирует метаданные labels, а сами логи хранит в чанках).
Для офлайн-аналитики логов/событий и ad-hoc расследований часто используются колоночные OLAP-СУБД вроде ClickHouse.
Jaeger остается широко используемым open-source tracing-решением. Документация отмечает происхождение и CNCF-статус. 

Также для крупных установок нередко используется Prometheus Remote Write. Спецификация описывает протокол, а best practices предупреждают про рост памяти (часто порядка ~25% в типичных случаях) и влияние series на память из-за кэша label-значений для WAL. 

Рис. 3: контейнерная диаграмма наблюдаемости

Расследование инцидентов: от p99 на графике к конкретному запросу

Почему без exemplars и tail sampling расследование хвоста часто “не сходится”

Метрики отвечают на вопрос “что происходит”: p99 вырос, RPS не упал, ошибки растут, saturations на грани. Но метрики почти никогда не отвечают на вопрос “почему именно эти запросы стали медленными”.

Для ускорения расследования экосистема предлагает exemplars: в OpenMetrics они определяются как ссылки на данные вне MetricSet (типичный пример – trace ID), и должны содержать LabelSet + value (и могут иметь timestamp).
Prometheus хранение exemplars включает через feature flag –enable-feature=exemplar-storage, и прямо связывает exemplars с OpenMetrics.
Grafana описывает exemplars как механизм, позволяющий находить “конкретные трейсы, показывающие высокую latency внутри выбранного интервала”, чтобы быстрее локализовать root cause. 

Если вы включили exemplars, естественный workflow расследования становится намного короче: “вижу spike p99 – кликаю exemplar – открываю trace – вижу, где время ушло”. (Сама идея “trace linking” через exemplars опирается на OpenMetrics semantics). 

Но даже при наличии трассировок другая частая проблема – sampling: если вы используете head-based sampling, то вы можете просто не сохранить медленные трейсы, потому что решение “сэмплировать или нет” принимается в начале. Tail sampling делает обратное: решение принимается после того, как система успела увидеть весь трейс и может применить политику (например, “сохранять все с ошибкой” или “сохранять медленнее X”). 

OpenTelemetry tail_sampling processor формализует важное ограничение: все спаны одного trace_id должны попасть в один и тот же экземпляр коллектора, иначе корректного решения не будет.
Там же описаны типовые политики (latency/status_code/attribute/probabilistic/rate_limiting и др.) и ключевые параметры вроде decision_wait (по умолчанию 30s) и num_traces (по умолчанию 50000). 

Рис. 4: диаграмма “p99 spike – exemplar – trace – причина”

Flowchart: практический алгоритм расследования tail latency

Рис.5: практический алгоритм расследования tail latency

Паттерны снижения p99 и защиты хвоста

Ниже – “не философия”, а причины, которые реально встречаются в проде, и практики, которые обычно дают ощутимое снижение p99/p999.

Таблица: типовые причины роста p99 и меры

Причина хвостаКак проявляетсяКорневая механикаЧто делать
Queueing / высокая утилизацияp50 норм, p99 растет, растет time_in_queue/pool waitнесколько слоев очередей усиливают вариативность лимиты конкуренции (bulkhead), уменьшение фан-аута, запас мощности, измерять очереди явно
Head-of-line blocking“тяжелые” запросы тормозят “легкие”, p99 по route растетсмешение классов работ в одном пуле, в paper предлагается дробить длинные запросы для интерливинга раздельные пулы, приоритизация, time slicing, ограничение тяжелых эндпоинтов
GC / background activity / compactionредкие “стены” latency, обычно коррелируют с CPU/IOpaper перечисляет garbage collection и фоновые операции как источники спайков уменьшение аллокаций, контроль фоновых задач, изоляция, планирование (джиттер/батчи)
Noisy neighbor / shared resourcesхвост появляется “волнами”, сложно воспроизвестиcgroup/квоты, выделенные ноды для latency-critical, I/O isolation, мониторинг iowait/throttlingFan-out
Retries – каскадыp99 сервиса сильно хуже p99 зависимостейвероятность медленного leaf растет с N, пример ~63% при ~100 leaf и 1/100 outlier уменьшить N, кэшировать, early return “good enough”, дедлайны на leaf
положительная обратная связь: сбой – рост нагрузки – больше сбоев рост RPS, рост latency, затем ошибки, “лавина”положительная обратная связь: сбой – рост нагрузки – больше сбоев retry budgets, “retry only one layer”, backoff/jitter, “don’t retry” ошибки 
Overload / отсутствие backpressure“ползучая смерть”: все медленно, но не падаетсистема пытается обработать все, накапливает очередь, p99 взлетаетload shedding, client-side throttling, квоты по клиентам 

Конкретные паттерны митигации, которые особенно эффективны против хвоста

Дедлайны и “бюджет времени” вместо “таймаута на глаз”

В больших fan-out системах критично иметь строгие дедлайны: leaf-операции должны завершаться в лимит, чтобы end-to-end оставался в бюджете. “The Tail at Scale” подчеркивает, что подоперации должны уложиться в строгий дедлайн, иначе сервис “не ощущается отзывчивым”.
Практически это означает: дедлайн должен быть в метаданных запроса и уменьшаться по мере прохождения слоев (edge – service – dependency).

Bulkheads и лимиты конкуренции как защита p99

Когда один класс запросов отъедает пул потоков/соединений, хвост начинает расти даже при неизменном среднем. В “The Tail at Scale” упоминаются идеи дифференциации service classes и higher-level queueing для сохранения интерактивности.
В SRE-подходе к handling overload показаны практики защиты задач через сигналы “нагрузки процесса” (например, рост активных потоков) и раннее отклонение запросов при превышении порога. 

Retry budget и запрет “комбинаторного ретрая”

SRE book прямо описывает retry budgets: per-request (например, максимум 3 попытки) и per-client (например, пока доля ретраев < 10%), а также принцип: ретраи должны происходить только на слое непосредственно над слоем, который отказал, иначе возникает комбинаторный взрыв retries в глубокой зависимости.
Это не “теория”: это типичный механизм, превращающий деградацию в outage.

Hedged requests и tied requests

“The Tail at Scale” предлагает tail-tolerant техники: hedged requests (отправить второй запрос к реплике после небольшой задержки) и tied requests (две копии запроса с координацией отмены). В статье приведен впечатляющий пример: в одном benchmark чтения 1000 ключей, распределенных по 100 серверам, hedging после 10ms уменьшал 99.9-й перцентиль с 1800ms до 74ms при ~2% дополнительных запросов.
Такие техники требуют аккуратности: без приоритизации и контроля бюджета они могут “победить хвост” ценой перегрузки системы.

Load shedding и client-side throttling

Для защиты хвоста иногда правильнее “выкинуть часть запросов быстро”, чем “замедлить все запросы”. В SRE книге описана client-side throttling стратегия: если клиент видит, что значимая доля запросов отклоняется “out of quota”, он начинает сам ограничивать исходящий трафик, чтобы не жечь ресурсы бэкенда и сети.
Тот же раздел разбирает обработку overload-ошибок, правила ретраев и “overloaded, don’t retry” сигналы. 

Чек-лист и FAQ

Чек-лист: что сделать в системе, чтобы p99 стал управляемым

  • Выбрать “истины latency”: client/edge/server/dependency и зафиксировать, какой слой является SLI для SLO (часто – edge/client). 
  • Перейти на гистограммы там, где нужна агрегация по инстансам. Избегать “p99 per instance – avg”. 
  • Привести лейблы к low-cardinality (http.route как template, bounded error.type). Убрать high-cardinality ключи. 
  • Взять рекомендованные бакеты как baseline и затем уплотнить вокруг SLO-порогов. Помнить про ошибку интерполяции. 
  • Включить exemplars (OpenMetrics + exemplar storage) и trace linking в UI. 
  • Включить tail-based sampling (или усилить sampling для медленных/ошибочных) и убедиться, что все спаны одного trace_id попадают в один collector-инстанс. 
  • Ввести лимиты конкуренции и разделение классов нагрузки, измерять pool/queue wait (иначе вы будете лечить “не то”). 
  • Настроить retry budgets и запрет многоуровневого ретрая. Внедрить “don’t retry” ошибки для overload. 
  • Перейти от алертов “p99 > X” к SLO burnFFFFGGGGrate алертингу (multiwindow, multiFFFFGGGGburnFFFFGGGGrate). 

Load testing и coordinated omission: почему “бенч показывает p99=50ms”, а прод нет

Coordinated omission – классическая ловушка измерения latency (термин активно продвигался Gil Tene в контексте “как не измерять latency”): измеряющая система “координируется” с измеряемой и пропускает часть хвоста, когда система замедляется.
Если генератор нагрузки работает как “closed loop” (отправил запрос – дождался ответа – отправил следующий), то при росте latency он уменьшает интенсивность, и хвост фиксируется хуже.

wrk2 сделан как constant throughput генератор с моделью измерения, которая учитывает “когда запрос должен был быть отправлен”, чтобы избегать coordinated omission. Это прямо описано в репозитории проекта. 

HDRHistogram дает механизмы коррекции coordinated omission: в документации (например, JavaDoc) описан метод copyCorrectedForCoordinatedOmission и логика добавления “промежуточных” значений, когда наблюдаемая задержка превышает ожидаемый интервал между измерениями.
Аналогичные формулировки есть в документации для других реализаций (например, Rust), где описано “record … while correcting for coordinated omission” и принцип автогенерации дополнительных значений. 

Exemplars: что нужно, чтобы “кликнуть p99 и открыть trace”

Минимальный набор условий:

  • Метрика должна быть экспортирована в формате OpenMetrics с exemplars (семантика exemplars описана в OpenMetrics spec). 
  • В Prometheus нужно включить exemplar storage feature flag. 
  • UI должен уметь показывать exemplars и прыгать в tracing backend (это прямо описано в документации Grafana по exemplars). 
  • Для совместимости важно использовать принятые ключи trace_id и span_id (это фиксируется в OpenTelemetry compatibility документации по Prometheus/OpenMetrics). 

Как выбрать p95 vs p99 vs p999

Практическая рамка выбора:

  • p95: хороший индикатор “массового UX” и деградаций средней тяжести. Часто подходит как первый шаг для SLO в командах, где observability еще дозревает.
  • p99: почти всегда нужен для интерактивных критичных операций (авторизация, платежи, поиск), потому что 1% на большом трафике превращается в постоянный поток плохих опытов. Tail-модель из “The Tail at Scale” показывает, насколько p99 системно усиливается fan-out и масштабом. 
  • p999: имеет смысл при очень большом трафике и зрелой системе наблюдаемости (иначе становится шумным и “дерганым”). Полезен, когда вы реально оптимизируете tail-tolerant техники (hedging/tied requests) и хотите отслеживать самые редкие outliers. “The Tail at Scale” оперирует 99.9-перцентилями в примерах про hedged requests, что показывает, что p999 – вполне рабочая цель при соответствующем масштабе. 

Заключение

Перцентили (p50/p95/p99/p999) – это не “статистика для графиков”, а рабочий инструмент управления предсказуемостью системы. Среднее почти всегда врёт в проде: оно сглаживает редкие, но массово ощущаемые пользователем провалы. В распределённых сервисах хвост усиливается архитектурой (fan-out), очередями и конкуренцией за ресурсы: 1% медленных leaf-вызовов легко превращается в десятки процентов “медленных” end-to-end ответов при ожидании нескольких зависимостей.

Правильное измерение – это не “посчитать p99”, а выбрать где измерять (client/edge/server/dependency), чем мерить (в большинстве случаев – histogram, а не summary), и как делать расследование: метрики должны вести к конкретному trace через exemplars, иначе p99-инциденты превращаются в гадание.

Снижение p99 – это всегда работа с причинами хвоста, а не “оптимизация среднего”: контроль очередей и пулов, ограничения конкуренции (bulkheads), дедлайны и таймауты как единый бюджет, аккуратные ретраи с retry-budget, а при перегрузке – управляемая деградация и load shedding. Именно так вы покупаете стабильность: p99 становится “плоским”, а система – управляемой даже под нагрузкой.

Loading