订单已付款,发货任务去哪了?用 SQLite 实现事务发件箱

老K 10 0

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

订单已付款,发货任务去哪了?用 SQLite 实现事务发件箱
本地实验示意图,两份独立的 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 抢占需要另行验证。

发表评论 取消回复
OwO 图片 链接 代码

分享