HTTP-сервер под конкурентной нагрузкой
Перед началом: модули 1–9 (горутины, контекст, лимиты, таймауты). Инструменты: Go 1.22+,
net/http,httptest,heyилиwrkдля нагрузки. Трудоёмкость: ~4 ч (теория 1.5 ч · практика 2 ч · квиз 0.5 ч).
🔥 Инцидент: медленные клиенты, бесконечные горутины и зелёный health-check
Мобильный фронт ходит в API через балансировщик. У части клиентов
слабый канал: соединение открыто, заголовки приходят по байту,
тело запроса льётся минутами. В http.Server не было ни одного
таймаута — значения по умолчанию нулевые, то есть «ждать вечно».
Горутины обработчиков копились, память росла, пул БД был занят
ожидающими запросами.
Балансировщик всё это время видел 200 OK от /health: процесс
жив, значит, инстанс здоров. Он продолжал слать трафик, пока
сервис не упал по OOM. Health-check проверял существование
процесса, а не его способность работать.
📖 Теория
10.1 Горутина на запрос: что она держит
На каждое соединение net/http выделяет горутину; в HTTP/2 —
на каждый поток, то есть фактически на запрос. Пока хендлер не
вернулся, горутина держит:
- стек и состояние хендлера — плюс всё, что вы положили в замыкания и локальные переменные;
*http.Requestс заголовками, телом иr.Context();- буферы соединения и само соединение (keep-alive);
- ожидание внешних ресурсов — БД, HTTP-клиента, мьютекса.
r.Context() отменяется, когда клиент отключился или хендлер
вернулся. Это главный сигнал «работа больше не нужна», и его нужно
прокидывать вниз. Тело запроса сервер закрывает сам, но если вы
читаете его частично, соединение может не переиспользоваться;
клиентские resp.Body закрывать обязаны вы.
Диагностика: runtime.NumGoroutine(), pprof/goroutine,
http.Server.ConnState для переходов соединений. Рост горутин без
роста RPS — первый признак утечки или медленных клиентов.
10.2 Таймауты сервера и TimeoutHandler
У http.Server четыре независимых таймаута, и все по умолчанию
нулевые:
gosrv := &http.Server{ Addr: ":8080", ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 20 * time.Second, IdleTimeout: 60 * time.Second, }
ReadHeaderTimeout— сколько ждать заголовки. Главная защита от Slowloris: медленный клиент не занимает горутину вечно.ReadTimeout— от начала чтения до полного тела запроса; для медленных загрузок может быть слишком строгим.WriteTimeout— сколько даётся на запись ответа (для HTTP/1 — от конца чтения заголовков); долгий стриминг в него не влезает без продления дедлайна.IdleTimeout— сколько живёт простаивающее keep-alive соединение.
Поверх этого — http.TimeoutHandler(h, d, msg): запускает хендлер
и, если тот не уложился в d, отдаёт 503 с текстом msg.
Важно: горутину хендлера он не убивает — та продолжает работу,
а её записи после таймаута отбрасываются с ErrHandlerTimeout.
Это защита клиента и соединения, а не отмена работы: r.Context()
получает дедлайн, но хендлер должен сам его проверять.
TimeoutHandler также буферизует ответ и плохо совместим со стримингом.
10.3 Лимит тела и MaxHeaderBytes
Размер запроса — вектор отказа не хуже количества запросов.
- Тело:
http.MaxBytesReader(w, r.Body, 1<<20)ограничивает чтение и возвращает ошибку при превышении; отвечайте413.io.LimitReaderтоже режет, но не даёт понять клиенту, что лимит превышен. - Заголовки:
MaxHeaderBytesпо умолчанию около 1 МБ — для API это много; разумные 16–64 КБ снижают давление на память. - JSON:
json.Decoderповерх ограниченного тела, а неio.ReadAllвсего запроса; проверяйтеdec.More()и хвост. - Мусор в теле: после отказа дочитывать гигабайты не нужно; соединение можно закрыть.
Лимит тела — часть контракта API: клиент должен получать
предсказуемый 413, а не таймаут или OOM.
10.4 Ограничение in-flight запросов: семафор, 503 vs очередь
Число одновременных хендлеров нужно ограничивать явно, иначе единственный способ узнать предел — падение. Семафор вокруг хендлера:
goselect { case sem <- struct{}{}: defer func() { <-sem }() default: http.Error(w, "busy", http.StatusServiceUnavailable) return }
Два режима:
- неблокирующий (
default) — быстрый503иRetry-After: load shedding, задержка не растёт; - блокирующий — запрос ждёт слот: это очередь, которая увеличивает latency, память и число клиентских таймаутов. Иногда очередь нужна, но её длина должна быть явной и маленькой.
Лимит стоит ставить до дорогих операций: аутентификации,
похода в БД, парсинга. Health-check и служебные ручки обычно
исключают из общего лимита, иначе сервис сам себя объявит мёртвым.
Ориентир для лимита — закон Литтла: concurrency = throughput × latency; при 500 rps и 100 мс нужно около 50 слотов, а не 5.
10.5 Стриминг vs буферизация ответа
Буферизация собирает ответ в память: можно заранее отдать
Content-Length, сжать gzip, аккуратно вернуть ошибку до первого
байта. Цена — задержка до первого байта и память, пропорциональная
размеру ответа; гигабайтный экспорт в буфере — это OOM.
Стриминг пишет по частям и вызывает Flush:
gow.Header().Set("Content-Type", "application/x-ndjson") enc := json.NewEncoder(w) for _, row := range rows { if err := enc.Encode(row); err != nil { return // клиент ушёл — ctx уже отменён } }
Стриминг даёт низкий TTFB и константную память, но держит горутину
и соединение дольше, а WriteTimeout должен покрывать всю передачу;
для длинных потоков дедлайн продлевают через
http.NewResponseController(w).SetWriteDeadline. Сжатие и
прокси-буферы могут съесть выгоду стриминга. Клиентский обрыв
виден через r.Context().Done() и ошибку записи.
10.6 Компромиссы
- Таймауты короткие vs длинные: короткие освобождают горутины, но режут медленные легитимные запросы; длинные безопаснее для клиента, но дороже при атаке и деградации.
- Лимит in-flight vs очередь: лимит с
503сохраняет предсказуемую задержку; очередь повышает успешность в коротком всплеске, но при насыщении превращается в усилитель таймаутов. - Глобальный лимит vs per-route: глобальный проще, но тяжёлая ручка может занять все слоты; per-route честнее, но требует настройки и защиты от суммарной перегрузки.
- Буферизация vs стриминг: буфер удобен для ошибок и gzip, но ест память; стриминг экономен, но сложнее с таймаутами, сжатием и частичными ошибками.
- Health-check «процесс жив» vs «готов работать»: первый скрывает деградацию, второй требует честных сигналов (очередь, latency, отказы), иначе балансировщик выключит здоровый сервис.
💻 Практика
- Slowloris своими руками. TCP-клиент отправляет заголовки по
байту раз в секунду. Запустите сервер без
ReadHeaderTimeoutи снимите рост горутин; затем поставьте 2 с и проверьте, что соединения закрываются, а сервис жив. - Проверьте TimeoutHandler. Хендлер спит 5 с, обёрнут в
TimeoutHandlerс 1 с. Клиент должен получить503, но горутина продолжит спать; покажите это по метрике горутин и объясните, чем это отличается от отмены черезctx. - Сравните 503 и очередь. Лимит 4 слота, 20 одновременных
запросов: вариант с
defaultи вариант с блокирующим захватом и очередью 100. Сравните p99, пик памяти и число успешных ответов; health-check вынесите из лимита. - Запустите сниппет модуля: конфиг с четырьмя таймаутами и
семафор на 4 слота. Из 6 запросов четыре займут слоты, два
получат быстрый отказ. Вывод:
отклонено сразу 2,пик в полёте 4 (лимит 4)— очередь не копится.
🧩 Quiz
✅ Итоги модуля
Студент должен:
- Объяснить, что держит горутина обработчика запроса
- Настраивать
ReadHeader,Read,WriteиIdleтаймауты - Использовать
TimeoutHandlerи понимать, что он не отменяет работу - Ограничивать тело и заголовки запроса
- Ставить лимит in-flight и выбирать между
503и очередью - Выбирать стриминг или буферизацию под задачу
- Строить health-check, отражающий деградацию, а не только жизнь
- Ответить на все вопросы Quiz
🔗 Мостик к Модулю 11
Лимиты и таймауты удерживают сервер в рабочем режиме, но рано или поздно его нужно остановить: деплой, масштабирование, рестарт. Если просто убить процесс, клиенты получат обрывы, а незавершённые запросы — потерю работы. Дальше — graceful shutdown: перестать принимать новое, дать доработать начатое и закрыть ресурсы в правильном порядке.