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月。