news 2026/10/4 3:05:13

MySQL锁机制全解析:从全局锁到行锁,详解死锁排查与实战优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL锁机制全解析:从全局锁到行锁,详解死锁排查与实战优化

昨天半夜接到同事电话,说线上一个核心接口的耗时突然从几十毫秒飙到十秒以上,数据库连接池被打满,一堆请求排队。登录到数据库一查,SHOW PROCESSLIST里几十个线程都卡在Waiting for table metadata lock上,源头是一个跑了好几分钟没结束的ALTER TABLE。这种场面,凡是碰过 MySQL 生产环境的人应该都不陌生——表面上看是 DDL 惹的祸,本质上全是锁的问题。

MySQL 的锁机制,是每个做后端开发、数据库运维的人迟早要正面刚的东西。你用SELECT ... FOR UPDATE扣库存,用UPDATE改订单状态,用INSERT ... ON DUPLICATE KEY UPDATE做幂等,背后全部牵扯到锁的获取、持有和释放。这篇文章我打算把 MySQL 里最常见的几类锁彻底讲透,从全局锁、表级锁到 InnoDB 的行锁,再到分布式系统里大家都会聊到的乐观锁和悲观锁,配合我自己踩过的一些坑,直接当作实战笔记看就行。

1. 锁的必要性:并发写入时都发生了什么

1.1 两个连接同时改一条记录的后果:丢失更新与脏读

先看一个最简单的场景。有一张商品库存表,stock字段存剩余库存。两个用户同时下单,各自执行了这样一条 SQL:

UPDATE product SET stock = stock - 1 WHERE id = 100;

如果没有锁,结果会是什么?两个事务都读到stock = 10,各自在内存里减一,然后都写回9。但正确的业务期望应该是8。最后商品明明卖出了两件,数据库里却只少了 1 件——这就是经典的丢失更新问题。

再延伸一点,如果一个事务读到另一个事务尚未提交的中间数据,然后基于这个脏数据做了一堆业务判断,这就是脏读。丢失更新和脏读都是并发事务没有隔离导致的。MySQL 解决这个问题的核心手段就是锁,配合事务的隔离级别,保证多个会话在操作同一份数据时,彼此之间是按规则排队的。

1.2 悲观锁与乐观锁:DB锁和版本号各自的主场

聊 MySQL 锁之前,必须先分清楚两个流派:悲观锁和乐观锁。

悲观锁的思路是“我改数据之前,先把这条记录锁住,防止别人动它”。数据库的行锁、表锁,本质上都是悲观锁。在 MySQL 里最常见的就是SELECT ... FOR UPDATE,执行这条语句时,InnoDB 会对命中的行加排他锁,直到当前事务提交或回滚才释放。其他事务想改这些行,只能阻塞等待。

乐观锁的思路是“我不提前锁,更新的时候比对一下数据版本”。常见做法是在表里加一个version字段,更新语句写成:

UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = 100 AND version = 8;

执行后看影响行数,如果为 0,说明版本对不上,有人抢先改了,业务层再重试或提示失败。

这两种流派没有绝对的好坏,取舍点在于冲突概率和并发量。冲突不频繁、重试成本低的场景,乐观锁很舒服;库存扣减这类冲突极高、不允许失败重试的核心链路,悲观锁更直接。我第一次做秒杀系统时,图省事用乐观锁,结果高并发下大量请求在版本比对处撞车,重试逻辑又写得不够健壮,最后被迫改回FOR UPDATE。后来我总结了一个经验:不确定场景下,先悲观锁保底,再在热点路径上做拆分优化。

2. 三种锁粒度:全局锁、表级锁、行级锁,各自管到什么范围

2.1 全局锁:备库一致性备份时为什么非用不可

全局锁是 MySQL 里范围最大的一种锁,由FLUSH TABLES WITH READ LOCK(简称 FTWRL)触发。执行后,整个实例的所有表都变成只读状态,任何写操作都会被阻塞,直到执行UNLOCK TABLES手动释放。

你可能想问,MySQL 8.0 不是有mysqldump --single-transaction吗?为什么还要用全局锁?--single-transaction依赖 InnoDB 的 MVCC,可以做到不加锁备份,但前提是所有表都是 InnoDB 引擎。如果库里还有 MyISAM 表,或者你的备份工具语义要求完全一致的快照点,FTWRL 依然是兜底方案。全局锁实际使用频率不高,但涉及主从一致性初始化、全库只读维护这类操作时,它是无法绕开的概念。

全局锁最需要注意的地方是:它锁的是整个实例,一旦在业务高峰期误执行,所有写入瞬间全挂,接口报错率直接拉满。我见过有人把FLUSH TABLES WITH READ LOCK和普通的LOCK TABLES混为一谈,结果在生产库上执行后,运维监控炸了一片。

2.2 表级锁:LOCK TABLES和MDL锁的恩怨

表级锁分两种:一种是你主动加的LOCK TABLES t READ/WRITE,另一种是 MySQL 自动维护的元数据锁(MDL 锁)。主动加表锁这种方式,在 InnoDB 时代已经被行锁替代得差不多了,现在我自己基本只在 MyISAM 表或某些特殊维护场景才会用到。

真正需要重视的是 MDL 锁。MDL 锁是 MySQL 5.6 以后引入的,目的是保护表结构不被并发修改。任何一条 DML 语句(增删改查)执行前,都需要先获取元数据锁;DDL 语句(ALTER TABLE、DROP TABLE)则需要获取排他的 MDL 锁。

文章开头提到的线上事故,就是这种场景。一个长事务拿着ALTER TABLE在跑,而这条 DDL 需要等待之前所有持有 MDL 读锁的事务结束。结果就是:DDL 排在最前面,后续所有新查询拿不到 MDL 读锁,全部阻塞在Waiting for table metadata lock。这个经典的“DDL 排头兵阻塞全表”问题,核心原因就是 MDL 锁的排队机制。

排查 MDL 锁有个很实用的方法:

SELECT * FROM performance_schema.metadata_locks;

这张表会列出所有会话当前持有的和等待中的 MDL 锁。找到阻塞源头的事务后,评估是否能安全KILL,如果可以就直接断开连接,让 DDL 跑完。

2.3 行级锁:InnoDB把粒度细到极致的原因

行级锁是 InnoDB 区别于 MyISAM 的核心特性之一。它的好处是并发度高,两个事务只要修改的不是同一行,互不干扰;代价是锁的管理更复杂,内存开销比表锁大。

InnoDB 的行锁本质上不是直接锁“行记录”,而是锁在索引项上。这句话值得反复琢磨,因为它解释了很多实际问题:为什么UPDATE语句条件列没走索引时,行锁可能升级成表锁,导致并发性能雪崩。后面我专门用一节来细讲这个坑。

行级锁的加锁方式有两种:LOCK IN SHARE MODE(8.0 里等价写法是FOR SHARE)加共享锁,多个事务可以同时持有;FOR UPDATE加排他锁,只能一个事务持有。共享锁之间兼容,排他锁和任何锁都不兼容,这是理解锁冲突的基础。

InnoDB 行级锁的兼容性关系,用一张表就能说清楚:

锁类型共享锁(S)排他锁(X)
共享锁(S)兼容冲突
排他锁(X)冲突冲突

3. InnoDB行锁的三种形态:Record Lock、Gap Lock、Next-Key Lock

3.1 Record Lock:命中唯一索引时的精确锁定

Record Lock 就是记录锁,锁的是索引项本身。当WHERE条件命中的是唯一索引或主键时,InnoDB 通常只需要加记录锁,不需要锁间隙。

举个例子:

SELECT * FROM orders WHERE order_id = 1024 FOR UPDATE;

order_id是主键,那么这条语句只锁主键值等于 1024 的那一行。其他人的插入操作,只要主键不是 1024,完全不受影响。这种锁的粒度最细,并发度最高,也是我们写 SQL 时最希望达到的状态。

要注意的是,记录锁要求条件列必须是唯一索引,并且查询能精确定位到单条记录。如果条件是WHERE status = 1这样的普通字段,即使结果只有一条,InnoDB 也无法确定扫描范围内有没有其他可能插入的间隙,加锁范围就会扩大。

3.2 Gap Lock:范围查询带来的间隙锁,以及一次经典的insert阻塞

Gap Lock 锁的是索引记录之间的“空隙”,防止其他事务在这个间隙里插入新数据。它主要出现在可重复读隔离级别下,范围查询或者条件列不是唯一索引时,InnoDB 为了保证数据的一致性读和避免幻读,会顺手把间隙也锁住。

有一个非常经典的面试题:两个事务同时插入同一张表,第一个事务执行了:

SELECT * FROM students WHERE age BETWEEN 20 AND 30 FOR UPDATE;

如果age上没有索引,这个查询就会在扫描范围内加大量间隙锁。此时另一个事务尝试插入一条age = 25的记录,会发现阻塞在那里,等第一个事务提交才继续。原因不是记录本身被锁,而是 20 到 30 之间的空隙被 Gap Lock 堵住了。

Gap Lock 在绝大多数业务场景下是“隐性副作用”,它不直接报错,但会降低并发插入的吞吐量。排查这类问题时,SHOW ENGINE INNODB STATUS里经常能看到LOCK_MODE: X, INSERT_INTENTION等待的记录,这就是另一个事务的插入意图锁在等 Gap Lock 释放。

3.3 Next-Key Lock:可重复读隔离级别下的真实加锁范围

Next-Key Lock 可以理解为“记录锁 + 间隙锁”的组合,锁的范围是“左开右闭”的区间。InnoDB 默认的可重复读隔离级别,就是通过 Next-Key Lock 来实现幻读拦截的。

举个具体例子,一张表的主键是 1、5、10,执行:

SELECT * FROM t WHERE id > 3 AND id < 9 FOR UPDATE;

实际加锁的范围不只是id = 5这条记录,而是(1, 5]和(5, 10]两个区间。这意味着即使现在表里没有id = 7这条记录,其他事务试图插入id = 7时也会被阻塞,因为插入位置落在被锁的间隙里。这就是为什么 InnoDB 能在可重复读下防住幻读——不让任何“新记录”在查询范围内出现。

这里顺便说一句,很多人以为把隔离级别改成“读已提交”就能完全消除 Gap Lock,这个说法不完全准确。在读已提交隔离级别下,InnoDB 确实会禁用纯粹的 Gap Lock,但在UPDATE和DELETE语句的执行过程中,它仍可能因为需要变更或删除数据而加短暂的间隙锁。所以把隔离级别当万能药之前,最好先想清楚自己要解决的问题是什么。

锁类型锁的范围触发典型场景对插入操作的影响
Record Lock单个索引记录主键/唯一索引等值查询只阻止修改同一行
Gap Lock索引记录之间的间隙范围查询、普通索引等值查询阻止在间隙内插入
Next-Key Lock记录 + 前向间隙可重复读下的范围查询记录和区间均受保护

4. 死锁:从一次线上卡死到一个锁等待节点的全程排查

4.1 死锁的四个必要条件:对照真实案例逐条打勾

死锁是指两个或多个事务互相持有对方需要的锁,谁都不肯放手,导致彼此永远阻塞。形成死锁必须同时满足四个条件:

  1. 互斥:至少有一个资源被排他锁占用;
  2. 占有且等待:一个事务持有锁的同时,还在等待另一个锁;
  3. 不可剥夺:已获得的锁不会被强制抢走,只能主动释放;
  4. 循环等待:每个事务都在等另一个事务释放锁,形成一个环。

说一个我之前遇到过的真实案例。两张表orders和order_items,业务逻辑是先更新订单主表,再更新订单明细表。事务 A 的操作顺序是“更新订单 100 → 更新订单明细 200”,事务 B 的操作顺序正好相反,“更新订单明细 200 → 更新订单 100”。

两个事务同时提交,A 拿到了订单 100 的锁,B 拿到了明细 200 的锁,然后 A 去等明细 200,B 去等订单 100,循环等待形成,死锁瞬间触发。InnoDB 会检测到死锁,并选择回滚其中一个事务(一般回滚 undo 日志量较小的那个,或者成本较低的那个),另一个事务继续执行。

我在排查时,对照真实日志逐条打勾确认,发现四个条件在这条链路上全部成立。这个案例恰好也说明了一个非常实用的结论:死锁很多时候不是锁本身的问题,而是业务代码里操作多个对象的顺序不一致导致的。

4.2 SHOW ENGINE INNODB STATUS里的死锁现场:读日志的先后顺序

死锁发生后,MySQL 会往错误日志里写入一段“死锁现场信息”,最直接的获取方式是:

SHOW ENGINE INNODB STATUS;

输出内容特别长,重点看末尾的LATEST DETECTED DEADLOCK段落。里面的关键信息按顺序读:

第一行会列出发生死锁的时间点,然后是“事务 A”和“事务 B”各自执行的最后一条 SQL、持有锁的列表、等待锁的列表。

举一个典型的输出片段(模拟):

*** (1) TRANSACTION: TRANSACTION 381516, ACTIVE 3 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 58 page no 3 n bits 72 index `PRIMARY` lock_mode X locks rec but not gap waiting Record lock, heap no 3 PHYSICAL RECORD: ... *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 58 page no 3 n bits 72 index `PRIMARY` lock_mode X locks rec but not gap

这段日志表达的意思很直白:事务 1 正在等待一个lock_mode X的记录锁,事务 2 持有这个记录锁,而事务 2 自身又在等待别的锁。从日志里能直接看到双方等待的锁对象和对应的 SQL 语句,足够还原死锁链路了。

排查死锁我一般不看日志的全文,而是先把两个事务的 SQL 单独拎出来,放到实际数据上复现一次,确认锁的获取顺序。MySQL 8.0 之后,performance_schema.data_lock_waits这张表也能提供阻塞等待的实时视图,适合死锁发生前进行预警和定位。

4.3 破解死锁的三板斧:顺序、粒度、时间

死锁的破解思路不是“让死锁不发生”,而是“降低死锁发生的概率,以及让它快速暴露、快速恢复”。三年生产环境踩过来,我总结了三板斧。

第一板斧:统一操作顺序。所有事务里涉及多张表、多行数据时,都按固定的顺序去加锁。比如先处理订单主表,再处理明细表,全公司统一。这样循环等待的必要条件就被破坏了。上面那个真实的死锁案例,就是用这招解决的:约定所有事务先锁orders再锁order_items。

第二板斧:缩小锁粒度。把大事务拆小,尽量让锁只在真正需要的那几行上持有。比如批量更新一万条数据,就可以分段提交,每 500 条一个事务。锁持有时间短了,两个事务碰撞的窗口就小很多。

第三板斧:死锁后的重试。InnoDB 检测到死锁后,会回滚其中一方的事务,并向客户端返回错误码1213(ER_LOCK_DEADLOCK)。我在业务代码里会捕获这个错误码,做有限次数的重试,比如最多重试三次,每次间隔随机退避。这些都有现成的客户端库可以配置,但对新人来说,知道“先判断错误码再重试”远比盲目重试安全。

5. 锁与SQL设计:这些年从生产环境摸出来的几条实操经验

5.1 条件列必须走索引:没索引的行锁会变成表锁

这一点是行锁使用中最大的坑。InnoDB 的行锁锁在索引项上,如果UPDATE的WHERE条件列没有索引,InnoDB 就需要全表扫描才能找到要更新的行。扫描过程中,它会对所有扫过的记录加锁,效果上等同于锁了整张表。

我处理过一起线上事故:某张业务表一个status字段忘了建索引,业务方在凌晨定时任务里执行:

UPDATE user_task SET status = 2 WHERE status = 1;

结果整张表的所有行都被锁住,白天的业务流量一进来,更新全部阻塞,数据库连接数直接打满。事后排查,就是条件列没索引导致行锁升级为全表范围的锁。

解决思路也很明确:所有走UPDATE、DELETE的筛选条件,必须确认索引可用,用EXPLAIN看执行计划,重点看possible_keys和key字段。另外,大批量更新时提前评估影响行数,不要一次性更新几十万行,既拖垮日志,又拖垮锁。

5.2 事务短一点,再短一点:锁持有时间才是并发上限的瓶颈

很多新手以为减少锁冲突要靠“少加锁”,其实更关键的是“缩短锁持有时间”。锁从加上的那一刻起,到事务提交或回滚才释放,中间执行的所有 SQL、业务逻辑、甚至远程调用,全都在持有锁。事务越长,锁被占用的时间越长,别的请求排队的时间就越久。

具体优化方向有三个:

  1. 事务里只放必要的 SQL,把无关的查询、计算移到事务外面;
  2. 避免在事务中做远程 RPC 调用、发消息、等待外部响应;
  3. 热点记录(比如爆款商品的库存行)的更新,尽量简化事务逻辑,甚至可以拆行、拆分库存,减少单行锁的争用。

我见过有人为了“保险”,在一个事务里执行了十几条 SQL 外加一次 Redis 调用,整个事务跑了一百多毫秒。扣库存这种高频操作,事务一百毫秒意味着同一行同一秒最多支持大约十次事务,并发稍稍一高就积压,这还是不谈锁等待的情况。把事务压缩到只包含一次库存操作和必要的记录变更后,吞吐直接翻了几倍。

5.3 SELECT FOR UPDATE不是银弹:先看清楚要保护什么

SELECT ... FOR UPDATE是悲观锁的典型用法,但它用不好很容易造成大面积锁等待。我见过不少人一遇到并发问题就随手加FOR UPDATE,结果锁的范围比自己想象的大得多。

一个典型的案例:查询某用户未完成订单时,用了FOR UPDATE。这个查询条件走的是user_id的非唯一索引,InnoDB 不仅会给匹配到的记录加锁,还会在索引区间上加 Gap Lock,其他用户同一范围内的插入、更新都会受到影响。如果只是想防止重复支付,更好的做法是锁唯一存在的“订单记录”,而不是锁一个范围。

另外要区分锁的目的。如果是防止超卖这种“库存更新”类场景,直接UPDATE ... SET stock = stock - 1 WHERE stock > 0也能达到目的,不需要先SELECT FOR UPDATE再更新。一条原子 UPDATE 自带排他锁,代码更简洁,锁的持有时间也更短。这个写法用好了,能省掉相当一部分显式加锁的复杂度。

5.4 查看锁等待的几个常用入口:从SHOW PROCESSLIST到performance_schema

遇到线上锁等待,第一反应要能想到哪些命令能快速定位问题。下面是我常用的排查入口,按使用频率排:

SHOW PROCESSLIST;最基础,能看到所有连接当前状态。重点关注State字段里的Waiting for table metadata lock、Waiting for lock to be granted这类关键字,以及Time字段的长耗时事务。

-- 查看当前运行中的事务 SELECT * FROM information_schema.INNODB_TRX\G

INNODB_TRX能直接看到未提交事务的执行时间、状态、是否有锁等待。结合SHOW PROCESSLIST里的trx_mysql_thread_id,能精确对应到会话。

-- MySQL 8.0 通过 data_lock_waits 查看锁等待链路 SELECT * FROM performance_schema.data_lock_waits\G

data_lock_waits是 MySQL 8.0 之后比较推荐的表。它会列出每个等待锁的事务对应的BLOCKING_TRX_ID,直接定位到阻塞源头。定位到源头事务后,就可以评估是否要 KILL 掉那个长事务,或者等它自然结束。

innodb_lock_wait_timeout参数控制的是等待锁的超时时间,默认 50 秒。生产环境我一般把它调低一些,比如 5 秒或 10 秒,宁可让请求快速失败进入重试,也不让一堆请求在数据库里憋几十秒。这个参数不是解决锁冲突,而是控制失控等待的时间成本。

最后想说的是,MySQL 锁学起来最容易出现的误区,就是死记锁类型而不理解“为什么”。真到了现场,你能拿到的只有一堆运行中的事务、等待状态和死锁日志,能不能根据日志还原出加锁链路,取决于你对索引结构、隔离级别和事务边界的理解程度。我处理过的每一起锁事故,最后复盘时都发现不是 MySQL 的锁设计不够好,而是 SQL 和事务边界没设计好。把 SQL 的执行计划检查清楚,把事务控制在合理的范围内,再复杂的锁机制也不会成为瓶颈。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 3:04:14

OpenShell 使用指南:经典开始菜单回归与效率提升

1. 从零认识 OpenShell&#xff1a;它到底解决什么问题第一次听到 OpenShell 这个名字&#xff0c;很多人会下意识以为它是某个操作系统的内核项目&#xff0c;或者是一个新的命令行终端工具。实际上&#xff0c;OpenShell 是一个面向 Windows 平台的开始菜单替代与增强工具&am…

作者头像 李华
网站建设 2026/10/4 3:03:42

Flutter 插件鸿蒙适配:从权限映射到原子写入的落地实践

如果你正在鸿蒙设备上做一个带 Web 内核的 Flutter 应用&#xff0c;或者准备把一个浏览器形态的产品搬进鸿蒙生态&#xff0c;大概很快会遇到这样一个坎&#xff1a;网页里的文件选择、本地读写、目录遍历这些能力&#xff0c;在 PC 浏览器上有 W3C File System Access API 可…

作者头像 李华
网站建设 2026/10/4 3:02:13

单表查询SQL:从执行顺序到去重与慢SQL优化全解析

单表查询SQL&#xff0c;是数据库开发里出现频率最高、也最容易翻车的基础操作。很多人写了两三年SQL&#xff0c;对付复杂JOIN头头是道&#xff0c;回头写一条SELECT * FROM table WHERE ...却依然踩空值、去重、分组这些坑。这篇文章不聊多表&#xff0c;只把单表查询从执行顺…

作者头像 李华
网站建设 2026/10/4 2:55:19

基于迁移学习的图像分类系统:从选型到部署的完整实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/4 2:54:23

自连接、交叉连接与复杂 JOIN:一条问题链讲透

我曾把员工查经理的 SQL 里的 LEFT JOIN 写成 INNER JOIN&#xff0c;结果 CEO 整个人从报表里消失了&#xff0c;我对着结果数了半小时人头。 这篇文章把自连接、交叉连接、复杂 JOIN 串成一条递进问题链&#xff0c;读完你能独立拆解多层关联查询&#xff0c;并避开我踩过的…

作者头像 李华