15 · 隔离级别:从写偏差推导可串行化
目标:画出写偏差的并发时序,并选择能保护读取条件的并发控制方式。
读到同一个快照仍然可能出错
活动至少要有一名负责人。当前 A、B 都值班,两人分别看到还有两名负责人,于是各自取消自己的值班。它们修改不同记录,没有互相覆盖,结果却无人值班。这是写偏差:决策读取的条件被另一个事务改变了。
读已提交避免读取未提交的数据,但前后两条查询仍可能看见不同已提交状态。快照隔离给事务一个稳定快照,适合一致的多表读取,却不自动保证所有跨行不变量。可串行化要求最终效果等价于事务按某个顺序逐个执行;这个顺序不一定符合实际时间先后,第 18 课再区分。
不要把产品名称中的 Repeatable Read 当成完全相同的语义,也不要以“没有脏读”推导“没有写偏差”。必须针对访问模式检查读集、写集和约束。
显式安排错误时序
保存 isolation.py。这是快照交错的模型,并非 SQLite 的实际并发行为。先按 A 读、B 读、A 写、B 写手算。
initial = {"A": True, "B": True}
snapshot_a = dict(initial)
snapshot_b = dict(initial)
concurrent = dict(initial)
if sum(snapshot_a.values()) > 1:
concurrent["A"] = False
if sum(snapshot_b.values()) > 1:
concurrent["B"] = False
def leave(state, person):
if state[person] and sum(state.values()) > 1:
state[person] = False
return True
return False
serial = dict(initial)
assert leave(serial, "A") is True
assert leave(serial, "B") is False
assert sum(concurrent.values()) == 0
assert sum(serial.values()) == 1
print("snapshot_interleaving:", sum(concurrent.values()))
print("serial_execution:", sum(serial.values()))
uv run --python 3.12 --no-project isolation.py
预期输出:
snapshot_interleaving: 0
serial_execution: 1
顺序执行时,B 的判断依据已经包含 A 的修改,因此不能退出。只比较自己那行的版本号不够,因为 A、B 改的是不同行。即使加了“当前负责人行”的锁,也要考虑新行出现的幻影条件和锁范围。
三种可串行化路线
直接串行执行把决策依次完成,适合短事务或可分片的工作。两阶段锁(2PL)通过读写锁以及必要的范围或谓词保护阻止危险交错,代价包括等待与死锁。可串行化快照隔离(SSI)监测读写依赖,必要时中止事务,应用重新读取并重试整个事务。2PL 是并发控制;第 16 课的 2PC 是跨节点提交协议,两者不是同一件事。
另一种具体办法是把“活动负责人集合”的修改汇聚到同一活动守卫记录,所有退出和加入事务先锁定它,再检查人数。这称为把逻辑冲突物化,前提是所有相关写路径都遵守同一约定,不能漏掉后台工具。
引导练习:① 把顺序执行改成先 B 后 A;② 改相应断言;③ 仍应剩一名负责人,只是身份不同。保证的是不变量,不是特定请求一定获胜。
独立练习:两个用户同时预订同一时间段的会议室,当前查询都找不到冲突。只锁查询返回的行为什么没用?提出一种解决方式。
参考时序与修复
查询为空,没有已有记录可锁。两个事务各插入一条重叠预订,形成基于“没有记录”的写偏差。可以先锁该会议室固定存在的守卫行,再检查时间段并插入;也可以使用数据库支持的范围约束或合适的可串行化事务。并发控制导致中止后要重新执行检查,不能直接重放最后一条插入。
若事务仍违反约束,先找是否有写路径绕开守卫;若反复重试,检查冲突热点、事务长度和退避。不要用固定睡眠作为同步协议。在 design.md 标出单行名额与跨行负责人约束采用的不同保护方式。