На код-ревью я регулярно вижу один и тот же паттерн: разработчик делает HTTP-запрос, проверяет статус-код, закрывает resp.Body и идёт дальше. Тело ответа ему не нужно, зачем его читать? Проблема в том, что http.Client в Go переиспользует TCP-соединения через keep-alive, но только если тело ответа полностью прочитано. Если не прочитано — соединение уничтожается, и на следующий запрос создаётся новое. Под нагрузкой это заметно.
Как работает пул соединений в http.Client
http.Client сам по себе не управляет соединениями. Этим занимается http.Transport. Если вы создаёте клиент без явного Transport, используется http.DefaultTransport:
var DefaultTransport RoundTripper = &Transport{
Proxy: ProxyFromEnvironment,
DialContext: defaultTransportDialContext(&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}),
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
Параметры пула, на которые стоит обратить внимание:
| Параметр | Значение по умолчанию | Назначение |
|---|---|---|
MaxIdleConns | 100 | Максимум простаивающих соединений по всем хостам |
MaxIdleConnsPerHost | 2 | Максимум простаивающих соединений на один хост |
IdleConnTimeout | 90s | Через сколько неиспользуемое соединение закроется |
DisableKeepAlives | false | Принудительное отключение keep-alive |
Когда клиент отправляет запрос, Transport сначала смотрит в пул: есть ли свободное соединение к нужному хосту. Если есть — берёт его, если нет — создаёт новое (TCP handshake, а для HTTPS ещё и TLS). После получения ответа Transport ждёт, пока тело будет полностью прочитано и resp.Body закрыт. Только тогда соединение возвращается в пул.
Последний шаг — самый важный. Пока в TCP-буфере остались непрочитанные данные, Transport не может положить соединение обратно в пул. Оно заблокировано.
Почему Body нужно читать полностью
Внутри Transport тело ответа обёрнуто в bodyEOFSignal — обёртку, которая отслеживает, дочитано ли тело до EOF. Когда вы вызываете resp.Body.Close() без предварительного чтения, обёртка отправляет Transport сигнал: «тело не прочитано». Transport получает этот сигнал и закрывает соединение вместо возврата в пул. Никакого автодочитывания на стороне клиента не происходит — соединение просто уничтожается.
Я проверил это в исходниках: решение о переиспользовании принимается в readLoop, и alive становится true только если bodyEOF == true, то есть чтение дошло до io.EOF.
Три сценария:
| Сценарий | Body прочитан | Соединение возвращается в пул |
|---|---|---|
io.Copy(io.Discard, resp.Body) + Close() | Полностью (до EOF) | Да |
io.CopyN(io.Discard, resp.Body, 10) + Close() | Частично | Нет |
Только resp.Body.Close() | Нет | Нет |
Если ваш сервис ходит в API и не читает тело ответа — каждый запрос открывает новое TCP-соединение. Независимо от размера ответа: хоть 100 байт, хоть 100 KB. На каждый запрос: TCP handshake (1 RTT) + TLS handshake (ещё 1-2 RTT для TLS 1.3).
Часто встречается утверждение, что Close() автоматически дочитывает небольшие тела. Это путаница с серверной стороной: на сервере Go действительно дочитывает req.Body до 256 KB (maxPostHandlerReadBytes), чтобы сохранить keep-alive. Но на клиентской стороне этого механизма нет.
Как правильно работать с resp.Body
Тело не нужно: drain + Close
Даже если тело ответа вам не нужно — например, вы проверяете только статус-код — дочитайте его:
resp, err := client.Get(url)
if err != nil {
return err
}
defer func() {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
Тело нужно: io.ReadAll
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// body содержит весь ответ, соединение вернётся в пул
io.ReadAll читает тело целиком, поэтому соединение вернётся в пул автоматически.
Ограничение размера ответа
Если вы не доверяете серверу и хотите ограничить размер:
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
// Ограничиваем чтение до 1 MB
limited := http.MaxBytesReader(nil, resp.Body, 1<<20)
body, err := io.ReadAll(limited)
if err != nil {
// При превышении лимита соединение не вернётся в пул,
// но это осознанный компромисс
return err
}
Настройка Transport
MaxIdleConnsPerHost по умолчанию равен 2. Если ваш сервис активно обращается к одному хосту (например, к внутреннему API), двух соединений в пуле не хватит:
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10, // Увеличиваем для активно используемых хостов
IdleConnTimeout: 90 * time.Second,
},
}
Проверка через httptrace
Проверить, переиспользуются ли соединения, проще всего через пакет net/http/httptrace. Я использую этот приём, когда хочу быстро убедиться, что всё работает как ожидается:
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptrace"
)
func main() {
client := &http.Client{}
for i := 0; i < 3; i++ {
req, _ := http.NewRequest("GET", "https://httpbin.org/get", nil)
trace := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
fmt.Printf("Запрос %d: reused=%v, idle=%v\n",
i+1, info.Reused, info.WasIdle)
},
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
resp, err := client.Do(req)
if err != nil {
fmt.Println("ошибка:", err)
continue
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
Вывод:
Запрос 1: reused=false, idle=false
Запрос 2: reused=true, idle=true
Запрос 3: reused=true, idle=true
Первый запрос создаёт соединение, остальные переиспользуют его. Если убрать io.Copy(io.Discard, resp.Body), все три запроса покажут reused=false.
Бенчмарки
Подробнее о бенчмарках в Go: Бенчмарки и оптимизация в Go.
Чтобы подтвердить разницу цифрами, я написал бенчмарки с httptest.NewServer. Сервер возвращает тело заданного размера (1 KB и 10 KB). Пять сценариев:
| Сценарий | Body | Keep-Alive | Соединение переиспользуется |
|---|---|---|---|
| FullRead | Прочитан полностью | Да | Да |
| PartialRead | Прочитано 10 байт | Да | Нет |
| NoRead | Только Close() | Да | Нет |
| NoKeepAlive_FullRead | Прочитан полностью | Нет | Нет |
| NoKeepAlive_NoRead | Только Close() | Нет | Нет |
Код бенчмарков
package experiment
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func sizedHandler(w http.ResponseWriter, r *http.Request) {
size := 1024
if _, err := fmt.Sscanf(r.URL.Query().Get("size"), "%d", &size); err != nil {
size = 1024
}
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, strings.Repeat("A", size))
}
func BenchmarkFullRead(b *testing.B) {
server := httptest.NewServer(http.HandlerFunc(sizedHandler))
defer server.Close()
client := &http.Client{
Transport: &http.Transport{DisableKeepAlives: false},
}
for _, bodySize := range []int{1024, 10240} {
url := fmt.Sprintf("%s/?size=%d", server.URL, bodySize)
b.Run(fmt.Sprintf("body_%d", bodySize), func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
resp, err := client.Get(url)
if err != nil {
b.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
})
}
}
Остальные бенчмарки (PartialRead, NoRead, NoKeepAlive_FullRead, NoKeepAlive_NoRead) отличаются только способом обработки тела и значением DisableKeepAlives.
Результаты
Запуск: go test -bench=. -benchmem -count=5
Body 1 KB:
| Сценарий | ns/op | B/op | allocs/op |
|---|---|---|---|
| FullRead | 243 000 | 7 375 | 77 |
| PartialRead | 319 000 | 19 236 | 130 |
| NoRead | 410 000 | 19 136 | 129 |
| NoKeepAlive_FullRead | 412 000 | 19 323 | 132 |
| NoKeepAlive_NoRead | 344 000 | 19 216 | 132 |
Body 10 KB:
| Сценарий | ns/op | B/op | allocs/op |
|---|---|---|---|
| FullRead | 276 000 | 16 975 | 80 |
| PartialRead | 349 000 | 29 269 | 135 |
| NoRead | 348 000 | 29 112 | 134 |
| NoKeepAlive_FullRead | 410 000 | 29 242 | 136 |
| NoKeepAlive_NoRead | 338 000 | 29 118 | 136 |
Анализ
FullRead с keep-alive в 1.3-1.7 раза быстрее остальных сценариев. Но нагляднее всего разница видна в аллокациях: 77 при FullRead (1 KB) против 129-132 при остальных. Лишние ~55 аллокаций — это создание нового TCP-соединения на каждый запрос. По памяти: 7 KB на операцию при FullRead против 19 KB без переиспользования.
Бенчмарки используют httptest.NewServer, который работает через localhost. В продакшене разница будет заметнее: к чистому времени запроса добавляется сетевой RTT (1-100 мс), а для HTTPS ещё и TLS handshake (1-2 дополнительных RTT). Плюс больше аллокаций — больше давление на GC.
Посчитаем на примере. Сервис делает 1000 RPS к внешнему API с RTT 10 мс. Потеря keep-alive означает +10 мс на TCP handshake + ~20 мс на TLS к каждому запросу. При 1000 RPS это 30 секунд суммарного ожидания в секунду. Сервис просто не вытянет.
Типичные ошибки
| Ошибка | Код | Решение |
|---|---|---|
| Только Close без чтения | resp.Body.Close() | io.Copy(io.Discard, resp.Body) перед Close() |
| Чтение без Close | io.ReadAll(resp.Body) без defer | Всегда defer resp.Body.Close() |
| Новый Transport на каждый запрос | client := &http.Client{Transport: &http.Transport{}} в цикле | Один http.Client на всё приложение |
| Новый Client на каждый запрос | http.Get(url) в цикле | Использовать переменную client |
| Низкий MaxIdleConnsPerHost | По умолчанию 2 | Увеличить для активных хостов |
Отдельно про http.Get(). Эта функция использует http.DefaultClient, который работает через http.DefaultTransport. Соединения она переиспользует, но таймаута у неё нет. Для продакшена создавайте свой клиент с явным Timeout.
Заключение
- Всегда полностью читайте
resp.Bodyперед закрытием. Паттернio.Copy(io.Discard, resp.Body)+Close()— стандартный подход - Не полагайтесь на автодочитывание в
Close(): на клиентской стороне его нет - Создавайте
http.Clientодин раз и переиспользуйте. Transport внутри клиента потокобезопасен - Увеличивайте
MaxIdleConnsPerHost, если активно обращаетесь к одному хосту - Проверяйте переиспользование через
httptrace— это занимает пару минут и снимает вопросы
Связанные статьи:
FAQ
Как ведёт себя keep-alive в HTTP/2?
В HTTP/2 мультиплексирование работает иначе: несколько запросов идут по одному TCP-соединению параллельно. Но правило чтения Body остаётся. Если тело не прочитано, поток (stream) внутри соединения занят, и flow control window блокируется. Результат тот же — производительность падает.
http.Transport в Go по умолчанию пытается использовать HTTP/2 (ForceAttemptHTTP2: true). Если сервер поддерживает HTTP/2, клиент переключится автоматически. Паттерн работы с Body одинаков для обоих протоколов.
Нужно ли читать Body при ошибочных статус-кодах (4xx, 5xx)?
Да. HTTP-статус — это часть ответа, а не признак отсутствия тела. Сервер может вернуть 500 с JSON-телом, содержащим подробности ошибки. Если закрыть Body без чтения, соединение не вернётся в пул.
resp, err := client.Get(url)
if err != nil {
return err
}
defer func() {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
if resp.StatusCode >= 400 {
// Соединение вернётся в пул благодаря defer выше
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
Безопасно ли переиспользовать http.Client из нескольких горутин?
Да. http.Client и http.Transport потокобезопасны. Один клиент на всё приложение — правильный подход. Transport сам управляет пулом соединений и синхронизацией.
// Один клиент на всё приложение
var httpClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
// Безопасно вызывать из любой горутины
func fetchData(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
return io.ReadAll(resp.Body)
}
Что произойдёт, если читать Body через json.Decoder?
json.NewDecoder(resp.Body).Decode(&result) прочитает один JSON-объект, но не обязательно всё тело. Если сервер вернул лишние байты после JSON (пробелы, переводы строк), они останутся в буфере. Добавляйте drain после декодирования:
resp, err := client.Get(url)
if err != nil {
return err
}
defer func() {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
var result MyStruct
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
// defer выше дочитает оставшиеся байты
Альтернатива — io.ReadAll + json.Unmarshal. Этот вариант гарантированно читает всё тело:
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var result MyStruct
return json.Unmarshal(body, &result)
Теги: