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 UPDATE、SELECT ... 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%)。修改时:
- 修改 Buffer Pool 中的页 -> 这页变"脏页"。
- 同时写 redo log buffer -> 后台 fsync 到磁盘(默认每秒 + 每事务提交时)。
- 后台线程异步把脏页刷回磁盘。
这种设计让写操作可以批量、顺序刷盘,性能远好于"每次写直接刷磁盘"。
redo log:崩溃恢复的根基
redo log 是"已提交事务的物理变更日志"。崩溃恢复时:
- 从最近 checkpoint 开始重放 redo log。
- 未提交的事务(根据 undo log)回滚。
- 状态恢复到崩溃前。
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