Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 23 из 25 ~20 мин +50 XP

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

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/): профиль подскажет, какой именно сервис виноват, а не «вообще код».

Содержание урока · 12 разделов

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

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

Каждая аллокация — это работа сборщика мусора, а 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-грабли добавятся всегда.

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

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

Прочитайте код и выберите, что он выведет:

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. Дисциплина та же, что у врачей: один профиль — одна гипотеза — одна правка — один повторный замер. «Оптимизировал сразу пять мест» — это значит, что вы не знаете, которое сработало.

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

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

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) }()

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

СимптомИнструментПервая правка
CPU в потолке, RPS не растётprofile?seconds=30, top -cumкэш повторных вычислений, меньше JSON-маршалинга
Память ползёт вверх под нагрузкойheap: inuse_space, diff по временинайти растущую карту или горутину, добавить границы
Много мелких аллокаций на запрос-benchmem, heap: alloc_objectsструктуры вместо map, strconv вместо Sprintf, prealloc
Долгие паузы GCGODEBUG=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 — такты на каждый запрос; дорогие ходы кэшировать или выносить.
  • Одна гипотеза за итерацию: правка без замера — гадание, пять правок разом — потеря знания, которое сработало.

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

Термин

gin.SetMode(gin.ReleaseMode)

нажмите, чтобы перевернуть
Определение

Режим без дамп маршрутов, предупреждений и ANSI-цветов; ставится кодом до создания движка или GIN_MODE=release. Логика хендлеров не меняется. Вместе с gin.DefaultWriter = io.Discard убирает access-логи, если их собирает nginx или ingress.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 и стоит ли менять фреймворк ради скорости?

На типичном железе gin держит десятки тысяч простых RPS, и узким местом будут JSON, база и сеть задолго до роутера. Смена фреймворка даёт проценты, а кэш, индексы и срез аллокаций — кратные выигрыши. Менять фреймворк имеет смысл по другим причинам: экосистема, команда, уже существующий код.

Где смотреть аллокации: inuse_space или alloc_objects?

Разные вопросы. inuse_space показывает, кто держит память прямо сейчас, — это инструмент утечек и OOM. alloc_objects показывает, кто рождает объекты, независимо от их размера, — это инструмент нагрузки на GC и охоты за аллокациями в горячем пути. Для производительности хендлеров чаще нужен второй.

Нужен ли GOMEMLIMIT в контейнере?

Да, если у поды стоит memory limit: runtime по умолчанию не знает о cgroup-лимите и может собрать GC слишком поздно. GOMEMLIMIT на 85-90% лимита контейнера заставит сборщик работать активнее ближе к границе и почти убирает OOM-киллы. Это страховка, а не замена охоте за аллокациями.

Сколько звеньев цепочки — норм?

Нормы нет, есть бюджет: каждое звено платит микросекунды замыканий и такты своего кода на каждый запрос. Пять-семь точечных звеньев — здоровая картина; проблема не в количестве, а в дорогих ходах внутри: запрос к базе за каждым запросом, синхронное форматирование логов, gzip на всё подряд. Профиль покажет, что реально ест время.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Echo25 уроков~13 чпрактика25 уроков уже доступны

Echo: минималистичный быстрый фреймворк

Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.

  • Строить REST API на Echo
  • Использовать типизированные запросы/ответы (Request/Response)
  • Управлять ошибками через echo.HTTPError
  • Писать middleware, включая JWT-аутентификацию