MySQL锁机制全解析:InnoDB行锁、死锁排查与实战
发布时间:2026/10/10 21:44:22 锦皓数字建站

做后端开发和数据库运维的朋友对“锁”这个词一定不陌生。我第一次被MySQL锁问题折腾到加班是好几年前线上一个库存扣减接口间歇性超时业务方一口咬定数据库变慢了查了一整晚才发现是两个事务互相等待对方的行锁典型的锁等待把响应时间拖垮了。从那以后我就明白锁机制不是面试题里的八股文它是线上真实故障的源头之一。这篇文章想把MySQL的锁分类与加锁机制完整梳理一遍重点关注InnoDB引擎下全局锁、表锁、行锁、意向锁、间隙锁这些概念在真实SQL里是怎么生效的适合正在做数据库调优、准备面试或者手头刚好有锁等待和死锁问题需要排查思路的朋友参考。我尽量用实际SQL场景来讲不堆概念。1. 锁的分类全景先看清楚MySQL锁家族都有谁1.1 按粒度划分全局锁、表级锁与行级锁MySQL的锁按粒度从大到小大致分成三类。全局锁是最大粒度的锁作用于整个实例。常见操作是FLUSH TABLES WITH READ LOCK执行之后整个库变成只读状态所有写操作、DDL操作全被阻塞。这个锁最常见的场景是传统逻辑备份工具做一致性快照在某些不支持在线热备的引擎上使用。线上生产环境我几乎不手动用全局锁因为那相当于把数据库临时切成只读对高可用系统来说是灾难。哪怕做维护也建议优先用在线备份方案除非你能接受读写中断。表级锁顾名思义锁住整张表。常见的有LOCK TABLES t READ/WRITE这种手动表锁还有两类特殊的表级锁一类是元数据锁MDL锁用于保护表结构的一致性另一类是自增锁AUTO-INC锁用于保护自增计数器的并发分配。这两类后面展开讲。行级锁是InnoDB引擎的核心特性粒度最细只锁索引记录所以并发性能最好。MyISAM只有表锁、没有行锁这也是为什么MyISAM在并发写场景下表现远不如InnoDB的原因之一。当初从MyISAM迁移到InnoDB的团队往往就是被写锁阻塞逼的。行锁虽好但不代表加锁范围就一定小它取决于SQL是否走了索引这点后面会重点讲。1.2 按模式划分共享锁与排他锁锁按读写模式分就两种共享锁S锁和排他锁X锁。共享锁也叫读锁。多个事务可以同时持有同一资源上的共享锁大家都能读谁也不挡谁。对应的SQL是SELECT ... LOCK IN SHARE MODE在MySQL 8.0里也可以写成SELECT ... FOR SHARE。排他锁也叫写锁。一个事务持有排他锁时其他事务既不能对该资源加共享锁也不能加排他锁只能等。UPDATE、DELETE、INSERT以及SELECT ... FOR UPDATE加的都是排他锁。这里有个特别容易踩坑的认知很多人以为普通SELECT也会加共享锁其实默认的普通查询是快照读不加任何锁靠MVCC读历史版本。只有显式加锁的读和写操作才属于当前读会真正触发加锁逻辑。判断一条SQL是否加锁第一步就是区分它到底是快照读还是当前读。1.3 意向锁表锁与行锁之间的桥梁意向锁是我见过最容易被忽略、但又极其重要的锁。它属于表级锁但本身不锁任何具体行只表达“这个事务想在表中的某些行上加什么类型的锁”。意向共享锁IS事务准备在表中的某些行加S锁。意向排他锁IX事务准备在表中的某些行加X锁。为什么需要这两个东西关键在于锁冲突检测的效率。假设没有意向锁事务A给某表的一行加了行级排他锁此时事务B想给整张表加表级排他锁它得怎么判断是否冲突只能全表扫描看看有没有任何一行被行锁占用这个成本高得离谱。有了意向锁事务A在加行锁之前会先给表打一个IX标记事务B加表锁时只要看一眼表上有没有IX/IS标记就能快速判断。这就是意向锁的意义——用极小的代价在表级别记录行锁的存在状态。意向锁之间是互相兼容的IS和IX可以同时存在两个事务都准备对表内不同行加锁这本身不冲突。真正的冲突判断要等到行锁级别再比较。2. InnoDB行锁的加锁机制为什么命中索引才锁得准2.1 行锁到底锁的是什么东西InnoDB的索引是B树结构所谓的“行锁”严格来说锁的是索引记录而不是物理行。每条记录在聚簇索引里都有一份如果表没有主键InnoDB会生成一个隐藏的GEN_CLUST_INDEX作为聚簇索引。所以SQL能不能命中索引直接决定了行锁的精准度。这里有个线上事故高发点UPDATE t SET status 0 WHERE create_time 2025-01-01如果create_time上没有索引InnoDB只能全表扫描表面上是“更新符合条件的行”实际上是给全表每一条记录都加了排他锁所有写操作全部排队。我一个朋友遇到过类似情况业务方以为只更新了几万行结果是整表被锁其他请求全在等锁数据库基本处于瘫痪状态。所以任何写操作执行之前第一件事就是EXPLAIN确认不是typeALL。2.2 记录锁、间隙锁与临键锁三分法InnoDB的加锁单位并不是单一的记录锁按作用范围可以拆成三种记录锁Record Lock锁一条具体的索引记录比如id5这条记录上的锁防止其他事务更新或删除它。间隙锁Gap Lock锁的是一个开区间比如记录1和3之间的(1,3)。间隙锁的目的是防止其他事务在这个间隙里插入新记录它不是锁某条记录而是锁“位置上不允许插入东西”。临键锁Next-Key Lock是记录锁加间隙锁的组合体锁的是左开右闭区间比如(1,3]。它既锁住了3这条记录又锁住了1到3之间的空隙是MySQL在REPEATABLE READ隔离级别下防止幻读的关键武器。举个例子就更直观假设表里有主键id1、3、5三条记录。事务A执行SELECT * FROM t WHERE id 1 FOR UPDATE在RR级别下它加的就是(1,3]、(3,5]、(5,∞)这三个临键锁。此时事务B想插入id4会被间隙锁挡住因为4落在(3,5)这个间隙里。这就是当前读场景下幻读被防住的原理。2.3 唯一索引与普通索引的加锁差异很多人在面试里被问过这个问题等值查询命中唯一索引和命中普通索引加锁范围一样吗答案是完全不一样。如果走唯一索引做等值查询比如WHERE id 5InnoDB能确定只有一条记录满足条件因此只需要加一个记录锁不需要间隙锁。如果走普通二级索引做等值查询比如WHERE name 张三情况就变了。普通索引不保证唯一当前这条记录存在不代表以后不会插入另一条name张三的记录。所以在RR级别下InnoDB会加临键锁把当前记录和它前后的间隙一起锁住防止幻影插入。还有一个容易被忽略的场景等值查询走唯一索引但记录不存在。比如WHERE id 4而表里没有id4。InnoDB依然会加锁但具体锁的是一个间隙例如(3,5)。这个间隙锁会让试图插入id4的事务阻塞。所以不要觉得“查不到数据就没锁”。2.4 两阶段锁协议与锁释放时机InnoDB遵循两阶段锁协议事务在执行过程中可以随时加锁但所有锁要到事务提交或者回滚时才会统一释放。也就是说锁的持有时间等于事务的存活时间。这个特性带来的一个工程指导原则尽量把事务写短把可能引发冲突的操作集中放后面。比如事务里先查询、再更新、再插入如果查询和公共资源的更新可以放在更早的阶段就尽量压缩整体持锁窗口。我见过一个慢事务打开后保持几十秒不提交期间它持有的行锁把其他事务全部卡住。这种长事务危害极大不只是锁等待还会让undo log膨胀。所以监控里要尤其关注trx_started时间早、trx_state为RUNNING的事务。3. 表级锁与元数据锁的实战认知3.1 手动表锁什么时候才会真的用上说实话在InnoDB默认引擎的前提下日常业务几乎没有手动加表锁的必要。LOCK TABLES t WRITE这种操作通常出现在存储过程、历史数据归档、或者多个表需要保持一致快照的场景中。手写表锁要特别小心LOCK TABLES会隐式提交当前事务而UNLOCK TABLES则释放所有表锁。如果你在事务中间执行了LOCK TABLES后续的COMMIT可能超出预期。我自己实践的时候一般只在脚本里这么写并且无论过程是否报错都要在finally或者退出分支里确保UNLOCK TABLES执行到。否则一旦脚本中断表锁会一直挂在连接上其他会话全部等锁。3.2 MDL锁DML与DDL之间的隐式战争MDL锁是MySQL Server层自动加的表级锁目的很单纯保护表结构元数据防止读到不一致的表定义。执行增删改查时需要拿MDL读锁执行ALTER TABLE、DROP TABLE这类DDL时需要拿MDL写锁。MDL读锁和读锁兼容但写锁和所有其他锁冲突。线上最典型的MDL事故是这个节奏一个长事务开了没提交手里握着表的MDL读锁。此时后台跑了一个ALTER TABLE排队等MDL写锁。问题在于MySQL 8.0里MDL写锁等待是会阻塞后续所有新请求的因为后面来的普通查询也想拿MDL读锁但写锁已经在等待队列里了读锁只能排在它后面。于是整张表的读写全部卡住连接池被占满应用层大面积超时。排查时SHOW PROCESSLIST里会出现一堆Waiting for table metadata lock状态的会话。处理办法很粗暴但有效找出那个没提交的长事务KILL掉。这也给我们两个启发一是生产环境的DDL尽量在低峰期执行二是lock_wait_timeout这个参数值得好好设置。默认值非常长等于一年线上通常要调到较小的值让DDL快速失败而不是无限排队。3.3 自增锁自增主键背后的并发之争自增锁是InnoDB为AUTO_INCREMENT列准备的专用表级锁。插入语句要拿到新的自增值就得保护自增计数器。innodb_autoinc_lock_mode这个参数决定了自增锁的加锁方式。0传统模式所有插入都会持表级自增锁并发最差基本不建议。1连续模式单条INSERT插入行数已知用互斥量轻量级拿值不挂表锁INSERT ... SELECT这类批量插入仍用表级锁保证自增值连续。这在5.7里是默认值。2交错模式不管单条还是批量都用轻量级方式并发最好代价是批量插入的自增值可能不连续。MySQL 8.0默认用2。这里有个认知误区自增ID出现空洞不代表数据库有问题。即使锁模式是连续模式插入失败回滚也不会重用已经分配的自增值所以从1到3之间直接跳到5太正常了。如果业务对数值连续性有强迫症那是业务表设计的问题不是InnoDB该管的。4. 不同隔离级别下的加锁差异4.1 READ COMMITTED 与 REPEATABLE READ 的锁差别隔离级别是决定加锁范围的重要变量。MySQL 8.0默认是REPEATABLE READRR在这个级别下InnoDB会启用间隙锁和临键锁来防止幻读。而READ COMMITTEDRC级别下间隙锁基本被禁用除了外键约束检查等极少场景每次加锁只锁记录本身因此锁冲突更少、并发更高但幻读无法被避免。很多云数据库默认就是RC比如一些托管服务把默认隔离级别设成了RC目的就是换取更高的吞吐和更低的锁冲突概率。自建MySQL里也经常有人手动改成RC。如果你不需要严格的幻读防护RC确实是更务实的选择。反之如果你的业务强依赖RR的防幻读保证那就别轻易动隔离级别否则某些诡异的数据重复问题会找上门。4.2 RR下的幻读防线临键锁是怎么工作的接着刚才的例子讲。RR级别下事务A执行SELECT * FROM t WHERE id 3 FOR UPDATEInnoDB会把(3,5]、(5,∞)这些区间都锁住。事务B想插入id4直接被挡在外面当前读的查询结果集就稳定了。这种“稳定”不是靠MVCC的快照而是靠真正的锁来限制其他事务的写入。但注意RR在快照读场景下靠的是MVCC的版本链在加锁读场景下靠的是临键锁。两者配合才做到当前读和快照读都不出现幻读。如果你只记住了“RR防幻读”却没搞清楚防的是哪一种读排查问题时容易走弯路。4.3 一致性读与当前读先分清再谈锁我在跟团队新人讲加锁时说得最多的一句话是先判断这条SQL是快照读还是当前读。快照读普通SELECT不加锁通过undo log构造一致性视图读到的可能是历史版本。当前读SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE、DELETE、INSERT这些操作必须读到最新已提交版本因此要加锁。举个例子同样是SELECT * FROM t WHERE id 1不带子句就是快照读带FOR UPDATE就成了当前读锁行为完全不同。很多线上“明明这条SELECT很慢”的疑惑一查才发现是有FOR UPDATE它根本不是普通查询。所以我自己有一个排查锁问题的三步框架先区分读类型再看SQL走了哪个索引最后确认隔离级别。这三步走完一条SQL的加锁范围基本能心中有数。5. 锁兼容性与死锁实战排查5.1 锁兼容矩阵谁和谁可以共存锁冲突这事不能靠死记得看兼容矩阵。下面这个表简化了S锁、X锁、IS锁、IX锁在表级别的兼容关系锁类型SXISIXS兼容冲突兼容冲突X冲突冲突冲突冲突IS兼容冲突兼容兼容IX冲突冲突兼容兼容看这个表的重点有两个。第一S锁和S锁兼容大家一起读没问题X锁跟谁都不兼容所以才叫排他锁。第二意向锁与意向锁之间都兼容IA和IX可以同时存在因为具体冲突要到行锁层面再判断。可以这样理解意向锁只是表级的“意愿声明”真正的裁决发生在行级或间隙级。死锁排查时看到IX和IX共存别急着下结论说冲突要看它们背后等待的具体行锁。5.2 死锁成因与一个经典复现场景死锁产生的四个必要条件互斥、持有并等待、不可剥夺、循环等待。在MySQL里最经典的死锁长这样事务A先更新id1的行再更新id2的行。事务B先更新id2的行再更新id1的行。两个事务同时执行A持有id1的X锁等待id2B持有id2的X锁等待id1谁也等不到谁于是死锁。InnoDB的死锁检测机制会在检测到循环等待后选择回滚代价较小的事务释放锁让另一个事务继续。所以遇到死锁不应该只看“报错了”要看是哪个事务被牺牲。工程上避免这类死锁的最直接手段是让所有事务按同一顺序加锁比如统一按主键升序处理多行更新。批量更新多条记录时先把主键集合排序再操作可以大幅降低死锁概率。5.3 排查工具从哪里看到锁的真身锁问题排查的核心工具是performance_schema和SHOW ENGINE INNODB STATUS。SHOW ENGINE INNODB STATUS里的LATEST DETECTED DEADLOCK段落会记录最近一次死锁的详细执行语句、持有锁和等待锁的信息是死锁分析的一手资料。performance_schema.data_locks表能看到当前所有事务持有的锁明细performance_schema.data_lock_waits能看到谁在等谁的锁。配合performance_schema查询锁等待的SQL可以写成这样SELECT * FROM performance_schema.data_locks; SELECT * FROM performance_schema.data_lock_waits;再用information_schema.innodb_trx查所有运行中的事务关联trx_started和trx_mysql_thread_id基本就能定位到是哪个事务持有锁、哪个事务在等待。SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx;定位到阻塞源之后如果持有锁的事务是个长期不提交的空事务直接KILL对应的线程ID就能释放阻塞。执行前最好评估一下这个事务是不是正在跑关键业务别误杀。5.4 两个锁等待参数别把超时时间当摆设和锁等待关系最大的两个参数很多人容易搞混。innodb_lock_wait_timeout是InnoDB行锁等待超时时间默认50秒。业务侧如果不想让请求在锁等待上耗太久可以考虑调小到5到10秒让请求快速失败、快速重试。但别调到1秒这种极端值否则正常的短暂锁等待也会被误杀。lock_wait_timeout则是MDL锁等待超时时间默认值长到吓人差不多一年。这就是之前说的MDL事故能拖垮业务的原因之一。生产环境建议把它调整到一个合理区间比如几十秒让DDL等待迅速超时而不是无限排队。这类参数最好由DBA统一评估后配置不要在业务代码里盲目改。6. 常见问题速查与避坑实录6.1 锁等待超时报错怎么办最典型的报错是ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction。出现这个错误说明当前事务等待某行锁超过了innodb_lock_wait_timeout。排查顺序我总结成四步。第一步查information_schema.innodb_trx找出所有正在运行且状态不为COMMITTED的事务重点看trx_started很早的那几个。第二步查performance_schema.data_lock_waits找到具体的等待链。第三步顺着data_locks表找到谁持有目标行的排他锁。第四步评估后KILL阻塞源或者等它自然提交。一个很重要的保命经验如果锁等待日志里同时出现大量同类请求很可能不是某一个事务的问题而是某条慢SQL全表扫描锁了全表。这时候光杀一个事务没用得先把那条SQL停止或优化掉。6.2 从工程层面降低死锁概率死锁无法完全避免但可以大幅压低它的发生概率。我常用这几个手段多行更新前先对主键排序保证加锁顺序全局一致。事务尽量短平快不在事务里做远程调用、重活、人工确认这些耗时操作。为高频更新字段建合适的索引让UPDATE和DELETE尽量缩小锁范围。报表类或高并发读多的场景可以引入乐观锁用版本号或时间戳字段做冲突检测避免长时间持锁。特别注意最后一点乐观锁适合读多写少写多读少的场景硬上乐观锁反而会频繁重试性能更差。6.3 一条UPDATE引起的连锁反应最后讲一个我印象很深的现场案例。某系统要清理历史数据开发人员执行了一条UPDATE t SET status 0 WHERE gmt_create 2024-01-01这条SQL没有走索引InnoDB直接全表扫描加行锁。表面上看只是历史数据的更新实际上把整张表的所有记录都锁了。随后上万的写入请求全部进入锁等待数据库连接池被占满最终表现为“数据库卡死”。当时的解决办法是马上找到这条UPDATE的会话并KILL让被阻塞的请求先恢复随后在gmt_create字段上补充索引把更新改成按主键分片批量执行每批一两千行批次之间留一点间隔。整个过程复盘下来本质是“没走索引”四个字引发的连锁反应。以后我只要见到UPDATE或DELETE语句都会习惯性先EXPLAIN看一眼扫描行数这是成本最低的保命动作。坦白说MySQL的锁机制内容很庞杂单靠一篇文章不可能覆盖所有犄角旮旯。我自己也是在线上被坑过几次之后才真正把这些分类和规则串成了一条完整的排查链路。如果你现在手头正好有锁等待或者死锁问题建议先把innodb_trx和data_locks查一遍用上文的三步框架走一遍绝大多数问题都能找到明确方向。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。