Skip to content

第13章:高级并发控制


元数据卡

维度
难度(黑魔法)
前置第11章:事务与 ACID
关键词两阶段锁、意向锁、死锁、乐观并发控制、时间戳排序、MVCC 并发控制
代码语言伪代码 / PostgreSQL 锁观察

你的进度

副本记录本解决了读写冲突,但还有更复杂的情况——两个管理员要修改同一件货物。一个人先锁住了货架,另一个人得排队等着。但如果两个人各自锁了不同的货架,又在等对方释放——就卡死了。这是死锁。这一章讲更精细的协作规则:两阶段锁、乐观锁、死锁调解。

你的任务

理解两阶段锁(2PL)的理论模型和实际锁管理器设计、死锁的检测与预防策略、乐观并发控制的三阶段流程、以及时间戳排序(TO)的基本原理。目标是:当你在一个数据库里看到 LOCK 或死锁日志时,能知道背后发生了什么。


破局 · 溯源

三种并发控制范式

并发控制的核心可以归结为三个问题:

  1. 事务在操作前要不要先获取资源控制权?
  2. 如果冲突发生了,让谁等待,让谁回滚?
  3. 执行顺序由什么决定?
范式策略代表适合场景
悲观(Pessimistic)操作前先上锁2PL、意向锁高冲突率
乐观(Optimistic)操作时不锁,提交前检查冲突OCC低冲突率
时间(Time-based)按时间戳定顺序,冲突时 abortTO、MVCC 的隐式排序混合

1. 两阶段锁(2PL)

基本思想

每个事务在操作数据前必须获取锁,释放锁后不能再获取新锁。这两条规则把事务分为两个阶段:

【生长阶段】获取锁,不能释放锁

【收缩阶段】释放锁,不能再获取锁
T1:
 LOCK(flame_sword) ← 生长阶段开始——先拿炎之剑
 READ(flame_sword)
 LOCK(frost_staff)
 READ(frost_staff)
 WRITE(flame_sword)
 UNLOCK(flame_sword) ← 收缩阶段开始——炎之剑做完了
 READ(frost_staff)
 UNLOCK(frost_staff)

如果 T2 在 T1 释放炎之剑之前就试图 LOCK(flame_sword),T2 会等待(或 abort)。

定理:遵守 2PL 的调度是冲突可串行化的。

注意 2PL 不保证无死锁——两个事务互相等待对方释放锁就会死锁。

严格两阶段锁(Strict 2PL / SS2PL)

标准 2PL 的问题是:如果 T1 释放了 A 的锁但在写入 B 时崩溃,其他事务可能读到了 A 的不一致值。严格 2PL 要求:

所有写锁在事务提交(或中止)后才释放。

T1(SS2PL):
 LOCK(flame_sword)
 READ(flame_sword)
 LOCK(frost_staff)
 WRITE(flame_sword)
 WRITE(frost_staff)
 → COMMIT → UNLOCK(flame_sword) UNLOCK(frost_staff) ← 两件物品的锁在 COMMIT 后同时释放

SS2PL 解决了级联回滚(Cascading Abort)的问题——因为未提交事务的写锁一直持有,其他事务不可能读到它未提交的数据。

2PL vs SS2PL:SS2PL 不是不同的事务管理器,而是 2PL 的加严格版本(锁保留到事务结束)。几乎所有实际数据库实现的都是 SS2PL 或其变体。

级联回滚(Cascading Abort)

T1: W(flame_sword) ← 然后崩溃
T2: R(flame_sword) W(frost_staff) ← 基于 T1 未提交的值做了修改
T3: R(frost_staff) ← 又基于 T2 的修改

如果 T1 回滚,T2 也要回滚(因为它读了 T1 的脏数据),T3 也要回滚。链条灾难。

SS2PL 通过"持有写锁直到事务结束"避免了这个问题。

2. 锁管理器设计

实际的锁管理器不是一个简单的"加锁/解锁"接口。它需要支持不同粒度的锁。

锁粒度(Lock Granularity)

粒度并发度管理开销场景
数据库级最低最低DDL、备份
表级全表扫描、DDL
页级某些 DB 的页分裂
行/元组级最高最高高并发 OLTP

行级锁的缺点是:当你要锁很多行(比如全表更新)时,需要申请大量锁,导致锁管理器内部膨胀。

意向锁(Intention Locks)

意向锁解决了"怎么知道有人在锁某个表的某几行"的问题。

锁类型含义
IS(意向共享)正在某些子资源上加 S 锁
IX(意向排他)正在某些子资源上加 X 锁
SIX(共享意向排他)S + IX,持表锁的在行上排他锁

锁兼容矩阵

已持\请求ISIXSSIXX
IS
IX
S
SIX
X

访问协议

要获取子资源上的 S 锁 → 父资源上先获取 IS 或更强的锁 要获取子资源上的 X 锁 → 父资源上先获取 IX 或更强的锁

直觉:意向锁是说"我打算在下面做点事"。别人想锁整表时看到意向锁就知道有人在用下面的行,不会傻等。

锁升级(Lock Escalation)

当同一个事务持有大量行锁时(比如全表 UPDATE),锁管理器内存可能耗尽。此时数据库自动将多个行锁升级为表锁:

多行 X 锁 → 表级 X 锁

锁升级在 SQL Server 和 DB2 中常见。MySQL InnoDB 不支持锁升级(它认为更小的粒度总是更好的)。PostgreSQL 也没有锁升级——它的锁内存是用哈希表管理的,不会膨胀到无限。

锁转换(Lock Conversion)

事务已持有低级别锁,需要升级权限:

S 锁 → X 锁(先读后写)
IS 锁 → IX 锁(修改部分行)

锁转换可能产生死锁:

T1: 持炎之剑的 S 锁,要求升级为 X 锁 → 等 T2 放
T2: 持炎之剑的 S 锁,要求升级为 X 锁 → 等 T1 放
 → 两人都在等对方先撒手——死锁

3. 死锁处理

死锁检测

等待图(Wait-for Graph):

  • 节点 = 事务
  • 边 Ti → Tj = Ti 在等待 Tj 持有的锁

周期性检查等待图是否有环。有环 → 选择环中的一个事务作为牺牲品回滚(选择回滚代价最小的事务:持有锁最少、执行时间最短)。

PostgreSQL 的死锁检测

PostgreSQL 有一个后台进程 deadlock_timeout(默认 1 秒)周期性地检查死锁。注意:它不是每毫秒都在检测——而是等锁等待超过 deadlock_timeout 后才触发检测。

sql
-- 查看当前死锁超时设置
SHOW deadlock_timeout;
-- 默认 1s

-- 查看被阻塞的锁
SELECT * FROM pg_stat_activity WHERE wait_event_type = 'Lock';

死锁预防

死锁检测的开销是:检测到后要回滚事务,浪费了已做的工作。死锁预防的思路是在等待发生前就避免死锁。

Wait-Die(等待-死亡)

Ti 请求 Tj 持有的锁:
 如果 Ti 比 Tj 老(Ti.TS < Tj.TS)→ Ti 等待(老等老?不,老让)
 
修正:
 如果 Ti 比 Tj 老(Ti.TS < Tj.TS)→ Ti 等待(老等)
 如果 Ti 比 Tj 年轻 → Ti 中止(die)

Wound-Wait(受伤-等待)

Ti 请求 Tj 持有的锁:
 如果 Ti 比 Tj 老(Ti.TS < Tj.TS)→ Ti 强制 Tj 中止(wound,老事务杀死年轻事务)
 如果 Ti 比 Tj 年轻 → Ti 等待(wait)
策略老事务遇到死锁怎么办年轻事务怎么办
Wait-Die死——回滚后重试
Wound-Wait杀——强制对方回滚

两者区别

  • Wait-Die 中,回滚的都是年轻事务
  • Wound-Wait 中,回滚的都是年轻事务(被老化事务杀死后回滚)

实际数据库的选择:大多数数据库使用死锁检测而非预防,因为预防策略会回滚太多事务("有嫌疑就预防"的代价大于"真发生了再解决")。

4. 乐观并发控制(OCC)

悲观方案假设"冲突很多",所以先上锁。乐观方案假设"冲突很少",所以先干,提交时再检查。

OCC 三阶段

T1 执行 
 Read Phase 
 读取需要的数据 
 记录读取的版本。
 

 Validation 
 检查是否有冲突 
 

 Write Phase 
 写入修改(本地 
 缓存到全局)

Read Phase

  • 事务读取数据,但不在数据项上加锁
  • 在事务的私有工作区(Private Workspace)里操作修改
  • 需要记录每个数据项的版本号或时间戳

Validation Phase

这是 OCC 的核心。有两种常见验证方式:

后验验证(Backward Validation):检查当前事务读取的数据是否被在它之后的已提交事务修改过。

每个事务 T 记录:

  • RS(T):读取集(读过的所有数据项)
  • WS(T):写入集(修改的所有数据项)

设事务 Ti 和 Tj(Ti 先于 Tj),检查规则:

  1. 规则 1:Tj 的 WS(Tj) ∩ Ti 的 RS(Ti) = ∅(Tj 没读 Ti 写过的东西)
  2. 规则 2:Tj 的 WS(Tj) ∩ Ti 的 WS(Ti) = ∅(没有写-写冲突)
  3. 如果 Ti 的验证在 Tj 之前,还需保证:Ti 的 WS(Ti) ∩ Tj 的 RS(Tj) = ∅(Tj 没读到 Ti 的过期数据)

串行验证(Serial Validation):验证阶段串行化(只有一个事务能进入验证阶段)。

Write Phase

验证通过后,将私有工作区的修改写入数据库。

OCC 的优缺点

优点缺点
无锁开销,低冲突场景吞吐高高冲突场景 abort 率暴增
不产生死锁验证失败浪费了大量计算(Read Phase 白干了)
读密集场景友好写 Phase 仍然需要某种锁保护(写入时其他事务不能读)

适用场景:低冲突率的读多写少场景(如 OLAP 报表、数据分析查询)。

不适用场景:高冲突率的写入密集场景(如热门物品抢领、计数器自增)。

5. MVCC 作为并发控制机制

第 12 章你已经知道了 MVCC 作为读取机制。但 MVCC 也是并发控制——它在"写-写冲突"上需要额外的机制。

MVCC + 2PL(MySQL InnoDB 的默认实现):

  • 读:从快照读,无锁
  • 写:行级 X 锁(通过 2PL 管理)
  • 更新锁(Update Lock):先读后写场景防止幻读

MVCC + OCC(Hekaton / SQL Server In-Memory):

  • 所有操作基于版本
  • 提交时检测冲突
  • 版本链提供多版本读

MVCC + 乐观锁(Optimistic Locking in Application)

  • 在应用层通过版本号实现——这实际上是 OCC 的一种简化形式
sql
-- 应用层乐观锁
UPDATE vault_items 
SET quantity = 5 version = version + 1
WHERE item_name = 'flame_sword' AND version = 1;
-- 如果 version != 1(被其他管理员修改过),影响行数为 0
-- 应用需要检查这个影响行数,如果不是 1 就重试

6. 时间戳排序(Timestamp Ordering / TO)

TO 的思路:给每个事务分配一个时间戳(T1.TS),所有操作的执行顺序由时间戳决定。

基本规则

  • 每个数据项记录 R_TS(最后读取该数据项的事务的时间戳)和 W_TS(最后写入该数据项的事务的时间戳)

  • 读操作 R(X):如果 TS(T) < W_TS(X),当前的 X 是"未来版本"——T 读到的是"不应该存在"的值,abort T。否则允许读,更新 R_TS(X) = max(R_TS(X) TS(T))

  • 写操作 W(X):如果 TS(T) < R_TS(X) 或 TS(T) < W_TS(X),abort T。否则允许写,更新 W_TS(X) = TS(T)

Thomas 写规则(优化)

当 TS(T) < W_TS(X) 时,T 的写操作是"过时的"——已经有更新的版本了。Thomas 写规则说:忽略这个写操作(不报错不 abort),因为它的效果会被后面的版本覆盖。这个优化让 TO 能接受一些冲突可串行化不能接受的调度。

Multi-version Timestamp Ordering

每个数据项维护多个版本,每个版本有 W_TS(写时间戳)。读操作总是返回"小于当前事务时间戳的最大版本"——这就做到了读不阻塞写。

这是 MVCC 的另一种实现思路(对比第 12 章的行版本链)。


深入冒险

锁观察实战

想知道谁在等谁的锁?PostgreSQL 的几个系统视图能帮你看到全貌:

sql
-- 查看当前所有锁
SELECT locktype relation::regclass mode granted pid
FROM pg_locks;

-- 查看阻塞链
SELECT blocked_locks.pid AS blocked_pid
 blocked_activity.query AS blocked_query
 blocking_locks.pid AS blocking_pid
 blocking_activity.query AS blocking_query
FROM pg_locks AS blocked_locks
JOIN pg_stat_activity AS blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_locks AS blocking_locks ON blocking_locks.locktype = blocked_locks.locktype
 AND blocking_locks.database = blocked_locks.database
 AND blocking_locks.relation = blocked_locks.relation
 AND blocking_locks.page = blocked_locks.page
 AND blocking_locks.tuple = blocked_locks.tuple
 AND blocking_locks.virtualxid = blocked_locks.virtualxid
 AND blocking_locks.transactionid = blocked_locks.transactionid
 AND blocking_locks.classid = blocked_locks.classid
 AND blocking_locks.objid = blocked_locks.objid
 AND blocking_locks.objsubid = blocked_locks.objsubid
 AND blocking_locks.pid != blocked_locks.pid
JOIN pg_stat_activity AS blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

这个查询会精确告诉你:哪个事务(PID)在等待哪个事务的什么锁。


常见陷阱

陷阱 1:混淆 2PL 与 SS2PL

2PL 允许在事务提交前释放锁,SS2PL 要求所有写锁在提交后才释放。实际数据库几乎都使用 SS2PL。说"2PL"时通常指 SS2PL。

陷阱 2:认为 OCC 总是比悲观方案快

OCC 在冲突率低时确实更快,但冲突率高时 abort 率暴增。如果冲突率 > 5%,OCC 通常不如 2PL。更糟的是,OCC 在 Validation Phase 失败时已经浪费了大量计算(Read Phase 白做了)。

陷阱 3:把意向锁当成普通锁

意向锁不阻塞常规的读/写操作——它只阻塞需要更大粒度锁的操作(比如有人要锁整表)。行级锁之间通过普通的 S/X 锁互斥。

陷阱 4:认为 MySQL InnoDB 的间隙锁是万能的

间隙锁确实阻止了幻读,但它也扩大了锁范围——可能锁住不存在的行范围,导致并发插入阻塞。这是 Repeatable Read 下一个常见性能瓶颈。


通关挑战

动手试试

  1. 死锁触发
sql
-- 管理员甲
BEGIN;
UPDATE vault_items SET quantity = 0 WHERE item_name = 'flame_sword';
-- 管理员乙
BEGIN;
UPDATE vault_items SET quantity = 0 WHERE item_name = 'frost_staff';
UPDATE vault_items SET quantity = 0 WHERE item_name = 'flame_sword'; -- 等待
-- 管理员甲
UPDATE vault_items SET quantity = 0 WHERE item_name = 'frost_staff'; -- 死锁!PostgreSQL 会自动选择一个 abort
  1. 锁观察:在触发死锁的过程中,用上述 pg_locks 查询观察锁的变化。

  2. 应用层乐观锁:给表加一个 version 字段,用 UPDATE ... WHERE version = ? 模拟 OCC。

验收标准

  • 能画出 2PL 的生长/收缩阶段图
  • 能根据意向锁兼容矩阵判断一个锁申请是否会被阻塞
  • 能区分死锁检测和死锁预防(wait-die / wound-wait)
  • 能描述 OCC 的三阶段流程
  • 能解释时间戳排序中 R_TS 和 W_TS 的作用
  • 能在 PostgreSQL 中观察和诊断锁等待

常见卡点

  • 2PL 证明:2PL 确保冲突可串行化的证明比较抽象。直觉是:2PL 强制了所有锁操作的全局偏序顺序,这个偏序就是串行化的顺序。
  • OCC 的 Validation Phase 时序:验证阶段的"原子性"是关键。如果验证和写阶段之间出现并发写入,就需要某种形式的验证阶段串行化。
  • TO 的 Thomas 写规则:只在"写-写"冲突且旧写不会影响后续读的情况下安全。如果前一个写未提交,忽略当前写可能导致不一致。

现在不需要理解

  • Multi-version Timestamp Ordering 的垃圾版本回收策略
  • 数据库并发控制的形式化证明(Serializability Theory 的完整公理系统)
  • SQL Server 的 Lock Escalation 阈值算法

旅人笔记

并发控制有三种范式:悲观(先锁后做)、乐观(先做后验)、时间(用时间戳定序)。两阶段锁(2PL/SS2PL)是悲观方案的核心——锁管理器用意向锁实现多粒度控制。死锁检测是实际系统的主流选择。乐观方案在低冲突场景表现出色,但高冲突时 abort 浪费严重。时间戳排序按"先到先得"定序,天然无死锁。


下一站预告

到这章你已经理解了数据库的并发控制机制。但所有这些锁、MVCC、冲突检测——它们都有一个隐含假设:数据在磁盘上,访问磁盘是毫秒级的。下一章的事情会彻底打破这个假设:当所有数据都在内存里时,数据库设计的整个思路都要重新考虑。

Built with VitePress | Software Systems Atlas