跳转至

MySQL MVCC 多版本并发控制

💡 一句话概述

MVCC 让普通 SELECT 不加锁、也不被写事务阻塞:靠「行隐藏列 + undo log 版本链 + Read View」三件套,给每个事务发一张"照片",读的时候沿版本链找到照片里该看到的那一版。READ COMMITTED 每读一次重新拍照,REPEATABLE READ 整个事务只拍一张——隔离级别的差别就在 Read View 的创建时机。


🔑 核心概念

  1. 读写不互斥 — 读不加锁、不被写阻塞,写也不被读阻塞;用"多留几个历史版本"这件事换来了并发度。
  2. 隐藏列 DB_TRX_ID / DB_ROLL_PTR — 每行都偷偷带着"最近谁改过我"和"上一个版本在哪"两个字段,是版本链的骨架。
  3. undo log 版本链 — 每次改行都把旧值写进 undo log,roll_ptr 一路串下去,同一逻辑行就有了 v3 → v2 → v1 这样一条链。
  4. Read View(一致性视图) — 事务的"照片",只有四个字段:m_ids、min_trx_id、max_trx_id、creator_trx_id,专门用来判断某个版本对我可见不可见。
  5. 快照读 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 还活跃:

Read View B
  m_ids          = [99, 101]      <-- 100 从名单里消失了
  min_trx_id     = 99
  max_trx_id     = 102
  creator_trx_id = 99

第一版 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 UPDATE
UPDATE / 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 就告警"的监控。


🔗 相关链接