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 秒,再对比三种路径,免得误读。
go
package main
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
func main() {
slowURL := "http://127.0.0.1:18080/sleep?seconds=5"
// 错误示范 1:DefaultClient,Timeout==0 → 无总时限
{
start := time.Now()
resp, err := http.Get(slowURL) // 等价于 DefaultClient.Get
fmt.Printf("DefaultClient Get: err=%v elapsed=%s\n", err, time.Since(start))
if resp != nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
// 错误示范 2:外层 WithTimeout,但 NewRequest 丢弃了 ctx
{
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
start := time.Now()
req, err := http.NewRequest(http.MethodGet, slowURL, nil)
if err != 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))
if resp != 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)
defer cancel()
start := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, slowURL, nil)
if err != nil {
panic(err)
}
resp, err := client.Do(req)
fmt.Printf("WithContext + Client.Timeout: err=%v elapsed=%s\n", err, time.Since(start))
if resp != 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 便宜得多。