---
title: "Производительность и тюнинг: benchmarks, компрессия, пулы и профилирование"
description: "производительность echo: benchmarks через e.ServeHTTP, режим debug и release, пулы контекстов, gzip-компрессия, таймауты http.Server и профилирование pprof."
course: "Echo: минималистичный быстрый фреймворк"
url: "https://golangmaster.ru/courses/echo/lessons/22-performance/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Производительность и тюнинг: benchmarks, компрессия, пулы и профилирование

*Курс «Echo: минималистичный быстрый фреймворк», урок 22 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/echo/lessons/22-performance/*

производительность echo — урок, где канон перестаёт быть просто правильным и становится быстрым. Приложение из [двадцать первого урока](/courses/echo/lessons/21-struktura/) разложено по слоям, тесты из [двадцатого](/courses/echo/lessons/20-testirovanie/) зелёные, gzip из [седьмого](/courses/echo/lessons/07-vstroennye-middleware/) включён — но ни одно из этих решений ещё не измерено числом. Тюнинг echo начинается с запрета: сначала замер, потом правка. Мы соберём бенчмарк, который гоняет e.ServeHTTP(rec, req) из двадцатого урока в цикле testing.B, разберём режим debug и его прод-настройки HideBanner и HidePort, заглянем в пулы — контексты Echo и sync.Pool gzip-райтеров, — посчитаем аллокации в обработчиках, проверим цену компрессии на SSE-эндпоинте из [девятнадцатого](/courses/echo/lessons/19-sse/), поставим честные таймауты http.Server и подключим pprof, чтобы оптимизация echo опиралась на профиль процессора, а не на интуицию. Каждый замер повторяем на локальном Go — цифры сверьте на своей машине. Приступаем — оптимизация echo считается в наносекундах и байтах, а не в ощущениях.

### производительность echo: сначала измерение, потом правка

Тема «производительность echo» обязана начинаться с приборов, потому что интуиция в этой области врёт с поразительным постоянством: «оптимизация» наугад добавляет сложность, ломает читаемость слоёв из двадцать первого урока и не меняет ни наносекунды. Прибор первый — бенчмарк: функция TestBench... точнее BenchmarkXxx(b *testing.B), которую Go гоняет b.N раз, поднимая N от единицы до статистически значимого, и печатает ns/op — наносекунд на итерацию, B/op — байт аллокаций на итерацию, allocs/op — число аллокаций. Прибор второй — профайлер: профиль процессора и кучи, который показывает, на какие строки уходит время и память. Прибор третий — счётчики прод-трафика, о которых пойдёт речь в двадцать третьем уроке; до него замеры в проде делаются логами и графаной из внешнего стека.

Порядок работы всегда один и тот же, и его лучше выучить раньше конкретных трюков: замерить базовую линию, найти горячую точку профилем, исправить одно место, повторить замер, оставить правку только если число изменилось. Всё остальное — догадки. И правило честности: бенчмарк делается на той же машине и той же сборке, что будет работать, — -race и debug-режим меняют цифры в разы, поэтому гонка проверяется отдельно от замера. Echo здесь удобный подопытный: его ядро — e.ServeHTTP(w, r) — это честный http.Handler из двадцатого урока, и его можно гонять в цикле без сети, портов и сокетов.

### Канон: бенчмарк стека через e.ServeHTTP

```go
// tasks_bench_test.go — benchmark рядом с тестами
func BenchmarkListTasks(b *testing.B) {
        e := app.New(repository.NewMemory(), []byte("bench-secret-32-bytes!"))
        req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
        req.Header.Set("Authorization", "Bearer "+makeAccess("admin"))

        var sink *bytes.Buffer // стакан: не дать компилятору выбросить работу
        b.ReportAllocs()
        b.ResetTimer()
        for i := 0; i < b.N; i++ {
                rec := httptest.NewRecorder()
                e.ServeHTTP(rec, req)
                sink = rec.Body
        }
        if sink == nil {
                b.Fatal("ничего не исполнилось")
        }
}

// go test -bench=. -benchmem
// BenchmarkListTasks-8   152341   7832 ns/op   4512 B/op   42 allocs/op
```

Четыре решения, которые делают бенчмарк пригодным для решений. Тестируется весь стек — app.New со слоями из двадцать первого урока: замеряется то, что отвечает клиенту, а не функция в изоляции. Новая пара rec — на каждую итерацию: рекордер копит тело, и переиспользование подмешало бы прошлые ответы (правило двадцатого урока, здесь оно же правило чистоты замера). sink — «стакан»: результат записывается в пакетную переменную, чтобы компилятор не выкинул весь цикл как мёртвый код; без стакана оптимизатор видит «результат не используется» и оставляет вам честные 0,3 нс/оп пустоты. ReportAllocs включает столбцы аллокаций без флага -benchmem. ResetTimer отсекает сборку стенда от замера. Числа в комментарии — порядок величин учебного канона, не эталон: на вашей машине они будут другими, важна динамика до и после правки.

### Прогноз: бенчмарк без стакана

```go
func BenchmarkList(b *testing.B) {
        e := app.New(repository.NewMemory(), secret)
        req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
                rec := httptest.NewRecorder()
                e.ServeHTTP(rec, req)
        }
}

// что покажет go test -bench=.?
```

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

- ✅ Почти ноль ns/op и 0 B/op: результат итерации не используется, компилятор выбросил тело цикла
- Честные цифры: ServeHTTP обязан исполниться каждый раз
- Панику: бенчмарк без b.ResetTimer запрещён
- 0 ns/op только без -benchmem, с ним — честные

Результат итерации никому не присваивается, и компилятор имеет право удалить недостижимую работу: рекордер не используется, ответы не читаются — тело цикла редуцируется до пустоты, и бенчмарк отчитывается о скорости ничего. Это не особенность Echo, а общая семантика оптимизатора Go: устранение мёртвого кода действует на любой замер. Лекарство — sink: присвоение результата пакетной переменной (или вызов с побочным эффектом, который нельзя выбросить, — b.Fatal по условию). ResetTimer ни при чём: он лишь отсекает подготовку стенда от таймера, без него бенчмарк тоже работает — просто мерит вместе со сборкой. И -benchmem не спасает: он добавляет столбцы отчёта, а не исполняет код. Скучный вывод — и хороший рефлекс: всякая цифра бенчмарка проверяется на осмысленность, прежде чем стать решением.

### Проверьте себя: столбцы бенчмарка

**Вопрос:** go test -bench=. напечатал «BenchmarkList-8 152341 7832 ns/op». Какого столбца тут нет и что он покажет при -benchmem?

- ✅ B/op и allocs/op: байты и число аллокаций на итерацию — память, а не время
- GOMAXPROCS: число ядер, использованных бенчмарком
- Байты на секунду: пропускная способность сети под нагрузкой
- Ничего: -benchmem показывает только прогресс-бар итераций

Столбец -8 — GOMAXPROCS, то уже напечатан, а вот B/op и allocs/op бенчмарк добавляет только по ReportAllocs() внутри функции или флагу -benchmem: 4512 B/op — сколько байт нагенерирует одна итерация, 42 allocs/op — сколько раз вызовется аллокатор. Для серверного кода это часто важнее наносекунд: аллокации — работа сборщика мусора, и пиковая задержка под нагрузкой растёт именно от мусора, даже когда средняя ns/op красивая. Правило чтения: ns/op говорит «сколько», allocs/op — «сколько мусора», и правки, снижающие аллокации при том же времени, обычно выигрывают в хвостах задержек. b.ReportAllocs внутри функции надёжнее флага: столбцы не потеряются, когда кто-то запустит -bench=. без флагов в CI.

### Режим debug и release: HideBanner, HidePort и уровень логов

У Echo нет отдельной «release-сборки», как у компилируемых фреймворков с режимами, — есть поля экземпляра e, которые в проде стоит выключить руками. e.Debug = true — режим разработки: встроенный логер переводится на уровень DEBUG, а дефолтный HTTPErrorHandler добавляет в ответ стектрейс ошибки — на локалке бесценно, в проде это раздача устройства стека наружу, поэтому для прод-сборки Debug остаётся false. Баннер «Echo! Echo! Echo!» с версией печатает e.Start на каждом запуске — e.HideBanner = true его гасит; в контейнере, где stdout — журнал, баннер и анонс порта «⇛ http server started on :1323» превращаются в шум, и e.HidePort = true убирает второй. Ни одно из полей не влияет на скорость обработки запросов — это гигиена старта, а не тюнинг горячего пути.

Уровень логов — уже прод-решение с последствиями для скорости: gommon-логер из [девятого урока](/courses/echo/lessons/09-logirovanie/) печатает записи уровня ниже установленного и молчит выше, поэтому e.Logger.SetLevel(log.INFO) в проде экономит не только место в журнале, но и время форматирования DEBUG-записей горячих путей. Третье прод-поле настроения — SetOutput: девятый урок показывал, что запись в файл отключает цвет; в контейнере правильнее оставить stdout — журналы собирает платформа, это её работа, а не вашего приложения. Наконец, e.DisableHTTP2 нужен редко — по умолчанию HTTP/2 работает поверх TLS и в проде обычно желателен; выключать его стоит осознанно, когда за Echo стоит прокси, который HTTP/2 уже терминировал сам.

### Канон: прод-конфигурация экземпляра

```go
// internal/app/app.go — сборка для проде и для локалки
func New(store service.TaskStore, secret []byte, prod bool) *echo.Echo {
        e := echo.New()
        if prod {
                e.HideBanner = true  // без «Echo! Echo! Echo!» в журнале
                e.HidePort = true    // без анонса ⇛ :1323
                e.Logger.SetLevel(log.INFO) // DEBUG-записи не форматируются
        } else {
                e.Debug = true // стектрейс в ответах ошибок + DEBUG-лог
        }
        e.Use(middleware.RequestID())
        e.Use(middleware.Logger())
        e.Use(middleware.Recover())
        ...
        return e
}

// cmd/server/main.go — prod решает окружение, не код
prod := os.Getenv("APP_ENV") == "production"
e := app.New(repository.NewMemory(), secret, prod)
```

Разветвление по prod — честный способ не держать два дерева сборки. В проде: HideBanner и HidePort убирают стартовый шум из журнала контейнера, SetLevel(log.INFO) отрезает DEBUG-форматирование, и все константы из девятого урока работают как задумано — INFO, WARN, ERROR печатаются, DEBUG молчит. На локалке Debug = true — и дефолтный HTTPErrorHandler из десятого урока прикладывает к 500 стектрейс: ловить nil-указатель без него — работа наугад. Обратите внимание, чего нет: e.Debug не влияет на ошибки, которые вы переводите конвертом сами — стектрейс добавляет только дефолтный обработчик, а ваш convert из двадцать первого урока отвечает за свой текст. Это и правильно: клиентская диагностика — ErrorHandler, серверная — лог, о чём подробно скажет двадцать третий урок. DisableHTTP2 оставлен по умолчанию: TLS-терминация на Echo — редкость, чаще на пути стоит прокси.

### Проверьте себя: e.Debug в проде

**Вопрос:** Канон уехал в прод с e.Debug = true. Что увидит клиент, чей запрос упал с необработанной ошибкой?

- ✅ 500 со стектрейсом в теле ответа: дефолтный HTTPErrorHandler в Debug прикладывает стек
- 500 с generic-конвертом: Debug влияет только на логи
- Стектрейс только в логе, клиенту пусто
- 403: Debug включает аутентификацию для отладочных эндпоинтов

Поле Debug у Echo — режим разработчика, и его главное прод-последствие именно в ответах: DefaultHTTPErrorHandler проверяет e.Debug и добавляет к *echo.HTTPError стектрейс ошибки, который уходит клиенту в теле. То есть это не «больше логов», а утечка внутренней структуры приложения наружу — пути пакетов, строки, иногда значения. Канонская защита — та же, что с ошибками баз данных: клиенту generic-сообщение, детали — серверу. В двадцать первом уроке convert рисует конверт и пишет e.Logger.Error — стектрейс в Debug-проде всё равно уйдёт в тело там, где конверт не покрывает (например, до коммита ответа). Вариант «только в логе» описывает поведение Recover-а, а не Debug-а. Урок: Debug — флаг окружения, не тюнинг, и его место — рядом с HideBanner в ветке !prod, а не в горячем пути.

### Пулы: контексты Echo, gzip-райтеры и sync.Pool

Echo экономит память не в проде «по волшебству», а пулами, и из двадцатого урока вы уже знаете главный: на каждый запрос ServeHTTP делает c := e.pool.Get().(*context), привязывает контекст к паре запрос-ответ через Reset, а в конце возвращает его — e.pool.Put(c). Тысяча запросов — тысячи переиспользований одной горстки структур: аллокация контекста происходит один раз за жизнь сервера, а не на каждый запрос. Тот же приём внутри gzip-компрессии: gzip.go v4.15.0 держит sync.Pool gzip-райтеров — дорогих объектов с внутренними буферами и таблицами Хаффмана, — берёт свободный на запрос и возвращает после ответа, не аллокируя новый на каждый сжатый ответ.

sync.Pool — стандартная библиотека, и у неё два правила, которые ломают наивное использование. Первое: «Any item stored in the Pool may be removed automatically at any time without notification» — любой объект из пула может быть выброшен сборщиком мусора в любой момент без уведомления; пул — кэш для снижения аллокаций, а не хранилище, и класть туда уникальное состояние нельзя. Второе: объект из Get может быть грязным — возвращая в пул, его чистят (gzip-райтер сбрасывается Close-ом и Reset-ом), а доставая — не полагаются на нулевые значения полей. Для вашего кода правило простое: sync.Pool оправдан для дорогих и часто создаваемых объектов — буферов, энкодеров, — и бессмыслен для дешёвых: пул сам стоит синхронизации, и «оптимизировать» аллокацию 32 байт на запрос им — значит добавить гонки на ноль выигрыша.

### Аллокации в обработчиках: где они рождаются

Аллокации канонного обработчика посчитать несложно, если знать, где искать. Каждый вызов c.JSON(code, v) сериализует структуру в новый слайс байт — json.Marshal из сериализатора Echo создаёт буфер, ескейпит HTML-символы и пишет ответ; на маленьких ответах это десятки байт, на больших списках из семнадцатого урока — килобайты на каждый запрос. c.String(code, s) внутри оборачивает строку в []byte — аллокация копии на каждый ответ. fmt.Sprintf в хендлере — отдельные аллокации на форматирование, которые на горячем пути видны в allocs/op как ровные столбики. c.Param и c.QueryParam аллокаций почти не делают — параметры живут в контексте из пула, но any-обёртки в карте store контекста (c.Set/c.Get из восьмого урока) — уже боксинг значения в интерфейс.

Что делать, а что — нет. Снижать размер пагинированных списков: limit из клампов семнадцатого урока — не только контракт API, но и лимит сериализации; страница на десять тысяч элементов убивает задержку сильнее любого «медленного» кода. Менять сериализатор, когда профиль показывает json.Marshal горячей точкой: e.JSONSerializer — интерфейс, и вместо дефолта можно поставить свой энкодер, переиспользующий буферы, — но только после профиля, потому что дефолт быстрый. e.SetEscapeHTML(false) отключает HTML-эскейпинг в JSON — чуть меньше работы энкодеру и чуть короче ответ, легально для API без HTML-встраивания. А вот микротрюки вроде strconv.AppendInt вместо strconv.Itoa в хендлере списка — преждевременны: единицы наносекунд против микросекунд маршрутизации, bind и сериализации. Профиль решает, что из этого ваша горячая точка.

### Канон: горячая точка на прицеле профиля

```go
// было: fmt.Sprintf на каждый элемент списка
for _, t := range list {
        rows = append(rows, fmt.Sprintf("%d: %s", t.ID, t.Title))
}

// стало: один буфер на запрос, AppendInt без промежуточных строк
buf := bufPool.Get().(*bytes.Buffer)
defer bufPool.Put(buf)
buf.Reset()
for _, t := range list {
        buf.WriteString(strconv.Itoa(t.ID))
        buf.WriteString(": ")
        buf.WriteString(t.Title)
        buf.WriteByte('\n')
}

// bufPool объявлен один раз на пакет:
var bufPool = sync.Pool{
        New: func() any { return new(bytes.Buffer) },
}
```

Пример показывает форму правки после профиля, а не рецепт на все случаи. fmt.Sprintf форматирует в новый []byte на каждом элементе: N элементов — N аллокаций строки плюс аллокация среза rows; столбик allocs/op растёт пропорционально размеру страницы. Замена — буфер из sync.Pool: Get переиспользует ёмкость между запросами (правило из раздела про пулы — Reset обязательно, грязный буфер хуже чистой аллокации), WriteString и AppendInt пишут без промежуточных строк. defer bufPool.Put возвращает буфер даже при панике. Заметьте масштаб: такая правка имеет смысл, когда лист — действительно горячий путь, что доказывает бенчмарк до и после; на эндпоинте /api/v1/tasks/42, который зовут раз в секунду, она — усложнение без выигрыша. И то же правило работает в обратную сторону: если профиль показал, что горячая точка — сериализация JSON, буферы строк не спасут, а смена сериализатора — да.

### Прогноз: gzip забыл про SSE

```go
e.Use(middleware.Gzip())
// ... и ничего больше; в каноне есть
v1.GET("/events", sseHandler) // text/event-stream

// клиент подключился EventSource-ом. Что он увидит?
```

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

- ✅ Молчание: gzip буферизует события, и строки уезжают пачками или не уезжают, пока буфер не наполнится
- Все события сразу: gzip сжимает каждый поток без задержек
- 404: Gzip запрещает text/event-stream
- События по одному: c.Stream сама отключает gzip

Компрессия и стриминг — антиподы: gzip-райтер держит внутренний буфер и выдаёт байты блоками, когда блок наполнен или соединение закрывается; для обычного JSON-ответа это идеально, для text/event-stream из девятнадцатого урока — смерть протокола, чей смысл в доставке события сразу. c.Stream не «отключает» gzip: она пишет в обёрнутый райтер и делает Flush, но Flush компрессора выдаёт только накопленный блок, а не пустоту — события копятся. Лекарство — Skipper: middleware.GzipWithConfig(middleware.GzipConfig{Skipper: func(c echo.Context) bool { return c.Path() == "/api/v1/events" }}) — и стриминговые пути обходят компрессию, как обходили её в девятнадцатом уроке, где compress.go упоминался среди источников. Сюда же — ws-маршрут из восемнадцатого: апгрейд соединения и gzip несовместимы по смыслу, и Skipper должен беречь оба пути.

### Компрессия: уровень, Skipper и цена CPU

middleware.Gzip из седьмого урока стоил канону одной строки, и тюнинг начинается с уровня сжатия. Уровни — константы compress/gzip: gzip.BestSpeed равен 1, gzip.BestCompression — 9, gzip.DefaultCompression — -1. Чем выше уровень, тем меньше байт в ответе и тем больше процессорного времени на сжатие каждого ответа; на живом API почти всегда выбирают 1–5: разница в размере между 5 и 9 — проценты, разница в CPU — краты. У конфига есть каверза, снятая прямо из gzip.go v4.15.0: ноль в поле Level считается «не задано» и заменяется на gzip.DefaultCompression — то есть GzipConfig{} без Level и GzipWithConfig(middleware.GzipConfig{}) дают дефолтное сжатие, а не «без сжатия»; константа gzip.NoCompression числом 0 через конфиг не передаётся.

Экономика компрессии считается на трёх числах: размер ответа, трафик и CPU. Ответы меньше пары сотен байт сжимаются плохо и могут даже вырасти — заголовки сжатия и метаданные не бесплатны; крупные JSON-списки сжимаются в разы. CPU-цена видна в бенчмарке: включите Gzip в стеке app.New и сравните ns/op — разница будет кратной, и это нормально: компрессор платит процессором за трафик. Skipper из предыдущего predict-а — не только для SSE: health-пробы из следующего урока и мелкие ответы тоже часто выводят из-под gzip. И помните из девятнадцатого: за обратным прокси компрессию иногда делают на границе — тогда Echo вообще не сжимает, и middleware.Gzip выключают за ненадобностью.

### Проверьте себя: уровень gzip

**Вопрос:** Вы написали middleware.GzipWithConfig(middleware.GzipConfig{}) — без поля Level. Какой уровень сжатия получит middleware?

- ✅ gzip.DefaultCompression: ноль в Level трактуется как «не задано» и заменяется дефолтом
- gzip.NoCompression: ноль и есть ноль
- gzip.BestSpeed: пустой конфиг даёт самый быстрый уровень
- Панику: Level обязателен

По gzip.go v4.15.0 middleware проверяет config.Level == 0 и подставляет дефолт — это дизайн «нулевой уровень значит не сконфигурирован», а не компрессия: числовая константа gzip.NoCompression совпадает с нулём, и выбрать её через GzipConfig нельзя, что стоит помнить, когда захочется «сжатие без сжатия» ради хеш-заголовков. Дефолт DefaultCompression равен -1 и даёт баланс размера и CPU. Для живого API каноннее явный Level: middleware.GzipConfig{Level: 5, Skipper: ...} — цифра читается без обращения к исходникам, и поведение не зависит от того, что в будущей версии сочли дефолтом. Паники нет: конфиг gzip прощает пустоту, обязательных полей у него нет вообще — в отличие от конфига echo-jwt из пятнадцатого урока, где ключ без SigningKey честно паниковал.

### Таймауты и лимиты http.Server: защита от зависания

e.Start(":1323") из второго урока создаёт http.Server без единого таймаута — соединение может читать запрос хоть сутки, и никакой тюнинг Echo это не изменит, потому что таймауты живут в http.Server, а не во фреймворке. Для проде это уязвимость, а не нейтральный дефолт: медленный клиент, который по байту в минуту шлёт заголовки (Slowloris), держит соединение и горутину бесконечно; сотня таких клиентов — и сервер исчерпал лимиты. Четыре ручки закрывают сценарий: ReadHeaderTimeout — время на чтение заголовков, главный щит против Slowloris; ReadTimeout — на чтение всего запроса с телом; WriteTimeout — на запись ответа, от первого байта; IdleTimeout — сколько держать keep-alive-соединение между запросами. HTTP/2-потоки ReadTimeout не ограничивает — для них важны IdleTimeout и WriteTimeout, ещё один аргумент измерять, а не копировать конфиги.

Практические ориентиры канона: ReadHeaderTimeout 5 секунд — generous для живых клиентов и беспощаден к сканерам; WriteTimeout для SSE-эндпоинта из девятнадцатого урока — отдельная история, бесконечный стрим не вписывается в общий WriteTimeout, и для стриминговых путей либо пишут через HandlerWrapper... точнее, выносят SSE на отдельный сервер с WriteTimeout 0, либо считают WriteTimeout с запасом и держат heartbeat. MaxHeaderBytes (дефолт — 1 МиБ) режет гигантские заголовки; SetKeepAlivesEnabled(false) — аварийная кнопка на время обслуживания, она закрывает keep-alive для новых запросов без остановки процесса. Всё это — e.StartServer(&http.Server{...}): один вызов, и сервер перестаёт быть беззащитным.

### Канон: StartServer вместо Start

```go
// cmd/server/main.go — запуск с таймаутами
func main() {
        e := app.New(repository.NewMemory(), secret, prod())
        srv := &http.Server{
                Addr:              ":1323",
                ReadHeaderTimeout: 5 * time.Second,
                ReadTimeout:       10 * time.Second,
                WriteTimeout:      30 * time.Second,
                IdleTimeout:       120 * time.Second,
                Handler:           e, // Echo — это http.Handler
        }
        e.Logger.Fatal(e.StartServer(srv))
}
```

Таймауты подбираются под трафик, а не копируются, и вот почему каждое число канона такое. ReadHeaderTimeout 5s: браузеры шлют заголовки мгновенно, сканеры и умирающие соединения — нет; это ровно щит Slowloris, о котором говорит документация net/http. ReadTimeout 10s покрывает тело с запасом — лимиты из седьмого урока (BodyLimit) всё равно отрежут лишнее раньше. WriteTimeout 30s — компромисс: обычные JSON-ответы уходят за миллисекунды, а SSE в этот сервер не вписывается — канон выносит /api/v1/events на отдельный srv с WriteTimeout 0, когда стрим становится серьёзным. IdleTimeout 120s держит keep-alive-соединения живыми для повторных запросов SPA и закрывает их, чтобы файловые дескрипторы не копились. Handler: e — потому что Echo реализует http.Handler через ServeHTTP из двадцатого урока; тот же srv можно передать в тесты и в graceful shutdown из следующего урока.

### Соберите цикл тюнинга по шагам

Расставьте шаги рабочего цикла оптимизации: от подозрения до оставленной правки.

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

1. `Замерить базовую линию: go test -bench=. -benchmem на актуальном коде`
2. `Зафиксировать результат: ns/op, B/op, allocs/op — числа, а не ощущения`
3. `Собрать профиль CPU: 30 секунд под нагрузкой через /debug/pprof/profile`
4. `Найти горячую точку в pprof top: где реально уходит время`
5. `Проверить профиль памяти: heap и allocs для мусора`
6. `Исправить ОДНО место: буфер, алгоритм, кэш — не всё сразу`
7. `Повторить бенчмарк: сравнить с базовой линией на той же машине`
8. `Оставить правку, если изменилось число; откатить, если нет`
9. `Прогнать тесты слоёв: оптимизация не должна менять поведение`
10. `Зафиксировать новую базовую линию для следующего цикла`

### Профилирование: pprof на админ-порте

Бенчмарк говорит «медленно», профиль говорит «где». Инструмент — pprof из стандартной библиотеки: импорт _ "net/http/pprof" регистрирует /debug/pprof/* на http.DefaultServeMux, go tool pprof забирает профили по HTTP и рисует top, дерево вызовов и flame graph. Для echo-приложения два пути. Первый — echo-contrib держит пакет pprof: pprof.Register(e) вешает /debug/pprof/* прямо на приложение — удобно на локалке, опасно в проде: профиль — инструмент разгрузки кучи и стека, и наружу его давать нельзя. Второй путь — канонный для проде: отдельный admin-сервер на внутреннем порту с тем же DefaultServeMux — go func() { log.Fatal(http.ListenAndServe("127.0.0.1:6060", nil)) }() рядом с основным запуском; в этом mux-е и живут эндпоинты pprof, а достать их может только тот, кто внутри сети.

Сценарий работы канонный: под нагрузкой собрать профиль процессора — go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30 — и посмотреть top: время в runtime.mallocgc говорит про аллокации, в syscall — про диск и сеть, в вашем json.Marshal — про сериализацию. Профиль кучи — /debug/pprof/heap — покажет, кто держит память; /debug/pprof/goroutine — утечки горутин, классическая находка для SSE-хабов из девятнадцатого урока, где забытый подписчик копится в канале. И главный этикет: профиль собирается под той нагрузкой, что интересует, — профиль простаивающего сервера покажет пустоту. В проде это означает: сначала снять трафик или погонять нагрузочный стенд, потом открывать pprof.

### Канон: админ-сервер с pprof

```go
// cmd/server/main.go — pprof на отдельном внутреннем порту
import (
        _ "net/http/pprof" // регистрирует /debug/pprof/* на DefaultServeMux
        "net/http"
)

func main() {
        // админ-порт: только изнутри сети, без Echo
        go func() {
                e.Logger.Error(http.ListenAndServe("127.0.0.1:6060", nil))
        }()

        e := app.New(repository.NewMemory(), secret, prod())
        srv := &http.Server{Addr: ":1323", ReadHeaderTimeout: 5 * time.Second, Handler: e}
        e.Logger.Fatal(e.StartServer(srv))
}

// сбор профиля под нагрузкой:
//   go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30
//   go tool pprof http://127.0.0.1:6060/debug/pprof/heap
```

Три решения этой сборки. Пустой импорт net/http/pprof ничего не зовёт — он только добавляет обработчики на http.DefaultServeMux, поэтому Echo не тронут: приложение отвечает на :1323, а профили живёт на 127.0.0.1:6060 — наружу закрыто адресом. Отдельная горутина с ListenAndServe(nil-mux) — канонный способ не смешивать admin-эндпоинты с бизнес-маршрутами: pprof не может попасть в матрицы curl-тестов и не регистрируется в роутере. Вызов go tool pprof по URL с seconds=30 держит соединение тридцать секунд и отдаёт срез CPU под текущей нагрузкой — поэтому нагрузка должна быть в этот момент. Альтернатива для локалки — pprof.Register(e) из echo-contrib, тот пакет, что упоминался в первом уроке рядом с сессиями и Prometheus: удобно, но только на dev-машине. Профили heap и goroutine читаются мгновенно и не требуют нагрузки — их можно снимать по факту инцидента.

### Проверьте себя: чтение профиля

**Вопрос:** go tool pprof top на профиле CPU под нагрузкой показывает 38% в runtime.mallocgc и ещё 12% в runtime.memmove. Куда смотреть?

- ✅ В аллокации: сервер тратит треть процессора на выделение памяти — искать B/op в бенчмарке и мусорить в горячем пути
- В сеть: mallocgc всегда про сетевые задержки
- В горутины: mallocgc — признак утечки горутин
- Никуда: mallocgc ниже 50% — это норма, профиль пуст

runtime.mallocgc — аллокатор: каждая треть процессора, ушедшая в него, означает горы мусора, которые GC вынужден собирать; в паре с memmove — копированием памяти — это классическая картина «создаём буферы и строки на каждый запрос». План правки описан в разделе аллокаций: бенчмарк с -benchmem покажет B/op и allocs/op, профиль heap назовёт виновника, а буферы из sync.Pool, переиспользуемые энкодеры и меньшие страницы пагинации снизят обе цифры. Профиль goroutine — отдельный инструмент для утечек горутин: он показывает, кто и где завис, и к mallocgc отношения не имеет. «Ниже 50% — норма» — опасная установка: 38% в аллокаторе при свободном CPU означает, что половина работы сервера — обслуживание мусора, и на пиках задержки растут именно здесь.

> **Оптимизация echo по порядку.** Порядок, который почти всегда приносит выигрыш: 1) таймауты http.Server — защита, а не скорость, но без неё остальное зря; 2) размер ответов — пагинация из семнадцатого урока и gzip с уровнем 5; 3) профиль CPU — потому что две трети «медленного кода» — сериализация и аллокации, и обе видны сразу; 4) аллокации горячего пути по профилю; 5) пулы — в последнюю очередь и только для доказанно дорогих объектов. Наоборот — типичная ошибка: sync.Pool на всё и тюнинг наносекунд при непомерной странице списка.

### Connection pooling наружу: http.Transport клиента

Тюнинг echo не заканчивается на границе сервера: как только канон начинает звать внешние API из service-слоя — вебхуки, платёжные шлюзы, — появляется исходящий трафик и его собственный пул. У http.DefaultTransport, которым пользуется http.Get, есть дефолты, о которых стоит знать: MaxIdleConns — 100 свободных соединений на всех хостов, MaxIdleConnsPerHost — всего 2 на один хост, IdleConnTimeout — 90 секунд. Дефолт «2 на хост» ломает нагрузочный тест на внешний API: под сотнями параллельных запросов свободные соединения кончаются, и клиент открывает новое TCP+TLS на каждый запрос — рукопожатия съедают миллисекунды, которых у вас не было.

Лекарство — свой Transport в конструкторе клиента из [урока про HTTP-клиент](/courses/go-advanced/lessons/22-http-klient/): &http.Transport{MaxIdleConns: 100, MaxIdleConnsPerHost: 100, IdleConnTimeout: 90 * time.Second} и http.Client{Transport: t, Timeout: 10 * time.Second} — и соединения к одному хосту живут пулом, переиспользуясь запрос за запросом. Серверная сторона того же сюжета — keep-alive, которым управляет SetKeepAlivesEnabled, и IdleTimeout из раздела таймаутов: соединение, закрытое сервером из-за IdleTimeout, клиент просто переоткроет. Связь с Echo прямая: ваши service-зависимости, собранные конструкторами в двадцать первом уроке, — то самое место, где клиент с пулом инъектируется в приложение, и тест подменяет его fake-транспортом так же, как fake-репозиторий.

### Карточки: тюнинг и производительность

- **Debug, HideBanner, HidePort** — e.Debug = true — DEBUG-логи и стектрейс в теле 500 от дефолтного ErrorHandler; в проде false. e.HideBanner — без ASCII-баннера, e.HidePort — без анонса ⇛ порта; на скорость запросов не влияют. e.Logger.SetLevel(log.INFO) — DEBUG-записи не форматируются вовсе. e.DisableHTTP2 — редко: HTTP/2 по TLS обычно желателен.
- **Пулы: Echo и sync.Pool** — Контексты: e.pool.Get().(*context) → c.Reset(r, w) → ... → e.pool.Put(c) — аллокация один раз на жизнь сервера. sync.Pool: «Any item stored in the Pool may be removed automatically at any time» — GC чистит в любой момент; объект из Get может быть грязным; возвращаем чистым (Reset). Оправдан для дорогих буферов/энкодеров, не для 32 байт.
- **Бенчмарк: столбцы и стакан** — ns/op — время, B/op — байты аллокаций, allocs/op — вызовы аллокатора; столбцы аллокаций — по b.ReportAllocs() или -benchmem. sink/стакан — присвоение результата пакетной переменной, иначе компилятор выбросит недостижимую работу и ns/op ≈ 0. b.ResetTimer отсекает сборку стенда; -race и Debug искажают замер — гонять отдельно.
- **Gzip: уровни и Skipper** — Уровни compress/gzip: BestSpeed 1, BestCompression 9, DefaultCompression -1; для API — 1-5. Каверза gzip.go v4.15.0: Level == 0 — «не задано», заменяется на DefaultCompression; gzip.NoCompression через GzipConfig не выбирается. Skipper обязателен для SSE и ws: буфер gzip задерживает события стрима.
- **Таймауты http.Server** — e.Start создаёт Server без таймаутов — Slowloris держит соединения вечно. ReadHeaderTimeout — щит против медленных заголовков (5s); ReadTimeout — тело; WriteTimeout — ответ (SSE выносят на отдельный srv с 0); IdleTimeout — keep-alive между запросами (120s). Запуск: e.StartServer(&http.Server{...}); MaxHeaderBytes режет гигантские заголовки.
- **pprof и Transport** — pprof: пустой импорт net/http/pprof → DefaultServeMux; прод — отдельный админ-порт 127.0.0.1:6060; profile?seconds=30 под нагрузкой, heap, goroutine (утечки SSE-хабов). mallocgc в top — мусорить горячий путь. Клиент: http.Transport MaxIdleConnsPerHost (дефолт 2!) поднять до сотни, IdleConnTimeout 90s, Client.Timeout — и пул живёт.

### Что дальше: числа, которые видит прод

Канон ускорился измеримо. Приборы: бенчмарк e.ServeHTTP со стаканом и ReportAllocs — ns/op, B/op, allocs/op; профиль pprof на админ-порте 6060 — top, heap, goroutine. Прод-гигиена: HideBanner, HidePort, SetLevel(INFO), Debug=false; HTTP-сервер с ReadHeaderTimeout 5s и IdleTimeout 120s через StartServer. Компрессия: gzip уровня 5 с Skipper-ом для /api/v1/events — стрим из девятнадцатого урока жив, а списки сжаты в разы. Аллокации: буферы из sync.Pool для доказанно горячих путей, JSONSerializer и SetEscapeHTML(false) — когда профиль назовёт сериализацию. Пул исходящих соединений: Transport с MaxIdleConnsPerHost 100 в конструкторе клиента — руки из service-слоя, тест — fake-транспорт.

Про песочницу честно, как во всём курсе: у курса Echo её нет — код исполняется локально, поэтому runner-ов здесь не будет; соберите бенчмарк из канона, гоните go test -bench=. -benchmem до и после правки и держите две цифры рядом.

Следующий, двадцать третий урок курса echo — Мониторинг и health-check: Prometheus-метрики, liveness и readiness, логирование в проде. Замеры, которые сегодня делались руками, станут счётчиками, которые собирает Prometheus, — а /healthz и /readyz скажут платформе, жив ли процесс и готов ли он принимать трафик. Навигация по курсу — на [странице курса Echo](/courses/echo/); gzip из каталога встроенных слоёв — в [уроке про встроенные middleware](/courses/echo/lessons/07-vstroennye-middleware/); e.ServeHTTP как цель бенчмарка — в [уроке про тестирование](/courses/echo/lessons/20-testirovanie/); SSE, чей Skipper спасает компрессию, — в [уроке про Server-Sent Events](/courses/echo/lessons/19-sse/); конструктор app.New, куда встанут таймауты и админ-порт, — в [уроке про структуру проекта](/courses/echo/lessons/21-struktura/); pprof из trio echo-contrib — в [уроке про знакомство](/courses/echo/lessons/01-znakomstvo/).

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

- Цикл тюнинга: базовая линия → профиль → одна правка → повторный замер → оставить/откатить. Приборы: BenchmarkXxx с e.ServeHTTP(rec, req) без сети; ns/op, B/op, allocs/op по ReportAllocs или -benchmem; sink/стакан против выброса недостижимой работы; -race и Debug искажают замер.
- Прод-гигиена экземпляра: e.Debug=false в проде (в Debug дефолтный HTTPErrorHandler кладёт стектрейс в тело 500), e.HideBanner и e.HidePort — стартовый шум из журнала контейнера, e.Logger.SetLevel(log.INFO) — DEBUG не форматируется. e.DisableHTTP2 — редкость: HTTP/2 поверх TLS обычно желателен.
- Пулы: контексты Echo — e.pool.Get/Put с Reset на каждый запрос; gzip-райтеры — sync.Pool в gzip.go v4.15.0. sync.Pool: объект может быть выброшен GC в любой момент без уведомления; из Get приходит грязный; возвращать чистым. Оправдан для дорогих объектов, не для дешёвых.
- Аллокации: c.JSON сериализует каждый раз, c.String копирует строку в []byte, fmt.Sprintf — аллокации на элемент; c.Param дешёв. Правки по профилю: меньше страница пагинации, свой JSONSerializer, e.SetEscapeHTML(false), буферы sync.Pool с Reset для доказанно горячих путей.
- Gzip: уровни gzip.BestSpeed 1 / BestCompression 9 / DefaultCompression -1, для API 1–5; каверза gzip.go v4.15.0 — Level == 0 трактуется как «не задано» и заменяется на DefaultCompression; Skipper обязателен для SSE /api/v1/events и ws — буфер gzip задерживает события стрима.
- Таймауты: e.Start создаёт http.Server без таймаутов; e.StartServer(&http.Server{ReadHeaderTimeout: 5s, ReadTimeout: 10s, WriteTimeout: 30s, IdleTimeout: 120s, Handler: e}); SSE-стрим — отдельный srv с WriteTimeout 0; MaxHeaderBytes, SetKeepAlivesEnabled(false) — аварийная кнопка keep-alive.
- Профилирование и исходящий пул: pprof на админ-порте 127.0.0.1:6060 (пустой импорт net/http/pprof на DefaultServeMux; echo-contrib/pprof — только dev); profile?seconds=30 под нагрузкой; mallocgc в top — аллокации; heap — кто держит память; goroutine — утечки SSE-хаба. Клиент наружу: Transport MaxIdleConnsPerHost (дефолт 2) → 100, IdleConnTimeout 90s, Client.Timeout — инъекция в service-слой.

## Домашнее задание

Проведите полный цикл тюнинга над каноном и задокументируйте числа. Первое: напишите BenchmarkListTasks из канона рядом с тестами, прогоните go test -bench=. -benchmem и зафиксируйте ns/op, B/op, allocs/op; затем уберите sink и повторите — объясните разницу в отчёте. Второе: соберите прод-конфигурацию New(..., prod=true) с HideBanner, HidePort и SetLevel(INFO); запустите и убедитесь, что журнал начинается сразу с логов запросов, а на e.Debug=false 500 от необработанной ошибки не содержит стектрейса. Третье: включите middleware.GzipWithConfig с Level 5 и сравните бенчмарк с базовой линией; затем добавьте Skipper для /api/v1/events и проверьте curl -N, что события приходят немедленно — объясните по compress/gzip, что меняет Skipper. Четвёртое: переведите запуск на e.StartServer с ReadHeaderTimeout 5s, ReadTimeout 10s, WriteTimeout 30s, IdleTimeout 120s; напишите тест-скрипт с curl --limit-rate 1 и убедитесь, что сервер рвёт медленные заголовки по таймауту. Пятое: поднимите админ-порт 127.0.0.1:6060 с пустым импортом net/http/pprof, снимите profile?seconds=30 под нагрузкой бенчмарка, найдите top-3 и объясните, что делал процесс; снимите heap и goroutine на живом SSE-хабе с двумя подписчиками. Шестое: найдите в коде канона fmt.Sprintf на горячем пути и перепишите на буфер из sync.Pool с Reset; докажите бенчмарком, что allocs/op упал, и прогоните go test ./... — поведение не изменилось. Седьмое: соберите http.Client с Transport{MaxIdleConnsPerHost: 100} и объясните по документации net/http, что меняет этот параметр против дефолтного 2.

## Вопросы и ответы по уроку

### С чего начать оптимизацию echo-приложения, если всё «тормозит»?

С измерения, а не с правок. Первым делом — таймауты http.Server через e.StartServer: без них зависшие соединения съедают ресурсы, и никакая скорость обработки не спасёт. Вторым — бенчмарк горячего пути через e.ServeHTTP с -benchmem: базовая линия в цифрах. Третьим — профиль CPU на админ-порте под реальной нагрузкой: обычно верх занимают сериализация JSON и аллокации, и обе лечатся размером пагинации, уровнем gzip 5 и буферами. Что не помогает — тюнинг наугад: sync.Pool на каждый хендлер и наносекундные трюки без профиля чаще добавляют сложность, чем скорость. И правило честности: каждая правка подтверждается повторным замером на той же машине, иначе это не оптимизация, а суеверие.

### Почему бенчмарк показывает 0 ns/op — код же честный?

Это оптимизатор: если результат итерации бенчмарка нигде не используется, компилятор вправе выкинуть недостижимую работу, и цикл из e.ServeHTTP(rec, req) без чтения rec.Body редуцируется до пустоты. Лекарство — «стакан»: присвойте результат пакетной переменной (var sink *bytes.Buffer; sink = rec.Body) и добавьте финальную проверку if sink == nil { b.Fatal(...) } — присвоение с побочным чтением выбросить нельзя. Похожие искажения дают -race (замедляет в разы — гоняйте отдельно от замеров) и debug-конфигурация с DEBUG-логами: печатать в терминал на каждой итерации — значит мерить терминал. Столбцы B/op и allocs/op тоже помогают опознать выброс: у «мёртвого» бенчмарка они нулевые или подозрительно круглые.

### Где в Echo включить режим production — есть специальный вызов?

Отдельного вызова нет: Echo не имеет «режимов сборки», и прод-настройка — это набор полей экземпляра, которые выставляет ваш конструктор из двадцать первого урока. Канонический набор: e.HideBanner = true и e.HidePort = true — убрать стартовый шум из журнала контейнера; e.Debug = false — не отдавать стектрейс в ответах дефолтного ErrorHandler; e.Logger.SetLevel(log.INFO) — не форматировать DEBUG-записи; и главное — запуск через e.StartServer с ReadHeaderTimeout, WriteTimeout и IdleTimeout, потому что e.Start создаёт http.Server без единого таймаута. Всё это решает ветка по окружению в app.New, а не фреймворк: то же приложение на локалке собирается с e.Debug = true и полными логами — код один, конфигурация — параметр конструктора.
