从一次线上事故聊起:为什么你的重试机制可能正在放大故障
从一次线上事故聊起:为什么你的重试机制可能正在放大故障
上周三凌晨两点,我被一条告警电话叫醒:核心支付链路错误率飙升到 40%。爬起来看监控,第一反应是某个下游服务挂了。但奇怪的是,下游服务的健康检查全是绿的,QPS 却在几分钟内翻了五倍。
最后定位到原因:某个调用方在超时后疯狂重试,而重试又打满了下游的连接池,导致原本健康的请求开始超时,进而触发更多重试。一个原本只影响 1% 请求的偶发超时,被重试机制放大成了一场雪崩。
这件事让我想聊聊重试这个看起来简单、实际上坑很深的话题。
重试的本质:一个被低估的放大器
大多数人对重试的理解是"失败了再试一次,提高成功率"。这个直觉在单请求视角下没错,但在系统视角下,重试是一个流量放大器。
假设下游有 10% 的请求超时,如果每个失败请求重试 3 次,那么下游实际承受的流量会变成:
正常流量 + 失败流量 × 重试次数 = 1 + 0.1 × 3 = 1.3 倍
看起来还好。但如果下游已经开始过载,超时率上升到 50%,同样的重试策略会让流量变成:
1 + 0.5 × 3 = 2.5 倍
这就是正反馈循环的起点:下游越慢 → 超时越多 → 重试越多 → 下游越慢。学术界管这叫"重试风暴"(retry storm),是分布式系统里最经典的故障放大模式之一。
三个必须做对的设计决策
1. 退避策略:别用固定间隔
最糟糕的重试实现是 sleep(1) 然后重试。固定间隔意味着所有失败的请求会在同一时刻再次涌向下游,形成周期性的流量尖峰。
指数退避(exponential backoff)是标配,但很多人只做了指数增长,忽略了抖动(jitter)。没有抖动的指数退避,会让同一批请求的重试时间点高度对齐,退避了个寂寞。
一个简单有效的做法是 full jitter:
def backoff(attempt, base=0.1, cap=10):
# 指数增长 + 全抖动
return random.uniform(0, min(cap, base * (2 ** attempt)))
random.uniform(0, ...) 把重试时间点打散到整个区间,这是 AWS 那篇著名的《Exponential Backoff and Jitter》里推荐的做法,实测对削峰效果显著。
2. 重试预算:给重试设一个总闸
单个请求的重试次数上限是不够的。真正需要控制的是全局重试比例。
思路很简单:维护一个滑动窗口,统计最近一段时间内"首次请求"和"重试请求"的数量。当重试请求占比超过阈值(比如 10%),就直接拒绝新的重试,让失败快速暴露给上游。
这就是所谓的 retry budget。它的价值在于:正常情况下重试能提升成功率,异常情况下重试不会成为压垮下游的最后一根稻草。gRPC 的 retryThrottling 和 Envoy 的 retry_budget 都是这个思路。
3. 只重试"值得重试"的错误
不是所有失败都该重试。一个 400 Bad Request 重试一百次还是 400,只会浪费资源。需要区分:
- 可重试:超时、连接拒绝、502/503/504、限流(429,但要配合 Retry-After)
- 不可重试:4xx 客户端错误、业务逻辑错误、幂等性无法保证的写操作
最后一点尤其关键。非幂等操作的重试是事故的常见来源。一个"创建订单"的请求超时了,你不知道是没创建成功还是创建成功但响应丢了。盲目重试可能产生重复订单。这类场景要么用幂等键(idempotency key)保证安全,要么干脆不重试、交给上层补偿。
一个容易被忽略的细节:重试的位置
重试发生在哪一层,效果天差地别。
如果 SDK、网关、服务框架、业务代码各自都有一层重试,那么一次请求实际可能被放大 3 × 3 × 3 = 27 倍。这种"重试叠加"在微服务架构里非常隐蔽,因为每一层单独看都配置得很合理。
实践中的建议是:重试尽量收敛到一层,通常在客户端 SDK 或服务网格的出口侧统一处理,业务代码里显式声明的重试要能被观测到。至少要做到:所有重试都打上标记(比如 x-retry-count header),在链路追踪里能一眼看出这次请求被重试了几次。
回到那次事故
我们最后的修复方案其实不复杂:
- 把固定间隔重试改成带 jitter 的指数退避
- 加了一个全局重试预算,重试占比超过 15% 就熔断重试
- 在网关层统一了重试策略,干掉业务代码里散落的三处重试
- 给所有重试请求加了 header 标记,接入监控
改完之后,同样的偶发超时再也没能演变成雪崩。下游 QPS 在故障期间的峰值从 5 倍降到了 1.3 倍。
小结
重试是一个典型的"默认直觉会骗人"的设计。它的收益在单请求视角下很直观,但它的风险在系统视角下才会显现。写重试代码的时候,值得多问自己几个问题:
- 这个操作幂等吗?
- 失败是暂时的还是永久的?
- 如果下游已经过载,我的重试会让它更糟吗?
- 系统里有几层重试在叠加?
想清楚这几个问题,比抄一段重试模板代码重要得多。
如果这篇对你有帮助,欢迎讨论。你们线上遇到过重试相关的事故吗?
- 点赞
- 收藏
- 关注作者
评论(0)