Представьте страницу поиска отеля. Она собирает ответ из пяти сервисов: цены, фото, отзывы, карта, рекомендации. Каждый отвечает в среднем за 20 мс — быстро. Но пятая часть страницы ждёт не «среднего», а самого медленного из пяти. И вот тут выясняется, что у сервиса цен каждый сотый запрос идёт 800 мс — и каждые двадцатые запросы пользователя упираются именно в него.
Это и есть хвостовая латентность. Средние числа о ней молчат, а именно она определяет, как система ощущается снаружи. В трейдинге цена исполнения зависит от скорости: кто быстрее увидел состояние рынка, тот в выигрыше. В обычных продуктах ставке ниже, но правило то же: пользователи не читают средние — они живут в хвосте распределения.
Что не так со средним
Среднее арифметическое складывает все значения и делит на число. Оно отвечает на вопрос «сколько времени заняла вся работа», а не «сколько ждёт один запрос». Тысяча запросов по 1 мс и один на 901 мс дают среднее 2 мс — формально честно, по сути обман: один клиент ждал почти секунду, и об этом числе нет ни слова.
Медиана (p50) честнее: она говорит, что половина запросов успела за это время. Половина — но не все. Для страницы из десяти последовательных вызовов вероятность упереться в медленный хотя бы раз близка к единице: хвост каждого вызова становится хвостом всей страницы.
Распределение задержек в реальных системах не похоже на колокол: у него длинный правый хвост из редких, но болезненных событий. Поэтому качество описывают перцентилями — значениями, за которыми лежит заданный процент запросов:
- p50 (медиана) — половина запросов быстрее;
- p99 — 99% быстрее, один из ста медленнее;
- p999 — один из тысячи в хвосте.
Перцентиль p99 = 800 мс читается так: обычный запрос быстрый, но каждый сотый клиент платит почти секундой. Если таким запросом является списание со счёта — каждый сотый списание смотрит на застывший экран.
Откуда берутся хвосты
Хвосты не появляются сами — у каждого есть источник, и почти все они регулярны и предсказуемы.
Сборка мусора. Пока GC останавливает мир, стоят все запросы разом. Пауза приходит раз в минуты, но каждая стопорит всё — и попадает в хвост сразу многих запросов. Чем больше куча и чем реже сборки, тем длиннее единичный хвост.
Блокировки и contention. Два запроса схлестнулись за один лок — один из них ждёт. Пока конкурентных запросов мало, столкновения редки; под нагрузкой их частота растёт нелинейно, и то, что в тестах было невидимо, на проде становится каждосотым запросом.
Ресайзы структур данных. HashMap удвоился, ArrayList скопировался — все, кто в этот момент писали, шагнули в хвост. Ресайз длится миллисекунды, но при частоте операций в тысячи в секунду он обязательно кому-то попадётся.
Деоптимизация JIT. Горячий код пере компилировался на другой уровень оптимизации — пауза в разы больше обычной. Тот же эффект дают холодные ветки, которые впервые прошли после часа работы.
Сеть. Ретраансмит TCP, переполнение буфера коммутатора, «шумный сосед» по гипервизору — всё это не зависит от вашего кода, но попадает в ваши хвосты.
Фан-аут. Запрос ждёт самый медленный из десяти параллельных вызовов. Если каждый вызов имеет 1% хвостов, вероятность хотя бы одного хвостового вызова — примерно 10%. Хвост одного сервиса становится хвостом всей страницы.
Как измерять честно
Первое правило: перцентили нельзя усреднять. Если узел А отдал p99 = 100 мс и узел Б отдал p99 = 100 мс, «средний p99 кластера» не 100 мс: хвосты складываются по вероятности, и настоящий p99 выше. Считать нужно по объединённой выборке — для этого собирают гистограммы задержек:
- в Prometheus —
histogram_quantile()по бакетам, а неavg(); - в Java — HDR-histogram: он не теряет точности на широких диапазонах.
Второе правило: мерить там, где живёт клиент. Перцентиль, посчитанный внутри сервиса, не видит очередь соединений, TLS-рукопожатие, сетевой путь и браузерные перерисовки. Клиентский p999 видит всё — поэтому хорошие команды смотрят оба и знают разницу между ними.
Третье: смотреть хвосты в разрезе. Общий p99 по всем запросам прячет то, что у одного эндпоинта хвост из-за GC, у другого — из-за фан-аута, у третьего — из-за отсутствия индекса. Разрез по маршруту превращает «что-то медленно» в конкретный список работ.
Что с этим делают
Оптимизация хвостов устроена иначе, чем оптимизация среднего: часто она делает среднее чуть хуже, чтобы убрать редкие провалы.
Изоляция медленных. Разным классам запросов — разные пулы потоков и соединений. Одна тяжёлая выгрузка не должна занимать слоты быстрых поисковых запросов.
Таймауты и fallback. Лучше быстрый приблизительный ответ, чем точный в хвосте: не дождались сервиса отзывов — покажите страницу без отзывов и догрузите. Каждый фан-аут получает бюджет времени.
Снижение частоты хвостов. GC с предсказуемыми паузами (G1/ZGC), отказ от ресайзов на горячем пути (фиксированные размеры, предвыделенные структуры), уменьшение contention (сегментирование локов, sharding счётчиков).
Уборка фан-аутов. Десять последовательных вызовов становятся одним BFF-запросом; данные, без которых можно жить, догружаются отдельно. Вероятность хвоста падает вместе с числом вызовов.
Общий принцип: система хороша не тогда, когда быстро в среднем, а когда предсказуемо быстро — когда о времени ответа можно заключать договор, а не бросать монетку.
Масса слева — быстрые запросы, из которых состоит среднее. Хвост справа редкий, но именно он достаётся каждому сотому клиенту.
Коротко
- Среднее отвечает «сколько заняла вся работа», а не «сколько ждёт один»; медиана молчит о половине.
- Качество описывают перцентилями: p99 — один из ста в хвосте, p999 — один из тысячи.
- Хвосты рождаются из GC, локов, ресайзов, JIT, сети и фан-аутов; хвост одного вызова становится хвостом страницы.
- Перцентили не усредняют: гистограммы и объединённая выборка; мерить там, где живёт клиент.
- Лечат изоляцией медленных, таймаутами с fallback и уборкой фан-аутов — даже ценой чуть худшего среднего.
Что почитать дальше
- Горячий путь: микросекунды решают — что убирают с критического пути, чтобы хвосты не появлялись.
- Сборка мусора в Java — источник хвостов №1 и как его приручить.