unify the translation of disk to hardisk, unless explicitly specified as magnetic disk

This commit is contained in:
Gang Yin 2021-12-01 13:17:46 +08:00
parent 313d25ace1
commit 41bbd53153
2 changed files with 90 additions and 90 deletions

90
ch3.md
View File

@ -63,7 +63,7 @@
42,{"name":"San Francisco","attractions":["Exploratorium"]}
```
`db_set` 函数对于极其简单的场景其实有非常好的性能,因为在文件尾部追加写入通常是非常高效的。与`db_set`做的事情类似,许多数据库在内部使用了**日志log**,也就是一个 **仅追加append-only** 的数据文件。真正的数据库有更多的问题需要处理(如并发控制,回收盘空间以避免日志无限增长,处理错误与部分写入的记录),但基本原理是一样的。日志极其有用,我们还将在本书的其它部分重复见到它好几次。
`db_set` 函数对于极其简单的场景其实有非常好的性能,因为在文件尾部追加写入通常是非常高效的。与`db_set`做的事情类似,许多数据库在内部使用了**日志log**,也就是一个 **仅追加append-only** 的数据文件。真正的数据库有更多的问题需要处理(如并发控制,回收盘空间以避免日志无限增长,处理错误与部分写入的记录),但基本原理是一样的。日志极其有用,我们还将在本书的其它部分重复见到它好几次。
> **日志log** 这个词通常指应用日志:即应用程序输出的描述正在发生的事情的文本。本书在更普遍的意义下使用**日志**这一词:一个仅追加的记录序列。它可能压根就不是给人类看的,它可以使用二进制格式,并仅能由其他程序读取。
@ -80,7 +80,7 @@
让我们从**键值数据key-value Data** 的索引开始。这不是你可以索引的唯一数据类型,但键值数据是很常见的。对于更复杂的索引来说,这也是一个有用的构建模块。
键值存储与在大多数编程语言中可以找到的**字典dictionary** 类型非常相似,通常字典都是用**散列映射hash map**(或**散列表hash table**实现的。散列映射在许多算法教科书中都有描述【1,2】所以这里我们不会讨论它的工作细节。既然我们已经可以用散列映射来表示**内存中**的数据结构,为什么不使用它来索引**盘上**的数据呢?
键值存储与在大多数编程语言中可以找到的**字典dictionary** 类型非常相似,通常字典都是用**散列映射hash map**(或**散列表hash table**实现的。散列映射在许多算法教科书中都有描述【1,2】所以这里我们不会讨论它的工作细节。既然我们已经可以用散列映射来表示**内存中**的数据结构,为什么不使用它来索引**盘上**的数据呢?
假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样,那么最简单的索引策略就是:保留一个内存中的散列映射,其中每个键都映射到数据文件中的一个字节偏移量,指明了可以找到对应值的位置,如[图3-1](img/fig3-1.png)所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用散列映射来查找数据文件中的偏移量,**寻找seek** 该位置并读取该值即可。
@ -88,11 +88,11 @@
**图3-1 以类CSV格式存储键值对的日志并使用内存散列映射进行索引。**
听上去简单但这是一个可行的方法。现实中Bitcask实际上就是这么做的Riak中默认的存储引擎【3】。 Bitcask提供高性能的读取和写入操作但要求所有的键必须能放入可用内存中因为散列映射完全保留在内存中。而数据值可以使用比可用内存更多的空间因为可以在磁盘上通过一次磁盘寻道操作来加载所需部分,如果数据文件的那部分已经在文件系统缓存中,则读取根本不需要任何盘I/O。
听上去简单但这是一个可行的方法。现实中Bitcask实际上就是这么做的Riak中默认的存储引擎【3】。 Bitcask提供高性能的读取和写入操作但要求所有的键必须能放入可用内存中因为散列映射完全保留在内存中。而数据值可以使用比可用内存更多的空间因为可以在硬盘上通过一次硬盘寻道操作来加载所需部分,如果数据文件的那部分已经在文件系统缓存中,则读取根本不需要任何盘I/O。
像Bitcask这样的存储引擎非常适合每个键的值经常更新的情况。例如键可能是某个猫咪视频的网址URL而值可能是该视频被播放的次数每次有人点击播放按钮时递增。在这种类型的工作负载中有很多写操作但是没有太多不同的键 —— 每个键有很多的写操作,但是将所有键保存在内存中是可行的。
直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完盘空间一种好的解决方案是将日志分为特定大小的段segment当日志增长到特定尺寸时关闭当前段文件并开始写入一个新的段文件。然后我们就可以对这些段进行**压缩compaction**,如[图3-2](img/fig3-2.png)所示。这里的压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。
直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完盘空间一种好的解决方案是将日志分为特定大小的段segment当日志增长到特定尺寸时关闭当前段文件并开始写入一个新的段文件。然后我们就可以对这些段进行**压缩compaction**,如[图3-2](img/fig3-2.png)所示。这里的压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。
![](img/fig3-2.png)
@ -118,7 +118,7 @@ CSV不是日志的最佳格式。使用二进制格式更快更简单
***崩溃恢复***
如果数据库重新启动,则内存散列映射将丢失。原则上,你可以通过从头到尾读取整个段文件并记录下来每个键的最近值来恢复每个段的散列映射。但是,如果段文件很大,可能需要很长时间,这会使服务的重启比较痛苦。 Bitcask 通过将每个段的散列映射的快照存储在盘上来加速恢复,可以使散列映射更快地加载到内存中。
如果数据库重新启动,则内存散列映射将丢失。原则上,你可以通过从头到尾读取整个段文件并记录下来每个键的最近值来恢复每个段的散列映射。但是,如果段文件很大,可能需要很长时间,这会使服务的重启比较痛苦。 Bitcask 通过将每个段的散列映射的快照存储在盘上来加速恢复,可以使散列映射更快地加载到内存中。
***部分写入记录***
@ -136,7 +136,7 @@ CSV不是日志的最佳格式。使用二进制格式更快更简单
但是,散列表索引也有其局限性:
* 散列表必须能放进内存。如果你有非常多的键,那真是倒霉。原则上可以在磁盘上维护一个散列映射,不幸的是磁盘散列映射很难表现优秀。它需要大量的随机访问I/O当它用满时想要再增长是很昂贵的并且散列冲突的处理也需要很烦琐的逻辑【5】。
* 散列表必须能放进内存。如果你有非常多的键,那真是倒霉。原则上可以在硬盘上维护一个散列映射,不幸的是硬盘散列映射很难表现优秀。它需要大量的随机访问I/O当它用满时想要再增长是很昂贵的并且散列冲突的处理也需要很烦琐的逻辑【5】。
* 范围查询效率不高。例如你无法轻松扫描kitty00000和kitty99999之间的所有键——你必须在散列映射中单独查找每个键。
在下一节中,我们将看到一个没有这些限制的索引结构。
@ -168,7 +168,7 @@ CSV不是日志的最佳格式。使用二进制格式更快更简单
[^i]: 如果所有的键与值都是定长的,你可以使用段文件上的二分查找并完全避免使用内存索引。然而实践中的键和值通常都是变长的,因此如果没有索引,就很难知道记录的分界点(前一条记录结束以及后一条记录开始的地方)。
3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对因此可以将这些记录分组为块block并在将其写入盘之前对其进行压缩(如[图3-5](img/fig3-5.png)中的阴影区域所示)[^译注i] 。稀疏内存索引中的每个条目都指向压缩块的开始处。除了节省盘空间之外压缩还可以减少对I/O带宽的使用。
3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对因此可以将这些记录分组为块block并在将其写入盘之前对其进行压缩(如[图3-5](img/fig3-5.png)中的阴影区域所示)[^译注i] 。稀疏内存索引中的每个条目都指向压缩块的开始处。除了节省盘空间之外压缩还可以减少对I/O带宽的使用。
[^译注i]: 这里的压缩是compression不是前文的compaction请注意区分。
@ -176,16 +176,16 @@ CSV不是日志的最佳格式。使用二进制格式更快更简单
到目前为止还不错,但是如何让你的数据能够预先排好序呢?毕竟我们接收到的写入请求可能以任何顺序发生。
虽然在盘上维护有序结构也是可能的(请参阅“[B树](#B树)”但在内存保存则要容易得多。有许多可以使用的众所周知的树形数据结构例如红黑树或AVL树【2】。使用这些数据结构你可以按任何顺序插入键并按排序顺序读取它们。
虽然在盘上维护有序结构也是可能的(请参阅“[B树](#B树)”但在内存保存则要容易得多。有许多可以使用的众所周知的树形数据结构例如红黑树或AVL树【2】。使用这些数据结构你可以按任何顺序插入键并按排序顺序读取它们。
现在我们可以让我们的存储引擎以如下方式工作:
* 有新写入时,将其添加到内存中的平衡树数据结构(例如红黑树)。这个内存树有时被称为**内存表memtable**。
* 当**内存表**大于某个阈值通常为几兆字节将其作为SSTable文件写入盘。这可以高效地完成因为树已经维护了按键排序的键值对。新的SSTable文件将成为数据库中最新的段。当该SSTable被写入盘时,新的写入可以在一个新的内存表实例上继续进行。
* 收到读取请求时,首先尝试在内存表中找到对应的键,如果没有就在最近的盘段中寻找,如果还没有就在下一个较旧的段中继续寻找,以此类推。
* 当**内存表**大于某个阈值通常为几兆字节将其作为SSTable文件写入盘。这可以高效地完成因为树已经维护了按键排序的键值对。新的SSTable文件将成为数据库中最新的段。当该SSTable被写入盘时,新的写入可以在一个新的内存表实例上继续进行。
* 收到读取请求时,首先尝试在内存表中找到对应的键,如果没有就在最近的盘段中寻找,如果还没有就在下一个较旧的段中继续寻找,以此类推。
* 时不时地,在后台运行一个合并和压缩过程,以合并段文件并将已覆盖或已删除的值丢弃掉。
这个方案效果很好。它只会遇到一个问题:如果数据库崩溃,则最近的写入(在内存表中,但尚未写入磁盘)将丢失。为了避免这个问题,我们可以在磁盘上保存一个单独的日志每个写入都会立即被追加到这个日志上就像在前面的章节中所描述的那样。这个日志没有按排序顺序但这并不重要因为它的唯一目的是在崩溃后恢复内存表。每当内存表写出到SSTable时相应的日志都可以被丢弃。
这个方案效果很好。它只会遇到一个问题:如果数据库崩溃,则最近的写入(在内存表中,但尚未写入硬盘)将丢失。为了避免这个问题,我们可以在硬盘上保存一个单独的日志每个写入都会立即被追加到这个日志上就像在前面的章节中所描述的那样。这个日志没有按排序顺序但这并不重要因为它的唯一目的是在崩溃后恢复内存表。每当内存表写出到SSTable时相应的日志都可以被丢弃。
#### 用SSTables制作LSM树
@ -197,11 +197,11 @@ Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎它使
#### 性能优化
与往常一样要让存储引擎在实践中表现良好涉及到大量设计细节。例如当查找数据库中不存在的键时LSM树算法可能会很慢你必须先检查内存表然后查看从最近的到最旧的所有的段可能还必须从盘读取每一个段文件然后才能确定这个键不存在。为了优化这种访问存储引擎通常使用额外的布隆过滤器Bloom filters【15】。 (布隆过滤器是用于近似集合内容的高效内存数据结构,它可以告诉你数据库中是不是不存在某个键,从而为不存在的键节省掉许多不必要的盘读取操作。)
与往常一样要让存储引擎在实践中表现良好涉及到大量设计细节。例如当查找数据库中不存在的键时LSM树算法可能会很慢你必须先检查内存表然后查看从最近的到最旧的所有的段可能还必须从盘读取每一个段文件然后才能确定这个键不存在。为了优化这种访问存储引擎通常使用额外的布隆过滤器Bloom filters【15】。 (布隆过滤器是用于近似集合内容的高效内存数据结构,它可以告诉你数据库中是不是不存在某个键,从而为不存在的键节省掉许多不必要的盘读取操作。)
还有一些不同的策略来确定SSTables被压缩和合并的顺序和时间。最常见的选择是size-tiered和leveled compaction。LevelDB和RocksDB使用leveled compactionLevelDB因此得名HBase使用size-tieredCassandra同时支持这两种【16】。对于sized-tiered较新和较小的SSTables相继被合并到较旧的和较大的SSTable中。对于leveled compactionkey范围被拆分到较小的SSTables而较旧的数据被移动到单独的层级level这使得压缩compaction能够更加增量地进行并且使用较少的盘空间。
还有一些不同的策略来确定SSTables被压缩和合并的顺序和时间。最常见的选择是size-tiered和leveled compaction。LevelDB和RocksDB使用leveled compactionLevelDB因此得名HBase使用size-tieredCassandra同时支持这两种【16】。对于sized-tiered较新和较小的SSTables相继被合并到较旧的和较大的SSTable中。对于leveled compactionkey范围被拆分到较小的SSTables而较旧的数据被移动到单独的层级level这使得压缩compaction能够更加增量地进行并且使用较少的盘空间。
即使有许多微妙的东西LSM树的基本思想 —— 保存一系列在后台合并的SSTables —— 简单而有效。即使数据集比可用内存大得多,它仍能继续正常工作。由于数据按排序顺序存储,你可以高效地执行范围查询(扫描所有从某个最小值到某个最大值之间的所有键),并且因为盘写入是连续的所以LSM树可以支持非常高的写入吞吐量。
即使有许多微妙的东西LSM树的基本思想 —— 保存一系列在后台合并的SSTables —— 简单而有效。即使数据集比可用内存大得多,它仍能继续正常工作。由于数据按排序顺序存储,你可以高效地执行范围查询(扫描所有从某个最小值到某个最大值之间的所有键),并且因为盘写入是连续的所以LSM树可以支持非常高的写入吞吐量。
### B树
@ -212,9 +212,9 @@ Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎它使
像SSTables一样B树保持按键排序的键值对这允许高效的键值查找和范围查询。但这也就是所有的相似之处了B树有着非常不同的设计理念。
我们前面看到的日志结构索引将数据库分解为可变大小的段通常是几兆字节或更大的大小并且总是按顺序写入段。相比之下B树将数据库分解成固定大小的块block或页面page传统上大小为4KB有时会更大并且一次只能读取或写入一个页面。这种设计更接近于底层硬件因为盘空间也是按固定大小的块来组织的。
我们前面看到的日志结构索引将数据库分解为可变大小的段通常是几兆字节或更大的大小并且总是按顺序写入段。相比之下B树将数据库分解成固定大小的块block或页面page传统上大小为4KB有时会更大并且一次只能读取或写入一个页面。这种设计更接近于底层硬件因为盘空间也是按固定大小的块来组织的。
每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如[图3-6](img/fig3-6.png)所示。
每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如[图3-6](img/fig3-6.png)所示。
![](img/fig3-6.png)
@ -228,7 +228,7 @@ Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎它使
在B树的一个页面中对子页面的引用的数量称为分支因子。例如在[图3-6](img/fig3-6.png)中分支因子是6。在实践中分支因子取决于存储页面引用和范围边界所需的空间量但通常是几百个。
如果要更新B树中现有键的值需要搜索包含该键的叶子页面更改该页面中的值并将该页面写回到盘(对该页面的任何引用都将保持有效)。如果你想添加一个新的键,你需要找到其范围能包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以反映新的键范围分区,如[图3-7](img/fig3-7.png)所示[^ii]。
如果要更新B树中现有键的值需要搜索包含该键的叶子页面更改该页面中的值并将该页面写回到盘(对该页面的任何引用都将保持有效)。如果你想添加一个新的键,你需要找到其范围能包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以反映新的键范围分区,如[图3-7](img/fig3-7.png)所示[^ii]。
![](img/fig3-7.png)
@ -246,7 +246,7 @@ B树的基本底层写操作是用新数据覆写硬盘上的页面并假定
而且,一些操作需要覆写几个不同的页面。例如,如果因为插入导致页面过满而拆分页面,则需要写入新拆分的两个页面,并覆写其父页面以更新对两个子页面的引用。这是一个危险的操作,因为如果数据库在仅有部分页面被写入时崩溃,那么最终将导致一个损坏的索引(例如,可能有一个孤儿页面不是任何父项的子项) 。
为了使数据库能处理异常崩溃的场景B树实现通常会带有一个额外的盘数据结构:**预写式日志WAL, write-ahead log**(也称为**重做日志redo log**。这是一个仅追加的文件每个B树的修改在其能被应用到树本身的页面之前都必须先写入到该文件。当数据库在崩溃后恢复时这个日志将被用来使B树恢复到一致的状态【5,20】。
为了使数据库能处理异常崩溃的场景B树实现通常会带有一个额外的盘数据结构:**预写式日志WAL, write-ahead log**(也称为**重做日志redo log**。这是一个仅追加的文件每个B树的修改在其能被应用到树本身的页面之前都必须先写入到该文件。当数据库在崩溃后恢复时这个日志将被用来使B树恢复到一致的状态【5,20】。
另外还有一个更新页面的复杂情况是如果多个线程要同时访问B树则需要仔细的并发控制 —— 否则线程可能会看到树处于不一致的状态。这通常是通过使用**锁存器latches**(轻量级锁)保护树的数据结构来完成。日志结构化的方法在这方面更简单,因为它们在后台进行所有的合并,而不会干扰新接收到的查询,并且能够时不时地将旧的段原子交换为新的段。
@ -256,9 +256,9 @@ B树的基本底层写操作是用新数据覆写硬盘上的页面并假定
* 一些数据库如LMDB使用写时复制方案【21】而不是覆盖页面并维护WAL以支持崩溃恢复。修改的页面被写入到不同的位置并且还在树中创建了父页面的新版本以指向新的位置。这种方法对于并发控制也很有用我们将在“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”中看到。
* 我们可以通过不存储整个键,而是缩短其大小,来节省页面空间。特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子,因此也就允许更少的层级[^iii]。
* 通常,页面可以放置在磁盘上的任何位置;没有什么要求相邻键范围的页面也放在磁盘上相邻的区域。如果某个查询需要按照排序顺序扫描大部分的键范围,那么这种按页面存储的布局可能会效率低下,因为每次页面读取可能都需要进行磁盘寻道。因此许多B树的实现在布局树时会尽量使叶子页面按顺序出现在磁盘上。但是随着树的增长要维持这个顺序是很困难的。相比之下由于LSM树在合并过程中一次又一次地重写存储的大部分所以它们更容易使顺序键在盘上彼此靠近。
* 通常,页面可以放置在硬盘上的任何位置;没有什么要求相邻键范围的页面也放在硬盘上相邻的区域。如果某个查询需要按照排序顺序扫描大部分的键范围,那么这种按页面存储的布局可能会效率低下,因为每次页面读取可能都需要进行硬盘寻道。因此许多B树的实现在布局树时会尽量使叶子页面按顺序出现在硬盘上。但是随着树的增长要维持这个顺序是很困难的。相比之下由于LSM树在合并过程中一次又一次地重写存储的大部分所以它们更容易使顺序键在盘上彼此靠近。
* 额外的指针已被添加到树中。例如,每个叶子页面可以引用其左边和右边的兄弟页面,使得不用跳回父页面就能按顺序对键进行扫描。
* B树的变体如分形树fractal tree【22】借用一些日志结构的思想来减少盘寻道(而且它们与分形无关)。
* B树的变体如分形树fractal tree【22】借用一些日志结构的思想来减少盘寻道(而且它们与分形无关)。
[^iii]: 这个变种有时被称为B+树但因为这个优化已被广泛使用所以经常无法区分于其它的B树变种。
@ -272,23 +272,23 @@ B树的基本底层写操作是用新数据覆写硬盘上的页面并假定
B树索引中的每块数据都必须至少写入两次一次写入预先写入日志WAL一次写入树页面本身如果有分页还需要再写入一次。即使在该页面中只有几个字节发生了变化也需要接受写入整个页面的开销。有些存储引擎甚至会覆写同一个页面两次以免在电源故障的情况下导致页面部分更新【24,25】。
由于反复压缩和合并SSTables日志结构索引也会多次重写数据。这种影响 —— 在数据库的生命周期中每次写入数据库导致对盘的多次写入 —— 被称为**写放大write amplification**。需要特别注意的是固态硬盘,固态硬盘的闪存寿命在覆写有限次数后就会耗尽。
由于反复压缩和合并SSTables日志结构索引也会多次重写数据。这种影响 —— 在数据库的生命周期中每次写入数据库导致对盘的多次写入 —— 被称为**写放大write amplification**。需要特别注意的是固态硬盘,固态硬盘的闪存寿命在覆写有限次数后就会耗尽。
在写入繁重的应用程序中,性能瓶颈可能是数据库可以写入磁盘的速度。在这种情况下,写放大会导致直接的性能代价:存储引擎写入磁盘的次数越多,可用磁盘带宽内它能处理的每秒写入次数就越少。
在写入繁重的应用程序中,性能瓶颈可能是数据库可以写入硬盘的速度。在这种情况下,写放大会导致直接的性能代价:存储引擎写入硬盘的次数越多,可用硬盘带宽内它能处理的每秒写入次数就越少。
而且LSM树通常能够比B树支持更高的写入吞吐量部分原因是它们有时具有较低的写放大尽管这取决于存储引擎的配置和工作负载部分是因为它们顺序地写入紧凑的SSTable文件而不是必须覆写树中的几个页面【26】。这种差异在磁性硬盘驱动器上尤其重要其顺序写入比随机写入要快得多。
LSM树可以被压缩得更好因此通常能比B树在盘上产生更小的文件。B树存储引擎会由于碎片化fragmentation而留下一些未使用的盘空间当页面被拆分或某行不能放入现有页面时页面中的某些空间仍未被使用。由于LSM树不是面向页面的并且会通过定期重写SSTables以去除碎片所以它们具有较低的存储开销特别是当使用分层压缩leveled compaction时【27】。
LSM树可以被压缩得更好因此通常能比B树在盘上产生更小的文件。B树存储引擎会由于碎片化fragmentation而留下一些未使用的盘空间当页面被拆分或某行不能放入现有页面时页面中的某些空间仍未被使用。由于LSM树不是面向页面的并且会通过定期重写SSTables以去除碎片所以它们具有较低的存储开销特别是当使用分层压缩leveled compaction时【27】。
在许多固态硬盘上固件内部使用了日志结构化算法以将随机写入转变为顺序写入底层存储芯片因此存储引擎写入模式的影响不太明显【19】。但是较低的写入放大率和减少的碎片仍然对固态硬盘更有利更紧凑地表示数据允许在可用的I/O带宽内处理更多的读取和写入请求。
#### LSM树的缺点
日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。尽管存储引擎尝试增量地执行压缩以尽量不影响并发访问,但是磁盘资源有限,所以很容易发生某个请求需要等待磁盘先完成昂贵的压缩操作。对吞吐量和平均响应时间的影响通常很小,但是日志结构化存储引擎在更高百分位的响应时间(请参阅“[描述性能](ch1.md#描述性能)”有时会相当长而B树的行为则相对更具可预测性【28】。
日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。尽管存储引擎尝试增量地执行压缩以尽量不影响并发访问,但是硬盘资源有限,所以很容易发生某个请求需要等待硬盘先完成昂贵的压缩操作。对吞吐量和平均响应时间的影响通常很小,但是日志结构化存储引擎在更高百分位的响应时间(请参阅“[描述性能](ch1.md#描述性能)”有时会相当长而B树的行为则相对更具可预测性【28】。
压缩的另一个问题出现在高写入吞吐量时:磁盘的有限写入带宽需要在初始写入(记录日志和刷新内存表到磁盘)和在后台运行的压缩线程之间共享。写入空数据库时,可以使用全磁盘带宽进行初始写入,但数据库越大,压缩所需的磁盘带宽就越多。
压缩的另一个问题出现在高写入吞吐量时:硬盘的有限写入带宽需要在初始写入(记录日志和刷新内存表到硬盘)和在后台运行的压缩线程之间共享。写入空数据库时,可以使用全硬盘带宽进行初始写入,但数据库越大,压缩所需的硬盘带宽就越多。
如果写入吞吐量很高,并且压缩没有仔细配置好,有可能导致压缩跟不上写入速率。在这种情况下,磁盘上未合并段的数量不断增加,直到磁盘空间用完读取速度也会减慢因为它们需要检查更多的段文件。通常情况下即使压缩无法跟上基于SSTable的存储引擎也不会限制传入写入的速率所以你需要进行明确的监控来检测这种情况【29,30】。
如果写入吞吐量很高,并且压缩没有仔细配置好,有可能导致压缩跟不上写入速率。在这种情况下,硬盘上未合并段的数量不断增加,直到硬盘空间用完读取速度也会减慢因为它们需要检查更多的段文件。通常情况下即使压缩无法跟上基于SSTable的存储引擎也不会限制传入写入的速率所以你需要进行明确的监控来检测这种情况【29,30】。
B树的一个优点是每个键只存在于索引中的一个位置而日志结构化的存储引擎可能在不同的段中有相同键的多个副本。这个方面使得B树在想要提供强大的事务语义的数据库中很有吸引力在许多关系数据库中事务隔离是通过在键范围上使用锁来实现的在B树索引中这些锁可以直接附加到树上【5】。在[第七章](ch7.md)中,我们将更详细地讨论这一点。
@ -345,21 +345,21 @@ B树在数据库架构中是非常根深蒂固的为许多工作负载都提
#### 在内存中存储一切
本章到目前为止讨论的数据结构都是对磁盘限制的应对。与主内存相比磁盘处理起来很麻烦。对于磁盘和SSD如果要在读取和写入时获得良好性能则需要仔细地布置磁盘上的数据。但是我们能容忍这种麻烦因为磁盘有两个显著的优点它们是持久的它们的内容在电源关闭时不会丢失并且每GB的成本比RAM低。
本章到目前为止讨论的数据结构都是对硬盘限制的应对。与主内存相比,硬盘处理起来很麻烦。对于磁性硬盘和固态硬盘,如果要在读取和写入时获得良好性能,则需要仔细地布置硬盘上的数据。但是,我们能容忍这种麻烦,因为硬盘有两个显著的优点它们是持久的它们的内容在电源关闭时不会丢失并且每GB的成本比RAM低。
随着RAM变得更便宜每GB成本的论据被侵蚀了。许多数据集不是那么大所以将它们全部保存在内存中是非常可行的包括可能分布在多个机器上。这导致了内存数据库的发展。
某些内存中的键值存储如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通过异步写入硬盘提供了较弱的持久性。
反直觉的是,内存数据库的性能优势并不是因为它们不需要从磁盘读取的事实。只要有足够的内存即使是基于磁盘的存储引擎也可能永远不需要从磁盘读取,因为操作系统在内存中缓存了最近使用的磁盘块。相反,它们更快的原因在于省去了将内存数据结构编码为磁盘数据结构的开销【44】。
反直觉的是,内存数据库的性能优势并不是因为它们不需要从硬盘读取的事实。只要有足够的内存即使是基于硬盘的存储引擎也可能永远不需要从硬盘读取,因为操作系统在内存中缓存了最近使用的硬盘块。相反,它们更快的原因在于省去了将内存数据结构编码为硬盘数据结构的开销【44】。
除了性能,内存数据库的另一个有趣的地方是提供了难以用基于盘的索引实现的数据模型。例如Redis为各种数据结构如优先级队列和集合提供了类似数据库的接口。因为它将所有数据保存在内存中所以它的实现相对简单。
除了性能,内存数据库的另一个有趣的地方是提供了难以用基于盘的索引实现的数据模型。例如Redis为各种数据结构如优先级队列和集合提供了类似数据库的接口。因为它将所有数据保存在内存中所以它的实现相对简单。
最近的研究表明,内存数据库体系结构可以扩展到支持比可用内存更大的数据集,而不必重新采用以盘为中心的体系结构【45】。所谓的 **反缓存anti-caching** 方法通过在内存不足的情况下将最近最少使用的数据从内存转移到并在将来再次访问时将其重新加载到内存中。这与操作系统对虚拟内存和交换文件的操作类似但数据库可以比操作系统更有效地管理内存因为它可以按单个记录的粒度工作而不是整个内存页面。尽管如此这种方法仍然需要索引能完全放入内存中就像本章开头的Bitcask例子
最近的研究表明,内存数据库体系结构可以扩展到支持比可用内存更大的数据集,而不必重新采用以盘为中心的体系结构【45】。所谓的 **反缓存anti-caching** 方法通过在内存不足的情况下将最近最少使用的数据从内存转移到并在将来再次访问时将其重新加载到内存中。这与操作系统对虚拟内存和交换文件的操作类似但数据库可以比操作系统更有效地管理内存因为它可以按单个记录的粒度工作而不是整个内存页面。尽管如此这种方法仍然需要索引能完全放入内存中就像本章开头的Bitcask例子
如果 **非易失性存储器non-volatile memory, NVM** 技术得到更广泛的应用可能还需要进一步改变存储引擎设计【46】。目前这是一个新的研究领域值得关注。
@ -470,7 +470,7 @@ GROUP BY
在大多数OLTP数据库中存储都是以面向行的方式进行布局的表格的一行中的所有值都相邻存储。文档数据库是相似的整个文档通常存储为一个连续的字节序列。你可以在[图3-1](img/fig3-1.png)的CSV例子中看到这个。
为了处理像[例3-1]()这样的查询,你可能在 `fact_sales.date_key` `fact_sales.product_sk`上有索引它们告诉存储引擎在哪里查找特定日期或特定产品的所有销售情况。但是面向行的存储引擎仍然需要将所有这些行每个包含超过100个属性盘加载到内存中,解析它们,并过滤掉那些不符合要求的属性。这可能需要很长时间。
为了处理像[例3-1]()这样的查询,你可能在 `fact_sales.date_key` `fact_sales.product_sk`上有索引它们告诉存储引擎在哪里查找特定日期或特定产品的所有销售情况。但是面向行的存储引擎仍然需要将所有这些行每个包含超过100个属性盘加载到内存中,解析它们,并过滤掉那些不符合要求的属性。这可能需要很长时间。
面向列的存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如[图3-10](img/fig3-10.png)所示。
@ -485,7 +485,7 @@ GROUP BY
### 列压缩
除了仅从磁盘加载查询所需的列以外,我们还可以通过压缩数据来进一步降低对磁盘吞吐量的需求。幸运的是,面向列的存储通常很适合压缩。
除了仅从硬盘加载查询所需的列以外,我们还可以通过压缩数据来进一步降低对硬盘吞吐量的需求。幸运的是,面向列的存储通常很适合压缩。
看看[图3-10](img/fig3-10.png)中每一列的值序列:它们通常看起来是相当重复的,这是压缩的好兆头。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如[图3-11](img/fig3-11.png)所示。
@ -520,9 +520,9 @@ WHERE product_sk = 31 AND store_sk = 3
#### 内存带宽和向量处理
对于需要扫描数百万行的数据仓库查询来说,一个巨大的瓶颈是从盘获取数据到内存的带宽。但是这不是唯一的瓶颈。分析数据库的开发人员也担心有效利用主存储器带宽到CPU缓存中的带宽避免CPU指令处理流水线中的分支错误预测和泡沫以及在现代中使用单指令多数据SIMD指令CPU 【59,60】。
对于需要扫描数百万行的数据仓库查询来说,一个巨大的瓶颈是从盘获取数据到内存的带宽。但是这不是唯一的瓶颈。分析数据库的开发人员也担心有效利用主存储器带宽到CPU缓存中的带宽避免CPU指令处理流水线中的分支错误预测和泡沫以及在现代中使用单指令多数据SIMD指令CPU 【59,60】。
除了减少需要从盘加载的数据量以外面向列的存储布局也可以有效利用CPU周期。例如查询引擎可以将大量压缩的列数据放在CPU的L1缓存中然后在紧密的循环中循环即没有函数调用。相比较每个记录的处理都需要大量函数调用和条件判断的代码CPU执行这样一个循环要快得多。列压缩允许列中的更多行适合相同数量的L1缓存。前面描述的按位“与”和“或”运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为矢量化处理【58,49】。
除了减少需要从盘加载的数据量以外面向列的存储布局也可以有效利用CPU周期。例如查询引擎可以将大量压缩的列数据放在CPU的L1缓存中然后在紧密的循环中循环即没有函数调用。相比较每个记录的处理都需要大量函数调用和条件判断的代码CPU执行这样一个循环要快得多。列压缩允许列中的更多行适合相同数量的L1缓存。前面描述的按位“与”和“或”运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为矢量化处理【58,49】。
### 列存储中的排序顺序
@ -551,9 +551,9 @@ WHERE product_sk = 31 AND store_sk = 3
使用B树的就地更新方法对于压缩的列是不可能的。如果你想在排序表的中间插入一行你很可能不得不重写所有的列文件。由于行由列中的位置标识因此插入必须始终更新所有列。
幸运的是本章前面已经看到了一个很好的解决方案LSM树。所有的写操作首先进入一个内存中的存储在这里它们被添加到一个已排序的结构中并准备写入盘。内存中的存储是面向行还是列的,这并不重要。当已经积累了足够的写入数据时,它们将与盘上的列文件合并并批量写入新文件。这基本上是Vertica所做的【62】。
幸运的是本章前面已经看到了一个很好的解决方案LSM树。所有的写操作首先进入一个内存中的存储在这里它们被添加到一个已排序的结构中并准备写入盘。内存中的存储是面向行还是列的,这并不重要。当已经积累了足够的写入数据时,它们将与盘上的列文件合并并批量写入新文件。这基本上是Vertica所做的【62】。
查询需要检查盘上的列数据和最近在内存中的写入,并将两者结合起来。但是,查询优化器隐藏了用户的这个区别。从分析师的角度来看,通过插入,更新或删除操作进行修改的数据会立即反映在后续查询中。
查询需要检查盘上的列数据和最近在内存中的写入,并将两者结合起来。但是,查询优化器隐藏了用户的这个区别。从分析师的角度来看,通过插入,更新或删除操作进行修改的数据会立即反映在后续查询中。
### 聚合:数据立方体和物化视图
@ -561,7 +561,7 @@ WHERE product_sk = 31 AND store_sk = 3
数据仓库的另一个值得一提的是物化汇总。如前所述数据仓库查询通常涉及一个聚合函数如SQL中的COUNTSUMAVGMIN或MAX。如果相同的聚合被许多不同的查询使用那么每次都可以通过原始数据来处理。为什么不缓存一些查询使用最频繁的计数或总和
创建这种缓存的一种方式是物化视图Materialized View。在关系数据模型中它通常被定义为一个标准虚拟视图一个类似于表的对象其内容是一些查询的结果。不同的是物化视图是查询结果的实际副本写入而虚拟视图只是写入查询的捷径。从虚拟视图读取时SQL引擎会将其展开到视图的底层查询中然后处理展开的查询。
创建这种缓存的一种方式是物化视图Materialized View。在关系数据模型中它通常被定义为一个标准虚拟视图一个类似于表的对象其内容是一些查询的结果。不同的是物化视图是查询结果的实际副本写入而虚拟视图只是写入查询的捷径。从虚拟视图读取时SQL引擎会将其展开到视图的底层查询中然后处理展开的查询。
当底层数据发生变化时物化视图需要更新因为它是数据的非规范化副本。数据库可以自动完成但是这样的更新使得写入成本更高这就是在OLTP数据库中不经常使用物化视图的原因。在读取繁重的数据仓库中它们可能更有意义它们是否实际上改善了读取性能取决于个别情况
@ -586,8 +586,8 @@ WHERE product_sk = 31 AND store_sk = 3
在高层次上,我们看到存储引擎分为两大类:优化 **事务处理OLTP****在线分析OLAP** 。这些用例的访问模式之间有很大的区别:
* OLTP系统通常面向用户这意味着系统可能会收到大量的请求。为了处理负载应用程序通常只访问每个查询中的少部分记录。应用程序使用某种键来请求记录存储引擎使用索引来查找所请求的键的数据。盘寻道时间往往是这里的瓶颈。
* 数据仓库和类似的分析系统会低调一些因为它们主要由业务分析人员使用而不是由最终用户使用。它们的查询量要比OLTP系统少得多但通常每个查询开销高昂需要在短时间内扫描数百万条记录。盘带宽(而不是查找时间)往往是瓶颈,列式存储是这种工作负载越来越流行的解决方案。
* OLTP系统通常面向用户这意味着系统可能会收到大量的请求。为了处理负载应用程序通常只访问每个查询中的少部分记录。应用程序使用某种键来请求记录存储引擎使用索引来查找所请求的键的数据。盘寻道时间往往是这里的瓶颈。
* 数据仓库和类似的分析系统会低调一些因为它们主要由业务分析人员使用而不是由最终用户使用。它们的查询量要比OLTP系统少得多但通常每个查询开销高昂需要在短时间内扫描数百万条记录。盘带宽(而不是查找时间)往往是瓶颈,列式存储是这种工作负载越来越流行的解决方案。
在OLTP方面我们能看到两派主流的存储引擎
@ -597,11 +597,11 @@ WHERE product_sk = 31 AND store_sk = 3
***就地更新学派***
盘视为一组可以覆写的固定大小的页面。 B树是这种哲学的典范用在所有主要的关系数据库中和许多非关系型数据库。
盘视为一组可以覆写的固定大小的页面。 B树是这种哲学的典范用在所有主要的关系数据库中和许多非关系型数据库。
日志结构的存储引擎是相对较新的发展。他们的主要想法是,他们系统地将随机访问写入转换为盘上的顺序写入由于硬盘驱动器和固态硬盘的性能特点可以实现更高的写入吞吐量。在完成OLTP方面我们通过一些更复杂的索引结构和为保留所有数据而优化的数据库做了一个简短的介绍。
日志结构的存储引擎是相对较新的发展。他们的主要想法是,他们系统地将随机访问写入转换为盘上的顺序写入由于硬盘驱动器和固态硬盘的性能特点可以实现更高的写入吞吐量。在完成OLTP方面我们通过一些更复杂的索引结构和为保留所有数据而优化的数据库做了一个简短的介绍。
然后我们从存储引擎的内部绕开看看典型数据仓库的高级架构。这一背景说明了为什么分析工作负载与OLTP差别很大当你的查询需要在大量行中顺序扫描时索引的相关性就会降低很多。相反非常紧凑地编码数据变得非常重要以最大限度地减少查询需要从盘读取的数据量。我们讨论了列式存储如何帮助实现这一目标。
然后我们从存储引擎的内部绕开看看典型数据仓库的高级架构。这一背景说明了为什么分析工作负载与OLTP差别很大当你的查询需要在大量行中顺序扫描时索引的相关性就会降低很多。相反非常紧凑地编码数据变得非常重要以最大限度地减少查询需要从盘读取的数据量。我们讨论了列式存储如何帮助实现这一目标。
作为一名应用程序开发人员,如果你掌握了有关存储引擎内部的知识,那么你就能更好地了解哪种工具最适合你的特定应用程序。如果你需要调整数据库的调整参数,这种理解可以让你设想一个更高或更低的值可能会产生什么效果。

View File

@ -63,7 +63,7 @@
42,{"name":"San Francisco","attractions":["Exploratorium"]}
```
`db_set` 函式對於極其簡單的場景其實有非常好的效能,因為在檔案尾部追加寫入通常是非常高效的。與`db_set`做的事情類似,許多資料庫在內部使用了**日誌log**,也就是一個 **僅追加append-only** 的資料檔案。真正的資料庫有更多的問題需要處理(如併發控制,回收碟空間以避免日誌無限增長,處理錯誤與部分寫入的記錄),但基本原理是一樣的。日誌極其有用,我們還將在本書的其它部分重複見到它好幾次。
`db_set` 函式對於極其簡單的場景其實有非常好的效能,因為在檔案尾部追加寫入通常是非常高效的。與`db_set`做的事情類似,許多資料庫在內部使用了**日誌log**,也就是一個 **僅追加append-only** 的資料檔案。真正的資料庫有更多的問題需要處理(如併發控制,回收碟空間以避免日誌無限增長,處理錯誤與部分寫入的記錄),但基本原理是一樣的。日誌極其有用,我們還將在本書的其它部分重複見到它好幾次。
> **日誌log** 這個詞通常指應用日誌:即應用程式輸出的描述正在發生的事情的文字。本書在更普遍的意義下使用**日誌**這一詞:一個僅追加的記錄序列。它可能壓根就不是給人類看的,它可以使用二進位制格式,並僅能由其他程式讀取。
@ -80,7 +80,7 @@
讓我們從**鍵值資料key-value Data** 的索引開始。這不是你可以索引的唯一資料型別,但鍵值資料是很常見的。對於更復雜的索引來說,這也是一個有用的構建模組。
鍵值儲存與在大多數程式語言中可以找到的**字典dictionary** 型別非常相似,通常字典都是用**雜湊對映hash map**(或**散列表hash table**實現的。雜湊對映在許多演算法教科書中都有描述【1,2】所以這裡我們不會討論它的工作細節。既然我們已經可以用雜湊對映來表示**記憶體中**的資料結構,為什麼不使用它來索引**碟上**的資料呢?
鍵值儲存與在大多數程式語言中可以找到的**字典dictionary** 型別非常相似,通常字典都是用**雜湊對映hash map**(或**散列表hash table**實現的。雜湊對映在許多演算法教科書中都有描述【1,2】所以這裡我們不會討論它的工作細節。既然我們已經可以用雜湊對映來表示**記憶體中**的資料結構,為什麼不使用它來索引**碟上**的資料呢?
假設我們的資料儲存只是一個追加寫入的檔案,就像前面的例子一樣,那麼最簡單的索引策略就是:保留一個記憶體中的雜湊對映,其中每個鍵都對映到資料檔案中的一個位元組偏移量,指明瞭可以找到對應值的位置,如[圖3-1](../img/fig3-1.png)所示。當你將新的鍵值對追加寫入檔案中時,還要更新雜湊對映,以反映剛剛寫入的資料的偏移量(這同時適用於插入新鍵與更新現有鍵)。當你想查詢一個值時,使用雜湊對映來查詢資料檔案中的偏移量,**尋找seek** 該位置並讀取該值即可。
@ -88,11 +88,11 @@
**圖3-1 以類CSV格式儲存鍵值對的日誌並使用記憶體雜湊對映進行索引。**
聽上去簡單但這是一個可行的方法。現實中Bitcask實際上就是這麼做的Riak中預設的儲存引擎【3】。 Bitcask提供高效能的讀取和寫入操作但要求所有的鍵必須能放入可用記憶體中因為雜湊對映完全保留在記憶體中。而資料值可以使用比可用記憶體更多的空間因為可以在磁碟上透過一次磁碟尋道操作來載入所需部分,如果資料檔案的那部分已經在檔案系統快取中,則讀取根本不需要任何碟I/O。
聽上去簡單但這是一個可行的方法。現實中Bitcask實際上就是這麼做的Riak中預設的儲存引擎【3】。 Bitcask提供高效能的讀取和寫入操作但要求所有的鍵必須能放入可用記憶體中因為雜湊對映完全保留在記憶體中。而資料值可以使用比可用記憶體更多的空間因為可以在硬碟上透過一次硬碟尋道操作來載入所需部分,如果資料檔案的那部分已經在檔案系統快取中,則讀取根本不需要任何碟I/O。
像Bitcask這樣的儲存引擎非常適合每個鍵的值經常更新的情況。例如鍵可能是某個貓咪影片的網址URL而值可能是該影片被播放的次數每次有人點選播放按鈕時遞增。在這種型別的工作負載中有很多寫操作但是沒有太多不同的鍵 —— 每個鍵有很多的寫操作,但是將所有鍵儲存在記憶體中是可行的。
直到現在,我們只是追加寫入一個檔案 —— 所以如何避免最終用完碟空間一種好的解決方案是將日誌分為特定大小的段segment當日志增長到特定尺寸時關閉當前段檔案並開始寫入一個新的段檔案。然後我們就可以對這些段進行**壓縮compaction**,如[圖3-2](../img/fig3-2.png)所示。這裡的壓縮意味著在日誌中丟棄重複的鍵,只保留每個鍵的最近更新。
直到現在,我們只是追加寫入一個檔案 —— 所以如何避免最終用完碟空間一種好的解決方案是將日誌分為特定大小的段segment當日志增長到特定尺寸時關閉當前段檔案並開始寫入一個新的段檔案。然後我們就可以對這些段進行**壓縮compaction**,如[圖3-2](../img/fig3-2.png)所示。這裡的壓縮意味著在日誌中丟棄重複的鍵,只保留每個鍵的最近更新。
![](../img/fig3-2.png)
@ -118,7 +118,7 @@ CSV不是日誌的最佳格式。使用二進位制格式更快更簡單
***崩潰恢復***
如果資料庫重新啟動,則記憶體雜湊對映將丟失。原則上,你可以透過從頭到尾讀取整個段檔案並記錄下來每個鍵的最近值來恢復每個段的雜湊對映。但是,如果段檔案很大,可能需要很長時間,這會使服務的重啟比較痛苦。 Bitcask 透過將每個段的雜湊對映的快照儲存在碟上來加速恢復,可以使雜湊對映更快地載入到記憶體中。
如果資料庫重新啟動,則記憶體雜湊對映將丟失。原則上,你可以透過從頭到尾讀取整個段檔案並記錄下來每個鍵的最近值來恢復每個段的雜湊對映。但是,如果段檔案很大,可能需要很長時間,這會使服務的重啟比較痛苦。 Bitcask 透過將每個段的雜湊對映的快照儲存在碟上來加速恢復,可以使雜湊對映更快地載入到記憶體中。
***部分寫入記錄***
@ -136,7 +136,7 @@ CSV不是日誌的最佳格式。使用二進位制格式更快更簡單
但是,散列表索引也有其侷限性:
* 散列表必須能放進記憶體。如果你有非常多的鍵,那真是倒黴。原則上可以在磁碟上維護一個雜湊對映,不幸的是磁碟雜湊對映很難表現優秀。它需要大量的隨機訪問I/O當它用滿時想要再增長是很昂貴的並且雜湊衝突的處理也需要很煩瑣的邏輯【5】。
* 散列表必須能放進記憶體。如果你有非常多的鍵,那真是倒黴。原則上可以在硬碟上維護一個雜湊對映,不幸的是硬碟雜湊對映很難表現優秀。它需要大量的隨機訪問I/O當它用滿時想要再增長是很昂貴的並且雜湊衝突的處理也需要很煩瑣的邏輯【5】。
* 範圍查詢效率不高。例如你無法輕鬆掃描kitty00000和kitty99999之間的所有鍵——你必須在雜湊對映中單獨查詢每個鍵。
在下一節中,我們將看到一個沒有這些限制的索引結構。
@ -168,7 +168,7 @@ CSV不是日誌的最佳格式。使用二進位制格式更快更簡單
[^i]: 如果所有的鍵與值都是定長的,你可以使用段檔案上的二分查詢並完全避免使用記憶體索引。然而實踐中的鍵和值通常都是變長的,因此如果沒有索引,就很難知道記錄的分界點(前一條記錄結束以及後一條記錄開始的地方)。
3. 由於讀取請求無論如何都需要掃描所請求範圍內的多個鍵值對因此可以將這些記錄分組為塊block並在將其寫入碟之前對其進行壓縮(如[圖3-5](../img/fig3-5.png)中的陰影區域所示)[^譯註i] 。稀疏記憶體索引中的每個條目都指向壓縮塊的開始處。除了節省碟空間之外壓縮還可以減少對I/O頻寬的使用。
3. 由於讀取請求無論如何都需要掃描所請求範圍內的多個鍵值對因此可以將這些記錄分組為塊block並在將其寫入碟之前對其進行壓縮(如[圖3-5](../img/fig3-5.png)中的陰影區域所示)[^譯註i] 。稀疏記憶體索引中的每個條目都指向壓縮塊的開始處。除了節省碟空間之外壓縮還可以減少對I/O頻寬的使用。
[^譯註i]: 這裡的壓縮是compression不是前文的compaction請注意區分。
@ -176,16 +176,16 @@ CSV不是日誌的最佳格式。使用二進位制格式更快更簡單
到目前為止還不錯,但是如何讓你的資料能夠預先排好序呢?畢竟我們接收到的寫入請求可能以任何順序發生。
雖然在碟上維護有序結構也是可能的(請參閱“[B樹](#B樹)”但在記憶體儲存則要容易得多。有許多可以使用的眾所周知的樹形資料結構例如紅黑樹或AVL樹【2】。使用這些資料結構你可以按任何順序插入鍵並按排序順序讀取它們。
雖然在碟上維護有序結構也是可能的(請參閱“[B樹](#B樹)”但在記憶體儲存則要容易得多。有許多可以使用的眾所周知的樹形資料結構例如紅黑樹或AVL樹【2】。使用這些資料結構你可以按任何順序插入鍵並按排序順序讀取它們。
現在我們可以讓我們的儲存引擎以如下方式工作:
* 有新寫入時,將其新增到記憶體中的平衡樹資料結構(例如紅黑樹)。這個記憶體樹有時被稱為**記憶體表memtable**。
* 當**記憶體表**大於某個閾值通常為幾兆位元組將其作為SSTable檔案寫入碟。這可以高效地完成因為樹已經維護了按鍵排序的鍵值對。新的SSTable檔案將成為資料庫中最新的段。當該SSTable被寫入碟時,新的寫入可以在一個新的記憶體表例項上繼續進行。
* 收到讀取請求時,首先嚐試在記憶體表中找到對應的鍵,如果沒有就在最近的碟段中尋找,如果還沒有就在下一個較舊的段中繼續尋找,以此類推。
* 當**記憶體表**大於某個閾值通常為幾兆位元組將其作為SSTable檔案寫入碟。這可以高效地完成因為樹已經維護了按鍵排序的鍵值對。新的SSTable檔案將成為資料庫中最新的段。當該SSTable被寫入碟時,新的寫入可以在一個新的記憶體表例項上繼續進行。
* 收到讀取請求時,首先嚐試在記憶體表中找到對應的鍵,如果沒有就在最近的碟段中尋找,如果還沒有就在下一個較舊的段中繼續尋找,以此類推。
* 時不時地,在後臺執行一個合併和壓縮過程,以合併段檔案並將已覆蓋或已刪除的值丟棄掉。
這個方案效果很好。它只會遇到一個問題:如果資料庫崩潰,則最近的寫入(在記憶體表中,但尚未寫入磁碟)將丟失。為了避免這個問題,我們可以在磁碟上儲存一個單獨的日誌每個寫入都會立即被追加到這個日誌上就像在前面的章節中所描述的那樣。這個日誌沒有按排序順序但這並不重要因為它的唯一目的是在崩潰後恢復記憶體表。每當記憶體表寫出到SSTable時相應的日誌都可以被丟棄。
這個方案效果很好。它只會遇到一個問題:如果資料庫崩潰,則最近的寫入(在記憶體表中,但尚未寫入硬碟)將丟失。為了避免這個問題,我們可以在硬碟上儲存一個單獨的日誌每個寫入都會立即被追加到這個日誌上就像在前面的章節中所描述的那樣。這個日誌沒有按排序順序但這並不重要因為它的唯一目的是在崩潰後恢復記憶體表。每當記憶體表寫出到SSTable時相應的日誌都可以被丟棄。
#### 用SSTables製作LSM樹
@ -197,11 +197,11 @@ Lucene是Elasticsearch和Solr使用的一種全文搜尋的索引引擎它使
#### 效能最佳化
與往常一樣要讓儲存引擎在實踐中表現良好涉及到大量設計細節。例如當查詢資料庫中不存在的鍵時LSM樹演算法可能會很慢你必須先檢查記憶體表然後檢視從最近的到最舊的所有的段可能還必須從碟讀取每一個段檔案然後才能確定這個鍵不存在。為了最佳化這種訪問儲存引擎通常使用額外的布隆過濾器Bloom filters【15】。 (布隆過濾器是用於近似集合內容的高效記憶體資料結構,它可以告訴你資料庫中是不是不存在某個鍵,從而為不存在的鍵節省掉許多不必要的碟讀取操作。)
與往常一樣要讓儲存引擎在實踐中表現良好涉及到大量設計細節。例如當查詢資料庫中不存在的鍵時LSM樹演算法可能會很慢你必須先檢查記憶體表然後檢視從最近的到最舊的所有的段可能還必須從碟讀取每一個段檔案然後才能確定這個鍵不存在。為了最佳化這種訪問儲存引擎通常使用額外的布隆過濾器Bloom filters【15】。 (布隆過濾器是用於近似集合內容的高效記憶體資料結構,它可以告訴你資料庫中是不是不存在某個鍵,從而為不存在的鍵節省掉許多不必要的碟讀取操作。)
還有一些不同的策略來確定SSTables被壓縮和合並的順序和時間。最常見的選擇是size-tiered和leveled compaction。LevelDB和RocksDB使用leveled compactionLevelDB因此得名HBase使用size-tieredCassandra同時支援這兩種【16】。對於sized-tiered較新和較小的SSTables相繼被合併到較舊的和較大的SSTable中。對於leveled compactionkey範圍被拆分到較小的SSTables而較舊的資料被移動到單獨的層級level這使得壓縮compaction能夠更加增量地進行並且使用較少的碟空間。
還有一些不同的策略來確定SSTables被壓縮和合並的順序和時間。最常見的選擇是size-tiered和leveled compaction。LevelDB和RocksDB使用leveled compactionLevelDB因此得名HBase使用size-tieredCassandra同時支援這兩種【16】。對於sized-tiered較新和較小的SSTables相繼被合併到較舊的和較大的SSTable中。對於leveled compactionkey範圍被拆分到較小的SSTables而較舊的資料被移動到單獨的層級level這使得壓縮compaction能夠更加增量地進行並且使用較少的碟空間。
即使有許多微妙的東西LSM樹的基本思想 —— 儲存一系列在後臺合併的SSTables —— 簡單而有效。即使資料集比可用記憶體大得多,它仍能繼續正常工作。由於資料按排序順序儲存,你可以高效地執行範圍查詢(掃描所有從某個最小值到某個最大值之間的所有鍵),並且因為碟寫入是連續的所以LSM樹可以支援非常高的寫入吞吐量。
即使有許多微妙的東西LSM樹的基本思想 —— 儲存一系列在後臺合併的SSTables —— 簡單而有效。即使資料集比可用記憶體大得多,它仍能繼續正常工作。由於資料按排序順序儲存,你可以高效地執行範圍查詢(掃描所有從某個最小值到某個最大值之間的所有鍵),並且因為碟寫入是連續的所以LSM樹可以支援非常高的寫入吞吐量。
### B樹
@ -212,9 +212,9 @@ Lucene是Elasticsearch和Solr使用的一種全文搜尋的索引引擎它使
像SSTables一樣B樹保持按鍵排序的鍵值對這允許高效的鍵值查詢和範圍查詢。但這也就是所有的相似之處了B樹有著非常不同的設計理念。
我們前面看到的日誌結構索引將資料庫分解為可變大小的段通常是幾兆位元組或更大的大小並且總是按順序寫入段。相比之下B樹將資料庫分解成固定大小的塊block或頁面page傳統上大小為4KB有時會更大並且一次只能讀取或寫入一個頁面。這種設計更接近於底層硬體因為碟空間也是按固定大小的塊來組織的。
我們前面看到的日誌結構索引將資料庫分解為可變大小的段通常是幾兆位元組或更大的大小並且總是按順序寫入段。相比之下B樹將資料庫分解成固定大小的塊block或頁面page傳統上大小為4KB有時會更大並且一次只能讀取或寫入一個頁面。這種設計更接近於底層硬體因為碟空間也是按固定大小的塊來組織的。
每個頁面都可以使用地址或位置來標識,這允許一個頁面引用另一個頁面 —— 類似於指標,但在碟而不是在記憶體中。我們可以使用這些頁面引用來構建一個頁面樹,如[圖3-6](../img/fig3-6.png)所示。
每個頁面都可以使用地址或位置來標識,這允許一個頁面引用另一個頁面 —— 類似於指標,但在碟而不是在記憶體中。我們可以使用這些頁面引用來構建一個頁面樹,如[圖3-6](../img/fig3-6.png)所示。
![](../img/fig3-6.png)
@ -228,7 +228,7 @@ Lucene是Elasticsearch和Solr使用的一種全文搜尋的索引引擎它使
在B樹的一個頁面中對子頁面的引用的數量稱為分支因子。例如在[圖3-6](../img/fig3-6.png)中分支因子是6。在實踐中分支因子取決於儲存頁面引用和範圍邊界所需的空間量但通常是幾百個。
如果要更新B樹中現有鍵的值需要搜尋包含該鍵的葉子頁面更改該頁面中的值並將該頁面寫回到碟(對該頁面的任何引用都將保持有效)。如果你想新增一個新的鍵,你需要找到其範圍能包含新鍵的頁面,並將其新增到該頁面。如果頁面中沒有足夠的可用空間容納新鍵,則將其分成兩個半滿頁面,並更新父頁面以反映新的鍵範圍分割槽,如[圖3-7](../img/fig3-7.png)所示[^ii]。
如果要更新B樹中現有鍵的值需要搜尋包含該鍵的葉子頁面更改該頁面中的值並將該頁面寫回到碟(對該頁面的任何引用都將保持有效)。如果你想新增一個新的鍵,你需要找到其範圍能包含新鍵的頁面,並將其新增到該頁面。如果頁面中沒有足夠的可用空間容納新鍵,則將其分成兩個半滿頁面,並更新父頁面以反映新的鍵範圍分割槽,如[圖3-7](../img/fig3-7.png)所示[^ii]。
![](../img/fig3-7.png)
@ -246,7 +246,7 @@ B樹的基本底層寫操作是用新資料覆寫硬碟上的頁面並假定
而且,一些操作需要覆寫幾個不同的頁面。例如,如果因為插入導致頁面過滿而拆分頁面,則需要寫入新拆分的兩個頁面,並覆寫其父頁面以更新對兩個子頁面的引用。這是一個危險的操作,因為如果資料庫在僅有部分頁面被寫入時崩潰,那麼最終將導致一個損壞的索引(例如,可能有一個孤兒頁面不是任何父項的子項) 。
為了使資料庫能處理異常崩潰的場景B樹實現通常會帶有一個額外的碟資料結構:**預寫式日誌WAL, write-ahead log**(也稱為**重做日誌redo log**。這是一個僅追加的檔案每個B樹的修改在其能被應用到樹本身的頁面之前都必須先寫入到該檔案。當資料庫在崩潰後恢復時這個日誌將被用來使B樹恢復到一致的狀態【5,20】。
為了使資料庫能處理異常崩潰的場景B樹實現通常會帶有一個額外的碟資料結構:**預寫式日誌WAL, write-ahead log**(也稱為**重做日誌redo log**。這是一個僅追加的檔案每個B樹的修改在其能被應用到樹本身的頁面之前都必須先寫入到該檔案。當資料庫在崩潰後恢復時這個日誌將被用來使B樹恢復到一致的狀態【5,20】。
另外還有一個更新頁面的複雜情況是如果多個執行緒要同時訪問B樹則需要仔細的併發控制 —— 否則執行緒可能會看到樹處於不一致的狀態。這通常是透過使用**鎖存器latches**(輕量級鎖)保護樹的資料結構來完成。日誌結構化的方法在這方面更簡單,因為它們在後臺進行所有的合併,而不會干擾新接收到的查詢,並且能夠時不時地將舊的段原子交換為新的段。
@ -256,9 +256,9 @@ B樹的基本底層寫操作是用新資料覆寫硬碟上的頁面並假定
* 一些資料庫如LMDB使用寫時複製方案【21】而不是覆蓋頁面並維護WAL以支援崩潰恢復。修改的頁面被寫入到不同的位置並且還在樹中建立了父頁面的新版本以指向新的位置。這種方法對於併發控制也很有用我們將在“[快照隔離和可重複讀](ch7.md#快照隔離和可重複讀)”中看到。
* 我們可以透過不儲存整個鍵,而是縮短其大小,來節省頁面空間。特別是在樹內部的頁面上,鍵只需要提供足夠的資訊來充當鍵範圍之間的邊界。在頁面中包含更多的鍵允許樹具有更高的分支因子,因此也就允許更少的層級[^iii]。
* 通常,頁面可以放置在磁碟上的任何位置;沒有什麼要求相鄰鍵範圍的頁面也放在磁碟上相鄰的區域。如果某個查詢需要按照排序順序掃描大部分的鍵範圍,那麼這種按頁面儲存的佈局可能會效率低下,因為每次頁面讀取可能都需要進行磁碟尋道。因此許多B樹的實現在佈局樹時會盡量使葉子頁面按順序出現在磁碟上。但是隨著樹的增長要維持這個順序是很困難的。相比之下由於LSM樹在合併過程中一次又一次地重寫儲存的大部分所以它們更容易使順序鍵在碟上彼此靠近。
* 通常,頁面可以放置在硬碟上的任何位置;沒有什麼要求相鄰鍵範圍的頁面也放在硬碟上相鄰的區域。如果某個查詢需要按照排序順序掃描大部分的鍵範圍,那麼這種按頁面儲存的佈局可能會效率低下,因為每次頁面讀取可能都需要進行硬碟尋道。因此許多B樹的實現在佈局樹時會盡量使葉子頁面按順序出現在硬碟上。但是隨著樹的增長要維持這個順序是很困難的。相比之下由於LSM樹在合併過程中一次又一次地重寫儲存的大部分所以它們更容易使順序鍵在碟上彼此靠近。
* 額外的指標已被新增到樹中。例如,每個葉子頁面可以引用其左邊和右邊的兄弟頁面,使得不用跳回父頁面就能按順序對鍵進行掃描。
* B樹的變體如分形樹fractal tree【22】借用一些日誌結構的思想來減少碟尋道(而且它們與分形無關)。
* B樹的變體如分形樹fractal tree【22】借用一些日誌結構的思想來減少碟尋道(而且它們與分形無關)。
[^iii]: 這個變種有時被稱為B+樹但因為這個最佳化已被廣泛使用所以經常無法區分於其它的B樹變種。
@ -272,23 +272,23 @@ B樹的基本底層寫操作是用新資料覆寫硬碟上的頁面並假定
B樹索引中的每塊資料都必須至少寫入兩次一次寫入預先寫入日誌WAL一次寫入樹頁面本身如果有分頁還需要再寫入一次。即使在該頁面中只有幾個位元組發生了變化也需要接受寫入整個頁面的開銷。有些儲存引擎甚至會覆寫同一個頁面兩次以免在電源故障的情況下導致頁面部分更新【24,25】。
由於反覆壓縮和合並SSTables日誌結構索引也會多次重寫資料。這種影響 —— 在資料庫的生命週期中每次寫入資料庫導致對碟的多次寫入 —— 被稱為**寫放大write amplification**。需要特別注意的是固態硬碟,固態硬碟的快閃記憶體壽命在覆寫有限次數後就會耗盡。
由於反覆壓縮和合並SSTables日誌結構索引也會多次重寫資料。這種影響 —— 在資料庫的生命週期中每次寫入資料庫導致對碟的多次寫入 —— 被稱為**寫放大write amplification**。需要特別注意的是固態硬碟,固態硬碟的快閃記憶體壽命在覆寫有限次數後就會耗盡。
在寫入繁重的應用程式中,效能瓶頸可能是資料庫可以寫入磁碟的速度。在這種情況下,寫放大會導致直接的效能代價:儲存引擎寫入磁碟的次數越多,可用磁碟頻寬內它能處理的每秒寫入次數就越少。
在寫入繁重的應用程式中,效能瓶頸可能是資料庫可以寫入硬碟的速度。在這種情況下,寫放大會導致直接的效能代價:儲存引擎寫入硬碟的次數越多,可用硬碟頻寬內它能處理的每秒寫入次數就越少。
而且LSM樹通常能夠比B樹支援更高的寫入吞吐量部分原因是它們有時具有較低的寫放大儘管這取決於儲存引擎的配置和工作負載部分是因為它們順序地寫入緊湊的SSTable檔案而不是必須覆寫樹中的幾個頁面【26】。這種差異在磁性硬碟驅動器上尤其重要其順序寫入比隨機寫入要快得多。
LSM樹可以被壓縮得更好因此通常能比B樹在碟上產生更小的檔案。B樹儲存引擎會由於碎片化fragmentation而留下一些未使用的碟空間當頁面被拆分或某行不能放入現有頁面時頁面中的某些空間仍未被使用。由於LSM樹不是面向頁面的並且會透過定期重寫SSTables以去除碎片所以它們具有較低的儲存開銷特別是當使用分層壓縮leveled compaction時【27】。
LSM樹可以被壓縮得更好因此通常能比B樹在碟上產生更小的檔案。B樹儲存引擎會由於碎片化fragmentation而留下一些未使用的碟空間當頁面被拆分或某行不能放入現有頁面時頁面中的某些空間仍未被使用。由於LSM樹不是面向頁面的並且會透過定期重寫SSTables以去除碎片所以它們具有較低的儲存開銷特別是當使用分層壓縮leveled compaction時【27】。
在許多固態硬碟上韌體內部使用了日誌結構化演算法以將隨機寫入轉變為順序寫入底層儲存晶片因此儲存引擎寫入模式的影響不太明顯【19】。但是較低的寫入放大率和減少的碎片仍然對固態硬碟更有利更緊湊地表示資料允許在可用的I/O頻寬內處理更多的讀取和寫入請求。
#### LSM樹的缺點
日誌結構儲存的缺點是壓縮過程有時會干擾正在進行的讀寫操作。儘管儲存引擎嘗試增量地執行壓縮以儘量不影響併發訪問,但是磁碟資源有限,所以很容易發生某個請求需要等待磁碟先完成昂貴的壓縮操作。對吞吐量和平均響應時間的影響通常很小,但是日誌結構化儲存引擎在更高百分位的響應時間(請參閱“[描述效能](ch1.md#描述效能)”有時會相當長而B樹的行為則相對更具可預測性【28】。
日誌結構儲存的缺點是壓縮過程有時會干擾正在進行的讀寫操作。儘管儲存引擎嘗試增量地執行壓縮以儘量不影響併發訪問,但是硬碟資源有限,所以很容易發生某個請求需要等待硬碟先完成昂貴的壓縮操作。對吞吐量和平均響應時間的影響通常很小,但是日誌結構化儲存引擎在更高百分位的響應時間(請參閱“[描述效能](ch1.md#描述效能)”有時會相當長而B樹的行為則相對更具可預測性【28】。
壓縮的另一個問題出現在高寫入吞吐量時:磁碟的有限寫入頻寬需要在初始寫入(記錄日誌和重新整理記憶體表到磁碟)和在後臺執行的壓縮執行緒之間共享。寫入空資料庫時,可以使用全磁碟頻寬進行初始寫入,但資料庫越大,壓縮所需的磁碟頻寬就越多。
壓縮的另一個問題出現在高寫入吞吐量時:硬碟的有限寫入頻寬需要在初始寫入(記錄日誌和重新整理記憶體表到硬碟)和在後臺執行的壓縮執行緒之間共享。寫入空資料庫時,可以使用全硬碟頻寬進行初始寫入,但資料庫越大,壓縮所需的硬碟頻寬就越多。
如果寫入吞吐量很高,並且壓縮沒有仔細配置好,有可能導致壓縮跟不上寫入速率。在這種情況下,磁碟上未合併段的數量不斷增加,直到磁碟空間用完讀取速度也會減慢因為它們需要檢查更多的段檔案。通常情況下即使壓縮無法跟上基於SSTable的儲存引擎也不會限制傳入寫入的速率所以你需要進行明確的監控來檢測這種情況【29,30】。
如果寫入吞吐量很高,並且壓縮沒有仔細配置好,有可能導致壓縮跟不上寫入速率。在這種情況下,硬碟上未合併段的數量不斷增加,直到硬碟空間用完讀取速度也會減慢因為它們需要檢查更多的段檔案。通常情況下即使壓縮無法跟上基於SSTable的儲存引擎也不會限制傳入寫入的速率所以你需要進行明確的監控來檢測這種情況【29,30】。
B樹的一個優點是每個鍵只存在於索引中的一個位置而日誌結構化的儲存引擎可能在不同的段中有相同鍵的多個副本。這個方面使得B樹在想要提供強大的事務語義的資料庫中很有吸引力在許多關係資料庫中事務隔離是透過在鍵範圍上使用鎖來實現的在B樹索引中這些鎖可以直接附加到樹上【5】。在[第七章](ch7.md)中,我們將更詳細地討論這一點。
@ -345,21 +345,21 @@ B樹在資料庫架構中是非常根深蒂固的為許多工作負載都提
#### 在記憶體中儲存一切
本章到目前為止討論的資料結構都是對磁碟限制的應對。與主記憶體相比磁碟處理起來很麻煩。對於磁碟和SSD如果要在讀取和寫入時獲得良好效能則需要仔細地佈置磁碟上的資料。但是我們能容忍這種麻煩因為磁碟有兩個顯著的優點它們是持久的它們的內容在電源關閉時不會丟失並且每GB的成本比RAM低。
本章到目前為止討論的資料結構都是對硬碟限制的應對。與主記憶體相比,硬碟處理起來很麻煩。對於磁性硬碟和固態硬碟,如果要在讀取和寫入時獲得良好效能,則需要仔細地佈置硬碟上的資料。但是,我們能容忍這種麻煩,因為硬碟有兩個顯著的優點它們是持久的它們的內容在電源關閉時不會丟失並且每GB的成本比RAM低。
隨著RAM變得更便宜每GB成本的論據被侵蝕了。許多資料集不是那麼大所以將它們全部儲存在記憶體中是非常可行的包括可能分佈在多個機器上。這導致了記憶體資料庫的發展。
某些記憶體中的鍵值儲存如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透過非同步寫入硬碟提供了較弱的永續性。
反直覺的是,記憶體資料庫的效能優勢並不是因為它們不需要從磁碟讀取的事實。只要有足夠的記憶體即使是基於磁碟的儲存引擎也可能永遠不需要從磁碟讀取,因為作業系統在記憶體中快取了最近使用的磁碟塊。相反,它們更快的原因在於省去了將記憶體資料結構編碼為磁碟資料結構的開銷【44】。
反直覺的是,記憶體資料庫的效能優勢並不是因為它們不需要從硬碟讀取的事實。只要有足夠的記憶體即使是基於硬碟的儲存引擎也可能永遠不需要從硬碟讀取,因為作業系統在記憶體中快取了最近使用的硬碟塊。相反,它們更快的原因在於省去了將記憶體資料結構編碼為硬碟資料結構的開銷【44】。
除了效能,記憶體資料庫的另一個有趣的地方是提供了難以用基於碟的索引實現的資料模型。例如Redis為各種資料結構如優先順序佇列和集合提供了類似資料庫的介面。因為它將所有資料儲存在記憶體中所以它的實現相對簡單。
除了效能,記憶體資料庫的另一個有趣的地方是提供了難以用基於碟的索引實現的資料模型。例如Redis為各種資料結構如優先順序佇列和集合提供了類似資料庫的介面。因為它將所有資料儲存在記憶體中所以它的實現相對簡單。
最近的研究表明,記憶體資料庫體系結構可以擴充套件到支援比可用記憶體更大的資料集,而不必重新採用以碟為中心的體系結構【45】。所謂的 **反快取anti-caching** 方法透過在記憶體不足的情況下將最近最少使用的資料從記憶體轉移到並在將來再次訪問時將其重新載入到記憶體中。這與作業系統對虛擬記憶體和交換檔案的操作類似但資料庫可以比作業系統更有效地管理記憶體因為它可以按單個記錄的粒度工作而不是整個記憶體頁面。儘管如此這種方法仍然需要索引能完全放入記憶體中就像本章開頭的Bitcask例子
最近的研究表明,記憶體資料庫體系結構可以擴充套件到支援比可用記憶體更大的資料集,而不必重新採用以碟為中心的體系結構【45】。所謂的 **反快取anti-caching** 方法透過在記憶體不足的情況下將最近最少使用的資料從記憶體轉移到並在將來再次訪問時將其重新載入到記憶體中。這與作業系統對虛擬記憶體和交換檔案的操作類似但資料庫可以比作業系統更有效地管理記憶體因為它可以按單個記錄的粒度工作而不是整個記憶體頁面。儘管如此這種方法仍然需要索引能完全放入記憶體中就像本章開頭的Bitcask例子
如果 **非易失性儲存器non-volatile memory, NVM** 技術得到更廣泛的應用可能還需要進一步改變儲存引擎設計【46】。目前這是一個新的研究領域值得關注。
@ -470,7 +470,7 @@ GROUP BY
在大多數OLTP資料庫中儲存都是以面向行的方式進行佈局的表格的一行中的所有值都相鄰儲存。文件資料庫是相似的整個文件通常儲存為一個連續的位元組序列。你可以在[圖3-1](../img/fig3-1.png)的CSV例子中看到這個。
為了處理像[例3-1]()這樣的查詢,你可能在 `fact_sales.date_key` `fact_sales.product_sk`上有索引它們告訴儲存引擎在哪裡查詢特定日期或特定產品的所有銷售情況。但是面向行的儲存引擎仍然需要將所有這些行每個包含超過100個屬性碟載入到記憶體中,解析它們,並過濾掉那些不符合要求的屬性。這可能需要很長時間。
為了處理像[例3-1]()這樣的查詢,你可能在 `fact_sales.date_key` `fact_sales.product_sk`上有索引它們告訴儲存引擎在哪裡查詢特定日期或特定產品的所有銷售情況。但是面向行的儲存引擎仍然需要將所有這些行每個包含超過100個屬性碟載入到記憶體中,解析它們,並過濾掉那些不符合要求的屬性。這可能需要很長時間。
面向列的儲存背後的想法很簡單:不要將所有來自一行的值儲存在一起,而是將來自每一列的所有值儲存在一起。如果每個列儲存在一個單獨的檔案中,查詢只需要讀取和解析查詢中使用的那些列,這可以節省大量的工作。這個原理如[圖3-10](../img/fig3-10.png)所示。
@ -485,7 +485,7 @@ GROUP BY
### 列壓縮
除了僅從磁碟載入查詢所需的列以外,我們還可以透過壓縮資料來進一步降低對磁碟吞吐量的需求。幸運的是,面向列的儲存通常很適合壓縮。
除了僅從硬碟載入查詢所需的列以外,我們還可以透過壓縮資料來進一步降低對硬碟吞吐量的需求。幸運的是,面向列的儲存通常很適合壓縮。
看看[圖3-10](../img/fig3-10.png)中每一列的值序列:它們通常看起來是相當重複的,這是壓縮的好兆頭。根據列中的資料,可以使用不同的壓縮技術。在資料倉庫中特別有效的一種技術是點陣圖編碼,如[圖3-11](../img/fig3-11.png)所示。
@ -520,9 +520,9 @@ WHERE product_sk = 31 AND store_sk = 3
#### 記憶體頻寬和向量處理
對於需要掃描數百萬行的資料倉庫查詢來說,一個巨大的瓶頸是從盤獲取資料到記憶體的頻寬。但是這不是唯一的瓶頸。分析資料庫的開發人員也擔心有效利用主儲存器頻寬到CPU快取中的頻寬避免CPU指令處理流水線中的分支錯誤預測和泡沫以及在現代中使用單指令多資料SIMD指令CPU 【59,60】。
對於需要掃描數百萬行的資料倉庫查詢來說,一個巨大的瓶頸是從盤獲取資料到記憶體的頻寬。但是這不是唯一的瓶頸。分析資料庫的開發人員也擔心有效利用主儲存器頻寬到CPU快取中的頻寬避免CPU指令處理流水線中的分支錯誤預測和泡沫以及在現代中使用單指令多資料SIMD指令CPU 【59,60】。
除了減少需要從碟載入的資料量以外面向列的儲存佈局也可以有效利用CPU週期。例如查詢引擎可以將大量壓縮的列資料放在CPU的L1快取中然後在緊密的迴圈中迴圈即沒有函式呼叫。相比較每個記錄的處理都需要大量函式呼叫和條件判斷的程式碼CPU執行這樣一個迴圈要快得多。列壓縮允許列中的更多行適合相同數量的L1快取。前面描述的按位“與”和“或”運算子可以被設計為直接在這樣的壓縮列資料塊上操作。這種技術被稱為向量化處理【58,49】。
除了減少需要從碟載入的資料量以外面向列的儲存佈局也可以有效利用CPU週期。例如查詢引擎可以將大量壓縮的列資料放在CPU的L1快取中然後在緊密的迴圈中迴圈即沒有函式呼叫。相比較每個記錄的處理都需要大量函式呼叫和條件判斷的程式碼CPU執行這樣一個迴圈要快得多。列壓縮允許列中的更多行適合相同數量的L1快取。前面描述的按位“與”和“或”運算子可以被設計為直接在這樣的壓縮列資料塊上操作。這種技術被稱為向量化處理【58,49】。
### 列儲存中的排序順序
@ -551,9 +551,9 @@ WHERE product_sk = 31 AND store_sk = 3
使用B樹的就地更新方法對於壓縮的列是不可能的。如果你想在排序表的中間插入一行你很可能不得不重寫所有的列檔案。由於行由列中的位置標識因此插入必須始終更新所有列。
幸運的是本章前面已經看到了一個很好的解決方案LSM樹。所有的寫操作首先進入一個記憶體中的儲存在這裡它們被新增到一個已排序的結構中並準備寫入碟。記憶體中的儲存是面向行還是列的,這並不重要。當已經積累了足夠的寫入資料時,它們將與碟上的列檔案合併並批次寫入新檔案。這基本上是Vertica所做的【62】。
幸運的是本章前面已經看到了一個很好的解決方案LSM樹。所有的寫操作首先進入一個記憶體中的儲存在這裡它們被新增到一個已排序的結構中並準備寫入碟。記憶體中的儲存是面向行還是列的,這並不重要。當已經積累了足夠的寫入資料時,它們將與碟上的列檔案合併並批次寫入新檔案。這基本上是Vertica所做的【62】。
查詢需要檢查碟上的列資料和最近在記憶體中的寫入,並將兩者結合起來。但是,查詢最佳化器隱藏了使用者的這個區別。從分析師的角度來看,透過插入,更新或刪除操作進行修改的資料會立即反映在後續查詢中。
查詢需要檢查碟上的列資料和最近在記憶體中的寫入,並將兩者結合起來。但是,查詢最佳化器隱藏了使用者的這個區別。從分析師的角度來看,透過插入,更新或刪除操作進行修改的資料會立即反映在後續查詢中。
### 聚合:資料立方體和物化檢視
@ -561,7 +561,7 @@ WHERE product_sk = 31 AND store_sk = 3
資料倉庫的另一個值得一提的是物化彙總。如前所述資料倉庫查詢通常涉及一個聚合函式如SQL中的COUNTSUMAVGMIN或MAX。如果相同的聚合被許多不同的查詢使用那麼每次都可以透過原始資料來處理。為什麼不快取一些查詢使用最頻繁的計數或總和
建立這種快取的一種方式是物化檢視Materialized View。在關係資料模型中它通常被定義為一個標準虛擬檢視一個類似於表的物件其內容是一些查詢的結果。不同的是物化檢視是查詢結果的實際副本寫入而虛擬檢視只是寫入查詢的捷徑。從虛擬檢視讀取時SQL引擎會將其展開到檢視的底層查詢中然後處理展開的查詢。
建立這種快取的一種方式是物化檢視Materialized View。在關係資料模型中它通常被定義為一個標準虛擬檢視一個類似於表的物件其內容是一些查詢的結果。不同的是物化檢視是查詢結果的實際副本寫入而虛擬檢視只是寫入查詢的捷徑。從虛擬檢視讀取時SQL引擎會將其展開到檢視的底層查詢中然後處理展開的查詢。
當底層資料發生變化時物化檢視需要更新因為它是資料的非規範化副本。資料庫可以自動完成但是這樣的更新使得寫入成本更高這就是在OLTP資料庫中不經常使用物化檢視的原因。在讀取繁重的資料倉庫中它們可能更有意義它們是否實際上改善了讀取效能取決於個別情況
@ -586,8 +586,8 @@ WHERE product_sk = 31 AND store_sk = 3
在高層次上,我們看到儲存引擎分為兩大類:最佳化 **事務處理OLTP****線上分析OLAP** 。這些用例的訪問模式之間有很大的區別:
* OLTP系統通常面向使用者這意味著系統可能會收到大量的請求。為了處理負載應用程式通常只訪問每個查詢中的少部分記錄。應用程式使用某種鍵來請求記錄儲存引擎使用索引來查詢所請求的鍵的資料。碟尋道時間往往是這裡的瓶頸。
* 資料倉庫和類似的分析系統會低調一些因為它們主要由業務分析人員使用而不是由終端使用者使用。它們的查詢量要比OLTP系統少得多但通常每個查詢開銷高昂需要在短時間內掃描數百萬條記錄。碟頻寬(而不是查詢時間)往往是瓶頸,列式儲存是這種工作負載越來越流行的解決方案。
* OLTP系統通常面向使用者這意味著系統可能會收到大量的請求。為了處理負載應用程式通常只訪問每個查詢中的少部分記錄。應用程式使用某種鍵來請求記錄儲存引擎使用索引來查詢所請求的鍵的資料。碟尋道時間往往是這裡的瓶頸。
* 資料倉庫和類似的分析系統會低調一些因為它們主要由業務分析人員使用而不是由終端使用者使用。它們的查詢量要比OLTP系統少得多但通常每個查詢開銷高昂需要在短時間內掃描數百萬條記錄。碟頻寬(而不是查詢時間)往往是瓶頸,列式儲存是這種工作負載越來越流行的解決方案。
在OLTP方面我們能看到兩派主流的儲存引擎
@ -597,11 +597,11 @@ WHERE product_sk = 31 AND store_sk = 3
***就地更新學派***
碟視為一組可以覆寫的固定大小的頁面。 B樹是這種哲學的典範用在所有主要的關係資料庫中和許多非關係型資料庫。
碟視為一組可以覆寫的固定大小的頁面。 B樹是這種哲學的典範用在所有主要的關係資料庫中和許多非關係型資料庫。
日誌結構的儲存引擎是相對較新的發展。他們的主要想法是,他們系統地將隨機訪問寫入轉換為碟上的順序寫入由於硬碟驅動器和固態硬碟的效能特點可以實現更高的寫入吞吐量。在完成OLTP方面我們透過一些更復雜的索引結構和為保留所有資料而最佳化的資料庫做了一個簡短的介紹。
日誌結構的儲存引擎是相對較新的發展。他們的主要想法是,他們系統地將隨機訪問寫入轉換為碟上的順序寫入由於硬碟驅動器和固態硬碟的效能特點可以實現更高的寫入吞吐量。在完成OLTP方面我們透過一些更復雜的索引結構和為保留所有資料而最佳化的資料庫做了一個簡短的介紹。
然後我們從儲存引擎的內部繞開看看典型資料倉庫的高階架構。這一背景說明了為什麼分析工作負載與OLTP差別很大當你的查詢需要在大量行中順序掃描時索引的相關性就會降低很多。相反非常緊湊地編碼資料變得非常重要以最大限度地減少查詢需要從碟讀取的資料量。我們討論了列式儲存如何幫助實現這一目標。
然後我們從儲存引擎的內部繞開看看典型資料倉庫的高階架構。這一背景說明了為什麼分析工作負載與OLTP差別很大當你的查詢需要在大量行中順序掃描時索引的相關性就會降低很多。相反非常緊湊地編碼資料變得非常重要以最大限度地減少查詢需要從碟讀取的資料量。我們討論了列式儲存如何幫助實現這一目標。
作為一名應用程式開發人員,如果你掌握了有關儲存引擎內部的知識,那麼你就能更好地瞭解哪種工具最適合你的特定應用程式。如果你需要調整資料庫的調整引數,這種理解可以讓你設想一個更高或更低的值可能會產生什麼效果。