基于磁盘的文件组织、查找算法-树

B-树

在20世纪80年代,B-树被运用在数据库管理系统上,主要用于解决如何确保一致的b树访问模式来支持并发控制和数据恢复的问题。最近,B-树再次被运用于磁盘文件管理上,用于解决磁盘高效读写IO和文件版本管理。例如,zfs是基于B-树实现的高效率IO文件系统解决方案。

原本B-树算法设计是用于解决高效率访问和维护索引文件在主内存中加载管理的,这导致B-树的三个目标:

  • 增加树的节点大小以最小化搜索,并最大化每次读取时传输的数据量
  • 将搜索时间减少到很少的次数,即使是对于大的集合
  • 支持高效的本地插入、搜索和删除

B-树的关键观点是树应该自下而上构建,而不是自上而下构建。我们首先将键插入到单个叶节点中。当这个叶子节点溢出时,我们将它分割成两个半满的叶子,并向上提升一个键以形成一个新的根节点。关键的是,由于我们将提升延迟到叶溢出时,所以我们可以选择对叶节点进行最佳分区的键。这个分裂提升操作在B-树的整个生命周期中持续进行。

B-树是BST的普遍化。持有k-1个键和k个子树引用,而不是在每个节点上持有一个键和指向两个子树的指针。这叫做order-k B-树。所以,BST也称为order-2 B-树。

尽管我们的示例的阶数很低,但b树节点通常每个节点拥有数百甚至数千个键,每个节点的大小足以填满一个或多个磁盘页。即使只有3的高度,这棵树也拥有超过10亿个键。我们最多需要3个键来找到树中的任意键,并生成精确的大小类型:正是我们正在寻找的比例。

B-树算法保证了order-k的一些重要性质,以确保有效的搜索、管理和空间利用。

  • 每个树节点最多可以保存k−1个键和k个子树引用
  • 除了根结点,树的每个节点都至少有k/2 - 1个键
  • 所有叶节点都在树的相同深度中出现
  • 节点中的键按升序存储

搜索

B-树搜索跟BST的搜索非常相似,但是,我们不需要在每个节点上递归地向左或向右移动,而是需要执行k-way搜索来查看要探测的子树。

B-树查找函数遍历节点中的键,寻找目标键。如果找到目标键,返回键以及值(e.g.,键的完整记录在数据文件中的位置)。一旦确定目标键不在节点中,我们就递归搜索相应的子树。这将一直持续下去,直到找到目标键,或者要搜索的子树不存在为止。也就是说目标键不包含在树中。

每次开始一次搜索,调用B-树查找函数btree_search(tg, root, k)开始在树的根节点向下查找。每次搜索的时间复杂度为O(lgn)。

插入数据

插入数据,B-树相比于BST要复杂得多。这两种插入算法都搜索保存新键的适当叶节点,如果叶溢出,B-树插入也必须提升一个中值键。

注意,在插入的每一步,B-树都满足四个必需的属性:节点持有k−1 = 3个键,内部节点拥有至少[3/2−1 ]= 1个键,所有叶节点在树中的深度都是相同的,节点中的键按升序存储。

每次插入都要遍历树以找到一个目标叶节点,然后再遍历树以进行分割和提升。时间复杂度为O(lgn),与搜索性能相同。

删除节点数据

B-树中的删除操作与BST中的删除操作类似。如果我们从叶子节点删除,我们可以直接删除键。如果从内部节点进行删除,则删除键并提升其前任或继任者——分别是内部节点左侧子树中的最大键或右侧子树中的最小键。

在所有情况下,无论是直接从叶子删除还是提升前任或后继者,都将从叶子节点删除一个键,在叶子中留下L个键。如果L大于等于[k/2 - 1],停止平衡(旋转)操作。如果L<[k/2 - 1],意味着,叶子节点数不够满,我们必须重新平衡树木来纠正这个问题,保持树节点的深度都相等。

借位。我们首先检查叶子的左子树,然后检查它的右子树(如果它们存在的话),看看它是否有大于k/2−1个键。如果子树中存在满足条件的情况,我们可以借一个子树叶节点的键,同时保证子树中的叶节点树满足叶节点数最小值。

如果我们从左子树借位,我们执行以下操作:

  • 在父节点中获取拆分左兄弟节点和叶子的键,即拆分键,并将其插入到叶子中
  • 将分割键替换为左子树的最大键

当我们从order-5B-树中删除一个F键,因为每一个节点必须包含最少[5/2 - 1] = 2个键,不然将导致了叶子的下渗,保证树的平衡。叶子的左子树中叶节点包含了3个键,因此我们可以借用其中的一个键,将其放在父节点中,并将父节点的拆分键移动到到叶子上。从右子树的借位是相同的,除了我们将分割键替换为右子树的最小键。

合并。如果两个子节点都只有[k/2 - 1]个键,我们将叶子与其左子树节点和父节点的分裂键合并。
1.合并左同胞键、拆分键和叶子键。这样就产生了一个有k−1个键的节点
2.删除父键的拆分键和它现在为空的右子树

两个兄弟节点都没有超过2个键,因此我们将左节点、父节点的拆分键和叶节点合并,形成一个具有4个键的完整叶节点。父节点被更新,以删除它的拆分键和空的右子树。

在合并后,必须做两道检查。首先,如果父节点是树的根,如果它现在是空的,我们将合并节点作为新的根节点。其次,如果父节点是一个内部节点,并且现在它的键数小于k/2−1,那么它必须使用相同的借用-合并策略进行递归重新平衡。

B*树

B*树与B-树最大的区别在于B*树在插入算法的改进。B*树提升了内部节点存储率,从近似地k/2,提升到近似2k/3的利用率。这可以推迟树高度的增加,从而提高搜索性能。

B*树基本的思想是通过将节点重新分配给左子树或者右子树来达到延迟分裂节点的目的,如果不能重新分配,两个子节点数饱和的节点才进行节点分裂,变成三个2/3饱和度的子节点。相比于B-树直接将饱和节点分裂成两个1/2饱和度的节点,更能够推迟树高度的增加。

如果一个拥有k个键的叶子节点达到饱和状态,假设左子节点有m< k -1个键,将合并兄弟节点的所有键,包括将要分裂键和左子树的键。这将生成一个由l + m + 1键组成的集合,我们如下所示重新分配这些键。
1.将第一个[l+m/2]个键留在左同胞中
2.将分割键替换为位置键
3.将最后的[l+m/2]个键存储在叶子中

除了第一部分的[l+m/2]个键在叶节点中,最后一部分的[l+m/2]个键在右兄弟节点中之外,对右兄弟的重新分配是相同的。

如果向父节点添加两个分割键导致其溢出,则使用相同的重分发分割策略来递归地重新平衡树。

搜索和删除。搜索B*树和搜索B-树是一样的。高德纳没有提供关于如何从B*树中删除的具体细节。问题是如何在执行删除的同时维护至少保留内部节点的约束——2/3饱和度。在缺少B*树的删除策略的情况下,我们执行B-树删除算法操作,这意味着删除后可能会违反2/3饱和度的要求,解决方案,是在删除节点数据时,补充标记节点,保证饱和度,然后返回结果。

B+树

B+树是一个有序的键-值对集合和位于序列集合顶部的索引的组合。序列集是块(通常是磁盘页)的集合,作为双向链表绑定在一起。这些块形成键的b树索引的叶节点,它允许我们快速识别持有目标键的块。

将所有数据放在索引的叶子中提供了B+树比B-树的许多优势。

  • 序列集的块是链接的,因此扫描一组键值需要搜索第一个键并进行一次线性传递,而不是在B-tree索引上进行多次查询
  • 内部B+树节点只包含键和指针——而普通B树中的键-值对则相反,因此每个节点可以容纳更多的键,这可能导致树更矮

B+树的一个缺点是,如果在内部节点上找到了目标键,我们仍然必须遍历树的底部,以找到保存键值的叶子。对于B-树,这个值可以立即返回。由于树的高度设计紧凑,这通常是一个我们愿意接受的小损失。

在B+树中搜索、插入和删除的工作原理与B-树相同,但是要注意的是,所有操作对象都是叶节点,键值对也是存储在叶节点中的。例如,要在B+树中插入一个新的键值对,我们需要执行以下操作。
1.搜索B+树以找到保存新键的块
2.如果块中有空间可用,则存储键-值对并停止
3.如果块已满,将一个新块追加到文件的末尾,保留现有块中的第一个k/2键,并将剩余的k/2键移动到新块中
4.更新B+树的索引(使用普通的B树插入算法),将新块链接到索引中

前缀键优化

B+树的内部节点不包含搜索请求的数据,它们只是简单地将搜索指向一个可能包含目标键的块(叶子节点块)。这意味着我们只需要存储正确分隔块所需的每个键的数量。

当两个键:Benson和Bolen,分别存储在第一个磁盘块和第二个磁盘块中。父节点使用Benson来定义第一个块包含所有键的值≤Benson(表示键的值在字符排序上都小于等于Bdnson)。然而,我们不需要用整个键来分隔这些块。这将是充分使用中间键值——Bf,因为这将正确地定义第一个块包含的键的值复合≤Bf的数据,第二块包含的键大的值复合>Bf的数据。同样,可以将内部节点中的关键值Cage、Fisher和Wyatt分别更改为D、G和Y,方便区分和扩展。

一般情况下,为了区分最大密钥kA的块A和最小密钥kB的块B,选择任意这样的分隔键kS需要满足如下要求:kA ≤ ks < kB

在我们的kA = Benson和kB = Bolen的例子中,kS = Benson满足这个要求,但更短的kS = Bf也满足这个要求。如果kS有以下属性
1.kS是kA和kB之间的分隔符
2.没有其他的分隔符kS’比kS更短

那么kS满足前缀属性。用前缀键构造的B+树被称为简单前缀B+树。

通过选择最小的kS,我们可以增加在每个内部节点中存储的键的数量,从而产生具有更好搜索性能的扁平化的树。然而,这种改进并不是没有代价的。内部节点现在必须管理可变长度的记录,这意味着固定大小的节点中的条目数量将会变化。为了支持这一点,必须对搜索、插入和删除算法进行修改,以适应当记录变化时,数据在存储空间上的变化。
分页和指针法,这两种方法在大量写的时候,会造成写放大问题,即写入数据时,需要组织数据在磁盘上的分布,造成分页频繁等拖累数据写入速率。

性能。性能结果取决于所存储的数据类型。实验结果表明,对于包含400到800页的树,简单前缀B+树所需的磁盘访问次数比B+树少20-25%。

一种更有效的方法可以沿着树中的任意给定路径删除冗余的前缀信息,这比简单的前缀B+树略有改进(≈2%),但需要50-100%的时间来构建前缀。这表明生成更复杂前缀的开销超过了减少树高度所节省的任何开销。

LSM树

为了解决B+树在大量数据写入时产生的写放大问题,催生了LSM树这种数据结构,它牺牲了一部分读取数据的性能,提供了写入大量数据的能力。
LSM树,不支持数据修改,只支持添加方式,并且对数据段按照键进行排序,并生成主键相关的索引,在内存使用上分为磁盘部分和主内存部分数据,在数据结构上,两部分数据差不多,都分为索引和数据两部分。
Kafka、HBase和RocksDB以及Cassandra这些产品,在底层的数据结构就是LSM树,当然在实现上各有不同,也导致数据库在功能上的差异,Kafka根据offset进行数据映射读取,速度和效率都很快,HBase与Cassandra是很类似的,因为Cassandra在索引上的差异,使得Cassandra支持二级索引,而HBase不支持,所以对出主键之外的其余字段的条件查询上比较弱,RocksDB是基于内存的键值对数据库,有基于Java的jar版本,Dubbo就使用了RocksDB保存元数据(不知道是不是,没有看Dubbo的源码只是看到pom文件上引入的RocksDB的jar,以后会验证),MySQL的开源版本MariaDB,已经支持基于RocksDB实现的数据库引擎了。
LSM树在写入性能上有很大的提升,但是在读取和查询方面稍弱,这也是HBase和Kafka等产品在原生上的缺陷。Cassandra在索引上按照用户对字段的索引语句,在字段的列数据上生成额外索引,从而提供了二级索引的功能。
LSM树在删除数据项时,在数据段上插入墓碑标记(查询时会对此标记数据进行过滤),数据库定时对数据段进行整理时,将标记数据删除,并对数据段中修改记录进行合并、索引重构等相关处理,完成真正的数据段空间回收。
为保证数据的可靠性,LSM树在完成数据写入之前会首先将数据写入WAL日志,再执行内存数据相关构建的执行,然后返回数据写入成功(中间对于副本的需要根据对应产品的实现,在此不讨论)。产品定时将内存上的数据刷新到磁盘后,对WAL日志会根据产品的设计要求,执行删除或者分段的操作处理,保证数据库意外重启时,减少执行WAL日志进行数据恢复阶段时的等待时间。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值