MySQL 间隙锁(Gap Lock)深度解析:从原理到实战优化

jxq
4
2026-06-26

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;

死锁原因

  1. order_no 是普通索引,查询触发 Next-Key Lock
  2. 两个事务互相持有对方需要的间隙锁
  3. 批量插入新订单时触发插入意图锁冲突

解决方案

-- 方案 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 的高并发场景。


参考资源

动物装饰