MVCC 完全指南:从 ReadView 到 InnoDB 多版本并发控制

MVCC(Multi-Version Concurrency Control,多版本并发控制)是现代数据库实现"读不阻塞写,写不阻塞读"的关键技术。MySQL InnoDB、PostgreSQL、Oracle 全用它。但大多数人对 MVCC 的理解停留在"有多个版本",问起 ReadView 怎么算、四个隔离级别下具体怎么工作、为什么 RR 隔离级别能解决幻读,答案就开始模糊。这篇文章把 MVCC 从原理讲到 InnoDB 的具体实现,全部配可复现的 SQL。

问题:为什么需要 MVCC

没有 MVCC 时,要在并发下保证一致性,只能用:

  • 读时加共享锁,写时加排他锁。
  • 结果:大量"读阻塞写、写阻塞读"。

SQL Server 早期就是纯锁机制,长事务读会让写请求排长队。MVCC 的核心思想:每次修改不覆盖旧数据,而是产生新版本;读时根据"开始时间"看对应版本。这样读完全不需要锁,写也只锁自己改的那一行。

InnoDB 的 MVCC 实现

InnoDB 给每行额外维护两个隐藏字段:

  • DB_TRX_ID:最近修改这行的事务 ID(6 字节)。
  • DB_ROLL_PTR:指向 undo log 的指针(7 字节)—— undo log 里链着这行的所有旧版本。
当前行: id=1, name='Alice', age=30, DB_TRX_ID=105, DB_ROLL_PTR -> undo_5
undo_5: id=1, name='Alice', age=29, DB_TRX_ID=103, DB_ROLL_PTR -> undo_3
undo_3: id=1, name='Alex',  age=28, DB_TRX_ID=101, DB_ROLL_PTR -> undo_1
undo_1: id=1, name='Alex',  age=25, DB_TRX_ID=100, DB_ROLL_PTR -> NULL

读取时从"当前行"开始,沿 DB_ROLL_PTR 链条往前找,找到"当前事务能看到的版本"为止。

ReadView:决定能看到哪些版本

事务开启时(或第一次读时,看隔离级别)生成一个 ReadView,核心字段:

  • m_ids:生成 ReadView 时活跃事务的 ID 列表。
  • min_trx_id:m_ids 中最小的。
  • max_trx_id:下一个将要分配的事务 ID(比所有已分配 ID 都大)。
  • creator_trx_id:创建 ReadView 的事务 ID。

读到某行版本时,比较该版本的 DB_TRX_ID 和 ReadView:

if DB_TRX_ID == creator_trx_id:
    可见(是自己改的)
elif DB_TRX_ID < min_trx_id:
    可见(在 ReadView 之前就提交了)
elif DB_TRX_ID >= max_trx_id:
    不可见(在 ReadView 之后才开始)
elif DB_TRX_ID in m_ids:
    不可见(ReadView 时还在运行,未提交)
else:
    可见(在 ReadView 之前提交了,但 ID 在 min~max 之间)

如果不可见 -> 沿 DB_ROLL_PTR 找上一个版本,重复判断

四个隔离级别下的 MVCC 行为

READ UNCOMMITTED(读未提交)

直接读最新版本,不管是否已提交。会出现脏读。实际工程几乎不用

READ COMMITTED(读已提交)

每次 SELECT 都生成一个新的 ReadView。同一个事务里,前一秒和后一秒查询可能看到不同结果(其他事务提交了新数据)。不可重复读是该级别的固有现象。

-- 事务 A
START TRANSACTION;
SELECT name FROM users WHERE id = 1;  -- 'Alice'

-- 此时事务 B 提交 UPDATE users SET name = 'Bob' WHERE id = 1;

SELECT name FROM users WHERE id = 1;  -- 'Bob' (新 ReadView)
COMMIT;

REPEATABLE READ(可重复读,InnoDB 默认)

事务第一次查询时生成 ReadView,整个事务期间复用。后续无论别的事务怎么提交,本事务看到的都是同一时刻的快照。

-- 同样的场景下,事务 A 两次查询都返回 'Alice'

SERIALIZABLE(串行化)

所有读都加共享锁,所有写都加排他锁,等于退化为锁机制。最严格,性能最差。

幻读:RR 是否真的解决了

幻读(Phantom Read):同一事务里两次"范围查询"看到的行数不同。

-- 事务 A
START TRANSACTION;
SELECT * FROM users WHERE age >= 30;  -- 返回 5 行

-- 事务 B 插入一行 INSERT INTO users (age) VALUES (35); COMMIT;

SELECT * FROM users WHERE age >= 30;  -- 这次返回几行?

MySQL InnoDB 在 RR 级别下:

  • 普通 SELECT(快照读):看 ReadView,仍返回 5 行。不会幻读
  • SELECT ... FOR UPDATE / LOCK IN SHARE MODE(当前读):看最新,返回 6 行。这就是幻读现象。

InnoDB 用"Next-Key Lock"(下面单独讲)在当前读时锁住范围,阻止其他事务在该范围内插入,从而避免幻读。所以严格说,InnoDB 在 RR 下用 MVCC + Next-Key Lock 共同避免了快照读和当前读两种场景的幻读。

当前读 vs 快照读

InnoDB 把读分两类:

  • 快照读:普通 SELECT,走 MVCC 看 ReadView。
  • 当前读:SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE、所有 INSERT/UPDATE/DELETE 内部的读,看最新版本 + 加锁。

关键认知:UPDATE 内部是当前读。事务 A 读到 X = 5,事务 B 把 X 改成 10 并提交,事务 A 执行 UPDATE WHERE x = 5 时看到的是 10 而不是 5 —— 这个 UPDATE 不会匹配上!这就是为什么 ORM 框架要用"乐观锁(版本号)"来防止丢失更新。

undo log 的作用

undo log 不只是 MVCC 数据源,也是事务回滚的根据:

  • 事务里改了一行,旧版本写进 undo log。
  • 事务回滚时,反向应用 undo 恢复原值。
  • 提交后 undo log 不立刻删 —— 还要等"没有任何活跃事务可能用到它"才能被 purge 线程清理。

这就是为什么"长事务"对性能有害 —— 它会让 undo log 不能被清理,持续膨胀,影响其他事务读取的链长度。SHOW ENGINE INNODB STATUS 可以看到 "history list length",过大说明有长事务卡着。

PostgreSQL 的 MVCC 差异

PostgreSQL 也用 MVCC,但实现完全不同:

  • InnoDB:行只有一份,旧版本在 undo log。
  • PostgreSQL:每个版本是独立的物理行,旧版本"就在表里",只是被标记不可见。

PG 的做法叫 in-place 多版本,代价是需要 VACUUM 定期清理"没人能看到的旧版本"。VACUUM 不及时表会膨胀 —— 这是 PG DBA 主要的维护工作。InnoDB 用 undo log 避免了这个问题,但 undo log 又有自己的膨胀问题。各有取舍。

实战:并发场景的隔离选择

转账

-- 必须 SERIALIZABLE 或使用 SELECT FOR UPDATE
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 'A' FOR UPDATE;   -- 锁住 A
UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';
COMMIT;

读统计

-- RR 足够:同一事务里多个聚合查询彼此一致
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT COUNT(*) FROM orders WHERE status = 'paid';
SELECT SUM(total) FROM orders WHERE status = 'paid';
COMMIT;

幂等更新

-- 用版本号(乐观锁)避免丢失更新
UPDATE accounts SET balance = ?, version = version + 1
WHERE id = ? AND version = ?
-- 返回 0 行说明被别人改过了,要重试

MVCC 的代价

  • 存储开销:每行有 13 字节隐藏字段;undo log 占空间。
  • 长事务害死人:undo log 不能被清,history list 越来越长,查询链路越长越慢。
  • 幻读边界微妙:RR + 快照读不幻读,RR + 当前读靠间隙锁,RC 永远可能幻读。这套规则不熟悉就容易踩坑。

实战:用 MVCC 排查"查询结果不一致"

生产中遇到"明明数据已经更新了,但查询还是返回旧值",大概率是 MVCC + 长事务的组合作用。

-- 客户端反馈:页面查 user 1 还是显示 age=29
-- 你直接连数据库查:SELECT * FROM users WHERE id=1 -> age=30(正确)

-- 排查方向:
-- 1. 应用是否开了长事务?
SELECT trx_id, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started ASC;
-- 如果有一个事务跑了几分钟还没提交,大概率就是它

-- 2. 该事务的 ReadView 是几分钟前生成的,所以看到的就是旧版本
-- 3. 找到该事务对应的应用进程,杀掉或修复代码

这种 bug 在 ORM 框架里很常见 —— Spring @Transactional 嵌套不当导致事务范围过大,或者 connection pool 配置不当让事务被复用。规范:事务范围尽量小,避免在事务里做远程调用 / 用户等待

历史链长度(History List Length)

-- 看 InnoDB 的 history list 长度
SHOW ENGINE INNODB STATUS\G
-- 找:History list length 12345

-- 几百是正常,几万就是警告,几十万是严重问题

History list 长意味着 undo log 不能 purge,通常因为有"超老" ReadView 还在用旧版本。解决方法:

  • 找出超长事务,kill 或让它提交。
  • 调小事务大小,把"读取 + 业务计算"和"数据库事务"分离。
  • 提高 purge 线程并发(innodb_purge_threads)。

MVCC 在副本集 / 主从场景

主从复制时,从节点也维护自己的 MVCC。同一时刻主从的 ReadView 不一样,所以"主上看到的最新数据,从上可能看不到"。这就是"读写分离的复制延迟问题" —— 用户写完后立刻读从可能拿到旧值。

解决:

  • 读自己的写:写后短时间内强制读主。
  • 会话粘性:用户的请求路由到同一节点。
  • 半同步复制:主要等至少一个从同步完才回应客户端,稍微缓解。

Snapshot Isolation 的异常:Write Skew

RR 级别下 MVCC 仍然存在一种异常 —— 写偏斜(Write Skew)。两个事务各自读不冲突的数据,各自做更新,最终违反业务约束:

-- 业务约束:同一房间至少要有一名值班医生
SELECT COUNT(*) FROM doctors WHERE room='ER' AND on_shift=1;
-- 两个医生都查到结果是 2(自己 + 另一个),都觉得可以下班

-- 医生 A 事务:UPDATE doctors SET on_shift=0 WHERE id=A;
-- 医生 B 事务:UPDATE doctors SET on_shift=0 WHERE id=B;

-- 两个事务都提交了,结果:ER 没有值班医生了!违反业务约束

MVCC 看到的是各自的快照,都觉得自己合法。要彻底防止 Write Skew,需要 SERIALIZABLE 隔离级别 —— 或者业务层加 SELECT FOR UPDATE 强制串行化。这是 MVCC 的一个根本限制。

事务隔离级别的工程权衡

为什么默认 RR 而不是 RC?

MySQL 选择 RR 作为默认隔离级别有历史原因 —— 早期 MySQL 主从复制基于 statement-based binlog,RC 下从库可能出现"主从数据不一致",所以 InnoDB 默认 RR 才安全。MySQL 5.1 之后引入 row-based binlog,这个问题不复存在,但默认隔离级别没改。

实际上很多互联网公司主动改成 RC:

  • RC 没有间隙锁,死锁概率降到极低。
  • RC 锁更少,并发吞吐更高。
  • 互联网业务大多能接受"同一事务内不可重复读"—— 反而 RR 那种"看到的快照"在长事务中可能更违反直觉。
-- 全局改
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 或在 my.cnf
[mysqld]
transaction-isolation = READ-COMMITTED

InnoDB 缓冲池与脏页

InnoDB 不是直接读写磁盘 —— 数据先进 Buffer Pool(默认占物理内存 70-80%)。修改时:

  1. 修改 Buffer Pool 中的页 -> 这页变"脏页"。
  2. 同时写 redo log buffer -> 后台 fsync 到磁盘(默认每秒 + 每事务提交时)。
  3. 后台线程异步把脏页刷回磁盘。

这种设计让写操作可以批量、顺序刷盘,性能远好于"每次写直接刷磁盘"。

redo log:崩溃恢复的根基

redo log 是"已提交事务的物理变更日志"。崩溃恢复时:

  1. 从最近 checkpoint 开始重放 redo log。
  2. 未提交的事务(根据 undo log)回滚。
  3. 状态恢复到崩溃前。

InnoDB 的 ACID 中的 D(持久性)就靠这个保证。redo log 也是循环写的(几个固定大小文件),所以脏页必须及时刷回磁盘,否则 redo log 写满会卡死写入。

逻辑日志 binlog 与 redo log 的两阶段提交

事务提交时,MySQL 要同时保证:

  • redo log 写入(InnoDB 持久性)。
  • binlog 写入(主从复制 / 数据恢复)。

两个日志的提交必须原子,否则崩溃恢复后主从数据可能不一致。MySQL 用"两阶段提交"协调:

1. 准备阶段:redo log 写入并刷盘,事务状态变 prepared
2. 提交阶段:binlog 写入并刷盘
3. redo log 标记事务为 committed

崩溃恢复时:
  redo log 有 prepared 但 binlog 没记录 -> 回滚事务
  redo log 有 prepared 且 binlog 有记录 -> 提交事务

这套机制让 MySQL 在崩溃后能精确恢复到一致状态,且与从库保持同步。是 InnoDB 工程上的核心保证。

写在最后

MVCC 是数据库领域过去 30 年最重要的工程突破之一,让"OLTP 数据库支持高并发"从梦想变成事实。理解它,你看 MySQL / PG 的隔离级别配置不再是黑盒,排查并发问题也能精确到"是 ReadView 看不到还是锁等不到"。生产经验:默认 RR 用就行,但要明白快照读和当前读的边界,选 FOR UPDATE 要慎重。理解 MVCC 是数据库工程师从"会用"到"懂行"的分水岭。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Kafka 完全指南:从 Partition 到 ISR 的内部机制

2026-5-15 16:19:20

技术教程

InnoDB 锁机制完全指南:Next-Key Lock、间隙锁与死锁排查

2026-5-15 16:26:44

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索