凌晨两点,告警群开始炸。监控面板上流量曲线几乎是平的——QPS 只涨了 30%,但下游服务的错误率从 0.1% 一路飙到 80%。更诡异的是,这波流量不是一次性的,它像涟漪一样,每隔两分钟涨一次,一次比一次高。
查了三个小时,最后在网关的配置里找到了元凶:
// 网关 -> 服务A:超时 10s
// 服务A -> 数据库:超时 10s
// 数据库池 wait 队列:无限制
// 服务A -> 服务B:超时 10s,重试 3 次
// 服务B -> 服务C:超时 10s,重试 3 次
每一个单独看都"很安全"。合在一起,是一个完美的自杀公式。
这篇不聊怎么加机器,怎么调线程池。我们只聊一件事:超时和重试,是分布式系统里最容易写出"局部正确、整体致命"的代码。它不像慢查询那样有火焰图可查,也不像内存泄漏那样有 profiler 现场。它是一种沉默的、结构性的放大器。
一、超时不是保护,是许可证
先说一个反直觉的事实:超时设得越长,系统越不安全。
因为超时决定了"一个失败请求最多能占用多少资源"。设 10 秒,意味着一次下游卡死可以同时占住 10 秒的连接、线程、内存槽位。一个服务有 200 个并发槽位,下游卡死时,10 秒超时就足以让 200 个请求全部占满,全部排队,10 秒内这个服务对外表现为"完全挂死"。
而设 500 毫秒,同样的场景下,卡死的请求会被迅速掐掉,槽位能以每秒两轮的速度回收,服务虽然 degraded,但还活着。
超时的本质不是"等多久算失败",而是"允许系统慢到什么程度还剩一口气"。
所以超时值的第一原则不是拍脑袋定 3 秒,而是从整体预算反推。
超时预算:像钱一样分配
一个请求的总预算通常是有限的,比如 5 秒。在这 5 秒里,调用链上每一层都必须分摊预算,而不是各自宣称"给我 10 秒"。
总预算 5000ms
├── 网关: 5000ms(总闸门)
├── 服务A: 4800ms(扣掉网关开销)
│ ├── 调用服务B: 3000ms
│ │ ├── 调用服务C: 2000ms
│ │ └── 数据库: 800ms
│ └── 本地缓存: 200ms
└── 兜底返回: 剩余
关键规则三条:
- 下游预算永远小于上游。上游比下游多留的那部分,是留给网络抖动、序列化、GC 的余量。
- 超时必须留出重试的总量约束。如果一次调用有 3 次重试,那单次超时应该是
预算 / (尝试次数 + 1),而不是预算 / 3——因为重试是叠加在原始尝试上的。 - 兜底必须有。缓存、降级、默认值、限流熔断返回,至少要有一个。超时链的尽头是"没有答案",那就是错误。
二、重试的幂律:你的 10% 流量会变成 300%
重试是好事,它是掩盖偶发抖动的止痛药。但重试有一个残酷的数学性质:它在故障时会把流量放大,而在恢复时会制造脉冲。
设调用链上有 n 层,每层重试 r 次。理论上放大倍数:
放大倍数 = (1 + r)^n
单层 r=3: 4 倍
两层: 16 倍
三层: 64 倍
四层: 256 倍
这就是为什么"每个服务都重试 3 次"看起来无害,实际是灾难。一个下游数据库变慢 30 秒,上游三层链路各重试 3 次,结果是数据库收到的查询量是正常值的 256 倍。
更要命的是恢复时的脉冲。数据库刚刚恢复,所有被积压的请求同时涌回来,而此时各个客户端的重试计时器刚好同时归零——因为它们是同步启动、同时超时的。结果是数据库刚缓过一口气,又被自己人的重试流量打死。这就是著名的 thundering herd(惊群),雪崩从"变慢"变成"反复暴毙"。
三、没有 Jitter 的指数退避,等于没有退避
标准退避公式:
delay = base * 2^attempt
看起来很美。但问题在于:所有客户端的 attempt 是同步的。1000 个客户端同时失败,它们会同时在 1s 后重试、同时在 3s 后重试、同时在 7s 后重试。退避并没有把请求打散,只是把它们从"同时一次"变成"同时几次",尖峰高度没变。
// 有问题的退避:所有客户端同时重试
time.Sleep(time.Duration(base * (1 << attempt)) * time.Millisecond)
// 带抖动的退避:把请求随机打散
func backoff(base time.Duration, attempt int) time.Duration {
max := base * time.Duration(1<<attempt)
// full jitter: 在 [0, max] 里随机
return time.Duration(rand.Int63n(int64(max)) + 1)
}
Go 官方早就给出了这个建议:Full Jitter(AWS 架构博客里那篇经典的 "Exponential Backoff And Jitter"),而不是简单的 "Equal Jitter"。区别在于:
- Full Jitter:
random(0, cap)——峰值最低,分布最均匀 - Equal Jitter:
cap/2 + random(0, cap/2)——保证最小延迟但仍留尖峰 - 不加抖动:
cap——完全同步,最糟
另外两个反直觉的细节:
- 重试次数应该递减,而不是恒定。下游越不健康,越应该少发请求。很多 SDK 提供"重试预算"机制——比如"重试流量不超过正常流量的 10%"——这比固定次数更贴合真实系统。
- 不要重试非幂等操作。这不是本文重点,但值得重复:重试一个 POST 可能就是重复扣款。幂等键、请求去重,必须在重试之前就位。
四、熔断不是保险丝,是投票器
很多人把熔断理解成"下游报错就断开"。这只是表象。熔断器的真实作用是用可控的失败替换不可控的失败——与其让请求挂在那里等超时(占资源、拖垮自己),不如快速失败 + 降级。
type Breaker struct {
failures int
threshold int
state int // 0=closed 1=open 2=half-open
}
func (b *Breaker) Allow() bool {
switch b.state {
case 0: return true
case 1: return false // 熔断中,直接拒绝
case 2:
// half-open:只放行少量探测请求
if b.failures < 3 { return true }
b.state = 0 // 探测成功,恢复
b.failures = 0
return false
}
return true
}
这里最容易翻车的是 half-open 的惊群。熔断器打开后,过一段时间放几个请求去探测下游是否恢复——如果你放 100 个,那就不是探测,是第二次雪崩。正确的做法是放 1 到 3 个,其余全部拒绝。熔断器的状态转换必须用带抖动的定时器,而不是固定间隔,否则所有实例会在同一时刻集体 half-open。
还有一点常被忽略:熔断粒度。按"服务"熔断太粗,下游一个不重要的接口挂了,整个服务全断;按"接口"熔断太细,熔断器数量爆炸。实践中的折中是"服务 + 错误类型"——比如只对超时和 5xx 计数,对 4xx(业务错误)不计数。否则你的参数校验错误会把熔断器打开,然后所有人请求都失败,问题反而更大。
五、超时发生在哪,比超时是多少更重要
一个请求的耗时可能花在很多地方:DNS 解析、TCP 握手、TLS 握手、发送、等待响应、读响应体。你设的 5 秒超时,是覆盖全部还是只覆盖"等待响应"?
这是 Go 里最经典的坑:
// 这段代码:ReadTimeout 在读响应体时可能不生效
resp, err := client.Do(req) // 等待响应头,受 ReadTimeout 约束
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) // 读响应体,可能永久阻塞!
因为很多 HTTP 客户端的 `ReadTimeout` 只覆盖到"读响应头",读 body 的超时需要单独设置。攻击者只要返回一个 `Content-Length: 1000000` 然后慢慢吐字节,你的连接就被永久占住——这就是经典的 Slowloris 攻击,也是慢速客户端拖垮服务的经典路径。
Go 的对策是给 context 设 deadline,并且在整个请求生命周期(包括 body 读取)都带着这个 context:
ctx, cancel := context.WithTimeout(parent, 800*time.Millisecond)
defer cancel()
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil { return nil, err }
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) // 同样受 ctx 控制
if err != nil { return nil, err }
更彻底的做法是显式设置 deadline:
if d, ok := ctx.Deadline(); ok {
resp.Body = &deadlineBody{
ReadCloser: resp.Body,
deadline: d,
}
}
还有就是 deadline 传播。如果服务 A 调服务 B 时,B 又调 C,A 给 B 的 deadline 是 1 秒,B 应该给 C 的 deadline 是 800ms 而不是自己的 10 秒。否则 A 早已超时返回了,B 和 C 还在傻乎乎地跑,白白消耗资源。gRPC 原生支持 deadline 传播,HTTP 需要自己塞 header(X-Deadline 或 gRPC 的 grpc-timeout)。
六、排障清单
下次再遇到"流量没涨但服务挂了",按这个顺序查:
- 画出完整超时链。每一跳的超时值、是否大于上游预算,一眼就能看出矛盾。
- 算重试放大倍数。
(1+r)^n,超过 10 就该砍。 - 看退避有没有 jitter。没有就加上。
- 查连接池等待时间。很多"超时"其实是池排队超时,池大小和超时不匹配时,排队会吃掉整个预算。
- 看熔断的 half-open 放行数量。大于 3 就是隐患。
- 查有没有"孤儿"请求:上游已经放弃,下游还在跑。这类请求不会体现在错误率上,但会实打实消耗 CPU。
- 确认超时的错误类型可观测。
context deadline exceeded和connection reset在监控里如果混在一起,你永远查不清是哪个环节超时。
分布式系统里最贵的 bug,从来不是"某个地方算错了",而是"每个地方都做对了,组合起来却会死"。超时和重试就是这样一对冤家:一个为了韧性,一个为了可用性,叠在一起却制造了最大的脆弱性。
写代码的时候,多问自己一句:如果我下游此刻完全死掉,我这条链路会在几秒内彻底失去响应能力?如果答案是"说不清",那你的超时就是拍脑袋定的。
以及:失败可以,但不能同步失败。所有随机化的东西——退避、熔断探测、负载均衡——都要带抖动。这句话我能刻在键盘上。