定义
间隙锁 (Gap Lock) 是 InnoDB 存储引擎在 REPEATABLE READ 隔离级别下使用的一种锁机制。它锁定索引记录之间的"间隙",或者锁定第一个记录之前、最后一个记录之后的间隙,目的是防止其他事务在该间隙中插入新记录,从而避免幻读(Phantom Read)现象。
Next-Key Lock = 记录锁(Record Lock) + 间隙锁(Gap Lock),是 InnoDB 默认的锁算法。
详细笔记
核心原理
为什么需要间隙锁?
sql
-- 场景:防止幻读
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50)
);
INSERT INTO users VALUES (1, 'Alice'), (3, 'Charlie'), (5, 'Eve');
-- 事务 A
START TRANSACTION;
SELECT * FROM users WHERE id > 2 AND id < 6 FOR UPDATE;
-- 读到: id=3 (Charlie), id=5 (Eve)
-- 锁定范围: (1, 3), (3, 5), (5, +∞) 的间隙
-- 事务 B(尝试插入)
INSERT INTO users VALUES (4, 'David'); -- ❌ 被阻塞!
-- 因为 id=4 落在事务 A 锁定的间隙 (3, 5) 中
-- 事务 A 再次查询
SELECT * FROM users WHERE id > 2 AND id < 6 FOR UPDATE;
-- 仍然读到: id=3, id=5
-- ✅ 没有幻读!如果没有间隙锁:
事务 A: SELECT ... WHERE id > 2 AND id < 6 FOR UPDATE
→ 读到: id=3, id=5
事务 B: INSERT INTO users VALUES (4, 'David')
→ 成功插入!
事务 A: SELECT ... WHERE id > 2 AND id < 6 FOR UPDATE
→ 读到: id=3, id=4, id=5 ❌ 幻读!多了一条记录锁的类型
InnoDB 的三种行级锁
1. 记录锁 (Record Lock)
锁定单个索引记录
示例:
UPDATE users SET name = 'New' WHERE id = 3 FOR UPDATE;
→ 锁定 id=3 这条记录2. 间隙锁 (Gap Lock)
锁定索引记录之间的间隙,不锁定记录本身
示例:
SELECT * FROM users WHERE id = 4 FOR UPDATE; -- id=4 不存在
→ 锁定间隙 (3, 5)
→ 阻止其他事务插入 id=43. Next-Key Lock
记录锁 + 间隙锁
锁定一个范围,包括记录本身和前面的间隙
示例:
SELECT * FROM users WHERE id >= 3 AND id <= 5 FOR UPDATE;
→ 锁定:
- 记录: id=3, id=5
- 间隙: (1, 3), (3, 5), (5, +∞)间隙锁的范围
确定锁定范围
sql
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50)
);
INSERT INTO users VALUES (1, 'A'), (3, 'C'), (5, 'E'), (10, 'J');示例一:等值查询 (记录存在)
sql
SELECT * FROM users WHERE id = 5 FOR UPDATE;
-- 锁定范围:
-- Next-Key Lock: (3, 5]
-- - 记录锁: id=5
-- - 间隙锁: (3, 5)示例二:等值查询 (记录不存在)
sql
SELECT * FROM users WHERE id = 4 FOR UPDATE;
-- 锁定范围:
-- Gap Lock: (3, 5)
-- - 纯间隙锁
-- - 阻止插入 id=4示例三:范围查询
sql
SELECT * FROM users WHERE id > 3 AND id < 10 FOR UPDATE;
-- 锁定范围:
-- - 记录锁: id=5
-- - 间隙锁: (3, 5), (5, 10)
-- - Next-Key Lock: (3, 10)示例四:唯一索引 vs 普通索引
sql
-- 唯一索引
CREATE UNIQUE INDEX uk_name ON users(name);
SELECT * FROM users WHERE name = 'C' FOR UPDATE;
-- 因为是唯一索引且记录存在
-- 只加记录锁,不加间隙锁
-- 锁定: name='C' 这条记录
-- 普通索引
CREATE INDEX idx_name ON users(name);
SELECT * FROM users WHERE name = 'C' FOR UPDATE;
-- 普通索引即使记录存在
-- 也加 Next-Key Lock
-- 锁定: 记录 + 前后间隙源码分析
InnoDB 锁的实现
cpp
// storage/innobase/lock/lock0lock.cc
/**
* 添加 Next-Key Lock
*
* @param trx 事务
* @param index 索引
* @param tuple 搜索元组
* @param mode 锁模式(X/S)
*/
void lock_rec_add_next_key_lock(
trx_t* trx,
dict_index_t* index,
const dtuple_t* tuple,
enum lock_mode mode)
{
// 1. 定位记录
rec_t* rec = index_search_tuple(index, tuple);
if (rec != NULL) {
// 记录存在
// 2. 添加记录锁
lock_rec_add(LOCK_REC | mode, trx, index, rec);
// 3. 添加前向间隙锁
lock_gap_add(LOCK_GAP | mode, trx, index, rec);
} else {
// 记录不存在
// 4. 只添加间隙锁
lock_gap_add(LOCK_GAP | mode, trx, index, get_supremum_rec());
}
}
/**
* 检查是否与现有锁冲突
*/
bool lock_has_conflicts(
const lock_t* new_lock,
const lock_t* existing_lock)
{
// X 锁与任何锁都冲突
if (new_lock->mode == LOCK_X || existing_lock->mode == LOCK_X) {
return true;
}
// S 锁与 S 锁不冲突
if (new_lock->mode == LOCK_S && existing_lock->mode == LOCK_S) {
return false;
}
// 间隙锁之间不冲突!(关键!)
if (is_gap_lock(new_lock) && is_gap_lock(existing_lock)) {
return false; // 允许多个事务同时持有间隙锁
}
return true;
}关键点:
- 间隙锁之间不冲突,多个事务可以同时锁定同一个间隙
- 但间隙锁会阻止 INSERT 操作
- 这是为了防止多个事务同时插入导致的不一致
间隙锁的特性
特性一:间隙锁之间不冲突
sql
-- 事务 A
START TRANSACTION;
SELECT * FROM users WHERE id = 4 FOR UPDATE; -- 锁定间隙 (3, 5)
-- 事务 B(并发)
START TRANSACTION;
SELECT * FROM users WHERE id = 4 FOR UPDATE; -- 也锁定间隙 (3, 5)
-- ✅ 成功!间隙锁不冲突
-- 事务 C(尝试插入)
INSERT INTO users VALUES (4, 'David');
-- ❌ 被阻塞!等待事务 A 或 B 释放间隙锁原因:
- 如果间隙锁之间冲突,会导致大量不必要的阻塞
- 间隙锁的目的是阻止 INSERT,而不是阻止其他 SELECT ... FOR UPDATE
特性二:只在 RR 级别使用
sql
-- READ COMMITTED 级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT * FROM users WHERE id > 3 AND id < 10 FOR UPDATE;
-- 只加记录锁,不加间隙锁
-- 允许其他事务插入
-- REPEATABLE READ 级别(默认)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM users WHERE id > 3 AND id < 10 FOR UPDATE;
-- 加 Next-Key Lock(记录锁 + 间隙锁)
-- 阻止其他事务插入MySQL 8.0.13+ 优化:
- 如果能确定查询只会返回一行(如主键等值查询)
- 即使是 RR 级别,也只加记录锁,不加间隙锁
特性三:外键检查使用间隙锁
sql
CREATE TABLE departments (
dept_id INT PRIMARY KEY,
dept_name VARCHAR(50)
);
CREATE TABLE employees (
emp_id INT PRIMARY KEY,
dept_id INT,
FOREIGN KEY (dept_id) REFERENCES departments(dept_id)
);
-- 插入员工时,InnoDB 会在 departments 表上加共享间隙锁
INSERT INTO employees VALUES (1, 10);
-- 锁定:departments 表中 dept_id=10 附近的间隙
-- 防止其他事务删除 dept_id=10 的部门间隙锁导致的死锁
典型死锁场景
sql
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50)
);
INSERT INTO users VALUES (1, 'A'), (5, 'E'), (10, 'J');
-- 事务 A
START TRANSACTION;
SELECT * FROM users WHERE id = 7 FOR UPDATE; -- 锁定间隙 (5, 10)
-- 事务 B
START TRANSACTION;
SELECT * FROM users WHERE id = 8 FOR UPDATE; -- 锁定间隙 (5, 10)
-- ✅ 成功!间隙锁不冲突
-- 事务 A
INSERT INTO users VALUES (7, 'G'); -- 等待事务 B 释放间隙锁
-- 事务 B
INSERT INTO users VALUES (8, 'H'); -- 等待事务 A 释放间隙锁
-- ❌ 死锁!原因分析:
1. 事务 A 锁定间隙 (5, 10)
2. 事务 B 也锁定间隙 (5, 10) ← 间隙锁不冲突,允许
3. 事务 A 尝试插入 id=7
→ 需要独占间隙 (5, 10)
→ 等待事务 B 释放
4. 事务 B 尝试插入 id=8
→ 需要独占间隙 (5, 10)
→ 等待事务 A 释放
5. 循环等待 → 死锁!解决方案:
sql
-- 方案一:使用 RC 级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- RC 级别不加间隙锁,不会因此死锁
-- 方案二:先插入再查询
START TRANSACTION;
INSERT INTO users VALUES (7, 'G'); -- 直接插入
SELECT * FROM users WHERE id = 7 FOR UPDATE; -- 锁定记录
COMMIT;
-- 方案三:应用层重试
try {
execute_transaction();
} catch (DeadlockException e) {
retry(); // 重试
}监控间隙锁
查看锁信息
sql
-- MySQL 8.0+
SELECT
lock_id,
lock_trx_id,
lock_mode,
lock_type,
lock_table,
lock_index,
lock_space,
lock_page,
lock_rec,
lock_data
FROM performance_schema.data_locks;
-- 输出示例:
-- +---------+-------------+-----------+-----------+------------+------------+-----------+----------+-----------+-----------+
-- | lock_id | lock_trx_id | lock_mode | lock_type | lock_table | lock_index | lock_space| lock_page| lock_rec | lock_data |
-- +---------+-------------+-----------+-----------+------------+------------+-----------+----------+-----------+-----------+
-- | ... | 12345 | X,GAP | RECORD | test.users | PRIMARY | 5 | 3 | 0 | 4 |
-- +---------+-------------+-----------+-----------+------------+------------+-----------+----------+-----------+-----------+
-- lock_mode: X,GAP 表示排他间隙锁SHOW ENGINE INNODB STATUS
sql
SHOW ENGINE INNODB STATUS\G
-- 输出包含:
-- ------------
-- TRANSACTIONS
-- ------------
-- Trx id counter 12345
-- Purge done for trx's n:o < 12340
--
-- LIST OF TRANSACTIONS FOR EACH SESSION:
-- ---TRANSACTION 12345, ACTIVE 5 sec
-- 2 lock struct(s), heap size 1136, 1 row lock(s)
-- MySQL thread id 100, OS thread handle 1234, query id 5678 localhost root
-- TABLE LOCK table `test`.`users` trx id 12345 lock mode IX
-- RECORD LOCKS space id 5 page no 3 n bits 72 index PRIMARY of table `test`.`users` trx id 12345 lock_mode X locks gap before rec
-- Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
-- 0: len 4; hex 80000005; asc ;; -- id=5
-- 1: len 6; hex ...; asc ;;
-- 2: len 7; hex ...; asc ;;
-- 3: len 1; hex 45; asc E;; -- name='E'
-- lock_mode X locks gap before rec: 间隙锁优化间隙锁的策略
策略一:使用 RC 隔离级别
sql
-- 如果业务可以接受幻读
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 优势:
-- - 无间隙锁
-- - 减少死锁
-- - 提高并发
-- 劣势:
-- - 可能幻读
-- - 某些场景数据不一致策略二:精确查询避免范围锁
sql
-- ❌ 范围查询,锁定大范围
SELECT * FROM users WHERE id > 3 AND id < 10 FOR UPDATE;
-- 锁定: (3, 10) 的大范围
-- ✅ 等值查询,只锁定单行
SELECT * FROM users WHERE id = 5 FOR UPDATE;
-- 只锁定: id=5 (如果是唯一索引,无间隙锁)策略三:批量插入排序
sql
-- ❌ 无序插入,可能与现有间隙锁冲突
INSERT INTO users VALUES (7, 'G');
INSERT INTO users VALUES (4, 'D');
INSERT INTO users VALUES (9, 'I');
-- ✅ 排序后插入,减少冲突
INSERT INTO users VALUES (4, 'D');
INSERT INTO users VALUES (7, 'G');
INSERT INTO users VALUES (9, 'I');策略四:避免在事务中查询不存在的记录
sql
-- ❌ 查询不存在的记录,加间隙锁
SELECT * FROM users WHERE id = 4 FOR UPDATE; -- id=4 不存在
-- 锁定间隙 (3, 5)
-- ✅ 先检查是否存在
IF EXISTS (SELECT 1 FROM users WHERE id = 4) THEN
SELECT * FROM users WHERE id = 4 FOR UPDATE;
ELSE
-- 处理不存在的情况
END IF;实际案例
案例一:订单系统幻读问题
sql
-- 问题:统计订单数量不一致
-- 事务 A
START TRANSACTION;
SELECT COUNT(*) FROM orders WHERE user_id = 100 AND status = 0;
-- 结果: 5
-- 事务 B
INSERT INTO orders (user_id, status, amount) VALUES (100, 0, 299.00);
COMMIT;
-- 事务 A
SELECT COUNT(*) FROM orders WHERE user_id = 100 AND status = 0;
-- 结果: 6 ❌ 幻读!
-- 解决方案:使用 FOR UPDATE
START TRANSACTION;
SELECT COUNT(*) FROM orders WHERE user_id = 100 AND status = 0 FOR UPDATE;
-- 加 Next-Key Lock,阻止事务 B 插入
-- 结果: 5
-- ... 业务逻辑 ...
COMMIT;案例二:库存扣减的死锁优化
sql
-- 问题:高峰期频繁死锁
-- 原始代码
START TRANSACTION;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- 如果 stock > 0
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;
-- 问题:
-- 多个事务同时 SELECT ... FOR UPDATE
-- 虽然是同一行,但可能因间隙锁导致死锁
-- 优化:直接 UPDATE
START TRANSACTION;
UPDATE products SET stock = stock - 1
WHERE id = 1 AND stock > 0;
-- 检查影响行数
IF ROW_COUNT() = 0 THEN
ROLLBACK;
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '库存不足';
END IF;
COMMIT;
-- 优势:
-- - 单次操作,减少锁竞争
-- - 原子性保证
-- - 降低死锁概率最佳实践
理解间隙锁的作用:防止幻读,但可能增加死锁风险
选择合适的隔离级别:
- 高并发场景:考虑使用 RC
- 强一致性要求:使用 RR
避免查询不存在的记录:减少不必要的间隙锁
固定顺序访问资源:预防死锁
监控锁等待和死锁:定期分析
SHOW ENGINE INNODB STATUS使用乐观锁:适合读多写少场景
缩短事务长度:减少锁持有时间
批量操作排序:减少锁冲突
关联术语
- [[死锁]]
- [[事务隔离级别]]
- [[MVCC]]
- [[锁粒度]]
参考资料
- MySQL 官方文档: InnoDB Locking
- InnoDB 源码:
storage/innobase/lock/lock0lock.cc - 《高性能 MySQL》第 7 章:事务与锁
- Percona Blog: Understanding InnoDB Gap Locks