Go:设了超时却一直挂着?先看DefaultClient和NewRequest
Timeout为 0 等于永不超时;NewRequest绑的是context.Background,外层WithTimeout进不了Do。两处都改对,才会真正取消。
一、症状:代码里「写了超时」,请求却能挂到天荒地老
排障时经常听到两句互相矛盾的话:「我明明用了context.WithTimeout」和「下游挂了我们的 goroutine 也一起挂」。对照 net/http · Client 文档,这类问题多半怪不到网关头上,根子在客户端默认值和 Request 构造方式叠在一起:
http.DefaultClient(以及&http.Client{})的Timeout零值表示 no timeout,文档原句是A Timeout of zero means no timeout;- 包级
http.Get/Post/Head走的就是DefaultClient,等于主动选择「可以永远等」; http.NewRequest只是对NewRequestWithContext(context.Background, ...)的包装。你在外层WithTimeout得到的 ctx,若没传进 Request,对Client.Do毫无约束。
触发条件很普通,用不着什么「大促事故」:从教程抄来的http.Get进了生产;中间件里WithTimeout之后仍调用封装好的NewRequest助手;或者团队规定「所有函数第一个参数是 ctx」,但 HTTP 工具方法内部重新NewRequest把 ctx 丢掉了。症状都一样:日志里业务截止时间早就过了,出站 goroutine 还在等响应。
下面不讲具体公司的事故,也不给 p99 数字,只把三层超时(Client.Timeout、Request Context、Transport细项)拆开,再给一段可本地跑的对照样例。顺带两点:Transport.CancelRequest已弃用,新代码应优先靠 Request 的 Context 取消;另外别把 DefaultClient 说成被某个 Go 小版本「移除」了,它仍是&Client{},零值可用。
二、机制:三层超时各管什么
先记住文档对Client.Timeout的覆盖范围:连接时间 + 重定向 + 读响应体都算在内;计时在Get/Do返回之后仍可能继续,用于打断后续的Response.Body读取。Client 取消底层 Transport 时,行为等价于让 Request 的 Context 结束。出于兼容,Client 仍可能调用 Transport 上已弃用的CancelRequest;新的RoundTripper实现应看 Request Context,不要再去实现那套旧取消方法。
三层分工可以这样理解:
| 层 | 典型字段 / API | 作用 |
|---|---|---|
| Client | Client.Timeout | 单次请求的总时限(含连、跳转、读 body);0= 无限制 |
| Request | NewRequestWithContext/WithContext | 把可取消的 ctx 带进Do;超时、上游 cancel、截止时间都走这里 |
| Transport | DialContext、TLSHandshakeTimeout、ResponseHeaderTimeout等 | 更细的阶段超时;管不住「整次调用」的语义,也替代不了上面两层 |
常见误读有三条:
- 「我 new 了 Client 却没设 Timeout」:零值 Client 完全可用,且默认走
DefaultTransport,但Timeout仍是 0,能发请求 ≠ 有总时限; - 「我在函数开头
WithTimeout了」:若随后调用http.NewRequest再http.DefaultClient.Do(req),Request 内部仍是context.Background,外层 cancel 到不了 Transport。文档在Get/Do相关说明里反复指向:带 Context 请用NewRequestWithContext+Client.Do; - 「Transport 里设了 Dial 超时就够了」:建连很快、服务端处理却极慢时,只有 Dial 超时挡不住;读 body 拖很久时,也要靠 Client.Timeout 或 Request 截止时间。
另外,Client.Timeout与 Request Context 可以同时存在:Client 会在超时触发时取消请求。更稳妥的组合是:给复用的http.Client设一个保守的总Timeout做兜底,同时每个出站调用传入带截止时间的 Context,让超时原因能和业务取消(例如 HTTP handler 返回、worker 收到取消)对齐。Context 包的惯例同样适用:跨 API 边界传递 ctx,WithTimeout后立刻defer cancel(),避免计时器泄漏。
和「能发」相关的另一个细节:DefaultClient文档写明它是默认 Client,供Get/Head/Post使用;其零值是可用客户端并使用DefaultTransport。很多示例因此从包级函数起步。这对演示友好,放到生产里,「无总时限」就成了默认语义。把它当「便捷入口」看,别当「推荐生产客户端」,排障时会少绕一圈。
三、最小复现:同一个 URL,三种写法
下面这段可直接go run。请把slowURL换成你环境里「故意睡几秒」的地址(本地 stub、httpbin的 delay 接口等均可);重点看err 是否在约 1 秒内返回,下游业务字段不用管。先确认 stub 确实会睡眠超过 1 秒,再对比三种路径,免得误读。
packagemainimport("context""fmt""io""net/http""time")funcmain(){slowURL:="http://127.0.0.1:18080/sleep?seconds=5"// 错误示范 1:DefaultClient,Timeout==0 → 无总时限{start:=time.Now()resp,err:=http.Get(slowURL)// 等价于 DefaultClient.Getfmt.Printf("DefaultClient Get: err=%v elapsed=%s\n",err,time.Since(start))ifresp!=nil{io.Copy(io.Discard,resp.Body)resp.Body.Close()}}// 错误示范 2:外层 WithTimeout,但 NewRequest 丢弃了 ctx{ctx,cancel:=context.WithTimeout(context.Background(),time.Second)defercancel()start:=time.Now()req,err:=http.NewRequest(http.MethodGet,slowURL,nil)iferr!=nil{panic(err)}// req 内部是 Background;下面这行「看起来用了 ctx」,实际没传进 Request_=ctx resp,err:=http.DefaultClient.Do(req)fmt.Printf("NewRequest + ignored ctx: err=%v elapsed=%s\n",err,time.Since(start))ifresp!=nil{io.Copy(io.Discard,resp.Body)resp.Body.Close()}}// 正确写法:专用 Client + NewRequestWithContext{client:=&http.Client{Timeout:3*time.Second}// 总时限兜底ctx,cancel:=context.WithTimeout(context.Background(),time.Second)defercancel()start:=time.Now()req,err:=http.NewRequestWithContext(ctx,http.MethodGet,slowURL,nil)iferr!=nil{panic(err)}resp,err:=client.Do(req)fmt.Printf("WithContext + Client.Timeout: err=%v elapsed=%s\n",err,time.Since(start))ifresp!=nil{io.Copy(io.Discard,resp.Body)resp.Body.Close()}}}预期现象(下游确实慢于 1s 时):前两种往往会拖到接近下游完成时间才返回(或一直挂到你手动停);第三种应在大约 1 秒附近因 Context 截止失败。若错误类型是*url.Error,其Timeout()方法可用来区分是否超时类失败,这同样来自 Client 文档对返回错误的说明。把该布尔值打进指标,比只看err != nil更利于区分「超时」与「连接拒绝 / DNS 失败」。
已有*http.Request、需要换 ctx 时,用req = req.WithContext(ctx)(或Clone)生成新请求,再交给Do;不要假设「外层变量叫 ctx」就会自动生效。封装公共DoJSON(ctx, method, url, body)时,函数签名里保留 ctx,并在内部只允许NewRequestWithContext,从根上堵住「助手函数吞掉 Context」的回归。
四、落地建议:复用 Client,显式传 Context
- 不要用包级
http.Get打生产依赖:改成进程内复用的*http.Client(Client / Transport 本身可并发复用,文档也建议复用,别每次 new)。每次请求new(Client)既丢失连接池收益,也容易漏设Timeout。 - 给 Client 设非零
Timeout:作为整次调用的上限兜底;具体秒数按下游 SLA 定,这里不给拍脑袋的基准数字。需要按依赖拆分时,可以为「支付」「检索」等维护多个命名 Client,别共用一个无超时的 DefaultClient。 - 每个出站调用
NewRequestWithContext:把 handler / worker 传入的 ctx(或WithTimeout/WithDeadline)传到底;函数返回前defer cancel(),避免计时器泄漏。单元测试里用context.WithTimeout断言「慢 stub 下必须在截止前失败」,能防止后人改回NewRequest。 - 需要阶段级收紧时再动 Transport:例如单独限制建连、TLS、等响应头;它们用来补充 Client/Context,两者不用二选一。改 Transport 时优先
Clone()默认 Transport 再调字段,避免从零手写漏掉代理、HTTP/2 等默认行为。 - 读完并关闭 Body:成功路径也要
defer resp.Body.Close(),并尽量读到 EOF,否则连接池复用会受影响。这和超时无关,却是同一条出站链路上最常被一起漏掉的点。超时返回后若仍持有未关闭的 Body,同样可能拖住连接。 - 弃用路径别走回头路:老代码若还在调
Transport.CancelRequest,迁到 Request Context;社区和官方方向一致:取消语义统一到 Context。
和 Java / Spring 侧对照一句(方便双栈同学):Boot 里 yaml 超时只贴自动配置的 Builder,RestClient.create()是旁路;Go 里DefaultClient+NewRequest则是「零超时 + Background」的旁路组合。两边都是默认值看起来能用,语义上却没有你以为的那道截止线。排查清单也可以对齐:先问「走的是不是推荐入口」,再问「数字有没有配错」。
若你们已经有统一的 HTTP 助手包,一次审查就够:导出函数是否强制要求context.Context、内部是否禁止http.Get/NewRequest、默认 Client 是否在init或依赖注入时写死非零Timeout。把这三条写成静态检查或简单的go vet/ast规则,比靠 Code Review 逐行盯更稳。新同事从标准库文档抄示例时,脚手架会自动把他拐到安全路径上,不必等线上 goroutine 泄漏再回头补课。
还有一点:超时成功取消之后,调用方仍要处理错误并决定是否重试。盲目重试且不尊重已取消的 ctx,会把一次超时放大成下游雪崩。正确姿势是检查ctx.Err()与errors.Is(err, context.DeadlineExceeded),仅在可重试错误且截止时间仍有余量时再发下一枪;否则把失败返回给上层做降级或限流。
五、排查清单
- 打印或调试
client.Timeout:若是0,先别怀疑 DNS;包级http.Get直接视为 Timeout=0。 - 看 Request 构造:是
NewRequest还是NewRequestWithContext?对运行中的req调req.Context(),是否仍是 Background?截止时间是否符合预期? - 确认
Do用的是哪一个 Client:有没有「业务 Client 设了超时,实际调用却走了http.DefaultClient」?依赖注入图或简单 grep 都能暴露这种分叉。 - 超时后是否仍有 goroutine 卡在读 Body:Client.Timeout 文档写明计时可能覆盖 Body 读取;业务侧也要用同一 ctx 或独立截止,避免读阶段再次失控。
- 日志与指标里带上
context.DeadlineExceeded/url.Error.Timeout(),和「连接被重置」「DNS 失败」分开统计,避免把所有出站失败都打成超时,进而误调大 Timeout。 - 若使用自定义
RoundTripper(观测、重试中间层),确认它把req.Context()继续传给下游,别在内部再NewRequest把 ctx 丢掉;重试时还应尊重已取消的 ctx,避免取消后继续打下游。
协作流程上,建议在服务模板仓库里放一个「推荐 Client」示例模块:导出共享的*http.Client、强制Do(ctx, req)形态的包装,并在 README 用十行对照写清「不要 Get / 不要 NewRequest」。新服务用模板生成时默认带上超时与 Context;老服务迁移时按依赖重要性分批替换,先替换面向核心链路的出站,再清理边角脚本。这样不必搞运动式全库替换,也能让最危险的挂死路径先消失。
Go 的默认 HTTP 客户端能发请求,不等于有超时;WithTimeout只创建 ctx,NewRequestWithContext才能把它送进Do。复用带Timeout的 Client,出站一律带 Context,三层职责分清,挂死排查会短很多。把这两点写进团队的 HTTP 客户端脚手架,比事后在全库搜http.Get便宜得多。