mirror of
https://github.com/Vonng/ddia.git
synced 2025-01-05 15:30:06 +08:00
continue to re-check the translation of chapter 3
This commit is contained in:
parent
98c25413a5
commit
fc565f1202
90
ch3.md
90
ch3.md
@ -144,74 +144,75 @@ CSV不是日志的最佳格式。使用二进制格式更快,更简单:首
|
||||
|
||||
### SSTables和LSM树
|
||||
|
||||
在[图3-3](img/fig3-3.png)中,每个日志结构存储段都是一系列键值对。这些对按照它们写入的顺序出现,日志中稍后的值优先于日志中较早的相同键的值。除此之外,文件中键值对的顺序并不重要。
|
||||
在[图3-3](img/fig3-3.png)中,每个日志结构存储段都是一系列键值对。这些键值对按照它们写入的顺序排列,日志中稍后的值优先于日志中较早的相同键的值。除此之外,文件中键值对的顺序并不重要。
|
||||
|
||||
现在我们可以对段文件的格式做一个简单的改变:我们要求键值对的序列按键排序。乍一看,这个要求似乎打破了我们使用顺序写入的能力,但是我们马上就会明白这一点。
|
||||
现在我们可以对段文件的格式做一个简单的改变:要求键值对的序列按键排序。乍一看,这个要求似乎打破了我们使用顺序写入的能力,我们将稍后再回到这个问题。
|
||||
|
||||
我们把这个格式称为**排序字符串表(Sorted String Table)**,简称SSTable。我们还要求每个键只在每个合并的段文件中出现一次(压缩过程已经保证)。与使用散列索引的日志段相比,SSTable有几个很大的优势:
|
||||
我们把这个格式称为**排序字符串表(Sorted String Table)**,简称SSTable。我们还要求每个键只在每个合并的段文件中出现一次(压缩过程已经保证)。与使用散列索引的日志段相比,SSTable有几个大的优势:
|
||||
|
||||
1. 合并段是简单而高效的,即使文件大于可用内存。这种方法就像归并排序算法中使用的方法一样,如[图3-4](img/fig3-4.png)所示:你开始并排读取输入文件,查看每个文件中的第一个键,复制最低键(根据排序顺序)到输出文件,并重复。这产生一个新的合并段文件,也按键排序。
|
||||
1. 即使文件大于可用内存,合并段的操作仍然是简单而高效的。这种方法就像归并排序算法中使用的方法一样,如[图3-4](img/fig3-4.png)所示:你开始并排读取多个输入文件,查看每个文件中的第一个键,复制最低的键(根据排序顺序)到输出文件,不断重复此步骤,将产生一个新的合并段文件,而且它也是也按键排序的。
|
||||
|
||||
![](img/fig3-4.png)
|
||||
|
||||
**图3-4 合并几个SSTable段,只保留每个键的最新值**
|
||||
|
||||
如果在几个输入段中出现相同的键,该怎么办?请记住,每个段都包含在一段时间内写入数据库的所有值。这意味着一个输入段中的所有值必须比另一个段中的所有值更新(假设我们总是合并相邻的段)。当多个段包含相同的键时,我们可以保留最近段的值,并丢弃旧段中的值。
|
||||
如果在几个输入段中出现相同的键,该怎么办?请记住,每个段都包含在一段时间内写入数据库的所有值。这意味着一个输入段中的所有值一定比另一个段中的所有值都更近(假设我们总是合并相邻的段)。当多个段包含相同的键时,我们可以保留最近段的值,并丢弃旧段中的值。
|
||||
|
||||
2. 为了在文件中找到一个特定的键,你不再需要保存内存中所有键的索引。以[图3-5](img/fig3-5.png)为例:假设你正在内存中寻找键 `handiwork`,但是你不知道段文件中该关键字的确切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由于排序特性,你知道 `handiwork` 必须出现在这两者之间。这意味着你可以跳到 `handbag` 的偏移位置并从那里扫描,直到你找到 `handiwork`(或没找到,如果该文件中没有该键)。
|
||||
2. 为了在文件中找到一个特定的键,你不再需要在内存中保存所有键的索引。以[图3-5](img/fig3-5.png)为例:假设你正在内存中寻找键 `handiwork`,但是你不知道段文件中该关键字的确切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由于排序特性,你知道 `handiwork` 必须出现在这两者之间。这意味着你可以跳到 `handbag` 的偏移位置并从那里扫描,直到你找到 `handiwork`(或没找到,如果该文件中没有该键)。
|
||||
|
||||
![](img/fig3-5.png)
|
||||
|
||||
**图3-5 具有内存索引的SSTable**
|
||||
|
||||
你仍然需要一个内存中索引来告诉你一些键的偏移量,但它可能很稀疏:每几千字节的段文件就有一个键就足够了,因为几千字节可以很快被扫描[^i]。
|
||||
你仍然需要一个内存中的索引来告诉你一些键的偏移量,但它可以是稀疏的:每几千字节的段文件有一个键就足够了,因为几千字节可以很快地被扫描完[^i]。
|
||||
|
||||
[^i]: 如果所有的键与值都是定长的,你可以使用段文件上的二分查找并完全避免使用内存索引。然而实践中的键和值通常都是变长的,因此如果没有索引,就很难知道记录的分界点(前一条记录结束以及后一条记录开始的地方)。
|
||||
|
||||
3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组到块中,并在将其写入磁盘之前对其进行压缩(如[图3-5](img/fig3-5.png)中的阴影区域所示) 。稀疏内存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少IO带宽的使用。
|
||||
3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组为块(block),并在将其写入磁盘之前对其进行压缩(如[图3-5](img/fig3-5.png)中的阴影区域所示)[^译注i] 。稀疏内存索引中的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少对I/O带宽的使用。
|
||||
|
||||
|
||||
[^i]: 如果所有的键与值都是定长的,你可以使用段文件上的二分查找并完全避免使用内存索引。然而实践中键值通常都是变长的,因此如果没有索引,就很难知道记录的分界点(前一条记录结束,后一条记录开始的地方)
|
||||
[^译注i]: 这里的压缩是compression,不是前文的compaction,请注意区分。
|
||||
|
||||
#### 构建和维护SSTables
|
||||
|
||||
到目前为止,但是如何让你的数据首先被按键排序呢?我们的传入写入可以以任何顺序发生。
|
||||
到目前为止还不错,但是如何让你的数据能够预先排好序呢?毕竟我们接收到的写入请求可能以任何顺序发生。
|
||||
|
||||
在磁盘上维护有序结构是可能的(请参阅“[B树](#B树)”),但在内存保存则要容易得多。有许多可以使用的众所周知的树形数据结构,例如红黑树或AVL树【2】。使用这些数据结构,你可以按任何顺序插入键,并按排序顺序读取它们。
|
||||
虽然在磁盘上维护有序结构也是可能的(请参阅“[B树](#B树)”),但在内存保存则要容易得多。有许多可以使用的众所周知的树形数据结构,例如红黑树或AVL树【2】。使用这些数据结构,你可以按任何顺序插入键,并按排序顺序读取它们。
|
||||
|
||||
现在我们可以使我们的存储引擎工作如下:
|
||||
现在我们可以让我们的存储引擎以如下方式工作:
|
||||
|
||||
* 写入时,将其添加到内存中的平衡树数据结构(例如,红黑树)。这个内存树有时被称为**内存表(memtable)**。
|
||||
* 当**内存表**大于某个阈值(通常为几兆字节)时,将其作为SSTable文件写入磁盘。这可以高效地完成,因为树已经维护了按键排序的键值对。新的SSTable文件成为数据库的最新部分。当SSTable被写入磁盘时,写入可以继续到一个新的内存表实例。
|
||||
* 为了提供读取请求,首先尝试在内存表中找到关键字,然后在最近的磁盘段中,然后在下一个较旧的段中找到该关键字。
|
||||
* 有时会在后台运行合并和压缩过程以组合段文件并丢弃覆盖或删除的值。
|
||||
* 有新写入时,将其添加到内存中的平衡树数据结构(例如红黑树)。这个内存树有时被称为**内存表(memtable)**。
|
||||
* 当**内存表**大于某个阈值(通常为几兆字节)时,将其作为SSTable文件写入磁盘。这可以高效地完成,因为树已经维护了按键排序的键值对。新的SSTable文件将成为数据库中最新的段。当该SSTable被写入磁盘时,新的写入可以在一个新的内存表实例上继续进行。
|
||||
* 收到读取请求时,首先尝试在内存表中找到对应的键,如果没有就在最近的磁盘段中寻找,如果还没有就在下一个较旧的段中继续寻找,以此类推。
|
||||
* 时不时地,在后台运行一个合并和压缩过程,以合并段文件并将已覆盖或已删除的值丢弃掉。
|
||||
|
||||
这个方案效果很好。它只会遇到一个问题:如果数据库崩溃,则最近的写入(在内存表中,但尚未写入磁盘)将丢失。为了避免这个问题,我们可以在磁盘上保存一个单独的日志,每个写入都会立即被附加到磁盘上,就像在前一节中一样。该日志不是按排序顺序,但这并不重要,因为它的唯一目的是在崩溃后恢复内存表。每当内存表写出到SSTable时,相应的日志都可以被丢弃。
|
||||
这个方案效果很好。它只会遇到一个问题:如果数据库崩溃,则最近的写入(在内存表中,但尚未写入磁盘)将丢失。为了避免这个问题,我们可以在磁盘上保存一个单独的日志,每个写入都会立即被追加到这个日志上,就像在前面的章节中所描述的那样。这个日志没有按排序顺序,但这并不重要,因为它的唯一目的是在崩溃后恢复内存表。每当内存表写出到SSTable时,相应的日志都可以被丢弃。
|
||||
|
||||
#### 用SSTables制作LSM树
|
||||
|
||||
这里描述的算法本质上是LevelDB 【6】和RocksDB 【7】中使用的键值存储引擎库,被设计嵌入到其他应用程序中。除此之外,LevelDB可以在Riak中用作Bitcask的替代品。在Cassandra和HBase中使用了类似的存储引擎【8】,这两种引擎都受到了Google的Bigtable文档【9】(引入了术语 SSTable 和 memtable )的启发。
|
||||
这里描述的算法本质上是LevelDB【6】和RocksDB【7】这些键值存储引擎库所使用的技术,这些存储引擎被设计嵌入到其他应用程序中。除此之外,LevelDB可以在Riak中用作Bitcask的替代品。在Cassandra和HBase中也使用了类似的存储引擎【8】,而且他们都受到了Google的Bigtable论文【9】(引入了术语 SSTable 和 memtable )的启发。
|
||||
|
||||
最初这种索引结构是由Patrick O'Neil等人描述的,且被命名为日志结构合并树(或LSM树)【10】,它是基于更早之前的日志结构文件系统【11】来构建的。基于这种合并和压缩排序文件原理的存储引擎通常被称为LSM存储引擎。
|
||||
|
||||
Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使用类似的方法来存储它的词典【12,13】。全文索引比键值索引复杂得多,但是基于类似的想法:在搜索查询中给出一个单词,找到提及单词的所有文档(网页,产品描述等)。这是通过键值结构实现的,其中键是单词(**关键词(term)**),值是包含单词(文章列表)的所有文档的ID的列表。在Lucene中,从术语到发布列表的这种映射保存在SSTable类的有序文件中,根据需要在后台合并【14】。
|
||||
Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使用类似的方法来存储它的关键词词典【12,13】。全文索引比键值索引复杂得多,但是基于类似的想法:在搜索查询中给出一个单词,找到提及单词的所有文档(网页,产品描述等)。这是通过键值结构实现的,其中键是单词(**关键词(term)**),值是所有包含该单词的文档的ID列表(记录列表)。在Lucene中,从术语到记录列表的这种映射保存在类似于SSTable的有序文件中,并根据需要在后台合并【14】。
|
||||
|
||||
#### 性能优化
|
||||
|
||||
与往常一样,大量的细节使得存储引擎在实践中表现良好。例如,当查找数据库中不存在的键时,LSM树算法可能会很慢:你必须检查内存表,然后将这些段一直回到最老的(可能必须从磁盘读取每一个),然后才能确定键不存在。为了优化这种访问,存储引擎通常使用额外的Bloom过滤器【15】。 (布隆过滤器是用于近似集合内容的内存高效数据结构,它可以告诉你数据库中是否出现键,从而为不存在的键节省许多不必要的磁盘读取操作。)
|
||||
与往常一样,要让存储引擎在实践中表现良好涉及到大量设计细节。例如,当查找数据库中不存在的键时,LSM树算法可能会很慢:你必须先检查内存表,然后查看从最近的到最旧的所有的段(可能还必须从磁盘读取每一个段文件),然后才能确定这个键不存在。为了优化这种访问,存储引擎通常使用额外的布隆过滤器(Bloom filters)【15】。 (布隆过滤器是用于近似集合内容的高效内存数据结构,它可以告诉你数据库中是不是不存在某个键,从而为不存在的键节省掉许多不必要的磁盘读取操作。)
|
||||
|
||||
还有不同的策略来确定SSTables如何被压缩和合并的顺序和时间。最常见的选择是size-tiered和leveled compaction。LevelDB和RocksDB使用leveled compaction(LevelDB因此得名),HBase使用size-tiered,Cassandra同时支持【16】。对于sized-tiered,较新和较小的SSTables相继被合并到较旧的和较大的SSTable中。对于leveled compaction,key范围被拆分到较小的SSTables,而较旧的数据被移动到单独的层级(level),这使得压缩(compaction)能够更加增量地进行,并且使用较少的磁盘空间。
|
||||
|
||||
即使有许多微妙的东西,LSM树的基本思想 —— 保存一系列在后台合并的SSTables —— 简单而有效。即使数据集比可用内存大得多,它仍能继续正常工作。由于数据按排序顺序存储,因此可以高效地执行范围查询(扫描所有高于某些最小值和最高值的所有键),并且因为磁盘写入是连续的,所以LSM树可以支持非常高的写入吞吐量。
|
||||
还有一些不同的策略来确定SSTables被压缩和合并的顺序和时间。最常见的选择是size-tiered和leveled compaction。LevelDB和RocksDB使用leveled compaction(LevelDB因此得名),HBase使用size-tiered,Cassandra同时支持这两种【16】。对于sized-tiered,较新和较小的SSTables相继被合并到较旧的和较大的SSTable中。对于leveled compaction,key范围被拆分到较小的SSTables,而较旧的数据被移动到单独的层级(level),这使得压缩(compaction)能够更加增量地进行,并且使用较少的磁盘空间。
|
||||
|
||||
即使有许多微妙的东西,LSM树的基本思想 —— 保存一系列在后台合并的SSTables —— 简单而有效。即使数据集比可用内存大得多,它仍能继续正常工作。由于数据按排序顺序存储,你可以高效地执行范围查询(扫描所有从某个最小值到某个最大值之间的所有键),并且因为磁盘写入是连续的,所以LSM树可以支持非常高的写入吞吐量。
|
||||
|
||||
|
||||
### B树
|
||||
|
||||
刚才讨论的日志结构索引正处在逐渐被接受的阶段,但它们并不是最常见的索引类型。使用最广泛的索引结构在1970年被引入【17】,不到10年后变得“无处不在”【18】,B树经受了时间的考验。在几乎所有的关系数据库中,它们仍然是标准的索引实现,许多非关系数据库也使用它们。
|
||||
前面讨论的日志结构索引正处在逐渐被接受的阶段,但它们并不是最常见的索引类型。使用最广泛的索引结构和日志结构索引相当不同,它就是我们接下来要讨论的B树。
|
||||
|
||||
像SSTables一样,B树保持按键排序的键值对,这允许高效的键值查找和范围查询。但这就是相似之处的结尾:B树有着非常不同的设计理念。
|
||||
从1970年被引入【17】,仅不到10年后就变得“无处不在”【18】,B树很好地经受了时间的考验。在几乎所有的关系数据库中,它们仍然是标准的索引实现,许多非关系数据库也会使用到B树。
|
||||
|
||||
我们前面看到的日志结构索引将数据库分解为可变大小的段,通常是几兆字节或更大的大小,并且总是按顺序编写段。相比之下,B树将数据库分解成固定大小的块或页面,传统上大小为4KB(有时会更大),并且一次只能读取或写入一个页面。这种设计更接近于底层硬件,因为磁盘也被安排在固定大小的块中。
|
||||
像SSTables一样,B树保持按键排序的键值对,这允许高效的键值查找和范围查询。但这也就是所有的相似之处了:B树有着非常不同的设计理念。
|
||||
|
||||
我们前面看到的日志结构索引将数据库分解为可变大小的段,通常是几兆字节或更大的大小,并且总是按顺序写入段。相比之下,B树将数据库分解成固定大小的块(block)或页面(page),传统上大小为4KB(有时会更大),并且一次只能读取或写入一个页面。这种设计更接近于底层硬件,因为磁盘空间也是按固定大小的块来组织的。
|
||||
|
||||
每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在磁盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如[图3-6](img/fig3-6.png)所示。
|
||||
|
||||
@ -221,37 +222,37 @@ Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使
|
||||
|
||||
一个页面会被指定为B树的根;在索引中查找一个键时,就从这里开始。该页面包含几个键和对子页面的引用。每个子页面负责一段连续范围的键,引用之间的键,指明了引用子页面的键范围。
|
||||
|
||||
在[图3-6](img/fig3-6.png)的例子中,我们正在寻找关键字 251 ,所以我们知道我们需要遵循边界 200 和 300 之间的页面引用。这将我们带到一个类似的页面,进一步打破了200 - 300到子范围。
|
||||
在[图3-6](img/fig3-6.png)的例子中,我们正在寻找关键字 251 ,所以我们知道我们需要跟踪边界 200 和 300 之间的页面引用。这将我们带到一个类似的页面,进一步将200到300的范围拆分到子范围。
|
||||
|
||||
最后,我们可以看到包含单个键(叶页)的页面,该页面包含每个键的内联值,或者包含对可以找到值的页面的引用。
|
||||
最终,我们将到达某个包含单个键的页面(叶子页面,leaf page),该页面或者直接包含每个键的值,或者包含了对可以找到值的页面的引用。
|
||||
|
||||
在B树的一个页面中对子页面的引用的数量称为分支因子。例如,在[图3-6](img/fig3-6.png)中,分支因子是 6 。在实践中,分支因子取决于存储页面参考和范围边界所需的空间量,但通常是几百个。
|
||||
在B树的一个页面中对子页面的引用的数量称为分支因子。例如,在[图3-6](img/fig3-6.png)中,分支因子是 6 。在实践中,分支因子取决于存储页面引用和范围边界所需的空间量,但通常是几百个。
|
||||
|
||||
如果要更新B树中现有键的值,则搜索包含该键的叶页,更改该页中的值,并将该页写回到磁盘(对该页的任何引用保持有效) 。如果你想添加一个新的键,你需要找到其范围包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以解释键范围的新分区,如[图3-7](img/fig3-7.png)所示[^ii]。
|
||||
|
||||
[^ii]: 向B树中插入一个新的键是相当符合直觉的,但删除一个键(同时保持树平衡)就会牵扯很多其他东西了。
|
||||
如果要更新B树中现有键的值,需要搜索包含该键的叶子页面,更改该页面中的值,并将该页面写回到磁盘(对该页面的任何引用都将保持有效)。如果你想添加一个新的键,你需要找到其范围能包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以反映新的键范围分区,如[图3-7](img/fig3-7.png)所示[^ii]。
|
||||
|
||||
![](img/fig3-7.png)
|
||||
|
||||
**图3-7 通过分割页面来生长B树**
|
||||
|
||||
该算法确保树保持平衡:具有 n 个键的B树总是具有 $O(log n)$ 的深度。大多数数据库可以放入一个三到四层的B树,所以你不需要追踪多个页面引用来找到你正在查找的页面。 (分支因子为 500 的 4KB 页面的四级树可以存储多达 256TB 。)
|
||||
[^ii]: 向B树中插入一个新的键是相当符合直觉的,但删除一个键(同时保持树平衡)就会牵扯很多其他东西了【2】。
|
||||
|
||||
这个算法可以确保树保持平衡:具有n个键的B树总是具有 $O(log n)$ 的深度。大多数数据库可以放入一个三到四层的B树,所以你不需要追踪多个页面引用来找到你正在查找的页面。(分支因子为500的4KB页面的四级树可以存储多达256TB的数据。)
|
||||
|
||||
#### 让B树更可靠
|
||||
|
||||
B树的基本底层写操作是用新数据覆盖磁盘上的页面。假定覆盖不改变页面的位置:即,当页面被覆盖时,对该页面的所有引用保持完整。这与日志结构索引(如LSM树)形成鲜明对比,后者只附加到文件(并最终删除过时的文件),但从不修改文件。
|
||||
B树的基本底层写操作是用新数据覆写硬盘上的页面,并假定覆写不改变页面的位置:即,当页面被覆写时,对该页面的所有引用保持完整。这与日志结构索引(如LSM树)形成鲜明对比,后者只追加到文件(并最终删除过时的文件),但从不修改文件中已有的内容。
|
||||
|
||||
你可以考虑将硬盘上的页面覆盖为实际的硬件操作。在磁性硬盘驱动器上,这意味着将磁头移动到正确的位置,等待旋转盘上的正确位置出现,然后用新的数据覆盖适当的扇区。在固态硬盘上,由于SSD必须一次擦除和重写相当大的存储芯片块,所以会发生更复杂的事情【19】。
|
||||
你可以把覆写硬盘上的页面对应为实际的硬件操作。在磁性硬盘驱动器上,这意味着将磁头移动到正确的位置,等待旋转盘上的正确位置出现,然后用新的数据覆写适当的扇区。在固态硬盘上,由于SSD必须一次擦除和重写相当大的存储芯片块,所以会发生更复杂的事情【19】。
|
||||
|
||||
而且,一些操作需要覆盖几个不同的页面。例如,如果因为插入导致页面过满而拆分页面,则需要编写已拆分的两个页面,并覆盖其父页面以更新对两个子页面的引用。这是一个危险的操作,因为如果数据库在仅有一些页面被写入后崩溃,那么最终将导致一个损坏的索引(例如,可能有一个孤儿页面不是任何父项的子项) 。
|
||||
而且,一些操作需要覆写几个不同的页面。例如,如果因为插入导致页面过满而拆分页面,则需要写入新拆分的两个页面,并覆写其父页面以更新对两个子页面的引用。这是一个危险的操作,因为如果数据库在仅有部分页面被写入时崩溃,那么最终将导致一个损坏的索引(例如,可能有一个孤儿页面不是任何父项的子项) 。
|
||||
|
||||
为了使数据库对崩溃具有韧性,B树实现通常会带有一个额外的磁盘数据结构:**预写式日志(WAL, write-ahead-log)**(也称为**重做日志(redo log)**)。这是一个仅追加的文件,每个B树修改都可以应用到树本身的页面上。当数据库在崩溃后恢复时,这个日志被用来使B树恢复到一致的状态【5,20】。
|
||||
为了使数据库能处理异常崩溃的场景,B树实现通常会带有一个额外的磁盘数据结构:**预写式日志(WAL, write-ahead log)**(也称为**重做日志(redo log)**)。这是一个仅追加的文件,每个B树的修改在其能被应用到树本身的页面之前都必须先写入到该文件。当数据库在崩溃后恢复时,这个日志将被用来使B树恢复到一致的状态【5,20】。
|
||||
|
||||
更新页面的一个额外的复杂情况是,如果多个线程要同时访问B树,则需要仔细的并发控制 —— 否则线程可能会看到树处于不一致的状态。这通常通过使用**锁存器(latches)**(轻量级锁)保护树的数据结构来完成。日志结构化的方法在这方面更简单,因为它们在后台进行所有的合并,而不会干扰传入的查询,并且不时地将旧的分段原子交换为新的分段。
|
||||
另外还有一个更新页面的复杂情况是,如果多个线程要同时访问B树,则需要仔细的并发控制 —— 否则线程可能会看到树处于不一致的状态。这通常是通过使用**锁存器(latches)**(轻量级锁)保护树的数据结构来完成。日志结构化的方法在这方面更简单,因为它们在后台进行所有的合并,而不会干扰新接收到的查询,并且能够时不时地将旧的段原子交换为新的段。
|
||||
|
||||
#### B树优化
|
||||
|
||||
由于B树已经存在了这么久,许多优化已经发展了多年,这并不奇怪。仅举几例:
|
||||
由于B树已经存在了很久,所以并不奇怪这么多年下来有很多优化的设计被开发出来,仅举几例:
|
||||
|
||||
* 一些数据库(如LMDB)使用写时复制方案【21】,而不是覆盖页面并维护WAL进行崩溃恢复。修改的页面被写入到不同的位置,并且树中的父页面的新版本被创建,指向新的位置。这种方法对于并发控制也很有用,我们将在“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”中看到。
|
||||
* 我们可以通过不存储整个键,而是缩短其大小,来节省页面空间。特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子,因此更少的层次。
|
||||
@ -361,7 +362,6 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
|
||||
如果 **非易失性存储器(NVM)** 技术得到更广泛的应用,可能还需要进一步改变存储引擎设计【46】。目前这是一个新的研究领域,值得关注。
|
||||
|
||||
|
||||
|
||||
## 事务处理还是分析?
|
||||
|
||||
在早期业务数据处理过程中,一次典型的数据库写入通常与一笔 *商业交易(commercial transaction)* 相对应:卖个货,向供应商下订单,支付员工工资等等。但随着数据库应用至那些不涉及到钱的领域,术语 **交易/事务(transaction)** 仍留了下来,用于指代一组读写操作构成的逻辑单元。
|
||||
@ -441,7 +441,6 @@ Teradata,Vertica,SAP HANA和ParAccel等数据仓库供应商通常使用昂
|
||||
在典型的数据仓库中,表格通常非常宽:事实表格通常有100列以上,有时甚至有数百列【51】。维度表也可以是非常宽的,因为它们包括可能与分析相关的所有元数据——例如,`dim_store` 表可以包括在每个商店提供哪些服务的细节,它是否具有店内面包房,店面面积,商店第一次开张的日期,最后一次改造的时间,离最近的高速公路的距离等等。
|
||||
|
||||
|
||||
|
||||
## 列存储
|
||||
|
||||
如果事实表中有万亿行和数PB的数据,那么高效地存储和查询它们就成为一个具有挑战性的问题。维度表通常要小得多(数百万行),所以在本节中我们将主要关注事实表的存储。
|
||||
@ -482,7 +481,6 @@ GROUP BY
|
||||
面向列的存储布局依赖于每个列文件包含相同顺序的行。 因此,如果你需要重新组装整行,你可以从每个单独的列文件中获取第23项,并将它们放在一起形成表的第23行。
|
||||
|
||||
|
||||
|
||||
### 列压缩
|
||||
|
||||
除了仅从磁盘加载查询所需的列以外,我们还可以通过压缩数据来进一步降低对磁盘吞吐量的需求。幸运的是,面向列的存储通常很适合压缩。
|
||||
@ -525,7 +523,6 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
除了减少需要从磁盘加载的数据量以外,面向列的存储布局也可以有效利用CPU周期。例如,查询引擎可以将大量压缩的列数据放在CPU的L1缓存中,然后在紧密的循环中循环(即没有函数调用)。相比较每个记录的处理都需要大量函数调用和条件判断的代码,CPU执行这样一个循环要快得多。列压缩允许列中的更多行适合相同数量的L1缓存。前面描述的按位“与”和“或”运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为矢量化处理【58,49】。
|
||||
|
||||
|
||||
|
||||
### 列存储中的排序顺序
|
||||
|
||||
在列存储中,存储行的顺序并不一定很重要。按插入顺序存储它们是最简单的,因为插入一个新行只需要追加到每个列文件。但是,我们可以选择增加一个特定的顺序,就像我们之前对SSTables所做的那样,并将其用作索引机制。
|
||||
@ -581,7 +578,6 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
缺点是数据立方体不具有查询原始数据的灵活性。例如,没有办法计算哪个销售比例来自成本超过100美元的项目,因为价格不是其中的一个维度。因此,大多数数据仓库试图保留尽可能多的原始数据,并将聚合数据(如数据立方体)仅用作某些查询的性能提升。
|
||||
|
||||
|
||||
|
||||
## 本章小结
|
||||
|
||||
在本章中,我们试图深入了解数据库如何处理存储和检索。将数据存储在数据库中会发生什么,以及稍后再次查询数据时数据库会做什么?
|
||||
@ -610,9 +606,6 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
尽管本章不能让你成为一个特定存储引擎的调参专家,但它至少有大概率使你有了足够的概念与词汇储备去读懂数据库的文档,从而选择合适的数据库。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## 参考文献
|
||||
|
||||
1. Alfred V. Aho, John E. Hopcroft, and Jeffrey D. Ullman: *Data Structures and Algorithms*. Addison-Wesley, 1983. ISBN: 978-0-201-00023-8
|
||||
@ -681,7 +674,6 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
1. Jim Gray, Surajit Chaudhuri, Adam Bosworth, et al.: “[Data Cube: A Relational Aggregation Operator Generalizing Group-By, Cross-Tab, and Sub-Totals](http://arxiv.org/pdf/cs/0701155.pdf),” *Data Mining and Knowledge Discovery*, volume 1, number 1, pages 29–53, March 2007. [doi:10.1023/A:1009726021843](http://dx.doi.org/10.1023/A:1009726021843)
|
||||
|
||||
|
||||
|
||||
------
|
||||
|
||||
| 上一章 | 目录 | 下一章 |
|
||||
|
90
zh-tw/ch3.md
90
zh-tw/ch3.md
@ -144,74 +144,75 @@ CSV不是日誌的最佳格式。使用二進位制格式更快,更簡單:
|
||||
|
||||
### SSTables和LSM樹
|
||||
|
||||
在[圖3-3](../img/fig3-3.png)中,每個日誌結構儲存段都是一系列鍵值對。這些對按照它們寫入的順序出現,日誌中稍後的值優先於日誌中較早的相同鍵的值。除此之外,檔案中鍵值對的順序並不重要。
|
||||
在[圖3-3](../img/fig3-3.png)中,每個日誌結構儲存段都是一系列鍵值對。這些鍵值對按照它們寫入的順序排列,日誌中稍後的值優先於日誌中較早的相同鍵的值。除此之外,檔案中鍵值對的順序並不重要。
|
||||
|
||||
現在我們可以對段檔案的格式做一個簡單的改變:我們要求鍵值對的序列按鍵排序。乍一看,這個要求似乎打破了我們使用順序寫入的能力,但是我們馬上就會明白這一點。
|
||||
現在我們可以對段檔案的格式做一個簡單的改變:要求鍵值對的序列按鍵排序。乍一看,這個要求似乎打破了我們使用順序寫入的能力,我們將稍後再回到這個問題。
|
||||
|
||||
我們把這個格式稱為**排序字串表(Sorted String Table)**,簡稱SSTable。我們還要求每個鍵只在每個合併的段檔案中出現一次(壓縮過程已經保證)。與使用雜湊索引的日誌段相比,SSTable有幾個很大的優勢:
|
||||
我們把這個格式稱為**排序字串表(Sorted String Table)**,簡稱SSTable。我們還要求每個鍵只在每個合併的段檔案中出現一次(壓縮過程已經保證)。與使用雜湊索引的日誌段相比,SSTable有幾個大的優勢:
|
||||
|
||||
1. 合併段是簡單而高效的,即使檔案大於可用記憶體。這種方法就像歸併排序演算法中使用的方法一樣,如[圖3-4](../img/fig3-4.png)所示:你開始並排讀取輸入檔案,檢視每個檔案中的第一個鍵,複製最低鍵(根據排序順序)到輸出檔案,並重復。這產生一個新的合併段檔案,也按鍵排序。
|
||||
1. 即使檔案大於可用記憶體,合併段的操作仍然是簡單而高效的。這種方法就像歸併排序演算法中使用的方法一樣,如[圖3-4](../img/fig3-4.png)所示:你開始並排讀取多個輸入檔案,檢視每個檔案中的第一個鍵,複製最低的鍵(根據排序順序)到輸出檔案,不斷重複此步驟,將產生一個新的合併段檔案,而且它也是也按鍵排序的。
|
||||
|
||||
![](../img/fig3-4.png)
|
||||
|
||||
**圖3-4 合併幾個SSTable段,只保留每個鍵的最新值**
|
||||
|
||||
如果在幾個輸入段中出現相同的鍵,該怎麼辦?請記住,每個段都包含在一段時間內寫入資料庫的所有值。這意味著一個輸入段中的所有值必須比另一個段中的所有值更新(假設我們總是合併相鄰的段)。當多個段包含相同的鍵時,我們可以保留最近段的值,並丟棄舊段中的值。
|
||||
如果在幾個輸入段中出現相同的鍵,該怎麼辦?請記住,每個段都包含在一段時間內寫入資料庫的所有值。這意味著一個輸入段中的所有值一定比另一個段中的所有值都更近(假設我們總是合併相鄰的段)。當多個段包含相同的鍵時,我們可以保留最近段的值,並丟棄舊段中的值。
|
||||
|
||||
2. 為了在檔案中找到一個特定的鍵,你不再需要儲存記憶體中所有鍵的索引。以[圖3-5](../img/fig3-5.png)為例:假設你正在記憶體中尋找鍵 `handiwork`,但是你不知道段檔案中該關鍵字的確切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由於排序特性,你知道 `handiwork` 必須出現在這兩者之間。這意味著你可以跳到 `handbag` 的偏移位置並從那裡掃描,直到你找到 `handiwork`(或沒找到,如果該檔案中沒有該鍵)。
|
||||
2. 為了在檔案中找到一個特定的鍵,你不再需要在記憶體中儲存所有鍵的索引。以[圖3-5](../img/fig3-5.png)為例:假設你正在記憶體中尋找鍵 `handiwork`,但是你不知道段檔案中該關鍵字的確切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由於排序特性,你知道 `handiwork` 必須出現在這兩者之間。這意味著你可以跳到 `handbag` 的偏移位置並從那裡掃描,直到你找到 `handiwork`(或沒找到,如果該檔案中沒有該鍵)。
|
||||
|
||||
![](../img/fig3-5.png)
|
||||
|
||||
**圖3-5 具有記憶體索引的SSTable**
|
||||
|
||||
你仍然需要一個記憶體中索引來告訴你一些鍵的偏移量,但它可能很稀疏:每幾千位元組的段檔案就有一個鍵就足夠了,因為幾千位元組可以很快被掃描[^i]。
|
||||
你仍然需要一個記憶體中的索引來告訴你一些鍵的偏移量,但它可以是稀疏的:每幾千位元組的段檔案有一個鍵就足夠了,因為幾千位元組可以很快地被掃描完[^i]。
|
||||
|
||||
[^i]: 如果所有的鍵與值都是定長的,你可以使用段檔案上的二分查詢並完全避免使用記憶體索引。然而實踐中的鍵和值通常都是變長的,因此如果沒有索引,就很難知道記錄的分界點(前一條記錄結束以及後一條記錄開始的地方)。
|
||||
|
||||
3. 由於讀取請求無論如何都需要掃描所請求範圍內的多個鍵值對,因此可以將這些記錄分組到塊中,並在將其寫入磁碟之前對其進行壓縮(如[圖3-5](../img/fig3-5.png)中的陰影區域所示) 。稀疏記憶體中索引的每個條目都指向壓縮塊的開始處。除了節省磁碟空間之外,壓縮還可以減少IO頻寬的使用。
|
||||
3. 由於讀取請求無論如何都需要掃描所請求範圍內的多個鍵值對,因此可以將這些記錄分組為塊(block),並在將其寫入磁碟之前對其進行壓縮(如[圖3-5](../img/fig3-5.png)中的陰影區域所示)[^譯註i] 。稀疏記憶體索引中的每個條目都指向壓縮塊的開始處。除了節省磁碟空間之外,壓縮還可以減少對I/O頻寬的使用。
|
||||
|
||||
|
||||
[^i]: 如果所有的鍵與值都是定長的,你可以使用段檔案上的二分查詢並完全避免使用記憶體索引。然而實踐中鍵值通常都是變長的,因此如果沒有索引,就很難知道記錄的分界點(前一條記錄結束,後一條記錄開始的地方)
|
||||
[^譯註i]: 這裡的壓縮是compression,不是前文的compaction,請注意區分。
|
||||
|
||||
#### 構建和維護SSTables
|
||||
|
||||
到目前為止,但是如何讓你的資料首先被按鍵排序呢?我們的傳入寫入可以以任何順序發生。
|
||||
到目前為止還不錯,但是如何讓你的資料能夠預先排好序呢?畢竟我們接收到的寫入請求可能以任何順序發生。
|
||||
|
||||
在磁碟上維護有序結構是可能的(請參閱“[B樹](#B樹)”),但在記憶體儲存則要容易得多。有許多可以使用的眾所周知的樹形資料結構,例如紅黑樹或AVL樹【2】。使用這些資料結構,你可以按任何順序插入鍵,並按排序順序讀取它們。
|
||||
雖然在磁碟上維護有序結構也是可能的(請參閱“[B樹](#B樹)”),但在記憶體儲存則要容易得多。有許多可以使用的眾所周知的樹形資料結構,例如紅黑樹或AVL樹【2】。使用這些資料結構,你可以按任何順序插入鍵,並按排序順序讀取它們。
|
||||
|
||||
現在我們可以使我們的儲存引擎工作如下:
|
||||
現在我們可以讓我們的儲存引擎以如下方式工作:
|
||||
|
||||
* 寫入時,將其新增到記憶體中的平衡樹資料結構(例如,紅黑樹)。這個記憶體樹有時被稱為**記憶體表(memtable)**。
|
||||
* 當**記憶體表**大於某個閾值(通常為幾兆位元組)時,將其作為SSTable檔案寫入磁碟。這可以高效地完成,因為樹已經維護了按鍵排序的鍵值對。新的SSTable檔案成為資料庫的最新部分。當SSTable被寫入磁碟時,寫入可以繼續到一個新的記憶體表例項。
|
||||
* 為了提供讀取請求,首先嚐試在記憶體表中找到關鍵字,然後在最近的磁碟段中,然後在下一個較舊的段中找到該關鍵字。
|
||||
* 有時會在後臺執行合併和壓縮過程以組合段檔案並丟棄覆蓋或刪除的值。
|
||||
* 有新寫入時,將其新增到記憶體中的平衡樹資料結構(例如紅黑樹)。這個記憶體樹有時被稱為**記憶體表(memtable)**。
|
||||
* 當**記憶體表**大於某個閾值(通常為幾兆位元組)時,將其作為SSTable檔案寫入磁碟。這可以高效地完成,因為樹已經維護了按鍵排序的鍵值對。新的SSTable檔案將成為資料庫中最新的段。當該SSTable被寫入磁碟時,新的寫入可以在一個新的記憶體表例項上繼續進行。
|
||||
* 收到讀取請求時,首先嚐試在記憶體表中找到對應的鍵,如果沒有就在最近的磁碟段中尋找,如果還沒有就在下一個較舊的段中繼續尋找,以此類推。
|
||||
* 時不時地,在後臺執行一個合併和壓縮過程,以合併段檔案並將已覆蓋或已刪除的值丟棄掉。
|
||||
|
||||
這個方案效果很好。它只會遇到一個問題:如果資料庫崩潰,則最近的寫入(在記憶體表中,但尚未寫入磁碟)將丟失。為了避免這個問題,我們可以在磁碟上儲存一個單獨的日誌,每個寫入都會立即被附加到磁碟上,就像在前一節中一樣。該日誌不是按排序順序,但這並不重要,因為它的唯一目的是在崩潰後恢復記憶體表。每當記憶體表寫出到SSTable時,相應的日誌都可以被丟棄。
|
||||
這個方案效果很好。它只會遇到一個問題:如果資料庫崩潰,則最近的寫入(在記憶體表中,但尚未寫入磁碟)將丟失。為了避免這個問題,我們可以在磁碟上儲存一個單獨的日誌,每個寫入都會立即被追加到這個日誌上,就像在前面的章節中所描述的那樣。這個日誌沒有按排序順序,但這並不重要,因為它的唯一目的是在崩潰後恢復記憶體表。每當記憶體表寫出到SSTable時,相應的日誌都可以被丟棄。
|
||||
|
||||
#### 用SSTables製作LSM樹
|
||||
|
||||
這裡描述的演算法本質上是LevelDB 【6】和RocksDB 【7】中使用的鍵值儲存引擎庫,被設計嵌入到其他應用程式中。除此之外,LevelDB可以在Riak中用作Bitcask的替代品。在Cassandra和HBase中使用了類似的儲存引擎【8】,這兩種引擎都受到了Google的Bigtable文件【9】(引入了術語 SSTable 和 memtable )的啟發。
|
||||
這裡描述的演算法本質上是LevelDB【6】和RocksDB【7】這些鍵值儲存引擎庫所使用的技術,這些儲存引擎被設計嵌入到其他應用程式中。除此之外,LevelDB可以在Riak中用作Bitcask的替代品。在Cassandra和HBase中也使用了類似的儲存引擎【8】,而且他們都受到了Google的Bigtable論文【9】(引入了術語 SSTable 和 memtable )的啟發。
|
||||
|
||||
最初這種索引結構是由Patrick O'Neil等人描述的,且被命名為日誌結構合併樹(或LSM樹)【10】,它是基於更早之前的日誌結構檔案系統【11】來構建的。基於這種合併和壓縮排序檔案原理的儲存引擎通常被稱為LSM儲存引擎。
|
||||
|
||||
Lucene是Elasticsearch和Solr使用的一種全文搜尋的索引引擎,它使用類似的方法來儲存它的詞典【12,13】。全文索引比鍵值索引複雜得多,但是基於類似的想法:在搜尋查詢中給出一個單詞,找到提及單詞的所有文件(網頁,產品描述等)。這是透過鍵值結構實現的,其中鍵是單詞(**關鍵詞(term)**),值是包含單詞(文章列表)的所有文件的ID的列表。在Lucene中,從術語到釋出列表的這種對映儲存在SSTable類的有序檔案中,根據需要在後臺合併【14】。
|
||||
Lucene是Elasticsearch和Solr使用的一種全文搜尋的索引引擎,它使用類似的方法來儲存它的關鍵詞詞典【12,13】。全文索引比鍵值索引複雜得多,但是基於類似的想法:在搜尋查詢中給出一個單詞,找到提及單詞的所有文件(網頁,產品描述等)。這是透過鍵值結構實現的,其中鍵是單詞(**關鍵詞(term)**),值是所有包含該單詞的文件的ID列表(記錄列表)。在Lucene中,從術語到記錄列表的這種對映儲存在類似於SSTable的有序檔案中,並根據需要在後臺合併【14】。
|
||||
|
||||
#### 效能最佳化
|
||||
|
||||
與往常一樣,大量的細節使得儲存引擎在實踐中表現良好。例如,當查詢資料庫中不存在的鍵時,LSM樹演算法可能會很慢:你必須檢查記憶體表,然後將這些段一直回到最老的(可能必須從磁碟讀取每一個),然後才能確定鍵不存在。為了最佳化這種訪問,儲存引擎通常使用額外的Bloom過濾器【15】。 (布隆過濾器是用於近似集合內容的記憶體高效資料結構,它可以告訴你資料庫中是否出現鍵,從而為不存在的鍵節省許多不必要的磁碟讀取操作。)
|
||||
與往常一樣,要讓儲存引擎在實踐中表現良好涉及到大量設計細節。例如,當查詢資料庫中不存在的鍵時,LSM樹演算法可能會很慢:你必須先檢查記憶體表,然後檢視從最近的到最舊的所有的段(可能還必須從磁碟讀取每一個段檔案),然後才能確定這個鍵不存在。為了最佳化這種訪問,儲存引擎通常使用額外的布隆過濾器(Bloom filters)【15】。 (布隆過濾器是用於近似集合內容的高效記憶體資料結構,它可以告訴你資料庫中是不是不存在某個鍵,從而為不存在的鍵節省掉許多不必要的磁碟讀取操作。)
|
||||
|
||||
還有不同的策略來確定SSTables如何被壓縮和合並的順序和時間。最常見的選擇是size-tiered和leveled compaction。LevelDB和RocksDB使用leveled compaction(LevelDB因此得名),HBase使用size-tiered,Cassandra同時支援【16】。對於sized-tiered,較新和較小的SSTables相繼被合併到較舊的和較大的SSTable中。對於leveled compaction,key範圍被拆分到較小的SSTables,而較舊的資料被移動到單獨的層級(level),這使得壓縮(compaction)能夠更加增量地進行,並且使用較少的磁碟空間。
|
||||
|
||||
即使有許多微妙的東西,LSM樹的基本思想 —— 儲存一系列在後臺合併的SSTables —— 簡單而有效。即使資料集比可用記憶體大得多,它仍能繼續正常工作。由於資料按排序順序儲存,因此可以高效地執行範圍查詢(掃描所有高於某些最小值和最高值的所有鍵),並且因為磁碟寫入是連續的,所以LSM樹可以支援非常高的寫入吞吐量。
|
||||
還有一些不同的策略來確定SSTables被壓縮和合並的順序和時間。最常見的選擇是size-tiered和leveled compaction。LevelDB和RocksDB使用leveled compaction(LevelDB因此得名),HBase使用size-tiered,Cassandra同時支援這兩種【16】。對於sized-tiered,較新和較小的SSTables相繼被合併到較舊的和較大的SSTable中。對於leveled compaction,key範圍被拆分到較小的SSTables,而較舊的資料被移動到單獨的層級(level),這使得壓縮(compaction)能夠更加增量地進行,並且使用較少的磁碟空間。
|
||||
|
||||
即使有許多微妙的東西,LSM樹的基本思想 —— 儲存一系列在後臺合併的SSTables —— 簡單而有效。即使資料集比可用記憶體大得多,它仍能繼續正常工作。由於資料按排序順序儲存,你可以高效地執行範圍查詢(掃描所有從某個最小值到某個最大值之間的所有鍵),並且因為磁碟寫入是連續的,所以LSM樹可以支援非常高的寫入吞吐量。
|
||||
|
||||
|
||||
### B樹
|
||||
|
||||
剛才討論的日誌結構索引正處在逐漸被接受的階段,但它們並不是最常見的索引型別。使用最廣泛的索引結構在1970年被引入【17】,不到10年後變得“無處不在”【18】,B樹經受了時間的考驗。在幾乎所有的關係資料庫中,它們仍然是標準的索引實現,許多非關係資料庫也使用它們。
|
||||
前面討論的日誌結構索引正處在逐漸被接受的階段,但它們並不是最常見的索引型別。使用最廣泛的索引結構和日誌結構索引相當不同,它就是我們接下來要討論的B樹。
|
||||
|
||||
像SSTables一樣,B樹保持按鍵排序的鍵值對,這允許高效的鍵值查詢和範圍查詢。但這就是相似之處的結尾:B樹有著非常不同的設計理念。
|
||||
從1970年被引入【17】,僅不到10年後就變得“無處不在”【18】,B樹很好地經受了時間的考驗。在幾乎所有的關係資料庫中,它們仍然是標準的索引實現,許多非關係資料庫也會使用到B樹。
|
||||
|
||||
我們前面看到的日誌結構索引將資料庫分解為可變大小的段,通常是幾兆位元組或更大的大小,並且總是按順序編寫段。相比之下,B樹將資料庫分解成固定大小的塊或頁面,傳統上大小為4KB(有時會更大),並且一次只能讀取或寫入一個頁面。這種設計更接近於底層硬體,因為磁碟也被安排在固定大小的塊中。
|
||||
像SSTables一樣,B樹保持按鍵排序的鍵值對,這允許高效的鍵值查詢和範圍查詢。但這也就是所有的相似之處了:B樹有著非常不同的設計理念。
|
||||
|
||||
我們前面看到的日誌結構索引將資料庫分解為可變大小的段,通常是幾兆位元組或更大的大小,並且總是按順序寫入段。相比之下,B樹將資料庫分解成固定大小的塊(block)或頁面(page),傳統上大小為4KB(有時會更大),並且一次只能讀取或寫入一個頁面。這種設計更接近於底層硬體,因為磁碟空間也是按固定大小的塊來組織的。
|
||||
|
||||
每個頁面都可以使用地址或位置來標識,這允許一個頁面引用另一個頁面 —— 類似於指標,但在磁碟而不是在記憶體中。我們可以使用這些頁面引用來構建一個頁面樹,如[圖3-6](../img/fig3-6.png)所示。
|
||||
|
||||
@ -221,37 +222,37 @@ Lucene是Elasticsearch和Solr使用的一種全文搜尋的索引引擎,它使
|
||||
|
||||
一個頁面會被指定為B樹的根;在索引中查詢一個鍵時,就從這裡開始。該頁面包含幾個鍵和對子頁面的引用。每個子頁面負責一段連續範圍的鍵,引用之間的鍵,指明瞭引用子頁面的鍵範圍。
|
||||
|
||||
在[圖3-6](../img/fig3-6.png)的例子中,我們正在尋找關鍵字 251 ,所以我們知道我們需要遵循邊界 200 和 300 之間的頁面引用。這將我們帶到一個類似的頁面,進一步打破了200 - 300到子範圍。
|
||||
在[圖3-6](../img/fig3-6.png)的例子中,我們正在尋找關鍵字 251 ,所以我們知道我們需要跟蹤邊界 200 和 300 之間的頁面引用。這將我們帶到一個類似的頁面,進一步將200到300的範圍拆分到子範圍。
|
||||
|
||||
最後,我們可以看到包含單個鍵(葉頁)的頁面,該頁面包含每個鍵的內聯值,或者包含對可以找到值的頁面的引用。
|
||||
最終,我們將到達某個包含單個鍵的頁面(葉子頁面,leaf page),該頁面或者直接包含每個鍵的值,或者包含了對可以找到值的頁面的引用。
|
||||
|
||||
在B樹的一個頁面中對子頁面的引用的數量稱為分支因子。例如,在[圖3-6](../img/fig3-6.png)中,分支因子是 6 。在實踐中,分支因子取決於儲存頁面參考和範圍邊界所需的空間量,但通常是幾百個。
|
||||
在B樹的一個頁面中對子頁面的引用的數量稱為分支因子。例如,在[圖3-6](../img/fig3-6.png)中,分支因子是 6 。在實踐中,分支因子取決於儲存頁面引用和範圍邊界所需的空間量,但通常是幾百個。
|
||||
|
||||
如果要更新B樹中現有鍵的值,則搜尋包含該鍵的葉頁,更改該頁中的值,並將該頁寫回到磁碟(對該頁的任何引用保持有效) 。如果你想新增一個新的鍵,你需要找到其範圍包含新鍵的頁面,並將其新增到該頁面。如果頁面中沒有足夠的可用空間容納新鍵,則將其分成兩個半滿頁面,並更新父頁面以解釋鍵範圍的新分割槽,如[圖3-7](../img/fig3-7.png)所示[^ii]。
|
||||
|
||||
[^ii]: 向B樹中插入一個新的鍵是相當符合直覺的,但刪除一個鍵(同時保持樹平衡)就會牽扯很多其他東西了。
|
||||
如果要更新B樹中現有鍵的值,需要搜尋包含該鍵的葉子頁面,更改該頁面中的值,並將該頁面寫回到磁碟(對該頁面的任何引用都將保持有效)。如果你想新增一個新的鍵,你需要找到其範圍能包含新鍵的頁面,並將其新增到該頁面。如果頁面中沒有足夠的可用空間容納新鍵,則將其分成兩個半滿頁面,並更新父頁面以反映新的鍵範圍分割槽,如[圖3-7](../img/fig3-7.png)所示[^ii]。
|
||||
|
||||
![](../img/fig3-7.png)
|
||||
|
||||
**圖3-7 透過分割頁面來生長B樹**
|
||||
|
||||
該演算法確保樹保持平衡:具有 n 個鍵的B樹總是具有 $O(log n)$ 的深度。大多數資料庫可以放入一個三到四層的B樹,所以你不需要追蹤多個頁面引用來找到你正在查詢的頁面。 (分支因子為 500 的 4KB 頁面的四級樹可以儲存多達 256TB 。)
|
||||
[^ii]: 向B樹中插入一個新的鍵是相當符合直覺的,但刪除一個鍵(同時保持樹平衡)就會牽扯很多其他東西了【2】。
|
||||
|
||||
這個演算法可以確保樹保持平衡:具有n個鍵的B樹總是具有 $O(log n)$ 的深度。大多數資料庫可以放入一個三到四層的B樹,所以你不需要追蹤多個頁面引用來找到你正在查詢的頁面。(分支因子為500的4KB頁面的四級樹可以儲存多達256TB的資料。)
|
||||
|
||||
#### 讓B樹更可靠
|
||||
|
||||
B樹的基本底層寫操作是用新資料覆蓋磁碟上的頁面。假定覆蓋不改變頁面的位置:即,當頁面被覆蓋時,對該頁面的所有引用保持完整。這與日誌結構索引(如LSM樹)形成鮮明對比,後者只附加到檔案(並最終刪除過時的檔案),但從不修改檔案。
|
||||
B樹的基本底層寫操作是用新資料覆寫硬碟上的頁面,並假定覆寫不改變頁面的位置:即,當頁面被覆寫時,對該頁面的所有引用保持完整。這與日誌結構索引(如LSM樹)形成鮮明對比,後者只追加到檔案(並最終刪除過時的檔案),但從不修改檔案中已有的內容。
|
||||
|
||||
你可以考慮將硬碟上的頁面覆蓋為實際的硬體操作。在磁性硬碟驅動器上,這意味著將磁頭移動到正確的位置,等待旋轉盤上的正確位置出現,然後用新的資料覆蓋適當的扇區。在固態硬碟上,由於SSD必須一次擦除和重寫相當大的儲存晶片塊,所以會發生更復雜的事情【19】。
|
||||
你可以把覆寫硬碟上的頁面對應為實際的硬體操作。在磁性硬碟驅動器上,這意味著將磁頭移動到正確的位置,等待旋轉盤上的正確位置出現,然後用新的資料覆寫適當的扇區。在固態硬碟上,由於SSD必須一次擦除和重寫相當大的儲存晶片塊,所以會發生更復雜的事情【19】。
|
||||
|
||||
而且,一些操作需要覆蓋幾個不同的頁面。例如,如果因為插入導致頁面過滿而拆分頁面,則需要編寫已拆分的兩個頁面,並覆蓋其父頁面以更新對兩個子頁面的引用。這是一個危險的操作,因為如果資料庫在僅有一些頁面被寫入後崩潰,那麼最終將導致一個損壞的索引(例如,可能有一個孤兒頁面不是任何父項的子項) 。
|
||||
而且,一些操作需要覆寫幾個不同的頁面。例如,如果因為插入導致頁面過滿而拆分頁面,則需要寫入新拆分的兩個頁面,並覆寫其父頁面以更新對兩個子頁面的引用。這是一個危險的操作,因為如果資料庫在僅有部分頁面被寫入時崩潰,那麼最終將導致一個損壞的索引(例如,可能有一個孤兒頁面不是任何父項的子項) 。
|
||||
|
||||
為了使資料庫對崩潰具有韌性,B樹實現通常會帶有一個額外的磁碟資料結構:**預寫式日誌(WAL, write-ahead-log)**(也稱為**重做日誌(redo log)**)。這是一個僅追加的檔案,每個B樹修改都可以應用到樹本身的頁面上。當資料庫在崩潰後恢復時,這個日誌被用來使B樹恢復到一致的狀態【5,20】。
|
||||
為了使資料庫能處理異常崩潰的場景,B樹實現通常會帶有一個額外的磁碟資料結構:**預寫式日誌(WAL, write-ahead log)**(也稱為**重做日誌(redo log)**)。這是一個僅追加的檔案,每個B樹的修改在其能被應用到樹本身的頁面之前都必須先寫入到該檔案。當資料庫在崩潰後恢復時,這個日誌將被用來使B樹恢復到一致的狀態【5,20】。
|
||||
|
||||
更新頁面的一個額外的複雜情況是,如果多個執行緒要同時訪問B樹,則需要仔細的併發控制 —— 否則執行緒可能會看到樹處於不一致的狀態。這通常透過使用**鎖存器(latches)**(輕量級鎖)保護樹的資料結構來完成。日誌結構化的方法在這方面更簡單,因為它們在後臺進行所有的合併,而不會干擾傳入的查詢,並且不時地將舊的分段原子交換為新的分段。
|
||||
另外還有一個更新頁面的複雜情況是,如果多個執行緒要同時訪問B樹,則需要仔細的併發控制 —— 否則執行緒可能會看到樹處於不一致的狀態。這通常是透過使用**鎖存器(latches)**(輕量級鎖)保護樹的資料結構來完成。日誌結構化的方法在這方面更簡單,因為它們在後臺進行所有的合併,而不會干擾新接收到的查詢,並且能夠時不時地將舊的段原子交換為新的段。
|
||||
|
||||
#### B樹最佳化
|
||||
|
||||
由於B樹已經存在了這麼久,許多最佳化已經發展了多年,這並不奇怪。僅舉幾例:
|
||||
由於B樹已經存在了很久,所以並不奇怪這麼多年下來有很多最佳化的設計被開發出來,僅舉幾例:
|
||||
|
||||
* 一些資料庫(如LMDB)使用寫時複製方案【21】,而不是覆蓋頁面並維護WAL進行崩潰恢復。修改的頁面被寫入到不同的位置,並且樹中的父頁面的新版本被建立,指向新的位置。這種方法對於併發控制也很有用,我們將在“[快照隔離和可重複讀](ch7.md#快照隔離和可重複讀)”中看到。
|
||||
* 我們可以透過不儲存整個鍵,而是縮短其大小,來節省頁面空間。特別是在樹內部的頁面上,鍵只需要提供足夠的資訊來充當鍵範圍之間的邊界。在頁面中包含更多的鍵允許樹具有更高的分支因子,因此更少的層次。
|
||||
@ -361,7 +362,6 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
|
||||
如果 **非易失性儲存器(NVM)** 技術得到更廣泛的應用,可能還需要進一步改變儲存引擎設計【46】。目前這是一個新的研究領域,值得關注。
|
||||
|
||||
|
||||
|
||||
## 事務處理還是分析?
|
||||
|
||||
在早期業務資料處理過程中,一次典型的資料庫寫入通常與一筆 *商業交易(commercial transaction)* 相對應:賣個貨,向供應商下訂單,支付員工工資等等。但隨著資料庫應用至那些不涉及到錢的領域,術語 **交易/事務(transaction)** 仍留了下來,用於指代一組讀寫操作構成的邏輯單元。
|
||||
@ -441,7 +441,6 @@ Teradata,Vertica,SAP HANA和ParAccel等資料倉庫供應商通常使用昂
|
||||
在典型的資料倉庫中,表格通常非常寬:事實表格通常有100列以上,有時甚至有數百列【51】。維度表也可以是非常寬的,因為它們包括可能與分析相關的所有元資料——例如,`dim_store` 表可以包括在每個商店提供哪些服務的細節,它是否具有店內麵包房,店面面積,商店第一次開張的日期,最後一次改造的時間,離最近的高速公路的距離等等。
|
||||
|
||||
|
||||
|
||||
## 列儲存
|
||||
|
||||
如果事實表中有萬億行和數PB的資料,那麼高效地儲存和查詢它們就成為一個具有挑戰性的問題。維度表通常要小得多(數百萬行),所以在本節中我們將主要關注事實表的儲存。
|
||||
@ -482,7 +481,6 @@ GROUP BY
|
||||
面向列的儲存佈局依賴於每個列檔案包含相同順序的行。 因此,如果你需要重新組裝整行,你可以從每個單獨的列檔案中獲取第23項,並將它們放在一起形成表的第23行。
|
||||
|
||||
|
||||
|
||||
### 列壓縮
|
||||
|
||||
除了僅從磁碟載入查詢所需的列以外,我們還可以透過壓縮資料來進一步降低對磁碟吞吐量的需求。幸運的是,面向列的儲存通常很適合壓縮。
|
||||
@ -525,7 +523,6 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
除了減少需要從磁碟載入的資料量以外,面向列的儲存佈局也可以有效利用CPU週期。例如,查詢引擎可以將大量壓縮的列資料放在CPU的L1快取中,然後在緊密的迴圈中迴圈(即沒有函式呼叫)。相比較每個記錄的處理都需要大量函式呼叫和條件判斷的程式碼,CPU執行這樣一個迴圈要快得多。列壓縮允許列中的更多行適合相同數量的L1快取。前面描述的按位“與”和“或”運算子可以被設計為直接在這樣的壓縮列資料塊上操作。這種技術被稱為向量化處理【58,49】。
|
||||
|
||||
|
||||
|
||||
### 列儲存中的排序順序
|
||||
|
||||
在列儲存中,儲存行的順序並不一定很重要。按插入順序儲存它們是最簡單的,因為插入一個新行只需要追加到每個列檔案。但是,我們可以選擇增加一個特定的順序,就像我們之前對SSTables所做的那樣,並將其用作索引機制。
|
||||
@ -581,7 +578,6 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
缺點是資料立方體不具有查詢原始資料的靈活性。例如,沒有辦法計算哪個銷售比例來自成本超過100美元的專案,因為價格不是其中的一個維度。因此,大多數資料倉庫試圖保留儘可能多的原始資料,並將聚合資料(如資料立方體)僅用作某些查詢的效能提升。
|
||||
|
||||
|
||||
|
||||
## 本章小結
|
||||
|
||||
在本章中,我們試圖深入瞭解資料庫如何處理儲存和檢索。將資料儲存在資料庫中會發生什麼,以及稍後再次查詢資料時資料庫會做什麼?
|
||||
@ -610,9 +606,6 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
儘管本章不能讓你成為一個特定儲存引擎的調參專家,但它至少有大概率使你有了足夠的概念與詞彙儲備去讀懂資料庫的文件,從而選擇合適的資料庫。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## 參考文獻
|
||||
|
||||
1. Alfred V. Aho, John E. Hopcroft, and Jeffrey D. Ullman: *Data Structures and Algorithms*. Addison-Wesley, 1983. ISBN: 978-0-201-00023-8
|
||||
@ -681,7 +674,6 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
1. Jim Gray, Surajit Chaudhuri, Adam Bosworth, et al.: “[Data Cube: A Relational Aggregation Operator Generalizing Group-By, Cross-Tab, and Sub-Totals](http://arxiv.org/pdf/cs/0701155.pdf),” *Data Mining and Knowledge Discovery*, volume 1, number 1, pages 29–53, March 2007. [doi:10.1023/A:1009726021843](http://dx.doi.org/10.1023/A:1009726021843)
|
||||
|
||||
|
||||
|
||||
------
|
||||
|
||||
| 上一章 | 目錄 | 下一章 |
|
||||
|
Loading…
Reference in New Issue
Block a user