[aru_24] 假设订单后台收到支付成功的通知,数据库里的订单已经变成「已付款」,紧接着处理进程退出。仓库没收到发货任务,可客服查订单时一切正常。我用两份本地 SQLite 数据库重现了另一个更容易漏掉的故障:发货任务已经落库,发送方还没来得及记下结果。这时补发同一条消息,会发生什么?

付款成功和发货通知,中间少了一条记录
直接在订单事务提交后调用发货接口,代码很好懂,但两步之间有个空档。进程在这里停了,订单库里只有「已付款」,没有记录哪张单还欠一条通知。如果把接口调用挪到订单事务里面,发货库仍有可能先提交,随后订单事务回滚。持有订单库的写锁也管不到发货库。
我在订单库里加了张 outbox 表,把付款状态和待投递事件一起提交。这样即使发送进程当时没启动,事件也能留在库里等它回来。这就是事务发件箱(Transactional Outbox)。我用 order.paid:A-001 作为事件编号,约定这个例子里每张订单只会从未付款转为已付款一次。
下面是实验里的 record_paid()。只有 paid = 0 的那次更新会写入事件,重复的付款通知沿用现有状态,订单不存在则直接报错。before_commit 是给测试用的故障注入点。
def record_paid(orders_db, order_id, *, before_commit=None):
event_id = f"order.paid:{order_id}"
payload = json.dumps({"order_id": order_id}, sort_keys=True)
with closing(connect(orders_db)) as db, transaction(db):
changed = db.execute(
"UPDATE orders SET paid = 1 WHERE id = ? AND paid = 0", (order_id,)
).rowcount
if changed == 0:
if db.execute("SELECT 1 FROM orders WHERE id = ?", (order_id,)).fetchone():
return False
raise LookupError(order_id)
db.execute(
"INSERT INTO outbox(event_id, payload) VALUES (?, ?)", (event_id, payload)
)
if before_commit is not None:
before_commit()
return True 实验用 isolation_level=None 配合显式的 BEGIN IMMEDIATE、COMMIT 和 ROLLBACK。在写入两张表后、提交前抛异常,测试重新打开数据库,看到订单仍是未付款,发件箱也没有残留。提交后即使发送端还没有跑,待发事件仍然可以从库里读到。SQLite 事务文档 · Python sqlite3 事务控制
对方收到了,发送方却没记下来
发送端先读一条 sent = 0 的事件,关闭读取连接,再交给接收方。接收方确认后,才另开一个短事务把它标成已投递。整个投递阶段不持有订单库的写锁。这里的 sent = 1 表示发货任务已被接收,物流有没有出库要另外记录。
def relay_one(orders_db, deliver, *, after_delivery=None):
with closing(connect(orders_db)) as db:
pending = db.execute("""
SELECT event_id, payload FROM outbox
WHERE sent = 0 ORDER BY rowid LIMIT 1
""").fetchone()
if pending is None:
return False
event_id, payload = pending
deliver(event_id, payload)
if after_delivery is not None:
after_delivery()
with closing(connect(orders_db)) as db, transaction(db):
db.execute("UPDATE outbox SET sent = 1 WHERE event_id = ?", (event_id,))
return True 接收方把 event_id 设成 shipping_jobs 的主键。对这个实验来说,插入发货任务本身就是副作用,因此一条记录同时承担接收去重和保存任务的职责。收到相同编号和相同载荷时直接确认。相同编号却带了不同载荷时抛出 EventConflict,把冲突留给人工排查。实验比较的是同一条 outbox 记录中保存的 JSON 字符串,重投时原样发送。
def accept_shipping_job(shipping_db, event_id, payload):
with closing(connect(shipping_db)) as db, transaction(db):
existing = db.execute(
"SELECT payload FROM shipping_jobs WHERE event_id = ?", (event_id,)
).fetchone()
if existing is None:
db.execute(
"INSERT INTO shipping_jobs(event_id, payload) VALUES (?, ?)",
(event_id, payload),
)
elif existing[0] != payload:
raise EventConflict(event_id) BEGIN IMMEDIATE 把接收方的查询和插入放进同一个写事务,主键也约束了重复记录。Outbox 允许重复投递,业务上只建一条任务靠的是接收方的约束。AWS 的事务发件箱说明
在两次提交之间退出一次
只在调用接收方之前抛异常,最多能证明「没收到」时可以重试。我还单独启动了一个子进程,让它等接收库提交后直接 os._exit(73),来不及执行发送方的 UPDATE。随后用新的数据库连接查看两边,再重投原事件。
| 检查时机 | 订单库的 sent | 发货库的任务数 |
|---|---|---|
| 付款事务已提交 | 0 | 0 |
| 接收库提交后,发送子进程退出 | 0 | 1 |
| 发送端用原编号重投并收到确认 | 1 | 1 |
第二行最关键:单看 sent = 0,我只知道发送方没有记下确认,猜不出对方有没有执行。测试还专门记录了重启后的投递调用,确认原事件确实发了第二次,任务数仍然是 1。
下载 SQLite Outbox 实验包(ZIP)。解压后进入实验包的目录运行:
python3 -m unittest -v 在 macOS 上,我分别用 Python 3.9.6 / SQLite 3.51.0 和 Python 3.12.14 / SQLite 3.53.1 跑了 7 项用例,均通过。除了进程退出重投,还检查了订单事务回滚、事件提交后待投递、重复付款、订单不存在、接收方写入前失败,以及相同事件编号的载荷冲突。两边都是本地临时库,发货任务只是一条记录。
接到真实发货服务前,我会先问两个问题
第一,对方能否用事件编号查到已经提交的结果?如果支持幂等键,我会用 outbox 中持久保存的原编号重试,并确认对方的去重有效期。如果接口超时后既不能按原编号查询,也不承诺幂等,我会把结果标成待核对,避免直接重发一个可能已经执行的发货指令。
第二,一条坏消息会不会挡住后面所有订单?这个实验每次只处理最早的一条待发事件,遇到持续失败时它会一直留在队首。实际的发送进程需要记录尝试次数和下次重试时间,把长期失败或载荷冲突单独留给排查。多个 worker 同时跑时还要设计任务领取和过期归还。实验里的两份数据库足以复现那次进程退出,重试调度和多 worker 抢占需要另行验证。
🎨 原创不易,支持请点赞、转载请注明本文作者为老K