数据库锁机制里有两种锁总被误解:意向锁(它不锁数据,只是路标)和间隙锁(它锁的不是行,是行与行之间的空隙)。本文用一个可运行的 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 再次范围查询: 结果集不变(幻读被阻止)

四、理论分析

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 则用强锁表(锁对象 + 持有者队列)。理解锁的存储模型能解释两个现象:行锁数量级到十万级时内存与检测开销可见;死锁日志里的『等待链』就是图的边。

五、工程应用

六、常见误区

七、参考文献

  1. J. Gray, R. Lorie, G. Putzolu (1975). Granularity of Locks and Degrees of Consistency in a Shared Data Base. IFIP.(多粒度加锁与意向锁的原始论文)
  2. P. Bernstein, V. Hadzilacos, N. Goodman (1987). Concurrency Control and Recovery in Database Systems, Chapter 3. Addison-Wesley.
  3. MySQL Documentation: InnoDB Locking(Record/Gap/Next-Key/Intention Locks 的官方定义)与 performance_schema.data_locks 表说明。
  4. C. Mohan (1990). ARIES/KVL: A Key-Value Locking Method for Concurrency Control. IBM Research Report.
  5. H. Berenson et al. (1995). A Critique of ANSI SQL Isolation Levels. ACM SIGMOD.(幻读现象的经典定义)
← 返回文章列表