MySQL

MySQL 锁机制与事务隔离

从事务四个特性出发,说明并发读取问题、隔离级别,以及表锁、行锁、间隙锁和临键锁在 InnoDB 中的作用。

#MySQL#数据库#事务#隔离级别##InnoDB

MySQL 锁机制与事务隔离

很多锁名词看起来复杂,其实都在解决同一件事:并发下怎么保证数据可靠。

我们要知道,锁不是为了增加复杂度,而是为了在并发环境里守住一致性。



为了能更好的理解,文章我们从上到下一点点过渡,先从最常用的事务开始,事务有什么基本特性,理解事务的目标,如果在并发情况下,会出现什么问题,再来看这些问题是怎么用隔离级别解决的,而隔离级别又是怎么和锁机制是怎么一步步实现这些目标的。

术语说明

这里事先说明一下,本文中可能出现的术语,先有个概念,后文在提到的时候还会再解释的 ✌️

  1. 事务四个特性:简称 ACID, 分别是,Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)、Durability(持久性)
  2. 并发读取问题Dirty Read(脏读)、Non-repeatable Read(不可重复读)、Phantom Read(幻读)
  3. 隔离级别Read Uncommitted(读未提交,简写: RU)、Read Committed(读已提交,简写: RC)、Repeatable Read(可重复读,简写: RR)、Serializable(串行化)
  4. 多版本并发控制Multi-Version Concurrency Control (简称 MVCC)

事务和四个基本特性

事务很好理解,我们简单带过一下:指数据库中一个操作,由多个步骤组成,要么全部都成功,要么全部都失败。

事务有四个特性,合在一起我们简称 ACID

  1. 原子性(Atomicity):事务要么全部成功,要么全部回滚。 例:扣款成功、加款失败时,系统会撤销已执行操作。
  2. 一致性(Consistency):事务前后都要满足业务约束。 例:转账前后两个账户总额不变。
  3. 隔离性(Isolation):并发事务之间互不干扰。 例:两个事务同时修改同一行,不会互相覆盖到错误状态。
  4. 持久性(Durability):提交成功后结果必须保留。 例:COMMIT 后即使数据库重启,数据依然存在。

前面的文字概念听起来有点干燥,下面我们举一个最常见的转账事务,分别说明一下四个特性:

transfer.sql
sql
1BEGIN;2UPDATE account SET balance = balance - 100 WHERE id = 1;3UPDATE account SET balance = balance + 100 WHERE id = 2;4COMMIT;
案例场景 转账
原子性
A 账户减 100,B 账户加 100 无论扣款失败,还是加钱失败,只要有 1 个失败,均失败;只有都成功,才算成功
一致性
无论怎么转钱,两个总额都是 2000
隔离性
假设事务 1,给 B 加 100 元,但事务 1 并未提交; 事务 2,查询 B,却查询到 1100 元; 说明事务 2 读取到了事务 1 未提交的数据,该现象称为:脏读,这就是事务之间没有隔离
持久性
假如转账成功,A 就有 900,B 有 1100 现在服务器重启或数据库重启,以上数据仍然保持
目前 A 和 B 都各有 1000 A 给 B 转 100 元

并发读取中的三个问题

经过上面转账的例子,应该知道事务的主要目标是保证数据可靠,那么在并发环境下,事务之间就可能会互相干扰,导致数据不可靠。比如上面演示的转账例子中,提到了隔离性的时候,会出现脏读问题,当然这只是其中一个,实际这些问题总结起来主要有三个:脏读、不可重复读、幻读。

脏读

脏读(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 提供了四个隔离级别,分别如下:

  1. 读未提交(Read Uncommitted,RU)
  2. 读已提交(Read Committed,RC)
  3. 可重复读(Repeatable Read,RR)
  4. 串行化(Serializable)
隔离级别脏读不可重复读幻读说明
读未提交可能发生可能发生可能发生并发高,一致性最低
读已提交避免可能发生可能发生常用于强调吞吐的场景
可重复读避免避免通常可避免InnoDB 默认级别
串行化避免避免避免一致性最强,并发最低

理解了前面的几个问题,就好理解每个隔离级别了:

  1. 读未提交:控制最低,但读取速度快,但会出现脏读、幻读、不可重复读这些问题。
  2. 读已提交:不再读到未提交数据,解决了脏读问题,但重复读取不稳定,同样幻读问题依旧存在。
  3. 可重复读:同一事务里读取稳定,并配合间隙控制范围插入,幻读问题基本不会发生(理论上会发生,概率极小)。
  4. 串行化:并发降到更低,串行执行,换取更强一致性。

隔离级别的实现

MySQL 的隔离级别,主要是两套机制协同处理的:MVCC多版本并发控制) 和 锁机制

  1. MVCC 负责普通查询,不加锁”的读(快照读),解决读-写冲突。
  2. 锁机制负责“加锁”的读(当前读)和写操作,解决写-写冲突。

Tip

MVCC 介绍可查看这篇文章快速回顾, 这里不再展开细讲,看完后对照着 MVCC 的内容,再看这部分内容会有更好的理解。

read-types.sql
sql
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(加共享锁)。
    • 读写操作相互排斥,事务串行化执行。
  • 影响:完全解决并发问题,但系统吞吐量最低。

锁机制的整体结构

前面说隔离级别的时候已经提到了一些锁,接下来我们就详细说说到底有哪些锁,可能会有点多,但慢慢看下来基本都还好理解

锁按粒度可以分成全局锁、表级锁、行级锁,日常业务里最关键的是行级锁,但全局锁和表级锁在备份、变更结构、线上故障排查时也很重要。

后面还会出现一些缩写,但基本都是数据库基础知识,这里再贴一下可能涉及到的术语说明

缩写全称作用常用命令
DQLData Query Language查询数据SELECT
DMLData Manipulation Language操作数据内容INSERT, UPDATE, DELETE
DDLData Definition Language定义数据库结构CREATE, ALTER, DROP, TRUNCATE
DCLData Control Language控制访问权限GRANT, REVOKE
TCLTransaction Control Language事务管理控制COMMIT, ROLLBACK, SAVEPOINT

Tip

注:有时会将查询数据的 SELECT 分类为 DQL,但通常也归纳在 DML 中。

锁概览

先看下方里的概览标签,可以建立完整框架认知:

MySQL InnoDB 锁机制

InnoDB 通过多层次锁机制保证并发一致性,可从粒度语义来源三个维度理解。
默认隔离级别 Repeatable Read,也就是可重复读,行级锁基于索引实现——无索引将退化为全表锁。

按粒度
锁定范围大小
全局锁
整个数据库实例只读,FTWRL
表级锁
表锁 · MDL · 意向锁 · AUTO-INC
行级锁
记录锁 · 间隙锁 · Next-Key · 插入意向
按语义
读写、协调、幻读控制
读写语义
共享锁 S(读锁)/ 排他锁 X(写锁)
协调多粒度
意向锁 IS / IX,协调行锁与表锁
幻读控制
Gap Lock / Next-Key Lock 防止幻读
自增控制
AUTO-INC Lock / 轻量级自增锁
按来源
显式 vs 隐式加锁
显式加锁
LOCK TABLES t READ / WRITESELECT … FOR UPDATESELECT … FOR SHARE
隐式加锁(引擎自动)
DML 自动加行锁
MDL:DDL/DML 自动申请
意向锁 / AUTO-INC 自动维护
锁兼容性关系
✓ 兼容共存 · ✗ 冲突等待
已持有 ╲ 申请S
行共享
X
行排他
IS
意向共享
IX
意向排他
Gap
间隙
Next-Key
临键
Insert Int.
插入意向
S(行共享)
X(行排他)
IS(意向共享)
IX(意向排他)
Gap(间隙锁)
注意:
S 锁之间兼容;
X 锁与任何锁都不兼容;
意向锁之间全部兼容;
间隙锁之间兼容(允许共存),但都阻止插入意向锁。
演示用表结构(不用记,后续演示时候还会再贴出来)
后续交互演示均基于以下两张表
表 tid主键 · c1唯一索引 · c2普通索引 · c3无索引
DDL — 表结构
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(无索引)
1111
2234
3369
表 t2id主键 · c普通索引 · d无索引
DDL — 表结构
CREATE TABLE t2 (
  id  INT PRIMARY KEY,
  c   INT,
  d   INT
);
CREATE INDEX idx_t2_c ON t2(c);
id(主键)c(普通索引)d(无索引)
555
101010
151515
202020
252525
t2 间隙分布:(-∞,5] · (5,10] · (10,15] · (15,20] · (20,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 列的值能够正确递增,避免重复值的出现。

全局锁和表级锁图示

可以在下方标签里查看全局锁表级锁的行为:

全局锁
FLUSH TABLES WITH READ LOCK · 简写 FTWRL
场景说明全库操作
全局锁作用于整个数据库实例,加锁后所有写操作均被阻塞,实例进入只读状态,主要用于全库逻辑备份。
InnoDB 推荐使用 --single-transaction 替代。
FTWRL 示例
-- 会话 1:加全局读锁
FLUSH TABLES WITH READ LOCK;

-- 会话 2:写操作被阻塞
INSERT INTO t VALUES(4, 4, 4, 4);
UPDATE      t SET c1 = 10;

-- 会话 1:释放
UNLOCK TABLES;
实例状态可视化
正常状态 · 读写均可
SELECTINSERTUPDATEDDLCOMMIT
使用建议:InnoDB 推荐 mysqldump --single-transaction,不影响在线写入。

行级锁

这部分主要介绍: 记录锁、间隙锁、临键锁

这三类都是行级锁,前面提到表所得时候,说 InnoDB 有后面那些更精细的行锁,说的其实就是这里,接下来分别说说。

记录锁

记录锁很直观的理解,锁住当前一行记录,但要注意,记录锁是锁的是索引记录。当你通过主键或唯一索引找一个确定的值,比如 SELECT * FROM t WHERE id = 1 FOR UPDATE;,只要这条数据在,InnoDB 就会把它锁死,事务没完,谁也别想改它或删它。

Warning

记录锁 (Record Lock) 锁的不是这行数据记录,而是锁索引记录,只锁索引!如果没有索引,InnoDB 会创建一个隐藏的聚簇索引,并使用这个索引进行记录锁定。

如果我们在一张表中没有定义主键,MySQL 会默认选择一个唯一的非空索引作为聚簇索引。 如果没有适合的非空唯一索引,则会创建一个隐藏的主键 (row_id) 作为聚簇索引, 这块儿可以看看 MySQL 索引文章

间隙锁

间隙锁不锁具体的行,它锁的是数据之间的“空位”,也就是索引记录之间的间隙。

间隙锁的目的就是防止幻读,即防止别人在你的查询范围内插新数据、删数据。

比如表中只有 id=1id=5,执行 WHERE id > 1 AND id < 5 FOR UPDATE 时,系统会在 $(1, 5)$ 这个开区间拉起警戒线。这时候想插入 id=3 的操作会被直接挂起。 注意: 间隙锁之间是不互斥的。两个事务可以同时锁住同一个间隙,因为大家的目标一致——谁也别想往这儿塞新数据。

临键锁

这是 InnoDB 的默认使用,可以理解为 记录锁 + 间隙锁

它锁住的是一个左开右闭的区间,比如 (5, 10],它既管住了 5 到 10 之间的空隙,也把 10 这一行给锁了。

不过它有个实用的优化逻辑:

  • 命中唯一索引且记录存在: 会“降级”成记录锁,毕竟目标明确,没必要扩大锁定范围。
  • 记录不存在: 会降级为间隙锁,专门用来防范那个特定位置的插入操作。

行级锁图示

下面可以在查看各个行级锁的说明:

记录锁(Record Lock)
精确锁定单条索引记录
场景说明锁定索引记录
记录锁精确锁定索引上的单条记录,如 WHERE id=1 FOR UPDATE 锁定主键 id=1 的索引记录。
如果没有索引,InnoDB 会使用隐藏的聚簇索引,导致全表扫描退化为全表锁。
核心要点:
  • 记录锁锁的是索引记录,而非数据行本身。
  • 使用主键或唯一索引等值查询时,Next-Key Lock 退化为纯记录锁。
  • 分为 S 共享X 排他 两种。
id=1
c1=1
id=2
c1=2
X 锁
id=3
c1=3
间隙锁(Gap Lock)
锁定索引记录之间的间隙,防止幻读插入
场景说明RR 隔离级别
间隙锁锁的是索引记录之间的间隙(开区间),阻止其他事务在该间隙插入新记录,用于防止幻读间隙锁仅在 RR 隔离级别生效
注意:Gap Lock 之间完全兼容!多事务可同时持有同一间隙的 Gap Lock。
id=5
c=5
Gap(5,10)
id=10
c=10
Gap(10,15)
id=15
c=15
Gap Lock 核心特性:
  • 锁的是间隙,不锁记录本身
  • Gap Lock 之间兼容(多人可同持一间隙)。
  • 阻止插入意向锁(Intent Insert Lock)进入同间隙。
  • 可通过降低隔离级别为 READ COMMITTED 来禁用。
Next-Key Lock(临键锁)
Record Lock + Gap Lock = 左开右闭区间锁
场景说明InnoDB 默认行锁
Next-Key Lock 是 InnoDB 在 RR 级别下的默认加锁方式,锁定一个索引记录及其前面的间隙(左开右闭区间),如 (5,10]。
优化降级:
  • 主键 / 唯一索引等值查询命中时 → 退化为记录锁
  • 等值查询向右遍历且最后一条不等于目标值时 → 退化为间隙锁
id=5
c=5
NK(5,10]
id=10
c=10
NK
Gap(10,15)
id=15
c=15
插入意向锁(Insert Intention Lock)
INSERT 前在目标间隙申请的特殊锁
场景说明间隙内的 INSERT
当事务要在某个间隙内插入记录时,先申请插入意向锁,它是一种特殊的间隙锁,插入意向锁之间不冲突(不同目标位置),但与已有的 Gap Lock 冲突。
关键交互:
  • 多个事务插入同一间隙的不同位置→ 插入意向锁互不冲突,可并发。
  • 已有 Gap Lock 的间隙 → 插入意向锁被阻塞,等待 Gap Lock 释放。
  • 这正是间隙锁导致死锁的典型原因(两个事务互持 Gap Lock 又互需 Insert Intention Lock)。
InnoDB 的行级锁依赖索引条件,缺少有效索引时锁范围会显著扩大。

读已提交和可重复读详解

这一部分只聚焦 读已提交可重复读 两种最常见隔离级别,并详细说明它们如何借助锁解决问题。

读已提交

在读已提交下,系统重点解决脏读问题,常见的锁处理行为是:

  1. 写入当前读会加记录锁。
  2. 普通查询不加行级锁
  3. 对范围插入的限制相对较少,因此幻读仍可能出现。

可在下方图示中,点击索引 tab 可切换对应内容,查看单值查询下的各索引的加锁表现:

演示表表 tid 主键 · c1 唯一索引 · c2 普通索引 · c3 无索引
id(主键)c1(唯一索引)c2(普通索引)c3(无索引)
1111
2234
3369
主键等值查询 · SELECT WHERE id=1 FOR UPDATE
主键等值命中时,Next-Key Lock 退化为记录锁
表 tid=1 命中
等值查询命中唯一索引记录 → NK 退化为纯记录锁,仅锁 id=1 记录本身。
加锁结构
主键索引(id=1)
X Record Lock
T2 尝试
id=1 阻塞
UPDATE t SET c3=99 WHERE id=1
记录锁冲突
id=2 成功
UPDATE t SET c3=99 WHERE id=2
其他行不受影响
INSERT 成功
INSERT INTO t VALUES(4,4,7,8)
无间隙锁

可重复读

在可重复读下,系统除了避免脏读和不可重复读,还会进一步处理范围一致性,常见的锁处理行为是:

  1. 在范围查询场景中大量使用间隙锁和临键锁。
  2. 通过锁定区间抑制并发插入,降低幻读出现概率。
  3. 一致性更强,但锁冲突和死锁概率会相应增加。

可在下方组件查看不同索引下范围查询的锁表现以及综合案例演示

演示表表 tid 主键 · c1 唯一索引 · c2 普通索引 · c3 无索引
id(主键)c1(唯一索引)c2(普通索引)c3(无索引)
1111
2234
3369
主键范围查询 · SELECT WHERE id ≥ 1 AND id < 3 FOR UPDATE
表 tid ∈ [1,3)
主键范围查询 → 命中 id=1, id=2
加锁:对命中记录加 NK Lock,向后扫到第一个不满足条件的记录 id=3 加 NK Lock(含该记录的间隙),因为是等值判断命中了3但不符合 <3 条件,该 NK 退化为 Gap。
加锁结构
id=1(命中)
NK(-∞,1]
id=2(命中)
NK(1,2]
id=3(首个不满足)
Gap(2,3)
总锁范围:(-∞, 3),即 id < 3 的所有记录和间隙都被锁定。

最后,最好的方式就是开两个会话复现实验,再对照组件、日志观察加锁变化。