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/