《Java 消息中间件·可靠消息与消费治理》7.1 Outbox 模式

先写库再发消息,会在进程崩溃时留下「有业务行、无事件」的孤儿数据。本篇用同库同事务的 outbox 表消除这个窗口,给出轮询发布器的完整实现,并在本机用嵌入式数据库真实 kill -9:写入 5 笔后崩溃、发送 3 条未标记就崩溃,重启补发后业务行 5、outbox 行 5、Kafka 8 条记录 5 个事件 ID,逐条对上。

双写:两个系统,没有共同的事务

业务代码里最常见的写法是「先写库,再发消息」:

loanRepository.insert(loan);          // 数据库事务
producer.send(new ProducerRecord<>("library.loan.created", loanId, payload));

这两步分属两个系统,没有共享事务。中间任何一个窗口出问题,都会留下不一致:

顺序崩溃点后果能否事后发现
先写库提交后、发送前库里有借阅,下游永远收不到事件只能靠对账扫描
先发消息发送后、写库前下游收到一个库里不存在的借阅消费者查库时才发现
先写库发送超时但实际已到达补发一次,下游收到重复靠事件 ID 去重

第一行是最麻烦的。进程被 kill -9 之后,内存里「还没发」的意图随进程一起消失,重启后的程序根本不知道要补哪一条。你可以在业务表上加一个 published 标志,但那又回到了双写:业务行和标志位仍然可能在不同事务里更新。

Outbox 模式解决的就是这个「意图丢失」问题:把「要发什么」先持久化到与业务数据同一个库、同一个事务里。发送动作可以晚一点、失败重试、进程重启,只要 outbox 行还在,就一定能补发。

先看反例:孤儿业务行

本机实验里有一个 naive-hold 模式,模拟「写库成功、发送前崩溃」:插入一条借阅,提交,然后停在发送之前,等外部 kill -9。

$ ./run.sh -Doutbox.db=/tmp/mqrel7-outbox/naive OutboxLab naive-hold
[naive] business row L-2001 committed; HOLD before send; kill -9 me
$ kill -9 <pid>
$ ./run.sh -Doutbox.db=/tmp/mqrel7-outbox/naive OutboxLab verify
[verify] business_loan=1 outbox=0 published=0 unpublished=0

business_loan=1,outbox=0。库里有一条借阅,Kafka 上没有对应事件,也没有任何一张表记录「L-2001 的事件还没发」。要修,只能写一个扫描全表、比对下游的对账任务;对账任务本身还要知道「哪些业务行应该产生事件」,这等于把业务规则复制了一遍。

同库同事务:把发送意图落盘

Outbox 的表结构很朴素,一行就是一条待发事件:

CREATE TABLE business_loan (
  loan_id     VARCHAR(32) PRIMARY KEY,
  book_id     VARCHAR(32) NOT NULL,
  member_id   VARCHAR(32) NOT NULL,
  status      VARCHAR(16) NOT NULL,
  created_at  TIMESTAMP   NOT NULL
);

CREATE TABLE outbox (
  id           BIGINT AUTO_INCREMENT PRIMARY KEY,
  event_id     VARCHAR(64) NOT NULL UNIQUE,
  aggregate_id VARCHAR(32) NOT NULL,
  event_type   VARCHAR(64) NOT NULL,
  payload      CLOB        NOT NULL,
  created_at   TIMESTAMP   NOT NULL,
  published    BOOLEAN     NOT NULL DEFAULT FALSE
);

写入时两个 INSERT 在同一个本地事务里:

c.setAutoCommit(false);
try {
    insertLoan(c, loan);       // 业务表
    insertOutbox(c, event);    // 同一连接、同一事务
    c.commit();                // 唯一的原子决策点
} catch (SQLException e) {
    c.rollback();
    throw e;
}

关键不在代码长短,而在提交语义:要么业务行和 outbox 行一起可见,要么都不可见。不存在「业务成功、事件丢失」的中间态。outbox 行里的 event_id 还要建唯一约束,它既是下游去重的键,也防止同一个事件被业务代码重复写入。

本机验证了事务的共同命运:先插入一条新业务行,再插入一个已存在的 event_id 制造唯一键冲突,然后回滚。

[rollback] outbox insert failed: 23505 Unique index or primary key violation:
"PUBLIC.CONSTRAINT_INDEX_8 ON PUBLIC.OUTBOX(EVENT_ID NULLS FIRST) VALUES ( /* 1 */ 'evt-1001' )"
[rollback] business_loan has L-3001 = false

outbox 插入失败,业务行也没有留下。如果两个写操作不在同一事务里,这里就会看到「业务行存在、outbox 缺失」,也就是前面那种孤儿数据。

轮询发布器:先确认,再标记

outbox 行只是意图,还需要一个 relay 把它送到 Kafka。最小实现是轮询未发布的行:

try (Connection c = open();
     PreparedStatement q = c.prepareStatement(
             "SELECT id, event_id, payload FROM outbox WHERE published = FALSE ORDER BY id");
     ResultSet rs = q.executeQuery()) {
    while (rs.next()) {
        long id = rs.getLong(1);
        String eventId = rs.getString(2);
        String payload = rs.getString(3);
        RecordMetadata md = producer.send(
                new ProducerRecord<>(TOPIC, eventId, payload)).get(10, TimeUnit.SECONDS);
        try (PreparedStatement u = c.prepareStatement(
                "UPDATE outbox SET published = TRUE WHERE id = ?")) {
            u.setLong(1, id);
            u.executeUpdate();
        }
    }
}

四个决定值得说明:

  1. 先 send().get() 拿到 broker 确认,再更新 published。 顺序反了就是「标记成功、消息没发」,重启后不会补发,直接丢事件。
  2. 用 event_id 做消息 key。 同一聚合的事件落到同一分区,分区内有序;下游也能按 key 去重。
  3. 标记是独立事务。 发送和标记无法原子,中间崩溃会产生重复,这是 at-least-once 的固有窗口,下一篇会展开。
  4. 一次只处理有限条。 生产实现要加 LIMIT、批大小和发送超时,否则一条慢消息会拖住整个 relay。

还有一个实现细节:上面在同一个连接的 ResultSet 上边读边更新。H2 允许这样做;MySQL 默认会在流式结果集上抛错,PostgreSQL 也要求用完再发新语句。生产实现应改成分页查询(WHERE id > ? ORDER BY id LIMIT ?),或者先收集一批 id 再逐条更新。

注意这里没有使用 Kafka 事务。Kafka 事务能保证「消费—处理—生产」的原子性,但解决不了「数据库提交」与「消息发送」的原子性:数据库不在 Kafka 的事务里。Outbox 用本地事务换取发送的最终一致,代价是 relay 必须是至少一次。

本机实测:两次 kill -9 后的对账

环境:Temurin 25.0.5+8,Kafka 4.3.0 KRaft 单节点,嵌入式 H2 2.3.232。实验里用文件模式的 H2,只是为了让它熬过进程重启;生产中把 JDBC 换成你自己的业务库,INSERT ... / UPDATE ... 的语义不变。下面是同一轮实验的原始输出,未做删改。

第一次崩溃:写入 5 笔,未发布

$ ./run.sh -Doutbox.db=/tmp/mqrel7-outbox/outbox OutboxLab reset
[reset] schema ready db=/tmp/mqrel7-outbox/outbox
$ ./run.sh -Doutbox.db=/tmp/mqrel7-outbox/outbox OutboxLab write-hold 5
[write] txn i=1 loan=L-1001 event=evt-1001 committed business=1 outbox=1
[write] txn i=2 loan=L-1002 event=evt-1002 committed business=2 outbox=2
[write] txn i=3 loan=L-1003 event=evt-1003 committed business=3 outbox=3
[write] txn i=4 loan=L-1004 event=evt-1004 committed business=4 outbox=4
[write] txn i=5 loan=L-1005 event=evt-1005 committed business=5 outbox=5
[write] committed all, HOLD before publish; kill -9 me
$ kill -9 79956
$ ./run.sh -Doutbox.db=/tmp/mqrel7-outbox/outbox OutboxLab verify
[verify] business_loan=5 outbox=5 published=0 unpublished=5

进程被强杀后,业务行与 outbox 行都是 5。没有一条消息被发出,但 5 条发送意图都还在,unpublished=5 就是待补发的数量。

第二次崩溃:发送 3 条,标记前被杀

$ ./run.sh -Doutbox.db=/tmp/mqrel7-outbox/outbox OutboxLab publish-hold 3
[send] outboxId=1 key=evt-1001 partition=0 offset=0
[send] outboxId=2 key=evt-1002 partition=0 offset=1
[send] outboxId=3 key=evt-1003 partition=0 offset=2
[publish-hold] sent=3, NOT marked published; HOLD before mark; kill -9 me
$ kill -9 <pid>
$ ./run.sh -Doutbox.db=/tmp/mqrel7-outbox/outbox OutboxLab verify
[verify] business_loan=5 outbox=5 published=0 unpublished=5

三条消息已经到了 broker(offset 0、1、2),但 published 仍是 0。这是发送与标记之间的窗口:broker 已经收下,数据库还不知道。下次启动会把这三条再发一遍。

第三次启动:补发并标记

$ ./run.sh -Doutbox.db=/tmp/mqrel7-outbox/outbox OutboxLab publish 10
[publish] outboxId=1 key=evt-1001 offset=3 marked=1
[publish] outboxId=2 key=evt-1002 offset=4 marked=2
[publish] outboxId=3 key=evt-1003 offset=5 marked=3
[publish] outboxId=4 key=evt-1004 offset=6 marked=4
[publish] outboxId=5 key=evt-1005 offset=7 marked=5
[publish] sent=5 marked=5 unpublished-left=0
$ ./run.sh -Doutbox.db=/tmp/mqrel7-outbox/outbox OutboxLab verify
[verify] business_loan=5 outbox=5 published=5 unpublished=0

最终对账

消费整个主题,按 key 统计:

$ docker exec mqrel7-kafka sh -c 'unset JMX_PORT; /opt/kafka/bin/kafka-console-consumer.sh \
    --bootstrap-server 127.0.0.1:9092 --topic v2c7.outbox --from-beginning \
    --timeout-ms 4000 --formatter-property print.key=true --formatter-property key.separator=,'
evt-1001,{"eventId":"evt-1001","loanId":"L-1001","bookId":"B-101"}
evt-1002,{"eventId":"evt-1002","loanId":"L-1002","bookId":"B-102"}
evt-1003,{"eventId":"evt-1003","loanId":"L-1003","bookId":"B-103"}
evt-1001,{"eventId":"evt-1001","loanId":"L-1001","bookId":"B-101"}
evt-1002,{"eventId":"evt-1002","loanId":"L-1002","bookId":"B-102"}
evt-1003,{"eventId":"evt-1003","loanId":"L-1003","bookId":"B-103"}
evt-1004,{"eventId":"evt-1004","loanId":"L-1004","bookId":"B-104"}
evt-1005,{"eventId":"evt-1005","loanId":"L-1005","bookId":"B-105"}
账目数值
业务表行数5
outbox 行数5
published=TRUE5
Kafka 记录数8
Kafka 不同事件 ID 数5
重复的 IDevt-1001、evt-1002、evt-1003 各 2 次

业务侧没有任何丢失:5 笔借阅对应 5 个事件,全部到达。代价是 3 条重复,正好是第二次崩溃前已发送、未标记的那一批。

这里要诚实指出:Kafka 的幂等生产者不能消除这 3 条重复。幂等生产者保证的是「同一个 producer 会话内、同一分区、重试不产生重复」,而这次重复跨越了进程重启,producer.id 和序列号都重新开始。消除重复只能靠下游按 eventId 去重,也就是 2.2 讲的幂等消费。

Outbox 把问题变成了「可重放的至少一次」

把三个实验连起来看,可靠性边界很清楚:

阶段崩溃后状态是否丢事件是否重复
写库 + outbox 事务内全部回滚否,业务也没提交否
提交后、发送前outbox 行还在否,重启补发否
发送后、标记前outbox 行还在否,重启再发一次是
标记后已发布否否

Outbox 没有让系统变成恰好一次,它做的是把「不可恢复的丢失」换成「可去重的重复」。前者需要人工对账,后者只需要一个唯一键。在工程上,这是划算的交易:去重表或下游唯一约束是常规能力,而对账补数往往是事故后的应急手段。

反过来也要说清楚它不解决什么:

  • 不保证下游不重复,重复窗口在发送与标记之间客观存在。
  • 不保证顺序跨聚合。同一 aggregate_id 用同一 key 才有分区内顺序;不同聚合之间本来就没有全局顺序。
  • 不解决 outbox 表膨胀。已发布的行要归档或删除,否则轮询会越来越慢。
  • 不解决多实例 relay 的竞争。两个 relay 同时扫到同一行会各发一次,重复放大;要用行锁、SKIP LOCKED 或按 id 取模分片。

发布器的工程细节

最小实现能跑通语义,离生产还有一段距离。下面这些点按重要性排序:

问题症状处理
表无限增长轮询扫描变慢,索引膨胀已发布行按时间归档;或分区表按天丢弃
多 relay 抢同一行重复发送成倍增加SELECT ... FOR UPDATE SKIP LOCKED,或按 id 哈希分片
单条毒行一直发送失败,堵住后面的行重试上限 + 跳过 + 告警,别无限重试
发送超时relay 卡住,unpublished 持续上涨设 delivery.timeout.ms 与单条超时,超时留给下轮
顺序错乱同一聚合的 created 晚于 returned同一聚合固定 key,单 relay 内按 id 顺序发
监控缺失不知道有没有积压暴露 unpublished 行数与最老未发布行的年龄

监控优先看「最老未发布行的年龄」,而不是行数。行数受业务量影响,年龄直接回答「最老的一条意图等了多久」。这个年龄超过阈值,说明 relay 挂了或 Kafka 不可用,与消费者 lag 是两回事。

还有一个常见误区:把 outbox 当成「消息表」,在业务事务里顺便写业务详情。outbox 只该放发送所需的最小信息:事件 ID、聚合 ID、类型、payload、创建时间。业务查询请回业务表,别让 relay 的扫描拖上大字段。

与 CDC 的分工

Outbox 要求业务代码显式写事件;CDC 则是从数据库日志或变更列里「偷看」数据变化,业务代码可以不改。两者的取舍在 7.2 展开,这里先给结论:

维度OutboxCDC
业务改造成本每个写路径都要加 outbox 插入查询轮询版要加版本列;日志挖掘版不改代码
事件语义由业务定义,字段稳定跟随表结构,DDL 会传导到事件
删除业务显式发删除事件硬删除在查询轮询里不可见
适用领域事件、跨服务契约数据同步、缓存失效、数仓入湖

两者也能组合:outbox 表本身就是一张普通表,CDC 可以只订阅 outbox 表,省掉轮询。这是很多团队的最终形态,代价是要维护一个 CDC 组件。

本节判据

  1. 「先写库再发消息」到底丢在哪? 答:提交成功、发送前进程崩溃,发送意图只存在于内存,重启后没有任何持久记录可以补发。
  2. Outbox 为什么必须与业务表同库同事务? 答:只有同一个本地事务才能保证业务行与 outbox 行同生共死,消除「有业务无事件」的中间态。
  3. 发布器为什么先确认再标记? 答:反过来的话,标记成功而消息未到达时会永久丢事件;先确认后标记最多产生重复。
  4. Kafka 幂等生产者能消除 outbox 的重复吗? 答:不能。跨进程重启后 producer 会话与序列号重置,重复只能由下游按事件 ID 去重。
  5. outbox 表该怎么清理? 答:已发布行按时间归档或分区丢弃,同时监控最老未发布行的年龄,别让扫描成本随历史线性增长。

阅读导航:上一节 6.3 毒消息隔离与人工干预 | 下一节 7.2 CDC:增量快照与变更捕获

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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