
MySQL的锁是个老生常谈的话题但真要问起来十个人里八个会卡壳。面试被问是一回事线上出问题时才是真考验——明明一条UPDATE就是执行不完明明两个事务互相等得死死的你却不知道该看哪张表、哪条命令、哪个参数。这篇就把MySQL锁从分类到实战拆开揉碎讲清楚从全局锁、表级锁、行级锁到间隙锁、临键锁再到死锁排查和分布式锁边界一次说透。写这东西的初衷很简单我见过太多人把锁表挂嘴边却分不清MDL锁和行锁的区别也见过开发在RR隔离级别下被间隙锁坑到全表插入阻塞却死活找不到原因。这篇不是给你背概念的是照着做就能定位问题、写出更稳的事务逻辑的实战手册。适合被锁问题困扰的开发者、DBA、以及对数据库并发控制有兴趣的任何人。1. 为什么MySQL需要锁并发控制的底层逻辑1.1 没有锁的数据库会变成什么样先做一个思想实验。假设你是一个电商平台的数据库商品表里有一行记录库存是10件。两个用户同时下单各自执行一条UPDATE product SET stock stock - 1 WHERE id 1。如果没有锁这两条语句会同时读到stock10然后都写回9最后你只卖了1件商品账面却显示库存从10变成了9——实际上应该卖出2件库存变成8。这个场景在计算机领域叫丢失更新。你可能觉得这个例子太理想化实际MySQL的UPDATE真的会同时执行吗答案是只要没有锁控制完全可能。InnoDB引擎默认用行锁来保证这种并发更新的正确性但如果你用了MyISAM引擎或者在某条语句上没有正确走索引锁的粒度就会从行退化成表并发能力瞬间断崖式下跌。这就是锁存在的意义在多个事务并发操作同一份数据时通过某种机制让它们排队或者互斥保证最终结果等价于串行执行。但排队的代价是吞吐量下降所以MySQL的锁体系设计得非常精细目的是在正确性和并发性之间找到平衡点。1.2 锁的粒度决定并发上限锁的核心维度是粒度granularity。粒度越小允许并发的程度越高但管理锁的成本也越大粒度越大实现越简单但阻塞范围也随之扩大。MySQL在同一套体系里同时提供了多种粒度的锁从数据库全局、到表、再到行覆盖不同场景。打个比方全局锁就像整栋楼停水谁都别用表级锁就像整层楼停水这层的人全等着行级锁就像你隔壁邻居家修水管只有他一家受影响。实际业务里我们追求的是精准打击——只锁住需要修改的那一行而不是殃及池鱼。MySQL的锁分类从粒度上可以分成三类全局锁、表级锁、行级锁。每一类里又有细分的锁类型和实现方式。你平时听说的MDL锁意向锁自增锁间隙锁临键锁这些名词全都归属在这三个大类之下。把分类体系搞清楚后面的所有排查手段才有意义。2. 锁的分类全景全局锁、表级锁、行级锁2.1 全局锁让整个实例只读的核按钮全局锁是对整个数据库实例加锁加锁期间整个实例处于只读状态。命令是FLUSH TABLES WITH READ LOCK简称FTWRL。这条命令执行后所有表的写操作都会被阻塞直到释放全局锁。什么时候会用到它最典型的是全库逻辑备份。比如用mysqldump做一致性备份如果不加锁备份过程中数据还在不断变化备份出来的文件就是一个「不一致的快照」——某个时刻的A表数据和B表数据可能不是同一时间点的恢复出来就会出问题。FTWRL的代价非常明显加锁期间业务的写入全部卡死。所以现在官方推荐的备份工具比如MySQL 8.0里的mysqldump --single-transaction会利用MVCC机制来做一致性快照避免使用全局锁。但需要注意--single-transaction只对InnoDB有效如果表里有MyISAM表仍然需要FTWRL来保证一致性。全局锁还有一个日常使用场景容易被忽略当你需要做只读从库的静态数据导出或者需要在多个节点间做数据比对时临时加一把全局锁可以确保数据在比对期间不被修改。但务必控制加锁时长实测超过10秒的业务高峰期应用端的连接池就会开始堆积请求很快打满。2.2 表级锁MDL锁、表锁和自增锁表级锁分三类分别解决不同的并发问题。第一类是表锁LOCK TABLES这是最古老的锁类型显式地对整张表加锁。这种锁在InnoDB引擎下基本被淘汰了因为InnoDB支持行锁不需要把整个表锁住来保证某一行数据的正确性。如果你在InnoDB表上执行LOCK TABLES t WRITE所有其他会话的读写都会被阻塞行锁的优势荡然无存。但在MyISAM引擎里表锁仍然是唯一的选择因为MyISAM根本不支持行锁。第二类是MDL锁Metadata Lock元数据锁这是MySQL 5.5以后引入的很多人不了解它的存在因为它是自动加、自动释放的。MDL锁的作用是保护表结构定义。比如一个事务在查询一张表的时候如果另一个事务同时执行ALTER TABLE修改表结构就会产生冲突——查出来的数据和表结构对不上怎么办所以MySQL规定表结构变更需要排他MDL锁读写操作需要共享MDL锁。这里有个大坑如果一个长事务一直没提交它持有的共享MDL锁会一直不释放导致所有ALTER TABLE操作被阻塞进而阻塞后续所有DML操作。线上排查为什么所有查询突然变慢十有八九是MDL锁堆积。第三类是自增锁AUTO-INC Lock专门用于自增列。插入数据时自增列需要生成唯一递增的值这个生成过程必须有锁保护。MySQL有多种自增锁模式默认是连续模式批量插入时可能会阻塞其他插入语句。如果自增列的值出现跳跃比如回滚后自增值不复用基本不影响正确性但如果插入频繁且批量插入多自增锁会成为热点瓶颈。2.3 行级锁InnoDB的杀手锏行级锁是InnoDB引擎的核心特性也是面试和实战中的绝对重点。InnoDB的行锁加在索引记录上。这句话值得划重点如果一条SQL没有走任何索引InnoDB在内部会通过隐藏的聚簇索引主键索引来扫描记录此时锁定的范围可能变成全表——也就是说行锁退化成伪表锁。这个特性是大量锁表问题的根源。你以为在执行行锁实际上因为WHERE条件列没索引把整张表的行都锁了。InnoDB行锁有共享锁S锁和排他锁X锁两种。共享锁之间可以共存共享锁和排他锁互斥排他锁之间也互斥。怎么理解共享锁就是大家可以一起读排他锁是我改的时候谁都不能碰。SELECT默认不加锁走MVCC快照读SELECT ... FOR SHARE加共享锁SELECT ... FOR UPDATE、UPDATE、DELETE加排他锁。MVCC机制让普通SELECT不会阻塞写操作这是InnoDB能承受高并发读写的关键。但快照读和当前读之间的区别容易让人困惑。快照读读的是历史版本不需要加锁当前读必须读最新版本必须加锁。在某些事务隔离级别下这两类读的配合方式直接决定了你会不会遇到幻读。2.4 意向锁表锁和行锁之间的沟通桥梁关于行级锁这里有一个常被忽视但非常重要的设计意向锁Intention Lock。它不是用来锁数据的而是用来预告的——表示我准备在某个表里加行锁了。分为意向共享锁IS锁和意向排他锁IX锁。为什么需要这个假设一个事务准备给整张表加表锁它怎么知道表里有没有行锁正在被持有最笨的办法是扫描每一行那代价太高了。有了意向锁事务在加行锁之前会先在表级别加一个意向锁这样后来的表锁请求只需要检查表上有没有意向锁就能快速判断是否可以加锁。这就是锁的排队效率的设计思路。很多人在SHOW ENGINE INNODB STATUS的输出里看到一堆LOCK_MODE: IX的锁记录以为有人在锁表其实那只是正常事务持有行锁之前打的预告牌不用担心。3. 行锁的深层细节Record Lock、Gap Lock与Next-Key Lock3.1 幻读问题和锁的范围化在可重复读RR隔离级别下InnoDB要解决一个经典问题幻读。幻读指的是同一个事务里两次查询第二次查出了第一次不存在的幻影行。光靠锁定已有的行记录是防不住幻读的——因为幻影行压根还不存在你怎么锁一个不存在的东西。解决思路是把不存在的范围也锁住。InnoDB引入了三种锁Record Lock记录锁锁定索引上的某一条具体记录。Gap Lock间隙锁锁定一个范围开区间但不锁定任何具体记录。Next-Key Lock临键锁锁定记录记录之前的间隙是一个左开右闭区间。它们之间的关系可以这样理解InnoDB默认用Next-Key Lock锁定扫描范围内的索引记录及其前方的间隙保证在事务提交前其他事务无法在这个范围内插入新数据。3.2 间隙锁的具体机制与复现过程光说概念太抽象。举个例子假设一张表t主键id从1到5我们再插入id7、id9两行表中就有1、3、5、7、9五个值。事务A执行SELECT * FROM t WHERE id BETWEEN 5 AND 7 FOR UPDATE;这条SQL不仅会锁住id5和id7这两条记录还会在(5,7)这个间隙加间隙锁在(5,7]这个范围内加Next-Key Lock。如果此时事务B试图插入id6的新记录会被阻塞直到事务A提交或回滚。间隙锁的规则在不同隔离级别下不一样。在读提交RC隔离级别下InnoDB只保留Record Lock用于防止更新丢失不启用Gap Lock和Next-Key Lock代价是无法彻底杜绝幻读。在RR级别下Gap Lock和Next-Key Lock是默认开启的这也是MySQL默认用RR的原因之一——它比SQL标准要求的RR更强几乎做到了可串行化的效果同时又不完全牺牲并发。实测中最容易踩的坑是条件列没有唯一索引时间隙锁的范围会扩大。比如上面那个表如果WHERE条件查的不是主键id而是没有索引的普通字段InnoDB会对所有匹配的行以及它们之间的所有间隙都加锁范围远大于你直觉上认为的那几行。结果就是你在事务里改一条记录整个表的插入操作全被堵住。3.3 插入意向锁的特殊行为在聊间隙锁的时候还有个绕不开的概念插入意向锁Insert Intention Lock。它不是普通的意向锁而是一种特殊的间隙锁专用于INSERT操作。当一个事务想在某个间隙插入记录时它必须先获取插入意向锁。插入意向锁之间是互相兼容的——两个不同的事务可以同时对同一个间隙加插入意向锁只要插入的位置不冲突。但如果这个间隙已经被另一个事务加了间隙锁或Next-Key Lock那插入意向锁就会和它冲突插入操作被阻塞。这里有一个诡异的场景事务A在间隙(5,7)上持有了间隙锁事务B尝试在这个间隙插入id6被阻塞事务C也尝试插入id6也被阻塞。两个事务都在等待同一个间隙锁释放。如果事务A提交了事务B和事务C会同时竞争其中一个插入成功另一个立刻因为主键冲突报错。你会在日志里看到这条有名的死锁报错Deadlock found when trying to get lock; try restarting transaction这种间隙锁打架问题光靠看代码很难定位必须在测试环境复现。很多线上的插入阻塞就是这么微妙。3.4 RR与RC隔离级别的锁差异对比隔离级别对锁的行为影响极大做成一张对比表方便查阅隔离级别脏读幻读Record LockGap LockNext-Key Lock适用场景READ UNCOMMITTED可能可能有无无几乎不用READ COMMITTED不会可能有无无互联网高并发业务常用REPEATABLE READ不会不会InnoDB有有有MySQL默认级别SERIALIZABLE不会不会有读也加锁有有需要强一致性的低频场景RC级别下因为没有间隙锁并发插入能力明显更强但牺牲的是语义一致性。如果业务本身有并发插入的需求又不需要RR的幻读保护可以考虑把隔离级别改成RC。需要强调的是更改隔离级别前必须和业务方确认语义是否有同一条件下不允许产生新数据的需求。这往往不是技术判断而是业务逻辑判断。4. 锁的监控与排查从定位到解决4.1 锁等待超时认识两个关键参数排查锁问题第一步是看两个超时参数innodb_lock_wait_timeout事务等待行锁的最长时间默认50秒。超过这个时间报错Lock wait timeout exceeded; try restarting transaction。lock_wait_timeout元数据锁MDL锁的等待超时时间默认31536000秒一年。注意这个默认值非常长意味着MDL锁一旦顶住你可能等一年也等不到超时。如果收到锁等待超时报错第一步先想为什么等了50秒还没拿到锁大概率是另一个事务持锁时间太长。这时候不要急着调大超时时间先把持锁事务找出来缩短锁的持有时间才是正道。调大超时时间只是把问题往后推而且会掩盖应用层的Bug。4.2 用information_schema三张表定位锁等待MySQL提供三张关键视图排查锁等待就是围绕它们展开的information_schema.INNODB_TRX当前所有未提交的事务信息包括事务ID、启动时间、状态、执行的SQL等。information_schema.INNODB_LOCKSMySQL 5.7或performance_schema.data_locksMySQL 8.0持有锁和等待锁的记录。information_schema.INNODB_LOCK_WAITSMySQL 5.7或performance_schema.data_lock_waitsMySQL 8.0锁等待关系可以直接看到谁在等谁。最常用的排查SQL当属这个经典三表联查SELECT r.trx_id AS waiting_trx_id, r.trx_mysql_thread_id AS waiting_thread, r.trx_query AS waiting_query, b.trx_id AS blocking_trx_id, b.trx_mysql_thread_id AS blocking_thread, b.trx_query AS blocking_query FROM information_schema.INNODB_LOCK_WAITS w JOIN information_schema.INNODB_TRX r ON w.requesting_trx_id r.trx_id JOIN information_schema.INNODB_TRX b ON w.blocking_trx_id b.trx_id;拿到阻塞者的线程ID后用information_schema.PROCESSLIST查SQL和连接来源基本就能定位是哪个应用、哪台机器、哪条SQL把锁卡住了。定位到具体连接后如果确认业务已经跑飞可以执行KILL thread_id强制杀掉阻塞事务让排队的事务继续执行。在MySQL 8.0里连sys.innodb_lock_waits这个视图都能直接用SELECT * FROM sys.innodb_lock_waits\G它会直接告诉你阻塞线程、被阻塞线程、锁类型、锁模式、持续时间比手写三表联查快得多。4.3 通过SHOW ENGINE INNODB STATUS分析锁信息如果三张表查出不止一层的锁等待链或者涉及死锁就需要看InnoDB内部的状态报告了SHOW ENGINE INNODB STATUS;在输出里找到LATEST DETECTED DEADLOCK部分这里会记录最近一次死锁的详细信息死锁涉及的两个事务、各自持有的锁、各自在等待的锁、以及被回滚的事务是谁。特别留意*** (1) TRANSACTION和*** (2) TRANSACTION的SQL语句它们能直接告诉你业务代码里的哪两个操作形成了循环等待。另一个有用信息是TRANSACTIONS部分的锁列表能看到每个未提交事务持有的锁类型和锁定的记录。不要被里面密密麻麻的十六进制数字吓到重点看锁模式X、S、IX、GAP等和等待状态WAITING FOR THIS LOCK TO BE GRANTED。4.4 在线排查的完整步骤实录我自己处理线上锁问题通常按下面这套流程走效率最高第一步先通过运维监控看数据库当前活跃线程数。如果线程数飙高、大量线程处于Waiting for lock状态基本是锁等待堆叠而不是单纯的高并发。第二步立即执行前面那个三表联查找出谁在阻塞谁。一般来说阻塞者往往就一两个连接把阻塞者的trx_query拿出来看是长事务还是短事务跑了多少秒。第三步如果阻塞者是个已经跑了几百秒的长事务说明应用端可能没有正确提交或回滚事务。不少ORM框架的默认配置是autocommit0如果某段业务逻辑中途异常退出事务就挂在那边一直不释放。这时候直接把对应会话KILL掉让业务恢复。第四步恢复之后查应用日志找到那个长事务对应的业务场景修复代码问题。只KILL不修复过几小时同样的场景会再次触发。这个流程看起来简单但关键点是快。越快定位到阻塞者越能缩短业务受影响时间。最忌讳的是在没有任何排查的情况下直接重启MySQL——那会把所有未提交事务全部回滚造成大范围写入失败。5. 死锁的本质与处理从原理到实战5.1 死锁的形成条件死锁从定义上说是至少两个事务各自持有对方需要的锁互相等待谁都不退让。形成死锁的必要条件有四个互斥、持有并等待、不可剥夺、循环等待。在MySQL里前三个条件天然满足所以只要出现循环等待死锁必然发生。最常见的死锁场景是两条UPDATE语句按不同顺序更新两张表。事务A先更新表1再更新表2事务B先更新表2再更新表1。两者同时运行时事务A持有表1的锁等表2事务B持有表2的锁等表1完美闭环。另一种很隐蔽的死锁场景是批量删除的情况下间隙锁互相等待。事务A删除一个范围事务B删除一个重叠范围各自持有一部分间隙锁又要获取对方的间隙锁范围时直接死锁。这类死锁在代码层面完全看不出明显的循环。5.2 InnoDB处理死锁的机制与规避思路InnoDB有一套死锁检测机制会在事务等待锁时自动检测是否存在循环等待。一旦检测到死锁它不会让所有事务都挂起而是选择回滚其中一个代价较小的事务持有锁最少的事务释放它持有的锁让另一个事务继续执行。被回滚的事务收到Deadlock found when trying to get lock; try restarting transaction的错误。关键是出现死锁不代表系统出错也不代表数据丢失。它只是告诉你有两条事务路径在并发下打架了其中一个被强制退出了。优秀的事务代码应该处理死锁错误的重试逻辑。实际使用中我在支付、库存等高频写场景里都会在应用层捕获死锁错误然后重试整个事务重试次数设置在3次以内。实测下来80%以上的死锁在重试一次后就能成功。毕竟两个事务互相打架其中一个退出了另一个马上就能拿到锁继续执行。死锁的规避从设计层面可以遵循一条铁律所有事务都按相同的顺序访问表和行。比如先更新表A再更新表B这个顺序在整个系统里统一执行循环等待就没有形成的基础。还有一条经验尽量缩小事务范围减少锁的持有时间——锁持有越短重叠窗口越小死锁概率越低。5.3 鱼与熊掌死锁检测开关的取舍InnoDB有一个参数innodb_deadlock_detect默认开启。死锁检测的代价是每次一个事务等待锁时它都要检查等待图里有没有形成环。如果等待链很长、事务数很多检测本身会成为性能瓶颈。真实情况是高并发场景下大量事务同时等待同一行锁死锁检测的压力会非常大反而把系统拖慢。很多团队在这种情况下选择关闭死锁检测同时把innodb_lock_wait_timeout调小比如5秒让等待锁的事务尽早超时报错而不是被困在死锁检测里。关闭死锁检测后InnoDB不会主动发现死锁循环等待会导致事务一直挂到innodb_lock_wait_timeout才被强制回滚。所以这是一个取舍是让死锁检测快速发现并回滚但检测本身费CPU还是让锁等待超时兜底但最长要等5秒甚至更久。选择哪种取决于你的业务容忍度。6. 从MySQL锁到分布式锁场景与边界6.1 为什么有了数据库锁还不够MySQL的行锁能保证单实例内的事务正确性但应对分布式架构时就有心无力了。比如两个服务实例同时操作同一个库存数据它们分别连到同一个MySQL实例行锁依然有效但如果数据做了分库分表两个实例操作的是不同库不同表就需要一个跨节点的锁机制来确保同一时间只有一个服务在做关键操作。这就引入了分布式锁。它解决的问题和MySQL锁本质上是一样的并发互斥。只是作用域从单个数据库节点扩大到了分布式系统的多个节点。6.2 基于数据库表/Redis的分布式锁方案对比分布式锁的实现方式主要有三种基于数据库唯一索引、基于Redis的SETNX、基于ZooKeeper/etcd的临时顺序节点。基于数据库唯一索引的分布式锁原理是建一张锁表method_name字段加唯一索引。谁抢到锁谁就能成功插入一条记录释放锁就是删除记录。这个方案实现简单但性能一般且存在锁超时无法自动释放的问题。基于Redis的分布式锁是目前使用最广的方案。核心命令是SET lock_key unique_value NX PX 30000NX保证只有不存在时才设置成功PX设置锁的自动过期时间。释放锁时要确保是持有者自己释放所以要用Lua脚本比对unique_value再删除防止误删别人的锁。Redis锁最容易踩的坑是三个锁过期时间没设好业务执行超过锁过期时间导致锁提前释放另一个请求拿锁进入临界区Redis主从切换时锁丢失以及Redisson这类库虽然提供看门狗续期但续期机制本身也有微妙的行为差异。这些处理起来比MySQL的行锁复杂得多没有哪个方案是银弹。6.3 本地锁、MySQL锁、分布式锁的选择边界日常开发中锁的选型有一条不复杂的经验单机应用、单个服务实例直接用语言自带的本地锁如Java的synchronized、ReentrantLock开销最小。单库环境需要跨多个连接协调且不依赖外部组件优先用MySQL的行锁/唯一索引做乐观锁控制。多实例部署需要跨进程协调才考虑引入Redis或etcd分布式锁。对于金融级强一致的扣款、下单场景不要只依赖Redis分布式锁建议数据库层面加唯一约束做兜底即外锁内锁的双保险。很多人一上来就用Redis锁反而承担了不必要的运维复杂度。记住了能不用分布式锁就不用。分布式锁每引入一层就多一分超时、宕机、网络分区的不确定性。7. 实战总结锁相关的最常见问题速查现象可能原因排查手段解决方法UPDATE/INSERT长时间卡住行锁被其他事务持有查INNODB_TRX与INNODB_LOCK_WAITS定位长事务KILL或等待提交所有读写突然变慢MDL锁堆积SHOW PROCESSLIST看Waiting for table metadata lock找到未提交DDL或长事务KILL对应连接偶尔报死锁错误两个事务按不同顺序更新多行SHOW ENGINE INNODB STATUS看LATEST DETECTED DEADLOCK统一更新顺序加错误重试逻辑高并发插入大面积阻塞间隙锁范围过大确认WHERE条件的索引情况加索引、改RC隔离级别、缩小事务自增值跳跃严重回滚或批量插入导致查看自增锁模式配置如无业务要求可忽略处理锁问题我的核心方法论是第一优先级永远是把锁的持有时间压缩到最短——事务里只放必要的SQL能拆成小事务的绝不大事务第二优先级是确保所有WHERE条件都走索引扫描行数越少加锁范围越小第三优先级才是处理锁冲突、死锁后的补偿逻辑比如重试。另外分享一个我实践中特别管用的小技巧粉笔写代码时养成习惯事务里SQL的ORDER BY条件尽量明确让多行更新的加锁顺序尽量一致。这个习惯能在你还没意识到的时候就消灭掉一大类隐性死锁。