3090 字
约 10 分钟
16
S17-A19 · 重试的两种动机:偶发抖动与依赖故障被同一套策略处理

"重试三次,指数退避加抖动"——这句话几乎进了每一份客户端配置模板。上线初期它确实管用:偶发的一次连接重置被重试消化,失败率降了一个数量级,之后没人再动它。半年后一次下游故障,同一条策略把上游线程池占满,一次局部超时升级为整条链路不可用。

配置没变,变的是环境:这条策略一直在服务两种完全不同的失败,而其中只有一个被服务好了。

故障分两种:偶发抖动(下游健康,只是某次调用运气不好)与依赖故障(下游正在病中)。前者要快速重试把概率抹平,后者要快速放弃把压力撤走——两个方向的正确策略完全相反。共用一套策略,通常的结果是两个都没做到。


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%,否则重试本身成为故障原因

它不会被发现,因为三件事同时成立:

  1. 重试成功率是个混合指标。抖动期重试成功率很高(典型量级 80% 以上),故障期接近于零,两个数混在一个均值里,永远显示"还行"。要发现异常,必须按错误类别拆开看。
  2. 放大倍数没有单独的账。故障期的流量上涨会被解释成客户端重试、监控抓取、缓存击穿,很少有人把它拆出来归因给服务间的重试配置。
  3. 归因被推给下游。下游看到的是自己过载,于是扩容;扩出来的余量又被重试吃掉,下次故障更深,结论继续写成"容量不足"。重试策略从头到尾没进过评审。

本质是:重试把失败的成本从调用方转移给被调方,而账单不在被调方手里。谁付账,谁才有资格定策略;现在付账的人没有配置权。

超时关系是重试的数学前提:

// 上下游超时必须满足同一条不等式,否则重试在数学上不可能成功
// 上游超时 < 下游超时 → 上游先放弃,重试才有窗口
// 上游超时 ≥ 下游超时 → 上游还在等,重试只是叠加并发
// 单次预算 = 上游超时 - 已用时间;不足一次往返则不允许再试
// 三类超时要一起核对:连接超时、读超时、含重试的总超时

熔断与重试的分工:熔断决定"要不要试",重试决定"试几次"。没有熔断的重试是没有刹车的油门;而熔断阈值若按吞吐绝对值设定,抖动期会被误开,所以阈值要基于错误率与连续失败计数。

非幂等操作的重试不是效率问题,是正确性问题:扣款、扣库存、发券在"结果未知"时不能用重试解决,要用查询确认加补偿(反向操作)。重试在这里一次都不该有。

// 四个口径,缺一个就无法区分两种动机
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. 自测

  1. 偶发抖动与依赖故障,在"重试次数"这个参数上的最优值为什么相反?
  2. 三层调用每层重试 3 次,放大倍数是多少?为什么不是 9 而是更高?
  3. 抖动解决的是哪个问题?它不解决哪个问题?
  4. 熔断与重试各自回答什么问题?只有其中一个会怎样?
  5. 为什么"重试成功率"这个指标单独看没有意义?该怎么拆?
  6. 什么情况下重试不是效率问题而是正确性问题?那时候该用什么替代?
S17-A19 · 重试的两种动机:偶发抖动与依赖故障被同一套策略处理
https://www.clxhxhhr.top/posts/6365/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。