凌晨三点,你被一条告警吵醒。监控系统显示接口可用性掉到了99%以下。你揉着惺忪的眼睛打开日志,发现某个接口响应时间暴增到30秒——但它没有报错,没有超时异常,就那么卡着,像一个温柔的死神。
这就是超时的可怕之处:它不会尖叫,它只会慢慢勒死你。
我经历过至少五次由超时引发的线上事故,每一次事后复盘都会发现同一个问题:我们太习惯相信"请求会正常返回"这个假设,而对"万一它不返回"这件事毫无准备。
为什么超时是最容易被忽略的故障点?
因为超时问题有以下几个让人麻痹的特性:
1. 平时没事,流量一大就出事
你在本地测试环境跑得好好的,单元测试全过,集成测试也过。但上了生产流量翻倍的时候,某条SQL突然慢了两秒,然后你发现你的超时设置的是30秒——30秒乘以100个并发连接,你的连接池直接被榨干。
2. 不会抛异常,只会让系统卡死
假设你调用一个第三方支付接口,它响应时间从200ms突然变成20秒。如果你的代码没有设置超时,会怎样?它会一直等,一直等,直到TCP keepalive超时。在这个过程中,你的200个线程全在等这一个接口,然后你的系统对外表现为"无响应",但进程还活着,监控也检测不到"进程崩溃"。
3. 超时错误被认为是"正常的"
很多团队对超时有种奇怪的宽容:超时嘛,网络波动,很正常。但如果你每个月都有几十次超时导致的用户体验下降,这就不正常了。
实战:我是如何一步步把超时处理做对的
第一层:认识超时不是"等待太久"而是"没有响应"
很多人对超时有个误解:超时就是"等太久了"。错。超时是"我认为这个请求不应该等待这么久,所以我选择放弃"。这个认知很关键,因为它决定了你如何设置超时值。
正确的超时设置应该基于:
- 用户能容忍的等待时间:一个页面加载超过5秒,用户就开始点刷新了
- 业务逻辑的合理性:查一个用户信息需要10秒?肯定有问题
- 下游系统的SLA:对方承诺99.9%可用,那0.1%你需要兜底
而不是基于:
- "之前没出过问题"
- "测试环境跑得挺快的"
- "我觉得3秒应该够了"
第二层:代码里怎么写超时
先看反面教材,这是我曾经写过的代码:
// 反面教材,请勿模仿
func CallPaymentAPI(ctx context.Context, req *PayRequest) (*PayResponse, error) {
// 没有任何超时控制
resp, err := paymentClient.Do(ctx, req)
return resp, err
}
这段代码看起来很正常对吧?但问题在于,如果paymentClient内部没有设置超时,那这个请求可能会无限等待。
再看正确写法:
func CallPaymentAPI(ctx context.Context, req *PayRequest) (*PayResponse, error) {
// 创建一个有超时的context
callCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
resp, err := paymentClient.Do(callCtx, req)
if errors.Is(err, context.DeadlineExceeded) {
// 记录关键信息:下游是哪个接口,超时了多久
log.WithFields(log.Fields{
"api": "payment",
"timeout": 3,
}).Error("payment API timeout")
return nil, ErrPaymentTimeout
}
return resp, err
}
这里有几个要点:
- 用
context.WithTimeout创建一个有超时限制的子context - 使用
defer cancel()避免资源泄漏 - 显式判断
context.DeadlineExceeded而不是简单判断err!=nil - 记录详细日志,方便事后排查
第三层:HTTP客户端的超时配置
如果你用的是Go的net/http客户端,超时配置是这样的:
client := &http.Client{
Timeout: 10 * time.Second, // 整体请求超时,包括连接、读取响应
}
// 更细粒度的控制
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second, // 建立TCP连接的超时
}).DialContext,
ResponseHeaderTimeout: 5 * time.Second, // 读取响应头的超时
}
client := &http.Client{
Transport: transport,
// 注意:整体Timeout不包含上面细粒度的超时
}
但这里有个坑:ResponseHeaderTimeout只控制读取响应头的时间,不控制读取响应体的时间。如果你的下游返回了一个很大的JSON但网络慢,ResponseHeaderTimeout不会生效。
所以更稳妥的做法是:
type timeoutResponseWriter struct {
http.ResponseWriter
timeout time.Duration
}
func (w *timeoutResponseWriter) Write(p []byte) (int, error) {
// 如果你想对响应体也做超时控制,需要包装ResponseWriter
// 但这很复杂,一般建议在业务层做超时控制
return w.ResponseWriter.Write(p)
}
client := &http.Client{
Timeout: 10 * time.Second,
}
我的建议是:先从整体超时入手,把细粒度超时作为进阶优化。不要在一开始就设计过度复杂的超时体系。
第四层:数据库连接超时
数据库超时是最容易引发雪崩的地方。来看一个真实案例:
我们的用户服务依赖一个读写分离的MySQL集群。某天主库切换,从库同步延迟从毫秒级变成30秒。在这个窗口期内,所有读从库的请求都在等——然后连接池耗尽,所有服务不可用。
事后我们加了以下超时配置:
// 连接池配置
db.SetConnMaxLifetime(10 * time.Minute) // 连接最大生命周期
db.SetConnMaxIdleTime(5 * time.Minute) // 空闲连接超时
db.SetMaxOpenConns(100) // 最大打开连接数
db.SetMaxIdleConns(20) // 最大空闲连接数
// 查询超时(不同数据库驱动写法不同)
// MySQL
db.QueryContext(ctx, "SELECT ...", ..., query.WithTimeout(3*time.Second))
// PostgreSQL
db.QueryContext(ctx, "SELECT ...", sql.StmtExecTimeout(3*time.Second))
关键是:数据库超时需要覆盖全链路——连接超时、查询超时、读取超时,缺一不可。
超时处理的几个反直觉观点
1. 超时时间不是越短越好
有些人被超时吓怕了,把所有超时都设成1秒。这会导致:正常情况下请求需要1.5秒的业务,因为超时被误杀。正确的做法是基于P99延迟来设置超时,而不是基于平均值。
2. 超时后要重试,但要有限制
超时不等于失败。很多临时性网络抖动,重试一次就成功了。但重试要有策略:
// 指数退避重试
backoff := 100 * time.Millisecond
for i := 0; i < 3; i++ {
resp, err := callAPI(ctx, req)
if err == nil {
return resp, nil
}
if !isRetryable(err) {
return nil, err
}
time.Sleep(backoff)
backoff *= 2 // 100ms -> 200ms -> 400ms
}
return nil, ErrMaxRetriesExceeded
3. 监控超时要关注"量"而不是"有没有"
任何一个系统都有超时。关键不是"有没有超时",而是"超时率是否在可接受范围内"。我们定义的告警阈值:超时率超过0.1%触发警告,超过1%触发紧急告警。
总结:超时处理 checklist
每次写调用下游的代码时,我都会过一遍这个清单:
- ✅ 有没有设置超时?超时时间是多少?
- ✅ 超时后有没有降级逻辑?
- ✅ 超时错误有没有记录详细日志?
- ✅ 有没有配置重试?重试有没有退避策略?
- ✅ 有没有监控超时率?
超时就像安全带——平时你觉得它碍事,出事的时候它救你一命。下次写代码的时候,花30秒加个超时配置,可能就省了你凌晨三点爬起来处理事故的凌晨三点。
记住:系统崩溃不可怕,可怕的是崩溃得悄无声息。 超时是你最后的安全网,别让它形同虚设。