В прошлой статье серии разобрали, как HAProxy распределяет HTTP/2-streams одного клиентского соединения между несколькими бэкендами — это забота балансировщика, а не клиента. Но прежде чем HAProxy сможет что-то распределить, клиент должен правильно сформировать сами запросы: не плодить лишние TCP-соединения, не изобретать свою многопоточность там, где библиотека уже умеет мультиплексировать, и укладываться в бюджет задержки собственными таймаутами.
Эта статья — про сторону клиента в нашем сквозном кейсе: JSON ~8 КБ, 2000–5000 rps, проверка 100–200 мс, SLA < 300 мс, около 1000 запросов одновременно в полёте (in-flight). Клиент, который откроет по соединению на запрос или неудачно настроит таймауты, сведёт на нет всю работу, проделанную на уровне транспорта и балансировщика.
Рабочий стенд. Всё, о чём идёт речь в серии, собрано в запускаемый пример
performance/highload-lowlatency: HAProxy L7 + пул Go/Java-бэкендов + клиент-нагрузчик,docker compose up. Клиент из этого стенда —clients/go/main.go— держит пул изCONNSпереиспользуемых h2c-соединений и распределяет запросы по least-inflight; именно его код разбирается ниже. В одном локальном прогоне с 4 соединениями в пуле латентность держалась порядка p50≈160 мс / p95≈230 мс / p99≈270 мс при проверке 100–200 мс и client-timeout 280 мс — запросы равномерно легли на все 4 бэкенда, соединения не пересоздавались (цифры иллюстративны — конкретные значения зависят от машины и нагрузки).
В статье
Правильная модель
Первое, что стоит проговорить: клиенту на Go не нужно вручную «создавать несколько streams и балансировать между ними». HTTP/2-транспорт (golang.org/x/net/http2 или встроенный в net/http с ForceAttemptHTTP2) сам открывает новый stream на каждый конкурентный запрос, отправленный через один и тот же *http.Client. Если из десяти горутин одновременно вызвать client.Do(req) на общем клиенте с HTTP/2-транспортом, библиотека мультиплексирует все десять запросов в одно TCP-соединение как десять независимых streams — этим не нужно управлять руками.
Правильная модель поэтому простая: один (или пул из нескольких — см. следующий раздел) H2-соединений, каждый конкурентный запрос — отдельный stream внутри него. Балансировка между инстансами бэкенда — не забота клиента: этим занимается HAProxy, который видит все streams одного клиентского соединения и распределяет их между бэкендами (статья 2 серии). Клиент отвечает только за то, чтобы соединение было одно (или в разумном пуле), переиспользовалось и не создавалось заново на каждый запрос.
Смешивать эти две ответственности — частая ошибка: как только на стороне клиента появляется собственная логика выбора “какой бэкенд дернуть”, она либо дублирует HAProxy, либо конфликтует с ним. Клиент видит только один адрес — адрес балансировщика.
Пул соединений
Одного H2-соединения хватает не всегда. У каждого соединения есть SETTINGS_MAX_CONCURRENT_STREAMS (обычно 100–250 в зависимости от настроек сервера/прокси) — если одновременных запросов больше, часть встанет в очередь на то же соединение, и это уже HoL-эффект, только не на уровне TCP, а на уровне конкретного H2-соединения. Добавляется TCP congestion window: одно соединение, разогнавшееся под 5000 rps, упирается в её рост медленнее, чем несколько соединений параллельно.
Практический ответ — пул из 2–8 H2-соединений на endpoint, а не одно и не сотня. Запросы внутри пула распределяются по своей мини-балансировке на стороне клиента: round-robin или least-inflight (по числу запросов, ещё не получивших ответ, на каждом соединении пула). Это ортогонально балансировке HAProxy между бэкендами — пул решает, через какое из своих TCP-соединений уйдёт запрос, а HAProxy решает, на какой бэкенд попадёт каждый stream.
В стенде это устроено буквально так: CONNS (по умолчанию 4) экземпляров *http.Client, у каждого — свой http2.Transport с cleartext HTTP/2 (h2c, prior knowledge, без TLS-handshake — оправдано внутри доверенного контура стенда), и общий atomic.Int64-счётчик in-flight у каждого клиента пула для least-inflight выбора.
// pooledClient — один член пула: свой *http.Client поверх своего
// http2.Transport, плюс счётчик in-flight для least-inflight выбора.
type pooledClient struct {
client *http.Client
inFlight atomic.Int64
}
// newH2CClient строит клиента с cleartext HTTP/2 (h2c, prior knowledge):
// AllowHTTP разрешает H2 без TLS, DialTLSContext переопределён на обычный
// net.Dial — так соединение остаётся cleartext, но кадрирование H2
// работает end-to-end. У каждого члена пула — свой транспорт и, как
// правило, одно долгоживущее H2-соединение, переиспользуемое на все
// запросы через него (нюанс про MaxConcurrentStreams — ниже).
func newH2CClient() *http.Client {
transport := &http2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, addr)
},
}
return &http.Client{Transport: transport}
}
// pickLeastInFlight выбирает член пула с наименьшим числом запросов
// в полёте — так CONCURRENCY воркеров равномерно ложатся на CONNS
// соединений, а не забивают одно из них.
func pickLeastInFlight(pool []*pooledClient) *pooledClient {
best := pool[0]
bestLoad := best.inFlight.Load()
for _, pc := range pool[1:] {
if load := pc.inFlight.Load(); load < bestLoad {
best, bestLoad = pc, load
}
}
return best
}Для TLS-варианта (не h2c) та же идея реализуется без DialTLSContext-подмены — достаточно http2.Transport{} поверх обычного TLS-соединения, либо http.Transport с ForceAttemptHTTP2: true и увеличенным MaxIdleConnsPerHost (не меньше размера пула, иначе idle-соединения будут закрываться и пересоздаваться).
Уточнение: «один транспорт = одно TCP-соединение» — упрощение, верное в рамках стенда, но не гарантия транспорта в общем случае. http2.Transport держит на адрес одно долгоживущее соединение и мультиплексирует в него запросы как streams — пока их одновременное число не упирается в SETTINGS_MAX_CONCURRENT_STREAMS, которое анонсирует сервер (у Go-сервера по умолчанию порядка сотни-другой). При достижении лимита новые streams встают в очередь ожидания, а транспорт может открыть дополнительное соединение. При параметрах стенда (CONNS=4, CONCURRENCY=200 — то есть ~50 конкурентных запросов на соединение) до лимита далеко, и каждый транспорт живёт с одним соединением; но в общем случае размер пула стоит выбирать с оглядкой и на этот лимит, а не только на число ядер клиента. Детали — в документации golang.org/x/net/http2.
inFlight"] C2["conn 2
inFlight"] C3["conn 3
inFlight"] C4["conn 4
inFlight"] end W["воркеры (CONCURRENCY)"] -->|least-inflight| Pool Pool --> HA["HAProxy"] HA --> B1["backend 1"] HA --> B2["backend 2"] HA --> B3["backend 3"] HA --> B4["backend 4"]
flowchart LR
subgraph Pool["Пул клиента (CONNS=4)"]
C1["conn 1
inFlight"]
C2["conn 2
inFlight"]
C3["conn 3
inFlight"]
C4["conn 4
inFlight"]
end
W["воркеры (CONCURRENCY)"] -->|least-inflight| Pool
Pool --> HA["HAProxy"]
HA --> B1["backend 1"]
HA --> B2["backend 2"]
HA --> B3["backend 3"]
HA --> B4["backend 4"]
Таймауты под SLA
SLA < 300 мс end-to-end не значит «где-то в сервисе стоит таймаут 300 мс». Клиентский таймаут должен быть меньше SLA — иначе клиент рискует прождать 299 мс, получить ответ, и всё равно нарушить SLA из-за сетевой задержки в обе стороны. В стенде это TIMEOUT_MS=280 при SLA 300 мс и проверке 100–200 мс: запас в 20 мс покрывает сетевой путь туда-обратно, а не саму проверку.
Таймаут задаётся через context.WithTimeout на каждый запрос отдельно — не на весь http.Client глобально (client.Timeout тоже можно выставить как страховку от совсем зависших запросов, но основной контроль — per-request context, потому что он же пробрасывается дальше по цепочке вызовов и позволяет серверу тоже отменить работу вовремя).
func doRequest(pc *pooledClient, cfg config) requestOutcome {
pc.inFlight.Add(1)
defer pc.inFlight.Add(-1)
// 280 мс при SLA 300 мс: запас на сетевой путь туда-обратно,
// не на саму проверку (100–200 мс).
ctx, cancel := context.WithTimeout(context.Background(), cfg.timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.target, bytes.NewReader(cfg.payload))
if err != nil {
return requestOutcome{kind: outcomeError}
}
req.Header.Set("Content-Type", "application/json")
resp, err := pc.client.Do(req)
if err != nil {
if ctx.Err() != nil {
return requestOutcome{kind: outcomeTimeout} // отдельно от прочих ошибок
}
return requestOutcome{kind: outcomeError}
}
defer resp.Body.Close()
// ...
}Keep-alive в этой схеме включён по умолчанию — соединения пула живут между запросами, TLS (если он есть) не переустанавливается. Retry — только для идемпотентных операций, и только с явным ключом идемпотентности (Idempotency-Key в заголовке или request_id в теле, как в payload стенда): повторная отправка проверки с тем же request_id не должна выполнить её дважды на стороне бэкенда. Retry без идемпотентности при таймауте на грани SLA — верный способ превратить один медленный запрос в два, ни один из которых не уложится в бюджет.
gRPC
Если вместо REST поверх HTTP/2 используется gRPC, идея пула сохраняется, только вместо *http.Client — несколько grpc.ClientConn. Один grpc.ClientConn тоже мультиплексирует множество RPC-вызовов в одно HTTP/2-соединение, поэтому пул нужен по той же причине, что и для REST-клиента: чтобы не упереться в лимит одновременных streams одного соединения при ~1000 in-flight.
type grpcPoolMember struct {
conn *grpc.ClientConn
client pb.CheckServiceClient
inFlight atomic.Int64
}
func newGRPCPool(target string, size int) ([]*grpcPoolMember, error) {
pool := make([]*grpcPoolMember, size)
for i := range pool {
conn, err := grpc.NewClient(target,
grpc.WithTransportCredentials(insecure.NewCredentials()), // TLS в проде
)
if err != nil {
return nil, fmt.Errorf("grpc.NewClient[%d]: %w", i, err)
}
pool[i] = &grpcPoolMember{conn: conn, client: pb.NewCheckServiceClient(conn)}
}
return pool, nil
}
func doCheck(ctx context.Context, m *grpcPoolMember, req *pb.CheckRequest) (*pb.CheckResponse, error) {
m.inFlight.Add(1)
defer m.inFlight.Add(-1)
ctx, cancel := context.WithTimeout(ctx, 280*time.Millisecond) // тот же бюджет, что и у REST-клиента
defer cancel()
return m.client.Check(ctx, req)
}Deadline здесь — тот же context.WithTimeout с тем же запасом от SLA, что и в REST-варианте: протокол сменился, бюджет — нет.
Этот вариант в стенде тоже запускаемый — clients/grpc-go: grpc.NewClient с insecure даёт h2c prior-knowledge, клиент ходит через отдельный gRPC-фронтенд HAProxy :8090 на пул grpc-backend-1/2, и в живом прогоне запросы ложатся ≈50/50 на оба инстанса. Подробнее про JVM-сторону gRPC (ManagedChannel) и почему для JVM это основной путь под cleartext-HTTP/2 — в пятой статье серии.
Антипаттерны
- ❌ неправильно — создавать
http.Client(илиhttp.Transport) заново внутри обработчика на каждый исходящий запрос. Каждое новое соединение — это TCP-handshake (и TLS-handshake, если он есть) на пустом месте, а под 2000–5000 rps это ощутимая доля CPU и латентности, потраченная на то, что при переиспользуемом клиенте вообще не существовало бы.http.Clientи его транспорт рассчитаны на то, чтобы жить всё время работы процесса, а не создаваться на запрос.
func handleRequest(w http.ResponseWriter, r *http.Request) {
// ❌ новый Transport и TCP-соединение на КАЖДЫЙ вызов —
// keep-alive и пул здесь не работают в принципе.
client := &http.Client{Transport: &http.Transport{}}
resp, err := client.Post(backendURL, "application/json", r.Body)
// ...
}- ❌ неправильно — городить самодельный multiplexing поверх одного WebSocket-соединения: свои message-id, своя очередь ответов, своя корреляция запрос/ответ вручную, чтобы «параллелить» вызовы. HTTP/2 (или gRPC поверх него) уже даёт мультиплексирование, корреляцию streams и flow control из коробки, вместе с готовыми библиотеками, инструментами отладки и поддержкой на уровне инфраструктуры (HAProxy, observability). Пересобирать то же самое поверх WebSocket — добавлять сложность без выигрыша.
- ⚠️ неоптимально — оставлять дефолтные таймауты и лимиты (
client.Timeoutне установлен,MaxIdleConnsPerHostдефолтный) и держать один H2-коннект на тысячи rps без пула. Формально это может работать, пока нагрузка невысокая, но при приближении к ~1000 in-flight именно эти дефолты и станут первой точкой деградации — не потому что HTTP/2 не тянет, а потому что один коннект уперся в лимит одновременных streams, а зависшие запросы без таймаута копятся, а не освобождают ресурсы.
Короткий checklist
- Клиент (
*http.Client/grpc.ClientConn) переиспользуется между запросами, а не создаётся заново в обработчике? - HTTP/2 включён явно (
ForceAttemptHTTP2,http2.Transport, либо TLS ALPN даёт h2 сам) — а не тихо откатывается на HTTP/1.1? - При высокой нагрузке (порядка 1000 in-flight и больше) настроен пул из нескольких H2-соединений/
ClientConn, а не одно на всё? - Client-side таймаут (
context.WithTimeout) меньше SLA с запасом на сетевой путь — а не равен ему или больше? - Retry выполняется только для идемпотентных операций?
- Идемпотентность обеспечена явным ключом (
Idempotency-Key/request_id), а не предположением «сервер и так справится»?
Что дальше
Клиент и транспорт на стороне Go разобраны — тот же набор вопросов (переиспользование соединений, пул, таймауты под SLA) встаёт и на JVM, но с другими API и другими подводными камнями (например, дефолтные пулы HttpClient и настройки Netty/Reactor). Этому посвящена следующая статья серии: highload-сервис на JVM.
Комментарии