数据库锁机制里有两种锁总被误解:意向锁(它不锁数据,只是路标)和间隙锁(它锁的不是行,是行与行之间的空隙)。本文用一个可运行的 Python 模拟器把多粒度加锁协议、意向锁兼容矩阵、记录锁/间隙锁/next-key 锁的范围语义和防幻读过程全部演示出来,每一行输出都来自真实运行。
一、为什么需要意向锁:多粒度加锁的协调问题
数据库同时存在表锁和行锁。设想事务 A 给某一行加了行级排他锁,事务 B 此时想给全表加表级排他锁(比如 DDL)。B 怎么知道表里有没有行锁?逐行扫描是 O(N) 的,不可接受。
意向锁就是为此发明的路标:事务在拿行锁之前,先在表级挂一个意向锁——IS(我将在某行加 S 锁)或 IX(我将在某行加 X 锁)。B 只需检查表级锁状态,发现 IX 存在就知道表内有行级 X 锁,直接阻塞。意向锁不锁任何数据行,它只是多粒度锁协议中的声明机制,O(1) 解决了层级冲突检测。
二、完整可运行模拟器(Python 3,约 100 行)
#!/usr/bin/env python3
"""锁机制模拟:意向锁兼容矩阵、记录锁/间隙锁/next-key 锁、防幻读"""
# ---------- 意向锁兼容性矩阵 ----------
# 键: (已持有, 请求); True=兼容
COMPAT = {
('IS', 'IS'): True, ('IS', 'IX'): True,
('IS', 'S'): True, ('IS', 'X'): False,
('IX', 'IS'): True, ('IX', 'IX'): True,
('IX', 'S'): False, ('IX', 'X'): False,
('S', 'IS'): True, ('S', 'IX'): False,
('S', 'S'): True, ('S', 'X'): False,
('X', 'IS'): False, ('X', 'IX'): False,
('X', 'S'): False, ('X', 'X'): False,
}
print('=== 数据库锁机制模拟 ===')
print('--- 意向锁多粒度兼容性矩阵 ---')
print('已持有\\请求 IS IX S X')
for held in ['IS', 'IX', 'S', 'X']:
row = []
for req in ['IS', 'IX', 'S', 'X']:
row.append('兼容 ' if COMPAT[(held, req)] else '冲突 ')
print(' %-10s %s' % (held, ' '.join(row)))
print()
print('--- 多粒度加锁协议演示 ---')
print('事务 T1: 要更新 users 表 id=7 的行')
print(' 1. 表级加 IX(意向排他)—— 声明「我将在某行加 X 锁」')
print(' 2. 行级加 X 锁')
print('事务 T2: 想锁全表做 DDL(表级 X)')
print(' 检查表级: T1 持 IX, T2 请求 X -> %s,T2 被阻塞'
% ('兼容' if COMPAT[('IX', 'X')] else '冲突'))
print(' 无需逐行扫描就知道有行锁存在——意向锁的存在意义')
print('事务 T3: 也想更新 id=9 的行')
print(' 表级 IX vs IX -> %s,T3 顺利进入行级'
% ('兼容' if COMPAT[('IX', 'IX')] else '冲突'))
# ---------- 记录锁 / 间隙锁 / next-key 锁 ----------
print()
print('--- 记录锁 / 间隙锁 / next-key 锁范围 ---')
rows = [3, 7, 11, 19]
print('索引现有键: %s' % rows)
def lock_range(lock_type, target):
prev = max([k for k in rows if k < target], default='-∞')
nxt = min([k for k in rows if k > target], default='+∞')
if lock_type == 'record':
return '[%d] 仅该行本身' % target
if lock_type == 'gap':
return '(%s, %s) 开区间,不含端点' % (prev, nxt)
if lock_type == 'next-key':
return '(%s, %d] 左开右闭区间' % (prev, target)
print('记录锁 on 7: %s' % lock_range('record', 7))
print('间隙锁 on 7: %s' % lock_range('gap', 7))
print('next-key 锁 on 7: %s' % lock_range('next-key', 7))
# ---------- 间隙锁防幻读 ----------
print()
print('--- 间隙锁防幻读(可重复读级别) ---')
index = sorted(rows)
def try_insert(val):
if val in index:
return '主键冲突'
if 3 < val <= 7:
return '插入被 next-key 锁 (3,7] 阻塞 <-- 防幻读'
index.append(val)
index.sort()
return '插入成功'
print('事务 A: SELECT WHERE id BETWEEN 4 AND 7 FOR UPDATE'
' -> 锁住 (3,7]')
print('事务 B: INSERT id=5 -> %s' % try_insert(5))
print('事务 B: INSERT id=8 -> %s' % try_insert(8))
print('事务 B: INSERT id=3 -> %s' % try_insert(3))
print('事务 A 再次范围查询: 结果集不变(幻读被阻止)')
三、真实运行输出与解读
以上代码在 Python 3 下原样运行(零依赖),输出如下:
=== 数据库锁机制模拟 ===
--- 意向锁多粒度兼容性矩阵 ---
已持有\请求 IS IX S X
IS 兼容 兼容 兼容 冲突
IX 兼容 兼容 冲突 冲突
S 兼容 冲突 兼容 冲突
X 冲突 冲突 冲突 冲突
--- 多粒度加锁协议演示 ---
事务 T1: 要更新 users 表 id=7 的行
1. 表级加 IX(意向排他)—— 声明「我将在某行加 X 锁」
2. 行级加 X 锁
事务 T2: 想锁全表做 DDL(表级 X)
检查表级: T1 持 IX, T2 请求 X -> 冲突,T2 被阻塞
无需逐行扫描就知道有行锁存在——意向锁的存在意义
事务 T3: 也想更新 id=9 的行
表级 IX vs IX -> 兼容,T3 顺利进入行级
--- 记录锁 / 间隙锁 / next-key 锁范围 ---
索引现有键: [3, 7, 11, 19]
记录锁 on 7: [7] 仅该行本身
间隙锁 on 7: (3, 11) 开区间,不含端点
next-key 锁 on 7: (3, 7] 左开右闭区间
--- 间隙锁防幻读(可重复读级别) ---
事务 A: SELECT WHERE id BETWEEN 4 AND 7 FOR UPDATE -> 锁住 (3,7]
事务 B: INSERT id=5 -> 插入被 next-key 锁 (3,7] 阻塞 <-- 防幻读
事务 B: INSERT id=8 -> 插入成功
事务 B: INSERT id=3 -> 主键冲突
事务 A 再次范围查询: 结果集不变(幻读被阻止)
- 兼容矩阵的两条铁律:IX 与 IX 兼容(两个事务可以同表更新不同行),X 与一切冲突(DDL 排他)。看矩阵第一行:IS 甚至兼容 S——声明『我要读某行』不妨碍别人拿表级读锁。整个矩阵的设计目标是让表级检查在 O(1) 内完成,同时不引入任何不必要的互斥。
- 三种锁的范围对比一目了然:以键 7 为目标,记录锁只管 [7] 本身;间隙锁管 (3, 11) 整个开区间但不含 7;next-key 锁管 (3, 7]——间隙加本行。InnoDB 在可重复读级别下默认用 next-key 锁,就是把『行存在性』和『区间完整性』打包保护。
- 防幻读的精确边界:A 锁住 (3,7] 后,B 插 id=5 被阻塞(落入区间),id=8 成功(区间外),id=3 撞上主键冲突(那是唯一性检查管的)。注意 id=7 本身也在锁内——左开右闭的右闭端点保证了边界行不可被动手脚。幻读的『幻』是新插入的行,间隙锁锁的正是『可以插入新行的空隙』。
四、理论分析
4.1 意向锁是多粒度锁协议的通行证
多粒度加锁(MGL)由 Gray 等人在 1975 年系统阐述:锁可以挂在数据库、表、页、行等不同粒度上,规则是『给下级节点加锁前,必须先给上级挂对应的意向锁』。这样任意层级的锁请求只需检查从根到目标路径上的意向声明。封锁粒度越细并发越高但管理开销越大,意向锁让粗细粒度得以共存——这是数据库锁系统的结构性基础,SQL Server 的锁层级(database→table→page→row)是同一协议的直接实现。
4.2 间隙锁只在特定条件下生效
间隙锁是 InnoDB 在可重复读级别 + 当前读下的产物,有三条重要的退化和边界规则:唯一索引上的等值查询命中存在的行时,next-key 锁退化为记录锁(无需防幻读——唯一约束天然保证);读已提交(RC)级别下间隙锁基本不启用(仅外键检查等少数场景保留);基于 MVCC 的快照读(普通 SELECT)完全不加锁,上一篇 MVCC 文章已详述。『间隙锁导致死锁频发』的事故几乎都发生在默认 RR 级别下的范围更新。
4.3 锁的存储与等待图
InnoDB 的锁不是每行一个内存对象,而是按『事务 × 页』聚合的位图结构(lock_sys),死锁检测用等待图(wait-for graph)找环。PostgreSQL 则用强锁表(锁对象 + 持有者队列)。理解锁的存储模型能解释两个现象:行锁数量级到十万级时内存与检测开销可见;死锁日志里的『等待链』就是图的边。
五、工程应用
- 观测锁状态:MySQL 8 用 performance_schema.data_locks(取代旧的 information_schema.innodb_locks)查看锁类型(LOCK_TYPE 列的 RECORD/GAP/ Next-Key 在 LOCK_MODE 中体现为 X / X,GAP / X,REC_NOT_GAP);data_lock_waits 看等待关系。
- 死锁排查:SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 段给出完整等待环;两个事务以相反顺序锁相同资源是经典成因,应用层统一加锁顺序是治本之策。
- 规避间隙锁争议:热点更新场景把隔离级别降到 RC(binlog 需配 row 格式)可消除大部分间隙锁死锁;范围更新的 SQL 尽量改写成基于主键的等值更新。
- DDL 与行锁的相互感知:理解了意向锁就明白为什么大表 ALTER 会等行锁、长事务会卡 DDL——MySQL 8 的 INSTANT ADD COLUMN 正是为了绕开这条链。
六、常见误区
- 误区 1:『意向锁会阻塞行级操作』。恰恰相反:IX 与 IX 兼容,意向锁之间几乎不互斥,它只是给表级锁请求留的路标。它阻塞的是表级 S/X,不是其他行操作。
- 误区 2:『间隙锁锁的是某一行』。间隙锁锁的是开区间——一堆『不存在的数据可能插入的空隙』。这就是为什么范围更新会阻塞看似无关的 INSERT。
- 误区 3:『next-key 锁是左闭右开』。是左开右闭 (prev, cur]:保护当前行和它与前一键之间的空隙。方向记反会推导出错误的阻塞结论。
- 误区 4:『RC 级别也有全套间隙锁』。RC 下间隙锁基本退场(这也是很多团队用 RC 降死锁的原因),防幻读职责部分让渡给应用逻辑。级别切换前先评估业务对幻读的容忍度。
- 误区 5:『SELECT 读的锁和普通写锁一回事』。普通 SELECT 是 MVCC 快照读不加锁(见本专栏 MVCC 一文);只有 SELECT ... FOR UPDATE / FOR SHARE 才走本文的锁路径。混为一谈会误判锁竞争来源。
七、参考文献
- J. Gray, R. Lorie, G. Putzolu (1975). Granularity of Locks and Degrees of Consistency in a Shared Data Base. IFIP.(多粒度加锁与意向锁的原始论文)
- P. Bernstein, V. Hadzilacos, N. Goodman (1987). Concurrency Control and Recovery in Database Systems, Chapter 3. Addison-Wesley.
- MySQL Documentation: InnoDB Locking(Record/Gap/Next-Key/Intention Locks 的官方定义)与 performance_schema.data_locks 表说明。
- C. Mohan (1990). ARIES/KVL: A Key-Value Locking Method for Concurrency Control. IBM Research Report.
- H. Berenson et al. (1995). A Critique of ANSI SQL Isolation Levels. ACM SIGMOD.(幻读现象的经典定义)