3271 字
约 10 分钟
21
S17-A08 · 幂等与重试治理:失败重试的连锁成本

一次 3 秒超时,客户端按默认策略重试三次。下游原本每秒 500 个请求,两秒后变成 6000 个——它不是被流量高峰压垮的,是被上游的"容错"压垮的。失败率只有 0.3%,重试放大了十倍,把局部失败升级成全局不可用。

重试不消除失败,它只做一件事:把失败的代价转移给下游,并压缩时间轴。治理重试不是调参数,而是回答三问——谁付账、放大多少、重复执行会怎样。


1. 重试的账单由谁付

角色 得到什么 付出什么 何时开始抱怨
调用方 请求成功率上升 线程与连接占用时间变长 线程池被慢依赖占满
被调方 无 请求量放大 2–10 倍,集中在故障窗口 从降级变成宕机
共享资源 无 连接池、句柄被抢占 无关业务开始超时
用户 部分请求变快 整体 P99 变差 高峰期

关键在时机:重试发生在故障窗口,而那正是下游容量最低的时刻。目标不是"提高成功率",而是"别在最脆弱时加倍打击它"。

2. 三个致命参数

参数 常见默认 危险取值 判据
最大次数 3 无上限、"重试到成功" 次数 × 下游耗时 < 总超时预算
间隔 100ms 固定 固定间隔 = 全员同时重发 必须带抖动
退避 指数 基数过大,首退就 10 秒 首退间隔应接近对方恢复时间

固定间隔最致命:它把随机散布的失败重排成对齐的脉冲,下游看到的是方波而不是衰减的尾巴。

# 客户端重试策略骨架(数值按自己的 RT 分布压测后再定)
retry:
  max_attempts: 3
  initial_backoff: 120ms
  multiplier: 2.0
  jitter: 0.3        # ±30%,避免同步化
  retry_on: [connect_timeout, 502, 503, 504]
  no_retry_on: [400, 401, 403, 422, 429]  # 语义错误与限流不重试
  budget: 0.1        # 重试量不得超过总请求的 10%

最后一行是常被漏掉的重试预算:超预算就停止重试、直接失败。没有预算的重试,等于把熔断决策交给了随机失败率。

3. 放大倍数:层级相乘

单层放大 = 1 + (次数 - 1) × 并发重试比例
三层链路:网关 3 次 → 服务 A 3 次 → 服务 B 3 次
最坏 = 3 × 3 × 3 = 27 倍;若每层还扇出三个副本,再 ×3
链路层数 每层次数 最坏放大 结果
1 3 3× 下游多扛三倍,通常可承受
2 3 9× 故障窗口内大概率打穿连接池
3 3 27× 下游被自己的上游杀死
3 + 副本扇出 3 80× 以上 雪崩,重试即攻击

结论:全链路只允许一层重试,放在最靠近用户、最懂业务语义的那层;其余层只快速失败或熔断。

4. 幂等的三个层次

层次 保证什么 手段 代价 失效场景
接口级 同一请求重复到达结果一致 幂等键 + 去重表 一次额外写与查 键生成不稳定
业务级 同一业务意图只生效一次 状态机、唯一业务号 需要建模状态 状态被绕过更新
存储级 同一行不会被写两遍 唯一约束、乐观锁版本号 依赖 DB 能力 分库分表后约束失效

三层不是任选其一:业务级做语义、存储级做兜底。状态机管"该不该发生",唯一约束管"万一发生也进不去"。接口级去重表只是效率优化,挡不住绕过接口的写入路径。

5. 幂等键的六个生成位置

位置 生成方式 可靠性 主要风险
客户端 操作时生成 UUID 高(区分用户意图) 换设备后重复提交
客户端 SDK 拦截并附加 高 多端不同步
网关 按请求体 hash 中 同内容不同意图被合并
服务端首层 事务前生成落库 中高 上游重试时已是新键
消息生产者 消息头带业务号 高 业务号缺失退化为随机
消费端 业务主键 + 版本 高 需业务主键稳定

排序的本质是越靠近用户意图越可靠:客户端知道"连点两次"与"点了两次要两件"的区别,服务端只看到两个相同请求。键必须在最靠近意图处生成、一路透传,中间层重建即放弃幂等。

6. 至少一次投递下,幂等是前提

任何"不丢"机制都会重复:发送方不确定对面是否收到只能重发;消费方不确定是否处理完只能重投;重平衡会让已处理未提交的消息再投一次。任一条成立,"恰好一次"就只存在于单机单线程的幻想里。

推论:订阅至少一次语义的队列,消费逻辑必须幂等——这是使用该中间件的前提条件,不是最佳实践。做不到幂等,就只能选"最多一次",用丢失换掉重复。

7. 重试与超时:客户端的钟必须更快

配置组合 后果
客户端 5s,服务端 6s 客户端已超时重试,第一次仍在跑,并发两份
客户端 5s、重试 3 次、预算 10s 第二次还没发完预算就光,名义重试实际一次
客户端超时 > 服务端超时 + 排队 服务端已不回,客户端还占着连接

规则是客户端超时 < 服务端超时 < 上游总预算,单调递增。任何一环倒挂,重试都会变成并发重复执行。

# 三层超时必须单调:网关 > 服务 > 下游
gateway:    timeout: 3s
service:    timeout: 2500ms
downstream: timeout: 1800ms
# 单次重试预算 = 3s - 2500ms - 网络开销,不足则不允许重试

8. 重试与事务:先确认上一次成没成

最危险的重试是"不知道上一次是否成功"。提交后网络断了,客户端看到超时,再发一次——业务不幂等就是两笔订单。顺序固定三步:查状态 → 决定动作 → 才补偿。

1. 用业务号查权威状态(读主库或一致性读,不读缓存)
2. 已是目标态 → 直接返回成功,不重复执行
3. 处于中间态且已超时 → 走回查/补偿,不"再提交一次"
4. 查不到 → 才发起首次执行

"查"必须容忍读延迟之外的零延迟:查不到就重复执行,恰好把幂等破坏在最需要它的时刻。

9. 死信队列与人工介入的位置

位置 谁处理 触发条件 必须记录
进程内立即重试 框架 瞬时错误 无(不落盘)
队列延迟重试 消费者 依赖短暂不可用 次数、下次时间
死信队列 人或工具 超次数或不可重试错误 原始载荷、幂等键、首败时间
补偿任务 定时任务 死信积压、对账不平 补偿结果与差异明细

死信不是垃圾桶,必须具备可回放(保留载荷与幂等键)与有 owner:没人看的队列等于静默丢数据。

10. 可观测:四个必须有的口径

-- 重试指标(字段名按自己的埋点方案调整)
SELECT count(*) FILTER (WHERE attempt > 1) * 1.0 / count(*) AS retry_ratio,
       count(*) FILTER (WHERE attempt > 1 AND status = 'ok') * 1.0
         / nullif(count(*) FILTER (WHERE attempt > 1), 0) AS retry_success_rate,
       max(attempt) AS max_attempt,
       count(*) FILTER (WHERE circuit_state = 'open') AS breaker_open_cnt
FROM request_log WHERE ts > now() - interval '1 hour';
指标 回答什么 危险信号
重试比 多少请求不是一次成功 升而成功率不变 → 下游在抖
重试成功率 重试到底有没有用 低于 10% → 纯属加压
单请求最大次数 谁在无限重试 触到次数上限 → 死循环
熔断次数与时长 系统是否在自保 频繁开合 → 阈值或依赖持续坏

重试成功率低于一成时,正确动作是关掉重试:它带来的增益已小于容量损失。

11. 同步重试与异步补偿的分界

场景 同步重试 异步补偿 判据
用户等待的读 适用(1 次短退避) 不适用 用户不能等三十秒
下单、支付 仅"结果未知"时重试一次 复杂场景走补偿 需要用户可见结果
通知、推送、对账 不适用 适用 失败可延后
跨系统同步 不适用 适用(定时 + 幂等) 允许分钟级延迟

分界线是用户是否在等。在等的场景,重试次数要少到用户察觉不到;不在等的场景,可以慢、可以多次,但要幂等、有上限、能落死信。

12. 下游幂等能力未知时

判断 动作 理由
纯查询接口 可重试 无副作用
有幂等键语义 带键重试 对方承诺了
无承诺但有业务单号 重试前用单号查状态 用查询换确定性
完全黑盒的写操作 不重试,只补偿与对账 重复代价高于失败代价
金额类写操作 一律不自动重试 一次重复不可接受

判断标准不是"能不能重试",而是重复一次的最坏后果是否可接受:金额、库存、发券重复即事故;埋点、通知重复只是噪声。

13. 排错

现象 原因 处理
下游在故障窗口被打满 多层重试叠加,放大九倍以上 全链路只留一层,其余快速失败
重试多但成功率没涨 重试的是不可恢复错误 收窄 retry_on 白名单
同一订单两条记录 幂等键在服务端首层被重建 键从入口透传,全程不重建
熔断一直开合 半开探测流量过大 降探测并发,延长冷却
死信堆积无人处理 缺 owner 与回放工具 看板 + 回放脚本 + 责任人

14. 边界:不该引入幂等键的地方

场景 做法 理由
纯查询 不做幂等键 重复读无副作用
天然幂等操作(置状态、删不存在的数据) 无需键,但要有断言 重复执行结果一致
下游无状态、可任意重放 只需防重试风暴 幂等是下游给的
单机脚本、一次性任务 不做键 没有并发重复入口

最后一条值得记:天然幂等不等于免于重试风暴。置状态为 CANCELLED 没副作用,但它调用下游三次全超时,风暴照样成立。幂等管正确性,预算与退避管容量。


15. 自测

  1. 为什么说重试是成本转移,而不是消除失败?
  2. 固定间隔重试为什么形成同步风暴,抖动解决的是哪一部分?
  3. 三层链路各重试三次,最坏放大多少倍,治理动作是什么?
  4. 幂等键为什么要在最靠近用户意图处生成,中间层重建会怎样?
  5. 客户端超时、服务端超时、上游总预算三者必须满足什么关系?
  6. 什么信号出现时应该直接关掉重试?
S17-A08 · 幂等与重试治理:失败重试的连锁成本
https://www.clxhxhhr.top/posts/6185/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。