bigywater.tech
Kafka 内部原理 · 39

Outbox Pattern:数据库和 Kafka 如何最终一致

用sqlite模拟先提交数据库再单独发Kafka的naive写法,对比把订单和待发事件塞进同一次数据库提交的outbox模式,在真实的进程崩溃面前分别会发生什么。

永久丢失
naive双写在崩溃后事件真实的下场
重复一次
outbox模式在relay崩溃重试后真实的代价

验证:先提交数据库,再单独调Kafka——中间崩溃,事件真实永久丢失

命令 · 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的系统永远不会知道这个订单存在过。

验证:把"要发的事件"和业务数据放进同一次COMMIT

命令 · 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真实成功,订单和"这个订单需要被通知出去"这件事,就真实变成了同一个不可分割的整体:要么两个都在,要么两个都不在,不存在"订单在、待发记录不在"的中间态。

验证:relay进程在发送和标记之间崩溃,重试之后真实的代价是重复

命令 · 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)]
outbox模式真实解决的是"丢失"这个问题,不是"重复"这个问题——它给的是至少一次(at least once)投递保证,不是精确一次。relay进程只要还没真实标记published,重启后就会真实把这条记录当成"没发过"再发一次,哪怕它上一次其实已经真实发出去了。这个真实的代价是可以接受的:数据丢失通常无法挽回,重复消息只要下游做了幂等处理(比如按订单ID去重)就真实没有副作用;反过来如果relay是先标记published再发Kafka,一旦发送失败就会真实永久丢失这条消息——所以顺序必须是"先发送,成功后再标记",宁可重复也不丢。

分步演示:naive双写 vs outbox,真实的差别

每一步都是本文上面用sqlite+Kafka跑出来的原始结果。

outbox真实拿重复换来了不丢
这一步新验证的状态

    结论

    数据库和Kafka之间没有真实的分布式事务,naive的"先提交DB、再单独发Kafka"在崩溃面前真实必然丢数据。outbox模式把"要发的事件"降级成数据库里的一条普通记录,用数据库自己的事务把业务写入和"记下这件事需要通知"绑成原子操作,再用一个可以安全重试的relay进程把这些记录搬到Kafka——重试带来的重复消息,比数据永久丢失真实更容易处理。

    参考与说明

    • 数据来自本机sqlite3(orders/outbox两张表) + Kafka 4.3.1单broker produce,relay是一段真实的Python轮询脚本。
    • 没有验证:真实生产环境常用CDC(如Debezium)监听数据库binlog自动生成outbox事件,而不是本文这种业务代码手动双写outbox表的做法;outbox表的清理和归档策略。
    京ICP备2026067502号-1京公网安备11010502063487号