上一章进入了事务与并发控制,有一个非常重要的问题:
如果两个事务同时访问同一条数据,数据库到底应该让它们看到哪个版本?
这就是 MVCC 要解决的问题。
MVCC 全称:
Multi-Version Concurrency Control,多版本并发控制。
它的核心思想其实并不复杂:
不要要求所有事务都只能看到同一个“当前值”,而是让数据在一段时间内存在多个版本,再根据事务自己的 Snapshot 判断应该看到哪个版本。
真正复杂的是:
- 一个
UPDATE到底产生了什么? - 旧版本为什么不能马上删除?
- 一个事务怎么知道某个版本能不能看?
- Snapshot 是什么?
- PostgreSQL 和 InnoDB 为什么采用不同的 MVCC 结构?
- 最后这些旧版本又什么时候被清理?
一、先从一个 UPDATE 看 MVCC
假设数据库最开始有:
id = 1
name = Alice
age = 20
事务 T1 执行:
UPDATE users
SET age = 21
WHERE id = 1;
最直观的想法是:
20
↓
21
直接把:
age = 20
覆盖成:
age = 21
但 MVCC 不希望简单地这么做。
因为此时可能还有另一个事务:
T2
正在执行:
SELECT age
FROM users
WHERE id = 1;
如果 T2 在 T1 修改之前开始,那么它可能应该继续看到:
20
而 T1 自己则应该看到:
21
于是数据库实际上需要同时理解:
users.id = 1
┌─────────────┐
│ age = 20 │ ← 旧版本
└─────────────┘
│
↓
┌─────────────┐
│ age = 21 │ ← 新版本
└─────────────┘
这就是 MVCC 的第一个核心:
同一个逻辑数据,在某一段时间内可能对应多个物理版本。
二、为什么旧版本不能立即删除?
这是理解 MVCC 最关键的一步。
假设:
T1:UPDATE age = 21
T2:SELECT age
时间线:
时间 →
────────────────────────────────────────>
T2 BEGIN
│
│
│ T1 UPDATE
│ │
│ ↓
│ age = 21
│
↓
T2 SELECT
如果数据库在 T1 更新以后立刻删除:
age = 20
那么 T2 就找不到自己应该看到的版本了。
所以:
旧版本不能根据“当前有没有人正在修改”简单判断是否可以删除。
数据库必须知道:
还有没有事务可能需要这个旧版本?
这就是 MVCC 的另一个核心问题:
Version Visibility + Version Lifetime
三、MVCC 真正保存的不是“多个值”,而是“版本信息”
我们把刚才的数据抽象一下:
Logical Row
│
├── Version 1
│ value = 20
│
└── Version 2
value = 21
实际上每个版本通常还需要携带一些事务相关的信息。
可以抽象成:
Version
├── Data
├── Creator Transaction
├── End/Invalidation Information
└── Other Metadata
例如:
Version 1
value = 20
created_by = T10
deleted_by = T20
Version 2
value = 21
created_by = T20
...
这并不是说所有数据库真的使用完全相同的字段,而是帮助我们理解 MVCC 的基本模型。
于是一个事务读取数据时,不再只是:
return row.value;
而更像:
for (version : versions) {
if (Visible(version, snapshot)) {
return version;
}
}
因此:
MVCC 的本质并不是“存旧数据”,而是“根据事务视图选择正确的数据版本”。
四、Snapshot 到底是什么?
这就进入 MVCC 最重要的概念之一:
Snapshot(快照)。
不要把这里的 Snapshot 理解成我们之前讨论过的那种“把整个数据库保存下来”。
事务 Snapshot 更多是在描述:
这个事务开始读取数据时,它应该把哪些事务的修改视为可见。
例如当前系统中:
T10
T11
T12
T13
假设 T20 创建 Snapshot 时:
T10:已经完成
T11:已经完成
T12:正在执行
T13:正在执行
那么 T20 可能得到一个类似这样的逻辑视图:
T10 ✓ 可见
T11 ✓ 可见
T12 ✗ 不可见
T13 ✗ 不可见
于是:
Version created by T10
↓
可见
Version created by T12
↓
不可见
所以同一时刻:
Transaction A → Version 20
Transaction B → Version 21
完全有可能是合理的。
五、所以 MVCC 的读取过程是什么?
现在可以把一个普通的:
SELECT *
FROM users
WHERE id = 1;
抽象成:
SELECT
│
↓
找到逻辑 Row
│
↓
找到相关 Version
│
┌───────┴────────┐
↓ ↓
Version A Version B
age = 20 age = 21
│ │
└───────┬────────┘
↓
Visibility Check
│
↓
Snapshot
│
↓
找到对当前事务可见的版本
因此:
索引解决的是“去哪找”。
MVCC 解决的是“找到以后,到底哪个版本属于我”。
这两个概念一定不要混在一起。
六、MVCC 并不意味着“完全不需要锁”
这是一个很容易产生的误解。
MVCC:
主要解决读取与版本可见性问题。
但是对于两个事务同时修改同一个数据:
T1 UPDATE X
T2 UPDATE X
仍然需要处理:
谁先修改?
谁等待?
谁失败?
谁回滚?
因此实际数据库通常是:
Transaction
│
┌─────────┴─────────┐
↓ ↓
MVCC Lock
│ │
读取哪个版本 修改冲突
│ │
└─────────┬─────────┘
↓
并发控制
所以不要把:
MVCC = 不需要锁
作为结论。
更准确的理解是:
MVCC 和锁解决的是并发控制中的不同问题,两者通常配合使用。
七、PostgreSQL:旧版本直接存在 Heap 中
现在来看真实数据库。
PostgreSQL 的 MVCC 很有代表性。
假设:
UPDATE age = 21
概念上可以理解成:
Heap
┌──────────────────┐
│ Tuple Version A │
│ age = 20 │
└──────────────────┘
┌──────────────────┐
│ Tuple Version B │
│ age = 21 │
└──────────────────┘
也就是说:
UPDATE 并不只是把原来的 Tuple 原地覆盖成新值。
旧 Tuple 可能继续存在。
PostgreSQL 的 Tuple 中还有与事务可见性相关的信息,例如:
xmin
xmax
可以把它们粗略理解成:
xmin → 这个 Tuple 版本由哪个事务产生
xmax → 这个 Tuple 版本在哪个事务之后失效
实际可见性判断远比这个简单模型复杂,但这个抽象足够帮助我们建立第一层理解。
八、PostgreSQL 为什么需要 VACUUM?
现在问题来了:
如果每次 UPDATE 都留下旧版本:
UPDATE
↓
旧 Tuple 保留
UPDATE
↓
又产生新版本
UPDATE
↓
继续产生新版本
长期运行:
Heap
旧版本
旧版本
旧版本
旧版本
新版本
旧版本
旧版本
新版本
...
磁盘空间会越来越大。
而且旧版本还会造成:
- 更多磁盘空间占用
- 更多页面扫描成本
- 表膨胀
- 索引相关空间问题
所以 PostgreSQL 需要后台进行垃圾回收。
这就是:
VACUUM
它需要判断:
这个旧 Tuple 是否已经不可能再被任何仍然有效的事务看到?
如果答案是:
不会再被任何事务需要
那么它才可以被认为是可以回收的垃圾。
因此:
UPDATE
↓
产生旧版本
↓
旧版本暂时保留
↓
等待所有相关事务不再需要
↓
VACUUM 回收
这和 C++ 中的:
对象还有没有人引用?
有一点相似。
只不过数据库的判断依据是:
事务可见性,而不是普通指针引用。
九、InnoDB:历史版本主要放在哪里?
再来看 MySQL 的 InnoDB。
InnoDB 的 MVCC 思路和 PostgreSQL 有明显区别。
概念上:
Clustered Index
│
↓
Current Row
│
↓
Undo
│
↓
Older Version
│
↓
Older Version
也就是说:
当前版本在聚簇索引中,而历史版本主要依赖 Undo Log 保存。
当一个事务读取数据时,如果当前版本对于它不可见,它可以利用 Undo 中的历史信息构造出适合当前 Snapshot 的版本。
于是可以形成一个非常重要的对比:
| PostgreSQL | InnoDB | |
|---|---|---|
| 当前数据 | Heap Tuple | Clustered Index |
| 历史版本 | Heap 中的旧 Tuple | Undo 中的历史信息 |
| 可见性 | Tuple + Transaction 信息 | Read View + Transaction 信息 + Undo |
| 历史版本回收 | VACUUM 等 | Purge 等 |
这也是为什么:
不能简单地认为“所有数据库的 MVCC 都一样”。
它们解决的是同一个问题,但数据结构完全可以不同。
十、InnoDB 为什么需要 Undo?
现在回到刚才的问题。
假设:
当前值:
age = 21
但是一个旧事务需要:
age = 20
如果当前记录已经是:
21
怎么办?
InnoDB 可以利用 Undo 信息:
Current
age = 21
│
↓
Undo
old age = 20
然后根据当前事务的 Read View:
当前版本不可见
↓
读取 Undo
↓
构造历史版本
↓
返回 age = 20
所以 Undo 不只是:
“回滚的时候用的东西。”
它还和:
MVCC 一致性读
密切相关。
这是学习 InnoDB 时非常重要的认识。
十一、一个 UPDATE 在两个数据库中发生了什么?
现在把它放在一起。
执行:
UPDATE users
SET age = 21
WHERE id = 1;
PostgreSQL 的概念模型
找到旧 Tuple
↓
创建新 Tuple
↓
旧 Tuple 仍存在
↓
新 Tuple 成为新的可见版本
↓
以后 VACUUM 清理旧版本
可以想象:
Heap
┌─────────────┐
│ age = 20 │ ← old
└─────────────┘
┌─────────────┐
│ age = 21 │ ← new
└─────────────┘
InnoDB 的概念模型
找到当前 Clustered Index Record
↓
修改当前记录
↓
旧版本信息进入 Undo
↓
其他事务根据 Read View 判断
↓
必要时从 Undo 构造旧版本
↓
以后 Purge 清理不再需要的历史
概念上:
Clustered Index
│
↓
age = 21
│
↓
Undo
│
↓
age = 20
两种实现的差异非常值得注意:
PostgreSQL 更像“多个 Tuple 版本直接存在 Heap 中”。
InnoDB 更像“当前版本 + Undo 历史链”。
十二、MVCC 为什么会带来空间管理问题?
现在我们可以发现一个很有意思的现象:
为了提高并发能力:
保存历史版本
↓
为了保存历史版本:
占用更多空间
↓
为了回收空间:
需要判断旧版本什么时候没人需要
↓
于是产生:
PostgreSQL → VACUUM
InnoDB → Purge
所以 MVCC 并不是“免费”的。
它用:
空间
+ 版本管理
+ 可见性判断
+ 垃圾回收
换取:
更好的并发读取能力
这其实就是数据库内核中非常典型的设计思想:
用空间和后台工作换取前台并发性能。
十三、把 MVCC 和 WAL 再联系起来
到这里,我们已经可以把前面的连起来了。
假设:
T1 UPDATE X = 200
数据库内部同时涉及两个维度:
维度一:事务可见性
MVCC
↓
产生/维护版本
↓
判断其他事务应该看到哪个版本
维度二:崩溃恢复
WAL
↓
记录修改
↓
WAL 持久化
↓
Crash Recovery
所以:
UPDATE
│
┌────────┴────────┐
↓ ↓
MVCC WAL
│ │
版本 / 可见性 持久化 / 恢复
│ │
↓ ↓
并发事务正确性 崩溃后恢复
这是两个不同的问题。
MVCC 不能替代 WAL。
WAL 也不能替代 MVCC。
十四、一个非常重要的“数据库内核视角”
到现在为止,已经讲到了:
B+Tree
Page
Buffer Pool
WAL
Transaction
MVCC
把它们串起来:
SQL
│
↓
Query Executor
│
↓
找到目标数据
│
┌─────┴─────┐
↓ ↓
Index Transaction
│ │
↓ ┌───┴────┐
Page ↓ ↓
│ MVCC Lock
↓ │ │
Buffer Pool └───┬────┘
│ │
│ ↓
│ 并发控制
│
↓
Dirty Page
│
↓
WAL
│
↓
Crash Recovery
你会发现,数据库内核开始逐渐形成一张完整的图。
以前我们看到的是:
SQL
↓
数据结构
现在开始变成:
SQL
↓
事务
↓
并发控制
↓
Buffer Pool
↓
Page
↓
磁盘
同时:
事务修改
↓
WAL
↓
Crash Recovery
十五、最值得建立的几个概念
如果这篇只保留几个核心概念,我建议建立下面这条关系:
UPDATE
↓
产生新的数据版本
↓
旧版本不能立即删除
↓
事务需要 Snapshot
↓
Snapshot 决定版本是否可见
↓
MVCC 实现并发读取
↓
旧版本最终需要垃圾回收
然后记住两个数据库的典型思路:
PostgreSQL
↓
Heap 中存在多个 Tuple 版本
↓
VACUUM 回收旧版本
InnoDB
↓
当前版本 + Undo 历史
↓
Read View 判断可见性
↓
Purge 回收历史
而整个事务系统:
Transaction
│
┌──────────┼──────────┐
↓ ↓ ↓
MVCC Lock WAL
│ │ │
可见性 冲突控制 持久化
│ │ │
└──────────┼──────────┘
↓
数据库事务