“先改数据库、再发消息”看似简单,进程在两步之间崩溃就会让数据库和下游不一致。Outbox 模式是业界标准解法。它到底解决什么、引入什么新问题、relay 的延迟和吞吐是多少?这篇用真实的数据库和 RabbitMQ 复现。
数据库里订单 1000 条,队列里消息 942 条,丢了 58 条消息(= 崩溃 58 次)——下游永远不知道这些订单 反过来(先发消息、再写库,中间崩溃)会得到“有消息、没订单”的幽灵事件;两种顺序都不对
写数据库和发消息不在同一个事务里,两步之间崩溃就会不一致:实测先写库后发消息会丢消息。
relay 一共崩溃 60 次、跑了 62 轮;订单 1000 条;队列里消息 1060 条 不同的 message_id 1000 个(= 订单数,没有丢);重复 60 条(崩溃那一刻已发出、还没标记的消息,重启后又发了一次) 所以 outbox 把“可能丢”换成了“可能重复”——至少一次;下游要用 message_id 去重(见第 25 篇)
把“待发送事件”和业务数据放进同一个数据库事务,由 relay 单独轮询发送、确认后标记。这样不会丢,但 relay 在“已发送、未标记”时崩溃会重复发送,所以是至少一次(见下)。
收到的消息(按内容 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 能减少数据库提交次数,吞吐提高近一倍。