Outbox Pattern与Debezium可靠消息系统 - 第4节
Outbox Pattern 核心思想:从“双写”到“单写”的思维转变
Outbox Pattern,说白了就是解决一个老难题:业务操作和消息发送,怎么保证一致性?
我早年做电商订单系统时,就踩过这个坑。订单创建后要发消息给库存、物流、积分系统。当时图省事,直接在事务里先写订单库,再发 MQ。结果呢?数据库提交成功了,消息却因为网络闪断没发出去。订单孤零零躺在库里,下游系统全不知情。用户下单成功,仓库却不发货——这体验,你想想看。
Outbox Pattern 的核心思想其实很简单:把“发消息”这件事,降级为“写数据库”。你不再直接调用 MQ 客户端,而是在业务数据库里建一张 outbox 表,把待发送的消息作为一条记录,和业务数据放在同一个本地事务里写入。
核心原则:业务操作与消息持久化,必须在同一个本地事务中完成。要么都成功,要么都回滚。
这样一来,你就不需要什么分布式事务了。一个本地事务,搞定业务数据和消息记录。消息会不会丢?不会。因为消息已经写进数据库了,和业务数据同生共死。
架构图:Outbox Pattern 的标准流程
下面这张图,是我自己项目里一直在用的架构。它把整个流程拆成了三个角色:
流程其实就六步:
- 业务服务收到请求,开始处理业务逻辑。
- 在同一个本地事务里,先写业务数据(比如订单表),再写一条消息记录到 outbox 表。
- 事务提交。此时业务数据和消息都安全落盘。
- 消息发布器(独立进程或线程)扫描 outbox 表,找到状态为“待发送”的记录。
- 发布器把消息发送到消息队列(Kafka、RocketMQ 等)。
- 发送成功后,更新 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 表,流程是这样的:
- 业务事务提交,outbox 表多了一条记录。
- 数据库的 binlog 记录了这个 insert 事件。
- CDC 工具(如 Debezium)捕获到 binlog 变更。
- CDC 将变更事件发送到 Kafka 等消息中间件。
- 下游的“消息发布器”消费这个 Kafka 消息,解析出 outbox 记录的内容。
- 发布器将真正的业务消息发送到目标队列(或直接调用下游接口)。
- 发布器回写 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”。一个保证不丢,一个保证有序且高效。两者结合,才是生产级的可靠消息方案。