open atlas
↑ К треку
Go с нуля до senior GO · 06 · 04

Бенчмарки реальных сервисов: перцентили, coordinated omission и цикл оптимизации

Микробенчмарки не предсказывают латентность сервиса. Нагружайте open-loop с фиксированной частотой, читайте p99/p999 вместо средних, помните о coordinated omission, профилируйте под нагрузкой, а в CI — benchstat по многим прогонам: один прогон ничего не доказывает.

GO Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Перед запуском нагрузочный тест говорил, что новый эндпоинт прайсинга в порядке: стабильные 5 000 rps, средняя латентность 9 мс, p99 — комфортные 31 мс. В день запуска — тикеты в поддержку: «чекаут виснет на секунды». Дашборды соглашались с пользователями, а не с тестом — реальный p99 всплесками доходил до 2,4 с. Нагрузочный инструмент, строго говоря, не врал. Это был closed-loop генератор с 50 виртуальными пользователями, каждый из которых ждал ответа перед следующим запросом. Всякий раз, когда сервис замирал — шторм GC assist, затык пула соединений, — инструмент замедлялся вместе с ним, вежливо посылая меньше запросов ровно в те окна, где латентность была худшей, и записывая в основном хорошие времена между ними. Coordinated omission: измерение и отказ скоординированно прятали друг друга. Перезапуск с open-loop генератором, бьющим фиксированные 5 000 rps независимо от ответов, не оставил затыкам укрытия: p99 2,1 с — ровно там, где жили пользователи. Тот же сервис, та же нагрузка — честные часы.

К концу урока вы поймёте, почему нагрузочный тест может врать в сто раз, что такое coordinated omission и как его устранить, и как выглядит защищаемый CI-гейт бенчмарков.

Микробенчмарки: точные ответы на маленькие вопросы

testing.B превосходен в том, что он реально измеряет: стоимость пути кода в тёплом кеше, без соседей, без давления GC от остального сервиса и без сети. Это делает его правильным инструментом для сравнения двух реализаций одной функции — и неправильным для предсказания латентности сервиса. Даже в своих рамках есть два классических самообмана. Первый — удаление мёртвого кода: если результат бенчмарка не используется, компилятор может выкинуть работу; держите результаты живыми (присваивайте в sink уровня пакета или используйте современный паттерн b.Loop(), который сам сохраняет результаты тела живыми). Второй — замалчивание аллокаций: всегда запускайте с b.ReportAllocs() — функция, «ставшая быстрее на 10%» при удвоении allocs/op, в контексте сервиса обычно регрессия: аллокации — это налог GC из предыдущих уроков.

var sink int // уровень пакета: отключает удаление мёртвого кода

func BenchmarkParsePrice(b *testing.B) {
	data := loadFixture()
	b.ReportAllocs()
	for b.Loop() {           // Go 1.24+; заменяет for i := 0; i < b.N; i++
		v, _ := ParsePrice(data)
		sink = v
	}
}

// $ go test -bench=ParsePrice -count=10 > old.txt
// … вносим изменение …
// $ go test -bench=ParsePrice -count=10 > new.txt
// $ benchstat old.txt new.txt
//             │   old.txt   │            new.txt            │
// ParsePrice    412.0n ± 2%    389.5n ± 1%   -5.46% (p=0.000 n=10)

Числа микробенчмарка реальны — для микромира. Сервис добавляет очереди, contention, взаимные помехи GC между запросами и холодные кеши. Мост между мирами — правило: микробенчмарк — чтобы выбрать реализацию; нагрузочный тест — чтобы делать заявления о латентности.

Честное нагрузочное тестирование: перцентили и открытый цикл

Средняя латентность — самое бесполезное число в отчёте: его определяет быстрое большинство, и оно структурно слепо к хвосту, который реально чувствуют пользователи. Отчитывайтесь перцентилями — p50, p99, p99.9 — и уважайте, как хвосты складываются: страница, разветвляющаяся на 100 бэкенд-вызовов, испытывает p99 бэкенда на большинстве загрузок (шанс, что все 100 вызовов увернутся от худшего 1%, равен 0,99^100 ≈ 37%). Хвостовая латентность — не краевой случай; при fan-out это медианный опыт.

И есть ловушка из пролога — coordinated omission (скоординированный пропуск: инструмент и отказ договариваются прятать друг друга). Closed-loop инструменты (фиксированный пул виртуальных пользователей, каждый ждёт ответа перед следующим запросом) измеряют время обслуживания, но тихо перестают приходить во время затыков — ровно когда приходящие запросы встали бы в очередь и пострадали. Записанная гистограмма пропускает худшие секунды скоординированно с их наступлением. Open-loop инструменты (wrk2, vegeta, всё с фиксированной частотой прихода) продолжают бить по расписанию; запрос, пришедший во время затыка, ждёт, и всё его время ожидания попадает в гистограмму. Различие — не педантизм: оно регулярно меняет p99 в 10–100 раз, а это разница между «выкатили» и «разбудили дежурного». Правила ведения боя: нагружайте систему фиксированной частотой, выведенной из реального трафика (плюс запас), гоняйте достаточно долго, чтобы пересечь несколько циклов GC и перевыпусков пула (минуты, не секунды), и меряйте со стороны клиента, включая установку соединения, — как меряют пользователи.

Викторина

Closed-loop инструмент с 50 виртуальными пользователями показывает p99 = 31 мс, но пользователи в проде видят зависания на секунды. Какой дефект измерения наиболее вероятен?

Цикл оптимизации: измерь, выдвини гипотезу, измени одно, измерь снова

Оптимизация без цикла вырождается в фольклор. Senior-workflow механичен: (1) измерить под реалистичной нагрузкой — open-loop, фиксированная частота, перцентили, с профилями, снятыми во время прогона (CPU- или mutex-профиль простаивающего сервиса описывает другую программу); (2) сформулировать фальсифицируемую гипотезу — «p99 — это GC assist во время маркировки, разогнанный аллокациями в сериализаторе», а не «наверное, GC»; (3) изменить ровно одну вещь — как только два изменения едут вместе, выигрыш неатрибутируем, а следующая регрессия неотлаживаема; (4) перемерить тем же способом и позволить числам наложить вето на нарратив. Профили под нагрузкой — фабрика гипотез: CPU-профиль говорит, куда уходят такты на 5 000 rps, mutex-профиль объясняет латентность, которую CPU не объясняет, heap alloc называет источник давления на GC. Два правила честности из горького опыта: остерегайтесь оптимизаций, которые перемещают стоимость, а не убирают её (sync.Pool, меняющий churn аллокаций на удержание; кеш, чинящий p50 и отравляющий p999 штормами вытеснения), и держите генератор нагрузки на отдельном железе — насыщенный генератор измеряет сам себя.

Регрессии производительности в CI: benchstat или этого не было

Одиночный прогон бенчмарка — подброшенная монета в лабораторном халате. Шум между прогонами на общих CI-раннерах — стандартные ±5–10% (тепловое состояние, соседи, частотный скейлинг), поэтому пайплайн, падающий на «на 3% медленнее прошлого прогона», падает случайно, а пайплайн, сравнивающий одиночные прогоны, пропускает реальные 10%-е регрессии, спрятанные в шуме. Честная схема: гонять каждый бенчмарк с -count=10 (или больше), сравнивать распределения через benchstat, который выдаёт дельту с p-value, — и гейтить только статистически значимые изменения за порогом, выбранным осознанно (например, «падать на ≥5% при p < 0,05»). Фиксируйте, что можете: выделенные или помеченные раннеры, фиксированная частота CPU где возможно, явный GOMAXPROCS, изоляция бенчмарков от параллельных джоб. Для чисел, которые реально будят людей — сервисный p99 под нагрузкой, — ночной open-loop тест против стейджинга с архивированием профилей ловит то, что пофункциональные бенчмарки структурно не видят: сквозные регрессии давления GC, contention локов и работы с соединениями. CI-бенчмарки охраняют функции; регулярные нагрузочные тесты охраняют сервис.

Викторина

CI-джоба гоняет каждый бенчмарк один раз и валит сборку, потому что функция на 4% медленнее прошлого прогона. Какова корректная оценка?

Вспомните перед уходом
  1. 01
    Объясните coordinated omission: механизм, симптом и лечение.
  2. 02
    Спроектируйте CI-схему, ловящую реальные регрессии производительности без ложных срабатываний на шуме.
Итог

testing.B точно отвечает на маленькие вопросы — какая реализация быстрее в тёплом изолированном мире, — если вы отключили удаление мёртвого кода через sink или b.Loop и следите за allocs/op через b.ReportAllocs, потому что регрессия аллокаций — это регрессия сервиса, даже когда наносекунды улучшились. Заявлениям о латентности сервиса нужна другая машинерия: перцентили, поскольку средние слепы к хвосту, а fan-out делает p99 медианным опытом страницы (0,99^100 ≈ 37% страниц увернутся от худшего 1% при 100 вызовах); и open-loop нагрузка с фиксированной частотой прихода, потому что closed-loop инструменты совершают coordinated omission — замедляются вместе с сервисом и пропускают ровно окна затыков, пряча 10–100× p99: так эндпоинт прайсинга из пролога прошёл на 31 мс и выкатился на 2,4 с. Оптимизация — цикл, а не акт: измерить под нагрузкой с профилями во время прогона, написать фальсифицируемую гипотезу с названным механизмом, изменить одно, перемерить идентично — и не доверять победам, перекладывающим стоимость в удержание или хвост. В CI одиночные прогоны — монетки при шуме раннеров ±5–10%: гоняйте -count=10, сравнивайте через benchstat, гейтите значимые дельты за выбранным порогом и дополняйте пофункциональный гейт регулярным open-loop тестом, чьи архивные профили охраняют сервисные числа, которые реально будят людей. Теперь, когда настраиваете или разбираете нагрузочный тест, первый вопрос: open-loop или closed-loop? Если ответ — закрытый цикл, p99 в отчёте — не тот, что видят пользователи.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.