Содержание урока · 12 разделов
- производительность echo: сначала измерение, потом правка
- Режим debug и release: HideBanner, HidePort и уровень логов
- Пулы: контексты Echo, gzip-райтеры и sync.Pool
- Аллокации в обработчиках: где они рождаются
- Компрессия: уровень, Skipper и цена CPU
- Таймауты и лимиты http.Server: защита от зависания
- Профилирование: pprof на админ-порте
- Connection pooling наружу: http.Transport клиента
- Что дальше: числа, которые видит прод
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
производительность 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 отсекает сборку стенда от замера. Числа в комментарии — порядок величин учебного канона, не эталон: на вашей машине они будут другими, важна динамика до и после правки.
Прогноз: бенчмарк без стакана
Прочитайте код и выберите, что он выведет:
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=.?Проверьте себя: столбцы бенчмарка
go test -bench=. напечатал «BenchmarkList-8 152341 7832 ns/op». Какого столбца тут нет и что он покажет при -benchmem?
Режим 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-логер из девятого урока печатает записи уровня ниже установленного и молчит выше, поэтому 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. Что увидит клиент, чей запрос упал с необработанной ошибкой?
Пулы: контексты 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
Прочитайте код и выберите, что он выведет:
e.Use(middleware.Gzip())
// ... и ничего больше; в каноне есть
v1.GET("/events", sseHandler) // text/event-stream
// клиент подключился EventSource-ом. Что он увидит?Компрессия: уровень, 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?
Таймауты и лимиты 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 из следующего урока.
Соберите цикл тюнинга по шагам
Расставьте шаги рабочего цикла оптимизации: от подозрения до оставленной правки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Профилирование: 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. Куда смотреть?
Порядок, который почти всегда приносит выигрыш: 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-клиент: &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-репозиторий.
Карточки: тюнинг и производительность
Что дальше: числа, которые видит прод#
Канон ускорился измеримо. Приборы: бенчмарк 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; gzip из каталога встроенных слоёв — в уроке про встроенные middleware; e.ServeHTTP как цель бенчмарка — в уроке про тестирование; SSE, чей Skipper спасает компрессию, — в уроке про Server-Sent Events; конструктор app.New, куда встанут таймауты и админ-порт, — в уроке про структуру проекта; pprof из trio echo-contrib — в уроке про знакомство.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Цикл тюнинга: базовая линия → профиль → одна правка → повторный замер → оставить/откатить. Приборы: 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 и полными логами — код один, конфигурация — параметр конструктора.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
GORM: ORM-библиотека для Go
Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.
- Подключать БД и настраивать пул соединений
- Описывать модели и связи: 1:1, 1:N, M:N
- Писать CRUD и сложные выборки chainable-стилем
- Контролировать N+1 через Preload