重复请求不应重复扣库存:用SQLite验证幂等键与事务回滚

举报
yd_234095983 发表于 2026/10/30 09:04:06 2026/10/30
【摘要】 用合成库存与44项本地检查验证幂等键、同键异参、事务回滚及整数边界。仅单连接内存SQLite实验,明确不等同生产级或分布式恰好一次。AI辅助起草与验证。

作者:lgbisha。本文由AI辅助起草、编写示例并在本地执行验证。所有事件和库存均为合成数据,仅使用内存SQLite数据库;不包含真实订单、生产库存或云部署,也不是经过并发压测的库存服务。

一次请求超时后,客户端可能重试。对于修改状态的操作,问题不只是“第二次能否成功”,更是“第一次是否已经成功”。本文把范围缩到一个商品、一条数据库连接,用可观察的状态验证两件事:相同事件不能重复修改库存;业务失败不能留下一个假装成功的事件标记。

一、先区分三种输入

假设初始库存100,事件receive-1表示入库20:

请求 希望看到的结果
首次receive-1, +20 库存120,保存事件,返回True
再次receive-1, +20 库存仍120,事件仍一条,返回False
再次receive-1, +21 拒绝:同一键对应不同内容

最后一行很重要。只判断“这个键出现过”,会把调用者误用幂等键也当作正常重放。这个演示只有delta一个业务字段,所以直接比较整数;真实接口应定义完整的请求语义、租户范围及规范化策略,不能照搬为只比较金额或数量。

二、让事件标记和库存修改处于同一事务

以下代码在Python 3.12.3、SQLite 3.45.1上执行。示例显式选择LEGACY_TRANSACTION_CONTROL及DEFERRED,不依赖未来版本的默认事务模式。with connection处理事务退出时的提交或回滚,但不负责关闭连接,因此最后仍需close()。

import sqlite3


def new_store():
    connection = sqlite3.connect(
        ":memory:",
        autocommit=sqlite3.LEGACY_TRANSACTION_CONTROL,
        isolation_level="DEFERRED",
    )
    connection.executescript("""
        CREATE TABLE stock (
            item_id INTEGER PRIMARY KEY,
            units INTEGER NOT NULL CHECK (
                typeof(units) = 'integer' AND units >= 0
            )
        );
        CREATE TABLE applied_change (
            event_id TEXT PRIMARY KEY NOT NULL,
            delta INTEGER NOT NULL
        );
        INSERT INTO stock VALUES (1, 100);
    """)
    return connection


def apply_change(connection, event_id, delta):
    if not isinstance(event_id, str) or not event_id.strip():
        raise ValueError("event_id必须是非空字符串")
    if type(delta) is not int or not -1000 <= delta <= 1000 or delta == 0:
        raise ValueError("演示delta必须是-1000到1000之间的非零整数")
    if connection.in_transaction:
        raise RuntimeError("必须传入无活动事务的连接")
    with connection:
        inserted = connection.execute(
            "INSERT INTO applied_change VALUES (?, ?) "
            "ON CONFLICT(event_id) DO NOTHING",
            (event_id, delta),
        )
        if inserted.rowcount == 0:
            previous = connection.execute(
                "SELECT delta FROM applied_change WHERE event_id = ?",
                (event_id,),
            ).fetchone()[0]
            if previous != delta:
                raise ValueError("同一event_id对应了不同delta")
            return False
        updated = connection.execute(
            "UPDATE stock SET units = units + ? "
            "WHERE item_id = 1 AND units + ? >= 0",
            (delta, delta),
        )
        if updated.rowcount != 1:
            raise ValueError("库存不足或演示商品不存在")
    return True

event_id是数据库唯一键。这里先尝试插入事件,并且仅针对该键的冲突执行DO NOTHING,再根据是否插入判断首次请求或重放。其他数据库错误仍然向外传播,不能用一个宽泛的异常捕获把约束错误全部吞成“已经处理”。

库存更新没有在Python里先查余额再写,而是把非负条件放在同一条UPDATE中。若更新不到目标行,抛出异常;此前刚插入的事件也跟着回滚。stock还有整数类型与非负约束,避免示例中的整数溢出变成浮点库存后悄悄通过。

这里没有使用REPLACE覆盖旧事件,也没有拼接SQL字符串。函数拒绝已有活动事务的连接,避免离开with时把调用者此前的未提交工作一起提交或回滚。这是此函数的接口限制,不是通用事务组合方案。

三、失败后,幂等键不能被提前占住

把上一段和下面代码放在同一个Python文件即可运行:

connection = new_store()
try:
    assert apply_change(connection, "receive-1", 20) is True
    assert apply_change(connection, "receive-1", 20) is False
    try:
        apply_change(connection, "ship-1", -121)
    except ValueError:
        pass
    else:
        raise AssertionError("应当拒绝库存不足")

    assert connection.execute("SELECT units FROM stock").fetchone()[0] == 120
    assert connection.execute(
        "SELECT COUNT(*) FROM applied_change WHERE event_id = ?",
        ("ship-1",),
    ).fetchone()[0] == 0
    assert apply_change(connection, "ship-1", -120) is True
    assert apply_change(connection, "ship-1", -120) is False
    assert connection.execute("SELECT units FROM stock").fetchone()[0] == 0
finally:
    connection.close()

第一次ship-1, -121失败时,表中没有这个事件,所以随后可以用同一键提交修正后的-120。这是“失败不占键”的演示策略,并非所有幂等协议都必须如此:有些系统会持久保存失败响应,要求修改请求后换键,应由接口合同明确。

最后一次重放发生在库存已经为0时,仍应返回False,而不是再次检查是否能扣120。因为这次请求对应的状态变更早已发生,重放不应该再次执行。

四、本地验证结果与覆盖范围

完整本地检查脚本执行结果:44项检查通过,0失败。上面的短代码只展示核心路径,不等同于完整44项测试清单;完整检查还读取事件表、事务状态,并覆盖下列分组:

分组 实际检查
首次及重放 首次生效、重复不生效、不同键可有相同delta
同键异参 抛出ValueError,原事件与库存不变
业务失败 库存不足时事件回滚,修正后可重新提交
输入边界 空键、非字符串、布尔值、0、浮点数、字符串及超出演示范围的delta被拒绝
事务所有权 活动事务被拒绝且保留给调用者,调用者可自行回滚
SQL及整数边界 SQL样式文本仅作为键保存;最大有符号64位整数再加1触发约束,库存和事件一起回滚

44项是断言覆盖数量,不是44种生产故障,更不代表并发正确性或崩溃恢复已经验证。测试使用一个进程、一条连接、内存数据库,没有模拟断电、锁等待、网络超时或多个写入者。

五、不能从这个小实验推出什么

  • :memory:随连接关闭消失,演示了事务内的状态变化,没有验证磁盘持久化与进程重启后的去重。
  • 事件和库存都在同一数据库事务中;若中间还要调用外部支付、发短信或发送消息,数据库回滚不会自动撤销外部副作用。
  • 没有实现认证、租户隔离、多商品、锁等待重试、事件过期、响应缓存或运维备份。
  • 不同数据库、驱动及事务模式需要独立验证。不能把本例直接宣布为分布式“恰好一次”交付。
  • delta的范围只是为了让输入规则具体可测,不是行业库存规则。空白字符串会被拒绝,但非空键没有被自动裁剪或大小写折叠。

结论

重试安全需要先定义“同一次操作”的身份,再定义失败后的状态。这个小实验给出了两个可直接观察的证据:相同成功事件不重复更新;库存修改失败时,事件标记也不会独立留下。把证据限制在已运行的范围内,比把一次成功执行扩写成“生产级幂等”更可靠。

参考资料:Python 3.12标准库官方文档sqlite3,重点为事务控制、连接上下文管理器、参数占位符及Connection.in_transaction;SQLite官方文档《UPSERT》《CHECK constraints》。本文代码和44项结果来自上述合成实验,不以官方文档代替本地执行证据。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。