在之前的文章中,我们讨论了不同类型的 IO 和一种称为LSM Tree 的不可变磁盘数据结构。继续这个系列,我们将讨论一种读取优化的可变存储,它也有许多变体(我们在这里只简要提到,以后可能会更深入地介绍),并通过 RUM 猜想总结我们对磁盘数据结构的探索历程。
该系列由 5 件组成:
- 详解磁盘 IO: 第 1 部分 IO 风格:页面缓存、标准 IO、O_DIRECT
- 详解磁盘 IO: 第 2 部分 mmap、fadvise、AIO
- 详解磁盘 IO: 第 3 部分 LSM 树
- 详解磁盘 IO: 第 4 部分 B 树和 RUM 猜想
- 详解磁盘 IO: 第 5 部分 LSM 树中的访问模式
可变存储通常使用堆表文件与某种索引结合来实现。在本文中,我们将仔细研究 B 树。
B 树
B 树是一种流行的索引数据结构,有多种变体,用于许多数据库,包括MySQL InnoDB和PostgreSQL。B树是二叉搜索树的泛化,其中每个节点允许两个以上的指针。B 树是_自平衡的_,因此插入和删除时不需要旋转步骤,只需合并和拆分。将它们用于查找时间很重要的索引的原因是它们对数查找时间的保证。
除非另有说明,否则我们将主要讨论 B+ 树,它是 B 树的现代变体,常用于数据库。B+ 树与原始 B 树论文的不同之处在于,它有一层额外的链接叶节点,用于存储值。
细节
B-Tree 有几种节点类型:根节点、_内部_节点和_叶_节点。_根节点_没有“父节点”(例如,不是任何其他节点的子节点)。_叶_节点没有子节点,但携带数据。_内部_节点既有父节点也有子节点,它们将根节点与叶节点连接起来。一些 B-Tree 变体允许在内部节点上存储数据。B-Tree 以其_分支因子_为特征:指向子节点的指针数量(N)。根节点和内部节点最多可容纳 N-1 个键。
B 树由根节点(顶部)、内部节点(中间)和叶节点(底部)组成。叶节点通常保存值,内部节点将根节点与叶节点连接起来。这描绘了一棵分支因子为 4 的 B 树(4 个指针、内部节点中的 3 个键和叶节点上存储的 4 个键/值对)。
树中的每个非叶节点都包含_N 个_键(索引条目),将树分成子树和_N+1 个_指向子树的指针。来自条目_Ki的指针__i_指向一个子树,该子树中所有索引条目都是这样的_Ki <= K < K+1_(其中_K_是一组键)。 First 和 last 指针是特殊情况,分别指向所有条目小于(或等于)和大于_K_的子树。从逻辑上讲,内部节点保存键,表示它们指向的子节点的_最小_键。内部节点和叶节点都保存指向同一级别的下一个和上一个节点的指针,形成兄弟节点的双向链表。
通常,最好将 B 树节点的大小保持在一到两页大小 (4-8K)。考虑到这一点并了解您的密钥大小,您可以估算出分支因子和树高 (层数)。高度不应太大,因为跳过整个层需要随机查找,而执行太多查找可能会导致性能下降。分支因子通常为数百;但是,当密钥很大时,它可以更低。将分支因子设置得太高也可能导致性能问题。B 树调整和选择分支因子的一个好经验法则是叶节点应占据绝大多数树空间。
查找
B 树的根节点和内部节点通常可以缓存在 RAM 中,以便更快地进行查找。由于每一层上的子节点数量都会按分支因子增长,因此叶节点占用的空间量将大得多。搜索叶节点将在内存中完成,而从叶节点搜索和检索最终值将从磁盘进行。
执行查找时,搜索从根节点开始,一直向下到叶级。在每一级上,算法都会找到两个键,其中一个键小于搜索项,另一个键大于搜索项(因此搜索项位于它们之间),然后沿着键之间的子指针进行查找。
B 树中的查找只进行一次从根到叶的传递,沿着两个键“之间”的指针,其中一个键大于(或等于)搜索词,另一个键小于搜索词。
节点内的搜索通常使用二分搜索。使用固定大小的键执行二分搜索的方法很明显:知道排序数组中的项数,跳到中间,进行比较,决定是向左还是向右遍历,然后重复,直到找到该项。
B 树中的搜索具有对数复杂度,因为在节点级别,键按顺序存储,并且执行二分搜索以找到匹配项。这也是为什么保持树中占用率高且均匀很重要的原因。
对于可变长度的数据,可以将间接向量添加到数据前面,保存实际记录的偏移量。间接向量中的指针必须按搜索键排序(实际可变大小的数据不必遵循排序)。二分搜索的执行方式是选择间接向量的中间指针,按顺序进行比较,选择遍历方向,然后重复该操作,直到找到搜索的键。
为了允许对可变大小的数据进行二分搜索和对数时间访问,使用了间接向量:间接向量保存实际数据的偏移量。二分搜索从间接向量的中间开始,将中间偏移处的键与搜索项进行比较,然后按照比较显示的方向继续进行二分搜索。
对于点查询,找到一个节点后搜索就完成了。范围扫描时,会先遍历当前节点的键和值,然后再遍历兄弟叶子节点的键和值,直到到达范围的末尾。
修改
执行插入时,首先必须找到目标叶子节点。为此,使用前面提到的搜索算法。找到目标叶子节点后,将键和值附加到该叶子节点。如果叶子节点没有足够的可用空间,这种情况称为_溢出_,必须将叶子节点分成两片。这是通过分配一个新叶子节点、将一半元素移到其中并将指向新分配叶子节点的指针附加到父节点来实现的。如果父节点也没有足够的空间,则执行另一次拆分。该操作一直持续到到达根节点。通常,当根节点被拆分时,它的内容会在新分配的节点之间拆分,并且根节点本身会被覆盖以避免重定位。这也意味着树的高度总是通过拆分根节点“从”根节点开始增长。
当节点满了(左节点)时,它就会被分裂。当根节点被分裂时,通常会创建两个节点,根键会分布在新节点之间。当内部节点被分裂时,会创建一个兄弟节点,并将其键和指针添加到父节点。
更新与插入类似,不同之处在于现有记录通常会被覆盖或附加到写入缓冲区以进行延迟写入。删除可以被认为是一种反向操作:当叶子占用率低于阈值时,这种情况被视为下_溢,_并且会发生负载平衡(将键从占用较多的节点转移到占用较少的节点)或两个兄弟节点合并,从而触发从父节点删除键和指针,这反过来可能会触发重新平衡和上游合并的级联。
我们不会深入探讨 B 树拆分和合并的语义(它们在什么阈值发生),因为它们取决于具体的 B 树风格。这里重要的是要提到,完整节点拆分及其内容必须重新定位,并且指针必须更新。节点拆分和合并也可能向上传播一个或多个级别,因为在拆分和合并期间,子指针会在父节点上更新;树高在拆分期间增加,在合并时缩小。
在进行拆分和合并时,树的一部分必须被锁定:对于读取者和写入者来说,在单个操作期间更新所有将被拆分的节点应该看起来是原子的。在 B 树的并发控制领域有一些研究,其中一项努力是 Blink-Trees,它承诺减少锁定,这是一种更适合高并发性的算法。
树的扇出直接影响将完成多少 IO 操作:较小的节点不仅可能导致树具有更高的深度,而且还会更频繁地触发分裂。
堆文件是可变大小的无序记录列表。由于它们通常用于可变存储,因此文件被拆分为页面大小的块(4KB 或多个)。块按顺序编号并从索引文件中引用。
B-Tree 变体
人们使用 B 树的方法之一是 ISAM(索引顺序访问方法),这是一种创建、维护和操作树的方法,可以分摊 B 树的部分成本。ISAM 结构完全是静态的和预分配的。与 B+ 树一样,数据仅驻留在叶页上,这有助于保持非叶节点的静态。ISAM 的一个特性是存在溢出页:写入将首先进入叶节点。当叶节点中没有足够的空间时,数据将进入溢出区域,因此在搜索过程中,首先遍历叶页,然后遍历溢出页。其背后的想法是,只会有少数溢出页面,不会影响性能。
另一个例子是 B+ 树。有很多方法可以实现它们,但许多现代实现都具有可变的动态 B+ 树。它们的特殊之处在于数据仅存储在叶子上。这可以简化对树结构的更新,因为键的大小较小并且可以很好地放入内部节点页面中。叶子页面可以增长,甚至在必要时重新定位,这只需要更新内部节点上的单个指针。
还有许多其他示例,每个示例都涵盖一个特殊的情况:Cache-Oblivious B-Trees 优化内存管理,已经提到的 Blink-Trees 提高并发性,Partitioned B-Trees 提高写入性能等等。
B 树维护
许多现代数据库都在使用 B 树索引:它们速度快、效率高,并且有许多广为人知和使用的优化。大多数实现都是可变的。这意味着数据结构是动态的,并且在磁盘上不断变化:当分配新节点时,内部结构也会发生变化。为了实现这一点,必须为表保留一定量的开销,即所谓的占用因子,以便为新写入留出一些回旋余地。在后面的章节中,我们将详细介绍数据库如何解决这个问题:使用写入缓冲区、使用溢出页面或通过重写对表进行碎片整理。
重新平衡是一种补偿占用率的方法。将来自占用率较高的节点的指针移至占用率较低的节点。由于数字越大,对数因子越大,因此二分搜索速度越快。不可变 B 树的占用率为百分之百,不需要重新平衡。
压缩和负载平衡有助于 B 树空间管理。在压缩期间(该术语实际上已被过度使用,在 B 树的上下文中与 LSM 树的含义不同),通过清除过时或已删除的记录并连续重写节点(当树存储可变长度的记录时,这可能会产生更大的影响)来回收可用空间。负载平衡从兄弟节点获取键,并将它们从包含更多记录的节点移动到包含较少记录的节点。保持节点平衡也有助于减少拆分数量。不幸的是,这种技术似乎被忽视了。页面拆分也具有一些性能优化潜力:为了便于进行键范围扫描,可以将节点放在磁盘上彼此靠近的位置。
索引可以通过批量加载形成(类似于在 LSM 树中创建 SSTable 索引的方式)。批量加载时,可以对数据进行预排序,整体算法要简单得多:所有附加操作都将执行到最右边的子节点,以避免遍历。最右边的索引页在已满时将被拆分,这又可能触发上层的级联拆分。使用 B 树的不可变变体进行批量加载可以保证完全占用,这既节省空间又提高读取性能,因为占用率越高,所需的节点就越少。
RUM 猜想
读写数据的方法有很多:数据结构、访问模式、优化:所有这些都会影响最终系统的性能。但很难让系统同时在所有方向上进行优化。在理想世界中,我们会拥有可以保证最佳读写性能且没有存储开销的数据结构,但在实践中这当然是不可能的。哈佛数据库实验室的研究人员总结了数据库系统研究人员试图优化的三个参数:_读_开销,_更新_开销,以及_记忆_开销。决定针对哪些开销进行优化将影响数据结构、访问方法的选择,甚至影响对某些工作负载的适用性。RUM 猜想指出,为上述两个开销设置上限也会为第三个开销设置下限。
RUM 猜想将读取、更新和内存优化中的数据结构分开。
基于树的数据结构通常针对读取性能进行了优化,以换取空间开销和来自节点拆分/合并、重新定位以及碎片/不平衡相关维护的写入放大。在读取放大方面,基于树的结构有助于最大限度地减少访问的总数据与要读取的数据之间的比率。使用自适应数据结构可以提供更好的读取性能,但维护成本会更高。添加促进遍历的元数据(如分数级联)会影响写入时间并占用空间,但可以缩短读取时间。
正如我们已经讨论过的,LSM-Trees 针对写入性能进行了优化。更新和删除都不需要搜索磁盘上的数据,并通过延迟和缓冲所有插入、更新和删除操作来保证顺序写入。但这是以更高的维护成本和需要压缩(这只是一种缓解不断增长的读取成本的方法)以及更昂贵的读取(因为必须从多个文件中读取数据并合并在一起)为代价的。同时,LSM Trees 有助于提高内存效率,因为它避免保留空白空间(某些读取优化数据结构中的开销来源),并允许块压缩(因为最终文件的占用率更高且不可变性更高)。
优化内存效率可能涉及使用压缩(例如,Gorilla 压缩、增量编码等算法),这会增加一些读写开销。有时您可以牺牲功能换取效率。例如,堆文件和哈希索引可以提供出色的性能保证和较小的空间开销,因为文件格式简单,但代价是除了点查询之外无法执行任何操作。您还可以通过使用近似数据结构(例如Bloom Filter、HyperLogLog、Count Min Sketch等)牺牲精度换取效率。
这三个可调参数:读取、_更新_和_内存_开销可以帮助您评估数据库并更深入地了解它最适合的工作负载。所有这些都非常直观,通常很容易将存储系统分类到其中一个存储桶中并猜测它将如何执行。
RUM 猜想:对两个可调参数设置上限也会对第三个可调参数设置下限。
话虽如此,使用最佳数据结构只是完成了一半的工作:数据库系统中仍有足够多的部分,性能可能仍然是瓶颈。但我们假设数据库开发人员始终渴望消除问题,并使其产品在设计负载下表现最佳,并在所有其他用例中表现良好。
下次我们将继续讨论数据库的不同方法。计划是仔细研究访问模式并了解哪些类型的工作负载可以提供某些保证。我们还将简要讨论不同类型的数据集如何影响数据库的选择。