B 树如何让您的查询更快?
B 树是一种帮助搜索大量数据的结构。它是 40 多年前发明的,但仍然被大多数现代数据库所采用。尽管有很多新的索引结构出现,例如 LSM 树,但 B 树在处理大多数数据库查询时有很大优势。
读完这篇文章后,您将了解 B 树如何组织数据以及它如何执行搜索查询。
Origins 起源
为了理解 B 树,我们首先关注二叉搜索树(BST)。
等等,这不是一样吗?
那么"B"代表什么?
据 wikipedia.org 报道,B 树的发明者 Edward M. McCreight 曾说过:
"你越多地思考 B 树中的 B 的含义,你就越能理解 B 树。"
将 B 树与 BST 混淆是一个非常常见的误解。无论如何,在我看来,BST 是理解 B 树的一个很好的起点。让我们从一个简单的 BST 示例开始:
大的数位于右侧,小的数字始终位于左侧。当我们添加更多数字时,情况可能会变得更清楚。
这棵树包含七个数字,但是我们最多需要访问三个节点才能找到任何数字。以下示例可视化搜索 14。我使用 SQL 定义查询,以便将这棵树视为实际的数据库索引。
硬件
理论上,使用二叉搜索树来运行我们的查询看起来不错。它的时间复杂度(搜索时)为 O(logn) ,与 B 树相同。然而,在实践中,这种数据结构需要在合适的硬件上工作。索引必须存储在计算机上的某个位置。
计算机可以存储数据的三个地方:
- CPU caches
- RAM (memory)
- Disk (storage)
缓存完全由 CPU 管理。而且它比较小,一般只有几兆字节。索引可能包含千兆字节的数据,因此它不适合放在那里。
数据库大量使用内存 (RAM)。它有一些很大的优点:
- 确保快速随机访问(下一段将详细介绍)
- 它的大小可能相当大(例如 RDS云服务提供具有几TB可用内存的实例)。
缺点?当电源关闭时,您会丢失数据。而且,与磁盘相比,它相当昂贵。
最后,内存的缺点也是磁盘存储的优点。它很便宜,即使我们断电,数据也会保留在那里。然而,天下没有免费的午餐!问题是我们需要小心随机和顺序访问。从磁盘读取速度很快,但仅限于特定条件!我将尝试简单地解释它们。
Random and sequential access
随机和顺序访问
内存可以被视为一排值的容器,其中每个容器都被编号。
现在假设我们要从容器 1、4 和 6 读取数据。它需要随机访问:
然后我们再与读取容器3、4、5进行比较。可能是顺序进行的:
T "随机跳转"和"顺序读取"之间的区别可以根据硬盘驱动器来解释。它由磁头和磁盘组成。
"随机跳转"需要将磁头移动到磁盘上的给定位置 。 "顺序读取"只是旋转磁盘,让磁头读取连续的值。当读取兆字节的数据时,这两种类型的访问之间的差异是巨大的。使用"顺序读取"可以显着减少获取数据所需的时间。
Adam Jacobs 发表在 Acm Queue 上的文章"大数据的病理学"研究了随机访问和顺序访问之间的速度差异。它揭示了一些令人震惊的事实:
-
HDD 上的顺序访问可能比随机访问快数十万倍。 🤯
-
从磁盘顺序读取可能比从内存随机读取更快。
现在谁还用HDD? SSD呢?这项研究表明,从 HDD 完全顺序读取可能比 SSD 更快。然而,请注意,这篇文章是 2009 年的,SSD 在过去十年中取得了显着的发展,因此这些结果可能已经过时了。
总而言之,关键的一点是尽可能选择顺序访问。在下一段中,我将解释如何将其应用到我们的索引结构中。
Optimizing a tree for sequential access
优化树的顺序访问
二叉搜索树在内存中的表示方式与堆(这里指数据结构堆)相同:
- 父节点位置为 i
- 左节点位置为 2i
- 右节点位置为 2i+1
这就是根据示例计算这些位置的方式(父节点从 1 开始):
根据计算出的位置,将节点对齐到内存中:
您还记得几章前可视化的查询吗?
这就是内存级别的样子:
执行查询时,需要访问内存地址1、3、6。访问三个节点不是问题;然而,当我们存储更多数据时,树就会变得更高 。存储超过一百万个值需要树的高度至少为 20。这意味着必须读取内存中不同位置的 20 个值。它会导致完全随机访问!
Pages 页数
当树越来越高时,随机访问会导致越来越多的延迟。减少这个问题的解决方案很简单:增加树的宽度而不是高度。它可以通过将多个值打包到单个节点中来实现。
它给我们带来了以下好处:
- 树更浅(两层而不是三层)
- 它仍然有很大的空间容纳新的值,而不需要进一步增长
对此类索引执行的查询如下所示:
Please note that every time we visit a node, we need to load all its values. In this example, we need to load 4 values (or 6 if the tree is full) in order to reach the one we are looking for. Below, you will find a visualization of this tree in a memory:
请注意,每次访问一个节点时,我们都需要加载它的(节点中)所有值。在此示例中,我们需要加载 4 个值(如果树已满,则加载 6 个值)才能找到我们要查找的值。下面,您将在内存中找到这棵树的可视化:
与前面的示例(树的高度增长)相比,此搜索应该更快。我们只需要随机访问两次(跳转到单元格 0 和 9),然后顺序读取其余值。
随着我们的数据库的增长,这个解决方案的效果越来越好。如果你想存储一百万个值,那么你需要:
- 二叉搜索树有 20 层
VS
- 具有 10 层的 3 值节点树
Values from a single node make a page. In the example above, each page consists of three values. A page is a set of values placed on a disk next to each other, so the database may reach the whole page at once with one sequential read.
来自单个节点的值构成一个page。在上面的示例中,每个page包含三个值。page是放置在磁盘上彼此相邻的一组值,因此数据库可以通过一次顺序读取一次到达整个page。
它如何指代现实? Postgres page大小为 8kB。假设 20% 用于元数据,因此还剩下 6kB。需要一半的page来存储指向节点子节点的指针,因此它为我们提供了 3kB 的值。 BIGINT 大小为 8 个字节,因此我们可以在单个page中存储约 375 个值。
假设数据库中一些相当大的表有 10 亿行,我们需要在 Postgres 树中有多少层来存储它们?根据上面的计算,如果我们创建一棵可以在单个节点中处理 375 个值的树,那么它可以用只有四层的树存储 10 亿个值。对于如此大量的数据,二叉搜索树需要 30 层。
总而言之,将多个值放置在树的单个节点中有助于我们降低其高度,从而利用顺序访问的好处。此外,B 树不仅可以在高度上增长,而且可以在宽度上增长(通过使用更大的page)。
平衡
数据库中有两种操作:写和读。在上一节中,我们解决了从 B 树读取数据的问题。尽管如此,写操作也是至关重要的一部分。将数据写入数据库时,B 树需要不断更新新值。
树的形状取决于添加到树中的值的顺序。它在二叉树中很容易看到。如果以不正确的顺序添加值,我们可能会获得不同深度的树。
当树在不同节点上具有不同的深度时,称为不平衡树。有两种基本方法可以使这样的树恢复平衡状态:
- 只需按照正确的顺序添加值即可从头开始重建它。
- 随着新值的添加,进行调整保持平衡。
B 树实现了第二个选项。使树始终保持平衡的一个特性称为自平衡。
Self-balancing algorithm by example
自平衡算法示例
构建 B 树可以简单地通过创建单个节点并添加新值直到其中没有可用空间来开始。
如果对应page没有空间,则需要进行分割。要执行分割,需要选择"分割点"。在这种情况下,它将是 12,因为它位于中间。 "分割点"是将移动到上方 page的值。
现在,它让我们看到一个有趣的点,即没有上方page。在这种情况下,需要生成一个新的页面(并且它成为新的根page!)。
最后,三个中还有一些可用空间,因此可以添加值 14。
按照这个算法,我们可以不断地向B树添加新的值,并且它会一直保持平衡!
此时,您可能会担心有大量可用空间没有机会被填充。例如,值 14、15 和 16 位于不同的页面上,因此这些页面将永远仅保留一个值和两个可用空间。
这是由于分割位置选择造成的。我们总是将页面从中间分开。但每次我们进行分割时,我们都可以选择任何我们想要的分割位置。
Postgres 有一个算法,每次执行分割时都会运行!它的实现可以在 Postgres 源代码中的 _bt_findsplitloc() 函数中找到。其目标是留下尽可能少的自由空间。*
总结
在本文中,您了解了 B 树的工作原理。总而言之,它可以简单地描述为二叉搜索树,有两个变化:
- 每个节点可能包含多个值
- 插入新值之后是自平衡算法。
尽管现代数据库使用的结构通常是B树的一些变体(例如B+树),但它们仍然基于原始概念。在我看来,B 树的一大优势在于它是直接设计用于在实际硬件上处理大量数据的。这可能就是B树陪伴我们这么久的原因。