一次 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. 自测
- 为什么说重试是成本转移,而不是消除失败?
- 固定间隔重试为什么形成同步风暴,抖动解决的是哪一部分?
- 三层链路各重试三次,最坏放大多少倍,治理动作是什么?
- 幂等键为什么要在最靠近用户意图处生成,中间层重建会怎样?
- 客户端超时、服务端超时、上游总预算三者必须满足什么关系?
- 什么信号出现时应该直接关掉重试?