diff --git a/ch3.md b/ch3.md index 1570e6e4..0a0e29ba 100644 --- a/ch3.md +++ b/ch3.md @@ -11,21 +11,17 @@ [TOC] -在最基本的层次上,一个数据库需要完成两件事情:当你给它数据时,它应该存储起来,而当你提问时,它应该把数据返回给你。 +一个数据库在最基础的层次上需要完成两件事情:当你把数据交给数据库时,它应当把数据存储起来;而后当你向数据库要数据时,它应当把数据返回给你。 -在[第2章](ch2.md)中,我们讨论了数据模型和查询语言,即程序员录入数据库的数据格式,以及你可以再次获取它的机制。在本章中,我们讨论同样的问题,却是从数据库的视角:数据库如何存储我们提供的数据,以及如何在我们需要时重新找到数据。 +在[第2章](ch2.md)中,我们讨论了数据模型和查询语言,即程序员将数据录入数据库的格式,以及再次要回数据的机制。在本章中我们会从数据库的视角来讨论同样的问题:数据库如何存储我们提供的数据,以及如何在我们需要时重新找到数据。 -作为程序员,为什么要关心数据库如何在内部处理存储和检索?你可能不会从头开始实现自己的存储引擎,但是您需要从可用的许多存储引擎中选择适合应用程序的存储引擎。为了调谐一个存储引擎以适应应用工作负载,你需要大致了解存储引擎在做什么。 +作为程序员,为什么要关心数据库内部存储与检索的机理?你可能不会去从头开始实现自己的存储引擎,但是你**确实**需要从许多可用的存储引擎中选择一个合适的。而且为了调谐存储引擎以适配应用工作负载,你也需要大致了解存储引擎在底层究竟做什么。 -特别需要注意,针对事务性工作负载优化的存储引擎,与针对分析优化的存储引擎之间存在着巨大差异。稍后我们将在 “[事务处理还是分析?](#事务处理还是分析?)” 和 “[列存储](#列存储)”中探讨这个区别,那里将讨论针对分析优化的一系列存储引擎。 +特别需要注意,针对**事务**性负载和**分析性**负载优化的存储引擎之间存在巨大差异。稍后我们将在 “[事务处理还是分析?](#事务处理还是分析?)” 一节中探讨这一区别,并在 “[列存储](#列存储)”中讨论一系列针对分析优化存储引擎。 -但是,我们将从您最可能熟悉的两大类数据库:传统关系型数据库与所谓的“NoSQL”数据库开始,通过介绍它们的存储引擎来开始本章的内容。 +但是,我们将从您最可能熟悉的两大类数据库:传统关系型数据库与很多所谓的“NoSQL”数据库开始,通过介绍它们的**存储引擎**来开始本章的内容。我们会研究两大类存储引擎:**日志结构(log-structured)**的存储引擎,以及**面向页面(page-oriented)**的存储引擎(例如B树)。 -我们会研究两大类存储引擎:**日志结构(log-structured)**的存储引擎,以及**面向页面(page-oriented)**的存储引擎(如B树)。 - - - -## 数据库的底层数据结构 +## 驱动数据库的数据结构 世界上最简单的数据库可以用两个Bash函数实现: @@ -40,15 +36,20 @@ db_get () { } ``` -这两个函数实现了键值存储的功能。执行 `db_set key value` ,会将 `key` 和 `value` 存储在数据库中。键和值可以是(几乎)任何你喜欢的东西,例如,值可以是JSON文档。然后调用 `db_get key` ,查找与该键关联的最新值并将其返回。麻雀虽小,五脏俱全: +这两个函数实现了键值存储的功能。执行 `db_set key value` ,会将 **键(key)**和**值(value)** 存储在数据库中。键和值(几乎)可以是你喜欢的任何东西,例如,值可以是JSON文档。然后调用 `db_get key` ,查找与该键关联的最新值并将其返回。 + +麻雀虽小,五脏俱全: ```bash -$ db_set 123456 '{"name":"London","attractions":["Big Ben","London Eye"]}' $ db_set 42 '{"name":"San Francisco","attractions":["Golden Gate Bridge"]}' +$ db_set 123456 '{"name":"London","attractions":["Big Ben","London Eye"]}' $ + +$ db_set 42 '{"name":"San Francisco","attractions":["Golden Gate Bridge"]}' + $ 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"]}' @@ -57,48 +58,48 @@ $ db_get 42 {"name":"San Francisco","attractions":["Exploratorium"]} $ cat database -123456,{"name":"London","attractions":["Big Ben","London Eye"]} 42,{"name":"San Francisco","attractions":["Golden Gate Bridge"]} 42,{"name":"San Francisco","attractions":["Exploratorium"]} +123456,{"name":"London","attractions":["Big Ben","London Eye"]} +42,{"name":"San Francisco","attractions":["Golden Gate Bridge"]} +42,{"name":"San Francisco","attractions":["Exploratorium"]} ``` -`db_set` 函数对于极其简单的场景其实有非常好的性能,因为在文件尾部追加写入通常是非常高效的。与`db_set`做的事情类似,许多数据库内部使用**日志(log)**,也就是一个**仅追加(append-only)**的数据文件。真正的数据库有更多的问题需要处理(如并发控制,回收磁盘空间以免日志无限增长,处理错误和部分写入记录),但基本原理是一样的。日志非常有用,我们还将在本书的其它部分见到它。 +`db_set` 函数对于极其简单的场景其实有非常好的性能,因为在文件尾部追加写入通常是非常高效的。与`db_set`做的事情类似,许多数据库在内部使用了**日志(log)**,也就是一个**仅追加(append-only)**的数据文件。真正的数据库有更多的问题需要处理(如并发控制,回收磁盘空间以避免日志无限增长,处理错误与部分写入的记录),但基本原理是一样的。日志极其有用,我们还将在本书的其它部分重复见到它好几次。 -> 日志这个词通常用来指应用程序日志,应用程序输出描述发生事情的文本。本书中,日志用于更一般的含义上:一个只有追加记录的序列。它不一定是人类可读的记录,它可能是只能由其他程序读取的二进制记录。 +> **日志(log)**这个词通常指应用日志:即应用程序输出的描述发生事情的文本。本书在更普遍的意义下使用**日志**这一词:一个仅追加的记录序列。它可能压根就不是给人类看的,使用二进制格式,并仅能由其他程序读取。 -另一方面,如果这个数据库中有大量记录,则我们的`db_get`函数的性能会非常糟糕。每次你想查找一个键时,`db_get`必须从头到尾扫描整个数据库文件来查找键的出现。在算法方面,查找的成本是`O(n)`:如果数据库的记录数量n增加了一倍,查找也需要一倍的时间。这就不好了。 +另一方面,如果这个数据库中有着大量记录,则这个`db_get` 函数的性能会非常糟糕。每次你想查找一个键时,`db_get` 必须从头到尾扫描整个数据库文件来查找键的出现。用算法的语言来说,查找的开销是 `O(n)` :如果数据库记录数量 n 翻了一倍,查找时间也要翻一倍。这就不好了。 -为了高效地找到数据库中特定键的值,我们需要一个数据结构:索引。本章将介绍一系列的索引结构并对它们进行对比。索引的通用思路是保存一些额外的元数据作为路标,帮助你找到你想要的数据。如果您想以几种不同的方式在相同的数据中搜索,也许需要在数据的不同部分使用多个不同的索引。 +为了高效查找数据库中特定键的值,我们需要一个数据结构:**索引(index)**。本章将介绍一系列的索引结构,并它们进行对比。索引背后的大致思想是,保存一些额外的元数据作为路标,帮助你找到想要的数据。如果您想在同一份数据中以几种不同的方式进行搜索,那么你也许需要不同的索引,建在数据的不同部分上。 -索引是从主数据派生的附加结构。许多数据库允许添加和删除索引,这不会影响数据的内容,它只影响查询的性能。维护额外的结构会产生开销,特别是在写入时。写入性能很难超过简单地追加写入文件,因为这是最简单的写入操作。任何类型的索引通常都会减慢写入速度,因为每次写入数据时都需要更新索引。 +索引是从主数据衍生的**附加(additional)**结构。许多数据库允许添加与删除索引,这不会影响数据的内容,它只影响查询的性能。维护额外的结构会产生开销,特别是在写入时。写入性能很难超过简单地追加写入文件,因为追加写入是最简单的写入操作。任何类型的索引通常都会减慢写入速度,因为每次写入数据时都需要更新索引。 -这是存储系统中一个重要的权衡:精心选取的索引加快了读取查询速度,但是每个索引都会减慢写入速度。因此数据库通常不会索引所有内容,需要程序员或DBA通过对应用查询模式的了解来手动选择索引。你可以选择能为应用带来最大收益,同时又不会引入超必要开销的索引。 +这是存储系统中一个重要的权衡:精心选择的索引加快了读查询的速度,但是每个索引都会拖慢写入速度。因为这个原因,数据库默认并不会索引所有的内容,而需要你(程序员或DBA)通过对应用查询模式的了解来手动选择索引。你可以选择能为应用带来最大收益,同时又不会引入超出必要开销的索引。 ### 哈希索引 -让我们从**键值数据(key-value Data)**的索引开始。这不是您可以索引的唯一一种数据类型,但键值数据是非常常见的。对于更复杂的索引来说,这是一个有用的构建模块。 - -键值存储与在大多数编程语言中可以找到的字典类型非常相似,通常字典都是用散列表(哈希表)实现的。哈希映射在许多算法教科书中都有描述【1,2】,所以我们不会详细讨论它们工作方式。既然我们已经有内存数据结构——HashMap,为什么不使用它们来索引在磁盘上的数据呢? +让我们从**键值数据(key-value Data)**的索引开始。这不是您可以索引的唯一数据类型,但键值数据是很常见的。对于更复杂的索引来说,这是一个有用的构建模块。 -假设我们的数据存储只包含一个文件,就像前面的例子一样。然后,最简单的索引策略是:保留一个内存HashMap,其中每个键映射到数据文件中的一个字节偏移量,即该值可以被找到的位置。 +键值存储与在大多数编程语言中可以找到的**字典(dictionary)**类型非常相似,通常字典都是用**散列映射(hash map)**(或**哈希表(hash table)**)实现的。哈希映射在许多算法教科书中都有描述【1,2】,所以这里我们不会讨论它的工作细节。既然我们已经有**内存中**数据结构 —— 哈希映射,为什么不使用它来索引在**磁盘上**的数据呢? -如[图3-1](img/fig3-1.png)所示。无论何时将新的键值对添加到文件中,还要更新散列映射以反映刚刚写入的数据的偏移量(这适用于插入新键和更新现有键)。查找一个值时,使用哈希映射来查找数据文件中的偏移量,寻找该位置并读取该值。 +假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样。那么最简单的索引策略就是:保留一个内存中的哈希映射,其中每个键都映射到一个数据文件中的字节偏移量,指明了可以找到对应值的位置,如[图3-1](img/fig3-1.png)所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用哈希映射来查找数据文件中的偏移量,**寻找(seek)**该位置并读取该值。 ![](img/fig3-1.png) **图3-1 以类CSV格式存储键值对的日志,并使用内存哈希映射进行索引。** -听上去简单,但这是一个可行的方法。这实际上就是Bitcask做的事情(Riak中默认的存储引擎)【3】。 Bitcask提供高性能的读取和写入操作,但所有键必须能合适地放入在内存,因为哈希映射完全保留在内存中。这些值可以使用比可用内存更多的空间,因为可以从磁盘上通过一次`seek`加载所需部分,如果数据文件的那部分已经在文件系统缓存中,则读取根本不需要任何磁盘I/O。 +听上去简单,但这是一个可行的方法。现实中,Bitcask实际上就是这么做的(Riak中默认的存储引擎)【3】。 Bitcask提供高性能的读取和写入操作,但所有键必须能放入可用内存中,因为哈希映射完全保留在内存中。这些值可以使用比可用内存更多的空间,因为可以从磁盘上通过一次`seek`加载所需部分,如果数据文件的那部分已经在文件系统缓存中,则读取根本不需要任何磁盘I/O。 像Bitcask这样的存储引擎非常适合每个键的值经常更新的情况。例如,键可能是视频的URL,值可能是它播放的次数(每次有人点击播放按钮时递增)。在这种类型的工作负载中,有很多写操作,但是没有太多不同的键——每个键有很多的写操作,但是将所有键保存在内存中是可行的。 -直到现在,我们只是追加写一个文件 - 所以我们如何避免最终用完磁盘空间?一个好的解决方案是通过在达到一定大小时关闭一个段文件,然后将其写入一个新的段文件来将日志分割成特定大小的段。然后我们可以对这些段进行压缩,如[图3-2](img/fig3-2.png)所示。压缩意味着在日志中丢弃重复的键,只保留每个键的最新更新。 +直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完磁盘空间?一种好的解决方案是,将日志分为特定大小的段,当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行**压缩(compaction)**,如[图3-2](img/fig3-2.png)所示。压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。 ![](img/fig3-2.png) **图3-2 压缩键值更新日志(统计猫视频的播放次数),只保留每个键的最近值** -而且,由于压缩经常会使得段更小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如[图3-3](img/fig3-3.png)所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,在进行时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新的合并段而不是旧段 - 然后可以简单地删除旧的段文件。 +而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如[图3-3](img/fig3-3.png)所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,在进行时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新的合并段而不是旧段 —— 然后可以简单地删除旧的段文件。 ![](img/fig3-3.png) @@ -129,7 +130,7 @@ $ cat database 乍一看,只有追加日志看起来很浪费:为什么不更新文件,用新值覆盖旧值?但是只能追加设计的原因有几个: -* 追加和分段合并是顺序写入操作,通常比随机写入快得多,尤其是在磁盘旋转硬盘上。在某种程度上,顺序写入在基于闪存的固态硬盘(SSD)上也是优选的【4】。我们将在第83页的“[比较B-树和LSM-树](#比较B-树和LSM-树)”中进一步讨论这个问题。 +* 追加和分段合并是顺序写入操作,通常比随机写入快得多,尤其是在磁盘旋转硬盘上。在某种程度上,顺序写入在基于闪存的**固态硬盘(SSD)**上也是优选的【4】。我们将在第83页的“[比较B-树和LSM-树](#比较B-树和LSM-树)”中进一步讨论这个问题。 * 如果段文件是附加的或不可变的,并发和崩溃恢复就简单多了。例如,您不必担心在覆盖值时发生崩溃的情况,而将包含旧值和新值的一部分的文件保留在一起。 * 合并旧段可以避免数据文件随着时间的推移而分散的问题。 @@ -151,9 +152,9 @@ $ cat database 现在我们可以对段文件的格式做一个简单的改变:我们要求键值对的序列按键排序。乍一看,这个要求似乎打破了我们使用顺序写入的能力,但是我们马上就会明白这一点。 -我们把这个格式称为Sorted String Table,简称SSTable。我们还要求每个键只在每个合并的段文件中出现一次(压缩过程已经确保)。与使用散列索引的日志段相比,SSTable有几个很大的优势: +我们把这个格式称为**排序字符串表(Sorted String Table)**,简称SSTable。我们还要求每个键只在每个合并的段文件中出现一次(压缩过程已经保证)。与使用散列索引的日志段相比,SSTable有几个很大的优势: -1. 合并段是简单而高效的,即使文件大于可用内存。这种方法就像mergesort算法中使用的方法一样,如图3-4所示:您开始并排读取输入文件,查看每个文件中的第一个键,复制最低键(根据排序顺序)到输出文件,并重复。这产生一个新的合并段文件,也按键排序。 +1. 合并段是简单而高效的,即使文件大于可用内存。这种方法就像归并排序算法中使用的方法一样,如[图3-4](img/fig3-4.png)所示:您开始并排读取输入文件,查看每个文件中的第一个键,复制最低键(根据排序顺序)到输出文件,并重复。这产生一个新的合并段文件,也按键排序。 ![](img/fig3-4.png) @@ -167,12 +168,14 @@ $ cat database **图3-5 具有内存索引的SSTable** - 您仍然需要一个内存中索引来告诉您一些键的偏移量,但它可能很稀疏:每几千字节的段文件就有一个键就足够了,因为几千字节可以很快被扫描。 + 您仍然需要一个内存中索引来告诉您一些键的偏移量,但它可能很稀疏:每几千字节的段文件就有一个键就足够了,因为几千字节可以很快被扫描[^i]。 -3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组到块中,并在将其写入磁盘之前对其进行压缩(如图3-5中的阴影区域所示) 。稀疏内存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少IO带宽的使用。 +3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组到块中,并在将其写入磁盘之前对其进行压缩(如[图3-5](img/fig3-5.png)中的阴影区域所示) 。稀疏内存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少IO带宽的使用。 +[^i]: 如果所有的键与值都是定长的,你可以使用段文件上的二分查找并完全避免使用内存索引。然而实践中键值通常都是变长的,因此如果没有索引,就很难知道记录的分界点(前一条记录结束,后一条记录开始的地方) + #### 构建和维护SSTables 到目前为止,但是如何让你的数据首先被按键排序呢?我们的传入写入可以以任何顺序发生。 @@ -181,24 +184,24 @@ $ cat database 现在我们可以使我们的存储引擎工作如下: -* 写入时,将其添加到内存中的平衡树数据结构(例如,红黑树)。这个内存树有时被称为memtable。 -* 当memtable大于某个阈值(通常为几兆字节)时,将其作为SSTable文件写入磁盘。这可以高效地完成,因为树已经维护了按键排序的键值对。新的SSTable文件成为数据库的最新部分。当SSTable被写入磁盘时,写入可以继续到一个新的memtable实例。 -* 为了提供读取请求,首先尝试在memtable中找到关键字,然后在最近的磁盘段中,然后在下一个较旧的段中找到该关键字。 +* 写入时,将其添加到内存中的平衡树数据结构(例如,红黑树)。这个内存树有时被称为**内存表(memtable)**。 +* 当**内存表**大于某个阈值(通常为几兆字节)时,将其作为SSTable文件写入磁盘。这可以高效地完成,因为树已经维护了按键排序的键值对。新的SSTable文件成为数据库的最新部分。当SSTable被写入磁盘时,写入可以继续到一个新的内存表实例。 +* 为了提供读取请求,首先尝试在内存表中找到关键字,然后在最近的磁盘段中,然后在下一个较旧的段中找到该关键字。 * 有时会在后台运行合并和压缩过程以组合段文件并丢弃覆盖或删除的值。 -这个方案效果很好。它只会遇到一个问题:如果数据库崩溃,则最近的写入(在memtable中,但尚未写入磁盘)将丢失。为了避免这个问题,我们可以在磁盘上保存一个单独的日志,每个写入都会立即被附加到磁盘上,就像在前一节中一样。该日志不是按排序顺序,但这并不重要,因为它的唯一目的是在崩溃后恢复memtable。每当Memtable写出到SSTable时,相应的日志都可以被丢弃。 +这个方案效果很好。它只会遇到一个问题:如果数据库崩溃,则最近的写入(在内存表中,但尚未写入磁盘)将丢失。为了避免这个问题,我们可以在磁盘上保存一个单独的日志,每个写入都会立即被附加到磁盘上,就像在前一节中一样。该日志不是按排序顺序,但这并不重要,因为它的唯一目的是在崩溃后恢复内存表。每当内存表写出到SSTable时,相应的日志都可以被丢弃。 #### 用SSTables制作LSM树 这里描述的算法本质上是LevelDB 【6】和RocksDB 【7】中使用的关键值存储引擎库,被设计嵌入到其他应用程序中。除此之外,LevelDB可以在Riak中用作Bitcask的替代品。在Cassandra和HBase中使用了类似的存储引擎【8】,这两种引擎都受到了Google的Bigtable文档【9】(引入了SSTable和memtable)的启发。 -最初这种索引结构是由Patrick O'Neil等人描述的。在日志结构合并树(或LSM-Tree)【10】的基础上,建立在以前的工作上日志结构的文件系统【11】。基于这种合并和压缩排序文件原理的存储引擎通常被称为LSM存储引擎。 +最初这种索引结构是由Patrick O'Neil等人描述的。在日志结构合并树(或LSM树)【10】的基础上,建立在以前的工作上日志结构的文件系统【11】。基于这种合并和压缩排序文件原理的存储引擎通常被称为LSM存储引擎。 -Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使用类似的方法来存储它的词典【12,13】。全文索引比键值索引复杂得多,但是基于类似的想法:在搜索查询中给出一个单词,找到提及单词的所有文档(网页,产品描述等)。这是通过键值结构实现的,其中键是单词(术语),值是包含单词(发布列表)的所有文档的ID的列表。在Lucene中,从术语到发布列表的这种映射保存在SSTable类的有序文件中,根据需要在后台合并【14】。 +Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使用类似的方法来存储它的词典【12,13】。全文索引比键值索引复杂得多,但是基于类似的想法:在搜索查询中给出一个单词,找到提及单词的所有文档(网页,产品描述等)。这是通过键值结构实现的,其中键是单词(**关键词(term)**),值是包含单词(文章列表)的所有文档的ID的列表。在Lucene中,从术语到发布列表的这种映射保存在SSTable类的有序文件中,根据需要在后台合并【14】。 #### 性能优化 -与往常一样,大量的细节使得存储引擎在实践中表现良好。例如,当查找数据库中不存在的键时,LSM树算法可能会很慢:您必须检查memtable,然后将这些段一直回到最老的(可能必须从磁盘读取每一个),然后才能确定键不存在。为了优化这种访问,存储引擎通常使用额外的Bloom过滤器【15】。 (布隆过滤器是用于近似集合内容的内存高效数据结构,它可以告诉您数据库中是否出现键,从而为不存在的键节省许多不必要的磁盘读取操作。 +与往常一样,大量的细节使得存储引擎在实践中表现良好。例如,当查找数据库中不存在的键时,LSM树算法可能会很慢:您必须检查内存表,然后将这些段一直回到最老的(可能必须从磁盘读取每一个),然后才能确定键不存在。为了优化这种访问,存储引擎通常使用额外的Bloom过滤器【15】。 (布隆过滤器是用于近似集合内容的内存高效数据结构,它可以告诉您数据库中是否出现键,从而为不存在的键节省许多不必要的磁盘读取操作。 还有不同的策略来确定SSTables如何被压缩和合并的顺序和时间。最常见的选择是大小分层压实。 LevelDB和RocksDB使用平坦压缩(LevelDB因此得名),HBase使用大小分层,Cassandra同时支持【16】。在规模级别的调整中,更新和更小的SSTables先后被合并到更老的和更大的SSTable中。在水平压实中,关键范围被拆分成更小的SSTables,而较旧的数据被移动到单独的“水平”,这使得压缩能够更加递增地进行,并且使用更少的磁盘空间。 @@ -226,9 +229,11 @@ Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使 最后,我们可以看到包含单个键(叶页)的页面,该页面包含每个键的内联值,或者包含对可以找到值的页面的引用。 -在B树的一个页面中对子页面的引用的数量称为分支因子。例如,在[图3-6](img/fig3-6.png)中,分支因子是六。在实践中,分支因子取决于存储页面参考和范围边界所需的空间量,但通常是几百个。 +在B树的一个页面中对子页面的引用的数量称为分支因子。例如,在[图3-6](img/fig3-6.png)中,分支因子是6 。在实践中,分支因子取决于存储页面参考和范围边界所需的空间量,但通常是几百个。 + +如果要更新B树中现有键的值,则搜索包含该键的叶页,更改该页中的值,并将该页写回到磁盘(对该页的任何引用保持有效) 。如果你想添加一个新的键,你需要找到其范围包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以解释键范围的新分区,如[图3-7](img/fig3-7.png)所示[^ii]。 -如果要更新B树中现有键的值,则搜索包含该键的叶页,更改该页中的值,并将该页写回到磁盘(对该页的任何引用保持有效) 。如果你想添加一个新的键,你需要找到其范围包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以解释键范围的新分区,如[图3-7](img/fig3-7.png)所示。 +[^ii]: 向B树中插入一个新的键是相当符合直觉的,但删除一个键(同时保持树平衡)就会牵扯很多其他东西了。 ![](img/fig3-7.png) @@ -350,7 +355,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 诸如VoltDB,MemSQL和Oracle TimesTen等产品是具有关系模型的内存数据库,供应商声称,通过消除与管理磁盘上的数据结构相关的所有开销,他们可以提供巨大的性能改进【41,42】。 RAM Cloud是一个开源的内存键值存储器,具有持久性(对存储器中的数据以及磁盘上的数据使用日志结构化方法)【43】。 Redis和Couchbase通过异步写入磁盘提供了较弱的持久性。 -**反直觉的是,内存数据库的性能优势并不是因为它们不需要从磁盘读取的事实。即使是基于磁盘的存储引擎也可能永远不需要从磁盘读取,因为操作系统缓存最近在内存中使用了磁盘块。相反,它们更快的原因在于省去了将内存数据结构编码为磁盘数据结构的开销**。【44】。 +反直觉的是,内存数据库的性能优势并不是因为它们不需要从磁盘读取的事实。即使是基于磁盘的存储引擎也可能永远不需要从磁盘读取,因为操作系统缓存最近在内存中使用了磁盘块。相反,它们更快的原因在于省去了将内存数据结构编码为磁盘数据结构的开销。【44】。 除了性能,内存数据库的另一个有趣的领域是提供难以用基于磁盘的索引实现的数据模型。例如,Redis为各种数据结构(如优先级队列和集合)提供了类似数据库的接口。因为它将所有数据保存在内存中,所以它的实现相对简单。 @@ -363,7 +368,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 ## 事务处理还是分析? 在业务数据处理的早期,对数据库的写入通常对应于正在进行的商业交易:进行销售,向供应商下订单,支付员工工资等等。随着数据库扩展到那些没有不涉及钱易手,术语交易仍然卡住,指的是形成一个逻辑单元的一组读写。 -事务不一定具有ACID(原子性,一致性,隔离性和持久性)属性。事务处理只是意味着允许客户端进行低延迟读取和写入 - 而不是批量处理作业,而这些作业只能定期运行(例如每天一次)。我们在[第7章](ch7.md)中讨论ACID属性,在[第10章](ch10.md)中讨论批处理。 +事务不一定具有ACID(原子性,一致性,隔离性和持久性)属性。事务处理只是意味着允许客户端进行低延迟读取和写入 —— 而不是批量处理作业,而这些作业只能定期运行(例如每天一次)。我们在[第7章](ch7.md)中讨论ACID属性,在[第10章](ch10.md)中讨论批处理。 即使数据库开始被用于许多不同类型的博客文章,游戏中的动作,地址簿中的联系人等等,基本访问模式仍然类似于处理业务事务。应用程序通常使用索引通过某个键查找少量记录。根据用户的输入插入或更新记录。由于这些应用程序是交互式的,因此访问模式被称为**在线事务处理(OLTP, OnLine Transaction Processing)**。 @@ -385,11 +390,11 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 | 处理的数据 | 数据的最新状态(当前时间点) | 随时间推移的历史事件 | | 数据集尺寸 | GB ~ TB | TB ~ PB | -起初,相同的数据库用于事务处理和分析查询。 SQL在这方面证明是非常灵活的:对于OLTP类型的查询以及OLAP类型的查询来说,效果很好。尽管如此,在二十世纪八十年代末和九十年代初期,公司有停止使用OLTP系统进行分析的趋势,而是在单独的数据库上运行分析。这个单独的数据库被称为数据仓库。 +起初,相同的数据库用于事务处理和分析查询。 SQL在这方面证明是非常灵活的:对于OLTP类型的查询以及OLAP类型的查询来说效果很好。尽管如此,在二十世纪八十年代末和九十年代初期,公司有停止使用OLTP系统进行分析的趋势,而是在单独的数据库上运行分析。这个单独的数据库被称为**数据仓库**。 ### 数据仓库 -一个企业可能有几十个不同的交易处理系统:系统为面向客户的网站提供动力,控制实体商店的销售点(checkout)系统,跟踪仓库中的库存,规划车辆路线,管理供应商,管理员工等。这些系统中的每一个都是复杂的,需要一个人员去维护,所以系统最终都是自动运行的。 +一个企业可能有几十个不同的交易处理系统:系统为面向客户的网站提供动力,控制实体商店的**销售点(checkout)**系统,跟踪仓库中的库存,规划车辆路线,管理供应商,管理员工等。这些系统中的每一个都是复杂的,需要一个人员去维护,所以系统最终都是自动运行的。 这些OLTP系统通常具有高度的可用性,并以低延迟处理事务,因为这些系统往往对业务运作至关重要。因此数据库管理员密切关注他们的OLTP数据库他们通常不愿意让业务分析人员在OLTP数据库上运行临时分析查询,因为这些查询通常很昂贵,扫描大部分数据集,这会损害同时执行的事务的性能。 @@ -417,7 +422,7 @@ Teradata,Vertica,SAP HANA和ParAccel等数据仓库供应商通常使用昂 正如[第2章](ch2.md)所探讨的,根据应用程序的需要,在事务处理领域中使用了大量不同的数据模型。另一方面,在分析中,数据模型的多样性则少得多。许多数据仓库都以相当公式化的方式使用,被称为星型模式(也称为维度建模【55】)。 -图3-9中的示例模式显示了可能在食品零售商处找到的数据仓库。在模式的中心是一个所谓的事实表(在这个例子中,它被称为`fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买的产品)。如果我们分析的是网站流量而不是零售量,则每行可能代表一个用户的页面浏览量或点击量。 +图3-9中的示例模式显示了可能在食品零售商处找到的数据仓库。在模式的中心是一个所谓的事实表(在这个例子中,它被称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买的产品)。如果我们分析的是网站流量而不是零售量,则每行可能代表一个用户的页面浏览量或点击量。 ![](img/fig3-9.png) @@ -427,15 +432,15 @@ Teradata,Vertica,SAP HANA和ParAccel等数据仓库供应商通常使用昂 事实表中的一些列是属性,例如产品销售的价格和从供应商那里购买的成本(允许计算利润余额)。事实表中的其他列是对其他表(称为维表)的外键引用。由于事实表中的每一行都表示一个事件,因此这些维度代表事件的发生地点,时间,方式和原因。 -例如,在[图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】。 +这个模板的变体被称为雪花模式,其中尺寸被进一步分解为子尺寸。例如,品牌和产品类别可能有单独的表格,并且 `dim_product` 表格中的每一行都可以将品牌和类别作为外键引用,而不是将它们作为字符串存储在 `dim_product` 表格中。雪花模式比星形模式更规范化,但是星形模式通常是首选,因为分析师使用它更简单【55】。 -在典型的数据仓库中,表格通常非常宽泛:事实表格通常有100列以上,有时甚至有数百列【51】。维度表也可以是非常宽的,因为它们包括可能与分析相关的所有元数据——例如,`dim_store`表可以包括在每个商店提供哪些服务的细节,它是否具有店内面包房,方形镜头,商店第一次开幕的日期,最后一次改造的时间,离最近的高速公路的距离等等。 +在典型的数据仓库中,表格通常非常宽泛:事实表格通常有100列以上,有时甚至有数百列【51】。维度表也可以是非常宽的,因为它们包括可能与分析相关的所有元数据——例如,`dim_store` 表可以包括在每个商店提供哪些服务的细节,它是否具有店内面包房,方形镜头,商店第一次开幕的日期,最后一次改造的时间,离最近的高速公路的距离等等。 @@ -443,7 +448,7 @@ Teradata,Vertica,SAP HANA和ParAccel等数据仓库供应商通常使用昂 如果事实表中有万亿行和数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 分析人们是否更倾向于购买新鲜水果或糖果,这取决于一周中的哪一天** diff --git a/ch4.md b/ch4.md index a7421b53..1b6be85c 100644 --- a/ch4.md +++ b/ch4.md @@ -410,7 +410,7 @@ REST风格的API倾向于更简单的方法,通常涉及较少的代码生成 #### 远程过程调用(RPC)的问题 -Web服务仅仅是通过网络进行API请求的一系列技术的最新版本,其中许多技术受到了大量的炒作,但是存在严重的问题。 Enterprise JavaBeans(EJB)和Java的**远程方法调用(RMI)**仅限于Java。**分布式组件对象模型(DCOM)**仅限于Microsoft平台。**公共对象请求代理体系结构(CORBA)**过于复杂,不提供前向或后向兼容性[41]。 +Web服务仅仅是通过网络进行API请求的一系列技术的最新版本,其中许多技术受到了大量的炒作,但是存在严重的问题。 Enterprise JavaBeans(EJB)和Java的**远程方法调用(RMI)**仅限于Java。**分布式组件对象模型(DCOM)**仅限于Microsoft平台。**公共对象请求代理体系结构(CORBA)**过于复杂,不提供前向或后向兼容性【41】。 所有这些都是基于**远程过程调用(RPC)**的思想,该过程调用自20世纪70年代以来一直存在【42】。 RPC模型试图向远程网络服务发出请求,看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明)。尽管RPC起初看起来很方便,但这种方法根本上是有缺陷的【43,44】。网络请求与本地函数调用非常不同: @@ -420,7 +420,7 @@ Web服务仅仅是通过网络进行API请求的一系列技术的最新版本 * 每次调用本地功能时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也是非常可变的:在不到一毫秒的时间内它可能会完成,但是当网络拥塞或者远程服务超载时,可能需要几秒钟的时间完全一样的东西。 * 调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当你发出一个网络请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节。没关系,如果参数是像数字或字符串这样的基本类型,但是对于较大的对象很快就会变成问题。 -客户端和服务可以用不同的编程语言实现,所以RPC框架必须将数据类型从一种语言翻译成另一种语言。这可能会捅出大篓子,因为不是所有的语言都具有相同的类型 - 例如回想一下JavaScript的数字大于$2^{53}$的问题(参阅“[JSON,XML和二进制变体](#JSON,XML和二进制变体)”)。用单一语言编写的单个进程中不存在此问题。 +客户端和服务可以用不同的编程语言实现,所以RPC框架必须将数据类型从一种语言翻译成另一种语言。这可能会捅出大篓子,因为不是所有的语言都具有相同的类型 —— 例如回想一下JavaScript的数字大于$2^{53}$的问题(参阅“[JSON,XML和二进制变体](#JSON,XML和二进制变体)”)。用单一语言编写的单个进程中不存在此问题。 所有这些因素意味着尝试使远程服务看起来像编程语言中的本地对象一样毫无意义,因为这是一个根本不同的事情。 REST的部分吸引力在于,它并不试图隐藏它是一个网络协议的事实(尽管这似乎并没有阻止人们在REST之上构建RPC库)。 @@ -440,7 +440,7 @@ Web服务仅仅是通过网络进行API请求的一系列技术的最新版本 RPC方案的前后向兼容性属性从它使用的编码方式中继承 -* Thrift,gRPC(协议缓冲区)和Avro RPC可以根据相应编码格式的兼容性规则进行演变。 +* Thrift,gRPC(Protobuf)和Avro RPC可以根据相应编码格式的兼容性规则进行演变。 * 在SOAP中,请求和响应是使用XML模式指定的。这些可以演变,但有一些微妙的陷阱【47】。 * RESTful API通常使用JSON(没有正式指定的模式)用于响应,以及用于请求的JSON或URI编码/表单编码的请求参数。添加可选的请求参数并向响应对象添加新的字段通常被认为是保持兼容性的改变。 @@ -478,7 +478,7 @@ RPC方案的前后向兼容性属性从它使用的编码方式中继承 #### 分布式的Actor框架 -actor模型是单个进程中并发的编程模型。逻辑被封装在角色中,而不是直接处理线程(以及竞争条件,锁定和死锁的相关问题)。每个角色通常代表一个客户或实体,它可能有一些本地状态(不与其他任何角色共享),它通过发送和接收异步消息与其他角色通信。消息传送不保证:在某些错误情况下,消息将丢失。由于每个角色一次只能处理一条消息,因此不需要担心线程,每个角色可以由框架独立调度。 +Actor模型是单个进程中并发的编程模型。逻辑被封装在角色中,而不是直接处理线程(以及竞争条件,锁定和死锁的相关问题)。每个角色通常代表一个客户或实体,它可能有一些本地状态(不与其他任何角色共享),它通过发送和接收异步消息与其他角色通信。消息传送不保证:在某些错误情况下,消息将丢失。由于每个角色一次只能处理一条消息,因此不需要担心线程,每个角色可以由框架独立调度。 在分布式的行为者框架中,这个编程模型被用来跨越多个节点来扩展应用程序。不管发送方和接收方是在同一个节点上还是在不同的节点上,都使用相同的消息传递机制。如果它们在不同的节点上,则该消息被透明地编码成字节序列,通过网络发送,并在另一侧解码。