补偿为什么会重复
9.1 的编排器在失败后会调用补偿。很多人写补偿时只想着「把正向动作反过来」,默认它只执行一次。实际系统里,同一条补偿至少会被五种力量重复触发:
| 触发源 | 重复形态 | 典型征兆 |
|---|---|---|
| 补偿调用超时 | 服务端已执行,调用方认为失败并重试 | 第一次 affected=1,第二次仍 affected=1 |
| 补偿后、写日志前崩溃 | 重启恢复重放整段补偿 | saga_step 里没有 OK 记录,但业务已回滚 |
| 人工队列重放 | 运维点了两次「重试」,或脚本重复跑 | 同一 saga_id 的补偿记录出现多条 |
| 补偿命令走消息主题 | 至少一次投递,消费者重启后从旧位移重读 | 补偿命令的 offset 回退 |
| 定时对账任务重叠 | 两个实例同时扫到同一条待补偿记录 | 同一时刻两条相同 SQL 并发执行 |
这五种重复有一个共同点:它们都不是 bug,而是可靠性机制的副作用。重试是为了熬过瞬时故障,重放是为了从崩溃中恢复,至少一次投递是消息系统的基本语义。你不能通过「小心一点」消除它们,只能让补偿本身可重复执行。
重试和补偿不是一回事
这两个词经常被混用,但方向相反:
| 维度 | 重试正向动作 | 执行补偿 |
|---|---|---|
| 目标 | 把没做成的做成 | 把已经做成的撤销 |
| 动作方向 | 与上次相同 | 与正向相反 |
| 失败的含义 | 目标仍未达成,可以稍后再试 | 状态未知,可能需要人工判断 |
| 副作用 | 可能重复创建业务对象 | 可能重复释放资源 |
| 幂等要求 | 正向动作幂等 | 补偿动作幂等 |
| 终止方式 | 重试预算耗尽后进死信或告警 | 重试预算耗尽后进人工队列 |
| 能否无限重试 | 不能,会放大下游压力 | 更不能,会持续破坏数据 |
关键区别在最后一列:正向重试失败,最坏结果是「事情没做成」;补偿重试失败,最坏结果是「数据被改坏」。所以补偿的重试预算应该比正向更保守,而且必须先保证幂等,再允许重试。顺序反了,重试就是在放大错误。
三种写法,三种结果
用一个最小场景:某本书 B-201 初始 available=0、reserved=1,这 1 份预留属于 Saga S-9100 的第 2 步。现在要执行补偿「释放这份预留」。
写法一:无条件反向更新
UPDATE inventory
SET available = available + 1,
reserved = reserved - 1
WHERE book_id = 'B-201'
执行两次会怎样?第一次把 reserved 从 1 减到 0,第二次从 0 减到 -1。库存不会变成负数被数据库拦住,因为列上没有 CHECK (reserved >= 0);即使有,第二次也会直接抛异常,让补偿卡住。两种结果都不可接受。
写法二:只加 WHERE 守卫
UPDATE inventory
SET available = available + 1,
reserved = reserved - 1
WHERE book_id = 'B-201' AND reserved >= 1
这能防止减成负数,但防不住「扣别人的预留」。假设 reserved=2:其中 1 份属于本 Saga,1 份属于另一个还没完成的 Saga。第一次补偿后 reserved=1,守卫仍然成立;第二次补偿继续扣,把别人的预留释放了。WHERE 只能表达「当前总量够不够扣」,不能表达「这份预留是不是我扣的」。
写法三:补偿令牌表
正确做法是先登记「这条补偿已经执行过」,再执行:
static Outcome compensateWithToken(String sagaId, String step, String bookId)
throws SQLException {
String compId = "comp-" + sagaId + "-" + step;
try (Connection c = open()) {
c.setAutoCommit(false);
try {
try (PreparedStatement ins = c.prepareStatement(
"INSERT INTO compensation_log VALUES (?, ?, ?)")) {
ins.setString(1, compId);
ins.setString(2, step);
ins.setTimestamp(3, Timestamp.from(Instant.now()));
ins.executeUpdate();
} catch (SQLException duplicate) {
c.rollback();
return new Outcome(false, true, 0); // 已经补偿过
}
int affected;
try (PreparedStatement upd = c.prepareStatement(
"UPDATE inventory SET available = available + 1, reserved = reserved - 1 "
+ "WHERE book_id = ? AND reserved >= 1")) {
upd.setString(1, bookId);
affected = upd.executeUpdate();
}
c.commit();
return new Outcome(true, false, affected);
} catch (SQLException e) {
c.rollback();
throw e;
}
}
}
令牌表的主键就是幂等键。三个设计要点:
compId必须由业务确定,形如comp-<sagaId>-<step>。如果每次重试生成一个新的 UUID,主键永远不冲突,令牌表就退化成一张无用的日志。- 登记和执行在同一个本地事务里。如果先提交令牌、再执行更新,中间崩溃会出现「令牌说补过了、实际没补」;反过来则会出现「补了两次、令牌只有一条」。同一个数据库才能保证两者同生共死。
- 令牌表要能长期保留。至少覆盖「补偿可能被重放」的最长时间窗;如果人工队列可能一周后才处理,令牌就得留一周以上。按时间分区、过期归档,不要随手
DELETE。
Outcome 里带 skipped 字段,是为了让调用方能区分「这次真的执行了」和「之前已经执行过」。监控上这两个数的比例很有用:skipped 持续偏高,说明上游在重复投递补偿命令。
本机实测:同一条补偿跑两次
环境:Temurin 25.0.5+8,H2 2.3.232(文件模式,让状态跨进程保留),单次运行。命令:
$ export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
$ javac --release 25 -cp h2-2.3.232.jar -d out src/CompensationLab.java
$ ./run.sh CompensationLab reset
$ ./run.sh CompensationLab naive
无条件更新:reserved 变成 -1
[naive] before: B-201 available=0 reserved=1
[naive] compensation #1 affected=1 -> B-201 available=1 reserved=0
[naive] compensation #2 affected=1 -> B-201 available=2 reserved=-1
[naive] verdict: 第二次把不属于本次借阅的 1 本也「还」了回去
第二次 affected=1,说明 SQL 完全「成功」——数据库没有任何异常,业务上却多还了一本。这类错误不会触发告警,只会在一段时间后表现为「库存比实际多」。
只加 WHERE:扣掉了别人的预留
为了暴露守卫的边界,把初始状态改成 reserved=2,其中 1 份属于本 Saga、1 份属于另一个 Saga:
[guard] before: B-201 available=0 reserved=2(1 个属于本 saga,1 个属于另一个 saga)
[guard] compensation #1 affected=1 -> B-201 available=1 reserved=1
[guard] compensation #2 affected=1 -> B-201 available=2 reserved=0
[guard] verdict: WHERE 只能防「减成负数」,防不住重复扣别人的预留
两次都命中,第二份预留被错误释放。如果此时另一个 Saga 也来补偿,它会发现 reserved=0,守卫让它的补偿变成空操作——错误被吞掉,没有任何一方报错。
令牌表:第二次直接跳过
同样的初始状态,换成令牌方案:
[token] before: B-201 available=0 reserved=2(1 个属于本 saga,1 个属于另一个 saga)
[token] compensation #1 tokenInserted=true skipped=false affected=1 -> B-201 available=1 reserved=1
[token] compensation #2 tokenInserted=false skipped=true affected=0 -> B-201 available=1 reserved=1
[token] verdict: 第二次在令牌表主键冲突处停下,库存不再变化
第二次连 UPDATE 都没执行,affected=0,状态停在 available=1、reserved=1,正好是「只释放了自己那一份」。这才是可重试的补偿。
三种写法的对照:
| 写法 | 第二次是否执行 | 第二次 affected | 最终 reserved | 能否安全重试 |
|---|---|---|---|---|
| 无条件更新 | 是 | 1 | -1 | 否 |
| WHERE 守卫 | 是 | 1 | 0(扣了别人的) | 否 |
| 令牌表 | 否 | 0 | 1(正确) | 是 |
补偿失败了怎么办
幂等只解决「重复执行不坏」,不解决「执行不了」。库存服务持续不可用时,补偿会一直失败。这时不能让编排器无限重试,也不能假装成功。
本实验的兜底路径是:有限重试 → 人工队列 → 人工恢复后幂等重放。
$ ./run.sh CompensationLab reset
$ ./run.sh CompensationLab failover
两次重试都遇到连接拒绝:
[failover] before: B-201 available=0 reserved=1
[failover] compensation attempt 1/2 FAIL Connection refused: inventory-service:8081
[failover] compensation attempt 2/2 FAIL Connection refused: inventory-service:8081
[failover] saga S-9201 -> MANUAL_PENDING
[failover] after: B-201 available=0 reserved=1(业务状态未回滚,等待人工)
[manual_queue] id=1 saga=S-9201 step=reserveInventory status=PENDING reason=retry exhausted after 2: Connection refused: inventory-service:8081
[saga_compensation] saga=S-9201 status=MANUAL_PENDING attempts=2
注意 after 那一行:库存仍然是 reserved=1,业务状态没有回滚。这是诚实的失败——系统没有把「补不了」伪装成「补好了」。MANUAL_PENDING 会一直亮在面板上,直到有人处理。
服务恢复后,运维执行重放:
$ ./run.sh CompensationLab replay
[replay] operator starts; compensation service is back
[replay] pending=1
[replay] queueId=1 tokenInserted=true skipped=false affected=1 -> B-201 available=1 reserved=0
[replay] after: B-201 available=1 reserved=0
[manual_queue] id=1 saga=S-9201 step=reserveInventory status=RESOLVED reason=retry exhausted after 2: Connection refused: inventory-service:8081
[saga_compensation] saga=S-9201 status=COMPENSATED attempts=2
再执行一次同样的重放:
[replay] operator starts; compensation service is back
[replay] pending=0
[replay] after: B-201 available=1 reserved=0
队列已经空了,库存没有变化。重放走的是和第一次完全相同的 compensateWithToken,所以重复点按钮、脚本重跑、两个运维同时操作,结果都一样。如果人工重放走一条「特殊修复 SQL」,幂等性就断了——这是人工兜底最常见的破口。
人工队列的设计
最小的人工队列只需要一张表,但字段要足以让人在不翻日志的情况下判断:
| 字段 | 作用 |
|---|---|
saga_id + step | 定位是哪条 Saga 的哪一步 |
command | 要执行的补偿动作与参数,能直接重放 |
idempotency_key | 与正常补偿共用,重放时靠它去重 |
attempts + last_error | 判断是瞬时故障还是逻辑错误 |
status | PENDING / RESOLVED / ABANDONED |
owner + created_at | 谁在负责、等了多久 |
resolved_at + resolution | 怎么解决的,供事后复盘 |
几条规则:
- 按「最老 PENDING 的年龄」告警,不要只按条数。条数受业务量影响,年龄直接回答「有没有事情被忘掉」。
- 不要自动无限重试。重试预算耗尽就是预算耗尽,继续重试只会把故障期拉长,还会掩盖真正的问题。
- 重放必须走同一条幂等路径,不允许为「着急上线」写一条一次性 SQL。
ABANDONED要留原因。有些补偿确实无法执行(比如对应商品已下架),这时要记录业务决策,而不是悄悄删行。- 队列行不删除。归档可以,删除不行;审计时要能回答「这条 Saga 最后怎么了」。
补偿的边界
即使幂等做对了,补偿仍有几个绕不开的限制。
不可逆的副作用要放到最后
发短信、发优惠券、调用外部支付退款,这些动作没有可靠的反向操作。设计 Saga 时,把不可逆动作放在所有可补偿步骤之后;前面的步骤全部成功后再执行它。如果它之后的步骤失败,你只能选择「接受这个副作用」或「人工处理」,不能假装能撤销。
补偿是业务语义,不是物理撤销
cancelLoan 把状态改成 CANCELLED,不是删除行;releaseInventory 写一笔释放,不是把时间倒回。审计、对账、客服查询都依赖这些「撤销痕迹」。把补偿写成物理删除,等于把审计线索一起删了。
补偿顺序不一定严格逆序
严格逆序最安全,但如果两个补偿之间没有依赖,顺序可以调整以缩短不一致窗口。判断标准是:交换顺序后,是否存在一个中间状态违反业务约束。释放库存和取消借阅之间没有硬约束,先释放能更早恢复可售库存;但先取消借阅、后释放库存会让「已取消但库存仍占用」的窗口变长。选择哪一种,取决于你更在意哪段时间的哪个指标。
补偿也可能被并发执行
两个线程同时重放同一条补偿,令牌表的主键冲突会让其中一个失败并回滚,这是正确的。但如果补偿动作本身是「读-改-写」而不是单条 SQL,事务隔离级别不够时会丢失更新。补偿实现要尽量落成一条带条件的 UPDATE,或者显式加行锁。
落地清单
- 每条补偿都有确定性幂等键:
comp-<sagaId>-<step>,不要用随机 UUID。 - 令牌登记与补偿执行同库同事务,两者同生共死。
- 补偿 SQL 带条件,但不把
WHERE当幂等方案;守卫只防越界,不防重复。 - 重试预算小而明确:2–3 次,指数退避,耗尽即进人工队列。
- 人工队列有主、有年龄、有告警,重放走同一段幂等代码。
- 不可逆动作放流程末尾,并明确它不属于可补偿范围。
- 监控补偿的
skipped比例与MANUAL_PENDING年龄,这两个指标比成功率更早暴露问题。
与 9.1 的衔接
9.1 的编排器负责「什么时候补」,本篇负责「补的时候怎么保证只补一次」。两者合起来才构成可恢复的 Saga:
- 编排器持久化状态,决定补偿范围与顺序;
- 补偿动作幂等,允许编排器安全重试;
- 重试耗尽后进人工队列,人工重放仍走幂等路径。
缺任何一环,Saga 都只停留在演示代码的水平:没有状态机,崩溃后不知道补到哪;没有幂等,重试会把数据改坏;没有人工队列,补偿失败就永远悬着。
本节判据
- 补偿为什么天然会重复? 答:超时重试、崩溃恢复、人工重放、至少一次投递、定时任务重叠都会再次触发同一条补偿。
WHERE reserved >= 1为什么不够? 答:它只能防减成负数,不能区分这份预留属于哪个 Saga,重复执行会扣掉别人的预留。- 补偿令牌的主键应该怎么生成? 答:由业务确定,如
comp-<sagaId>-<step>;每次重试生成新 UUID 会让去重失效。 - 补偿重试耗尽后应该进入什么状态? 答:
MANUAL_PENDING,并把命令、失败原因、重试次数写入人工队列,不能标成已回滚。 - 人工重放为什么要走同一段补偿代码? 答:只有复用同一幂等路径,重复点击、脚本重跑和并发操作才安全;一次性修复 SQL 会绕过去重。
阅读导航:上一节 9.1 Saga:编排与协同 | 下一节 9.3 最终一致性的度量
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。