ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL锁机制详解:行锁、间隙锁、临键锁如何引发死锁与优化策略

MySQL锁机制详解:行锁、间隙锁、临键锁如何引发死锁与优化策略 曾有段时间我被线上一个订单创建接口频繁超时的告警折腾得头大。监控面板里InnoDB的死锁次数每分钟冲到几十次业务方第一反应都是是不是缓存穿透了是不是接口代码有重复提交结果查到最后定位到一个让我印象极深的根因一条普通的UPDATE语句和另一条INSERT语句在RR可重复读隔离级别下因为间隙锁互相等待把整张订单表的高并发写入堵成了串行。从那天起我就意识到MySQL的行锁、间隙锁、临键锁这三兄弟如果不把它们的运作机制彻底搞明白排查死锁基本就是盲人摸象。这篇文章我把这套锁机制拆开讲透。先说明白每种锁解决什么问题、在什么场景下生效再配合具体的加锁规则和死锁排查链路最后给出能直接落地的优化建议。不管你是刚接触MySQL的开发者还是已经被线上死锁折磨过几次的运维这篇都值得花十分钟认真读完。1. 行锁、间隙锁、临键锁三种锁到底在解决什么问题很多刚接触MySQL锁机制的人上来就背概念行锁锁一行间隙锁锁间隙临键锁是行锁加间隙锁。背是背下来了但一遇到实际问题还是懵。原因很简单——你不清楚这三种锁各自为了解决什么痛点而被设计出来。1.1 先从并发下数据失真这件事说起数据库并发访问最怕两件事数据错乱和读到不一致的数据。同一个字段事务A改成1事务B改成2如果不加控制最终结果取决于谁后提交这就是典型的更新丢失。而事务C在读取的过程中事务D插入了一条新记录C再查一次发现多了一行这就是幻读。InnoDB的锁机制本质上就是在用不同粒度的锁去堵住这些并发漏洞。行锁堵的是同一行记录的读写冲突间隙锁堵的是区间内插入新记录产生的幻读临键锁则是把两者合并做到既锁住已有记录、又锁住可能插入的记录。1.2 三种锁的定位差异与关系先给一个总览表后文会逐个展开。| 锁类型 | 英文名 | 锁定的对象 | 解决的核心问题 | 是否依赖索引 | | 行锁 | Record Lock | 单条索引记录 | 同一行记录的并发修改冲突 | 是 | | 间隙锁 | Gap Lock | 两条索引记录之间的间隙 | 防止在间隙中插入新记录导致的幻读 | 是 | | 临键锁 | Next-Key Lock | 一条记录及其前面的间隙 | 行锁与间隙锁的组合根治幻读 | 是 |看到是否依赖索引这一列了吗这是理解InnoDB锁机制的关键。InnoDB的锁不是加在表的一行上而是加在索引记录上。就算你的表没有显式定义主键InnoDB也会在内部生成一个聚簇索引通常是主键索引没有主键就找第一个非空的唯一索引再没有就生成隐藏的ROW_ID。所以行锁、间隙锁、临键锁本质上都是加在索引记录上的。1.3 当前读与快照读锁只对当前读生效在深入锁类型之前必须先分清两种读取方式快照读普通的SELECT语句走MVCC多版本并发控制通过undo log版本链读取历史快照不需要加锁。这也是为什么RR隔离级别下普通SELECT不会阻塞INSERT。当前读SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE这些操作读的是最新版本并且必须加锁。间隙锁、临键锁这些机制只对当前读生效。如果全走快照读MVCC就能解决幻读问题但一旦有当前读就需要临键锁去锁定区间防止其他事务往这个区间里插入数据。理解了这个前提你再看后文的各种加锁场景思路会清晰很多。2. 行锁Record Lock锁定索引记录的最小单元行锁是InnoDB锁体系里最基础、最容易理解的一种。它锁定的是一条具体的索引记录。很多人以为行锁是加在表的一行上这是个隐蔽的误区——实际上它是加在索引记录上这句话后面会有大用。2.1 共享锁与排他锁的读读/读写互斥关系InnoDB的行锁分为两种模式共享锁S锁允许持有者读取该记录也允许其他事务获取同一条记录的S锁。排他锁X锁允许持有者对记录进行修改或删除在同一时刻内不允许其他事务再获取该记录的S锁或X锁。这两种锁的兼容矩阵是| 锁模式 | S锁 | X锁 | | S锁 | 兼容 | 互斥 | | X锁 | 互斥 | 互斥 |通俗点说S锁之间友好相处可以并发读同一条记录S锁和X锁互不相让读的时候不允许改X锁之间也互不相让改的时候不允许再改。2.2 行锁加锁的典型SQL与实操测试动手验证一下。建一张最基础的用户表CREATE TABLE t_user ( id int NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL, age int DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO t_user(id, name, age) VALUES (1, 张三, 10), (3, 李四, 20), (5, 王五, 30);开启两个会话模拟并发-- 会话A START TRANSACTION; SELECT * FROM t_user WHERE id 1 FOR UPDATE; -- 此时id1这条记录被加了X锁 -- 会话B START TRANSACTION; SELECT * FROM t_user WHERE id 1 FOR UPDATE; -- 这里会一直阻塞直到会话A提交或回滚如果会话B执行的是SELECT * FROM t_user WHERE id 3 FOR UPDATE则完全不会阻塞因为锁定的索引记录不同。这就是行锁的粒度优势——把锁冲突控制在最小范围允许不同行上的并发读写。2.3 主键、唯一索引与普通索引上的行锁差异行锁落在不同的索引上效果是有区别的主键索引锁直接落在聚簇索引记录上定位最精确。唯一索引非主键InnoDB会先锁唯一索引记录再通过回表锁定聚簇索引记录。普通索引同样会锁普通索引记录和聚簇索引记录但普通索引允许重复值加锁的记录条数不止一条。这里埋一个重要的伏笔只要加锁路径走了索引即使你Update的是十行只要索引范围小锁的范围也小但如果一条UPDATE的WHERE条件无法使用索引InnoDB会扫描聚簇索引的所有记录相当于对全表每条记录都加X锁——表面是行锁实际表现和表锁差不多。这个坑我在第五部分详细说。3. 间隙锁Gap Lock锁住不存在的区间行锁能管住已有的记录却管不住还没有插入的记录。假设事务A执行SELECT * FROM t_user WHERE age 20 FOR UPDATE锁住了age20那条记录事务B往这个区间插入一条age15的新记录是否受影响如果只有行锁完全不受影响因为新记录是不存在的行锁锁不到它。结果就是事务A第二次执行同样的查询发现结果集里多了一条记录幻读出现了。3.1 间隙锁的区间判定以数据为界的开区间间隙锁锁定的不是某条记录而是两条索引记录之间的空白区间。仍然以上面的t_user表为例假设按age字段建了索引age列上有10、20、30三条记录那么age上的间隙锁可以锁这些区间(-∞, 10)(10, 20)(20, 30)(30, ∞)注意这些区间都是开区间两端是已存在的索引记录值。如果事务A锁住了间隙(10,20)事务B想要往这个间隙里插入age15的新记录会被阻塞直到事务A提交或回滚。3.2 间隙锁为什么要存在幻读问题与插入冲突间隙锁存在的全部意义就是把间隙这种不存在的区域也纳入锁定范围从而阻止其他事务在间隙中插入新记录。这样事务A在RR隔离级别下反复执行同一个当前读查询结果集就不会多出新行——幻读被从机制上掐死了。我来模拟一个典型的间隙锁场景-- 会话A START TRANSACTION; SELECT * FROM t_user WHERE age 20 FOR UPDATE; -- age上建立了普通索引idx_age -- 这条语句不仅会锁住age20对应的聚簇索引记录 -- 还会锁住间隙(10, 20)防止其他事务插入age在10到20之间的记录 -- 实际加锁区间为(10, 20]也就是临键锁这点第四部分细说 -- 会话B START TRANSACTION; INSERT INTO t_user(name, age) VALUES (赵六, 15); -- 阻塞因为age15落在间隙(10, 20)中这个例子中会话B的插入操作会被间隙锁挡住。很多人第一次遇到这种情况都会很困惑明明插入的是另一条新记录为什么会被锁住就是因为你锁住了它应该落进去的间隙。3.3 间隙锁的副作用与死锁温床间隙锁是解决幻读的功臣但也带来了两个不容忽视的副作用副作用一是锁范围变大并发度下降。行锁只堵一条记录的读写间隙锁则堵住整个区间的插入操作相当于把一个范围内的插入全部串行化。副作用二是间隙锁本身是死锁的温床。因为间隙锁与间隙锁之间是兼容的两个事务可以同时锁住同一个间隙但谁都别想往这个间隙里插入新记录——一旦互相等对方的间隙释放死锁就产生了。后面第六部分我会给一个真实的死锁拆解案例。还有一点容易被忽略间隙锁只在RR及以上隔离级别生效。如果切到READ COMMITTED隔离级别间隙锁会被禁用只保留行锁。这也是很多高并发团队宁可牺牲一点隔离性也要把隔离级别降到RC的原因之一。4. 临键锁Next-Key Lock行锁与间隙锁的组合体临键锁可以说是InnoDB锁机制里最精妙的设计。它把行锁和间隙锁合并成一个原子动作锁定范围用的是左开右闭区间。4.1 左开右闭的区间定义与示例一条临键锁的区间可以表示为(a, b]其中a是上一条索引记录的值b是当前索引记录的值。它同时做了两件事锁住了(a,b)之间的间隙还锁住了b这条记录本身。继续看t_user表age列上有10、20、30三条记录那么age上的临键锁区间为(∞, 10](10, 20](20, 30](30, ∞]看到区别了吗和间隙锁相比每个区间多了一个右端点也就是多锁住了一条记录。这意味着临键锁既能防止其他事务往间隙里插入新记录又能防止其他事务修改/删除右端点这条记录。4.2 范围查询与等值查询中的临键锁加锁过程为什么InnoDB选择临键锁作为默认加锁单位我拿一个范围查询来演示-- 会话A START TRANSACTION; SELECT * FROM t_user WHERE age 20 FOR UPDATE;这条语句在age索引上扫描时会依次加这些临键锁(10, 20](20, 30](30, ∞]它覆盖了age从20开始的全部记录及其前面的间隙、以及最后一段无上界的间隙。会话B如果试图INSERT INTO t_user(name, age) VALUES (周七, 25)会被阻塞因为25落在(20,30]这个区间里。等值查询的情况则微妙得多。在唯一索引上做等值查询且命中时临键锁会退化为纯行锁因为唯一索引不会出现两条相同的记录幻读的威胁不存在没必要锁间隙。这个退化规则是整个加锁机制里最实用的知识点第五部分展开。4.3 为什么RR隔离级别默认使用临键锁因为临键锁是最安全的加锁方式。它同时解决了两类问题已有记录的并发修改冲突行锁的职责和新记录插入产生的幻读间隙锁的职责。把这两件事放在同一个锁动作里完成事务在执行当前读时心里非常踏实锁过的区间内既不能改旧数据也别想插新数据。代价同样是并发度下降。一条等值查询如果走的是普通索引可能连锁住一大段区间线上如果频繁执行这类SQL插入操作的等待会非常明显。所以临键锁绝不是越多越好——它是一个基于正确性和性能之间的平衡设计我们在实际优化中经常通过精确利用退化规则来减少锁范围。5. 加锁规则的黄金法则与实际坑这部分是我在实际排查中反复验证过的核心规则。理解了它你基本能手动推演一条SQL加了哪些锁死锁分析也不再是玄学。5.1 等值查询命中与未命中的加锁差异等值查询是加锁规则中最容易踩坑的地方。我把几种情况整理成一张表| 查询条件 | 索引类型 | 命中情况 | 实际加锁 | | 等值查询 | 唯一索引 | 命中 | 退化为行锁只锁目标记录 | | 等值查询 | 唯一索引 | 未命中 | 退化为间隙锁锁住目标值所在间隙 | | 等值查询 | 普通索引 | 命中 | 临键锁 下一记录的间隙锁退化 | | 等值查询 | 普通索引 | 未命中 | 间隙锁锁住目标值所在间隙 |看第二行唯一索引等值查询未命中这是新手最容易忽略的情况。举个例子表里主键id有1、5、9三条记录事务A执行SELECT * FROM t_user WHERE id 7 FOR UPDATE。id7不存在但这条SQL不会因为没查到数据就万事大吉它会在(5,9)这个间隙上加间隙锁。这时会话B试图插入id8会被阻塞。为什么会这样因为InnoDB无法预知id8是否会在当前事务后续步骤中被需要。为了保持RR隔离级别的一致性它宁可把整个间隙锁住也不让任何新记录趁虚而入。5.2 非唯一索引等值查询的回表加锁坑非唯一索引等值查询命中时加的锁远比想象中多。还是那张t_user表age列有普通索引现有记录id1/age10、id3/age20、id5/age30、id7/age40-- 会话A START TRANSACTION; SELECT * FROM t_user WHERE age 20 FOR UPDATE;这条SQL的加锁路径分成两段在idx_age普通索引上加临键锁(10,20]并额外对(20,30)范围加间隙锁因为要向右遍历到第一个不满足条件的值30。通过回表对聚簇索引中的id3记录加行锁。也就是说age20这个等值查询实际上把[10,30)这个范围内的插入动作和记录id3都锁住了。很多并发插入的卡顿就死在这个看似一条记录的查询却锁了半个索引区间的设计上。5.3 范围查询访问到不满足条件的第一条记录意味着什么范围查询加锁的终点往往比你想的更远。执行SELECT * FROM t_user WHERE id 3 FOR UPDATEInnoDB会从id3开始一路加临键锁直到碰到第一个不满足条件的记录才停下来。假设表里id最大是7那会一直锁到(7, ∞)这个上界间隙意味着整个表的新插入都被堵死。这就是为什么范围查询加锁范围大不只是一句忠告而是有明确技术依据的它要保证事务期间这个查询的范围不再产生新数据必须锁定到索引边界。生产环境里但凡UPDATE或DELETE的WHERE条件是范围表达式都要格外小心。我见过太多因为一个UPDATE ... WHERE create_time 2025-01-01把整个业务表写操作堵半天的案例create_time上虽然有索引但范围查询的临键锁直接锁到了正无穷。5.4 无索引条件下的行锁伪表锁最后补一个最隐蔽的坑如果WHERE条件没法使用索引InnoDB会退化为扫描全表的聚簇索引记录逐条加临键锁。虽然锁的模式还是行级别的但因为所有记录都被锁住了新插入的记录即便落在空位也无法落下去上界间隙也被锁了实际效果和表锁没有区别。判断标准很简单用EXPLAIN看执行计划type显示ALL或者extra里出现Using where但没走索引就说明这条SQL在锁层面已经失控了。优化方向是给WHERE条件涉及的列建立合适的索引而不是指望数据库自己聪明地绕过某些记录。6. 死锁排查从现象到根因的完整链路理论说得再多不如一个真实的死锁案例来得直观。我拿最经典的双间隙锁死锁做完整拆解。6.1 经典死锁场景拆解两个事务互相等待间隙锁还是t_user表表中只有id1和id5两条记录。-- 事务A -- 事务B START TRANSACTION; START TRANSACTION; UPDATE t_user SET nameA WHERE id 3; -- 加锁间隙锁(1,5) UPDATE t_user SET nameB WHERE id 4; -- 加锁间隙锁(1,5) INSERT INTO t_user(id,name) VALUES (2,C); -- 需要向间隙(1,5)插入id2 -- 等待事务B释放间隙锁 INSERT INTO t_user(id,name) VALUES (3,D); -- 需要向间隙(1,5)插入id3 -- 等待事务A释放间隙锁 -- DEADLOCK! InnoDB检测到死锁 -- 回滚其中一个事务注意两个UPDATE都没有命中任何记录所以都只加了间隙锁(1,5)而间隙锁之间是兼容的两个事务相安无事。但当双方都想往这个间隙里插入新记录时插入动作需要获取插入意向锁插入意向锁与已有的间隙锁互斥于是双方互相等待死锁形成。InnoDB的死锁检测机制innodb_deadlock_detect默认开启会自动回滚代价较小的事务但业务端会收到明确报错。如果你的代码没做重试补偿这条请求就失败了。6.2 SHOW ENGINE INNODB STATUS输出的解读要点遇到死锁第一个动作不是看代码而是拉InnoDB的状态输出SHOW ENGINE INNODB STATUS\G重点看LATEST DETECTED DEADLOCK这一段。它包含两个事务各自的SQL语句各自持有的锁显示的锁描述类似space id ... page no ... n_bits ... index idx_age各自等待的锁最终被回滚的是哪个事务WE ROLL BACK TRANSACTION (1)这种提示。如果你用的是MySQL 8.0除了这个输出还可以查performance_schema下的锁等待表SELECT * FROM performance_schema.data_locks; SELECT * FROM performance_schema.data_lock_waits;这两张表能实时看到当前所有锁的持有者ENGINE_TRANSACTION_ID和等待关系REQUESTING_ENGINE_TRANSACTION_ID排查锁等待比看死锁日志更快。6.3 降低锁冲突的落地优化建议从锁机制的角度我梳理了几条切实有效的优化方向| 优化措施 | 原理 | 适用场景 | | 尽可能走唯一索引等值查询 | 让临键锁退化为行锁最小化锁范围 | UPDATE/DELETE的WHERE条件优化 | | 范围查询拆分为等值查询或缩小范围 | 减少扫描的索引记录数减少临键锁区间 | 报表统计、批量处理 | | 事务保持短小尽快提交 | 缩短锁持有时间降低死锁窗口 | 所有在线业务 | | 降低隔离级别到READ COMMITTED | 禁用间隙锁和临键锁只保留行锁 | 可容忍不可重复读的子系统 | | 避免无索引条件的大范围更新 | 防止行锁退化为伪表锁 | 定期批量更新任务 |这里特别说一下事务短小。很多死锁不是必然发生而是窗口期太长——两个事务各自持有一把锁然后在较长的一段时间里才去请求对方的锁这期间任何一点交叉都会引爆死锁。把事务里无关的查询挪出去、减少事务中的网络往返、及时提交死锁概率会大幅下降。6.4 关于死锁重试与业务补偿的实操建议锁机制能做到的是尽可能减少死锁但完全避免死锁是不现实的。所以我在实际项目中都会要求业务层对死锁错误码MySQL的1213对应ER_LOCK_DEADLOCK做显式重试。一般来说死锁自动回滚后把当前事务的整个操作重跑一次就能成功因为死锁是瞬时的资源竞争问题不会持续存在。重试要注意两点一是要区分死锁1213和锁等待超时1205后者通常意味着某条SQL运行时间过长盲目重试只会让系统更慢二是重试次数要有限制常见做法是3次以内每次间隔几十毫秒到几百毫秒的随机退避避免多个客户端同时重试再次撞车。7. 把锁机制用到日常SQL审核与设计中去光会排查还不够。我更想说的是锁知识最大的价值在事前预防——在SQL上线之前就基本能判断它会加什么范围的锁会不会影响线上的写入。这是我们团队SQL评审的基本功。我总结了一个懒人判断法首先看WHERE条件是否走索引然后看走的是唯一索引还是普通索引再看是等值查询还是范围查询最后结合当前的隔离级别判断是否会产生间隙锁。这三步走下来一条SQL的锁范围基本能画个八九不离十。每次提交SQL变更前花一分钟过一遍这个过程能避免大量的线上事故。如果你所在团队没有SQL评审机制我建议至少在关键更新语句前面加一行注释标明预计加锁范围出了问题也方便回溯。等线上真的出现死锁再按第六部分的链路去查效率会高很多。毕竟锁机制这种东西等到线上炸了再去翻文档代价太大了。
返回列表