阅读契约:先不背连接池字段。读完前半篇,你应该能区分三种“等”:等一条可用连接、等服务器返回数据、等 HTTP/2 连接里的 stream 名额。它们都表现为请求变慢,却需要完全不同的证据和修复。
本文先讲公开 Transport 与 Response.Body 契约,再用 Go 1.26.0 tag 的
net/http/transport.go、
h2_bundle.go 与
httptrace/trace.go。
诊断契约来自 httptrace、
runtime/metrics、
net/http/pprof 与 Diagnostics。
一、先看一个“已经复用,为什么还在等”的请求
假设 fetchd 同时向同一个站点发出二十个请求。共享的 http.Client 确实会复用连接,但“复用”不表示每个请求立刻拿到通行证:
HTTP/1 连接同一时刻通常只服务一组顺序交换;连接总数达到上限时,新请求会排队;HTTP/2 虽能在一条连接上并发多个 stream,也仍受 stream 与流控窗口限制。
因此看到延迟上涨时,先问请求卡在哪一段:还没拿到连接,已经发出但服务器没回,还是拿到了 HTTP/2 连接却没有可用 stream 或窗口? 连接池的意义,是让这些资源由一个长期存在的 owner 统一复用和限流,而不是简单保存一袋 socket。
20 个请求到同一个站点:
先到的请求借走现有连接或 stream
→ 资源够:立刻写出请求
→ 连接额度满:在 GetConn 前后排队
→ HTTP/2 stream 满:等一张 stream 票
→ 已经写出:等上游响应或流控额度
所以“连接复用了”只回答有没有重复建 TCP/TLS,不能回答请求是否排队。诊断时要给同一条请求分别记录拿资源、写请求、 收响应三个时间点,才能知道等待属于池、上游还是协议流控。
一个共享 Transport 同时维护按 connect method 分组的 idle connection、等待 idle 的请求票据、
正在 dial 的票据和 per-host 总连接计数。cache key 不只是裸 hostname,还受 scheme、address、proxy 与是否强制 HTTP/1 等连接方法影响。
所以两个 URL 看起来同域,不代表一定命中同一池;反过来,为每次请求新建 Transport,则会把所有复用状态主动丢掉。
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxConnsPerHost = 16
transport.MaxIdleConnsPerHost = 8
transport.IdleConnTimeout = 90 * time.Second
client := &http.Client{Transport: transport} // 进程内共享
http.Client 和 Transport 都设计为并发安全、可长期复用。这里的“共享”也明确了负责人:
进程负责创建和关闭 idle connections,请求只借用连接或 stream。把 client 建在 handler 内,不仅重复 DNS/TCP/TLS 工作,也让
MaxConnsPerHost 失去全局约束意义。
二、HTTP/1:拿连接、用连接、归还连接
2.1 getConn 让 idle 与 dial 竞争同一张票据
getConn 先为请求建立 wantConn,调用 queueForIdleConn;若没有立即交付,再进入
queueForDial。此时 idle connection 可能先出现,也可能 dial 先完成,w.tryDeliver 保证只有一方赢得票据。
这就是源码注释引用的 socket late binding:请求不必在一开始就押注“等旧连接”还是“建新连接”。
if delivered := t.queueForIdleConn(w); !delivered {
t.queueForDial(w)
}
select {
case r := <-w.result:
return r.pc, r.err
case <-treq.ctx.Done():
return nil, context.Cause(treq.ctx)
}
Transport.getConn 与票据等待。
还有一个容易错读的细节:dial context 用 context.WithoutCancel 从 request context 脱离,再单独加内部 cancel。
当前请求取消后,getConn 会返回取消错误,但已经开始的 dial 可以继续,因为刚建好的连接可能交付给未来请求。
这不是忽略 deadline;它是在 request owner 与 pool owner 之间转移连接创建的生命周期。
2.2 三个限制分别控制“总量”和“留下多少”
| 配置 | 计数范围 | 达到上限 | 常见误读 |
|---|---|---|---|
MaxConnsPerHost | dialing + active + idle | 新 dial 排队 | 只限制 idle |
MaxIdleConnsPerHost | 单 host 保留的 idle | 多余 idle 关闭 | 限制并发请求 |
MaxIdleConns | 所有 host 的 idle 总量 | 按 LRU 清理 | 总连接硬上限 |
IdleConnTimeout | 连接保持 idle 的时长 | 超时关闭 | 请求总 timeout |
queueForDial 在 MaxConnsPerHost 为 0 时直接 dial;有上限时先检查当前计数,满了把票据压进
connsPerHostWait。取消请求会让票据不再 waiting,队列清理时跳过它。这个等待时间不会在默认 metrics 中自动出现,
需要应用通过 httptrace 的 GetConn/GotConn 差值或显式 wrapper 记录。
2.3 HTTP/1 的 persistConn 是一读一写两条循环
dial 成功后,HTTP/1 路径建立 persistConn,由一个 writeLoop 串行写 request,一个 readLoop
读取对应 response。keep-alive 让多个请求顺序复用同一连接,并没有在同一 HTTP/1 pipe 上把多个 response 任意交错。
readLoop 还负责判断连接是否健康、response body 是否完成,以及何时调用 tryPutIdleConn。
request goroutine
→ persistConn.roundTrip
→ writeLoop writes request
→ readLoop parses response
→ caller owns Response.Body
→ readLoop waits for body EOF decision
没有 body 的 response 可以在交付前回池;有 body 时,readLoop 包一层 bodyEOFSignal,然后停在
waitForBodyRead。调用方读到底层 io.EOF 才发送 true;在 EOF 前 Close 发送 false。
只有 true、连接未见 socket EOF、request 写成功且连接仍健康时,才进入 tryPutIdleConn。
2.4 Close 是释放资源,不自动等于可复用
短响应:读到 resp.Body 的真实 EOF → Close → 连接可以回池
超限响应:LimitReader 自己返回 EOF → 底层 body 仍有数据
→ Close 走 early-close → HTTP/1 连接不能回池
两条路都正确释放了响应资源,但只有第一条证明这条 HTTP/1 连接已经读到消息边界。先记住这个对比,
再看 bodyEOFSignal 怎样把 true/false 交给连接读循环。

waitForBodyRead := make(chan bool, 2)
resp.Body = &bodyEOFSignal{
body: resp.Body,
earlyCloseFn: func() error {
waitForBodyRead <- false
<-eofc
return nil
},
fn: func(err error) error {
isEOF := err == io.EOF
waitForBodyRead <- isEOF
// ...
return err
},
}
完整判断:persistConn.readLoop;wrapper:bodyEOFSignal。
这正好解释 fetchd 的资源边界:io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBytes))
对小于 1 MiB 的正常响应会读到真实 body EOF,连接可复用;若上游仍有数据而读取达到上限,LimitReader 在自己的 N 归零时返回 EOF,
却没有让被包装的 resp.Body 再读到 EOF。随后 defer Close 会走 early-close 分支,HTTP/1 连接不复用。
这不是简单 bug,而是防止不可信大响应无限消耗带宽的正确取舍。可以在已知安全的小尾部上做有界 drain,但不能为了复用连接无上限地读攻击者数据。 应把“因 response cap 丢弃复用”的次数作为应用指标,并让结果显式标记 truncated;是否值得 drain 要用连接建立成本与额外流量共同决定。
2.5 tryPutIdleConn 先服务 waiter,再留下 idle
回池不是简单 append。tryPutIdleConn 先拒绝禁用 keep-alive 或 broken 的连接,再标记 reused;
若已有请求在 idleConnWait 等待,HTTP/1 connection 直接交付给第一个仍 waiting 的票据。
无 waiter 才进入 per-host idle list,应用 MaxIdleConnsPerHost、global LRU 和 IdleConnTimeout。
HTTP/2 在这里已经出现分叉:pconn.alt != nil 的连接不会像 HTTP/1 那样从 idle list “取走”,
因为同一连接能同时交付给所有等待者,还会继续留给未来请求。这不是 idle 一词的日常含义,而是 Transport 把可共享 alternative protocol connection
放在同一套池接口下的实现选择。
三、HTTP/2 把“一连接一请求”变成“一连接多 stream”
先想象两个 worker 同时抓网页:它们共用一条 TCP/TLS 通道,但各拿一张独立的 stream 票,响应片段可以在通道上交错。 每张票有自己的“小水杯”——stream 流控窗口;整条连接还有所有 stream 共用的“大水箱”——connection 流控窗口。 任一额度用完都要等对端归还;能多路复用,不等于没有排队和背压。
一个 http2ClientConn 持有底层 TLS connection、streams map[uint32]*http2clientStream、
nextStreamID、maxConcurrentStreams、connection-level flow window 与写锁。
每次 RoundTrip 创建独立 clientStream,保存 request context、response header channel、body pipe、stream flow window 和 abort state,
再启动 go cs.doRequest。客户端发起的 stream ID 使用递增奇数 1、3、5……

type http2ClientConn struct {
tconn net.Conn
flow http2outflow
inflow http2inflow
streams map[uint32]*http2clientStream
nextStreamID uint32
maxConcurrentStreams uint32
reqHeaderMu chan struct{}
}
type http2clientStream struct {
cc *http2ClientConn
ctx context.Context
ID uint32
flow http2outflow
inflow http2inflow
abort chan struct{}
}
当前字段布局:http2ClientConn 与 clientStream。
3.1 并发上限是 stream slot,不只是 TCP 连接数
connection 可接新请求的条件包括未收到 GOAWAY、未 closing、stream ID 未耗尽且不过 idle timeout。
当前占用数包含 active streams、reserved slots 和等待确认的 reset streams;达到 peer 的
SETTINGS_MAX_CONCURRENT_STREAMS 后不能继续在这条连接开 stream。
默认 StrictMaxConcurrentStreams=false 时,Transport 可以新建 TCP connection,让每条连接分别服从 peer 的 per-connection stream limit;
strict=true 时把该 limit 视作全局限制,RoundTrip 必要时等待。前者提高并行度但增加 socket/TLS 和 upstream 压力,后者提供更硬的并发边界却可能形成 queue。
因此“HTTP/2 只需要一条连接”不是契约,只是低于 stream limit 且连接健康时的常见结果。
3.2 两级 flow control 让“没读 body”影响更大
每条 stream 有自己的 window,connection 还有所有 stream 共享的 window。接收 DATA 会消耗两者;
应用从 response body 读取后,client 才累计 credit 并发送 WINDOW_UPDATE 给 stream 与 connection。
单个不读的巨大 response 先卡住自己的 stream;若持续占满 connection-level receive window,也会压住同连接的其他 stream。
reqHeaderMu 是发送新 request header block 的 semaphore,避免 header block 交错,不代表整条 HTTP/2 connection 串行。
DATA frame 仍会在 stream 间 multiplex。HTTP/2 消除了 HTTP/1 请求级队头阻塞,却没有消除 TCP loss 的传输层队头阻塞:一个丢包仍会阻塞同一 TCP connection 上所有 stream 的有序字节。
3.3 取消一个 stream,通常保留整条连接
roundTrip 同时等待 response headers、stream abort、request context 和旧式 Request.Cancel。
context 取消时先 abortStream,writer 发送 RST_STREAM;当前 stream 结束,但其他 stream 继续使用同一连接。
reset 还会计入并发 slot,直到 runtime 通过 bundled PING 确认 peer 收到,避免对完全无响应的连接无限发送 reset 后的新请求。
GOAWAY 是 connection 级状态:不再创建新 stream,已被 server 接受的旧 stream 仍可能完成;客户端按 stream ID 和错误决定可否安全 retry。
应用不能把所有错误都无脑重试,特别是 request body 不可重放或非幂等操作。使用 Request.GetBody、幂等键与明确 retry budget,
而不是把 Transport 的内部重试当业务 exactly-once。
四、实验同时证明复用、排队和 multiplex
transport_lab_test.go
先用 httptrace.GotConn 验证 HTTP/1:第一条请求读到底层 EOF 并 Close 后,第二条请求的
Reused=true 且获得同一个 net.Conn;如果第一条 body 在 EOF 前 Close,第二条得到新连接。
trace := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
gotConn = info.Conn
reused = info.Reused
},
PutIdleConn: func(err error) {
putIdle <- err
},
}
per-host queue 测试把 MaxConnsPerHost 设为 1,保留第一条 HTTP/1 response body 不读;
第二条请求进入 GetConn 后取消,必须以 context.Canceled 返回,server 只看到一条请求。
HTTP/2 测试先 warm 一条 TLS connection,再让两个 handler 同时到达 barrier;两条请求都报告 HTTP/2、共享 warm connection,
server 的 StateNew 计数仍为 1。测试用 channel 证明并发,不用 sleep 猜时序。
cd go-runtime/examples/fetchd
go test -run 'Test(Transport|EarlyBody|MaxConns|HTTP2)' -count=20
go test ./...
go test -race ./...
go vet ./...
五、联合诊断从“在哪一层慢”开始
一次 P99 排查:
httptrace 发现 GetConn → GotConn 变长
→ 应用指标确认同一 host 的 in-flight 到上限
→ goroutine profile 找到等待 getConn 的 owner
→ execution trace 区分 runnable 排队与纯连接等待
→ 修正容量或上游延迟假设,再用同一指标验证
P99 上升时先写一个可证伪假设:在 GetConn 排队、DNS/Connect/TLS、等待 upstream headers、读取 body、runtime runnable、锁、GC assist, 还是数据竞争?每个工具只回答一层,不能拿一张 goroutine dump 同时证明网络、池配置和 GC。 最小证据顺序是 request phase → 持续趋势 → owner/stack → timeline → 并发安全。

| 现象 | 最小证据 | 优先假设 | 不要先做 |
|---|---|---|---|
| GetConn → GotConn 变长 | httptrace + in-flight / queue wait | 连接上限、body 未 EOF、stream slot | 直接加 MaxIdleConns |
| Reused 下降,Connect/TLS 上升 | GotConnInfo + body outcome | Transport 被重建、idle timeout、early Close | 先怪 DNS |
| GotConn 快,FirstByte 慢 | WroteRequest → GotFirstResponseByte | upstream queue / compute / proxy | 只调客户端连接池 |
| HTTP/2 body 卡住 | trace + per-request bytes + server evidence | stream/connection flow window、TCP loss | 假设 multiplex 无阻塞 |
| runnable 与 GC assist 同升 | runtime metrics + execution trace | CPU 饱和、allocation burst | 只盯 STW |
| block/mutex profile 集中 | 开启采样后的 delta profile | channel/lock contention | 用 heap profile 解释锁等待 |
5.1 httptrace 切开 client request phases
ClientTrace 提供 DNSStart/Done、ConnectStart/Done、TLSHandshakeStart/Done、GetConn、GotConn、
WroteRequest 和 GotFirstResponseByte 等 hooks。GotConnInfo 还给出 Reused、WasIdle 与 IdleTime。
它非常适合回答“时间花在哪一段”,但它是 client 观察,不是 upstream server 的执行真相。
trace := &httptrace.ClientTrace{
GetConn: func(hostPort string) { phase("get_conn", hostPort) },
GotConn: func(info httptrace.GotConnInfo) {
gauge("conn_reused", boolToFloat(info.Reused))
observe("idle_seconds", info.IdleTime.Seconds())
},
GotFirstResponseByte: func() { phase("first_byte", "") },
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
hook 契约:ClientTrace。callback 可能在不同 goroutine 调用,记录器自身必须并发安全且轻量。
phase 之间要用 monotonic duration,同一次请求挂 request ID;不能给每个请求无上限写高基数 metrics label。 推荐把细粒度 phase 放进 trace/span,把 reuse ratio、queue duration histogram 和 timeout reason 做有界聚合。 server-side timing 需要 upstream 自己的 span/log,二者用 trace ID 对齐。
5.2 metrics 看趋势,pprof 找 owner
HTTP pool 的 in-flight、GetConn wait、reuse ratio、early-close reason 属于应用指标;标准
runtime/metrics 没有 Transport pool 指标。runtime 一侧可看
/sched/goroutines/runnable:goroutines、/sched/latencies:seconds、
/sync/mutex/wait/total:seconds 与 /cpu/classes/gc/mark/assist:cpu-seconds,
用来判断 pool 症状背后是否还有 CPU、锁或 GC 压力。
pprof 再回答“谁”:goroutine profile 看大量请求卡在哪个 stack;heap/allocs 区分 retained 与 allocation traffic;
CPU 找计算热点;block 和 mutex 找同步等待。block/mutex 默认可能没有足够样本,必须有意识调用
runtime.SetBlockProfileRate / SetMutexProfileFraction,控制开销并在窗口结束后恢复。
go tool pprof 'http://admin/debug/pprof/goroutine'
go tool pprof 'http://admin/debug/pprof/heap'
go tool pprof 'http://admin/debug/pprof/block?seconds=30'
go tool pprof 'http://admin/debug/pprof/mutex?seconds=30'
go tool pprof 'http://admin/debug/pprof/profile?seconds=30'
pprof 必须放在独立管理 listener、认证与网络访问控制之后。goroutine stack、command line 与 profile 都可能泄漏内部路径和请求信息;
不要因为方便把 net/http/pprof 暴露到公网业务端口。采 profile 也有开销,先限定时间、复现窗口与样本率。
5.3 execution trace 串时间线,race 验证访问安全
execution trace 能在同一时间轴看到 goroutine runnable、network blocking/netpoll、syscall、GC 与 mark assist。
用 trace.NewTask 为一次 fetch batch 建 task,用 trace.WithRegion 标记每个 outbound fetch,
再把 request ID 写进 structured log。这样 GetConn wait、worker runnable、GC assist 和 response 完成能在同一窗口解释,而不是靠多张截图猜先后。
ctx, task := trace.NewTask(ctx, "fetch-batch")
defer task.End()
trace.WithRegion(ctx, "outbound-fetch", func() {
h.fetch(ctx, target, results)
})
race detector 回答另一类问题:观测到的执行路径上是否发生未同步冲突访问。它不会证明没有 race,也不会替代 memory model 推理;
运行时开销也不适合默认挂在生产实例。把真实并发负载缩成测试或 staging replay,结合 go test -race,
让 transport config reload、metrics map、trace callback 与 shutdown registry 等路径被真正执行。
5.4 一次可复现 incident 的交付物
- 症状窗口:P50/P99、timeout reason、请求量、版本与 deploy 时间。
- request phase:GetConn wait、reuse、DNS/Connect/TLS、first byte、body duration。
- 资源趋势:in-flight、queue、connections/streams、runnable G、mutex wait、GC assist CPU。
- owner 证据:同一窗口的 goroutine/CPU/heap 或 delta block/mutex profile。
- 时间线:5–10 秒受控 execution trace,带 task/region 与 request ID。
- 最小复现:像 transport lab 一样用 channel/barrier 证明状态,不用 sleep 猜。
- 单变量修正:先修 body 生命周期、owner 或热点,再单独改变连接/stream 参数。
- 回归条件:正确性测试、race、资源上限与 tail latency 同时达标。
至此,一条 fetchd 请求完成了整个系列的闭环:从值怎样复制、goroutine 怎样调度、channel 与锁怎样协调、
interface/泛型/反射怎样保存类型,到 context 怎样结束工作、对象怎样进入 heap、GC 怎样追上分配,最后回到连接复用与生产证据。
可复用的总原则只有一句:先用源码建立机制模型,再用最小实验与生产证据证明当前系统真的走了那条路径。
参考源码与文档
- net/http Transport documentation
- getConn and per-host dial queue
- Idle connection delivery and pooling
- persistConn.readLoop and body EOF decision
- bodyEOFSignal
- HTTP/2 client connection and stream state
- HTTP/2 RoundTrip and cancellation
- HTTP/2 connection and stream flow control
- net/http/httptrace documentation
- runtime/metrics documentation
- net/http/pprof documentation
- Go diagnostics
- Data race detector
- fetchd transport lab
