症状
“什么都没失败”本身就是问题。goroutine 剖析里挤满 transport 帧:数千个栈停在 net/http 内部、上面没有任何应用帧——请求卡在传输层,等一个接受了 TCP 连接后就沉默的对端。一个终端对即可复现:nc -l 监听但不应答,应用用 http.Get 打过去,该请求永不返回。乘以流量,幽灵请求同时消耗池槽位和 goroutine,对同一主机的健康调用也被饿死在它们后面。没有失败信号、没有超时、没有返回,正是零超时的三无特征。
诊断思路
先数 goroutine 剖析里 net/http 帧的数量并确认无应用帧覆盖;再审计客户端构造——http.Get/http.Post 包级函数与 &http.Client{} 零值都意味着无超时,每一处都是悬挂上游下的挂点;最后用 nc 停摆端点做确定性复现,超时修复可以直接对它验证。
根因
http.Client 零值的 Timeout 为 0,即整体请求截止时间被禁用。对“接受连接但永不响应”的上游发出的请求,其 goroutine 无限期停在传输层,每个悬挂调用消耗一个池槽位加一个 goroutine,且没有任何失败信号。
修复与验证
双层截止时间:客户端层设 Timeout 作为粗粒度总界(从拨号到读完响应体),Transport 层按相位设 ResponseHeaderTimeout、TLSHandshakeTimeout、IdleConnTimeout;每请求用 context.WithTimeout 挂精确预算(比客户端界更紧),NewRequestWithContext 携带。审计所有 http.Get/http.Post——包级辅助函数用的都是 Timeout 为 0 的 DefaultClient;共享的已配置客户端还附赠连接池,逐调用临时客户端会摧毁它。验证时把服务指向停摆端点,断言每个请求在其截止时间内失败、失败风暴后 goroutine 计数回到基线。单一激进 Timeout 会砍断大下载与流式响应——紧超时按端点用 context 作用域化,客户端层保持宽裕。
代码示例
$ curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | grep -c "net/http"
3184
nc -l 9443 # accepts connections, never answers
# app calls http.Get("http://localhost:9443/") using DefaultClient
var client = &http.Client{
Timeout: 30 * time.Second, // overall bound: dial to body read
Transport: &http.Transport{
ResponseHeaderTimeout: 10 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
IdleConnTimeout: 90 * time.Second,
},
}
// per-request budget, tighter than the client bound:
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)