Lecture 19: Transactions and Concurrency Control — 事务与并发控制

目录 · ← l18 · l20 →

Lecture 19: Transactions and Concurrency Control — 事务与并发控制

讲义对应:CS 425 FA2026 Lecture 21「Concurrency Control, Transactions」。本章素材取自原始讲义 L19-20.FA25.pdf(Lecture 19-20: RPCs and Concurrency Control,57 页)的后半部分(事务与并发控制):事务的定义与 ACID、串行等价(serial equivalence)、冲突操作判定、悲观并发控制(锁与两阶段锁)、乐观并发控制、时间戳排序、多版本并发控制,以及死锁问题的完整处理。该讲义前半部分的 RPC/RMI、stub/dispatcher、marshalling 与三种调用语义属于进程间通信一章,本章不重复。 边界声明:本章只讲单机/一般的事务与并发控制分布式事务、两阶段提交(2PC)、Atomic Commit 问题、复制控制(one-copy serializability)由 Lecture 20 章节负责(素材 L21.FA25.pdf);分布式死锁检测所依赖的一致性全局快照见「Snapshots」一章(Chandy-Lamport 算法,素材 L13.FA25.pdf),本章只引用其结论。 教材对应:Coulouris 5th Ed. Ch. 16 Transactions and Concurrency Control(16.1 事务的 ACID、16.2 嵌套事务、16.3 锁、16.4 乐观并发控制、16.5 时间戳排序);补充 Ch. 17(分布式事务,见 Lecture 20)、Sec 14.5(全局快照)。 阅读材料:Bernstein, Hadzilacos & Goodman, Concurrency Control and Recovery in Database Systems(Ch. 3 锁、Ch. 4 时间戳、Ch. 5 多版本);Kung & Robinson, On Optimistic Methods for Concurrency Control, ACM TODS 1981;Berenson et al., A Critique of ANSI SQL Isolation Levels, SIGMOD 1995;Cahill, Röhm & Fekete, Serializable Isolation for Snapshot Databases, SIGMOD 2008(SSI)。

19.1 概述

单个客户端的操作序列一旦跨越多个服务器对象(讲义里的订票例子:先查余票、再订票、再选座位),就必然会遇到一个新问题:如果执行到一半机器崩了,或者另一个客户端同时在改同一份数据,我们怎么保证这些操作”要么一起成功、要么一起不做”? 这个问题的答案就是事务(Transaction)——一个原子的、不可分割的操作序列,加上 ACID 四条性质(原子性、一致性、隔离性、持久性)。而其中最难、也最有技术含量的一条是隔离性:当许多事务同时在跑,我们要让它们”互不干扰”,效果等价于它们一个一个串行执行。为隔离性服务的整套机制,就是本章的主角——并发控制(Concurrency Control)

本章的主线可以概括成一条推理链:串行执行天然正确 → 并发的目标是”等价于某个串行执行” → 把这个等价性形式化为”冲突可串行化” → 用优先图无环来判定 → 于是得到四类实现手段:锁(2PL,悲观)、验证(OCC,乐观)、时间戳排序(TO,全局定序)、多版本(MVCC,读写分离)。 每类手段都能保证可串行化,但都要付出各自的代价:锁会死锁,验证会回滚,时间戳会饥饿,多版本会写偏斜。

这门课的位置:它向前承接 RPC(事务的每个操作往往就是一次 RPC)与「复制」主题,向后直接通向 Lecture 20 的分布式事务与 2PC(当对象分布在多个服务器上时,原子提交本身变成一个共识问题),也向回呼应快照与全局状态一章(分布式死锁检测必须建立在一个一致性全局快照之上)。

19.2 核心概念与分布式机制图解

19.2.1 事务(Transaction)

  • 定义与目的事务是客户端执行的一个操作序列,其中每个操作都是对服务器的远程调用(RPC);这些操作要么全部完成并提交(commit)——把更新反映到服务器对象上,要么全部不生效(abort)——服务器上不留任何痕迹。讲义用订票程序的伪代码给出了最直观的样子:
int transaction_id = openTransaction();
x = server.getFlightAvailability(ABC, 123, date);   // read(ABC, 123, date)
if (x > 0)
    y = server.bookTicket(ABC, 123, date);          // write(ABC, 123, date)
server.putSeat(y, "aisle");                         // write(ABC, 123, date)
// commit entire transaction or abort
closeTransaction(transaction_id);
  • 直观解释(”它是什么?”):事务就像银行转账。从 A 账户扣 100 元和给 B 账户加 100 元是两条独立的写操作,如果系统在两条之间崩溃,钱就会凭空消失(A 少了 100,B 没多)。事务把这两条写打包成一个不可分割的单位:要么”扣款 + 入账”都发生,要么两条都不发生。现实类比:事务像婚礼上的誓言——要么两人都说了”我愿意”,要么这场婚礼根本不算数,绝不会出现”只有一方说了我愿意”的中间状态被别人看到。
  • 机制图解:事务的生命周期与两种收尾方式。
         BEGIN / openTransaction()
                 │
                 ▼
        ┌──────────────────────┐
        │  读操作 R(x) / 写操作 W(x) │  ← 每个操作可能是一次 RPC
        │  (可以反复读写多个对象)   │
        └──────────┬───────────┘
                   │
        ┌──────────┴───────────┐
        ▼                      ▼
   COMMIT(提交)          ABORT(中止/回滚)
   效果全部落到服务器        服务器上不留任何效果
   数据进入"永久状态"        事务可以重试(retry)
  • 关键假设与系统模型:一个事务被抽象成”由若干读写操作组成、以 commit 或 abort 结尾”的原子单位;操作作用于命名对象(数据项)。两个必须面对的现实是:① 客户端和服务器都可能崩溃② 多个客户端的事务会并发执行。这两点分别把事务推向”恢复(recovery)”与”并发控制(concurrency control)”两个子问题——本章主要解决后者。

19.2.2 ACID 四性质(逐一严格定义)

事务被要求同时满足四条性质,讲义原文如下(并补充了每条的实现机制):

① 原子性(Atomicity)—— all or nothing

  • 事务要么完整成功地完成,其效果被记录到服务器对象上;要么完全没有效果。这是”故障时的全有或全无”。
  • 实现机制:日志(logging)与恢复(recovery)。数据库在执行每个操作前先把”要做什么/怎么撤销”写进日志(undo 日志记旧值以便回滚,redo 日志记新值以便重放),崩溃重启后根据日志决定撤销(undo)未提交事务重放(redo)已提交事务。日志必须先于数据落盘,否则崩溃后无从恢复。

② 一致性(Consistency)

  • 如果服务器一开始处于一致状态,那么事务结束时服务器仍处于一致状态。这里的”一致”指的是不违反完整性约束(integrity constraints),例如”账户余额 ≥ 0”、”转账前后两个账户的总和不变”、”每个订单必须对应一个已存在的用户”。
  • 注意:这个 C 是应用/业务语义层面的,与 CAP 定理里的 C(线性一致性,linearizability)完全不同——见 19.2.3 的澄清表。数据库只能帮你检查声明式约束(外键、CHECK、唯一索引),“转账前后总额不变”这类业务不变量最终要靠应用自己来保证

③ 隔离性(Isolation)

  • 一个事务必须不受其他事务干扰地执行;其他事务不得看到它的中间(非最终)状态。等价说法:并发执行的效果必须等同于某个串行执行
  • 这是并发控制要解决的问题,也是本章 80% 篇幅的所在。

④ 持久性(Durability)

  • 事务成功完成之后,它的所有效果都被保存到永久存储(permanent storage)中,即使随后系统崩溃也不会丢失。
  • 实现机制:预写日志(Write-Ahead Logging, WAL) + fsync。所谓 write-ahead,就是”日志先落盘,数据页可以稍后落盘“:提交时至少保证这条 commit 记录已经 fsync 到磁盘,之后即便内存里的脏页全部丢失,重启后也能用 redo 日志把它们重新做出来。

19.2.3 澄清一个最常见的混淆:ACID 的 C ≠ CAP 的 C

维度ACID 的 C(Consistency)CAP 的 C(Consistency)
所属层次应用/业务语义层(不变量与完整性约束)分布式副本语义层(多副本对外表现)
精确含义事务把数据库从一个满足约束的状态带到另一个满足约束的状态所有副本对同一数据呈现同一份”最新”值,读总能读到最近一次写(可线性化)
谁来保证应用 + 声明式约束;数据库只能检查外键、CHECK、唯一性等复制协议:quorum 读写、共识(Paxos/Raft)、主从复制 + 线性一致读
能否被单一机制保证不能:业务不变量(如”总额守恒”)必须由应用在事务里显式维护能:由系统对客户端透明地保证
与 A/I/D 的关系C 是 A + I + D 共同作用后在业务上看到的结果与 A(可用性)在分区时构成 CAP 权衡
典型误解“用了事务,系统就强一致了”“ACID 里的 C 就是 CAP 里的 C”

一句话记忆:ACID 的 C 是”钱不能凭空多出来或消失”,CAP 的 C 是”所有副本说法一致”。前者是业务不变量,后者是副本一致性;事务给不了后者,复制协议也给不了前者。

19.2.4 事务的两种失败处理:abort 与 crash

事务失败有两条性质完全不同的路径,实现上的处理方式也不同:

 abort(中止 / 回滚)crash(崩溃)
触发者应用主动放弃(用户取消、约束冲突)、并发控制协议判定冲突进程/机器/电源故障,或服务器被杀死
系统是否知情知情:这是一次正常的控制流事后才知情:重启时通过日志发现
处理方式撤销(undo)本次事务已做的写,释放其持有的锁,回到事务开始前的状态重启后扫描日志:撤销所有未提交事务重做已提交事务的更新
能否重试可以,通常是立即或稍后重试(retry)客户端通常需要重新发起整个事务
对客户端语义客户端应当预期”事务可能失败”,必须写重试逻辑连接断开,客户端需要重新连接并重做

实践含义:abort 是”常态”,不是异常。任何声称”事务一定成功”的应用代码都是错的——乐观协议下 abort 可能频繁发生,锁协议下死锁牺牲者也会收到 abort。

19.2.5 为什么需要并发:吞吐、利用率与响应时间

  • 提高吞吐(throughput):一个事务在等磁盘 I/O 或网络往返时,CPU 是空闲的;让另一个事务占用 CPU,可以把CPU 与 I/O 重叠起来,每秒完成的事务数(transactions per second, TPS)因此显著提高。
  • 提高资源利用率:减少 CPU 空转、减少磁盘队列空闲,让昂贵的硬件不闲着。
  • 降低平均响应时间:短事务不必排在一个长事务后面等它跑完(head-of-line blocking)。
  • 经济动机(讲义的说法很直白):“Transactions per second directly related to revenue of companies” —— 每秒事务数直接关系到公司营收,所以这个指标必须被最大化。

反过来,最朴素的”正确做法”是把事务一个一个串行执行(服务器一次只跑一个事务)。它天然正确,但把并发度压到了 1,把上面三条收益全部丢掉。于是整个并发控制的目标可以精确表述为:

在保持 ACID(尤其是隔离性)正确性的前提下,尽可能提高并发度。

19.2.6 并发带来的三类经典异常(含交错时序图)

先约定记号:$R_i(x)$ 表示事务 $T_i$ 读数据项 $x$,$W_i(x)$ 表示 $T_i$ 写 $x$。下面沿用讲义里的机票例子:服务器上有数据项 ABC123ABC789 表示两条航线的余票数。

异常一:丢失更新(Lost Update Problem)

两个事务都”读—改—写”同一个数据项,后写者覆盖了先写者的更新。

时刻   事务 T1                       服务器数据项 ABC123        事务 T2
────   ────────────────────────      ────────────────────      ────────────────────────
 t1    R1(ABC123) → 读到 x = 10       seats = 10
 t2                                                             R2(ABC123) → 读到 x = 10
 t3    W1(ABC123, 10-1 = 9)           seats = 9
 t4                                                             W2(ABC123, 10-1 = 9)
 t5    commit                                                   seats = 9
 t6                                                             commit
                                                                最终 seats = 9 ✗

 正确结果:串行执行(T1;T2 或 T2;T1)都得到 seats = 8
 事实  :两个事务各卖出一张票,却只扣了一张 —— T1 的更新被"丢失"了

两个事务都基于同一个旧值 10 计算新值 9,于是第二次写覆盖了第一次写。要发现它,只需比较冲突操作对的顺序:$(R_1, W_2)$ 给出 $(T_1,T_2)$,$(R_2, W_1)$ 给出 $(T_2,T_1)$——两对的顺序不一致,历史不可串行化(讲义称 “Caught!”)。

异常二:不一致检索 / 脏读(Inconsistent Retrieval Problem)

一个事务在读另一个事务未完成的中间状态:讲义的例子是”转账”与”对账”并发。

时刻   事务 T1(从 ABC123 转 5 张到 ABC789)        服务器状态            事务 T2(对账:求总票数)
────   ──────────────────────────────────────      ──────────────────    ───────────────────────────
 t1    R1(ABC123) → x = 10                         ABC123 = 10
 t2    W1(ABC123, 10-5 = 5)                        ABC123 = 5
 t3                                                                      R2(ABC123) → 读到 5
 t4                                                                      R2(ABC789) → 读到 15
 t5    R1(ABC789) → y = 15                         ABC789 = 15
 t6    W1(ABC789, 15+5 = 20)                       ABC789 = 20
 t7    commit
 t8                                                                      print("Total:" 5+15 = 20)
 t9                                                                      commit

 正确结果:无论串行顺序如何,总额恒为 25(10+15 = 5+20)
 事实    :T2 打印 Total: 20 —— 它读到了"钱已经转走、但还没到账"的中间状态

这就是不一致检索(inconsistent retrieval):T2 的两次读分别落在 T1 的两个写之间,看到的是一个从未真实存在过的数据库状态。它同时也是脏读(dirty read)的一种——T2 读到了 T1 未提交的中间结果 $x=5$;如果 T1 随后 abort(比如转账目标账户不存在),T2 就是”基于一份从未存在的数据做了决策”。

脏读的极端形态(T1 最终回滚):

 t1    W1(x, 50)     x = 50(未提交)
 t2                  T2 读到 x = 50,据此给客户发了 50 元优惠券
 t3    abort         x 回滚为 100 —— 那个 50 从来没有存在过
异常三:脏写(Dirty Write / 未提交依赖)

$T_2$ 覆盖了 $T_1$ 尚未提交的写;如果 $T_1$ 之后回滚,$T_2$ 的写就建立在错误前提上,而系统往往已经无法恢复($T_2$ 的旧值信息被覆盖了)。

时刻   T1                        数据项 x            T2
────   ────────────────────      ─────────────      ────────────────────
 t1    W1(x, 50)                 x = 50(未提交)
 t2                                                W2(x, 70)   ← 覆盖了未提交的写
 t3    abort(回滚)              x = ?  ← 无法回到 100,因为旧值被 T2 覆盖
 t4                                                commit

所有隔离级别(包括 READ UNCOMMITTED)都禁止脏写——不是因为它破坏隔离性,而是因为它让回滚(undo)本身失去了可能,直接摧毁原子性与持久性。

补充异常:不可重复读(Non-repeatable Read)与幻读(Phantom Read)
  • 不可重复读:同一事务内两次读同一个数据项得到不同结果,因为中间有别的事务提交了写。$T_1$ 读 $x=10$,$T_2$ 写 $x=20$ 并提交,$T_1$ 再读 $x$ 得到 20。这是同一行的读-写冲突。
  • 幻读(phantom):同一事务内两次执行同一个范围查询,第二次多出(或少掉)了”行”,因为别的事务插入/删除了满足条件的记录。$T_1$ 执行 SELECT count(*) FROM orders WHERE amount > 100 得 5;$T_2$ 插入一条 amount=200 的订单并提交;$T_1$ 再查得 6。行锁锁不住”还不存在的行”,所以幻读需要谓词锁(predicate lock)间隙锁(gap lock)(MySQL InnoDB 在 REPEATABLE READ 下用 next-key lock 处理)。

19.2.7 隔离级别(Isolation Levels)

完美隔离(SERIALIZABLE)代价高,于是 SQL 标准定义了一组”阶梯式”的隔离级别,允许应用按需在正确性并发度之间取舍。现实类比:隔离级别像调节”你能看到别人多少未完成的草稿”——READ UNCOMMITTED 是”别人的草稿纸随便看”,READ COMMITTED 是”只给看已定稿的段落”,REPEATABLE READ 是”你开始读时就给你拍一张照片,之后一直看这张照片”,SERIALIZABLE 是”全图书馆一次只让一个人进来改书”。

隔离级别脏读 Dirty Read不可重复读 Non-repeatable Read幻读 Phantom丢失更新 Lost Update
READ UNCOMMITTED可能可能可能可能
READ COMMITTED不可能可能可能可能(标准的 P4 现象;UPDATE t SET x = x+1 这类原地更新因行锁 + 重读而安全,但应用层”先读后算再写”仍会丢失更新)
REPEATABLE READ不可能不可能可能(标准允许;InnoDB 用间隙锁实际阻止)不可能
SERIALIZABLE不可能不可能不可能不可能
(任何级别)脏写在所有级别都被禁止(否则无法回滚)   
  • 注意:这张表出自 SQL-92 标准;标准只定义了前三个”现象”(P1 脏读、P2 不可重复读、P3 幻读),丢失更新(P4)是后来的 Critique of ANSI SQL Isolation Levels(SIGMOD 1995)补充的,并且指出:标准文本本身存在歧义,许多商业数据库的实际行为与”标准答案”并不一致
  • 现实默认值大多数数据库默认是 READ COMMITTED(PostgreSQL、Oracle、SQL Server);MySQL InnoDB 默认是 REPEATABLE READ。原因很实际:READ COMMITTED 下读不上锁(配合 MVCC),并发度与吞吐更好,而多数应用实际上不需要可串行化。
  • 可串行化快照隔离(Serializable Snapshot Isolation, SSI):现代方案。它在快照隔离(SI)之上加一层冲突检测:SSI 跟踪读-写依赖(rw-antidependency),一旦发现“危险结构”(两个连续的 rw 依赖构成环)就中止其中一个事务。因为 SI 下所有事务读的是同一时刻的快照,检测可以做得比传统 2PL 更轻量:读不阻塞写、写不阻塞读,只有真正危险的模式才回滚。PostgreSQL 9.1+ 的 SERIALIZABLE 就是 SSI。详见 19.2.19。

19.2.8 串行等价(Serial Equivalence)与冲突操作

  • 串行历史(Serial History):事务一个接一个地执行,没有任何交错(每个事务的操作连续成批地发生)。串行历史永远是正确的——它就是”一次只跑一个事务”的定义。
  • 串行等价(Serially Equivalent):一个交错(interleaving)$O$ 是串行等价的,当且仅当存在这些事务的某个串行顺序 $O^{\prime}$,使得
    1. 所有对象与所有事务而言,$O$ 的最终结果(服务器对象的值 + 每个读操作返回的值)与 $O^{\prime}$ 相同;
    2. 且 $O^{\prime}$ 中每个事务的操作是连续成批出现的。

    讲义的措辞很精炼:“你无法区分真实操作顺序 $O$ 的结果和那个(虚构的)串行顺序 $O^{\prime}$ 的结果。” 注意条件 1 里的”for all objects and transactions“——不只是服务器上的最终值要对,每个事务读到的值也要对(这一点在写代码验证时会变成关键:只比对最终状态会漏掉不一致检索)。

  • 冲突操作(Conflicting Operations):如果两个操作的联合效果取决于它们的执行顺序,就称它们冲突。讲义给出的清单:
    • $read(x)$ 与 $write(x)$ —— 冲突(RW)
    • $write(x)$ 与 $read(x)$ —— 冲突(WR)
    • $write(x)$ 与 $write(x)$ —— 冲突(WW)
    • $read(x)$ 与 $read(x)$ —— 不冲突(交换顺序不改变任何效果)
    • $read/write(x)$ 与 $read/write(y)$(不同数据项)—— 不冲突
  • 为什么”冲突”是核心概念:它把”结果是否可能改变”这个语义问题,化归成一个纯语法性质——只看操作类型和数据项名字就能判定,无需知道业务语义。整个并发控制理论都建立在这个化归之上。

  • 冲突等价(Conflict Equivalence):两个历史 $H_1$、$H_2$ 冲突等价,如果它们包含相同的事务相同的操作,并且每一对冲突操作在两个历史中的先后顺序相同
  • 冲突可串行化(Conflict Serializable):历史 $H$ 冲突可串行化,如果 $H$ 冲突等价于某个串行历史。

  • 讲义给出的操作化判定流程(不画图也能判):
    1. 取出所有冲突操作对,每对含一个来自 $T_1$、一个来自 $T_2$ 的操作;
    2. 若 $T_1$ 的操作在服务器上先被反映(先执行),把这一对标记为 $(T_1,T_2)$,否则标记为 $(T_2,T_1)$;
    3. 所有对必须被标记为同一种——全是 $(T_1,T_2)$ 或全是 $(T_2,T_1)$,否则不可串行化。

    用这个方法回看 19.2.6 的丢失更新:冲突对 $(R_1(x), W_2(x))$ 标记为 $(T_1,T_2)$,而 $(R_2(x), W_1(x))$ 标记为 $(T_2,T_1)$——标记不一致,判为不可串行化。讲义在每个异常例子旁都写了 “Caught!“,正是这个检查抓出来的。

19.2.9 优先图(Precedence Graph)与串行化定理

  • 定义与目的:把”冲突顺序”画成一张有向图,就可把”是否可串行化”变成”图里有没有环”这个线性时间可判的问题。
  • 构图规则
    • 节点 = 事务
    • 若事务 $T_i$ 的某个操作与 $T_j$ 的某个操作冲突,且在历史中 $T_i$ 的操作先执行,则画一条有向边 $T_i \to T_j$。
  • 机制图解(三个典型历史):
   历史与冲突                                              优先图                判定
   ────────────────────────────────────────────────────   ───────────────────   ─────────────────
   例 1  H1 = R1(x) W1(x) R2(x) W2(x)                       T1 ──► T2            无环 ⇒ 冲突可
         (串行执行 T1 后 T2)                                                   串行化
                                                                                等价串行序 T1,T2

   例 2  H2 = R1(x) R2(x) W1(x) W2(x)                       T1 ──► T2            有环 ⇒ 不可
         (丢失更新的交错)                                   ▲     │             串行化
         冲突对:R1(x)~W2(x) ⇒ T1→T2                          └─────┘             环 T1→T2→T1
                 R2(x)~W1(x) ⇒ T2→T1

   例 3  H3 = R1(a) W2(a) R2(b) W3(b) R3(c) W1(c)           T1 ──► T2 ──► T3     有环 ⇒ 不可
         (三条相互冲突的边首尾相接)                          ▲             │      串行化
                                                              └─────────────┘      环 T1→T2→T3→T1

   例 4  H4 = W1(x) R2(x) W2(y) R3(y)                       T1 ──► T2 ──► T3     无环 ⇒ 冲突可
         (事务首尾相接但不闭环)                                                  串行化,序 T1,T2,T3
  • 串行化定理(Serialization Theorem):历史 $H$ 冲突可串行化 当且仅当它的优先图是无环的(acyclic)

证明(两个方向都必须证)

(⇐ 充分性) 设 $H$ 的优先图 $G$ 无环。无环有向图必存在拓扑序(topological order):把节点排成 $T_1, T_2, \ldots, T_n$,使得每条边都从序号小的指向序号大的。构造串行历史 $H_s$:按这个顺序把每个事务的操作连续地执行一遍。现在证明 $H$ 与 $H_s$ 冲突等价。

任取一对在 $H$ 中冲突的操作 $p \in T_i$、$q \in T_j$($i \neq j$)。由构图规则,$H$ 中 $p$ 先于 $q$ 就等价于图中存在边 $T_i \to T_j$。拓扑序保证 $T_i$ 排在 $T_j$ 之前,于是在 $H_s$ 中 $T_i$ 的所有操作都在 $T_j$ 的所有操作之前,$p$ 仍在 $q$ 之前。每一对冲突操作的相对顺序都被保持 ⇒ $H$ 与 $H_s$ 冲突等价 ⇒ $H$ 冲突可串行化。$\blacksquare$

(⇒ 必要性) 设 $H$ 冲突可串行化,即存在串行历史 $H_s$(事务顺序 $T_{\pi(1)}, \ldots, T_{\pi(n)}$)与 $H$ 冲突等价。假设 $G$ 中存在环 $T_{i_1} \to T_{i_2} \to \cdots \to T_{i_k} \to T_{i_1}$。对每条边 $T_a \to T_b$,由定义存在一对冲突操作,在 $H$ 中 $T_a$ 的操作先执行;冲突等价要求 $H_s$ 中同样是 $T_a$ 先于 $T_b$,即 $T_a$ 必须排在 $T_b$ 之前。把这条要求沿环传递一圈得到:

\[\pi^{-1}(i_1) < \pi^{-1}(i_2) < \cdots < \pi^{-1}(i_k) < \pi^{-1}(i_1)\]

这是一个严格不等式回到自身的矛盾。故 $G$ 不可能有环。$\blacksquare$

  • 这个定理的价值
    • 它给出了正确性的可判定判据:无环 ⟺ 可串行化;检测环只需 DFS 或 Kahn 拓扑排序,$O(V+E)$,而 $E \le n^2$($n$ 为并发事务数)。
    • 它给出了构造性的等价串行序:拓扑序本身就是一个合法的串行顺序。
    • 它成为后面所有算法的证明模板:只要证明”协议产生的历史其优先图必然无环”,就证明了协议保证可串行化。19.2.12 的 2PL、19.2.19 的 OCC、19.2.21 的时间戳排序都是套用这个模板。

19.2.10 视图可串行化(View Serializability):更宽的标准与它的代价

  • 定义:两个历史 $H_1$、$H_2$ 视图等价(view equivalent),如果
    1. 它们有相同的事务集合与操作集合;
    2. 对每个读操作 $R_i(x)$,两者中它读取的那个版本由同一个事务写入(或都读初始版本)——即读的来源(read-from)相同
    3. 对每个数据项,最后一个写操作来自同一个事务(最终写相同)。

    历史 $H$ 视图可串行化,如果它视图等价于某个串行历史。

  • 关系冲突可串行化 $\subsetneq$ 视图可串行化(前者是后者的充分非必要条件)。经典反例只需要一次”盲写”(blind write,不读就写):
   H = R1(x) W2(x) W1(x) W3(x)

   · 视图等价视角:T2 的 W2(x) 随后被 T1 的 W1(x) 覆盖,最终写者是 T3,
     T1 读到的仍是初始值 —— 与串行历史 T1;T2;T3 完全一致(读来源相同、最终写相同)
     ⇒ H 视图可串行化 ✔
   · 冲突等价视角:冲突对 R1(x)~W2(x) 给出 T1→T2;
     冲突对 W2(x)~W1(x) 给出 T2→T1 —— 两条边方向相反,优先图有环
     ⇒ H 冲突不可串行化 ✘
   · 结论:视图可串行化确实比冲突可串行化更宽松
  • 为什么实践中不用它判定视图可串行化是 NP-完全的(直觉上,它要求对”每个读操作到底读了谁的版本”作全局推理,本质上要搜索所有可能的串行顺序);而判定冲突可串行化只需 $O(V+E)$ 的环检测。因此所有实际系统、以及本课程的全部算法,都以冲突可串行化作为正确性标准。所谓”副作用”是:某些视图可串行化的历史会被保守地拒绝(回滚),这是可接受的安全性代价(拒绝一个安全历史只损失性能,不会出错)。
  • 补充说明:Thomas 写规则(19.2.21)正是少数”故意放宽到视图可串行化”的优化——它产生的是视图可串行化(而非冲突可串行化)的历史,这也是它能”忽略一次过时写而不回滚”的原因。

19.2.11 锁与锁相容性(悲观并发控制的基础)

  • 定义与目的:悲观(pessimistic)策略的假设是”冲突随时会发生“,因此在事务真正访问对象之前就阻止别人访问——最直接的手段就是锁(lock)
  • 排他锁(Exclusive Lock, X 锁 / 写锁):每个对象有一把锁,同一时刻至多一个事务能进入(讲义:“Sound familiar? This is Mutual Exclusion!”)。
    • 事务 $T$ 在读写对象 $O$ 之前必须调用 lock(O);若已有别的事务在锁内则阻塞(block)
    • 进入锁后 $T$ 可以对 $O$ 反复读写
    • 用完(或到提交点)调用 unlock(O);若有事务在等待,唤醒其中一个。
  • 读-写锁(Read-Write Locks):讲义指出”现实负载中有大量只读或读多写少的事务”,而排他锁把并发度压得太低。改进:每把锁有两种模式
    • 共享锁 / 读锁(Shared / Read Lock, S 锁)多个事务可同时持有(因为 read-read 不是冲突对);
    • 排他锁 / 写锁(Exclusive / Write Lock, X 锁):独占;
    • read_lock(O) 仅当锁内所有持有者都是通过读模式进入时才允许;
    • write_lock(O) 仅当没有任何其他事务持有该锁时才允许;
    • 锁升级(lock promotion / upgrade):若 $T$ 已持 read_lock(O) 又想写,就调用 write_lock(O) 把读锁升级为写锁——只有在自己是唯一持有者时才能成功,否则 $T$ 阻塞。
  • 锁相容矩阵(Compatibility Matrix):$\checkmark$ 表示可同时持有,$\times$ 表示冲突(后来的请求必须等待)。
            当前持有锁
             ┌────────┬────────┬────────┐
   请求锁     │   S    │   X    │  空闲  │
   ──────────┼────────┼────────┼────────┤
     S       │   ✓    │   ✗    │   ✓    │     读-读相容(不冲突)
   ──────────┼────────┼────────┼────────┤
     X       │   ✗    │   ✗    │   ✓    │     写与任何操作都冲突
   ──────────┴────────┴────────┴────────┘

   对应到 19.2.8 的冲突定义:读-读不冲突 ⇒ S-S 相容;
   只要有一方是写 ⇒ 冲突 ⇒ 涉及 X 的组合全部不相容。
  • 关键假设与系统模型:锁管理器维护锁表(每个对象:当前持有者集合 + 等待队列)。等待通常按 FIFO 排队以避免饥饿。阻塞的事务继续持有它已经拿到的锁——这正是死锁的根源(19.2.14)。

19.2.12 两阶段锁(Two-Phase Locking, 2PL)与它的正确性

  • 定义与目的:光有锁还不够——讲义明确指出需要一个额外的纪律才能保证可串行化,它就是两阶段锁:

    事务一旦开始释放锁,就再也不能获取(或升级)任何锁。

  • 两个阶段
    1. 增长阶段(Growing Phase):只能获取或升级锁,不能释放;
    2. 收缩阶段(Shrinking Phase):只能释放锁,不能获取。
  锁数量
    │                    ◆ 锁点(lock point)= 持有全部锁的时刻
    │                  / \
    │                /     \
    │  增长阶段     /         \   收缩阶段
    │  (只加锁)  /             \ (只放锁)
    │            /                 \
    │          /                     \
    └────────●───────────────────────────●──────────► 时间
          事务开始                      提交/中止
                                              (严格 2PL:锁一直持到这一刻)

  为什么"锁点"是证明的关键:
    对任意冲突对,若 Ti 的操作先于 Tj,
    则 Ti 必须在 Tj 拿到那把锁之前就已经释放了它
     ⇒ Ti 的收缩阶段早于 Tj 的增长阶段结束
     ⇒ lock_point(Ti) < lock_point(Tj)

  于是:冲突边 Ti → Tj 一律"从锁点小指向锁点大" ⇒ 图不可能有环 ⇒ 可串行化 ✔
  • 定理:2PL 保证冲突可串行化。

证明(讲义版:反证法,两条事实互相矛盾)

设某个 2PL 系统的历史违反了串行等价,那么存在两个事务 $T_1, T_2$,使得冲突对的方向不一致,即:

  • (A) 存在对象 $O_1$,$T_1$ 与 $T_2$ 在 $O_1$ 上的冲突操作的时间顺序是 $(T_1, T_2)$;
  • (B) 存在对象 $O_2$,$T_2$ 与 $T_1$ 在 $O_2$ 上的冲突操作的时间顺序是 $(T_2, T_1)$。

由 (A):$T_1$ 先在 $O_1$ 上执行了操作、然后释放了 $O_1$ 的锁,$T_2$ 之后才获得它(因为两者对 $O_1$ 的访问相冲突,锁保证了互斥,所以”先执行”必然意味着”先持锁、先释放”)。记 $s_i$ 为 $T_i$ 进入收缩阶段的时刻,$g_i$ 为 $T_i$ 在增长阶段获得那把锁的时刻。于是

\[s_1 \le \text{释放}(O_1) < \text{获得}(O_1) \le g_2 \le s_2 \quad\Longrightarrow\quad s_1 < s_2\]

由 (B) 同理得 $s_2 < s_1$。两者不可能同时成立,矛盾。故 2PL 产生的历史必定串行等价(冲突可串行化)。$\blacksquare$

证明(优先图版:锁点单调 ⇒ 无环)

对每条冲突边 $T_i \to T_j$,用上面的推理可得 $\text{lock\point}(T_i) < \text{lock\_point}(T_j)$,其中锁点定义为事务结束增长阶段(即第一次释放锁)的时刻。假设优先图有环 $T{i_1} \to T_{i_2} \to \cdots \to T_{i_k} \to T_{i_1}$,沿环传递得到

\[\text{lock\_point}(T_{i_1}) < \text{lock\_point}(T_{i_2}) < \cdots < \text{lock\_point}(T_{i_k}) < \text{lock\_point}(T_{i_1})\]

严格不等式回到自身,矛盾 ⇒ 无环 ⇒ 由 19.2.9 的串行化定理,历史冲突可串行化。$\blacksquare$

  • 一个漂亮的推论按锁点从早到晚排序,就得到等价的串行顺序。也就是说,2PL 的串行化顺序是由”谁先拿齐锁”决定的,这个顺序在事务执行过程中就固定下来了,不需要事后检测。

19.2.13 2PL 的三种变体:严格、强严格、保守

2PL 只约束”加锁/放锁的次序”,没有约束什么时候放锁。不同的放锁时机带来不同的额外性质:

变体纪律获得的额外性质代价
基本 2PL满足两阶段即可,可以在收缩阶段随时放锁冲突可串行化允许级联回滚:$T_2$ 读了 $T_1$ 未提交的写,$T_1$ 回滚时 $T_2$ 也必须回滚(涟漪效应)
严格 2PL(Strict 2PL)所有排他锁(X 锁)一直持有到事务提交/中止无脏读、无级联回滚(可恢复 recoverable + 避免级联中止 ACA);串行化顺序 = 提交顺序锁持有时间更长,并发度略降
强严格 2PL(Rigorous 2PL)所有锁(含共享锁 S)都持有到提交上述全部性质,且串行序恰好等于提交顺序;实现最简单(放锁就是”提交时清空锁表项”)并发度最低(连读锁都不早放),但最安全
保守 2PL(Conservative 2PL)事务开始时一次性申请它需要的全部锁;任何一个拿不到就全部放弃并重启无死锁(不存在”持一部分等另一部分”的循环)必须预先知道整个访问集(很多场景做不到);提前占锁导致并发度低、高峰期大量无谓重启
  • 实践结论严格 2PL(乃至强严格 2PL)是实际系统最常用的。原因很实际:级联回滚意味着”一个事务失败会连累一串事务”,这在高并发下会造成回滚风暴;而”锁持到提交”虽然降低了理论并发度,却把恢复逻辑简化到了极致。数据库教科书的经验法则是:在正确性相同的方案里,选恢复最简单的那个。
  • 补充说明:讲义只点名了 strict two phase locking: releases locks only at commit point;强严格 2PL 与保守 2PL 是教科书的标准补充分类,用来解释”为什么工程上一律用严格变体”以及”如何用 2PL 彻底消灭死锁”。

19.2.14 死锁:2PL 不能避免死锁

这是 2PL 最重要的一条缺陷:它保证了可串行化,却完全无法避免死锁。讲义用「Downside of Locking – Deadlocks!」小节专门给出了这个例子:$T_1$ 先锁 ABC123 再锁 ABC789,$T_2$ 反过来先锁 ABC789 再锁 ABC123。两边各拿到一半,然后互相等对方手里那一半。

时刻   事务 T1                                事务 T2
────   ──────────────────────────────────    ──────────────────────────────────
 t1    Lock(ABC123)  ✔                        (等待或做别的事)
 t2                                            Lock(ABC789)  ✔
 t3    W1(ABC123, 10)
 t4                                            W2(ABC789, 15)
 t5    Lock(ABC789)  ✗ 阻塞 —— 等 T2            ...
 t6                                            Lock(ABC123)  ✗ 阻塞 —— 等 T1
 t7    …… 谁也不会释放,永远等待 ……             …… 谁也不会释放,永远等待 ……

对应的等待图(Wait-for Graph)——节点是事务,边 $T_i \to T_j$ 表示”$T_i$ 正在等 $T_j$ 持有的锁”:

   T1 ──────► T2               T1 等 T2 手里的 ABC789
   ▲          │
   │          │                T2 等 T1 手里的 ABC123
   └──────────┘

   环 ⇒ 死锁。注意这与"不可串行化"是两回事:
   死锁关心的是【活性 Liveness】(永远做不完),
   不可串行化关心的是【安全性 Safety】(做完了但结果错)。

死锁发生的三个必要条件(讲义明确强调:必要不等于充分——三个条件都成立不一定真的死锁,但只要发生死锁,三条必然都成立):

  1. 有些对象以排他(exclusive)模式被访问——存在互斥;
  2. 持有锁的事务不可被抢占(no preemption)——拿到的东西不会被强行夺走;
  3. 等待图中存在循环等待(cycle)

补充说明(与操作系统课上的 Coffman 条件对照):经典四条件是”互斥、持有并等待、不可抢占、循环等待”;这里把”互斥 + 持有并等待”压缩成了第 1 条(访问排他对象时,事务会一边持有已得的锁、一边等待新的锁)。理解成同一件事即可。

为什么 2PL 天然会死锁:2PL 只规定”加锁/放锁的相对顺序“,它要求事务在收缩阶段之前必须持有全部所需的锁——这恰恰是”持有并等待”。所以”提高可串行化保证”和”消灭死锁”在 2PL 框架内是两个正交的问题,必须分开解决。下面三节分别对应讲义给出的三条出路。

19.2.15 死锁预防之一:时间戳排序(Wait-Die 与 Wound-Wait)

核心思想:给每个事务在开始时分配一个全局唯一、单调递增的时间戳(timestamp),它代表”事务的年龄”:时间戳越小 = 越老(older)。当发生锁冲突时,用”谁更老”来决定是”等待”还是”回滚”,从而从结构上排除循环等待。这样构造出来的等待关系天然是偏序,图里不可能有环。

  • Wait-Die(等待-死亡,非抢占式):请求者 $T_i$ 想拿 $T_j$ 持有的锁时:
    • 若 $T_i$ 更老($\mathrm{TS}(T_i) < \mathrm{TS}(T_j)$)⇒ 等待(wait)
    • 若 $T_i$ 更年轻死亡(die):回滚 $T_i$,并用原来的时间戳重启。
    • 口诀:“老的等,年轻的死”
  • Wound-Wait(伤害-等待,抢占式):请求者 $T_i$ 想拿 $T_j$ 持有的锁时:
    • 若 $T_i$ 更老伤害(wound) $T_j$:强行回滚持有者 $T_j$($T_j$ 用原时间戳重启),$T_i$ 拿走锁;
    • 若 $T_i$ 更年轻等待(wait)
    • 口诀:“老的伤害年轻的,年轻的等老的”

同一场景下两种策略的行为对比($T_1$ 时间戳 10,$T_2$ 时间戳 20,$T_1$ 更老):

场景:T1 持有 A,T2 持有 B;随后 T2 请求 A、T1 请求 B(若都不处理就是死锁)

   ┌──────────────────────────── Wait-Die(非抢占) ────────────────────────────┐
   │ T2(更年轻)请求 A(T1 持有)  → T2 更年轻 ⇒ 【die】回滚 T2,用 TS=20 重启     │
   │ T1(更老)  请求 B(T2 持有)  → T1 更老   ⇒ 【wait】T1 等待                  │
   │ 结果:T2 回滚释放 B → T1 拿到 B 继续;T2 重启后再跑。无死锁 ✔                │
   │ 代价:年轻事务可能被反复"杀死"(每次重启仍是年轻),但老的终会完成 → 不饥饿    │
   └──────────────────────────────────────────────────────────────────────────┘

   ┌─────────────────────────── Wound-Wait(抢占式) ───────────────────────────┐
   │ T2(更年轻)请求 A(T1 持有)  → T2 更年轻 ⇒ 【wait】T2 等待                  │
   │ T1(更老)  请求 B(T2 持有)  → T1 更老   ⇒ 【wound】强行回滚 T2,拿走 B      │
   │ 结果:T2 被伤害并释放锁 → T1 拿到 B 继续;T2 用 TS=20 重启。无死锁 ✔          │
   │ 代价:被伤害者已经做的工作白费(回滚),但"伤害"次数通常少于 Wait-Die 的"死亡") │
   └──────────────────────────────────────────────────────────────────────────┘

为什么两者都绝不会死锁(证明):考察各自产生的等待边 $T_i \to T_j$($T_i$ 在等 $T_j$):

  • Wait-Die 中,只有更老的事务才会等待,所以每条等待边满足 $\mathrm{TS}(T_i) < \mathrm{TS}(T_j)$——严格递增
  • Wound-Wait 中,只有更年轻的事务才会等待,所以每条等待边满足 $\mathrm{TS}(T_i) > \mathrm{TS}(T_j)$——严格递减

两种情况下,”时间戳沿等待边严格单调”都成立。若等待图存在环 $T_{i_1} \to \cdots \to T_{i_k} \to T_{i_1}$,沿环传递会得到 $\mathrm{TS}(T_{i_1}) < \mathrm{TS}(T_{i_1})$(或 $>$),严格不等式回到自身,矛盾。故等待图无环 ⇒ 无死锁。$\blacksquare$

为什么 Wait-Die 不会饿死(starvation-free):一个年轻事务 $Y$ 被回滚的唯一原因是”它请求了某个更老事务持有的锁”;而时间戳在开始时分配,所以在 $Y$ 启动之后再也不会出现比它更老的事务——能杀死 $Y$ 的只有那批有限个”启动更早”的事务,而它们每一个最终都会完成(老事务只等待年轻事务,年轻事务要么做完、要么被杀死后释放全部锁,老事务的等待因此总会被解除)。于是 $Y$ 至多被杀死有限次,最终必然成功。关键前提是:重启时沿用原时间戳,这样”$Y$ 永远是年轻的”这个事实不会因为重启而改变。反之,若重启时分配新时间戳,就可能出现”不断被更老的事务杀死”的活锁。

  • 时间戳的另一种死锁预防用法——超时(Timeout):拿不到锁就等一段固定时间,超时未获得就 abort 并重试。
    • 优点:实现极简,不需要任何全局信息,在分布式系统中尤其常见(无法高效维护全局等待图时这是默认选择)。
    • 缺点:可能误杀——一个只是执行得慢的长事务会被误判为死锁而回滚;超时时间难以调参(太短误杀多、太长真死锁拖久了才被发现)。

19.2.16 死锁避免与死锁检测/恢复

讲义给出的”combating deadlocks”三条路径如下(结合三种必要条件的逐一破坏):

策略具体做法破坏了哪个必要条件评价
死锁预防(Prevention)① 允许对对象做只读访问(用 S 锁代替 X 锁);② 允许抢占(Wait-Die/Wound-Wait 回滚某个事务);③ 一次性申请全部锁(保守 2PL,失败就整体放弃)① 互斥范围、② 不可抢占、③ 循环等待从源头杜绝,但并发度下降、需要预知访问集;时间戳与超时属于此类
死锁避免(Avoidance)运行时检查”如果这次加锁会形成环就不加”(等价于维护等待图并只在无环时放行);保守 2PL 也算循环等待不会真的出现死锁,但每次加锁都要做一次全局判断,开销大
死锁检测与恢复(Detection & Recovery)允许死锁发生:维护等待图,周期性地检测环;发现环就 abort 一个或多个事务来破环——(事后处理)并发度最高、最通用;代价是检测开销 + 牺牲者的重做浪费;分布式下有”幻死锁”问题

死锁检测与恢复的具体步骤(讲义原文的流程,并补足实现细节):

  1. 维护等待图(Wait-For Graph, WFG):节点 = 事务,边 $T_i \to T_j$ 表示”$T_i$ 在等 $T_j$ 持有的锁”。可以在事务阻塞时增量添加边、获得锁或回滚时删除边——增量维护是 $O(1)$ 的,比重建快得多。
  2. 周期性地找环:用 DFS(记录灰色节点,遇到灰色节点即发现环,还可回溯出环上的路径)或 Kahn 拓扑排序(若排序结果不足 $n$ 个节点则有环)。复杂度 $O(V+E)$。
  3. 选牺牲者(victim)并回滚,打破环。讲义强调”abort one or more transactions”——环上回滚一个就够(环被断掉),但如果多个环共享节点,可能一次要回滚多个。
  4. 回滚后:释放牺牲者的全部锁、撤销其写(undo),并决定它是否重启(通常重启,但要避免它再次成为牺牲者)。

牺牲者选择(victim selection)的常见启发式(必须防止”同一个倒霉事务被反复选中”造成饥饿):

启发式理由
已做工作量最少的事务(rollback cost 最小)回滚浪费最小
持有锁最少的事务释放的锁少 → 需要重做的等待者少,破环代价小
最年轻的事务(时间戳最大)它本来就应该排后面,回滚它符合时间戳序,且老事务优先完成
已回滚次数最少的事务直接防止饥饿:rollback_count 高的事务优先级提升(类似于老化 aging)
  • 检测频率的权衡:检测太频繁 ⇒ 大量 CPU 花在遍历等待图上;检测太稀疏 ⇒ 死锁存在很久才被发现,期间所有相关事务都白等。工程经验:以”事务平均执行时间”的量级为周期,或当”最近一段时间没有事务成功提交”时触发一次检测。

19.2.17 分布式死锁检测(重点)

当数据分散在多个站点上,一个事务的锁可能横跨若干站点,任何一个站点都看不到完整的等待图。三个层次的方案如下。

(1)集中式检测(Centralized)

  • 选一个中心死锁检测器(central coordinator);每个站点把自己的局部 WFG 变化上报给它,中心拼出全局 WFG 后检测环。
  • 优点:算法简单,环检测就是单机 DFS。
  • 缺点:单点故障(中心挂了就没人检测)、通信瓶颈(所有等待边变化都要上报)、延迟高(要等路径信息汇聚)。讲义特别提到:”keep track of Wait-for graph (e.g., via Global Snapshot algorithm)”——即用 Chandy-Lamport 之类的算法取得一致性的全局快照来构造 WFG。

(2)分层检测(Hierarchical)

  • 把站点组织成树状:叶子检测器负责一小簇站点,把”出口边”(跨簇的等待边)汇总给上一层检测器;上层再汇总。
  • 优点:分摊了中心节点的负载,可扩展到较大规模。
  • 缺点:层次结构需要维护;跨层环的检测有额外延迟;树根仍可能是瓶颈。

(3)分布式检测:Chandy-Misra-Haas 的边追踪(Edge Chasing)算法

  • 不做全局快照,而是把探测消息(probe)沿等待边推送:只有当一条等待路径真的绕回起点时,才说明有环。细节与伪代码见 算法 19.3.4
  • 基本规则($T_i$ 发起,探测消息记作 $\text{probe}(i, j, k)$:发起者 $i$,发送者 $j$,目标 $k$):
    1. 当 $T_i$ 开始等待 $T_j$ 时,向 $T_j$ 所在站点发送 $\text{probe}(i, i, j)$;
    2. 站点收到 $\text{probe}(i, j, k)$:若 $k = i$(探测回到了发起者)⇒ 检测到死锁;否则若 $k$ 正在等待某些事务,则对每个 $k$ 等待的 $m$ 转发 $\text{probe}(i, k, m)$;若 $k$ 不在等待任何事务,则丢弃该探测(这条分支无环);
    3. 优化——消息合并/抑制(message suppression):站点记录已经转发过的 $(i, j, k)$,同一探测只转发一次;更激进的做法是”同一发起者 $i$ 的探测,若该站点已经转发过,则后续同类探测直接丢弃”。
  • Obermarck 算法:另一条分布式路线,不用探测消息,而是在局部 WFG 之间传递路径信息(把各站点的局部等待图连同”外部等待”的出口点一起交换),据此在本地推导出全局环。相比边追踪,它把开销放在”路径信息的传播”上而不是”探测消息的洪流”上。

难点的核心:幻死锁(Phantom Deadlock)

定义:分布式检测器报出了一个全局并不存在的环。原因是——检测所依据的等待信息来自不同时刻,在信息传播的过程中,环上的某个事务已经提交或中止,环早已被打断,但”在途的探测消息”仍会绕回起点。

   真实的环:T1 等 T2 等 T3 等 T1(三个事务分布在三个站点上)

   站点0: T1 ──► T2 ──► T3 ──► T1
          │      │      │      ▲
    ① 发探测(1,1,2) │      │      │
          │      │      │      │
    ② site1 收到后转发 (1,2,3)   │
          │      │      │      │
    ③ site2 收到后转发 (1,3,1) ──┘
          │
   ④ ★ 在这条探测消息到达 site0 之前,T2 因超时/其他原因【中止并释放全部锁】★
          │      ⇒ 真实的等待边 T1→T2 断开了,全局只剩 T3→T1(无环)
          │
   ⑤ site0 仍收到 (1,3,1):k = i = 1 ⇒ "探测回来了!检测到死锁!"
          ⇒ 报出了一个【已经不存在的死锁】= 幻死锁 ✗
  • 为什么它无法根除:在异步系统里,”环曾经存在过,但现在已经不存在”这件事无法仅靠收到的消息判断——因为消息只会告诉你”过去某个时刻存在这条边”,不会告诉你”它现在还成立”。这与快照一章的结论一致:没有任何一致性信息的分布式判定都会误报
  • 缓解手段
    1. 用一致性全局快照(Chandy-Lamport 算法)拿到一个因果一致的等待图快照再检测——快照保证所有”边”来自同一个一致割(consistent cut),环在快照意义下是”真的存在过”的。这也解释了讲义为什么写 “keep track of Wait-for graph (e.g., via Global Snapshot algorithm)”注意:即使如此,”存在过”仍不等于”现在仍存在”,幻死锁只能减少、不能彻底消除。
    2. 给探测消息带时间戳/事务状态:收到探测时校验目标事务是否仍处于等待状态(本例中 site0 可以在报死锁前先问一句”T1 还在等吗?T2 还在等吗?”),把误报率压低,但引入额外往返与新的竞态。
    3. 接受误报:牺牲者被回滚本来就是”可能白回滚”的,只要回滚是安全的(能正确 undo),误报只损失性能不破坏正确性。因此许多系统选择”允许幻死锁 + 让牺牲者重试”。

19.2.18 多粒度锁(Multi-granularity Locking)

  • 定义与目的:数据是层次化的(数据库 → 表 → 页 → 行)。如果只在”行”上加锁,一个要扫描整张表的事务就得申请几百万把行锁(锁表爆炸、检查锁也要百万次);如果只在”表”上加锁,两个只改不同行的事务又会互相阻塞(并发度暴跌)。多粒度锁让事务按需选择合适的粒度,并用意图锁(Intention Lock)解决”在不同层级上加锁如何互相检查”的问题。
                        数据库 DB
                       /    │    \
                   表 A     表 B     表 C
                  / │ \      │
              页1  页2  页3    页4
             /│\
          行1 行2 行3

  加锁规则("从根往下、层层标记"):
    想读某个【行】  ⇒ 在 DB、表、页 上加 【IS】,在该行上加 【S】
    想写某个【行】  ⇒ 在 DB、表、页 上加 【IX】,在该行上加 【X】
    想读【整张表但可能要改其中几行】 ⇒ 在 DB 上加 IX,在表上加 【SIX】
    想读/写【整张表】⇒ 在 DB 上加 IX/S,在表上加 X/S
  • 三种意图锁的含义
    • IS(Intention Shared):我打算在更低层级加共享锁(即我要读下面某些节点);
    • IX(Intention Exclusive):我打算在更低层级加排他锁(即我要写下面某些节点);
    • SIX(Shared + Intention Exclusive):我正在共享地读整个节点(相当于 S),同时打算修改它的某些下级(相当于 IX)。
  • 完整锁相容矩阵(行 = 请求的锁,列 = 已持有的锁;$\checkmark$ = 相容可授予,$\times$ = 冲突需等待):
   请求 \ 已持有 │   NL  │   IS  │   IX  │   S   │  SIX  │   X
   ─────────────┼───────┼───────┼───────┼───────┼───────┼───────
        NL      │   ✓   │   ✓   │   ✓   │   ✓   │   ✓   │   ✓
   ─────────────┼───────┼───────┼───────┼───────┼───────┼───────
        IS      │   ✓   │   ✓   │   ✓   │   ✓   │   ✓   │   ✗
   ─────────────┼───────┼───────┼───────┼───────┼───────┼───────
        IX      │   ✓   │   ✓   │   ✓   │   ✗   │   ✗   │   ✗
   ─────────────┼───────┼───────┼───────┼───────┼───────┼───────
        S       │   ✓   │   ✓   │   ✗   │   ✓   │   ✗   │   ✗
   ─────────────┼───────┼───────┼───────┼───────┼───────┼───────
       SIX      │   ✓   │   ✓   │   ✗   │   ✗   │   ✗   │   ✗
   ─────────────┼───────┼───────┼───────┼───────┼───────┼───────
        X       │   ✓   │   ✗   │   ✗   │   ✗   │   ✗   │   ✗
   ─────────────┴───────┴───────┴───────┴───────┴───────┴───────
   NL = 未加锁(No Lock)。读法与 19.2.11 的 S/X 矩阵一致:
   IS 与 IS/IX 都相容(两人可以分别读、写不同的下级行);
   S 与 IX 不相容(有人要读整张表,就没人能改里面任何一行)。
  • 为什么需要意图锁(关键收益):假设没有意图锁。事务 $T$ 想给表 A 加 X 锁,它必须确认”当前没有任何事务持有表 A 中任何一行的锁”——这要求扫描全表所有行的锁记录,代价与表大小成正比。有了意图锁,$T$ 只需检查表 A 这一个节点上的锁:只要上面没有 S/IS/IX/SIX 等任何锁(即矩阵中所有含意图锁的组合都冲突),就说明没有人在动它的下级,可以放心授予表级 X 锁。代价是”向上多标记一层”,收益是”检查只需看一个节点”——这正是层次化锁协议的精髓:用 $O(\text{深度})$ 的额外标记,换取 $O(1)$ 的冲突检查
  • 锁升级(Lock Escalation):当事务在同一个节点下积累了太多细粒度锁(例如对某表加了 5000 把行锁),锁管理器的内存与检查开销会变得不可接受。此时把它升级成一把粗粒度锁(例如把这 5000 把行锁换成该表的 1 把 X 锁):
    • 触发条件:单事务持有的行锁数超过阈值(SQL Server 默认约 5000)。
    • 收益:锁表空间与检查次数大幅下降。
    • 代价:阻塞范围扩大——原本只想改几行,现在把整张表锁住了,别的行级事务全被挡住,并发度骤降;极端情况下还可能人为制造死锁(升级请求与别人的细粒度锁互相等待)。
    • 因此工程上要谨慎:要么设较高的阈值,要么避免让大事务在热点表上做全表更新。

19.2.19 乐观并发控制(Optimistic Concurrency Control, OCC)

  • 动机:悲观锁的一切开销(加锁/解锁的系统调用、锁表维护、阻塞与唤醒、死锁检测与牺牲者回滚)在冲突很少的负载下全是浪费——花了大力气去预防几乎不会发生的事故。OCC 反过来假设”冲突是罕见的“:

    读的时候不上锁,写的时候先写在私有工作区,等到提交时再验证这次执行是否与别人冲突;不冲突就提交,冲突就回滚重来。

  • 讲义的两条动机描述“Increases concurrency more than pessimistic concurrency control”“Preferable than pessimistic when conflicts are expected to be rare — but still need to ensure conflicts are caught!”必须仍然保证冲突能被抓到,否则就不是并发控制,而只是”没有控制”)。

Kung-Robinson 三阶段(Three Phases)

  事务 Ti 的 OCC 生命周期
  ─────────────────────────────────────────────────────────────────────────
   ① 读阶段 Read Phase          ② 验证阶段 Validation    ③ 写阶段 Write Phase
   ┌───────────────────────┐   ┌──────────────────┐   ┌──────────────────┐
   │ 读数据库已提交版本      │   │ 拿 Ti 的读集/写集 │   │ 把私有工作区      │
   │ 所有修改只写进【私有    │──►│ 与所有更早的并发  │──►│ 原子地安装到数据库 │
   │ 工作区】,不动数据库     │   │ 事务比对          │   │ (需要短暂的锁/临界区)│
   │ 记录 读集 RS / 写集 WS  │   │ 冲突 ⇒ 回滚重来   │   │ 完成 → 提交        │
   └───────────────────────┘   └──────────────────┘   └──────────────────┘
        ↑ 无锁、不阻塞别人            ↑ 唯一的串行点:验证必须互斥地做

  验证窗口(Validation Window):判断"谁与谁重叠"靠的是三个时间点
        Tj 开始读 ──────── Tj 结束读 ── Tj 验证并写 ────►
              │  ← 重叠区间 →  │
        Ti 开始读 ─────────────── Ti 结束读 ─ Ti 验证并写 ──►
   · Ti 与 Tj 重叠 ⇒ 必须检查它们的读写集是否相交(条件 2、条件 3)
   · Tj 完全在 Ti 开始之前结束 ⇒ 天然有序,无需检查(条件 1)
  • 验证规则(三种经典条件):设 $T_j$ 排在 $T_i$ 之前($\mathrm{TS}(T_j) < \mathrm{TS}(T_i)$,即 $T_j$ 先通过验证),则需要满足下列之一:
    1. 条件 1:$T_j$ 在 $T_i$ 开始之前就完成了 ⇒ 两者不重叠,所有冲突操作天然按 $T_j$ 在前排列,无需检查
    2. 条件 2:$T_j$ 在 $T_i$ 开始前开始,但在 $T_i$ 完成前完成(两者重叠,且 $T_j$ 的写阶段在 $T_i$ 的读阶段之后)⇒ 此时 $T_i$ 不可能读到 $T_j$ 的过时值,唯一需要担心的是”两者都写同一数据项”造成的覆盖顺序颠倒 ⇒ 检查 $WS(T_i) \cap WS(T_j) = \varnothing$。
    3. 条件 3:其余的重叠情形(尤其 $T_j$ 的写阶段落在 $T_i$ 的读阶段之内)⇒ $T_i$ 可能读到 $T_j$ 覆盖前的过时值,也可能与 $T_j$ 争抢同一个数据项的写权 ⇒ 必须做强检查: \(WS(T_j) \cap \big(RS(T_i) \cup WS(T_i)\big) = \varnothing\)
  • 补充说明(一个真实存在的实现陷阱):教科书上条件 2/3 的划分依赖于”事务的时间戳在进入验证阶段时分配“这一约定。若时间戳在事务开始时分配、验证却发生在读阶段结束后,就可能出现”$T_j$ 的写阶段落在 $T_i$ 的读阶段内部”的情形——此时若按条件 2 只检查写-写,就会漏掉 $T_i$ 读到过时值的情况,破坏可串行性安全的判据是:只要 $T_j$ 的提交(写阶段)落在 $T_i$ 的读区间 $[start_i, end_i]$ 之内,就必须用条件 3 的强检查。本讲 19.4.1 的代码就是这样实现的(把时间戳分配在验证阶段,并对”写阶段落在我的读区间内”的情况强制强检查)。
  • 验证的正确性(结论,完整证明见算法 19.3.5):若所有事务都通过验证,则任何一对冲突操作的实际执行顺序都与”验证顺序(时间戳顺序)”一致,因此整个已提交历史冲突等价于按验证顺序串行执行的历史 ⇒ 可串行化。
  • 失败处理:验证失败 ⇒ 回滚该事务(丢弃私有工作区)并重新开始。讲义特别提醒了基本做法的副作用:“An abort may result in other transactions that read dirty data, also being aborted”级联回滚(cascading aborts)。缓解办法:读阶段永远只读已提交版本(本讲的实现如此),或者把验证推迟到”所有可能读到它的读都已验证”之后(这就是 MVCC + SSI 的思路)。

  • OCC 的优缺点
维度表现
优点无锁开销(读路径完全不加锁)、无死锁(不做持有并等待)、读不阻塞、写不阻塞、低冲突时吞吐与延迟都极佳
缺点高冲突时性能崩溃:验证失败 ⇒ 大量回滚 + 重做,白烧 CPU(实验里 OCC 的回滚次数随冲突率急剧上升,见 19.4.1 场景 D);需要为每个事务维护私有工作区 + 读集/写集的内存;写阶段仍需短暂加锁(保证安装是原子的)
适合场景冲突率低、事务短、读多写少或写集不相交的负载;内存数据库(无磁盘 I/O,冲突窗口短)
  • 真实系统
    • 内存数据库 H-Store / VoltDB:所有数据在内存里,事务执行极快,OCC/确定性执行能发挥最大优势。
    • Google Percolator(Bigtable 上的增量索引更新):用 OCC + 2PC + 全局时间戳实现跨行事务,是大规模 OCC 的经典工业实现。
    • PostgreSQL 的 SSI:在快照隔离之上做可串行化验证(本质是”基于快照的乐观验证”)。
    • Redis 的 WATCH / MULTI / EXEC:最朴素的乐观锁——WATCH 记住版本,EXEC 时若被监视的键被改过就整体放弃,由客户端重试。
    • 讲义提到的非事务系统:Cassandra / DynamoDB 的”最后写入获胜(LWW)”、Riak 的向量时钟冲突检测,讲义把它们归类为”乐观并发控制在键值存储中的变体“——因为没有事务,验证退化成”写时比时间戳”或”读时发现兄弟版本(siblings)”。

19.2.20 时间戳排序(Timestamp Ordering, TO)

  • 核心思想:既然”可串行化”意味着”等价于某个串行顺序”,那就事先给每个事务定好它在这个串行顺序里的位置——在事务开始时分配一个全局唯一的时间戳 $\mathrm{TS}(T)$;然后强制所有操作按时间戳顺序执行:如果某个操作会违反时间戳顺序,就回滚事务。根本不用锁,因此也就没有死锁。

  • 讲义给出的两条规则(原文逐字翻译):
    1. $T$ 对对象 $O$ 的写,只有当所有读过或写过 $O$ 的事务的 id 都小于 $T$ 的 id 时才被允许;
    2. $T$ 对对象 $O$ 的读,只有当$O$ 最后一次是由 id 小于 $T$ 的事务写的时才被允许。 实现方式:为每个对象维护 read timestampwrite timestamp;规则被违反就 abort
  • 精确的操作规则(含伪代码级细节):每个数据项 $x$ 维护两个值——$\mathrm{read\_TS}(x)$(读过 $x$ 的最大事务时间戳)与 $\mathrm{write\_TS}(x)$(写过 $x$ 的最大事务时间戳,指已提交的写)。
   事务 T 读 x:
      若 TS(T) < write_TS(x)   ⇒ 拒绝:已有更年轻的事务写过 x,
                                  T 若继续读就会读到【过时值】 ⇒ 回滚 T
      否则                      ⇒ 执行读;read_TS(x) ← max(read_TS(x), TS(T))
                                 (把 x 的读时间戳推高到 T,用来阻止更老的事务以后写 x)

   事务 T 写 x(基本 TO):
      若 TS(T) < read_TS(x)    ⇒ 拒绝:已有更年轻的事务读过 x,
                                  T 的写会让那次读"读了个寂寞"(不可重复读)⇒ 回滚 T
      若 TS(T) < write_TS(x)   ⇒ 拒绝:过时写(stale write),
                                  顺序上 T 的写应当发生在那次写之前 ⇒ 回滚 T
      否则                      ⇒ 执行写;write_TS(x) ← TS(T)
  • 严格 TO 的等待规则:为了让”读到的值”与”时间戳序”严格一致,工程实现(严格时间戳排序, Strict TO)通常规定:任何读写操作都必须等到所有时间戳更小、且写过该数据项的事务结束(提交或中止)之后才能执行。这带来一个非常漂亮的推论:等待只发生在”年轻者等年老者”的方向上($\mathrm{TS}(T_{\text{waiter}}) > \mathrm{TS}(T_{\text{holder}})$),沿等待边时间戳严格递减 ⇒ 等待图不可能有环 ⇒ TO 无死锁(与 19.2.15 的证明完全同构)。本讲 19.4.1 的代码实现的就是这个版本。

  • 正确性论证(直觉版,完整证明见算法 19.3.6):把已提交事务按时间戳从小到大排序,声称这就是等价的串行顺序。任取一对冲突操作:若两者来自同一事务,事务内部顺序天然正确;若来自不同事务 $T_a, T_b$ 且 $\mathrm{TS}(T_a) < \mathrm{TS}(T_b)$,则读规则保证 $T_b$ 不会读到 $T_a$ 写之前的旧版本,写规则保证 $T_b$ 的写不会排到 $T_a$ 的前面(否则 $T_b$ 的请求会被拒绝)。因此所有冲突对的顺序都与时间戳顺序一致 ⇒ 历史冲突等价于按时间戳的串行历史 ⇒ 可串行化。$\blacksquare$

  • 优点与缺点

维度说明
优点无死锁(不加锁、不循环等待);不需要锁管理器;冲突判定是 $O(1)$ 的本地比较;天然给出一个全局一致的串行顺序(对分布式系统很友好——时间戳可以全局分配)
缺点①可能饥饿(starvation):一个老事务的写请求可能被一连串更年轻的事务的读请求反复”顶掉”($\mathrm{TS}(T) < \mathrm{read\_TS}(x)$ 会一直成立),于是被反复回滚。本讲代码实验里可以看到:如果回滚后沿用原时间戳,系统会陷入活锁(几百次回滚仍无法收敛);实践中通常改为回滚后重新分配一个更大的时间戳来打破僵局
缺点②级联回滚风险:基本 TO 若不等待未提交的写,就可能让 $T_b$ 读到 $T_a$ 未提交的写;$T_a$ 一旦回滚,$T_b$ 也必须回滚。严格 TO 通过在”未提交写者”上阻塞读来消除它
缺点③不适合长事务:长事务持有老时间戳,任何年轻事务的读都会把 $\mathrm{read\_TS}$ 推高,从而把长事务的写全部判为非法——长事务被回滚的概率随时间线性上升
  • 两个重要变体
    • 严格时间戳排序(Strict TO):推迟读写直到”所有更早的事务”都完成。它保证可恢复性(recoverability)无级联回滚,代价是引入等待(但等待方向单调 ⇒ 仍无死锁)。它对标的是 2PL 里的”严格 2PL”。
    • Thomas 写规则(Thomas’ Write Rule)——一个极其实用的优化:当 $\mathrm{TS}(T) < \mathrm{write\_TS}(x)$ 时,不要回滚 $T$,而是直接忽略(ignore)这次写为什么可以这样做:条件是 $\mathrm{TS}(T) < \mathrm{write\_TS}(x)$,说明已经有一个更年轻的版本存在。此后任何对 $x$ 的读,其时间戳必须 $> \mathrm{write\_TS}(x) > \mathrm{TS}(T)$(否则会被读规则拒绝),因此没有任何事务可能读到 $T$ 写的这个版本;而在此之前发生的读也早已完成、与这次写无关。既然这个版本永远不会被读到,删掉它不改变任何”读的来源(read-from)”,也不改变最终写 ⇒ 历史仍然是视图可串行化的。 注意这个措辞:Thomas 规则产生的是视图可串行化(比冲突可串行化宽松),这正是它敢”不回滚”的底气。它的收益很直接:大量本来会引发回滚的过时写变成了一次安静的丢弃,在高冲突负载下显著降低回滚率。
  • 补充说明(与键值存储的联系):讲义最后把线索接到了”最终一致性”上——Cassandra / DynamoDB 用物理时钟时间戳 + 最后写入获胜(LWW)来定序,Riak 用向量时钟判断”新写是否因果更新”还是”并发冲突(siblings)”。这些正是时间戳排序在无事务系统中的退化形态:没有事务边界、没有回滚,只有”谁的时间戳大谁赢”。它们的代价是把冲突解决推给了应用,且依赖时钟同步(时钟不同步时,靠得近的两次写可能出现”更旧的写反而赢了”)。详见 NoSQL 一章。

19.2.21 多版本并发控制(MVCC)与快照隔离

  • 定义与目的多版本并发控制(Multi-Version Concurrency Control, MVCC)为每个数据项保留多个版本(每个版本带时间戳或事务 id)。读操作不去争抢当前值,而是读一个快照版本;写操作新建一个版本而不覆盖旧版本。核心收益一句话:

    读不阻塞写、写不阻塞读(readers never block writers, writers never block readers)。 讲义的说法是”为每个对象维护 per-transaction 的版本,标记为临时的(tentative)版本,另有一个已提交版本;读或写时找出’正确的’那个 tentative 版本——’正确’的依据是事务 id,目标是让事务只读紧邻前一个事务写的版本”。

   数据项 x 的版本链(version chain,按时间戳从旧到新)

     [x = 100, ts=50] ──► [x = 80, ts=120] ──► [x = 70, ts=200] ──► (未提交: x=60, T9)

   读事务 R(快照 ts=150)
        │
        └──► 沿链找到"最大的 ts ≤ 150"的那一版 = [x = 80, ts=120] ⇒ 读 80
             完全不需要加锁,也不会被 T9 的写阻塞 ✔

   写事务 W9(ts=200)
        │
        └──► 追加一个新版本 [x = 60, T9](先标记为未提交)⇒ 不覆盖 ts=120 的旧版本
             旧版本仍然活着 ⇒ 正在进行的读者继续读旧版本,不会被阻塞 ✔

   冲突只剩下一类:【写-写冲突】——两个事务同时想写 x。
        · first-updater-wins:写的时候就要拿到 x 的写锁/版本锁,谁先写谁赢,
          后到者等锁或直接 abort;
        · first-committer-wins:写的时候各写各的私有版本,提交时发现
          "我基于的快照之后有人已经提交了对 x 的写" ⇒ abort 我自己(即快照隔离的实现方式)
  • MVCC 如何”解决”读-写冲突:读-写冲突的本质是”读的人想看到一个稳定值,写的人想改它”。单版本系统只能用锁强制排队;多版本系统让读者留在旧版本上、写者去建新版本,两者物理上不碰面。这就是 MVCC 能让 PostgreSQL/InnoDB 在 READ COMMITTED 下做到”读完全不加锁”的原因。
  • 但写-写冲突仍然存在,且必须显式处理(否则会有脏写、丢失更新),于是产生了两种经典策略:
策略时机行为代表
first-updater-wins(先更新者赢)写的时候写前先”预约”该数据项的写权(加写锁);拿不到就等待或 abort多数 MVCC 数据库的写路径
first-committer-wins(先提交者赢)提交的时候提交时检查”我读快照之后,我写过的数据项是否已被别人提交过写”;是则 abort快照隔离(SI)
  • 快照隔离(Snapshot Isolation, SI):SI 的定义有两条:
    1. 每个事务读自己开始时的一个一致快照(该快照包含当时所有已提交事务的写)——避免了不可重复读与幻读;
    2. 写-写冲突用 first-committer-wins 解决:若事务 $T$ 要写的某个数据项在 $T$ 的快照之后已被别的已提交事务写过,则 $T$ 回滚。 SI 广泛存在于 Oracle、SQL Server、PostgreSQL、MySQL InnoDBREPEATABLE READ 的实现),因为它既避免了大部分异常,又几乎不阻塞。
  • SI 不是可串行化的!——写偏斜(Write Skew)

经典例子:医生值班。约束是”任何时刻至少有一名医生在值班“。当前 Alice 和 Bob 都在值班。两人同时想请假,各自执行同一个事务:

   T_Alice:  SELECT count(*) FROM on_call WHERE shift = 'A' AND on_call = true;   -- 读到 2
             if count > 1:  UPDATE on_call SET on_call = false WHERE name = 'Alice';
   T_Bob  :  SELECT count(*) FROM on_call WHERE shift = 'A' AND on_call = true;   -- 也读到 2
             if count > 1:  UPDATE on_call SET on_call = false WHERE name = 'Bob';

   在 SI 下:两个事务读的是同一个快照(都看到 2,满足 count > 1 的条件),
             写的是【不同的行】(Alice 改 Alice 行,Bob 改 Bob 行)⇒ 没有写-写冲突
             ⇒ 两者都成功提交 ⇒ 结果是【没有人在值班】—— 约束被破坏 ✘

为什么 SI 抓不到它:这个异常依赖的是读-写冲突(rw-antidependency)——$T_{Alice}$ 读了 $T_{Bob}$ 将要修改的数据的”旧版本”。SI 只检测写-写冲突,对读写冲突视而不见,因此这类”两个事务各自读同一条件、各自写不同行、合起来违反约束”的模式会漏过去。这也解释了为什么”银行转账”这类异常 SI 能防(因为转账的两条写会碰撞)、而”医生值班”这类异常 SI 防不住(写集不相交)

  • 可串行化快照隔离(SSI):在 SI 基础上额外跟踪读-写依赖,一旦检测到危险结构(两个连续的 rw 依赖 $T_1 \xrightarrow{rw} T_2 \xrightarrow{rw} T_3$,且 $T_1, T_3$ 之间有写依赖或并发关系),就中止其中一个事务。它保留了 SI 的”读不阻塞写”,只在真正危险的少数情形下回滚。PostgreSQL 9.1+ 的 SERIALIZABLE 就是 SSI
  • MVCC 的代价
    • 空间开销:多版本长期存活会占用大量存储(PostgreSQL 的”表膨胀 table bloat”是著名运维问题);
    • 垃圾回收(vacuum / purge):必须回收”不再有任何活跃快照可能读到”的旧版本——回收太早会破坏快照一致性,太晚则空间失控;
    • 实现复杂度:版本链的可见性判断(哪一版对本事务可见)是每个读操作的额外开销;索引也要版本化(或额外回表判断可见性);
    • 写偏斜不是唯一盲区:SI 还允许”只读事务异常(read-only anomaly)”等更加微妙的问题,这也是 SSI 存在的原因。
  • 真实系统一览PostgreSQL(MVCC + SSI 做 SERIALIZABLE)、MySQL InnoDB(MVCC + next-key lock 做 REPEATABLE READ)、Oracle(MVCC + 回滚段)、SQL ServerREAD_COMMITTED_SNAPSHOT 行版本)、Spanner(MVCC + 悲观锁 + TrueTime 时间戳)、CockroachDB(MVCC + 串行化验证)。可见 MVCC 是”存储层”的普适技术,各家差别主要在于冲突检测策略时间戳来源

19.3 算法伪代码与正确性分析

本节给出六份伪代码。为便于书写,统一约定:

  • 记号 $R_i(x)$/$W_i(x)$ 表示事务 $T_i$ 读/写数据项 $x$;$\mathrm{TS}(T)$ 为事务时间戳(越小越老);$RS(T)$、$WS(T)$ 为读集与写集。
  • 事务状态:ready(可推进)、blocked(在等锁/等更早的写者)、committedaborted
  • 共同的正确性判据:只要证明”协议产生的历史其优先图无环”,就由 19.2.9 的串行化定理得到可串行化。下面每个证明都套用这个模板。

算法 19.3.1:严格两阶段锁(Strict Two-Phase Locking)

假设与系统模型

  • 单机(单锁管理器)或”锁管理器可被所有事务原子访问”;事务之间异步并发。
  • 故障模型:crash-stop + 崩溃恢复(崩溃后重启,用 WAL 撤销未提交事务、重做已提交事务)。协议本身不处理崩溃,恢复由日志负责。
  • 通道:本地调用,可靠(加锁请求不会丢失);lock 调用返回 granted / blocked,不阻塞调用线程。
  • 事务数 $n$ 任意;每个事务的访问集在运行前未知(与保守 2PL 的区别)。

伪代码

# 锁管理器(Lock Manager)
state:
    holders[item] : map item -> map(tid -> mode)     # 当前持有者及模式 ('S'/'X')
    queue[item]   : FIFO list of (tid, mode)         # 只按 FIFO 排队,防止饥饿

upon request_lock(item, tid, mode):
    if holders[item][tid] == 'X' or (holders[item][tid]=='S' and mode=='S'):
        return GRANTED                               # 重入 / 已持更强锁
    if (tid, mode) not in queue[item]: queue[item].append((tid, mode))
    if queue[item][0] != (tid, mode): return BLOCKED # 必须排在队首,保证 FIFO
    for (other, m) in holders[item]:
        if other != tid and (mode == 'X' or m == 'X'): return BLOCKED   # 相容矩阵判定
    holders[item][tid] = mode
    queue[item].remove((tid, mode))
    return GRANTED

upon release_all(tid):                                # 严格 2PL:只在提交/中止时调用
    for item in holders: holders[item].remove(tid)
    for item in queue:   queue[item].remove_if(t -> t.tid == tid)

# 事务执行器(每个事务 T 的驱动逻辑)
upon T.step():
    if T.ops 已耗尽:                                  # 收尾
        release_all(T.tid)                            # ← 严格 2PL 的关键:此刻才放锁
        T.state = COMMITTED
        return DONE
    op = T.ops[T.pc]
    mode = ('S' if op 是读 else 'X')
    if request_lock(op.item, T.tid, mode) == BLOCKED:
        T.state = BLOCKED                             # 保持已持有的锁不动 → 可能死锁
        return BLOCKED
    if op 是读:
        T.local[op.var] = read_from_db(op.item)       # 持 S 锁后读(因为 X 锁未释放,绝不会读到脏数据)
    else:
        write_to_db(op.item, op.fn(T.local))          # 持 X 锁后直接写(严格 2PL 下无人能读到脏数据)
    T.pc += 1
    return PROGRESS

算法逻辑解说(用 19.4.1 场景 A 走一遍:x = 100,$T_1$ 扣 10,$T_2$ 扣 20)

  1. $T_1$ 要 R(x):申请 S(x),队首且无冲突 ⇒ 授予,读到 100。
  2. $T_2$ 要 R(x):申请 S(x),与 $T_1$ 的 S 相容 ⇒ 也授予,也读到 100。
  3. $T_1$ 要 W(x):申请 X(x)锁升级)。它是队首,但持有者里除自己外还有 $T_2$ ⇒ BLOCKED,$T_1$ 阻塞(注意它没有释放 S 锁)。
  4. $T_2$ 要 W(x):申请 X(x),但 $T_1$ 已排在队首 ⇒ BLOCKED
  5. 系统进入”全员阻塞”状态 ⇒ 触发死锁检测(算法 19.3.2):等待图有环 $T_1 \to T_2 \to T_1$ ⇒ 回滚牺牲者 $T_2$(最年轻)。
  6. $T_2$ 释放 S 锁 ⇒ $T_1$ 的升级成功,写 90、提交、放锁;$T_2$ 重跑:读到 90,写 70 ✓。
  7. 最终 x = 70,与串行执行一致 ✓。

正确性论证

  • 安全性 Safety(2PL ⇒ 冲突可串行化)
    1. 对每条冲突边 $T_i \to T_j$,设 $T_i$ 的操作为 $p$、$T_j$ 的操作为 $q$,两者访问同一数据项且至少一个是写。因为访问相冲突,两者必须持有不相容的锁模式,所以 $T_i$ 必须先获取、后释放该锁,$T_j$ 才能获取它。
    2. 记 $s_i$ 为 $T_i$ 进入收缩阶段(第一次释放任何锁)的时刻,$g_j$ 为 $T_j$ 增长阶段中获取该锁的时刻。由第 1 步:$s_i \le \text{release}_i < \text{acquire}_j \le g_j \le s_j$,即 $s_i < s_j$;而 $s_i$ 恰好就是 $T_i$ 的锁点(lock point)。
    3. 于是每条冲突边都满足 $\text{lock\_point}(T_i) < \text{lock\_point}(T_j)$——锁点沿边严格递增
    4. 假设优先图有环 $T_{i_1} \to T_{i_2} \to \cdots \to T_{i_k} \to T_{i_1}$,沿环传递得 $\text{lock\point}(T{i_1}) < \cdots < \text{lock\point}(T{i_1})$,矛盾。故无环 ⇒ 由串行化定理冲突可串行化。$\blacksquare$
      • 补充:这个证明只用到了”两阶段”这一条纪律,与放锁早晚无关,因此基本 2PL、严格 2PL、强严格 2PL 都满足可串行化
  • 安全性(严格 2PL 的额外保证)
    • 可恢复性(recoverable):$T_j$ 若读了 $T_i$ 写的数据,则 $T_j$ 必然是在 $T_i$ 释放 X 锁之后才读到它——而严格 2PL 的 X 锁在提交时才释放,所以 $T_i$ 提交必早于 $T_j$ 读到该数据、也早于 $T_j$ 提交。故不存在”读了未提交数据却先提交”的情形,回滚时不会牵连已提交事务。
    • 无级联回滚(avoids cascading aborts, ACA):读者只会读到已提交事务的写(脏数据被 X 锁挡住),所以回滚 $T_i$ 不会迫使任何其他事务一起回滚。
    • 串行化顺序 = 提交顺序:严格 2PL 下锁点与提交点重合,于是由安全性第 3 步得到”提交顺序就是等价的串行顺序”。
  • 活性 Liveness
    • 无饥饿:锁授予严格 FIFO,任何请求者的排队位置只会前进(前面的人拿完就走),不会无限期被插队。
    • 可能死锁:协议保证无死锁(这正是它的代价)——所以必须外挂死锁检测(19.3.2)或死锁预防(19.3.3)/超时。若配合周期性检测,则活性在”检测器最终会发现环并牺牲一个事务”的前提下成立(见算法 19.3.2 的活性论证)。

复杂度

  • 每次加锁:哈希查表 + 相容性检查,$O(1)$(若用位图表示模式集合则更快)。
  • 每个事务的锁数上界 = 它访问过的不同数据项数 $\vert items(T)\vert $;总空间 $O(\sum_T \vert items(T)\vert )$。
  • 释放:$O(\vert items(T)\vert )$(事务结束时需要清空它在所有队列中的排队项)。
  • 死锁检测:增量维护 $O(1)$/边;一次全图检测 $O(V+E)$,其中 $V \le n$(活跃事务数)、$E$ 为等待边数,$E = O(V^2)$。

算法 19.3.2:等待图死锁检测与牺牲者回滚

假设与系统模型

  • 单机(一个锁管理器即可看到全部等待关系)。分布式下的推广见算法 19.3.4。
  • 同步/异步均可;要求锁管理器的状态更新是原子的(因此等待图永远是当前的,不会出现幻死锁)。
  • 检测是周期性触发的(例如每 $T_{poll}$ 毫秒),或在”所有事务都阻塞”时立即触发。

伪代码

state:
    wfg : directed graph, 节点 = 活跃事务, 边 (Ti -> Tj) 表示 Ti 在等 Tj 持有的锁
    rollback_count[tid] : 该事务被牺牲的次数(用于防饥饿)

# ---- 边维护:在加锁/放锁的同一临界区里增量更新,O(1) ----
upon T_i 因 item 被阻塞(持有者是 T_j):
    for each T_j in holders[item] conflicting with requested mode:
        wfg.add_edge(T_i, T_j)
upon T_i 获得 item 的锁 或 T_i 回滚:
    wfg.remove_all_out_edges(T_i)         # 它不再等任何人了
    wfg.remove_all_in_edges(T_i)          # 别人可能不再等它(若它释放了锁)
    for each blocked T_a: 按其阻塞条件重新添加必要的边

# ---- 周期性检测 ----
upon detect_deadlock():
    color = {}                            # 0 = 未访问, 1 = 在栈上(灰), 2 = 已完成(黑)
    stack = []
    function dfs(u):
        color[u] = 1; stack.push(u)
        for v in wfg.successors(u):
            if color[v] == 1:             # 遇到灰色节点 ⇒ 发现环
                cycle = stack.from(v)      # 截取环上的事务列表
                return cycle
            if color[v] == 0:
                r = dfs(v); if r != null: return r
        stack.pop(); color[u] = 2
        return null
    for u in wfg.nodes():
        if color[u] == 0:
            cycle = dfs(u)
            if cycle != null:
                victim = select_victim(cycle)
                return abort_and_recover(victim)

upon select_victim(cycle):
    # 启发式:优先(持有锁数少, 越年轻, 已回滚次数少)
    return argmin over t in cycle of ( locks_held(t), -TS(t), rollback_count[t] )

upon abort_and_recover(victim):
    rollback_count[victim] += 1
    undo_all_writes(victim)               # 用 undo 日志恢复旧值
    release_all_locks(victim)             # 唤醒因它而阻塞的事务(并更新 wfg 的边)
    victim.restart()                      # 重启;沿用原时间戳可保证 TS 序稳定

算法逻辑解说(用 19.4.3 的三事务环:$T_1$ 持 A 等 B、$T_2$ 持 B 等 C、$T_3$ 持 C 等 A)

  1. 三个事务各自持有一把锁并申请下一把 ⇒ 三条等待边,等待图是一个三元环。
  2. 检测器从 $T_1$ 出发 DFS:$T_1 \to T_2 \to T_3 \to T_1$,$T_1$ 是灰色节点 ⇒ 得到环 $[T_1,T_2,T_3]$。
  3. select_victim:三人各持 1 把锁、回滚次数都是 0,于是按”最年轻”选 $T_3$。
  4. 回滚 $T_3$:undo 它对 C 的写、释放 C 的锁 ⇒ $T_2$ 拿到 C,继续执行并提交 ⇒ 死锁解除,系统重新推进。
  5. 恢复后重新检测:等待图只剩 $T_1 \to T_2$,无环 ✓。

正确性论证

  • 安全性 Safety(报出的环一定是真死锁):若 DFS 找到环 $T_{i_1} \to \cdots \to T_{i_k} \to T_{i_1}$,则每条边都表示”前者正在等待后者持有的锁”,即环上每个事务都在等待环上下一个事务持有的另一把锁,而它自己持有的锁又不会释放(阻塞期间锁不释放)⇒ 环上没有任何事务能推进,这是一个真正的死锁。另外,因为等待图由锁管理器在临界区内原子维护,不会出现幻死锁(信息永远是最新的)。$\blacksquare$
  • 活性 Liveness(有死锁终会被发现,且系统不会永久卡死)
    1. 检测完备性:只要检测器周期性运行(或在”一圈无人推进”时立即运行),活跃的环必然会被某次检测遍历到(DFS 会访问所有节点),因此死锁不会”永远不被发现”。
    2. 系统前进性:一次成功的检测至少回滚环上一个事务,撤销其全部等待边,环被破坏(可能的例外:多个环共享节点时,牺牲者可能同时在别的环上,需要再检测一轮——由于牺牲者会重启并重新竞争,且检测会重复进行,系统最终能推进;实践中通过”牺牲者优先选择不在多个环上的事务”来加速)。
    3. 无饥饿rollback_count 参与牺牲者选择,使被反复牺牲的事务逐渐获得豁免(老化)。

复杂度

  • 边维护:每次阻塞/唤醒 $O(\text{冲突持有者数})$,均摊 $O(1)$。
  • 一次检测:$O(V + E)$,其中 $E$ 为等待边数(最坏 $O(V^2)$)。
  • 空间:$O(V + E)$。
  • 检测频率的代价:设每次检测成本 $C = O(V+E)$,则检测的额外开销为 $C / T_{poll}$(每秒);$T_{poll}$ 太小 ⇒ CPU 浪费,太大 ⇒ 死锁存活时间变长、事务白等。经验做法是把 $T_{poll}$ 设为事务平均执行时间的量级。

算法 19.3.3:Wait-Die 与 Wound-Wait(时间戳死锁预防)

假设与系统模型

  • 时间戳在事务开始时分配,全局唯一、单调;回滚后重启时沿用原时间戳(这是”不饥饿”论证的关键前提)。
  • 每个事务的锁请求是原子的;Wait-Die 不允许抢占(只能回滚请求者自己),Wound-Wait 允许抢占(可以强行回滚锁的持有者)。
  • 锁的授予与释放语义同 19.3.1(严格 2PL 的收尾方式)。

伪代码

# ---------- Wait-Die(非抢占:老的等,年轻的死)----------
upon request_lock(Ti, item, mode):
    loop:
        if 不相容的持有者集合 conflict(Ti, item, mode) 为空:
            grant(Ti, item, mode); return GRANTED
        let Tj = conflict 集合中的任意一个(通常取最老的或最先的)
        if TS(Ti) < TS(Tj):                  # Ti 更老
            block(Ti, waiting_for = Tj)      # 【wait】老老实实等
            return BLOCKED
        else:                                # Ti 更年轻
            abort(Ti)                        # 【die】回滚自己
            restart(Ti, ts = TS(Ti))         # 【关键】沿用原时间戳重启
            return ABORTED

# ---------- Wound-Wait(抢占:老的伤害,年轻的等)----------
upon request_lock(Ti, item, mode):
    loop:
        if 不相容的持有者集合 conflict(Ti, item, mode) 为空:
            grant(Ti, item, mode); return GRANTED
        for each Tj in conflict 集合:
            if TS(Ti) < TS(Tj):              # Ti 更老
                abort(Tj)                    # 【wound】强行回滚持有者 Tj
                restart(Tj, ts = TS(Tj))     # Tj 也用原时间戳重启
                # 继续 loop:等 Tj 的锁真正释放后再检查一次
            else:                            # Ti 更年轻
                block(Ti, waiting_for = Tj)  # 【wait】
                return BLOCKED

算法逻辑解说($T_1$ 时间戳 10、$T_2$ 时间戳 20;$T_1$ 持 A,$T_2$ 持 B;随后 $T_2$ 请求 A、$T_1$ 请求 B)

步骤Wait-Die 的处置Wound-Wait 的处置
$T_2$(20)请求 A($T_1$ 持有)$T_2$ 更年轻 ⇒ die:$T_2$ 回滚,释放 B,用 20 重启$T_2$ 更年轻 ⇒ wait:$T_2$ 阻塞等 A
$T_1$(10)请求 B($T_2$ 持有)$T_1$ 更老 ⇒ wait:等 B(此时 B 已因 $T_2$ 死亡而释放,立刻拿到)$T_1$ 更老 ⇒ wound:强行回滚 $T_2$,$T_1$ 拿到 B
结果$T_2$ 白做一次(重做),$T_1$ 顺利推进$T_2$ 白做一次(被打断),$T_1$ 顺利推进
倾向让老事务等待(老事务的响应时间变差),年轻事务被反复回滚让年轻事务让路(老事务优先完成),等待发生在年轻一侧

正确性论证

  • 安全性 Safety(无死锁)
    • Wait-Die 中,只有”$T_i$ 更老”时才会产生等待,所以每条等待边满足 $\mathrm{TS}(T_i) < \mathrm{TS}(T_j)$ ⇒ 沿边严格递增
    • Wound-Wait 中,只有”$T_i$ 更年轻”时才会产生等待,所以每条等待边满足 $\mathrm{TS}(T_i) > \mathrm{TS}(T_j)$ ⇒ 沿边严格递减
    • 两种情况下,等待关系都是一个严格偏序(时间戳沿边单向严格变化)。若存在环 $T_{i_1}\to\cdots\to T_{i_k}\to T_{i_1}$,沿环传递会得到 $\mathrm{TS}(T_{i_1}) < \mathrm{TS}(T_{i_1})$(或 $>$),严格不等式回到自身,矛盾 ⇒ 等待图无环 ⇒ 无死锁。$\blacksquare$
    • 注意前提:重启必须沿用原时间戳。否则一个刚被回滚的事务可能拿到更大的时间戳,等待边的单调方向就会被破坏,证明失效(这也是实现中必须遵守的纪律)。
  • 安全性(可串行化):两种协议都只是”在 2PL 的框架内决定谁等待、谁回滚”,回滚的事务用原时间戳重做并最终重新经历完整的 2PL 生命周期,所以产生的历史仍然是 2PL 历史 ⇒ 由算法 19.3.1 的证明,冲突可串行化
  • 活性 Liveness(无饥饿)
    • Wait-Die:能杀死年轻事务 $Y$ 的只有”比 $Y$ 更老”的事务;时间戳在开始时分配,所以 $Y$ 启动之后再也不会出现比它更老的事务——这个集合是有限且不再增长的。而每个老事务最终都会完成(老事务只等待更年轻的事务,年轻者要么做完、要么被杀死后释放全部锁,因此老事务的等待总会被解除)。有限次杀死之后,$Y$ 必然成功。$Y$ 之所以能保持”年轻”,正是因为重启时沿用原时间戳
    • Wound-Wait:最老的事务从不等待(它只会伤害别人),因此总有一个事务能推进;同理每个事务在被有限的更老事务”伤害”完之后必然完成。
  • 补充:两种策略的取舍——Wait-Die 让老事务等,可能拖长老事务的响应时间;Wound-Wait 让老事务优先,平均上更少回滚(因为老事务有更多的已做工作,让它继续更划算),代价是”被伤害”的事务要重做全部工作。

复杂度

  • 每次锁请求:冲突集合的检查 $O(\vert \text{holders}\vert )$,比较时间戳 $O(1)$;被回滚时需要 undo 其写并释放锁,$O(\vert items(T)\vert )$。
  • 无需维护任何全局图、无需周期性检测 ⇒ 空间 $O(1)$(除锁表外),通信 $O(1)$。这是它在分布式系统中特别受欢迎的原因。
  • 代价体现在回滚率:本讲 19.4.3 的实验里,高冲突负载下 TO/时间戳类策略的回滚次数显著高于 2PL(后者靠”阻塞等待”避免回滚)。

算法 19.3.4:Chandy-Misra-Haas 分布式死锁检测(边追踪)

假设与系统模型

  • $N$ 个站点,事务分布在站点上;每个站点只知道自己本地事务的等待关系(局部 WFG)。
  • 通道:可靠(探测消息不丢失)、FIFO(加速检测,非必需);站点可能 crash-stop(崩溃会导致探测消息丢失 ⇒ 该分支的检测失效,因此实际系统会配合超时)。
  • AND 等待模型:事务要拿到全部所需锁才能继续(本课程采用的口径);OR 模型需要不同的算法。
  • 目标:不发全局快照,用探测消息沿等待边传播来发现环。

伪代码

# 变量(每个站点 S 本地维护)
    waits[t]      : 事务 t 正在等待的事务集合(局部 WFG 的出边)
    forwarded[t]  : 事务 t 已经转发过的 (initiator, sender, target) 集合(消息抑制)
    site_of[t]    : 事务 t 所在站点(通过路由表查到)

# ---------- 1) 发起:当本地事务 Ti 开始等待本地或远程事务 Tj ----------
upon 本地事务 Ti 因 item 被 Tj 阻塞:
    waits[Ti].add(Tj)
    send PROBE(initiator=Ti, sender=Ti, target=Tj) to site_of[Tj]

# ---------- 2) 站点 S 收到探测消息 ----------
upon receive PROBE(i, j, k):
    # 语义:发起者是 Ti,发送者 Tj 报告"Tj 正在等待 Tk"
    if k == i:                                  # 探测绕回发起者 ⇒ 存在环
        report_deadlock(cycle_ending_at = i)
        return
    if (i, j, k) in forwarded[k]:               # 【消息抑制】同样的探测不必重复转发
        return
    if waits[k] is empty:                       # Tk 不在等待任何人 ⇒ 这条路径不可能成环
        return
    forwarded[k].add((i, j, k))
    for each m in waits[k]:                     # 沿等待边继续追击
        send PROBE(initiator=i, sender=k, target=m) to site_of[m]

# ---------- 3) 本地等待关系变化时 ----------
upon Tt 获得锁(不再等待)或 Tt 提交/中止:
    waits.remove(Tt)
    forwarded.remove(Tt)                        # 它的历史转发记录一并作废

算法逻辑解说(3 个站点上的环:$T_1@S_0 \to T_2@S_1 \to T_3@S_2 \to T_1@S_0$)

事件说明
1$S_0$ 发出 PROBE(1, 1, 2) 给 $S_1$$T_1$ 开始等待 $T_2$
2$S_1$ 收到:$k=2 \neq i=1$,且 waits[2] = {3} ⇒ 转发 PROBE(1, 2, 3) 给 $S_2$探测沿等待边前进
3$S_2$ 收到:$k=3 \neq 1$,且 waits[3] = {1} ⇒ 转发 PROBE(1, 3, 1) 给 $S_0$探测绕到最后一个事务
4$S_0$ 收到:$k = 1 = i$ ⇒ 检测到死锁,环为 $T_1\to T_2\to T_3\to T_1$只有真正成环才会收到”自己的”探测
5若 $S_1$ 重传了同一条 PROBE(1,1,2)(1,1,2) 已在 forwarded 中 ⇒ 直接丢弃消息抑制把重复探测的爆炸式放大压住

正确性论证

  • 安全性 Safety(何时”报告 ⇒ 真死锁”成立):设收到 PROBE(i, k, i) 时报告死锁。
    • 这条探测路径可以还原成 $i \to t_2 \to t_3 \to \cdots \to t_k \to i$:PROBE(i, sender, target) 的每一跳都意味着”在发起这一跳的那一刻,sender 确实在等待 target“(因为只有当 sender 被 target 阻塞时,它的 waits 里才会有 target)。
    • 若假设等待关系在整次检测期间保持不变,则路径上所有边同时成立 ⇒ 等待图确实含环 ⇒ 是真死锁。$\blacksquare$
    • 这个证明依赖”等待关系不变”这一前提——去掉它,安全性就只剩”曾经存在过部分环”,于是出现幻死锁。
  • 幻死锁(phantom deadlock)的构造:在步骤 3 与步骤 4 之间,让 $T_2$(环上的中间节点)中止并释放全部锁
    • 真实的等待边只剩 $T_3 \to T_1$(无环);
    • 但 $S_1$ 早已把 PROBE(1,2,3) 发出去了,$S_2$ 收到时它的本地 waits[3] = {1} 仍然成立,于是继续转发 PROBE(1,3,1)
    • $S_0$ 收到后 $k=i$ ⇒ 报出一个已经不存在的死锁
    • 本质原因:探测消息携带的是”发送时刻的等待信息”,而环的成立需要”同一时刻所有边都成立”。异步系统里这两者无法区分。这与快照一章的结论完全一致。
    • 缓解:① 用一致性全局快照(Chandy-Lamport)获取一个因果一致的等待图再检测,使”快照意义下的环”是真实存在过的;② 探测消息携带时间戳/事务状态,报死锁前向环上站点确认”这些事务是否仍在等待”;③ 接受误报并保证回滚安全——牺牲者本来就可能白回滚,误报只损失性能不破坏正确性。
  • 活性 Liveness(报告 ⇒ 能检测到)
    1. 完整性:若存在一个”持续存在”的环(环上所有等待一直不被解除),则环上某事务 $T_i$ 的探测最终会沿环传播 $k$ 步回到 $T_i$,因为通道可靠、FIFO,且每一步的 waits 都非空。因此死锁必然最终被检测到
    2. 终止性:每条探测路径长度不超过”等待链的最大长度”(因为在无环的链上必然终止,而在有环时会立即报告并停止),配合消息抑制,探测风暴不会无限扩散。
    3. 注意:站点崩溃会丢失探测消息 ⇒ 完整性只在无故障(或配合超时重发)时成立。这也是为什么分布式系统里”超时 + 回滚”仍然是最常用的兜底手段。

复杂度

  • 消息复杂度:一次检测最坏为”沿等待图的所有路径”传播,最坏 $O(E)$ 条消息($E$ 为全局等待边数),每条消息 $O(1)$ 大小;消息抑制把同一 $(i,j,k)$ 的重复转发降为一次。
  • 时间:最坏 $O(\text{最长等待链长度})$ 个消息延迟(异步系统下没有时间上界,只能说”有限步内”)。
  • 空间:每个站点 $O(\text{本地事务数} \times \text{局部边数})$(主要是 forwarded 集合)。
  • 对比集中式:集中式是”每有边变化就上报中心”($O(E)$ 消息但集中在一条链路上,且有单点);边追踪是”只有发起者才发消息”(开销按事务发起等待的次数计),路径信息留在原地、延迟更高。

算法 19.3.5:Kung-Robinson 乐观并发控制(OCC)

假设与系统模型

  • 数据库提供已提交版本的读(本实现中读操作直接读当前已提交值,绝不允许脏读);
  • 每个事务有私有工作区验证与写阶段在一个临界区中原子执行(这是 OCC 唯一的串行点,也是正确的必要条件);
  • 事务的时间戳在进入验证阶段时分配(即”验证顺序”),而不是开始时分配——这一点是验证条件正确的前提(见 19.2.19 的补充说明);
  • 事务之间异步;无故障注入(崩溃恢复由 WAL 负责,与协议正交)。

伪代码

state(全局):
    db            : 已提交数据
    committed     : 已通过验证的事务列表(按验证顺序,携带其 start / read_end / commit_step / RS / WS)
    verify_lock   : 互斥锁,保护"验证 + 写阶段"这一段临界区

# ---------------- 阶段 ① 读阶段 ----------------
upon T.step_read_phase():
    if T.pc >= |T.ops|: return ENTER_VALIDATION
    op = T.ops[T.pc]
    if op 是读 item:
        T.local[op.var] = db[item]        # 读已提交版本,不加锁
        T.RS.add(item)                    # 记录读集
    else:  # 写
        T.buffer[item] = op.fn(T.local)   # 只写【私有工作区】,不碰 db
        T.WS.add(item)                    # 记录写集
    T.pc += 1
    return PROGRESS

# ---------------- 阶段 ② 验证 + 阶段 ③ 写(同一临界区)----------------
upon T.enter_validation():
    acquire(verify_lock)                  # ← 验证与写阶段必须原子(串行点)
    T.read_end = now()
    T.ts = next_timestamp()               # 【时间戳在此分配】= 进入验证的顺序
    for each Tj in committed:             # 只与"先通过验证"的事务比较(TS(Tj) < TS(T))
        if Tj.commit_step < T.start:      # 条件 1:Tj 完全早于 T
            continue                      #   所有冲突天然有序,无需检查
        if Tj.read_end < T.read_end and Tj.commit_step > T.read_end:
            # 条件 2:Tj 的写阶段发生在 T 的读阶段【之后】→ T 不可能读到 Tj 的过时值
            #         唯一危险是"两者写同一数据项"导致覆盖顺序颠倒
            if T.WS ∩ Tj.WS ≠ ∅: goto ABORT
        else:
            # 条件 3(含"Tj 的写阶段落在 T 的读阶段之内"这一最危险情形):
            #         T 可能读到了 Tj 覆盖前的过时值,也可能与 Tj 争抢同一个写
            if (T.RS ∪ T.WS) ∩ Tj.WS ≠ ∅: goto ABORT

    # ---------------- 阶段 ③ 写阶段(仍在临界区内)----------------
    for each (item, val) in T.buffer:
        db[item] = val                    # 原子地安装,中间不被任何读观察到
    committed.append(T)
    release(verify_lock)
    T.state = COMMITTED
    return DONE

ABORT:
    discard(T.buffer)                     # 丢弃私有工作区(数据库从未被污染)
    release(verify_lock)
    T.state = ABORTED                     # 由外层驱动重试(restart)

算法逻辑解说(19.4.1 场景 A:$x=100$,$T_1$ 扣 10,$T_2$ 扣 20,交错脚本 T1,T2,T1,T2

  1. 读阶段:$T_1$ 读 $x=100$($RS_1=\{x\}$),把 $100-10=90$ 写进私有工作区($WS_1=\{x\}$,数据库里还是 100);$T_2$ 读 $x=100$($RS_2=\{x\}$),私有工作区里放 $80$。
  2. $T_1$ 进入验证:committed 为空 ⇒ 无条件通过;把 $x$ 装成 90,加入 committedcommit_step = 5
  3. $T_2$ 进入验证:对 $T_1$ 检查——$T_1.commit\_step = 5$ 不小于 $T_2.start = 2$ ⇒ 不满足条件 1;$T_1.read\_end = 3 < T_2.read\_end = 6$,但 $T_1.commit\_step = 5 \le T_2.read\_end = 6$($T_1$ 的写阶段落在 $T_2$ 的读阶段之内)⇒ 走条件 3 的强检查:$(RS_2 \cup WS_2) \cap WS_1 = \{x\} \neq \varnothing$ ⇒ 回滚 $T_2$
  4. $T_2$ 重做:这次读到 90,算出 70,验证通过 ⇒ 最终 $x = 70$ ✓,与串行执行一致。

正确性论证

  • 安全性 Safety(验证通过的已提交集合冲突可串行化):把已提交事务按验证顺序排列 $T_1, T_2, \ldots, T_m$,声称 $H$ 冲突等价于这个串行历史。任取 $T_j$(先验证)与 $T_i$(后验证)的一对冲突操作,按 $T_j$ 的写阶段相对于 $T_i$ 的读区间 $[start_i, read\_end_i]$ 的位置分三种情形:
    1. $T_j$ 的写阶段在 $start_i$ 之前(条件 1):$T_j$ 的所有操作都在 $T_i$ 的所有操作之前 ⇒ 该冲突对顺序为 $T_j$ 在前 ✓,与串行序一致。
    2. $T_j$ 的写阶段落在 $[start_i, read\_end_i]$ 之内(条件 3):验证要求 $WS(T_j) \cap (RS(T_i) \cup WS(T_i)) = \varnothing$。逐类核查冲突:
      • $W_j(x)$ 与 $R_i(x)$:由 $RS(T_i) \cap WS(T_j) = \varnothing$ 排除 ⇒ 二者不可能同时访问 $x$,不存在“$T_i$ 读到 $T_j$ 覆盖前的过时值”的冲突;
      • $W_j(x)$ 与 $W_i(x)$:由 $WS(T_i) \cap WS(T_j) = \varnothing$ 排除 ⇒ 不存在覆盖顺序颠倒;
      • $W_i(x)$ 与 $R_j(x)$:$T_j$ 的读发生在 $T_j$ 的写阶段之前(更在 $T_j$ 的验证之前),而 $T_i$ 的写发生在 $T_i$ 的验证(晚于 $T_j$ 的验证)⇒ $T_j$ 的读先于 $T_i$ 的写 ✓ 顺序与串行序一致。
    3. $T_j$ 的写阶段在 $read\_end_i$ 之后:由于 $T_j$ 先通过验证,$T_j.commit\_step < T_i.read\_end$($T_i.read\_end$ 就是 $T_i$ 进入验证的时刻),所以这一情形不可能出现;若实现允许”延迟验证”,则此时 $T_i$ 不可能读到 $T_j$ 的写,唯一危险仍是写-写覆盖 ⇒ 检查 $WS(T_i) \cap WS(T_j) = \varnothing$(即条件 2)。 三种情形下每一对冲突操作的顺序都与”验证顺序”一致 ⇒ $H$ 冲突等价于按验证顺序的串行历史 ⇒ 冲突可串行化。$\blacksquare$
  • 关键依赖:证明里”$T_j.commit\_step < T_i.read\_end$”这一步依赖”验证与写阶段原子执行“以及”比较对象只有先通过验证的事务“。如果验证能被并发执行、或时间戳在事务开始时分配而验证延后,就必须用条件 3 的强检查兜底(否则会有反例)。
  • 活性 Liveness
    1. 每次回滚都会释放全部资源(无锁)并允许重试,系统不会进入死锁(OCC 不存在”持有并等待”)。
    2. 可能饥饿:若一个事务每次验证时都恰好被新提交的冲突事务挡住,它可能被反复回滚。经典缓解手段是”重试次数越多、优先级越高”,或让它在验证时抢占(把冲突的已提交事务回滚掉,代价更高故少用)。
    3. 完整性:在冲突有限的负载下,重试最终会成功(每次重试都读到更新的快照,而与之冲突的事务集合是有限的)。

复杂度

  • 读阶段:$O(\vert ops\vert )$ 次数据库访问 + 私有工作区写入;无锁、无等待。
  • 验证:对每个并发已提交事务做一次集合相交,$O(c \cdot (\vert RS\vert + \vert WS\vert ))$,其中 $c$ 是重叠事务数(用哈希集合可实现 $O(\vert RS\vert +\vert WS\vert )$ 每次比较)。
  • 空间:每个活跃事务 $O(\vert RS\vert + \vert WS\vert + \vert ops\vert )$(私有工作区),$n$ 个并发事务即 $O(n \cdot \vert ops\vert )$。
  • 通信:单机为 0;分布式 OCC 的验证需要跨站点交换读写集,开销显著(见 19.5)。

算法 19.3.6:时间戳排序 + Thomas 写规则

假设与系统模型

  • 时间戳在事务开始时分配,全局唯一;回滚后重启可沿用原时间戳(会饿死)或重新分配更大的时间戳(本实现默认后者,用于演示”饥饿”与”收敛”的差别)。
  • 每个数据项维护 read_TS(已执行读的最大时间戳)与 write_TS(已提交写的最大时间戳);
  • 写采用缓冲到提交(buffer 到 commit 才安装),因此读者绝不可能读到未提交数据 ⇒ 无脏读、无级联回滚(这就是”严格 TO”);
  • “等待更早的写者”是本协议的等待来源:等待只发生在年轻等年老的方向上。

伪代码

state:
    item.value, item.read_TS, item.write_TS
    item.pending_write_ts : 该数据项上【未提交】写者的时间戳集合

upon T.read(item):
    if TS(T) < item.write_TS:                       # 已有更年轻的事务提交过写
        abort(T, reason="会读到过时值"); return ABORTED
    if 存在 pending 写者 Tj 且 TS(Tj) < TS(T):        # 严格 TO:等更早的写者
        block(T); return BLOCKED
    item.read_TS = max(item.read_TS, TS(T))          # 抬高读时间戳,阻止更老的事务以后写
    T.local[var] = item.value
    return item.value

upon T.write(item, value):
    if TS(T) < item.read_TS:                         # 更年轻的事务已经读过 → 我的写会作废它的读
        abort(T, reason="不可重复读风险"); return ABORTED
    if 存在 pending 写者 Tj 且 TS(Tj) < TS(T):        # 严格 TO:等更早的写者
        block(T); return BLOCKED
    if TS(T) < item.write_TS:                        # 【过时写】
        if not THOMAS_ENABLED:
            abort(T, reason="过时写"); return ABORTED
        else:
            # ----- Thomas 写规则:忽略这次写,而不是回滚 -----
            # 理由:item.write_TS > TS(T) 说明存在更新的版本;
            #       此后任何读的 TS 必须 > write_TS > TS(T)(否则被读规则拒绝),
            #       所以【没有任何事务可能读到我这次写的版本】→ 删除它不改变任何 read-from
            log("ignore stale write"); return OK_IGNORED
    T.buffer[item] = value                           # 先缓冲,提交时才安装
    item.pending_write_ts.add(TS(T))
    return OK_BUFFERED

upon T.commit():
    for (item, val) in T.buffer:
        item.value = val
        item.write_TS = max(item.write_TS, TS(T))    # 只有到了提交,才更新 write_TS
        item.pending_write_ts.remove(TS(T))
    T.state = COMMITTED

算法逻辑解说(19.4.1 场景 A,$T_1$ 时间戳 1、$T_2$ 时间戳 2,交错 T1,T2,T1,T2

  1. $T_1$ 读 $x$:$\mathrm{TS}=1 \ge write\_TS=0$ ⇒ 允许;$read\_TS(x) \gets 1$,读到 100。
  2. $T_2$ 读 $x$:$2 \ge 0$ ⇒ 允许;$read\_TS(x) \gets 2$,也读到 100。
  3. $T_1$ 写 $x$:检查 $\mathrm{TS}(T_1)=1 < read\_TS(x)=2$ ⇒ 回滚 $T_1$(”更年轻的事务已经读过,我的写会让它读了个寂寞”)。这正是经典 TO 的饥饿现象:如果重启时沿用时间戳 1,$read\_TS(x)$ 仍然是 2,$T_1$ 会永远回滚下去。
  4. 本实现默认”重启时分配更大的时间戳”:$T_1$ 拿到 $\mathrm{TS}=3$ ⇒ 重新读 $x$($read\_TS \gets 3$,读到 100)、写 $x$($3 \ge read\_TS = 3$ ✓,$\mathrm{TS}=3 \ge write\_TS=0$ ✓)⇒ 提交后 $x=90$、$write\_TS(x)=3$。
  5. $T_2$ 重做:读 $x$ 时发现 $\mathrm{TS}(T_2)=2 < write\_TS(x)=3$ ⇒ 回滚,拿到 $\mathrm{TS}=4$;再读得 90,写 70,提交 ⇒ 最终 $x = 70$ ✓。
  6. 若把 Thomas 规则打开并构造”老事务重复写同一数据项”的负载,可以看到日志里出现 ignore stale write——那次写被安静地丢弃,而不是引发一次回滚,这正是它降低回滚率的机制。

正确性论证

  • 安全性 Safety(按时间戳的串行序):把已提交事务按时间戳 $T_1, T_2, \ldots, T_m$($\mathrm{TS}$ 递增)排列,声称这是等价的串行顺序。任取一对来自不同事务、访问同一数据项的冲突操作:
    • 读-写($R_i(x)$ 与 $W_j(x)$):若 $\mathrm{TS}(T_i) < \mathrm{TS}(T_j)$,则 $T_j$ 执行写时必须满足 $\mathrm{TS}(T_j) \ge write\_TS(x)$,而 $T_i$ 的读已把 $x$ 的版本固定在 $\mathrm{TS} \le \mathrm{TS}(T_i) < \mathrm{TS}(T_j)$ 的版本上,两者在时间戳序上不矛盾;反向若 $\mathrm{TS}(T_j) < \mathrm{TS}(T_i)$,则 $T_i$ 的读会被规则 $\mathrm{TS}(T_i) < write\_TS(x)$ 拒绝(因为 $T_j$ 已经写入并抬高了 $write\_TS$)⇒ 这种”读到更老版本却排在后”的情形不可能提交。故读的来源与时间戳序一致
    • 写-写($W_i(x)$ 与 $W_j(x)$):设 $\mathrm{TS}(T_i) < \mathrm{TS}(T_j)$。$T_j$ 执行写时若 $\mathrm{TS}(T_j) < write\_TS(x)$(已被一个中间时间戳的写占据)⇒ 按基本 TO 回滚、按 Thomas 规则忽略;无论哪种,$T_i$ 的写都不会覆盖 $T_j$ 的写(时间戳大的写一定在”值序列”的后端生效)。故写的生效顺序与时间戳序一致。
    • 每一对冲突操作的生效顺序都与时间戳序一致 ⇒ $H$ 冲突等价于按时间戳的串行历史 ⇒ 冲突可串行化。$\blacksquare$
  • Thomas 写规则的正确性(更弱但够用):开启 Thomas 规则后,”忽略一次过时写”意味着历史里少了一次写操作,因此得到的是视图可串行化而非严格意义上的冲突可串行化。论证分两步:
    1. 该版本永不被读:忽略发生在 $\mathrm{TS}(T) < write\_TS(x)$ 时。此后的任何读都要求 $\mathrm{TS}(\text{reader}) \ge write\_TS(x) > \mathrm{TS}(T)$,于是读者看到的一定是时间戳不小于 $write\_TS(x)$ 的版本(更年轻或同代),绝不会看到 $T$ 的版本;此前的读发生在这次写之前,自然也没见过它。
    2. 删除一个”永不被读、且不是最终写”的版本,不改变任何 read-from 关系与最终写 ⇒ 由视图等价的定义,历史仍视图等价于按时间戳的串行历史 ⇒ 视图可串行化。$\blacksquare$
      • 直观结论:Thomas 规则用”稍微放宽正确性标准(视图可串行化)”换来了”显著降低回滚率”;而 19.2.10 已说明视图可串行化的历史在实际系统中是安全的。
  • 无死锁(安全性的一部分):本协议的等待只发生在”$\mathrm{TS}(T)$ 较大者等待 $\mathrm{TS}$ 较小者”的方向上(block 的两个触发条件都要求存在 pending 写者 $T_j$ 且 $\mathrm{TS}(T_j) < \mathrm{TS}(T)$)。于是每条等待边沿时间戳严格递减,与算法 19.3.3 的论证同构 ⇒ 等待图无环 ⇒ 无死锁。$\blacksquare$
  • 活性 Liveness
    1. 前进性:设 $T_{\min}$ 是当前活跃事务中时间戳最小者。它不可能被 blockblock 要求存在更早的写者,而 $T_{\min}$ 是最早的),因此 $T_{\min}$ 总能推进并在有限步内提交或回滚。用归纳法:每一轮至少消除一个事务。
    2. 饥饿:若回滚后沿用原时间戳,$T_{\min}$ 提交后,某个老事务仍可能因为 read_TS 被更年轻的事务抬得过高而反复回滚、永不成功——这就是经典 TO 的饥饿/活锁(19.4.1 场景 D 中”沿用原时间戳”一列出现数百次回滚且多个事务始终未完成)。可行修法:① 回滚后分配新的(更大的)时间戳(本实现默认),把”老人优先”改成”先进先出”;② 只回滚”读时间戳”冲突中的年轻一方;③ 维护活跃事务集合,令 $\min(read\_TS(x), \{\mathrm{TS}(T): T \text{ 活跃}\})$ 而不是历史最大值——即及时回收过期的 read_TS

复杂度

  • 每次读/写:$O(1)$(几次时间戳比较;pending 集合小,或用堆取最小值 $O(\log n)$)。
  • 空间:每数据项 $O(1)$(加上 pending 集合);每事务 $O(\vert WS\vert )$ 的写缓冲。
  • 与 2PL 的对比:TO 零锁表、零等待结构,但需要全局时间戳分配器——单机是计数器,分布式下要么用中心分配器(瓶颈),要么用”时间戳区间预分配”(每站点一次领一段区间),后者是 Spanner/早期分布式数据库的常用做法。

19.3.7 六份算法的横向对照

算法冲突检测时机是否加锁死锁饥饿关键正确性依据
严格 2PL访问前(加锁时)是(S/X)可能否(FIFO)锁点沿冲突边严格递增 ⇒ 优先图无环
等待图死锁检测阻塞后周期性检测并破环否(回滚计数老化)环 ⇒ 真死锁;回滚破环 ⇒ 前进
Wait-Die / Wound-Wait加锁时(时间戳裁决)不可能否(原时间戳重启 + 有限老事务集)等待边时间戳严格单调 ⇒ 无环
CMH 边追踪等待边产生时是(配合锁)检测(可能幻报)——探测路径还原为等待边链;等待不变 ⇒ 环真实
Kung-Robinson OCC提交时(验证)读不加锁/写阶段短暂临界区不可能理论上可能三类冲突在验证条件覆盖下均按验证序排列 ⇒ 冲突等价
TO + Thomas每次读写时不可能可能(需新时间戳或回收 read_TS)所有冲突对生效顺序 = 时间戳序 ⇒ 冲突等价(Thomas:视图等价)

19.4 代码示例与分布式实现

本节给出三个可直接 python3 运行的程序(只用标准库、固定随机种子、无外部依赖)。第一个是四种并发控制协议的完整模拟器,第二个是优先图与可串行化判定工具,第三个是死锁演示与分布式边追踪检测器。三个程序互相独立,也都与 19.3 的伪代码一一对应。

19.4.1 完整并发控制模拟器(NoCC / 严格 2PL / OCC / TO)

"""cc_sim.py -- 并发控制模拟器:NoCC / Strict-2PL / OCC / 时间戳排序(TO)

只用标准库,固定随机种子,`python3 cc_sim.py` 直接运行。
"""
import itertools
import random

random.seed(425)


# ==================== 数据结构 ====================
class Item:
    """一个数据项:当前值 + read_TS + write_TS + 未提交写者列表"""

    def __init__(self, value):
        self.value, self.read_ts, self.write_ts, self.pending = value, 0, 0, []


class LockMgr:
    """共享/排他锁:holders[item]={tid:'S'|'X'},queue[item]=[(tid,mode)]"""

    def __init__(self):
        self.holders, self.queue = {}, {}

    def request(self, item, tid, mode):
        held = self.holders.setdefault(item, {})
        q = self.queue.setdefault(item, [])
        if held.get(tid) == 'X' or (held.get(tid) == 'S' and mode == 'S'):
            return True                                     # 重入 / 已持更强锁
        entry = (tid, mode)
        if entry not in q:
            q.append(entry)                                 # FIFO 排队,防止饥饿
        if q[0] != entry:
            return False                                    # 不是队首 -> 等待
        if mode == 'X' and tid in held and len(held) > 1:
            return False                                    # 锁升级失败:别人持 S
        if any(o != tid and (mode == 'X' or m == 'X') for o, m in held.items()):
            return False                                    # 与持有者冲突
        held[tid] = mode
        q.pop(0)
        return True

    def release_all(self, tid):
        for held in self.holders.values():
            held.pop(tid, None)
        for item in self.queue:
            self.queue[item] = [e for e in self.queue[item] if e[0] != tid]

    def wait_for_edges(self, alive):
        """由锁表构造等待图:T_i -> T_j 表示 T_i 正在等 T_j"""
        edges = set()
        for item, q in self.queue.items():
            held = self.holders.get(item, {})
            for i, (tid, mode) in enumerate(q):
                if tid not in alive:
                    continue
                edges |= {(tid, t2) for t2, _ in q[:i] if t2 in alive}
                edges |= {(tid, h) for h, hm in held.items()
                          if h != tid and h in alive and (mode == 'X' or hm == 'X')}
        return edges


class Txn:
    """ops: ('R', item, var) 读入局部变量;('W', item, fn) 用 fn(local) 求新值"""

    def __init__(self, tid, ts, ops):
        self.tid, self.ts, self.ops, self.name = tid, ts, ops, "T%d" % tid
        self.attempt = 0
        self.restart(0)

    def restart(self, step, new_ts=None):
        if new_ts is not None:
            self.ts = new_ts
        self.attempt += 1
        self.pc, self.state = 0, 'ready'
        self.local, self.buffer = {}, {}
        self.read_set, self.write_set, self.observed = set(), set(), {}
        self.start, self.read_end, self.commit_step = step, None, None
        self.committed_attempt, self.final_observed = None, {}


# ==================== 四种并发控制协议 ====================
class Protocol:
    retry_same_ts = True                # 回滚后是否沿用原时间戳(TO 会覆盖它)

    def __init__(self, txns, db):
        self.txns, self.db, self.clock = txns, db, max(t.ts for t in txns)
        self.history = []               # (tid, attempt, 'R'/'W', item) 按真实时间顺序
        self.log, self.deadlocks, self.victims = [], 0, []

    def record(self, t, op, item):
        self.history.append((t.tid, t.attempt, op, item))

    def finish(self, t, step_no):
        t.state, t.commit_step, t.committed_attempt = 'committed', step_no, t.attempt
        t.final_observed = dict(t.observed)
        return 'done'

    def step(self, t, step_no):
        raise NotImplementedError


class NoCC(Protocol):
    name = "无并发控制"

    def step(self, t, step_no):
        if t.pc >= len(t.ops):
            self.log.append("%s COMMIT" % t.name)
            return self.finish(t, step_no)
        op = t.ops[t.pc]
        t.pc += 1
        if op[0] == 'R':
            t.local[op[2]] = t.observed[op[2]] = self.db[op[1]].value
            t.read_set.add(op[1])
            self.record(t, 'R', op[1])
            self.log.append("%s R(%s)=%d" % (t.name, op[1], t.local[op[2]]))
        else:
            self.db[op[1]].value = op[2](t.local)
            t.write_set.add(op[1])
            self.record(t, 'W', op[1])
            self.log.append("%s W(%s)=%d" % (t.name, op[1], self.db[op[1]].value))
        return 'progress'


class Strict2PL(Protocol):
    """严格两阶段锁:排他锁持到提交;加锁后立即写数据库"""
    name = "严格两阶段锁"

    def __init__(self, txns, db):
        Protocol.__init__(self, txns, db)
        self.lm = LockMgr()

    def step(self, t, step_no):
        if t.pc >= len(t.ops):
            self.lm.release_all(t.tid)                      # 提交时才释放全部锁
            self.log.append("%s COMMIT(此刻才释放全部锁)" % t.name)
            return self.finish(t, step_no)
        op = t.ops[t.pc]
        mode = 'S' if op[0] == 'R' else 'X'
        if not self.lm.request(op[1], t.tid, mode):
            self.log.append("%s 等待 %s 上的 %s 锁" % (t.name, op[1], mode))
            return 'blocked'
        t.pc += 1
        if op[0] == 'R':
            t.local[op[2]] = t.observed[op[2]] = self.db[op[1]].value
            t.read_set.add(op[1])
            self.record(t, 'R', op[1])
            self.log.append("%s 取得 %s 锁并 R(%s)=%d" % (t.name, mode, op[1], t.local[op[2]]))
        else:
            self.db[op[1]].value = op[2](t.local)
            t.write_set.add(op[1])
            self.record(t, 'W', op[1])
            self.log.append("%s 取得 X 锁并 W(%s)=%d" % (t.name, op[1], self.db[op[1]].value))
        return 'progress'


class OCC(Protocol):
    """Kung-Robinson 三阶段 OCC:读阶段(私有工作区)-> 验证 -> 写阶段"""
    name = "乐观并发控制"

    def __init__(self, txns, db):
        Protocol.__init__(self, txns, db)
        self.committed = []

    def conflict(self, t, u):
        """u 更早通过验证(TS(u) < TS(t));返回 True 表示 t 必须回滚"""
        if u.commit_step < t.start:
            return False                                       # 条件 1:u 完全早于 t
        if u.read_end < t.read_end and u.commit_step > t.read_end:
            return bool(t.write_set & u.write_set)             # 条件 2:只查写-写
        return bool((t.read_set | t.write_set) & u.write_set)  # 条件 3:读-写与写-写强检查

    def step(self, t, step_no):
        if t.pc >= len(t.ops):
            t.read_end = step_no                               # 读阶段结束 -> 验证
            self.clock += 1
            t.ts = self.clock                                  # 时间戳 = 进入验证阶段的顺序
            bad = [u.name for u in self.committed if self.conflict(t, u)]
            if bad:
                t.state = 'aborted'
                self.log.append("%s 验证失败(与 %s 冲突:读集 %s 写集 %s)-> 回滚"
                                % (t.name, ",".join(bad), sorted(t.read_set), sorted(t.write_set)))
                return 'abort'
            for item, val in t.buffer.items():                 # 写阶段:原子安装
                self.db[item].value = val
                self.record(t, 'W', item)
            self.committed.append(t)
            self.log.append("%s 验证通过 -> 写阶段安装 %s" % (t.name, t.buffer))
            return self.finish(t, step_no)
        op = t.ops[t.pc]
        t.pc += 1
        if op[0] == 'R':
            t.local[op[2]] = t.observed[op[2]] = self.db[op[1]].value
            t.read_set.add(op[1])
            self.log.append("%s(读阶段) R(%s)=%d" % (t.name, op[1], t.local[op[2]]))
        else:
            t.buffer[op[1]] = op[2](t.local)                   # 只写私有工作区
            t.write_set.add(op[1])
            self.log.append("%s(读阶段) 私有工作区 W(%s)=%d" % (t.name, op[1], t.buffer[op[1]]))
        return 'progress'


class TimestampOrdering(Protocol):
    """严格时间戳排序(写缓冲到提交)+ Thomas 写规则"""
    name = "时间戳排序+Thomas"

    def __init__(self, txns, db, thomas=True, retry_same_ts=False):
        Protocol.__init__(self, txns, db)
        self.thomas, self.retry_same_ts, self.ignored = thomas, retry_same_ts, 0

    def step(self, t, step_no):
        if t.pc >= len(t.ops):                                 # 提交:安装写缓冲
            for item, val in t.buffer.items():
                it = self.db[item]
                it.value, it.write_ts = val, max(it.write_ts, t.ts)
                it.pending = [p for p in it.pending if p[1] != t.tid]
                self.record(t, 'W', item)
            self.log.append("%s COMMIT(安装写缓冲 %s,更新 write_TS)" % (t.name, t.buffer))
            return self.finish(t, step_no)
        op = t.ops[t.pc]
        it = self.db[op[1]]
        if op[0] == 'R':
            if t.ts < it.write_ts:                             # 已有更年轻的事务写过
                t.state = 'aborted'
                self.log.append("%s 读 %s 被拒(TS=%d < write_TS=%d)-> 回滚"
                                % (t.name, op[1], t.ts, it.write_ts))
                return 'abort'
            if any(p[0] < t.ts for p in it.pending):
                return 'blocked'                               # 等更早的写者结束
            it.read_ts = max(it.read_ts, t.ts)
            t.local[op[2]] = t.observed[op[2]] = it.value
            t.read_set.add(op[1])
            self.record(t, 'R', op[1])
            self.log.append("%s R(%s)=%d, read_TS:=%d" % (t.name, op[1], it.value, it.read_ts))
        else:
            if t.ts < it.read_ts:                              # 更年轻的事务读过 -> 写会作废它的读
                t.state = 'aborted'
                self.log.append("%s 写 %s 被拒(TS=%d < read_TS=%d)-> 回滚"
                                % (t.name, op[1], t.ts, it.read_ts))
                return 'abort'
            if any(p[0] < t.ts for p in it.pending):
                return 'blocked'
            if t.ts < it.write_ts:                             # 过时写
                if not self.thomas:
                    t.state = 'aborted'
                    self.log.append("%s 写 %s 被拒(过时写)-> 回滚" % (t.name, op[1]))
                    return 'abort'
                self.ignored += 1
                self.log.append("%s 的 W(%s) 是过时写(TS=%d < write_TS=%d)-> Thomas 规则忽略"
                                % (t.name, op[1], t.ts, it.write_ts))
                t.pc += 1
                return 'progress'
            t.buffer[op[1]] = op[2](t.local)
            t.write_set.add(op[1])
            it.pending.append((t.ts, t.tid))
            self.log.append("%s 缓冲 W(%s)=%d" % (t.name, op[1], t.buffer[op[1]]))
        t.pc += 1
        return 'progress'


# ==================== 驱动器 ====================
def run(proto_cls, programs, values, schedule=None, max_steps=300, rnd=False, **kw):
    db = {k: Item(v) for k, v in values.items()}
    txns = [Txn(i + 1, i + 1, programs[i]) for i in range(len(programs))]
    proto = proto_cls(txns, db, **kw) if kw else proto_cls(txns, db)
    step_no, ptr, sched, restarts = 0, 0, list(schedule or []), 0

    def abort(t):
        nonlocal restarts
        restarts += 1
        if isinstance(proto, Strict2PL):
            proto.lm.release_all(t.tid)                        # 牺牲者必须放锁
        if isinstance(proto, TimestampOrdering):
            for it in db.values():
                it.pending = [p for p in it.pending if p[1] != t.tid]
        nt = None if proto.retry_same_ts else proto.clock + 1
        if nt:
            proto.clock = nt
        t.restart(step_no, nt)

    while any(t.state == 'ready' for t in txns) and step_no < max_steps:
        step_no += 1
        moved = False
        while sched and not moved:                             # 1) 显式交错脚本
            t = txns[sched.pop(0)]
            if t.state != 'ready':
                continue
            r = proto.step(t, step_no)
            if r == 'blocked':
                break
            moved = True
            if r == 'abort':
                abort(t)
        if moved:
            continue
        for _ in range(len(txns)):                             # 2) 轮转 / 随机调度
            if rnd:
                ready = [x for x in txns if x.state == 'ready']
                if not ready:
                    break
                t = random.choice(ready)
            else:
                t = txns[ptr % len(txns)]
                ptr += 1
                if t.state != 'ready':
                    continue
            r = proto.step(t, step_no)
            if r == 'blocked':
                continue
            moved = True
            if r == 'abort':
                abort(t)
            break
        if not moved:                                          # 3) 一圈无人推进 -> 死锁
            alive = {t.tid for t in txns if t.state == 'ready'}
            victim = detect_deadlock(proto, alive)
            if victim is None:
                proto.log.append("全部阻塞但等待图无环(在等外部事件)")
                break
            proto.deadlocks += 1
            proto.victims.append(victim)
            v = [t for t in txns if t.tid == victim][0]
            proto.log.append(">>> 检测到死锁!等待图有环,牺牲者 = %s,回滚重启" % v.name)
            abort(v)

    done = {t.tid: t.committed_attempt for t in txns if t.committed_attempt}
    hist = [h for h in proto.history if h[1] == done.get(h[0])]
    reads = {t.name: t.final_observed for t in txns if t.committed_attempt}
    starved = sum(1 for t in txns if t.state == 'ready')       # 未收敛(活锁 / 饥饿)
    return proto, {k: v.value for k, v in db.items()}, reads, hist, restarts, starved


def detect_deadlock(proto, alive):
    """等待图 DFS 找环;返回环上时间戳最大的事务(最年轻者)"""
    if not isinstance(proto, Strict2PL):
        return None
    adj = {}
    for a, b in proto.lm.wait_for_edges(alive):
        adj.setdefault(a, set()).add(b)
    color, stack, cycle = {}, [], None

    def dfs(u):
        nonlocal cycle
        color[u] = 1
        stack.append(u)
        for v in adj.get(u, ()):
            if color.get(v, 0) == 1:
                cycle = stack[stack.index(v):]
                return True
            if color.get(v, 0) == 0 and dfs(v):
                return True
        stack.pop()
        color[u] = 2
        return False

    for u in sorted(alive):
        if color.get(u, 0) == 0 and dfs(u):
            return max(cycle)
    return None


# ==================== 校验器:优先图 + 串行等价 ====================
def precedence_cycle(hist):
    """由历史构造优先图,返回 (是否有环, 边集合)"""
    edges = set()
    for i in range(len(hist)):
        for j in range(i + 1, len(hist)):
            ti, _, oi, xi = hist[i]
            tj, _, oj, xj = hist[j]
            if ti != tj and xi == xj and (oi == 'W' or oj == 'W'):
                edges.add((ti, tj))
    adj = {}
    for a, b in edges:
        adj.setdefault(a, set()).add(b)
    color = {}

    def dfs(u):
        color[u] = 1
        for v in adj.get(u, ()):
            if color.get(v, 0) == 1:
                return True
            if color.get(v, 0) == 0 and dfs(v):
                return True
        color[u] = 2
        return False

    return any(color.get(u, 0) == 0 and dfs(u) for u in sorted(adj)), edges


def serial_runs(programs, values):
    """穷举所有串行顺序:{串行序: (最终状态, 各事务读到的值)}"""
    out = {}
    for perm in itertools.permutations(range(len(programs))):
        db, reads = dict(values), {}
        for k in perm:
            local = {}
            for op in programs[k]:
                if op[0] == 'R':
                    local[op[2]] = db[op[1]]
                else:
                    db[op[1]] = op[2](local)
            reads["T%d" % (k + 1)] = dict(local)
        out["->".join("T%d" % (k + 1) for k in perm)] = (db, reads)
    return out


# ==================== 场景 ====================
def transfer_lost_update():
    """场景 A:丢失更新(两个事务各自扣款,语义上等价于共扣 30)"""
    return [[('R', 'x', 'a'), ('W', 'x', lambda L: L['a'] - 10)],
            [('R', 'x', 'b'), ('W', 'x', lambda L: L['b'] - 20)]]


def transfer_inconsistent_read():
    """场景 B:T1 从 x 转 100 到 y;T2 读 x、y 求总额写入 z(必须恒为 300)"""
    return [[('R', 'x', 'a'), ('W', 'x', lambda L: L['a'] - 100),
             ('R', 'y', 'b'), ('W', 'y', lambda L: L['b'] + 100)],
            [('R', 'x', 's1'), ('R', 'y', 's2'), ('W', 'z', lambda L: L['s1'] + L['s2'])]]


def deadlock_pair():
    """场景 C:T1 先锁 A 再锁 B;T2 先锁 B 再锁 A"""
    return [[('W', 'A', lambda L: 1), ('W', 'B', lambda L: 2)],
            [('W', 'B', lambda L: 3), ('W', 'A', lambda L: 4)]]


def conflict_workload(n, hot):
    """场景 D:n 个事务各自读改写 hot 个热点数据项之一"""
    progs = []
    for _ in range(n):
        item = "k%d" % random.randint(0, hot - 1)
        progs.append([('R', item, 'v'), ('W', item, lambda L: L['v'] + 1)])
    return progs


def scenario(title, programs, values, schedule=None):
    print("=" * 78)
    print("场景 %s   初始状态 %s" % (title, values))
    runs = serial_runs(programs, values)
    print("所有串行执行的结果(最终状态 / 各事务读到的值):")
    for perm, (st, rd) in runs.items():
        print("   %-12s -> %-28s %s" % (perm, st, rd))
    if schedule:
        print("本次使用的交错脚本(按操作给出事务序号):%s" % (schedule,))
    print("-" * 78)
    rows = []
    for cls in (NoCC, Strict2PL, OCC, TimestampOrdering):
        proto, final, reads, hist, restarts, _ = run(cls, programs, values, schedule=schedule)
        cyclic, edges = precedence_cycle(hist)
        equ = [p for p, v in runs.items() if v == (final, reads)]
        ok = bool(equ) and not cyclic
        print("[%-14s] 最终状态=%s  读到的值=%s" % (cls.name, final, reads))
        print("     优先图边=%s 有环=%s  串行等价于=%s  判定:%s"
              % (sorted(edges), "是" if cyclic else "否", equ or "无",
                 "可串行化" if ok else "不可串行化"))
        print("     回滚重启=%d 次  死锁=%d 次  牺牲者=%s  Thomas 忽略的过时写=%d"
              % (restarts, proto.deadlocks, proto.victims, getattr(proto, 'ignored', 0)))
        if cls in (NoCC, Strict2PL):
            for line in proto.log[:14]:
                print("       | " + line)
        rows.append((cls.name, final, ok, restarts, proto.deadlocks))
    print("-" * 78)
    print("  %-16s %-24s %-10s %-10s %s" % ("协议", "最终状态", "串行等价", "回滚次数", "死锁次数"))
    for r in rows:
        print("  %-16s %-24s %-10s %-10d %d" % (r[0], str(r[1]), "是" if r[2] else "否", r[3], r[4]))
    print()
    return rows


if __name__ == "__main__":
    scenario("A. 丢失更新(x=100,T1 扣 10,T2 扣 20)", transfer_lost_update(), {'x': 100},
             schedule=[0, 1, 0, 1])
    scenario("B. 不一致检索(x=200,y=100,T1 转 100,T2 求总额写入 z)",
             transfer_inconsistent_read(), {'x': 200, 'y': 100, 'z': 0},
             schedule=[0, 0, 1, 1, 0, 0, 1])
    scenario("C. 两阶段锁的死锁(T1 先锁 A 再锁 B;T2 先锁 B 再锁 A)", deadlock_pair(),
             {'A': 0, 'B': 0})

    print("=" * 78)
    print("场景 D:冲突率扫描(8 个事务;hot = 热点数据项个数,越小冲突越激烈)")
    print("  %-5s %-20s %-16s %-18s %-18s" %
          ("hot", "严格2PL(回滚/死锁)", "OCC 回滚", "TO 回滚(新时间戳)", "TO 回滚(原时间戳)"))
    for hot in (8, 4, 2, 1):
        progs = conflict_workload(8, hot)
        vals = {"k%d" % i: 0 for i in range(8)}
        p2, _, _, _, r2, _ = run(Strict2PL, progs, vals, rnd=True)
        _, _, _, _, ro, _ = run(OCC, progs, vals, rnd=True)
        _, _, _, _, rt, st = run(TimestampOrdering, progs, vals, rnd=True)
        _, _, _, _, rf, sf = run(TimestampOrdering, progs, vals, rnd=True, retry_same_ts=True)
        print("  %-5d %-20s %-16s %-18s %-18s" %
              (hot, "回滚%d 死锁%d" % (r2, p2.deadlocks), "回滚%d" % ro,
               "回滚%d 未完成%d" % (rt, st), "回滚%d 未完成%d" % (rf, sf)))
    print("\n注:hot 越小冲突越激烈 -> OCC 回滚飙升;2PL 靠阻塞等待但会死锁;")
    print("    TO 沿用原时间戳重试会活锁(老事务被反复回滚),重新分配时间戳才收敛。")

【代码做什么?】

  1. 建模”数据库 + 事务”Item 是一个数据项(value + read_ts + write_ts + pending 未提交写者列表);Txn 是一个事务,它的程序是一串操作 ('R', item, var)(读进局部变量)与 ('W', item, fn)(用 fn(local) 算出新值)——用 lambda 表达”新值依赖我读到的值”,丢失更新/不一致检索这类异常就自然产生了。
  2. 四种协议各实现一个 step(t, step_no),每次推进一步并返回 progress / blocked / abort / done
    • NoCC:直接读写数据库,作为对照组(展示异常);
    • Strict2PLLockMgr 实现 S/X 锁的相容性判定 + FIFO 等待队列,事务按”访问前加锁、提交时一次性释放全部锁”执行(严格 2PL);
    • OCC:读阶段把修改写进 t.buffer私有工作区),记录 read_set/write_set;操作耗尽时进入验证 + 写阶段(在同一 step 里原子完成);
    • TimestampOrdering:每次读写先做时间戳规则判定,写进写缓冲、提交时才安装并更新 write_tsretry_same_ts 开关用来对比”沿用原时间戳(会活锁)”与”重新分配时间戳”。
  3. 驱动器 run() 支持三种调度:显式交错脚本schedule=[0,1,0,1],用来精确复现讲义里的异常交错)、轮转随机(用于冲突率扫描)。当”绕一圈没有任何事务能推进”时,触发 detect_deadlock():由锁表构造等待图、DFS 找环、选出最年轻的牺牲者、回滚并重启。
  4. 两个独立校验器(不依赖协议自身的说法):
    • serial_runs()穷举所有串行顺序,记录每种顺序的”最终状态 + 每个事务读到的值”;
    • precedence_cycle():从真实发生的操作历史构造优先图并检测环。 只有”优先图无环”“结果与某个串行执行完全一致(含读到的值)”才判定为可串行化。
  5. 四个场景 + 一张统计对比表:A 丢失更新、B 不一致检索、C 两阶段锁死锁、D 冲突率扫描(对照 2PL / OCC / TO)。

实际运行结果(节选)

场景 A. 丢失更新(x=100,T1 扣 10,T2 扣 20) 初始状态 {‘x’: 100} 所有串行执行的结果(最终状态 / 各事务读到的值): T1->T2 -> {‘x’: 70} {‘T1’: {‘a’: 100}, ‘T2’: {‘b’: 90}} T2->T1 -> {‘x’: 70} {‘T2’: {‘b’: 100}, ‘T1’: {‘a’: 80}} 本次使用的交错脚本(按操作给出事务序号):[0, 1, 0, 1] —————————————————————————— [无并发控制 ] 最终状态={‘x’: 80} 读到的值={‘T1’: {‘a’: 100}, ‘T2’: {‘b’: 100}} 优先图边=[(1, 2), (2, 1)] 有环=是 串行等价于=无 判定:不可串行化 回滚重启=0 次 死锁=0 次 牺牲者=[] Thomas 忽略的过时写=0 | T1 R(x)=100 | T2 R(x)=100 | T1 W(x)=90 | T2 W(x)=80 | T1 COMMIT | T2 COMMIT [严格两阶段锁 ] 最终状态={‘x’: 70} 读到的值={‘T1’: {‘a’: 100}, ‘T2’: {‘b’: 90}} 优先图边=[(1, 2)] 有环=否 串行等价于=[‘T1->T2’] 判定:可串行化 回滚重启=1 次 死锁=1 次 牺牲者=[2] Thomas 忽略的过时写=0 | T1 取得 S 锁并 R(x)=100 | T2 取得 S 锁并 R(x)=100 | T1 等待 x 上的 X 锁 | T1 等待 x 上的 X 锁 | T2 等待 x 上的 X 锁 | »> 检测到死锁!等待图有环,牺牲者 = T2,回滚重启 | T2 等待 x 上的 S 锁 | T1 取得 X 锁并 W(x)=90 | T2 等待 x 上的 S 锁 | T1 COMMIT(此刻才释放全部锁) | T2 取得 S 锁并 R(x)=90 | T2 取得 X 锁并 W(x)=70 | T2 COMMIT(此刻才释放全部锁) [乐观并发控制 ] 最终状态={‘x’: 70} 读到的值={‘T1’: {‘a’: 100}, ‘T2’: {‘b’: 90}} 优先图边=[(1, 2)] 有环=否 串行等价于=[‘T1->T2’] 判定:可串行化 回滚重启=1 次 死锁=0 次 牺牲者=[] Thomas 忽略的过时写=0 [时间戳排序+Thomas ] 最终状态={‘x’: 70} 读到的值={‘T1’: {‘a’: 80}, ‘T2’: {‘b’: 100}} 优先图边=[(2, 1)] 有环=否 串行等价于=[‘T2->T1’] 判定:可串行化 回滚重启=1 次 死锁=0 次 牺牲者=[] Thomas 忽略的过时写=0 —————————————————————————— 协议 最终状态 串行等价 回滚次数 死锁次数 无并发控制 {‘x’: 80} 否 0 0 严格两阶段锁 {‘x’: 70} 是 1 1 乐观并发控制 {‘x’: 70} 是 1 0 时间戳排序+Thomas {‘x’: 70} 是 1 0

==============================================================================

场景 C. 两阶段锁的死锁(T1 先锁 A 再锁 B;T2 先锁 B 再锁 A) 初始状态 {‘A’: 0, ‘B’: 0} 所有串行执行的结果(最终状态 / 各事务读到的值): T1->T2 -> {‘A’: 4, ‘B’: 3} {‘T1’: {}, ‘T2’: {}} T2->T1 -> {‘A’: 1, ‘B’: 2} {‘T2’: {}, ‘T1’: {}} —————————————————————————— [无并发控制 ] 最终状态={‘A’: 4, ‘B’: 2} 读到的值={‘T1’: {}, ‘T2’: {}} 优先图边=[(1, 2), (2, 1)] 有环=是 串行等价于=无 判定:不可串行化 回滚重启=0 次 死锁=0 次 牺牲者=[] Thomas 忽略的过时写=0 | T1 W(A)=1 | T2 W(B)=3 | T1 W(B)=2 | T2 W(A)=4 | T1 COMMIT | T2 COMMIT [严格两阶段锁 ] 最终状态={‘A’: 4, ‘B’: 3} 读到的值={‘T1’: {}, ‘T2’: {}} 优先图边=[(1, 2)] 有环=否 串行等价于=[‘T1->T2’] 判定:可串行化 回滚重启=1 次 死锁=1 次 牺牲者=[2] Thomas 忽略的过时写=0 | T1 取得 X 锁并 W(A)=1 | T2 取得 X 锁并 W(B)=3 | T1 等待 B 上的 X 锁 | T2 等待 A 上的 X 锁 | »> 检测到死锁!等待图有环,牺牲者 = T2,回滚重启 | T1 取得 X 锁并 W(B)=2 | T2 等待 B 上的 X 锁 | T1 COMMIT(此刻才释放全部锁) | T2 取得 X 锁并 W(B)=3 | T2 取得 X 锁并 W(A)=4 | T2 COMMIT(此刻才释放全部锁) [乐观并发控制 ] 最终状态={‘A’: 4, ‘B’: 3} 读到的值={‘T1’: {}, ‘T2’: {}} 优先图边=[(1, 2)] 有环=否 串行等价于=[‘T1->T2’] 判定:可串行化 回滚重启=1 次 死锁=0 次 牺牲者=[] Thomas 忽略的过时写=0 [时间戳排序+Thomas ] 最终状态={‘A’: 4, ‘B’: 3} 读到的值={‘T1’: {}, ‘T2’: {}} 优先图边=[(1, 2)] 有环=否 串行等价于=[‘T1->T2’] 判定:可串行化 回滚重启=0 次 死锁=0 次 牺牲者=[] Thomas 忽略的过时写=0 —————————————————————————— 协议 最终状态 串行等价 回滚次数 死锁次数 无并发控制 {‘A’: 4, ‘B’: 2} 否 0 0 严格两阶段锁 {‘A’: 4, ‘B’: 3} 是 1 1 乐观并发控制 {‘A’: 4, ‘B’: 3} 是 1 0 时间戳排序+Thomas {‘A’: 4, ‘B’: 3} 是 0 0

==============================================================================

场景 D:冲突率扫描(8 个事务;hot = 热点数据项个数,越小冲突越激烈) hot 严格2PL(回滚/死锁) OCC 回滚 TO 回滚(新时间戳) TO 回滚(原时间戳)
8 回滚2 死锁2 回滚2 回滚2 未完成0 回滚280 未完成2
4 回滚4 死锁4 回滚6 回滚8 未完成0 回滚280 未完成4
2 回滚9 死锁9 回滚10 回滚16 未完成0 回滚285 未完成5
1 回滚6 死锁6 回滚20 回滚22 未完成0 回滚285 未完成6

注:hot 越小冲突越激烈 -> OCC 回滚飙升;2PL 靠阻塞等待但会死锁; TO 沿用原时间戳重试会活锁(老事务被反复回滚),重新分配时间戳才收敛。

【分布式机制透视】

  • 并发/时序如何体现run() 的调度循环就是”操作系统调度器 + 网络延迟”的抽象——每调用一次 step 就推进一个操作,操作的相对顺序完全由调度决定。显式交错脚本等价于”精确控制网络消息到达顺序”,随机调度等价于”真实的竞态”。
  • 哪些是真实分布式系统的对应物
    • LockMgr + FIFO 队列 ⇔ 数据库的锁管理器(真实系统里它是内存中的哈希表 + 每对象的等待队列);
    • detect_deadlock() 里的等待图 ⇔ 单机数据库的死锁检测后台线程(真实系统每秒跑几次);跨站点的推广就是 19.4.3 的 CMH 算法;
    • OCCbuffer(私有工作区)⇔ 事务的局部变量 + 撤销日志;”验证 + 写阶段原子”⇔ 真实的验证临界区/短锁
    • TimestampOrderingwrite_ts / read_ts ⇔ 真实 TO 数据库在每个数据项头部维护的两个字段;”写缓冲到提交”⇔ 严格 TO 的延迟写
    • serial_runs() 的穷举 ⇔ 真实系统的串行化验证(工程上用优先图/SI 的危险结构检测代替穷举,但语义完全相同)。
  • 简化之处(必须清楚):① 单机、无网络分区、无消息丢失(锁请求是本地调用);② 没有真正实现崩溃恢复(undo 由”回滚后重启事务”隐式体现);③ 事务程序是静态的(真实系统里访问集在执行中动态确定);④ 时间戳是自增整数(分布式下需要一个分配器或区间预分配)。

【与理论的对应】

代码位置对应本章理论验证了哪条结论
LockMgr.request() 的相容性判定19.2.11 锁相容矩阵S-S 相容、含 X 必冲突
LockMgr 持锁到 COMMITrelease_all19.2.13 严格 2PL无脏读、无级联回滚
场景 A 中 2PL 的 T1 等待 x 上的 X 锁>>> 检测到死锁19.2.14 / 算法 19.3.22PL 不能避免死锁;等待图有环
场景 A 中 precedence_cycle 对 NoCC 报”有环=是”19.2.9 串行化定理丢失更新的优先图确有环
OCC.conflict() 的三个分支19.2.19 条件 1/2/3 + 算法 19.3.5验证条件保证冲突按验证序排列
OCC 的时间戳在验证时分配19.2.19 补充说明否则条件 2 会漏掉”读到过时值”
TimestampOrderingt.ts < it.read_ts 拒绝写19.2.20 TO 规则老事务的写会作废年轻人的读 ⇒ 回滚
TimestampOrderingblocked 只在 pending 更早时出现算法 19.3.6等待边从年轻指向年老 ⇒ 无死锁
场景 D “TO 回滚(原时间戳)” 一列出现数百次回滚且事务未完成19.2.20 缺点①固定时间戳的 TO 会饥饿/活锁

19.4.2 优先图与冲突可串行化判定工具

"""serial_graph.py -- 优先图 / 冲突可串行化判定工具

历史用紧凑记号书写:'R1(x)' = T1 读 x,'W2(y)' = T2 写 y,按真实发生顺序排列。
直接用 `python3 serial_graph.py` 运行,会对若干精心构造的历史给出判定。
"""
import itertools


# ------------------------- 1. 解析与优先图构造 -------------------------
def parse(hist):
    """'R1(x) W2(x)' -> [(1,'R','x'), (2,'W','x')]"""
    out = []
    for tok in hist.split():
        out.append((int(tok[1]), tok[0], tok[3]))
    return out


def build_graph(ops):
    """两个来自不同事务的操作访问同一数据项且至少一个是写 => 冲突,画边"""
    edges, conflicts = set(), []
    for i in range(len(ops)):
        for j in range(i + 1, len(ops)):
            ti, oi, xi = ops[i]
            tj, oj, xj = ops[j]
            if ti != tj and xi == xj and (oi == 'W' or oj == 'W'):
                edges.add((ti, tj))
                conflicts.append((ops[i], ops[j]))
    adj = {}
    for a, b in edges:
        adj.setdefault(a, set()).add(b)
    return adj, sorted(edges), conflicts


def find_cycle(adj):
    """DFS 找环,返回环上的事务序列(无环返回 None)"""
    color, stack = {}, []

    def dfs(u):
        color[u] = 1
        stack.append(u)
        for v in sorted(adj.get(u, ())):
            if color.get(v, 0) == 1:
                return stack[stack.index(v):] + [v]
            if color.get(v, 0) == 0:
                r = dfs(v)
                if r:
                    return r
        stack.pop()
        color[u] = 2
        return None

    for u in sorted(adj):
        if color.get(u, 0) == 0:
            r = dfs(u)
            if r:
                return r
    return None


def topo_order(adj, nodes):
    """Kahn 拓扑排序,返回一个等价串行序(有环返回 None)"""
    indeg = {n: 0 for n in nodes}
    for a in adj:
        for b in adj[a]:
            indeg[b] = indeg.get(b, 0) + 1
    ready = sorted(n for n in nodes if indeg.get(n, 0) == 0)
    order = []
    while ready:
        u = ready.pop(0)
        order.append(u)
        for v in sorted(adj.get(u, ())):
            indeg[v] -= 1
            if indeg[v] == 0:
                ready.append(v)
                ready.sort()
    return order if len(order) == len(nodes) else None


def equivalent(ops, order):
    """检查串行序 order 是否与历史 ops 冲突等价(所有冲突对的相对顺序一致)"""
    pos = {t: i for i, t in enumerate(order)}
    for i in range(len(ops)):
        for j in range(i + 1, len(ops)):
            ti, oi, xi = ops[i]
            tj, oj, xj = ops[j]
            if ti != tj and xi == xj and (oi == 'W' or oj == 'W'):
                if pos[ti] > pos[tj]:          # 历史里 Ti 在前,串行序里却在后 -> 不等价
                    return False
    return True


def analyze(name, hist):
    ops = parse(hist)
    nodes = sorted({t for t, _, _ in ops})
    adj, edges, conflicts = build_graph(ops)
    cyc = find_cycle(adj)
    order = topo_order(adj, nodes)
    print("历史 %s: %s" % (name, hist))
    print("  冲突操作对:%s" % "  ".join("%s~%s" % (a, b) for a, b in conflicts))
    print("  优先图(邻接表):%s" % ("  ".join("%d->%s" % (a, sorted(adj[a])) for a in sorted(adj))
                                     or "(无边)"))
    if cyc:
        print("  >>> 检测到环 %s(%s)=> 冲突不可串行化"
              % ("->".join(map(str, cyc)), "  ".join("%d->%d" % (cyc[i], cyc[i + 1])
                                                     for i in range(len(cyc) - 1))))
        print("  正确做法:至少回滚环上的一个事务(如 T%d)" % cyc[0])
    else:
        ok = equivalent(ops, order)
        print("  >>> 无环 => 冲突可串行化;等价串行序 = %s(冲突等价校验:%s)"
              % ("->".join("T%d" % t for t in order), "通过" if ok else "失败"))
    print()
    return not cyc


# ------------------------- 2. 测试历史 -------------------------
H_SERIAL = "R1(x) W1(x) R2(x) W2(x)"                     # 串行序 T1;T2
H_LOST_UPDATE = "R1(x) R2(x) W1(x) W2(x)"                # 丢失更新的交错
H_INCONSISTENT = "W1(x) R2(x) R2(y) W1(y)"               # 不一致检索
H_2PL_OK = "R1(x) W1(x) R1(y) W1(y) R2(y) W2(y)"         # 严格 2PL 能产生的历史
H_2PL_BAD = "W1(x) R2(x) W2(y) R1(y)"                    # 2PL 不可能产生(放锁后又加锁)
H_VIEW_NOT_CONFLICT = "R1(x) W2(x) W1(x) W3(x)"          # 视图可串行化但冲突不可串行化
H_CYCLE3 = "R1(a) W2(a) R2(b) W3(b) R3(c) W1(c)"         # 三事务环


def exhaustive_2pl_check(n_trials=4000):
    """随机生成 2PL 产生的历史,验证『2PL 产生的历史必定冲突可串行化』"""
    import random
    random.seed(425)
    bad = 0
    for _ in range(n_trials):
        nt = random.randint(2, 4)
        items = ['x', 'y', 'z']
        locks, held, released, seq = {}, {t: set() for t in range(1, nt + 1)}, set(), []
        for _ in range(random.randint(4, 14)):
            t = random.randint(1, nt)
            it = random.choice(items)
            owner = locks.get(it)
            if owner not in (None, t):
                continue                              # 已被别人锁住 -> 阻塞
            if owner is None:
                if t in released:
                    continue                          # 收缩阶段不得再加锁(2PL 的硬约束)
                locks[it] = t
                held[t].add(it)
            seq.append((t, random.choice('RW'), it))
            if t not in released and held[t] and random.random() < 0.3:
                i = random.choice(sorted(held[t]))    # 释放一个锁 -> 进入收缩阶段
                locks[i] = None
                held[t].discard(i)
                released.add(t)
        adj, _, _ = build_graph(seq)
        if find_cycle(adj):
            bad += 1
            print("   反例历史:%s" % " ".join("%s%d(%s)" % (o, t, x) for t, o, x in seq))
    print("随机 2PL 历史 %d 条,其中优先图有环的 = %d 条(定理断言应为 0)" % (n_trials, bad))
    return bad == 0


if __name__ == "__main__":
    print("=" * 74)
    print("优先图与冲突可串行化判定")
    print("=" * 74)
    analyze("s1(串行)", H_SERIAL)
    analyze("s2(丢失更新)", H_LOST_UPDATE)
    analyze("s3(不一致检索)", H_INCONSISTENT)
    analyze("s4(2PL 可产生)", H_2PL_OK)
    analyze("s5(2PL 不可产生)", H_2PL_BAD)
    analyze("s6(视图可串行化 ≠ 冲突可串行化)", H_VIEW_NOT_CONFLICT)
    analyze("s7(三事务环)", H_CYCLE3)

    print("=" * 74)
    print("定理校验:2PL 产生的历史必定冲突可串行化(优先图无环)")
    print("=" * 74)
    assert exhaustive_2pl_check(), "出现反例!"
    print("结论:未发现反例,与 2PL 的可串行化定理一致。")

    print()
    print("说明:s6 中 T1 是'读后写',T2 与 T3 都是盲写(不读直接写)——")
    print("      T2 的 W(x) 随后被 T1 的 W(x) 覆盖,最终写者是 T3,")
    print("      T1 读到的仍是初始值,与串行序 T1;T2;T3 完全一致,")
    print("      所以它是【视图可串行化】的;但在冲突等价的意义上,")
    print("      历史里 W2(x) 先于 W1(x),而串行序要求 W1(x) 先于 W2(x),")
    print("      两条边 1->2 与 2->1 同时存在 —— 冲突等价不成立。")

【代码做什么?】

  1. parse() 把紧凑记号(R1(x) = $T_1$ 读 $x$)解析成操作序列。
  2. build_graph() 逐对检查操作:来自不同事务、访问同一数据项、且至少一个是写 ⇒ 画一条与执行顺序同向的边(这就是 19.2.9 的构图规则),并顺带打印出所有冲突操作对。
  3. find_cycle()三色标记 DFS 找环,并还原出环上的事务序列(打印 T1->T2->T3->T1 这种可读形式),从而给出”至少回滚哪一个”的建议。
  4. topo_order()Kahn 算法求拓扑序——它就是定理”(⇐)方向”的构造性证明:无环 ⇒ 拓扑序 ⇒ 该序就是等价的串行顺序。随后 equivalent()独立校验一次”所有冲突对的相对顺序在串行序里是否被保持”,把定理的两个方向都落到代码上。
  5. exhaustive_2pl_check()随机生成器产生 4000 条严格按 2PL 纪律(拿不到锁就跳过、进入收缩阶段后绝不再加锁)的历史,逐条检查优先图是否有环——对 19.2.12 的定理做统计意义上的证伪尝试

实际运行结果(节选)

========================================================================== 优先图与冲突可串行化判定 ========================================================================== 历史 s1(串行): R1(x) W1(x) R2(x) W2(x) 冲突操作对:(1, ‘R’, ‘x’)~(2, ‘W’, ‘x’) (1, ‘W’, ‘x’)~(2, ‘R’, ‘x’) (1, ‘W’, ‘x’)~(2, ‘W’, ‘x’) 优先图(邻接表):1->[2]

无环 => 冲突可串行化;等价串行序 = T1->T2(冲突等价校验:通过)

历史 s2(丢失更新): R1(x) R2(x) W1(x) W2(x) 冲突操作对:(1, ‘R’, ‘x’)~(2, ‘W’, ‘x’) (2, ‘R’, ‘x’)~(1, ‘W’, ‘x’) (1, ‘W’, ‘x’)~(2, ‘W’, ‘x’) 优先图(邻接表):1->[2] 2->[1]

检测到环 1->2->1(1->2 2->1)=> 冲突不可串行化 正确做法:至少回滚环上的一个事务(如 T1)

历史 s3(不一致检索): W1(x) R2(x) R2(y) W1(y) 冲突操作对:(1, ‘W’, ‘x’)~(2, ‘R’, ‘x’) (2, ‘R’, ‘y’)~(1, ‘W’, ‘y’) 优先图(邻接表):1->[2] 2->[1]

检测到环 1->2->1(1->2 2->1)=> 冲突不可串行化 正确做法:至少回滚环上的一个事务(如 T1)

历史 s4(2PL 可产生): R1(x) W1(x) R1(y) W1(y) R2(y) W2(y) 冲突操作对:(1, ‘R’, ‘y’)~(2, ‘W’, ‘y’) (1, ‘W’, ‘y’)~(2, ‘R’, ‘y’) (1, ‘W’, ‘y’)~(2, ‘W’, ‘y’) 优先图(邻接表):1->[2]

无环 => 冲突可串行化;等价串行序 = T1->T2(冲突等价校验:通过)

历史 s5(2PL 不可产生): W1(x) R2(x) W2(y) R1(y) 冲突操作对:(1, ‘W’, ‘x’)~(2, ‘R’, ‘x’) (2, ‘W’, ‘y’)~(1, ‘R’, ‘y’) 优先图(邻接表):1->[2] 2->[1]

检测到环 1->2->1(1->2 2->1)=> 冲突不可串行化 正确做法:至少回滚环上的一个事务(如 T1)

历史 s6(视图可串行化 ≠ 冲突可串行化): R1(x) W2(x) W1(x) W3(x) 冲突操作对:(1, ‘R’, ‘x’)~(2, ‘W’, ‘x’) (1, ‘R’, ‘x’)~(3, ‘W’, ‘x’) (2, ‘W’, ‘x’)~(1, ‘W’, ‘x’) (2, ‘W’, ‘x’)~(3, ‘W’, ‘x’) (1, ‘W’, ‘x’)~(3, ‘W’, ‘x’) 优先图(邻接表):1->[2, 3] 2->[1, 3]

检测到环 1->2->1(1->2 2->1)=> 冲突不可串行化 正确做法:至少回滚环上的一个事务(如 T1)

历史 s7(三事务环): R1(a) W2(a) R2(b) W3(b) R3(c) W1(c) 冲突操作对:(1, ‘R’, ‘a’)~(2, ‘W’, ‘a’) (2, ‘R’, ‘b’)~(3, ‘W’, ‘b’) (3, ‘R’, ‘c’)~(1, ‘W’, ‘c’) 优先图(邻接表):1->[2] 2->[3] 3->[1]

检测到环 1->2->3->1(1->2 2->3 3->1)=> 冲突不可串行化 正确做法:至少回滚环上的一个事务(如 T1)

========================================================================== 定理校验:2PL 产生的历史必定冲突可串行化(优先图无环) ========================================================================== 随机 2PL 历史 4000 条,其中优先图有环的 = 0 条(定理断言应为 0) 结论:未发现反例,与 2PL 的可串行化定理一致。

说明:s6 中 T1 是’读后写’,T2 与 T3 都是盲写(不读直接写)—— T2 的 W(x) 随后被 T1 的 W(x) 覆盖,最终写者是 T3, T1 读到的仍是初始值,与串行序 T1;T2;T3 完全一致, 所以它是【视图可串行化】的;但在冲突等价的意义上, 历史里 W2(x) 先于 W1(x),而串行序要求 W1(x) 先于 W2(x), 两条边 1->2 与 2->1 同时存在 —— 冲突等价不成立。

【分布式机制透视】

  • 这段程序模拟的是”历史回放式验证“:真实系统(如 SSI 的 PostgreSQL、CockroachDB 的验证器)不可能穷举串行顺序,而是增量维护依赖图并在检测到”危险结构”时回滚。本程序给出的是同一理论的可判定版本——它存在的意义是当裁判:任何并发控制协议声称自己可串行化,都可以用这个工具去检验它产生的历史。
  • exhaustive_2pl_check() 的随机生成器落实了一条分布式调度纪律:锁的授予/阻塞决定”谁能前进”,而纪律本身(收缩阶段不再加锁)是协议的一部分。这正是分布式 2PL(跨站点的锁管理器 + 全局死锁检测)的本地版本。

【与理论的对应】

  • 历史 s2(丢失更新)与 s3(不一致检索)都被判”有环 ⇒ 冲突不可串行化”,与 19.2.6 的两张交错图一一对应;
  • 历史 s5 给出一个 2PL 不可能产生的历史:它的两条冲突边方向相反,任何 2PL 调度都无法实现(因为 2PL 要求锁点单调,做不到”先放锁再加锁”);
  • 历史 s6 展示了 视图可串行化 ⊊ 冲突可串行化(盲写反例),解释了为什么 19.2.10 说”判定视图可串行化是 NP-完全的”,而工程上只做冲突可串行化;
  • 最后的定理校验输出 优先图有环的 = 0 条,是对 2PL ⇒ 冲突可串行化 的一次机器验证。

19.4.3 死锁演示与分布式死锁检测(含幻死锁)

"""deadlock.py -- 死锁检测三件套:
   1) 集中式等待图(WFG)检测与牺牲者回滚
   2) Chandy-Misra-Haas 分布式边追踪(edge chasing)算法
   3) 幻死锁(phantom deadlock)的构造

`python3 deadlock.py` 直接运行。
"""
from collections import deque


# ==================== 第 1 部分:集中式等待图 ====================
def find_cycle(adj):
    color, stack = {}, []

    def dfs(u):
        color[u] = 1
        stack.append(u)
        for v in sorted(adj.get(u, ())):
            if color.get(v, 0) == 1:
                return stack[stack.index(v):] + [v]
            if color.get(v, 0) == 0:
                r = dfs(v)
                if r:
                    return r
        stack.pop()
        color[u] = 2
        return None

    for u in sorted(adj):
        if color.get(u, 0) == 0:
            r = dfs(u)
            if r:
                return r
    return None


def centralized_demo():
    print("=" * 74)
    print("第 1 部分:集中式等待图(WFG)死锁检测")
    print("=" * 74)
    holds = {1: {'A'}, 2: {'B'}, 3: {'C'}}           # 各事务当前持有的锁
    waits = {1: ('B', 2), 2: ('C', 3), 3: ('A', 1)}  # 事务 -> (想要的数据项, 持有者)
    print("锁表:%s" % {t: sorted(s) for t, s in holds.items()})
    print("等待:%s" % {t: "%s <- T%d" % (it, h) for t, (it, h) in waits.items()})
    adj = {t: {h} for t, (_, h) in waits.items()}
    print("等待图:%s" % "   ".join("T%d->T%d" % (t, list(adj[t])[0]) for t in sorted(adj)))
    cyc = find_cycle(adj)
    print("DFS 检测结果:环 = %s => 死锁!" % "->".join("T%d" % t for t in cyc))

    # 牺牲者选择:优先锁最少者,其次最年轻者
    cands = sorted(set(cyc), key=lambda t: (len(holds[t]), -t))
    victim = cands[0]
    print("牺牲者选择:候选 %s;按(持有锁数, 越年轻越优先)排序 -> 牺牲者 = T%d"
          % (["T%d(锁%d)" % (t, len(holds[t])) for t in sorted(set(cyc))], victim))
    for it in sorted(holds[victim]):
        owner = [t for t, (i, h) in waits.items() if h == victim and i == it]
        for w in owner:
            print("  回滚 T%d:释放 %s -> T%d 的等待被满足" % (victim, it, w))
            del waits[w]
    del holds[victim]
    waits.pop(victim, None)
    adj = {t: {h} for t, (_, h) in waits.items()}
    print("恢复后等待图:%s,再检测 -> %s"
          % (adj, "仍有环" if find_cycle(adj) else "无环,死锁解除"))
    print()


# ==================== 第 2 部分:CMH 边追踪 ====================
class CMHSite:
    def __init__(self, sid):
        self.sid = sid
        self.waits = {}          # 本地事务 -> 它正等待的事务集合
        self.forwarded = set()   # 消息合并/抑制:已转发过的 (initiator, sender, target)
        self.reports = []

    def add_wait(self, t, target):
        self.waits.setdefault(t, set()).add(target)

    def drop_wait(self, t):
        self.waits.pop(t, None)


def cmh_demo(phantom=False):
    print("=" * 74)
    print("第 2 部分:Chandy-Misra-Haas 边追踪%s" % ("(含幻死锁场景)" if phantom else ""))
    print("=" * 74)
    owner = {1: 0, 2: 1, 3: 2}                      # 事务 -> 站点
    sites = {0: CMHSite(0), 1: CMHSite(1), 2: CMHSite(2)}
    edges = [(1, 2), (2, 3), (3, 1)]                # T1 等 T2 等 T3 等 T1
    for a, b in edges:
        sites[owner[a]].add_wait(a, b)
    print("等待边:%s   事务分布:T1@site0, T2@site1, T3@site2"
          % "  ".join("T%d->T%d" % e for e in edges))

    q = deque([("PROBE", 1, 1, 2, owner[2], "site0"),        # (类型, 发起者, 发送者, 目标, 目的站, 备注)
               ("PROBE", 1, 1, 2, owner[2], "T1 重传同一探测")])  # 故意重传,演示消息抑制
    if phantom:
        q.insert(2, ("EVENT", 2, 0, 0, 1, "T2 中止并释放全部锁(等待边 T1->T2 断开)"))
    seq = 0
    while q:
        seq += 1
        kind, i, j, k, dst, note = q.popleft()
        if kind == "EVENT":
            print("%2d. [事件] %s" % (seq, note))
            sites[1].drop_wait(i)                        # T2 不再等待
            sites[0].drop_wait(1)                        # T1 的等待随之结束
            print("     site1 局部等待表变为 %s,site0 局部等待表变为 %s"
                  % (sites[1].waits, sites[0].waits))
            continue
        print("%2d. site%d 收到 PROBE(发起者=T%d, T%d -> T%d)  %s"
              % (seq, dst, i, j, k, "<%s>" % note if "重传" in note else ""))
        if (i, j, k) in sites[dst].forwarded:
            print("     该探测已转发过 -> 抑制,不再重复发送(消息合并优化)")
            continue
        sites[dst].forwarded.add((i, j, k))
        if k == i:
            print("     *** T%d 收到由自己发起的探测回来了 => 检测到死锁!环 = T%d->...->T%d->T%d"
                  % (k, i, j, k))
            sites[dst].reports.append(i)
            continue
        targets = sites[dst].waits.get(k, set())
        if not targets:
            print("     T%d 当前不在等待任何事务 -> 探测终止(该分支无环)" % k)
            continue
        for m in sorted(targets):
            q.append(("PROBE", i, k, m, owner[m], "转发"))
            print("     转发 PROBE(发起者=T%d, T%d -> T%d) 给 site%d" % (i, k, m, owner[m]))
    reported = set(x for s in sites.values() for x in s.reports)
    print("检测结论:%s" % ("报告存在死锁,发起者为 T%d" % min(reported) if reported
                            else "未检测到死锁"))
    if phantom:
        print("真相:此刻全局等待边只剩 %s —— 环已被 T2 的中止打断,"
              % sorted((a, b) for a, b in edges if a != 2 and b != 2))
        print("      但探测消息已经上路,于是报出了一个【不存在的死锁】= 幻死锁(phantom deadlock)。")
    print()


if __name__ == "__main__":
    centralized_demo()
    cmh_demo(phantom=False)
    cmh_demo(phantom=True)
    print("=" * 74)
    print("对比:集中式检测要收集全局 WFG(单点 + 通信开销);边追踪把探测消息")
    print("      沿等待边推送,只有真正成环时才收到自己的探测消息;代价是每条新等待边")
    print("      都要发探测消息(O(边数)),而且异步系统下无法避免幻死锁 —— 只能用")
    print("      一致性全局快照(Chandy-Lamport)或给探测消息带时间戳来减少误报。")

【代码做什么?】

  1. 第 1 部分(集中式):构造三事务环($T_1$ 持 A 等 B、$T_2$ 持 B 等 C、$T_3$ 持 C 等 A),由锁表构造等待图,DFS 找到环,按”持有锁数少、更年轻”的启发式选出牺牲者 $T_3$,回滚它(释放锁 + 让等待者继续),再检测一次确认无环——完整走了一遍算法 19.3.2。
  2. 第 2 部分(分布式 CMH 边追踪):把三个事务分布到三个站点,用消息队列模拟探测消息的传播
    • $S_0$ 发 PROBE(1,1,2) → $S_1$ 转发 PROBE(1,2,3) → $S_2$ 转发 PROBE(1,3,1) → $S_0$ 发现 $k=i$ ⇒ 报告死锁
    • 故意重传同一条探测:第二次到达时命中 forwarded 集合 ⇒ 消息抑制,不再重复转发(这就是讲义强调的消息合并优化);
    • 在”幻死锁”模式里,往消息队列中间插入一个事件:$T_2$ 中止并释放全部锁。此后探测消息照旧传播、照着过期的等待信息继续转发,最终仍然报出死锁——而此刻全局等待边只剩 $T_3 \to T_1$,环已经不存在了

实际运行结果(节选)

========================================================================== 第 1 部分:集中式等待图(WFG)死锁检测 ========================================================================== 锁表:{1: [‘A’], 2: [‘B’], 3: [‘C’]} 等待:{1: ‘B <- T2’, 2: ‘C <- T3’, 3: ‘A <- T1’} 等待图:T1->T2 T2->T3 T3->T1 DFS 检测结果:环 = T1->T2->T3->T1 => 死锁! 牺牲者选择:候选 [‘T1(锁1)’, ‘T2(锁1)’, ‘T3(锁1)’];按(持有锁数, 越年轻越优先)排序 -> 牺牲者 = T3 回滚 T3:释放 C -> T2 的等待被满足 恢复后等待图:{1: {2}},再检测 -> 无环,死锁解除

========================================================================== 第 2 部分:Chandy-Misra-Haas 边追踪 ========================================================================== 等待边:T1->T2 T2->T3 T3->T1 事务分布:T1@site0, T2@site1, T3@site2

  1. site1 收到 PROBE(发起者=T1, T1 -> T2)
    转发 PROBE(发起者=T1, T2 -> T3) 给 site2
  2. site1 收到 PROBE(发起者=T1, T1 -> T2) 该探测已转发过 -> 抑制,不再重复发送(消息合并优化)
  3. site2 收到 PROBE(发起者=T1, T2 -> T3)
    转发 PROBE(发起者=T1, T3 -> T1) 给 site0
  4. site0 收到 PROBE(发起者=T1, T3 -> T1)
    *** T1 收到由自己发起的探测回来了 => 检测到死锁!环 = T1->…->T3->T1 检测结论:报告存在死锁,发起者为 T1

========================================================================== 第 2 部分:Chandy-Misra-Haas 边追踪(含幻死锁场景) ========================================================================== 等待边:T1->T2 T2->T3 T3->T1 事务分布:T1@site0, T2@site1, T3@site2

  1. site1 收到 PROBE(发起者=T1, T1 -> T2)
    转发 PROBE(发起者=T1, T2 -> T3) 给 site2
  2. site1 收到 PROBE(发起者=T1, T1 -> T2) 该探测已转发过 -> 抑制,不再重复发送(消息合并优化)
  3. [事件] T2 中止并释放全部锁(等待边 T1->T2 断开) site1 局部等待表变为 {},site0 局部等待表变为 {}
  4. site2 收到 PROBE(发起者=T1, T2 -> T3)
    转发 PROBE(发起者=T1, T3 -> T1) 给 site0
  5. site0 收到 PROBE(发起者=T1, T3 -> T1)
    *** T1 收到由自己发起的探测回来了 => 检测到死锁!环 = T1->…->T3->T1 检测结论:报告存在死锁,发起者为 T1 真相:此刻全局等待边只剩 [(3, 1)] —— 环已被 T2 的中止打断, 但探测消息已经上路,于是报出了一个【不存在的死锁】= 幻死锁(phantom deadlock)。

========================================================================== 对比:集中式检测要收集全局 WFG(单点 + 通信开销);边追踪把探测消息 沿等待边推送,只有真正成环时才收到自己的探测消息;代价是每条新等待边 都要发探测消息(O(边数)),而且异步系统下无法避免幻死锁 —— 只能用 一致性全局快照(Chandy-Lamport)或给探测消息带时间戳来减少误报。

【分布式机制透视】

  • 消息如何传递:用一个 deque 当作”网络 + 各站点的收件箱”,每条消息携带 (发起者, 发送者, 目标, 目的站)。真实系统里这就是三个进程之间的 socket 通信;把消息队列换成 socketpair/multiprocessing.Queue 即可获得”真并发”的版本,但语义完全相同(异步、乱序、可能延迟)。
  • 每个站点的状态waits(局部等待图出边)+ forwarded(已转发探测集合)。关键设计点是”站点只知道自己那部分等待关系”——这正是分布式死锁检测困难的根源:没有任何单点拥有全局视野。
  • 时序如何体现幻死锁:事件插入的位置就是”消息在途时系统状态发生变化”。程序故意让 PROBE(1,2,3) 在 $T_2$ 中止之后才到达 $S_2$——由于 $S_2$ 对本地的 waits[3] = {1} 判断仍然成立,它照常转发,于是 $S_0$ 收到了”绕回来的探测”。这段时序就是幻死锁的定义
  • 对应真实系统:Cassandra 等系统在跨节点事务/锁上使用”超时 + 探测消息”的组合;实际的分布式数据库(Spanner、CockroachDB)更倾向于尽量避免跨节点持锁(用时间戳/TrueTime 定序),从根本上绕开分布式死锁检测这个难题。

【与理论的对应】

代码位置对应本章理论
find_cycle() + select_victim()算法 19.3.2 的检测与牺牲者选择
环上三人各持 1 把锁 ⇒ 按”最年轻”选 $T_3$19.2.16 牺牲者启发式(锁最少 → 最年轻)
PROBE(i, j, k) 沿 waits 转发算法 19.3.4 的边追踪规则
if k == i: report_deadlock“探测绕回发起者 ⇒ 存在环”
forwarded 命中即丢弃消息合并/抑制优化
幻死锁场景19.2.17 的难点:等待信息来自不同时刻,异步系统无法区分”曾经有环”与”现在有环”
程序结尾引出的对策一致性全局快照(Chandy-Lamport,见 Snapshots 一章)或给探测消息带时间戳

19.5 性能与可扩展性分析

19.5.1 四类并发控制方法总览

维度2PL(严格 / 保守)OCC(Kung-Robinson)时间戳排序(+ Thomas)MVCC(+ SI / SSI)
何时检测冲突访问前:加锁的那一刻提交时:验证阶段每次读写时:即时比较时间戳读写时判断可见性;写-写冲突在写/提交时判
是否加锁是(S/X 锁,严格变体持到提交)不加锁;写阶段需短暂临界区完全不加锁读不加锁;写需要版本锁/写锁(first-updater-wins)
是否可能死锁(2PL 的固有问题);保守 2PL/时间戳预防可消除不会(无持有并等待)不会(等待边单向 ⇒ 无环)不会(多为无等待的版本判定;少数实现有写锁等待)
是否可能饥饿基本不会(FIFO 排队;牺牲者老化)理论上可能(反复验证失败)(老事务被反复回滚,见 19.4.1 场景 D)理论可能(写冲突反复失败),实践中靠优先级缓解
并发度中~低(写-写、读-写都互斥;保守 2PL 最低)高(读阶段完全无阻塞)高(无等待)、但回滚会降低有效并发最高(读不阻塞写、写不阻塞读)
适合场景冲突高、需要确定性行为、事务较短冲突低、读多写少、内存数据库冲突中低、需要全局定序(分布式友好)、事务短读多写少、长读事务、需要高并发(几乎所有现代 DBMS)
不适合场景长事务、热点数据(锁等待与死锁爆炸)高冲突(回滚风暴)、长事务(验证期长)长事务(时间戳老化)、随机重试的高冲突写密集 + 需要可串行化(SI 要加 SSI 检测)
额外开销锁表内存 + 上下文切换 + 死锁检测私有工作区/读写集内存 + 重做成本全局时间戳分配 + 回滚重做多版本存储 + 垃圾回收(vacuum)
正确性标准冲突可串行化(严格变体另有 ACA)冲突可串行化(验证条件)冲突可串行化(Thomas 规则下为视图可串行化)SI 不是可串行化;SI+SSI 才是
真实系统MySQL InnoDB 的锁路径、SQL Server、Spanner(悲观锁 + TrueTime)VoltDB/H-StoreGoogle Percolator、Redis WATCH/MULTI早期 TSO 数据库、Spanner 的 TrueTime 定序思想、DynamoDB/Cassandra 的 LWWPostgreSQL(+SSI)MySQL InnoDB、Oracle、SQL Server、CockroachDB

19.5.2 吞吐 vs 冲突率:悲观与乐观的交叉点

并发控制的性能几乎完全由冲突率(单位时间内两个活跃事务访问同一数据项的强度)决定。把吞吐(TPS)画成冲突率的函数,会看到两条方向相反的曲线

 吞吐
 (TPS)
   ▲
   │  ╲                                    悲观(2PL)
   │    ╲                                ╱
   │      ╲                            ╱
   │        ╲                        ╱
   │          ╲                    ╱
   │            ╲                ╱
   │  OCC/TO      ╲            ╱
   │  (乐观)       ╲        ╱
   │                  ╲    ╱
   │                    ✕  ← 交叉点:冲突率在此附近时两者相当
   │                  ╱    ╲
   │                ╱        ╲
   │              ╱            ╲
   │            ╱                ╲
   │          ╱                    ╲
   │        ╱                        ╲   ← 乐观协议:冲突率高时
   │      ╱                            ╲     回滚风暴,吞吐断崖式下跌
   └──────────────────────────────────────────────► 冲突率
     低冲突区(乐观更好)        高冲突区(悲观更好)

 为什么曲线会交叉:
   · 低冲突:乐观协议的"没有锁开销 + 没有死锁处理"占优;
             悲观协议白白花了加锁/解锁/阻塞唤醒的钱。
   · 高冲突:悲观协议的锁等待是"有序排队",虽然慢但结果都是有用功;
             乐观协议的回滚是"全部白做 + 重做",冲突越激烈白做越多,
             还可能引发连锁回滚 ⇒ 有效吞吐急剧下降。
 交叉点的位置取决于:事务长度、冲突窗口(读阶段有多长)、回滚成本。
   —— 内存数据库(窗口短)交叉点偏向高冲突侧;
      磁盘数据库(窗口长)交叉点明显偏低,因此传统 DBMS 长期以 2PL 为主。

用 19.4.1 场景 D 的实验数据印证(8 个事务读改写热点数据项,hot 越小冲突越激烈):

hot(热点项数)8(低冲突)421(高冲突)
严格 2PL:回滚 / 死锁次数2 / 24 / 49 / 96 / 6
OCC:回滚次数261020
TO(新时间戳):回滚次数281622
TO(沿用原时间戳):回滚次数280(未完成 2)280(未完成 4)285(未完成 5)285(未完成 6)

读法:冲突越激烈,乐观协议(OCC/TO)的回滚次数单调上升,而 2PL 的回滚次数基本平稳——2PL 把代价转化为”等待”(时间变长但工作不浪费),乐观协议把代价转化为”重做”(时间与 CPU 双重浪费)。最后一行的 TO 则展示了饥饿:几百次回滚之后仍有事务没能完成(活锁)。

19.5.3 延迟(Latency)的三个来源

协议延迟构成特点
2PL执行时间 + 锁等待时间(排队)+ 可能的死锁检测与回滚重做延迟方差大:抢到锁的事务很快,排在后面的事务可能等很久(尤其热点数据项)。长尾延迟(tail latency) 是 2PL 的著名问题
OCC执行时间(快,无阻塞)+ 验证时间 + 失败时的重做(期望值要按失败率加权)低冲突时延迟最低且方差小;高冲突时重做使平均延迟迅速上升,且尾部可能反复重试
时间戳排序执行时间 + 检查时间;严格 TO 有”等更早写者”的等待无锁、延迟可预测;但回滚重做同样计入
MVCC读操作无等待(读快照版本);写操作需版本判定/加写锁读延迟几乎等于纯计算时间——这是 MVCC 被广泛采用的核心原因;代价是版本存储与 GC

一条工程经验:“平均延迟”往往不是问题,”P99 延迟”才是。2PL 的锁等待会让少数事务等很久,OCC 的反复重试也会拉高尾延迟,而 MVCC 的读路径几乎没有尾延迟——这是现代数据库普遍采用 MVCC 的直接原因。

19.5.4 可扩展性(Scalability)

  • 单机:锁管理器是集中式数据结构(哈希表 + 每对象等待队列),所有事务都要碰它 ⇒ 高核数下锁管理器本身就是瓶颈(真实系统的做法是分区锁管理器:按数据项哈希把锁表分片,减少争用)。
  • 分布式 2PL:锁必须跨站点持有,于是
    1. 一次加锁 = 一次跨网络往返(延迟从几十纳秒变成几十微秒~毫秒);
    2. 事务持锁期间跨站点,导致全局死锁检测成为必需(19.2.17),检测本身又要通信;
    3. 站点故障会让”持锁者”消失,需要额外的锁恢复协议(这与 Lecture 20 的 2PC 强耦合)。 结论:2PL 在分布式下延迟高、协调开销大,但在需要强一致的场景仍被采用(如 Spanner,用悲观锁 + TrueTime 来换取可串行化)。
  • 分布式 OCC:读阶段完全本地(无通信),但验证阶段需要全局协调——要检查”我的读集是否与并发提交的写集相交”,就必须把读写集发到某个仲裁者或做分布式求交,通信量 $O(\vert RS\vert +\vert WS\vert )$ 且验证点是新的瓶颈。它适合”冲突很少 + 事务很短”的场景(因此常见于内存数据库与单分区事务)。
  • 分布式 TO:不需要锁协调,但需要全局时间戳分配器(中心分配器是瓶颈;常用”各站点一次预领一个时间戳区间”来摊销),此外”等待更早的写者”在跨站点时也会引入延迟。
  • MVCC 的可扩展性:读路径天然可扩展(各副本/各节点读自己的快照),写路径取决于版本冲突检测策略;瓶颈从”锁争用”转移到版本存储与 GC(CockroachDB、TiDB 用”按时间戳的 MVCC + 范围分区”把 GC 也做成分布式的)。

19.5.5 死锁的代价

  • 检测频率的二次代价:检测太频繁 ⇒ CPU 花在遍历等待图上(每 $T_{poll}$ 一次 $O(V+E)$);太稀疏 ⇒ 事务白等(每个死锁事务浪费”死锁存活时间 × 其资源占用”)。经验做法是以”事务平均执行时间”为周期,或”最近没有事务提交”时立刻检测。
  • 牺牲者的重做浪费:被回滚的事务已经消耗的 CPU、I/O 全部作废,还要重新执行一次。因此牺牲者选择偏好”回滚成本最小“的事务(工作量少、持有锁少、最年轻)。
  • 级联回滚的放大效应:基本 2PL 下,一个事务回滚可能迫使读过它脏数据的事务一并回滚,形成回滚风暴;这就是严格 2PL(X 锁持到提交)成为默认选择的原因——它用”多持一会儿锁”换掉了”连锁回滚”这个尾部风险。
  • 对比:Wait-Die/Wound-Wait 用额外的回滚换取”完全不检测死锁”,把”偶发的检测开销”换成了”常态的回滚开销”;Raft/Paxos 式的共识系统则进一步把冲突消灭在”单点定序”层面(见共识与复制各章)。

19.5.6 MVCC 的空间开销与垃圾回收

  • 版本存储:每个数据项的多个版本都要占空间。若一个长事务(快照)长时间不结束,它之后产生的所有旧版本都不能回收——PostgreSQL 的”表膨胀(table bloat)”与 “long-running transaction 阻塞 vacuum” 就是这样来的。
  • 回收(vacuum / purge):只有当”没有任何活跃快照可能读到某个旧版本“时才能回收它。工程上因此要:
    1. 限制事务与快照的最长存活时间(idle_in_transaction_session_timeout、快照上限);
    2. 后台定期 vacuum(PostgreSQL 的 autovacuum);
    3. 版本水位线(low-water mark) 批量回收(CockroachDB/TiDB 的 GC TTL)。
  • 取舍:MVCC 用空间与 GC 复杂度换来了”读不阻塞写、写不阻塞读”的延迟优势——在存储便宜、延迟昂贵的今天,这笔交易通常是划算的。

19.5.7 真实系统中的实践

系统并发控制方案关键点
PostgreSQLMVCC + 多级隔离;SERIALIZABLESSI默认 READ COMMITTED;SSI 通过跟踪 rw-依赖检测”危险结构”,读不阻塞写;需要 autovacuum 对抗表膨胀
MySQL InnoDBMVCC + next-key lock(行锁 + 间隙锁)默认 REPEATABLE READ;用间隙锁防幻读,代价是更多锁与死锁;SELECT ... FOR UPDATE 显式加锁
Oracle / SQL ServerMVCC(回滚段 / 行版本)READ COMMITTED 默认;提供 SI 级别的 SNAPSHOT 隔离;长事务导致的 undo 空间压力是运维重点
Spanner悲观锁 + 2PC + TrueTime 时间戳用”跨数据中心持锁 + Paxos 组”实现外部一致性(线性一致)的可串行化;为此接受较高的写延迟
CockroachDBMVCC + 串行化验证(+分布式事务)事务读快照、提交时验证;按范围分区与时间戳水位线做 GC;冲突时回滚重试(客户端可见的 RETRY
VoltDB / H-StoreOCC + 分区 + 确定性执行单分区事务在内存中顺序执行,彻底避免锁与死锁;跨分区事务代价高
Redis(事务)乐观锁WATCH + MULTI/EXEC没有回滚:EXEC 时若被监视的键被改过则整体放弃,由客户端重试

一句话总结这条链路:单机靠锁,高并发靠 MVCC,低冲突靠乐观,跨机房靠”时间戳 + 共识”

19.6 关键要点

  • 本讲黄金法则可串行化是并发控制的正确性标准;2PL 用锁与两阶段保证它但会死锁,OCC 用推迟验证保证它但会回滚,时间戳排序用全局顺序保证它但会饥饿,MVCC 用多版本让读不阻塞写但需要额外处理写偏斜。 四者不是”谁更好”,而是”用哪种代价换取可串行化”。
  • 串行化定理是整章的骨架:历史冲突可串行化 $\iff$ 优先图无环。所有协议的正确性证明都是”证明自己产生的历史优先图无环”:2PL 靠锁点单调,Wait-Die/Wound-Wait 与 TO 靠时间戳沿等待边严格单调,OCC 靠验证条件保证冲突按验证序排列
  • 安全性与活性是两件事:死锁破坏的是活性(大家都做不完),不可串行化破坏的是安全性(做完了但结果错)。2PL 牺牲活性换安全性,因此”2PL 保证可串行化”与”2PL 会死锁”毫无矛盾。
  • 冲突的定义是一切的起点:读-读不冲突,读-写、写-读、写-写冲突;这个纯语法化的定义把语义问题变成了图论问题,也正是”读写锁能提高并发度”的根本原因。
  • 隔离级别是旋钮,不是开关:大多数系统默认 READ COMMITTED;需要可串行化时优先考虑 SSI(读不阻塞写),而不是直接退化到 2PL。SI 不等于可串行化,写偏斜就是它的漏洞。
  • 分布式把每个问题都放大一档:局部等待图 ≠ 全局等待图,因此有幻死锁;”检测需要一致性快照”这一条与快照一章的结论完全一致——异步系统里没有免费的全局判断

19.7 常见陷阱与注意事项

  1. 把 ACID 的 C 当成 CAP 的 C
    • 错在哪:ACID 的 C 是”不违反业务完整性约束”(应用语义),CAP 的 C 是”多副本对外表现如单一副本(线性一致)”(复制语义)。
    • 正确做法:看到”一致性”先问是哪个层次;用事务保证不了副本一致,用复制协议也保证不了业务不变量(后者必须由应用在事务里显式维护)。
  2. 用”最终数据状态”判断可串行化
    • 错在哪:不一致检索(19.2.6 异常二)最终落盘的数据可能恰好正确(转账两条写都生效了),但某个事务读到的总额是错的。只比最终状态会漏判。
    • 正确做法:按定义比较”所有对象 + 所有事务“的结果——包括每个事务读到的值。19.4.1 的 serial_runs() 同时比较最终状态与读值,正是为此。
  3. 以为”加了锁就万事大吉”(忽略两阶段的必要性)
    • 错在哪:没有”收缩阶段不得再加锁”的纪律,锁本身并不能保证可串行化——一个事务可以放了 A 的锁再拿 B 的锁,从而产生循环的冲突边。
    • 正确做法:要么两阶段(2PL),要么用时间戳/验证来定序;”锁 + 任意加解锁顺序”不是并发控制协议。
  4. 以为 2PL 能防死锁,或以为死锁意味着不可串行化
    • 错在哪:2PL 只约束加/放锁次序,完全不管”持有并等待”,因此死锁必然可能(19.2.14);而死锁是活性问题,与历史的可串行化无关。
    • 正确做法:把两件事分开解决——用严格/保守 2PL、Wait-Die/Wound-Wait 或超时来对付死锁,用 2PL 本身来保证可串行化。
  5. OCC 验证里照抄教科书条件 2 而不检查”写阶段是否落在我的读阶段内”
    • 错在哪:若 $T_j$ 的写阶段正好落在 $T_i$ 的读阶段内部,$T_i$ 可能读到了 $T_j$ 覆盖前的过时值,而条件 2 只检查写-写,会漏掉它 ⇒ 可串行性被破坏
    • 正确做法:把时间戳分配在验证阶段(验证顺序即时间戳序),并且只要”对方的提交落在我的读区间内”就一律走条件 3 的强检查(本讲代码即如此)。
  6. 以为快照隔离(SI)就安全了
    • 错在哪:SI 只检测写-写冲突,对读-写冲突视而不见,”医生值班”式的写偏斜会让两个事务都提交并破坏约束(19.2.21)。
    • 正确做法:真正需要可串行化时用 SSI(PostgreSQL 的 SERIALIZABLE),或把约束做成显式冲突(例如先 SELECT ... FOR UPDATE 锁住被检查的行,把读写冲突转成写写冲突)。
  7. 用局部等待图判断全局死锁
    • 错在哪:每个站点只看到自己那部分等待关系,局部有环不等于全局有环,而信息的时序错位会造成幻死锁(19.2.17:环在探测消息传播期间被打断,却仍被报告)。
    • 正确做法:要么依赖一致性全局快照(Chandy-Lamport)构造全局等待图,要么接受误报但保证回滚安全(牺牲者本来就可能白回滚),并给探测消息带时间戳/状态确认。
  8. 把 Thomas 写规则当成”任何冲突的写都能忽略”
    • 错在哪:Thomas 规则只适用于”过时写“——即 $\mathrm{TS}(T) < \mathrm{write\_TS}(x)$、已经有更年轻的版本存在、且此后没有任何事务可能读到它的情形。若错把”$\mathrm{TS}(T) < \mathrm{read\_TS}(x)$”(更年轻的事务读过)也当成可忽略,就会产生不可重复读。
    • 正确做法:严格区分两条规则——读过就打回(回滚),写得过时才能忽略;实现上还要正确处理”未提交写者(pending)”,否则会把未提交的写误当已提交而错误忽略。

19.8 思考题(带答案)

题 1(推演题) 给定并发历史

\[H = R_1(x)\; W_2(x)\; R_2(y)\; W_3(y)\; R_3(z)\; W_1(z)\]

(1) 画出优先图并判定是否冲突可串行化;(2) 若不可串行化,最少回滚几个事务才能得到一个可串行化的历史?回滚哪个(些)最方便?(3) 该历史能否被严格 2PL 产生?

答案: (1) 逐对检查冲突操作(不同事务、同一数据项、至少一个写):

  • $R_1(x)$ 与 $W_2(x)$ ⇒ 边 $T_1 \to T_2$;
  • $R_2(y)$ 与 $W_3(y)$ ⇒ 边 $T_2 \to T_3$;
  • $R_3(z)$ 与 $W_1(z)$ ⇒ 边 $T_3 \to T_1$。

优先图为 $T_1 \to T_2 \to T_3 \to T_1$,有环 ⇒ 冲突不可串行化

(2) 环上任意一个事务回滚即可破环(回滚一个,保留其他两个)。例如回滚 $T_2$ 后,剩下的冲突只有 $R_3(z)$ 与 $W_1(z)$ ⇒ 边 $T_3 \to T_1$,无环;等价串行序为 $T_3, T_1$。若按”回滚工作量最少”选,则应比较各事务已执行的操作数与持有的锁数;本题三者规模相同,通常再按”最年轻者优先”选 $T_3$。

(3) 不能。严格 2PL 的历史必然冲突可串行化(算法 19.3.1 的锁点单调性证明),而 $H$ 有环。反过来说,如果某个调度器产出了 $H$,那么它一定违反了 2PL——例如 $T_1$ 必须先拿 $x$ 再拿 $z$($R_1(x) \to W_1(z)$,是”先加锁后加锁”),$T_2$ 必须先拿 $x$(被 $T_1$ 挡住)、再拿 $y$,$T_3$ 需要 $y$ 和 $z$——把三条链串起来会得到一个”某事务在收缩之后又加锁”的矛盾。

题 2(”直观但错误的想法”) 某同学说:“既然可串行化的定义是’结果等价于某个串行执行’,那我只要在每个事务结束时对比一下最终数据状态是否正确,不对就回滚重做,不就能保证可串行化了吗?这样实现起来最简单,还不用加锁。” 这个想法错在哪?

答案:错在把”结果”理解成了”最终落盘的数据”。可串行化的定义要求对所有对象和所有事务的结果一致,其中读操作返回的值同样是”结果”的一部分:

  • 反例就是不一致检索:转账事务的两条写最终都落盘了,数据看起来完全正确;但并发执行的对账事务读到了”钱已转出、尚未到账”的中间状态,打印出的总额少了 100。此时”最终状态正确”成立,可串行化却不成立。
  • 更深一层:这种”事后靠最终状态回滚”的方案无法知道该回滚谁。若两个事务都提交了、结果错了,撤销哪一个?撤销任一都会丢掉一个本应生效的更新(丢失更新场景里,$x=80$ 既不是 70 也不是任何串行结果,撤销 $T_1$ 或 $T_2$ 都需要它自己的旧值,而旧值可能已被覆盖)。
  • 正确做法:要么在执行过程中阻止冲突(锁/时间戳),要么在提交时用读集+写集判断冲突(OCC 验证),要么用多版本让读者看到一致快照(MVCC)。判据必须是”冲突操作的顺序”,而不是”最终数据的模样”。

题 3(推演题) 两个事务 $T_1$(时间戳 10)与 $T_2$(时间戳 20)。$T_1$ 已持有 $A$ 上的 X 锁,$T_2$ 已持有 $B$ 上的 X 锁。现在 $T_1$ 请求 $B$、$T_2$ 请求 $A$。请分别给出 Wait-DieWound-Wait 的处置,并说明为什么两者都不会死锁;再说明如果回滚后重新分配新时间戳会破坏哪一条性质。

答案

  • Wait-Die:$T_2$(20,更年轻)请求 $A$(被更老的 $T_1$ 持有)⇒ $T_2$ 死亡:回滚 $T_2$、释放 $B$、用原时间戳 20 重启。$T_1$(10,更老)请求 $B$ ⇒ $T_1$ 等待(此时 $B$ 已因 $T_2$ 回滚而释放,$T_1$ 立即获得)。$T_2$ 重启后重新竞争。
  • Wound-Wait:$T_2$(更年轻)请求 $A$ ⇒ $T_2$ 等待。$T_1$(更老)请求 $B$ ⇒ $T_1$ 伤害 $T_2$:强行回滚 $T_2$,$T_1$ 拿走 $B$。$T_2$ 用原时间戳 20 重启。
  • 为什么都不会死锁:Wait-Die 中只有”更老者”会等待,因此每条等待边 $T_i \to T_j$ 满足 $\mathrm{TS}(T_i) < \mathrm{TS}(T_j)$,沿边严格递增;Wound-Wait 中只有”更年轻者”会等待,等待边沿时间戳严格递减。两种情况都是严格偏序,环上会出现 $\mathrm{TS}(T) < \mathrm{TS}(T)$ 的矛盾 ⇒ 等待图无环 ⇒ 无死锁。
  • 如果回滚后重新分配更大的时间戳:等待边的单调方向不再是一致同向的(例如一个刚重启的事务拿到了比原持有者更大的时间戳,它可能在某处等待、在别处又让别人等待),“无死锁”的证明失效,两者都可能出现死锁;同时 Wait-Die 的”不饥饿”论证也失效(重启后变”更年轻”,可能被不断更新的老事务反复杀死)。这也解释了为什么这两种协议实现时都强调”沿用原时间戳重启“。

题 4(计算题) 某 OCC 实现中,事务按下列时刻执行(时间单位为调度步;时间戳在进入验证阶段时分配,即验证顺序)。请判断哪些事务能提交:

事务开始读读阶段结束(=验证并写)读集 RS写集 WS
$T_1$13$\{x\}$$\{x\}$
$T_2$58$\{x\}$$\{y\}$
$T_3$29$\{y\}$$\{z\}$

答案:按验证顺序为 $T_1$(第 3 步)、$T_2$(第 8 步)、$T_3$(第 9 步),故 $\mathrm{TS}(T_1) < \mathrm{TS}(T_2) < \mathrm{TS}(T_3)$。

  • $T_1$committed 为空 ⇒ 通过,安装 $x$,commit_step = 3
  • $T_2$:与 $T_1$ 比较——$T_1$ 的 commit_step = 3 < T_2.start = 5 ⇒ 满足条件 1($T_1$ 完全早于 $T_2$)⇒ 无需检查 ⇒ 通过,安装 $y$,commit_step = 8
  • $T_3$:依次比较
    • 与 $T_1$:$T_1$ 的 commit_step = 3 不小于 $T_3.start = 2$(条件 1 不满足);$T_1.read\_end = 3 < T_3.read\_end = 9$ 且 $T_1.commit\_step = 3$ 不大于 $T_3.read\_end = 9$ ⇒ 走条件 3 强检查:$(RS_3 \cup WS_3) \cap WS_1 = \{y,z\} \cap \{x\} = \varnothing$ ⇒ 通过;
    • 与 $T_2$:$T_2$ 的 commit_step = 8 不小于 $T_3.start = 2$;$T_2.read\_end = 8 < T_3.read\_end = 9$ 且 $T_2.commit\_step = 8$ 不大于 9 ⇒ 仍走条件 3 强检查:$(RS_3 \cup WS_3) \cap WS_2 = \{y,z\} \cap \{y\} = \{y\} \neq \varnothing$ ⇒ 冲突 ⇒ $T_3$ 回滚
  • 结论:$T_1$ 与 $T_2$ 提交,$T_3$ 回滚重试。原因是 $T_3$ 在读阶段读到了 $y$,而 $T_2$ 在 $T_3$ 的读阶段内部(第 8 步 ≤ 第 9 步)提交了对 $y$ 的写——$T_3$ 读到的 $y$ 可能已经过时,因此必须回滚。这正是 19.2.19 补充说明强调的”写阶段落在我的读区间内 ⇒ 必须强检查”的情形:如果这里错误地按条件 2 只查写-写($WS_3 \cap WS_2 = \{z\} \cap \{y\} = \varnothing$)就放行,就会产出一个读到过时值的不可串行化历史。