有次值班,线上 MySQL 突然像抽搐了一样,所有写请求都卡住,拉到 show processlist 一看,满屏的 Waiting for lock。那段时间正好在看锁机制,从全局锁、表级锁一路梳理到行级锁,整个过程把很多模糊的概念彻底理清了。这篇就把我自己的理解和实际排查经验完整写出来,遇到锁表、锁等待、死锁问题时,能帮你少走弯路。
1. 并发控制与 MySQL 锁机制的三个层级
1.1 从“多人改同一行”说起
MySQL 作为一个数据库,每天要面对大量并发读写。多个事务同时读数据不会出问题,但只要有写操作,就可能互相干扰。比如两个事务同时把某一行金额从 100 改成 80,如果没有任何约束,最终结果可能是 80,也可能是 60,完全取决于谁后提交。
锁的作用就是给资源访问立规矩。事务在修改数据前必须拿到对应的“排他许可”,其他事务想改同一份数据时只能排队等候。这个机制和公共厕所差不多:有人占用时,后面的人只能等着,等里面的人出来才能进。
MySQL 的锁并不是一把大锁锁住全部数据,而是按影响范围分层设计。理解清楚每一层锁锁什么、什么时候生效、怎么释放,才能解释线上很多诡异现象。
1.2 锁粒度越大,并发能力越低
从影响范围看,MySQL 的锁可以分为三个层级:
- 全局锁:锁住整个数据库实例,所有表都变成只读。
- 表级锁:锁住某一张表,表内所有记录都受影响。
- 行级锁:只锁住某一行或某个索引区间,其他行依然可并发操作。
这里有一个取舍问题。全局锁最简单粗暴,但并发能力最低;行级锁最精细,并发能力最高,但对实现和排查的要求也最高。InnoDB 引擎同时实现了表级锁和行级锁,线上绝大多数锁问题其实都集中在行级锁与元数据锁上。
1.3 InnoDB 才是主角
默认情况下 MySQL 使用 InnoDB 存储引擎。MyISAM 只有表级锁,写并发一高基本卡死,现在很少用于核心业务。后面讲的行级锁、间隙锁、死锁检测,都是 InnoDB 的特性。这也是为什么线上排查锁问题时,第一步要看表引擎是否为 InnoDB,如果是 MyISAM,很多现象就要用另一套逻辑去解释。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 全局锁:影响整实例的命令不要乱用
2.1 全局锁到底锁住了什么
全局锁的官方命令是 FLUSH TABLES WITH READ LOCK,一般简写成 FTWRL。
执行这条命令后,整个实例的所有表都会被加上只读锁。业务上的 INSERT、UPDATE、DELETE、ALTER TABLE 全部阻塞,只有 SELECT 可以正常执行。
可以这样理解:数据库进入“全员阅读模式”,任何想改数据的人都得停在门外。
实际操作中很少会手动执行这条命令,但必须知道它的存在,因为一些备份工具在特定场景下会自动调用,导致线上突然写入卡住。
2.2 全局锁和一致性备份的取舍
全局锁经常出现在“保证备份一致性”的场景里。如果你用文件系统快照方式备份,或者备份的库里有 MyISAM 表,想要保证所有表数据处于同一个时间点,就需要先加全局锁,让写入停住,再拷贝数据文件。
但如果线上库全是 InnoDB,其实有更温和的方案。用 mysqldump 备份时加 --single-transaction 参数,会在事务隔离级别为可重复读的情况下开启一个一致性快照,备份过程中 InnoDB 表的数据依然可以被业务正常读写。
很多 DBA 的习惯是:
bash复制mysqldump -uroot -p --single-transaction --set-gtid-purged=OFF database_name > backup.sql
这样备份对业务几乎无感。关键点在于:--single-transaction 只对 InnoDB 表有效,如果库里混着 MyISAM 表,一致性就无法保证,这时才需要 FTWRL 配合。
2.3 全局锁引发线上事故的真实场景
我见过一次比较典型的事故:同事在业务高峰期执行了一个备份脚本,脚本为了“保险”,在备份前先执行了 FLUSH TABLES WITH READ LOCK,然后又把整个库的所有表数据导出。结果所有写请求在数据库连接池里堆成山,过了几十秒,连接池被打满,线上服务直接雪崩。
这里有一个容易忽略的连锁反应:全局锁虽然没有设置超时,但应用侧的连接池有最大连接数。写请求一旦被阻塞,连接就迟迟不释放,新请求也拿不到连接,最后把整个服务拖死。
所以使用全局锁,一定要避开业务高峰,并且把加锁的时间控制到最短。哪怕需要 FTWRL,也应该在备份脚本里先快速拿到锁,然后立刻开始备份,不要让锁空挂着。
3. 表级锁与 MDL 锁:一条 ALTER 卡住业务的排查思路
3.1 表锁和元数据锁是两回事
表级锁最常见的有两类:手动加的 LOCK TABLES ... READ/WRITE,以及 InnoDB 的意向锁。
手动表锁现在已经不推荐在日常业务里使用,因为粒度太粗,一行写入就能把整张表堵住。它现在主要出现在一些老系统、临时脚本,或者某些需要强一致导数的场景中。
比手动表锁更值得关注的是 MDL 锁,也就是元数据锁。MDL 不是由 SQL 里的 LOCK 语法显式触发的,而是 MySQL 在访问表结构时自动加上的。任何一条 SELECT、UPDATE 在执行时都要拿 MDL 读锁,任何一条 ALTER TABLE 需要拿 MDL 写锁。
MDL 锁的核心特点是:
- MDL 读锁之间不互斥,所以很多查询可以同时跑。
- MDL 写锁和读锁互斥,所以 DDL 执行期间,表上的普通查询也会被阻塞。
- 在一个事务提交前,它持有的 MDL 锁不会释放。
3.2 现场还原:一条 ALTER 为什么会拖垮所有请求
这是线上非常经典的一类故障。假设有一张大表,某个开发在业务低峰期执行了一条 ALTER TABLE 增加字段。如果当时表上没有长事务,这条 DDL 几秒就完成;但如果有一个事务开启了却迟迟没有提交,情况就完全不同了。
事务 A 执行:
sql复制BEGIN;
SELECT * FROM user_order WHERE id = 1;
事务不提交。
事务 B 执行:
sql复制ALTER TABLE user_order ADD COLUMN remark VARCHAR(50);
这条 DDL 需要 MDL 写锁,但事务 A 还握着 MDL 读锁,所以 B 只能一直等待。更麻烦的是,在 MySQL 8.0 的 MDL 队列机制里,一旦有写锁请求在排队,后续新来的普通查询也只能排在后面,避免写锁饿死。于是现象很快变成:整张表的所有读写全部卡住,show processlist 里能看到大量 Waiting for table metadata lock。
排查时可以用这条 SQL 查看 MDL 等待:
sql复制SELECT * FROM sys.schema_table_lock_waits\G
sys 库在 MySQL 5.7 起默认安装,这条视图可以直接看到哪个会话阻塞了 DDL。如果环境里没有这张视图,也可以通过 performance_schema.metadata_locks 查看:
sql复制SELECT OBJECT_TYPE, OBJECT_SCHEMA, OBJECT_NAME, LOCK_TYPE, LOCK_STATUS, OWNER_THREAD_ID
FROM performance_schema.metadata_locks
WHERE OBJECT_SCHEMA = 'test';
处理办法通常是找到那个持有 MDL 读锁的长时间事务,让应用层提交或回滚,必要时 KILL 对应连接。执行 DDL 前养成检查长事务的习惯,能避免 80% 的这类事故。
3.3 手动加表锁的遗留场景
手动表锁现在的应用场景已经很少。如果确实需要用到类似机制,可以在会话里执行:
sql复制LOCK TABLES user_order WRITE;
-- 只能操作被锁的表
UPDATE user_order SET status = 1 WHERE id = 1;
UNLOCK TABLES;
注意 LOCK TABLES 执行后,当前会话只能访问显式锁定的表,访问其他表会报错。这也是一个容易踩的坑。
4. 行级锁深度拆解:从 Record Lock 到 Next-Key Lock
4.1 行锁在 InnoDB 中锁的到底是什么
InnoDB 的行锁,本质上锁的是索引记录,不是“行数据”这个抽象概念。
InnoDB 的数据组织方式是聚簇索引,表数据本身就存储在基于主键的 B+ 树里。执行一条带 WHERE 条件的 SQL,InnoDB 会通过索引定位到具体记录,然后在这些索引记录上加锁。
这个机制衍生出一个非常重要的结论:如果 SQL 没有走索引,InnoDB 只能全表扫描聚簇索引,意味着每条扫描到的记录都会被加锁,最终效果等同于锁住了整张表的所有写入。你写了一条 UPDATE ... WHERE status = 1,如果 status 字段没有索引,执行时可能把全表所有行都锁住。这就是很多人说的“行锁退化成表锁”。
这个现象其实并不复杂,也很容易复现。给一张表插入少量数据,然后在一个事务里执行不带索引条件的 UPDATE,在另一个会话执行任意行的 UPDATE,会发现后一个会话完全无法运行。
4.2 行锁的类型:Record Lock、Gap Lock、Next-Key Lock
按锁定的范围,InnoDB 行锁可以进一步分为三种:
- Record Lock:记录锁,只锁某一条索引记录。
- Gap Lock:间隙锁,锁住两个索引记录之间的空隙,防止其他事务在这个间隙插入新记录。
- Next-Key Lock:临键锁,可以理解为“记录锁 + 间隙锁”的组合,锁住一个左开右闭的区间。
Next-Key Lock 是 InnoDB 在可重复读隔离级别下解决幻读问题的核心手段。举一个简单的例子:
表里有主键 id,当前数据为 1, 5, 10。事务 A 执行:
sql复制BEGIN;
SELECT * FROM user_order WHERE id > 1 AND id < 10 FOR UPDATE;
InnoDB 不仅要锁住 id = 5 这条已有记录,还要锁住 (1, 5] 和 (5, 10] 这样的区间。这样其他事务想插入 id = 6 的新记录时,会因为无法跨越间隙锁而阻塞,从而防止同一个事务内两次查询结果不一致。
这里很多人会把间隙锁理解错:间隙锁锁住的不是具体记录,而是“不允许别人在这个空档里插入数据”。如果没有间隙锁,一个事务先查询了一批数据,另一个事务插入了一条符合条件的新数据并提交,再查一次就会发现多了一行,也就是幻读。
4.3 等值查询和范围查询的加锁规则
实际开发中,很多人不需要死记所有加锁边界,但两个规则一定要清楚。
第一,命中唯一索引的等值查询通常只加 Record Lock。比如 SELECT * FROM user_order WHERE id = 5 FOR UPDATE,只要 id = 5 这条记录存在,就只锁这一行。因为唯一索引能精确定位,不需要担心其他事务插入新的 id = 5。
第二,范围查询或非唯一索引查询会用到 Next-Key Lock。比如按 status 字段查询,status 上建了普通索引但值不唯一,InnoDB 不敢确定是否还有相同值插进来,所以会把相邻的索引区间也锁住。
这里把我实际验证过的经典场景贴出来。
假设有一张订单表:
sql复制CREATE TABLE user_order (
id INT PRIMARY KEY,
user_id INT NOT NULL,
order_no VARCHAR(32) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
KEY idx_user_id (user_id)
) ENGINE=InnoDB;
INSERT INTO user_order VALUES
(1, 10, 'A10001', 0),
(2, 10, 'A10002', 0),
(3, 12, 'B10001', 1),
(4, 13, 'C10001', 0);
事务 A:
sql复制BEGIN;
SELECT * FROM user_order WHERE id = 3 FOR UPDATE;
由于 id 是主键,等值命中,只有 id = 3 这一行会被加上排他锁。事务 B 可以正常更新 id = 1 的记录,但如果尝试更新 id = 3,就会一直等待。
如果事务 A 执行的是:
sql复制BEGIN;
SELECT * FROM user_order WHERE user_id = 10 FOR UPDATE;
由于 user_id 是普通辅助索引,InnoDB 除了锁住两条 user_id = 10 的记录外,还可能锁住辅助索引记录附近的间隙。事务 B 即使想插入一条 user_id = 11 的新记录,也可能被阻塞,因为新记录插入的位置落在了间隙锁范围内。
4.4 插入意向锁:并发插入是怎么通行的
插入意向锁是一种特殊的间隙锁。它表示一个事务打算在某个间隙中插入记录,但在真正插入之前,需要先等待该间隙上已有的间隙锁释放。
这和普通间隙锁的互斥关系不一样:多个事务如果都打算往同一个间隙插入数据,它们各自持的是插入意向锁,并不互相阻塞。只有当间隙中已经存在间隙锁时,插入意向锁才会等待。
举个例子,表里两个主键分别是 id = 10 和 id = 20,两个事务同时插入 id = 15 和 id = 16。正常情况下它们不会互相卡住,因为插入意向锁之间是兼容的。但如果事务 A 先锁住了 (10, 20) 这个间隙,事务 B 再想插入 id = 15,就必须等 A 释放间隙锁。
4.5 行锁退化成全表范围的经典坑
最后要专门说一下“不加索引导致行锁全表”的问题,因为这是生产环境里最常见的隐性事故。
有次排查一个问题:某个表的所有写操作突然全部阻塞,一开始以为是死锁,查了半天发现只有一条 UPDATE 在跑。后来看执行计划,那条 SQL 的 WHERE 条件字段没有索引,全表扫了一遍。MySQL 对扫描过的每一条记录都加了锁,相当于把整张表的写入都堵住了。
解决思路很直接:给查询条件加合适的索引。加了索引后,同样一条 UPDATE 只需要精确定位到少量记录,锁的范围小,并发能力立刻提上来。这也是为什么我一直强调:线上写 SQL 之前,先看执行计划,确认 type 不是 ALL,key 是否有效。
5. 锁等待与死锁排查实战
5.1 快速找出谁在等待哪把锁
线上锁问题最需要的是“快速定位阻塞源头”,而不是拿着 SQL 猜。我自己常用的排查顺序是三步。
第一步,看当前事务:
sql复制SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.INNODB_TRX;
重点看 trx_state 是否为 LOCK WAIT,以及 trx_started 是否很早。一个事务超过几秒还在 LOCK WAIT,基本可以确定锁问题。
第二步,看锁等待关系。MySQL 5.7 之后可以用:
sql复制SELECT * FROM sys.innodb_lock_waits\G
这个视图会直接列出谁在等待、谁阻塞了谁、等待了多久,非常直观。
如果 sys 库不可用,也可以手写关联查询:
sql复制SELECT
r.trx_mysql_thread_id AS waiting_thread,
r.trx_query AS waiting_query,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS blocking_query
FROM information_schema.innodb_trx r
JOIN information_schema.innodb_lock_waits lw
ON r.trx_id = lw.requesting_trx_id
JOIN information_schema.innodb_trx b
ON b.trx_id = lw.blocking_trx_id;
第三步,确认阻塞线程后,尽快联系业务方提交事务。如果事务确实已经异常,可以通过 KILL 杀掉:
sql复制KILL 线程ID;
这里的线程 ID 是 INNODB_TRX.trx_mysql_thread_id,不是连接 ID,执行前仔细核对,避免杀错正常业务会话。
5.2 模拟死锁与日志解读
死锁比普通锁等待更难排查,因为两个事务互相等待,系统如果不干预,会一直卡到天荒地老。InnoDB 默认开启死锁检测,检测到循环等待后,会立刻选择一个代价较小的事务回滚,并抛出错误。
可以用两个会话复现一次死锁。
会话 A:
sql复制BEGIN;
UPDATE user_order SET order_no = CONCAT(order_no, '-A') WHERE id = 1;
会话 B:
sql复制BEGIN;
UPDATE user_order SET order_no = CONCAT(order_no, '-B') WHERE id = 3;
此时两个事务各锁一行,互不干扰。接着会话 A 继续执行:
sql复制UPDATE user_order SET order_no = CONCAT(order_no, '-A2') WHERE id = 3;
这一步会等待会话 B 释放 id = 3 的行锁。然后会话 B 继续执行:
sql复制UPDATE user_order SET order_no = CONCAT(order_no, '-B2') WHERE id = 1;
InnoDB 会检测到这个循环等待,其中一个事务立刻报错,常见的错误码是:
text复制ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
死锁发生后,用下面的命令查看最近一次死锁的信息:
sql复制SHOW ENGINE INNODB STATUS\G
重点看 LATEST DETECTED DEADLOCK 部分。日志会列出两个事务分别持有哪把锁、等待哪把锁、执行了什么 SQL。通过日志里的 SQL,基本能反推出业务代码里哪些场景的加锁顺序不一致。
5.3 锁问题速查与处理方向
| 现象 | 可能原因 | 快速定位方式 | 处理方向 |
|---|---|---|---|
| 所有写操作卡住,读正常 | 全局锁或 MDL 写锁等待 | show processlist,检查是否有 FLUSH TABLES 或 Waiting for table metadata lock |
找到持有锁的会话,提交或 KILL;避免高峰期执行 FTWRL |
| 单条 UPDATE 等待 | 另一事务持有同一行锁未提交 | sys.innodb_lock_waits 查看阻塞线程 |
通知业务方提交事务,或者配置更短的 innodb_lock_wait_timeout |
| DDL 执行很久 | MDL 写锁被长查询阻塞 | sys.schema_table_lock_waits |
找到长事务并处理,DDL 尽量避开业务高峰 |
| 业务频繁报 1213 死锁 | 多个事务加锁顺序不一致 | SHOW ENGINE INNODB STATUS 查看 LATEST DETECTED DEADLOCK |
调整业务代码,保证多行更新按统一顺序执行 |
| 无索引字段更新导致全表写入阻塞 | 行锁退化,扫描全表加锁 | EXPLAIN 查看执行计划 |
给条件字段加索引,减少锁范围 |
innodb_lock_wait_timeout 的默认值是 50 秒。线上如果经常出现锁等待,可以把值调小一些,比如 5 到 10 秒,让异常事务更快失败,而不是长时间占住连接,导致连接池耗尽。
5.4 降低锁冲突的 5 个设计思路
排查锁问题只是应急手段,更重要的从源头减少锁冲突。
第一,让事务足够短。锁是在事务提交或回滚时才释放的,一个事务里如果跑了很多无关查询,再随手做一次更新,锁的持有时间就会被无限拉长。更新操作应该尽量放在事务的最后一个步骤,提交前不干多余的事。
第二,确保更新和删除语句走索引。执行写操作前先跑一次 EXPLAIN,确认 type 不是 ALL,key 字段不为空。这是最容易被忽略、又最能拉开差距的一步。
第三,批量更新多行时保持一致的加锁顺序。比如订单状态流转,业务代码里总是先更新 id 较小的记录,再更新 id 较大的记录,不要在不同接口里出现相反的顺序。加锁顺序一致,绝大多数死锁自然消失。
第四,合理选择隔离级别。如果业务对幻读不敏感,可以考虑将隔离级别设为 READ COMMITTED。这个级别下 InnoDB 会放弃大部分间隙锁,只用记录锁,并发能力会明显提升。修改前需要在架构层面评估一致性需求,不能一概而论。
第五,避免大事务里执行用户交互或外部接口调用。事务开启期间,任何一句 SELECT 都可能因为普通查询变成间隙锁的持有者,后续再更新数据,锁等待时间会被外部延迟拖长。
有一段时间我特别迷信数据库调优参数,后来发现大部分锁问题都不是参数改出来的,而是 SQL 写法和事务设计导致的。把每个事务里的锁范围缩到最小,比任何参数都有效。
写到最后想说的话
每次遇到锁问题,我的第一反应已经不再是去翻文档背间隙锁规则,而是先回答三个问题:这条 SQL 用了哪些索引?事务会持锁多久?会不会同时出现多条 SQL 在争同一批资源?把这三个问题想清楚,锁等待基本就能定位到原因。死锁日志看起来吓人,本质上仍然是业务代码的加锁顺序出了问题。有空的时候,建议你本地建一张表,开两个终端窗口,把文中的场景亲手跑一遍,看到行锁等待和死锁报错真实出现一次,理解会深很多。
