mirror of
https://github.com/Vonng/ddia.git
synced 2024-12-06 15:20:12 +08:00
commit
406b558a0d
100
ch3.md
100
ch3.md
@ -78,7 +78,7 @@ $ cat database
|
|||||||
|
|
||||||
### 散列索引
|
### 散列索引
|
||||||
|
|
||||||
让我们从 **键值数据(key-value Data)** 的索引开始。这不是你可以索引的唯一数据类型,但键值数据是很常见的。对于更复杂的索引来说,这也是一个有用的构建模块。
|
让我们从 **键值数据(key-value Data)** 的索引开始。这不是你可以索引的唯一数据类型,但键值数据是很常见的。在引入更复杂的索引之前,它是重要的第一步。
|
||||||
|
|
||||||
键值存储与在大多数编程语言中可以找到的 **字典(dictionary)** 类型非常相似,通常字典都是用 **散列映射(hash map)** 或 **散列表(hash table)** 实现的。散列映射在许多算法教科书中都有描述【1,2】,所以这里我们不会讨论它的工作细节。既然我们已经可以用散列映射来表示 **内存中** 的数据结构,为什么不使用它来索引 **硬盘上** 的数据呢?
|
键值存储与在大多数编程语言中可以找到的 **字典(dictionary)** 类型非常相似,通常字典都是用 **散列映射(hash map)** 或 **散列表(hash table)** 实现的。散列映射在许多算法教科书中都有描述【1,2】,所以这里我们不会讨论它的工作细节。既然我们已经可以用散列映射来表示 **内存中** 的数据结构,为什么不使用它来索引 **硬盘上** 的数据呢?
|
||||||
|
|
||||||
@ -92,7 +92,7 @@ $ cat database
|
|||||||
|
|
||||||
像 Bitcask 这样的存储引擎非常适合每个键的值经常更新的情况。例如,键可能是某个猫咪视频的网址(URL),而值可能是该视频被播放的次数(每次有人点击播放按钮时递增)。在这种类型的工作负载中,有很多写操作,但是没有太多不同的键 —— 每个键有很多的写操作,但是将所有键保存在内存中是可行的。
|
像 Bitcask 这样的存储引擎非常适合每个键的值经常更新的情况。例如,键可能是某个猫咪视频的网址(URL),而值可能是该视频被播放的次数(每次有人点击播放按钮时递增)。在这种类型的工作负载中,有很多写操作,但是没有太多不同的键 —— 每个键有很多的写操作,但是将所有键保存在内存中是可行的。
|
||||||
|
|
||||||
直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完硬盘空间?一种好的解决方案是,将日志分为特定大小的段(segment),当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行 **压缩(compaction)**,如 [图 3-2](img/fig3-2.png) 所示。这里的压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。
|
到目前为止,我们只是在追加写入一个文件 —— 所以如何避免最终用完硬盘空间?一种好的解决方案是,将日志分为特定大小的**段**(**segment**),当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行 **压缩(compaction)**,如 [图 3-2](img/fig3-2.png) 所示。这里的压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。
|
||||||
|
|
||||||
![](img/fig3-2.png)
|
![](img/fig3-2.png)
|
||||||
|
|
||||||
@ -136,7 +136,7 @@ $ cat database
|
|||||||
|
|
||||||
但是,散列表索引也有其局限性:
|
但是,散列表索引也有其局限性:
|
||||||
|
|
||||||
* 散列表必须能放进内存。如果你有非常多的键,那真是倒霉。原则上可以在硬盘上维护一个散列映射,不幸的是硬盘散列映射很难表现优秀。它需要大量的随机访问 I/O,当它用满时想要再增长是很昂贵的,并且散列冲突的处理也需要很烦琐的逻辑【5】。
|
* 散列表必须能放进内存。如果你有非常多的键,那真是倒霉。原则上可以在硬盘上维护一个散列映射,不幸的是硬盘散列映射很难表现优秀。它需要大量的随机访问 I/O,而后者耗尽时想要再扩充是很昂贵的,并且需要很烦琐的逻辑去解决散列冲突【5】。
|
||||||
* 范围查询效率不高。例如,你无法轻松扫描 kitty00000 和 kitty99999 之间的所有键 —— 你必须在散列映射中单独查找每个键。
|
* 范围查询效率不高。例如,你无法轻松扫描 kitty00000 和 kitty99999 之间的所有键 —— 你必须在散列映射中单独查找每个键。
|
||||||
|
|
||||||
在下一节中,我们将看到一个没有这些限制的索引结构。
|
在下一节中,我们将看到一个没有这些限制的索引结构。
|
||||||
@ -191,28 +191,28 @@ $ cat database
|
|||||||
|
|
||||||
这里描述的算法本质上是 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 存储引擎。
|
这种索引结构最早由 Patrick O'Neil 等人发明,且被命名为日志结构合并树(或 LSM 树)【10】,它是基于更早之前的日志结构文件系统【11】来构建的。基于这种合并和压缩排序文件原理的存储引擎通常被称为 LSM 存储引擎。
|
||||||
|
|
||||||
Lucene 是 Elasticsearch 和 Solr 使用的一种全文搜索的索引引擎,它使用类似的方法来存储它的关键词词典【12,13】。全文索引比键值索引复杂得多,但是基于类似的想法:在搜索查询中给出一个单词,找到提及单词的所有文档(网页,产品描述等)。这是通过键值结构实现的,其中键是单词(或 **词语**,即 term),值是所有包含该单词的文档的 ID 列表(记录列表)。在 Lucene 中,从词语到记录列表的这种映射保存在类似于 SSTable 的有序文件中,并根据需要在后台合并【14】。
|
Lucene,是一种全文搜索的索引引擎,在 Elasticsearch 和 Solr 被使用,它使用类似的方法来存储它的关键词词典【12,13】。全文索引比键值索引复杂得多,但是基于类似的想法:在搜索查询中,由一个给定的单词,找到提及单词的所有文档(网页,产品描述等)。这也是通过键值结构实现的:其中键是单词(**term**),值是所有包含该单词的文档的 ID 列表(**postings list**)。在 Lucene 中,从词语到记录列表的这种映射保存在类似于 SSTable 的有序文件中,并根据需要在后台执行合并【14】。
|
||||||
|
|
||||||
#### 性能优化
|
#### 性能优化
|
||||||
|
|
||||||
与往常一样,要让存储引擎在实践中表现良好涉及到大量设计细节。例如,当查找数据库中不存在的键时,LSM 树算法可能会很慢:你必须先检查内存表,然后查看从最近的到最旧的所有的段(可能还必须从硬盘读取每一个段文件),然后才能确定这个键不存在。为了优化这种访问,存储引擎通常使用额外的布隆过滤器(Bloom filters)【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)能够更加增量地进行,并且使用较少的硬盘空间。
|
还有一些不同的策略来确定 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 树可以支持非常高的写入吞吐量。
|
即使有许多微妙的东西,LSM 树的基本思想 —— 保存一系列在后台合并的 SSTables —— 简单而有效。即使数据集比可用内存大得多,它仍能继续正常工作。由于数据按排序顺序存储,你可以高效地执行范围查询(扫描所有从某个最小值到某个最大值之间的所有键),并且因为硬盘写入是连续的,所以 LSM 树可以支持非常高的写入吞吐量。
|
||||||
|
|
||||||
|
|
||||||
### B树
|
### B树
|
||||||
|
|
||||||
前面讨论的日志结构索引正处在逐渐被接受的阶段,但它们并不是最常见的索引类型。使用最广泛的索引结构和日志结构索引相当不同,它就是我们接下来要讨论的 B 树。
|
前面讨论的日志结构索引看起来已经相当可用了,但它们却不是最常见的索引类型。使用最广泛的索引结构和日志结构索引相当不同,它就是我们接下来要讨论的 B 树。
|
||||||
|
|
||||||
从 1970 年被引入【17】,仅不到 10 年后就变得 “无处不在”【18】,B 树很好地经受了时间的考验。在几乎所有的关系数据库中,它们仍然是标准的索引实现,许多非关系数据库也会使用到 B 树。
|
从 1970 年被引入【17】,仅不到 10 年后就变得 “无处不在”【18】,B 树很好地经受了时间的考验。在几乎所有的关系数据库中,它们仍然是标准的索引实现,许多非关系数据库也会使用到 B 树。
|
||||||
|
|
||||||
像 SSTables 一样,B 树保持按键排序的键值对,这允许高效的键值查找和范围查询。但这也就是所有的相似之处了:B 树有着非常不同的设计理念。
|
像 SSTables 一样,B 树保持按键排序的键值对,这允许高效的键值查找和范围查询。但这也就是仅有的相似之处了:B 树有着非常不同的设计理念。
|
||||||
|
|
||||||
我们前面看到的日志结构索引将数据库分解为可变大小的段,通常是几兆字节或更大的大小,并且总是按顺序写入段。相比之下,B 树将数据库分解成固定大小的块(block)或页面(page),传统上大小为 4KB(有时会更大),并且一次只能读取或写入一个页面。这种设计更接近于底层硬件,因为硬盘空间也是按固定大小的块来组织的。
|
我们前面看到的日志结构索引将数据库分解为可变大小的段,通常是几兆字节或更大的大小,并且总是按顺序写入段。相比之下,B 树将数据库分解成固定大小的**块**(**block**)或**分页**(**page**),传统上大小为 4KB(有时会更大),并且一次只能读取或写入一个页面。这种设计更接近于底层硬件,因为硬盘空间也是按固定大小的块来组织的。
|
||||||
|
|
||||||
每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在硬盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如 [图 3-6](img/fig3-6.png) 所示。
|
每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在硬盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如 [图 3-6](img/fig3-6.png) 所示。
|
||||||
|
|
||||||
@ -220,13 +220,13 @@ Lucene 是 Elasticsearch 和 Solr 使用的一种全文搜索的索引引擎,
|
|||||||
|
|
||||||
**图 3-6 使用 B 树索引查找一个键**
|
**图 3-6 使用 B 树索引查找一个键**
|
||||||
|
|
||||||
一个页面会被指定为 B 树的根;在索引中查找一个键时,就从这里开始。该页面包含几个键和对子页面的引用。每个子页面负责一段连续范围的键,引用之间的键,指明了引用子页面的键范围。
|
一个页面会被指定为 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),该页面或者直接包含每个键的值,或者包含了对可以找到值的页面的引用。
|
最终,我们将到达某个包含单个键的页面(叶子页面,leaf page),该页面或者直接包含每个键的值,或者包含了对可以找到值的页面的引用。
|
||||||
|
|
||||||
在 B 树的一个页面中对子页面的引用的数量称为分支因子。例如,在 [图 3-6](img/fig3-6.png) 中,分支因子是 6。在实践中,分支因子取决于存储页面引用和范围边界所需的空间量,但通常是几百个。
|
在 B 树的一个页面中对子页面的引用的数量称为**分支因子**(**branching factor**)。例如,在 [图 3-6](img/fig3-6.png) 中,分支因子是 6。在实践中,分支因子的大小取决于存储页面引用和范围边界所需的空间,但这个值通常是几百。
|
||||||
|
|
||||||
如果要更新 B 树中现有键的值,需要搜索包含该键的叶子页面,更改该页面中的值,并将该页面写回到硬盘(对该页面的任何引用都将保持有效)。如果你想添加一个新的键,你需要找到其范围能包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以反映新的键范围分区,如 [图 3-7](img/fig3-7.png) 所示 [^ii]。
|
如果要更新 B 树中现有键的值,需要搜索包含该键的叶子页面,更改该页面中的值,并将该页面写回到硬盘(对该页面的任何引用都将保持有效)。如果你想添加一个新的键,你需要找到其范围能包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以反映新的键范围分区,如 [图 3-7](img/fig3-7.png) 所示 [^ii]。
|
||||||
|
|
||||||
@ -244,39 +244,39 @@ B 树的基本底层写操作是用新数据覆写硬盘上的页面,并假定
|
|||||||
|
|
||||||
你可以把覆写硬盘上的页面对应为实际的硬件操作。在磁性硬盘驱动器上,这意味着将磁头移动到正确的位置,等待旋转盘上的正确位置出现,然后用新的数据覆写适当的扇区。在固态硬盘上,由于 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 树已经存在了很久,所以并不奇怪这么多年下来有很多优化的设计被开发出来,仅举几例:
|
由于 B 树已经存在了很久,所以并不奇怪这么多年下来有很多优化的设计被开发出来,仅举几例:
|
||||||
|
|
||||||
* 一些数据库(如 LMDB)使用写时复制方案【21】,而不是覆盖页面并维护 WAL 以支持崩溃恢复。修改的页面被写入到不同的位置,并且还在树中创建了父页面的新版本,以指向新的位置。这种方法对于并发控制也很有用,我们将在 “[快照隔离和可重复读](ch7.md#快照隔离和可重复读)” 中看到。
|
* 不同于覆写页面并维护 WAL 以支持崩溃恢复,一些数据库(如 LMDB)使用写时复制方案【21】。经过修改的页面被写入到不同的位置,并且还在树中创建了父页面的新版本,以指向新的位置。这种方法对于并发控制也很有用,我们将在 “[快照隔离和可重复读](ch7.md#快照隔离和可重复读)” 中看到。
|
||||||
* 我们可以通过不存储整个键,而是缩短其大小,来节省页面空间。特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子,因此也就允许更少的层级 [^iii]。
|
* 我们可以通过不存储整个键,而是缩短其大小,来节省页面空间。特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子,因此也就允许更少的层级 [^iii]。
|
||||||
* 通常,页面可以放置在硬盘上的任何位置;没有什么要求相邻键范围的页面也放在硬盘上相邻的区域。如果某个查询需要按照排序顺序扫描大部分的键范围,那么这种按页面存储的布局可能会效率低下,因为每次页面读取可能都需要进行硬盘查找。因此,许多 B 树的实现在布局树时会尽量使叶子页面按顺序出现在硬盘上。但是,随着树的增长,要维持这个顺序是很困难的。相比之下,由于 LSM 树在合并过程中一次又一次地重写存储的大部分,所以它们更容易使顺序键在硬盘上彼此靠近。
|
* 通常,页面可以放置在硬盘上的任何位置;没有什么要求相邻键范围的页面也放在硬盘上相邻的区域。如果某个查询需要按照排序顺序扫描大部分的键范围,那么这种按页面存储的布局可能会效率低下,因为每个页面的读取都需要执行一次硬盘查找。因此,许多 B 树的实现在布局树时会尽量使叶子页面按顺序出现在硬盘上。但是,随着树的增长,要维持这个顺序是很困难的。相比之下,由于 LSM 树在合并过程中一次性重写一大段存储,所以它们更容易使顺序键在硬盘上连续存储。
|
||||||
* 额外的指针已被添加到树中。例如,每个叶子页面可以引用其左边和右边的兄弟页面,使得不用跳回父页面就能按顺序对键进行扫描。
|
* 额外的指针被添加到树中。例如,每个叶子页面可以引用其左边和右边的兄弟页面,使得不用跳回父页面就能按顺序对键进行扫描。
|
||||||
* B 树的变体如分形树(fractal tree)【22】借用一些日志结构的思想来减少硬盘查找(而且它们与分形无关)。
|
* B 树的变体如**分形树**(**fractal trees**)【22】借用了一些日志结构的思想来减少硬盘查找(而且它们与分形无关)。
|
||||||
|
|
||||||
[^iii]: 这个变种有时被称为 B+ 树,但因为这个优化已被广泛使用,所以经常无法区分于其它的 B 树变种。
|
[^iii]: 这个变种有时被称为 B+ 树,但因为这个优化已被广泛使用,所以经常无法区分于其它的 B 树变种。
|
||||||
|
|
||||||
### 比较B树和LSM树
|
### 比较B树和LSM树
|
||||||
|
|
||||||
尽管 B 树实现通常比 LSM 树实现更成熟,但 LSM 树由于其性能特点也非常有趣。根据经验,通常 LSM 树的写入速度更快,而 B 树的读取速度更快【23】。 LSM 树上的读取通常比较慢,因为它们必须检查几种不同的数据结构和不同压缩(Compaction)层级的 SSTables。
|
尽管 B 树实现通常比 LSM 树实现更成熟,但 LSM 树由于性能特征也非常有趣。根据经验,通常 LSM 树的写入速度更快,而 B 树的读取速度更快【23】。 LSM 树上的读取通常比较慢,因为它们必须检查几种不同的数据结构和不同压缩(Compaction)层级的 SSTables。
|
||||||
|
|
||||||
然而,基准测试的结果通常和工作负载的细节相关。你需要用你特有的工作负载来测试系统,以便进行有效的比较。在本节中,我们将简要讨论一些在衡量存储引擎性能时值得考虑的事情。
|
然而,基准测试的结果通常和工作负载的细节相关。你需要用你特有的工作负载来测试系统,以便进行有效的比较。在本节中,我们将简要讨论一些在衡量存储引擎性能时值得考虑的事情。
|
||||||
|
|
||||||
#### LSM树的优点
|
#### LSM树的优点
|
||||||
|
|
||||||
B 树索引中的每块数据都必须至少写入两次:一次写入预先写入日志(WAL),一次写入树页面本身(如果有分页还需要再写入一次)。即使在该页面中只有几个字节发生了变化,也需要接受写入整个页面的开销。有些存储引擎甚至会覆写同一个页面两次,以免在电源故障的情况下导致页面部分更新【24,25】。
|
B 树索引中的每块数据都必须至少写入两次:一次写入预先写入日志(WAL),一次写入树页面本身(如果有分页还需要再写入一次)。即使在该页面中只有几个字节发生了变化,也需要接受写入整个页面的开销。有些存储引擎甚至会覆写同一个页面两次,以免在电源故障的情况下页面未完整更新【24,25】。
|
||||||
|
|
||||||
由于反复压缩和合并 SSTables,日志结构索引也会多次重写数据。这种影响 —— 在数据库的生命周期中每次写入数据库导致对硬盘的多次写入 —— 被称为 **写放大(write amplification)**。需要特别注意的是固态硬盘,固态硬盘的闪存寿命在覆写有限次数后就会耗尽。
|
由于反复压缩和合并 SSTables,日志结构索引也会多次重写数据。这种影响 —— 在数据库的生命周期中每笔数据导致对硬盘的多次写入 —— 被称为 **写入放大(write amplification)**。使用固态硬盘的机器需要额外关注这点,固态硬盘的闪存寿命在覆写有限次数后就会耗尽。
|
||||||
|
|
||||||
在写入繁重的应用程序中,性能瓶颈可能是数据库可以写入硬盘的速度。在这种情况下,写放大会导致直接的性能代价:存储引擎写入硬盘的次数越多,可用硬盘带宽内它能处理的每秒写入次数就越少。
|
在写入繁重的应用程序中,性能瓶颈可能是数据库可以写入硬盘的速度。在这种情况下,写放大会导致直接的性能代价:存储引擎写入硬盘的次数越多,可用硬盘带宽内它能处理的每秒写入次数就越少。
|
||||||
|
|
||||||
而且,LSM 树通常能够比 B 树支持更高的写入吞吐量,部分原因是它们有时具有较低的写放大(尽管这取决于存储引擎的配置和工作负载),部分是因为它们顺序地写入紧凑的 SSTable 文件而不是必须覆写树中的几个页面【26】。这种差异在磁性硬盘驱动器上尤其重要,其顺序写入比随机写入要快得多。
|
进而,LSM 树通常能够比 B 树支持更高的写入吞吐量,部分原因是它们有时具有较低的写放大(尽管这取决于存储引擎的配置和工作负载),部分是因为它们顺序地写入紧凑的 SSTable 文件而不是必须覆写树中的几个页面【26】。这种差异在机械硬盘上尤其重要,其顺序写入比随机写入要快得多。
|
||||||
|
|
||||||
LSM 树可以被压缩得更好,因此通常能比 B 树在硬盘上产生更小的文件。B 树存储引擎会由于碎片化(fragmentation)而留下一些未使用的硬盘空间:当页面被拆分或某行不能放入现有页面时,页面中的某些空间仍未被使用。由于 LSM 树不是面向页面的,并且会通过定期重写 SSTables 以去除碎片,所以它们具有较低的存储开销,特别是当使用分层压缩(leveled compaction)时【27】。
|
LSM 树可以被压缩得更好,因此通常能比 B 树在硬盘上产生更小的文件。B 树存储引擎会由于碎片化(fragmentation)而留下一些未使用的硬盘空间:当页面被拆分或某行不能放入现有页面时,页面中的某些空间仍未被使用。由于 LSM 树不是面向页面的,并且会通过定期重写 SSTables 以去除碎片,所以它们具有较低的存储开销,特别是当使用分层压缩(leveled compaction)时【27】。
|
||||||
|
|
||||||
@ -292,7 +292,7 @@ LSM 树可以被压缩得更好,因此通常能比 B 树在硬盘上产生更
|
|||||||
|
|
||||||
B 树的一个优点是每个键只存在于索引中的一个位置,而日志结构化的存储引擎可能在不同的段中有相同键的多个副本。这个方面使得 B 树在想要提供强大的事务语义的数据库中很有吸引力:在许多关系数据库中,事务隔离是通过在键范围上使用锁来实现的,在 B 树索引中,这些锁可以直接附加到树上【5】。在 [第七章](ch7.md) 中,我们将更详细地讨论这一点。
|
B 树的一个优点是每个键只存在于索引中的一个位置,而日志结构化的存储引擎可能在不同的段中有相同键的多个副本。这个方面使得 B 树在想要提供强大的事务语义的数据库中很有吸引力:在许多关系数据库中,事务隔离是通过在键范围上使用锁来实现的,在 B 树索引中,这些锁可以直接附加到树上【5】。在 [第七章](ch7.md) 中,我们将更详细地讨论这一点。
|
||||||
|
|
||||||
B 树在数据库架构中是非常根深蒂固的,为许多工作负载都提供了始终如一的良好性能,所以它们不可能很快就会消失。在新的数据存储中,日志结构化索引变得越来越流行。没有快速和容易的规则来确定哪种类型的存储引擎对你的场景更好,所以值得去通过一些测试来得到相关的经验。
|
B 树在数据库架构中是非常根深蒂固的,为许多工作负载都提供了始终如一的良好性能,所以它们不可能在短期内消失。在新的数据库中,日志结构化索引变得越来越流行。没有简单易行的办法来判断哪种类型的存储引擎对你的使用场景更好,所以需要通过一些测试来得到相关经验。
|
||||||
|
|
||||||
### 其他索引结构
|
### 其他索引结构
|
||||||
|
|
||||||
@ -300,7 +300,7 @@ B 树在数据库架构中是非常根深蒂固的,为许多工作负载都提
|
|||||||
|
|
||||||
次级索引(secondary indexes)也很常见。在关系数据库中,你可以使用 `CREATE INDEX` 命令在同一个表上创建多个次级索引,而且这些索引通常对于有效地执行联接(join)而言至关重要。例如,在 [第二章](ch2.md) 中的 [图 2-1](img/fig2-1.png) 中,很可能在 `user_id` 列上有一个次级索引,以便你可以在每个表中找到属于同一用户的所有行。
|
次级索引(secondary indexes)也很常见。在关系数据库中,你可以使用 `CREATE INDEX` 命令在同一个表上创建多个次级索引,而且这些索引通常对于有效地执行联接(join)而言至关重要。例如,在 [第二章](ch2.md) 中的 [图 2-1](img/fig2-1.png) 中,很可能在 `user_id` 列上有一个次级索引,以便你可以在每个表中找到属于同一用户的所有行。
|
||||||
|
|
||||||
次级索引可以很容易地从键值索引构建。次级索引主要的不同是键不是唯一的,即可能有许多行(文档,顶点)具有相同的键。这可以通过两种方式来解决:或者将匹配行标识符的列表作为索引里的值(就像全文索引中的记录列表),或者通过向每个键添加行标识符来使键唯一。无论哪种方式,B 树和日志结构索引都可以用作次级索引。
|
次级索引可以很容易地从键值索引构建。次级索引主要的不同是键不是唯一的,即可能有许多行(文档,顶点)具有相同的键。这可以通过两种方式来解决:将匹配行标识符的列表作为索引里的值(就像全文索引中的记录列表),或者通过向每个键添加行标识符来使键唯一。无论哪种方式,B 树和日志结构索引都可以用作次级索引。
|
||||||
|
|
||||||
#### 将值存储在索引中
|
#### 将值存储在索引中
|
||||||
|
|
||||||
@ -312,7 +312,7 @@ B 树在数据库架构中是非常根深蒂固的,为许多工作负载都提
|
|||||||
|
|
||||||
在 **聚集索引**(在索引中存储所有的行数据)和 **非聚集索引**(仅在索引中存储对数据的引用)之间的折衷被称为 **覆盖索引(covering index)** 或 **包含列的索引(index with included columns)**,其在索引内存储表的一部分列【33】。这允许通过单独使用索引来处理一些查询(这种情况下,可以说索引 **覆盖(cover)** 了查询)【32】。
|
在 **聚集索引**(在索引中存储所有的行数据)和 **非聚集索引**(仅在索引中存储对数据的引用)之间的折衷被称为 **覆盖索引(covering index)** 或 **包含列的索引(index with included columns)**,其在索引内存储表的一部分列【33】。这允许通过单独使用索引来处理一些查询(这种情况下,可以说索引 **覆盖(cover)** 了查询)【32】。
|
||||||
|
|
||||||
与任何类型的数据重复一样,聚集索引和覆盖索引可以加快读取速度,但是它们需要额外的存储空间,并且会增加写入开销。数据库还需要额外的努力来执行事务保证,因为应用程序不应看到任何因为重复而导致的不一致。
|
与任何类型的数据重复一样,聚集索引和覆盖索引可以加快读取速度,但是它们需要额外的存储空间,并且会增加写入开销。数据库还需要额外的努力来执行事务保证,因为应用程序不应看到任何因为使用副本而导致的不一致。
|
||||||
|
|
||||||
#### 多列索引
|
#### 多列索引
|
||||||
|
|
||||||
@ -329,15 +329,15 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
|
|||||||
|
|
||||||
一个标准的 B 树或者 LSM 树索引不能够高效地处理这种查询:它可以返回一个纬度范围内的所有餐馆(但经度可能是任意值),或者返回在同一个经度范围内的所有餐馆(但纬度可能是北极和南极之间的任意地方),但不能同时满足两个条件。
|
一个标准的 B 树或者 LSM 树索引不能够高效地处理这种查询:它可以返回一个纬度范围内的所有餐馆(但经度可能是任意值),或者返回在同一个经度范围内的所有餐馆(但纬度可能是北极和南极之间的任意地方),但不能同时满足两个条件。
|
||||||
|
|
||||||
一种选择是使用空间填充曲线将二维位置转换为单个数字,然后使用常规 B 树索引【34】。更普遍的是,使用特殊化的空间索引,例如 R 树。例如,PostGIS 使用 PostgreSQL 的通用 GiST 工具【35】将地理空间索引实现为 R 树。这里我们没有足够的地方来描述 R 树,但是有大量的文献可供参考。
|
一种选择是使用**空间填充曲线**(space-filling curve)将二维位置转换为单个数字,然后使用常规 B 树索引【34】。更普遍的是,使用特殊化的空间索引,例如 R 树。例如,PostGIS 使用 PostgreSQL 的通用 GiST 工具【35】将地理空间索引实现为 R 树。这里我们没有足够的地方来描述 R 树,但是有大量的文献可供参考。
|
||||||
|
|
||||||
有趣的是,多维索引不仅可以用于地理位置。例如,在电子商务网站上可以使用建立在(红,绿,蓝)维度上的三维索引来搜索特定颜色范围内的产品,也可以在天气观测数据库中建立(日期,温度)的二维索引,以便有效地搜索 2013 年内的温度在 25 至 30°C 之间的所有观测资料。如果使用一维索引,你将不得不扫描 2013 年的所有记录(不管温度如何),然后通过温度进行过滤,或者反之亦然。 二维索引可以同时通过时间戳和温度来收窄数据集。这个技术被 HyperDex 所使用【36】。
|
有趣的是,多维索引不仅可以用于地理位置。例如,在电子商务网站上可以使用建立在(红,绿,蓝)维度上的三维索引来搜索特定颜色范围内的产品,也可以在天气观测数据库中建立(日期,温度)的二维索引,以便有效地搜索 2013 年内的温度在 25 至 30°C 之间的所有观测资料。如果使用一维索引,你将不得不扫描 2013 年的所有记录(不管温度如何),然后通过温度进行过滤,或者反之亦然。二维索引可以同时通过时间戳和温度来收窄数据集。这个技术被 HyperDex 所使用【36】。
|
||||||
|
|
||||||
#### 全文搜索和模糊索引
|
#### 全文搜索和模糊索引
|
||||||
|
|
||||||
到目前为止所讨论的所有索引都假定你有确切的数据,并允许你查询键的确切值或具有排序顺序的键的值范围。他们不允许你做的是搜索类似的键,如拼写错误的单词。这种模糊的查询需要不同的技术。
|
到目前为止所讨论的所有索引都假定你有确切的数据,并允许你查询键的确切值或具有排序顺序的键的值范围。他们不允许你做的是搜索**类似**的键,如拼写错误的单词。这种模糊的查询需要不同的技术。
|
||||||
|
|
||||||
例如,全文搜索引擎通常允许搜索一个单词以扩展为包括该单词的同义词,忽略单词的语法变体,搜索在相同文档中彼此靠近的单词的出现,并且支持各种其他取决于文本的语言分析功能。为了处理文档或查询中的拼写错误,Lucene 能够在一定的编辑距离(编辑距离 1 意味着添加,删除或替换了一个字母)内搜索文本【37】。
|
例如,全文搜索引擎通常允许搜索目标从一个单词扩展为包括该单词的同义词,忽略单词的语法变体,搜索在相同文档中的近义词,并且支持各种其他取决于文本的语言分析功能。为了处理文档或查询中的拼写错误,Lucene 能够在一定的编辑距离内搜索文本【37】(编辑距离 1 意味着单词内发生了 1 个字母的添加、删除或替换)。
|
||||||
|
|
||||||
正如 “[用 SSTables 制作 LSM 树](#用SSTables制作LSM树)” 中所提到的,Lucene 为其词典使用了一个类似于 SSTable 的结构。这个结构需要一个小的内存索引,告诉查询需要在排序文件中哪个偏移量查找键。在 LevelDB 中,这个内存中的索引是一些键的稀疏集合,但在 Lucene 中,内存中的索引是键中字符的有限状态自动机,类似于 trie 【38】。这个自动机可以转换成 Levenshtein 自动机,它支持在给定的编辑距离内有效地搜索单词【39】。
|
正如 “[用 SSTables 制作 LSM 树](#用SSTables制作LSM树)” 中所提到的,Lucene 为其词典使用了一个类似于 SSTable 的结构。这个结构需要一个小的内存索引,告诉查询需要在排序文件中哪个偏移量查找键。在 LevelDB 中,这个内存中的索引是一些键的稀疏集合,但在 Lucene 中,内存中的索引是键中字符的有限状态自动机,类似于 trie 【38】。这个自动机可以转换成 Levenshtein 自动机,它支持在给定的编辑距离内有效地搜索单词【39】。
|
||||||
|
|
||||||
@ -351,7 +351,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
|
|||||||
|
|
||||||
某些内存中的键值存储(如 Memcached)仅用于缓存,在重新启动计算机时丢失的数据是可以接受的。但其他内存数据库的目标是持久性,可以通过特殊的硬件(例如电池供电的 RAM)来实现,也可以将更改日志写入硬盘,还可以将定时快照写入硬盘或者将内存中的状态复制到其他机器上。
|
某些内存中的键值存储(如 Memcached)仅用于缓存,在重新启动计算机时丢失的数据是可以接受的。但其他内存数据库的目标是持久性,可以通过特殊的硬件(例如电池供电的 RAM)来实现,也可以将更改日志写入硬盘,还可以将定时快照写入硬盘或者将内存中的状态复制到其他机器上。
|
||||||
|
|
||||||
内存数据库重新启动时,需要从硬盘或通过网络从副本重新加载其状态(除非使用特殊的硬件)。尽管写入硬盘,它仍然是一个内存数据库,因为硬盘仅出于持久性目的进行日志追加,读取请求完全由内存来处理。写入硬盘同时还有运维上的好处:硬盘上的文件可以很容易地由外部实用程序进行备份、检查和分析。
|
内存数据库重新启动时,需要从硬盘或通过网络从副本重新加载其状态(除非使用特殊的硬件)。尽管写入硬盘,它仍然是一个内存数据库,因为硬盘仅出于持久性目的进行日志追加,读取请求完全由内存来处理。写入硬盘同时还有运维上的好处:硬盘上的文件可以很容易地由外部程序进行备份、检查和分析。
|
||||||
|
|
||||||
诸如 VoltDB、MemSQL 和 Oracle TimesTen 等产品是具有关系模型的内存数据库,供应商声称,通过消除与管理硬盘上的数据结构相关的所有开销,他们可以提供巨大的性能改进【41,42】。 RAM Cloud 是一个开源的内存键值存储器,具有持久性(对内存和硬盘上的数据都使用日志结构化方法)【43】。 Redis 和 Couchbase 通过异步写入硬盘提供了较弱的持久性。
|
诸如 VoltDB、MemSQL 和 Oracle TimesTen 等产品是具有关系模型的内存数据库,供应商声称,通过消除与管理硬盘上的数据结构相关的所有开销,他们可以提供巨大的性能改进【41,42】。 RAM Cloud 是一个开源的内存键值存储器,具有持久性(对内存和硬盘上的数据都使用日志结构化方法)【43】。 Redis 和 Couchbase 通过异步写入硬盘提供了较弱的持久性。
|
||||||
|
|
||||||
@ -408,7 +408,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
|
|||||||
|
|
||||||
几乎所有的大型企业都有数据仓库,但在小型企业中几乎闻所未闻。这可能是因为大多数小公司没有这么多不同的 OLTP 系统,大多数小公司只有少量的数据 —— 可以在传统的 SQL 数据库中查询,甚至可以在电子表格中分析。在一家大公司里,要做一些在一家小公司很简单的事情,需要很多繁重的工作。
|
几乎所有的大型企业都有数据仓库,但在小型企业中几乎闻所未闻。这可能是因为大多数小公司没有这么多不同的 OLTP 系统,大多数小公司只有少量的数据 —— 可以在传统的 SQL 数据库中查询,甚至可以在电子表格中分析。在一家大公司里,要做一些在一家小公司很简单的事情,需要很多繁重的工作。
|
||||||
|
|
||||||
使用单独的数据仓库,而不是直接查询 OLTP 系统进行分析的一大优势是数据仓库可针对分析访问模式进行优化。事实证明,本章前半部分讨论的索引算法对于 OLTP 来说工作得很好,但对于处理分析查询并不是很好。在本章的其余部分中,我们将研究为分析而优化的存储引擎。
|
使用单独的数据仓库,而不是直接查询 OLTP 系统进行分析的一大优势是数据仓库可针对分析类的访问模式进行优化。事实证明,本章前半部分讨论的索引算法对于 OLTP 来说工作得很好,但对于处理分析查询并不是很好。在本章的其余部分中,我们将研究为分析而优化的存储引擎。
|
||||||
|
|
||||||
#### OLTP数据库和数据仓库之间的分歧
|
#### OLTP数据库和数据仓库之间的分歧
|
||||||
|
|
||||||
@ -416,7 +416,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
|
|||||||
|
|
||||||
表面上,一个数据仓库和一个关系型 OLTP 数据库看起来很相似,因为它们都有一个 SQL 查询接口。然而,系统的内部看起来可能完全不同,因为它们针对非常不同的查询模式进行了优化。现在许多数据库供应商都只是重点支持事务处理负载和分析工作负载这两者中的一个,而不是都支持。
|
表面上,一个数据仓库和一个关系型 OLTP 数据库看起来很相似,因为它们都有一个 SQL 查询接口。然而,系统的内部看起来可能完全不同,因为它们针对非常不同的查询模式进行了优化。现在许多数据库供应商都只是重点支持事务处理负载和分析工作负载这两者中的一个,而不是都支持。
|
||||||
|
|
||||||
一些数据库(例如 Microsoft SQL Server 和 SAP HANA)支持在同一产品中进行事务处理和数据仓库。但是,它们也正日益成为两个独立的存储和查询引擎,只是这些引擎正好可以通过一个通用的 SQL 接口访问【49,50,51】。
|
一些数据库(例如 Microsoft SQL Server 和 SAP HANA)支持在同一产品中进行事务处理和数据仓库。但是,它们也正日益发展为两套独立的存储和查询引擎,只是这些引擎正好可以通过一个通用的 SQL 接口访问【49,50,51】。
|
||||||
|
|
||||||
Teradata、Vertica、SAP HANA 和 ParAccel 等数据仓库供应商通常使用昂贵的商业许可证销售他们的系统。 Amazon RedShift 是 ParAccel 的托管版本。最近,大量的开源 SQL-on-Hadoop 项目已经出现,它们还很年轻,但是正在与商业数据仓库系统竞争,包括 Apache Hive、Spark SQL、Cloudera Impala、Facebook Presto、Apache Tajo 和 Apache Drill【52,53】。其中一些基于了谷歌 Dremel 的想法【54】。
|
Teradata、Vertica、SAP HANA 和 ParAccel 等数据仓库供应商通常使用昂贵的商业许可证销售他们的系统。 Amazon RedShift 是 ParAccel 的托管版本。最近,大量的开源 SQL-on-Hadoop 项目已经出现,它们还很年轻,但是正在与商业数据仓库系统竞争,包括 Apache Hive、Spark SQL、Cloudera Impala、Facebook Presto、Apache Tajo 和 Apache Drill【52,53】。其中一些基于了谷歌 Dremel 的想法【54】。
|
||||||
|
|
||||||
@ -449,7 +449,7 @@ Teradata、Vertica、SAP HANA 和 ParAccel 等数据仓库供应商通常使用
|
|||||||
|
|
||||||
如果事实表中有万亿行和数 PB 的数据,那么高效地存储和查询它们就成为一个具有挑战性的问题。维度表通常要小得多(数百万行),所以在本节中我们将主要关注事实表的存储。
|
如果事实表中有万亿行和数 PB 的数据,那么高效地存储和查询它们就成为一个具有挑战性的问题。维度表通常要小得多(数百万行),所以在本节中我们将主要关注事实表的存储。
|
||||||
|
|
||||||
尽管事实表通常超过 100 列,但典型的数据仓库查询一次只会访问其中 4 个或 5 个列( “`SELECT *`” 查询很少用于分析)【51】。以 [例 3-1]() 中的查询为例:它访问了大量的行(在 2013 日历年中每次都有人购买水果或糖果),但只需访问 `fact_sales` 表的三列:`date_key, product_sk, quantity`。该查询忽略了所有其他的列。
|
尽管事实表通常超过 100 列,但典型的数据仓库查询一次只会访问其中 4 个或 5 个列( “`SELECT *`” 查询很少用于分析)【51】。以 [例 3-1]() 中的查询为例:它访问了大量的行(在 2013 年中所有购买了水果或糖果的记录),但只需访问 `fact_sales` 表的三列:`date_key, product_sk, quantity`。该查询忽略了所有其他的列。
|
||||||
|
|
||||||
**例 3-1 分析人们是否更倾向于在一周的某一天购买新鲜水果或糖果**
|
**例 3-1 分析人们是否更倾向于在一周的某一天购买新鲜水果或糖果**
|
||||||
|
|
||||||
@ -497,7 +497,7 @@ GROUP BY
|
|||||||
|
|
||||||
通常情况下,一列中不同值的数量与行数相比要小得多(例如,零售商可能有数十亿的销售交易,但只有 100,000 个不同的产品)。现在我们可以拿一个有 n 个不同值的列,并把它转换成 n 个独立的位图:每个不同值对应一个位图,每行对应一个比特位。如果该行具有该值,则该位为 1,否则为 0。
|
通常情况下,一列中不同值的数量与行数相比要小得多(例如,零售商可能有数十亿的销售交易,但只有 100,000 个不同的产品)。现在我们可以拿一个有 n 个不同值的列,并把它转换成 n 个独立的位图:每个不同值对应一个位图,每行对应一个比特位。如果该行具有该值,则该位为 1,否则为 0。
|
||||||
|
|
||||||
如果 n 非常小(例如,国家 / 地区列可能有大约 200 个不同的值),则这些位图可以将每行存储成一个比特位。但是,如果 n 更大,大部分位图中将会有很多的零(我们说它们是稀疏的)。在这种情况下,位图可以另外再进行游程编码,如 [图 3-11](fig3-11.png) 底部所示。这可以使列的编码非常紧凑。
|
如果 n 非常小(例如,国家 / 地区列可能有大约 200 个不同的值),则这些位图可以将每行存储成一个比特位。但是,如果 n 更大,大部分位图中将会有很多的零(我们说它们是稀疏的)。在这种情况下,位图可以另外再进行游程编码(run-length encoding,一种无损数据压缩技术),如 [图 3-11](fig3-11.png) 底部所示。这可以使列的编码非常紧凑。
|
||||||
|
|
||||||
这些位图索引非常适合数据仓库中常见的各种查询。例如:
|
这些位图索引非常适合数据仓库中常见的各种查询。例如:
|
||||||
|
|
||||||
@ -522,28 +522,28 @@ WHERE product_sk = 31 AND store_sk = 3
|
|||||||
|
|
||||||
#### 内存带宽和矢量化处理
|
#### 内存带宽和矢量化处理
|
||||||
|
|
||||||
对于需要扫描数百万行的数据仓库查询来说,一个巨大的瓶颈是从硬盘获取数据到内存的带宽。但是,这不是唯一的瓶颈。分析型数据库的开发人员还需要有效地利用主存储器到 CPU 缓存的带宽,避免 CPU 指令处理流水线中的分支预测错误和气泡,以及在现代 CPU 上使用单指令多数据(SIMD)指令【59,60】。
|
对于需要扫描数百万行的数据仓库查询来说,一个巨大的瓶颈是从硬盘获取数据到内存的带宽。但是,这不是唯一的瓶颈。分析型数据库的开发人员还需要有效地利用内存到 CPU 缓存的带宽,避免 CPU 指令处理流水线中的分支预测错误和闲置等待,以及在现代 CPU 上使用单指令多数据(SIMD)指令来加速运算【59,60】。
|
||||||
|
|
||||||
除了减少需要从硬盘加载的数据量以外,列式存储布局也可以有效利用 CPU 周期。例如,查询引擎可以将大量压缩的列数据放在 CPU 的 L1 缓存中,然后在紧密的循环(即没有函数调用)中遍历。相比较每个记录的处理都需要大量函数调用和条件判断的代码,CPU 执行这样一个循环要快得多。列压缩允许列中的更多行被放进相同数量的 L1 缓存。前面描述的按位 “与” 和 “或” 运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为矢量化处理【58,49】。
|
除了减少需要从硬盘加载的数据量以外,列式存储布局也可以有效利用 CPU 周期。例如,查询引擎可以将一整块压缩好的列数据放进 CPU 的 L1 缓存中,然后在紧密的循环(即没有函数调用)中遍历。相比于每条记录的处理都需要大量函数调用和条件判断的代码,CPU 执行这样一个循环要快得多。列压缩允许列中的更多行被同时放进容量有限的 L1 缓存。前面描述的按位 “与” 和 “或” 运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为矢量化处理(vectorized processing)【58,49】。
|
||||||
|
|
||||||
|
|
||||||
### 列式存储中的排序顺序
|
### 列式存储中的排序顺序
|
||||||
|
|
||||||
在列式存储中,存储行的顺序并不一定很重要。按插入顺序存储它们是最简单的,因为插入一个新行只需要追加到每个列文件。但是,我们可以选择增加一个特定的顺序,就像我们之前对 SSTables 所做的那样,并将其用作索引机制。
|
在列式存储中,存储行的顺序并不关键。按插入顺序存储它们是最简单的,因为插入一个新行只需要追加到每个列文件。但是,我们也可以选择按某种顺序来排列数据,就像我们之前对 SSTables 所做的那样,并将其用作索引机制。
|
||||||
|
|
||||||
注意,每列独自排序是没有意义的,因为那样我们就没法知道不同列中的哪些项属于同一行。我们只能在知道一列中的第 k 项与另一列中的第 k 项属于同一行的情况才能重建出完整的行。
|
注意,对每列分别执行排序是没有意义的,因为那样就没法知道不同列中的哪些项属于同一行。我们只能在明确一列中的第 k 项与另一列中的第 k 项属于同一行的情况下,才能重建出完整的行。
|
||||||
|
|
||||||
相反,即使按列式存储数据,也需要一次对整行进行排序。数据库的管理员可以根据他们对常用查询的了解来选择表格应该被排序的列。例如,如果查询通常以日期范围为目标,例如上个月,则可以将 `date_key` 作为第一个排序键。这样查询优化器就可以只扫描上个月的行了,这比扫描所有行要快得多。
|
相反,数据的排序需要对一整行统一操作,即使它们的存储方式是按列的。数据库管理员可以根据他们对常用查询的了解,来选择表格中用来排序的列。例如,如果查询通常以日期范围为目标,例如“上个月”,则可以将 `date_key` 作为第一个排序键。这样查询优化器就可以只扫描近1个月范围的行了,这比扫描所有行要快得多。
|
||||||
|
|
||||||
对于第一排序列中具有相同值的行,可以用第二排序列来进一步排序。例如,如果 `date_key` 是 [图 3-10](img/fig3-10.png) 中的第一个排序关键字,那么 `product_sk` 可能是第二个排序关键字,以便同一天的同一产品的所有销售都将在存储中组合在一起。这将有助于需要在特定日期范围内按产品对销售进行分组或过滤的查询。
|
对于第一排序列中具有相同值的行,可以用第二排序列来进一步排序。例如,如果 `date_key` 是 [图 3-10](img/fig3-10.png) 中的第一个排序关键字,那么 `product_sk` 可能是第二个排序关键字,以便同一天的同一产品的所有销售数据都被存储在相邻位置。这将有助于需要在特定日期范围内按产品对销售进行分组或过滤的查询。
|
||||||
|
|
||||||
排序顺序的另一个好处是它可以帮助压缩列。如果主要排序列没有太多个不同的值,那么在排序之后,它将具有很长的序列,其中相同的值连续重复多次。一个简单的游程编码(就像我们用于 [图 3-11](img/fig3-11.png) 中的位图一样)可以将该列压缩到几千字节 —— 即使表中有数十亿行。
|
按顺序排序的另一个好处是它可以帮助压缩列。如果主要排序列没有太多个不同的值,那么在排序之后,将会得到一个相同的值连续重复多次的序列。一个简单的游程编码(就像我们用于 [图 3-11](img/fig3-11.png) 中的位图一样)可以将该列压缩到几 KB —— 即使表中有数十亿行。
|
||||||
|
|
||||||
第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长的连续的重复值。排序优先级更低的列以基本上随机的顺序出现,所以它们可能不会被压缩。但前几列排序在整体上仍然是有好处的。
|
第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长的连续的重复值。排序优先级更低的列以几乎随机的顺序出现,所以可能不会被压缩。但对前几列做排序在整体上仍然是有好处的。
|
||||||
|
|
||||||
#### 几个不同的排序顺序
|
#### 几个不同的排序顺序
|
||||||
|
|
||||||
C-Store 中引入了这个想法的一个巧妙扩展,并在商业数据仓库 Vertica 中被采用【61,62】。不同的查询受益于不同的排序顺序,为什么不以几种不同的方式来存储相同的数据呢?无论如何,数据需要复制到多台机器,这样,如果一台机器发生故障,你不会丢失数据。你可能还需要存储以不同方式排序的冗余数据,以便在处理查询时,可以使用最适合查询模式的版本。
|
对这个想法,有一个巧妙的扩展被 C-Store 发现,并在商业数据仓库 Vertica 中被采用【61,62】:既然不同的查询受益于不同的排序顺序,为什么不以几种不同的方式来存储相同的数据呢?反正数据都需要做备份,以防单点故障时丢失数据。因此你可以用不同排序方式来存储冗余数据,以便在处理查询时,调用最适合查询模式的版本。
|
||||||
|
|
||||||
在一个列式存储中有多个排序顺序有点类似于在一个面向行的存储中有多个次级索引。但最大的区别在于面向行的存储将每一行保存在一个地方(在堆文件或聚集索引中),次级索引只包含指向匹配行的指针。在列式存储中,通常在其他地方没有任何指向数据的指针,只有包含值的列。
|
在一个列式存储中有多个排序顺序有点类似于在一个面向行的存储中有多个次级索引。但最大的区别在于面向行的存储将每一行保存在一个地方(在堆文件或聚集索引中),次级索引只包含指向匹配行的指针。在列式存储中,通常在其他地方没有任何指向数据的指针,只有包含值的列。
|
||||||
|
|
||||||
@ -555,17 +555,17 @@ C-Store 中引入了这个想法的一个巧妙扩展,并在商业数据仓库
|
|||||||
|
|
||||||
幸运的是,本章前面已经看到了一个很好的解决方案:LSM 树。所有的写操作首先进入一个内存中的存储,在这里它们被添加到一个已排序的结构中,并准备写入硬盘。内存中的存储是面向行还是列的并不重要。当已经积累了足够的写入数据时,它们将与硬盘上的列文件合并,并批量写入新文件。这基本上是 Vertica 所做的【62】。
|
幸运的是,本章前面已经看到了一个很好的解决方案:LSM 树。所有的写操作首先进入一个内存中的存储,在这里它们被添加到一个已排序的结构中,并准备写入硬盘。内存中的存储是面向行还是列的并不重要。当已经积累了足够的写入数据时,它们将与硬盘上的列文件合并,并批量写入新文件。这基本上是 Vertica 所做的【62】。
|
||||||
|
|
||||||
查询需要检查硬盘上的列数据和最近在内存中的写入,并将两者结合起来。但是,查询优化器对用户隐藏了这个细节。从分析师的角度来看,通过插入、更新或删除操作进行修改的数据会立即反映在后续的查询中。
|
查询操作需要检查硬盘上的列数据和内存中的最近写入,并将两者的结果合并起来。但是,查询优化器对用户隐藏了这个细节。从分析师的角度来看,通过插入、更新或删除操作进行修改的数据会立即反映在后续的查询中。
|
||||||
|
|
||||||
### 聚合:数据立方体和物化视图
|
### 聚合:数据立方体和物化视图
|
||||||
|
|
||||||
并不是每个数据仓库都必定是一个列式存储:传统的面向行的数据库和其他一些架构也被使用。然而,列式存储可以显著加快专门的分析查询,所以它正在迅速变得流行起来【51,63】。
|
并非所有数据仓库都需要采用列式存储:传统的面向行的数据库和其他一些架构也被使用。然而,列式存储可以显著加快专门的分析查询,所以它正在迅速变得流行起来【51,63】。
|
||||||
|
|
||||||
数据仓库的另一个值得一提的方面是物化汇总(materialized aggregates)。如前所述,数据仓库查询通常涉及一个聚合函数,如 SQL 中的 COUNT、SUM、AVG、MIN 或 MAX。如果相同的聚合被许多不同的查询使用,那么每次都通过原始数据来处理可能太浪费了。为什么不将一些查询使用最频繁的计数或总和缓存起来?
|
数据仓库的另一个值得一提的方面是物化聚合(materialized aggregates)。如前所述,数据仓库查询通常涉及一个聚合函数,如 SQL 中的 COUNT、SUM、AVG、MIN 或 MAX。如果相同的聚合被许多不同的查询使用,那么每次都通过原始数据来处理可能太浪费了。为什么不将一些查询使用最频繁的计数或总和缓存起来?
|
||||||
|
|
||||||
创建这种缓存的一种方式是物化视图(Materialized View)。在关系数据模型中,它通常被定义为一个标准(虚拟)视图:一个类似于表的对象,其内容是一些查询的结果。不同的是,物化视图是查询结果的实际副本,会被写入硬盘,而虚拟视图只是编写查询的一个捷径。从虚拟视图读取时,SQL 引擎会将其展开到视图的底层查询中,然后再处理展开的查询。
|
创建这种缓存的一种方式是物化视图(Materialized View)。在关系数据模型中,它通常被定义为一个标准(虚拟)视图:一个类似于表的对象,其内容是一些查询的结果。不同的是,物化视图是查询结果的实际副本,会被写入硬盘,而虚拟视图只是编写查询的一个捷径。从虚拟视图读取时,SQL 引擎会将其展开到视图的底层查询中,然后再处理展开的查询。
|
||||||
|
|
||||||
当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。数据库可以自动完成该操作,但是这样的更新使得写入成本更高,这就是在 OLTP 数据库中不经常使用物化视图的原因。在读取繁重的数据仓库中,它们可能更有意义(它们是否实际上改善了读取性能取决于个别情况)。
|
当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。数据库可以自动完成该操作,但是这样的更新使得写入成本更高,这就是在 OLTP 数据库中不经常使用物化视图的原因。在读取繁重的数据仓库中,它们可能更有意义(它们是否实际上改善了读取性能取决于使用场景)。
|
||||||
|
|
||||||
物化视图的常见特例称为数据立方体或 OLAP 立方【64】。它是按不同维度分组的聚合网格。[图 3-12](img/fig3-12.png) 显示了一个例子。
|
物化视图的常见特例称为数据立方体或 OLAP 立方【64】。它是按不同维度分组的聚合网格。[图 3-12](img/fig3-12.png) 显示了一个例子。
|
||||||
|
|
||||||
@ -573,7 +573,7 @@ C-Store 中引入了这个想法的一个巧妙扩展,并在商业数据仓库
|
|||||||
|
|
||||||
**图 3-12 数据立方的两个维度,通过求和聚合**
|
**图 3-12 数据立方的两个维度,通过求和聚合**
|
||||||
|
|
||||||
想象一下,现在每个事实都只有两个维度表的外键 —— 在 [图 3-12](img/fig-3-12.png) 中分别是日期和产品。你现在可以绘制一个二维表格,一个轴线上是日期,另一个轴线上是产品。每个单元格包含具有该日期 - 产品组合的所有事实的属性(例如 `net_price`)的聚集(例如 `SUM`)。然后,你可以沿着每行或每列应用相同的汇总,并获得减少了一个维度的汇总(按产品的销售额,无论日期,或者按日期的销售额,无论产品)。
|
想象一下,现在每个事实都只有两个维度表的外键 —— 在 [图 3-12](img/fig-3-12.png) 中分别是日期和产品。你现在可以绘制一个二维表格,一个轴线上是日期,另一个轴线上是产品。每个单元格包含具有该日期 - 产品组合的所有事实的属性(例如 `net_price`)的聚合(例如 `SUM`)。然后,你可以沿着每行或每列应用相同的汇总,并获得减少了一个维度的汇总(按产品的销售额,无论日期,或者按日期的销售额,无论产品)。
|
||||||
|
|
||||||
一般来说,事实往往有两个以上的维度。在图 3-9 中有五个维度:日期、产品、商店、促销和客户。要想象一个五维超立方体是什么样子是很困难的,但是原理是一样的:每个单元格都包含特定日期 - 产品 - 商店 - 促销 - 客户组合的销售额。这些值可以在每个维度上求和汇总。
|
一般来说,事实往往有两个以上的维度。在图 3-9 中有五个维度:日期、产品、商店、促销和客户。要想象一个五维超立方体是什么样子是很困难的,但是原理是一样的:每个单元格都包含特定日期 - 产品 - 商店 - 促销 - 客户组合的销售额。这些值可以在每个维度上求和汇总。
|
||||||
|
|
||||||
@ -589,7 +589,7 @@ C-Store 中引入了这个想法的一个巧妙扩展,并在商业数据仓库
|
|||||||
在高层次上,我们看到存储引擎分为两大类:针对 **事务处理(OLTP)** 优化的存储引擎和针对 **在线分析(OLAP)** 优化的存储引擎。这两类使用场景的访问模式之间有很大的区别:
|
在高层次上,我们看到存储引擎分为两大类:针对 **事务处理(OLTP)** 优化的存储引擎和针对 **在线分析(OLAP)** 优化的存储引擎。这两类使用场景的访问模式之间有很大的区别:
|
||||||
|
|
||||||
* OLTP 系统通常面向最终用户,这意味着系统可能会收到大量的请求。为了处理负载,应用程序在每个查询中通常只访问少量的记录。应用程序使用某种键来请求记录,存储引擎使用索引来查找所请求的键的数据。硬盘查找时间往往是这里的瓶颈。
|
* OLTP 系统通常面向最终用户,这意味着系统可能会收到大量的请求。为了处理负载,应用程序在每个查询中通常只访问少量的记录。应用程序使用某种键来请求记录,存储引擎使用索引来查找所请求的键的数据。硬盘查找时间往往是这里的瓶颈。
|
||||||
* 数据仓库和类似的分析系统会低调一些,因为它们主要由业务分析人员使用,而不是最终用户。它们的查询量要比 OLTP 系统少得多,但通常每个查询开销高昂,需要在短时间内扫描数百万条记录。硬盘带宽(而不是查找时间)往往是瓶颈,列式存储是针对这种工作负载的日益流行的解决方案。
|
* 数据仓库和类似的分析系统会少见一些,因为它们主要由业务分析人员使用,而不是最终用户。它们的查询量要比 OLTP 系统少得多,但通常每个查询开销高昂,需要在短时间内扫描数百万条记录。硬盘带宽(而不是查找时间)往往是瓶颈,列式存储是针对这种工作负载的日益流行的解决方案。
|
||||||
|
|
||||||
在 OLTP 这一边,我们能看到两派主流的存储引擎:
|
在 OLTP 这一边,我们能看到两派主流的存储引擎:
|
||||||
|
|
||||||
@ -602,7 +602,7 @@ C-Store 中引入了这个想法的一个巧妙扩展,并在商业数据仓库
|
|||||||
|
|
||||||
然后,我们暂时放下了存储引擎的内部细节,查看了典型数据仓库的高级架构,并说明了为什么分析工作负载与 OLTP 差别很大:当你的查询需要在大量行中顺序扫描时,索引的重要性就会降低很多。相反,非常紧凑地编码数据变得非常重要,以最大限度地减少查询需要从硬盘读取的数据量。我们讨论了列式存储如何帮助实现这一目标。
|
然后,我们暂时放下了存储引擎的内部细节,查看了典型数据仓库的高级架构,并说明了为什么分析工作负载与 OLTP 差别很大:当你的查询需要在大量行中顺序扫描时,索引的重要性就会降低很多。相反,非常紧凑地编码数据变得非常重要,以最大限度地减少查询需要从硬盘读取的数据量。我们讨论了列式存储如何帮助实现这一目标。
|
||||||
|
|
||||||
作为一名应用程序开发人员,如果你掌握了有关存储引擎内部的知识,那么你就能更好地了解哪种工具最适合你的特定应用程序。如果你需要调整数据库的调整参数,这种理解可以让你设想一个更高或更低的值可能会产生什么效果。
|
作为一名应用程序开发人员,如果你掌握了有关存储引擎内部的知识,那么你就能更好地了解哪种工具最适合你的特定应用程序。当你调整数据库的优化参数时,这种理解让你能够设想增减某个值会产生怎样的效果。
|
||||||
|
|
||||||
尽管本章不能让你成为一个特定存储引擎的调参专家,但它至少大概率使你有了足够的概念与词汇储备去读懂你所选择的数据库的文档。
|
尽管本章不能让你成为一个特定存储引擎的调参专家,但它至少大概率使你有了足够的概念与词汇储备去读懂你所选择的数据库的文档。
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user