每个事件驱动的服务迟早都会写出这样的代码:先保存订单,再发布 OrderPlaced。测试时一切正常。到了生产环境,进程在这两行之间崩溃,订单存在了,事件却没有。或者发布成功了,数据库事务却回滚了,事件宣告了一个并不存在的订单。
这就是双写问题,而事务性发件箱(transactional outbox)模式是它的标准解法。本文介绍这个问题、这个模式、构建中继的两种方式,以及决定它能否真正奏效的细节:顺序、重复和清理。
双写问题
数据库和消息代理是两个没有共享事务的系统。任何同时写这两者的代码,都存在一个时间窗口:一个写入成功了,另一个还没有。
| 操作顺序 | 可能出什么问题 |
|---|---|
| 先提交,再发布 | 提交后崩溃:事件永远丢失 |
| 先发布,再提交 | 提交失败:消费者针对一个从未存在的订单采取了行动 |
| 在事务内发布 | 对代理的调用成功了,事务却仍然回滚 |
重试无法关闭这个窗口,只会让它挪个位置。调整顺序的技巧,比如在提交后的钩子里发布,只能缩小窗口:在提交和钩子之间崩溃,事件依然会丢失。跨数据库和消息代理的分布式事务很少可用,即使可用也很慢。
这个模式
把事件和业务数据写进同一个数据库事务,之后再从那里发布:
- 服务在一个本地事务中插入订单行和一条 outbox 记录(事件 ID、聚合 ID、类型、负载)。两者要么都提交,要么都不提交。
- 一个独立的 中继 读取新的 outbox 记录,并把它们发布到消息代理。
- 消息代理确认后,中继把这些记录标记为已发布,或者直接删除。
现在保证变得很简单:只要订单存在,它的事件就一定会被发布,至少一次。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 中堆积;服务继续接单,等代理恢复后中继再追上进度。针对最早一条未发布记录的存在时长设置告警。
无论哪种情况,数据库始终是唯一可信来源,这正是这个模式存在的意义。
架构图
图表内容
- 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 事务中写入两行数据,分别写入 orders 表和 outbox 表。框架标出了事务的边界。
- 中间: outbox 中继通过轮询或 CDC 读取新记录,并发布到 order-events 主题。
- 右侧: 配送服务和邮件服务消费这些事件。邮件服务在执行操作前会查询已处理事件 ID 表,重复事件就是这样被消化掉的。
- SQL 块 展示了这个事务,这样就没人需要猜测这里的“原子性”到底指什么。
图上应该画什么
- 事务边界。 把两张表都画在里面;这就是整个模式的核心。
- 中继及其类型。 轮询还是 CDC,会改变延迟和顺序。
- 投递语义。 在中继上写“至少一次”,在每个消费者上写“幂等”。
- 消息键。 注明事件以聚合 ID 作为键。
事件驱动架构文章 更深入地介绍了消费者一侧,Saga 模式图 展示了多服务工作流的每一步在哪里需要 outbox。数据库复制文章 解释了 CDC 读取的预写日志。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加 C4 模型节点包中的一个 容器 表示订单服务,再添加一个表示 outbox 中继。
- 添加基础节点包中的一个 框架,标注数据库和事务,并在其中放入实体关系节点包中的两个 实体 节点,表示 orders 表和 outbox 表。
- 添加数据节点包中的 Kafka 表示主题,添加 容器 节点表示消费者,再添加一个 数据库 存放已处理的事件 ID。
- 添加一个基础节点包中的 Markdown 块,写上这个事务的 SQL。
这个示例共用 10 个图表节点,在 Free 套餐的限制之内。
常见问题
事务性发件箱解决的是什么问题? 当服务分两个独立步骤写数据库和发布消息时,事件可能丢失,也可能凭空出现。发件箱让事件成为同一个数据库事务的一部分。
发件箱模式能做到恰好一次吗? 不能。它保证的是至少一次投递。消费者必须处理重复事件,通常的做法是记录已处理的事件 ID。
轮询还是 CDC:该用哪种中继? 轮询更简单,中等规模足够用。CDC 延迟更低,并且按提交顺序投递,在流量大或很多服务都使用发件箱时值得采用。
如果使用事件溯源,还需要发件箱吗? 如果消费者订阅的来源就是事件存储本身,就不需要。只要服务把状态存在表里,同时还要发布事件,就需要它。
outbox 表会变得多大? 你允许它多大,它就多大。及时删除已发布的记录,或者按时间对表分区并删除旧分区。