MySQL InnoDB间隙锁机制与并发事务死锁排查实战

MySQL InnoDB引擎在RR(可重复读)隔离级别下使用间隙锁防止幻读,但间隙锁的锁定范围与并发事务的交互方式容易引发死锁问题。数据库运维中死锁排查需要理解锁的兼容性矩阵与加锁顺序,通过`SHOW ENGINE INNODB STATUS`和`performance_schema`定位死锁根因。本文通过实际案例演示间隙锁死锁的触发场景与排查流程。

InnoDB锁类型与加锁规则

InnoDB的行级锁分为三种类型:

Record Lock(记录锁)
  锁定索引上的一条记录,防止单行数据的并发修改

Gap Lock(间隙锁)
  锁定索引记录之间的间隙,防止其他事务在此间隙插入新记录
  间隙锁只阻止INSERT,不阻止SELECT/UPDATE/DELETE已有记录

Next-Key Lock(临键锁)
  Record Lock + Gap Lock的组合,锁定一条记录及其前方的间隙
  InnoDB在RR隔离级别下默认使用Next-Key Lock

加锁规则总结(RR隔离级别,使用唯一/非唯一索引):

规则1: 等值查询命中记录
  - 唯一索引:退化为Record Lock(不加间隙锁)
  - 非唯一索引:加Next-Key Lock + 后一条记录的间隙锁

规则2: 等值查询未命中记录
  - 退化为Gap Lock(锁定命中的间隙)

规则3: 范围查询
  - 锁定范围内所有命中的记录的Next-Key Lock
  - 锁定范围后第一条不满足条件的记录的Gap Lock

创建测试表并插入数据:

-- 建表
CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(32) NOT NULL,
    user_id INT NOT NULL,
    amount DECIMAL(10,2) DEFAULT 0,
    status VARCHAR(16) DEFAULT 'pending',
    INDEX idx_user_id (user_id),
    INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 插入测试数据
INSERT INTO orders (order_no, user_id, amount, status) VALUES
('ORD001', 100, 50.00, 'pending'),
('ORD002', 200, 120.00, 'paid'),
('ORD003', 300, 80.00, 'pending'),
('ORD004', 400, 200.00, 'paid'),
('ORD005', 500, 35.00, 'pending');

间隙锁触发条件

以下操作在RR隔离级别下会触发间隙锁。通过`SHOW ENGINE INNODB STATUS`可查看锁信息:

-- 事务A: 查询user_id=250的记录(不存在)
BEGIN;
SELECT * FROM orders WHERE user_id = 250 FOR UPDATE;
-- user_id索引上 (200, 300) 之间加Gap Lock

-- 事务B: 尝试在间隙中插入
BEGIN;
INSERT INTO orders (order_no, user_id, amount, status)
VALUES ('ORD006', 250, 60.00, 'pending');
-- 被阻塞,等待间隙锁释放

-- 查看锁等待
SELECT * FROM performance_schema.data_locks
WHERE OBJECT_NAME = 'orders'\G

锁信息查询结果分析:

-- 查询当前持有和等待的锁
SELECT
    eng.object_schema AS db,
    eng.object_name AS table_name,
    eng.index_name,
    eng.lock_type,
    eng.lock_mode,
    eng.lock_status,
    eng.lock_data,
    thr.processlist_id AS thread_id,
    thr.processlist_user AS user
FROM performance_schema.data_locks eng
LEFT JOIN performance_schema.threads thr
    ON eng.thread_id = thr.thread_id
WHERE eng.object_name = 'orders';

-- 输出示例:
-- index_name: idx_user_id
-- lock_type: RECORD
-- lock_mode: X,GAP
-- lock_status: GRANTED
-- lock_data: 300 (表示锁定了user_id=300之前的间隙)
--
-- 另一行:
-- lock_status: WAITING
-- lock_mode: X,GAP,INSERT_INTENTION
-- lock_data: 300

死锁场景复现

以下是一个典型的间隙锁死锁场景:

-- 初始数据: user_id = 100, 200, 300

-- ===== 事务A =====
BEGIN;
-- STEP 1: 查询 user_id=150(不存在),加间隙锁(100,200)
SELECT * FROM orders WHERE user_id = 150 FOR UPDATE;

-- ===== 事务B =====
BEGIN;
-- STEP 2: 查询 user_id=250(不存在),加间隙锁(200,300)
SELECT * FROM orders WHERE user_id = 250 FOR UPDATE;

-- ===== 回到事务A =====
-- STEP 3: 尝试插入 user_id=250,需要间隙锁(200,300),被事务B阻塞
INSERT INTO orders (order_no, user_id, amount, status)
VALUES ('ORD010', 250, 70.00, 'pending');
-- 阻塞等待...

-- ===== 回到事务B =====
-- STEP 4: 尝试插入 user_id=150,需要间隙锁(100,200),被事务A阻塞
INSERT INTO orders (order_no, user_id, amount, status)
VALUES ('ORD011', 150, 70.00, 'pending');

-- 死锁产生!InnoDB检测到死锁,回滚其中一个事务
-- ERROR 1213 (40001): Deadlock found when trying to get lock;
--   try restarting transaction

死锁产生的根因:间隙锁之间互相持有对方需要的锁。事务A持有(100,200)的间隙锁,事务B持有(200,300)的间隙锁。双方都在对方持有的间隙中插入数据,形成循环等待。

死锁日志分析

查看InnoDB存储引擎的最新死锁日志:

SHOW ENGINE INNODB STATUS\G

在输出的`LATEST DETECTED DEADLOCK`部分:

=====================================
LATEST DETECTED DEADLOCK
=====================================
2026-08-15 10:30:45 0x7f8b2c9a0700
*** (1) TRANSACTION:
TRANSACTION 1234567, ACTIVE 12 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 locks struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 35, OS thread handle 140235456780032, query id 6789

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 50 page no 4 n bits 80
index idx_user_id of table `test`.`orders`
trx id 1234567 lock_mode X locks gap before rec
insert intention waiting
-- 事务A正在等待(200,300)间隙的插入意向锁

*** (2) TRANSACTION:
TRANSACTION 1234568, ACTIVE 8 sec inserting
mysql tables in use 1, locked 1
3 locks struct(s), heap size 1136, 2 row lock(s)

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 50 page no 4 n bits 80
index idx_user_id of table `test`.`orders`
trx id 1234568 lock_mode X locks gap before rec
-- 事务B持有(200,300)间隙锁

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 50 page no 4 n bits 80
index idx_user_id of table `test`.`orders`
trx id 1234568 lock_mode X locks gap before rec
insert intention waiting
-- 事务B正在等待(100,200)间隙的插入意向锁

*** (2) ROLLING BACK
-- InnoDB选择回滚事务B(undo log较小的事务)

死锁日志的阅读要点:

1. TRANSACTION ID: 区分两个事务
2. ACTIVE sec: 事务活跃时间,帮助判断是否长事务
3. lock_mode: X=排他锁, S=共享锁, GAP=间隙锁
4. waiting: 表示该锁正在等待获取
5. HOLDS THE LOCK(S): 已持有的锁
6. ROLLING BACK: 哪个事务被回滚

# 关键标识:
# "locks gap before rec" = 间隙锁
# "insert intention waiting" = 插入意向锁等待
# "locks rec but not gap" = 记录锁(无间隙锁)

开启死锁日志记录到错误日志:

-- my.cnf配置
[mysqld]
innodb_print_all_deadlocks = ON
# 将所有死锁信息输出到error log

-- 查看error log位置
SHOW VARIABLES LIKE 'log_error';

-- 也可以动态开启
SET GLOBAL innodb_print_all_deadlocks = ON;

死锁预防与优化

方案一:降低隔离级别为RC

-- 修改会话隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 或全局配置
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- my.cnf配置
[mysqld]
transaction-isolation = READ-COMMITTED

RC隔离级别下不使用间隙锁(Gap Lock),只使用记录锁。但代价是放弃幻读防护——同一事务内两次相同范围查询可能返回不同数量的行。大多数业务场景下,应用层有幂等性设计,幻读不会造成实际问题。

方案二:统一加锁顺序

-- 反面模式:不同事务按不同顺序加锁
-- 事务A先锁user_id=150再锁user_id=250
-- 事务B先锁user_id=250再锁user_id=150  <- 死锁

-- 正确模式:所有事务按相同顺序(如user_id升序)依次加锁
-- 事务A: 先锁150,再锁250
-- 事务B: 先锁150,再锁250(即使只需要250,也先锁150)

// Java代码示例:统一排序后加锁
public void batchProcess(List<Integer> userIds) {
    // 按user_id排序,保证加锁顺序一致
    userIds.sort(Integer::compareTo);

    Connection conn = dataSource.getConnection();
    conn.setAutoCommit(false);
    try {
        for (Integer userId : userIds) {
            // 使用SELECT FOR UPDATE预先加锁
            PreparedStatement ps = conn.prepareStatement(
                "SELECT * FROM orders WHERE user_id = ? FOR UPDATE"
            );
            ps.setInt(1, userId);
            ps.executeQuery();
        }
        // 执行业务逻辑...
        conn.commit();
    } catch (Exception e) {
        conn.rollback();
    }
}

方案三:使用INSERT … ON DUPLICATE KEY UPDATE替代先查后插

-- 反面模式(容易死锁)
BEGIN;
SELECT * FROM orders WHERE order_no = 'ORD010' FOR UPDATE;
-- 如果不存在,间隙锁已加上
INSERT INTO orders (order_no, user_id, ...) VALUES ('ORD010', 250, ...);
-- INSERT可能与间隙锁冲突
COMMIT;

-- 正确模式:单条原子操作
INSERT INTO orders (order_no, user_id, amount, status)
VALUES ('ORD010', 250, 70.00, 'pending')
ON DUPLICATE KEY UPDATE amount = VALUES(amount);
-- 只需要order_no上有唯一索引
-- 不需要先SELECT加间隙锁

方案四:缩短事务持有锁的时间

-- 反面模式:事务中包含耗时操作
BEGIN;
SELECT * FROM orders WHERE user_id = 150 FOR UPDATE;
-- 调用外部API(耗时3秒)
-- 发送消息到MQ
-- 这期间锁一直持有,增大死锁概率
UPDATE orders SET status = 'processed' WHERE user_id = 150;
COMMIT;

-- 正确模式:先执行写操作,快速提交
BEGIN;
UPDATE orders SET status = 'processed' WHERE user_id = 150;
-- 立即提交释放锁
COMMIT;
-- 事务提交后再执行耗时操作
callExternalAPI();
sendMessage();

建立死锁监控告警:

-- 查询最近死锁次数
SELECT * FROM performance_schema.events_errors_summary_global_by_error
WHERE ERROR_NAME = 'ER_LOCK_DEADLOCK';

-- 定期巡检脚本
SELECT
    COUNT(*) AS deadlock_count,
    FIRST_SEEN,
    LAST_SEEN
FROM performance_schema.events_errors_summary_global_by_error
WHERE ERROR_NAME = 'ER_LOCK_DEADLOCK';

-- 建议监控阈值:
-- 单次死锁: 告警但不紧急
-- 5分钟内超过10次: 紧急告警,需人工介入排查

InnoDB间隙锁的问题是RR隔离级别与并发写入的固有冲突。生产环境中优先考虑将隔离级别降到RC并配合应用层幂等性设计,从根本上消除间隙锁。对于必须使用RR的场景,需严格保证多事务间的加锁顺序一致,并将锁持有时间缩至最短。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/mysqlinnodb-jian-xi-suo-ji-zhi-yu-bing-fa-shi-wu-si-suo-pai/

(0)
小编小编
上一篇 7小时前
下一篇 7小时前

相关推荐