多版本并发控制协议(MVCC)详解
多版本并发控制协议 MVCC
这篇文章我们先从整体上介绍一下多版本并发控制协议, 后续我们会对多版本并发控制协议(MVCC) 的每个部分的具体实现进行分析, 主要解释在 BUSTUB 中是如何实现的.
上篇文章 我们介绍了在数据库的并发事务中会出现的常见的几种冲突, 以及在 文章 中介绍了一些事务的并发控制以及调度机制, 冲突可串行化的概念等.
那么 MVCC 是如何保证数据库 DBMS 的 AICD 特性的呢, 是如何解决并发事务的冲突的呢, 如何调度才能实现冲突的可串行化呢? 带着这些问题我们来介绍一下多版本并发控制协议(MVCC).
MVCC 简介
多版本并发控制协议(MVCC) 是一个数据库中较大的概念, 不仅仅是一个并发控制协议这么简单, 它涉及数据库的设计与实践. 最近十年的数据库引擎中, 基本采用的都是 MVCC. 对于数据库中的一个逻辑单元, MVCC 会维护这个逻辑单元的多个物理版本. 当一个事务写一个数据库对象的时候, DBMS 就会新建这个对象的一个版本. 当一个事务读一个数据库对象的时候, 它需要读取事务开始时, 在数据库中的最新版本. 版本的控制意味着, 使用 MVCC, 数据库中的读事务不会阻塞写事务, 写事务不会阻塞读事务, 但是当多个写事务修改相同的数据库逻辑对象时, 写事务还是会阻塞写事务, 这是因为写事务还是涉及到锁.
MVCC 的一大优势就是仅读的事务只需要读一个连续的时间片, 无需使用锁控制, 而且很方便的支持时间旅行查询.
一个典型的基于 MVCC 的数据库需要考虑如下设计:
- 使用一个版本仓库存储数据库中逻辑对象的不同的物理版本.
- 当一个事务开始的时候, DBMS 获取当前数据库的快照.
- DBMS 使用快照决定事务使用逻辑对象的那个物理版本.
基于 MVCC 的数据库中涉及到以下五个方面的关键设计点:
- 并发控制协议, MVCC 通常会使用时间片的方式进行事务并发控制, 但是也可以使用 2 阶段锁
- 数据库对象的版本管理
- 垃圾回收机制
- 检索管理
- 数据库对象的删除
快照隔离
MVCC 机制中的多版本控制的方式采用的是快照隔离.
快照隔离机制是指在事务开始的时候为事务提供一个数据库的连续快照, 快照中的数据仅来自已经提交的事务, 事务对数据处理时处于完全隔离的状态. 对于读事务, 它不必等待写事务, 避免了读写冲突, 对于写事务, 对数据的修改会暂存在写事务的私有空间中, 直到事务成功提交时才会生效.
在基本的 MVCC 中, 通常使用时间片进行事务的并发控制, 使用快照隔离实现版本控制.
但是仅依靠时间片算法与快照隔离无法直接解决所有冲突类型, 例如下列冲突:
- 写偏差: 当两个事务根据不同的条件修改数据对象的时候, 可能造成写偏差, 例如修改棋子颜色问题, 事务一将所有白色棋子颜色修改为黑色, 而事务二将所有黑色棋子修改为白色, 最后的结果可能既不符合事务一的要求, 也不符合事务二的要求.
- 幻读: 仅使用快照隔离的 MVCC 无法解决幻读的问题. 幻读的问题可以参考前一篇博客.
由于 BUSTUB 最终需要实现所有冲突的可串行化, 我也后实现, 所以这里只对快照隔离与时间片控制进行简单的介绍, 后续具体实现的时候会做出更加详细的解释.
版本存储
MVCC 的一大核心技术就是需要存储相同数据库逻辑对象的不同物理版本, 以及如何在某一时刻找到该逻辑对象对应的物理版本.
MVCC 的基本思想是使用 Tuple 中的指针来构建版本链, 版本链通常按照时间戳的顺序构建, 也就是事务执行的顺序构建. 版本链允许事务找到事务执行时对应的版本, 事务通常会按照顺序遍历版本链, 找到事务对应的版本, 不同的版本链的构建方式也使用不同的检索机制. 常见的 MVCC 的数据的存储与版本的管理方式如下:
基于隐藏列的行存储(Undolog/Tuple-Versioning)
存储方式
在数据库表的每一行数据(Tuple)中存储额外的隐藏字段, 例如:
- 创建事务ID(Create Transaction ID, txid_created): 记录插入该行的事务ID.
- 删除事务ID(Delete Transaction ID, txid_deleted): 记录删除或更新该行的事务ID.
- 指向旧版本的指针(有些实现会有)用于回溯查询历史版本.
版本管理方式
读取数据时, 数据库通过事务ID(txid)判断哪些数据是可见的:
事务 txid 只能看到比它早提交的数据(txid_created ≤ txid).
事务 txid 不能看到未提交的事务生成的数据(txid_created 是未提交的).
事务 txid 不能看到它启动后被其他事务修改或删除的数据(txid_deleted ≤ txid).
更新数据时:
并不会直接覆盖原数据, 而是创建一个新版本, 并修改 txid_deleted 指向当前事务的 ID, 表示该行数据已被修改或删除.
垃圾回收(GC, Vacuum)
旧版本的数据由后台线程或 VACUUM 进程回收, 例如 PostgreSQL 的 autovacuum.
基于回滚段(Undo Log)的存储
存储方式
数据表只存储当前版本, 每次修改都会将旧版本的数据存入 Undo Log(回滚日志).
Undo Log 采用链表结构, 每次事务修改数据时, 会把旧值存入 Undo Log 并更新指针.
版本管理方式
读取时:
- 事务从 Undo Log 反向回溯找到符合其可见性的版本.
- 事务 txid 不能看到 Undo Log 中比它晚的版本(即新事务修改后的数据).
更新时: - 直接修改数据表中的记录, 并在 Undo Log 中存储旧值.
- 删除操作不直接移除数据, 而是插入一个“删除标记”并写入 Undo Log.
Undo Log 清理
只要所有事务都不需要访问某个 Undo Log 版本, 它就可以被清理(例如 MySQL InnoDB 的 purge 线程).
基于 Append-Only 的存储(LSM-Tree)
存储方式
每次更新或删除数据时, 不修改原数据, 而是追加一个新的版本(append-only).
采用 LSM-Tree 结构, 将新的数据版本写入MemTable, 并周期性合并到SSTable(顺序存储).
版本管理方式
读取时:
- 事务需要按时间戳(或版本号)扫描数据, 获取最新可见版本.
- 查询时利用时间戳索引或多层数据合并策略(compaction)优化查询性能.
更新时:
旧版本数据仍然保留, 新版本直接追加写入.
垃圾回收
通过合并压缩(Compaction)机制删除无效版本(HBase、RocksDB 采用 compaction 机制).
基于 Delta Storage(增量存储)
存储方式:
适用于列存储数据库, 以减少存储开销.
主要分为:
- Base Storage(基础存储): 存储最早提交的版本.
- Delta Storage(增量存储): 存储变更数据(类似于日志结构).
版本管理方式:
读取时:
读取 Base Storage, 再应用 Delta Storage 里的变更, 得到最新数据.
更新时:
变更不会立即修改 Base Storage, 而是追加到 Delta Storage.
合并(Merge):
周期性合并 Delta Storage 和 Base Storage, 清理旧版本.
垃圾回收
由于事务状态的改变或者数据的修改, 着时间的推移, DBMS需要从数据库中删除可回收的物理版本.
常见的垃圾回收有两种机制, 分别是基于 Tuple 级别与基于事务级别.
Tuple 级别的垃圾回收机制
Tuple 级别的垃圾回收机制通常使用一个后台线程定时的扫描数据库表, 查看哪些 Tuple 是被修改的, 并且按照时间片是已经过期的了, 没有事务会再次访问, 那么就将该版本删除. 这种方式的问题是后台线程太耗时, 因此通常也会使用页面的位图来跳过那些没有修改的页面.
事务级别的垃圾回收机制
事务级别的垃圾回收机制由事务自身管理 Tuple 的状态, 事务会管理一个事务执行过程中的 Tuples 集合, 当事务执行完成后, 会将该集合内的所有 Tuples 标记为可回收, DBMS将这些可回收的 Tupels删除, 完成垃圾回收机制
检索管理
在 MVCC 中, 所有主键(pkey)索引始终指向版本链的头(version chain head). 在 MVCC 中, 每条数据(tuple)可能有多个版本, 但主键索引始终指向该数据的最新版本(最新的事务修改后产生的新版本).
主键索引的更新
主键索引的更新频率取决于 MVCC 版本管理策略, 如果数据库在更新时创建新的版本(即不会直接覆盖数据), 那么主键索引在插入新版本时可能需要更新. 如果更新的是主键字段, 数据库会将其视为:
- DELETE(删除旧记录)
- INSERT(插入新记录)
这样做是因为主键的变更会导致原始数据的地址发生变化.
二级索引(Secondary Index)的管理方式
二级索引是指索引的叶子节点存储的是主键值, 而不是数据行的物理地址(对于 InnoDB), 在使用二级索引检索时, 需要先通过二级索引找到主键, 再用主键索引获取完整数据(即 回表查询).
二级索引的管理比主键索引要复杂, 主要有两种方法:
逻辑指针(Logical Pointers)
给每个 tuple 赋予一个固定的标识符(logical ID), 这个 ID 在整个 tuple 生命周期内不变. 二级索引存储这个逻辑 ID, 而不是物理地址.
需要一个额外的映射层(indirection layer): 将逻辑 ID 映射到 物理地址(指向数据的实际存储位置). 也就是使用一样映射表存储 Tuple 的 ID 与 RID 之间的关系. 这样, 每次更新 tuple 时, 只需要更新映射关系, 而不需要修改索引.
物理指针(Physical Pointers)
索引直接存储物理地址(physical address), 指向版本链的头(最新版本).
当版本链头更新时, 索引也必须更新: 例如, 如果一个事务创建了新的 tuple 版本, 索引中所有指向这个 tuple 的二级索引都需要更新, 指向新的版本.
这样的好处时使用二级索引也可以直接访问物理地址, 减少一次间接查找, 提高查询效率. 但是更新成本高, 因为每次更新 tuple 都可能导致多个索引项的修改.
MVCC Duplicate Key Problem (MVCC 重复键问题)
为什么要支持重复 key ?
在 MVCC 中, 一个逻辑 Tuple 存在多个不同的物理版本, 这些不同物理版本的物理地址是不同的, 但是可能这些不同的版本主键相同, 此时 Key 是相同的, 需要支持重复的 Key, 但是也有可能主键不同, 即上述的更新主键字段, 删除旧记录, 插入新纪录.
例如:
事务 TXN1 看到 tuple 的版本 V0. 事务 TXN2 修改了 tuple, 并创建了 V1.
这样, TXN1 仍然需要访问 V0, 而 TXN2 需要访问 V1.
这意味着, 索引中需要同时存储 V0 和 V1, 以确保两个事务都能访问正确的版本.
索引查找的处理
查询时, 可能会返回多个匹配项:
例如, 事务 TXN 可能在索引中获取到多个版本的 tuple 地址. 然后, 事务必须沿着 MVCC 版本链遍历, 找到自己可见的版本: 跟随指针(pointers)检查版本的事务 ID(txid), 确保它符合当前事务的可见性规则.
删除的处理
DBMS 需要控制 Tuple 在数据库中的逻辑删除与物理删除, 删除的控制可以很好的节省与控制资源的使用. 通常使用两种方式管理 Tuple 的删除, 分别是删除标记以及空 Tuple 的方式.