跳到内容

第 5 卷 / 第 11 章 / 第 2 课

11.2 并发异常、调度与可串行化 ​


元数据卡

  • 前置:11.1 事务边界、ACID 与失败语义
  • 关键词:并发异常、调度、冲突图、可串行化、重试
  • 代码语言:SQL、调度记号

“两次查询结果不一样”只是表象。分析并发问题时,要同时记录谁读了什么、谁写了什么、修改是否提交,以及业务判断依赖了哪些行或范围。

先分清几种异常 ​

设 R1(A) 表示事务 T1 读取 A,W2(A) 表示 T2 写入 A。

异常关键条件风险
脏读读取另一事务尚未提交的写入依据最终会回滚的数据做决定
脏写覆盖另一事务尚未提交的写入回滚次序无法保持已提交结果
不可重复读再读同一行时看到另一事务已提交的新版本一个事务中的判断基准漂移
幻读再执行同一谓词查询时,满足条件的行集合改变范围不变量失效
丢失更新多个事务基于旧读值计算,再相互覆盖一次业务修改悄然消失
写偏斜各事务读取同一约束集合,却写入不同行单行无冲突,集合约束仍被破坏

“丢失更新”不能只按隔离级别名称判断。下面两种写法的行为不同:

sql
-- 数据库直接在当前行上做原子增量;并发写通常会串行等待
UPDATE counter SET value = value + 1 WHERE counter_id = 1;
text
T1: SELECT value -> 10
T2: SELECT value -> 10
T1: UPDATE ... SET value = 11
T2: UPDATE ... SET value = 11

第二种是应用端读、改、写,更容易丢更新。可用原子 SQL、SELECT ... FOR UPDATE、版本条件或 Serializable 保护,而不是笼统声称某级别“自动解决所有更新丢失”。

调度是操作的交错顺序 ​

上面的 counter SQL 是机制片段,需已有 counter(counter_id, value) 和目标行。用 PostgreSQL 16+ 的 Read Committed 运行两个原子增量,初值 10 应变为 12;在较高隔离级别还可能收到需重试的冲突错误。

串行调度让 T1 的所有操作完成后才运行 T2。可串行化调度允许交错,但成功提交后的效果必须等价于某个串行顺序。

判断冲突可串行化时,两项操作只有在以下条件同时满足时才冲突:

  1. 属于不同事务;
  2. 访问同一逻辑数据项;
  3. 至少一项是写。

因此两次读不冲突,读与写、写与读、两次写会冲突。

用优先图判定冲突可串行化 ​

考虑这个明确写出先后次序的调度:

text
R1(A)  W1(A)  R2(A)  W2(B)  R1(B)  C1  C2

逐对找冲突:

  • W1(A) 在 R2(A) 之前,得到边 T1 → T2;
  • W2(B) 在 R1(B) 之前,得到边 T2 → T1。

优先图出现环 T1 → T2 → T1,所以它不是冲突可串行化调度。本例还包含脏读,仅用于单版本调度理论;PostgreSQL 的普通 SELECT 不会直接复现这种未提交版本读取。若图无环,对图做拓扑排序即可得到一个等价串行顺序。

不要只列出每个事务内部的操作而省略全局交错次序;那样无法确定边的方向,也就无法判环。

冲突可串行化不是全部理论 ​

视图等价还要求:

  • 读取初始值的事务相同;
  • 每次读取所依赖的写入事务相同;
  • 每个数据项的最终写入者相同。

存在带盲写的视图可串行化调度,不是冲突可串行化调度。实际系统更常围绕冲突依赖设计协议,因为冲突图判环直接,而一般的视图可串行化判定困难得多。

还要区分“冲突可串行化的历史”与 SQL 的 Serializable 隔离接口。数据库可以用严格两阶段锁,也可以像 PostgreSQL 一样用 SSI 检测危险的读写依赖;实现不一定在运行时构造本节这张完整教学图。

可恢复性与级联回滚 ​

可串行化描述结果是否等价于串行执行,可恢复性描述提交顺序是否安全。若 T2 读取了 T1 的写入,T2 不应在 T1 之前提交;若 T1 最后回滚,T2 也必须回滚。

在禁止脏读的隔离级别中,读取器不会依赖其他事务尚未提交的写入,从而避免这类级联回滚。MVCC 普通读通常选择已提交的可见版本,并能看到本事务先前的修改;允许 Read Uncommitted 的产品不能套用这个结论。

把重试当作事务接口的一部分 ​

Serializable 不承诺每次尝试都提交,它承诺成功提交的事务具有串行语义。PostgreSQL 重试规则给出的序列化失败 SQLSTATE 是 40001;死锁失败通常是 40P01。应用应重试整个事务,而不是只重放最后一条 SQL。

text
repeat up to retry_limit:
    begin
    try:
        execute all reads, decisions, and writes
        commit
        return success
    catch retryable_transaction_error:
        rollback
        wait with bounded exponential backoff and jitter
fail or send to recovery path

重试必须满足三个条件:

  • 事务外副作用可幂等或延后到 outbox;
  • 每次从头重新读取,不能复用上次失败事务中的判断;
  • 有次数、总时限和监控,避免热点冲突造成无限重试风暴。

练习 ​

给定调度:

text
R1(A)  W2(A)  R3(B)  W1(B)  R2(B)  C1  C2  C3

完成以下任务:

  1. 列出所有事务间冲突边;
  2. 判断图中是否有环;
  3. 若无环,给出一个拓扑序;若有环,指出构成环的边;
  4. 说明仅交换两个不冲突操作能否改变判定。

核对:边为 T1 → T2、T3 → T1、T3 → T2,无环,拓扑序为 T3, T1, T2。交换相邻的不冲突操作不会改变冲突边。本题虽然冲突可串行化,T2 却在 T1 提交前读取其 B 写入;若允许这种读取,仍须单独检查恢复性。

本节检查点 ​

  • 能按“是否提交、同一行还是谓词范围、读后写还是原子更新”描述异常。
  • 能从完整调度构造优先图,而不是凭事务名称猜边。
  • 知道可串行化不等于永不失败,能设计整事务重试边界。

下一章进入 MVCC,解释数据库如何为一次读取选择正确版本,以及同名隔离级别为什么在不同产品中表现不同。

Built with VitePress | Software Systems Atlas