持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第16天,点击查看活动详情
最多使用的索引结构在1970年诞生,此后便“无处不在”,即B树。
类似SSTables,B树保持按K排序的KV对,这可实现高效的KV查找和范围查询。但相似也就这些:B树有很不同的设计理念。
- 之前的日志结构索引将数据库分解为可变大小的段,通常几兆字节或更大,且按顺序写入段
- 而B树将数据库分解成固定大小的块或页,一般为4KB,页是内部读/写的最小单元。这种设计更接近底层硬件,因为磁盘也是固定大小的块排列。
每个页面都能使用地址或位置来标识,这就能让一个页面引用另一个页面,就像指针,不过是指向磁盘而非内存。可使用这些页面引用来构建一个页面树:
某一页被指定为B树根;每当在索引中查找一个K,就从根开始。该页包含若干K和对子页的引用。每个子页负责一段连续范围的K,相邻引用之间的K指明了这些范围之间的边界。
图6正寻找K=251 ,所以需要沿着200300之间的页引用,到达类似的页,它进一步将 200300范围分解成子范围。最后,到达一个包含单个K的页面(叶子页),该页包含:
- 每个内联K的值
- 或包含能找到值的页的引用
B树的一个页所包含的子页引用的数量称为分支因子。图6中,分支因子为6 。实践中,分支因子取决于存储页面引用和范围边界所需的空间总量,通常为几百个。
- 若更新B树中已有K的值,则搜索包含该K的叶子页,更改该页中的值,并将该页写回磁盘(对该页的任何引用仍有效)
- 若添加一个新K,找到范围包含新K的页,并将其添加到该页。若页已无可用空间保存新K,则将其分成两个半满的页,并更新父页以包含分裂后的新的K范围,如图7:
该算法确保树保持平衡:具有 n 个K的B树总是 O(logn) 深度。大多数DB可放入一个3~4层的B树,所以无需追踪很深的页面层次即可找到所需页面(分支因子为500的4KB页的四级树可存储256TB )。
让B树更可靠
B树的基本底层写操作是用新数据覆盖磁盘上的旧页。假设覆盖不改变页的磁盘存储位置:即当页被覆盖时,对该页的所有引用保持不变。这与日志结构索引(如LSM树)完全不同,后者仅追加更新文件(并最终删除过时文件),但从不会修改文件。
可认为硬盘上的页覆盖为实际的硬件操作。在磁性硬盘驱动器上,这意味着将磁头先移动到正确位置,然后旋转盘面,最后用新数据覆盖对应扇区。由于SSD必须一次擦除并重写很大的存储芯片块,具体更复杂。
某些操作需覆盖多个不同页。如因为插入导致页溢出而需要页分裂,则得写已拆分的两个页,并覆盖其父页以更新对两个子页的引用,这是个危险操作,因为若数据库在完成部分页写入后崩溃,最终会导致索引破坏(可能有个孤儿页,没有被任何其他页指向)。
为使数据库能从崩溃中恢复,B树一般有个额外的磁盘数据结构:预写日志(WAL, write-ahead-log) (也称为重做日志(redo log) )。这是个仅支持追加修改的文件,每个B树的修改必须先更新WAL,再修改树的页。当数据库在崩溃后需要恢复时,该日志就能被用来使B树恢复到最近一致的状态。
原地更新页的另一个难点是,若多线程要同时访问B树,则需注意并发控制,否则线程可能会看到树处于不一致状态。一般使用锁存器(latches) (轻量级锁)保护树的数据结构来完成。在这方面,日志结构化的方法更简单,因为它们在后台执行所有合并,而不会干扰前端传入的查询,并且不时用新段原子替换旧段。
B树的优化
- 一些数据库(如LMDB)使用写时复制,而非覆盖页并维护WAL进行崩溃恢复。修改的页被写到不同位置,树中父页的新版本被创建,并指向新位置。这种方法对于并发控制也很有用。
- 保存键的缩略信息,而非完整的K,这样就能节省页空间。特别是在树中间的页,K只需能提供足够信息来充当K范围的边界。这样就能将更多K压入页,让树有更高的分支因子,极大减少层数。
- 页一般能放在磁盘任何位置;没谁要求相邻K就得放在磁盘的相邻位置。若查询需按序扫描大段的K范围,考虑到每个读取的页都可能要磁盘 I/O,所以这种逐页存储的布局可能效率低下。因此,许多B树实现尝试布局树,以便相邻的叶子页能按序保存在磁盘。但随树增长,维持该顺序很困难。相比之下,由于LSM树在合并过程中一次次重写大量存储段,所以它们更容易让连续的K在磁盘上相互靠近
- 添加额外的指针到树。如每个叶子页可能会左右引用同级的兄弟页,这无需跳回父页,就能顺序扫描K
- B树的变体如分形树,借用一些日志结构的思想来减少磁盘寻道(而且它们与分形无关)