Спорим, в твоем Go-коде прямо сейчас есть строчка defer resp.Body.Close()? Мы пишем ее на автомате, веря, что стандартная библиотека сама вернет сокет в пул Keep-Alive. А потом под нагрузкой удивляемся, почему сервис открывает тысячи лишних TCP-соединений в секунду и задыхается на TLS-рукопожатиях.
Инженеры команды Go Николас Хусин и Дэмиен Нил опубликовали разбор этой проблемы. Годами пакет net/http при малейшей неосторожности молча рвал соединения. В релизе Go 1.27 поведение рантайма изменили кардинально.
Невычитанный хвост: почему сокет объявлялся «грязным»
В HTTP/1.1 запросы и ответы идут по сокету последовательно единым байтовым потоком. Если приложение вызвало resp.Body.Close(), но не дочитало тело до маркера io.EOF, в сокете остаются байты предыдущего ответа.
До Go 1.26 логика транспорта была жесткой: сокет помечался «грязным», сессия немедленно закрывалась, а под следующий запрос открывалось новое TCP-подключение. Это приводило к потере времени на TCP handshake (1 RTT) и TLS handshake (1-2 RTT), перегружая CPU.
Коварство потокового JSON: цена одного байта
Самое неприятное — как легко было поймать этот сбой непреднамеренно:
- Проверка статус-кода: функция проверяет
resp.StatusCode == 200и выходит, оставляя даже крошечное тело в сокете. - Декодирование через
json.NewDecoder: парсер останавливается на закрывающей скобке}, оставляя в буфере символ перевода строки\n. Всего один непрочитанный байт приводил к разрыву сокета!
Разработчикам годами приходилось вычитывать тело в никуда через io.Copy(io.Discard, resp.Body) перед закрытием.
Революция в Go 1.27: умный фоновый дренаж
Начиная с Go 1.27, пакет net/http.Transport научился наводить порядок сам. При вызове resp.Body.Close() рантайм запускает асинхронное фоновое дочитывание:
- Лимит объема: максимум 256 КБ.
- Лимит времени: таймаут 50 мс.
- Пропуск: при наличии заголовка
Connection: closeдочитывание пропускается.
Если сокет успевает очиститься, он сразу переиспользуется либо отправляется в idle pool.
В тесте на 100 запросов к index.golang.org время выполнения упало с ~710 мс в Go 1.26 до ~150 мс в Go 1.27 — ускорение почти в 5 раз!
Как писать идиоматичный код сегодня
На Go 1.27 костыли с io.Discard больше не нужны:
package main
import (
"fmt"
"net/http"
)
// net/http в Go 1.27 автоматически дочитает остаток тела до 256 КБ за 50 мс
func checkHealth(url string) (int, error) {
resp, err := http.Get(url)
if err != nil {
return 0, err
}
defer resp.Body.Close()
return resp.StatusCode, nil
}
А для тюнинга пула под высокой нагрузкой настраивай транспорт:
package main
import (
"net"
"net/http"
"time"
)
func newCustomClient() *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20, // По умолчанию всего 2!
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
}
return &http.Client{
Transport: transport,
Timeout: 15 * time.Second,
}
}
Подъем MaxIdleConnsPerHost до 20–50 сокетов вместе с автодочитыванием Go 1.27 надежно защитит микросервисы от лишней нагрузки.
