事务性发件箱模式:架构图与实现指南

事务性发件箱模式解决双写问题:把事件和数据写在同一个事务里,再由中继转发。附架构图、SQL,以及轮询与 CDC 的对比。

· 创始人兼开发者

发布于

事务性发件箱模式:架构图与实现指南

每个事件驱动的服务迟早都会写出这样的代码:先保存订单,再发布 OrderPlaced。测试时一切正常。到了生产环境,进程在这两行之间崩溃,订单存在了,事件却没有。或者发布成功了,数据库事务却回滚了,事件宣告了一个并不存在的订单。

这就是双写问题,而事务性发件箱(transactional outbox)模式是它的标准解法。本文介绍这个问题、这个模式、构建中继的两种方式,以及决定它能否真正奏效的细节:顺序、重复和清理。

双写问题

数据库和消息代理是两个没有共享事务的系统。任何同时写这两者的代码,都存在一个时间窗口:一个写入成功了,另一个还没有。

操作顺序 可能出什么问题
先提交,再发布 提交后崩溃:事件永远丢失
先发布,再提交 提交失败:消费者针对一个从未存在的订单采取了行动
在事务内发布 对代理的调用成功了,事务却仍然回滚

重试无法关闭这个窗口,只会让它挪个位置。调整顺序的技巧,比如在提交后的钩子里发布,只能缩小窗口:在提交和钩子之间崩溃,事件依然会丢失。跨数据库和消息代理的分布式事务很少可用,即使可用也很慢。

这个模式

把事件和业务数据写进同一个数据库事务,之后再从那里发布:

  1. 服务在一个本地事务中插入订单行和一条 outbox 记录(事件 ID、聚合 ID、类型、负载)。两者要么都提交,要么都不提交。
  2. 一个独立的 中继 读取新的 outbox 记录,并把它们发布到消息代理。
  3. 消息代理确认后,中继把这些记录标记为已发布,或者直接删除。

现在保证变得很简单:只要订单存在,它的事件就一定会被发布,至少一次。Chris Richardson 对 这个模式的描述 采用的是同样的结构。

CREATE TABLE outbox (
  id            uuid PRIMARY KEY,
  aggregate_id  bigint      NOT NULL,
  type          text        NOT NULL,
  payload       jsonb       NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now()
);

构建中继的两种方式

轮询发布器。 一个循环分批选出未发布的记录,发布后删除。在 Postgres 中,SELECT ... FOR UPDATE SKIP LOCKED 让多个中继实例可以分担工作而互不阻塞;参见 锁定子句的文档。它容易构建,也容易理解。代价是轮询带来的延迟和负载,以及一个关于顺序的微妙问题:ID 较小的行可能在 ID 较大的行之后才提交,所以按 ID 顺序读取的轮询器可能跳过一条尚不可见的记录。

日志跟踪(变更数据捕获)。 CDC 工具读取数据库的事务日志,把已提交的 outbox 插入转换成消息。Debezium 的 outbox event router 正是为 outbox 表做这件事的,它根据每行的列把记录路由到对应的主题。事件按提交顺序输出,延迟低,也没有轮询查询,代价是要运行和运维 CDC 管道。

轮询 CDC
延迟 轮询间隔 接近提交时间
顺序 需要小心处理 提交顺序
组成部分 一个工作进程 一个连接器和日志配置
适用场景 中等规模、小团队 大流量、大量 outbox

至少一次意味着会有重复

中继可能在发布一条记录之后、把它标记为已发布之前崩溃。重启后,它会再次发布这条记录。这无法避免,所以消费者必须幂等:

  • 每个事件都带有唯一 ID(即 outbox 记录的 ID)。
  • 消费者记录已处理过的 ID,最好与处理结果放在同一个事务中(一张 inbox 表),并跳过见过的 ID。
  • 或者让处理结果天然幂等,比如以订单 ID 为键的 upsert。

顺序

消费者通常关心的是每个聚合内部的顺序:对同一个订单来说,OrderPlaced 必须在 OrderCancelled 之前。把聚合 ID 作为消息键,让同一订单的所有事件进入同一个分区,并按提交顺序发布(CDC 本身就是如此;轮询器则应按提交时分配的序列号排序,或者串行处理每个聚合的事件)。跨所有订单的全局顺序很少需要,而且代价很高。

清理

从不清空的 outbox 表会无限增长,并拖慢轮询器。发布之后删除记录,或者只在短暂的审计窗口内保留,然后删除旧分区。使用 CDC 时,记录可以在插入后于同一事务中立即删除,因为连接器是从日志而不是从表中读取插入的。

逐一检查故障

针对每个可能崩溃的时间点检查这个设计:

  • 提交前崩溃。 订单和事件都不存在。客户端收到错误,可以重试。
  • 提交后、中继运行前崩溃。 两行数据都存在;中继启动后会发布事件。
  • 中继在发布后、删除记录前崩溃。 重启后事件会被再次发布,消费者按 ID 丢弃重复事件。
  • 消息代理宕机。 记录在 outbox 中堆积;服务继续接单,等代理恢复后中继再追上进度。针对最早一条未发布记录的存在时长设置告警。

无论哪种情况,数据库始终是唯一可信来源,这正是这个模式存在的意义。

架构图

订单服务在一个 Postgres 事务中向 orders 和 outbox 两张表插入数据。outbox 中继读取新行并发布到 order-events 主题。配送服务和邮件服务消费这些事件;邮件服务记录已处理的事件 ID,以跳过重复事件。

10 个组件 · 6 个连接

在 Archboard 中打开
图表内容
  • Order service
  • Postgres: one transaction
  • orders
  • outbox
  • Outbox relay
  • order-events
  • Shipping service
  • Email service
  • Processed events
  • both rows
  • new rows
  • publish
  • OrderPlaced
  • seen it?
事务性发件箱模式。订单服务在同一个 Postgres 事务中写入订单行和一条 outbox 记录;中继把 outbox 记录发布到 Kafka 主题,由于投递是至少一次,消费者按事件 ID 去重。

这张图的读法如下:

  • 左侧: 订单服务在一个 Postgres 事务中写入两行数据,分别写入 orders 表和 outbox 表。框架标出了事务的边界。
  • 中间: outbox 中继通过轮询或 CDC 读取新记录,并发布到 order-events 主题。
  • 右侧: 配送服务和邮件服务消费这些事件。邮件服务在执行操作前会查询已处理事件 ID 表,重复事件就是这样被消化掉的。
  • SQL 块 展示了这个事务,这样就没人需要猜测这里的“原子性”到底指什么。

图上应该画什么

  • 事务边界。 把两张表都画在里面;这就是整个模式的核心。
  • 中继及其类型。 轮询还是 CDC,会改变延迟和顺序。
  • 投递语义。 在中继上写“至少一次”,在每个消费者上写“幂等”。
  • 消息键。 注明事件以聚合 ID 作为键。

事件驱动架构文章 更深入地介绍了消费者一侧,Saga 模式图 展示了多服务工作流的每一步在哪里需要 outbox。数据库复制文章 解释了 CDC 读取的预写日志。

在 Archboard 中构建

  1. 使用 Google 或 GitHub 登录,并新建一张图。
  2. 添加 C4 模型节点包中的一个 容器 表示订单服务,再添加一个表示 outbox 中继。
  3. 添加基础节点包中的一个 框架,标注数据库和事务,并在其中放入实体关系节点包中的两个 实体 节点,表示 orders 表和 outbox 表。
  4. 添加数据节点包中的 Kafka 表示主题,添加 容器 节点表示消费者,再添加一个 数据库 存放已处理的事件 ID。
  5. 添加一个基础节点包中的 Markdown 块,写上这个事务的 SQL。

这个示例共用 10 个图表节点,在 Free 套餐的限制之内。

常见问题

事务性发件箱解决的是什么问题? 当服务分两个独立步骤写数据库和发布消息时,事件可能丢失,也可能凭空出现。发件箱让事件成为同一个数据库事务的一部分。

发件箱模式能做到恰好一次吗? 不能。它保证的是至少一次投递。消费者必须处理重复事件,通常的做法是记录已处理的事件 ID。

轮询还是 CDC:该用哪种中继? 轮询更简单,中等规模足够用。CDC 延迟更低,并且按提交顺序投递,在流量大或很多服务都使用发件箱时值得采用。

如果使用事件溯源,还需要发件箱吗? 如果消费者订阅的来源就是事件存储本身,就不需要。只要服务把状态存在表里,同时还要发布事件,就需要它。

outbox 表会变得多大? 你允许它多大,它就多大。及时删除已发布的记录,或者按时间对表分区并删除旧分区。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。