
在 MySQL 的高并发事务开发中死锁Deadlock和锁超时Lock wait timeout exceeded是困扰存储研发人员频率最高的两类故障。绝大多数死锁事件的根源都在于对可重复读Repeatable Read下文简称 RR隔离级别下的 Next-Key Lock 加锁范围缺乏精确的物理边界感知。在 MySQL 8.4 LTS 版本中虽然底层锁子系统持续进行了性能重构但 Next-Key Lock 针对唯一索引与非唯一索引的加锁策略与退化逻辑依然遵循严苛的形式化状态机。本文将基于 8.4 版本提供的performance_schema.data_locks视图对两种索引体系的加锁物理边界进行逐一推演与复盘。InnoDB 锁类型物理定义与 Next-Key Lock 构成InnoDB 在 RR 隔离级别下主要通过三类行级锁来协同防止脏读、不可重复读与幻读记录锁Record Lock直接锁定索引记录本身锁模式记为LOCK_REC_NOT_GAP。间隙锁Gap Lock锁定两条索引记录之间的开区间或者第一条记录前/最后一条记录后的开区间锁模式记为LOCK_GAP。间隙锁之间互不排斥唯一目的是阻止其他事务在此间隙插入新记录Prevent Phantom Insert。临键锁Next-Key Lock记录锁与该记录左侧间隙锁的组合锁定的是一个左开右闭区间(prev_record, current_record]。Next-Key Lock 是 RR 级别的默认加锁单位。当执行当前读如SELECT ... FOR UPDATE、UPDATE、DELETE时InnoDB 会沿着索引树从左至右扫描。在扫描过程中根据查询条件是否精确匹配、使用的是主键/唯一索引还是普通辅助索引Next-Key Lock 会触发确定性的降级Degradation机制。实验环境构建与元数据探查准备为了剔除干扰我们在 MySQL 8.4 实例中构建一张包含主键、唯一索引与普通索引的标准测试表-- 环境准备MySQL 8.4 LTS CREATE DATABASE IF NOT EXISTS db_lock_lab; USE db_lock_lab; DROP TABLE IF EXISTS t_account; CREATE TABLE t_account ( id INT NOT NULL AUTO_INCREMENT, biz_code VARCHAR(32) NOT NULL, user_type INT NOT NULL, balance DECIMAL(10,2) NOT NULL DEFAULT 0.00, PRIMARY KEY (id), UNIQUE KEY uk_biz_code (biz_code), KEY idx_user_type (user_type) ) ENGINEInnoDB; -- 灌入离散测试数据 INSERT INTO t_account (id, biz_code, user_type, balance) VALUES (5, B005, 10, 100.00), (10, B010, 20, 200.00), (15, B015, 30, 300.00), (20, B020, 40, 400.00);在此数据分布下索引列形成的物理间隙如下主键id间隙(-∞, 5],(5, 10],(10, 15],(15, 20],(20, supremum]普通索引user_type间隙(-∞, 10],(10, 20],(20, 30],(30, 40],(40, supremum]在 MySQL 8.4 中查看锁详情应当统一使用performance_schema.data_locksSELECT ENGINE_TRANSACTION_ID AS trx_id, OBJECT_NAME AS table_name, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA FROM performance_schema.data_locks;场景推演唯一索引与非唯一索引的加锁分化实验一唯一索引等值命中与未命中在事务 T1 中对唯一索引执行等值命中查询-- 事务 T1 BEGIN; SELECT * FROM t_account WHERE biz_code B010 FOR UPDATE;底层状态推演与退化机制扫描到biz_code B010的唯一索引项。由于uk_biz_code是唯一索引且查询为等值操作数据库确认在当前系统状态下该值不可能出现重复行Next-Key Lock 触发优化退化uk_biz_code上的(B005, B010]临键锁退化为单个记录锁LOCK_REC_NOT_GAP。回表到聚簇索引id 10同样仅施加单个记录锁LOCK_REC_NOT_GAP。最终结果不会锁定任何间隙并发插入B008或B012完全不受阻塞。在事务 T2 中对唯一索引执行等值未命中查询-- 事务 T2 BEGIN; SELECT * FROM t_account WHERE biz_code B012 FOR UPDATE;底层状态推演与退化机制扫描定位到第一个大于B012的记录即B015。引擎对B015尝试施加 Next-Key Lock区间为(B010, B015]。由于是等值查询且记录不存在引擎判定无需锁定B015记录本身Next-Key Lock 退化为纯间隙锁LOCK_GAP锁定区间为(B010, B015)开区间。不会对聚簇索引加锁。实验二非唯一索引普通二级索引等值命中加锁扩散这是生产环境引发死锁最严重的重灾区。我们在事务 T3 中对普通索引执行等值命中查询-- 事务 T3 BEGIN; SELECT * FROM t_account WHERE user_type 20 FOR UPDATE;底层状态推演与退化机制沿着idx_user_type索引树扫描首先定位到user_type 20的记录。对该记录施加 Next-Key Lock锁定区间为(10, 20]。对该记录对应的聚簇索引主键id 10施加记录锁LOCK_REC_NOT_GAP。关键差异点由于普通索引不具备唯一性约束引擎无法确认user_type 20的数据是否已经全部遍历完毕。因此引擎必须强制向右继续扫描下一条记录直到扫到第一个不满足条件的记录即user_type 30。引擎对user_type 30的索引项施加锁。根据 MySQL 优化规则向右扫描到的第一个不满足条件的记录其 Next-Key Lock 退化为间隙锁LOCK_GAP锁定区间为(20, 30)。综合加锁结果二级索引idx_user_type上的锁定范围被横向拉大到了(10, 30)的连续开区间主键索引仅锁定id 10。此时如果并发事务执行以下插入语句-- 事务 T4尝试插入 user_type 25 INSERT INTO t_account (id, biz_code, user_type, balance) VALUES (12, B012, 25, 50.00); -- 阻塞因为 25 落在了 (20, 30) 间隙内。 -- 事务 T5尝试插入 user_type 15 INSERT INTO t_account (id, biz_code, user_type, balance) VALUES (8, B008, 15, 50.00); -- 同样阻塞因为 15 落在了 (10, 20) 间隙内。生产排障与性能调优底线从上述加锁推演中可以总结出高并发存储设计中的几项铁律杜绝在普通二级索引上滥用FOR UPDATE普通索引的等值锁定会天然向右外溢一个间隙锁。如果并发业务线同时基于不同的普通索引字段执行写入和加锁这些外溢的间隙极易发生交叉覆盖百分之百诱发死锁。如果必须加排他锁务必先通过二级索引查出主键id再通过主键精确执行SELECT ... WHERE id ? FOR UPDATE将加锁范围牢牢锚定在单个主键记录上。隔离级别权衡RR 与 RC 的 ROI 评估在读多写极多的核心 OLTP 业务系统中若无强烈的防止可重复读业务需求建议将隔离级别调整为 RCRead Committed。在 RC 级别下间隙锁Gap Lock与 Next-Key Lock 会被默认彻底关闭仅在进行外键完整性约束检查和重复键检查时保留。查询仅在扫描到的真实行上加行锁未命中的记录在语句执行完后立即释放锁系统并发写入能力可提升数倍并大幅消除死锁。监控视图迁移MySQL 8.4 强制要求在编写巡检或自愈脚本时必须废弃旧版的information_schema.innodb_locks与innodb_lock_waits视图这两个视图在 8.0 已经宣布废弃并在高版本中移除。必须无缝切换至performance_schema.data_locks与performance_schema.data_lock_waits并建立针对LOCK_MODE字段中GAP与REC_NOT_GAP属性的自动化解析逻辑。对索引加锁边界的理解越透彻线上系统的并发吞吐越稳固。