双写:两个系统,没有共同的事务
业务代码里最常见的写法是「先写库,再发消息」:
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();
}
}
}
四个决定值得说明:
- 先
send().get()拿到 broker 确认,再更新published。 顺序反了就是「标记成功、消息没发」,重启后不会补发,直接丢事件。 - 用
event_id做消息 key。 同一聚合的事件落到同一分区,分区内有序;下游也能按 key 去重。 - 标记是独立事务。 发送和标记无法原子,中间崩溃会产生重复,这是 at-least-once 的固有窗口,下一篇会展开。
- 一次只处理有限条。 生产实现要加
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=TRUE | 5 |
| Kafka 记录数 | 8 |
| Kafka 不同事件 ID 数 | 5 |
| 重复的 ID | evt-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 展开,这里先给结论:
| 维度 | Outbox | CDC |
|---|---|---|
| 业务改造成本 | 每个写路径都要加 outbox 插入 | 查询轮询版要加版本列;日志挖掘版不改代码 |
| 事件语义 | 由业务定义,字段稳定 | 跟随表结构,DDL 会传导到事件 |
| 删除 | 业务显式发删除事件 | 硬删除在查询轮询里不可见 |
| 适用 | 领域事件、跨服务契约 | 数据同步、缓存失效、数仓入湖 |
两者也能组合:outbox 表本身就是一张普通表,CDC 可以只订阅 outbox 表,省掉轮询。这是很多团队的最终形态,代价是要维护一个 CDC 组件。
本节判据
- 「先写库再发消息」到底丢在哪? 答:提交成功、发送前进程崩溃,发送意图只存在于内存,重启后没有任何持久记录可以补发。
- Outbox 为什么必须与业务表同库同事务? 答:只有同一个本地事务才能保证业务行与 outbox 行同生共死,消除「有业务无事件」的中间态。
- 发布器为什么先确认再标记? 答:反过来的话,标记成功而消息未到达时会永久丢事件;先确认后标记最多产生重复。
- Kafka 幂等生产者能消除 outbox 的重复吗? 答:不能。跨进程重启后 producer 会话与序列号重置,重复只能由下游按事件 ID 去重。
- outbox 表该怎么清理? 答:已发布行按时间归档或分区丢弃,同时监控最老未发布行的年龄,别让扫描成本随历史线性增长。
阅读导航:上一节 6.3 毒消息隔离与人工干预 | 下一节 7.2 CDC:增量快照与变更捕获
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。