📖 本篇简介:锁是数据库并发控制的核心机制,也是生产环境"偶发接口超时"背后最常见的元凶。本篇在第 14 章事务与隔离级别的基础上,深入 InnoDB 的行锁、表锁、间隙锁,讲解死锁、锁等待的现场排查方法,并对比乐观锁与悲观锁在业务中的取舍。学完本篇,你将能看懂SHOW ENGINE INNODB STATUS里的死锁报告,并用information_schema视图定位阻塞源头。本系列示例基于 MySQL 8.0 语法。
目录
- 1 MySQL 锁机制总览
- 2 InnoDB 行锁与表锁
- 3 间隙锁与临键锁
- 4 死锁分析与处理
- 5 锁等待排查
- 6 乐观锁与悲观锁
- 7 隔离级别与锁的关系
- 8 锁机制速查表
- 9 常见坑与注意事项
- 10 练习题与参考答案
1 MySQL 锁机制总览
数据库为什么需要锁?因为多个事务同时读写同一份数据时,如果不加任何约束,就会出现脏读、不可重复读、幻读等问题。锁的本质是一种"互斥协议"——谁先拿到锁,谁就能操作数据,其他事务只能排队等待。
MySQL 的锁体系可以从三个维度划分:
| 维度 | 类型 | 说明 |
|---|---|---|
| 按粒度分 | 表锁、行锁、页锁 | 行锁并发最高,表锁开销最小 |
| 按性质分 | 共享锁(S)、排他锁(X) | 读锁共享,写锁独占 |
| 按意图分 | 意向共享(IS)、意向排他(IX) | 表级别的"我下面要加行锁"声明 |
不同存储引擎支持的锁粒度不同:MyISAM 只支持表锁,InnoDB 同时支持表锁与行锁。我们生产环境几乎只用 InnoDB,所以本篇重点讲 InnoDB。
一张表的锁兼容矩阵(S=共享锁,X=排他锁):
| 已有 \ 请求 | IS | IX | S | X |
|---|---|---|---|---|
| IS | ✅ | ✅ | ✅ | ❌ |
| IX | ✅ | ✅ | ❌ | ❌ |
| S | ✅ | ❌ | ✅ | ❌ |
| X | ❌ | ❌ | ❌ | ❌ |
简单记:读与读兼容,其他任意组合基本互斥。意向锁之间互相兼容,目的是让"想加表锁的人"快速判断"下面是否有行锁",而不用逐行扫描。
1.1 锁的三层结构
理解 InnoDB 锁,最容易混乱的是"到底有多少层锁"。可以把它想象成三层:
1. Server 层 MDL:任何对表的访问都要先申请 MDL(读/写),防止一边改表一边读数据;
2. 引擎层表级意向锁:事务打算加行锁之前,先在表上挂一把 IX/IS,告诉别人"我下面要动行";
3. 引擎层行锁:真正加在 B+ 树索引记录上的 Record/Gap/Next-Key 锁。
三层同时存在,请求一把行锁时,InnoDB 会按顺序从外到内依次申请。任何一层被卡住,整个事务就停在那里。这也是为什么"一个简单 UPDATE 却要等 50 秒"——它可能卡在 MDL、可能卡在 IX、也可能卡在某条行锁上。
1.2 用 Python 模拟一张简单的锁兼容矩阵
为了把兼容矩阵刻进脑子,下面用 Python 写一个极简模拟器(本示例本机实跑):
# 锁兼容矩阵:key=(已有锁, 请求锁),值表示是否兼容
compat = {
('IS', 'IS'): True, ('IS', 'IX'): True, ('IS', 'S'): True, ('IS', 'X'): False,
('IX', 'IS'): True, ('IX', 'IX'): True, ('IX', 'S'): False, ('IX', 'X'): False,
('S', 'IS'): True, ('S', 'IX'): False, ('S', 'S'): True, ('S', 'X'): False,
('X', 'IS'): False, ('X', 'IX'): False, ('X', 'S'): False, ('X', 'X'): False,
}
# 测试几个典型组合
cases = [('S', 'S'), ('S', 'X'), ('X', 'X'), ('IX', 'IX')]
for hold, req in cases:
print(f"已有 {hold},请求 {req} → {'兼容' if compat[(hold, req)] else '阻塞'}")
实跑输出:
已有 S,请求 S → 兼容
已有 S,请求 X → 阻塞
已有 X,请求 X → 阻塞
已有 IX,请求 IX → 兼容
这张矩阵的规律:只有"读与读"、"意向与意向"是兼容的,其他任意组合都会互相阻塞。
2 InnoDB 行锁与表锁
2.1 行锁的两种形式
InnoDB 的行锁是加在索引上的,不是加在物理行上。这是新手最容易误解的一点。两种行锁:
- Record Lock(记录锁):锁住索引上的一条具体记录;
- Gap Lock(间隙锁):锁住索引记录之间的"间隙",防止别的事务在间隙中插入。
-- 事务 A:对 id=100 这条记录加排他锁(行锁)
BEGIN;
SELECT * FROM orders WHERE id = 100 FOR UPDATE; -- 加 X 锁
COMMIT;
-- 事务 B:对 id=100 这条记录加共享锁
BEGIN;
SELECT * FROM products WHERE id = 100 LOCK IN SHARE MODE; -- 加 S 锁
COMMIT;
FOR UPDATE 是排他锁,LOCK IN SHARE MODE(MySQL 8.0 也可写 FOR SHARE)是共享锁。两个事务如果对同一行分别加 X 和 S,后者会等待前者提交。
2.2 行锁加在索引上的含义
InnoDB 行锁依赖索引。如果 WHERE 条件没有走索引,InnoDB 会对全表所有记录加锁——效果等同于表锁。
-- 假设 orders 表只有主键索引,status 字段没有索引
-- 这条 UPDATE 没走索引,会锁住全表所有行!
UPDATE orders SET status = 'PAID' WHERE status = 'PENDING';
这是生产环境最经典的"明明加了行锁却把表锁死"事故。结论:凡是 `UPDATE` / `DELETE` 的 WHERE 字段,必须有索引。
2.3 表锁的几种触发方式
虽然 InnoDB 支持行锁,但以下场景仍会退化为表锁:
-- 1. 显式加表锁(一般不推荐,DBA 应急时用)
LOCK TABLES orders WRITE;
-- 操作...
UNLOCK TABLES;
-- 2. 执行 DDL(ALTER / OPTIMIZE 等)时会自动上表级 MD L 锁
ALTER TABLE orders ADD INDEX idx_user(user_id);
-- 3. 锁升级:当一行加锁的开销过大,InnoDB 不会自动升级,但 MyISAM 会
MySQL 8.0 引入了 MDL(Metadata Lock,元数据锁),任何对表的读写都会先上 MDL 读锁,DDL 会请求 MDL 写锁。如果有一个长事务持有 MDL 读锁,后续 DDL 会堵住所有新的读写请求——这是"DDL 一执行全站卡住"的根因。
3 间隙锁与临键锁
间隙锁(Gap Lock)是 InnoDB 在可重复读(RR)隔离级别下特有的机制,目的是防止幻读。它锁住的是"两条索引记录之间的间隙",而不是具体某条记录。
3.1 临键锁(Next-Key Lock)
InnoDB 默认使用的是临键锁 = 记录锁 + 间隙锁。它是左开右闭区间 (a, b]。假设有索引值 10, 20, 30,那么临键锁的区间是:
(-∞, 10] (10, 20] (20, 30] (30, +∞)
当事务执行范围查询 WHERE id BETWEEN 15 AND 25 FOR UPDATE 时,InnoDB 会锁住 (10, 20] 和 (20, 30] 这两个临键区间——也就是说,其他事务想在这个范围里插入 16、25 都会被阻塞。
3.2 一个间隙锁引发的经典死锁
-- 假设 orders 表主键 id 上有 10、20 两条记录
-- 事务 A
BEGIN;
SELECT * FROM orders WHERE id BETWEEN 15 AND 25 FOR UPDATE;
-- 此时锁住 (10, 20] 和 (20, 30],间隙 15~20 不能插入
-- 事务 B
BEGIN;
INSERT INTO orders(id, amount) VALUES (15, 100);
-- 被 A 的间隙锁阻塞,等待 A 提交
-- 事务 A
INSERT INTO orders(id, amount) VALUES (15, 200);
-- 试图插入 15,但 B 已经在等这把间隙锁,A 自己也拿不到
-- → 死锁!MySQL 会自动检测并回滚其中一个事务
这个例子说明:在 RR 隔离级别下,范围查询会加临键锁,业务代码里不恰当的范围查询极易引发死锁。把隔离级别降到 RC(读提交)可以关闭间隙锁,但会失去防幻读能力。
4 死锁分析与处理
死锁(Deadlock)是两个或多个事务互相持有对方需要的锁,形成循环等待。InnoDB 有自动死锁检测机制(innodb_deadlock_detect=ON),检测到死锁后会主动回滚其中权重较小的那个事务,让另一个继续。
4.1 查看最近一次死锁
-- 查看最近一次死锁的详细报告
SHOW ENGINE INNODB STATUS;
输出里 LATEST DETECTED DEADLOCK 段会告诉你:
- 哪个事务在等哪把锁;
- 锁的模式(X/S、GAP/REC);
- 被回滚的是哪个事务、执行了哪条 SQL。
4.2 死锁的典型场景
| 场景 | 原因 | 解决思路 |
|---|---|---|
| 多事务以不同顺序更新多行 | 加锁顺序不一致 | 统一加锁顺序(按主键升序) |
| 范围查询 + 并发插入 | 间隙锁互相阻塞 | 降到 RC 隔离级别 |
| 长事务 + 小事务 | 长事务持锁久 | 缩短事务 |
| 唯一索引冲突 | INSERT 等待 GAP 锁 | 提前查重或改用 INSERT IGNORE |
4.3 减少死锁的实践建议
1. 固定访问顺序:所有事务都按主键升序更新多行,避免交叉等待;
2. 缩短事务:事务里不要夹外部 RPC、用户输入;
3. 降低隔离级别:业务允许的话用 RC,关闭间隙锁;
4. 降低并发:热点行排队可以用分布式锁兜底;
5. 业务重试:应用层捕获 1213 错误(死锁错误码)并重试。
-- 查看死锁相关的状态变量
SHOW STATUS LIKE 'Innodb_deadlocks'; -- 自上次启动以来死锁总数
4.4 一个典型的双事务死锁还原
下面这个场景在真实业务里经常出现:两个用户同时给对方转账,恰好两边都先扣自己账户、再给对方加钱。
-- 事务 A(用户 1 给用户 2 转 100 元)
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- 锁住行 1
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- 等行 2
-- 事务 B(用户 2 给用户 1 转 50 元)
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE user_id = 2; -- 锁住行 2
UPDATE accounts SET balance = balance + 50 WHERE user_id = 1; -- 等行 1
-- 此时 A 等 B、B 等 A,形成循环等待
-- InnoDB 死锁检测会立刻回滚其中一个事务(一般是回滚 undo 量小的那个)
解决办法:所有转账事务都统一按 user_id 升序加锁——不管谁转给谁,都先锁 user_id 小的那一行。这样两个事务就不会交叉等待,死锁自然消失。
5 锁等待排查
死锁会被自动解决,但"锁等待"(Lock Wait)会一直堵到超时(默认 innodb_lock_wait_timeout=50 秒)。这才是生产环境更常见的问题——一条 SQL 卡住 50 秒,整个接口就超时了。
5.1 查看当前锁等待
MySQL 8.0 推荐用 performance_schema.data_locks 和 data_lock_waits:
-- 查看当前所有 InnoDB 锁
SELECT
engine_lock_id, -- 锁 ID
engine_transaction_id,-- 持锁事务 ID
thread_id, -- 线程 ID
object_name, -- 表名
index_name, -- 索引名
lock_type, -- REC/X 等
lock_mode, -- X 、X,GAP 等
lock_data -- 锁的键值
FROM performance_schema.data_locks;
-- 查看当前锁等待关系:谁在等谁
SELECT
waiting_thread,
waiting_pid,
waiting_query,
blocking_pid,
blocking_query
FROM sys.innodb_lock_waits;
sys.innodb_lock_waits 是 MySQL 8.0 自带的系统视图,直接告诉你"谁堵了谁",比手算事务 ID 方便得多。
5.2 处理锁等待
找到阻塞源之后,一般两种处理方式:
-- 方式一:直接 KILL 阻塞源连接(紧急止血)
KILL <blocking_pid>;
-- 方式二:缩短锁等待超时时间,让等待方更快失败
SET GLOBAL innodb_lock_wait_timeout = 5;
生产环境建议把 innodb_lock_wait_timeout 从默认 50 秒降到 5~10 秒,避免一个慢 SQL 堵死一片业务线程。
5.3 长事务排查
锁等待的根源往往是长事务。查看当前所有活跃事务:
-- 查看运行超过 30 秒的事务
SELECT
trx_id,
trx_started,
trx_mysql_thread_id,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS run_seconds,
trx_query
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 30
ORDER BY run_seconds DESC;
发现长事务后,要追溯它为什么不提交:是应用代码忘了 commit,还是事务里夹了远程调用?
6 乐观锁与悲观锁
业务层处理并发有两种思路:悲观锁(Pessimistic Lock)认为"冲突一定会发生",先加锁再操作;乐观锁(Optimistic Lock)认为"冲突很少发生",提交时再校验。
6.1 悲观锁
就是上面讲的 SELECT ... FOR UPDATE,先把行锁住,再操作:
-- 悲观锁:先锁后改,避免并发覆盖
BEGIN;
SELECT stock FROM products WHERE id = 100 FOR UPDATE; -- 加行锁
-- 应用层判断 stock > 0 后扣减
UPDATE products SET stock = stock - 1 WHERE id = 100;
COMMIT;
适合并发冲突激烈的场景(秒杀扣库存)。缺点是锁持有期间其他事务都要等。
6.2 乐观锁
在表里加一个版本号字段,更新时校验:
-- 表结构:products 增加 version 字段
ALTER TABLE products ADD COLUMN version INT NOT NULL DEFAULT 0;
-- 读
SELECT stock, version FROM products WHERE id = 100;
-- 假设读到 version=3
-- 写:只在 version 没被别人改过的时候才更新
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 3;
-- 如果 affected_rows = 0,说明被别人抢了,应用层重试
| 对比维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 加锁时机 | 操作前 | 提交时 |
| 并发性能 | 低(排队) | 高(无锁) |
| 冲突率高时 | 表现好 | 重试多 |
| 冲突率低时 | 浪费 | 表现好 |
| 实现复杂度 | SQL 简单 | 应用要处理重试 |
互联网业务绝大多数读多写少、冲突率低,优先选乐观锁;只有秒杀、库存扣减这类强竞争场景才用悲观锁。
7 隔离级别与锁的关系
隔离级别决定了"事务之间能看到对方多少未提交的数据",也直接影响加锁策略。回顾第 14 章的四种隔离级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB 加锁策略 |
|---|---|---|---|---|
| READ UNCOMMITTED | ✅ | ✅ | ✅ | 几乎不加锁 |
| READ COMMITTED (RC) | ❌ | ✅ | ✅ | 记录锁,无间隙锁 |
| REPEATABLE READ (RR) | ❌ | ❌ | ❌(InnoDB) | 记录锁 + 间隙锁 |
| SERIALIZABLE | ❌ | ❌ | ❌ | 所有 SELECT 都加共享锁 |
7.1 RR 与 RC 的实际差异
很多互联网公司把 MySQL 从 RR 降到 RC,理由是:
- RC 没有间隙锁,并发插入不会被范围查询阻塞,死锁大幅减少;
- RC 的语句级一致性读足够业务用;
- RC 下 UPDATE/DELETE 的锁释放更早。
代价是失去 InnoDB 对幻读的自动防护,业务要自己处理"同一查询两次返回行数不同"的问题。
-- 查看当前会话隔离级别
SELECT @@transaction_isolation;
-- 设置会话级别为 RC
SET SESSION transaction_isolation = 'READ-COMMITTED';
7.2 SERIALIZABLE 的实际表现
SERIALIZABLE 会把所有普通 SELECT 隐式转成 LOCK IN SHARE MODE,意味着纯查询也会加共享锁,并发性能极差,生产几乎不用。
8 锁机制速查表
| 命令/视图 | 作用 |
|---|---|
| SELECT ... FOR UPDATE | 加排他行锁 |
| SELECT ... FOR SHARE | 加共享行锁(8.0) |
| SHOW ENGINE INNODB STATUS | 看最近一次死锁 |
| SHOW STATUS LIKE 'Innodb_row_lock%' | 行锁统计 |
| SELECT * FROM information_schema.INNODB_TRX | 看当前事务 |
| SELECT * FROM performance_schema.data_locks | 看当前所有锁 |
| SELECT * FROM sys.innodb_lock_waits | 看谁堵谁 |
| KILL <pid> | 杀掉阻塞源 |
| innodb_lock_wait_timeout | 锁等待超时(秒) |
| innodb_deadlock_detect | 是否开启死锁检测 |
常见锁模式速记:
| 锁模式 | 含义 |
|---|---|
| X | 排他记录锁 |
| X,GAP | 排他间隙锁 |
| X,REC_NOT_GAP | 纯记录锁,不加间隙 |
| S | 共享记录锁 |
| IS / IX | 意向共享 / 意向排他(表级) |
9 常见坑与注意事项
坑 1:UPDATE 没走索引,结果锁了全表。
这是最常见的事故。UPDATE orders SET ... WHERE status='PENDING',status 没索引 → InnoDB 对全表所有行加 X 锁,业务全线卡死。凡是写操作,WHERE 字段必须有索引。
坑 2:事务里夹了外部 RPC。
事务还没 commit,就调了一个耗时 3 秒的远程接口,这 3 秒里持有的行锁一直不释放,并发量稍高就全部排队。原则:事务里只做数据库操作,远程调用放到事务外。
坑 3:误以为 RR 一定比 RC 安全。
RR 的间隙锁在并发插入场景反而会引发大量死锁。业务允许时,RC + 应用层重试往往更高效。
坑 4:KILL 错了方向。
看到锁等待,新手容易 KILL 等待方。但等待方本来就什么都没干,KILL 它毫无意义;真正要 KILL 的是阻塞方(blocking_pid)。
坑 5:长事务导致 purge 滞后。
InnoDB 的 undo log 要等所有"可能还需要读它"的事务提交后才能清理。一个长事务会让 undo 表空间无限膨胀,磁盘被撑满。
坑 6:MDL 锁被遗忘。
执行 ALTER TABLE 时,如果前面有一个 sleep 状态的连接持有 MDL 读锁,DDL 会排在它后面,后续所有新读写也会排在 DDL 后面——整库看似"hang 死"。用 sys.schema_table_lock_waits 排查。
10 练习题与参考答案
练习 1:为什么 UPDATE orders SET status='PAID' WHERE status='PENDING' 在 status 没索引时会锁住全表?怎么验证?
<details>
<summary>参考答案</summary>
InnoDB 行锁必须加在索引上。当 WHERE 条件走不到索引,优化器只能全表扫描,扫描到的每一行都会被加 X 锁,效果等价于表锁。验证方法:在事务 A 里执行该 UPDATE 不提交,再开事务 B 去 UPDATE 任意一行,会发现 B 一直等待。
</details>
练习 2:悲观锁和乐观锁的本质区别是什么?商品库存扣减应该选哪种?
<details>
<summary>参考答案</summary>
悲观锁是"先加锁再操作"(SELECT ... FOR UPDATE),适合冲突率高的场景;乐观锁是"提交时校验版本号",适合读多写少。商品库存扣减在秒杀这种高并发冲突场景下,用悲观锁 + 行锁更稳;普通商品销量低,用乐观锁 + 重试即可。
</details>
练习 3:RR 隔离级别下,事务 A 执行 SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE,其他事务在这个区间插入新行为什么会被阻塞?
<details>
<summary>参考答案</summary>
因为 InnoDB 在 RR 下对范围查询加的是临键锁(Next-Key Lock)= 记录锁 + 间隙锁。临键锁覆盖 (10, 20] 这个左开右闭区间,其他事务在这个区间内插入新 id 会被间隙锁阻塞,目的就是防止幻读。
</details>
练习 4:线上出现大量锁等待,你会按什么顺序排查?
<details>
<summary>参考答案</summary>
1. SELECT * FROM sys.innodb_lock_waits 找到阻塞方 pid;
2. SELECT * FROM performance_schema.data_locks 看持的是什么锁、哪张表;
3. SELECT * FROM information_schema.INNODB_TRX 看阻塞事务已经运行多久、执行什么 SQL;
4. 紧急时 KILL 阻塞方;非紧急时联系业务方确认。
</details>
下一篇预告:第 21 章 InnoDB存储引擎原理
文章回复
0 条公开回复