现象
请求报 context deadline exceeded (Client.Timeout exceeded while awaiting headers);同一接口用 curl 正常,从 Go 服务调用却超时;流量高峰或 DNS 变更后集中出现;延迟升高的同时 goroutine 与连接数不断累积。
诊断
先在 Go 之外分段测量:
curl -sS -o /dev/null -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} first_byte=%{time_starttransfer} total=%{time_total}\n' https://api.example.com/health
GODEBUG=http2debug=1 go test ./internal/client -run TestFetch -v -count=1
go tool pprof -top http://localhost:6060/debug/pprof/goroutine
首字节时间接近 Go 超时值,说明上游确实慢;连接时间长则查网络或 DNS。HTTP/2 调试日志能看出连接是否复用、是否收到 GOAWAY。goroutine profile 中大量阻塞在读取响应的协程,通常意味着 body 未关闭,连接无法复用。
修复
复用单个 http.Client,同时设置客户端总超时和 Transport 的分段超时:
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 2 * time.Second}).DialContext,
TLSHandshakeTimeout: 2 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
IdleConnTimeout: 90 * time.Second,
MaxIdleConnsPerHost: 32,
},
}
每次请求都 defer resp.Body.Close() 并读完 body,否则连接无法复用;调用方预算更紧时传入带 deadline 的 context。不要每次请求新建 client,那会失去 keep-alive 并放大延迟。
验证
重新压测,p99 应低于客户端超时且不再出现 deadline 错误;观察空闲连接被复用,压测结束后 goroutine 数回落到基线。再注入慢上游,确认超时在预算内触发、body 被关闭、没有连接泄漏;重试仅限幂等请求,避免重复写入。