MySQL 间隙锁(Gap Lock)深度解析:从原理到实战优化
一次看似简单的
SELECT ... FOR UPDATE,竟然导致整个系统死锁?背后的元凶,很可能是你从未真正理解的——间隙锁。
一、什么是间隙锁?
在 MySQL InnoDB 存储引擎中,锁机制是保证事务隔离性的核心。除了我们熟悉的行锁(Record Lock),还有一种更隐蔽但同样重要的锁——间隙锁(Gap Lock)。
锁的分类全景图
graph TD
A[InnoDB 锁类型] --> B[行锁 Record Lock]
A --> C[间隙锁 Gap Lock]
A --> D[Next-Key Lock]
B --> B1[锁住索引记录本身]
C --> C1[锁住索引记录之间的间隙]
D --> D1[Record Lock + Gap Lock]
B1 --> B2[精确匹配时触发]
C1 --> C2[防止幻读的关键]
D1 --> D2[默认的锁定方式]
间隙锁的核心作用:锁住两个索引记录之间的"空隙",防止其他事务在这个空隙中插入新记录。
举个例子:
-- 假设 users 表有 id: 1, 5, 10
SELECT * FROM users WHERE id = 5 FOR UPDATE;
此时,间隙锁会锁住 (1, 5) 和 (5, 10) 两个区间。其他事务无法在这个区间内插入 id 为 2、3、4 或 6、7、8、9 的记录。
二、为什么需要间隙锁?
2.1 解决幻读问题
在 RR(Repeatable Read) 隔离级别下,间隙锁是解决幻读(Phantom Read) 的关键机制。
幻读定义:同一个事务内,前后两次执行相同的查询,得到的结果集行数不同。
幻读问题演示
-- 事务 A
BEGIN;
SELECT * FROM orders WHERE status = 'pending'; -- 返回 5 条记录
-- 此时事务 B 插入了一条新记录
INSERT INTO orders (id, status) VALUES (100, 'pending');
COMMIT;
SELECT * FROM orders WHERE status = 'pending'; -- 返回 6 条记录!幻读发生
2.2 间隙锁如何解决幻读?
sequenceDiagram
participant T1 as 事务 A
participant DB as InnoDB
participant T2 as 事务 B
T1->>DB: SELECT * FROM orders WHERE status='pending' FOR UPDATE
DB->>DB: 获取行锁 + 间隙锁
Note over DB: 锁住所有匹配行及其间隙
T2->>DB: INSERT INTO orders (status='pending')
DB-->>T2: 阻塞(被间隙锁阻止)
T1->>DB: SELECT * FROM orders WHERE status='pending'
DB-->>T1: 返回 5 条(结果一致)
T1->>DB: COMMIT
DB->>DB: 释放所有锁
DB-->>T2: 插入成功
核心原理:间隙锁阻止其他事务在查询范围内插入新记录,从而保证同一事务内多次查询结果一致。
三、间隙锁的触发场景
3.1 触发条件矩阵
| 场景 | 是否触发间隙锁 | 触发条件 |
|---|---|---|
| 唯一索引精确匹配 | ❌ | 主键/唯一索引 + 等值查询 + 记录存在 |
| 唯一索引范围查询 | ✅ | 主键/唯一索引 + 范围查询(>、<、BETWEEN) |
| 唯一索引精确匹配(不存在) | ✅ | 主键/唯一索引 + 等值查询 + 记录不存在 |
| 非唯一索引查询 | ✅ | 普通索引上的任何查询 |
| 无索引查询 | ✅ | 全表扫描,锁住所有间隙 |
3.2 代码示例:不同场景的锁行为
场景 1:唯一索引精确匹配(记录存在)— 不触发间隙锁
-- 表结构:id 为主键,有记录 id=1, 5, 10
BEGIN;
SELECT * FROM users WHERE id = 5 FOR UPDATE;
-- 只获取 id=5 的行锁,无间隙锁
-- 其他事务可以插入 id=2,3,4,6,7,8,9
场景 2:唯一索引精确匹配(记录不存在)— 触发间隙锁
BEGIN;
SELECT * FROM users WHERE id = 7 FOR UPDATE;
-- 触发间隙锁,锁住 (5, 10) 区间
-- 其他事务无法插入 id=6,7,8,9
场景 3:非唯一索引查询 — 必定触发间隙锁
-- 表结构:status 为普通索引,有记录 status='pending' (id: 1,5,10)
BEGIN;
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
-- 触发 Next-Key Lock:
-- 1. 行锁:锁住所有 status='pending' 的记录
-- 2. 间隙锁:锁住这些记录前后的间隙
-- 阻止任何新的 status='pending' 记录插入
场景 4:范围查询 — 触发间隙锁
BEGIN;
SELECT * FROM users WHERE id > 5 FOR UPDATE;
-- 触发间隙锁,锁住 (5, 10] 和 (10, +∞)
-- 阻止插入 id=6,7,8,9 及任何 >10 的值
四、间隙锁带来的问题
4.1 死锁风险
间隙锁与间隙锁之间不会冲突,但间隙锁与插入意图锁(Insert Intention Lock)会冲突,这是死锁的高发区。
经典死锁案例
-- 初始数据:id 有 1, 5, 10
-- 事务 A
BEGIN;
SELECT * FROM users WHERE id = 3 FOR UPDATE; -- 间隙锁 (1, 5)
-- 事务 B
BEGIN;
SELECT * FROM users WHERE id = 7 FOR UPDATE; -- 间隙锁 (5, 10)
-- 事务 A
INSERT INTO users VALUES (6); -- 被事务 B 的间隙锁阻塞
-- 事务 B
INSERT INTO users VALUES (4); -- 被事务 A 的间隙锁阻塞
-- 💥 死锁发生!
graph LR
A[事务 A<br/>间隙锁 1,5] -->|阻塞| B[事务 B 插入 id=4]
C[事务 B<br/>间隙锁 5,10] -->|阻塞| D[事务 A 插入 id=6]
B -->|等待| A
D -->|等待| C
style A fill:#ff6b6b
style C fill:#ff6b6b
4.2 并发性能下降
间隙锁的锁定范围往往比预期更大:
-- 看似只查一条记录
SELECT * FROM orders WHERE order_no = 'ORD001' FOR UPDATE;
-- 但如果 order_no 是普通索引,实际上锁住了:
-- 1. 所有 order_no='ORD001' 的记录
-- 2. 这些记录前后的所有间隙
-- 可能导致大面积阻塞
4.3 隐式锁定难以排查
间隙锁不会在 SHOW ENGINE INNODB STATUS 中直观显示,需要深入分析锁等待链:
-- 查看锁等待关系
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
五、实际案例分析
案例 1:电商库存扣减死锁
问题场景
-- 订单表,order_no 为普通索引
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32),
status VARCHAR(20),
INDEX idx_order_no (order_no)
);
-- 并发扣减库存时
-- 线程 1
SELECT * FROM orders WHERE order_no = 'ORD001' FOR UPDATE;
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 100;
-- 线程 2
SELECT * FROM orders WHERE order_no = 'ORD002' FOR UPDATE;
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 100;
死锁原因
order_no是普通索引,查询触发 Next-Key Lock- 两个事务互相持有对方需要的间隙锁
- 批量插入新订单时触发插入意图锁冲突
解决方案
-- 方案 1:使用主键查询(避免间隙锁)
SELECT * FROM orders WHERE id = 123 FOR UPDATE;
-- 方案 2:使用唯一索引
ALTER TABLE orders ADD UNIQUE INDEX uk_order_no (order_no);
-- 方案 3:降低隔离级别(不推荐)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
案例 2:批量导入数据超时
问题代码
// 批量插入订单,每次查询是否存在
public void batchInsert(List<Order> orders) {
for (Order order : orders) {
// 每次查询都会触发间隙锁
Order existing = orderMapper.selectForUpdate(order.getOrderNo());
if (existing == null) {
orderMapper.insert(order);
}
}
}
优化方案
// 方案 1:使用 INSERT ... ON DUPLICATE KEY UPDATE
public void batchInsert(List<Order> orders) {
orderMapper.batchInsertOnDuplicate(orders);
}
// SQL
INSERT INTO orders (order_no, status)
VALUES (?, ?), (?, ?), (?, ?)
ON DUPLICATE KEY UPDATE status = VALUES(status);
// 方案 2:预加载 + 内存过滤
public void batchInsert(List<Order> orders) {
Set<String> existingNos = orderMapper.selectExistingNos(
orders.stream().map(Order::getOrderNo).collect(Collectors.toList())
);
List<Order> newOrders = orders.stream()
.filter(o -> !existingNos.contains(o.getOrderNo()))
.collect(Collectors.toList());
orderMapper.batchInsert(newOrders);
}
六、监控和分析方法
6.1 实时监控 SQL
-- 1. 查看当前锁等待
SELECT
trx_id,
trx_state,
trx_mysql_thread_id,
trx_query,
trx_rows_locked,
trx_lock_structs,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds
FROM information_schema.innodb_trx
ORDER BY trx_started;
-- 2. 查看死锁日志
SHOW ENGINE INNODB STATUS\G
-- 在 LATEST DETECTED DEADLOCK 部分查看详细信息
-- 3. 查看锁结构(MySQL 8.0+)
SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
LOCK_TYPE,
LOCK_MODE,
LOCK_STATUS,
THREAD_ID,
PROCESSLIST_ID
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA = 'your_database';
-- 4. 查看锁等待链(MySQL 8.0+)
SELECT
WAITING_THREAD_ID,
WAITING_LOCK_TYPE,
WAITING_LOCK_MODE,
BLOCKING_THREAD_ID,
BLOCKING_LOCK_TYPE,
BLOCKING_LOCK_MODE
FROM performance_schema.metadata_locks
WHERE LOCK_STATUS = 'PENDING';
6.2 监控指标
# Prometheus 关键指标
innodb_row_lock_waits: 行锁等待次数
innodb_row_lock_time: 行锁等待总时间(毫秒)
innodb_row_lock_time_avg: 平均行锁等待时间
innodb_row_lock_time_max: 最大行锁等待时间
# 告警规则示例
groups:
- name: mysql_lock_alerts
rules:
- alert: HighLockWaitRate
expr: rate(innodb_row_lock_waits[5m]) > 10
for: 2m
labels:
severity: warning
annotations:
summary: "MySQL 行锁等待频率过高"
- alert: LongLockWait
expr: innodb_row_lock_time_avg > 1000
for: 1m
labels:
severity: critical
annotations:
summary: "MySQL 平均行锁等待时间过长"
6.3 分析工具
# 1. pt-deadlock-logger(Percona Toolkit)
pt-deadlock-logger --host=localhost --user=root --password=xxx \
--dest D=test,t=deadlocks --daemonize
# 2. 开启死锁监控日志
[mysqld]
innodb_print_all_deadlocks = 1
# 3. 使用 Performance Schema(MySQL 8.0)
UPDATE performance_schema.setup_instruments
SET ENABLED = 'YES', TIMED = 'YES'
WHERE NAME LIKE 'wait/lock%';
七、优化建议
7.1 设计层面的优化
graph TD
A[间隙锁优化策略] --> B[索引设计]
A --> C[SQL 优化]
A --> D[事务设计]
A --> E[隔离级别]
B --> B1[优先使用主键/唯一索引]
B --> B2[避免冗余普通索引]
B --> B3[索引覆盖查询]
C --> C1[精确匹配优于范围查询]
C --> C2[避免无索引查询]
C --> C3[批量操作合并]
D --> D1[缩短事务持锁时间]
D --> D2[固定加锁顺序]
D --> D3[乐观锁替代悲观锁]
E --> E1[RC 级别无间隙锁]
E --> E2[评估业务容忍度]
7.2 具体优化措施
1. 索引优化
-- ❌ 会导致间隙锁
SELECT * FROM orders WHERE order_no = 'ORD001' FOR UPDATE;
-- order_no 是普通索引
-- ✅ 改用唯一索引或主键
ALTER TABLE orders ADD UNIQUE INDEX uk_order_no (order_no);
-- 或使用主键查询
SELECT * FROM orders WHERE id = 123 FOR UPDATE;
2. 隔离级别调整
-- 评估业务场景后,考虑降级到 RC
-- RC 级别下不存在间隙锁,但可能有幻读问题
-- 会话级别设置
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 全局设置(需重启)
[mysqld]
transaction-isolation = READ-COMMITTED
3. 乐观锁替代
// 使用版本号实现乐观锁
@Update("UPDATE products SET stock = stock - #{qty}, version = version + 1 " +
"WHERE id = #{id} AND version = #{version} AND stock >= #{qty}")
int deductStock(@Param("id") long id,
@Param("qty") int qty,
@Param("version") int version);
// 业务代码
int retries = 3;
while (retries-- > 0) {
Product p = productMapper.selectById(id);
if (productMapper.deductStock(id, qty, p.getVersion()) > 0) {
return true; // 成功
}
Thread.sleep(50); // 重试间隔
}
return false; // 失败
4. 分布式锁替代
// 使用 Redis 分布式锁替代数据库锁
public boolean deductStock(Long productId, int qty) {
String lockKey = "lock:product:" + productId;
try {
// 获取分布式锁
if (redisLock.tryLock(lockKey, 5, TimeUnit.SECONDS)) {
// 执行库存扣减
return productMapper.deductStock(productId, qty) > 0;
}
return false;
} finally {
redisLock.unlock(lockKey);
}
}
7.3 优化效果对比
| 优化方案 | 间隙锁影响 | 幻读风险 | 适用场景 |
|---|---|---|---|
| 使用唯一索引 | ✅ 完全避免 | ❌ 无 | 查询条件可唯一化 |
| 降级到 RC | ✅ 完全避免 | ⚠️ 有 | 允许一定程度的幻读 |
| 乐观锁 | ✅ 完全避免 | ❌ 无 | 冲突率低的场景 |
| 分布式锁 | ✅ 完全避免 | ❌ 无 | 分布式系统、高并发 |
八、总结
核心要点回顾
mindmap
root((间隙锁))
本质
锁住索引间隙
防止幻读
RR 级别特有
触发条件
非唯一索引查询
范围查询
记录不存在
问题
死锁风险
性能下降
难以排查
优化方向
唯一索引
降低隔离级别
乐观锁
分布式锁
最佳实践清单
- ✅ 优先使用主键或唯一索引进行精确查询
- ✅ 评估隔离级别,确认是否真的需要 RR
- ✅ 监控锁等待指标,及时发现异常
- ✅ 使用乐观锁处理低冲突场景
- ✅ 开启死锁日志,便于事后分析
- ⚠️ 避免在普通索引上使用 SELECT FOR UPDATE
- ⚠️ 避免长事务持锁时间过长
- ❌ 不要在RR 隔离级别下忽视间隙锁影响
记住:间隙锁不是洪水猛兽,它是 InnoDB 保证事务隔离性的重要机制。理解它、监控它、合理规避它,才能真正驾驭 MySQL 的高并发场景。
参考资源: