Skip to content

MySQL MVCC ​

MVCC 是 Multi-Version Concurrency Control,多版本并发控制。它让 InnoDB 在很多情况下做到“读不阻塞写,写不阻塞普通读”。

一句话理解:

MVCC 不是让数据没有锁,而是让普通查询可以根据 Read View 去读取某个历史版本,从而减少读写互相等待。

为什么需要 MVCC ​

如果没有 MVCC,读写并发通常只能依赖锁:

mermaid
flowchart TD
    A["事务 A 正在 update 一行"] --> B["持有写锁"]
    B --> C["事务 B 想 select 同一行"]
    C --> D["如果只能读最新值,就必须等待写锁释放"]

这样读多写少的业务会被大量写操作阻塞。MVCC 的做法是:写操作产生新版本,普通读根据快照判断能看到哪个版本。

mermaid
flowchart TD
    A["事务 A 修改数据"] --> B["当前行变成新版本"]
    B --> C["旧版本进入 undo log 版本链"]
    D["事务 B 普通 select"] --> E["根据 Read View 判断可见版本"]
    E --> F["返回可见的旧版本或新版本"]

MVCC 解决什么问题 ​

问题MVCC 的作用
普通读被写阻塞普通读可以读历史可见版本
同一事务多次读不一致RR 下复用同一个 Read View
已提交数据什么时候可见RC 下每次查询生成新 Read View
回滚怎么恢复旧值undo log 保存旧版本

MVCC 主要服务普通 select,也叫快照读。update、delete、select ... for update 是当前读,仍然需要加锁。

核心概念 ​

概念说明
trx_id记录最近修改该行的事务 ID
roll_pointer指向 undo log 中的旧版本
undo log保存修改前的旧版本,用于回滚和快照读
Read View一致性读视图,用来判断某个版本是否可见
版本链当前记录通过 roll_pointer 串起多个历史版本

这里最容易混淆的是 undo log。很多人只记得“undo log 用于回滚”,但在 InnoDB 里,undo log 同时也是 MVCC 旧版本链的载体。

MVCC 和 undo log 到底是什么关系 ​

先给结论:

MVCC 的“多版本”不是复制了多份完整表数据,而是当前记录加上 undo log 中的历史版本。Read View 负责判断哪个版本可见,undo log 负责保存找得到的旧版本。

也就是说,MVCC 至少依赖三样东西:

组件在 MVCC 中的作用
当前聚簇索引记录保存最新版本的数据和隐藏字段
undo log 版本链保存一次次修改前的旧版本
Read View判断当前事务应该看哪个版本
mermaid
flowchart TD
    A["普通 select"] --> B["生成或复用 Read View"]
    B --> C["读取当前记录"]
    C --> D{"当前版本对 Read View 可见吗"}
    D -- "可见" --> E["返回当前版本"]
    D -- "不可见" --> F["根据 roll_pointer 找 undo 旧版本"]
    F --> G{"旧版本可见吗"}
    G -- "可见" --> H["返回旧版本"]
    G -- "不可见" --> F

所以两句话要分清:

  1. undo log 不是 MVCC 本身,但 MVCC 找历史版本主要靠 undo log。
  2. Read View 不保存行数据,它只保存事务可见性判断信息。

如果没有 undo log,事务可以通过锁阻塞来保证一致性,但普通查询就很难在不阻塞写入的情况下读到历史版本。如果没有 Read View,即使 undo log 里有旧版本,事务也不知道哪个版本对自己可见。

undo log 本身解决什么问题 ​

undo log 有两个核心作用:

作用解释
事务回滚事务失败时,根据 undo log 把数据恢复到修改前
MVCC 快照读普通 select 需要历史版本时,沿 undo log 版本链查找

假设原始数据是:

sql
id = 1, name = 'Tom'

事务执行:

sql
update mvcc_user
set name = 'Jerry'
where id = 1;

InnoDB 会把“修改前的 Tom”记录到 undo log 中,然后当前记录变成 Jerry。事务如果回滚,就根据 undo log 把 Jerry 改回 Tom;其他事务如果根据 Read View 不能看到 Jerry,也可以沿 undo log 找到 Tom。

mermaid
flowchart TD
    A["更新前当前记录 name=Tom"] --> B["生成 undo log:旧值 Tom"]
    B --> C["当前记录改为 name=Jerry"]
    C --> D{"事务后续结果"}
    D -- "rollback" --> E["根据 undo 恢复 Tom"]
    D -- "commit" --> F["Jerry 成为已提交版本"]
    F --> G["旧 Read View 仍可能通过 undo 看到 Tom"]

注意:事务提交不代表 undo log 立刻删除。只要还有旧 Read View 可能需要这个版本,undo log 就不能被清理。

undo log 的两类常见形态 ​

从用途上可以先这样理解:

类型产生场景特点
insert undo插入新记录主要用于事务回滚,事务提交后通常可以较快清理
update undo更新或删除记录既用于回滚,也可能用于 MVCC 版本链

为什么 insert undo 提交后通常更容易清理?因为一条记录如果是某个未提交事务新插入的,其他事务本来就看不到它;提交之后,它没有“插入前的旧版本”要给别人读。update undo 不同,更新前的旧值可能仍然被其他旧 Read View 读取,所以不能马上删。

删除也不是简单地把物理记录立刻从页里抹掉。InnoDB 通常会先做删除标记,旧版本仍然可能被 MVCC 读取;等确认没有事务需要旧版本后,再由 purge 线程真正清理。

隐藏字段如何串起版本链 ​

InnoDB 聚簇索引记录中有几个和 MVCC 密切相关的隐藏信息:

隐藏信息作用
DB_TRX_ID最近一次修改该行的事务 ID
DB_ROLL_PTR指向 undo log 中上一个历史版本
DB_ROW_ID没有显式主键时生成的隐藏行 ID

这三个字段不用在 select * 里看到,但要理解它们的职责。

mermaid
flowchart TD
    A["聚簇索引当前记录\nname=Jerry\nDB_TRX_ID=120\nDB_ROLL_PTR=p1"] --> B["undo 版本 p1\nname=Tom\ntrx_id=110\nroll_ptr=p0"]
    B --> C["undo 版本 p0\nname=Alice\ntrx_id=100"]

普通查询读取当前记录后,会拿记录上的 DB_TRX_ID 和自己的 Read View 做比较:

  1. 如果当前版本可见,直接返回。
  2. 如果当前版本不可见,通过 DB_ROLL_PTR 找到 undo 旧版本。
  3. 对旧版本继续做可见性判断。
  4. 直到找到可见版本,或者发现没有可见版本。

这就是版本链。

版本链 ​

当一行数据被多次修改时,InnoDB 会通过 undo log 保留旧版本。

mermaid
flowchart TD
    A["当前记录<br/>name=Jerry<br/>trx_id=120"] --> B["undo 版本 1<br/>name=Tom<br/>trx_id=110"]
    B --> C["undo 版本 2<br/>name=Alice<br/>trx_id=100"]

普通查询不会简单地返回当前记录,而是要判断当前版本对自己是否可见。如果不可见,就沿着 undo log 版本链找上一个版本。

update 时版本链怎么形成 ​

用一个更具体的例子说明。

初始数据:

sql
insert into mvcc_user(id, name) values (1, 'Alice');

事务 100 提交后,当前记录可以理解为:

版本nametrx_idroll_pointer
当前记录Alice100null

事务 110 执行并提交:

sql
update mvcc_user set name = 'Tom' where id = 1;

此时当前记录变成 Tom,旧的 Alice 进入 undo:

位置nametrx_idroll_pointer
当前记录Tom110指向 Alice 的 undo
undo 版本Alice100null

事务 120 又执行并提交:

sql
update mvcc_user set name = 'Jerry' where id = 1;

此时版本链变成:

位置nametrx_idroll_pointer
当前记录Jerry120指向 Tom 的 undo
undo 版本 1Tom110指向 Alice 的 undo
undo 版本 2Alice100null

如果一个旧事务的 Read View 只能看到 trx_id <= 110 的版本,它读取当前 Jerry 时发现不可见,就会沿版本链找到 Tom。

undo log 与 redo log 的关系 ​

undo log 负责“撤销”和“旧版本”,redo log 负责“崩溃恢复”。它们不是替代关系,而是互相配合。

日志作用类比
undo log记录修改前的数据,用于回滚和 MVCC后悔药
redo log记录页修改,用于宕机后重做保险单

一个关键点:undo log 本身也需要 redo log 保护。

因为 undo log 也是写在 InnoDB 页里的数据。如果事务修改了业务数据,同时生成了 undo log,但宕机后 undo log 自己丢了,就无法正确回滚未提交事务,也无法给旧 Read View 提供旧版本。因此,对 undo 页的修改也会产生 redo log。

mermaid
flowchart TD
    A["执行 update"] --> B["写 undo log 旧值"]
    B --> C["修改当前数据页"]
    B --> D["undo 页变化产生 redo"]
    C --> E["数据页变化产生 redo"]
    D --> F["宕机恢复时恢复 undo 页"]
    E --> G["宕机恢复时恢复数据页"]
    F --> H["未提交事务可回滚"]
    G --> H

这能解释一个深层问题:事务没提交就宕机了,MySQL 重启后为什么知道怎么回滚?因为 redo 先把必要的数据页和 undo 页恢复到一致状态,然后 InnoDB 再根据事务状态和 undo 信息回滚未提交事务。

MVCC、undo、redo、binlog 的分工 ​

机制解决的问题与 MVCC 的关系
MVCC普通读如何不阻塞写,还能读到一致版本总体机制
undo log历史版本在哪里,事务如何回滚MVCC 版本链载体
Read View哪个版本对当前事务可见MVCC 可见性规则
redo log宕机后如何恢复已提交修改和必要 undo 信息保障 undo 和数据页可靠
binlog主从复制、增量恢复、审计不直接实现 MVCC

更完整的事务修改链路见:redo log 与 binlog。

Read View 是什么 ​

Read View 可以理解为“查询开始时,数据库里哪些事务还没提交”的快照。它不是复制一份完整数据,而是一组用于判断可见性的事务信息。

常见字段可以这样理解:

字段含义
m_ids创建 Read View 时仍然活跃的事务 ID 列表
min_trx_id活跃事务中最小的事务 ID
max_trx_id下一个将要分配的事务 ID
creator_trx_id创建这个 Read View 的事务 ID

可见性判断简化理解:

mermaid
flowchart TD
    A["读取某个版本 trx_id"] --> B{"是否自己事务修改"}
    B -- "是" --> C["可见"]
    B -- "否" --> D{"trx_id < min_trx_id"}
    D -- "是" --> C
    D -- "否" --> E{"trx_id >= max_trx_id"}
    E -- "是" --> F["不可见,找旧版本"]
    E -- "否" --> G{"trx_id 是否在 m_ids 中"}
    G -- "在" --> F
    G -- "不在" --> C

不用背每个字段的源码细节,但要理解判断方向:

  1. 已经在快照创建前提交的事务,对我可见。
  2. 快照创建时还没提交的事务,对我不可见。
  3. 快照创建后才出现的事务,对我不可见。
  4. 自己事务里修改的数据,对自己可见。

读取流程 ​

mermaid
flowchart TD
    A["普通 select"] --> B["生成或复用 Read View"]
    B --> C["读取当前记录版本"]
    C --> D{"当前版本是否可见"}
    D -- "是" --> E["返回数据"]
    D -- "否" --> F["沿 undo log 找上一版本"]
    F --> D

这个流程解释了为什么长版本链会影响查询:如果当前版本不可见,查询需要沿 undo log 找旧版本。

RC 与 RR 的区别 ​

隔离级别Read View 生成时机结果
Read Committed每次普通 select 都生成新的 Read View能读到其他事务已提交的新数据
Repeatable Read事务中第一次普通 select 生成 Read View,并复用同一事务多次普通读结果更稳定
mermaid
sequenceDiagram
    participant A as "事务 A"
    participant B as "事务 B"
    participant DB as "InnoDB"
    A->>DB: "select,生成 Read View"
    B->>DB: "update 并 commit"
    A->>DB: "再次 select"
    DB-->>A: "RR 复用旧 Read View,仍看旧版本"

RC 下第二次 select 会生成新的 Read View,因此可以看到事务 B 已提交的新值。RR 下普通查询复用旧 Read View,因此还是看到旧值。

当前读与快照读 ​

类型示例是否走 MVCC 快照是否加锁
快照读普通 select是通常不加行锁
当前读select ... for update否加锁
当前读select ... lock in share mode否加共享锁
当前读update、delete否加锁

示例:

sql
-- 快照读
select *
from mvcc_user
where id = 1;

-- 当前读
select *
from mvcc_user
where id = 1
for update;

为什么当前读不能只读历史版本:update 必须基于最新数据修改,否则会把已经提交的新值覆盖掉,造成数据错误。

SQL Demo:观察 RC 和 RR 差异 ​

准备数据:

sql
create table mvcc_user (
  id bigint primary key,
  name varchar(50) not null
) engine = InnoDB;

insert into mvcc_user(id, name) values (1, 'Tom');

事务 A,在 RR 隔离级别下:

sql
set session transaction isolation level repeatable read;
begin;
select * from mvcc_user where id = 1;

事务 B:

sql
begin;
update mvcc_user set name = 'Jerry' where id = 1;
commit;

事务 A 再查:

sql
select * from mvcc_user where id = 1;
commit;

在 RR 下,事务 A 的普通 select 仍然看到第一次 Read View 中的结果,也就是 Tom。

把事务 A 改成 RC:

sql
set session transaction isolation level read committed;
begin;
select * from mvcc_user where id = 1;
-- 等事务 B 修改并提交后
select * from mvcc_user where id = 1;
commit;

RC 下第二次普通 select 会看到 Jerry,因为每次查询都会生成新的 Read View。

Demo:当前读会看到最新数据 ​

仍然使用 RR 隔离级别。

事务 A:

sql
set session transaction isolation level repeatable read;
begin;
select * from mvcc_user where id = 1;

事务 B:

sql
begin;
update mvcc_user set name = 'Jerry' where id = 1;
commit;

事务 A 执行当前读:

sql
select * from mvcc_user where id = 1 for update;

这时事务 A 会读取最新已提交版本,并尝试加锁。它和普通快照读结果可能不同。

这也是很多初学者困惑的点:RR 下“可重复读”主要针对普通快照读,不代表所有读都永远返回旧版本。

长事务为什么危险 ​

长事务会长期持有 Read View,导致旧版本不能被清理。

mermaid
flowchart TD
    A["事务 A 开启后长时间不提交"] --> B["Read View 长期存在"]
    B --> C["其他事务不断更新数据"]
    C --> D["undo log 旧版本不能及时清理"]
    D --> E["版本链变长,空间增长,查询变慢"]

常见长事务来源:

  1. 开启事务后执行慢查询。
  2. 事务里调用远程接口。
  3. 程序异常但连接没有提交或回滚。
  4. 手工操作数据库,begin 后忘记 commit。

查看事务:

sql
select *
from information_schema.innodb_trx;

Purge 线程为什么不能随便清理 undo ​

undo log 不是事务一提交就立刻删除。InnoDB 后台有 purge 机制,负责清理已经没有事务需要的历史版本。

问题是:能不能清理,不取决于“产生这个 undo 的事务是否已经提交”,还取决于“当前系统里是否还有更早的 Read View 可能要读它”。

mermaid
flowchart TD
    A["事务 A 很早开始"] --> B["生成旧 Read View"]
    B --> C["事务 B/C/D 不断更新同一批数据"]
    C --> D["产生大量 update undo"]
    D --> E{"事务 A 是否结束"}
    E -- "否" --> F["旧版本仍可能被 A 读取"]
    F --> G["Purge 不能清理相关 undo"]
    E -- "是" --> H["旧 Read View 释放"]
    H --> I["Purge 可以逐步清理无用版本"]

这就是线上常说的“长事务导致 undo 堆积”。它的本质不是连接占用时间长这么简单,而是长事务的 Read View 把历史版本的清理边界卡住了。

常见后果:

后果原因
undo 表空间增长旧版本无法及时清理
查询变慢版本链变长,快照读可能要沿 undo 找很多次
磁盘压力增加undo、redo、脏页都会增加 IO 压力
DDL 或维护变慢长事务可能阻塞元数据锁或清理进度

排查长事务时重点看:

sql
select
  trx_id,
  trx_state,
  trx_started,
  trx_mysql_thread_id,
  trx_query
from information_schema.innodb_trx
order by trx_started;

再结合连接状态:

sql
show processlist;

生产处理原则:

  1. 先确认是不是业务长事务、慢查询、人工会话未提交。
  2. 能让业务自然提交或回滚就优先自然结束。
  3. 确认无风险后再 kill 对应连接。
  4. 复盘代码中是否把远程调用、文件处理、大批量循环放进了事务。

为什么当前读不能用旧 undo 版本 ​

普通 select 可以读 undo 里的历史版本,但 update、delete、select ... for update 不能只读历史版本。原因很简单:这些操作要修改或锁定“现在最新的数据”。

假设库存原来是 10:

sql
select stock from sku_stock where sku_id = 1;

事务 A 的旧 Read View 看到 stock=10。与此同时,事务 B 已经扣减并提交,当前库存变成 9。

如果事务 A 的扣库存操作还按旧版本 10 去改,就可能覆盖掉事务 B 的提交结果。所以当前读必须读取最新已提交版本,并对记录加锁。

mermaid
flowchart TD
    A["普通 select"] --> B["可以基于 Read View 读旧版本"]
    C["update/delete/for update"] --> D["必须读最新版本"]
    D --> E["加锁保证修改顺序"]
    E --> F["避免覆盖其他已提交事务"]

这也是 MVCC 和锁必须配合的原因:MVCC 提升普通读并发,锁保证写入和当前读的正确顺序。

MVCC 和幻读 ​

在 RR 隔离级别下,普通快照读通过复用 Read View,可以避免普通查询看到新增幻影行。但当前读要读取最新数据,为了防止范围内被插入新记录,InnoDB 会使用间隙锁、临键锁。

mermaid
flowchart TD
    A["普通范围 select"] --> B["MVCC Read View"]
    B --> C["同一事务内结果稳定"]
    D["select ... for update"] --> E["当前读 + 锁"]
    E --> F["通过间隙锁防止范围插入"]

所以 MVCC 和锁不是互相替代关系:

场景主要机制
普通一致性读MVCC
修改数据行锁
当前读防止幻读临键锁 / 间隙锁

常见误区 ​

误区正确理解
MVCC 不需要锁MVCC 主要优化普通读,写仍然要锁
RR 下所有读都可重复普通快照读可重复,当前读会读最新数据
undo log 只用于回滚undo log 也支撑快照读的历史版本
Read View 是复制整张表Read View 只是事务可见性判断信息
长事务只是占连接长事务会阻止旧版本清理,导致 undo 堆积

面试标准回答 ​

MVCC 是什么? ​

MVCC 是多版本并发控制。InnoDB 通过当前记录、undo log 版本链和 Read View 实现普通一致性读。写事务修改数据时,会生成新版本,并把旧版本放到 undo log 中;普通 select 读取时,根据 Read View 判断当前版本是否可见,如果不可见,就沿着 roll_pointer 去 undo log 中找旧版本。因此 MVCC 可以让普通读在很多情况下不阻塞写,写也不阻塞普通读。

MVCC 和 undo log 是什么关系? ​

undo log 是 MVCC 版本链的重要载体。MVCC 的多版本不是复制多份完整表数据,而是当前记录通过隐藏字段 DB_ROLL_PTR 串到 undo log 中的历史版本。Read View 决定哪个版本可见,undo log 提供可读取的旧版本。undo log 还用于事务回滚,所以它既服务原子性,也服务 MVCC 的一致性读。

undo log 提交后会立刻删除吗? ​

不会。事务提交只代表这个事务的修改完成了,但如果系统里还有更早的 Read View 可能读取旧版本,相关 undo log 就不能被 purge 清理。只有确认没有事务再需要这些历史版本后,后台 purge 线程才能逐步清理。因此长事务会长期持有旧 Read View,导致 undo 堆积、版本链变长、查询变慢和磁盘压力增加。

undo log 和 redo log 有什么关系? ​

undo log 记录修改前的数据,用于回滚和 MVCC;redo log 记录页修改,用于崩溃恢复。undo log 本身也是 InnoDB 页数据,对 undo 页的修改也会产生 redo log。宕机恢复时,InnoDB 先通过 redo 恢复数据页和 undo 页,再根据事务状态用 undo 回滚未提交事务。所以 undo 和 redo 不是互斥关系,而是配合保证事务正确性。

小结 ​

  1. MVCC 用版本链和 Read View 实现一致性读。
  2. undo log 保存旧版本,既用于回滚,也用于快照读。
  3. RC 每次普通读生成新的 Read View,RR 第一次普通读生成并复用。
  4. 普通 select 是快照读,select ... for update 是当前读。
  5. 长事务会让旧版本无法清理,是线上数据库的重要风险。

继续学习时建议阅读 锁:MVCC 解释普通读,锁解释写冲突和当前读。