Go http.Client: переиспользование соединений и почему важно читать Body

На код-ревью я регулярно вижу один и тот же паттерн: разработчик делает 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,
}

Параметры пула, на которые стоит обратить внимание:

ПараметрЗначение по умолчаниюНазначение
MaxIdleConns100Максимум простаивающих соединений по всем хостам
MaxIdleConnsPerHost2Максимум простаивающих соединений на один хост
IdleConnTimeout90sЧерез сколько неиспользуемое соединение закроется
DisableKeepAlivesfalseПринудительное отключение 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). Пять сценариев:

СценарийBodyKeep-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/opB/opallocs/op
FullRead243 0007 37577
PartialRead319 00019 236130
NoRead410 00019 136129
NoKeepAlive_FullRead412 00019 323132
NoKeepAlive_NoRead344 00019 216132

Body 10 KB:

Сценарийns/opB/opallocs/op
FullRead276 00016 97580
PartialRead349 00029 269135
NoRead348 00029 112134
NoKeepAlive_FullRead410 00029 242136
NoKeepAlive_NoRead338 00029 118136

Анализ

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()
Чтение без Closeio.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.

Заключение

Связанные статьи:

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)

Теги: