bigywater.tech
RabbitMQ · 35

Outbox 模式:数据库和消息的一致

“先改数据库、再发消息”看似简单,进程在两步之间崩溃就会让数据库和下游不一致。Outbox 模式是业界标准解法。它到底解决什么、引入什么新问题、relay 的延迟和吞吐是多少?这篇用真实的数据库和 RabbitMQ 复现。

58 / 1000
先写库再发消息、5% 崩溃时,丢失的消息条数
0 丢 / 60 重复
Outbox + relay 崩溃 60 次后,订单事件的丢失与重复条数
8 / 58 / 284 ms
poll 间隔 10 / 100 / 500 ms 时的 relay 延迟 p50

双写问题

实测 · 本机执行输出
  数据库里订单 1000 条,队列里消息 942 条,丢了 58 条消息(= 崩溃 58 次)——下游永远不知道这些订单
  反过来(先发消息、再写库,中间崩溃)会得到“有消息、没订单”的幽灵事件;两种顺序都不对

写数据库和发消息不在同一个事务里,两步之间崩溃就会不一致:实测先写库后发消息会丢消息。

Outbox

实测 · 本机执行输出
  relay 一共崩溃 60 次、跑了 62 轮;订单 1000 条;队列里消息 1060 条
  不同的 message_id 1000 个(= 订单数,没有丢);重复 60 条(崩溃那一刻已发出、还没标记的消息,重启后又发了一次)
  所以 outbox 把“可能丢”换成了“可能重复”——至少一次;下游要用 message_id 去重(见第 25 篇)

把“待发送事件”和业务数据放进同一个数据库事务,由 relay 单独轮询发送、确认后标记。这样不会丢,但 relay 在“已发送、未标记”时崩溃会重复发送,所以是至少一次(见下)。

Outbox 的新问题

实测 · 本机执行输出
  收到的消息(按内容 order-N 记)里不同的有 1000 个,业务效果执行 1000 次,订单 1000 个——一一对应

relay 在“消息已确认、标记未写”时崩溃,重启后会再发一次:实测 relay 崩溃 60 次,队列里多出 60 条重复。

配合幂等

实测 · 本机执行输出
  poll  10 ms: p50 8 ms,p99 15 ms
  poll 100 ms: p50 58 ms,p99 111 ms
  poll 500 ms: p50 284 ms,p99 515 ms

下游用事件 id 去重,Outbox 的“不丢”加上消费端的“不重”,就得到端到端的效果恰好一次。

延迟与吞吐

实测 · 本机执行输出
  每批    1: 搬 10000 行用了 3506 ms,2852 行/秒
  每批  100: 搬 10000 行用了 1828 ms,5471 行/秒
  每批 1000: 搬 10000 行用了 1861 ms,5374 行/秒

relay 延迟取决于轮询间隔(p50 约半个间隔);批量标记 sent 能减少数据库提交次数,吞吐提高近一倍。

分步演示

Outbox 模式
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / RabbitMQ 4.3.6 / Erlang/OTP 28.5 / pika 1.4.4(Homebrew 与临时虚拟环境);所有节点都是本机自己起的 RabbitMQ 进程(127.0.0.1,端口 9500 以上,每个实验独立的数据目录与节点名,不连任何外部服务);命令输出来自真实的 rabbitmqctl 与管理 API,发布与消费用 pika 的真实连接;计时用 time.perf_counter,数字每次运行会有小幅抖动。数据库是 sqlite(WAL);崩溃用随机数模拟(固定随机种子);relay 逐条等待 confirm 并只在确认后标记 sent;延迟取每个订单从提交到发布完成,100 个订单取 p50 / p99;吞吐是 10000 行 outbox 的整体耗时,单次测量
    • 没有验证:真实进程被 kill -9 时的行为(这里是在代码里模拟崩溃);多个 relay 实例并发(需要行锁 / SKIP LOCKED);基于数据库日志(CDC)的 outbox;outbox 表的清理策略与增长;事务性发件箱在 PostgreSQL / MySQL 上的实现细节;消息顺序(同一聚合按 id 顺序发,这里没有专门验证)
    京ICP备2026067502号-1京公网安备11010502063487号