MySQL锁机制详解:从全局锁到死锁排查的实战指南
1. 后端开发为什么必须啃下 MySQL 锁这门课
1.1 一个并发扣减场景引发的惨案
两年前我接手过一个二手交易平台的订单模块,有个很典型的库存扣减逻辑,核心代码大概长这样:先查库存,判断够不够,够就执行 UPDATE 扣减。压测时 20 个并发线程同时下单买同一个商品,才跑了一分钟,数据库里的库存就变成了负数。
当时的第一反应是代码写错了,把 SQL 翻来覆去看了好几遍也没发现问题。后来在测试环境复现,手动开了两个事务同时执行同样的“查询-判断-更新”操作,结果两个事务都读到库存还有 10 件,各自扣掉 1 件,最后库存变成 9 件而不是 8 件。那一刻才真正意识到:业务逻辑没错,错的是没有让两个并发事务在数据修改上形成互斥。
这个场景其实就是后端开发里最常见的并发一致性问题,而 MySQL 原生锁机制正是解决这类问题的核心手段。所谓“原生锁”,指的是 MySQL 服务层和 InnoDB 存储引擎自带的那套锁体系,不依赖任何中间件或分布式组件。对后端开发来说,理解这玩意儿不是面试八股,而是排查线上数据异常、设计高并发接口的基本功。
1.2 锁要解决的本质问题:从并发冲突到数据一致性
我见过不少工作了三五年的后端,聊起锁能说出一堆名词——共享锁、排他锁、间隙锁、临键锁,但真问一句“你这张表在 RR 隔离级别下执行一条 UPDATE 到底锁住了哪些行”,就答不上来了。原因很简单:锁这个东西,平时不直接写在代码里,它是 SQL 执行时数据库自动加的,所以很多人只把它当成一个需要背的概念,而没有建立“锁是事务隔离的实现工具”这条主线。
一条 UPDATE 语句看起来只是改了数据,实际上它背后要做三件事:找到目标行、给目标行加锁防止别人同时改、修改后等事务提交才释放锁。如果第二步没有做好,就会出现我开头说的超卖问题。所以锁的本质,是数据库在并发访问下保证数据一致性的仲裁机制。它决定了多个事务同时操作同一批数据时,谁先谁后、谁能改、谁只能等。
后端的日常开发中,凡是涉及金额、库存、状态流转这类的写操作,几乎都离不开对锁的正确理解。不要觉得“我只需要把 SQL 写对就行”,因为在并发面前,“正确的 SQL”和“安全执行下来的 SQL”是两码事。
1.3 学习路径建议:别把锁当成孤立的语法点
MySQL 的锁体系初看非常庞杂,全局锁、表锁、行锁、间隙锁、意图锁……每个名词背后还牵扯 MVCC、隔离级别、索引结构,很容易劝退新手。我的建议是从上到下吃:先搞清锁到底解决什么问题,再按锁的粒度分层理解,然后聚焦 InnoDB 行锁在各种隔离级别下的行为,最后结合死锁和监控工具把知识落到实战上。
这篇文章就按这条路径来写,尽量用场景说话,不讲空理论,争取让对着电脑敲过 SQL 的人都能跟着走一遍。
2. 全局锁与表级锁:它们解决的场景比想象中更多
2.1 全局锁:逻辑备份时的一致性快照是怎么来的
全局锁是 MySQL 锁体系里粒度最大的锁,一条 FLUSH TABLES WITH READ LOCK(简称 FTWRL)就能让整个库进入只读状态。所有表的 DML 和 DDL 都会被阻塞,只有 SELECT 能正常执行。
大多数人第一次见到全局锁,是在用 mysqldump 做逻辑备份的时候。mysqldump 默认会加 FTWRL,目的是拿到一个全局一致性的备份点。如果没有这把锁,备份到一半时有别的事务在写数据,导出来的文件就是逻辑混乱的,恢复的时候很容易出问题。
不过要留意一点:InnoDB 引擎下用 mysqldump --single-transaction 备份时,并不会长时间持有全局锁,而是利用 MVCC 的一致性读来获取快照。FTWRL 只在备份开始时短暂获取,用来确保 binlog 位置等元数据一致后马上释放。这也是为什么生产环境备份用 InnoDB 表时,大家都会建议加 --single-transaction 参数,否则大库备份会让整个业务停滞。
注意:FTWRL 是 DBA 运维时才会主动用的手段。普通后端开发在代码里永远不要碰它,一旦执行,全局只读的窗口期内所有写请求都会堆积,线上事故的基本操作。
2.2 表级锁:LOCK TABLES 和 DDL 的隐藏代价
表级锁分为表读锁和表写锁,语法是 LOCK TABLES xxx READ / WRITE。读锁之间互不阻塞,但读锁和写锁、写锁和写锁之间都会互相阻塞。MyISAM 引擎因为没有行锁,所有 DML 只能靠表锁来保证一致性,并发写稍高一点就扛不住。
但即使你用的是 InnoDB,表级锁依然存在,只是优先级让位给了行锁。真正值得关注的是 InnoDB 下的 MDL(Metadata Lock,元数据锁)。MDL 是 MySQL 5.5 之后引入的,作用是保护表结构定义的一致性——在你执行 SELECT、UPDATE 时,数据库会自动加 MDL 读锁;执行 ALTER TABLE、DROP TABLE 时,需要拿 MDL 写锁。
MDL 的坑在于它是隐式的,出了问题非常难排查。我遇到过一个真实案例:线上一个订单表数据量很大,某天 DBA 执行一条加索引的 ALTER TABLE,结果跑了几个小时都没完成。查下来发现,有个后台任务事务一直没提交,SELECT 的 MDL 读锁还占着,ALTER 拿不到 MDL 写锁,被卡在等待队列里。更要命的是,MDL 写锁排队会阻塞后续所有对这张表的查询,导致整个订单接口全线超时。
所以后端开发写事务一定要短平快,尤其不能在事务里做远程调用、消息发送这类耗时操作。拖延一秒,表结构变更的 MDL 风暴就可能砸到你头上。
2.3 意向锁不是“锁”,但它决定了表级锁的判定效率
在 InnoDB 中,行锁和表锁是共存的。问题来了:如果事务 A 给某一行加了行锁,事务 B 想给整张表加表锁,数据库得怎么判断?总不能一行一行去扫描有没有行锁冲突吧。意向锁就是为这个问题设计的。
意向锁分为意向共享锁(IS)和意向排他锁(IX),它存在的意义是“表级锁定时的快速预判”。当事务给一行加共享锁或排他锁之前,会先在表上打一个 IS 或 IX 的标记。这样后来者申请表锁时,只需要看意向锁是否冲突,就能瞬间知道表里有没有行级锁在活动,而不必遍历全部行数据。
意向锁之间相互兼容,IX 和 IX 可以共存,因为它们是“预告”而非真正的锁。但表级的 S 锁和任何 IX 冲突,表级的 X 锁和所有别的锁都冲突。理解这一点,遇到“为什么我明明只锁了一行,别人想锁整表却等半天”这类问题,就能很快定位到根因。
3. 行级锁:InnoDB 并发能力的真正基石
3.1 三种行锁的基本形态:记录锁、间隙锁、临键锁
InnoDB 的行锁是建立在索引上的,这句话怎么强调都不过分。如果 SQL 没有走索引,那 InnoDB 只能退化成锁全表——表面看是行锁,实际效果和表锁没区别。
行锁有三种基本形态:
- 记录锁(Record Lock):锁住的是索引记录本身,
SELECT ... FOR UPDATE在 RC 隔离级别下通常就是这种锁。 - 间隙锁(Gap Lock):锁住索引记录之间的“空隙”,防止其他事务在这个空隙里插入新记录。它锁的是一个区间,不是某一行。
- 临键锁(Next-Key Lock):记录锁和间隙锁的组合,锁住“当前记录 + 前面的空隙”,范围左开右闭。
为什么要引入间隙锁?要理解这一点得回到 MySQL 默认隔离级别 RR(可重复读)。RR 要求同一个事务里多次读取同一批数据结果完全一致。如果用记录锁只锁住已存在的行,其他事务就能在两条记录的间隙里插入新行,导致本事务第二次查询时多出来一条“幻影记录”。间隙锁就是专门来堵这个洞的。
用一个例子说明:假设表里有 id 为 1、5、10 的三条记录,在 RR 下执行 SELECT * FROM t WHERE id BETWEEN 5 AND 10 FOR UPDATE,不只是 5 和 10 两行被锁住,间隙(5, 10)也会被锁住。另一个事务想插入 id=7 的新行,会被阻塞。但要注意,间隙锁之间是兼容的,两个事务可以同时持有同一个间隙的锁,它们冲突的对象是“往间隙里插入数据的操作”。
3.2 二级索引与主键索引的加锁差异
刚才说 InnoDB 行锁挂在索引上,那如果条件走的是二级索引,锁是怎么加的?这里有一个很重要的细节:先锁二级索引记录,再回表锁主键索引记录。
举个例子,用户表里有二级索引 idx_phone(手机号)。执行 UPDATE user SET status = 1 WHERE phone = '13800138000',InnoDB 会先在 idx_phone 上给对应记录加锁,然后根据索引里的主键值回表,在主键索引上也加锁。这意味着,即使两个 SQL 走了不同的二级索引,只要最终落到同一条主键记录上,锁就会冲突。
这一点对高并发场景有直接影响。如果你的更新条件能用主键就用主键,减少回表步骤,也降低锁的持有范围。反过来,如果二级索引的区分度很低——比如给 status 字段建索引,然后 UPDATE ... WHERE status = 0——可能锁定大量二级索引记录,再回表锁大量主键,相当于小半个表被锁住,并发直接崩盘。这种“索引设计不当导致锁范围爆炸”的问题,在后端线上环境里属于高频事故。
3.3 RR 隔离级别下,间隙锁和插入意向锁的相互作用
RR 隔离级别下,间隙锁默认是开启的,这也成了很多后端开发最容易踩坑的地方。看一个典型场景:
事务 A 执行 SELECT * FROM t WHERE id > 100 FOR UPDATE,在 RR 下会对 id 大于 100 的所有索引记录及间隙加上临键锁。这时事务 B 想插入 id=200 的新记录,会被阻塞在插入阶段,因为 id=200 正好落在事务 A 锁定的区间里。
此时事务 B 的插入请求会生成一个插入意向锁(Insert Intention Lock),它本质是一种特殊的间隙锁,表示“我打算在这个间隙里插入一行”。插入意向锁之间互相兼容——两个事务都想往同一个间隙插记录,互不阻塞,只要插入的位置不重叠就行。但插入意向锁和间隙锁是冲突的,所以事务 B 必须等事务 A 释放锁才能继续。
我在实际开发中遇到的坑是:一个简单的按条件批量更新接口,在高并发下经常出现 insert 等待超时。排查发现前面有一个长事务执行了范围查询 FOR UPDATE,把某个区间的间隙全锁住了,后面所有往这个区间插入的请求全部排队。后来把隔离级别从 RR 调到 RC,或者把查询条件精确到主键范围,问题才缓解。
提示:RC 隔离级别下 InnoDB 只保留记录锁,间隙锁会被禁用。所以在 RC 下不会出现“范围查询锁住空区间”的问题,因此故障率远低于 RR。如果你的业务对可重复读没有强需求,优先用 RC。
3.4 当前读与一致性读:为什么同样的 SQL 锁的效果不同
很多初学者会困惑:同样一条 SELECT,为什么有的场景会阻塞写入,有的场景完全不影响?原因在于 MySQL 有两种读模式:当前读和一致性读(快照读)。
当前读:读的是最新版本,并且会加锁。比如 SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE 里的读都属于当前读。当前读不加锁的只有普通 SELECT 会走快照读。
一致性读:通过 MVCC 机制读取一个历史快照,不加任何锁。在 RR 下,事务第一次 SELECT 时生成快照,后续 SELECT 都基于这个快照返回,所以一个事务里多次查询结果是一致的;在 RC 下,每次 SELECT 都生成新快照。
这个差异直接决定了 SQL 是否阻塞写入。举个例子,订单系统里一个定时任务用 SELECT * FROM order WHERE status = 0 查待处理订单,如果它走的是快照读,其他事务同时更新这些订单也不会被阻塞,大家各干各的。但如果这条 SQL 用了 FOR UPDATE,其他事务想更新相同订单就得等锁。
理解这两个读模式后,写代码时就能有意识地选择:只是读数据、不强求最新的话,别加 FOR UPDATE,让锁的竞争面更小;必须保证更新前数据没被别人改过,才用 FOR UPDATE 做条件保护。
4. 死锁:后端事故里最容易让人夜不能寐的两个字
4.1 一个真实死锁案例的完整复盘
死锁在 InnoDB 里每天都在发生,有些被引擎自动解开了,有些则直接导致接口超时、事务回滚。说一个我线上遇到过的经典案例。
业务场景是购买会员后要同时更新订单表和用户余额表。代码里的执行顺序是:先更新订单状态,再给用户加余额。还有一种触发路径是用户发起退款,先扣余额,再更新订单状态。两个操作在并发执行时,事务 A 持有订单行锁、等待用户行锁;事务 B 持有用户行锁、等待订单行锁,完美的循环等待,死锁就这么产生了。
当时数据库报的错长这样:
这个错误码 1213 是 InnoDB 自带的死锁检测机制触发的。它检测到死锁后,会选择牺牲其中代价最小的事务,回滚掉它,让另一个事务继续执行。所以死锁不是靠 DBA 去解决的,而是数据库自动处理的,只不过被牺牲的那个事务会收到异常,业务代码必须处理这个异常并重试。
复盘这个案例,根因只有一个:两个事务按不同的顺序申请资源。解决方法是全链路统一更新顺序,比如凡是涉及订单和余额的操作,都先更新订单表再更新余额表,死锁概率直接清零。
4.2 死锁检测机制与 innodb_deadlock_detect 的取舍
InnoDB 默认开启死锁检测,参数是 innodb_deadlock_detect = ON。它的原理是:每个新事务加锁时,会通过 wait-for graph 检测是否形成环,如果发现环,就回滚其中一个小事务。
但有得必有失。在高并发场景下——比如秒杀——大量线程同时对同一行数据加锁排队,每次加锁都要做死锁检测,CPU 的消耗会被明显拉高。有些团队在极端场景下会选择把死锁检测关掉,依赖 innodb_lock_wait_timeout(默认 50 秒)超时兜底,让卡住的事务主动报错退出。这样牺牲了“快速发现死锁”的能力,换来了主机的 CPU 喘息空间。
我自己在维护高并发库存接口时,确实有段时间把死锁检测关了,因为监控显示 InnoDB 的死锁检测线程占用相当夸张的 CPU。但关掉后就要承担一个后果:真的死锁发生时,可能要等几十秒才会被超时机制解锁,这个窗口期内,所有涉及同一热点行的写请求都会堆积。
关还是不开,没有标准答案,建议根据具体业务压测数据做决策。但绝大多数中小体量的系统,保持默认开启就好,不要过早优化一个还没出现的问题。
4.3 减少死锁的实操方法:顺序、粒度与重试
经历几次死锁事故后,我总结了一套减少死锁的实操方案,按优先级排列:
- 统一资源访问顺序。多张表的更新,尽量安排一样的先后顺序,从根上消除环形等待。
- 缩小锁的粒度。更新条件越精确、走索引越好;避免大范围更新,比如
UPDATE ... WHERE status = 0这种可能锁非常多行的 SQL,改成循环单行更新或分批更新。 - 缩短事务时间。事务里不要做外部接口调用、等待锁竞争外的计算任务。事务提交越快,持锁窗口越短,碰撞概率越低。
- 降低隔离级别。业务不需要 RR 就用 RC,少掉间隙锁,死锁场景少一大半。
- 设置合理的锁等待超时。
innodb_lock_wait_timeout调成 3~5 秒,宁可快速失败让业务重试,也不要持锁等待几十秒拖垮系统。 - 捕获死锁异常自动重试。代码层面捕获 1213 错误,做有限次数重试(比如 3 次),能有效提升成功率。但重试逻辑一定要考虑接口幂等性,避免重复更新。
第 6 点尤其重要。死锁无法完全避免,但业务代码若能在死锁发生时自动重试,用户体验基本无感。前提是底层 SQL 要设计成天然幂等,比如扣减库存只能用 SET stock = stock - 1 WHERE stock >= 1 这种条件更新,而不是“先查再算再改”的三步操作。
5. 锁状态观测与优化:出了问题怎么定位
5.1 information_schema 里那几个关键视图怎么用
光会加锁、会分析死锁还不够,真正上了生产环境,你还需要一套“透视锁状态”的手段。MySQL 在这方面的支持很完善,但很多人不知道去查。
在 MySQL 8.0 之前,常用的视图是 information_schema.INNODB_TRX(事务表)、INNODB_LOCKS(锁表)、INNODB_LOCK_WAITS(锁等待关系表)。MySQL 8.0 里 INNODB_LOCKS 被废弃了,换成了 performance_schema.data_locks 和 data_lock_waits。
假设线上有一条 UPDATE 一直卡住,先查正在运行的事务:
重点关注 trx_id、trx_state、trx_started、trx_query 这几个字段。如果发现某个事务启动很早还没提交,它大概率就是持锁的元凶。再通过 performance_schema.data_locks 查它持有哪些锁:
LOCK_MODE 里的 X 代表排他锁、S 代表共享锁,LOCK_TYPE 区分 RECORD(行锁)还是 TABLE(表锁)。配合 data_lock_waits 就能看到谁在等谁:
这条链路走一遍,锁等待的阻塞链基本就浮出水面了。我排查线上问题时,通常先用第一个 SQL 找到长事务,再用第二个 SQL 确认它的锁范围,最后用第三个 SQL 找到等待的受害者,效率很高。
5.2 performance_schema 的锁等待与事务关联分析
锁等待问题的难点往往不在“有没有锁”,而在“这个锁是谁加的”。因为持锁事务可能来自另一个连接、另一个业务入口,甚至一个半夜定时任务。这时候需要把锁等待的请求和事务执行历史关联起来。
一个比较实用的排查姿势是:先通过 performance_schema 的 events_statements_current 或 events_statements_history_long 看某个连接最近执行了什么 SQL。例如:
再结合 threads 表和 INNODB_TRX 里的 trx_mysql_thread_id 字段,把“数据库内部的锁”和“业务侧的连接会话”对上号。这时候你就能看到持锁事务对应的完整 SQL 序列——它进来先干了什么、卡在哪一步,定位起来非常直观。
另一个高频问题是从等待结果反查阻塞者。比如一条 SQL 报 Lock wait timeout exceeded; try restarting transaction,不用急着改代码,先打开一张表看 information_schema.innodb_trx 里有没有 trx_state = 'LOCK WAIT' 的事务,再找它的 trx_wait_started 时间,配合上面提到的 data_locks 就能画出完整的等待链。95% 的锁等待问题靠这三板斧就能解决。
5.3 从执行计划看锁粒度:哪些 SQL 不该出现在高并发链路
最后一个想分享的点,是如何提前在开发阶段预判 SQL 的锁粒度,而不是等线上出问题再去亡羊补牢。核心方法就是看执行计划:
如果 type 列显示 ALL(全表扫描)或 index(全索引扫描),那 InnoDB 只能把扫描到的每一行都加上锁——表面上是行锁,实际效果变成了表锁。这种 SQL 出现在高并发链路上就是定时炸弹。
类似的危险 SQL 特征我归纳了几类:
- 条件字段没有索引的 UPDATE/DELETE,会锁全表。
- 使用
LIKE '%xx%'左模糊匹配做更新条件,索引失效。 - 在事务里对同一张表多次执行不同条件的查询再更新,锁集合膨胀。
- 更新范围过大,比如一次更新几万行数据,锁持有时间长,还容易产生大量 undo log。
把 EXPLAIN 养成写 SQL 之后的肌肉记忆,碰到 type 是 ALL 的写语句,第一个念头就应该是“这语句上生产后会不会把表锁死”。哪怕业务现在流量很小,也要趁早把索引补上,等高并发来了再改就晚了。
另外补充一个细节:InnoDB 的锁是事务提交或回滚才释放,不是语句执行完就释放。所以即便是单条 UPDATE,只要外层事务没提交,锁就一直握着。这也是我在评审代码时一定会盯的点——事务边界必须短,能自动提交就别包一层大事务。很多线上锁问题,追到根因根本不是锁用错了,而是事务拖太长了。