阅读目标:先不背连接池字段。读完前半篇,你应该能区分三种“等”:等一条可用连接、等服务器返回数据、等 HTTP/2 连接里的 stream 名额。它们都表现为请求变慢,却需要完全不同的证据和修复。

本文先讲 Transport 与 Response.Body 文档保证的行为,再用 Go 开发分支固定快照 的 net/http/transport.go、 internal/http2/transport.go 与 httptrace/trace.go。 这是开发分支快照,不代表稳定版行为;HTTP/2 实现已从旧版 bundled 文件迁入内部包。各诊断工具能观察什么,依据 httptrace、 runtime/metrics、 net/http/pprof 与 Diagnostics。

一、先看一个“已经复用,为什么还在等”的请求

假设 fetchd 同时向同一个站点发出二十个请求。共享的 http.Client 确实会复用连接,但“复用”不表示每个请求立刻拿到通行证: HTTP/1 连接同一时刻通常只服务一组顺序交换;连接总数达到上限时,新请求会排队;HTTP/2 虽能在一条连接上并发多个 stream,也仍受 stream 与流控窗口限制。

因此看到延迟上涨时,先问请求卡在哪一段:还没拿到连接,已经发出但服务器没回,还是拿到了 HTTP/2 连接却没有可用 stream 或窗口? 连接池的意义,是让一个长期存在的 Transport 统一复用连接、计算每个 host 的并发数并让超额请求排队,而不是简单保存一袋 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。若每次 handler 都新建 Transport,就会重复 DNS/TCP/TLS 工作,也让 MaxConnsPerHost 失去跨请求的统一约束。仅新建 Client 而继续共享同一个 Transport,仍会共享连接池。

二、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;请求已经不再等待这次 dial,但 Transport 可以保留 dial 结果供下一次请求复用。

2.2 三个限制分别控制“总量”和“留下多少”

配置计数范围达到上限常见误读
MaxConnsPerHostdialing + 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 记录。

配置语义:Transport fields;排队实现:queueForDial。

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 会传回该错误值;在尚未读到终止错误前 Close 会传回内部 errClosedEarly。 正常 EOF 可以直接尝试回池;提前关闭则进入下面的有界排空分支。两条路径都仍要求连接健康、未见 socket EOF、request 已写完。

2.4 Close 是释放资源,不自动等于可复用

短响应:读到 resp.Body 的真实 EOF → Close → 连接可以回池
提前关闭:LimitReader 的 EOF 未证明底层 EOF → Close 通知 readLoop 后返回
        → 符合条件:后台有界 drain → 真实 EOF 后才尝试回池
        → 不符合条件、超量、超时或错误:关闭连接

当前开发快照已不再把“EOF 前 Close”一律解释为丢弃连接。readLoop 会先判断连接可复用、keep-alive 开启且 ContentLength ≤ 256 KiB;未知长度的 -1 也满足这个入口条件。随后先允许 Close 返回,再尝试丢弃未读数据:最多读取 256 KiB + 1 字节,必须在 50ms 内观察真实 EOF 才能回池。入口比较的是响应的 ContentLength,不是剩余字节数;已知总长度大于上限,即使只剩一个字节也不走此分支。这里读的是当前 resp.ContentLength;自动 gzip 解压会把它改成 -1,所以不能直接拿线上原始 Content-Length 推断。排空读取底层响应体,压缩字节预算也不等于应用的解压后读取上限。

HTTP/1 响应生命周期:真实 EOF 可尝试回池;提前 Close 返回后,符合条件的响应由 readLoop 在 256 KiB 与 50ms 预算内尝试排空,成功到达 EOF 才可复用,否则关闭连接;Close 返回不代表回池完成
waitForBodyRead := make(chan error, 1)
resp.Body = &bodyEOFSignal{
    body: resp.Body,
    earlyCloseFn: func() error {
        waitForBodyRead <- errClosedEarly
        <-eofc
        return nil
    },
    fn: func(err error) error {
        waitForBodyRead <- err
        // ...
        return err
    },
}
通知分支摘要;排空与完整判断:maybeDrainBody;persistConn.readLoop;wrapper:bodyEOFSignal。

这正好解释 fetchd 在 1 MiB 上限附近的行为:io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBytes)) 对小于 1 MiB 的正常响应会读到真实 body EOF,连接可复用;若上游仍有数据而读取达到上限,LimitReader 在自己的 N 归零时返回 EOF, 却没有让被包装的 resp.Body 再读到 EOF。随后 defer Close 进入提前关闭分支。若已知总长度超过 1 MiB,它也超过 256 KiB 排空入口限制,连接会关闭;若长度未知,当前实现仍可能在上述预算内排空尾部后复用。

这不是简单 bug,而是防止不可信大响应无限消耗带宽的正确取舍。不能为了复用连接无上限地读取不可信数据。还要区分应用读取上限与实际网络字节数:自动排空会在应用停止读取后继续消费少量数据。 结果是否 truncated、Close 何时返回与连接最终是否复用应分别记录;需要证明回池时观察 httptrace.PutIdleConn 或后续 GotConn,而不是把 Close 返回当作完成信号。这些排空阈值是源码实现,不是 API 保证。

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 放在同一套池接口下的实现选择。

tryPutIdleConn 与 HTTP/1/HTTP/2 waiter 分支。

三、HTTP/2 把“一连接一请求”变成“一连接多 stream”

先想象两个 worker 同时抓网页:它们共用一条 TCP/TLS 通道,但各拿一张独立的 stream 票,响应片段可以在通道上交错。 每张票有自己的“小水杯”——stream 流控窗口;整条连接还有所有 stream 共用的“大水箱”——connection 流控窗口。 任一额度用完都要等对端归还;能多路复用,不等于没有排队和背压。

内部包中的 http2.ClientConn 持有底层 TLS connection、streams map[uint32]*clientStream、 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……

HTTP/2 接收流控实验台:三个流共用一条连接,响应 DATA 从服务端流向客户端;每条流有窗口并共享连接窗口,读取响应体后以 WINDOW_UPDATE 返还接收额度;并发名额不足时按配置等待或另建连接
type ClientConn struct {
    tconn                net.Conn
    flow                 outflow
    inflow               inflow
    streams              map[uint32]*clientStream
    nextStreamID         uint32
    maxConcurrentStreams uint32
    reqHeaderMu          chan struct{}
}

type clientStream struct {
    cc    *ClientConn
    ctx   context.Context
    ID    uint32
    flow  outflow
    inflow inflow
    abort chan struct{}
}
内部包字段节选:http2.ClientConn 与 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。

当前公共配置入口是 Transport.HTTP2.StrictMaxConcurrentRequests。默认 false 时,Transport 可以新建 TCP connection,让每条连接分别服从 peer 的 per-connection stream limit; 设为 true 时跨到该服务器的连接遵守这个限制,RoundTrip 必要时等待。HTTP2Config.MaxConcurrentStreams 则只适用于 Server,不能拿它设置客户端并发。前者提高并行度但增加 socket/TLS 和 upstream 压力,后者限制得更严格,却可能形成 queue。 因此不能依赖“HTTP/2 只需要一条连接”;它只是低于 stream limit 且连接健康时的常见结果。

transport := http.DefaultTransport.(*http.Transport).Clone()
transport.HTTP2 = &http.HTTP2Config{
    StrictMaxConcurrentRequests: true,
}

此字段以本文开发快照为准;较旧工具链可能没有该字段。

可用性与 slot:isUsableLocked / availableLocked;strict 模式语义:StrictMaxConcurrentRequests。

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 的有序字节。

两级 inflow/outflow:HTTP/2 flow control;response body 返还 credit:transportResponseBody.Read。

3.3 取消一个 stream,通常保留整条连接

roundTrip 同时等待 response headers、stream abort、request context 和旧式 Request.Cancel。 context 取消时先 abortStream,writer 发送 RST_STREAM;当前 stream 结束,但其他 stream 继续使用同一连接。 reset 还会计入并发 slot,直到 HTTP/2 实现通过与 reset 一起发送的 PING 确认 peer 收到,避免对完全无响应的连接无限发送 reset 后的新请求。

GOAWAY 是 connection 级状态:不再创建新 stream,已被 server 接受的旧 stream 仍可能完成;客户端按 stream ID 和错误决定可否安全 retry。 应用不能把所有错误都无脑重试,特别是 request body 不可重放或非幂等操作。使用 Request.GetBody、幂等键与明确 retry budget, 而不是把 Transport 的内部重试当业务 exactly-once。

每请求 stream 与取消 select:http2.ClientConn.roundTrip;reset 写入:writeStreamReset。

四、实验同时证明复用、排队和 multiplex

transport_lab_test.go 先用 httptrace.GotConn 验证 HTTP/1:第一条请求读到底层 EOF 并 Close 后,第二条请求的 Reused=true 且获得同一个 net.Conn;大于排空阈值的已知长度响应在 EOF 前 Close 后,第二条得到新连接。对应 TestEarlyLargeBodyClosePreventsHTTP1Reuse。Go 1.27 及以上另运行 transport_drain_go127_test.go 中的 TestEarlySmallBodyCloseCanReuseAfterDrain:小响应提前 Close 后先等 PutIdleConn,再验证下一请求复用,避免把 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 的调用栈
  → execution trace 区分 runnable 排队与纯连接等待
  → 修正容量或上游延迟假设,再用同一指标验证

P99 上升时先写一个可证伪假设:在 GetConn 排队、DNS/Connect/TLS、等待 upstream headers、读取 body、runtime runnable、锁、GC assist, 还是数据竞争?每个工具只回答一层,不能拿一张 goroutine dump 同时证明网络、池配置和 GC。 最小证据顺序是 request phase → 持续趋势 → 等待栈 → timeline → 并发安全。

联合取证工作台:按请求 ID、版本和同一时间窗口对齐 httptrace、metrics / pprof 与 execution trace;runtime/metrics 不提供连接池指标,race 在测试环境只检查执行过的路径;提出假设后用最小实验和同负载复验
现象最小证据优先假设不要先做
GetConn → GotConn 变长httptrace + in-flight / queue wait连接上限、body 未 EOF、stream slot直接加 MaxIdleConns
Reused 下降,Connect/TLS 上升GotConnInfo + body outcomeTransport 被重建、idle timeout、early Close先怪 DNS
GotConn 快,FirstByte 慢WroteRequest → GotFirstResponseByteupstream queue / compute / proxy只调客户端连接池
HTTP/2 body 卡住trace + per-request bytes + server evidencestream/connection flow window、TCP loss假设 multiplex 无阻塞
runnable 与 GC assist 同升runtime metrics + execution traceCPU 饱和、allocation burst只盯 STW
block/mutex profile 集中开启采样后的 delta profilechannel/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 找等待发生在哪段代码

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 的交付物

  1. 症状窗口:P50/P99、timeout reason、请求量、版本与 deploy 时间。
  2. request phase:GetConn wait、reuse、DNS/Connect/TLS、first byte、body duration。
  3. 资源趋势:in-flight、queue、connections/streams、runnable G、mutex wait、GC assist CPU。
  4. 调用栈证据:同一窗口的 goroutine/CPU/heap 或 delta block/mutex profile。
  5. 时间线:5–10 秒受控 execution trace,带 task/region 与 request ID。
  6. 最小复现:像 transport lab 一样用 channel/barrier 证明状态,不用 sleep 猜。
  7. 单变量修正:先修 body 何时关闭、哪段代码长期保留资源或热点,再单独改变连接/stream 参数。
  8. 回归条件:正确性测试、race、资源上限与 tail latency 同时达标。

至此可以沿一条 fetchd 请求回顾整个系列:从值怎样复制、goroutine 怎样调度、channel 与锁怎样协调、 interface/泛型/反射怎样保存类型,到 context 怎样结束工作、对象怎样进入 heap、GC 怎样追上分配,最后回到连接复用与生产证据。 可复用的总原则只有一句:先用源码建立机制模型,再用最小实验与生产证据证明当前系统真的走了那条路径。

参考源码与文档