MySQL锁机制全解:间隙锁为什么会导致锁竞争比你想象的大
2026/4/30大约 8 分钟
MySQL锁机制全解:间隙锁为什么会导致锁竞争比你想象的大
适读人群:Java后端开发、DBA、对MySQL锁机制感兴趣的工程师 | 阅读时长:约25分钟
开篇故事
2021年,我们的电商系统在大促期间频繁出现死锁告警,但死锁发生在两个看似不相关的SQL上:
-- 线程A:插入一个新用户的首单
INSERT INTO orders(user_id, amount, status) VALUES(10001, 299, 'PENDING');
-- 线程B:也在插入另一个用户的首单
INSERT INTO orders(user_id, amount, status) VALUES(10002, 199, 'PENDING');这两条SQL毫无关联,不同用户,不同记录,为什么会死锁?
排查了两个小时才发现:这两条INSERT之前,各有一条SELECT FOR UPDATE:
-- 线程A:检查10001用户是否已有PENDING订单
SELECT * FROM orders WHERE user_id = 10001 AND status = 'PENDING' FOR UPDATE;
-- 结果为空,然后INSERT
-- 线程B:检查10002用户是否已有PENDING订单
SELECT * FROM orders WHERE user_id = 10002 AND status = 'PENDING' FOR UPDATE;
-- 结果为空,然后INSERT关键在于:两个FOR UPDATE都查到了空记录,但都对各自查询范围所在的间隙加了Gap Lock,导致双方的INSERT都被对方的Gap Lock阻塞,死锁形成。
今天把MySQL的锁机制从头到尾拆解一遍,尤其是间隙锁的范围到底有多大。
一、MySQL锁的完整体系
MySQL锁的层次结构:
全局锁(FTWRL):锁定整个数据库,用于全库备份
│
表级锁:
├── 表锁(LOCK TABLES):锁定整张表
├── MDL锁(元数据锁):DDL操作时自动加
└── 意向锁(IS/IX):行锁的"预告",让表锁能快速判断是否有行锁
│
行级锁(InnoDB专有):
├── Record Lock:锁定具体行
├── Gap Lock:锁定行之间的间隙
└── Next-Key Lock:Record Lock + Gap Lock二、底层原理:间隙锁的范围计算
2.1 间隙锁的精确范围
假设表中的主键值为:1, 5, 10, 15, 20
间隙锁将整个主键空间划分为以下区间:
(-∞, 1) (1, 5) (5, 10) (10, 15) (15, 20) (20, +∞)对于不同的查询,加锁范围如下:
等值查询,命中记录(id=10):
Next-Key Lock: (5, 10]
解释:既锁住id=10这行,也锁住(5,10)间隙
阻止:在(5,10)区间插入新记录
等值查询,未命中(id=8):
Gap Lock: (5, 10)
解释:只锁间隙,不锁记录
阻止:插入id=6,7,8,9
范围查询(id > 10 AND id <= 15):
Next-Key Lock: (10, 15]
解释:锁住(10,15]区间
阻止:插入id=11,12,13,14以及修改id=15
范围查询到最大值(id > 15):
Next-Key Lock: (15, +∞)
阻止:插入id=16,17,...任何大于15的值2.2 非唯一索引的间隙锁(更复杂)
-- 表结构
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
status VARCHAR(20),
INDEX idx_user_id (user_id)
);
-- 数据
-- id: 1,2,3,4,5
-- user_id: 10,20,10,30,20
-- 查询:带非唯一索引的FOR UPDATE
SELECT * FROM orders WHERE user_id = 20 FOR UPDATE;非唯一索引的加锁范围更大:
非唯一索引 idx_user_id 的叶子节点(排序后):
(user_id=10, id=1), (user_id=10, id=3),
(user_id=20, id=2), (user_id=20, id=5),
(user_id=30, id=4)
查询 user_id=20 FOR UPDATE 的加锁范围:
① 非唯一索引上:
Gap Lock before (user_id=20, id=2):即 (user_id=10, id=3) 到 (user_id=20, id=2) 之间
Record Lock on (user_id=20, id=2)
Record Lock on (user_id=20, id=5)
Gap Lock after (user_id=20, id=5):即 (user_id=20, id=5) 到 (user_id=30, id=4) 之间
② 主键索引上:
Record Lock on id=2
Record Lock on id=5
影响:不仅阻止了 user_id=20 的新记录,
还阻止了 user_id=10 到 user_id=30 之间的所有新记录!
例如:INSERT INTO orders(user_id) VALUES(15) 会被阻塞!这就是开篇死锁的根因:两个查询分别对不同user_id范围加了Gap Lock,而各自的INSERT恰好在对方的Gap Lock范围内。
三、完整解决方案与代码
3.1 死锁分析与修复
// 问题代码:先SELECT FOR UPDATE查空记录,再INSERT
@Transactional
public Order createFirstOrder(Long userId, BigDecimal amount) {
// 步骤1:检查是否已有PENDING订单
// 这里查到空记录,但对查询范围加了Gap Lock
List<Order> pending = orderMapper.selectPendingByUserId(userId);
if (!pending.isEmpty()) {
throw new BusinessException("已有进行中的订单");
}
// 步骤2:INSERT(会被其他事务的Gap Lock阻塞)
Order order = new Order(userId, amount, "PENDING");
orderMapper.insert(order);
return order;
}对应的SQL分析:
-- 步骤1:这条FOR UPDATE加了Gap Lock
SELECT * FROM orders WHERE user_id = 10001 AND status = 'PENDING' FOR UPDATE;
-- 由于user_id=10001在索引中不存在,加Gap Lock on (user_id=9999, ...) to (user_id=10002, ...)
-- 步骤2:这条INSERT会被其他事务的Gap Lock阻塞
INSERT INTO orders(user_id, amount, status) VALUES(10001, 299, 'PENDING');
-- 如果另一个事务对user_id=10002加了Gap Lock,10001的INSERT会被阻塞修复方案:
// 方案1:改为INSERT ON DUPLICATE KEY(避免先查后插)
// 前提:在(user_id, status)上加唯一索引
ALTER TABLE orders ADD UNIQUE KEY uk_user_status (user_id, status);
@Transactional
public Order createOrder(Long userId, BigDecimal amount) {
Order order = new Order(userId, amount, "PENDING");
try {
orderMapper.insert(order);
return order;
} catch (DuplicateKeyException e) {
// 唯一键冲突:已有PENDING订单
throw new BusinessException("已有进行中的订单");
}
}
// 方案2:降级为READ COMMITTED(没有Gap Lock)
@Transactional(isolation = Isolation.READ_COMMITTED)
public Order createFirstOrder(Long userId, BigDecimal amount) {
// RC隔离级别:只有Record Lock,没有Gap Lock
// 不能防幻读,但减少了锁冲突
List<Order> pending = orderMapper.selectPendingByUserId(userId);
if (!pending.isEmpty()) throw new BusinessException("已有进行中的订单");
return orderMapper.insert(new Order(userId, amount, "PENDING"));
}
// 方案3:使用乐观锁
@Transactional
public Order createOrder(Long userId, BigDecimal amount) {
// 不加FOR UPDATE,直接INSERT,用唯一索引防重复
int affected = orderMapper.insertIfNotExists(userId, amount);
if (affected == 0) throw new BusinessException("已有进行中的订单");
return orderMapper.selectLastByUserId(userId);
}3.2 死锁监控与告警
-- 实时查看死锁信息
SHOW ENGINE INNODB STATUS\G
-- 关键部分:LATEST DETECTED DEADLOCK
/*
LATEST DETECTED DEADLOCK
2022-06-18 10:23:45
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 0 sec starting index read
MySQL thread id 100, query id 5678
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 100 page no 5 n bits 72 index idx_user_id
of table `orders` trx id 12345 lock_mode X locks gap before rec insert intention waiting
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 0 sec inserting
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 100 page no 5 n bits 72 index idx_user_id
of table `orders` trx id 12346 lock_mode X locks gap before rec
*** WE ROLL BACK TRANSACTION (1) ← MySQL自动回滚了事务1
*/
-- 开启死锁日志
SET GLOBAL innodb_print_all_deadlocks = ON;
-- 死锁信息会打印到MySQL error log// Java层捕获死锁并重试
@Service
public class OrderService {
// 捕获Spring的DeadlockLoserDataAccessException并重试
@Retryable(
value = {DeadlockLoserDataAccessException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2) // 50ms, 100ms, 200ms
)
@Transactional
public Order createOrder(Long userId, BigDecimal amount) {
// 业务逻辑
return orderMapper.insert(new Order(userId, amount, "PENDING"));
}
@Recover
public Order recoverFromDeadlock(DeadlockLoserDataAccessException e,
Long userId, BigDecimal amount) {
// 3次重试都失败后的兜底处理
log.error("死锁重试3次仍失败,userId={}", userId, e);
throw new BusinessException("系统繁忙,请稍后重试");
}
}3.3 表锁与MDL锁的影响
/**
* MDL锁(元数据锁)是一个容易被忽视的锁竞争来源
* DDL操作(ALTER TABLE)需要MDL写锁
* 如果有长事务持有MDL读锁,ALTER TABLE会被阻塞
* 更糟糕的是:ALTER TABLE阻塞后,后续的SELECT也会被阻塞!
*/-- 场景:在线加字段时的MDL锁问题
-- 事务T1(未提交)
BEGIN;
SELECT * FROM orders LIMIT 1; -- 获取MDL读锁
-- DDL操作(必须等T1提交才能执行)
ALTER TABLE orders ADD COLUMN remark VARCHAR(200);
-- 等待获取MDL写锁,被T1阻塞!
-- 此时所有后续的SELECT/INSERT/UPDATE都在等待...
-- 查看MDL锁等待
SELECT * FROM performance_schema.metadata_locks
WHERE LOCK_STATUS = 'PENDING'\G
-- 预防措施
SET lock_wait_timeout = 5; -- DDL获取MDL锁的超时时间(5秒,超时则失败而不是无限等待)四、踩坑实录
坑1:DELETE操作的间隙锁范围超预期
-- 表数据:id = 1,3,5,7,9
-- 事务T1:删除id=5
DELETE FROM t WHERE id = 5;
-- 加锁范围:Next-Key Lock (3, 5],还有Gap Lock (5, 7)
-- 事务T2:插入id=6(在(5,7)间隙内)
INSERT INTO t VALUES(6, 'data');
-- 被T1的Gap Lock (5,7) 阻塞!
-- 如果T1还未提交,T2一直等待
-- 如果等待超时(innodb_lock_wait_timeout=50秒),T2报错:报错:
ERROR 1205 (HY000): Lock wait timeout exceeded;
try restarting transaction解决方案:
-- 方案1:降低隔离级别到RC(没有Gap Lock)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 方案2:尽量让DELETE操作走主键(主键命中时,Gap Lock范围最小)
DELETE FROM t WHERE id = 5; -- 精确主键,Gap Lock最小
-- 方案3:控制事务时长,尽快提交,减少Gap Lock持有时间坑2:IN查询的间隙锁叠加
-- IN查询会对每个值分别加锁
-- 表数据:id = 1,3,5,7,9
SELECT * FROM t WHERE id IN (3, 7) FOR UPDATE;
-- 等价于:
-- SELECT * WHERE id = 3 FOR UPDATE → Next-Key Lock (1,3]
-- SELECT * WHERE id = 7 FOR UPDATE → Next-Key Lock (5,7]
-- 合并后:锁住 (1,3] 和 (5,7]
-- 阻止插入:id=2, id=4, id=6这比直接查WHERE id=3的锁范围大了一倍,高并发INSERT场景要注意。
坑3:意向锁被误认为是性能瓶颈
-- 查看当前锁情况
SELECT * FROM performance_schema.data_locks
WHERE LOCK_TYPE = 'TABLE' AND LOCK_MODE LIKE 'I%';
-- 看到大量 IX(意向排他锁),以为是锁竞争
-- 实际上意向锁之间不冲突,它们只是行锁的"预告"
-- 意向锁只和全局的LOCK TABLES冲突
-- 意向锁的兼容矩阵:
-- IS IX S X
-- IS ✓ ✓ ✓ ✗
-- IX ✓ ✓ ✗ ✗
-- S ✓ ✗ ✓ ✗
-- X ✗ ✗ ✗ ✗
-- IS和IX之间完全兼容!不是锁竞争来源真正的锁竞争来自行级锁(Record Lock和Gap Lock),不是意向锁。
五、总结与延伸
MySQL锁机制的核心规律:
Gap Lock是防幻读的代价:RR隔离级别为了防止幻读,引入了Gap Lock,但Gap Lock的范围可能远比你预期的大,特别是非唯一索引场景。
减少锁竞争的实用技巧:
- 尽量用主键查询(Gap Lock范围最小)
- 避免「先SELECT FOR UPDATE查空记录,再INSERT」的模式
- 高并发INSERT场景考虑降级到RC隔离级别(无Gap Lock)
- 长事务会持有锁更久,保持事务短小
死锁不可避免,但可以减少:捕获死锁异常并重试是合理的处理方式,同时要分析死锁原因并从根本上减少锁竞争。
