昨天半夜接到同事电话,说线上一个核心接口的耗时突然从几十毫秒飙到十秒以上,数据库连接池被打满,一堆请求排队。登录到数据库一查,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 死锁的四个必要条件:对照真实案例逐条打勾
死锁是指两个或多个事务互相持有对方需要的锁,谁都不肯放手,导致彼此永远阻塞。形成死锁必须同时满足四个条件:
- 互斥:至少有一个资源被排他锁占用;
- 占有且等待:一个事务持有锁的同时,还在等待另一个锁;
- 不可剥夺:已获得的锁不会被强制抢走,只能主动释放;
- 循环等待:每个事务都在等另一个事务释放锁,形成一个环。
说一个我之前遇到过的真实案例。两张表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、业务逻辑、甚至远程调用,全都在持有锁。事务越长,锁被占用的时间越长,别的请求排队的时间就越久。
具体优化方向有三个:
- 事务里只放必要的 SQL,把无关的查询、计算移到事务外面;
- 避免在事务中做远程 RPC 调用、发消息、等待外部响应;
- 热点记录(比如爆款商品的库存行)的更新,尽量简化事务逻辑,甚至可以拆行、拆分库存,减少单行锁的争用。
我见过有人为了“保险”,在一个事务里执行了十几条 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\GINNODB_TRX能直接看到未提交事务的执行时间、状态、是否有锁等待。结合SHOW PROCESSLIST里的trx_mysql_thread_id,能精确对应到会话。
-- MySQL 8.0 通过 data_lock_waits 查看锁等待链路 SELECT * FROM performance_schema.data_lock_waits\Gdata_lock_waits是 MySQL 8.0 之后比较推荐的表。它会列出每个等待锁的事务对应的BLOCKING_TRX_ID,直接定位到阻塞源头。定位到源头事务后,就可以评估是否要 KILL 掉那个长事务,或者等它自然结束。
innodb_lock_wait_timeout参数控制的是等待锁的超时时间,默认 50 秒。生产环境我一般把它调低一些,比如 5 秒或 10 秒,宁可让请求快速失败进入重试,也不让一堆请求在数据库里憋几十秒。这个参数不是解决锁冲突,而是控制失控等待的时间成本。
最后想说的是,MySQL 锁学起来最容易出现的误区,就是死记锁类型而不理解“为什么”。真到了现场,你能拿到的只有一堆运行中的事务、等待状态和死锁日志,能不能根据日志还原出加锁链路,取决于你对索引结构、隔离级别和事务边界的理解程度。我处理过的每一起锁事故,最后复盘时都发现不是 MySQL 的锁设计不够好,而是 SQL 和事务边界没设计好。把 SQL 的执行计划检查清楚,把事务控制在合理的范围内,再复杂的锁机制也不会成为瓶颈。