Модуль: Сети и протоколы · Уровень: 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слишком мал под параллелизм.
- HTTP: незакрытый/недочитанный
- Диагностика:
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.Timeoutvs контекст:Timeoutпокрывает весь запрос включая чтение тела; для гранулярности используйтеreq.WithContext(ctx). Без таймаутов горутины виснут на мёртвых пирах.- HTTP/2 и пул: один h2-коннект мультиплексирует много запросов →
MaxConnsPerHost/idle ведут себя иначе; один коннект может стать узким местом (см. h2 балансировку). - БД:
MaxOpenConns× число инстансов должно влезать в лимит соединений сервера БД (max_connectionsPostgres). Частая авария при масштабировании реплик/подов. - 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недостаточен для стриминговых/долгих ответов.