mirror of
https://github.com/Vonng/ddia.git
synced 2024-12-06 15:20:12 +08:00
Merge remote-tracking branch 'refs/remotes/Vonng/master'
This commit is contained in:
commit
b0fe58a1d9
16
README.md
16
README.md
@ -106,18 +106,15 @@
|
||||
| 第六章:分区 | 初翻 | |
|
||||
| 第七章:事务 | 精翻 60% | Vonng |
|
||||
| 第八章:分布式系统中的问题 | 初翻 | |
|
||||
| 第九章:一致性与共识 | 初翻 30% | Vonng |
|
||||
| 第九章:一致性与共识 | 初翻 | Vonng |
|
||||
| 第三部分:前言 | 精翻 | |
|
||||
| 第十章:批处理 | 机翻 | 于鑫 |
|
||||
| 第十一章:流处理 | 机翻 | 于鑫 |
|
||||
| 第十二章:数据系统的未来 | 机翻 | |
|
||||
| 第十章:批处理 | 草翻 | |
|
||||
| 第十一章:流处理 | 草翻 | |
|
||||
| 第十二章:数据系统的未来 | 草翻 | |
|
||||
| 术语表 | - | |
|
||||
| 后记 | 机翻 | |
|
||||
|
||||
|
||||
计划在3月内完成所有章节的初翻。
|
||||
|
||||
|
||||
|
||||
## CONTRIBUTION
|
||||
|
||||
@ -128,7 +125,10 @@
|
||||
All contribution will give proper credit. 贡献者需要同意[法律声明](#法律声明)所叙内容。
|
||||
|
||||
1. [序言初翻修正](https://github.com/Vonng/ddia/commit/afb5edab55c62ed23474149f229677e3b42dfc2c) by [@seagullbird](https://github.com/Vonng/ddia/commits?author=seagullbird)
|
||||
2. [第一章语法标点修正](https://github.com/Vonng/ddia/commit/973b12cd8f8fcdf4852f1eb1649ddd9d187e3644) by [@nevertiree](https://github.com/Vonng/ddia/commits?author=nevertiree)
|
||||
2. [第一章语法标点校正](https://github.com/Vonng/ddia/commit/973b12cd8f8fcdf4852f1eb1649ddd9d187e3644) by [@nevertiree](https://github.com/Vonng/ddia/commits?author=nevertiree)
|
||||
3. [第六章部分校正](https://github.com/Vonng/ddia/commit/d4eb0852c0ec1e93c8aacc496c80b915bb1e6d48) by @[MuAlex](https://github.com/Vonng/ddia/commits?author=MuAlex)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
36
ch1.md
36
ch1.md
@ -2,7 +2,7 @@
|
||||
|
||||
![](img/ch1.png)
|
||||
|
||||
> 互联网做得太棒了,以至于多数人将它看作像海洋这样的天然资源,而不是什么人工产物。上一次出现这种大规模且无差错的技术, 你还记得是什么时候吗?
|
||||
> 互联网做得太棒了,以至于大多数人将它看作像太平洋这样的自然资源,而不是什么人工产物。上一次出现这种大规模且无差错的技术, 你还记得是什么时候吗?
|
||||
>
|
||||
> ——阿兰·凯在接受Dobb博士杂志采访时说(2012年)
|
||||
|
||||
@ -32,11 +32,11 @@
|
||||
|
||||
***批处理(batch processing)***
|
||||
|
||||
定期压缩累积的大批量数据
|
||||
定期处理累积的大批量数据
|
||||
|
||||
如果这些功能听上去平淡无奇,那是因为这些**数据系统(data system)**是非常成功的抽象,我们一直不假思索地使用它们并习以为常。绝大多数工程师不会想从零开始编写存储引擎,因为在开发应用时,数据库已经是足够完美的工具了。
|
||||
如果这些功能听上去平淡无奇,那是因为这些**数据系统(data system)**是非常成功的抽象:我们一直不假思索地使用它们并习以为常。绝大多数工程师不会幻想从零开始编写存储引擎,因为在开发应用时,数据库已经是足够完美的工具了。
|
||||
|
||||
但事实并没有这么简单。不同的应用有着不同的需求,所以数据库系统也是百花齐放,有着各式各样的特性。我们有很多种手段可以实现缓存,也有好几种方法可以搞定搜索索引,诸如此类。因此在开发应用前,我们有必要先弄清楚最适合手头工作的工具和方法。而且当单个工具解决不了你的问题时,你会发现组合使用这些工具还是挺有难度的。
|
||||
但现实没有这么简单。不同的应用有着不同的需求,因而数据库系统也是百花齐放,有着各式各样的特性。实现缓存有很多种手段,创建搜索索引也有好几种方法,诸如此类。因此在开发应用前,我们依然有必要先弄清楚最适合手头工作的工具和方法。而且当单个工具解决不了你的问题时,组合使用这些工具可能还是有些难度的。
|
||||
|
||||
本书将是一趟关于数据系统原理、实践与应用的旅程,并讲述了设计数据密集型应用的方法。我们将探索不同工具之间的共性与特性,以及各自的实现原理。
|
||||
|
||||
@ -141,7 +141,7 @@
|
||||
|
||||
尽管人类不可靠,但怎么做才能让系统变得可靠?最好的系统会组合使用以下几种办法:
|
||||
|
||||
* 以最小化犯错机会的方式设计系统。例如,精心设计的抽象、API和管理后台使做对事情更容易,搞砸事情更困难。但如果接口限制太多,人们就会否定它们的好处而想办法绕开。这是一个很难正确把握的棘手平衡。
|
||||
* 以最小化犯错机会的方式设计系统。例如,精心设计的抽象、API和管理后台使做对事情更容易,搞砸事情更困难。但如果接口限制太多,人们就会忽略它们的好处而想办法绕开。很难正确把握这种微妙的平衡。
|
||||
* 将人们最容易犯错的地方与可能导致失效的地方**解耦(decouple)**。特别是提供一个功能齐全的非生产环境**沙箱(sandbox)**,使人们可以在不影响真实用户的情况下,使用真实数据安全地探索和实验。
|
||||
* 在各个层次进行彻底的测试【3】,从单元测试、全系统集成测试到手动测试。自动化测试易于理解,已经被广泛使用,特别适合用来覆盖正常情况中少见的**边缘场景(corner case)**。
|
||||
* 允许从人为错误中简单快速地恢复,以最大限度地减少失效情况带来的影响。 例如,快速回滚配置变更,分批发布新代码(以便任何意外错误只影响一小部分用户),并提供数据重算工具(以备旧的计算出错)。
|
||||
@ -150,7 +150,7 @@
|
||||
|
||||
### 可靠性有多重要?
|
||||
|
||||
可靠性不仅仅是针对核电站和空中交通管制软件而言,我们也期望许多平凡的应用能可靠地运行。商务应用中的错误会导致生产力损失(也许数据报告不完整还会有法律风险),而电商网站的中断则可能会导致收入和声誉的巨大损失。
|
||||
可靠性不仅仅是针对核电站和空中交通管制软件而言,我们也期望更多平凡的应用能可靠地运行。商务应用中的错误会导致生产力损失(也许数据报告不完整还会有法律风险),而电商网站的中断则可能会导致收入和声誉的巨大损失。
|
||||
|
||||
即使在“非关键”应用中,我们也对用户负有责任。试想一位家长把所有的照片和孩子的视频储存在你的照片应用里【15】。如果数据库突然损坏,他们会感觉如何?他们可能会知道如何从备份恢复吗?
|
||||
|
||||
@ -166,7 +166,7 @@
|
||||
|
||||
### 描述负载
|
||||
|
||||
讨论增长问题(如果负载加倍会发生什么?)前,首先要能简要描述系统的当前负载。负载可以用一些称为**负载参数(load parameters)**的数字来描述。参数的最佳选择取决于系统架构,它可能是每秒向Web服务器发出的请求、数据库中的读写比率、聊天室中同时活跃的用户数量、缓存命中率或其他东西。除此之外,也许平均情况对你很重要,也许你的瓶颈是少数极端场景。
|
||||
在讨论增长问题(如果负载加倍会发生什么?)前,首先要能简要描述系统的当前负载。负载可以用一些称为**负载参数(load parameters)**的数字来描述。参数的最佳选择取决于系统架构,它可能是每秒向Web服务器发出的请求、数据库中的读写比率、聊天室中同时活跃的用户数量、缓存命中率或其他东西。除此之外,也许平均情况对你很重要,也许你的瓶颈是少数极端场景。
|
||||
|
||||
为了使这个概念更加具体,我们以推特在2012年11月发布的数据【16】为例。推特的两个主要业务是:
|
||||
|
||||
@ -205,31 +205,31 @@
|
||||
|
||||
推特的第一个版本使用了方法1,但系统很难跟上主页时间线查询的负载。所以公司转向了方法2,方法2的效果更好,因为发推频率比查询主页时间线的频率几乎低了两个数量级,所以在这种情况下,最好在写入时做更多的工作,而在读取时做更少的工作。
|
||||
|
||||
然而方法2的缺点是,发推现在需要大量的额外工作。平均来说,一条推文会发往约75个关注者,所以每秒4.6k的发推写入,变成了对主页时间线缓存每秒345k的写入。但这个平均值隐藏了用户粉丝数差异巨大这一现实,一些用户有超过3000万的粉丝,这意味着一条推文就可能会导致主页时间线缓存的3000万次写入!及时完成这种操作是一个巨大的挑战——推特尝试在5秒内向粉丝发送推文。
|
||||
然而方法2的缺点是,发推现在需要大量的额外工作。平均来说,一条推文会发往约75个关注者,所以每秒4.6k的发推写入,变成了对主页时间线缓存每秒345k的写入。但这个平均值隐藏了用户粉丝数差异巨大这一现实,一些用户有超过3000万的粉丝,这意味着一条推文就可能会导致主页时间线缓存的3000万次写入!及时完成这种操作是一个巨大的挑战 —— 推特尝试在5秒内向粉丝发送推文。
|
||||
|
||||
在推特的例子中,每个用户粉丝数的分布(可能按这些用户的发推频率来加权)是讨论可扩展性的关键负载参数,因为它决定了扇出负载。你的应用程序可能具有非常不同的特征,但可以使用相似的原则来考虑你的负载。
|
||||
在推特的例子中,每个用户粉丝数的分布(可能按这些用户的发推频率来加权)是探讨可扩展性的一个关键负载参数,因为它决定了扇出负载。你的应用程序可能具有非常不同的特征,但可以采用相似的原则来考虑它的负载。
|
||||
|
||||
推特轶事的最终转折:现在方法2已经稳健地实现了,但推特又转向了两种方法的混合。大多数用户发推时仍然是扇出写入粉丝的主页时间线缓存中。但是少数拥有海量粉丝的用户(即名流)被排除在外。当用户读取主页时间线时,来自所关注名流的推文都会单独拉取,并与用户的主页时间线缓存合并,如方法1所示。这种混合方法能始终如一地提供良好性能。在[第12章](ch12.md)中将重新讨论这个例子,那时我们已经覆盖了更多的技术层面。
|
||||
推特轶事的最终转折:现在已经稳健地实现了方法2,推特逐步转向了两种方法的混合。大多数用户发的推文会被扇出写入其粉丝主页时间线缓存中。但是少数拥有海量粉丝的用户(即名流)会被排除在外。当用户读取主页时间线时,分别地获取出该用户所关注的每位名流的推文,再与用户的主页时间线缓存合并,如方法1所示。这种混合方法能始终如一地提供良好性能。在[第12章](ch12.md)中我们将重新讨论这个例子,这在覆盖更多技术层面之后。
|
||||
|
||||
### 描述性能
|
||||
|
||||
一旦系统的负载可以被描述,就可以研究当负载增加会发生什么。我们可以从两种角度来看:
|
||||
一旦系统的负载被描述好,就可以研究当负载增加会发生什么。我们可以从两种角度来看:
|
||||
|
||||
* 增加负载参数并保持系统资源(CPU、内存、网络带宽等)不变时,系统性能将有什么影响?
|
||||
* 增加负载参数并保持系统资源(CPU、内存、网络带宽等)不变时,系统性能将受到什么影响?
|
||||
* 增加负载参数并希望保持性能不变时,需要增加多少系统资源?
|
||||
|
||||
这两个问题都需要性能数据,所以让我们简单地看一下如何描述系统性能。
|
||||
|
||||
对于Hadoop这样的批处理系统,通常关心的是**吞吐量(throughput)**,即每秒可以处理的记录数量,或者在特定规模数据集上运行作业的总时间[^iii]。对于在线系统,通常更重要的是服务的**响应时间(response time)**,即客户端发送请求和接收响应之间的时间。
|
||||
对于Hadoop这样的批处理系统,通常关心的是**吞吐量(throughput)**,即每秒可以处理的记录数量,或者在特定规模数据集上运行作业的总时间[^iii]。对于在线系统,通常更重要的是服务的**响应时间(response time)**,即客户端发送请求到接收响应之间的时间。
|
||||
|
||||
[^iii]: 理想情况下,批量作业的运行时间是数据集的大小除以吞吐量。 在实践中由于数据倾斜(数据不是均匀分布在每个工作进程中),需要等待最慢的任务完成,所以运行时间往往更长。
|
||||
|
||||
> #### 延迟和响应时间
|
||||
>
|
||||
> **延迟(latency)**和**响应时间(response time)**通常当成同义词用,但实际上它们并不一样。响应时间是客户所看到的,除了实际处理请求的时间(**服务时间(service time)**)之外,还包括网络延迟和排队延迟。延迟是某个请求等待处理的**持续时长**,在此期间它处于**休眠(latent)**状态,并等待服务【17】。
|
||||
> **延迟(latency)**和**响应时间(response time)**经常用作同义词,但实际上它们并不一样。响应时间是客户所看到的,除了实际处理请求的时间(**服务时间(service time)**)之外,还包括网络延迟和排队延迟。延迟是某个请求等待处理的**持续时长**,在此期间它处于**休眠(latent)**状态,并等待服务【17】。
|
||||
>
|
||||
|
||||
即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的**分布(distribution)**,而不是单个数值。
|
||||
即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值**分布(distribution)**,而不是单个数值。
|
||||
|
||||
在[图1-4](img/fig1-4.png)中,每个灰条表代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导致结果变化,例如:上下文切换到后台进程,网络数据包丢失与TCP重传,垃圾收集暂停,强制从磁盘读取的页面错误,服务器机架中的震动【18】,还有很多其他原因。
|
||||
|
||||
@ -237,7 +237,7 @@
|
||||
|
||||
**图1-4 展示了一个服务100次请求响应时间的均值与百分位数**
|
||||
|
||||
通常报表都会展示服务的平均响应时间。 (严格来讲“平均”一词并不指代任何特定公式,但实际上它通常被理解为**算术平均值(arithmetic mean)**:给定n个值,加起来除以n)。然而如果你想知道“**典型(typical)**”响应时间,那么平均值并不是一个非常好的指标,因为它不能告诉你有多少用户实际上经历了这个延迟。
|
||||
通常报表都会展示服务的平均响应时间。 (严格来讲“平均”一词并不指代任何特定公式,但实际上它通常被理解为**算术平均值(arithmetic mean)**:给定 n 个值,加起来除以 n )。然而如果你想知道“**典型(typical)**”响应时间,那么平均值并不是一个非常好的指标,因为它不能告诉你有多少用户实际上经历了这个延迟。
|
||||
|
||||
通常使用**百分位点(percentiles)**会更好。如果将响应时间列表按最快到最慢排序,那么**中位数(median)**就在正中间:举个例子,如果你的响应时间中位数是200毫秒,这意味着一半请求的返回时间少于200毫秒,另一半比这个要长。
|
||||
|
||||
@ -245,7 +245,7 @@
|
||||
|
||||
为了弄清异常值有多糟糕,可以看看更高的百分位点,例如第95、99和99.9百分位点(缩写为p95,p99和p999)。它们意味着95%,99%或99.9%的请求响应时间要比该阈值快,例如:如果第95百分位点响应时间是1.5秒,则意味着100个请求中的95个响应时间快于1.5秒,而100个请求中的5个响应时间超过1.5秒。如[图1-4](img/fig1-4.png)所示。
|
||||
|
||||
响应时间的高百分位点(也称为**尾部延迟(tail percentil)**)非常重要,因为它们直接影响用户的服务体验。例如亚马逊在描述内部服务的响应时间要求时以99.9百分位点为准,即使它只影响一千个请求中的一个。这是因为请求响应最慢的客户往往也是数据最多的客户,也可以说是最有价值的客户——因为他们掏钱了【19】。保证网站响应迅速对于保持客户的满意度非常重要,亚马逊观察到:响应时间增加100毫秒,销售量就减少1%【20】;而另一些报告说:慢1秒钟会让客户满意度指标减少16%【21,22】。
|
||||
响应时间的高百分位点(也称为**尾部延迟(tail latencies)**)非常重要,因为它们直接影响用户的服务体验。例如亚马逊在描述内部服务的响应时间要求时以99.9百分位点为准,即使它只影响一千个请求中的一个。这是因为请求响应最慢的客户往往也是数据最多的客户,也可以说是最有价值的客户 —— 因为他们掏钱了【19】。保证网站响应迅速对于保持客户的满意度非常重要,亚马逊观察到:响应时间增加100毫秒,销售量就减少1%【20】;而另一些报告说:慢 1 秒钟会让客户满意度指标减少16%【21,22】。
|
||||
|
||||
另一方面,优化第99.99百分位点(一万个请求中最慢的一个)被认为太昂贵了,不能为亚马逊的目标带来足够好处。减小高百分位点处的响应时间相当困难,因为它很容易受到随机事件的影响,这超出了控制范围,而且效益也很小。
|
||||
|
||||
@ -386,7 +386,7 @@
|
||||
|
||||
不幸的是,使应用可靠、可扩展或可持续并不容易。但是某些模式和技术会不断重新出现在不同的应用中。在接下来的几章中,我们将看到一些数据系统的例子,并分析它们如何实现这些目标。
|
||||
|
||||
在本书后面的[第三部分](part-iii.md)中,我们将看到一种模式:几个组件协同工作以构成一个完整的系统(例如[图1-1](img/fig1-1.png)中的)
|
||||
在本书后面的[第三部分](part-iii.md)中,我们将看到一种模式:几个组件协同工作以构成一个完整的系统(如[图1-1](img/fig1-1.png)中的例子)
|
||||
|
||||
|
||||
|
||||
|
431
ch10.md
431
ch10.md
@ -2,7 +2,7 @@
|
||||
|
||||
![](img/ch10.png)
|
||||
|
||||
> 带有太强个人色彩的系统无法成功。当第一版健壮的设计完成时,不同的人们以自己的方式来测试,真正的考验才开始。
|
||||
> 带有太强个人色彩的系统无法成功。当第一版健壮的设计完成时,不同的人们以自己的方式来测试时,真正的考验才开始。
|
||||
>
|
||||
> ——高德纳
|
||||
|
||||
@ -10,61 +10,58 @@
|
||||
|
||||
[TOC]
|
||||
|
||||
在本书的前两部分中,我们讨论了很多关于请求和查询以及相应的响应或结果。这种数据处理方式在许多现代数据系统中都是假设的:你要求什么,或者发送指令,一段时间后系统(希望)会给你一个答案。数据库,缓存,搜索索引,Web服务器以及其他许多系统都以这种方式工作。
|
||||
在本书的前两部分中,我们讨论了很多关于**请求(requests)**和**查询(queries)**以及相应的**响应(response)**或**结果(results)**。在许多现代数据系统中都假设采用这种数据处理方式:你要求某些东西,或者发送指令,一段时间后(希望)系统会给你一个答案。数据库,缓存,搜索索引,Web服务器以及其他许多系统都以这种方式工作。
|
||||
|
||||
在这样的在线系统中,无论是浏览器请求页面还是调用远程API的服务,我们通常都假设请求是由人类用户触发的,并且用户正在等待响应。他们不必等太久,所以我们非常重视这些系统的响应时间(请参阅第13页的“描述性能”)。
|
||||
在这类**在线(online)**系统中,无论是浏览器请求页面还是调用远程API的服务,我们通常都假设请求是由人类用户触发的,且用户正在等待响应。他们不必等太久,所以我们非常重视这些系统的响应时间(参阅“[描述性能](ch1.md)”)。
|
||||
|
||||
Web和越来越多的基于HTTP / REST的API使交互的请求/响应风格变得如此普遍,以至于很容易将其视为理所当然。但我们应该记住,这不是构建系统的唯一方式,其他方法也有其优点。我们来区分三种不同类型的系统:
|
||||
服务(在线系统)
|
||||
Web和越来越多的基于HTTP/REST的API使交互的请求/响应风格变得如此普遍,以至于很容易将其视为理所当然。但我们应该记住,这不是构建系统的唯一方式,其他方法也有其优点。我们来区分三种不同类型的系统:
|
||||
|
||||
***服务(在线系统)***
|
||||
|
||||
服务等待客户的请求或指令到达。当收到一个,服务试图尽快处理它,并发回一个响应。响应时间通常是服务性能的主要衡量指标,可用性通常非常重要(如果客户端无法访问服务,用户可能会收到错误消息)。
|
||||
|
||||
***批处理系统(离线系统)***
|
||||
|
||||
一个批处理系统需要大量的输入数据,运行一个工作来处理它,并产生一些输出数据。工作往往需要一段时间(从几分钟到几天),所以通常不会有用户等待工作完成。相反,批量作业通常会定期运行(例如,每天一次)。批处理作业的主要性能衡量标准通常是吞吐量(通过特定大小的输入数据集所需的时间)。我们讨论本章中的批处理。
|
||||
一个批处理系统需要大量的输入数据,运行一个工作来处理它,并产生一些输出数据。工作往往需要一段时间(从几分钟到几天),所以通常不会有用户等待工作完成。相反,批量作业通常会定期运行(例如,每天一次)。批处理作业的主要性能衡量标准通常是吞吐量(通过特定大小的输入数据集所需的时间)。我们将在本章中讨论批处理。
|
||||
|
||||
***流处理系统(近实时系统)***
|
||||
|
||||
流处理是在线和离线/批处理之间的一个地方(所以有时候被称为近实时或近线处理)。像批处理系统一样,流处理器消耗输入并产生输出(而不是响应请求)。但是,流式作业在事件发生后不久就会对事件进行操作,而批处理作业则使用固定的一组输入数据进行操作。这种差异使流处理系统比等效的批处理系统具有更低的延迟。由于流处理基于批处理,我们将在第11章讨论它。
|
||||
流处理是在线和离线/批处理之间的一个地方(所以有时候被称为近实时或近线处理)。像批处理系统一样,流处理器消耗输入并产生输出(而不是响应请求)。但是,流式作业在事件发生后不久就会对事件进行操作,而批处理作业则使用固定的一组输入数据进行操作。这种差异使流处理系统比等效的批处理系统具有更低的延迟。由于流处理基于批处理,我们将在[第11章](ch11.md)讨论它。
|
||||
|
||||
正如我们将在本章中看到的那样,批量处理是构建可靠,可扩展和可维护应用程序的重要组成部分。例如,2004年发布的批处理算法Map-Reduce(可能过度热情地)被称为“使得Google具有如此大规模可扩展性的算法”[2]。随后在各种开源数据系统中实施,包括Hadoop,CouchDB和MongoDB。
|
||||
正如我们将在本章中看到的那样,批量处理是构建可靠,可扩展和可维护应用程序的重要组成部分。例如,2004年发布的批处理算法Map-Reduce(可能过热地)被称为“使得Google具有如此大规模可扩展性的算法”【2】。随后在各种开源数据系统中实现,包括Hadoop,CouchDB和MongoDB。
|
||||
|
||||
与多年前为数据仓库开发的并行处理系统[3,4]相比,MapReduce是一个相当低级别的编程模型,但它在处理规模方面迈出了重要的一步。在商品硬件上。虽然MapReduce的重要性正在下降[5],但它仍然值得理解,因为它提供了批处理为什么以及如何有用的清晰画面。
|
||||
|
||||
实际上,批处理是一种非常古老的计算形式。早在可编程数字计算机诞生之前,打孔卡制表机(例如1890年美国人口普查[6]中使用的霍尔里斯机)实现了半机械化的批处理形式,以计算来自大量输入的汇总统计量。 Map-Reduce与1940年代和1950年代广泛用于商业数据处理的机电IBM卡片分类机器有着惊人的相似之处[7]。像往常一样,历史有重演的趋势。
|
||||
|
||||
在本章中,我们将看看MapReduce和其他一些批处理算法和框架,并探讨它们在现代数据系统中的使用方式。但首先,要开始,我们将看看使用标准Unix工具的数据处理。即使你已经熟悉了它们,Unix的哲学提醒也是值得的,因为从Unix的想法和经验教训转移到大规模,异构的分布式数据系统。
|
||||
与多年前为数据仓库开发的并行处理系统【3,4】相比,MapReduce是一个相当低级别的编程模型,但它对于在商用硬件上的处理规模上迈出了重要的一步。。虽然MapReduce的重要性正在下降【5】,但它仍然值得理解,因为它提供了一副批处理为什么,以及如何有用的清晰画面。
|
||||
|
||||
实际上,批处理是一种非常古老的计算形式。早在可编程数字计算机诞生之前,打孔卡制表机(例如1890年美国人口普查【6】中使用的霍尔里斯机)实现了半机械化的批处理形式,以计算来自大量输入的汇总统计量。 Map-Reduce与1940年代和1950年代广泛用于商业数据处理的机电IBM卡片分类机器有着惊人的相似之处【7】。像往常一样,历史总是在不断重复自己。
|
||||
|
||||
在本章中,我们将看看MapReduce和其他一些批处理算法和框架,并探讨它们在现代数据系统中的使用方式。但首先,我们将从如何使用标准Unix工具进行数据处理开始。即使你已经熟悉了它们,也值得重温一下Unix哲学,因为从Unix的想法和经验教训能转移到大规模,异构的分布式数据系统。
|
||||
|
||||
|
||||
|
||||
## 使用Unix工具的批处理
|
||||
|
||||
我们从一个简单的例子开始。假设您有一台Web服务器,每次提供请求时都会在日志文件中附加一行。例如,使用nginx默认访问日志格式,日志的一行可能如下所示:
|
||||
我们从一个简单的例子开始。假设你有一台Web服务器,每次提供请求时都会在日志文件中附加一行。例如,使用nginx默认访问日志格式,日志的一行可能如下所示:
|
||||
|
||||
```
|
||||
```bash
|
||||
216.58.210.78 - - [27/Feb/2015:17:55:11 +0000] "GET /css/typography.css HTTP/1.1"
|
||||
200 3377 "http://martin.kleppmann.com/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5)
|
||||
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 Safari/537.36"
|
||||
```
|
||||
|
||||
(实际上这只是一行,为了便于阅读,它只是分成多行)。这一行中有很多信息。为了解释它,你需要看看日志格式的定义,如下所示::
|
||||
(实际上这只是一行,为了可读性拆分为多行)。这一行中有很多信息。为了解释它,你需要看一看日志格式的定义,如下所示::
|
||||
|
||||
```
|
||||
```bash
|
||||
$remote_addr - $remote_user [$time_local] "$request"
|
||||
$status $body_bytes_sent "$http_referer" "$http_user_agent"
|
||||
```
|
||||
|
||||
因此,日志的这一行表明,在2015年2月27日17:55:11 UTC,服务器从客户端IP地址216.58.210.78接收到文件`/css/typography.css`的请求。用户没有被认证,所以`$remote_user`被设置为连字符(`-` )。响应状态是200(即请求成功),响应的大小是3377字节。网页浏览器是Chrome 40,并且它加载了该文件,因为它是在URL `http://martin.kleppmann.com/`的页面中引用的。
|
||||
|
||||
|
||||
因此,日志的这一行表明,在`2015-02-27 17:55:11 UTC`,服务器从客户端IP地址`216.58.210.78`接收到文件`/css/typography.css`的请求。用户没有被认证,所以`$remote_user`被设置为连字符(`-` )。响应状态是200(即请求成功),响应的大小是3377字节。网页浏览器是Chrome 40,并且它加载了该文件,因为它是在URL `http://martin.kleppmann.com/`的页面中引用的。
|
||||
|
||||
|
||||
|
||||
### 分析简单日志
|
||||
|
||||
各种工具可以把这些日志文件,并产生漂亮的报告有关您的网站流量,但为了锻炼,让我们建立自己的,使用基本的Unix工具。 例如,假设你想在你的网站上找到五个最受欢迎的网页。 你可以在Unix shell中这样做:[^i]
|
||||
各种工具可以把这些日志文件,并产生漂亮的报告有关你的网站流量,但出于练习的目的,让我们使用基本的Unix工具自己写一个。 例如,假设你想在你的网站上找到五个最受欢迎的网页。 可以在Unix shell中这样做:[^i]
|
||||
|
||||
[^i]: 有些人喜欢抬杠,认为`cat`这里并没有必要,因为输入文件可以直接作为awk的参数。 但这种写法让流水线更显眼。
|
||||
|
||||
@ -80,11 +77,11 @@ cat /var/log/nginx/access.log | #1
|
||||
1. 读取日志文件
|
||||
2. 将每一行按空格分割成不同的字段,每行只输出第七个这样的字段,恰好是请求的URL。在我们的示例行中,这个请求URL是`/css/typography.css`。
|
||||
3. 按字母顺序排列请求的URL列表。如果某个URL被请求过n次,那么排序后,该文件将包含连续n次重复的URL。
|
||||
4. uniq命令通过检查两条相邻的行是否相同来过滤掉其输入中的重复行。 -c选项告诉它也输出一个计数器:对于每个不同的URL,它会报告输入中出现该URL的次数。
|
||||
5. 第二种排序按每行起始处的数字(-n)排序,这是请求URL的次数。然后以反向(-r)顺序返回结果,即首先以最大的数字返回结果。
|
||||
6. 最后,头只输出输入的前五行(-n 5),并丢弃其余的。该系列命令的输出如下所示:
|
||||
4. uniq命令通过检查两条相邻的行是否相同来过滤掉其输入中的重复行。 `-c`选项告诉它也输出一个计数器:对于每个不同的URL,它会报告输入中出现该URL的次数。
|
||||
5. 第二种排序按每行起始处的数字(`-n`)排序,这是请求URL的次数。然后以反向(`-r`)顺序返回结果,即首先以最大的数字返回结果。
|
||||
6. 最后,头只输出输入的前五行(`-n 5`),并丢弃其余的。该系列命令的输出如下所示:
|
||||
|
||||
```
|
||||
```bash
|
||||
4189 /favicon.ico
|
||||
3631 /2013/05/24/improving-security-of-ssh-private-keys.html
|
||||
2124 /2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html
|
||||
@ -92,19 +89,19 @@ cat /var/log/nginx/access.log | #1
|
||||
915 /css/typography.css
|
||||
```
|
||||
|
||||
尽管如果你不熟悉Unix工具,上面的命令行可能看起来有点模糊,但是它非常强大。它将在几秒钟内处理千兆字节的日志文件,您可以轻松修改分析以适应您的需求。例如,如果要从报告中省略CSS文件,请将awk参数更改为`'$7 !~ /\.css$/ {print $7}'`等等。
|
||||
尽管如果你不熟悉Unix工具,上面的命令行可能看起来有点模糊,但是它非常强大。它将在几秒钟内处理千兆字节的日志文件,你可以轻松修改分析以适应你的需求。例如,如果要从报告中省略CSS文件,请将`awk`参数更改为`'$7 !~ /\.css$/ {print $7}'`等等。
|
||||
|
||||
本书中没有空间来详细探索Unix工具,但是非常值得学习。令人惊讶的是,使用awk,sed,grep,sort,uniq和xargs的组合,可以在几分钟内完成许多数据分析,并且它们的表现令人惊讶地很好[8]。
|
||||
本书中没有空间来详细探索Unix工具,但是非常值得学习。令人惊讶的是,使用awk,sed,grep,sort,uniq和xargs的组合,可以在几分钟内完成许多数据分析,并且它们的表现令人惊讶地很好【8】。
|
||||
|
||||
#### 命令链与自定义程序
|
||||
|
||||
而不是Unix命令链,你可以写一个简单的程序来做同样的事情。例如,在Ruby中,它可能看起来像这样:
|
||||
除了Unix命令链,你还可以写一个简单的程序来做同样的事情。例如在Ruby中,它可能看起来像这样:
|
||||
|
||||
```ruby
|
||||
counts = Hash.new(0) # 1
|
||||
File.open('/var/log/nginx/access.log') do |file|
|
||||
file.each do |line|
|
||||
url = line.split[6] # 2
|
||||
url = line.split【6】 # 2
|
||||
counts[url] += 1 # 3
|
||||
end
|
||||
end
|
||||
@ -113,72 +110,76 @@ top5 = counts.map{|url, count| [count, url] }.sort.reverse[0...5] # 4
|
||||
top5.each{|count, url| puts "#{count} #{url}" } # 5
|
||||
```
|
||||
|
||||
1. `counts`是一个哈希表,保持一个计数器的次数,我们已经看到每个网址。计数器默认为零。
|
||||
2. 从日志的每一行,我们都把URL作为第七个空格分隔的字段(这里的数组索引是6,因为Ruby的数组是零索引的)。
|
||||
1. `counts`是一个哈希表,保存了我们浏览每个URL次数的计数器,计数器默认为零。
|
||||
2. 对日志的每一行,从第七个空格分隔的字段提取URL(这里的数组索引是6,因为Ruby的数组索引从零开始)。
|
||||
3. 增加日志当前行中URL的计数器。
|
||||
4. 按计数器值(降序)对散列表内容进行排序,并取前五位。
|
||||
5. 打印出前五个条目。
|
||||
|
||||
这个程序并不像Unix管道那样简洁,但是它的可读性很强,你喜欢的两个中的哪一个是味道的一部分。但是,除了两者之间的表面差异之外,执行流程也有很大差异,如果您在大文件上运行此分析,则会变得明显。
|
||||
这个程序并不像Unix管道那样简洁,但是它的可读性很强,你喜欢哪一个属于口味的问题。但两者除了表面上的差异之外,执行流程也有很大差异,如果你在大文件上运行此分析,则会变得明显。
|
||||
|
||||
#### 排序与内存中的聚合
|
||||
|
||||
Ruby脚本保存一个URL的内存哈希表,其中每个URL映射到它已经被看到的次数。 Unix流水线的例子没有这样一个哈希表,而是依赖于对一个URL列表进行排序,在这个URL列表中,同一个URL的多个发生只是简单的重复。
|
||||
|
||||
哪种方法更好?这取决于你有多少个不同的网址。对于大多数中小型网站,您可能可以适应所有不同的URL,并且可以为每个网址(例如1GB内存)提供一个计数器。在此示例中,作业的工作集(作业需要随机访问的内存量)仅取决于不同URL的数量:如果单个URL有一百万个日志条目,则散列中所需的空间表仍然只有一个URL加上计数器的大小。如果这个工作集足够小,那么内存散列表工作正常,甚至在笔记本电脑上也是如此。
|
||||
哪种方法更好?这取决于你有多少个不同的网址。对于大多数中小型网站,你可能可以适应所有不同的URL,并且可以为每个网址(例如1GB内存)提供一个计数器。在此示例中,作业的工作集(作业需要随机访问的内存量)仅取决于不同URL的数量:如果单个URL有一百万个日志条目,则散列中所需的空间表仍然只有一个URL加上计数器的大小。如果这个工作集足够小,那么内存散列表工作正常,甚至在笔记本电脑上也是如此。
|
||||
|
||||
另一方面,如果作业的工作集大于可用内存,则排序方法的优点是可以高效地使用磁盘。这与我们在第74页的“SSTables和LSM-Trees”中讨论过的原理是一样的:数据块可以在内存中排序并作为段文件写入磁盘,然后多个排序的段可以合并为一个更大的排序文件。 Mergesort具有在磁盘上运行良好的顺序访问模式。 (请记住,在顺序I / O中进行优化是第3章中反复出现的主题。这里再次出现相同的模式。)
|
||||
另一方面,如果作业的工作集大于可用内存,则排序方法的优点是可以高效地使用磁盘。这与我们在第74页的“[SSTables和LSM树](ch3.md#SSTables和LSM树)”中讨论过的原理是一样的:数据块可以在内存中排序并作为段文件写入磁盘,然后多个排序的段可以合并为一个更大的排序文件。 归并排序具有在磁盘上运行良好的顺序访问模式。 (请记住,优化为顺序I/O是[第3章](ch3.md)中反复出现的主题。这里再次出现相同的模式。)
|
||||
|
||||
GNU Coreutils(Linux)中的排序实用程序通过溢出到磁盘自动处理大于内存的数据集,并自动并行排序跨多个CPU核心[9]。这意味着我们之前看到的简单的Unix命令链很容易扩展到大数据集,而不会耗尽内存。瓶颈可能是从磁盘读取输入文件的速度。
|
||||
GNU Coreutils(Linux)中的排序实用程序通过溢出到磁盘自动处理大于内存的数据集,并自动并行排序跨多个CPU核心【9】。这意味着我们之前看到的简单的Unix命令链很容易扩展到大数据集,而不会耗尽内存。瓶颈可能是从磁盘读取输入文件的速度。
|
||||
|
||||
|
||||
|
||||
### Unix哲学
|
||||
|
||||
我们可以非常容易地使用前一个例子中的一系列命令来分析日志文件,这并非巧合:事实上,这实际上是Unix的关键设计思想之一,而且它今天依然令人惊讶。让我们更深入地研究一下,这样我们可以从Unix中借鉴一些想法[10]。
|
||||
我们可以非常容易地使用前一个例子中的一系列命令来分析日志文件,这并非巧合:事实上,这实际上是Unix的关键设计思想之一,且它今天仍然令人惊讶地关联。让我们更深入地研究一下,以便从Unix中借鉴一些想法【10】。
|
||||
|
||||
Unix管道的发明者道格·麦克罗伊(Doug McIlroy)在1964年首先描述了这种情况[11]:“当需要以另一种方式处理数据时,我们应该有一些连接程序的方法,比如[a] 。这也是I / O的方式。“管道类比困难了,连接程序和管道的想法成为了现在被称为Unix哲学的一部分 - 一套在开发者中流行的设计原则。 Unix的用户。哲学在1978年描述如下[12,13]:
|
||||
Unix管道的发明者道格·麦克罗伊(Doug McIlroy)在1964年首先描述了这种情况【11】:“当需要以另一种方式处理数据时,我们应该有一些连接程序的方法,比如[a] 。这也是I/O的方式。“管道类比困难了,连接程序和管道的想法成为了现在被称为**Unix哲学**的一部分 —— 一套在开发者中流行的设计原则。 Unix哲学在1978年描述如下【12,13】:
|
||||
|
||||
1. 让每个程序都做好一件事。要做好新的工作,重新建立一个新的“特征”,而不是使旧的计划复杂化。
|
||||
2. 期待每个程序的输出成为另一个程序的输入。不要混淆输出与无关的信息。避免使用严格的柱状或二进制输入格式。不要坚持交互式输入。
|
||||
1. 让每个程序都做好一件事。要做一件新的工作,写一个新程序,而不是通过添加“功能”让老程序复杂化。
|
||||
2. 期待每个程序的输出成为另一个程序的输入。不要将无关信息混入输出。避免使用严格的列数据或二进制输入格式。不要坚持交互式输入。
|
||||
3. 设计和构建软件,甚至是操作系统,要尽早尝试,最好在几周内完成。不要犹豫,扔掉笨拙的部分,重建它们。
|
||||
4. 使用工具优先于不熟练的帮助来减轻编程任务,即使您必须绕道建立工具,并期望在完成使用后将其中一些工具扔掉。
|
||||
4. 优先使用工具,而不是不熟练的帮助来减轻编程任务,即使必须曲线救国编写工具,并期望在用完后扔掉大部分。
|
||||
|
||||
这种方法 - 自动化,快速原型设计,迭代式迭代,对实验友好,将大型项目分解成可管理的块 - 听起来非常像今天的敏捷和DevOps运动。奇怪的是,四十年来变化不大。
|
||||
这种方法 —— 自动化,快速原型设计,迭代式迭代,实验友好,将大型项目分解成可管理的块 —— 听起来非常像今天的敏捷开发和DevOps运动。奇怪的是,四十年来变化不大。
|
||||
|
||||
排序工具是一个很好的例子。它可以说是一个比大多数编程语言在其标准库(不会溢出到磁盘并且不使用多线程,即使是有益的)中更好的排序实现。然而,这种分类几乎没有用处。它只能与其他Unix工具(如uniq)结合使用。
|
||||
排序工具是一个很好的例子。它可以说是一个比大多数编程语言在其标准库(不会溢出到磁盘并且不使用多线程,即使是有益的)中更好的排序实现。然而,单独使用`sort` 几乎没什么用。它只能与其他Unix工具(如`uniq`)结合使用。
|
||||
|
||||
像bash这样的Unix shell可以让我们轻松地将这些小程序组合成令人惊讶的强大数据处理作业。尽管这些程序中有很多是由不同人群编写的,但它们可以灵活地结合在一起。 Unix如何实现这种可组合性?
|
||||
|
||||
#### 统一的接口
|
||||
|
||||
如果您希望一个程序的输出成为另一个程序的输入,那意味着这些程序必须使用相同的数据格式 - 换句话说,一个兼容的接口。如果您希望能够将任何程序的输出连接到任何程序的输入,那意味着所有程序必须使用相同的输入/输出接口。
|
||||
如果你希望一个程序的输出成为另一个程序的输入,那意味着这些程序必须使用相同的数据格式 —— 换句话说,一个兼容的接口。如果你希望能够将任何程序的输出连接到任何程序的输入,那意味着所有程序必须使用相同的I/O接口。
|
||||
|
||||
在Unix中,该接口是一个文件(更准确地说,是一个文件描述符)。一个文件只是一个有序的字节序列。因为这是一个非常简单的接口,所以可以使用相同的接口来表示许多不同的东西:文件系统上的实际文件,到另一个进程(Unix套接字,stdin,stdout)的通信通道,设备驱动程序(比如`/dev/audio`或`/dev/lp0`),表示TCP连接的套接字等等。理所当然的事很容易,但实际上这些非常不同的事物可以共享一个统一的界面,所以它们可以很容易地连接在一起[^ii]。
|
||||
在Unix中,这种接口是一个**文件(file)**(更准确地说,是一个**文件描述符(file descriptor)**)。一个文件只是一串有序的字节序列。因为这是一个非常简单的接口,所以可以使用相同的接口来表示许多不同的东西:文件系统上的真实文件,到另一个进程(Unix套接字,stdin,stdout)的通信通道,设备驱动程序(比如`/dev/audio`或`/dev/lp0`),表示TCP连接的套接字等等。很容易将这些视为理所当然的事情,但实际上这是非常出色的设计:这些非常不同的事物可以共享一个统一的接口,所以它们可以很容易地连接在一起[^ii]。
|
||||
|
||||
[^ii]: 统一接口的另一个例子是URL和HTTP,这是Web的基础。 一个URL标识一个网站上的一个特定的东西(资源),你可以链接到任何其他网站的任何网址。 具有网络浏览器的用户因此可以通过跟随链接在网站之间无缝跳转,即使服务器可能由完全不相关的组织操作。 这个原则今天似乎很明显,但它是使网络取得今天成功的关键。 之前的系统并不是那么统一:例如,在公告板系统(BBS)时代,每个系统都有自己的电话号码和波特率配置。 从一个BBS到另一个BBS的引用必须以电话号码和调制解调器设置的形式; 用户将不得不挂断,拨打其他BBS,然后手动找到他们正在寻找的信息。 这是不可能的直接链接到另一个BBS内的一些内容。
|
||||
|
||||
按照惯例,许多(但不是全部)Unix程序将这个字节序列视为ASCII文本。我们的日志分析示例使用了这个事实:awk,sort,uniq和head都将它们的输入文件视为由`\n`(换行符,ASCII 0x0A)字符分隔的记录列表。 `\n`的选择是任意的 - 可以说,ASCII记录分隔符`0x1E`本来就是一个更好的选择,因为它是为了这个目的而设计的[14],但是无论如何,所有这些程序都使用相同的记录分隔符允许它们互操作。
|
||||
按照惯例,许多(但不是全部)Unix程序将这个字节序列视为ASCII文本。我们的日志分析示例使用了这个事实:awk,sort,uniq和head都将它们的输入文件视为由`\n`(换行符,ASCII 0x0A)字符分隔的记录列表。 `\n`的选择是任意的 —— 可以说,ASCII记录分隔符`0x1E`本来就是一个更好的选择,因为它是为了这个目的而设计的【14】,但是无论如何,所有这些程序都使用相同的记录分隔符允许它们互操作。
|
||||
|
||||
每个记录(即一行输入)的解析更加模糊。 Unix工具通常通过空白或制表符将行分割成字段,但也使用CSV(逗号分隔),管道分隔和其他编码。即使像xargs这样一个相当简单的工具也有六个命令行选项,用于指定如何解析输入。
|
||||
每个记录(即一行输入)的解析更加模糊。 Unix工具通常通过空白或制表符将行分割成字段,但也使用CSV(逗号分隔),管道分隔和其他编码。即使像`xargs`这样一个相当简单的工具也有六个命令行选项,用于指定如何解析输入。
|
||||
|
||||
ASCII文本的统一接口主要工作,但它不是很漂亮:我们的日志分析示例使用`{print $ 7}`来提取网址,这是不是很可读。在理想的世界中,这可能是`{print $ request_url}`或类似的东西。我们稍后会回到这个想法。
|
||||
ASCII文本的统一接口主要工作,但它不是很漂亮:我们的日志分析示例使用`{print $7}`来提取网址,这样可读性不是很好。在理想的世界中,这可能是`{print $request_url}`或类似的东西。我们稍后会回到这个想法。
|
||||
|
||||
尽管几十年后还不够完美,但统一的Unix界面仍然非常显着。与Unix工具一样,软件的交互操作和编写并不是很多,您不能通过自定义分析工具轻松地将电子邮件帐户的内容和在线购物历史记录传送到电子表格中,并将结果发布到社交网络或维基。今天,像Unix工具一样流畅地运行程序是一个例外,而不是规范。
|
||||
尽管几十年后还不够完美,但统一的Unix接口仍然是非常出色的设计。与Unix工具一样,软件的交互操作和编写并不是很多,你不能通过自定义分析工具轻松地将电子邮件帐户的内容和在线购物历史记录传送到电子表格中,并将结果发布到社交网络或维基。今天,像Unix工具一样流畅地运行程序是一个例外,而不是规范。
|
||||
|
||||
即使是具有相同数据模型的数据库,也往往不容易将数据从一个数据模型中移出。这种缺乏整合导致数据的巴尔干化。
|
||||
即使是具有**相同数据模型(same data model)**的数据库,将数据从一种导到另一种也并不容易。缺乏整合导致了数据的巴尔干化[^译注i]。
|
||||
|
||||
#### 逻辑和布线的分离
|
||||
[^译注i]: ****巴尔干化(Balkanization)**是一个常带有贬义的地缘政治学术语,其定义为:一个国家或政区分裂成多个互相敌对的国家或政区的过程。
|
||||
|
||||
Unix工具的另一个特点是使用标准输入(stdin)和标准输出(stdout)。如果你运行一个程序,而不指定任何其他的东西,标准输入来自键盘和标准输出到屏幕上。但是,您也可以从文件输入和/或将输出重定向到文件。管道允许您将一个进程的标准输出附加到另一个进程的标准输入(具有小内存缓冲区,而不需要将整个中间数据流写入磁盘)。
|
||||
|
||||
程序仍然可以直接读取和写入文件,但如果程序不担心特定的文件路径,只使用标准输入和标准输出,则Unix方法效果最好。这允许shell用户以任何他们想要的方式连接输入和输出;该程序不知道或不关心输入来自哪里以及输出到哪里。 (人们可以说这是一种松耦合,后期绑定[15]或控制反转[16])。将输入/输出接线与程序逻辑分开,可以将小工具组合成更大的系统。
|
||||
|
||||
您甚至可以编写自己的程序,并将它们与操作系统提供的工具组合在一起。你的程序只需要从标准输入读取输入,并将输出写入标准输出,并且可以参与数据处理流水线。在日志分析示例中,您可以编写一个工具将用户代理字符串转换为更灵敏的浏览器标识符,或者将IP地址转换为国家代码的工具,并将其插入管道。排序程序并不关心它是否与操作系统的另一部分或者你写的程序通信。
|
||||
#### 逻辑和布线相分离
|
||||
|
||||
但是,使用stdin和stdout可以做什么是有限的。需要多个输入或输出的程序是可能的但棘手的。如果程序直接打开文件进行读取和写入,或者将另一个程序作为子进程启动,或者打开网络连接,则无法将程序的输出传输到网络连接中【17,18】[^iii] 。 I / O由程序本身连接。它仍然可以配置(例如通过命令行选项),但是减少了在Shell中连接输入和输出的灵活性。
|
||||
Unix工具的另一个特点是使用标准输入(stdin)和标准输出(stdout)。如果你运行一个程序,而不指定任何其他的东西,标准输入来自键盘,标准输出指向屏幕。但是,你也可以从文件输入和/或将输出重定向到文件。管道允许你将一个进程的标准输出附加到另一个进程的标准输入(具有小内存缓冲区,而不需要将整个中间数据流写入磁盘)。
|
||||
|
||||
[^iii]: 除了使用一个单独的工具,如netcat或curl。 Unix开始试图将所有东西都表示为文件,但是BSD套接字API偏离了这个惯例[17]。研究操作系统Plan 9和Inferno在使用文件方面更加一致:它们将TCP连接表示为/ net / tcp中的文件[18]。
|
||||
程序仍然可以直接读取和写入文件,但如果程序不担心特定的文件路径,只使用标准输入和标准输出,则Unix方法效果最好。这允许shell用户以任何他们想要的方式连接输入和输出;该程序不知道或不关心输入来自哪里以及输出到哪里。 (人们可以说这是一种**松耦合(loose coupling)**,**晚期绑定(late binding)**【15】或**控制反转(inversion of control)**【16】)。将输入/输出布线与程序逻辑分开,可以将小工具组合成更大的系统。
|
||||
|
||||
你甚至可以编写自己的程序,并将它们与操作系统提供的工具组合在一起。你的程序只需要从标准输入读取输入,并将输出写入标准输出,并且可以参与数据处理流水线。在日志分析示例中,你可以编写一个工具将用户代理字符串转换为更灵敏的浏览器标识符,或者将IP地址转换为国家代码的工具,并将其插入管道。排序程序并不关心它是否与操作系统的另一部分或者你写的程序通信。
|
||||
|
||||
但是,使用stdin和stdout可以做什么是有限的。需要多个输入或输出的程序是可能的但棘手的。如果程序直接打开文件进行读取和写入,或者将另一个程序作为子进程启动,或者打开网络连接,则无法将程序的输出传输到网络连接中【17,18】[^iii] 。 I/O由程序本身连接。它仍然可以配置(例如通过命令行选项),但是减少了在Shell中连接输入和输出的灵活性。
|
||||
|
||||
[^iii]: 除了使用一个单独的工具,如`netcat`或`curl`。 Unix开始试图将所有东西都表示为文件,但是BSD套接字API偏离了这个惯例【17】。研究用操作系统Plan 9和Inferno在使用文件方面更加一致:它们将TCP连接表示为`/net/tcp`中的文件【18】。
|
||||
|
||||
|
||||
|
||||
@ -186,14 +187,15 @@ Unix工具的另一个特点是使用标准输入(stdin)和标准输出(st
|
||||
|
||||
使Unix工具如此成功的部分原因是它们使得查看正在发生的事情变得非常容易:
|
||||
|
||||
Unix命令的输入文件通常被视为不可变的。这意味着您可以随意运行命令,尝试各种命令行选项,而不会损坏输入文件。
|
||||
* Unix命令的输入文件通常被视为不可变的。这意味着你可以随意运行命令,尝试各种命令行选项,而不会损坏输入文件。
|
||||
|
||||
* 您可以在任何时候结束管道,将输出管道输送到较少的位置,然后查看它是否具有预期的形式。这种检查能力对调试非常有用。
|
||||
* 您可以将一个流水线阶段的输出写入文件,并将该文件用作下一阶段的输入。这使您可以重新启动后面的阶段,而无需重新运行整个管道。
|
||||
|
||||
* 你可以在任何时候结束管道,将管道输出到`less`,然后查看它是否具有预期的形式。这种检查能力对调试非常有用。
|
||||
* 你可以将一个流水线阶段的输出写入文件,并将该文件用作下一阶段的输入。这使你可以重新启动后面的阶段,而无需重新运行整个管道。
|
||||
|
||||
因此,与关系数据库的查询优化器相比,即使Unix工具非常简单,工具简单,但仍然非常有用,特别是对于实验而言。
|
||||
|
||||
然而,Unix工具的最大局限在于它们只能在一台机器上运行 - 而Hadoop这样的工具就是在这里工作的。
|
||||
然而,Unix工具的最大局限在于它们只能在一台机器上运行 —— 而Hadoop这样的工具即为此而生。
|
||||
|
||||
|
||||
|
||||
@ -207,90 +209,91 @@ MapReduce有点像Unix工具,但分布在数千台机器上。像Unix工具一
|
||||
|
||||
和大多数Unix工具一样,运行MapReduce作业通常不会修改输入,除了生成输出外没有任何副作用。输出文件以连续的方式写入一次(一旦写入文件,不会修改任何现有的文件部分)。
|
||||
|
||||
虽然Unix工具使用stdin和stdout作为输入和输出,但MapReduce作业在分布式文件系统上读写文件。在Hadoop的Map-Reduce实现中,该文件系统被称为HDFS(Hadoop分布式文件系统),一个开源的重新实现Google文件系统(GFS)[19]。
|
||||
虽然Unix工具使用stdin和stdout作为输入和输出,但MapReduce作业在分布式文件系统上读写文件。在Hadoop的Map-Reduce实现中,该文件系统被称为HDFS(Hadoop分布式文件系统),一个开源的重新实现Google文件系统(GFS)【19】。
|
||||
|
||||
除HDFS外,还有各种其他分布式文件系统,如GlusterFS和Quantcast File System(QFS)[20]。诸如Amazon S3,Azure Blob存储和OpenStack Swift [21]等对象存储服务在很多方面都是相似的[^iv]。在本章中,我们将主要使用HDFS作为示例,但是这些原则适用于任何分布式文件系统。
|
||||
除HDFS外,还有各种其他分布式文件系统,如GlusterFS和Quantcast File System(QFS)【20】。诸如Amazon S3,Azure Blob存储和OpenStack Swift 【21】等对象存储服务在很多方面都是相似的[^iv]。在本章中,我们将主要使用HDFS作为示例,但是这些原则适用于任何分布式文件系统。
|
||||
|
||||
[^iv]: 一个不同之处在于,对于HDFS,可以将计算任务安排在存储特定文件副本的计算机上运行,而对象存储通常将存储和计算分开。如果网络带宽是一个瓶颈,从本地磁盘读取有性能优势。但是请注意,如果使用删除编码,局部优势将会丢失,因为来自多台机器的数据必须进行合并以重建原始文件[20]。
|
||||
[^iv]: 一个不同之处在于,对于HDFS,可以将计算任务安排在存储特定文件副本的计算机上运行,而对象存储通常将存储和计算分开。如果网络带宽是一个瓶颈,从本地磁盘读取有性能优势。但是请注意,如果使用删除编码,局部优势将会丢失,因为来自多台机器的数据必须进行合并以重建原始文件【20】。
|
||||
|
||||
与网络连接存储(NAS)和存储区域网络(SAN)架构的共享磁盘方法相比,HDFS基于无共享原则(参见第二部分的介绍)。共享磁盘存储由集中式存储设备实现,通常使用定制硬件和专用网络基础设施(如光纤通道)。另一方面,无共享方法不需要特殊的硬件,只需要通过传统数据中心网络连接的计算机。
|
||||
与网络连接存储(NAS)和存储区域网络(SAN)架构的共享磁盘方法相比,HDFS基于无共享原则(参见[第二部分](part-ii.md)的介绍)。共享磁盘存储由集中式存储设备实现,通常使用定制硬件和专用网络基础设施(如光纤通道)。另一方面,无共享方法不需要特殊的硬件,只需要通过传统数据中心网络连接的计算机。
|
||||
|
||||
HDFS包含在每台机器上运行的守护进程,暴露一个允许其他节点访问存储在该机器上的文件的网络服务(假设数据中心中的每台通用计算机都附带有一些磁盘)。名为NameNode的中央服务器会跟踪哪个文件块存储在哪台机器上。因此,HDFS在概念上创建一个可以使用运行守护进程的所有机器的磁盘上的空间的大文件系统。
|
||||
|
||||
为了容忍机器和磁盘故障,文件块被复制到多台机器上。复制可能意味着多个机器上的相同数据的多个副本,如第5章中所述,或者像Reed-Solomon代码这样的擦除编码方案,它允许以比完全复制更低的存储开销恢复丢失的数据[20, 22。这些技术与RAID相似,可以在连接到同一台机器的多个磁盘上提供冗余;区别在于在分布式文件系统中,文件访问和复制是在传统的数据中心网络上完成的,没有特殊的硬件。
|
||||
为了容忍机器和磁盘故障,文件块被复制到多台机器上。复制可能意味着多个机器上的相同数据的多个副本,如[第5章](ch5.md)中所述,或者像Reed-Solomon代码这样的擦除编码方案,它允许以比完全复制更低的存储开销恢复丢失的数据【20,22】。这些技术与RAID相似,可以在连接到同一台机器的多个磁盘上提供冗余;区别在于在分布式文件系统中,文件访问和复制是在传统的数据中心网络上完成的,没有特殊的硬件。
|
||||
|
||||
HDFS已经很好地扩展了:在撰写本文时,最大的HDFS部署运行在成千上万台机器上,总存储容量达数百peta-bytes [23]。如此大的规模已经变得可行,因为使用商品硬件和开源软件的HDFS上的数据存储和访问成本远低于专用存储设备上的同等容量[24]。
|
||||
HDFS已经很好地扩展了:在撰写本文时,最大的HDFS部署运行在成千上万台机器上,总存储容量达数百PB【23】。如此大的规模已经变得可行,因为使用商品硬件和开源软件的HDFS上的数据存储和访问成本远低于专用存储设备上的同等容量【24】。
|
||||
|
||||
### MapReduce作业执行
|
||||
|
||||
MapReduce是一个编程框架,您可以使用它编写代码来处理HDFS等分布式文件系统中的大型数据集。理解它的最简单方法是参考第391页上的“简单日志分析”中的Web服务器日志分析示例。MapReduce中的数据处理模式与此示例非常相似:
|
||||
MapReduce是一个编程框架,你可以使用它编写代码来处理HDFS等分布式文件系统中的大型数据集。理解它的最简单方法是参考“[简单日志分析](#简单日志分析)”中的Web服务器日志分析示例。MapReduce中的数据处理模式与此示例非常相似:
|
||||
|
||||
1. 读取一组输入文件,并将其分解成记录。在Web服务器日志示例中,每条记录都是日志中的一行(即\ n是记录分隔符)。
|
||||
2. 调用Mapper函数从每个输入记录中提取一个键和值。在前面的例子中,mapper函数是`awk'{print $ 7}'`:它提取`URL($7)`作为关键字,并将值保留为空。
|
||||
1. 读取一组输入文件,并将其分解成记录。在Web服务器日志示例中,每条记录都是日志中的一行(即`\n`是记录分隔符)。
|
||||
2. 调用Mapper函数从每个输入记录中提取一个键和值。在前面的例子中,mapper函数是`awk'{print $7}'`:它提取`URL($7)`作为关键字,并将值保留为空。
|
||||
3. 按键排序所有的键值对。在日志示例中,这由第一个排序命令完成。
|
||||
4. 调用reducer函数遍历排序后的键值对。如果同一个键出现多次,排序使它们在列表中相邻,所以很容易组合这些值而不必在内存中保留很多状态。在前面的例子中,reducer是由uniq -c命令实现的,该命令使用相同的密钥来统计相邻记录的数量。
|
||||
4. 调用reducer函数遍历排序后的键值对。如果同一个键出现多次,排序使它们在列表中相邻,所以很容易组合这些值而不必在内存中保留很多状态。在前面的例子中,reducer是由`uniq -c`命令实现的,该命令使用相同的键来统计相邻记录的数量。
|
||||
|
||||
这四个步骤可以由一个MapReduce作业执行。步骤2(地图)和4(减少)是您编写自定义数据处理代码的地方。步骤1(将文件分解成记录)由输入格式解析器处理。步骤3中的排序步骤隐含在MapReduce中 - 您不必编写它,因为映射器的输出始终在给予reducer之前进行排序。
|
||||
这四个步骤可以由一个MapReduce作业执行。步骤2(Map)和4(Reduce)是你编写自定义数据处理代码的地方。步骤1(将文件分解成记录)由输入格式解析器处理。步骤3中的排序步骤隐含在MapReduce中 —— 你不必编写它,因为Mapper的输出始终在送往reducer之前进行排序。
|
||||
|
||||
要创建MapReduce作业,您需要实现两个回调函数,mapper和reducer,其行为如下(另请参阅“MapReduce查询”(第46页)):
|
||||
要创建MapReduce作业,你需要实现两个回调函数,mapper和reducer,其行为如下(参阅“[MapReduce查询](ch2.md#MapReduce查询)”):
|
||||
|
||||
***Mapper***
|
||||
|
||||
每个输入记录都会调用一次映射器,其工作是从输入记录中提取键和值。对于每个输入,它可以生成任意数量的键值对(包括无)。它不会保留从一个输入记录到下一个记录的任何状态,因此每个记录都是独立处理的。
|
||||
每个输入记录都会调用一次Mapper,其工作是从输入记录中提取键和值。对于每个输入,它可以生成任意数量的键值对(包括None)。它不会保留从一个输入记录到下一个记录的任何状态,因此每个记录都是独立处理的。
|
||||
|
||||
***Reducer***
|
||||
MapReduce框架采用由映射器生成的键值对,收集属于同一个键的所有值,并使用迭代器调用reducer以调用该值集合。 Reducer可以产生输出记录(例如相同URL的出现次数)。
|
||||
MapReduce框架采用由Mapper生成的键值对,收集属于同一个键的所有值,并使用迭代器调用reducer以调用该值集合。 Reducer可以产生输出记录(例如相同URL的出现次数)。
|
||||
|
||||
在Web服务器日志示例中,我们在第5步中有第二个排序命令,它按请求数对URL进行排序。在MapReduce中,如果您需要第二个排序阶段,则可以通过编写第二个MapReduce作业并将第一个作业的输出用作第二个作业的输入来实现它。这样看来,映射器的作用是将数据放入一个适合排序的表单中,并且还原器的作用是处理已排序的数据。
|
||||
在Web服务器日志示例中,我们在第5步中有第二个排序命令,它按请求数对URL进行排序。在MapReduce中,如果你需要第二个排序阶段,则可以通过编写第二个MapReduce作业并将第一个作业的输出用作第二个作业的输入来实现它。这样看来,Mapper的作用是将数据放入一个适合排序的表单中,并且Reducer的作用是处理已排序的数据。
|
||||
|
||||
#### 分布式执行MapReduce
|
||||
|
||||
Unix命令管道的主要区别在于,MapReduce可以在多台机器上并行执行计算,而无需编写代码来明确处理并行性。映射器和简化器一次只能处理一条记录;他们不需要知道他们的输入来自哪里或者输出什么地方,所以框架可以处理机器之间移动数据的复杂性。
|
||||
Unix命令管道的主要区别在于,MapReduce可以在多台机器上并行执行计算,而无需编写代码来明确处理并行性。Mapper和Reducer一次只能处理一条记录;他们不需要知道他们的输入来自哪里或者输出什么地方,所以框架可以处理机器之间移动数据的复杂性。
|
||||
|
||||
在分布式计算中可以使用标准的Unix工具作为映射器和简化器[25],但更常见的是,它们被实现为传统编程语言的函数。在Hadoop MapReduce中,映射器和简化器都是实现特定接口的Java类。在MongoDB和CouchDB中,映射器和简化器都是JavaScript函数(请参阅第46页的“MapReduce查询”)。
|
||||
在分布式计算中可以使用标准的Unix工具作为Mapper和Reducer【25】,但更常见的是,它们被实现为传统编程语言的函数。在Hadoop MapReduce中,Mapper和Reducer都是实现特定接口的Java类。在MongoDB和CouchDB中,Mapper和Reducer都是JavaScript函数(参阅“[MapReduce查询](ch2.md#MapReduce查询)”)。
|
||||
|
||||
[图10-1]()显示了Hadoop MapReduce作业中的数据流。其并行化基于分区(参见第6章):作业的输入通常是HDFS中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理map任务(图10-1中的m 1,m 2和m 3标记)。
|
||||
[图10-1]()显示了Hadoop MapReduce作业中的数据流。其并行化基于分区(参见[第6章](ch6.md)):作业的输入通常是HDFS中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理map任务([图10-1](img/fig10-1.png)中的m1,m2和m3标记)。
|
||||
|
||||
每个输入文件的大小通常是数百兆字节。 MapReduce调度器(图中未显示)试图在其中一台存储输入文件副本的机器上运行每个映射器,只要该机器有足够的备用RAM和CPU资源来运行映射任务[26]。这个原则被称为将数据放在数据附近[27]:它节省了通过网络复制输入文件,减少网络负载和增加局部性。
|
||||
每个输入文件的大小通常是数百兆字节。 MapReduce调度器(图中未显示)试图在其中一台存储输入文件副本的机器上运行每个Mapper,只要该机器有足够的备用RAM和CPU资源来运行映射任务【26】。这个原则被称为将数据放在数据附近【27】:它节省了通过网络复制输入文件,减少网络负载和增加局部性。
|
||||
|
||||
![](img/fig10-1.png)
|
||||
|
||||
**图10-1 具有三个Mapper和三个Reducer的MapReduce任务**
|
||||
|
||||
在大多数情况下,应该在映射任务中运行的应用程序代码在分配运行它的任务的计算机上还不存在,所以MapReduce框架首先复制代码(例如Java程序中的JAR文件)到适当的机器。然后启动地图任务并开始读取输入文件,一次将一条记录传递给mapper回调。映射器的输出由键值对组成。
|
||||
在大多数情况下,应该在映射任务中运行的应用程序代码在分配运行它的任务的计算机上还不存在,所以MapReduce框架首先复制代码(例如Java程序中的JAR文件)到适当的机器。然后启动Map任务并开始读取输入文件,一次将一条记录传递给Mapper回调。Mapper的输出由键值对组成。
|
||||
|
||||
计算的减少方面也被分割。虽然Map任务的数量由输入文件块的数量决定,但Reducer的任务的数量是由作业作者配置的(它可以不同于地图任务的数量)。为了确保具有相同密钥的所有键值对在相同的缩减器处结束,框架使用密钥的散列值来确定哪个减少的任务应该接收到特定的键值对(参见“通过密钥散列分区”)第203页)。
|
||||
计算的减少方面也被分割。虽然Map任务的数量由输入文件块的数量决定,但Reducer的任务的数量是由作业作者配置的(它可以不同于Map任务的数量)。为了确保具有相同键的所有键值对在相同的Reducer处结束,框架使用键的散列值来确定哪个减少的任务应该接收到特定的键值对(参见“[按键散列分区](ch6.md#按键散列分区)”))。
|
||||
|
||||
键值对必须进行排序,但数据集可能太大,无法在单台机器上使用常规排序算法进行排序。相反,分类是分阶段进行的。首先,每个映射任务都基于密钥的散列,通过简化器分割其输出。这些分区中的每一个都被写入映射程序本地磁盘上的已排序文件,使用的技术与我们在第76页的“SSTables and LSM-Trees”中讨论的类似。
|
||||
键值对必须进行排序,但数据集可能太大,无法在单台机器上使用常规排序算法进行排序。相反,分类是分阶段进行的。首先,每个映射任务都基于键的散列,通过Reducer分割其输出。这些分区中的每一个都被写入映射程序本地磁盘上的已排序文件,使用的技术与我们在“[SSTables与LSM树](ch3.md#SSTables与LSM树)”中讨论的类似。
|
||||
|
||||
只要映射器完成读取输入文件并写入其排序后的输出文件,MapReduce调度器就会通知减速器他们可以从该映射器开始获取输出文件。减法器连接到每个映射器,并为其分区下载排序后的键值对的文件。通过简化,分类和将数据分区从映射器复制到简化器的过程被称为混洗[26](一个令人困惑的术语 - 不像洗牌一样,在MapReduce中没有随机性)。
|
||||
只要Mapper完成读取输入文件并写入其排序后的输出文件,MapReduce调度器就会通知减速器他们可以从该Mapper开始获取输出文件。减法器连接到每个Mapper,并为其分区下载排序后的键值对的文件。通过简化,分类和将数据分区从Mapper复制到Reducer的过程被称为**混洗(shuffle)**【26】(一个令人困惑的术语 —— 不像洗牌一样,在MapReduce中没有随机性)。
|
||||
|
||||
reduce任务从映射器获取文件并将它们合并在一起,并保存排序顺序。因此,如果不同的映射器使用相同的键生成记录,则它们将在合并的缩减器输入中相邻。
|
||||
reduce任务从Mapper获取文件并将它们合并在一起,并保存排序顺序。因此,如果不同的Mapper使用相同的键生成记录,则它们将在合并的Reducer输入中相邻。
|
||||
|
||||
使用一个键和一个迭代器调用reducer,迭代器使用相同的键(在某些情况下可能不是全部适合内存)逐步扫描所有记录。 Reducer可以使用任意逻辑来处理这些记录,并且可以生成任意数量的输出记录。这些输出记录写入到分布式文件系统上的文件(通常是运行reducer的机器的本地磁盘上的一个副本,其他机器上的副本)。
|
||||
MapReduce工作流程
|
||||
|
||||
单个MapReduce作业可以解决的问题范围有限。请参阅日志分析示例,一个MapReduce作业可以确定每个URL的页面浏览次数,但不是最常用的URL,因为这需要第二轮排序。
|
||||
#### MapReduce工作流
|
||||
|
||||
单个MapReduce作业可以解决的问题范围有限。参阅日志分析示例,一个MapReduce作业可以确定每个URL的页面浏览次数,但不是最常用的URL,因为这需要第二轮排序。
|
||||
|
||||
因此,将MapReduce作业链接到工作流中是非常常见的,例如,一个作业的输出成为下一个作业的输入。 Hadoop Map-Reduce框架对工作流程没有特别的支持,所以这个链接是通过目录名隐含完成的:第一个作业必须被配置为将其输出写入HDFS中的指定目录,第二个作业必须是配置为读取与其输入相同的目录名称。从MapReduce框架的角度来看,他们是两个独立的工作。
|
||||
|
||||
因此,被链接的MapReduce作业不如Unix命令的流水线(它直接将一个进程的输出作为输入传递给另一个进程,只使用一个小的内存缓冲区),更像是一系列命令,其中每个命令的输出写入临时文件,下一个命令从临时文件中读取。这种设计有利有弊,我们将在第419页“中间状态的物化”中讨论。
|
||||
因此,被链接的MapReduce作业不如Unix命令的流水线(它直接将一个进程的输出作为输入传递给另一个进程,只使用一个小的内存缓冲区),更像是一系列命令,其中每个命令的输出写入临时文件,下一个命令从临时文件中读取。这种设计有利有弊,我们将在“[物化中间状态](#物化中间状态)”中讨论。
|
||||
|
||||
当作业成功完成时,批处理作业的输出仅被视为有效(MapReduce丢弃失败作业的部分输出)。因此,工作流程中的一项工作只有在先前的工作 - 即生产其投入方向的工作 - 成功完成时才能开始。处理这些作业之间的依赖关系执行,为Hadoop开发了各种工作流调度器,包括Oozie,Azkaban,Luigi,Airflow和Pinball [28]。
|
||||
当作业成功完成时,批处理作业的输出仅被视为有效(MapReduce丢弃失败作业的部分输出)。因此,工作流程中的一项工作只有在先前的工作 —— 即生产其投入方向的工作 —— 成功完成时才能开始。处理这些作业之间的依赖关系执行,为Hadoop开发了各种工作流调度器,包括Oozie,Azkaban,Luigi,Airflow和Pinball 【28】。
|
||||
|
||||
这些调度程序还具有管理功能,在维护大量批处理作业时非常有用。在构建推荐系统[29]时,由50到100个MapReduce作业组成的工作流是常见的,而在大型组织中,许多不同的团队可能运行不同的作业来读取彼此的输出。工具支持对于管理这样复杂的数据流非常重要。
|
||||
这些调度程序还具有管理功能,在维护大量批处理作业时非常有用。在构建推荐系统【29】时,由50到100个MapReduce作业组成的工作流是常见的,而在大型组织中,许多不同的团队可能运行不同的作业来读取彼此的输出。工具支持对于管理这样复杂的数据流非常重要。
|
||||
|
||||
Hadoop的各种高级工具(如Pig [30],Hive [31],Cascading [32],Crunch [33]和FlumeJava [34])也设置了多个MapReduce阶段的工作流程 。
|
||||
Hadoop的各种高级工具(如Pig 【30】,Hive 【31】,Cascading 【32】,Crunch 【33】和FlumeJava 【34】)也设置了多个MapReduce阶段的工作流程 。
|
||||
|
||||
### Reduce端连接与分组
|
||||
|
||||
我们在第2章中讨论了数据模型和查询语言的联接,但是我们还没有深入探讨联接是如何实现的。现在是我们再次拿起那个线程的时候了。
|
||||
我们在[第2章](ch2.md)中讨论了数据模型和查询语言的联接,但是我们还没有深入探讨连接是如何实现的。现在是我们再次拿起那个线程的时候了。
|
||||
|
||||
在许多数据集中,通常一条记录与另一条记录有关联:关系模型中的外键,文档模型中的文档引用或图模型中的边。只要有一些代码需要访问该关联两边的记录(包含引用的记录和被引用的记录),连接就是必需的。正如第2章所讨论的,非规范化可以减少对连接的需求,但通常不会将其完全移除[^v]。
|
||||
在许多数据集中,通常一条记录与另一条记录有关联:关系模型中的外键,文档模型中的文档引用或图模型中的边。只要有一些代码需要访问该关联两边的记录(包含引用的记录和被引用的记录),连接就是必需的。正如[第2章](ch2.md)所讨论的,非规范化可以减少对连接的需求,但通常不会将其完全移除[^v]。
|
||||
|
||||
在数据库中,如果执行只涉及少量记录的查询,数据库通常会使用索引来快速定位感兴趣的记录(请参阅第3章)。如果查询涉及连接,则可能需要多个索引查找。然而,MapReduce没有索引的概念 - 至少不是通常意义上的。
|
||||
在数据库中,如果执行只涉及少量记录的查询,数据库通常会使用索引来快速定位感兴趣的记录(参阅[第3章](ch3.md))。如果查询涉及连接,则可能需要多个索引查找。然而,MapReduce没有索引的概念 —— 至少不是通常意义上的。
|
||||
|
||||
当MapReduce作业被赋予一组文件作为输入时,它读取所有这些文件的全部内容;一个数据库会调用这个操作一个全表扫描。如果您只想读取少量的记录,则与索引查找相比,全表扫描的成本非常高昂。但是,在分析查询中(请参阅第88页上的“事务处理或分析?”),通常需要计算大量记录的聚合。在这种情况下,扫描整个输入可能是相当合理的事情,特别是如果可以在多台机器上并行处理。
|
||||
当MapReduce作业被赋予一组文件作为输入时,它读取所有这些文件的全部内容;一个数据库会调用这个操作一个全表扫描。如果你只想读取少量的记录,则与索引查找相比,全表扫描的成本非常高昂。但是,在分析查询中(参阅“[事务处理或分析?](ch3.md#事务处理还是分析?)”),通常需要计算大量记录的聚合。在这种情况下,扫描整个输入可能是相当合理的事情,特别是如果可以在多台机器上并行处理。
|
||||
|
||||
[^v]: 我们在本书中讨论的连接通常是等值连接,即最常见的连接类型,其中记录与其他记录在特定字段(例如ID)中具有相同的值相关联。有些数据库支持更一般的连接类型,例如使用小于运算符而不是等号运算符,但是我们没有空间来覆盖它们。
|
||||
|
||||
@ -298,7 +301,7 @@ Hadoop的各种高级工具(如Pig [30],Hive [31],Cascading [32],Crunch
|
||||
|
||||
#### 示例:分析用户活动事件
|
||||
|
||||
图10-2给出了一个批处理作业中加入典型的例子。 在左侧是事件日志,描述登录用户在网站上做的事情(称为活动事件或点击流数据),右侧是用户数据库。 您可以将此示例看作是星型模式的一部分(请参阅“星号和雪花:分析的示意图”(第93页)):事件日志是事实表,用户数据库是其中一个尺寸。
|
||||
[图10-2](img/fig10-2.png)给出了一个批处理作业中加入典型的例子。 在左侧是事件日志,描述登录用户在网站上做的事情(称为活动事件或点击流数据),右侧是用户数据库。 你可以将此示例看作是星型模式的一部分(参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中一个纬度。
|
||||
|
||||
![](img/fig10-2.png)
|
||||
|
||||
@ -306,183 +309,184 @@ Hadoop的各种高级工具(如Pig [30],Hive [31],Cascading [32],Crunch
|
||||
|
||||
分析任务可能需要将用户活动与用户简档信息相关联:例如,如果简档包含用户的年龄或出生日期,则系统可以确定哪些年龄组最受欢迎。但是,活动事件仅包含用户标识,而不包含完整的用户配置文件信息。在每一个活动事件中嵌入这个简介信息很可能是非常浪费的。因此,活动事件需要加入用户配置文件数据库。
|
||||
|
||||
这个连接的最简单实现将逐个遍历活动事件,并为每个遇到的用户ID查询用户数据库(在远程服务器上)。这是可能的,但是它很可能会遭受非常差的性能:处理吞吐量将受到数据库服务器的往返时间的限制,本地缓存的有效性将很大程度上取决于数据的分布,并行运行大量查询可能会轻易压倒数据库[35]。
|
||||
这个连接的最简单实现将逐个遍历活动事件,并为每个遇到的用户ID查询用户数据库(在远程服务器上)。这是可能的,但是它很可能会遭受非常差的性能:处理吞吐量将受到数据库服务器的往返时间的限制,本地缓存的有效性将很大程度上取决于数据的分布,并行运行大量查询可能会轻易压倒数据库【35】。
|
||||
|
||||
为了在批处理过程中实现良好的吞吐量,计算必须(尽可能)在一台机器上进行。通过网络为您要处理的每个记录进行随机访问请求太慢。而且,查询远程数据库意味着批处理作业变得不确定,因为远程数据库中的数据可能会改变。
|
||||
为了在批处理过程中实现良好的吞吐量,计算必须(尽可能)在一台机器上进行。通过网络为你要处理的每个记录进行随机访问请求太慢。而且,查询远程数据库意味着批处理作业变得不确定,因为远程数据库中的数据可能会改变。
|
||||
|
||||
因此,更好的方法是获取用户数据库的副本(例如,使用ETL进程从数据库备份中提取数据,请参阅第91页上的“数据仓库”),并将其放入与日志相同的分布式文件系统用户活动事件。然后,您可以将用户数据库存储在HDFS中的一组文件中,并将用户活动记录在另一组文件中,并且可以使用MapReduce将所有相关记录集中到同一地点并高效地处理它们。
|
||||
因此,更好的方法是获取用户数据库的副本(例如,使用ETL进程从数据库备份中提取数据,参阅“[数据仓库](ch3.md#数据仓库)”),并将其放入与日志相同的分布式文件系统用户活动事件。然后,你可以将用户数据库存储在HDFS中的一组文件中,并将用户活动记录在另一组文件中,并且可以使用MapReduce将所有相关记录集中到同一地点并高效地处理它们。
|
||||
|
||||
#### 排序合并连接
|
||||
|
||||
回想一下,映射器的目的是从每个输入记录中提取一个键和值。在图10-2的情况下,这个键就是用户ID:一组映射器会覆盖活动事件(提取用户ID作为键和活动事件作为值),而另一组映射器将会检查用户数据库(提取用户ID作为键和用户的出生日期作为值)。这个过程如图10-3所示。
|
||||
回想一下,Mapper的目的是从每个输入记录中提取一个键和值。在[图10-2](img/fig10-2.png)的情况下,这个键就是用户ID:一组Mapper会覆盖活动事件(提取用户ID作为键和活动事件作为值),而另一组Mapper将会检查用户数据库(提取用户ID作为键和用户的出生日期作为值)。这个过程如[图10-3](img/fig10-3.png)所示。
|
||||
|
||||
![](img/fig10-3.png)
|
||||
|
||||
**图10-3 Reduce端在user ID上进行归并排序连接,如果输入数据集分片成多个文件,则每个都会被多个Mapper并行处理**
|
||||
|
||||
当MapReduce框架通过key对mapper输出进行分区,然后对键值对进行排序时,效果是所有活动事件和用户ID相同的用户记录在reducer输入中彼此相邻。 Map-Reduce作业甚至可以安排记录进行排序,使减速器始终如一
|
||||
首先从用户数据库中查看记录,然后按照时间戳顺序查看活动事件 - 这种技术被称为次级排序[26]。
|
||||
首先从用户数据库中查看记录,然后按照时间戳顺序查看活动事件 —— 这种技术被称为次级排序【26】。
|
||||
|
||||
然后reducer可以很容易地执行实际的加入逻辑:每个用户ID调用一次reducer函数,并且由于二次排序,第一个值应该是来自用户数据库的出生日期记录。 Reducer将出生日期存储在局部变量中,然后使用相同的用户ID遍历活动事件,输出已观看网址和观看者年龄对。随后的Map- Reduce作业可以计算每个URL的查看者年龄分布,并按年龄组进行聚类。
|
||||
然后reducer可以很容易地执行实际的加入逻辑:每个用户ID调用一次reducer函数,并且由于二次排序,第一个值应该是来自用户数据库的出生日期记录。 Reducer将出生日期存储在局部变量中,然后使用相同的用户ID遍历活动事件,输出已观看网址和观看者年龄对。随后的Map-Reduce作业可以计算每个URL的查看者年龄分布,并按年龄组进行聚类。
|
||||
|
||||
由于reducer一次处理一个特定用户ID的所有记录,因此只需要一次将一个用户记录保存在内存中,而不需要通过网络发出任何请求。这个算法被称为排序合并连接,因为映射器输出是按键排序的,然后缩减器将来自连接两边的排序的记录列表合并在一起。
|
||||
由于reducer一次处理一个特定用户ID的所有记录,因此只需要一次将一个用户记录保存在内存中,而不需要通过网络发出任何请求。这个算法被称为排序合并连接,因为Mapper输出是按键排序的,然后Reducer将来自连接两边的排序的记录列表合并在一起。
|
||||
|
||||
#### 把相关数据放在一起
|
||||
|
||||
在排序合并连接中,映射器和排序过程确保将执行特定用户标识的连接操作的所有必需数据放在一起:一次调用reducer。预先排列了所有需要的数据,reducer可以是一个相当简单,单线程的代码,可以通过高吞吐量和低内存开销通过记录。
|
||||
在排序合并连接中,Mapper和排序过程确保将执行特定用户标识的连接操作的所有必需数据放在一起:一次调用reducer。预先排列了所有需要的数据,reducer可以是一个相当简单,单线程的代码,可以通过高吞吐量和低内存开销通过记录。
|
||||
|
||||
查看这种体系结构的一种方法是映射器将“消息”发送给reducer。当一个映射器发出一个键值对时,这个键的作用就像值应该传递到的目标地址。即使密钥只是一个任意的字符串(不是像IP地址和端口号那样的实际的网络地址),它的行为就像一个地址:所有具有相同密钥的密钥对将被传送到相同的目标(a呼叫减速机)。
|
||||
查看这种体系结构的一种方法是Mapper将“消息”发送给reducer。当一个Mapper发出一个键值对时,这个键的作用就像值应该传递到的目标地址。即使键只是一个任意的字符串(不是像IP地址和端口号那样的实际的网络地址),它的行为就像一个地址:所有具有相同键的键对将被传送到相同的目标(一次Reduce的调用)。
|
||||
|
||||
使用MapReduce编程模型将计算的物理网络通信方面(从正确的计算机获取数据)从应用程序逻辑中分离出来(处理完数据后)。这种分离与数据库的典型使用形成了鲜明的对比,从数据库中获取数据的请求经常发生在应用程序代码的深处[36]。由于MapReduce能够处理所有的网络通信,因此它也避免了应用程序代码担心部分故障,例如另一个节点的崩溃:MapReduce在不影响应用程序逻辑的情况下,透明地重试失败的任务。
|
||||
使用MapReduce编程模型将计算的物理网络通信方面(从正确的计算机获取数据)从应用程序逻辑中分离出来(处理完数据后)。这种分离与数据库的典型使用形成了鲜明的对比,从数据库中获取数据的请求经常发生在应用程序代码的深处【36】。由于MapReduce能够处理所有的网络通信,因此它也避免了应用程序代码担心部分故障,例如另一个节点的崩溃:MapReduce在不影响应用程序逻辑的情况下,透明地重试失败的任务。
|
||||
|
||||
### GROUP BY
|
||||
|
||||
除了连接之外,“将相关数据引入同一地点”模式的另一个常见用法是通过某个键(如SQL中的GROUP BY子句)对记录进行分组。所有用相同的密钥记录一个组,并且下一步往往是在每个组内进行某种聚合,例如:
|
||||
除了连接之外,“将相关数据引入同一地点”模式的另一个常见用法是通过某个键(如SQL中的GROUP BY子句)对记录进行分组。所有用相同的键记录一个组,并且下一步往往是在每个组内进行某种聚合,例如:
|
||||
|
||||
* 计算每个组中记录的数量(例如,在统计页面视图的示例中,您将在SQL中表示为COUNT(*)聚合)
|
||||
* 在SQL中的一个特定字段(SUM(fieldname))中添加值
|
||||
* 计算每个组中记录的数量(例如,在统计页面视图的示例中,你将在SQL中表示为COUNT(*)聚合)
|
||||
* 在SQL中的一个特定字段(`SUM(fieldname)`)中添加值
|
||||
* 根据某些排名函数选择前k个记录
|
||||
|
||||
使用MapReduce实现这种分组操作的最简单方法是设置映射器,以便它们生成的键值对使用所需的分组键。然后,分区和排序过程将所有记录与同一个缩减器中的相同键集合在一起。因此,在MapReduce上实现时,分组和连接看起来非常相似。
|
||||
使用MapReduce实现这种分组操作的最简单方法是设置Mapper,以便它们生成的键值对使用所需的分组键。然后,分区和排序过程将所有记录与同一个Reducer中的相同键集合在一起。因此,在MapReduce上实现时,分组和连接看起来非常相似。
|
||||
|
||||
分组的另一个常见用途是整理特定用户会话的所有活动事件,以便找出用户采取的一系列操作(称为会话化[37])。例如,可以使用这种分析来确定显示网站新版本的用户是否比那些显示旧版本(A / B测试)的用户更有可能进行购买,或计算某个营销活动是值得的。
|
||||
分组的另一个常见用途是整理特定用户会话的所有活动事件,以便找出用户采取的一系列操作(称为会话化【37】)。例如,可以使用这种分析来确定显示网站新版本的用户是否比那些显示旧版本(A/B测试)的用户更有可能进行购买,或计算某个营销活动是值得的。
|
||||
|
||||
如果您有多个Web服务器处理用户请求,则特定用户的活动事件很可能分散在各个不同的服务器的日志文件中。您可以通过使用会话cookie,用户ID或类似的标识符作为分组键来实现会话,并将特定用户的所有活动事件放在一起,同时将不同用户的事件分配到不同的分区。
|
||||
如果你有多个Web服务器处理用户请求,则特定用户的活动事件很可能分散在各个不同的服务器的日志文件中。你可以通过使用会话cookie,用户ID或类似的标识符作为分组键来实现会话,并将特定用户的所有活动事件放在一起,同时将不同用户的事件分配到不同的分区。
|
||||
|
||||
#### 处理倾斜
|
||||
|
||||
如果存在与单个密钥相关的大量数据,则“将具有相同密钥的所有记录带到相同位置”的模式被破坏。例如,在社交网络中,大多数用户可能会连接到几百人,但少数名人可能有数百万的追随者。这种不成比例的活动数据库记录被称为关键对象[38]或热键。
|
||||
如果存在与单个键相关的大量数据,则“将具有相同键的所有记录带到相同位置”的模式被破坏。例如,在社交网络中,大多数用户可能会连接到几百人,但少数名人可能有数百万的追随者。这种不成比例的活动数据库记录被称为关键对象【38】或热键。
|
||||
|
||||
在单个缩减器中收集与名人相关的所有活动(例如回复他们发布的内容)可能导致严重的偏差(也称为热点) - 也就是说,一个减速器必须处理比其他更多的记录(参见“歪曲的工作负载和消除热点“)。由于MapReduce作业只有在其所有映射器和缩减器都完成时才完成,所有后续作业必须等待最慢的缩减器才能启动。
|
||||
在单个Reducer中收集与名人相关的所有活动(例如回复他们发布的内容)可能导致严重的倾斜(也称为热点)—— 也就是说,一个减速器必须处理比其他更多的记录(参见“[负载倾斜与消除热点](ch6.md#负载倾斜与消除热点)“)。由于MapReduce作业只有在其所有Mapper和Reducer都完成时才完成,所有后续作业必须等待最慢的Reducer才能启动。
|
||||
|
||||
如果加入输入有热键,则可以使用一些算法进行补偿。例如,Pig中的偏斜连接方法首先运行一个抽样作业来确定哪些键是热的[39]。执行实际加入时,映射器发送任何随机选择一个与几个减速器之一相关的热键的记录(与传统的MapReduce相比,它选择一个基于密钥散列确定性的减速器)。对于加入的其他输入,与热键相关的记录需要被复制到所有处理该密钥的缩减器[40]。
|
||||
如果加入输入有热点键,则可以使用一些算法进行补偿。例如,Pig中的倾斜连接方法首先运行一个抽样作业来确定哪些键是热的【39】。执行实际加入时,Mapper发送任何随机选择一个与几个减速器之一相关的热键的记录(与传统的MapReduce相比,它选择一个基于键散列确定性的减速器)。对于加入的其他输入,与热键相关的记录需要被复制到所有处理该键的Reducer【40】。
|
||||
|
||||
这种技术将处理热键的工作分散到多个reducer上,这样可以使其更好地并行化,而不必将其他join连接复制到多个reducer。 Crunch中的分片连接方法是相似的,但需要显式指定热键而不是使用采样作业。这种技术也非常类似于我们在第205页的“倾斜的工作负载和减轻热点”中讨论的技术,使用随机化来缓解分区数据库中的热点。
|
||||
这种技术将处理热键的工作分散到多个reducer上,这样可以使其更好地并行化,而不必将其他join连接复制到多个reducer。 Crunch中的分片连接方法是相似的,但需要显式指定热键而不是使用采样作业。这种技术也非常类似于我们在“[负载倾斜与消除热点](ch6.md#负载倾斜与消除热点)”中讨论的技术,使用随机化来缓解分区数据库中的热点。
|
||||
|
||||
Hive的偏斜连接优化采取了另一种方法。它需要在表格元数据中明确指定热键,并将与这些键相关的记录与其余文件分开存放。在该表上执行连接时,它将使用地图边连接(请参阅下一节)获取热键。
|
||||
Hive的偏斜连接优化采取了另一种方法。它需要在表格元数据中明确指定热键,并将与这些键相关的记录与其余文件分开存放。在该表上执行连接时,它将使用Map边连接(参阅下一节)获取热键。
|
||||
|
||||
使用热键对记录进行分组并汇总记录时,可以分两个阶段进行分组。第一个MapReduce阶段将记录发送到随机缩减器,以便每个缩减器对热键的记录子集执行分组,并为每个键输出更紧凑的聚合值。第二个Map-Reduce作业然后将来自所有第一阶段减速器的值合并为每个键的单个值。
|
||||
使用热键对记录进行分组并汇总记录时,可以分两个阶段进行分组。第一个MapReduce阶段将记录发送到随机Reducer,以便每个Reducer对热键的记录子集执行分组,并为每个键输出更紧凑的聚合值。第二个Map-Reduce作业然后将来自所有第一阶段减速器的值合并为每个键的单个值。
|
||||
|
||||
### Map端连接
|
||||
|
||||
上一节描述的连接算法在reducer中执行实际的连接逻辑,因此被称为reduce-side连接。映射器扮演着输入数据的角色:从每个输入记录中提取键和值,将键值对分配给reducer分区,并按键排序。
|
||||
上一节描述的连接算法在reducer中执行实际的连接逻辑,因此被称为reduce-side连接。Mapper扮演着输入数据的角色:从每个输入记录中提取键和值,将键值对分配给reducer分区,并按键排序。
|
||||
|
||||
减少方法的优点是不需要对输入数据做任何假设:无论其属性和结构如何,映射器都可以准备数据以准备加入。然而,不利的一面是,所有这些排序,复制到缩减器以及合并减速器输入可能是非常昂贵的。取决于可用的内存缓冲区,当数据通过MapReduce [37]阶段时,数据可能被写入磁盘几次。
|
||||
减少方法的优点是不需要对输入数据做任何假设:无论其属性和结构如何,Mapper都可以准备数据以准备加入。然而,不利的一面是,所有这些排序,复制到Reducer以及合并减速器输入可能是非常昂贵的。取决于可用的内存缓冲区,当数据通过MapReduce 【37】阶段时,数据可能被写入磁盘几次。
|
||||
|
||||
另一方面,如果您可以对输入数据进行某些假设,则可以通过使用所谓的map端连接来加快连接速度。这种方法使用了一个缩减的MapReduce作业,其中没有减速器,也没有排序。相反,每个映射器只需从分布式文件系统读取一个输入文件块,然后将一个输出文件写入文件系统即可。
|
||||
另一方面,如果你可以对输入数据进行某些假设,则可以通过使用所谓的map端连接来加快连接速度。这种方法使用了一个Reduce的MapReduce作业,其中没有减速器,也没有排序。相反,每个Mapper只需从分布式文件系统读取一个输入文件块,然后将一个输出文件写入文件系统即可。
|
||||
|
||||
#### 广播散列连接
|
||||
|
||||
执行地图边连接最简单的方法适用于大数据集与小数据集连接的情况。特别是,小数据集需要足够小,以便可以将其全部加载到每个映射器的内存中。
|
||||
执行Map端连接最简单的方法适用于大数据集与小数据集连接的情况。特别是,小数据集需要足够小,以便可以将其全部加载到每个Mapper的内存中。
|
||||
|
||||
例如,假设在图10-2的情况下,用户数据库足够小以适应内存。在这种情况下,当映射器启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的哈希表中。完成此操作后,映射程序可以扫描用户活动事件,并简单地查找散列表中每个事件的用户标识[^vi]。
|
||||
例如,假设在[图10-2](img/fig10-2.png)的情况下,用户数据库足够小以适应内存。在这种情况下,当Mapper启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的哈希表中。完成此操作后,Map程序可以扫描用户活动事件,并简单地查找散列表中每个事件的用户标识[^vi]。
|
||||
|
||||
[^vi]: 这个例子假定散列表中的每个键只有一个条目,这对用户数据库(用户ID唯一标识一个用户)可能是正确的。通常,哈希表可能需要包含具有相同键的多个条目,并且连接运算符将输出关键字的所有匹配。
|
||||
|
||||
仍然可以有几个映射任务:一个用于连接的大输入的每个文件块(在图10-2的例子中,活动事件是大输入)。这些映射器中的每一个都将小输入全部加载到内存中。
|
||||
仍然可以有几个映射任务:一个用于连接的大输入的每个文件块(在[图10-2](img/fig10-2.png)的例子中,活动事件是大输入)。这些Mapper中的每一个都将小输入全部加载到内存中。
|
||||
|
||||
这种简单而有效的算法被称为广播散列连接:广播词反映了这样一个事实,即大输入的分区的每个映射器都读取整个小输入(所以小输入有效地“广播”到大的输入),单词hash反映了它使用一个哈希表。 Pig(名为“replicated join”),Hive(“MapJoin”),Cascading和Crunch支持此连接方法。它也用于数据仓库查询引擎,如Impala [41]。
|
||||
这种简单而有效的算法被称为广播散列连接:广播一词反映了这样一个事实,即大输入的分区的每个Mapper都读取整个小输入(所以小输入有效地“广播”到大的输入),单词hash反映了它使用一个哈希表。 Pig(名为“replicated join”),Hive(“MapJoin”),Cascading和Crunch支持此连接方法。它也用于数据仓库查询引擎,如Impala 【41】。
|
||||
|
||||
而不是将小连接输入加载到内存散列表中,另一种方法是将小连接输入存储在本地磁盘上的只读索引中[42]。该索引中经常使用的部分将保留在操作系统的页面缓存中,因此这种方法可以提供与内存中哈希表几乎一样快的随机访问查找,但实际上并不需要数据集适合内存。
|
||||
而不是将小连接输入加载到内存散列表中,另一种方法是将小连接输入存储在本地磁盘上的只读索引中【42】。该索引中经常使用的部分将保留在操作系统的页面缓存中,因此这种方法可以提供与内存中哈希表几乎一样快的随机访问查找,但实际上并不需要数据集适合内存。
|
||||
|
||||
#### 分区散列连接
|
||||
|
||||
如果以相同方式对映射端连接的输入进行分区,则散列连接方法可以独立应用于每个分区。在图10-2的情况下,您可以根据用户标识的最后一位十进制数字来安排活动事件和用户数据库的每一个(因此每边有10个分区)。例如,映射器3首先将所有具有以3结尾的ID的用户加载到散列表中,然后扫描ID为3的每个用户的所有活动事件。
|
||||
如果以相同方式对映射端连接的输入进行分区,则散列连接方法可以独立应用于每个分区。在[图10-2](img/fig10-2.png)的情况下,你可以根据用户标识的最后一位十进制数字来安排活动事件和用户数据库的每一个(因此每边有10个分区)。例如,Mapper3首先将所有具有以3结尾的ID的用户加载到散列表中,然后扫描ID为3的每个用户的所有活动事件。
|
||||
|
||||
如果分区正确完成,您可以确定所有您可能要加入的记录都位于相同编号的分区中,因此每个映射器只能从每个输入数据集中读取一个分区就足够了。这具有的优点是每个映射器都可以将较少量的数据加载到其哈希表中。
|
||||
如果分区正确完成,你可以确定所有你可能要加入的记录都位于相同编号的分区中,因此每个Mapper只能从每个输入数据集中读取一个分区就足够了。这具有的优点是每个Mapper都可以将较少量的数据加载到其哈希表中。
|
||||
|
||||
这种方法只适用于两个连接的输入具有相同数量的分区,记录根据相同的密钥和相同的散列函数分配给分区。如果输入是由之前执行过这个分组的MapReduce作业生成的,那么这可能是一个合理的假设。
|
||||
这种方法只适用于两个连接的输入具有相同数量的分区,记录根据相同的键和相同的散列函数分配给分区。如果输入是由之前执行过这个分组的MapReduce作业生成的,那么这可能是一个合理的假设。
|
||||
|
||||
分区散列连接在Hive [37]中称为bucketed映射连接。地图边合并连接
|
||||
分区散列连接在Hive 【37】中称为bucketed映射连接。Map端合并连接
|
||||
|
||||
如果输入数据集不仅以相同的方式进行分区,而且还基于相同的键进行排序,则应用另一种地图端联接的变体。在这种情况下,输入是否足够小以适应内存并不重要,因为映射器可以执行通常由reducer执行的相同合并操作:按递增键递增读取两个输入文件,以及匹配相同的密钥记录。
|
||||
如果输入数据集不仅以相同的方式进行分区,而且还基于相同的键进行排序,则应用另一种Map端联接的变体。在这种情况下,输入是否足够小以适应内存并不重要,因为Mapper可以执行通常由reducer执行的相同合并操作:按递增键递增读取两个输入文件,以及匹配相同的键记录。
|
||||
|
||||
如果地图边合并连接是可能的,则可能意味着先前的MapReduce作业首先将输入数据集引入到这个分区和排序的表单中。原则上,这个加入可以在之前工作的缩减阶段进行。但是,在单独的仅用于地图的作业中执行合并连接仍然是适当的,例如,除了此特定连接之外,还需要分区和排序数据集以用于其他目的。
|
||||
如果Map端合并连接是可能的,则可能意味着先前的MapReduce作业首先将输入数据集引入到这个分区和排序的表单中。原则上,这个加入可以在之前工作的Reduce阶段进行。但是,在单独的仅用于Map的作业中执行合并连接仍然是适当的,例如,除了此特定连接之外,还需要分区和排序数据集以用于其他目的。
|
||||
|
||||
#### MapReduce与Map端连接的工作流程
|
||||
|
||||
当下游作业使用MapReduce连接的输出时,map-side或reduce-side连接的选择会影响输出的结构。 reduce-side连接的输出按连接键进行分区和排序,而map-side连接的输出按照与大输入相同的方式进行分区和排序(因为对每个文件块启动一个map任务无论是使用分区连接还是广播连接,连接的大输入)。
|
||||
|
||||
如前所述,地图边连接也对输入数据集的大小,排序和分区做出了更多的假设。在优化连接策略时,了解分布式文件系统中数据集的物理布局变得非常重要:仅仅知道编码格式和数据存储目录的名称是不够的;您还必须知道数据分区和排序的分区数量和密钥。
|
||||
如前所述,Map边连接也对输入数据集的大小,排序和分区做出了更多的假设。在优化连接策略时,了解分布式文件系统中数据集的物理布局变得非常重要:仅仅知道编码格式和数据存储目录的名称是不够的;你还必须知道数据分区和排序的分区数量和键。
|
||||
|
||||
在Hadoop生态系统中,这种关于数据集分区的元数据经常在HCatalog和Hive Metastore中维护[37]。
|
||||
在Hadoop生态系统中,这种关于数据集分区的元数据经常在HCatalog和Hive Metastore中维护【37】。
|
||||
|
||||
### 工作流的输出
|
||||
|
||||
我们已经谈了很多关于实现MapReduce工作流程的各种算法,但是我们忽略了一个重要的问题:一旦完成,所有处理的结果是什么?我们为什么要把所有这些工作放在首位?
|
||||
|
||||
在数据库查询的情况下,我们根据分析目的来区分事务处理(OLTP)目的(请参阅第90页上的“事务处理或分析?”)。我们看到,OLTP查询通常使用索引按键查找少量记录,以便将其呈现给用户(例如,在网页上)。另一方面,分析查询通常会扫描大量记录,执行分组和汇总,输出通常具有报告的形式:显示某个指标随时间变化的图表,或前10个项目根据一些排名,或一些数量分解成子类别。这种报告的消费者通常是需要做出商业决策的分析师或经理。
|
||||
在数据库查询的情况下,我们根据分析目的来区分事务处理(OLTP)目的(参阅“[事务处理或分析?](ch3.md#事务处理或分析?)”)。我们看到,OLTP查询通常使用索引按键查找少量记录,以便将其呈现给用户(例如,在网页上)。另一方面,分析查询通常会扫描大量记录,执行分组和汇总,输出通常具有报告的形式:显示某个指标随时间变化的图表,或前10个项目根据一些排名,或一些数量分解成子类别。这种报告的消费者通常是需要做出商业决策的分析师或经理。
|
||||
|
||||
批处理在哪里适合?这不是交易处理,也不是分析。与分析更接近,因为批处理过程通常扫描输入数据集的大部分。但是,MapReduce作业的工作流程与用于分析目的的SQL查询不同(请参阅第418页的“比较Hadoop与分布式数据库”)。批处理过程的输出通常不是报告,而是一些其他类型的结构。
|
||||
批处理在哪里适合?这不是交易处理,也不是分析。与分析更接近,因为批处理过程通常扫描输入数据集的大部分。但是,MapReduce作业的工作流程与用于分析目的的SQL查询不同(参阅“[比较Hadoop与分布式数据库](比较Hadoop与分布式数据库)”)。批处理过程的输出通常不是报告,而是一些其他类型的结构。
|
||||
|
||||
#### 建立搜索索引
|
||||
|
||||
Google最初使用的MapReduce是为其搜索引擎建立索引,这个索引是作为5到10个MapReduce作业的工作流实现的[1]。虽然Google为了这个目的后来不再使用MapReduce [43],但是如果从建立搜索索引的角度来看,它可以帮助理解MapReduce。 (即使在今天,Hadoop MapReduce仍然是构建Lucene / Solr索引的好方法。)
|
||||
Google最初使用的MapReduce是为其搜索引擎建立索引,这个索引是作为5到10个MapReduce作业的工作流实现的【1】。虽然Google为了这个目的后来不再使用MapReduce 【43】,但是如果从建立搜索索引的角度来看,它可以帮助理解MapReduce。 (即使在今天,Hadoop MapReduce仍然是构建Lucene / Solr索引的好方法。)
|
||||
|
||||
我们在第88页的“全文搜索和模糊索引”中简要地看到了Lucene这样的全文搜索索引是如何工作的:它是一个文件(术语字典),您可以在其中高效地查找特定关键字并找到包含该关键字的所有文档ID列表(发布列表)。这是一个非常简单的搜索索引视图 - 实际上,它需要各种附加数据,以便根据相关性对搜索结果进行排名,纠正拼写错误,解析同义词等等,但这一原则是成立的。
|
||||
我们在“[全文搜索和模糊索引](ch3.md#全文搜索和模糊索引)”中简要地看到了Lucene这样的全文搜索索引是如何工作的:它是一个文件(关键词字典),你可以在其中高效地查找特定关键字并找到包含该关键字的所有文档ID列表(发布列表)。这是一个非常简单的搜索索引视图 —— 实际上,它需要各种附加数据,以便根据相关性对搜索结果进行排名,纠正拼写错误,解析同义词等等,但这一原则是成立的。
|
||||
|
||||
如果需要对一组固定文档执行全文搜索,则批处理是构建索引的一种非常有效的方法:映射器根据需要对文档集进行分区,每个reducer构建其分区的索引,并将索引文件写入分布式文件系统。构建这样的文档分区索引(请参阅“分区和二级索引”(第184页))并行处理非常好。
|
||||
如果需要对一组固定文档执行全文搜索,则批处理是构建索引的一种非常有效的方法:Mapper根据需要对文档集进行分区,每个reducer构建其分区的索引,并将索引文件写入分布式文件系统。构建这样的文档分区索引(参阅“[分区和二级索引](ch6.md#分区和二级索引)”)并行处理非常好。
|
||||
|
||||
由于按关键字查询搜索索引是只读操作,因此这些索引文件一旦创建就是不可变的。
|
||||
|
||||
如果索引的文档集合发生更改,则可以选择定期重新运行整个索引工作流程,并在完成后用新的索引文件批量替换以前的索引文件。如果只有少量的文档发生了变化,这种方法可能会带来很高的计算成本,但是它的优点是索引过程很容易推理:文档,索引。
|
||||
|
||||
或者,可以逐渐建立索引。如第3章所述,如果要添加,删除或更新索引中的文档,Lucene会写出新的段文件,并异步合并和压缩背景中的段文件。我们将在第11章中看到更多这样的增量处理。
|
||||
或者,可以逐渐建立索引。如第3章所述,如果要添加,删除或更新索引中的文档,Lucene会写出新的段文件,并异步合并和压缩背景中的段文件。我们将在[第11章](ch11.md)中看到更多这样的增量处理。
|
||||
|
||||
#### 键值存储作为批处理输出
|
||||
|
||||
搜索索引只是批处理工作流程可能输出的一个示例。批量处理的另一个常见用途是构建机器学习系统,如分类器(例如,垃圾邮件过滤器,异常检测,图像识别)和推荐系统(例如,您可能认识的人,您可能感兴趣的产品或相关搜索) ])。
|
||||
搜索索引只是批处理工作流程可能输出的一个示例。批量处理的另一个常见用途是构建机器学习系统,如分类器(例如,垃圾邮件过滤器,异常检测,图像识别)和推荐系统(例如,你可能认识的人,你可能感兴趣的产品或相关搜索)。
|
||||
|
||||
这些批处理作业的输出通常是某种数据库:例如,可以通过用户ID查询以获取该用户的建议朋友的数据库,或者可以通过产品ID查询的数据库以获取相关产品[45]。
|
||||
这些批处理作业的输出通常是某种数据库:例如,可以通过用户ID查询以获取该用户的建议朋友的数据库,或者可以通过产品ID查询的数据库以获取相关产品【45】。
|
||||
|
||||
这些数据库需要从处理用户请求的Web应用程序中查询,这些请求通常与Hadoop基础架构分离。那么批处理过程的输出如何返回到Web应用程序可以查询的数据库?
|
||||
|
||||
最明显的选择可能是直接在映射器或简化器中使用客户端库作为您最喜欢的数据库,并从批处理作业直接写入数据库服务器,一次写入一条记录。这将起作用(假设您的防火墙规则允许从您的Hadoop环境直接访问您的生产数据库),但由于以下几个原因,这是一个坏主意:
|
||||
最明显的选择可能是直接在Mapper或Reducer中使用客户端库作为你最喜欢的数据库,并从批处理作业直接写入数据库服务器,一次写入一条记录。这将起作用(假设你的防火墙规则允许从你的Hadoop环境直接访问你的生产数据库),但由于以下几个原因,这是一个坏主意:
|
||||
|
||||
* 正如前面讨论的连接一样,为每个记录提出一个网络请求比批处理任务的正常吞吐量要慢几个数量级。即使客户端库支持批处理,性能也可能很差。
|
||||
* MapReduce作业经常并行运行许多任务。如果所有映射器或简化器都同时写入相同的输出数据库,并且批处理过程期望的速率,那么该数据库可能很容易被压倒,并且其查询性能可能受到影响。这可能会导致系统其他部分的操作问题[35]。
|
||||
* 通常情况下,MapReduce为作业输出提供了一个干净的“全有或全无”的保证:如果作业成功,则结果就是只执行一次任务的输出,即使某些任务失败并且必须重试。如果整个作业失败,则不会生成输出。然而,从作业内部写入外部系统会产生外部可见的副作用,这种副作用是不能被隐藏的。因此,您不得不担心部分完成的作业对其他系统可见的结果,以及Hadoop任务尝试和推测性执行的复杂性。
|
||||
* MapReduce作业经常并行运行许多任务。如果所有Mapper或Reducer都同时写入相同的输出数据库,并且批处理过程期望的速率,那么该数据库可能很容易被压倒,并且其查询性能可能受到影响。这可能会导致系统其他部分的操作问题【35】。
|
||||
* 通常情况下,MapReduce为作业输出提供了一个干净的“全有或全无”的保证:如果作业成功,则结果就是只执行一次任务的输出,即使某些任务失败并且必须重试。如果整个作业失败,则不会生成输出。然而,从作业内部写入外部系统会产生外部可见的副作用,这种副作用是不能被隐藏的。因此,你不得不担心部分完成的作业对其他系统可见的结果,以及Hadoop任务尝试和推测性执行的复杂性。
|
||||
|
||||
更好的解决方案是在批处理作业中创建一个全新的数据库,并将其作为文件写入分布式文件系统中作业的输出目录,就像上一节的搜索索引一样。这些数据文件一旦写入就是不可变的,可以批量加载到处理只读查询的服务器中。各种键值存储支持在MapReduce作业中构建数据库文件,包括Voldemort [46],Terrapin [47],ElephantDB [48]和HBase批量加载[49]。
|
||||
更好的解决方案是在批处理作业中创建一个全新的数据库,并将其作为文件写入分布式文件系统中作业的输出目录,就像上一节的搜索索引一样。这些数据文件一旦写入就是不可变的,可以批量加载到处理只读查询的服务器中。各种键值存储支持在MapReduce作业中构建数据库文件,包括Voldemort 【46】,Terrapin 【47】,ElephantDB 【48】和HBase批量加载【49】。
|
||||
|
||||
构建这些数据库文件是MapReduce的一个很好的使用方法:使用映射器提取一个键,然后使用该键进行排序已经成为构建索引所需的大量工作。由于大多数这些键值存储是只读的(文件只能由批处理作业一次写入,而且是不可变的),所以数据结构非常简单。例如,它们不需要WAL(请参阅第82页的「使B树可靠」)。
|
||||
构建这些数据库文件是MapReduce的一个很好的使用方法:使用Mapper提取一个键,然后使用该键进行排序已经成为构建索引所需的大量工作。由于大多数这些键值存储是只读的(文件只能由批处理作业一次写入,而且是不可变的),所以数据结构非常简单。例如,它们不需要WAL(参阅“[使B树可靠](ch3.md#使B树可靠)”)。
|
||||
|
||||
将数据加载到Voldemort时,服务器将继续向旧数据文件提供请求,同时将新数据文件从分布式文件系统复制到服务器的本地磁盘。一旦复制完成,服务器会自动切换到查询新文件。如果在这个过程中出现任何问题,它可以很容易地再次切换回旧的文件,因为它们仍然存在,并且是不变的[46]。
|
||||
将数据加载到Voldemort时,服务器将继续向旧数据文件提供请求,同时将新数据文件从分布式文件系统复制到服务器的本地磁盘。一旦复制完成,服务器会自动切换到查询新文件。如果在这个过程中出现任何问题,它可以很容易地再次切换回旧的文件,因为它们仍然存在,并且是不变的【46】。
|
||||
|
||||
#### 批量过程输出的哲学
|
||||
|
||||
本章前面讨论过的Unix哲学(第394页的“Unix哲学”)鼓励通过对数据流的非常明确的实验来进行实验:程序读取输入并写入输出。在这个过程中,输入保持不变,任何以前的输出都被新输出完全替换,并且没有其他副作用。这意味着您可以随心所欲地重新运行一个命令,调整或调试它,而不会扰乱系统的状态。
|
||||
本章前面讨论过的Unix哲学(“[Unix哲学](#Unix哲学)”)鼓励通过对数据流的非常明确的实验来进行实验:程序读取输入并写入输出。在这个过程中,输入保持不变,任何以前的输出都被新输出完全替换,并且没有其他副作用。这意味着你可以随心所欲地重新运行一个命令,调整或调试它,而不会扰乱系统的状态。
|
||||
|
||||
MapReduce作业的输出处理遵循相同的原理。通过将输入视为不可变且避免副作用(如写入外部数据库),批处理作业不仅实现了良好的性能,而且更容易维护:
|
||||
|
||||
* 如果在代码中引入了一个错误,并且输出错误或损坏了,则可以简单地回滚到代码的先前版本,然后重新运行该作业,输出将再次正确。或者,甚至更简单,您可以将旧的输出保存在不同的目录中,然后切换回原来的目录。具有读写事务的数据库没有这个属性:如果你部署了错误的代码,将错误的数据写入数据库,那么回滚代码将无法修复数据库中的数据。 (能够从错误代码中恢复的思想被称为人类容错[50]。)
|
||||
* 由于易于回滚,功能开发可以比错误意味着不可挽回的损害的环境更快地进行。这种使不可逆性最小化的原则有利于敏捷软件的开发[51]。
|
||||
* 如果在代码中引入了一个错误,并且输出错误或损坏了,则可以简单地回滚到代码的先前版本,然后重新运行该作业,输出将再次正确。或者,甚至更简单,你可以将旧的输出保存在不同的目录中,然后切换回原来的目录。具有读写事务的数据库没有这个属性:如果你部署了错误的代码,将错误的数据写入数据库,那么回滚代码将无法修复数据库中的数据。 (能够从错误代码中恢复的思想被称为人类容错【50】。)
|
||||
* 由于易于回滚,功能开发可以比错误意味着不可挽回的损害的环境更快地进行。这种使不可逆性最小化的原则有利于敏捷软件的开发【51】。
|
||||
* 如果映射或减少任务失败,MapReduce框架将自动重新调度并在同一个输入上再次运行它。如果失败是由于代码中的一个错误造成的,那么它会一直崩溃,并最终导致作业在几次尝试之后失败。但是如果故障是由于暂时的问题引起的,那么故障是可以容忍的。这种自动重试只是安全的,因为输入是不可变的,而失败任务的输出被MapReduce框架丢弃。
|
||||
* 同一组文件可用作各种不同作业的输入,其中包括计算度量标准的计算作业,并评估作业的输出是否具有预期的特性(例如,将其与前一次运行的输出进行比较并测量差异) 。
|
||||
* 与Unix工具类似,MapReduce作业将逻辑与布线(配置输入和输出目录)分开,这就提供了关注点的分离,并且可以重用代码:一个团队可以专注于实现一件好事的工作其他团队可以决定何时何地运行这项工作。
|
||||
|
||||
在这些领域,对Unix运行良好的设计原则似乎也适用于Hadoop,但Unix和Hadoop在某些方面也有所不同。例如,因为大多数Unix工具都假定没有类型的文本文件,所以他们必须做大量的输入解析(本章开头的日志分析示例使用{print $ 7}来提取URL)。在Hadoop上,通过使用更多结构化的文件格式,可以消除一些低价值的语法转换:Avro(请参阅第122页上的“Avro”)和Parquet(请参阅第95页上的“面向列的存储”)经常使用,因为它们提供高效的基于模式的编码,并允许随着时间的推移模式的演变(见第4章)。
|
||||
在这些领域,对Unix运行良好的设计原则似乎也适用于Hadoop,但Unix和Hadoop在某些方面也有所不同。例如,因为大多数Unix工具都假定没有类型的文本文件,所以他们必须做大量的输入解析(本章开头的日志分析示例使用`{print $7}`来提取URL)。在Hadoop上,通过使用更多结构化的文件格式,可以消除一些低价值的语法转换:Avro(参阅“[Avro](ch4.md#Avro)”)和Parquet(参阅第95页上的“[列存储](ch3.md#列存储)”)经常使用,因为它们提供高效的基于模式的编码,并允许随着时间的推移模式的演变(见第4章)。
|
||||
|
||||
### 比较Hadoop和分布式数据库
|
||||
|
||||
正如我们所看到的,Hadoop有点像Unix的分布式版本,其中HDFS是文件系统,而MapReduce是Unix进程的古怪实现(这恰好总是在映射阶段和缩小阶段之间运行排序实用程序)。我们看到了如何在这些基元之上实现各种连接和分组操作。
|
||||
|
||||
当MapReduce论文[1]发表时,它在某种意义上说并不新鲜。我们在前几节中讨论的所有处理和并行连接算法已经在十多年前的所谓的大规模并行处理(MPP)数据库中实现了[3,40]。例如,Gamma数据库机器,Teradata和Tandem NonStop SQL是这方面的先驱[52]。
|
||||
当MapReduce论文【1】发表时,它在某种意义上说并不新鲜。我们在前几节中讨论的所有处理和并行连接算法已经在十多年前的所谓的**大规模并行处理(MPP, massively parallel processing)**数据库中实现了【3,40】。例如,Gamma数据库机器,Teradata和Tandem NonStop SQL是这方面的先驱【52】。
|
||||
|
||||
最大的区别是MPP数据库集中于在一组机器上并行执行分析SQL查询,而MapReduce和分布式文件系统[19]的组合则更像是一个可以运行任意程序的通用操作系统。
|
||||
最大的区别是MPP数据库集中于在一组机器上并行执行分析SQL查询,而MapReduce和分布式文件系统【19】的组合则更像是一个可以运行任意程序的通用操作系统。
|
||||
|
||||
#### 存储的多样性
|
||||
|
||||
数据库要求您根据特定的模型(例如关系或文档)来构造数据,而分布式文件系统中的文件只是字节序列,可以使用任何数据模型和编码来编写。它们可能是数据库记录的集合,但同样可以是文本,图像,视频,传感器读数,稀疏矩阵,特征向量,基因组序列或任何其他类型的数据。
|
||||
数据库要求你根据特定的模型(例如关系或文档)来构造数据,而分布式文件系统中的文件只是字节序列,可以使用任何数据模型和编码来编写。它们可能是数据库记录的集合,但同样可以是文本,图像,视频,传感器读数,稀疏矩阵,特征向量,基因组序列或任何其他类型的数据。
|
||||
|
||||
说白了,Hadoop开放了将数据不加区分地转储到HDFS的可能性,之后才想出如何进一步处理它[53]。相比之下,在将数据导入数据库专有存储格式之前,MPP数据库通常需要对数据和查询模式进行仔细的前期建模。
|
||||
从纯粹的角度来看,这种仔细的建模和导入似乎是可取的,因为这意味着数据库的用户有更好的质量数据来处理。然而,在实践中,似乎只是简单地使数据可用 - 即使它是一个古怪的,难以使用的原始格式 - 通常比尝试决定理想的数据模型更有价值[54 ]。
|
||||
说白了,Hadoop开放了将数据不加区分地转储到HDFS的可能性,之后才想出如何进一步处理它【53】。相比之下,在将数据导入数据库专有存储格式之前,MPP数据库通常需要对数据和查询模式进行仔细的前期建模。
|
||||
|
||||
这个想法与数据仓库类似(请参阅第91页上的“数据仓库”):将大型组织的各个部分的数据集中在一起是很有价值的,因为它可以跨以前不同的数据集进行联接。 MPP数据库所要求的谨慎的模式设计减慢了集中式数据收集速度;以原始形式收集数据,以后担心模式设计,使数据收集速度加快(有时被称为“数据湖”或“企业数据中心”[55])。
|
||||
从纯粹的角度来看,这种仔细的建模和导入似乎是可取的,因为这意味着数据库的用户有更好的质量数据来处理。然而,在实践中,似乎只是简单地使数据可用 —— 即使它是一个古怪的,难以使用的原始格式 —— 通常比尝试决定理想的数据模型更有价值[54 ]。
|
||||
|
||||
不加区别的数据倾销改变了解释数据的负担:不是强迫数据集的生产者将其转化为标准化的格式,而是数据的解释成为消费者的问题(模式在读方法[56];请参阅第39页上的“文档模型中的模式灵活性”)。如果生产者和消费者是不同优先级的不同团队,这可能是一个优势。甚至可能不存在一个理想的数据模型,而是对适合不同目的的数据有不同的看法。以原始形式简单地转储数据可以进行多次这样的转换。这种方法被称为寿司原则:“原始数据更好”[57]。
|
||||
这个想法与数据仓库类似(参阅“[数据仓库](ch3.md#数据仓库)”):将大型组织的各个部分的数据集中在一起是很有价值的,因为它可以跨以前不同的数据集进行联接。 MPP数据库所要求的谨慎的模式设计减慢了集中式数据收集速度;以原始形式收集数据,以后担心模式设计,使数据收集速度加快(有时被称为“**数据湖(data lake)**”或“**企业数据中心(enterprise data hub)**”【55】)。
|
||||
|
||||
因此,Hadoop经常被用于实现ETL过程(请参阅“数据仓库”第91页):事务处理系统中的数据以某种原始形式转储到分布式文件系统中,然后编写MapReduce作业来清理数据,将其转换为关系表单,并将其导入MPP数据仓库以进行分析。数据建模仍然在发生,但它是在一个单独的步骤中,从数据收集中分离出来的。这种解耦是可能的,因为分布式文件系统支持以任何格式编码的数据。
|
||||
不加区别的数据倾销改变了解释数据的负担:不是强迫数据集的生产者将其转化为标准化的格式,而是数据的解释成为消费者的问题(读时模式方法【56】;参阅“[文档模型中的架构灵活性](ch2.md#文档模型中的架构灵活性)”)。如果生产者和消费者是不同优先级的不同团队,这可能是一个优势。甚至可能不存在一个理想的数据模型,而是对适合不同目的的数据有不同的看法。以原始形式简单地转储数据可以进行多次这样的转换。这种方法被称为寿司原则:“原始数据更好”【57】。
|
||||
|
||||
因此,Hadoop经常被用于实现ETL过程(参阅“[数据仓库](ch3.md#数据仓库)”):事务处理系统中的数据以某种原始形式转储到分布式文件系统中,然后编写MapReduce作业来清理数据,将其转换为关系表单,并将其导入MPP数据仓库以进行分析。数据建模仍然在发生,但它是在一个单独的步骤中,从数据收集中分离出来的。这种解耦是可能的,因为分布式文件系统支持以任何格式编码的数据。
|
||||
|
||||
#### 加工模型的多样性
|
||||
|
||||
@ -490,35 +494,35 @@ MPP数据库是单一的,紧密集成的软件,负责磁盘上的存储布
|
||||
|
||||
另一方面,并非所有类型的处理都可以合理地表达为SQL查询。例如,如果要构建机器学习和推荐系统,或者使用相关性排名模型的全文搜索索引,或者执行图像分析,则很可能需要更一般的数据处理模型。这些类型的处理通常对特定的应用程序非常具体(例如机器学习的特征工程,机器翻译的自然语言模型,欺诈预测的风险评估函数),因此它们不可避免地需要编写代码,而不仅仅是查询。
|
||||
|
||||
MapReduce使工程师能够轻松地在大型数据集上运行自己的代码。如果你有HDFS和MapReduce,那么你可以在它上面建立一个SQL查询执行引擎,事实上这正是Hive项目所做的[31]。但是,您也可以编写许多其他形式的批处理,这些批处理不适合用SQL查询表示。
|
||||
MapReduce使工程师能够轻松地在大型数据集上运行自己的代码。如果你有HDFS和MapReduce,那么你可以在它上面建立一个SQL查询执行引擎,事实上这正是Hive项目所做的【31】。但是,你也可以编写许多其他形式的批处理,这些批处理不适合用SQL查询表示。
|
||||
|
||||
随后,人们发现MapReduce对于某些类型的处理来说太过于限制,执行得太差,因此其他各种处理模型都是在Hadoop之上开发的(我们将在第419页的“Beyond MapReduce”中看到其中的一些)。有两种处理模型,SQL和MapReduce,还不够,需要更多不同的模型!而且由于Hadoop平台的开放性,实施一整套方法是可行的,而这在整体MPP数据库的范围内是不可能的[58]。
|
||||
随后,人们发现MapReduce对于某些类型的处理来说太过于限制,执行得太差,因此其他各种处理模型都是在Hadoop之上开发的(我们将在“[后MapReduce时代](#后MapReduce时代)”中看到其中的一些)。有两种处理模型,SQL和MapReduce,还不够,需要更多不同的模型!而且由于Hadoop平台的开放性,实施一整套方法是可行的,而这在整体MPP数据库的范围内是不可能的【58】。
|
||||
|
||||
至关重要的是,这些不同的处理模型都可以在一个共享的机器上运行,所有这些机器都可以访问分布式文件系统上的相同文件。在Hadoop方法中,不需要将数据导入到几个不同的专用系统中进行不同类型的处理:系统足够灵活,可以支持同一个群集内不同的工作负载。不需要移动数据使得从数据中获得价值变得容易得多,并且使用新的处理模型更容易进行实验。
|
||||
|
||||
Hadoop生态系统包括随机访问的OLTP数据库,如HBase(请参阅第70页的“SSTables和LSM-Trees”)和MPA样式的分析数据库,如Impala [41]。 HBase和Impala都不使用MapReduce,但都使用HDFS进行存储。它们是访问和处理数据的非常不同的方法,但是它们可以共存并被集成到同一个系统中。
|
||||
Hadoop生态系统包括随机访问的OLTP数据库,如HBase(参阅“[SSTables和LSM树](ch3.md#SSTables和LSM树)”)和MPA样式的分析数据库,如Impala 【41】。 HBase和Impala都不使用MapReduce,但都使用HDFS进行存储。它们是访问和处理数据的非常不同的方法,但是它们可以共存并被集成到同一个系统中。
|
||||
|
||||
#### 为频繁的故障而设计
|
||||
|
||||
在比较MapReduce和MPP数据库时,设计方法的另外两个不同点是:处理故障和使用内存和磁盘。与在线系统相比,批处理对故障不太敏感,因为如果失败,用户不会立即影响用户,并且可以再次运行。
|
||||
|
||||
如果一个节点在执行查询时崩溃,大多数MPP数据库会中止整个查询,并让用户重新提交查询或自动重新运行它[3]。由于查询通常最多运行几秒钟或几分钟,所以这种处理错误的方法是可以接受的,因为重试的代价不是太大。 MPP数据库还倾向于在内存中保留尽可能多的数据(例如,使用散列连接)以避免从磁盘读取的成本。
|
||||
如果一个节点在执行查询时崩溃,大多数MPP数据库会中止整个查询,并让用户重新提交查询或自动重新运行它【3】。由于查询通常最多运行几秒钟或几分钟,所以这种处理错误的方法是可以接受的,因为重试的代价不是太大。 MPP数据库还倾向于在内存中保留尽可能多的数据(例如,使用散列连接)以避免从磁盘读取的成本。
|
||||
|
||||
另一方面,MapReduce可以容忍映射或减少任务的失败,而不会影响作业的整体,通过以单个任务的粒度重试工作。它也非常渴望将数据写入磁盘,一方面是为了容错,另一方面是假设数据集太大而不能适应内存。
|
||||
|
||||
MapReduce方法更适用于较大的作业:处理如此之多的数据并运行很长时间的作业,以至于在此过程中可能至少遇到一个任务故障。在这种情况下,由于单个任务失败而重新运行整个工作将是浪费的。即使以单个任务的粒度进行恢复引入了使得无故障处理更慢的开销,但如果任务失败率足够高,仍然可以进行合理的权衡。
|
||||
|
||||
但是这些假设有多现实呢?在大多数集群中,机器故障确实发生,但是它们不是很频繁 - 可能很少,大多数工作都不会经验,因为机器故障。为了容错,真的值得引起重大的开销吗?
|
||||
但是这些假设有多现实呢?在大多数集群中,机器故障确实发生,但是它们不是很频繁 —— 可能很少,大多数工作都不会经验,因为机器故障。为了容错,真的值得引起重大的开销吗?
|
||||
|
||||
要了解MapReduce节省使用内存和任务级恢复的原因,查看最初设计MapReduce的环境是很有帮助的。 Google拥有混合使用的数据中心,在线生产服务和离线批处理作业在同一台机器上运行。每个任务都有一个使用容器执行的资源分配(CPU核心,RAM,磁盘空间等)。每个任务也具有优先级,如果优先级较高的任务需要更多的资源,则可以终止(抢占)同一台机器上较低优先级的任务以释放资源。优先级还决定了计算资源的定价:团队必须为他们使用的资源付费,而优先级更高的流程花费更多[59]。
|
||||
要了解MapReduce节省使用内存和任务级恢复的原因,查看最初设计MapReduce的环境是很有帮助的。 Google拥有混合使用的数据中心,在线生产服务和离线批处理作业在同一台机器上运行。每个任务都有一个使用容器执行的资源分配(CPU核心,RAM,磁盘空间等)。每个任务也具有优先级,如果优先级较高的任务需要更多的资源,则可以终止(抢占)同一台机器上较低优先级的任务以释放资源。优先级还决定了计算资源的定价:团队必须为他们使用的资源付费,而优先级更高的流程花费更多【59】。
|
||||
|
||||
这种架构允许非生产(低优先级)计算资源被过度使用,因为系统知道如果必要的话它可以回收资源。与分离生产和非生产任务的系统相比,过度使用资源可以更好地利用机器和提高效率。但是,由于MapReduce作业以低优先级运行,因此它们随时都有被抢占的风险,因为优先级较高的进程需要其资源。批量工作有效地“拿起桌子下面的碎片”,利用高优先级进程已经采取的任何计算资源。
|
||||
|
||||
在谷歌,运行一个小时的MapReduce任务有大约5%被终止的风险,为更高优先级的进程腾出空间。由于硬件问题,机器重新启动或其他原因,这个速率比故障率高出一个数量级[59]。按照这种抢先率,如果一个作业有100个任务,每个任务运行10分钟,那么至少有一个任务在完成之前将被终止的风险大于50%。
|
||||
在谷歌,运行一个小时的MapReduce任务有大约5%被终止的风险,为更高优先级的进程腾出空间。由于硬件问题,机器重新启动或其他原因,这个速率比故障率高出一个数量级【59】。按照这种抢先率,如果一个作业有100个任务,每个任务运行10分钟,那么至少有一个任务在完成之前将被终止的风险大于50%。
|
||||
|
||||
这就是为什么MapReduce能够容忍频繁意外的任务终止的原因:这不是因为硬件特别不可靠,这是因为任意终止进程的自由可以在计算集群中更好地利用资源。
|
||||
|
||||
在开源的集群调度器中,抢占的使用较少。 YARN的CapacityScheduler支持抢占以平衡不同队列的资源分配[58],但在编写本文时,YARN,Mesos或Kubernetes不支持通用优先级抢占[60]。在任务不经常被终止的环境中,MapReduce的设计决策没有多少意义。在下一节中,我们将看看MapReduce的一些替代方案,这些替代方案做出了不同的设计决定。
|
||||
在开源的集群调度器中,抢占的使用较少。 YARN的CapacityScheduler支持抢占以平衡不同队列的资源分配【58】,但在编写本文时,YARN,Mesos或Kubernetes不支持通用优先级抢占【60】。在任务不经常被终止的环境中,MapReduce的设计决策没有多少意义。在下一节中,我们将看看MapReduce的一些替代方案,这些替代方案做出了不同的设计决定。
|
||||
|
||||
|
||||
|
||||
@ -526,11 +530,11 @@ MapReduce方法更适用于较大的作业:处理如此之多的数据并运
|
||||
|
||||
虽然MapReduce在二十世纪二十年代后期变得非常流行并受到大量的炒作,但它只是分布式系统的许多可能的编程模型之一。根据数据量,数据结构和处理类型,其他工具可能更适合表达计算。
|
||||
|
||||
尽管如此,我们在讨论MapReduce的这一章花了很多时间,因为它是一个有用的学习工具,因为它是分布式文件系统的一个相当清晰和简单的抽象。也就是说,能够理解它在做什么,而不是在易于使用的意义上是简单的。恰恰相反:使用原始的MapReduce API来实现复杂的处理工作实际上是非常困难和费力的 - 例如,您需要从头开始实现任何连接算法[37]。
|
||||
尽管如此,我们在讨论MapReduce的这一章花了很多时间,因为它是一个有用的学习工具,因为它是分布式文件系统的一个相当清晰和简单的抽象。也就是说,能够理解它在做什么,而不是在易于使用的意义上是简单的。恰恰相反:使用原始的MapReduce API来实现复杂的处理工作实际上是非常困难和费力的 —— 例如,你需要从头开始实现任何连接算法【37】。
|
||||
|
||||
针对直接使用MapReduce的困难,在MapReduce上创建了各种更高级的编程模型(Pig,Hive,Cascading,Crunch)作为抽象。如果您了解MapReduce的工作原理,那么它们相当容易学习,而且它们的高级构造使许多常见的批处理任务更容易实现。
|
||||
针对直接使用MapReduce的困难,在MapReduce上创建了各种更高级的编程模型(Pig,Hive,Cascading,Crunch)作为抽象。如果你了解MapReduce的工作原理,那么它们相当容易学习,而且它们的高级构造使许多常见的批处理任务更容易实现。
|
||||
|
||||
但是,MapReduce执行模型本身也存在一些问题,这些问题并没有通过增加另一个抽象层次来解决,而且在某些类型的处理中表现得很差。一方面,MapReduce非常强大:您可以使用它来处理频繁任务终止的不可靠多租户系统上几乎任意大量的数据,并且仍然可以完成工作(虽然速度很慢)。另一方面,对于某些类型的处理来说,其他工具有时也会更快。
|
||||
但是,MapReduce执行模型本身也存在一些问题,这些问题并没有通过增加另一个抽象层次来解决,而且在某些类型的处理中表现得很差。一方面,MapReduce非常强大:你可以使用它来处理频繁任务终止的不可靠多租户系统上几乎任意大量的数据,并且仍然可以完成工作(虽然速度很慢)。另一方面,对于某些类型的处理来说,其他工具有时也会更快。
|
||||
|
||||
在本章的其余部分中,我们将介绍一些批处理方法。在第十一章我们将转向流处理,这可以看作是加速批处理的另一种方法。
|
||||
|
||||
@ -538,44 +542,44 @@ MapReduce方法更适用于较大的作业:处理如此之多的数据并运
|
||||
|
||||
如前所述,每个MapReduce作业都独立于其他任何作业。作业与世界其他地方的主要联系点是分布式文件系统上的输入和输出目录。如果希望一个作业的输出成为第二个作业的输入,则需要将第二个作业的输入目录配置为与第一个作业的输出目录相同,并且外部工作流调度程序必须仅在第一份工作已经完成。
|
||||
|
||||
如果第一个作业的输出是要在组织内广泛发布的数据集,则此设置是合理的。在这种情况下,您需要能够通过名称来引用它,并将其用作多个不同作业(包括由其他团队开发的作业)的输入。将数据发布到分布式文件系统中的众所周知的位置允许松耦合,这样作业就不需要知道是谁在输入输出或消耗其输出(请参阅“分离逻辑和布线”在本页395)。
|
||||
如果第一个作业的输出是要在组织内广泛发布的数据集,则此设置是合理的。在这种情况下,你需要能够通过名称来引用它,并将其用作多个不同作业(包括由其他团队开发的作业)的输入。将数据发布到分布式文件系统中的众所周知的位置允许松耦合,这样作业就不需要知道是谁在输入输出或消耗其输出(参阅“[分离逻辑和布线](#分离逻辑和布线)”在本页395)。
|
||||
|
||||
但是,在很多情况下,您知道一个工作的输出只能用作另一个工作的输入,这个工作由同一个团队维护。在这种情况下,分布式文件系统上的文件只是简单的中间状态:一种将数据从一个作业传递到下一个作业的方式。在用于构建由50或100个MapReduce作业[29]组成的推荐系统的复杂工作流程中,存在很多这样的中间状态。
|
||||
但是,在很多情况下,你知道一个工作的输出只能用作另一个工作的输入,这个工作由同一个团队维护。在这种情况下,分布式文件系统上的文件只是简单的中间状态:一种将数据从一个作业传递到下一个作业的方式。在用于构建由50或100个MapReduce作业【29】组成的推荐系统的复杂工作流程中,存在很多这样的中间状态。
|
||||
|
||||
将这个中间状态写入文件的过程称为物化。 (我们在第101页的“聚合:数据立方体和物化视图”中已经在物化视图的背景下遇到了这个术语。它意味着要急于计算某个操作的结果并写出来,而不是计算需要时按要求。)
|
||||
将这个中间状态写入文件的过程称为物化。 (我们在“[聚合:数据立方体和物化视图](ch2.md#聚合:数据立方体和物化视图)”中已经在物化视图的背景下遇到了这个术语。它意味着要急于计算某个操作的结果并写出来,而不是计算需要时按要求。)
|
||||
|
||||
相反,本章开头的日志分析示例使用Unix管道将一个命令的输出与另一个命令的输出连接起来。管道并没有完全实现中间状态,而是只使用一个小的内存缓冲区,将输出逐渐流向输入。
|
||||
|
||||
MapReduce的完全实现中间状态的方法与Unix管道相比存在不足:
|
||||
|
||||
* MapReduce作业只有在前面的作业(生成其输入)中的所有任务都完成时才能启动,而由Unix管道连接的进程同时启动,输出一旦生成就会被使用。不同机器上的偏差或不同的负荷意味着一份工作往往会有一些比其他人更快完成的离散任务。必须等到所有前面的工作完成才能减慢整个工作流程的执行。
|
||||
* 映射器通常是多余的:它们只读取刚刚由reducer写入的相同文件,并为下一个分区和排序阶段做好准备。在许多情况下,映射器代码可能是以前的reducer的一部分:如果reducer输出被分区和排序的方式与mapper输出相同,那么reducers可以直接链接在一起,而不与mapper阶段交错。
|
||||
* Mapper通常是多余的:它们只读取刚刚由reducer写入的相同文件,并为下一个分区和排序阶段做好准备。在许多情况下,Mapper代码可能是以前的reducer的一部分:如果reducer输出被分区和排序的方式与mapper输出相同,那么reducers可以直接链接在一起,而不与mapper阶段交错。
|
||||
* 将中间状态存储在分布式文件系统中意味着这些文件被复制到多个节点,这对于这样的临时数据通常是过度的。
|
||||
|
||||
#### 数据流引擎
|
||||
|
||||
了解决MapReduce的这些问题,开发了几种用于分布式批量计算的新的执行引擎,其中最着名的是Spark [61,62],Tez [63,64]和Flink [65,66]。他们设计的方式有很多不同之处,但他们有一个共同点:他们把整个工作流作为一项工作来处理,而不是把它分解成独立的子作业。
|
||||
了解决MapReduce的这些问题,开发了几种用于分布式批量计算的新的执行引擎,其中最着名的是Spark 【61,62】,Tez 【63,64】和Flink 【65,66】。他们设计的方式有很多不同之处,但他们有一个共同点:他们把整个工作流作为一项工作来处理,而不是把它分解成独立的子作业。
|
||||
|
||||
由于它们通过几个处理阶段明确地建模数据流,所以这些系统被称为数据流引擎。像MapReduce一样,它们通过反复调用用户定义的函数来在单个线程上一次处理一条记录。他们通过对输入进行分区来并行工作,并将一个功能的输出复制到网络上,成为另一个功能的输入。
|
||||
|
||||
与MapReduce不同,这些功能不需要交替映射和缩减的严格角色,而是可以以更灵活的方式进行组合。我们称之为这些函数操作符,数据流引擎提供了几个不同的选项来连接一个操作符的输出到另一个的输入:
|
||||
与MapReduce不同,这些功能不需要交替映射和Reduce的严格角色,而是可以以更灵活的方式进行组合。我们称之为这些函数操作符,数据流引擎提供了几个不同的选项来连接一个操作符的输出到另一个的输入:
|
||||
|
||||
* 一个选项是通过键对记录进行重新分区和排序,就像在MapReduce的混洗阶段一样(请参阅“分布式执行MapReduce”)。此功能可以像在MapReduce中一样启用排序合并连接和分组。
|
||||
* 一个选项是通过键对记录进行重新分区和排序,就像在MapReduce的混洗阶段一样(参阅“[分布式执行MapReduce](#分布式执行MapReduce)”)。此功能可以像在MapReduce中一样启用排序合并连接和分组。
|
||||
* 另一种可能是采取几个输入,并以相同的方式进行分区,但跳过排序。这节省了分区散列连接的工作,其中记录的分区是重要的,但顺序是不相关的,因为构建散列表随机化了顺序。
|
||||
* 对于广播散列连接,可以将一个运算符的相同输出发送到连接运算符的所有分区。
|
||||
|
||||
这种处理引擎的风格基于像Dryad [67]和Nephele [68]这样的研究系统,与MapReduce模型相比,它提供了几个优点:
|
||||
这种处理引擎的风格基于像Dryad 【67】和Nephele 【68】这样的研究系统,与MapReduce模型相比,它提供了几个优点:
|
||||
|
||||
* 排序等昂贵的工作只需要在实际需要的地方执行,而不是在每个Map和Reduce阶段之间默认发生。
|
||||
* 没有不必要的地图任务,因为映射器所做的工作通常可以合并到前面的reduce操作器中(因为映射器不会更改数据集的分区)。
|
||||
* 没有不必要的Map任务,因为Mapper所做的工作通常可以合并到前面的reduce操作器中(因为Mapper不会更改数据集的分区)。
|
||||
* 由于工作流程中的所有连接和数据依赖性都是明确声明的,因此调度程序会概述哪些数据是必需的,因此可以进行本地优化。例如,它可以尝试将占用某些数据的任务放在与生成它的任务相同的机器上,以便可以通过共享内存缓冲区交换数据,而不必通过网络复制数据。
|
||||
* 通常将操作员之间的中间状态保存在内存中或写入本地磁盘就足够了,这比写入HDFS需要更少的I / O(必须将其复制到多个计算机并写入到每个代理的磁盘上)。 MapReduce已经将这种优化用于映射器的输出,但是数据流引擎将该思想推广到了所有的中间状态。
|
||||
* 通常将操作员之间的中间状态保存在内存中或写入本地磁盘就足够了,这比写入HDFS需要更少的I/O(必须将其复制到多个计算机并写入到每个代理的磁盘上)。 MapReduce已经将这种优化用于Mapper的输出,但是数据流引擎将该思想推广到了所有的中间状态。
|
||||
* 操作员可以在输入准备就绪后立即开始执行;在下一个开始之前不需要等待整个前一阶段的完成。
|
||||
* 与MapReduce(为每个任务启动一个新的JVM)相比,现有的Java虚拟机(JVM)进程可以重用来运行新操作,从而减少启动开销。
|
||||
|
||||
您可以使用数据流引擎来执行与MapReduce工作流相同的计算,并且由于此处所述的优化,通常执行速度会明显更快。既然操作符是map和reduce的泛化,相同的处理代码可以在任一执行引擎上运行:Pig,Hive或Cascading中实现的工作流可以通过简单的配置更改从MapReduce切换到Tez或Spark,而无需修改代码[64]。
|
||||
你可以使用数据流引擎来执行与MapReduce工作流相同的计算,并且由于此处所述的优化,通常执行速度会明显更快。既然操作符是map和reduce的泛化,相同的处理代码可以在任一执行引擎上运行:Pig,Hive或Cascading中实现的工作流可以通过简单的配置更改从MapReduce切换到Tez或Spark,而无需修改代码【64】。
|
||||
|
||||
Tez是一个相当薄的库,它依赖于YARN shuffle服务来实现节点间数据的实际复制[58],而Spark和Flink则是包含自己的网络通信层,调度器和面向用户的API的大型框架。我们将在短期内讨论这些高级API。
|
||||
Tez是一个相当薄的库,它依赖于YARN shuffle服务来实现节点间数据的实际复制【58】,而Spark和Flink则是包含自己的网络通信层,调度器和面向用户的API的大型框架。我们将在短期内讨论这些高级API。
|
||||
|
||||
#### 容错
|
||||
|
||||
@ -583,7 +587,7 @@ Tez是一个相当薄的库,它依赖于YARN shuffle服务来实现节点间
|
||||
|
||||
Spark,Flink和Tez避免将中间状态写入HDFS,因此他们采取了不同的方法来容忍错误:如果一台机器发生故障,并且该机器上的中间状态丢失,则会从其他仍然可用的数据重新计算在可能的情况下是在先的中间阶段,或者是通常在HDFS上的原始输入数据)。
|
||||
|
||||
为了实现这个重新计算,框架必须跟踪一个给定的数据是如何计算的 - 使用哪个输入分区,以及哪个操作符被应用到它。 Spark使用弹性分布式数据集(RDD)抽象来追踪数据的祖先[61],而Flink检查点操作符状态,允许其恢复运行在执行过程中遇到错误的操作符[66]。
|
||||
为了实现这个重新计算,框架必须跟踪一个给定的数据是如何计算的 —— 使用哪个输入分区,以及哪个操作符被应用到它。 Spark使用弹性分布式数据集(RDD)抽象来追踪数据的祖先【61】,而Flink检查点操作符状态,允许其恢复运行在执行过程中遇到错误的操作符【66】。
|
||||
|
||||
在重新计算数据时,重要的是要知道计算是否是确定性的:也就是说,给定相同的输入数据,操作员是否始终生成相同的输出?如果一些丢失的数据已经发送给下游运营商,这个问题就很重要。如果运营商重新启动,重新计算的数据与原有的丢失数据不一致,下游运营商很难解决新旧数据之间的矛盾。对于不确定性运营商来说,解决方案通常是杀死下游运营商,然后再运行新数据。
|
||||
|
||||
@ -595,19 +599,19 @@ Spark,Flink和Tez避免将中间状态写入HDFS,因此他们采取了不同
|
||||
|
||||
回到Unix的类比,我们看到MapReduce就像是将每个命令的输出写入临时文件,而数据流引擎看起来更像是Unix管道。尤其是Flink是围绕流水线执行的思想而建立的:也就是说,将运算符的输出递增地传递给其他操作符,并且在开始处理之前不等待输入完成。
|
||||
|
||||
排序操作不可避免地需要消耗其整个输入,然后才能生成任何输出,因为最后一个输入记录可能是具有最低密钥的输入记录,因此需要作为第一个输出记录。任何需要分类的操作员都需要至少暂时地累积状态。但是工作流程的许多其他部分可以以流水线方式执行。
|
||||
排序操作不可避免地需要消耗其整个输入,然后才能生成任何输出,因为最后一个输入记录可能是具有最低键的输入记录,因此需要作为第一个输出记录。任何需要分类的操作员都需要至少暂时地累积状态。但是工作流程的许多其他部分可以以流水线方式执行。
|
||||
|
||||
当作业完成时,它的输出需要持续到某个地方,以便用户可以找到并使用它 - 很可能它会再次写入分布式文件系统。因此,在使用数据流引擎时,HDFS上的物化数据集通常仍是作业的输入和最终输出。和MapReduce一样,输入是不可变的,输出被完全替换。对MapReduce的改进是,您可以节省自己将所有中间状态写入文件系统。
|
||||
当作业完成时,它的输出需要持续到某个地方,以便用户可以找到并使用它—— 很可能它会再次写入分布式文件系统。因此,在使用数据流引擎时,HDFS上的物化数据集通常仍是作业的输入和最终输出。和MapReduce一样,输入是不可变的,输出被完全替换。对MapReduce的改进是,你可以节省自己将所有中间状态写入文件系统。
|
||||
|
||||
### 图与迭代处理
|
||||
|
||||
在第49页上的“类似图形的数据模型”中,我们讨论了使用图形来建模数据,并使用图形查询语言来遍历图形中的边和顶点。第2章的讨论集中在OLTP风格的使用上:快速执行查询来查找少量符合特定条件的顶点。
|
||||
在“[图数据模型](ch2.md#图数据模型)”中,我们讨论了使用图形来建模数据,并使用图形查询语言来遍历图形中的边和顶点。[第2章](ch2.md)的讨论集中在OLTP风格的使用上:快速执行查询来查找少量符合特定条件的顶点。
|
||||
|
||||
在批处理环境中查看图表也很有趣,其目标是在整个图表上执行某种离线处理或分析。这种需求经常出现在机器学习应用程序(如推荐引擎)或排序系统中。例如,最着名的图形分析算法之一是PageRank [69],它试图根据其他网页链接的网页来估计网页的流行度。它被用作确定网络搜索引擎呈现结果的顺序的公式的一部分。
|
||||
在批处理环境中查看图表也很有趣,其目标是在整个图表上执行某种离线处理或分析。这种需求经常出现在机器学习应用程序(如推荐引擎)或排序系统中。例如,最着名的图形分析算法之一是PageRank 【69】,它试图根据其他网页链接的网页来估计网页的流行度。它被用作确定网络搜索引擎呈现结果的顺序的公式的一部分。
|
||||
|
||||
> 像Spark,Flink和Tez这样的数据流引擎(参见第419页“中间状态的实现化”)通常将操作符作为有向无环图(DAG)排列在作业中。这与图形处理不一样:在数据流引擎中,从一个操作符到另一个操作符的数据流被构造成一个图,而数据本身通常由关系式元组构成。在图形处理中,数据本身具有图形的形式。另一个不幸的命名混乱!
|
||||
> 像Spark,Flink和Tez这样的数据流引擎(参见“[中间状态的物化](#中间状态的物化)”)通常将操作符作为**有向无环图(DAG)**排列在作业中。这与图形处理不一样:在数据流引擎中,从一个操作符到另一个操作符的数据流被构造成一个图,而数据本身通常由关系式元组构成。在图形处理中,数据本身具有图形的形式。另一个不幸的命名混乱!
|
||||
|
||||
许多图算法是通过一次遍历一个边来表示的,将一个顶点与相邻的顶点连接起来以便传播一些信息,并且重复直到满足一些条件为止 - 例如,直到没有更多的边要跟随,或者直到一些度量收敛。我们在图2-6中看到一个例子,它通过重复地跟踪指示哪个位置在哪个其他位置(这种算法被称为传递闭包)的边缘,列出了包含在数据库中的北美所有位置。
|
||||
许多图算法是通过一次遍历一个边来表示的,将一个顶点与相邻的顶点连接起来以便传播一些信息,并且重复直到满足一些条件为止——例如,直到没有更多的边要跟随,或者直到一些度量收敛。我们在[图2-6](img/fig2-6.png)中看到一个例子,它通过重复地跟踪指示哪个位置在哪个其他位置(这种算法被称为传递闭包)的边缘,列出了包含在数据库中的北美所有位置。
|
||||
|
||||
可以在分布式文件系统(包含顶点和边的列表的文件)中存储图形,但是这种“重复直到完成”的想法不能用普通的MapReduce来表示,因为它只执行一次数据传递。这种算法因此经常以迭代方式实现:
|
||||
|
||||
@ -618,20 +622,20 @@ Spark,Flink和Tez避免将中间状态写入HDFS,因此他们采取了不同
|
||||
这种方法是有效的,但是用MapReduce实现它往往是非常低效的,因为MapReduce没有考虑算法的迭代性质:它总是读取整个输入数据集并产生一个全新的输出数据集,即使只有一小部分该图与上次迭代相比已经改变。
|
||||
Pregel处理模型
|
||||
|
||||
作为批处理图形的优化,计算的批量同步并行(BSP)模型[70]已经流行起来。其中,它由Apache Giraph [37],Spark的GraphX API和Flink的Gelly API [71]实现。它也被称为Pregel模型,正如Google的Pregel论文推广这种处理图的方法[72]。
|
||||
作为批处理图形的优化,计算的批量同步并行(BSP)模型【70】已经流行起来。其中,它由Apache Giraph 【37】,Spark的GraphX API和Flink的Gelly API 【71】实现。它也被称为Pregel模型,正如Google的Pregel论文推广这种处理图的方法【72】。
|
||||
|
||||
回想一下在MapReduce中,映射器在概念上“发送消息”给reducer的特定调用,因为框架将所有的mapper输出集中在一起。 Pregel背后有一个类似的想法:一个顶点可以“发送消息”到另一个顶点,通常这些消息沿着图的边被发送。
|
||||
回想一下在MapReduce中,Mapper在概念上“发送消息”给reducer的特定调用,因为框架将所有的mapper输出集中在一起。 Pregel背后有一个类似的想法:一个顶点可以“发送消息”到另一个顶点,通常这些消息沿着图的边被发送。
|
||||
|
||||
在每次迭代中,为每个顶点调用一个函数,将所有发送给它的消息传递给它 - 就像调用reducer一样。与MapReduce的不同之处在于,在Pregel模型中,顶点从一次迭代到下一次迭代记忆它的状态,所以这个函数只需要处理新的传入消息。如果在图的某个部分没有发送消息,则不需要做任何工作。
|
||||
在每次迭代中,为每个顶点调用一个函数,将所有发送给它的消息传递给它 —— 就像调用reducer一样。与MapReduce的不同之处在于,在Pregel模型中,顶点从一次迭代到下一次迭代记忆它的状态,所以这个函数只需要处理新的传入消息。如果在图的某个部分没有发送消息,则不需要做任何工作。
|
||||
|
||||
这与演员模型有些相似(请参阅第130页上的“分布式演员框架”),除非顶点状态和顶点之间的消息具有容错性和耐久性,并且通信以固定的方式进行,否则将每个顶点视为主角轮次:在每一次迭代中,框架传递在前一次迭代中发送的所有消息。演员通常没有这样的时间保证。
|
||||
这与演员模型有些相似(参阅“[分布式的Actor框架](ch4.md#分布式的Actor框架)”),除非顶点状态和顶点之间的消息具有容错性和耐久性,并且通信以固定的方式进行,否则将每个顶点视为主角轮次:在每一次迭代中,框架传递在前一次迭代中发送的所有消息。演员通常没有这样的时间保证。
|
||||
|
||||
#### 容错
|
||||
|
||||
顶点只能通过消息传递进行通信(而不是直接相互查询)的事实有助于提高Pregel作业的性能,因为消息可以成批处理,而且等待通信的次数也减少了。唯一的等待是在迭代之间:由于Pregel模型保证所有在一次迭代中发送的消息都在下一次迭代中传递,所以先前的迭代必须完全完成,并且所有的消息必须在网络上复制,然后下一个开始。
|
||||
即使底层网络可能丢失,重复或任意延迟消息(请参阅第267页上的“不可靠网络”),Pregel实施可保证在接下来的迭代中消息在其目标顶点处理一次。像MapReduce一样,该框架透明地从故障中恢复,以简化Pregel顶层算法的编程模型。
|
||||
即使底层网络可能丢失,重复或任意延迟消息(参阅“[不可靠的网络](ch8.md#不可靠的网络)”),Pregel实施可保证在接下来的迭代中消息在其目标顶点处理一次。像MapReduce一样,该框架透明地从故障中恢复,以简化Pregel顶层算法的编程模型。
|
||||
|
||||
这种容错是通过在迭代结束时定期检查所有顶点的状态来实现的,即将其全部状态写入持久存储。如果某个节点发生故障并且其内存中状态丢失,则最简单的解决方法是将整个图计算回滚到上一个检查点,然后重新启动计算。如果算法是确定性的并且记录了消息,那么也可以选择性地只恢复丢失的分区(就像我们之前讨论过的数据流引擎)[72]。
|
||||
这种容错是通过在迭代结束时定期检查所有顶点的状态来实现的,即将其全部状态写入持久存储。如果某个节点发生故障并且其内存中状态丢失,则最简单的解决方法是将整个图计算回滚到上一个检查点,然后重新启动计算。如果算法是确定性的并且记录了消息,那么也可以选择性地只恢复丢失的分区(就像我们之前讨论过的数据流引擎)【72】。
|
||||
|
||||
#### 并行执行
|
||||
|
||||
@ -641,7 +645,7 @@ Pregel处理模型
|
||||
|
||||
因此,图算法通常会有很多跨机器通信,而中间状态(节点之间发送的消息)往往比原始图大。通过网络发送消息的开销会显着减慢分布式图算法的速度。
|
||||
|
||||
出于这个原因,如果你的图可以放在一台计算机的内存中,那么单机(甚至可能是单线程)算法很可能会超越分布式批处理[73,74]。即使图形大于内存,也可以放在单个计算机的磁盘上,使用GraphChi等框架进行单机处理是一个可行的选择[75]。如果图形太大而不适合单个机器,像Pregel这样的分布式方法是不可避免的。有效的并行化图算法是一个正在进行的领域。
|
||||
出于这个原因,如果你的图可以放在一台计算机的内存中,那么单机(甚至可能是单线程)算法很可能会超越分布式批处理【73,74】。即使图形大于内存,也可以放在单个计算机的磁盘上,使用GraphChi等框架进行单机处理是一个可行的选择【75】。如果图形太大而不适合单个机器,像Pregel这样的分布式方法是不可避免的。有效的并行化图算法是一个正在进行的领域。
|
||||
|
||||
|
||||
|
||||
@ -649,27 +653,27 @@ Pregel处理模型
|
||||
|
||||
自MapReduce第一次流行以来,分布式批处理的执行引擎已经成熟。到目前为止,基础设施已经足够强大,能够存储和处理超过10,000台机器群集上的数PB的数据。由于在这种规模下物理操作批处理过程的问题已经或多或少得到了解决,所以已经转向其他领域:改进编程模型,提高处理效率,扩大这些技术可以解决的问题集。
|
||||
|
||||
如前所述,Hive,Pig,Cascading和Crunch等高级语言和API由于手工编写MapReduce作业而变得非常流行。随着Tez的出现,这些高级语言还有额外的好处,可以移动到新的数据流执行引擎,而无需重写作业代码。 Spark和Flink也包括他们自己的高级数据流API,经常从FlumeJava中获得灵感[34]。
|
||||
如前所述,Hive,Pig,Cascading和Crunch等高级语言和API由于手工编写MapReduce作业而变得非常流行。随着Tez的出现,这些高级语言还有额外的好处,可以移动到新的数据流执行引擎,而无需重写作业代码。 Spark和Flink也包括他们自己的高级数据流API,经常从FlumeJava中获得灵感【34】。
|
||||
|
||||
这些数据流API通常使用关系式构建块来表达一个计算:连接数据集以获取某个字段的值;按键分组元组;过滤一些条件;并通过计数,求和或其他函数来聚合元组。在内部,这些操作是使用本章前面讨论过的各种连接和分组算法来实现的。
|
||||
|
||||
除了需要较少代码的明显优势之外,这些高级接口还允许交互式使用,在这种交互式使用中,您可以将分析代码逐步编写到shell中并经常运行,以观察它正在做什么。这种发展风格在探索数据集和试验处理方法时非常有用。这也让人联想到Unix哲学,我们在第394页的“Unix哲学”中讨论过这个问题。
|
||||
除了需要较少代码的明显优势之外,这些高级接口还允许交互式使用,在这种交互式使用中,你可以将分析代码逐步编写到shell中并经常运行,以观察它正在做什么。这种发展风格在探索数据集和试验处理方法时非常有用。这也让人联想到Unix哲学,我们在第394页的“Unix哲学”中讨论过这个问题。
|
||||
|
||||
而且,这些高级接口不仅使人类使用系统的效率更高,而且提高了机器级别的工作执行效率。
|
||||
|
||||
#### 向声明式查询语言的转变
|
||||
|
||||
与拼写执行连接的代码相比,指定连接为关系运算符的优点是,框架可以分析连接输入的属性,并自动决定哪个上述连接算法最适合手头的任务。 Hive,Spark和Flink都有基于代价的查询优化器,可以做到这一点,甚至可以改变连接顺序,使中间状态的数量最小化[66,77,78,79]。
|
||||
与拼写执行连接的代码相比,指定连接为关系运算符的优点是,框架可以分析连接输入的属性,并自动决定哪个上述连接算法最适合手头的任务。 Hive,Spark和Flink都有基于代价的查询优化器,可以做到这一点,甚至可以改变连接顺序,使中间状态的数量最小化【66,77,78,79】。
|
||||
|
||||
连接算法的选择可以对批处理作业的性能产生很大的影响,不必理解和记住本章中讨论的各种连接算法。如果以声明的方式指定连接,则这是可能的:应用程序简单地说明哪些连接是必需的,查询优化器决定如何最好地执行连接。我们以前在第42页的“数据的查询语言”中遇到了这个想法。
|
||||
|
||||
但是,在其他方面,MapReduce及其数据流后继与SQL的完全声明性查询模型有很大不同。 MapReduce是围绕函数回调的思想构建的:对于每个记录或者一组记录,调用一个用户定义的函数(mapper或reducer),并且该函数可以自由地调用任意代码来决定输出什么。这种方法的优点是可以绘制在现有库的大型生态系统上进行分析,自然语言分析,图像分析以及运行数字或统计算法等。
|
||||
|
||||
轻松运行任意代码的自由是从MPP数据库(参见“比较Hadoop到分布式数据库”一节,第414页)中分离出来的MapReduce传统批处理系统。虽然数据库具有编写用户定义函数的功能,但是它们通常使用起来很麻烦,而且与大多数编程语言中广泛使用的程序包管理器和依赖管理系统(例如Maven for Java,npm for Java-Script,和Ruby的Ruby的Ruby)。
|
||||
轻松运行任意代码的自由是从MPP数据库(参见“[比较Hadoop和分布式数据库](#比较Hadoop和分布式数据库)”一节)中分离出来的MapReduce传统批处理系统。虽然数据库具有编写用户定义函数的功能,但是它们通常使用起来很麻烦,而且与大多数编程语言中广泛使用的程序包管理器和依赖管理系统(例如Maven for Java,npm for Java-Script,和Ruby的Ruby的Ruby)。
|
||||
|
||||
但是,数据流引擎已经发现,除了连接之外,在合并更多的声明性特征方面也是有优势的。例如,如果一个回调函数只包含一个简单的过滤条件,或者只是从一条记录中选择了一些字段,那么在调用每条记录的函数时会有相当大的CPU开销。如果以声明方式表示这样简单的过滤和映射操作,那么查询优化器可以利用面向列的存储布局(请参阅第95页的“面向列的存储”),并从磁盘只读取所需的列。 Hive,Spark DataFrames和Impala也使用向量化执行(请参阅第99页的“内存带宽和向量化处理”):在对CPU缓存很友好的内部循环中迭代数据,并避免函数调用。
|
||||
但是,数据流引擎已经发现,除了连接之外,在合并更多的声明性特征方面也是有优势的。例如,如果一个回调函数只包含一个简单的过滤条件,或者只是从一条记录中选择了一些字段,那么在调用每条记录的函数时会有相当大的CPU开销。如果以声明方式表示这样简单的过滤和映射操作,那么查询优化器可以利用面向列的存储布局(参阅“[列存储](ch3.md#列存储)”),并从磁盘只读取所需的列。 Hive,Spark DataFrames和Impala也使用向量化执行(参阅“[内存带宽和向量处理](ch3.md#内存带宽和向量处理)”):在对CPU缓存很友好的内部循环中迭代数据,并避免函数调用。
|
||||
|
||||
Spark生成JVM字节码[79],Impala使用LLVM为这些内部循环生成本机代码[41]。
|
||||
Spark生成JVM字节码【79】,Impala使用LLVM为这些内部循环生成本机代码【41】。
|
||||
|
||||
通过将声明性方面与高级API结合起来,并使查询优化器可以在执行期间利用这些优化方法,批处理框架看起来更像MPP数据库(并且可以实现可比较的性能)。同时,通过具有运行任意代码和以任意格式读取数据的可扩展性,它们保持了灵活性的优势。
|
||||
|
||||
@ -677,12 +681,14 @@ Spark生成JVM字节码[79],Impala使用LLVM为这些内部循环生成本机
|
||||
|
||||
尽管能够运行任意代码的可扩展性是有用的,但是在标准处理模式不断重复发生的情况下也有许多常见的情况,所以值得重用通用构建块的实现。传统上,MPP数据库满足了商业智能分析师和业务报告的需求,但这只是许多使用批处理的领域之一。
|
||||
|
||||
另一个越来越重要的领域是统计和数值算法,它们是机器学习应用(如分类和推荐系统)所需要的。可重复使用的实现正在出现:例如,Mahout在MapReduce,Spark和Flink之上实现了用于机器学习的各种算法,而MADlib在关系型MPP数据库(Apache HAWQ)中实现了类似的功能[54]。
|
||||
另一个越来越重要的领域是统计和数值算法,它们是机器学习应用(如分类和推荐系统)所需要的。可重复使用的实现正在出现:例如,Mahout在MapReduce,Spark和Flink之上实现了用于机器学习的各种算法,而MADlib在关系型MPP数据库(Apache HAWQ)中实现了类似的功能【54】。
|
||||
|
||||
空间算法也是有用的,例如k-最近邻居[80],它在一些多维空间中搜索与给定物品接近的物品 - 这是一种类似的搜索。近似搜索对于基因组分析算法也很重要,它们需要找到相似但不相同的字符串[81]。
|
||||
空间算法也是有用的,例如最近邻搜索(kNN)【80】,它在一些多维空间中搜索与给定物品接近的物品 - 这是一种类似的搜索。近似搜索对于基因组分析算法也很重要,它们需要找到相似但不相同的字符串【81】。
|
||||
|
||||
批处理引擎正被用于分布式执行来自日益广泛的领域的算法。随着批处理系统获得内置功能和高级声明性操作符,并且随着MPP数据库变得更加可编程和灵活,两者开始看起来更相似:最终,它们都只是存储和处理数据的系统。
|
||||
|
||||
|
||||
|
||||
## 本章小结
|
||||
|
||||
|
||||
@ -694,8 +700,9 @@ Spark生成JVM字节码[79],Impala使用LLVM为这些内部循环生成本机
|
||||
|
||||
***分区***
|
||||
|
||||
在MapReduce中,映射器根据输入文件块进行分区。映射器的输出被重新分区,分类并合并到可配置数量的reducer分区中。这个过程的目的是把所有的相关数据 - 例如,所有的记录都放在同一个地方。
|
||||
Post-MapReduce数据流引擎尽量避免排序,除非它是必需的,但它们采取了大致类似的分区方法。
|
||||
在MapReduce中,Mapper根据输入文件块进行分区。Mapper的输出被重新分区,分类并合并到可配置数量的reducer分区中。这个过程的目的是把所有的相关数据 —— 例如,所有的记录都放在同一个地方。
|
||||
|
||||
后MapReduce数据流引擎尽量避免排序,除非它是必需的,但它们采取了大致类似的分区方法。
|
||||
|
||||
***容错***
|
||||
|
||||
@ -705,23 +712,23 @@ MapReduce经常写入磁盘,这使得从单个失败的任务中轻松地恢
|
||||
|
||||
***排序合并连接***
|
||||
|
||||
每个正在连接的输入都通过一个提取连接键的映射器。通过分区,排序和合并,具有相同密钥的所有记录最终都会进入reducer的相同调用。这个函数可以输出连接的记录。
|
||||
每个正在连接的输入都通过一个提取连接键的Mapper。通过分区,排序和合并,具有相同键的所有记录最终都会进入reducer的相同调用。这个函数可以输出连接的记录。
|
||||
|
||||
***广播散列连接***
|
||||
|
||||
两个连接输入之一是小的,所以它没有分区,它可以被完全加载到一个哈希表。因此,您可以为大连接输入的每个分区启动一个映射器,将小输入的散列表加载到每个映射器中,然后一次扫描大输入一条记录,查询每条记录的散列表。
|
||||
两个连接输入之一是小的,所以它没有分区,它可以被完全加载到一个哈希表。因此,你可以为大连接输入的每个分区启动一个Mapper,将小输入的散列表加载到每个Mapper中,然后一次扫描大输入一条记录,查询每条记录的散列表。
|
||||
|
||||
***分区散列连接***
|
||||
|
||||
如果两个连接输入以相同的方式分区(使用相同的密钥,相同的散列函数和相同数量的分区),则可以独立地为每个分区使用散列表方法。
|
||||
如果两个连接输入以相同的方式分区(使用相同的键,相同的散列函数和相同数量的分区),则可以独立地为每个分区使用散列表方法。
|
||||
|
||||
分布式批处理引擎有一个有意限制的编程模型:回调函数(比如映射器和简化器)被认为是无状态的,除了指定的输出外,没有外部可见的副作用。这个限制允许框架隐藏抽象背后的一些硬分布式系统问题:面对崩溃和网络问题,任务可以安全地重试,任何失败任务的输出都被丢弃。如果某个分区的多个任务成功,则只有其中一个实际上使其输出可见。
|
||||
分布式批处理引擎有一个有意限制的编程模型:回调函数(比如Mapper和Reducer)被认为是无状态的,除了指定的输出外,没有外部可见的副作用。这个限制允许框架隐藏抽象背后的一些硬分布式系统问题:面对崩溃和网络问题,任务可以安全地重试,任何失败任务的输出都被丢弃。如果某个分区的多个任务成功,则只有其中一个实际上使其输出可见。
|
||||
|
||||
得益于这个框架,您在批处理作业中的代码无需担心实现容错机制:框架可以保证作业的最终输出与没有发生错误的情况相同,也许不得不重新尝试各种任务。这些可靠的语义要比在线服务处理用户请求时经常使用的要多得多,而且在处理请求的副作用时写入数据库。
|
||||
得益于这个框架,你在批处理作业中的代码无需担心实现容错机制:框架可以保证作业的最终输出与没有发生错误的情况相同,也许不得不重新尝试各种任务。这些可靠的语义要比在线服务处理用户请求时经常使用的要多得多,而且在处理请求的副作用时写入数据库。
|
||||
|
||||
批量处理工作的显着特点是它读取一些输入数据并产生一些输出数据,而不修改输入 - 换句话说,输出是从输入导出的。重要的是,输入数据是有界的:它有一个已知的,固定的大小(例如,它包含一些时间点的日志文件或数据库内容的快照)。因为它是有界的,一个工作知道什么时候它完成了整个输入的读取,所以一个工作最终完成。
|
||||
批量处理工作的显着特点是它读取一些输入数据并产生一些输出数据,而不修改输入—— 换句话说,输出是从输入衍生出的。重要的是,输入数据是有界的:它有一个已知的,固定的大小(例如,它包含一些时间点的日志文件或数据库内容的快照)。因为它是有界的,一个工作知道什么时候它完成了整个输入的读取,所以一个工作最终完成。
|
||||
|
||||
在下一章中,我们将转向流处理,其中的输入是未知的 - 也就是说,你还有一份工作,但是它的输入是永无止境的数据流。在这种情况下,工作永远不会完成,因为在任何时候都可能有更多的工作进来。我们将看到流和批处理在某些方面是相
|
||||
在下一章中,我们将转向流处理,其中的输入是未知的 —— 也就是说,你还有一份工作,但是它的输入是永无止境的数据流。在这种情况下,工作永远不会完成,因为在任何时候都可能有更多的工作进来。我们将看到流和批处理在某些方面是相似的。但是关于无尽数据流的假设,也对我们构建系统的方式产生了很大的改变。
|
||||
|
||||
|
||||
|
||||
|
328
ch11.md
328
ch11.md
@ -10,16 +10,16 @@
|
||||
|
||||
[TOC]
|
||||
|
||||
在第10章中,我们讨论了批处理技术,它将一组文件读取为输入并生成一组新的输出文件。输出是派生数据的一种形式;也就是说,如果需要,可以通过再次运行批处理过程来重新创建数据集。我们看到了如何使用这个简单而强大的想法来创建搜索索引,推荐系统,分析等等。
|
||||
在[第10章](ch10.md)中,我们讨论了批处理技术,它将一组文件读取为输入并生成一组新的输出文件。输出是派生数据的一种形式;也就是说,如果需要,可以通过再次运行批处理过程来重新创建数据集。我们看到了如何使用这个简单而强大的想法来创建搜索索引,推荐系统,分析等等。
|
||||
|
||||
然而,在第10章中仍然有一个大的假设:即输入是有界的,即已知和有限的大小,所以批处理知道它何时完成了它的输入。例如,MapReduce中心的排序操作必须读取其全部输入,然后才能开始生成输出:可能发生最后一个输入记录是具有最低键的输入记录,因此必须是第一个输出记录,所以提前开始输出不是一种选择。
|
||||
然而,在[第10章](ch10.md)中仍然有一个大的假设:即输入是有界的,即已知和有限的大小,所以批处理知道它何时完成了它的输入。例如,MapReduce中心的排序操作必须读取其全部输入,然后才能开始生成输出:可能发生最后一个输入记录是具有最低键的输入记录,因此必须是第一个输出记录,所以提前开始输出不是一种选择。
|
||||
|
||||
实际上,很多数据是无限的,因为它随着时间的推移逐渐到达:你的用户昨天和今天产生了数据,明天他们将继续产生更多的数据。除非您停业,否则这个过程永远都不会结束,所以数据集从来就不会以任何有意义的方式“完成”[1]。因此,批处理程序必须将数据人为地分成固定时间段的数据块,例如,在每天结束时处理一天的数据,或者在每小时结束时处理一小时的数据。
|
||||
实际上,很多数据是无限的,因为它随着时间的推移逐渐到达:你的用户昨天和今天产生了数据,明天他们将继续产生更多的数据。除非您停业,否则这个过程永远都不会结束,所以数据集从来就不会以任何有意义的方式“完成”【1】。因此,批处理程序必须将数据人为地分成固定时间段的数据块,例如,在每天结束时处理一天的数据,或者在每小时结束时处理一小时的数据。
|
||||
|
||||
日常批处理过程中的问题是,输入的更改只会在一天之后的输出中反映出来,这对于许多急躁的用户来说太慢了。为了减少延迟,我们可以更频繁地运行处理 - 比如说,在每秒钟的末尾,甚至连续地处理秒数的数据,完全放弃固定的时间片,并简单地处理每一个事件。这是流处理背后的想法。
|
||||
|
||||
一般来说,“流”是指随着时间的推移逐渐可用的数据。这个概念出现在很多地方:Unix的stdin和stdout,编程语言(lazy lists)[2],文件系统API(如Java的FileInputStream),TCP连接,通过互联网传送音频和视频等等。
|
||||
在本章中,我们将把事件流视为一个数据管理机制:我们在上一章中看到的批量数据的无界的,递增处理的对应物。我们将首先讨论流如何被表示,存储和通过网络传输。在第451页的“数据库和流”中,我们将调查流和数据库之间的关系。最后,在第464页的“Processing Streams”中,我们将探索连续处理这些流的方法和工具,以及它们可以用来构建应用程序的方法。
|
||||
一般来说,“流”是指随着时间的推移逐渐可用的数据。这个概念出现在很多地方:Unix的stdin和stdout,编程语言(lazy lists)【2】,文件系统API(如Java的FileInputStream),TCP连接,通过互联网传送音频和视频等等。
|
||||
在本章中,我们将把事件流视为一个数据管理机制:我们在上一章中看到的批量数据的无界的,递增处理的对应物。我们将首先讨论流如何被表示,存储和通过网络传输。在“[数据库和流](#数据库和流)”中,我们将研究流和数据库之间的关系。最后在“[流处理](#流处理)”中,我们将探索连续处理这些流的方法和工具,以及它们可以用来构建应用程序的方法。
|
||||
|
||||
|
||||
|
||||
@ -27,45 +27,45 @@
|
||||
|
||||
在批处理领域,作业的输入和输出是文件(也许在分布式文件系统上)。什么是类似的流媒体?
|
||||
|
||||
当输入是一个文件(一个字节序列)时,第一个处理步骤通常是将其解析为一系列记录。在流处理的上下文中,记录通常被称为事件,但它本质上是一样的:一个小的,自包含的,不可变的对象,包含某个时间点发生的事情的细节。一个事件通常包含一个时间戳,指示何时根据时钟来发生(参见第288页上的“单调对时钟”)。
|
||||
当输入是一个文件(一个字节序列)时,第一个处理步骤通常是将其解析为一系列记录。在流处理的上下文中,记录通常被称为事件,但它本质上是一样的:一个小的,自包含的,不可变的对象,包含某个时间点发生的事情的细节。一个事件通常包含一个时间戳,指示何时根据时钟来发生(参见“[单调钟与时钟](ch8.md#单调钟与时钟)”)。
|
||||
|
||||
例如,发生的事情可能是用户采取的行动,例如查看页面或进行购买。它也可能来源于机器,例如来自温度传感器的周期性测量或者CPU利用率度量。在第391页上的“使用Unix工具进行批处理”的示例中,Web服务器日志的每一行都是一个事件。
|
||||
例如,发生的事情可能是用户采取的行动,例如查看页面或进行购买。它也可能来源于机器,例如来自温度传感器的周期性测量或者CPU利用率度量。在“[使用Unix工具进行批处理](ch10.md#使用Unix工具进行批处理)”的示例中,Web服务器日志的每一行都是一个事件。
|
||||
|
||||
事件可能被编码为文本字符串或JSON,或者以某种二进制形式编码,如第4章所述。这种编码允许您存储一个事件,例如将其附加到一个文件,将其插入关系表,或将其写入文档数据库。它还允许您通过网络将事件发送到另一个节点以进行处理。
|
||||
|
||||
在批处理领域,作业的输入和输出是文件(也许在分布式文件系统上)。什么是类似的流媒体?
|
||||
|
||||
当输入是一个文件(一个字节序列)时,第一个处理步骤通常是将其解析为一系列记录。在流处理的上下文中,记录通常被称为事件,但它本质上是一样的:一个小的,自包含的,不可变的对象,包含某个时间点发生的事情的细节。一个事件通常包含一个时间戳,指示何时根据时钟来发生(参见第288页上的“单调对时钟”)。
|
||||
当输入是一个文件(一个字节序列)时,第一个处理步骤通常是将其解析为一系列记录。在流处理的上下文中,记录通常被称为事件,但它本质上是一样的:一个小的,自包含的,不可变的对象,包含某个时间点发生的事情的细节。一个事件通常包含一个时间戳,指示何时根据时钟来发生(参见“[单调钟与时钟](ch8.md#单调钟与时钟)”)。
|
||||
|
||||
例如,发生的事情可能是用户采取的行动,例如查看页面或进行购买。它也可能来源于机器,例如来自温度传感器的周期性测量或者CPU利用率度量。在第391页上的“使用Unix工具进行批处理”的示例中,Web服务器日志的每一行都是一个事件。
|
||||
例如,发生的事情可能是用户采取的行动,例如查看页面或进行购买。它也可能来源于机器,例如来自温度传感器的周期性测量或者CPU利用率度量。在“[使用Unix工具进行批处理](ch10.md#使用Unix工具进行批处理)”的示例中,Web服务器日志的每一行都是一个事件。
|
||||
|
||||
事件可能被编码为文本字符串或JSON,或者以某种二进制形式编码,如第4章所述。这种编码允许您存储一个事件,例如将其附加到一个文件,将其插入关系表,或将其写入文档数据库。它还允许您通过网络将事件发送到另一个节点以进行处理。
|
||||
事件可能被编码为文本字符串或JSON,或者以某种二进制形式编码,如[第4章](ch4.md)所述。这种编码允许您存储一个事件,例如将其附加到一个文件,将其插入关系表,或将其写入文档数据库。它还允许您通过网络将事件发送到另一个节点以进行处理。
|
||||
|
||||
在批处理中,文件被写入一次,然后可能被多个作业读取。类似地,在流媒体术语中,一个事件由生产者(也称为发布者或发送者)生成一次,然后由多个消费者(订阅者或接收者)进行处理[3]。在文件系统中,文件名标识一组相关记录;在流媒体系统中,相关的事件通常被组合成一个主题或流。
|
||||
在批处理中,文件被写入一次,然后可能被多个作业读取。类似地,在流媒体术语中,一个事件由生产者(也称为发布者或发送者)生成一次,然后由多个消费者(订阅者或接收者)进行处理【3】。在文件系统中,文件名标识一组相关记录;在流媒体系统中,相关的事件通常被组合成一个主题或流。
|
||||
|
||||
原则上,文件或数据库足以连接生产者和消费者:生产者将其生成的每个事件写入数据存储,并且每个使用者定期轮询数据存储以检查自上次运行以来出现的事件。这实际上是批处理在每天结束时处理一天的数据的过程。
|
||||
|
||||
但是,如果数据存储不是为这种用途而设计的,那么在延迟较小的情况下继续进行处理时,轮询将变得非常昂贵。您调查的次数越多,返回新事件的请求百分比越低,因此开销越高。相反,当新事件出现时,最好通知消费者。
|
||||
|
||||
数据库传统上不太支持这种通知机制:关系数据库通常具有触发器,它们可以对变化作出反应(例如,将一行插入到表中),但是它们的功能非常有限,有点在数据库设计中是事后的[4,5]。相反,已经开发了专门的工具来提供事件通知。
|
||||
数据库传统上不太支持这种通知机制:关系数据库通常具有触发器,它们可以对变化作出反应(例如,将一行插入到表中),但是它们的功能非常有限,有点在数据库设计中是事后的【4,5】。相反,已经开发了专门的工具来提供事件通知。
|
||||
|
||||
|
||||
|
||||
### 消息系统
|
||||
|
||||
通知消费者有关新事件的常用方法是使用消息传递系统:生产者发送包含事件的消息,然后将消息推送给消费者。我们之前在第136页的“消息传递数据流”中介绍了这些系统,但现在我们将详细介绍这些系统。
|
||||
通知消费者有关新事件的常用方法是使用消息传递系统:生产者发送包含事件的消息,然后将消息推送给消费者。我们之前在“[消息传递中的数据流](ch4.md#消息传递中的数据流)”中介绍了这些系统,但现在我们将详细介绍这些系统。
|
||||
|
||||
像生产者和消费者之间的Unix管道或TCP连接这样的直接通信渠道将是实现消息传递系统的简单方法。但是,大多数消息传递系统都在这个基本模型上扩展特别是,Unix管道和TCP将一个发送者与一个接收者完全连接,而一个消息传递系统允许多个生产者节点将消息发送到相同的主题,并允许多个消费者节点接收主题中的消息。
|
||||
|
||||
在这个发布/订阅模式中,不同的系统采取多种方法,对于所有目的都没有一个正确的答案。为了区分这些系统,询问以下两个问题特别有帮助:
|
||||
|
||||
1. 如果生产者发送消息的速度比消费者能够处理的速度快,会发生什么?一般来说,有三种选择:系统可以放置消息,缓冲队列中的消息或应用背压(也称为流量控制;即阻止生产者发送更多的消息)。例如,Unix管道和TCP使用背压:他们有一个小的固定大小的缓冲区,如果填满,发件人被阻塞,直到收件人从缓冲区中取出数据(参见“网络拥塞和排队”(第282页))。
|
||||
1. 如果生产者发送消息的速度比消费者能够处理的速度快,会发生什么?一般来说,有三种选择:系统可以放置消息,缓冲队列中的消息或应用背压(也称为流量控制;即阻止生产者发送更多的消息)。例如,Unix管道和TCP使用背压:他们有一个小的固定大小的缓冲区,如果填满,发件人被阻塞,直到收件人从缓冲区中取出数据(参见“[网络拥塞和排队](ch8.md#网络拥塞和排队)”)。
|
||||
|
||||
如果消息被缓存在队列中,那么了解该队列增长会发生什么很重要。如果队列不再适合内存,或者将消息写入磁盘,系统是否会崩溃?如果是这样,磁盘访问如何影响邮件系统的性能[6]?
|
||||
如果消息被缓存在队列中,那么了解该队列增长会发生什么很重要。如果队列不再适合内存,或者将消息写入磁盘,系统是否会崩溃?如果是这样,磁盘访问如何影响邮件系统的性能【6】?
|
||||
|
||||
2. 如果节点崩溃或暂时脱机,会发生什么情况 - 是否有消息丢失?与数据库一样,持久性可能需要写入磁盘和/或复制的某种组合(请参阅第227页的侧栏“复制和耐久性”),这有成本。如果您有时可能会丢失消息,则可能在同一硬件上获得更高的吞吐量和更低的延迟。
|
||||
2. 如果节点崩溃或暂时脱机,会发生什么情况 - 是否有消息丢失?与数据库一样,持久性可能需要写入磁盘和/或复制的某种组合(参阅“[复制和持久性](ch7.md#复制和持久性)”),这有成本。如果您有时可能会丢失消息,则可能在同一硬件上获得更高的吞吐量和更低的延迟。
|
||||
|
||||
消息丢失是否可以接受取决于应用程序。例如,对于定期传输的传感器读数和指标,偶尔丢失的数据点可能并不重要,因为更新后的值将在短时间后发送。但是,要注意,如果大量的消息被丢弃,那么衡量标准是不正确的[7]。如果您正在计数事件,那么更重要的是它们可靠地传送,因为每个丢失的消息都意味着不正确的计数器。
|
||||
消息丢失是否可以接受取决于应用程序。例如,对于定期传输的传感器读数和指标,偶尔丢失的数据点可能并不重要,因为更新后的值将在短时间后发送。但是,要注意,如果大量的消息被丢弃,那么衡量标准是不正确的【7】。如果您正在计数事件,那么更重要的是它们可靠地传送,因为每个丢失的消息都意味着不正确的计数器。
|
||||
|
||||
我们在第10章中探讨的批处理系统的一个很好的特性是它们提供了强大的可靠性保证:失败的任务会自动重试,失败任务的部分输出会自动丢弃。这意味着输出与没有发生故障一样,这有助于简化编程模型。在本章的后面,我们将研究如何在流式上下文中提供类似的保证。
|
||||
|
||||
@ -73,13 +73,13 @@
|
||||
|
||||
许多消息传递系统使用生产者和消费者之间的直接网络通信,而不通过中间节点:
|
||||
|
||||
* UDP组播广泛应用于金融行业,例如股票市场,其中低时延非常重要[8]。虽然UDP本身是不可靠的,但应用程序级别的协议可以恢复丢失的数据包(生产者必须记住它发送的数据包,以便它可以按需重新发送数据包)。
|
||||
* UDP组播广泛应用于金融行业,例如股票市场,其中低时延非常重要【8】。虽然UDP本身是不可靠的,但应用程序级别的协议可以恢复丢失的数据包(生产者必须记住它发送的数据包,以便它可以按需重新发送数据包)。
|
||||
|
||||
* 无代理的消息库,如ZeroMQ [9]和nanomsg采取类似的方法,通过TCP或IP多播实现发布/订阅消息传递。
|
||||
* 无代理的消息库,如ZeroMQ 【9】和nanomsg采取类似的方法,通过TCP或IP多播实现发布/订阅消息传递。
|
||||
|
||||
StatsD [10]和Brubeck [7]使用不可靠的UDP消息传递来收集网络中所有机器的指标并对其进行监控。 (在StatsD协议中,如果接收到所有消息,则计数器度量标准是正确的;使用UDP将使得度量标准最好近似为[11]。另请参阅“TCP与UDP”
|
||||
StatsD 【10】和Brubeck 【7】使用不可靠的UDP消息传递来收集网络中所有机器的指标并对其进行监控。 (在StatsD协议中,如果接收到所有消息,则计数器度量标准是正确的;使用UDP将使得度量标准最好近似为【11】。另请参阅“[TCP与UDP](ch8.md#TCP与UDP)”
|
||||
|
||||
* 如果消费者在网络上公开服务,生产者可以直接发送HTTP或RPC请求(请参阅第131页的“通过服务进行数据流:REST和RPC”)以将消息推送给使用者。这就是webhooks背后的想法[12],一种服务的回调URL被注册到另一个服务中,并且每当事件发生时都会向该URL发出请求。
|
||||
* 如果消费者在网络上公开服务,生产者可以直接发送HTTP或RPC请求(参阅“[通过服务进行数据流:REST和RPC](ch4.md#通过服务进行数据流:REST和RPC)”)以将消息推送给使用者。这就是webhooks背后的想法【12】,一种服务的回调URL被注册到另一个服务中,并且每当事件发生时都会向该URL发出请求。
|
||||
|
||||
尽管这些直接消息传递系统在设计它们的情况下运行良好,但是它们通常要求应用程序代码知道消息丢失的可能性。他们可以容忍的错误是相当有限的:即使协议检测并重新传输在网络中丢失的数据包,他们通常假设生产者和消费者不断在线。
|
||||
|
||||
@ -95,14 +95,14 @@
|
||||
|
||||
#### 消息代理与数据库进行比较
|
||||
|
||||
有些消息代理甚至可以使用XA或JTA参与两阶段提交协议(请参阅第367页的“实践中的分布式事务”)。这个功能与数据库在本质上非常相似,尽管消息代理和数据库之间仍存在重要的实际差异:
|
||||
有些消息代理甚至可以使用XA或JTA参与两阶段提交协议(参阅“[实践中的分布式事务](ch9.md#实践中的分布式事务)”)。这个功能与数据库在本质上非常相似,尽管消息代理和数据库之间仍存在重要的实际差异:
|
||||
|
||||
* 数据库通常保留数据,直到被明确删除,而大多数消息代理在消息成功传递给消费者时自动删除消息。这样的消息代理不适合长期的数据存储。
|
||||
* 由于他们很快删除了邮件,大多数邮件经纪人都认为他们的工作集合相当小,即队列很短。如果代理需要缓冲很多消息,因为消费者速度较慢(如果消息不再适合内存,则可能会将消息泄漏到磁盘),每个消息需要更长的时间处理,整体吞吐量可能会降低[6]。
|
||||
* 由于他们很快删除了邮件,大多数邮件经纪人都认为他们的工作集合相当小,即队列很短。如果代理需要缓冲很多消息,因为消费者速度较慢(如果消息不再适合内存,则可能会将消息泄漏到磁盘),每个消息需要更长的时间处理,整体吞吐量可能会降低【6】。
|
||||
* 数据库通常支持二级索引和各种搜索数据的方式,而消息代理通常支持某种方式订阅匹配某种模式的主题子集。机制是不同的,但是这两种方式都是客户选择想要了解的数据部分的根本途径。
|
||||
* 查询数据库时,结果通常基于数据的时间点快照;如果另一个客户端随后向数据库写入更改查询结果的内容,则第一个客户端不会发现其先前的结果现在已过期(除非它重复查询或轮询更改)。相比之下,消息代理不支持任意查询,但是当数据发生变化时(即新消息可用时),它们会通知客户端。
|
||||
|
||||
这是消息代理的传统观点,它被封装在JMS [14]和AMQP [15]等标准中,并以RabbitMQ,ActiveMQ,HornetQ,Qpid,TIBCO企业消息服务,IBM MQ,Azure服务总线和Google Cloud Pub/Sub [16]。
|
||||
这是消息代理的传统观点,它被封装在JMS 【14】和AMQP 【15】等标准中,并以RabbitMQ,ActiveMQ,HornetQ,Qpid,TIBCO企业消息服务,IBM MQ,Azure服务总线和Google Cloud Pub/Sub 【16】。
|
||||
|
||||
#### 多个消费者
|
||||
|
||||
@ -119,21 +119,22 @@
|
||||
![](img/fig11-1.png)
|
||||
|
||||
**图11-1。 (a)负载平衡:分担消费者之间消费话题的工作; (b)扇出:将消息传递给多个消费者。**
|
||||
|
||||
两种模式可以组合:例如,两个独立的消费者组可以每个订阅一个话题,使得每个组共同收到所有消息,但是在每个组内,只有一个节点接收每个消息。
|
||||
|
||||
#### 确认和重新交付
|
||||
|
||||
消费者可能会随时崩溃,所以可能发生的情况是经纪人向消费者提供消息,但消费者从不处理消费者,或者在消费者崩溃之前只处理消费者。为了确保消息不会丢失,消息代理使用确认:客户端必须明确告诉代理处理消息的时间,以便代理可以将其从队列中移除。
|
||||
|
||||
如果与客户端的连接关闭或超时而没有代理收到确认,则认为消息未处理,因此它将消息再次传递给另一个消费者。 (请注意,可能发生这样的消息实际上是完全处理的,但网络中的确认丢失了,处理这种情况需要一个原子提交协议,正如在第360页的“实践中的分布式事务”中所讨论的那样)
|
||||
如果与客户端的连接关闭或超时而没有代理收到确认,则认为消息未处理,因此它将消息再次传递给另一个消费者。 (请注意,可能发生这样的消息实际上是完全处理的,但网络中的确认丢失了,处理这种情况需要一个原子提交协议,正如在“[实践中的分布式事务](ch9.md#实践中的分布式事务)”中所讨论的那样)
|
||||
|
||||
当与负载平衡相结合时,这种重新传递行为对消息的排序有一个有趣的影响。在图11-2中,消费者通常按照生产者发送的顺序处理消息。然而,消费者2在处理消息m3时崩溃,与此同时消费者1正在处理消息m4。未确认的消息m3随后被重新发送给消费者1,结果消费者1按照m4,m3,m5的顺序处理消息。因此,m3和m4不是以与生产者1发送的顺序相同的顺序交付的。
|
||||
当与负载平衡相结合时,这种重新传递行为对消息的排序有一个有趣的影响。在[图11-2](img/fig11-2.png)中,消费者通常按照生产者发送的顺序处理消息。然而,消费者2在处理消息m3时崩溃,与此同时消费者1正在处理消息m4。未确认的消息m3随后被重新发送给消费者1,结果消费者1按照m4,m3,m5的顺序处理消息。因此,m3和m4不是以与生产者1发送的顺序相同的顺序交付的。
|
||||
|
||||
![](img/fig11-2.png)
|
||||
|
||||
**图11-2 消费者2在处理m3时崩溃,因此稍后再次向消费者1递送**
|
||||
|
||||
即使消息代理试图保留消息的顺序(如JMS和AMQP标准所要求的),负载平衡与重新传递的组合也不可避免地导致消息被重新排序。为避免此问题,您可以为每个使用者使用单独的队列(即不使用负载平衡功能)。如果消息是完全独立的,消息重新排序并不是一个问题,但是如果消息之间存在因果依赖关系,这一点很重要,我们将在后面的章节中看到。
|
||||
即使消息代理试图保留消息的顺序(如JMS和AMQP标准所要求的),负载平衡与重新传递的组合也不可避免地导致消息被重新排序。为避免此问题,您可以为每个使用者使用单独的队列(即不使用负载均衡功能)。如果消息是完全独立的,消息重新排序并不是一个问题,但是如果消息之间存在因果依赖关系,这一点很重要,我们将在后面的章节中看到。
|
||||
|
||||
### 分区日志
|
||||
|
||||
@ -141,7 +142,7 @@
|
||||
|
||||
数据库和文件系统采用相反的方法:写入数据库或文件的所有内容通常都要被永久记录下来,至少在某些人明确选择将其删除之前。
|
||||
|
||||
思维方式上的这种差异对如何创建派生数据有很大的影响。批处理过程的一个关键特征是,你可以反复运行它们,试验处理步骤,没有损坏输入的风险(因为输入是只读的)。 AMQP / JMS风格的消息并非如此:如果确认导致从代理中删除消息,则接收消息具有破坏性,因此您不能再次运行同一消费者,并期望得到相同的结果。
|
||||
思维方式上的这种差异对如何创建派生数据有很大的影响。批处理过程的一个关键特征是,你可以反复运行它们,试验处理步骤,没有损坏输入的风险(因为输入是只读的)。 AMQP/JMS风格的消息并非如此:如果确认导致从代理中删除消息,则接收消息具有破坏性,因此您不能再次运行同一消费者,并期望得到相同的结果。
|
||||
|
||||
如果您将新消费者添加到消息系统,则通常只会开始接收在注册后发送的消息;任何先前的消息已经消失,无法恢复。将它与文件和数据库进行对比,您可以随时添加新客户端,并且可以读取过去任意写入的数据(只要应用程序没有明确覆盖或删除数据)。
|
||||
|
||||
@ -149,19 +150,19 @@
|
||||
|
||||
#### 使用日志进行消息存储
|
||||
|
||||
日志只是磁盘上只能追加记录的序列。我们先前在第3章中的日志结构存储引擎和预写日志的上下文中讨论了日志,在第5章中也讨论了复制的上下文中。
|
||||
日志只是磁盘上只能追加记录的序列。我们先前在第3章中的日志结构存储引擎和预写日志的上下文中讨论了日志,在[第5章](ch5.md)中也讨论了复制的上下文中。
|
||||
|
||||
可以使用相同的结构来实现消息代理:生产者通过将消息附加到日志的末尾来发送消息,并且消费者通过依次读取日志来接收消息。如果消费者到达日志的末尾,则等待通知新消息已被追加。 Unix工具tail -f监视数据被附加的文件,基本上是这样工作的。
|
||||
可以使用相同的结构来实现消息代理:生产者通过将消息附加到日志的末尾来发送消息,并且消费者通过依次读取日志来接收消息。如果消费者到达日志的末尾,则等待通知新消息已被追加。 Unix工具`tail -f`监视数据被附加的文件,基本上是这样工作的。
|
||||
|
||||
为了扩展到比单个磁盘提供更高的吞吐量,可以对日志进行分区(在第6章的意义上)。然后可以在不同的机器上托管不同的分区,使每个分区成为一个单独的日志,可以独立于其他分区读取和写入。然后可以将一个话题定义为一组分段,它们都携带相同类型的消息。这种方法如图11-3所示。
|
||||
为了扩展到比单个磁盘提供更高的吞吐量,可以对日志进行分区(在[第6章](ch6.md)的意义上)。然后可以在不同的机器上托管不同的分区,使每个分区成为一个单独的日志,可以独立于其他分区读取和写入。然后可以将一个话题定义为一组分段,它们都携带相同类型的消息。这种方法如[图11-3](img/fig11-3.png)所示。
|
||||
|
||||
在每个分区中,代理为每个消息分配一个单调递增的序列号或偏移量(在图11-3中,框中的数字是消息偏移量)。这样的序列号是有意义的,因为分区是仅附加的,所以分区内的消息是完全有序的。没有跨不同分区的订购保证。
|
||||
在每个分区中,代理为每个消息分配一个单调递增的序列号或偏移量(在[图11-3](img/fig11-3.png)中,框中的数字是消息偏移量)。这样的序列号是有意义的,因为分区是仅附加的,所以分区内的消息是完全有序的。没有跨不同分区的订购保证。
|
||||
|
||||
![](img/fig11-3.png)
|
||||
|
||||
**图11-3 生产者通过将消息附加到主题分区文件来发送消息,消费者依次读取这些文件**
|
||||
|
||||
Apache Kafka [17,18],Amazon Kinesis Streams [19]和Twitter的DistributedLog [20,21]都是基于日志的消息代理。 Google Cloud Pub / Sub在体系结构上相似,但是暴露了JMS风格的API而不是日志抽象[16]。尽管这些消息代理将所有消息写入磁盘,但通过在多台机器上进行分区,每秒能够实现数百万条消息的吞吐量,并通过复制消息来实现容错性[22,23]。
|
||||
Apache Kafka 【17,18】,Amazon Kinesis Streams 【19】和Twitter的DistributedLog 【20,21】都是基于日志的消息代理。 Google Cloud Pub / Sub在体系结构上相似,但是暴露了JMS风格的API而不是日志抽象【16】。尽管这些消息代理将所有消息写入磁盘,但通过在多台机器上进行分区,每秒能够实现数百万条消息的吞吐量,并通过复制消息来实现容错性【22,23】。
|
||||
|
||||
#### 日志与传统消息相比
|
||||
|
||||
@ -170,9 +171,9 @@ Apache Kafka [17,18],Amazon Kinesis Streams [19]和Twitter的DistributedLog [2
|
||||
每个客户端然后使用分配的分区中的所有消息。通常情况下,当一个用户被分配了一个日志分区时,它会以直接的单线程的方式顺序地读取分区中的消息。这种粗粒度的负载均衡方法有一些缺点:
|
||||
|
||||
* 共享消费主题工作的节点数最多可以是该主题中的日志分区数,因为同一个分区内的消息被传递到同一个节点[^i]。
|
||||
* 如果单个消息处理缓慢,则会阻止处理该分区中的后续消息(一种行头阻塞形式;请参阅第13页上的“描述性能”)。
|
||||
* 如果单个消息处理缓慢,则会阻止处理该分区中的后续消息(一种行头阻塞形式;请参阅“[描述性能](ch1.md#描述性能)”)。
|
||||
|
||||
因此,在处理消息可能代价高昂,并且希望逐个消息地平行处理以及消息排序并不那么重要的情况下,消息代理的JMS / AMQP风格是可取的。另一方面,在消息吞吐量高的情况下,每条消息的处理速度都很快,消息的排序也是重要的,基于日志的方法运行得很好。
|
||||
因此,在处理消息可能代价高昂,并且希望逐个消息地平行处理以及消息排序并不那么重要的情况下,消息代理的JMS/AMQP风格是可取的。另一方面,在消息吞吐量高的情况下,每条消息的处理速度都很快,消息的排序也是重要的,基于日志的方法运行得很好。
|
||||
|
||||
[^i]: 有可能创建一个负载均衡方案,在这个方案中,两个消费者通过读取全部消息来共享处理分区的工作,但是其中一个只考虑具有偶数偏移量的消息,而另一个消费者处理奇数编号的偏移量。或者,您可以将消息处理扩展到线程池,但这种方法会使消费者偏移管理变得复杂。一般来说,分区的单线程处理是可取的,并行分区可以通过使用更多的分区来增加。
|
||||
|
||||
@ -180,7 +181,7 @@ Apache Kafka [17,18],Amazon Kinesis Streams [19]和Twitter的DistributedLog [2
|
||||
|
||||
消耗一个分区依次可以很容易地判断哪些消息已经被处理:所有消息的偏移量小于消费者的当前偏移量已经被处理,并且具有更大偏移量的所有消息还没有被看到。因此,经纪人不需要跟踪每条消息的确认,只需要定期记录消费者的偏移。在这种方法中减少的簿记开销以及批处理和流水线的机会有助于提高基于日志的系统的吞吐量。
|
||||
|
||||
实际上,这种偏移量与单领先数据库复制中常见的日志序列号非常相似,我们在第151页的“设置新的跟踪者”中讨论了这种情况。在数据库复制中,日志序列号允许跟随者断开连接后,重新连接到领导,并在不跳过任何写入的情况下恢复复制。这里使用的原则完全相同:信息经纪人的行为像一个领导者数据库,而消费者就像一个追随者。
|
||||
实际上,这种偏移量与单领先数据库复制中常见的日志序列号非常相似,我们在“[设置新从库](ch5.md#设置新从库)”中讨论了这种情况。在数据库复制中,日志序列号允许跟随者断开连接后,重新连接到领导,并在不跳过任何写入的情况下恢复复制。这里使用的原则完全相同:消息代理的行为像一个领导者数据库,而消费者就像一个追随者。
|
||||
|
||||
如果消费者节点失败,则使用者组中的另一个节点将被分配失败的使用者分区,并开始以最后记录的偏移量使用消息。如果消费者已经处理了后续的消息,但还没有记录他们的偏移量,那么在重新启动后这些消息将被第二次处理。本章后面我们将讨论处理这个问题的方法。
|
||||
|
||||
@ -190,13 +191,13 @@ Apache Kafka [17,18],Amazon Kinesis Streams [19]和Twitter的DistributedLog [2
|
||||
|
||||
这就意味着,如果一个消费者的速度慢,消费者的消费速度落后于消费者的偏移量,那么消费者的偏移量就会指向一个已经被删除的消费者。实际上,日志实现了一个有限大小的缓冲区,当旧的消息满时,它也被称为循环缓冲区或环形缓冲区。但是,由于该缓冲区在磁盘上,因此可能相当大。
|
||||
|
||||
让我们来做一个后台计算。在撰写本文时,典型的大型硬盘驱动器容量为6TB,顺序写入吞吐量为150MB / s。如果您以最快的速度写邮件,则需要大约11个小时才能填满驱动器。因此,磁盘可以缓存11个小时的消息,之后它将开始覆盖旧的消息。即使您使用多个硬盘驱动器和机器,这个比率也是一样的。在实践中,部署很少使用磁盘的完整写入带宽,所以日志通常可以保存几天甚至几周的缓冲区。
|
||||
让我们来做一个后台计算。在撰写本文时,典型的大型硬盘驱动器容量为6TB,顺序写入吞吐量为150MB/s。如果您以最快的速度写邮件,则需要大约11个小时才能填满驱动器。因此,磁盘可以缓存11个小时的消息,之后它将开始覆盖旧的消息。即使您使用多个硬盘驱动器和机器,这个比率也是一样的。在实践中,部署很少使用磁盘的完整写入带宽,所以日志通常可以保存几天甚至几周的缓冲区。
|
||||
|
||||
不管你保留多长时间的消息,一个日志的吞吐量或多或少保持不变,因为无论如何每个消息都被写入磁盘[18]。这种行为与将邮件默认保存在内存中的消息传递系统形成鲜明对比,如果队列变得太大,只将其写入磁盘:当这些系统开始写入磁盘时,这些系统速度很快,并且变慢得多,所以吞吐量取决于保留的历史数量。
|
||||
不管你保留多长时间的消息,一个日志的吞吐量或多或少保持不变,因为无论如何每个消息都被写入磁盘【18】。这种行为与将邮件默认保存在内存中的消息传递系统形成鲜明对比,如果队列变得太大,只将其写入磁盘:当这些系统开始写入磁盘时,这些系统速度很快,并且变慢得多,所以吞吐量取决于保留的历史数量。
|
||||
|
||||
#### 当消费者跟不上生产者时
|
||||
|
||||
在“信息系统”第441页的开头,我们讨论了如果消费者无法跟上生产者发送信息的速度的三种选择:丢弃信息,缓冲或施加背压。在这个分类法中,基于日志的方法是一种缓冲形式,具有较大但固定大小的缓冲区(受可用磁盘空间的限制)。
|
||||
在“[信息系统]()”第441页的开头,我们讨论了如果消费者无法跟上生产者发送信息的速度的三种选择:丢弃信息,缓冲或施加背压。在这个分类法中,基于日志的方法是一种缓冲形式,具有较大但固定大小的缓冲区(受可用磁盘空间的限制)。
|
||||
|
||||
如果消费者远远落后于它所要求的信息比保留在磁盘上的信息要旧,那么它将不能读取这些信息,所以代理人有效地丢弃了比缓冲区容量更大的旧信息。您可以监控消费者在日志头后面的距离,如果显着落后,则会发出警报。由于缓冲区很大,因此有足够的时间让人类操作员修复缓慢的消费者,并在消息开始丢失之前让其赶上。
|
||||
|
||||
@ -210,7 +211,7 @@ Apache Kafka [17,18],Amazon Kinesis Streams [19]和Twitter的DistributedLog [2
|
||||
|
||||
处理的唯一副作用,除了消费者的任何产出之外,消费者补偿正在向前发展。但是偏移量是在消费者的控制之下的,所以如果需要的话可以很容易地被操纵:例如,你可以用昨天的偏移量开始一个消费者的副本,并将输出写到不同的位置,以便重新处理最后一天值得的消息。您可以重复这个任意次数,改变处理代码。
|
||||
|
||||
这方面使得基于日志的消息传递更像上一章的批处理过程,其中派生数据通过可重复的转换过程与输入数据明确分离。它允许更多的实验,更容易从错误和错误中恢复,使其成为在组织内集成数据流的好工具[24]。
|
||||
这方面使得基于日志的消息传递更像上一章的批处理过程,其中派生数据通过可重复的转换过程与输入数据明确分离。它允许更多的实验,更容易从错误和错误中恢复,使其成为在组织内集成数据流的好工具【24】。
|
||||
|
||||
|
||||
|
||||
@ -220,30 +221,31 @@ Apache Kafka [17,18],Amazon Kinesis Streams [19]和Twitter的DistributedLog [2
|
||||
|
||||
我们之前曾经说过,事件是某个时刻发生的事情的记录。发生的事情可能是用户操作(例如键入搜索查询)或传感器读取,但也可能是写入数据库。事情被写入数据库的事实是可以被捕获,存储和处理的事件。这一观察结果表明,数据库和数据流之间的连接不仅仅是磁盘上日志的物理存储 - 这是非常重要的。
|
||||
|
||||
事实上,复制日志(请参阅第158页上的“复制日志的实现”)是数据库写入事件的流,由领导者在处理事务时生成。追随者将写入流应用到他们自己的数据库副本,从而最终得到相同数据的精确副本。复制日志中的事件描述发生的数据更改。
|
||||
事实上,复制日志(参阅“[复制日志的实现](ch5.md#复制日志的实现)”)是数据库写入事件的流,由领导者在处理事务时生成。追随者将写入流应用到他们自己的数据库副本,从而最终得到相同数据的精确副本。复制日志中的事件描述发生的数据更改。
|
||||
|
||||
我们还在“[全序广播](ch9.md#全序广播)”中遇到了状态机复制原理,其中指出:如果每个事件代表对数据库的写入,并且每个副本按相同的顺序处理相同的事件,则副本将所有这些都以相同的最终状态结束。 (处理一个事件被认为是一个确定性的操作。)这只是事件流的另一种情况!
|
||||
|
||||
我们还在第348页的“全序广播”中遇到了状态机复制原理,其中指出:如果每个事件代表对数据库的写入,并且每个副本按相同的顺序处理相同的事件,则副本将所有这些都以相同的最终状态结束。 (处理一个事件被认为是一个确定性的操作。)这只是事件流的另一种情况!
|
||||
在本节中,我们将首先看看异构数据系统中出现的一个问题,然后探讨如何通过将事件流的想法带入数据库来解决这个问题。
|
||||
|
||||
### 保持系统同步
|
||||
|
||||
正如我们在本书中所看到的,没有一个系统能够满足所有的数据存储,查询和处理需求。在实践中,大多数不重要的应用程序需要结合多种不同的技术来满足他们的需求:例如,使用OLTP数据库来为用户请求提供服务,缓存来加速常见请求,处理全文索引搜索查询和用于分析的数据仓库。每个数据都有其自己的数据副本,存储在自己的表示中,并根据自己的目的进行了优化。
|
||||
|
||||
由于相同或相关的数据出现在不同的地方,因此需要保持相互同步:如果某个项目在数据库中进行了更新,则还需要在缓存,搜索索引和数据仓库中进行更新。对于数据仓库,这种同步通常由ETL进程执行(参见第91页的“数据仓库”),通常通过获取数据库的完整副本,转换数据库并将其批量加载到数据仓库中 - 换句话说,一个批处理。同样,我们在第419页的“批量工作流的输出”中看到了如何使用批处理过程创建搜索索引,建议系统和其他派生数据系统。
|
||||
由于相同或相关的数据出现在不同的地方,因此需要保持相互同步:如果某个项目在数据库中进行了更新,则还需要在缓存,搜索索引和数据仓库中进行更新。对于数据仓库,这种同步通常由ETL进程执行(参见“[数据仓库](ch3.md#数据仓库)”),通常通过获取数据库的完整副本,转换数据库并将其批量加载到数据仓库中 —— 换句话说,一个批处理。同样,我们在“[批量工作流的输出](ch10.md#批量工作流的输出)”中看到了如何使用批处理过程创建搜索索引,建议系统和其他派生数据系统。
|
||||
|
||||
如果周期性的完整数据库转储过于缓慢,有时使用的替代方法是双重写入,其中应用程序代码在数据更改时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存条目无效(或者甚至同时执行这些写入)。
|
||||
|
||||
但是,双重写入有一些严重的问题,其中一个是图11-4所示的竞争条件。在这个例子中,两个客户端同时想要更新一个项目X:客户端1想要将值设置为A,客户端2想要将其设置为B.两个客户端首先将新值写入数据库,然后将其写入到搜索索引。由于运行时间不正确,这些请求是交错的:数据库首先看到从客户端1的写入将值设置为A,然后从客户端2写入将值设置为B,因此数据库中的最终值为B.搜索索引首先看到来自客户端2,然后是客户端1的写入,所以搜索索引中的最终值是A.这两个系统现在永久地不一致,即使没有发生错误。
|
||||
但是,双重写入有一些严重的问题,其中一个是[图11-4](img/fig11-4.png)所示的竞争条件。在这个例子中,两个客户端同时想要更新一个项目X:客户端1想要将值设置为A,客户端2想要将其设置为B.两个客户端首先将新值写入数据库,然后将其写入到搜索索引。由于运行时间不正确,这些请求是交错的:数据库首先看到从客户端1的写入将值设置为A,然后从客户端2写入将值设置为B,因此数据库中的最终值为B.搜索索引首先看到来自客户端2,然后是客户端1的写入,所以搜索索引中的最终值是A.这两个系统现在永久地不一致,即使没有发生错误。
|
||||
|
||||
![](img/fig11-4.png)
|
||||
|
||||
**图11-4 在数据库中,X首先被设置为A,然后被设置为B,而在搜索索引处,写入以相反的顺序到达**
|
||||
|
||||
除非有一些额外的并发检测机制,例如我们在第184页上的“检测并发写入”中讨论的版本向量,否则您甚至不会注意到发生了并发写入 - 一个值将简单地以无提示方式覆盖另一个值。
|
||||
除非有一些额外的并发检测机制,例如我们在“[检测并发写入](ch5.md#检测并发写入)”中讨论的版本向量,否则您甚至不会注意到发生了并发写入 —— 一个值将简单地以无提示方式覆盖另一个值。
|
||||
|
||||
双重写入的另一个问题是其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相矛盾的结果。确保它们都成功或者两者都失败是原子提交问题的一个例子,这个问题的解决是昂贵的(请参阅第354页上的“原子提交和两阶段提交(2PC)”)。
|
||||
双重写入的另一个问题是其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相矛盾的结果。确保它们都成功或者两者都失败是原子提交问题的一个例子,这个问题的解决是昂贵的(参阅“[原子提交和两阶段提交(2PC)](ch7.md#原子提交和两阶段提交(2PC))”)。
|
||||
|
||||
如果只有一个复制的数据库和一个领导者,那么这个领导者决定了写入顺序,所以状态机复制方法可以在数据库的副本中工作。然而,在图11-4中,没有一个领导者:数据库可能有一个领导者,搜索索引可能有一个领导者,但是既不在另一个领导者之后,也可能发生冲突(参见“多领导者复制“)。
|
||||
如果只有一个复制的数据库和一个领导者,那么这个领导者决定了写入顺序,所以状态机复制方法可以在数据库的副本中工作。然而,在[图11-4](img/fig11-4.png)中,没有一个领导者:数据库可能有一个领导者,搜索索引可能有一个领导者,但是既不在另一个领导者之后,也可能发生冲突(参见“[多领导者复制](ch5.md#多领导者复制)“)。
|
||||
|
||||
如果实际上只有一个领导者(例如数据库),并且如果我们可以使搜索索引成为数据库的追随者,情况会更好。但这在实践中可能吗?
|
||||
|
||||
@ -257,21 +259,23 @@ Apache Kafka [17,18],Amazon Kinesis Streams [19]和Twitter的DistributedLog [2
|
||||
|
||||
最近,人们对变更数据捕获(CDC)越来越感兴趣,它是观察写入数据库的所有数据变化并将其提取出来,并将其复制到其他系统中的过程。 CDC特别感兴趣的是,如果改变可以立即用于流,可以立即写入。
|
||||
|
||||
例如,您可以捕获数据库中的更改并不断将相同的更改应用于搜索索引。如果更改的日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据匹配。搜索索引和任何其他派生的数据系统只是变化流的消费者,如图11-5所示。
|
||||
例如,您可以捕获数据库中的更改并不断将相同的更改应用于搜索索引。如果更改的日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据匹配。搜索索引和任何其他派生的数据系统只是变化流的消费者,如[图11-5](img/fig11-5.png)所示。
|
||||
|
||||
![](img/fig11-5.png)
|
||||
|
||||
**图11-5 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统**
|
||||
实施更改数据捕获
|
||||
|
||||
#### 实现变更数据捕获
|
||||
|
||||
我们可以调用日志消费者导出的数据系统,正如在第三部分的介绍中所讨论的:存储在搜索索引和数据仓库中的数据只是记录系统中数据的另一个视图。更改数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在派生数据系统中,以便派生系统具有数据的准确副本。
|
||||
|
||||
从本质上说,改变数据捕获使得一个数据库成为领导者(从中捕获变化的数据库),并将其他人变成追随者。基于日志的消息代理非常适合从源数据库传输更改事件,因为它保留了消息的排序(避免了图11-2的重新排序问题)。
|
||||
从本质上说,改变数据捕获使得一个数据库成为领导者(从中捕获变化的数据库),并将其他人变成追随者。基于日志的消息代理非常适合从源数据库传输更改事件,因为它保留了消息的排序(避免了[图11-2](img/fig11-2.png)的重新排序问题)。
|
||||
|
||||
数据库触发器可用于通过注册触发器来实现更改数据捕获(请参阅第152页的“基于触发器的复制”),这些触发器可观察数据表的所有更改,并将相应的条目添加到更改日志表中。但是,他们往往是脆弱的,并有显着的性能开销。解析复制日志可以是一个更强大的方法,但它也带来了挑战,例如处理模式更改。
|
||||
数据库触发器可用于通过注册触发器来实现更改数据捕获(参阅“[基于触发器的复制](ch5.md#基于触发器的复制)”),这些触发器可观察数据表的所有更改,并将相应的条目添加到更改日志表中。但是,他们往往是脆弱的,并有显着的性能开销。解析复制日志可以是一个更强大的方法,但它也带来了挑战,例如处理模式更改。
|
||||
|
||||
LinkedIn的Databus [25],Facebook的Wormhole [26]和Yahoo!的Sherpa [27]大规模地使用这个想法。 Bottled Water使用解码预写日志的API来实现PostgreSQL的CDC [28],Maxwell和Debezium通过解析binlog为MySQL做类似的事情[29,30,31],Mongoriver读取MongoDB oplog [32,33] ,而GoldenGate为Oracle提供类似的功能[34,35]。
|
||||
LinkedIn的Databus 【25】,Facebook的Wormhole 【26】和Yahoo!的Sherpa 【27】大规模地使用这个想法。 Bottled Water使用解码预写日志的API来实现PostgreSQL的CDC 【28】,Maxwell和Debezium通过解析binlog为MySQL做类似的事情【29,30,31】,Mongoriver读取MongoDB oplog 【32,33】 ,而GoldenGate为Oracle提供类似的功能【34,35】。
|
||||
|
||||
像消息代理一样,更改数据捕获通常是异步的:记录数据库系统不会等待更改应用到消费者,然后再进行更改。这种设计具有的操作优势是添加缓慢的使用者不会影响记录系统太多,但是它具有所有复制滞后问题的缺点(请参见第161页中的“复制滞后问题”)。
|
||||
像消息代理一样,更改数据捕获通常是异步的:记录数据库系统不会等待更改应用到消费者,然后再进行更改。这种设计具有的操作优势是添加缓慢的使用者不会影响记录系统太多,但是它具有所有复制滞后问题的缺点(参见“[复制延迟问题](ch5.md#复制延迟问题)”)。
|
||||
|
||||
#### 初始快照
|
||||
|
||||
@ -285,23 +289,23 @@ LinkedIn的Databus [25],Facebook的Wormhole [26]和Yahoo!的Sherpa [27]大
|
||||
|
||||
如果只能保留有限的日志历史记录,则每次需要添加新的派生数据系统时都需要执行快照过程。但是,日志压缩提供了一个很好的选择。
|
||||
|
||||
在日志结构化的存储引擎的情况下,我们先讨论了第72页的“Hash索引”中的日志压缩(参见图3-2的示例)。原理很简单:存储引擎使用相同的密钥定期查找日志记录,丢弃任何重复内容,并且只保留每个密钥的最新更新。这个压缩和合并过程在后台运行。
|
||||
在日志结构化的存储引擎的情况下,我们先讨论了“[Hash索引](ch3.md#Hash索引)”中的日志压缩(参见[图3-2](img/fig3-2.png)的示例)。原理很简单:存储引擎使用相同的密钥定期查找日志记录,丢弃任何重复内容,并且只保留每个密钥的最新更新。这个压缩和合并过程在后台运行。
|
||||
|
||||
在日志结构存储引擎中,具有特殊空值(逻辑删除)的更新指示删除了一个密钥,并在日志压缩过程中将其删除。但只要密钥不被覆盖或删除,它就永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容,而不取决于数据库中发生的写入次数。如果相同的密钥经常被覆盖,则以前的值将最终被垃圾收集,并且只保留最新的值。
|
||||
|
||||
在基于日志的消息代理和更改数据捕获方面,相同的想法也适用。如果CDC系统设置为每个更改都有一个主键,并且每个键的更新都替换了该键的以前的值,那么仅保留最近写入的特定键就足够了。
|
||||
|
||||
现在,无论何时要重建派生数据系统(如搜索索引),都可以从日志压缩主题的偏移量0开始新的使用者,然后依次扫描日志中的所有消息。日志保证包含数据库中每个键的最新值(也可能是一些较旧的值) - 换句话说,您可以使用它来获取数据库内容的完整副本,而无需获取CDC的另一个快照源数据库。
|
||||
现在,无论何时要重建派生数据系统(如搜索索引),都可以从日志压缩主题的偏移量0开始新的使用者,然后依次扫描日志中的所有消息。日志保证包含数据库中每个键的最新值(也可能是一些较旧的值)—— 换句话说,您可以使用它来获取数据库内容的完整副本,而无需获取CDC的另一个快照源数据库。
|
||||
|
||||
Apache Kafka支持此日志压缩功能。正如我们将在本章后面看到的,它允许消息代理被用于持久存储,而不仅仅是用于临时消息。
|
||||
|
||||
#### API支持更改流
|
||||
|
||||
越来越多的数据库开始支持变更流作为一流的接口,而不是典型的改造和逆向工程CDC的努力。例如,RethinkDB允许查询在查询更改结果[36],Firebase [37]和CouchDB [38]基于同样可用于应用程序的更改提要进行数据同步时订阅通知,而Meteor使用MongoDB oplog订阅数据更改并更新用户界面[39]。
|
||||
越来越多的数据库开始支持变更流作为一流的接口,而不是典型的改造和逆向工程CDC的努力。例如,RethinkDB允许查询在查询更改结果【36】,Firebase 【37】和CouchDB 【38】基于同样可用于应用程序的更改提要进行数据同步时订阅通知,而Meteor使用MongoDB oplog订阅数据更改并更新用户界面【39】。
|
||||
|
||||
VoltDB允许事务以流的形式连续地从数据库中导出数据[40]。数据库将关系数据模型中的输出流表示为一个表,事务可以在其中插入元组,但不能被查询。然后这个流由提交事务已经写入这个特殊表的元组日志组成,它们按照提交的顺序。外部使用者可以异步使用此日志并使用它来更新派生的数据系统。
|
||||
VoltDB允许事务以流的形式连续地从数据库中导出数据【40】。数据库将关系数据模型中的输出流表示为一个表,事务可以在其中插入元组,但不能被查询。然后这个流由提交事务已经写入这个特殊表的元组日志组成,它们按照提交的顺序。外部使用者可以异步使用此日志并使用它来更新派生的数据系统。
|
||||
|
||||
Kafka Connect [41]致力于将广泛的数据库系统的变更数据捕获工具与Kafka集成。一旦更改事件发生在Kafka中,它就可以用来更新派生的数据系统,比如搜索索引,也可以用于本章稍后讨论的流处理系统。
|
||||
Kafka Connect 【41】致力于将广泛的数据库系统的变更数据捕获工具与Kafka集成。一旦更改事件发生在Kafka中,它就可以用来更新派生的数据系统,比如搜索索引,也可以用于本章稍后讨论的流处理系统。
|
||||
|
||||
### 事件源
|
||||
|
||||
@ -309,22 +313,22 @@ Kafka Connect [41]致力于将广泛的数据库系统的变更数据捕获工
|
||||
|
||||
与更改数据捕获类似,事件采购涉及将所有对应用程序状态的更改存储为更改事件的日志。最大的区别是事件源代码在不同的抽象层次上应用了这个想法:
|
||||
|
||||
* 在更改数据捕获中,应用程序以可变方式使用数据库,随意更新和删除记录。从数据库中提取较低级别的更改日志(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免图中的竞争条件11-4。写入数据库的应用程序不需要知道CDC正在发生。
|
||||
* 在更改数据捕获中,应用程序以可变方式使用数据库,随意更新和删除记录。从数据库中提取较低级别的更改日志(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免[图11-4](img/fig11-4.png)中的竞争条件。写入数据库的应用程序不需要知道CDC正在发生。
|
||||
* 在事件源中,应用程序逻辑是基于写入事件日志的不可变事件而显式构建的。在这种情况下,事件存储是附加的,更新或删除是不鼓励或禁止的。事件旨在反映应用程序级别发生的事情,而不是低级状态更改。
|
||||
|
||||
事件源是一种强大的数据建模技术:从应用程序的角度来看,将用户的行为记录为不可变的事件更有意义,而不是记录这些行为对可变数据库的影响。事件采购使得随着时间的推移而逐渐发展应用程序变得更加容易,通过更容易理解事情发生的原因以及防范应用程序错误(请参阅“不可变事件的优点”),帮助进行调试。
|
||||
事件源是一种强大的数据建模技术:从应用程序的角度来看,将用户的行为记录为不可变的事件更有意义,而不是记录这些行为对可变数据库的影响。事件采购使得随着时间的推移而逐渐发展应用程序变得更加容易,通过更容易理解事情发生的原因以及防范应用程序错误(请参阅“[不可变事件的优点](#不可变事件的优点)”),帮助进行调试。
|
||||
|
||||
例如,存储“学生取消课程注册”事件清楚地表达了单一行为的中性意图,而副作用“从注册表中删除了一个条目,并且一个取消原因被添加到学生反馈表“嵌入了很多有关方式的假设数据稍后将被使用。如果引入新的应用程序功能,例如“将地点提供给等待列表中的下一个人” - 事件采购方法允许将新的副作用轻松地链接到现有事件上。
|
||||
例如,存储“学生取消课程注册”事件清楚地表达了单一行为的中性意图,而副作用“从注册表中删除了一个条目,并且一个取消原因被添加到学生反馈表“嵌入了很多有关方式的假设数据稍后将被使用。如果引入新的应用程序功能,例如“将地点提供给等待列表中的下一个人” —— 事件顺序方法允许将新的副作用轻松地链接到现有事件上。
|
||||
|
||||
事件采购类似于编年史数据模型[45],事件日志和事实表之间也有相似之处,您可以在星型模式中找到它(请参阅第93页上的“星星和雪花:分析模式”) 。
|
||||
事件顺序类似于编年史数据模型【45】,事件日志和事实表之间也有相似之处,您可以在星型模式中找到它(参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”) 。
|
||||
|
||||
专门的数据库如Event Store [46]已经被开发来支持使用事件采购的应用程序,但总的来说,这个方法是独立于任何特定的工具的。传统的数据库或基于日志的消息代理也可以用来构建这种风格的应用程序。
|
||||
专门的数据库如Event Store 【46】已经被开发来支持使用事件采购的应用程序,但总的来说,这个方法是独立于任何特定的工具的。传统的数据库或基于日志的消息代理也可以用来构建这种风格的应用程序。
|
||||
|
||||
#### 从事件日志中导出当前状态
|
||||
|
||||
事件日志本身并不是很有用,因为用户通常期望看到系统的当前状态,而不是修改的历史。例如,在购物网站上,用户期望能够看到他们购物车的当前内容,而不是他们对购物车所做的所有改变的附加列表。
|
||||
|
||||
因此,使用事件源的应用程序需要记录事件的日志(表示写入系统的数据),并将其转换为适合向用户显示的应用程序状态(从系统读取数据的方式[47 ])。这种转换可以使用任意的逻辑,但它应该是确定性的,以便您可以再次运行它并从事件日志中派生相同的应用程序状态。
|
||||
因此,使用事件源的应用程序需要记录事件的日志(表示写入系统的数据),并将其转换为适合向用户显示的应用程序状态(从系统读取数据的方式【47】)。这种转换可以使用任意的逻辑,但它应该是确定性的,以便您可以再次运行它并从事件日志中派生相同的应用程序状态。
|
||||
|
||||
与更改数据捕获一样,重放事件日志可以让您重新构建系统的当前状态。但是,日志压缩需要以不同的方式处理:
|
||||
|
||||
@ -335,9 +339,9 @@ Kafka Connect [41]致力于将广泛的数据库系统的变更数据捕获工
|
||||
|
||||
#### 命令和事件
|
||||
|
||||
事件采购哲学是仔细区分事件和命令[48]。当来自用户的请求首先到达时,它最初是一个命令:在这一点上它可能仍然失败,例如因为违反了一些完整性条件。应用程序必须首先验证它是否可以执行该命令。如果验证成功并且命令被接受,则它变成一个持久且不可变的事件。
|
||||
事件采购哲学是仔细区分事件和命令【48】。当来自用户的请求首先到达时,它最初是一个命令:在这一点上它可能仍然失败,例如因为违反了一些完整性条件。应用程序必须首先验证它是否可以执行该命令。如果验证成功并且命令被接受,则它变成一个持久且不可变的事件。
|
||||
|
||||
例如,如果用户试图注册特定用户名,或在飞机上或剧院中预定座位,则应用程序需要检查用户名或座位是否已被占用。 (我们先前在第364页的“容错概念”中讨论过这个例子。)当检查成功时,应用程序可以生成一个事件来指示特定的用户名是由特定的用户ID注册的,座位已经预留给特定的顾客。
|
||||
例如,如果用户试图注册特定用户名,或在飞机上或剧院中预定座位,则应用程序需要检查用户名或座位是否已被占用。 (我们先前在第364页的“[容错概念](ch8.md#容错概念)”中讨论过这个例子。)当检查成功时,应用程序可以生成一个事件来指示特定的用户名是由特定的用户ID注册的,座位已经预留给特定的顾客。
|
||||
|
||||
在事件发生的时候,这成为事实。即使客户稍后决定更改或取消预订,事实仍然是事实,他们以前曾为某个特定的座位进行预订,而更改或取消是稍后添加的单独事件。
|
||||
|
||||
@ -355,63 +359,63 @@ Kafka Connect [41]致力于将广泛的数据库系统的变更数据捕获工
|
||||
|
||||
无论国家如何变化,总会有一系列事件导致这些变化。即使事情已经解决,事实仍然是事实发生的事实。关键的想法是可变状态和不可变事件的附加日志不相互矛盾:它们是同一枚硬币的两面。所有变化的日志,变化日志,代表了随着时间的推移状态的演变。
|
||||
|
||||
如果您有数学上的倾向,那么您可能会说应用程序状态是随着时间的推移整合了一个事件流而得到的,而且当您按照时间区分状态时会得到一个更改流,如图11-6所示[ 49,50,51]。这个比喻有一定的局限性(例如,国家的二阶导数似乎没有意义),但这是考虑数据的一个有用的起点。
|
||||
如果您有数学上的倾向,那么您可能会说应用程序状态是随着时间的推移整合了一个事件流而得到的,而且当您按照时间区分状态时会得到一个更改流,如[图11-6](img/fig11-6.png)所示【49,50,51】。这个比喻有一定的局限性(例如,国家的二阶导数似乎没有意义),但这是考虑数据的一个有用的起点。
|
||||
|
||||
![](img/fig11-6.png)
|
||||
|
||||
**图11-6 当前应用程序状态和事件流之间的关系**
|
||||
|
||||
如果你持久地存储更新日志,那么这只是使状态重现的效果。如果你认为事件的日志是你的记录系统,并且从它派生出任何可变状态,那么就更容易推断通过系统的数据流。正如帕特·赫兰(Pat Helland)所说的[52]:
|
||||
如果你持久地存储更新日志,那么这只是使状态重现的效果。如果你认为事件的日志是你的记录系统,并且从它派生出任何可变状态,那么就更容易推断通过系统的数据流。正如帕特·赫兰(Pat Helland)所说的【52】:
|
||||
|
||||
> 事务日志记录对数据库所做的所有更改。高速追加是更改日志的唯一方法。从这个角度来看,数据库的内容会保存日志中最新记录值的缓存。事实是日志。数据库是日志子集的缓存。该缓存子集恰好是来自日志的每个记录和索引值的最新值。
|
||||
|
||||
日志压缩(如第456页的“日志压缩”中所述)是一种桥接日志和数据库状态之间区别的方法:它只保留每条记录的最新版本,并丢弃被覆盖的版本。
|
||||
日志压缩(如“[日志压缩](#日志压缩)”中所述)是一种桥接日志和数据库状态之间区别的方法:它只保留每条记录的最新版本,并丢弃被覆盖的版本。
|
||||
|
||||
#### 不可变事件的优点
|
||||
|
||||
数据库中的不变性是一个古老的想法。例如,会计师在数个世纪以来一直使用不变性财务簿记。当一笔交易发生时,它被记录在一个仅追加分类帐中,这本质上是描述货币,商品或服务已经转手的事件日志。账目,如损益或资产负债表,是从分类账中的交易中加起来得来的[53]。
|
||||
数据库中的不变性是一个古老的想法。例如,会计师在数个世纪以来一直使用不变性财务簿记。当一笔交易发生时,它被记录在一个仅追加分类帐中,这本质上是描述货币,商品或服务已经转手的事件日志。账目,如损益或资产负债表,是从分类账中的交易中加起来得来的【53】。
|
||||
|
||||
如果发生错误,会计师不会删除或更改分类帐中的错误交易 - 而是增加另一笔交易,以补偿错误,例如退还不正确的费用。不正确的交易将永远保留在分类帐中,因为审计原因可能很重要。如果从不正确的分类账导出的错误数字已经公布,那么下一个会计期间的数字就包括一个更正。这个过程在会计中是完全正常的[54]。
|
||||
如果发生错误,会计师不会删除或更改分类帐中的错误交易 - 而是增加另一笔交易,以补偿错误,例如退还不正确的费用。不正确的交易将永远保留在分类帐中,因为审计原因可能很重要。如果从不正确的分类账导出的错误数字已经公布,那么下一个会计期间的数字就包括一个更正。这个过程在会计中是完全正常的【54】。
|
||||
|
||||
尽管这种可审计性在金融系统中尤其重要,但对于不受这种严格管制的许多其他系统也是有益的。如“批处理输出的哲学”(第439页)中所述,如果您意外地部署了将错误数据写入数据库的错误代码,那么如果代码能够破坏性地覆盖数据,恢复将更加困难。通过不可变事件的追加日志,诊断发生的事情和从问题中恢复起来要容易得多。
|
||||
|
||||
不可变的事件也捕获比当前状态更多的信息。例如,在购物网站上,顾客可以将物品添加到他们的购物车,然后再将其移除。虽然第二个事件从订单履行角度取消了第一个事件,但为了分析目的,客户正在考虑某个特定项目,但是之后决定采取反对措施。也许他们会选择在未来购买,或者他们找到替代品。这个信息被记录在一个事件日志中,但是当它们从购物车中被删除时,这个信息会丢失在删除项目的数据库中[42]。
|
||||
不可变的事件也捕获比当前状态更多的信息。例如,在购物网站上,顾客可以将物品添加到他们的购物车,然后再将其移除。虽然第二个事件从订单履行角度取消了第一个事件,但为了分析目的,客户正在考虑某个特定项目,但是之后决定采取反对措施。也许他们会选择在未来购买,或者他们找到替代品。这个信息被记录在一个事件日志中,但是当它们从购物车中被删除时,这个信息会丢失在删除项目的数据库中【42】。
|
||||
|
||||
#### 从同一事件日志中获取多个视图
|
||||
|
||||
而且,通过从不变事件日志中分离可变状态,可以从事件的相同日志中派生出几个不同的面向读取的表示。这就像一个流的多个消费者一样工作(图11-5):例如,分析数据库Druid使用这种方法从Kafka直接获取[55],Pista chio是一个分布式的键值存储,使用Kafka作为提交日志[56],Kafka Connect接收器可以将来自Kafka的数据导出到各种不同的数据库和索引[41]。对于许多其他存储和索引系统(如搜索服务器)来说,类似地从分布式日志中获取输入也是有意义的(请参阅第455页上的“保持系统同步”)。
|
||||
而且,通过从不变事件日志中分离可变状态,可以从事件的相同日志中派生出几个不同的面向读取的表示。这就像一个流的多个消费者一样工作([图11-5](img/fig11-5.png)):例如,分析数据库Druid使用这种方法从Kafka直接获取【55】,Pista chio是一个分布式的键值存储,使用Kafka作为提交日志【56】,Kafka Connect接收器可以将来自Kafka的数据导出到各种不同的数据库和索引【41】。对于许多其他存储和索引系统(如搜索服务器)来说,类似地从分布式日志中获取输入也是有意义的(请参阅“[保持系统同步](#保持系统同步)”)。
|
||||
|
||||
从事件日志到数据库有一个明确的转换步骤,可以更容易地随时间推移您的应用程序:如果您想要引入一个以新的方式呈现现有数据的新功能,您可以使用事件日志来构建一个单独的新功能的读取优化视图,并与现有的一起运行
|
||||
|
||||
系统而不必修改它们。并行运行旧系统和新系统通常比在现有系统中执行复杂的模式迁移更容易。一旦旧的系统不再需要,你可以简单地关闭它并回收它的资源[47,57]。
|
||||
系统而不必修改它们。并行运行旧系统和新系统通常比在现有系统中执行复杂的模式迁移更容易。一旦旧的系统不再需要,你可以简单地关闭它并回收它的资源【47,57】。
|
||||
|
||||
如果您不必担心如何查询和访问数据,那么存储数据通常是非常简单的。模式设计,索引和存储引擎的许多复杂性都是希望支持某些查询和访问模式的结果(参见第3章)。出于这个原因,通过将数据写入的形式与读取形式分开,并允许几个不同的读取视图,可以获得很大的灵活性。这个想法有时被称为命令查询责任分离(CQRS)[42,58,59]。
|
||||
如果您不必担心如何查询和访问数据,那么存储数据通常是非常简单的。模式设计,索引和存储引擎的许多复杂性都是希望支持某些查询和访问模式的结果(参见[第3章](ch3.md))。出于这个原因,通过将数据写入的形式与读取形式分开,并允许几个不同的读取视图,可以获得很大的灵活性。这个想法有时被称为命令查询责任分离(CQRS)【42,58,59】。
|
||||
|
||||
数据库和模式设计的传统方法是基于数据必须以与查询相同的形式写入的谬误。有关正常化和非规范化的争论(请参阅第31页上的“多对一和多对多关系”),如果可以将数据从写入优化的事件日志转换为读取优化的应用程序状态,则变得基本无关紧要:在读取优化的视图中对数据进行非规范化是完全合理的,因为翻译过程为您提供了一种机制,使其与事件日志保持一致。
|
||||
数据库和模式设计的传统方法是基于数据必须以与查询相同的形式写入的谬误。有关正常化和非规范化的争论(参阅“[多对一和多对多的关系](ch2.md#多对一和多对多的关系)”),如果可以将数据从写入优化的事件日志转换为读取优化的应用程序状态,则变得基本无关紧要:在读取优化的视图中对数据进行非规范化是完全合理的,因为翻译过程为您提供了一种机制,使其与事件日志保持一致。
|
||||
|
||||
在第11页的“描述负载”中,我们讨论了Twitter的家庭时间表,最近一个特定用户正在关注的人(如邮箱)写的最近发布的推文缓存。这是阅读优化状态的另一个例子:家庭时间表高度变形,因为你的推文在所有跟随你的人的时间线上都是重复的。然而,扇出服务保持这种复制状态与新的推文和新的以下关系保持同步,这保持了复制的可管理性。
|
||||
在“[描述负载](ch1.md#描述负载)”中,我们讨论了推特主页时间表,最近一个特定用户正在关注的人(如邮箱)写的最近发布的推文缓存。这是阅读优化状态的另一个例子:家庭时间表高度变形,因为你的推文在所有跟随你的人的时间线上都是重复的。然而,扇出服务保持这种复制状态与新的推文和新的以下关系保持同步,这保持了复制的可管理性。
|
||||
|
||||
#### 并发控制
|
||||
|
||||
事件采集和更改数据捕获的最大缺点是事件日志的消费者通常是异步的,所以用户可能会写入日志,然后从日志派生的视图中读取并查找他们的写作还没有反映在读取视图。我们在第162页的“阅读您自己的作品”中讨论了这个问题和潜在的解决方案。
|
||||
事件采集和更改数据捕获的最大缺点是事件日志的消费者通常是异步的,所以用户可能会写入日志,然后从日志派生的视图中读取并查找他们的写作还没有反映在读取视图。我们在“[读己之写](ch5.md#读己之写)”中讨论了这个问题和潜在的解决方案。
|
||||
|
||||
一种解决方案是同步执行读取视图的更新,并将事件附加到日志中。这需要一个事务来将写入操作合并到一个原子单元中,所以要么需要将事件日志和读取视图保存在同一个存储系统中,要么需要跨不同系统的分布式事务。或者,您可以使用第350页上的“使用总订单广播实现线性化存储”中讨论的方法。
|
||||
一种解决方案是同步执行读取视图的更新,并将事件附加到日志中。这需要一个事务来将写入操作合并到一个原子单元中,所以要么需要将事件日志和读取视图保存在同一个存储系统中,要么需要跨不同系统的分布式事务。或者,您可以使用在“[使用全序广播实现线性化存储](ch9.md#使用全序广播实现线性化存储)”中讨论的方法。
|
||||
|
||||
另一方面,从事件日志导出当前状态也简化了并发控制的某些方面。对多个对象事务的需求(请参阅第228页上的“单对象和多对象操作”)源于单个用户操作,需要在多个不同的位置更改数据。通过事件采购,您可以设计一个事件,以便对用户操作进行独立的描述。用户操作只需要在一个地方进行一次写操作,即将事件附加到日志中,这很容易使原子化。
|
||||
另一方面,从事件日志导出当前状态也简化了并发控制的某些方面。对多个对象事务的需求(参阅“[单对象和多对象操作](ch7.md#单对象和多对象操作)”)源于单个用户操作,需要在多个不同的位置更改数据。通过事件采购,您可以设计一个事件,以便对用户操作进行独立的描述。用户操作只需要在一个地方进行一次写操作,即将事件附加到日志中,这很容易使原子化。
|
||||
|
||||
如果事件日志和应用程序状态以相同的方式分区(例如,为分区3中的客户处理事件只需要更新应用程序状态的分区3),则直接的单线程日志消费者不需要并发控制(write-by)构造,它一次只处理一个事件(另请参阅第252页的“实际的串行执行”)。该日志通过在分区中定义事件的串行顺序来消除并发性的不确定性[24]。如果一个事件触及多个状态分区,那么需要做更多的工作,我们将在第12章讨论。
|
||||
如果事件日志和应用程序状态以相同的方式分区(例如,为分区3中的客户处理事件只需要更新应用程序状态的分区3),则直接的单线程日志消费者不需要并发控制(write-by)构造,它一次只处理一个事件(参阅“[真的的串行执行](ch7.md#真的的串行执行)”)。该日志通过在分区中定义事件的串行顺序来消除并发性的不确定性【24】。如果一个事件触及多个状态分区,那么需要做更多的工作,我们将在[第12章](ch12.md)讨论。
|
||||
|
||||
#### 不变性的限制
|
||||
|
||||
许多不使用事件源模型的系统依赖于不可变性:各种数据库在内部使用不可变的数据结构或多版本数据来支持时间点快照(请参见“索引和快照隔离” )。 Git,Mercurial和Fossil等版本控制系统也依靠不可变的数据来保存文件的版本历史记录。
|
||||
许多不使用事件源模型的系统依赖于不可变性:各种数据库在内部使用不可变的数据结构或多版本数据来支持时间点快照(参见“[索引和快照隔离](ch7.md#索引和快照隔离)” )。 Git,Mercurial和Fossil等版本控制系统也依靠不可变的数据来保存文件的版本历史记录。
|
||||
|
||||
永远保持所有变化的不变的历史在多大程度上是可行的?答案取决于数据集中的流失量。一些工作负载主要是添加数据,很少更新或删除;他们很容易使不变。其他工作负载在较小的数据集上有较高的更新和删除率;在这些情况下,不可改变的历史可能变得过于庞大,碎片化可能成为一个问题,压缩和垃圾收集的表现对于操作的鲁棒性变得至关重要[60,61]。
|
||||
永远保持所有变化的不变的历史在多大程度上是可行的?答案取决于数据集中的流失量。一些工作负载主要是添加数据,很少更新或删除;他们很容易使不变。其他工作负载在较小的数据集上有较高的更新和删除率;在这些情况下,不可改变的历史可能变得过于庞大,碎片化可能成为一个问题,压缩和垃圾收集的表现对于操作的鲁棒性变得至关重要【60,61】。
|
||||
|
||||
除了性能方面的原因外,也可能出于管理方面的原因需要删除数据的情况,尽管这些数据都是不可变的。例如,隐私条例可能要求在关闭帐户后删除用户的个人信息,数据保护立法可能要求删除错误的信息,或者可能需要包含敏感信息的意外泄露。
|
||||
|
||||
在这种情况下,仅仅在日志中添加另一个事件来指示先前的数据应该被视为删除是不够的 - 您实际上是想重写历史并假装数据从未写在第一位。例如,Datomic调用这个特性excision [62],而Fossil版本控制系统有一个类似的概念叫做shunning [63]。
|
||||
在这种情况下,仅仅在日志中添加另一个事件来指示先前的数据应该被视为删除是不够的 —— 您实际上是想重写历史并假装数据从未写在第一位。例如,Datomic调用这个特性excision 【62】,而Fossil版本控制系统有一个类似的概念叫做shunning 【63】。
|
||||
|
||||
真正的删除数据是非常困难的[64],因为拷贝可以存在于很多地方:例如,存储引擎,文件系统和SSD通常写入一个新的位置,而不是覆盖到位[52],而备份通常是故意不可改变的防止意外删除或腐败。删除更多的是“使检索数据更难”,而不是“使检索数据不可能”。无论如何,有时您必须尝试,正如我们在“立法和自律”中所看到的第542页。
|
||||
真正的删除数据是非常困难的【64】,因为拷贝可以存在于很多地方:例如,存储引擎,文件系统和SSD通常写入一个新的位置,而不是覆盖到位【52】,而备份通常是故意不可改变的防止意外删除或腐败。删除更多的是“使检索数据更难”,而不是“使检索数据不可能”。无论如何,有时您必须尝试,正如我们在“[立法和自律](ch12.md#立法和自律)”中所看到的。
|
||||
|
||||
|
||||
|
||||
@ -419,17 +423,17 @@ Kafka Connect [41]致力于将广泛的数据库系统的变更数据捕获工
|
||||
|
||||
到目前为止,本章中我们已经讨论了流的来源(用户活动事件,传感器和写入数据库),我们讨论了流如何传输(通过直接消息传送,通过消息代理和事件日志)。
|
||||
|
||||
剩下的就是讨论一下你可以用流做什么 - 也就是说,你可以处理它。一般来说,有三种选择:
|
||||
剩下的就是讨论一下你可以用流做什么 —— 也就是说,你可以处理它。一般来说,有三种选择:
|
||||
|
||||
1. 您可以将事件中的数据写入数据库,缓存,搜索索引或类似的存储系统,然后由其他客户端查询。如图11-5所示,这是保持数据库与系统其他部分发生更改同步的好方法 - 特别是当流消费者是写入数据库的唯一客户端时。写入存储系统的流程相当于我们在“批处理工作流程的输出”页面上讨论的内容。
|
||||
1. 您可以将事件中的数据写入数据库,缓存,搜索索引或类似的存储系统,然后由其他客户端查询。如[图11-5](img/fig11-5.png)所示,这是保持数据库与系统其他部分发生更改同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。写入存储系统的流程相当于我们在“批处理工作流程的输出”页面上讨论的内容。
|
||||
2. 您可以以某种方式将事件推送给用户,例如通过发送电子邮件警报或推送通知,或通过将事件流式传输到可实时显示的实时仪表板。在这种情况下,人是流的最终消费者。
|
||||
3. 您可以处理一个或多个输入流以产生一个或多个输出流。数据流可能会经过由几个这样的处理阶段组成的流水线,然后才会输出(选项1或2)。
|
||||
|
||||
在本章的其余部分中,我们将讨论选项3:处理流以产生其他派生流。处理这样的流的代码片段被称为操作员或作业。它与我们在第10章中讨论过的Unix进程和MapReduce作业密切相关,数据流的模式是相似的:一个流处理器以只读的方式使用输入流,并将其输出写入一个不同的位置时尚。
|
||||
在本章的其余部分中,我们将讨论选项3:处理流以产生其他派生流。处理这样的流的代码片段被称为操作员或作业。它与我们在[第10章](ch10.md)中讨论过的Unix进程和MapReduce作业密切相关,数据流的模式是相似的:一个流处理器以只读的方式使用输入流,并将其输出写入一个不同的位置时尚。
|
||||
|
||||
流处理器中的分区和并行化模式也非常类似于第10章中介绍的MapReduce和数据流引擎,因此我们不在这里重复这些主题。基本的映射操作(如转换和过滤记录)也是一样的。
|
||||
流处理器中的分区和并行化模式也非常类似于[第10章](ch10.md)中介绍的MapReduce和数据流引擎,因此我们不在这里重复这些主题。基本的映射操作(如转换和过滤记录)也是一样的。
|
||||
|
||||
批量作业的一个关键区别是流不会结束。这种差别有很多含义:正如本章开始部分所讨论的,排序对无界数据集没有意义,因此不能使用排序合并联接(请参阅“减少联接和分组”)。容错机制也必须改变:对于已经运行了几分钟的批处理作业,可以简单地从头开始重新启动失败的任务,但是对于已经运行数年的流作业,在开始后重新开始崩溃可能不是一个可行的选择。
|
||||
批量作业的一个关键区别是流不会结束。这种差别有很多含义:正如本章开始部分所讨论的,排序对无界数据集没有意义,因此不能使用排序合并联接(请参阅“[减少连接和分组](ch10.md#减少连接和分组)”)。容错机制也必须改变:对于已经运行了几分钟的批处理作业,可以简单地从头开始重新启动失败的任务,但是对于已经运行数年的流作业,在开始后重新开始崩溃可能不是一个可行的选择。
|
||||
|
||||
### 流处理的应用
|
||||
|
||||
@ -444,13 +448,13 @@ Kafka Connect [41]致力于将广泛的数据库系统的变更数据捕获工
|
||||
|
||||
#### 复杂的事件处理
|
||||
|
||||
复杂事件处理(CEP)是20世纪90年代为分析事件流而开发的一种方法,尤其适用于需要搜索某些事件模式的应用程序[65,66]。与正则表达式允许您在字符串中搜索特定字符模式的方式类似,CEP允许您指定规则以在流中搜索某些事件模式。
|
||||
复杂事件处理(CEP)是20世纪90年代为分析事件流而开发的一种方法,尤其适用于需要搜索某些事件模式的应用程序【65,66】。与正则表达式允许您在字符串中搜索特定字符模式的方式类似,CEP允许您指定规则以在流中搜索某些事件模式。
|
||||
|
||||
CEP系统通常使用高级声明式查询语言(如SQL或图形用户界面)来描述应该检测到的事件模式。这些查询被提交给一个处理引擎,该引擎使用输入流并在内部维护一个执行所需匹配的状态机。当发现匹配时,引擎发出一个复杂的事件(因此名字)与事件模式的细节[67]。
|
||||
CEP系统通常使用高级声明式查询语言(如SQL或图形用户界面)来描述应该检测到的事件模式。这些查询被提交给一个处理引擎,该引擎使用输入流并在内部维护一个执行所需匹配的状态机。当发现匹配时,引擎发出一个复杂的事件(因此名字)与事件模式的细节【67】。
|
||||
|
||||
在这些系统中,查询和数据之间的关系与普通数据库相比是颠倒的。通常情况下,数据库会持久存储数据,并将查询视为暂时的:当查询进入时,数据库搜索与查询匹配的数据,然后在查询完成时忘记查询。 CEP引擎反转了这些角色:查询是长期存储的,来自输入流的事件不断流过它们,以搜索匹配事件模式的查询[68]。
|
||||
在这些系统中,查询和数据之间的关系与普通数据库相比是颠倒的。通常情况下,数据库会持久存储数据,并将查询视为暂时的:当查询进入时,数据库搜索与查询匹配的数据,然后在查询完成时忘记查询。 CEP引擎反转了这些角色:查询是长期存储的,来自输入流的事件不断流过它们,以搜索匹配事件模式的查询【68】。
|
||||
|
||||
CEP的实现包括Esper [69],IBM InfoSphere Streams [70],Apama,TIBCO StreamBase和SQLstream。像Samza这样的分布式流处理器也获得了对流声明式查询的SQL支持[71]。
|
||||
CEP的实现包括Esper 【69】,IBM InfoSphere Streams 【70】,Apama,TIBCO StreamBase和SQLstream。像Samza这样的分布式流处理器也获得了对流声明式查询的SQL支持【71】。
|
||||
|
||||
#### 流分析
|
||||
|
||||
@ -460,38 +464,40 @@ CEP的实现包括Esper [69],IBM InfoSphere Streams [70],Apama,TIBCO Strea
|
||||
* 计算一段时间内某个值的滚动平均值
|
||||
* 将当前的统计数据与以前的时间间隔进行比较(例如,检测趋势或提醒与上周同期相比过高或过低的指标)
|
||||
|
||||
这些统计信息通常是在固定的时间间隔内进行计算的,例如,您可能想知道在过去5分钟内每秒对服务的平均查询次数,以及在此期间的第99百分位响应时间。在几分钟内平均,从一秒钟到下一秒钟平滑无关的波动,同时还能及时了解交通模式的任何变化。您汇总的时间间隔称为窗口,我们将在第468页的“关于时间的推理”中更详细地讨论窗口。
|
||||
这些统计信息通常是在固定的时间间隔内进行计算的,例如,您可能想知道在过去5分钟内每秒对服务的平均查询次数,以及在此期间的第99百分位响应时间。在几分钟内平均,从一秒钟到下一秒钟平滑无关的波动,同时还能及时了解交通模式的任何变化。您汇总的时间间隔称为窗口,我们将在“[关于时间的推理](#关于时间的推理)”中更详细地讨论窗口。
|
||||
|
||||
|
||||
|
||||
流分析系统有时使用概率算法,例如Bloom filter(我们在第79页的“性能优化”中遇到过),设置成员资格,HyperLogLog [72]基数估计以及各种百分比估计算法(请参阅“Percentiles in Practice “第16页)。概率算法产生近似的结果,但是具有在流处理器中比精确算法需要少得多的存储器的优点。近似算法的使用有时会使人们相信流处理系统总是有损和不精确的,但这是错误的:流处理没有任何内在的近似,而概率算法只是一个优化[73]。
|
||||
许多开源分布式流处理框架的设计都是以分析为基础的:例如Apache Storm,Spark Streaming,Flink,Concord,Samza和Kafka Streams [74]。托管服务包括Google Cloud Dataflow和Azure Stream Analytics。
|
||||
流分析系统有时使用概率算法,例如Bloom filter(我们在“[性能优化](ch3.md#性能优化)”中遇到过),设置成员资格,HyperLogLog 【72】基数估计以及各种百分比估计算法(请参阅“[实践中的百分位点](ch1.md#实践中的百分位点)“第16页)。概率算法产生近似的结果,但是具有在流处理器中比精确算法需要少得多的存储器的优点。近似算法的使用有时会使人们相信流处理系统总是有损和不精确的,但这是错误的:流处理没有任何内在的近似,而概率算法只是一个优化【73】。
|
||||
|
||||
许多开源分布式流处理框架的设计都是以分析为基础的:例如Apache Storm,Spark Streaming,Flink,Concord,Samza和Kafka Streams 【74】。托管服务包括Google Cloud Dataflow和Azure Stream Analytics。
|
||||
|
||||
#### 保持物化视图
|
||||
|
||||
我们在第451页的“数据库和数据流”中看到,可以使用数据库更改流来保持派生数据系统(如缓存,搜索索引和数据仓库)与源数据库保持最新。我们可以将这些示例视为维护实体化视图的具体情况(请参阅“聚合:数据多维数据集和实例化视图”(第101页)):导出某个数据集的替代视图,以便可以高效地查询它,并在底层数据更改[50]。
|
||||
我们在“[数据库和数据流](#数据库和数据流)”中看到,可以使用数据库更改流来保持派生数据系统(如缓存,搜索索引和数据仓库)与源数据库保持最新。我们可以将这些示例视为维护实体化视图的具体情况(请参阅“[聚合:数据立方体和物化视图](ch3.md#聚合:数据立方体和物化视图)”):导出某个数据集的替代视图,以便可以高效地查询它,并在底层数据更改【50】。
|
||||
|
||||
同样,在事件采购中,应用程序状态通过应用事件日志来维护;这里的应用状态也是一种物化视图。与流分析场景不同,在某个时间窗口内仅考虑事件通常是不够的:构建物化视图可能需要在任意时间段内的所有事件,除了可能由日志压缩丢弃的任何过时事件(请参阅“日志压缩“)。实际上,您需要一个可以一直延伸到一开始的窗口。
|
||||
原则上,任何流处理器都可以用于物化视图维护,尽管永久维护事件的需要与一些主要在有限持续时间的窗口上运行的面向分析的框架的假设背道而驰。 Samza和Kafka Streams支持这种用法,建立在Kafka对夯实的支持上[75]。
|
||||
同样,在事件采购中,应用程序状态通过应用事件日志来维护;这里的应用状态也是一种物化视图。与流分析场景不同,在某个时间窗口内仅考虑事件通常是不够的:构建物化视图可能需要在任意时间段内的所有事件,除了可能由日志压缩丢弃的任何过时事件(请参阅“[日志压缩](#日志压缩)“)。实际上,您需要一个可以一直延伸到一开始的窗口。
|
||||
|
||||
原则上,任何流处理器都可以用于物化视图维护,尽管永久维护事件的需要与一些主要在有限持续时间的窗口上运行的面向分析的框架的假设背道而驰。 Samza和Kafka Streams支持这种用法,建立在Kafka对夯实的支持上【75】。
|
||||
|
||||
#### 在流上搜索
|
||||
|
||||
除了允许搜索由多个事件组成的模式的CEP外,还有时需要基于复杂的标准(例如全文搜索查询)来搜索单个事件。
|
||||
|
||||
例如,媒体监测服务可以订阅新闻文章和媒体广播,并搜索任何关于公司,产品或感兴趣的话题的新闻。这是通过预先制定一个搜索查询来完成的,然后不断地将新闻项目流与这个查询进行匹配。在一些网站上也有类似的功能:例如,房地产网站的用户在市场上出现符合其搜索条件的新房产时,可以要求通知。 Elasticsearch [76]的渗滤器功能是实现这种流式搜索的一种选择。
|
||||
例如,媒体监测服务可以订阅新闻文章和媒体广播,并搜索任何关于公司,产品或感兴趣的话题的新闻。这是通过预先制定一个搜索查询来完成的,然后不断地将新闻项目流与这个查询进行匹配。在一些网站上也有类似的功能:例如,房地产网站的用户在市场上出现符合其搜索条件的新房产时,可以要求通知。 Elasticsearch 【76】的渗滤器功能是实现这种流式搜索的一种选择。
|
||||
|
||||
传统的搜索引擎首先索引文件,然后在索引上运行查询。相比之下,搜索一个数据流将会处理它的头部:查询被存储,文档通过查询运行,就像CEP一样。在最简单的情况下,您可以针对每个查询来测试每个文档,但是如果您有大量查询,这可能会变慢。为了优化过程,可以对查询和文档进行索引,从而缩小可能匹配的查询集合[77]。
|
||||
传统的搜索引擎首先索引文件,然后在索引上运行查询。相比之下,搜索一个数据流将会处理它的头部:查询被存储,文档通过查询运行,就像CEP一样。在最简单的情况下,您可以针对每个查询来测试每个文档,但是如果您有大量查询,这可能会变慢。为了优化过程,可以对查询和文档进行索引,从而缩小可能匹配的查询集合【77】。
|
||||
|
||||
#### 消息传递和RPC
|
||||
|
||||
在第136页的“消息传递数据流”中,我们讨论了消息传递系统作为RPC的替代方案,即作为通信服务的机制,例如在参与者模型中所使用的。虽然这些系统也是基于消息和事件,但我们通常不会将它们视为流处理器:
|
||||
在第136页的“[消息传递数据流](ch4.md#消息传递数据流)”中,我们讨论了消息传递系统作为RPC的替代方案,即作为通信服务的机制,例如在参与者模型中所使用的。虽然这些系统也是基于消息和事件,但我们通常不会将它们视为流处理器:
|
||||
|
||||
Actor框架主要是管理通信模块的并发和分布式执行的机制,而流处理主要是数据管理技术。
|
||||
|
||||
* 参与者之间的交流往往是短暂的,而且是一对一的,而事件日志则是持久的,多用户的。
|
||||
* 参与者可以以任意方式进行通信(包括循环请求/响应模式),但流处理器通常设置在非循环流水线中,其中每个流是一个特定作业的输出,并且从一组明确定义的输入流派生。
|
||||
|
||||
也就是说,RPC类系统和流处理之间有一些交叉区域。例如,Apache Storm有一个称为分布式RPC的功能,它允许将用户查询分散到一系列也处理事件流的节点上;这些查询然后与来自输入流的事件交织,结果可以被汇总并发回给用户[78]。 (另请参阅“多分区数据处理”(第514页)。)
|
||||
也就是说,RPC类系统和流处理之间有一些交叉区域。例如,Apache Storm有一个称为分布式RPC的功能,它允许将用户查询分散到一系列也处理事件流的节点上;这些查询然后与来自输入流的事件交织,结果可以被汇总并发回给用户【78】。 (另参阅“[多分区数据处理](ch12.md#多分区数据处理)”)
|
||||
|
||||
也可以使用actor框架来处理流。但是,很多这样的框架在崩溃的情况下不能保证消息的传递,所以这个过程不是容错的,除非你实现了额外的重试逻辑。
|
||||
|
||||
@ -501,13 +507,13 @@ Actor框架主要是管理通信模块的并发和分布式执行的机制,而
|
||||
|
||||
在批处理过程中,处理任务通过大量的历史事件迅速收缩。如果需要按时间分类,批处理需要查看每个事件中嵌入的时间戳。查看运行批处理的机器的系统时钟没有意义,因为处理运行的时间与事件实际发生的时间无关。
|
||||
|
||||
批处理可以在几分钟内读取一年的历史事件;在大多数情况下,感兴趣的时间表是历史的一年,而不是几分钟的处理。而且,在事件中使用时间戳允许处理确定性的:在相同的输入上再次运行相同的处理过程会得到相同的结果(请参阅“故障容错”在页面429)。
|
||||
批处理可以在几分钟内读取一年的历史事件;在大多数情况下,感兴趣的时间表是历史的一年,而不是几分钟的处理。而且,在事件中使用时间戳允许处理确定性的:在相同的输入上再次运行相同的处理过程会得到相同的结果(参阅“[故障容错](ch10.md#故障容错)”)。
|
||||
|
||||
另一方面,许多流处理框架使用处理机器上的本地系统时钟(处理时间)来确定窗口[79]。这种方法具有简单的优点,事件创建和事件处理之间的延迟可以忽略不计。然而,如果存在任何显着的处理滞后,即处理可能比事件实际发生的时间显着晚,则会中断处理。
|
||||
另一方面,许多流处理框架使用处理机器上的本地系统时钟(处理时间)来确定窗口【79】。这种方法具有简单的优点,事件创建和事件处理之间的延迟可以忽略不计。然而,如果存在任何显着的处理滞后,即处理可能比事件实际发生的时间显着晚,则会中断处理。
|
||||
|
||||
#### 事件时间与处理时间
|
||||
|
||||
有许多原因可能会延迟处理:排队,网络故障(请参阅第267页的“不可靠的网络”),导致消息代理或处理器中出现争用的性能问题,重新启动流消费者或重新处理过去的事件(请参阅第451页的“重播旧消息”),或者在修复代码中的错误之后进行恢复。
|
||||
有许多原因可能会延迟处理:排队,网络故障(请参阅第267页的“[不可靠的网络](ch8.md#不可靠的网络)”),导致消息代理或处理器中出现争用的性能问题,重新启动流消费者或重新处理过去的事件(参阅“[重播旧消息](#重播旧消息)”),或者在修复代码中的错误之后进行恢复。
|
||||
|
||||
而且,消息延迟还可能导致消息的不可预知的排序。例如,假设用户首先发出一个Web请求(由Web服务器A处理),然后发出第二个请求(由服务器B处理)。 A和B发出描述他们处理的请求的事件,但是B的事件在A的事件发生之前到达消息代理。现在,流处理器将首先看到B事件,然后看到A事件,即使它们实际上是以相反的顺序发生的。
|
||||
|
||||
@ -515,7 +521,7 @@ Actor框架主要是管理通信模块的并发和分布式执行的机制,而
|
||||
|
||||
[^ii]: 感谢Flink社区的Kostas Kloudas提出这个比喻。
|
||||
|
||||
令人困惑的事件时间和处理时间导致错误的数据。例如,假设您有一个流处理器来测量请求率(计算每秒请求数)。如果您重新部署流处理器,则可能会关闭一分钟,并在事件恢复时处理积压的事件。如果您根据处理时间来衡量速率,那么看起来好像在处理积压时突然出现异常的请求高峰,而事实上请求的实际速率是稳定的(图11-7)。
|
||||
令人困惑的事件时间和处理时间导致错误的数据。例如,假设您有一个流处理器来测量请求率(计算每秒请求数)。如果您重新部署流处理器,则可能会关闭一分钟,并在事件恢复时处理积压的事件。如果您根据处理时间来衡量速率,那么看起来好像在处理积压时突然出现异常的请求高峰,而事实上请求的实际速率是稳定的([图11-7](img/fig11-7.png))。
|
||||
|
||||
![](img/fig11-7.png)
|
||||
|
||||
@ -527,20 +533,20 @@ Actor框架主要是管理通信模块的并发和分布式执行的机制,而
|
||||
|
||||
例如,假设您将事件分组为一分钟的窗口,以便您可以统计每分钟的请求数。你已经计算了一些事件,这些事件的时间戳是在第37分钟的时间落下的,时间已经推移了。现在大部分的事件都在一小时的第38和第39分钟之内。你什么时候宣布你已经完成了第37分钟的窗口,并输出其计数器值?
|
||||
|
||||
在一段时间没有看到任何新的事件之后,您可以超时并宣布一个窗口,但仍然可能发生某些事件被缓存在另一台计算机上,由于网络中断而延迟。您需要能够处理窗口已经声明完成后到达的这样的滞留事件。大体上,你有两个选择[1]:
|
||||
在一段时间没有看到任何新的事件之后,您可以超时并宣布一个窗口,但仍然可能发生某些事件被缓存在另一台计算机上,由于网络中断而延迟。您需要能够处理窗口已经声明完成后到达的这样的滞留事件。大体上,你有两个选择【1】:
|
||||
|
||||
1. 忽略这些零散的事件,因为它们在正常情况下可能只是一小部分事件。您可以将丢弃事件的数量作为度量标准进行跟踪,并在您开始丢弃大量数据时发出警报。
|
||||
2. 发布一个更正,更新的窗口与包含散兵队员的价值。您可能还需要收回以前的输出。
|
||||
|
||||
在某些情况下,可以使用特殊的消息来指示“从现在开始,不会有比t更早的时间戳的消息”,消费者可以使用它来触发窗口[81]。但是,如果不同机器上的多个生产者正在生成事件,每个事件都有自己的最小时间戳阈值,则消费者需要分别跟踪每个生产者。在这种情况下添加和删除生产者是比较棘手的。
|
||||
在某些情况下,可以使用特殊的消息来指示“从现在开始,不会有比t更早的时间戳的消息”,消费者可以使用它来触发窗口【81】。但是,如果不同机器上的多个生产者正在生成事件,每个事件都有自己的最小时间戳阈值,则消费者需要分别跟踪每个生产者。在这种情况下添加和删除生产者是比较棘手的。
|
||||
|
||||
#### 你用的是什么时间?
|
||||
|
||||
当事件可以在系统中的多个点缓冲时,为事件分配时间戳更加困难。例如,考虑将使用率度量的事件报告给服务器的移动应用程序。该应用程序可能会在设备处于脱机状态时使用,在这种情况下,它将在设备上本地缓冲事件,并在下一次可用的互联网连接(可能是几小时甚至几天)时将它们发送到服务器。对于这个流的任何消费者来说,这些事件将显示为极其滞后的落后者。
|
||||
|
||||
在这种情况下,根据移动设备的本地时钟,事件的时间戳实际上应该是发生用户交互的时间。但是,用户控制的设备上的时钟通常是不可信的,因为它可能会被意外或故意设置为错误的时间(请参见“时钟同步和精度”(第269页))。服务器收到事件的时间(根据服务器的时钟)更可能是准确的,因为服务器在您的控制之下,但在描述用户交互方面意义不大。
|
||||
在这种情况下,根据移动设备的本地时钟,事件的时间戳实际上应该是发生用户交互的时间。但是,用户控制的设备上的时钟通常是不可信的,因为它可能会被意外或故意设置为错误的时间(请参见“[时钟同步与准确性](ch8.md#时钟同步与准确性)”)。服务器收到事件的时间(根据服务器的时钟)更可能是准确的,因为服务器在您的控制之下,但在描述用户交互方面意义不大。
|
||||
|
||||
要调整不正确的设备时钟,一种方法是记录三个时间戳[82]:
|
||||
要调整不正确的设备时钟,一种方法是记录三个时间戳【82】:
|
||||
|
||||
* 事件发生的时间,根据设备时钟
|
||||
* 根据设备时钟将事件发送到服务器的时间
|
||||
@ -552,15 +558,15 @@ Actor框架主要是管理通信模块的并发和分布式执行的机制,而
|
||||
|
||||
#### 窗口的类型
|
||||
|
||||
一旦你知道如何确定一个事件的时间戳,下一步就是决定如何定义一段时间的窗口。窗口然后可以用于聚合,例如计数事件,或计算窗口内的值的平均值。有几种窗口是常用的[79,83]:
|
||||
一旦你知道如何确定一个事件的时间戳,下一步就是决定如何定义一段时间的窗口。窗口然后可以用于聚合,例如计数事件,或计算窗口内的值的平均值。有几种窗口是常用的【79,83】:
|
||||
|
||||
***Tumbling窗口***
|
||||
|
||||
一个翻滚的窗口有一个固定的长度,每个事件都属于一个窗口。例如,如果您有1分钟的翻滚窗口,则所有时间戳在10:03:00和10:03:59之间的事件会被分组到一个窗口中,10:04:00和10:04:59之间的事件下一个窗口,等等。您可以通过获取每个事件时间戳并将其四舍五入到最接近的分钟来确定它所属的窗口,从而实现1分钟的翻滚窗口。
|
||||
一个翻滚的窗口有一个固定的长度,每个事件都属于一个窗口。例如,如果您有1分钟的翻滚窗口,则所有时间戳在`10:03:00`和`10:03:59`之间的事件会被分组到一个窗口中,`10:04:00`和`10:04:59`之间的事件下一个窗口,等等。您可以通过获取每个事件时间戳并将其四舍五入到最接近的分钟来确定它所属的窗口,从而实现1分钟的翻滚窗口。
|
||||
|
||||
***Hopping窗***
|
||||
|
||||
跳频窗口也具有固定的长度,但允许窗口重叠以提供一些平滑。例如,1分钟跳跃大小的5分钟窗口将包含10:03:00至10:07:59之间的事件,则下一个窗口将覆盖10:04:00至10:08之间的事件: 59,等等。您可以通过首先计算1分钟滚动窗口,然后聚合在几个相邻的窗口上来实现此跳频窗口。
|
||||
跳频窗口也具有固定的长度,但允许窗口重叠以提供一些平滑。例如,1分钟跳跃大小的5分钟窗口将包含`10:03:00`至`10:07:59`之间的事件,则下一个窗口将覆盖`10:04:00`至`10:08`之间的事件: 59,等等。您可以通过首先计算1分钟滚动窗口,然后聚合在几个相邻的窗口上来实现此跳频窗口。
|
||||
|
||||
***滑动窗口***
|
||||
|
||||
@ -568,16 +574,16 @@ Actor框架主要是管理通信模块的并发和分布式执行的机制,而
|
||||
|
||||
***会话窗口***
|
||||
|
||||
与其他窗口类型不同,会话窗口没有固定的持续时间。相反,它是通过将同一用户的所有事件分组在一起,并在时间上紧密地组合在一起来定义的,并且当用户在一段时间内不活动时(例如,如果30分钟内没有事件),窗口结束。会话化是网站分析的常见要求(请参阅第406页的“GROUP BY”)。
|
||||
与其他窗口类型不同,会话窗口没有固定的持续时间。相反,它是通过将同一用户的所有事件分组在一起,并在时间上紧密地组合在一起来定义的,并且当用户在一段时间内不活动时(例如,如果30分钟内没有事件),窗口结束。会话化是网站分析的常见要求(参阅“[GROUP BY](ch10.md#GROUP BY)”)。
|
||||
|
||||
### 流式连接
|
||||
|
||||
在第10章中,我们讨论了批处理作业如何通过关键连接数据集,以及这种连接如何构成数据管道的重要组成部分。由于流处理将数据管道概括为对无界数据集进行增量处理,因此对流进行连接的需求也完全相同。
|
||||
在[第10章](ch10.md)中,我们讨论了批处理作业如何通过关键连接数据集,以及这种连接如何构成数据管道的重要组成部分。由于流处理将数据管道概括为对无界数据集进行增量处理,因此对流进行连接的需求也完全相同。
|
||||
|
||||
然而,新事件随时可能出现在一个流中,这使得加入流比批处理作业更具挑战性。为了更好地理解情况,我们来区分三种不同类型的连接:流 - 流连接,流表连接和表连接[84]。在下面的章节中,我们将通过例子来说明。
|
||||
然而,新事件随时可能出现在一个流中,这使得加入流比批处理作业更具挑战性。为了更好地理解情况,我们来区分三种不同类型的连接:流-流连接,流表连接和表连接【84】。在下面的章节中,我们将通过例子来说明。
|
||||
流 - 流连接(窗口连接)
|
||||
|
||||
假设您的网站上有搜索功能,并且想要检测搜索到的网址的近期趋势。每次有人输入搜索查询时,都会记录包含查询和返回结果的事件。每当有人点击其中一个搜索结果时,就会记录另一个记录点击的事件。为了计算搜索结果中每个网址的点击率,您需要将搜索操作和点击操作的事件组合在一起,这些事件通过具有相同的会话ID进行连接。广告系统需要类似的分析[85]。
|
||||
假设您的网站上有搜索功能,并且想要检测搜索到的网址的近期趋势。每次有人输入搜索查询时,都会记录包含查询和返回结果的事件。每当有人点击其中一个搜索结果时,就会记录另一个记录点击的事件。为了计算搜索结果中每个网址的点击率,您需要将搜索操作和点击操作的事件组合在一起,这些事件通过具有相同的会话ID进行连接。广告系统需要类似的分析【85】。
|
||||
|
||||
如果用户放弃他们的搜索,点击可能永远不会到来,即使它到了,搜索和点击之间的时间可能是高度可变的:在很多情况下,它可能是几秒钟,但可能长达几天或几周(如果用户运行搜索,忘记关于该浏览器选项卡,然后返回到选项卡,稍后再单击一个结果)。由于可变的网络延迟,点击事件甚至可能在搜索事件之前到达。您可以选择合适的加入窗口,例如,如果间隔至多一小时发生一次搜索,您可以选择加入搜索。
|
||||
|
||||
@ -587,26 +593,28 @@ Actor框架主要是管理通信模块的并发和分布式执行的机制,而
|
||||
|
||||
#### 流表连接(stream enrichment)
|
||||
|
||||
在第404页的“示例:用户活动事件分析”(图10-2)中,我们看到了加入两个数据集的批量作业示例:一组用户活动事件和一个用户配置文件数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的:输入是
|
||||
在“[示例:用户活动事件分析](ch10.md#示例:用户活动事件分析)”([图10-2](img/fig10-2.png))中,我们看到了加入两个数据集的批量作业示例:一组用户活动事件和一个用户配置文件数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的:输入是包含用户ID的活动事件流,并且输出是活动事件流,其中用户ID已经用关于用户的简档信息来扩充。这个过程有时被称为使用来自数据库的信息来丰富活动事件。
|
||||
|
||||
包含用户ID的活动事件流,并且输出是活动事件流,其中用户ID已经用关于用户的简档信息来扩充。这个过程有时被称为使用来自数据库的信息来丰富活动事件。
|
||||
要执行此联接,流程过程需要一次查看一个活动事件,在数据库中查找事件的用户标识,并将该概要信息添加到活动事件中。数据库查询可以通过查询远程数据库来实现;但是,正如在“[示例:分析用户活动事件](ch10.md#示例:分析用户活动事件)”一节中讨论的,此类远程查询可能会很慢并且有可能导致数据库过载【75】。
|
||||
|
||||
另一种方法是将数据库副本加载到流处理器中,以便在本地进行查询而无需网络往返。这种技术与我们在“[Map端连接](ch10.md#Map端连接)”中讨论的散列连接非常相似:如果数据库的本地副本足够小,则本地副本可能是内存中的散列表,或者是本地磁盘上的索引。
|
||||
|
||||
要执行此联接,流程过程需要一次查看一个活动事件,在数据库中查找事件的用户标识,并将该概要信息添加到活动事件中。数据库查询可以通过查询远程数据库来实现;但是,正如在“示例:分析用户活动事件”一节中讨论的,此类远程查询可能会很慢并且有可能导致数据库过载[75]。
|
||||
另一种方法是将数据库副本加载到流处理器中,以便在本地进行查询而无需网络往返。这种技术与我们在408页的“Map-Side连接”中讨论的散列连接非常相似:如果数据库的本地副本足够小,则本地副本可能是内存中的散列表,或者是本地磁盘上的索引。
|
||||
与批处理作业的区别在于批处理作业使用数据库的时间点快照作为输入,而流处理器是长时间运行的,并且数据库的内容可能随时间而改变,所以流处理器数据库的本地副本需要保持最新。这个问题可以通过更改数据捕获来解决:流处理器可以订阅用户配置文件数据库的更新日志以及活动事件流。在创建或修改配置文件时,流处理器会更新其本地副本。因此,我们获得两个流之间的连接:活动事件和配置文件更新。
|
||||
|
||||
流表连接实际上非常类似于流 - 流连接;最大的区别在于对于表changelog流,连接使用一个可以回溯到“开始时间”(概念上是无限的窗口)的窗口,新版本的记录会覆盖较早的版本。对于流输入,连接可能根本没有维护窗口。
|
||||
|
||||
#### 表格表连接(物化视图维护)
|
||||
|
||||
考虑我们在第11页的“描述负载”中讨论的Twitter时间线示例。我们说过,当用户想要查看他们的家庭时间线时,对用户所关注的所有人进行迭代是非常昂贵的,推文,并合并它们。
|
||||
相反,我们需要一个时间线缓存:一种每个用户的“收件箱”,在发送tweets的时候写入这些信息,以便读取时间线是一次查询。实现和维护此缓存需要以下事件处理:
|
||||
考虑我们在“[描述负载](ch1.md#描述负载)”中讨论的推特时间线例子。我们说过,当用户想要查看他们的主页时间线时,对用户所关注的所有人进行迭代是非常昂贵的,推文,并合并它们。
|
||||
|
||||
相反,我们需要一个时间线缓存:一种每个用户的“收件箱”,在发送推文的时候写入这些信息,以便读取时间线是一次查询。实现和维护此缓存需要以下事件处理:
|
||||
|
||||
* 当用户发送新的推文时,它将被添加到每个跟随你的用户的时间线上。
|
||||
* 用户删除推文时,将从所有用户的时间表中删除。
|
||||
* 当用户u1开始跟随用户u2时,u2最近的tweets被添加到u1的时间线上。
|
||||
* 当用户u1取消关注用户u2时,u1的推文将从u1的时间线中移除。
|
||||
|
||||
要在流处理器中实现这种缓存维护,需要用于推文(发送和删除)和跟随关系(跟随和取消跟随)的事件流。流过程需要维护一个包含每个用户关注者集合的数据库,以便知道当一个新的tweet到达时需要更新哪些时间轴[86]。
|
||||
要在流处理器中实现这种缓存维护,需要用于推文(发送和删除)和跟随关系(跟随和取消跟随)的事件流。流过程需要维护一个包含每个用户关注者集合的数据库,以便知道当一个新的tweet到达时需要更新哪些时间轴【86】。
|
||||
查看这个流过程的另一种方式是维护一个连接两个表(tweet和follow)的查询的物化视图,如下所示:
|
||||
|
||||
```sql
|
||||
@ -619,33 +627,33 @@ GROUP BY follows.follower_id
|
||||
|
||||
流的连接直接对应于该查询中的表的连接。时间轴实际上是这个查询结果的缓存,每当基础表发生变化时都会更新[^iii]。
|
||||
|
||||
[^iii]: 如果你把一个流视为一个表的衍生物,如图11-6所示,并且把一个连接看作是两个表u·v的乘积,那么会发生一些有趣的事情:物化连接的变化流遵循产品规则 u·v)'= u'v + uv'。 换句话说,任何tweets的变化都与当前的追随者联系在一起,任何追随者的变化都与当前的tweets [49,50]相结合。
|
||||
[^iii]: 如果你把一个流视为一个表的衍生物,如[图11-6](img/fig11-6.png)所示,并且把一个连接看作是两个表u·v的乘积,那么会发生一些有趣的事情:物化连接的变化流遵循产品规则(u·v)'= u'v + uv'(u·v)'= u'v + uv'。 换句话说,任何tweets的变化都与当前的追随者联系在一起,任何追随者的变化都与当前的tweets 【49,50】相结合。
|
||||
|
||||
#### 连接的时间依赖性
|
||||
|
||||
这就产生了一个问题:如果不同的事件发生在相似的时间周围,他们按照何种顺序进行处理?在流表连接示例中,如果用户更新其配置文件,哪些活动事件与旧配置文件(在配置文件更新之前处理)结合,哪些与新配置文件结合(在配置文件更新之后处理)?换句话说:如果状态随着时间的推移而改变,并且你加入了某个状态,那么你使用什么时间点来加入[45]?
|
||||
这就产生了一个问题:如果不同的事件发生在相似的时间周围,他们按照何种顺序进行处理?在流表连接示例中,如果用户更新其配置文件,哪些活动事件与旧配置文件(在配置文件更新之前处理)结合,哪些与新配置文件结合(在配置文件更新之后处理)?换句话说:如果状态随着时间的推移而改变,并且你加入了某个状态,那么你使用什么时间点来加入【45】?
|
||||
|
||||
这种时间依赖性可能发生在许多地方。例如,如果您销售东西,则需要对发票进行适当的税率,这取决于国家或州,产品类型和销售日期(因为税率会随时变化)。将销售额加入税率表时,如果您正在重新处理历史数据,您可能希望加入销售时的税率,这可能与当前的税率不同。
|
||||
|
||||
如果跨流的事件排序是未确定的,那么这个连接变得不确定[87],这意味着你不能在相同的输入上重新运行相同的工作,并且必然会得到相同的结果:输入流上的事件可能交织在当你再次运行这个工作时,采用不同的方式
|
||||
如果跨流的事件排序是未确定的,那么这个连接变得不确定【87】,这意味着你不能在相同的输入上重新运行相同的工作,并且必然会得到相同的结果:输入流上的事件可能交织在当你再次运行这个工作时,采用不同的方式
|
||||
|
||||
在数据仓库中,这个问题被称为缓慢变化的维度(SCD),通常通过对特定版本的联合记录使用唯一的标识符来解决:例如,每当税率改变时,新的标识符,并且发票包括销售时的税率标识符[88,89]。这种变化使连接成为确定性的,但是由于表中所有记录的版本都需要保留,导致日志压缩是不可能的。
|
||||
在数据仓库中,这个问题被称为缓慢变化的维度(SCD),通常通过对特定版本的联合记录使用唯一的标识符来解决:例如,每当税率改变时,新的标识符,并且发票包括销售时的税率标识符【88,89】。这种变化使连接成为确定性的,但是由于表中所有记录的版本都需要保留,导致日志压缩是不可能的。
|
||||
|
||||
### 容错
|
||||
|
||||
在本章的最后一节中,让我们考虑流处理器如何容忍错误。我们在第10章中看到,批处理框架可以很容易地容忍错误:如果MapReduce作业中的任务失败,可以简单地在另一台机器上重新启动,并且丢弃失败任务的输出。这种透明的重试是可能的,因为输入文件是不可变的,每个任务都将其输出写入到HDFS上的单独文件,并且输出仅在任务成功完成时可见。
|
||||
在本章的最后一节中,让我们考虑流处理器如何容忍错误。我们在[第10章](ch10.md)中看到,批处理框架可以很容易地容忍错误:如果MapReduce作业中的任务失败,可以简单地在另一台机器上重新启动,并且丢弃失败任务的输出。这种透明的重试是可能的,因为输入文件是不可变的,每个任务都将其输出写入到HDFS上的单独文件,并且输出仅在任务成功完成时可见。
|
||||
|
||||
特别是,批处理容错方法可确保批处理作业的输出与没有出错的情况相同,即使事实上某些任务失败了。看起来好像每个输入记录都被处理了一次 - 没有记录被跳过,而且没有处理两次。尽管重新启动任务意味着实际上可能会多次处理记录,但输出中的可见效果好像只处理过一次。这个原则被称为一次语义学,虽然有效 - 一次将是一个更具描述性的术语[90]。
|
||||
特别是,批处理容错方法可确保批处理作业的输出与没有出错的情况相同,即使事实上某些任务失败了。看起来好像每个输入记录都被处理了一次 —— 没有记录被跳过,而且没有处理两次。尽管重新启动任务意味着实际上可能会多次处理记录,但输出中的可见效果好像只处理过一次。这个原则被称为一次语义学,虽然有效 —— 一次将是一个更具描述性的术语【90】。
|
||||
|
||||
在流处理过程中也出现了同样的容错问题,但是处理起来不那么直观:等到某个任务完成之后才使其输出可见,因为流是无限的,因此您永远无法完成处理。
|
||||
|
||||
#### 小批量和检查点
|
||||
|
||||
一个解决方案是将流分解成小块,并像小型批处理一样处理每个块。这种方法被称为microbatching,它被用于Spark Streaming [91]。批处理大小通常约为1秒,这是性能折中的结果:较小的批次会导致更大的调度和协调开销,而较大的批次意味着流处理器的结果变得可见之前的较长延迟。
|
||||
一个解决方案是将流分解成小块,并像小型批处理一样处理每个块。这种方法被称为**小批量(microbatching)**,它被用于Spark Streaming 【91】。批处理大小通常约为1秒,这是性能折中的结果:较小的批次会导致更大的调度和协调开销,而较大的批次意味着流处理器的结果变得可见之前的较长延迟。
|
||||
|
||||
微缩也隐含地提供了与批量大小相等的翻滚窗口(通过处理时间而不是事件时间戳)。任何需要更大窗口的作业都需要明确地将状态从一个微阵列转移到下一个微阵列。
|
||||
|
||||
Apache Flink中使用的一种变体方法是定期生成状态滚动检查点并将其写入持久存储器[92,93]。如果流操作符崩溃,它可以从最近的检查点重新启动,并放弃在最后一个检查点和崩溃之间生成的任何输出。检查点由消息流中的条形码触发,类似于微型图形之间的边界,但不强制特定的窗口大小。
|
||||
Apache Flink中使用的一种变体方法是定期生成状态滚动检查点并将其写入持久存储器【92,93】。如果流操作符崩溃,它可以从最近的检查点重新启动,并放弃在最后一个检查点和崩溃之间生成的任何输出。检查点由消息流中的条形码触发,类似于微型图形之间的边界,但不强制特定的窗口大小。
|
||||
|
||||
在流处理框架的范围内,微观网格化和检查点方法提供了与批处理一样的一次语义。但是,只要输出离开流处理器(例如,通过写入数据库,向外部消息代理发送消息或发送电子邮件),框架将不再能够放弃失败批处理的输出。在这种情况下,重新启动失败的任务会导致外部副作用发生两次,单独使用微配量或检查点不足以防止此问题。
|
||||
|
||||
@ -655,29 +663,29 @@ Apache Flink中使用的一种变体方法是定期生成状态滚动检查点
|
||||
|
||||
这些事情要么都是原子地发生,要么都不发生,但是不应该彼此不同步。如果这种方法听起来很熟悉,那是因为我们在分布式事务和两阶段提交的情况下,在第360页的“准确一次的消息处理”中讨论了它。
|
||||
|
||||
在第9章中,我们讨论了分布式交易(如XA)的传统实现中的问题。然而,在更受限制的环境中,可以有效地实现这样的原子提交设施。 Google云数据流[81,92]和VoltDB [94]中使用了这种方法,并计划在Apache Kafka [95,96]中添加类似的功能。与XA不同,这些实现不会尝试跨异构技术提供事务,而是通过在流处理框架中管理状态更改和消息传递来保持内部事务。事务协议的开销可以通过在单个事务中处理几个输入消息来分摊。
|
||||
在[第9章](ch9.md)中,我们讨论了分布式交易(如XA)的传统实现中的问题。然而,在更受限制的环境中,可以有效地实现这样的原子提交设施。 Google云数据流【81,92】和VoltDB 【94】中使用了这种方法,并计划在Apache Kafka 【95,96】中添加类似的功能。与XA不同,这些实现不会尝试跨异构技术提供事务,而是通过在流处理框架中管理状态更改和消息传递来保持内部事务。事务协议的开销可以通过在单个事务中处理几个输入消息来分摊。
|
||||
|
||||
#### 幂等
|
||||
|
||||
我们的目标是放弃任何失败的任务的部分输出,以便他们可以安全地重试,而不会两次生效。分布式事务是实现这一目标的一种方式,但另一种方式是依赖幂等性[97]。
|
||||
我们的目标是放弃任何失败的任务的部分输出,以便他们可以安全地重试,而不会两次生效。分布式事务是实现这一目标的一种方式,但另一种方式是依赖幂等性【97】。
|
||||
|
||||
幂等操作是可以多次执行的操作,并且与只执行一次操作具有相同的效果。例如,将键值存储中的某个键设置为某个固定值是幂等的(再次写入该值会覆盖具有相同值的值),而递增计数器不是幂等的(再次执行递增意味着该值递增两次)。
|
||||
|
||||
即使一个操作不是天生的幂等,它往往可以与一些额外的元数据幂等。例如,在使用来自卡夫卡的消息时,每条消息都有一个持续的,单调递增的偏移量。将值写入外部数据库时,可以将触发上次写入的消息的偏移量与值包含在一起。因此,您可以判断是否已应用更新,并避免再次执行相同的更新。
|
||||
|
||||
风暴三叉戟的状态处理基于类似的想法[78]。依赖幂等性意味着一些假设:重启一个失败的任务必须以相同的顺序重播相同的消息(一个基于日志的消息代理这样做),处理必须是确定性的,其他节点不能同时更新相同的值[ 98,99]。
|
||||
风暴三叉戟的状态处理基于类似的想法【78】。依赖幂等性意味着一些假设:重启一个失败的任务必须以相同的顺序重播相同的消息(一个基于日志的消息代理这样做),处理必须是确定性的,其他节点不能同时更新相同的值【98,99】。
|
||||
|
||||
当从一个处理节点故障转移到另一个处理节点时,可能需要进行防护(请参阅第291页上的“领导和锁定”),以防止被认为是死的节点的干扰
|
||||
当从一个处理节点故障转移到另一个处理节点时,可能需要进行防护(参阅“[领导和锁](ch8.md#领导和锁)”),以防止被认为是死的节点的干扰
|
||||
|
||||
#### 失败后重建状态
|
||||
|
||||
任何需要状态的流进程(例如,任何窗口聚合(例如计数器,平均值和直方图)以及用于连接的任何表和索引)都必须确保在失败之后可以恢复此状态。
|
||||
|
||||
一种选择是将状态保持在远程数据存储中并复制它,尽管如每个单独消息的远程数据库查询速度可能会很慢,正如在“流表加入(第473页)”中所述。另一种方法是保持流处理器的本地状态,并定期复制。然后,当流处理器从故障中恢复时,新任务可以读取复制状态并恢复处理而不丢失数据。
|
||||
一种选择是将状态保持在远程数据存储中并复制它,尽管如每个单独消息的远程数据库查询速度可能会很慢,正如在“[流表连接](#流表连接)”中所述。另一种方法是保持流处理器的本地状态,并定期复制。然后,当流处理器从故障中恢复时,新任务可以读取复制状态并恢复处理而不丢失数据。
|
||||
|
||||
例如,Flink定期捕获操作员状态的快照,并将它们写入HDFS等持久存储器中[92,93]。 Samza和Kafka Streams通过将状态更改发送到具有日志压缩功能的专用Kafka主题来复制状态更改,这类似于更改数据捕获[84,100]。 VoltDB通过冗余处理多个节点上的每个输入消息来复制状态(请参阅第252页的“实际的串行执行”)。
|
||||
例如,Flink定期捕获操作员状态的快照,并将它们写入HDFS等持久存储器中【92,93】。 Samza和Kafka Streams通过将状态更改发送到具有日志压缩功能的专用Kafka主题来复制状态更改,这类似于更改数据捕获【84,100】。 VoltDB通过冗余处理多个节点上的每个输入消息来复制状态(参阅“[真的串行执行](ch7.md#真的串行执行)”)。
|
||||
|
||||
在某些情况下,甚至可能不需要复制状态,因为它可以从输入流重建。例如,如果状态由一个相当短的窗口中的聚合组成,则它可能足够快,以便重放与该窗口相对应的输入事件。如果状态是通过更改数据捕获维护的数据库的本地副本,那么也可以从日志压缩的更改流重建数据库(请参阅“日志压缩”一节第456页)。
|
||||
在某些情况下,甚至可能不需要复制状态,因为它可以从输入流重建。例如,如果状态由一个相当短的窗口中的聚合组成,则它可能足够快,以便重放与该窗口相对应的输入事件。如果状态是通过更改数据捕获维护的数据库的本地副本,那么也可以从日志压缩的更改流重建数据库(请参阅“[日志压缩](#日志压缩)”一节)。
|
||||
|
||||
但是,所有这些权衡取决于底层基础架构的性能特征:在某些系统中,网络延迟可能低于磁盘访问延迟,网络带宽可能与磁盘带宽相当。在所有情况下都没有普遍理想的权衡,随着存储和网络技术的发展,本地和远程状态的优点也可能会发生变化。
|
||||
|
||||
@ -685,24 +693,28 @@ Apache Flink中使用的一种变体方法是定期生成状态滚动检查点
|
||||
|
||||
## 本章小结
|
||||
|
||||
在本章中,我们讨论了事件流,他们所服务的目的以及如何处理它们。在某些方面,流处理非常类似于我们在第10章讨论的批处理,而是在无限的(永无止境的)流而不是固定大小的输入上持续进行。从这个角度来看,消息代理和事件日志可以作为文件系统的流媒体。
|
||||
在本章中,我们讨论了事件流,他们所服务的目的以及如何处理它们。在某些方面,流处理非常类似于我们在[第10章](ch10.md)讨论的批处理,而是在无限的(永无止境的)流而不是固定大小的输入上持续进行。从这个角度来看,消息代理和事件日志可以作为文件系统的流媒体。
|
||||
|
||||
我们花了一些时间比较两种消息代理:
|
||||
|
||||
***AMQP/JMS风格的消息代理***
|
||||
经纪人将个人消息分配给消费者,消费者在成功处理个人消息时确认消息。消息被确认后从代理中删除。这种方法适合作为RPC的异步形式(另请参阅第136页的“消息传递数据流”),例如在任务队列中,消息处理的确切顺序并不重要,没有在处理之后,需要重新读取旧消息。
|
||||
|
||||
|
||||
经纪人将个人消息分配给消费者,消费者在成功处理个人消息时确认消息。消息被确认后从代理中删除。这种方法适合作为RPC的异步形式(另请参阅“[消息传递数据流](ch4.md#消息传递数据流)”),例如在任务队列中,消息处理的确切顺序并不重要,没有在处理之后,需要重新读取旧消息。
|
||||
|
||||
***基于日志的消息代理***
|
||||
|
||||
代理将分区中的所有消息分配给相同的使用者节点,并始终以相同的顺序传递消息。并行性是通过划分来实现的,消费者通过检查他们所处理的最后一个消息的偏移来跟踪他们的进度。代理将消息保留在磁盘上,因此如有必要,可以跳回并重新读取旧消息。
|
||||
|
||||
基于日志的方法与数据库中的复制日志(参见第5章)和日志结构存储引擎(请参阅第3章)具有相似之处。我们看到,这种方法特别适用于消耗输入流并生成派生状态或派生输出流的流处理系统。
|
||||
|
||||
就流的来源而言,我们讨论了几种可能性:用户活动事件,提供定期读数的传感器和数据馈送(例如金融市场数据)自然地表示为流。我们看到,将数据写入数据流也是有用的:我们可以捕获更改日志 - 即对数据库所做的所有更改的历史记录 - 隐式地通过更改数据捕获或通过事件明确地捕获采购。日志压缩允许流保留数据库内容的完整副本。
|
||||
|
||||
基于日志的方法与数据库中的复制日志(参见[第5章](ch5.md))和日志结构存储引擎(请参阅[第3章](ch3.md))具有相似之处。我们看到,这种方法特别适用于消耗输入流并生成派生状态或派生输出流的流处理系统。
|
||||
|
||||
就流的来源而言,我们讨论了几种可能性:用户活动事件,提供定期读数的传感器和数据馈送(例如金融市场数据)自然地表示为流。我们看到,将数据写入数据流也是有用的:我们可以捕获更改日志 —— 即对数据库所做的所有更改的历史记录 —— 隐式地通过更改数据捕获或通过事件明确地捕获采购。日志压缩允许流保留数据库内容的完整副本。
|
||||
|
||||
将数据库表示为流为系统集成提供了强大的机会。您可以通过使用更改日志并将其应用于派生系统,使派生的数据系统(如搜索索引,缓存和分析系统)保持最新。您甚至可以从头开始,从开始一直到现在消耗更改的日志,从而为现有数据构建新的视图。
|
||||
|
||||
将状态保持为流并重放消息的设施也是在各种流处理框架中实现流连接和容错的技术的基础。我们讨论了流处理的几个目的,包括搜索事件模式(复杂事件处理),计算加窗聚合(流分析)以及保持派生数据系统处于最新状态(材料化视图)。
|
||||
将状态保持为流并重放消息的设施也是在各种流处理框架中实现流连接和容错的技术的基础。我们讨论了流处理的几个目的,包括搜索事件模式(复杂事件处理),计算加窗聚合(流分析)以及保持派生数据系统处于最新状态(物化视图)。
|
||||
|
||||
然后我们讨论了在流处理器中推理时间的困难,包括处理时间和事件时间戳之间的区别,以及在你认为窗口完成之后处理到达的离散事件的问题。
|
||||
|
||||
@ -808,8 +820,7 @@ Apache Flink中使用的一种变体方法是定期生成状态滚动检查点
|
||||
*engineering.linkedin.com*, December 16, 2013.
|
||||
|
||||
1. Shirshanka Das, Chavdar Botev, Kapil Surlaker,
|
||||
et al.: “[All Aboard the Databus!](http://www.socc2012.org/s18-das.pdf),” at *3rd ACM
|
||||
Symposium on Cloud Computing* (SoCC), October 2012.
|
||||
et al.: “[All Aboard the Databus!](http://www.socc2012.org/s18-das.pdf),” at *3rd ACM Symposium on Cloud Computing* (SoCC), October 2012.
|
||||
|
||||
1. Yogeshwer Sharma, Philippe Ajoux, Petchean Ang, et al.:
|
||||
“[Wormhole: Reliable Pub-Sub to Support Geo-Replicated Internet Services](https://www.usenix.org/system/files/conference/nsdi15/nsdi15-paper-sharma.pdf),” at *12th USENIX Symposium on
|
||||
@ -861,8 +872,7 @@ Apache Flink中使用的一种变体方法是定期生成状态滚动检查点
|
||||
February 18, 2016.
|
||||
|
||||
1. Greg Young:
|
||||
“[CQRS and Event Sourcing](https://www.youtube.com/watch?v=JHGkaShoyNs),” at *Code on
|
||||
the Beach*, August 2014.
|
||||
“[CQRS and Event Sourcing](https://www.youtube.com/watch?v=JHGkaShoyNs),” at *Code on the Beach*, August 2014.
|
||||
|
||||
1. Martin Fowler:
|
||||
“[Event Sourcing](http://martinfowler.com/eaaDev/EventSourcing.html),” *martinfowler.com*,
|
||||
@ -873,15 +883,13 @@ Apache Flink中使用的一种变体方法是定期生成状态滚动检查点
|
||||
Addison-Wesley Professional, 2013. ISBN: 978-0-321-83457-7
|
||||
|
||||
1. H. V. Jagadish, Inderpal Singh Mumick, and Abraham Silberschatz:
|
||||
“[View Maintenance Issues for the Chronicle Data Model](http://www.mathcs.emory.edu/~cheung/papers/StreamDB/Histogram/1995-Jagadish-Histo.pdf),” at *14th ACM SIGACT-SIGMOD-SIGART Symposium
|
||||
on Principles of Database Systems* (PODS), May 1995.
|
||||
“[View Maintenance Issues for the Chronicle Data Model](http://www.mathcs.emory.edu/~cheung/papers/StreamDB/Histogram/1995-Jagadish-Histo.pdf),” at *14th ACM SIGACT-SIGMOD-SIGART Symposium on Principles of Database Systems* (PODS), May 1995.
|
||||
[doi:10.1145/212433.220201](http://dx.doi.org/10.1145/212433.220201)
|
||||
|
||||
1. “[Event Store 3.5.0 Documentation](http://docs.geteventstore.com/),” Event Store LLP, *docs.geteventstore.com*, February 2016.
|
||||
|
||||
1. Martin Kleppmann:
|
||||
<a href="http://www.oreilly.com/data/free/stream-processing.csp">*Making Sense of Stream
|
||||
Processing*</a>. Report, O'Reilly Media, May 2016.
|
||||
<a href="http://www.oreilly.com/data/free/stream-processing.csp">*Making Sense of Stream Processing*</a>. Report, O'Reilly Media, May 2016.
|
||||
|
||||
1. Sander Mak:
|
||||
“[Event-Sourced Architectures with Akka](http://www.slideshare.net/SanderMak/eventsourced-architectures-with-akka),” at *JavaOne*, September 2014.
|
||||
@ -895,8 +903,7 @@ Apache Flink中使用的一种变体方法是定期生成状态滚动检查点
|
||||
ISBN: 978-0-262-57122-7
|
||||
|
||||
1. Timothy Griffin and Leonid Libkin:
|
||||
“[Incremental Maintenance of Views with Duplicates](http://homepages.inf.ed.ac.uk/libkin/papers/sigmod95.pdf),” at *ACM International Conference on Management of
|
||||
Data* (SIGMOD), May 1995.
|
||||
“[Incremental Maintenance of Views with Duplicates](http://homepages.inf.ed.ac.uk/libkin/papers/sigmod95.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), May 1995.
|
||||
[doi:10.1145/223784.223849](http://dx.doi.org/10.1145/223784.223849)
|
||||
|
||||
1. Pat Helland:
|
||||
@ -966,8 +973,7 @@ Apache Flink中使用的一种变体方法是定期生成状态滚动检查点
|
||||
|
||||
1. Philippe Flajolet, Éric Fusy, Olivier
|
||||
Gandouet, and Frédéric Meunier:
|
||||
“[HyperLo⁠g​Log: The Analysis of a Near-Optimal Cardinality Estimation Algorithm](http://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf),” at *Conference on Analysis of
|
||||
Algorithms* (AofA), June 2007.
|
||||
“[HyperLo⁠g​Log: The Analysis of a Near-Optimal Cardinality Estimation Algorithm](http://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf),” at *Conference on Analysis of Algorithms* (AofA), June 2007.
|
||||
|
||||
1. Jay Kreps:
|
||||
“[Questioning the Lambda Architecture](https://www.oreilly.com/ideas/questioning-the-lambda-architecture),” *oreilly.com*, July 2, 2014.
|
||||
@ -1007,8 +1013,7 @@ Apache Flink中使用的一种变体方法是定期生成状态滚动检查点
|
||||
|
||||
1. Rajagopal Ananthanarayanan,
|
||||
Venkatesh Basker, Sumit Das, et al.:
|
||||
“[Photon: Fault-Tolerant and Scalable Joining of Continuous Data Streams](http://research.google.com/pubs/pub41318.html),” at *ACM International Conference on Management of
|
||||
Data* (SIGMOD), June 2013.
|
||||
“[Photon: Fault-Tolerant and Scalable Joining of Continuous Data Streams](http://research.google.com/pubs/pub41318.html),” at *ACM International Conference on Management of Data* (SIGMOD), June 2013.
|
||||
[doi:10.1145/2463676.2465272](http://dx.doi.org/10.1145/2463676.2465272)
|
||||
|
||||
1. Martin Kleppmann:
|
||||
@ -1041,8 +1046,7 @@ Apache Flink中使用的一种变体方法是定期生成状态滚动检查点
|
||||
“[Lightweight Asynchronous Snapshots for Distributed Dataflows](http://arxiv.org/abs/1506.08603),” arXiv:1506.08603 [cs.DC], June 29, 2015.
|
||||
|
||||
1. Ryan Betts and John Hugg:
|
||||
<a href="http://www.oreilly.com/data/free/fast-data-smart-and-at-scale.csp">*Fast Data: Smart and
|
||||
at Scale*</a>. Report, O'Reilly Media, October 2015.
|
||||
<a href="http://www.oreilly.com/data/free/fast-data-smart-and-at-scale.csp">*Fast Data: Smart and at Scale*</a>. Report, O'Reilly Media, October 2015.
|
||||
|
||||
1. Flavio Junqueira:
|
||||
“[Making Sense of Exactly-Once Semantics](http://conferences.oreilly.com/strata/hadoop-big-data-eu/public/schedule/detail/49690),” at *Strata+Hadoop World London*, June 2016.
|
||||
|
218
ch2.md
218
ch2.md
@ -2,80 +2,81 @@
|
||||
|
||||
![](img/ch2.png)
|
||||
|
||||
> 语言的极限即世界的极限
|
||||
> 语言的边界就是思想的边界。
|
||||
>
|
||||
> —— 路德维奇·维特根斯坦, 《逻辑哲学》(1922)
|
||||
> —— 路德维奇·维特根斯坦,《逻辑哲学》(1922)
|
||||
>
|
||||
|
||||
-------------------
|
||||
|
||||
[TOC]
|
||||
|
||||
数据模型可能是开发软件最重要的部分,因为它们有着深远的影响:不仅影响软件的编写方式,而且会影响我们的解题思路。
|
||||
数据模型可能是软件开发中最重要的部分了,因为它们的影响如此深远:不仅仅影响着软件的编写方式,而且影响着我们的**解题思路**。
|
||||
|
||||
大多数应用程序是通过将一个数据模型叠加在另一个之上来构建的。对于每一层,关键问题是:它是如何用下一层来表示的?例如:
|
||||
多数应用使用层层叠加的数据模型构建。对于每层数据模型的关键问题是:它是如何用低一层数据模型来**表示**的?例如:
|
||||
|
||||
1. 作为一名应用程序开发人员,您将看到现实世界(包括人员,组织,货物,行为,资金流向,传感器等),并根据对象或数据结构以及API进行建模,操纵这些数据结构。这些结构通常是应用程序特定的。
|
||||
2. 如果要存储这些数据结构,可以使用通用数据模型(如JSON或XML文档,关系数据库中的表、或图模型)来表示它们。
|
||||
3. 构建数据库软件的工程师决定以内存,磁盘或网络上的字节表示JSON/XML/关系/图数据。该表示可以允许以各种方式查询,搜索,操纵和处理数据。
|
||||
4. 在更低的层面上,硬件工程师已经计算出如何用电流,光脉冲,磁场等来表示字节。
|
||||
1. 作为一名应用开发人员,你观察现实世界(里面有人员,组织,货物,行为,资金流向,传感器等),并采用对象或数据结构,以及操控那些数据结构的API来进行建模。那些结构通常是特定于应用程序的。
|
||||
2. 当要存储那些数据结构时,你可以利用通用数据模型来表示它们,如JSON或XML文档,关系数据库中的表、或图模型。
|
||||
3. 数据库软件的工程师选定如何以内存、磁盘或网络上的字节来表示JSON/XML/关系/图数据。这类表示形式使数据有可能以各种方式来查询,搜索,操纵和处理。
|
||||
4. 在更低的层次上,硬件工程师已经想出了使用电流,光脉冲,磁场或者其他东西来表示字节的方法。
|
||||
|
||||
在一个复杂的应用程序中,可能会有更多的中间层次,比如基于API的API,但是基本思想仍然是一样的:每个层都通过提供一个干净的数据模型来隐藏下面层的复杂性。这些抽象允许不同的人群(例如数据库供应商的工程师和使用他们的数据库的应用程序开发人员)有效地协作。
|
||||
一个复杂的应用程序可能会有更多的中间层次,比如基于API的API,不过基本思想仍然是一样的:每个层都通过提供一个明确的数据模型来隐藏更低层次中的复杂性。这些抽象允许不同的人群有效地协作(例如数据库厂商的工程师和使用数据库的应用程序开发人员)。
|
||||
|
||||
有许多不同类型的数据模型,每个数据模型都体现了如何使用它的假设。某些用法很容易,有些不被支持;一些操作很快,一些操作不好;一些数据转换感觉自然,有些是尴尬的。
|
||||
数据模型种类繁多,每个数据模型都带有如何使用的设想。有些用法很容易,有些则不支持如此;有些操作运行很快,有些则表现很差;有些数据转换非常自然,有些则很麻烦。
|
||||
|
||||
掌握一个数据模型可能需要很多努力(想想关系数据建模有多少本书)。即使只使用一种数据模型,而不用担心其内部工作,构建软件也是非常困难的。但是由于数据模型对软件的功能有很大的影响,因此选择适合应用程序的软件是非常重要的。
|
||||
掌握一个数据模型需要花费很多精力(想想关系数据建模有多少本书)。即便只使用一个数据模型,不用操心其内部工作机制,构建软件也是非常困难的。然而,因为数据模型对上层软件的功能(能做什么,不能做什么)有着至深的影响,所以选择一个适合的数据模型是非常重要的。
|
||||
|
||||
在本章中,我们将研究一系列用于数据存储和查询的通用数据模型(前面列表中的第2点)。特别是,我们将比较关系模型,文档模型和一些基于图形的数据模型。我们还将查看各种查询语言并比较它们的用例。在第3章中,我们将讨论存储引擎是如何工作的。也就是说,这些数据模型是如何实际实现的(列表中的第3点)。
|
||||
在本章中,我们将研究一系列用于数据存储和查询的通用数据模型(前面列表中的第2点)。特别地,我们将比较关系模型,文档模型和少量基于图形的数据模型。我们还将查看各种查询语言并比较它们的用例。在第3章中,我们将讨论存储引擎是如何工作的。也就是说,这些数据模型实际上是如何实现的(列表中的第3点)。
|
||||
|
||||
|
||||
|
||||
## 关系模型与文档模型
|
||||
|
||||
现在最着名的数据模型可能是SQL,它基于Edgar Codd在1970年提出的关系模型【1】:数据被组织到关系中(称为SQL表),其中每个关系是元组的无序集合SQL中的行)。
|
||||
现在最著名的数据模型可能是SQL。它基于Edgar Codd在1970年提出的关系模型【1】:数据被组织成**关系**(SQL中称作**表**),其中每个关系是**元组**(SQL中称作**行**)的无序集合。
|
||||
|
||||
关系模型是一个理论上的提议,当时很多人都怀疑是否能够有效实现。然而到了20世纪80年代中期,关系数据库管理系统(RDBMSes)和SQL已成为大多数需要存储和查询具有某种规模结构的数据的人们的首选工具。关系数据库的优势已经持续了大约25~30年——计算史中的永恒。
|
||||
关系模型曾是一个理论性的提议,当时很多人都怀疑是否能够有效实现它。然而到了20世纪80年代中期,关系数据库管理系统(RDBMSes)和SQL已成为大多数人们存储和查询某些常规结构的数据的首选工具。关系数据库已经持续称霸了大约25~30年——这对计算机史来说是极其漫长的时间。
|
||||
|
||||
关系数据库起源于商业数据处理,这是在20世纪60年代和70年代在大型计算机上进行的。从今天的角度来看,用例显得很平常:通常是交易处理(进入销售或银行交易,航空公司预订,仓库库存)和批处理(客户发票,工资单,报告)。
|
||||
关系数据库起源于商业数据处理,在20世纪60年代和70年代用大型计算机来执行。从今天的角度来看,那些用例显得很平常:典型的**事务处理**(将销售或银行交易,航空公司预订,库存管理信息记录在库)和**批处理**(客户发票,工资单,报告)。
|
||||
|
||||
当时的其他数据库迫使应用程序开发人员考虑数据库内部的数据表示。关系模型的目标是将实现细节隐藏在更简洁的界面之后。
|
||||
当时的其他数据库迫使应用程序开发人员必须考虑数据库内部的数据表示形式。关系模型致力于将上述实现细节隐藏在更简洁的接口之后。
|
||||
|
||||
多年来,在数据存储和查询方面存在着许多相互竞争的方法。在20世纪70年代和80年代初,网络模型和分层模型是主要的选择,但关系模型占据了主导地位。对象数据库在二十世纪八十年代末和九十年代初再次出现。 XML数据库出现在二十一世纪初,但只有小众采用。关系模型的每个竞争者都在其时代产生了大量的炒作,但从来没有持续【2】。
|
||||
多年来,在数据存储和查询方面存在着许多相互竞争的方法。在20世纪70年代和80年代初,网络模型和分层模型曾是主要的选择,但关系模型随后占据了主导地位。对象数据库在20世纪80年代末和90年代初来了又去。 XML数据库在二十一世纪初出现,但只有小众采用过。关系模型的每个竞争者都在其时代产生了大量的炒作,但从来没有持续【2】。
|
||||
|
||||
随着电脑越来越强大和联网,它们开始被用于日益多样化的目的。值得注意的是,关系数据库在业务数据处理的原始范围之外被推广到很广泛的用例。您今天在网上看到的大部分内容仍然是由关系数据库提供支持,无论是在线发布,讨论,社交网络,电子商务,游戏,软件即服务生产力应用程序等等。
|
||||
随着电脑越来越强大和互联,它们开始用于日益多样化的目的。关系数据库非常成功地被推广到业务数据处理的原始范围之外更为广泛的用例上。您今天在网上看到的大部分内容依旧是由关系数据库来提供支持,无论是在线发布,讨论,社交网络,电子商务,游戏,软件即服务生产力应用程序等等内容。
|
||||
|
||||
### NoSQL的诞生
|
||||
|
||||
现在在2010年代,NoSQL是推翻关系模式主导地位的最新尝试。 “NoSQL”这个名字非常不幸,因为它实际上并没有涉及到任何特定的技术,它最初只是作为一个吸引人的Twitter标签在2009年的一个关于分布式,非关系数据库上的开源聚会。无论如何,这个术语触动了某些神经,并迅速通过网络启动社区和更远的地方传播开来。一些有趣的数据库系统现在与*#NoSQL#*标签相关联,并被追溯性地重新解释为不仅是SQL 【4】。
|
||||
现在 - 2010年代,NoSQL开始了最新一轮尝试,试图推翻关系模型的统治地位。 “NoSQL”这个名字让人遗憾,因为实际上它并没有涉及到任何特定的技术。最初它只是作为一个醒目的Twitter标签,用在2009年一个关于分布式,非关系数据库上的开源聚会上。无论如何,这个术语触动了某些神经,并迅速在网络创业社区内外传播开来。好些有趣的数据库系统现在都与*#NoSQL#*标签相关联,并且NoSQL被追溯性地重新解释为**不仅是SQL(Not Only SQL)** 【4】。
|
||||
|
||||
采用NoSQL数据库有几个驱动力,其中包括:
|
||||
采用NoSQL数据库的背后有几个驱动因素,其中包括:
|
||||
|
||||
* 需要比关系数据库更好的可扩展性,包括非常大的数据集或非常高的写入吞吐量
|
||||
* 相比商业数据库产品,偏爱免费和开源软件
|
||||
* 相比商业数据库产品,免费和开源软件更受偏爱。
|
||||
* 关系模型不能很好地支持一些特殊的查询操作
|
||||
* 对关系模型限制性感到受挫,对更多动态性与表现力的渴望
|
||||
* 受挫于关系模型的限制性,渴望一种更具多动态性与表现力的数据模型【5】
|
||||
|
||||
不同的应用程序有不同的要求,一个用例的最佳技术选择可能不同于另一个用例的最佳选择。因此,在可预见的未来,关系数据库似乎可能会继续与各种非关系数据库一起使用 - 这种想法有时也被称为**混合持久化(Polyglot Persistences)**
|
||||
不同的应用程序有不同的需求,一个用例的最佳技术选择可能不同于另一个用例的最佳技术选择。因此,在可预见的未来,关系数据库似乎可能会继续与各种非关系数据库一起使用 - 这种想法有时也被称为**混合持久化(polyglot persistence)**
|
||||
|
||||
### 对象关系不匹配
|
||||
|
||||
现在大多数应用程序开发都是在面向对象的编程语言中完成的,这导致了对SQL数据模型的普遍批评:如果数据存储在关系表中,那么应用程序代码中的对象之间需要一个笨拙的转换层,表,行和列的数据库模型。模型之间的不连贯有时被称为**阻抗不匹配(impedance mismatch)**[^i]。
|
||||
目前大多数应用程序开发都使用面向对象的编程语言来开发,这导致了对SQL数据模型的普遍批评:如果数据存储在关系表中,那么需要一个笨拙的转换层,处于应用程序代码中的对象和表,行,列的数据库模型之间。模型之间的不连贯有时被称为**阻抗不匹配(impedance mismatch)**[^i]。
|
||||
|
||||
[^i]: 从电子学借用一个术语。每个电路的输入和输出都有一定的阻抗(交流电阻)。当您将一个电路的输出连接到另一个电路的输入时,如果两个电路的输出和输入阻抗匹配,则连接上的功率传输将被最大化。阻抗不匹配可能导致信号反射和其他问题
|
||||
[^i]: 一个从电子学借用的术语。每个电路的输入和输出都有一定的阻抗(交流电阻)。当您将一个电路的输出连接到另一个电路的输入时,如果两个电路的输出和输入阻抗匹配,则连接上的功率传输将被最大化。阻抗不匹配会导致信号反射及其他问题。
|
||||
|
||||
像ActiveRecord和Hibernate这样的**对象关系映射(object-relational mapping, ORM)**框架减少了这个翻译层需要的样板代码的数量,但是它们不能完全隐藏这两个模型之间的差异。
|
||||
像ActiveRecord和Hibernate这样的**对象关系映射(object-relational mapping, ORM)**框架可以减少这个转换层所需的样板代码的数量,但是它们不能完全隐藏这两个模型之间的差异。
|
||||
|
||||
![](img/fig2-1.png)
|
||||
|
||||
**图2-1 使用关系型模式来表示领英简历**
|
||||
**图2-1 使用关系型模式来表示领英简介**
|
||||
|
||||
例如,[图2-1](img/fig2-1.png)展示了如何在关系模式中表达简历(一个LinkedIn简介)。整个配置文件可以通过一个唯一的标识符`user_id`来标识。像`first_name`和`last_name`这样的字段每个用户只出现一次,所以他们可以在用户表上建模为列。但是,大多数人的职业(职位)多于一份工作,人们可能有不同的教育期限和不同数量的联系信息。从用户到这些项目之间存在一对多的关系,可以用多种方式来表示:
|
||||
例如,[图2-1](img/fig2-1.png)展示了如何在关系模式中表示简历(一个LinkedIn简介)。整个简介可以通过一个唯一的标识符`user_id`来标识。像`first_name`和`last_name`这样的字段每个用户只出现一次,所以他们可以在用户表上建模为列。但是,大多数人在职业生涯中拥有多于一份的工作,人们可能有不同样的教育阶段和任意数量的联系信息。从用户到这些项目之间存在一对多的关系,可以用多种方式来表示:
|
||||
|
||||
* 在传统SQL模型(SQL:1999之前)中,最常见的规范化表示形式是将职位,培训和联系信息放在单独的表中,对用户表提供外键引用,如[图2-1](img/fig2-1.png)所示。
|
||||
* 更高版本的SQL标准增加了对结构化数据类型和XML数据的支持;这允许将多值数据存储在单行内,支持在这些文档内查询和索引。这些功能在Oracle,IBM DB2,MS SQL Server和PostgreSQL中都有不同程度的支持【6,7】。 JSON数据类型也受到几个数据库的支持,包括IBM DB2,MySQL和PostgreSQL 【8】。
|
||||
* 第三种选择是将职业,教育和联系信息编码为JSON或XML文档,将其存储在数据库的文本列中,并让应用程序解释其结构和内容。在这种配置中,通常不能使用数据库查询该编码列中的值。
|
||||
* 传统SQL模型(SQL:1999之前)中,最常见的规范化表示形式是将职位,教育和联系信息放在单独的表中,对用户表提供外键引用,如[图2-1](img/fig2-1.png)所示。
|
||||
* 后续的SQL标准增加了对结构化数据类型和XML数据的支持;这允许将多值数据存储在单行内,并支持在这些文档内查询和索引。这些功能在Oracle,IBM DB2,MS SQL Server和PostgreSQL中都有不同程度的支持【6,7】。 JSON数据类型也得到多个数据库的支持,包括IBM DB2,MySQL和PostgreSQL 【8】。
|
||||
* 第三种选择是将职业,教育和联系信息编码为JSON或XML文档,将其存储在数据库的文本列中,并让应用程序解析其结构和内容。这种配置下,通常不能使用数据库来查询该编码列中的值。
|
||||
|
||||
对于一个像简历这样自包含的数据结构而言,JSON表示是非常合适的:参见[例2-1]()。 JSON比XML更简单。 面向文档的数据库(如MongoDB 【9】,RethinkDB 【10】,CouchDB 【11】和Espresso【12】)支持这种数据模型。
|
||||
对于一个像简历这样自包含文档的数据结构而言,JSON表示是非常合适的:参见[例2-1]()。JSON比XML更简单。面向文档的数据库(如MongoDB 【9】,RethinkDB 【10】,CouchDB 【11】和Espresso【12】)支持这种数据模型。
|
||||
**例2-1. 用JSON文档表示一个LinkedIn简介**
|
||||
|
||||
```json
|
||||
{
|
||||
@ -115,11 +116,11 @@
|
||||
}
|
||||
```
|
||||
|
||||
一些开发人员认为JSON模型减少了应用程序代码和存储层之间的阻抗不匹配。但是,正如我们将在[第4章](ch4.md)中看到的那样,JSON作为数据编码格式也存在问题。缺乏一个模式往往被认为是一个优势;我们将在“[文档模型中的模式灵活性](#文档模型中的模式灵活性)”中讨论这个问题。
|
||||
有一些开发人员认为JSON模型减少了应用程序代码和存储层之间的阻抗不匹配。不过,正如我们将在[第4章](ch4.md)中看到的那样,JSON作为数据编码格式也存在问题。缺乏一个模式往往被认为是一个优势;我们将在“[文档模型中的模式灵活性](#文档模型中的模式灵活性)”中讨论这个问题。
|
||||
|
||||
JSON表示比[图2-1](img/fig2-1.png)中的多表模式具有更好的局部性。如果要在关系示例中获取配置文件,则需要执行多个查询(通过`user_id`查询每个表),或者在用户表与其下属表之间执行混乱的多路连接。在JSON表示中,所有的相关信息都在一个地方,一个查询就足够了。
|
||||
JSON表示比[图2-1](img/fig2-1.png)中的多表模式具有更好的**局部性(locality)**。如果在上面的关系型示例中获取简介,那需要执行多个查询(通过`user_id`查询每个表),或者在用户表与其下属表之间混乱地执行多路连接。而在JSON表示中,所有相关信息都在同一个地方,一个查询就足够了。
|
||||
|
||||
从用户配置文件到用户位置,教育历史和联系信息的一对多关系意味着数据中的树状结构,而JSON表示使得这个树状结构变得明确(见[图2-2](img/fig2-2.png))。
|
||||
从用户简介文件到用户职位,教育历史和联系信息,这种一对多关系隐含了数据中的树状结构,而JSON表示使得这个树状结构变得明确(见[图2-2](img/fig2-2.png))。
|
||||
|
||||
![](img/fig2-2.png)
|
||||
|
||||
@ -127,148 +128,148 @@ JSON表示比[图2-1](img/fig2-1.png)中的多表模式具有更好的局部性
|
||||
|
||||
### 多对一和多对多的关系
|
||||
|
||||
在上一节的[例2-1]()中,`region_id`和`industry_id`是以ID而不是纯字符串“大西雅图地区”和“慈善”的形式给出的。为什么?
|
||||
在上一节的[例2-1]()中,`region_id`和`industry_id`是以ID,而不是纯字符串“Greater Seattle Area”和“Philanthropy”的形式给出的。为什么?
|
||||
|
||||
如果用户界面具有用于输入区域和行业的自由文本字段,则将其存储为纯文本字符串是有意义的。但是,对地理区域和行业进行标准化,并让用户从下拉列表或自动填充器中进行选择是有好处的:
|
||||
如果用户界面用一个自由文本字段来输入区域和行业,那么将他们存储为纯文本字符串是合理的。另一方式是给出地理区域和行业的标准化的列表,并让用户从下拉列表或自动填充器中进行选择,其优势如下:
|
||||
|
||||
* 统一的样式和拼写
|
||||
* 各个简介之间样式和拼写统一
|
||||
* 避免歧义(例如,如果有几个同名的城市)
|
||||
* 易于更新——名称只存储在一个地方,所以如果需要更改(例如,由于政治事件而改变城市名称),便于全面更新。
|
||||
* 本地化支持——当网站翻译成其他语言时,标准化的名单可以被本地化,所以地区和行业可以使用用户的语言来表示
|
||||
* 更好的搜索——例如,搜索华盛顿州的慈善家可以匹配这份简历,因为地区列表可以编码记录西雅图在华盛顿的事实(从“大西雅图地区”这个字符串中看不出来)
|
||||
* 易于更新——名称只存储在一个地方,如果需要更改(例如,由于政治事件而改变城市名称),很容易进行全面更新。
|
||||
* 本地化支持——当网站翻译成其他语言时,标准化的列表可以被本地化,使得地区和行业可以使用用户的语言来显示
|
||||
* 更好的搜索——例如,搜索华盛顿州的慈善家就会匹配这份简介,因为地区列表可以编码记录西雅图在华盛顿这一事实(从“Greater Seattle Area”这个字符串中看不出来)
|
||||
|
||||
无论是存储一个ID还是一个文本字符串,都是一个关于**重复**的问题。当你使用一个ID时,对人类有意义的信息(比如单词:慈善)只存储在一个地方,引用它的所有信息都使用一个ID(ID只在数据库中有意义)。当你直接存储文本时,每个使用它的记录中,都存储的是有意义的信息。
|
||||
存储ID还是文本字符串,这是个**复制(duplication)**问题。当使用ID时,对人类有意义的信息(比如单词:Philanthropy)只存储在一处,所有引用它的地方使用ID(ID只在数据库中有意义)。当直接存储文本时,对人类有意义的信息会复制在每处使用记录中。
|
||||
|
||||
使用ID的好处是,因为它对人类没有任何意义,所以永远不需要改变:ID可以保持不变,即使它标识的信息发生变化。任何对人类有意义的东西都可能需要在将来某个时候改变——如果这些信息被复制,所有的冗余副本都需要更新。这会导致写入开销,并且存在不一致的风险(信息的一些副本被更新,但其他信息的副本不被更新)。去除这种重复是数据库规范化的关键思想。(关系模型区分了几种不同的范式,但这些区别实际上并不重要。 作为一个经验法则,如果您重复只能存储在一个地方的值,那么架构不会被**规范化(normalized)**[^ii]。)
|
||||
使用ID的好处是,ID对人类没有任何意义,永远不需要改变:ID可以保持不变,即使它标识的信息发生变化。任何对人类有意义的东西都可能需要在将来某个时候改变——如果这些信息被复制,所有的冗余副本都需要更新。这会导致写入开销,也存在不一致的风险(一些副本被更新了,还有些副本没有被更新)。去除此类重复是数据库**规范化(normalization)**的关键思想。[^ii]
|
||||
|
||||
[^ii]: 关于关系模型的文献区分了几种不同的规范形式,但这些区别几乎没有实际意义。 作为一个经验法则,如果重复存储了只能存储在一个地方的值,则模式就不是规范化的。
|
||||
[^ii]: 关于关系模型的文献区分了几种不同的规范形式,但这些区别几乎没有实际意义。一个经验法则是,如果重复存储了可以存储在一个地方的值,则模式就不是**规范化(normalized)**的。
|
||||
|
||||
> 数据库管理员和开发人员喜欢争论规范化和非规范化,但我们现在暂停判断。 在本书的[第三部分](part-iii.md),我们将回到这个话题,探讨处理缓存,非规范化和派生数据的系统方法。
|
||||
> 数据库管理员和开发人员喜欢争论规范化和非规范化,让我们暂时保留判断吧。在本书的[第三部分](part-iii.md),我们将回到这个话题,探讨系统的方法用以处理缓存,非规范化和派生数据。
|
||||
|
||||
不幸的是,对这些数据进行规范化需要多对一的关系(许多人生活在一个特定的地区,许多人在一个特定的行业工作),这与文档模型不太吻合。在关系数据库中,通过ID来引用其他表中的行是正常的,因为连接很容易。在文档数据库中,一对多树结构不需要连接,对连接的支持通常很弱[^iii]。
|
||||
不幸的是,对这些数据进行规范化需要多对一的关系(许多人生活在一个特定的地区,许多人在一个特定的行业工作),这与文档模型不太吻合。在关系数据库中,通过ID来引用其他表中的行是正常的,因为连接很容易。在文档数据库中,一对多树结构没有必要用连接,对连接的支持通常很弱[^iii]。
|
||||
|
||||
[^iii]: 在撰写本文时,RethinkDB支持连接,MongoDB不支持连接,并且只支持CouchDB中的预先声明的视图。
|
||||
[^iii]: 在撰写本文时,RethinkDB支持连接,MongoDB不支持连接,而CouchDB只支持预先声明的视图。
|
||||
|
||||
如果数据库本身不支持连接,则必须通过对数据库进行多个查询来模拟应用程序代码中的连接。 (在这种情况下,地区和行业的名单可能很小,变化不大,应用程序可以简单地将它们留在内存中,但是,联接的工作从数据库转移到应用程序代码。
|
||||
如果数据库本身不支持连接,则必须在应用程序代码中通过对数据库进行多个查询来模拟连接。(在这种情况中,地区和行业的列表可能很小,改动很少,应用程序可以简单地将其保存在内存中。不过,执行连接的工作从数据库被转移到应用程序代码上。
|
||||
|
||||
而且,即使应用程序的初始版本适合无连接的文档模型,随着功能添加到应用程序中,数据也会变得更加互联。例如,考虑一下我们可以对简历例子进行的一些修改:
|
||||
此外,即便应用程序的最初版本适合无连接的文档模型,随着功能添加到应用程序中,数据会变得更加互联。例如,考虑一下对简历例子进行的一些修改:
|
||||
|
||||
***组织和学校作为实体***
|
||||
|
||||
在前面的描述中,组织(用户工作的公司)和`school_name`(他们学习的地方)只是字符串。也许他们应该是对实体的引用呢?然后,每个组织,学校或大学都可以拥有自己的网页(标识,新闻提要等)。每个简历可以链接到它所提到的组织和学校,并且包括他们的标识和其他信息(参见[图2-3](img/fig2-3.png),来自LinkedIn的一个例子)。
|
||||
在前面的描述中,`organization`(用户工作的公司)和`school_name`(他们学习的地方)只是字符串。也许他们应该是对实体的引用呢?然后,每个组织,学校或大学都可以拥有自己的网页(标识,新闻提要等)。每个简历可以链接到它所提到的组织和学校,并且包括他们的图标和其他信息(参见[图2-3](img/fig2-3.png),来自LinkedIn的一个例子)。
|
||||
|
||||
***推荐***
|
||||
|
||||
假设你想添加一个新的功能:一个用户可以为另一个用户写一个推荐。推荐在用户的简历上显示,并附上推荐用户的姓名和照片。如果推荐人更新他们的照片,他们写的任何建议都需要反映新的照片。因此,推荐应该引用作者的个人资料。
|
||||
|
||||
假设你想添加一个新的功能:一个用户可以为另一个用户写一个推荐。在用户的简历上显示推荐,并附上推荐用户的姓名和照片。如果推荐人更新他们的照片,他们写的任何建议都需要显示新的照片。因此,推荐应该有作者个人简介的引用。
|
||||
![](img/fig2-3.png)
|
||||
|
||||
**图2-3 公司名不仅是字符串,还是一个指向公司实体的连接(领英截图)**
|
||||
**图2-3 公司名不仅是字符串,还是一个指向公司实体的链接(LinkedIn截图)**
|
||||
|
||||
[图2-4](img/fig2-4.png)阐明了这些新功能怎样使用多对多关系。 每个虚线矩形内的数据可以分组成一个文档,但是对单位,学校和其他用户的引用需要表示为引用,并且在查询时需要连接。
|
||||
[图2-4](img/fig2-4.png)阐明了这些新功能怎样使用多对多关系。 每个虚线矩形内的数据可以分组成一个文档,但是对单位,学校和其他用户的引用需要表示成引用,并且在查询时需要连接。
|
||||
|
||||
![](img/fig2-4.png)
|
||||
|
||||
**图2-4 使用多对多关系扩展简历**
|
||||
|
||||
### 文档数据库是否在重蹈覆辙?
|
||||
### 文档数据库是否在重演历史?
|
||||
|
||||
虽然关系数据库中经常使用多对多的关系和连接,但文档数据库和NoSQL重新讨论了如何最好地在数据库中表示这种关系的争论。这个辩论比NoSQL早得多,事实上,它可以追溯到最早的计算机化数据库系统。
|
||||
在多对多的关系和连接已常规用在关系数据库时,文档数据库和NoSQL重启了辩论:如何最好地在数据库中表示多对多关系。这个辩论可比NoSQL古老得多,事实上,最早可以追溯到计算机化数据库系统。
|
||||
|
||||
20世纪70年代最受欢迎的业务数据处理数据库是IBM的信息管理系统(IMS),最初是为了在阿波罗太空计划中进行库存管理而开发的,并于1968年首次商业发布【13】。目前它仍在使用和维护,在IBM大型机的OS/390上运行【14】。
|
||||
IMS的设计使用了一个相当简单的数据模型,称为层次模型,它与文档数据库使用的JSON模型有一些显着的相似之处【2】。它将所有数据表示为嵌套在记录中的记录树,就像[图2-2](img/fig2-2.png)的JSON结构一样。
|
||||
20世纪70年代最受欢迎的业务数据处理数据库是IBM的信息管理系统(IMS),最初是为了阿波罗太空计划的库存管理而开发的,并于1968年有了首次商业发布【13】。目前它仍在使用和维护,运行在IBM大型机的OS/390上【14】。
|
||||
|
||||
像文档数据库一样,IMS在一对多的关系中运行良好,但是它使多对多的关系变得困难,并且不支持连接。开发人员必须决定是否冗余(非规范化)数据或手动解决从一个记录到另一个记录的引用。这些二十世纪六七十年代的问题与开发人员今天遇到的文档数据库问题非常相似【15】。
|
||||
IMS的设计中使用了一个相当简单的数据模型,称为**层次模型(hierarchical model)**,它与文档数据库使用的JSON模型有一些惊人的相似之处【2】。它将所有数据表示为嵌套在记录中的记录树,这很像[图2-2](img/fig2-2.png)的JSON结构。
|
||||
|
||||
提出了各种解决方案来解决层次模型的局限性。其中最突出的两个是关系模型(它变成了SQL,接管了世界)和网络模型(最初很受关注,但最终变得模糊)。这两个阵营之间的“大辩论”持续了70年代的大部分时间【2】。
|
||||
同文档数据库一样,IMS能良好处理一对多的关系,但是很难应对多对多的关系,并且不支持连接。开发人员必须决定是否复制(非规范化)数据或手动解决从一个记录到另一个记录的引用。这些二十世纪六七十年代的问题与现在开发人员遇到的文档数据库问题非常相似【15】。
|
||||
|
||||
由于这两个模式解决的问题今天仍然如此相关,今天的辩论值得简要回顾一下。
|
||||
那时人们提出了各种不同的解决方案来解决层次模型的局限性。其中最突出的两个是**关系模型(relational model)**(它变成了SQL,统治了世界)和**网络模型(network model)**(最初很受关注,但最终变得冷门)。这两个阵营之间的“大辩论”在70年代持续了很久时间【2】。
|
||||
|
||||
那两个模式解决的问题与当前的问题相关,因此值得简要回顾一下那场辩论。
|
||||
|
||||
#### 网络模型
|
||||
|
||||
网络模型由一个称为数据系统语言会议(CODASYL)的委员会进行了标准化,并由几个不同的数据源进行实施;它也被称为CODASYL模型【16】。
|
||||
网络模型由一个称为数据系统语言会议(CODASYL)的委员会进行了标准化,并被数个不同的数据库商实现;它也被称为CODASYL模型【16】。
|
||||
|
||||
CODASYL模型是层次模型的推广。在分层模型的树结构中,每条记录只有一个父节点,在网络模式中,一个记录可能有多个父母。例如,“大西雅图地区”地区可能有一条记录,而且每个居住在该地区的用户都可以与之相关联。这允许对多对一和多对多的关系进行建模。
|
||||
CODASYL模型是层次模型的推广。在层次模型的树结构中,每条记录只有一个父节点;在网络模式中,每条记录可能有多个父节点。例如,“Greater Seattle Area”地区可能是一条记录,每个居住在该地区的用户都可以与之相关联。这允许对多对一和多对多的关系进行建模。
|
||||
|
||||
网络模型中记录之间的链接不是外键,而更像编程语言中的指针(同时仍然存储在磁盘上)。访问记录的唯一方法是沿着这些链路链上的根记录进行路径。这被称为**访问路径**。
|
||||
网络模型中记录之间的链接不是外键,而更像编程语言中的指针(同时仍然存储在磁盘上)。访问记录的唯一方法是跟随从根记录起沿这些链路所形成的路径。这被称为**访问路径(access path)**。
|
||||
|
||||
在最简单的情况下,访问路径可能类似于遍历链表:从列表头开始,一次查看一条记录,直到找到所需的记录。但在一个多对多关系的世界里,几条不同的路径可能会导致相同的记录,一个使用网络模型的程序员必须跟踪这些不同的访问路径。
|
||||
最简单的情况下,访问路径类似遍历链表:从列表头开始,每次查看一条记录,直到找到所需的记录。但在多对多关系的情况中,数条不同的路径可以到达相同的记录,网络模型的程序员必须跟踪这些不同的访问路径。
|
||||
|
||||
CODASYL中的查询是通过遍历记录列表和访问路径后,通过在数据库中移动游标来执行的。如果记录有多个父母(即来自其他记录的多个传入指针),则应用程序代码必须跟踪所有的各种关系。甚至CODASYL委员会成员也承认,这就像在一个n维数据空间中进行导航【17】。
|
||||
CODASYL中的查询是通过利用遍历记录列和跟随访问路径表在数据库中移动游标来执行的。如果记录有多个父结点(即多个来自其他记录的传入指针),则应用程序代码必须跟踪所有的各种关系。甚至CODASYL委员会成员也承认,这就像在n维数据空间中进行导航【17】。
|
||||
|
||||
尽管手动访问路径选择能够最有效地利用20世纪70年代非常有限的硬件功能(如磁带驱动器,其搜索速度非常慢),但问题是他们使查询和更新数据库的代码变得复杂不灵活。无论是分层还是网络模型,如果你没有一个你想要的数据的路径,那么你就处于一个困难的境地。你可以改变访问路径,但是你必须经过大量的手写数据库查询代码,并重写它来处理新的访问路径。很难对应用程序的数据模型进行更改。
|
||||
尽管手动选择访问路径够能最有效地利用20世纪70年代非常有限的硬件功能(如磁带驱动器,其搜索速度非常慢),但这使得查询和更新数据库的代码变得复杂不灵活。无论是分层还是网络模型,如果你没有所需数据的路径,就会陷入困境。你可以改变访问路径,但是必须浏览大量手写数据库查询代码,并重写来处理新的访问路径。更改应用程序的数据模型是很难的。
|
||||
|
||||
#### 关系模型
|
||||
|
||||
相比之下,关系模型做的就是将所有的数据放在光天化日之下:一个**关系(table)**只是一个**元组(行)**的集合,就是这样。没有迷宫似的嵌套结构,如果你想看看数据,没有复杂的访问路径。您可以读取表中的任何或所有行,选择符合任意条件的行。您可以通过指定某些列作为关键字并匹配这些关键字来读取特定行。您可以在任何表中插入一个新的行,而不必担心与其他表的外键关系[^iv]。
|
||||
相比之下,关系模型做的就是将所有的数据放在光天化日之下:一个**关系(表)**只是一个**元组(行)**的集合,仅此而已。如果你想读取数据,它没有迷宫似的嵌套结构,也没有复杂的访问路径。你可以选中符合任意条件的行,读取表中的任何或所有行。你可以通过指定某些列作为匹配关键字来读取特定行。你可以在任何表中插入一个新的行,而不必担心与其他表的外键关系[^iv]。
|
||||
|
||||
[^iv]: 外键约束允许对修改做限制,对于关系模型这并不是必选项。 即使有约束,在查询时执行外键连接,而在CODASYL中,连接在插入时高效完成。
|
||||
[^iv]: 外键约束允许对修改做限制,对于关系模型这并不是必选项。即使有约束,查询时会执行外键连接,而在CODASYL中,连接在插入时高效完成。
|
||||
|
||||
在关系数据库中,查询优化器自动决定查询的哪些部分以哪个顺序执行,以及使用哪些索引。这些选择实际上是“访问路径”,但最大的区别在于它们是由查询优化器自动生成的,而不是由程序员生成,所以我们很少需要考虑它们。
|
||||
|
||||
如果你想以新的方式查询你的数据,你可以声明一个新的索引,查询会自动使用哪个索引是最合适的。您不需要更改查询来利用新的索引。 (请参阅“[用于数据的查询语言](#用于数据的查询语言)”。)关系模型因此使向应用程序添加新功能变得更加容易。
|
||||
如果想按新的方式查询数据,可以声明一个新的索引,查询会自动使用最合适的那些索引。无需更改查询来利用新的索引。 (请参阅“[用于数据的查询语言](#用于数据的查询语言)”。)关系模型因此使添加应用程序新功能变得更加容易。
|
||||
|
||||
关系数据库的查询优化器是复杂的,他们已经耗费了多年的研究和开发工作【18】。但关系模型的一个关键洞察是:只需构建一次查询优化器,然后使用该数据库的所有应用程序都可以从中受益。如果您没有查询优化器,那么为特定查询手动编写访问路径比编写通用优化器更容易——但通用解决方案从长期看更好。
|
||||
关系数据库的查询优化器是复杂的,耗费多年的研究和开发精力【18】。关系模型的一个关键洞察是:只需构建一次查询优化器,随后使用该数据库的所有应用程序都可以从中受益。如果你没有查询优化器的话,那么为特定查询手动编写访问路径比编写通用优化器更容易——不过通用解决方案从长期看更好。
|
||||
|
||||
#### 与文档数据库相比
|
||||
|
||||
文档数据库在一个方面还原为层次模型:在其父记录中存储嵌套记录([图2-1]()中的一对多关系,如位置,教育和`contact_info`),而不是在单独的表中。
|
||||
在一个方面,文档数据库还原为层次模型:在其父记录中存储嵌套记录([图2-1]()中的一对多关系,如`positions`,`education`和`contact_info`),而不是在单独的表中。
|
||||
|
||||
但是,在表示多对一和多对多的关系时,关系数据库和文档数据库并没有根本的不同:在这两种情况下,相关项目都被一个唯一的标识符引用,这个标识符在关系模型中被称为外键,在文档模型中称为文档引用【9】。该标识符在读取时通过使用加入或后续查询来解决。迄今为止,文档数据库没有遵循CODASYL的路径。
|
||||
但是,在表示多对一和多对多的关系时,关系数据库和文档数据库并没有根本的不同:在这两种情况下,相关项目都被一个唯一的标识符引用,这个标识符在关系模型中被称为**外键**,在文档模型中称为**文档引用**【9】。该标识符在读取时通过连接或后续查询来解析。迄今为止,文档数据库没有遵循CODASYL的路数。
|
||||
|
||||
### 关系型数据库与文档数据库在今日的对比
|
||||
|
||||
将关系数据库与文档数据库进行比较时,需要考虑许多差异,包括它们的容错属性(参阅[第5章](ch5.md))和处理并发性(参阅[第7章](ch7.md))。在本章中,我们将只关注数据模型中的差异。
|
||||
将关系数据库与文档数据库进行比较时,可以考虑许多方面的差异,包括它们的容错属性(参阅[第5章](ch5.md))和处理并发性(参阅[第7章](ch7.md))。本章将只关注数据模型中的差异。
|
||||
|
||||
支持文档数据模型的主要论据是架构灵活性,由于局部性而导致的更好的性能,对于某些应用程序而言更接近于应用程序使用的数据结构。关系模型通过为连接提供更好的支持以及支持多对一和多对多的关系来反击。
|
||||
支持文档数据模型的主要论据是架构灵活性,因局部性而拥有更好的性能,以及对于某些应用程序而言更接近于应用程序使用的数据结构。关系模型通过为连接提供更好的支持以及支持多对一和多对多的关系来反击。
|
||||
|
||||
#### 哪个数据模型更方便写代码?
|
||||
|
||||
如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常整个树被一次加载),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表(如[图2-1](img/fig2-1.png)中的位置,教育和`contact_info`)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。
|
||||
如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表(如[图2-1](img/fig2-1.png)中的位置,教育和`contact_info`)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。
|
||||
|
||||
文档模型有一定的局限性:例如,你不能直接引用文档中的需要的项目,而是需要说“用户251的位置列表中的第二项”(很像访问路径在分层模型中)。但是,只要文件嵌套不太深,通常不是问题。
|
||||
文档模型有一定的局限性:例如,不能直接引用文档中的嵌套的项目,而是需要说“用户251的位置列表中的第二项”(很像分层模型中的访问路径)。但是,只要文件嵌套不太深,通常不是问题。
|
||||
|
||||
应用程序对文档数据库连接的垃圾支持也许或也许不是一个问题。例如,在使用文档数据库记录 哪个事件发生在哪儿 的分析应用程序中,可能永远不需要多对多的关系【19】。
|
||||
对文档数据库连接的糟糕支持也许或也许不是一个问题,这取决于应用程序。例如,分析应用程可能永远不需要多对多的关系,如果它使用文档数据库来记录何事发生于何时【19】。
|
||||
|
||||
但是,如果您的应用程序确实使用多对多关系,那么文档模型就没有那么吸引人了。通过反规范化可以减少对连接的需求,但是应用程序代码需要做额外的工作来保持数据的一致性。通过向数据库发出多个请求,可以在应用程序代码中模拟连接,但是这也将复杂性转移到应用程序中,并且通常比由数据库内的专用代码执行的连接慢。在这种情况下,使用文档模型会导致更复杂的应用程序代码和更差的性能【15】。
|
||||
|
||||
说哪个数据模型在一般情况下导致更简单的应用程序代码是不可能的;它取决于数据项之间存在的关系种类。对于高度相互关联的数据,文档模型很尴尬,关系模型是可接受的,而图形模型(参见“[图数据模型](#图数据模型)”)是最自然的。
|
||||
很难说在一般情况下哪个数据模型让应用程序代码更简单;它取决于数据项之间存在的关系种类。对于高度相联的数据,文档模型是糟糕的,关系模型是可接受的,而图形模型(参见“[图数据模型](#图数据模型)”)是最自然的。
|
||||
|
||||
#### 文档模型中的架构灵活性
|
||||
|
||||
大多数文档数据库以及关系数据库中的JSON支持都不会对文档中的数据执行任何模式。关系数据库中的XML支持通常带有可选的模式验证。没有模式意味着可以将任意的键和值添加到文档中,并且在读取时,客户端对于文档可能包含的字段没有保证。
|
||||
大多数文档数据库以及关系数据库中的JSON支持都不会强制文档中的数据采用何种模式。关系数据库中的XML支持通常带有可选的模式验证。没有模式意味着可以将任意的键和值添加到文档中,并且当读取时,客户端对无法保证文档可能包含的字段。
|
||||
|
||||
文档数据库有时称为**无模式(schemaless)**,但这是误导性的,因为读取数据的代码通常采用某种结构——即存在隐式模式,但不由数据库强制执行【20】。一个更精确的术语是**读时模式(schema-on-read)**(数据的结构是隐含的,只有在数据被读取时才被解释),相应的是**写时模式(schema-on-write)**(传统的关系数据库方法,模式是明确,数据库确保所有的数据都符合它的形式)【21】。
|
||||
文档数据库有时称为**无模式(schemaless)**,但这是误导性的,因为读取数据的代码通常假定某种结构——即存在隐式模式,但不由数据库强制执行【20】。一个更精确的术语是**读时模式(schema-on-read)**(数据的结构是隐含的,只有在数据被读取时才被解释),相应的是**写时模式(schema-on-write)**(传统的关系数据库方法,模式明确,且数据库确保所有的数据都符合其模式)【21】。
|
||||
|
||||
读取模式类似于编程语言中的动态(运行时)类型检查,而模式写入类似于静态(编译时)类型检查。就像静态和动态类型检查的倡导者对于它们的相对优点有很大的争议【22】,数据库中模式的执行是一个有争议的话题,一般来说没有正确或错误的答案。
|
||||
读取模式类似于编程语言中的动态(运行时)类型检查,而模式写入类似于静态(编译时)类型检查。就像静态和动态类型检查的相对优点具有很大的争议性【22】,数据库中模式的强制性是一个具有争议的话题,一般来说没有正确或错误的答案。
|
||||
|
||||
在应用程序想要改变其数据格式的情况下,这些方法之间的区别特别明显。例如,假设你正在将每个用户的全名存储在一个字段中,而你想分别存储名字和姓氏【23】。在文档数据库中,只需开始使用新字段写入新文档,并在应用程序中使用代码来处理读取旧文档时的情况。例如:
|
||||
在应用程序想要改变其数据格式的情况下,这些方法之间的区别特别明显。例如,假设你把每个用户的全名存储在一个字段中,而现在想分别存储名字和姓氏【23】。在文档数据库中,只需开始写入具有新字段的新文档,并在应用程序中使用代码来处理读取旧文档时的情况。例如:
|
||||
|
||||
```go
|
||||
if (user && user.name && !user.first_name) {
|
||||
// Documents written before Dec 8, 2013 don't have first_name
|
||||
// Documents written before Dec 8, 2013 don't have first_name
|
||||
user.first_name = user.name.split(" ")[0];
|
||||
}
|
||||
```
|
||||
|
||||
另一方面,在“静态类型”数据库模式中,通常会执行以下操作:
|
||||
另一方面,在“静态类型”数据库模式中,通常会执行以下**迁移(migration)**操作:
|
||||
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN first_name text;
|
||||
UPDATE users SET first_name = split_part(name, ' ', 1); -- PostgreSQL
|
||||
UPDATE users SET first_name = split_part(name, ' ', 1); -- PostgreSQL
|
||||
UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
|
||||
```
|
||||
|
||||
模式变更的速度很慢,而且需要停机。这种声誉并不是完全应得的:大多数关系数据库系统在几毫秒内执行`ALTER TABLE`语句。 MySQL是一个值得注意的例外,它执行`ALTER TABLE`时会复制整个表,这可能意味着在更改一个大表时会花费几分钟甚至几个小时的停机时间,尽管存在各种工具可以解决这个限制【24,25,26】。
|
||||
模式变更的速度很慢,而且需要停运。这种坏声誉并不是完全应得的:大多数关系数据库系统可在几毫秒内执行`ALTER TABLE`语句。 MySQL是一个值得注意的例外,它执行`ALTER TABLE`时会复制整个表,这可能意味着在更改一个大表时会花费几分钟甚至几个小时的停机时间,尽管存在各种工具可以解决这个限制【24,25,26】。
|
||||
|
||||
在大型表上运行`UPDATE`语句在任何数据库上都可能会很慢,因为每一行都需要重写。如果这是不可接受的,应用程序可以将`first_name`设置为默认值`NULL`,并在读取时填充它,就像使用文档数据库一样。
|
||||
在大型表上运行`UPDATE`语句在任何数据库上都可能会很慢,因为每一行都需要重写。如果这是不可接受的,应用程序可以将`first_name`设置为默认值`NULL`,并在读取时再填充,就像使用文档数据库一样。
|
||||
|
||||
如果由于某种原因(例如,数据是异构的)集合中的项目并不都具有相同的结构,例如,因为:
|
||||
读时模式更具优势,当由于某种原因(例如,数据是异构的)集合中的项目并不都具有相同的结构,例如,因为:
|
||||
|
||||
* 有许多不同类型的对象,将每种类型的对象放在自己的表中是不现实的。
|
||||
* 数据的结构由您无法控制,且随时可能更改的外部系统决定。
|
||||
* 数据的结构由您无法控制且随时可能变化的外部系统决定。
|
||||
|
||||
在这样的情况下,模式的伤害远大于它的帮助,无模式文档可能是一个更加自然的数据模型。但是,如果所有记录都有相同的结构,那么模式就是记录和强制这种结构的有用机制。我们将在第四章更详细地讨论模式和模式演化。
|
||||
在这样的情况下,模式的伤害远大于它的帮助,无模式文档可能是一个更加自然的数据模型。但是,如果所有记录都具有相同的结构,那么模式就是记录并强制这种结构的有用机制。第四章将更详细地讨论模式和模式演化。
|
||||
|
||||
#### 查询的数据局部性
|
||||
|
||||
@ -367,7 +368,7 @@ SQL示例不保证任何特定的顺序,所以它不介意顺序是否改变
|
||||
现在想让当前所选页面的标题有一个蓝色的背景,以便在视觉上突出显示。 使用CSS实现起来非常简单:
|
||||
|
||||
```css
|
||||
li.selected > p {
|
||||
li.selected > p {
|
||||
background-color: blue;
|
||||
}
|
||||
```
|
||||
@ -552,7 +553,7 @@ db.observations.aggregate([
|
||||
|
||||
* 唯一标识符
|
||||
* **边的起点/尾点(tail vertex)**
|
||||
* **边的终点/头点(head vertex)**
|
||||
* **边的终点/头点(head vertex)**
|
||||
* 描述两个顶点之间关系类型的标签
|
||||
* 一组属性(键值对)
|
||||
|
||||
@ -613,7 +614,7 @@ CREATE
|
||||
```cypher
|
||||
MATCH
|
||||
(person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (us:Location {name:'United States'}),
|
||||
(person) -[:LIVES_IN]-> () -[:WITHIN*0..]-> (eu:Location {name:'Europe'})
|
||||
(person) -[:LIVES_IN]-> () -[:WITHIN*0..]-> (eu:Location {name:'Europe'})
|
||||
RETURN person.name
|
||||
```
|
||||
|
||||
@ -633,9 +634,9 @@ RETURN person.name
|
||||
|
||||
对于声明性查询语言来说,典型的情况是,在编写查询语句时,您不需要指定执行细节:查询优化程序会自动选择预测效率最高的策略,因此您可以继续编写其余的应用程序。
|
||||
|
||||
### SQL中的图表查询
|
||||
### SQL中的图查询
|
||||
|
||||
[例2-2]()建议可以在关系数据库中表示图形数据。但是,如果我们把图形数据放入关系结构中,我们是否也可以使用SQL查询它?答案是肯定的,但有些困难。在关系数据库中,您通常会事先知道在查询中需要哪些连接。在图表查询中,您可能需要在找到要查找的顶点之前,遍历可变数量的边。也就是说,连接的数量事先并不确定。
|
||||
[例2-2]()建议可以在关系数据库中表示图数据。但是,如果我们把图数据放入关系结构中,我们是否也可以使用SQL查询它?答案是肯定的,但有些困难。在关系数据库中,您通常会事先知道在查询中需要哪些连接。在图查询中,您可能需要在找到要查找的顶点之前,遍历可变数量的边。也就是说,连接的数量事先并不确定。
|
||||
|
||||
在我们的例子中,这发生在Cypher查询中的`() -[:WITHIN*0..]-> ()`规则中。一个人的`LIVES_IN`边缘可以指向任何类型的位置:街道,城市,地区,地区,国家等。城市可以在一个地区,在一个州内的一个地区,在一个国家内的一个州等等。`LIVES_IN`边可以直接指向你正在查找的位置,或者可以在位置层次结构中删除几个级别。
|
||||
在Cypher中,`WITHIN * 0`表示这个事实非常简洁:意思是“沿着一个`WITHIN`边,零次或多次”。它就像正则表达式中的`*`运算符。
|
||||
@ -649,24 +650,24 @@ WITH RECURSIVE
|
||||
in_usa(vertex_id) AS (
|
||||
SELECT vertex_id FROM vertices WHERE properties ->> 'name' = 'United States'
|
||||
UNION
|
||||
SELECT edges.tail_vertex FROM edges
|
||||
SELECT edges.tail_vertex FROM edges
|
||||
JOIN in_usa ON edges.head_vertex = in_usa.vertex_id
|
||||
WHERE edges.label = 'within'
|
||||
),
|
||||
-- in_europe 包含所有的欧洲境内的地点ID
|
||||
in_europe(vertex_id) AS (
|
||||
SELECT vertex_id FROM vertices WHERE properties ->> 'name' = 'Europe'
|
||||
UNION
|
||||
UNION
|
||||
SELECT edges.tail_vertex FROM edges
|
||||
JOIN in_europe ON edges.head_vertex = in_europe.vertex_id
|
||||
WHERE edges.label = 'within' ),
|
||||
|
||||
|
||||
-- born_in_usa 包含了所有类型为Person,且出生在美国的顶点
|
||||
born_in_usa(vertex_id) AS (
|
||||
SELECT edges.tail_vertex FROM edges
|
||||
JOIN in_usa ON edges.head_vertex = in_usa.vertex_id
|
||||
WHERE edges.label = 'born_in' ),
|
||||
|
||||
|
||||
-- lives_in_europe 包含了所有类型为Person,且居住在欧洲的顶点。
|
||||
lives_in_europe(vertex_id) AS (
|
||||
SELECT edges.tail_vertex FROM edges
|
||||
@ -816,7 +817,7 @@ SPARQL是一种很好的查询语言——即使语义网从来没有出现,
|
||||
|
||||
> #### 图形数据库与网络模型相比较
|
||||
>
|
||||
> 在“[文档数据库是否在重蹈覆辙?](#文档数据库是否在重蹈覆辙?)”中,我们讨论了CODASYL和关系模型如何竞争解决IMS中的多对多关系问题。乍一看,CODASYL的网络模型看起来与图模型相似。 CODASYL是否是图形数据库的第二个变种?
|
||||
> 在“[文档数据库是否在重演历史?](#文档数据库是否在重演历史?)”中,我们讨论了CODASYL和关系模型如何竞争解决IMS中的多对多关系问题。乍一看,CODASYL的网络模型看起来与图模型相似。 CODASYL是否是图形数据库的第二个变种?
|
||||
>
|
||||
> 不,他们在几个重要方面有所不同:
|
||||
>
|
||||
@ -862,15 +863,15 @@ born_in(lucy, idaho).
|
||||
```
|
||||
within_recursive(Location, Name) :- name(Location, Name). /* Rule 1 */
|
||||
|
||||
within_recursive(Location, Name) :- within(Location, Via), /* Rule 2 */
|
||||
within_recursive(Location, Name) :- within(Location, Via), /* Rule 2 */
|
||||
within_recursive(Via, Name).
|
||||
|
||||
migrated(Name, BornIn, LivingIn) :- name(Person, Name), /* Rule 3 */
|
||||
migrated(Name, BornIn, LivingIn) :- name(Person, Name), /* Rule 3 */
|
||||
born_in(Person, BornLoc),
|
||||
within_recursive(BornLoc, BornIn),
|
||||
lives_in(Person, LivingLoc),
|
||||
within_recursive(LivingLoc, LivingIn).
|
||||
|
||||
|
||||
?- migrated(Who, 'United States', 'Europe'). /* Who = 'Lucy'. */
|
||||
|
||||
```
|
||||
@ -908,19 +909,19 @@ Datalog方法需要对本章讨论的其他查询语言采取不同的思维方
|
||||
1. 文档数据库的应用场景是:数据通常是自我包含的,而且文档之间的关系非常罕见。
|
||||
2. 图形数据库用于相反的场景: 任何东西都可能和任何东西相关联。
|
||||
|
||||
所有这三种模型(文档,关系和图形)今天都被广泛使用,并且在各自的领域都是很好用的。一个模型可以用另一个模型来模拟 - 例如,图形数据可以在关系数据库中表示 - 但结果往往是尴尬的。这就是为什么我们有不同的系统用于不同的目的,而不是一个单一的万能解决方案。
|
||||
所有这三种模型(文档,关系和图形)今天都被广泛使用,并且在各自的领域都是很好用的。一个模型可以用另一个模型来模拟 —— 例如,图数据可以在关系数据库中表示 —— 但结果往往是尴尬的。这就是为什么我们有着用于不同目的的不同系统,而不是一个单一的万能解决方案。
|
||||
|
||||
文档数据库和图数据库有一个共同点,那就是它们通常不会为存储的数据强制实施一个模式,这可以使应用程序更容易适应不断变化的需求。但是应用程序很可能仍假定数据具有一定的结构:这只是模式是明确的(强制写入)还是隐含的(在读取时处理)的问题。
|
||||
|
||||
每个数据模型都带有自己的查询语言或框架,我们讨论了几个例子:SQL,MapReduce,MongoDB的聚合管道,Cypher,SPARQL和Datalog。我们也谈到了CSS和XSL/XPath,它们不是数据库查询语言,而包含有趣的相似之处。
|
||||
每个数据模型都带有自己的查询语言或框架,我们讨论了几个例子:SQL,MapReduce,MongoDB的聚合管道,Cypher,SPARQL和Datalog。我们也谈到了CSS和 XSL/XPath,它们不是数据库查询语言,而包含有趣的相似之处。
|
||||
|
||||
虽然我们已经覆盖了很多地方,但仍然有许多数据模型没有提到。举几个简单的例子:
|
||||
|
||||
* 研究人员使用基因组数据通常需要执行序列相似性搜索,这意味着需要一个很长的字符串(代表一个DNA分子),并将其与一个类似但不完全相同的大型字符串数据库进行匹配。这里所描述的数据库都不能处理这种用法,这就是为什么研究人员编写了像GenBank这样的专门的基因组数据库软件的原因【48】。
|
||||
* 粒子物理学家数十年来一直在进行大数据类型的大规模数据分析,像大型强子对撞机(LHC)这样的项目现在可以工作在数百亿兆字节的范围内!在这样的规模下,需要定制解决方案来阻止硬件成本从失控中解脱出来【49】。
|
||||
* 全文搜索可以说是一种经常与数据库一起使用的数据模型。信息检索是一个大的专业课题,在本书中我们不会详细介绍,但是我们将在第三章和第三章中介绍搜索指标。
|
||||
* 全文搜索可以说是一种经常与数据库一起使用的数据模型。信息检索是一个大的专业课题,在本书中我们不会详细介绍,但是我们将在第三章和第三章中介绍搜索索引。
|
||||
|
||||
在下一章中,我们将讨论在实现本章描述的数据模型时会发挥的一些权衡。
|
||||
在[下一章](ch3.md)中,我们将讨论在实现本章描述的数据模型时会遇到的一些权衡。
|
||||
|
||||
|
||||
|
||||
@ -1035,4 +1036,3 @@ Datalog方法需要对本章讨论的其他查询语言采取不同的思维方
|
||||
| 上一章 | 目录 | 下一章 |
|
||||
| -------------------------------------- | ------------------------------- | ---------------------------- |
|
||||
| [第一章:可靠、可扩展、可维护](ch1.md) | [设计数据密集型应用](README.md) | [第三章:存储与检索](ch3.md) |
|
||||
|
||||
|
167
ch3.md
167
ch3.md
@ -11,21 +11,17 @@
|
||||
|
||||
[TOC]
|
||||
|
||||
在最基本的层次上,一个数据库需要完成两件事情:当你给它数据时,它应该存储起来,而当你提问时,它应该把数据返回给你。
|
||||
一个数据库在最基础的层次上需要完成两件事情:当你把数据交给数据库时,它应当把数据存储起来;而后当你向数据库要数据时,它应当把数据返回给你。
|
||||
|
||||
在第二章中,我们讨论了数据模型和查询语言,即程序员录入数据库的数据格式,以及你可以再次获取它的机制。在本章中,我们讨论同样的问题,却是从数据库的视角:数据库如何存储我们提供的数据,以及如何在我们需要时重新找到数据。
|
||||
在[第2章](ch2.md)中,我们讨论了数据模型和查询语言,即程序员将数据录入数据库的格式,以及再次要回数据的机制。在本章中我们会从数据库的视角来讨论同样的问题:数据库如何存储我们提供的数据,以及如何在我们需要时重新找到数据。
|
||||
|
||||
作为程序员,为什么要关心数据库如何在内部处理存储和检索?你可能不会从头开始实现自己的存储引擎,但是您需要从可用的许多存储引擎中选择适合应用程序的存储引擎。为了调谐一个存储引擎以适应应用工作负载,你需要大致了解存储引擎在做什么。
|
||||
作为程序员,为什么要关心数据库内部存储与检索的机理?你可能不会去从头开始实现自己的存储引擎,但是你**确实**需要从许多可用的存储引擎中选择一个合适的。而且为了调谐存储引擎以适配应用工作负载,你也需要大致了解存储引擎在底层究竟做什么。
|
||||
|
||||
特别需要注意,针对事务性工作负载优化的存储引擎,与针对分析优化的存储引擎之间存在着巨大差异。稍后我们将在 “[事务处理还是分析?](#事务处理还是分析?)” 和 “[列存储](#列存储)”中探讨这个区别,那里将讨论针对分析优化的一系列存储引擎。
|
||||
特别需要注意,针对**事务**性负载和**分析性**负载优化的存储引擎之间存在巨大差异。稍后我们将在 “[事务处理还是分析?](#事务处理还是分析?)” 一节中探讨这一区别,并在 “[列存储](#列存储)”中讨论一系列针对分析优化存储引擎。
|
||||
|
||||
但是,我们将从您最可能熟悉的两大类数据库:传统关系型数据库与所谓的“NoSQL”数据库开始,通过介绍它们的存储引擎来开始本章的内容。
|
||||
但是,我们将从您最可能熟悉的两大类数据库:传统关系型数据库与很多所谓的“NoSQL”数据库开始,通过介绍它们的**存储引擎**来开始本章的内容。我们会研究两大类存储引擎:**日志结构(log-structured)**的存储引擎,以及**面向页面(page-oriented)**的存储引擎(例如B树)。
|
||||
|
||||
我们会研究两大类存储引擎:日志结构(log-structured)的存储引擎,以及面向页面(page-oriented)的存储引擎(如B树)。
|
||||
|
||||
|
||||
|
||||
## 数据库的底层数据结构
|
||||
## 驱动数据库的数据结构
|
||||
|
||||
世界上最简单的数据库可以用两个Bash函数实现:
|
||||
|
||||
@ -40,62 +36,70 @@ 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"]}' $ db_get 42
|
||||
{"name":"San Francisco","attractions":["Exploratorium"]}
|
||||
$ db_set 42 '{"name":"San Francisco","attractions":["Exploratorium"]}'
|
||||
|
||||
$ 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)的索引开始。这不是您可以索引的唯一一种数据类型,但键值数据是非常常见的。对于更复杂的索引来说,这是一个有用的构建模块。
|
||||
让我们从**键值数据(key-value Data)**的索引开始。这不是您可以索引的唯一数据类型,但键值数据是很常见的。对于更复杂的索引来说,这是一个有用的构建模块。
|
||||
|
||||
键值存储与在大多数编程语言中可以找到的字典类型非常相似,通常字典都是用散列表(哈希表)实现的。哈希映射在许多算法教科书中都有描述【1,2】,所以我们不会详细讨论它们工作方式。既然我们已经有内存数据结构——HashMap,为什么不使用它们来索引在磁盘上的数据呢?
|
||||
键值存储与在大多数编程语言中可以找到的**字典(dictionary)**类型非常相似,通常字典都是用**散列映射(hash map)**(或**哈希表(hash table)**)实现的。哈希映射在许多算法教科书中都有描述【1,2】,所以这里我们不会讨论它的工作细节。既然我们已经有**内存中**数据结构 —— 哈希映射,为什么不使用它来索引在**磁盘上**的数据呢?
|
||||
|
||||
假设我们的数据存储只包含一个文件,就像前面的例子一样。然后,最简单的索引策略是:保留一个内存HashMap,其中每个键映射到数据文件中的一个字节偏移量,即该值可以被找到的位置。
|
||||
|
||||
如[图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)
|
||||
|
||||
@ -126,7 +130,7 @@ $ cat database
|
||||
|
||||
乍一看,只有追加日志看起来很浪费:为什么不更新文件,用新值覆盖旧值?但是只能追加设计的原因有几个:
|
||||
|
||||
* 追加和分段合并是顺序写入操作,通常比随机写入快得多,尤其是在磁盘旋转硬盘上。在某种程度上,顺序写入在基于闪存的固态硬盘(SSD)上也是优选的【4】。我们将在第83页的“[比较B-树和LSM-树](#比较B-树和LSM-树)”中进一步讨论这个问题。
|
||||
* 追加和分段合并是顺序写入操作,通常比随机写入快得多,尤其是在磁盘旋转硬盘上。在某种程度上,顺序写入在基于闪存的**固态硬盘(SSD)**上也是优选的【4】。我们将在第83页的“[比较B-树和LSM-树](#比较B-树和LSM-树)”中进一步讨论这个问题。
|
||||
* 如果段文件是附加的或不可变的,并发和崩溃恢复就简单多了。例如,您不必担心在覆盖值时发生崩溃的情况,而将包含旧值和新值的一部分的文件保留在一起。
|
||||
* 合并旧段可以避免数据文件随着时间的推移而分散的问题。
|
||||
|
||||
@ -142,15 +146,15 @@ $ cat database
|
||||
|
||||
|
||||
|
||||
### SSTables和LSM-Trees
|
||||
### SSTables和LSM树
|
||||
|
||||
在[图3-3](img/fig3-3.png)中,每个日志结构存储段都是一系列键值对。这些对按照它们写入的顺序出现,日志中稍后的值优先于日志中较早的相同键的值。除此之外,文件中键值对的顺序并不重要。
|
||||
|
||||
现在我们可以对段文件的格式做一个简单的改变:我们要求键值对的序列按键排序。乍一看,这个要求似乎打破了我们使用顺序写入的能力,但是我们马上就会明白这一点。
|
||||
|
||||
我们把这个格式称为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)
|
||||
|
||||
@ -158,44 +162,46 @@ $ cat database
|
||||
|
||||
如果在几个输入段中出现相同的键,该怎么办?请记住,每个段都包含在一段时间内写入数据库的所有值。这意味着一个输入段中的所有值必须比另一个段中的所有值更新(假设我们总是合并相邻的段)。当多个段包含相同的键时,我们可以保留最近段的值,并丢弃旧段中的值。
|
||||
|
||||
2. 为了在文件中找到一个特定的键,你不再需要保存内存中所有键的索引。以[图3-5](img/fig3-5.png)为例:假设你正在内存中寻找键`handiwork`,但是你不知道段文件中该关键字的确切偏移量。然而,你知道`handbag`和`handsome`的偏移,而且由于排序特性,你知道`handiwork`必须出现在这两者之间。这意味着您可以跳到`handbag`的偏移位置并从那里扫描,直到您找到`handiwork`(或没找到,如果该文件中没有该键)。
|
||||
2. 为了在文件中找到一个特定的键,你不再需要保存内存中所有键的索引。以[图3-5](img/fig3-5.png)为例:假设你正在内存中寻找键 `handiwork`,但是你不知道段文件中该关键字的确切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由于排序特性,你知道 `handiwork` 必须出现在这两者之间。这意味着您可以跳到 `handbag` 的偏移位置并从那里扫描,直到您找到 `handiwork`(或没找到,如果该文件中没有该键)。
|
||||
|
||||
![](img/fig3-5.png)
|
||||
|
||||
**图3-5 具有内存索引的SSTable**
|
||||
|
||||
您仍然需要一个内存中索引来告诉您一些键的偏移量,但它可能很稀疏:每几千字节的段文件就有一个键就足够了,因为几千字节可以很快被扫描。
|
||||
您仍然需要一个内存中索引来告诉您一些键的偏移量,但它可能很稀疏:每几千字节的段文件就有一个键就足够了,因为几千字节可以很快被扫描[^i]。
|
||||
|
||||
|
||||
3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组到块中,并在将其写入磁盘之前对其进行压缩(如图3-5中的阴影区域所示) 。稀疏内存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少IO带宽的使用。
|
||||
3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组到块中,并在将其写入磁盘之前对其进行压缩(如[图3-5](img/fig3-5.png)中的阴影区域所示) 。稀疏内存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少IO带宽的使用。
|
||||
|
||||
|
||||
[^i]: 如果所有的键与值都是定长的,你可以使用段文件上的二分查找并完全避免使用内存索引。然而实践中键值通常都是变长的,因此如果没有索引,就很难知道记录的分界点(前一条记录结束,后一条记录开始的地方)
|
||||
|
||||
#### 构建和维护SSTables
|
||||
|
||||
到目前为止,但是如何让你的数据首先被按键排序呢?我们的传入写入可以以任何顺序发生。
|
||||
|
||||
在磁盘上维护有序结构是可能的(参阅“B-Tree”),但在内存保存则要容易得多。有许多可以使用的众所周知的树形数据结构,例如红黑树或AVL树【2】。使用这些数据结构,您可以按任何顺序插入键,并按排序顺序读取它们。
|
||||
在磁盘上维护有序结构是可能的(参阅“[B树](#B树)”),但在内存保存则要容易得多。有许多可以使用的众所周知的树形数据结构,例如红黑树或AVL树【2】。使用这些数据结构,您可以按任何顺序插入键,并按排序顺序读取它们。
|
||||
|
||||
现在我们可以使我们的存储引擎工作如下:
|
||||
|
||||
* 写入时,将其添加到内存中的平衡树数据结构(例如,红黑树)。这个内存树有时被称为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,而较旧的数据被移动到单独的“水平”,这使得压缩能够更加递增地进行,并且使用更少的磁盘空间。
|
||||
|
||||
@ -211,7 +217,7 @@ Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使
|
||||
|
||||
我们前面看到的日志结构索引将数据库分解为可变大小的段,通常是几兆字节或更大的大小,并且总是按顺序编写段。相比之下,B树将数据库分解成固定大小的块或页面,传统上大小为4 KB(有时会更大),并且一次只能读取或写入一个页面。这种设计更接近于底层硬件,因为磁盘也被安排在固定大小的块中。
|
||||
|
||||
每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 - 类似于指针,但在磁盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如[图3-6](img/fig3-6.png)所示。
|
||||
每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在磁盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如[图3-6](img/fig3-6.png)所示。
|
||||
|
||||
![](img/fig3-6.png)
|
||||
|
||||
@ -223,25 +229,27 @@ 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)所示。
|
||||
如果要更新B树中现有键的值,则搜索包含该键的叶页,更改该页中的值,并将该页写回到磁盘(对该页的任何引用保持有效) 。如果你想添加一个新的键,你需要找到其范围包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以解释键范围的新分区,如[图3-7](img/fig3-7.png)所示[^ii]。
|
||||
|
||||
[^ii]: 向B树中插入一个新的键是相当符合直觉的,但删除一个键(同时保持树平衡)就会牵扯很多其他东西了。
|
||||
|
||||
![](img/fig3-7.png)
|
||||
|
||||
**图3-7 通过分割页面来生长B树**
|
||||
|
||||
该算法确保树保持平衡:具有n个键的B树总是具有O(log n)的深度。大多数数据库可以放入一个三到四层的B树,所以你不需要遵循许多页面引用来找到你正在查找的页面。 (分支因子为500的4 KB页面的四级树可以存储多达256 TB。)
|
||||
该算法确保树保持平衡:具有 n 个键的B树总是具有$O(log n)$的深度。大多数数据库可以放入一个三到四层的B树,所以你不需要遵循许多页面引用来找到你正在查找的页面。 (分支因子为500的4KB页面的四级树可以存储多达256 TB。)
|
||||
|
||||
#### 让B树更可靠
|
||||
|
||||
B树的基本底层写操作是用新数据覆盖磁盘上的页面。假定覆盖不改变页面的位置;即,当页面被覆盖时,对该页面的所有引用保持完整。这与日志结构索引(如LSM-trees)形成鲜明对比,后者只附加到文件(并最终删除过时的文件),但从不修改文件。
|
||||
B树的基本底层写操作是用新数据覆盖磁盘上的页面。假定覆盖不改变页面的位置;即,当页面被覆盖时,对该页面的所有引用保持完整。这与日志结构索引(如LSM树)形成鲜明对比,后者只附加到文件(并最终删除过时的文件),但从不修改文件。
|
||||
|
||||
您可以考虑将硬盘上的页面覆盖为实际的硬件操作。在磁性硬盘驱动器上,这意味着将磁头移动到正确的位置,等待旋转盘上的正确位置出现,然后用新的数据覆盖适当的扇区。在固态硬盘上,由于SSD必须一次擦除和重写相当大的存储芯片块,所以会发生更复杂的事情【19】。
|
||||
|
||||
而且,一些操作需要覆盖几个不同的页面。例如,如果因为插入导致页面过度而拆分页面,则需要编写已拆分的两个页面,并覆盖其父页面以更新对两个子页面的引用。这是一个危险的操作,因为如果数据库在仅有一些页面被写入后崩溃,那么最终将导致一个损坏的索引(例如,可能有一个孤儿页面不是任何父项的子项) 。
|
||||
|
||||
为了使数据库对崩溃具有韧性,B树实现通常会带有一个额外的磁盘数据结构:预写式日志(WAL,也称为重做日志)。这是一个只能追加的文件,每个B树修改都可以应用到树本身的页面上。当数据库在崩溃后恢复时,这个日志被用来恢复B树回到一致的状态【5,20】。
|
||||
为了使数据库对崩溃具有韧性,B树实现通常会带有一个额外的磁盘数据结构:**预写式日志(write-ahead-log)**(WAL,也称为重做日志)。这是一个只能追加的文件,每个B树修改都可以应用到树本身的页面上。当数据库在崩溃后恢复时,这个日志被用来使B树恢复到一致的状态【5,20】。
|
||||
|
||||
更新页面的一个额外的复杂情况是,如果多个线程要同时访问B树,则需要仔细的并发控制 - 否则线程可能会看到树处于不一致的状态。这通常通过使用**锁存器(latches)**(轻量级锁)保护树的数据结构来完成。日志结构化的方法在这方面更简单,因为它们在后台进行所有的合并,而不会干扰传入的查询,并且不时地将旧的分段原子交换为新的分段。
|
||||
|
||||
@ -291,9 +299,9 @@ B树在数据库体系结构中是非常根深蒂固的,为许多工作负载
|
||||
|
||||
到目前为止,我们只讨论了关键值索引,它们就像关系模型中的**主键(primary key)**索引。主键唯一标识关系表中的一行,或文档数据库中的一个文档或图形数据库中的一个顶点。数据库中的其他记录可以通过其主键(或ID)引用该行/文档/顶点,并且索引用于解析这样的引用。
|
||||
|
||||
有二级索引也很常见。在关系数据库中,您可以使用`CREATE INDEX`命令在同一个表上创建多个二级索引,而且这些索引通常对于有效地执行联接而言至关重要。例如,在[第2章](ch2.md)中的[图2-1](img/fig2-1.png)中,很可能在`user_id`列上有一个二级索引,以便您可以在每个表中找到属于同一用户的所有行。
|
||||
有二级索引也很常见。在关系数据库中,您可以使用 `CREATE INDEX` 命令在同一个表上创建多个二级索引,而且这些索引通常对于有效地执行联接而言至关重要。例如,在[第2章](ch2.md)中的[图2-1](img/fig2-1.png)中,很可能在 `user_id` 列上有一个二级索引,以便您可以在每个表中找到属于同一用户的所有行。
|
||||
|
||||
一个二级索引可以很容易地从一个键值索引构建。主要的不同是Key不是唯一的。即可能有许多行(文档,顶点)具有相同的键。这可以通过两种方式来解决:或者通过使索引中的每个值,成为匹配行标识符的列表(如全文索引中的发布列表),或者通过向每个索引添加行标识符来使每个关键字唯一。无论哪种方式,B树和日志结构索引都可以用作辅助索引。
|
||||
一个二级索引可以很容易地从一个键值索引构建。主要的不同是键不是唯一的。即可能有许多行(文档,顶点)具有相同的键。这可以通过两种方式来解决:或者通过使索引中的每个值,成为匹配行标识符的列表(如全文索引中的发布列表),或者通过向每个索引添加行标识符来使每个关键字唯一。无论哪种方式,B树和日志结构索引都可以用作辅助索引。
|
||||
|
||||
#### 将值存储在索引中
|
||||
|
||||
@ -302,7 +310,7 @@ B树在数据库体系结构中是非常根深蒂固的,为许多工作负载
|
||||
|
||||
在某些情况下,从索引到堆文件的额外跳跃对读取来说性能损失太大,因此可能希望将索引行直接存储在索引中。这被称为聚集索引。例如,在MySQL的InnoDB存储引擎中,表的主键总是一个聚簇索引,二级索引用主键(而不是堆文件中的位置)【31】。在SQL Server中,可以为每个表指定一个聚簇索引【32】。
|
||||
|
||||
在**聚集索引(clustered index)**(在索引中存储所有行数据)和**非聚集索引(nonclustered index)**(仅在索引中存储对数据的引用)之间的折衷被称为**包含列的索引(index with included columns)**或**覆盖索引(covering index)**,其存储表的一部分在索引内【33】。这允许通过单独使用索引来回答一些查询(这种情况叫做:索引**覆盖了(cover)**查询)【32】。
|
||||
在**聚集索引(clustered index)**(在索引中存储所有行数据)和**非聚集索引(nonclustered index)**(仅在索引中存储对数据的引用)之间的折衷被称为**包含列的索引(index with included columns)**或**覆盖索引(covering index)**,其存储表的一部分在索引内【33】。这允许通过单独使用索引来回答一些查询(这种情况叫做:索引**覆盖(cover)**了查询)【32】。
|
||||
|
||||
与任何类型的数据重复一样,聚簇和覆盖索引可以加快读取速度,但是它们需要额外的存储空间,并且会增加写入开销。数据库还需要额外的努力来执行事务保证,因为应用程序不应该因为重复而导致不一致。
|
||||
|
||||
@ -321,7 +329,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
|
||||
|
||||
一个标准的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】。
|
||||
|
||||
@ -347,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为各种数据结构(如优先级队列和集合)提供了类似数据库的接口。因为它将所有数据保存在内存中,所以它的实现相对简单。
|
||||
|
||||
@ -360,16 +368,17 @@ 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)**。
|
||||
|
||||
即使数据库开始被用于许多不同类型的博客文章,游戏中的动作,地址簿中的联系人等等,基本访问模式仍然类似于处理业务事务。应用程序通常使用索引通过某个键查找少量记录。根据用户的输入插入或更新记录。由于这些应用程序是交互式的,因此访问模式被称为在线事务处理(OLTP)。
|
||||
但是,数据库也开始越来越多地用于数据分析,这些数据分析具有非常不同的访问模式。通常,分析查询需要扫描大量记录,每个记录只读取几列,并计算汇总统计信息(如计数,总和或平均值),而不是将原始数据返回给用户。例如,如果您的数据是一个销售交易表,那么分析查询可能是:
|
||||
|
||||
* 一月份我们每个商店的总收入是多少?
|
||||
* 我们在最近的推广活动中销售多少香蕉?
|
||||
* 哪种品牌的婴儿食品最常与X品牌的尿布一起购买?
|
||||
|
||||
这些查询通常由业务分析师编写,并提供给帮助公司管理层做出更好决策(商业智能)的报告。为了区分这种使用数据库的事务处理模式,它被称为在线分析处理(OLAP)。【47】。OLTP和OLAP之间的区别并不总是清晰的,但是一些典型的特征在[表3-1]()中列出。
|
||||
这些查询通常由业务分析师编写,并提供给帮助公司管理层做出更好决策(商业智能)的报告。为了区分这种使用数据库的事务处理模式,它被称为**在线分析处理(OLAP, OnLine Analytice Processing)**。【47】。OLTP和OLAP之间的区别并不总是清晰的,但是一些典型的特征在[表3-1]()中列出。
|
||||
|
||||
**表3-1 比较交易处理和分析系统的特点**
|
||||
|
||||
@ -381,15 +390,15 @@ 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数据库上运行临时分析查询,因为这些查询通常很昂贵,扫描大部分数据集,这会损害同时执行的事务的性能。
|
||||
|
||||
相比之下,数据仓库是一个独立的数据库,分析人员可以查询他们心中的内容,而不影响OLTP操作【48】。数据仓库包含公司所有各种OLTP系统中的只读数据副本。从OLTP数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为“抽取-转换-加载(ETL)”,如[图3-8](img/fig3-8)所示。
|
||||
相比之下,数据仓库是一个独立的数据库,分析人员可以查询他们心中的内容,而不影响OLTP操作【48】。数据仓库包含公司所有各种OLTP系统中的只读数据副本。从OLTP数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为“**抽取-转换-加载(ETL)**”,如[图3-8](img/fig3-8)所示。
|
||||
|
||||
![](img/fig3-8.png)
|
||||
|
||||
@ -401,7 +410,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
|
||||
|
||||
#### OLTP数据库和数据仓库之间的分歧
|
||||
|
||||
数据仓库的数据模型通常是关系型的,因为SQL通常很适合分析查询。有许多图形数据分析工具可以生成SQL查询,可视化结果,并允许分析人员(通过下钻,切片和切块等操作)探索数据。
|
||||
数据仓库的数据模型通常是关系型的,因为SQL通常很适合分析查询。有许多图形数据分析工具可以生成SQL查询,可视化结果,并允许分析人员探索数据(通过下钻,切片和切块等操作)。
|
||||
|
||||
表面上,一个数据仓库和一个关系OLTP数据库看起来很相似,因为它们都有一个SQL查询接口。然而,系统的内部看起来可能完全不同,因为它们针对非常不同的查询模式进行了优化。现在许多数据库供应商都将重点放在支持事务处理或分析工作负载上,而不是两者都支持。
|
||||
|
||||
@ -413,7 +422,7 @@ Teradata,Vertica,SAP HANA和ParAccel等数据仓库供应商通常使用昂
|
||||
|
||||
正如[第2章](ch2.md)所探讨的,根据应用程序的需要,在事务处理领域中使用了大量不同的数据模型。另一方面,在分析中,数据模型的多样性则少得多。许多数据仓库都以相当公式化的方式使用,被称为星型模式(也称为维度建模【55】)。
|
||||
|
||||
图3-9中的示例模式显示了可能在食品零售商处找到的数据仓库。在模式的中心是一个所谓的事实表(在这个例子中,它被称为`fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买的产品)。如果我们分析的是网站流量而不是零售量,则每行可能代表一个用户的页面浏览量或点击量。
|
||||
图3-9中的示例模式显示了可能在食品零售商处找到的数据仓库。在模式的中心是一个所谓的事实表(在这个例子中,它被称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买的产品)。如果我们分析的是网站流量而不是零售量,则每行可能代表一个用户的页面浏览量或点击量。
|
||||
|
||||
![](img/fig3-9.png)
|
||||
|
||||
@ -423,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` 表可以包括在每个商店提供哪些服务的细节,它是否具有店内面包房,方形镜头,商店第一次开幕的日期,最后一次改造的时间,离最近的高速公路的距离等等。
|
||||
|
||||
|
||||
|
||||
@ -439,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 分析人们是否更倾向于购买新鲜水果或糖果,这取决于一周中的哪一天**
|
||||
|
||||
@ -462,7 +471,7 @@ GROUP BY
|
||||
|
||||
在大多数OLTP数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库是相似的:整个文档通常存储为一个连续的字节序列。你可以在[图3-1](img/fig3-1.png)的CSV例子中看到这个。
|
||||
|
||||
为了处理像[例3-1]()这样的查询,您可能在`fact_sales.date_key` `fact_sales.product_sk`上有索引,它们告诉存储引擎在哪里查找特定日期或特定产品的所有销售情况。但是,面向行的存储引擎仍然需要将所有这些行(每个包含超过100个属性)从磁盘加载到内存中,解析它们,并过滤掉那些不符合要求的条件。这可能需要很长时间。
|
||||
为了处理像[例3-1]()这样的查询,您可能在 `fact_sales.date_key`, `fact_sales.product_sk`上有索引,它们告诉存储引擎在哪里查找特定日期或特定产品的所有销售情况。但是,面向行的存储引擎仍然需要将所有这些行(每个包含超过100个属性)从磁盘加载到内存中,解析它们,并过滤掉那些不符合要求的条件。这可能需要很长时间。
|
||||
|
||||
面向列的存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如[图3-10](img/fig3-10.png)所示。
|
||||
|
||||
@ -486,9 +495,9 @@ GROUP BY
|
||||
|
||||
**图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)底部所示。这可以使列的编码非常紧凑。
|
||||
|
||||
这些位图索引非常适合数据仓库中常见的各种查询。例如:
|
||||
|
||||
@ -496,13 +505,13 @@ GROUP BY
|
||||
WHERE product_sk IN(30,68,69)
|
||||
```
|
||||
|
||||
加载`product_sk = 30, product_sk = 68, product_sk = 69`的三个位图,并计算三个位图的按位或,这可以非常有效地完成。
|
||||
加载 `product_sk = 30` , `product_sk = 68` , `product_sk = 69` 的三个位图,并计算三个位图的按位或,这可以非常有效地完成。
|
||||
|
||||
```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】的概述。
|
||||
|
||||
@ -525,11 +534,11 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
|
||||
注意,每列独自排序是没有意义的,因为那样我们就不会知道列中的哪些项属于同一行。我们只能重建一行,因为我们知道一列中的第k项与另一列中的第k项属于同一行。
|
||||
|
||||
相反,即使按列存储数据,也需要一次对整行进行排序。数据库的管理员可以使用他们对常见查询的知识来选择表格应该被排序的列。例如,如果查询通常以日期范围为目标,例如上个月,则可以将`date_key`作为第一个排序键。然后,查询优化器只能扫描上个月的行,这比扫描所有行要快得多。
|
||||
相反,即使按列存储数据,也需要一次对整行进行排序。数据库的管理员可以使用他们对常见查询的知识来选择表格应该被排序的列。例如,如果查询通常以日期范围为目标,例如上个月,则可以将 `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)中的位图一样)可以将该列压缩到几千字节 —— 即使表中有数十亿行。
|
||||
|
||||
第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长时间的重复值。排序优先级下面的列以基本上随机的顺序出现,所以它们可能不会被压缩。但前几列排序仍然是一个整体。
|
||||
|
||||
@ -559,7 +568,7 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
|
||||
当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。数据库可以自动完成,但是这样的更新使得写入成本更高,这就是在OLTP数据库中不经常使用物化视图的原因。在读取繁重的数据仓库中,它们可能更有意义(不管它们是否实际上改善了读取性能取决于个别情况)。
|
||||
|
||||
物化视图的常见特例称为数据立方体或OLAP立方体【64】。它是按不同维度分组的聚合网格。[图3-12](img/fig3-12.png)显示了一个例子。
|
||||
物化视图的常见特例称为数据立方体或OLAP立方【64】。它是按不同维度分组的聚合网格。[图3-12](img/fig3-12.png)显示了一个例子。
|
||||
|
||||
![](img/fig3-12.png)
|
||||
|
||||
@ -567,7 +576,7 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
|
||||
想象一下,现在每个事实都只有两个维度表的外键——在[图3-12](img/fig-3-12.png)中,这些是日期和产品。您现在可以绘制一个二维表格,一个轴线上的日期和另一个轴上的产品。每个单元包含具有该日期 - 产品组合的所有事实的属性(例如,`net_price`)的聚集(例如,`SUM`)。然后,您可以沿着每行或每列应用相同的汇总,并获得一个维度减少的汇总(按产品的销售额,无论日期,还是按日期销售,无论产品如何)。
|
||||
|
||||
一般来说,事实往往有两个以上的维度。在图3-9中有五个维度:日期,产品,商店,促销和客户。要想象一个五维超立方体是什么样子是很困难的,但是原理是一样的:每个单元格都包含特定日期(产品 - 商店 - 促销 - 客户组合)的销售。这些值可以在每个维度上重复概括。
|
||||
一般来说,事实往往有两个以上的维度。在图3-9中有五个维度:日期,产品,商店,促销和客户。要想象一个五维超立方体是什么样子是很困难的,但是原理是一样的:每个单元格都包含特定日期(产品-商店-促销-客户)组合的销售。这些值可以在每个维度上重复概括。
|
||||
|
||||
物化数据立方体的优点是某些查询变得非常快,因为它们已经被有效地预先计算了。例如,如果您想知道每个商店的总销售额,则只需查看合适维度的总计,无需扫描数百万行。
|
||||
|
||||
@ -586,7 +595,7 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
|
||||
在OLTP方面,我们看到了来自两大主流学派的存储引擎:
|
||||
|
||||
* 日志结构学派,只允许附加到文件和删除过时的文件,但不会更新已经写入的文件。 Bitcask,SSTables,LSM-tree,LevelDB,Cassandra,HBase,Lucene等都属于这个组。
|
||||
* 日志结构学派,只允许附加到文件和删除过时的文件,但不会更新已经写入的文件。 Bitcask,SSTables,LSM树,LevelDB,Cassandra,HBase,Lucene等都属于这个组。
|
||||
* 就地更新学派,将磁盘视为一组可以覆盖的固定大小的页面。 B树是这种哲学的最大的例子,被用在所有主要的关系数据库中,还有许多非关系数据库。
|
||||
|
||||
日志结构的存储引擎是相对较新的发展。他们的主要想法是,他们系统地将随机访问写入顺序写入磁盘,由于硬盘驱动器和固态硬盘的性能特点,可以实现更高的写入吞吐量。在完成OLTP方面,我们通过一些更复杂的索引结构和为保留所有数据而优化的数据库做了一个简短的介绍。
|
||||
|
12
ch4.md
12
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库)。
|
||||
|
||||
@ -428,7 +428,7 @@ Web服务仅仅是通过网络进行API请求的一系列技术的最新版本
|
||||
|
||||
尽管有这样那样的问题,RPC不会消失。在本章提到的所有编码的基础上构建了各种RPC框架:例如,Thrift和Avro带有RPC支持,gRPC是使用Protocol Buffers的RPC实现,Finagle也使用Thrift,Rest.li使用JSON over HTTP。
|
||||
|
||||
这种新一代的RPC框架更加明确的是,远程请求与本地函数调用不同。例如,Finagle和Rest.li使用futures(promises)来封装可能失败的异步操作。`Futures`还可以简化需要并行发出多项服务的情况,并将其结果合并【45】。 gRPC支持流,其中一个调用不仅包括一个请求和一个响应,还包括一系列的请求和响应【46】。
|
||||
这种新一代的RPC框架更加明确的是,远程请求与本地函数调用不同。例如,Finagle和Rest.li 使用futures(promises)来封装可能失败的异步操作。`Futures`还可以简化需要并行发出多项服务的情况,并将其结果合并【45】。 gRPC支持流,其中一个调用不仅包括一个请求和一个响应,还包括一系列的请求和响应【46】。
|
||||
|
||||
其中一些框架还提供服务发现,即允许客户端找出在哪个IP地址和端口号上可以找到特定的服务。我们将在“[请求路由](ch6.md#请求路由)”中回到这个主题。
|
||||
|
||||
@ -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模型是单个进程中并发的编程模型。逻辑被封装在角色中,而不是直接处理线程(以及竞争条件,锁定和死锁的相关问题)。每个角色通常代表一个客户或实体,它可能有一些本地状态(不与其他任何角色共享),它通过发送和接收异步消息与其他角色通信。消息传送不保证:在某些错误情况下,消息将丢失。由于每个角色一次只能处理一条消息,因此不需要担心线程,每个角色可以由框架独立调度。
|
||||
|
||||
在分布式的行为者框架中,这个编程模型被用来跨越多个节点来扩展应用程序。不管发送方和接收方是在同一个节点上还是在不同的节点上,都使用相同的消息传递机制。如果它们在不同的节点上,则该消息被透明地编码成字节序列,通过网络发送,并在另一侧解码。
|
||||
|
||||
@ -489,7 +489,7 @@ actor模型是单个进程中并发的编程模型。逻辑被封装在角色中
|
||||
三个流行的分布式actor框架处理消息编码如下:
|
||||
|
||||
* 默认情况下,Akka使用Java的内置序列化,不提供前向或后向兼容性。 但是,你可以用类似缓冲区的东西替代它,从而获得滚动升级的能力【50】。
|
||||
* `Orleans`默认使用不支持滚动升级部署的自定义数据编码格式; 要部署新版本的应用程序,您需要设置一个新的群集,将流量从旧群集迁移到新群集,然后关闭旧群集【51,52】。 像Akka一样,可以使用自定义序列化插件。
|
||||
* Orleans 默认使用不支持滚动升级部署的自定义数据编码格式; 要部署新版本的应用程序,您需要设置一个新的群集,将流量从旧群集迁移到新群集,然后关闭旧群集【51,52】。 像Akka一样,可以使用自定义序列化插件。
|
||||
* 在Erlang OTP中,对记录模式进行更改是非常困难的(尽管系统具有许多为高可用性设计的功能)。 滚动升级是可能的,但需要仔细计划【53】。 一个新的实验性的`maps`数据类型(2014年在Erlang R17中引入的类似于JSON的结构)可能使得这个数据类型在未来更容易【54】。
|
||||
|
||||
|
||||
|
34
ch5.md
34
ch5.md
@ -596,9 +596,9 @@ PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志
|
||||
|
||||
在所有常见的Dynamo实现中,松散法定人数是可选的。在Riak中,它们默认是启用的,而在Cassandra和Voldemort中它们默认是禁用的【46,49,50】。
|
||||
|
||||
#### 多数据中心操作
|
||||
#### 运维多个数据中心
|
||||
|
||||
我们先前讨论了跨数据中心复制作为多主复制的用例(请参阅第162页的“[多重复制]()”)。无主复制还适用于多数据中心操作,因为它旨在容忍冲突的并发写入,网络中断和延迟尖峰。
|
||||
我们先前讨论了跨数据中心复制作为多主复制的用例(参阅“[多主复制](#多主复制)”)。无主复制还适用于多数据中心操作,因为它旨在容忍冲突的并发写入,网络中断和延迟尖峰。
|
||||
|
||||
Cassandra和Voldemort在正常的无主模型中实现了他们的多数据中心支持:副本的数量n包括所有数据中心的节点,在配置中,您可以指定每个数据中心中您想拥有的副本的数量。无论数据中心如何,每个来自客户端的写入都会发送到所有副本,但客户端通常只等待来自其本地数据中心内的法定节点的确认,从而不会受到跨数据中心链路延迟和中断的影响。对其他数据中心的高延迟写入通常被配置为异步发生,尽管配置有一定的灵活性【50,51】。
|
||||
|
||||
@ -606,19 +606,19 @@ Riak将客户端和数据库节点之间的所有通信保持在一个数据中
|
||||
|
||||
### 检测并发写入
|
||||
|
||||
Dynamo风格的数据库允许多个客户端同时写入相同的Key,这意味着即使使用严格的法定人数也会发生冲突。这种情况与多领导者复制相似(请参阅第171页的“处理写冲突”),但在Dynamo样式的数据库中,在**读修复**或**带提示的接力**期间也可能会产生冲突。
|
||||
Dynamo风格的数据库允许多个客户端同时写入相同的Key,这意味着即使使用严格的法定人数也会发生冲突。这种情况与多领导者复制相似(参阅“[处理写入冲突](#处理写入冲突)”),但在Dynamo样式的数据库中,在**读修复**或**带提示的接力**期间也可能会产生冲突。
|
||||
|
||||
问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,图5-12显示了两个客户机A和B同时写入三节点数据存储区中的键X:
|
||||
问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,[图5-12](img/fig5-12.png)显示了两个客户机A和B同时写入三节点数据存储区中的键X:
|
||||
|
||||
* 节点1接收来自A的写入,但由于暂时中断,从不接收来自B的写入。
|
||||
* 节点2首先接收来自A的写入,然后接收来自B的写入。
|
||||
* 节点3首先接收来自B的写入,然后从A写入。
|
||||
* 节点 1 接收来自 A 的写入,但由于暂时中断,从不接收来自 B 的写入。
|
||||
* 节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入。
|
||||
* 节点 3 首先接收来自 B 的写入,然后从 A 写入。
|
||||
|
||||
![](img/fig5-12.png)
|
||||
|
||||
**图5-12 并发写入Dynamo风格的数据存储:没有明确定义的顺序。**
|
||||
|
||||
如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如[图5-12](img/fig5-12.png)中的最终获取请求所示:节点2认为X的最终值是B,而其他节点认为值是A.
|
||||
如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如[图5-12](img/fig5-12.png)中的最终获取请求所示:节点2认为 X 的最终值是 B,而其他节点认为值是 A 。
|
||||
|
||||
为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你(应用程序开发人员)需要知道很多有关数据库冲突处理的内部信息。
|
||||
|
||||
@ -632,7 +632,7 @@ Dynamo风格的数据库允许多个客户端同时写入相同的Key,这意
|
||||
|
||||
即使写入没有自然的排序,我们也可以强制任意排序。例如,可以为每个写入附加一个时间戳,挑选最**“最近”**的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为**最后写入为准(LWW, last write wins)**,是Cassandra 【53】唯一支持的冲突解决方法,也是Riak 【35】中的一个可选特征。
|
||||
|
||||
LWW实现了最终收敛的目标,但以**持久性**为代价:如果同一个Key有多个并发写入,即使它们都被报告为客户端成功(因为它们被写入w个副本),其中一个写道会生存下来,其他的将被无声丢弃。此外,LWW甚至可能会删除不是并发的写入,我们将在的“[有序事件的时间戳](ch8.md#有序事件的时间戳)”中讨论。
|
||||
LWW实现了最终收敛的目标,但以**持久性**为代价:如果同一个Key有多个并发写入,即使它们都被报告为客户端成功(因为它们被写入 w 个副本),其中一个写道会生存下来,其他的将被无声丢弃。此外,LWW甚至可能会删除不是并发的写入,我们将在的“[有序事件的时间戳](ch8.md#有序事件的时间戳)”中讨论。
|
||||
|
||||
有一些情况,如缓存,其中丢失的写入可能是可以接受的。如果丢失数据不可接受,LWW是解决冲突的一个很烂的选择。
|
||||
|
||||
@ -661,17 +661,17 @@ LWW实现了最终收敛的目标,但以**持久性**为代价:如果同一
|
||||
|
||||
|
||||
|
||||
#### 捕捉"此前发生"关系
|
||||
#### 捕获"此前发生"关系
|
||||
|
||||
来看一个算法,它确定两个操作是否为并发的,还是一个在另一个之前。为了简单起见,我们从一个只有一个副本的数据库开始。一旦我们已经制定了如何在单个副本上完成这项工作,我们可以将该方法概括为具有多个副本的无领导者数据库。
|
||||
|
||||
[图5-13]()显示了两个客户端同时向同一购物车添加项目。 (如果这样的例子让你觉得太麻烦了,那么可以想象,两个空中交通管制员同时把飞机添加到他们正在跟踪的区域)最初,购物车是空的。在它们之间,客户端向数据库发出五次写入:
|
||||
|
||||
1. 客户端1将牛奶加入购物车。这是该键的第一次写入,服务器成功存储了它并为其分配版本号1,最后将值与版本号一起回送给客户端。
|
||||
2. 客户端2将鸡蛋加入购物车,不知道客户端1同时添加了牛奶(客户端2认为它的鸡蛋是购物车中的唯一物品)。服务器为此写入分配版本号2,并将鸡蛋和牛奶存储为两个单独的值。然后它将这两个值**都**反回给客户端2,并附上版本号2。
|
||||
3. 客户端1不知道客户端2的写入,想要将面粉加入购物车,因此认为当前的购物车内容应该是 [牛奶,面粉]。它将此值与服务器先前向客户端1提供的版本号1一起发送到服务器。服务器可以从版本号中知道[牛奶,面粉]的写入取代了[牛奶]的先前值,但与[鸡蛋]的值是**并发**的。因此,服务器将版本3分配给[牛奶,面粉],覆盖版本1值[牛奶],但保留版本2的值[蛋],并将所有的值返回给客户端1。
|
||||
4. 同时,客户端2想要加入火腿,不知道客端户1刚刚加了面粉。客户端2在最后一个响应中从服务器收到了两个值[牛奶]和[蛋],所以客户端2现在合并这些值,并添加火腿形成一个新的值,[鸡蛋,牛奶,火腿]。它将这个值发送到服务器,带着之前的版本号2。服务器检测到新值会覆盖版本2 [eggs],但新值也会与v3 [牛奶,面粉]**并发**,所以剩下的两个值是v3 [milk,flour],和v4:[鸡蛋,牛奶,火腿]。
|
||||
5. 最后,客户端1想要加培根。它以前在v3中从服务器接收[牛奶,面粉]和[鸡蛋],所以它合并这些,添加培根,并将最终值[牛奶,面粉,鸡蛋,培根]连同版本号v3发往服务器。这会覆盖v3[牛奶,面粉](请注意[鸡蛋]已经在最后一步被覆盖),但与v4[鸡蛋,牛奶,火腿]并发,所以服务器保留这两个并发值。
|
||||
1. 客户端 1 将牛奶加入购物车。这是该键的第一次写入,服务器成功存储了它并为其分配版本号1,最后将值与版本号一起回送给客户端。
|
||||
2. 客户端 2 将鸡蛋加入购物车,不知道客户端 1 同时添加了牛奶(客户端 2 认为它的鸡蛋是购物车中的唯一物品)。服务器为此写入分配版本号 2,并将鸡蛋和牛奶存储为两个单独的值。然后它将这两个值**都**反回给客户端 2 ,并附上版本号 2 。
|
||||
3. 客户端 1 不知道客户端 2 的写入,想要将面粉加入购物车,因此认为当前的购物车内容应该是 [牛奶,面粉]。它将此值与服务器先前向客户端 1 提供的版本号 1 一起发送到服务器。服务器可以从版本号中知道[牛奶,面粉]的写入取代了[牛奶]的先前值,但与[鸡蛋]的值是**并发**的。因此,服务器将版本 3 分配给[牛奶,面粉],覆盖版本1值[牛奶],但保留版本 2 的值[蛋],并将所有的值返回给客户端 1 。
|
||||
4. 同时,客户端 2 想要加入火腿,不知道客端户 1 刚刚加了面粉。客户端 2 在最后一个响应中从服务器收到了两个值[牛奶]和[蛋],所以客户端 2 现在合并这些值,并添加火腿形成一个新的值,[鸡蛋,牛奶,火腿]。它将这个值发送到服务器,带着之前的版本号 2 。服务器检测到新值会覆盖版本 2 [eggs],但新值也会与版本 3 [牛奶,面粉]**并发**,所以剩下的两个值是v3 [milk,flour],和v4:[鸡蛋,牛奶,火腿]。
|
||||
5. 最后,客户端 1 想要加培根。它以前在v3中从服务器接收[牛奶,面粉]和[鸡蛋],所以它合并这些,添加培根,并将最终值[牛奶,面粉,鸡蛋,培根]连同版本号v3发往服务器。这会覆盖v3[牛奶,面粉](请注意[鸡蛋]已经在最后一步被覆盖),但与v4[鸡蛋,牛奶,火腿]并发,所以服务器保留这两个并发值。
|
||||
|
||||
![](img/fig5-13.png)
|
||||
|
||||
@ -690,13 +690,13 @@ LWW实现了最终收敛的目标,但以**持久性**为代价:如果同一
|
||||
* 客户端写入键时,必须包含之前读取的版本号,并且必须将之前读取的所有值合并在一起。 (来自写入请求的响应可以像读取一样,返回所有当前值,这使得我们可以像购物车示例那样连接多个写入。)
|
||||
* 当服务器接收到具有特定版本号的写入时,它可以覆盖该版本号或更低版本的所有值(因为它知道它们已经被合并到新的值中),但是它必须保持所有值更高版本号(因为这些值与传入的写入同时发生)。
|
||||
|
||||
当一个写入包含前一次读取的版本号时,它会告诉我们写入的是哪一种状态。如果在不包含版本号的情况下进行写操作,则与所有其他写操作并发,因此它不会覆盖任何内容 - 只会在随后的读取中作为其中一个值返回。
|
||||
当一个写入包含前一次读取的版本号时,它会告诉我们写入的是哪一种状态。如果在不包含版本号的情况下进行写操作,则与所有其他写操作并发,因此它不会覆盖任何内容 —— 只会在随后的读取中作为其中一个值返回。
|
||||
|
||||
#### 合并同时写入的值
|
||||
|
||||
这种算法可以确保没有数据被无声地丢弃,但不幸的是,客户端需要做一些额外的工作:如果多个操作并发发生,则客户端必须通过合并并发写入的值来擦屁股。 Riak称这些并发值**兄弟(siblings)**。
|
||||
|
||||
合并兄弟值,本质上是与多领导者复制中的冲突解决相同的问题,我们先前讨论过(请参阅第171页的“[处理写冲突](#处理写冲突)”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中做更聪明的事情。
|
||||
合并兄弟值,本质上是与多领导者复制中的冲突解决相同的问题,我们先前讨论过(参阅“[处理写入冲突](#处理写入冲突)”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中做更聪明的事情。
|
||||
|
||||
以购物车为例,一种合理的合并兄弟方法就是集合求并。在[图5-14](img/fig5-14.png)中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋出现在两个,即使他们每个只写一次。合并的价值可能是像[牛奶,面粉,鸡蛋,培根,火腿],没有重复。
|
||||
|
||||
|
70
ch7.md
70
ch7.md
@ -82,7 +82,7 @@ ACID一致性的概念是,**对数据的一组特定陈述必须始终成立**
|
||||
|
||||
但是,一致性的这种概念取决于应用程序对不变量的观念,应用程序负责正确定义它的事务,并保持一致性。这并不是数据库可以保证的事情:如果你写入违反不变量的脏数据,数据库也无法阻止你。 (一些特定类型的不变量可以由数据库检查,例如外键约束或唯一约束,但是一般来说,是应用程序来定义什么样的数据是有效的,什么样是无效的。—— 数据库只管存储。)
|
||||
|
||||
原子性,隔离性和持久性是数据库的属性,而一致性(在ACID意义上)是应用程序的属性。应用程序可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母C不属于ACID[^i]。
|
||||
原子性,隔离性和持久性是数据库的属性,而一致性(在ACID意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母C不属于ACID[^i]。
|
||||
|
||||
[^i]: 乔·海勒斯坦(Joe Hellerstein)指出,在论Härder与Reuter的论文中,“ACID中的C”是被“扔进去凑缩写单词的”【7】,而且那时候大家都不怎么在乎一致性。
|
||||
|
||||
@ -90,7 +90,7 @@ ACID一致性的概念是,**对数据的一组特定陈述必须始终成立**
|
||||
|
||||
大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分,这是没有问题的,但是如果它们访问相同的数据库记录,则可能会遇到**并发**问题(**竞争条件(race conditions)**)。
|
||||
|
||||
[图7-1](img/fig7-1.png)是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库中没有自增操作)每个客户端需要读取计数器的当前值,加1,再回写新值。[图7-1](img/fig7-1.png)中,因为发生了两次增长,计数器应该从42增至44;但由于竞态条件,实际上只增至43。
|
||||
[图7-1](img/fig7-1.png)是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库中没有自增操作)每个客户端需要读取计数器的当前值,加 1 ,再回写新值。[图7-1](img/fig7-1.png) 中,因为发生了两次增长,计数器应该从42增至44;但由于竞态条件,实际上只增至 43 。
|
||||
|
||||
ACID意义上的隔离性意味着,**同时执行的事务是相互隔离的**:它们不能相互冒犯。传统的数据库教科书将隔离性形式化为**可序列化(Serializability)**,这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的【10】。
|
||||
|
||||
@ -131,7 +131,7 @@ ACID意义上的隔离性意味着,**同时执行的事务是相互隔离的**
|
||||
|
||||
***原子性***
|
||||
|
||||
如果在一系列写操作的中途发生错误,则应中止事务处理,并丢弃当前事务的所有写入。换句话说,数据库免去了用户对部分失败的担忧——通过提供“**宁为玉碎,不为瓦全**(all-or-nothing)”的保证。
|
||||
如果在一系列写操作的中途发生错误,则应中止事务处理,并丢弃当前事务的所有写入。换句话说,数据库免去了用户对部分失败的担忧——通过提供“**宁为玉碎,不为瓦全(all-or-nothing)**”的保证。
|
||||
|
||||
***隔离性***
|
||||
|
||||
@ -145,7 +145,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
但如果邮件太多,你可能会觉得这个查询太慢,并决定用单独的字段存储未读邮件的数量(一种反规范化)。现在每当一个新消息写入时,必须也增长未读计数器,每当一个消息被标记为已读时,也必须减少未读计数器。
|
||||
|
||||
在[图7-2](img/fig7-2.png)中,用户2遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生[^ii]。隔离性可以避免这个问题:通过确保用户2要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。
|
||||
在[图7-2](img/fig7-2.png)中,用户2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生[^ii]。隔离性可以避免这个问题:通过确保用户2 要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。
|
||||
|
||||
[^ii]: 可以说邮件应用中的错误计数器并不是什么特别重要的问题。但换种方式来看,你可以把未读计数器换成客户账户余额,把邮件收发看成支付交易。
|
||||
|
||||
@ -159,7 +159,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
**图7-3 原子性确保发生错误时,事务先前的任何写入都会被撤消,以避免状态不一致**
|
||||
|
||||
多对象事务需要某种方式来确定哪些读写操作属于同一个事务。在关系型数据库中,通常基于客户端与数据库服务器的TCP连接:在任何特定连接上,`BEGIN TRANSACTION`和`COMMIT`语句之间的所有内容,被认为是同一事务的一部分.[^iii]
|
||||
多对象事务需要某种方式来确定哪些读写操作属于同一个事务。在关系型数据库中,通常基于客户端与数据库服务器的TCP连接:在任何特定连接上,`BEGIN TRANSACTION` 和 `COMMIT` 语句之间的所有内容,被认为是同一事务的一部分.[^iii]
|
||||
|
||||
[^iii]: 这并不完美。如果TCP连接中断,则事务必须中止。如果中断发生在客户端请求提交之后,但在服务器确认提交发生之前,客户端并不知道事务是否已提交。为了解决这个问题,事务管理器可以通过一个唯一事务标识符来对操作进行分组,这个标识符并未绑定到特定TCP连接。后续再“[数据库端到端的争论](ch12.md#数据库端到端的争论)”一节将回到这个主题。
|
||||
|
||||
@ -167,30 +167,30 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
#### 单对象写入
|
||||
|
||||
当单个对象发生改变时,原子性和隔离也是适用的。例如,假设您正在向数据库写入一个20 KB的JSON文档:
|
||||
当单个对象发生改变时,原子性和隔离也是适用的。例如,假设您正在向数据库写入一个 20 KB的 JSON文档:
|
||||
|
||||
- 如果在发送第一个10 KB之后网络连接中断,数据库是否存储了不可解析的10 KB JSON片段?
|
||||
- 如果在发送第一个10 KB之后网络连接中断,数据库是否存储了不可解析的10KB JSON片段?
|
||||
- 如果在数据库正在覆盖磁盘上的前一个值的过程中电源发生故障,是否最终将新旧值拼接在一起?
|
||||
- 如果另一个客户端在写入过程中读取该文档,是否会看到部分更新的值?
|
||||
|
||||
这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(请参阅第82页的“[使B树可靠]()”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) )。
|
||||
这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(参阅“[使B树可靠]()”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) )。
|
||||
|
||||
一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要像[图7-1](img/fig7-1.png)那样的读取-修改-写入序列了。同样流行的是**[比较和设置(CAS, compare-and-set)](#比较并设置(CAS))**操作,当值没有并发被其他人修改过时,才允许执行写操作。
|
||||
一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要像 [图7-1](img/fig7-1.png) 那样的读取-修改-写入序列了。同样流行的是**[比较和设置(CAS, compare-and-set)](#比较并设置(CAS))**操作,当值没有并发被其他人修改过时,才允许执行写操作。
|
||||
|
||||
这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新(参阅“[防止丢失更新](#防止丢失更新)”)。但是,它们不是通常意义上的事务。CAS以及其他单一对象操作被称为“轻量级事务”,甚至出于营销目的被称为“ACID”【20,21,22】,但是这个术语是误导性的。事务通常被理解为,**将多个对象上的多个操作合并为一个执行单元的机制**。[^iv]
|
||||
这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新(参阅“[防止丢失更新](#防止丢失更新)”)。但它们不是通常意义上的事务。CAS以及其他单一对象操作被称为“轻量级事务”,甚至出于营销目的被称为“ACID”【20,21,22】,但是这个术语是误导性的。事务通常被理解为,**将多个对象上的多个操作合并为一个执行单元的机制**。[^iv]
|
||||
|
||||
[^iv]: 严格地说,**原子自增(atomic increment)**这个术语在多线程编程的意义上使用了原子这个词。 在ACID的情况下,它实际上应该被称为**孤立(isolated)**的或**可序列化(serializable)**的增量。 但这就太吹毛求疵了。
|
||||
|
||||
#### 多对象事务的需求
|
||||
|
||||
许多分布式数据存储已经放弃了多对象事务,因为多对象事务很难跨分区实现,而且在需要高可用性或高性能的情况下,它们可能会碍事。但说到底,在分布式数据库中实现事务,并没有什么根本性的障碍。[第9章](ch9.md)将讨论分布式事务的实现。
|
||||
许多分布式数据存储已经放弃了多对象事务,因为多对象事务很难跨分区实现,而且在需要高可用性或高性能的情况下,它们可能会碍事。但说到底,在分布式数据库中实现事务,并没有什么根本性的障碍。[第9章](ch9.md) 将讨论分布式事务的实现。
|
||||
|
||||
但是我们是否需要多对象事务?**是否有可能只用键值数据模型和单对象操作来实现任何应用程序?**
|
||||
|
||||
有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象:
|
||||
|
||||
* 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。 (类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边)。多对象事务使你确信这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的,最新的,不然数据就没有意义。
|
||||
* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(参阅“[关系型数据库与文档数据库在今日的对比](ch2.md#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如[图7-2](img/fig7-2.png)所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。
|
||||
* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(参阅“[关系型数据库与文档数据库在今日的对比](ch2.md#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如 [图7-2](img/fig7-2.png) 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。
|
||||
* 在具有二级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。
|
||||
|
||||
这些应用仍然可以在没有事务的情况下实现。然而,**没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题**。我们将在“[弱隔离级别](#弱隔离级别)”中讨论这些问题,并在[第12章]()中探讨其他方法。
|
||||
@ -227,7 +227,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
实际上不幸的是:隔离并没有那么简单。**可序列化**会有性能损失,许多数据库不愿意支付这个代价【8】。因此,系统通常使用较弱的隔离级别来防止一部分,而不是全部的并发问题。这些隔离级别难以理解,并且会导致微妙的错误,但是它们仍然在实践中被使用【23】。
|
||||
|
||||
并发性错误导致的并发性错误不仅仅是一个理论问题。他们造成了很多的资金损失【24,25】,耗费了财务审计人员的调查【26】,并导致客户数据被破坏【27】。关于这类问题的一个流行的评论是“如果你正在处理财务数据,请使用ACID数据库!” ——但是这一点没有提到。即使是很多流行的关系型数据库系统(通常被认为是“ACID”)也使用弱隔离级别,所以它们也不一定能防止这些错误的发生。
|
||||
并发性错误导致的并发性错误不仅仅是一个理论问题。它们造成了很多的资金损失【24,25】,耗费了财务审计人员的调查【26】,并导致客户数据被破坏【27】。关于这类问题的一个流行的评论是“如果你正在处理财务数据,请使用ACID数据库!” —— 但是这一点没有提到。即使是很多流行的关系型数据库系统(通常被认为是“ACID”)也使用弱隔离级别,所以它们也不一定能防止这些错误的发生。
|
||||
|
||||
比起盲目地依赖工具,我们应该对存在的并发问题的种类,以及如何防止这些问题有深入的理解。然后就可以使用我们所掌握的工具来构建可靠和正确的应用程序。
|
||||
|
||||
@ -248,7 +248,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
设想一个事务已经将一些数据写入数据库,但事务还没有提交或中止。另一个事务可以看到未提交的数据吗?如果是的话,那就叫做**脏读(dirty reads)**【2】。
|
||||
|
||||
在**读已提交**隔离级别运行的事务必须防止脏读。这意味着事务的任何写入操作只有在该事务提交时才能被其他人看到(然后所有的写入操作都会立即变得可见)。如[图7-4]()所示,用户1设置了`x = 3`,但用户2的`get x`仍旧返回旧值2,而用户1尚未提交。
|
||||
在**读已提交**隔离级别运行的事务必须防止脏读。这意味着事务的任何写入操作只有在该事务提交时才能被其他人看到(然后所有的写入操作都会立即变得可见)。如[图7-4]()所示,用户1 设置了`x = 3`,但用户2 的 `get x `仍旧返回旧值2 ,而用户1 尚未提交。
|
||||
|
||||
![](img/fig7-4.png)
|
||||
|
||||
@ -267,7 +267,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
通过防止脏写,这个隔离级别避免了一些并发问题:
|
||||
|
||||
- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑[图7-5](img/fig7-5.png),[图7-5](img/fig7-5.png)以一个二手车销售网站为例,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在[图7-5](img/fig7-5.png)的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样这样的事故。
|
||||
- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图7-5](img/fig7-5.png),[图7-5](img/fig7-5.png) 以一个二手车销售网站为例,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在[图7-5](img/fig7-5.png)的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样这样的事故。
|
||||
- 但是,提交读取并不能防止[图7-1]()中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在“[防止更新丢失](#防止丢失更新)”中将讨论如何使这种计数器增量安全。
|
||||
|
||||
![](img/fig7-5.png)
|
||||
@ -336,11 +336,11 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
**图7-7 使用多版本对象实现快照隔离**
|
||||
|
||||
表中的每一行都有一个`created_by`字段,其中包含将该行插入到表中的的事务ID。此外,每行都有一个`deleted_by`字段,最初是空的。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过将`deleted_by`字段设置为请求删除的事务的ID来标记为删除。在稍后的时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会将所有带有删除标记的行移除,并释放其空间。[^译注ii]
|
||||
表中的每一行都有一个 `created_by` 字段,其中包含将该行插入到表中的的事务ID。此外,每行都有一个 `deleted_by` 字段,最初是空的。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过将 `deleted_by` 字段设置为请求删除的事务的ID来标记为删除。在稍后的时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会将所有带有删除标记的行移除,并释放其空间。[^译注ii]
|
||||
|
||||
[^译注ii]: 在PostgreSQL中,`created_by`实际名称为`xmin`,`deleted_by`实际名称为`xmax`
|
||||
[^译注ii]: 在PostgreSQL中,`created_by` 的实际名称为`xmin`,`deleted_by` 的实际名称为`xmax`
|
||||
|
||||
`UPDATE`操作在内部翻译为`DELETE`和`INSERT`。例如,在[图7-7]()中,事务13从账户2中扣除100美元,将余额从500美元改为400美元。实际上包含两条账户2的记录:余额为\$500的行被标记为**被事务13删除**,余额为\$400的行**由事务13创建**。
|
||||
`UPDATE` 操作在内部翻译为 `DELETE` 和 `INSERT` 。例如,在[图7-7]()中,事务13 从账户2 中扣除100美元,将余额从500美元改为400美元。实际上包含两条账户2 的记录:余额为 \$500 的行被标记为**被事务13删除**,余额为 \$400 的行**由事务13创建**。
|
||||
|
||||
#### 观察一致性快照的可见性规则
|
||||
|
||||
@ -351,7 +351,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
3. 由具有较晚事务ID(即,在当前事务开始之后开始的)的事务所做的任何写入都被忽略,而不管这些事务是否已经提交。
|
||||
4. 所有其他写入,对应用都是可见的。
|
||||
|
||||
这些规则适用于创建和删除对象。在[图7-7]()中,当事务12从账户2读取时,它会看到\$500的余额,因为\$500余额的删除是由事务13完成的(根据规则3,事务12看不到事务13执行的删除),且400美元记录的创建也是不可见的(按照相同的规则)。
|
||||
这些规则适用于创建和删除对象。在[图7-7]()中,当事务12 从账户2 读取时,它会看到 \$500 的余额,因为 \$500 余额的删除是由事务13 完成的(根据规则3,事务12 看不到事务13 执行的删除),且400美元记录的创建也是不可见的(按照相同的规则)。
|
||||
|
||||
换句话说,如果以下两个条件都成立,则可见一个对象:
|
||||
|
||||
@ -439,7 +439,7 @@ COMMIT;
|
||||
|
||||
这种方法的一个优点是,数据库可以结合快照隔离高效地执行此检查。事实上,PostgreSQL的可重复读,Oracle的可串行化和SQL Server的快照隔离级别,都会自动检测到丢失更新,并中止惹麻烦的事务。但是,MySQL/InnoDB的可重复读并不会检测**丢失更新**【23】。一些作者【28,30】认为,数据库必须能防止丢失更新才称得上是提供了**快照隔离**,所以在这个定义下,MySQL下不提供快照隔离。
|
||||
|
||||
丢失更新检测是一个很好的功能,因为它不需要应用代码使用任何特殊的数据库功能,你可能会忘记使用锁或原子操作,从而引入一个错误;但丢失更新的检测是自动发生的,因此不太容易出错。
|
||||
丢失更新检测是一个很好的功能,因为它不需要应用代码使用任何特殊的数据库功能,你可能会忘记使用锁或原子操作,从而引入错误;但丢失更新的检测是自动发生的,因此不太容易出错。
|
||||
|
||||
#### 比较并设置(CAS)
|
||||
|
||||
@ -626,7 +626,7 @@ COMMIT;
|
||||
|
||||
在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。
|
||||
|
||||
出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如[图7-9](img/fig7-9.png)所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。
|
||||
出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如[图7-9](img/fig7-9.png) 所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。
|
||||
|
||||
![](img/fig7-9.png)
|
||||
|
||||
@ -673,7 +673,7 @@ VoltDB还使用存储过程进行复制:但不是将事务的写入结果从
|
||||
|
||||
大约30年来,在数据库中只有一种广泛使用的序列化算法:**两阶段锁定(2PL,two-phase locking)**[^xi]
|
||||
|
||||
[^xi]: 有时也称为严格两阶段锁定(SS2PL, strict two-phas locking),以便和其他2PL变体区分。
|
||||
[^xi]: 有时也称为**严格两阶段锁定(SS2PL, strict two-phas locking)**,以便和其他2PL变体区分。
|
||||
|
||||
> #### 2PL不是2PC
|
||||
>
|
||||
@ -692,8 +692,6 @@ VoltDB还使用存储过程进行复制:但不是将事务的写入结果从
|
||||
|
||||
2PL用于MySQL(InnoDB)和SQL Server中的可序列化隔离级别,以及DB2中的可重复读隔离级别【23,36】。
|
||||
|
||||
[^xi]: 有时被称为强有力的严格的两阶段锁定(SS2PL),以区别于2PL的其他变种。
|
||||
|
||||
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于**共享模式(shared mode)**或**独占模式(exclusive mode)**。锁使用如下:
|
||||
|
||||
- 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
|
||||
@ -719,7 +717,7 @@ VoltDB还使用存储过程进行复制:但不是将事务的写入结果从
|
||||
|
||||
在前面关于锁的描述中,我们掩盖了一个微妙而重要的细节。在“[导致写入偏差的幻读](#导致写入偏差的幻读)”中,我们讨论了**幻读(phantoms)**的问题。即一个事务改变另一个事务的搜索查询的结果。具有可序列化隔离级别的数据库必须防止**幻读**。
|
||||
|
||||
在会议室预订的例子中,这意味着如果一个事务在某个时间窗口内搜索了一个房间的现有预订(见例7-2),则另一个事务不能同时插入或更新同一时间窗口与同一房间的另一个预订 (可以同时插入其他房间的预订,或在不影响另一个预定的条件下预定同一房间的其他时间段)。
|
||||
在会议室预订的例子中,这意味着如果一个事务在某个时间窗口内搜索了一个房间的现有预订(见[例7-2]()),则另一个事务不能同时插入或更新同一时间窗口与同一房间的另一个预订 (可以同时插入其他房间的预订,或在不影响另一个预定的条件下预定同一房间的其他时间段)。
|
||||
|
||||
如何实现这一点?从概念上讲,我们需要一个**谓词锁(predicate lock)**【3】。它类似于前面描述的共享/排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象,如:
|
||||
|
||||
@ -732,7 +730,7 @@ WHERE room_id = 123 AND
|
||||
|
||||
谓词锁限制访问,如下所示:
|
||||
|
||||
- 如果事务A想要读取匹配某些条件的对象,就像在这个`SELECT`查询中那样,它必须获取查询条件上的**共享谓词锁(shared-mode predicate lock)**。如果另一个事务B持有任何满足这一查询条件对象的排它锁,那么A必须等到B释放它的锁之后才允许进行查询。
|
||||
- 如果事务A想要读取匹配某些条件的对象,就像在这个 `SELECT` 查询中那样,它必须获取查询条件上的**共享谓词锁(shared-mode predicate lock)**。如果另一个事务B持有任何满足这一查询条件对象的排它锁,那么A必须等到B释放它的锁之后才允许进行查询。
|
||||
- 如果事务A想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务B持有匹配的谓词锁,那么A必须等到B已经提交或中止后才能继续。
|
||||
|
||||
这里的关键思想是,谓词锁甚至适用于数据库中尚不存在,但将来可能会添加的对象(幻象)。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写入偏差和其他竞争条件,因此其隔离实现了可串行化。
|
||||
@ -743,7 +741,7 @@ WHERE room_id = 123 AND
|
||||
|
||||
通过使谓词匹配到一个更大的集合来简化谓词锁是安全的。例如,如果你有在中午和下午1点之间预订123号房间的谓词锁,则锁定123号房间的所有时间段,或者锁定12:00~13:00时间段的所有房间(不只是123号房间)是一个安全的近似,因为任何满足原始谓词的写入也一定会满足这种更松散的近似。
|
||||
|
||||
在房间预订数据库中,您可能会在`room_id`列上有一个索引,并且/或者在`start_time`和`end_time`上有索引(否则前面的查询在大型数据库上的速度会非常慢):
|
||||
在房间预订数据库中,您可能会在`room_id`列上有一个索引,并且/或者在`start_time` 和 `end_time`上有索引(否则前面的查询在大型数据库上的速度会非常慢):
|
||||
|
||||
- 假设您的索引位于`room_id`上,并且数据库使用此索引查找123号房间的现有预订。现在数据库可以简单地将共享锁附加到这个索引项上,指示事务已搜索123号房间用于预订。
|
||||
- 或者,如果数据库使用基于时间的索引来查找现有预订,那么它可以将共享锁附加到该索引中的一系列值,指示事务已经将12:00~13:00时间段标记为用于预定。
|
||||
@ -762,7 +760,7 @@ WHERE room_id = 123 AND
|
||||
|
||||
也许不是:一个称为**可序列化快照隔离(SSI, serializable snapshot isolation)**的算法是非常有前途的。它提供了完整的可序列化隔离级别,但与快照隔离相比只有只有很小的性能损失。 SSI是相当新的:它在2008年首次被描述【40】,并且是Michael Cahill的博士论文【51】的主题。
|
||||
|
||||
今天,SSI既用于单节点数据库(PostgreSQL9.1以后的可序列化隔离级别)和分布式数据库(FoundationDB使用类似的算法)。由于SSI与其他并发控制机制相比还很年轻,还处于在实践中证明自己表现的阶段。但它有可能因为足够快而在未来成为新的默认选项。
|
||||
今天,SSI既用于单节点数据库(PostgreSQL9.1 以后的可序列化隔离级别)和分布式数据库(FoundationDB使用类似的算法)。由于SSI与其他并发控制机制相比还很年轻,还处于在实践中证明自己表现的阶段。但它有可能因为足够快而在未来成为新的默认选项。
|
||||
|
||||
#### 悲观与乐观的并发控制
|
||||
|
||||
@ -793,7 +791,7 @@ WHERE room_id = 123 AND
|
||||
|
||||
#### 检测旧MVCC读取
|
||||
|
||||
回想一下,快照隔离通常是通过多版本并发控制(MVCC;见[图7-10]())来实现的。当一个事务从MVCC数据库中的一致快照读时,它将忽略取快照时尚未提交的任何其他事务所做的写入。在[图7-10]()中,事务43认为Alice的`on_call = true`,因为事务42(修改Alice的待命状态)未被提交。然而,在事务43想要提交时,事务42已经提交。这意味着在读一致性快照时被忽略的写入已经生效,事务43的前提不再为真。
|
||||
回想一下,快照隔离通常是通过多版本并发控制(MVCC;见[图7-10]())来实现的。当一个事务从MVCC数据库中的一致快照读时,它将忽略取快照时尚未提交的任何其他事务所做的写入。在[图7-10]()中,事务43 认为Alice的 `on_call = true` ,因为事务42(修改Alice的待命状态)未被提交。然而,在事务43想要提交时,事务42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效,事务43 的前提不再为真。
|
||||
|
||||
![](img/fig7-10.png)
|
||||
|
||||
@ -801,23 +799,23 @@ WHERE room_id = 123 AND
|
||||
|
||||
为了防止这种异常,数据库需要跟踪一个事务由于MVCC可见性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样,事务必须中止。
|
||||
|
||||
为什么要等到提交?当检测到陈旧的读取时,为什么不立即中止事务43?因为如果事务43是只读事务,则不需要中止,因为没有写入偏差的风险。当事务43进行读取时,数据库还不知道事务是否要稍后执行写操作。此外,事务42可能在事务43被提交的时候中止或者可能仍然未被提交,因此读取可能终究不是陈旧的。通过避免不必要的中止,SSI保留快照隔离对从一致快照中长时间运行的读取的支持。
|
||||
为什么要等到提交?当检测到陈旧的读取时,为什么不立即中止事务43 ?因为如果事务43 是只读事务,则不需要中止,因为没有写入偏差的风险。当事务43 进行读取时,数据库还不知道事务是否要稍后执行写操作。此外,事务42 可能在事务43 被提交的时候中止或者可能仍然未被提交,因此读取可能终究不是陈旧的。通过避免不必要的中止,SSI 保留快照隔离对从一致快照中长时间运行的读取的支持。
|
||||
|
||||
#### 检测影响之前读取的写入
|
||||
|
||||
第二种情况要考虑的是另一个事务在读取数据之后修改数据。这种情况如图7-11所示。
|
||||
第二种情况要考虑的是另一个事务在读取数据之后修改数据。这种情况如[图7-11](img/fig7-11.png)所示。
|
||||
|
||||
![](img/fig7-11.png)
|
||||
|
||||
**图7-11 在可序列化快照隔离中,检测一个事务何时修改另一个事务的读取。**
|
||||
|
||||
在两阶段锁定的上下文中,我们讨论了[索引范围锁]()(请参阅“[索引范围锁]()”),它允许数据库锁定与某个搜索查询匹配的所有行的访问权,例如`WHERE shift_id = 1234`。可以在这里使用类似的技术,除了SSI锁不会阻塞其他事务。
|
||||
在两阶段锁定的上下文中,我们讨论了[索引范围锁]()(请参阅“[索引范围锁]()”),它允许数据库锁定与某个搜索查询匹配的所有行的访问权,例如 `WHERE shift_id = 1234`。可以在这里使用类似的技术,除了SSI锁不会阻塞其他事务。
|
||||
|
||||
在[图7-11]()中,事务42和43都在班次1234查找值班医生。如果在`shift_id`上有索引,则数据库可以使用索引项1234来记录事务42和43读取这个数据的事实。 (如果没有索引,这个信息可以在表级别进行跟踪)。这个信息只需要保留一段时间:在一个事务完成(提交或中止)之后,所有的并发事务完成之后,数据库就可以忘记它读取的数据了。
|
||||
在[图7-11]()中,事务42 和43 都在班次1234 查找值班医生。如果在`shift_id`上有索引,则数据库可以使用索引项1234 来记录事务42 和43 读取这个数据的事实。 (如果没有索引,这个信息可以在表级别进行跟踪)。这个信息只需要保留一段时间:在一个事务完成(提交或中止)之后,所有的并发事务完成之后,数据库就可以忘记它读取的数据了。
|
||||
|
||||
当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务到其他事务完成,而是像一个引线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。
|
||||
|
||||
在[图7-11]()中,事务43通知事务42其先前读已过时,反之亦然。事务42首先提交并成功,尽管事务43的写影响了42,但因为事务43尚未提交,所以写入尚未生效。然而当事务43想要提交时,来自事务42的冲突写入已经被提交,所以43必须中止。
|
||||
在[图7-11]()中,事务43 通知事务42 其先前读已过时,反之亦然。事务42首先提交并成功,尽管事务43 的写影响了42 ,但因为事务43 尚未提交,所以写入尚未生效。然而当事务43 想要提交时,来自事务42 的冲突写入已经被提交,所以事务43 必须中止。
|
||||
|
||||
#### 可序列化的快照隔离的性能
|
||||
|
||||
@ -829,7 +827,7 @@ WHERE room_id = 123 AND
|
||||
|
||||
与串行执行相比,可序列化快照隔离并不局限于单个CPU核的吞吐量:FoundationDB将检测到的序列化冲突分布在多台机器上,允许扩展到很高的吞吐量。即使数据可能跨多台机器进行分区,事务也可以在保证可序列化隔离等级的同时读写多个分区中的数据【54】。
|
||||
|
||||
中止率显着影响SSI的整体表现。例如,长时间读取和写入数据的事务很可能会发生冲突并中止,因此SSI要求同时读写的事务尽量短(长时间运行的只读事务可能没问题)。对于慢事务,SSI可能比两阶段锁定或串行执行更不敏感。
|
||||
中止率显着影响SSI的整体表现。例如,长时间读取和写入数据的事务很可能会发生冲突并中止,因此SSI要求同时读写的事务尽量短(只读长事务可能没问题)。对于慢事务,SSI可能比两阶段锁定或串行执行更不敏感。
|
||||
|
||||
|
||||
|
||||
@ -871,7 +869,7 @@ WHERE room_id = 123 AND
|
||||
|
||||
***字面意义上的串行执行***
|
||||
|
||||
如果每个事务的执行速度非常快,并且事务吞吐量足够低,足以在单个CPU内核上处理,这是一个简单而有效的选择。
|
||||
如果每个事务的执行速度非常快,并且事务吞吐量足够低,足以在单个CPU核上处理,这是一个简单而有效的选择。
|
||||
|
||||
***两阶段锁定***
|
||||
|
||||
|
10
ch8.md
10
ch8.md
@ -464,7 +464,7 @@ while(true){
|
||||
|
||||
最常见的法定人数是超过一半的绝对多数(尽管其他类型的法定人数也是可能的)。多数法定人数允许系统继续工作,如果单个节点发生故障(三个节点可以容忍单节点故障;五个节点可以容忍双节点故障)。系统仍然是安全的,因为在这个制度中只能有一个多数——不能同时存在两个相互冲突的多数决定。当我们在[第9章](ch9.md)中讨论**共识算法(consensus algorithms)**时,我们将更详细地讨论法定人数的应用。
|
||||
|
||||
#### 领导和锁
|
||||
#### 领导者与锁定
|
||||
|
||||
通常情况下,一些东西在一个系统中只能有一个。例如:
|
||||
|
||||
@ -484,15 +484,15 @@ while(true){
|
||||
|
||||
这个问题就是我们先前在“[进程暂停](#进程暂停)”中讨论过的一个例子:如果持有租约的客户端暂停太久,它的租约将到期。另一个客户端可以获得同一文件的租约,并开始写入文件。当暂停的客户端回来时,它认为(不正确)它仍然有一个有效的租约,并继续写入文件。结果,客户的写入冲突和损坏的文件。
|
||||
|
||||
#### 击剑令牌
|
||||
#### 防护令牌
|
||||
|
||||
当使用锁或租约来保护对某些资源(如[图8-4](img/fig8-4.png)中的文件存储)的访问时,需要确保一个被误认为自己是“天选者”的节点不能中断系统的其它部分。实现这一目标的一个相当简单的技术就是**屏蔽(fencing)**,如[图8-5]()所示
|
||||
当使用锁或租约来保护对某些资源(如[图8-4](img/fig8-4.png)中的文件存储)的访问时,需要确保一个被误认为自己是“天选者”的节点不能中断系统的其它部分。实现这一目标的一个相当简单的技术就是**防护(fencing)**,如[图8-5]()所示
|
||||
|
||||
![](img/fig8-5.png)
|
||||
|
||||
**图8-5 只允许以增加屏蔽令牌的顺序进行写操作,从而保证存储安全**
|
||||
|
||||
我们假设每次锁定服务器授予锁或租约时,它还会返回一个**屏蔽令牌(fencing token)**,这个数字在每次授予锁定时都会增加(例如,由锁定服务增加)。然后,我们可以要求客户端每次向存储服务发送写入请求时,都必须包含当前的屏蔽令牌。
|
||||
我们假设每次锁定服务器授予锁或租约时,它还会返回一个**防护令牌(fencing token)**,这个数字在每次授予锁定时都会增加(例如,由锁定服务增加)。然后,我们可以要求客户端每次向存储服务发送写入请求时,都必须包含当前的屏蔽令牌。
|
||||
|
||||
在[图8-5](img/fig8-5.png)中,客户端1以33的令牌获得租约,但随后进入一个长时间的停顿并且租约到期。客户端2以34的令牌(该数字总是增加)获取租约,然后将其写入请求发送到存储服务,包括34的令牌。稍后,客户端1恢复生机并将其写入存储服务,包括其令牌值33.但是,存储服务器会记住它已经处理了一个具有更高令牌编号(34)的写入,因此它会拒绝带有令牌33的请求。
|
||||
|
||||
@ -588,7 +588,7 @@ Web应用程序确实需要预期受终端用户控制的客户端(如Web浏
|
||||
|
||||
***单调序列***
|
||||
|
||||
如果请求$x$返回了令牌$t_x$,并且请求$y$返回了令牌$t_y$,并且$x$在$y$开始之前已经完成,那么$t_x <t_y$。
|
||||
如果请求 $x$ 返回了令牌 $t_x$,并且请求$y$返回了令牌$t_y$,并且 $x$ 在 $y$ 开始之前已经完成,那么$t_x <t_y$。
|
||||
|
||||
***可用性***
|
||||
|
||||
|
10
colophon.md
10
colophon.md
@ -16,18 +16,18 @@ Martin是一位常规会议演讲者,博主和开源贡献者。他认为,
|
||||
|
||||
PostgreSQL DBA @ TanTan
|
||||
|
||||
前Alibaba+-Finplus 架构师/全栈工程师 (15.08 ~ 17.12)
|
||||
Alibaba+-Finplus 架构师/全栈工程师 (2015 ~ 2017)
|
||||
|
||||
|
||||
|
||||
## 后记
|
||||
|
||||
设计数据密集型应用程序封面上的动物是印度野猪(Sus scrofa cristatus),它是在印度,缅甸,尼泊尔,斯里兰卡和泰国发现的一种野猪的亚种。它们与欧洲公猪不同,它们具有较高的背部刷毛,没有羊毛底毛,以及更大,更直的头骨。
|
||||
《设计数据密集型应用》封面上的动物是**印度野猪(Sus scrofa cristatus)**,它是在印度,缅甸,尼泊尔,斯里兰卡和泰国发现的一种野猪的亚种。它们与欧洲公猪不同,它们具有较高的背部刷毛,没有羊毛底毛,以及更大,更直的头骨。
|
||||
|
||||
印度的野猪有一头灰色或黑色的头发,脊椎上有僵硬的硬毛。男性有突出的犬齿(称为t),用来与对手战斗或抵御掠食者。男性比女性大,但这些物种平均肩高33-35英寸,体重200-300磅。他们的天敌包括熊,老虎和各种大型猫科动物。
|
||||
印度的野猪有一头灰色或黑色的头发,脊椎上有僵硬的硬毛。雄性有突出的犬齿(称为T),用来与对手战斗或抵御掠食者。雄性比雌性大,但这些物种平均肩高33-35英寸,体重200-300磅。他们的天敌包括熊,老虎和各种大型猫科动物。
|
||||
|
||||
这些动物夜行和杂食 - 他们吃各种各样的东西,包括根,昆虫,腐肉,坚果,浆果和小动物。野猪也被称为通过垃圾和作物田地,造成大量的破坏,并赢得农民的仇恨。他们需要每天吃4,000-4,500卡路里。公猪有一个发达的嗅觉,这有助于他们寻找地下植物材料和挖掘动物。但是,他们的视力很差。
|
||||
这些动物夜行且杂食 —— 它们吃各种各样的东西,包括根,昆虫,腐肉,坚果,浆果和小动物。野猪也被称为通过垃圾和作物田地,造成大量的破坏,并赢得农民的仇恨。他们需要每天吃4,000 ~ 4,500卡路里。公猪有着发达的嗅觉,这有助于它们寻找地下的植物材料和挖掘动物。但是它们的视力很差。
|
||||
|
||||
野猪在人类文化中一直具有重要意义。在印度教传说中,野猪是毗湿奴神的化身。在古希腊的丧葬纪念碑中,它是一个勇敢的失败者的象征(与胜利的狮子相反)。由于它的侵略,它被描绘在斯堪的纳维亚,日耳曼和盎格鲁 - 撒克逊战士的盔甲和武器上。在中国十二生肖中,它象征着决心和急躁。
|
||||
野猪在人类文化中一直具有重要意义。在印度教传说中,野猪是毗湿奴神的化身。在古希腊的丧葬纪念碑中,它是一个勇敢失败者的象征(与胜利的狮子相反)。由于它的侵略,它被描绘在斯堪的纳维亚,日耳曼和盎格鲁 ~ 撒克逊战士的盔甲和武器上。在中国十二生肖中,它象征着决心和急躁。
|
||||
|
||||
O'Reilly封面上的许多动物都受到威胁;所有这些对世界都很重要。要了解有关如何提供帮助的更多信息,请访问animals.oreilly.com。封面图片来自Shaw's Zoology。封面字体是URW Typewriter和Guardian Sans。文字字体是Adobe Minion Pro;图中的字体是Adobe Myriad Pro;标题字体是Adobe Myriad Condensed;代码字体是Dalton Maag的Ubuntu Mono。
|
Binary file not shown.
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
@ -4,10 +4,10 @@
|
||||
|
||||
1. [第一章](ch1.md)将介绍本书使用的术语和方法。**可靠性,可扩展性和可维护性** ,这些词汇到底意味着什么?如何实现这些目标?
|
||||
2. [第二章](ch2.md)将对几种不同的**数据模型和查询语言**进行比较。从程序员的角度看,这是数据库之间最明显的区别。不同的数据模型适用于不同的应用场景。
|
||||
3. [第三章](ch3.md)将深**存储引擎**内部,并研究数据库如何在磁盘上摆放数据。不同的存储引擎针对不同的负载进行优化,选择合适的存储引擎对系统性能有巨大影响。
|
||||
4. [第四章](ch4)将对几种不同的 **数据编码**进行比较。特别讨论了在应用需求经常变化、模式需要随时间演化的环境中,这些格式的适用情况。
|
||||
3. [第三章](ch3.md)将深入**存储引擎**内部,研究数据库如何在磁盘上摆放数据。不同的存储引擎针对不同的负载进行优化,选择合适的存储引擎对系统性能有巨大影响。
|
||||
4. [第四章](ch4)将对几种不同的 **数据编码**进行比较。特别研究了这些格式在应用需求经常变化、模式需要随时间演变的环境中表现如何。
|
||||
|
||||
第二部分将专门讨论在**分布式数据系统**中才有的问题。
|
||||
第二部分将专门讨论在**分布式数据系统**中特有的问题。
|
||||
|
||||
|
||||
|
||||
|
78
preface.md
78
preface.md
@ -1,100 +1,102 @@
|
||||
# 序
|
||||
# 序言
|
||||
|
||||
如果近几年从业于软件工程,特别是服务器端和后端系统开发,那么您很有可能已经被大量关于数据存储和处理的时髦词汇轰炸过了: NoSQL!大数据!Web-Scale!分片!最终一致性!ACID! CAP定理!云服务!MapReduce!实时!
|
||||
|
||||
在最近十年中,我们看到了很多有趣的进展,关于数据库,分布式系统,以及在此基础上构建应用程序的方式。这些进展有着各种各样的驱动力:
|
||||
|
||||
* 谷歌,雅虎,亚马逊,脸书,领英,微软和推特等互联网公司正在和巨大的流量/数据打交道,这迫使它们创造能有效应对这种规模的新工具。
|
||||
* 企业需要敏捷,廉价地测试假设,通过缩短开发周期和保持数据模型的灵活性,快速响应新的市场洞察。
|
||||
* 谷歌,雅虎,亚马逊,脸书,领英,微软和推特等互联网公司正在和巨大的流量/数据打交道,这迫使他们去创造能有效应对如此规模的新工具。
|
||||
* 企业需要变得敏捷,需要低成本地检验假设,需要通过缩短开发周期和保持数据模型的灵活性,快速地响应新的市场洞察。
|
||||
* 免费和开源软件变得非常成功,在许多环境中比商业软件和定制软件更受欢迎。
|
||||
* 处理器主频几乎没有增长,但是多核处理器已经成为标配,网络也越来越快。这意味着并行程度只增不减。
|
||||
* 处理器主频几乎没有增长,但是多核处理器已经成为标配,网络也越来越快。这意味着并行化程度只增不减。
|
||||
* 即使您在一个小团队中工作,现在也可以构建分布在多台计算机甚至多个地理区域的系统,这要归功于譬如亚马逊网络服务(AWS)等基础设施即服务(IaaS)概念的践行者。
|
||||
* 许多服务都要求高可用,因停电或维护导致的服务不可用,变得越来越难以接受。
|
||||
|
||||
数据密集型应用正在通过利用这些技术进步推动可能性的边界。如果**数据是其主要挑战**(数据量,数据复杂度或数据变化速度),我们称这类应用为**数据密集型**,与之相对的是**计算密集型**,即处理器速度是瓶颈。
|
||||
**数据密集型应用(data-intensive applications)**正在通过使用这些技术进步来推动可能性的边界。一个应用被称为**数据密集型**的,如果**数据是其主要挑战**(数据量,数据复杂度或数据变化速度)—— 与之相对的是**计算密集型**,即处理器速度是其瓶颈。
|
||||
|
||||
帮助数据密集型应用程序存储和处理数据的工具和技术已经迅速适应这些变化。新型数据库系统(“NoSQL”)已经引起了人们的关注,但消息队列,缓存,搜索索引,批处理和流处理框架以及相关技术也非常重要。许多应用程序组合使用这些技术。
|
||||
帮助数据密集型应用存储和处理数据的工具与技术,正迅速地适应这些变化。新型数据库系统(“NoSQL”)已经备受关注,而消息队列,缓存,搜索索引,批处理和流处理框架以及相关技术也非常重要。很多应用组合使用这些工具与技术。
|
||||
|
||||
这些充满整个空间的流行语是对新的可能性的热情的表现,这是一件好事。但是,作为软件工程师和架构师,如果我们想要建立良好的应用程序,我们还需要对各种层出不穷的技术持有准确而严谨的技术性理解,以及它们之间的取舍权衡。为此,我们必须深入挖掘流行语背后的内容。
|
||||
这些生意盎然的时髦词汇体现出人们对新的可能性的热情,这是一件好事。但是作为软件工程师和架构师,如果要开发优秀的应用,我们还需要对各种层出不穷的技术及其利弊权衡有精准的技术理解。为了获得这种洞察,我们需要深挖时髦词汇背后的内容。
|
||||
|
||||
幸运的是,在技术快速变化的背后,无论您使用的是什么版本的特定工具,都存在一些持久的原则。如果你了解这些原则,你就可以看到每个工具的适用位置,如何充分利用它们,以及如何避免其中的陷阱。这是本书的初衷。
|
||||
幸运的是,在技术迅速变化的背后总是存在一些持续成立的原则,无论您使用了特定工具的哪个版本。如果您理解了这些原则,就可以领会这些工具的适用场景,如何充分利用它们,以及如何避免其中的陷阱。这正是本书的初衷。
|
||||
|
||||
本书的目标是帮助您在多样而快速变化的处理和存储数据技术的大观园中找到方向。这本书不是一个特定工具的教程,也不是一本充满干枯理论的教科书。相反,我们将看到一些成功的数据系统示例:一些构成了许多流行应用程序基础,必须在每天的生产中满足可伸缩性,性能和可靠性需求的技术。
|
||||
本书的目标是帮助您在飞速变化的数据处理和数据存储技术大观园中找到方向。本书并不是某个特定工具的教程,也不是一本充满枯燥理论的教科书。相反,我们将看到一些成功数据系统的样例:许多流行应用每天都要在生产中会满足可扩展性、性能、以及可靠性的要求,而这些技术构成了这些应用的基础。
|
||||
|
||||
我们将深入这些系统的内部,梳理他们的关键算法,讨论他们的原则和它们必须做出的权衡。在这个过程中,我们将尝试寻找有用的思考数据系统的方式 —— 不仅仅关于它们是如何工作的,还包括为什么它们以这种方式工作,以及我们需要问什么问题。
|
||||
我们将深入这些系统的内部,理清它们的关键算法,讨论背后的原则和它们必须做出的权衡。在这个过程中,我们将尝试寻找**思考**数据系统的有效方式 —— 不仅关于它们**如何**工作,还包括它们**为什么**以这种方式工作,以及哪些问题是我们需要问的。
|
||||
|
||||
阅读本书后,您将能够很好地决定哪种技术适合哪种用途,并了解如何将工具组合起来,形成良好应用架构的基础。您不会获得从头开始构建自己的数据库存储引擎的能力,但幸运的是,这是很少有必要的。您将会获得的是,对你的系统在底层做什么有一个很好的直觉,这样您就可以推断它们的行为,做出好的设计决定,并追踪任何可能出现的问题。
|
||||
阅读本书后,你能很好地决定哪种技术适合哪种用途,并了解如何将工具组合起来,为一个良好应用架构奠定基础。本书并不足以使你从头开始构建自己的数据库存储引擎,不过幸运的是这基本上很少有必要。你将获得对系统底层发生事情的敏锐直觉,这样你就有能力推理它们的行为,做出优秀的设计决策,并追踪任何可能出现的问题。
|
||||
|
||||
|
||||
|
||||
## 本书的目标读者
|
||||
|
||||
如果您开发的应用程序具有用于存储或处理数据的某种服务器/后端系统,并且您的应用程序使用互联网(例如,Web应用程序,移动应用程序或连接到互联网的传感器),那么本书就是为您准备的。
|
||||
如果你开发的应用具有用于存储或处理数据的某种服务器/后端系统,而且使用网络(例如,Web应用,移动应用或连接到互联网的传感器),那么本书就是为你准备的。
|
||||
|
||||
本书适用于热爱编写代码的软件工程师,软件架构师和技术经理。如果您需要就您所从事的系统架构做出决定,例如您需要选择解决某个特定问题的工具,并找出如何最好地应用这些工具来解决问题,那么这本书对您尤其重要。但即使您不能选择您的工具,这本书仍将帮助您更好地了解您所使用工具的长处和短处。
|
||||
本书是为软件工程师,软件架构师,以及喜欢写代码的技术经理准备的。如果您需要对所从事系统的架构做出决策 —— 例如您需要选择解决某个特定问题的工具,并找出如何最好地使用这些工具,那么这本书对您尤有价值。但即使你无法选择你的工具,本书仍将帮助你更好地了解所使用工具的长处和短处。
|
||||
|
||||
您应该具有构建基于Web的应用程序或网络服务的一些经验,并且您应该熟悉关系数据库和SQL。任何您了解的非关系型数据库和其他与数据相关的工具都会更有帮助,但不是必需的。常见的网络协议如TCP和HTTP的一般理解是有帮助的。编程语言或框架的选择对阅读本书没有任何不同影响。
|
||||
您应当具有一些开发Web应用或网络服务的经验,且应当熟悉关系型数据库和SQL。任何您了解的非关系型数据库和其他与数据相关工具都会有所帮助,但不是必需的。对常见网络协议如TCP和HTTP的大概理解是有帮助的。编程语言或框架的选择对阅读本书没有任何不同影响。
|
||||
|
||||
如果以下任何一条对你是真的,你会发现这本书很有价值:
|
||||
如果以下任意一条对您为真,你会发现这本书很有价值:
|
||||
|
||||
* 您想了解如何使数据系统可扩展,例如,支持拥有数百万用户的Web或移动应用程序。
|
||||
* 您需要提高应用程序的可用性(最大限度地减少停机时间)和运行稳定。
|
||||
* 您正在寻找使系统长期易于维护的方法,即使随着需求和技术的变化而变化。
|
||||
* 您对事物的运作方式有着天然的好奇心,并且希望知道一些主要网站和在线服务背后发生的事情。这本书打破了各种数据库和数据处理系统的内幕,探索他们设计中的智慧是非常有趣的。
|
||||
* 您想了解如何使数据系统可扩展,例如,支持拥有数百万用户的Web或移动应用。
|
||||
* 您需要提高应用程序的可用性(最大限度地减少停机时间),保持稳定运行。
|
||||
* 您正在寻找使系统在长期运行过程易于维护的方法,即使系统规模增长,需求与技术也发生变化。
|
||||
* 您对事物的运作方式有着天然的好奇心,并且希望知道一些主流网站和在线服务背后发生的事情。这本书打破了各种数据库和数据处理系统的内幕,探索这些系统设计中的智慧是非常有趣的。
|
||||
|
||||
有时在讨论可扩展的数据系统时,人们会评论:“你又不在谷歌或亚马逊,别操心可扩展性了,直接上关系数据库。“这个陈述有一定的道理:为了您不需要的规模而构建程序不仅会浪费不必要的精力,并且可能会把您锁死在一个不灵活的设计中。实际上这是“过早优化”的一种形式。不过,选择合适的工具确实很重要,而不同的技术各有优缺点。我们将会看到,关系数据库虽然很重要,但绝不是数据处理的终章。
|
||||
有时在讨论可扩展的数据系统时,人们会说:“你又不在谷歌或亚马逊,别操心可扩展性了,直接上关系型数据库”。这个陈述有一定的道理:为了不必要的扩展性而设计程序,不仅会浪费不必要的精力,并且可能会把你锁死在一个不灵活的设计中。实际上这是一种“过早优化”的形式。不过,选择合适的工具确实很重要,而不同的技术各有优缺点。我们将看到,关系数据库虽然很重要,但绝不是数据处理的终章。
|
||||
|
||||
|
||||
|
||||
## 本书涉及的领域
|
||||
|
||||
本书不会试图给出详细的指导,说明如何安装或使用特定的软件包或API,因为已经有大量的文档。相反,我们讨论了基本的数据系统的各种原则和权衡,并探讨了不同产品所做出的不同设计决策。
|
||||
本书并不会尝试告诉读者如何安装或使用特定的软件包或API,因为已经有大量文档给出了详细的使用说明。相反,我们会讨论数据系统的基石——各种原则与利弊权衡,并探讨了不同产品所做出的不同设计决策。
|
||||
|
||||
在电子书版本中,我们包含了在线资源全文的链接。所有链接都在发布时进行了验证,但不幸的是,由于网络的性质,链接往往频繁地中断。如果您遇到链接断开的情况,或者您正在阅读本书的打印副本,则可以使用搜索引擎查找引用。对于学术论文,您可以在Google学术搜索中搜索标题以查找开放获取的PDF文件。或者,您可以在[DDIA-Reference](https://github.com/ept/ddia-references)上找到所有的参考资料,我们在那里维护最新的链接。
|
||||
在电子书中包含了在线资源全文的链接。所有链接在出版时都进行了验证,但不幸的是,由于网络的自然规律,链接往往会频繁地破损。如果您遇到链接断开的情况,或者正在阅读本书的打印副本,可以使用搜索引擎查找参考文献。对于学术论文,您可以在Google学术中搜索标题,查找可以公开获取的PDF文件。或者,您也可以在 https://github.com/ept/ddia-references 中找到所有的参考资料,我们在那儿维护最新的链接。
|
||||
|
||||
我们主要关注数据系统的体系结构以及它们被集成到数据密集型应用程序中的方式。本书没有讨论部署,操作,安全,管理等领域的空间 —— 这些都是复杂而重要的话题,仅仅在本书中用肤浅的注解讨论它们对它们不公平。它们各自配得上一本单独的书。
|
||||
|
||||
本书中描述的许多技术都被涵盖在**大数据**这个时髦词的范畴中。然而“大数据”这个术语被滥用,缺乏明确定义,以至于在严肃的工程讨论中没有用处。这本书使用更少歧义的术语,如“单点系统”之于”分布式系统“,或”在线/交互式“之于”离线/批处理系统“。
|
||||
|
||||
本书对自由和开放源码的软件(FOSS)有一定偏好,因为阅读,修改和执行源代码是了解一个东西详细工作原理的好方法。开放平台还可以降低供应商垄断的风险。然而,在适当的情况下,我们也讨论专有软件(封闭源码软件,软件即服务,或一些公司内部的仅在文献中描述但未公开发布的软件)。
|
||||
我们主要关注的是数据系统的**架构(architecture)**,以及它们被集成到数据密集型应用中的方式。本书没有足够的空间覆盖部署,运维,安全,管理等领域 —— 这些都是复杂而重要的主题,仅仅在本书中用粗略的注解讨论这些对它们很不公平。每个领域都值得用单独的书去讲。
|
||||
|
||||
本书中描述的许多技术都被涵盖在**大数据(Big Data)**这个时髦词的范畴中。然而“大数据”这个术语被滥用,缺乏明确定义,以至于在严肃的工程讨论中没有用处。这本书使用歧义更小的术语,如“单节点”之于”分布式系统“,或”在线/交互式系统“之于”离线/批处理系统“。
|
||||
|
||||
本书对自由和开源软件(FOSS)有一定偏好,因为阅读,修改和执行源码是了解一样东西详细工作原理的好方法。开放的平台也可以降低供应商垄断的风险。然而在适当的情况下,我们也会讨论专利软件(闭源软件,软件即服务 SaaS,或一些在文献中描述过但未公开发行的公司内部软件)。
|
||||
|
||||
## 本书纲要
|
||||
|
||||
本书分为三部分:
|
||||
|
||||
1. 在[第一部分](part-i.md)中,我们会讨论设计数据密集型应用所赖的基本思想。我们从[第1章](ch1.md)开始,讨论我们实际要达到的目标:可靠性,可扩展性和可维护性;我们该如何考虑它们;以及如何实现它们。在[第2章](ch2.md)中,我们比较了几种不同的数据模型和查询语言,看看它们是如何适用于不同的场景。在[第3章](ch3.md)中将讨论存储引擎:数据库如何在磁盘上摆放数据,以便能高效地再次找到它。[第4章](ch4.md)转向数据编码(序列化),以及随时间演化的模式。
|
||||
1. 在[第一部分](part-i.md)中,我们会讨论设计数据密集型应用所赖的基本思想。我们从[第1章](ch1.md)开始,讨论我们实际要达到的目标:可靠性,可扩展性和可维护性;我们该如何思考这些概念;以及如何实现它们。在[第2章](ch2.md)中,我们比较了几种不同的数据模型和查询语言,看看它们如何适用于不同的场景。在[第3章](ch3.md)中将讨论存储引擎:数据库如何在磁盘上摆放数据,以便能高效地再次找到它。[第4章](ch4.md)转向数据编码(序列化),以及随时间演化的模式。
|
||||
|
||||
2. 在[第二部分](part-ii.md)中,我们从讨论存储在一台机器上的数据转向讨论分布在多台机器上的数据。这对于可扩展性通常是必需的,但带来了各种独特的挑战。我们首先讨论复制([第5章](ch5.md)),分区/分片([第6章](ch6.md))和事务([第7章](ch7.md))。我们然后探索关于分布式系统的问题的更多细节([第8章](ch8.md)),以及在分布式系统中实现共识和一致性意味着什么([第9章](ch9.md))。
|
||||
2. 在[第二部分](part-ii.md)中,我们从讨论存储在一台机器上的数据转向讨论分布在多台机器上的数据。这对于可扩展性通常是必需的,但带来了各种独特的挑战。我们首先讨论复制([第5章](ch5.md)),分区/分片([第6章](ch6.md))和事务([第7章](ch7.md))。然后我们将探索关于分布式系统问题的更多细节([第8章](ch8.md)),以及在分布式系统中实现一致性与共识意味着什么([第9章](ch9.md))。
|
||||
|
||||
3. 在[第三部分](part-iii.md)中,我们讨论从其他数据集衍生一些数据集的系统。派生数据经常发生在异构系统中:当没有一个数据库可以把所有事都做好时,应用程序需要集成几个不同的数据库,缓存,索引等等。在[第10章](ch10.md)中我们将从一种批处理派生数据的方法开始,然后我们将在第11章中在此基础上用流处理构建。最后,在[第12章](ch12.md)中,我们将所有内容放在一起,并讨论未来构建可靠,可伸缩和可维护的应用程序的方法。
|
||||
3. 在[第三部分](part-iii.md)中,我们讨论那些从其他数据集衍生出一些数据集的系统。衍生数据经常出现在异构系统中:当没有单个数据库可以把所有事情都做的很好时,应用需要集成几种不同的数据库,缓存,索引等。在[第10章](ch10.md)中我们将从一种衍生数据的批处理方法开始,然后在此基础上建立在[第11章](ch11.md)中讨论的流处理。最后,在[第12章](ch12.md)中,我们将所有内容汇总,讨论在将来构建可靠,可伸缩和可维护的应用程序的方法。
|
||||
|
||||
|
||||
|
||||
|
||||
## 参考文献与延伸阅读
|
||||
|
||||
本书中讨论的大部分内容已经在其它地方以某种形式出现过了 —— 会议演示文稿,研究论文,博客文章,代码,BUG跟踪器,邮件列表,以及工程习惯中。本书总结了不同来源资料中最重要的想法,并在文本中包含了指向原始文献的指针。 如果你想更深入地探索一个领域,那么每章末尾的参考文献都是很好的资源,其中大部分可以免费在线获取。
|
||||
本书中讨论的大部分内容已经在其它地方以某种形式出现过了 —— 会议演示文稿,研究论文,博客文章,代码,BUG跟踪器,邮件列表,以及工程习惯中。本书总结了不同来源资料中最重要的想法,并在文本中包含了指向原始文献的链接。 如果你想更深入地探索一个领域,那么每章末尾的参考文献都是很好的资源,其中大部分可以免费在线获取。
|
||||
|
||||
|
||||
|
||||
## O‘Reilly Safari
|
||||
|
||||
[Safari](http://oreilly.com/safari)赛高!
|
||||
[Safari](http://oreilly.com/safari) (formerly Safari Books Online) is a membership-based training and reference platform for enterprise, government, educators, and individuals.
|
||||
|
||||
Members have access to thousands of books, training videos, Learning Paths, interac‐ tive tutorials, and curated playlists from over 250 publishers, including O’Reilly Media, Harvard Business Review, Prentice Hall Professional, Addison-Wesley Pro‐ fessional, Microsoft Press, Sams, Que, Peachpit Press, Adobe, Focal Press, Cisco Press, John Wiley & Sons, Syngress, Morgan Kaufmann, IBM Redbooks, Packt, Adobe Press, FT Press, Apress, Manning, New Riders, McGraw-Hill, Jones & Bartlett, and Course Technology, among others.
|
||||
|
||||
For more information, please visit http://oreilly.com/safari.
|
||||
|
||||
|
||||
|
||||
## 致谢
|
||||
|
||||
本书融合了学术研究和工业实践的经验,是大量其他人的思想和知识的融合与系统化。在计算中,我们往往会被新的东西所吸引,但是我认为我们有很多东西可以从以前的东西中学习。这本书有超过800篇文章,博客文章,讲座,文档等参考资料,对我来说这是一个宝贵的学习资源。我非常感谢这些材料的作者分享他们的知识。
|
||||
本书融合了学术研究和工业实践的经验,融合并系统化了大量其他人的想法与知识。在计算领域,我们往往会被各种新鲜花样所吸引,但我认为前人完成的工作中,有太多值得我们学习的地方了。本书有800多处引用:文章,博客,讲座,文档等,对我来说这些都是宝贵的学习资源。我非常感谢这些材料的作者分享他们的知识。
|
||||
|
||||
我也从个人对话中学到了很多东西,这要感谢大量的人花时间讨论想法,耐心地向我解释。特别感谢Joe Adler, Ross Anderson, Peter Bailis, Márton Balassi, Alastair Beresford, Mark Callaghan, Mat Clayton, Patrick Collison, Sean Cribbs, Shirshanka Das, Niklas Ekström, Stephan Ewen, Alan Fekete, Gyula Fóra, Camille Fournier, Andres Freund, John Garbutt, Seth Gilbert, Tom Haggett, Pat Hel‐ land, Joe Hellerstein, Jakob Homan, Heidi Howard, John Hugg, Julian Hyde, Conrad Irwin, Evan Jones, Flavio Junqueira, Jessica Kerr, Kyle Kingsbury, Jay Kreps, Carl Lerche, Nicolas Liochon, Steve Loughran, Lee Mallabone, Nathan Marz, Caitie McCaffrey, Josie McLellan, Christopher Meiklejohn, Ian Meyers, Neha Narkhede, Neha Narula, Cathy O’Neil, Onora O’Neill, Ludovic Orban, Zoran Perkov, Julia Powles, Chris Riccomini, Henry Robinson, David Rosenthal, Jennifer Rullmann, Matthew Sackman, Martin Scholl, Amit Sela, Gwen Shapira, Greg Spurrier, Sam Stokes, Ben Stopford, Tom Stuart, Diana Vasile, Rahul Vohra, Pete Warden, 以及 Brett Wooldridge.
|
||||
我也从与人交流中学到了很多东西,很多人花费了宝贵的时间与我讨论想法并耐心解释。特别感谢 Joe Adler, Ross Anderson, Peter Bailis, Márton Balassi, Alastair Beresford, Mark Callaghan, Mat Clayton, Patrick Collison, Sean Cribbs, Shirshanka Das, Niklas Ekström, Stephan Ewen, Alan Fekete, Gyula Fóra, Camille Fournier, Andres Freund, John Garbutt, Seth Gilbert, Tom Haggett, Pat Hel‐ land, Joe Hellerstein, Jakob Homan, Heidi Howard, John Hugg, Julian Hyde, Conrad Irwin, Evan Jones, Flavio Junqueira, Jessica Kerr, Kyle Kingsbury, Jay Kreps, Carl Lerche, Nicolas Liochon, Steve Loughran, Lee Mallabone, Nathan Marz, Caitie McCaffrey, Josie McLellan, Christopher Meiklejohn, Ian Meyers, Neha Narkhede, Neha Narula, Cathy O’Neil, Onora O’Neill, Ludovic Orban, Zoran Perkov, Julia Powles, Chris Riccomini, Henry Robinson, David Rosenthal, Jennifer Rullmann, Matthew Sackman, Martin Scholl, Amit Sela, Gwen Shapira, Greg Spurrier, Sam Stokes, Ben Stopford, Tom Stuart, Diana Vasile, Rahul Vohra, Pete Warden, 以及 Brett Wooldridge.
|
||||
|
||||
通过审阅草案并提供反馈意见,更多的人对本书的撰写非常有价值。对于这些贡献,我特别感谢Raul Agepati,Tyler Akidau,Mattias Andersson,Sasha Baranov,Veena Basavaraj,David Beyer,Jim Brikman,Paul Carey,Raul Castro Fernandez,Joseph Chow,Derek Elkins,Sam Elliott,Alexander Gallego,Mark Grover ,斯图尔·万洛威(Stu Hallow Halloway),海蒂·霍华德(Heidi Howard),尼科拉·克莱普曼(Nicola Kleppmann),斯特凡·克鲁帕(Stefan Kruppa),比约恩·马德森(Bjorn Madsen),麦克尔·桑德(Sandder Mak),斯特凡·波德科维斯基(Stefan Podkowinski),菲尔·波特(Phil Potter)当然,对于本书中的任何遗留错误或不愉快的意见,我承担全部责任。
|
||||
更多人通过审阅草稿并提供反馈意见在本书的创作过程中做出了无价的贡献。我要特别感谢Raul Agepati, Tyler Akidau, Mattias Andersson, Sasha Baranov, Veena Basavaraj, David Beyer, Jim Brikman, Paul Carey, Raul Castro Fernandez, Joseph Chow, Derek Elkins, Sam Elliott, Alexander Gallego, Mark Grover, Stu Halloway, Heidi Howard, Nicola Kleppmann, Stefan Kruppa, Bjorn Madsen, Sander Mak, Stefan Podkowinski, Phil Potter, Hamid Ramazani, Sam Stokes, 以及Ben Summers。当然对于本书中的任何遗留错误或难以接受的见解,我都承担全部责任。
|
||||
|
||||
为了帮助这本书出版,并且耐心地处理我缓慢的写作和不寻常的要求,我感谢编辑Marie Beaugureau,Mike Loukides,Ann Spencer和O'Reilly的所有团队。为了帮助找到合适的单词,我要感谢Rachel Head。尽管有其他的工作承诺给了我写作的时间和自由,但我要感谢Alastair Beresford,Susan Goodhue,Neha Narkhede和Kevin Scott。
|
||||
为了帮助这本书落地,并且耐心地处理我缓慢的写作和不寻常的要求,我要对编辑Marie Beaugureau,Mike Loukides,Ann Spencer和O'Reilly的所有团队表示感谢。我要感谢Rachel Head帮我找到了合适的术语。我要感谢Alastair Beresford,Susan Goodhue,Neha Narkhede和Kevin Scott,在其他工作事务之外给了我充分地创作时间和自由。
|
||||
|
||||
非常特别的感谢是Shabbir Diwan和Edie Freedman,他们非常小心的说明了各章的地图。他们以非常规的创作地图的想法,使他们如此美丽和令人兴奋,真是太棒了。
|
||||
特别感谢Shabbir Diwan和Edie Freedman,他们非常用心地为各章配了地图。他们提出了不落俗套的灵感,创作了这些地图,美丽而引人入胜,真是太棒了。
|
||||
|
||||
最后,我的爱情传到了我的家人和朋友身上,没有他,我将无法完成这个花了近四年时间的写作过程。你是最好的。
|
||||
最后我要表达对家人和朋友们的爱,没有他们,我将无法走完这个将近四年的写作历程。你们是最棒的。
|
Loading…
Reference in New Issue
Block a user