InnoDB 行锁三件套:记录锁、间隙锁、临键锁
一句话结论(30s)
临键锁 = 记录锁 + 左侧间隙锁(前开后闭区间 (prev, cur]),是 RR 隔离级别防幻读的主力。关键设计是它的退化规则:等值查询 + 唯一索引时,记录存在就退化为记录锁以释放间隙提升并发,记录不存在就退化为间隙锁以防幻插。权衡在于:范围查询或普通索引不退化、保持临键锁,因为此时无法精确锁定目标行,必须锁住区间防止新行插入。
核心原理(2min)
InnoDB 在 RR 下的行锁有三种形态:记录锁锁具体一行、间隙锁锁两行之间的空隙防止 INSERT、临键锁 = 记录锁 + 左侧间隙锁。思考:next-key 锁为什么会退化? 临键锁同时锁”行 + 左侧间隙”,是为了在拿不准目标位置时堵住幻插;一旦”等值查询 + 唯一索引”能精确定位目标行,锁间隙就失去了防幻插的意义,反而拖累并发插入——于是退化规则用”锁得准不准”来决定要不要释放间隙。退化只发生在”等值查询 + 唯一索引”:WHERE id = 10 FOR UPDATE 且 id=10 存在时,已精确找到目标行、无需锁间隙防幻插,退化为记录锁;WHERE id = 15 且 15 不存在时,无行可锁、退化为 (10, 20) 间隙锁,防止其他事务插入 id=15。范围查询或普通索引等值查询则不退化,保持临键锁并追加间隙锁扫尾。这套退化机制在保证防幻读语义的同时,释放了”已精确命中”场景下不必要的间隙锁,从而提升并发插入吞吐。
底层深入(5-10min)
锁类型枚举:三种形态的位定义
InnoDB 的三种锁形态不是独立的 enum,而是叠加在锁强度(LOCK_S/LOCK_X)之上的标志位(bitmask),定义在 include/lock0lock.h:
/** Waiting lock flag; when set, it means that the lock has not yet been
granted, it is just waiting for its turn in the wait queue */
constexpr uint32_t LOCK_WAIT = 256;
/* Precise modes */
/** this flag denotes an ordinary next-key lock in contrast to LOCK_GAP or
LOCK_REC_NOT_GAP */
constexpr uint32_t LOCK_ORDINARY = 0;
/** when this bit is set, it means that the lock holds only on the gap before
the record; for instance, an x-lock on the gap does not give permission to
modify the record on which the bit is set; locks of this type are created
when records are removed from the index chain of records */
constexpr uint32_t LOCK_GAP = 512;
/** this bit means that the lock is only on the index record and does NOT
block inserts to the gap before the index record; this is used in the case
when we retrieve a record with a unique key, and is also used in locking
plain SELECTs (not part of UPDATE or DELETE) when the user has set the READ
COMMITTED isolation level */
constexpr uint32_t LOCK_REC_NOT_GAP = 1024;
/** this bit is set when we place a waiting gap type record lock request in
order to let an insert of an index record to wait until there are no
conflicting locks by other transactions on the gap; note that this flag
remains set when the waiting lock is granted, or if the lock is inherited to
a neighboring record */
constexpr uint32_t LOCK_INSERT_INTENTION = 2048;
分析:LOCK_ORDINARY = 0 就是”临键锁”(记录 + 左侧间隙)——它没有额外标志位,是默认形态;LOCK_GAP 只锁左侧间隙;LOCK_REC_NOT_GAP 只锁记录本身、不锁间隙。三者是互斥的精确模式(precise modes),由调用方在 gap_mode 里三选一,最终叠加到 mode 上传给 lock_rec_lock。
加锁入口:lock_rec_lock
所有记录锁最终都收敛到 lock_rec_lock(lock/lock0lock.cc)。它先走快路径 lock_rec_lock_fast,失败再走慢路径 lock_rec_lock_slow:
static dberr_t lock_rec_lock(bool impl, select_mode sel_mode, ulint mode,
const buf_block_t *block, ulint heap_no,
dict_index_t *index, que_thr_t *thr) {
ut_ad(locksys::owns_page_shard(block->get_page_id()));
ut_ad(!srv_read_only_mode);
ut_ad((LOCK_MODE_MASK & mode) != LOCK_S ||
lock_table_has(thr_get_trx(thr), index->table, LOCK_IS));
ut_ad((LOCK_MODE_MASK & mode) != LOCK_X ||
lock_table_has(thr_get_trx(thr), index->table, LOCK_IX));
ut_ad((LOCK_MODE_MASK & mode) == LOCK_S || (LOCK_MODE_MASK & mode) == LOCK_X);
ut_ad(mode - (LOCK_MODE_MASK & mode) == LOCK_GAP ||
mode - (LOCK_MODE_MASK & mode) == LOCK_REC_NOT_GAP ||
mode - (LOCK_MODE_MASK & mode) == 0);
ut_ad(index->is_clustered() || !dict_index_is_online_ddl(index));
/* Implicit locks are equivalent to LOCK_X|LOCK_REC_NOT_GAP, so we can omit
creation of explicit lock only if the requested mode was LOCK_REC_NOT_GAP */
ut_ad(!impl || ((mode & LOCK_REC_NOT_GAP) == LOCK_REC_NOT_GAP));
/* We try a simplified and faster subroutine for the most
common cases */
switch (lock_rec_lock_fast(impl, mode, block, heap_no, index, thr)) {
case LOCK_REC_SUCCESS:
return (DB_SUCCESS);
case LOCK_REC_SUCCESS_CREATED:
return (DB_SUCCESS_LOCKED_REC);
case LOCK_REC_FAIL:
return (
lock_rec_lock_slow(impl, sel_mode, mode, block, heap_no, index, thr));
default:
ut_error;
}
}
分析:注意倒数第二个 ut_ad 断言——mode 去掉锁强度(LOCK_MODE_MASK,即 S/X)后,只能是 LOCK_GAP、LOCK_REC_NOT_GAP 或 0(LOCK_ORDINARY)。这说明”临键锁 / 间隙锁 / 记录锁”三选一在进入本函数之前就已定好,lock_rec_lock 本身不做退化决策,退化决策发生在调用方(row0sel.cc)。
退化前提:unique_search 判定
真正的退化逻辑在 row/row0sel.cc 的 row_search_mvcc。它先判断本次查询是否为”唯一索引精确等值”:
if (match_mode == ROW_SEL_EXACT && dict_index_is_unique(index) &&
dtuple_get_n_fields(search_tuple) == dict_index_get_n_unique(index) &&
(index->is_clustered() || !dtuple_contains_null(search_tuple))) {
/* Note above that a UNIQUE secondary index can contain many
rows with the same key value if one of the columns is the SQL
null. A clustered index under MySQL can never contain null
columns because we demand that all the columns in primary key
are non-null. */
unique_search = true;
分析:只有”等值匹配 + 唯一索引 + 查询字段数等于唯一索引字段数 + 非空”时,unique_search 才为 true。这正是退化规则的前提——等值查询 + 唯一索引;普通索引或范围查询不会走到这里。
思考:为什么退化判断被限定在”等值 + 唯一索引”? 因为退化释放的是”防幻插的间隙”,只有当查询能精确锁定唯一目标位置、不存在”同值多行”的歧义时,才敢放心释放;普通索引同一个 key 可能对应多行、范围查询连目标都不唯一,自然不能退化。
退化核心:row_compare_row_to_range
拿到 unique_search 后,每读到一行就用 row_compare_row_to_range 判断该行与扫描区间的关系,关键判断是:
if (!set_also_gap_locks || trx->skip_gap_locks() ||
(unique_search && !rec_get_deleted_flag(rec, comp)) ||
dict_index_is_spatial(index) ||
(index == clust_index && mode == PAGE_CUR_GE && direction == 0 &&
dtuple_get_n_fields_cmp(search_tuple) ==
dict_index_get_n_unique(index) &&
0 == cmp_dtuple_rec(search_tuple, rec, index, offsets))) {
row_to_range_relation.gap_can_intersect_range = false;
return (row_to_range_relation);
}
分析:这里 (unique_search && !rec_get_deleted_flag(rec, comp)) 是”退化 1(记录锁退化)“的源码依据——唯一搜索且命中未删除记录时,直接把 gap_can_intersect_range 置为 false,表示”间隙不会与区间相交”,于是后面不会再锁间隙。
思考:两个布尔量怎么就能推出三种锁类型? row_can_be_in_range 回答”行是不是目标”、gap_can_intersect_range 回答”间隙要不要一起锁”,两者组合恰好穷尽三种情况:行在区间 + 锁间隙 = 临键锁、行在区间 + 不锁间隙 = 记录锁、行不在区间 + 锁间隙 = 间隙锁。
锁类型三选一:记录锁 / 间隙锁 / 临键锁
最终根据 row_to_range_relation 的两个布尔量(row_can_be_in_range、gap_can_intersect_range)决定 lock_type:
ulint lock_type;
if (row_to_range_relation.row_can_be_in_range) {
if (row_to_range_relation.gap_can_intersect_range) {
lock_type = LOCK_ORDINARY;
} else {
lock_type = LOCK_REC_NOT_GAP;
}
} else {
if (row_to_range_relation.gap_can_intersect_range) {
lock_type = LOCK_GAP;
} else {
err = DB_RECORD_NOT_FOUND;
goto normal_return;
}
}
分析:这是退化规则的最终落地。行在区间内 + 间隙相交 → LOCK_ORDINARY(临键锁,对应范围查询不退化);行在区间内 + 间隙不相交 → LOCK_REC_NOT_GAP(记录锁,对应”唯一等值命中”的退化 1);行不在区间内 + 间隙相交 → LOCK_GAP(间隙锁,对应”唯一等值未命中”的退化 2)。锁类型定好后交给 sel_set_rec_lock,最终传入上面的 lock_rec_lock 完成加锁。
三种锁的形态
记录锁 (Record Lock): 锁住索引中的具体一行
间隙锁 (Gap Lock): 锁住两行之间的间隙(不包含行本身),防止 INSERT
临键锁 (Next-Key Lock): 记录锁 + 左侧间隙锁,前开后闭区间 (prev, cur]
默认使用,RR 隔离级别下防幻读的主力
临键锁 = 记录锁 + 前一个间隙锁,区间是 (prev_record, current_record]。它既锁住了当前行(别人不能修改),也锁住了这行与上一行之间的间隙(别人不能在这个间隙插入)。
临键锁的退化规则
正确理解退化规则,是写出高并发安全 SQL 的核心:
退化 1:记录锁退化
条件:等值查询 + 唯一索引 + 记录存在。
SELECT * FROM t WHERE id = 10 FOR UPDATE; -- id=10 存在,主键唯一索引
-- 锁定:只有 id=10 这一行(记录锁)
-- 不锁间隙:(9,10) 和 (10, 20) 都不锁
-- 意义:已精确找到目标行,不需要锁间隙来"防止幻插"
退化 2:间隙锁退化
条件:等值查询 + 唯一索引 + 记录不存在。
SELECT * FROM t WHERE id = 15 FOR UPDATE; -- id=15 不存在,15 在 (10, 20) 之间
-- 锁定:间隙 (10, 20)
-- 意义:无行可锁,但需锁住目标 key 周围的间隙,防止其他事务插入 id=15
不退化:保持临键锁
条件:范围查询 + 普通索引或唯一索引。
SELECT * FROM t WHERE id > 10 AND id < 20 FOR UPDATE;
-- 锁定:(10, 15] + (15, 20](临键锁)+ (20, 25)(间隙锁)
-- 防止范围内新行的插入(幻读)
实验验证
-- 准备数据: id = 1, 10, 20, 30
-- 事务A:
BEGIN;
SELECT * FROM t WHERE id = 10 FOR UPDATE; -- 记录存在 → 记录锁
-- 事务B:
INSERT INTO t VALUES (9); -- ✓ 不阻塞(间隙未锁)
INSERT INTO t VALUES (11); -- ✓ 不阻塞(间隙未锁)
UPDATE t SET val='x' WHERE id=10; -- ✗ 阻塞(记录锁住了这一行)
-- 事务A:
SELECT * FROM t WHERE id = 15 FOR UPDATE; -- 记录不存在 → 间隙锁 (10, 20)
-- 事务B:
INSERT INTO t VALUES (12); -- ✗ 阻塞(在锁的间隙内)
INSERT INTO t VALUES (21); -- ✓ 不阻塞(不在间隙内)
为什么退化机制很重要
如果临键锁从来不退化——每次查询都锁住前一个间隙 + 行本身 + 后一个间隙——并发插入性能会受到不必要的影响。退化让”已经精确找到了目标行”的场景释放不必要的间隙锁,提升并发插入的吞吐。
总结
| 查询类型 | 索引类型 | 记录存在? | 最终锁类型 |
|---|---|---|---|
| 等值 | 唯一索引 | 存在 | 记录锁 |
| 等值 | 唯一索引 | 不存在 | 间隙锁 |
| 范围 | 唯一/普通 | — | 临键锁(+间隙锁扫尾) |
| 等值 | 普通索引 | 存在/不存在 | 临键锁 |
退化规则的关键词:等值查询 + 唯一索引。这是唯二能触发退化的条件,也是写出精准锁范围 SQL 的前提。
章末提问
1. 临键锁和记录锁、间隙锁的区别是什么?
结论先行:临键锁 = 记录锁 + 左侧间隙锁,锁区间 (prev, cur]。因为记录锁只锁具体一行、间隙锁只锁两行之间的空隙,临键锁两者都锁,是 RR 防幻读的主力。
2. 什么条件下临键锁会退化成记录锁? 结论先行:等值查询 + 唯一索引 + 记录存在。因为此时已精确命中目标行,无需锁间隙防幻插,释放间隙反而能提升并发插入。
3. 等值查询 + 唯一索引但记录不存在,锁的是什么? 结论先行:退化为间隙锁,锁住目标 key 两侧的间隙。因为无行可锁,但必须堵住这个 key 的插入位置,防止别的事务插进一个”本该不存在”的行造成幻读。
4. 范围查询为什么不退化? 结论先行:因为范围查询锁的是一个区间而不是一个精确点,区间内任何位置都可能被插入新行。因为无法确定唯一目标行,只能锁住整个范围来防幻插。
5. 普通索引的等值查询为什么也不退化? 结论先行:因为普通索引同一个 key 可能对应多行,命中一行不代表没有其他同值行。因为还有别的同索引值存在,必须保持临键锁,防止同值区间被插入新行。