MySQL 锁机制与事务隔离
从事务四个特性出发,说明并发读取问题、隔离级别,以及表锁、行锁、间隙锁和临键锁在 InnoDB 中的作用。
MySQL 锁机制与事务隔离
很多锁名词看起来复杂,其实都在解决同一件事:并发下怎么保证数据可靠。
我们要知道,锁不是为了增加复杂度,而是为了在并发环境里守住一致性。
为了能更好的理解,文章我们从上到下一点点过渡,先从最常用的事务开始,事务有什么基本特性,理解事务的目标,如果在并发情况下,会出现什么问题,再来看这些问题是怎么用隔离级别解决的,而隔离级别又是怎么和锁机制是怎么一步步实现这些目标的。
术语说明
这里事先说明一下,本文中可能出现的术语,先有个概念,后文在提到的时候还会再解释的 ✌️
- 事务四个特性:简称 ACID, 分别是,Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)、Durability(持久性)
- 并发读取问题:Dirty Read(脏读)、Non-repeatable Read(不可重复读)、Phantom Read(幻读)
- 隔离级别:Read Uncommitted(读未提交,简写: RU)、Read Committed(读已提交,简写: RC)、Repeatable Read(可重复读,简写: RR)、Serializable(串行化)
- 多版本并发控制:Multi-Version Concurrency Control (简称 MVCC)
事务和四个基本特性
事务很好理解,我们简单带过一下:指数据库中一个操作,由多个步骤组成,要么全部都成功,要么全部都失败。
事务有四个特性,合在一起我们简称 ACID:
- 原子性(Atomicity):事务要么全部成功,要么全部回滚。 例:扣款成功、加款失败时,系统会撤销已执行操作。
- 一致性(Consistency):事务前后都要满足业务约束。 例:转账前后两个账户总额不变。
- 隔离性(Isolation):并发事务之间互不干扰。 例:两个事务同时修改同一行,不会互相覆盖到错误状态。
- 持久性(Durability):提交成功后结果必须保留。
例:
COMMIT后即使数据库重启,数据依然存在。
前面的文字概念听起来有点干燥,下面我们举一个最常见的转账事务,分别说明一下四个特性:
1BEGIN;2UPDATE account SET balance = balance - 100 WHERE id = 1;3UPDATE account SET balance = balance + 100 WHERE id = 2;4COMMIT;并发读取中的三个问题
经过上面转账的例子,应该知道事务的主要目标是保证数据可靠,那么在并发环境下,事务之间就可能会互相干扰,导致数据不可靠。比如上面演示的转账例子中,提到了隔离性的时候,会出现脏读问题,当然这只是其中一个,实际这些问题总结起来主要有三个:脏读、不可重复读、幻读。
脏读
脏读(Dirty Read)指的是在一个事务处理过程里读取了另一个未提交的事务中的数据。
在这个例子里,T1 事务更新了 account 表 id 为 1 的余额为 500,但还没有提交事务。此时 T2 事务查询这个余额,读到了 500 的值。如果 T1 最后提交了事务,那么 T2 读到的 500 就是正确的;但如果 T1 回滚了事务,那么 T2 读到的 500 就是脏数据了,因为实际值恢复成了 1000。
不可重复读
不可重复读(Non-repeatable Read)指的是一个事务两次读取同一行的数据,中间正好另一个事务更新了该数据,两次得到不同状态的结果,这个现象被称“不可重复读”。
因为可以读到已经提交事务的数据,所以这个现象常出现在 读已提交 隔离级别中。
下面这个例子中,T1 事务第一次查询 id 为 10 的订单数量是 100。此时 T2 事务更新了这条记录的数量为 120,并提交了事务。T1 再次查询 id 为 10 的订单数量时,结果变成了 120,这就是不可重复读。
幻读
幻读(Phantom Read)指的是同一事务中两次范围查询,结果集行数发生变化。稍微细化一下就是,一个事务执行两次查询,第二次结果集包含第一次结果集中没有或某些行已经被删除的数据,造成两次结果不一致,这是另一个事务在这两次查询中间插入或删除了数据造成的。
下面这个例子中,T1 事务第一次查询 amount 大于 100 的订单数量是 2 条。此时 T2 事务插入了一条 amount 为 180 的订单,并提交了事务。T1 再次查询 amount 大于 100 的订单数量时,结果变成了 3 条了,就跟发生幻觉一样,这就是幻读。
简单总结就是,脏读是读到未提交值,不可重复读是同一行数据前后不一致,幻读是同一范围数量前后不一致。
隔离级别
既然会出现上面说的这些并发读取问题,那么数据库系统就需要一种机制来控制并发事务之间的干扰程度,这就是隔离级别的作用。
MySQL 提供了四个隔离级别,分别如下:
- 读未提交(Read Uncommitted,RU)
- 读已提交(Read Committed,RC)
- 可重复读(Repeatable Read,RR)
- 串行化(Serializable)
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| 读未提交 | 可能发生 | 可能发生 | 可能发生 | 并发高,一致性最低 |
| 读已提交 | 避免 | 可能发生 | 可能发生 | 常用于强调吞吐的场景 |
| 可重复读 | 避免 | 避免 | 通常可避免 | InnoDB 默认级别 |
| 串行化 | 避免 | 避免 | 避免 | 一致性最强,并发最低 |
理解了前面的几个问题,就好理解每个隔离级别了:
- 读未提交:控制最低,但读取速度快,但会出现脏读、幻读、不可重复读这些问题。
- 读已提交:不再读到未提交数据,解决了脏读问题,但重复读取不稳定,同样幻读问题依旧存在。
- 可重复读:同一事务里读取稳定,并配合间隙控制范围插入,幻读问题基本不会发生(理论上会发生,概率极小)。
- 串行化:并发降到更低,串行执行,换取更强一致性。
隔离级别的实现
MySQL 的隔离级别,主要是两套机制协同处理的:MVCC(多版本并发控制) 和 锁机制。
- MVCC 负责普通查询,不加锁”的读(快照读),解决读-写冲突。
- 锁机制负责“加锁”的读(当前读)和写操作,解决写-写冲突。
Tip
MVCC 介绍可查看这篇文章快速回顾, 这里不再展开细讲,看完后对照着 MVCC 的内容,再看这部分内容会有更好的理解。
1-- 普通查询,多数情况下是快照读取(走 MVCC 机制,不加锁)2SELECT * FROM orders WHERE id = 10;3 4-- 当前读,通常会加锁5SELECT * FROM orders WHERE id = 10 FOR UPDATE;6UPDATE orders SET amount = amount + 1 WHERE id = 10;既然隔离级别都是用这两套机制,也就是说各个隔离级别在 MVCC 和锁机制这两者之间寻找平衡,不同的隔离级别是用 MVCC 的时机和搭配哪种锁机制都是不一样的。
1. 读未提交
- 快照读:不使用 MVCC,直接读取内存中最新的物理记录,即使该数据尚未被其他事务提交,典型的问题就是脏读。
- 写操作 / 当前读:仅对修改的记录加 记录锁 锁住当前行,直到事务结束释放。
- 影响:存在脏读、不可重复读和幻读。
2. 读已提交
- 快照读:使用 MVCC,每次执行 SELECT 时都会重新生成 Read View 快照,保证只能读取到已提交的事务数据,解决了脏读。
- 写操作 / 当前读:加 记录锁 锁住当前行,但不使用间隙锁,因此无法阻止其他事务在记录之间插入数据,也就是无法避免不可重复读。
- 影响:解决了脏读,但仍存在不可重复读和幻读。
3. 可重复读
- 快照读:使用 MVCC,仅在事务第一次执行 SELECT 时生成 Read View 快照,后续查询复用该视图,确保事务内多次读取结果一致,解决了不可重复读。
- 写操作 / 当前读:使用 临键锁。
- 由记录锁与间隙锁组成。
- 锁住目标记录的同时,锁住记录前后的间隙,防止其他事务插入新数据。
- 影响:解决了不可重复读,并在很大程度上规避了幻读。
4. 串行化
- 快照读:不使用 MVCC
- 读写操作:
- 所有的
SELECT会隐式转换为SELECT ... LOCK IN SHARE MODE(加共享锁)。 - 读写操作相互排斥,事务串行化执行。
- 所有的
- 影响:完全解决并发问题,但系统吞吐量最低。
锁机制的整体结构
前面说隔离级别的时候已经提到了一些锁,接下来我们就详细说说到底有哪些锁,可能会有点多,但慢慢看下来基本都还好理解
锁按粒度可以分成全局锁、表级锁、行级锁,日常业务里最关键的是行级锁,但全局锁和表级锁在备份、变更结构、线上故障排查时也很重要。
后面还会出现一些缩写,但基本都是数据库基础知识,这里再贴一下可能涉及到的术语说明
| 缩写 | 全称 | 作用 | 常用命令 |
|---|---|---|---|
| DQL | Data Query Language | 查询数据 | SELECT |
| DML | Data Manipulation Language | 操作数据内容 | INSERT, UPDATE, DELETE |
| DDL | Data Definition Language | 定义数据库结构 | CREATE, ALTER, DROP, TRUNCATE |
| DCL | Data Control Language | 控制访问权限 | GRANT, REVOKE |
| TCL | Transaction Control Language | 事务管理控制 | COMMIT, ROLLBACK, SAVEPOINT |
Tip
注:有时会将查询数据的 SELECT 分类为 DQL,但通常也归纳在 DML 中。
锁概览
先看下方里的概览标签,可以建立完整框架认知:
MySQL InnoDB 锁机制
InnoDB 通过多层次锁机制保证并发一致性,可从粒度、语义、来源三个维度理解。
默认隔离级别 Repeatable Read,也就是可重复读,行级锁基于索引实现——无索引将退化为全表锁。
LOCK TABLES t READ / WRITESELECT … FOR UPDATESELECT … FOR SHAREMDL:DDL/DML 自动申请
意向锁 / AUTO-INC 自动维护
| 已持有 ╲ 申请 | S 行共享 | X 行排他 | IS 意向共享 | IX 意向排他 | Gap 间隙 | Next-Key 临键 | Insert Int. 插入意向 |
|---|---|---|---|---|---|---|---|
| S(行共享) | ✓ | ✗ | ✓ | ✗ | ✓ | ✗ | ✓ |
| X(行排他) | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
| IS(意向共享) | ✓ | ✗ | ✓ | ✓ | ✓ | ✓ | ✓ |
| IX(意向排他) | ✗ | ✗ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Gap(间隙锁) | ✓ | ✗ | ✓ | ✓ | ✓ | ✓ | ✗ |
S 锁之间兼容;
X 锁与任何锁都不兼容;
意向锁之间全部兼容;
间隙锁之间兼容(允许共存),但都阻止插入意向锁。
CREATE TABLE t ( id INT AUTO_INCREMENT PRIMARY KEY, c1 INT, c2 INT, c3 INT ); CREATE UNIQUE INDEX idx_t_c1 ON t(c1); CREATE INDEX idx_t_c2 ON t(c2);
| id(主键) | c1(唯一索引) | c2(普通索引) | c3(无索引) |
|---|---|---|---|
| 1 | 1 | 1 | 1 |
| 2 | 2 | 3 | 4 |
| 3 | 3 | 6 | 9 |
CREATE TABLE t2 ( id INT PRIMARY KEY, c INT, d INT ); CREATE INDEX idx_t2_c ON t2(c);
| id(主键) | c(普通索引) | d(无索引) |
|---|---|---|
| 5 | 5 | 5 |
| 10 | 10 | 10 |
| 15 | 15 | 15 |
| 20 | 20 | 20 |
| 25 | 25 | 25 |
全局锁和表级锁
这部分主要介绍:全局锁、表级锁(表锁、元数据锁、意向锁、AUTO-INC 锁)
全局锁
全局锁作用于整个实例,常用于特定备份流程,比如数据库整体备份。
表锁
表锁加锁速度快,但并发能力弱,基本不用,因为 InnoDB 有后面那些更精细的行锁。
元数据锁 MDL
元数据锁(MDL)用于保护表结构与数据操作的一致性,为了防止你在查询数据时,别人偷偷把表结构给改了。 MDL 锁一般是自动加的,不需要我们手动干预,主要规则如下:
- 对表做 增删改查 (DML) 时,加 MDL 读锁。
- 对表做 结构变更 (DDL) 时,加 MDL 写锁。
- MDL 读锁和写锁之间互斥,写锁会等待读锁释放,读锁会等待写锁释放。
意向锁
除了 S 锁(共享锁)和 X 锁(排他锁)之外,InnoDB 还有两种锁,就是 IS 锁和 IX 锁,S 和 X 前面的 I 是 Intention 的意思,也就是“意向”的意思,所以 IS 就是意向共享锁,IX 就是意向排他锁。
意向锁用于协调行锁和表锁,比如说,如果有人想给整张表加“表锁”,他不需要一行行检查有没有行锁,直接看表上有没有 IS/IX 锁标记就行了。另一方面,如果一个事物想给某行加锁,它会先在表上加一个意向锁,相当于已经贴上“厕所有人”标志了,就是告诉其他事务“嘿,我要锁这行了”,这样如果有事务想给整张表加锁,就知道不能加了,因为已经有行锁了。
所以,意向锁的目的是为了快速判断表里是否有记录被加锁。
AUTO-INC 锁
AUTO-INC 锁是 InnoDB 在处理 AUTO_INCREMENT 列时使用的一种特殊锁,确保在高并发插入时,AUTO_INCREMENT 列的值能够正确递增,避免重复值的出现。
全局锁和表级锁图示
可以在下方标签里查看全局锁和表级锁的行为:
InnoDB 推荐使用
--single-transaction 替代。-- 会话 1:加全局读锁 FLUSH TABLES WITH READ LOCK; -- 会话 2:写操作被阻塞 INSERT INTO t VALUES(4, 4, 4, 4); UPDATE t SET c1 = 10; -- 会话 1:释放 UNLOCK TABLES;
mysqldump --single-transaction,不影响在线写入。行级锁
这部分主要介绍: 记录锁、间隙锁、临键锁
这三类都是行级锁,前面提到表所得时候,说 InnoDB 有后面那些更精细的行锁,说的其实就是这里,接下来分别说说。
记录锁
记录锁很直观的理解,锁住当前一行记录,但要注意,记录锁是锁的是索引记录。当你通过主键或唯一索引找一个确定的值,比如 SELECT * FROM t WHERE id = 1 FOR UPDATE;,只要这条数据在,InnoDB 就会把它锁死,事务没完,谁也别想改它或删它。
Warning
记录锁 (Record Lock) 锁的不是这行数据记录,而是锁索引记录,只锁索引!如果没有索引,InnoDB 会创建一个隐藏的聚簇索引,并使用这个索引进行记录锁定。
如果我们在一张表中没有定义主键,MySQL 会默认选择一个唯一的非空索引作为聚簇索引。 如果没有适合的非空唯一索引,则会创建一个隐藏的主键 (row_id) 作为聚簇索引, 这块儿可以看看 MySQL 索引文章
间隙锁
间隙锁不锁具体的行,它锁的是数据之间的“空位”,也就是索引记录之间的间隙。
间隙锁的目的就是防止幻读,即防止别人在你的查询范围内插新数据、删数据。
比如表中只有 id=1 和 id=5,执行 WHERE id > 1 AND id < 5 FOR UPDATE 时,系统会在 $(1, 5)$ 这个开区间拉起警戒线。这时候想插入 id=3 的操作会被直接挂起。
注意: 间隙锁之间是不互斥的。两个事务可以同时锁住同一个间隙,因为大家的目标一致——谁也别想往这儿塞新数据。
临键锁
这是 InnoDB 的默认使用,可以理解为 记录锁 + 间隙锁。
它锁住的是一个左开右闭的区间,比如 (5, 10],它既管住了 5 到 10 之间的空隙,也把 10 这一行给锁了。
不过它有个实用的优化逻辑:
- 命中唯一索引且记录存在: 会“降级”成记录锁,毕竟目标明确,没必要扩大锁定范围。
- 记录不存在: 会降级为间隙锁,专门用来防范那个特定位置的插入操作。
行级锁图示
下面可以在查看各个行级锁的说明:
WHERE id=1 FOR UPDATE 锁定主键 id=1 的索引记录。如果没有索引,InnoDB 会使用隐藏的聚簇索引,导致全表扫描退化为全表锁。
- 记录锁锁的是索引记录,而非数据行本身。
- 使用主键或唯一索引等值查询时,Next-Key Lock 退化为纯记录锁。
- 分为 S 共享 和 X 排他 两种。
注意:Gap Lock 之间完全兼容!多事务可同时持有同一间隙的 Gap Lock。
- 锁的是间隙,不锁记录本身。
- Gap Lock 之间兼容(多人可同持一间隙)。
- 阻止插入意向锁(Intent Insert Lock)进入同间隙。
- 可通过降低隔离级别为
READ COMMITTED来禁用。
优化降级:
- 主键 / 唯一索引等值查询命中时 → 退化为记录锁。
- 等值查询向右遍历且最后一条不等于目标值时 → 退化为间隙锁。
- 多个事务插入同一间隙的不同位置→ 插入意向锁互不冲突,可并发。
- 已有 Gap Lock 的间隙 → 插入意向锁被阻塞,等待 Gap Lock 释放。
- 这正是间隙锁导致死锁的典型原因(两个事务互持 Gap Lock 又互需 Insert Intention Lock)。
读已提交和可重复读详解
这一部分只聚焦 读已提交 和 可重复读 两种最常见隔离级别,并详细说明它们如何借助锁解决问题。
读已提交
在读已提交下,系统重点解决脏读问题,常见的锁处理行为是:
- 写入与当前读会加记录锁。
- 普通查询不加行级锁。
- 对范围插入的限制相对较少,因此幻读仍可能出现。
可在下方图示中,点击索引 tab 可切换对应内容,查看单值查询下的各索引的加锁表现:
| id(主键) | c1(唯一索引) | c2(普通索引) | c3(无索引) |
|---|---|---|---|
| 1 | 1 | 1 | 1 |
| 2 | 2 | 3 | 4 |
| 3 | 3 | 6 | 9 |
可重复读
在可重复读下,系统除了避免脏读和不可重复读,还会进一步处理范围一致性,常见的锁处理行为是:
- 在范围查询场景中大量使用间隙锁和临键锁。
- 通过锁定区间抑制并发插入,降低幻读出现概率。
- 一致性更强,但锁冲突和死锁概率会相应增加。
可在下方组件查看不同索引下范围查询的锁表现以及综合案例演示:
| id(主键) | c1(唯一索引) | c2(普通索引) | c3(无索引) |
|---|---|---|---|
| 1 | 1 | 1 | 1 |
| 2 | 2 | 3 | 4 |
| 3 | 3 | 6 | 9 |
加锁:对命中记录加 NK Lock,向后扫到第一个不满足条件的记录 id=3 加 NK Lock(含该记录的间隙),因为是等值判断命中了3但不符合 <3 条件,该 NK 退化为 Gap。
最后,最好的方式就是开两个会话复现实验,再对照组件、日志观察加锁变化。