MVCC——数据库为什么要保存“旧版本”?

0 阅读10分钟

上一章进入了事务与并发控制,有一个非常重要的问题:

如果两个事务同时访问同一条数据,数据库到底应该让它们看到哪个版本?

这就是 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 的版本。

于是可以形成一个非常重要的对比:

PostgreSQLInnoDB
当前数据Heap TupleClustered Index
历史版本Heap 中的旧 TupleUndo 中的历史信息
可见性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
       │          │          │
    可见性      冲突控制     持久化
       │          │          │
       └──────────┼──────────┘
                  ↓
              数据库事务