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

本文先讲公开 TransportResponse.Body 契约,再用 Go 1.26.0 tagnet/http/transport.goh2_bundle.gohttptrace/trace.go。 诊断契约来自 httptraceruntime/metricsnet/http/pprofDiagnostics

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

假设 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.ClientTransport 都设计为并发安全、可长期复用。这里的“共享”也明确了负责人: 进程负责创建和关闭 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 三个限制分别控制“总量”和“留下多少”

配置计数范围达到上限常见误读
MaxConnsPerHostdialing + active + idle新 dial 排队只限制 idle
MaxIdleConnsPerHost单 host 保留的 idle多余 idle 关闭限制并发请求
MaxIdleConns所有 host 的 idle 总量按 LRU 清理总连接硬上限
IdleConnTimeout连接保持 idle 的时长超时关闭请求总 timeout

queueForDialMaxConnsPerHost 为 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 才发送 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 交给连接读循环。

Transport.getConn 先找 idle connection 再排 dial,MaxConnsPerHost 满时等待;HTTP/1 persistConn 的 bodyEOFSignal 只有看到底层 io.EOF 才 tryPutIdleConn,EOF 前 Close 会关闭连接,LimitReader 到 N 不等于底层 EOF
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 放在同一套池接口下的实现选择。

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

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

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

一个 http2ClientConn 持有底层 TLS connection、streams map[uint32]*http2clientStreamnextStreamIDmaxConcurrentStreams、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 client connection 在一条 TLS 连接上保存 stream map,stream 1、3、5 并发传输,各自有 stream window 并共享 connection window;读 response body 返回 WINDOW_UPDATE,取消当前请求发送 RST_STREAM,其他 stream 继续
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 且连接健康时的常见结果。

可用性与 slot:isUsableLocked / availableLocked;strict 契约:StrictMaxConcurrentStreams

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,直到 runtime 通过 bundled PING 确认 peer 收到,避免对完全无响应的连接无限发送 reset 后的新请求。

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

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

四、实验同时证明复用、排队和 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 → 并发安全。

生产诊断五层证据:用 httptrace 定位请求阶段,应用指标与 runtime metrics 判断持续压力,pprof 找 owner 和阻塞,execution trace 对齐 netpoll、runnable、GC assist 和 block,race 在测试或预发验证并发访问;用 request ID、时间与版本对齐
现象最小证据优先假设不要先做
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 找 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 的交付物

  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. owner 证据:同一窗口的 goroutine/CPU/heap 或 delta block/mutex profile。
  5. 时间线:5–10 秒受控 execution trace,带 task/region 与 request ID。
  6. 最小复现:像 transport lab 一样用 channel/barrier 证明状态,不用 sleep 猜。
  7. 单变量修正:先修 body 生命周期、owner 或热点,再单独改变连接/stream 参数。
  8. 回归条件:正确性测试、race、资源上限与 tail latency 同时达标。

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

参考源码与文档