卷二讲了模式,这一节讲落地
outbox 的思路一句话能说完:把「业务数据修改」和「待发消息」放进同一个本地事务,再由一个独立的中继把消息发出去。卷二第 7 章讲的是它为什么成立、和双写相比强在哪。这一节回答另外三个问题:
- 表到底怎么建,哪些字段是必须的?
- 中继怎么发,崩在中间会留下什么?
- 发出去之后,怎么知道「该发的都发了、该收的都收了」?
第三个问题的答案是对账,而它比前两个更容易被跳过。大多数团队把 outbox 写完、压测通过就上线了,直到某天发现读模型少了一条记录,才回头补对账任务。这一节把对账和 outbox 放在一起讲,因为它们本来就是一体的:outbox 保证「正常情况下不丢」,对账负责「异常情况下能被发现」。
三张表与一条铁律
完整的链路涉及三张表,分别属于三个阶段:
| 表 | 属于谁 | 记录什么 | 谁写 |
|---|---|---|---|
biz_loan | 写侧业务库 | 借阅的当前状态 | 借阅服务 |
outbox | 写侧业务库 | 待发布的事件(与业务行同事务) | 借阅服务 |
read_model_loan | 读侧投影库 | 从事件推导出的视图 | 消费者 |
outbox 与 biz_loan 在同一个库,这是铁律。跨库就不存在「同一个本地事务」,outbox 立刻退化成双写,白做。
建表语句(H2 方言,换成 MySQL/PostgreSQL 只需改类型):
CREATE TABLE biz_loan (
loan_id VARCHAR(16) PRIMARY KEY,
book_id VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL, -- ON_LOAN / RETURNED / OVERDUE
version INT NOT NULL, -- 聚合版本,事件必须带同一个版本
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE outbox (
event_id VARCHAR(32) PRIMARY KEY, -- 业务生成,不是自增
loan_id VARCHAR(16) NOT NULL,
event_type VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL, -- 事件里的业务状态
version INT NOT NULL,
state VARCHAR(8) NOT NULL, -- PENDING / SENT
created_at TIMESTAMP NOT NULL
);
CREATE TABLE read_model_loan (
loan_id VARCHAR(16) PRIMARY KEY,
status VARCHAR(16) NOT NULL,
version INT NOT NULL,
last_event_id VARCHAR(32), -- 最后应用的事件,用于排查
updated_at TIMESTAMP NOT NULL
);
有三个字段值得单独解释,它们都是踩过坑之后才会加的:
event_id由业务生成,不用自增。 自增在主从切换、批量重放时会重号;重号的事件在消费端会被幂等表当成重复丢掉,直接导致数据丢失。version跟着聚合走。 它是后面判断「库和事件是否一致」的唯一依据。没有版本号,对账只能比内容,而内容字段一多就无法可靠比较。state只有两个值。 不要设计成PENDING/SENDING/FAILED/DEAD,中继的失败重试不需要状态机,PENDING没发成功就是没发成功,重试即可。状态越多,卡在中间态的记录越多。
中继:至少一次,别装成恰好一次
中继是一个独立进程,循环做三件事:取一批 PENDING、发到 Kafka、把这批标成 SENT。
while (running) {
List<Row> batch = outbox.pollPending(200); // 取一批待发记录
for (Row r : batch) {
producer.send(new ProducerRecord<>(TOPIC, r.loanId(), r.payload()));
}
producer.flush(); // 全部确认后再标记
outbox.markSent(batch); // 单独的短事务
if (batch.isEmpty()) Thread.sleep(200);
}
这段代码有一个无法消除的窗口:flush() 返回成功、markSent 之前进程崩溃。重启后这批记录仍是 PENDING,会被再发一次。
这不是 bug,是 outbox 的语义决定的:发送和标记不可能原子,所以中继只能保证至少一次。 想把它做成恰好一次,就得让 Kafka 的事务覆盖数据库写入——而上一节已经说过,那需要 XA,成本比问题本身高。
所以正确的做法是承认重复,在消费端消掉:
| 重复来源 | 表现 | 消费端对策 |
|---|---|---|
| 中继崩溃在 send 与 mark 之间 | 同一 event_id 出现两次 | 按 event_id 幂等,唯一键冲突即忽略 |
| 生产者重试 | 同一批记录重复投递 | 幂等生产者 + 业务幂等键双保险 |
| 消费者位移回退 | 一段事件被重放 | 幂等写入或按 version 覆盖 |
还有一个容易被忽略的点是顺序。中继并发发送时,同一笔借阅的两条事件可能乱序到达。解决办法不是在中继里加全局锁,而是让同一 loan_id 的事件用同一个 key、进同一个分区——Kafka 保证分区内有序,中继只要按 created_at 顺序发送同一 key 的记录就够了。跨 key 的顺序不在保证范围内,也不需要保证。
对账为什么必须是三方
最常见的错误是「两方对账」:拿 outbox 和读模型比,或者拿业务库和读模型比。两种都比不全。
| 比法 | 能发现 | 发现不了 |
|---|---|---|
| 业务库 vs 读模型 | 最终不一致的所有行 | 不一致是哪一步造成的:没发事件、没消费、还是投影写错 |
| outbox vs 读模型 | 已发未消费 | 事件根本没写进 outbox 的情况(两边都没有,比不出来) |
| 业务库 vs outbox vs 读模型 | 上面全部,且能定位到阶段 | 需要三张表都能查 |
两方对账的致命盲区是「静默丢失」:业务事务提交了,outbox 没写,读模型自然也没有。业务库和读模型比,能发现差异,但说不清原因;outbox 和读模型比,两边都缺这一行,反而显示「一致」。
三方比对能分出四类差异,外加一类不算差异的状态:
| 类型 | 判据 | 含义 | 能自动修吗 |
|---|---|---|---|
| 有库无事件 | 库里有行,日志里没有对应版本的已发布事件 | 写侧漏发,最危险 | 不能,见下文 |
| 有事件无库 | 日志里有事件,库里没有这笔借阅 | 业务事务回滚了,事件却留下 | 不能,要先判断哪边是对的 |
| 内容不一致(写侧) | 版本相同,状态不同 | 事件内容写错了 | 不能,需要人工判断真相 |
| 内容不一致(读侧) | 事件与库一致,投影不同 | 投影算错或被人工改过 | 能,按日志重建 |
| 在途 | 事件在 outbox 但 state=PENDING | 中继还没发 | 不算差异,等下一轮 |
对账任务:SQL 与代码
对账任务的骨架是一次全量扫描加三次比对。真实系统里会按时间窗分批,但比对逻辑是一样的。
写侧比对用一条 SQL 就能列出「库里有、事件版本落后」的借阅:
SELECT b.loan_id, b.version AS biz_version, MAX(o.version) AS log_version
FROM biz_loan b
LEFT JOIN outbox o
ON o.loan_id = b.loan_id AND o.state = 'SENT'
GROUP BY b.loan_id, b.version
HAVING MAX(o.version) IS NULL OR MAX(o.version) < b.version;
读侧比对则是「每条借阅的最后一条已发布事件」与投影行比较:
SELECT l.loan_id, l.status AS log_status, l.version AS log_version,
r.status AS rm_status, r.version AS rm_version
FROM (SELECT o.* FROM outbox o
WHERE o.state = 'SENT'
AND o.event_id = (SELECT e2.event_id FROM outbox e2
WHERE e2.loan_id = o.loan_id AND e2.state = 'SENT'
ORDER BY e2.created_at DESC, e2.event_id DESC
FETCH FIRST 1 ROW ONLY)) l
LEFT JOIN read_model_loan r ON r.loan_id = l.loan_id
WHERE r.loan_id IS NULL
OR r.version <> l.version
OR r.status <> l.status;
把四类差异合并成一份报告,代码只需要把三个 Map 按 loan_id 对齐:
for (String id : allLoans) {
Biz b = biz.get(id);
List<Ev> sent = log.getOrDefault(id, List.of())
.stream().filter(e -> e.state().equals("SENT")).toList();
boolean pending = log.getOrDefault(id, List.of())
.stream().anyMatch(e -> e.state().equals("PENDING"));
if (b != null && sent.isEmpty()) {
if (pending) report("IN_FLIGHT", id, "已入 outbox 未发布,不算差异");
else report("MISSING_EVENT", id, "库 v" + b.version() + ",日志 0 条");
} else if (b != null) {
Ev last = sent.get(sent.size() - 1);
if (last.version() < b.version())
report("MISSING_EVENT", id, "库 v" + b.version() + ",日志最新 v" + last.version());
else if (last.version() == b.version() && !last.status().equals(b.status()))
report("MISMATCH_WRITE", id, "同版本:库=" + b.status() + ",事件=" + last.status());
}
if (b == null && !sent.isEmpty())
report("ORPHAN_EVENT", id, "日志有事件,库中无此借阅");
// 读侧比对(日志与投影)结构相同,此处省略;对应 SQL 见上一段
}
本机实测:六个借阅、四类差异
实验用 H2 2.3.232 内存库,一个 ReconcileLab 类,六笔借阅分别构造一类状态。种子数据如下(state 列区分已发布与在途):
[biz_loan]
L-1001 B-201 RETURNED v2
L-1002 B-202 RETURNED v2
L-1004 B-204 OVERDUE v2
L-1005 B-205 ON_LOAN v1
L-1006 B-206 RETURNED v2
[outbox]
E-1001-1 L-1001 loan.created ON_LOAN v1 SENT
E-1001-2 L-1001 loan.returned RETURNED v2 SENT
E-1002-1 L-1002 loan.created ON_LOAN v1 SENT
E-1003-1 L-1003 loan.created ON_LOAN v1 SENT
E-1004-1 L-1004 loan.returned RETURNED v2 SENT
E-1005-1 L-1005 loan.created ON_LOAN v1 PENDING
E-1006-1 L-1006 loan.created ON_LOAN v1 SENT
E-1006-2 L-1006 loan.returned RETURNED v2 SENT
[read_model_loan]
L-1001 RETURNED v2 E-1001-2
L-1002 ON_LOAN v1 E-1002-1
L-1003 ON_LOAN v1 E-1003-1
L-1004 RETURNED v2 E-1004-1
L-1006 OVERDUE v2 E-1006-2
本机实测(Temurin 25.0.5+8 / H2 2.3.232 内存库 / 2026-10-29):单个 JVM 进程内跑完,不涉及网络与磁盘。以下输出为原样粘贴。
[差异明细]
MISSING_EVENT L-1002 库 v2,日志最新 v1,缺 1 条事件
MISMATCH_WRITE L-1004 同版本 v2:库=OVERDUE,事件=RETURNED
IN_FLIGHT L-1005 库 v1 ON_LOAN;outbox 有事件但未发布,不算差异
MISMATCH_READ L-1006 事件 v2 RETURNED,读模型 v2 OVERDUE
ORPHAN_EVENT L-1003 日志有 1 条已发布事件,库中无此借阅
[按类统计]
MISSING_EVENT 1
MISMATCH_WRITE 1
IN_FLIGHT 1
MISMATCH_READ 1
ORPHAN_EVENT 1
合计 4 条差异,其中在途 1 条
四类差异各一条,加上一条被正确识别为「不算差异」的在途记录。最后一行是这份报告最重要的地方:如果对账不区分在途,每一轮扫描都会把还没发出去的事件报成差异,告警立刻失去意义——正常运行时 outbox 里永远有一些 PENDING。
修复:读模型能重建,写侧不能
对账发现差异之后的下一步是处置。这里有一条清晰的分界线:
- 读模型是派生数据,可以由事件日志整表重建,重建是安全的;
- 写侧是真相,「库里有、事件没有」这类差异不能靠重放修复——重放只会得到同样的结果。
实验里加了一步重建,让读模型按「每个借阅的最后一条已发布事件」重新生成:
DELETE FROM read_model_loan;
INSERT INTO read_model_loan (loan_id, status, version, last_event_id, updated_at)
SELECT o.loan_id, o.status, o.version, o.event_id, CURRENT_TIMESTAMP
FROM outbox o
WHERE o.state = 'SENT'
AND o.event_id = (SELECT e2.event_id FROM outbox e2
WHERE e2.loan_id = o.loan_id AND e2.state = 'SENT'
ORDER BY e2.created_at DESC, e2.event_id DESC
FETCH FIRST 1 ROW ONLY);
重建之后重跑对账:
--- 读模型按事件日志重建:修复 5 行 ---
[差异明细]
MISSING_EVENT L-1002 库 v2,日志最新 v1,缺 1 条事件
MISMATCH_WRITE L-1004 同版本 v2:库=OVERDUE,事件=RETURNED
IN_FLIGHT L-1005 库 v1 ON_LOAN;outbox 有事件但未发布,不算差异
ORPHAN_EVENT L-1003 日志有 1 条已发布事件,库中无此借阅
[按类统计]
MISSING_EVENT 1
MISMATCH_WRITE 1
IN_FLIGHT 1
ORPHAN_EVENT 1
合计 3 条差异,其中在途 1 条
MISMATCH_READ 消失了,另外三类原样留下。这正是三方对账的价值:它能把「投影写错」和「消息没发」区分开,前者一键重建,后者必须回到写侧处理。
对于留下的三类,处置方式各不相同:
| 差异 | 常见成因 | 处置 |
|---|---|---|
| 有库无事件 | 业务写与 outbox 不在同一事务;或事件被人工删了 | 从业务表反推事件并补发(需要业务能重建事件),或标记为「不一致」等业务裁决 |
| 有事件无库 | 业务事务回滚,outbox 已提交(双写残留) | 判断哪边是真相;多数情况应把事件标记为作废,并通知下游回滚 |
| 写侧内容不一致 | 发事件的代码分支写错,版本号却相同 | 只能人工判断;同时必须修代码,否则下一笔还会错 |
对账任务的工程参数
对账不是写个脚本跑一次,它要作为常驻任务设计。几个必须提前定下来的参数:
| 参数 | 怎么定 | 定错的后果 |
|---|---|---|
| 扫描频率 | 比业务能容忍的不一致时长短一个量级;容忍 1 小时就 5 分钟跑一次 | 太慢:差异长期存在;太快:把在途误报成差异 |
| 扫描范围 | 按 updated_at 取最近 N 天,配合每周一次全量 | 只看增量会漏掉长期不动的问题行 |
| 在途豁免 | PENDING 且 created_at 在阈值内的一律不计差异 | 不豁免会把正常积压报成故障 |
| 报警阈值 | 按类分别设:MISSING_EVENT 一条就报,ORPHAN_EVENT 可聚合 | 混在一起设阈值,噪声会淹没真问题 |
| 结果留存 | 每轮报告落表,保留至少 30 天 | 只打印日志,事后无法回答「这个问题从哪天开始」 |
还有一条不在表里但最重要:对账任务本身要幂等、要能重入。 它可能在扫描到一半时被杀掉,下一轮必须从头再算,而不是接着上次的游标继续——否则一轮失败就会永久漏掉一段区间。
小结
- outbox 与业务表必须同库同事务;跨库就退化成双写,模式失效。
event_id由业务生成、version跟聚合走、状态只留两态,这三个字段决定了后面能不能对账。- 中继只能做到至少一次:发送与标记之间崩溃必然重复,重复要在消费端按业务幂等键消掉。
- 对账必须三方比对;两方比对发现不了「业务写了、outbox 没写」这种静默丢失。
- 差异分四类,外加一类「在途」;不区分在途,告警会被正常积压淹没。
- 读模型可以按事件日志重建,写侧差异不能;能自动修的只有「读侧内容不一致」。
阅读导航:上一节 4.1 分布式事务的三种落地 | 下一节 4.3 补偿与最终一致性
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。