订单状态没错,确认事件却有两条:重复请求应该检查什么?

6 阅读5分钟

AI SaaS 工程诊断实战(三)

确认按钮点两次,订单仍然显示“已确认”。只看页面,这像是没有问题;如果后台每次都登记一次确认事件,下游通知或统计仍可能重复。

这篇用一个教学构造程序说明:最终状态正确,不代表操作的副作用也正确。案例只在单连接、顺序调用条件下运行,不把它包装成分布式幂等方案。

先把一次确认的规则写清楚

这里约定四件事:草稿订单可以确认;已经确认的订单再确认,返回 already_confirmed;取消订单不允许确认;未知 ID 报错。确认状态的改变与本地确认事件,应在同一事务中完成。

规则之所以要先说,是因为“重复调用应该怎样处理”没有唯一答案。有的业务可以返回同一结果,有的需要记录每次尝试,但尝试日志与业务确认事件应分别建模。本例关心的是后者只产生一次。

一个容易漏掉的错误

with db:
    db.execute("UPDATE orders SET status='confirmed' WHERE id=?", (order_id,))
    db.execute("INSERT INTO events VALUES (?, 'confirm')", (order_id,))

每次调用都写 confirmed,每次调用也都插入事件。跑两次以后,状态是 confirmed,事件数量是 2。只验证状态的测试会通过;检查事件的测试会失败。

定位到这里后,先别急着加一个“已处理 ID 集合”。要问这个集合放在哪里,重启后是否存在,多实例能否共享,哪些动作算处理完成。对当前最小问题,更直接的办法是把允许的状态转换写进操作。

在事务里完成状态转换与本地事件

修复版先读取状态,区分未知、已确认和不允许的状态;真正转换时,再让 UPDATE 带上 status='draft' 条件,并核对受影响行数。只有转换成功,才插入本地事件。

为什么读取后还保留条件?因为代码应表达它允许的转换,而不是“无论旧状态是什么,都覆盖成新状态”。不过这里的检查不等于已经验证了多连接并发:并发锁、重试与异常仍需要单独设计和测试。

状态与事件在同一个事务块内。演示还提供一个故障注入点:状态更新后、事件插入前抛出异常。我们检查异常后的真实存储,预期状态回到 draft,事件数量仍为 0。

这一项检查支持“本例事务内异常时两项本地写入一起回滚”。它不证明进程被强制终止、磁盘损坏或外部消息发送的行为。

本次得到的结果

场景状态确认事件数调用结果
故障版顺序调用两次confirmed2缺少重复保护
修复版在更新后注入异常draft0抛出异常并回滚
修复版首次确认confirmed1confirmed
修复版再次确认confirmed1already_confirmed

取消订单与未知订单的调用也被拒绝。这里每个数字都来自下方程序的数据库查询,而不是函数声称的成功。

完整可运行示例

保存为 demo.py,使用 Python 3.12 运行 python demo.py,无需额外依赖。程序包含故障版、修复版和断言;异常注入是为验证事务而特意加入的教学开关。

import json
import sqlite3


def setup():
    db = sqlite3.connect(':memory:')
    db.executescript('''
        CREATE TABLE orders(id TEXT PRIMARY KEY, status TEXT NOT NULL);
        CREATE TABLE events(order_id TEXT, action TEXT);
        INSERT INTO orders VALUES ('A', 'draft');
        INSERT INTO orders VALUES ('C', 'cancelled');
    ''')
    return db


def bad_confirm(db, order_id):
    with db:
        db.execute("UPDATE orders SET status='confirmed' WHERE id=?", (order_id,))
        db.execute("INSERT INTO events VALUES (?, 'confirm')", (order_id,))


def confirm(db, order_id, fail_after_update=False):
    with db:
        row = db.execute('SELECT status FROM orders WHERE id=?', (order_id,)).fetchone()
        if row is None:
            raise LookupError(order_id)
        if row[0] == 'confirmed':
            return 'already_confirmed'
        if row[0] != 'draft':
            raise ValueError('cannot confirm from ' + row[0])
        changed = db.execute(
            "UPDATE orders SET status='confirmed' WHERE id=? AND status='draft'",
            (order_id,)).rowcount
        if changed != 1:
            raise RuntimeError('state changed')
        if fail_after_update:
            raise RuntimeError('injected failure')
        db.execute("INSERT INTO events VALUES (?, 'confirm')", (order_id,))
    return 'confirmed'


def inspect(db):
    return {
        'status': db.execute("SELECT status FROM orders WHERE id='A'").fetchone()[0],
        'events': db.execute("SELECT COUNT(*) FROM events WHERE order_id='A'").fetchone()[0],
    }


if __name__ == '__main__':
    db = setup()
    bad_confirm(db, 'A')
    bad_confirm(db, 'A')
    bad = inspect(db)
    db.close()
    db = setup()
    try:
        confirm(db, 'A', fail_after_update=True)
    except RuntimeError:
        pass
    rollback = inspect(db)
    first, repeat = confirm(db, 'A'), confirm(db, 'A')
    good = inspect(db)
    rejected = {}
    for order_id in ('C', 'missing'):
        try:
            confirm(db, order_id)
            rejected[order_id] = False
        except (ValueError, LookupError):
            rejected[order_id] = True
    assert bad == {'status': 'confirmed', 'events': 2}
    assert rollback == {'status': 'draft', 'events': 0}
    assert good == {'status': 'confirmed', 'events': 1}
    assert repeat == 'already_confirmed' and all(rejected.values())
    print(json.dumps(dict(bad=bad, rollback=rollback, first=first, repeat=repeat,
                         good=good, rejected=rejected), indent=2))
    db.close()

接到真实 SaaS 时,要再补哪几层

如果只是数据库内的确认事件,事务能把本地写入组合起来。如果事务里直接发送邮件或调用支付、物流等外部服务,数据库回滚并不能撤回已经发生的外部动作。此时需要另行设计可靠投递、去重与恢复,而不是把外部调用放进 with 块就结束。

如果同一业务允许多次确认、撤销再确认,幂等标识和事件唯一性也需要按业务周期定义,不能直接用“每个订单最多一条事件”覆盖所有阶段。

本篇没有进行多进程、并发请求、跨库事务和外部服务测试,因此不宣称端到端 exactly-once。想继续推进,应先列出真实副作用,再为每个副作用决定唯一性、失败恢复与验收方式。

关于本例使用的事务上下文行为,参考 Python sqlite3 官方说明。

—— Linksass|AI SaaS 独立开发者。AI 辅助整理;教学代码已在本地执行,未使用客户数据。