06-幂等-重试-失败补偿

幂等、重试、失败补偿

如果说前几篇解决的是“任务怎么跑起来”,那这一篇解决的是“任务跑坏了怎么办”。

长链路系统一定会遇到这些现实问题:

  • 用户重复点击提交按钮
  • 消息被重复投递
  • Worker 执行到一半崩了
  • 外部服务暂时超时
  • 某一步已经产生副作用,后面步骤却失败了

所以一个能上线的任务系统,必须认真处理三件事:

  1. 幂等:重复来一次,不能把结果搞乱
  2. 重试:临时失败,要有机会自动恢复
  3. 失败补偿:已经做出去的副作用,要能收回来或修正掉

先说幂等:为什么它是基础

幂等的直白定义是:

同一个请求或同一条消息被重复执行多次,最终结果应该和执行一次一致。

这件事在长链路里尤其重要,因为重复非常常见:

  • 用户刷新页面又点了一次提交
  • 网关重试了 HTTP 请求
  • 消息队列把同一条消息投递了两次
  • Worker 超时后,任务又被重新拉起

如果没有幂等,结果可能是:

  • 建了两条任务
  • 扣了两次余额
  • 发了两次通知
  • 写了两份重复数据

幂等通常要做在哪几层

1. 提交层幂等

比如前端提交任务时带一个 idempotencyKey

POST /tasks
Idempotency-Key: upload_abc_001

后端如果发现这个 key 已经创建过任务,就直接返回原来的 taskId

2. 消费层幂等

Worker 消费消息时,即使拿到重复消息,也要先检查:

  • 这个任务是不是已经成功了
  • 这个步骤是不是已经完成了
  • 这个副作用是不是已经落过库了

3. 外部副作用幂等

如果你要调扣费、发券、发送通知、创建第三方任务,也要尽量带业务唯一键,避免重复执行。

一个简单案例:AI 报告生成并扣减额度

假设用户点一次“生成报告”,流程是:

  1. 创建任务
  2. 扣减 1 次额度
  3. 调模型生成报告
  4. 保存结果
  5. 通知用户

如果消息重复消费了两次,而系统没有幂等,就可能:

  • 扣两次额度
  • 生成两份报告
  • 发两次通知

所以你至少要保证:

  • 同一个 taskId 只会扣一次额度
  • 同一个 taskId 成功后再次执行直接跳过

再说重试:不是所有失败都值得重试

重试的作用是处理暂时性错误,比如:

  • 网络抖动
  • 外部服务超时
  • 短暂的资源不足
  • 下游服务瞬时 5xx

但有些错误重试也没用,比如:

  • 参数非法
  • 文件格式不支持
  • 业务校验失败
  • 权限不足

可以简单分成两类:

错误类型 是否重试
网络超时、临时 5xx 可以重试
参数错误、业务拒绝 不要重试

重试应该怎么做

工程上比较常见的策略:

  • 限制最大重试次数
  • 使用指数退避
  • 加一点随机抖动,避免同一时间雪崩重试

例如:

  • 第 1 次失败,5 秒后重试
  • 第 2 次失败,30 秒后重试
  • 第 3 次失败,2 分钟后重试
  • 超过次数后进入死信或人工处理

可以用状态机理解:

stateDiagram-v2
	[*] --> QUEUED
	QUEUED --> RUNNING
	RUNNING --> SUCCESS
	RUNNING --> FAILED
	FAILED --> RETRYING : 可重试错误
	RETRYING --> QUEUED
	FAILED --> COMPENSATING : 不可恢复或超限
	COMPENSATING --> COMPENSATED
	COMPENSATED --> [*]
	SUCCESS --> [*]

这里的重点是:

  • FAILED 不等于立刻结束
  • 有些失败会进入 RETRYING
  • 有些失败要进入 COMPENSATING

失败补偿是什么

失败补偿不是“数据库事务回滚”的放大版。

在长链路里,很多副作用已经发出去了,比如:

  • 已经扣了额度
  • 已经创建了第三方任务
  • 已经写了部分中间结果

这时做不到真正原子回滚,只能做补偿动作

例如:

  • 扣过额度后失败了,就退回额度
  • 创建过第三方任务后失败了,就调用取消接口
  • 写过临时文件后失败了,就清理临时文件

所以补偿的本质是:

用一个反向业务动作,把系统尽量拉回一致状态。

一个补偿案例

还是用“AI 报告生成”举例。

假设流程是:

  1. 创建任务
  2. 扣减额度
  3. 调模型生成
  4. 上传结果文件

如果执行到第 3 步时模型服务长时间失败,且超过最大重试次数,那么系统可以这样补偿:

  1. 标记任务失败
  2. 退回之前扣掉的额度
  3. 清理临时文件
  4. 给用户一个明确失败原因

这就比“任务失败了,但额度也没了”要合理得多。

幂等、重试、补偿之间的关系

这三者不是孤立的,而是一条链。

flowchart LR
	A[收到请求或消息] --> B[先做幂等检查]
	B --> C[执行任务]
	C --> D{是否成功}
	D -- 是 --> E[结束]
	D -- 否 --> F{是否可重试}
	F -- 是 --> G[按策略重试]
	F -- 否 --> H[执行补偿]
	G --> C
	H --> I[失败结束]

这张图想表达的是:

  • 没有幂等,重试越多越容易出错
  • 没有重试,暂时失败就会直接放大成业务失败
  • 没有补偿,失败后的副作用会长期残留

初学者最容易踩的坑

1. 看到失败就一律重试

这样会把永久错误变成无限资源浪费。

2. 只做接口幂等,不做消费幂等

消息重复投递时照样会出问题。

3. 认为补偿一定能完全回到原点

很多场景只能“尽量恢复一致”,而不是绝对回滚。

4. 重试没有上限

最后死循环占满系统资源。

这一篇要记住的核心点

  1. 幂等是长链路系统的前提,不是优化项
  2. 重试只针对暂时性错误,且要有次数、退避和终点
  3. 失败补偿是对已经发生的副作用做反向修正,不是魔法回滚

最后一篇再把视角拉高一点:当系统真的跑起来之后,如何通过日志、指标、链路追踪来观察它。

github