Course Builder个人学习工作台 · 本地模式
DDIA 第二版:从数据模型到分布式系统设计

14 · 事务:把名额与报名作为一个决定

目标:用事务和条件更新保证扣减名额与创建报名一起成功或一起撤销。

不变量先于 ACID 字母

系统必须同时满足:名额不为负;每个已确认报名对应一次扣减;重试不能多扣。这些是业务不变量。事务的原子性让一组修改要么全部提交,要么撤销。隔离性约束并发交错,持久性约束提交后故障下的保留;ACID 中的一致性指业务状态正确,不是副本读取的线性一致性。

先 SELECT remaining,在应用中减一,再写回,很容易与另一个请求互相覆盖。条件更新 UPDATE ... SET remaining = remaining - 1 WHERE remaining > 0 把判断和扣减放在数据库中,再检查影响行数。报名记录和扣减还必须处在同一事务里,否则进程可能在两者之间崩溃。

本课使用 SQLite 的显式事务。isolation_level=None 关闭 Python 接口的隐式开启事务行为,代码自己执行 BEGIN、COMMIT、ROLLBACK。BEGIN IMMEDIATE 提前取得写事务;这个单机实验不代表其他数据库具有完全相同的并发策略。

在最坏的位置注入异常

保存 transactions.py。预测第一次扣减之后抛异常,会留下多少名额。

import sqlite3

db = sqlite3.connect(":memory:", isolation_level=None)
db.executescript("""
CREATE TABLE activity(id TEXT PRIMARY KEY, remaining INTEGER CHECK(remaining >= 0));
CREATE TABLE registration(request_id TEXT PRIMARY KEY, user_id TEXT NOT NULL);
INSERT INTO activity VALUES ('a1', 1);
""")

def book(request_id, user_id, fail=False):
    db.execute("BEGIN IMMEDIATE")
    try:
        prior = db.execute("SELECT user_id FROM registration WHERE request_id = ?", (request_id,)).fetchone()
        if prior:
            if prior[0] != user_id:
                raise ValueError("payload mismatch")
            db.execute("COMMIT")
            return "confirmed"
        changed = db.execute("UPDATE activity SET remaining = remaining - 1 WHERE id = 'a1' AND remaining > 0").rowcount
        if changed == 0:
            db.execute("ROLLBACK")
            return "full"
        if fail:
            raise RuntimeError("crash before registration")
        db.execute("INSERT INTO registration VALUES (?, ?)", (request_id, user_id))
        db.execute("COMMIT")
        return "confirmed"
    except Exception:
        if db.in_transaction:
            db.execute("ROLLBACK")
        raise

try:
    book("q1", "u1", fail=True)
except RuntimeError:
    pass
assert db.execute("SELECT remaining FROM activity").fetchone()[0] == 1
assert book("q1", "u1") == book("q1", "u1") == "confirmed"
assert book("q2", "u2") == "full"
assert db.execute("SELECT COUNT(*) FROM registration").fetchone()[0] == 1
print("remaining:", db.execute("SELECT remaining FROM activity").fetchone()[0])
print("registrations:", db.execute("SELECT COUNT(*) FROM registration").fetchone()[0])
db.close()
uv run --python 3.12 --no-project transactions.py

预期输出:

remaining: 0
registrations: 1

第一个调用真的修改了表,但事务回滚撤销了扣减。再次调用成功后,相同键读取已有结果,不再扣减。full 没有保存为永久请求结果;若业务要求同一请求的拒绝结果也稳定,需要另外记录状态。这里固定只有一个活动,后续扩展时必须把活动 ID 纳入请求输入和报名模型。

引导修改:① 把初始名额改为 2;② 让 q2 的期望变为 confirmed;③ 报名数应为 2、名额为 0;④ 再增加不同键,应返回 full。

独立练习:在 INSERT 后、COMMIT 前抛异常,预测两个表的状态并写断言。

参考故障点

将 if fail: raise RuntimeError(...) 移到插入语句之后。恢复初始名额为 1,首次异常之后断言 remaining == 1,并断言 SELECT COUNT(*) FROM registration 等于 0。随后正常重试仍得到一条报名。不要在事务中执行发邮件等无法回滚的外部操作;第 21 课用 Outbox 处理它。

遇到 database is locked 要检查长事务和并发写竞争,设置有限重试而非无限等待;遇到 cannot start a transaction 要检查上次路径是否提交或回滚。实验处理的是异常回滚,不是磁盘断电恢复测试。

把名额与报名的同事务边界、请求键及故障注入结果写入 design.md。

参考资料