先说个我上周值班碰到的事。一条非常普通的 UPDATE 语句,按主键更新一条记录,平时几毫秒就完事,结果那天卡了快 40 秒,最后报了个 1205 lock wait timeout exceeded。打电话的研发小哥第一反应是“数据库是不是挂了”,我登上去一查,processlist 里清清楚楚:这条 UPDATE 在等另一条事务释放行锁。
这不是什么稀奇问题,但它让我发现很多同学对 MySQL 锁的理解停留在“锁分为行锁和表锁”这个层面,出了事只知道看数据库有没有死锁日志,不会从锁类型和锁兼容性的角度去推导等待链。所以这篇我把 MySQL 的锁类型系统完整捋一遍——从全局锁到表级锁再到行级锁,从显式锁到隐式锁,把每个锁是怎么来的、会阻塞谁、怎么观察、怎么调优一次讲清楚,给还在对着报错日志发愁的同学一个能直接拿去用的排查路径。
1. MySQL 为什么要锁:先从并发一致性讲起
1.1 锁解决的核心矛盾
数据库本质上是一个多用户并发访问的系统,多个事务同时读写同一份数据是常态。如果完全不加控制,就会出现三类经典问题:脏读、不可重复读、幻读。锁和 MVCC(多版本并发控制)是 InnoDB 管理并发控制的两条腿,它们各有分工:
- MVCC 负责快照读,也就是普通
SELECT,读的是历史版本,不加锁。 - 锁 负责当前读,也就是
UPDATE、DELETE、INSERT以及SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE这类操作,读的是最新版本,必须加锁。
理解这个分工很重要,因为很多人以为 MySQL 的锁是“查询时自动加的”,实际上普通查询走的是 MVCC 快照,根本不加行锁,只有当前读才会触发锁机制。这个区别直接决定了你在 SELECT 时能否读到别的事务未提交的数据。
那为什么要用锁?核心就三个字:防冲突。事务 A 修改了一行,事务 B 也想去修改同一行,如果不加锁,A 的修改可能被 B 覆盖,产生更新丢失。锁的本质就是协调竞争,让同一时刻只有一个事务能修改目标数据,或者让其他事务在合适的时机读到一致的数据。
1.2 锁类型的全景分类
MySQL 的锁不是单一概念,而是分层、分维度的一套体系。常说的“行锁”和“表锁”只是按粒度划分,往细了说,还要区分锁的模式、加锁算法、归属层级。我把它们整理成一张全景表:
| 分类维度 | 锁类型 | 作用范围 | 典型场景 |
|---|---|---|---|
| 按粒度 | 全局锁、表级锁、行级锁 | 实例 / 表 / 记录 | 备份、DDL、事务更新 |
| 按模式 | 共享锁(S)、排他锁(X) | 表或记录 | 读写操作 |
| 按算法 | 记录锁、间隙锁、临键锁、插入意向锁 | 索引记录与区间 | 行级并发控制 |
| 按功能 | 元数据锁(MDL)、自增锁(AUTO-INC) | 表、自增列 | DDL、INSERT 自增 |
| 按归属层级 | Server 层锁、InnoDB 引擎层锁 | 全局 / 表 / 行 | 大部分业务场景 |
这些维度不是互斥的,而是一层层叠加的。比如一个最常见的 UPDATE t SET name='x' WHERE id=10,在 RR(可重复读)隔离级别下走主键索引等值更新,它可能同时触发:Server 层的 MDL 读锁、InnoDB 层的意向排他锁(IX)、记录上的排他锁(X, REC_NOT_GAP)。你看到锁等待时,要能判断出它到底卡在哪一层,这才是排查问题的起点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 按粒度认识锁:全局锁、表锁、意向锁
2.1 全局锁:影响整个实例的“大杀器”
全局锁最典型的是 FLUSH TABLES WITH READ LOCK(简称 FTWRL),执行后整个实例进入只读状态,所有事务的写操作、DDL 都会被阻塞。这个锁的使用场景几乎只有一个:全库逻辑备份,为了拿到一致性快照。
但实际生产环境,我强烈不建议直接用 FTWRL 做在线备份,因为它会把业务写操作全部卡住,如果是核心库,一次备份可能引发线上故障。更稳妥的做法是用 mysqldump --single-transaction --master-data=2 配合 InnoDB 的 MVCC 机制,在事务隔离级别下拿到一致性快照,不需要锁整个实例。5.7 之后的 --single-transaction 已经足够满足大部分 InnoDB 表的备份需求。MyISAM 表因为没有事务支持,只能靠 FTWRL 或 LOCK TABLES,这也是 MyISAM 逐渐被淘汰的原因之一。
2.2 表级锁的两副面孔:显式表锁与隐式 MDL 锁
表级锁分两种:显式的 LOCK TABLES 和隐式的 MDL 锁。
显式表锁就是手动执行:
sql复制LOCK TABLES t WRITE;
-- 业务操作
UNLOCK TABLES;
执行后其他会话不能读也不能写这张表。MyISAM 引擎完全依赖表锁来保证并发安全,一个写事务会锁住整张表,所以并发写入能力很弱。InnoDB 场景下手动加表锁的情况非常少见,大部分 DBA 都不推荐使用,因为它会把行级并发优势直接抹平。
隐式的表级锁才是重点,也就是 MDL 锁(Metadata Lock)。MySQL 从 5.5 开始引入 MDL 锁,目的是保护表结构(元数据)在 DDL 和 DML 并发时的一致性。规则很简单:
- 增删改查(DML)自动加 MDL 读锁,多个读锁可以共存。
- 修改表结构(DDL)自动加 MDL 写锁,写锁与读锁、写锁之间都互斥。
这个地方是很多线上事故的元凶。我见过一个典型场景:开发在业务高峰期执行 ALTER TABLE t ADD COLUMN ...,这个 DDL 需要 MDL 写锁,但当时有一条长时间未提交的事务持有了 MDL 读锁,于是 DDL 排队等待。更坑的是,MySQL 的 MDL 锁有一个等待队列,DDL 一旦开始等待,它后面所有新的 DML 请求都会排到 DDL 后面,哪怕 DML 之间本来可以并发执行。结果就是一条 DDL 卡住,后续所有读写全部堆积,数据库连接池被打满,生产瞬间雪崩。
排查这类问题,在 8.0 里可以查 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' AND OBJECT_NAME = 't';
看到 LOCK_STATUS 是 PENDING 的,基本就是正在等待 MDL 写锁的 DDL,然后顺着 OWNER_THREAD_ID 去找持有读锁的线程,通常是一条大事务或慢查询。
2.3 意向锁:连接表锁和行锁的桥梁
意向锁是 InnoDB 特有的一类表级锁,名字叫“意向”,但它的作用不是直接锁表,而是登记当前事务准备在表的某些行上加什么锁。InnoDB 在加行级共享锁(S)之前,会先在表上加意向共享锁(IS);在加行级排他锁(X)之前,会先在表上加意向排他锁(IX)。
为什么要多这一层?我打个比方:图书馆每个书桌上都有读者(行锁),管理员想整层楼临时封闭(表锁),如果没意向锁,管理员得挨个座位检查有没有人,效率极低。意向锁就是每个读者进来时先在门口登记“我要在某个座位读书”,管理员一看登记表就知道有没有人在,不用逐个检查。
意向锁的兼容性规则是:
| 锁类型 | IS | IX | S | X |
|---|---|---|---|---|
| IS | 兼容 | 兼容 | 兼容 | 不兼容 |
| IX | 兼容 | 兼容 | 不兼容 | 不兼容 |
| S | 兼容 | 不兼容 | 兼容 | 不兼容 |
| X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
注意一个关键点,也是很多人的误区:IS 锁和 IX 锁之间是互相兼容的。也就是说,两个事务分别给不同行加行锁,它们各自持有的 IX 表锁不会互相阻塞。意向锁只有和 S/X 表锁对比时才有冲突关系。所以实际业务里,意向锁本身很少成为等待瓶颈,它更像是 InnoDB 内部做兼容性判断的“提示牌”。
3. 行锁三兄弟:记录锁、间隙锁、临键锁的取舍逻辑
3.1 记录锁:锁定单条索引记录
记录锁是行级锁最基础的一种,锁的是索引记录,不是一个抽象意义上的“行”。InnoDB 的行锁本质上是索引记录锁,这句话值得记一辈子——如果表没有索引,InnoDB 会先建一个隐藏的聚簇索引(通常是主键),行锁就锁在这个隐藏索引上。
最常见的情况,走主键或唯一索引等值更新:
sql复制START TRANSACTION;
UPDATE t SET name = 'a' WHERE id = 10;
此时 id 是主键,InnoDB 会在主键索引记录 id=10 上加一个排他记录锁(LOCK_MODE: X, REC_NOT_GAP),这个锁只锁这一条记录,不锁它前后的范围。其他事务可以正常插入 id=9、id=11 的记录,只是不能修改或删除 id=10 这条。
记录锁有一个很重要的优化:在 RR(可重复读)隔离级别下,如果等值查询命中唯一索引且记录存在,MySQL 会把临键锁“退化”为记录锁,只锁这一条,不加间隙锁,这样既避免了幻读,又最大化并发度。这一点我会在后面的临键锁部分再展开。
3.2 间隙锁:防幻读的真正主力
间隙锁锁的不是记录,而是两个索引记录之间开放的区间。比如表中主键有 5、10、15 三条记录,那么间隙就是 (-∞,5)、(5,10)、(10,15)、(15,+∞) 这四个区间。对某个间隙加锁后,其他事务无法在区间内插入任何记录。
间隙锁默认只在 RR 隔离级别下启用。它存在的意义就是为了解决幻读:事务 A 在 RR 下执行 SELECT * FROM t WHERE id > 10 FOR UPDATE,如果只锁已有记录,事务 B 插入一条 id=12 的记录再提交,A 再次查询就会多出一行,这就叫幻读。为了避免这种情况,A 必须把 (10,+∞) 这个间隙也锁住,让 B 无法插入。
间隙锁和记录锁的行为差异很大,你需要记住下面几条:
- 间隙锁之间互相兼容。两个事务可以同时持有同一个间隙的间隙锁,因为间隙锁只阻止插入,不阻止其他间隙锁。
- 间隙锁只阻塞插入操作,不阻塞另一个事务对已有记录的修改。
- 间隙锁在 RC(读已提交)隔离级别下基本不存在,因此 RC 下插入冲突更少,并发度更高,但可能出现幻读。
用停车场的例子类比:记录锁相当于锁死某个具体车位,间隙锁相当于在地面上画了一条“禁停线”,别的车可以看、可以路过,但想停进这个区域就会被拦下来。
3.3 临键锁:记录 + 间隙的组合拳
临键锁(Next-Key Lock)是记录锁和间隙锁的组合,范围包含一个左开右闭的区间 (前一条记录, 当前记录]。它是 RR 隔离级别下的默认加锁单位,也就是说,RR 下普通索引上的范围查询,加的不是单纯记录锁,也不是单纯间隙锁,而是临键锁。
举例:表 t 有主键 5、10、15,事务执行:
sql复制START TRANSACTION;
SELECT * FROM t WHERE id >= 10 FOR UPDATE;
在 RR 下,InnoDB 会加这些锁:
- 主键
id=10上的记录锁; - 间隙
(5,10)上的间隙锁; - 主键
id=15上的记录锁; - 间隙
(10,15)和(15,+∞)上的间隙锁。
也就是说,区间 (5,+∞) 内的写入都会阻塞。这能保证事务再次查询时结果集不变,也就杜绝了幻读。
但临键锁也是死锁的高发区。两个事务如果交叉访问相邻范围的数据,很容易互相持有间隙锁、等待对方释放,形成循环等待。比如事务 A 更新 id > 10 的记录,事务 B 更新 id < 15 的记录,两个范围重叠,就可能同时锁住中间的间隙,然后再拿着自己的间隙锁去请求对方记录上的锁,死锁就来了。
所以,评估一条 SQL 的加锁范围,不能只看它定位到几条记录,还要看它走了什么索引、锁了哪几个间隙。定位方法后面会讲,用 performance_schema.data_locks 可以直接把实际加锁情况拉出来。
4. DDL 与隐式锁:MDL 锁和自增锁容易被忽略的阻塞源
4.1 MDL 锁阻塞的经典链路:一条 DDL 引发的连环事故
前面提到 MDL 锁的规则,这里展开讲一个完整链路,因为这个场景在线上出现频率太高了。
场景还原:有一张订单表 orders,业务侧执行了一条长达几十秒的批量 UPDATE,事务一直未提交,持有 orders 表上的 MDL 读锁。此时运维/开发执行:
sql复制ALTER TABLE orders ADD INDEX idx_status(status);
这个 DDL 请求 MDL 写锁,发现表上有未释放的读锁,于是进入等待状态。关键问题来了:MySQL 的 MDL 子系统会维护一个等待队列,写锁请求排在读锁请求前面。新来的 SELECT、UPDATE 虽然只需要 MDL 读锁,但因为前面有写锁在排队,它们全部被阻塞。这就像高速公路收费站,某条车道因为事故封闭,后面所有车都在排队,哪怕你的车原本可以走别的车道。
我在生产环境遇到过一次,一条 ALTER TABLE 导致整库线程数打满,连接池报 Too many connections。当时解决问题的步骤是:
- 查看
performance_schema.metadata_locks,找到LOCK_STATUS = 'PENDING'的 DDL 线程。 - 顺着
OWNER_THREAD_ID找到持有 MDL 读锁的线程。 SELECT * FROM information_schema.innodb_trx看这个线程对应的事务是否长时间未提交。- 如果确认事务可以终止,用
KILL结束事务,DDL 立即获得写锁并继续执行。
所以,MDL 锁的排查思路虽然简单,但理解它的排队机制至关重要。做 DDL 前务必检查是否有长事务,条件允许的话用 pt-online-schema-change 这类工具做在线表结构变更,尽量避免高峰期直接 ALTER TABLE。
4.2 自增锁与 AUTO-INC 锁:并发插入的隐形约束
自增锁(AUTO-INC Lock)是一种特殊的表级锁,专门保护 AUTO_INCREMENT 列的值分配。它和 MDL 锁一样,也是自动加、自动释放的,不需要你手动控制。
关于自增锁,重点在于 innodb_autoinc_lock_mode 参数,它决定自增值分配的加锁策略:
| 参数值 | 模式 | 行为 | 适用场景 |
|---|---|---|---|
| 0 | traditional | 所有 INSERT 都持有 AUTO-INC 表锁到语句结束 | 兼容老版本,并发低 |
| 1 | consecutive | 简单插入用轻量互斥锁,批量插入才用 AUTO-INC 锁 | 5.7 默认,兼顾并发与连续性 |
| 2 | interleaved | 所有情况都用轻量互斥锁,不加 AUTO-INC 表锁 | 8.0 默认,并发最高,自增值可能不连续 |
为什么 8.0 默认改成 2?因为模式 2 下批量插入的自增值可能不连续,而 MySQL 8.0 默认 binlog_format = ROW,自增值不连续不会影响主从数据一致性。如果你还在 5.7 环境,binlog 是 STATEMENT 模式,使用模式 2 可能导致从库自增值与主库不一致,这个坑要注意。
自增锁很少成为业务瓶颈,但有一个高频问题:事务回滚后自增 ID 出现空洞是正常现象,不要试图去补 ID。INSERT ... ON DUPLICATE KEY UPDATE 每执行一次也会消耗一个自增 ID,哪怕没真正插入新行,这是 InnoDB 为了并发安全做的取舍,属于预期行为。
5. 插入意向锁与死锁:一个真实等待链的排查复盘
5.1 插入意向锁:为什么 INSERT 也会等待
插入意向锁(Insert Intention Lock)是一种特殊的间隙锁,准确说它是“为了插入而设置的意向”。当多个事务想要往同一个间隙插入数据时,它们会先获取插入意向锁,插入意向锁之间是互相兼容的,所以可以并发插入。
但如果这个间隙已经被其他事务加了普通的间隙锁或临键锁,插入意向锁就会与之冲突,INSERT 必须等待。举个例子:
sql复制-- 事务 A
START TRANSACTION;
SELECT * FROM t WHERE id > 10 FOR UPDATE; -- 锁住间隙 (10, +∞)
-- 事务 B
INSERT INTO t (id, name) VALUES (12, 'x'); -- 等待,因为间隙被 A 的临键锁锁住
通过 performance_schema.data_locks 可以看到事务 B 的 LOCK_MODE 为 X, INSERT_INTENTION,LOCK_STATUS 为 WAITING。这个锁机制就是为了防止两个事务同时插入一个“间隙锁已覆盖”的范围,从而破坏幻读保护。
有一点容易被忽略:插入意向锁是在真正插入之前临时申请的,它本身不阻塞其他插入意向锁,只和间隙锁/临键锁冲突。因此,在 RC 隔离级别下没有间隙锁,INSERT 的等待概率会大幅下降,这也是为什么很多高并发业务会把隔离级别从 RR 调到 RC 的原因之一。
5.2 一个典型的死锁案例:双事务互相等待
死锁不是“锁太多了”才发生,而是两个或多个事务互持资源、互相等待。经典的场景是交叉更新:
- 事务 A:
UPDATE t SET name='a' WHERE id=1;然后UPDATE t SET name='b' WHERE id=2; - 事务 B:
UPDATE t SET name='b' WHERE id=2;然后UPDATE t SET name='a' WHERE id=1;
并发执行时,可能出现 A 先锁住 id=1,B 先锁住 id=2,然后 A 请求 id=2 等 B,B 请求 id=1 等 A,两边都在等对方释放锁,死锁形成。
InnoDB 有一个后台死锁检测机制,通过等待图判断是否形成循环等待。一旦检测到死锁,会选择一个代价较小的事务回滚,另一个事务继续执行。所以你看到 1213 Deadlock found when trying to get lock; try restarting transaction,说明 InnoDB 已经帮你处理了一方,业务代码只需要捕获异常重试即可。
下面是一个典型的死锁日志片段:
code复制LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 8188, ACTIVE 10 sec
mysql tables in use 1, locked 1
LOCK WAIT ... lock struct(s), heap size ...
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index `PRIMARY` of table `test`.`t` ...
lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 8189, ACTIVE 8 sec
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS ... lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... lock_mode X locks rec but not gap
WE ROLL BACK TRANSACTION (1)
读日志时重点关注三块:
HOLDS THE LOCK(S):事务当前持有哪些锁。WAITING FOR THIS LOCK TO BE GRANTED:事务正在等待哪个锁。WE ROLL BACK TRANSACTION:InnoDB 选择回滚了哪个事务。
5.3 死锁规避的常见策略
死锁无法完全消除,但可以从代码层面大幅降低概率。我的建议按优先级排列:
- 固定访问顺序:多个事务更新多条记录时,按相同顺序更新(比如都按主键从小到大),避免交叉。
- 缩短事务时间:事务里只放必要的 SQL,不要在事务里做远程调用、循环大批量操作。
- 避免无索引更新:
UPDATE的 WHERE 条件如果没走索引,InnoDB 会扫描大量记录,加锁范围失控,极容易引发死锁。用EXPLAIN确认执行计划走的是索引。 - 评估隔离级别:业务允许的话,把 RR 降为 RC,去掉间隙锁和临键锁,死锁概率会明显下降。
- 批量操作分批:大批量 UPDATE/DELETE 可以按主键范围分批处理,每批一个事务,降低锁持有时间和锁重叠概率。
6. 锁状态的实时观测与参数调优
6.1 用 performance_schema 精确定位锁等待
排查锁问题的第一步,不是看日志,而是看实时状态。MySQL 8.0 最实用的是 performance_schema.data_locks 和 performance_schema.data_lock_waits,前者展示当前所有锁,后者展示锁等待关系。
查看当前所有等待中的锁:
sql复制SELECT *
FROM performance_schema.data_locks
WHERE LOCK_STATUS = 'WAITING';
查看谁在等谁:
sql复制SELECT *
FROM performance_schema.data_lock_waits;
data_lock_waits 的 REQUESTING_ENGINE_TRANSACTION_ID 是被阻塞事务,BLOCKING_ENGINE_TRANSACTION_ID 是持锁事务。再配合 information_schema.innodb_trx 看事务的执行 SQL、启动时间、状态,一条完整的等待链就出来了。
data_locks 表有几个字段要会读:
LOCK_TYPE:TABLE或RECORD,区分表锁和行锁。LOCK_MODE:X, REC_NOT_GAP表示排他记录锁;X, GAP表示排他间隙锁;X表示临键锁(RR 下常见);X, INSERT_INTENTION表示插入意向锁;S表示共享锁。LOCK_DATA:被锁的具体记录或间隙范围。LOCK_DATA显示为10, 15这类形式时,代表锁住的索引值范围。
举个例子,你想确认一条 SELECT * FROM t WHERE id > 10 FOR UPDATE 到底锁了什么,执行后查 data_locks 能看到好几条 RECORD 锁记录,LOCK_DATA 从 supremum pseudo-record 到 15 不等,这就是临键锁覆盖的间隙在现实中的表现。
6.2 sys 库视图:快速定位阻塞源头
performance_schema 的表字段多,不够直观。MySQL 的 sys 库已经把常用场景封装成了视图,其中 sys.innodb_lock_waits 最适合快速判断锁阻塞:
sql复制SELECT * FROM sys.innodb_lock_waits\G
返回结果会直接告诉你:
waiting_pid:被阻塞的会话 ID。waiting_query:被阻塞会话正在执行的 SQL。blocking_pid:持有锁的会话 ID。blocking_query:持锁会话执行的 SQL(通常能看出它是一个事务)。
这个视图比手写多表关联高效得多,我线上排查锁等待基本都用它开头。已经确定是行锁等待后,再回 innodb_trx 看持锁事务跑了多久,决定是 KILL 还是等它自然结束。
6.3 与锁相关的参数优化
锁问题的调优,绕不开几个核心参数。我给出日常使用的参考值和建议依据:
| 参数 | 默认值 | 建议 | 说明 |
|---|---|---|---|
innodb_lock_wait_timeout |
50 | 5~30 | 事务等待行锁的超时时间,超过后报 1205。设太短容易误伤正常等待,设太长会拖垮连接池。 |
transaction_isolation |
REPEATABLE-READ | 视业务而定 | RR 下间隙锁多,死锁概率高;RC 下并发好,但有幻读风险。 |
innodb_deadlock_detect |
ON | 默认开启 | 热点更新并发极高时可考虑关闭,但必须配合极短的 innodb_lock_wait_timeout,否则可能长时间阻塞。 |
innodb_autoinc_lock_mode |
2(8.0)/1(5.7) | 8.0 保持默认 | 自增锁模式,8.0 默认 ROW binlog 下用 2 提升并发。 |
innodb_lock_wait_timeout 是我最常调的参数。很多业务默认值 50 秒太长,一次行锁等待就会占住一个数据库连接 50 秒,连接池很快耗尽。建议根据业务响应要求设置成 5~10 秒,宁愿报 1205 让业务快速重试,也不要让请求在数据库层默默堆积。
6.4 从代码侧减少锁竞争
参数调优只是治标,很多锁等待问题根源在代码。我总结了几个代码侧的硬性要求,团队开发规范里可以直接抄:
- 事务尽量短:一个事务里只包含必须一起提交的 SQL,其他查询和计算放到事务外。
- 避免大事务:一次更新几十万行会持有大量行锁,拖慢所有访问同一范围的请求。
- 更新条件必须走索引:没有索引的 UPDATE/DELETE 会退化为全表扫描加锁,严重影响并发。
- 批量操作分批提交:比如定时任务要清理过期数据,按主键范围每批 500~1000 条,提交后再处理下一批。
- 读写分离要考虑延时:主库锁等待会直接体现写延迟,如果业务能接受,把部分查询压到从库,减少主库负载。
7. 最后分享几个我排查锁问题的习惯
7.1 判断加锁范围:用 EXPLAIN 和 data_locks 配合
很多人在排查锁问题时喜欢直接猜,其实 MySQL 已经把加锁范围暴露得很清楚了,只是你没有去读。我判断一条 SQL 的加锁范围,固定两步走:
第一步看执行计划:
sql复制EXPLAIN SELECT * FROM t WHERE id > 10 FOR UPDATE;
确认是否走索引、扫描范围多大。如果 type 是 ALL(全表扫描),说明 InnoDB 会对扫过的记录和间隙都加锁,范围可能远超预期。
第二步看实际加锁:
sql复制SELECT OBJECT_NAME, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks;
这样你能直接看到真实加的是记录锁还是临键锁、锁到哪条记录。不要凭感觉推断,用数据说话。
7.2 锁问题的排查优先级
遇到“数据库卡了”“SQL 超时”这类反馈,我建议按这个顺序排查:
- 先看
processlist,有没有大量Waiting for table metadata lock或Waiting for row lock状态的会话。 - 如果是 MDL 等待,查
metadata_locks,找 DDL 源头,KILL 长事务后恢复。 - 如果是行锁等待,查
sys.innodb_lock_waits看阻塞者和被阻塞者,确认持锁事务是什么,评估能否 KILL。 - 如果出现死锁,看
SHOW ENGINE INNODB STATUS\G里的LATEST DETECTED DEADLOCK,找代码里的交叉更新路径。 - 最后回顾代码和参数:是否有大事务、是否缺索引、
innodb_lock_wait_timeout是否合理。
这套流程走下来,90% 的锁问题都能在十分钟内定位。遇到锁相关的问题,不要慌,也不要一上来就重启数据库——重启只会让持锁信息消失,问题下一个高峰还会来。把锁类型、等待链、死锁日志读明白,才能真正把这个问题从根上解决。
