mirror of
https://github.com/Vonng/ddia.git
synced 2025-01-05 15:30:06 +08:00
update text spacing with pangu and some manual adjustments
This commit is contained in:
parent
6d0ef40db5
commit
11429ddb4e
326
ch3.md
326
ch3.md
@ -1,4 +1,4 @@
|
||||
# 第三章:存储与检索
|
||||
# 第三章:存储与检索
|
||||
|
||||
![](img/ch3.png)
|
||||
|
||||
@ -13,17 +13,17 @@
|
||||
|
||||
一个数据库在最基础的层次上需要完成两件事情:当你把数据交给数据库时,它应当把数据存储起来;而后当你向数据库要数据时,它应当把数据返回给你。
|
||||
|
||||
在[第二章](ch2.md)中,我们讨论了数据模型和查询语言,即程序员将数据录入数据库的格式,以及再次要回数据的机制。在本章中我们会从数据库的视角来讨论同样的问题:数据库如何存储我们提供的数据,以及如何在我们需要时重新找到数据。
|
||||
在 [第二章](ch2.md) 中,我们讨论了数据模型和查询语言,即程序员将数据录入数据库的格式,以及再次要回数据的机制。在本章中我们会从数据库的视角来讨论同样的问题:数据库如何存储我们提供的数据,以及如何在我们需要时重新找到数据。
|
||||
|
||||
作为程序员,为什么要关心数据库内部存储与检索的机理?你可能不会去从头开始实现自己的存储引擎,但是你**确实**需要从许多可用的存储引擎中选择一个合适的。而且为了让存储引擎能在你的工作负载类型上运行良好,你也需要大致了解存储引擎在底层究竟做了什么。
|
||||
作为程序员,为什么要关心数据库内部存储与检索的机理?你可能不会去从头开始实现自己的存储引擎,但是你 **确实** 需要从许多可用的存储引擎中选择一个合适的。而且为了让存储引擎能在你的工作负载类型上运行良好,你也需要大致了解存储引擎在底层究竟做了什么。
|
||||
|
||||
特别需要注意,针对**事务性**负载优化的和针对**分析性**负载优化的存储引擎之间存在巨大差异。稍后我们将在 “[事务处理还是分析?](#事务处理还是分析?)” 一节中探讨这一区别,并在 “[列式存储](#列式存储)”中讨论一系列针对分析性负载而优化的存储引擎。
|
||||
特别需要注意,针对 **事务性** 负载优化的和针对 **分析性** 负载优化的存储引擎之间存在巨大差异。稍后我们将在 “[事务处理还是分析?](#事务处理还是分析?)” 一节中探讨这一区别,并在 “[列式存储](#列式存储)” 中讨论一系列针对分析性负载而优化的存储引擎。
|
||||
|
||||
但首先,我们将从你可能已经很熟悉的两大类数据库(传统的关系型数据库和很多所谓的“NoSQL”数据库)中使用的**存储引擎**来开始本章的内容。我们将研究两大类存储引擎:**日志结构(log-structured)** 的存储引擎,以及**面向页面(page-oriented)** 的存储引擎(例如B树)。
|
||||
但首先,我们将从你可能已经很熟悉的两大类数据库(传统的关系型数据库和很多所谓的 “NoSQL” 数据库)中使用的 **存储引擎** 来开始本章的内容。我们将研究两大类存储引擎:**日志结构(log-structured)** 的存储引擎,以及 **面向页面(page-oriented)** 的存储引擎(例如 B 树)。
|
||||
|
||||
## 驱动数据库的数据结构
|
||||
|
||||
世界上最简单的数据库可以用两个Bash函数实现:
|
||||
世界上最简单的数据库可以用两个 Bash 函数实现:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
@ -36,7 +36,7 @@ db_get () {
|
||||
}
|
||||
```
|
||||
|
||||
这两个函数实现了键值存储的功能。执行 `db_set key value` 会将 **键(key)** 和**值(value)** 存储在数据库中。键和值(几乎)可以是你喜欢的任何东西,例如,值可以是JSON文档。然后调用 `db_get key` 会查找与该键关联的最新值并将其返回。
|
||||
这两个函数实现了键值存储的功能。执行 `db_set key value` 会将 **键(key)** 和 **值(value)** 存储在数据库中。键和值(几乎)可以是你喜欢的任何东西,例如,值可以是 JSON 文档。然后调用 `db_get key` 会查找与该键关联的最新值并将其返回。
|
||||
|
||||
麻雀虽小,五脏俱全:
|
||||
|
||||
@ -49,7 +49,7 @@ $ db_get 42
|
||||
{"name":"San Francisco","attractions":["Golden Gate Bridge"]}
|
||||
```
|
||||
|
||||
底层的存储格式非常简单:一个文本文件,每行包含一条逗号分隔的键值对(忽略转义问题的话,大致与CSV文件类似)。每次对 `db_set` 的调用都会向文件末尾追加记录,所以更新键的时候旧版本的值不会被覆盖 —— 因而查找最新值的时候,需要找到文件中键最后一次出现的位置(因此 `db_get` 中使用了 `tail -n 1 ` )。
|
||||
底层的存储格式非常简单:一个文本文件,每行包含一条逗号分隔的键值对(忽略转义问题的话,大致与 CSV 文件类似)。每次对 `db_set` 的调用都会向文件末尾追加记录,所以更新键的时候旧版本的值不会被覆盖 —— 因而查找最新值的时候,需要找到文件中键最后一次出现的位置(因此 `db_get` 中使用了 `tail -n 1` )。
|
||||
|
||||
```bash
|
||||
$ db_set 42 '{"name":"San Francisco","attractions":["Exploratorium"]}'
|
||||
@ -63,46 +63,46 @@ $ cat database
|
||||
42,{"name":"San Francisco","attractions":["Exploratorium"]}
|
||||
```
|
||||
|
||||
`db_set` 函数对于极其简单的场景其实有非常好的性能,因为在文件尾部追加写入通常是非常高效的。与`db_set`做的事情类似,许多数据库在内部使用了**日志(log)**,也就是一个 **仅追加(append-only)** 的数据文件。真正的数据库有更多的问题需要处理(如并发控制,回收硬盘空间以避免日志无限增长,处理错误与部分写入的记录),但基本原理是一样的。日志极其有用,我们还将在本书的其它部分重复见到它好几次。
|
||||
`db_set` 函数对于极其简单的场景其实有非常好的性能,因为在文件尾部追加写入通常是非常高效的。与 `db_set` 做的事情类似,许多数据库在内部使用了 **日志(log)**,也就是一个 **仅追加(append-only)** 的数据文件。真正的数据库有更多的问题需要处理(如并发控制,回收硬盘空间以避免日志无限增长,处理错误与部分写入的记录),但基本原理是一样的。日志极其有用,我们还将在本书的其它部分重复见到它好几次。
|
||||
|
||||
> **日志(log)** 这个词通常指应用日志:即应用程序输出的描述正在发生的事情的文本。本书在更普遍的意义下使用**日志**这一词:一个仅追加的记录序列。它可能压根就不是给人类看的,它可以使用二进制格式,并仅能由其他程序读取。
|
||||
> **日志(log)** 这个词通常指应用日志:即应用程序输出的描述正在发生的事情的文本。本书在更普遍的意义下使用 **日志** 这一词:一个仅追加的记录序列。它可能压根就不是给人类看的,它可以使用二进制格式,并仅能由其他程序读取。
|
||||
|
||||
另一方面,如果这个数据库中有着大量记录,则这个`db_get` 函数的性能会非常糟糕。每次你想查找一个键时,`db_get` 必须从头到尾扫描整个数据库文件来查找键的出现。用算法的语言来说,查找的开销是 `O(n)` :如果数据库记录数量 n 翻了一倍,查找时间也要翻一倍。这就不好了。
|
||||
另一方面,如果这个数据库中有着大量记录,则这个 `db_get` 函数的性能会非常糟糕。每次你想查找一个键时,`db_get` 必须从头到尾扫描整个数据库文件来查找键的出现。用算法的语言来说,查找的开销是 `O(n)` :如果数据库记录数量 n 翻了一倍,查找时间也要翻一倍。这就不好了。
|
||||
|
||||
为了高效查找数据库中特定键的值,我们需要一个数据结构:**索引(index)**。本章将介绍一系列的索引结构,并在它们之间进行比较。索引背后的大致思想是通过保存一些额外的元数据作为路标来帮助你找到想要的数据。如果你想以几种不同的方式搜索同一份数据,那么你也许需要在数据的不同部分上建立多个索引。
|
||||
|
||||
索引是从主数据衍生的**额外的(additional)** 结构。许多数据库允许添加与删除索引,这不会影响数据的内容,而只会影响查询的性能。维护额外的结构会产生开销,特别是在写入时。写入性能很难超过简单地追加写入文件,因为追加写入是最简单的写入操作。任何类型的索引通常都会减慢写入速度,因为每次写入数据时都需要更新索引。
|
||||
索引是从主数据衍生的 **额外的(additional)** 结构。许多数据库允许添加与删除索引,这不会影响数据的内容,而只会影响查询的性能。维护额外的结构会产生开销,特别是在写入时。写入性能很难超过简单地追加写入文件,因为追加写入是最简单的写入操作。任何类型的索引通常都会减慢写入速度,因为每次写入数据时都需要更新索引。
|
||||
|
||||
这是存储系统中一个重要的权衡:精心选择的索引加快了读查询的速度,但是每个索引都会拖慢写入速度。因为这个原因,数据库默认并不会索引所有的内容,而需要你,也就是程序员或数据库管理员(DBA),基于对应用的典型查询模式的了解来手动选择索引。你可以选择那些能为应用带来最大收益而且又不会引入超出必要开销的索引。
|
||||
|
||||
|
||||
### 散列索引
|
||||
|
||||
让我们从**键值数据(key-value Data)** 的索引开始。这不是你可以索引的唯一数据类型,但键值数据是很常见的。对于更复杂的索引来说,这也是一个有用的构建模块。
|
||||
让我们从 **键值数据(key-value Data)** 的索引开始。这不是你可以索引的唯一数据类型,但键值数据是很常见的。对于更复杂的索引来说,这也是一个有用的构建模块。
|
||||
|
||||
键值存储与在大多数编程语言中可以找到的**字典(dictionary)** 类型非常相似,通常字典都是用**散列映射(hash map)**或**散列表(hash table)**实现的。散列映射在许多算法教科书中都有描述【1,2】,所以这里我们不会讨论它的工作细节。既然我们已经可以用散列映射来表示**内存中**的数据结构,为什么不使用它来索引**硬盘上**的数据呢?
|
||||
键值存储与在大多数编程语言中可以找到的 **字典(dictionary)** 类型非常相似,通常字典都是用 **散列映射(hash map)** 或 **散列表(hash table)** 实现的。散列映射在许多算法教科书中都有描述【1,2】,所以这里我们不会讨论它的工作细节。既然我们已经可以用散列映射来表示 **内存中** 的数据结构,为什么不使用它来索引 **硬盘上** 的数据呢?
|
||||
|
||||
假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样,那么最简单的索引策略就是:保留一个内存中的散列映射,其中每个键都映射到数据文件中的一个字节偏移量,指明了可以找到对应值的位置,如[图3-1](img/fig3-1.png)所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用散列映射来查找数据文件中的偏移量,**寻找(seek)** 该位置并读取该值即可。
|
||||
假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样,那么最简单的索引策略就是:保留一个内存中的散列映射,其中每个键都映射到数据文件中的一个字节偏移量,指明了可以找到对应值的位置,如 [图 3-1](img/fig3-1.png) 所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用散列映射来查找数据文件中的偏移量,**寻找(seek)** 该位置并读取该值即可。
|
||||
|
||||
![](img/fig3-1.png)
|
||||
|
||||
**图3-1 以类CSV格式存储键值对的日志,并使用内存散列映射进行索引。**
|
||||
**图 3-1 以类 CSV 格式存储键值对的日志,并使用内存散列映射进行索引。**
|
||||
|
||||
听上去简单,但这是一个可行的方法。现实中,Bitcask实际上就是这么做的(Riak中默认的存储引擎)【3】。 Bitcask提供高性能的读取和写入操作,但要求所有的键必须能放入可用内存中,因为散列映射完全保留在内存中。而数据值可以使用比可用内存更多的空间,因为可以在硬盘上通过一次硬盘查找操作来加载所需部分,如果数据文件的那部分已经在文件系统缓存中,则读取根本不需要任何硬盘I/O。
|
||||
听上去简单,但这是一个可行的方法。现实中,Bitcask 实际上就是这么做的(Riak 中默认的存储引擎)【3】。 Bitcask 提供高性能的读取和写入操作,但要求所有的键必须能放入可用内存中,因为散列映射完全保留在内存中。而数据值可以使用比可用内存更多的空间,因为可以在硬盘上通过一次硬盘查找操作来加载所需部分,如果数据文件的那部分已经在文件系统缓存中,则读取根本不需要任何硬盘 I/O。
|
||||
|
||||
像Bitcask这样的存储引擎非常适合每个键的值经常更新的情况。例如,键可能是某个猫咪视频的网址(URL),而值可能是该视频被播放的次数(每次有人点击播放按钮时递增)。在这种类型的工作负载中,有很多写操作,但是没有太多不同的键 —— 每个键有很多的写操作,但是将所有键保存在内存中是可行的。
|
||||
像 Bitcask 这样的存储引擎非常适合每个键的值经常更新的情况。例如,键可能是某个猫咪视频的网址(URL),而值可能是该视频被播放的次数(每次有人点击播放按钮时递增)。在这种类型的工作负载中,有很多写操作,但是没有太多不同的键 —— 每个键有很多的写操作,但是将所有键保存在内存中是可行的。
|
||||
|
||||
直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完硬盘空间?一种好的解决方案是,将日志分为特定大小的段(segment),当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行**压缩(compaction)**,如[图3-2](img/fig3-2.png)所示。这里的压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。
|
||||
直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完硬盘空间?一种好的解决方案是,将日志分为特定大小的段(segment),当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行 **压缩(compaction)**,如 [图 3-2](img/fig3-2.png) 所示。这里的压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。
|
||||
|
||||
![](img/fig3-2.png)
|
||||
|
||||
**图3-2 键值更新日志(统计猫咪视频的播放次数)的压缩,只保留每个键的最近值**
|
||||
**图 3-2 键值更新日志(统计猫咪视频的播放次数)的压缩,只保留每个键的最近值**
|
||||
|
||||
而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如[图3-3](img/fig3-3.png)所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,这个过程进行的同时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新合并的段而不是旧的段 —— 然后旧的段文件就可以简单地删除掉了。
|
||||
而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如 [图 3-3](img/fig3-3.png) 所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,这个过程进行的同时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新合并的段而不是旧的段 —— 然后旧的段文件就可以简单地删除掉了。
|
||||
|
||||
![](img/fig3-3.png)
|
||||
|
||||
**图3-3 同时执行压缩和分段合并**
|
||||
**图 3-3 同时执行压缩和分段合并**
|
||||
|
||||
每个段现在都有自己的内存散列表,将键映射到文件偏移量。为了找到一个键的值,我们首先检查最近的段的散列映射;如果键不存在,我们就检查第二个最近的段,依此类推。合并过程将保持段的数量足够小,所以查找过程不需要检查太多的散列映射。
|
||||
|
||||
@ -110,11 +110,11 @@ $ cat database
|
||||
|
||||
* 文件格式
|
||||
|
||||
CSV不是日志的最佳格式。使用二进制格式更快,更简单:首先以字节为单位对字符串的长度进行编码,然后是原始的字符串(不需要转义)。
|
||||
CSV 不是日志的最佳格式。使用二进制格式更快,更简单:首先以字节为单位对字符串的长度进行编码,然后是原始的字符串(不需要转义)。
|
||||
|
||||
* 删除记录
|
||||
|
||||
如果要删除一个键及其关联的值,则必须在数据文件中追加一个特殊的删除记录(逻辑删除,有时被称为墓碑,即tombstone)。当日志段被合并时,合并过程会通过这个墓碑知道要将被删除键的所有历史值都丢弃掉。
|
||||
如果要删除一个键及其关联的值,则必须在数据文件中追加一个特殊的删除记录(逻辑删除,有时被称为墓碑,即 tombstone)。当日志段被合并时,合并过程会通过这个墓碑知道要将被删除键的所有历史值都丢弃掉。
|
||||
|
||||
* 崩溃恢复
|
||||
|
||||
@ -122,7 +122,7 @@ $ cat database
|
||||
|
||||
* 部分写入记录
|
||||
|
||||
数据库随时可能崩溃,包括在将记录追加到日志的过程中。 Bitcask文件包含校验和,允许检测和忽略日志中的这些损坏部分。
|
||||
数据库随时可能崩溃,包括在将记录追加到日志的过程中。 Bitcask 文件包含校验和,允许检测和忽略日志中的这些损坏部分。
|
||||
|
||||
* 并发控制
|
||||
|
||||
@ -130,185 +130,185 @@ $ cat database
|
||||
|
||||
乍一看,仅追加日志似乎很浪费:为什么不直接在文件里更新,用新值覆盖旧值?仅追加的设计之所以是个好的设计,有如下几个原因:
|
||||
|
||||
* 追加和分段合并都是顺序写入操作,通常比随机写入快得多,尤其是在磁性机械硬盘上。在某种程度上,顺序写入在基于闪存的**固态硬盘(SSD)** 上也是好的选择【4】。我们将在“[比较B树和LSM树](#比较B树和LSM树)”中进一步讨论这个问题。
|
||||
* 追加和分段合并都是顺序写入操作,通常比随机写入快得多,尤其是在磁性机械硬盘上。在某种程度上,顺序写入在基于闪存的 **固态硬盘(SSD)** 上也是好的选择【4】。我们将在“[比较 B 树和 LSM 树](#比较B树和LSM树)”中进一步讨论这个问题。
|
||||
* 如果段文件是仅追加的或不可变的,并发和崩溃恢复就简单多了。例如,当一个数据值被更新的时候发生崩溃,你不用担心文件里将会同时包含旧值和新值各自的一部分。
|
||||
* 合并旧段的处理也可以避免数据文件随着时间的推移而碎片化的问题。
|
||||
|
||||
但是,散列表索引也有其局限性:
|
||||
|
||||
* 散列表必须能放进内存。如果你有非常多的键,那真是倒霉。原则上可以在硬盘上维护一个散列映射,不幸的是硬盘散列映射很难表现优秀。它需要大量的随机访问I/O,当它用满时想要再增长是很昂贵的,并且散列冲突的处理也需要很烦琐的逻辑【5】。
|
||||
* 范围查询效率不高。例如,你无法轻松扫描kitty00000和kitty99999之间的所有键——你必须在散列映射中单独查找每个键。
|
||||
* 散列表必须能放进内存。如果你有非常多的键,那真是倒霉。原则上可以在硬盘上维护一个散列映射,不幸的是硬盘散列映射很难表现优秀。它需要大量的随机访问 I/O,当它用满时想要再增长是很昂贵的,并且散列冲突的处理也需要很烦琐的逻辑【5】。
|
||||
* 范围查询效率不高。例如,你无法轻松扫描 kitty00000 和 kitty99999 之间的所有键 —— 你必须在散列映射中单独查找每个键。
|
||||
|
||||
在下一节中,我们将看到一个没有这些限制的索引结构。
|
||||
|
||||
|
||||
### 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段,只保留每个键的最新值**
|
||||
**图 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**
|
||||
**图 3-5 具有内存索引的 SSTable**
|
||||
|
||||
你仍然需要一个内存中的索引来告诉你一些键的偏移量,但它可以是稀疏的:每几千字节的段文件有一个键就足够了,因为几千字节可以很快地被扫描完[^i]。
|
||||
你仍然需要一个内存中的索引来告诉你一些键的偏移量,但它可以是稀疏的:每几千字节的段文件有一个键就足够了,因为几千字节可以很快地被扫描完 [^i]。
|
||||
|
||||
[^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,请注意区分。
|
||||
[^译注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存储引擎。
|
||||
最初这种索引结构是由 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 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 树。
|
||||
|
||||
从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) 所示。
|
||||
|
||||
![](img/fig3-6.png)
|
||||
|
||||
**图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),该页面或者直接包含每个键的值,或者包含了对可以找到值的页面的引用。
|
||||
|
||||
在B树的一个页面中对子页面的引用的数量称为分支因子。例如,在[图3-6](img/fig3-6.png)中,分支因子是6。在实践中,分支因子取决于存储页面引用和范围边界所需的空间量,但通常是几百个。
|
||||
在 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)
|
||||
|
||||
**图3-7 通过分割页面来生长B树**
|
||||
**图 3-7 通过分割页面来生长 B 树**
|
||||
|
||||
[^ii]: 向B树中插入一个新的键是相当符合直觉的,但删除一个键(同时保持树平衡)就会牵扯很多其他东西了【2】。
|
||||
[^ii]: 向 B 树中插入一个新的键是相当符合直觉的,但删除一个键(同时保持树平衡)就会牵扯很多其他东西了【2】。
|
||||
|
||||
这个算法可以确保树保持平衡:具有n个键的B树总是具有 $O(log n)$ 的深度。大多数数据库可以放入一个三到四层的B树,所以你不需要追踪多个页面引用来找到你正在查找的页面(分支因子为500的4KB页面的四层树可以存储多达256TB的数据)。
|
||||
这个算法可以确保树保持平衡:具有 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#快照隔离和可重复读)”中看到。
|
||||
* 我们可以通过不存储整个键,而是缩短其大小,来节省页面空间。特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子,因此也就允许更少的层级[^iii]。
|
||||
* 通常,页面可以放置在硬盘上的任何位置;没有什么要求相邻键范围的页面也放在硬盘上相邻的区域。如果某个查询需要按照排序顺序扫描大部分的键范围,那么这种按页面存储的布局可能会效率低下,因为每次页面读取可能都需要进行硬盘查找。因此,许多B树的实现在布局树时会尽量使叶子页面按顺序出现在硬盘上。但是,随着树的增长,要维持这个顺序是很困难的。相比之下,由于LSM树在合并过程中一次又一次地重写存储的大部分,所以它们更容易使顺序键在硬盘上彼此靠近。
|
||||
* 一些数据库(如 LMDB)使用写时复制方案【21】,而不是覆盖页面并维护 WAL 以支持崩溃恢复。修改的页面被写入到不同的位置,并且还在树中创建了父页面的新版本,以指向新的位置。这种方法对于并发控制也很有用,我们将在 “[快照隔离和可重复读](ch7.md#快照隔离和可重复读)” 中看到。
|
||||
* 我们可以通过不存储整个键,而是缩短其大小,来节省页面空间。特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子,因此也就允许更少的层级 [^iii]。
|
||||
* 通常,页面可以放置在硬盘上的任何位置;没有什么要求相邻键范围的页面也放在硬盘上相邻的区域。如果某个查询需要按照排序顺序扫描大部分的键范围,那么这种按页面存储的布局可能会效率低下,因为每次页面读取可能都需要进行硬盘查找。因此,许多 B 树的实现在布局树时会尽量使叶子页面按顺序出现在硬盘上。但是,随着树的增长,要维持这个顺序是很困难的。相比之下,由于 LSM 树在合并过程中一次又一次地重写存储的大部分,所以它们更容易使顺序键在硬盘上彼此靠近。
|
||||
* 额外的指针已被添加到树中。例如,每个叶子页面可以引用其左边和右边的兄弟页面,使得不用跳回父页面就能按顺序对键进行扫描。
|
||||
* B树的变体如分形树(fractal tree)【22】借用一些日志结构的思想来减少硬盘查找(而且它们与分形无关)。
|
||||
* B 树的变体如分形树(fractal tree)【22】借用一些日志结构的思想来减少硬盘查找(而且它们与分形无关)。
|
||||
|
||||
[^iii]: 这个变种有时被称为B+树,但因为这个优化已被广泛使用,所以经常无法区分于其它的B树变种。
|
||||
[^iii]: 这个变种有时被称为 B+ 树,但因为这个优化已被广泛使用,所以经常无法区分于其它的 B 树变种。
|
||||
|
||||
### 比较B树和LSM树
|
||||
|
||||
尽管B树实现通常比LSM树实现更成熟,但LSM树由于其性能特点也非常有趣。根据经验,通常LSM树的写入速度更快,而B树的读取速度更快【23】。 LSM树上的读取通常比较慢,因为它们必须检查几种不同的数据结构和不同压缩(Compaction)层级的SSTables。
|
||||
尽管 B 树实现通常比 LSM 树实现更成熟,但 LSM 树由于其性能特点也非常有趣。根据经验,通常 LSM 树的写入速度更快,而 B 树的读取速度更快【23】。 LSM 树上的读取通常比较慢,因为它们必须检查几种不同的数据结构和不同压缩(Compaction)层级的 SSTables。
|
||||
|
||||
然而,基准测试的结果通常和工作负载的细节相关。你需要用你特有的工作负载来测试系统,以便进行有效的比较。在本节中,我们将简要讨论一些在衡量存储引擎性能时值得考虑的事情。
|
||||
|
||||
#### 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】。
|
||||
|
||||
在许多固态硬盘上,固件内部使用了日志结构化算法,以将随机写入转变为顺序写入底层存储芯片,因此存储引擎写入模式的影响不太明显【19】。但是,较低的写入放大率和减少的碎片仍然对固态硬盘更有利:更紧凑地表示数据允许在可用的I/O带宽内处理更多的读取和写入请求。
|
||||
在许多固态硬盘上,固件内部使用了日志结构化算法,以将随机写入转变为顺序写入底层存储芯片,因此存储引擎写入模式的影响不太明显【19】。但是,较低的写入放大率和减少的碎片仍然对固态硬盘更有利:更紧凑地表示数据允许在可用的 I/O 带宽内处理更多的读取和写入请求。
|
||||
|
||||
#### LSM树的缺点
|
||||
|
||||
日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。尽管存储引擎尝试增量地执行压缩以尽量不影响并发访问,但是硬盘资源有限,所以很容易发生某个请求需要等待硬盘先完成昂贵的压缩操作。对吞吐量和平均响应时间的影响通常很小,但是日志结构化存储引擎在更高百分位的响应时间(请参阅“[描述性能](ch1.md#描述性能)”)有时会相当长,而B树的行为则相对更具可预测性【28】。
|
||||
日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。尽管存储引擎尝试增量地执行压缩以尽量不影响并发访问,但是硬盘资源有限,所以很容易发生某个请求需要等待硬盘先完成昂贵的压缩操作。对吞吐量和平均响应时间的影响通常很小,但是日志结构化存储引擎在更高百分位的响应时间(请参阅 “[描述性能](ch1.md#描述性能)”)有时会相当长,而 B 树的行为则相对更具可预测性【28】。
|
||||
|
||||
压缩的另一个问题出现在高写入吞吐量时:硬盘的有限写入带宽需要在初始写入(记录日志和刷新内存表到硬盘)和在后台运行的压缩线程之间共享。写入空数据库时,可以使用全硬盘带宽进行初始写入,但数据库越大,压缩所需的硬盘带宽就越多。
|
||||
|
||||
如果写入吞吐量很高,并且压缩没有仔细配置好,有可能导致压缩跟不上写入速率。在这种情况下,硬盘上未合并段的数量不断增加,直到硬盘空间用完,读取速度也会减慢,因为它们需要检查更多的段文件。通常情况下,即使压缩无法跟上,基于SSTable的存储引擎也不会限制传入写入的速率,所以你需要进行明确的监控来检测这种情况【29,30】。
|
||||
如果写入吞吐量很高,并且压缩没有仔细配置好,有可能导致压缩跟不上写入速率。在这种情况下,硬盘上未合并段的数量不断增加,直到硬盘空间用完,读取速度也会减慢,因为它们需要检查更多的段文件。通常情况下,即使压缩无法跟上,基于 SSTable 的存储引擎也不会限制传入写入的速率,所以你需要进行明确的监控来检测这种情况【29,30】。
|
||||
|
||||
B树的一个优点是每个键只存在于索引中的一个位置,而日志结构化的存储引擎可能在不同的段中有相同键的多个副本。这个方面使得B树在想要提供强大的事务语义的数据库中很有吸引力:在许多关系数据库中,事务隔离是通过在键范围上使用锁来实现的,在B树索引中,这些锁可以直接附加到树上【5】。在[第七章](ch7.md)中,我们将更详细地讨论这一点。
|
||||
B 树的一个优点是每个键只存在于索引中的一个位置,而日志结构化的存储引擎可能在不同的段中有相同键的多个副本。这个方面使得 B 树在想要提供强大的事务语义的数据库中很有吸引力:在许多关系数据库中,事务隔离是通过在键范围上使用锁来实现的,在 B 树索引中,这些锁可以直接附加到树上【5】。在 [第七章](ch7.md) 中,我们将更详细地讨论这一点。
|
||||
|
||||
B树在数据库架构中是非常根深蒂固的,为许多工作负载都提供了始终如一的良好性能,所以它们不可能很快就会消失。在新的数据存储中,日志结构化索引变得越来越流行。没有快速和容易的规则来确定哪种类型的存储引擎对你的场景更好,所以值得去通过一些测试来得到相关的经验。
|
||||
B 树在数据库架构中是非常根深蒂固的,为许多工作负载都提供了始终如一的良好性能,所以它们不可能很快就会消失。在新的数据存储中,日志结构化索引变得越来越流行。没有快速和容易的规则来确定哪种类型的存储引擎对你的场景更好,所以值得去通过一些测试来得到相关的经验。
|
||||
|
||||
### 其他索引结构
|
||||
|
||||
到目前为止,我们只讨论了键值索引,它们就像关系模型中的**主键(primary key)** 索引。主键唯一标识关系表中的一行,或文档数据库中的一个文档或图形数据库中的一个顶点。数据库中的其他记录可以通过其主键(或ID)引用该行/文档/顶点,索引就被用于解析这样的引用。
|
||||
到目前为止,我们只讨论了键值索引,它们就像关系模型中的 **主键(primary key)** 索引。主键唯一标识关系表中的一行,或文档数据库中的一个文档或图形数据库中的一个顶点。数据库中的其他记录可以通过其主键(或 ID)引用该行 / 文档 / 顶点,索引就被用于解析这样的引用。
|
||||
|
||||
次级索引(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 树和日志结构索引都可以用作次级索引。
|
||||
|
||||
#### 将值存储在索引中
|
||||
|
||||
索引中的键是查询要搜索的内容,而其值可以是以下两种情况之一:它可以是实际的行(文档,顶点),也可以是对存储在别处的行的引用。在后一种情况下,行被存储的地方被称为**堆文件(heap file)**,并且存储的数据没有特定的顺序(它可以是仅追加的,或者它可以跟踪被删除的行以便后续可以用新的数据进行覆盖)。堆文件方法很常见,因为它避免了在存在多个次级索引时对数据的复制:每个索引只引用堆文件中的一个位置,实际的数据都保存在一个地方。
|
||||
索引中的键是查询要搜索的内容,而其值可以是以下两种情况之一:它可以是实际的行(文档,顶点),也可以是对存储在别处的行的引用。在后一种情况下,行被存储的地方被称为 **堆文件(heap file)**,并且存储的数据没有特定的顺序(它可以是仅追加的,或者它可以跟踪被删除的行以便后续可以用新的数据进行覆盖)。堆文件方法很常见,因为它避免了在存在多个次级索引时对数据的复制:每个索引只引用堆文件中的一个位置,实际的数据都保存在一个地方。
|
||||
|
||||
在不更改键的情况下更新值时,堆文件方法可以非常高效:只要新值的字节数不大于旧值,就可以覆盖该记录。如果新值更大,情况会更复杂,因为它可能需要移到堆中有足够空间的新位置。在这种情况下,要么所有的索引都需要更新,以指向记录的新堆位置,或者在旧堆位置留下一个转发指针【5】。
|
||||
|
||||
在某些情况下,从索引到堆文件的额外跳跃对读取来说性能损失太大,因此可能希望将被索引的行直接存储在索引中。这被称为聚集索引(clustered index)。例如,在MySQL的InnoDB存储引擎中,表的主键总是一个聚集索引,次级索引则引用主键(而不是堆文件中的位置)【31】。在SQL Server中,可以为每个表指定一个聚集索引【32】。
|
||||
在某些情况下,从索引到堆文件的额外跳跃对读取来说性能损失太大,因此可能希望将被索引的行直接存储在索引中。这被称为聚集索引(clustered index)。例如,在 MySQL 的 InnoDB 存储引擎中,表的主键总是一个聚集索引,次级索引则引用主键(而不是堆文件中的位置)【31】。在 SQL Server 中,可以为每个表指定一个聚集索引【32】。
|
||||
|
||||
在 **聚集索引**(在索引中存储所有的行数据)和 **非聚集索引**(仅在索引中存储对数据的引用)之间的折衷被称为 **覆盖索引(covering index)** 或 **包含列的索引(index with included columns)**,其在索引内存储表的一部分列【33】。这允许通过单独使用索引来处理一些查询(这种情况叫做:索引 **覆盖(cover)** 了查询)【32】。
|
||||
|
||||
@ -318,7 +318,7 @@ B树在数据库架构中是非常根深蒂固的,为许多工作负载都提
|
||||
|
||||
至今讨论的索引只是将一个键映射到一个值。如果我们需要同时查询一个表中的多个列(或文档中的多个字段),这显然是不够的。
|
||||
|
||||
最常见的多列索引被称为 **连接索引(concatenated index)** ,它通过将一列的值追加到另一列后面,简单地将多个字段组合成一个键(索引定义中指定了字段的连接顺序)。这就像一个老式的纸质电话簿,它提供了一个从(姓氏,名字)到电话号码的索引。由于排序顺序,索引可以用来查找所有具有特定姓氏的人,或所有具有特定姓氏-名字组合的人。但如果你想找到所有具有特定名字的人,这个索引是没有用的。
|
||||
最常见的多列索引被称为 **连接索引(concatenated index)** ,它通过将一列的值追加到另一列后面,简单地将多个字段组合成一个键(索引定义中指定了字段的连接顺序)。这就像一个老式的纸质电话簿,它提供了一个从(姓氏,名字)到电话号码的索引。由于排序顺序,索引可以用来查找所有具有特定姓氏的人,或所有具有特定姓氏 - 名字组合的人。但如果你想找到所有具有特定名字的人,这个索引是没有用的。
|
||||
|
||||
**多维索引(multi-dimensional index)** 是一种查询多个列的更一般的方法,这对于地理空间数据尤为重要。例如,餐厅搜索网站可能有一个数据库,其中包含每个餐厅的经度和纬度。当用户在地图上查看餐馆时,网站需要搜索用户正在查看的矩形地图区域内的所有餐馆。这需要一个二维范围查询,如下所示:
|
||||
|
||||
@ -327,131 +327,131 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
|
||||
AND longitude > -0.1162 AND longitude < -0.1004;
|
||||
```
|
||||
|
||||
一个标准的B树或者LSM树索引不能够高效地处理这种查询:它可以返回一个纬度范围内的所有餐馆(但经度可能是任意值),或者返回在同一个经度范围内的所有餐馆(但纬度可能是北极和南极之间的任意地方),但不能同时满足两个条件。
|
||||
一个标准的 B 树或者 LSM 树索引不能够高效地处理这种查询:它可以返回一个纬度范围内的所有餐馆(但经度可能是任意值),或者返回在同一个经度范围内的所有餐馆(但纬度可能是北极和南极之间的任意地方),但不能同时满足两个条件。
|
||||
|
||||
一种选择是使用空间填充曲线将二维位置转换为单个数字,然后使用常规B树索引【34】。更普遍的是,使用特殊化的空间索引,例如R树。例如,PostGIS使用PostgreSQL的通用GiST工具【35】将地理空间索引实现为R树。这里我们没有足够的地方来描述R树,但是有大量的文献可供参考。
|
||||
一种选择是使用空间填充曲线将二维位置转换为单个数字,然后使用常规 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 能够在一定的编辑距离(编辑距离 1 意味着添加,删除或替换了一个字母)内搜索文本【37】。
|
||||
|
||||
正如“[用SSTables制作LSM树](#用SSTables制作LSM树)”中所提到的,Lucene为其词典使用了一个类似于SSTable的结构。这个结构需要一个小的内存索引,告诉查询需要在排序文件中哪个偏移量查找键。在LevelDB中,这个内存中的索引是一些键的稀疏集合,但在Lucene中,内存中的索引是键中字符的有限状态自动机,类似于trie 【38】。这个自动机可以转换成Levenshtein自动机,它支持在给定的编辑距离内有效地搜索单词【39】。
|
||||
正如 “[用 SSTables 制作 LSM 树](#用SSTables制作LSM树)” 中所提到的,Lucene 为其词典使用了一个类似于 SSTable 的结构。这个结构需要一个小的内存索引,告诉查询需要在排序文件中哪个偏移量查找键。在 LevelDB 中,这个内存中的索引是一些键的稀疏集合,但在 Lucene 中,内存中的索引是键中字符的有限状态自动机,类似于 trie 【38】。这个自动机可以转换成 Levenshtein 自动机,它支持在给定的编辑距离内有效地搜索单词【39】。
|
||||
|
||||
其他的模糊搜索技术正朝着文档分类和机器学习的方向发展。更多详细信息请参阅信息检索教科书,例如【40】。
|
||||
|
||||
#### 在内存中存储一切
|
||||
|
||||
本章到目前为止讨论的数据结构都是对硬盘限制的应对。与主内存相比,硬盘处理起来很麻烦。对于磁性硬盘和固态硬盘,如果要在读取和写入时获得良好性能,则需要仔细地布置硬盘上的数据。但是,我们能容忍这种麻烦,因为硬盘有两个显著的优点:它们是持久的(它们的内容在电源关闭时不会丢失),并且每GB的成本比RAM低。
|
||||
本章到目前为止讨论的数据结构都是对硬盘限制的应对。与主内存相比,硬盘处理起来很麻烦。对于磁性硬盘和固态硬盘,如果要在读取和写入时获得良好性能,则需要仔细地布置硬盘上的数据。但是,我们能容忍这种麻烦,因为硬盘有两个显著的优点:它们是持久的(它们的内容在电源关闭时不会丢失),并且每 GB 的成本比 RAM 低。
|
||||
|
||||
随着RAM变得更便宜,每GB成本的论据被侵蚀了。许多数据集不是那么大,所以将它们全部保存在内存中是非常可行的,包括可能分布在多个机器上。这导致了内存数据库的发展。
|
||||
随着 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】。
|
||||
|
||||
除了性能,内存数据库的另一个有趣的地方是提供了难以用基于硬盘的索引实现的数据模型。例如,Redis为各种数据结构(如优先级队列和集合)提供了类似数据库的接口。因为它将所有数据保存在内存中,所以它的实现相对简单。
|
||||
除了性能,内存数据库的另一个有趣的地方是提供了难以用基于硬盘的索引实现的数据模型。例如,Redis 为各种数据结构(如优先级队列和集合)提供了类似数据库的接口。因为它将所有数据保存在内存中,所以它的实现相对简单。
|
||||
|
||||
最近的研究表明,内存数据库体系结构可以扩展到支持比可用内存更大的数据集,而不必重新采用以硬盘为中心的体系结构【45】。所谓的 **反缓存(anti-caching)** 方法通过在内存不足的情况下将最近最少使用的数据从内存转移到硬盘,并在将来再次访问时将其重新加载到内存中。这与操作系统对虚拟内存和交换文件的操作类似,但数据库可以比操作系统更有效地管理内存,因为它可以按单个记录的粒度工作,而不是整个内存页面。尽管如此,这种方法仍然需要索引能完全放入内存中(就像本章开头的Bitcask例子)。
|
||||
最近的研究表明,内存数据库体系结构可以扩展到支持比可用内存更大的数据集,而不必重新采用以硬盘为中心的体系结构【45】。所谓的 **反缓存(anti-caching)** 方法通过在内存不足的情况下将最近最少使用的数据从内存转移到硬盘,并在将来再次访问时将其重新加载到内存中。这与操作系统对虚拟内存和交换文件的操作类似,但数据库可以比操作系统更有效地管理内存,因为它可以按单个记录的粒度工作,而不是整个内存页面。尽管如此,这种方法仍然需要索引能完全放入内存中(就像本章开头的 Bitcask 例子)。
|
||||
|
||||
如果 **非易失性存储器(non-volatile memory, NVM)** 技术得到更广泛的应用,可能还需要进一步改变存储引擎设计【46】。目前这是一个新的研究领域,值得关注。
|
||||
|
||||
|
||||
## 事务处理还是分析?
|
||||
|
||||
在早期的业务数据处理过程中,一次典型的数据库写入通常与一笔 *商业交易(commercial transaction)* 相对应:卖个货,向供应商下订单,支付员工工资等等。但随着数据库开始应用到那些不涉及到钱的领域,术语**交易/事务(transaction)** 仍留了下来,用于指代一组读写操作构成的逻辑单元。
|
||||
在早期的业务数据处理过程中,一次典型的数据库写入通常与一笔 *商业交易(commercial transaction)* 相对应:卖个货,向供应商下订单,支付员工工资等等。但随着数据库开始应用到那些不涉及到钱的领域,术语 **交易 / 事务(transaction)** 仍留了下来,用于指代一组读写操作构成的逻辑单元。
|
||||
|
||||
> 事务不一定具有ACID(原子性,一致性,隔离性和持久性)属性。事务处理只是意味着允许客户端进行低延迟的读取和写入 —— 而不是只能定期运行(例如每天一次)的批处理作业。我们在[第七章](ch7.md)中讨论ACID属性,在[第十章](ch10.md)中讨论批处理。
|
||||
> 事务不一定具有 ACID(原子性,一致性,隔离性和持久性)属性。事务处理只是意味着允许客户端进行低延迟的读取和写入 —— 而不是只能定期运行(例如每天一次)的批处理作业。我们在 [第七章](ch7.md) 中讨论 ACID 属性,在 [第十章](ch10.md) 中讨论批处理。
|
||||
|
||||
即使数据库开始被用于许多不同类型的数据,比如博客文章的评论,游戏中的动作,地址簿中的联系人等等,基本的访问模式仍然类似于处理商业交易。应用程序通常使用索引通过某个键查找少量记录。根据用户的输入插入或更新记录。由于这些应用程序是交互式的,这种访问模式被称为**在线事务处理(OLTP, OnLine Transaction Processing)**。
|
||||
即使数据库开始被用于许多不同类型的数据,比如博客文章的评论,游戏中的动作,地址簿中的联系人等等,基本的访问模式仍然类似于处理商业交易。应用程序通常使用索引通过某个键查找少量记录。根据用户的输入插入或更新记录。由于这些应用程序是交互式的,这种访问模式被称为 **在线事务处理(OLTP, OnLine Transaction Processing)**。
|
||||
|
||||
但是,数据库也开始越来越多地用于数据分析,这些数据分析具有非常不同的访问模式。通常,分析查询需要扫描大量记录,每个记录只读取几列,并计算汇总统计信息(如计数、总和或平均值),而不是将原始数据返回给用户。例如,如果你的数据是一个销售交易表,那么分析查询可能是:
|
||||
|
||||
* 一月份每个商店的总收入是多少?
|
||||
* 在最近的推广活动中多卖了多少香蕉?
|
||||
* 哪个牌子的婴儿食品最常与X品牌的尿布同时购买?
|
||||
* 哪个牌子的婴儿食品最常与 X 品牌的尿布同时购买?
|
||||
|
||||
这些查询通常由业务分析师编写,并提供报告以帮助公司管理层做出更好的决策(商业智能)。为了将这种使用数据库的模式和事务处理区分开,它被称为**在线分析处理(OLAP, OnLine Analytice Processing)**【47】[^iv]。OLTP和OLAP之间的区别并不总是清晰的,但是一些典型的特征在[表3-1]()中列出。
|
||||
这些查询通常由业务分析师编写,并提供报告以帮助公司管理层做出更好的决策(商业智能)。为了将这种使用数据库的模式和事务处理区分开,它被称为 **在线分析处理(OLAP, OnLine Analytice Processing)**【47】[^iv]。OLTP 和 OLAP 之间的区别并不总是清晰的,但是一些典型的特征在 [表 3-1]() 中列出。
|
||||
|
||||
**表3-1 比较事务处理和分析系统的特点**
|
||||
**表 3-1 比较事务处理和分析系统的特点**
|
||||
|
||||
| 属性 | 事务处理系统 OLTP | 分析系统 OLAP |
|
||||
| :----------: | :--------------------------: | :----------------------: |
|
||||
| 主要读取模式 | 查询少量记录,按键读取 | 在大批量记录上聚合 |
|
||||
| 主要写入模式 | 随机访问,写入要求低延时 | 批量导入(ETL)或者事件流 |
|
||||
| 主要用户 | 终端用户,通过Web应用 | 内部数据分析师,用于决策支持 |
|
||||
| 主要用户 | 终端用户,通过 Web 应用 | 内部数据分析师,用于决策支持 |
|
||||
| 处理的数据 | 数据的最新状态(当前时间点) | 随时间推移的历史事件 |
|
||||
| 数据集尺寸 | GB ~ TB | TB ~ PB |
|
||||
|
||||
[^iv]: OLAP中的首字母O(online)的含义并不明确,它可能是指查询并不是用来生成预定义好的报告的事实,也可能是指分析师通常是交互式地使用OLAP系统来进行探索式的查询。
|
||||
[^iv]: OLAP 中的首字母 O(online)的含义并不明确,它可能是指查询并不是用来生成预定义好的报告的事实,也可能是指分析师通常是交互式地使用 OLAP 系统来进行探索式的查询。
|
||||
|
||||
起初,事务处理和分析查询使用了相同的数据库。 SQL在这方面已证明是非常灵活的:对于OLTP类型的查询以及OLAP类型的查询来说效果都很好。尽管如此,在二十世纪八十年代末和九十年代初期,企业有停止使用OLTP系统进行分析的趋势,转而在单独的数据库上运行分析。这个单独的数据库被称为**数据仓库(data warehouse)**。
|
||||
起初,事务处理和分析查询使用了相同的数据库。 SQL 在这方面已证明是非常灵活的:对于 OLTP 类型的查询以及 OLAP 类型的查询来说效果都很好。尽管如此,在二十世纪八十年代末和九十年代初期,企业有停止使用 OLTP 系统进行分析的趋势,转而在单独的数据库上运行分析。这个单独的数据库被称为 **数据仓库(data warehouse)**。
|
||||
|
||||
### 数据仓库
|
||||
|
||||
一个企业可能有几十个不同的交易处理系统:面向终端客户的网站,控制实体商店的收银系统,仓库库存跟踪,车辆路线规划,供应链管理,员工管理等。这些系统中每一个都很复杂,需要专人维护,所以最终这些系统互相之间都是独立运行的。
|
||||
|
||||
这些OLTP系统往往对业务运作至关重要,因而通常会要求 **高可用** 与 **低延迟**。所以DBA会密切关注他们的OLTP数据库,他们通常不愿意让业务分析人员在OLTP数据库上运行临时的分析查询,因为这些查询通常开销巨大,会扫描大部分数据集,这会损害同时在执行的事务的性能。
|
||||
这些 OLTP 系统往往对业务运作至关重要,因而通常会要求 **高可用** 与 **低延迟**。所以 DBA 会密切关注他们的 OLTP 数据库,他们通常不愿意让业务分析人员在 OLTP 数据库上运行临时的分析查询,因为这些查询通常开销巨大,会扫描大部分数据集,这会损害同时在执行的事务的性能。
|
||||
|
||||
相比之下,数据仓库是一个独立的数据库,分析人员可以查询他们想要的内容而不影响OLTP操作【48】。数据仓库包含公司各种OLTP系统中所有的只读数据副本。从OLTP数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为“**抽取-转换-加载(ETL)**”,如[图3-8](img/fig3-8.png)所示。
|
||||
相比之下,数据仓库是一个独立的数据库,分析人员可以查询他们想要的内容而不影响 OLTP 操作【48】。数据仓库包含公司各种 OLTP 系统中所有的只读数据副本。从 OLTP 数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为 “**抽取 - 转换 - 加载(ETL)**”,如 [图 3-8](img/fig3-8.png) 所示。
|
||||
|
||||
![](img/fig3-8.png)
|
||||
|
||||
**图3-8 ETL至数据仓库的简化提纲**
|
||||
**图 3-8 ETL 至数据仓库的简化提纲**
|
||||
|
||||
几乎所有的大型企业都有数据仓库,但在小型企业中几乎闻所未闻。这可能是因为大多数小公司没有这么多不同的OLTP系统,大多数小公司只有少量的数据 —— 可以在传统的SQL数据库中查询,甚至可以在电子表格中分析。在一家大公司里,要做一些在一家小公司很简单的事情,需要很多繁重的工作。
|
||||
几乎所有的大型企业都有数据仓库,但在小型企业中几乎闻所未闻。这可能是因为大多数小公司没有这么多不同的 OLTP 系统,大多数小公司只有少量的数据 —— 可以在传统的 SQL 数据库中查询,甚至可以在电子表格中分析。在一家大公司里,要做一些在一家小公司很简单的事情,需要很多繁重的工作。
|
||||
|
||||
使用单独的数据仓库,而不是直接查询OLTP系统进行分析的一大优势是数据仓库可针对分析访问模式进行优化。事实证明,本章前半部分讨论的索引算法对于OLTP来说工作得很好,但对于处理分析查询并不是很好。在本章的其余部分中,我们将研究为分析而优化的存储引擎。
|
||||
使用单独的数据仓库,而不是直接查询 OLTP 系统进行分析的一大优势是数据仓库可针对分析访问模式进行优化。事实证明,本章前半部分讨论的索引算法对于 OLTP 来说工作得很好,但对于处理分析查询并不是很好。在本章的其余部分中,我们将研究为分析而优化的存储引擎。
|
||||
|
||||
#### OLTP数据库和数据仓库之间的分歧
|
||||
|
||||
数据仓库的数据模型通常是关系型的,因为SQL通常很适合分析查询。有许多图形数据分析工具可以生成SQL查询,可视化结果,并允许分析人员探索数据(通过下钻、切片和切块等操作)。
|
||||
数据仓库的数据模型通常是关系型的,因为 SQL 通常很适合分析查询。有许多图形数据分析工具可以生成 SQL 查询,可视化结果,并允许分析人员探索数据(通过下钻、切片和切块等操作)。
|
||||
|
||||
表面上,一个数据仓库和一个关系型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】。
|
||||
|
||||
### 星型和雪花型:分析的模式
|
||||
|
||||
正如[第二章](ch2.md)所探讨的,根据应用程序的需要,在事务处理领域中使用了大量不同的数据模型。另一方面,在分析型业务中,数据模型的多样性则少得多。许多数据仓库都以相当公式化的方式使用,被称为星型模式(也称为维度建模【55】)。
|
||||
正如 [第二章](ch2.md) 所探讨的,根据应用程序的需要,在事务处理领域中使用了大量不同的数据模型。另一方面,在分析型业务中,数据模型的多样性则少得多。许多数据仓库都以相当公式化的方式使用,被称为星型模式(也称为维度建模【55】)。
|
||||
|
||||
[图3-9](img/fig3-9.md)中的示例模式显示了可能在食品零售商处找到的数据仓库。在模式的中心是一个所谓的事实表(在这个例子中,它被称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买的产品)。如果我们分析的是网站流量而不是零售量,则每行可能代表一个用户的页面浏览或点击。
|
||||
[图 3-9](img/fig3-9.md) 中的示例模式显示了可能在食品零售商处找到的数据仓库。在模式的中心是一个所谓的事实表(在这个例子中,它被称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买的产品)。如果我们分析的是网站流量而不是零售量,则每行可能代表一个用户的页面浏览或点击。
|
||||
|
||||
![](img/fig3-9.png)
|
||||
|
||||
**图3-9 用于数据仓库的星型模式的示例**
|
||||
**图 3-9 用于数据仓库的星型模式的示例**
|
||||
|
||||
通常情况下,事实被视为单独的事件,因为这样可以在以后分析中获得最大的灵活性。但是,这意味着事实表可以变得非常大。像苹果、沃尔玛或eBay这样的大企业在其数据仓库中可能有几十PB的交易历史,其中大部分保存在事实表中【56】。
|
||||
通常情况下,事实被视为单独的事件,因为这样可以在以后分析中获得最大的灵活性。但是,这意味着事实表可以变得非常大。像苹果、沃尔玛或 eBay 这样的大企业在其数据仓库中可能有几十 PB 的交易历史,其中大部分保存在事实表中【56】。
|
||||
|
||||
事实表中的一些列是属性,例如产品销售的价格和从供应商那里购买的成本(可以用来计算利润余额)。事实表中的其他列是对其他表(称为维度表)的外键引用。由于事实表中的每一行都表示一个事件,因此这些维度代表事件发生的对象、内容、地点、时间、方式和原因。
|
||||
|
||||
例如,在[图3-9](img/fig3-9.md)中,其中一个维度是已售出的产品。 `dim_product` 表中的每一行代表一种待售产品,包括库存单位(SKU)、产品描述、品牌名称、类别、脂肪含量、包装尺寸等。`fact_sales` 表中的每一行都使用外键表明在特定交易中销售了什么产品。 (简单起见,如果客户一次购买了几种不同的产品,则它们在事实表中被表示为单独的行)。
|
||||
例如,在 [图 3-9](img/fig3-9.md) 中,其中一个维度是已售出的产品。 `dim_product` 表中的每一行代表一种待售产品,包括库存单位(SKU)、产品描述、品牌名称、类别、脂肪含量、包装尺寸等。`fact_sales` 表中的每一行都使用外键表明在特定交易中销售了什么产品。 (简单起见,如果客户一次购买了几种不同的产品,则它们在事实表中被表示为单独的行)。
|
||||
|
||||
甚至日期和时间也通常使用维度表来表示,因为这允许对日期的附加信息(诸如公共假期)进行编码,从而允许区分假期和非假期的销售查询。
|
||||
|
||||
“星型模式”这个名字来源于这样一个事实,即当我们对表之间的关系进行可视化时,事实表在中间,被维度表包围;与这些表的连接就像星星的光芒。
|
||||
“星型模式” 这个名字来源于这样一个事实,即当我们对表之间的关系进行可视化时,事实表在中间,被维度表包围;与这些表的连接就像星星的光芒。
|
||||
|
||||
这个模板的变体被称为雪花模式,其中维度被进一步分解为子维度。例如,品牌和产品类别可能有单独的表格,并且 `dim_product` 表格中的每一行都可以将品牌和类别作为外键引用,而不是将它们作为字符串存储在 `dim_product` 表格中。雪花模式比星形模式更规范化,但是星形模式通常是首选,因为分析师使用它更简单【55】。
|
||||
|
||||
在典型的数据仓库中,表格通常非常宽:事实表通常有100列以上,有时甚至有数百列【51】。维度表也可以是非常宽的,因为它们包括了所有可能与分析相关的元数据——例如,`dim_store` 表可以包括在每个商店提供哪些服务的细节,它是否具有店内面包房,店面面积,商店第一次开张的日期,最近一次改造的时间,离最近的高速公路的距离等等。
|
||||
在典型的数据仓库中,表格通常非常宽:事实表通常有 100 列以上,有时甚至有数百列【51】。维度表也可以是非常宽的,因为它们包括了所有可能与分析相关的元数据 —— 例如,`dim_store` 表可以包括在每个商店提供哪些服务的细节,它是否具有店内面包房,店面面积,商店第一次开张的日期,最近一次改造的时间,离最近的高速公路的距离等等。
|
||||
|
||||
|
||||
## 列式存储
|
||||
|
||||
如果事实表中有万亿行和数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 分析人们是否更倾向于在一周的某一天购买新鲜水果或糖果**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
@ -470,34 +470,34 @@ GROUP BY
|
||||
|
||||
我们如何有效地执行这个查询?
|
||||
|
||||
在大多数OLTP数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库也是相似的:整个文档通常存储为一个连续的字节序列。你可以在[图3-1](img/fig3-1.png)的CSV例子中看到这个。
|
||||
在大多数 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)所示。
|
||||
列式存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列式存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如 [图 3-10](img/fig3-10.png) 所示。
|
||||
|
||||
![](img/fig3-10.png)
|
||||
|
||||
**图3-10 按列存储关系型数据,而不是行**
|
||||
**图 3-10 按列存储关系型数据,而不是行**
|
||||
|
||||
> 列式存储在关系数据模型中是最容易理解的,但它同样适用于非关系数据。例如,Parquet【57】是一种列式存储格式,支持基于Google的Dremel的文档数据模型【54】。
|
||||
> 列式存储在关系数据模型中是最容易理解的,但它同样适用于非关系数据。例如,Parquet【57】是一种列式存储格式,支持基于 Google 的 Dremel 的文档数据模型【54】。
|
||||
|
||||
列式存储布局依赖于每个列文件包含相同顺序的行。 因此,如果你需要重新组装完整的行,你可以从每个单独的列文件中获取第23项,并将它们放在一起形成表的第23行。
|
||||
列式存储布局依赖于每个列文件包含相同顺序的行。 因此,如果你需要重新组装完整的行,你可以从每个单独的列文件中获取第 23 项,并将它们放在一起形成表的第 23 行。
|
||||
|
||||
|
||||
### 列压缩
|
||||
|
||||
除了仅从硬盘加载查询所需的列以外,我们还可以通过压缩数据来进一步降低对硬盘吞吐量的需求。幸运的是,列式存储通常很适合压缩。
|
||||
|
||||
看看[图3-10](img/fig3-10.png)中每一列的值序列:它们通常看起来是相当重复的,这是压缩的好兆头。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如[图3-11](img/fig3-11.png)所示。
|
||||
看看 [图 3-10](img/fig3-10.png) 中每一列的值序列:它们通常看起来是相当重复的,这是压缩的好兆头。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如 [图 3-11](img/fig3-11.png) 所示。
|
||||
|
||||
![](img/fig3-11.png)
|
||||
|
||||
**图3-11 压缩的位图索引存储布局**
|
||||
**图 3-11 压缩的位图索引存储布局**
|
||||
|
||||
通常情况下,一列中不同值的数量与行数相比要小得多(例如,零售商可能有数十亿的销售交易,但只有100,000个不同的产品)。现在我们可以拿一个有 n 个不同值的列,并把它转换成 n 个独立的位图:每个不同值对应一个位图,每行对应一个比特位。如果该行具有该值,则该位为1,否则为0。
|
||||
通常情况下,一列中不同值的数量与行数相比要小得多(例如,零售商可能有数十亿的销售交易,但只有 100,000 个不同的产品)。现在我们可以拿一个有 n 个不同值的列,并把它转换成 n 个独立的位图:每个不同值对应一个位图,每行对应一个比特位。如果该行具有该值,则该位为 1,否则为 0。
|
||||
|
||||
如果n非常小(例如,国家/地区列可能有大约200个不同的值),则这些位图可以将每行存储成一个比特位。但是,如果n更大,大部分位图中将会有很多的零(我们说它们是稀疏的)。在这种情况下,位图可以另外再进行游程编码,如[图3-11](fig3-11.png)底部所示。这可以使列的编码非常紧凑。
|
||||
如果 n 非常小(例如,国家 / 地区列可能有大约 200 个不同的值),则这些位图可以将每行存储成一个比特位。但是,如果 n 更大,大部分位图中将会有很多的零(我们说它们是稀疏的)。在这种情况下,位图可以另外再进行游程编码,如 [图 3-11](fig3-11.png) 底部所示。这可以使列的编码非常紧凑。
|
||||
|
||||
这些位图索引非常适合数据仓库中常见的各种查询。例如:
|
||||
|
||||
@ -505,45 +505,45 @@ GROUP BY
|
||||
WHERE product_sk IN(30,68,69)
|
||||
```
|
||||
|
||||
加载`product_sk = 30`、`product_sk = 68`和`product_sk = 69`这三个位图,并计算三个位图的按位或(OR),这可以非常有效地完成。
|
||||
加载 `product_sk = 30`、`product_sk = 68` 和 `product_sk = 69` 这三个位图,并计算三个位图的按位或(OR),这可以非常有效地完成。
|
||||
|
||||
```sql
|
||||
WHERE product_sk = 31 AND store_sk = 3
|
||||
```
|
||||
|
||||
加载`product_sk = 31`和`store_sk = 3`的位图,并计算按位与(AND)。这是因为列按照相同的顺序包含行,因此一列的位图中的第k位和另一列的位图中的第k位对应相同的行。
|
||||
加载 `product_sk = 31` 和 `store_sk = 3` 的位图,并计算按位与(AND)。这是因为列按照相同的顺序包含行,因此一列的位图中的第 k 位和另一列的位图中的第 k 位对应相同的行。
|
||||
|
||||
对于不同种类的数据,也有各种不同的压缩方案,但我们不会详细讨论它们,请参阅【58】的概述。
|
||||
|
||||
> #### 列式存储和列族
|
||||
>
|
||||
> Cassandra和HBase有一个列族(column families)的概念,他们从Bigtable继承【9】。然而,把它们称为列式(column-oriented)是非常具有误导性的:在每个列族中,它们将一行中的所有列与行键一起存储,并且不使用列压缩。因此,Bigtable模型仍然主要是面向行的。
|
||||
> Cassandra 和 HBase 有一个列族(column families)的概念,他们从 Bigtable 继承【9】。然而,把它们称为列式(column-oriented)是非常具有误导性的:在每个列族中,它们将一行中的所有列与行键一起存储,并且不使用列压缩。因此,Bigtable 模型仍然主要是面向行的。
|
||||
>
|
||||
|
||||
#### 内存带宽和矢量化处理
|
||||
|
||||
对于需要扫描数百万行的数据仓库查询来说,一个巨大的瓶颈是从硬盘获取数据到内存的带宽。但是,这不是唯一的瓶颈。分析型数据库的开发人员还需要有效地利用主存储器到CPU缓存的带宽,避免CPU指令处理流水线中的分支预测错误和气泡,以及在现代CPU上使用单指令多数据(SIMD)指令【59,60】。
|
||||
对于需要扫描数百万行的数据仓库查询来说,一个巨大的瓶颈是从硬盘获取数据到内存的带宽。但是,这不是唯一的瓶颈。分析型数据库的开发人员还需要有效地利用主存储器到 CPU 缓存的带宽,避免 CPU 指令处理流水线中的分支预测错误和气泡,以及在现代 CPU 上使用单指令多数据(SIMD)指令【59,60】。
|
||||
|
||||
除了减少需要从硬盘加载的数据量以外,列式存储布局也可以有效利用CPU周期。例如,查询引擎可以将大量压缩的列数据放在CPU的L1缓存中,然后在紧密的循环(即没有函数调用)中遍历。相比较每个记录的处理都需要大量函数调用和条件判断的代码,CPU执行这样一个循环要快得多。列压缩允许列中的更多行被放进相同数量的L1缓存。前面描述的按位“与”和“或”运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为矢量化处理【58,49】。
|
||||
除了减少需要从硬盘加载的数据量以外,列式存储布局也可以有效利用 CPU 周期。例如,查询引擎可以将大量压缩的列数据放在 CPU 的 L1 缓存中,然后在紧密的循环(即没有函数调用)中遍历。相比较每个记录的处理都需要大量函数调用和条件判断的代码,CPU 执行这样一个循环要快得多。列压缩允许列中的更多行被放进相同数量的 L1 缓存。前面描述的按位 “与” 和 “或” 运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为矢量化处理【58,49】。
|
||||
|
||||
|
||||
### 列式存储中的排序顺序
|
||||
|
||||
在列式存储中,存储行的顺序并不一定很重要。按插入顺序存储它们是最简单的,因为插入一个新行只需要追加到每个列文件。但是,我们可以选择增加一个特定的顺序,就像我们之前对SSTables所做的那样,并将其用作索引机制。
|
||||
在列式存储中,存储行的顺序并不一定很重要。按插入顺序存储它们是最简单的,因为插入一个新行只需要追加到每个列文件。但是,我们可以选择增加一个特定的顺序,就像我们之前对 SSTables 所做的那样,并将其用作索引机制。
|
||||
|
||||
注意,每列独自排序是没有意义的,因为那样我们就没法知道不同列中的哪些项属于同一行。我们只能在知道一列中的第k项与另一列中的第k项属于同一行的情况才能重建出完整的行。
|
||||
注意,每列独自排序是没有意义的,因为那样我们就没法知道不同列中的哪些项属于同一行。我们只能在知道一列中的第 k 项与另一列中的第 k 项属于同一行的情况才能重建出完整的行。
|
||||
|
||||
相反,即使按列式存储数据,也需要一次对整行进行排序。数据库的管理员可以根据他们对常用查询的了解来选择表格应该被排序的列。例如,如果查询通常以日期范围为目标,例如上个月,则可以将 `date_key` 作为第一个排序键。这样查询优化器就可以只扫描上个月的行了,这比扫描所有行要快得多。
|
||||
|
||||
对于第一排序列中具有相同值的行,可以用第二排序列来进一步排序。例如,如果 `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) 中的位图一样)可以将该列压缩到几千字节 —— 即使表中有数十亿行。
|
||||
|
||||
第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长的连续的重复值。排序优先级更低的列以基本上随机的顺序出现,所以它们可能不会被压缩。但前几列排序在整体上仍然是有好处的。
|
||||
|
||||
#### 几个不同的排序顺序
|
||||
|
||||
C-Store中引入了这个想法的一个巧妙扩展,并在商业数据仓库Vertica中被采用【61,62】。不同的查询受益于不同的排序顺序,为什么不以几种不同的方式来存储相同的数据呢?无论如何,数据需要复制到多台机器,这样,如果一台机器发生故障,你不会丢失数据。你可能还需要存储以不同方式排序的冗余数据,以便在处理查询时,可以使用最适合查询模式的版本。
|
||||
C-Store 中引入了这个想法的一个巧妙扩展,并在商业数据仓库 Vertica 中被采用【61,62】。不同的查询受益于不同的排序顺序,为什么不以几种不同的方式来存储相同的数据呢?无论如何,数据需要复制到多台机器,这样,如果一台机器发生故障,你不会丢失数据。你可能还需要存储以不同方式排序的冗余数据,以便在处理查询时,可以使用最适合查询模式的版本。
|
||||
|
||||
在一个列式存储中有多个排序顺序有点类似于在一个面向行的存储中有多个次级索引。但最大的区别在于面向行的存储将每一行保存在一个地方(在堆文件或聚集索引中),次级索引只包含指向匹配行的指针。在列式存储中,通常在其他地方没有任何指向数据的指针,只有包含值的列。
|
||||
|
||||
@ -551,9 +551,9 @@ C-Store中引入了这个想法的一个巧妙扩展,并在商业数据仓库V
|
||||
|
||||
这些优化在数据仓库中是有意义的,因为其负载主要由分析人员运行的大型只读查询组成。列式存储、压缩和排序都有助于更快地读取这些查询。然而,他们的缺点是写入更加困难。
|
||||
|
||||
使用B树的就地更新方法对于压缩的列是不可能的。如果你想在排序表的中间插入一行,你很可能不得不重写所有的列文件。由于行由列中的位置标识,因此插入必须对所有列进行一致地更新。
|
||||
使用 B 树的就地更新方法对于压缩的列是不可能的。如果你想在排序表的中间插入一行,你很可能不得不重写所有的列文件。由于行由列中的位置标识,因此插入必须对所有列进行一致地更新。
|
||||
|
||||
幸运的是,本章前面已经看到了一个很好的解决方案:LSM树。所有的写操作首先进入一个内存中的存储,在这里它们被添加到一个已排序的结构中,并准备写入硬盘。内存中的存储是面向行还是列的并不重要。当已经积累了足够的写入数据时,它们将与硬盘上的列文件合并,并批量写入新文件。这基本上是Vertica所做的【62】。
|
||||
幸运的是,本章前面已经看到了一个很好的解决方案:LSM 树。所有的写操作首先进入一个内存中的存储,在这里它们被添加到一个已排序的结构中,并准备写入硬盘。内存中的存储是面向行还是列的并不重要。当已经积累了足够的写入数据时,它们将与硬盘上的列文件合并,并批量写入新文件。这基本上是 Vertica 所做的【62】。
|
||||
|
||||
查询需要检查硬盘上的列数据和最近在内存中的写入,并将两者结合起来。但是,查询优化器对用户隐藏了这个细节。从分析师的角度来看,通过插入、更新或删除操作进行修改的数据会立即反映在后续的查询中。
|
||||
|
||||
@ -561,46 +561,46 @@ C-Store中引入了这个想法的一个巧妙扩展,并在商业数据仓库V
|
||||
|
||||
并不是每个数据仓库都必定是一个列式存储:传统的面向行的数据库和其他一些架构也被使用。然而,列式存储可以显著加快专门的分析查询,所以它正在迅速变得流行起来【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) 显示了一个例子。
|
||||
|
||||
![](img/fig3-12.png)
|
||||
|
||||
**图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 中有五个维度:日期、产品、商店、促销和客户。要想象一个五维超立方体是什么样子是很困难的,但是原理是一样的:每个单元格都包含特定日期 - 产品 - 商店 - 促销 - 客户组合的销售额。这些值可以在每个维度上求和汇总。
|
||||
|
||||
物化数据立方体的优点是可以让某些查询变得非常快,因为它们已经被有效地预先计算了。例如,如果你想知道每个商店的总销售额,则只需查看合适维度的总计,而无需扫描数百万行的原始数据。
|
||||
|
||||
数据立方体的缺点是不具有查询原始数据的灵活性。例如,没有办法计算有多少比例的销售来自成本超过100美元的项目,因为价格不是其中的一个维度。因此,大多数数据仓库试图保留尽可能多的原始数据,并将聚合数据(如数据立方体)仅用作某些查询的性能提升手段。
|
||||
数据立方体的缺点是不具有查询原始数据的灵活性。例如,没有办法计算有多少比例的销售来自成本超过 100 美元的项目,因为价格不是其中的一个维度。因此,大多数数据仓库试图保留尽可能多的原始数据,并将聚合数据(如数据立方体)仅用作某些查询的性能提升手段。
|
||||
|
||||
|
||||
## 本章小结
|
||||
|
||||
在本章中,我们试图深入了解数据库是如何处理存储和检索的。将数据存储在数据库中会发生什么?稍后再次查询数据时数据库会做什么?
|
||||
|
||||
在高层次上,我们看到存储引擎分为两大类:针对**事务处理(OLTP)** 优化的存储引擎和针对**在线分析(OLAP)** 优化的存储引擎。这两类使用场景的访问模式之间有很大的区别:
|
||||
在高层次上,我们看到存储引擎分为两大类:针对 **事务处理(OLTP)** 优化的存储引擎和针对 **在线分析(OLAP)** 优化的存储引擎。这两类使用场景的访问模式之间有很大的区别:
|
||||
|
||||
* OLTP系统通常面向最终用户,这意味着系统可能会收到大量的请求。为了处理负载,应用程序在每个查询中通常只访问少量的记录。应用程序使用某种键来请求记录,存储引擎使用索引来查找所请求的键的数据。硬盘查找时间往往是这里的瓶颈。
|
||||
* 数据仓库和类似的分析系统会低调一些,因为它们主要由业务分析人员使用,而不是最终用户。它们的查询量要比OLTP系统少得多,但通常每个查询开销高昂,需要在短时间内扫描数百万条记录。硬盘带宽(而不是查找时间)往往是瓶颈,列式存储是针对这种工作负载的日益流行的解决方案。
|
||||
* OLTP 系统通常面向最终用户,这意味着系统可能会收到大量的请求。为了处理负载,应用程序在每个查询中通常只访问少量的记录。应用程序使用某种键来请求记录,存储引擎使用索引来查找所请求的键的数据。硬盘查找时间往往是这里的瓶颈。
|
||||
* 数据仓库和类似的分析系统会低调一些,因为它们主要由业务分析人员使用,而不是最终用户。它们的查询量要比 OLTP 系统少得多,但通常每个查询开销高昂,需要在短时间内扫描数百万条记录。硬盘带宽(而不是查找时间)往往是瓶颈,列式存储是针对这种工作负载的日益流行的解决方案。
|
||||
|
||||
在OLTP这一边,我们能看到两派主流的存储引擎:
|
||||
在 OLTP 这一边,我们能看到两派主流的存储引擎:
|
||||
|
||||
* 日志结构学派:只允许追加到文件和删除过时的文件,但不会更新已经写入的文件。Bitcask、SSTables、LSM树、LevelDB、Cassandra、HBase、Lucene等都属于这个类别。
|
||||
* 就地更新学派:将硬盘视为一组可以覆写的固定大小的页面。 B树是这种理念的典范,用在所有主要的关系数据库和许多非关系型数据库中。
|
||||
* 日志结构学派:只允许追加到文件和删除过时的文件,但不会更新已经写入的文件。Bitcask、SSTables、LSM 树、LevelDB、Cassandra、HBase、Lucene 等都属于这个类别。
|
||||
* 就地更新学派:将硬盘视为一组可以覆写的固定大小的页面。 B 树是这种理念的典范,用在所有主要的关系数据库和许多非关系型数据库中。
|
||||
|
||||
日志结构的存储引擎是相对较新的技术。他们的主要想法是,通过系统性地将随机访问写入转换为硬盘上的顺序写入,由于硬盘驱动器和固态硬盘的性能特点,可以实现更高的写入吞吐量。
|
||||
|
||||
关于OLTP,我们最后还介绍了一些更复杂的索引结构,以及针对所有数据都放在内存里而优化的数据库。
|
||||
关于 OLTP,我们最后还介绍了一些更复杂的索引结构,以及针对所有数据都放在内存里而优化的数据库。
|
||||
|
||||
然后,我们暂时放下了存储引擎的内部细节,查看了典型数据仓库的高级架构,并说明了为什么分析工作负载与OLTP差别很大:当你的查询需要在大量行中顺序扫描时,索引的重要性就会降低很多。相反,非常紧凑地编码数据变得非常重要,以最大限度地减少查询需要从硬盘读取的数据量。我们讨论了列式存储如何帮助实现这一目标。
|
||||
然后,我们暂时放下了存储引擎的内部细节,查看了典型数据仓库的高级架构,并说明了为什么分析工作负载与 OLTP 差别很大:当你的查询需要在大量行中顺序扫描时,索引的重要性就会降低很多。相反,非常紧凑地编码数据变得非常重要,以最大限度地减少查询需要从硬盘读取的数据量。我们讨论了列式存储如何帮助实现这一目标。
|
||||
|
||||
作为一名应用程序开发人员,如果你掌握了有关存储引擎内部的知识,那么你就能更好地了解哪种工具最适合你的特定应用程序。如果你需要调整数据库的调整参数,这种理解可以让你设想一个更高或更低的值可能会产生什么效果。
|
||||
|
||||
|
324
zh-tw/ch3.md
324
zh-tw/ch3.md
@ -13,17 +13,17 @@
|
||||
|
||||
一個數據庫在最基礎的層次上需要完成兩件事情:當你把資料交給資料庫時,它應當把資料儲存起來;而後當你向資料庫要資料時,它應當把資料返回給你。
|
||||
|
||||
在[第二章](ch2.md)中,我們討論了資料模型和查詢語言,即程式設計師將資料錄入資料庫的格式,以及再次要回資料的機制。在本章中我們會從資料庫的視角來討論同樣的問題:資料庫如何儲存我們提供的資料,以及如何在我們需要時重新找到資料。
|
||||
在 [第二章](ch2.md) 中,我們討論了資料模型和查詢語言,即程式設計師將資料錄入資料庫的格式,以及再次要回資料的機制。在本章中我們會從資料庫的視角來討論同樣的問題:資料庫如何儲存我們提供的資料,以及如何在我們需要時重新找到資料。
|
||||
|
||||
作為程式設計師,為什麼要關心資料庫內部儲存與檢索的機理?你可能不會去從頭開始實現自己的儲存引擎,但是你**確實**需要從許多可用的儲存引擎中選擇一個合適的。而且為了讓儲存引擎能在你的工作負載型別上執行良好,你也需要大致瞭解儲存引擎在底層究竟做了什麼。
|
||||
作為程式設計師,為什麼要關心資料庫內部儲存與檢索的機理?你可能不會去從頭開始實現自己的儲存引擎,但是你 **確實** 需要從許多可用的儲存引擎中選擇一個合適的。而且為了讓儲存引擎能在你的工作負載型別上執行良好,你也需要大致瞭解儲存引擎在底層究竟做了什麼。
|
||||
|
||||
特別需要注意,針對**事務性**負載最佳化的和針對**分析性**負載最佳化的儲存引擎之間存在巨大差異。稍後我們將在 “[事務處理還是分析?](#事務處理還是分析?)” 一節中探討這一區別,並在 “[列式儲存](#列式儲存)”中討論一系列針對分析性負載而最佳化的儲存引擎。
|
||||
特別需要注意,針對 **事務性** 負載最佳化的和針對 **分析性** 負載最佳化的儲存引擎之間存在巨大差異。稍後我們將在 “[事務處理還是分析?](#事務處理還是分析?)” 一節中探討這一區別,並在 “[列式儲存](#列式儲存)” 中討論一系列針對分析性負載而最佳化的儲存引擎。
|
||||
|
||||
但首先,我們將從你可能已經很熟悉的兩大類資料庫(傳統的關係型資料庫和很多所謂的“NoSQL”資料庫)中使用的**儲存引擎**來開始本章的內容。我們將研究兩大類儲存引擎:**日誌結構(log-structured)** 的儲存引擎,以及**面向頁面(page-oriented)** 的儲存引擎(例如B樹)。
|
||||
但首先,我們將從你可能已經很熟悉的兩大類資料庫(傳統的關係型資料庫和很多所謂的 “NoSQL” 資料庫)中使用的 **儲存引擎** 來開始本章的內容。我們將研究兩大類儲存引擎:**日誌結構(log-structured)** 的儲存引擎,以及 **面向頁面(page-oriented)** 的儲存引擎(例如 B 樹)。
|
||||
|
||||
## 驅動資料庫的資料結構
|
||||
|
||||
世界上最簡單的資料庫可以用兩個Bash函式實現:
|
||||
世界上最簡單的資料庫可以用兩個 Bash 函式實現:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
@ -36,7 +36,7 @@ db_get () {
|
||||
}
|
||||
```
|
||||
|
||||
這兩個函式實現了鍵值儲存的功能。執行 `db_set key value` 會將 **鍵(key)** 和**值(value)** 儲存在資料庫中。鍵和值(幾乎)可以是你喜歡的任何東西,例如,值可以是JSON文件。然後呼叫 `db_get key` 會查詢與該鍵關聯的最新值並將其返回。
|
||||
這兩個函式實現了鍵值儲存的功能。執行 `db_set key value` 會將 **鍵(key)** 和 **值(value)** 儲存在資料庫中。鍵和值(幾乎)可以是你喜歡的任何東西,例如,值可以是 JSON 文件。然後呼叫 `db_get key` 會查詢與該鍵關聯的最新值並將其返回。
|
||||
|
||||
麻雀雖小,五臟俱全:
|
||||
|
||||
@ -49,7 +49,7 @@ $ db_get 42
|
||||
{"name":"San Francisco","attractions":["Golden Gate Bridge"]}
|
||||
```
|
||||
|
||||
底層的儲存格式非常簡單:一個文字檔案,每行包含一條逗號分隔的鍵值對(忽略轉義問題的話,大致與CSV檔案類似)。每次對 `db_set` 的呼叫都會向檔案末尾追加記錄,所以更新鍵的時候舊版本的值不會被覆蓋 —— 因而查詢最新值的時候,需要找到檔案中鍵最後一次出現的位置(因此 `db_get` 中使用了 `tail -n 1 ` )。
|
||||
底層的儲存格式非常簡單:一個文字檔案,每行包含一條逗號分隔的鍵值對(忽略轉義問題的話,大致與 CSV 檔案類似)。每次對 `db_set` 的呼叫都會向檔案末尾追加記錄,所以更新鍵的時候舊版本的值不會被覆蓋 —— 因而查詢最新值的時候,需要找到檔案中鍵最後一次出現的位置(因此 `db_get` 中使用了 `tail -n 1` )。
|
||||
|
||||
```bash
|
||||
$ db_set 42 '{"name":"San Francisco","attractions":["Exploratorium"]}'
|
||||
@ -63,46 +63,46 @@ $ cat database
|
||||
42,{"name":"San Francisco","attractions":["Exploratorium"]}
|
||||
```
|
||||
|
||||
`db_set` 函式對於極其簡單的場景其實有非常好的效能,因為在檔案尾部追加寫入通常是非常高效的。與`db_set`做的事情類似,許多資料庫在內部使用了**日誌(log)**,也就是一個 **僅追加(append-only)** 的資料檔案。真正的資料庫有更多的問題需要處理(如併發控制,回收硬碟空間以避免日誌無限增長,處理錯誤與部分寫入的記錄),但基本原理是一樣的。日誌極其有用,我們還將在本書的其它部分重複見到它好幾次。
|
||||
`db_set` 函式對於極其簡單的場景其實有非常好的效能,因為在檔案尾部追加寫入通常是非常高效的。與 `db_set` 做的事情類似,許多資料庫在內部使用了 **日誌(log)**,也就是一個 **僅追加(append-only)** 的資料檔案。真正的資料庫有更多的問題需要處理(如併發控制,回收硬碟空間以避免日誌無限增長,處理錯誤與部分寫入的記錄),但基本原理是一樣的。日誌極其有用,我們還將在本書的其它部分重複見到它好幾次。
|
||||
|
||||
> **日誌(log)** 這個詞通常指應用日誌:即應用程式輸出的描述正在發生的事情的文字。本書在更普遍的意義下使用**日誌**這一詞:一個僅追加的記錄序列。它可能壓根就不是給人類看的,它可以使用二進位制格式,並僅能由其他程式讀取。
|
||||
> **日誌(log)** 這個詞通常指應用日誌:即應用程式輸出的描述正在發生的事情的文字。本書在更普遍的意義下使用 **日誌** 這一詞:一個僅追加的記錄序列。它可能壓根就不是給人類看的,它可以使用二進位制格式,並僅能由其他程式讀取。
|
||||
|
||||
另一方面,如果這個資料庫中有著大量記錄,則這個`db_get` 函式的效能會非常糟糕。每次你想查詢一個鍵時,`db_get` 必須從頭到尾掃描整個資料庫檔案來查詢鍵的出現。用演算法的語言來說,查詢的開銷是 `O(n)` :如果資料庫記錄數量 n 翻了一倍,查詢時間也要翻一倍。這就不好了。
|
||||
另一方面,如果這個資料庫中有著大量記錄,則這個 `db_get` 函式的效能會非常糟糕。每次你想查詢一個鍵時,`db_get` 必須從頭到尾掃描整個資料庫檔案來查詢鍵的出現。用演算法的語言來說,查詢的開銷是 `O(n)` :如果資料庫記錄數量 n 翻了一倍,查詢時間也要翻一倍。這就不好了。
|
||||
|
||||
為了高效查詢資料庫中特定鍵的值,我們需要一個數據結構:**索引(index)**。本章將介紹一系列的索引結構,並在它們之間進行比較。索引背後的大致思想是透過儲存一些額外的元資料作為路標來幫助你找到想要的資料。如果你想以幾種不同的方式搜尋同一份資料,那麼你也許需要在資料的不同部分上建立多個索引。
|
||||
|
||||
索引是從主資料衍生的**額外的(additional)** 結構。許多資料庫允許新增與刪除索引,這不會影響資料的內容,而只會影響查詢的效能。維護額外的結構會產生開銷,特別是在寫入時。寫入效能很難超過簡單地追加寫入檔案,因為追加寫入是最簡單的寫入操作。任何型別的索引通常都會減慢寫入速度,因為每次寫入資料時都需要更新索引。
|
||||
索引是從主資料衍生的 **額外的(additional)** 結構。許多資料庫允許新增與刪除索引,這不會影響資料的內容,而只會影響查詢的效能。維護額外的結構會產生開銷,特別是在寫入時。寫入效能很難超過簡單地追加寫入檔案,因為追加寫入是最簡單的寫入操作。任何型別的索引通常都會減慢寫入速度,因為每次寫入資料時都需要更新索引。
|
||||
|
||||
這是儲存系統中一個重要的權衡:精心選擇的索引加快了讀查詢的速度,但是每個索引都會拖慢寫入速度。因為這個原因,資料庫預設並不會索引所有的內容,而需要你,也就是程式設計師或資料庫管理員(DBA),基於對應用的典型查詢模式的瞭解來手動選擇索引。你可以選擇那些能為應用帶來最大收益而且又不會引入超出必要開銷的索引。
|
||||
|
||||
|
||||
### 雜湊索引
|
||||
|
||||
讓我們從**鍵值資料(key-value Data)** 的索引開始。這不是你可以索引的唯一資料型別,但鍵值資料是很常見的。對於更復雜的索引來說,這也是一個有用的構建模組。
|
||||
讓我們從 **鍵值資料(key-value Data)** 的索引開始。這不是你可以索引的唯一資料型別,但鍵值資料是很常見的。對於更復雜的索引來說,這也是一個有用的構建模組。
|
||||
|
||||
鍵值儲存與在大多數程式語言中可以找到的**字典(dictionary)** 型別非常相似,通常字典都是用**雜湊對映(hash map)**或**散列表(hash table)**實現的。雜湊對映在許多演算法教科書中都有描述【1,2】,所以這裡我們不會討論它的工作細節。既然我們已經可以用雜湊對映來表示**記憶體中**的資料結構,為什麼不使用它來索引**硬碟上**的資料呢?
|
||||
鍵值儲存與在大多數程式語言中可以找到的 **字典(dictionary)** 型別非常相似,通常字典都是用 **雜湊對映(hash map)** 或 **散列表(hash table)** 實現的。雜湊對映在許多演算法教科書中都有描述【1,2】,所以這裡我們不會討論它的工作細節。既然我們已經可以用雜湊對映來表示 **記憶體中** 的資料結構,為什麼不使用它來索引 **硬碟上** 的資料呢?
|
||||
|
||||
假設我們的資料儲存只是一個追加寫入的檔案,就像前面的例子一樣,那麼最簡單的索引策略就是:保留一個記憶體中的雜湊對映,其中每個鍵都對映到資料檔案中的一個位元組偏移量,指明瞭可以找到對應值的位置,如[圖3-1](../img/fig3-1.png)所示。當你將新的鍵值對追加寫入檔案中時,還要更新雜湊對映,以反映剛剛寫入的資料的偏移量(這同時適用於插入新鍵與更新現有鍵)。當你想查詢一個值時,使用雜湊對映來查詢資料檔案中的偏移量,**尋找(seek)** 該位置並讀取該值即可。
|
||||
假設我們的資料儲存只是一個追加寫入的檔案,就像前面的例子一樣,那麼最簡單的索引策略就是:保留一個記憶體中的雜湊對映,其中每個鍵都對映到資料檔案中的一個位元組偏移量,指明瞭可以找到對應值的位置,如 [圖 3-1](../img/fig3-1.png) 所示。當你將新的鍵值對追加寫入檔案中時,還要更新雜湊對映,以反映剛剛寫入的資料的偏移量(這同時適用於插入新鍵與更新現有鍵)。當你想查詢一個值時,使用雜湊對映來查詢資料檔案中的偏移量,**尋找(seek)** 該位置並讀取該值即可。
|
||||
|
||||
![](../img/fig3-1.png)
|
||||
|
||||
**圖3-1 以類CSV格式儲存鍵值對的日誌,並使用記憶體雜湊對映進行索引。**
|
||||
**圖 3-1 以類 CSV 格式儲存鍵值對的日誌,並使用記憶體雜湊對映進行索引。**
|
||||
|
||||
聽上去簡單,但這是一個可行的方法。現實中,Bitcask實際上就是這麼做的(Riak中預設的儲存引擎)【3】。 Bitcask提供高效能的讀取和寫入操作,但要求所有的鍵必須能放入可用記憶體中,因為雜湊對映完全保留在記憶體中。而資料值可以使用比可用記憶體更多的空間,因為可以在硬碟上透過一次硬碟查詢操作來載入所需部分,如果資料檔案的那部分已經在檔案系統快取中,則讀取根本不需要任何硬碟I/O。
|
||||
聽上去簡單,但這是一個可行的方法。現實中,Bitcask 實際上就是這麼做的(Riak 中預設的儲存引擎)【3】。 Bitcask 提供高效能的讀取和寫入操作,但要求所有的鍵必須能放入可用記憶體中,因為雜湊對映完全保留在記憶體中。而資料值可以使用比可用記憶體更多的空間,因為可以在硬碟上透過一次硬碟查詢操作來載入所需部分,如果資料檔案的那部分已經在檔案系統快取中,則讀取根本不需要任何硬碟 I/O。
|
||||
|
||||
像Bitcask這樣的儲存引擎非常適合每個鍵的值經常更新的情況。例如,鍵可能是某個貓咪影片的網址(URL),而值可能是該影片被播放的次數(每次有人點選播放按鈕時遞增)。在這種型別的工作負載中,有很多寫操作,但是沒有太多不同的鍵 —— 每個鍵有很多的寫操作,但是將所有鍵儲存在記憶體中是可行的。
|
||||
像 Bitcask 這樣的儲存引擎非常適合每個鍵的值經常更新的情況。例如,鍵可能是某個貓咪影片的網址(URL),而值可能是該影片被播放的次數(每次有人點選播放按鈕時遞增)。在這種型別的工作負載中,有很多寫操作,但是沒有太多不同的鍵 —— 每個鍵有很多的寫操作,但是將所有鍵儲存在記憶體中是可行的。
|
||||
|
||||
直到現在,我們只是追加寫入一個檔案 —— 所以如何避免最終用完硬碟空間?一種好的解決方案是,將日誌分為特定大小的段(segment),當日志增長到特定尺寸時關閉當前段檔案,並開始寫入一個新的段檔案。然後,我們就可以對這些段進行**壓縮(compaction)**,如[圖3-2](../img/fig3-2.png)所示。這裡的壓縮意味著在日誌中丟棄重複的鍵,只保留每個鍵的最近更新。
|
||||
直到現在,我們只是追加寫入一個檔案 —— 所以如何避免最終用完硬碟空間?一種好的解決方案是,將日誌分為特定大小的段(segment),當日志增長到特定尺寸時關閉當前段檔案,並開始寫入一個新的段檔案。然後,我們就可以對這些段進行 **壓縮(compaction)**,如 [圖 3-2](../img/fig3-2.png) 所示。這裡的壓縮意味著在日誌中丟棄重複的鍵,只保留每個鍵的最近更新。
|
||||
|
||||
![](../img/fig3-2.png)
|
||||
|
||||
**圖3-2 鍵值更新日誌(統計貓咪影片的播放次數)的壓縮,只保留每個鍵的最近值**
|
||||
**圖 3-2 鍵值更新日誌(統計貓咪影片的播放次數)的壓縮,只保留每個鍵的最近值**
|
||||
|
||||
而且,由於壓縮經常會使得段變得很小(假設在一個段內鍵被平均重寫了好幾次),我們也可以在執行壓縮的同時將多個段合併在一起,如[圖3-3](../img/fig3-3.png)所示。段被寫入後永遠不會被修改,所以合併的段被寫入一個新的檔案。凍結段的合併和壓縮可以在後臺執行緒中完成,這個過程進行的同時,我們仍然可以繼續使用舊的段檔案來正常提供讀寫請求。合併過程完成後,我們將讀取請求轉換為使用新合併的段而不是舊的段 —— 然後舊的段檔案就可以簡單地刪除掉了。
|
||||
而且,由於壓縮經常會使得段變得很小(假設在一個段內鍵被平均重寫了好幾次),我們也可以在執行壓縮的同時將多個段合併在一起,如 [圖 3-3](../img/fig3-3.png) 所示。段被寫入後永遠不會被修改,所以合併的段被寫入一個新的檔案。凍結段的合併和壓縮可以在後臺執行緒中完成,這個過程進行的同時,我們仍然可以繼續使用舊的段檔案來正常提供讀寫請求。合併過程完成後,我們將讀取請求轉換為使用新合併的段而不是舊的段 —— 然後舊的段檔案就可以簡單地刪除掉了。
|
||||
|
||||
![](../img/fig3-3.png)
|
||||
|
||||
**圖3-3 同時執行壓縮和分段合併**
|
||||
**圖 3-3 同時執行壓縮和分段合併**
|
||||
|
||||
每個段現在都有自己的記憶體散列表,將鍵對映到檔案偏移量。為了找到一個鍵的值,我們首先檢查最近的段的雜湊對映;如果鍵不存在,我們就檢查第二個最近的段,依此類推。合併過程將保持段的數量足夠小,所以查詢過程不需要檢查太多的雜湊對映。
|
||||
|
||||
@ -110,11 +110,11 @@ $ cat database
|
||||
|
||||
* 檔案格式
|
||||
|
||||
CSV不是日誌的最佳格式。使用二進位制格式更快,更簡單:首先以位元組為單位對字串的長度進行編碼,然後是原始的字串(不需要轉義)。
|
||||
CSV 不是日誌的最佳格式。使用二進位制格式更快,更簡單:首先以位元組為單位對字串的長度進行編碼,然後是原始的字串(不需要轉義)。
|
||||
|
||||
* 刪除記錄
|
||||
|
||||
如果要刪除一個鍵及其關聯的值,則必須在資料檔案中追加一個特殊的刪除記錄(邏輯刪除,有時被稱為墓碑,即tombstone)。當日志段被合併時,合併過程會透過這個墓碑知道要將被刪除鍵的所有歷史值都丟棄掉。
|
||||
如果要刪除一個鍵及其關聯的值,則必須在資料檔案中追加一個特殊的刪除記錄(邏輯刪除,有時被稱為墓碑,即 tombstone)。當日志段被合併時,合併過程會透過這個墓碑知道要將被刪除鍵的所有歷史值都丟棄掉。
|
||||
|
||||
* 崩潰恢復
|
||||
|
||||
@ -122,7 +122,7 @@ $ cat database
|
||||
|
||||
* 部分寫入記錄
|
||||
|
||||
資料庫隨時可能崩潰,包括在將記錄追加到日誌的過程中。 Bitcask檔案包含校驗和,允許檢測和忽略日誌中的這些損壞部分。
|
||||
資料庫隨時可能崩潰,包括在將記錄追加到日誌的過程中。 Bitcask 檔案包含校驗和,允許檢測和忽略日誌中的這些損壞部分。
|
||||
|
||||
* 併發控制
|
||||
|
||||
@ -130,185 +130,185 @@ $ cat database
|
||||
|
||||
乍一看,僅追加日誌似乎很浪費:為什麼不直接在檔案裡更新,用新值覆蓋舊值?僅追加的設計之所以是個好的設計,有如下幾個原因:
|
||||
|
||||
* 追加和分段合併都是順序寫入操作,通常比隨機寫入快得多,尤其是在磁性機械硬碟上。在某種程度上,順序寫入在基於快閃記憶體的**固態硬碟(SSD)** 上也是好的選擇【4】。我們將在“[比較B樹和LSM樹](#比較B樹和LSM樹)”中進一步討論這個問題。
|
||||
* 追加和分段合併都是順序寫入操作,通常比隨機寫入快得多,尤其是在磁性機械硬碟上。在某種程度上,順序寫入在基於快閃記憶體的 **固態硬碟(SSD)** 上也是好的選擇【4】。我們將在“[比較 B 樹和 LSM 樹](#比較B樹和LSM樹)”中進一步討論這個問題。
|
||||
* 如果段檔案是僅追加的或不可變的,併發和崩潰恢復就簡單多了。例如,當一個數據值被更新的時候發生崩潰,你不用擔心檔案裡將會同時包含舊值和新值各自的一部分。
|
||||
* 合併舊段的處理也可以避免資料檔案隨著時間的推移而碎片化的問題。
|
||||
|
||||
但是,散列表索引也有其侷限性:
|
||||
|
||||
* 散列表必須能放進記憶體。如果你有非常多的鍵,那真是倒黴。原則上可以在硬碟上維護一個雜湊對映,不幸的是硬碟雜湊對映很難表現優秀。它需要大量的隨機訪問I/O,當它用滿時想要再增長是很昂貴的,並且雜湊衝突的處理也需要很煩瑣的邏輯【5】。
|
||||
* 範圍查詢效率不高。例如,你無法輕鬆掃描kitty00000和kitty99999之間的所有鍵——你必須在雜湊對映中單獨查詢每個鍵。
|
||||
* 散列表必須能放進記憶體。如果你有非常多的鍵,那真是倒黴。原則上可以在硬碟上維護一個雜湊對映,不幸的是硬碟雜湊對映很難表現優秀。它需要大量的隨機訪問 I/O,當它用滿時想要再增長是很昂貴的,並且雜湊衝突的處理也需要很煩瑣的邏輯【5】。
|
||||
* 範圍查詢效率不高。例如,你無法輕鬆掃描 kitty00000 和 kitty99999 之間的所有鍵 —— 你必須在雜湊對映中單獨查詢每個鍵。
|
||||
|
||||
在下一節中,我們將看到一個沒有這些限制的索引結構。
|
||||
|
||||
|
||||
### 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段,只保留每個鍵的最新值**
|
||||
**圖 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**
|
||||
**圖 3-5 具有記憶體索引的 SSTable**
|
||||
|
||||
你仍然需要一個記憶體中的索引來告訴你一些鍵的偏移量,但它可以是稀疏的:每幾千位元組的段檔案有一個鍵就足夠了,因為幾千位元組可以很快地被掃描完[^i]。
|
||||
你仍然需要一個記憶體中的索引來告訴你一些鍵的偏移量,但它可以是稀疏的:每幾千位元組的段檔案有一個鍵就足夠了,因為幾千位元組可以很快地被掃描完 [^i]。
|
||||
|
||||
[^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,請注意區分。
|
||||
[^譯註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儲存引擎。
|
||||
最初這種索引結構是由 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 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 樹。
|
||||
|
||||
從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) 所示。
|
||||
|
||||
![](../img/fig3-6.png)
|
||||
|
||||
**圖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),該頁面或者直接包含每個鍵的值,或者包含了對可以找到值的頁面的引用。
|
||||
|
||||
在B樹的一個頁面中對子頁面的引用的數量稱為分支因子。例如,在[圖3-6](../img/fig3-6.png)中,分支因子是6。在實踐中,分支因子取決於儲存頁面引用和範圍邊界所需的空間量,但通常是幾百個。
|
||||
在 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)
|
||||
|
||||
**圖3-7 透過分割頁面來生長B樹**
|
||||
**圖 3-7 透過分割頁面來生長 B 樹**
|
||||
|
||||
[^ii]: 向B樹中插入一個新的鍵是相當符合直覺的,但刪除一個鍵(同時保持樹平衡)就會牽扯很多其他東西了【2】。
|
||||
[^ii]: 向 B 樹中插入一個新的鍵是相當符合直覺的,但刪除一個鍵(同時保持樹平衡)就會牽扯很多其他東西了【2】。
|
||||
|
||||
這個演算法可以確保樹保持平衡:具有n個鍵的B樹總是具有 $O(log n)$ 的深度。大多數資料庫可以放入一個三到四層的B樹,所以你不需要追蹤多個頁面引用來找到你正在查詢的頁面(分支因子為500的4KB頁面的四層樹可以儲存多達256TB的資料)。
|
||||
這個演算法可以確保樹保持平衡:具有 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#快照隔離和可重複讀)”中看到。
|
||||
* 我們可以透過不儲存整個鍵,而是縮短其大小,來節省頁面空間。特別是在樹內部的頁面上,鍵只需要提供足夠的資訊來充當鍵範圍之間的邊界。在頁面中包含更多的鍵允許樹具有更高的分支因子,因此也就允許更少的層級[^iii]。
|
||||
* 通常,頁面可以放置在硬碟上的任何位置;沒有什麼要求相鄰鍵範圍的頁面也放在硬碟上相鄰的區域。如果某個查詢需要按照排序順序掃描大部分的鍵範圍,那麼這種按頁面儲存的佈局可能會效率低下,因為每次頁面讀取可能都需要進行硬碟查詢。因此,許多B樹的實現在佈局樹時會盡量使葉子頁面按順序出現在硬碟上。但是,隨著樹的增長,要維持這個順序是很困難的。相比之下,由於LSM樹在合併過程中一次又一次地重寫儲存的大部分,所以它們更容易使順序鍵在硬碟上彼此靠近。
|
||||
* 一些資料庫(如 LMDB)使用寫時複製方案【21】,而不是覆蓋頁面並維護 WAL 以支援崩潰恢復。修改的頁面被寫入到不同的位置,並且還在樹中建立了父頁面的新版本,以指向新的位置。這種方法對於併發控制也很有用,我們將在 “[快照隔離和可重複讀](ch7.md#快照隔離和可重複讀)” 中看到。
|
||||
* 我們可以透過不儲存整個鍵,而是縮短其大小,來節省頁面空間。特別是在樹內部的頁面上,鍵只需要提供足夠的資訊來充當鍵範圍之間的邊界。在頁面中包含更多的鍵允許樹具有更高的分支因子,因此也就允許更少的層級 [^iii]。
|
||||
* 通常,頁面可以放置在硬碟上的任何位置;沒有什麼要求相鄰鍵範圍的頁面也放在硬碟上相鄰的區域。如果某個查詢需要按照排序順序掃描大部分的鍵範圍,那麼這種按頁面儲存的佈局可能會效率低下,因為每次頁面讀取可能都需要進行硬碟查詢。因此,許多 B 樹的實現在佈局樹時會盡量使葉子頁面按順序出現在硬碟上。但是,隨著樹的增長,要維持這個順序是很困難的。相比之下,由於 LSM 樹在合併過程中一次又一次地重寫儲存的大部分,所以它們更容易使順序鍵在硬碟上彼此靠近。
|
||||
* 額外的指標已被新增到樹中。例如,每個葉子頁面可以引用其左邊和右邊的兄弟頁面,使得不用跳回父頁面就能按順序對鍵進行掃描。
|
||||
* B樹的變體如分形樹(fractal tree)【22】借用一些日誌結構的思想來減少硬碟查詢(而且它們與分形無關)。
|
||||
* B 樹的變體如分形樹(fractal tree)【22】借用一些日誌結構的思想來減少硬碟查詢(而且它們與分形無關)。
|
||||
|
||||
[^iii]: 這個變種有時被稱為B+樹,但因為這個最佳化已被廣泛使用,所以經常無法區分於其它的B樹變種。
|
||||
[^iii]: 這個變種有時被稱為 B+ 樹,但因為這個最佳化已被廣泛使用,所以經常無法區分於其它的 B 樹變種。
|
||||
|
||||
### 比較B樹和LSM樹
|
||||
|
||||
儘管B樹實現通常比LSM樹實現更成熟,但LSM樹由於其效能特點也非常有趣。根據經驗,通常LSM樹的寫入速度更快,而B樹的讀取速度更快【23】。 LSM樹上的讀取通常比較慢,因為它們必須檢查幾種不同的資料結構和不同壓縮(Compaction)層級的SSTables。
|
||||
儘管 B 樹實現通常比 LSM 樹實現更成熟,但 LSM 樹由於其效能特點也非常有趣。根據經驗,通常 LSM 樹的寫入速度更快,而 B 樹的讀取速度更快【23】。 LSM 樹上的讀取通常比較慢,因為它們必須檢查幾種不同的資料結構和不同壓縮(Compaction)層級的 SSTables。
|
||||
|
||||
然而,基準測試的結果通常和工作負載的細節相關。你需要用你特有的工作負載來測試系統,以便進行有效的比較。在本節中,我們將簡要討論一些在衡量儲存引擎效能時值得考慮的事情。
|
||||
|
||||
#### 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】。
|
||||
|
||||
在許多固態硬碟上,韌體內部使用了日誌結構化演算法,以將隨機寫入轉變為順序寫入底層儲存晶片,因此儲存引擎寫入模式的影響不太明顯【19】。但是,較低的寫入放大率和減少的碎片仍然對固態硬碟更有利:更緊湊地表示資料允許在可用的I/O頻寬內處理更多的讀取和寫入請求。
|
||||
在許多固態硬碟上,韌體內部使用了日誌結構化演算法,以將隨機寫入轉變為順序寫入底層儲存晶片,因此儲存引擎寫入模式的影響不太明顯【19】。但是,較低的寫入放大率和減少的碎片仍然對固態硬碟更有利:更緊湊地表示資料允許在可用的 I/O 頻寬內處理更多的讀取和寫入請求。
|
||||
|
||||
#### LSM樹的缺點
|
||||
|
||||
日誌結構儲存的缺點是壓縮過程有時會干擾正在進行的讀寫操作。儘管儲存引擎嘗試增量地執行壓縮以儘量不影響併發訪問,但是硬碟資源有限,所以很容易發生某個請求需要等待硬碟先完成昂貴的壓縮操作。對吞吐量和平均響應時間的影響通常很小,但是日誌結構化儲存引擎在更高百分位的響應時間(請參閱“[描述效能](ch1.md#描述效能)”)有時會相當長,而B樹的行為則相對更具可預測性【28】。
|
||||
日誌結構儲存的缺點是壓縮過程有時會干擾正在進行的讀寫操作。儘管儲存引擎嘗試增量地執行壓縮以儘量不影響併發訪問,但是硬碟資源有限,所以很容易發生某個請求需要等待硬碟先完成昂貴的壓縮操作。對吞吐量和平均響應時間的影響通常很小,但是日誌結構化儲存引擎在更高百分位的響應時間(請參閱 “[描述效能](ch1.md#描述效能)”)有時會相當長,而 B 樹的行為則相對更具可預測性【28】。
|
||||
|
||||
壓縮的另一個問題出現在高寫入吞吐量時:硬碟的有限寫入頻寬需要在初始寫入(記錄日誌和重新整理記憶體表到硬碟)和在後臺執行的壓縮執行緒之間共享。寫入空資料庫時,可以使用全硬碟頻寬進行初始寫入,但資料庫越大,壓縮所需的硬碟頻寬就越多。
|
||||
|
||||
如果寫入吞吐量很高,並且壓縮沒有仔細配置好,有可能導致壓縮跟不上寫入速率。在這種情況下,硬碟上未合併段的數量不斷增加,直到硬碟空間用完,讀取速度也會減慢,因為它們需要檢查更多的段檔案。通常情況下,即使壓縮無法跟上,基於SSTable的儲存引擎也不會限制傳入寫入的速率,所以你需要進行明確的監控來檢測這種情況【29,30】。
|
||||
如果寫入吞吐量很高,並且壓縮沒有仔細配置好,有可能導致壓縮跟不上寫入速率。在這種情況下,硬碟上未合併段的數量不斷增加,直到硬碟空間用完,讀取速度也會減慢,因為它們需要檢查更多的段檔案。通常情況下,即使壓縮無法跟上,基於 SSTable 的儲存引擎也不會限制傳入寫入的速率,所以你需要進行明確的監控來檢測這種情況【29,30】。
|
||||
|
||||
B樹的一個優點是每個鍵只存在於索引中的一個位置,而日誌結構化的儲存引擎可能在不同的段中有相同鍵的多個副本。這個方面使得B樹在想要提供強大的事務語義的資料庫中很有吸引力:在許多關係資料庫中,事務隔離是透過在鍵範圍上使用鎖來實現的,在B樹索引中,這些鎖可以直接附加到樹上【5】。在[第七章](ch7.md)中,我們將更詳細地討論這一點。
|
||||
B 樹的一個優點是每個鍵只存在於索引中的一個位置,而日誌結構化的儲存引擎可能在不同的段中有相同鍵的多個副本。這個方面使得 B 樹在想要提供強大的事務語義的資料庫中很有吸引力:在許多關係資料庫中,事務隔離是透過在鍵範圍上使用鎖來實現的,在 B 樹索引中,這些鎖可以直接附加到樹上【5】。在 [第七章](ch7.md) 中,我們將更詳細地討論這一點。
|
||||
|
||||
B樹在資料庫架構中是非常根深蒂固的,為許多工作負載都提供了始終如一的良好效能,所以它們不可能很快就會消失。在新的資料儲存中,日誌結構化索引變得越來越流行。沒有快速和容易的規則來確定哪種型別的儲存引擎對你的場景更好,所以值得去透過一些測試來得到相關的經驗。
|
||||
B 樹在資料庫架構中是非常根深蒂固的,為許多工作負載都提供了始終如一的良好效能,所以它們不可能很快就會消失。在新的資料儲存中,日誌結構化索引變得越來越流行。沒有快速和容易的規則來確定哪種型別的儲存引擎對你的場景更好,所以值得去透過一些測試來得到相關的經驗。
|
||||
|
||||
### 其他索引結構
|
||||
|
||||
到目前為止,我們只討論了鍵值索引,它們就像關係模型中的**主鍵(primary key)** 索引。主鍵唯一標識關係表中的一行,或文件資料庫中的一個文件或圖形資料庫中的一個頂點。資料庫中的其他記錄可以透過其主鍵(或ID)引用該行/文件/頂點,索引就被用於解析這樣的引用。
|
||||
到目前為止,我們只討論了鍵值索引,它們就像關係模型中的 **主鍵(primary key)** 索引。主鍵唯一標識關係表中的一行,或文件資料庫中的一個文件或圖形資料庫中的一個頂點。資料庫中的其他記錄可以透過其主鍵(或 ID)引用該行 / 文件 / 頂點,索引就被用於解析這樣的引用。
|
||||
|
||||
次級索引(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 樹和日誌結構索引都可以用作次級索引。
|
||||
|
||||
#### 將值儲存在索引中
|
||||
|
||||
索引中的鍵是查詢要搜尋的內容,而其值可以是以下兩種情況之一:它可以是實際的行(文件,頂點),也可以是對儲存在別處的行的引用。在後一種情況下,行被儲存的地方被稱為**堆檔案(heap file)**,並且儲存的資料沒有特定的順序(它可以是僅追加的,或者它可以跟蹤被刪除的行以便後續可以用新的資料進行覆蓋)。堆檔案方法很常見,因為它避免了在存在多個次級索引時對資料的複製:每個索引只引用堆檔案中的一個位置,實際的資料都儲存在一個地方。
|
||||
索引中的鍵是查詢要搜尋的內容,而其值可以是以下兩種情況之一:它可以是實際的行(文件,頂點),也可以是對儲存在別處的行的引用。在後一種情況下,行被儲存的地方被稱為 **堆檔案(heap file)**,並且儲存的資料沒有特定的順序(它可以是僅追加的,或者它可以跟蹤被刪除的行以便後續可以用新的資料進行覆蓋)。堆檔案方法很常見,因為它避免了在存在多個次級索引時對資料的複製:每個索引只引用堆檔案中的一個位置,實際的資料都儲存在一個地方。
|
||||
|
||||
在不更改鍵的情況下更新值時,堆檔案方法可以非常高效:只要新值的位元組數不大於舊值,就可以覆蓋該記錄。如果新值更大,情況會更復雜,因為它可能需要移到堆中有足夠空間的新位置。在這種情況下,要麼所有的索引都需要更新,以指向記錄的新堆位置,或者在舊堆位置留下一個轉發指標【5】。
|
||||
|
||||
在某些情況下,從索引到堆檔案的額外跳躍對讀取來說效能損失太大,因此可能希望將被索引的行直接儲存在索引中。這被稱為聚集索引(clustered index)。例如,在MySQL的InnoDB儲存引擎中,表的主鍵總是一個聚集索引,次級索引則引用主鍵(而不是堆檔案中的位置)【31】。在SQL Server中,可以為每個表指定一個聚集索引【32】。
|
||||
在某些情況下,從索引到堆檔案的額外跳躍對讀取來說效能損失太大,因此可能希望將被索引的行直接儲存在索引中。這被稱為聚集索引(clustered index)。例如,在 MySQL 的 InnoDB 儲存引擎中,表的主鍵總是一個聚集索引,次級索引則引用主鍵(而不是堆檔案中的位置)【31】。在 SQL Server 中,可以為每個表指定一個聚集索引【32】。
|
||||
|
||||
在 **聚集索引**(在索引中儲存所有的行資料)和 **非聚集索引**(僅在索引中儲存對資料的引用)之間的折衷被稱為 **覆蓋索引(covering index)** 或 **包含列的索引(index with included columns)**,其在索引記憶體儲表的一部分列【33】。這允許透過單獨使用索引來處理一些查詢(這種情況叫做:索引 **覆蓋(cover)** 了查詢)【32】。
|
||||
|
||||
@ -318,7 +318,7 @@ B樹在資料庫架構中是非常根深蒂固的,為許多工作負載都提
|
||||
|
||||
至今討論的索引只是將一個鍵對映到一個值。如果我們需要同時查詢一個表中的多個列(或文件中的多個欄位),這顯然是不夠的。
|
||||
|
||||
最常見的多列索引被稱為 **連線索引(concatenated index)** ,它透過將一列的值追加到另一列後面,簡單地將多個欄位組合成一個鍵(索引定義中指定了欄位的連線順序)。這就像一個老式的紙質電話簿,它提供了一個從(姓氏,名字)到電話號碼的索引。由於排序順序,索引可以用來查詢所有具有特定姓氏的人,或所有具有特定姓氏-名字組合的人。但如果你想找到所有具有特定名字的人,這個索引是沒有用的。
|
||||
最常見的多列索引被稱為 **連線索引(concatenated index)** ,它透過將一列的值追加到另一列後面,簡單地將多個欄位組合成一個鍵(索引定義中指定了欄位的連線順序)。這就像一個老式的紙質電話簿,它提供了一個從(姓氏,名字)到電話號碼的索引。由於排序順序,索引可以用來查詢所有具有特定姓氏的人,或所有具有特定姓氏 - 名字組合的人。但如果你想找到所有具有特定名字的人,這個索引是沒有用的。
|
||||
|
||||
**多維索引(multi-dimensional index)** 是一種查詢多個列的更一般的方法,這對於地理空間資料尤為重要。例如,餐廳搜尋網站可能有一個數據庫,其中包含每個餐廳的經度和緯度。當用戶在地圖上檢視餐館時,網站需要搜尋使用者正在檢視的矩形地圖區域內的所有餐館。這需要一個二維範圍查詢,如下所示:
|
||||
|
||||
@ -327,131 +327,131 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
|
||||
AND longitude > -0.1162 AND longitude < -0.1004;
|
||||
```
|
||||
|
||||
一個標準的B樹或者LSM樹索引不能夠高效地處理這種查詢:它可以返回一個緯度範圍內的所有餐館(但經度可能是任意值),或者返回在同一個經度範圍內的所有餐館(但緯度可能是北極和南極之間的任意地方),但不能同時滿足兩個條件。
|
||||
一個標準的 B 樹或者 LSM 樹索引不能夠高效地處理這種查詢:它可以返回一個緯度範圍內的所有餐館(但經度可能是任意值),或者返回在同一個經度範圍內的所有餐館(但緯度可能是北極和南極之間的任意地方),但不能同時滿足兩個條件。
|
||||
|
||||
一種選擇是使用空間填充曲線將二維位置轉換為單個數字,然後使用常規B樹索引【34】。更普遍的是,使用特殊化的空間索引,例如R樹。例如,PostGIS使用PostgreSQL的通用GiST工具【35】將地理空間索引實現為R樹。這裡我們沒有足夠的地方來描述R樹,但是有大量的文獻可供參考。
|
||||
一種選擇是使用空間填充曲線將二維位置轉換為單個數字,然後使用常規 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 能夠在一定的編輯距離(編輯距離 1 意味著新增,刪除或替換了一個字母)內搜尋文字【37】。
|
||||
|
||||
正如“[用SSTables製作LSM樹](#用SSTables製作LSM樹)”中所提到的,Lucene為其詞典使用了一個類似於SSTable的結構。這個結構需要一個小的記憶體索引,告訴查詢需要在排序檔案中哪個偏移量查詢鍵。在LevelDB中,這個記憶體中的索引是一些鍵的稀疏集合,但在Lucene中,記憶體中的索引是鍵中字元的有限狀態自動機,類似於trie 【38】。這個自動機可以轉換成Levenshtein自動機,它支援在給定的編輯距離內有效地搜尋單詞【39】。
|
||||
正如 “[用 SSTables 製作 LSM 樹](#用SSTables製作LSM樹)” 中所提到的,Lucene 為其詞典使用了一個類似於 SSTable 的結構。這個結構需要一個小的記憶體索引,告訴查詢需要在排序檔案中哪個偏移量查詢鍵。在 LevelDB 中,這個記憶體中的索引是一些鍵的稀疏集合,但在 Lucene 中,記憶體中的索引是鍵中字元的有限狀態自動機,類似於 trie 【38】。這個自動機可以轉換成 Levenshtein 自動機,它支援在給定的編輯距離內有效地搜尋單詞【39】。
|
||||
|
||||
其他的模糊搜尋技術正朝著文件分類和機器學習的方向發展。更多詳細資訊請參閱資訊檢索教科書,例如【40】。
|
||||
|
||||
#### 在記憶體中儲存一切
|
||||
|
||||
本章到目前為止討論的資料結構都是對硬碟限制的應對。與主記憶體相比,硬碟處理起來很麻煩。對於磁性硬碟和固態硬碟,如果要在讀取和寫入時獲得良好效能,則需要仔細地佈置硬碟上的資料。但是,我們能容忍這種麻煩,因為硬碟有兩個顯著的優點:它們是持久的(它們的內容在電源關閉時不會丟失),並且每GB的成本比RAM低。
|
||||
本章到目前為止討論的資料結構都是對硬碟限制的應對。與主記憶體相比,硬碟處理起來很麻煩。對於磁性硬碟和固態硬碟,如果要在讀取和寫入時獲得良好效能,則需要仔細地佈置硬碟上的資料。但是,我們能容忍這種麻煩,因為硬碟有兩個顯著的優點:它們是持久的(它們的內容在電源關閉時不會丟失),並且每 GB 的成本比 RAM 低。
|
||||
|
||||
隨著RAM變得更便宜,每GB成本的論據被侵蝕了。許多資料集不是那麼大,所以將它們全部儲存在記憶體中是非常可行的,包括可能分佈在多個機器上。這導致了記憶體資料庫的發展。
|
||||
隨著 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】。
|
||||
|
||||
除了效能,記憶體資料庫的另一個有趣的地方是提供了難以用基於硬碟的索引實現的資料模型。例如,Redis為各種資料結構(如優先順序佇列和集合)提供了類似資料庫的介面。因為它將所有資料儲存在記憶體中,所以它的實現相對簡單。
|
||||
除了效能,記憶體資料庫的另一個有趣的地方是提供了難以用基於硬碟的索引實現的資料模型。例如,Redis 為各種資料結構(如優先順序佇列和集合)提供了類似資料庫的介面。因為它將所有資料儲存在記憶體中,所以它的實現相對簡單。
|
||||
|
||||
最近的研究表明,記憶體資料庫體系結構可以擴充套件到支援比可用記憶體更大的資料集,而不必重新採用以硬碟為中心的體系結構【45】。所謂的 **反快取(anti-caching)** 方法透過在記憶體不足的情況下將最近最少使用的資料從記憶體轉移到硬碟,並在將來再次訪問時將其重新載入到記憶體中。這與作業系統對虛擬記憶體和交換檔案的操作類似,但資料庫可以比作業系統更有效地管理記憶體,因為它可以按單個記錄的粒度工作,而不是整個記憶體頁面。儘管如此,這種方法仍然需要索引能完全放入記憶體中(就像本章開頭的Bitcask例子)。
|
||||
最近的研究表明,記憶體資料庫體系結構可以擴充套件到支援比可用記憶體更大的資料集,而不必重新採用以硬碟為中心的體系結構【45】。所謂的 **反快取(anti-caching)** 方法透過在記憶體不足的情況下將最近最少使用的資料從記憶體轉移到硬碟,並在將來再次訪問時將其重新載入到記憶體中。這與作業系統對虛擬記憶體和交換檔案的操作類似,但資料庫可以比作業系統更有效地管理記憶體,因為它可以按單個記錄的粒度工作,而不是整個記憶體頁面。儘管如此,這種方法仍然需要索引能完全放入記憶體中(就像本章開頭的 Bitcask 例子)。
|
||||
|
||||
如果 **非易失性儲存器(non-volatile memory, NVM)** 技術得到更廣泛的應用,可能還需要進一步改變儲存引擎設計【46】。目前這是一個新的研究領域,值得關注。
|
||||
|
||||
|
||||
## 事務處理還是分析?
|
||||
|
||||
在早期的業務資料處理過程中,一次典型的資料庫寫入通常與一筆 *商業交易(commercial transaction)* 相對應:賣個貨,向供應商下訂單,支付員工工資等等。但隨著資料庫開始應用到那些不涉及到錢的領域,術語**交易/事務(transaction)** 仍留了下來,用於指代一組讀寫操作構成的邏輯單元。
|
||||
在早期的業務資料處理過程中,一次典型的資料庫寫入通常與一筆 *商業交易(commercial transaction)* 相對應:賣個貨,向供應商下訂單,支付員工工資等等。但隨著資料庫開始應用到那些不涉及到錢的領域,術語 **交易 / 事務(transaction)** 仍留了下來,用於指代一組讀寫操作構成的邏輯單元。
|
||||
|
||||
> 事務不一定具有ACID(原子性,一致性,隔離性和永續性)屬性。事務處理只是意味著允許客戶端進行低延遲的讀取和寫入 —— 而不是隻能定期執行(例如每天一次)的批處理作業。我們在[第七章](ch7.md)中討論ACID屬性,在[第十章](ch10.md)中討論批處理。
|
||||
> 事務不一定具有 ACID(原子性,一致性,隔離性和永續性)屬性。事務處理只是意味著允許客戶端進行低延遲的讀取和寫入 —— 而不是隻能定期執行(例如每天一次)的批處理作業。我們在 [第七章](ch7.md) 中討論 ACID 屬性,在 [第十章](ch10.md) 中討論批處理。
|
||||
|
||||
即使資料庫開始被用於許多不同型別的資料,比如部落格文章的評論,遊戲中的動作,地址簿中的聯絡人等等,基本的訪問模式仍然類似於處理商業交易。應用程式通常使用索引透過某個鍵查詢少量記錄。根據使用者的輸入插入或更新記錄。由於這些應用程式是互動式的,這種訪問模式被稱為**線上事務處理(OLTP, OnLine Transaction Processing)**。
|
||||
即使資料庫開始被用於許多不同型別的資料,比如部落格文章的評論,遊戲中的動作,地址簿中的聯絡人等等,基本的訪問模式仍然類似於處理商業交易。應用程式通常使用索引透過某個鍵查詢少量記錄。根據使用者的輸入插入或更新記錄。由於這些應用程式是互動式的,這種訪問模式被稱為 **線上事務處理(OLTP, OnLine Transaction Processing)**。
|
||||
|
||||
但是,資料庫也開始越來越多地用於資料分析,這些資料分析具有非常不同的訪問模式。通常,分析查詢需要掃描大量記錄,每個記錄只讀取幾列,並計算彙總統計資訊(如計數、總和或平均值),而不是將原始資料返回給使用者。例如,如果你的資料是一個銷售交易表,那麼分析查詢可能是:
|
||||
|
||||
* 一月份每個商店的總收入是多少?
|
||||
* 在最近的推廣活動中多賣了多少香蕉?
|
||||
* 哪個牌子的嬰兒食品最常與X品牌的尿布同時購買?
|
||||
* 哪個牌子的嬰兒食品最常與 X 品牌的尿布同時購買?
|
||||
|
||||
這些查詢通常由業務分析師編寫,並提供報告以幫助公司管理層做出更好的決策(商業智慧)。為了將這種使用資料庫的模式和事務處理區分開,它被稱為**線上分析處理(OLAP, OnLine Analytice Processing)**【47】[^iv]。OLTP和OLAP之間的區別並不總是清晰的,但是一些典型的特徵在[表3-1]()中列出。
|
||||
這些查詢通常由業務分析師編寫,並提供報告以幫助公司管理層做出更好的決策(商業智慧)。為了將這種使用資料庫的模式和事務處理區分開,它被稱為 **線上分析處理(OLAP, OnLine Analytice Processing)**【47】[^iv]。OLTP 和 OLAP 之間的區別並不總是清晰的,但是一些典型的特徵在 [表 3-1]() 中列出。
|
||||
|
||||
**表3-1 比較事務處理和分析系統的特點**
|
||||
**表 3-1 比較事務處理和分析系統的特點**
|
||||
|
||||
| 屬性 | 事務處理系統 OLTP | 分析系統 OLAP |
|
||||
| :----------: | :--------------------------: | :----------------------: |
|
||||
| 主要讀取模式 | 查詢少量記錄,按鍵讀取 | 在大批次記錄上聚合 |
|
||||
| 主要寫入模式 | 隨機訪問,寫入要求低延時 | 批次匯入(ETL)或者事件流 |
|
||||
| 主要使用者 | 終端使用者,透過Web應用 | 內部資料分析師,用於決策支援 |
|
||||
| 主要使用者 | 終端使用者,透過 Web 應用 | 內部資料分析師,用於決策支援 |
|
||||
| 處理的資料 | 資料的最新狀態(當前時間點) | 隨時間推移的歷史事件 |
|
||||
| 資料集尺寸 | GB ~ TB | TB ~ PB |
|
||||
|
||||
[^iv]: OLAP中的首字母O(online)的含義並不明確,它可能是指查詢並不是用來生成預定義好的報告的事實,也可能是指分析師通常是互動式地使用OLAP系統來進行探索式的查詢。
|
||||
[^iv]: OLAP 中的首字母 O(online)的含義並不明確,它可能是指查詢並不是用來生成預定義好的報告的事實,也可能是指分析師通常是互動式地使用 OLAP 系統來進行探索式的查詢。
|
||||
|
||||
起初,事務處理和分析查詢使用了相同的資料庫。 SQL在這方面已證明是非常靈活的:對於OLTP型別的查詢以及OLAP型別的查詢來說效果都很好。儘管如此,在二十世紀八十年代末和九十年代初期,企業有停止使用OLTP系統進行分析的趨勢,轉而在單獨的資料庫上執行分析。這個單獨的資料庫被稱為**資料倉庫(data warehouse)**。
|
||||
起初,事務處理和分析查詢使用了相同的資料庫。 SQL 在這方面已證明是非常靈活的:對於 OLTP 型別的查詢以及 OLAP 型別的查詢來說效果都很好。儘管如此,在二十世紀八十年代末和九十年代初期,企業有停止使用 OLTP 系統進行分析的趨勢,轉而在單獨的資料庫上執行分析。這個單獨的資料庫被稱為 **資料倉庫(data warehouse)**。
|
||||
|
||||
### 資料倉庫
|
||||
|
||||
一個企業可能有幾十個不同的交易處理系統:面向終端客戶的網站,控制實體商店的收銀系統,倉庫庫存跟蹤,車輛路線規劃,供應鏈管理,員工管理等。這些系統中每一個都很複雜,需要專人維護,所以最終這些系統互相之間都是獨立執行的。
|
||||
|
||||
這些OLTP系統往往對業務運作至關重要,因而通常會要求 **高可用** 與 **低延遲**。所以DBA會密切關注他們的OLTP資料庫,他們通常不願意讓業務分析人員在OLTP資料庫上執行臨時的分析查詢,因為這些查詢通常開銷巨大,會掃描大部分資料集,這會損害同時在執行的事務的效能。
|
||||
這些 OLTP 系統往往對業務運作至關重要,因而通常會要求 **高可用** 與 **低延遲**。所以 DBA 會密切關注他們的 OLTP 資料庫,他們通常不願意讓業務分析人員在 OLTP 資料庫上執行臨時的分析查詢,因為這些查詢通常開銷巨大,會掃描大部分資料集,這會損害同時在執行的事務的效能。
|
||||
|
||||
相比之下,資料倉庫是一個獨立的資料庫,分析人員可以查詢他們想要的內容而不影響OLTP操作【48】。資料倉庫包含公司各種OLTP系統中所有的只讀資料副本。從OLTP資料庫中提取資料(使用定期的資料轉儲或連續的更新流),轉換成適合分析的模式,清理並載入到資料倉庫中。將資料存入倉庫的過程稱為“**抽取-轉換-載入(ETL)**”,如[圖3-8](../img/fig3-8.png)所示。
|
||||
相比之下,資料倉庫是一個獨立的資料庫,分析人員可以查詢他們想要的內容而不影響 OLTP 操作【48】。資料倉庫包含公司各種 OLTP 系統中所有的只讀資料副本。從 OLTP 資料庫中提取資料(使用定期的資料轉儲或連續的更新流),轉換成適合分析的模式,清理並載入到資料倉庫中。將資料存入倉庫的過程稱為 “**抽取 - 轉換 - 載入(ETL)**”,如 [圖 3-8](../img/fig3-8.png) 所示。
|
||||
|
||||
![](../img/fig3-8.png)
|
||||
|
||||
**圖3-8 ETL至資料倉庫的簡化提綱**
|
||||
**圖 3-8 ETL 至資料倉庫的簡化提綱**
|
||||
|
||||
幾乎所有的大型企業都有資料倉庫,但在小型企業中幾乎聞所未聞。這可能是因為大多數小公司沒有這麼多不同的OLTP系統,大多數小公司只有少量的資料 —— 可以在傳統的SQL資料庫中查詢,甚至可以在電子表格中分析。在一家大公司裡,要做一些在一家小公司很簡單的事情,需要很多繁重的工作。
|
||||
幾乎所有的大型企業都有資料倉庫,但在小型企業中幾乎聞所未聞。這可能是因為大多數小公司沒有這麼多不同的 OLTP 系統,大多數小公司只有少量的資料 —— 可以在傳統的 SQL 資料庫中查詢,甚至可以在電子表格中分析。在一家大公司裡,要做一些在一家小公司很簡單的事情,需要很多繁重的工作。
|
||||
|
||||
使用單獨的資料倉庫,而不是直接查詢OLTP系統進行分析的一大優勢是資料倉庫可針對分析訪問模式進行最佳化。事實證明,本章前半部分討論的索引演算法對於OLTP來說工作得很好,但對於處理分析查詢並不是很好。在本章的其餘部分中,我們將研究為分析而最佳化的儲存引擎。
|
||||
使用單獨的資料倉庫,而不是直接查詢 OLTP 系統進行分析的一大優勢是資料倉庫可針對分析訪問模式進行最佳化。事實證明,本章前半部分討論的索引演算法對於 OLTP 來說工作得很好,但對於處理分析查詢並不是很好。在本章的其餘部分中,我們將研究為分析而最佳化的儲存引擎。
|
||||
|
||||
#### OLTP資料庫和資料倉庫之間的分歧
|
||||
|
||||
資料倉庫的資料模型通常是關係型的,因為SQL通常很適合分析查詢。有許多圖形資料分析工具可以生成SQL查詢,視覺化結果,並允許分析人員探索資料(透過下鑽、切片和切塊等操作)。
|
||||
資料倉庫的資料模型通常是關係型的,因為 SQL 通常很適合分析查詢。有許多圖形資料分析工具可以生成 SQL 查詢,視覺化結果,並允許分析人員探索資料(透過下鑽、切片和切塊等操作)。
|
||||
|
||||
表面上,一個數據倉庫和一個關係型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】。
|
||||
|
||||
### 星型和雪花型:分析的模式
|
||||
|
||||
正如[第二章](ch2.md)所探討的,根據應用程式的需要,在事務處理領域中使用了大量不同的資料模型。另一方面,在分析型業務中,資料模型的多樣性則少得多。許多資料倉庫都以相當公式化的方式使用,被稱為星型模式(也稱為維度建模【55】)。
|
||||
正如 [第二章](ch2.md) 所探討的,根據應用程式的需要,在事務處理領域中使用了大量不同的資料模型。另一方面,在分析型業務中,資料模型的多樣性則少得多。許多資料倉庫都以相當公式化的方式使用,被稱為星型模式(也稱為維度建模【55】)。
|
||||
|
||||
[圖3-9](../img/fig3-9.md)中的示例模式顯示了可能在食品零售商處找到的資料倉庫。在模式的中心是一個所謂的事實表(在這個例子中,它被稱為 `fact_sales`)。事實表的每一行代表在特定時間發生的事件(這裡,每一行代表客戶購買的產品)。如果我們分析的是網站流量而不是零售量,則每行可能代表一個使用者的頁面瀏覽或點選。
|
||||
[圖 3-9](../img/fig3-9.md) 中的示例模式顯示了可能在食品零售商處找到的資料倉庫。在模式的中心是一個所謂的事實表(在這個例子中,它被稱為 `fact_sales`)。事實表的每一行代表在特定時間發生的事件(這裡,每一行代表客戶購買的產品)。如果我們分析的是網站流量而不是零售量,則每行可能代表一個使用者的頁面瀏覽或點選。
|
||||
|
||||
![](../img/fig3-9.png)
|
||||
|
||||
**圖3-9 用於資料倉庫的星型模式的示例**
|
||||
**圖 3-9 用於資料倉庫的星型模式的示例**
|
||||
|
||||
通常情況下,事實被視為單獨的事件,因為這樣可以在以後分析中獲得最大的靈活性。但是,這意味著事實表可以變得非常大。像蘋果、沃爾瑪或eBay這樣的大企業在其資料倉庫中可能有幾十PB的交易歷史,其中大部分儲存在事實表中【56】。
|
||||
通常情況下,事實被視為單獨的事件,因為這樣可以在以後分析中獲得最大的靈活性。但是,這意味著事實表可以變得非常大。像蘋果、沃爾瑪或 eBay 這樣的大企業在其資料倉庫中可能有幾十 PB 的交易歷史,其中大部分儲存在事實表中【56】。
|
||||
|
||||
事實表中的一些列是屬性,例如產品銷售的價格和從供應商那裡購買的成本(可以用來計算利潤餘額)。事實表中的其他列是對其他表(稱為維度表)的外來鍵引用。由於事實表中的每一行都表示一個事件,因此這些維度代表事件發生的物件、內容、地點、時間、方式和原因。
|
||||
|
||||
例如,在[圖3-9](../img/fig3-9.md)中,其中一個維度是已售出的產品。 `dim_product` 表中的每一行代表一種待售產品,包括庫存單位(SKU)、產品描述、品牌名稱、類別、脂肪含量、包裝尺寸等。`fact_sales` 表中的每一行都使用外來鍵表明在特定交易中銷售了什麼產品。 (簡單起見,如果客戶一次購買了幾種不同的產品,則它們在事實表中被表示為單獨的行)。
|
||||
例如,在 [圖 3-9](../img/fig3-9.md) 中,其中一個維度是已售出的產品。 `dim_product` 表中的每一行代表一種待售產品,包括庫存單位(SKU)、產品描述、品牌名稱、類別、脂肪含量、包裝尺寸等。`fact_sales` 表中的每一行都使用外來鍵表明在特定交易中銷售了什麼產品。 (簡單起見,如果客戶一次購買了幾種不同的產品,則它們在事實表中被表示為單獨的行)。
|
||||
|
||||
甚至日期和時間也通常使用維度表來表示,因為這允許對日期的附加資訊(諸如公共假期)進行編碼,從而允許區分假期和非假期的銷售查詢。
|
||||
|
||||
“星型模式”這個名字來源於這樣一個事實,即當我們對錶之間的關係進行視覺化時,事實表在中間,被維度表包圍;與這些表的連線就像星星的光芒。
|
||||
“星型模式” 這個名字來源於這樣一個事實,即當我們對錶之間的關係進行視覺化時,事實表在中間,被維度表包圍;與這些表的連線就像星星的光芒。
|
||||
|
||||
這個模板的變體被稱為雪花模式,其中維度被進一步分解為子維度。例如,品牌和產品類別可能有單獨的表格,並且 `dim_product` 表格中的每一行都可以將品牌和類別作為外來鍵引用,而不是將它們作為字串儲存在 `dim_product` 表格中。雪花模式比星形模式更規範化,但是星形模式通常是首選,因為分析師使用它更簡單【55】。
|
||||
|
||||
在典型的資料倉庫中,表格通常非常寬:事實表通常有100列以上,有時甚至有數百列【51】。維度表也可以是非常寬的,因為它們包括了所有可能與分析相關的元資料——例如,`dim_store` 表可以包括在每個商店提供哪些服務的細節,它是否具有店內麵包房,店面面積,商店第一次開張的日期,最近一次改造的時間,離最近的高速公路的距離等等。
|
||||
在典型的資料倉庫中,表格通常非常寬:事實表通常有 100 列以上,有時甚至有數百列【51】。維度表也可以是非常寬的,因為它們包括了所有可能與分析相關的元資料 —— 例如,`dim_store` 表可以包括在每個商店提供哪些服務的細節,它是否具有店內麵包房,店面面積,商店第一次開張的日期,最近一次改造的時間,離最近的高速公路的距離等等。
|
||||
|
||||
|
||||
## 列式儲存
|
||||
|
||||
如果事實表中有萬億行和數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 分析人們是否更傾向於在一週的某一天購買新鮮水果或糖果**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
@ -470,34 +470,34 @@ GROUP BY
|
||||
|
||||
我們如何有效地執行這個查詢?
|
||||
|
||||
在大多數OLTP資料庫中,儲存都是以面向行的方式進行佈局的:表格的一行中的所有值都相鄰儲存。文件資料庫也是相似的:整個文件通常儲存為一個連續的位元組序列。你可以在[圖3-1](../img/fig3-1.png)的CSV例子中看到這個。
|
||||
在大多數 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)所示。
|
||||
列式儲存背後的想法很簡單:不要將所有來自一行的值儲存在一起,而是將來自每一列的所有值儲存在一起。如果每個列式儲存在一個單獨的檔案中,查詢只需要讀取和解析查詢中使用的那些列,這可以節省大量的工作。這個原理如 [圖 3-10](../img/fig3-10.png) 所示。
|
||||
|
||||
![](../img/fig3-10.png)
|
||||
|
||||
**圖3-10 按列儲存關係型資料,而不是行**
|
||||
**圖 3-10 按列儲存關係型資料,而不是行**
|
||||
|
||||
> 列式儲存在關係資料模型中是最容易理解的,但它同樣適用於非關係資料。例如,Parquet【57】是一種列式儲存格式,支援基於Google的Dremel的文件資料模型【54】。
|
||||
> 列式儲存在關係資料模型中是最容易理解的,但它同樣適用於非關係資料。例如,Parquet【57】是一種列式儲存格式,支援基於 Google 的 Dremel 的文件資料模型【54】。
|
||||
|
||||
列式儲存佈局依賴於每個列檔案包含相同順序的行。 因此,如果你需要重新組裝完整的行,你可以從每個單獨的列檔案中獲取第23項,並將它們放在一起形成表的第23行。
|
||||
列式儲存佈局依賴於每個列檔案包含相同順序的行。 因此,如果你需要重新組裝完整的行,你可以從每個單獨的列檔案中獲取第 23 項,並將它們放在一起形成表的第 23 行。
|
||||
|
||||
|
||||
### 列壓縮
|
||||
|
||||
除了僅從硬碟載入查詢所需的列以外,我們還可以透過壓縮資料來進一步降低對硬碟吞吐量的需求。幸運的是,列式儲存通常很適合壓縮。
|
||||
|
||||
看看[圖3-10](../img/fig3-10.png)中每一列的值序列:它們通常看起來是相當重複的,這是壓縮的好兆頭。根據列中的資料,可以使用不同的壓縮技術。在資料倉庫中特別有效的一種技術是點陣圖編碼,如[圖3-11](../img/fig3-11.png)所示。
|
||||
看看 [圖 3-10](../img/fig3-10.png) 中每一列的值序列:它們通常看起來是相當重複的,這是壓縮的好兆頭。根據列中的資料,可以使用不同的壓縮技術。在資料倉庫中特別有效的一種技術是點陣圖編碼,如 [圖 3-11](../img/fig3-11.png) 所示。
|
||||
|
||||
![](../img/fig3-11.png)
|
||||
|
||||
**圖3-11 壓縮的點陣圖索引儲存佈局**
|
||||
**圖 3-11 壓縮的點陣圖索引儲存佈局**
|
||||
|
||||
通常情況下,一列中不同值的數量與行數相比要小得多(例如,零售商可能有數十億的銷售交易,但只有100,000個不同的產品)。現在我們可以拿一個有 n 個不同值的列,並把它轉換成 n 個獨立的點陣圖:每個不同值對應一個位圖,每行對應一個位元位。如果該行具有該值,則該位為1,否則為0。
|
||||
通常情況下,一列中不同值的數量與行數相比要小得多(例如,零售商可能有數十億的銷售交易,但只有 100,000 個不同的產品)。現在我們可以拿一個有 n 個不同值的列,並把它轉換成 n 個獨立的點陣圖:每個不同值對應一個位圖,每行對應一個位元位。如果該行具有該值,則該位為 1,否則為 0。
|
||||
|
||||
如果n非常小(例如,國家/地區列可能有大約200個不同的值),則這些點陣圖可以將每行儲存成一個位元位。但是,如果n更大,大部分點陣圖中將會有很多的零(我們說它們是稀疏的)。在這種情況下,點陣圖可以另外再進行遊程編碼,如[圖3-11](fig3-11.png)底部所示。這可以使列的編碼非常緊湊。
|
||||
如果 n 非常小(例如,國家 / 地區列可能有大約 200 個不同的值),則這些點陣圖可以將每行儲存成一個位元位。但是,如果 n 更大,大部分點陣圖中將會有很多的零(我們說它們是稀疏的)。在這種情況下,點陣圖可以另外再進行遊程編碼,如 [圖 3-11](fig3-11.png) 底部所示。這可以使列的編碼非常緊湊。
|
||||
|
||||
這些點陣圖索引非常適合資料倉庫中常見的各種查詢。例如:
|
||||
|
||||
@ -505,45 +505,45 @@ GROUP BY
|
||||
WHERE product_sk IN(30,68,69)
|
||||
```
|
||||
|
||||
載入`product_sk = 30`、`product_sk = 68`和`product_sk = 69`這三個點陣圖,並計算三個點陣圖的按位或(OR),這可以非常有效地完成。
|
||||
載入 `product_sk = 30`、`product_sk = 68` 和 `product_sk = 69` 這三個點陣圖,並計算三個點陣圖的按位或(OR),這可以非常有效地完成。
|
||||
|
||||
```sql
|
||||
WHERE product_sk = 31 AND store_sk = 3
|
||||
```
|
||||
|
||||
載入`product_sk = 31`和`store_sk = 3`的點陣圖,並計算按位與(AND)。這是因為列按照相同的順序包含行,因此一列的點陣圖中的第k位和另一列的點陣圖中的第k位對應相同的行。
|
||||
載入 `product_sk = 31` 和 `store_sk = 3` 的點陣圖,並計算按位與(AND)。這是因為列按照相同的順序包含行,因此一列的點陣圖中的第 k 位和另一列的點陣圖中的第 k 位對應相同的行。
|
||||
|
||||
對於不同種類的資料,也有各種不同的壓縮方案,但我們不會詳細討論它們,請參閱【58】的概述。
|
||||
|
||||
> #### 列式儲存和列族
|
||||
>
|
||||
> Cassandra和HBase有一個列族(column families)的概念,他們從Bigtable繼承【9】。然而,把它們稱為列式(column-oriented)是非常具有誤導性的:在每個列族中,它們將一行中的所有列與行鍵一起儲存,並且不使用列壓縮。因此,Bigtable模型仍然主要是面向行的。
|
||||
> Cassandra 和 HBase 有一個列族(column families)的概念,他們從 Bigtable 繼承【9】。然而,把它們稱為列式(column-oriented)是非常具有誤導性的:在每個列族中,它們將一行中的所有列與行鍵一起儲存,並且不使用列壓縮。因此,Bigtable 模型仍然主要是面向行的。
|
||||
>
|
||||
|
||||
#### 記憶體頻寬和向量化處理
|
||||
|
||||
對於需要掃描數百萬行的資料倉庫查詢來說,一個巨大的瓶頸是從硬盤獲取資料到記憶體的頻寬。但是,這不是唯一的瓶頸。分析型資料庫的開發人員還需要有效地利用主儲存器到CPU快取的頻寬,避免CPU指令處理流水線中的分支預測錯誤和氣泡,以及在現代CPU上使用單指令多資料(SIMD)指令【59,60】。
|
||||
對於需要掃描數百萬行的資料倉庫查詢來說,一個巨大的瓶頸是從硬盤獲取資料到記憶體的頻寬。但是,這不是唯一的瓶頸。分析型資料庫的開發人員還需要有效地利用主儲存器到 CPU 快取的頻寬,避免 CPU 指令處理流水線中的分支預測錯誤和氣泡,以及在現代 CPU 上使用單指令多資料(SIMD)指令【59,60】。
|
||||
|
||||
除了減少需要從硬碟載入的資料量以外,列式儲存佈局也可以有效利用CPU週期。例如,查詢引擎可以將大量壓縮的列資料放在CPU的L1快取中,然後在緊密的迴圈(即沒有函式呼叫)中遍歷。相比較每個記錄的處理都需要大量函式呼叫和條件判斷的程式碼,CPU執行這樣一個迴圈要快得多。列壓縮允許列中的更多行被放進相同數量的L1快取。前面描述的按位“與”和“或”運算子可以被設計為直接在這樣的壓縮列資料塊上操作。這種技術被稱為向量化處理【58,49】。
|
||||
除了減少需要從硬碟載入的資料量以外,列式儲存佈局也可以有效利用 CPU 週期。例如,查詢引擎可以將大量壓縮的列資料放在 CPU 的 L1 快取中,然後在緊密的迴圈(即沒有函式呼叫)中遍歷。相比較每個記錄的處理都需要大量函式呼叫和條件判斷的程式碼,CPU 執行這樣一個迴圈要快得多。列壓縮允許列中的更多行被放進相同數量的 L1 快取。前面描述的按位 “與” 和 “或” 運算子可以被設計為直接在這樣的壓縮列資料塊上操作。這種技術被稱為向量化處理【58,49】。
|
||||
|
||||
|
||||
### 列式儲存中的排序順序
|
||||
|
||||
在列式儲存中,儲存行的順序並不一定很重要。按插入順序儲存它們是最簡單的,因為插入一個新行只需要追加到每個列檔案。但是,我們可以選擇增加一個特定的順序,就像我們之前對SSTables所做的那樣,並將其用作索引機制。
|
||||
在列式儲存中,儲存行的順序並不一定很重要。按插入順序儲存它們是最簡單的,因為插入一個新行只需要追加到每個列檔案。但是,我們可以選擇增加一個特定的順序,就像我們之前對 SSTables 所做的那樣,並將其用作索引機制。
|
||||
|
||||
注意,每列獨自排序是沒有意義的,因為那樣我們就沒法知道不同列中的哪些項屬於同一行。我們只能在知道一列中的第k項與另一列中的第k項屬於同一行的情況才能重建出完整的行。
|
||||
注意,每列獨自排序是沒有意義的,因為那樣我們就沒法知道不同列中的哪些項屬於同一行。我們只能在知道一列中的第 k 項與另一列中的第 k 項屬於同一行的情況才能重建出完整的行。
|
||||
|
||||
相反,即使按列式儲存資料,也需要一次對整行進行排序。資料庫的管理員可以根據他們對常用查詢的瞭解來選擇表格應該被排序的列。例如,如果查詢通常以日期範圍為目標,例如上個月,則可以將 `date_key` 作為第一個排序鍵。這樣查詢最佳化器就可以只掃描上個月的行了,這比掃描所有行要快得多。
|
||||
|
||||
對於第一排序列中具有相同值的行,可以用第二排序列來進一步排序。例如,如果 `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) 中的點陣圖一樣)可以將該列壓縮到幾千位元組 —— 即使表中有數十億行。
|
||||
|
||||
第一個排序鍵的壓縮效果最強。第二和第三個排序鍵會更混亂,因此不會有這麼長的連續的重複值。排序優先順序更低的列以基本上隨機的順序出現,所以它們可能不會被壓縮。但前幾列排序在整體上仍然是有好處的。
|
||||
|
||||
#### 幾個不同的排序順序
|
||||
|
||||
C-Store中引入了這個想法的一個巧妙擴充套件,並在商業資料倉庫Vertica中被採用【61,62】。不同的查詢受益於不同的排序順序,為什麼不以幾種不同的方式來儲存相同的資料呢?無論如何,資料需要複製到多臺機器,這樣,如果一臺機器發生故障,你不會丟失資料。你可能還需要儲存以不同方式排序的冗餘資料,以便在處理查詢時,可以使用最適合查詢模式的版本。
|
||||
C-Store 中引入了這個想法的一個巧妙擴充套件,並在商業資料倉庫 Vertica 中被採用【61,62】。不同的查詢受益於不同的排序順序,為什麼不以幾種不同的方式來儲存相同的資料呢?無論如何,資料需要複製到多臺機器,這樣,如果一臺機器發生故障,你不會丟失資料。你可能還需要儲存以不同方式排序的冗餘資料,以便在處理查詢時,可以使用最適合查詢模式的版本。
|
||||
|
||||
在一個列式儲存中有多個排序順序有點類似於在一個面向行的儲存中有多個次級索引。但最大的區別在於面向行的儲存將每一行儲存在一個地方(在堆檔案或聚集索引中),次級索引只包含指向匹配行的指標。在列式儲存中,通常在其他地方沒有任何指向資料的指標,只有包含值的列。
|
||||
|
||||
@ -551,9 +551,9 @@ C-Store中引入了這個想法的一個巧妙擴充套件,並在商業資料
|
||||
|
||||
這些最佳化在資料倉庫中是有意義的,因為其負載主要由分析人員執行的大型只讀查詢組成。列式儲存、壓縮和排序都有助於更快地讀取這些查詢。然而,他們的缺點是寫入更加困難。
|
||||
|
||||
使用B樹的就地更新方法對於壓縮的列是不可能的。如果你想在排序表的中間插入一行,你很可能不得不重寫所有的列檔案。由於行由列中的位置標識,因此插入必須對所有列進行一致地更新。
|
||||
使用 B 樹的就地更新方法對於壓縮的列是不可能的。如果你想在排序表的中間插入一行,你很可能不得不重寫所有的列檔案。由於行由列中的位置標識,因此插入必須對所有列進行一致地更新。
|
||||
|
||||
幸運的是,本章前面已經看到了一個很好的解決方案:LSM樹。所有的寫操作首先進入一個記憶體中的儲存,在這裡它們被新增到一個已排序的結構中,並準備寫入硬碟。記憶體中的儲存是面向行還是列的並不重要。當已經積累了足夠的寫入資料時,它們將與硬碟上的列檔案合併,並批次寫入新檔案。這基本上是Vertica所做的【62】。
|
||||
幸運的是,本章前面已經看到了一個很好的解決方案:LSM 樹。所有的寫操作首先進入一個記憶體中的儲存,在這裡它們被新增到一個已排序的結構中,並準備寫入硬碟。記憶體中的儲存是面向行還是列的並不重要。當已經積累了足夠的寫入資料時,它們將與硬碟上的列檔案合併,並批次寫入新檔案。這基本上是 Vertica 所做的【62】。
|
||||
|
||||
查詢需要檢查硬碟上的列資料和最近在記憶體中的寫入,並將兩者結合起來。但是,查詢最佳化器對使用者隱藏了這個細節。從分析師的角度來看,透過插入、更新或刪除操作進行修改的資料會立即反映在後續的查詢中。
|
||||
|
||||
@ -561,46 +561,46 @@ C-Store中引入了這個想法的一個巧妙擴充套件,並在商業資料
|
||||
|
||||
並不是每個資料倉庫都必定是一個列式儲存:傳統的面向行的資料庫和其他一些架構也被使用。然而,列式儲存可以顯著加快專門的分析查詢,所以它正在迅速變得流行起來【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) 顯示了一個例子。
|
||||
|
||||
![](../img/fig3-12.png)
|
||||
|
||||
**圖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 中有五個維度:日期、產品、商店、促銷和客戶。要想象一個五維超立方體是什麼樣子是很困難的,但是原理是一樣的:每個單元格都包含特定日期 - 產品 - 商店 - 促銷 - 客戶組合的銷售額。這些值可以在每個維度上求和彙總。
|
||||
|
||||
物化資料立方體的優點是可以讓某些查詢變得非常快,因為它們已經被有效地預先計算了。例如,如果你想知道每個商店的總銷售額,則只需檢視合適維度的總計,而無需掃描數百萬行的原始資料。
|
||||
|
||||
資料立方體的缺點是不具有查詢原始資料的靈活性。例如,沒有辦法計算有多少比例的銷售來自成本超過100美元的專案,因為價格不是其中的一個維度。因此,大多數資料倉庫試圖保留儘可能多的原始資料,並將聚合資料(如資料立方體)僅用作某些查詢的效能提升手段。
|
||||
資料立方體的缺點是不具有查詢原始資料的靈活性。例如,沒有辦法計算有多少比例的銷售來自成本超過 100 美元的專案,因為價格不是其中的一個維度。因此,大多數資料倉庫試圖保留儘可能多的原始資料,並將聚合資料(如資料立方體)僅用作某些查詢的效能提升手段。
|
||||
|
||||
|
||||
## 本章小結
|
||||
|
||||
在本章中,我們試圖深入瞭解資料庫是如何處理儲存和檢索的。將資料儲存在資料庫中會發生什麼?稍後再次查詢資料時資料庫會做什麼?
|
||||
|
||||
在高層次上,我們看到儲存引擎分為兩大類:針對**事務處理(OLTP)** 最佳化的儲存引擎和針對**線上分析(OLAP)** 最佳化的儲存引擎。這兩類使用場景的訪問模式之間有很大的區別:
|
||||
在高層次上,我們看到儲存引擎分為兩大類:針對 **事務處理(OLTP)** 最佳化的儲存引擎和針對 **線上分析(OLAP)** 最佳化的儲存引擎。這兩類使用場景的訪問模式之間有很大的區別:
|
||||
|
||||
* OLTP系統通常面向終端使用者,這意味著系統可能會收到大量的請求。為了處理負載,應用程式在每個查詢中通常只訪問少量的記錄。應用程式使用某種鍵來請求記錄,儲存引擎使用索引來查詢所請求的鍵的資料。硬碟查詢時間往往是這裡的瓶頸。
|
||||
* 資料倉庫和類似的分析系統會低調一些,因為它們主要由業務分析人員使用,而不是終端使用者。它們的查詢量要比OLTP系統少得多,但通常每個查詢開銷高昂,需要在短時間內掃描數百萬條記錄。硬碟頻寬(而不是查詢時間)往往是瓶頸,列式儲存是針對這種工作負載的日益流行的解決方案。
|
||||
* OLTP 系統通常面向終端使用者,這意味著系統可能會收到大量的請求。為了處理負載,應用程式在每個查詢中通常只訪問少量的記錄。應用程式使用某種鍵來請求記錄,儲存引擎使用索引來查詢所請求的鍵的資料。硬碟查詢時間往往是這裡的瓶頸。
|
||||
* 資料倉庫和類似的分析系統會低調一些,因為它們主要由業務分析人員使用,而不是終端使用者。它們的查詢量要比 OLTP 系統少得多,但通常每個查詢開銷高昂,需要在短時間內掃描數百萬條記錄。硬碟頻寬(而不是查詢時間)往往是瓶頸,列式儲存是針對這種工作負載的日益流行的解決方案。
|
||||
|
||||
在OLTP這一邊,我們能看到兩派主流的儲存引擎:
|
||||
在 OLTP 這一邊,我們能看到兩派主流的儲存引擎:
|
||||
|
||||
* 日誌結構學派:只允許追加到檔案和刪除過時的檔案,但不會更新已經寫入的檔案。Bitcask、SSTables、LSM樹、LevelDB、Cassandra、HBase、Lucene等都屬於這個類別。
|
||||
* 就地更新學派:將硬碟視為一組可以覆寫的固定大小的頁面。 B樹是這種理念的典範,用在所有主要的關係資料庫和許多非關係型資料庫中。
|
||||
* 日誌結構學派:只允許追加到檔案和刪除過時的檔案,但不會更新已經寫入的檔案。Bitcask、SSTables、LSM 樹、LevelDB、Cassandra、HBase、Lucene 等都屬於這個類別。
|
||||
* 就地更新學派:將硬碟視為一組可以覆寫的固定大小的頁面。 B 樹是這種理念的典範,用在所有主要的關係資料庫和許多非關係型資料庫中。
|
||||
|
||||
日誌結構的儲存引擎是相對較新的技術。他們的主要想法是,透過系統性地將隨機訪問寫入轉換為硬碟上的順序寫入,由於硬碟驅動器和固態硬碟的效能特點,可以實現更高的寫入吞吐量。
|
||||
|
||||
關於OLTP,我們最後還介紹了一些更復雜的索引結構,以及針對所有資料都放在記憶體裡而最佳化的資料庫。
|
||||
關於 OLTP,我們最後還介紹了一些更復雜的索引結構,以及針對所有資料都放在記憶體裡而最佳化的資料庫。
|
||||
|
||||
然後,我們暫時放下了儲存引擎的內部細節,查看了典型資料倉庫的高階架構,並說明了為什麼分析工作負載與OLTP差別很大:當你的查詢需要在大量行中順序掃描時,索引的重要性就會降低很多。相反,非常緊湊地編碼資料變得非常重要,以最大限度地減少查詢需要從硬碟讀取的資料量。我們討論了列式儲存如何幫助實現這一目標。
|
||||
然後,我們暫時放下了儲存引擎的內部細節,查看了典型資料倉庫的高階架構,並說明了為什麼分析工作負載與 OLTP 差別很大:當你的查詢需要在大量行中順序掃描時,索引的重要性就會降低很多。相反,非常緊湊地編碼資料變得非常重要,以最大限度地減少查詢需要從硬碟讀取的資料量。我們討論了列式儲存如何幫助實現這一目標。
|
||||
|
||||
作為一名應用程式開發人員,如果你掌握了有關儲存引擎內部的知識,那麼你就能更好地瞭解哪種工具最適合你的特定應用程式。如果你需要調整資料庫的調整引數,這種理解可以讓你設想一個更高或更低的值可能會產生什麼效果。
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user