Go报错context deadline exceeded与dial tcp i/o timeout的排查方法(http.Client超时设置、Transport三个超时字段与数据库连接池定位)

Go程序里context deadline exceeded和dial tcp ... i/o timeout都叫超时,但来源完全不同:前者是context的截止时间到了,后者是net包在建连阶段卡住。分不清这两个,排查时就会一直往错的方向使劲——明明是上游线程池打满不回响应头,却在那里查DNS和安全组。

报错串里的括号是分诊依据

# Client.Timeout 触发,卡在等响应头
Get "https://api.example.com/v1/order": context deadline exceeded 
    (Client.Timeout exceeded while awaiting headers)

# 建连阶段超时,TCP握手没回包
Get "https://api.example.com/v1/order": dial tcp 10.0.0.7:443: i/o timeout

# 自己传的ctx到期,net/http没有附加说明
Get "https://api.example.com/v1/order": context deadline exceeded

# TLS阶段
net/http: TLS handshake timeout

# 压根没解析出地址
dial tcp: lookup api.internal: no such host

带(Client.Timeout exceeded while awaiting headers)那串字样的,说明是http.Client.Timeout这个值触发的,而且卡在发送之后、收到响应头之前。裸的context deadline exceeded则是自己用context.WithTimeout设的截止时间到了。判断时用errors.Is(err, context.DeadlineExceeded)比在字符串里找timeout稳,字符串匹配会随着版本变化失效。

Go的超时分三层

第一层是http.Client.Timeout,覆盖整个请求生命周期:DNS解析、建立TCP、TLS握手、发请求、等响应头、读响应体全都算在内,读body期间计时器仍在跑。

第二层是context.Context,给单次请求设截止时间。Client.Timeout和ctx同时存在时,哪个先到哪个生效。

第三层是http.Transport上的分阶段字段。这一层最容易被忽略,而它恰恰是定位"卡在哪一步"的关键:

transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = (&net.Dialer{
    Timeout:   3 * time.Second,   // DNS + TCP建连
    KeepAlive: 30 * time.Second,
}).DialContext
transport.TLSHandshakeTimeout   = 5 * time.Second  // TLS握手
transport.ResponseHeaderTimeout  = 10 * time.Second // 等响应头
transport.ExpectContinueTimeout  = 1 * time.Second
transport.IdleConnTimeout         = 90 * time.Second // 空闲连接存活
transport.MaxIdleConns            = 100
transport.MaxIdleConnsPerHost     = 20
transport.MaxConnsPerHost          = 50

client := &http.Client{Transport: transport, Timeout: 30 * time.Second}

注意ResponseHeaderTimeout只管到响应头为止,读响应体不受它约束;要限制大文件下载的耗时,得靠Client.Timeout或者ctx。

默认值的坑

http.DefaultClient和http.Get、http.Post这三个常见入口都没有整体超时,服务端接了连接但迟迟不回响应头,请求就会一直挂着。DefaultTransport倒是给了分阶段默认值:拨号30秒、TLS握手10秒、空闲连接90秒、MaxIdleConns为100,但MaxIdleConnsPerHost默认只有2。这个2在并发一上来就会让请求在连接池外排队,表现是延迟突然变高而不是报错,很容易被误判成网络问题。之前有个服务就是卡在这儿,加完MaxIdleConnsPerHost之后p99从两秒多降到三百毫秒出头。

克隆DefaultTransport再改字段,比直接new一个http.Transport好,前者保留了代理支持和HTTP/2协商。

用curl先把耗时分段打出来

怀疑上游慢的时候,先别改代码,用curl把各阶段耗时打出来看看卡在哪:

curl -o /dev/null -s -w 'dns=%{time_namelookup} connect=%{time_connect} \
tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
  --connect-timeout 5 --max-time 15 https://api.example.com/v1/order

nc -zv -w 3 api.example.com 443
dig +short api.example.com

ttfb远大于connect,说明是上游处理慢,改ResponseHeaderTimeout或者去查上游线程池;connect本身就大,那就是网络或DNS的事。实测遇到过一次DNS慢到接近5秒,原因是/etc/resolv.conf里列的第一个DNS不可达,一直等到超时才换下一个,而Dialer.Timeout是包含DNS解析耗时的。

在Go里定位到具体阶段

上面那一步确定不了时,用httptrace打点,把每个阶段的时间戳打进日志:

trace := &httptrace.ClientTrace{
    DNSStart:             func(i httptrace.DNSStartInfo) { log.Println("dns start", i.Host) },
    DNSDone:              func(i httptrace.DNSDoneInfo)  { log.Println("dns done", i.Addrs) },
    ConnectDone:           func(n, a string, e error) { log.Println("connect", a, e) },
    TLSHandshakeDone:      func(s tls.ConnectionState, e error) { log.Println("tls", e) },
    GotFirstResponseByte: func() { log.Println("ttfb") },
}
req := req.WithContext(httptrace.WithClientTrace(ctx, trace))

另外,程序跑起来之后看goroutine数量也很管用。请求超时但连接没释放,runtime.NumGoroutine()会一直涨;开着net/http/pprof的话直接看http://127.0.0.1:6060/debug/pprof/goroutine?debug=2,堆栈里大量卡在net/http.(*persistConn).readLoop的基本就是响应体没关。

resp.Body没关是头号泄漏源

连接复用要求响应体被读完并关闭,只Close不读完也不行。见过一个采集程序跑几小时就开始报i/o timeout,现场看goroutine堆到八千多,根因就是错误分支里提前return,漏了defer resp.Body.Close(),连接全被占住不回池,新的请求只能新建连接,最后把本机端口和fd都耗光。

顺带一条:每次请求新建一个http.Client等于放弃了连接池,MaxIdleConnsPerHost设多少都没用。客户端应当是包级变量或者注入进来的单例。

数据库的超时不走这一套

database/sql的超时跟http.Client一点关系没有,靠的是context和连接池参数:

ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, "select * from orders where id = ?", id)

db.SetMaxOpenConns(50)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)

从QueryContext里抛出来的context deadline exceeded,很多时候不是查询本身慢,而是连接全被占着、在等一个空闲连接。这种情况调大MaxOpenConns只是把压力往下推,真正要查的是为什么连接不释放——通常是事务没提交或者rows没close。

驱动层还有自己的超时参数,MySQL的DSN里能带timeout、readTimeout、writeTimeout,PostgreSQL用connect_timeout。它们和ctx是两套机制,取更严格的那个。

超时值怎么定

比上游稳态p99略高一点,别拍脑袋写1秒。批量导出、大文件下载、流式接口这几类要用单独的客户端,给更长的截止时间,套在同一个短超时上只会制造假故障。Go 1.20、1.21、1.22、1.23、1.24、1.25、1.26,这几个版本里DefaultTransport的默认值一直没变,升级不会替你把这些补上。

数据截至2026年10月。