MySQL 8.0 InnoDB锁机制深度解析:行锁、间隙锁与死锁诊断实战

MySQL InnoDB存储引擎的锁机制是数据库并发控制的核心,也是生产环境中最容易引发性能问题的环节。理解行锁、间隙锁、临键锁的加锁规则,掌握死锁日志的解读方法,对排查锁等待超时和死锁类故障至关重要。本文通过实验复现各类加锁场景,并给出诊断排查的完整流程。

InnoDB锁类型与加锁规则总览

InnoDB的锁体系基于索引实现。行锁不是直接锁定数据行本身,而是锁定索引记录。如果查询没有命中索引,InnoDB不得不锁定全表的所有索引记录,表现为表锁效果。这是行锁退化为表锁的根本原因。

锁的粒度分为三种:记录锁(Record Lock)锁定索引中的一条记录;间隙锁(Gap Lock)锁定索引记录之间的区间,防止其他事务在此区间插入新记录;临键锁(Next-Key Lock)是记录锁加间隙锁的组合,锁定一条记录及其前面的区间。这是InnoDB在RR(REPEATABLE READ)隔离级别下的默认加锁方式。

使用以下测试表进行实验,表结构模拟电商库存场景:

CREATE TABLE inventory (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_id BIGINT NOT NULL,
    stock INT NOT NULL DEFAULT 0,
    version INT NOT NULL DEFAULT 0,
    INDEX idx_product (product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO inventory (id, product_id, stock) VALUES
(1, 1001, 50), (5, 1002, 30), (10, 1003, 80), (15, 1004, 20), (20, 1005, 60);

SELECT @@transaction_isolation;  -- REPEATABLE-READ (默认)

id为主键聚簇索引,product_id为二级索引。注意ID值不连续(1、5、10、15、20),这便于观察间隙锁的锁范围。

等值查询加锁场景实验

场景1:通过唯一索引(主键)等值查询命中记录。

-- 事务A
BEGIN;
SELECT * FROM inventory WHERE id = 10 FOR UPDATE;

-- 查看锁信息
SELECT * FROM performance_schema.data_locks WHERE OBJECT_NAME='inventory'\G
-- lock_mode: X,REC_NOT_GAP    -- 记录锁,无间隙锁
-- lock_data: 10

-- 事务B尝试插入id=11的记录
INSERT INTO inventory (id, product_id, stock) VALUES (11, 1006, 40);
-- Query OK  成功,间隙未锁定

唯一索引等值命中时,InnoDB只加记录锁,不加间隙锁。因为唯一索引保证了该值不会重复插入。

场景2:通过唯一索引等值查询未命中记录(加间隙锁)。

-- 事务A
BEGIN;
SELECT * FROM inventory WHERE id = 8 FOR UPDATE;
-- id=8的记录不存在

-- lock_mode: X,GAP    -- 间隙锁
-- lock_data: 10       -- 锁定(5, 10)区间

-- 事务B尝试在间隙内插入
INSERT INTO inventory (id, product_id, stock) VALUES (7, 1007, 10);
-- BLOCKED... 锁等待
-- 事务B尝试插入间隙外
INSERT INTO inventory (id, product_id, stock) VALUES (12, 1008, 10);
-- Query OK  成功,不在锁定间隙内

唯一索引等值未命中时,InnoDB在可能插入位置加间隙锁,锁定(5, 10)区间防止幻读。

场景3:通过非唯一二级索引等值查询(加临键锁)。

-- 事务A
BEGIN;
SELECT * FROM inventory WHERE product_id = 1002 FOR UPDATE;

-- 1. 二级索引 idx_product:
--    lock_mode: X              -- 临键锁
--    lock_data: 1002, 5        -- 锁定(-inf,1002]的二级索引区间
-- 2. 聚簇索引 PRIMARY:
--    lock_mode: X,REC_NOT_GAP  -- 记录锁
--    lock_data: 5              -- 锁定对应主键记录

非唯一索引的等值查询即使命中记录,也会加临键锁,因为非唯一索引允许重复值。InnoDB需要锁定后续区间防止插入相同值的记录导致幻读。

范围查询加锁场景与间隙蔓延

范围查询的加锁规则:等值扫描到第一个不满足条件的记录后停止,但这个边界记录也会被加临键锁。

-- 事务A
BEGIN;
SELECT * FROM inventory WHERE id >= 10 AND id < 15 FOR UPDATE;

-- 锁范围分析:
-- id=10:  X,REC_NOT_GAP  (命中,唯一索引等值)
-- id=15:  X              (临键锁,扫描到15时不满足<15但用于防止插入)
-- 锁定区间: [10, 15]

-- 事务B测试
INSERT INTO inventory (id, product_id, stock) VALUES (12, 1009, 10);  -- BLOCKED
INSERT INTO inventory (id, product_id, stock) VALUES (16, 1010, 10);  -- OK
SELECT * FROM inventory WHERE id = 15 FOR UPDATE;                      -- BLOCKED

关键点:id < 15的扫描在id=15处停止,但id=15记录仍被临键锁锁定。这是因为InnoDB需要防止其他事务在[10,15)区间插入记录。

死锁产生条件与日志复现

死锁是两个或多个事务互相持有对方需要的锁,形成循环等待。以下实验复现一个经典的死锁场景:

-- 事务A
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE id = 1;  -- 锁定id=1

-- 事务B(同时执行)
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE id = 2;  -- 锁定id=2

-- 事务A继续
UPDATE inventory SET stock = stock - 1 WHERE id = 2;  -- 等待id=2的锁

-- 事务B继续
UPDATE inventory SET stock = stock - 1 WHERE id = 1;  -- 等待id=1的锁

-- 死锁检测触发,InnoDB选择回滚undo量较小的事务
-- ERROR 1213 (40001): Deadlock found when trying to get lock

查看死锁日志:

SHOW ENGINE INNODB STATUS\G

-- *** (1) TRANSACTION:
-- UPDATE inventory SET stock = stock - 1 WHERE id = 2
-- *** (1) WAITING FOR THIS LOCK TO BE GRANTED:
-- trx id 12345 lock_mode X locks rec but not gap waiting
-- *** (1) HOLDS THE LOCK(S):
-- trx id 12345 lock_mode X locks rec but not gap

-- *** (2) TRANSACTION:
-- UPDATE inventory SET stock = stock - 1 WHERE id = 1
-- *** (2) HOLDS THE LOCK(S):
-- trx id 12346 lock_mode X locks rec but not gap
-- *** (2) WAITING FOR THIS LOCK TO BE GRANTED:
-- trx id 12346 lock_mode X locks rec but not gap waiting

-- *** WE ROLL BACK TRANSACTION (2)

死锁日志解读:*** (1) TRANSACTION持有id=1的记录锁,等待id=2的锁;*** (2) TRANSACTION持有id=2的锁,等待id=1的锁。lock_mode X locks rec but not gap表明是排他记录锁。InnoDB选择了undo量较小的事务2进行回滚。持有锁(HOLDS)和等待锁(WAITING FOR)信息构成完整的锁等待环。

锁等待排查与information_schema

生产环境中遇到锁等待超时(Lock wait timeout exceeded),需要定位阻塞源。MySQL 8.0的performance_schema.data_locksdata_lock_waits表提供了实时锁信息:

-- 查看当前锁等待关系
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,
    TIMESTAMPDIFF(SECOND, b.trx_started, NOW()) AS blocking_duration_sec
FROM information_schema.innodb_trx r
JOIN information_schema.innodb_lock_waits w ON r.trx_id = w.requesting_trx_id
JOIN information_schema.innodb_trx b ON w.blocking_trx_id = b.trx_id
ORDER BY blocking_duration_sec DESC;

-- 查看具体锁定了哪些索引记录
SELECT
    OBJECT_SCHEMA, OBJECT_NAME, INDEX_NAME,
    LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LEFT(LOCK_DATA, 50) AS LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_NAME = 'inventory'
ORDER BY INDEX_NAME, LOCK_DATA;

如果阻塞事务长时间未提交(blocking_duration_sec超过阈值),可通过KILL命令终止对应线程:KILL <blocking_thread_id>;。但需要在确认安全的前提下操作,因为KILL会导致事务回滚。

乐观锁与悲观锁的选型策略

悲观锁通过SELECT ... FOR UPDATE显式加锁,适合写多读少、冲突概率高的场景。乐观锁通过version字段实现CAS(Compare And Swap),适合读多写少、冲突概率低的场景。

-- 悲观锁方案:库存扣减
BEGIN;
SELECT stock FROM inventory WHERE product_id = 1001 FOR UPDATE;
-- 判断stock是否充足
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1001;
COMMIT;

-- 乐观锁方案:基于version的CAS
SELECT id, stock, version FROM inventory WHERE product_id = 1001;
-- 应用层判断stock是否充足
UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE id = #{id} AND version = #{current_version};
-- affected_rows = 0 表示version不匹配,需要重试

乐观锁的完整重试逻辑(伪代码):

int maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
    Inventory inv = inventoryMapper.selectByProductId(productId);
    if (inv.getStock() < quantity) {
        throw new BusinessException("库存不足");
    }
    int affected = inventoryMapper.casDeduct(inv.getId(), quantity, inv.getVersion());
    if (affected > 0) {
        return;  // 成功
    }
    // version不匹配,等待短暂时间后重试
    Thread.sleep(50 * (i + 1));
}
throw new BusinessException("并发冲突,请重试");

在高并发秒杀场景中,悲观锁会将所有请求串行化,QPS被行锁持有时间限制在较低水平。乐观锁允许并发读取,只有UPDATE时才检测冲突,但冲突率高时大量重试反而增加数据库负载。根据实际冲突率选择:冲突率低于5%用乐观锁,高于20%用悲观锁,中间区间可以考虑Redis预扣减加异步DB同步的混合方案。

innodb_lock_wait_timeout与死锁检测调优

-- 查看当前锁等待超时设置(默认50秒)
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

-- 设置锁等待超时为10秒(会话级)
SET SESSION innodb_lock_wait_timeout = 10;

-- 死锁检测(默认开启)
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
-- innodb_deadlock_detect = ON

-- 开启后所有死锁日志写入error log
SET GLOBAL innodb_print_all_deadlocks = ON;

innodb_lock_wait_timeout控制行锁等待的最大时长,超时后返回错误而非无限等待。生产环境建议设为5-10秒,及时发现慢SQL导致的锁阻塞。innodb_deadlock_detect开启时,每次锁等待都会检测是否存在等待环,时间复杂度为O(n^2),在50+并发事务争用同一行时CPU飙升明显。如果业务层面已通过加锁顺序保证无死锁,可关闭检测并用innodb_lock_wait_timeout兜底。innodb_print_all_deadlocks建议开启,将死锁日志写入error log便于事后分析。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/mysql80innodb-suo-ji-zhi-shen-du-jie-xi-xing-suo-jian-xi/

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

相关推荐