CMU15-445 数据库系统播客:深入理解多版本并发控制 (MVCC) – 现代数据库的并发基石

构建任何高并发应用时,数据库的性能和稳定性都是我们关注的重中之重。而谈到数据库,一个我们无法绕开的概念就是 并发控制 。今天,我们将深入探讨现代关系型数据库中最主流的并发控制思想—— 多版本并发控制(Multi-Version Concurrency Control, MVCC) 。

无论是 PostgreSQL, MySQL (InnoDB), Oracle, 还是 SQL Server(在特定隔离级别下),MVCC 都是它们处理高并发读写请求、保证数据一致性的核心机制。

核心思想:MVCC 是什么?

许多初学者可能会误解,认为 MVCC 是一种与两阶段锁(2PL)或乐观并发控制(OCC)并列的并发控制“协议”。但一个更精确的定义是: MVCC 是一种实现并发控制的系统架构(System Architecture) 。

它的核心理念极其优雅: 通过保留数据的多个历史版本,来实现读写操作的并行不悖 。

想象一下传统的锁机制:当一个事务正在写入某行数据时,这行数据就会被“锁”住。此时,其他任何想要读取这行数据的事务都必须等待,直到写事务提交或回滚。同样,一个事务在读取数据时,也可能阻塞其他事务的写入。这种读写相互阻塞的模式,在并发量高的场景下会严重影响系统吞吐量。

MVCC 则彻底改变了这一游戏规则。它的核心承诺是:

写操作不阻塞读操作,读操作也不阻塞写操作。

这是如何实现的呢?当一个事务需要读取数据时,系统不会直接返回“最新”的数据,而是为其提供一个 “一致性快照(Consistent Snapshot)” 。这个快照代表了该事务启动时,整个数据库的某个一致性状态。如此一来,即使其他事务正在修改数据,当前事务读取的也只是它自己快照中的、未被修改的旧版本,完全不受干扰。

然而,需要明确的是,MVCC 自身并不解决 写-写冲突(Write-Write Conflicts) 。当两个事务尝试修改 同一个 数据对象时,系统仍然需要一个仲裁机制来决定谁胜谁负。这时,MVCC 架构就需要与一个传统的并发控制协议(如时间戳排序、乐观锁或两阶段锁)相结合,来处理这类冲突。

历史回眸:一个经久不衰的理念

MVCC 的思想并非凭空出现。它的理论雏形最早可以追溯到 1978 年麻省理工学院(MIT)的一篇博士论文 。但真正将其工程化并推向市场的,是 20 世纪 80 年代早期的 DEC 公司 。

DEC 公司开发的两款数据库产品—— Rdb/VMS 和 InterBase ,是商业上最早成功实现 MVCC 的系统。这背后的关键人物是 Jim Starkey ,他不仅是 MVCC 的早期实践者,还被认为是 BLOBs(二进制大对象)和数据库触发器的发明人。

这段历史还有一些有趣的后续:

DEC 的 Rdb/VMS 最终被 Oracle 收购,成为了 Oracle Rdb。InterBase 在几经易手后被开源,演变成了今天我们所熟知的 Firebird 数据库。

一个有趣的花絮是:Mozilla 最初想将浏览器命名为 Phoenix,因版权冲突改为 Firebird,但再次与 Firebird 数据库重名,最终才定名为我们熟悉的 Firefox。

MVCC 的两大杀手级优势

为什么 MVCC 能够经受住时间的考验,成为现代数据库的标配?因为它带来了两个无与伦比的优势:

极致高效的只读事务

在 MVCC 架构下,只读事务的执行速度快得惊人。当一个事务被声明为只读(Read-Only)时,数据库系统知道它绝不会修改数据。因此,系统无需为其获取任何锁,也无需追踪其读写集。它要做的仅仅是读取其事务开始时那个“一致性快照”。这几乎是零成本的并发,极大地提升了读多写少场景下的系统性能。

强大的“时间旅行”查询能力

由于 MVCC 天然地保存了数据的历史版本,它为实现“时间旅行查询(Time-Travel Queries)”提供了可能。这意味着你可以向数据库提出这样的问题:

“三天前,我们的用户表是什么状态?”“上个季度末,公司的总销售额是多少?”

这个概念最早由 Postgres 在 20 世纪 80 年代提出。但遗憾的是,除了特定领域的数据库外,大多数通用数据库系统并没有完全开放这个功能。主要原因在于成本:支持任意时间点的查询意味着 必须永久保留所有历史版本 ,这将导致存储空间的无限膨胀。

尽管如此,在某些对数据审计和历史追溯有强需求的领域(如 金融行业 ),时间旅行查询的价值巨大。法规可能要求金融机构保留长达数年的交易记录,MVCC 使得在这海量历史数据中进行查询变得异常高效。

深入实现:MVCC 的四大核心设计决策

实现一个健壮高效的 MVCC 系统,远比听起来复杂。数据库开发者必须在四个关键领域做出精心的设计和权衡。

并发控制协议 (Concurrency Control Protocol)

如前所述,MVCC 架构需要一个“伙伴”协议来处理写-写冲突。不同的数据库选择了不同的方案:

时间戳排序 (Timestamp Ordering, T/O) : 每个事务获取一个时间戳,系统根据时间戳的先后顺序来决定事务的执行次序。乐观并发控制 (Optimistic Concurrency Control, OCC) : 事务执行期间不做任何检查,直到提交时才检查是否存在冲突。如果冲突,则回滚。两阶段锁 (Two-Phase Locking, 2PL) : 仍然使用锁来解决写-写冲突,但读操作不受影响。

这个协议的选择,直接决定了数据库在不同冲突场景下的行为和性能。

版本存储 (Version Storage)

这是 MVCC 实现中最核心的部分:如何组织和存储一个逻辑数据的多个物理版本?通常,系统会为每个 逻辑元组(Logical Tuple) 维护一个 版本链(Version Chain) ,而索引指向这个链的“头部”。

主要有三种主流方案:

仅追加存储 (Append-Only Storage)

这是最直观的方式,也是 PostgreSQL 采用的策略。当一个元组被更新时,系统不会在原地修改它,而是:

将旧版本的完整内容复制一份,形成一个新的物理元组。在这个新的物理元组上执行修改。将版本链的指针指向这个新版本。

这种方式又引申出版本链的组织顺序问题:

从旧到新 (Oldest-to-Newest, O2N) : 新版本追加在链表的末尾。优点是追加操作简单;缺点是查找最新版本时可能需要遍历整个链。从新到旧 (Newest-to-Oldest, N2O) : 新版本放在链表的头部。优点是查找最新版本非常快(O(1));缺点是每次创建新版本,所有指向旧头部的索引都必须更新为指向新头部,更新开销较大。

权衡分析 :仅追加存储的 读取旧版本性能极好 ,因为旧元组是完整独立的,无需任何计算。但其 写入开销较大 ,即使只修改一个字段,也需要复制整个元组。

时间旅行存储 (Time-Travel Storage)

这种方案将主表和历史表分开。主表上永远只保存最新版本的数据。当数据被更新时,旧版本被复制到一个独立的 “时间旅行表” 中。这种方式逻辑清晰,但可能增加数据管理的复杂性。

Delta 存储 (Delta Storage)

这是 MySQL (InnoDB) 和 Oracle 采用的策略。其思想类似于 Git 的 diff:不存储完整的旧版本,而 只记录被修改字段的“增量(Delta)” 。

当一个元组被更新时,系统会将修改前的“旧值”存放到一个独立的 Delta 存储区(在 InnoDB 中称为 Rollback Segment),然后在主表上进行原地更新。版本链实际上是通过指针串联起来的 Delta 记录。

权衡分析 :Delta 存储的 写入性能通常更高 ,因为只需复制少量修改的字段,而非整个元组。但其 读取旧版本的开销更大 ,因为需要从最新版本开始,通过“重放(Replay)”一系列的 Delta 记录来逐步回溯,才能重建出目标历史版本。

性能对决的根源 :正是由于版本存储策略的根本不同,我们经常观察到:在读密集型,特别是需要读取历史数据的分析场景下, PostgreSQL 可能表现更优;而在写密集型,特别是更新操作频繁的 OLTP 场景下, MySQL (InnoDB) 可能更具优势。

垃圾回收 (Garbage Collection)

随着系统运行,会产生大量不再需要的旧版本(例如,所有活跃事务都无法再看到它们,或者由已中止事务创建的版本)。垃圾回收机制(常被称为 VACUUM)负责清理这些“垃圾”以回收磁盘空间。

何时可以回收? 一个版本可以被安全回收的条件是:当前系统中没有任何一个活跃事务的“快照时间戳”会落在该版本的生命周期内。

如何高效回收?

后台清扫 (Background Vacuuming) : 由一个或多个专用后台线程定期扫描表,查找并清理无效版本。为了避免全表扫描的巨大开销,系统通常会维护一个 “脏页位图(Dirty Page Bitmap)” ,只检查那些被修改过的数据页。这是 PostgreSQL 的主要方式。协作式清理 (Cooperative Cleaning) : 工作线程在执行查询、遍历版本链的过程中,“顺手”清理掉它们遇到的无效旧版本。这种方式非常巧妙,但它 仅适用于从旧到新(O2N)的版本链 ,因为只有在这种顺序下,查找新版本才会自然地经过旧版本。索引管理 (Index Management)

在 MVCC 中,索引不仅要能找到数据,还要能找到 正确版本 的数据。

主键索引 (Primary Key Indexes) : 通常比较直接,索引条目直接指向版本链的头部。如果主键本身被更新,系统通常将其处理为一次 DELETE + 一次 INSERT

二级索引 (Secondary Indexes) : 处理起来要复杂得多,直接影响数据库的性能特征。

物理指针 (Physical Pointers) : 二级索引的叶子节点直接存储元组的物理地址(如:页ID + 页内偏移量)。这是 PostgreSQL 的典型实现。

a.优点 : 查询速度快。通过二级索引能一次性定位到数据,无需额外查找。

b.缺点 : 更新开销大。在 PostgreSQL 的仅追加模型中,一次 UPDATE 会导致元组产生新的物理位置。这意味着, 所有 指向该元组的二级索引(可能有很多个)都必须被更新,这在写密集型负载下会成为巨大的性能瓶颈。

逻辑指针 (Logical Pointers) : 二级索引的叶子节点存储的是一个不变的逻辑标识符,通常是 主键的值 。这是 MySQL (InnoDB) 的实现方式。

a.优点 : 更新开销小。当元组更新(即使物理位置改变)时,只要主键不变,所有二级索引都 无需改动 。这极大地提升了写入性能。

b.缺点 : 查询需要“回表”。通过二级索引查找时,只能先找到主键值,然后必须再回到主键索引(聚簇索引)中进行第二次查找,才能定位到最终的数据。这增加了一次额外的索引查找开销。

总结

MVCC 不仅仅是一个技术术语,它是一种精妙的设计哲学,是现代数据库能够在高并发世界中保持优雅和高效的关键。它通过多版本的“时空”换取了读写并发的“自由”,但其实现背后充满了深刻的权衡。

从版本存储(Append-Only vs. Delta)到索引管理(物理指针 vs. 逻辑指针),每一个设计决策都直接影响了数据库(如 PostgreSQL 和 MySQL)在不同应用场景下的性能表现。理解这些内在机制,不仅能帮助我们更好地选择和使用数据库,更能让我们在进行系统设计和性能优化时,做到胸有成竹,游刃有余。

阅读剩余
THE END