本地消息表与 Outbox 模式
用途:解决“数据库事务提交了,消息却没发出去”这类经典不一致时查阅
问题
「问题」小节链接跨资源写入有两个动作:更新本地数据库、向消息中间件发送事件。二者不在一个事务里,于是存在四种失败组合,其中两种会造成永久不一致:
| 顺序 | 故障 | 后果 |
|---|---|---|
| 先发消息后提交 | 提交失败 | 下游收到未发生的事件(脏事件) |
| 先提交后发消息 | 发送失败/进程崩溃 | 事件永久丢失,两侧长期不一致 |
Outbox(发件箱)模式把两个动作合并为一个本地事务:事件与业务数据一起写入同库的 outbox_message 表,由独立投递器把消息搬到中间件并标记完成。
标准结构
「标准结构」小节链接业务表写入 + outbox_message 插入 ← 同一本地事务 ↓投递器(轮询 / CDC)读取未发送记录 → 发送到 MQ → 标记已发送 ↓消费方按业务幂等键去重关键字段:聚合标识、事件类型、载荷、创建时间、状态(待发送/已发送/失败)、重试次数、下一次重试时间、唯一消息键。
两种投递方式
「两种投递方式」小节链接| 方式 | 做法 | 优点 | 注意点 |
|---|---|---|---|
| 轮询发布(Poll-Publish) | 后台任务定时查表发送 | 实现简单、无额外组件 | 延迟取决于轮询周期;多实例需抢占避免重复 |
| CDC 发布 | 用 Debezium 等监听 outbox 表的 binlog 变更 | 低延迟、应用无发送代码 | 需要运维 CDC;顺序与 exactly-once 仍靠消费幂等 |
必须同时具备的配套
「必须同时具备的配套」小节链接- 消费端幂等:Outbox 保证“至少一次”,重复投递必然发生。
- 顺序要求显式声明:同一聚合的事件需要按序时,用聚合键做分区/顺序消息。
- 积压与告警:未发送记录堆积是故障前兆,要有阈值告警。
- 清理策略:已发送记录定期归档,避免表膨胀拖慢轮询。
- 对账兜底:投递器自身故障时,需要比对两侧状态的修复任务。
与其他方案的对比
「与其他方案的对比」小节链接| 方案 | 一致性 | 复杂度 | 适用 |
|---|---|---|---|
| 直接双写(无 Outbox) | 可能永久不一致 | 低 | 只适合可容忍丢失的场景 |
| 本地消息表 / Outbox | 至少一次 + 幂等 | 中 | 通用首选,跨库/跨中间件 |
| 事务消息(半消息 + 回查) | 至少一次 | 中 | 中间件原生支持时(RocketMQ) |
| XA/2PC | 强一致 | 高 | 参与者少且可控、不可接受不一致 |
事务消息与 Outbox 解决的是同一个问题,取舍在于:事务消息把可靠性放进中间件(依赖回查实现),Outbox 放进自己的数据库(可审计、可重放、与中间件无关)。
开源参考:Debezium(Outbox Event Router)、Spring Transactional Outbox、Apache RocketMQ 事务消息。
常见误区
「常见误区」小节链接| 误区 | 修正 |
|---|---|
| outbox 表与业务表不同库 | 必须同库同事务,否则问题原样存在 |
| 投递器无并发抢占 | 多实例重复发送,依赖消费端幂等兜住(成本更高) |
| 只发不记状态 | 无法重放与排障 |
| 消费端用消息 ID 去重 | 业务重试会产生新消息 ID,必须用业务幂等键 |
| 忽略载荷版本演进 | 事件 Schema 需版本化,历史积压消息仍会被消费 |
延伸阅读
「延伸阅读」小节链接- Chris Richardson《微服务架构设计模式》第 8 章(Outbox 与事务日志钩子)
- Debezium 官方文档的 Outbox Event Router 章节