MySQL InnoDB行锁机制与间隙锁死锁排查实战

MySQL InnoDB的锁机制是高并发场景下数据一致性的基础保障。InnoDB支持行级锁,但在不同隔离级别和查询条件下,锁的行为差异显著。间隙锁(Gap Lock)和临键锁(Next-Key Lock)是RR隔离级别特有的机制,用于防止幻读,但也是死锁高发区。理解锁的类型、加锁规则和排查方法是数据库运维和开发的核心能力。

InnoDB锁类型与加锁规则

InnoDB的行锁分为两种:

– 共享锁(S Lock):SELECT … LOCK IN SHARE MODE
– 排他锁(X Lock):SELECT … FOR UPDATE, INSERT, UPDATE, DELETE

此外还有表级意向锁(IS/IX),用于快速判断表中是否有行锁,不阻塞行级操作。

加锁的基本规则取决于隔离级别和查询条件:

-- 准备测试数据
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    order_no VARCHAR(32) NOT NULL,
    user_id BIGINT NOT NULL,
    amount DECIMAL(10,2),
    status VARCHAR(20),
    created_at DATETIME
);

CREATE INDEX idx_user_id ON orders(user_id);
CREATE INDEX idx_status ON orders(status);

INSERT INTO orders VALUES
(1, 'ORD001', 1001, 99.00, 'paid', '2026-08-01 10:00:00'),
(5, 'ORD005', 1002, 199.00, 'paid', '2026-08-02 10:00:00'),
(10, 'ORD010', 1003, 299.00, 'pending', '2026-08-03 10:00:00'),
(15, 'ORD015', 1004, 399.00, 'shipped', '2026-08-04 10:00:00'),
(20, 'ORD020', 1005, 499.00, 'cancelled', '2026-08-05 10:00:00');

RR隔离级别下的临键锁与间隙锁

InnoDB默认隔离级别是REPEATABLE READ,使用Next-Key Lock(临键锁)防止幻读。Next-Key Lock是行锁+间隙锁的组合,锁定一个左开右闭区间。

以idx_user_id索引为例,当前数据为1001, 1002, 1003, 1004, 1005:

-- 事务A
BEGIN;
SELECT * FROM orders WHERE user_id = 1003 FOR UPDATE;
-- 锁定的是idx_user_id上的Next-Key Lock: (1002, 1003]

等值查询命中记录时,临键锁退化为行锁(Record Lock),只锁定命中的那条记录。但如果查询条件是唯一索引且命中,则只加记录锁不加间隙锁。

范围查询的加锁规则:

-- 事务A
BEGIN;
SELECT * FROM orders WHERE user_id > 1002 AND user_id < 1005 FOR UPDATE;
-- 锁定区间: (1002, 1003], (1003, 1004], (1004, 1005)
-- 即: user_id在(1002, 1005)范围内的所有间隙和记录

这个锁定阻止其他事务在user_id (1002, 1005)之间插入任何新记录。

不同索引类型对锁范围的影响

索引类型直接影响锁范围,这是死锁分析的关键:

-- 场景1:通过主键等值查询
SELECT * FROM orders WHERE id = 10 FOR UPDATE;
-- 主键是唯一索引,命中记录只加Record Lock: {10}

-- 场景2:通过主键范围查询
SELECT * FROM orders WHERE id > 5 AND id < 15 FOR UPDATE;
-- 加Next-Key Lock: (5, 10], (10, 15]

-- 场景3:通过非唯一索引等值查询
SELECT * FROM orders WHERE user_id = 1003 FOR UPDATE;
-- 非唯一索引,加Next-Key Lock: (1002, 1003]
-- 同时加下一个间隙锁: (1003, 1004)
-- 因为非唯一索引可能有重复值,需要锁定两侧间隙

-- 场景4:无索引查询
SELECT * FROM orders WHERE amount = 199.00 FOR UPDATE;
-- amount列无索引,全表扫描
-- 对所有记录加Next-Key Lock,效果等同于锁表

场景4是生产环境最危险的锁行为。无索引查询的FOR UPDATE会锁定全表所有记录和间隙,导致所有写操作阻塞。务必确保WHERE条件命中索引。

典型死锁场景与复现

间隙锁导致的死锁是最常见类型。以下场景在并发插入时高频出现:

-- 初始数据: user_id有1001, 1003, 1005三条记录

-- 事务A
BEGIN;
SELECT * FROM orders WHERE user_id = 1003 FOR UPDATE;
-- 加锁: idx_user_id (1001, 1003] + (1003, 1005)

-- 事务B(并发)
BEGIN;
SELECT * FROM orders WHERE user_id = 1005 FOR UPDATE;
-- 加锁: idx_user_id (1003, 1005] + (1005, +∞)
-- 成功获取锁

-- 事务A继续
INSERT INTO orders (id, order_no, user_id, amount, status, created_at)
VALUES (8, 'ORD008', 1004, 150.00, 'pending', NOW());
-- 尝试插入user_id=1004,落在间隙(1003, 1005)
-- 被事务B的间隙锁(1003, 1005)阻塞 → 等待

-- 事务B继续
INSERT INTO orders (id, order_no, user_id, amount, status, created_at)
VALUES (7, 'ORD007', 1004, 160.00, 'pending', NOW());
-- 尝试插入user_id=1004,落在间隙(1003, 1005)
-- 被事务A的间隙锁(1003, 1005)阻塞 → 等待

-- 死锁!A等B释放间隙锁,B等A释放间隙锁
-- InnoDB检测到死锁,回滚undo量较小的事务

这类死锁的本质是两个事务互相持有对方需要的间隙锁。间隙锁之间不冲突(多个事务可同时持有同一间隙锁),但间隙锁阻塞INSERT。

死锁排查与诊断工具

开启死锁日志记录:

-- 查看死锁检测是否开启
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
-- 默认ON,死锁时自动回滚一个事务

-- 查看死锁日志输出方式
SHOW VARIABLES LIKE 'innodb_print_all_deadlocks';
-- OFF: 只在SHOW ENGINE INNODB STATUS中显示最后一个死锁
-- ON: 所有死锁记录到error log

-- 临时开启
SET GLOBAL innodb_print_all_deadlocks = ON;

查看最近一次死锁信息:

SHOW ENGINE INNODB STATUS\G

死锁日志的关键部分:

========================
LATEST DETECTED DEADLOCK
========================
2026-08-17 10:30:45 0x7f8a3c2b7000
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 10, OS thread handle 1402345678, query id 100
UPDATE orders SET status = 'paid' WHERE user_id = 1003

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 50 page no 3 n bits 72 index idx_user_id of table `test`.`orders`
trx id 12345 lock_mode X locks gap before rec
Insert intention waiting

*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 11, OS thread handle 1402345679, query id 101
INSERT INTO orders (id, order_no, user_id, ...) VALUES (8, 'ORD008', 1004, ...)

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 50 page no 3 n bits 72 index idx_user_id of table `test`.`orders`
trx id 12346 lock_mode X

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 50 page no 3 n bits 72 index idx_user_id of table `test`.`orders`
trx id 12346 lock_mode X locks gap before rec
Insert intention waiting

*** WE ROLL BACK TRANSACTION (2)

日志解读:

– lock_mode X locks gap before rec:间隙锁
– Insert intention waiting:插入意向锁等待,表明INSERT被间隙锁阻塞
– HOLDS THE LOCK(S):该事务持有的锁
– WAITING FOR THIS LOCK TO BE GRANTED:该事务等待的锁
– WE ROLL BACK TRANSACTION (2):回滚了事务2(undo log较小的一方)

通过performance_schema分析当前锁状态

MySQL 8.0使用performance_schema查看锁信息:

SELECT
    r.trx_id AS waiting_trx_id,
    r.trx_mysql_thread_id AS waiting_thread,
    r.trx_query AS waiting_query,
    b.trx_id AS blocking_trx_id,
    b.trx_mysql_thread_id AS blocking_thread,
    b.trx_query AS blocking_query,
    TIMEDIFF(NOW(), b.trx_started) AS blocking_duration
FROM performance_schema.data_lock_waits w
INNER JOIN information_schema.innodb_trx b
    ON b.trx_id = w.blocking_engine_transaction_id
INNER JOIN information_schema.innodb_trx r
    ON r.trx_id = w.requesting_engine_transaction_id;

查看具体锁详情:

SELECT
    ENGINE_LOCK_ID,
    ENGINE_TRANSACTION_ID,
    OBJECT_SCHEMA,
    OBJECT_NAME,
    INDEX_NAME,
    LOCK_TYPE,
    LOCK_MODE,
    LOCK_STATUS,
    LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_NAME = 'orders'
ORDER BY ENGINE_TRANSACTION_ID;

LOCK_DATA字段显示锁定的具体值或间隙范围。LOCK_MODE为X表示排他记录锁,X,GAP表示间隙锁,X,REC_NOT_GAP表示纯记录锁。

死锁预防与优化策略

1. 降低隔离级别为READ COMMITTED(推荐):

-- RC级别下不存在间隙锁,只加记录锁
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET GLOBAL transaction_isolation = 'READ-COMMITTED';

RC级别下FOR UPDATE只锁定命中记录,不锁间隙。上述死锁场景在RC下不会发生。代价是放弃了RR的防幻读能力,但大多数业务可接受。

2. 固定加锁顺序,避免交叉等待:

-- 不推荐:不同事务按不同条件加锁
-- 事务A: WHERE user_id = 1003 FOR UPDATE → WHERE user_id = 1005 FOR UPDATE
-- 事务B: WHERE user_id = 1005 FOR UPDATE → WHERE user_id = 1003 FOR UPDATE
-- 死锁风险高

-- 推荐:所有事务按id升序加锁
-- 事务A和B都: WHERE id IN (10, 15) FOR UPDATE ORDER BY id

3. 使用INSERT … ON DUPLICATE KEY UPDATE替代先查后插:

-- 不推荐:先SELECT再INSERT,间隙锁风险
SELECT * FROM orders WHERE order_no = 'ORD008';
-- 如果不存在则INSERT

-- 推荐:原子操作
INSERT INTO orders (id, order_no, user_id, amount, status, created_at)
VALUES (8, 'ORD008', 1004, 150.00, 'pending', NOW())
ON DUPLICATE KEY UPDATE amount = VALUES(amount);

4. 大事务拆分为小事务,缩短锁持有时间:

-- 不推荐:一个事务中处理多个订单
BEGIN;
UPDATE orders SET status = 'processing' WHERE id IN (1, 5, 10, 15, 20);
-- 执行其他耗时操作...
UPDATE inventory SET stock = stock - 1 WHERE product_id = 100;
COMMIT;

-- 推荐:拆分为多个短事务
BEGIN;
UPDATE orders SET status = 'processing' WHERE id = 1;
COMMIT;

BEGIN;
UPDATE orders SET status = 'processing' WHERE id = 5;
COMMIT;

5. 合理使用索引,避免全表扫描加锁:

-- 确保WHERE条件的所有列都有索引
-- 特别注意复合索引的最左前缀原则
-- 无索引的FOR UPDATE等于锁表

6. 设置锁超时时间,快速失败而非长时间等待:

SET SESSION innodb_lock_wait_timeout = 5;
SET GLOBAL innodb_lock_wait_timeout = 5;

默认50秒过长,生产环境建议设为3-5秒。锁等待超时后事务回滚,应用层重试,比长时间阻塞更优。

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

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

相关推荐