一、开篇故事:停车场的三种车位锁 🅿️
想象一个停车场,有编号为 1、5、10、15、20 的车位:
锁类型1:记录锁(Record Lock)—— 锁住车位
小明锁住5号车位:
[1] [5🔒] [10] [15] [20]
只有5号车位被锁,其他人可以停其他车位。
其他人不能停5号,但可以在2、3、4号新建车位并停车。
锁类型2:间隙锁(Gap Lock)—— 锁住间隙
小明锁住(5,10)之间的间隙:
[1] [5] (🔒6,7,8,9🔒) [10] [15] [20]
5号和10号车位可以使用,
但不能在6、7、8、9号新建车位!
→ 防止别人插队
锁类型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 Lock | Record Lock | Next-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: 如何避免间隙锁导致的性能问题?
答:
- 使用RC隔离级别(没有间隙锁)
- 精确匹配,避免范围查询
- 使用乐观锁代替悲观锁
- 缩小事务范围,快速提交
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
愿你的锁永远精准,性能永远在线! 🔐⚡