《Java 消息中间件·事件驱动架构》12.1 Outbox 模式

Outbox 模式用数据库本地事务保证业务写入与事件的原子性,再由中继可靠投递到 Kafka。本文用 H2 + kafka-clients 写一段可运行的 outbox 演示,故意构造一条不一致记录,展示对账检测、自动重发、幂等标记与至少一次语义的完整流程与本机实测输出。

为什么双写会不一致

事件驱动系统最常见的错误做法是双写:先写业务库,再发 Kafka 消息。两次网络调用之间,进程崩溃、超时回滚、代码异常,都可能导致「库里有数据、事件没发出去」或「事件发了、库回滚了」。

outbox 模式的核心思路是:把「业务数据修改」和「待发事件」塞进同一个本地事务,由数据库保证原子性;再用一个独立的中继进程扫描 outbox 表,把事件发到 Kafka。

这一节回答一个 4.2 没展开的问题:当中继成功发送但还没标记 SENT 时崩溃,重启后会发生什么?以及,如果生产代码真的漏写了一条 outbox,怎么被发现、怎么补发?

两张表与一条铁律

演示只用两张表:

表作用谁写
orders业务订单状态借阅服务
outbox同一事务内的事件记录借阅服务(与 orders 同事务)

铁律:orders 和 outbox 必须在同一个数据库连接、同一个事务内完成。跨库就不是 outbox,退化成带补偿的双写。

建表语句(H2 方言):

CREATE TABLE orders (
  id       VARCHAR(16) PRIMARY KEY,
  customer VARCHAR(32) NOT NULL,
  sku      VARCHAR(16) NOT NULL,
  qty      INT         NOT NULL,
  status   VARCHAR(16) NOT NULL
);

CREATE TABLE outbox (
  event_id   VARCHAR(32) PRIMARY KEY,
  order_id   VARCHAR(16) NOT NULL,
  payload    VARCHAR(256),
  state      VARCHAR(8)  NOT NULL,  -- PENDING / SENT
  created_at TIMESTAMP
);

event_id 由业务生成,不用自增;自增在重放、主从切换时会重号,导致消费端幂等表误判。

写入:正常路径与故障路径

insertOrderWithOutbox 把两张表包在同一个事务里:

try (Connection conn = DriverManager.getConnection(DB_URL, "sa", "")) {
    conn.setAutoCommit(false);
    // 1. 写 orders
    PreparedStatement ps1 = conn.prepareStatement(
        "INSERT INTO orders(id,customer,sku,qty,status) VALUES(?,?,?,?,?)");
    ps1.setString(1, id); ps1.setString(2, customer); ps1.setString(3, sku);
    ps1.setInt(4, qty); ps1.setString(5, "CONFIRMED"); ps1.executeUpdate();

    // 2. 写 outbox(同事务)
    String eventId = "E-" + id;
    String payload = String.format(
        "{orderId:%s,status:CONFIRMED,sku:%s,qty:%d}", id, sku, qty);
    PreparedStatement ps2 = conn.prepareStatement(
        "INSERT INTO outbox(event_id,order_id,payload,state,created_at) VALUES(?,?,?,?,?)");
    ps2.setString(1, eventId); ps2.setString(2, id); ps2.setString(3, payload);
    ps2.setString(4, "PENDING"); ps2.setTimestamp(5, new Timestamp(System.currentTimeMillis()));
    ps2.executeUpdate();

    conn.commit();   // 要么都成功,要么都回滚
}

但真实代码里可能有分支遗漏。实验故意构造一条只写 orders、不写 outbox 的记录(O-1004),模拟「代码分支忘记写 outbox」或「中继进程还没启动时全量写入」的场景。

中继:至少一次,承认重复

中继循环做三件事:取 PENDING、发到 Kafka、标 SENT。

Properties p = new Properties();
p.put("bootstrap.servers", KAFKA);
p.put("key.serializer",   StringSerializer.class.getName());
p.put("value.serializer", StringSerializer.class.getName());
p.put("acks", "all");

// 取待发记录
ResultSet rs = conn.createStatement().executeQuery(
    "SELECT event_id, payload FROM outbox WHERE state='PENDING' ORDER BY event_id");

// 发到 Kafka
try (KafkaProducer<String,String> producer = new KafkaProducer<>(p)) {
    for (String[] row : pending) {
        producer.send(new ProducerRecord<>(TOPIC, row[0], row[1])).get();
    }
}

// 标为已发送(独立短事务)
PreparedStatement ps = conn.prepareStatement(
    "UPDATE outbox SET state='SENT' WHERE event_id=?");

flush 返回成功与 UPDATE 之间,进程可能崩溃。重启后这批记录仍是 PENDING,会被再发一次。这不是 bug,是 outbox 的语义:发送与标记不可能原子,中继只能保证至少一次。 重复由消费端按 event_id 幂等消掉。

对账:检测「库里有、事件没有」

对账任务扫描 orders 与 outbox,按 order_id 对齐,分出三类状态:

状态判据含义
OKorders 与 outbox 的 SENT 记录都存在正常
PENDINGoutbox 有记录但 state=PENDING在途,还没发
INCONSISTENTorders 有行,outbox 完全没有写侧漏发,必须补

核心逻辑:

for (String oid : orders.keySet()) {
    if (!sent.containsKey(oid) && !pending.contains(oid)) {
        report("INCONSISTENT", oid, "order exists but outbox missing");
    }
}

「有事件无库」的情况在 outbox 模式下不会发生,因为事件和业务写是同事务的;但如果业务事务回滚而 outbox 没回滚(比如代码错误地拆了两个事务),对账也能发现。

不一致后的自动重发

发现不一致后,有两种处置:

  1. 自动补发:从业务表反推事件,生成新的 event_id(带 -RESEND 后缀),发到 Kafka,同时补进 outbox 标为 SENT。
  2. 人工介入:如果反推事件需要业务上下文(如谁在什么时间做了审批),自动补发可能会丢字段,此时进补偿任务表。
String eventId = "E-" + id + "-RESEND";
String payload = String.format(
    "{orderId:%s,status:%s,sku:%s,qty:%s,resend:true}", ...);
producer.send(new ProducerRecord<>(TOPIC, eventId, payload)).get();

resend:true 标记让消费端知道这是补发,审计链路可以区分原始事件与重发事件。

本机实测:构造 1 条不一致,检测并重发

实验环境:同一个 H2 内存库 + Kafka 4.3.0 单节点 KRaft,端口 19792。五个订单,其中 O-1004 故意不写 outbox。

本机实测(Temurin 27.0.1.0.3 / kafka-clients 4.3.0 / H2 2.3.232 / 2026-10-29):通过隧道直连容器内 Kafka。以下输出为原样粘贴。

=== phase 1: schema ===
[orders]
[outbox]
Topic exists: arch.outbox.events

=== phase 2: after insert with O-1004 missing outbox ===
[orders]
  O-1001   Alice    BOOK-001   qty=2 CONFIRMED
  O-1002   Bob      BOOK-002   qty=1 CONFIRMED
  O-1003   Carol    BOOK-003   qty=3 CONFIRMED
  O-1004   Dave     BOOK-004   qty=1 CONFIRMED
  O-1005   Eve      BOOK-005   qty=1 CONFIRMED
[outbox]
  E-O-1001   O-1001   state=PENDING payload={orderId:O-1001,status:CONFIRMED,sku:BOOK-001,qty:
  E-O-1002   O-1002   state=PENDING payload={orderId:O-1002,status:CONFIRMED,sku:BOOK-002,qty:
  E-O-1003   O-1003   state=PENDING payload={orderId:O-1003,status:CONFIRMED,sku:BOOK-003,qty:
  E-O-1005   O-1005   state=PENDING payload={orderId:O-1005,status:CONFIRMED,sku:BOOK-005,qty:

=== phase 3: relay sent 4 events to Kafka ===

=== phase 4: reconcile ===
  OK           O-1001 sent: CONFIRMED
  OK           O-1002 sent: CONFIRMED
  OK           O-1003 sent: CONFIRMED
  INCONSISTENT O-1004 order exists but outbox missing: CONFIRMED
  OK           O-1005 sent: CONFIRMED
  found 1 inconsistency

=== phase 5: resend missing ===
  RESENT O-1004 event_id=E-O-1004-RESEND

=== phase 6: reconcile after resend ===
  OK           O-1001 sent: CONFIRMED
  OK           O-1002 sent: CONFIRMED
  OK           O-1003 sent: CONFIRMED
  OK           O-1004 sent: CONFIRMED
  OK           O-1005 sent: CONFIRMED
  (no inconsistency)

中继的崩溃窗口与幂等设计

中继在 flush 之后、markSent 之前崩溃的场景,是 outbox 模式里无法消除的根本窗口。它的后果不是数据丢失,而是重复投递。消费端必须有幂等机制来承接这个重复。

幂等键的选择直接影响重复的处理效率:

幂等键判据优势局限
event_idevent_id 已存在则忽略精确到单条事件需要消费者维护已见集合
(order_id, status)同一状态已存在则忽略自然幂等,无需额外存储状态回退场景会误判
(order_id, version)版本号 >= 当前则更新支持乱序到达需要业务层生成版本

实验选用 event_id 做幂等键,因为它是 outbox 表的主键,天然唯一。消费端只需在投影表里建一个 seen_event_id 的唯一索引,重复写入时触发唯一键冲突并忽略。

CREATE TABLE projection_orders (
  order_id      VARCHAR(16) PRIMARY KEY,
  status        VARCHAR(16) NOT NULL,
  seen_event_id VARCHAR(32) NOT NULL UNIQUE
);

outbox 的变体方案

outbox 模式有几种变体,适用于不同场景:

变体实现方式适用场景代价
表内 outbox业务表内加 event_* 列简单场景业务表膨胀
独立 outbox 表单独的 outbox 表通用场景额外的写入和轮询
变更数据捕获(CDC)监听 binlog 自动生成事件不适合改代码的旧系统延迟和乱序

表内 outbox 把事件字段直接加在业务表里,省了一张表,但业务表会膨胀。独立 outbox 表是最常见的方案。CDC 适合遗留系统,但 binlog 延迟和乱序问题需要额外处理。

与 4.2 的对账有什么区别

4.2 的三方对账比的是「业务库 vs outbox vs 读模型」,目标是发现跨系统不一致。这一节的对账只有两方——业务库 vs outbox——但目的不同:它在 outbox 发送之前就做校验,回答「outbox 本身有没有漏写」。

更重要的是,4.2 的对账跑在全量数据上,频率是小时级或天级;而这一节的检测可以在每次业务事务提交后异步做一次轻量扫描,把不一致窗口从「天」压到「秒」。

还有一个微妙差别:4.2 的对账修复手段是「按日志重建读模型」,而本节的检测修复手段是「反推并补发事件」。前者修的是读侧投影,后者修的是写侧漏发——两种修复不能互换。

小结

  • outbox 的核心不是「发消息」,是「用数据库事务保证业务写和事件记录原子」。
  • 中继只能做到至少一次:flush 和 UPDATE 之间崩溃必然重复,重复在消费端消掉。
  • 对账分为「前置检测」(业务 vs outbox)和「后置检测」(三方比对),两者频率和目的不同。
  • 自动重发要注意 event_id 不能复用,且应带 resend 标记供审计区分。
  • 不补救的不一致等于没有检测——对账任务必须有明确的修复出口。

为什么 outbox 不是银弹

outbox 模式解决了「业务写入和事件发送的原子性」问题,但它没有解决以下问题:

  1. 事件乱序:outbox 表中的事件按写入顺序排列,但中继发送时可能因重试导致乱序。消费端需要有版本号或因果序机制来处理乱序。
  2. 事件延迟:outbox 需要轮询或触发机制来发送事件,引入了额外的延迟。对于延迟敏感的场景,outbox 可能不是最佳选择。
  3. 数据库性能:每个业务写入都伴随着一次 outbox 写入,增加了数据库的写压力。在高写入场景下,outbox 表可能成为瓶颈。

这些问题不是 outbox 模式本身的缺陷,而是使用 outbox 时必须考虑的约束。

本节判据

  • outbox 的核心不是「发消息」,是「用数据库事务保证业务写和事件记录原子」。

  • 中继只能做到至少一次:flush 和 UPDATE 之间崩溃必然重复,重复在消费端消掉。

  • 对账分为「前置检测」(业务 vs outbox)和「后置检测」(三方比对),两者频率和目的不同。

  • 自动重发要注意 event_id 不能复用,且应带 resend 标记供审计区分。

  • 不补救的不一致等于没有检测——对账任务必须有明确的修复出口。

  • outbox 不解决事件乱序和延迟问题,消费端需要额外处理。

  • outbox 增加了数据库写压力,高写入场景可能成为性能瓶颈。

  • outbox 模式有表内 outbox、独立 outbox 表、CDC 三种变体,按场景选择。

  • 前置检测(业务 vs outbox)与后置检测(三方比对)频率和目的不同,不能互相替代。

  • outbox 模式的三种变体(表内、独立表、CDC)适用于不同场景,不能一刀切。

阅读导航:上一节 11.3 配额与成本分摊 | 下一节 12.2 从单体到事件的迁移

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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