Содержание урока · 12 разделов
- производительность gin: мерить, потом менять
- Release mode: что выключает gin
- Аллокации: где хендлер тратит память
- sync.Pool: буферы по кругу
- Цена цепочки: роутер и middleware
- Профилирование: pprof сверху донизу
- Симптом → инструмент → лечение
- Грабли: ранняя оптимизация и слепые зоны
- Шесть правил производительности gin
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
производительность gin: мерить, потом менять#
Первое правило оптимизации — не оптимизировать вслепую. Интуиция про «тяжёлые» места работает в разы хуже, чем тридцать секунд профиля: в реальных сервисах половина CPU уходит в сериализацию JSON и шифрование TLS, четверть — в базу, и только остаток делится между вашим кодом и роутером. Поэтому цикл всегда один: воспроизводим нагрузку, снимаем профиль, находим топ-функцию, правим одну гипотезу, сравниваем бенчмарки, фиксируем или откатываем. В Go этот цикл короткий, потому что инструментарий встроен: net/http/pprof вешается на порт двумя строками, go tool pprof читает профили, testing.B бенчмаркует функцию с счётчиком аллокаций -benchmem, а benchstat считает, значим ли выигрыш. Отдельно про цели: цифра RPS без указания профиля трафика и железа — не метрика, а хвастовство; реальные цели формулируются как «p99 меньше 200 мс на 5000 rps с долей ошибок 0.1%». И ещё: оптимизируйте на Release-режиме — debug-режим gin с цветными логами и подсказками искажает картину, а лишние логи на горячем пути делают хвост латентности длиннее, чем весь ваш хендлер.
Release mode: что выключает gin#
У gin три режима: debug по умолчанию, release и test. В debug на старте пишется реклама фреймворка, дамп всех маршрутов и предупреждения вроде «set GIN_MODE=release in production»; в release всё это уходит, остаются только те логи, которые вы поставили сами. Переключение — gin.SetMode(gin.ReleaseMode) до создания движка или переменная окружения GIN_MODE=release: второй путь удобнее для продакшена, потому что один и тот же бинарник ведёт себя по окружению. Дальше логи. gin.DefaultWriter — туда пишет доступ-лог звена gin.Logger и дампы роутера; gin.DefaultErrorWriter — туда падают паники Recovery. На высоконагруженном сервисе access-лог в stdout на каждый запрос — это и CPU на форматирование, и диск, и конкуренция за мьютекс писателя: если логи собирает nginx или ingress, gin-лог можно отключить совсем — gin.DefaultWriter = io.Discard — и собирать метрики вместо текста. Третий пункт — Recovery и цвет: в release логи без ANSI-кодов, что радует любой централизованной системе сбора. Проверка режима — gin.Mode() возвращает текущую строку; на нём удобно ветвить отладочные звенья: в debug — подробный лог, в release — выборочная выборка. Помните предупреждение на старте — это тоже фича: если вы увидели его в проде, значит Release так и не включили.
Прод-старт: release, тихий писатель, pprof рядом
GOfunc main() {
gin.SetMode(gin.ReleaseMode)
// access-логи в сток: их пишет ingress, а метрики — prometheus
gin.DefaultWriter = io.Discard
r := gin.New()
r.Use(gin.Recovery())
r.GET("/todos", listTodos)
// pprof на внутреннем порту: наружу не светим
go func() {
http.ListenAndServe("127.0.0.1:6060", nil)
}()
if err := r.Run(":8080"); err != nil {
log.Fatalf("server: %v", err)
}
}SetMode до создания движка — иначе часть предупреждений уже уехала в лог. io.Discard съедает access-логи: не путайте с gin.Logger, который вы можете вернуть на отдельные маршруты. Пустой импорт net/http/pprof добавляет обработчики /debug/pprof к дефолтному ServeMux, и отдельный ListenAndServe на localhost держит профили внутри машины — внешний трафик до них не дотянется.
Проверка: что исчезнет в release
Вы переключили gin.SetMode(gin.ReleaseMode). Что изменится по сравнению с debug-режимом?
Аллокации: где хендлер тратит память#
Каждая аллокация — это работа сборщика мусора, а GC в горячем пути означает паузы и лишний CPU. Типовые дыры хендлера gin. Первая — map[string]any под видом gin.H: на каждый ответ строится хеш-таблица, каждая строка и число упаковываются в interface{} с боксингом на куче; готовая структура с json-тегами из урока про ответы сериализуется быстрее и проверяется компилятором. Вторая — fmt.Sprintf в горячем пути: для склейки строки из числа в десятки раз дешевле strconv.Itoa и конкатенация; Sprintf тянет парсер формата и рефлексию. Третья — слайсы без ёмкости: make([]T, 0) под цикл на десять тысяч элементов даёт десятки реаллокаций и копирований; заранее известный размер — make([]T, 0, n). Четвёртая — строки в цикле через += вместо strings.Builder. Пятая — замыкания и вызовы, которые вы не заметили: time.After в цикле аллоцирует таймер на итерацию, defer в узком цикле копит записи. Прицел на всё это — профиль кучи с опцией alloc_objects: он показывает не «сколько памяти лежит», а «сколько объектов родилось», и родившиеся — это и есть нагрузка на GC. Помните масштаб: пять аллокаций на запрос при 100 rps — мелочь, которую никто не заметит; пятьсот аллокаций на запрос при 5000 rps — уже гигабайты мусора в минуту.
Горячий путь: до и после
GO// Было: карта, боксинг, Sprintf — три дыры в одном хендлере
func bad(c *gin.Context) {
n := len(cache.Keys())
c.JSON(http.StatusOK, gin.H{
"count": n,
"message": fmt.Sprintf("готово: %d записей", n),
})
}
// Стало: статическая структура, без карты и без Sprintf
type countResponse struct {
Count int `json:"count"`
Message string `json:"message"`
}
func good(c *gin.Context) {
n := len(cache.Keys())
c.JSON(http.StatusOK, countResponse{
Count: n,
Message: "готово: " + strconv.Itoa(n) + " записей",
})
}Структура вместо gin.H убирает хеш-таблицу и боксинг полей; strconv.Itoa вместо fmt.Sprintf убирает разбор формата. Бенчмарк с -benchmem покажет падение с десятков аллокаций до единиц. Мораль шире примера: в горячем пути каждая функция спрашивается — обязан ли этот объект родиться на куче, или можно обойтись значением на стеке и переиспользованием.
sync.Pool: буферы по кругу#
Когда объекты дорогие и короткоживущие — буферы для рендера, энкодеры, временные слайсы — их можно не выбрасывать, а возвращать в пул. sync.Pool хранит набор объектов: Get отдаёт произвольный (возможно, свежесозданный через New), Put возвращает после использования. Выигрыш двойной: меньше аллокаций — меньше работы GC, и дорогая инициализация не повторяется. Три правила, без которых пул стреляет по ногам. Первое: буфер перед Put обязательно Reset — пул не чистит объекты, и следующий Get получит ваши старые байты вместе с новыми. Второе: пул не хранилище — сборщик мусора в любой момент может выкинуть содержимое, поэтому контракт «одолжил — вернул», а не «положил на потом»; данные нельзя терять при удалении из пула. Третье: возврат в defer, но после того, как данные скопированы клиенту: если вернёте буфер, пока io ещё читает срез — получите перезапись на лету. В самом gin пулы уже работают: внутренние буферы записи и парсинг multipart живут в пулах стандартной библиотеки, звенья вроде gzip-мидлвари держат пул своих энкодеров. Вашим кандидатам на пул — та же квалификация: горячий путь, дорогой объект, короткая жизнь. Пул на редкий путь — усложнение без выигрыша, а Reset-грабли добавятся всегда.
Пул буферов для кусочного рендера
GOvar bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func report(c *gin.Context) {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset() // иначе следующий получит чужие байты
bufPool.Put(buf)
}()
for _, line := range buildLines() {
buf.WriteString(line)
buf.WriteByte('\n')
}
// Data копирует буфер в сеть до возврата в пул
c.Data(http.StatusOK, "text/plain; charset=utf-8", buf.Bytes())
}Get вернул буфер — Reset в defer вернёт его чистым: порядок внутри defer важен, сначала чистка, потом Put. c.Data записывает байты в ответ сразу, поэтому к моменту возврата в пул данные уже ушли. Для многогигабайтных ответов буфер не спасёт — там по-прежнему io.Copy потоками, как в прокси; пул — про частые и короткие тела.
Забытый Reset
Прочитайте код и выберите, что он выведет:
func render(c *gin.Context, data string) {
buf := pool.Get().(*bytes.Buffer)
defer pool.Put(buf) // Reset забыли
buf.WriteString(data)
c.Writer.Write(buf.Bytes())
}
// первый запрос: render(c, "отчёт №1")
// второй запрос: render(c, "отчёт №2")Проверка: контракт пула
Вы положили в sync.Pool слайс с данными ответа и через минуту достали — а он пуст. Почему?
Цена цепочки: роутер и middleware#
После аллокаций второй источник латентности — то, что выполняется на каждый запрос до и после хендлера. Роутер gin — radix-дерево из httprouter: матчинг быстрый, но статические маршруты дешевле параметров, а wildcard — дороже всего; на сотнях маршрутов разница всё ещё микросекунды, так что переписывать пути ради скорости не стоит. А вот цепочка звеньев — честные такты на каждый запрос: каждое звено вызывает c.Next, держит свои замыкания, пишет логи. Звено из урока про кастомные middleware, которое на каждый запрос лезет в базу за тарифом пользователя, стоит на порядке дороже самого роутера — кэшируйте или переносите в хендлер. Логирование из урока 08 на горячем пути — форматирование строк в никуда; в release его заменяет сэмплирование или метрики. Gzip — классический обмен: минус трафик, плюс CPU на сжатие каждого ответа; для JSON в несколько байт сжатие дороже доставки, для больших текстов — наоборот, и уровень компрессии подбирается замером. И не забывайте выборочность: звено rate limiting нужно на публичной группе, но не на внутренних health-чеках. Итог прост: производительность gin на практике — это аудит цепочки, а не замена фреймворка.
Профилирование: pprof сверху донизу#
Инструментарий Go — главный аргумент против гаданий. CPU-профиль: go tool pprof снимает 30 секунд сэмплов с /debug/pprof/profile и показывает top — топ функций по времени, list — раскраску строк конкретной функции, web — граф вызовов; flamegraph с -http=:8081 интерактивнее списков. Профиль кучы: /debug/pprof/heap даёт две картины — inuse_space (кто держит память сейчас, для утечек) и alloc_objects (кто рождает объекты, для GC-нагрузки); их путать нельзя. Горутины: /debug/pprof/goroutine покажет, где они копятся — классическая утечка без ctx.Done из потоковых хендлеров. Mutex-профиль найдёт драку за лок, если под нагрузкой горутины стоят в очереди. Бенчмарки: testing.B гоняет функцию N раз, флаг -benchmem печатает аллокации на операцию; compare с benchstat отличает реальный выигрыш от шума — минимум десять прогонов. Нагрузка извне: hey или wrk дают честный RPS и распределение латентности, а не самонадеянный curl. Дисциплина та же, что у врачей: один профиль — одна гипотеза — одна правка — один повторный замер. «Оптимизировал сразу пять мест» — это значит, что вы не знаете, которое сработало.
Бенчмарк сервиса и команды профилей
GOfunc BenchmarkCreate(b *testing.B) {
svc := service.NewTodoService(repository.NewMemory())
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = svc.Create(context.Background(), uint(i), "дело")
}
}
// go test -bench=. -benchmem ./internal/service/
// BenchmarkCreate-8 2411 ns/op 384 B/op 5 allocs/op
//
// go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30
// (pprof) top10 -cum
// (pprof) list TodoService.Create
// (pprof) webСлои из урока 22 делают бенчмарк трёхстрочным: сервис собирается с in-memory репозиторием без HTTP и базы. ReportAllocs включает счётчик аллокаций, b.N — адаптивное число итераций. Команды после комментариев — рабочие: top с -cum сортирует по суммарному времени с учётом вызовов, list раскрашивает строки, web строит граф — всё на живом процессе через порт 6060 из первого примера.
Соберите замер аллокаций
Допишите бенчмарк и точку входа pprof: сервинг, счётчик аллокаций и адаптивный счётчик.
Соберите цикл оптимизации
Расставьте шаги от первого подозрения до закоммиченного выигрыша.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Симптом → инструмент → лечение#
| Симптом | Инструмент | Первая правка |
|---|---|---|
| CPU в потолке, RPS не растёт | profile?seconds=30, top -cum | кэш повторных вычислений, меньше JSON-маршалинга |
| Память ползёт вверх под нагрузкой | heap: inuse_space, diff по времени | найти растущую карту или горутину, добавить границы |
| Много мелких аллокаций на запрос | -benchmem, heap: alloc_objects | структуры вместо map, strconv вместо Sprintf, prealloc |
| Долгие паузы GC | GODEBUG=gctrace=1, alloc_objects | пулы буферов, меньше боксинга, GOMEMLIMIT как страховка |
| Хвост p99 длиннее среднего | mutex-профиль, goroutine-профиль | убрать общий лок, таймауты на внешние ходы |
Грабли: ранняя оптимизация и слепые зоны#
Грабль первый — оптимизация без профиля: «кажется, map медленный» превращается в час переписывания рабочего кода, а узким местом был медленный SQL-запрос без индекса. Правило Knuth-а никто не отменял: premature optimization — корень зла, а профессиональная оптимизация — это профиль, а не вкусовщина. Второй — оптимизация не того слоя: переписывать роутер, когда 90% времени уходит в сериализацию, — классика; смотрите top -cum, он честно покажет, что gin отъедает проценты. Третий — мерить на debug-режиме с логами: цветной вывод на каждый запрос стоит дороже хендлера, и вы будете оптимизировать логгер. Четвёртый — бенчмарк на одной итерации: шум процессора, турборежим и соседние процессы дают разброс в десятки процентов; минимум десять прогонов и benchstat, иначе вы оптимизируете случайность. Пятый — забытый GOMEMLIMIT: контейнер с лимитом памяти без этой переменной ловит OOM-киллы, хотя runtime мог бы договориться сборщиком; поставьте на 85-90% лимита поды. Шестой — микробенчмарки вместо системных: функция стала в три раза быстрее, а RPS не сдвинулся, потому что она вызывалась на 0.1% запросов. Седьмой — оптимизировать то, что уходить должно: иногда честнее выбросить звено из цепочки, чем сделать его быстрее, и урок про статические файлы тут пример: отдачу статики на проде обычно выносят из gin в nginx или CDN, и никакой пул буферов этого не заменит.
Шесть правил производительности gin#
- Сначала профиль, потом правка: pprof за CPU и heap, -benchmem за аллокации, benchstat за значимость.
- Release-режим и io.Discard в проде: отладочный вывод и access-логи на горячем пути — это CPU в трубу.
- Готовая структура вместо gin.H: без хеш-таблицы и боксинга; strconv вместо Sprintf; ёмкость слайсов заранее.
- sync.Pool для частой дорогой тары: Reset перед Put, данные скопированы до возврата, пул не кэш.
- Аудит цепочки звеньев: каждое middleware — такты на каждый запрос; дорогие ходы кэшировать или выносить.
- Одна гипотеза за итерацию: правка без замера — гадание, пять правок разом — потеря знания, которое сработало.
Карточки: оптимизация
Поднимите todo-сервис из урока про слои с in-memory репозиторием, повесьте pprof на 6060 и врубите hey -n 50000 -c 100. Снимите CPU-профиль и heap с alloc_objects, найдите топ-функцию и сделайте одну правку: структура вместо gin.H или strconv вместо Sprintf. Запустите go test -bench=. -benchmem до и после, сравните benchstat-ом — и вы увидите честные числа вместо ощущений. Проверьте и тихий режим: без SetMode(release) на старте сыплется дамп маршрутов, а с io.Discard access-лог исчезает из stdout. В браузерной песочнице сайта сетевой код не исполняется, поэтому интерактивы здесь quiz, predict, fill, order и флешкарты, а профили снимаются в локальном go run, как и весь курс gin. Дальше — 24-deploy: сборка бинарника, Docker, health-чеки и graceful shutdown — оптимизированный сервис нужно ещё не уронить при выкатке.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Оптимизация gin начинается с замера: pprof за CPU и память, testing.B с -benchmem за аллокации, benchstat за значимость; интуиция в цикл не входит.
- Release-режим убирает отладочный вывод, io.Discard гасит access-логи; сам код хендлеров от режима не меняется.
- Главные аллокационные дыры: gin.H вместо структур, fmt.Sprintf вместо strconv, слайсы без ёмкости и time.After в циклах.
- sync.Pool переиспользует дорогую тару: Reset перед Put обязателен, данные копируются до возврата, пул — не кэш и не хранилище.
- Цепочка звеньев дороже роутера: каждое middleware — такты на каждый запрос, дорогие ходы кэшировать, gzip и лимиты — выборочно.
- Одна гипотеза за итерацию и сравнение бенчмарков: пять правок разом — способ потерять знание о том, что сработало.
Домашнее задание
Возьмите сервис из урока про слои и проведите полный цикл: включите Release-режим, повесьте net/http/pprof на 127.0.0.1:6060, поставьте io.Discard в DefaultWriter. Запустите go test -bench=. -benchmem на BenchmarkCreate с in-memory репозиторием и зафиксируйте базовые ns/op, B/op, allocs/op. Снимите CPU-профиль на 30 секунд под hey -n 50000 -c 100 и найдите top-3 функции по -cum. Сделайте одну правку из урока — структура вместо gin.H в горячем ответе или пул буферов с Reset-ом — и снимите бенчмарк заново. Сравните через benchstat десять прогонов до и после и приложите дельту к коммиту. Отдельно проверьте predict-кейс урока: уберите Reset из пула и убедитесь под нагрузкой, что ответы сцепляются со старыми байтами.
Личные заметкиПусто
Вопросы и ответы по уроку
Насколько быст gin и стоит ли менять фреймворк ради скорости?
На типичном железе gin держит десятки тысяч простых RPS, и узким местом будут JSON, база и сеть задолго до роутера. Смена фреймворка даёт проценты, а кэш, индексы и срез аллокаций — кратные выигрыши. Менять фреймворк имеет смысл по другим причинам: экосистема, команда, уже существующий код.
Где смотреть аллокации: inuse_space или alloc_objects?
Разные вопросы. inuse_space показывает, кто держит память прямо сейчас, — это инструмент утечек и OOM. alloc_objects показывает, кто рождает объекты, независимо от их размера, — это инструмент нагрузки на GC и охоты за аллокациями в горячем пути. Для производительности хендлеров чаще нужен второй.
Нужен ли GOMEMLIMIT в контейнере?
Да, если у поды стоит memory limit: runtime по умолчанию не знает о cgroup-лимите и может собрать GC слишком поздно. GOMEMLIMIT на 85-90% лимита контейнера заставит сборщик работать активнее ближе к границе и почти убирает OOM-киллы. Это страховка, а не замена охоте за аллокациями.
Сколько звеньев цепочки — норм?
Нормы нет, есть бюджет: каждое звено платит микросекунды замыканий и такты своего кода на каждый запрос. Пять-семь точечных звеньев — здоровая картина; проблема не в количестве, а в дорогих ходах внутри: запрос к базе за каждым запросом, синхронное форматирование логов, gzip на всё подряд. Профиль покажет, что реально ест время.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Echo: минималистичный быстрый фреймворк
Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.
- Строить REST API на Echo
- Использовать типизированные запросы/ответы (Request/Response)
- Управлять ошибками через echo.HTTPError
- Писать middleware, включая JWT-аутентификацию