订单入库后要通知别的服务。先写库再发消息,或者先发消息再写库,都会在两步之间丢东西或多东西。Outbox 把事件和订单放进同一个本地事务。这篇用真实的 MySQL 和 Kafka、真的 kill -9,把双写的问题和 Outbox 的保证与代价量出来。
A(先写库,再发消息),在两者之间被杀:订单库里 有订单 A-1,Kafka 里 没有事件 → 订单已存在、事件丢了(下游永远不知道) B(先发消息,再写库),在两者之间被杀:订单库里 没有订单 B-1,Kafka 里 有事件 → 事件发出去了、订单不存在(下游看到了一个不存在的订单)
先写库再发消息:进程在两者之间死掉,订单有而事件丢。先发消息再写库:事件有而订单没有。两个系统没有共同的提交点。
kill -9 之后:订单库 ['O-1'] ,outbox 表 [(1, 'O-1', 'OrderCreated', 0)] ,Kafka ev-O [] relay 跑一轮:发出 1 条;Kafka ev-O ['O-1'] ,outbox sent 标记 [1] 事务如果回滚,事件跟着回滚: 回滚之后:订单库有 O-2 吗? False ,outbox 有 O-2 吗? False
订单和事件写在同一个本地事务,回滚时一起回滚,提交后即使进程立刻崩溃,事件还在 outbox 表里,relay 补发。
写法 | 提交成功的订单 | Kafka 里不同的事件数 | 丢失的事件 | 幽灵事件(没有订单) | 重复投递的条数 -------------------------------+---------+---------------+-------+------------+-------- A 先写库再发消息(发布失败率 3%) | 1000 | 966 | 34 | 0 | 0 B 先发消息再写库(提交失败率 3%) | 970 | 1000 | 0 | 30 | 0 Outbox(提交失败率 3%,relay 崩溃 20 次) | 973 | 973 | 0 | 0 | 20 A 丢事件,B 出现幽灵事件;Outbox 既不丢也没有幽灵,代价是至少一次投递——relay 在“已发出、未标记”处崩溃就会重发,消费者必须能处理重复(第 14、15 篇)
3% 的失败率,1000 单:A 丢 34 个,B 多 30 个幽灵,Outbox 丢 0、幽灵 0,但 relay 崩溃造成 20 条重复。
只写订单:中位数 98 µs;订单 + outbox 一行:中位数 137 µs(多 40%) outbox 表 2 万行(payload 约 40 字节的 JSON)占 2.8 MiB,平均每行约 149 字节(含 (sent, id) 索引);发出去的行不清理会一直涨
多写一行,延迟多 40%,每行约 149 字节,发出去的行要清理。