Модуль: Сети и протоколы · Уровень: Middle+/Senior

TL;DR#

  • Пул соединений переиспользует установленные TCP(+TLS) соединения, экономя дорогой handshake (RTT + TLS + slow start) и избегая исчерпания эфемерных портов / TIME_WAIT.
  • В Go HTTP-пул живёт в http.Transport: ключевые ручки — MaxIdleConns, MaxIdleConnsPerHost, MaxConnsPerHost, IdleConnTimeout.
  • Главный антипаттерн: создавать новый http.Client/Transport на каждый запрос — пул не переиспользуется, соединения не шарятся. Клиент должен быть один и переиспользуемый.
  • Утечки соединений: незакрытое resp.Body → соединение не возвращается в пул → рост FD, новые коннекты, деградация. То же для БД: незакрытые *sql.Rows.
  • Для БД (database/sql): SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime, SetConnMaxIdleTime. Lifetime критичен для DNS-failover и балансировки.

Теория#

Зачем пул#

Установка соединения дорогая: TCP handshake (1 RTT) + TLS handshake (1-2 RTT) + TCP slow start. На горячем пути это десятки-сотни мс. Пул держит idle-соединения готовыми к переиспользованию (keep-alive), убирая эти затраты и снижая TIME_WAIT/исчерпание портов.

http.Transport: ключевые параметры#

tr := &http.Transport{
    MaxIdleConns:        100,              // всего idle во всём пуле
    MaxIdleConnsPerHost: 10,               // idle на один host (ДЕФОЛТ = 2!)
    MaxConnsPerHost:     0,                // 0 = без лимита активных на host
    IdleConnTimeout:     90 * time.Second, // когда idle закрывается
    TLSHandshakeTimeout: 10 * time.Second,
    ExpectContinueTimeout: 1 * time.Second,
    // DialContext контролирует TCP-таймаут и keep-alive
    DialContext: (&net.Dialer{
        Timeout:   5 * time.Second,
        KeepAlive: 30 * time.Second,
    }).DialContext,
    ForceAttemptHTTP2: true,
}
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
ПараметрЧто делаетДефолт
MaxIdleConnsмакс. idle во всём пуле100
MaxIdleConnsPerHostмакс. idle на хост2 (часто мало!)
MaxConnsPerHostмакс. всего (active+idle) на хост, блокирует сверх лимита0 (∞)
IdleConnTimeoutвремя жизни idle-соединения90s (у DefaultTransport)
  • MaxIdleConnsPerHost = 2 — самый частый перформанс-баг: под нагрузкой к одному бэкенду создаётся много соединений, но в пуле кэшируется только 2; остальные после запроса закрываются → постоянные новые handshake + TIME_WAIT. Поднимать под профиль нагрузки.
  • MaxConnsPerHost ограничивает конкурентность (избегает заваливания бэкенда), при достижении лимита запросы ждут.
  • http.DefaultTransport уже пулит — но дефолтный per-host=2 его ограничивает.

Правильное использование клиента#

// ХОРОШО: один клиент на всё приложение (или на интеграцию)
var apiClient = &http.Client{Transport: tr, Timeout: 30 * time.Second}

// ПЛОХО: новый клиент/транспорт на каждый запрос — пул не работает
func bad() {
    c := &http.Client{} // новый транспорт каждый раз = нет переиспользования
    c.Get("https://api")
}

Критично: всегда закрывать и дочитывать Body#

resp, err := client.Get(url)
if err != nil { return err }
defer resp.Body.Close()                 // обязательно
// дочитать тело, иначе соединение НЕ вернётся в пул
io.Copy(io.Discard, resp.Body)          // drain
  • Соединение возвращается в пул только если тело полностью прочитано И закрыто. Иначе Go не может переиспользовать соединение (не знает границу ответа) → закрывает его, теряя пул.
  • Это причина №1 “утечки” соединений и роста числа коннектов в Go HTTP-клиентах.

Пулы для БД (database/sql)#

*sql.DB — это пул, а не одно соединение. Создавайте один на приложение.

db, _ := sql.Open("postgres", dsn)
db.SetMaxOpenConns(25)              // макс. одновременных соединений к БД
db.SetMaxIdleConns(25)             // idle в пуле (часто = MaxOpen)
db.SetConnMaxLifetime(5 * time.Minute) // макс. время жизни соединения
db.SetConnMaxIdleTime(1 * time.Minute) // макс. простоя
ПараметрЗачем
SetMaxOpenConnsограничить нагрузку на БД, избежать “too many connections”
SetMaxIdleConnsсколько держать готовыми; меньше MaxOpen → постоянное пересоздание
SetConnMaxLifetimeпринудительно обновлять соединения: подхват DNS-изменений, ребаланс после failover/деплоя БД, обход серверных таймаутов
SetConnMaxIdleTimeзакрывать давно неиспользуемые
  • SetConnMaxLifetime критичен за load balancer / при failover: без него пул вечно держит соединения к старому мастеру/инстансу.
  • MaxIdleConns < MaxOpenConns → соединения сверх idle закрываются после возврата и пересоздаются под нагрузкой (churn). Обычно ставят равными.
  • Незакрытые rows.Close() / неосвобождённые *sql.Conn/*sql.Tx держат соединение занятым → пул исчерпывается → запросы виснут на ожидании свободного соединения.

Утечки соединений: симптомы и причины#

  • Симптомы: рост числа FD/established-соединений, “cannot assign requested address” (TIME_WAIT), “too many open files”, таймауты на получение соединения из пула, рост latency.
  • Причины:
    • HTTP: незакрытый/недочитанный resp.Body; новый клиент на каждый запрос; слишком низкий per-host idle.
    • БД: незакрытые rows/tx/stmt; контекст без таймаута; MaxOpenConns слишком мал под параллелизм.
  • Диагностика: ss -s, lsof, runtime метрики, db.Stats() (WaitCount, WaitDuration, InUse, Idle), pprof на горутины (висящие на пуле).

Подводные камни / gotchas#

  • MaxIdleConnsPerHost дефолт = 2 — почти всегда мало для сервиса, активно ходящего к одному upstream. Поднимать.
  • Новый http.Client{} на запрос = новый Transport = новый пул каждый раз. Шарьте клиент.
  • Незакрытое/недочитанное Body → соединение не возвращается в пул, эффективно “утечка” и потеря keep-alive.
  • DNS не подхватывается пулом: keep-alive соединение живёт со старым IP даже после смены DNS. Лечится IdleConnTimeout/ConnMaxLifetime или принудительным реконнектом.
  • Client.Timeout vs контекст: Timeout покрывает весь запрос включая чтение тела; для гранулярности используйте req.WithContext(ctx). Без таймаутов горутины виснут на мёртвых пирах.
  • HTTP/2 и пул: один h2-коннект мультиплексирует много запросов → MaxConnsPerHost/idle ведут себя иначе; один коннект может стать узким местом (см. h2 балансировку).
  • БД: MaxOpenConns × число инстансов должно влезать в лимит соединений сервера БД (max_connections Postgres). Частая авария при масштабировании реплик/подов.
  • PgBouncer / внешний пул: при transaction-pooling нельзя использовать prepared statements/сессионные фичи; настройки Go-пула должны это учитывать.
  • SetConnMaxIdleTime появился позже (Go 1.15) — на старых версиях только Lifetime.

Вопросы на собеседовании#

В: Зачем нужен пул соединений? О: Переиспользовать дорогие соединения (TCP+TLS handshake, slow start) вместо создания на каждый запрос; снизить латентность, нагрузку на CPU и сеть, избежать исчерпания эфемерных портов и накопления TIME_WAIT.

В: Почему важно закрывать и дочитывать resp.Body в Go? О: Соединение возвращается в keep-alive пул только если тело полностью прочитано и закрыто — иначе Go не знает границу сообщения и закрывает соединение. Незакрытое Body = потеря переиспользования + утечка FD/горутин.

В: Что не так с MaxIdleConnsPerHost = 2 по умолчанию? О: Под нагрузкой к одному хосту создаётся много соединений, но кэшируется только 2 idle; остальные после запроса закрываются и пересоздаются → постоянные handshake и TIME_WAIT. Для сервиса с одним upstream это надо поднимать.

В: Почему нельзя создавать http.Client на каждый запрос? О: Каждый новый клиент (без явного общего Transport) создаёт новый Transport со своим пулом → соединения не переиспользуются между запросами. Клиент/транспорт надо шарить на всё приложение.

В: Зачем SetConnMaxLifetime для БД? О: Принудительно закрывать и пересоздавать соединения, чтобы подхватывать DNS/топологические изменения (failover, новый мастер), ребалансировать через load balancer и обходить серверные таймауты на простой. Без него пул вечно держит старые соединения.

В: Что произойдёт, если MaxOpenConns мал, а параллелизм высок? О: Горутины блокируются в ожидании свободного соединения (видно в db.Stats().WaitCount/WaitDuration), растёт latency, возможны таймауты по контексту. Надо балансировать MaxOpenConns с лимитом сервера БД и реальным параллелизмом.

В: Как диагностировать утечку соединений? О: db.Stats() (InUse растёт и не падает), рост established-соединений (ss, lsof), “too many open files”, pprof горутин висящих на получении соединения. Искать незакрытые Body/rows/tx и отсутствие таймаутов.

В: Соединения в пуле держат старый IP после failover. Почему и как лечить? О: Keep-alive соединение переживает смену DNS — пул не перерезолвит, пока соединение живо. Лечится IdleConnTimeout/ConnMaxLifetime, ограничением времени жизни, либо клиентской балансировкой/health-checks с реконнектом.

На что копают на senior+#

  • Взаимодействие пула с DNS-TTL и failover; почему DNS-балансировка не работает поверх keep-alive и как решать (lifetime, клиентский LB).
  • Расчёт MaxOpenConns под max_connections БД при N подах/репликах; роль PgBouncer (session/transaction/statement pooling) и ограничения каждого режима.
  • Поведение пула под HTTP/2: один мультиплексированный коннект, влияние на throughput и балансировку, ReadIdleTimeout/h2 health pings.
  • Тонкости возврата соединения в пул в Go: что считается “успешным” завершением, как resp.Body.Close() без drain всё равно может закрыть коннект.
  • Backpressure через MaxConnsPerHost как защита upstream от перегрузки; trade-off с латентностью (запросы ждут).
  • Метрики и SLO: connection wait time, pool saturation, churn rate как сигналы неправильной конфигурации.
  • Таймауты на всех уровнях (Dial, TLS handshake, response header, body read, общий) и почему один общий Client.Timeout недостаточен для стриминговых/долгих ответов.