MySQL 的锁机制是数据库面试中比“索引优化”还高频的考点也是线上并发问题排查时绕不开的核心技能。本文讲清楚行锁、间隙锁、临键锁这三类 InnoDB 行级锁的底层逻辑、加锁规则和实战排查方法帮大家把锁机制从“背概念”变成“真正会用”。在正式展开之前说一个经常被误解的前提InnoDB 的行锁是“锁在索引上”的不是锁在整行数据上的。这个认知是整个锁机制的基础很多看起来“莫名其妙”的锁冲突其实都是因为对索引和锁的关系理解不到位导致的。后面我会反复提到这一点先记在心里。1. 先把 MySQL 锁机制放在一个框架里看1.1 为什么锁机制这么重要数据库最重要的四个特性 ACID 里隔离性Isolation就是靠锁来实现的。如果不加锁多个事务同时写一条记录数据就会互相覆盖同时读和写一条记录读到一半的数据数据一致性直接崩溃。实际业务中锁引发的问题往往比慢查询更棘手一条普通的 UPDATE 语句卡住后面排队的请求越来越多数据库连接池被打满整个服务就像“雪崩”一样挂掉。解决这类问题的第一步就是理解当前这条 SQL 到底加了什么锁范围有多大为什么会影响其他事务。InnoDB 行级锁一共就三类这是整个行锁机制的全部内容Record Lock行锁锁住的是索引记录本身也就是具体的一行。Gap Lock间隙锁锁住的是索引记录之间的“空隙”防止其他事务在这个区间内插入数据。Next-Key Lock临键锁行锁和间隙锁的组合既锁住记录本身又锁住记录前面的间隙。理解这三者其实只需要弄清楚一个问题为什么只锁一行记录还不够1.2 锁与索引之间的关系InnoDB 的锁机制和索引是强绑定的。聚簇索引主键索引的叶子节点存的是整行数据二级索引的叶子节点存的是主键值。当你执行一条 SQL 时InnoDB 会沿着索引扫描找到目标记录然后对扫描过程中“访问过”的索引记录加锁。这意味着如果 SQL 走了二级索引InnoDB 会同时给二级索引记录和对应的聚簇索引记录加锁。如果 SQL 没有走索引哪怕只更新一行数据InnoDB 也需要全表扫描等于给聚簇索引上的每一条记录都加上锁实际效果等同于锁表并发性能急剧下降。加锁粒度上锁有共享锁S锁读锁和排他锁X锁写锁的区分。S 锁和 S 锁兼容S 锁和 X 锁不兼容X 锁和 X 锁不兼容。InnoDB 行级锁默认情况下普通 SELECT 走的是 MVCC 快照读不加锁INSERT、UPDATE、DELETE 以及 SELECT ... FOR UPDATE 等操作走的是当前读需要加锁。搞清楚这两个概念后面看锁日志时才不会懵。2. 行锁Record Lock锁住一条具体记录的起点2.1 行锁的加锁场景行锁Record Lock是三者中最容易理解的锁住一条具体的索引记录。比如执行SELECT * FROM user WHERE id 10 FOR UPDATE;如果 id 是主键InnoDB 就会在主键索引上给 id10 这条记录加上 X 锁。事务 A 持有该锁期间事务 B 想修改这条记录、或者执行 SELECT ... FOR UPDATE 去读取这条记录都会被阻塞直到事务 A 提交或回滚。行锁的主要应用场景是等值查询主键等值查询、唯一索引等值查询这类查询能够精确定位到一条记录锁的范围精准对其他并发操作影响最小。这里有一个很容易忽略的细节行锁锁的是“索引记录”不是“数据行”本身。如果表上有多个索引每条索引记录都会被单独加锁。例如执行UPDATE user SET name张三 WHERE emailzhangsanexample.com假设 email 上有二级索引InnoDB 会同时锁住二级索引中 email 对应的记录以及聚簇索引中对应主键的记录。索引记录加锁对象 - 二级索引记录email zhangsanexample.com - 聚簇索引记录id 123, name ..., email ...2.2 加锁时机与两阶段锁协议行锁什么时候加在需要的时候才加而且不是在事务一开始就全部加好。这里涉及 InnoDB 非常重要的“两阶段锁协议”2PLTwo-Phase Locking加锁阶段事务执行过程中每执行一条加锁的 SQL就逐步获取所需的锁。解锁阶段事务提交COMMIT或回滚ROLLBACK时所有锁才被统一释放。注意在可重复读RR隔离级别下普通的加锁语句如 UPDATE、DELETE在事务提交前会一直持锁不会中途释放。行锁的最长持有时间就是整个事务的持续时间。这也是为什么“大事务”是锁问题的主要根源之一事务越长持锁时间越长其他事务等待的时间就越长。两阶段锁协议有一个非常实用的推论如果事务中需要操作多个资源尽量把锁竞争最激烈的 SQL 放在最后执行。比如事务里既要更新订单表又要更新库存表而库存表是热点数据那就应该先更新订单表最后再去锁库存表缩短锁的持有时间降低死锁概率。2.3 行锁失效的典型场景行锁失效本质是“无法定位到精确记录”于是锁的范围被迫扩大。唯一索引、主键索引等值查询且条件是唯一命中此时锁退化为精确的行锁。如果查询条件是范围查询哪怕是主键加锁范围就不再只是一条记录而是会扩展到范围内的多条记录。最典型的问题是索引失效。比如索引列上使用了函数、隐式类型转换或者最左前缀原则没有被遵守SQL 就走不了索引变成全表扫描。前面说过全表扫描意味着聚簇索引的所有记录都被加锁并发会被无差别的拖垮。我遇到过最典型的线上事故是开发人员在 id 是 VARCHAR 类型的字段上直接写WHERE id 123MySQL 发生了隐式类型转换索引失效一条只更新一行的 SQL 把所有行都锁了导致全表更新串行化业务直接不可用。排查的时候用EXPLAIN看执行计划type 列从 const/ref 变成了 ALL立刻定位到问题。行锁的本质是“精确制导”它锁小范围的前提是走索引并精确定位缺一不可。3. 间隙锁Gap Lock解决幻读的关键3.1 为什么需要间隙锁还是用一个例子来说。事务 A 执行下面的查询SELECT * FROM user WHERE age BETWEEN 20 AND 30 FOR UPDATE;这个查询满足条件的记录有 3 条。事务 A 加锁了这 3 条记录之后事务 B 能不能插入一条 age25 的新记录从行锁的角度看事务 B 插入的是新记录这 3 条旧记录的行锁并不冲突InnoDB 允许插入。但插入成功后事务 A 再执行同样的查询会发现结果集多了一条记录这就是幻读Phantom Read。幻读的可怕之处在于事务 A 明明“锁定”了查询范围内的数据却无法阻止其他事务在范围内插入新数据导致同一查询在不同时间返回的结果集不一样。为了解决幻读InnoDB 在可重复读RR隔离级别下引入间隙锁锁住记录与记录之间的“空隙”不允许其他事务向这个空隙中插入任何记录。上面的例子中事务 A 加锁的范围除了那 3 条记录本身之外还会锁住这 3 条记录前后以及相互之间的间隙这样事务 B 在插入 age25 时会发现插入位置正好落在间隙锁范围内插入操作被阻塞事务 A 的查询结果就稳定了。3.2 间隙锁加了什么范围间隙锁锁的是索引记录之间的空隙包括第一条记录之前的区间两条记录之间的区间最后一条记录之后到正无穷的区间比如一个 age 索引列上有值 10、20、30那么间隙锁可能覆盖以下范围(-∞, 10) (10, 20) (20, 30) (30, ∞)事务 A 执行WHERE age BETWEEN 20 AND 30 FOR UPDATE加锁范围会覆盖记录 20、30 以及它们之间的间隙 (20, 30)还有可能根据查询条件扩展一些边界间隙保证没有新数据能落在查询范围内。这里有个行业里的经典总结——“加锁规则”常被用于面试答题原则 1加锁的基本单位是 Next-Key Lock临键锁它本身是左开右闭区间比如 (20, 30]。原则 2查找过程中访问到的对象才会被加锁。优化 1唯一索引上的等值查询命中记录时Next-Key Lock 退化为 Record Lock行锁。优化 2等值查询向右遍历时最后一个不满足等值条件的记录其临键锁退化为间隙锁也就是只锁间隙不锁记录本身。不要死记硬背这四条规则在实践中反复印证后自然就理解了。退化的逻辑是合理的既然已经精确定位到了唯一记录就没有必要锁一大片间隙既然已经找到了边界锁住空隙阻挡插入即可不需要再锁住不相干的记录。3.3 间隙锁的副作用间隙锁是解决幻读的功臣但它对高并发插入非常不友好。间隙锁和行锁不同它不锁具体记录只是锁住了某个区间。间隙锁与间隙锁之间相互兼容两个事务可以同时持有重叠的间隙锁。间隙锁与插入操作互斥间隙锁存在时其他事务往该间隙插入记录会被阻塞。这意味着一个事务执行范围查询 FOR UPDATE 加了间隙锁另一个事务往相同区间插入数据时大概率会被卡住直到第一个事务提交。如果业务中大量依赖自增主键或高频插入间隙锁带来的锁等待会成为性能瓶颈。主流处理方案有两个方向一是把事务隔离级别降为读已提交RCRC 下 InnoDB 会取消间隙锁只保留行锁幻读问题从这个层面被“放弃治疗”换来更高的并发度二是尽量少用宽泛的范围查询 FOR UPDATE让等值查询命中唯一索引从而退化为行锁尽量不触发间隙锁。4. 临键锁Next-Key Lock两个锁的组合拳4.1 临键锁的加锁范围临键锁Next-Key Lock是 InnoDB 默认的加锁单位它的加锁范围是左开右闭区间(前一条记录、当前记录]。换句话说一条记录被临键锁锁住表示该记录本身、以及该记录前面的那个间隙都被锁住了。为什么默认加锁单位是临键锁而不是单纯的行锁因为如果只锁记录不锁间隙仍然可能出现幻读。只有当“记录本身”和“记录前的间隙”同时被锁住才能保证查询区间内不会出现新的记录。举个例子在 age 索引上有记录 10、20、30执行范围查询后事务 A 可能会对以下区间加临键锁(10, 20] (20, 30] (30, ∞)如果事务 B 想插入一条 age25 的新记录它需要插入到 20 和 30 之间的间隙中但 (20, 30] 中既包含 30 的记录锁又包含 (20, 30) 的间隙锁插入操作被阻塞。4.2 临键锁的退化机制有意思的是临键锁并不是在所有场景下都以“组合拳”形式存在。按照前面说的加锁规则它存在两个重要的退化分支第一唯一索引等值查询命中时退化为行锁。例如主键 id5 的等值查询直接定位到唯一记录临键锁 (4, 5] 退化为仅锁 id5 这一条记录。原因很好理解等值查询走唯一索引不可能出现其他记录落到这个区间间隙锁没有存在价值去掉它以提升并发。第二等值查询未命中时退化为间隙锁。例如查询WHERE id 7但表中只有 id5 和 id10 的记录此时会向右遍历找到第一条大于 7 的记录 id10然后给区间 (5, 10] 加临键锁但 id7 并不存在10 本身不是查询目标于是退化为只锁间隙 (5, 10)不锁 id10 这条记录本身。这个设计是为了间隔新插入的 id7 数据却不会影响 id10 的更新尽可能缩小影响面。退化机制保证了锁的粒度和查询的精确程度相匹配查得越精确锁的范围越小查得越模糊锁的范围越大。理解这个思想加锁行为就不难预测了。4.3 RR 和 RC 隔离级别下临键锁的差异如果还在纠结“为什么我的 InnoDB 好像没有临键锁”大概率是隔离级别的问题。可重复读RR是 MySQL InnoDB 的默认隔离级别也是临键锁发挥作用的舞台通过“行锁间隙锁”的组合解决幻读。读已提交RC隔离级别下InnoDB 只保留行锁间隙锁被禁用临键锁自然也就不存在了代价是可能发生幻读。RC 的优势是锁范围更小、并发能力更强很多高并发互联网业务会把隔离级别调整为 RC这就是“用一致性换并发”。需要特别强调的是外键约束检查和唯一性检查即使在 RC 下也要用到间隙锁。比如插入数据时检查唯一索引冲突如果没有间隙锁两个事务同时插入相同值就会严重影响唯一性约束的检查所以这部分场景 InnoDB 内部仍然会加上必要的锁。看到这里可以记住一个结论事务隔离级别不会简单地“关闭”锁机制它调整的是默认加锁策略。5. 实战排查和优化锁等待、死锁与性能调优5.1 查看锁等待线上遇到 SQL 执行卡住第一反应往往不是慢查询而是锁等待。优先执行以下命令查看锁状态SHOW ENGINE INNODB STATUS\G输出中的LATEST DETECTED DEADLOCK段展示最近一次死锁的详细信息TRANSACTIONS段展示当前活跃事务的锁等待情况包括持锁事务、等待事务和锁定的具体记录。这是定位锁问题最基础的工具。MySQL 8.0 还提供了更结构化的事务和锁查询表SELECT * FROM performance_schema.data_locks\G SELECT * FROM performance_schema.data_lock_waits\Gdata_locks显示当前持有和等待的锁data_lock_waits显示锁与被锁的依赖关系一眼就能看出谁在等谁。配合information_schema.innodb_trx可以查看事务的运行时间、状态和正在执行的 SQLSELECT trx_id, trx_state, trx_started, trx_wait_started, trx_query FROM information_schema.innodb_trx;如果确认某个事务持锁时间过长导致大量会话堆积可以直接终止该事务KILL 12345; -- 12345 是事务对应会话的 id但这只是急救手段。根本解决思路是找到为什么事务这么长——是业务逻辑里包含外部接口调用还是大批量数据操作迟迟不提交又或者是查询走了全表扫描导致锁范围失控。5.2 一个典型死锁案例分析两个事务同时操作两条记录但加锁顺序相反是死锁最常见的诱因。假设有 id1 和 id2 两条记录两个事务执行如下操作事务 AUPDATE user SET namea1 WHERE id1; 事务 AUPDATE user SET namea2 WHERE id2; 事务 BUPDATE user SET nameb2 WHERE id2; 事务 BUPDATE user SET nameb1 WHERE id1;事务 A 先拿到 id1 的锁事务 B 先拿到 id2 的锁然后 A 想拿 id2 的锁时发现被 B持有B 想拿 id1 的锁时发现被 A 持有两个事务互相等待死锁形成。InnoDB 的死锁检测机制会在发现死锁后主动牺牲一个事务做回滚处理被牺牲的事务收到类似下边的报错ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction这个报错说到一个关键词try restarting transaction。对于被死锁“牺牲”的事务业务代码正确姿势是捕获这个报错并重试而不是直接抛给用户。同时从设计上规避死锁的方式是让所有事务按照相同的顺序加锁——比如都先操作 id1再操作 id2这样锁竞争变成有序的排队而不是互相等待。5.3 锁优化的减法思维对锁机制理解到位之后优化思路其实是一个“做减法”的过程缩小事务范围能一条 SQL 搞定的事情不要拆成多条事务里不要包含 RPC 远程调用小事务优先提交。缩小锁范围让 UPDATE/DELETE/SELECT FOR UPDATE 尽量使用唯一索引等值查询利用临键锁的退化机制锁定单条记录避免在热点表上做宽泛的范围查询和全表 UPDATE。锁定顺序统一同一个业务操作涉及的多个表、多行记录在所有事务中都按照相同的顺序访问减少死锁概率。关注索引设计锁是锁在索引上的索引设计直接影响锁粒度。好的索引不仅加速查询还压缩锁的范围。如果整个事务只涉及单条记录最常见的稳定性建议是让更新语句精确命中唯一索引让锁退化为行锁并发能力会明显提升。这个优化思路在秒杀、库存扣减、订单状态流转等强并发场景下非常常见。5.4 锁机制速查表锁类型锁定范围触发场景隔离级别行锁Record Lock单条索引记录唯一索引/主键等值查询命中RR、RC 均有间隙锁Gap Lock索引记录之间的空隙范围查询、等值未命中仅 RR 默认开启临键锁Next-Key Lock记录本身前面间隙默认加锁单位范围查询仅 RR 默认开启临键锁退化视情况退化为行锁/间隙锁唯一索引等值命中、等值未命中RR 下自动退化我的实操心得聊点个人体会。锁机制学了再多理论真正内化都是靠线上问题喂出来的。我最建议的实践方法是把一堆并发事务的 SQL 用两个会话模拟执行然后用SHOW ENGINE INNODB STATUS去对答案反复验证加锁范围。这套动作做几次对临键锁的退化逻辑会有脱胎换骨的认识。其次遇到锁等待问题别急着调innodb_lock_wait_timeout先定位持锁事务把它干掉再去看业务到底为什么把锁拿这么久。锁机制本身不复杂复杂的是事务里藏着多少没必要的操作。最后记住一句话好的业务设计是让锁机制在绝大多数场景下没有存在感。
