跳到正文
Kale的数字花园
目录 文章
/13 分钟/8 阅读/0 评论

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 Lock
  • name='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。加锁过程:

  1. 尝试在插入位置加 Gap Lock(防止其他事务插入)
  2. 如果唯一键冲突,在已有记录上加 X 锁
  3. 执行 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 导致) 较低
相关阅读

22 分钟

Java 锁机制全景指南

要理解锁,必须先理解 JMM(Java Memory Model)。锁的本质是解决多线程之间的**可见性**和**有序性**问题。 每个线程有自己的工作内存(CPU 缓存 + 寄存器),变量存储在主内存中。线程操作变量时,先从主内存拷贝到工

面试#lock13 阅读 · 0 评论

评论需要登录。生产环境使用 GitHub 登录,本地可前往 登录页 使用开发登录。

还没有评论,来抢沙发。