CMU15-445 数据库系统播客:深入解析数据库并发控制 – 两阶段锁(2PL)

在构建任何多用户应用程序时,数据库的并发控制都是一个无法回避的核心问题。我们如何允许多个用户同时读写数据,同时又能保证数据的完整性和一致性?这正是数据库事务隔离性(Isolation)所要解决的难题。

本文将深入探讨实现隔离性的经典协议—— 两阶段锁(Two-Phase Locking, 2PL) 。我们将从最基本的问题出发,层层递进,揭示2PL的工作原理、它面临的挑战以及在现代数据库系统(如 MySQL 和 PostgreSQL)中的实际应用。

问题的起点:为什么不能“随用随放”锁?

一个很自然的想法是:当一个事务需要访问某个数据时,就给它加上锁;用完之后,立刻释放锁,让其他事务可以继续使用。这种策略看似高效,但却隐藏着巨大的风险。

让我们看一个经典的例子—— 不可重复读(Unrepeatable Read) :

事务 T1 读取账户 A 的余额为 100 元,并对其加上锁。T1 释放了对 A 的锁。此时,事务 T2 介入,它也读取账户 A 的余额,并将其修改为 200 元后提交。随后,T1 因为业务需要, 再次读取 账户 A 的余额,却发现余额变成了 200 元。

对于 T1 而言,在同一个事务中两次读取同一数据,得到的结果却不一致。这破坏了事务的隔离性,可能导致严重的业务逻辑错误。根本原因在于,我们 无法预知一个事务未来的所有操作 。T1 在释放锁时,并不知道自己未来是否还会再次访问该数据。

为了在這種“未来未知”的動態場景下保證隔離性,我们需要一个更严谨的并发控制协议。两阶段锁(2PL)应运而生,它是一种经典的 悲观并发控制 方法,其核心思想是“先锁定,再操作”。

两阶段锁(2PL)的核心思想

2PL 协议非常直观,它规定了事务在获取和释放锁时必须遵守的一个简单规则。在了解规则之前,我们先明确锁的类型和对象。

锁的类型

2PL 主要使用两种基本锁:

共享锁(Shared Lock, S-LOCK) :用于 读 操作。多个事务可以同时持有同一个数据对象的共享锁。可以把它理解为“大家可以一起读”。排他锁(Exclusive Lock, X-LOCK) :用于 写 操作。一旦某个事务持有了数据对象的排他锁,其他任何事务都不能再对该对象施加任何锁(无论是 S 锁还是 X 锁)。可以理解为“我正在写,谁都别动”。

它们的兼容关系如下矩阵所示:

S-LOCK

X-LOCK

S-LOCK

兼容

不兼容

X-LOCK

不兼容

不兼容

两个阶段(The Two Phases)

2PL 的精髓在于,它将一个事务的生命周期严格划分为两个阶段:

增长阶段(Growing Phase)

在此阶段,事务可以 不断地请求和获取 它所需要的锁。事务向数据库的 锁管理器(Lock Manager) 发送锁请求。锁管理器会根据锁的兼容性矩阵来决定是立即授予锁,还是让事务阻塞等待。在此阶段,事务绝对不能释放任何锁。

收缩阶段(Shrinking Phase)

当事务 释放了它的第一个锁 时,它就立即进入收缩阶段。一旦进入此阶段,事务就只能释放已经持有的锁,而不能再请求任何新的锁。

正是这个“先增长、后收缩”的规定,保证了由 2PL 管理的事务调度是 冲突可串行化(Conflict Serializable) 的。这意味着,尽管事务在并发执行,其最终效果等同于以某种顺序串行执行,从而保证了数据的一致性。

两阶段锁例子

为了更具体地理解这个过程,让我们通过一个银行转账的例子来逐步分解这两个阶段,并回答 “锁何时释放?” 以及 “阶段何时转换?” 这两个核心问题。

场景设定:

事务 T1 需要从账户 A 转账 50 元到账户 B。

整个过程如下:

第一阶段:增长阶段(Growing Phase)

事务 T1 开始执行,它的目标是不断获取它完成任务所需的所有锁。

T1: BEGIN TRANSACTION;

事务开始。

T1: LOCK-X(A); // 请求对 A 的排他锁

T1 的第一个操作是修改账户 A。它向锁管理器请求对 A 的排他锁(X-Lock)。锁管理器授予该锁。此时,T1 正式进入增长阶段。 它只持有 LOCK-X(A)。

T1: READ(A); WRITE(A);

T1 读取 A 的余额,减去 50 元,然后将新余额写回。在整个操作期间,它都持有 A 的锁。

T1: LOCK-X(B); // 请求对 B 的排他锁

接下来,T1 需要修改账户 B。它向锁管理器请求对 B 的排他锁。锁管理器授予该锁。T1 仍然处于增长阶段,因为它仍在获取新的锁,并且没有释放任何已有的锁。此刻,T1 同时持有 LOCK-X(A) 和 LOCK-X(B)。

T1: READ(B); WRITE(B);

T1 读取 B 的余额,加上 50 元,然后将新余额写回。

转折点:从增长阶段到收缩阶段

现在,T1 已经完成了对 A 和 B 的所有修改。它可以开始释放锁了。

T1: UNLOCK(A); // 释放对 A 的锁

这是整个过程最关键的转折点!当 T1 执行 UNLOCK(A),释放它持有的第一个锁时:T1 的增长阶段(Growing Phase)立即结束。T1 的收缩阶段(Shrinking Phase)立即开始。

第二阶段:收缩阶段(Shrinking Phase)

一旦进入此阶段,T1 的行为将受到严格限制。

核心规则生效 :从现在起,T1 绝对不能再请求任何新的锁 。如果此时业务逻辑突然要求 T1 去检查另一个账户 C 的状态(需要获取 LOCK-S(C)),根据 2PL 协议,该请求将被拒绝,T1 必须中止。这就是 2PL 保证可串行性的核心机制。

T1: UNLOCK(B); // 释放对 B 的锁

T1 继续处于收缩阶段,它释放了持有的最后一个锁。

T1: COMMIT;

事务提交,所有修改永久生效。

通过这个例子,我们可以清晰地回答之前的问题:

增长阶段何时结束?

在事务释放其持有的第一个锁的瞬间结束。在我们的例子中,是执行 UNLOCK(A) 的那一刻。

收缩阶段何时开始?

与增长阶段结束是同一时刻,即释放第一个锁时立刻开始。

锁何时被释放?

在标准的 2PL 中,锁可以在 收缩阶段 的任何时候被释放, 不一定需要等到事务提交 。然而,正如我们前文所讨论的,这种提前释放会导致“脏读”和“级联终止”等问题。因此,在实际系统中更常用的是 强严格两阶段锁(SS2PL) ,它规定所有锁必须持有到事务最终提交(Commit)或中止(Abort)时才能一次性释放,这相当于将整个收缩阶段压缩到了事务结束的最后一个点。

2PL 的挑战与演进:从理论到实践

虽然标准的 2PL 解决了可串行化的问题,但在实际应用中,它仍然有两个致命的缺陷: 级联终止(Cascading Aborts) 和 死锁(Deadlocks) 。

挑战一:级联终止

想象以下场景:

事务 T1 修改了数据 A,但 尚未提交 。根据标准 2PL,T1 可以在不提交的情况下进入收缩阶段,释放了对 A 的 X 锁。事务 T2 立即获取了 A 的锁,并读取了 T1 修改后的(但未提交的)值。我们称之为 脏读(Dirty Read) 。此时,T1 由于某种原因执行失败,必须 中止(Abort) 并回滚其所有修改。现在问题来了:T2 读取了一个根本不存在的“脏”数据。为了维护数据一致性, 数据库系统必须强制中止 T2 。如果还有 T3 读取了 T2 的(同样未提交的)修改,那么 T3 也必须被中止。

这种一个事务的失败导致一连串相关事务被动中止的现象,就是 级联终止 。它不仅会造成大量计算资源的浪费,也让系统恢复的逻辑变得异常复杂。

解决方案:严格与强严格 2PL

为了解决脏读和级联终止问题,现实世界的数据库系统普遍采用了 2PL 的两个增强版本:

严格两阶段锁(Strict 2PL) :该协议要求事务 在提交或中止之前,不得释放任何它持有的排他锁(X-LOCK) 。这意味着,任何事务所做的修改在它提交之前,对其他事务都是不可见的。这就彻底杜绝了脏读。强严格两阶段锁(Strong Strict 2PL, SS2PL) :也称为 Rigorous 2PL,这是最常用的版本。它比 Strict 2PL 更进一步,要求事务 在提交或中止之前,不得释放任何它持有的锁(包括 S-LOCK 和 X-LOCK) 。

SS2PL 实际上消除了独立的“收缩阶段” 。事务的所有锁都在其生命周期结束时(commit 或 abort)一次性全部释放。

SS2PL 的优势是巨大的:

完全避免级联终止 :由于所有锁都持有到最后,其他事务不可能读到未提交的数据。简化恢复逻辑 :当一个事务需要中止时,DBMS 只需恢复它自己修改的原始值即可,无需担心对其他并发事务的影响。

当然,SS2PL 的代价是牺牲了一部分并发度(因为锁的持有时间变长了)。但在实践中,它带来的安全性和简明性远比这点并发度损失更重要。

挑战二:死锁(Deadlock)

死锁是所有基于锁的并发控制协议都可能面临的问题。当两个或多个事务循环等待对方持有的锁时,死锁就发生了。

经典例子

T1 持有数据 A 的锁,并请求数据 B 的锁。T2 持有数据 B 的锁,并请求数据 A 的锁。

此时,T1 和 T2 都将无限期地等待下去,系统陷入停滞。

处理死锁通常有两种策略: 死锁检测 和 死锁预防 。

策略一:死锁检测(Deadlock Detection)

这是一种“事后处理”的策略。

构建等待图(Waits-For Graph) :DBMS 在后台维护一个有向图。图中的每个节点代表一个事务,如果 T1 正在等待 T2 释放锁,就有一条从 T1 指向 T2 的边。周期性检测 :系统会定期检查这个等待图中是否存在 循环 。一旦发现循环,就证明发生了死锁。选择受害者并中止 :检测到死锁后,DBMS 必须选择一个“受害者”事务并将其中止,以打破循环。受害者释放其所有锁,让其他事务得以继续。

如何选择受害者是一个复杂的问题,通常会基于一些启发式规则,例如:

选择最年轻的事务(时间戳最大)。选择已执行工作最少的事务。选择持有锁最少的事务。为防止某个事务总是被选为受害者( 饥饿 Starvation ),也会考虑其历史重启次数。策略二:死锁预防(Deadlock Prevention)

这是一种“防患于未然”的策略,它通过制定规则来确保死锁从一开始就不会发生,从而避免了构建和检测等待图的开销。

一种常见的方法是 基于事务时间戳分配优先级 (通常时间戳越小,即越老的事务,优先级越高)。当发生锁冲突时,按以下策略之一处理:

Wait-Die(“老”等“新”)

如果请求锁的事务 T_req优先级更高 (更老),则 T_req等待 持有锁的 T_hold 。如果请求锁的事务 T_req优先级更低 (更年轻),则 T_req中止 (Die)并稍后重启。

Wound-Wait(“老”抢“新”)

如果请求锁的事务 T_req优先级更高 (更老),则它会 强制 持有锁的 T_hold中止 (Wound),抢占锁。如果请求锁的事务 T_req优先级更低 (更年轻),则 T_req等待T_hold 。

一个关键细节是:当一个事务因死锁预防而中止并重启时, 它会保留其原始的时间戳 。这保证了它最终会成为“最老”的事务,从而获得最高优先级,避免了饥饿问题。

2PL 的实际应用:多粒度与意向锁

如果一个事务需要更新一个包含数百万行数据的表,难道要为每一行都获取一个排他锁吗?这显然是低效的。锁管理器的开销会变得巨大。

为了解决这个问题,数据库引入了 多粒度锁定(Multi-Granularity Locking) 的概念。锁可以施加在不同的层级上:

Database → Table → Page → Tuple(Row)

事务可以在这个层次结构的任何一层上请求锁。当一个事务在某个高层级节点(如 Table)上获取锁时,它就 隐式地锁定了其下的所有子节点 (如该表的所有 Page 和 Tuple)。

但这又带来了新问题:如果事务 T1 锁定了整个表,事务 T2 如何知道它不能去修改表中的某一行呢?反之,如果 T2 锁定了某一行,T1 又如何知道它不能直接锁定整个表呢?

答案就是 意向锁(Intention Locks) 。

意向锁是施加在 高层级节点 的“标记”或“提示”,它表明了事务 打算 在更低的层级上施加何种锁。

意向共享锁(Intention-Shared, IS) :表明事务打算在下层节点获取 S 锁。例如,在对某个元组加 S 锁之前,先在表上加 IS 锁。意向排他锁(Intention-Exclusive, IX) :表明事务打算在下层节点获取 X 锁。例如,在对某个元组加 X 锁之前,先在表上加 IX 锁。共享意向排他锁(Shared+Intention-Exclusive, SIX) :一种组合锁,表示对当前节点(如表)加 S 锁,同时打算在下层节点(如元组)加 X 锁。一个典型的场景是:SELECT * FROM table; UPDATE table SET ... WHERE id=...

通过意向锁,锁管理器可以非常高效地判断锁请求是否冲突。例如,当一个事务想对整个表加 X 锁时,它只需检查表上是否有任何其他锁(包括 IS, IX 等)。如果发现表上已有一个 IX 锁,它就知道有其他事务正在修改表内的某些行,因此必须等待。

结论:2PL 在现代数据库中的地位

时至今日,两阶段锁协议及其变体(尤其是 强严格两阶段锁 SS2PL )仍然是绝大多数关系型数据库(如 MySQL InnoDB, PostgreSQL, SQL Server )并发控制的核心基石。

当你在 MySQL 中将事务隔离级别设置为 SERIALIZABLE 时,其底层正是通过 SS2PL 来防止所有并发异常,确保事务的可串行化。

作为开发者,你通常不需要在 SQL 中手动声明 LOCK TABLE。数据库管理系统会根据你的查询(SELECT, INSERT, UPDATE, DELETE)自动为你获取和管理所需的锁。但理解 2PL 的工作原理,可以帮助你:

分析和解决应用中遇到的死锁问题。理解不同隔离级别之间的性能和一致性权衡。更有效地使用 SELECT ... FOR UPDATE 这样的语句来提前锁定资源,优化业务逻辑。

总而言之,两阶段锁协议是数据库理论与工程实践完美结合的典范。它通过一个简单而优雅的规则,为复杂的数据世界带来了秩序和稳定。

阅读剩余
THE END