从数据库系统到Spark SQL (二)

478 阅读12分钟

在上一篇,我们将存储结构分为两类,可变存储结构和不可变存储结构 大多数可变存储结构使用原地更新的机制,在插入,删除或者更新操作期间,数据记录直接在目标文件中原本的位置被更新

存储引擎通常允许同一条数据记录在数据库中存在多个版本,例如,当使用多版本并发控制 或者 分页槽结构时,为了简单起见,现在我们假设每个键只与一条数据记录想关联,该数据记录具有唯一的位置,最流行的存储结构之一是B树。许多开源系统都基于B树

本篇旨在讨论为什么考虑替代传统搜索树(例如:二分搜索树,2-3树,AVL树) 先回顾一下什么是二分搜索树

二分搜索树

二分搜索树是一种有序的内存数据结构,用来高效的进行键值查找,二分搜索树由多个节点组成,每个树由一个键,一个与该键相关的值,以及两个子节点指针组成,二分搜索树从成为根节点的单一节点开始,一颗二分搜索树中只能由一个根节点,如下图

这种树的特征在于 从根节点开始一直沿着节点的左指针向下可以到达叶子层(该层节点没有子节点)的一个节点,此节点持有树最小键以及与其关联的值。沿着右指针往下可以找到持有树中的最大键以及与其滚凝练的值的节点。另外这种结构存储在树的的所有节点中,对于根节开始的搜索,如果在更高的层上找到了搜索到的键,则搜索可能在到达树的底部之前终止

插入操作并不会遵循任何特定模式,元素插入可能导致树不平衡的问题(即分支比另一个分支长),最差的情况如下所示

平衡树是指高度为log2N的树(其中N是树中数据项的总数),并且两个子树之间的高度差不大于1,如果不进行平衡,我们将失去二分搜索树的性能优势,使得树的最终形状由插入和删除的顺序而定

在平衡树种沿着左指针或者右指针移动会将搜索空间平均减少一半,因此查找复杂度O(log2N),如果树不平衡,则最差情况的时间复杂度就会上升到O(n)

保持树平衡的办法的方式之一是在添加或删除节点之后旋转,如果插入操作使得分支不平衡(分支中两个连续节点只有一个子节点),可以围绕中间节点旋转树

基于磁盘存储的树

不平衡树的最差情况是复杂度O(n),平衡树的平均时间复杂度是O(log2N),同事由于扇出较低(扇出指每个节点允许拥有的最子节点数),我们必须频繁的执行平衡操作,重新定位节点并更新指针,维护成本作为在磁盘上的数据结构边的不切实际

如果想在磁盘上维护二分搜索树,则需要面临以下以下几个问题

  • 局部性 由于元素是以随机顺序添加的,所以不能保证新创建的节点是在其父节点附近写入的,意味着子指针可能跨多个磁盘页,通过修改树的布局和使用分页二叉树,可以一定程度上改善状况

  • 树高 由于二分树的扇出为2,所以树的高度是树中元素个数的以2位底的对数,必须执行O(log2N)次查找以定位要搜索的元素,这就要求执行相同数量的磁盘传输。2-3树的其他低扇出树具体类似的限制,虽然他们作为内存数据结构是有用的,但较小的节点大小使得他们在外部存储上并不实用

一个磁盘上存储二分搜索树的简单方法是所需的磁盘寻道次数和比较次数一样多,因为这样的数据结构不具备数据局部性 包含以上的因素,适合磁盘实现的树必须要具有以下属性

  • 高扇出:以改善邻近建的数据局部性
  • 低高度:以减少遍历期间的寻道次数

基于磁盘的结构

  • 机械硬盘

旋转型硬盘是最广为使用的存储介质,后来存储介质的发展(闪存驱动器)体现了对原有算法的改进。他们被优化以应用于非易失性的字节可寻址存储(例如[XIA17]和[KANNAN18])

在旋转型磁盘上,寻道增加了随机读取的成本,因为其需要磁盘旋转和机械磁头运动来将 读/写磁头定位到期望的位置,一旦完成了这些读写高成本的阶段,读取或者写入后续字节成本相对降低了

旋转型驱动器的最小传送单元是扇区,因此当执行某些操作的时候,至少可以读取或者写入一整个扇区,扇区大小为512字节到4kb(pagesize的最小单位)不等

磁头定位是机械硬盘操作中读写成本最高的部分,这就是为什么顺序IO可以带来正面效果,因为它将会从磁盘读取和写入连续的存储段

  • 固态硬盘

固态硬盘(SSD)没有可以移动的部件,既没有需要旋转的磁盘,也没有为读取而必须要移动的磁头,典型的SSD由记忆单元构成,这些单元连接成串(每个串为32到64个单元),串被组合成阵列,阵列被组合成页,页被组合成块 根据使用的某种具体技术,一个记忆单元可以保存一位或者多位数据,不同设备的页大小不同,通常在2kb到16kb之间。块通常包含64到512个页,块被组织成平面,最后平面被放置在晶圆核心上。SSD可以拥有一个或者多个晶圆核心。

可写或者可2读的最小单位是页,但是只能对空的记忆单元进行更改(即,对写入之间已经擦除的单元进行更改),最小的擦除实体不是页,仍然是保存多个页的块,这就是为什么它会通常被称为擦除块,空块中的页必须要按照顺序写入

闪存控制器的一个组件被称为闪存转换层(FTL),它负责将页ID映射到对应位置,并跟踪空的,被写过的,丢弃过的页.另外它还负责垃圾收集,再次期间FTl会找到可以被安全擦除的块,有些块可能仍有活页,在这种情况下,它会将活页从这些块迁移到新位置,并将页ID重新映射到那里,在这之后,他擦除现在的块,使他们变为科协状态

在SSD中,并不像在机械硬盘非常强调随机IO和顺序Io的区别,因为二者延迟并不是很大,不过由于预读取,读取连续的页和内部并行性的缘故,仍然存在一些差异

磁盘存储结构

在磁盘上,大部分时间都是手动管理数据布局(内存映射文件除外),这种类似指针的操作,必须计算目标指针的地址并追踪这个指针。 大多数情况下,磁盘偏移量是预先计算出来的(指针在它所指向的那部分内容被存储之前被写入磁盘),或者缓存到内存中直到被刷到磁盘上,在磁盘中创建较长的依赖链会增加结构复杂性,因此最好将指针的数量及其跨度保持最小

综上所述,二分搜索树在磁盘上并不能很好的满足,下面我们引出本篇的主角--B树

B树是建立在平衡搜索树的基础之上的,不同于前者具有更高的扇出(即具有更多的叶子结点)和更低的高度,具体表示如下:

B树的特征为:
         - 根节点没有父节点
         - 叶子结点没有子节点的底层节点
         - 内部节点 链接根节点和叶节点的其他节点,B树通常包含多层的内部节点
         - 对于每个指针块相当于分割键,分割叶子结点的页上存储的值属于哪个区间

在上图中,某个节点可以看作一个磁盘页,而每一个指针指向的"格子",则为索引的指针块。由此可见B树是一种页组织技术,即组织和导航固定大小页。 这样的结构优点在于其扇出,存储在每个节点中键的个数,为保持树的平衡需要作出一些结构上的更改,而更高的扇出(每个节点的最大子节点数)可以均摊这些更改带来的开销,同时通过单个块和多个连续块中存储指向子节点的键和指针,可以减少磁盘寻道的次数,平衡操作(分裂和合并)会在节点已满或者几乎为空时触发

其次,B树是有序的,B树的节点按照顺序存储,因此可以使用二分搜索这样的方式定位搜索的键,上一篇文章中也提到过,假设整颗数的节点数为K,那么树的高度则为O(log2K)。目前对于B树查找的复杂度在于:块的传输数量和查找期间完成的比较次数

  • 就传输次数而言,对数基为N(每个节点的键数)。从根节点每往下走一层,节点个数就多K倍,并且跟随一个子指针可以将搜索空间减少至N分之一,在查找期间,最多寻址logkM(其中M是B树中项的总数)个页。从根到叶的通路上必须经过子指针的数量等于层数,最坏的情况下,等于树的高度

  • 从比较次数来看,对数基为2,因为每个节点内搜索一个键是使用二分搜索完成的,每次比较都将搜索空间减半,因此复杂度为log2M,树的占用率为50%

也就是说,在 40(4*10^9) 亿个项中找到搜索的关键字需要大概32次比较。这个计算结果

那么B tree有什么劣势呢?

B树仅允许在根节点,内部节点和叶节点当中任意层存储值。这就意味着在最坏的情况下(每个非叶子结点达到最大扇出),他只能通过指针进行更多次的磁盘寻道,才能找到叶子结点。这显然不满足上一章所述基于磁盘存储的低高度,所以为了适应可变的存储结构,演化出了B+tree

B+tree仅在叶节点中存储值,其内部节点仅存储分隔键,用于指引搜索算法去找到叶子结点上的关联值。那么这样一来,树的高度最多为3层,因为第二层可以全部用来存储分隔键了,大大减少了磁盘寻道的次数,且插入,更新,删除和检索操作仅影响叶子结点,只有当非叶子结点的扇出达到最大节点数或者相邻结点所拥有的值太少(即树的占用率低于一个固定阈值),发生平衡操作(分裂和合并)时,才会影响分隔键的提升和下降。

对于mysql而言,为了支持可变存储结构和较好的查询性能,Innodb引擎出于应对高读负载的情况下,采用了B+tree

而对于mongodb而言,设计的目标是一种文档型数据库,所有的数据通过直接通过key索引到value,采取尽可能的低高度减少查询时的磁盘寻道,采用了b-tree

对于平衡操作而言,分裂和合并的次数随着数据分布越稀疏,平衡次数也会很多,如何实现再平衡呢?

- 1.在每一层内再平衡,保障父节点下的子节点至少是半满的
- 2.持续在相同节点之间分发数据,直到两个同级节点已满

B树的平衡多少有些不同,将同一层的两个相邻结点拆分成三个节点。每个节点为三分之二满。这种方法实现了推迟分裂来提高平均占用率,更高的利用率也意味着更多次的检索,因为树的高度更低,通往搜索叶节点的路径上页也更少

但是对于插入而言,难道不会使树频繁的执行平衡操作,且在保证高扇出的情况下,更改父节点的分隔键信息,不会引起更多的磁盘寻道么?

许多数据库系统使用单调递增的数值作为主索引的键,之前我们提过B树上的节点按照前序遍历都是顺序存储的,所有的插入操作都会发生在索引的末尾(在最右边的叶子结点中),也就是说对于B树而言,由于每次都是从底向上构建。每次插入最右非叶子节点的指针块都会传播给父级,更高层的父级节点保存指针,指向叶子结点。始终在最右的叶子结点构造一颗最小子树(每个非叶子结点拥有N个分隔键和N+1个指针的节点).使平衡操作发生在最叶子结点上,从而避免大范围的分裂和合并