先来个算术题。
网关超时 3 秒,网关重试 3 次;下游服务超时 3 秒,它自己也重试 3 次;数据库客户端超时 3 秒,再重试 3 次。写在配置文件里的是「超时 3 秒」,而用户实际等到的最坏路径是 3 × 3 × 3 = 27 秒。按钮转圈半分钟,最后弹出来一句「网关超时」。
更离谱的是,运维查监控,发现每一层的超时配置都规规矩矩写着 3 秒,没有一个配错。配置完全正确,行为完全错误。这就是我今天想聊的:超时从来不是一个数字,而是一份需要被分配、传递、消耗的预算。你把它当数字配,它就当乘法算。
一、超时放大:重试是乘法,不是加法
很多人对重试的理解停留在「失败就再试一次」,但重试一旦叠在超时之上,就变成了指数级的负载放大器。
设想一个下游服务已经在崩溃边缘:它每个请求要花 3 秒才超时。你的上游看到超时,重试一次;重试又超时,再重试一次。一次用户请求,在下游产生 3 次负载;下游有 3 层依赖,用户请求放大 27 倍。下游本来只是「有点慢」,重试一开,直接雪崩。恢复起来更慢,因为还在源源不断地产生新流量。
这有个名字叫 Retry Storm。Google SRE 那本书里把它讲得很直白:重试风暴里,每一个客户端都在「帮忙」把服务按死,因为所有客户端同时在恢复的第一秒集体重试,流量瞬间回到峰值的三倍。
重试的正确性前提不是「失败了就能重试」,而是「重试一次和重试三次,后果完全一样」。
所以判断能不能开重试,先问三个问题:这个操作幂等吗?下游能承受额外流量吗?重试的代价是否小于不重试的代价?三个答案有一个是「否」,重试就不该开。
数据库写操作尤其危险。用户说「我点了没反应」,你以为是网络抖动,于是重试,结果插了两条订单。这种事故我见过,排查的时候所有人都很安静。
二、预算丢失:把「超时 3 秒」换成「还剩 1.8 秒」
更隐蔽的坑是:超时在层与层之间传递时,只有数值,没有截止时刻。
网关在 T=0 发起请求,规定最多等 3 秒;服务 A 在 T=0.2 收到请求,它的配置也写「超时 3 秒」,于是它允许自己干到 T=3.2;服务 B 在 T=0.4 收到,同样写「3 秒」,干到 T=3.4。每个环节都守规矩,合起来总时长却是各环节之和,而不是最大值。
正确的做法是传绝对截止时间(deadline),而不是传相对时长。Go 里 context 的 deadline 就是干这个的,不传就退化成超时;传了就该往下传。
// 反模式:每一层都用自己的超时,预算被重新刷新
func (c *Client) Call(ctx context.Context) (*Reply, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second) // 覆盖了上游的截止时间
defer cancel()
return c.rpc.Invoke(ctx, req)
}
// 正模式:没有上游 deadline 才自己兜底;有就继承
func (c *Client) Call(ctx context.Context) (*Reply, error) {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 3*time.Second)
defer cancel()
}
return c.rpc.Invoke(ctx, req)
}
这行 if _, ok := ctx.Deadline(); !ok 看着不起眼,实际项目里能救下半个系统。更彻底的做法是引入超时预算:入口设一个总预算(比如 5 秒),每跳一次按比例扣减,剩余时间随请求一路往下传。入口给下游 60% 的剩余时间,下游再给自己调用的依赖 50%,剩下的留给序列化和返回。
预算传递还有一个附带好处:它天然实现了「快速失败」。下游发现剩余预算只剩 200 毫秒,就别做那件耗时 500 毫秒的富文本渲染了,直接返回一个「稍后重试」,把资源留给还能成功的请求。
三、超时黑洞:排队时间不计入超时
这是我最想吐槽的一个现象:你把超时从 3 秒调成 5 秒,用户却说更慢了。
原因是连接池。假设你的服务有 200 个数据库连接,QPS 打满后新请求进来,必须排队等连接。数据库本身只要 20 毫秒,但队列里排了 300 个请求,池满时等待超过 5 秒——这 5 秒是在拿到连接之前发生的,还没走到你设置超时的那个调用上。超时设置管的是「拿到连接之后的执行时间」,排队时间根本不归它管。
于是监控上的超时计数纹丝不动,因为那些请求根本没超时,它们是被无限期排队饿死的。用户视角看,就是「什么都没发生,就一直转圈」。
// 典型事故代码
db, _ := sql.Open("mysql", dsn)
db.SetMaxOpenConns(200)
db.SetMaxIdleConns(200)
// 500 个并发请求进来,300 个在 pool 队列里静默等待
// 超时配置:QueryContext(ctx, 30*time.Second) —— 管不到排队
解法有三层:
- 连接获取也要有超时。Go 里用
db.Conn(ctx)配合一个短超时的 context 拿连接,或者用SetConnMaxLifetime之外再加获取连接的超时控制。Java 侧对应 HikariCP 的connectionTimeout(注意它和validationTimeout不是一回事,很多人只配了后者)。 - 池子大小要按下游真实并发能力算,而不是按「数据库能扛多少 QPS」算。数据库连接数不是吞吐,是并发阀门。
- 队列要可见。JVM 池可以打
jstack看线程栈,Go 里看runtime/的 goroutine 状态和go tool pprof的 block profile。没有排队指标的限流和超时系统,等于蒙眼开车。
排队时间不属于超时,但用户等的就是排队时间。这是我见过最诚实的一句大实话,也是最少人承认的一句。
四、抖动、熔断与舱壁:三个必须配套的东西
退避加抖动(backoff with jitter)。固定间隔重试是灾难放大器:1000 个客户端同时失败、同时等 1 秒、同时重试,服务刚喘口气就被再打一轮。加随机抖动,让它们错开。AWS 那篇讲重试的文章基本算是这个领域的标准答案:full jitter 比等比退避更抗雪崩。
// 等比退避:2s, 4s, 8s —— 整齐划一,容易共振
sleep = base * 2^attempt
// full jitter:随机在 [0, 上限] 内取,实际最稳
sleep = random(0, min(cap, base * 2^attempt))
熔断。重试是「失败了再试」,熔断是「失败太多就别试了,直接快速失败」。没有熔断的重试,本质上是让系统在自己最虚弱的时候继续殴打自己。熔断器的经典三态(关闭 / 打开 / 半开)我就不展开了,重点是:熔断打开后要放少量探测请求过去,成功了再关闭,失败了继续打开。很多人的熔断器一旦打开就是永久打开,直到人工干预。
舱壁隔离。这条最容易被忽略:每个下游依赖要有独立的连接池、独立的并发上限、独立的熔断器。共享一个池意味着一个慢的下游能把所有下游一起拖死。你的支付接口和你的日志上报接口,不该共用 200 个连接。
五、排查方法:找到那个超时黑洞
「用户说慢,监控说快」这类问题,我现在的固定套路是:
- 先拆时间。用分布式追踪把一次请求的耗时拆成:排队时间 / 连接建立 / 执行 / 序列化 / 网络。见到的往往不是「执行慢」,而是「排队慢」。
- 看 p99 而不是均值。均值 30 毫秒、p99 三秒的系统,平均值完全没有参考价值,而且平均值好看会让人不查。
- 对比超时计数和错误计数的口径。服务端记的「超时」是执行阶段超时,客户端记的「失败」包含排队超时、连接拒绝、熔断拒绝。三者对不上,说明口径本身有问题。
- 看重试放大系数。入口 QPS 是 100,下游收到的 QPS 是多少?如果是 300,你的重试链就有三层在放大。生产环境应该能按依赖维度看到这个比值。
六、落地清单
- 入口设一个明确的总超时预算,内部按比例分配,deadline 一路传下去,禁止在中途刷新。
- 每一层重试次数上限为 1(全链路总重试预算,而不是每层各 3 次)。
- 所有重试加指数退避 + full jitter。
- 连接池、线程池的获取等待时间纳入监控和超时控制。
- 按依赖做舱壁隔离:独立池、独立并发上限、独立熔断。
- 重试只做在幂等操作上;写操作用幂等键去重。
- 监控看 p99、重试放大系数、池等待时长,而不是平均值和 QPS。
最后一句吐槽:超时是最容易被复制粘贴、也最容易被误配的设置。它躺在配置文件里看起来像一件小事,直到某天你的服务开始用 27 秒的耐心,回应一个用户只有 3 秒的等待。
配置正确不等于行为正确。数字写对了,故事照样是错的。