---
title: "Оптимизация производительности"
description: "Производительность gin: Release mode и тишина логов, pprof и бенчмарки, охота на аллокации, sync.Pool для буферов и цена каждого звена в цепочке middleware."
course: "Gin: самый популярный веб-фреймворк Go"
url: "https://golangmaster.ru/courses/gin/lessons/23-optimizaciya/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Оптимизация производительности

*Курс «Gin: самый популярный веб-фреймворк Go», урок 23 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gin/lessons/23-optimizaciya/*

Gin быстр из коробки — роутер на radix-дереве и тонкий контекст дают десятки тысяч запросов в секунду на ноутбуке. Но узким местом настоящий сервис делает почти никогда не фреймворк: JSON-маршалинг, база, сеть и ваш собственный код съедают в разы больше. Производительность gin измеряется, а не угадывается: сначала профили и бенчмарки, потом правки, потом снова числа — в обратном порядке лежат только грабли. Разберём режим Release и что он выключает, охоту на аллокации в горячем пути, sync.Pool для буферов и цену [звеньев из урока 06](/courses/gin/lessons/06-middleware/), а затем — net/http/pprof, testing.B и benchstat: инструменты, без которых оптимизация gin превращается в перебор советов из интернета. Всё это встанет на [слои из урока 22](/courses/gin/lessons/22-struktura/): профиль подскажет, какой именно сервис виноват, а не «вообще код».

### производительность 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 рядом

```go
func 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-режимом?

- Пропадёт Recovery: паники начнут валить процесс, поэтому release ставят только с собранными логами
- ✅ Уйдут дамп маршрутов, предупреждения и ANSI-цвета; хендлеры и звенья работают как работали
- Сериализация JSON переключится на быстрый кодек, и все ответы ускорятся процентов на двадцать
- Роутер начнёт кэшировать ответы, и повторные GET станут отдаваться из памяти

Release режет только отладочный вывод: рекламу фреймворка, таблицу маршрутов и подсказки. Recovery, маршрутизация, bind и сериализация — те же самые; никакого быстрого JSON-кодека и кэша ответов в gin нет. Если нужен другой маршалер — c.SetJSONMarshaler или собственный сериализатор, но это отдельное решение с совместимостью, а не эффект режима.

### Аллокации: где хендлер тратит память

Каждая аллокация — это работа сборщика мусора, а GC в горячем пути означает паузы и лишний CPU. Типовые дыры хендлера gin. Первая — map[string]any под видом gin.H: на каждый ответ строится хеш-таблица, каждая строка и число упаковываются в interface{} с боксингом на куче; готовая структура с json-тегами из [урока про ответы](/courses/gin/lessons/05-json-otvety/) сериализуется быстрее и проверяется компилятором. Вторая — 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-грабли добавятся всегда.

### Пул буферов для кусочного рендера

```go
var 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

```go
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")
```

**Что выведет программа?**

- Клиент второго запроса увидит только «отчёт №2»: пул возвращает чистый буфер
- ✅ Клиент второго запроса увидит «отчёт №1отчёт №2»: буфер приехал из пула со старыми байтами
- Второй запрос повиснет: Put удерживает буфер до конца процесса
- Второй запрос упадёт в панику: нельзя писать в буфер из пула дважды

Пул не чистит объекты: первый буфер уехал в пул с «отчёт №1» внутри, второй Get забрал его и дописал новые байты в конец. Забытый Reset — самая частая пуля sync.Pool, и проявляется она только под нагрузкой, когда пул реально переиспользуется: на холодном старте всё выглядит идеально. Правило: Reset стоит рядом с Put всегда, даже если «сейчас не нужно».

### Проверка: контракт пула

**Вопрос:** Вы положили в sync.Pool слайс с данными ответа и через минуту достали — а он пуст. Почему?

- sync.Pool чистит объекты при каждом Put, чтобы не копить мусор
- ✅ Пул не гарантирует хранение: после GC содержимое может быть собрано, это встроено в контракт
- Слайс нельзя класть в пул — только указатели на структуры
- Виноват runtime.GOMAXPROCS: пул привязан к P-процессору, и чужой P не видит ваш объект

sync.Pool — не кэш, а ускоритель переиспользования: после сборки мусора локальные объекты пула могут быть сброшены, и Get честно создаст новый через New. Это фича, а не баг — иначе пулы превращались бы в утечки памяти. Поэтому в пул кладут только «одноразовую тару»: буферы, энкодеры, вспомогательные структуры; бизнес-данные хранят в карте, базе или кэше. Привязка к P действительно есть — она ускоряет локальный доступ, но не объясняет пропажу.

### Цена цепочки: роутер и middleware

После аллокаций второй источник латентности — то, что выполняется на каждый запрос до и после хендлера. Роутер gin — radix-дерево из httprouter: матчинг быстрый, но статические маршруты дешевле параметров, а wildcard — дороже всего; на сотнях маршрутов разница всё ещё микросекунды, так что переписывать пути ради скорости не стоит. А вот цепочка звеньев — честные такты на каждый запрос: каждое звено вызывает c.Next, держит свои замыкания, пишет логи. Звено из урока про [кастомные middleware](/courses/gin/lessons/07-kastomnye-middleware/), которое на каждый запрос лезет в базу за тарифом пользователя, стоит на порядке дороже самого роутера — кэшируйте или переносите в хендлер. Логирование из [урока 08](/courses/gin/lessons/08-logirovanie-recovery/) на горячем пути — форматирование строк в никуда; в release его заменяет сэмплирование или метрики. Gzip — классический обмен: минус трафик, плюс CPU на сжатие каждого ответа; для JSON в несколько байт сжатие дороже доставки, для больших текстов — наоборот, и уровень компрессии подбирается замером. И не забывайте выборочность: звено [rate limiting](/courses/gin/lessons/18-rate-limit/) нужно на публичной группе, но не на внутренних 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. Дисциплина та же, что у врачей: один профиль — одна гипотеза — одна правка — один повторный замер. «Оптимизировал сразу пять мест» — это значит, что вы не знаете, которое сработало.

### Бенчмарк сервиса и команды профилей

```go
func 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: сервинг, счётчик аллокаций и адаптивный счётчик.

```go
import _ "net/http/pprof"

func BenchmarkCreate(b *testing.B) {
        svc := service.NewTodoService(repository.NewMemory())

        b.___()
        for i := 0; i < b.___; i++ {
                _, _ = svc.Create(context.Background(), uint(i), "дело")
        }
}

// отдельная горутина для профилей:
go func() {
        http.___("127.0.0.1:6060", nil)
}()
```

Подсказка: b.ReportAllocs включает колонки B/op и allocs/op; цикл крутится b.N раз — число подбирает сам бенчмарк; дефолтный мультиплексор стартует через http.ListenAndServe.

Правильные ответы в пропусках: `ReportAllocs`, `N`, `ListenAndServe`

### Соберите цикл оптимизации

Расставьте шаги от первого подозрения до закоммиченного выигрыша.

Правильный порядок строк:

1. `Воспроизводим нагрузку: hey или wrk на прод-подобный профиль трафика`
2. `Снимаем CPU-профиль на 30 секунд с /debug/pprof/profile`
3. `top и list показывают, куда уходит время: JSON, SQL или наша функция`
4. `Снимаем heap с alloc_objects: кто рождает объекты в горячем пути`
5. `Формулируем одну гипотезу: структура вместо map или пул буферов`
6. `Правим ровно одно место, не трогая остальное`
7. `Гоняем бенчмарк с -benchmem до и после, сравниваем benchstat-ом`
8. `Выигрыш значим — коммитим с числами в описании; нет — откатываем`
9. `Повторяем цикл: следующий профиль снимаем после нового релиза, а не по памяти`

### Симптом → инструмент → лечение

| Симптом | Инструмент | Первая правка |
| --- | --- | --- |
| 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% запросов. Седьмой — оптимизировать то, что уходить должно: иногда честнее выбросить звено из цепочки, чем сделать его быстрее, и [урок про статические файлы](/courses/gin/lessons/11-statika/) тут пример: отдачу статики на проде обычно выносят из gin в nginx или CDN, и никакой пул буферов этого не заменит.

### Шесть правил производительности gin

- Сначала профиль, потом правка: pprof за CPU и heap, -benchmem за аллокации, benchstat за значимость.
- Release-режим и io.Discard в проде: отладочный вывод и access-логи на горячем пути — это CPU в трубу.
- Готовая структура вместо gin.H: без хеш-таблицы и боксинга; strconv вместо Sprintf; ёмкость слайсов заранее.
- sync.Pool для частой дорогой тары: Reset перед Put, данные скопированы до возврата, пул не кэш.
- Аудит цепочки звеньев: каждое middleware — такты на каждый запрос; дорогие ходы кэшировать или выносить.
- Одна гипотеза за итерацию: правка без замера — гадание, пять правок разом — потеря знания, которое сработало.

### Карточки: оптимизация

- **gin.SetMode(gin.ReleaseMode)** — Режим без дамп маршрутов, предупреждений и ANSI-цветов; ставится кодом до создания движка или GIN_MODE=release. Логика хендлеров не меняется. Вместе с gin.DefaultWriter = io.Discard убирает access-логи, если их собирает nginx или ingress.
- **gin.H против структуры** — gin.H — это map[string]any: хеш-таблица и боксинг каждого поля на каждый ответ, плюс нулевая типобезопасность. Структура с json-тегами сериализуется быстрее, проверяется компилятором и аллоцируется предсказуемо. Для горячих ответов — только структуры.
- **sync.Pool** — Набор переиспользуемых объектов: Get отдаёт произвольный, Put возвращает. Не гарантирует хранение после GC. Контракт: Reset перед Put, данные скопированы до возврата, в пул — только буферы и энкодеры, не бизнес-данные. Забытый Reset = чужие байты в ответе.
- **pprof: какой профиль когда** — CPU: /debug/pprof/profile?seconds=30 — где время. heap inuse_space — кто держит память (утечки). heap alloc_objects — кто рождает объекты (нагрузка на GC). goroutine — накопление горутин. mutex — драка за лок. Всё через net/http/pprof на внутреннем порту.
- **testing.B и benchstat** — Бенчмарк: func BenchmarkX(b *testing.B), цикл по b.N, b.ReportAllocs для B/op и allocs/op. Запуск: go test -bench=. -benchmem. Сравнение версий — benchstat на 10+ прогонах: показывает дельту и разброс, отсекает шум процессора.
- **Цена middleware** — Каждое звено выполняется на каждый запрос: замыкание, лог, ход в базу — всё складывается. Дорогие ходы кэшировать, логи сэмплировать, gzip включать выборочно по типу тела, лимиты — только на публичных группах. Роутер на radix-дереве — не узкое место; узкое место — цепочка.

> **Прогоните цикл оптимизации на своём сервисе.** Поднимите todo-сервис из [урока про слои](/courses/gin/lessons/22-struktura/) с 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](/courses/gin/). Дальше — 24-deploy: сборка бинарника, Docker, health-чеки и graceful shutdown — оптимизированный сервис нужно ещё не уронить при выкатке.

## Коротко: что запомнить

- Оптимизация 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 на всё подряд. Профиль покажет, что реально ест время.
