《Java 消息中间件·可靠消息与消费治理》9.2 补偿事务的幂等

补偿会被重试、重放和并发再次执行;补偿本身不幂等时,一次失败会变成两次超还。本篇在 libraryd 库存场景里分别跑无条件反向更新、WHERE 守卫和补偿令牌三种写法,贴出同一条补偿执行两次的真实差异,并演示补偿服务持续不可用时进入人工队列、恢复后幂等重放的完整路径。

补偿为什么会重复

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;
        }
    }
}

令牌表的主键就是幂等键。三个设计要点:

  1. compId 必须由业务确定,形如 comp-<sagaId>-<step>。如果每次重试生成一个新的 UUID,主键永远不冲突,令牌表就退化成一张无用的日志。
  2. 登记和执行在同一个本地事务里。如果先提交令牌、再执行更新,中间崩溃会出现「令牌说补过了、实际没补」;反过来则会出现「补了两次、令牌只有一条」。同一个数据库才能保证两者同生共死。
  3. 令牌表要能长期保留。至少覆盖「补偿可能被重放」的最长时间窗;如果人工队列可能一周后才处理,令牌就得留一周以上。按时间分区、过期归档,不要随手 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 守卫是10(扣了别人的)否
令牌表否01(正确)是

补偿失败了怎么办

幂等只解决「重复执行不坏」,不解决「执行不了」。库存服务持续不可用时,补偿会一直失败。这时不能让编排器无限重试,也不能假装成功。

本实验的兜底路径是:有限重试 → 人工队列 → 人工恢复后幂等重放。

$ ./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判断是瞬时故障还是逻辑错误
statusPENDING / RESOLVED / ABANDONED
owner + created_at谁在负责、等了多久
resolved_at + resolution怎么解决的,供事后复盘

几条规则:

  1. 按「最老 PENDING 的年龄」告警,不要只按条数。条数受业务量影响,年龄直接回答「有没有事情被忘掉」。
  2. 不要自动无限重试。重试预算耗尽就是预算耗尽,继续重试只会把故障期拉长,还会掩盖真正的问题。
  3. 重放必须走同一条幂等路径,不允许为「着急上线」写一条一次性 SQL。
  4. ABANDONED 要留原因。有些补偿确实无法执行(比如对应商品已下架),这时要记录业务决策,而不是悄悄删行。
  5. 队列行不删除。归档可以,删除不行;审计时要能回答「这条 Saga 最后怎么了」。

补偿的边界

即使幂等做对了,补偿仍有几个绕不开的限制。

不可逆的副作用要放到最后

发短信、发优惠券、调用外部支付退款,这些动作没有可靠的反向操作。设计 Saga 时,把不可逆动作放在所有可补偿步骤之后;前面的步骤全部成功后再执行它。如果它之后的步骤失败,你只能选择「接受这个副作用」或「人工处理」,不能假装能撤销。

补偿是业务语义,不是物理撤销

cancelLoan 把状态改成 CANCELLED,不是删除行;releaseInventory 写一笔释放,不是把时间倒回。审计、对账、客服查询都依赖这些「撤销痕迹」。把补偿写成物理删除,等于把审计线索一起删了。

补偿顺序不一定严格逆序

严格逆序最安全,但如果两个补偿之间没有依赖,顺序可以调整以缩短不一致窗口。判断标准是:交换顺序后,是否存在一个中间状态违反业务约束。释放库存和取消借阅之间没有硬约束,先释放能更早恢复可售库存;但先取消借阅、后释放库存会让「已取消但库存仍占用」的窗口变长。选择哪一种,取决于你更在意哪段时间的哪个指标。

补偿也可能被并发执行

两个线程同时重放同一条补偿,令牌表的主键冲突会让其中一个失败并回滚,这是正确的。但如果补偿动作本身是「读-改-写」而不是单条 SQL,事务隔离级别不够时会丢失更新。补偿实现要尽量落成一条带条件的 UPDATE,或者显式加行锁。

落地清单

  1. 每条补偿都有确定性幂等键:comp-<sagaId>-<step>,不要用随机 UUID。
  2. 令牌登记与补偿执行同库同事务,两者同生共死。
  3. 补偿 SQL 带条件,但不把 WHERE 当幂等方案;守卫只防越界,不防重复。
  4. 重试预算小而明确:2–3 次,指数退避,耗尽即进人工队列。
  5. 人工队列有主、有年龄、有告警,重放走同一段幂等代码。
  6. 不可逆动作放流程末尾,并明确它不属于可补偿范围。
  7. 监控补偿的 skipped 比例与 MANUAL_PENDING 年龄,这两个指标比成功率更早暴露问题。

与 9.1 的衔接

9.1 的编排器负责「什么时候补」,本篇负责「补的时候怎么保证只补一次」。两者合起来才构成可恢复的 Saga:

  • 编排器持久化状态,决定补偿范围与顺序;
  • 补偿动作幂等,允许编排器安全重试;
  • 重试耗尽后进人工队列,人工重放仍走幂等路径。

缺任何一环,Saga 都只停留在演示代码的水平:没有状态机,崩溃后不知道补到哪;没有幂等,重试会把数据改坏;没有人工队列,补偿失败就永远悬着。

本节判据

  1. 补偿为什么天然会重复? 答:超时重试、崩溃恢复、人工重放、至少一次投递、定时任务重叠都会再次触发同一条补偿。
  2. WHERE reserved >= 1 为什么不够? 答:它只能防减成负数,不能区分这份预留属于哪个 Saga,重复执行会扣掉别人的预留。
  3. 补偿令牌的主键应该怎么生成? 答:由业务确定,如 comp-<sagaId>-<step>;每次重试生成新 UUID 会让去重失效。
  4. 补偿重试耗尽后应该进入什么状态? 答:MANUAL_PENDING,并把命令、失败原因、重试次数写入人工队列,不能标成已回滚。
  5. 人工重放为什么要走同一段补偿代码? 答:只有复用同一幂等路径,重复点击、脚本重跑和并发操作才安全;一次性修复 SQL 会绕过去重。

阅读导航:上一节 9.1 Saga:编排与协同 | 下一节 9.3 最终一致性的度量

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收