Outbox Pattern与Debezium可靠消息系统 - 第4节

Outbox Pattern 核心思想:从“双写”到“单写”的思维转变

Outbox Pattern,说白了就是解决一个老难题:业务操作和消息发送,怎么保证一致性?

我早年做电商订单系统时,就踩过这个坑。订单创建后要发消息给库存、物流、积分系统。当时图省事,直接在事务里先写订单库,再发 MQ。结果呢?数据库提交成功了,消息却因为网络闪断没发出去。订单孤零零躺在库里,下游系统全不知情。用户下单成功,仓库却不发货——这体验,你想想看。

Outbox Pattern 的核心思想其实很简单:把“发消息”这件事,降级为“写数据库”。你不再直接调用 MQ 客户端,而是在业务数据库里建一张 outbox 表,把待发送的消息作为一条记录,和业务数据放在同一个本地事务里写入。

核心原则:业务操作与消息持久化,必须在同一个本地事务中完成。要么都成功,要么都回滚。

这样一来,你就不需要什么分布式事务了。一个本地事务,搞定业务数据和消息记录。消息会不会丢?不会。因为消息已经写进数据库了,和业务数据同生共死。

架构图:Outbox Pattern 的标准流程

下面这张图,是我自己项目里一直在用的架构。它把整个流程拆成了三个角色:

Outbox Pattern 标准架构流程 业务服务 订单/支付/用户 业务数据库 订单表 用户表 Outbox 表 id, aggregate_id event_type, payload 1. 写业务数据 2. 写消息记录 同一个本地事务 消息发布器 CDC / 定时轮询 3. 读取未发送消息 消息队列 Kafka / RocketMQ 4. 发送消息 下游消费者 5. 消费 6. 发送成功后删除/标记

流程其实就六步:

  1. 业务服务收到请求,开始处理业务逻辑。
  2. 在同一个本地事务里,先写业务数据(比如订单表),再写一条消息记录到 outbox 表。
  3. 事务提交。此时业务数据和消息都安全落盘。
  4. 消息发布器(独立进程或线程)扫描 outbox 表,找到状态为“待发送”的记录。
  5. 发布器把消息发送到消息队列(Kafka、RocketMQ 等)。
  6. 发送成功后,更新 outbox 表记录状态为“已发送”,或者直接删除该记录。

我个人的习惯:outbox 表里不要只存消息内容,还要存 aggregate_id(聚合根ID)和 event_type(事件类型)。这样下游消费者可以根据聚合ID做幂等处理,避免重复消费。

Outbox 表的设计:别小看这张表

很多初学者觉得 outbox 表就是存个消息体,随便建建就行。嗯,这里要注意——表结构设计不好,后面 CDC 采集和消息去重都会出问题。

我推荐的标准结构是这样的:

字段名 类型 说明
id BIGINT / UUID 主键,自增或 UUID
aggregate_id VARCHAR(64) 聚合根ID,用于幂等
aggregate_type VARCHAR(32) 聚合类型,如 "order"
event_type VARCHAR(64) 事件类型,如 "OrderCreated"
payload JSON / TEXT 消息体,序列化后的数据
status TINYINT 0=待发送, 1=已发送, 2=发送失败
created_at DATETIME 创建时间
sent_at DATETIME 发送时间,可为 NULL

我曾经踩过的坑:payload 字段一开始用了 TEXT,结果消息体里包含特殊字符时,序列化/反序列化各种出问题。后来统一改成 JSON 类型(MySQL 5.7+ 支持),配合 JSON_VALID() 约束,再也没出过乱子。

Outbox Pattern 与 CDC:天生一对

讲到这里,你可能已经发现了——outbox 表里的数据变化,本质上就是业务数据库的 变更事件。而 CDC(Change Data Capture)正好是干这个的:捕获数据库的增量变更。

所以,Outbox Pattern 和 CDC 的关系,说白了就是:Outbox 制造了“待消费的变更”,CDC 负责“发现并搬运这些变更”。

两种实现路径

我在实际项目中,见过两种主流的搭配方式:

  • 定时轮询 + Outbox:写一个定时任务,每隔几秒扫描 outbox 表,把 status=0 的记录捞出来发消息。优点是简单,缺点是有延迟,而且轮询频率高了会压数据库。
  • CDC + Outbox:用 Debezium、Canal 这类 CDC 工具,监听业务数据库的 binlog。当 outbox 表有新记录写入时,CDC 立刻捕获到 insert 事件,然后推送给消息队列。延迟低,对数据库无侵入。

我个人更推荐第二种。为什么?因为 CDC 是 事件驱动 的,outbox 表一有动静,它立马就知道。不像轮询那样,每隔几秒去“问”数据库一次——你想想看,如果 outbox 表大部分时间都是空的,轮询就是在做无用功。

关键认知:Outbox Pattern 解决了“业务与消息的一致性”问题,CDC 解决了“如何高效可靠地读取 outbox 表”的问题。两者结合,才是完整的可靠消息驱动方案。

CDC 读取 Outbox 的典型流程

用 CDC 来消费 outbox 表,流程是这样的:

  1. 业务事务提交,outbox 表多了一条记录。
  2. 数据库的 binlog 记录了这个 insert 事件。
  3. CDC 工具(如 Debezium)捕获到 binlog 变更。
  4. CDC 将变更事件发送到 Kafka 等消息中间件。
  5. 下游的“消息发布器”消费这个 Kafka 消息,解析出 outbox 记录的内容。
  6. 发布器将真正的业务消息发送到目标队列(或直接调用下游接口)。
  7. 发布器回写 outbox 表,将 status 更新为“已发送”。

这里有个细节要注意:CDC 捕获到的是 outbox 表的 insert 事件,而不是业务表的 insert 事件。为什么?因为业务表可能有多次更新,但只有最终状态才需要发消息。而 outbox 表里每一条记录,都明确对应一条待发送的消息——这就是 Outbox Pattern 的“显式意图”设计。

避坑指南:使用 CDC 时,一定要确保 CDC 工具能正确处理 outbox 表记录的删除或状态更新。我遇到过 CDC 把 delete 事件也当成业务事件转发出去的情况——后来加了 event_type 过滤,只处理 insert 事件,才彻底解决。

为什么说 Outbox + CDC 是“可靠消息驱动”的基石?

你想想看,一个消息驱动的系统,最怕什么?

  • 消息丢了——业务操作成功了,下游不知道。
  • 消息重复了——下游收到两次,数据乱套。
  • 消息顺序乱了——先发的后到,后发的先到。

Outbox Pattern 解决了第一个问题:消息不会丢,因为它和业务数据在同一个事务里。CDC 解决了第二个和第三个问题:binlog 是顺序的,CDC 消费 binlog 也是顺序的,所以消息的顺序天然得到保证。至于重复,outbox 表里的 aggregate_id 和 event_type 就是为幂等准备的。

所以,Outbox + CDC 这套组合拳,打的就是“可靠”二字。我在好几个高并发项目里都用这个方案,线上从来没出现过因为消息丢失导致的业务故障。

一句话总结:Outbox Pattern 把“发消息”变成了“写数据库”,CDC 把“读数据库”变成了“监听 binlog”。一个保证不丢,一个保证有序且高效。两者结合,才是生产级的可靠消息方案。