CMU15-445数据库bustub Project #2:B+ Tree(上)

Posted Altair_Alpha_

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了CMU15-445数据库bustub Project #2:B+ Tree(上)相关的知识,希望对你有一定的参考价值。

(最近两个月学校项目有亿点忙,鸽得有点久,先来把 Project 2 补上)

本节实验文档地址:Project #2 - B+Tree

Project 2 要实现的是数据结构课上都会讲的一个经典结构 B+ 树,但是相信大多数的同学(包括博主)当时都没有自己动手实现过它,本节就是一个很好的锻炼机会。

本节内容会大量使用到 Project 1 实现的 BufferPoolManager(当然也包含了其内部用到的 ExtendibleHashTable 和 LRUKReplacer),所以需要完成前置内容(博主也比较建议这样做,否则直接上手本节可能不好理解对 Page 的 Fetch 和 Unpin 操作)。

由于代码量较多,打算拆成上下两篇写完,本篇介绍用到的数据结构和 B+ 树的查找和插入实现,下一篇讲迭代器,删除和并发控制。

关于 B+ 树的文字介绍就不赘述了,查阅资料过程中发现维基百科的 B+ 树词条的算法描述不够具体,推荐一个有比较具体的例子的博客:
B树和B+树的插入、删除图文详解
(同时不建议参考那些插入和删除分 N 多种具体情况讨论的介绍)

数据结构

B+ 树中有内部节点和叶节点两种结构,它们存储的数据格式和内容不同。bustub 为我们设计好了下面这三个类:

  • 节点基类 BPlusTreePage

    内部节点和叶节点的基类,包含了节点类型、当前容量、最大/最小容量、ID、父节点 ID 信息,从类结构上可以看做是两种节点的头信息。按照函数字面意思将其实现即可。可以规定 parent_page_id_INVALID_PAGE_ID 表示根节点

  • 内部节点 BPlusTreeInternalPage<KeyType, ValueType, KeyComparator>



内部节点,首先看用到的三个泛型 KeyType, ValueType, KeyComparatorKeyType 不一定直接可用大于小于号比较,所以引入了 KeyComparator,从 cpp 文件中的实例化可以看出用的是 GenericKeyGenericComparator,查看二者源码可以得到以下信息:

  • GenericKey 可以调用 ToString() 函数得到其 int64 表示,然后用 %ld 格式符打印。这对我们后面调试时非常重要。
  • GenericComparator 的比较规则是:左边小于右边时,返回 -1;左边大于右边时,返回 1;相等返回 0。

ValueType 代表的是指向子页面的指针,从实例化可以看出实际只用了 page_id_t,也就是 int。

数据存储上,其理论结构应为 <指针,键,指针,键…,键,指针>,为方便存储,实际上在头部多补了一个无效键,从而可以用一个 pair 的数组存储:

#define MappingType std::pair<KeyType, ValueType>
...
class BPlusTreeInternalPage : public BPlusTreePage 
...
private:
  // Flexible array member for page data.
  MappingType array_[1];

array_[1] 等价于一个指针,按照一般习惯应该在构造函数中为其 new 出一片大小为 max_size_ 的空间,但实际上不需要这样做,因为:

Each B+Tree leaf/internal page corresponds to the content (i.e., the data_ part) of a memory page fetched by buffer pool. So every time you try to read or write a leaf/internal page, you need to first fetch the page from buffer pool using its unique page_id, then reinterpret cast to either a leaf or an internal page, and unpin the page after any writing or reading operations.

简单翻译一下就是 内部节点和叶节点对象都不是直接创建出来,而是由一个 Buffer Pool 管理的 Page 的 data 部分类型转化而来(所以要用到很少用很暴力的 reinterpret_cast。所以,节点对象使用的是预先分配好的固定空间,array_ 可以控制从该位置开始到 Page 的 data 结束为止的这一段空间。因此,节点对象的生命周期也不是由 new 和 delete,而是由我们上节实现的 BufferPoolManager 管理:取一个页面,用 FetchPage;使用结束归还一个页面,用 UnpinPage。同时也就能理解 BPlusTreePagepage_id_ 成员的另一个含义:它不仅是 B+ 树中节点的编号,同时也是这个节点使用的 Page 在 BufferPool 中的编号

  • 叶节点 BPlusTreeLeafPage<KeyType, ValueType, KeyComparator>


数据存储上,叶节点也是一个 键+值 的数组,但不像内部节点那样第一个键无效。值的类型实际用的也只有一种:RID。这个和我们本节的内容关系不大,大致知道它是代表数据实际存放的位置即可。

BPlusTree 类代表整个 B+ 树:


其主要成员有:buffer_pool_manager_,由外部传入;root_page_id,表示根节点 ID;comparator_KeyComparator 类型的对象,用于键的大小比较;leaf_max_size_internal_max_size_,表示叶节点和内部节点的最大容量。我们需要实现 B+ 树的四个功能:查找,插入,删除和迭代器。

Checkpoint 1:查找,插入和删除

实验非常贴心地将所有内容分为了两个 checkpoint,其中 checkpoint 1 要实现查找,插入和删除功能,checkpoint 2 要实现迭代器和并发控制,Autograder 上也对应有两个提交位置。下面放出的代码都只通过 checkpoint 1,没有考虑加锁,这样能更专注于讲解其本身的逻辑。本篇先讲查找和插入。

查找(GetValue)

给定一个键 x x x,查找其是否在 B+ 树中存在。实现逻辑是先找到键可能在的叶节点,然后扫描一遍叶节点的内容确定是否存在,其中重点是前者。编写一个函数 GetLeafPage,根据 B+ 树的规则,应该从根节点开始,每次在内部节点中找到 k i < x < k i + 1 k_i < x < k_i+1 ki<x<ki+1 的位置,然后沿着 v i v_i vi 指针继续向下,直到达到叶节点。函数实现如下:

Tips:循环时找内部节点中第一个比 x x x 大的键,取其左侧的值即可( k [ 0 ] k[0] k[0]无效),而这样不能探测到 x x x 比所有 k k k 都大的情况,所以要将 next_page_id 初始化为最右侧的键

在此基础上,GetValue 的实现就很简单了:

插入(Insert)

热身完毕,下面进入本节第一个难点,插入的实现。B+ 树的插入流程为:

  1. 如果是空树,创建一个叶节点作为根。注意涉及 root_page_id_ 更新时都要调用一次 UpdateRootPageId,如果是第一次创建传 1 作为参数,更新不用,以下不再复述
  2. 从根节点向下查找到键值应该所在的叶节点。文档说明了不支持重复键,所以先扫描一遍叶节点,如果发现键存在则直接返回 false
  3. 如果叶节点 插入后 达到了 max_size,则要进行分裂(split),创建一个新的叶节点,将原节点的一半内容拷贝到新节点,分裂点的键插入父节点,该键对应的值指向新的叶节点。(如果父节点不存在,说明是第一个叶节点兼根节点,需要创建一个新的根,这种情况和 4 的建根可以合并处理)
  4. 如果父节点(内部节点)插入前 达到了 max_size,也要递归进行分裂并向上插入,此时还要调整原节点的一半子节点的 parent_id_ 指针指向新的内部节点。如果根节点满了,则要创建一个新的根节点,使得 B+ 树长高一层。

Tips:特别注意这里叶节点和内部节点的判断条件是不同的,摘一段文档原文:
You should correctly perform splits if insertion triggers the splitting condition (number of key/value pairs AFTER insertion equals to max_size for leaf nodes, number of children BEFORE insertion equals to max_size for internal nodes).

第 1、2 步代码:


第 3 步,未溢出情况,插入的具体逻辑可以放到 LeafPage 类中做,所以添加一个 Insert 函数,找到插入位置,将所有后面的键值对后移一位,再设置。由于 array_ 是有序的,如果还想提高效率,可以把找插入位置用二分搜索实现。

Tips:comparator_ 也要作为参数传入 Insert,否则 LeafPage 中无法进行键的比较,也就无法查找


叶节点溢出情况,注意处理好 next_page_id_。移动一半数据的逻辑也可以放到 LeafPage 类中,添加一个 MoveDataTo 函数:


Tips:MoveDataTo 不用真的对原叶节点后一半数据进行“抹除”,修改 size 即可,以后的新数据自然会覆盖掉这些数据。

真正的难点来了:如何处理向父节点插入、同时处理父节点可能继续分裂的递归逻辑。需要想清楚的是:在两次递归之间,需要传递的数据是什么?我的设计是,传递两个子节点对象和分裂点的键。前者是为了获取到其父节点,也可以对其本身的父节点指针进行更新,后者是要插入父节点的键。进一步思考,在第一轮,传递的子节点对象是叶节点,而后面每轮是内部节点,看起来不统一,但实际上我们需要这两个子节点只涉及到 page_id 的父子指针的更改,所以,传递的形式应设计为基类指针 BPlusTreePage *,就可以兼顾这两种情况。

这里我用一个 while(true) 循环实现,写成函数递归调用当然也可以。三个传递数据分别命名为 old_tree_pagenew_tree_pagesplit_key

第一轮初始化和到达根节点的处理。正因为用的是 BPlusTreePage *,所以可以兼顾 3 和 4,即上一层是叶节点和内部节点两种情况的建根。


未到达根节点,则在父节点进行插入。这里类似地我在 InternalPage 中也添加了一个 Insert 函数,但要注意逻辑上有一丁点不同,就是查找插入位置要从 1 开始


如果父节点也溢出,创建新的内部节点并移动一半数据。这里涉及到子节点的指针修改,所以直接把逻辑写在这里了。最后将三个传递数据更新,准备做下一轮处理。


细心的读者可能注意到上面达到跳出循环条件后没有 return true 而是写了 break,这是因为在最后一轮循环结束后还要统一做一件事情:释放最后两个页面。


如果你做完后本地测试和 AutoGrader 其它测试都能通过,只有 ScaleTest 报错 SIGSEGV,InternalPage 或 LeafPage 的函数(比如 GetSize())访问了空地址,则很可能是 Insert 函数中没有把所有 Fetch 的 Page 最后 Unpin 掉,导致其一直占着 BufferPoolManager 的空间,最终空间耗尽无法取到新页面,FetchPage 返回 nullptr。检查也很简单,改一下 BufferPoolManagerInstance 的代码,例如每次 Fetch 和 Unpin 时打印一个信息,看一下是不是所有的页面都被释放了(0 号页面不被释放是正常的)。

Debug 方法

这里我要吹爆 bustub 的开发组,他们提供了一个非常好用的工具 b_plus_tree_printer,可视化展现树的结构,帮助检查你的实现效果是否正确。


更感人的是他们还提供了一个打印正确实现的 B+ 树的在线版本,可以与自己本地的效果对比(泪目)


本篇内容到此结束,下一篇继续讲迭代器,删除和并发控制的实现

CMU 15-445 数据库课程第三课文字版 - 存储1

熟肉视频地址:

1. 课程大纲

CMU

这门课主要是关于如何开发一个功能全面的数据库管理系统,而不是如何编写复杂的 SQL 查询以及设计出最合理的关系模型数据库表。这门课会告诉你从低往上设计一个数据库管理系统需要的这些技术栈层:

  • 磁盘管理(Disk Manager)
  • 缓存池管理(Buffer Pool Manager)
  • 访问方法(Access Method)
  • 操作执行(Operator Execution)
  • 查询计划(Query Plan)

涉及的主题包括:

  • 关系数据库(Relational Databases,这个前面两节课已经过了一遍,咱们这个系列从第三节课开始,这部分就省略了)
  • 存储(Storage)
  • 执行(Execution)
  • 并发控制(Concurrency Control)
  • 恢复(Recovery)
  • 分布式数据库(Distributed Databases)
  • 其他一些有意思的实战(Potpourri)

2. 存储介质与为何不用 mmap

CMU

面向磁盘的数据库系统是这样一个系统,软件假定数据库的主要搜索位置在磁盘上。这意味着执行一个查询,它可能要访问不在内存中的数据,它需要将数据从 non-volatile 存储(例如磁盘)加载到 volatile 存储(例如内存)中。

CMU

计算机存储层次结构如上图所示,越往上,大小越小,成本越昂贵但是访问速度越快,越往下,大小越大,成本越低但是访问速度更慢,这些从顶部往下包括:

  • Volatile 存储:断电后,存储的数据会丢失。支持字节访问,即可以直接读取取任意字节大小的数据,也可以直接更新。随机访问与顺序访问的速度差不多。
  • CPU 寄存器
  • CPU 缓存(L1,L2,L3)
  • DRAM(Dynamic Random Access Memory 动态随机存取存储器)
  • non-volatile 存储:断电后,存储的数据不会会丢失。不支持字节访问,只支持块访问,即如果你要读取某一字节的数据,必须将这字节所在的块或者页(page)的数据一起读取出来,并且一起更新。随机访问速度远小于顺序读取,所以对于 non-volatile 存储必须尽量用顺序访问。
  • SSD(Solid State Disk,固态硬盘)
  • HDD(Hard Disk Drive,硬盘驱动器):这里他经常管这块叫做 Spinning Disk Hard Drive,其实就是机械硬盘,一个磁头在磁盘上面不断移动寻找数据。
  • Network Storage(网络存储):像是 AWS 的 EBS 以及 S3 这种网络存储服务。

CMU

在这门课中,不会关心 CPU 寄存器以及缓存将 DRAM 看做内存(Memory),将 SSD、HDD、Network Storage 看做磁盘(Disk)。我们只关心内存和磁盘。

这里其实还有一些新型的存储,可能打破这些边界,例如:

  • Non-volatile Memory:可以像内存一样支持字节访问,同时掉电也不会丢失数据。目前发布的产品是 Intel® Optane™ Memory:https://www.intel.com/content/www/us/en/products/details/memory-storage/optane-memory.html
  • Fast Network Storage:快速网络存储,例如 NAS(Network Attached Storage)

这些新型存储可能会打破现有的设计,但是他们还没有被大幅度的采用,这门课还不会涉及这些。
下面我们再来看看访问这些不同存储大概的耗时(网上各种数字很多,我们只要关注数量级即可)

CMUCMU

因此,我们数据库管理系统的目标,虽然我们想要存储一个超过可用内存容量的数据库,但是我们想给应用程序提供一种错觉,即我们有足够的内存将整个数据库存储在内存中,用一些缓存,预先计算一些数据,允许不同的线程或不同的查询同时运行来避免每次读或写的时候都因为写入或者读取磁盘导致执行效率低。

CMU

我们要在这门课上设计的是一个基于磁盘持久化的 DBMS,如上图所示:

  • 最下面一层是磁盘,放着单个文件或者多个文件构成的数据库文件
  • 用不同的(Page)来划分文件中的内容,是比较学术的说法。
  • 同时文件中还包含页表或者页目录(Directory),类似于文件内容与页与文件位置的索引。
  • 在上层内存中,有一个缓冲池(Buffer Pool),里面使用一些缓存算法将文件中的页和页目录缓存在内存中。
  • 最上层是执行引擎(Execution Engine),直接将读取页的请求发送给缓冲池,缓冲池不存在就会从磁盘查找,根据页目录定位到页,加载到缓冲池,返回内存中页的地址。之后执行引擎用这个内存中的地址做一些事情。

CMU

对于前面提到的内存与缓冲池这一层,想那些学过操作系统或者对操作系统熟悉的人可能知道,操作系统有类似的机制,为啥不通过系统调用使用操作系统现有的机制实现缓冲池呢?例如 mmap() (内存映射文件的系统调用),我们来看下上图所示的这个场景:

  • 假设我们磁盘上有四页,物理内存最多容纳两页
  • 内存分配是先 reverse 虚拟内存,虚拟内存是很大的,基本用不完。在实际使用的时候,commit 映射实际的物理内存。对于 mmap,是先将文件映射到进程的虚拟地址空间,实际使用到这个地址的时候,如果不在内存中(也就是没有实际映射物理内存),就会发生缺页中断(Page Fault),需要阻塞等待加载这页实际映射到物理内存中。
  • 假设我们先读取的是第一页,在虚拟内存中查找我们发现第一页实际没有映射物理内存,发生了缺页中断,阻塞加载磁盘第一页数据到内存
  • 之后读取的是第三页,和上一步一样
  • 如果这时候我们读取第二页,物理内存不够了,我们需要删除内存中的某一页。这个缓存过期策略,我们是全权交给系统了,但是系统并不知道我们的业务,可能不如我们自己管理做得好

如果我们使用 mmap(),我们就是将这种缓存过期以及内存管理全权交给操作系统来执行了,操作系统并不知道我们的业务场景,以及哪些缓存被过期掉是更加合适的。从应用程序的角度来看,我可能需要读取不在内存中的东西,也就会发生缺页中断,我们可以将它交给另一个线程来做,不阻塞当前线程,这样当前线程就可以去处理其他请求,这样可以增加吞吐量。对于只读的场景这样的优化已经足够了,但是如果还涉及写入的话,这个优化也就不够用了

CMU

因为操作系统被告知要写一个数据,操作系统不会管是否合适(比如批量写,聚合内存块在一起的,减少内存中断)就直接写了。
我们还可以通过一些系统调用来优化:

CMU

还有一些系统调用可以让我们来告诉操作系统如何操作 mmap 内存:

CMU

  • madvise:告诉操作系统你会怎么读取,是随机读取还是顺序读取
  • mlock:防止操作系统将某些页与物理内存解绑
  • msync:告诉操作系统将某些页刷入磁盘

主流数据库(比如 MySQL,Postgres,Oracle,sqlserver 等等)都没有使用 mmap,那些在使用的大部分也正在考虑替换掉 mmap。
所以,我们总结下:

CMU

如果你将内存管理全权交给操作系统,操作系统可能会做出一些对于你的性能有损的决策,DBMS 通常都想自己控制内存管理策略,这样就可以带来:

  • 根据当前数据库存储格式以及用户请求,决策脏页刷入磁盘的顺序,提高效率
  • 根据存储结构以及用户请求,缓存预取提高查询速度
  • 根据存储结构优化缓冲过期策略
  • 多线程管理

3. 数据库文件存储结构-存储管理(Storage Manager)

CMU

数据库其实就是磁盘上的一个或者多个文件,例如 sqlite 整个数据库就在一个文件中,大多数其他的数据库例如 MySQL 则是分了不同文件存储。sqlite 本身就是轻量级的系统,MySQL 这种一般考虑存储性能以及限制等会划分成不同的文件,在现在的文件系统中,文件大小限制已经不太是一个考虑因素了,你甚至可以有超 PB 的文件,但是像是那种老的文件系统例如 fat32 那种,最大只支持 2GB 大小。我们这里提到了文件系统(File System),文件系统其实就是操作系统给我们提供的操作文件的 api,我们通过这些 api 操作文件(文件其实就是一堆存储上面的块)。大部分数据库是依赖操作系统提供的文件系统的,曾经有一些厂商提供了自己原生的文件系统专门用于自己的数据库产品使用,但是这样就丧失了迁移以及部署在云上的灵活性。

我们这里要实现的就是DBMS的存储管理器(Storage Manager)

CMU

存储管理器负责管理上面提到的数据库文件,我们在这里会向操作系统发送读和写请求,操作系统会调度这些读写。一些比较高端的系统,还会优化这些系统调度,即在这文件系统上抽象出来一个中介层,在这个中阶层管理本由操作系统的管理的调度,比如这个中阶层知道会有多线程发多请求写互相临近的块,这个中阶层会组合在一起形成一个写请求。这里我们就不做这个了,太复杂了。

在这些文件里面,是由(page)组织起来的,由一组页组成。我们会以页的维度记录这些页的读写(例如哪些页还有空间可以存储新数据,哪些页里面有脏数据还没刷入磁盘,哪些页已经完全满了)

CMU页其实就是固定大小的数据块,页中存储数据库中所有相关的数据,例如元组数据、元数据、索引还有日志记录等等。但是我们总是尽量将内容存储在单个页中,并且页需要是自包含的,即关于如何解释和理解页内容,所需要的所有信息都必须存储在页本身中。这样,即使丢失任何一页,也不会影响其他任何一页的解析和使用。如果你把元数据存储与元组数据分开存储在不同的页,如果元数据页丢失或者损坏了,那么元组数据页也就无法解析了。这种自包含的设计,对于容灾更好

虽然不同种类的数据(例如元组数据、元数据、索引还有日志记录)都是存储在页中,但是在同一页内一般不会存储不同类型的数据。有一些研究型的数据库可能会在同一页中存储不同类型的数据,但是大部分系统都没有这么做。
每个页都有唯一的标识符即页 ID(Page ID)。我们使用这个唯一标识符形成一个中间层,在这个中间层维护 Page ID 与实际存储的文件的位置的映射,通过 Page ID 就能定位到对应的文件位置并读取这个页。同时如果我们移动了页(例如我们想扩展存储,压缩存储使用等等),Page ID 不会变,只是修改了文件位置,这比直接使用文件位置存储要方便很多。这样 DBMS 的其它层就不用关心这个,只记录 Page ID 就可以,我们在修改页位置的时候不用告知其他层同时修改。

CMU

我们有很多不同的层面有页这个概念:

  • 硬件存储页(Hardware Page):通常是 4KB。也就是如果你写的数据在 4KB 以内,那么就能保证原子性,不会发生写了一部分掉电导致只写入了一部分成功的情况,只会要么全成功或者全写入失败
  • 操作系统页(OS Page):通常也是 4KB
  • 数据库页(Database Page):通常是 512~16KB,例如 MySQL InnoDB 页大小默认 16KB,目前 8.0 可以配置成 4KB 8KB 16KB 32KB 64KB。在一些比较高端的系统,你甚至可以对于不同类型的页设置不同的页大小,例如元组数据页大小比较小,索引页大小调整的比较大。

将数据库页调大,那么单页就可以保存更多的数据,那么后面会提到的页目录大小就会降低,页目录是用来找所有页的位置的存储,一般会一直存在于内存中,页目录越小,那么 CPU 缓存命中率越高。并且更多的数据在同一页上面,读取数据也是一页一页读取,这样对于缓存预取也有好处。但是相应的,写入也是一页一页刷入,带来的写入消耗也更大。这就是为啥那些商用的数据库允许你调整页大小的原因。

CMU

那么如何设计文件页结构呢,一般有三种:

  • 堆文件组织(Heap File Organization):这是最常用的,我们也会主要研究这种。每种关系存储到一个单独的文件,文件中的记录是无顺序的。
  • 顺序文件组织(Sequential File Organization):所有文件里面的记录内容,都按照记录的某个属性值的一定顺序排序好。
  • 哈希散列文件组织(Hashing File Organization):使用记录的某些属性计算哈希值,决定存储在文件的哪个位置。

CMU

数据库堆文件是一个无序的页面集合,其中元组数据可以随机顺序存储。其实关系模型中,也没有对元组定义任何顺序。我们所要实现的就是针对页操作的增删改查 API,以及遍历所有页的 API。
同时,我们需要一些元数据追踪哪些页还有剩余空间,这样在我们需要插入新数据的时候就能快速定位到在哪里插入。
一般我们通过页目录(Page)这种方式设计堆文件中的结构,但是首先我们先来通过链表(Linked List)设计下文件结构来看下为何这种方式是愚蠢的

CMU

如果使用链表设计的话,一般我们会维护一个 Header 页,在这个页 里面我们维护两个指针,一个指针是指向所有还有剩余空间的页链表的指针,另一个指针指向的是已经被填充满的页链表指针。这两个链表都是双向链表,我们可能顺序遍历也可能逆序遍历:顺序遍历一般用于找合适的位置比如找还有足够容纳我们要插入数据空间的页,逆序遍历一般用于移动。这并不是一个比较好的设计方式,主要是插入与更新的效率比较低

CMU

这是使用更多的设计方式,即页目录。我们维护一个专门存储目录的页,在这个目录中维护页 ID 到具体文件位置的偏移量的映射,可以简单把它理解成一个 key 为页 ID value 为文件位置偏移的哈希表。并且在这里还会维护页的空闲空间信息,这样在插入的时候,我们可以直接通过页目录直接定位到要插入的页。但是这样也带来了原子更新的问题,即页的空闲空间信息与插入数据是否在一个原子操作内。但是由于这个两个页的操作,硬件层面上我是很难保证两页更新是原子性的,所以我们需要额外的机制在数据库重启的时候检查是否有这些未完成的写入,这在后面讲恢复与日志的章节的时候会说到。


4. 页布局(Page Layout)

CMU

每个页都有页头(Header),在 Header 中一般包含:

  • 页大小
  • 校验和(CheckSum):这个可能会用来检查是否有未完成的写入(例如写一半就宕机了)
  • DBMS 版本:创建这个页的数据库管理系统的版本,这个一般用于向前兼容使用,比如在某个版本后页布局发生了变化,我们可以通过这个 DBMS 版本让这个页的解析走不同的分支。
  • 压缩相关信息(Compression Information):如果对页面做了压缩,需要标注一些信息,例如使用的算法,是 lz4 还是 gzip 等等。
  • 事务可见性相关信息(Transaction Visibility):对于实现事务可见性需要的一些信息,例如是哪个事务修改了这个页的内容,以及修改后的内容在当前时间点对于谁可见等等。

页需要是自包含的,即关于如何解释和理解页内容,所需要的所有信息都必须存储在页本身中。这样,即使丢失任何一页,也不会影响其他任何一页的解析和使用。如果你把元数据存储与元组数据分开存储在不同的页,如果元数据页丢失或者损坏了,那么元组数据页也就无法解析了。这种自包含的设计,对于容灾更好

CMU

在页内部,我们有两种方式存储元组信息:

  • 面向元组存储(Tuple-oriented):即页中存储元组的数据
  • 面向日志存储(Log-oriented):即存储修改元组的日志而不是元组数据本身

首先我们来看看面向元组的存储方式设计,首先看一个很糟糕的设计

CMU假设所有元组的字节长度都是一样的,那么实现方式比较简单,基本就是在头维护一个元组数量(这样我们就能直接跳转到要插入下一个元组的文件位置偏移,如果元组大小不一样,那么这里维护的就是要插入的文件位置偏移),如果有新的元组插入,则根据元组数量计算出要插入的位置插入然后更新头部计数。当需要删除一个元组的时候,假设删除的是 Tuple 2:

CMU

我们可以把 Tuple 2 本来占用的空间标记为未使用,而不是将 Tuple 2 之后的所有元组数据都向前移动。但是这带来了其他的问题,即存储碎片:

  • 我想再插入新的数据的时候,假设所有元组的字节长度都是一样的,我可以插入到原来 Tuple 2 的位置,但是我怎么知道这个位置有空闲的空间呢?这样就需要引入额外的记录
  • 如果元组的字节长度不一样呢?就更麻烦了

所以这种设计很不好,没人会这么做。一般采用槽页(Slotted Pages)这种设计:

CMU

这种设计被大部分数据库所采用,虽然在细节上有些不同,但是大致是这么个结构。在开头还是前面提到的页头,之后跟着两种存储结构

  • 槽数组(slot array):从前向后写,在这个数组中的元素记录所有元组在文件中的起始位置偏移。槽数组可以解耦元组位置与外部访问,相当于前面提到的间接层。我们可能在页内部移动元组(比如更新元组导致元组长度改变会标记删除原始元组在最后追加新的元组数据),通过这个槽数组外部就可以不关心这个位置变化了。
  • 实际元组数据从后向前写

思考下:我们是否可以在这一页中存储来自于不同表的数据呢?实际上没有人这么做,首先是需要额外的元数据记录每个元组所属的表,然后是表数据访问一般具有局部性,即我们访问表的某一条数据,那么之后这个表这个记录他周围的数据也可能稍后会访问到,把他们放在同一页里面一起读取效率更高,如果在一页中混合了不同表的数据就丧失了这个局部性。

还有就是如果一个元祖或者一个元祖的某个属性数据大小超过一页的大小,那我们应该怎么做?这个下节课会讲到。这节课还是假设某个元组的数据只会存在于单页上。

5. 元组结构(Tuple Layout)

元组其实就是一个字节序列,DBMS 负责解析这个字节序列。

CMU

每个元组都有一个头,包含元组的元数据,例如:

  • 元组的长度
  • 可见性信息(Visibility info):例如是哪个事务那个查询最后修改的这个元组,其他事务或者查询根据这个信息以及事务隔离级别设置来决定是否能看到这个元组。
  • NULL 位图(NULL BitMap):通过位图标记哪些列是 NULL 值

我们一般不将模式信息(例如有哪些属性列,列是什么格式的数据,是否可以为 NULL 等等)存储在每个元组的头部,这样会冗余太多数据造成存储浪费并且一页内包含更少的数据导致更新与读取效率下降。

CMU

然后是元组数据,我们通常按照创建表语句中的属性顺序去存储元组数据,大部分系统是这么做的。有的系统会对于属性进行重排序,让它能更适应内存对齐(例如 8 字节对齐)增加访问效率(内存与磁盘存储访问一般都是内存对齐的访问)。
DBMS 的其它层,如何在这种存储结构下定位一个元组的数据呢

CMU

一般所有元组都会被分配一个唯一 ID,这个 ID 中会直接或者间接包页 ID 信息和槽(或者偏移量)信息,直接包含即从 ID 中直接就可以看出页 ID 以及槽信息,间接包含则是需要解码或者查询另一个元数据表来解析出页 ID 以及槽信息。可能还会包含文件位置信息用于定位去哪个目录或者文件位置去寻找页目录定位页 ID 对应的文件位置偏移量等等。这样,我们可以通过页 ID 查询页目录找出页对应的文件以及偏移量,根据槽信息读取页中的槽数组找到元组的位置进行读取。


微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer

CMU

我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:

  • 知乎:https://www.zhihu.com/people/zhxhash
  • B 站:https://space.bilibili.com/31359187

以上是关于CMU15-445数据库bustub Project #2:B+ Tree(上)的主要内容,如果未能解决你的问题,请参考以下文章

CMU 15-445 数据库课程第五课文字版

CMU 15-445 数据库课程第三课文字版 - 存储1

CMU 15-445 数据库课程第五课文字版 - 缓冲池

CMU 15-445 数据库课程第三课文字版 - 存储1

CMU 15-445 数据库课程第三课文字版 - 存储1

CMU 15-445 数据库课程第五课文字版 - 缓冲池