"重试三次,指数退避加抖动"——这句话几乎进了每一份客户端配置模板。上线初期它确实管用:偶发的一次连接重置被重试消化,失败率降了一个数量级,之后没人再动它。半年后一次下游故障,同一条策略把上游线程池占满,一次局部超时升级为整条链路不可用。
配置没变,变的是环境:这条策略一直在服务两种完全不同的失败,而其中只有一个被服务好了。
故障分两种:偶发抖动(下游健康,只是某次调用运气不好)与依赖故障(下游正在病中)。前者要快速重试把概率抹平,后者要快速放弃把压力撤走——两个方向的正确策略完全相反。共用一套策略,通常的结果是两个都没做到。
1. 两种动机的判据
| 维度 | 偶发抖动 | 依赖故障 |
|---|---|---|
| 现象 | 孤立失败,前后请求都正常 | 连续失败,错误率随负载上升 |
| 失败概率 | 千分之几,与流量无关 | 随并发上升,成明显正反馈 |
| 时间尺度 | 毫秒到秒,一次重试就可能过 | 分钟到小时,重试窗口内不会恢复 |
| 正确动作 | 快速重试,短退避,大抖动 | 快速放弃,降级或排队 |
| 重试的价值 | 高:一次重试能把成功率抬一个量级 | 负:给正在过载的系统继续加压 |
| 正确参数 | 次数少、间隔短、抖动大 | 次数 0–1、总预算短、优先熔断 |
| 判据信号 | 单次超时但连接建立正常 | 连续同类错误 / 错误率骤升 |
| 谁受益 | 调用方的成功率 | 谁都不受益,除了"看起来试过了" |
同一份模板,两种场景想要的值几乎逐项相反:
// 抖动场景:下游健康,想用重试把概率抹平
max_attempts: 3
initial_backoff: 50ms
jitter: 0.5 // 抖动要覆盖全部退避级别,不能只抖首次
budget: 0.05 // 重试量不得超过总请求的 5%
// 故障场景:下游病中,想尽快停止打它
max_attempts: 1 // 等于不重试,交给熔断兜底
budget: 0.005 // 几乎不允许重试
// 用一套值同时服务两种场景,等于对其中一个说谎
判据落到错误分类上,比落到次数上更稳:
| 错误类别 | 典型例子 | 该有的策略 |
|---|---|---|
| 可重试(瞬时) | 连接超时、连接重置、502 / 503 / 504 | 有限次 + 短退避 + 全级别抖动 |
| 不可重试(语义) | 400、401、403、422、业务校验失败 | 一次都不重试,直接返回 |
| 不可重试(过载信号) | 429、对方明确的熔断响应 | 尊重拒绝,不重试 |
| 未知(结果不明) | 无响应、读到一半断开 | 仅幂等时可重试,否则走补偿 |
把错误分成四类,收益通常大于把次数从 3 调到 5:不可重试的错误被重试,是在制造重复副作用;把 429 当抖动重试,是把对方的保护动作当成了偶发失败。
2. 一套策略只能服务一个动机
| 配置偏向 | 抖动场景 | 故障场景 | 净结果 |
|---|---|---|---|
| 按抖动配(3 次 + 短退避) | 达标 | 流量放大 2–4 倍(典型量级),故障窗口被拉长 | 局部故障升级为链路故障 |
| 按故障配(1 次 + 长熔断) | 偶发失败不被消化,成功率停在低位 | 达标 | 长期可用性偏低,被归因为"下游不稳" |
| 折中(2 次 + 中等退避) | 略差 | 仍然放大 | 两个目的都只做到一半 |
折中是最危险的一档:它同时损失两边,却让两边都"看起来做过了"。评审时"有重试配置"这个事实会被当成已完成,实际效果无人复核。
退避与抖动的真实作用不是礼貌,是把同一时刻的重试错开:
// 固定间隔为什么最坏:它把随机散布的失败重排成对齐脉冲
// t=0.0s 1000 个请求同时失败
// t=0.1s 1000 个重试同时到达下游(方波,不是衰减的尾巴)
// t=0.2s 第二波与第一波的残留在下游叠加
// 加抖动后:失败在 0–0.1s 内均匀散开,峰值降到约 1/N
// 结论:抖动解决"重试对齐",不解决"总量放大"——总量由次数与预算决定
这两件事经常被混为一谈,于是出现"我已经加了抖动了"这种回答——它回答的是脉冲问题,不是放大问题。
"重试次数"这个参数几乎总是被拍脑袋定,原因是它是唯一一个"调大了看起来更稳"的参数:短期成功率确实上升,代价要等下一次故障才结算,而结算时它已经不在改动记录的上下文里了。
3. 被牺牲的是"快速放弃"
共用一套策略时,被牺牲的一定是依赖故障期的快速放弃——也就是"少打一拳"的能力。抖动场景的需求是显性的(成功率会掉),故障场景的需求是隐性的(不要在别人病的时候继续打),而机制只会朝显性的那一侧优化。
先算清放大倍数,它是这件事唯一硬的部分:
// 放大按层相乘,不是相加
// 单层 3 次 = 3 倍;三层串联 = 3 × 3 × 3 = 27 倍
// 再叠加超时错配:上游给 30s、单次耗时 3s → 允许 10 次而不是 3 次
// 实际放大 = min(次数配置, 超时预算 / 单次耗时) × 并发 × 层数
// 目标:故障窗口内到达下游的 QPS 不应超过其容量的 60%,否则重试本身成为故障原因
它不会被发现,因为三件事同时成立:
- 重试成功率是个混合指标。抖动期重试成功率很高(典型量级 80% 以上),故障期接近于零,两个数混在一个均值里,永远显示"还行"。要发现异常,必须按错误类别拆开看。
- 放大倍数没有单独的账。故障期的流量上涨会被解释成客户端重试、监控抓取、缓存击穿,很少有人把它拆出来归因给服务间的重试配置。
- 归因被推给下游。下游看到的是自己过载,于是扩容;扩出来的余量又被重试吃掉,下次故障更深,结论继续写成"容量不足"。重试策略从头到尾没进过评审。
本质是:重试把失败的成本从调用方转移给被调方,而账单不在被调方手里。谁付账,谁才有资格定策略;现在付账的人没有配置权。
超时关系是重试的数学前提:
// 上下游超时必须满足同一条不等式,否则重试在数学上不可能成功
// 上游超时 < 下游超时 → 上游先放弃,重试才有窗口
// 上游超时 ≥ 下游超时 → 上游还在等,重试只是叠加并发
// 单次预算 = 上游超时 - 已用时间;不足一次往返则不允许再试
// 三类超时要一起核对:连接超时、读超时、含重试的总超时
熔断与重试的分工:熔断决定"要不要试",重试决定"试几次"。没有熔断的重试是没有刹车的油门;而熔断阈值若按吞吐绝对值设定,抖动期会被误开,所以阈值要基于错误率与连续失败计数。
非幂等操作的重试不是效率问题,是正确性问题:扣款、扣库存、发券在"结果未知"时不能用重试解决,要用查询确认加补偿(反向操作)。重试在这里一次都不该有。
// 四个口径,缺一个就无法区分两种动机
retry_rate // 重试请求数 / 总请求数
retry_success_rate // 必须按错误类别拆分,总体均值无意义
amplification_factor // 故障窗口内到达下游的 QPS / 正常期 QPS
timeout_mismatch_count // 上游超时 ≥ 下游超时的链路数,应为 0
4. 排错
| 现象 | 可能原因 | 验证方式 | 处置方向 |
|---|---|---|---|
| 故障期下游被打满,上游却不忙 | 重试放大且无预算上限 | 对比故障窗口与正常期到达下游的 QPS | 加重试预算与熔断,次数降到 1 |
| 重试成功率很低但配置没动过 | 重试在服务故障而非抖动 | 按错误类别拆分重试成功率 | 故障期禁用重试 |
| 上游已超时,下游日志显示仍在处理 | 上游超时小于下游超时 | 对齐三层超时配置 | 超时单调 + 预算含重试 |
| 同一笔扣款出现两次 | 非幂等操作被重试 | 查幂等键与补偿记录 | 改用确认 + 补偿 |
| 加了抖动仍然出现脉冲 | 抖动只作用于首次退避 | 看重试时间戳直方图 | 抖动覆盖全部退避级别 |
| 429 数量持续上升 | 把过载信号当成抖动重试 | 统计 429 前的重试次数 | 429 归入不可重试 |
5. 边界
| 场景 | 结论 | 理由 |
|---|---|---|
| 调用方只有一两个、下游稳定 | 简单重试就够 | 放大倍数上限低,无人串联叠加 |
| 下游容量冗余大(常态利用率低) | 可以宽容重试 | 放大被余量吸收,代价可测 |
| 单次调用成本极低(本地缓存、无状态查询) | 重试近乎无成本 | 放大的是廉价资源,不是稀缺连接 |
| 非幂等且无补偿手段 | 一次都不重试 | 正确性优先于成功率 |
| 强实时的人机交互链路 | 至多 1 次,总预算极短 | 用户等不起,重试挤占体验预算 |
| 离线批处理 | 慢重试 + 长退避 + 死信队列 | 时间尺度是小时,不需要"快速放弃" |
// 最小可用的分档:按调用链深度给不同的重试权,而不是全链路同一套
// 边缘(面向用户) :1 次,总预算 < 2s
// 中间层(服务间) :0 次,交给熔断与降级
// 后端(异步/批处理) :3 次,退避起步 1s,进死信
// 原则:重试权集中在离用户最远、成本最低、幂等最容易保证的那一层
6. 自测
- 偶发抖动与依赖故障,在"重试次数"这个参数上的最优值为什么相反?
- 三层调用每层重试 3 次,放大倍数是多少?为什么不是 9 而是更高?
- 抖动解决的是哪个问题?它不解决哪个问题?
- 熔断与重试各自回答什么问题?只有其中一个会怎样?
- 为什么"重试成功率"这个指标单独看没有意义?该怎么拆?
- 什么情况下重试不是效率问题而是正确性问题?那时候该用什么替代?