第13章:高级并发控制
元数据卡
维度 值 难度 (黑魔法) 前置 第11章:事务与 ACID 关键词 两阶段锁、意向锁、死锁、乐观并发控制、时间戳排序、MVCC 并发控制 代码语言 伪代码 / PostgreSQL 锁观察
你的进度
副本记录本解决了读写冲突,但还有更复杂的情况——两个管理员要修改同一件货物。一个人先锁住了货架,另一个人得排队等着。但如果两个人各自锁了不同的货架,又在等对方释放——就卡死了。这是死锁。这一章讲更精细的协作规则:两阶段锁、乐观锁、死锁调解。
你的任务
理解两阶段锁(2PL)的理论模型和实际锁管理器设计、死锁的检测与预防策略、乐观并发控制的三阶段流程、以及时间戳排序(TO)的基本原理。目标是:当你在一个数据库里看到 LOCK 或死锁日志时,能知道背后发生了什么。
破局 · 溯源
三种并发控制范式
并发控制的核心可以归结为三个问题:
- 事务在操作前要不要先获取资源控制权?
- 如果冲突发生了,让谁等待,让谁回滚?
- 执行顺序由什么决定?
| 范式 | 策略 | 代表 | 适合场景 |
|---|---|---|---|
| 悲观(Pessimistic) | 操作前先上锁 | 2PL、意向锁 | 高冲突率 |
| 乐观(Optimistic) | 操作时不锁,提交前检查冲突 | OCC | 低冲突率 |
| 时间(Time-based) | 按时间戳定顺序,冲突时 abort | TO、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,持表锁的在行上排他锁 |
锁兼容矩阵:
| 已持\请求 | IS | IX | S | SIX | X |
|---|---|---|---|---|---|
| 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 后才触发检测。
-- 查看当前死锁超时设置
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:Tj 的
WS(Tj)∩ Ti 的RS(Ti)= ∅(Tj 没读 Ti 写过的东西) - 规则 2:Tj 的
WS(Tj)∩ Ti 的WS(Ti)= ∅(没有写-写冲突) - 如果 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 的一种简化形式
-- 应用层乐观锁
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 的几个系统视图能帮你看到全貌:
-- 查看当前所有锁
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 下一个常见性能瓶颈。
通关挑战
动手试试
- 死锁触发:
-- 管理员甲
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锁观察:在触发死锁的过程中,用上述
pg_locks查询观察锁的变化。应用层乐观锁:给表加一个
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、冲突检测——它们都有一个隐含假设:数据在磁盘上,访问磁盘是毫秒级的。下一章的事情会彻底打破这个假设:当所有数据都在内存里时,数据库设计的整个思路都要重新考虑。