Анализ исходного состояния: поиск Bottlenecks под нагрузкой
Анализ исходного состояния: поиск Bottlenecks под нагрузкой
Представьте ситуацию: вы написали монолитный сервис агрегации профилей пользователей (назовём его Profile API). На локальной машине он отвечает за 5 миллисекунд. Вы разворачиваете его в staging-окружении, подаёте нагрузку в 1000 RPS, ожидая увидеть триумф статической типизации и легковесных горутин... но сервис ложится. Задержка (p99 Latency) улетает за 3 секунды, а часть запросов отваливается по таймауту.
В предыдущих курсах мы детально разобрали, как работают горутины, сборщик мусора и инструменты профилирования. Теперь мы объединим эти знания. В этом практикуме мы пройдём путь от падающего сервиса до кратно ускоренной системы. И первый шаг — это не переписывание кода, а постановка точного диагноза.
Методология: Базлайн и воспроизводимость
Главная ошибка при оптимизации — начать менять код, опираясь на интуицию. «Наверное, здесь медленный SQL-запрос» или «Давайте добавим кэш». Любая оптимизация начинается с фиксации базлайна (Baseline).
Базлайн — это задокументированное состояние системы до внесения изменений, включающее целевую нагрузку (RPS) и ключевые метрики (p99 Latency, потребление CPU/RAM).
Без базлайна вы не сможете доказать, что ваше изменение действительно улучшило систему, а не просто перенесло узкое место (Bottleneck) в другой компонент.
Для постановки диагноза нам нужен диагностический стенд, состоящий из трёх элементов:
- Генератор нагрузки (например, утилиты
vegetaилиwrk), который подаёт стабильный поток запросов. - Целевой сервис, скомпилированный без оптимизаций, искажающих стек вызовов (стандартная сборка).
- Сборщик профилей, который опрашивает эндпоинты
net/http/pprofво время работы генератора нагрузки.
Сбор метрик в момент деградации
Профили pprof (CPU, Heap, Block) абсолютно бесполезны, если сервис простаивает. Узкие места проявляются только в состоянии насыщения (Saturation).
Сценарий сбора выглядит так:
- Запускаем генератор нагрузки на уровне, при котором сервис начинает деградировать (например, 300 RPS, когда p99 Latency превышает норму).
- Ждём 10–15 секунд, чтобы сервис «прогрелся» (заполнились кэши, установились соединения с БД).
- Запускаем сбор CPU-профиля на 30 секунд:
go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30. - Параллельно собираем профили памяти и блокировок.
Получив профили, мы начинаем расследование. В распределённых системах анализ всегда идёт по трём векторам: Процессор, Блокировки и Память.
Вектор 1: CPU-профиль и иллюзия простоя
Открываем Flame Graph собранного CPU-профиля. В идеальном мире высоконагруженного сервиса самые широкие блоки (метрика Cum) должны принадлежать полезной бизнес-логике или ожиданию сети.
Но в нашем Profile API мы видим иную картину:
- 40% процессорного времени тратится внутри
json.Marshal. - 20% уходит на системные вызовы сборщика мусора (
runtime.gcBgMarkWorker).
Казалось бы, нужно срочно оптимизировать JSON. Но есть парадокс: мы смотрим на метрики сервера и видим, что утилизация CPU составляет всего 25%. Процессор свободен на три четверти! При этом p99 Latency составляет 2 секунды.
Если процессор не загружен, а запросы обрабатываются медленно, значит, горутины не работают. Они спят. И CPU-профайлер, который сэмплирует только активные потоки, не покажет нам причину их сна.
Вектор 2: Block и Mutex профили (Скрытые заторы)
Когда горутины не потребляют CPU, но время ответа растёт, мы обращаемся к профилям блокировок. Вспоминаем закон Литтла (): при стабильном входящем потоке () рост времени обработки () неизбежно приводит к лавинообразному росту конкурентности () — количества одновременно висящих в памяти горутин.
Открыв Mutex-профиль нашего Profile API, мы видим гигантский блок на строке:
cache.go:42 - sync.(*RWMutex).Lock()
В коде реализован in-memory кэш профилей. Чтобы защитить map от состояния гонки (Race Condition), разработчик использовал sync.RWMutex. Однако при 300 RPS количество записей в кэш (инвалидация) возросло. Каждая запись берёт эксклюзивную блокировку (Lock), выстраивая сотни читающих горутин (ожидающих RLock) в очередь.
Возникает эффект Contention (конкуренция за ресурс). Планировщик Go тратит ресурсы на переключение контекста спящих горутин, очередь запросов растёт, и сервис деградирует нелинейно.
Вектор 3: Heap-профиль (Аллокационный налог)
Вернёмся к тому, что 20% времени CPU тратилось на сборку мусора. Открываем Heap-профиль в режиме alloc_space (аллоцированная память за всё время).
Мы видим, что на каждый из 300 запросов в секунду сервис выделяет новые буферы []byte для сериализации JSON и создания структур ответа. Escape Analysis отправляет всё это в кучу (Heap).
Вспоминаем механику GC: чем быстрее мы мусорим, тем агрессивнее GC Pacer запускает сборку мусора. Каждая сборка — это микропаузы Stop-The-World и налог на процессорное время (до 25% CPU уходит на фазу Concurrent Marking).
Аллокации усугубляют проблему блокировок:
- Горутина захватывает мьютекс кэша.
- В этот момент запускается GC, замедляя работу этой горутины.
- Горутина удерживает эксклюзивную блокировку дольше обычного.
- Очередь ожидающих горутин вырастает экспоненциально.
Формирование диагноза
Анализ исходного состояния завершён. Мы не написали ни строчки кода, но теперь у нас есть чёткая карта узких мест нашего Profile API. Мы сводим её в единый диагноз:
| Компонент | Симптом из pprof | Корневая причина |
|---|---|---|
| In-memory Кэш | Mutex профиль: 80% времени ожидания на RWMutex.Lock |
Единый глобальный лок на map не справляется с потоком инвалидаций. |
| Сериализация | CPU профиль: 40% json.Marshal |
Использование стандартного пакета encoding/json с рефлексией на каждый чих. |
| Память | Heap профиль: огромный alloc_space буферов |
Отсутствие переиспользования памяти. Каждый запрос создаёт объекты, триггеря GC. |
Имея на руках базлайн (300 RPS, p99 = 2s) и точный диагноз, мы готовы к хирургическому вмешательству. В следующей главе мы начнём с самого критичного узла — устраним глобальную блокировку в кэше, применив lock-free подходы.