MySQL 锁机制全景指南
全局锁作用于**整个数据库实例**,加锁后整个实例处于只读状态。 主要用于**全库备份**。在备份期间加全局读锁,确保备份期间数据不会变化,保证备份的一致性。 全局锁会让整个数据库只读,生产环境慎用。 现代备份通常使用 参数(基于 MVCC
一、锁的分类总览
MySQL 锁体系
├── 按粒度划分
│ ├── 全局锁(Global Lock)—— FLUSH TABLES WITH READ LOCK
│ ├── 表级锁(Table-level Lock)
│ │ ├── 表锁(Table Lock)—— LOCK TABLES
│ │ ├── 元数据锁(MDL, Metadata Lock)—— DDL/DML 自动获取
│ │ └── 意向锁(Intention Lock)—— IS / IX,InnoDB 自动管理
│ └── 行级锁(Row-level Lock)—— InnoDB 存储引擎
│ ├── 记录锁(Record Lock)—— 精确锁定某一行
│ ├── 间隙锁(Gap Lock)—— 锁定索引记录之间的间隙
│ └── 临键锁(Next-Key Lock)—— Record Lock + Gap Lock
│
├── 按锁的兼容性
│ ├── 读锁(共享锁,Shared Lock,S 锁)—— 多个事务可同时持有
│ └── 写锁(排他锁,Exclusive Lock,X 锁)—— 同一时刻只能有一个事务持有
│
├── 按存储引擎
│ ├── MyISAM —— 只支持表锁(读锁/写锁)
│ └── InnoDB —— 支持行锁、表锁,默认使用行锁
│
└── 按加锁时机
├── 悲观锁 —— 认为会发生冲突,先加锁再操作(InnoDB 默认行为)
└── 乐观锁 —— 认为不会冲突,更新时检查版本号/条件(应用层实现)
二、全局锁(Global Lock)
2.1 是什么
全局锁作用于整个数据库实例,加锁后整个实例处于只读状态。
-- 加全局读锁
FLUSH TABLES WITH READ LOCK;
-- 释放全局锁
UNLOCK TABLES;
2.2 用途
主要用于全库备份。在备份期间加全局读锁,确保备份期间数据不会变化,保证备份的一致性。
2.3 问题与替代方案
全局锁会让整个数据库只读,生产环境慎用。
现代备份通常使用 --single-transaction 参数(基于 MVCC),在 RR 隔离级别下,InnoDB 会为事务创建一个一致性读快照,无需加全局锁即可实现热备份。
mysqldump --single-transaction --master-data=2 -u root -p dbname > backup.sql
三、表级锁(Table-level Lock)
3.1 表锁(Table Lock)
MyISAM 引擎只支持表锁。InnoDB 也支持,但粒度太大,极少直接使用。
-- 加表读锁
LOCK TABLES table_name READ;
-- 加表写锁
LOCK TABLES table_name WRITE;
-- 释放
UNLOCK TABLES;
| 特性 | 表读锁(READ) | 表写锁(WRITE) |
|---|---|---|
| 当前会话读 | ✅ | ✅ |
| 当前会话写 | ❌ | ✅ |
| 其他会话读 | ✅ | ❌(阻塞) |
| 其他会话写 | ❌(阻塞) | ❌(阻塞) |
3.2 元数据锁(MDL, Metadata Lock)
不需要手动加,MySQL 自动管理。
当执行 DML 语句(SELECT、INSERT、UPDATE、DELETE)时,MySQL 自动给表加上 MDL 读锁(MDL_SHARED_READ)。
当执行 DDL 语句(ALTER TABLE、DROP TABLE、TRUNCATE、RENAME)时,MySQL 自动给表加上 MDL 写锁(MDL_EXCLUSIVE)。
核心作用:保证 DDL 和 DML 之间的并发安全。防止 DML 过程中表结构被修改。
常见坑:
-- 事务 A:执行了一个慢查询,持有 MDL 读锁
BEGIN;
SELECT * FROM big_table WHERE ...; -- 慢查询,运行很久
-- 此时事务 B 想执行 DDL
ALTER TABLE big_table ADD COLUMN new_col INT;
-- 事务 B 被阻塞!等待事务 A 释放 MDL 读锁
-- 更糟的是,后续所有访问 big_table 的查询都会被 DDL 阻塞
-- 因为 DDL 拿的是 MDL 写锁(排他),不释放的话,
-- 新的 DML 拿不到 MDL 读锁,全部堆积
-- 严重情况下导致连接数爆满,数据库不可用
排查方法:
-- MySQL 5.7+ 可通过 sys 库查看 MDL 等待
SELECT * FROM sys.schema_table_lock_waits;
-- 或通过 performance_schema
SELECT * FROM performance_schema.metadata_locks;
3.3 意向锁(Intention Lock)
InnoDB 内部自动使用,用户无需手动干预。这是面试高频考点。
为什么需要意向锁?
没有意向锁时,如果事务 A 想对整张表加表级 X 锁(排他锁),它必须逐行检查是否有其他事务持有行级锁,效率极低。
意向锁的作用是提前声明意图:
- 意向共享锁(IS):事务打算在表的某些行上加 S 锁
- 意向排他锁(IX):事务打算在表的某些行上加 X 锁
加行锁之前,InnoDB 会先在表级别加上对应的意向锁。这样当有事务想加表锁时,只需检查表上的意向锁,不需要逐行判断。
意向锁的兼容性矩阵
| IS | IX | S(表共享锁) | X(表排他锁) | |
|---|---|---|---|---|
| IS | ✅ | ✅ | ✅ | ❌ |
| IX | ✅ | ✅ | ❌ | ❌ |
| S | ✅ | ❌ | ✅ | ❌ |
| X | ❌ | ❌ | ❌ | ❌ |
解读:
- IS 和 IS/IX 兼容 → 多个事务可以同时在同一张表的不同行上加锁
- IX 和 S 不兼容 → 如果有事务想加行级 X 锁(IX),就不能再加表级 S 锁
- X 锁和任何锁都不兼容 → 表级排他锁是互斥的
简单记忆:意向锁之间互相兼容,意向锁和行锁不冲突,但意向锁和表级锁冲突。
四、行级锁(Row-level Lock)—— InnoDB 核心
InnoDB 的行锁是加在索引上的,不是加在数据行上的。这是理解 InnoDB 锁机制的核心前提。
没有索引的地方加行锁,会退化为表锁。 因为全表扫描时,每一行都会被加锁,最终效果和锁整张表一样。
4.1 记录锁(Record Lock)
最基本的行锁。精确锁定某一条索引记录。
-- 假设 id 是主键
SELECT * FROM user WHERE id = 1 FOR UPDATE;
这条语句只对 id = 1 这条记录加 X 锁(排他锁)。
InnoDB 的 FOR UPDATE 和 LOCK IN SHARE MODE 必须在事务中才有意义。事务提交或回滚后锁自动释放。
4.2 间隙锁(Gap Lock)
锁定索引记录之间的间隙,不包括记录本身。
-- user 表中 id 值有: 1, 5, 10, 20
SELECT * FROM user WHERE id = 7 FOR UPDATE;
id = 7 不存在。InnoDB 会在 (5, 10) 这个区间加 Gap Lock,防止其他事务在这个区间内插入新记录。
Gap Lock 的作用:防止幻读(Phantom Read)。
没有 Gap Lock 的话:
-- 事务 A
BEGIN;
SELECT * FROM user WHERE id BETWEEN 5 AND 10 FOR UPDATE;
-- 查到了 id=5 和 id=10 两条记录
-- 事务 B(如果没有 Gap Lock)
INSERT INTO user (id, name) VALUES (7, 'new');
COMMIT;
-- 事务 A 再次查询
SELECT * FROM user WHERE id BETWEEN 5 AND 10 FOR UPDATE;
-- 多了一条 id=7 的记录!这就是幻读
有了 Gap Lock,事务 A 执行第一次查询时,不仅锁住了 id=5 和 id=10 两条记录,还锁住了 (5, 10) 这个间隙,事务 B 的 INSERT 会被阻塞。
4.3 临键锁(Next-Key Lock)
Record Lock + Gap Lock 的组合。锁定一个左开右闭区间。
-- user 表中 id 值有: 1, 5, 10, 20
SELECT * FROM user WHERE id = 5 FOR UPDATE;
此时加的是 Next-Key Lock,锁定的范围是 (1, 5] —— 包括 id=5 这条记录本身(Record Lock)和它前面的间隙 (1, 5)(Gap Lock)。
实际锁定的区间(B+树索引结构):
间隙: (-∞, 1] (1, 5] (5, 10] (10, 20] (20, +∞)
↑ 锁定这个区间(包括 5)
4.4 锁范围总结
| 锁类型 | 锁定对象 | 作用 |
|---|---|---|
| Record Lock | 某一条索引记录 | 防止并发修改同一行 |
| Gap Lock | 索引记录之间的间隙 | 防止其他事务插入新记录(防幻读) |
| Next-Key Lock | 记录 + 间隙(左开右闭区间) | Record + Gap 的组合 |
五、不同 SQL 语句的加锁分析(核心考点)
这是面试中最爱问的部分。以下分析基于 RR(可重复读)隔离级别,这是 MySQL 的默认隔离级别。
5.1 主键等值查询
-- id 是主键,id=5 存在
SELECT * FROM t WHERE id = 5 FOR UPDATE;
加锁:id=5 的记录加 Record Lock(X 锁)。
如果主键索引上 id=5 存在,Next-Key Lock 会退化为 Record Lock。
5.2 主键等值查询(记录不存在)
-- id 是主键,id=7 不存在,表中 id 值有 1, 5, 10
SELECT * FROM t WHERE id = 7 FOR UPDATE;
加锁:(5, 10) 区间加 Gap Lock。
因为记录不存在,不需要锁记录,只需要锁间隙。
5.3 主键范围查询
-- id 是主键,表中 id 值有 1, 5, 10, 20, 30
SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE;
加锁:
[10, 20]范围内的记录加 Record Lock(X 锁)(5, 10]加 Next-Key Lock(10, 20]加 Next-Key Lock(20, 30)加 Gap Lock
总共锁定的区间:(5, 30)。
为什么锁到 30?因为 Next-Key Lock 是左开右闭区间,锁到 id=20 时会连带锁住 (20, 30) 的间隙,防止在 20 和 30 之间插入新记录。
5.4 唯一索引等值查询
-- code 是唯一索引,code='ABC' 存在
SELECT * FROM t WHERE code = 'ABC' FOR UPDATE;
加锁:code='ABC' 的唯一索引记录 + 对应的主键索引记录,各加 Record Lock。
InnoDB 中,非唯一索引上锁时,会同时锁二级索引和主键索引。因为二级索引的叶子节点存的是主键值,需要回表。
5.5 普通索引等值查询
-- name 是普通索引(非唯一),name='Tom' 存在,有多条
SELECT * FROM t WHERE name = 'Tom' FOR UPDATE;
加锁:
name='Tom'的所有记录加 Record Lockname='Tom'的前后间隙加 Gap Lock(Next-Key Lock)
因为普通索引不唯一,InnoDB 不知道扫描到哪里结束,必须加 Gap Lock 防止插入新的 'Tom'。
5.6 普通索引范围查询
-- name 是普通索引
SELECT * FROM t WHERE name BETWEEN 'A' AND 'C' FOR UPDATE;
加锁:范围查询在 RR 级别下会锁定所有扫描到的索引记录及其间隙,包括扫描到但不满足 WHERE 条件的记录。
这是 InnoDB 的一个重要特性:锁是基于扫描路径的,不是基于 WHERE 条件的。如果查询没有走索引,会锁住整张表。
5.7 无索引查询
-- age 没有索引
SELECT * FROM t WHERE age = 20 FOR UPDATE;
加锁:全表扫描,每一行都会被加锁。退化为表锁。
同时会对所有间隙加 Gap Lock,其他事务无法插入任何新记录。
5.8 总结表
| SQL 类型 | 索引类型 | 记录存在? | 加锁范围 |
|---|---|---|---|
| 主键等值 | 主键 | 存在 | Record Lock(退化为纯记录锁) |
| 主键等值 | 主键 | 不存在 | Gap Lock(间隙锁) |
| 主键范围 | 主键 | — | Next-Key Lock(范围+间隙) |
| 唯一索引等值 | 唯一索引 | 存在 | Record Lock(二级索引+主键索引) |
| 普通索引等值 | 普通索引 | 存在 | Next-Key Lock(记录+前后间隙) |
| 普通索引范围 | 普通索引 | — | Next-Key Lock(扫描路径全部锁定) |
| 无索引查询 | 无索引 | — | 全表锁(退化为表锁) |
六、隔离级别对锁的影响
MySQL 的 InnoDB 支持四种隔离级别,默认是 REPEATABLE READ(RR)。
6.1 读未提交(READ UNCOMMITTED)
不加读锁(除非显式使用 FOR UPDATE),存在脏读。几乎不在生产环境使用。
6.2 读已提交(READ COMMITTED, RC)
RC 级别下 不使用 Gap Lock 和 Next-Key Lock,只有 Record Lock。
-- RC 级别
SELECT * FROM t WHERE id = 5 FOR UPDATE;
-- 只对 id=5 的记录加 Record Lock,不锁间隙
RC 级别下的问题:
- 无法防止幻读 —— 没有 Gap Lock,其他事务可以在间隙中插入新记录
- 范围查询锁定范围更小 —— 只锁匹配的记录,不锁间隙
- binlog 格式限制 —— RC 级别下 binlog 只能用 ROW 格式,因为 STATEMENT 格式在 RC 下主从复制会产生数据不一致
适用场景:对并发要求高、可以接受幻读的场景。很多互联网公司(如阿里)使用 RC + ROW 格式 binlog 的组合。
6.3 可重复读(REPEATABLE READ, RR)
默认级别。使用 Next-Key Lock(Record + Gap)防止幻读。
RR + Next-Key Lock 是 InnoDB 防止幻读的核心机制。结合 MVCC(多版本并发控制),RR 级别下的普通 SELECT(快照读)完全不会出现幻读。
6.4 串行化(SERIALIZABLE)
最高的隔离级别。所有读操作自动加锁(隐式 LOCK IN SHARE MODE),读写都互斥,并发度最低。生产环境极少使用。
七、快照读 vs 当前读
理解 InnoDB 锁机制的另一个核心前提:读分为两种。
7.1 快照读(Snapshot Read)
读取的是数据的历史版本,不加锁。基于 MVCC 实现。
-- 以下都是快照读
SELECT * FROM t WHERE ...;
SELECT * FROM t WHERE id = 1;
普通 SELECT 不加任何锁,通过 undo log 中的版本链读取事务开始时的快照。
7.2 当前读(Current Read)
读取的是数据的最新版本,会加锁。
-- 以下都是当前读
SELECT * FROM t WHERE id = 1 FOR UPDATE; -- 加 X 锁
SELECT * FROM t WHERE id = 1 LOCK IN SHARE MODE; -- 加 S 锁
INSERT INTO t ...;
UPDATE t SET ...;
DELETE FROM t WHERE ...;
当前读需要获取最新数据,所以必须加锁保证并发安全。
7.3 MVCC 与锁的关系
flowchart TD
A[SQL 查询] --> B{查询类型?}
B -->|普通 SELECT| C[快照读]
B -->|SELECT ... FOR UPDATE<br/>SELECT ... LOCK IN SHARE MODE<br/>INSERT/UPDATE/DELETE| D[当前读]
C --> E[通过 undo log 版本链<br/>读取事务开始时的快照]
E --> F[不加锁, 高并发]
D --> G[读取最新版本]
G --> H[加锁: S 锁或 X 锁]
H --> I[RR 级别: Next-Key Lock<br/>RC 级别: Record Lock]
style C fill:#99ff99
style D fill:#ffcc66
style H fill:#ff9999
MVCC(多版本并发控制) 通过 undo log 的版本链 + Read View 实现:
- 每行数据有两个隐藏字段:
trx_id(最后修改的事务 ID)和roll_pointer(指向上一个版本的指针) - undo log 形成了版本链,从最新到最旧
- Read View 决定了一个事务能看到哪个版本的数据
RR 级别:事务第一次 SELECT 时创建 Read View,后续 SELECT 复用同一个 Read View → 保证了可重复读。
RC 级别:每次 SELECT 都创建新的 Read View → 每次读到其他事务已提交的数据。
八、死锁(Deadlock)
8.1 什么是死锁
两个或多个事务互相等待对方释放锁,形成环形依赖,谁都执行不下去。
-- 事务 A
BEGIN;
UPDATE t SET a = 1 WHERE id = 1; -- 获取 id=1 的 X 锁
UPDATE t SET a = 2 WHERE id = 2; -- 等待 id=2 的 X 锁(被事务 B 持有)
-- 事务 B(同时执行)
BEGIN;
UPDATE t SET a = 3 WHERE id = 2; -- 获取 id=2 的 X 锁
UPDATE t SET a = 4 WHERE id = 1; -- 等待 id=1 的 X 锁(被事务 A 持有)
-- 死锁!
-- InnoDB 检测到死锁后,会回滚其中一个事务(undo log 量小的那个)
8.2 死锁检测
InnoDB 默认开启死锁检测:
SHOW VARIABLES LIKE 'innodb_deadlock_detect'; -- ON
死锁检测的算法是 wait-for graph(等待图),用 DFS 检测环。复杂度 O(N),N 是等待锁的事务数。
问题:高并发场景下,死锁检测本身会成为性能瓶颈。大量事务互相等待,DFS 检测环的开销巨大。
8.3 解决方案
-- 方案一:关闭死锁检测,依赖锁超时
SET GLOBAL innodb_deadlock_detect = OFF;
SET GLOBAL innodb_lock_wait_timeout = 50; -- 默认 50 秒
-- 方案二:调整隔离级别为 RC(减少 Gap Lock,降低死锁概率)
-- 方案三:业务层保证加锁顺序一致
-- 比如所有事务都按 id 从小到大的顺序加锁,就不会出现循环等待
8.4 查看死锁
-- 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS\G
-- 在输出中找 "LATEST DETECTED DEADLOCK" 部分
九、锁监控与排查
9.1 查看当前锁等待
-- MySQL 5.7+
SELECT * FROM information_schema.innodb_lock_waits;
-- 查看事务信息
SELECT * FROM information_schema.innodb_trx;
-- 查看锁信息
SELECT * FROM information_schema.innodb_locks;
9.2 性能模式
-- MySQL 5.7+ 通过 sys 库
SELECT * FROM sys.innodb_lock_waits;
-- 更详细的信息
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
9.3 常见排查流程
flowchart TD
A[发现查询慢/超时] --> B{检查 innodb_trx}
B --> C[找到长时间未提交的事务]
C --> D{是否在等锁?}
D -->|是| E[查看 innodb_lock_waits<br/>找到锁阻塞链]
D -->|否| F[检查是否是慢查询<br/>分析执行计划 EXPLAIN]
E --> G[找到持有锁的事务]
G --> H{该事务是否异常?}
H -->|是| I[KILL 异常事务]
H -->|否| J[等待事务完成<br/>或优化业务逻辑]
十、特殊场景的锁行为
10.1 INSERT ... ON DUPLICATE KEY UPDATE
先尝试 INSERT,如果遇到唯一键冲突,转为 UPDATE。加锁过程:
- 尝试在插入位置加 Gap Lock(防止其他事务插入)
- 如果唯一键冲突,在已有记录上加 X 锁
- 执行 UPDATE
10.2 INSERT 的锁
INSERT 语句对插入的记录加 隐式排他锁。
同时会在插入位置的间隙加 Gap Lock,防止其他事务插入到相同位置导致主键/唯一键冲突。
10.3 外键约束的锁
如果表有外键约束,更新子表时会隐式地对父表对应的记录加 S 锁(检查外键约束需要读取父表数据)。
10.4 自增锁(AUTO-INC Lock)
INSERT 涉及 AUTO_INCREMENT 列时,InnoDB 会给表的自增列加一个特殊的表级锁(AUTO-INC Lock),直到语句执行完才释放。
-- MySQL 8.0 之前:语句级锁
INSERT INTO t (name) VALUES ('a'); -- 整个语句持有 AUTO-INC Lock
INSERT INTO t (name) VALUES ('b'); -- 阻塞,等待 AUTO-INC Lock
-- MySQL 8.0+:支持 interleaved 模式
-- 通过 innodb_autoinc_lock_mode=2,INSERT 不需要持有到语句结束
-- 并发性能大幅提升
MySQL 8.0 默认 innodb_autoinc_lock_mode = 2(interleaved),INSERT 不需要等语句结束才释放自增锁,并发性能大幅提升。
十一、常见面试追问
Q1:InnoDB 的行锁是加在什么上面的?
加在索引上,不是加在数据行上。如果查询没有走索引(全表扫描),行锁会退化为表锁,锁住每一行。
Q2:Gap Lock 的作用是什么?RC 级别下有吗?
防止幻读。锁定索引记录之间的间隙,阻止其他事务在间隙中插入新记录。RC 级别下没有 Gap Lock,只有 Record Lock,所以 RC 级别无法防止幻读。
Q3:Next-Key Lock 锁定的区间是什么样的?
左开右闭区间 (a, b]。包括索引值 b 这条记录本身(Record Lock)和它前面的间隙 (a, b)(Gap Lock)。
Q4:什么情况下会产生死锁?怎么预防?
两个或多个事务互相等待对方持有的锁,形成环形依赖。
预防方案:
- 统一加锁顺序(所有事务按相同顺序获取锁)
- 一次性获取所有需要的锁(降低锁粒度、缩短事务)
- RC 隔离级别减少 Gap Lock
- 关闭死锁检测 + 设置合理的锁超时
Q5:为什么 update 没走索引会锁表?
InnoDB 行锁加在索引上。update 不走索引 → 全表扫描 → 每一行都被扫描并加锁 → 效果等同于锁表。
Q6:MDL 锁是什么?生产环境遇到过相关问题吗?
元数据锁,DDL 操作时自动获取的排他锁。生产常见问题:慢查询持有 MDL 读锁 → DDL 等待 MDL → 后续所有 DML 被 DDL 阻塞 → 连接堆积爆满。
Q7:InnoDB 的意向锁有什么用?
简化表锁和行锁的兼容性检查。没有意向锁,加表锁需要逐行检查是否有行锁;有意向锁后,只需检查表级的意向锁即可判断。
十二、速查表
| 锁类型 | 粒度 | 引擎 | 手动/自动 | 说明 |
|---|---|---|---|---|
| 全局锁 | 实例级 | 所有 | 手动 | 全库只读,备份用 |
| MDL | 表级 | 所有 | 自动 | DDL/DML 自动获取 |
| 表锁 | 表级 | 所有 | 手动 | LOCK TABLES |
| 意向锁(IS/IX) | 表级 | InnoDB | 自动 | 声明行锁意图 |
| 记录锁(Record) | 行级 | InnoDB | 自动 | 锁精确的索引记录 |
| 间隙锁(Gap) | 间隙 | InnoDB | 自动 | 锁索引之间的间隙 |
| 临键锁(Next-Key) | 行+间隙 | InnoDB | 自动 | Record + Gap |
| 自增锁(AUTO-INC) | 表级 | InnoDB | 自动 | INSERT 自增列 |
RR vs RC 锁行为对比
| 维度 | RR(默认) | RC |
|---|---|---|
| Gap Lock | ✅ 有 | ❌ 无 |
| Next-Key Lock | ✅ 有 | ❌ 无 |
| Record Lock | ✅ 有 | ✅ 有 |
| 幻读 | 防止 | 不防止 |
| 并发度 | 较低 | 较高 |
| 死锁概率 | 较高(Gap Lock 导致) | 较低 |