indexing
Key-Value Store and Relational DB
尽管关系数据库支持多种类型的查询,但几乎所有查询都可以分为三种类型的磁盘操作:
- 扫描整个数据集。 (不使用索引)
- 单点查询:通过特定 key 查询索引
- 范围查询:按范围查询索引(索引已排序)
数据库索引主要涉及范围查询和点查询,很容易看出,范围查询只是点查询的超集。如果我们提取数据库索引的功能,那么创建一个键值存储就很简单了。但问题的关键是,数据库系统可以建立在键值存储之上。
在尝试关系数据库之前,我们将构建一个KV存储,但让我们首先探索一下我们的选择。
Hashtables
在设计通用 KV 存储时,哈希表是第一个被排除的。主要原因是排序——许多现实世界的应用程序确实需要排序。
但是,可以在专门的应用程序中使用哈希表。使用哈希表的一个令人头疼的问题是调整大小操作。单纯调整大小的时间复杂度为 O(n),并会导致磁盘空间和 IO 突然增加。可以逐步调整哈希表的大小,但这会增加复杂性。哈希表的另一个问题是何时调整大小;哈希表通常不会自动收缩,以避免频繁且昂贵的调整大小,从而浪费磁盘空间。
Hash 表不能作为底层存储结构的原因是:不支持范围查询(不支持 key 排序)
需要动态扩缩(渐进性扩容)
B-Trees
平衡二叉树可以在 O(log(n)) 内查询和更新,并且可以进行范围查询。 B 树大致是平衡的n 叉树。为什么使用n叉树而不是二叉树?有以下几个原因:
- 更少的空间开销
二叉树中的每个叶节点都是通过父节点的指针到达的,并且父节点也可能有父节点。平均每个叶子节点需要 1~2 个指针。
这与 B 树相反,B 树中叶节点中的多个数据共享一个父节点;并且 n 叉树也更短。指针上浪费的空间更少。
- 更少的磁盘IO
B树更短,这意味着更少的磁盘寻道。
磁盘 IO 的最小大小通常是内存页的大小(可能是4K)。操作系统将填满整个4K页面,即使你读取的是较小的数据。
如果我们利用4K页面中的所有信息(通过选择至少一个页面的节点大小),这是最佳的。
- 内存速度更快
即使数据缓存在内存中并且磁盘 IO 不在考虑范围内,由于现代 CPU 内存缓存和其他因素,n 叉树可以比二叉树更快,即使它们的大 O 复杂度相同。
我们将在本书中使用 B 树,但 B 树并不是唯一的选择。
LSM-Trees
日志结构的合并树,下面是 LSM-Tree 如何工作的概述。
让我们从 2 个文件开始:一个保存最近更新的小文件和一个保存其余数据的大文件。更新首先转到较小的文件,但该文件不能永远增长,它必须在某个时刻与大文件合并以创建一个新的更大文件。与更新某些内容时覆盖整个数据库的愚蠢方法相比,这是一种改进,因为它减少了写入。
writes => | new updates | => | accumulated data |
file 1 file 2
以及如何查询数据库?您必须查询这两个文件,并且较新(较小)的文件具有更高的优先级。对于点查询,可以先查询小文件,如果漏掉了再查询大文件。对于范围查询,同时查询两个文件并合并结果。删除通常是通过在小文件中放置一个标记来表明某个 key 已被删除;实际删除发生在文件合并时。
这两个文件都包含用于查询的索引数据结构。这样做的好处是,你可以使用更简单的数据结构,因为文件不会就地更新,更新操作会被合并操作取代。每个文件都可以是一个由指针数组索引的排序 KV 列表,这比 B 树更容易实现,也更不容易出错。
在合并文件时,2 个文件的写入量仍然不是最佳的,因为大文件中的数据会反复写入磁盘。幸运的是,这个想法可以推广到 2 个以上的文件,每个 "文件 "通常被称为一个 "层"。数据先进入第一层,当第一层太大时,第一层就会合并到第二层,第二层现在更大了。每一级数据过大时,就会合并到下一级更大、更老的数据中。
|level 1|
||
\/
|------level 2------|
||
\/
|-----------------level 3-----------------|
为什么这个方案写的比2级方案少?级别以指数级增长,超额磁盘写入(称为写放大)乘以数据大小为 O (log (n))。例如,你可以想象一个文件列表,其大小以2的幂指数递增,然后你将第一个文件的大小加倍,现在的大小如果与第二个文件相同,将其与第二个文件合并,然后将其与第三个文件合并,等等。
真正的数据库不会使用级别之间的两个比率的幂,因为它创建了太多级别,这会损害查询性能。级别之间的大小比率通常是可调的,以允许在写入放大和查询性能之间进行权衡。
此外,真正的数据库通常将级别实现为多个排序且不重叠的文件,而不是一个大的排序文件。合并是在小部分中执行的,这使得操作更顺畅。这也减少了磁盘空间要求,否则,合并最后一个级别将使磁盘空间使用量增加一倍。
读者读完本书后可以尝试使用LSM树来代替B树。并比较了B树和LSM树的优缺点。
B-Tree: The Ideas
The Intuitions of the B-Tree and BST
我们的第一直觉来自平衡二叉树(BST),二叉树是排序数据的流行数据结构。
在插入或移除 key 后保持树的良好状态就是“平衡”的含义。
正如上面所述,应该使用 n 叉树而不是二叉树来利用“页”(IO的最小单位)。
B- 树可以从 BST 中推广出来。B 树的每个节点包含多个键和指向其子节点的多个链接。在节点中查找键时,所有键都用于确定下一个子节点。
[1, 4, 9]
/ | \
v v v
[1, 2, 3] [4, 6] [9, 11, 12]
B 树的平衡与 BST 不同,流行的 BST(如 RB 树或 AVL 树)是根据子树的高度(通过旋转)来平衡的。所有 B 树叶节点的高度都是相同的,而 B 树则根据节点的大小来平衡:
- 如果节点太大而无法容纳在一页上,则会将其拆分为两个节点。这将增加父节点的大小,如果根节点被分割,则可能会增加树的高度。
- 如果节点太小,请尝试将其与兄弟节点合并。
如果您熟悉 RB 树,您可能也知道有 2-3 棵树可以很容易地概括为 B 树。
B-tree and Nested Arrays
即使对 2-3 树不熟悉,也可以通过嵌套数组获得一些直觉。
让我们从一个排序数组开始。可以通过二分来进行查询。但是,更新数组的复杂度是 O(n),我们需要解决这个问题。更新一个大数组是不好的,所以我们把它分成更小的数组。假设我们将数组分成 sqrt(n) 个部分,每个部分平均包含 sqrt(n) 个键。
[[1,2,3], [4,6], [9,11,12]]
要查询一个 key,我们必须首先确定哪个部分包含该密钥,在 sqrt(n) 部分上进行平分的结果是 O(log(n))。然后,在该部分上对键进行平分,也是 O(log(n)) - 不会比以前差。而更新则改进为 O(sqrt(n))
这是一个2级排序的嵌套数组,如果我们添加更多的级别会怎样? 这是 B 树的另一个直觉。
B-Tree Operations
查询 B 树与查询 BST 相同,更新 B 树则更为复杂。
从现在起,我们将使用一种名为 "B+ 树 "的 B 树变体,B+ 树只在叶子节点中存储值,内部节点只包含键。
键的插入从叶节点开始,叶子只是键的排序列表。在叶子中插入键是很容易的,但是插入可能会导致节点大小超过页面大小。
在这种情况下,我们需要将叶子节点拆分为两个节点,每个节点包含一半的键,这样两个叶子节点就可以放在一页中。
内部节点包括:
- 指向其子节点的指针列表
- 与指针列表配对的键列表,每个键都是相应子 key 的第一个键。
将叶子节点拆分为 2 个节点后,父节点会用新的指针和键替换旧的指针和键。
节点的大小也会增加,这可能会引发进一步的分裂。
parent parent
/ | \ => / | | \
L1 L2 L6 L1 L3 L4 L6
在根节点被拆分后,会添加一个新的根节点,这就是 B 树的生长过程。
new_root
/ \
root N1 N2
/ | \ => / | | \
L1 L2 L6 L1 L3 L4 L6
键删除与插入正好相反:节点永远不会是空的,因为一个小节点会被合并到它的左同级节点或右同级节点中。 而当一个非叶根被缩减为一个 key 时,该根就会被其唯一的子节点取代,这就是 B 树的收缩过程。
Immutable Data Structures
不可变意味着永远不会在原处更新数据。类似的术语还有 "只追加"、"写时拷贝"和 "持久数据结构"("持久 "一词与我们前面谈到的 "持久性 "无关)。
例如,在叶节点中插入键时,不要修改原节点,而是创建一个新节点,其中包含待更新节点的所有键和新键。现在还必须更新父节点,使其指向新节点。
同样,父节点也要用新的指针进行复制。直到我们到达根节点,整个路径都被复制了。这实际上是创建了一个与旧版本共存的新版本的树。我们之前提到的 LSM 树也被认为是不可变的。
不可变数据结构有几个优点:
- 避免数据损坏。不可变数据结构不会修改现有数据,而只是添加新数据,因此即使更新中断,旧版本的数据也会保持不变。
- 易于并发:reader 可以与 writer 同时运行,因为 reader 可以不受影响地处理旧版本的数据(读写分离)。
持久性和并发性将在后面的章节中介绍。现在,我们将首先编写一个不可变的 B+ 树。