Skip to content

定义 ​

间隙锁 (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=4

3. 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;

-- 优势:
-- - 单次操作,减少锁竞争
-- - 原子性保证
-- - 降低死锁概率

最佳实践 ​

  1. 理解间隙锁的作用:防止幻读,但可能增加死锁风险

  2. 选择合适的隔离级别:

  • 高并发场景:考虑使用 RC
  • 强一致性要求:使用 RR
  1. 避免查询不存在的记录:减少不必要的间隙锁

  2. 固定顺序访问资源:预防死锁

  3. 监控锁等待和死锁:定期分析 SHOW ENGINE INNODB STATUS

  4. 使用乐观锁:适合读多写少场景

  5. 缩短事务长度:减少锁持有时间

  6. 批量操作排序:减少锁冲突

关联术语 ​

  • [[死锁]]
  • [[事务隔离级别]]
  • [[MVCC]]
  • [[锁粒度]]

参考资料 ​

Released under MIT License.