CMU 15445
Storage Manager
DBMS 底层是如何存储数据的?
BusTub 中有基于内存的存储后端(可以设定读写延时)和基于文件系统的存储后端 DiskManager。DiskManager 支持 Page 粒度的读写,使用链表或字典组织页面(BusTub 中没有 Page 回收的逻辑,使用数组)。
Page Header 包含以下信息:
- Page Size
- Checksum(校验数据存储的正确性)
- DBMS Version
- Transaction Visibility(事务可见性)
- Compression Information
Page Data 部分的布局,通常采用 Slotted Pages 或 LSM,Slot 从页的 Data 区域的最前面往后存,Tuple 是从 Data 区域的最后面往前存,Tuple 之间可以是乱序的,Slot 的数据可以动态更新,简化了 Tuple 存储的方式。
BusTub 中没有使用 Direct I/O 的原因与影响?
课上提到,大部分数据库管理系统使用 Direct I/O 绕过内核的 Page Cache,但 BusTub 的 DiskManager 并没有使用 Direct I/O,在维护自身缓冲池的同时,也使用了 Page Cache。
- 原因:Direct I/O 要求 4K 对齐和文件系统支持,实现较繁琐。BusTub 目前没有考虑 Recovery,所以没有用到
fsync()
。 - 影响:应该不影响 Buffer Pool Manager,也不影响性能,取决于 Page Cache 容量。正确性上没有区别。有一些场景需要 Page Cache + Buffer Pool Manager,有一些场景 Direct I/O 更合适。如读无法 4K 对齐,其实用 Page Cache 也问题不大;还有一些 page compression 的场景,如果能让 Page Cache 缓存压缩后的 page,也可以省一点 I/O。正确性上,如果有 ARIES,正确性由 redo/undo log 保证;redo undo log 做了
fsync()
就没有正确性问题。
如果使用 O_DIRECT
选项,磁盘读写从文件系统层直接访问最底层 的存储设备,不走操作系统层的 Page Cache。但除了文件数据本身,文件的元数据(如文件大小)也会影响数据完整性。Direct I/O 只对文件数据本身有效,元数据读写还是会经过内核缓存。因此即使使用了 O_DIRECT
选项,在写入后,还需要调用 fsync()
保证数据真正落到磁盘。
fsync
卡顿怎么处理?
参考 PostgreSQL 9.6 平滑 fsync, write 原理浅析-阿里云开发者社区 一文,当数据库产生尖锐(堆积)的 I/O 需求时,就可能造成 fsync()
的卡顿。解决方式为:
- 定期检测 os dirty page 的占比,超过水位线则异步刷脏
- 对属于同一个存储块的脏页排序后再写入,降低离散 I/O
将数据刷入磁盘时宕机了怎么办?
参考 基于 Redo Log 和 Undo Log 的 MySQL 崩溃恢复流程 - 知乎 一文,可以使用 Redo Log 和 Undo Log 以保证数据的持久化。MySQL 使用 2PC (two-phase commit protocol) 以保证 Redo Log 和 Binlog 在事务提交时的数据一致性。
- Redo Log 记录了此次事务「完成后」的数据状态,记录的是更新之「后」的值
- Undo Log 记录了此次事务「开始前」的数据状态,记录的是更新之「前」的值
Buffer Pool
LRU-K 比 LRU 替换算法好在哪里?
在 LRU-K 算法的论文中,列举了以下情况:
- 内部事务 (Intra-Transaction):事务进行更新时,先读取某页,再更新该页
- 事务重试 (Transaction-Retry):事务访问页后中止,重试时再次访问该页
- 内部流程 (Intra-Process): 具有相同流程的事务接连访问某页,如连续更新 100 条记录
- 巧合流程 (Inter-Process): 两个完全独立的事务刚好访问相同的页
前 3 个例子被称作关联访问 (correlated reference-pair type),LRU 算法无法处理这些情况,无法避免因为周期性模式而淘汰了需要长时间保存在缓存中的数据;原始的 LRU-K 算法在保留前 K 次的访问情况的同时,会将关联访问缩点,从而提高缓存的命中率和性能表现。
在 BusTub 的测试中,为 Scan 线程和 Get 线程提供了不同的标签,可以用比较方便的方式区分关联访问和非关联访问。
LRU-K 替换算法中 K 的选择依据?
一般取 K=2。对于稳定不变的访问规律,K 越大,cache 命中率越高;但访问规律变化较大时,K 越大则表明需要更加多的访问去适应新的规律,因此变化响应更差。
在 BusTub 的测试中,包含多个顺序访问的 Scan 线程,和基于 zipfian 分布访问页面的 Get 线程,K 的默认取值为 16,Frame 数量为 64。使用为读写操作上添加 1ms 延迟的内存存储后端,对 K 分别为 1、2、3、4、16 的情况进行 30 秒的测试,命中率结果如下:
| Scan\K | 1 | 2 | 3 | 4 | 16 | | ------- | ----- | ----- | ----- | ----- | ----- | | 区分 | 0.051 | 0.143 | 0.106 | 0.084 | 0.050 | | 不区分 | 0.055 | 0.143 | 0.106 | 0.087 | 0.047 |
其中 K=1 的情况(LRU)和 K=16 的情况,命中率基本一致,约为 5%;K=2 时命中率最高,约为 14%;之后伴随着 K 的增加,逐渐趋近 LRU 的命中率。可能是测试场景问题,区分关联访问似乎并非带来明显受益。结合实验,K=2 确实是 LRU-K 替换算法的最佳选择。
LRU 的时间复杂度为 O(1)
,LRU-K 的时间复杂度为 O(logN)
,在 K=2 的 LRU-K 替换算法的基础上,后续业界还提出了一些时间复杂度为 O(1)
的 改进算法 ,如 2Q、LIRS 等。
如何实现无锁 Buffer Pool?
无锁 Buffer Pool 的实现需要无锁数据结构的支持,在 Buffer Pool 中使用到的数据结构,一是提供从 Page ID 到 Frame ID 映射的哈希表,二是维护了替换策略信息的某种数据结构。
参考 A Lock-free Buffer for WattDB - Lume UFRGS 论文中给出的无锁 Buffer Pool 的实现,替换策略基于非阻塞版本的 GCLOCK 算法 (Nb-GCLOCK),无锁哈希表基于 Libcds (Khizsinsky 2013) 的实现,使用 FIX-USE-UNFIX 协议以保证并发操作的一致性。
数据库为什么要用 Buffer Pool 而非 mmap 管理内存?
Buffer Pool 可以针对应用程序的访问模式进行调整,如特殊的淘汰策略、预取优化、将脏页按顺序刷盘。参考 Are You Sure You Want to Use MMAP in Your Database Management System? ,mmap 由 OS 将文件的一部分映射到内存中,存在以下问题:
- OS 可能在任何时候刷脏,从而破坏事务(解决方案:CoW / Shadow Paging)
- 伴随着不确定的 I/O 开销(解决方案:OS hints)
- 缺页中断导致的总线错误,增大程序中错误处理的难度
- 不确定文件是何时读取的,难以校验文件的有效性
- 难以性能优化
Buffer Pool 的优化方式?
- 在 BusTub 中,我细化了 bpm 读写锁与 frame 的锁,实现并发 I/O,QPS 提升 25 倍。
- 优化 LRU-K 算法,借助 Perf 和火焰图工 具,发现 LRU-K 中用于存储历史记录的
std:: list
结构开销较大,可以尝试改为 2Q 等更高效的算法。 - 关联访问缩点。对于顺序扫描类型的数据,可以不存入 Buffer Pool。
- 还有一些优化方式,如预取、共享扫描、Direct I/O 等。
B+Tree Index
BusTub 中 B+Tree 的用途?
Project 2 实现了不支持重复键和并发安全 Iterator 的 B+Tree,用于索引,支持创建最多 2 列的 Integer 类型索引。BusTub 中共有 3 种索引:
- BPlusTreeIndex
- LinearProbeHashTableIndex(线性探测法)
- ExtendibleHashTableIndex(可拓展哈希)
在已经建立索引的表上排序、点查询、范围查询时,Query Optimizer 会将 SeqScan 优化为 IndexScan,从而使用到我们实现的 B+Tree。课程组保证了测试数据中不会出现重复键。
B+Tree 相比 B-Tree 的优劣?
B+树和 B-树都是常用的数据结构,用于在磁盘或其他外部存储设备上实现高效的索引结构。B+树相比 B-树有以下优势:
- 更适合外存储:B+树将所有数据都存储在叶子节点中,而非在中间节点中,B+树的每个节点可以存放更多的 Key,相同数据量下,层 高比 B-树更低,可减少 I/O 的次数。
- 更适合范围查询:B+树叶子节点构成链表,只需要遍历叶子节点即可进行范围查询或排序;B-树则需要用中序遍历的方式。
B-树相比 B+树的优势在于:
- 更适合基于频率的搜索:所有关键字在 B-树中出现且只出现一次,非叶子结点可以命中。经常访问的元素可能离根节点更近,访问更加迅速。
B+Tree 增删查的流程?
- 查找:每次找到第一个严格大于 key 的节点的前一个节点,直至叶节点。
- 插入:先按照查找逻辑找到应该插入的位置,如果插入后
LeafPageSize = MaxSize
,则将一半的键值对分裂到新产生的右兄弟节点中,并递归向父节点插入。 - 删除:先按照查找逻辑找到应该删除的位置,如果删除后
PageSize < MinSize
,则向兄弟节点借节点,或与兄弟节点合并,合并后需要在父节点中递归删除被合并节点的键值对。
B+Tree 中节点大小的选择依据?
- Page 大小:B+树中节点大小通常设为 Page 或 Page 的倍数。如果节点大小不满一页,但读取该节点时依然需要读取完整的一页,就造成了资源的浪费;如果每个节点都刚好一页的话,就可以用 Page ID 作为指向其他节点的指针。MySQL 中 B+ 树节点大小为 16K(1 页),层数为 3 的 B+树,可以存放约两千多万条的数据。
- 磁盘性能:存储设备越慢,B+树节点需要越大,对单个节点的磁盘 I/O 就可以读取更多的数据;存储设备越快,B+树节点需要越小,减少冗余数据。
- 负载类型:OLAP 类型负载常会全表扫描,会遍历 B+树的叶子节点,可增加节点大小,单次 I/O 能扫描更多数据;OLTP 类型负载常会进行点查询,可减小节点大小,降低查询过程在节点之间跳跃的开销。
B+Tree 如何处理 Duplicate Keys?
- 为 Key 追加主键 ID:DBMS 可以使用 Partial Key 查找
- 叶子节点溢出:在原有的叶子节点后面,外接一个溢出节点
MySQL 在 InnoDB 引擎下,非唯一索引会同时存储对应行的主键 ID,所以非唯一索引相同时,会按记录的主键进行排序。
B+Tree 如何处理 Variable-Length Keys?
参考网友的总结,主要有下列 4 种方法:
- 改为存储 Key 的指针:笔者个人感觉有可能采取 ELF 文件格式里符号表中的表项里的符号名存储的是该符号名在字符串表中的索引这种策略
- 让 B+树每个节点也都是变长节点:但这种策略并不推荐,因为节点变长的话就不一定对应一个或多个文件页了,管理的难度会变大
- Padding 策略:padding 这个词笔者之前在学习 OS 的引导扇区时见过,OS 启动所需的引导扇区的大小是固定的,xv6 中引导扇区是位于磁盘的第一个 block,但引导扇区中存储的用于启动的代码的长度未必正好有一个 block 的大小,因此就需要在程序文件剩下的位置补 0,直至总长度达到一个 block 那么大,然后存入磁盘里对应的扇区,从而制作好引导扇区,这种方式就叫 padding,在 DBMS 里,如果 Key 是变长的,我们也可以再向 Key 中补入空的字节,直到让 Key 的长度达到我们所设计的 fixed-size
- Slot 策略:和存储引擎模块里的 tuple 在 Page 里面的 Layout 通过 slot 来组织的方式)很像,节点里面有数组形式排布的 slot,slot 中存储指向对应 KV 的指针,这使得节点中 KV 的存储有很大灵活性
B+Tree 的优化方式?
- 前缀压缩 (Prefix Compression):在同一个叶子节点里很多 Key 的前缀是相同的,可以在叶子节点的元数据中存储公共前缀
- 去重 (Deduplication):多个 KV 的 K 相同时,可去掉冗余的 K 减少占用空间
- 批量插入 (Bulk Insert):如果一开始你就知道要插的所有 KEY,可以对他们先排序,然后对这些 KEY 去构建 INDEX BOTTOM UP,这种思想和创建堆时可以选择快速建堆策略而非一个一个地插入建堆的思想有点像
- 指针调整:如果一个 PAGE 已经在 BUFFER POOL 里 PINNED 了 ,我们可以存直接的指针来代替原来存的 PAGE ID。避免去 PAGE TABLE 查 ADDRESS
Latch Crabbing 的基本思想?
Latch Crabbing 是 B+树常用的并发策略,意思是锁的释放和获取就像螃蟹一样在节点间爬行。线程在遍历时,只有当子节点被认为是安全时,才释放父节点的锁。安全的定义是:节点在进行操作后,不会触发分裂或合并,影响父节点的指针。
- 插入:叶节点和中间节点的安全性分别考虑,因为它们分裂的时机不同
- 删除:子节点要比最小的 minsize 大
Latch Crabbing 乐观锁如何实现?
标准的 Latch Crabbing 是悲观锁,插入删除都要对根节点加写锁,根节点是锁的瓶颈,高并发场景下导致糟糕的多线程扩展性。
乐观锁采用一种乐观思想,假设大部分写操作不会修改树结构。实现比较简单,修改 find_leaf_page
的逻辑,一路加读锁,最后对 leafpage
加写锁即可。如果发现对 leafpage
的操作会影响 b+树结构时,放弃获取的所有锁,回滚到根节点,悲观地获取锁。
如果 B+ 树已经有好几层,每一个叶节点可以容纳大量键值对信息,在写操作下不会那么容易发生合并分裂操作,乐观锁可以显著提升性能。但是如果树只有几个节点,频 繁发生分裂合并,性能就比较差了。
什么是 B-link Tree?
在我的 BusTub 实现中,我使用的是「基于螃蟹协议的乐观策略」,然而,B+Tree 本身通常是「矮胖」的,如 MySQL 通常为 3 层。这种情况下,需要持有 2 层读锁的实现方式就未必足够乐观。B-link Tree 就是一种进一步提升并发能力的数据结构,可以避免在树结构调整时全局加锁而带来的性能衰退,主要有两点核心思想:
- 在中间节点增加字段 link pointer,指向右兄弟节点,B-link Tree 的名字也由此而来
- 在每个节点内增加一个字段 high key,在查询时如果目标值超过该节点的 high key,就需要循着 link pointer 继续往后继节点查找
B-link Tree 的劣势在于需要额外维护 link pointer 和 high key 字段,以及查询时需要额外判断,如果查询超过 high key,需要额外通过 link pointer 查询其后继节点,在数据库应用中可能会产生一次额外的 IO,从而造成单次查找性能的下降。
如何实现无锁 B+Tree?
在创建这张卡片时,对无锁容器的理解还不够深入,这里先贴几篇链接:
- 微软提出的无锁 B 族树 —— Bw-tree - 知乎
- 数据库管理系统(二): Lock-free, high-performance Bw Tree - 知乎
- 高性能无锁数据结构探索-B+树读写、SMO 及 swap - 知乎
常见的 Hash 算法有哪些?
- Static Hashing Schemes
- Linear Probe Hashing
- Robin Hood Hashing
- Cuckoo Hashing
- Dynamic Hashing Schemes
- Chained Hashing
- Extendible Hashing
- Linear Hashing
什么是跳表?
跳表(Skip List)是一种数据结构,用于实现有序集合(例如有序的键值对)。它可以在 O(log n) 的时间复杂度内执行搜索、插入和删除等操作,与平衡树相比,它的实现更加简单,而且在许多情况下性能也更好。
跳表通过在每个节点上增加多个指针,从而实现了跳跃式的访问。在一个跳表中,每一层都是一个有序的链表,每个节点上都包含了多个指向下一层节点的指针。这些指针可以让我们在搜索时跳过一些不必要的节点,从而提高搜索的效率。
跳表的实现基于一种简单的随机化算法,该算法可以保证跳表的高度始终保持在 O(log n) 级别,从而保证了跳表操作的时间复杂度。
Query Model
Processing Models 有哪些?
在 DBMS 中,SQL 语句会被组织成树状的查询计划,数据从叶节点流向根节点,查询结果在根节点得出。 Processing Model 定义了系统如何执行 Query Plan,主要有三种模型:
- Iterator Model(迭代器模型/火山模型/管道模型):父节点递归调用子节点的
next()
- Materialization Model(物化模型):每个算子一次性返回全部数据
- Vectorized/Batch Model(矢量化/批量模型):每个算子一次返回一批数据
不同模型的区别如下:
| 模型 | 调用方向 | 提交 | 场景 | | ------------------ | -------- | ----------- | --------------- | | Iterator / Volcano | 自顶向下 | 单个 Tuple | General Purpose | | Materialization | 自底向上 | 全部 Tuple | OLTP | | Vectorized | 自顶向下 | Tuple Batch | OLAP |
详细介绍一下火山模型?
火山模型中,每个算子都实现了 Init()
和 Next()
方法,每次调用时操作符会返回一个 Tuple 或者 Null,Null 表示已经全部返回完了。操作符本身由一个循环实现,循环内部调用其子操作符的 Next()
方法,并从它们那边获取下一条数据供自己操作。
部分算子会在 Init()
阶段直接获取子操作符的数据并操作,如 Sort、Hash Join、Aggregate 等,这一类算子被称为 pipeline breaker。在 BusTub 中,我们假设这类操作完全适合内存,因此实现比较简单。
火山模型的优劣?
参考 SQL 优化之火山模型 - 知乎 一文,火山模型的优点在于实现简单,每个 Operator 可以单独抽象实现、不需要关心其他 Operator 的逻辑;缺点在于造成大量的虚函数调用,不利于编译器优化、CPU 分支预测等,进而降低 CPU 的利用率。
在 I/O 速度获得极大提升的今天,对火山模型的优化主要可以从两方面考虑:
- 代码生成:将查询计划生成为通过循环推数据的代码,从而避免虚函数调用以提升速度。但可能因查询计划过于复杂而导致代码生成过慢。
- 向量化:算子每次返回多条数据,借助 SIMD(Single Instruction Multiple Data,单指令流多数据流)技术提高 CPU 的利用率,通常基于列存储。
火山模型一次吐出多个 Tuple,和向量化模型的区别?
参考 列式数据库和向量化 | zulu.wang 一文,向量化模型通常是针对列存储而言的,具有如下优势:
- 减少 I/O 的读入总量:列存储利于压缩,读取一组数据所需 I/O 次数比行存储少
- 可以充分利用 CPU 的缓存:在基于列的处理中,只会读入感兴趣的列数据,可以有效使用 CPU 的内存带宽;行存储还需要进行一些列裁剪的优化才能减少冗余数据。
火山模型一次吐出多个 Tuple,说明仍是基于行的处理,相比基于列存储的向量化模型,两者的区别应该主要在 I/O 成本上。
Query Executor
External Merge Sort 实现细节?
外排序 (External Merge Sort) 的思路和归并排序比较像。以 2-Way 外排序为例,待排序数据占 N 页,缓冲池大小为 B 页,缓冲池最少要有 3 页(2 页存储输入数据,1 页缓存中间结果),外排序的步骤如下:
- 先将带排序的数据分块(在 DBMS 就是 Page 大小的块)
- 将每块放入内存分别排序,得到有序子串
- 将排序好的子串合并为更大的有序子串
排序的优化方式:
- Double Buffering Optimization:预读下一个要排序的 Page,类似流水线
- 使用 n-Way 外排序,,则有:
- Number of Passes:
- Total I/O Cost:
- 使用有序的 B+Tree 索引
Aggregation 实现细节?
Aggregation 算子的执行有三种策略:
- PLAIN-AGG:计算不含
group by
的聚集函数 - SORT-AGG:输入的元组已经是有序的
- HASH-AGG:BusTub 中实现的
- Partition:以 Group By 字段构建哈希表,以哈希桶为分区落盘
- Rehash:区分哈希碰撞的值,将结果放入最终哈希表中
Join 早物化和晚物化的区别?
- 早物化 (Early Materialization):Join 操作输出完整的一行数据,无需回表。
- 晚物化 (Late Materialization):Join 操作输出对应原始表的 Record ID,获取其他字段需要回表,适合列存储的情况,DBMS 在对 Join 后得到的表执行查询时,无需拷贝不需要的数据。
Nested Loop Join 实现细节?
以 Inner Equal Join 为例,有 3 种 Join 的实现方式:
- Simple / Stupid Nested-Loop Join:外表占 M 页,内表占 N 页,外表的元组数量为 m,则开销为 。
- Block Nested-Loop Join:将外表的数据批量与内表的数据进行匹配。外表占 M 页,内表占 N 页,缓冲池大小为 B 页,缓冲池一个页用于输出,一个页用于缓存右侧(内表),其余表都用于缓存外表。则开销为 。
- Index Nested-Loop Join:以内表参与 Join 的那一列字段为 Key 构建索引,假设每次查询索引的开销为 C,则开销为 。
Sort-Merge Join 实现细节?
又名 Merge Join,先给参与 Join 的两个表按照连接列的字段进行排序,然后对这两个排好序的表进行 Merge,Merge 的操作类似于双指针(存在回溯右指针的情况)。
开销为 ,在参与 Join 运算的表已经按照连接列排好序时(如 Sort 算子、索引),或者输出要求为按照连接列排序时,使用 Merge Join 更合适。
Hash Join 实现细节?
基础的 Hash Join 主要由两阶段组成:
- 构建哈希表 (Build): 扫描 Outer Table,使用哈希函数 H1 构建哈希表,以连接列为 Key
- 点查询 (Probe):Inner Table 以哈希函数 H1 进行查询
在点查询时,一种可行的优化是在构建阶段创建布隆过滤器 (Bloom Filter),以提前确定不存在的相关表项。而对于假阳性的情况,则通过对哈希表的查询以排除。
Grace Hash Join 实现细节?
针对内存无法存下整个哈希表的情况,需要将哈希表的部分内容驱逐到硬盘,采用 Grace Hash Join 算法:
- 构建哈希表 (Build): 对 R 表和 S 表都使用相同的哈希函数构建哈希索引,存入硬盘,读取和写入的开销为
- 点查询 (Probe):把硬盘中两个表相具有相同桶号的哈希桶取出,做 Nested Loop Join,匹配所需开销为
对于哈希桶太大的情况,则另一种哈希函数递归地分区,直到拆分为足够小的块。Grace Hash Join 的总开销为 。
不同 Join 算法的使用场景?
- Block Nested Loop Join:绝对不会用 Simple Nested Loop Join 的
- Index Nested Loop Join
- Sort-Merge Join:
- 数据倾斜时,为其构建哈希表会导致严重的哈希碰撞
- 输出要求有序
- 下层算子已经经过排序
- Hash Join:OLAP 下多数情况下最好的选择
Query Optimize
查询优化的方式?
查询优化是数据库最难的部分。优化通常包含两个阶段:
- 基于规则的优化(Rule-Based-Optimization,简称 RBO):如常量折叠、列裁剪、谓词下推、谓词上推、Max/Min 转 TopN 等
- 基于代价的优化(Cost-Based Optimization,简称 CBO):如 Join Reorder 等
怎么实现基于代价优化?
使用代价模型来进行索引选择和算子选择,计算每个索引的访问代价和计划中每个物理算子的执行代价(如 HashJoin、IndexJoin 等),选择代价最低的计划。在 2022 的 BusTub 中,提到了 Join Reorder 的代价优化,每个表可以根据后缀得到表的规模,进而计算执行代价。