用sqlite模拟先提交数据库再单独发Kafka的naive写法,对比把订单和待发事件塞进同一次数据库提交的outbox模式,在真实的进程崩溃面前分别会发生什么。
命令 · python (sqlite3 + confluent-kafka) def naive_write(order_id, item): conn.execute("INSERT INTO orders VALUES(?,?)", (order_id, item)) conn.commit() # 数据库事务真实已经提交 raise RuntimeError("模拟:提交之后、调用Kafka produce之前进程崩溃") p.produce("order-events", ...) # 真实永远不会跑到这一行
naive方式真实崩溃: 模拟:进程在DB提交之后、真正调用Kafka produce之前崩溃 数据库里订单1: (1, 'widget-A') (已经真实存在) order-events topic真实收到的消息: [] (应该是空——订单1的事件永远丢了)
数据库和Kafka是两个真实独立的系统,各自有自己的事务边界,没有任何机制能让"insert到sqlite"和"produce到Kafka"这两个动作真实变成一个原子操作。真实的结果就是:只要中间有一次崩溃、网络抖动或者进程被杀掉,订单表里真实已经有这条记录,但下游任何订阅order-events的系统永远不会知道这个订单存在过。
命令 · python def outbox_write(order_id, item): conn.execute("INSERT INTO orders VALUES(?,?)", (order_id, item)) conn.execute("INSERT INTO outbox(payload) VALUES(?)", (f"order-created:{order_id}:{item}",)) conn.commit() # 订单和待发消息真实在同一次COMMIT里落盘
数据库里订单2: (2, 'widget-B') outbox表里待发送的记录: [(1, 'order-created:2:widget-B', 0)] (哪怕relay进程还没跑,这条记录已经真实安全落盘)
outbox表和orders表是同一个sqlite数据库里的两张普通表,插入两条记录、一次commit——这本来就是sqlite单机事务真实原生支持的能力,不需要引入分布式事务。只要这次commit真实成功,订单和"这个订单需要被通知出去"这件事,就真实变成了同一个不可分割的整体:要么两个都在,要么两个都不在,不存在"订单在、待发记录不在"的中间态。
命令 · python def relay_once(): rows = query_unpublished() for row in rows: p.produce("order-events", row.payload) p.flush() raise RuntimeError("模拟:Kafka发送成功之后、标记published之前崩溃") mark_published(rows) # 真实没跑到
relay第一次运行真实崩溃: 模拟:relay进程在Kafka发送成功之后、标记published之前崩溃 order-events topic真实收到: ['order-created:2:widget-B'] (order 2的消息真实已经发出去了) outbox表状态: [(1, 'order-created:2:widget-B', 0)] (published还是0,因为标记那一步没跑到) relay重新跑一次(模拟进程重启后重试)... order-events topic真实收到(第二次relay之后): ['order-created:2:widget-B', 'order-created:2:widget-B'] (order 2的消息真实重复了一次——outbox给的是至少一次投递) outbox表最终状态: [(1, 'order-created:2:widget-B', 1)]
每一步都是本文上面用sqlite+Kafka跑出来的原始结果。
数据库和Kafka之间没有真实的分布式事务,naive的"先提交DB、再单独发Kafka"在崩溃面前真实必然丢数据。outbox模式把"要发的事件"降级成数据库里的一条普通记录,用数据库自己的事务把业务写入和"记下这件事需要通知"绑成原子操作,再用一个可以安全重试的relay进程把这些记录搬到Kafka——重试带来的重复消息,比数据永久丢失真实更容易处理。