MySQL 锁机制深度解析:从行锁到间隙锁再到意向锁的内部实现
MySQL 锁机制深度解析从行锁到间隙锁再到意向锁的内部实现凌晨的交易对账系统突然大面积超时。排查发现一个 batch update 语句在等待一个已提交事务的间隙锁——而这个间隙锁之所以没有释放是因为该事务的隔离级别是 REPEATABLE-READ且上一个查询建立了一个范围锁定。这就是 MySQL 锁机制的复杂性一个看似无害的 SELECT在特定隔离级别下变成了核弹级锁。一、MySQL 锁体系的全景地图MySQL InnoDB 的锁体系可以从两个维度理解锁的粒度行级 vs 表级和锁的模式共享 vs 排他 vs 意向。但真正让开发者困惑的是行级锁的三个子类型Record Lock、Gap Lock 和 Next-Key Lock。具体而言InnoDB 锁体系的结构可以拆解为以下三个核心层面表级锁范畴包含传统的表锁Table Lock、用于协调行锁与表锁的意向锁Intention Lock分为意向共享锁 IS 和意向排他锁 IX以及自增锁AUTO-INC Lock。行级锁范畴这是核心所在分为锁定单行索引记录的 Record Lock、锁定索引记录之间间隙的 Gap Lock以及结合前两者的临键锁Next-Key Lock即前开后闭区间。锁兼容性规则意向锁之间IS 与 IX通常兼容但意向排他锁IX会与共享锁S或排他锁X冲突。值得注意的是间隙锁Gap Lock之间总是兼容的这有助于理解为何某些并发查询不会相互阻塞。理解这一层级关系后我们重点聚焦于行级锁的具体行为。二、行锁三兄弟Record、Gap、Next-Key2.1 Record Lock锁定精确行Record Lock 锁定的是索引记录而非数据行本身。InnoDB 的所有行锁都加载在索引上——如果表没有索引InnoDB 会创建一个隐藏的聚簇索引来加锁。-- 对 id100 的行加排他 Record Lock SELECT * FROM orders WHERE id 100 FOR UPDATE;Record Lock 只在 READ-COMMITTED 和 REPEATABLE-READ 下使用但行为不同RC 下只锁住符合条件的行RR 下可能升级为 Next-Key Lock。2.2 Gap Lock锁住不存在的行Gap Lock 是 InnoDB 在 REPEATABLE-READ 下的核心机制用于防止幻读。它锁定的是索引记录之间的间隙而不是具体的行。假设表中有 id10, 20, 30 三条记录-- 在 RR 隔离级别下锁定 (-∞, 10), (10, 20), (20, 30), (30, ∞) 这些间隙 SELECT * FROM orders WHERE id BETWEEN 15 AND 25 FOR UPDATE;这个语句会锁住id20Record Lock (10,20) 和 (20,30)Gap Lock。事务提交前任何插入 id16 或 id25 的操作都会被阻塞。Gap Lock 的关键特性Gap Lock 之间总是兼容的。多个事务可以同时持有同一间隙的 Gap Lock因为它们的目的都是防止别人在这个间隙插入而非我要独占这个间隙。2.3 Next-Key Lock前开后闭的黄金组合Next-Key Lock Record Lock 它前面的 Gap Lock。在 RR 隔离级别下当查询使用唯一索引进行等值查询且记录存在时退化为 Record Lock其他情况范围查询、等值查询但记录不存在使用 Next-Key Lock。-- id25不存在在RR下使用Next-Key Lock锁定(20, 30) SELECT * FROM orders WHERE id 25 FOR UPDATE; -- id20存在且是唯一索引等值查询退化为Record Lock SELECT * FROM orders WHERE id 20 FOR UPDATE; -- 范围查询锁定(10, 20] (20, 30] 实际是(10, 30] SELECT * FROM orders WHERE id 10 AND id 30 FOR UPDATE;2.4 加锁范围验证实验-- 实验准备 CREATE TABLE lock_test ( id INT PRIMARY KEY, name VARCHAR(50), INDEX idx_name (name) ) ENGINEInnoDB; INSERT INTO lock_test VALUES (10, a), (20, b), (30, c), (40, d); -- Session A: RR隔离级别 BEGIN; SELECT * FROM lock_test WHERE id 15 AND id 35 FOR UPDATE; -- Session B: 以下操作观察阻塞情况 INSERT INTO lock_test VALUES (9, x); -- ✅ 通过9 10在锁定范围外 INSERT INTO lock_test VALUES (11, y); -- ❌ 阻塞(10,20]被Next-Key锁定 INSERT INTO lock_test VALUES (25, z); -- ❌ 阻塞在(20,30]范围内 INSERT INTO lock_test VALUES (41, w); -- ✅ 通过41 40当前最大记录之后三、意向锁表级与行级的桥梁3.1 为什么需要意向锁InnoDB 支持行锁和表锁共存。如果没有意向锁当一个事务要对表加 X 锁时必须遍历所有行检查是否有行锁冲突——这在千万级表上不可接受。意向锁的作用是声明意图在加行锁之前先在表级加一个意向锁。这样加表锁时只需要检查意向锁无需扫描全表。3.2 意向锁的加锁层级-- 事务A: 对id10的行加S锁 SELECT * FROM lock_test WHERE id 10 LOCK IN SHARE MODE; -- 实际加锁: 表级IS锁 行级S锁 -- 事务B: 尝试加表级X锁 LOCK TABLES lock_test WRITE; -- MySQL检查: 表上有IS锁 → IS与X冲突 → 阻塞 -- 无需扫描每一行information_schema.innodb_trx和innodb_locksMySQL 5.7或performance_schema.data_locksMySQL 8.0可以实时查看锁状态-- MySQL 8.0 查看当前所有锁 SELECT r.trx_id AS waiting_trx, r.trx_mysql_thread_id AS waiting_thread, b.trx_id AS blocking_trx, b.trx_mysql_thread_id AS blocking_thread, l.lock_type, l.lock_mode, l.lock_status, l.lock_data FROM performance_schema.data_lock_waits w JOIN information_schema.innodb_trx r ON w.requesting_engine_transaction_id r.trx_id JOIN information_schema.innodb_trx b ON w.blocking_engine_transaction_id b.trx_id JOIN performance_schema.data_locks l ON w.requesting_engine_lock_id l.engine_lock_id;四、死锁检测Wait-For Graph算法4.1 死锁的经典场景-- 事务A BEGIN; UPDATE orders SET status paid WHERE id 1; -- 持有 id1 的X锁 UPDATE orders SET status paid WHERE id 2; -- 等待 id2 的X锁被事务B持有 -- 事务B同时执行 BEGIN; UPDATE orders SET status shipped WHERE id 2; -- 持有 id2 的X锁 UPDATE orders SET status shipped WHERE id 1; -- 等待 id1 的X锁被事务A持有 -- 死锁形成A等BB等A → 闭环InnoDB 的死锁检测基于 Wait-For GraphWFG算法。每个事务是一个节点事务 A 等待事务 B 释放锁 → 图中有一条 A→B 的有向边。当图中出现环时死锁发生。4.2 死锁检测的性能陷阱死锁检测的时间复杂度是 O(VE)V 是活动事务数E 是等待关系数。当并发事务数超过数千时死锁检测本身会成为 CPU 瓶颈。-- 当并发极高时可以关闭死锁检测依赖锁等待超时 SET GLOBAL innodb_deadlock_detect OFF; SET GLOBAL innodb_lock_wait_timeout 5; -- 5秒后自动放弃4.3 避免死锁的编码实践public class DeadlockSafeOrderService { /** * 避免死锁的批量更新——按主键排序保证加锁顺序一致。 * 两个事务同时更新同一批订单时如果都按id排序加锁 * 不会形成循环等待。 */ public void batchUpdateOrders(ListLong orderIds) { // 关键按主键排序保证所有事务的加锁顺序一致 ListLong sortedIds orderIds.stream() .sorted() .collect(Collectors.toList()); for (Long id : sortedIds) { updateOrderById(id); } } private void updateOrderById(Long orderId) { // 一次只更新一条缩短持锁时间 // 如果可能把多个UPDATE合并为一条批量SQL更优 jdbcTemplate.update( UPDATE orders SET updated_at NOW() WHERE id ?, orderId ); } /** * 更优方案使用IN子句一次性加锁避免逐行持有的间隙 */ public void batchUpdateOrdersOptimized(ListLong orderIds) { // 一次性对所有行加锁MySQL自行处理加锁顺序 String sql UPDATE orders SET updated_at NOW() WHERE id IN ( orderIds.stream().map(String::valueOf).collect(Collectors.joining(,)) ); jdbcTemplate.update(sql); } }五、总结MySQL InnoDB 的锁机制设计体现了 CAP 理论中的一致性优先哲学。Record Lock 保证单行的读写互斥Gap Lock 在 RR 级别防止幻读Next-Key Lock 将两者组合成范围保护意向锁在表级和行级之间架起效率的桥梁。理解锁机制不是为了炫技而是为了在以下场景做出正确决策SELECT FOR UPDATE 的范围要尽可能精确避免锁定不必要的间隙批量更新按主键排序保证所有事务的加锁顺序一致RR 隔离级别的 Gap Lock 可能导致意想不到的阻塞需要评估是否可以降级到 RC高并发场景关闭死锁检测用innodb_lock_wait_timeout替代锁是数据库一致性的物理实现。每一行被锁住的数据都是 ACID 中 Isolation 在物理世界的投影。本文基于 MySQL 8.0.33 InnoDB 引擎MySQL 5.7 的锁信息查询使用information_schema.innodb_locks和innodb_lock_waits。