MySQL MVCC 多版本并发控制¶
💡 一句话概述
MVCC 让普通 SELECT 不加锁、也不被写事务阻塞:靠「行隐藏列 + undo log 版本链 + Read View」三件套,给每个事务发一张"照片",读的时候沿版本链找到照片里该看到的那一版。READ COMMITTED 每读一次重新拍照,REPEATABLE READ 整个事务只拍一张——隔离级别的差别就在 Read View 的创建时机。
🔑 核心概念¶
- 读写不互斥 — 读不加锁、不被写阻塞,写也不被读阻塞;用"多留几个历史版本"这件事换来了并发度。
- 隐藏列
DB_TRX_ID/DB_ROLL_PTR— 每行都偷偷带着"最近谁改过我"和"上一个版本在哪"两个字段,是版本链的骨架。 - undo log 版本链 — 每次改行都把旧值写进 undo log,
roll_ptr一路串下去,同一逻辑行就有了 v3 → v2 → v1 这样一条链。 - Read View(一致性视图) — 事务的"照片",只有四个字段:
m_ids、min_trx_id、max_trx_id、creator_trx_id,专门用来判断某个版本对我可见不可见。 - 快照读 vs 当前读 — 普通 SELECT 走 MVCC 不加锁(快照读);
FOR UPDATE/UPDATE/DELETE/INSERT读最新已提交版本并加锁(当前读)。MVCC 只管前者。
📝 详解¶
1. 先说人话:MVCC 到底解决了什么问题¶
想象一个库存表,读的人特别多(查商品详情),写的人也不少(下单扣库存)。
如果没有 MVCC,读也得加锁,世界会变成这样:
| 读的方式 | 谁等谁 | 后果 |
|---|---|---|
| 读加共享锁(S 锁) | 写事务要等所有读事务放锁才能改 | 一个慢查询能卡住整条写入链路 |
| 读加排他锁 | 读和写完全互斥 | 并发直接掉到接近串行 |
| 读不加锁也不做多版本 | 读到别人还没提交的中间值 | 脏读,业务逻辑全错 |
MVCC 的答案很朴素:别抢同一份数据了,我给你留几份旧的。
写事务改行时,把改之前的样子存进 undo log;读事务按自己那张"照片"去链上找对应版本。写只动链头,读只往链下翻,两条路不打架。
一句话记忆:MVCC = 用「历史版本」把「读写冲突」变成「各读各的」。
它不是万能的
MVCC 只解决**读和写**的冲突。**写和写**照样要抢行锁(两个事务同时 UPDATE 同一行,后者必须等前者提交)。这是最常被搞错的一点,见后面的「常见陷阱三」。
2. 三个基础件¶
2.1 隐藏列:DB_TRX_ID 与 DB_ROLL_PTR¶
InnoDB 的每一行记录,除了你 CREATE TABLE 写的列,还额外藏着几个系统列:
| 隐藏列 | 含义 | 什么时候被写 |
|---|---|---|
DB_TRX_ID |
最近修改这一行的事务 ID | 每次 INSERT / UPDATE / DELETE 都更新成本事务的 trx_id |
DB_ROLL_PTR |
回滚指针,指向 undo log 里这一行的上一个版本 | 每次修改前,先把旧版本地址写进来 |
DB_ROW_ID |
没有主键时自动生成的隐藏自增 ID | 与 MVCC 无关,顺带提一下 |
这两个列你 SELECT 不出来
SELECT * FROM t 看不到 DB_TRX_ID。InnoDB 没有对外暴露隐藏列的 SQL 接口,网上有些"查看 DB_TRX_ID 的 SQL"是编的。真实可观察的只有 information_schema.innodb_trx 里的事务 ID 和 SHOW ENGINE INNODB STATUS 里的 undo 堆积指标。
2.2 undo log 版本链¶
undo log 本来是给回滚用的"后悔药"(记着"怎么把这行改回去"),MVCC 顺手把它当成了"历史版本仓库"——一份日志,两个用途:
| 用途 | 谁在用 | 生命周期 |
|---|---|---|
| 事务回滚 / 崩溃恢复时回滚未提交事务 | 写事务自己 | insert undo(记 INSERT 的)事务提交后即可丢弃——那行本来就不存在,没人需要看"插入前的样子" |
| MVCC 提供历史版本 | 别的事务的快照读 | update undo(记 UPDATE / DELETE 的)提交后**不能**丢,必须等到没有任何 Read View 还需要它,由 purge 线程清理 |
每改一次,链就长一节。假设 product 表 id=1 这行被事务 90 → 100 → 101 → 102 依次改过:
聚簇索引里的当前行(最新一版) ← 当前读、写操作动的都是这一份
+------------------------------------------------+
| id = 1 price = 350 |
| DB_TRX_ID = 102 |
| DB_ROLL_PTR = ptr |
+------------------------------------------------+
|
| 顺着 roll_ptr 往 undo log 里找上一个版本
v
+------------------------------------------------+
| undo v3: price = 300 DB_TRX_ID = 101 |
| DB_ROLL_PTR = ptr |
+------------------------------------------------+
|
v
+------------------------------------------------+
| undo v2: price = 200 DB_TRX_ID = 100 |
| DB_ROLL_PTR = ptr |
+------------------------------------------------+
|
v
+------------------------------------------------+
| undo v1: price = 100 DB_TRX_ID = 90 |
| DB_ROLL_PTR = NULL <-- 版本链尽头 |
+------------------------------------------------+
三个要点:
- 链头(聚簇索引里的那份)永远是**最新**的,越往下越**旧**。
- 每个节点都带自己的
DB_TRX_ID,这就是待会儿做可见性判断的依据。 - DELETE 也是走这条链:删除会把行标记为 delete-mark 并留一份 undo 版本,所以别的事务的快照读**还能看见"被删之前"的那一行**。
进阶细节:二级索引上没有版本链
DB_TRX_ID / DB_ROLL_PTR 只存在于聚簇索引记录上。二级索引记录里没有它们,只有页头存了一个 PAGE_MAX_TRX_ID(修改过这个页的最大事务 ID)。快照读走二级索引时:如果 PAGE_MAX_TRX_ID 比 Read View 更老,说明这条索引记录没人动过,可以直接用;否则必须回聚簇索引取版本、再做可见性判断。这也是"覆盖索引更快"的一个隐藏原因。
2.3 Read View:事务的那张"照片"¶
光有版本链还不够——链上那么多版本,我该读哪一个?答案是**拿事务自己的 Read View 去比对**。
Read View 说白了就是一句话:"我拍这张照片的瞬间,世界上有哪些事务还没提交。" 它只有四个字段:
| 字段 | 含义 | 大白话 |
|---|---|---|
m_ids |
创建 Read View 时,系统里**活跃(未提交)**的事务 ID 列表 | "拍照那一刻还活着没交卷的人" |
min_trx_id |
m_ids 中的最小值 |
"这些人里最早开工的那个" |
max_trx_id |
创建 Read View 时,系统**将要分配给下一个事务**的 ID,即当前最大事务 ID + 1 | "下一个来的人的号,比他大的都是未来人" |
creator_trx_id |
创建这个 Read View 的事务自己的 ID | "我自己" |
三件套的关系:
graph LR
subgraph row["行本身"]
H["隐藏列<br/>DB_TRX_ID<br/>DB_ROLL_PTR"]
end
subgraph history["历史(undo log)"]
U["版本链<br/>v3 → v2 → v1"]
end
subgraph view["事务视角"]
RV["Read View<br/>m_ids / min_trx_id<br/>max_trx_id / creator_trx_id"]
end
H -->|"roll_ptr 串起来"| U
RV -->|"拿版本的 trx_id 逐个比对"| H
RV -->|"不可见就往下翻一版"| U
为什么 m_ids 里要算上自己?
自己也是个"活跃未提交事务",所以它也在 m_ids 里。但自己改的数据当然得让自己看见——这就是可见性算法**第一条规则先判 creator_trx_id** 的原因:把"自己改的"提前短路掉,免得被第四条规则误判成不可见。
3. 可见性判断算法(重点,顺序不能乱)¶
拿到版本链上某个版本的 trx_id,**严格按下面顺序**判断:
| 顺序 | 条件 | 结论 | 为什么 |
|---|---|---|---|
| ① | trx_id == creator_trx_id |
✅ 可见 | 自己改的,当然要看见 |
| ② | trx_id < min_trx_id |
✅ 可见 | 比"最早的活跃事务"还老 → 拍照前就已经提交了 |
| ③ | trx_id >= max_trx_id |
❌ 不可见 | 拍照之后才开启的事务,属于"未来人" |
| ④ | min_trx_id <= trx_id < max_trx_id 且 在 m_ids 中 |
❌ 不可见 | 拍照那一刻它还活跃着,还没提交 |
| ④ | min_trx_id <= trx_id < max_trx_id 且 不在 m_ids 中 |
✅ 可见 | 拍照前开过工、且不在活跃名单里 → 说明已经提交了 |
如果不可见:顺着 DB_ROLL_PTR 找上一个版本,用**同一个** Read View 再判一遍;一直往下,直到找到可见版本,或者走到链尽头(roll_ptr = NULL,返回"这行不存在")。
flowchart TD
S(["取版本链上的一个版本<br/>读出它的 trx_id"]) --> Q1{"trx_id 等于<br/>creator_trx_id ?"}
Q1 -->|"是"| V1["✅ 可见<br/>我自己改的"]
Q1 -->|"否"| Q2{"trx_id 小于<br/>min_trx_id ?"}
Q2 -->|"是"| V2["✅ 可见<br/>建视图前就已提交"]
Q2 -->|"否"| Q3{"trx_id 大于等于<br/>max_trx_id ?"}
Q3 -->|"是"| N1["❌ 不可见<br/>建视图后才开启的事务"]
Q3 -->|"否"| Q4{"trx_id 在<br/>m_ids 里 ?"}
Q4 -->|"在"| N2["❌ 不可见<br/>建视图时它还没提交"]
Q4 -->|"不在"| V3["✅ 可见<br/>建视图前已提交"]
N1 --> R{"roll_ptr 还有<br/>上一个版本 ?"}
N2 --> R
R -->|"有"| S
R -->|"没有"| E(["返回空<br/>这行对本事务不存在"])
V1 --> OUT(["返回这一版"])
V2 --> OUT
V3 --> OUT
顺序真的很重要
规则 ① 必须在 ④ 之前,否则"自己改的行"会因为自己也在 m_ids 里而被判成不可见。
规则 ② 必须在 ④ 之前,否则 trx_id < min_trx_id 的老版本会被误塞进区间判断。
面试时**背顺序**比背结论值钱:自己 → 太老 → 太新 → 区间内查名单。
4. 贯穿全文的例子:事务 90 / 99 / 100 / 101 / 102¶
现在把上面所有零件装起来跑一遍。
场景:product 表 id=1 这行,price 初始是 100。
- 事务 90:很久以前把
price改成 100 并提交了(链底)。 - 事务 99:一个**只读**事务,全程只做普通 SELECT,是我们的主角。
- 事务 100 / 101 / 102:陆续来改
price的写事务。
CREATE TABLE product (
id INT PRIMARY KEY,
name VARCHAR(32),
price INT
) ENGINE = InnoDB;
INSERT INTO product VALUES (1, 'gopher 玩偶', 100); -- 事务 90,早已提交
4.1 时间线¶
| 时刻 | 事务 99(读者) | 事务 100 | 事务 101 | 事务 102 | 链上状态 |
|---|---|---|---|---|---|
| T1 | — | — | — | — | price=100,trx_id=90(已提交) |
| T2 | BEGIN |
— | — | — | 同上 |
| T3 | — | BEGIN |
— | — | 同上 |
| T4 | — | UPDATE price=200(拿到行锁,未提交) |
— | — | 链头 200/100,下接 100/90 |
| T5 | SELECT price → 创建 Read View A |
仍未提交 | — | — | 同上 |
| T6 | — | — | BEGIN |
— | 同上 |
| T7 | — | COMMIT |
等行锁… | — | 链头 200/100 已提交 |
| T8 | — | — | UPDATE price=300(未提交) |
— | 300/101 → 200/100 → 100/90 |
| T9 | SELECT price |
— | 仍未提交 | — | 同上 |
| T10 | — | — | COMMIT |
— | 同上 |
| T11 | — | — | — | BEGIN; UPDATE price=350; COMMIT |
350/102 → 300/101 → 200/100 → 100/90 |
| T12 | SELECT price |
— | — | — | 同上 |
关于事务 99 的一个较真点
InnoDB 里**纯只读事务其实不分配 trx_id**(它不修改任何行,也就不会出现在活跃读写事务列表里)。这里为了讲解方便,把它当成一个有 ID 的事务。放心,这不影响任何可见性结论——把它从 m_ids 里去掉,min_trx_id 变成 100,下面每一步的判断结果都一模一样。
4.2 T5:创建 Read View A,第一次读¶
拍照那一刻,谁还没提交?99(自己)和 100。下一个要分配的事务 ID 是 101。
Read View A
m_ids = [99, 100]
min_trx_id = 99
max_trx_id = 101 <-- 下一个将分配的事务 ID = 当前最大 ID(100) + 1
creator_trx_id = 99
此时的版本链(注意:100 还没提交,但聚簇索引里的行**已经**被改成 200 了——InnoDB 是"先改后提交",靠锁挡住别人):
+------------------------------------------------+
| 当前行 price = 200 DB_TRX_ID = 100 | <-- 未提交
+------------------------------------------------+
|
v
+------------------------------------------------+
| undo v1: price = 100 DB_TRX_ID = 90 | <-- 已提交
+------------------------------------------------+
走一遍算法(trx_id = 100):
| 步 | 判断 | 结果 |
|---|---|---|
| ① | 100 == creator_trx_id(99)? |
否 |
| ② | 100 < min_trx_id(99)? |
否 |
| ③ | 100 >= max_trx_id(101)? |
否(100 < 101) |
| ④ | 99 <= 100 < 101,落在区间内。100 在 m_ids=[99,100] 里吗? |
在 → 不可见 ❌ |
不可见 → 顺 roll_ptr 翻到 undo v1(trx_id = 90):
| 步 | 判断 | 结果 |
|---|---|---|
| ① | 90 == 99? |
否 |
| ② | 90 < min_trx_id(99)? |
是 → 可见 ✅ |
事务 99 读到 price = 100。 而磁盘上的行明明是 200。这就是 MVCC:没加一把锁,没等事务 100,读的是 undo 里的旧版本。
4.3 T9(RR 下):复用 Read View A¶
现在链长这样:
+------------------------------------------------+
| 当前行 price = 300 DB_TRX_ID = 101 | <-- 未提交
+------------------------------------------------+
|
v
+------------------------------------------------+
| undo v2: price = 200 DB_TRX_ID = 100 | <-- T7 已提交
+------------------------------------------------+
|
v
+------------------------------------------------+
| undo v1: price = 100 DB_TRX_ID = 90 | <-- 已提交
+------------------------------------------------+
REPEATABLE READ 下**不新建 Read View,继续用 A**。
第一版 trx_id = 101:
| 步 | 判断 | 结果 |
|---|---|---|
| ① | 101 == 99? |
否 |
| ② | 101 < 99? |
否 |
| ③ | 101 >= max_trx_id(101)? |
是 → 不可见 ❌(101 是 T6 才开的,在照片之后) |
翻到 undo v2,trx_id = 100:
| 步 | 判断 | 结果 |
|---|---|---|
| ① | 100 == 99? |
否 |
| ② | 100 < 99? |
否 |
| ③ | 100 >= 101? |
否 |
| ④ | 落在 [99, 101),100 在 m_ids=[99,100] 里吗? |
在 → 不可见 ❌ |
这一步最反直觉,也最关键:事务 100 明明在 T7 就提交了!但 Read View A 是 T5 拍的,它的 m_ids 把"100 未提交"这个事实**冻结**住了。A 不知道后来发生了什么——照片不会自己更新。
翻到 undo v1,trx_id = 90: 90 < 99 → 可见 ✅
事务 99 在 T9 仍然读到 price = 100。 和 T5 一模一样 → 这就是可重复读。
4.4 T9(RC 下):新建 Read View B¶
换成 READ COMMITTED,T9 会**重新拍照**。此刻 100 已提交、101 还活跃:
第一版 trx_id = 101: ①否 → ②否 → ③ 101 >= 102?否 → ④ 落在 [99,102),101 在 m_ids 里 → 不可见 ❌
翻到 undo v2,trx_id = 100:
| 步 | 判断 | 结果 |
|---|---|---|
| ① | 100 == 99? |
否 |
| ② | 100 < 99? |
否 |
| ③ | 100 >= 102? |
否 |
| ④ | 落在 [99, 102),100 在 m_ids=[99,101] 里吗? |
不在 → 可见 ✅ |
事务 99 在 RC 下 T9 读到 price = 200。 和 T5 读的 100 不一样了 → 这就是不可重复读。
同一份版本链、同一套算法,只因为 Read View 换没换,结果就完全不同。
4.5 收尾:不同 Read View 下的结果对比¶
把前面几步横向摆一起。注意**版本链一直在变长,算法一直是同一套**,唯一的变量是手上那张照片:
| 时刻 | 用的 Read View | 当时的版本链(新→旧) | 判断路径 | 读到 |
|---|---|---|---|---|
| T5 | A m_ids=[99,100]max=101 |
200/100 → 100/90 |
100:④在名单 → 不可见;90:②可见 |
100 |
| T9(RR) | 复用 A | 300/101 → 200/100 → 100/90 |
101:③101>=101 不可见;100:④在名单 不可见;90:②可见 |
100 |
| T9(RC) | 新建 B m_ids=[99,101]max=102 |
同上 | 101:④在名单 不可见;100:④**不在**名单 → 可见 |
200 |
| T12(RR) | 仍复用 A | 350/102 → 300/101 → 200/100 → 100/90 |
102:③ 不可见;101:③ 不可见;100:④在名单 不可见;90:②可见 |
100 |
| T12(RC) | 新建 C m_ids=[99]max=103(全部已提交) |
同上 | 102:④不在名单 → 可见 |
350 |
RR 三次读下来死死咬住 100;RC 一路 100 → 200 → 350 跟着真实世界走。
RC 不是读不到新数据,只是每次读都要重新问一遍
T12 的 RC 读到 350,走的是规则④的"不在名单 → 可见"分支;T9 的 RC 读到 200,走的也是④。同一套算法,输入(Read View)不同,输出就不同。 这就是为什么面试问"RC 和 RR 的区别",标准答案只需要一句话:Read View 什么时候建、建几次。
5. Read View 的创建时机 = 隔离级别的分水岭¶
这是面试最高频的一问,结论先给:
| READ COMMITTED (RC) | REPEATABLE READ (RR) | |
|---|---|---|
| Read View 创建时机 | **每一条**普通 SELECT 都新建一个 | 事务内**第一次**快照读时创建,之后**全程复用** |
| 能看到别人后来的提交吗 | 能(每次读都刷新视野) | 不能(视野冻结在第一次读) |
| 不可重复读 | ✅ 会发生 | ❌ 不会 |
| 幻读(快照读层面) | ✅ 会发生 | ❌ 被 MVCC 挡住 |
| undo log 压力 | 小(视图活得很短,purge 跟得上) | 大(视图要活到事务结束) |
| MySQL 默认 | 不是(很多互联网公司手动改成 RC) | ✅ InnoDB 默认 |
再往外扩一层,四个隔离级别下 MVCC 的参与度完全不同:
| 隔离级别 | Read View | 读的方式 | 允许的现象 |
|---|---|---|---|
| READ UNCOMMITTED | ❌ 不建 | 直接读版本链**链头**,哪怕它还没提交 | 脏读、不可重复读、幻读 |
| READ COMMITTED | ✅ 每条 SELECT 新建 | 快照读 | 不可重复读、幻读 |
| REPEATABLE READ(默认) | ✅ 首次快照读建,全程复用 | 快照读 + 当前读配 Next-Key Lock | 基本都没有(当前读混用时仍可能幻读) |
| SERIALIZABLE | ⚠️ 基本不用 | 普通 SELECT 被悄悄升级成 LOCK IN SHARE MODE,全走当前读 |
都没有,但并发极差 |
时间 ───────────────────────────────────────────────────────────▶
T5 T9 T12
│ │ │
RR 下: SELECT① ───── SELECT② ─────── SELECT③
建 Read View A 复用 A 复用 A
│ │ │
▼ ▼ ▼
price=100 price=100 price=100 ✅ 可重复读
RC 下: SELECT① ───── SELECT② ─────── SELECT③
建 Read View A 新建 B 新建 C
│ │ │
▼ ▼ ▼
price=100 price=200 price=350 ❌ 不可重复读
外部世界: 事务100 提交 事务101 提交 事务102 提交
price 100→200 price 200→300 price 300→350
5.1 SQL 时序例子一:REPEATABLE READ(默认)¶
-- ============ 会话 A(读者,事务 99)============
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
-- ============ 会话 B(写者,事务 100)============
BEGIN;
UPDATE product SET price = 200 WHERE id = 1; -- 拿到 id=1 的行锁,未提交
-- ============ 会话 A ============
SELECT price FROM product WHERE id = 1; -- ★ 100
-- 此刻建 Read View A
-- 链头 200/trx=100 在 m_ids 里 → 不可见
-- 回溯到 100/trx=90 → 可见
-- 注意:没有等会话 B 的锁
-- ============ 会话 B ============
COMMIT; -- price=200 正式生效
-- ============ 会话 A ============
SELECT price FROM product WHERE id = 1; -- ★ 还是 100
-- 复用 Read View A
-- A 的 m_ids 冻结了"100 未提交"
COMMIT;
SELECT price FROM product WHERE id = 1; -- ★ 200
-- 事务结束后新事务重新拍照
5.2 SQL 时序例子二:READ COMMITTED¶
-- ============ 会话 A ============
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
-- ============ 会话 B ============
BEGIN;
UPDATE product SET price = 200 WHERE id = 1;
-- ============ 会话 A ============
SELECT price FROM product WHERE id = 1; -- ★ 100(Read View A,B 未提交)
-- ============ 会话 B ============
COMMIT;
-- ============ 会话 A ============
SELECT price FROM product WHERE id = 1; -- ★ 200 ← 同一条 SQL,值变了
-- 重新建了 Read View B
-- B 的 m_ids 里已经没有 100
-- → 规则④「不在名单里」→ 可见
COMMIT;
RC 也不是脏读
上面 RC 读到 200 的前提是会话 B 已经 COMMIT。如果 B 还没提交,会话 A 新建的 Read View 里 B 依然在 m_ids 中,照样读不到 200。RC 只是"愿意接受新的已提交数据",不是"愿意接受未提交数据"。
6. 快照读 vs 当前读¶
MVCC 只对快照读生效。这是必须刻进脑子里的一条边界。
| 快照读(Snapshot Read) | 当前读(Current Read) | |
|---|---|---|
| 别名 | 一致性非锁定读 | 加锁读 |
| 哪些语句 | 普通 SELECT |
SELECT ... LOCK IN SHARE MODE(8.0 推荐 FOR SHARE)SELECT ... FOR UPDATEUPDATE / DELETE / INSERT |
| 读哪个版本 | 沿版本链找 Read View 可见的那一版 | 直接读**最新已提交**版本 |
| 加不加锁 | ❌ 不加锁 | ✅ 加锁(Record / Gap / Next-Key) |
| 建不建 Read View | 建(或复用) | 不建 |
| 会被写阻塞吗 | 不会 | 会(要等对方释放行锁) |
一条读请求进来
|
+---------------+---------------+
| |
普通 SELECT SELECT ... FOR UPDATE
(快照读) SELECT ... LOCK IN SHARE MODE
| UPDATE / DELETE / INSERT
| (当前读)
| |
不加任何锁 加锁:Record / Gap / Next-Key
| |
用事务的 Read View 不建 Read View
| |
沿版本链回溯找可见版本 直接读聚簇索引里的
| 最新已提交版本
v v
读不阻塞写、写不阻塞读 写写互斥、读会等锁
—— MVCC 的主战场 —— 锁的主战场
为什么 UPDATE / DELETE 必须是当前读?
如果 UPDATE product SET price = price + 1 WHERE id = 1 走快照读,它可能拿着一个 5 分钟前的旧 price 去计算,然后覆盖掉别人已经提交的结果——丢失更新。所以所有写操作都必须读最新已提交版本,并且加锁。这不是实现偷懒,是正确性要求。
7. MVCC 与锁的配合:RR 下怎么防幻读¶
幻读 = 同一事务内,两次执行同一个范围查询,**行数**变了(别人插了新行)。它和不可重复读的区别是:不可重复读关心"同一行的值变了",幻读关心"结果集多了一行/少了一行"。
InnoDB 在 RR 下用**两条腿**防幻读,一条都不能少:
| 读的类型 | 防幻读靠什么 | 原理 |
|---|---|---|
| 快照读(普通 SELECT) | MVCC | 整个事务复用同一个 Read View;别人 INSERT 的新行 trx_id >= max_trx_id,规则③直接判不可见 |
当前读(FOR UPDATE / UPDATE / DELETE) |
Next-Key Lock | 锁住扫描范围,别人**插不进来** |
锁三兄弟:
| 锁 | 锁什么 | 作用 |
|---|---|---|
| Record Lock(行锁) | 索引上的**某一条具体记录** | 防止别人改/删这一行 |
| Gap Lock(间隙锁) | 索引记录**之间的空隙**(不含记录本身) | 专门防止别人往这个空隙里 INSERT;间隙锁之间通常不互斥 |
| Next-Key Lock(临键锁) | Record Lock + Gap Lock,**左开右闭**区间 | RR 下当前读防幻读的主力 |
sequenceDiagram
participant A as 会话 A(RR)
participant DB as InnoDB
participant B as 会话 B
A->>DB: SELECT * FROM product WHERE price >= 100
Note over A,DB: 快照读:建 Read View,1 行
B->>DB: INSERT INTO product VALUES (2,'新玩偶',150)
DB-->>B: 成功(快照读不加锁,挡不住插入)
B->>DB: COMMIT
A->>DB: SELECT * FROM product WHERE price >= 100
Note over A,DB: 快照读:复用 Read View<br/>新行 trx_id ≥ max_trx_id → 不可见<br/>仍然 1 行 ✅ MVCC 挡住了幻读
A->>DB: SELECT * FROM product WHERE price >= 100 FOR UPDATE
Note over A,DB: 当前读!读到 2 行,并对区间加 Next-Key Lock
注意上图最后一步
一旦在 RR 事务里用了 FOR UPDATE(当前读),就会看到快照读看不到的那一行——MVCC 的"可重复"承诺在这里失效。这正是「常见陷阱一」的现场。
8. purge:undo log 不能无限留¶
版本链越攒越长,undo log 迟早撑爆磁盘,快照读也会越翻越慢。所以 InnoDB 有个后台 **purge 线程**专门回收。
purge 线程的判断(简化)
=========================
InnoDB 内部维护「所有存活 Read View」的信息
|
v
+-------------------------------------------+
| 扫回滚段里的一条 undo 版本记录 |
| |
| 问:还有没有任何一个存活的 Read View, |
| 可能需要看到这个旧版本? |
+-------------------------------------------+
| |
没有 还有
| |
v v
回收这块 undo 原地保留,谁都不能动
History list length 下降 长事务就卡在这里
→ 版本链越拖越长
→ 快照读一路回溯,越查越慢
判断依据正是 Read View,但要注意方向:purge 的进度由「最老的那个存活 Read View」一个人说了算。 只有当所有存活的 Read View 都会选择比某个版本**更新**的版本时,这个版本以及它下面更旧的一串才能被回收。
所以一个长期不提交的事务,会独自把整条版本链的回收进度**钉死**在最老那一版——这就是长事务能把 History list length 从几百拖到几百万的全部原因。
| 相关参数 | 说明 | 默认值 |
|---|---|---|
innodb_purge_threads |
purge 后台线程数 | 4 |
innodb_purge_batch_size |
单次 purge 处理的 undo log 页数 | 300 |
innodb_max_purge_lag |
允许的 History list 最大长度,超了会**主动延迟 DML** | 0(不限制) |
innodb_undo_log_truncate |
是否允许 undo 表空间截断回收(8.0) | ON |
观测手段(这两个是真的能用的):
-- History list length:还没被 purge 掉的 undo 版本数量,越大越危险
SHOW ENGINE INNODB STATUS\G
-- 在输出的 TRANSACTIONS 段里找:
-- History list length 3147
-- 谁在拖着不让 purge 干活?找最老的活跃事务
SELECT trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_sec,
trx_mysql_thread_id AS conn_id,
trx_rows_modified,
trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started
LIMIT 10;
9. MVCC 不是免费的¶
| 代价 | 说明 | 应对 |
|---|---|---|
| 空间 | 每次改行都要多存一份 undo 版本 | 靠 purge 回收;别让长事务堵住它 |
| 时间 | 快照读可能要沿链回溯好几跳才找到可见版本 | 控制事务长度;避免热点行被高频更新 |
| 回滚段膨胀 | 长事务把 undo 全钉住,History list length 飙升 |
监控 + 杀长事务;innodb_max_purge_lag 兜底 |
| 写写仍要锁 | MVCC 完全不解决两个事务改同一行 | 老实排队等行锁 |
💻 代码示例¶
SQL:一次完整的可重复读验证¶
-- 1. 确认自己在哪个隔离级别
-- MySQL 8.0 用 transaction_isolation,5.7 用 tx_isolation
SELECT @@global.transaction_isolation AS global_level,
@@session.transaction_isolation AS session_level;
-- 2. 准备数据
DROP TABLE IF EXISTS product;
CREATE TABLE product (
id INT PRIMARY KEY,
name VARCHAR(32),
price INT
) ENGINE = InnoDB;
INSERT INTO product VALUES (1, 'gopher 玩偶', 100);
-- 3. 观察自己的事务 ID(用来跟 Read View 的字段对应上)
BEGIN;
SELECT trx_id, trx_state, trx_started
FROM information_schema.innodb_trx
WHERE trx_mysql_thread_id = CONNECTION_ID();
-- 注意:纯只读事务在真正读数据前,这里可能查不到记录
-- (InnoDB 不给只读事务分配 trx_id)
ROLLBACK;
SQL:亲手复现「快照读 + 当前读混用」的幻读¶
-- ============ 会话 A(RR)============
BEGIN;
SELECT * FROM product WHERE price >= 100;
-- 1 行:(1, 'gopher 玩偶', 100)
-- ============ 会话 B ============
INSERT INTO product VALUES (2, '新玩偶', 150);
COMMIT;
-- ============ 会话 A ============
SELECT * FROM product WHERE price >= 100;
-- 还是 1 行 ← MVCC 生效,新行 trx_id >= max_trx_id,不可见
UPDATE product SET price = price + 1 WHERE price >= 100;
-- 影响 2 行! ← 当前读,读到了最新已提交数据,id=2 也被改了
-- 而且 id=2 的 DB_TRX_ID 被写成了会话 A 自己的 trx_id
SELECT * FROM product WHERE price >= 100;
-- 变成 2 行了 ← 幻读出现
-- 原因:id=2 的 trx_id 现在等于 creator_trx_id,命中可见性规则①
COMMIT;
Go:database/sql 的只读事务¶
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
_ "github.com/go-sql-driver/mysql"
)
// readPriceTwice 演示 REPEATABLE READ 下的可重复读。
//
// 要点:Go 侧能控制的只有「隔离级别」和「是否只读」两件事,
// Read View 什么时候建、沿哪条版本链回溯,全是 InnoDB 内部行为,
// 应用代码既看不到也管不着。
func readPriceTwice(ctx context.Context, db *sql.DB, id int) (int, error) {
// 给事务加超时:Read View 活得越久,undo log 越清不掉。
// 这个 timeout 本质上是在给「长事务」上保险。
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
tx, err := db.BeginTx(ctx, &sql.TxOptions{
// 驱动会先发 SET TRANSACTION ISOLATION LEVEL REPEATABLE READ
Isolation: sql.LevelRepeatableRead,
// 驱动会发 START TRANSACTION READ ONLY;
// InnoDB 不会给纯只读事务分配 trx_id,也保证你不会误写
ReadOnly: true,
})
if err != nil {
return 0, fmt.Errorf("begin tx: %w", err)
}
// 兜底:任何一条 return 路径都能把事务关掉,避免连接带着
// 一个存活的 Read View 被还回连接池(这是长事务的头号来源)。
// Commit 成功后 Rollback 返回 sql.ErrTxDone,忽略即可。
defer func() { _ = tx.Rollback() }()
var first, second int
// ★ 第一条 SELECT 就在这里创建了 Read View A
if err := tx.QueryRowContext(ctx,
`SELECT price FROM product WHERE id = ?`, id).Scan(&first); err != nil {
return 0, fmt.Errorf("first read: %w", err)
}
// ★ 第二条 SELECT 复用 Read View A。
// 即使这期间别的事务把 price 改了并提交,这里仍然读到 first。
if err := tx.QueryRowContext(ctx,
`SELECT price FROM product WHERE id = ?`, id).Scan(&second); err != nil {
return 0, fmt.Errorf("second read: %w", err)
}
if first != second {
// RR 下不该发生。真发生了,八成是:
// ① 隔离级别被 DSN 或连接池配置改成了 RC;
// ② 中间夹了 FOR UPDATE / UPDATE 之类的当前读。
return 0, fmt.Errorf("unexpected non-repeatable read: %d != %d", first, second)
}
if err := tx.Commit(); err != nil && !errors.Is(err, sql.ErrTxDone) {
return 0, fmt.Errorf("commit: %w", err)
}
return first, nil
}
不开事务的 db.Query 等于每次都是新照片
Go 里直接 db.QueryRow(...) 而不开事务,MySQL 是 autocommit:每条语句自成一个小事务,各建各的 Read View。所以两次 db.QueryRow 之间完全可能读到不同的值——行为上等同于 READ COMMITTED,哪怕你的 session 隔离级别是 RR。要可重复读,必须显式 BeginTx。
// ❌ 反例:把慢操作塞进事务,Read View 被迫长期存活
func badLongTransaction(ctx context.Context, db *sql.DB, id int) error {
tx, _ := db.BeginTx(ctx, nil) // RR,读写事务
defer tx.Rollback()
var price int
tx.QueryRowContext(ctx,
`SELECT price FROM product WHERE id = ?`, id).Scan(&price)
// ↑ Read View A 从这一刻开始存活
resp, err := callInventoryRPC(ctx, id) // ← 一次 300ms~数秒 的跨服务调用
if err != nil { // 这期间 A 一直钉着 undo log,
return err // purge 一点都清不动
}
_, err = tx.ExecContext(ctx,
`UPDATE product SET price = ? WHERE id = ?`, resp.Price, id)
if err != nil {
return err
}
return tx.Commit()
}
// ✅ 正解:RPC 放事务外,事务里只做 DB 操作,尽量短
func goodShortTransaction(ctx context.Context, db *sql.DB, id int) error {
resp, err := callInventoryRPC(ctx, id) // 先做慢的、非 DB 的事
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`UPDATE product SET price = ? WHERE id = ?`, resp.Price, id); err != nil {
return err
}
return tx.Commit() // 事务存活时间 = 两条 SQL 的耗时
}
⚠️ 常见陷阱¶
陷阱一:以为 RR 下 MVCC 能挡住一切幻读
现象:RR 事务里先 SELECT 看到 1 行,然后 UPDATE / DELETE 却发现影响了 2 行,再 SELECT 变成 2 行。
原因:MVCC 只管**快照读**。UPDATE / DELETE / SELECT ... FOR UPDATE 是**当前读**,读的是最新已提交版本,会碰到别人后来插入的行;一旦被本事务改过,这行的 DB_TRX_ID 就变成了 creator_trx_id,命中可见性规则①,于是后续的快照读也能看见它了——快照被自己"戳穿"。
解决:RR 防幻读是"MVCC + Next-Key Lock"两条腿,只要事务里出现当前读,就要按当前读的规则想问题。需要严格一致的范围,一开始就用 SELECT ... FOR UPDATE 把区间锁住(此时会加 Next-Key Lock 阻止插入),别先快照读再改。
陷阱二:长事务——MVCC 最贵的账单
现象:磁盘慢慢涨满、History list length 从几百飙到几百万、普通 SELECT 越来越慢、最后可能大面积超时。
原因:一个事务开着不提交(典型:Go 代码 BeginTx 后忘了 Rollback、事务里夹了 HTTP/RPC 调用、人工在客户端 BEGIN 之后去开了个会),它的 Read View 就一直存活。purge 线程不敢回收任何它可能需要的 undo 版本,于是:
- undo 表空间只增不减;
- 热点行的版本链越拖越长,每次快照读都要一路回溯,读放大严重;
- 极端情况下 innodb_max_purge_lag 触发,开始主动给 DML 加延迟。
解决:① 事务体里**绝不**放网络调用、大循环、人工交互;② defer tx.Rollback() 兜底 + context.WithTimeout;③ 连接池设 ConnMaxLifetime,防止连接带着脏事务被复用;④ 上线监控 information_schema.innodb_trx 里 running_sec 超阈值的事务,必要时 KILL。
陷阱三:以为用了 MVCC 就完全不需要锁
现象:两个 goroutine 并发扣库存,都用了事务,结果库存少扣了。
原因:MVCC 解决的是**读写冲突**(读不阻塞写、写不阻塞读),写写冲突照样要抢行锁。两个事务同时 UPDATE ... WHERE id = 1,后者必须阻塞等前者提交;更危险的是 SELECT price 然后应用层算一算再 UPDATE price = 新值——两条语句之间别人可能已经改过了,这就是**丢失更新**,MVCC 一点忙都帮不上。
解决:需要"读了再决定"的场景,用当前读 + 锁:SELECT ... FOR UPDATE;或者干脆用原子 SQL UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock >= 1,靠行锁 + 条件判断一次搞定。
陷阱四:以为 RR 的快照是 BEGIN 那一刻建的
现象:BEGIN; 之后隔了几秒才 SELECT,结果读到了这几秒内别人提交的数据,觉得"RR 不生效"。
原因:InnoDB 的 RR 不是**在 BEGIN / START TRANSACTION 时创建 Read View,而是在事务里**第一次执行快照读**时才创建。(想真的把视图钉在事务开始那一刻,得用 START TRANSACTION WITH CONSISTENT SNAPSHOT。)这也意味着:**同一份代码在 RC 和 RR 下的 Read View 数量差异,取决于你 SELECT 了几次,而不是事务开了多久。
解决:需要"事务开始即快照"的语义时显式使用 START TRANSACTION WITH CONSISTENT SNAPSHOT;写业务代码时记住"第一条 SELECT 才是拍照时刻"。
🏋️ 练习题¶
练习 1:给定 Read View m_ids = [100, 101]、min_trx_id = 100、max_trx_id = 103、creator_trx_id = 99,请分别判断 trx_id 为 98、100、102、103 的四个版本是否可见,并说明命中哪条规则。
答案
按「自己 → 太老 → 太新 → 区间内查名单」的顺序走:
| trx_id | 判断过程 | 结论 |
|---|---|---|
| 98 | ① 98 != 99;② 98 < min_trx_id(100) 成立 |
✅ 可见(创建视图前就已提交) |
| 100 | ① 否;② 100 < 100 不成立;③ 100 >= 103 不成立;④ 落在 [100,103),100 在 m_ids 中 |
❌ 不可见(拍照时它还活跃、未提交) |
| 102 | ①②③ 均否;④ 落在 [100,103),102 不在 m_ids=[100,101] 中 |
✅ 可见(拍照前开启且已提交) |
| 103 | ① 否;② 否;③ 103 >= max_trx_id(103) 成立 |
❌ 不可见(拍照之后才开启的事务) |
不可见的两个版本,都要沿 DB_ROLL_PTR 翻到更旧的版本,用同一个 Read View 重判。
练习 2:为什么 READ COMMITTED 会发生不可重复读,而 REPEATABLE READ 不会?请从 Read View 的角度解释,并说明「事务 100 已经提交了,RR 下为什么还看不到它」。
答案
差别只在 Read View 的创建时机,底层的版本链和可见性算法完全一样。
- RC:每执行一次普通 SELECT 都**新建**一个 Read View。前一个视图里
m_ids中的事务如果在这期间提交了,新视图的m_ids里就没有它了,规则④会判"不在名单 → 可见",于是读到新值 → 同一行两次读值不同 = 不可重复读。 - RR:只在事务内**第一次**快照读时创建 Read View,之后全程复用。旧视图的
m_ids把"事务 100 未提交"这个事实**冻结**住了,即使 100 后来真的提交了,规则④仍然认为它在名单里 → 不可见 → 继续回溯到更老的版本 → 两次读结果一致 = 可重复读。
关键点:Read View 是一张**照片**,不是实时监控。照片拍完之后世界怎么变,照片本身不会更新。RR 的"可重复"就是靠这种"故意视而不见"实现的,代价是 undo log 必须留到事务结束才能 purge。
练习 3:线上 MySQL 的 History list length 从 500 涨到了 300 万,同时业务反馈「简单的主键查询越来越慢」。请分析可能的原因、MVCC 层面的解释,以及排查和处置步骤。
答案
原因:有长事务(Read View 长期存活),purge 线程无法回收任何它可能需要的 undo 版本,导致未清理的版本不断堆积。
为什么主键查询也变慢:History list length 高意味着热点行的**版本链被拖得很长**。快照读必须从链头开始,拿 Read View 逐版比对、沿 roll_ptr 一路回溯,直到找到可见版本。原本一跳就命中的查询,可能要翻几十上百跳,还伴随大量随机 IO 读 undo 页——这就是"读放大"。
排查步骤:
-- ① 确认堆积规模
SHOW ENGINE INNODB STATUS\G -- 看 History list length
-- ② 找出最老的活跃事务
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_sec,
trx_mysql_thread_id AS conn_id, trx_rows_modified, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started LIMIT 10;
-- ③ 顺着 conn_id 找是谁的连接(IP、用户名、当前 SQL)
SELECT processlist_id, processlist_user, processlist_host, processlist_db
FROM performance_schema.threads
WHERE processlist_id = <上面的 conn_id>;
处置:
1. 紧急:确认无误后 KILL <conn_id> 掉那个长事务(注意它可能需要长时间回滚)。
2. 短期:设 innodb_max_purge_lag 给 DML 加背压,避免继续恶化;观察 purge 追上进度。
3. 根治:改代码——事务里不许有 RPC / HTTP / 人工交互,defer tx.Rollback() 兜底,context.WithTimeout 限时长,连接池设 ConnMaxLifetime;再加一条"innodb_trx 中 running_sec > N 就告警"的监控。
🔗 相关链接¶
- MySQL 官方文档:Consistent Nonlocking Reads — 快照读与 Read View 的权威定义
- MySQL 官方文档:Transaction Isolation Levels — 四种隔离级别下 InnoDB 的具体行为
- MySQL 官方文档:InnoDB Locking — Record / Gap / Next-Key Lock 详解,配合 MVCC 一起看
- MySQL 官方文档:InnoDB UNDO Tablespaces — undo 表空间与截断回收(purge 相关参数在此章)
- go-sql-driver/mysql — Go 侧
sql.TxOptions最终就是发给它的SET TRANSACTION ISOLATION LEVEL - 《MySQL 技术内幕:InnoDB 存储引擎》 — MVCC、锁、事务章节的中文经典参考