LOADING
5402 words
27 minutes
MVCC 多版本并发控制 + 锁机制:事务隔离级别的底层实现(三)

数据库事务系列 · 第 3 篇

引言:一个「诡异」的现象

在前两篇文章中,我们做了一个完整的演示:

  1. 第 1 篇:我们学会了事务的基本操作(BEGIN、COMMIT、ROLLBACK、SAVEPOINT),并验证了原子性和持久性。
  2. 第 2 篇:我们把隔离级别调来调去,亲眼看到了脏读、不可重复读、幻读,以及四种隔离级别如何应对它们。

但你有没有想过一个问题——

演示三(Repeatable Read) 中,我们看到了一个「诡异」的现象:

时间终端B(柜员小李)终端A(柜员小张)张三余额
T1BEGIN;100
T2SELECT balance FROM account WHERE id = 1;读到 100
T3BEGIN;
T4UPDATE account SET balance = 0 WHERE id = 1;
T5COMMIT;已提交,物理数据变为 0
T6SELECT balance FROM account WHERE id = 1;仍然读到 100

诡异的地方在于:

终端A 已经在 T5 时刻提交了事务,数据库里的「真实数据」已经变成了 0。但终端B 在 T6 时刻(自己的事务还没结束)读取到的,依然是 100。

终端B 凭什么能看到「已经不存在」的旧数据?

这个问题,指向了 MySQL 事务隔离性最核心的底层机制——

MVCC(Multi-Version Concurrency Control,多版本并发控制)

一、MVCC 的核心思想:每个事务都有自己的「专属快照」

1.1 什么是 MVCC?

MVCC 的全称是 Multi-Version Concurrency Control,翻译过来就是「多版本并发控制」。

它的核心思想非常巧妙:

每次修改数据时,不直接覆盖旧数据,而是生成一个新版本。旧版本继续保留,供其他事务读取。

用生活场景来理解:

你在 Git 上提交代码,每次提交都会生成一个新的 commit。团队成员可以继续查看旧的 commit,也可以拉取最新的 commit。大家各看各的版本,互不干扰。

MVCC 在数据库里干的是同样的事情:

┌─────────────────────────────────────────────────────────────────┐
│ 数据行的版本链 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 版本 V1 (balance=100) ←── 事务B 正在读这个版本 │
│ │ │
│ ▼ (事务A 修改并提交) │
│ 版本 V2 (balance=0) ←── 物理数据现在是这个版本 │
│ │
│ 事务B 读到的还是 V1——因为事务B 启动时,V2 还没诞生 │
│ │
└─────────────────────────────────────────────────────────────────┘

1.2 MVCC 解决了什么问题?

MVCC 让数据库实现了两个关键能力:

能力说明对应业务价值
读写不互斥读操作不会阻塞写操作,写操作也不会阻塞读操作高并发场景下,查询和更新可以同时进行
一致性读每个事务都能读到「自己启动那一刻」的数据快照事务内多次查询结果一致(可重复读)

没有 MVCC 之前,读操作需要加锁,写操作也要加锁——读会阻塞写,写也会阻塞读,并发性能很差。有了 MVCC,读操作直接读取「旧版本」,不需要等写操作完成。

1.3 MVCC 的适用边界

MVCC 解决了「读写冲突」,但解决不了「写写冲突」。

如果两个事务同时修改同一行数据(比如终端A 和终端C 同时要把张三的余额改为 0 和 50),MVCC 就无能为力了——因为最终只能有一个版本是「最新」的。

这个冲突,由锁机制来处理。MVCC 和锁,一个管「读-写」,一个管「写-写」,共同构建了完整的并发控制体系。

完整的分工图:

二、MVCC 的三大核心组成部分

在深入细节之前,我们先看一张完整的 MVCC 工作流程图

这张图把隐藏字段、undo log 版本链、read view 三者的关系一次性展现出来。接下来的三小节,我们会逐一拆解图中的每一个部件。

2.1 隐藏字段:每行数据都有的「身份证」

在 InnoDB 中,每一行数据除了我们定义的字段(id、name、balance),还有三个隐藏字段

隐藏字段名称大小存储内容
DB_TRX_ID事务ID6 字节最近一次修改该行的事务ID
DB_ROLL_PTR回滚指针7 字节指向 Undo Log 中旧版本的指针
DB_ROW_ID行ID6 字节单调递增的行ID(无主键时用于生成聚集索引)

我们通常不用关注 DB_ROW_ID,但 DB_TRX_ID 和 DB_ROLL_PTR 是理解 MVCC 的关键

用「张三的余额」来看:

78358117899

trx_id 是自增的,意味着什么?

规则含义
trx_id 越小该版本被创建得越早
trx_id 越大该版本被创建得越晚
当前事务的 trx_id就是当前事务在系统中的唯一编号

这就是 read view 判断「谁先谁后」的依据——用 trx_id 的大小关系,判断数据版本和当前事务的时间先后。

2.2 Undo Log 链:数据版本的「全家桶」

每次修改数据时,InnoDB 会把修改前的旧版本记录到 Undo Log 中。多个旧版本通过 roll_pointer 串联成一条版本链

关键理解:多个事务并行操作同一行时,每个版本都记录着「谁改的」,并通过 roll_pointer 串联成一个链表。

每个版本都记录了两个关键信息:

  • 是谁(哪个事务ID)创建了这个版本
  • 上一个版本在哪里(DB_ROLL_PTR)
作用说明
支持回滚事务回滚时,沿着版本链找到旧版本,恢复数据
支持 MVCCread view 沿着版本链遍历,找到「可见」的版本
支持一致性读RR 级别下,始终读取事务开始时的版本快照

2.3 Read View:事务启动时的「快照相机」

Read View 是 MVCC 中最重要的概念。它的作用是:

在事务执行 SELECT 时,生成一个「快照」,记录当时系统中所有活跃事务的状态。

Read View 包含三个核心字段:

字段含义
min_trx_id当前所有活跃事务中,最小的 ID
max_trx_id系统下一个要分配的事务 ID(边界值)
m_ids当前所有活跃事务的 ID 列表(即正在进行中、还没提交的事务)

Read View 生成后,数据可见性的判断规则:

当要读取某行数据时,看该行的 DB_TRX_ID(创建该版本的事务ID):

78358111360

三、RR 与 RC 的核心差异:Read View 生成时机不同

这是理解「为什么 RR 能可重复读,而 RC 不能」的关键。

3.1 Repeatable Read:一个事务只用一张「快照」

在 RR 级别下:

Read View 在事务中第一次执行 SELECT 时生成,之后一直复用,直到事务结束。

这就是终端B 在演示三中「始终读到 100」的原因:

时间终端BRead View 状态
T1BEGIN;事务开始,但还没生成 Read View
T2SELECT ...(读到 100)生成 Read View,记录了当时的数据快照
T3终端A 修改并提交(100 → 0)事务已提交,但 Read View 不变
T4SELECT ...(仍然读到 100)复用同一个 Read View,仍然看到旧快照
┌─────────────────────────────────────────────────────────────────┐
│ RR 级别:Read View 复用示意图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 事务B 时间线 │
│ ────────────────────────────────────────────────► │
│ BEGIN 第一次 SELECT 第二次 SELECT COMMIT │
│ │ │ │ │ │
│ │ ┌────▼────┐ ┌────▼────┐ │ │
│ │ │生成 │ │复用 │ │ │
│ │ │Read View│ │Read View│ │ │
│ │ └─────────┘ └─────────┘ │ │
│ │ │ │ │ │
│ 数据快照: 100 100 100 → 事务结束 │
│ │
│ 即使终端A 在中间提交了修改(0),终端B 看到的始终是 100 │
│ │
└─────────────────────────────────────────────────────────────────┘

3.2 Read Committed:每次 SELECT 都生成一张新「快照」

在 RC 级别下:

每次执行 SELECT,都重新生成一个 Read View。

这就是为什么 RC 会出现「不可重复读」——每次查询都看到最新的已提交数据。

时间终端BRead View 状态
T1BEGIN;事务开始
T2SELECT ...(读到 100)生成 Read View #1
T3终端A 修改并提交(100 → 0)事务已提交
T4SELECT ...(读到 0)重新生成 Read View #2,看到了最新的已提交数据

四、锁机制:处理「写-写冲突」的唯一武器

MVCC 解决了「读-写冲突」,但「写-写冲突」必须由锁机制来处理。

4.1 为什么需要锁?

想象这个场景:

柜员小张(终端A)正在修改张三的余额:100 → 0(事务未提交)

柜员小王(终端C)也在修改张三的余额:100 → 50(事务未提交)

如果两个事务同时修改同一行数据,最终结果应该是 0 还是 50?

MVCC 解决不了这个问题,因为最终只能有一个版本成为「最新版本」。所以,第二个修改的事务必须等待第一个修改的事务完成(提交或回滚)

这就是锁的作用——让「写-写」操作排队执行

4.2 锁的分类(按粒度)

锁的「粒度」是指锁定的范围大小。粒度越大,锁定的数据越多,并发性能越差;粒度越小,锁定的数据越少,并发性能越好。

4.2.1 表级锁(Table Lock)

锁定整张表,粒度最大,并发最差。

场景: 在执行 ALTER TABLEDROP TABLE 等 DDL 操作时,会加表级锁。

类比:把整个仓库锁上,所有人都不能进出。

4.2.2 行级锁(Row Lock)

锁定某一行记录,粒度最小,并发最好。

场景: InnoDB 在执行 UPDATE ... WHERE id = 1 时,只锁定 id=1 的这一行,其他行不受影响。

类比:只锁住仓库里的某一个货架,其他人还能搬其他货架的东西。

4.2.3 页级锁(Page Lock)

锁定一个数据页(通常 16KB),介于表锁和行锁之间。

说明: MySQL InnoDB 主要使用行锁,页级锁在一些其他数据库中更常见(如 SQL Server)。

4.3 锁的分类(按模式)

锁模式英文俗称作用
共享锁Shared Lock读锁(S锁)允许其他事务读取,不允许修改
排他锁Exclusive Lock写锁(X锁)不允许其他事务读取或修改

4.3.1 共享锁(S锁,读锁)

多个事务可以同时持有共享锁,但不能修改数据。

  • 加了 S 锁的数据,其他事务可以,但不能
  • 其他事务也可以加 S 锁,但不能加 X 锁。

加锁方式:

SELECT * FROM account WHERE id = 1 LOCK IN SHARE MODE;

业务场景: 读取数据用于生成报表。报表生成过程中,数据可以被其他人读,但不希望被修改(防止报表数据不一致)。

4.3.2 排他锁(X锁,写锁)

只允许一个事务持有排他锁,其他事务既不能读也不能写。

  • 加了 X 锁的数据,其他事务不能读(会被阻塞),也不能写
  • 在事务提交或回滚之前,X 锁不会释放。

加锁方式:

-- 自动加 X 锁(任何修改操作都会自动加 X 锁)
UPDATE account SET balance = 0 WHERE id = 1;
-- 手动加 X 锁(SELECT ... FOR UPDATE)
SELECT * FROM account WHERE id = 1 FOR UPDATE;

业务场景: 查询某条数据并准备修改。在查询和修改之间,不希望其他事务修改这条数据。

4.4 共享锁 vs 排他锁 兼容性矩阵

锁类型共享锁(S)排他锁(X)
共享锁(S)✅ 兼容❌ 冲突
排他锁(X)❌ 冲突❌ 冲突

一句话记住:

  • 多个读可以同时进行(S 和 S 兼容)
  • 只要有写,其他人都不能动(X 和谁都冲突)

4.5 意向锁(Intention Lock):表级锁和行级锁的「协调员」

意向锁是 InnoDB 中一个非常重要但容易被忽视的概念。它的作用非常纯粹:

意向锁是一种「表级锁」,用于表示一个事务「打算」在表中的某些行加锁。

为什么需要意向锁?

想象一个场景:

  • 事务A 正在修改 account 表中的某一行(加行级 X 锁)
  • 事务B 想要锁定整张 account 表(加表级锁)

如果没有意向锁,事务B 需要扫描所有行,检查是否有行级锁存在——这效率极低。

有了意向锁,事务A 在加行锁之前,会先给表加一个「意向锁」作为标记。事务B 看到表上有意向锁,就知道「表里已经有行锁了」,直接等待即可。

意向锁的两种类型:

意向锁简称含义
意向共享锁IS 锁事务打算给某些行加共享锁(S 锁)
意向排他锁IX 锁事务打算给某些行加排他锁(X 锁)

加锁顺序(重点):

事务要加行级 X 锁
先加表级 IX 锁(意向排他锁)
再加行级 X 锁(排他锁)

意向锁的兼容性:

锁类型共享锁(S)排他锁(X)意向共享(IS)意向排他(IX)
共享锁(S)✅ 兼容❌ 冲突✅ 兼容❌ 冲突
排他锁(X)❌ 冲突❌ 冲突❌ 冲突❌ 冲突
意向共享(IS)✅ 兼容❌ 冲突✅ 兼容✅ 兼容
意向排他(IX)❌ 冲突❌ 冲突✅ 兼容✅ 兼容

关键点:意向锁之间是互相兼容的。 多个事务可以同时持有 IX 锁(表示多个事务都在修改不同行),但一旦有事务要加表级 X 锁,就必须等待所有意向锁释放。

4.6 行锁的三种具体类型(InnoDB 特有)

InnoDB 的行锁不是一种,而是三种,分别应对不同的场景:

4.6.1 记录锁(Record Lock)

锁定单个索引记录。

-- 锁定 id=1 这一行
SELECT * FROM account WHERE id = 1 FOR UPDATE;

只锁住 id=1 这一条记录,其他 id 的记录不受影响。

业务场景: 柜员查询张三的余额并准备修改,防止其他事务在查询和修改之间改变数据。

4.6.2 间隙锁(Gap Lock)

锁定索引记录之间的「间隙」,防止其他事务在该间隙插入数据。

-- 假设 account 表中有 id: 1, 5, 10
SELECT * FROM account WHERE id BETWEEN 1 AND 10 FOR UPDATE;

间隙锁会锁定 (1, 5) 和 (5, 10) 之间的空隙,阻止其他事务插入 id=3 或 id=7 的新记录。

间隙锁的主要目的:防止幻读——阻止其他事务在查询范围内插入新数据。

业务场景: 柜员小李统计「余额大于 0 的账户总数」,不希望统计过程中有新账户开户(插入),否则统计数据就不准了。

4.6.3 Next-Key Lock

记录锁 + 间隙锁的组合。

Next-Key Lock = Record Lock + Gap Lock

它既锁住了记录本身,又锁住了记录前面的间隙。

在 MySQL 的 RR(可重复读)级别下,查询使用索引时,默认加的就是 Next-Key Lock。这就是为什么 MySQL 的 RR 级别能避免大部分幻读的原因。

五、三种行锁的详细说明与案例

5.1:记录锁(Record Lock)

目标:证明记录锁只锁住 id=1 这一行,其他行不受影响。

Step 1:终端A 锁定 id=1

BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE;

Step 2:终端B 修改 id=1(被阻塞)

BEGIN;
UPDATE account SET balance = 999 WHERE id = 1;

Step 3:终端B 修改 id=5(不受影响)

Ctrl+C 取消上一条被阻塞的语句,然后在终端B 执行:

UPDATE account SET balance = 888 WHERE id = 5;

📸 截图 3:显示 Query OK, 1 row affected——立即成功。

Step 4:终端A 提交,释放锁

COMMIT;

此时终端B 被阻塞的 UPDATE 会立即执行成功。

结论:记录锁只锁住 id=1 这一行,id=5 不受影响。

5.2:间隙锁(Gap Lock)

目标:证明范围查询会锁住 (1,5) 之间的间隙,阻止在间隙内插入数据。

Step 1:重置数据

DELETE FROM account;
INSERT INTO account VALUES (1, '张三', 100);
INSERT INTO account VALUES (5, '李四', 200);
INSERT INTO account VALUES (10, '王五', 300);

Step 2:终端A 执行范围查询,加锁

BEGIN;
SELECT * FROM account WHERE id BETWEEN 1 AND 5 FOR UPDATE;

此时间隙锁锁住了 (1,5) 之间的空隙——也就是 id=2、3、4 的位置。**

Step 3:终端B 在间隙内插入(被阻塞)

BEGIN;
INSERT INTO account VALUES (3, '赵六', 400);

Step 4:终端B 在间隙外插入(不受影响)

Ctrl+C 取消,然后执行:

INSERT INTO account VALUES (7, '赵六', 400);

Step 5:终端A 提交

COMMIT;

结论:间隙锁锁住了 (1,5) 之间的空隙,阻止在间隙内插入数据(防止幻读)。

5.3:Next-Key Lock

目标:证明 SELECT ... WHERE id = 5 FOR UPDATE 不仅锁住了 id=5 这一行,还锁住了它前面的间隙 (1,5)

Step 1:重置数据

DELETE FROM account;
INSERT INTO account VALUES (1, '张三', 100);
INSERT INTO account VALUES (5, '李四', 200);
INSERT INTO account VALUES (10, '王五', 300);

Step 2:终端A 锁定 id=5

BEGIN;
SELECT * FROM account WHERE id BETWEEN 1 AND 5 FOR UPDATE;

Step 3:终端B 修改 id=5(被阻塞——记录锁效果)

BEGIN;
INSERT INTO account VALUES (3, '赵六', 400);

78358519028

Step 4:终端B 在间隙 (1,5) 中插入(被阻塞——间隙锁效果)

Ctrl+C 取消,然后执行:

INSERT INTO account VALUES (3, '赵六', 400);

Step 5:终端B 在间隙外插入(不受影响)

Ctrl+C 取消,然后执行:

INSERT INTO account VALUES (6, '赵六', 400);

Step 6:终端A 提交

COMMIT;

结论:Next-Key Lock = 记录锁(锁住 id=5)+ 间隙锁(锁住前面的间隙 (1,5))。

三种行锁演示效果对比表

演示终端A 的操作终端B 的操作结果说明
记录锁SELECT ... WHERE id=1 FOR UPDATEUPDATE ... WHERE id=1❌ 阻塞记录锁锁住 id=1
UPDATE ... WHERE id=5✅ 成功id=5 不受影响
间隙锁SELECT ... WHERE id BETWEEN 1 AND 5 FOR UPDATEINSERT ... id=3❌ 阻塞3 在间隙 (1,5) 中
INSERT ... id=7✅ 成功7 不在间隙中
Next-Key LockSELECT ... WHERE id=5 FOR UPDATEUPDATE ... WHERE id=5❌ 阻塞记录锁
INSERT ... id=3❌ 阻塞间隙锁(前面的间隙)
INSERT ... id=6✅ 成功6 不在锁范围内

六、不同隔离级别下的锁策略对比

这是本文最重要的总结表,建议收藏:

隔离级别使用的锁机制特点并发性能
Read Uncommitted几乎不加锁(读操作不加锁,写操作加 X 锁)性能最高,但脏读、不可重复读、幻读都可能最高
Read Committed读操作不加锁(MVCC),写操作加 X 锁;无间隙锁避免脏读,但不可重复读、幻读可能较高
Repeatable Read读操作不加锁(MVCC),写操作加 X 锁;有间隙锁(Next-Key Lock)避免脏读、不可重复读,基本避免幻读中等
Serializable读操作加 S 锁,写操作加 X 锁;所有操作串行执行避免所有并发问题,性能最低最低

关键差异说明:

1. Read Committed 没有间隙锁,而 Repeatable Read 有

这就是为什么 RC 会出现幻读,而 RR 不会(在大多数场景下)。

我整理了一个简要对比表:

对比维度RCRR
是否使用 MVCC✅ 是✅ 是
Read View 生成时机每次 SELECT第一次 SELECT
是否使用间隙锁❌ 否✅ 是
能否避免幻读❌ 不能✅ 基本能

七、MVCC + 锁 + 日志:三者如何协作

7.1 三者的职责分工

机制负责什么解决什么问题
Undo Log存储数据的历史版本原子性(回滚)+ MVCC 的数据来源
MVCC(Read View)控制事务能看到哪个版本隔离性中的「读-写」冲突
锁(Lock)控制事务的并发修改隔离性中的「写-写」冲突
Redo Log记录修改操作持久性(崩溃恢复)

7.2 一个完整的「查询」流程(RR 级别下)

当终端B 执行 SELECT * FROM account WHERE id = 1; 时:

7.3 一个完整的「更新」流程(所有级别下)

当终端A 执行 UPDATE account SET balance = 0 WHERE id = 1; 时:

7.4 三者协作的完整视图

八、全文总结

8.1 核心知识点回顾

知识点核心内容一句话记忆
MVCC多版本并发控制,通过 Undo Log 链 + Read View 实现每个事务都有自己的数据快照
Read View事务启动时记录活跃事务列表,决定数据可见性RR 级别只用一张快照,RC 每次重新拍
Undo Log 链数据行的历史版本串联成链,供 MVCC 读取每个版本都记录着「谁改的」和「上一个」
记录锁锁定单行,粒度最小只锁这一条
间隙锁锁定索引间隙,防止插入RR 级别用来防幻读
Next-Key Lock记录锁 + 间隙锁RR 级别的默认行锁
意向锁表级锁,标记表内有行锁让表锁和行锁能协调工作

8.2 隔离级别与底层实现对照表

隔离级别MVCC 使用Read View 策略间隙锁使用如何保证可重复读
Read Uncommitted❌ 不用❌ 不用不保证
Read Committed✅ 使用每次 SELECT 生成❌ 不用不保证
Repeatable Read✅ 使用首次 SELECT 生成✅ 使用固定 Read View + 间隙锁
Serializable⚠️ 退化无(串行化)✅ 使用强制串行执行

8.3 一句话全文总结

MVCC 为每个事务创建「专属快照」,让读写互不干扰;锁机制在「写-写」冲突时强制排队,保证数据最终一致;Undo Log 提供历史版本,Redo Log 保证崩溃恢复。四者共同撑起了事务隔离性的整片天空。

📌 下篇预告

前三篇文章中,我们从 ACID 聊到了 MVCC,从脏读聊到了间隙锁——理论已经讲得够多了。

但实际开发中,我们该如何选择隔离级别?死锁是怎么产生的?怎么排查?用 Prisma ORM 写事务时有哪些坑?

下一篇文章(第 4 篇),我们将走进生产环境,聊聊事务的实战经验

  • 不同业务场景如何选隔离级别
  • 死锁的成因、排查与预防
  • 大事务的危害与拆分策略
  • Prisma ORM 中的事务操作实践
MVCC 多版本并发控制 + 锁机制:事务隔离级别的底层实现(三)
/posts/2026-6-27/3-mvcc多版本并发控制锁机制事务隔离级别的底层实现/
Author
Atopos
Published at
2026-06-27
License
CC BY-NC-SA 4.0

Some information may be outdated