MySQL间隙锁与Next-Key锁的守门之道 🔐

51 阅读12分钟

一、开篇故事:停车场的三种车位锁 🅿️

想象一个停车场,有编号为 1、5、10、15、20 的车位:

锁类型1:记录锁(Record Lock)—— 锁住车位

小明锁住5号车位:
  [1] [5🔒] [10] [15] [20]
  
只有5号车位被锁,其他人可以停其他车位。
其他人不能停5号,但可以在234号新建车位并停车。

锁类型2:间隙锁(Gap Lock)—— 锁住间隙

小明锁住(5,10)之间的间隙:
  [1] [5] (🔒6,7,8,9🔒) [10] [15] [20]
  
5号和10号车位可以使用,
但不能在6789号新建车位!
→ 防止别人插队

锁类型3:Next-Key锁(Record + Gap)—— 锁住车位+前面的间隙

小明锁住10号车位及其前面的间隙:
  [1] [5] (🔒6,7,8,9🔒) [10🔒] [15] [20]
  
既锁住10号车位,又锁住(5,10)之间的间隙。
→ 防止幻读的终极武器!

二、为什么需要间隙锁和Next-Key锁?🤔

2.1 幻读问题回顾

-- 事务A
BEGIN;
SELECT * FROM users WHERE age > 20; -- 查到3条

-- 事务B
INSERT INTO users VALUES (4, '李四', 25); -- 插入新数据
COMMIT;

-- 事务A
SELECT * FROM users WHERE age > 20; -- 还是3条(快照读)✅
UPDATE users SET name = '修改' WHERE age > 20; -- 修改了4条(当前读)❌
SELECT * FROM users WHERE age > 20; -- 现在是4条了!👻 幻读!

COMMIT;

2.2 间隙锁的使命

目标:防止幻读

方法:锁住间隙,不让别人插入新数据

SELECT * FROM users WHERE age > 20 FOR UPDATE;
  → 锁住所有age>20的记录
  → 同时锁住(20, +∞)的间隙
  → 别人无法插入age>20的新数据
  → 完美解决幻读!✅

三、三种锁详解 🎯

3.1 记录锁(Record Lock)

定义: 锁住索引记录本身。

示例数据:

id(主键): 1, 5, 10, 15, 20

加锁SQL:

SELECT * FROM users WHERE id = 10 FOR UPDATE;

锁住范围:

[1] [5] [10🔒] [15] [20]
     ↑
  只锁id=10这条记录

特点:

  • ✅ 锁粒度最小
  • ✅ 并发度最高
  • ❌ 无法防止幻读(其他事务可以插入id=11、12等)

3.2 间隙锁(Gap Lock)

定义: 锁住索引记录之间的间隙,不锁记录本身。

加锁SQL:

-- 假设id=7不存在
SELECT * FROM users WHERE id = 7 FOR UPDATE;

锁住范围:

[1] [5] (🔒6,7,8,9🔒) [10] [15] [20]
         ↑
    锁住(5, 10)之间的间隙

特点:

  • ✅ 防止插入,解决幻读
  • ⚠️ 不锁记录本身(id=5和id=10可以被其他事务修改)
  • ⚠️ 只在REPEATABLE READ隔离级别下存在

间隙类型:

1. (5, 10):开区间,不包括5和10
2. (-∞, 1):第一条记录前的间隙
3. (20, +∞):最后一条记录后的间隙

3.3 Next-Key锁(Record + Gap)

定义: 记录锁 + 间隙锁,锁住记录本身及前面的间隙。

加锁SQL:

SELECT * FROM users WHERE id >= 10 FOR UPDATE;

锁住范围:

[1] [5] (🔒6,7,8,9🔒) [10🔒] (🔒11,12,13,14🔒) [15🔒] (🔒16,17,18,19🔒) [20🔒] (🔒21...🔒)
         ↑          ↑                    ↑                     ↑
    锁住(5,10)    锁住10               锁住(10,15)           锁住15...

特点:

  • ✅ MySQL默认的行锁类型
  • ✅ 完美解决幻读
  • ❌ 锁的范围大,并发度低

范围表示:

Next-Key Lock = (上一条记录, 当前记录]

例如:
  id=10的Next-Key Lock = (5, 10]
  → 锁住(5, 10)的间隙 + id=10的记录

四、加锁规则详解 📏

4.1 加锁的两个原则

原则1: 加锁的基本单位是Next-Key Lock(左开右闭区间)

原则2: 查找过程中访问到的对象才会加锁

4.2 两个优化

优化1: 索引上的等值查询,给唯一索引加锁时,Next-Key Lock退化为Record Lock

优化2: 索引上的等值查询,向右遍历时且最后一个值不满足条件时,Next-Key Lock退化为Gap Lock

4.3 一个Bug

唯一索引的范围查询会访问到不满足条件的第一个值为止。


五、加锁场景分析 🔍

场景1:主键等值查询(记录存在)

-- 数据:id = 1, 5, 10, 15, 20
SELECT * FROM users WHERE id = 10 FOR UPDATE;

加锁分析:

1. 主键是唯一索引
2. 等值查询
3. 记录存在

结果:Next-Key Lock退化为Record Lock
锁住:id=10 ✅

锁范围:

[1] [5] [10🔒] [15] [20]

其他事务:

-- ✅ 可以插入id=9
INSERT INTO users VALUES (9, '张三', 25);

-- ❌ 不能修改id=10
UPDATE users SET name = '李四' WHERE id = 10; -- 阻塞

-- ✅ 可以修改id=15
UPDATE users SET name = '王五' WHERE id = 15;

场景2:主键等值查询(记录不存在)

-- 数据:id = 1, 5, 10, 15, 20
SELECT * FROM users WHERE id = 7 FOR UPDATE;

加锁分析:

1. 查询id=7,不存在
2. 向右遍历到id=10
3. id=10不满足条件,Next-Key Lock退化为Gap Lock

结果:锁住(5, 10)的间隙

锁范围:

[1] [5] (🔒6,7,8,9🔒) [10] [15] [20]

其他事务:

-- ❌ 不能插入id=7
INSERT INTO users VALUES (7, '张三', 25); -- 阻塞

-- ✅ 可以修改id=5
UPDATE users SET name = '李四' WHERE id = 5;

-- ✅ 可以修改id=10
UPDATE users SET name = '王五' WHERE id = 10;

场景3:主键范围查询

-- 数据:id = 1, 5, 10, 15, 20
SELECT * FROM users WHERE id >= 10 FOR UPDATE;

加锁分析:

1. 范围查询,从id=10开始
2. 锁住id=10的Next-Key Lock:(5, 10]
3. 锁住id=15的Next-Key Lock:(10, 15]
4. 锁住id=20的Next-Key Lock:(15, 20]
5. 向右遍历到末尾:(20, +∞)

锁范围:

[1] [5] (🔒6...🔒) [10🔒] (🔒11...🔒) [15🔒] (🔒16...🔒) [20🔒] (🔒21...🔒)

其他事务:

-- ❌ 不能插入id=9(在(5,10)间隙中)
INSERT INTO users VALUES (9, '张三', 25); -- 阻塞

-- ❌ 不能插入id=11
INSERT INTO users VALUES (11, '李四', 25); -- 阻塞

-- ❌ 不能修改id=10, 15, 20
UPDATE users SET name = '王五' WHERE id = 10; -- 阻塞

-- ✅ 可以修改id=5
UPDATE users SET name = '赵六' WHERE id = 5;

场景4:非唯一索引等值查询

-- age索引:age = 10, 10, 20, 30
SELECT * FROM users WHERE age = 10 FOR UPDATE;

加锁分析:

1. 非唯一索引,可能有多个age=10的记录
2. 锁住第一个age=10的Next-Key Lock:(上一条, 10]
3. 锁住第二个age=10的Next-Key Lock:(10, 10](实际就是记录锁)
4. 向右遍历到age=20,不满足条件,退化为Gap Lock:(10, 20)

锁范围:

age索引:
  (🔒-∞, 10🔒] [10🔒] (🔒10, 20🔒) [20] [30]

主键索引(回表):
  对应的主键记录也会加Record Lock

其他事务:

-- ❌ 不能插入age=10
INSERT INTO users VALUES (100, '张三', 10); -- 阻塞

-- ❌ 不能插入age=15(在间隙中)
INSERT INTO users VALUES (101, '李四', 15); -- 阻塞

-- ✅ 可以插入age=20
INSERT INTO users VALUES (102, '王五', 20);

场景5:非唯一索引范围查询

-- age索引:age = 10, 20, 30
SELECT * FROM users WHERE age >= 20 FOR UPDATE;

加锁分析:

1. 锁住age=20的Next-Key Lock:(10, 20]
2. 锁住age=30的Next-Key Lock:(20, 30]
3. 向右遍历到末尾:(30, +∞)

锁范围:

[10] (🔒10, 20🔒] (🔒20, 30🔒] (🔒30, +∞🔒)

场景6:无索引查询(全表扫描)

-- name字段无索引
SELECT * FROM users WHERE name = '张三' FOR UPDATE;

加锁分析:

1. 全表扫描
2. 锁住所有记录的Next-Key Lock
3. 锁住所有间隙

锁范围:

整个表!所有记录和间隙都被锁住!💀

结果:表锁!其他事务无法插入、修改、删除任何数据!

教训:

⚠️ 必须在查询字段上建索引!
⚠️ 否则全表锁,并发度为0!

六、死锁案例分析 💀

案例1:间隙锁导致的死锁

-- 数据:id = 5, 10

-- 事务A
BEGIN;
SELECT * FROM users WHERE id = 6 FOR UPDATE;
-- 锁住间隙(5, 10)

-- 事务B
BEGIN;
SELECT * FROM users WHERE id = 7 FOR UPDATE;
-- 也想锁住间隙(5, 10),但间隙锁互相兼容,可以同时持有 ✅

-- 事务A
INSERT INTO users VALUES (6, '张三', 25);
-- 需要插入到间隙(5, 10),但事务B也持有间隙锁
-- 等待事务B释放... 😴

-- 事务B
INSERT INTO users VALUES (7, '李四', 30);
-- 需要插入到间隙(5, 10),但事务A也持有间隙锁
-- 等待事务A释放... 😴

💀 死锁!MySQL自动检测并回滚其中一个事务

原因分析:

1. 间隙锁之间不互斥(可以同时持有)
2. 但插入意向锁与间隙锁互斥
3. 两个事务都持有间隙锁,都想插入
4. 互相等待 → 死锁

解决方案:

1. 修改业务逻辑,避免并发插入同一间隙
2. 使用乐观锁代替悲观锁
3. 降低事务隔离级别为RC(没有间隙锁)

案例2:Next-Key锁导致的死锁

-- 数据:id = 5, 10, 15

-- 事务A
BEGIN;
UPDATE users SET name = '张三' WHERE id = 10;
-- 锁住id=10的Record Lock

-- 事务B
BEGIN;
UPDATE users SET name = '李四' WHERE id = 15;
-- 锁住id=15的Record Lock

-- 事务A
UPDATE users SET name = '王五' WHERE id = 15;
-- 等待事务B释放id=15的锁... 😴

-- 事务B
UPDATE users SET name = '赵六' WHERE id = 10;
-- 等待事务A释放id=10的锁... 😴

💀 死锁!

解决方案:

1. 保证事务中锁的获取顺序一致
2. 减少事务持有锁的时间
3. 使用乐观锁

七、间隙锁的兼容性矩阵 📊

7.1 锁之间的兼容性

持有的锁 ↓ / 请求的锁 →Gap LockRecord LockNext-Key Lock
Gap Lock✅ 兼容✅ 兼容✅ 兼容
Record Lock✅ 兼容❌ 互斥❌ 互斥
Next-Key Lock✅ 兼容❌ 互斥❌ 互斥

关键点:

1. 间隙锁之间互相兼容(可以同时持有)
2. 记录锁之间互斥
3. 插入意向锁与间隙锁互斥

7.2 插入意向锁

定义: INSERT操作在插入前需要获取插入意向锁(一种特殊的Gap Lock)

特点:

  • 与Gap Lock冲突
  • 与其他插入意向锁兼容(只要插入的位置不同)

示例:

-- 事务A持有间隙锁(5, 10)
BEGIN;
SELECT * FROM users WHERE id = 7 FOR UPDATE;

-- 事务B想插入id=6
INSERT INTO users VALUES (6, '张三', 25);
-- 需要获取插入意向锁,但与事务A的间隙锁冲突
-- 等待... 😴

八、如何避免间隙锁问题?💡

方法1:使用READ COMMITTED隔离级别

-- RC隔离级别下没有间隙锁
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

BEGIN;
SELECT * FROM users WHERE id = 7 FOR UPDATE;
-- 只锁住查询到的记录,没有间隙锁

-- 其他事务可以插入id=6、7、8等
INSERT INTO users VALUES (7, '张三', 25); -- 不阻塞 ✅

优点:

✅ 没有间隙锁,并发度高
✅ 不会因为间隙锁导致死锁

缺点:

❌ 无法完全防止幻读
❌ binlog需要使用row格式(statement格式可能导致主从不一致)

方法2:精确匹配,避免范围查询

-- ❌ 不好:范围查询,锁很多
SELECT * FROM users WHERE age >= 20 FOR UPDATE;

-- ✅ 好:精确查询,锁最少
SELECT * FROM users WHERE id IN (20, 25, 30) FOR UPDATE;

方法3:使用乐观锁

-- 添加版本号
ALTER TABLE users ADD COLUMN version INT DEFAULT 0;

-- 乐观锁
BEGIN;
SELECT id, name, version FROM users WHERE id = 10;
-- version = 5

UPDATE users 
SET name = '张三', version = version + 1
WHERE id = 10 AND version = 5;

-- 如果version被别人改了,UPDATE失败,重试
COMMIT;

方法4:缩小事务范围

-- ❌ 不好:大事务
BEGIN;
SELECT * FROM users WHERE id = 10 FOR UPDATE;
-- 做很多业务逻辑...
sleep(10); -- 持有锁10秒
UPDATE users SET name = '张三' WHERE id = 10;
COMMIT;

-- ✅ 好:小事务
-- 先查询(不加锁)
SELECT * FROM users WHERE id = 10;
-- 做业务逻辑...

-- 快速更新
BEGIN;
UPDATE users SET name = '张三' WHERE id = 10;
COMMIT;

九、实战案例:秒杀系统防超卖 🛒

场景

CREATE TABLE products (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    stock INT,
    INDEX idx_stock (stock)
);

INSERT INTO products VALUES (1, 'iPhone', 100);

方案1:悲观锁(Record Lock)

BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- 加锁
-- stock = 100

IF stock > 0 THEN
    UPDATE products SET stock = stock - 1 WHERE id = 1;
END IF;

COMMIT;

优点: 完全防止超卖
缺点: 性能差(串行执行)


方案2:乐观锁

BEGIN;
SELECT stock, version FROM products WHERE id = 1;
-- stock = 100, version = 1

UPDATE products 
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 1;

-- 如果version已经变了,UPDATE失败,重试
COMMIT;

优点: 并发度高
缺点: 高并发下大量重试


方案3:Redis预减 + MySQL最终扣减

// 1. Redis预减库存
Long stock = redisTemplate.opsForValue().decrement("product:1:stock");
if (stock < 0) {
    return "库存不足";
}

// 2. 异步扣减MySQL库存(通过MQ)
rabbitTemplate.send("order.create", order);

// 3. 消费者处理
@RabbitListener(queues = "order.create")
public void handleOrder(Order order) {
    productMapper.updateStock(order.getProductId(), -1);
}

优点: 性能极高
缺点: 复杂度高,需要保证最终一致性


十、面试高频问题 🎤

Q1: 什么是Gap Lock和Next-Key Lock?

答:

  • Gap Lock(间隙锁):锁住索引记录之间的间隙,防止其他事务插入数据
  • Next-Key Lock:Record Lock + Gap Lock,锁住记录本身及前面的间隙
  • 作用:防止幻读

Q2: 为什么RC隔离级别下没有间隙锁?

答: 因为RC隔离级别不需要解决幻读问题,只需要解决脏读。间隙锁的目的是防止幻读,所以RC下不需要间隙锁,可以提高并发度。

Q3: 间隙锁会导致死锁吗?

答: 会。虽然间隙锁之间互相兼容,但插入意向锁与间隙锁互斥。两个事务同时持有间隙锁,都想插入数据,就会互相等待,导致死锁。

Q4: 如何避免间隙锁导致的性能问题?

答:

  1. 使用RC隔离级别(没有间隙锁)
  2. 精确匹配,避免范围查询
  3. 使用乐观锁代替悲观锁
  4. 缩小事务范围,快速提交

Q5: 主键等值查询会加间隙锁吗?

答:

  • 如果记录存在:只加Record Lock,不加Gap Lock
  • 如果记录不存在:加Gap Lock,锁住相邻记录之间的间隙

十一、总结口诀 📝

MySQL锁有三兄弟,
Record、Gap、Next-Key。
Record锁住记录本身,
Gap锁住间隙防插入。

Next-Key两者结合体,
左开右闭是区间。
RR级别默认用它,
防止幻读是关键。

等值查询唯一索,
Record Lock就够了。
等值查询非唯一,
Next-Key加Gap Lock。

范围查询要小心,
锁的范围会很大。
无索引全表扫描,
整张表都被锁住。

间隙锁可以共享,
插入意向会冲突。
两个事务同时持有,
都想插入就死锁。

RC级别没间隙锁,
并发度高性能好。
RR级别防幻读强,
选择级别要权衡!

参考资料 📚


本批次完成! 🎉
已完成文档136-140(共5个)!


编写时间:2025年
作者:技术文档小助手 ✍️
版本:v1.0

愿你的锁永远精准,性能永远在线! 🔐⚡