多版本并发控制(MVCC):多版本并发控制比单纯的并发控制协议更为广泛,它涉及 DBMS 设计与实现的各个方面。MVCC 是数据库系统中最广泛使用的方案,几乎所有在过去十年里实现的新 DBMS 都采用了它。即便一些不支持多语句事务的系统(例如某些 NoSQL)也在使用它。
在 MVCC 中,DBMS 为同一个逻辑对象维护多个物理版本。当一个事务对对象执行写操作时,DBMS 会创建该对象的新版本;当一个事务读取对象时,它读取的是该事务开始时"存在"的最新版本。
MVCC 的基本概念/好处在于写者不会阻塞读者,读者也不会阻塞写者。这意味着一个事务可以修改对象,而其他事务同时读取该对象的旧版本。若多个写事务写同一对象,写者之间仍可能互相阻塞,因为与该逻辑对象相关的版本上仍存在锁或写冲突控制。
使用 MVCC 的一个优势是只读事务可以在不使用任何锁的情况下读取到数据库的一致快照。此外,多版本 DBMS 能方便支持"时间旅行查询",即基于数据库在某个过去时间点的状态进行查询(例如查询三小时前的数据库状态)。
典型的基于 MVCC 的数据库设计会包括:
- 一个版本化的存储,用于保存同一逻辑对象的不同版本。
- 事务开始时,DBMS 通过复制事务状态表来获取一个快照。
- DBMS 使用该快照来决定对事务可见的对象版本。
MVCC 有五个重要的设计决策:
- 并发控制协议
- 版本存储
- 垃圾回收
- 索引管理
- 删除(Deletes)
并发控制协议的选择在于采用前几讲讨论过的方法之一(两段锁、时间戳排序、乐观并发控制等)。
快照隔离:快照隔离为事务提供事务开始时的一个一致性快照。快照中的数据值仅由已提交事务的值组成,事务在完成之前与其他事务完全隔离。这对只读事务很理想,因为它们不需要等待其他事务的写。写入可以保存在事务的私有工作区,或与事务元数据一起写入存储;只有当事务成功提交后,这些写才对数据库可见。
写冲突:如果两个事务更新同一对象,先提交(或先写)的事务胜出(first writer wins)。
写偏差(Write Skew):在快照隔离下,当两个并发事务修改不同对象时可能发生写偏差,导致不可串行的调度。例如,一个事务把所有白色弹珠改为黑色,另一个事务把所有黑色弹珠改为白色,最终结果可能无法对应任何串行执行的顺序。
版本存储:这里讲的是 DBMS 如何存放同一逻辑对象的不同物理版本,以及事务如何在运行时找到对其可见的最新版本。
DBMS 使用元组的指针字段为每个逻辑元组建立一个版本链,这本质上是按时间戳排序的版本链表。这样 DBMS 在运行时就能找到对某个事务可见的版本。索引总是指向链表的头,该头节点根据实现可能是最新版本或最旧版本。线程沿着版本链遍历,直到找到正确的版本。不同的存储方案决定了每个版本应存放在哪里以及存放什么内容。
方案一:追加写入(Append-Only Storage)
- 同一逻辑元组的所有物理版本都存放在同一个表空间中。版本在表中混合存放,每次更新只是将元组的新版本追加到表中并更新版本链。链可以按"从旧到新"(O2N)排序,这种方式在查找时需要遍历链,或者按"从新到旧"(N2O)排序,这种方式每次新增版本都要更新索引指针。
方案二:时间旅行存储(Time-Travel Storage)
- DBMS 维护一个单独的表,称为时间旅行表,用来存储元组的旧版本。每次更新时,DBMS 将元组的旧版本复制到时间旅行表,然后在主表中用新数据覆盖该元组。主表中的元组指针指向时间旅行表中过去的版本。
方案三:增量(Delta)存储(Delta Storage)
- 类似于时间旅行存储,但不是保存整个旧元组,而只保存元组间的差异(即增量),这些差异被存放在所谓的增量存储段中。事务可以通过反向遍历这些增量并逐一应用来重建旧版本。相比时间旅行存储,这种方式写入更快但读取更慢。
垃圾回收:DBMS需要随着时间清除并回收可回收的物理版本。若没有任何活动事务仍能"看到"某个版本,或该版本由已中止的事务创建,则该版本可被回收。
方案一:基于元组的垃圾回收(Tuple-level GC)
- 在基于元组的垃圾回收中,DBMS 通过直接检查元组来查找旧版本。有两种实现方式:
- 后台 Vacuum(Background Vacuuming):独立的线程周期性扫描表,查找可回收的版本。此方式适用于任何版本存储方案。一个简单的优化是维护"脏页位图"(dirty page bitmap),记录自上次扫描以来被修改的页面,从而跳过未改变的页面以提高效率。
- 协作清理(Cooperative Cleaning):工作线程在遍历版本链时顺便识别并清理可回收的版本。该方法仅适用于按"从旧到新"(O2N)排序的版本链;如果某数据从未被访问,就永远不会被清理到。
方案二:基于事务的垃圾回收(Transaction-level GC)
- 在基于事务的垃圾回收中,每个事务负责记录自己产生的旧版本,从而免去了 DBMS 扫描所有元组的需要。每个事务维护自己的读/写集合(read/write set)。当事务完成后,垃圾回收器可以利用这些信息来确定哪些元组可以被回收。DBMS 会判断某个已完成事务创建的所有版本何时对任何活动事务都不可见,继而安全地回收它们。
索引管理:所有主键索引(pkey)始终指向版本链的头节点。是否以及多频繁需要更新主键索引取决于系统在更新元组时是否创建新版本。如果一个事务更新了主键属性,则通常将其视为一次 DELETE(删除)后跟一次 INSERT(插入)。
所有主键索引(pkey)始终指向版本链的头节点。是否以及多频繁需要更新主键索引取决于系统在更新元组时是否创建新版本。如果一个事务更新了主键属性,则通常将其视为一次 DELETE(删除)后跟一次 INSERT(插入)。
管理二级索引要复杂得多。有两种处理方式:
方案一:逻辑指针
- DBMS 为每个元组使用一个固定的逻辑标识符(logical id),该标识符不随版本或物理位置改变。这需要额外的一层间接映射,用于把逻辑 id 映射到元组的物理位置。这样,更新元组时只需更新间接映射即可,而不必修改索引条目本身。
- 缺点是多了一层间接访问本身具有少量额外开销,并且需要维护额外的数据结构并处理其并发和持久性。优点在于索引里存固定的逻辑标识符,比如主键索引。我们只需要修改映射表而无需触碰二级索引,大幅度降低索引维护成本。
方案二:物理指针
- DBMS 在索引中直接存储指向版本链头的物理地址(physical address)。这要求在版本链头被更新时更新每个相关索引,这在开销上可能非常昂贵。
MVCC 数据库的索引(通常)不会在键条目中存储元组的版本信息。相反,每个索引必须支持来自不同快照的重复键(duplicate keys),因为同一个键在不同快照中可能指向不同的逻辑元组。
MVCC 重复键问题(MVCC Duplicate Key Problem)描述的正是当多个事务需要同一逻辑元组的多个版本时,索引必须支持重复键的需求。例如:事务 TXN1 指向某逻辑元组的版本 0,而事务 TXN2 创建了同一逻辑元组的版本 1。索引需要能同时指向这两个版本,以便不同事务在任意时刻都能访问到对它们可见的正确版本。
因此,在一次索引查找中,工作线程可能会得到多个条目(对应不同版本),随后必须沿着指针追溯,找到对该事务实际可见的物理版本。
注:更新元组如果不改索引列,只改非索引列,那么索引项无需增删,只需指向新的物理版本或者通过映射表修改head,这种情况下不一定需要重复条目。但是如果更新修改了索引列,必须在索引中同时存在 old-key->old-version 和 new-key->new-version(直到 old-version 可以被回收),这就是产生重复键的直接原因。否则,如果更新被索引的列值并把索引项改为指向新版本时,旧key的索引条目就被覆盖丢失,老事务找不到旧版本了。
删除:DBMS只有在一个逻辑上已删除的元组的所有版本对任何活动事务都不可见时,才会在物理上将该元组从数据库中删除。如果某个元组被标记为删除,那么在其最新的物理版本之后就不能再出现该元组的新版本。这意味着不存在写---写冲突(write‑write conflicts),先写者胜出(first‑writer wins)。
需要一种方法来表示某个元组在某个时间点被逻辑删除。常见有两种做法:
方法一:删除标志(Deleted Flag)
- 维护一个标志位来指示该逻辑元组在最新物理版本之后已被删除。该标志可以放在元组头(tuple header)中,或作为表中的独立一列。
方法二:墓碑元组(Tombstone Tuple)
- 创建一个空的物理版本来表示该逻辑元组已被删除。可以将墓碑元组放在单独的池中,并在版本链指针中使用特殊位模式来表示,从而减少存储开销。