mirror of
https://github.com/Vonng/ddia.git
synced 2024-12-06 15:20:12 +08:00
update text spacing with pangu and some manual adjustments
This commit is contained in:
parent
34fdbe28c4
commit
8547560e62
504
ch10.md
504
ch10.md
@ -10,11 +10,11 @@
|
||||
|
||||
[TOC]
|
||||
|
||||
在本书的前两部分中,我们讨论了很多关于**请求**和**查询**以及相应的**响应**或**结果**。许多现有数据系统中都采用这种数据处理方式:你发送请求指令,一段时间后(我们期望)系统会给出一个结果。数据库、缓存、搜索索引、Web服务器以及其他一些系统都以这种方式工作。
|
||||
在本书的前两部分中,我们讨论了很多关于 **请求** 和 **查询** 以及相应的 **响应** 或 **结果**。许多现有数据系统中都采用这种数据处理方式:你发送请求指令,一段时间后(我们期望)系统会给出一个结果。数据库、缓存、搜索索引、Web 服务器以及其他一些系统都以这种方式工作。
|
||||
|
||||
像这样的**在线(online)** 系统,无论是浏览器请求页面还是调用远程API的服务,我们通常认为请求是由人类用户触发的,并且正在等待响应。他们不应该等太久,所以我们非常关注系统的响应时间(请参阅“[描述性能](ch1.md#描述性能)”)。
|
||||
像这样的 **在线(online)** 系统,无论是浏览器请求页面还是调用远程 API 的服务,我们通常认为请求是由人类用户触发的,并且正在等待响应。他们不应该等太久,所以我们非常关注系统的响应时间(请参阅 “[描述性能](ch1.md#描述性能)”)。
|
||||
|
||||
Web和越来越多的基于HTTP/REST的API使交互的请求/响应风格变得如此普遍,以至于很容易将其视为理所当然。但我们应该记住,这不是构建系统的唯一方式,其他方法也有其优点。我们来看看三种不同类型的系统:
|
||||
Web 和越来越多的基于 HTTP/REST 的 API 使交互的请求 / 响应风格变得如此普遍,以至于很容易将其视为理所当然。但我们应该记住,这不是构建系统的唯一方式,其他方法也有其优点。我们来看看三种不同类型的系统:
|
||||
|
||||
* 服务(在线系统)
|
||||
|
||||
@ -22,24 +22,24 @@ Web和越来越多的基于HTTP/REST的API使交互的请求/响应风格变得
|
||||
|
||||
* 批处理系统(离线系统)
|
||||
|
||||
一个批处理系统有大量的输入数据,跑一个**作业(job)** 来处理它,并生成一些输出数据,这往往需要一段时间(从几分钟到几天),所以通常不会有用户等待作业完成。相反,批量作业通常会定期运行(例如,每天一次)。批处理作业的主要性能衡量标准通常是吞吐量(处理特定大小的输入所需的时间)。本章中讨论的就是批处理。
|
||||
一个批处理系统有大量的输入数据,跑一个 **作业(job)** 来处理它,并生成一些输出数据,这往往需要一段时间(从几分钟到几天),所以通常不会有用户等待作业完成。相反,批量作业通常会定期运行(例如,每天一次)。批处理作业的主要性能衡量标准通常是吞吐量(处理特定大小的输入所需的时间)。本章中讨论的就是批处理。
|
||||
|
||||
* 流处理系统(准实时系统)
|
||||
|
||||
流处理介于在线和离线(批处理)之间,所以有时候被称为**准实时(near-real-time)** 或**准在线(nearline)** 处理。像批处理系统一样,流处理消费输入并产生输出(并不需要响应请求)。但是,流式作业在事件发生后不久就会对事件进行操作,而批处理作业则需等待固定的一组输入数据。这种差异使流处理系统比起批处理系统具有更低的延迟。由于流处理基于批处理,我们将在[第十一章](ch11.md)讨论它。
|
||||
流处理介于在线和离线(批处理)之间,所以有时候被称为 **准实时(near-real-time)** 或 **准在线(nearline)** 处理。像批处理系统一样,流处理消费输入并产生输出(并不需要响应请求)。但是,流式作业在事件发生后不久就会对事件进行操作,而批处理作业则需等待固定的一组输入数据。这种差异使流处理系统比起批处理系统具有更低的延迟。由于流处理基于批处理,我们将在 [第十一章](ch11.md) 讨论它。
|
||||
|
||||
正如我们将在本章中看到的那样,批处理是构建可靠、可伸缩和可维护应用程序的重要组成部分。例如,2004年发布的批处理算法Map-Reduce(可能被过分热情地)被称为“造就Google大规模可伸缩性的算法”【2】。随后在各种开源数据系统中得到应用,包括Hadoop,CouchDB和MongoDB。
|
||||
正如我们将在本章中看到的那样,批处理是构建可靠、可伸缩和可维护应用程序的重要组成部分。例如,2004 年发布的批处理算法 Map-Reduce(可能被过分热情地)被称为 “造就 Google 大规模可伸缩性的算法”【2】。随后在各种开源数据系统中得到应用,包括 Hadoop,CouchDB 和 MongoDB。
|
||||
|
||||
与多年前为数据仓库开发的并行处理系统【3,4】相比,MapReduce是一个相当低级别的编程模型,但它使得在商用硬件上能进行的处理规模迈上一个新的台阶。虽然MapReduce的重要性正在下降【5】,但它仍然值得去理解,因为它描绘了一幅关于批处理为什么有用,以及如何做到有用的清晰图景。
|
||||
与多年前为数据仓库开发的并行处理系统【3,4】相比,MapReduce 是一个相当低级别的编程模型,但它使得在商用硬件上能进行的处理规模迈上一个新的台阶。虽然 MapReduce 的重要性正在下降【5】,但它仍然值得去理解,因为它描绘了一幅关于批处理为什么有用,以及如何做到有用的清晰图景。
|
||||
|
||||
实际上,批处理是一种非常古老的计算方式。早在可编程数字计算机诞生之前,打孔卡制表机(例如1890年美国人口普查【6】中使用的霍尔里斯机)实现了半机械化的批处理形式,从大量输入中汇总计算。 Map-Reduce与1940年代和1950年代广泛用于商业数据处理的机电IBM卡片分类机器有着惊人的相似之处【7】。正如我们所说,历史总是在不断重复自己。
|
||||
实际上,批处理是一种非常古老的计算方式。早在可编程数字计算机诞生之前,打孔卡制表机(例如 1890 年美国人口普查【6】中使用的霍尔里斯机)实现了半机械化的批处理形式,从大量输入中汇总计算。 Map-Reduce 与 1940 年代和 1950 年代广泛用于商业数据处理的机电 IBM 卡片分类机器有着惊人的相似之处【7】。正如我们所说,历史总是在不断重复自己。
|
||||
|
||||
在本章中,我们将了解MapReduce和其他一些批处理算法和框架,并探索它们在现代数据系统中的作用。但首先我们将看看使用标准Unix工具的数据处理。即使你已经熟悉了它们,Unix的哲学也值得一读,Unix的思想和经验教训可以迁移到大规模、异构的分布式数据系统中。
|
||||
在本章中,我们将了解 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"
|
||||
@ -54,14 +54,14 @@ AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 Safari/537.36"
|
||||
$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 年 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/` 的页面中的引用导致该文件被加载。
|
||||
|
||||
|
||||
### 简单日志分析
|
||||
|
||||
很多工具可以从这些日志文件生成关于网站流量的漂亮的报告,但为了练手,让我们使用基本的Unix功能创建自己的工具。 例如,假设你想在你的网站上找到五个最受欢迎的网页。 则可以在Unix shell中这样做:[^i]
|
||||
很多工具可以从这些日志文件生成关于网站流量的漂亮的报告,但为了练手,让我们使用基本的 Unix 功能创建自己的工具。 例如,假设你想在你的网站上找到五个最受欢迎的网页。 则可以在 Unix shell 中这样做:[^i]
|
||||
|
||||
[^i]: 有些人认为`cat`这里并没有必要,因为输入文件可以直接作为awk的参数。 但这种写法让线性管道更为显眼。
|
||||
[^i]: 有些人认为 `cat` 这里并没有必要,因为输入文件可以直接作为 awk 的参数。 但这种写法让线性管道更为显眼。
|
||||
|
||||
```bash
|
||||
cat /var/log/nginx/access.log | #1
|
||||
@ -73,10 +73,10 @@ cat /var/log/nginx/access.log | #1
|
||||
```
|
||||
|
||||
1. 读取日志文件
|
||||
2. 将每一行按空格分割成不同的字段,每行只输出第七个字段,恰好是请求的URL。在我们的例子中是`/css/typography.css`。
|
||||
3. 按字母顺序排列请求的URL列表。如果某个URL被请求过n次,那么排序后,文件将包含连续重复出现n次的该URL。
|
||||
4. `uniq`命令通过检查两个相邻的行是否相同来过滤掉输入中的重复行。 `-c`则表示还要输出一个计数器:对于每个不同的URL,它会报告输入中出现该URL的次数。
|
||||
5. 第二种排序按每行起始处的数字(`-n`)排序,这是URL的请求次数。然后逆序(`-r`)返回结果,大的数字在前。
|
||||
2. 将每一行按空格分割成不同的字段,每行只输出第七个字段,恰好是请求的 URL。在我们的例子中是 `/css/typography.css`。
|
||||
3. 按字母顺序排列请求的 URL 列表。如果某个 URL 被请求过 n 次,那么排序后,文件将包含连续重复出现 n 次的该 URL。
|
||||
4. `uniq` 命令通过检查两个相邻的行是否相同来过滤掉输入中的重复行。 `-c` 则表示还要输出一个计数器:对于每个不同的 URL,它会报告输入中出现该 URL 的次数。
|
||||
5. 第二种排序按每行起始处的数字(`-n`)排序,这是 URL 的请求次数。然后逆序(`-r`)返回结果,大的数字在前。
|
||||
6. 最后,只输出前五行(`-n 5`),并丢弃其余的。该系列命令的输出如下所示:
|
||||
|
||||
```
|
||||
@ -87,13 +87,13 @@ cat /var/log/nginx/access.log | #1
|
||||
915 /css/typography.css
|
||||
```
|
||||
|
||||
如果你不熟悉Unix工具,上面的命令行可能看起来有点吃力,但是它非常强大。它能在几秒钟内处理几GB的日志文件,并且你可以根据需要轻松修改命令。例如,如果要从报告中省略CSS文件,可以将awk参数更改为`'$7 !~ /\.css$/ {print $7}'`,如果想统计最多的客户端IP地址,可以把awk参数改为`'{print $1}'`等等。
|
||||
如果你不熟悉 Unix 工具,上面的命令行可能看起来有点吃力,但是它非常强大。它能在几秒钟内处理几 GB 的日志文件,并且你可以根据需要轻松修改命令。例如,如果要从报告中省略 CSS 文件,可以将 awk 参数更改为 `'$7 !~ /\.css$/ {print $7}'`, 如果想统计最多的客户端 IP 地址,可以把 awk 参数改为 `'{print $1}'` 等等。
|
||||
|
||||
我们不会在这里详细探索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
|
||||
@ -108,480 +108,480 @@ top5 = counts.map{|url, count| [count, url] }.sort.reverse[0...5] # 4
|
||||
top5.each{|count, url| puts "#{count} #{url}" } # 5
|
||||
```
|
||||
|
||||
1. `counts`是一个存储计数器的哈希表,保存了每个URL被浏览的次数,默认为0。
|
||||
2. 逐行读取日志,抽取每行第七个被空格分隔的字段为URL(这里的数组索引是6,因为Ruby的数组索引从0开始计数)
|
||||
3. 将日志当前行中URL对应的计数器值加一。
|
||||
1. `counts` 是一个存储计数器的哈希表,保存了每个 URL 被浏览的次数,默认为 0。
|
||||
2. 逐行读取日志,抽取每行第七个被空格分隔的字段为 URL(这里的数组索引是 6,因为 Ruby 的数组索引从 0 开始计数)
|
||||
3. 将日志当前行中 URL 对应的计数器值加一。
|
||||
4. 按计数器值(降序)对哈希表内容进行排序,并取前五位。
|
||||
5. 打印出前五个条目。
|
||||
|
||||
这个程序并不像Unix管道那样简洁,但是它的可读性很强,喜欢哪一种属于口味的问题。但两者除了表面上的差异之外,执行流程也有很大差异,如果你在大文件上运行此分析,则会变得明显。
|
||||
这个程序并不像 Unix 管道那样简洁,但是它的可读性很强,喜欢哪一种属于口味的问题。但两者除了表面上的差异之外,执行流程也有很大差异,如果你在大文件上运行此分析,则会变得明显。
|
||||
|
||||
#### 排序 VS 内存中的聚合
|
||||
|
||||
Ruby脚本在内存中保存了一个URL的哈希表,将每个URL映射到它出现的次数。 Unix管道没有这样的哈希表,而是依赖于对URL列表的排序,在这个URL列表中,同一个URL的只是简单地重复出现。
|
||||
Ruby 脚本在内存中保存了一个 URL 的哈希表,将每个 URL 映射到它出现的次数。 Unix 管道没有这样的哈希表,而是依赖于对 URL 列表的排序,在这个 URL 列表中,同一个 URL 的只是简单地重复出现。
|
||||
|
||||
哪种方法更好?这取决于你有多少个不同的URL。对于大多数中小型网站,你可能可以为所有不同网址提供一个计数器(假设我们使用1GB内存)。在此例中,作业的**工作集**(working set,即作业需要随机访问的内存大小)仅取决于不同URL的数量:如果日志中只有单个URL,重复出现一百万次,则散列表所需的空间表就只有一个URL加上一个计数器的大小。当工作集足够小时,内存散列表表现良好,甚至在性能较差的笔记本电脑上也可以正常工作。
|
||||
哪种方法更好?这取决于你有多少个不同的 URL。对于大多数中小型网站,你可能可以为所有不同网址提供一个计数器(假设我们使用 1GB 内存)。在此例中,作业的 **工作集**(working set,即作业需要随机访问的内存大小)仅取决于不同 URL 的数量:如果日志中只有单个 URL,重复出现一百万次,则散列表所需的空间表就只有一个 URL 加上一个计数器的大小。当工作集足够小时,内存散列表表现良好,甚至在性能较差的笔记本电脑上也可以正常工作。
|
||||
|
||||
另一方面,如果作业的工作集大于可用内存,则排序方法的优点是可以高效地使用磁盘。这与我们在“[SSTables和LSM树](ch3.md#SSTables和LSM树)”中讨论过的原理是一样的:数据块可以在内存中排序并作为段文件写入磁盘,然后多个排序好的段可以合并为一个更大的排序文件。 归并排序具有在磁盘上运行良好的顺序访问模式。 (请记住,针对顺序I/O进行优化是[第三章](ch3.md)中反复出现的主题,相同的模式在此重现)
|
||||
另一方面,如果作业的工作集大于可用内存,则排序方法的优点是可以高效地使用磁盘。这与我们在 “[SSTables 和 LSM 树](ch3.md#SSTables和LSM树)” 中讨论过的原理是一样的:数据块可以在内存中排序并作为段文件写入磁盘,然后多个排序好的段可以合并为一个更大的排序文件。 归并排序具有在磁盘上运行良好的顺序访问模式。 (请记住,针对顺序 I/O 进行优化是 [第三章](ch3.md) 中反复出现的主题,相同的模式在此重现)
|
||||
|
||||
GNU Coreutils(Linux)中的`sort `程序通过溢出至磁盘的方式来自动应对大于内存的数据集,并能同时使用多个CPU核进行并行排序【9】。这意味着我们之前看到的简单的Unix命令链很容易伸缩至大数据集,且不会耗尽内存。瓶颈可能是从磁盘读取输入文件的速度。
|
||||
GNU Coreutils(Linux)中的 `sort` 程序通过溢出至磁盘的方式来自动应对大于内存的数据集,并能同时使用多个 CPU 核进行并行排序【9】。这意味着我们之前看到的简单的 Unix 命令链很容易伸缩至大数据集,且不会耗尽内存。瓶颈可能是从磁盘读取输入文件的速度。
|
||||
|
||||
|
||||
### Unix哲学
|
||||
|
||||
我们可以非常容易地使用前一个例子中的一系列命令来分析日志文件,这并非巧合:事实上,这实际上是Unix的关键设计思想之一,而且它直至今天也仍然令人讶异地重要。让我们更深入地研究一下,以便从Unix中借鉴一些想法【10】。
|
||||
我们可以非常容易地使用前一个例子中的一系列命令来分析日志文件,这并非巧合:事实上,这实际上是 Unix 的关键设计思想之一,而且它直至今天也仍然令人讶异地重要。让我们更深入地研究一下,以便从 Unix 中借鉴一些想法【10】。
|
||||
|
||||
Unix管道的发明者道格·麦克罗伊(Doug McIlroy)在1964年首先描述了这种情况【11】:“我们需要一种类似园艺胶管的方式来拼接程序 —— 当我们需要将消息从一个程序传递另一个程序时,直接接上去就行。I/O应该也按照这种方式进行“。水管的类比仍然在生效,通过管道连接程序的想法成为了现在被称为**Unix哲学**的一部分 —— 这一组设计原则在Unix用户与开发者之间流行起来,该哲学在1978年表述如下【12,13】:
|
||||
Unix 管道的发明者道格・麦克罗伊(Doug McIlroy)在 1964 年首先描述了这种情况【11】:“我们需要一种类似园艺胶管的方式来拼接程序 —— 当我们需要将消息从一个程序传递另一个程序时,直接接上去就行。I/O 应该也按照这种方式进行 “。水管的类比仍然在生效,通过管道连接程序的想法成为了现在被称为 **Unix 哲学** 的一部分 —— 这一组设计原则在 Unix 用户与开发者之间流行起来,该哲学在 1978 年表述如下【12,13】:
|
||||
|
||||
1. 让每个程序都做好一件事。要做一件新的工作,写一个新程序,而不是通过添加“功能”让老程序复杂化。
|
||||
1. 让每个程序都做好一件事。要做一件新的工作,写一个新程序,而不是通过添加 “功能” 让老程序复杂化。
|
||||
2. 期待每个程序的输出成为另一个程序的输入。不要将无关信息混入输出。避免使用严格的列数据或二进制输入格式。不要坚持交互式输入。
|
||||
3. 设计和构建软件时,即使是操作系统,也让它们能够尽早地被试用,最好在几周内完成。不要犹豫,扔掉笨拙的部分,重建它们。
|
||||
4. 优先使用工具来减轻编程任务,即使必须曲线救国编写工具,且在用完后很可能要扔掉大部分。
|
||||
|
||||
这种方法 —— 自动化,快速原型设计,增量式迭代,对实验友好,将大型项目分解成可管理的块 —— 听起来非常像今天的敏捷开发和DevOps运动。奇怪的是,四十年来变化不大。
|
||||
这种方法 —— 自动化,快速原型设计,增量式迭代,对实验友好,将大型项目分解成可管理的块 —— 听起来非常像今天的敏捷开发和 DevOps 运动。奇怪的是,四十年来变化不大。
|
||||
|
||||
`sort`工具是一个很好的例子。可以说它比大多数编程语言标准库中的实现(它们不会利用磁盘或使用多线程,即使这样做有很大好处)要更好。然而,单独使用`sort` 几乎没什么用。它只能与其他Unix工具(如`uniq`)结合使用。
|
||||
`sort` 工具是一个很好的例子。可以说它比大多数编程语言标准库中的实现(它们不会利用磁盘或使用多线程,即使这样做有很大好处)要更好。然而,单独使用 `sort` 几乎没什么用。它只能与其他 Unix 工具(如 `uniq`)结合使用。
|
||||
|
||||
像 `bash`这样的Unix shell可以让我们轻松地将这些小程序组合成令人讶异的强大数据处理任务。尽管这些程序中有很多是由不同人群编写的,但它们可以灵活地结合在一起。 Unix如何实现这种可组合性?
|
||||
像 `bash` 这样的 Unix shell 可以让我们轻松地将这些小程序组合成令人讶异的强大数据处理任务。尽管这些程序中有很多是由不同人群编写的,但它们可以灵活地结合在一起。 Unix 如何实现这种可组合性?
|
||||
|
||||
#### 统一的接口
|
||||
|
||||
如果你希望一个程序的输出成为另一个程序的输入,那意味着这些程序必须使用相同的数据格式 —— 换句话说,一个兼容的接口。如果你希望能够将任何程序的输出连接到任何程序的输入,那意味着所有程序必须使用相同的I/O接口。
|
||||
如果你希望一个程序的输出成为另一个程序的输入,那意味着这些程序必须使用相同的数据格式 —— 换句话说,一个兼容的接口。如果你希望能够将任何程序的输出连接到任何程序的输入,那意味着所有程序必须使用相同的 I/O 接口。
|
||||
|
||||
在Unix中,这种接口是一个**文件**(file,更准确地说,是一个文件描述符)。一个文件只是一串有序的字节序列。因为这是一个非常简单的接口,所以可以使用相同的接口来表示许多不同的东西:文件系统上的真实文件,到另一个进程(Unix套接字,stdin,stdout)的通信通道,设备驱动程序(比如`/dev/audio`或`/dev/lp0`),表示TCP连接的套接字等等。很容易将这些设计视为理所当然的,但实际上能让这些差异巨大的东西共享一个统一的接口是非常厉害的,这使得它们可以很容易地连接在一起[^ii]。
|
||||
在 Unix 中,这种接口是一个 **文件**(file,更准确地说,是一个文件描述符)。一个文件只是一串有序的字节序列。因为这是一个非常简单的接口,所以可以使用相同的接口来表示许多不同的东西:文件系统上的真实文件,到另一个进程(Unix 套接字,stdin,stdout)的通信通道,设备驱动程序(比如 `/dev/audio` 或 `/dev/lp0`),表示 TCP 连接的套接字等等。很容易将这些设计视为理所当然的,但实际上能让这些差异巨大的东西共享一个统一的接口是非常厉害的,这使得它们可以很容易地连接在一起 [^ii]。
|
||||
|
||||
[^ii]: 统一接口的另一个例子是URL和HTTP,这是Web的基石。 一个URL标识一个网站上的一个特定的东西(资源),你可以链接到任何其他网站的任何网址。 具有网络浏览器的用户因此可以通过跟随链接在网站之间无缝跳转,即使服务器可能由完全不相关的组织维护。 这个原则现在似乎非常明显,但它却是网络取能取得今天成就的关键。 之前的系统并不是那么统一:例如,在公告板系统(BBS)时代,每个系统都有自己的电话号码和波特率配置。 从一个BBS到另一个BBS的引用必须以电话号码和调制解调器设置的形式;用户将不得不挂断,拨打其他BBS,然后手动找到他们正在寻找的信息。 直接链接到另一个BBS内的一些内容当时是不可能的。
|
||||
[^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 工具一样流畅地运行程序是一种例外,而不是规范。
|
||||
|
||||
即使是具有**相同数据模型**的数据库,将数据从一种数据库导出再导入到另一种数据库也并不容易。缺乏整合导致了数据的**巴尔干化**[^译注i]。
|
||||
即使是具有 **相同数据模型** 的数据库,将数据从一种数据库导出再导入到另一种数据库也并不容易。缺乏整合导致了数据的 **巴尔干化**[^译注i]。
|
||||
|
||||
[^译注i]: **巴尔干化(Balkanization)** 是一个常带有贬义的地缘政治学术语,其定义为:一个国家或政区分裂成多个互相敌对的国家或政区的过程。
|
||||
|
||||
|
||||
#### 逻辑与布线相分离
|
||||
|
||||
Unix工具的另一个特点是使用标准输入(`stdin`)和标准输出(`stdout`)。如果你运行一个程序,而不指定任何其他的东西,标准输入来自键盘,标准输出指向屏幕。但是,你也可以从文件输入和/或将输出重定向到文件。管道允许你将一个进程的标准输出附加到另一个进程的标准输入(有个小内存缓冲区,而不需要将整个中间数据流写入磁盘)。
|
||||
Unix 工具的另一个特点是使用标准输入(`stdin`)和标准输出(`stdout`)。如果你运行一个程序,而不指定任何其他的东西,标准输入来自键盘,标准输出指向屏幕。但是,你也可以从文件输入和 / 或将输出重定向到文件。管道允许你将一个进程的标准输出附加到另一个进程的标准输入(有个小内存缓冲区,而不需要将整个中间数据流写入磁盘)。
|
||||
|
||||
如果需要,程序仍然可以直接读取和写入文件,但Unix方法在程序不关心特定的文件路径、只使用标准输入和标准输出时效果最好。这允许shell用户以任何他们想要的方式连接输入和输出;该程序不知道或不关心输入来自哪里以及输出到哪里。 (人们可以说这是一种**松耦合(loose coupling)**,**晚期绑定(late binding)**【15】或**控制反转(inversion of control)**【16】)。将输入/输出布线与程序逻辑分开,可以将小工具组合成更大的系统。
|
||||
如果需要,程序仍然可以直接读取和写入文件,但 Unix 方法在程序不关心特定的文件路径、只使用标准输入和标准输出时效果最好。这允许 shell 用户以任何他们想要的方式连接输入和输出;该程序不知道或不关心输入来自哪里以及输出到哪里。 (人们可以说这是一种 **松耦合(loose coupling)**,**晚期绑定(late binding)**【15】或 **控制反转(inversion of control)**【16】)。将输入 / 输出布线与程序逻辑分开,可以将小工具组合成更大的系统。
|
||||
|
||||
你甚至可以编写自己的程序,并将它们与操作系统提供的工具组合在一起。你的程序只需要从标准输入读取输入,并将输出写入标准输出,它就可以加入数据处理的管道中。在日志分析示例中,你可以编写一个将Usage-Agent字符串转换为更灵敏的浏览器标识符,或者将IP地址转换为国家代码的工具,并将其插入管道。`sort`程序并不关心它是否与操作系统的另一部分或者你写的程序通信。
|
||||
你甚至可以编写自己的程序,并将它们与操作系统提供的工具组合在一起。你的程序只需要从标准输入读取输入,并将输出写入标准输出,它就可以加入数据处理的管道中。在日志分析示例中,你可以编写一个将 Usage-Agent 字符串转换为更灵敏的浏览器标识符,或者将 IP 地址转换为国家代码的工具,并将其插入管道。`sort` 程序并不关心它是否与操作系统的另一部分或者你写的程序通信。
|
||||
|
||||
但是,使用`stdin`和`stdout`能做的事情是有限的。需要多个输入或输出的程序虽然可能,却非常棘手。你没法将程序的输出管道连接至网络连接中【17,18】[^iii] 。如果程序直接打开文件进行读取和写入,或者将另一个程序作为子进程启动,或者打开网络连接,那么I/O的布线就取决于程序本身了。它仍然可以被配置(例如通过命令行选项),但在Shell中对输入和输出进行布线的灵活性就少了。
|
||||
但是,使用 `stdin` 和 `stdout` 能做的事情是有限的。需要多个输入或输出的程序虽然可能,却非常棘手。你没法将程序的输出管道连接至网络连接中【17,18】[^iii] 。如果程序直接打开文件进行读取和写入,或者将另一个程序作为子进程启动,或者打开网络连接,那么 I/O 的布线就取决于程序本身了。它仍然可以被配置(例如通过命令行选项),但在 Shell 中对输入和输出进行布线的灵活性就少了。
|
||||
|
||||
[^iii]: 除了使用一个单独的工具,如`netcat`或`curl`。 Unix起初试图将所有东西都表示为文件,但是BSD套接字API偏离了这个惯例【17】。研究用操作系统Plan 9和Inferno在使用文件方面更加一致:它们将TCP连接表示为`/net/tcp`中的文件【18】。
|
||||
[^iii]: 除了使用一个单独的工具,如 `netcat` 或 `curl`。 Unix 起初试图将所有东西都表示为文件,但是 BSD 套接字 API 偏离了这个惯例【17】。研究用操作系统 Plan 9 和 Inferno 在使用文件方面更加一致:它们将 TCP 连接表示为 `/net/tcp` 中的文件【18】。
|
||||
|
||||
|
||||
#### 透明度和实验
|
||||
|
||||
使Unix工具如此成功的部分原因是,它们使查看正在发生的事情变得非常容易:
|
||||
使 Unix 工具如此成功的部分原因是,它们使查看正在发生的事情变得非常容易:
|
||||
|
||||
- Unix命令的输入文件通常被视为不可变的。这意味着你可以随意运行命令,尝试各种命令行选项,而不会损坏输入文件。
|
||||
- 你可以在任何时候结束管道,将管道输出到`less`,然后查看它是否具有预期的形式。这种检查能力对调试非常有用。
|
||||
- Unix 命令的输入文件通常被视为不可变的。这意味着你可以随意运行命令,尝试各种命令行选项,而不会损坏输入文件。
|
||||
- 你可以在任何时候结束管道,将管道输出到 `less`,然后查看它是否具有预期的形式。这种检查能力对调试非常有用。
|
||||
- 你可以将一个流水线阶段的输出写入文件,并将该文件用作下一阶段的输入。这使你可以重新启动后面的阶段,而无需重新运行整个管道。
|
||||
|
||||
因此,与关系数据库的查询优化器相比,即使Unix工具非常简单,但仍然非常有用,特别是对于实验而言。
|
||||
因此,与关系数据库的查询优化器相比,即使 Unix 工具非常简单,但仍然非常有用,特别是对于实验而言。
|
||||
|
||||
然而,Unix工具的最大局限在于它们只能在一台机器上运行 —— 而Hadoop这样的工具即应运而生。
|
||||
然而,Unix 工具的最大局限在于它们只能在一台机器上运行 —— 而 Hadoop 这样的工具即应运而生。
|
||||
|
||||
|
||||
## MapReduce和分布式文件系统
|
||||
|
||||
MapReduce有点像Unix工具,但分布在数千台机器上。像Unix工具一样,它相当简单粗暴,但令人惊异地管用。一个MapReduce作业可以和一个Unix进程相类比:它接受一个或多个输入,并产生一个或多个输出。
|
||||
MapReduce 有点像 Unix 工具,但分布在数千台机器上。像 Unix 工具一样,它相当简单粗暴,但令人惊异地管用。一个 MapReduce 作业可以和一个 Unix 进程相类比:它接受一个或多个输入,并产生一个或多个输出。
|
||||
|
||||
和大多数Unix工具一样,运行MapReduce作业通常不会修改输入,除了生成输出外没有任何副作用。输出文件以连续的方式一次性写入(一旦写入文件,不会修改任何现有的文件部分)。
|
||||
和大多数 Unix 工具一样,运行 MapReduce 作业通常不会修改输入,除了生成输出外没有任何副作用。输出文件以连续的方式一次性写入(一旦写入文件,不会修改任何现有的文件部分)。
|
||||
|
||||
虽然Unix工具使用`stdin`和`stdout`作为输入和输出,但MapReduce作业在分布式文件系统上读写文件。在Hadoop的MapReduce实现中,该文件系统被称为**HDFS(Hadoop分布式文件系统)**,一个Google文件系统(GFS)的开源实现【19】。
|
||||
虽然 Unix 工具使用 `stdin` 和 `stdout` 作为输入和输出,但 MapReduce 作业在分布式文件系统上读写文件。在 Hadoop 的 MapReduce 实现中,该文件系统被称为 **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,可以将计算任务安排在存储特定文件副本的计算机上运行,而对象存储通常将存储和计算分开。如果网络带宽是一个瓶颈,从本地磁盘读取有性能优势。但是请注意,如果使用纠删码(Erasure Coding),则会丢失局部性,因为来自多台机器的数据必须进行合并以重建原始文件【20】。
|
||||
[^iv]: 一个不同之处在于,对于 HDFS,可以将计算任务安排在存储特定文件副本的计算机上运行,而对象存储通常将存储和计算分开。如果网络带宽是一个瓶颈,从本地磁盘读取有性能优势。但是请注意,如果使用纠删码(Erasure Coding),则会丢失局部性,因为来自多台机器的数据必须进行合并以重建原始文件【20】。
|
||||
|
||||
与网络连接存储(NAS)和存储区域网络(SAN)架构的共享磁盘方法相比,HDFS基于**无共享**原则(请参阅[第二部分](part-ii.md)的介绍)。共享磁盘存储由集中式存储设备实现,通常使用定制硬件和专用网络基础设施(如光纤通道)。而另一方面,无共享方法不需要特殊的硬件,只需要通过传统数据中心网络连接的计算机。
|
||||
与网络连接存储(NAS)和存储区域网络(SAN)架构的共享磁盘方法相比,HDFS 基于 **无共享** 原则(请参阅 [第二部分](part-ii.md) 的介绍)。共享磁盘存储由集中式存储设备实现,通常使用定制硬件和专用网络基础设施(如光纤通道)。而另一方面,无共享方法不需要特殊的硬件,只需要通过传统数据中心网络连接的计算机。
|
||||
|
||||
HDFS在每台机器上运行了一个守护进程,它对外暴露网络服务,允许其他节点访问存储在该机器上的文件(假设数据中心中的每台通用计算机都挂载着一些磁盘)。名为**NameNode**的中央服务器会跟踪哪个文件块存储在哪台机器上。因此,HDFS在概念上创建了一个大型文件系统,可以使用所有运行有守护进程的机器的磁盘。
|
||||
HDFS 在每台机器上运行了一个守护进程,它对外暴露网络服务,允许其他节点访问存储在该机器上的文件(假设数据中心中的每台通用计算机都挂载着一些磁盘)。名为 **NameNode** 的中央服务器会跟踪哪个文件块存储在哪台机器上。因此,HDFS 在概念上创建了一个大型文件系统,可以使用所有运行有守护进程的机器的磁盘。
|
||||
|
||||
为了容忍机器和磁盘故障,文件块被复制到多台机器上。复制可能意味着多个机器上的相同数据的多个副本,如[第五章](ch5.md)中所述,或者诸如Reed-Solomon码这样的纠删码方案,它能以比完全复制更低的存储开销来支持恢复丢失的数据【20,22】。这些技术与RAID相似,后者可以在连接到同一台机器的多个磁盘上提供冗余;区别在于在分布式文件系统中,文件访问和复制是在传统的数据中心网络上完成的,没有特殊的硬件。
|
||||
为了容忍机器和磁盘故障,文件块被复制到多台机器上。复制可能意味着多个机器上的相同数据的多个副本,如 [第五章](ch5.md) 中所述,或者诸如 Reed-Solomon 码这样的纠删码方案,它能以比完全复制更低的存储开销来支持恢复丢失的数据【20,22】。这些技术与 RAID 相似,后者可以在连接到同一台机器的多个磁盘上提供冗余;区别在于在分布式文件系统中,文件访问和复制是在传统的数据中心网络上完成的,没有特殊的硬件。
|
||||
|
||||
HDFS的可伸缩性已经很不错了:在撰写本书时,最大的HDFS部署运行在上万台机器上,总存储容量达数百PB【23】。如此大的规模已经变得可行,因为使用商品硬件和开源软件的HDFS上的数据存储和访问成本远低于在专用存储设备上支持同等容量的成本【24】。
|
||||
HDFS 的可伸缩性已经很不错了:在撰写本书时,最大的 HDFS 部署运行在上万台机器上,总存储容量达数百 PB【23】。如此大的规模已经变得可行,因为使用商品硬件和开源软件的 HDFS 上的数据存储和访问成本远低于在专用存储设备上支持同等容量的成本【24】。
|
||||
|
||||
### MapReduce作业执行
|
||||
|
||||
MapReduce是一个编程框架,你可以使用它编写代码来处理HDFS等分布式文件系统中的大型数据集。理解它的最简单方法是参考“[简单日志分析](#简单日志分析)”中的Web服务器日志分析示例。MapReduce中的数据处理模式与此示例非常相似:
|
||||
MapReduce 是一个编程框架,你可以使用它编写代码来处理 HDFS 等分布式文件系统中的大型数据集。理解它的最简单方法是参考 “[简单日志分析](#简单日志分析)” 中的 Web 服务器日志分析示例。MapReduce 中的数据处理模式与此示例非常相似:
|
||||
|
||||
1. 读取一组输入文件,并将其分解成**记录(records)**。在Web服务器日志示例中,每条记录都是日志中的一行(即`\n`是记录分隔符)。
|
||||
2. 调用Mapper函数,从每条输入记录中提取一对键值。在前面的例子中,Mapper函数是`awk '{print $7}'`:它提取URL(`$7`)作为键,并将值留空。
|
||||
3. 按键排序所有的键值对。在日志的例子中,这由第一个`sort`命令完成。
|
||||
4. 调用Reducer函数遍历排序后的键值对。如果同一个键出现多次,排序使它们在列表中相邻,所以很容易组合这些值而不必在内存中保留很多状态。在前面的例子中,Reducer是由`uniq -c`命令实现的,该命令使用相同的键来统计相邻记录的数量。
|
||||
1. 读取一组输入文件,并将其分解成 **记录(records)**。在 Web 服务器日志示例中,每条记录都是日志中的一行(即 `\n` 是记录分隔符)。
|
||||
2. 调用 Mapper 函数,从每条输入记录中提取一对键值。在前面的例子中,Mapper 函数是 `awk '{print $7}'`:它提取 URL(`$7`)作为键,并将值留空。
|
||||
3. 按键排序所有的键值对。在日志的例子中,这由第一个 `sort` 命令完成。
|
||||
4. 调用 Reducer 函数遍历排序后的键值对。如果同一个键出现多次,排序使它们在列表中相邻,所以很容易组合这些值而不必在内存中保留很多状态。在前面的例子中,Reducer 是由 `uniq -c` 命令实现的,该命令使用相同的键来统计相邻记录的数量。
|
||||
|
||||
这四个步骤可以作为一个MapReduce作业执行。步骤2(Map)和4(Reduce)是你编写自定义数据处理代码的地方。步骤1(将文件分解成记录)由输入格式解析器处理。步骤3中的排序步骤隐含在MapReduce中 —— 你不必编写它,因为Mapper的输出始终在送往Reducer之前进行排序。
|
||||
这四个步骤可以作为一个 MapReduce 作业执行。步骤 2(Map)和 4(Reduce)是你编写自定义数据处理代码的地方。步骤 1(将文件分解成记录)由输入格式解析器处理。步骤 3 中的排序步骤隐含在 MapReduce 中 —— 你不必编写它,因为 Mapper 的输出始终在送往 Reducer 之前进行排序。
|
||||
|
||||
要创建MapReduce作业,你需要实现两个回调函数,Mapper和Reducer,其行为如下(请参阅“[MapReduce 查询](ch2.md#MapReduce查询)”):
|
||||
要创建 MapReduce 作业,你需要实现两个回调函数,Mapper 和 Reducer,其行为如下(请参阅 “[MapReduce 查询](ch2.md#MapReduce查询)”):
|
||||
|
||||
* Mapper
|
||||
|
||||
Mapper会在每条输入记录上调用一次,其工作是从输入记录中提取键值。对于每个输入,它可以生成任意数量的键值对(包括None)。它不会保留从一个输入记录到下一个记录的任何状态,因此每个记录都是独立处理的。
|
||||
Mapper 会在每条输入记录上调用一次,其工作是从输入记录中提取键值。对于每个输入,它可以生成任意数量的键值对(包括 None)。它不会保留从一个输入记录到下一个记录的任何状态,因此每个记录都是独立处理的。
|
||||
|
||||
* Reducer
|
||||
|
||||
MapReduce框架拉取由Mapper生成的键值对,收集属于同一个键的所有值,并在这组值上迭代调用Reducer。 Reducer可以产生输出记录(例如相同URL的出现次数)。
|
||||
MapReduce 框架拉取由 Mapper 生成的键值对,收集属于同一个键的所有值,并在这组值上迭代调用 Reducer。 Reducer 可以产生输出记录(例如相同 URL 的出现次数)。
|
||||
|
||||
在Web服务器日志的例子中,我们在第5步中有第二个`sort`命令,它按请求数对URL进行排序。在MapReduce中,如果你需要第二个排序阶段,则可以通过编写第二个MapReduce作业并将第一个作业的输出用作第二个作业的输入来实现它。这样看来,Mapper的作用是将数据放入一个适合排序的表单中,并且Reducer的作用是处理已排序的数据。
|
||||
在 Web 服务器日志的例子中,我们在第 5 步中有第二个 `sort` 命令,它按请求数对 URL 进行排序。在 MapReduce 中,如果你需要第二个排序阶段,则可以通过编写第二个 MapReduce 作业并将第一个作业的输出用作第二个作业的输入来实现它。这样看来,Mapper 的作用是将数据放入一个适合排序的表单中,并且 Reducer 的作用是处理已排序的数据。
|
||||
|
||||
#### 分布式执行MapReduce
|
||||
|
||||
MapReduce与Unix命令管道的主要区别在于,MapReduce可以在多台机器上并行执行计算,而无需编写代码来显式处理并行问题。Mapper和Reducer一次只能处理一条记录;它们不需要知道它们的输入来自哪里,或者输出去往什么地方,所以框架可以处理在机器之间移动数据的复杂性。
|
||||
MapReduce 与 Unix 命令管道的主要区别在于,MapReduce 可以在多台机器上并行执行计算,而无需编写代码来显式处理并行问题。Mapper 和 Reducer 一次只能处理一条记录;它们不需要知道它们的输入来自哪里,或者输出去往什么地方,所以框架可以处理在机器之间移动数据的复杂性。
|
||||
|
||||
在分布式计算中可以使用标准的Unix工具作为Mapper和Reducer【25】,但更常见的是,它们被实现为传统编程语言的函数。在Hadoop MapReduce中,Mapper和Reducer都是实现特定接口的Java类。在MongoDB和CouchDB中,Mapper和Reducer都是JavaScript函数(请参阅“[MapReduce 查询](ch2.md#MapReduce查询)”)。
|
||||
在分布式计算中可以使用标准的 Unix 工具作为 Mapper 和 Reducer【25】,但更常见的是,它们被实现为传统编程语言的函数。在 Hadoop MapReduce 中,Mapper 和 Reducer 都是实现特定接口的 Java 类。在 MongoDB 和 CouchDB 中,Mapper 和 Reducer 都是 JavaScript 函数(请参阅 “[MapReduce 查询](ch2.md#MapReduce查询)”)。
|
||||
|
||||
[图10-1](img/fig10-1.png)显示了Hadoop MapReduce作业中的数据流。其并行化基于分区(请参阅[第六章](ch6.md)):作业的输入通常是HDFS中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理map任务([图10-1](img/fig10-1.png)中的m1,m2和m3标记)。
|
||||
[图 10-1](img/fig10-1.png) 显示了 Hadoop MapReduce 作业中的数据流。其并行化基于分区(请参阅 [第六章](ch6.md)):作业的输入通常是 HDFS 中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理 map 任务([图 10-1](img/fig10-1.png) 中的 m1,m2 和 m3 标记)。
|
||||
|
||||
每个输入文件的大小通常是数百兆字节。 MapReduce调度器(图中未显示)试图在其中一台存储输入文件副本的机器上运行每个Mapper,只要该机器有足够的备用RAM和CPU资源来运行Mapper任务【26】。这个原则被称为**将计算放在数据附近**【27】:它节省了通过网络复制输入文件的开销,减少网络负载并增加局部性。
|
||||
每个输入文件的大小通常是数百兆字节。 MapReduce 调度器(图中未显示)试图在其中一台存储输入文件副本的机器上运行每个 Mapper,只要该机器有足够的备用 RAM 和 CPU 资源来运行 Mapper 任务【26】。这个原则被称为 **将计算放在数据附近**【27】:它节省了通过网络复制输入文件的开销,减少网络负载并增加局部性。
|
||||
|
||||
![](img/fig10-1.png)
|
||||
|
||||
**图10-1 具有三个Mapper和三个Reducer的MapReduce任务**
|
||||
**图 10-1 具有三个 Mapper 和三个 Reducer 的 MapReduce 任务**
|
||||
|
||||
在大多数情况下,应该在Mapper任务中运行的应用代码在将要运行它的机器上还不存在,所以MapReduce框架首先将代码(例如Java程序中的JAR文件)复制到适当的机器。然后启动Map任务并开始读取输入文件,一次将一条记录传入Mapper回调函数。Mapper的输出由键值对组成。
|
||||
在大多数情况下,应该在 Mapper 任务中运行的应用代码在将要运行它的机器上还不存在,所以 MapReduce 框架首先将代码(例如 Java 程序中的 JAR 文件)复制到适当的机器。然后启动 Map 任务并开始读取输入文件,一次将一条记录传入 Mapper 回调函数。Mapper 的输出由键值对组成。
|
||||
|
||||
计算的Reduce端也被分区。虽然Map任务的数量由输入文件块的数量决定,但Reducer的任务的数量是由作业作者配置的(它可以不同于Map任务的数量)。为了确保具有相同键的所有键值对最终落在相同的Reducer处,框架使用键的散列值来确定哪个Reduce任务应该接收到特定的键值对(请参阅“[根据键的散列分区](ch6.md#根据键的散列分区)”)。
|
||||
计算的 Reduce 端也被分区。虽然 Map 任务的数量由输入文件块的数量决定,但 Reducer 的任务的数量是由作业作者配置的(它可以不同于 Map 任务的数量)。为了确保具有相同键的所有键值对最终落在相同的 Reducer 处,框架使用键的散列值来确定哪个 Reduce 任务应该接收到特定的键值对(请参阅 “[根据键的散列分区](ch6.md#根据键的散列分区)”)。
|
||||
|
||||
键值对必须进行排序,但数据集可能太大,无法在单台机器上使用常规排序算法进行排序。相反,分类是分阶段进行的。首先每个Map任务都按照Reducer对输出进行分区。每个分区都被写入Mapper程序的本地磁盘,使用的技术与我们在“[SSTables与LSM树](ch3.md#SSTables与LSM树)”中讨论的类似。
|
||||
键值对必须进行排序,但数据集可能太大,无法在单台机器上使用常规排序算法进行排序。相反,分类是分阶段进行的。首先每个 Map 任务都按照 Reducer 对输出进行分区。每个分区都被写入 Mapper 程序的本地磁盘,使用的技术与我们在 “[SSTables 与 LSM 树](ch3.md#SSTables与LSM树)” 中讨论的类似。
|
||||
|
||||
只要当Mapper读取完输入文件,并写完排序后的输出文件,MapReduce调度器就会通知Reducer可以从该Mapper开始获取输出文件。Reducer连接到每个Mapper,并下载自己相应分区的有序键值对文件。按Reducer分区,排序,从Mapper向Reducer复制分区数据,这一整个过程被称为**混洗(shuffle)**【26】(一个容易混淆的术语 —— 不像洗牌,在MapReduce中的混洗没有随机性)。
|
||||
只要当 Mapper 读取完输入文件,并写完排序后的输出文件,MapReduce 调度器就会通知 Reducer 可以从该 Mapper 开始获取输出文件。Reducer 连接到每个 Mapper,并下载自己相应分区的有序键值对文件。按 Reducer 分区,排序,从 Mapper 向 Reducer 复制分区数据,这一整个过程被称为 **混洗(shuffle)**【26】(一个容易混淆的术语 —— 不像洗牌,在 MapReduce 中的混洗没有随机性)。
|
||||
|
||||
Reduce任务从Mapper获取文件,并将它们合并在一起,并保留有序特性。因此,如果不同的Mapper生成了键相同的记录,则在Reducer的输入中,这些记录将会相邻。
|
||||
Reduce 任务从 Mapper 获取文件,并将它们合并在一起,并保留有序特性。因此,如果不同的 Mapper 生成了键相同的记录,则在 Reducer 的输入中,这些记录将会相邻。
|
||||
|
||||
Reducer调用时会收到一个键,和一个迭代器作为参数,迭代器会顺序地扫过所有具有该键的记录(因为在某些情况可能无法完全放入内存中)。Reducer可以使用任意逻辑来处理这些记录,并且可以生成任意数量的输出记录。这些输出记录会写入分布式文件系统上的文件中(通常是在跑Reducer的机器本地磁盘上留一份,并在其他机器上留几份副本)。
|
||||
Reducer 调用时会收到一个键,和一个迭代器作为参数,迭代器会顺序地扫过所有具有该键的记录(因为在某些情况可能无法完全放入内存中)。Reducer 可以使用任意逻辑来处理这些记录,并且可以生成任意数量的输出记录。这些输出记录会写入分布式文件系统上的文件中(通常是在跑 Reducer 的机器本地磁盘上留一份,并在其他机器上留几份副本)。
|
||||
|
||||
#### MapReduce工作流
|
||||
|
||||
单个MapReduce作业可以解决的问题范围很有限。以日志分析为例,单个MapReduce作业可以确定每个URL的页面浏览次数,但无法确定最常见的URL,因为这需要第二轮排序。
|
||||
单个 MapReduce 作业可以解决的问题范围很有限。以日志分析为例,单个 MapReduce 作业可以确定每个 URL 的页面浏览次数,但无法确定最常见的 URL,因为这需要第二轮排序。
|
||||
|
||||
因此将MapReduce作业链接成为**工作流(workflow)** 中是极为常见的,例如,一个作业的输出成为下一个作业的输入。 Hadoop MapReduce框架对工作流没有特殊支持,所以这个链是通过目录名隐式实现的:第一个作业必须将其输出配置为HDFS中的指定目录,第二个作业必须将其输入配置为从同一个目录。从MapReduce框架的角度来看,这是两个独立的作业。
|
||||
因此将 MapReduce 作业链接成为 **工作流(workflow)** 中是极为常见的,例如,一个作业的输出成为下一个作业的输入。Hadoop MapReduce 框架对工作流没有特殊支持,所以这个链是通过目录名隐式实现的:第一个作业必须将其输出配置为 HDFS 中的指定目录,第二个作业必须将其输入配置为从同一个目录。从 MapReduce 框架的角度来看,这是两个独立的作业。
|
||||
|
||||
因此,被链接的MapReduce作业并没有那么像Unix命令管道(它直接将一个进程的输出作为另一个进程的输入,仅用一个很小的内存缓冲区)。它更像是一系列命令,其中每个命令的输出写入临时文件,下一个命令从临时文件中读取。这种设计有利也有弊,我们将在“[物化中间状态](#物化中间状态)”中讨论。
|
||||
因此,被链接的 MapReduce 作业并没有那么像 Unix 命令管道(它直接将一个进程的输出作为另一个进程的输入,仅用一个很小的内存缓冲区)。它更像是一系列命令,其中每个命令的输出写入临时文件,下一个命令从临时文件中读取。这种设计有利也有弊,我们将在 “[物化中间状态](#物化中间状态)” 中讨论。
|
||||
|
||||
只有当作业成功完成后,批处理作业的输出才会被视为有效的(MapReduce会丢弃失败作业的部分输出)。因此,工作流中的一项作业只有在先前的作业 —— 即生产其输入的作业 —— 成功完成后才能开始。为了处理这些作业之间的依赖,有很多针对Hadoop的工作流调度器被开发出来,包括Oozie,Azkaban,Luigi,Airflow和Pinball 【28】。
|
||||
只有当作业成功完成后,批处理作业的输出才会被视为有效的(MapReduce 会丢弃失败作业的部分输出)。因此,工作流中的一项作业只有在先前的作业 —— 即生产其输入的作业 —— 成功完成后才能开始。为了处理这些作业之间的依赖,有很多针对 Hadoop 的工作流调度器被开发出来,包括 Oozie、Azkaban、Luigi、Airflow 和 Pinball 【28】。
|
||||
|
||||
这些调度程序还具有管理功能,在维护大量批处理作业时非常有用。在构建推荐系统时,由50到100个MapReduce作业组成的工作流是常见的【29】。而在大型组织中,许多不同的团队可能运行不同的作业来读取彼此的输出。工具支持对于管理这样复杂的数据流而言非常重要。
|
||||
这些调度程序还具有管理功能,在维护大量批处理作业时非常有用。在构建推荐系统时,由 50 到 100 个 MapReduce 作业组成的工作流是常见的【29】。而在大型组织中,许多不同的团队可能运行不同的作业来读取彼此的输出。工具支持对于管理这样复杂的数据流而言非常重要。
|
||||
|
||||
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侧连接与分组
|
||||
|
||||
我们在[第二章](ch2.md)中讨论了数据模型和查询语言的连接,但是我们还没有深入探讨连接是如何实现的。现在是我们再次捡起这条线索的时候了。
|
||||
我们在 [第二章](ch2.md) 中讨论了数据模型和查询语言的连接,但是我们还没有深入探讨连接是如何实现的。现在是我们再次捡起这条线索的时候了。
|
||||
|
||||
在许多数据集中,一条记录与另一条记录存在关联是很常见的:关系模型中的**外键**,文档模型中的**文档引用**或图模型中的**边**。当你需要同时访问这一关联的两侧(持有引用的记录与被引用的记录)时,连接就是必须的。正如[第二章](ch2.md)所讨论的,非规范化可以减少对连接的需求,但通常无法将其完全移除[^v]。
|
||||
在许多数据集中,一条记录与另一条记录存在关联是很常见的:关系模型中的 **外键**,文档模型中的 **文档引用** 或图模型中的 **边**。当你需要同时访问这一关联的两侧(持有引用的记录与被引用的记录)时,连接就是必须的。正如 [第二章](ch2.md) 所讨论的,非规范化可以减少对连接的需求,但通常无法将其完全移除 [^v]。
|
||||
|
||||
[^v]: 我们在本书中讨论的连接通常是等值连接,即最常见的连接类型,其中记录通过与其他记录在特定字段(例如ID)中具有**相同值**相关联。有些数据库支持更通用的连接类型,例如使用小于运算符而不是等号运算符,但是我们没有地方来讲这些东西。
|
||||
[^v]: 我们在本书中讨论的连接通常是等值连接,即最常见的连接类型,其中记录通过与其他记录在特定字段(例如 ID)中具有 **相同值** 相关联。有些数据库支持更通用的连接类型,例如使用小于运算符而不是等号运算符,但是我们没有地方来讲这些东西。
|
||||
|
||||
在数据库中,如果执行只涉及少量记录的查询,数据库通常会使用**索引**来快速定位感兴趣的记录(请参阅[第三章](ch3.md))。如果查询涉及到连接,则可能涉及到查找多个索引。然而MapReduce没有索引的概念 —— 至少在通常意义上没有。
|
||||
在数据库中,如果执行只涉及少量记录的查询,数据库通常会使用 **索引** 来快速定位感兴趣的记录(请参阅 [第三章](ch3.md))。如果查询涉及到连接,则可能涉及到查找多个索引。然而 MapReduce 没有索引的概念 —— 至少在通常意义上没有。
|
||||
|
||||
当MapReduce作业被赋予一组文件作为输入时,它读取所有这些文件的全部内容;数据库会将这种操作称为**全表扫描**。如果你只想读取少量的记录,则全表扫描与索引查询相比,代价非常高昂。但是在分析查询中(请参阅“[事务处理还是分析?](ch3.md#事务处理还是分析?)”),通常需要计算大量记录的聚合。在这种情况下,特别是如果能在多台机器上并行处理时,扫描整个输入可能是相当合理的事情。
|
||||
当 MapReduce 作业被赋予一组文件作为输入时,它读取所有这些文件的全部内容;数据库会将这种操作称为 **全表扫描**。如果你只想读取少量的记录,则全表扫描与索引查询相比,代价非常高昂。但是在分析查询中(请参阅 “[事务处理还是分析?](ch3.md#事务处理还是分析?)”),通常需要计算大量记录的聚合。在这种情况下,特别是如果能在多台机器上并行处理时,扫描整个输入可能是相当合理的事情。
|
||||
|
||||
当我们在批处理的语境中讨论连接时,我们指的是在数据集中解析某种关联的全量存在。 例如我们假设一个作业是同时处理所有用户的数据,而非仅仅是为某个特定用户查找数据(而这能通过索引更高效地完成)。
|
||||
|
||||
#### 示例:用户活动事件分析
|
||||
|
||||
[图10-2](img/fig10-2.png)给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为**活动事件**,即activity events,或**点击流数据**,即clickstream data),右侧是用户数据库。 你可以将此示例看作是星型模式的一部分(请参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。
|
||||
[图 10-2](img/fig10-2.png) 给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为 **活动事件**,即 activity events,或 **点击流数据**,即 clickstream data),右侧是用户数据库。 你可以将此示例看作是星型模式的一部分(请参阅 “[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。
|
||||
|
||||
![](img/fig10-2.png)
|
||||
|
||||
**图10-2 用户行为日志与用户档案的连接**
|
||||
**图 10-2 用户行为日志与用户档案的连接**
|
||||
|
||||
分析任务可能需要将用户活动与用户档案信息相关联:例如,如果档案包含用户的年龄或出生日期,系统就可以确定哪些页面更受哪些年龄段的用户欢迎。然而活动事件仅包含用户ID,而没有包含完整的用户档案信息。在每个活动事件中嵌入这些档案信息很可能会非常浪费。因此,活动事件需要与用户档案数据库相连接。
|
||||
分析任务可能需要将用户活动与用户档案信息相关联:例如,如果档案包含用户的年龄或出生日期,系统就可以确定哪些页面更受哪些年龄段的用户欢迎。然而活动事件仅包含用户 ID,而没有包含完整的用户档案信息。在每个活动事件中嵌入这些档案信息很可能会非常浪费。因此,活动事件需要与用户档案数据库相连接。
|
||||
|
||||
实现这一连接的最简单方法是,逐个遍历活动事件,并为每个遇到的用户ID查询用户数据库(在远程服务器上)。这是可能的,但是它的性能可能会非常差:处理吞吐量将受限于受数据库服务器的往返时间,本地缓存的有效性很大程度上取决于数据的分布,并行运行大量查询可能会轻易压垮数据库【35】。
|
||||
实现这一连接的最简单方法是,逐个遍历活动事件,并为每个遇到的用户 ID 查询用户数据库(在远程服务器上)。这是可能的,但是它的性能可能会非常差:处理吞吐量将受限于受数据库服务器的往返时间,本地缓存的有效性很大程度上取决于数据的分布,并行运行大量查询可能会轻易压垮数据库【35】。
|
||||
|
||||
为了在批处理过程中实现良好的吞吐量,计算必须(尽可能)限于单台机器上进行。为待处理的每条记录发起随机访问的网络请求实在是太慢了。而且,查询远程数据库意味着批处理作业变为**非确定的(nondeterministic)**,因为远程数据库中的数据可能会改变。
|
||||
为了在批处理过程中实现良好的吞吐量,计算必须(尽可能)限于单台机器上进行。为待处理的每条记录发起随机访问的网络请求实在是太慢了。而且,查询远程数据库意味着批处理作业变为 **非确定的(nondeterministic)**,因为远程数据库中的数据可能会改变。
|
||||
|
||||
因此,更好的方法是获取用户数据库的副本(例如,使用ETL进程从数据库备份中提取数据,请参阅“[数据仓库](ch3.md#数据仓库)”),并将它和用户行为日志放入同一个分布式文件系统中。然后你可以将用户数据库存储在HDFS中的一组文件中,而用户活动记录存储在另一组文件中,并能用MapReduce将所有相关记录集中到同一个地方进行高效处理。
|
||||
因此,更好的方法是获取用户数据库的副本(例如,使用 ETL 进程从数据库备份中提取数据,请参阅 “[数据仓库](ch3.md#数据仓库)”),并将它和用户行为日志放入同一个分布式文件系统中。然后你可以将用户数据库存储在 HDFS 中的一组文件中,而用户活动记录存储在另一组文件中,并能用 MapReduce 将所有相关记录集中到同一个地方进行高效处理。
|
||||
|
||||
#### 排序合并连接
|
||||
|
||||
回想一下,Mapper的目的是从每个输入记录中提取一对键值。在[图10-2](img/fig10-2.png)的情况下,这个键就是用户ID:一组Mapper会扫过活动事件(提取用户ID作为键,活动事件作为值),而另一组Mapper将会扫过用户数据库(提取用户ID作为键,用户的出生日期作为值)。这个过程如[图10-3](img/fig10-3.png)所示。
|
||||
回想一下,Mapper 的目的是从每个输入记录中提取一对键值。在 [图 10-2](img/fig10-2.png) 的情况下,这个键就是用户 ID:一组 Mapper 会扫过活动事件(提取用户 ID 作为键,活动事件作为值),而另一组 Mapper 将会扫过用户数据库(提取用户 ID 作为键,用户的出生日期作为值)。这个过程如 [图 10-3](img/fig10-3.png) 所示。
|
||||
|
||||
![](img/fig10-3.png)
|
||||
|
||||
**图10-3 在用户ID上进行的Reduce端连接。如果输入数据集分区为多个文件,则每个分区都会被多个Mapper并行处理**
|
||||
**图 10-3 在用户 ID 上进行的 Reduce 端连接。如果输入数据集分区为多个文件,则每个分区都会被多个 Mapper 并行处理**
|
||||
|
||||
当MapReduce框架通过键对Mapper输出进行分区,然后对键值对进行排序时,效果是具有相同ID的所有活动事件和用户记录在Reducer输入中彼此相邻。 Map-Reduce作业甚至可以也让这些记录排序,使Reducer总能先看到来自用户数据库的记录,紧接着是按时间戳顺序排序的活动事件 —— 这种技术被称为**二次排序(secondary sort)**【26】。
|
||||
当 MapReduce 框架通过键对 Mapper 输出进行分区,然后对键值对进行排序时,效果是具有相同 ID 的所有活动事件和用户记录在 Reducer 输入中彼此相邻。 Map-Reduce 作业甚至可以也让这些记录排序,使 Reducer 总能先看到来自用户数据库的记录,紧接着是按时间戳顺序排序的活动事件 —— 这种技术被称为 **二次排序(secondary sort)**【26】。
|
||||
|
||||
然后Reducer可以容易地执行实际的连接逻辑:每个用户ID都会被调用一次Reducer函数,且因为二次排序,第一个值应该是来自用户数据库的出生日期记录。 Reducer将出生日期存储在局部变量中,然后使用相同的用户ID遍历活动事件,输出**已观看网址**和**观看者年龄**的结果对。随后的Map-Reduce作业可以计算每个URL的查看者年龄分布,并按年龄段进行聚集。
|
||||
然后 Reducer 可以容易地执行实际的连接逻辑:每个用户 ID 都会被调用一次 Reducer 函数,且因为二次排序,第一个值应该是来自用户数据库的出生日期记录。 Reducer 将出生日期存储在局部变量中,然后使用相同的用户 ID 遍历活动事件,输出 **已观看网址** 和 **观看者年龄** 的结果对。随后的 Map-Reduce 作业可以计算每个 URL 的查看者年龄分布,并按年龄段进行聚集。
|
||||
|
||||
由于Reducer一次处理一个特定用户ID的所有记录,因此一次只需要将一条用户记录保存在内存中,而不需要通过网络发出任何请求。这个算法被称为**排序合并连接(sort-merge join)**,因为Mapper的输出是按键排序的,然后Reducer将来自连接两侧的有序记录列表合并在一起。
|
||||
由于 Reducer 一次处理一个特定用户 ID 的所有记录,因此一次只需要将一条用户记录保存在内存中,而不需要通过网络发出任何请求。这个算法被称为 **排序合并连接(sort-merge join)**,因为 Mapper 的输出是按键排序的,然后 Reducer 将来自连接两侧的有序记录列表合并在一起。
|
||||
|
||||
#### 把相关数据放在一起
|
||||
|
||||
在排序合并连接中,Mapper和排序过程确保了所有对特定用户ID执行连接操作的必须数据都被放在同一个地方:单次调用Reducer的地方。预先排好了所有需要的数据,Reducer可以是相当简单的单线程代码,能够以高吞吐量和与低内存开销扫过这些记录。
|
||||
在排序合并连接中,Mapper 和排序过程确保了所有对特定用户 ID 执行连接操作的必须数据都被放在同一个地方:单次调用 Reducer 的地方。预先排好了所有需要的数据,Reducer 可以是相当简单的单线程代码,能够以高吞吐量和与低内存开销扫过这些记录。
|
||||
|
||||
这种架构可以看做,Mapper将“消息”发送给Reducer。当一个Mapper发出一个键值对时,这个键的作用就像值应该传递到的目标地址。即使键只是一个任意的字符串(不是像IP地址和端口号那样的实际的网络地址),它表现的就像一个地址:所有具有相同键的键值对将被传递到相同的目标(一次Reducer的调用)。
|
||||
这种架构可以看做,Mapper 将 “消息” 发送给 Reducer。当一个 Mapper 发出一个键值对时,这个键的作用就像值应该传递到的目标地址。即使键只是一个任意的字符串(不是像 IP 地址和端口号那样的实际的网络地址),它表现的就像一个地址:所有具有相同键的键值对将被传递到相同的目标(一次 Reducer 的调用)。
|
||||
|
||||
使用MapReduce编程模型,能将计算的物理网络通信层面(从正确的机器获取数据)从应用逻辑中剥离出来(获取数据后执行处理)。这种分离与数据库的典型用法形成了鲜明对比,从数据库中获取数据的请求经常出现在应用代码内部【36】。由于MapReduce处理了所有的网络通信,因此它也避免了让应用代码去担心部分故障,例如另一个节点的崩溃:MapReduce在不影响应用逻辑的情况下能透明地重试失败的任务。
|
||||
使用 MapReduce 编程模型,能将计算的物理网络通信层面(从正确的机器获取数据)从应用逻辑中剥离出来(获取数据后执行处理)。这种分离与数据库的典型用法形成了鲜明对比,从数据库中获取数据的请求经常出现在应用代码内部【36】。由于 MapReduce 处理了所有的网络通信,因此它也避免了让应用代码去担心部分故障,例如另一个节点的崩溃:MapReduce 在不影响应用逻辑的情况下能透明地重试失败的任务。
|
||||
|
||||
#### 分组
|
||||
|
||||
除了连接之外,“把相关数据放在一起”的另一种常见模式是,按某个键对记录分组(如SQL中的GROUP BY子句)。所有带有相同键的记录构成一个组,而下一步往往是在每个组内进行某种聚合操作,例如:
|
||||
除了连接之外,“把相关数据放在一起” 的另一种常见模式是,按某个键对记录分组(如 SQL 中的 GROUP BY 子句)。所有带有相同键的记录构成一个组,而下一步往往是在每个组内进行某种聚合操作,例如:
|
||||
|
||||
- 统计每个组中记录的数量(例如在统计PV的例子中,在SQL中表示为`COUNT(*)`聚合)
|
||||
- 对某个特定字段求和(SQL中的`SUM(fieldname)`)
|
||||
- 按某种分级函数取出排名前k条记录。
|
||||
- 统计每个组中记录的数量(例如在统计 PV 的例子中,在 SQL 中表示为 `COUNT(*)` 聚合)
|
||||
- 对某个特定字段求和(SQL 中的 `SUM(fieldname)`)
|
||||
- 按某种分级函数取出排名前 k 条记录。
|
||||
|
||||
使用MapReduce实现这种分组操作的最简单方法是设置Mapper,以便它们生成的键值对使用所需的分组键。然后分区和排序过程将所有具有相同分区键的记录导向同一个Reducer。因此在MapReduce之上实现分组和连接看上去非常相似。
|
||||
使用 MapReduce 实现这种分组操作的最简单方法是设置 Mapper,以便它们生成的键值对使用所需的分组键。然后分区和排序过程将所有具有相同分区键的记录导向同一个 Reducer。因此在 MapReduce 之上实现分组和连接看上去非常相似。
|
||||
|
||||
分组的另一个常见用途是整理特定用户会话的所有活动事件,以找出用户进行的一系列操作(称为**会话化(sessionization)**【37】)。例如,可以使用这种分析来确定显示新版网站的用户是否比那些显示旧版本的用户更有购买欲(A/B测试),或者计算某个营销活动是否值得。
|
||||
分组的另一个常见用途是整理特定用户会话的所有活动事件,以找出用户进行的一系列操作(称为 **会话化(sessionization)**【37】)。例如,可以使用这种分析来确定显示新版网站的用户是否比那些显示旧版本的用户更有购买欲(A/B 测试),或者计算某个营销活动是否值得。
|
||||
|
||||
如果你有多个Web服务器处理用户请求,则特定用户的活动事件很可能分散在各个不同的服务器的日志文件中。你可以通过使用会话cookie,用户ID或类似的标识符作为分组键,以将特定用户的所有活动事件放在一起来实现会话化,与此同时,不同用户的事件仍然散布在不同的分区中。
|
||||
如果你有多个 Web 服务器处理用户请求,则特定用户的活动事件很可能分散在各个不同的服务器的日志文件中。你可以通过使用会话 cookie,用户 ID 或类似的标识符作为分组键,以将特定用户的所有活动事件放在一起来实现会话化,与此同时,不同用户的事件仍然散布在不同的分区中。
|
||||
|
||||
#### 处理偏斜
|
||||
|
||||
如果存在与单个键关联的大量数据,则“将具有相同键的所有记录放到相同的位置”这种模式就被破坏了。例如在社交网络中,大多数用户可能会与几百人有连接,但少数名人可能有数百万的追随者。这种不成比例的活动数据库记录被称为**关键对象(linchpin object)**【38】或**热键(hot key)**。
|
||||
如果存在与单个键关联的大量数据,则 “将具有相同键的所有记录放到相同的位置” 这种模式就被破坏了。例如在社交网络中,大多数用户可能会与几百人有连接,但少数名人可能有数百万的追随者。这种不成比例的活动数据库记录被称为 **关键对象(linchpin object)**【38】或 **热键(hot key)**。
|
||||
|
||||
在单个Reducer中收集与某个名人相关的所有活动(例如他们发布内容的回复)可能导致严重的**偏斜**(也称为**热点**,即hot spot)—— 也就是说,一个Reducer必须比其他Reducer处理更多的记录(请参阅“[负载偏斜与热点消除](ch6.md#负载偏斜与热点消除)“)。由于MapReduce作业只有在所有Mapper和Reducer都完成时才完成,所有后续作业必须等待最慢的Reducer才能启动。
|
||||
在单个 Reducer 中收集与某个名人相关的所有活动(例如他们发布内容的回复)可能导致严重的 **偏斜**(也称为 **热点**,即 hot spot)—— 也就是说,一个 Reducer 必须比其他 Reducer 处理更多的记录(请参阅 “[负载偏斜与热点消除](ch6.md#负载偏斜与热点消除)“)。由于 MapReduce 作业只有在所有 Mapper 和 Reducer 都完成时才完成,所有后续作业必须等待最慢的 Reducer 才能启动。
|
||||
|
||||
如果连接的输入存在热键,可以使用一些算法进行补偿。例如,Pig中的**偏斜连接(skewed join)** 方法首先运行一个抽样作业(Sampling Job)来确定哪些键是热键【39】。连接实际执行时,Mapper会将热键的关联记录**随机**(相对于传统MapReduce基于键散列的确定性方法)发送到几个Reducer之一。对于另外一侧的连接输入,与热键相关的记录需要被复制到**所有**处理该键的Reducer上【40】。
|
||||
如果连接的输入存在热键,可以使用一些算法进行补偿。例如,Pig 中的 **偏斜连接(skewed join)** 方法首先运行一个抽样作业(Sampling Job)来确定哪些键是热键【39】。连接实际执行时,Mapper 会将热键的关联记录 **随机**(相对于传统 MapReduce 基于键散列的确定性方法)发送到几个 Reducer 之一。对于另外一侧的连接输入,与热键相关的记录需要被复制到 **所有** 处理该键的 Reducer 上【40】。
|
||||
|
||||
这种技术将处理热键的工作分散到多个Reducer上,这样可以使其更好地并行化,代价是需要将连接另一侧的输入记录复制到多个Reducer上。 Crunch中的**分片连接(sharded join)** 方法与之类似,但需要显式指定热键而不是使用抽样作业。这种技术也非常类似于我们在“[负载偏斜与热点消除](ch6.md#负载偏斜与热点消除)”中讨论的技术,使用随机化来缓解分区数据库中的热点。
|
||||
这种技术将处理热键的工作分散到多个 Reducer 上,这样可以使其更好地并行化,代价是需要将连接另一侧的输入记录复制到多个 Reducer 上。 Crunch 中的 **分片连接(sharded join)** 方法与之类似,但需要显式指定热键而不是使用抽样作业。这种技术也非常类似于我们在 “[负载偏斜与热点消除](ch6.md#负载偏斜与热点消除)” 中讨论的技术,使用随机化来缓解分区数据库中的热点。
|
||||
|
||||
Hive的偏斜连接优化采取了另一种方法。它需要在表格元数据中显式指定热键,并将与这些键相关的记录单独存放,与其它文件分开。当在该表上执行连接时,对于热键,它会使用Map端连接(请参阅下一节)。
|
||||
Hive 的偏斜连接优化采取了另一种方法。它需要在表格元数据中显式指定热键,并将与这些键相关的记录单独存放,与其它文件分开。当在该表上执行连接时,对于热键,它会使用 Map 端连接(请参阅下一节)。
|
||||
|
||||
当按照热键进行分组并聚合时,可以将分组分两个阶段进行。第一个MapReduce阶段将记录发送到随机Reducer,以便每个Reducer只对热键的子集执行分组,为每个键输出一个更紧凑的中间聚合结果。然后第二个MapReduce作业将所有来自第一阶段Reducer的中间聚合结果合并为每个键一个值。
|
||||
当按照热键进行分组并聚合时,可以将分组分两个阶段进行。第一个 MapReduce 阶段将记录发送到随机 Reducer,以便每个 Reducer 只对热键的子集执行分组,为每个键输出一个更紧凑的中间聚合结果。然后第二个 MapReduce 作业将所有来自第一阶段 Reducer 的中间聚合结果合并为每个键一个值。
|
||||
|
||||
|
||||
### Map侧连接
|
||||
|
||||
上一节描述的连接算法在Reducer中执行实际的连接逻辑,因此被称为Reduce侧连接。Mapper扮演着预处理输入数据的角色:从每个输入记录中提取键值,将键值对分配给Reducer分区,并按键排序。
|
||||
上一节描述的连接算法在 Reducer 中执行实际的连接逻辑,因此被称为 Reduce 侧连接。Mapper 扮演着预处理输入数据的角色:从每个输入记录中提取键值,将键值对分配给 Reducer 分区,并按键排序。
|
||||
|
||||
Reduce侧方法的优点是不需要对输入数据做任何假设:无论其属性和结构如何,Mapper都可以对其预处理以备连接。然而不利的一面是,排序,复制至Reducer,以及合并Reducer输入,所有这些操作可能开销巨大。当数据通过MapReduce 阶段时,数据可能需要落盘好几次,取决于可用的内存缓冲区【37】。
|
||||
Reduce 侧方法的优点是不需要对输入数据做任何假设:无论其属性和结构如何,Mapper 都可以对其预处理以备连接。然而不利的一面是,排序,复制至 Reducer,以及合并 Reducer 输入,所有这些操作可能开销巨大。当数据通过 MapReduce 阶段时,数据可能需要落盘好几次,取决于可用的内存缓冲区【37】。
|
||||
|
||||
另一方面,如果你**能**对输入数据作出某些假设,则通过使用所谓的Map侧连接来加快连接速度是可行的。这种方法使用了一个裁减掉Reducer与排序的MapReduce作业,每个Mapper只是简单地从分布式文件系统中读取一个输入文件块,然后将输出文件写入文件系统,仅此而已。
|
||||
另一方面,如果你 **能** 对输入数据作出某些假设,则通过使用所谓的 Map 侧连接来加快连接速度是可行的。这种方法使用了一个裁减掉 Reducer 与排序的 MapReduce 作业,每个 Mapper 只是简单地从分布式文件系统中读取一个输入文件块,然后将输出文件写入文件系统,仅此而已。
|
||||
|
||||
#### 广播散列连接
|
||||
|
||||
适用于执行Map端连接的最简单场景是大数据集与小数据集连接的情况。要点在于小数据集需要足够小,以便可以将其全部加载到每个Mapper的内存中。
|
||||
适用于执行 Map 端连接的最简单场景是大数据集与小数据集连接的情况。要点在于小数据集需要足够小,以便可以将其全部加载到每个 Mapper 的内存中。
|
||||
|
||||
例如,假设在[图10-2](img/fig10-2.png)的情况下,用户数据库小到足以放进内存中。在这种情况下,当Mapper启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的散列表中。完成此操作后,Mapper可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户ID[^vi]。
|
||||
例如,假设在 [图 10-2](img/fig10-2.png) 的情况下,用户数据库小到足以放进内存中。在这种情况下,当 Mapper 启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的散列表中。完成此操作后,Mapper 可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户 ID [^vi]。
|
||||
|
||||
[^vi]: 这个例子假定散列表中的每个键只有一个条目,这对用户数据库(用户ID唯一标识一个用户)可能是正确的。通常,哈希表可能需要包含具有相同键的多个条目,而连接运算符将对每个键输出所有的匹配。
|
||||
[^vi]: 这个例子假定散列表中的每个键只有一个条目,这对用户数据库(用户 ID 唯一标识一个用户)可能是正确的。通常,哈希表可能需要包含具有相同键的多个条目,而连接运算符将对每个键输出所有的匹配。
|
||||
|
||||
参与连接的较大输入的每个文件块各有一个Mapper(在[图10-2](img/fig10-2.png)的例子中活动事件是较大的输入)。每个Mapper都会将较小输入整个加载到内存中。
|
||||
参与连接的较大输入的每个文件块各有一个 Mapper(在 [图 10-2](img/fig10-2.png) 的例子中活动事件是较大的输入)。每个 Mapper 都会将较小输入整个加载到内存中。
|
||||
|
||||
这种简单有效的算法被称为**广播散列连接(broadcast hash join)**:**广播**一词反映了这样一个事实,每个连接较大输入端分区的Mapper都会将较小输入端数据集整个读入内存中(所以较小输入实际上“广播”到较大数据的所有分区上),**散列**一词反映了它使用一个散列表。 Pig(名为“**复制链接(replicated join)**”),Hive(“**MapJoin**”),Cascading和Crunch支持这种连接。它也被诸如Impala的数据仓库查询引擎使用【41】。
|
||||
这种简单有效的算法被称为 **广播散列连接(broadcast hash join)**:**广播** 一词反映了这样一个事实,每个连接较大输入端分区的 Mapper 都会将较小输入端数据集整个读入内存中(所以较小输入实际上 “广播” 到较大数据的所有分区上),**散列** 一词反映了它使用一个散列表。 Pig(名为 “**复制链接(replicated join)**”),Hive(“**MapJoin**”),Cascading 和 Crunch 支持这种连接。它也被诸如 Impala 的数据仓库查询引擎使用【41】。
|
||||
|
||||
除了将较小的连接输入加载到内存散列表中,另一种方法是将较小输入存储在本地磁盘上的只读索引中【42】。索引中经常使用的部分将保留在操作系统的页面缓存中,因而这种方法可以提供与内存散列表几乎一样快的随机查找性能,但实际上并不需要数据集能放入内存中。
|
||||
|
||||
#### 分区散列连接
|
||||
|
||||
如果Map侧连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在[图10-2](img/fig10-2.png)的情况中,你可以根据用户ID的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有10个分区)。例如,Mapper3首先将所有具有以3结尾的ID的用户加载到散列表中,然后扫描ID为3的每个用户的所有活动事件。
|
||||
如果 Map 侧连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在 [图 10-2](img/fig10-2.png) 的情况中,你可以根据用户 ID 的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有 10 个分区)。例如,Mapper3 首先将所有具有以 3 结尾的 ID 的用户加载到散列表中,然后扫描 ID 为 3 的每个用户的所有活动事件。
|
||||
|
||||
如果分区正确无误,可以确定的是,所有你可能需要连接的记录都落在同一个编号的分区中。因此每个Mapper只需要从输入两端各读取一个分区就足够了。好处是每个Mapper都可以在内存散列表中少放点数据。
|
||||
如果分区正确无误,可以确定的是,所有你可能需要连接的记录都落在同一个编号的分区中。因此每个 Mapper 只需要从输入两端各读取一个分区就足够了。好处是每个 Mapper 都可以在内存散列表中少放点数据。
|
||||
|
||||
这种方法只有当连接两端输入有相同的分区数,且两侧的记录都是使用相同的键与相同的哈希函数做分区时才适用。如果输入是由之前执行过这种分组的MapReduce作业生成的,那么这可能是一个合理的假设。
|
||||
这种方法只有当连接两端输入有相同的分区数,且两侧的记录都是使用相同的键与相同的哈希函数做分区时才适用。如果输入是由之前执行过这种分组的 MapReduce 作业生成的,那么这可能是一个合理的假设。
|
||||
|
||||
分区散列连接在Hive中称为**Map侧桶连接(bucketed map joins)【37】**。
|
||||
分区散列连接在 Hive 中称为 **Map 侧桶连接(bucketed map joins)【37】**。
|
||||
|
||||
#### Map侧合并连接
|
||||
|
||||
如果输入数据集不仅以相同的方式进行分区,而且还基于相同的键进行**排序**,则可适用另一种Map侧连接的变体。在这种情况下,输入是否小到能放入内存并不重要,因为这时候Mapper同样可以执行归并操作(通常由Reducer执行)的归并操作:按键递增的顺序依次读取两个输入文件,将具有相同键的记录配对。
|
||||
如果输入数据集不仅以相同的方式进行分区,而且还基于相同的键进行 **排序**,则可适用另一种 Map 侧连接的变体。在这种情况下,输入是否小到能放入内存并不重要,因为这时候 Mapper 同样可以执行归并操作(通常由 Reducer 执行)的归并操作:按键递增的顺序依次读取两个输入文件,将具有相同键的记录配对。
|
||||
|
||||
如果能进行Map侧合并连接,这通常意味着前一个MapReduce作业可能一开始就已经把输入数据做了分区并进行了排序。原则上这个连接就可以在前一个作业的Reduce阶段进行。但使用独立的仅Map作业有时也是合适的,例如,分好区且排好序的中间数据集可能还会用于其他目的。
|
||||
如果能进行 Map 侧合并连接,这通常意味着前一个 MapReduce 作业可能一开始就已经把输入数据做了分区并进行了排序。原则上这个连接就可以在前一个作业的 Reduce 阶段进行。但使用独立的仅 Map 作业有时也是合适的,例如,分好区且排好序的中间数据集可能还会用于其他目的。
|
||||
|
||||
#### MapReduce工作流与Map侧连接
|
||||
|
||||
当下游作业使用MapReduce连接的输出时,选择Map侧连接或Reduce侧连接会影响输出的结构。Reduce侧连接的输出是按照**连接键**进行分区和排序的,而Map端连接的输出则按照与较大输入相同的方式进行分区和排序(因为无论是使用分区连接还是广播连接,连接较大输入端的每个文件块都会启动一个Map任务)。
|
||||
当下游作业使用 MapReduce 连接的输出时,选择 Map 侧连接或 Reduce 侧连接会影响输出的结构。Reduce 侧连接的输出是按照 **连接键** 进行分区和排序的,而 Map 端连接的输出则按照与较大输入相同的方式进行分区和排序(因为无论是使用分区连接还是广播连接,连接较大输入端的每个文件块都会启动一个 Map 任务)。
|
||||
|
||||
如前所述,Map侧连接也对输入数据集的大小,有序性和分区方式做出了更多假设。在优化连接策略时,了解分布式文件系统中数据集的物理布局变得非常重要:仅仅知道编码格式和数据存储目录的名称是不够的;你还必须知道数据是按哪些键做的分区和排序,以及分区的数量。
|
||||
如前所述,Map 侧连接也对输入数据集的大小,有序性和分区方式做出了更多假设。在优化连接策略时,了解分布式文件系统中数据集的物理布局变得非常重要:仅仅知道编码格式和数据存储目录的名称是不够的;你还必须知道数据是按哪些键做的分区和排序,以及分区的数量。
|
||||
|
||||
在Hadoop生态系统中,这种关于数据集分区的元数据通常在HCatalog和Hive Metastore中维护【37】。
|
||||
在 Hadoop 生态系统中,这种关于数据集分区的元数据通常在 HCatalog 和 Hive Metastore 中维护【37】。
|
||||
|
||||
|
||||
### 批处理工作流的输出
|
||||
|
||||
我们已经说了很多用于实现MapReduce工作流的算法,但却忽略了一个重要的问题:这些处理完成之后的最终结果是什么?我们最开始为什么要跑这些作业?
|
||||
我们已经说了很多用于实现 MapReduce 工作流的算法,但却忽略了一个重要的问题:这些处理完成之后的最终结果是什么?我们最开始为什么要跑这些作业?
|
||||
|
||||
在数据库查询的场景中,我们将事务处理(OLTP)与分析两种目的区分开来(请参阅“[事务处理还是分析?](ch3.md#事务处理还是分析?)”)。我们看到,OLTP查询通常根据键查找少量记录,使用索引,并将其呈现给用户(比如在网页上)。另一方面,分析查询通常会扫描大量记录,执行分组与聚合,输出通常有着报告的形式:显示某个指标随时间变化的图表,或按照某种排位取前10项,或将一些数字细化为子类。这种报告的消费者通常是需要做出商业决策的分析师或经理。
|
||||
在数据库查询的场景中,我们将事务处理(OLTP)与分析两种目的区分开来(请参阅 “[事务处理还是分析?](ch3.md#事务处理还是分析?)”)。我们看到,OLTP 查询通常根据键查找少量记录,使用索引,并将其呈现给用户(比如在网页上)。另一方面,分析查询通常会扫描大量记录,执行分组与聚合,输出通常有着报告的形式:显示某个指标随时间变化的图表,或按照某种排位取前 10 项,或将一些数字细化为子类。这种报告的消费者通常是需要做出商业决策的分析师或经理。
|
||||
|
||||
批处理放哪里合适?它不属于事务处理,也不是分析。它和分析比较接近,因为批处理通常会扫过输入数据集的绝大部分。然而MapReduce作业工作流与用于分析目的的SQL查询是不同的(请参阅“[Hadoop与分布式数据库的对比](#Hadoop与分布式数据库的对比)”)。批处理过程的输出通常不是报表,而是一些其他类型的结构。
|
||||
批处理放哪里合适?它不属于事务处理,也不是分析。它和分析比较接近,因为批处理通常会扫过输入数据集的绝大部分。然而 MapReduce 作业工作流与用于分析目的的 SQL 查询是不同的(请参阅 “[Hadoop 与分布式数据库的对比](#Hadoop与分布式数据库的对比)”)。批处理过程的输出通常不是报表,而是一些其他类型的结构。
|
||||
|
||||
#### 建立搜索索引
|
||||
|
||||
Google最初使用MapReduce是为其搜索引擎建立索引,其实现为由5到10个MapReduce作业组成的工作流【1】。虽然Google后来也不仅仅是为这个目的而使用MapReduce 【43】,但如果从构建搜索索引的角度来看,更能帮助理解MapReduce。 (直至今日,Hadoop MapReduce仍然是为Lucene/Solr构建索引的好方法【44】)
|
||||
Google 最初使用 MapReduce 是为其搜索引擎建立索引,其实现为由 5 到 10 个 MapReduce 作业组成的工作流【1】。虽然 Google 后来也不仅仅是为这个目的而使用 MapReduce 【43】,但如果从构建搜索索引的角度来看,更能帮助理解 MapReduce。 (直至今日,Hadoop MapReduce 仍然是为 Lucene/Solr 构建索引的好方法【44】)
|
||||
|
||||
我们在“[全文搜索和模糊索引](ch3.md#全文搜索和模糊索引)”中简要地了解了Lucene这样的全文搜索索引是如何工作的:它是一个文件(关键词字典),你可以在其中高效地查找特定关键字,并找到包含该关键字的所有文档ID列表(文章列表)。这是一种非常简化的看法 —— 实际上,搜索索引需要各种额外数据,以便根据相关性对搜索结果进行排名,纠正拼写错误,解析同义词等等 —— 但这个原则是成立的。
|
||||
我们在 “[全文搜索和模糊索引](ch3.md#全文搜索和模糊索引)” 中简要地了解了 Lucene 这样的全文搜索索引是如何工作的:它是一个文件(关键词字典),你可以在其中高效地查找特定关键字,并找到包含该关键字的所有文档 ID 列表(文章列表)。这是一种非常简化的看法 —— 实际上,搜索索引需要各种额外数据,以便根据相关性对搜索结果进行排名,纠正拼写错误,解析同义词等等 —— 但这个原则是成立的。
|
||||
|
||||
如果需要对一组固定文档执行全文搜索,则批处理是一种构建索引的高效方法:Mapper根据需要对文档集合进行分区,每个Reducer构建该分区的索引,并将索引文件写入分布式文件系统。构建这样的文档分区索引(请参阅“[分区与次级索引](ch6.md#分区与次级索引)”)并行处理效果拔群。
|
||||
如果需要对一组固定文档执行全文搜索,则批处理是一种构建索引的高效方法:Mapper 根据需要对文档集合进行分区,每个 Reducer 构建该分区的索引,并将索引文件写入分布式文件系统。构建这样的文档分区索引(请参阅 “[分区与次级索引](ch6.md#分区与次级索引)”)并行处理效果拔群。
|
||||
|
||||
由于按关键字查询搜索索引是只读操作,因而这些索引文件一旦创建就是不可变的。
|
||||
|
||||
如果索引的文档集合发生更改,一种选择是定期重跑整个索引工作流,并在完成后用新的索引文件批量替换以前的索引文件。如果只有少量的文档发生了变化,这种方法的计算成本可能会很高。但它的优点是索引过程很容易理解:文档进,索引出。
|
||||
|
||||
另一个选择是,可以增量建立索引。如[第三章](ch3.md)中讨论的,如果要在索引中添加,删除或更新文档,Lucene会写新的段文件,并在后台异步合并压缩段文件。我们将在[第十一章](ch11.md)中看到更多这种增量处理。
|
||||
另一个选择是,可以增量建立索引。如 [第三章](ch3.md) 中讨论的,如果要在索引中添加,删除或更新文档,Lucene 会写新的段文件,并在后台异步合并压缩段文件。我们将在 [第十一章](ch11.md) 中看到更多这种增量处理。
|
||||
|
||||
#### 键值存储作为批处理输出
|
||||
|
||||
搜索索引只是批处理工作流可能输出的一个例子。批处理的另一个常见用途是构建机器学习系统,例如分类器(比如垃圾邮件过滤器,异常检测,图像识别)与推荐系统(例如,你可能认识的人,你可能感兴趣的产品或相关的搜索【29】)。
|
||||
|
||||
这些批处理作业的输出通常是某种数据库:例如,可以通过给定用户ID查询该用户推荐好友的数据库,或者可以通过产品ID查询相关产品的数据库【45】。
|
||||
这些批处理作业的输出通常是某种数据库:例如,可以通过给定用户 ID 查询该用户推荐好友的数据库,或者可以通过产品 ID 查询相关产品的数据库【45】。
|
||||
|
||||
这些数据库需要被处理用户请求的Web应用所查询,而它们通常是独立于Hadoop基础设施的。那么批处理过程的输出如何回到Web应用可以查询的数据库中呢?
|
||||
这些数据库需要被处理用户请求的 Web 应用所查询,而它们通常是独立于 Hadoop 基础设施的。那么批处理过程的输出如何回到 Web 应用可以查询的数据库中呢?
|
||||
|
||||
最直接的选择可能是,直接在Mapper或Reducer中使用你最爱的数据库的客户端库,并从批处理作业直接写入数据库服务器,一次写入一条记录。它能工作(假设你的防火墙规则允许从你的Hadoop环境直接访问你的生产数据库),但这并不是一个好主意,出于以下几个原因:
|
||||
最直接的选择可能是,直接在 Mapper 或 Reducer 中使用你最爱的数据库的客户端库,并从批处理作业直接写入数据库服务器,一次写入一条记录。它能工作(假设你的防火墙规则允许从你的 Hadoop 环境直接访问你的生产数据库),但这并不是一个好主意,出于以下几个原因:
|
||||
|
||||
- 正如前面在连接的上下文中讨论的那样,为每条记录发起一个网络请求,要比批处理任务的正常吞吐量慢几个数量级。即使客户端库支持批处理,性能也可能很差。
|
||||
- MapReduce作业经常并行运行许多任务。如果所有Mapper或Reducer都同时写入相同的输出数据库,并以批处理的预期速率工作,那么该数据库很可能被轻易压垮,其查询性能可能变差。这可能会导致系统其他部分的运行问题【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的一种好用法:使用Mapper提取出键并按该键排序,已经完成了构建索引所必需的大量工作。由于这些键值存储大多都是只读的(文件只能由批处理作业一次性写入,然后就不可变),所以数据结构非常简单。比如它们就不需要预写式日志(WAL,请参阅“[让B树更可靠](ch3.md#让B树更可靠)”)。
|
||||
构建这些数据库文件是 MapReduce 的一种好用法:使用 Mapper 提取出键并按该键排序,已经完成了构建索引所必需的大量工作。由于这些键值存储大多都是只读的(文件只能由批处理作业一次性写入,然后就不可变),所以数据结构非常简单。比如它们就不需要预写式日志(WAL,请参阅 “[让 B 树更可靠](ch3.md#让B树更可靠)”)。
|
||||
|
||||
将数据加载到Voldemort时,服务器将继续用旧数据文件服务请求,同时将新数据文件从分布式文件系统复制到服务器的本地磁盘。一旦复制完成,服务器会自动将查询切换到新文件。如果在这个过程中出现任何问题,它可以轻易回滚至旧文件,因为它们仍然存在而且不可变【46】。
|
||||
将数据加载到 Voldemort 时,服务器将继续用旧数据文件服务请求,同时将新数据文件从分布式文件系统复制到服务器的本地磁盘。一旦复制完成,服务器会自动将查询切换到新文件。如果在这个过程中出现任何问题,它可以轻易回滚至旧文件,因为它们仍然存在而且不可变【46】。
|
||||
|
||||
#### 批处理输出的哲学
|
||||
|
||||
本章前面讨论过的Unix哲学(“[Unix哲学](#Unix哲学)”)鼓励以显式指明数据流的方式进行实验:程序读取输入并写入输出。在这一过程中,输入保持不变,任何先前的输出都被新输出完全替换,且没有其他副作用。这意味着你可以随心所欲地重新运行一个命令,略做改动或进行调试,而不会搅乱系统的状态。
|
||||
本章前面讨论过的 Unix 哲学(“[Unix 哲学](#Unix哲学)”)鼓励以显式指明数据流的方式进行实验:程序读取输入并写入输出。在这一过程中,输入保持不变,任何先前的输出都被新输出完全替换,且没有其他副作用。这意味着你可以随心所欲地重新运行一个命令,略做改动或进行调试,而不会搅乱系统的状态。
|
||||
|
||||
MapReduce作业的输出处理遵循同样的原理。通过将输入视为不可变且避免副作用(如写入外部数据库),批处理作业不仅实现了良好的性能,而且更容易维护:
|
||||
MapReduce 作业的输出处理遵循同样的原理。通过将输入视为不可变且避免副作用(如写入外部数据库),批处理作业不仅实现了良好的性能,而且更容易维护:
|
||||
|
||||
- 如果在代码中引入了一个错误,而输出错误或损坏了,则可以简单地回滚到代码的先前版本,然后重新运行该作业,输出将重新被纠正。或者,甚至更简单,你可以将旧的输出保存在不同的目录中,然后切换回原来的目录。具有读写事务的数据库没有这个属性:如果你部署了错误的代码,将错误的数据写入数据库,那么回滚代码将无法修复数据库中的数据。 (能够从错误代码中恢复的概念被称为**人类容错(human fault tolerance)**【50】)
|
||||
- 由于回滚很容易,比起在错误意味着不可挽回的伤害的环境,功能开发进展能快很多。这种**最小化不可逆性(minimizing irreversibility)** 的原则有利于敏捷软件开发【51】。
|
||||
- 如果Map或Reduce任务失败,MapReduce框架将自动重新调度,并在同样的输入上再次运行它。如果失败是由代码中的错误造成的,那么它会不断崩溃,并最终导致作业在几次尝试之后失败。但是如果故障是由于临时问题导致的,那么故障就会被容忍。因为输入不可变,这种自动重试是安全的,而失败任务的输出会被MapReduce框架丢弃。
|
||||
- 如果在代码中引入了一个错误,而输出错误或损坏了,则可以简单地回滚到代码的先前版本,然后重新运行该作业,输出将重新被纠正。或者,甚至更简单,你可以将旧的输出保存在不同的目录中,然后切换回原来的目录。具有读写事务的数据库没有这个属性:如果你部署了错误的代码,将错误的数据写入数据库,那么回滚代码将无法修复数据库中的数据。 (能够从错误代码中恢复的概念被称为 **人类容错(human fault tolerance)**【50】)
|
||||
- 由于回滚很容易,比起在错误意味着不可挽回的伤害的环境,功能开发进展能快很多。这种 **最小化不可逆性(minimizing irreversibility)** 的原则有利于敏捷软件开发【51】。
|
||||
- 如果 Map 或 Reduce 任务失败,MapReduce 框架将自动重新调度,并在同样的输入上再次运行它。如果失败是由代码中的错误造成的,那么它会不断崩溃,并最终导致作业在几次尝试之后失败。但是如果故障是由于临时问题导致的,那么故障就会被容忍。因为输入不可变,这种自动重试是安全的,而失败任务的输出会被 MapReduce 框架丢弃。
|
||||
- 同一组文件可用作各种不同作业的输入,包括计算指标的监控作业并且评估作业的输出是否具有预期的性质(例如,将其与前一次运行的输出进行比较并测量差异) 。
|
||||
- 与Unix工具类似,MapReduce作业将逻辑与布线(配置输入和输出目录)分离,这使得关注点分离,可以重用代码:一个团队可以专注实现一个做好一件事的作业;而其他团队可以决定何时何地运行这项作业。
|
||||
- 与 Unix 工具类似,MapReduce 作业将逻辑与布线(配置输入和输出目录)分离,这使得关注点分离,可以重用代码:一个团队可以专注实现一个做好一件事的作业;而其他团队可以决定何时何地运行这项作业。
|
||||
|
||||
在这些领域,在Unix上表现良好的设计原则似乎也适用于Hadoop,但Unix和Hadoop在某些方面也有所不同。例如,因为大多数Unix工具都假设输入输出是无类型文本文件,所以它们必须做大量的输入解析工作(本章开头的日志分析示例使用`{print $7}`来提取URL)。在Hadoop上可以通过使用更结构化的文件格式消除一些低价值的语法转换:比如Avro(请参阅“[Avro](ch4.md#Avro)”)和Parquet(请参阅“[列式存储](ch3.md#列式存储)”)经常使用,因为它们提供了基于模式的高效编码,并允许模式随时间推移而演进(见[第四章](ch4.md))。
|
||||
在这些领域,在 Unix 上表现良好的设计原则似乎也适用于 Hadoop,但 Unix 和 Hadoop 在某些方面也有所不同。例如,因为大多数 Unix 工具都假设输入输出是无类型文本文件,所以它们必须做大量的输入解析工作(本章开头的日志分析示例使用 `{print $7}` 来提取 URL)。在 Hadoop 上可以通过使用更结构化的文件格式消除一些低价值的语法转换:比如 Avro(请参阅 “[Avro](ch4.md#Avro)”)和 Parquet(请参阅 “[列式存储](ch3.md#列式存储)”)经常使用,因为它们提供了基于模式的高效编码,并允许模式随时间推移而演进(见 [第四章](ch4.md))。
|
||||
|
||||
### Hadoop与分布式数据库的对比
|
||||
|
||||
正如我们所看到的,Hadoop有点像Unix的分布式版本,其中HDFS是文件系统,而MapReduce是Unix进程的怪异实现(总是在Map阶段和Reduce阶段运行`sort`工具)。我们了解了如何在这些原语的基础上实现各种连接和分组操作。
|
||||
正如我们所看到的,Hadoop 有点像 Unix 的分布式版本,其中 HDFS 是文件系统,而 MapReduce 是 Unix 进程的怪异实现(总是在 Map 阶段和 Reduce 阶段运行 `sort` 工具)。我们了解了如何在这些原语的基础上实现各种连接和分组操作。
|
||||
|
||||
当MapReduce论文发表时【1】,它从某种意义上来说 —— 并不新鲜。我们在前几节中讨论的所有处理和并行连接算法已经在十多年前所谓的**大规模并行处理(MPP, massively parallel processing)** 数据库中实现了【3,40】。比如Gamma database machine,Teradata和Tandem NonStop SQL就是这方面的先驱【52】。
|
||||
当 MapReduce 论文发表时【1】,它从某种意义上来说 —— 并不新鲜。我们在前几节中讨论的所有处理和并行连接算法已经在十多年前所谓的 **大规模并行处理(MPP, massively parallel processing)** 数据库中实现了【3,40】。比如 Gamma database machine、Teradata 和 Tandem NonStop SQL 就是这方面的先驱【52】。
|
||||
|
||||
最大的区别是,MPP数据库专注于在一组机器上并行执行分析SQL查询,而MapReduce和分布式文件系统【19】的组合则更像是一个可以运行任意程序的通用操作系统。
|
||||
最大的区别是,MPP 数据库专注于在一组机器上并行执行分析 SQL 查询,而 MapReduce 和分布式文件系统【19】的组合则更像是一个可以运行任意程序的通用操作系统。
|
||||
|
||||
#### 存储多样性
|
||||
|
||||
数据库要求你根据特定的模型(例如关系或文档)来构造数据,而分布式文件系统中的文件只是字节序列,可以使用任何数据模型和编码来编写。它们可能是数据库记录的集合,但同样可以是文本、图像、视频、传感器读数、稀疏矩阵、特征向量、基因组序列或任何其他类型的数据。
|
||||
|
||||
说白了,Hadoop开放了将数据不加区分地转储到HDFS的可能性,允许后续再研究如何进一步处理【53】。相比之下,在将数据导入数据库专有存储格式之前,MPP数据库通常需要对数据和查询模式进行仔细的前期建模。
|
||||
说白了,Hadoop 开放了将数据不加区分地转储到 HDFS 的可能性,允许后续再研究如何进一步处理【53】。相比之下,在将数据导入数据库专有存储格式之前,MPP 数据库通常需要对数据和查询模式进行仔细的前期建模。
|
||||
|
||||
在纯粹主义者看来,这种仔细的建模和导入似乎是可取的,因为这意味着数据库的用户有更高质量的数据来处理。然而实践经验表明,简单地使数据快速可用 —— 即使它很古怪,难以使用,使用原始格式 —— 也通常要比事先决定理想数据模型要更有价值【54】。
|
||||
|
||||
这个想法与数据仓库类似(请参阅“[数据仓库](ch3.md#数据仓库)”):将大型组织的各个部分的数据集中在一起是很有价值的,因为它可以跨越以前相互分离的数据集进行连接。 MPP数据库所要求的谨慎模式设计拖慢了集中式数据收集速度;以原始形式收集数据,稍后再操心模式的设计,能使数据收集速度加快(有时被称为“**数据湖(data lake)**”或“**企业数据中心(enterprise data hub)**”【55】)。
|
||||
这个想法与数据仓库类似(请参阅 “[数据仓库](ch3.md#数据仓库)”):将大型组织的各个部分的数据集中在一起是很有价值的,因为它可以跨越以前相互分离的数据集进行连接。 MPP 数据库所要求的谨慎模式设计拖慢了集中式数据收集速度;以原始形式收集数据,稍后再操心模式的设计,能使数据收集速度加快(有时被称为 “**数据湖(data lake)**” 或 “**企业数据中心(enterprise data hub)**”【55】)。
|
||||
|
||||
不加区分的数据转储转移了解释数据的负担:数据集的生产者不再需要强制将其转化为标准格式,数据的解释成为消费者的问题(**读时模式**方法【56】;请参阅“[文档模型中的模式灵活性](ch2.md#文档模型中的模式灵活性)”)。如果生产者和消费者是不同优先级的不同团队,这可能是一种优势。甚至可能不存在一个理想的数据模型,对于不同目的有不同的合适视角。以原始形式简单地转储数据,可以允许多种这样的转换。这种方法被称为**寿司原则(sushi principle)**:“原始数据更好”【57】。
|
||||
不加区分的数据转储转移了解释数据的负担:数据集的生产者不再需要强制将其转化为标准格式,数据的解释成为消费者的问题(**读时模式** 方法【56】;请参阅 “[文档模型中的模式灵活性](ch2.md#文档模型中的模式灵活性)”)。如果生产者和消费者是不同优先级的不同团队,这可能是一种优势。甚至可能不存在一个理想的数据模型,对于不同目的有不同的合适视角。以原始形式简单地转储数据,可以允许多种这样的转换。这种方法被称为 **寿司原则(sushi principle)**:“原始数据更好”【57】。
|
||||
|
||||
因此,Hadoop经常被用于实现ETL过程(请参阅“[数据仓库](ch3.md#数据仓库)”):事务处理系统中的数据以某种原始形式转储到分布式文件系统中,然后编写MapReduce作业来清理数据,将其转换为关系形式,并将其导入MPP数据仓库以进行分析。数据建模仍然在进行,但它在一个单独的步骤中进行,与数据收集相解耦。这种解耦是可行的,因为分布式文件系统支持以任何格式编码的数据。
|
||||
因此,Hadoop 经常被用于实现 ETL 过程(请参阅 “[数据仓库](ch3.md#数据仓库)”):事务处理系统中的数据以某种原始形式转储到分布式文件系统中,然后编写 MapReduce 作业来清理数据,将其转换为关系形式,并将其导入 MPP 数据仓库以进行分析。数据建模仍然在进行,但它在一个单独的步骤中进行,与数据收集相解耦。这种解耦是可行的,因为分布式文件系统支持以任何格式编码的数据。
|
||||
|
||||
#### 处理模型的多样性
|
||||
|
||||
MPP数据库是单体的,紧密集成的软件,负责磁盘上的存储布局,查询计划,调度和执行。由于这些组件都可以针对数据库的特定需求进行调整和优化,因此整个系统可以在其设计针对的查询类型上取得非常好的性能。而且,SQL查询语言允许以优雅的语法表达查询,而无需编写代码,可以在业务分析师使用的可视化工具(例如Tableau)中访问到。
|
||||
MPP 数据库是单体的,紧密集成的软件,负责磁盘上的存储布局,查询计划,调度和执行。由于这些组件都可以针对数据库的特定需求进行调整和优化,因此整个系统可以在其设计针对的查询类型上取得非常好的性能。而且,SQL 查询语言允许以优雅的语法表达查询,而无需编写代码,可以在业务分析师使用的可视化工具(例如 Tableau)中访问到。
|
||||
|
||||
另一方面,并非所有类型的处理都可以合理地表达为SQL查询。例如,如果要构建机器学习和推荐系统,或者使用相关性排名模型的全文搜索索引,或者执行图像分析,则很可能需要更一般的数据处理模型。这些类型的处理通常是特别针对特定应用的(例如机器学习的特征工程,机器翻译的自然语言模型,欺诈预测的风险评估函数),因此它们不可避免地需要编写代码,而不仅仅是查询。
|
||||
另一方面,并非所有类型的处理都可以合理地表达为 SQL 查询。例如,如果要构建机器学习和推荐系统,或者使用相关性排名模型的全文搜索索引,或者执行图像分析,则很可能需要更一般的数据处理模型。这些类型的处理通常是特别针对特定应用的(例如机器学习的特征工程,机器翻译的自然语言模型,欺诈预测的风险评估函数),因此它们不可避免地需要编写代码,而不仅仅是查询。
|
||||
|
||||
MapReduce使工程师能够轻松地在大型数据集上运行自己的代码。如果你有HDFS和MapReduce,那么你**可以**在它之上建立一个SQL查询执行引擎,事实上这正是Hive项目所做的【31】。但是,你也可以编写许多其他形式的批处理,这些批处理不必非要用SQL查询表示。
|
||||
MapReduce 使工程师能够轻松地在大型数据集上运行自己的代码。如果你有 HDFS 和 MapReduce,那么你 **可以** 在它之上建立一个 SQL 查询执行引擎,事实上这正是 Hive 项目所做的【31】。但是,你也可以编写许多其他形式的批处理,这些批处理不必非要用 SQL 查询表示。
|
||||
|
||||
随后,人们发现MapReduce对于某些类型的处理而言局限性很大,表现很差,因此在Hadoop之上其他各种处理模型也被开发出来(我们将在“[MapReduce之后](#MapReduce之后)”中看到其中一些)。只有两种处理模型,SQL和MapReduce,还不够,需要更多不同的模型!而且由于Hadoop平台的开放性,实施一整套方法是可行的,而这在单体MPP数据库的范畴内是不可能的【58】。
|
||||
随后,人们发现 MapReduce 对于某些类型的处理而言局限性很大,表现很差,因此在 Hadoop 之上其他各种处理模型也被开发出来(我们将在 “[MapReduce 之后](#MapReduce之后)” 中看到其中一些)。只有两种处理模型,SQL 和 MapReduce,还不够,需要更多不同的模型!而且由于 Hadoop 平台的开放性,实施一整套方法是可行的,而这在单体 MPP 数据库的范畴内是不可能的【58】。
|
||||
|
||||
至关重要的是,这些不同的处理模型都可以在共享的单个机器集群上运行,所有这些机器都可以访问分布式文件系统上的相同文件。在Hadoop方式中,不需要将数据导入到几个不同的专用系统中进行不同类型的处理:系统足够灵活,可以支持同一个集群内不同的工作负载。不需要移动数据,使得从数据中挖掘价值变得容易得多,也使采用新的处理模型容易的多。
|
||||
至关重要的是,这些不同的处理模型都可以在共享的单个机器集群上运行,所有这些机器都可以访问分布式文件系统上的相同文件。在 Hadoop 方式中,不需要将数据导入到几个不同的专用系统中进行不同类型的处理:系统足够灵活,可以支持同一个集群内不同的工作负载。不需要移动数据,使得从数据中挖掘价值变得容易得多,也使采用新的处理模型容易的多。
|
||||
|
||||
Hadoop生态系统包括随机访问的OLTP数据库,如HBase(请参阅“[SSTables和LSM树](ch3.md#SSTables和LSM树)”)和MPP风格的分析型数据库,如Impala 【41】。 HBase与Impala都不使用MapReduce,但都使用HDFS进行存储。它们是迥异的数据访问与处理方法,但是它们可以共存,并被集成到同一个系统中。
|
||||
Hadoop 生态系统包括随机访问的 OLTP 数据库,如 HBase(请参阅 “[SSTables 和 LSM 树](ch3.md#SSTables和LSM树)”)和 MPP 风格的分析型数据库,如 Impala 【41】。 HBase 与 Impala 都不使用 MapReduce,但都使用 HDFS 进行存储。它们是迥异的数据访问与处理方法,但是它们可以共存,并被集成到同一个系统中。
|
||||
|
||||
#### 针对频繁故障设计
|
||||
|
||||
当比较MapReduce和MPP数据库时,两种不同的设计思路出现了:处理故障和使用内存与磁盘的方式。与在线系统相比,批处理对故障不太敏感,因为就算失败也不会立即影响到用户,而且它们总是能再次运行。
|
||||
当比较 MapReduce 和 MPP 数据库时,两种不同的设计思路出现了:处理故障和使用内存与磁盘的方式。与在线系统相比,批处理对故障不太敏感,因为就算失败也不会立即影响到用户,而且它们总是能再次运行。
|
||||
|
||||
如果一个节点在执行查询时崩溃,大多数MPP数据库会中止整个查询,并让用户重新提交查询或自动重新运行它【3】。由于查询通常最多运行几秒钟或几分钟,所以这种错误处理的方法是可以接受的,因为重试的代价不是太大。 MPP数据库还倾向于在内存中保留尽可能多的数据(例如,使用散列连接)以避免从磁盘读取的开销。
|
||||
如果一个节点在执行查询时崩溃,大多数 MPP 数据库会中止整个查询,并让用户重新提交查询或自动重新运行它【3】。由于查询通常最多运行几秒钟或几分钟,所以这种错误处理的方法是可以接受的,因为重试的代价不是太大。 MPP 数据库还倾向于在内存中保留尽可能多的数据(例如,使用散列连接)以避免从磁盘读取的开销。
|
||||
|
||||
另一方面,MapReduce可以容忍单个Map或Reduce任务的失败,而不会影响作业的整体,通过以单个任务的粒度重试工作。它也会非常急切地将数据写入磁盘,一方面是为了容错,另一部分是因为假设数据集太大而不能适应内存。
|
||||
另一方面,MapReduce 可以容忍单个 Map 或 Reduce 任务的失败,而不会影响作业的整体,通过以单个任务的粒度重试工作。它也会非常急切地将数据写入磁盘,一方面是为了容错,另一部分是因为假设数据集太大而不能适应内存。
|
||||
|
||||
MapReduce方式更适用于较大的作业:要处理如此之多的数据并运行很长时间的作业,以至于在此过程中很可能至少遇到一个任务故障。在这种情况下,由于单个任务失败而重新运行整个作业将是非常浪费的。即使以单个任务的粒度进行恢复引入了使得无故障处理更慢的开销,但如果任务失败率足够高,这仍然是一种合理的权衡。
|
||||
MapReduce 方式更适用于较大的作业:要处理如此之多的数据并运行很长时间的作业,以至于在此过程中很可能至少遇到一个任务故障。在这种情况下,由于单个任务失败而重新运行整个作业将是非常浪费的。即使以单个任务的粒度进行恢复引入了使得无故障处理更慢的开销,但如果任务失败率足够高,这仍然是一种合理的权衡。
|
||||
|
||||
但是这些假设有多么现实呢?在大多数集群中,机器故障确实会发生,但是它们不是很频繁 —— 可能少到绝大多数作业都不会经历机器故障。为了容错,真的值得带来这么大的额外开销吗?
|
||||
|
||||
要了解MapReduce节约使用内存和在任务的层次进行恢复的原因,了解最初设计MapReduce的环境是很有帮助的。 Google有着混用的数据中心,在线生产服务和离线批处理作业在同样机器上运行。每个任务都有一个通过容器强制执行的资源配给(CPU核心,RAM,磁盘空间等)。每个任务也具有优先级,如果优先级较高的任务需要更多的资源,则可以终止(抢占)同一台机器上较低优先级的任务以释放资源。优先级还决定了计算资源的定价:团队必须为他们使用的资源付费,而优先级更高的进程花费更多【59】。
|
||||
要了解 MapReduce 节约使用内存和在任务的层次进行恢复的原因,了解最初设计 MapReduce 的环境是很有帮助的。Google 有着混用的数据中心,在线生产服务和离线批处理作业在同样机器上运行。每个任务都有一个通过容器强制执行的资源配给(CPU 核心、RAM、磁盘空间等)。每个任务也具有优先级,如果优先级较高的任务需要更多的资源,则可以终止(抢占)同一台机器上较低优先级的任务以释放资源。优先级还决定了计算资源的定价:团队必须为他们使用的资源付费,而优先级更高的进程花费更多【59】。
|
||||
|
||||
这种架构允许非生产(低优先级)计算资源被**过量使用(overcommitted)**,因为系统知道必要时它可以回收资源。与分离生产和非生产任务的系统相比,过量使用资源可以更好地利用机器并提高效率。但由于MapReduce作业以低优先级运行,它们随时都有被抢占的风险,因为优先级较高的进程可能需要其资源。在高优先级进程拿走所需资源后,批量作业能有效地“捡面包屑”,利用剩下的任何计算资源。
|
||||
这种架构允许非生产(低优先级)计算资源被 **过量使用(overcommitted)**,因为系统知道必要时它可以回收资源。与分离生产和非生产任务的系统相比,过量使用资源可以更好地利用机器并提高效率。但由于 MapReduce 作业以低优先级运行,它们随时都有被抢占的风险,因为优先级较高的进程可能需要其资源。在高优先级进程拿走所需资源后,批量作业能有效地 “捡面包屑”,利用剩下的任何计算资源。
|
||||
|
||||
在谷歌,运行一个小时的MapReduce任务有大约有5%的风险被终止,为了给更高优先级的进程挪地方。这一概率比硬件问题、机器重启或其他原因的概率高了一个数量级【59】。按照这种抢占率,如果一个作业有100个任务,每个任务运行10分钟,那么至少有一个任务在完成之前被终止的风险大于50%。
|
||||
在谷歌,运行一个小时的 MapReduce 任务有大约有 5% 的风险被终止,为了给更高优先级的进程挪地方。这一概率比硬件问题、机器重启或其他原因的概率高了一个数量级【59】。按照这种抢占率,如果一个作业有 100 个任务,每个任务运行 10 分钟,那么至少有一个任务在完成之前被终止的风险大于 50%。
|
||||
|
||||
这就是MapReduce被设计为容忍频繁意外任务终止的原因:不是因为硬件很不可靠,而是因为任意终止进程的自由有利于提高计算集群中的资源利用率。
|
||||
这就是 MapReduce 被设计为容忍频繁意外任务终止的原因:不是因为硬件很不可靠,而是因为任意终止进程的自由有利于提高计算集群中的资源利用率。
|
||||
|
||||
在开源的集群调度器中,抢占的使用较少。 YARN的CapacityScheduler支持抢占,以平衡不同队列的资源分配【58】,但在编写本文时,YARN,Mesos或Kubernetes不支持通用的优先级抢占【60】。在任务不经常被终止的环境中,MapReduce的这一设计决策就没有多少意义了。在下一节中,我们将研究一些与MapReduce设计决策相异的替代方案。
|
||||
在开源的集群调度器中,抢占的使用较少。 YARN 的 CapacityScheduler 支持抢占,以平衡不同队列的资源分配【58】,但在编写本文时,YARN,Mesos 或 Kubernetes 不支持通用的优先级抢占【60】。在任务不经常被终止的环境中,MapReduce 的这一设计决策就没有多少意义了。在下一节中,我们将研究一些与 MapReduce 设计决策相异的替代方案。
|
||||
|
||||
|
||||
## MapReduce之后
|
||||
|
||||
虽然MapReduce在2000年代后期变得非常流行,并受到大量的炒作,但它只是分布式系统的许多可能的编程模型之一。对于不同的数据量,数据结构和处理类型,其他工具可能更适合表示计算。
|
||||
虽然 MapReduce 在 2000 年代后期变得非常流行,并受到大量的炒作,但它只是分布式系统的许多可能的编程模型之一。对于不同的数据量,数据结构和处理类型,其他工具可能更适合表示计算。
|
||||
|
||||
|
||||
不管如何,我们在这一章花了大把时间来讨论MapReduce,因为它是一种有用的学习工具,它是分布式文件系统的一种相当简单明晰的抽象。在这里,**简单**意味着我们能理解它在做什么,而不是意味着使用它很简单。恰恰相反:使用原始的MapReduce API来实现复杂的处理工作实际上是非常困难和费力的 —— 例如,任意一种连接算法都需要你从头开始实现【37】。
|
||||
不管如何,我们在这一章花了大把时间来讨论 MapReduce,因为它是一种有用的学习工具,它是分布式文件系统的一种相当简单明晰的抽象。在这里,**简单** 意味着我们能理解它在做什么,而不是意味着使用它很简单。恰恰相反:使用原始的 MapReduce API 来实现复杂的处理工作实际上是非常困难和费力的 —— 例如,任意一种连接算法都需要你从头开始实现【37】。
|
||||
|
||||
针对直接使用MapReduce的困难,在MapReduce上有很多高级编程模型(Pig,Hive,Cascading,Crunch)被创造出来,作为建立在MapReduce之上的抽象。如果你了解MapReduce的原理,那么它们学起来相当简单。而且它们的高级结构能显著简化许多常见批处理任务的实现。
|
||||
针对直接使用 MapReduce 的困难,在 MapReduce 上有很多高级编程模型(Pig、Hive、Cascading、Crunch)被创造出来,作为建立在 MapReduce 之上的抽象。如果你了解 MapReduce 的原理,那么它们学起来相当简单。而且它们的高级结构能显著简化许多常见批处理任务的实现。
|
||||
|
||||
但是,MapReduce执行模型本身也存在一些问题,这些问题并没有通过增加另一个抽象层次而解决,而对于某些类型的处理,它表现得非常差劲。一方面,MapReduce非常稳健:你可以使用它在任务会频繁终止的多租户系统上处理几乎任意大量级的数据,并且仍然可以完成工作(虽然速度很慢)。另一方面,对于某些类型的处理而言,其他工具有时会快上几个数量级。
|
||||
但是,MapReduce 执行模型本身也存在一些问题,这些问题并没有通过增加另一个抽象层次而解决,而对于某些类型的处理,它表现得非常差劲。一方面,MapReduce 非常稳健:你可以使用它在任务会频繁终止的多租户系统上处理几乎任意大量级的数据,并且仍然可以完成工作(虽然速度很慢)。另一方面,对于某些类型的处理而言,其他工具有时会快上几个数量级。
|
||||
|
||||
在本章的其余部分中,我们将介绍一些批处理方法。在[第十一章](ch11.md)我们将转向流处理,它可以看作是加速批处理的另一种方法。
|
||||
在本章的其余部分中,我们将介绍一些批处理方法。在 [第十一章](ch11.md) 我们将转向流处理,它可以看作是加速批处理的另一种方法。
|
||||
|
||||
### 物化中间状态
|
||||
|
||||
如前所述,每个MapReduce作业都独立于其他任何作业。作业与世界其他地方的主要连接点是分布式文件系统上的输入和输出目录。如果希望一个作业的输出成为第二个作业的输入,则需要将第二个作业的输入目录配置为第一个作业输出目录,且外部工作流调度程序必须在第一个作业完成后再启动第二个。
|
||||
如前所述,每个 MapReduce 作业都独立于其他任何作业。作业与世界其他地方的主要连接点是分布式文件系统上的输入和输出目录。如果希望一个作业的输出成为第二个作业的输入,则需要将第二个作业的输入目录配置为第一个作业输出目录,且外部工作流调度程序必须在第一个作业完成后再启动第二个。
|
||||
|
||||
如果第一个作业的输出是要在组织内广泛发布的数据集,则这种配置是合理的。在这种情况下,你需要通过名称引用它,并将其重用为多个不同作业的输入(包括由其他团队开发的作业)。将数据发布到分布式文件系统中众所周知的位置能够带来**松耦合**,这样作业就不需要知道是谁在提供输入或谁在消费输出(请参阅“[逻辑与布线相分离](#逻辑与布线相分离)”)。
|
||||
如果第一个作业的输出是要在组织内广泛发布的数据集,则这种配置是合理的。在这种情况下,你需要通过名称引用它,并将其重用为多个不同作业的输入(包括由其他团队开发的作业)。将数据发布到分布式文件系统中众所周知的位置能够带来 **松耦合**,这样作业就不需要知道是谁在提供输入或谁在消费输出(请参阅 “[逻辑与布线相分离](#逻辑与布线相分离)”)。
|
||||
|
||||
但在很多情况下,你知道一个作业的输出只能用作另一个作业的输入,这些作业由同一个团队维护。在这种情况下,分布式文件系统上的文件只是简单的**中间状态(intermediate state)**:一种将数据从一个作业传递到下一个作业的方式。在一个用于构建推荐系统的,由50或100个MapReduce作业组成的复杂工作流中,存在着很多这样的中间状态【29】。
|
||||
但在很多情况下,你知道一个作业的输出只能用作另一个作业的输入,这些作业由同一个团队维护。在这种情况下,分布式文件系统上的文件只是简单的 **中间状态(intermediate state)**:一种将数据从一个作业传递到下一个作业的方式。在一个用于构建推荐系统的,由 50 或 100 个 MapReduce 作业组成的复杂工作流中,存在着很多这样的中间状态【29】。
|
||||
|
||||
将这个中间状态写入文件的过程称为**物化(materialization)**。 (在“[聚合:数据立方体和物化视图](ch3.md#聚合:数据立方体和物化视图)”中已经在物化视图的背景中遇到过这个术语。它意味着对某个操作的结果立即求值并写出来,而不是在请求时按需计算)
|
||||
将这个中间状态写入文件的过程称为 **物化(materialization)**。 (在 “[聚合:数据立方体和物化视图](ch3.md#聚合:数据立方体和物化视图)” 中已经在物化视图的背景中遇到过这个术语。它意味着对某个操作的结果立即求值并写出来,而不是在请求时按需计算)
|
||||
|
||||
作为对照,本章开头的日志分析示例使用Unix管道将一个命令的输出与另一个命令的输入连接起来。管道并没有完全物化中间状态,而是只使用一个小的内存缓冲区,将输出增量地**流(stream)** 向输入。
|
||||
作为对照,本章开头的日志分析示例使用 Unix 管道将一个命令的输出与另一个命令的输入连接起来。管道并没有完全物化中间状态,而是只使用一个小的内存缓冲区,将输出增量地 **流(stream)** 向输入。
|
||||
|
||||
与Unix管道相比,MapReduce完全物化中间状态的方法存在不足之处:
|
||||
与 Unix 管道相比,MapReduce 完全物化中间状态的方法存在不足之处:
|
||||
|
||||
- MapReduce作业只有在前驱作业(生成其输入)中的所有任务都完成时才能启动,而由Unix管道连接的进程会同时启动,输出一旦生成就会被消费。不同机器上的数据偏斜或负载不均意味着一个作业往往会有一些掉队的任务,比其他任务要慢得多才能完成。必须等待至前驱作业的所有任务完成,拖慢了整个工作流程的执行。
|
||||
- Mapper通常是多余的:它们仅仅是读取刚刚由Reducer写入的同样文件,为下一个阶段的分区和排序做准备。在许多情况下,Mapper代码可能是前驱Reducer的一部分:如果Reducer和Mapper的输出有着相同的分区与排序方式,那么Reducer就可以直接串在一起,而不用与Mapper相互交织。
|
||||
- MapReduce 作业只有在前驱作业(生成其输入)中的所有任务都完成时才能启动,而由 Unix 管道连接的进程会同时启动,输出一旦生成就会被消费。不同机器上的数据偏斜或负载不均意味着一个作业往往会有一些掉队的任务,比其他任务要慢得多才能完成。必须等待至前驱作业的所有任务完成,拖慢了整个工作流程的执行。
|
||||
- Mapper 通常是多余的:它们仅仅是读取刚刚由 Reducer 写入的同样文件,为下一个阶段的分区和排序做准备。在许多情况下,Mapper 代码可能是前驱 Reducer 的一部分:如果 Reducer 和 Mapper 的输出有着相同的分区与排序方式,那么 Reducer 就可以直接串在一起,而不用与 Mapper 相互交织。
|
||||
- 将中间状态存储在分布式文件系统中意味着这些文件被复制到多个节点,对这些临时数据这么搞就比较过分了。
|
||||
|
||||
#### 数据流引擎
|
||||
|
||||
为了解决MapReduce的这些问题,几种用于分布式批处理的新执行引擎被开发出来,其中最著名的是Spark 【61,62】,Tez 【63,64】和Flink 【65,66】。它们的设计方式有很多区别,但有一个共同点:把整个工作流作为单个作业来处理,而不是把它分解为独立的子作业。
|
||||
为了解决 MapReduce 的这些问题,几种用于分布式批处理的新执行引擎被开发出来,其中最著名的是 Spark 【61,62】,Tez 【63,64】和 Flink 【65,66】。它们的设计方式有很多区别,但有一个共同点:把整个工作流作为单个作业来处理,而不是把它分解为独立的子作业。
|
||||
|
||||
由于它们将工作流显式建模为数据从几个处理阶段穿过,所以这些系统被称为**数据流引擎(dataflow engines)**。像MapReduce一样,它们在一条线上通过反复调用用户定义的函数来一次处理一条记录,它们通过输入分区来并行化载荷,它们通过网络将一个函数的输出复制到另一个函数的输入。
|
||||
由于它们将工作流显式建模为数据从几个处理阶段穿过,所以这些系统被称为 **数据流引擎(dataflow engines)**。像 MapReduce 一样,它们在一条线上通过反复调用用户定义的函数来一次处理一条记录,它们通过输入分区来并行化载荷,它们通过网络将一个函数的输出复制到另一个函数的输入。
|
||||
|
||||
与MapReduce不同,这些函数不需要严格扮演交织的Map与Reduce的角色,而是可以以更灵活的方式进行组合。我们称这些函数为**算子(operators)**,数据流引擎提供了几种不同的选项来将一个算子的输出连接到另一个算子的输入:
|
||||
与 MapReduce 不同,这些函数不需要严格扮演交织的 Map 与 Reduce 的角色,而是可以以更灵活的方式进行组合。我们称这些函数为 **算子(operators)**,数据流引擎提供了几种不同的选项来将一个算子的输出连接到另一个算子的输入:
|
||||
|
||||
- 一种选项是对记录按键重新分区并排序,就像在MapReduce的混洗阶段一样(请参阅“[分布式执行MapReduce](#分布式执行MapReduce)”)。这种功能可以用于实现排序合并连接和分组,就像在MapReduce中一样。
|
||||
- 一种选项是对记录按键重新分区并排序,就像在 MapReduce 的混洗阶段一样(请参阅 “[分布式执行 MapReduce](#分布式执行MapReduce)”)。这种功能可以用于实现排序合并连接和分组,就像在 MapReduce 中一样。
|
||||
- 另一种可能是接受多个输入,并以相同的方式进行分区,但跳过排序。当记录的分区重要但顺序无关紧要时,这省去了分区散列连接的工作,因为构建散列表还是会把顺序随机打乱。
|
||||
- 对于广播散列连接,可以将一个算子的输出,发送到连接算子的所有分区。
|
||||
|
||||
这种类型的处理引擎是基于像Dryad【67】和Nephele【68】这样的研究系统,与MapReduce模型相比,它有几个优点:
|
||||
这种类型的处理引擎是基于像 Dryad【67】和 Nephele【68】这样的研究系统,与 MapReduce 模型相比,它有几个优点:
|
||||
|
||||
- 排序等昂贵的工作只需要在实际需要的地方执行,而不是默认地在每个Map和Reduce阶段之间出现。
|
||||
- 没有不必要的Map任务,因为Mapper所做的工作通常可以合并到前面的Reduce算子中(因为Mapper不会更改数据集的分区)。
|
||||
- 排序等昂贵的工作只需要在实际需要的地方执行,而不是默认地在每个 Map 和 Reduce 阶段之间出现。
|
||||
- 没有不必要的 Map 任务,因为 Mapper 所做的工作通常可以合并到前面的 Reduce 算子中(因为 Mapper 不会更改数据集的分区)。
|
||||
- 由于工作流中的所有连接和数据依赖都是显式声明的,因此调度程序能够总览全局,知道哪里需要哪些数据,因而能够利用局部性进行优化。例如,它可以尝试将消费某些数据的任务放在与生成这些数据的任务相同的机器上,从而数据可以通过共享内存缓冲区传输,而不必通过网络复制。
|
||||
- 通常,算子间的中间状态足以保存在内存中或写入本地磁盘,这比写入HDFS需要更少的I/O(必须将其复制到多台机器,并将每个副本写入磁盘)。 MapReduce已经对Mapper的输出做了这种优化,但数据流引擎将这种思想推广至所有的中间状态。
|
||||
- 通常,算子间的中间状态足以保存在内存中或写入本地磁盘,这比写入 HDFS 需要更少的 I/O(必须将其复制到多台机器,并将每个副本写入磁盘)。 MapReduce 已经对 Mapper 的输出做了这种优化,但数据流引擎将这种思想推广至所有的中间状态。
|
||||
- 算子可以在输入就绪后立即开始执行;后续阶段无需等待前驱阶段整个完成后再开始。
|
||||
- 与MapReduce(为每个任务启动一个新的JVM)相比,现有Java虚拟机(JVM)进程可以重用来运行新算子,从而减少启动开销。
|
||||
- 与 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。
|
||||
|
||||
#### 容错
|
||||
|
||||
完全物化中间状态至分布式文件系统的一个优点是,它具有持久性,这使得MapReduce中的容错相当容易:如果一个任务失败,它可以在另一台机器上重新启动,并从文件系统重新读取相同的输入。
|
||||
完全物化中间状态至分布式文件系统的一个优点是,它具有持久性,这使得 MapReduce 中的容错相当容易:如果一个任务失败,它可以在另一台机器上重新启动,并从文件系统重新读取相同的输入。
|
||||
|
||||
Spark,Flink和Tez避免将中间状态写入HDFS,因此它们采取了不同的方法来容错:如果一台机器发生故障,并且该机器上的中间状态丢失,则它会从其他仍然可用的数据重新计算(在可行的情况下是先前的中间状态,要么就只能是原始输入数据,通常在HDFS上)。
|
||||
Spark、Flink 和 Tez 避免将中间状态写入 HDFS,因此它们采取了不同的方法来容错:如果一台机器发生故障,并且该机器上的中间状态丢失,则它会从其他仍然可用的数据重新计算(在可行的情况下是先前的中间状态,要么就只能是原始输入数据,通常在 HDFS 上)。
|
||||
|
||||
为了实现这种重新计算,框架必须跟踪一个给定的数据是如何计算的 —— 使用了哪些输入分区?应用了哪些算子? Spark使用**弹性分布式数据集(RDD,Resilient Distributed Dataset)** 的抽象来跟踪数据的谱系【61】,而Flink对算子状态存档,允许恢复运行在执行过程中遇到错误的算子【66】。
|
||||
为了实现这种重新计算,框架必须跟踪一个给定的数据是如何计算的 —— 使用了哪些输入分区?应用了哪些算子? Spark 使用 **弹性分布式数据集(RDD,Resilient Distributed Dataset)** 的抽象来跟踪数据的谱系【61】,而 Flink 对算子状态存档,允许恢复运行在执行过程中遇到错误的算子【66】。
|
||||
|
||||
在重新计算数据时,重要的是要知道计算是否是**确定性的**:也就是说,给定相同的输入数据,算子是否始终产生相同的输出?如果一些丢失的数据已经发送给下游算子,这个问题就很重要。如果算子重新启动,重新计算的数据与原有的丢失数据不一致,下游算子很难解决新旧数据之间的矛盾。对于不确定性算子来说,解决方案通常是杀死下游算子,然后再重跑新数据。
|
||||
在重新计算数据时,重要的是要知道计算是否是 **确定性的**:也就是说,给定相同的输入数据,算子是否始终产生相同的输出?如果一些丢失的数据已经发送给下游算子,这个问题就很重要。如果算子重新启动,重新计算的数据与原有的丢失数据不一致,下游算子很难解决新旧数据之间的矛盾。对于不确定性算子来说,解决方案通常是杀死下游算子,然后再重跑新数据。
|
||||
|
||||
为了避免这种级联故障,最好让算子具有确定性。但需要注意的是,非确定性行为很容易悄悄溜进来:例如,许多编程语言在迭代哈希表的元素时不能对顺序作出保证,许多概率和统计算法显式依赖于使用随机数,以及用到系统时钟或外部数据源,这些都是都不确定性的行为。为了能可靠地从故障中恢复,需要消除这种不确定性因素,例如使用固定的种子生成伪随机数。
|
||||
|
||||
@ -589,136 +589,136 @@ Spark,Flink和Tez避免将中间状态写入HDFS,因此它们采取了不同
|
||||
|
||||
#### 关于物化的讨论
|
||||
|
||||
回到Unix的类比,我们看到,MapReduce就像是将每个命令的输出写入临时文件,而数据流引擎看起来更像是Unix管道。尤其是Flink是基于管道执行的思想而建立的:也就是说,将算子的输出增量地传递给其他算子,不待输入完成便开始处理。
|
||||
回到 Unix 的类比,我们看到,MapReduce 就像是将每个命令的输出写入临时文件,而数据流引擎看起来更像是 Unix 管道。尤其是 Flink 是基于管道执行的思想而建立的:也就是说,将算子的输出增量地传递给其他算子,不待输入完成便开始处理。
|
||||
|
||||
排序算子不可避免地需要消费全部的输入后才能生成任何输出,因为输入中最后一条输入记录可能具有最小的键,因此需要作为第一条记录输出。因此,任何需要排序的算子都需要至少暂时地累积状态。但是工作流的许多其他部分可以以流水线方式执行。
|
||||
|
||||
当作业完成时,它的输出需要持续到某个地方,以便用户可以找到并使用它—— 很可能它会再次写入分布式文件系统。因此,在使用数据流引擎时,HDFS上的物化数据集通常仍是作业的输入和最终输出。和MapReduce一样,输入是不可变的,输出被完全替换。比起MapReduce的改进是,你不用再自己去将中间状态写入文件系统了。
|
||||
当作业完成时,它的输出需要持续到某个地方,以便用户可以找到并使用它 —— 很可能它会再次写入分布式文件系统。因此,在使用数据流引擎时,HDFS 上的物化数据集通常仍是作业的输入和最终输出。和 MapReduce 一样,输入是不可变的,输出被完全替换。比起 MapReduce 的改进是,你不用再自己去将中间状态写入文件系统了。
|
||||
|
||||
### 图与迭代处理
|
||||
|
||||
在“[图数据模型](ch2.md#图数据模型)”中,我们讨论了使用图来建模数据,并使用图查询语言来遍历图中的边与点。[第二章](ch2.md)的讨论集中在OLTP风格的应用场景:快速执行查询来查找少量符合特定条件的顶点。
|
||||
在 “[图数据模型](ch2.md#图数据模型)” 中,我们讨论了使用图来建模数据,并使用图查询语言来遍历图中的边与点。[第二章](ch2.md) 的讨论集中在 OLTP 风格的应用场景:快速执行查询来查找少量符合特定条件的顶点。
|
||||
|
||||
批处理上下文中的图也很有趣,其目标是在整个图上执行某种离线处理或分析。这种需求经常出现在机器学习应用(如推荐引擎)或排序系统中。例如,最着名的图形分析算法之一是PageRank 【69】,它试图根据链接到某个网页的其他网页来估计该网页的流行度。它作为配方的一部分,用于确定网络搜索引擎呈现结果的顺序。
|
||||
批处理上下文中的图也很有趣,其目标是在整个图上执行某种离线处理或分析。这种需求经常出现在机器学习应用(如推荐引擎)或排序系统中。例如,最着名的图形分析算法之一是 PageRank 【69】,它试图根据链接到某个网页的其他网页来估计该网页的流行度。它作为配方的一部分,用于确定网络搜索引擎呈现结果的顺序。
|
||||
|
||||
> 像Spark,Flink和Tez这样的数据流引擎(请参阅“[物化中间状态](#物化中间状态)”)通常将算子作为**有向无环图(DAG)** 的一部分安排在作业中。这与图处理不一样:在数据流引擎中,**从一个算子到另一个算子的数据流**被构造成一个图,而数据本身通常由关系型元组构成。在图处理中,数据本身具有图的形式。又一个不幸的命名混乱!
|
||||
> 像 Spark、Flink 和 Tez 这样的数据流引擎(请参阅 “[物化中间状态](#物化中间状态)”)通常将算子作为 **有向无环图(DAG)** 的一部分安排在作业中。这与图处理不一样:在数据流引擎中,**从一个算子到另一个算子的数据流** 被构造成一个图,而数据本身通常由关系型元组构成。在图处理中,数据本身具有图的形式。又一个不幸的命名混乱!
|
||||
|
||||
许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在[图2-6](img/fig2-6.png)中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为**传递闭包**,即transitive closure)。
|
||||
许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在 [图 2-6](img/fig2-6.png) 中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为 **传递闭包**,即 transitive closure)。
|
||||
|
||||
可以在分布式文件系统中存储图(包含顶点和边的列表的文件),但是这种“重复至完成”的想法不能用普通的MapReduce来表示,因为它只扫过一趟数据。这种算法因此经常以**迭代**的风格实现:
|
||||
可以在分布式文件系统中存储图(包含顶点和边的列表的文件),但是这种 “重复至完成” 的想法不能用普通的 MapReduce 来表示,因为它只扫过一趟数据。这种算法因此经常以 **迭代** 的风格实现:
|
||||
|
||||
1. 外部调度程序运行批处理来计算算法的一个步骤。
|
||||
2. 当批处理过程完成时,调度器检查它是否完成(基于完成条件 —— 例如,没有更多的边要跟进,或者与上次迭代相比的变化低于某个阈值)。
|
||||
3. 如果尚未完成,则调度程序返回到步骤1并运行另一轮批处理。
|
||||
3. 如果尚未完成,则调度程序返回到步骤 1 并运行另一轮批处理。
|
||||
|
||||
这种方法是有效的,但是用MapReduce实现它往往非常低效,因为MapReduce没有考虑算法的迭代性质:它总是读取整个输入数据集并产生一个全新的输出数据集,即使与上次迭代相比,改变的仅仅是图中的一小部分。
|
||||
这种方法是有效的,但是用 MapReduce 实现它往往非常低效,因为 MapReduce 没有考虑算法的迭代性质:它总是读取整个输入数据集并产生一个全新的输出数据集,即使与上次迭代相比,改变的仅仅是图中的一小部分。
|
||||
|
||||
#### Pregel处理模型
|
||||
|
||||
针对图批处理的优化 —— **批量同步并行(BSP,Bulk Synchronous Parallel)** 计算模型【70】已经开始流行起来。其中,Apache Giraph 【37】,Spark的GraphX API和Flink的Gelly API 【71】实现了它。它也被称为**Pregel**模型,因为Google的Pregel论文推广了这种处理图的方法【72】。
|
||||
针对图批处理的优化 —— **批量同步并行(BSP,Bulk Synchronous Parallel)** 计算模型【70】已经开始流行起来。其中,Apache Giraph 【37】,Spark 的 GraphX API 和 Flink 的 Gelly API 【71】实现了它。它也被称为 **Pregel** 模型,因为 Google 的 Pregel 论文推广了这种处理图的方法【72】。
|
||||
|
||||
回想一下在MapReduce中,Mapper在概念上向Reducer的特定调用“发送消息”,因为框架将所有具有相同键的Mapper输出集中在一起。 Pregel背后有一个类似的想法:一个顶点可以向另一个顶点“发送消息”,通常这些消息是沿着图的边发送的。
|
||||
回想一下在 MapReduce 中,Mapper 在概念上向 Reducer 的特定调用 “发送消息”,因为框架将所有具有相同键的 Mapper 输出集中在一起。 Pregel 背后有一个类似的想法:一个顶点可以向另一个顶点 “发送消息”,通常这些消息是沿着图的边发送的。
|
||||
|
||||
在每次迭代中,为每个顶点调用一个函数,将所有发送给它的消息传递给它 —— 就像调用Reducer一样。与MapReduce的不同之处在于,在Pregel模型中,顶点在一次迭代到下一次迭代的过程中会记住它的状态,所以这个函数只需要处理新的传入消息。如果图的某个部分没有被发送消息,那里就不需要做任何工作。
|
||||
在每次迭代中,为每个顶点调用一个函数,将所有发送给它的消息传递给它 —— 就像调用 Reducer 一样。与 MapReduce 的不同之处在于,在 Pregel 模型中,顶点在一次迭代到下一次迭代的过程中会记住它的状态,所以这个函数只需要处理新的传入消息。如果图的某个部分没有被发送消息,那里就不需要做任何工作。
|
||||
|
||||
这与Actor模型有些相似(请参阅“[分布式的Actor框架](ch4.md#分布式的Actor框架)”),除了顶点状态和顶点之间的消息具有容错性和持久性,且通信以固定的回合进行:在每次迭代中,框架递送上次迭代中发送的所有消息。Actor通常没有这样的时序保证。
|
||||
这与 Actor 模型有些相似(请参阅 “[分布式的 Actor 框架](ch4.md#分布式的Actor框架)”),除了顶点状态和顶点之间的消息具有容错性和持久性,且通信以固定的回合进行:在每次迭代中,框架递送上次迭代中发送的所有消息。Actor 通常没有这样的时序保证。
|
||||
|
||||
#### 容错
|
||||
|
||||
顶点只能通过消息传递进行通信(而不是直接相互查询)的事实有助于提高Pregel作业的性能,因为消息可以成批处理,且等待通信的次数也减少了。唯一的等待是在迭代之间:由于Pregel模型保证所有在一轮迭代中发送的消息都在下轮迭代中送达,所以在下一轮迭代开始前,先前的迭代必须完全完成,而所有的消息必须在网络上完成复制。
|
||||
顶点只能通过消息传递进行通信(而不是直接相互查询)的事实有助于提高 Pregel 作业的性能,因为消息可以成批处理,且等待通信的次数也减少了。唯一的等待是在迭代之间:由于 Pregel 模型保证所有在一轮迭代中发送的消息都在下轮迭代中送达,所以在下一轮迭代开始前,先前的迭代必须完全完成,而所有的消息必须在网络上完成复制。
|
||||
|
||||
即使底层网络可能丢失、重复或任意延迟消息(请参阅“[不可靠的网络](ch8.md#不可靠的网络)”),Pregel的实现能保证在后续迭代中消息在其目标顶点恰好处理一次。像MapReduce一样,框架能从故障中透明地恢复,以简化在Pregel上实现算法的编程模型。
|
||||
即使底层网络可能丢失、重复或任意延迟消息(请参阅 “[不可靠的网络](ch8.md#不可靠的网络)”),Pregel 的实现能保证在后续迭代中消息在其目标顶点恰好处理一次。像 MapReduce 一样,框架能从故障中透明地恢复,以简化在 Pregel 上实现算法的编程模型。
|
||||
|
||||
这种容错是通过在迭代结束时,定期存档所有顶点的状态来实现的,即将其全部状态写入持久化存储。如果某个节点发生故障并且其内存中的状态丢失,则最简单的解决方法是将整个图计算回滚到上一个存档点,然后重启计算。如果算法是确定性的,且消息记录在日志中,那么也可以选择性地只恢复丢失的分区(就像之前讨论过的数据流引擎)【72】。
|
||||
|
||||
#### 并行执行
|
||||
|
||||
顶点不需要知道它在哪台物理机器上执行;当它向其他顶点发送消息时,它只是简单地将消息发往某个顶点ID。图的分区取决于框架 —— 即,确定哪个顶点运行在哪台机器上,以及如何通过网络路由消息,以便它们到达正确的地方。
|
||||
顶点不需要知道它在哪台物理机器上执行;当它向其他顶点发送消息时,它只是简单地将消息发往某个顶点 ID。图的分区取决于框架 —— 即,确定哪个顶点运行在哪台机器上,以及如何通过网络路由消息,以便它们到达正确的地方。
|
||||
|
||||
由于编程模型一次仅处理一个顶点(有时称为“像顶点一样思考”),所以框架可以以任意方式对图分区。理想情况下如果顶点需要进行大量的通信,那么它们最好能被分区到同一台机器上。然而找到这样一种优化的分区方法是很困难的 —— 在实践中,图经常按照任意分配的顶点ID分区,而不会尝试将相关的顶点分组在一起。
|
||||
由于编程模型一次仅处理一个顶点(有时称为 “像顶点一样思考”),所以框架可以以任意方式对图分区。理想情况下如果顶点需要进行大量的通信,那么它们最好能被分区到同一台机器上。然而找到这样一种优化的分区方法是很困难的 —— 在实践中,图经常按照任意分配的顶点 ID 分区,而不会尝试将相关的顶点分组在一起。
|
||||
|
||||
因此,图算法通常会有很多跨机器通信的额外开销,而中间状态(节点之间发送的消息)往往比原始图大。通过网络发送消息的开销会显著拖慢分布式图算法的速度。
|
||||
|
||||
出于这个原因,如果你的图可以放入一台计算机的内存中,那么单机(甚至可能是单线程)算法很可能会超越分布式批处理【73,74】。图比内存大也没关系,只要能放入单台计算机的磁盘,使用GraphChi等框架进行单机处理是就一个可行的选择【75】。如果图太大,不适合单机处理,那么像Pregel这样的分布式方法是不可避免的。高效的并行图算法是一个进行中的研究领域【76】。
|
||||
出于这个原因,如果你的图可以放入一台计算机的内存中,那么单机(甚至可能是单线程)算法很可能会超越分布式批处理【73,74】。图比内存大也没关系,只要能放入单台计算机的磁盘,使用 GraphChi 等框架进行单机处理是就一个可行的选择【75】。如果图太大,不适合单机处理,那么像 Pregel 这样的分布式方法是不可避免的。高效的并行图算法是一个进行中的研究领域【76】。
|
||||
|
||||
|
||||
### 高级API和语言
|
||||
|
||||
自MapReduce开始流行的这几年以来,分布式批处理的执行引擎已经很成熟了。到目前为止,基础设施已经足够强大,能够存储和处理超过10,000台机器集群上的数PB的数据。由于在这种规模下物理执行批处理的问题已经被认为或多或少解决了,所以关注点已经转向其他领域:改进编程模型,提高处理效率,扩大这些技术可以解决的问题集。
|
||||
自 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通常使用关系型构建块来表达一个计算:按某个字段连接数据集;按键对元组做分组;按某些条件过滤;并通过计数求和或其他函数来聚合元组。在内部,这些操作是使用本章前面讨论过的各种连接和分组算法来实现的。
|
||||
这些数据流 API 通常使用关系型构建块来表达一个计算:按某个字段连接数据集;按键对元组做分组;按某些条件过滤;并通过计数求和或其他函数来聚合元组。在内部,这些操作是使用本章前面讨论过的各种连接和分组算法来实现的。
|
||||
|
||||
除了少写代码的明显优势之外,这些高级接口还支持交互式用法,在这种交互式使用中,你可以在Shell中增量式编写分析代码,频繁运行来观察它做了什么。这种开发风格在探索数据集和试验处理方法时非常有用。这也让人联想到Unix哲学,我们在“[Unix哲学](#Unix哲学)”中讨论过这个问题。
|
||||
除了少写代码的明显优势之外,这些高级接口还支持交互式用法,在这种交互式使用中,你可以在 Shell 中增量式编写分析代码,频繁运行来观察它做了什么。这种开发风格在探索数据集和试验处理方法时非常有用。这也让人联想到 Unix 哲学,我们在 “[Unix 哲学](#Unix哲学)” 中讨论过这个问题。
|
||||
|
||||
此外,这些高级接口不仅提高了人类的工作效率,也提高了机器层面的作业执行效率。
|
||||
|
||||
#### 向声明式查询语言的转变
|
||||
|
||||
与硬写执行连接的代码相比,指定连接关系算子的优点是,框架可以分析连接输入的属性,并自动决定哪种上述连接算法最适合当前任务。 Hive,Spark和Flink都有基于代价的查询优化器可以做到这一点,甚至可以改变连接顺序,最小化中间状态的数量【66,77,78,79】。
|
||||
与硬写执行连接的代码相比,指定连接关系算子的优点是,框架可以分析连接输入的属性,并自动决定哪种上述连接算法最适合当前任务。 Hive、Spark 和 Flink 都有基于代价的查询优化器可以做到这一点,甚至可以改变连接顺序,最小化中间状态的数量【66,77,78,79】。
|
||||
|
||||
连接算法的选择可以对批处理作业的性能产生巨大影响,而无需理解和记住本章中讨论的各种连接算法。如果连接是以**声明式(declarative)** 的方式指定的,那这就这是可行的:应用只是简单地说明哪些连接是必需的,查询优化器决定如何最好地执行连接。我们以前在“[数据查询语言](ch2.md#数据查询语言)”中见过这个想法。
|
||||
连接算法的选择可以对批处理作业的性能产生巨大影响,而无需理解和记住本章中讨论的各种连接算法。如果连接是以 **声明式(declarative)** 的方式指定的,那这就这是可行的:应用只是简单地说明哪些连接是必需的,查询优化器决定如何最好地执行连接。我们以前在 “[数据查询语言](ch2.md#数据查询语言)” 中见过这个想法。
|
||||
|
||||
但MapReduce及其数据流后继者在其他方面,与SQL的完全声明式查询模型有很大区别。 MapReduce是围绕着回调函数的概念建立的:对于每条记录或者一组记录,调用一个用户定义的函数(Mapper或Reducer),并且该函数可以自由地调用任意代码来决定输出什么。这种方法的优点是可以基于大量已有库的生态系统创作:解析、自然语言分析、图像分析以及运行数值或统计算法等。
|
||||
但 MapReduce 及其数据流后继者在其他方面,与 SQL 的完全声明式查询模型有很大区别。 MapReduce 是围绕着回调函数的概念建立的:对于每条记录或者一组记录,调用一个用户定义的函数(Mapper 或 Reducer),并且该函数可以自由地调用任意代码来决定输出什么。这种方法的优点是可以基于大量已有库的生态系统创作:解析、自然语言分析、图像分析以及运行数值或统计算法等。
|
||||
|
||||
自由运行任意代码,长期以来都是传统MapReduce批处理系统与MPP数据库的区别所在(请参阅“[Hadoop与分布式数据库的对比](#Hadoop与分布式数据库的对比)”一节)。虽然数据库具有编写用户定义函数的功能,但是它们通常使用起来很麻烦,而且与大多数编程语言中广泛使用的程序包管理器和依赖管理系统兼容不佳(例如Java的Maven、Javascript的npm以及Ruby的gems)。
|
||||
自由运行任意代码,长期以来都是传统 MapReduce 批处理系统与 MPP 数据库的区别所在(请参阅 “[Hadoop 与分布式数据库的对比](#Hadoop与分布式数据库的对比)” 一节)。虽然数据库具有编写用户定义函数的功能,但是它们通常使用起来很麻烦,而且与大多数编程语言中广泛使用的程序包管理器和依赖管理系统兼容不佳(例如 Java 的 Maven、Javascript 的 npm 以及 Ruby 的 gems)。
|
||||
|
||||
然而数据流引擎已经发现,支持除连接之外的更多**声明式特性**还有其他的优势。例如,如果一个回调函数只包含一个简单的过滤条件,或者只是从一条记录中选择了一些字段,那么在为每条记录调用函数时会有相当大的额外CPU开销。如果以声明方式表示这些简单的过滤和映射操作,那么查询优化器可以利用列式存储布局(请参阅“[列式存储](ch3.md#列式存储)”),只从磁盘读取所需的列。 Hive、Spark DataFrames和Impala还使用了向量化执行(请参阅“[内存带宽和向量处理](ch3.md#内存带宽和向量处理)”):在对CPU缓存友好的内部循环中迭代数据,避免函数调用。Spark生成JVM字节码【79】,Impala使用LLVM为这些内部循环生成本机代码【41】。
|
||||
然而数据流引擎已经发现,支持除连接之外的更多 **声明式特性** 还有其他的优势。例如,如果一个回调函数只包含一个简单的过滤条件,或者只是从一条记录中选择了一些字段,那么在为每条记录调用函数时会有相当大的额外 CPU 开销。如果以声明方式表示这些简单的过滤和映射操作,那么查询优化器可以利用列式存储布局(请参阅 “[列式存储](ch3.md#列式存储)”),只从磁盘读取所需的列。 Hive、Spark DataFrames 和 Impala 还使用了向量化执行(请参阅 “[内存带宽和向量处理](ch3.md#内存带宽和向量处理)”):在对 CPU 缓存友好的内部循环中迭代数据,避免函数调用。Spark 生成 JVM 字节码【79】,Impala 使用 LLVM 为这些内部循环生成本机代码【41】。
|
||||
|
||||
通过在高级API中引入声明式的部分,并使查询优化器可以在执行期间利用这些来做优化,批处理框架看起来越来越像MPP数据库了(并且能实现可与之媲美的性能)。同时,通过拥有运行任意代码和以任意格式读取数据的可扩展性,它们保持了灵活性的优势。
|
||||
通过在高级 API 中引入声明式的部分,并使查询优化器可以在执行期间利用这些来做优化,批处理框架看起来越来越像 MPP 数据库了(并且能实现可与之媲美的性能)。同时,通过拥有运行任意代码和以任意格式读取数据的可扩展性,它们保持了灵活性的优势。
|
||||
|
||||
#### 专业化的不同领域
|
||||
|
||||
尽管能够运行任意代码的可扩展性是很有用的,但是也有很多常见的例子,不断重复着标准的处理模式。因而这些模式值得拥有自己的可重用通用构建模块实现。传统上,MPP数据库满足了商业智能分析和业务报表的需求,但这只是许多使用批处理的领域之一。
|
||||
尽管能够运行任意代码的可扩展性是很有用的,但是也有很多常见的例子,不断重复着标准的处理模式。因而这些模式值得拥有自己的可重用通用构建模块实现。传统上,MPP 数据库满足了商业智能分析和业务报表的需求,但这只是许多使用批处理的领域之一。
|
||||
|
||||
另一个越来越重要的领域是统计和数值算法,它们是机器学习应用所需要的(例如分类器和推荐系统)。可重用的实现正在出现:例如,Mahout在MapReduce、Spark和Flink之上实现了用于机器学习的各种算法,而MADlib在关系型MPP数据库(Apache HAWQ)中实现了类似的功能【54】。
|
||||
另一个越来越重要的领域是统计和数值算法,它们是机器学习应用所需要的(例如分类器和推荐系统)。可重用的实现正在出现:例如,Mahout 在 MapReduce、Spark 和 Flink 之上实现了用于机器学习的各种算法,而 MADlib 在关系型 MPP 数据库(Apache HAWQ)中实现了类似的功能【54】。
|
||||
|
||||
空间算法也是有用的,例如**k近邻搜索(k-nearest neighbors, kNN)**【80】,它在一些多维空间中搜索与给定项最近的项目 —— 这是一种相似性搜索。近似搜索对于基因组分析算法也很重要,它们需要找到相似但不相同的字符串【81】。
|
||||
空间算法也是有用的,例如 **k 近邻搜索(k-nearest neighbors, kNN)**【80】,它在一些多维空间中搜索与给定项最近的项目 —— 这是一种相似性搜索。近似搜索对于基因组分析算法也很重要,它们需要找到相似但不相同的字符串【81】。
|
||||
|
||||
批处理引擎正被用于分布式执行日益广泛的各领域算法。随着批处理系统获得各种内置功能以及高级声明式算子,且随着MPP数据库变得更加灵活和易于编程,两者开始看起来相似了:最终,它们都只是存储和处理数据的系统。
|
||||
批处理引擎正被用于分布式执行日益广泛的各领域算法。随着批处理系统获得各种内置功能以及高级声明式算子,且随着 MPP 数据库变得更加灵活和易于编程,两者开始看起来相似了:最终,它们都只是存储和处理数据的系统。
|
||||
|
||||
|
||||
## 本章小结
|
||||
|
||||
在本章中,我们探索了批处理的主题。我们首先看到了诸如awk、grep和sort之类的Unix工具,然后我们看到了这些工具的设计理念是如何应用到MapReduce和更近的数据流引擎中的。一些设计原则包括:输入是不可变的,输出是为了作为另一个(仍未知的)程序的输入,而复杂的问题是通过编写“做好一件事”的小工具来解决的。
|
||||
在本章中,我们探索了批处理的主题。我们首先看到了诸如 awk、grep 和 sort 之类的 Unix 工具,然后我们看到了这些工具的设计理念是如何应用到 MapReduce 和更近的数据流引擎中的。一些设计原则包括:输入是不可变的,输出是为了作为另一个(仍未知的)程序的输入,而复杂的问题是通过编写 “做好一件事” 的小工具来解决的。
|
||||
|
||||
在Unix世界中,允许程序与程序组合的统一接口是文件与管道;在MapReduce中,该接口是一个分布式文件系统。我们看到数据流引擎添加了自己的管道式数据传输机制,以避免将中间状态物化至分布式文件系统,但作业的初始输入和最终输出通常仍是HDFS。
|
||||
在 Unix 世界中,允许程序与程序组合的统一接口是文件与管道;在 MapReduce 中,该接口是一个分布式文件系统。我们看到数据流引擎添加了自己的管道式数据传输机制,以避免将中间状态物化至分布式文件系统,但作业的初始输入和最终输出通常仍是 HDFS。
|
||||
|
||||
分布式批处理框架需要解决的两个主要问题是:
|
||||
|
||||
* 分区
|
||||
|
||||
在MapReduce中,Mapper根据输入文件块进行分区。Mapper的输出被重新分区、排序并合并到可配置数量的Reducer分区中。这一过程的目的是把所有的**相关**数据(例如带有相同键的所有记录)都放在同一个地方。
|
||||
在 MapReduce 中,Mapper 根据输入文件块进行分区。Mapper 的输出被重新分区、排序并合并到可配置数量的 Reducer 分区中。这一过程的目的是把所有的 **相关** 数据(例如带有相同键的所有记录)都放在同一个地方。
|
||||
|
||||
后MapReduce时代的数据流引擎若非必要会尽量避免排序,但它们也采取了大致类似的分区方法。
|
||||
后 MapReduce 时代的数据流引擎若非必要会尽量避免排序,但它们也采取了大致类似的分区方法。
|
||||
|
||||
* 容错
|
||||
|
||||
MapReduce经常写入磁盘,这使得从单个失败的任务恢复很轻松,无需重新启动整个作业,但在无故障的情况下减慢了执行速度。数据流引擎更多地将中间状态保存在内存中,更少地物化中间状态,这意味着如果节点发生故障,则需要重算更多的数据。确定性算子减少了需要重算的数据量。
|
||||
MapReduce 经常写入磁盘,这使得从单个失败的任务恢复很轻松,无需重新启动整个作业,但在无故障的情况下减慢了执行速度。数据流引擎更多地将中间状态保存在内存中,更少地物化中间状态,这意味着如果节点发生故障,则需要重算更多的数据。确定性算子减少了需要重算的数据量。
|
||||
|
||||
|
||||
我们讨论了几种MapReduce的连接算法,其中大多数也在MPP数据库和数据流引擎内部使用。它们也很好地演示了分区算法是如何工作的:
|
||||
我们讨论了几种 MapReduce 的连接算法,其中大多数也在 MPP 数据库和数据流引擎内部使用。它们也很好地演示了分区算法是如何工作的:
|
||||
|
||||
* 排序合并连接
|
||||
|
||||
每个参与连接的输入都通过一个提取连接键的Mapper。通过分区、排序和合并,具有相同键的所有记录最终都会进入相同的Reducer调用。这个函数能输出连接好的记录。
|
||||
每个参与连接的输入都通过一个提取连接键的 Mapper。通过分区、排序和合并,具有相同键的所有记录最终都会进入相同的 Reducer 调用。这个函数能输出连接好的记录。
|
||||
|
||||
* 广播散列连接
|
||||
|
||||
两个连接输入之一很小,所以它并没有分区,而且能被完全加载进一个哈希表中。因此,你可以为连接输入大端的每个分区启动一个Mapper,将输入小端的散列表加载到每个Mapper中,然后扫描大端,一次一条记录,并为每条记录查询散列表。
|
||||
两个连接输入之一很小,所以它并没有分区,而且能被完全加载进一个哈希表中。因此,你可以为连接输入大端的每个分区启动一个 Mapper,将输入小端的散列表加载到每个 Mapper 中,然后扫描大端,一次一条记录,并为每条记录查询散列表。
|
||||
|
||||
* 分区散列连接
|
||||
|
||||
如果两个连接输入以相同的方式分区(使用相同的键,相同的散列函数和相同数量的分区),则可以独立地对每个分区应用散列表方法。
|
||||
|
||||
分布式批处理引擎有一个刻意限制的编程模型:回调函数(比如Mapper和Reducer)被假定是无状态的,而且除了指定的输出外,必须没有任何外部可见的副作用。这一限制允许框架在其抽象下隐藏一些困难的分布式系统问题:当遇到崩溃和网络问题时,任务可以安全地重试,任何失败任务的输出都被丢弃。如果某个分区的多个任务成功,则其中只有一个能使其输出实际可见。
|
||||
分布式批处理引擎有一个刻意限制的编程模型:回调函数(比如 Mapper 和 Reducer)被假定是无状态的,而且除了指定的输出外,必须没有任何外部可见的副作用。这一限制允许框架在其抽象下隐藏一些困难的分布式系统问题:当遇到崩溃和网络问题时,任务可以安全地重试,任何失败任务的输出都被丢弃。如果某个分区的多个任务成功,则其中只有一个能使其输出实际可见。
|
||||
|
||||
得益于这个框架,你在批处理作业中的代码无需操心实现容错机制:框架可以保证作业的最终输出与没有发生错误的情况相同,虽然实际上也许不得不重试各种任务。比起在线服务一边处理用户请求一边将写入数据库作为处理请求的副作用,批处理提供的这种可靠性语义要强得多。
|
||||
|
||||
批处理作业的显著特点是,它读取一些输入数据并产生一些输出数据,但不修改输入—— 换句话说,输出是从输入衍生出的。最关键的是,输入数据是**有界的(bounded)**:它有一个已知的,固定的大小(例如,它包含一些时间点的日志文件或数据库内容的快照)。因为它是有界的,一个作业知道自己什么时候完成了整个输入的读取,所以一个工作在做完后,最终总是会完成的。
|
||||
批处理作业的显著特点是,它读取一些输入数据并产生一些输出数据,但不修改输入 —— 换句话说,输出是从输入衍生出的。最关键的是,输入数据是 **有界的(bounded)**:它有一个已知的,固定的大小(例如,它包含一些时间点的日志文件或数据库内容的快照)。因为它是有界的,一个作业知道自己什么时候完成了整个输入的读取,所以一个工作在做完后,最终总是会完成的。
|
||||
|
||||
在下一章中,我们将转向流处理,其中的输入是**无界的(unbounded)** —— 也就是说,你还有活儿要干,然而它的输入是永无止境的数据流。在这种情况下,作业永无完成之日。因为在任何时候都可能有更多的工作涌入。我们将看到,在某些方面上,流处理和批处理是相似的。但是关于无尽数据流的假设也对我们构建系统的方式产生了很多改变。
|
||||
在下一章中,我们将转向流处理,其中的输入是 **无界的(unbounded)** —— 也就是说,你还有活儿要干,然而它的输入是永无止境的数据流。在这种情况下,作业永无完成之日。因为在任何时候都可能有更多的工作涌入。我们将看到,在某些方面上,流处理和批处理是相似的。但是关于无尽数据流的假设也对我们构建系统的方式产生了很多改变。
|
||||
|
||||
|
||||
## 参考文献
|
||||
|
396
ch11.md
396
ch11.md
@ -4,34 +4,34 @@
|
||||
|
||||
> 有效的复杂系统总是从简单的系统演化而来。 反之亦然:从零设计的复杂系统没一个能有效工作的。
|
||||
>
|
||||
> —— 约翰·加尔,Systemantics(1975)
|
||||
> —— 约翰・加尔,Systemantics(1975)
|
||||
|
||||
---------------
|
||||
|
||||
[TOC]
|
||||
|
||||
在[第十章](ch10.md)中,我们讨论了批处理技术,它读取一组文件作为输入,并生成一组新的文件作为输出。输出是**衍生数据(derived data)** 的一种形式;也就是说,如果需要,可以通过再次运行批处理过程来重新创建数据集。我们看到了如何使用这个简单而强大的想法来建立搜索索引、推荐系统、做分析等等。
|
||||
在 [第十章](ch10.md) 中,我们讨论了批处理技术,它读取一组文件作为输入,并生成一组新的文件作为输出。输出是 **衍生数据(derived data)** 的一种形式;也就是说,如果需要,可以通过再次运行批处理过程来重新创建数据集。我们看到了如何使用这个简单而强大的想法来建立搜索索引、推荐系统、做分析等等。
|
||||
|
||||
然而,在[第十章](ch10.md)中仍然有一个很大的假设:即输入是有界的,即已知和有限的大小,所以批处理知道它何时完成输入的读取。例如,MapReduce核心的排序操作必须读取其全部输入,然后才能开始生成输出:可能发生这种情况:最后一条输入记录具有最小的键,因此需要第一个被输出,所以提早开始输出是不可行的。
|
||||
然而,在 [第十章](ch10.md) 中仍然有一个很大的假设:即输入是有界的,即已知和有限的大小,所以批处理知道它何时完成输入的读取。例如,MapReduce 核心的排序操作必须读取其全部输入,然后才能开始生成输出:可能发生这种情况:最后一条输入记录具有最小的键,因此需要第一个被输出,所以提早开始输出是不可行的。
|
||||
|
||||
实际上,很多数据是**无界限**的,因为它随着时间的推移而逐渐到达:你的用户在昨天和今天产生了数据,明天他们将继续产生更多的数据。除非你停业,否则这个过程永远都不会结束,所以数据集从来就不会以任何有意义的方式“完成”【1】。因此,批处理程序必须将数据人为地分成固定时间段的数据块,例如,在每天结束时处理一天的数据,或者在每小时结束时处理一小时的数据。
|
||||
实际上,很多数据是 **无界限** 的,因为它随着时间的推移而逐渐到达:你的用户在昨天和今天产生了数据,明天他们将继续产生更多的数据。除非你停业,否则这个过程永远都不会结束,所以数据集从来就不会以任何有意义的方式 “完成”【1】。因此,批处理程序必须将数据人为地分成固定时间段的数据块,例如,在每天结束时处理一天的数据,或者在每小时结束时处理一小时的数据。
|
||||
|
||||
日常批处理中的问题是,输入的变更只会在一天之后的输出中反映出来,这对于许多急躁的用户来说太慢了。为了减少延迟,我们可以更频繁地运行处理 —— 比如说,在每秒钟的末尾 —— 或者甚至更连续一些,完全抛开固定的时间切片,当事件发生时就立即进行处理,这就是**流处理(stream processing)** 背后的想法。
|
||||
日常批处理中的问题是,输入的变更只会在一天之后的输出中反映出来,这对于许多急躁的用户来说太慢了。为了减少延迟,我们可以更频繁地运行处理 —— 比如说,在每秒钟的末尾 —— 或者甚至更连续一些,完全抛开固定的时间切片,当事件发生时就立即进行处理,这就是 **流处理(stream processing)** 背后的想法。
|
||||
|
||||
一般来说,“流”是指随着时间的推移逐渐可用的数据。这个概念出现在很多地方:Unix的stdin和stdout,编程语言(惰性列表)【2】,文件系统API(如Java的`FileInputStream`),TCP连接,通过互联网传送音频和视频等等。
|
||||
一般来说,“流” 是指随着时间的推移逐渐可用的数据。这个概念出现在很多地方:Unix 的 stdin 和 stdout,编程语言(惰性列表)【2】,文件系统 API(如 Java 的 `FileInputStream`),TCP 连接,通过互联网传送音频和视频等等。
|
||||
|
||||
在本章中,我们将把**事件流(event stream)** 视为一种数据管理机制:无界限,增量处理,与上一章中的批量数据相对应。我们将首先讨论怎样表示、存储、通过网络传输流。在“[数据库与流](#数据库与流)”中,我们将研究流和数据库之间的关系。最后在“[流处理](#流处理)”中,我们将研究连续处理这些流的方法和工具,以及它们用于应用构建的方式。
|
||||
在本章中,我们将把 **事件流(event stream)** 视为一种数据管理机制:无界限,增量处理,与上一章中的批量数据相对应。我们将首先讨论怎样表示、存储、通过网络传输流。在 “[数据库与流](#数据库与流)” 中,我们将研究流和数据库之间的关系。最后在 “[流处理](#流处理)” 中,我们将研究连续处理这些流的方法和工具,以及它们用于应用构建的方式。
|
||||
|
||||
|
||||
## 传递事件流
|
||||
|
||||
在批处理领域,作业的输入和输出是文件(也许在分布式文件系统上)。流处理领域中的等价物看上去是什么样子的?
|
||||
|
||||
当输入是一个文件(一个字节序列),第一个处理步骤通常是将其解析为一系列记录。在流处理的上下文中,记录通常被叫做 **事件(event)** ,但它本质上是一样的:一个小的、自包含的、不可变的对象,包含某个时间点发生的某件事情的细节。一个事件通常包含一个来自日历时钟的时间戳,以指明事件发生的时间(请参阅“[单调钟与日历时钟](ch8.md#单调钟与日历时钟)”)。
|
||||
当输入是一个文件(一个字节序列),第一个处理步骤通常是将其解析为一系列记录。在流处理的上下文中,记录通常被叫做 **事件(event)** ,但它本质上是一样的:一个小的、自包含的、不可变的对象,包含某个时间点发生的某件事情的细节。一个事件通常包含一个来自日历时钟的时间戳,以指明事件发生的时间(请参阅 “[单调钟与日历时钟](ch8.md#单调钟与日历时钟)”)。
|
||||
|
||||
例如,发生的事件可能是用户采取的行动,例如查看页面或进行购买。它也可能来源于机器,例如对温度传感器或CPU利用率的周期性测量。在“[使用Unix工具的批处理](ch10.md#使用Unix工具的批处理)”的示例中,Web服务器日志的每一行都是一个事件。
|
||||
例如,发生的事件可能是用户采取的行动,例如查看页面或进行购买。它也可能来源于机器,例如对温度传感器或 CPU 利用率的周期性测量。在 “[使用 Unix 工具的批处理](ch10.md#使用Unix工具的批处理)” 的示例中,Web 服务器日志的每一行都是一个事件。
|
||||
|
||||
事件可能被编码为文本字符串或JSON,或者某种二进制编码,如[第四章](ch4.md)所述。这种编码允许你存储一个事件,例如将其追加到一个文件,将其插入关系表,或将其写入文档数据库。它还允许你通过网络将事件发送到另一个节点以进行处理。
|
||||
事件可能被编码为文本字符串或 JSON,或者某种二进制编码,如 [第四章](ch4.md) 所述。这种编码允许你存储一个事件,例如将其追加到一个文件,将其插入关系表,或将其写入文档数据库。它还允许你通过网络将事件发送到另一个节点以进行处理。
|
||||
|
||||
在批处理中,文件被写入一次,然后可能被多个作业读取。类似地,在流处理术语中,一个事件由 **生产者(producer)** (也称为 **发布者(publisher)** 或 **发送者(sender)** )生成一次,然后可能由多个 **消费者(consumer)** ( **订阅者(subscribers)** 或 **接收者(recipients)** )进行处理【3】。在文件系统中,文件名标识一组相关记录;在流式系统中,相关的事件通常被聚合为一个 **主题(topic)** 或 **流(stream)** 。
|
||||
|
||||
@ -44,30 +44,30 @@
|
||||
|
||||
### 消息传递系统
|
||||
|
||||
向消费者通知新事件的常用方式是使用**消息传递系统(messaging system)**:生产者发送包含事件的消息,然后将消息推送给消费者。我们之前在“[消息传递中的数据流](ch4.md#消息传递中的数据流)”中谈到了这些系统,但现在我们将详细介绍这些系统。
|
||||
向消费者通知新事件的常用方式是使用 **消息传递系统(messaging system)**:生产者发送包含事件的消息,然后将消息推送给消费者。我们之前在 “[消息传递中的数据流](ch4.md#消息传递中的数据流)” 中谈到了这些系统,但现在我们将详细介绍这些系统。
|
||||
|
||||
像生产者和消费者之间的Unix管道或TCP连接这样的直接信道,是实现消息传递系统的简单方法。但是,大多数消息传递系统都在这一基本模型上进行了扩展。特别的是,Unix管道和TCP将恰好一个发送者与恰好一个接收者连接,而一个消息传递系统允许多个生产者节点将消息发送到同一个主题,并允许多个消费者节点接收主题中的消息。
|
||||
像生产者和消费者之间的 Unix 管道或 TCP 连接这样的直接信道,是实现消息传递系统的简单方法。但是,大多数消息传递系统都在这一基本模型上进行了扩展。特别的是,Unix 管道和 TCP 将恰好一个发送者与恰好一个接收者连接,而一个消息传递系统允许多个生产者节点将消息发送到同一个主题,并允许多个消费者节点接收主题中的消息。
|
||||
|
||||
在这个**发布/订阅**模式中,不同的系统采取各种各样的方法,并没有针对所有目的的通用答案。为了区分这些系统,问一下这两个问题会特别有帮助:
|
||||
在这个 **发布 / 订阅** 模式中,不同的系统采取各种各样的方法,并没有针对所有目的的通用答案。为了区分这些系统,问一下这两个问题会特别有帮助:
|
||||
|
||||
1. **如果生产者发送消息的速度比消费者能够处理的速度快会发生什么?** 一般来说,有三种选择:系统可以丢掉消息,将消息放入缓冲队列,或使用**背压**(backpressure,也称为**流量控制**,即flow control:阻塞生产者,以免其发送更多的消息)。例如Unix管道和TCP就使用了背压:它们有一个固定大小的小缓冲区,如果填满,发送者会被阻塞,直到接收者从缓冲区中取出数据(请参阅“[网络拥塞和排队](ch8.md#网络拥塞和排队)”)。
|
||||
1. **如果生产者发送消息的速度比消费者能够处理的速度快会发生什么?** 一般来说,有三种选择:系统可以丢掉消息,将消息放入缓冲队列,或使用 **背压**(backpressure,也称为 **流量控制**,即 flow control:阻塞生产者,以免其发送更多的消息)。例如 Unix 管道和 TCP 就使用了背压:它们有一个固定大小的小缓冲区,如果填满,发送者会被阻塞,直到接收者从缓冲区中取出数据(请参阅 “[网络拥塞和排队](ch8.md#网络拥塞和排队)”)。
|
||||
|
||||
如果消息被缓存在队列中,那么理解队列增长会发生什么是很重要的。当队列装不进内存时系统会崩溃吗?还是将消息写入磁盘?如果是这样,磁盘访问又会如何影响消息传递系统的性能【6】?
|
||||
|
||||
2. **如果节点崩溃或暂时脱机,会发生什么情况? —— 是否会有消息丢失?** 与数据库一样,持久性可能需要写入磁盘和/或复制的某种组合(请参阅“[复制与持久性](ch7.md#复制与持久性)”),这是有代价的。如果你能接受有时消息会丢失,则可能在同一硬件上获得更高的吞吐量和更低的延迟。
|
||||
2. **如果节点崩溃或暂时脱机,会发生什么情况? —— 是否会有消息丢失?** 与数据库一样,持久性可能需要写入磁盘和 / 或复制的某种组合(请参阅 “[复制与持久性](ch7.md#复制与持久性)”),这是有代价的。如果你能接受有时消息会丢失,则可能在同一硬件上获得更高的吞吐量和更低的延迟。
|
||||
|
||||
是否可以接受消息丢失取决于应用。例如,对于周期传输的传感器读数和指标,偶尔丢失的数据点可能并不重要,因为更新的值会在短时间内发出。但要注意,如果大量的消息被丢弃,可能无法立刻意识到指标已经不正确了【7】。如果你正在对事件计数,那么它们能够可靠送达是更重要的,因为每个丢失的消息都意味着使计数器的错误扩大。
|
||||
|
||||
我们在[第十章](ch10.md)中探讨的批处理系统的一个很好的特性是,它们提供了强大的可靠性保证:失败的任务会自动重试,失败任务的部分输出会自动丢弃。这意味着输出与没有发生故障一样,这有助于简化编程模型。在本章的后面,我们将研究如何在流处理的上下文中提供类似的保证。
|
||||
我们在 [第十章](ch10.md) 中探讨的批处理系统的一个很好的特性是,它们提供了强大的可靠性保证:失败的任务会自动重试,失败任务的部分输出会自动丢弃。这意味着输出与没有发生故障一样,这有助于简化编程模型。在本章的后面,我们将研究如何在流处理的上下文中提供类似的保证。
|
||||
|
||||
#### 直接从生产者传递给消费者
|
||||
|
||||
许多消息传递系统使用生产者和消费者之间的直接网络通信,而不通过中间节点:
|
||||
|
||||
* UDP组播广泛应用于金融行业,例如股票市场,其中低时延非常重要【8】。虽然UDP本身是不可靠的,但应用层的协议可以恢复丢失的数据包(生产者必须记住它发送的数据包,以便能按需重新发送数据包)。
|
||||
* 无代理的消息库,如ZeroMQ 【9】和nanomsg采取类似的方法,通过TCP或IP多播实现发布/订阅消息传递。
|
||||
* StatsD 【10】和Brubeck 【7】使用不可靠的UDP消息传递来收集网络中所有机器的指标并对其进行监控。 (在StatsD协议中,只有接收到所有消息,才认为计数器指标是正确的;使用UDP将使得指标处在一种最佳近似状态【11】。另请参阅“[TCP与UDP](ch8.md#TCP与UDP)”
|
||||
* 如果消费者在网络上公开了服务,生产者可以直接发送HTTP或RPC请求(请参阅“[服务中的数据流:REST与RPC](ch4.md#服务中的数据流:REST与RPC)”)将消息推送给使用者。这就是webhooks背后的想法【12】,一种服务的回调URL被注册到另一个服务中,并且每当事件发生时都会向该URL发出请求。
|
||||
* UDP 组播广泛应用于金融行业,例如股票市场,其中低时延非常重要【8】。虽然 UDP 本身是不可靠的,但应用层的协议可以恢复丢失的数据包(生产者必须记住它发送的数据包,以便能按需重新发送数据包)。
|
||||
* 无代理的消息库,如 ZeroMQ 【9】和 nanomsg 采取类似的方法,通过 TCP 或 IP 多播实现发布 / 订阅消息传递。
|
||||
* StatsD 【10】和 Brubeck 【7】使用不可靠的 UDP 消息传递来收集网络中所有机器的指标并对其进行监控。 (在 StatsD 协议中,只有接收到所有消息,才认为计数器指标是正确的;使用 UDP 将使得指标处在一种最佳近似状态【11】。另请参阅 “[TCP 与 UDP](ch8.md#TCP与UDP)”
|
||||
* 如果消费者在网络上公开了服务,生产者可以直接发送 HTTP 或 RPC 请求(请参阅 “[服务中的数据流:REST 与 RPC](ch4.md#服务中的数据流:REST与RPC)”)将消息推送给使用者。这就是 webhooks 背后的想法【12】,一种服务的回调 URL 被注册到另一个服务中,并且每当事件发生时都会向该 URL 发出请求。
|
||||
|
||||
尽管这些直接消息传递系统在设计它们的环境中运行良好,但是它们通常要求应用代码意识到消息丢失的可能性。它们的容错程度极为有限:即使协议检测到并重传在网络中丢失的数据包,它们通常也只是假设生产者和消费者始终在线。
|
||||
|
||||
@ -75,54 +75,54 @@
|
||||
|
||||
#### 消息代理
|
||||
|
||||
一种广泛使用的替代方法是通过**消息代理**(message broker,也称为**消息队列**,即message queue)发送消息,消息代理实质上是一种针对处理消息流而优化的数据库。它作为服务器运行,生产者和消费者作为客户端连接到服务器。生产者将消息写入代理,消费者通过从代理那里读取来接收消息。
|
||||
一种广泛使用的替代方法是通过 **消息代理**(message broker,也称为 **消息队列**,即 message queue)发送消息,消息代理实质上是一种针对处理消息流而优化的数据库。它作为服务器运行,生产者和消费者作为客户端连接到服务器。生产者将消息写入代理,消费者通过从代理那里读取来接收消息。
|
||||
|
||||
通过将数据集中在代理上,这些系统可以更容易地容忍来来去去的客户端(连接,断开连接和崩溃),而持久性问题则转移到代理的身上。一些消息代理只将消息保存在内存中,而另一些消息代理(取决于配置)将其写入磁盘,以便在代理崩溃的情况下不会丢失。针对缓慢的消费者,它们通常会允许无上限的排队(而不是丢弃消息或背压),尽管这种选择也可能取决于配置。
|
||||
|
||||
排队的结果是,消费者通常是**异步(asynchronous)** 的:当生产者发送消息时,通常只会等待代理确认消息已经被缓存,而不等待消息被消费者处理。向消费者递送消息将发生在未来某个未定的时间点 —— 通常在几分之一秒之内,但有时当消息堆积时会显著延迟。
|
||||
排队的结果是,消费者通常是 **异步(asynchronous)** 的:当生产者发送消息时,通常只会等待代理确认消息已经被缓存,而不等待消息被消费者处理。向消费者递送消息将发生在未来某个未定的时间点 —— 通常在几分之一秒之内,但有时当消息堆积时会显著延迟。
|
||||
|
||||
#### 消息代理与数据库的对比
|
||||
|
||||
有些消息代理甚至可以使用XA或JTA参与两阶段提交协议(请参阅“[实践中的分布式事务](ch9.md#实践中的分布式事务)”)。这个功能与数据库在本质上非常相似,尽管消息代理和数据库之间仍存在实践上很重要的差异:
|
||||
有些消息代理甚至可以使用 XA 或 JTA 参与两阶段提交协议(请参阅 “[实践中的分布式事务](ch9.md#实践中的分布式事务)”)。这个功能与数据库在本质上非常相似,尽管消息代理和数据库之间仍存在实践上很重要的差异:
|
||||
|
||||
* 数据库通常保留数据直至显式删除,而大多数消息代理在消息成功递送给消费者时会自动删除消息。这样的消息代理不适合长期的数据存储。
|
||||
* 由于它们很快就能删除消息,大多数消息代理都认为它们的工作集相当小—— 即队列很短。如果代理需要缓冲很多消息,比如因为消费者速度较慢(如果内存装不下消息,可能会溢出到磁盘),每个消息需要更长的处理时间,整体吞吐量可能会恶化【6】。
|
||||
* 由于它们很快就能删除消息,大多数消息代理都认为它们的工作集相当小 —— 即队列很短。如果代理需要缓冲很多消息,比如因为消费者速度较慢(如果内存装不下消息,可能会溢出到磁盘),每个消息需要更长的处理时间,整体吞吐量可能会恶化【6】。
|
||||
* 数据库通常支持次级索引和各种搜索数据的方式,而消息代理通常支持按照某种模式匹配主题,订阅其子集。虽然机制并不一样,但对于客户端选择想要了解的数据的一部分,都是基本的方式。
|
||||
* 查询数据库时,结果通常基于某个时间点的数据快照;如果另一个客户端随后向数据库写入一些改变了查询结果的内容,则第一个客户端不会发现其先前结果现已过期(除非它重复查询或轮询变更)。相比之下,消息代理不支持任意查询,但是当数据发生变化时(即新消息可用时),它们会通知客户端。
|
||||
|
||||
这是关于消息代理的传统观点,它被封装在诸如JMS 【14】和AMQP 【15】的标准中,并且被诸如RabbitMQ、ActiveMQ、HornetQ、Qpid、TIBCO企业消息服务、IBM MQ、Azure Service Bus和Google Cloud Pub/Sub所实现 【16】。
|
||||
这是关于消息代理的传统观点,它被封装在诸如 JMS 【14】和 AMQP 【15】的标准中,并且被诸如 RabbitMQ、ActiveMQ、HornetQ、Qpid、TIBCO 企业消息服务、IBM MQ、Azure Service Bus 和 Google Cloud Pub/Sub 所实现 【16】。
|
||||
|
||||
#### 多个消费者
|
||||
|
||||
当多个消费者从同一主题中读取消息时,有两种主要的消息传递模式,如[图11-1](img/fig11-1.png)所示:
|
||||
当多个消费者从同一主题中读取消息时,有两种主要的消息传递模式,如 [图 11-1](img/fig11-1.png) 所示:
|
||||
|
||||
* 负载均衡(load balancing)
|
||||
|
||||
每条消息都被传递给消费者**之一**,所以处理该主题下消息的工作能被多个消费者共享。代理可以为消费者任意分配消息。当处理消息的代价高昂,希望能并行处理消息时,此模式非常有用(在AMQP中,可以通过让多个客户端从同一个队列中消费来实现负载均衡,而在JMS中则称之为**共享订阅**,即shared subscription)。
|
||||
每条消息都被传递给消费者 **之一**,所以处理该主题下消息的工作能被多个消费者共享。代理可以为消费者任意分配消息。当处理消息的代价高昂,希望能并行处理消息时,此模式非常有用(在 AMQP 中,可以通过让多个客户端从同一个队列中消费来实现负载均衡,而在 JMS 中则称之为 **共享订阅**,即 shared subscription)。
|
||||
|
||||
* 扇出(fan-out)
|
||||
|
||||
每条消息都被传递给**所有**消费者。扇出允许几个独立的消费者各自“收听”相同的消息广播,而不会相互影响 —— 这个流处理中的概念对应批处理中多个不同批处理作业读取同一份输入文件 (JMS中的主题订阅与AMQP中的交叉绑定提供了这一功能)。
|
||||
每条消息都被传递给 **所有** 消费者。扇出允许几个独立的消费者各自 “收听” 相同的消息广播,而不会相互影响 —— 这个流处理中的概念对应批处理中多个不同批处理作业读取同一份输入文件 (JMS 中的主题订阅与 AMQP 中的交叉绑定提供了这一功能)。
|
||||
|
||||
![](img/fig11-1.png)
|
||||
|
||||
**图11-1 (a)负载平衡:在消费者间共享消费主题;(b)扇出:将每条消息传递给多个消费者。**
|
||||
**图 11-1 (a)负载平衡:在消费者间共享消费主题;(b)扇出:将每条消息传递给多个消费者。**
|
||||
|
||||
两种模式可以组合使用:例如,两个独立的消费者组可以每组各订阅同一个主题,每一组都共同收到所有消息,但在每一组内部,每条消息仅由单个节点处理。
|
||||
|
||||
#### 确认与重新传递
|
||||
|
||||
消费者随时可能会崩溃,所以有一种可能的情况是:代理向消费者递送消息,但消费者没有处理,或者在消费者崩溃之前只进行了部分处理。为了确保消息不会丢失,消息代理使用**确认(acknowledgments)**:客户端必须显式告知代理消息处理完毕的时间,以便代理能将消息从队列中移除。
|
||||
消费者随时可能会崩溃,所以有一种可能的情况是:代理向消费者递送消息,但消费者没有处理,或者在消费者崩溃之前只进行了部分处理。为了确保消息不会丢失,消息代理使用 **确认(acknowledgments)**:客户端必须显式告知代理消息处理完毕的时间,以便代理能将消息从队列中移除。
|
||||
|
||||
如果与客户端的连接关闭,或者代理超出一段时间未收到确认,代理则认为消息没有被处理,因此它将消息再递送给另一个消费者。 (请注意可能发生这样的情况,消息**实际上是**处理完毕的,但**确认**在网络中丢失了。需要一种原子提交协议才能处理这种情况,正如在“[实践中的分布式事务](ch9.md#实践中的分布式事务)”中所讨论的那样)
|
||||
如果与客户端的连接关闭,或者代理超出一段时间未收到确认,代理则认为消息没有被处理,因此它将消息再递送给另一个消费者。 (请注意可能发生这样的情况,消息 **实际上是** 处理完毕的,但 **确认** 在网络中丢失了。需要一种原子提交协议才能处理这种情况,正如在 “[实践中的分布式事务](ch9.md#实践中的分布式事务)” 中所讨论的那样)
|
||||
|
||||
当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在[图11-2](img/fig11-2.png)中,消费者通常按照生产者发送的顺序处理消息。然而消费者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 在处理m3时消费者2崩溃,因此稍后重传至消费者1**
|
||||
**图 11-2 在处理 m3 时消费者 2 崩溃,因此稍后重传至消费者 1**
|
||||
|
||||
即使消息代理试图保留消息的顺序(如JMS和AMQP标准所要求的),负载均衡与重传的组合也不可避免地导致消息被重新排序。为避免此问题,你可以让每个消费者使用单独的队列(即不使用负载均衡功能)。如果消息是完全独立的,则消息顺序重排并不是一个问题。但正如我们将在本章后续部分所述,如果消息之间存在因果依赖关系,这就是一个很重要的问题。
|
||||
即使消息代理试图保留消息的顺序(如 JMS 和 AMQP 标准所要求的),负载均衡与重传的组合也不可避免地导致消息被重新排序。为避免此问题,你可以让每个消费者使用单独的队列(即不使用负载均衡功能)。如果消息是完全独立的,则消息顺序重排并不是一个问题。但正如我们将在本章后续部分所述,如果消息之间存在因果依赖关系,这就是一个很重要的问题。
|
||||
|
||||
### 分区日志
|
||||
|
||||
@ -130,38 +130,38 @@
|
||||
|
||||
数据库和文件系统采用截然相反的方法论:至少在某人显式删除前,通常写入数据库或文件的所有内容都要被永久记录下来。
|
||||
|
||||
这种思维方式上的差异对创建衍生数据的方式有巨大影响。如[第十章](ch10.md)所述,批处理过程的一个关键特性是,你可以反复运行它们,试验处理步骤,不用担心损坏输入(因为输入是只读的)。而 AMQP/JMS风格的消息传递并非如此:收到消息是具有破坏性的,因为确认可能导致消息从代理中被删除,因此你不能期望再次运行同一个消费者能得到相同的结果。
|
||||
这种思维方式上的差异对创建衍生数据的方式有巨大影响。如 [第十章](ch10.md) 所述,批处理过程的一个关键特性是,你可以反复运行它们,试验处理步骤,不用担心损坏输入(因为输入是只读的)。而 AMQP/JMS 风格的消息传递并非如此:收到消息是具有破坏性的,因为确认可能导致消息从代理中被删除,因此你不能期望再次运行同一个消费者能得到相同的结果。
|
||||
|
||||
如果你将新的消费者添加到消息传递系统,通常只能接收到消费者注册之后开始发送的消息。先前的任何消息都随风而逝,一去不复返。作为对比,你可以随时为文件和数据库添加新的客户端,且能读取任意久远的数据(只要应用没有显式覆盖或删除这些数据)。
|
||||
|
||||
为什么我们不能把它俩杂交一下,既有数据库的持久存储方式,又有消息传递的低延迟通知?这就是**基于日志的消息代理(log-based message brokers)** 背后的想法。
|
||||
为什么我们不能把它俩杂交一下,既有数据库的持久存储方式,又有消息传递的低延迟通知?这就是 **基于日志的消息代理(log-based message brokers)** 背后的想法。
|
||||
|
||||
#### 使用日志进行消息存储
|
||||
|
||||
日志只是磁盘上简单的仅追加记录序列。我们先前在[第三章](ch3.md)中日志结构存储引擎和预写式日志的上下文中讨论了日志,在[第五章](ch5.md)复制的上下文里也讨论了它。
|
||||
日志只是磁盘上简单的仅追加记录序列。我们先前在 [第三章](ch3.md) 中日志结构存储引擎和预写式日志的上下文中讨论了日志,在 [第五章](ch5.md) 复制的上下文里也讨论了它。
|
||||
|
||||
同样的结构可以用于实现消息代理:生产者通过将消息追加到日志末尾来发送消息,而消费者通过依次读取日志来接收消息。如果消费者读到日志末尾,则会等待新消息追加的通知。 Unix工具`tail -f` 能监视文件被追加写入的数据,基本上就是这样工作的。
|
||||
同样的结构可以用于实现消息代理:生产者通过将消息追加到日志末尾来发送消息,而消费者通过依次读取日志来接收消息。如果消费者读到日志末尾,则会等待新消息追加的通知。 Unix 工具 `tail -f` 能监视文件被追加写入的数据,基本上就是这样工作的。
|
||||
|
||||
为了伸缩超出单个磁盘所能提供的更高吞吐量,可以对日志进行**分区**(按[第六章](ch6.md)的定义)。不同的分区可以托管在不同的机器上,使得每个分区都有一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如[图11-3](img/fig11-3.png)所示。
|
||||
为了伸缩超出单个磁盘所能提供的更高吞吐量,可以对日志进行 **分区**(按 [第六章](ch6.md) 的定义)。不同的分区可以托管在不同的机器上,使得每个分区都有一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如 [图 11-3](img/fig11-3.png) 所示。
|
||||
|
||||
在每个分区内,代理为每个消息分配一个单调递增的序列号或**偏移量**(offset,在[图11-3](img/fig11-3.png)中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。
|
||||
在每个分区内,代理为每个消息分配一个单调递增的序列号或 **偏移量**(offset,在 [图 11-3](img/fig11-3.png) 中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。
|
||||
|
||||
![](img/fig11-3.png)
|
||||
|
||||
**图11-3 生产者通过将消息追加写入主题分区文件来发送消息,消费者依次读取这些文件**
|
||||
**图 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】。
|
||||
|
||||
#### 日志与传统的消息传递相比
|
||||
|
||||
基于日志的方法天然支持扇出式消息传递,因为多个消费者可以独立读取日志,而不会相互影响 —— 读取消息不会将其从日志中删除。为了在一组消费者之间实现负载平衡,代理可以将整个分区分配给消费者组中的节点,而不是将单条消息分配给消费者客户端。
|
||||
|
||||
然后每个客户端将消费被指派分区中的**所有**消息。通常情况下,当一个用户被指派了一个日志分区时,它会以简单的单线程方式顺序地读取分区中的消息。这种粗粒度的负载均衡方法有一些缺点:
|
||||
然后每个客户端将消费被指派分区中的 **所有** 消息。通常情况下,当一个用户被指派了一个日志分区时,它会以简单的单线程方式顺序地读取分区中的消息。这种粗粒度的负载均衡方法有一些缺点:
|
||||
|
||||
* 共享消费主题工作的节点数,最多为该主题中的日志分区数,因为同一个分区内的所有消息被递送到同一个节点[^i]。
|
||||
* 如果某条消息处理缓慢,则它会阻塞该分区中后续消息的处理(一种行首阻塞的形式;请参阅“[描述性能](ch1.md#描述性能)”)。
|
||||
* 共享消费主题工作的节点数,最多为该主题中的日志分区数,因为同一个分区内的所有消息被递送到同一个节点 [^i]。
|
||||
* 如果某条消息处理缓慢,则它会阻塞该分区中后续消息的处理(一种行首阻塞的形式;请参阅 “[描述性能](ch1.md#描述性能)”)。
|
||||
|
||||
因此在消息处理代价高昂,希望逐条并行处理,以及消息的顺序并没有那么重要的情况下,JMS/AMQP风格的消息代理是可取的。另一方面,在消息吞吐量很高,处理迅速,顺序很重要的情况下,基于日志的方法表现得非常好。
|
||||
因此在消息处理代价高昂,希望逐条并行处理,以及消息的顺序并没有那么重要的情况下,JMS/AMQP 风格的消息代理是可取的。另一方面,在消息吞吐量很高,处理迅速,顺序很重要的情况下,基于日志的方法表现得非常好。
|
||||
|
||||
[^i]: 要设计一种负载均衡方案也是有可能的,在这种方案中,两个消费者通过读取全部消息来共享分区处理的工作,但是其中一个只考虑具有偶数偏移量的消息,而另一个消费者只处理奇数编号的偏移量。或者你可以将消息摊到一个线程池中来处理,但这种方法会使消费者偏移量管理变得复杂。一般来说,单线程处理单分区是合适的,可以通过增加更多分区来提高并行度。
|
||||
|
||||
@ -169,7 +169,7 @@ Apache Kafka 【17,18】,Amazon Kinesis Streams 【19】和Twitter的Distribut
|
||||
|
||||
顺序消费一个分区使得判断消息是否已经被处理变得相当容易:所有偏移量小于消费者的当前偏移量的消息已经被处理,而具有更大偏移量的消息还没有被看到。因此,代理不需要跟踪确认每条消息,只需要定期记录消费者的偏移即可。这种方法减少了额外簿记开销,而且在批处理和流处理中采用这种方法有助于提高基于日志的系统的吞吐量。
|
||||
|
||||
实际上,这种偏移量与单领导者数据库复制中常见的日志序列号非常相似,我们在“[设置新从库](ch5.md#设置新从库)”中讨论了这种情况。在数据库复制中,日志序列号允许跟随者断开连接后,重新连接到领导者,并在不跳过任何写入的情况下恢复复制。这里原理完全相同:消息代理表现得像一个主库,而消费者就像一个从库。
|
||||
实际上,这种偏移量与单领导者数据库复制中常见的日志序列号非常相似,我们在 “[设置新从库](ch5.md#设置新从库)” 中讨论了这种情况。在数据库复制中,日志序列号允许跟随者断开连接后,重新连接到领导者,并在不跳过任何写入的情况下恢复复制。这里原理完全相同:消息代理表现得像一个主库,而消费者就像一个从库。
|
||||
|
||||
如果消费者节点失效,则失效消费者的分区将指派给其他节点,并从最后记录的偏移量开始消费消息。如果消费者已经处理了后续的消息,但还没有记录它们的偏移量,那么重启后这些消息将被处理两次。我们将在本章后面讨论这个问题的处理方法。
|
||||
|
||||
@ -177,25 +177,25 @@ Apache Kafka 【17,18】,Amazon Kinesis Streams 【19】和Twitter的Distribut
|
||||
|
||||
如果只追加写入日志,则磁盘空间终究会耗尽。为了回收磁盘空间,日志实际上被分割成段,并不时地将旧段删除或移动到归档存储。 (我们将在后面讨论一种更为复杂的磁盘空间释放方式)
|
||||
|
||||
这就意味着如果一个慢消费者跟不上消息产生的速率而落后得太多,它的消费偏移量指向了删除的段,那么它就会错过一些消息。实际上,日志实现了一个有限大小的缓冲区,当缓冲区填满时会丢弃旧消息,它也被称为**循环缓冲区(circular buffer)** 或**环形缓冲区(ring buffer)**。不过由于缓冲区在磁盘上,因此缓冲区可能相当的大。
|
||||
这就意味着如果一个慢消费者跟不上消息产生的速率而落后得太多,它的消费偏移量指向了删除的段,那么它就会错过一些消息。实际上,日志实现了一个有限大小的缓冲区,当缓冲区填满时会丢弃旧消息,它也被称为 **循环缓冲区(circular buffer)** 或 **环形缓冲区(ring buffer)**。不过由于缓冲区在磁盘上,因此缓冲区可能相当的大。
|
||||
|
||||
让我们做个简单计算。在撰写本文时,典型的大型硬盘容量为6TB,顺序写入吞吐量为150MB/s。如果以最快的速度写消息,则需要大约11个小时才能填满磁盘。因而磁盘可以缓冲11个小时的消息,之后它将开始覆盖旧的消息。即使使用多个磁盘和机器,这个比率也是一样的。实践中的部署很少能用满磁盘的写入带宽,所以通常可以保存一个几天甚至几周的日志缓冲区。
|
||||
让我们做个简单计算。在撰写本文时,典型的大型硬盘容量为 6TB,顺序写入吞吐量为 150MB/s。如果以最快的速度写消息,则需要大约 11 个小时才能填满磁盘。因而磁盘可以缓冲 11 个小时的消息,之后它将开始覆盖旧的消息。即使使用多个磁盘和机器,这个比率也是一样的。实践中的部署很少能用满磁盘的写入带宽,所以通常可以保存一个几天甚至几周的日志缓冲区。
|
||||
|
||||
不管保留多长时间的消息,日志的吞吐量或多或少保持不变,因为无论如何,每个消息都会被写入磁盘【18】。这种行为与默认将消息保存在内存中,仅当队列太长时才写入磁盘的消息传递系统形成鲜明对比。当队列很短时,这些系统非常快;而当这些系统开始写入磁盘时,就要慢的多,所以吞吐量取决于保留的历史数量。
|
||||
|
||||
#### 当消费者跟不上生产者时
|
||||
|
||||
在“[消息传递系统](#消息传递系统)”中,如果消费者无法跟上生产者发送信息的速度时,我们讨论了三种选择:丢弃信息,进行缓冲或施加背压。在这种分类法里,基于日志的方法是缓冲的一种形式,具有很大但大小固定的缓冲区(受可用磁盘空间的限制)。
|
||||
在 “[消息传递系统](#消息传递系统)” 中,如果消费者无法跟上生产者发送信息的速度时,我们讨论了三种选择:丢弃信息,进行缓冲或施加背压。在这种分类法里,基于日志的方法是缓冲的一种形式,具有很大但大小固定的缓冲区(受可用磁盘空间的限制)。
|
||||
|
||||
如果消费者远远落后,而所要求的信息比保留在磁盘上的信息还要旧,那么它将不能读取这些信息,所以代理实际上丢弃了比缓冲区容量更大的旧信息。你可以监控消费者落后日志头部的距离,如果落后太多就发出报警。由于缓冲区很大,因而有足够的时间让运维人员来修复慢消费者,并在消息开始丢失之前让其赶上。
|
||||
|
||||
即使消费者真的落后太多开始丢失消息,也只有那个消费者受到影响;它不会中断其他消费者的服务。这是一个巨大的运维优势:你可以实验性地消费生产日志,以进行开发,测试或调试,而不必担心会中断生产服务。当消费者关闭或崩溃时,会停止消耗资源,唯一剩下的只有消费者偏移量。
|
||||
|
||||
这种行为也与传统的消息代理形成了鲜明对比,在那种情况下,你需要小心地删除那些消费者已经关闭的队列—— 否则那些队列就会累积不必要的消息,从其他仍活跃的消费者那里占走内存。
|
||||
这种行为也与传统的消息代理形成了鲜明对比,在那种情况下,你需要小心地删除那些消费者已经关闭的队列 —— 否则那些队列就会累积不必要的消息,从其他仍活跃的消费者那里占走内存。
|
||||
|
||||
#### 重播旧消息
|
||||
|
||||
我们之前提到,使用AMQP和JMS风格的消息代理,处理和确认消息是一个破坏性的操作,因为它会导致消息在代理上被删除。另一方面,在基于日志的消息代理中,使用消息更像是从文件中读取数据:这是只读操作,不会更改日志。
|
||||
我们之前提到,使用 AMQP 和 JMS 风格的消息代理,处理和确认消息是一个破坏性的操作,因为它会导致消息在代理上被删除。另一方面,在基于日志的消息代理中,使用消息更像是从文件中读取数据:这是只读操作,不会更改日志。
|
||||
|
||||
除了消费者的任何输出之外,处理的唯一副作用是消费者偏移量的前进。但偏移量是在消费者的控制之下的,所以如果需要的话可以很容易地操纵:例如你可以用昨天的偏移量跑一个消费者副本,并将输出写到不同的位置,以便重新处理最近一天的消息。你可以使用各种不同的处理代码重复任意次。
|
||||
|
||||
@ -206,158 +206,158 @@ Apache Kafka 【17,18】,Amazon Kinesis Streams 【19】和Twitter的Distribut
|
||||
|
||||
我们已经在消息代理和数据库之间进行了一些比较。尽管传统上它们被视为单独的工具类别,但是我们看到基于日志的消息代理已经成功地从数据库中获取灵感并将其应用于消息传递。我们也可以反过来:从消息传递和流中获取灵感,并将它们应用于数据库。
|
||||
|
||||
我们之前曾经说过,事件是某个时刻发生的事情的记录。发生的事情可能是用户操作(例如键入搜索查询)或读取传感器,但也可能是**写入数据库**。某些东西被写入数据库的事实是可以被捕获、存储和处理的事件。这一观察结果表明,数据库和数据流之间的联系不仅仅是磁盘日志的物理存储 —— 而是更深层的联系。
|
||||
我们之前曾经说过,事件是某个时刻发生的事情的记录。发生的事情可能是用户操作(例如键入搜索查询)或读取传感器,但也可能是 **写入数据库**。某些东西被写入数据库的事实是可以被捕获、存储和处理的事件。这一观察结果表明,数据库和数据流之间的联系不仅仅是磁盘日志的物理存储 —— 而是更深层的联系。
|
||||
|
||||
事实上,复制日志(请参阅“[复制日志的实现](ch5.md#复制日志的实现)”)是一个由数据库写入事件组成的流,由主库在处理事务时生成。从库将写入流应用到它们自己的数据库副本,从而最终得到相同数据的精确副本。复制日志中的事件描述发生的数据更改。
|
||||
事实上,复制日志(请参阅 “[复制日志的实现](ch5.md#复制日志的实现)”)是一个由数据库写入事件组成的流,由主库在处理事务时生成。从库将写入流应用到它们自己的数据库副本,从而最终得到相同数据的精确副本。复制日志中的事件描述发生的数据更改。
|
||||
|
||||
我们还在“[全序广播](ch9.md#全序广播)”中遇到了状态机复制原理,其中指出:如果每个事件代表对数据库的写入,并且每个副本按相同的顺序处理相同的事件,则副本将达到相同的最终状态 (假设事件处理是一个确定性的操作)。这是事件流的又一种场景!
|
||||
我们还在 “[全序广播](ch9.md#全序广播)” 中遇到了状态机复制原理,其中指出:如果每个事件代表对数据库的写入,并且每个副本按相同的顺序处理相同的事件,则副本将达到相同的最终状态 (假设事件处理是一个确定性的操作)。这是事件流的又一种场景!
|
||||
|
||||
在本节中,我们将首先看看异构数据系统中出现的一个问题,然后探讨如何通过将事件流的想法带入数据库来解决这个问题。
|
||||
|
||||
### 保持系统同步
|
||||
|
||||
正如我们在本书中所看到的,没有一个系统能够满足所有的数据存储、查询和处理需求。在实践中,大多数重要应用都需要组合使用几种不同的技术来满足所有的需求:例如,使用OLTP数据库来为用户请求提供服务,使用缓存来加速常见请求,使用全文索引来处理搜索查询,使用数据仓库用于分析。每一种技术都有自己的数据副本,并根据自己的目的进行存储方式的优化。
|
||||
正如我们在本书中所看到的,没有一个系统能够满足所有的数据存储、查询和处理需求。在实践中,大多数重要应用都需要组合使用几种不同的技术来满足所有的需求:例如,使用 OLTP 数据库来为用户请求提供服务,使用缓存来加速常见请求,使用全文索引来处理搜索查询,使用数据仓库用于分析。每一种技术都有自己的数据副本,并根据自己的目的进行存储方式的优化。
|
||||
|
||||
由于相同或相关的数据出现在了不同的地方,因此相互间需要保持同步:如果某个项目在数据库中被更新,它也应当在缓存、搜索索引和数据仓库中被更新。对于数据仓库,这种同步通常由ETL进程执行(请参阅“[数据仓库](ch3.md#数据仓库)”),通常是先取得数据库的完整副本,然后执行转换,并批量加载到数据仓库中 —— 换句话说,批处理。我们在“[批处理工作流的输出](ch10.md#批处理工作流的输出)”中同样看到了如何使用批处理创建搜索索引、推荐系统和其他衍生数据系统。
|
||||
由于相同或相关的数据出现在了不同的地方,因此相互间需要保持同步:如果某个项目在数据库中被更新,它也应当在缓存、搜索索引和数据仓库中被更新。对于数据仓库,这种同步通常由 ETL 进程执行(请参阅 “[数据仓库](ch3.md#数据仓库)”),通常是先取得数据库的完整副本,然后执行转换,并批量加载到数据仓库中 —— 换句话说,批处理。我们在 “[批处理工作流的输出](ch10.md#批处理工作流的输出)” 中同样看到了如何使用批处理创建搜索索引、推荐系统和其他衍生数据系统。
|
||||
|
||||
如果周期性的完整数据库转储过于缓慢,有时会使用的替代方法是**双写(dual write)**,其中应用代码在数据变更时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。
|
||||
如果周期性的完整数据库转储过于缓慢,有时会使用的替代方法是 **双写(dual write)**,其中应用代码在数据变更时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。
|
||||
|
||||
但是,双写有一些严重的问题,其中一个是竞争条件,如[图11-4](img/fig11-4.png)所示。在这个例子中,两个客户端同时想要更新一个项目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,而在搜索索引处,写入以相反的顺序到达**
|
||||
**图 11-4 在数据库中 X 首先被设置为 A,然后被设置为 B,而在搜索索引处,写入以相反的顺序到达**
|
||||
|
||||
除非有一些额外的并发检测机制,例如我们在“[检测并发写入](ch5.md#检测并发写入)”中讨论的版本向量,否则你甚至不会意识到发生了并发写入 —— 一个值将简单地以无提示方式覆盖另一个值。
|
||||
除非有一些额外的并发检测机制,例如我们在 “[检测并发写入](ch5.md#检测并发写入)” 中讨论的版本向量,否则你甚至不会意识到发生了并发写入 —— 一个值将简单地以无提示方式覆盖另一个值。
|
||||
|
||||
双重写入的另一个问题是,其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的(请参阅“[原子提交与两阶段提交](ch7.md#原子提交与两阶段提交)”)。
|
||||
双重写入的另一个问题是,其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的(请参阅 “[原子提交与两阶段提交](ch7.md#原子提交与两阶段提交)”)。
|
||||
|
||||
如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在[图11-4](img/fig11-4.png)中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(请参阅“[多主复制](ch5.md#多主复制)“)。
|
||||
如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在 [图 11-4](img/fig11-4.png) 中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(请参阅 “[多主复制](ch5.md#多主复制)“)。
|
||||
|
||||
如果实际上只有一个领导者 —— 例如,数据库 —— 而且我们能让搜索索引成为数据库的追随者,情况要好得多。但这在实践中可能吗?
|
||||
|
||||
### 变更数据捕获
|
||||
|
||||
大多数数据库的复制日志的问题在于,它们一直被当做数据库的内部实现细节,而不是公开的API。客户端应该通过其数据模型和查询语言来查询数据库,而不是解析复制日志并尝试从中提取数据。
|
||||
大多数数据库的复制日志的问题在于,它们一直被当做数据库的内部实现细节,而不是公开的 API。客户端应该通过其数据模型和查询语言来查询数据库,而不是解析复制日志并尝试从中提取数据。
|
||||
|
||||
数十年来,许多数据库根本没有记录在档的获取变更日志的方式。由于这个原因,捕获数据库中所有的变更,然后将其复制到其他存储技术(搜索索引、缓存或数据仓库)中是相当困难的。
|
||||
|
||||
最近,人们对**变更数据捕获(change data capture, CDC)** 越来越感兴趣,这是一种观察写入数据库的所有数据变更,并将其提取并转换为可以复制到其他系统中的形式的过程。 CDC是非常有意思的,尤其是当变更能在被写入后立刻用于流时。
|
||||
最近,人们对 **变更数据捕获(change data capture, CDC)** 越来越感兴趣,这是一种观察写入数据库的所有数据变更,并将其提取并转换为可以复制到其他系统中的形式的过程。 CDC 是非常有意思的,尤其是当变更能在被写入后立刻用于流时。
|
||||
|
||||
例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如[图11-5](img/fig11-5.png)所示。
|
||||
例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如 [图 11-5](img/fig11-5.png) 所示。
|
||||
|
||||
![](img/fig11-5.png)
|
||||
|
||||
**图11-5 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统**
|
||||
**图 11-5 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统**
|
||||
|
||||
#### 变更数据捕获的实现
|
||||
|
||||
我们可以将日志消费者叫做**衍生数据系统**,正如在[第三部分](part-iii.md)的介绍中所讨论的:存储在搜索索引和数据仓库中的数据,只是**记录系统**数据的额外视图。变更数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在衍生数据系统中,以便衍生系统具有数据的准确副本。
|
||||
我们可以将日志消费者叫做 **衍生数据系统**,正如在 [第三部分](part-iii.md) 的介绍中所讨论的:存储在搜索索引和数据仓库中的数据,只是 **记录系统** 数据的额外视图。变更数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在衍生数据系统中,以便衍生系统具有数据的准确副本。
|
||||
|
||||
从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了[图11-2](img/fig11-2.png)的重新排序问题)。
|
||||
从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了 [图 11-2](img/fig11-2.png) 的重新排序问题)。
|
||||
|
||||
数据库触发器可用来实现变更数据捕获(请参阅“[基于触发器的复制](ch5.md#基于触发器的复制)”),通过注册观察所有变更的触发器,并将相应的变更项写入变更日志表中。但是它们往往是脆弱的,而且有显著的性能开销。解析复制日志可能是一种更稳健的方法,但它也很有挑战,例如如何应对模式变更。
|
||||
数据库触发器可用来实现变更数据捕获(请参阅 “[基于触发器的复制](ch5.md#基于触发器的复制)”),通过注册观察所有变更的触发器,并将相应的变更项写入变更日志表中。但是它们往往是脆弱的,而且有显著的性能开销。解析复制日志可能是一种更稳健的方法,但它也很有挑战,例如如何应对模式变更。
|
||||
|
||||
LinkedIn的Databus【25】,Facebook的Wormhole【26】和Yahoo!的Sherpa【27】大规模地应用这个思路。 Bottled Water使用解码WAL的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 使用解码 WAL 的 API 实现了 PostgreSQL 的 CDC【28】,Maxwell 和 Debezium 通过解析 binlog 对 MySQL 做了类似的事情【29,30,31】,Mongoriver 读取 MongoDB oplog【32,33】,而 GoldenGate 为 Oracle 提供类似的功能【34,35】。
|
||||
|
||||
像消息代理一样,变更数据捕获通常是异步的:记录数据库系统不会等待消费者应用变更再进行提交。这种设计具有的运维优势是,添加缓慢的消费者不会过度影响记录系统。不过,所有复制延迟可能有的问题在这里都可能出现(请参阅“[复制延迟问题](ch5.md#复制延迟问题)”)。
|
||||
像消息代理一样,变更数据捕获通常是异步的:记录数据库系统不会等待消费者应用变更再进行提交。这种设计具有的运维优势是,添加缓慢的消费者不会过度影响记录系统。不过,所有复制延迟可能有的问题在这里都可能出现(请参阅 “[复制延迟问题](ch5.md#复制延迟问题)”)。
|
||||
|
||||
#### 初始快照
|
||||
|
||||
如果你拥有**所有**对数据库进行变更的日志,则可以通过重播该日志,来重建数据库的完整状态。但是在许多情况下,永远保留所有更改会耗费太多磁盘空间,且重播过于费时,因此日志需要被截断。
|
||||
如果你拥有 **所有** 对数据库进行变更的日志,则可以通过重播该日志,来重建数据库的完整状态。但是在许多情况下,永远保留所有更改会耗费太多磁盘空间,且重播过于费时,因此日志需要被截断。
|
||||
|
||||
例如,构建新的全文索引需要整个数据库的完整副本 —— 仅仅应用最近变更的日志是不够的,因为这样会丢失最近未曾更新的项目。因此,如果你没有完整的历史日志,则需要从一个一致的快照开始,如先前的“[设置新从库](ch5.md#设置新从库)”中所述。
|
||||
例如,构建新的全文索引需要整个数据库的完整副本 —— 仅仅应用最近变更的日志是不够的,因为这样会丢失最近未曾更新的项目。因此,如果你没有完整的历史日志,则需要从一个一致的快照开始,如先前的 “[设置新从库](ch5.md#设置新从库)” 中所述。
|
||||
|
||||
数据库的快照必须与变更日志中的已知位置或偏移量相对应,以便在处理完快照后知道从哪里开始应用变更。一些CDC工具集成了这种快照功能,而其他工具则把它留给你手动执行。
|
||||
数据库的快照必须与变更日志中的已知位置或偏移量相对应,以便在处理完快照后知道从哪里开始应用变更。一些 CDC 工具集成了这种快照功能,而其他工具则把它留给你手动执行。
|
||||
|
||||
#### 日志压缩
|
||||
|
||||
如果你只能保留有限的历史日志,则每次要添加新的衍生数据系统时,都需要做一次快照。但**日志压缩(log compaction)** 提供了一个很好的备选方案。
|
||||
如果你只能保留有限的历史日志,则每次要添加新的衍生数据系统时,都需要做一次快照。但 **日志压缩(log compaction)** 提供了一个很好的备选方案。
|
||||
|
||||
我们之前在“[散列索引](ch3.md#散列索引)”中关于日志结构存储引擎的上下文中讨论了日志压缩(请参阅[图3-2](img/fig3-2.png)的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。
|
||||
我们之前在 “[散列索引](ch3.md#散列索引)” 中关于日志结构存储引擎的上下文中讨论了日志压缩(请参阅 [图 3-2](img/fig3-2.png) 的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。
|
||||
|
||||
在日志结构存储引擎中,具有特殊值NULL(**墓碑**,即tombstone)的更新表示该键被删除,并会在日志压缩过程中被移除。但只要键不被覆盖或删除,它就会永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容,而不取决于数据库中曾经发生的写入次数。如果相同的键经常被覆盖写入,则先前的值将最终将被垃圾回收,只有最新的值会保留下来。
|
||||
在日志结构存储引擎中,具有特殊值 NULL(**墓碑**,即 tombstone)的更新表示该键被删除,并会在日志压缩过程中被移除。但只要键不被覆盖或删除,它就会永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容,而不取决于数据库中曾经发生的写入次数。如果相同的键经常被覆盖写入,则先前的值将最终将被垃圾回收,只有最新的值会保留下来。
|
||||
|
||||
在基于日志的消息代理与变更数据捕获的上下文中也适用相同的想法。如果CDC系统被配置为,每个变更都包含一个主键,且每个键的更新都替换了该键以前的值,那么只需要保留对键的最新写入就足够了。
|
||||
在基于日志的消息代理与变更数据捕获的上下文中也适用相同的想法。如果 CDC 系统被配置为,每个变更都包含一个主键,且每个键的更新都替换了该键以前的值,那么只需要保留对键的最新写入就足够了。
|
||||
|
||||
现在,无论何时需要重建衍生数据系统(如搜索索引),你可以从压缩日志主题的零偏移量处启动新的消费者,然后依次扫描日志中的所有消息。日志能保证包含数据库中每个键的最新值(也可能是一些较旧的值)—— 换句话说,你可以使用它来获取数据库内容的完整副本,而无需从CDC源数据库取一个快照。
|
||||
现在,无论何时需要重建衍生数据系统(如搜索索引),你可以从压缩日志主题的零偏移量处启动新的消费者,然后依次扫描日志中的所有消息。日志能保证包含数据库中每个键的最新值(也可能是一些较旧的值)—— 换句话说,你可以使用它来获取数据库内容的完整副本,而无需从 CDC 源数据库取一个快照。
|
||||
|
||||
Apache Kafka支持这种日志压缩功能。正如我们将在本章后面看到的,它允许消息代理被当成持久性存储使用,而不仅仅是用于临时消息。
|
||||
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 中,它就可以用于更新衍生数据系统,比如搜索索引,也可以用于本章稍后讨论的流处理系统。
|
||||
|
||||
### 事件溯源
|
||||
|
||||
我们在这里讨论的想法和**事件溯源(Event Sourcing)** 之间有一些相似之处,这是一个在 **领域驱动设计(domain-driven design, DDD)** 社区中折腾出来的技术。我们将简要讨论事件溯源,因为它包含了一些关于流处理系统的有用想法。
|
||||
我们在这里讨论的想法和 **事件溯源(Event Sourcing)** 之间有一些相似之处,这是一个在 **领域驱动设计(domain-driven design, DDD)** 社区中折腾出来的技术。我们将简要讨论事件溯源,因为它包含了一些关于流处理系统的有用想法。
|
||||
|
||||
与变更数据捕获类似,事件溯源涉及到**将所有对应用状态的变更**存储为变更事件日志。最大的区别是事件溯源将这一想法应用到了一个不同的抽象层次上:
|
||||
与变更数据捕获类似,事件溯源涉及到 **将所有对应用状态的变更** 存储为变更事件日志。最大的区别是事件溯源将这一想法应用到了一个不同的抽象层次上:
|
||||
|
||||
* 在变更数据捕获中,应用以**可变方式(mutable way)** 使用数据库,可以任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免[图11-4](img/fig11-4.png)中的竞态条件。写入数据库的应用不需要知道CDC的存在。
|
||||
* 在变更数据捕获中,应用以 **可变方式(mutable way)** 使用数据库,可以任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免 [图 11-4](img/fig11-4.png) 中的竞态条件。写入数据库的应用不需要知道 CDC 的存在。
|
||||
* 在事件溯源中,应用逻辑显式构建在写入事件日志的不可变事件之上。在这种情况下,事件存储是仅追加写入的,更新与删除是不鼓励的或禁止的。事件被设计为旨在反映应用层面发生的事情,而不是底层的状态变更。
|
||||
|
||||
事件溯源是一种强大的数据建模技术:从应用的角度来看,将用户的行为记录为不可变的事件更有意义,而不是在可变数据库中记录这些行为的影响。事件溯源使得应用随时间演化更为容易,通过更容易理解事情发生的原因来帮助调试的进行,并有利于防止应用Bug(请参阅“[不可变事件的优点](#不可变事件的优点)”)。
|
||||
事件溯源是一种强大的数据建模技术:从应用的角度来看,将用户的行为记录为不可变的事件更有意义,而不是在可变数据库中记录这些行为的影响。事件溯源使得应用随时间演化更为容易,通过更容易理解事情发生的原因来帮助调试的进行,并有利于防止应用 Bug(请参阅 “[不可变事件的优点](#不可变事件的优点)”)。
|
||||
|
||||
例如,存储“学生取消选课”事件以中性的方式清楚地表达了单个行为的意图,而其副作用“从登记表中删除了一个条目,而一条取消原因的记录被添加到学生反馈表“则嵌入了很多有关稍后对数据的使用方式的假设。如果引入一个新的应用功能,例如“将位置留给等待列表中的下一个人” —— 事件溯源方法允许将新的副作用轻松地从现有事件中脱开。
|
||||
例如,存储 “学生取消选课” 事件以中性的方式清楚地表达了单个行为的意图,而其副作用 “从登记表中删除了一个条目,而一条取消原因的记录被添加到学生反馈表 “则嵌入了很多有关稍后对数据的使用方式的假设。如果引入一个新的应用功能,例如 “将位置留给等待列表中的下一个人” —— 事件溯源方法允许将新的副作用轻松地从现有事件中脱开。
|
||||
|
||||
事件溯源类似于**编年史(chronicle)** 数据模型【45】,事件日志与星型模式中的事实表之间也存在相似之处(请参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”) 。
|
||||
事件溯源类似于 **编年史(chronicle)** 数据模型【45】,事件日志与星型模式中的事实表之间也存在相似之处(请参阅 “[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”) 。
|
||||
|
||||
诸如Event Store【46】这样的专业数据库已经被开发出来,供使用事件溯源的应用使用,但总的来说,这种方法独立于任何特定的工具。传统的数据库或基于日志的消息代理也可以用来构建这种风格的应用。
|
||||
诸如 Event Store【46】这样的专业数据库已经被开发出来,供使用事件溯源的应用使用,但总的来说,这种方法独立于任何特定的工具。传统的数据库或基于日志的消息代理也可以用来构建这种风格的应用。
|
||||
|
||||
#### 从事件日志中派生出当前状态
|
||||
|
||||
事件日志本身并不是很有用,因为用户通常期望看到的是系统的当前状态,而不是变更历史。例如,在购物网站上,用户期望能看到他们购物车里的当前内容,而不是他们购物车所有变更的一个仅追加列表。
|
||||
|
||||
因此,使用事件溯源的应用需要拉取事件日志(表示**写入**系统的数据),并将其转换为适合向用户显示的应用状态(从系统**读取**数据的方式【47】)。这种转换可以使用任意逻辑,但它应当是确定性的,以便能再次运行,并从事件日志中衍生出相同的应用状态。
|
||||
因此,使用事件溯源的应用需要拉取事件日志(表示 **写入** 系统的数据),并将其转换为适合向用户显示的应用状态(从系统 **读取** 数据的方式【47】)。这种转换可以使用任意逻辑,但它应当是确定性的,以便能再次运行,并从事件日志中衍生出相同的应用状态。
|
||||
|
||||
与变更数据捕获一样,重播事件日志允许让你重新构建系统的当前状态。不过,日志压缩需要采用不同的方式处理:
|
||||
|
||||
* 用于记录更新的CDC事件通常包含记录的**完整新版本**,因此主键的当前值完全由该主键的最近事件确定,而日志压缩可以丢弃相同主键的先前事件。
|
||||
* 用于记录更新的 CDC 事件通常包含记录的 **完整新版本**,因此主键的当前值完全由该主键的最近事件确定,而日志压缩可以丢弃相同主键的先前事件。
|
||||
* 另一方面,事件溯源在更高层次进行建模:事件通常表示用户操作的意图,而不是因为操作而发生的状态更新机制。在这种情况下,后面的事件通常不会覆盖先前的事件,所以你需要完整的历史事件来重新构建最终状态。这里进行同样的日志压缩是不可能的。
|
||||
|
||||
使用事件溯源的应用通常有一些机制,用于存储从事件日志中导出的当前状态快照,因此它们不需要重复处理完整的日志。然而这只是一种性能优化,用来加速读取,提高从崩溃中恢复的速度;真正的目的是系统能够永久存储所有原始事件,并在需要时重新处理完整的事件日志。我们将在“[不变性的局限性](#不变性的局限性)”中讨论这个假设。
|
||||
使用事件溯源的应用通常有一些机制,用于存储从事件日志中导出的当前状态快照,因此它们不需要重复处理完整的日志。然而这只是一种性能优化,用来加速读取,提高从崩溃中恢复的速度;真正的目的是系统能够永久存储所有原始事件,并在需要时重新处理完整的事件日志。我们将在 “[不变性的局限性](#不变性的局限性)” 中讨论这个假设。
|
||||
|
||||
#### 命令和事件
|
||||
|
||||
事件溯源的哲学是仔细区分**事件(event)** 和**命令(command)**【48】。当来自用户的请求刚到达时,它一开始是一个命令:在这个时间点上它仍然可能可能失败,比如,因为违反了一些完整性条件。应用必须首先验证它是否可以执行该命令。如果验证成功并且命令被接受,则它变为一个持久化且不可变的事件。
|
||||
事件溯源的哲学是仔细区分 **事件(event)** 和 **命令(command)**【48】。当来自用户的请求刚到达时,它一开始是一个命令:在这个时间点上它仍然可能可能失败,比如,因为违反了一些完整性条件。应用必须首先验证它是否可以执行该命令。如果验证成功并且命令被接受,则它变为一个持久化且不可变的事件。
|
||||
|
||||
例如,如果用户试图注册特定用户名,或预定飞机或剧院的座位,则应用需要检查用户名或座位是否已被占用。(先前在“[容错共识](ch8.md#容错共识)”中讨论过这个例子)当检查成功时,应用可以生成一个事件,指示特定的用户名是由特定的用户ID注册的,或者座位已经预留给特定的顾客。
|
||||
例如,如果用户试图注册特定用户名,或预定飞机或剧院的座位,则应用需要检查用户名或座位是否已被占用。(先前在 “[容错共识](ch8.md#容错共识)” 中讨论过这个例子)当检查成功时,应用可以生成一个事件,指示特定的用户名是由特定的用户 ID 注册的,或者座位已经预留给特定的顾客。
|
||||
|
||||
在事件生成的时刻,它就成为了**事实(fact)**。即使客户稍后决定更改或取消预订,他们之前曾预定了某个特定座位的事实仍然成立,而更改或取消是之后添加的单独的事件。
|
||||
在事件生成的时刻,它就成为了 **事实(fact)**。即使客户稍后决定更改或取消预订,他们之前曾预定了某个特定座位的事实仍然成立,而更改或取消是之后添加的单独的事件。
|
||||
|
||||
事件流的消费者不允许拒绝事件:当消费者看到事件时,它已经成为日志中不可变的一部分,并且可能已经被其他消费者看到了。因此任何对命令的验证,都需要在它成为事件之前同步完成。例如,通过使用一个可以原子性地自动验证命令并发布事件的可串行事务。
|
||||
|
||||
或者,预订座位的用户请求可以拆分为两个事件:第一个是暂时预约,第二个是验证预约后的独立的确认事件(如“[使用全序广播实现线性一致的存储](ch9.md#使用全序广播实现线性一致的存储)”中所述) 。这种分割方式允许验证发生在一个异步的过程中。
|
||||
或者,预订座位的用户请求可以拆分为两个事件:第一个是暂时预约,第二个是验证预约后的独立的确认事件(如 “[使用全序广播实现线性一致的存储](ch9.md#使用全序广播实现线性一致的存储)” 中所述) 。这种分割方式允许验证发生在一个异步的过程中。
|
||||
|
||||
### 状态、流和不变性
|
||||
|
||||
我们在[第十章](ch10.md)中看到,批处理因其输入文件不变性而受益良多,你可以在现有输入文件上运行实验性处理作业,而不用担心损坏它们。这种不变性原则也是使得事件溯源与变更数据捕获如此强大的原因。
|
||||
我们在 [第十章](ch10.md) 中看到,批处理因其输入文件不变性而受益良多,你可以在现有输入文件上运行实验性处理作业,而不用担心损坏它们。这种不变性原则也是使得事件溯源与变更数据捕获如此强大的原因。
|
||||
|
||||
我们通常将数据库视为应用程序当前状态的存储 —— 这种表示针对读取进行了优化,而且通常对于服务查询而言是最为方便的表示。状态的本质是,它会变化,所以数据库才会支持数据的增删改。这又该如何匹配不变性呢?
|
||||
|
||||
只要你的状态发生了变化,那么这个状态就是这段时间中事件修改的结果。例如,当前可用的座位列表是你已处理的预订所产生的结果,当前帐户余额是帐户中的借与贷的结果,而Web服务器的响应时间图,是所有已发生Web请求的独立响应时间的聚合结果。
|
||||
只要你的状态发生了变化,那么这个状态就是这段时间中事件修改的结果。例如,当前可用的座位列表是你已处理的预订所产生的结果,当前帐户余额是帐户中的借与贷的结果,而 Web 服务器的响应时间图,是所有已发生 Web 请求的独立响应时间的聚合结果。
|
||||
|
||||
无论状态如何变化,总是有一系列事件导致了这些变化。即使事情已经执行与回滚,这些事件出现是始终成立的。关键的想法是:可变的状态与不可变事件的仅追加日志相互之间并不矛盾:它们是一体两面,互为阴阳的。所有变化的日志—— **变化日志(changelog)**,表示了随时间演变的状态。
|
||||
无论状态如何变化,总是有一系列事件导致了这些变化。即使事情已经执行与回滚,这些事件出现是始终成立的。关键的想法是:可变的状态与不可变事件的仅追加日志相互之间并不矛盾:它们是一体两面,互为阴阳的。所有变化的日志 —— **变化日志(changelog)**,表示了随时间演变的状态。
|
||||
|
||||
如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如[图11-6](img/fig11-6.png)所示【49,50,51】。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。
|
||||
如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如 [图 11-6](img/fig11-6.png) 所示【49,50,51】。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。
|
||||
$$
|
||||
state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\
|
||||
stream(t) = \frac{d\ state(t)}{dt}
|
||||
$$
|
||||
![](img/fig11-6.png)
|
||||
|
||||
**图11-6 应用当前状态与事件流之间的关系**
|
||||
**图 11-6 应用当前状态与事件流之间的关系**
|
||||
|
||||
如果你持久存储了变更日志,那么重现状态就非常简单。如果你认为事件日志是你的记录系统,而所有的衍生状态都从它派生而来,那么系统中的数据流动就容易理解的多。正如帕特·赫兰(Pat Helland)所说的【52】:
|
||||
如果你持久存储了变更日志,那么重现状态就非常简单。如果你认为事件日志是你的记录系统,而所有的衍生状态都从它派生而来,那么系统中的数据流动就容易理解的多。正如帕特・赫兰(Pat Helland)所说的【52】:
|
||||
|
||||
> 事务日志记录了数据库的所有变更。高速追加是更改日志的唯一方法。从这个角度来看,数据库的内容其实是日志中记录最新值的缓存。日志才是真相,数据库是日志子集的缓存,这一缓存子集恰好来自日志中每条记录与索引值的最新值。
|
||||
|
||||
日志压缩(如“[日志压缩](#日志压缩)”中所述)是连接日志与数据库状态之间的桥梁:它只保留每条记录的最新版本,并丢弃被覆盖的版本。
|
||||
日志压缩(如 “[日志压缩](#日志压缩)” 中所述)是连接日志与数据库状态之间的桥梁:它只保留每条记录的最新版本,并丢弃被覆盖的版本。
|
||||
|
||||
#### 不可变事件的优点
|
||||
|
||||
@ -365,43 +365,43 @@ $$
|
||||
|
||||
如果发生错误,会计师不会删除或更改分类帐中的错误交易 —— 而是添加另一笔交易以补偿错误,例如退还一笔不正确的费用。不正确的交易将永远保留在分类帐中,对于审计而言可能非常重要。如果从不正确的分类账衍生出的错误数字已经公布,那么下一个会计周期的数字就会包括一个更正。这个过程在会计事务中是很常见的【54】。
|
||||
|
||||
尽管这种可审计性只在金融系统中尤其重要,但对于不受这种严格监管的许多其他系统,也是很有帮助的。如“[批处理输出的哲学](ch10.md#批处理输出的哲学)”中所讨论的,如果你意外地部署了将错误数据写入数据库的错误代码,当代码会破坏性地覆写数据时,恢复要困难得多。使用不可变事件的仅追加日志,诊断问题与故障恢复就要容易的多。
|
||||
尽管这种可审计性只在金融系统中尤其重要,但对于不受这种严格监管的许多其他系统,也是很有帮助的。如 “[批处理输出的哲学](ch10.md#批处理输出的哲学)” 中所讨论的,如果你意外地部署了将错误数据写入数据库的错误代码,当代码会破坏性地覆写数据时,恢复要困难得多。使用不可变事件的仅追加日志,诊断问题与故障恢复就要容易的多。
|
||||
|
||||
不可变的事件也包含了比当前状态更多的信息。例如在购物网站上,顾客可以将物品添加到他们的购物车,然后再将其移除。虽然从履行订单的角度,第二个事件取消了第一个事件,但对分析目的而言,知道客户考虑过某个特定项而之后又反悔,可能是很有用的。也许他们会选择在未来购买,或者他们已经找到了替代品。这个信息被记录在事件日志中,但对于移出购物车就删除记录的数据库而言,这个信息在移出购物车时可能就丢失了【42】。
|
||||
|
||||
#### 从同一事件日志中派生多个视图
|
||||
|
||||
此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图11-5](img/fig11-5.png)):例如,分析型数据库Druid使用这种方式直接从Kafka摄取数据【55】,Pistachio是一个分布式的键值存储,使用Kafka作为提交日志【56】,Kafka Connect能将来自Kafka的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统(如搜索服务器)来说是很有意义的,当系统要从分布式日志中获取输入时亦然(请参阅“[保持系统同步](#保持系统同步)”)。
|
||||
此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图 11-5](img/fig11-5.png)):例如,分析型数据库 Druid 使用这种方式直接从 Kafka 摄取数据【55】,Pistachio 是一个分布式的键值存储,使用 Kafka 作为提交日志【56】,Kafka Connect 能将来自 Kafka 的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统(如搜索服务器)来说是很有意义的,当系统要从分布式日志中获取输入时亦然(请参阅 “[保持系统同步](#保持系统同步)”)。
|
||||
|
||||
添加从事件日志到数据库的显式转换,能够使应用更容易地随时间演进:如果你想要引入一个新功能,以新的方式表示现有数据,则可以使用事件日志来构建一个单独的、针对新功能的读取优化视图,无需修改现有系统而与之共存。并行运行新旧系统通常比在现有系统中执行复杂的模式迁移更容易。一旦不再需要旧的系统,你可以简单地关闭它并回收其资源【47,57】。
|
||||
|
||||
如果你不需要担心如何查询与访问数据,那么存储数据通常是非常简单的。模式设计、索引和存储引擎的许多复杂性,都是希望支持某些特定查询和访问模式的结果(请参阅[第三章](ch3.md))。出于这个原因,通过将数据写入的形式与读取形式相分离,并允许几个不同的读取视图,你能获得很大的灵活性。这个想法有时被称为**命令查询责任分离(command query responsibility segregation, CQRS)**【42,58,59】。
|
||||
如果你不需要担心如何查询与访问数据,那么存储数据通常是非常简单的。模式设计、索引和存储引擎的许多复杂性,都是希望支持某些特定查询和访问模式的结果(请参阅 [第三章](ch3.md))。出于这个原因,通过将数据写入的形式与读取形式相分离,并允许几个不同的读取视图,你能获得很大的灵活性。这个想法有时被称为 **命令查询责任分离(command query responsibility segregation, CQRS)**【42,58,59】。
|
||||
|
||||
数据库和模式设计的传统方法是基于这样一种谬论,数据必须以与查询相同的形式写入。如果可以将数据从针对写入优化的事件日志转换为针对读取优化的应用状态,那么有关规范化和非规范化的争论就变得无关紧要了(请参阅“[多对一和多对多的关系](ch2.md#多对一和多对多的关系)”):在针对读取优化的视图中对数据进行非规范化是完全合理的,因为翻译过程提供了使其与事件日志保持一致的机制。
|
||||
数据库和模式设计的传统方法是基于这样一种谬论,数据必须以与查询相同的形式写入。如果可以将数据从针对写入优化的事件日志转换为针对读取优化的应用状态,那么有关规范化和非规范化的争论就变得无关紧要了(请参阅 “[多对一和多对多的关系](ch2.md#多对一和多对多的关系)”):在针对读取优化的视图中对数据进行非规范化是完全合理的,因为翻译过程提供了使其与事件日志保持一致的机制。
|
||||
|
||||
在“[描述负载](ch1.md#描述负载)”中,我们讨论了推特主页时间线,它是特定用户关注的人群所发推特的缓存(类似邮箱)。这是**针对读取优化的状态**的又一个例子:主页时间线是高度非规范化的,因为你的推文与你所有粉丝的时间线都构成了重复。然而,扇出服务保持了这种重复状态与新推特以及新关注关系的同步,从而保证了重复的可管理性。
|
||||
在 “[描述负载](ch1.md#描述负载)” 中,我们讨论了推特主页时间线,它是特定用户关注的人群所发推特的缓存(类似邮箱)。这是 **针对读取优化的状态** 的又一个例子:主页时间线是高度非规范化的,因为你的推文与你所有粉丝的时间线都构成了重复。然而,扇出服务保持了这种重复状态与新推特以及新关注关系的同步,从而保证了重复的可管理性。
|
||||
|
||||
#### 并发控制
|
||||
|
||||
事件溯源和变更数据捕获的最大缺点是,事件日志的消费者通常是异步的,所以可能会出现这样的情况:用户会写入日志,然后从日志衍生视图中读取,结果发现他的写入还没有反映在读取视图中。我们之前在“[读己之写](ch5.md#读己之写)”中讨论了这个问题以及可能的解决方案。
|
||||
事件溯源和变更数据捕获的最大缺点是,事件日志的消费者通常是异步的,所以可能会出现这样的情况:用户会写入日志,然后从日志衍生视图中读取,结果发现他的写入还没有反映在读取视图中。我们之前在 “[读己之写](ch5.md#读己之写)” 中讨论了这个问题以及可能的解决方案。
|
||||
|
||||
一种解决方案是将事件追加到日志时同步执行读取视图的更新。而将这些写入操作合并为一个原子单元需要**事务**,所以要么将事件日志和读取视图保存在同一个存储系统中,要么就需要跨不同系统进行分布式事务。或者,你也可以使用在“[使用全序广播实现线性一致的存储](ch9.md#使用全序广播实现线性一致的存储)”中讨论的方法。
|
||||
一种解决方案是将事件追加到日志时同步执行读取视图的更新。而将这些写入操作合并为一个原子单元需要 **事务**,所以要么将事件日志和读取视图保存在同一个存储系统中,要么就需要跨不同系统进行分布式事务。或者,你也可以使用在 “[使用全序广播实现线性一致的存储](ch9.md#使用全序广播实现线性一致的存储)” 中讨论的方法。
|
||||
|
||||
另一方面,从事件日志导出当前状态也简化了并发控制的某些部分。许多对于多对象事务的需求(请参阅“[单对象和多对象操作](ch7.md#单对象和多对象操作)”)源于单个用户操作需要在多个不同的位置更改数据。通过事件溯源,你可以设计一个自包含的事件以表示一个用户操作。然后用户操作就只需要在一个地方进行单次写入操作 —— 即将事件附加到日志中 —— 这个还是很容易使原子化的。
|
||||
另一方面,从事件日志导出当前状态也简化了并发控制的某些部分。许多对于多对象事务的需求(请参阅 “[单对象和多对象操作](ch7.md#单对象和多对象操作)”)源于单个用户操作需要在多个不同的位置更改数据。通过事件溯源,你可以设计一个自包含的事件以表示一个用户操作。然后用户操作就只需要在一个地方进行单次写入操作 —— 即将事件附加到日志中 —— 这个还是很容易使原子化的。
|
||||
|
||||
如果事件日志与应用状态以相同的方式分区(例如,处理分区3中的客户事件只需要更新分区3中的应用状态),那么直接使用单线程日志消费者就不需要写入并发控制了。它从设计上一次只处理一个事件(请参阅“[真的串行执行](ch7.md#真的串行执行)”)。日志通过在分区中定义事件的序列顺序,消除了并发性的不确定性【24】。如果一个事件触及多个状态分区,那么需要做更多的工作,我们将在[第十二章](ch12.md)讨论。
|
||||
如果事件日志与应用状态以相同的方式分区(例如,处理分区 3 中的客户事件只需要更新分区 3 中的应用状态),那么直接使用单线程日志消费者就不需要写入并发控制了。它从设计上一次只处理一个事件(请参阅 “[真的串行执行](ch7.md#真的串行执行)”)。日志通过在分区中定义事件的序列顺序,消除了并发性的不确定性【24】。如果一个事件触及多个状态分区,那么需要做更多的工作,我们将在 [第十二章](ch12.md) 讨论。
|
||||
|
||||
#### 不变性的局限性
|
||||
|
||||
许多不使用事件溯源模型的系统也还是依赖不可变性:各种数据库在内部使用不可变的数据结构或多版本数据来支持时间点快照(请参阅“[索引和快照隔离](ch7.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】,而备份通常是特意做成不可变的,防止意外删除或损坏。删除操作更多的是指“使取回数据更困难”,而不是指“使取回数据不可能”。无论如何,有时你必须得尝试,正如我们在“[立法与自律](ch12.md#立法与自律)”中所看到的。
|
||||
真正删除数据是非常非常困难的【64】,因为副本可能存在于很多地方:例如,存储引擎,文件系统和 SSD 通常会向一个新位置写入,而不是原地覆盖旧数据【52】,而备份通常是特意做成不可变的,防止意外删除或损坏。删除操作更多的是指 “使取回数据更困难”,而不是指 “使取回数据不可能”。无论如何,有时你必须得尝试,正如我们在 “[立法与自律](ch12.md#立法与自律)” 中所看到的。
|
||||
|
||||
|
||||
## 流处理
|
||||
@ -410,15 +410,15 @@ $$
|
||||
|
||||
剩下的就是讨论一下你可以用流做什么 —— 也就是说,你可以处理它。一般来说,有三种选项:
|
||||
|
||||
1. 你可以将事件中的数据写入数据库、缓存、搜索索引或类似的存储系统,然后能被其他客户端查询。如[图11-5](img/fig11-5.png)所示,这是数据库与系统其他部分所发生的变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如“[批处理工作流的输出](ch10.md#批处理工作流的输出)”中所讨论的,它是写入存储系统的流等价物。
|
||||
1. 你可以将事件中的数据写入数据库、缓存、搜索索引或类似的存储系统,然后能被其他客户端查询。如 [图 11-5](img/fig11-5.png) 所示,这是数据库与系统其他部分所发生的变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如 “[批处理工作流的输出](ch10.md#批处理工作流的输出)” 中所讨论的,它是写入存储系统的流等价物。
|
||||
2. 你能以某种方式将事件推送给用户,例如发送报警邮件或推送通知,或将事件流式传输到可实时显示的仪表板上。在这种情况下,人是流的最终消费者。
|
||||
3. 你可以处理一个或多个输入流,并产生一个或多个输出流。流可能会经过由几个这样的处理阶段组成的流水线,最后再输出(选项1或2)。
|
||||
3. 你可以处理一个或多个输入流,并产生一个或多个输出流。流可能会经过由几个这样的处理阶段组成的流水线,最后再输出(选项 1 或 2)。
|
||||
|
||||
在本章的剩余部分中,我们将讨论选项3:处理流以产生其他衍生流。处理这样的流的代码片段,被称为**算子(operator)** 或**作业(job)**。它与我们在[第十章](ch10.md)中讨论过的Unix进程和MapReduce作业密切相关,数据流的模式是相似的:一个流处理器以只读的方式使用输入流,并将其输出以仅追加的方式写入一个不同的位置。
|
||||
在本章的剩余部分中,我们将讨论选项 3:处理流以产生其他衍生流。处理这样的流的代码片段,被称为 **算子(operator)** 或 **作业(job)**。它与我们在 [第十章](ch10.md) 中讨论过的 Unix 进程和 MapReduce 作业密切相关,数据流的模式是相似的:一个流处理器以只读的方式使用输入流,并将其输出以仅追加的方式写入一个不同的位置。
|
||||
|
||||
流处理中的分区和并行化模式也非常类似于[第十章](ch10.md)中介绍的MapReduce和数据流引擎,因此我们不再重复这些主题。基本的Map操作(如转换和过滤记录)也是一样的。
|
||||
流处理中的分区和并行化模式也非常类似于 [第十章](ch10.md) 中介绍的 MapReduce 和数据流引擎,因此我们不再重复这些主题。基本的 Map 操作(如转换和过滤记录)也是一样的。
|
||||
|
||||
与批量作业相比的一个关键区别是,流不会结束。这种差异会带来很多隐含的结果。正如本章开始部分所讨论的,排序对无界数据集没有意义,因此无法使用**排序合并连接**(请参阅“[Reduce侧连接与分组](ch10.md#Reduce侧连接与分组)”)。容错机制也必须改变:对于已经运行了几分钟的批处理作业,可以简单地从头开始重启失败任务,但是对于已经运行数年的流作业,重启后从头开始跑可能并不是一个可行的选项。
|
||||
与批量作业相比的一个关键区别是,流不会结束。这种差异会带来很多隐含的结果。正如本章开始部分所讨论的,排序对无界数据集没有意义,因此无法使用 **排序合并连接**(请参阅 “[Reduce 侧连接与分组](ch10.md#Reduce侧连接与分组)”)。容错机制也必须改变:对于已经运行了几分钟的批处理作业,可以简单地从头开始重启失败任务,但是对于已经运行数年的流作业,重启后从头开始跑可能并不是一个可行的选项。
|
||||
|
||||
### 流处理的应用
|
||||
|
||||
@ -433,100 +433,100 @@ $$
|
||||
|
||||
#### 复合事件处理
|
||||
|
||||
**复合事件处理(complex event processing, CEP)** 是20世纪90年代为分析事件流而开发出的一种方法,尤其适用于需要搜索某些事件模式的应用【65,66】。与正则表达式允许你在字符串中搜索特定字符模式的方式类似,CEP允许你指定规则以在流中搜索某些事件模式。
|
||||
**复合事件处理(complex event processing, CEP)** 是 20 世纪 90 年代为分析事件流而开发出的一种方法,尤其适用于需要搜索某些事件模式的应用【65,66】。与正则表达式允许你在字符串中搜索特定字符模式的方式类似,CEP 允许你指定规则以在流中搜索某些事件模式。
|
||||
|
||||
CEP系统通常使用高层次的声明式查询语言,比如SQL,或者图形用户界面,来描述应该检测到的事件模式。这些查询被提交给处理引擎,该引擎消费输入流,并在内部维护一个执行所需匹配的状态机。当发现匹配时,引擎发出一个**复合事件**(即complex event,CEP因此得名),并附有检测到的事件模式详情【67】。
|
||||
CEP 系统通常使用高层次的声明式查询语言,比如 SQL,或者图形用户界面,来描述应该检测到的事件模式。这些查询被提交给处理引擎,该引擎消费输入流,并在内部维护一个执行所需匹配的状态机。当发现匹配时,引擎发出一个 **复合事件**(即 complex event,CEP 因此得名),并附有检测到的事件模式详情【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】。
|
||||
|
||||
#### 流分析
|
||||
|
||||
使用流处理的另一个领域是对流进行分析。 CEP与流分析之间的边界是模糊的,但一般来说,分析往往对找出特定事件序列并不关心,而更关注大量事件上的聚合与统计指标 —— 例如:
|
||||
使用流处理的另一个领域是对流进行分析。 CEP 与流分析之间的边界是模糊的,但一般来说,分析往往对找出特定事件序列并不关心,而更关注大量事件上的聚合与统计指标 —— 例如:
|
||||
|
||||
* 测量某种类型事件的速率(每个时间间隔内发生的频率)
|
||||
* 滚动计算一段时间窗口内某个值的平均值
|
||||
* 将当前的统计值与先前的时间区间的值对比(例如,检测趋势,当指标与上周同比异常偏高或偏低时报警)
|
||||
|
||||
这些统计值通常是在固定时间区间内进行计算的,例如,你可能想知道在过去5分钟内服务每秒查询次数的均值,以及此时间段内响应时间的第99百分位点。在几分钟内取平均,能抹平秒和秒之间的无关波动,且仍然能向你展示流量模式的时间图景。聚合的时间间隔称为**窗口(window)**,我们将在“[时间推理](#时间推理)”中更详细地讨论窗口。
|
||||
这些统计值通常是在固定时间区间内进行计算的,例如,你可能想知道在过去 5 分钟内服务每秒查询次数的均值,以及此时间段内响应时间的第 99 百分位点。在几分钟内取平均,能抹平秒和秒之间的无关波动,且仍然能向你展示流量模式的时间图景。聚合的时间间隔称为 **窗口(window)**,我们将在 “[时间推理](#时间推理)” 中更详细地讨论窗口。
|
||||
|
||||
流分析系统有时会使用概率算法,例如Bloom filter(我们在“[性能优化](ch3.md#性能优化)”中遇到过)来管理成员资格,HyperLogLog【72】用于基数估计以及各种百分比估计算法(请参阅“[实践中的百分位点](ch1.md#实践中的百分位点)“)。概率算法产出近似的结果,但比起精确算法的优点是内存使用要少得多。使用近似算法有时让人们觉得流处理系统总是有损的和不精确的,但这是错误看法:流处理并没有任何内在的近似性,而概率算法只是一种优化【73】。
|
||||
流分析系统有时会使用概率算法,例如 Bloom filter(我们在 “[性能优化](ch3.md#性能优化)” 中遇到过)来管理成员资格,HyperLogLog【72】用于基数估计以及各种百分比估计算法(请参阅 “[实践中的百分位点](ch1.md#实践中的百分位点)“)。概率算法产出近似的结果,但比起精确算法的优点是内存使用要少得多。使用近似算法有时让人们觉得流处理系统总是有损的和不精确的,但这是错误看法:流处理并没有任何内在的近似性,而概率算法只是一种优化【73】。
|
||||
|
||||
许多开源分布式流处理框架的设计都是针对分析设计的:例如Apache Storm,Spark Streaming,Flink,Concord,Samza和Kafka Streams 【74】。托管服务包括Google Cloud Dataflow和Azure Stream Analytics。
|
||||
许多开源分布式流处理框架的设计都是针对分析设计的:例如 Apache Storm、Spark Streaming、Flink、Concord、Samza 和 Kafka Streams 【74】。托管服务包括 Google Cloud Dataflow 和 Azure Stream Analytics。
|
||||
|
||||
#### 维护物化视图
|
||||
|
||||
我们在“[数据库与流](#数据库与流)”中看到,数据库的变更流可以用于维护衍生数据系统(如缓存、搜索索引和数据仓库),并使其与源数据库保持最新。我们可以将这些示例视作维护**物化视图(materialized view)** 的一种具体场景(请参阅“[聚合:数据立方体和物化视图](ch3.md#聚合:数据立方体和物化视图)”):在某个数据集上衍生出一个替代视图以便高效查询,并在底层数据变更时更新视图【50】。
|
||||
我们在 “[数据库与流](#数据库与流)” 中看到,数据库的变更流可以用于维护衍生数据系统(如缓存、搜索索引和数据仓库),并使其与源数据库保持最新。我们可以将这些示例视作维护 **物化视图(materialized view)** 的一种具体场景(请参阅 “[聚合:数据立方体和物化视图](ch3.md#聚合:数据立方体和物化视图)”):在某个数据集上衍生出一个替代视图以便高效查询,并在底层数据变更时更新视图【50】。
|
||||
|
||||
同样,在事件溯源中,应用程序的状态是通过应用事件日志来维护的;这里的应用程序状态也是一种物化视图。与流分析场景不同的是,仅考虑某个时间窗口内的事件通常是不够的:构建物化视图可能需要任意时间段内的**所有**事件,除了那些可能由日志压缩丢弃的过时事件(请参阅“[日志压缩](#日志压缩)“)。实际上,你需要一个可以一直延伸到时间开端的窗口。
|
||||
同样,在事件溯源中,应用程序的状态是通过应用事件日志来维护的;这里的应用程序状态也是一种物化视图。与流分析场景不同的是,仅考虑某个时间窗口内的事件通常是不够的:构建物化视图可能需要任意时间段内的 **所有** 事件,除了那些可能由日志压缩丢弃的过时事件(请参阅 “[日志压缩](#日志压缩)“)。实际上,你需要一个可以一直延伸到时间开端的窗口。
|
||||
|
||||
原则上讲,任何流处理组件都可以用于维护物化视图,尽管“永远运行”与一些面向分析的框架假设的“主要在有限时间段窗口上运行”背道而驰, Samza和Kafka Streams支持这种用法,建立在Kafka对日志压缩的支持上【75】。
|
||||
原则上讲,任何流处理组件都可以用于维护物化视图,尽管 “永远运行” 与一些面向分析的框架假设的 “主要在有限时间段窗口上运行” 背道而驰, Samza 和 Kafka Streams 支持这种用法,建立在 Kafka 对日志压缩的支持上【75】。
|
||||
|
||||
#### 在流上搜索
|
||||
|
||||
除了允许搜索由多个事件构成模式的CEP外,有时也存在基于复杂标准(例如全文搜索查询)来搜索单个事件的需求。
|
||||
除了允许搜索由多个事件构成模式的 CEP 外,有时也存在基于复杂标准(例如全文搜索查询)来搜索单个事件的需求。
|
||||
|
||||
例如,媒体监测服务可以订阅新闻文章Feed与来自媒体的播客,搜索任何关于公司、产品或感兴趣的话题的新闻。这是通过预先构建一个搜索查询来完成的,然后不断地将新闻项的流与该查询进行匹配。在一些网站上也有类似的功能:例如,当市场上出现符合其搜索条件的新房产时,房地产网站的用户可以要求网站通知他们。Elasticsearch的这种过滤器功能,是实现这种流搜索的一种选择【76】。
|
||||
例如,媒体监测服务可以订阅新闻文章 Feed 与来自媒体的播客,搜索任何关于公司、产品或感兴趣的话题的新闻。这是通过预先构建一个搜索查询来完成的,然后不断地将新闻项的流与该查询进行匹配。在一些网站上也有类似的功能:例如,当市场上出现符合其搜索条件的新房产时,房地产网站的用户可以要求网站通知他们。Elasticsearch 的这种过滤器功能,是实现这种流搜索的一种选择【76】。
|
||||
|
||||
传统的搜索引擎首先索引文件,然后在索引上跑查询。相比之下,搜索一个数据流则反了过来:查询被存储下来,文档从查询中流过,就像在CEP中一样。最简单的情况就是,你可以为每个文档测试每个查询。但是如果你有大量查询,这可能会变慢。为了优化这个过程,可以像对文档一样,为查询建立索引。因而收窄可能匹配的查询集合【77】。
|
||||
传统的搜索引擎首先索引文件,然后在索引上跑查询。相比之下,搜索一个数据流则反了过来:查询被存储下来,文档从查询中流过,就像在 CEP 中一样。最简单的情况就是,你可以为每个文档测试每个查询。但是如果你有大量查询,这可能会变慢。为了优化这个过程,可以像对文档一样,为查询建立索引。因而收窄可能匹配的查询集合【77】。
|
||||
|
||||
#### 消息传递和RPC
|
||||
|
||||
在“[消息传递中的数据流](ch4.md#消息传递中的数据流)”中我们讨论过,消息传递系统可以作为RPC的替代方案,即作为一种服务间通信的机制,比如在Actor模型中所使用的那样。尽管这些系统也是基于消息和事件,但我们通常不会将其视作流处理组件:
|
||||
在 “[消息传递中的数据流](ch4.md#消息传递中的数据流)” 中我们讨论过,消息传递系统可以作为 RPC 的替代方案,即作为一种服务间通信的机制,比如在 Actor 模型中所使用的那样。尽管这些系统也是基于消息和事件,但我们通常不会将其视作流处理组件:
|
||||
|
||||
* Actor框架主要是管理模块通信的并发和分布式执行的一种机制,而流处理主要是一种数据管理技术。
|
||||
* Actor之间的交流往往是短暂的、一对一的;而事件日志则是持久的、多订阅者的。
|
||||
* Actor可以以任意方式进行通信(包括循环的请求/响应模式),但流处理通常配置在无环流水线中,其中每个流都是一个特定作业的输出,由良好定义的输入流中派生而来。
|
||||
* Actor 框架主要是管理模块通信的并发和分布式执行的一种机制,而流处理主要是一种数据管理技术。
|
||||
* Actor 之间的交流往往是短暂的、一对一的;而事件日志则是持久的、多订阅者的。
|
||||
* Actor 可以以任意方式进行通信(包括循环的请求 / 响应模式),但流处理通常配置在无环流水线中,其中每个流都是一个特定作业的输出,由良好定义的输入流中派生而来。
|
||||
|
||||
也就是说,RPC类系统与流处理之间有一些交叉领域。例如,Apache Storm有一个称为**分布式RPC**的功能,它允许将用户查询分散到一系列也处理事件流的节点上;然后这些查询与来自输入流的事件交织,而结果可以被汇总并发回给用户【78】(另请参阅“[多分区数据处理](ch12.md#多分区数据处理)”)。
|
||||
也就是说,RPC 类系统与流处理之间有一些交叉领域。例如,Apache Storm 有一个称为 **分布式 RPC** 的功能,它允许将用户查询分散到一系列也处理事件流的节点上;然后这些查询与来自输入流的事件交织,而结果可以被汇总并发回给用户【78】(另请参阅 “[多分区数据处理](ch12.md#多分区数据处理)”)。
|
||||
|
||||
也可以使用Actor框架来处理流。但是,很多这样的框架在崩溃时不能保证消息的传递,除非你实现了额外的重试逻辑,否则这种处理不是容错的。
|
||||
也可以使用 Actor 框架来处理流。但是,很多这样的框架在崩溃时不能保证消息的传递,除非你实现了额外的重试逻辑,否则这种处理不是容错的。
|
||||
|
||||
### 时间推理
|
||||
|
||||
流处理通常需要与时间打交道,尤其是用于分析目的时候,会频繁使用时间窗口,例如“过去五分钟的平均值”。“过去五分钟”的含义看上去似乎是清晰而无歧义的,但不幸的是,这个概念非常棘手。
|
||||
流处理通常需要与时间打交道,尤其是用于分析目的时候,会频繁使用时间窗口,例如 “过去五分钟的平均值”。“过去五分钟” 的含义看上去似乎是清晰而无歧义的,但不幸的是,这个概念非常棘手。
|
||||
|
||||
在批处理中过程中,大量的历史事件被快速地处理。如果需要按时间来分析,批处理器需要检查每个事件中嵌入的时间戳。读取运行批处理机器的系统时钟没有任何意义,因为处理运行的时间与事件实际发生的时间无关。
|
||||
|
||||
批处理可以在几分钟内读取一年的历史事件;在大多数情况下,感兴趣的时间线是历史中的一年,而不是处理中的几分钟。而且使用事件中的时间戳,使得处理是**确定性**的:在相同的输入上再次运行相同的处理过程会得到相同的结果(请参阅“[容错](ch10.md#容错)”)。
|
||||
批处理可以在几分钟内读取一年的历史事件;在大多数情况下,感兴趣的时间线是历史中的一年,而不是处理中的几分钟。而且使用事件中的时间戳,使得处理是 **确定性** 的:在相同的输入上再次运行相同的处理过程会得到相同的结果(请参阅 “[容错](ch10.md#容错)”)。
|
||||
|
||||
另一方面,许多流处理框架使用处理机器上的本地系统时钟(**处理时间**,即processing time)来确定**窗口(windowing)**【79】。这种方法的优点是简单,如果事件创建与事件处理之间的延迟可以忽略不计,那也是合理的。然而,如果存在任何显著的处理延迟 —— 即,事件处理显著地晚于事件实际发生的时间,这种处理方式就失效了。
|
||||
另一方面,许多流处理框架使用处理机器上的本地系统时钟(**处理时间**,即 processing time)来确定 **窗口(windowing)**【79】。这种方法的优点是简单,如果事件创建与事件处理之间的延迟可以忽略不计,那也是合理的。然而,如果存在任何显著的处理延迟 —— 即,事件处理显著地晚于事件实际发生的时间,这种处理方式就失效了。
|
||||
|
||||
#### 事件时间与处理时间
|
||||
|
||||
很多原因都可能导致处理延迟:排队,网络故障(请参阅“[不可靠的网络](ch8.md#不可靠的网络)”),性能问题导致消息代理/消息处理器出现争用,流消费者重启,从故障中恢复时重新处理过去的事件(请参阅“[重播旧消息](#重播旧消息)”),或者在修复代码BUG之后。
|
||||
很多原因都可能导致处理延迟:排队,网络故障(请参阅 “[不可靠的网络](ch8.md#不可靠的网络)”),性能问题导致消息代理 / 消息处理器出现争用,流消费者重启,从故障中恢复时重新处理过去的事件(请参阅 “[重播旧消息](#重播旧消息)”),或者在修复代码 BUG 之后。
|
||||
|
||||
而且,消息延迟还可能导致无法预测消息顺序。例如,假设用户首先发出一个Web请求(由Web服务器A处理),然后发出第二个请求(由服务器B处理)。 A和B发出描述它们所处理请求的事件,但是B的事件在A的事件发生之前到达消息代理。现在,流处理器将首先看到B事件,然后看到A事件,即使它们实际上是以相反的顺序发生的。
|
||||
而且,消息延迟还可能导致无法预测消息顺序。例如,假设用户首先发出一个 Web 请求(由 Web 服务器 A 处理),然后发出第二个请求(由服务器 B 处理)。 A 和 B 发出描述它们所处理请求的事件,但是 B 的事件在 A 的事件发生之前到达消息代理。现在,流处理器将首先看到 B 事件,然后看到 A 事件,即使它们实际上是以相反的顺序发生的。
|
||||
|
||||
有一个类比也许能帮助理解,“星球大战”电影:第四集于1977年发行,第五集于1980年,第六集于1983年,紧随其后的是1999年的第一集,2002年的第二集,和2005年的第三集,以及2015年的第七集【80】[^ii]。如果你按照按照它们上映的顺序观看电影,你处理电影的顺序与它们叙事的顺序就是不一致的。 (集数编号就像事件时间戳,而你观看电影的日期就是处理时间)作为人类,我们能够应对这种不连续性,但是流处理算法需要专门编写,以适应这种时序与顺序的问题。
|
||||
有一个类比也许能帮助理解,“星球大战” 电影:第四集于 1977 年发行,第五集于 1980 年,第六集于 1983 年,紧随其后的是 1999 年的第一集,2002 年的第二集,和 2005 年的第三集,以及 2015 年的第七集【80】[^ii]。如果你按照按照它们上映的顺序观看电影,你处理电影的顺序与它们叙事的顺序就是不一致的。 (集数编号就像事件时间戳,而你观看电影的日期就是处理时间)作为人类,我们能够应对这种不连续性,但是流处理算法需要专门编写,以适应这种时序与顺序的问题。
|
||||
|
||||
[^ii]: 感谢Flink社区的Kostas Kloudas提出这个比喻。
|
||||
[^ii]: 感谢 Flink 社区的 Kostas Kloudas 提出这个比喻。
|
||||
|
||||
将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图11-7](img/fig11-7.png))。
|
||||
将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图 11-7](img/fig11-7.png))。
|
||||
|
||||
![](img/fig11-7.png)
|
||||
|
||||
**图11-7 按处理时间分窗,会因为处理速率的变动引入人为因素**
|
||||
**图 11-7 按处理时间分窗,会因为处理速率的变动引入人为因素**
|
||||
|
||||
#### 知道什么时候准备好了
|
||||
|
||||
用事件时间来定义窗口的一个棘手的问题是,你永远也无法确定是不是已经收到了特定窗口的所有事件,还是说还有一些事件正在来的路上。
|
||||
|
||||
例如,假设你将事件分组为一分钟的窗口,以便统计每分钟的请求数。你已经计数了一些带有本小时内第37分钟时间戳的事件,时间流逝,现在进入的主要都是本小时内第38和第39分钟的事件。什么时候才能宣布你已经完成了第37分钟的窗口计数,并输出其计数器值?
|
||||
例如,假设你将事件分组为一分钟的窗口,以便统计每分钟的请求数。你已经计数了一些带有本小时内第 37 分钟时间戳的事件,时间流逝,现在进入的主要都是本小时内第 38 和第 39 分钟的事件。什么时候才能宣布你已经完成了第 37 分钟的窗口计数,并输出其计数器值?
|
||||
|
||||
在一段时间没有看到任何新的事件之后,你可以超时并宣布一个窗口已经就绪,但仍然可能发生这种情况:某些事件被缓冲在另一台机器上,由于网络中断而延迟。你需要能够处理这种在窗口宣告完成之后到达的 **滞留(straggler)** 事件。大体上,你有两种选择【1】:
|
||||
|
||||
1. 忽略这些滞留事件,因为在正常情况下它们可能只是事件中的一小部分。你可以将丢弃事件的数量作为一个监控指标,并在出现大量丢消息的情况时报警。
|
||||
2. 发布一个**更正(correction)**,一个包括滞留事件的更新窗口值。你可能还需要收回以前的输出。
|
||||
2. 发布一个 **更正(correction)**,一个包括滞留事件的更新窗口值。你可能还需要收回以前的输出。
|
||||
|
||||
在某些情况下,可以使用特殊的消息来指示“从现在开始,不会有比t更早时间戳的消息了”,消费者可以使用它来触发窗口【81】。但是,如果不同机器上的多个生产者都在生成事件,每个生产者都有自己的最小时间戳阈值,则消费者需要分别跟踪每个生产者。在这种情况下,添加和删除生产者都是比较棘手的。
|
||||
在某些情况下,可以使用特殊的消息来指示 “从现在开始,不会有比 t 更早时间戳的消息了”,消费者可以使用它来触发窗口【81】。但是,如果不同机器上的多个生产者都在生成事件,每个生产者都有自己的最小时间戳阈值,则消费者需要分别跟踪每个生产者。在这种情况下,添加和删除生产者都是比较棘手的。
|
||||
|
||||
#### 你用的是谁的时钟?
|
||||
|
||||
当事件可能在系统内多个地方进行缓冲时,为事件分配时间戳更加困难了。例如,考虑一个移动应用向服务器上报关于用量的事件。该应用可能会在设备处于脱机状态时被使用,在这种情况下,它将在设备本地缓冲事件,并在下一次互联网连接可用时向服务器上报这些事件(可能是几小时甚至几天)。对于这个流的任意消费者而言,它们就如延迟极大的滞留事件一样。
|
||||
|
||||
在这种情况下,事件上的事件戳实际上应当是用户交互发生的时间,取决于移动设备的本地时钟。然而用户控制的设备上的时钟通常是不可信的,因为它可能会被无意或故意设置成错误的时间(请参阅“[时钟同步与准确性](ch8.md#时钟同步与准确性)”)。服务器收到事件的时间(取决于服务器的时钟)可能是更准确的,因为服务器在你的控制之下,但在描述用户交互方面意义不大。
|
||||
在这种情况下,事件上的事件戳实际上应当是用户交互发生的时间,取决于移动设备的本地时钟。然而用户控制的设备上的时钟通常是不可信的,因为它可能会被无意或故意设置成错误的时间(请参阅 “[时钟同步与准确性](ch8.md#时钟同步与准确性)”)。服务器收到事件的时间(取决于服务器的时钟)可能是更准确的,因为服务器在你的控制之下,但在描述用户交互方面意义不大。
|
||||
|
||||
要校正不正确的设备时钟,一种方法是记录三个时间戳【82】:
|
||||
|
||||
@ -544,58 +544,58 @@ CEP的实现包括Esper【69】,IBM InfoSphere Streams【70】,Apama,TIBCO
|
||||
|
||||
* 滚动窗口(Tumbling Window)
|
||||
|
||||
滚动窗口有着固定的长度,每个事件都仅能属于一个窗口。例如,假设你有一个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 Window)
|
||||
|
||||
跳动窗口也有着固定的长度,但允许窗口重叠以提供一些平滑。例如,一个带有1分钟跳跃步长的5分钟窗口将包含`10:03:00`至`10:07:59`之间的事件,而下一个窗口将覆盖`10:04:00`至`10:08:59`之间的事件,等等。通过首先计算1分钟的滚动窗口(tunmbling window),然后在几个相邻窗口上进行聚合,可以实现这种跳动窗口。
|
||||
跳动窗口也有着固定的长度,但允许窗口重叠以提供一些平滑。例如,一个带有 1 分钟跳跃步长的 5 分钟窗口将包含 `10:03:00` 至 `10:07:59` 之间的事件,而下一个窗口将覆盖 `10:04:00` 至 `10:08:59` 之间的事件,等等。通过首先计算 1 分钟的滚动窗口(tunmbling window),然后在几个相邻窗口上进行聚合,可以实现这种跳动窗口。
|
||||
|
||||
* 滑动窗口(Sliding Window)
|
||||
|
||||
滑动窗口包含了彼此间距在特定时长内的所有事件。例如,一个5分钟的滑动窗口应当覆盖`10:03:39`和`10:08:12`的事件,因为它们相距不超过5分钟(注意滚动窗口与步长5分钟的跳动窗口可能不会把这两个事件分组到同一个窗口中,因为它们使用固定的边界)。通过维护一个按时间排序的事件缓冲区,并不断从窗口中移除过期的旧事件,可以实现滑动窗口。
|
||||
滑动窗口包含了彼此间距在特定时长内的所有事件。例如,一个 5 分钟的滑动窗口应当覆盖 `10:03:39` 和 `10:08:12` 的事件,因为它们相距不超过 5 分钟(注意滚动窗口与步长 5 分钟的跳动窗口可能不会把这两个事件分组到同一个窗口中,因为它们使用固定的边界)。通过维护一个按时间排序的事件缓冲区,并不断从窗口中移除过期的旧事件,可以实现滑动窗口。
|
||||
|
||||
* 会话窗口(Session window)
|
||||
|
||||
与其他窗口类型不同,会话窗口没有固定的持续时间,而定义为:将同一用户出现时间相近的所有事件分组在一起,而当用户一段时间没有活动时(例如,如果30分钟内没有事件)窗口结束。会话切分是网站分析的常见需求(请参阅“[分组](ch10.md#分组)”)。
|
||||
与其他窗口类型不同,会话窗口没有固定的持续时间,而定义为:将同一用户出现时间相近的所有事件分组在一起,而当用户一段时间没有活动时(例如,如果 30 分钟内没有事件)窗口结束。会话切分是网站分析的常见需求(请参阅 “[分组](ch10.md#分组)”)。
|
||||
|
||||
### 流连接
|
||||
|
||||
在[第十章](ch10.md)中,我们讨论了批处理作业如何通过键来连接数据集,以及这种连接是如何成为数据管道的重要组成部分的。由于流处理将数据管道泛化为对无限数据集进行增量处理,因此对流进行连接的需求也是完全相同的。
|
||||
在 [第十章](ch10.md) 中,我们讨论了批处理作业如何通过键来连接数据集,以及这种连接是如何成为数据管道的重要组成部分的。由于流处理将数据管道泛化为对无限数据集进行增量处理,因此对流进行连接的需求也是完全相同的。
|
||||
|
||||
然而,新事件随时可能出现在一个流中,这使得流连接要比批处理连接更具挑战性。为了更好地理解情况,让我们先来区分三种不同类型的连接:**流-流**连接,**流-表**连接,与**表-表**连接【84】。我们将在下面的章节中通过例子来说明。
|
||||
然而,新事件随时可能出现在一个流中,这使得流连接要比批处理连接更具挑战性。为了更好地理解情况,让我们先来区分三种不同类型的连接:**流 - 流** 连接,**流 - 表** 连接,与 **表 - 表** 连接【84】。我们将在下面的章节中通过例子来说明。
|
||||
|
||||
#### 流流连接(窗口连接)
|
||||
|
||||
假设你的网站上有搜索功能,而你想要找出搜索URL的近期趋势。每当有人键入搜索查询时,都会记录下一个包含查询与其返回结果的事件。每当有人点击其中一个搜索结果时,就会记录另一个记录点击事件。为了计算搜索结果中每个URL的点击率,你需要将搜索动作与点击动作的事件连在一起,这些事件通过相同的会话ID进行连接。广告系统中需要类似的分析【85】。
|
||||
假设你的网站上有搜索功能,而你想要找出搜索 URL 的近期趋势。每当有人键入搜索查询时,都会记录下一个包含查询与其返回结果的事件。每当有人点击其中一个搜索结果时,就会记录另一个记录点击事件。为了计算搜索结果中每个 URL 的点击率,你需要将搜索动作与点击动作的事件连在一起,这些事件通过相同的会话 ID 进行连接。广告系统中需要类似的分析【85】。
|
||||
|
||||
如果用户丢弃了搜索结果,点击可能永远不会发生,即使它出现了,搜索与点击之间的时间可能是高度可变的:在很多情况下,它可能是几秒钟,但也可能长达几天或几周(如果用户执行搜索,忘掉了这个浏览器页面,过了一段时间后重新回到这个浏览器页面上,并点击了一个结果)。由于可变的网络延迟,点击事件甚至可能先于搜索事件到达。你可以选择合适的连接窗口 —— 例如,如果点击与搜索之间的时间间隔在一小时内,你可能会选择连接两者。
|
||||
|
||||
请注意,在点击事件中嵌入搜索详情与事件连接并不一样:这样做的话,只有当用户点击了一个搜索结果时你才能知道,而那些没有点击的搜索就无能为力了。为了衡量搜索质量,你需要准确的点击率,为此搜索事件和点击事件两者都是必要的。
|
||||
|
||||
为了实现这种类型的连接,流处理器需要维护**状态**:例如,按会话ID索引最近一小时内发生的所有事件。无论何时发生搜索事件或点击事件,都会被添加到合适的索引中,而流处理器也会检查另一个索引是否有具有相同会话ID的事件到达。如果有匹配事件就会发出一个表示搜索结果被点击的事件;如果搜索事件直到过期都没看见有匹配的点击事件,就会发出一个表示搜索结果未被点击的事件。
|
||||
为了实现这种类型的连接,流处理器需要维护 **状态**:例如,按会话 ID 索引最近一小时内发生的所有事件。无论何时发生搜索事件或点击事件,都会被添加到合适的索引中,而流处理器也会检查另一个索引是否有具有相同会话 ID 的事件到达。如果有匹配事件就会发出一个表示搜索结果被点击的事件;如果搜索事件直到过期都没看见有匹配的点击事件,就会发出一个表示搜索结果未被点击的事件。
|
||||
|
||||
#### 流表连接(流扩充)
|
||||
|
||||
在“[示例:用户活动事件分析](ch10.md#示例:用户活动事件分析)”([图10-2](img/fig10-2.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户ID的活动事件流,而输出还是活动事件流,但其中用户ID已经被扩展为用户的档案信息。这个过程有时被称为使用数据库的信息来**扩充(enriching)** 活动事件。
|
||||
在 “[示例:用户活动事件分析](ch10.md#示例:用户活动事件分析)”([图 10-2](img/fig10-2.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户 ID 的活动事件流,而输出还是活动事件流,但其中用户 ID 已经被扩展为用户的档案信息。这个过程有时被称为使用数据库的信息来 **扩充(enriching)** 活动事件。
|
||||
|
||||
要执行此连接,流处理器需要一次处理一个活动事件,在数据库中查找事件的用户ID,并将档案信息添加到活动事件中。数据库查询可以通过查询远程数据库来实现。但正如在“[示例:用户活动事件分析](ch10.md#示例:用户活动事件分析)”一节中讨论的,此类远程查询可能会很慢,并且有可能导致数据库过载【75】。
|
||||
要执行此连接,流处理器需要一次处理一个活动事件,在数据库中查找事件的用户 ID,并将档案信息添加到活动事件中。数据库查询可以通过查询远程数据库来实现。但正如在 “[示例:用户活动事件分析](ch10.md#示例:用户活动事件分析)” 一节中讨论的,此类远程查询可能会很慢,并且有可能导致数据库过载【75】。
|
||||
|
||||
另一种方法是将数据库副本加载到流处理器中,以便在本地进行查询而无需网络往返。这种技术与我们在“[Map侧连接](ch10.md#Map侧连接)”中讨论的散列连接非常相似:如果数据库的本地副本足够小,则可以是内存中的散列表,比较大的话也可以是本地磁盘上的索引。
|
||||
另一种方法是将数据库副本加载到流处理器中,以便在本地进行查询而无需网络往返。这种技术与我们在 “[Map 侧连接](ch10.md#Map侧连接)” 中讨论的散列连接非常相似:如果数据库的本地副本足够小,则可以是内存中的散列表,比较大的话也可以是本地磁盘上的索引。
|
||||
|
||||
与批处理作业的区别在于,批处理作业使用数据库的时间点快照作为输入,而流处理器是长时间运行的,且数据库的内容可能随时间而改变,所以流处理器数据库的本地副本需要保持更新。这个问题可以通过变更数据捕获来解决:流处理器可以订阅用户档案数据库的更新日志,如同活动事件流一样。当增添或修改档案时,流处理器会更新其本地副本。因此,我们有了两个流之间的连接:活动事件和档案更新。
|
||||
|
||||
流表连接实际上非常类似于流流连接;最大的区别在于对于表的变更日志流,连接使用了一个可以回溯到“时间起点”的窗口(概念上是无限的窗口),新版本的记录会覆盖更早的版本。对于输入的流,连接可能压根儿就没有维护任何窗口。
|
||||
流表连接实际上非常类似于流流连接;最大的区别在于对于表的变更日志流,连接使用了一个可以回溯到 “时间起点” 的窗口(概念上是无限的窗口),新版本的记录会覆盖更早的版本。对于输入的流,连接可能压根儿就没有维护任何窗口。
|
||||
|
||||
#### 表表连接(维护物化视图)
|
||||
|
||||
我们在“[描述负载](ch1.md#描述负载)”中讨论的推特时间线例子时说过,当用户想要查看他们的主页时间线时,迭代用户所关注人群的推文并合并它们是一个开销巨大的操作。
|
||||
我们在 “[描述负载](ch1.md#描述负载)” 中讨论的推特时间线例子时说过,当用户想要查看他们的主页时间线时,迭代用户所关注人群的推文并合并它们是一个开销巨大的操作。
|
||||
|
||||
相反,我们需要一个时间线缓存:一种每个用户的“收件箱”,在发送推文的时候写入这些信息,因而读取时间线时只需要简单地查询即可。物化与维护这个缓存需要处理以下事件:
|
||||
相反,我们需要一个时间线缓存:一种每个用户的 “收件箱”,在发送推文的时候写入这些信息,因而读取时间线时只需要简单地查询即可。物化与维护这个缓存需要处理以下事件:
|
||||
|
||||
* 当用户u发送新的推文时,它将被添加到每个关注用户u的时间线上。
|
||||
* 当用户 u 发送新的推文时,它将被添加到每个关注用户 u 的时间线上。
|
||||
* 用户删除推文时,推文将从所有用户的时间表中删除。
|
||||
* 当用户$u_1$开始关注用户$u_2$时,$u_2$最近的推文将被添加到$u_1$的时间线上。
|
||||
* 当用户$u_1$取消关注用户$u_2$时,$u_2$的推文将从$u_1$的时间线中移除。
|
||||
* 当用户 $u_1$ 开始关注用户 $u_2$ 时,$u_2$ 最近的推文将被添加到 $u_1$ 的时间线上。
|
||||
* 当用户 $u_1$ 取消关注用户 $u_2$ 时,$u_2$ 的推文将从 $u_1$ 的时间线中移除。
|
||||
|
||||
要在流处理器中实现这种缓存维护,你需要推文事件流(发送与删除)和关注关系事件流(关注与取消关注)。流处理需要维护一个数据库,包含每个用户的粉丝集合。以便知道当一条新推文到达时,需要更新哪些时间线【86】。
|
||||
|
||||
@ -609,9 +609,9 @@ JOIN follows ON follows.followee_id = tweets.sender_id
|
||||
GROUP BY follows.follower_id
|
||||
```
|
||||
|
||||
流连接直接对应于这个查询中的表连接。时间线实际上是这个查询结果的缓存,每当底层的表发生变化时都会更新[^iii]。
|
||||
流连接直接对应于这个查询中的表连接。时间线实际上是这个查询结果的缓存,每当底层的表发生变化时都会更新 [^iii]。
|
||||
|
||||
[^iii]: 如果你将流视作表的衍生物,如[图11-6](img/fig11-6.png)所示,而把一个连接看作是两个表的乘法u·v,那么会发生一些有趣的事情:物化连接的变化流遵循乘积法则:(u·v)'= u'v + uv'。 换句话说,任何推文的变化量都与当前的关注联系在一起,任何关注的变化量都与当前的推文相连接【49,50】。
|
||||
[^iii]: 如果你将流视作表的衍生物,如 [图 11-6](img/fig11-6.png) 所示,而把一个连接看作是两个表的乘法u·v,那么会发生一些有趣的事情:物化连接的变化流遵循乘积法则:(u·v)'= u'v + uv'。 换句话说,任何推文的变化量都与当前的关注联系在一起,任何关注的变化量都与当前的推文相连接【49,50】。
|
||||
|
||||
#### 连接的时间依赖性
|
||||
|
||||
@ -621,80 +621,80 @@ GROUP BY follows.follower_id
|
||||
|
||||
这就产生了一个问题:如果不同流中的事件发生在近似的时间范围内,则应该按照什么样的顺序进行处理?在流表连接的例子中,如果用户更新了它们的档案,哪些活动事件与旧档案连接(在档案更新前处理),哪些又与新档案连接(在档案更新之后处理)?换句话说:你需要对一些状态做连接,如果状态会随着时间推移而变化,那应当使用什么时间点来连接呢【45】?
|
||||
|
||||
这种时序依赖可能出现在很多地方。例如销售东西需要对发票应用适当的税率,这取决于所处的国家/州,产品类型,销售日期(因为税率时不时会变化)。当连接销售额与税率表时,你可能期望的是使用销售时的税率参与连接。如果你正在重新处理历史数据,销售时的税率可能和现在的税率有所不同。
|
||||
这种时序依赖可能出现在很多地方。例如销售东西需要对发票应用适当的税率,这取决于所处的国家 / 州,产品类型,销售日期(因为税率时不时会变化)。当连接销售额与税率表时,你可能期望的是使用销售时的税率参与连接。如果你正在重新处理历史数据,销售时的税率可能和现在的税率有所不同。
|
||||
|
||||
如果跨越流的事件顺序是未定的,则连接会变为不确定性的【87】,这意味着你在同样输入上重跑相同的作业未必会得到相同的结果:当你重跑任务时,输入流上的事件可能会以不同的方式交织。
|
||||
|
||||
在数据仓库中,这个问题被称为**缓慢变化的维度(slowly changing dimension, SCD)**,通常通过对特定版本的记录使用唯一的标识符来解决:例如,每当税率改变时都会获得一个新的标识符,而发票在销售时会带有税率的标识符【88,89】。这种变化使连接变为确定性的,但也会导致日志压缩无法进行:表中所有的记录版本都需要保留。
|
||||
在数据仓库中,这个问题被称为 **缓慢变化的维度(slowly changing dimension, SCD)**,通常通过对特定版本的记录使用唯一的标识符来解决:例如,每当税率改变时都会获得一个新的标识符,而发票在销售时会带有税率的标识符【88,89】。这种变化使连接变为确定性的,但也会导致日志压缩无法进行:表中所有的记录版本都需要保留。
|
||||
|
||||
### 容错
|
||||
|
||||
在本章的最后一节中,让我们看一看流处理是如何容错的。我们在[第十章](ch10.md)中看到,批处理框架可以很容易地容错:如果MapReduce作业中的任务失败,可以简单地在另一台机器上再次启动,并且丢弃失败任务的输出。这种透明的重试是可能的,因为输入文件是不可变的,每个任务都将其输出写入到HDFS上的独立文件中,而输出仅当任务成功完成后可见。
|
||||
在本章的最后一节中,让我们看一看流处理是如何容错的。我们在 [第十章](ch10.md) 中看到,批处理框架可以很容易地容错:如果 MapReduce 作业中的任务失败,可以简单地在另一台机器上再次启动,并且丢弃失败任务的输出。这种透明的重试是可能的,因为输入文件是不可变的,每个任务都将其输出写入到 HDFS 上的独立文件中,而输出仅当任务成功完成后可见。
|
||||
|
||||
特别是,批处理容错方法可确保批处理作业的输出与没有出错的情况相同,即使实际上某些任务失败了。看起来好像每条输入记录都被处理了恰好一次 —— 没有记录被跳过,而且没有记录被处理两次。尽管重启任务意味着实际上可能会多次处理记录,但输出中的可见效果看上去就像只处理过一次。这个原则被称为**恰好一次语义(exactly-once semantics)**,尽管**等效一次(effectively-once)** 可能会是一个更写实的术语【90】。
|
||||
特别是,批处理容错方法可确保批处理作业的输出与没有出错的情况相同,即使实际上某些任务失败了。看起来好像每条输入记录都被处理了恰好一次 —— 没有记录被跳过,而且没有记录被处理两次。尽管重启任务意味着实际上可能会多次处理记录,但输出中的可见效果看上去就像只处理过一次。这个原则被称为 **恰好一次语义(exactly-once semantics)**,尽管 **等效一次(effectively-once)** 可能会是一个更写实的术语【90】。
|
||||
|
||||
在流处理中也出现了同样的容错问题,但是处理起来没有那么直观:等待某个任务完成之后再使其输出可见并不是一个可行选项,因为你永远无法处理完一个无限的流。
|
||||
|
||||
#### 微批量与存档点
|
||||
|
||||
一个解决方案是将流分解成小块,并像微型批处理一样处理每个块。这种方法被称为**微批次(microbatching)**,它被用于Spark Streaming 【91】。批次的大小通常约为1秒,这是对性能妥协的结果:较小的批次会导致更大的调度与协调开销,而较大的批次意味着流处理器结果可见之前的延迟要更长。
|
||||
一个解决方案是将流分解成小块,并像微型批处理一样处理每个块。这种方法被称为 **微批次(microbatching)**,它被用于 Spark Streaming 【91】。批次的大小通常约为 1 秒,这是对性能妥协的结果:较小的批次会导致更大的调度与协调开销,而较大的批次意味着流处理器结果可见之前的延迟要更长。
|
||||
|
||||
微批次也隐式提供了一个与批次大小相等的滚动窗口(按处理时间而不是事件时间戳分窗)。任何需要更大窗口的作业都需要显式地将状态从一个微批次转移到下一个微批次。
|
||||
|
||||
Apache Flink则使用不同的方法,它会定期生成状态的滚动存档点并将其写入持久存储【92,93】。如果流算子崩溃,它可以从最近的存档点重启,并丢弃从最近检查点到崩溃之间的所有输出。存档点会由消息流中的**壁障(barrier)** 触发,类似于微批次之间的边界,但不会强制一个特定的窗口大小。
|
||||
Apache Flink 则使用不同的方法,它会定期生成状态的滚动存档点并将其写入持久存储【92,93】。如果流算子崩溃,它可以从最近的存档点重启,并丢弃从最近检查点到崩溃之间的所有输出。存档点会由消息流中的 **壁障(barrier)** 触发,类似于微批次之间的边界,但不会强制一个特定的窗口大小。
|
||||
|
||||
在流处理框架的范围内,微批次与存档点方法提供了与批处理一样的**恰好一次语义**。但是,只要输出离开流处理器(例如,写入数据库,向外部消息代理发送消息,或发送电子邮件),框架就无法抛弃失败批次的输出了。在这种情况下,重启失败任务会导致外部副作用发生两次,只有微批次或存档点不足以阻止这一问题。
|
||||
在流处理框架的范围内,微批次与存档点方法提供了与批处理一样的 **恰好一次语义**。但是,只要输出离开流处理器(例如,写入数据库,向外部消息代理发送消息,或发送电子邮件),框架就无法抛弃失败批次的输出了。在这种情况下,重启失败任务会导致外部副作用发生两次,只有微批次或存档点不足以阻止这一问题。
|
||||
|
||||
#### 原子提交再现
|
||||
|
||||
为了在出现故障时表现出恰好处理一次的样子,我们需要确保事件处理的所有输出和副作用**当且仅当**处理成功时才会生效。这些影响包括发送给下游算子或外部消息传递系统(包括电子邮件或推送通知)的任何消息,任何数据库写入,对算子状态的任何变更,以及对输入消息的任何确认(包括在基于日志的消息代理中将消费者偏移量前移)。
|
||||
为了在出现故障时表现出恰好处理一次的样子,我们需要确保事件处理的所有输出和副作用 **当且仅当** 处理成功时才会生效。这些影响包括发送给下游算子或外部消息传递系统(包括电子邮件或推送通知)的任何消息,任何数据库写入,对算子状态的任何变更,以及对输入消息的任何确认(包括在基于日志的消息代理中将消费者偏移量前移)。
|
||||
|
||||
这些事情要么都原子地发生,要么都不发生,但是它们不应当失去同步。如果这种方法听起来很熟悉,那是因为我们在分布式事务和两阶段提交的上下文中讨论过它(请参阅“[恰好一次的消息处理](ch9.md#恰好一次的消息处理)”)。
|
||||
这些事情要么都原子地发生,要么都不发生,但是它们不应当失去同步。如果这种方法听起来很熟悉,那是因为我们在分布式事务和两阶段提交的上下文中讨论过它(请参阅 “[恰好一次的消息处理](ch9.md#恰好一次的消息处理)”)。
|
||||
|
||||
在[第九章](ch9.md)中,我们讨论了分布式事务传统实现中的问题(如XA)。然而在限制更为严苛的环境中,也是有可能高效实现这种原子提交机制的。 Google Cloud Dataflow【81,92】和VoltDB 【94】中使用了这种方法,Apache Kafka有计划加入类似的功能【95,96】。与XA不同,这些实现不会尝试跨异构技术提供事务,而是通过在流处理框架中同时管理状态变更与消息传递来内化事务。事务协议的开销可以通过在单个事务中处理多个输入消息来分摊。
|
||||
在 [第九章](ch9.md) 中,我们讨论了分布式事务传统实现中的问题(如 XA)。然而在限制更为严苛的环境中,也是有可能高效实现这种原子提交机制的。 Google Cloud Dataflow【81,92】和 VoltDB 【94】中使用了这种方法,Apache Kafka 有计划加入类似的功能【95,96】。与 XA 不同,这些实现不会尝试跨异构技术提供事务,而是通过在流处理框架中同时管理状态变更与消息传递来内化事务。事务协议的开销可以通过在单个事务中处理多个输入消息来分摊。
|
||||
|
||||
#### 幂等性
|
||||
|
||||
我们的目标是丢弃任何失败任务的部分输出,以便能安全地重试,而不会生效两次。分布式事务是实现这个目标的一种方式,而另一种方式是依赖**幂等性(idempotence)**【97】。
|
||||
我们的目标是丢弃任何失败任务的部分输出,以便能安全地重试,而不会生效两次。分布式事务是实现这个目标的一种方式,而另一种方式是依赖 **幂等性(idempotence)**【97】。
|
||||
|
||||
幂等操作是多次重复执行与单次执行效果相同的操作。例如,将键值存储中的某个键设置为某个特定值是幂等的(再次写入该值,只是用同样的值替代),而递增一个计数器不是幂等的(再次执行递增意味着该值递增两次)。
|
||||
|
||||
即使一个操作不是天生幂等的,往往可以通过一些额外的元数据做成幂等的。例如,在使用来自Kafka的消息时,每条消息都有一个持久的、单调递增的偏移量。将值写入外部数据库时可以将这个偏移量带上,这样你就可以判断一条更新是不是已经执行过了,因而避免重复执行。
|
||||
即使一个操作不是天生幂等的,往往可以通过一些额外的元数据做成幂等的。例如,在使用来自 Kafka 的消息时,每条消息都有一个持久的、单调递增的偏移量。将值写入外部数据库时可以将这个偏移量带上,这样你就可以判断一条更新是不是已经执行过了,因而避免重复执行。
|
||||
|
||||
Storm的Trident基于类似的想法来处理状态【78】。依赖幂等性意味着隐含了一些假设:重启一个失败的任务必须以相同的顺序重播相同的消息(基于日志的消息代理能做这些事),处理必须是确定性的,没有其他节点能同时更新相同的值【98,99】。
|
||||
Storm 的 Trident 基于类似的想法来处理状态【78】。依赖幂等性意味着隐含了一些假设:重启一个失败的任务必须以相同的顺序重播相同的消息(基于日志的消息代理能做这些事),处理必须是确定性的,没有其他节点能同时更新相同的值【98,99】。
|
||||
|
||||
当从一个处理节点故障切换到另一个节点时,可能需要进行**防护**(fencing,请参阅“[领导者和锁](ch8.md#领导者和锁)”),以防止被假死节点干扰。尽管有这么多注意事项,幂等操作是一种实现**恰好一次语义**的有效方式,仅需很小的额外开销。
|
||||
当从一个处理节点故障切换到另一个节点时,可能需要进行 **防护**(fencing,请参阅 “[领导者和锁](ch8.md#领导者和锁)”),以防止被假死节点干扰。尽管有这么多注意事项,幂等操作是一种实现 **恰好一次语义** 的有效方式,仅需很小的额外开销。
|
||||
|
||||
#### 失败后重建状态
|
||||
|
||||
任何需要状态的流处理 —— 例如,任何窗口聚合(例如计数器,平均值和直方图)以及任何用于连接的表和索引,都必须确保在失败之后能恢复其状态。
|
||||
|
||||
一种选择是将状态保存在远程数据存储中,并进行复制,然而正如在“[流表连接(流扩充)](#流表连接(流扩充))”中所述,每个消息都要查询远程数据库可能会很慢。另一种方法是在流处理器本地保存状态,并定期复制。然后当流处理器从故障中恢复时,新任务可以读取状态副本,恢复处理而不丢失数据。
|
||||
一种选择是将状态保存在远程数据存储中,并进行复制,然而正如在 “[流表连接(流扩充)](#流表连接(流扩充))” 中所述,每个消息都要查询远程数据库可能会很慢。另一种方法是在流处理器本地保存状态,并定期复制。然后当流处理器从故障中恢复时,新任务可以读取状态副本,恢复处理而不丢失数据。
|
||||
|
||||
例如,Flink定期捕获算子状态的快照,并将它们写入HDFS等持久存储中【92,93】。 Samza和Kafka Streams通过将状态变更发送到具有日志压缩功能的专用Kafka主题来复制状态变更,这与变更数据捕获类似【84,100】。 VoltDB通过在多个节点上对每个输入消息进行冗余处理来复制状态(请参阅“[真的串行执行](ch7.md#真的串行执行)”)。
|
||||
例如,Flink 定期捕获算子状态的快照,并将它们写入 HDFS 等持久存储中【92,93】。 Samza 和 Kafka Streams 通过将状态变更发送到具有日志压缩功能的专用 Kafka 主题来复制状态变更,这与变更数据捕获类似【84,100】。 VoltDB 通过在多个节点上对每个输入消息进行冗余处理来复制状态(请参阅 “[真的串行执行](ch7.md#真的串行执行)”)。
|
||||
|
||||
在某些情况下,甚至可能都不需要复制状态,因为它可以从输入流重建。例如,如果状态是从相当短的窗口中聚合而成,则简单地重播该窗口中的输入事件可能是足够快的。如果状态是通过变更数据捕获来维护的数据库的本地副本,那么也可以从日志压缩的变更流中重建数据库(请参阅“[日志压缩](#日志压缩)”)。
|
||||
在某些情况下,甚至可能都不需要复制状态,因为它可以从输入流重建。例如,如果状态是从相当短的窗口中聚合而成,则简单地重播该窗口中的输入事件可能是足够快的。如果状态是通过变更数据捕获来维护的数据库的本地副本,那么也可以从日志压缩的变更流中重建数据库(请参阅 “[日志压缩](#日志压缩)”)。
|
||||
|
||||
然而,所有这些权衡取决于底层基础架构的性能特征:在某些系统中,网络延迟可能低于磁盘访问延迟,网络带宽也可能与磁盘带宽相当。没有针对所有情况的普适理想权衡,随着存储和网络技术的发展,本地状态与远程状态的优点也可能会互换。
|
||||
|
||||
|
||||
## 本章小结
|
||||
|
||||
在本章中,我们讨论了事件流,它们所服务的目的,以及如何处理它们。在某些方面,流处理非常类似于在[第十章](ch10.md) 中讨论的批处理,不过是在无限的(永无止境的)流而不是固定大小的输入上持续进行。从这个角度来看,消息代理和事件日志可以视作文件系统的流式等价物。
|
||||
在本章中,我们讨论了事件流,它们所服务的目的,以及如何处理它们。在某些方面,流处理非常类似于在 [第十章](ch10.md) 中讨论的批处理,不过是在无限的(永无止境的)流而不是固定大小的输入上持续进行。从这个角度来看,消息代理和事件日志可以视作文件系统的流式等价物。
|
||||
|
||||
我们花了一些时间比较两种消息代理:
|
||||
|
||||
* AMQP/JMS风格的消息代理
|
||||
* AMQP/JMS 风格的消息代理
|
||||
|
||||
代理将单条消息分配给消费者,消费者在成功处理单条消息后确认消息。消息被确认后从代理中删除。这种方法适合作为一种异步形式的RPC(另请参阅“[消息传递中的数据流](ch4.md#消息传递中的数据流)”),例如在任务队列中,消息处理的确切顺序并不重要,而且消息在处理完之后,不需要回头重新读取旧消息。
|
||||
代理将单条消息分配给消费者,消费者在成功处理单条消息后确认消息。消息被确认后从代理中删除。这种方法适合作为一种异步形式的 RPC(另请参阅 “[消息传递中的数据流](ch4.md#消息传递中的数据流)”),例如在任务队列中,消息处理的确切顺序并不重要,而且消息在处理完之后,不需要回头重新读取旧消息。
|
||||
|
||||
* 基于日志的消息代理
|
||||
|
||||
代理将一个分区中的所有消息分配给同一个消费者节点,并始终以相同的顺序传递消息。并行是通过分区实现的,消费者通过存档最近处理消息的偏移量来跟踪工作进度。消息代理将消息保留在磁盘上,因此如有必要的话,可以回跳并重新读取旧消息。
|
||||
|
||||
基于日志的方法与数据库中的复制日志(请参阅[第五章](ch5.md))和日志结构存储引擎(请参阅[第三章](ch3.md))有相似之处。我们看到,这种方法对于消费输入流,并产生衍生状态或衍生输出数据流的系统而言特别适用。
|
||||
基于日志的方法与数据库中的复制日志(请参阅 [第五章](ch5.md))和日志结构存储引擎(请参阅 [第三章](ch3.md))有相似之处。我们看到,这种方法对于消费输入流,并产生衍生状态或衍生输出数据流的系统而言特别适用。
|
||||
|
||||
就流的来源而言,我们讨论了几种可能性:用户活动事件,定期读数的传感器,和Feed数据(例如,金融中的市场数据)能够自然地表示为流。我们发现将数据库写入视作流也是很有用的:我们可以捕获变更日志 —— 即对数据库所做的所有变更的历史记录 —— 隐式地通过变更数据捕获,或显式地通过事件溯源。日志压缩允许流也能保有数据库内容的完整副本。
|
||||
就流的来源而言,我们讨论了几种可能性:用户活动事件,定期读数的传感器,和 Feed 数据(例如,金融中的市场数据)能够自然地表示为流。我们发现将数据库写入视作流也是很有用的:我们可以捕获变更日志 —— 即对数据库所做的所有变更的历史记录 —— 隐式地通过变更数据捕获,或显式地通过事件溯源。日志压缩允许流也能保有数据库内容的完整副本。
|
||||
|
||||
将数据库表示为流为系统集成带来了很多强大机遇。通过消费变更日志并将其应用至衍生系统,你能使诸如搜索索引、缓存以及分析系统这类衍生数据系统不断保持更新。你甚至能从头开始,通过读取从创世至今的所有变更日志,为现有数据创建全新的视图。
|
||||
|
||||
@ -706,7 +706,7 @@ Storm的Trident基于类似的想法来处理状态【78】。依赖幂等性意
|
||||
|
||||
* 流流连接
|
||||
|
||||
两个输入流都由活动事件组成,而连接算子在某个时间窗口内搜索相关的事件。例如,它可能会将同一个用户30分钟内进行的两个活动联系在一起。如果你想要找出一个流内的相关事件,连接的两侧输入可能实际上都是同一个流(**自连接**,即self-join)。
|
||||
两个输入流都由活动事件组成,而连接算子在某个时间窗口内搜索相关的事件。例如,它可能会将同一个用户 30 分钟内进行的两个活动联系在一起。如果你想要找出一个流内的相关事件,连接的两侧输入可能实际上都是同一个流(**自连接**,即 self-join)。
|
||||
|
||||
* 流表连接
|
||||
|
||||
|
386
ch8.md
386
ch8.md
@ -10,40 +10,40 @@
|
||||
>
|
||||
> 无食我数
|
||||
>
|
||||
> —— Kyle Kingsbury, Carly Rae Jepsen 《网络分区的危害》(2013年)[^译著1]
|
||||
> —— Kyle Kingsbury, Carly Rae Jepsen 《网络分区的危害》(2013 年)[^译著1]
|
||||
|
||||
---------
|
||||
|
||||
[TOC]
|
||||
|
||||
最近几章中反复出现的主题是,系统如何处理错误的事情。例如,我们讨论了**副本故障切换**(“[处理节点中断](ch5.md#处理节点宕机)”),**复制延迟**(“[复制延迟问题](ch5.md#复制延迟问题)”)和事务控制(“[弱隔离级别](ch7.md#弱隔离级别)”)。当我们了解可能在实际系统中出现的各种边缘情况时,我们会更好地处理它们。
|
||||
最近几章中反复出现的主题是,系统如何处理错误的事情。例如,我们讨论了 **副本故障切换**(“[处理节点中断](ch5.md#处理节点宕机)”),**复制延迟**(“[复制延迟问题](ch5.md#复制延迟问题)”)和事务控制(“[弱隔离级别](ch7.md#弱隔离级别)”)。当我们了解可能在实际系统中出现的各种边缘情况时,我们会更好地处理它们。
|
||||
|
||||
但是,尽管我们已经谈了很多错误,但之前几章仍然过于乐观。现实更加黑暗。我们现在将悲观主义最大化,假设任何可能出错的东西**都会**出错[^i]。(经验丰富的系统运维会告诉你,这是一个合理的假设。如果你问得好,他们可能会一边治疗心理创伤一边告诉你一些可怕的故事)
|
||||
但是,尽管我们已经谈了很多错误,但之前几章仍然过于乐观。现实更加黑暗。我们现在将悲观主义最大化,假设任何可能出错的东西 **都会** 出错 [^i]。(经验丰富的系统运维会告诉你,这是一个合理的假设。如果你问得好,他们可能会一边治疗心理创伤一边告诉你一些可怕的故事)
|
||||
|
||||
[^i]: 除了一个例外:我们将假定故障是非拜占庭式的(请参阅“[拜占庭故障](#拜占庭故障)”)。
|
||||
[^i]: 除了一个例外:我们将假定故障是非拜占庭式的(请参阅 “[拜占庭故障](#拜占庭故障)”)。
|
||||
|
||||
使用分布式系统与在一台计算机上编写软件有着根本的区别,主要的区别在于,有许多新颖和刺激的方法可以使事情出错【1,2】。在这一章中,我们将了解实践中出现的问题,理解我们能够依赖,和不可以依赖的东西。
|
||||
|
||||
最后,作为工程师,我们的任务是构建能够完成工作的系统(即满足用户期望的保证),尽管一切都出错了。 在[第九章](ch9.md)中,我们将看看一些可以在分布式系统中提供这种保证的算法的例子。 但首先,在本章中,我们必须了解我们面临的挑战。
|
||||
最后,作为工程师,我们的任务是构建能够完成工作的系统(即满足用户期望的保证),尽管一切都出错了。 在 [第九章](ch9.md) 中,我们将看看一些可以在分布式系统中提供这种保证的算法的例子。 但首先,在本章中,我们必须了解我们面临的挑战。
|
||||
|
||||
本章对分布式系统中可能出现的问题进行彻底的悲观和沮丧的总结。 我们将研究网络的问题(“[不可靠的网络](#不可靠的网络)”); 时钟和时序问题(“[不可靠的时钟](#不可靠的时钟)”); 我们将讨论他们可以避免的程度。 所有这些问题的后果都是困惑的,所以我们将探索如何思考一个分布式系统的状态,以及如何推理发生的事情(“[知识、真相与谎言](#知识、真相与谎言)”)。
|
||||
|
||||
|
||||
## 故障与部分失效
|
||||
|
||||
当你在一台计算机上编写一个程序时,它通常会以一种相当可预测的方式运行:无论是工作还是不工作。充满错误的软件可能会让人觉得电脑有时候也会有“糟糕的一天”(这种问题通常是重新启动就恢复了),但这主要是软件写得不好的结果。
|
||||
当你在一台计算机上编写一个程序时,它通常会以一种相当可预测的方式运行:无论是工作还是不工作。充满错误的软件可能会让人觉得电脑有时候也会有 “糟糕的一天”(这种问题通常是重新启动就恢复了),但这主要是软件写得不好的结果。
|
||||
|
||||
单个计算机上的软件没有根本性的不可靠原因:当硬件正常工作时,相同的操作总是产生相同的结果(这是确定性的)。如果存在硬件问题(例如,内存损坏或连接器松动),其后果通常是整个系统故障(例如,内核恐慌,“蓝屏死机”,启动失败)。装有良好软件的个人计算机通常要么功能完好,要么完全失效,而不是介于两者之间。
|
||||
|
||||
这是计算机设计中的一个有意的选择:如果发生内部错误,我们宁愿电脑完全崩溃,而不是返回错误的结果,因为错误的结果很难处理。因为计算机隐藏了模糊不清的物理实现,并呈现出一个理想化的系统模型,并以数学一样的完美的方式运作。 CPU指令总是做同样的事情;如果你将一些数据写入内存或磁盘,那么这些数据将保持不变,并且不会被随机破坏。从第一台数字计算机开始,*始终正确地计算*这个设计目标贯穿始终【3】。
|
||||
这是计算机设计中的一个有意的选择:如果发生内部错误,我们宁愿电脑完全崩溃,而不是返回错误的结果,因为错误的结果很难处理。因为计算机隐藏了模糊不清的物理实现,并呈现出一个理想化的系统模型,并以数学一样的完美的方式运作。 CPU 指令总是做同样的事情;如果你将一些数据写入内存或磁盘,那么这些数据将保持不变,并且不会被随机破坏。从第一台数字计算机开始,*始终正确地计算* 这个设计目标贯穿始终【3】。
|
||||
|
||||
当你编写运行在多台计算机上的软件时,情况有本质上的区别。在分布式系统中,我们不再处于理想化的系统模型中,我们别无选择,只能面对现实世界的混乱现实。而在现实世界中,各种各样的事情都可能会出现问题【4】,如下面的轶事所述:
|
||||
|
||||
> 在我有限的经验中,我已经和很多东西打过交道:单个**数据中心(DC)** 中长期存在的网络分区,配电单元PDU故障,交换机故障,整个机架的意外重启,整个数据中心主干网络故障,整个数据中心的电源故障,以及一个低血糖的司机把他的福特皮卡撞在数据中心的HVAC(加热,通风和空调)系统上。而且我甚至不是一个运维。
|
||||
> 在我有限的经验中,我已经和很多东西打过交道:单个 **数据中心(DC)** 中长期存在的网络分区,配电单元 PDU 故障,交换机故障,整个机架的意外重启,整个数据中心主干网络故障,整个数据中心的电源故障,以及一个低血糖的司机把他的福特皮卡撞在数据中心的 HVAC(加热,通风和空调)系统上。而且我甚至不是一个运维。
|
||||
>
|
||||
> —— 柯达黑尔
|
||||
|
||||
在分布式系统中,尽管系统的其他部分工作正常,但系统的某些部分可能会以某种不可预知的方式被破坏。这被称为**部分失效(partial failure)**。难点在于部分失效是**不确定性的(nonderterministic)**:如果你试图做任何涉及多个节点和网络的事情,它有时可能会工作,有时会出现不可预知的失败。正如我们将要看到的,你甚至不知道是否成功了,因为消息通过网络传播的时间也是不确定的!
|
||||
在分布式系统中,尽管系统的其他部分工作正常,但系统的某些部分可能会以某种不可预知的方式被破坏。这被称为 **部分失效(partial failure)**。难点在于部分失效是 **不确定性的(nonderterministic)**:如果你试图做任何涉及多个节点和网络的事情,它有时可能会工作,有时会出现不可预知的失败。正如我们将要看到的,你甚至不知道是否成功了,因为消息通过网络传播的时间也是不确定的!
|
||||
|
||||
这种不确定性和部分失效的可能性,使得分布式系统难以工作【5】。
|
||||
|
||||
@ -51,27 +51,27 @@
|
||||
|
||||
关于如何构建大型计算系统有一系列的哲学:
|
||||
|
||||
* 一个极端是高性能计算(HPC)领域。具有数千个CPU的超级计算机通常用于计算密集型科学计算任务,如天气预报或分子动力学(模拟原子和分子的运动)。
|
||||
* 另一个极端是**云计算(cloud computing)**,云计算并不是一个良好定义的概念【6】,但通常与多租户数据中心,连接IP网络(通常是以太网)的商用计算机,弹性/按需资源分配以及计量计费等相关联。
|
||||
* 一个极端是高性能计算(HPC)领域。具有数千个 CPU 的超级计算机通常用于计算密集型科学计算任务,如天气预报或分子动力学(模拟原子和分子的运动)。
|
||||
* 另一个极端是 **云计算(cloud computing)**,云计算并不是一个良好定义的概念【6】,但通常与多租户数据中心,连接 IP 网络(通常是以太网)的商用计算机,弹性 / 按需资源分配以及计量计费等相关联。
|
||||
* 传统企业数据中心位于这两个极端之间。
|
||||
|
||||
不同的哲学会导致不同的故障处理方式。在超级计算机中,作业通常会不时地会将计算的状态存盘到持久存储中。如果一个节点出现故障,通常的解决方案是简单地停止整个集群的工作负载。故障节点修复后,计算从上一个检查点重新开始【7,8】。因此,超级计算机更像是一个单节点计算机而不是分布式系统:通过让部分失败升级为完全失败来处理部分失败——如果系统的任何部分发生故障,只是让所有的东西都崩溃(就像单台机器上的内核恐慌一样)。
|
||||
不同的哲学会导致不同的故障处理方式。在超级计算机中,作业通常会不时地会将计算的状态存盘到持久存储中。如果一个节点出现故障,通常的解决方案是简单地停止整个集群的工作负载。故障节点修复后,计算从上一个检查点重新开始【7,8】。因此,超级计算机更像是一个单节点计算机而不是分布式系统:通过让部分失败升级为完全失败来处理部分失败 —— 如果系统的任何部分发生故障,只是让所有的东西都崩溃(就像单台机器上的内核恐慌一样)。
|
||||
|
||||
在本书中,我们将重点放在实现互联网服务的系统上,这些系统通常与超级计算机看起来有很大不同:
|
||||
|
||||
* 许多与互联网有关的应用程序都是**在线(online)** 的,因为它们需要能够随时以低延迟服务用户。使服务不可用(例如,停止集群以进行修复)是不可接受的。相比之下,像天气模拟这样的离线(批处理)工作可以停止并重新启动,影响相当小。
|
||||
* 许多与互联网有关的应用程序都是 **在线(online)** 的,因为它们需要能够随时以低延迟服务用户。使服务不可用(例如,停止集群以进行修复)是不可接受的。相比之下,像天气模拟这样的离线(批处理)工作可以停止并重新启动,影响相当小。
|
||||
|
||||
* 超级计算机通常由专用硬件构建而成,每个节点相当可靠,节点通过共享内存和**远程直接内存访问(RDMA)** 进行通信。另一方面,云服务中的节点是由商用机器构建而成的,由于规模经济,可以以较低的成本提供相同的性能,而且具有较高的故障率。
|
||||
* 超级计算机通常由专用硬件构建而成,每个节点相当可靠,节点通过共享内存和 **远程直接内存访问(RDMA)** 进行通信。另一方面,云服务中的节点是由商用机器构建而成的,由于规模经济,可以以较低的成本提供相同的性能,而且具有较高的故障率。
|
||||
|
||||
* 大型数据中心网络通常基于IP和以太网,以CLOS拓扑排列,以提供更高的对分(bisection)带宽【9】。超级计算机通常使用专门的网络拓扑结构,例如多维网格和Torus网络 【10】,这为具有已知通信模式的HPC工作负载提供了更好的性能。
|
||||
* 大型数据中心网络通常基于 IP 和以太网,以 CLOS 拓扑排列,以提供更高的对分(bisection)带宽【9】。超级计算机通常使用专门的网络拓扑结构,例如多维网格和 Torus 网络 【10】,这为具有已知通信模式的 HPC 工作负载提供了更好的性能。
|
||||
|
||||
* 系统越大,其组件之一就越有可能坏掉。随着时间的推移,坏掉的东西得到修复,新的东西又坏掉,但是在一个有成千上万个节点的系统中,有理由认为总是有一些东西是坏掉的【7】。当错误处理的策略只由简单放弃组成时,一个大的系统最终会花费大量时间从错误中恢复,而不是做有用的工作【8】。
|
||||
|
||||
* 如果系统可以容忍发生故障的节点,并继续保持整体工作状态,那么这对于运营和维护非常有用:例如,可以执行滚动升级(请参阅[第四章](ch4.md)),一次重新启动一个节点,同时继续给用户提供不中断的服务。在云环境中,如果一台虚拟机运行不佳,可以杀死它并请求一台新的虚拟机(希望新的虚拟机速度更快)。
|
||||
* 如果系统可以容忍发生故障的节点,并继续保持整体工作状态,那么这对于运营和维护非常有用:例如,可以执行滚动升级(请参阅 [第四章](ch4.md)),一次重新启动一个节点,同时继续给用户提供不中断的服务。在云环境中,如果一台虚拟机运行不佳,可以杀死它并请求一台新的虚拟机(希望新的虚拟机速度更快)。
|
||||
|
||||
* 在地理位置分散的部署中(保持数据在地理位置上接近用户以减少访问延迟),通信很可能通过互联网进行,与本地网络相比,通信速度缓慢且不可靠。超级计算机通常假设它们的所有节点都靠近在一起。
|
||||
|
||||
如果要使分布式系统工作,就必须接受部分故障的可能性,并在软件中建立容错机制。换句话说,我们需要从不可靠的组件构建一个可靠的系统(正如“[可靠性](ch1.md#可靠性)”中所讨论的那样,没有完美的可靠性,所以我们需要理解我们可以实际承诺的极限)。
|
||||
如果要使分布式系统工作,就必须接受部分故障的可能性,并在软件中建立容错机制。换句话说,我们需要从不可靠的组件构建一个可靠的系统(正如 “[可靠性](ch1.md#可靠性)” 中所讨论的那样,没有完美的可靠性,所以我们需要理解我们可以实际承诺的极限)。
|
||||
|
||||
即使在只有少数节点的小型系统中,考虑部分故障也是很重要的。在一个小系统中,很可能大部分组件在大部分时间都正常工作。然而,迟早会有一部分系统出现故障,软件必须以某种方式处理。故障处理必须是软件设计的一部分,并且作为软件的运维,你需要知道在发生故障的情况下,软件可能会表现出怎样的行为。
|
||||
|
||||
@ -79,74 +79,74 @@
|
||||
|
||||
> #### 从不可靠的组件构建可靠的系统
|
||||
>
|
||||
> 你可能想知道这是否有意义——直观地看来,系统只能像其最不可靠的组件(最薄弱的环节)一样可靠。事实并非如此:事实上,从不太可靠的潜在基础构建更可靠的系统是计算机领域的一个古老思想【11】。例如:
|
||||
> 你可能想知道这是否有意义 —— 直观地看来,系统只能像其最不可靠的组件(最薄弱的环节)一样可靠。事实并非如此:事实上,从不太可靠的潜在基础构建更可靠的系统是计算机领域的一个古老思想【11】。例如:
|
||||
>
|
||||
> * 纠错码允许数字数据在通信信道上准确传输,偶尔会出现一些错误,例如由于无线网络上的无线电干扰【12】。
|
||||
> * **互联网协议(Internet Protocol, IP)** 不可靠:可能丢弃、延迟、重复或重排数据包。 传输控制协议(Transmission Control Protocol, TCP)在互联网协议(IP)之上提供了更可靠的传输层:它确保丢失的数据包被重新传输,消除重复,并且数据包被重新组装成它们被发送的顺序。
|
||||
>
|
||||
> 虽然这个系统可以比它的底层部分更可靠,但它的可靠性总是有限的。例如,纠错码可以处理少量的单比特错误,但是如果你的信号被干扰所淹没,那么通过信道可以得到多少数据,是有根本性的限制的【13】。 TCP可以隐藏数据包的丢失,重复和重新排序,但是它不能神奇地消除网络中的延迟。
|
||||
> 虽然这个系统可以比它的底层部分更可靠,但它的可靠性总是有限的。例如,纠错码可以处理少量的单比特错误,但是如果你的信号被干扰所淹没,那么通过信道可以得到多少数据,是有根本性的限制的【13】。 TCP 可以隐藏数据包的丢失,重复和重新排序,但是它不能神奇地消除网络中的延迟。
|
||||
>
|
||||
> 虽然更可靠的高级系统并不完美,但它仍然有用,因为它处理了一些棘手的低级错误,所以其余的错误通常更容易推理和处理。我们将在“[数据库的端到端原则](ch12.md#数据库的端到端原则)”中进一步探讨这个问题。
|
||||
> 虽然更可靠的高级系统并不完美,但它仍然有用,因为它处理了一些棘手的低级错误,所以其余的错误通常更容易推理和处理。我们将在 “[数据库的端到端原则](ch12.md#数据库的端到端原则)” 中进一步探讨这个问题。
|
||||
|
||||
|
||||
## 不可靠的网络
|
||||
|
||||
正如在[第二部分](part-ii.md)的介绍中所讨论的那样,我们在本书中关注的分布式系统是无共享的系统,即通过网络连接的一堆机器。网络是这些机器可以通信的唯一途径——我们假设每台机器都有自己的内存和磁盘,一台机器不能访问另一台机器的内存或磁盘(除了通过网络向服务器发出请求)。
|
||||
正如在 [第二部分](part-ii.md) 的介绍中所讨论的那样,我们在本书中关注的分布式系统是无共享的系统,即通过网络连接的一堆机器。网络是这些机器可以通信的唯一途径 —— 我们假设每台机器都有自己的内存和磁盘,一台机器不能访问另一台机器的内存或磁盘(除了通过网络向服务器发出请求)。
|
||||
|
||||
**无共享**并不是构建系统的唯一方式,但它已经成为构建互联网服务的主要方式,其原因如下:相对便宜,因为它不需要特殊的硬件,可以利用商品化的云计算服务,通过跨多个地理分布的数据中心进行冗余可以实现高可靠性。
|
||||
**无共享** 并不是构建系统的唯一方式,但它已经成为构建互联网服务的主要方式,其原因如下:相对便宜,因为它不需要特殊的硬件,可以利用商品化的云计算服务,通过跨多个地理分布的数据中心进行冗余可以实现高可靠性。
|
||||
|
||||
互联网和数据中心(通常是以太网)中的大多数内部网络都是**异步分组网络(asynchronous packet networks)**。在这种网络中,一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。如果你发送请求并期待响应,则很多事情可能会出错(其中一些如[图8-1](img/fig8-1.png)所示):
|
||||
互联网和数据中心(通常是以太网)中的大多数内部网络都是 **异步分组网络(asynchronous packet networks)**。在这种网络中,一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。如果你发送请求并期待响应,则很多事情可能会出错(其中一些如 [图 8-1](img/fig8-1.png) 所示):
|
||||
|
||||
1. 请求可能已经丢失(可能有人拔掉了网线)。
|
||||
2. 请求可能正在排队,稍后将交付(也许网络或接收方过载)。
|
||||
3. 远程节点可能已经失效(可能是崩溃或关机)。
|
||||
4. 远程节点可能暂时停止了响应(可能会遇到长时间的垃圾回收暂停;请参阅“[进程暂停](#进程暂停)”),但稍后会再次响应。
|
||||
4. 远程节点可能暂时停止了响应(可能会遇到长时间的垃圾回收暂停;请参阅 “[进程暂停](#进程暂停)”),但稍后会再次响应。
|
||||
5. 远程节点可能已经处理了请求,但是网络上的响应已经丢失(可能是网络交换机配置错误)。
|
||||
6. 远程节点可能已经处理了请求,但是响应已经被延迟,并且稍后将被传递(可能是网络或者你自己的机器过载)。
|
||||
|
||||
![](img/fig8-1.png)
|
||||
|
||||
**图8-1 如果发送请求并没有得到响应,则无法区分(a)请求是否丢失,(b)远程节点是否关闭,或(c)响应是否丢失。**
|
||||
**图 8-1 如果发送请求并没有得到响应,则无法区分(a)请求是否丢失,(b)远程节点是否关闭,或(c)响应是否丢失。**
|
||||
|
||||
发送者甚至不能分辨数据包是否被发送:唯一的选择是让接收者发送响应消息,这可能会丢失或延迟。这些问题在异步网络中难以区分:你所拥有的唯一信息是,你尚未收到响应。如果你向另一个节点发送请求并且没有收到响应,则不可能判断是什么原因。
|
||||
|
||||
处理这个问题的通常方法是**超时(Timeout)**:在一段时间之后放弃等待,并且认为响应不会到达。但是,当发生超时时,你仍然不知道远程节点是否收到了请求(如果请求仍然在某个地方排队,那么即使发送者已经放弃了该请求,仍然可能会将其发送给接收者)。
|
||||
处理这个问题的通常方法是 **超时(Timeout)**:在一段时间之后放弃等待,并且认为响应不会到达。但是,当发生超时时,你仍然不知道远程节点是否收到了请求(如果请求仍然在某个地方排队,那么即使发送者已经放弃了该请求,仍然可能会将其发送给接收者)。
|
||||
|
||||
### 真实世界的网络故障
|
||||
|
||||
我们几十年来一直在建设计算机网络——有人可能希望现在我们已经找出了使网络变得可靠的方法。但是现在似乎还没有成功。
|
||||
我们几十年来一直在建设计算机网络 —— 有人可能希望现在我们已经找出了使网络变得可靠的方法。但是现在似乎还没有成功。
|
||||
|
||||
有一些系统的研究和大量的轶事证据表明,即使在像一家公司运营的数据中心那样的受控环境中,网络问题也可能出乎意料地普遍。在一家中型数据中心进行的一项研究发现,每个月大约有12个网络故障,其中一半断开一台机器,一半断开整个机架【15】。另一项研究测量了架顶式交换机,汇聚交换机和负载平衡器等组件的故障率【16】。它发现添加冗余网络设备不会像你所希望的那样减少故障,因为它不能防范人为错误(例如,错误配置的交换机),这是造成中断的主要原因。
|
||||
有一些系统的研究和大量的轶事证据表明,即使在像一家公司运营的数据中心那样的受控环境中,网络问题也可能出乎意料地普遍。在一家中型数据中心进行的一项研究发现,每个月大约有 12 个网络故障,其中一半断开一台机器,一半断开整个机架【15】。另一项研究测量了架顶式交换机,汇聚交换机和负载平衡器等组件的故障率【16】。它发现添加冗余网络设备不会像你所希望的那样减少故障,因为它不能防范人为错误(例如,错误配置的交换机),这是造成中断的主要原因。
|
||||
|
||||
诸如EC2之类的公有云服务因频繁的暂态网络故障而臭名昭着【14】,管理良好的私有数据中心网络可能是更稳定的环境。尽管如此,没有人不受网络问题的困扰:例如,交换机软件升级过程中的一个问题可能会引发网络拓扑重构,在此期间网络数据包可能会延迟超过一分钟【17】。鲨鱼可能咬住海底电缆并损坏它们 【18】。其他令人惊讶的故障包括网络接口有时会丢弃所有入站数据包,但是成功发送出站数据包 【19】:仅仅因为网络链接在一个方向上工作,并不能保证它也在相反的方向工作。
|
||||
诸如 EC2 之类的公有云服务因频繁的暂态网络故障而臭名昭着【14】,管理良好的私有数据中心网络可能是更稳定的环境。尽管如此,没有人不受网络问题的困扰:例如,交换机软件升级过程中的一个问题可能会引发网络拓扑重构,在此期间网络数据包可能会延迟超过一分钟【17】。鲨鱼可能咬住海底电缆并损坏它们 【18】。其他令人惊讶的故障包括网络接口有时会丢弃所有入站数据包,但是成功发送出站数据包 【19】:仅仅因为网络链接在一个方向上工作,并不能保证它也在相反的方向工作。
|
||||
|
||||
> #### 网络分区
|
||||
>
|
||||
> 当网络的一部分由于网络故障而被切断时,有时称为**网络分区(network partition)** 或**网络断裂(netsplit)**。在本书中,我们通常会坚持使用更一般的术语**网络故障(network fault)**,以避免与[第六章](ch6.md)讨论的存储系统的分区(分片)相混淆。
|
||||
> 当网络的一部分由于网络故障而被切断时,有时称为 **网络分区(network partition)** 或 **网络断裂(netsplit)**。在本书中,我们通常会坚持使用更一般的术语 **网络故障(network fault)**,以避免与 [第六章](ch6.md) 讨论的存储系统的分区(分片)相混淆。
|
||||
|
||||
即使网络故障在你的环境中非常罕见,故障可能发生的事实,意味着你的软件需要能够处理它们。无论何时通过网络进行通信,都可能会失败,这是无法避免的。
|
||||
|
||||
如果网络故障的错误处理没有定义与测试,武断地讲,各种错误可能都会发生:例如,即使网络恢复【20】,集群可能会发生**死锁**,永久无法为请求提供服务,甚至可能会删除所有的数据【21】。如果软件被置于意料之外的情况下,它可能会做出出乎意料的事情。
|
||||
如果网络故障的错误处理没有定义与测试,武断地讲,各种错误可能都会发生:例如,即使网络恢复【20】,集群可能会发生 **死锁**,永久无法为请求提供服务,甚至可能会删除所有的数据【21】。如果软件被置于意料之外的情况下,它可能会做出出乎意料的事情。
|
||||
|
||||
处理网络故障并不意味着容忍它们:如果你的网络通常是相当可靠的,一个有效的方法可能是当你的网络遇到问题时,简单地向用户显示一条错误信息。但是,你确实需要知道你的软件如何应对网络问题,并确保系统能够从中恢复。有意识地触发网络问题并测试系统响应(这是Chaos Monkey背后的想法;请参阅“[可靠性](ch1.md#可靠性)”)。
|
||||
处理网络故障并不意味着容忍它们:如果你的网络通常是相当可靠的,一个有效的方法可能是当你的网络遇到问题时,简单地向用户显示一条错误信息。但是,你确实需要知道你的软件如何应对网络问题,并确保系统能够从中恢复。有意识地触发网络问题并测试系统响应(这是 Chaos Monkey 背后的想法;请参阅 “[可靠性](ch1.md#可靠性)”)。
|
||||
|
||||
### 检测故障
|
||||
|
||||
许多系统需要自动检测故障节点。例如:
|
||||
|
||||
* 负载平衡器需要停止向已死亡的节点转发请求(从轮询列表移出,即out of rotation)。
|
||||
* 在单主复制功能的分布式数据库中,如果主库失效,则需要将从库之一升级为新主库(请参阅“[处理节点宕机](ch5.md#处理节点宕机)”)。
|
||||
* 负载平衡器需要停止向已死亡的节点转发请求(从轮询列表移出,即 out of rotation)。
|
||||
* 在单主复制功能的分布式数据库中,如果主库失效,则需要将从库之一升级为新主库(请参阅 “[处理节点宕机](ch5.md#处理节点宕机)”)。
|
||||
|
||||
不幸的是,网络的不确定性使得很难判断一个节点是否工作。在某些特定的情况下,你可能会收到一些反馈信息,明确告诉你某些事情没有成功:
|
||||
|
||||
* 如果你可以连接到运行节点的机器,但没有进程正在侦听目标端口(例如,因为进程崩溃),操作系统将通过发送FIN或RST来关闭并重用TCP连接。但是,如果节点在处理请求时发生崩溃,则无法知道远程节点实际处理了多少数据【22】。
|
||||
* 如果节点进程崩溃(或被管理员杀死),但节点的操作系统仍在运行,则脚本可以通知其他节点有关该崩溃的信息,以便另一个节点可以快速接管,而无需等待超时到期。例如,HBase就是这么做的【23】。
|
||||
* 如果你可以连接到运行节点的机器,但没有进程正在侦听目标端口(例如,因为进程崩溃),操作系统将通过发送 FIN 或 RST 来关闭并重用 TCP 连接。但是,如果节点在处理请求时发生崩溃,则无法知道远程节点实际处理了多少数据【22】。
|
||||
* 如果节点进程崩溃(或被管理员杀死),但节点的操作系统仍在运行,则脚本可以通知其他节点有关该崩溃的信息,以便另一个节点可以快速接管,而无需等待超时到期。例如,HBase 就是这么做的【23】。
|
||||
* 如果你有权访问数据中心网络交换机的管理界面,则可以通过它们检测硬件级别的链路故障(例如,远程机器是否关闭电源)。如果你通过互联网连接,或者如果你处于共享数据中心而无法访问交换机,或者由于网络问题而无法访问管理界面,则排除此选项。
|
||||
* 如果路由器确认你尝试连接的IP地址不可用,则可能会使用ICMP目标不可达数据包回复你。但是,路由器不具备神奇的故障检测能力——它受到与网络其他参与者相同的限制。
|
||||
* 如果路由器确认你尝试连接的 IP 地址不可用,则可能会使用 ICMP 目标不可达数据包回复你。但是,路由器不具备神奇的故障检测能力 —— 它受到与网络其他参与者相同的限制。
|
||||
|
||||
关于远程节点关闭的快速反馈很有用,但是你不能指望它。即使TCP确认已经传送了一个数据包,应用程序在处理之前可能已经崩溃。如果你想确保一个请求是成功的,你需要应用程序本身的正确响应【24】。
|
||||
关于远程节点关闭的快速反馈很有用,但是你不能指望它。即使 TCP 确认已经传送了一个数据包,应用程序在处理之前可能已经崩溃。如果你想确保一个请求是成功的,你需要应用程序本身的正确响应【24】。
|
||||
|
||||
相反,如果出了什么问题,你可能会在堆栈的某个层次上得到一个错误响应,但总的来说,你必须假设你可能根本就得不到任何回应。你可以重试几次(TCP重试是透明的,但是你也可以在应用程序级别重试),等待超时过期,并且如果在超时时间内没有收到响应,则最终声明节点已经死亡。
|
||||
相反,如果出了什么问题,你可能会在堆栈的某个层次上得到一个错误响应,但总的来说,你必须假设你可能根本就得不到任何回应。你可以重试几次(TCP 重试是透明的,但是你也可以在应用程序级别重试),等待超时过期,并且如果在超时时间内没有收到响应,则最终声明节点已经死亡。
|
||||
|
||||
### 超时与无穷的延迟
|
||||
|
||||
@ -154,87 +154,87 @@
|
||||
|
||||
长时间的超时意味着长时间等待,直到一个节点被宣告死亡(在这段时间内,用户可能不得不等待,或者看到错误信息)。短的超时可以更快地检测到故障,但有更高地风险误将一个节点宣布为失效,而该节点实际上只是暂时地变慢了(例如由于节点或网络上的负载峰值)。
|
||||
|
||||
过早地声明一个节点已经死了是有问题的:如果这个节点实际上是活着的,并且正在执行一些动作(例如,发送一封电子邮件),而另一个节点接管,那么这个动作可能会最终执行两次。我们将在“[知识、真相与谎言](#知识、真相与谎言)”以及[第九章](ch9.md)和[第十一章](ch11.md)中更详细地讨论这个问题。
|
||||
过早地声明一个节点已经死了是有问题的:如果这个节点实际上是活着的,并且正在执行一些动作(例如,发送一封电子邮件),而另一个节点接管,那么这个动作可能会最终执行两次。我们将在 “[知识、真相与谎言](#知识、真相与谎言)” 以及 [第九章](ch9.md) 和 [第十一章](ch11.md) 中更详细地讨论这个问题。
|
||||
|
||||
当一个节点被宣告死亡时,它的职责需要转移到其他节点,这会给其他节点和网络带来额外的负担。如果系统已经处于高负荷状态,则过早宣告节点死亡会使问题更严重。特别是如果节点实际上没有死亡,只是由于过载导致其响应缓慢;这时将其负载转移到其他节点可能会导致**级联失效**(即cascading failure,表示在极端情况下,所有节点都宣告对方死亡,所有节点都将停止工作)。
|
||||
当一个节点被宣告死亡时,它的职责需要转移到其他节点,这会给其他节点和网络带来额外的负担。如果系统已经处于高负荷状态,则过早宣告节点死亡会使问题更严重。特别是如果节点实际上没有死亡,只是由于过载导致其响应缓慢;这时将其负载转移到其他节点可能会导致 **级联失效**(即 cascading failure,表示在极端情况下,所有节点都宣告对方死亡,所有节点都将停止工作)。
|
||||
|
||||
设想一个虚构的系统,其网络可以保证数据包的最大延迟——每个数据包要么在一段时间内传送,要么丢失,但是传递永远不会比$d$更长。此外,假设你可以保证一个非故障节点总是在一段时间内处理一个请求$r$。在这种情况下,你可以保证每个成功的请求在$2d + r$时间内都能收到响应,如果你在此时间内没有收到响应,则知道网络或远程节点不工作。如果这是成立的,$2d + r$ 会是一个合理的超时设置。
|
||||
设想一个虚构的系统,其网络可以保证数据包的最大延迟 —— 每个数据包要么在一段时间内传送,要么丢失,但是传递永远不会比 $d$ 更长。此外,假设你可以保证一个非故障节点总是在一段时间内处理一个请求 $r$。在这种情况下,你可以保证每个成功的请求在 $2d + r$ 时间内都能收到响应,如果你在此时间内没有收到响应,则知道网络或远程节点不工作。如果这是成立的,$2d + r$ 会是一个合理的超时设置。
|
||||
|
||||
不幸的是,我们所使用的大多数系统都没有这些保证:异步网络具有无限的延迟(即尽可能快地传送数据包,但数据包到达可能需要的时间没有上限),并且大多数服务器实现并不能保证它们可以在一定的最大时间内处理请求(请参阅“[响应时间保证](#响应时间保证)”)。对于故障检测,即使系统大部分时间快速运行也是不够的:如果你的超时时间很短,往返时间只需要一个瞬时尖峰就可以使系统失衡。
|
||||
不幸的是,我们所使用的大多数系统都没有这些保证:异步网络具有无限的延迟(即尽可能快地传送数据包,但数据包到达可能需要的时间没有上限),并且大多数服务器实现并不能保证它们可以在一定的最大时间内处理请求(请参阅 “[响应时间保证](#响应时间保证)”)。对于故障检测,即使系统大部分时间快速运行也是不够的:如果你的超时时间很短,往返时间只需要一个瞬时尖峰就可以使系统失衡。
|
||||
|
||||
#### 网络拥塞和排队
|
||||
|
||||
在驾驶汽车时,由于交通拥堵,道路交通网络的通行时间往往不尽相同。同样,计算机网络上数据包延迟的可变性通常是由于排队【25】:
|
||||
|
||||
* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如[图8-2](img/fig8-2.png)所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络拥塞)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。
|
||||
* 当数据包到达目标机器时,如果所有CPU内核当前都处于繁忙状态,则来自网络的传入请求将被操作系统排队,直到应用程序准备好处理它为止。根据机器上的负载,这可能需要一段任意的时间。
|
||||
* 在虚拟化环境中,正在运行的操作系统经常暂停几十毫秒,因为另一个虚拟机正在使用CPU内核。在这段时间内,虚拟机不能从网络中消耗任何数据,所以传入的数据被虚拟机监视器 【26】排队(缓冲),进一步增加了网络延迟的可变性。
|
||||
* TCP执行**流量控制**(flow control,也称为**拥塞避免**,即congestion avoidance,或**背压**,即backpressure),其中节点会限制自己的发送速率以避免网络链路或接收节点过载【27】。这意味着甚至在数据进入网络之前,在发送者处就需要进行额外的排队。
|
||||
* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如 [图 8-2](img/fig8-2.png) 所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络拥塞)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。
|
||||
* 当数据包到达目标机器时,如果所有 CPU 内核当前都处于繁忙状态,则来自网络的传入请求将被操作系统排队,直到应用程序准备好处理它为止。根据机器上的负载,这可能需要一段任意的时间。
|
||||
* 在虚拟化环境中,正在运行的操作系统经常暂停几十毫秒,因为另一个虚拟机正在使用 CPU 内核。在这段时间内,虚拟机不能从网络中消耗任何数据,所以传入的数据被虚拟机监视器 【26】排队(缓冲),进一步增加了网络延迟的可变性。
|
||||
* TCP 执行 **流量控制**(flow control,也称为 **拥塞避免**,即 congestion avoidance,或 **背压**,即 backpressure),其中节点会限制自己的发送速率以避免网络链路或接收节点过载【27】。这意味着甚至在数据进入网络之前,在发送者处就需要进行额外的排队。
|
||||
|
||||
![](img/fig8-2.png)
|
||||
|
||||
**图8-2 如果有多台机器将网络流量发送到同一目的地,则其交换机队列可能会被填满。在这里,端口1,2和4都试图发送数据包到端口3**
|
||||
**图 8-2 如果有多台机器将网络流量发送到同一目的地,则其交换机队列可能会被填满。在这里,端口 1,2 和 4 都试图发送数据包到端口 3**
|
||||
|
||||
而且,如果TCP在某个超时时间内没有被确认(这是根据观察的往返时间计算的),则认为数据包丢失,丢失的数据包将自动重新发送。尽管应用程序没有看到数据包丢失和重新传输,但它看到了延迟(等待超时到期,然后等待重新传输的数据包得到确认)。
|
||||
而且,如果 TCP 在某个超时时间内没有被确认(这是根据观察的往返时间计算的),则认为数据包丢失,丢失的数据包将自动重新发送。尽管应用程序没有看到数据包丢失和重新传输,但它看到了延迟(等待超时到期,然后等待重新传输的数据包得到确认)。
|
||||
|
||||
|
||||
> #### TCP与UDP
|
||||
>
|
||||
> 一些对延迟敏感的应用程序,比如视频会议和IP语音(VoIP),使用了UDP而不是TCP。这是在可靠性和和延迟变化之间的折衷:由于UDP不执行流量控制并且不重传丢失的分组,所以避免了网络延迟变化的一些原因(尽管它仍然易受切换队列和调度延迟的影响)。
|
||||
> 一些对延迟敏感的应用程序,比如视频会议和 IP 语音(VoIP),使用了 UDP 而不是 TCP。这是在可靠性和和延迟变化之间的折衷:由于 UDP 不执行流量控制并且不重传丢失的分组,所以避免了网络延迟变化的一些原因(尽管它仍然易受切换队列和调度延迟的影响)。
|
||||
>
|
||||
> 在延迟数据毫无价值的情况下,UDP是一个不错的选择。例如,在VoIP电话呼叫中,可能没有足够的时间重新发送丢失的数据包,并在扬声器上播放数据。在这种情况下,重发数据包没有意义——应用程序必须使用静音填充丢失数据包的时隙(导致声音短暂中断),然后在数据流中继续。重试发生在人类层。 (“你能再说一遍吗?声音刚刚断了一会儿。“)
|
||||
> 在延迟数据毫无价值的情况下,UDP 是一个不错的选择。例如,在 VoIP 电话呼叫中,可能没有足够的时间重新发送丢失的数据包,并在扬声器上播放数据。在这种情况下,重发数据包没有意义 —— 应用程序必须使用静音填充丢失数据包的时隙(导致声音短暂中断),然后在数据流中继续。重试发生在人类层。 (“你能再说一遍吗?声音刚刚断了一会儿。“)
|
||||
|
||||
所有这些因素都会造成网络延迟的变化。当系统接近其最大容量时,排队延迟的变化范围特别大:拥有足够备用容量的系统可以轻松排空队列,而在高利用率的系统中,很快就能积累很长的队列。
|
||||
|
||||
在公共云和多租户数据中心中,资源被许多客户共享:网络链接和交换机,甚至每个机器的网卡和CPU(在虚拟机上运行时)。批处理工作负载(如MapReduce,请参阅[第十章](ch10.md))能够很容易使网络链接饱和。由于无法控制或了解其他客户对共享资源的使用情况,如果附近的某个人(嘈杂的邻居)正在使用大量资源,则网络延迟可能会发生剧烈变化【28,29】。
|
||||
在公共云和多租户数据中心中,资源被许多客户共享:网络链接和交换机,甚至每个机器的网卡和 CPU(在虚拟机上运行时)。批处理工作负载(如 MapReduce,请参阅 [第十章](ch10.md))能够很容易使网络链接饱和。由于无法控制或了解其他客户对共享资源的使用情况,如果附近的某个人(嘈杂的邻居)正在使用大量资源,则网络延迟可能会发生剧烈变化【28,29】。
|
||||
|
||||
在这种环境下,你只能通过实验方式选择超时:在一段较长的时期内、在多台机器上测量网络往返时间的分布,以确定延迟的预期变化。然后,考虑到应用程序的特性,可以确定**故障检测延迟**与**过早超时风险**之间的适当折衷。
|
||||
在这种环境下,你只能通过实验方式选择超时:在一段较长的时期内、在多台机器上测量网络往返时间的分布,以确定延迟的预期变化。然后,考虑到应用程序的特性,可以确定 **故障检测延迟** 与 **过早超时风险** 之间的适当折衷。
|
||||
|
||||
更好的一种做法是,系统不是使用配置的常量超时时间,而是连续测量响应时间及其变化(抖动),并根据观察到的响应时间分布自动调整超时时间。这可以通过Phi Accrual故障检测器【30】来完成,该检测器在例如Akka和Cassandra 【31】中使用。 TCP的超时重传机制也是以类似的方式工作【27】。
|
||||
更好的一种做法是,系统不是使用配置的常量超时时间,而是连续测量响应时间及其变化(抖动),并根据观察到的响应时间分布自动调整超时时间。这可以通过 Phi Accrual 故障检测器【30】来完成,该检测器在例如 Akka 和 Cassandra 【31】中使用。 TCP 的超时重传机制也是以类似的方式工作【27】。
|
||||
|
||||
### 同步网络与异步网络
|
||||
|
||||
如果我们可以依靠网络来传递一些**最大延迟固定**的数据包,而不是丢弃数据包,那么分布式系统就会简单得多。为什么我们不能在硬件层面上解决这个问题,使网络可靠,使软件不必担心呢?
|
||||
如果我们可以依靠网络来传递一些 **最大延迟固定** 的数据包,而不是丢弃数据包,那么分布式系统就会简单得多。为什么我们不能在硬件层面上解决这个问题,使网络可靠,使软件不必担心呢?
|
||||
|
||||
为了回答这个问题,将数据中心网络与非常可靠的传统固定电话网络(非蜂窝,非VoIP)进行比较是很有趣的:延迟音频帧和掉话是非常罕见的。一个电话需要一个很低的端到端延迟,以及足够的带宽来传输你声音的音频采样数据。在计算机网络中有类似的可靠性和可预测性不是很好吗?
|
||||
为了回答这个问题,将数据中心网络与非常可靠的传统固定电话网络(非蜂窝,非 VoIP)进行比较是很有趣的:延迟音频帧和掉话是非常罕见的。一个电话需要一个很低的端到端延迟,以及足够的带宽来传输你声音的音频采样数据。在计算机网络中有类似的可靠性和可预测性不是很好吗?
|
||||
|
||||
当你通过电话网络拨打电话时,它会建立一个电路:在两个呼叫者之间的整个路线上为呼叫分配一个固定的,有保证的带宽量。这个电路会保持至通话结束【32】。例如,ISDN网络以每秒4000帧的固定速率运行。呼叫建立时,每个帧内(每个方向)分配16位空间。因此,在通话期间,每一方都保证能够每250微秒发送一个精确的16位音频数据【33,34】。
|
||||
当你通过电话网络拨打电话时,它会建立一个电路:在两个呼叫者之间的整个路线上为呼叫分配一个固定的,有保证的带宽量。这个电路会保持至通话结束【32】。例如,ISDN 网络以每秒 4000 帧的固定速率运行。呼叫建立时,每个帧内(每个方向)分配 16 位空间。因此,在通话期间,每一方都保证能够每 250 微秒发送一个精确的 16 位音频数据【33,34】。
|
||||
|
||||
这种网络是同步的:即使数据经过多个路由器,也不会受到排队的影响,因为呼叫的16位空间已经在网络的下一跳中保留了下来。而且由于没有排队,网络的最大端到端延迟是固定的。我们称之为**有限延迟(bounded delay)**。
|
||||
这种网络是同步的:即使数据经过多个路由器,也不会受到排队的影响,因为呼叫的 16 位空间已经在网络的下一跳中保留了下来。而且由于没有排队,网络的最大端到端延迟是固定的。我们称之为 **有限延迟(bounded delay)**。
|
||||
|
||||
#### 我们不能简单地使网络延迟可预测吗?
|
||||
|
||||
请注意,电话网络中的电路与TCP连接有很大不同:电路是固定数量的预留带宽,在电路建立时没有其他人可以使用,而TCP连接的数据包**机会性地**使用任何可用的网络带宽。你可以给TCP一个可变大小的数据块(例如,一个电子邮件或一个网页),它会尽可能在最短的时间内传输它。 TCP连接空闲时,不使用任何带宽[^ii]。
|
||||
请注意,电话网络中的电路与 TCP 连接有很大不同:电路是固定数量的预留带宽,在电路建立时没有其他人可以使用,而 TCP 连接的数据包 **机会性地** 使用任何可用的网络带宽。你可以给 TCP 一个可变大小的数据块(例如,一个电子邮件或一个网页),它会尽可能在最短的时间内传输它。 TCP 连接空闲时,不使用任何带宽 [^ii]。
|
||||
|
||||
[^ii]: 除了偶尔的keepalive数据包,如果TCP keepalive被启用。
|
||||
[^ii]: 除了偶尔的 keepalive 数据包,如果 TCP keepalive 被启用。
|
||||
|
||||
如果数据中心网络和互联网是电路交换网络,那么在建立电路时就可以建立一个受保证的最大往返时间。但是,它们并不是:以太网和IP是**分组交换协议**,不得不忍受排队的折磨,及其导致的网络无限延迟。这些协议没有电路的概念。
|
||||
如果数据中心网络和互联网是电路交换网络,那么在建立电路时就可以建立一个受保证的最大往返时间。但是,它们并不是:以太网和 IP 是 **分组交换协议**,不得不忍受排队的折磨,及其导致的网络无限延迟。这些协议没有电路的概念。
|
||||
|
||||
为什么数据中心网络和互联网使用分组交换?答案是,它们针对**突发流量(bursty traffic)** 进行了优化。一个电路适用于音频或视频通话,在通话期间需要每秒传送相当数量的比特。另一方面,请求网页,发送电子邮件或传输文件没有任何特定的带宽要求——我们只是希望它尽快完成。
|
||||
为什么数据中心网络和互联网使用分组交换?答案是,它们针对 **突发流量(bursty traffic)** 进行了优化。一个电路适用于音频或视频通话,在通话期间需要每秒传送相当数量的比特。另一方面,请求网页,发送电子邮件或传输文件没有任何特定的带宽要求 —— 我们只是希望它尽快完成。
|
||||
|
||||
如果想通过电路传输文件,你得预测一个带宽分配。如果你猜的太低,传输速度会不必要的太慢,导致网络容量闲置。如果你猜的太高,电路就无法建立(因为如果无法保证其带宽分配,网络不能建立电路)。因此,将电路用于突发数据传输会浪费网络容量,并且使传输不必要地缓慢。相比之下,TCP动态调整数据传输速率以适应可用的网络容量。
|
||||
如果想通过电路传输文件,你得预测一个带宽分配。如果你猜的太低,传输速度会不必要的太慢,导致网络容量闲置。如果你猜的太高,电路就无法建立(因为如果无法保证其带宽分配,网络不能建立电路)。因此,将电路用于突发数据传输会浪费网络容量,并且使传输不必要地缓慢。相比之下,TCP 动态调整数据传输速率以适应可用的网络容量。
|
||||
|
||||
已经有一些尝试去建立同时支持电路交换和分组交换的混合网络,比如ATM[^iii]。InfiniBand有一些相似之处【35】:它在链路层实现了端到端的流量控制,从而减少了在网络中排队的需要,尽管它仍然可能因链路拥塞而受到延迟【36】。通过仔细使用**服务质量**(quality of service,即QoS,数据包的优先级和调度)和**准入控制**(admission control,限速发送器),可以在分组网络上模拟电路交换,或提供统计上的**有限延迟**【25,32】。
|
||||
已经有一些尝试去建立同时支持电路交换和分组交换的混合网络,比如 ATM [^iii]。InfiniBand 有一些相似之处【35】:它在链路层实现了端到端的流量控制,从而减少了在网络中排队的需要,尽管它仍然可能因链路拥塞而受到延迟【36】。通过仔细使用 **服务质量**(quality of service,即 QoS,数据包的优先级和调度)和 **准入控制**(admission control,限速发送器),可以在分组网络上模拟电路交换,或提供统计上的 **有限延迟**【25,32】。
|
||||
|
||||
[^iii]: **异步传输模式(Asynchronous Transfer Mode, ATM)** 在20世纪80年代是以太网的竞争对手【32】,但在电话网核心交换机之外并没有得到太多的采用。它与自动柜员机(也称为自动取款机)无关,尽管共用一个缩写词。或许,在一些平行的世界里,互联网是基于像ATM这样的东西,因此它们的互联网视频通话可能比我们的更可靠,因为它们不会遭受包的丢失和延迟。
|
||||
[^iii]: **异步传输模式(Asynchronous Transfer Mode, ATM)** 在 20 世纪 80 年代是以太网的竞争对手【32】,但在电话网核心交换机之外并没有得到太多的采用。它与自动柜员机(也称为自动取款机)无关,尽管共用一个缩写词。或许,在一些平行的世界里,互联网是基于像 ATM 这样的东西,因此它们的互联网视频通话可能比我们的更可靠,因为它们不会遭受包的丢失和延迟。
|
||||
|
||||
但是,目前在多租户数据中心和公共云或通过互联网[^iv]进行通信时,此类服务质量尚未启用。当前部署的技术不允许我们对网络的延迟或可靠性作出任何保证:我们必须假设网络拥塞,排队和无限的延迟总是会发生。因此,超时时间没有“正确”的值——它需要通过实验来确定。
|
||||
但是,目前在多租户数据中心和公共云或通过互联网 [^iv] 进行通信时,此类服务质量尚未启用。当前部署的技术不允许我们对网络的延迟或可靠性作出任何保证:我们必须假设网络拥塞,排队和无限的延迟总是会发生。因此,超时时间没有 “正确” 的值 —— 它需要通过实验来确定。
|
||||
|
||||
[^iv]: 互联网服务提供商之间的对等协议和通过**BGP网关协议(BGP)** 建立的路由,与IP协议相比,更接近于电路交换。在这个级别上,可以购买专用带宽。但是,互联网路由在网络级别运行,而不是主机之间的单独连接,而且运行时间要长得多。
|
||||
[^iv]: 互联网服务提供商之间的对等协议和通过 **BGP 网关协议(BGP)** 建立的路由,与 IP 协议相比,更接近于电路交换。在这个级别上,可以购买专用带宽。但是,互联网路由在网络级别运行,而不是主机之间的单独连接,而且运行时间要长得多。
|
||||
|
||||
> ### 延迟和资源利用
|
||||
>
|
||||
> 更一般地说,可以将**延迟变化**视为**动态资源分区**的结果。
|
||||
> 更一般地说,可以将 **延迟变化** 视为 **动态资源分区** 的结果。
|
||||
>
|
||||
> 假设两台电话交换机之间有一条线路,可以同时进行10,000个呼叫。通过此线路切换的每个电路都占用其中一个呼叫插槽。因此,你可以将线路视为可由多达10,000个并发用户共享的资源。资源以静态方式分配:即使你现在是电话上唯一的电话,并且所有其他9,999个插槽都未使用,你的电路仍将分配与导线充分利用时相同的固定数量的带宽。
|
||||
> 假设两台电话交换机之间有一条线路,可以同时进行 10,000 个呼叫。通过此线路切换的每个电路都占用其中一个呼叫插槽。因此,你可以将线路视为可由多达 10,000 个并发用户共享的资源。资源以静态方式分配:即使你现在是电话上唯一的电话,并且所有其他 9,999 个插槽都未使用,你的电路仍将分配与导线充分利用时相同的固定数量的带宽。
|
||||
>
|
||||
> 相比之下,互联网动态分享网络带宽。发送者互相推挤和争夺,以让他们的数据包尽可能快地通过网络,并且网络交换机决定从一个时刻到另一个时刻发送哪个分组(即,带宽分配)。这种方法有排队的缺点,但其优点是它最大限度地利用了电线。电线固定成本,所以如果你更好地利用它,你通过电线发送的每个字节都会更便宜。
|
||||
>
|
||||
> CPU也会出现类似的情况:如果你在多个线程间动态共享每个CPU内核,则一个线程有时必须在操作系统的运行队列里等待,而另一个线程正在运行,这样每个线程都有可能被暂停一个不定的时间长度。但是,与为每个线程分配静态数量的CPU周期相比,这会更好地利用硬件(请参阅“[响应时间保证](#响应时间保证)”)。更好的硬件利用率也是使用虚拟机的重要动机。
|
||||
> CPU 也会出现类似的情况:如果你在多个线程间动态共享每个 CPU 内核,则一个线程有时必须在操作系统的运行队列里等待,而另一个线程正在运行,这样每个线程都有可能被暂停一个不定的时间长度。但是,与为每个线程分配静态数量的 CPU 周期相比,这会更好地利用硬件(请参阅 “[响应时间保证](#响应时间保证)”)。更好的硬件利用率也是使用虚拟机的重要动机。
|
||||
>
|
||||
> 如果资源是静态分区的(例如,专用硬件和专用带宽分配),则在某些环境中可以实现**延迟保证**。但是,这是以降低利用率为代价的——换句话说,它是更昂贵的。另一方面,动态资源分配的多租户提供了更好的利用率,所以它更便宜,但它具有可变延迟的缺点。
|
||||
> 如果资源是静态分区的(例如,专用硬件和专用带宽分配),则在某些环境中可以实现 **延迟保证**。但是,这是以降低利用率为代价的 —— 换句话说,它是更昂贵的。另一方面,动态资源分配的多租户提供了更好的利用率,所以它更便宜,但它具有可变延迟的缺点。
|
||||
>
|
||||
> 网络中的可变延迟不是一种自然规律,而只是成本/收益权衡的结果。
|
||||
> 网络中的可变延迟不是一种自然规律,而只是成本 / 收益权衡的结果。
|
||||
|
||||
|
||||
## 不可靠的时钟
|
||||
@ -242,7 +242,7 @@
|
||||
时钟和时间很重要。应用程序以各种方式依赖于时钟来回答以下问题:
|
||||
|
||||
1. 这个请求是否超时了?
|
||||
2. 这项服务的第99百分位响应时间是多少?
|
||||
2. 这项服务的第 99 百分位响应时间是多少?
|
||||
3. 在过去五分钟内,该服务平均每秒处理多少个查询?
|
||||
4. 用户在我们的网站上花了多长时间?
|
||||
5. 这篇文章在何时发布?
|
||||
@ -250,11 +250,11 @@
|
||||
7. 这个缓存条目何时到期?
|
||||
8. 日志文件中此错误消息的时间戳是什么?
|
||||
|
||||
[例1-4](ch1.md)测量了**持续时间**(durations,例如,请求发送与响应接收之间的时间间隔),而[例5-8](ch5.md)描述了**时间点**(point in time,在特定日期和和特定时间发生的事件)。
|
||||
[例 1-4](ch1.md) 测量了 **持续时间**(durations,例如,请求发送与响应接收之间的时间间隔),而 [例 5-8](ch5.md) 描述了 **时间点**(point in time,在特定日期和和特定时间发生的事件)。
|
||||
|
||||
在分布式系统中,时间是一件棘手的事情,因为通信不是即时的:消息通过网络从一台机器传送到另一台机器需要时间。收到消息的时间总是晚于发送的时间,但是由于网络中的可变延迟,我们不知道晚了多少时间。这个事实导致有时很难确定在涉及多台机器时发生事情的顺序。
|
||||
|
||||
而且,网络上的每台机器都有自己的时钟,这是一个实际的硬件设备:通常是石英晶体振荡器。这些设备不是完全准确的,所以每台机器都有自己的时间概念,可能比其他机器稍快或更慢。可以在一定程度上同步时钟:最常用的机制是**网络时间协议(NTP)**,它允许根据一组服务器报告的时间来调整计算机时钟【37】。服务器则从更精确的时间源(如GPS接收机)获取时间。
|
||||
而且,网络上的每台机器都有自己的时钟,这是一个实际的硬件设备:通常是石英晶体振荡器。这些设备不是完全准确的,所以每台机器都有自己的时间概念,可能比其他机器稍快或更慢。可以在一定程度上同步时钟:最常用的机制是 **网络时间协议(NTP)**,它允许根据一组服务器报告的时间来调整计算机时钟【37】。服务器则从更精确的时间源(如 GPS 接收机)获取时间。
|
||||
|
||||
### 单调钟与日历时钟
|
||||
|
||||
@ -262,50 +262,50 @@
|
||||
|
||||
#### 日历时钟
|
||||
|
||||
日历时钟是你直观地了解时钟的依据:它根据某个日历(也称为**挂钟时间**,即wall-clock time)返回当前日期和时间。例如,Linux上的`clock_gettime(CLOCK_REALTIME)`[^v]和Java中的`System.currentTimeMillis()`返回自epoch(UTC时间1970年1月1日午夜)以来的秒数(或毫秒),根据公历(Gregorian)日历,不包括闰秒。有些系统使用其他日期作为参考点。
|
||||
日历时钟是你直观地了解时钟的依据:它根据某个日历(也称为 **挂钟时间**,即 wall-clock time)返回当前日期和时间。例如,Linux 上的 `clock_gettime(CLOCK_REALTIME)`[^v] 和 Java 中的 `System.currentTimeMillis()` 返回自 epoch(UTC 时间 1970 年 1 月 1 日午夜)以来的秒数(或毫秒),根据公历(Gregorian)日历,不包括闰秒。有些系统使用其他日期作为参考点。
|
||||
|
||||
[^v]: 虽然该时钟被称为实时时钟,但它与实时操作系统无关,如“[响应时间保证](#响应时间保证)”中所述。
|
||||
[^v]: 虽然该时钟被称为实时时钟,但它与实时操作系统无关,如 “[响应时间保证](#响应时间保证)” 中所述。
|
||||
|
||||
日历时钟通常与NTP同步,这意味着来自一台机器的时间戳(理想情况下)与另一台机器上的时间戳相同。但是如下节所述,日历时钟也具有各种各样的奇特之处。特别是,如果本地时钟在NTP服务器之前太远,则它可能会被强制重置,看上去好像跳回了先前的时间点。这些跳跃以及他们经常忽略闰秒的事实,使日历时钟不能用于测量经过时间(elapsed time)【38】。
|
||||
日历时钟通常与 NTP 同步,这意味着来自一台机器的时间戳(理想情况下)与另一台机器上的时间戳相同。但是如下节所述,日历时钟也具有各种各样的奇特之处。特别是,如果本地时钟在 NTP 服务器之前太远,则它可能会被强制重置,看上去好像跳回了先前的时间点。这些跳跃以及他们经常忽略闰秒的事实,使日历时钟不能用于测量经过时间(elapsed time)【38】。
|
||||
|
||||
历史上的日历时钟还具有相当粗略的分辨率,例如,在较早的Windows系统上以10毫秒为单位前进【39】。在最近的系统中这已经不是一个问题了。
|
||||
历史上的日历时钟还具有相当粗略的分辨率,例如,在较早的 Windows 系统上以 10 毫秒为单位前进【39】。在最近的系统中这已经不是一个问题了。
|
||||
|
||||
#### 单调钟
|
||||
|
||||
单调钟适用于测量持续时间(时间间隔),例如超时或服务的响应时间:Linux上的`clock_gettime(CLOCK_MONOTONIC)`,和Java中的`System.nanoTime()`都是单调时钟。这个名字来源于他们保证总是往前走的事实(而日历时钟可以往回跳)。
|
||||
单调钟适用于测量持续时间(时间间隔),例如超时或服务的响应时间:Linux 上的 `clock_gettime(CLOCK_MONOTONIC)`,和 Java 中的 `System.nanoTime()` 都是单调时钟。这个名字来源于他们保证总是往前走的事实(而日历时钟可以往回跳)。
|
||||
|
||||
你可以在某个时间点检查单调钟的值,做一些事情,且稍后再次检查它。这两个值之间的差异告诉你两次检查之间经过了多长时间。但单调钟的绝对值是毫无意义的:它可能是计算机启动以来的纳秒数,或类似的任意值。特别是比较来自两台不同计算机的单调钟的值是没有意义的,因为它们并不是一回事。
|
||||
|
||||
在具有多个CPU插槽的服务器上,每个CPU可能有一个单独的计时器,但不一定与其他CPU同步。操作系统会补偿所有的差异,并尝试向应用线程表现出单调钟的样子,即使这些线程被调度到不同的CPU上。当然,明智的做法是不要太把这种单调性保证当回事【40】。
|
||||
在具有多个 CPU 插槽的服务器上,每个 CPU 可能有一个单独的计时器,但不一定与其他 CPU 同步。操作系统会补偿所有的差异,并尝试向应用线程表现出单调钟的样子,即使这些线程被调度到不同的 CPU 上。当然,明智的做法是不要太把这种单调性保证当回事【40】。
|
||||
|
||||
如果NTP协议检测到计算机的本地石英钟比NTP服务器要更快或更慢,则可以调整单调钟向前走的频率(这称为**偏移(skewing)** 时钟)。默认情况下,NTP允许时钟速率增加或减慢最高至0.05%,但NTP不能使单调时钟向前或向后跳转。单调时钟的分辨率通常相当好:在大多数系统中,它们能在几微秒或更短的时间内测量时间间隔。
|
||||
如果 NTP 协议检测到计算机的本地石英钟比 NTP 服务器要更快或更慢,则可以调整单调钟向前走的频率(这称为 **偏移(skewing)** 时钟)。默认情况下,NTP 允许时钟速率增加或减慢最高至 0.05%,但 NTP 不能使单调时钟向前或向后跳转。单调时钟的分辨率通常相当好:在大多数系统中,它们能在几微秒或更短的时间内测量时间间隔。
|
||||
|
||||
在分布式系统中,使用单调钟测量**经过时间**(elapsed time,比如超时)通常很好,因为它不假定不同节点的时钟之间存在任何同步,并且对测量的轻微不准确性不敏感。
|
||||
在分布式系统中,使用单调钟测量 **经过时间**(elapsed time,比如超时)通常很好,因为它不假定不同节点的时钟之间存在任何同步,并且对测量的轻微不准确性不敏感。
|
||||
|
||||
### 时钟同步与准确性
|
||||
|
||||
单调钟不需要同步,但是日历时钟需要根据NTP服务器或其他外部时间源来设置才能有用。不幸的是,我们获取时钟的方法并不像你所希望的那样可靠或准确——硬件时钟和NTP可能会变幻莫测。举几个例子:
|
||||
单调钟不需要同步,但是日历时钟需要根据 NTP 服务器或其他外部时间源来设置才能有用。不幸的是,我们获取时钟的方法并不像你所希望的那样可靠或准确 —— 硬件时钟和 NTP 可能会变幻莫测。举几个例子:
|
||||
|
||||
* 计算机中的石英钟不够精确:它会**漂移**(drifts,即运行速度快于或慢于预期)。时钟漂移取决于机器的温度。 Google假设其服务器时钟漂移为200 ppm(百万分之一)【41】,相当于每30秒与服务器重新同步一次的时钟漂移为6毫秒,或者每天重新同步的时钟漂移为17秒。即使一切工作正常,此漂移也会限制可以达到的最佳准确度。
|
||||
* 如果计算机的时钟与NTP服务器的时钟差别太大,可能会拒绝同步,或者本地时钟将被强制重置【37】。任何观察重置前后时间的应用程序都可能会看到时间倒退或突然跳跃。
|
||||
* 如果某个节点被NTP服务器的防火墙意外阻塞,有可能会持续一段时间都没有人会注意到。有证据表明,这在实践中确实发生过。
|
||||
* NTP同步只能和网络延迟一样好,所以当你在拥有可变数据包延迟的拥塞网络上时,NTP同步的准确性会受到限制。一个实验表明,当通过互联网同步时,35毫秒的最小误差是可以实现的,尽管偶尔的网络延迟峰值会导致大约一秒的误差。根据配置,较大的网络延迟会导致NTP客户端完全放弃。
|
||||
* 一些NTP服务器是错误的或者配置错误的,报告的时间可能相差几个小时【43,44】。还好NTP客户端非常健壮,因为他们会查询多个服务器并忽略异常值。无论如何,依赖于互联网上的陌生人所告诉你的时间来保证你的系统的正确性,这还挺让人担忧的。
|
||||
* 闰秒导致一分钟可能有59秒或61秒,这会打破一些在设计之时未考虑闰秒的系统的时序假设【45】。闰秒已经使许多大型系统崩溃的事实【38,46】说明了,关于时钟的错误假设是多么容易偷偷溜入系统中。处理闰秒的最佳方法可能是让NTP服务器“撒谎”,并在一天中逐渐执行闰秒调整(这被称为**拖尾**,即smearing)【47,48】,虽然实际的NTP服务器表现各异【49】。
|
||||
* 在虚拟机中,硬件时钟被虚拟化,这对于需要精确计时的应用程序提出了额外的挑战【50】。当一个CPU核心在虚拟机之间共享时,每个虚拟机都会暂停几十毫秒,与此同时另一个虚拟机正在运行。从应用程序的角度来看,这种停顿表现为时钟突然向前跳跃【26】。
|
||||
* 计算机中的石英钟不够精确:它会 **漂移**(drifts,即运行速度快于或慢于预期)。时钟漂移取决于机器的温度。 Google 假设其服务器时钟漂移为 200 ppm(百万分之一)【41】,相当于每 30 秒与服务器重新同步一次的时钟漂移为 6 毫秒,或者每天重新同步的时钟漂移为 17 秒。即使一切工作正常,此漂移也会限制可以达到的最佳准确度。
|
||||
* 如果计算机的时钟与 NTP 服务器的时钟差别太大,可能会拒绝同步,或者本地时钟将被强制重置【37】。任何观察重置前后时间的应用程序都可能会看到时间倒退或突然跳跃。
|
||||
* 如果某个节点被 NTP 服务器的防火墙意外阻塞,有可能会持续一段时间都没有人会注意到。有证据表明,这在实践中确实发生过。
|
||||
* NTP 同步只能和网络延迟一样好,所以当你在拥有可变数据包延迟的拥塞网络上时,NTP 同步的准确性会受到限制。一个实验表明,当通过互联网同步时,35 毫秒的最小误差是可以实现的,尽管偶尔的网络延迟峰值会导致大约一秒的误差。根据配置,较大的网络延迟会导致 NTP 客户端完全放弃。
|
||||
* 一些 NTP 服务器是错误的或者配置错误的,报告的时间可能相差几个小时【43,44】。还好 NTP 客户端非常健壮,因为他们会查询多个服务器并忽略异常值。无论如何,依赖于互联网上的陌生人所告诉你的时间来保证你的系统的正确性,这还挺让人担忧的。
|
||||
* 闰秒导致一分钟可能有 59 秒或 61 秒,这会打破一些在设计之时未考虑闰秒的系统的时序假设【45】。闰秒已经使许多大型系统崩溃的事实【38,46】说明了,关于时钟的错误假设是多么容易偷偷溜入系统中。处理闰秒的最佳方法可能是让 NTP 服务器 “撒谎”,并在一天中逐渐执行闰秒调整(这被称为 **拖尾**,即 smearing)【47,48】,虽然实际的 NTP 服务器表现各异【49】。
|
||||
* 在虚拟机中,硬件时钟被虚拟化,这对于需要精确计时的应用程序提出了额外的挑战【50】。当一个 CPU 核心在虚拟机之间共享时,每个虚拟机都会暂停几十毫秒,与此同时另一个虚拟机正在运行。从应用程序的角度来看,这种停顿表现为时钟突然向前跳跃【26】。
|
||||
* 如果你在没有完整控制权的设备(例如,移动设备或嵌入式设备)上运行软件,则可能完全不能信任该设备的硬件时钟。一些用户故意将其硬件时钟设置为不正确的日期和时间,例如,为了规避游戏中的时间限制,时钟可能会被设置到很远的过去或将来。
|
||||
|
||||
如果你足够在乎这件事并投入大量资源,就可以达到非常好的时钟精度。例如,针对金融机构的欧洲法规草案MiFID II要求所有高频率交易基金在UTC时间100微秒内同步时钟,以便调试“闪崩”等市场异常现象,并帮助检测市场操纵【51】。
|
||||
如果你足够在乎这件事并投入大量资源,就可以达到非常好的时钟精度。例如,针对金融机构的欧洲法规草案 MiFID II 要求所有高频率交易基金在 UTC 时间 100 微秒内同步时钟,以便调试 “闪崩” 等市场异常现象,并帮助检测市场操纵【51】。
|
||||
|
||||
通过GPS接收机,精确时间协议(PTP)【52】以及仔细的部署和监测可以实现这种精确度。然而,这需要很多努力和专业知识,而且有很多东西都会导致时钟同步错误。如果你的NTP守护进程配置错误,或者防火墙阻止了NTP通信,由漂移引起的时钟误差可能很快就会变大。
|
||||
通过 GPS 接收机,精确时间协议(PTP)【52】以及仔细的部署和监测可以实现这种精确度。然而,这需要很多努力和专业知识,而且有很多东西都会导致时钟同步错误。如果你的 NTP 守护进程配置错误,或者防火墙阻止了 NTP 通信,由漂移引起的时钟误差可能很快就会变大。
|
||||
|
||||
### 依赖同步时钟
|
||||
|
||||
时钟的问题在于,虽然它们看起来简单易用,但却具有令人惊讶的缺陷:一天可能不会有精确的86,400秒,**日历时钟**可能会前后跳跃,而一个节点上的时间可能与另一个节点上的时间完全不同。
|
||||
时钟的问题在于,虽然它们看起来简单易用,但却具有令人惊讶的缺陷:一天可能不会有精确的 86,400 秒,**日历时钟** 可能会前后跳跃,而一个节点上的时间可能与另一个节点上的时间完全不同。
|
||||
|
||||
本章早些时候,我们讨论了网络丢包和任意延迟包的问题。尽管网络在大多数情况下表现良好,但软件的设计必须假定网络偶尔会出现故障,而软件必须正常处理这些故障。时钟也是如此:尽管大多数时间都工作得很好,但需要准备健壮的软件来处理不正确的时钟。
|
||||
|
||||
有一部分问题是,不正确的时钟很容易被视而不见。如果一台机器的CPU出现故障或者网络配置错误,很可能根本无法工作,所以很快就会被注意和修复。另一方面,如果它的石英时钟有缺陷,或者它的NTP客户端配置错误,大部分事情似乎仍然可以正常工作,即使它的时钟逐渐偏离现实。如果某个软件依赖于精确同步的时钟,那么结果更可能是悄无声息的,仅有微量的数据丢失,而不是一次惊天动地的崩溃【53,54】。
|
||||
有一部分问题是,不正确的时钟很容易被视而不见。如果一台机器的 CPU 出现故障或者网络配置错误,很可能根本无法工作,所以很快就会被注意和修复。另一方面,如果它的石英时钟有缺陷,或者它的 NTP 客户端配置错误,大部分事情似乎仍然可以正常工作,即使它的时钟逐渐偏离现实。如果某个软件依赖于精确同步的时钟,那么结果更可能是悄无声息的,仅有微量的数据丢失,而不是一次惊天动地的崩溃【53,54】。
|
||||
|
||||
因此,如果你使用需要同步时钟的软件,必须仔细监控所有机器之间的时钟偏移。时钟偏离其他时钟太远的节点应当被宣告死亡,并从集群中移除。这样的监控可以确保你在损失发生之前注意到破损的时钟。
|
||||
|
||||
@ -313,55 +313,55 @@
|
||||
|
||||
让我们考虑一个特别的情况,一件很有诱惑但也很危险的事情:依赖时钟,在多个节点上对事件进行排序。 例如,如果两个客户端写入分布式数据库,谁先到达? 哪一个更近?
|
||||
|
||||
[图8-3](img/fig8-3.png)显示了在具有多领导者复制的数据库中对时钟的危险使用(该例子类似于[图5-9](img/fig5-9.png))。 客户端A在节点1上写入`x = 1`;写入被复制到节点3;客户端B在节点3上增加x(我们现在有`x = 2`);最后这两个写入都被复制到节点2。
|
||||
[图 8-3](img/fig8-3.png) 显示了在具有多领导者复制的数据库中对时钟的危险使用(该例子类似于 [图 5-9](img/fig5-9.png))。 客户端 A 在节点 1 上写入 `x = 1`;写入被复制到节点 3;客户端 B 在节点 3 上增加 x(我们现在有 `x = 2`);最后这两个写入都被复制到节点 2。
|
||||
|
||||
![](img/fig8-3.png)
|
||||
|
||||
**图8-3 客户端B的写入比客户端A的写入要晚,但是B的写入具有较早的时间戳。**
|
||||
**图 8-3 客户端 B 的写入比客户端 A 的写入要晚,但是 B 的写入具有较早的时间戳。**
|
||||
|
||||
在[图8-3](img/fig8-3.png)中,当一个写入被复制到其他节点时,它会根据发生写入的节点上的日历时钟标记一个时间戳。在这个例子中,时钟同步是非常好的:节点1和节点3之间的偏差小于3ms,这可能比你在实践中能预期的更好。
|
||||
在 [图 8-3](img/fig8-3.png) 中,当一个写入被复制到其他节点时,它会根据发生写入的节点上的日历时钟标记一个时间戳。在这个例子中,时钟同步是非常好的:节点 1 和节点 3 之间的偏差小于 3ms,这可能比你在实践中能预期的更好。
|
||||
|
||||
尽管如此,[图8-3](img/fig8-3.png)中的时间戳却无法正确排列事件:写入`x = 1`的时间戳为42.004秒,但写入`x = 2`的时间戳为42.003秒,即使`x = 2`在稍后出现。当节点2接收到这两个事件时,会错误地推断出`x = 1`是最近的值,而丢弃写入`x = 2`。效果上表现为,客户端B的增量操作会丢失。
|
||||
尽管如此,[图 8-3](img/fig8-3.png) 中的时间戳却无法正确排列事件:写入 `x = 1` 的时间戳为 42.004 秒,但写入 `x = 2` 的时间戳为 42.003 秒,即使 `x = 2` 在稍后出现。当节点 2 接收到这两个事件时,会错误地推断出 `x = 1` 是最近的值,而丢弃写入 `x = 2`。效果上表现为,客户端 B 的增量操作会丢失。
|
||||
|
||||
这种冲突解决策略被称为**最后写入胜利(LWW)**,它在多领导者复制和无领导者数据库(如Cassandra 【53】和Riak 【54】)中被广泛使用(请参阅“[最后写入胜利(丢弃并发写入)](ch5.md#最后写入胜利(丢弃并发写入))”一节)。有些实现会在客户端而不是服务器上生成时间戳,但这并不能改变LWW的基本问题:
|
||||
这种冲突解决策略被称为 **最后写入胜利(LWW)**,它在多领导者复制和无领导者数据库(如 Cassandra 【53】和 Riak 【54】)中被广泛使用(请参阅 “[最后写入胜利(丢弃并发写入)](ch5.md#最后写入胜利(丢弃并发写入))” 一节)。有些实现会在客户端而不是服务器上生成时间戳,但这并不能改变 LWW 的基本问题:
|
||||
|
||||
* 数据库写入可能会神秘地消失:具有滞后时钟的节点无法覆盖之前具有快速时钟的节点写入的值,直到节点之间的时钟偏差消逝【54,55】。此方案可能导致一定数量的数据被悄悄丢弃,而未向应用报告任何错误。
|
||||
* LWW无法区分**高频顺序写入**(在[图8-3](img/fig8-3.png)中,客户端B的增量操作**一定**发生在客户端A的写入之后)和**真正并发写入**(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止违背因果关系(请参阅“[检测并发写入](ch5.md#检测并发写入)”)。
|
||||
* 两个节点很可能独立地生成具有相同时间戳的写入,特别是在时钟仅具有毫秒分辨率的情况下。为了解决这样的冲突,还需要一个额外的**决胜值**(tiebreaker,可以简单地是一个大随机数),但这种方法也可能会导致违背因果关系【53】。
|
||||
* LWW 无法区分 **高频顺序写入**(在 [图 8-3](img/fig8-3.png) 中,客户端 B 的增量操作 **一定** 发生在客户端 A 的写入之后)和 **真正并发写入**(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止违背因果关系(请参阅 “[检测并发写入](ch5.md#检测并发写入)”)。
|
||||
* 两个节点很可能独立地生成具有相同时间戳的写入,特别是在时钟仅具有毫秒分辨率的情况下。为了解决这样的冲突,还需要一个额外的 **决胜值**(tiebreaker,可以简单地是一个大随机数),但这种方法也可能会导致违背因果关系【53】。
|
||||
|
||||
因此,尽管通过保留最“最近”的值并放弃其他值来解决冲突是很诱惑人的,但是要注意,“最近”的定义取决于本地的**日历时钟**,这很可能是不正确的。即使用严格同步的NTP时钟,一个数据包也可能在时间戳100毫秒(根据发送者的时钟)时发送,并在时间戳99毫秒(根据接收者的时钟)处到达——看起来好像数据包在发送之前已经到达,这是不可能的。
|
||||
因此,尽管通过保留最 “最近” 的值并放弃其他值来解决冲突是很诱惑人的,但是要注意,“最近” 的定义取决于本地的 **日历时钟**,这很可能是不正确的。即使用严格同步的 NTP 时钟,一个数据包也可能在时间戳 100 毫秒(根据发送者的时钟)时发送,并在时间戳 99 毫秒(根据接收者的时钟)处到达 —— 看起来好像数据包在发送之前已经到达,这是不可能的。
|
||||
|
||||
NTP同步是否能足够准确,以至于这种不正确的排序不会发生?也许不能,因为NTP的同步精度本身,除了石英钟漂移这类误差源之外,还受到网络往返时间的限制。为了进行正确的排序,你需要一个比测量对象(即网络延迟)要精确得多的时钟。
|
||||
NTP 同步是否能足够准确,以至于这种不正确的排序不会发生?也许不能,因为 NTP 的同步精度本身,除了石英钟漂移这类误差源之外,还受到网络往返时间的限制。为了进行正确的排序,你需要一个比测量对象(即网络延迟)要精确得多的时钟。
|
||||
|
||||
所谓的**逻辑时钟(logic clock)**【56,57】是基于递增计数器而不是振荡石英晶体,对于排序事件来说是更安全的选择(请参阅“[检测并发写入](ch5.md#检测并发写入)”)。逻辑时钟不测量一天中的时间或经过的秒数,而仅测量事件的相对顺序(无论一个事件发生在另一个事件之前还是之后)。相反,用来测量实际经过时间的**日历时钟**和**单调钟**也被称为**物理时钟(physical clock)**。我们将在“[顺序保证](ch9.md#顺序保证)”中来看顺序问题。
|
||||
所谓的 **逻辑时钟(logic clock)**【56,57】是基于递增计数器而不是振荡石英晶体,对于排序事件来说是更安全的选择(请参阅 “[检测并发写入](ch5.md#检测并发写入)”)。逻辑时钟不测量一天中的时间或经过的秒数,而仅测量事件的相对顺序(无论一个事件发生在另一个事件之前还是之后)。相反,用来测量实际经过时间的 **日历时钟** 和 **单调钟** 也被称为 **物理时钟(physical clock)**。我们将在 “[顺序保证](ch9.md#顺序保证)” 中来看顺序问题。
|
||||
|
||||
#### 时钟读数存在置信区间
|
||||
|
||||
你可能能够以微秒或甚至纳秒的精度读取机器的时钟。但即使可以得到如此细致的测量结果,这并不意味着这个值对于这样的精度实际上是准确的。实际上,大概率是不准确的——如前所述,即使你每分钟与本地网络上的NTP服务器进行同步,几毫秒的时间漂移也很容易在不精确的石英时钟上发生。使用公共互联网上的NTP服务器,最好的准确度可能达到几十毫秒,而且当网络拥塞时,误差可能会超过100毫秒【57】。
|
||||
你可能能够以微秒或甚至纳秒的精度读取机器的时钟。但即使可以得到如此细致的测量结果,这并不意味着这个值对于这样的精度实际上是准确的。实际上,大概率是不准确的 —— 如前所述,即使你每分钟与本地网络上的 NTP 服务器进行同步,几毫秒的时间漂移也很容易在不精确的石英时钟上发生。使用公共互联网上的 NTP 服务器,最好的准确度可能达到几十毫秒,而且当网络拥塞时,误差可能会超过 100 毫秒【57】。
|
||||
|
||||
因此,将时钟读数视为一个时间点是没有意义的——它更像是一段时间范围:例如,一个系统可能以95%的置信度认为当前时间处于本分钟内的第10.3秒和10.5秒之间,它可能没法比这更精确了【58】。如果我们只知道±100毫秒的时间,那么时间戳中的微秒数字部分基本上是没有意义的。
|
||||
因此,将时钟读数视为一个时间点是没有意义的 —— 它更像是一段时间范围:例如,一个系统可能以 95% 的置信度认为当前时间处于本分钟内的第 10.3 秒和 10.5 秒之间,它可能没法比这更精确了【58】。如果我们只知道 ±100 毫秒的时间,那么时间戳中的微秒数字部分基本上是没有意义的。
|
||||
|
||||
不确定性界限可以根据你的时间源来计算。如果你的GPS接收器或原子(铯)时钟直接连接到你的计算机上,预期的错误范围由制造商告知。如果从服务器获得时间,则不确定性取决于自上次与服务器同步以来的石英钟漂移的期望值,加上NTP服务器的不确定性,再加上到服务器的网络往返时间(只是获取粗略近似值,并假设服务器是可信的)。
|
||||
不确定性界限可以根据你的时间源来计算。如果你的 GPS 接收器或原子(铯)时钟直接连接到你的计算机上,预期的错误范围由制造商告知。如果从服务器获得时间,则不确定性取决于自上次与服务器同步以来的石英钟漂移的期望值,加上 NTP 服务器的不确定性,再加上到服务器的网络往返时间(只是获取粗略近似值,并假设服务器是可信的)。
|
||||
|
||||
不幸的是,大多数系统不公开这种不确定性:例如,当调用`clock_gettime()`时,返回值不会告诉你时间戳的预期错误,所以你不知道其置信区间是5毫秒还是5年。
|
||||
不幸的是,大多数系统不公开这种不确定性:例如,当调用 `clock_gettime()` 时,返回值不会告诉你时间戳的预期错误,所以你不知道其置信区间是 5 毫秒还是 5 年。
|
||||
|
||||
一个有趣的例外是Spanner中的Google TrueTime API 【41】,它明确地报告了本地时钟的置信区间。当你询问当前时间时,你会得到两个值:[最早,最晚],这是最早可能的时间戳和最晚可能的时间戳。在不确定性估计的基础上,时钟知道当前的实际时间落在该区间内。区间的宽度取决于自从本地石英钟最后与更精确的时钟源同步以来已经过了多长时间。
|
||||
一个有趣的例外是 Spanner 中的 Google TrueTime API 【41】,它明确地报告了本地时钟的置信区间。当你询问当前时间时,你会得到两个值:[最早,最晚],这是最早可能的时间戳和最晚可能的时间戳。在不确定性估计的基础上,时钟知道当前的实际时间落在该区间内。区间的宽度取决于自从本地石英钟最后与更精确的时钟源同步以来已经过了多长时间。
|
||||
|
||||
#### 全局快照的同步时钟
|
||||
|
||||
在“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”中,我们讨论了快照隔离,这是数据库中非常有用的功能,需要支持小型快速读写事务和大型长时间运行的只读事务(用于备份或分析)。它允许只读事务看到特定时间点的处于一致状态的数据库,且不会锁定和干扰读写事务。
|
||||
在 “[快照隔离和可重复读](ch7.md#快照隔离和可重复读)” 中,我们讨论了快照隔离,这是数据库中非常有用的功能,需要支持小型快速读写事务和大型长时间运行的只读事务(用于备份或分析)。它允许只读事务看到特定时间点的处于一致状态的数据库,且不会锁定和干扰读写事务。
|
||||
|
||||
快照隔离最常见的实现需要单调递增的事务ID。如果写入比快照晚(即,写入具有比快照更大的事务ID),则该写入对于快照事务是不可见的。在单节点数据库上,一个简单的计数器就足以生成事务ID。
|
||||
快照隔离最常见的实现需要单调递增的事务 ID。如果写入比快照晚(即,写入具有比快照更大的事务 ID),则该写入对于快照事务是不可见的。在单节点数据库上,一个简单的计数器就足以生成事务 ID。
|
||||
|
||||
但是当数据库分布在许多机器上,也许可能在多个数据中心中时,由于需要协调,(跨所有分区)全局单调递增的事务ID会很难生成。事务ID必须反映因果关系:如果事务B读取由事务A写入的值,则B必须具有比A更大的事务ID,否则快照就无法保持一致。在有大量的小规模、高频率的事务情景下,在分布式系统中创建事务ID成为一个难以处理的瓶颈[^vi]。
|
||||
但是当数据库分布在许多机器上,也许可能在多个数据中心中时,由于需要协调,(跨所有分区)全局单调递增的事务 ID 会很难生成。事务 ID 必须反映因果关系:如果事务 B 读取由事务 A 写入的值,则 B 必须具有比 A 更大的事务 ID,否则快照就无法保持一致。在有大量的小规模、高频率的事务情景下,在分布式系统中创建事务 ID 成为一个难以处理的瓶颈 [^vi]。
|
||||
|
||||
[^vi]: 存在分布式序列号生成器,例如Twitter的雪花(Snowflake),其以可伸缩的方式(例如,通过将ID空间的块分配给不同节点)近似单调地增加唯一ID。但是,它们通常无法保证与因果关系一致的排序,因为分配的ID块的时间范围比数据库读取和写入的时间范围要长。另请参阅“[顺序保证](ch9.md#顺序保证)”。
|
||||
[^vi]: 存在分布式序列号生成器,例如 Twitter 的雪花(Snowflake),其以可伸缩的方式(例如,通过将 ID 空间的块分配给不同节点)近似单调地增加唯一 ID。但是,它们通常无法保证与因果关系一致的排序,因为分配的 ID 块的时间范围比数据库读取和写入的时间范围要长。另请参阅 “[顺序保证](ch9.md#顺序保证)”。
|
||||
|
||||
我们可以使用同步时钟的时间戳作为事务ID吗?如果我们能够获得足够好的同步性,那么这种方法将具有很合适的属性:更晚的事务会有更大的时间戳。当然,问题在于时钟精度的不确定性。
|
||||
我们可以使用同步时钟的时间戳作为事务 ID 吗?如果我们能够获得足够好的同步性,那么这种方法将具有很合适的属性:更晚的事务会有更大的时间戳。当然,问题在于时钟精度的不确定性。
|
||||
|
||||
Spanner以这种方式实现跨数据中心的快照隔离【59,60】。它使用TrueTime API报告的时钟置信区间,并基于以下观察结果:如果你有两个置信区间,每个置信区间包含最早和最晚可能的时间戳( $A = [A_{earliest}, A_{latest}]$, $B=[B_{earliest}, B_{latest}]$),这两个区间不重叠(即:$A_{earliest} < A_{latest} < B_{earliest} < B_{latest}$)的话,那么B肯定发生在A之后——这是毫无疑问的。只有当区间重叠时,我们才不确定A和B发生的顺序。
|
||||
Spanner 以这种方式实现跨数据中心的快照隔离【59,60】。它使用 TrueTime API 报告的时钟置信区间,并基于以下观察结果:如果你有两个置信区间,每个置信区间包含最早和最晚可能的时间戳($A = [A_{earliest}, A_{latest}]$, $B=[B_{earliest}, B_{latest}]$),这两个区间不重叠(即:$A_{earliest} <A_{latest} <B_{earliest} <B_{latest}$)的话,那么 B 肯定发生在 A 之后 —— 这是毫无疑问的。只有当区间重叠时,我们才不确定 A 和 B 发生的顺序。
|
||||
|
||||
为了确保事务时间戳反映因果关系,在提交读写事务之前,Spanner在提交读写事务时,会故意等待置信区间长度的时间。通过这样,它可以确保任何可能读取数据的事务处于足够晚的时间,因此它们的置信区间不会重叠。为了保持尽可能短的等待时间,Spanner需要保持尽可能小的时钟不确定性,为此,Google在每个数据中心都部署了一个GPS接收器或原子钟,这允许时钟同步到大约7毫秒以内【41】。
|
||||
为了确保事务时间戳反映因果关系,在提交读写事务之前,Spanner 在提交读写事务时,会故意等待置信区间长度的时间。通过这样,它可以确保任何可能读取数据的事务处于足够晚的时间,因此它们的置信区间不会重叠。为了保持尽可能短的等待时间,Spanner 需要保持尽可能小的时钟不确定性,为此,Google 在每个数据中心都部署了一个 GPS 接收器或原子钟,这允许时钟同步到大约 7 毫秒以内【41】。
|
||||
|
||||
对分布式事务语义使用时钟同步是一个活跃的研究领域【57,61,62】。这些想法很有趣,但是它们还没有在谷歌之外的主流数据库中实现。
|
||||
|
||||
@ -369,7 +369,7 @@ Spanner以这种方式实现跨数据中心的快照隔离【59,60】。它使
|
||||
|
||||
让我们考虑在分布式系统中使用危险时钟的另一个例子。假设你有一个数据库,每个分区只有一个领导者。只有领导被允许接受写入。一个节点如何知道它仍然是领导者(它并没有被别人宣告为死亡),并且它可以安全地接受写入?
|
||||
|
||||
一种选择是领导者从其他节点获得一个**租约(lease)**,类似一个带超时的锁【63】。任一时刻只有一个节点可以持有租约——因此,当一个节点获得一个租约时,它知道它在某段时间内自己是领导者,直到租约到期。为了保持领导地位,节点必须周期性地在租约过期前续期。
|
||||
一种选择是领导者从其他节点获得一个 **租约(lease)**,类似一个带超时的锁【63】。任一时刻只有一个节点可以持有租约 —— 因此,当一个节点获得一个租约时,它知道它在某段时间内自己是领导者,直到租约到期。为了保持领导地位,节点必须周期性地在租约过期前续期。
|
||||
|
||||
如果节点发生故障,就会停止续期,所以当租约过期时,另一个节点可以接管。
|
||||
|
||||
@ -378,7 +378,7 @@ Spanner以这种方式实现跨数据中心的快照隔离【59,60】。它使
|
||||
```java
|
||||
while (true) {
|
||||
request = getIncomingRequest();
|
||||
// 确保租约还剩下至少10秒
|
||||
// 确保租约还剩下至少 10 秒
|
||||
if (lease.expiryTimeMillis - System.currentTimeMillis() < 10000){
|
||||
lease = lease.renew();
|
||||
}
|
||||
@ -389,23 +389,23 @@ while (true) {
|
||||
}
|
||||
```
|
||||
|
||||
这个代码有什么问题?首先,它依赖于同步时钟:租约到期时间由另一台机器设置(例如,当前时间加上30秒,计算到期时间),并将其与本地系统时钟进行比较。如果时钟不同步超过几秒,这段代码将开始做奇怪的事情。
|
||||
这个代码有什么问题?首先,它依赖于同步时钟:租约到期时间由另一台机器设置(例如,当前时间加上 30 秒,计算到期时间),并将其与本地系统时钟进行比较。如果时钟不同步超过几秒,这段代码将开始做奇怪的事情。
|
||||
|
||||
其次,即使我们将协议更改为仅使用本地单调时钟,也存在另一个问题:代码假定在执行剩余时间检查`System.currentTimeMillis()`和实际执行请求`process(request)`中间的时间间隔非常短。通常情况下,这段代码运行得非常快,所以10秒的缓冲区已经足够确保**租约**在请求处理到一半时不会过期。
|
||||
其次,即使我们将协议更改为仅使用本地单调时钟,也存在另一个问题:代码假定在执行剩余时间检查 `System.currentTimeMillis()` 和实际执行请求 `process(request)` 中间的时间间隔非常短。通常情况下,这段代码运行得非常快,所以 10 秒的缓冲区已经足够确保 **租约** 在请求处理到一半时不会过期。
|
||||
|
||||
但是,如果程序执行中出现了意外的停顿呢?例如,想象一下,线程在`lease.isValid()`行周围停止15秒,然后才继续。在这种情况下,在请求被处理的时候,租约可能已经过期,而另一个节点已经接管了领导。然而,没有什么可以告诉这个线程已经暂停了这么长时间了,所以这段代码不会注意到租约已经到期了,直到循环的下一个迭代 ——到那个时候它可能已经做了一些不安全的处理请求。
|
||||
但是,如果程序执行中出现了意外的停顿呢?例如,想象一下,线程在 `lease.isValid()` 行周围停止 15 秒,然后才继续。在这种情况下,在请求被处理的时候,租约可能已经过期,而另一个节点已经接管了领导。然而,没有什么可以告诉这个线程已经暂停了这么长时间了,所以这段代码不会注意到租约已经到期了,直到循环的下一个迭代 —— 到那个时候它可能已经做了一些不安全的处理请求。
|
||||
|
||||
假设一个线程可能会暂停很长时间,这是疯了吗?不幸的是,这种情况发生的原因有很多种:
|
||||
|
||||
* 许多编程语言运行时(如Java虚拟机)都有一个垃圾收集器(GC),偶尔需要停止所有正在运行的线程。这些“**停止所有处理(stop-the-world)**”GC暂停有时会持续几分钟【64】!甚至像HotSpot JVM的CMS这样的所谓的“并行”垃圾收集器也不能完全与应用程序代码并行运行,它需要不时地停止所有处理【65】。尽管通常可以通过改变分配模式或调整GC设置来减少暂停【66】,但是如果我们想要提供健壮的保证,就必须假设最坏的情况发生。
|
||||
* 在虚拟化环境中,可以**挂起(suspend)** 虚拟机(暂停执行所有进程并将内存内容保存到磁盘)并恢复(恢复内存内容并继续执行)。这个暂停可以在进程执行的任何时候发生,并且可以持续任意长的时间。这个功能有时用于虚拟机从一个主机到另一个主机的实时迁移,而不需要重新启动,在这种情况下,暂停的长度取决于进程写入内存的速率【67】。
|
||||
* 许多编程语言运行时(如 Java 虚拟机)都有一个垃圾收集器(GC),偶尔需要停止所有正在运行的线程。这些 “**停止所有处理(stop-the-world)**”GC 暂停有时会持续几分钟【64】!甚至像 HotSpot JVM 的 CMS 这样的所谓的 “并行” 垃圾收集器也不能完全与应用程序代码并行运行,它需要不时地停止所有处理【65】。尽管通常可以通过改变分配模式或调整 GC 设置来减少暂停【66】,但是如果我们想要提供健壮的保证,就必须假设最坏的情况发生。
|
||||
* 在虚拟化环境中,可以 **挂起(suspend)** 虚拟机(暂停执行所有进程并将内存内容保存到磁盘)并恢复(恢复内存内容并继续执行)。这个暂停可以在进程执行的任何时候发生,并且可以持续任意长的时间。这个功能有时用于虚拟机从一个主机到另一个主机的实时迁移,而不需要重新启动,在这种情况下,暂停的长度取决于进程写入内存的速率【67】。
|
||||
* 在最终用户的设备(如笔记本电脑)上,执行也可能被暂停并随意恢复,例如当用户关闭笔记本电脑的盖子时。
|
||||
* 当操作系统上下文切换到另一个线程时,或者当管理程序切换到另一个虚拟机时(在虚拟机中运行时),当前正在运行的线程可能在代码中的任意点处暂停。在虚拟机的情况下,在其他虚拟机中花费的CPU时间被称为**窃取时间(steal time)**。如果机器处于沉重的负载下(即,如果等待运行的线程队列很长),暂停的线程再次运行可能需要一些时间。
|
||||
* 如果应用程序执行同步磁盘访问,则线程可能暂停,等待缓慢的磁盘I/O操作完成【68】。在许多语言中,即使代码没有包含文件访问,磁盘访问也可能出乎意料地发生——例如,Java类加载器在第一次使用时惰性加载类文件,这可能在程序执行过程中随时发生。 I/O暂停和GC暂停甚至可能合谋组合它们的延迟【69】。如果磁盘实际上是一个网络文件系统或网络块设备(如亚马逊的EBS),I/O延迟进一步受到网络延迟变化的影响【29】。
|
||||
* 如果操作系统配置为允许交换到磁盘(页面交换),则简单的内存访问可能导致**页面错误(page fault)**,要求将磁盘中的页面装入内存。当这个缓慢的I/O操作发生时,线程暂停。如果内存压力很高,则可能需要将另一个页面换出到磁盘。在极端情况下,操作系统可能花费大部分时间将页面交换到内存中,而实际上完成的工作很少(这被称为**抖动**,即thrashing)。为了避免这个问题,通常在服务器机器上禁用页面调度(如果你宁愿干掉一个进程来释放内存,也不愿意冒抖动风险)。
|
||||
* 可以通过发送SIGSTOP信号来暂停Unix进程,例如通过在shell中按下Ctrl-Z。 这个信号立即阻止进程继续执行更多的CPU周期,直到SIGCONT恢复为止,此时它将继续运行。 即使你的环境通常不使用SIGSTOP,也可能由运维工程师意外发送。
|
||||
* 当操作系统上下文切换到另一个线程时,或者当管理程序切换到另一个虚拟机时(在虚拟机中运行时),当前正在运行的线程可能在代码中的任意点处暂停。在虚拟机的情况下,在其他虚拟机中花费的 CPU 时间被称为 **窃取时间(steal time)**。如果机器处于沉重的负载下(即,如果等待运行的线程队列很长),暂停的线程再次运行可能需要一些时间。
|
||||
* 如果应用程序执行同步磁盘访问,则线程可能暂停,等待缓慢的磁盘 I/O 操作完成【68】。在许多语言中,即使代码没有包含文件访问,磁盘访问也可能出乎意料地发生 —— 例如,Java 类加载器在第一次使用时惰性加载类文件,这可能在程序执行过程中随时发生。 I/O 暂停和 GC 暂停甚至可能合谋组合它们的延迟【69】。如果磁盘实际上是一个网络文件系统或网络块设备(如亚马逊的 EBS),I/O 延迟进一步受到网络延迟变化的影响【29】。
|
||||
* 如果操作系统配置为允许交换到磁盘(页面交换),则简单的内存访问可能导致 **页面错误(page fault)**,要求将磁盘中的页面装入内存。当这个缓慢的 I/O 操作发生时,线程暂停。如果内存压力很高,则可能需要将另一个页面换出到磁盘。在极端情况下,操作系统可能花费大部分时间将页面交换到内存中,而实际上完成的工作很少(这被称为 **抖动**,即 thrashing)。为了避免这个问题,通常在服务器机器上禁用页面调度(如果你宁愿干掉一个进程来释放内存,也不愿意冒抖动风险)。
|
||||
* 可以通过发送 SIGSTOP 信号来暂停 Unix 进程,例如通过在 shell 中按下 Ctrl-Z。 这个信号立即阻止进程继续执行更多的 CPU 周期,直到 SIGCONT 恢复为止,此时它将继续运行。 即使你的环境通常不使用 SIGSTOP,也可能由运维工程师意外发送。
|
||||
|
||||
所有这些事件都可以随时**抢占(preempt)** 正在运行的线程,并在稍后的时间恢复运行,而线程甚至不会注意到这一点。这个问题类似于在单个机器上使多线程代码线程安全:你不能对时序做任何假设,因为随时可能发生上下文切换,或者出现并行运行。
|
||||
所有这些事件都可以随时 **抢占(preempt)** 正在运行的线程,并在稍后的时间恢复运行,而线程甚至不会注意到这一点。这个问题类似于在单个机器上使多线程代码线程安全:你不能对时序做任何假设,因为随时可能发生上下文切换,或者出现并行运行。
|
||||
|
||||
当在一台机器上编写多线程代码时,我们有相当好的工具来实现线程安全:互斥量,信号量,原子计数器,无锁数据结构,阻塞队列等等。不幸的是,这些工具并不能直接转化为分布式系统操作,因为分布式系统没有共享内存,只有通过不可靠网络发送的消息。
|
||||
|
||||
@ -413,19 +413,19 @@ while (true) {
|
||||
|
||||
#### 响应时间保证
|
||||
|
||||
在许多编程语言和操作系统中,线程和进程可能暂停一段无限制的时间,正如讨论的那样。如果你足够努力,导致暂停的原因是**可以**消除的。
|
||||
在许多编程语言和操作系统中,线程和进程可能暂停一段无限制的时间,正如讨论的那样。如果你足够努力,导致暂停的原因是 **可以** 消除的。
|
||||
|
||||
某些软件的运行环境要求很高,不能在特定时间内响应可能会导致严重的损失:控制飞机、火箭、机器人、汽车和其他物体的计算机必须对其传感器输入做出快速而可预测的响应。在这些系统中,软件必须有一个特定的**截止时间(deadline)**,如果截止时间不满足,可能会导致整个系统的故障。这就是所谓的**硬实时(hard real-time)** 系统。
|
||||
某些软件的运行环境要求很高,不能在特定时间内响应可能会导致严重的损失:控制飞机、火箭、机器人、汽车和其他物体的计算机必须对其传感器输入做出快速而可预测的响应。在这些系统中,软件必须有一个特定的 **截止时间(deadline)**,如果截止时间不满足,可能会导致整个系统的故障。这就是所谓的 **硬实时(hard real-time)** 系统。
|
||||
|
||||
> #### 实时是真的吗?
|
||||
>
|
||||
> 在嵌入式系统中,实时是指系统经过精心设计和测试,以满足所有情况下的特定时间保证。这个含义与Web上对实时术语的模糊使用相反,后者描述了服务器将数据推送到客户端以及没有严格的响应时间限制的流处理(见[第十一章](ch11.md))。
|
||||
> 在嵌入式系统中,实时是指系统经过精心设计和测试,以满足所有情况下的特定时间保证。这个含义与 Web 上对实时术语的模糊使用相反,后者描述了服务器将数据推送到客户端以及没有严格的响应时间限制的流处理(见 [第十一章](ch11.md))。
|
||||
|
||||
例如,如果车载传感器检测到当前正在经历碰撞,你肯定不希望安全气囊释放系统因为GC暂停而延迟弹出。
|
||||
例如,如果车载传感器检测到当前正在经历碰撞,你肯定不希望安全气囊释放系统因为 GC 暂停而延迟弹出。
|
||||
|
||||
在系统中提供**实时保证**需要各级软件栈的支持:一个实时操作系统(RTOS),允许在指定的时间间隔内保证CPU时间的分配。库函数必须申明最坏情况下的执行时间;动态内存分配可能受到限制或完全不允许(实时垃圾收集器存在,但是应用程序仍然必须确保它不会给GC太多的负担);必须进行大量的测试和测量,以确保达到保证。
|
||||
在系统中提供 **实时保证** 需要各级软件栈的支持:一个实时操作系统(RTOS),允许在指定的时间间隔内保证 CPU 时间的分配。库函数必须申明最坏情况下的执行时间;动态内存分配可能受到限制或完全不允许(实时垃圾收集器存在,但是应用程序仍然必须确保它不会给 GC 太多的负担);必须进行大量的测试和测量,以确保达到保证。
|
||||
|
||||
所有这些都需要大量额外的工作,严重限制了可以使用的编程语言、库和工具的范围(因为大多数语言和工具不提供实时保证)。由于这些原因,开发实时系统非常昂贵,并且它们通常用于安全关键的嵌入式设备。而且,“**实时**”与“**高性能**”不一样——事实上,实时系统可能具有较低的吞吐量,因为他们必须让及时响应的优先级高于一切(另请参阅“[延迟和资源利用](#延迟和资源利用)“)。
|
||||
所有这些都需要大量额外的工作,严重限制了可以使用的编程语言、库和工具的范围(因为大多数语言和工具不提供实时保证)。由于这些原因,开发实时系统非常昂贵,并且它们通常用于安全关键的嵌入式设备。而且,“**实时**” 与 “**高性能**” 不一样 —— 事实上,实时系统可能具有较低的吞吐量,因为他们必须让及时响应的优先级高于一切(另请参阅 “[延迟和资源利用](#延迟和资源利用)“)。
|
||||
|
||||
对于大多数服务器端数据处理系统来说,实时保证是不经济或不合适的。因此,这些系统必须承受在非实时环境中运行的暂停和时钟不稳定性。
|
||||
|
||||
@ -433,9 +433,9 @@ while (true) {
|
||||
|
||||
进程暂停的负面影响可以在不诉诸昂贵的实时调度保证的情况下得到缓解。语言运行时在计划垃圾回收时具有一定的灵活性,因为它们可以跟踪对象分配的速度和随着时间的推移剩余的空闲内存。
|
||||
|
||||
一个新兴的想法是将GC暂停视为一个节点的短暂计划中断,并在这个节点收集其垃圾的同时,让其他节点处理来自客户端的请求。如果运行时可以警告应用程序一个节点很快需要GC暂停,那么应用程序可以停止向该节点发送新的请求,等待它完成处理未完成的请求,然后在没有请求正在进行时执行GC。这个技巧向客户端隐藏了GC暂停,并降低了响应时间的高百分比【70,71】。一些对延迟敏感的金融交易系统【72】使用这种方法。
|
||||
一个新兴的想法是将 GC 暂停视为一个节点的短暂计划中断,并在这个节点收集其垃圾的同时,让其他节点处理来自客户端的请求。如果运行时可以警告应用程序一个节点很快需要 GC 暂停,那么应用程序可以停止向该节点发送新的请求,等待它完成处理未完成的请求,然后在没有请求正在进行时执行 GC。这个技巧向客户端隐藏了 GC 暂停,并降低了响应时间的高百分比【70,71】。一些对延迟敏感的金融交易系统【72】使用这种方法。
|
||||
|
||||
这个想法的一个变种是只用垃圾收集器来处理短命对象(这些对象可以快速收集),并定期在积累大量长寿对象(因此需要完整GC)之前重新启动进程【65,73】。一次可以重新启动一个节点,在计划重新启动之前,流量可以从该节点移开,就像[第四章](ch4.md)里描述的滚动升级一样。
|
||||
这个想法的一个变种是只用垃圾收集器来处理短命对象(这些对象可以快速收集),并定期在积累大量长寿对象(因此需要完整 GC)之前重新启动进程【65,73】。一次可以重新启动一个节点,在计划重新启动之前,流量可以从该节点移开,就像 [第四章](ch4.md) 里描述的滚动升级一样。
|
||||
|
||||
这些措施不能完全阻止垃圾回收暂停,但可以有效地减少它们对应用的影响。
|
||||
|
||||
@ -444,63 +444,63 @@ while (true) {
|
||||
|
||||
本章到目前为止,我们已经探索了分布式系统与运行在单台计算机上的程序的不同之处:没有共享内存,只有通过可变延迟的不可靠网络传递的消息,系统可能遭受部分失效,不可靠的时钟和处理暂停。
|
||||
|
||||
如果你不习惯于分布式系统,那么这些问题的后果就会让人迷惑不解。网络中的一个节点无法确切地知道任何事情——它只能根据它通过网络接收到(或没有接收到)的消息进行猜测。节点只能通过交换消息来找出另一个节点所处的状态(存储了哪些数据,是否正确运行等等)。如果远程节点没有响应,则无法知道它处于什么状态,因为网络中的问题不能可靠地与节点上的问题区分开来。
|
||||
如果你不习惯于分布式系统,那么这些问题的后果就会让人迷惑不解。网络中的一个节点无法确切地知道任何事情 —— 它只能根据它通过网络接收到(或没有接收到)的消息进行猜测。节点只能通过交换消息来找出另一个节点所处的状态(存储了哪些数据,是否正确运行等等)。如果远程节点没有响应,则无法知道它处于什么状态,因为网络中的问题不能可靠地与节点上的问题区分开来。
|
||||
|
||||
这些系统的讨论与哲学有关:在系统中什么是真什么是假?如果感知和测量的机制都是不可靠的,那么关于这些知识我们又能多么确定呢?软件系统应该遵循我们对物理世界所期望的法则,如因果关系吗?
|
||||
|
||||
幸运的是,我们不需要去搞清楚生命的意义。在分布式系统中,我们可以陈述关于行为(系统模型)的假设,并以满足这些假设的方式设计实际系统。算法可以被证明在某个系统模型中正确运行。这意味着即使底层系统模型提供了很少的保证,也可以实现可靠的行为。
|
||||
|
||||
但是,尽管可以使软件在不可靠的系统模型中表现良好,但这并不是可以直截了当实现的。在本章的其余部分中,我们将进一步探讨分布式系统中的知识和真相的概念,这将有助于我们思考我们可以做出的各种假设以及我们可能希望提供的保证。在[第九章](ch9.md)中,我们将着眼于分布式系统的一些例子,这些算法在特定的假设条件下提供了特定的保证。
|
||||
但是,尽管可以使软件在不可靠的系统模型中表现良好,但这并不是可以直截了当实现的。在本章的其余部分中,我们将进一步探讨分布式系统中的知识和真相的概念,这将有助于我们思考我们可以做出的各种假设以及我们可能希望提供的保证。在 [第九章](ch9.md) 中,我们将着眼于分布式系统的一些例子,这些算法在特定的假设条件下提供了特定的保证。
|
||||
|
||||
### 真相由多数所定义
|
||||
|
||||
设想一个具有不对称故障的网络:一个节点能够接收发送给它的所有消息,但是来自该节点的任何传出消息被丢弃或延迟【19】。即使该节点运行良好,并且正在接收来自其他节点的请求,其他节点也无法听到其响应。经过一段时间后,其他节点宣布它已经死亡,因为他们没有听到节点的消息。这种情况就像梦魇一样:**半断开(semi-disconnected)** 的节点被拖向墓地,敲打尖叫道“我没死!” ——但是由于没有人能听到它的尖叫,葬礼队伍继续以坚忍的决心继续行进。
|
||||
设想一个具有不对称故障的网络:一个节点能够接收发送给它的所有消息,但是来自该节点的任何传出消息被丢弃或延迟【19】。即使该节点运行良好,并且正在接收来自其他节点的请求,其他节点也无法听到其响应。经过一段时间后,其他节点宣布它已经死亡,因为他们没有听到节点的消息。这种情况就像梦魇一样:**半断开(semi-disconnected)** 的节点被拖向墓地,敲打尖叫道 “我没死!” —— 但是由于没有人能听到它的尖叫,葬礼队伍继续以坚忍的决心继续行进。
|
||||
|
||||
在一个稍微不那么梦魇的场景中,半断开的节点可能会注意到它发送的消息没有被其他节点确认,因此意识到网络中必定存在故障。尽管如此,节点被其他节点错误地宣告为死亡,而半连接的节点对此无能为力。
|
||||
|
||||
第三种情况,想象一个经历了一个长时间**停止所有处理垃圾收集暂停(stop-the-world GC Pause)** 的节点。节点的所有线程被GC抢占并暂停一分钟,因此没有请求被处理,也没有响应被发送。其他节点等待,重试,不耐烦,并最终宣布节点死亡,并将其丢到灵车上。最后,GC完成,节点的线程继续,好像什么也没有发生。其他节点感到惊讶,因为所谓的死亡节点突然从棺材中抬起头来,身体健康,开始和旁观者高兴地聊天。GC后的节点最初甚至没有意识到已经经过了整整一分钟,而且自己已被宣告死亡。从它自己的角度来看,从最后一次与其他节点交谈以来,几乎没有经过任何时间。
|
||||
第三种情况,想象一个经历了一个长时间 **停止所有处理垃圾收集暂停(stop-the-world GC Pause)** 的节点。节点的所有线程被 GC 抢占并暂停一分钟,因此没有请求被处理,也没有响应被发送。其他节点等待,重试,不耐烦,并最终宣布节点死亡,并将其丢到灵车上。最后,GC 完成,节点的线程继续,好像什么也没有发生。其他节点感到惊讶,因为所谓的死亡节点突然从棺材中抬起头来,身体健康,开始和旁观者高兴地聊天。GC 后的节点最初甚至没有意识到已经经过了整整一分钟,而且自己已被宣告死亡。从它自己的角度来看,从最后一次与其他节点交谈以来,几乎没有经过任何时间。
|
||||
|
||||
这些故事的寓意是,节点不一定能相信自己对于情况的判断。分布式系统不能完全依赖单个节点,因为节点可能随时失效,可能会使系统卡死,无法恢复。相反,许多分布式算法都依赖于法定人数,即在节点之间进行投票(请参阅“[读写的法定人数](ch5.md#读写的法定人数)“):决策需要来自多个节点的最小投票数,以减少对于某个特定节点的依赖。
|
||||
这些故事的寓意是,节点不一定能相信自己对于情况的判断。分布式系统不能完全依赖单个节点,因为节点可能随时失效,可能会使系统卡死,无法恢复。相反,许多分布式算法都依赖于法定人数,即在节点之间进行投票(请参阅 “[读写的法定人数](ch5.md#读写的法定人数)“):决策需要来自多个节点的最小投票数,以减少对于某个特定节点的依赖。
|
||||
|
||||
这也包括关于宣告节点死亡的决定。如果法定数量的节点宣告另一个节点已经死亡,那么即使该节点仍感觉自己活着,它也必须被认为是死的。个体节点必须遵守法定决定并下台。
|
||||
|
||||
最常见的法定人数是超过一半的绝对多数(尽管其他类型的法定人数也是可能的)。多数法定人数允许系统继续工作,如果单个节点发生故障(三个节点可以容忍单节点故障;五个节点可以容忍双节点故障)。系统仍然是安全的,因为在这个制度中只能有一个多数——不能同时存在两个相互冲突的多数决定。当我们在[第九章](ch9.md)中讨论**共识算法(consensus algorithms)** 时,我们将更详细地讨论法定人数的应用。
|
||||
最常见的法定人数是超过一半的绝对多数(尽管其他类型的法定人数也是可能的)。多数法定人数允许系统继续工作,如果单个节点发生故障(三个节点可以容忍单节点故障;五个节点可以容忍双节点故障)。系统仍然是安全的,因为在这个制度中只能有一个多数 —— 不能同时存在两个相互冲突的多数决定。当我们在 [第九章](ch9.md) 中讨论 **共识算法(consensus algorithms)** 时,我们将更详细地讨论法定人数的应用。
|
||||
|
||||
#### 领导者和锁
|
||||
|
||||
通常情况下,一些东西在一个系统中只能有一个。例如:
|
||||
|
||||
* 数据库分区的领导者只能有一个节点,以避免**脑裂**(即split brain,请参阅“[处理节点宕机](ch5.md#处理节点宕机)”)。
|
||||
* 特定资源的锁或对象只允许一个事务/客户端持有,以防同时写入和损坏。
|
||||
* 数据库分区的领导者只能有一个节点,以避免 **脑裂**(即 split brain,请参阅 “[处理节点宕机](ch5.md#处理节点宕机)”)。
|
||||
* 特定资源的锁或对象只允许一个事务 / 客户端持有,以防同时写入和损坏。
|
||||
* 一个特定的用户名只能被一个用户所注册,因为用户名必须唯一标识一个用户。
|
||||
|
||||
在分布式系统中实现这一点需要注意:即使一个节点认为它是“**天选者(the choosen one)**”(分区的负责人,锁的持有者,成功获取用户名的用户的请求处理程序),但这并不一定意味着有法定人数的节点同意!一个节点可能以前是领导者,但是如果其他节点在此期间宣布它死亡(例如,由于网络中断或GC暂停),则它可能已被降级,且另一个领导者可能已经当选。
|
||||
在分布式系统中实现这一点需要注意:即使一个节点认为它是 “**天选者(the choosen one)**”(分区的负责人,锁的持有者,成功获取用户名的用户的请求处理程序),但这并不一定意味着有法定人数的节点同意!一个节点可能以前是领导者,但是如果其他节点在此期间宣布它死亡(例如,由于网络中断或 GC 暂停),则它可能已被降级,且另一个领导者可能已经当选。
|
||||
|
||||
如果一个节点继续表现为**天选者**,即使大多数节点已经声明它已经死了,则在考虑不周的系统中可能会导致问题。这样的节点能以自己赋予的权能向其他节点发送消息,如果其他节点相信,整个系统可能会做一些不正确的事情。
|
||||
如果一个节点继续表现为 **天选者**,即使大多数节点已经声明它已经死了,则在考虑不周的系统中可能会导致问题。这样的节点能以自己赋予的权能向其他节点发送消息,如果其他节点相信,整个系统可能会做一些不正确的事情。
|
||||
|
||||
例如,[图8-4](img/fig8-4.png)显示了由于不正确的锁实现导致的数据损坏错误。 (这个错误不仅仅是理论上的:HBase曾经有这个问题【74,75】)假设你要确保一个存储服务中的文件一次只能被一个客户访问,因为如果多个客户试图对此写入,该文件将被损坏。你尝试通过在访问文件之前要求客户端从锁定服务获取租约来实现此目的。
|
||||
例如,[图 8-4](img/fig8-4.png) 显示了由于不正确的锁实现导致的数据损坏错误。 (这个错误不仅仅是理论上的:HBase 曾经有这个问题【74,75】)假设你要确保一个存储服务中的文件一次只能被一个客户访问,因为如果多个客户试图对此写入,该文件将被损坏。你尝试通过在访问文件之前要求客户端从锁定服务获取租约来实现此目的。
|
||||
|
||||
![](img/fig8-4.png)
|
||||
|
||||
**图8-4 分布式锁的实现不正确:客户端1认为它仍然具有有效的租约,即使它已经过期,从而破坏了存储中的文件**
|
||||
**图 8-4 分布式锁的实现不正确:客户端 1 认为它仍然具有有效的租约,即使它已经过期,从而破坏了存储中的文件**
|
||||
|
||||
这个问题就是我们先前在“[进程暂停](#进程暂停)”中讨论过的一个例子:如果持有租约的客户端暂停太久,它的租约将到期。另一个客户端可以获得同一文件的租约,并开始写入文件。当暂停的客户端回来时,它认为(不正确)它仍然有一个有效的租约,并继续写入文件。结果,客户的写入将产生冲突并损坏文件。
|
||||
这个问题就是我们先前在 “[进程暂停](#进程暂停)” 中讨论过的一个例子:如果持有租约的客户端暂停太久,它的租约将到期。另一个客户端可以获得同一文件的租约,并开始写入文件。当暂停的客户端回来时,它认为(不正确)它仍然有一个有效的租约,并继续写入文件。结果,客户的写入将产生冲突并损坏文件。
|
||||
|
||||
#### 防护令牌
|
||||
|
||||
当使用锁或租约来保护对某些资源(如[图8-4](img/fig8-4.png)中的文件存储)的访问时,需要确保一个被误认为自己是“天选者”的节点不能扰乱系统的其它部分。实现这一目标的一个相当简单的技术就是**防护(fencing)**,如[图8-5](img/fig8-5.png)所示
|
||||
当使用锁或租约来保护对某些资源(如 [图 8-4](img/fig8-4.png) 中的文件存储)的访问时,需要确保一个被误认为自己是 “天选者” 的节点不能扰乱系统的其它部分。实现这一目标的一个相当简单的技术就是 **防护(fencing)**,如 [图 8-5](img/fig8-5.png) 所示
|
||||
|
||||
![](img/fig8-5.png)
|
||||
|
||||
**图8-5 只允许以增加防护令牌的顺序进行写操作,从而保证存储安全**
|
||||
**图 8-5 只允许以增加防护令牌的顺序进行写操作,从而保证存储安全**
|
||||
|
||||
我们假设每次锁定服务器授予锁或租约时,它还会返回一个**防护令牌(fencing token)**,这个数字在每次授予锁定时都会增加(例如,由锁定服务增加)。然后,我们可以要求客户端每次向存储服务发送写入请求时,都必须包含当前的防护令牌。
|
||||
我们假设每次锁定服务器授予锁或租约时,它还会返回一个 **防护令牌(fencing token)**,这个数字在每次授予锁定时都会增加(例如,由锁定服务增加)。然后,我们可以要求客户端每次向存储服务发送写入请求时,都必须包含当前的防护令牌。
|
||||
|
||||
在[图8-5](img/fig8-5.png)中,客户端1以33的令牌获得租约,但随后进入一个长时间的停顿并且租约到期。客户端2以34的令牌(该数字总是增加)获取租约,然后将其写入请求发送到存储服务,包括34的令牌。稍后,客户端1恢复生机并将其写入存储服务,包括其令牌值33。但是,存储服务器会记住它已经处理了一个具有更高令牌编号(34)的写入,因此它会拒绝带有令牌33的请求。
|
||||
在 [图 8-5](img/fig8-5.png) 中,客户端 1 以 33 的令牌获得租约,但随后进入一个长时间的停顿并且租约到期。客户端 2 以 34 的令牌(该数字总是增加)获取租约,然后将其写入请求发送到存储服务,包括 34 的令牌。稍后,客户端 1 恢复生机并将其写入存储服务,包括其令牌值 33。但是,存储服务器会记住它已经处理了一个具有更高令牌编号(34)的写入,因此它会拒绝带有令牌 33 的请求。
|
||||
|
||||
如果将ZooKeeper用作锁定服务,则可将事务标识`zxid`或节点版本`cversion`用作防护令牌。由于它们保证单调递增,因此它们具有所需的属性【74】。
|
||||
如果将 ZooKeeper 用作锁定服务,则可将事务标识 `zxid` 或节点版本 `cversion` 用作防护令牌。由于它们保证单调递增,因此它们具有所需的属性【74】。
|
||||
|
||||
请注意,这种机制要求资源本身在检查令牌方面发挥积极作用,通过拒绝使用旧的令牌,而不是已经被处理的令牌来进行写操作——仅仅依靠客户端检查自己的锁状态是不够的。对于不明确支持防护令牌的资源,可能仍然可以解决此限制(例如,在文件存储服务的情况下,可以将防护令牌包含在文件名中)。但是,为了避免在锁的保护之外处理请求,需要进行某种检查。
|
||||
请注意,这种机制要求资源本身在检查令牌方面发挥积极作用,通过拒绝使用旧的令牌,而不是已经被处理的令牌来进行写操作 —— 仅仅依靠客户端检查自己的锁状态是不够的。对于不明确支持防护令牌的资源,可能仍然可以解决此限制(例如,在文件存储服务的情况下,可以将防护令牌包含在文件名中)。但是,为了避免在锁的保护之外处理请求,需要进行某种检查。
|
||||
|
||||
在服务器端检查一个令牌可能看起来像是一个缺点,但这可以说是一件好事:一个服务假定它的客户总是守规矩并不明智,因为使用客户端的人与运行服务的人优先级非常不一样【76】。因此,任何服务保护自己免受意外客户的滥用是一个好主意。
|
||||
|
||||
@ -508,42 +508,42 @@ while (true) {
|
||||
|
||||
防护令牌可以检测和阻止无意中发生错误的节点(例如,因为它尚未发现其租约已过期)。但是,如果节点有意破坏系统的保证,则可以通过使用假防护令牌发送消息来轻松完成此操作。
|
||||
|
||||
在本书中,我们假设节点是不可靠但诚实的:它们可能很慢或者从不响应(由于故障),并且它们的状态可能已经过时(由于GC暂停或网络延迟),但是我们假设如果节点它做出了回应,它正在说出“真相”:尽其所知,它正在按照协议的规则扮演其角色。
|
||||
在本书中,我们假设节点是不可靠但诚实的:它们可能很慢或者从不响应(由于故障),并且它们的状态可能已经过时(由于 GC 暂停或网络延迟),但是我们假设如果节点它做出了回应,它正在说出 “真相”:尽其所知,它正在按照协议的规则扮演其角色。
|
||||
|
||||
如果存在节点可能“撒谎”(发送任意错误或损坏的响应)的风险,则分布式系统的问题变得更困难了——例如,如果节点可能声称其实际上没有收到特定的消息。这种行为被称为**拜占庭故障(Byzantine fault)**,**在不信任的环境中达成共识的问题被称为拜占庭将军问题**【77】。
|
||||
如果存在节点可能 “撒谎”(发送任意错误或损坏的响应)的风险,则分布式系统的问题变得更困难了 —— 例如,如果节点可能声称其实际上没有收到特定的消息。这种行为被称为 **拜占庭故障(Byzantine fault)**,**在不信任的环境中达成共识的问题被称为拜占庭将军问题**【77】。
|
||||
|
||||
> ### 拜占庭将军问题
|
||||
>
|
||||
> 拜占庭将军问题是对所谓“两将军问题”的泛化【78】,它想象两个将军需要就战斗计划达成一致的情况。由于他们在两个不同的地点建立了营地,他们只能通过信使进行沟通,信使有时会被延迟或丢失(就像网络中的信息包一样)。我们将在[第九章](ch9.md)讨论这个共识问题。
|
||||
> 拜占庭将军问题是对所谓 “两将军问题” 的泛化【78】,它想象两个将军需要就战斗计划达成一致的情况。由于他们在两个不同的地点建立了营地,他们只能通过信使进行沟通,信使有时会被延迟或丢失(就像网络中的信息包一样)。我们将在 [第九章](ch9.md) 讨论这个共识问题。
|
||||
>
|
||||
> 在这个问题的拜占庭版本里,有n位将军需要同意,他们的努力因为有一些叛徒在他们中间而受到阻碍。大多数的将军都是忠诚的,因而发出了真实的信息,但是叛徒可能会试图通过发送虚假或不真实的信息来欺骗和混淆他人(在试图保持未被发现的同时)。事先并不知道叛徒是谁。
|
||||
> 在这个问题的拜占庭版本里,有 n 位将军需要同意,他们的努力因为有一些叛徒在他们中间而受到阻碍。大多数的将军都是忠诚的,因而发出了真实的信息,但是叛徒可能会试图通过发送虚假或不真实的信息来欺骗和混淆他人(在试图保持未被发现的同时)。事先并不知道叛徒是谁。
|
||||
>
|
||||
> 拜占庭是后来成为君士坦丁堡的古希腊城市,现在在土耳其的伊斯坦布尔。没有任何历史证据表明拜占庭将军比其他地方更容易出现阴谋和阴谋。相反,这个名字来源于拜占庭式的过度复杂,官僚,迂回等意义,早在计算机之前就已经在政治中被使用了【79】。Lamport想要选一个不会冒犯任何读者的国家,他被告知将其称为阿尔巴尼亚将军问题并不是一个好主意【80】。
|
||||
> 拜占庭是后来成为君士坦丁堡的古希腊城市,现在在土耳其的伊斯坦布尔。没有任何历史证据表明拜占庭将军比其他地方更容易出现阴谋和阴谋。相反,这个名字来源于拜占庭式的过度复杂,官僚,迂回等意义,早在计算机之前就已经在政治中被使用了【79】。Lamport 想要选一个不会冒犯任何读者的国家,他被告知将其称为阿尔巴尼亚将军问题并不是一个好主意【80】。
|
||||
|
||||
当一个系统在部分节点发生故障、不遵守协议、甚至恶意攻击、扰乱网络时仍然能继续正确工作,称之为**拜占庭容错(Byzantine fault-tolerant)** 的,在特定场景下,这种担忧在是有意义的:
|
||||
当一个系统在部分节点发生故障、不遵守协议、甚至恶意攻击、扰乱网络时仍然能继续正确工作,称之为 **拜占庭容错(Byzantine fault-tolerant)** 的,在特定场景下,这种担忧在是有意义的:
|
||||
|
||||
* 在航空航天环境中,计算机内存或CPU寄存器中的数据可能被辐射破坏,导致其以任意不可预知的方式响应其他节点。由于系统故障非常昂贵(例如,飞机撞毁和炸死船上所有人员,或火箭与国际空间站相撞),飞行控制系统必须容忍拜占庭故障【81,82】。
|
||||
* 在航空航天环境中,计算机内存或 CPU 寄存器中的数据可能被辐射破坏,导致其以任意不可预知的方式响应其他节点。由于系统故障非常昂贵(例如,飞机撞毁和炸死船上所有人员,或火箭与国际空间站相撞),飞行控制系统必须容忍拜占庭故障【81,82】。
|
||||
* 在多个参与组织的系统中,一些参与者可能会试图欺骗或欺骗他人。在这种情况下,节点仅仅信任另一个节点的消息是不安全的,因为它们可能是出于恶意的目的而被发送的。例如,像比特币和其他区块链一样的对等网络可以被认为是让互不信任的各方同意交易是否发生的一种方式,而不依赖于中心机构(central authority)【83】。
|
||||
|
||||
然而,在本书讨论的那些系统中,我们通常可以安全地假设没有拜占庭式的错误。在你的数据中心里,所有的节点都是由你的组织控制的(所以他们可以信任),辐射水平足够低,内存损坏不是一个大问题。制作拜占庭容错系统的协议相当复杂【84】,而容错嵌入式系统依赖于硬件层面的支持【81】。在大多数服务器端数据系统中,部署拜占庭容错解决方案的成本使其变得不切实际。
|
||||
|
||||
Web应用程序确实需要预期受终端用户控制的客户端(如Web浏览器)的任意和恶意行为。这就是为什么输入验证,数据清洗和输出转义如此重要:例如,防止SQL注入和跨站点脚本。然而,我们通常不在这里使用拜占庭容错协议,而只是让服务器有权决定是否允许客户端行为。但在没有这种中心机构的对等网络中,拜占庭容错更为重要。
|
||||
Web 应用程序确实需要预期受终端用户控制的客户端(如 Web 浏览器)的任意和恶意行为。这就是为什么输入验证,数据清洗和输出转义如此重要:例如,防止 SQL 注入和跨站点脚本。然而,我们通常不在这里使用拜占庭容错协议,而只是让服务器有权决定是否允许客户端行为。但在没有这种中心机构的对等网络中,拜占庭容错更为重要。
|
||||
|
||||
软件中的一个错误(bug)可能被认为是拜占庭式的错误,但是如果你将相同的软件部署到所有节点上,那么拜占庭式的容错算法帮不到你。大多数拜占庭式容错算法要求超过三分之二的节点能够正常工作(即,如果有四个节点,最多只能有一个故障)。要使用这种方法对付bug,你必须有四个独立的相同软件的实现,并希望一个bug只出现在四个实现之一中。
|
||||
软件中的一个错误(bug)可能被认为是拜占庭式的错误,但是如果你将相同的软件部署到所有节点上,那么拜占庭式的容错算法帮不到你。大多数拜占庭式容错算法要求超过三分之二的节点能够正常工作(即,如果有四个节点,最多只能有一个故障)。要使用这种方法对付 bug,你必须有四个独立的相同软件的实现,并希望一个 bug 只出现在四个实现之一中。
|
||||
|
||||
同样,如果一个协议可以保护我们免受漏洞,安全渗透和恶意攻击,那么这将是有吸引力的。不幸的是,这也是不现实的:在大多数系统中,如果攻击者可以渗透一个节点,那他们可能会渗透所有这些节点,因为它们可能都运行着相同的软件。因此,传统机制(认证,访问控制,加密,防火墙等)仍然是抵御攻击者的主要保护措施。
|
||||
|
||||
#### 弱谎言形式
|
||||
|
||||
尽管我们假设节点通常是诚实的,但值得向软件中添加防止“撒谎”弱形式的机制——例如,由硬件问题导致的无效消息,软件错误和错误配置。这种保护机制并不是完全的拜占庭容错,因为它们不能抵挡决心坚定的对手,但它们仍然是简单而实用的步骤,以提高可靠性。例如:
|
||||
尽管我们假设节点通常是诚实的,但值得向软件中添加防止 “撒谎” 弱形式的机制 —— 例如,由硬件问题导致的无效消息,软件错误和错误配置。这种保护机制并不是完全的拜占庭容错,因为它们不能抵挡决心坚定的对手,但它们仍然是简单而实用的步骤,以提高可靠性。例如:
|
||||
|
||||
* 由于硬件问题或操作系统、驱动程序、路由器等中的错误,网络数据包有时会受到损坏。通常,损坏的数据包会被内建于TCP和UDP中的校验和所俘获,但有时它们也会逃脱检测【85,86,87】 。要对付这种破坏通常使用简单的方法就可以做到,例如应用程序级协议中的校验和。
|
||||
* 由于硬件问题或操作系统、驱动程序、路由器等中的错误,网络数据包有时会受到损坏。通常,损坏的数据包会被内建于 TCP 和 UDP 中的校验和所俘获,但有时它们也会逃脱检测【85,86,87】 。要对付这种破坏通常使用简单的方法就可以做到,例如应用程序级协议中的校验和。
|
||||
* 可公开访问的应用程序必须仔细清理来自用户的任何输入,例如检查值是否在合理的范围内,并限制字符串的大小以防止通过大内存分配的拒绝服务。防火墙后面的内部服务对于输入也许可以只采取一些不那么严格的检查,但是采取一些基本的合理性检查(例如,在协议解析中)仍然是一个好主意。
|
||||
* NTP客户端可以配置多个服务器地址。同步时,客户端联系所有的服务器,估计它们的误差,并检查大多数服务器是否对某个时间范围达成一致。只要大多数的服务器没问题,一个配置错误的NTP服务器报告的时间会被当成特异值从同步中排除【37】。使用多个服务器使NTP更健壮(比起只用单个服务器来)。
|
||||
* NTP 客户端可以配置多个服务器地址。同步时,客户端联系所有的服务器,估计它们的误差,并检查大多数服务器是否对某个时间范围达成一致。只要大多数的服务器没问题,一个配置错误的 NTP 服务器报告的时间会被当成特异值从同步中排除【37】。使用多个服务器使 NTP 更健壮(比起只用单个服务器来)。
|
||||
|
||||
### 系统模型与现实
|
||||
|
||||
已经有很多算法被设计以解决分布式系统问题——例如,我们将在[第九章](ch9.md)讨论共识问题的解决方案。为了有用,这些算法需要容忍我们在本章中讨论的分布式系统的各种故障。
|
||||
已经有很多算法被设计以解决分布式系统问题 —— 例如,我们将在 [第九章](ch9.md) 讨论共识问题的解决方案。为了有用,这些算法需要容忍我们在本章中讨论的分布式系统的各种故障。
|
||||
|
||||
算法的编写方式不应该过分依赖于运行的硬件和软件配置的细节。这就要求我们以某种方式将我们期望在系统中发生的错误形式化。我们通过定义一个系统模型来做到这一点,这个模型是一个抽象,描述一个算法可以假设的事情。
|
||||
|
||||
@ -559,30 +559,30 @@ Web应用程序确实需要预期受终端用户控制的客户端(如Web浏
|
||||
|
||||
* 异步模型
|
||||
|
||||
在这个模型中,一个算法不允许对时序做任何假设——事实上它甚至没有时钟(所以它不能使用超时)。一些算法被设计为可用于异步模型,但非常受限。
|
||||
在这个模型中,一个算法不允许对时序做任何假设 —— 事实上它甚至没有时钟(所以它不能使用超时)。一些算法被设计为可用于异步模型,但非常受限。
|
||||
|
||||
|
||||
进一步来说,除了时序问题,我们还要考虑**节点失效**。三种最常见的节点系统模型是:
|
||||
进一步来说,除了时序问题,我们还要考虑 **节点失效**。三种最常见的节点系统模型是:
|
||||
|
||||
* 崩溃-停止故障
|
||||
* 崩溃 - 停止故障
|
||||
|
||||
在**崩溃停止(crash-stop)** 模型中,算法可能会假设一个节点只能以一种方式失效,即通过崩溃。这意味着节点可能在任意时刻突然停止响应,此后该节点永远消失——它永远不会回来。
|
||||
在 **崩溃停止(crash-stop)** 模型中,算法可能会假设一个节点只能以一种方式失效,即通过崩溃。这意味着节点可能在任意时刻突然停止响应,此后该节点永远消失 —— 它永远不会回来。
|
||||
|
||||
* 崩溃-恢复故障
|
||||
* 崩溃 - 恢复故障
|
||||
|
||||
我们假设节点可能会在任何时候崩溃,但也许会在未知的时间之后再次开始响应。在**崩溃-恢复(crash-recovery)** 模型中,假设节点具有稳定的存储(即,非易失性磁盘存储)且会在崩溃中保留,而内存中的状态会丢失。
|
||||
我们假设节点可能会在任何时候崩溃,但也许会在未知的时间之后再次开始响应。在 **崩溃 - 恢复(crash-recovery)** 模型中,假设节点具有稳定的存储(即,非易失性磁盘存储)且会在崩溃中保留,而内存中的状态会丢失。
|
||||
|
||||
* 拜占庭(任意)故障
|
||||
|
||||
节点可以做(绝对意义上的)任何事情,包括试图戏弄和欺骗其他节点,如上一节所述。
|
||||
|
||||
对于真实系统的建模,具有**崩溃-恢复故障(crash-recovery)** 的**部分同步模型(partial synchronous)** 通常是最有用的模型。分布式算法如何应对这种模型?
|
||||
对于真实系统的建模,具有 **崩溃 - 恢复故障(crash-recovery)** 的 **部分同步模型(partial synchronous)** 通常是最有用的模型。分布式算法如何应对这种模型?
|
||||
|
||||
#### 算法的正确性
|
||||
|
||||
为了定义算法是正确的,我们可以描述它的属性。例如,排序算法的输出具有如下特性:对于输出列表中的任何两个不同的元素,左边的元素比右边的元素小。这只是定义对列表进行排序含义的一种形式方式。
|
||||
|
||||
同样,我们可以写下我们想要的分布式算法的属性来定义它的正确含义。例如,如果我们正在为一个锁生成防护令牌(请参阅“[防护令牌](#防护令牌)”),我们可能要求算法具有以下属性:
|
||||
同样,我们可以写下我们想要的分布式算法的属性来定义它的正确含义。例如,如果我们正在为一个锁生成防护令牌(请参阅 “[防护令牌](#防护令牌)”),我们可能要求算法具有以下属性:
|
||||
|
||||
* 唯一性(uniqueness)
|
||||
|
||||
@ -590,7 +590,7 @@ Web应用程序确实需要预期受终端用户控制的客户端(如Web浏
|
||||
|
||||
* 单调序列(monotonic sequence)
|
||||
|
||||
如果请求 $x$ 返回了令牌 $t_x$,并且请求$y$返回了令牌$t_y$,并且 $x$ 在 $y$ 开始之前已经完成,那么$t_x <t_y$。
|
||||
如果请求 $x$ 返回了令牌 $t_x$,并且请求 $y$ 返回了令牌 $t_y$,并且 $x$ 在 $y$ 开始之前已经完成,那么 $t_x <t_y$。
|
||||
|
||||
* 可用性(availability)
|
||||
|
||||
@ -600,28 +600,28 @@ Web应用程序确实需要预期受终端用户控制的客户端(如Web浏
|
||||
|
||||
#### 安全性和活性
|
||||
|
||||
为了澄清这种情况,有必要区分两种不同的属性:**安全(safety)属性**和**活性(liveness)属性**。在刚刚给出的例子中,**唯一性**和**单调序列**是安全属性,而**可用性**是活性属性。
|
||||
为了澄清这种情况,有必要区分两种不同的属性:**安全(safety)属性** 和 **活性(liveness)属性**。在刚刚给出的例子中,**唯一性** 和 **单调序列** 是安全属性,而 **可用性** 是活性属性。
|
||||
|
||||
这两种性质有什么区别?一个试金石就是,活性属性通常在定义中通常包括“**最终**”一词(是的,你猜对了——最终一致性是一个活性属性【89】)。
|
||||
这两种性质有什么区别?一个试金石就是,活性属性通常在定义中通常包括 “**最终**” 一词(是的,你猜对了 —— 最终一致性是一个活性属性【89】)。
|
||||
|
||||
安全通常被非正式地定义为:**没有坏事发生**,而活性通常就类似:**最终好事发生**。但是,最好不要过多地阅读那些非正式的定义,因为好与坏的含义是主观的。安全和活性的实际定义是精确的和数学的【90】:
|
||||
|
||||
* 如果安全属性被违反,我们可以指向一个特定的安全属性被破坏的时间点(例如,如果违反了唯一性属性,我们可以确定重复的防护令牌被返回的特定操作)。违反安全属性后,违规行为不能被撤销——损失已经发生。
|
||||
* 如果安全属性被违反,我们可以指向一个特定的安全属性被破坏的时间点(例如,如果违反了唯一性属性,我们可以确定重复的防护令牌被返回的特定操作)。违反安全属性后,违规行为不能被撤销 —— 损失已经发生。
|
||||
* 活性属性反过来:在某个时间点(例如,一个节点可能发送了一个请求,但还没有收到响应),它可能不成立,但总是希望在未来能成立(即通过接受答复)。
|
||||
|
||||
区分安全属性和活性属性的一个优点是可以帮助我们处理困难的系统模型。对于分布式算法,在系统模型的所有可能情况下,要求**始终**保持安全属性是常见的【88】。也就是说,即使所有节点崩溃,或者整个网络出现故障,算法仍然必须确保它不会返回错误的结果(即保证安全属性得到满足)。
|
||||
区分安全属性和活性属性的一个优点是可以帮助我们处理困难的系统模型。对于分布式算法,在系统模型的所有可能情况下,要求 **始终** 保持安全属性是常见的【88】。也就是说,即使所有节点崩溃,或者整个网络出现故障,算法仍然必须确保它不会返回错误的结果(即保证安全属性得到满足)。
|
||||
|
||||
但是,对于活性属性,我们可以提出一些注意事项:例如,只有在大多数节点没有崩溃的情况下,只有当网络最终从中断中恢复时,我们才可以说请求需要接收响应。部分同步模型的定义要求系统最终返回到同步状态——即任何网络中断的时间段只会持续一段有限的时间,然后进行修复。
|
||||
但是,对于活性属性,我们可以提出一些注意事项:例如,只有在大多数节点没有崩溃的情况下,只有当网络最终从中断中恢复时,我们才可以说请求需要接收响应。部分同步模型的定义要求系统最终返回到同步状态 —— 即任何网络中断的时间段只会持续一段有限的时间,然后进行修复。
|
||||
|
||||
#### 将系统模型映射到现实世界
|
||||
|
||||
安全属性和活性属性以及系统模型对于推理分布式算法的正确性非常有用。然而,在实践中实施算法时,现实的混乱事实再一次地让你咬牙切齿,很明显系统模型是对现实的简化抽象。
|
||||
|
||||
例如,在崩溃-恢复(crash-recovery)模型中的算法通常假设稳定存储器中的数据在崩溃后可以幸存。但是,如果磁盘上的数据被破坏,或者由于硬件错误或错误配置导致数据被清除,会发生什么情况【91】?如果服务器存在固件错误并且在重新启动时无法识别其硬盘驱动器,即使驱动器已正确连接到服务器,那又会发生什么情况【92】?
|
||||
例如,在崩溃 - 恢复(crash-recovery)模型中的算法通常假设稳定存储器中的数据在崩溃后可以幸存。但是,如果磁盘上的数据被破坏,或者由于硬件错误或错误配置导致数据被清除,会发生什么情况【91】?如果服务器存在固件错误并且在重新启动时无法识别其硬盘驱动器,即使驱动器已正确连接到服务器,那又会发生什么情况【92】?
|
||||
|
||||
法定人数算法(请参阅“[读写法定人数](ch5.md#读写法定人数)”)依赖节点来记住它声称存储的数据。如果一个节点可能患有健忘症,忘记了以前存储的数据,这会打破法定条件,从而破坏算法的正确性。也许需要一个新的系统模型,在这个模型中,我们假设稳定的存储大多能在崩溃后幸存,但有时也可能会丢失。但是那个模型就变得更难以推理了。
|
||||
法定人数算法(请参阅 “[读写法定人数](ch5.md#读写法定人数)”)依赖节点来记住它声称存储的数据。如果一个节点可能患有健忘症,忘记了以前存储的数据,这会打破法定条件,从而破坏算法的正确性。也许需要一个新的系统模型,在这个模型中,我们假设稳定的存储大多能在崩溃后幸存,但有时也可能会丢失。但是那个模型就变得更难以推理了。
|
||||
|
||||
算法的理论描述可以简单宣称一些事是不会发生的——在非拜占庭式系统中,我们确实需要对可能发生和不可能发生的故障做出假设。然而,真实世界的实现,仍然会包括处理“假设上不可能”情况的代码,即使代码可能就是`printf("Sucks to be you")`和`exit(666)`,实际上也就是留给运维来擦屁股【93】。(这可以说是计算机科学和软件工程间的一个差异)。
|
||||
算法的理论描述可以简单宣称一些事是不会发生的 —— 在非拜占庭式系统中,我们确实需要对可能发生和不可能发生的故障做出假设。然而,真实世界的实现,仍然会包括处理 “假设上不可能” 情况的代码,即使代码可能就是 `printf("Sucks to be you")` 和 `exit(666)`,实际上也就是留给运维来擦屁股【93】。(这可以说是计算机科学和软件工程间的一个差异)。
|
||||
|
||||
这并不是说理论上抽象的系统模型是毫无价值的,恰恰相反。它们对于将实际系统的复杂性提取成一个个我们可以推理的可处理的错误类型是非常有帮助的,以便我们能够理解这个问题,并试图系统地解决这个问题。我们可以证明算法是正确的,通过表明它们的属性在某个系统模型中总是成立的。
|
||||
|
||||
@ -633,24 +633,24 @@ Web应用程序确实需要预期受终端用户控制的客户端(如Web浏
|
||||
在本章中,我们讨论了分布式系统中可能发生的各种问题,包括:
|
||||
|
||||
* 当你尝试通过网络发送数据包时,数据包可能会丢失或任意延迟。同样,答复可能会丢失或延迟,所以如果你没有得到答复,你不知道消息是否发送成功了。
|
||||
* 节点的时钟可能会与其他节点显著不同步(尽管你尽最大努力设置NTP),它可能会突然跳转或跳回,依靠它是很危险的,因为你很可能没有好的方法来测量你的时钟的错误间隔。
|
||||
* 节点的时钟可能会与其他节点显著不同步(尽管你尽最大努力设置 NTP),它可能会突然跳转或跳回,依靠它是很危险的,因为你很可能没有好的方法来测量你的时钟的错误间隔。
|
||||
* 一个进程可能会在其执行的任何时候暂停一段相当长的时间(可能是因为停止所有处理的垃圾收集器),被其他节点宣告死亡,然后再次复活,却没有意识到它被暂停了。
|
||||
|
||||
这类**部分失效(partial failure)** 可能发生的事实是分布式系统的决定性特征。每当软件试图做任何涉及其他节点的事情时,偶尔就有可能会失败,或者随机变慢,或者根本没有响应(最终超时)。在分布式系统中,我们试图在软件中建立**部分失效**的容错机制,这样整个系统在即使某些组成部分被破坏的情况下,也可以继续运行。
|
||||
这类 **部分失效(partial failure)** 可能发生的事实是分布式系统的决定性特征。每当软件试图做任何涉及其他节点的事情时,偶尔就有可能会失败,或者随机变慢,或者根本没有响应(最终超时)。在分布式系统中,我们试图在软件中建立 **部分失效** 的容错机制,这样整个系统在即使某些组成部分被破坏的情况下,也可以继续运行。
|
||||
|
||||
为了容忍错误,第一步是**检测**它们,但即使这样也很难。大多数系统没有检测节点是否发生故障的准确机制,所以大多数分布式算法依靠**超时**来确定远程节点是否仍然可用。但是,超时无法区分网络失效和节点失效,并且可变的网络延迟有时会导致节点被错误地怀疑发生故障。此外,有时一个节点可能处于降级状态:例如,由于驱动程序错误,千兆网卡可能突然下降到1 Kb/s的吞吐量【94】。这样一个“跛行”而不是死掉的节点可能比一个干净的失效节点更难处理。
|
||||
为了容忍错误,第一步是 **检测** 它们,但即使这样也很难。大多数系统没有检测节点是否发生故障的准确机制,所以大多数分布式算法依靠 **超时** 来确定远程节点是否仍然可用。但是,超时无法区分网络失效和节点失效,并且可变的网络延迟有时会导致节点被错误地怀疑发生故障。此外,有时一个节点可能处于降级状态:例如,由于驱动程序错误,千兆网卡可能突然下降到 1 Kb/s 的吞吐量【94】。这样一个 “跛行” 而不是死掉的节点可能比一个干净的失效节点更难处理。
|
||||
|
||||
一旦检测到故障,使系统容忍它也并不容易:没有全局变量,没有共享内存,没有共同的知识,或机器之间任何其他种类的共享状态。节点甚至不能就现在是什么时间达成一致,就不用说更深奥的了。信息从一个节点流向另一个节点的唯一方法是通过不可靠的网络发送信息。重大决策不能由一个节点安全地完成,因此我们需要一个能从其他节点获得帮助的协议,并争取达到法定人数以达成一致。
|
||||
|
||||
如果你习惯于在理想化的数学完美的单机环境(同一个操作总能确定地返回相同的结果)中编写软件,那么转向分布式系统的凌乱的物理现实可能会有些令人震惊。相反,如果能够在单台计算机上解决一个问题,那么分布式系统工程师通常会认为这个问题是平凡的【5】,现在单个计算机确实可以做很多事情【95】。如果你可以避免打开潘多拉的盒子,把东西放在一台机器上,那么通常是值得的。
|
||||
|
||||
但是,正如在[第二部分](part-ii.md)的介绍中所讨论的那样,可伸缩性并不是使用分布式系统的唯一原因。容错和低延迟(通过将数据放置在距离用户较近的地方)是同等重要的目标,而这些不能用单个节点实现。
|
||||
但是,正如在 [第二部分](part-ii.md) 的介绍中所讨论的那样,可伸缩性并不是使用分布式系统的唯一原因。容错和低延迟(通过将数据放置在距离用户较近的地方)是同等重要的目标,而这些不能用单个节点实现。
|
||||
|
||||
在本章中,我们也转换了几次话题,探讨了网络、时钟和进程的不可靠性是否是不可避免的自然规律。我们看到这并不是:有可能给网络提供硬实时的响应保证和有限的延迟,但是这样做非常昂贵,且导致硬件资源的利用率降低。大多数非安全关键系统会选择**便宜而不可靠**,而不是**昂贵和可靠**。
|
||||
在本章中,我们也转换了几次话题,探讨了网络、时钟和进程的不可靠性是否是不可避免的自然规律。我们看到这并不是:有可能给网络提供硬实时的响应保证和有限的延迟,但是这样做非常昂贵,且导致硬件资源的利用率降低。大多数非安全关键系统会选择 **便宜而不可靠**,而不是 **昂贵和可靠**。
|
||||
|
||||
我们还谈到了超级计算机,它们采用可靠的组件,因此当组件发生故障时必须完全停止并重新启动。相比之下,分布式系统可以永久运行而不会在服务层面中断,因为所有的错误和维护都可以在节点级别进行处理——至少在理论上是如此。 (实际上,如果一个错误的配置变更被应用到所有的节点,仍然会使分布式系统瘫痪)。
|
||||
我们还谈到了超级计算机,它们采用可靠的组件,因此当组件发生故障时必须完全停止并重新启动。相比之下,分布式系统可以永久运行而不会在服务层面中断,因为所有的错误和维护都可以在节点级别进行处理 —— 至少在理论上是如此。 (实际上,如果一个错误的配置变更被应用到所有的节点,仍然会使分布式系统瘫痪)。
|
||||
|
||||
本章一直在讲存在的问题,给我们展现了一幅黯淡的前景。在[下一章](ch9.md)中,我们将继续讨论解决方案,并讨论一些旨在解决分布式系统中所有问题的算法。
|
||||
本章一直在讲存在的问题,给我们展现了一幅黯淡的前景。在 [下一章](ch9.md) 中,我们将继续讨论解决方案,并讨论一些旨在解决分布式系统中所有问题的算法。
|
||||
|
||||
|
||||
## 参考文献
|
||||
|
504
zh-tw/ch10.md
504
zh-tw/ch10.md
@ -10,11 +10,11 @@
|
||||
|
||||
[TOC]
|
||||
|
||||
在本書的前兩部分中,我們討論了很多關於**請求**和**查詢**以及相應的**響應**或**結果**。許多現有資料系統中都採用這種資料處理方式:你傳送請求指令,一段時間後(我們期望)系統會給出一個結果。資料庫、快取、搜尋索引、Web伺服器以及其他一些系統都以這種方式工作。
|
||||
在本書的前兩部分中,我們討論了很多關於 **請求** 和 **查詢** 以及相應的 **響應** 或 **結果**。許多現有資料系統中都採用這種資料處理方式:你傳送請求指令,一段時間後(我們期望)系統會給出一個結果。資料庫、快取、搜尋索引、Web 伺服器以及其他一些系統都以這種方式工作。
|
||||
|
||||
像這樣的**線上(online)** 系統,無論是瀏覽器請求頁面還是呼叫遠端API的服務,我們通常認為請求是由人類使用者觸發的,並且正在等待響應。他們不應該等太久,所以我們非常關注系統的響應時間(請參閱“[描述效能](ch1.md#描述效能)”)。
|
||||
像這樣的 **線上(online)** 系統,無論是瀏覽器請求頁面還是呼叫遠端 API 的服務,我們通常認為請求是由人類使用者觸發的,並且正在等待響應。他們不應該等太久,所以我們非常關注系統的響應時間(請參閱 “[描述效能](ch1.md#描述效能)”)。
|
||||
|
||||
Web和越來越多的基於HTTP/REST的API使互動的請求/響應風格變得如此普遍,以至於很容易將其視為理所當然。但我們應該記住,這不是構建系統的唯一方式,其他方法也有其優點。我們來看看三種不同型別的系統:
|
||||
Web 和越來越多的基於 HTTP/REST 的 API 使互動的請求 / 響應風格變得如此普遍,以至於很容易將其視為理所當然。但我們應該記住,這不是構建系統的唯一方式,其他方法也有其優點。我們來看看三種不同型別的系統:
|
||||
|
||||
* 服務(線上系統)
|
||||
|
||||
@ -22,24 +22,24 @@ Web和越來越多的基於HTTP/REST的API使互動的請求/響應風格變得
|
||||
|
||||
* 批處理系統(離線系統)
|
||||
|
||||
一個批處理系統有大量的輸入資料,跑一個**作業(job)** 來處理它,並生成一些輸出資料,這往往需要一段時間(從幾分鐘到幾天),所以通常不會有使用者等待作業完成。相反,批次作業通常會定期執行(例如,每天一次)。批處理作業的主要效能衡量標準通常是吞吐量(處理特定大小的輸入所需的時間)。本章中討論的就是批處理。
|
||||
一個批處理系統有大量的輸入資料,跑一個 **作業(job)** 來處理它,並生成一些輸出資料,這往往需要一段時間(從幾分鐘到幾天),所以通常不會有使用者等待作業完成。相反,批次作業通常會定期執行(例如,每天一次)。批處理作業的主要效能衡量標準通常是吞吐量(處理特定大小的輸入所需的時間)。本章中討論的就是批處理。
|
||||
|
||||
* 流處理系統(準實時系統)
|
||||
|
||||
流處理介於線上和離線(批處理)之間,所以有時候被稱為**準實時(near-real-time)** 或**準線上(nearline)** 處理。像批處理系統一樣,流處理消費輸入併產生輸出(並不需要響應請求)。但是,流式作業在事件發生後不久就會對事件進行操作,而批處理作業則需等待固定的一組輸入資料。這種差異使流處理系統比起批處理系統具有更低的延遲。由於流處理基於批處理,我們將在[第十一章](ch11.md)討論它。
|
||||
流處理介於線上和離線(批處理)之間,所以有時候被稱為 **準實時(near-real-time)** 或 **準線上(nearline)** 處理。像批處理系統一樣,流處理消費輸入併產生輸出(並不需要響應請求)。但是,流式作業在事件發生後不久就會對事件進行操作,而批處理作業則需等待固定的一組輸入資料。這種差異使流處理系統比起批處理系統具有更低的延遲。由於流處理基於批處理,我們將在 [第十一章](ch11.md) 討論它。
|
||||
|
||||
正如我們將在本章中看到的那樣,批處理是構建可靠、可伸縮和可維護應用程式的重要組成部分。例如,2004年釋出的批處理演算法Map-Reduce(可能被過分熱情地)被稱為“造就Google大規模可伸縮性的演算法”【2】。隨後在各種開源資料系統中得到應用,包括Hadoop,CouchDB和MongoDB。
|
||||
正如我們將在本章中看到的那樣,批處理是構建可靠、可伸縮和可維護應用程式的重要組成部分。例如,2004 年釋出的批處理演算法 Map-Reduce(可能被過分熱情地)被稱為 “造就 Google 大規模可伸縮性的演算法”【2】。隨後在各種開源資料系統中得到應用,包括 Hadoop,CouchDB 和 MongoDB。
|
||||
|
||||
與多年前為資料倉庫開發的並行處理系統【3,4】相比,MapReduce是一個相當低級別的程式設計模型,但它使得在商用硬體上能進行的處理規模邁上一個新的臺階。雖然MapReduce的重要性正在下降【5】,但它仍然值得去理解,因為它描繪了一幅關於批處理為什麼有用,以及如何做到有用的清晰圖景。
|
||||
與多年前為資料倉庫開發的並行處理系統【3,4】相比,MapReduce 是一個相當低級別的程式設計模型,但它使得在商用硬體上能進行的處理規模邁上一個新的臺階。雖然 MapReduce 的重要性正在下降【5】,但它仍然值得去理解,因為它描繪了一幅關於批處理為什麼有用,以及如何做到有用的清晰圖景。
|
||||
|
||||
實際上,批處理是一種非常古老的計算方式。早在可程式設計數字計算機誕生之前,打孔卡製表機(例如1890年美國人口普查【6】中使用的霍爾里斯機)實現了半機械化的批處理形式,從大量輸入中彙總計算。 Map-Reduce與1940年代和1950年代廣泛用於商業資料處理的機電IBM卡片分類機器有著驚人的相似之處【7】。正如我們所說,歷史總是在不斷重複自己。
|
||||
實際上,批處理是一種非常古老的計算方式。早在可程式設計數字計算機誕生之前,打孔卡製表機(例如 1890 年美國人口普查【6】中使用的霍爾里斯機)實現了半機械化的批處理形式,從大量輸入中彙總計算。 Map-Reduce 與 1940 年代和 1950 年代廣泛用於商業資料處理的機電 IBM 卡片分類機器有著驚人的相似之處【7】。正如我們所說,歷史總是在不斷重複自己。
|
||||
|
||||
在本章中,我們將瞭解MapReduce和其他一些批處理演算法和框架,並探索它們在現代資料系統中的作用。但首先我們將看看使用標準Unix工具的資料處理。即使你已經熟悉了它們,Unix的哲學也值得一讀,Unix的思想和經驗教訓可以遷移到大規模、異構的分散式資料系統中。
|
||||
在本章中,我們將瞭解 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"
|
||||
@ -54,14 +54,14 @@ AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 Safari/537.36"
|
||||
$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 年 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/` 的頁面中的引用導致該檔案被載入。
|
||||
|
||||
|
||||
### 簡單日誌分析
|
||||
|
||||
很多工具可以從這些日誌檔案生成關於網站流量的漂亮的報告,但為了練手,讓我們使用基本的Unix功能建立自己的工具。 例如,假設你想在你的網站上找到五個最受歡迎的網頁。 則可以在Unix shell中這樣做:[^i]
|
||||
很多工具可以從這些日誌檔案生成關於網站流量的漂亮的報告,但為了練手,讓我們使用基本的 Unix 功能建立自己的工具。 例如,假設你想在你的網站上找到五個最受歡迎的網頁。 則可以在 Unix shell 中這樣做:[^i]
|
||||
|
||||
[^i]: 有些人認為`cat`這裡並沒有必要,因為輸入檔案可以直接作為awk的引數。 但這種寫法讓線性管道更為顯眼。
|
||||
[^i]: 有些人認為 `cat` 這裡並沒有必要,因為輸入檔案可以直接作為 awk 的引數。 但這種寫法讓線性管道更為顯眼。
|
||||
|
||||
```bash
|
||||
cat /var/log/nginx/access.log | #1
|
||||
@ -73,10 +73,10 @@ cat /var/log/nginx/access.log | #1
|
||||
```
|
||||
|
||||
1. 讀取日誌檔案
|
||||
2. 將每一行按空格分割成不同的欄位,每行只輸出第七個欄位,恰好是請求的URL。在我們的例子中是`/css/typography.css`。
|
||||
3. 按字母順序排列請求的URL列表。如果某個URL被請求過n次,那麼排序後,檔案將包含連續重複出現n次的該URL。
|
||||
4. `uniq`命令透過檢查兩個相鄰的行是否相同來過濾掉輸入中的重複行。 `-c`則表示還要輸出一個計數器:對於每個不同的URL,它會報告輸入中出現該URL的次數。
|
||||
5. 第二種排序按每行起始處的數字(`-n`)排序,這是URL的請求次數。然後逆序(`-r`)返回結果,大的數字在前。
|
||||
2. 將每一行按空格分割成不同的欄位,每行只輸出第七個欄位,恰好是請求的 URL。在我們的例子中是 `/css/typography.css`。
|
||||
3. 按字母順序排列請求的 URL 列表。如果某個 URL 被請求過 n 次,那麼排序後,檔案將包含連續重複出現 n 次的該 URL。
|
||||
4. `uniq` 命令透過檢查兩個相鄰的行是否相同來過濾掉輸入中的重複行。 `-c` 則表示還要輸出一個計數器:對於每個不同的 URL,它會報告輸入中出現該 URL 的次數。
|
||||
5. 第二種排序按每行起始處的數字(`-n`)排序,這是 URL 的請求次數。然後逆序(`-r`)返回結果,大的數字在前。
|
||||
6. 最後,只輸出前五行(`-n 5`),並丟棄其餘的。該系列命令的輸出如下所示:
|
||||
|
||||
```
|
||||
@ -87,13 +87,13 @@ cat /var/log/nginx/access.log | #1
|
||||
915 /css/typography.css
|
||||
```
|
||||
|
||||
如果你不熟悉Unix工具,上面的命令列可能看起來有點吃力,但是它非常強大。它能在幾秒鐘內處理幾GB的日誌檔案,並且你可以根據需要輕鬆修改命令。例如,如果要從報告中省略CSS檔案,可以將awk引數更改為`'$7 !~ /\.css$/ {print $7}'`,如果想統計最多的客戶端IP地址,可以把awk引數改為`'{print $1}'`等等。
|
||||
如果你不熟悉 Unix 工具,上面的命令列可能看起來有點吃力,但是它非常強大。它能在幾秒鐘內處理幾 GB 的日誌檔案,並且你可以根據需要輕鬆修改命令。例如,如果要從報告中省略 CSS 檔案,可以將 awk 引數更改為 `'$7 !~ /\.css$/ {print $7}'`, 如果想統計最多的客戶端 IP 地址,可以把 awk 引數改為 `'{print $1}'` 等等。
|
||||
|
||||
我們不會在這裡詳細探索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
|
||||
@ -108,480 +108,480 @@ top5 = counts.map{|url, count| [count, url] }.sort.reverse[0...5] # 4
|
||||
top5.each{|count, url| puts "#{count} #{url}" } # 5
|
||||
```
|
||||
|
||||
1. `counts`是一個儲存計數器的雜湊表,儲存了每個URL被瀏覽的次數,預設為0。
|
||||
2. 逐行讀取日誌,抽取每行第七個被空格分隔的欄位為URL(這裡的陣列索引是6,因為Ruby的陣列索引從0開始計數)
|
||||
3. 將日誌當前行中URL對應的計數器值加一。
|
||||
1. `counts` 是一個儲存計數器的雜湊表,儲存了每個 URL 被瀏覽的次數,預設為 0。
|
||||
2. 逐行讀取日誌,抽取每行第七個被空格分隔的欄位為 URL(這裡的陣列索引是 6,因為 Ruby 的陣列索引從 0 開始計數)
|
||||
3. 將日誌當前行中 URL 對應的計數器值加一。
|
||||
4. 按計數器值(降序)對雜湊表內容進行排序,並取前五位。
|
||||
5. 打印出前五個條目。
|
||||
|
||||
這個程式並不像Unix管道那樣簡潔,但是它的可讀性很強,喜歡哪一種屬於口味的問題。但兩者除了表面上的差異之外,執行流程也有很大差異,如果你在大檔案上執行此分析,則會變得明顯。
|
||||
這個程式並不像 Unix 管道那樣簡潔,但是它的可讀性很強,喜歡哪一種屬於口味的問題。但兩者除了表面上的差異之外,執行流程也有很大差異,如果你在大檔案上執行此分析,則會變得明顯。
|
||||
|
||||
#### 排序 VS 記憶體中的聚合
|
||||
|
||||
Ruby指令碼在記憶體中儲存了一個URL的雜湊表,將每個URL對映到它出現的次數。 Unix管道沒有這樣的雜湊表,而是依賴於對URL列表的排序,在這個URL列表中,同一個URL的只是簡單地重複出現。
|
||||
Ruby 指令碼在記憶體中儲存了一個 URL 的雜湊表,將每個 URL 對映到它出現的次數。 Unix 管道沒有這樣的雜湊表,而是依賴於對 URL 列表的排序,在這個 URL 列表中,同一個 URL 的只是簡單地重複出現。
|
||||
|
||||
哪種方法更好?這取決於你有多少個不同的URL。對於大多數中小型網站,你可能可以為所有不同網址提供一個計數器(假設我們使用1GB記憶體)。在此例中,作業的**工作集**(working set,即作業需要隨機訪問的記憶體大小)僅取決於不同URL的數量:如果日誌中只有單個URL,重複出現一百萬次,則散列表所需的空間表就只有一個URL加上一個計數器的大小。當工作集足夠小時,記憶體散列表表現良好,甚至在效能較差的膝上型電腦上也可以正常工作。
|
||||
哪種方法更好?這取決於你有多少個不同的 URL。對於大多數中小型網站,你可能可以為所有不同網址提供一個計數器(假設我們使用 1GB 記憶體)。在此例中,作業的 **工作集**(working set,即作業需要隨機訪問的記憶體大小)僅取決於不同 URL 的數量:如果日誌中只有單個 URL,重複出現一百萬次,則散列表所需的空間表就只有一個 URL 加上一個計數器的大小。當工作集足夠小時,記憶體散列表表現良好,甚至在效能較差的膝上型電腦上也可以正常工作。
|
||||
|
||||
另一方面,如果作業的工作集大於可用記憶體,則排序方法的優點是可以高效地使用磁碟。這與我們在“[SSTables和LSM樹](ch3.md#SSTables和LSM樹)”中討論過的原理是一樣的:資料塊可以在記憶體中排序並作為段檔案寫入磁碟,然後多個排序好的段可以合併為一個更大的排序檔案。 歸併排序具有在磁碟上執行良好的順序訪問模式。 (請記住,針對順序I/O進行最佳化是[第三章](ch3.md)中反覆出現的主題,相同的模式在此重現)
|
||||
另一方面,如果作業的工作集大於可用記憶體,則排序方法的優點是可以高效地使用磁碟。這與我們在 “[SSTables 和 LSM 樹](ch3.md#SSTables和LSM樹)” 中討論過的原理是一樣的:資料塊可以在記憶體中排序並作為段檔案寫入磁碟,然後多個排序好的段可以合併為一個更大的排序檔案。 歸併排序具有在磁碟上執行良好的順序訪問模式。 (請記住,針對順序 I/O 進行最佳化是 [第三章](ch3.md) 中反覆出現的主題,相同的模式在此重現)
|
||||
|
||||
GNU Coreutils(Linux)中的`sort `程式透過溢位至磁碟的方式來自動應對大於記憶體的資料集,並能同時使用多個CPU核進行並行排序【9】。這意味著我們之前看到的簡單的Unix命令鏈很容易伸縮至大資料集,且不會耗盡記憶體。瓶頸可能是從磁碟讀取輸入檔案的速度。
|
||||
GNU Coreutils(Linux)中的 `sort` 程式透過溢位至磁碟的方式來自動應對大於記憶體的資料集,並能同時使用多個 CPU 核進行並行排序【9】。這意味著我們之前看到的簡單的 Unix 命令鏈很容易伸縮至大資料集,且不會耗盡記憶體。瓶頸可能是從磁碟讀取輸入檔案的速度。
|
||||
|
||||
|
||||
### Unix哲學
|
||||
|
||||
我們可以非常容易地使用前一個例子中的一系列命令來分析日誌檔案,這並非巧合:事實上,這實際上是Unix的關鍵設計思想之一,而且它直至今天也仍然令人訝異地重要。讓我們更深入地研究一下,以便從Unix中借鑑一些想法【10】。
|
||||
我們可以非常容易地使用前一個例子中的一系列命令來分析日誌檔案,這並非巧合:事實上,這實際上是 Unix 的關鍵設計思想之一,而且它直至今天也仍然令人訝異地重要。讓我們更深入地研究一下,以便從 Unix 中借鑑一些想法【10】。
|
||||
|
||||
Unix管道的發明者道格·麥克羅伊(Doug McIlroy)在1964年首先描述了這種情況【11】:“我們需要一種類似園藝膠管的方式來拼接程式 —— 當我們需要將訊息從一個程式傳遞另一個程式時,直接接上去就行。I/O應該也按照這種方式進行“。水管的類比仍然在生效,透過管道連線程式的想法成為了現在被稱為**Unix哲學**的一部分 —— 這一組設計原則在Unix使用者與開發者之間流行起來,該哲學在1978年表述如下【12,13】:
|
||||
Unix 管道的發明者道格・麥克羅伊(Doug McIlroy)在 1964 年首先描述了這種情況【11】:“我們需要一種類似園藝膠管的方式來拼接程式 —— 當我們需要將訊息從一個程式傳遞另一個程式時,直接接上去就行。I/O 應該也按照這種方式進行 “。水管的類比仍然在生效,透過管道連線程式的想法成為了現在被稱為 **Unix 哲學** 的一部分 —— 這一組設計原則在 Unix 使用者與開發者之間流行起來,該哲學在 1978 年表述如下【12,13】:
|
||||
|
||||
1. 讓每個程式都做好一件事。要做一件新的工作,寫一個新程式,而不是透過新增“功能”讓老程式複雜化。
|
||||
1. 讓每個程式都做好一件事。要做一件新的工作,寫一個新程式,而不是透過新增 “功能” 讓老程式複雜化。
|
||||
2. 期待每個程式的輸出成為另一個程式的輸入。不要將無關資訊混入輸出。避免使用嚴格的列資料或二進位制輸入格式。不要堅持互動式輸入。
|
||||
3. 設計和構建軟體時,即使是作業系統,也讓它們能夠儘早地被試用,最好在幾周內完成。不要猶豫,扔掉笨拙的部分,重建它們。
|
||||
4. 優先使用工具來減輕程式設計任務,即使必須曲線救國編寫工具,且在用完後很可能要扔掉大部分。
|
||||
|
||||
這種方法 —— 自動化,快速原型設計,增量式迭代,對實驗友好,將大型專案分解成可管理的塊 —— 聽起來非常像今天的敏捷開發和DevOps運動。奇怪的是,四十年來變化不大。
|
||||
這種方法 —— 自動化,快速原型設計,增量式迭代,對實驗友好,將大型專案分解成可管理的塊 —— 聽起來非常像今天的敏捷開發和 DevOps 運動。奇怪的是,四十年來變化不大。
|
||||
|
||||
`sort`工具是一個很好的例子。可以說它比大多數程式語言標準庫中的實現(它們不會利用磁碟或使用多執行緒,即使這樣做有很大好處)要更好。然而,單獨使用`sort` 幾乎沒什麼用。它只能與其他Unix工具(如`uniq`)結合使用。
|
||||
`sort` 工具是一個很好的例子。可以說它比大多數程式語言標準庫中的實現(它們不會利用磁碟或使用多執行緒,即使這樣做有很大好處)要更好。然而,單獨使用 `sort` 幾乎沒什麼用。它只能與其他 Unix 工具(如 `uniq`)結合使用。
|
||||
|
||||
像 `bash`這樣的Unix shell可以讓我們輕鬆地將這些小程式組合成令人訝異的強大資料處理任務。儘管這些程式中有很多是由不同人群編寫的,但它們可以靈活地結合在一起。 Unix如何實現這種可組合性?
|
||||
像 `bash` 這樣的 Unix shell 可以讓我們輕鬆地將這些小程式組合成令人訝異的強大資料處理任務。儘管這些程式中有很多是由不同人群編寫的,但它們可以靈活地結合在一起。 Unix 如何實現這種可組合性?
|
||||
|
||||
#### 統一的介面
|
||||
|
||||
如果你希望一個程式的輸出成為另一個程式的輸入,那意味著這些程式必須使用相同的資料格式 —— 換句話說,一個相容的介面。如果你希望能夠將任何程式的輸出連線到任何程式的輸入,那意味著所有程式必須使用相同的I/O介面。
|
||||
如果你希望一個程式的輸出成為另一個程式的輸入,那意味著這些程式必須使用相同的資料格式 —— 換句話說,一個相容的介面。如果你希望能夠將任何程式的輸出連線到任何程式的輸入,那意味著所有程式必須使用相同的 I/O 介面。
|
||||
|
||||
在Unix中,這種介面是一個**檔案**(file,更準確地說,是一個檔案描述符)。一個檔案只是一串有序的位元組序列。因為這是一個非常簡單的介面,所以可以使用相同的介面來表示許多不同的東西:檔案系統上的真實檔案,到另一個程序(Unix套接字,stdin,stdout)的通訊通道,裝置驅動程式(比如`/dev/audio`或`/dev/lp0`),表示TCP連線的套接字等等。很容易將這些設計視為理所當然的,但實際上能讓這些差異巨大的東西共享一個統一的介面是非常厲害的,這使得它們可以很容易地連線在一起[^ii]。
|
||||
在 Unix 中,這種介面是一個 **檔案**(file,更準確地說,是一個檔案描述符)。一個檔案只是一串有序的位元組序列。因為這是一個非常簡單的介面,所以可以使用相同的介面來表示許多不同的東西:檔案系統上的真實檔案,到另一個程序(Unix 套接字,stdin,stdout)的通訊通道,裝置驅動程式(比如 `/dev/audio` 或 `/dev/lp0`),表示 TCP 連線的套接字等等。很容易將這些設計視為理所當然的,但實際上能讓這些差異巨大的東西共享一個統一的介面是非常厲害的,這使得它們可以很容易地連線在一起 [^ii]。
|
||||
|
||||
[^ii]: 統一介面的另一個例子是URL和HTTP,這是Web的基石。 一個URL標識一個網站上的一個特定的東西(資源),你可以連結到任何其他網站的任何網址。 具有網路瀏覽器的使用者因此可以透過跟隨連結在網站之間無縫跳轉,即使伺服器可能由完全不相關的組織維護。 這個原則現在似乎非常明顯,但它卻是網路取能取得今天成就的關鍵。 之前的系統並不是那麼統一:例如,在公告板系統(BBS)時代,每個系統都有自己的電話號碼和波特率配置。 從一個BBS到另一個BBS的引用必須以電話號碼和調變解調器設定的形式;使用者將不得不掛斷,撥打其他BBS,然後手動找到他們正在尋找的資訊。 直接連結到另一個BBS內的一些內容當時是不可能的。
|
||||
[^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 工具一樣流暢地執行程式是一種例外,而不是規範。
|
||||
|
||||
即使是具有**相同資料模型**的資料庫,將資料從一種資料庫匯出再匯入到另一種資料庫也並不容易。缺乏整合導致了資料的**巴爾幹化**[^譯註i]。
|
||||
即使是具有 **相同資料模型** 的資料庫,將資料從一種資料庫匯出再匯入到另一種資料庫也並不容易。缺乏整合導致了資料的 **巴爾幹化**[^譯註i]。
|
||||
|
||||
[^譯註i]: **巴爾幹化(Balkanization)** 是一個常帶有貶義的地緣政治學術語,其定義為:一個國家或政區分裂成多個互相敵對的國家或政區的過程。
|
||||
|
||||
|
||||
#### 邏輯與佈線相分離
|
||||
|
||||
Unix工具的另一個特點是使用標準輸入(`stdin`)和標準輸出(`stdout`)。如果你執行一個程式,而不指定任何其他的東西,標準輸入來自鍵盤,標準輸出指向螢幕。但是,你也可以從檔案輸入和/或將輸出重定向到檔案。管道允許你將一個程序的標準輸出附加到另一個程序的標準輸入(有個小記憶體緩衝區,而不需要將整個中間資料流寫入磁碟)。
|
||||
Unix 工具的另一個特點是使用標準輸入(`stdin`)和標準輸出(`stdout`)。如果你執行一個程式,而不指定任何其他的東西,標準輸入來自鍵盤,標準輸出指向螢幕。但是,你也可以從檔案輸入和 / 或將輸出重定向到檔案。管道允許你將一個程序的標準輸出附加到另一個程序的標準輸入(有個小記憶體緩衝區,而不需要將整個中間資料流寫入磁碟)。
|
||||
|
||||
如果需要,程式仍然可以直接讀取和寫入檔案,但Unix方法在程式不關心特定的檔案路徑、只使用標準輸入和標準輸出時效果最好。這允許shell使用者以任何他們想要的方式連線輸入和輸出;該程式不知道或不關心輸入來自哪裡以及輸出到哪裡。 (人們可以說這是一種**松耦合(loose coupling)**,**晚期繫結(late binding)**【15】或**控制反轉(inversion of control)**【16】)。將輸入/輸出佈線與程式邏輯分開,可以將小工具組合成更大的系統。
|
||||
如果需要,程式仍然可以直接讀取和寫入檔案,但 Unix 方法在程式不關心特定的檔案路徑、只使用標準輸入和標準輸出時效果最好。這允許 shell 使用者以任何他們想要的方式連線輸入和輸出;該程式不知道或不關心輸入來自哪裡以及輸出到哪裡。 (人們可以說這是一種 **松耦合(loose coupling)**,**晚期繫結(late binding)**【15】或 **控制反轉(inversion of control)**【16】)。將輸入 / 輸出佈線與程式邏輯分開,可以將小工具組合成更大的系統。
|
||||
|
||||
你甚至可以編寫自己的程式,並將它們與作業系統提供的工具組合在一起。你的程式只需要從標準輸入讀取輸入,並將輸出寫入標準輸出,它就可以加入資料處理的管道中。在日誌分析示例中,你可以編寫一個將Usage-Agent字串轉換為更靈敏的瀏覽器識別符號,或者將IP地址轉換為國家程式碼的工具,並將其插入管道。`sort`程式並不關心它是否與作業系統的另一部分或者你寫的程式通訊。
|
||||
你甚至可以編寫自己的程式,並將它們與作業系統提供的工具組合在一起。你的程式只需要從標準輸入讀取輸入,並將輸出寫入標準輸出,它就可以加入資料處理的管道中。在日誌分析示例中,你可以編寫一個將 Usage-Agent 字串轉換為更靈敏的瀏覽器識別符號,或者將 IP 地址轉換為國家程式碼的工具,並將其插入管道。`sort` 程式並不關心它是否與作業系統的另一部分或者你寫的程式通訊。
|
||||
|
||||
但是,使用`stdin`和`stdout`能做的事情是有限的。需要多個輸入或輸出的程式雖然可能,卻非常棘手。你沒法將程式的輸出管道連線至網路連線中【17,18】[^iii] 。如果程式直接開啟檔案進行讀取和寫入,或者將另一個程式作為子程序啟動,或者開啟網路連線,那麼I/O的佈線就取決於程式本身了。它仍然可以被配置(例如透過命令列選項),但在Shell中對輸入和輸出進行佈線的靈活性就少了。
|
||||
但是,使用 `stdin` 和 `stdout` 能做的事情是有限的。需要多個輸入或輸出的程式雖然可能,卻非常棘手。你沒法將程式的輸出管道連線至網路連線中【17,18】[^iii] 。如果程式直接開啟檔案進行讀取和寫入,或者將另一個程式作為子程序啟動,或者開啟網路連線,那麼 I/O 的佈線就取決於程式本身了。它仍然可以被配置(例如透過命令列選項),但在 Shell 中對輸入和輸出進行佈線的靈活性就少了。
|
||||
|
||||
[^iii]: 除了使用一個單獨的工具,如`netcat`或`curl`。 Unix起初試圖將所有東西都表示為檔案,但是BSD套接字API偏離了這個慣例【17】。研究用作業系統Plan 9和Inferno在使用檔案方面更加一致:它們將TCP連線表示為`/net/tcp`中的檔案【18】。
|
||||
[^iii]: 除了使用一個單獨的工具,如 `netcat` 或 `curl`。 Unix 起初試圖將所有東西都表示為檔案,但是 BSD 套接字 API 偏離了這個慣例【17】。研究用作業系統 Plan 9 和 Inferno 在使用檔案方面更加一致:它們將 TCP 連線表示為 `/net/tcp` 中的檔案【18】。
|
||||
|
||||
|
||||
#### 透明度和實驗
|
||||
|
||||
使Unix工具如此成功的部分原因是,它們使檢視正在發生的事情變得非常容易:
|
||||
使 Unix 工具如此成功的部分原因是,它們使檢視正在發生的事情變得非常容易:
|
||||
|
||||
- Unix命令的輸入檔案通常被視為不可變的。這意味著你可以隨意執行命令,嘗試各種命令列選項,而不會損壞輸入檔案。
|
||||
- 你可以在任何時候結束管道,將管道輸出到`less`,然後檢視它是否具有預期的形式。這種檢查能力對除錯非常有用。
|
||||
- Unix 命令的輸入檔案通常被視為不可變的。這意味著你可以隨意執行命令,嘗試各種命令列選項,而不會損壞輸入檔案。
|
||||
- 你可以在任何時候結束管道,將管道輸出到 `less`,然後檢視它是否具有預期的形式。這種檢查能力對除錯非常有用。
|
||||
- 你可以將一個流水線階段的輸出寫入檔案,並將該檔案用作下一階段的輸入。這使你可以重新啟動後面的階段,而無需重新執行整個管道。
|
||||
|
||||
因此,與關係資料庫的查詢最佳化器相比,即使Unix工具非常簡單,但仍然非常有用,特別是對於實驗而言。
|
||||
因此,與關係資料庫的查詢最佳化器相比,即使 Unix 工具非常簡單,但仍然非常有用,特別是對於實驗而言。
|
||||
|
||||
然而,Unix工具的最大侷限在於它們只能在一臺機器上執行 —— 而Hadoop這樣的工具即應運而生。
|
||||
然而,Unix 工具的最大侷限在於它們只能在一臺機器上執行 —— 而 Hadoop 這樣的工具即應運而生。
|
||||
|
||||
|
||||
## MapReduce和分散式檔案系統
|
||||
|
||||
MapReduce有點像Unix工具,但分佈在數千臺機器上。像Unix工具一樣,它相當簡單粗暴,但令人驚異地管用。一個MapReduce作業可以和一個Unix程序相類比:它接受一個或多個輸入,併產生一個或多個輸出。
|
||||
MapReduce 有點像 Unix 工具,但分佈在數千臺機器上。像 Unix 工具一樣,它相當簡單粗暴,但令人驚異地管用。一個 MapReduce 作業可以和一個 Unix 程序相類比:它接受一個或多個輸入,併產生一個或多個輸出。
|
||||
|
||||
和大多數Unix工具一樣,執行MapReduce作業通常不會修改輸入,除了生成輸出外沒有任何副作用。輸出檔案以連續的方式一次性寫入(一旦寫入檔案,不會修改任何現有的檔案部分)。
|
||||
和大多數 Unix 工具一樣,執行 MapReduce 作業通常不會修改輸入,除了生成輸出外沒有任何副作用。輸出檔案以連續的方式一次性寫入(一旦寫入檔案,不會修改任何現有的檔案部分)。
|
||||
|
||||
雖然Unix工具使用`stdin`和`stdout`作為輸入和輸出,但MapReduce作業在分散式檔案系統上讀寫檔案。在Hadoop的MapReduce實現中,該檔案系統被稱為**HDFS(Hadoop分散式檔案系統)**,一個Google檔案系統(GFS)的開源實現【19】。
|
||||
雖然 Unix 工具使用 `stdin` 和 `stdout` 作為輸入和輸出,但 MapReduce 作業在分散式檔案系統上讀寫檔案。在 Hadoop 的 MapReduce 實現中,該檔案系統被稱為 **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,可以將計算任務安排在儲存特定檔案副本的計算機上執行,而物件儲存通常將儲存和計算分開。如果網路頻寬是一個瓶頸,從本地磁碟讀取有效能優勢。但是請注意,如果使用糾刪碼(Erasure Coding),則會丟失區域性性,因為來自多臺機器的資料必須進行合併以重建原始檔案【20】。
|
||||
[^iv]: 一個不同之處在於,對於 HDFS,可以將計算任務安排在儲存特定檔案副本的計算機上執行,而物件儲存通常將儲存和計算分開。如果網路頻寬是一個瓶頸,從本地磁碟讀取有效能優勢。但是請注意,如果使用糾刪碼(Erasure Coding),則會丟失區域性性,因為來自多臺機器的資料必須進行合併以重建原始檔案【20】。
|
||||
|
||||
與網路連線儲存(NAS)和儲存區域網路(SAN)架構的共享磁碟方法相比,HDFS基於**無共享**原則(請參閱[第二部分](part-ii.md)的介紹)。共享磁碟儲存由集中式儲存裝置實現,通常使用定製硬體和專用網路基礎設施(如光纖通道)。而另一方面,無共享方法不需要特殊的硬體,只需要透過傳統資料中心網路連線的計算機。
|
||||
與網路連線儲存(NAS)和儲存區域網路(SAN)架構的共享磁碟方法相比,HDFS 基於 **無共享** 原則(請參閱 [第二部分](part-ii.md) 的介紹)。共享磁碟儲存由集中式儲存裝置實現,通常使用定製硬體和專用網路基礎設施(如光纖通道)。而另一方面,無共享方法不需要特殊的硬體,只需要透過傳統資料中心網路連線的計算機。
|
||||
|
||||
HDFS在每臺機器上運行了一個守護程序,它對外暴露網路服務,允許其他節點訪問儲存在該機器上的檔案(假設資料中心中的每臺通用計算機都掛載著一些磁碟)。名為**NameNode**的中央伺服器會跟蹤哪個檔案塊儲存在哪臺機器上。因此,HDFS在概念上建立了一個大型檔案系統,可以使用所有執行有守護程序的機器的磁碟。
|
||||
HDFS 在每臺機器上運行了一個守護程序,它對外暴露網路服務,允許其他節點訪問儲存在該機器上的檔案(假設資料中心中的每臺通用計算機都掛載著一些磁碟)。名為 **NameNode** 的中央伺服器會跟蹤哪個檔案塊儲存在哪臺機器上。因此,HDFS 在概念上建立了一個大型檔案系統,可以使用所有執行有守護程序的機器的磁碟。
|
||||
|
||||
為了容忍機器和磁碟故障,檔案塊被複制到多臺機器上。複製可能意味著多個機器上的相同資料的多個副本,如[第五章](ch5.md)中所述,或者諸如Reed-Solomon碼這樣的糾刪碼方案,它能以比完全複製更低的儲存開銷來支援恢復丟失的資料【20,22】。這些技術與RAID相似,後者可以在連線到同一臺機器的多個磁碟上提供冗餘;區別在於在分散式檔案系統中,檔案訪問和複製是在傳統的資料中心網路上完成的,沒有特殊的硬體。
|
||||
為了容忍機器和磁碟故障,檔案塊被複制到多臺機器上。複製可能意味著多個機器上的相同資料的多個副本,如 [第五章](ch5.md) 中所述,或者諸如 Reed-Solomon 碼這樣的糾刪碼方案,它能以比完全複製更低的儲存開銷來支援恢復丟失的資料【20,22】。這些技術與 RAID 相似,後者可以在連線到同一臺機器的多個磁碟上提供冗餘;區別在於在分散式檔案系統中,檔案訪問和複製是在傳統的資料中心網路上完成的,沒有特殊的硬體。
|
||||
|
||||
HDFS的可伸縮性已經很不錯了:在撰寫本書時,最大的HDFS部署執行在上萬臺機器上,總儲存容量達數百PB【23】。如此大的規模已經變得可行,因為使用商品硬體和開源軟體的HDFS上的資料儲存和訪問成本遠低於在專用儲存裝置上支援同等容量的成本【24】。
|
||||
HDFS 的可伸縮性已經很不錯了:在撰寫本書時,最大的 HDFS 部署執行在上萬臺機器上,總儲存容量達數百 PB【23】。如此大的規模已經變得可行,因為使用商品硬體和開源軟體的 HDFS 上的資料儲存和訪問成本遠低於在專用儲存裝置上支援同等容量的成本【24】。
|
||||
|
||||
### MapReduce作業執行
|
||||
|
||||
MapReduce是一個程式設計框架,你可以使用它編寫程式碼來處理HDFS等分散式檔案系統中的大型資料集。理解它的最簡單方法是參考“[簡單日誌分析](#簡單日誌分析)”中的Web伺服器日誌分析示例。MapReduce中的資料處理模式與此示例非常相似:
|
||||
MapReduce 是一個程式設計框架,你可以使用它編寫程式碼來處理 HDFS 等分散式檔案系統中的大型資料集。理解它的最簡單方法是參考 “[簡單日誌分析](#簡單日誌分析)” 中的 Web 伺服器日誌分析示例。MapReduce 中的資料處理模式與此示例非常相似:
|
||||
|
||||
1. 讀取一組輸入檔案,並將其分解成**記錄(records)**。在Web伺服器日誌示例中,每條記錄都是日誌中的一行(即`\n`是記錄分隔符)。
|
||||
2. 呼叫Mapper函式,從每條輸入記錄中提取一對鍵值。在前面的例子中,Mapper函式是`awk '{print $7}'`:它提取URL(`$7`)作為鍵,並將值留空。
|
||||
3. 按鍵排序所有的鍵值對。在日誌的例子中,這由第一個`sort`命令完成。
|
||||
4. 呼叫Reducer函式遍歷排序後的鍵值對。如果同一個鍵出現多次,排序使它們在列表中相鄰,所以很容易組合這些值而不必在記憶體中保留很多狀態。在前面的例子中,Reducer是由`uniq -c`命令實現的,該命令使用相同的鍵來統計相鄰記錄的數量。
|
||||
1. 讀取一組輸入檔案,並將其分解成 **記錄(records)**。在 Web 伺服器日誌示例中,每條記錄都是日誌中的一行(即 `\n` 是記錄分隔符)。
|
||||
2. 呼叫 Mapper 函式,從每條輸入記錄中提取一對鍵值。在前面的例子中,Mapper 函式是 `awk '{print $7}'`:它提取 URL(`$7`)作為鍵,並將值留空。
|
||||
3. 按鍵排序所有的鍵值對。在日誌的例子中,這由第一個 `sort` 命令完成。
|
||||
4. 呼叫 Reducer 函式遍歷排序後的鍵值對。如果同一個鍵出現多次,排序使它們在列表中相鄰,所以很容易組合這些值而不必在記憶體中保留很多狀態。在前面的例子中,Reducer 是由 `uniq -c` 命令實現的,該命令使用相同的鍵來統計相鄰記錄的數量。
|
||||
|
||||
這四個步驟可以作為一個MapReduce作業執行。步驟2(Map)和4(Reduce)是你編寫自定義資料處理程式碼的地方。步驟1(將檔案分解成記錄)由輸入格式解析器處理。步驟3中的排序步驟隱含在MapReduce中 —— 你不必編寫它,因為Mapper的輸出始終在送往Reducer之前進行排序。
|
||||
這四個步驟可以作為一個 MapReduce 作業執行。步驟 2(Map)和 4(Reduce)是你編寫自定義資料處理程式碼的地方。步驟 1(將檔案分解成記錄)由輸入格式解析器處理。步驟 3 中的排序步驟隱含在 MapReduce 中 —— 你不必編寫它,因為 Mapper 的輸出始終在送往 Reducer 之前進行排序。
|
||||
|
||||
要建立MapReduce作業,你需要實現兩個回撥函式,Mapper和Reducer,其行為如下(請參閱“[MapReduce 查詢](ch2.md#MapReduce查詢)”):
|
||||
要建立 MapReduce 作業,你需要實現兩個回撥函式,Mapper 和 Reducer,其行為如下(請參閱 “[MapReduce 查詢](ch2.md#MapReduce查詢)”):
|
||||
|
||||
* Mapper
|
||||
|
||||
Mapper會在每條輸入記錄上呼叫一次,其工作是從輸入記錄中提取鍵值。對於每個輸入,它可以生成任意數量的鍵值對(包括None)。它不會保留從一個輸入記錄到下一個記錄的任何狀態,因此每個記錄都是獨立處理的。
|
||||
Mapper 會在每條輸入記錄上呼叫一次,其工作是從輸入記錄中提取鍵值。對於每個輸入,它可以生成任意數量的鍵值對(包括 None)。它不會保留從一個輸入記錄到下一個記錄的任何狀態,因此每個記錄都是獨立處理的。
|
||||
|
||||
* Reducer
|
||||
|
||||
MapReduce框架拉取由Mapper生成的鍵值對,收集屬於同一個鍵的所有值,並在這組值上迭代呼叫Reducer。 Reducer可以產生輸出記錄(例如相同URL的出現次數)。
|
||||
MapReduce 框架拉取由 Mapper 生成的鍵值對,收集屬於同一個鍵的所有值,並在這組值上迭代呼叫 Reducer。 Reducer 可以產生輸出記錄(例如相同 URL 的出現次數)。
|
||||
|
||||
在Web伺服器日誌的例子中,我們在第5步中有第二個`sort`命令,它按請求數對URL進行排序。在MapReduce中,如果你需要第二個排序階段,則可以透過編寫第二個MapReduce作業並將第一個作業的輸出用作第二個作業的輸入來實現它。這樣看來,Mapper的作用是將資料放入一個適合排序的表單中,並且Reducer的作用是處理已排序的資料。
|
||||
在 Web 伺服器日誌的例子中,我們在第 5 步中有第二個 `sort` 命令,它按請求數對 URL 進行排序。在 MapReduce 中,如果你需要第二個排序階段,則可以透過編寫第二個 MapReduce 作業並將第一個作業的輸出用作第二個作業的輸入來實現它。這樣看來,Mapper 的作用是將資料放入一個適合排序的表單中,並且 Reducer 的作用是處理已排序的資料。
|
||||
|
||||
#### 分散式執行MapReduce
|
||||
|
||||
MapReduce與Unix命令管道的主要區別在於,MapReduce可以在多臺機器上並行執行計算,而無需編寫程式碼來顯式處理並行問題。Mapper和Reducer一次只能處理一條記錄;它們不需要知道它們的輸入來自哪裡,或者輸出去往什麼地方,所以框架可以處理在機器之間移動資料的複雜性。
|
||||
MapReduce 與 Unix 命令管道的主要區別在於,MapReduce 可以在多臺機器上並行執行計算,而無需編寫程式碼來顯式處理並行問題。Mapper 和 Reducer 一次只能處理一條記錄;它們不需要知道它們的輸入來自哪裡,或者輸出去往什麼地方,所以框架可以處理在機器之間移動資料的複雜性。
|
||||
|
||||
在分散式計算中可以使用標準的Unix工具作為Mapper和Reducer【25】,但更常見的是,它們被實現為傳統程式語言的函式。在Hadoop MapReduce中,Mapper和Reducer都是實現特定介面的Java類。在MongoDB和CouchDB中,Mapper和Reducer都是JavaScript函式(請參閱“[MapReduce 查詢](ch2.md#MapReduce查詢)”)。
|
||||
在分散式計算中可以使用標準的 Unix 工具作為 Mapper 和 Reducer【25】,但更常見的是,它們被實現為傳統程式語言的函式。在 Hadoop MapReduce 中,Mapper 和 Reducer 都是實現特定介面的 Java 類。在 MongoDB 和 CouchDB 中,Mapper 和 Reducer 都是 JavaScript 函式(請參閱 “[MapReduce 查詢](ch2.md#MapReduce查詢)”)。
|
||||
|
||||
[圖10-1](../img/fig10-1.png)顯示了Hadoop MapReduce作業中的資料流。其並行化基於分割槽(請參閱[第六章](ch6.md)):作業的輸入通常是HDFS中的一個目錄,輸入目錄中的每個檔案或檔案塊都被認為是一個單獨的分割槽,可以單獨處理map任務([圖10-1](../img/fig10-1.png)中的m1,m2和m3標記)。
|
||||
[圖 10-1](../img/fig10-1.png) 顯示了 Hadoop MapReduce 作業中的資料流。其並行化基於分割槽(請參閱 [第六章](ch6.md)):作業的輸入通常是 HDFS 中的一個目錄,輸入目錄中的每個檔案或檔案塊都被認為是一個單獨的分割槽,可以單獨處理 map 任務([圖 10-1](../img/fig10-1.png) 中的 m1,m2 和 m3 標記)。
|
||||
|
||||
每個輸入檔案的大小通常是數百兆位元組。 MapReduce排程器(圖中未顯示)試圖在其中一臺儲存輸入檔案副本的機器上執行每個Mapper,只要該機器有足夠的備用RAM和CPU資源來執行Mapper任務【26】。這個原則被稱為**將計算放在資料附近**【27】:它節省了透過網路複製輸入檔案的開銷,減少網路負載並增加區域性性。
|
||||
每個輸入檔案的大小通常是數百兆位元組。 MapReduce 排程器(圖中未顯示)試圖在其中一臺儲存輸入檔案副本的機器上執行每個 Mapper,只要該機器有足夠的備用 RAM 和 CPU 資源來執行 Mapper 任務【26】。這個原則被稱為 **將計算放在資料附近**【27】:它節省了透過網路複製輸入檔案的開銷,減少網路負載並增加區域性性。
|
||||
|
||||
![](../img/fig10-1.png)
|
||||
|
||||
**圖10-1 具有三個Mapper和三個Reducer的MapReduce任務**
|
||||
**圖 10-1 具有三個 Mapper 和三個 Reducer 的 MapReduce 任務**
|
||||
|
||||
在大多數情況下,應該在Mapper任務中執行的應用程式碼在將要執行它的機器上還不存在,所以MapReduce框架首先將程式碼(例如Java程式中的JAR檔案)複製到適當的機器。然後啟動Map任務並開始讀取輸入檔案,一次將一條記錄傳入Mapper回撥函式。Mapper的輸出由鍵值對組成。
|
||||
在大多數情況下,應該在 Mapper 任務中執行的應用程式碼在將要執行它的機器上還不存在,所以 MapReduce 框架首先將程式碼(例如 Java 程式中的 JAR 檔案)複製到適當的機器。然後啟動 Map 任務並開始讀取輸入檔案,一次將一條記錄傳入 Mapper 回撥函式。Mapper 的輸出由鍵值對組成。
|
||||
|
||||
計算的Reduce端也被分割槽。雖然Map任務的數量由輸入檔案塊的數量決定,但Reducer的任務的數量是由作業作者配置的(它可以不同於Map任務的數量)。為了確保具有相同鍵的所有鍵值對最終落在相同的Reducer處,框架使用鍵的雜湊值來確定哪個Reduce任務應該接收到特定的鍵值對(請參閱“[根據鍵的雜湊分割槽](ch6.md#根據鍵的雜湊分割槽)”)。
|
||||
計算的 Reduce 端也被分割槽。雖然 Map 任務的數量由輸入檔案塊的數量決定,但 Reducer 的任務的數量是由作業作者配置的(它可以不同於 Map 任務的數量)。為了確保具有相同鍵的所有鍵值對最終落在相同的 Reducer 處,框架使用鍵的雜湊值來確定哪個 Reduce 任務應該接收到特定的鍵值對(請參閱 “[根據鍵的雜湊分割槽](ch6.md#根據鍵的雜湊分割槽)”)。
|
||||
|
||||
鍵值對必須進行排序,但資料集可能太大,無法在單臺機器上使用常規排序演算法進行排序。相反,分類是分階段進行的。首先每個Map任務都按照Reducer對輸出進行分割槽。每個分割槽都被寫入Mapper程式的本地磁碟,使用的技術與我們在“[SSTables與LSM樹](ch3.md#SSTables與LSM樹)”中討論的類似。
|
||||
鍵值對必須進行排序,但資料集可能太大,無法在單臺機器上使用常規排序演算法進行排序。相反,分類是分階段進行的。首先每個 Map 任務都按照 Reducer 對輸出進行分割槽。每個分割槽都被寫入 Mapper 程式的本地磁碟,使用的技術與我們在 “[SSTables 與 LSM 樹](ch3.md#SSTables與LSM樹)” 中討論的類似。
|
||||
|
||||
只要當Mapper讀取完輸入檔案,並寫完排序後的輸出檔案,MapReduce排程器就會通知Reducer可以從該Mapper開始獲取輸出檔案。Reducer連線到每個Mapper,並下載自己相應分割槽的有序鍵值對檔案。按Reducer分割槽,排序,從Mapper向Reducer複製分割槽資料,這一整個過程被稱為**混洗(shuffle)**【26】(一個容易混淆的術語 —— 不像洗牌,在MapReduce中的混洗沒有隨機性)。
|
||||
只要當 Mapper 讀取完輸入檔案,並寫完排序後的輸出檔案,MapReduce 排程器就會通知 Reducer 可以從該 Mapper 開始獲取輸出檔案。Reducer 連線到每個 Mapper,並下載自己相應分割槽的有序鍵值對檔案。按 Reducer 分割槽,排序,從 Mapper 向 Reducer 複製分割槽資料,這一整個過程被稱為 **混洗(shuffle)**【26】(一個容易混淆的術語 —— 不像洗牌,在 MapReduce 中的混洗沒有隨機性)。
|
||||
|
||||
Reduce任務從Mapper獲取檔案,並將它們合併在一起,並保留有序特性。因此,如果不同的Mapper生成了鍵相同的記錄,則在Reducer的輸入中,這些記錄將會相鄰。
|
||||
Reduce 任務從 Mapper 獲取檔案,並將它們合併在一起,並保留有序特性。因此,如果不同的 Mapper 生成了鍵相同的記錄,則在 Reducer 的輸入中,這些記錄將會相鄰。
|
||||
|
||||
Reducer呼叫時會收到一個鍵,和一個迭代器作為引數,迭代器會順序地掃過所有具有該鍵的記錄(因為在某些情況可能無法完全放入記憶體中)。Reducer可以使用任意邏輯來處理這些記錄,並且可以生成任意數量的輸出記錄。這些輸出記錄會寫入分散式檔案系統上的檔案中(通常是在跑Reducer的機器本地磁碟上留一份,並在其他機器上留幾份副本)。
|
||||
Reducer 呼叫時會收到一個鍵,和一個迭代器作為引數,迭代器會順序地掃過所有具有該鍵的記錄(因為在某些情況可能無法完全放入記憶體中)。Reducer 可以使用任意邏輯來處理這些記錄,並且可以生成任意數量的輸出記錄。這些輸出記錄會寫入分散式檔案系統上的檔案中(通常是在跑 Reducer 的機器本地磁碟上留一份,並在其他機器上留幾份副本)。
|
||||
|
||||
#### MapReduce工作流
|
||||
|
||||
單個MapReduce作業可以解決的問題範圍很有限。以日誌分析為例,單個MapReduce作業可以確定每個URL的頁面瀏覽次數,但無法確定最常見的URL,因為這需要第二輪排序。
|
||||
單個 MapReduce 作業可以解決的問題範圍很有限。以日誌分析為例,單個 MapReduce 作業可以確定每個 URL 的頁面瀏覽次數,但無法確定最常見的 URL,因為這需要第二輪排序。
|
||||
|
||||
因此將MapReduce作業連結成為**工作流(workflow)** 中是極為常見的,例如,一個作業的輸出成為下一個作業的輸入。 Hadoop MapReduce框架對工作流沒有特殊支援,所以這個鏈是透過目錄名隱式實現的:第一個作業必須將其輸出配置為HDFS中的指定目錄,第二個作業必須將其輸入配置為從同一個目錄。從MapReduce框架的角度來看,這是兩個獨立的作業。
|
||||
因此將 MapReduce 作業連結成為 **工作流(workflow)** 中是極為常見的,例如,一個作業的輸出成為下一個作業的輸入。Hadoop MapReduce 框架對工作流沒有特殊支援,所以這個鏈是透過目錄名隱式實現的:第一個作業必須將其輸出配置為 HDFS 中的指定目錄,第二個作業必須將其輸入配置為從同一個目錄。從 MapReduce 框架的角度來看,這是兩個獨立的作業。
|
||||
|
||||
因此,被連結的MapReduce作業並沒有那麼像Unix命令管道(它直接將一個程序的輸出作為另一個程序的輸入,僅用一個很小的記憶體緩衝區)。它更像是一系列命令,其中每個命令的輸出寫入臨時檔案,下一個命令從臨時檔案中讀取。這種設計有利也有弊,我們將在“[物化中間狀態](#物化中間狀態)”中討論。
|
||||
因此,被連結的 MapReduce 作業並沒有那麼像 Unix 命令管道(它直接將一個程序的輸出作為另一個程序的輸入,僅用一個很小的記憶體緩衝區)。它更像是一系列命令,其中每個命令的輸出寫入臨時檔案,下一個命令從臨時檔案中讀取。這種設計有利也有弊,我們將在 “[物化中間狀態](#物化中間狀態)” 中討論。
|
||||
|
||||
只有當作業成功完成後,批處理作業的輸出才會被視為有效的(MapReduce會丟棄失敗作業的部分輸出)。因此,工作流中的一項作業只有在先前的作業 —— 即生產其輸入的作業 —— 成功完成後才能開始。為了處理這些作業之間的依賴,有很多針對Hadoop的工作流排程器被開發出來,包括Oozie,Azkaban,Luigi,Airflow和Pinball 【28】。
|
||||
只有當作業成功完成後,批處理作業的輸出才會被視為有效的(MapReduce 會丟棄失敗作業的部分輸出)。因此,工作流中的一項作業只有在先前的作業 —— 即生產其輸入的作業 —— 成功完成後才能開始。為了處理這些作業之間的依賴,有很多針對 Hadoop 的工作流排程器被開發出來,包括 Oozie、Azkaban、Luigi、Airflow 和 Pinball 【28】。
|
||||
|
||||
這些排程程式還具有管理功能,在維護大量批處理作業時非常有用。在構建推薦系統時,由50到100個MapReduce作業組成的工作流是常見的【29】。而在大型組織中,許多不同的團隊可能執行不同的作業來讀取彼此的輸出。工具支援對於管理這樣複雜的資料流而言非常重要。
|
||||
這些排程程式還具有管理功能,在維護大量批處理作業時非常有用。在構建推薦系統時,由 50 到 100 個 MapReduce 作業組成的工作流是常見的【29】。而在大型組織中,許多不同的團隊可能執行不同的作業來讀取彼此的輸出。工具支援對於管理這樣複雜的資料流而言非常重要。
|
||||
|
||||
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側連線與分組
|
||||
|
||||
我們在[第二章](ch2.md)中討論了資料模型和查詢語言的連線,但是我們還沒有深入探討連線是如何實現的。現在是我們再次撿起這條線索的時候了。
|
||||
我們在 [第二章](ch2.md) 中討論了資料模型和查詢語言的連線,但是我們還沒有深入探討連線是如何實現的。現在是我們再次撿起這條線索的時候了。
|
||||
|
||||
在許多資料集中,一條記錄與另一條記錄存在關聯是很常見的:關係模型中的**外來鍵**,文件模型中的**文件引用**或圖模型中的**邊**。當你需要同時訪問這一關聯的兩側(持有引用的記錄與被引用的記錄)時,連線就是必須的。正如[第二章](ch2.md)所討論的,非規範化可以減少對連線的需求,但通常無法將其完全移除[^v]。
|
||||
在許多資料集中,一條記錄與另一條記錄存在關聯是很常見的:關係模型中的 **外來鍵**,文件模型中的 **文件引用** 或圖模型中的 **邊**。當你需要同時訪問這一關聯的兩側(持有引用的記錄與被引用的記錄)時,連線就是必須的。正如 [第二章](ch2.md) 所討論的,非規範化可以減少對連線的需求,但通常無法將其完全移除 [^v]。
|
||||
|
||||
[^v]: 我們在本書中討論的連線通常是等值連線,即最常見的連線型別,其中記錄透過與其他記錄在特定欄位(例如ID)中具有**相同值**相關聯。有些資料庫支援更通用的連線型別,例如使用小於運算子而不是等號運算子,但是我們沒有地方來講這些東西。
|
||||
[^v]: 我們在本書中討論的連線通常是等值連線,即最常見的連線型別,其中記錄透過與其他記錄在特定欄位(例如 ID)中具有 **相同值** 相關聯。有些資料庫支援更通用的連線型別,例如使用小於運算子而不是等號運算子,但是我們沒有地方來講這些東西。
|
||||
|
||||
在資料庫中,如果執行只涉及少量記錄的查詢,資料庫通常會使用**索引**來快速定位感興趣的記錄(請參閱[第三章](ch3.md))。如果查詢涉及到連線,則可能涉及到查詢多個索引。然而MapReduce沒有索引的概念 —— 至少在通常意義上沒有。
|
||||
在資料庫中,如果執行只涉及少量記錄的查詢,資料庫通常會使用 **索引** 來快速定位感興趣的記錄(請參閱 [第三章](ch3.md))。如果查詢涉及到連線,則可能涉及到查詢多個索引。然而 MapReduce 沒有索引的概念 —— 至少在通常意義上沒有。
|
||||
|
||||
當MapReduce作業被賦予一組檔案作為輸入時,它讀取所有這些檔案的全部內容;資料庫會將這種操作稱為**全表掃描**。如果你只想讀取少量的記錄,則全表掃描與索引查詢相比,代價非常高昂。但是在分析查詢中(請參閱“[事務處理還是分析?](ch3.md#事務處理還是分析?)”),通常需要計算大量記錄的聚合。在這種情況下,特別是如果能在多臺機器上並行處理時,掃描整個輸入可能是相當合理的事情。
|
||||
當 MapReduce 作業被賦予一組檔案作為輸入時,它讀取所有這些檔案的全部內容;資料庫會將這種操作稱為 **全表掃描**。如果你只想讀取少量的記錄,則全表掃描與索引查詢相比,代價非常高昂。但是在分析查詢中(請參閱 “[事務處理還是分析?](ch3.md#事務處理還是分析?)”),通常需要計算大量記錄的聚合。在這種情況下,特別是如果能在多臺機器上並行處理時,掃描整個輸入可能是相當合理的事情。
|
||||
|
||||
當我們在批處理的語境中討論連線時,我們指的是在資料集中解析某種關聯的全量存在。 例如我們假設一個作業是同時處理所有使用者的資料,而非僅僅是為某個特定使用者查詢資料(而這能透過索引更高效地完成)。
|
||||
|
||||
#### 示例:使用者活動事件分析
|
||||
|
||||
[圖10-2](../img/fig10-2.png)給出了一個批處理作業中連線的典型例子。左側是事件日誌,描述登入使用者在網站上做的事情(稱為**活動事件**,即activity events,或**點選流資料**,即clickstream data),右側是使用者資料庫。 你可以將此示例看作是星型模式的一部分(請參閱“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日誌是事實表,使用者資料庫是其中的一個維度。
|
||||
[圖 10-2](../img/fig10-2.png) 給出了一個批處理作業中連線的典型例子。左側是事件日誌,描述登入使用者在網站上做的事情(稱為 **活動事件**,即 activity events,或 **點選流資料**,即 clickstream data),右側是使用者資料庫。 你可以將此示例看作是星型模式的一部分(請參閱 “[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日誌是事實表,使用者資料庫是其中的一個維度。
|
||||
|
||||
![](../img/fig10-2.png)
|
||||
|
||||
**圖10-2 使用者行為日誌與使用者檔案的連線**
|
||||
**圖 10-2 使用者行為日誌與使用者檔案的連線**
|
||||
|
||||
分析任務可能需要將使用者活動與使用者檔案資訊相關聯:例如,如果檔案包含使用者的年齡或出生日期,系統就可以確定哪些頁面更受哪些年齡段的使用者歡迎。然而活動事件僅包含使用者ID,而沒有包含完整的使用者檔案資訊。在每個活動事件中嵌入這些檔案資訊很可能會非常浪費。因此,活動事件需要與使用者檔案資料庫相連線。
|
||||
分析任務可能需要將使用者活動與使用者檔案資訊相關聯:例如,如果檔案包含使用者的年齡或出生日期,系統就可以確定哪些頁面更受哪些年齡段的使用者歡迎。然而活動事件僅包含使用者 ID,而沒有包含完整的使用者檔案資訊。在每個活動事件中嵌入這些檔案資訊很可能會非常浪費。因此,活動事件需要與使用者檔案資料庫相連線。
|
||||
|
||||
實現這一連線的最簡單方法是,逐個遍歷活動事件,併為每個遇到的使用者ID查詢使用者資料庫(在遠端伺服器上)。這是可能的,但是它的效能可能會非常差:處理吞吐量將受限於受資料庫伺服器的往返時間,本地快取的有效性很大程度上取決於資料的分佈,並行執行大量查詢可能會輕易壓垮資料庫【35】。
|
||||
實現這一連線的最簡單方法是,逐個遍歷活動事件,併為每個遇到的使用者 ID 查詢使用者資料庫(在遠端伺服器上)。這是可能的,但是它的效能可能會非常差:處理吞吐量將受限於受資料庫伺服器的往返時間,本地快取的有效性很大程度上取決於資料的分佈,並行執行大量查詢可能會輕易壓垮資料庫【35】。
|
||||
|
||||
為了在批處理過程中實現良好的吞吐量,計算必須(儘可能)限於單臺機器上進行。為待處理的每條記錄發起隨機訪問的網路請求實在是太慢了。而且,查詢遠端資料庫意味著批處理作業變為**非確定的(nondeterministic)**,因為遠端資料庫中的資料可能會改變。
|
||||
為了在批處理過程中實現良好的吞吐量,計算必須(儘可能)限於單臺機器上進行。為待處理的每條記錄發起隨機訪問的網路請求實在是太慢了。而且,查詢遠端資料庫意味著批處理作業變為 **非確定的(nondeterministic)**,因為遠端資料庫中的資料可能會改變。
|
||||
|
||||
因此,更好的方法是獲取使用者資料庫的副本(例如,使用ETL程序從資料庫備份中提取資料,請參閱“[資料倉庫](ch3.md#資料倉庫)”),並將它和使用者行為日誌放入同一個分散式檔案系統中。然後你可以將使用者資料庫儲存在HDFS中的一組檔案中,而使用者活動記錄儲存在另一組檔案中,並能用MapReduce將所有相關記錄集中到同一個地方進行高效處理。
|
||||
因此,更好的方法是獲取使用者資料庫的副本(例如,使用 ETL 程序從資料庫備份中提取資料,請參閱 “[資料倉庫](ch3.md#資料倉庫)”),並將它和使用者行為日誌放入同一個分散式檔案系統中。然後你可以將使用者資料庫儲存在 HDFS 中的一組檔案中,而使用者活動記錄儲存在另一組檔案中,並能用 MapReduce 將所有相關記錄集中到同一個地方進行高效處理。
|
||||
|
||||
#### 排序合併連線
|
||||
|
||||
回想一下,Mapper的目的是從每個輸入記錄中提取一對鍵值。在[圖10-2](../img/fig10-2.png)的情況下,這個鍵就是使用者ID:一組Mapper會掃過活動事件(提取使用者ID作為鍵,活動事件作為值),而另一組Mapper將會掃過使用者資料庫(提取使用者ID作為鍵,使用者的出生日期作為值)。這個過程如[圖10-3](../img/fig10-3.png)所示。
|
||||
回想一下,Mapper 的目的是從每個輸入記錄中提取一對鍵值。在 [圖 10-2](../img/fig10-2.png) 的情況下,這個鍵就是使用者 ID:一組 Mapper 會掃過活動事件(提取使用者 ID 作為鍵,活動事件作為值),而另一組 Mapper 將會掃過使用者資料庫(提取使用者 ID 作為鍵,使用者的出生日期作為值)。這個過程如 [圖 10-3](../img/fig10-3.png) 所示。
|
||||
|
||||
![](../img/fig10-3.png)
|
||||
|
||||
**圖10-3 在使用者ID上進行的Reduce端連線。如果輸入資料集分割槽為多個檔案,則每個分割槽都會被多個Mapper並行處理**
|
||||
**圖 10-3 在使用者 ID 上進行的 Reduce 端連線。如果輸入資料集分割槽為多個檔案,則每個分割槽都會被多個 Mapper 並行處理**
|
||||
|
||||
當MapReduce框架透過鍵對Mapper輸出進行分割槽,然後對鍵值對進行排序時,效果是具有相同ID的所有活動事件和使用者記錄在Reducer輸入中彼此相鄰。 Map-Reduce作業甚至可以也讓這些記錄排序,使Reducer總能先看到來自使用者資料庫的記錄,緊接著是按時間戳順序排序的活動事件 —— 這種技術被稱為**二次排序(secondary sort)**【26】。
|
||||
當 MapReduce 框架透過鍵對 Mapper 輸出進行分割槽,然後對鍵值對進行排序時,效果是具有相同 ID 的所有活動事件和使用者記錄在 Reducer 輸入中彼此相鄰。 Map-Reduce 作業甚至可以也讓這些記錄排序,使 Reducer 總能先看到來自使用者資料庫的記錄,緊接著是按時間戳順序排序的活動事件 —— 這種技術被稱為 **二次排序(secondary sort)**【26】。
|
||||
|
||||
然後Reducer可以容易地執行實際的連線邏輯:每個使用者ID都會被呼叫一次Reducer函式,且因為二次排序,第一個值應該是來自使用者資料庫的出生日期記錄。 Reducer將出生日期儲存在區域性變數中,然後使用相同的使用者ID遍歷活動事件,輸出**已觀看網址**和**觀看者年齡**的結果對。隨後的Map-Reduce作業可以計算每個URL的檢視者年齡分佈,並按年齡段進行聚集。
|
||||
然後 Reducer 可以容易地執行實際的連線邏輯:每個使用者 ID 都會被呼叫一次 Reducer 函式,且因為二次排序,第一個值應該是來自使用者資料庫的出生日期記錄。 Reducer 將出生日期儲存在區域性變數中,然後使用相同的使用者 ID 遍歷活動事件,輸出 **已觀看網址** 和 **觀看者年齡** 的結果對。隨後的 Map-Reduce 作業可以計算每個 URL 的檢視者年齡分佈,並按年齡段進行聚集。
|
||||
|
||||
由於Reducer一次處理一個特定使用者ID的所有記錄,因此一次只需要將一條使用者記錄儲存在記憶體中,而不需要透過網路發出任何請求。這個演算法被稱為**排序合併連線(sort-merge join)**,因為Mapper的輸出是按鍵排序的,然後Reducer將來自連線兩側的有序記錄列表合併在一起。
|
||||
由於 Reducer 一次處理一個特定使用者 ID 的所有記錄,因此一次只需要將一條使用者記錄儲存在記憶體中,而不需要透過網路發出任何請求。這個演算法被稱為 **排序合併連線(sort-merge join)**,因為 Mapper 的輸出是按鍵排序的,然後 Reducer 將來自連線兩側的有序記錄列表合併在一起。
|
||||
|
||||
#### 把相關資料放在一起
|
||||
|
||||
在排序合併連線中,Mapper和排序過程確保了所有對特定使用者ID執行連線操作的必須資料都被放在同一個地方:單次呼叫Reducer的地方。預先排好了所有需要的資料,Reducer可以是相當簡單的單執行緒程式碼,能夠以高吞吐量和與低記憶體開銷掃過這些記錄。
|
||||
在排序合併連線中,Mapper 和排序過程確保了所有對特定使用者 ID 執行連線操作的必須資料都被放在同一個地方:單次呼叫 Reducer 的地方。預先排好了所有需要的資料,Reducer 可以是相當簡單的單執行緒程式碼,能夠以高吞吐量和與低記憶體開銷掃過這些記錄。
|
||||
|
||||
這種架構可以看做,Mapper將“訊息”傳送給Reducer。當一個Mapper發出一個鍵值對時,這個鍵的作用就像值應該傳遞到的目標地址。即使鍵只是一個任意的字串(不是像IP地址和埠號那樣的實際的網路地址),它表現的就像一個地址:所有具有相同鍵的鍵值對將被傳遞到相同的目標(一次Reducer的呼叫)。
|
||||
這種架構可以看做,Mapper 將 “訊息” 傳送給 Reducer。當一個 Mapper 發出一個鍵值對時,這個鍵的作用就像值應該傳遞到的目標地址。即使鍵只是一個任意的字串(不是像 IP 地址和埠號那樣的實際的網路地址),它表現的就像一個地址:所有具有相同鍵的鍵值對將被傳遞到相同的目標(一次 Reducer 的呼叫)。
|
||||
|
||||
使用MapReduce程式設計模型,能將計算的物理網路通訊層面(從正確的機器獲取資料)從應用邏輯中剝離出來(獲取資料後執行處理)。這種分離與資料庫的典型用法形成了鮮明對比,從資料庫中獲取資料的請求經常出現在應用程式碼內部【36】。由於MapReduce處理了所有的網路通訊,因此它也避免了讓應用程式碼去擔心部分故障,例如另一個節點的崩潰:MapReduce在不影響應用邏輯的情況下能透明地重試失敗的任務。
|
||||
使用 MapReduce 程式設計模型,能將計算的物理網路通訊層面(從正確的機器獲取資料)從應用邏輯中剝離出來(獲取資料後執行處理)。這種分離與資料庫的典型用法形成了鮮明對比,從資料庫中獲取資料的請求經常出現在應用程式碼內部【36】。由於 MapReduce 處理了所有的網路通訊,因此它也避免了讓應用程式碼去擔心部分故障,例如另一個節點的崩潰:MapReduce 在不影響應用邏輯的情況下能透明地重試失敗的任務。
|
||||
|
||||
#### 分組
|
||||
|
||||
除了連線之外,“把相關資料放在一起”的另一種常見模式是,按某個鍵對記錄分組(如SQL中的GROUP BY子句)。所有帶有相同鍵的記錄構成一個組,而下一步往往是在每個組內進行某種聚合操作,例如:
|
||||
除了連線之外,“把相關資料放在一起” 的另一種常見模式是,按某個鍵對記錄分組(如 SQL 中的 GROUP BY 子句)。所有帶有相同鍵的記錄構成一個組,而下一步往往是在每個組內進行某種聚合操作,例如:
|
||||
|
||||
- 統計每個組中記錄的數量(例如在統計PV的例子中,在SQL中表示為`COUNT(*)`聚合)
|
||||
- 對某個特定欄位求和(SQL中的`SUM(fieldname)`)
|
||||
- 按某種分級函式取出排名前k條記錄。
|
||||
- 統計每個組中記錄的數量(例如在統計 PV 的例子中,在 SQL 中表示為 `COUNT(*)` 聚合)
|
||||
- 對某個特定欄位求和(SQL 中的 `SUM(fieldname)`)
|
||||
- 按某種分級函式取出排名前 k 條記錄。
|
||||
|
||||
使用MapReduce實現這種分組操作的最簡單方法是設定Mapper,以便它們生成的鍵值對使用所需的分組鍵。然後分割槽和排序過程將所有具有相同分割槽鍵的記錄導向同一個Reducer。因此在MapReduce之上實現分組和連線看上去非常相似。
|
||||
使用 MapReduce 實現這種分組操作的最簡單方法是設定 Mapper,以便它們生成的鍵值對使用所需的分組鍵。然後分割槽和排序過程將所有具有相同分割槽鍵的記錄導向同一個 Reducer。因此在 MapReduce 之上實現分組和連線看上去非常相似。
|
||||
|
||||
分組的另一個常見用途是整理特定使用者會話的所有活動事件,以找出使用者進行的一系列操作(稱為**會話化(sessionization)**【37】)。例如,可以使用這種分析來確定顯示新版網站的使用者是否比那些顯示舊版本的使用者更有購買慾(A/B測試),或者計算某個營銷活動是否值得。
|
||||
分組的另一個常見用途是整理特定使用者會話的所有活動事件,以找出使用者進行的一系列操作(稱為 **會話化(sessionization)**【37】)。例如,可以使用這種分析來確定顯示新版網站的使用者是否比那些顯示舊版本的使用者更有購買慾(A/B 測試),或者計算某個營銷活動是否值得。
|
||||
|
||||
如果你有多個Web伺服器處理使用者請求,則特定使用者的活動事件很可能分散在各個不同的伺服器的日誌檔案中。你可以透過使用會話cookie,使用者ID或類似的識別符號作為分組鍵,以將特定使用者的所有活動事件放在一起來實現會話化,與此同時,不同使用者的事件仍然散佈在不同的分割槽中。
|
||||
如果你有多個 Web 伺服器處理使用者請求,則特定使用者的活動事件很可能分散在各個不同的伺服器的日誌檔案中。你可以透過使用會話 cookie,使用者 ID 或類似的識別符號作為分組鍵,以將特定使用者的所有活動事件放在一起來實現會話化,與此同時,不同使用者的事件仍然散佈在不同的分割槽中。
|
||||
|
||||
#### 處理偏斜
|
||||
|
||||
如果存在與單個鍵關聯的大量資料,則“將具有相同鍵的所有記錄放到相同的位置”這種模式就被破壞了。例如在社交網路中,大多數使用者可能會與幾百人有連線,但少數名人可能有數百萬的追隨者。這種不成比例的活動資料庫記錄被稱為**關鍵物件(linchpin object)**【38】或**熱鍵(hot key)**。
|
||||
如果存在與單個鍵關聯的大量資料,則 “將具有相同鍵的所有記錄放到相同的位置” 這種模式就被破壞了。例如在社交網路中,大多數使用者可能會與幾百人有連線,但少數名人可能有數百萬的追隨者。這種不成比例的活動資料庫記錄被稱為 **關鍵物件(linchpin object)**【38】或 **熱鍵(hot key)**。
|
||||
|
||||
在單個Reducer中收集與某個名人相關的所有活動(例如他們釋出內容的回覆)可能導致嚴重的**偏斜**(也稱為**熱點**,即hot spot)—— 也就是說,一個Reducer必須比其他Reducer處理更多的記錄(請參閱“[負載偏斜與熱點消除](ch6.md#負載偏斜與熱點消除)“)。由於MapReduce作業只有在所有Mapper和Reducer都完成時才完成,所有後續作業必須等待最慢的Reducer才能啟動。
|
||||
在單個 Reducer 中收集與某個名人相關的所有活動(例如他們釋出內容的回覆)可能導致嚴重的 **偏斜**(也稱為 **熱點**,即 hot spot)—— 也就是說,一個 Reducer 必須比其他 Reducer 處理更多的記錄(請參閱 “[負載偏斜與熱點消除](ch6.md#負載偏斜與熱點消除)“)。由於 MapReduce 作業只有在所有 Mapper 和 Reducer 都完成時才完成,所有後續作業必須等待最慢的 Reducer 才能啟動。
|
||||
|
||||
如果連線的輸入存在熱鍵,可以使用一些演算法進行補償。例如,Pig中的**偏斜連線(skewed join)** 方法首先執行一個抽樣作業(Sampling Job)來確定哪些鍵是熱鍵【39】。連線實際執行時,Mapper會將熱鍵的關聯記錄**隨機**(相對於傳統MapReduce基於鍵雜湊的確定性方法)傳送到幾個Reducer之一。對於另外一側的連線輸入,與熱鍵相關的記錄需要被複制到**所有**處理該鍵的Reducer上【40】。
|
||||
如果連線的輸入存在熱鍵,可以使用一些演算法進行補償。例如,Pig 中的 **偏斜連線(skewed join)** 方法首先執行一個抽樣作業(Sampling Job)來確定哪些鍵是熱鍵【39】。連線實際執行時,Mapper 會將熱鍵的關聯記錄 **隨機**(相對於傳統 MapReduce 基於鍵雜湊的確定性方法)傳送到幾個 Reducer 之一。對於另外一側的連線輸入,與熱鍵相關的記錄需要被複制到 **所有** 處理該鍵的 Reducer 上【40】。
|
||||
|
||||
這種技術將處理熱鍵的工作分散到多個Reducer上,這樣可以使其更好地並行化,代價是需要將連線另一側的輸入記錄複製到多個Reducer上。 Crunch中的**分片連線(sharded join)** 方法與之類似,但需要顯式指定熱鍵而不是使用抽樣作業。這種技術也非常類似於我們在“[負載偏斜與熱點消除](ch6.md#負載偏斜與熱點消除)”中討論的技術,使用隨機化來緩解分割槽資料庫中的熱點。
|
||||
這種技術將處理熱鍵的工作分散到多個 Reducer 上,這樣可以使其更好地並行化,代價是需要將連線另一側的輸入記錄複製到多個 Reducer 上。 Crunch 中的 **分片連線(sharded join)** 方法與之類似,但需要顯式指定熱鍵而不是使用抽樣作業。這種技術也非常類似於我們在 “[負載偏斜與熱點消除](ch6.md#負載偏斜與熱點消除)” 中討論的技術,使用隨機化來緩解分割槽資料庫中的熱點。
|
||||
|
||||
Hive的偏斜連線最佳化採取了另一種方法。它需要在表格元資料中顯式指定熱鍵,並將與這些鍵相關的記錄單獨存放,與其它檔案分開。當在該表上執行連線時,對於熱鍵,它會使用Map端連線(請參閱下一節)。
|
||||
Hive 的偏斜連線最佳化採取了另一種方法。它需要在表格元資料中顯式指定熱鍵,並將與這些鍵相關的記錄單獨存放,與其它檔案分開。當在該表上執行連線時,對於熱鍵,它會使用 Map 端連線(請參閱下一節)。
|
||||
|
||||
當按照熱鍵進行分組並聚合時,可以將分組分兩個階段進行。第一個MapReduce階段將記錄傳送到隨機Reducer,以便每個Reducer只對熱鍵的子集執行分組,為每個鍵輸出一個更緊湊的中間聚合結果。然後第二個MapReduce作業將所有來自第一階段Reducer的中間聚合結果合併為每個鍵一個值。
|
||||
當按照熱鍵進行分組並聚合時,可以將分組分兩個階段進行。第一個 MapReduce 階段將記錄傳送到隨機 Reducer,以便每個 Reducer 只對熱鍵的子集執行分組,為每個鍵輸出一個更緊湊的中間聚合結果。然後第二個 MapReduce 作業將所有來自第一階段 Reducer 的中間聚合結果合併為每個鍵一個值。
|
||||
|
||||
|
||||
### Map側連線
|
||||
|
||||
上一節描述的連線演算法在Reducer中執行實際的連線邏輯,因此被稱為Reduce側連線。Mapper扮演著預處理輸入資料的角色:從每個輸入記錄中提取鍵值,將鍵值對分配給Reducer分割槽,並按鍵排序。
|
||||
上一節描述的連線演算法在 Reducer 中執行實際的連線邏輯,因此被稱為 Reduce 側連線。Mapper 扮演著預處理輸入資料的角色:從每個輸入記錄中提取鍵值,將鍵值對分配給 Reducer 分割槽,並按鍵排序。
|
||||
|
||||
Reduce側方法的優點是不需要對輸入資料做任何假設:無論其屬性和結構如何,Mapper都可以對其預處理以備連線。然而不利的一面是,排序,複製至Reducer,以及合併Reducer輸入,所有這些操作可能開銷巨大。當資料透過MapReduce 階段時,資料可能需要落盤好幾次,取決於可用的記憶體緩衝區【37】。
|
||||
Reduce 側方法的優點是不需要對輸入資料做任何假設:無論其屬性和結構如何,Mapper 都可以對其預處理以備連線。然而不利的一面是,排序,複製至 Reducer,以及合併 Reducer 輸入,所有這些操作可能開銷巨大。當資料透過 MapReduce 階段時,資料可能需要落盤好幾次,取決於可用的記憶體緩衝區【37】。
|
||||
|
||||
另一方面,如果你**能**對輸入資料作出某些假設,則透過使用所謂的Map側連線來加快連線速度是可行的。這種方法使用了一個裁減掉Reducer與排序的MapReduce作業,每個Mapper只是簡單地從分散式檔案系統中讀取一個輸入檔案塊,然後將輸出檔案寫入檔案系統,僅此而已。
|
||||
另一方面,如果你 **能** 對輸入資料作出某些假設,則透過使用所謂的 Map 側連線來加快連線速度是可行的。這種方法使用了一個裁減掉 Reducer 與排序的 MapReduce 作業,每個 Mapper 只是簡單地從分散式檔案系統中讀取一個輸入檔案塊,然後將輸出檔案寫入檔案系統,僅此而已。
|
||||
|
||||
#### 廣播雜湊連線
|
||||
|
||||
適用於執行Map端連線的最簡單場景是大資料集與小資料集連線的情況。要點在於小資料集需要足夠小,以便可以將其全部載入到每個Mapper的記憶體中。
|
||||
適用於執行 Map 端連線的最簡單場景是大資料集與小資料集連線的情況。要點在於小資料集需要足夠小,以便可以將其全部載入到每個 Mapper 的記憶體中。
|
||||
|
||||
例如,假設在[圖10-2](../img/fig10-2.png)的情況下,使用者資料庫小到足以放進記憶體中。在這種情況下,當Mapper啟動時,它可以首先將使用者資料庫從分散式檔案系統讀取到記憶體中的散列表中。完成此操作後,Mapper可以掃描使用者活動事件,並簡單地在散列表中查詢每個事件的使用者ID[^vi]。
|
||||
例如,假設在 [圖 10-2](../img/fig10-2.png) 的情況下,使用者資料庫小到足以放進記憶體中。在這種情況下,當 Mapper 啟動時,它可以首先將使用者資料庫從分散式檔案系統讀取到記憶體中的散列表中。完成此操作後,Mapper 可以掃描使用者活動事件,並簡單地在散列表中查詢每個事件的使用者 ID [^vi]。
|
||||
|
||||
[^vi]: 這個例子假定散列表中的每個鍵只有一個條目,這對使用者資料庫(使用者ID唯一標識一個使用者)可能是正確的。通常,雜湊表可能需要包含具有相同鍵的多個條目,而連線運算子將對每個鍵輸出所有的匹配。
|
||||
[^vi]: 這個例子假定散列表中的每個鍵只有一個條目,這對使用者資料庫(使用者 ID 唯一標識一個使用者)可能是正確的。通常,雜湊表可能需要包含具有相同鍵的多個條目,而連線運算子將對每個鍵輸出所有的匹配。
|
||||
|
||||
參與連線的較大輸入的每個檔案塊各有一個Mapper(在[圖10-2](../img/fig10-2.png)的例子中活動事件是較大的輸入)。每個Mapper都會將較小輸入整個載入到記憶體中。
|
||||
參與連線的較大輸入的每個檔案塊各有一個 Mapper(在 [圖 10-2](../img/fig10-2.png) 的例子中活動事件是較大的輸入)。每個 Mapper 都會將較小輸入整個載入到記憶體中。
|
||||
|
||||
這種簡單有效的演算法被稱為**廣播雜湊連線(broadcast hash join)**:**廣播**一詞反映了這樣一個事實,每個連線較大輸入端分割槽的Mapper都會將較小輸入端資料集整個讀入記憶體中(所以較小輸入實際上“廣播”到較大資料的所有分割槽上),**雜湊**一詞反映了它使用一個散列表。 Pig(名為“**複製連結(replicated join)**”),Hive(“**MapJoin**”),Cascading和Crunch支援這種連線。它也被諸如Impala的資料倉庫查詢引擎使用【41】。
|
||||
這種簡單有效的演算法被稱為 **廣播雜湊連線(broadcast hash join)**:**廣播** 一詞反映了這樣一個事實,每個連線較大輸入端分割槽的 Mapper 都會將較小輸入端資料集整個讀入記憶體中(所以較小輸入實際上 “廣播” 到較大資料的所有分割槽上),**雜湊** 一詞反映了它使用一個散列表。 Pig(名為 “**複製連結(replicated join)**”),Hive(“**MapJoin**”),Cascading 和 Crunch 支援這種連線。它也被諸如 Impala 的資料倉庫查詢引擎使用【41】。
|
||||
|
||||
除了將較小的連線輸入載入到記憶體散列表中,另一種方法是將較小輸入儲存在本地磁碟上的只讀索引中【42】。索引中經常使用的部分將保留在作業系統的頁面快取中,因而這種方法可以提供與記憶體散列表幾乎一樣快的隨機查詢效能,但實際上並不需要資料集能放入記憶體中。
|
||||
|
||||
#### 分割槽雜湊連線
|
||||
|
||||
如果Map側連線的輸入以相同的方式進行分割槽,則雜湊連線方法可以獨立應用於每個分割槽。在[圖10-2](../img/fig10-2.png)的情況中,你可以根據使用者ID的最後一位十進位制數字來對活動事件和使用者資料庫進行分割槽(因此連線兩側各有10個分割槽)。例如,Mapper3首先將所有具有以3結尾的ID的使用者載入到散列表中,然後掃描ID為3的每個使用者的所有活動事件。
|
||||
如果 Map 側連線的輸入以相同的方式進行分割槽,則雜湊連線方法可以獨立應用於每個分割槽。在 [圖 10-2](../img/fig10-2.png) 的情況中,你可以根據使用者 ID 的最後一位十進位制數字來對活動事件和使用者資料庫進行分割槽(因此連線兩側各有 10 個分割槽)。例如,Mapper3 首先將所有具有以 3 結尾的 ID 的使用者載入到散列表中,然後掃描 ID 為 3 的每個使用者的所有活動事件。
|
||||
|
||||
如果分割槽正確無誤,可以確定的是,所有你可能需要連線的記錄都落在同一個編號的分割槽中。因此每個Mapper只需要從輸入兩端各讀取一個分割槽就足夠了。好處是每個Mapper都可以在記憶體散列表中少放點資料。
|
||||
如果分割槽正確無誤,可以確定的是,所有你可能需要連線的記錄都落在同一個編號的分割槽中。因此每個 Mapper 只需要從輸入兩端各讀取一個分割槽就足夠了。好處是每個 Mapper 都可以在記憶體散列表中少放點資料。
|
||||
|
||||
這種方法只有當連線兩端輸入有相同的分割槽數,且兩側的記錄都是使用相同的鍵與相同的雜湊函式做分割槽時才適用。如果輸入是由之前執行過這種分組的MapReduce作業生成的,那麼這可能是一個合理的假設。
|
||||
這種方法只有當連線兩端輸入有相同的分割槽數,且兩側的記錄都是使用相同的鍵與相同的雜湊函式做分割槽時才適用。如果輸入是由之前執行過這種分組的 MapReduce 作業生成的,那麼這可能是一個合理的假設。
|
||||
|
||||
分割槽雜湊連線在Hive中稱為**Map側桶連線(bucketed map joins)【37】**。
|
||||
分割槽雜湊連線在 Hive 中稱為 **Map 側桶連線(bucketed map joins)【37】**。
|
||||
|
||||
#### Map側合併連線
|
||||
|
||||
如果輸入資料集不僅以相同的方式進行分割槽,而且還基於相同的鍵進行**排序**,則可適用另一種Map側連線的變體。在這種情況下,輸入是否小到能放入記憶體並不重要,因為這時候Mapper同樣可以執行歸併操作(通常由Reducer執行)的歸併操作:按鍵遞增的順序依次讀取兩個輸入檔案,將具有相同鍵的記錄配對。
|
||||
如果輸入資料集不僅以相同的方式進行分割槽,而且還基於相同的鍵進行 **排序**,則可適用另一種 Map 側連線的變體。在這種情況下,輸入是否小到能放入記憶體並不重要,因為這時候 Mapper 同樣可以執行歸併操作(通常由 Reducer 執行)的歸併操作:按鍵遞增的順序依次讀取兩個輸入檔案,將具有相同鍵的記錄配對。
|
||||
|
||||
如果能進行Map側合併連線,這通常意味著前一個MapReduce作業可能一開始就已經把輸入資料做了分割槽並進行了排序。原則上這個連線就可以在前一個作業的Reduce階段進行。但使用獨立的僅Map作業有時也是合適的,例如,分好區且排好序的中間資料集可能還會用於其他目的。
|
||||
如果能進行 Map 側合併連線,這通常意味著前一個 MapReduce 作業可能一開始就已經把輸入資料做了分割槽並進行了排序。原則上這個連線就可以在前一個作業的 Reduce 階段進行。但使用獨立的僅 Map 作業有時也是合適的,例如,分好區且排好序的中間資料集可能還會用於其他目的。
|
||||
|
||||
#### MapReduce工作流與Map側連線
|
||||
|
||||
當下遊作業使用MapReduce連線的輸出時,選擇Map側連線或Reduce側連線會影響輸出的結構。Reduce側連線的輸出是按照**連線鍵**進行分割槽和排序的,而Map端連線的輸出則按照與較大輸入相同的方式進行分割槽和排序(因為無論是使用分割槽連線還是廣播連線,連線較大輸入端的每個檔案塊都會啟動一個Map任務)。
|
||||
當下遊作業使用 MapReduce 連線的輸出時,選擇 Map 側連線或 Reduce 側連線會影響輸出的結構。Reduce 側連線的輸出是按照 **連線鍵** 進行分割槽和排序的,而 Map 端連線的輸出則按照與較大輸入相同的方式進行分割槽和排序(因為無論是使用分割槽連線還是廣播連線,連線較大輸入端的每個檔案塊都會啟動一個 Map 任務)。
|
||||
|
||||
如前所述,Map側連線也對輸入資料集的大小,有序性和分割槽方式做出了更多假設。在最佳化連線策略時,瞭解分散式檔案系統中資料集的物理佈局變得非常重要:僅僅知道編碼格式和資料儲存目錄的名稱是不夠的;你還必須知道資料是按哪些鍵做的分割槽和排序,以及分割槽的數量。
|
||||
如前所述,Map 側連線也對輸入資料集的大小,有序性和分割槽方式做出了更多假設。在最佳化連線策略時,瞭解分散式檔案系統中資料集的物理佈局變得非常重要:僅僅知道編碼格式和資料儲存目錄的名稱是不夠的;你還必須知道資料是按哪些鍵做的分割槽和排序,以及分割槽的數量。
|
||||
|
||||
在Hadoop生態系統中,這種關於資料集分割槽的元資料通常在HCatalog和Hive Metastore中維護【37】。
|
||||
在 Hadoop 生態系統中,這種關於資料集分割槽的元資料通常在 HCatalog 和 Hive Metastore 中維護【37】。
|
||||
|
||||
|
||||
### 批處理工作流的輸出
|
||||
|
||||
我們已經說了很多用於實現MapReduce工作流的演算法,但卻忽略了一個重要的問題:這些處理完成之後的最終結果是什麼?我們最開始為什麼要跑這些作業?
|
||||
我們已經說了很多用於實現 MapReduce 工作流的演算法,但卻忽略了一個重要的問題:這些處理完成之後的最終結果是什麼?我們最開始為什麼要跑這些作業?
|
||||
|
||||
在資料庫查詢的場景中,我們將事務處理(OLTP)與分析兩種目的區分開來(請參閱“[事務處理還是分析?](ch3.md#事務處理還是分析?)”)。我們看到,OLTP查詢通常根據鍵查詢少量記錄,使用索引,並將其呈現給使用者(比如在網頁上)。另一方面,分析查詢通常會掃描大量記錄,執行分組與聚合,輸出通常有著報告的形式:顯示某個指標隨時間變化的圖表,或按照某種排位取前10項,或將一些數字細化為子類。這種報告的消費者通常是需要做出商業決策的分析師或經理。
|
||||
在資料庫查詢的場景中,我們將事務處理(OLTP)與分析兩種目的區分開來(請參閱 “[事務處理還是分析?](ch3.md#事務處理還是分析?)”)。我們看到,OLTP 查詢通常根據鍵查詢少量記錄,使用索引,並將其呈現給使用者(比如在網頁上)。另一方面,分析查詢通常會掃描大量記錄,執行分組與聚合,輸出通常有著報告的形式:顯示某個指標隨時間變化的圖表,或按照某種排位取前 10 項,或將一些數字細化為子類。這種報告的消費者通常是需要做出商業決策的分析師或經理。
|
||||
|
||||
批處理放哪裡合適?它不屬於事務處理,也不是分析。它和分析比較接近,因為批處理通常會掃過輸入資料集的絕大部分。然而MapReduce作業工作流與用於分析目的的SQL查詢是不同的(請參閱“[Hadoop與分散式資料庫的對比](#Hadoop與分散式資料庫的對比)”)。批處理過程的輸出通常不是報表,而是一些其他型別的結構。
|
||||
批處理放哪裡合適?它不屬於事務處理,也不是分析。它和分析比較接近,因為批處理通常會掃過輸入資料集的絕大部分。然而 MapReduce 作業工作流與用於分析目的的 SQL 查詢是不同的(請參閱 “[Hadoop 與分散式資料庫的對比](#Hadoop與分散式資料庫的對比)”)。批處理過程的輸出通常不是報表,而是一些其他型別的結構。
|
||||
|
||||
#### 建立搜尋索引
|
||||
|
||||
Google最初使用MapReduce是為其搜尋引擎建立索引,其實現為由5到10個MapReduce作業組成的工作流【1】。雖然Google後來也不僅僅是為這個目的而使用MapReduce 【43】,但如果從構建搜尋索引的角度來看,更能幫助理解MapReduce。 (直至今日,Hadoop MapReduce仍然是為Lucene/Solr構建索引的好方法【44】)
|
||||
Google 最初使用 MapReduce 是為其搜尋引擎建立索引,其實現為由 5 到 10 個 MapReduce 作業組成的工作流【1】。雖然 Google 後來也不僅僅是為這個目的而使用 MapReduce 【43】,但如果從構建搜尋索引的角度來看,更能幫助理解 MapReduce。 (直至今日,Hadoop MapReduce 仍然是為 Lucene/Solr 構建索引的好方法【44】)
|
||||
|
||||
我們在“[全文搜尋和模糊索引](ch3.md#全文搜尋和模糊索引)”中簡要地瞭解了Lucene這樣的全文搜尋索引是如何工作的:它是一個檔案(關鍵詞字典),你可以在其中高效地查詢特定關鍵字,並找到包含該關鍵字的所有文件ID列表(文章列表)。這是一種非常簡化的看法 —— 實際上,搜尋索引需要各種額外資料,以便根據相關性對搜尋結果進行排名,糾正拼寫錯誤,解析同義詞等等 —— 但這個原則是成立的。
|
||||
我們在 “[全文搜尋和模糊索引](ch3.md#全文搜尋和模糊索引)” 中簡要地瞭解了 Lucene 這樣的全文搜尋索引是如何工作的:它是一個檔案(關鍵詞字典),你可以在其中高效地查詢特定關鍵字,並找到包含該關鍵字的所有文件 ID 列表(文章列表)。這是一種非常簡化的看法 —— 實際上,搜尋索引需要各種額外資料,以便根據相關性對搜尋結果進行排名,糾正拼寫錯誤,解析同義詞等等 —— 但這個原則是成立的。
|
||||
|
||||
如果需要對一組固定文件執行全文搜尋,則批處理是一種構建索引的高效方法:Mapper根據需要對文件集合進行分割槽,每個Reducer構建該分割槽的索引,並將索引檔案寫入分散式檔案系統。構建這樣的文件分割槽索引(請參閱“[分割槽與次級索引](ch6.md#分割槽與次級索引)”)並行處理效果拔群。
|
||||
如果需要對一組固定文件執行全文搜尋,則批處理是一種構建索引的高效方法:Mapper 根據需要對文件集合進行分割槽,每個 Reducer 構建該分割槽的索引,並將索引檔案寫入分散式檔案系統。構建這樣的文件分割槽索引(請參閱 “[分割槽與次級索引](ch6.md#分割槽與次級索引)”)並行處理效果拔群。
|
||||
|
||||
由於按關鍵字查詢搜尋索引是隻讀操作,因而這些索引檔案一旦建立就是不可變的。
|
||||
|
||||
如果索引的文件集合發生更改,一種選擇是定期重跑整個索引工作流,並在完成後用新的索引檔案批次替換以前的索引檔案。如果只有少量的文件發生了變化,這種方法的計算成本可能會很高。但它的優點是索引過程很容易理解:文件進,索引出。
|
||||
|
||||
另一個選擇是,可以增量建立索引。如[第三章](ch3.md)中討論的,如果要在索引中新增,刪除或更新文件,Lucene會寫新的段檔案,並在後臺非同步合併壓縮段檔案。我們將在[第十一章](ch11.md)中看到更多這種增量處理。
|
||||
另一個選擇是,可以增量建立索引。如 [第三章](ch3.md) 中討論的,如果要在索引中新增,刪除或更新文件,Lucene 會寫新的段檔案,並在後臺非同步合併壓縮段檔案。我們將在 [第十一章](ch11.md) 中看到更多這種增量處理。
|
||||
|
||||
#### 鍵值儲存作為批處理輸出
|
||||
|
||||
搜尋索引只是批處理工作流可能輸出的一個例子。批處理的另一個常見用途是構建機器學習系統,例如分類器(比如垃圾郵件過濾器,異常檢測,影象識別)與推薦系統(例如,你可能認識的人,你可能感興趣的產品或相關的搜尋【29】)。
|
||||
|
||||
這些批處理作業的輸出通常是某種資料庫:例如,可以透過給定使用者ID查詢該使用者推薦好友的資料庫,或者可以透過產品ID查詢相關產品的資料庫【45】。
|
||||
這些批處理作業的輸出通常是某種資料庫:例如,可以透過給定使用者 ID 查詢該使用者推薦好友的資料庫,或者可以透過產品 ID 查詢相關產品的資料庫【45】。
|
||||
|
||||
這些資料庫需要被處理使用者請求的Web應用所查詢,而它們通常是獨立於Hadoop基礎設施的。那麼批處理過程的輸出如何回到Web應用可以查詢的資料庫中呢?
|
||||
這些資料庫需要被處理使用者請求的 Web 應用所查詢,而它們通常是獨立於 Hadoop 基礎設施的。那麼批處理過程的輸出如何回到 Web 應用可以查詢的資料庫中呢?
|
||||
|
||||
最直接的選擇可能是,直接在Mapper或Reducer中使用你最愛的資料庫的客戶端庫,並從批處理作業直接寫入資料庫伺服器,一次寫入一條記錄。它能工作(假設你的防火牆規則允許從你的Hadoop環境直接訪問你的生產資料庫),但這並不是一個好主意,出於以下幾個原因:
|
||||
最直接的選擇可能是,直接在 Mapper 或 Reducer 中使用你最愛的資料庫的客戶端庫,並從批處理作業直接寫入資料庫伺服器,一次寫入一條記錄。它能工作(假設你的防火牆規則允許從你的 Hadoop 環境直接訪問你的生產資料庫),但這並不是一個好主意,出於以下幾個原因:
|
||||
|
||||
- 正如前面在連線的上下文中討論的那樣,為每條記錄發起一個網路請求,要比批處理任務的正常吞吐量慢幾個數量級。即使客戶端庫支援批處理,效能也可能很差。
|
||||
- MapReduce作業經常並行執行許多工。如果所有Mapper或Reducer都同時寫入相同的輸出資料庫,並以批處理的預期速率工作,那麼該資料庫很可能被輕易壓垮,其查詢效能可能變差。這可能會導致系統其他部分的執行問題【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的一種好用法:使用Mapper提取出鍵並按該鍵排序,已經完成了構建索引所必需的大量工作。由於這些鍵值儲存大多都是隻讀的(檔案只能由批處理作業一次性寫入,然後就不可變),所以資料結構非常簡單。比如它們就不需要預寫式日誌(WAL,請參閱“[讓B樹更可靠](ch3.md#讓B樹更可靠)”)。
|
||||
構建這些資料庫檔案是 MapReduce 的一種好用法:使用 Mapper 提取出鍵並按該鍵排序,已經完成了構建索引所必需的大量工作。由於這些鍵值儲存大多都是隻讀的(檔案只能由批處理作業一次性寫入,然後就不可變),所以資料結構非常簡單。比如它們就不需要預寫式日誌(WAL,請參閱 “[讓 B 樹更可靠](ch3.md#讓B樹更可靠)”)。
|
||||
|
||||
將資料載入到Voldemort時,伺服器將繼續用舊資料檔案服務請求,同時將新資料檔案從分散式檔案系統複製到伺服器的本地磁碟。一旦複製完成,伺服器會自動將查詢切換到新檔案。如果在這個過程中出現任何問題,它可以輕易回滾至舊檔案,因為它們仍然存在而且不可變【46】。
|
||||
將資料載入到 Voldemort 時,伺服器將繼續用舊資料檔案服務請求,同時將新資料檔案從分散式檔案系統複製到伺服器的本地磁碟。一旦複製完成,伺服器會自動將查詢切換到新檔案。如果在這個過程中出現任何問題,它可以輕易回滾至舊檔案,因為它們仍然存在而且不可變【46】。
|
||||
|
||||
#### 批處理輸出的哲學
|
||||
|
||||
本章前面討論過的Unix哲學(“[Unix哲學](#Unix哲學)”)鼓勵以顯式指明資料流的方式進行實驗:程式讀取輸入並寫入輸出。在這一過程中,輸入保持不變,任何先前的輸出都被新輸出完全替換,且沒有其他副作用。這意味著你可以隨心所欲地重新執行一個命令,略做改動或進行除錯,而不會攪亂系統的狀態。
|
||||
本章前面討論過的 Unix 哲學(“[Unix 哲學](#Unix哲學)”)鼓勵以顯式指明資料流的方式進行實驗:程式讀取輸入並寫入輸出。在這一過程中,輸入保持不變,任何先前的輸出都被新輸出完全替換,且沒有其他副作用。這意味著你可以隨心所欲地重新執行一個命令,略做改動或進行除錯,而不會攪亂系統的狀態。
|
||||
|
||||
MapReduce作業的輸出處理遵循同樣的原理。透過將輸入視為不可變且避免副作用(如寫入外部資料庫),批處理作業不僅實現了良好的效能,而且更容易維護:
|
||||
MapReduce 作業的輸出處理遵循同樣的原理。透過將輸入視為不可變且避免副作用(如寫入外部資料庫),批處理作業不僅實現了良好的效能,而且更容易維護:
|
||||
|
||||
- 如果在程式碼中引入了一個錯誤,而輸出錯誤或損壞了,則可以簡單地回滾到程式碼的先前版本,然後重新執行該作業,輸出將重新被糾正。或者,甚至更簡單,你可以將舊的輸出儲存在不同的目錄中,然後切換回原來的目錄。具有讀寫事務的資料庫沒有這個屬性:如果你部署了錯誤的程式碼,將錯誤的資料寫入資料庫,那麼回滾程式碼將無法修復資料庫中的資料。 (能夠從錯誤程式碼中恢復的概念被稱為**人類容錯(human fault tolerance)**【50】)
|
||||
- 由於回滾很容易,比起在錯誤意味著不可挽回的傷害的環境,功能開發進展能快很多。這種**最小化不可逆性(minimizing irreversibility)** 的原則有利於敏捷軟體開發【51】。
|
||||
- 如果Map或Reduce任務失敗,MapReduce框架將自動重新排程,並在同樣的輸入上再次執行它。如果失敗是由程式碼中的錯誤造成的,那麼它會不斷崩潰,並最終導致作業在幾次嘗試之後失敗。但是如果故障是由於臨時問題導致的,那麼故障就會被容忍。因為輸入不可變,這種自動重試是安全的,而失敗任務的輸出會被MapReduce框架丟棄。
|
||||
- 如果在程式碼中引入了一個錯誤,而輸出錯誤或損壞了,則可以簡單地回滾到程式碼的先前版本,然後重新執行該作業,輸出將重新被糾正。或者,甚至更簡單,你可以將舊的輸出儲存在不同的目錄中,然後切換回原來的目錄。具有讀寫事務的資料庫沒有這個屬性:如果你部署了錯誤的程式碼,將錯誤的資料寫入資料庫,那麼回滾程式碼將無法修復資料庫中的資料。 (能夠從錯誤程式碼中恢復的概念被稱為 **人類容錯(human fault tolerance)**【50】)
|
||||
- 由於回滾很容易,比起在錯誤意味著不可挽回的傷害的環境,功能開發進展能快很多。這種 **最小化不可逆性(minimizing irreversibility)** 的原則有利於敏捷軟體開發【51】。
|
||||
- 如果 Map 或 Reduce 任務失敗,MapReduce 框架將自動重新排程,並在同樣的輸入上再次執行它。如果失敗是由程式碼中的錯誤造成的,那麼它會不斷崩潰,並最終導致作業在幾次嘗試之後失敗。但是如果故障是由於臨時問題導致的,那麼故障就會被容忍。因為輸入不可變,這種自動重試是安全的,而失敗任務的輸出會被 MapReduce 框架丟棄。
|
||||
- 同一組檔案可用作各種不同作業的輸入,包括計算指標的監控作業並且評估作業的輸出是否具有預期的性質(例如,將其與前一次執行的輸出進行比較並測量差異) 。
|
||||
- 與Unix工具類似,MapReduce作業將邏輯與佈線(配置輸入和輸出目錄)分離,這使得關注點分離,可以重用程式碼:一個團隊可以專注實現一個做好一件事的作業;而其他團隊可以決定何時何地執行這項作業。
|
||||
- 與 Unix 工具類似,MapReduce 作業將邏輯與佈線(配置輸入和輸出目錄)分離,這使得關注點分離,可以重用程式碼:一個團隊可以專注實現一個做好一件事的作業;而其他團隊可以決定何時何地執行這項作業。
|
||||
|
||||
在這些領域,在Unix上表現良好的設計原則似乎也適用於Hadoop,但Unix和Hadoop在某些方面也有所不同。例如,因為大多數Unix工具都假設輸入輸出是無型別文字檔案,所以它們必須做大量的輸入解析工作(本章開頭的日誌分析示例使用`{print $7}`來提取URL)。在Hadoop上可以透過使用更結構化的檔案格式消除一些低價值的語法轉換:比如Avro(請參閱“[Avro](ch4.md#Avro)”)和Parquet(請參閱“[列式儲存](ch3.md#列式儲存)”)經常使用,因為它們提供了基於模式的高效編碼,並允許模式隨時間推移而演進(見[第四章](ch4.md))。
|
||||
在這些領域,在 Unix 上表現良好的設計原則似乎也適用於 Hadoop,但 Unix 和 Hadoop 在某些方面也有所不同。例如,因為大多數 Unix 工具都假設輸入輸出是無型別文字檔案,所以它們必須做大量的輸入解析工作(本章開頭的日誌分析示例使用 `{print $7}` 來提取 URL)。在 Hadoop 上可以透過使用更結構化的檔案格式消除一些低價值的語法轉換:比如 Avro(請參閱 “[Avro](ch4.md#Avro)”)和 Parquet(請參閱 “[列式儲存](ch3.md#列式儲存)”)經常使用,因為它們提供了基於模式的高效編碼,並允許模式隨時間推移而演進(見 [第四章](ch4.md))。
|
||||
|
||||
### Hadoop與分散式資料庫的對比
|
||||
|
||||
正如我們所看到的,Hadoop有點像Unix的分散式版本,其中HDFS是檔案系統,而MapReduce是Unix程序的怪異實現(總是在Map階段和Reduce階段執行`sort`工具)。我們瞭解瞭如何在這些原語的基礎上實現各種連線和分組操作。
|
||||
正如我們所看到的,Hadoop 有點像 Unix 的分散式版本,其中 HDFS 是檔案系統,而 MapReduce 是 Unix 程序的怪異實現(總是在 Map 階段和 Reduce 階段執行 `sort` 工具)。我們瞭解瞭如何在這些原語的基礎上實現各種連線和分組操作。
|
||||
|
||||
當MapReduce論文發表時【1】,它從某種意義上來說 —— 並不新鮮。我們在前幾節中討論的所有處理和並行連線演算法已經在十多年前所謂的**大規模並行處理(MPP, massively parallel processing)** 資料庫中實現了【3,40】。比如Gamma database machine,Teradata和Tandem NonStop SQL就是這方面的先驅【52】。
|
||||
當 MapReduce 論文發表時【1】,它從某種意義上來說 —— 並不新鮮。我們在前幾節中討論的所有處理和並行連線演算法已經在十多年前所謂的 **大規模並行處理(MPP, massively parallel processing)** 資料庫中實現了【3,40】。比如 Gamma database machine、Teradata 和 Tandem NonStop SQL 就是這方面的先驅【52】。
|
||||
|
||||
最大的區別是,MPP資料庫專注於在一組機器上並行執行分析SQL查詢,而MapReduce和分散式檔案系統【19】的組合則更像是一個可以執行任意程式的通用作業系統。
|
||||
最大的區別是,MPP 資料庫專注於在一組機器上並行執行分析 SQL 查詢,而 MapReduce 和分散式檔案系統【19】的組合則更像是一個可以執行任意程式的通用作業系統。
|
||||
|
||||
#### 儲存多樣性
|
||||
|
||||
資料庫要求你根據特定的模型(例如關係或文件)來構造資料,而分散式檔案系統中的檔案只是位元組序列,可以使用任何資料模型和編碼來編寫。它們可能是資料庫記錄的集合,但同樣可以是文字、影象、影片、感測器讀數、稀疏矩陣、特徵向量、基因組序列或任何其他型別的資料。
|
||||
|
||||
說白了,Hadoop開放了將資料不加區分地轉儲到HDFS的可能性,允許後續再研究如何進一步處理【53】。相比之下,在將資料匯入資料庫專有儲存格式之前,MPP資料庫通常需要對資料和查詢模式進行仔細的前期建模。
|
||||
說白了,Hadoop 開放了將資料不加區分地轉儲到 HDFS 的可能性,允許後續再研究如何進一步處理【53】。相比之下,在將資料匯入資料庫專有儲存格式之前,MPP 資料庫通常需要對資料和查詢模式進行仔細的前期建模。
|
||||
|
||||
在純粹主義者看來,這種仔細的建模和匯入似乎是可取的,因為這意味著資料庫的使用者有更高質量的資料來處理。然而實踐經驗表明,簡單地使資料快速可用 —— 即使它很古怪,難以使用,使用原始格式 —— 也通常要比事先決定理想資料模型要更有價值【54】。
|
||||
|
||||
這個想法與資料倉庫類似(請參閱“[資料倉庫](ch3.md#資料倉庫)”):將大型組織的各個部分的資料集中在一起是很有價值的,因為它可以跨越以前相互分離的資料集進行連線。 MPP資料庫所要求的謹慎模式設計拖慢了集中式資料收集速度;以原始形式收集資料,稍後再操心模式的設計,能使資料收集速度加快(有時被稱為“**資料湖(data lake)**”或“**企業資料中心(enterprise data hub)**”【55】)。
|
||||
這個想法與資料倉庫類似(請參閱 “[資料倉庫](ch3.md#資料倉庫)”):將大型組織的各個部分的資料集中在一起是很有價值的,因為它可以跨越以前相互分離的資料集進行連線。 MPP 資料庫所要求的謹慎模式設計拖慢了集中式資料收集速度;以原始形式收集資料,稍後再操心模式的設計,能使資料收集速度加快(有時被稱為 “**資料湖(data lake)**” 或 “**企業資料中心(enterprise data hub)**”【55】)。
|
||||
|
||||
不加區分的資料轉儲轉移瞭解釋資料的負擔:資料集的生產者不再需要強制將其轉化為標準格式,資料的解釋成為消費者的問題(**讀時模式**方法【56】;請參閱“[文件模型中的模式靈活性](ch2.md#文件模型中的模式靈活性)”)。如果生產者和消費者是不同優先順序的不同團隊,這可能是一種優勢。甚至可能不存在一個理想的資料模型,對於不同目的有不同的合適視角。以原始形式簡單地轉儲資料,可以允許多種這樣的轉換。這種方法被稱為**壽司原則(sushi principle)**:“原始資料更好”【57】。
|
||||
不加區分的資料轉儲轉移瞭解釋資料的負擔:資料集的生產者不再需要強制將其轉化為標準格式,資料的解釋成為消費者的問題(**讀時模式** 方法【56】;請參閱 “[文件模型中的模式靈活性](ch2.md#文件模型中的模式靈活性)”)。如果生產者和消費者是不同優先順序的不同團隊,這可能是一種優勢。甚至可能不存在一個理想的資料模型,對於不同目的有不同的合適視角。以原始形式簡單地轉儲資料,可以允許多種這樣的轉換。這種方法被稱為 **壽司原則(sushi principle)**:“原始資料更好”【57】。
|
||||
|
||||
因此,Hadoop經常被用於實現ETL過程(請參閱“[資料倉庫](ch3.md#資料倉庫)”):事務處理系統中的資料以某種原始形式轉儲到分散式檔案系統中,然後編寫MapReduce作業來清理資料,將其轉換為關係形式,並將其匯入MPP資料倉庫以進行分析。資料建模仍然在進行,但它在一個單獨的步驟中進行,與資料收集相解耦。這種解耦是可行的,因為分散式檔案系統支援以任何格式編碼的資料。
|
||||
因此,Hadoop 經常被用於實現 ETL 過程(請參閱 “[資料倉庫](ch3.md#資料倉庫)”):事務處理系統中的資料以某種原始形式轉儲到分散式檔案系統中,然後編寫 MapReduce 作業來清理資料,將其轉換為關係形式,並將其匯入 MPP 資料倉庫以進行分析。資料建模仍然在進行,但它在一個單獨的步驟中進行,與資料收集相解耦。這種解耦是可行的,因為分散式檔案系統支援以任何格式編碼的資料。
|
||||
|
||||
#### 處理模型的多樣性
|
||||
|
||||
MPP資料庫是單體的,緊密整合的軟體,負責磁碟上的儲存佈局,查詢計劃,排程和執行。由於這些元件都可以針對資料庫的特定需求進行調整和最佳化,因此整個系統可以在其設計針對的查詢型別上取得非常好的效能。而且,SQL查詢語言允許以優雅的語法表達查詢,而無需編寫程式碼,可以在業務分析師使用的視覺化工具(例如Tableau)中訪問到。
|
||||
MPP 資料庫是單體的,緊密整合的軟體,負責磁碟上的儲存佈局,查詢計劃,排程和執行。由於這些元件都可以針對資料庫的特定需求進行調整和最佳化,因此整個系統可以在其設計針對的查詢型別上取得非常好的效能。而且,SQL 查詢語言允許以優雅的語法表達查詢,而無需編寫程式碼,可以在業務分析師使用的視覺化工具(例如 Tableau)中訪問到。
|
||||
|
||||
另一方面,並非所有型別的處理都可以合理地表達為SQL查詢。例如,如果要構建機器學習和推薦系統,或者使用相關性排名模型的全文搜尋索引,或者執行影象分析,則很可能需要更一般的資料處理模型。這些型別的處理通常是特別針對特定應用的(例如機器學習的特徵工程,機器翻譯的自然語言模型,欺詐預測的風險評估函式),因此它們不可避免地需要編寫程式碼,而不僅僅是查詢。
|
||||
另一方面,並非所有型別的處理都可以合理地表達為 SQL 查詢。例如,如果要構建機器學習和推薦系統,或者使用相關性排名模型的全文搜尋索引,或者執行影象分析,則很可能需要更一般的資料處理模型。這些型別的處理通常是特別針對特定應用的(例如機器學習的特徵工程,機器翻譯的自然語言模型,欺詐預測的風險評估函式),因此它們不可避免地需要編寫程式碼,而不僅僅是查詢。
|
||||
|
||||
MapReduce使工程師能夠輕鬆地在大型資料集上執行自己的程式碼。如果你有HDFS和MapReduce,那麼你**可以**在它之上建立一個SQL查詢執行引擎,事實上這正是Hive專案所做的【31】。但是,你也可以編寫許多其他形式的批處理,這些批處理不必非要用SQL查詢表示。
|
||||
MapReduce 使工程師能夠輕鬆地在大型資料集上執行自己的程式碼。如果你有 HDFS 和 MapReduce,那麼你 **可以** 在它之上建立一個 SQL 查詢執行引擎,事實上這正是 Hive 專案所做的【31】。但是,你也可以編寫許多其他形式的批處理,這些批處理不必非要用 SQL 查詢表示。
|
||||
|
||||
隨後,人們發現MapReduce對於某些型別的處理而言侷限性很大,表現很差,因此在Hadoop之上其他各種處理模型也被開發出來(我們將在“[MapReduce之後](#MapReduce之後)”中看到其中一些)。只有兩種處理模型,SQL和MapReduce,還不夠,需要更多不同的模型!而且由於Hadoop平臺的開放性,實施一整套方法是可行的,而這在單體MPP資料庫的範疇內是不可能的【58】。
|
||||
隨後,人們發現 MapReduce 對於某些型別的處理而言侷限性很大,表現很差,因此在 Hadoop 之上其他各種處理模型也被開發出來(我們將在 “[MapReduce 之後](#MapReduce之後)” 中看到其中一些)。只有兩種處理模型,SQL 和 MapReduce,還不夠,需要更多不同的模型!而且由於 Hadoop 平臺的開放性,實施一整套方法是可行的,而這在單體 MPP 資料庫的範疇內是不可能的【58】。
|
||||
|
||||
至關重要的是,這些不同的處理模型都可以在共享的單個機器叢集上執行,所有這些機器都可以訪問分散式檔案系統上的相同檔案。在Hadoop方式中,不需要將資料匯入到幾個不同的專用系統中進行不同型別的處理:系統足夠靈活,可以支援同一個叢集內不同的工作負載。不需要移動資料,使得從資料中挖掘價值變得容易得多,也使採用新的處理模型容易的多。
|
||||
至關重要的是,這些不同的處理模型都可以在共享的單個機器叢集上執行,所有這些機器都可以訪問分散式檔案系統上的相同檔案。在 Hadoop 方式中,不需要將資料匯入到幾個不同的專用系統中進行不同型別的處理:系統足夠靈活,可以支援同一個叢集內不同的工作負載。不需要移動資料,使得從資料中挖掘價值變得容易得多,也使採用新的處理模型容易的多。
|
||||
|
||||
Hadoop生態系統包括隨機訪問的OLTP資料庫,如HBase(請參閱“[SSTables和LSM樹](ch3.md#SSTables和LSM樹)”)和MPP風格的分析型資料庫,如Impala 【41】。 HBase與Impala都不使用MapReduce,但都使用HDFS進行儲存。它們是迥異的資料訪問與處理方法,但是它們可以共存,並被整合到同一個系統中。
|
||||
Hadoop 生態系統包括隨機訪問的 OLTP 資料庫,如 HBase(請參閱 “[SSTables 和 LSM 樹](ch3.md#SSTables和LSM樹)”)和 MPP 風格的分析型資料庫,如 Impala 【41】。 HBase 與 Impala 都不使用 MapReduce,但都使用 HDFS 進行儲存。它們是迥異的資料訪問與處理方法,但是它們可以共存,並被整合到同一個系統中。
|
||||
|
||||
#### 針對頻繁故障設計
|
||||
|
||||
當比較MapReduce和MPP資料庫時,兩種不同的設計思路出現了:處理故障和使用記憶體與磁碟的方式。與線上系統相比,批處理對故障不太敏感,因為就算失敗也不會立即影響到使用者,而且它們總是能再次執行。
|
||||
當比較 MapReduce 和 MPP 資料庫時,兩種不同的設計思路出現了:處理故障和使用記憶體與磁碟的方式。與線上系統相比,批處理對故障不太敏感,因為就算失敗也不會立即影響到使用者,而且它們總是能再次執行。
|
||||
|
||||
如果一個節點在執行查詢時崩潰,大多數MPP資料庫會中止整個查詢,並讓使用者重新提交查詢或自動重新執行它【3】。由於查詢通常最多執行幾秒鐘或幾分鐘,所以這種錯誤處理的方法是可以接受的,因為重試的代價不是太大。 MPP資料庫還傾向於在記憶體中保留儘可能多的資料(例如,使用雜湊連線)以避免從磁碟讀取的開銷。
|
||||
如果一個節點在執行查詢時崩潰,大多數 MPP 資料庫會中止整個查詢,並讓使用者重新提交查詢或自動重新執行它【3】。由於查詢通常最多執行幾秒鐘或幾分鐘,所以這種錯誤處理的方法是可以接受的,因為重試的代價不是太大。 MPP 資料庫還傾向於在記憶體中保留儘可能多的資料(例如,使用雜湊連線)以避免從磁碟讀取的開銷。
|
||||
|
||||
另一方面,MapReduce可以容忍單個Map或Reduce任務的失敗,而不會影響作業的整體,透過以單個任務的粒度重試工作。它也會非常急切地將資料寫入磁碟,一方面是為了容錯,另一部分是因為假設資料集太大而不能適應記憶體。
|
||||
另一方面,MapReduce 可以容忍單個 Map 或 Reduce 任務的失敗,而不會影響作業的整體,透過以單個任務的粒度重試工作。它也會非常急切地將資料寫入磁碟,一方面是為了容錯,另一部分是因為假設資料集太大而不能適應記憶體。
|
||||
|
||||
MapReduce方式更適用於較大的作業:要處理如此之多的資料並執行很長時間的作業,以至於在此過程中很可能至少遇到一個任務故障。在這種情況下,由於單個任務失敗而重新執行整個作業將是非常浪費的。即使以單個任務的粒度進行恢復引入了使得無故障處理更慢的開銷,但如果任務失敗率足夠高,這仍然是一種合理的權衡。
|
||||
MapReduce 方式更適用於較大的作業:要處理如此之多的資料並執行很長時間的作業,以至於在此過程中很可能至少遇到一個任務故障。在這種情況下,由於單個任務失敗而重新執行整個作業將是非常浪費的。即使以單個任務的粒度進行恢復引入了使得無故障處理更慢的開銷,但如果任務失敗率足夠高,這仍然是一種合理的權衡。
|
||||
|
||||
但是這些假設有多麼現實呢?在大多數叢集中,機器故障確實會發生,但是它們不是很頻繁 —— 可能少到絕大多數作業都不會經歷機器故障。為了容錯,真的值得帶來這麼大的額外開銷嗎?
|
||||
|
||||
要了解MapReduce節約使用記憶體和在任務的層次進行恢復的原因,瞭解最初設計MapReduce的環境是很有幫助的。 Google有著混用的資料中心,線上生產服務和離線批處理作業在同樣機器上執行。每個任務都有一個透過容器強制執行的資源配給(CPU核心,RAM,磁碟空間等)。每個任務也具有優先順序,如果優先順序較高的任務需要更多的資源,則可以終止(搶佔)同一臺機器上較低優先順序的任務以釋放資源。優先順序還決定了計算資源的定價:團隊必須為他們使用的資源付費,而優先順序更高的程序花費更多【59】。
|
||||
要了解 MapReduce 節約使用記憶體和在任務的層次進行恢復的原因,瞭解最初設計 MapReduce 的環境是很有幫助的。Google 有著混用的資料中心,線上生產服務和離線批處理作業在同樣機器上執行。每個任務都有一個透過容器強制執行的資源配給(CPU 核心、RAM、磁碟空間等)。每個任務也具有優先順序,如果優先順序較高的任務需要更多的資源,則可以終止(搶佔)同一臺機器上較低優先順序的任務以釋放資源。優先順序還決定了計算資源的定價:團隊必須為他們使用的資源付費,而優先順序更高的程序花費更多【59】。
|
||||
|
||||
這種架構允許非生產(低優先順序)計算資源被**過量使用(overcommitted)**,因為系統知道必要時它可以回收資源。與分離生產和非生產任務的系統相比,過量使用資源可以更好地利用機器並提高效率。但由於MapReduce作業以低優先順序執行,它們隨時都有被搶佔的風險,因為優先順序較高的程序可能需要其資源。在高優先順序程序拿走所需資源後,批次作業能有效地“撿麵包屑”,利用剩下的任何計算資源。
|
||||
這種架構允許非生產(低優先順序)計算資源被 **過量使用(overcommitted)**,因為系統知道必要時它可以回收資源。與分離生產和非生產任務的系統相比,過量使用資源可以更好地利用機器並提高效率。但由於 MapReduce 作業以低優先順序執行,它們隨時都有被搶佔的風險,因為優先順序較高的程序可能需要其資源。在高優先順序程序拿走所需資源後,批次作業能有效地 “撿麵包屑”,利用剩下的任何計算資源。
|
||||
|
||||
在谷歌,執行一個小時的MapReduce任務有大約有5%的風險被終止,為了給更高優先順序的程序挪地方。這一概率比硬體問題、機器重啟或其他原因的概率高了一個數量級【59】。按照這種搶佔率,如果一個作業有100個任務,每個任務執行10分鐘,那麼至少有一個任務在完成之前被終止的風險大於50%。
|
||||
在谷歌,執行一個小時的 MapReduce 任務有大約有 5% 的風險被終止,為了給更高優先順序的程序挪地方。這一概率比硬體問題、機器重啟或其他原因的概率高了一個數量級【59】。按照這種搶佔率,如果一個作業有 100 個任務,每個任務執行 10 分鐘,那麼至少有一個任務在完成之前被終止的風險大於 50%。
|
||||
|
||||
這就是MapReduce被設計為容忍頻繁意外任務終止的原因:不是因為硬體很不可靠,而是因為任意終止程序的自由有利於提高計算叢集中的資源利用率。
|
||||
這就是 MapReduce 被設計為容忍頻繁意外任務終止的原因:不是因為硬體很不可靠,而是因為任意終止程序的自由有利於提高計算叢集中的資源利用率。
|
||||
|
||||
在開源的叢集排程器中,搶佔的使用較少。 YARN的CapacityScheduler支援搶佔,以平衡不同佇列的資源分配【58】,但在編寫本文時,YARN,Mesos或Kubernetes不支援通用的優先順序搶佔【60】。在任務不經常被終止的環境中,MapReduce的這一設計決策就沒有多少意義了。在下一節中,我們將研究一些與MapReduce設計決策相異的替代方案。
|
||||
在開源的叢集排程器中,搶佔的使用較少。 YARN 的 CapacityScheduler 支援搶佔,以平衡不同佇列的資源分配【58】,但在編寫本文時,YARN,Mesos 或 Kubernetes 不支援通用的優先順序搶佔【60】。在任務不經常被終止的環境中,MapReduce 的這一設計決策就沒有多少意義了。在下一節中,我們將研究一些與 MapReduce 設計決策相異的替代方案。
|
||||
|
||||
|
||||
## MapReduce之後
|
||||
|
||||
雖然MapReduce在2000年代後期變得非常流行,並受到大量的炒作,但它只是分散式系統的許多可能的程式設計模型之一。對於不同的資料量,資料結構和處理型別,其他工具可能更適合表示計算。
|
||||
雖然 MapReduce 在 2000 年代後期變得非常流行,並受到大量的炒作,但它只是分散式系統的許多可能的程式設計模型之一。對於不同的資料量,資料結構和處理型別,其他工具可能更適合表示計算。
|
||||
|
||||
|
||||
不管如何,我們在這一章花了大把時間來討論MapReduce,因為它是一種有用的學習工具,它是分散式檔案系統的一種相當簡單明晰的抽象。在這裡,**簡單**意味著我們能理解它在做什麼,而不是意味著使用它很簡單。恰恰相反:使用原始的MapReduce API來實現複雜的處理工作實際上是非常困難和費力的 —— 例如,任意一種連線演算法都需要你從頭開始實現【37】。
|
||||
不管如何,我們在這一章花了大把時間來討論 MapReduce,因為它是一種有用的學習工具,它是分散式檔案系統的一種相當簡單明晰的抽象。在這裡,**簡單** 意味著我們能理解它在做什麼,而不是意味著使用它很簡單。恰恰相反:使用原始的 MapReduce API 來實現複雜的處理工作實際上是非常困難和費力的 —— 例如,任意一種連線演算法都需要你從頭開始實現【37】。
|
||||
|
||||
針對直接使用MapReduce的困難,在MapReduce上有很多高階程式設計模型(Pig,Hive,Cascading,Crunch)被創造出來,作為建立在MapReduce之上的抽象。如果你瞭解MapReduce的原理,那麼它們學起來相當簡單。而且它們的高階結構能顯著簡化許多常見批處理任務的實現。
|
||||
針對直接使用 MapReduce 的困難,在 MapReduce 上有很多高階程式設計模型(Pig、Hive、Cascading、Crunch)被創造出來,作為建立在 MapReduce 之上的抽象。如果你瞭解 MapReduce 的原理,那麼它們學起來相當簡單。而且它們的高階結構能顯著簡化許多常見批處理任務的實現。
|
||||
|
||||
但是,MapReduce執行模型本身也存在一些問題,這些問題並沒有透過增加另一個抽象層次而解決,而對於某些型別的處理,它表現得非常差勁。一方面,MapReduce非常穩健:你可以使用它在任務會頻繁終止的多租戶系統上處理幾乎任意大量級的資料,並且仍然可以完成工作(雖然速度很慢)。另一方面,對於某些型別的處理而言,其他工具有時會快上幾個數量級。
|
||||
但是,MapReduce 執行模型本身也存在一些問題,這些問題並沒有透過增加另一個抽象層次而解決,而對於某些型別的處理,它表現得非常差勁。一方面,MapReduce 非常穩健:你可以使用它在任務會頻繁終止的多租戶系統上處理幾乎任意大量級的資料,並且仍然可以完成工作(雖然速度很慢)。另一方面,對於某些型別的處理而言,其他工具有時會快上幾個數量級。
|
||||
|
||||
在本章的其餘部分中,我們將介紹一些批處理方法。在[第十一章](ch11.md)我們將轉向流處理,它可以看作是加速批處理的另一種方法。
|
||||
在本章的其餘部分中,我們將介紹一些批處理方法。在 [第十一章](ch11.md) 我們將轉向流處理,它可以看作是加速批處理的另一種方法。
|
||||
|
||||
### 物化中間狀態
|
||||
|
||||
如前所述,每個MapReduce作業都獨立於其他任何作業。作業與世界其他地方的主要連線點是分散式檔案系統上的輸入和輸出目錄。如果希望一個作業的輸出成為第二個作業的輸入,則需要將第二個作業的輸入目錄配置為第一個作業輸出目錄,且外部工作流排程程式必須在第一個作業完成後再啟動第二個。
|
||||
如前所述,每個 MapReduce 作業都獨立於其他任何作業。作業與世界其他地方的主要連線點是分散式檔案系統上的輸入和輸出目錄。如果希望一個作業的輸出成為第二個作業的輸入,則需要將第二個作業的輸入目錄配置為第一個作業輸出目錄,且外部工作流排程程式必須在第一個作業完成後再啟動第二個。
|
||||
|
||||
如果第一個作業的輸出是要在組織內廣泛釋出的資料集,則這種配置是合理的。在這種情況下,你需要透過名稱引用它,並將其重用為多個不同作業的輸入(包括由其他團隊開發的作業)。將資料釋出到分散式檔案系統中眾所周知的位置能夠帶來**松耦合**,這樣作業就不需要知道是誰在提供輸入或誰在消費輸出(請參閱“[邏輯與佈線相分離](#邏輯與佈線相分離)”)。
|
||||
如果第一個作業的輸出是要在組織內廣泛釋出的資料集,則這種配置是合理的。在這種情況下,你需要透過名稱引用它,並將其重用為多個不同作業的輸入(包括由其他團隊開發的作業)。將資料釋出到分散式檔案系統中眾所周知的位置能夠帶來 **松耦合**,這樣作業就不需要知道是誰在提供輸入或誰在消費輸出(請參閱 “[邏輯與佈線相分離](#邏輯與佈線相分離)”)。
|
||||
|
||||
但在很多情況下,你知道一個作業的輸出只能用作另一個作業的輸入,這些作業由同一個團隊維護。在這種情況下,分散式檔案系統上的檔案只是簡單的**中間狀態(intermediate state)**:一種將資料從一個作業傳遞到下一個作業的方式。在一個用於構建推薦系統的,由50或100個MapReduce作業組成的複雜工作流中,存在著很多這樣的中間狀態【29】。
|
||||
但在很多情況下,你知道一個作業的輸出只能用作另一個作業的輸入,這些作業由同一個團隊維護。在這種情況下,分散式檔案系統上的檔案只是簡單的 **中間狀態(intermediate state)**:一種將資料從一個作業傳遞到下一個作業的方式。在一個用於構建推薦系統的,由 50 或 100 個 MapReduce 作業組成的複雜工作流中,存在著很多這樣的中間狀態【29】。
|
||||
|
||||
將這個中間狀態寫入檔案的過程稱為**物化(materialization)**。 (在“[聚合:資料立方體和物化檢視](ch3.md#聚合:資料立方體和物化檢視)”中已經在物化檢視的背景中遇到過這個術語。它意味著對某個操作的結果立即求值並寫出來,而不是在請求時按需計算)
|
||||
將這個中間狀態寫入檔案的過程稱為 **物化(materialization)**。 (在 “[聚合:資料立方體和物化檢視](ch3.md#聚合:資料立方體和物化檢視)” 中已經在物化檢視的背景中遇到過這個術語。它意味著對某個操作的結果立即求值並寫出來,而不是在請求時按需計算)
|
||||
|
||||
作為對照,本章開頭的日誌分析示例使用Unix管道將一個命令的輸出與另一個命令的輸入連線起來。管道並沒有完全物化中間狀態,而是隻使用一個小的記憶體緩衝區,將輸出增量地**流(stream)** 向輸入。
|
||||
作為對照,本章開頭的日誌分析示例使用 Unix 管道將一個命令的輸出與另一個命令的輸入連線起來。管道並沒有完全物化中間狀態,而是隻使用一個小的記憶體緩衝區,將輸出增量地 **流(stream)** 向輸入。
|
||||
|
||||
與Unix管道相比,MapReduce完全物化中間狀態的方法存在不足之處:
|
||||
與 Unix 管道相比,MapReduce 完全物化中間狀態的方法存在不足之處:
|
||||
|
||||
- MapReduce作業只有在前驅作業(生成其輸入)中的所有任務都完成時才能啟動,而由Unix管道連線的程序會同時啟動,輸出一旦生成就會被消費。不同機器上的資料偏斜或負載不均意味著一個作業往往會有一些掉隊的任務,比其他任務要慢得多才能完成。必須等待至前驅作業的所有任務完成,拖慢了整個工作流程的執行。
|
||||
- Mapper通常是多餘的:它們僅僅是讀取剛剛由Reducer寫入的同樣檔案,為下一個階段的分割槽和排序做準備。在許多情況下,Mapper程式碼可能是前驅Reducer的一部分:如果Reducer和Mapper的輸出有著相同的分割槽與排序方式,那麼Reducer就可以直接串在一起,而不用與Mapper相互交織。
|
||||
- MapReduce 作業只有在前驅作業(生成其輸入)中的所有任務都完成時才能啟動,而由 Unix 管道連線的程序會同時啟動,輸出一旦生成就會被消費。不同機器上的資料偏斜或負載不均意味著一個作業往往會有一些掉隊的任務,比其他任務要慢得多才能完成。必須等待至前驅作業的所有任務完成,拖慢了整個工作流程的執行。
|
||||
- Mapper 通常是多餘的:它們僅僅是讀取剛剛由 Reducer 寫入的同樣檔案,為下一個階段的分割槽和排序做準備。在許多情況下,Mapper 程式碼可能是前驅 Reducer 的一部分:如果 Reducer 和 Mapper 的輸出有著相同的分割槽與排序方式,那麼 Reducer 就可以直接串在一起,而不用與 Mapper 相互交織。
|
||||
- 將中間狀態儲存在分散式檔案系統中意味著這些檔案被複制到多個節點,對這些臨時資料這麼搞就比較過分了。
|
||||
|
||||
#### 資料流引擎
|
||||
|
||||
為了解決MapReduce的這些問題,幾種用於分散式批處理的新執行引擎被開發出來,其中最著名的是Spark 【61,62】,Tez 【63,64】和Flink 【65,66】。它們的設計方式有很多區別,但有一個共同點:把整個工作流作為單個作業來處理,而不是把它分解為獨立的子作業。
|
||||
為了解決 MapReduce 的這些問題,幾種用於分散式批處理的新執行引擎被開發出來,其中最著名的是 Spark 【61,62】,Tez 【63,64】和 Flink 【65,66】。它們的設計方式有很多區別,但有一個共同點:把整個工作流作為單個作業來處理,而不是把它分解為獨立的子作業。
|
||||
|
||||
由於它們將工作流顯式建模為資料從幾個處理階段穿過,所以這些系統被稱為**資料流引擎(dataflow engines)**。像MapReduce一樣,它們在一條線上透過反覆呼叫使用者定義的函式來一次處理一條記錄,它們透過輸入分割槽來並行化載荷,它們透過網路將一個函式的輸出複製到另一個函式的輸入。
|
||||
由於它們將工作流顯式建模為資料從幾個處理階段穿過,所以這些系統被稱為 **資料流引擎(dataflow engines)**。像 MapReduce 一樣,它們在一條線上透過反覆呼叫使用者定義的函式來一次處理一條記錄,它們透過輸入分割槽來並行化載荷,它們透過網路將一個函式的輸出複製到另一個函式的輸入。
|
||||
|
||||
與MapReduce不同,這些函式不需要嚴格扮演交織的Map與Reduce的角色,而是可以以更靈活的方式進行組合。我們稱這些函式為**運算元(operators)**,資料流引擎提供了幾種不同的選項來將一個運算元的輸出連線到另一個運算元的輸入:
|
||||
與 MapReduce 不同,這些函式不需要嚴格扮演交織的 Map 與 Reduce 的角色,而是可以以更靈活的方式進行組合。我們稱這些函式為 **運算元(operators)**,資料流引擎提供了幾種不同的選項來將一個運算元的輸出連線到另一個運算元的輸入:
|
||||
|
||||
- 一種選項是對記錄按鍵重新分割槽並排序,就像在MapReduce的混洗階段一樣(請參閱“[分散式執行MapReduce](#分散式執行MapReduce)”)。這種功能可以用於實現排序合併連線和分組,就像在MapReduce中一樣。
|
||||
- 一種選項是對記錄按鍵重新分割槽並排序,就像在 MapReduce 的混洗階段一樣(請參閱 “[分散式執行 MapReduce](#分散式執行MapReduce)”)。這種功能可以用於實現排序合併連線和分組,就像在 MapReduce 中一樣。
|
||||
- 另一種可能是接受多個輸入,並以相同的方式進行分割槽,但跳過排序。當記錄的分割槽重要但順序無關緊要時,這省去了分割槽雜湊連線的工作,因為構建散列表還是會把順序隨機打亂。
|
||||
- 對於廣播雜湊連線,可以將一個運算元的輸出,傳送到連線運算元的所有分割槽。
|
||||
|
||||
這種型別的處理引擎是基於像Dryad【67】和Nephele【68】這樣的研究系統,與MapReduce模型相比,它有幾個優點:
|
||||
這種型別的處理引擎是基於像 Dryad【67】和 Nephele【68】這樣的研究系統,與 MapReduce 模型相比,它有幾個優點:
|
||||
|
||||
- 排序等昂貴的工作只需要在實際需要的地方執行,而不是預設地在每個Map和Reduce階段之間出現。
|
||||
- 沒有不必要的Map任務,因為Mapper所做的工作通常可以合併到前面的Reduce運算元中(因為Mapper不會更改資料集的分割槽)。
|
||||
- 排序等昂貴的工作只需要在實際需要的地方執行,而不是預設地在每個 Map 和 Reduce 階段之間出現。
|
||||
- 沒有不必要的 Map 任務,因為 Mapper 所做的工作通常可以合併到前面的 Reduce 運算元中(因為 Mapper 不會更改資料集的分割槽)。
|
||||
- 由於工作流中的所有連線和資料依賴都是顯式宣告的,因此排程程式能夠總覽全域性,知道哪裡需要哪些資料,因而能夠利用區域性性進行最佳化。例如,它可以嘗試將消費某些資料的任務放在與生成這些資料的任務相同的機器上,從而資料可以透過共享記憶體緩衝區傳輸,而不必透過網路複製。
|
||||
- 通常,運算元間的中間狀態足以儲存在記憶體中或寫入本地磁碟,這比寫入HDFS需要更少的I/O(必須將其複製到多臺機器,並將每個副本寫入磁碟)。 MapReduce已經對Mapper的輸出做了這種最佳化,但資料流引擎將這種思想推廣至所有的中間狀態。
|
||||
- 通常,運算元間的中間狀態足以儲存在記憶體中或寫入本地磁碟,這比寫入 HDFS 需要更少的 I/O(必須將其複製到多臺機器,並將每個副本寫入磁碟)。 MapReduce 已經對 Mapper 的輸出做了這種最佳化,但資料流引擎將這種思想推廣至所有的中間狀態。
|
||||
- 運算元可以在輸入就緒後立即開始執行;後續階段無需等待前驅階段整個完成後再開始。
|
||||
- 與MapReduce(為每個任務啟動一個新的JVM)相比,現有Java虛擬機器(JVM)程序可以重用來執行新運算元,從而減少啟動開銷。
|
||||
- 與 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。
|
||||
|
||||
#### 容錯
|
||||
|
||||
完全物化中間狀態至分散式檔案系統的一個優點是,它具有永續性,這使得MapReduce中的容錯相當容易:如果一個任務失敗,它可以在另一臺機器上重新啟動,並從檔案系統重新讀取相同的輸入。
|
||||
完全物化中間狀態至分散式檔案系統的一個優點是,它具有永續性,這使得 MapReduce 中的容錯相當容易:如果一個任務失敗,它可以在另一臺機器上重新啟動,並從檔案系統重新讀取相同的輸入。
|
||||
|
||||
Spark,Flink和Tez避免將中間狀態寫入HDFS,因此它們採取了不同的方法來容錯:如果一臺機器發生故障,並且該機器上的中間狀態丟失,則它會從其他仍然可用的資料重新計算(在可行的情況下是先前的中間狀態,要麼就只能是原始輸入資料,通常在HDFS上)。
|
||||
Spark、Flink 和 Tez 避免將中間狀態寫入 HDFS,因此它們採取了不同的方法來容錯:如果一臺機器發生故障,並且該機器上的中間狀態丟失,則它會從其他仍然可用的資料重新計算(在可行的情況下是先前的中間狀態,要麼就只能是原始輸入資料,通常在 HDFS 上)。
|
||||
|
||||
為了實現這種重新計算,框架必須跟蹤一個給定的資料是如何計算的 —— 使用了哪些輸入分割槽?應用了哪些運算元? Spark使用**彈性分散式資料集(RDD,Resilient Distributed Dataset)** 的抽象來跟蹤資料的譜系【61】,而Flink對運算元狀態存檔,允許恢復執行在執行過程中遇到錯誤的運算元【66】。
|
||||
為了實現這種重新計算,框架必須跟蹤一個給定的資料是如何計算的 —— 使用了哪些輸入分割槽?應用了哪些運算元? Spark 使用 **彈性分散式資料集(RDD,Resilient Distributed Dataset)** 的抽象來跟蹤資料的譜系【61】,而 Flink 對運算元狀態存檔,允許恢復執行在執行過程中遇到錯誤的運算元【66】。
|
||||
|
||||
在重新計算資料時,重要的是要知道計算是否是**確定性的**:也就是說,給定相同的輸入資料,運算元是否始終產生相同的輸出?如果一些丟失的資料已經發送給下游運算元,這個問題就很重要。如果運算元重新啟動,重新計算的資料與原有的丟失資料不一致,下游運算元很難解決新舊資料之間的矛盾。對於不確定性運算元來說,解決方案通常是殺死下游運算元,然後再重跑新資料。
|
||||
在重新計算資料時,重要的是要知道計算是否是 **確定性的**:也就是說,給定相同的輸入資料,運算元是否始終產生相同的輸出?如果一些丟失的資料已經發送給下游運算元,這個問題就很重要。如果運算元重新啟動,重新計算的資料與原有的丟失資料不一致,下游運算元很難解決新舊資料之間的矛盾。對於不確定性運算元來說,解決方案通常是殺死下游運算元,然後再重跑新資料。
|
||||
|
||||
為了避免這種級聯故障,最好讓運算元具有確定性。但需要注意的是,非確定性行為很容易悄悄溜進來:例如,許多程式語言在迭代雜湊表的元素時不能對順序作出保證,許多概率和統計演算法顯式依賴於使用隨機數,以及用到系統時鐘或外部資料來源,這些都是都不確定性的行為。為了能可靠地從故障中恢復,需要消除這種不確定性因素,例如使用固定的種子生成偽隨機數。
|
||||
|
||||
@ -589,136 +589,136 @@ Spark,Flink和Tez避免將中間狀態寫入HDFS,因此它們採取了不同
|
||||
|
||||
#### 關於物化的討論
|
||||
|
||||
回到Unix的類比,我們看到,MapReduce就像是將每個命令的輸出寫入臨時檔案,而資料流引擎看起來更像是Unix管道。尤其是Flink是基於管道執行的思想而建立的:也就是說,將運算元的輸出增量地傳遞給其他運算元,不待輸入完成便開始處理。
|
||||
回到 Unix 的類比,我們看到,MapReduce 就像是將每個命令的輸出寫入臨時檔案,而資料流引擎看起來更像是 Unix 管道。尤其是 Flink 是基於管道執行的思想而建立的:也就是說,將運算元的輸出增量地傳遞給其他運算元,不待輸入完成便開始處理。
|
||||
|
||||
排序運算元不可避免地需要消費全部的輸入後才能生成任何輸出,因為輸入中最後一條輸入記錄可能具有最小的鍵,因此需要作為第一條記錄輸出。因此,任何需要排序的運算元都需要至少暫時地累積狀態。但是工作流的許多其他部分可以以流水線方式執行。
|
||||
|
||||
當作業完成時,它的輸出需要持續到某個地方,以便使用者可以找到並使用它—— 很可能它會再次寫入分散式檔案系統。因此,在使用資料流引擎時,HDFS上的物化資料集通常仍是作業的輸入和最終輸出。和MapReduce一樣,輸入是不可變的,輸出被完全替換。比起MapReduce的改進是,你不用再自己去將中間狀態寫入檔案系統了。
|
||||
當作業完成時,它的輸出需要持續到某個地方,以便使用者可以找到並使用它 —— 很可能它會再次寫入分散式檔案系統。因此,在使用資料流引擎時,HDFS 上的物化資料集通常仍是作業的輸入和最終輸出。和 MapReduce 一樣,輸入是不可變的,輸出被完全替換。比起 MapReduce 的改進是,你不用再自己去將中間狀態寫入檔案系統了。
|
||||
|
||||
### 圖與迭代處理
|
||||
|
||||
在“[圖資料模型](ch2.md#圖資料模型)”中,我們討論了使用圖來建模資料,並使用圖查詢語言來遍歷圖中的邊與點。[第二章](ch2.md)的討論集中在OLTP風格的應用場景:快速執行查詢來查詢少量符合特定條件的頂點。
|
||||
在 “[圖資料模型](ch2.md#圖資料模型)” 中,我們討論了使用圖來建模資料,並使用圖查詢語言來遍歷圖中的邊與點。[第二章](ch2.md) 的討論集中在 OLTP 風格的應用場景:快速執行查詢來查詢少量符合特定條件的頂點。
|
||||
|
||||
批處理上下文中的圖也很有趣,其目標是在整個圖上執行某種離線處理或分析。這種需求經常出現在機器學習應用(如推薦引擎)或排序系統中。例如,最著名的圖形分析演算法之一是PageRank 【69】,它試圖根據連結到某個網頁的其他網頁來估計該網頁的流行度。它作為配方的一部分,用於確定網路搜尋引擎呈現結果的順序。
|
||||
批處理上下文中的圖也很有趣,其目標是在整個圖上執行某種離線處理或分析。這種需求經常出現在機器學習應用(如推薦引擎)或排序系統中。例如,最著名的圖形分析演算法之一是 PageRank 【69】,它試圖根據連結到某個網頁的其他網頁來估計該網頁的流行度。它作為配方的一部分,用於確定網路搜尋引擎呈現結果的順序。
|
||||
|
||||
> 像Spark,Flink和Tez這樣的資料流引擎(請參閱“[物化中間狀態](#物化中間狀態)”)通常將運算元作為**有向無環圖(DAG)** 的一部分安排在作業中。這與圖處理不一樣:在資料流引擎中,**從一個運算元到另一個運算元的資料流**被構造成一個圖,而資料本身通常由關係型元組構成。在圖處理中,資料本身具有圖的形式。又一個不幸的命名混亂!
|
||||
> 像 Spark、Flink 和 Tez 這樣的資料流引擎(請參閱 “[物化中間狀態](#物化中間狀態)”)通常將運算元作為 **有向無環圖(DAG)** 的一部分安排在作業中。這與圖處理不一樣:在資料流引擎中,**從一個運算元到另一個運算元的資料流** 被構造成一個圖,而資料本身通常由關係型元組構成。在圖處理中,資料本身具有圖的形式。又一個不幸的命名混亂!
|
||||
|
||||
許多圖演算法是透過一次遍歷一條邊來表示的,將一個頂點與近鄰的頂點連線起來,以傳播一些資訊,並不斷重複,直到滿足一些條件為止 —— 例如,直到沒有更多的邊要跟進,或直到一些指標收斂。我們在[圖2-6](../img/fig2-6.png)中看到一個例子,它透過重複跟進標明地點歸屬關係的邊,生成了資料庫中北美包含的所有地點列表(這種演算法被稱為**傳遞閉包**,即transitive closure)。
|
||||
許多圖演算法是透過一次遍歷一條邊來表示的,將一個頂點與近鄰的頂點連線起來,以傳播一些資訊,並不斷重複,直到滿足一些條件為止 —— 例如,直到沒有更多的邊要跟進,或直到一些指標收斂。我們在 [圖 2-6](../img/fig2-6.png) 中看到一個例子,它透過重複跟進標明地點歸屬關係的邊,生成了資料庫中北美包含的所有地點列表(這種演算法被稱為 **傳遞閉包**,即 transitive closure)。
|
||||
|
||||
可以在分散式檔案系統中儲存圖(包含頂點和邊的列表的檔案),但是這種“重複至完成”的想法不能用普通的MapReduce來表示,因為它只掃過一趟資料。這種演算法因此經常以**迭代**的風格實現:
|
||||
可以在分散式檔案系統中儲存圖(包含頂點和邊的列表的檔案),但是這種 “重複至完成” 的想法不能用普通的 MapReduce 來表示,因為它只掃過一趟資料。這種演算法因此經常以 **迭代** 的風格實現:
|
||||
|
||||
1. 外部排程程式執行批處理來計算演算法的一個步驟。
|
||||
2. 當批處理過程完成時,排程器檢查它是否完成(基於完成條件 —— 例如,沒有更多的邊要跟進,或者與上次迭代相比的變化低於某個閾值)。
|
||||
3. 如果尚未完成,則排程程式返回到步驟1並執行另一輪批處理。
|
||||
3. 如果尚未完成,則排程程式返回到步驟 1 並執行另一輪批處理。
|
||||
|
||||
這種方法是有效的,但是用MapReduce實現它往往非常低效,因為MapReduce沒有考慮演算法的迭代性質:它總是讀取整個輸入資料集併產生一個全新的輸出資料集,即使與上次迭代相比,改變的僅僅是圖中的一小部分。
|
||||
這種方法是有效的,但是用 MapReduce 實現它往往非常低效,因為 MapReduce 沒有考慮演算法的迭代性質:它總是讀取整個輸入資料集併產生一個全新的輸出資料集,即使與上次迭代相比,改變的僅僅是圖中的一小部分。
|
||||
|
||||
#### Pregel處理模型
|
||||
|
||||
針對圖批處理的最佳化 —— **批次同步並行(BSP,Bulk Synchronous Parallel)** 計算模型【70】已經開始流行起來。其中,Apache Giraph 【37】,Spark的GraphX API和Flink的Gelly API 【71】實現了它。它也被稱為**Pregel**模型,因為Google的Pregel論文推廣了這種處理圖的方法【72】。
|
||||
針對圖批處理的最佳化 —— **批次同步並行(BSP,Bulk Synchronous Parallel)** 計算模型【70】已經開始流行起來。其中,Apache Giraph 【37】,Spark 的 GraphX API 和 Flink 的 Gelly API 【71】實現了它。它也被稱為 **Pregel** 模型,因為 Google 的 Pregel 論文推廣了這種處理圖的方法【72】。
|
||||
|
||||
回想一下在MapReduce中,Mapper在概念上向Reducer的特定呼叫“傳送訊息”,因為框架將所有具有相同鍵的Mapper輸出集中在一起。 Pregel背後有一個類似的想法:一個頂點可以向另一個頂點“傳送訊息”,通常這些訊息是沿著圖的邊傳送的。
|
||||
回想一下在 MapReduce 中,Mapper 在概念上向 Reducer 的特定呼叫 “傳送訊息”,因為框架將所有具有相同鍵的 Mapper 輸出集中在一起。 Pregel 背後有一個類似的想法:一個頂點可以向另一個頂點 “傳送訊息”,通常這些訊息是沿著圖的邊傳送的。
|
||||
|
||||
在每次迭代中,為每個頂點呼叫一個函式,將所有傳送給它的訊息傳遞給它 —— 就像呼叫Reducer一樣。與MapReduce的不同之處在於,在Pregel模型中,頂點在一次迭代到下一次迭代的過程中會記住它的狀態,所以這個函式只需要處理新的傳入訊息。如果圖的某個部分沒有被傳送訊息,那裡就不需要做任何工作。
|
||||
在每次迭代中,為每個頂點呼叫一個函式,將所有傳送給它的訊息傳遞給它 —— 就像呼叫 Reducer 一樣。與 MapReduce 的不同之處在於,在 Pregel 模型中,頂點在一次迭代到下一次迭代的過程中會記住它的狀態,所以這個函式只需要處理新的傳入訊息。如果圖的某個部分沒有被傳送訊息,那裡就不需要做任何工作。
|
||||
|
||||
這與Actor模型有些相似(請參閱“[分散式的Actor框架](ch4.md#分散式的Actor框架)”),除了頂點狀態和頂點之間的訊息具有容錯性和永續性,且通訊以固定的回合進行:在每次迭代中,框架遞送上次迭代中傳送的所有訊息。Actor通常沒有這樣的時序保證。
|
||||
這與 Actor 模型有些相似(請參閱 “[分散式的 Actor 框架](ch4.md#分散式的Actor框架)”),除了頂點狀態和頂點之間的訊息具有容錯性和永續性,且通訊以固定的回合進行:在每次迭代中,框架遞送上次迭代中傳送的所有訊息。Actor 通常沒有這樣的時序保證。
|
||||
|
||||
#### 容錯
|
||||
|
||||
頂點只能透過訊息傳遞進行通訊(而不是直接相互查詢)的事實有助於提高Pregel作業的效能,因為訊息可以成批處理,且等待通訊的次數也減少了。唯一的等待是在迭代之間:由於Pregel模型保證所有在一輪迭代中傳送的訊息都在下輪迭代中送達,所以在下一輪迭代開始前,先前的迭代必須完全完成,而所有的訊息必須在網路上完成複製。
|
||||
頂點只能透過訊息傳遞進行通訊(而不是直接相互查詢)的事實有助於提高 Pregel 作業的效能,因為訊息可以成批處理,且等待通訊的次數也減少了。唯一的等待是在迭代之間:由於 Pregel 模型保證所有在一輪迭代中傳送的訊息都在下輪迭代中送達,所以在下一輪迭代開始前,先前的迭代必須完全完成,而所有的訊息必須在網路上完成複製。
|
||||
|
||||
即使底層網路可能丟失、重複或任意延遲訊息(請參閱“[不可靠的網路](ch8.md#不可靠的網路)”),Pregel的實現能保證在後續迭代中訊息在其目標頂點恰好處理一次。像MapReduce一樣,框架能從故障中透明地恢復,以簡化在Pregel上實現演算法的程式設計模型。
|
||||
即使底層網路可能丟失、重複或任意延遲訊息(請參閱 “[不可靠的網路](ch8.md#不可靠的網路)”),Pregel 的實現能保證在後續迭代中訊息在其目標頂點恰好處理一次。像 MapReduce 一樣,框架能從故障中透明地恢復,以簡化在 Pregel 上實現演算法的程式設計模型。
|
||||
|
||||
這種容錯是透過在迭代結束時,定期存檔所有頂點的狀態來實現的,即將其全部狀態寫入持久化儲存。如果某個節點發生故障並且其記憶體中的狀態丟失,則最簡單的解決方法是將整個圖計算回滾到上一個存檔點,然後重啟計算。如果演算法是確定性的,且訊息記錄在日誌中,那麼也可以選擇性地只恢復丟失的分割槽(就像之前討論過的資料流引擎)【72】。
|
||||
|
||||
#### 並行執行
|
||||
|
||||
頂點不需要知道它在哪臺物理機器上執行;當它向其他頂點發送訊息時,它只是簡單地將訊息發往某個頂點ID。圖的分割槽取決於框架 —— 即,確定哪個頂點執行在哪臺機器上,以及如何透過網路路由訊息,以便它們到達正確的地方。
|
||||
頂點不需要知道它在哪臺物理機器上執行;當它向其他頂點發送訊息時,它只是簡單地將訊息發往某個頂點 ID。圖的分割槽取決於框架 —— 即,確定哪個頂點執行在哪臺機器上,以及如何透過網路路由訊息,以便它們到達正確的地方。
|
||||
|
||||
由於程式設計模型一次僅處理一個頂點(有時稱為“像頂點一樣思考”),所以框架可以以任意方式對圖分割槽。理想情況下如果頂點需要進行大量的通訊,那麼它們最好能被分割槽到同一臺機器上。然而找到這樣一種最佳化的分割槽方法是很困難的 —— 在實踐中,圖經常按照任意分配的頂點ID分割槽,而不會嘗試將相關的頂點分組在一起。
|
||||
由於程式設計模型一次僅處理一個頂點(有時稱為 “像頂點一樣思考”),所以框架可以以任意方式對圖分割槽。理想情況下如果頂點需要進行大量的通訊,那麼它們最好能被分割槽到同一臺機器上。然而找到這樣一種最佳化的分割槽方法是很困難的 —— 在實踐中,圖經常按照任意分配的頂點 ID 分割槽,而不會嘗試將相關的頂點分組在一起。
|
||||
|
||||
因此,圖演算法通常會有很多跨機器通訊的額外開銷,而中間狀態(節點之間傳送的訊息)往往比原始圖大。透過網路傳送訊息的開銷會顯著拖慢分散式圖演算法的速度。
|
||||
|
||||
出於這個原因,如果你的圖可以放入一臺計算機的記憶體中,那麼單機(甚至可能是單執行緒)演算法很可能會超越分散式批處理【73,74】。圖比記憶體大也沒關係,只要能放入單臺計算機的磁碟,使用GraphChi等框架進行單機處理是就一個可行的選擇【75】。如果圖太大,不適合單機處理,那麼像Pregel這樣的分散式方法是不可避免的。高效的並行圖演算法是一個進行中的研究領域【76】。
|
||||
出於這個原因,如果你的圖可以放入一臺計算機的記憶體中,那麼單機(甚至可能是單執行緒)演算法很可能會超越分散式批處理【73,74】。圖比記憶體大也沒關係,只要能放入單臺計算機的磁碟,使用 GraphChi 等框架進行單機處理是就一個可行的選擇【75】。如果圖太大,不適合單機處理,那麼像 Pregel 這樣的分散式方法是不可避免的。高效的並行圖演算法是一個進行中的研究領域【76】。
|
||||
|
||||
|
||||
### 高階API和語言
|
||||
|
||||
自MapReduce開始流行的這幾年以來,分散式批處理的執行引擎已經很成熟了。到目前為止,基礎設施已經足夠強大,能夠儲存和處理超過10,000臺機器叢集上的數PB的資料。由於在這種規模下物理執行批處理的問題已經被認為或多或少解決了,所以關注點已經轉向其他領域:改進程式設計模型,提高處理效率,擴大這些技術可以解決的問題集。
|
||||
自 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通常使用關係型構建塊來表達一個計算:按某個欄位連線資料集;按鍵對元組做分組;按某些條件過濾;並透過計數求和或其他函式來聚合元組。在內部,這些操作是使用本章前面討論過的各種連線和分組演算法來實現的。
|
||||
這些資料流 API 通常使用關係型構建塊來表達一個計算:按某個欄位連線資料集;按鍵對元組做分組;按某些條件過濾;並透過計數求和或其他函式來聚合元組。在內部,這些操作是使用本章前面討論過的各種連線和分組演算法來實現的。
|
||||
|
||||
除了少寫程式碼的明顯優勢之外,這些高階介面還支援互動式用法,在這種互動式使用中,你可以在Shell中增量式編寫分析程式碼,頻繁執行來觀察它做了什麼。這種開發風格在探索資料集和試驗處理方法時非常有用。這也讓人聯想到Unix哲學,我們在“[Unix哲學](#Unix哲學)”中討論過這個問題。
|
||||
除了少寫程式碼的明顯優勢之外,這些高階介面還支援互動式用法,在這種互動式使用中,你可以在 Shell 中增量式編寫分析程式碼,頻繁執行來觀察它做了什麼。這種開發風格在探索資料集和試驗處理方法時非常有用。這也讓人聯想到 Unix 哲學,我們在 “[Unix 哲學](#Unix哲學)” 中討論過這個問題。
|
||||
|
||||
此外,這些高階介面不僅提高了人類的工作效率,也提高了機器層面的作業執行效率。
|
||||
|
||||
#### 向宣告式查詢語言的轉變
|
||||
|
||||
與硬寫執行連線的程式碼相比,指定連線關係運算元的優點是,框架可以分析連線輸入的屬性,並自動決定哪種上述連線演算法最適合當前任務。 Hive,Spark和Flink都有基於代價的查詢最佳化器可以做到這一點,甚至可以改變連線順序,最小化中間狀態的數量【66,77,78,79】。
|
||||
與硬寫執行連線的程式碼相比,指定連線關係運算元的優點是,框架可以分析連線輸入的屬性,並自動決定哪種上述連線演算法最適合當前任務。 Hive、Spark 和 Flink 都有基於代價的查詢最佳化器可以做到這一點,甚至可以改變連線順序,最小化中間狀態的數量【66,77,78,79】。
|
||||
|
||||
連線演算法的選擇可以對批處理作業的效能產生巨大影響,而無需理解和記住本章中討論的各種連線演算法。如果連線是以**宣告式(declarative)** 的方式指定的,那這就這是可行的:應用只是簡單地說明哪些連線是必需的,查詢最佳化器決定如何最好地執行連線。我們以前在“[資料查詢語言](ch2.md#資料查詢語言)”中見過這個想法。
|
||||
連線演算法的選擇可以對批處理作業的效能產生巨大影響,而無需理解和記住本章中討論的各種連線演算法。如果連線是以 **宣告式(declarative)** 的方式指定的,那這就這是可行的:應用只是簡單地說明哪些連線是必需的,查詢最佳化器決定如何最好地執行連線。我們以前在 “[資料查詢語言](ch2.md#資料查詢語言)” 中見過這個想法。
|
||||
|
||||
但MapReduce及其資料流後繼者在其他方面,與SQL的完全宣告式查詢模型有很大區別。 MapReduce是圍繞著回撥函式的概念建立的:對於每條記錄或者一組記錄,呼叫一個使用者定義的函式(Mapper或Reducer),並且該函式可以自由地呼叫任意程式碼來決定輸出什麼。這種方法的優點是可以基於大量已有庫的生態系統創作:解析、自然語言分析、影象分析以及執行數值或統計演算法等。
|
||||
但 MapReduce 及其資料流後繼者在其他方面,與 SQL 的完全宣告式查詢模型有很大區別。 MapReduce 是圍繞著回撥函式的概念建立的:對於每條記錄或者一組記錄,呼叫一個使用者定義的函式(Mapper 或 Reducer),並且該函式可以自由地呼叫任意程式碼來決定輸出什麼。這種方法的優點是可以基於大量已有庫的生態系統創作:解析、自然語言分析、影象分析以及執行數值或統計演算法等。
|
||||
|
||||
自由執行任意程式碼,長期以來都是傳統MapReduce批處理系統與MPP資料庫的區別所在(請參閱“[Hadoop與分散式資料庫的對比](#Hadoop與分散式資料庫的對比)”一節)。雖然資料庫具有編寫使用者定義函式的功能,但是它們通常使用起來很麻煩,而且與大多數程式語言中廣泛使用的程式包管理器和依賴管理系統相容不佳(例如Java的Maven、Javascript的npm以及Ruby的gems)。
|
||||
自由執行任意程式碼,長期以來都是傳統 MapReduce 批處理系統與 MPP 資料庫的區別所在(請參閱 “[Hadoop 與分散式資料庫的對比](#Hadoop與分散式資料庫的對比)” 一節)。雖然資料庫具有編寫使用者定義函式的功能,但是它們通常使用起來很麻煩,而且與大多數程式語言中廣泛使用的程式包管理器和依賴管理系統相容不佳(例如 Java 的 Maven、Javascript 的 npm 以及 Ruby 的 gems)。
|
||||
|
||||
然而資料流引擎已經發現,支援除連線之外的更多**宣告式特性**還有其他的優勢。例如,如果一個回撥函式只包含一個簡單的過濾條件,或者只是從一條記錄中選擇了一些欄位,那麼在為每條記錄呼叫函式時會有相當大的額外CPU開銷。如果以宣告方式表示這些簡單的過濾和對映操作,那麼查詢最佳化器可以利用列式儲存佈局(請參閱“[列式儲存](ch3.md#列式儲存)”),只從磁碟讀取所需的列。 Hive、Spark DataFrames和Impala還使用了向量化執行(請參閱“[記憶體頻寬和向量處理](ch3.md#記憶體頻寬和向量處理)”):在對CPU快取友好的內部迴圈中迭代資料,避免函式呼叫。Spark生成JVM位元組碼【79】,Impala使用LLVM為這些內部迴圈生成本機程式碼【41】。
|
||||
然而資料流引擎已經發現,支援除連線之外的更多 **宣告式特性** 還有其他的優勢。例如,如果一個回撥函式只包含一個簡單的過濾條件,或者只是從一條記錄中選擇了一些欄位,那麼在為每條記錄呼叫函式時會有相當大的額外 CPU 開銷。如果以宣告方式表示這些簡單的過濾和對映操作,那麼查詢最佳化器可以利用列式儲存佈局(請參閱 “[列式儲存](ch3.md#列式儲存)”),只從磁碟讀取所需的列。 Hive、Spark DataFrames 和 Impala 還使用了向量化執行(請參閱 “[記憶體頻寬和向量處理](ch3.md#記憶體頻寬和向量處理)”):在對 CPU 快取友好的內部迴圈中迭代資料,避免函式呼叫。Spark 生成 JVM 位元組碼【79】,Impala 使用 LLVM 為這些內部迴圈生成本機程式碼【41】。
|
||||
|
||||
透過在高階API中引入宣告式的部分,並使查詢最佳化器可以在執行期間利用這些來做最佳化,批處理框架看起來越來越像MPP資料庫了(並且能實現可與之媲美的效能)。同時,透過擁有執行任意程式碼和以任意格式讀取資料的可擴充套件性,它們保持了靈活性的優勢。
|
||||
透過在高階 API 中引入宣告式的部分,並使查詢最佳化器可以在執行期間利用這些來做最佳化,批處理框架看起來越來越像 MPP 資料庫了(並且能實現可與之媲美的效能)。同時,透過擁有執行任意程式碼和以任意格式讀取資料的可擴充套件性,它們保持了靈活性的優勢。
|
||||
|
||||
#### 專業化的不同領域
|
||||
|
||||
儘管能夠執行任意程式碼的可擴充套件性是很有用的,但是也有很多常見的例子,不斷重複著標準的處理模式。因而這些模式值得擁有自己的可重用通用構建模組實現。傳統上,MPP資料庫滿足了商業智慧分析和業務報表的需求,但這只是許多使用批處理的領域之一。
|
||||
儘管能夠執行任意程式碼的可擴充套件性是很有用的,但是也有很多常見的例子,不斷重複著標準的處理模式。因而這些模式值得擁有自己的可重用通用構建模組實現。傳統上,MPP 資料庫滿足了商業智慧分析和業務報表的需求,但這只是許多使用批處理的領域之一。
|
||||
|
||||
另一個越來越重要的領域是統計和數值演算法,它們是機器學習應用所需要的(例如分類器和推薦系統)。可重用的實現正在出現:例如,Mahout在MapReduce、Spark和Flink之上實現了用於機器學習的各種演算法,而MADlib在關係型MPP資料庫(Apache HAWQ)中實現了類似的功能【54】。
|
||||
另一個越來越重要的領域是統計和數值演算法,它們是機器學習應用所需要的(例如分類器和推薦系統)。可重用的實現正在出現:例如,Mahout 在 MapReduce、Spark 和 Flink 之上實現了用於機器學習的各種演算法,而 MADlib 在關係型 MPP 資料庫(Apache HAWQ)中實現了類似的功能【54】。
|
||||
|
||||
空間演算法也是有用的,例如**k近鄰搜尋(k-nearest neighbors, kNN)**【80】,它在一些多維空間中搜索與給定項最近的專案 —— 這是一種相似性搜尋。近似搜尋對於基因組分析演算法也很重要,它們需要找到相似但不相同的字串【81】。
|
||||
空間演算法也是有用的,例如 **k 近鄰搜尋(k-nearest neighbors, kNN)**【80】,它在一些多維空間中搜索與給定項最近的專案 —— 這是一種相似性搜尋。近似搜尋對於基因組分析演算法也很重要,它們需要找到相似但不相同的字串【81】。
|
||||
|
||||
批處理引擎正被用於分散式執行日益廣泛的各領域演算法。隨著批處理系統獲得各種內建功能以及高階宣告式運算元,且隨著MPP資料庫變得更加靈活和易於程式設計,兩者開始看起來相似了:最終,它們都只是儲存和處理資料的系統。
|
||||
批處理引擎正被用於分散式執行日益廣泛的各領域演算法。隨著批處理系統獲得各種內建功能以及高階宣告式運算元,且隨著 MPP 資料庫變得更加靈活和易於程式設計,兩者開始看起來相似了:最終,它們都只是儲存和處理資料的系統。
|
||||
|
||||
|
||||
## 本章小結
|
||||
|
||||
在本章中,我們探索了批處理的主題。我們首先看到了諸如awk、grep和sort之類的Unix工具,然後我們看到了這些工具的設計理念是如何應用到MapReduce和更近的資料流引擎中的。一些設計原則包括:輸入是不可變的,輸出是為了作為另一個(仍未知的)程式的輸入,而複雜的問題是透過編寫“做好一件事”的小工具來解決的。
|
||||
在本章中,我們探索了批處理的主題。我們首先看到了諸如 awk、grep 和 sort 之類的 Unix 工具,然後我們看到了這些工具的設計理念是如何應用到 MapReduce 和更近的資料流引擎中的。一些設計原則包括:輸入是不可變的,輸出是為了作為另一個(仍未知的)程式的輸入,而複雜的問題是透過編寫 “做好一件事” 的小工具來解決的。
|
||||
|
||||
在Unix世界中,允許程式與程式組合的統一介面是檔案與管道;在MapReduce中,該介面是一個分散式檔案系統。我們看到資料流引擎添加了自己的管道式資料傳輸機制,以避免將中間狀態物化至分散式檔案系統,但作業的初始輸入和最終輸出通常仍是HDFS。
|
||||
在 Unix 世界中,允許程式與程式組合的統一介面是檔案與管道;在 MapReduce 中,該介面是一個分散式檔案系統。我們看到資料流引擎添加了自己的管道式資料傳輸機制,以避免將中間狀態物化至分散式檔案系統,但作業的初始輸入和最終輸出通常仍是 HDFS。
|
||||
|
||||
分散式批處理框架需要解決的兩個主要問題是:
|
||||
|
||||
* 分割槽
|
||||
|
||||
在MapReduce中,Mapper根據輸入檔案塊進行分割槽。Mapper的輸出被重新分割槽、排序併合併到可配置數量的Reducer分割槽中。這一過程的目的是把所有的**相關**資料(例如帶有相同鍵的所有記錄)都放在同一個地方。
|
||||
在 MapReduce 中,Mapper 根據輸入檔案塊進行分割槽。Mapper 的輸出被重新分割槽、排序併合併到可配置數量的 Reducer 分割槽中。這一過程的目的是把所有的 **相關** 資料(例如帶有相同鍵的所有記錄)都放在同一個地方。
|
||||
|
||||
後MapReduce時代的資料流引擎若非必要會盡量避免排序,但它們也採取了大致類似的分割槽方法。
|
||||
後 MapReduce 時代的資料流引擎若非必要會盡量避免排序,但它們也採取了大致類似的分割槽方法。
|
||||
|
||||
* 容錯
|
||||
|
||||
MapReduce經常寫入磁碟,這使得從單個失敗的任務恢復很輕鬆,無需重新啟動整個作業,但在無故障的情況下減慢了執行速度。資料流引擎更多地將中間狀態儲存在記憶體中,更少地物化中間狀態,這意味著如果節點發生故障,則需要重算更多的資料。確定性運算元減少了需要重算的資料量。
|
||||
MapReduce 經常寫入磁碟,這使得從單個失敗的任務恢復很輕鬆,無需重新啟動整個作業,但在無故障的情況下減慢了執行速度。資料流引擎更多地將中間狀態儲存在記憶體中,更少地物化中間狀態,這意味著如果節點發生故障,則需要重算更多的資料。確定性運算元減少了需要重算的資料量。
|
||||
|
||||
|
||||
我們討論了幾種MapReduce的連線演算法,其中大多數也在MPP資料庫和資料流引擎內部使用。它們也很好地演示了分割槽演算法是如何工作的:
|
||||
我們討論了幾種 MapReduce 的連線演算法,其中大多數也在 MPP 資料庫和資料流引擎內部使用。它們也很好地演示了分割槽演算法是如何工作的:
|
||||
|
||||
* 排序合併連線
|
||||
|
||||
每個參與連線的輸入都透過一個提取連線鍵的Mapper。透過分割槽、排序和合並,具有相同鍵的所有記錄最終都會進入相同的Reducer呼叫。這個函式能輸出連線好的記錄。
|
||||
每個參與連線的輸入都透過一個提取連線鍵的 Mapper。透過分割槽、排序和合並,具有相同鍵的所有記錄最終都會進入相同的 Reducer 呼叫。這個函式能輸出連線好的記錄。
|
||||
|
||||
* 廣播雜湊連線
|
||||
|
||||
兩個連線輸入之一很小,所以它並沒有分割槽,而且能被完全載入進一個雜湊表中。因此,你可以為連線輸入大端的每個分割槽啟動一個Mapper,將輸入小端的散列表載入到每個Mapper中,然後掃描大端,一次一條記錄,併為每條記錄查詢散列表。
|
||||
兩個連線輸入之一很小,所以它並沒有分割槽,而且能被完全載入進一個雜湊表中。因此,你可以為連線輸入大端的每個分割槽啟動一個 Mapper,將輸入小端的散列表載入到每個 Mapper 中,然後掃描大端,一次一條記錄,併為每條記錄查詢散列表。
|
||||
|
||||
* 分割槽雜湊連線
|
||||
|
||||
如果兩個連線輸入以相同的方式分割槽(使用相同的鍵,相同的雜湊函式和相同數量的分割槽),則可以獨立地對每個分割槽應用散列表方法。
|
||||
|
||||
分散式批處理引擎有一個刻意限制的程式設計模型:回撥函式(比如Mapper和Reducer)被假定是無狀態的,而且除了指定的輸出外,必須沒有任何外部可見的副作用。這一限制允許框架在其抽象下隱藏一些困難的分散式系統問題:當遇到崩潰和網路問題時,任務可以安全地重試,任何失敗任務的輸出都被丟棄。如果某個分割槽的多個任務成功,則其中只有一個能使其輸出實際可見。
|
||||
分散式批處理引擎有一個刻意限制的程式設計模型:回撥函式(比如 Mapper 和 Reducer)被假定是無狀態的,而且除了指定的輸出外,必須沒有任何外部可見的副作用。這一限制允許框架在其抽象下隱藏一些困難的分散式系統問題:當遇到崩潰和網路問題時,任務可以安全地重試,任何失敗任務的輸出都被丟棄。如果某個分割槽的多個任務成功,則其中只有一個能使其輸出實際可見。
|
||||
|
||||
得益於這個框架,你在批處理作業中的程式碼無需操心實現容錯機制:框架可以保證作業的最終輸出與沒有發生錯誤的情況相同,雖然實際上也許不得不重試各種任務。比起線上服務一邊處理使用者請求一邊將寫入資料庫作為處理請求的副作用,批處理提供的這種可靠性語義要強得多。
|
||||
|
||||
批處理作業的顯著特點是,它讀取一些輸入資料併產生一些輸出資料,但不修改輸入—— 換句話說,輸出是從輸入衍生出的。最關鍵的是,輸入資料是**有界的(bounded)**:它有一個已知的,固定的大小(例如,它包含一些時間點的日誌檔案或資料庫內容的快照)。因為它是有界的,一個作業知道自己什麼時候完成了整個輸入的讀取,所以一個工作在做完後,最終總是會完成的。
|
||||
批處理作業的顯著特點是,它讀取一些輸入資料併產生一些輸出資料,但不修改輸入 —— 換句話說,輸出是從輸入衍生出的。最關鍵的是,輸入資料是 **有界的(bounded)**:它有一個已知的,固定的大小(例如,它包含一些時間點的日誌檔案或資料庫內容的快照)。因為它是有界的,一個作業知道自己什麼時候完成了整個輸入的讀取,所以一個工作在做完後,最終總是會完成的。
|
||||
|
||||
在下一章中,我們將轉向流處理,其中的輸入是**無界的(unbounded)** —— 也就是說,你還有活兒要幹,然而它的輸入是永無止境的資料流。在這種情況下,作業永無完成之日。因為在任何時候都可能有更多的工作湧入。我們將看到,在某些方面上,流處理和批處理是相似的。但是關於無盡資料流的假設也對我們構建系統的方式產生了很多改變。
|
||||
在下一章中,我們將轉向流處理,其中的輸入是 **無界的(unbounded)** —— 也就是說,你還有活兒要幹,然而它的輸入是永無止境的資料流。在這種情況下,作業永無完成之日。因為在任何時候都可能有更多的工作湧入。我們將看到,在某些方面上,流處理和批處理是相似的。但是關於無盡資料流的假設也對我們構建系統的方式產生了很多改變。
|
||||
|
||||
|
||||
## 參考文獻
|
||||
|
396
zh-tw/ch11.md
396
zh-tw/ch11.md
@ -4,34 +4,34 @@
|
||||
|
||||
> 有效的複雜系統總是從簡單的系統演化而來。 反之亦然:從零設計的複雜系統沒一個能有效工作的。
|
||||
>
|
||||
> —— 約翰·加爾,Systemantics(1975)
|
||||
> —— 約翰・加爾,Systemantics(1975)
|
||||
|
||||
---------------
|
||||
|
||||
[TOC]
|
||||
|
||||
在[第十章](ch10.md)中,我們討論了批處理技術,它讀取一組檔案作為輸入,並生成一組新的檔案作為輸出。輸出是**衍生資料(derived data)** 的一種形式;也就是說,如果需要,可以透過再次執行批處理過程來重新建立資料集。我們看到了如何使用這個簡單而強大的想法來建立搜尋索引、推薦系統、做分析等等。
|
||||
在 [第十章](ch10.md) 中,我們討論了批處理技術,它讀取一組檔案作為輸入,並生成一組新的檔案作為輸出。輸出是 **衍生資料(derived data)** 的一種形式;也就是說,如果需要,可以透過再次執行批處理過程來重新建立資料集。我們看到了如何使用這個簡單而強大的想法來建立搜尋索引、推薦系統、做分析等等。
|
||||
|
||||
然而,在[第十章](ch10.md)中仍然有一個很大的假設:即輸入是有界的,即已知和有限的大小,所以批處理知道它何時完成輸入的讀取。例如,MapReduce核心的排序操作必須讀取其全部輸入,然後才能開始生成輸出:可能發生這種情況:最後一條輸入記錄具有最小的鍵,因此需要第一個被輸出,所以提早開始輸出是不可行的。
|
||||
然而,在 [第十章](ch10.md) 中仍然有一個很大的假設:即輸入是有界的,即已知和有限的大小,所以批處理知道它何時完成輸入的讀取。例如,MapReduce 核心的排序操作必須讀取其全部輸入,然後才能開始生成輸出:可能發生這種情況:最後一條輸入記錄具有最小的鍵,因此需要第一個被輸出,所以提早開始輸出是不可行的。
|
||||
|
||||
實際上,很多資料是**無界限**的,因為它隨著時間的推移而逐漸到達:你的使用者在昨天和今天產生了資料,明天他們將繼續產生更多的資料。除非你停業,否則這個過程永遠都不會結束,所以資料集從來就不會以任何有意義的方式“完成”【1】。因此,批處理程式必須將資料人為地分成固定時間段的資料塊,例如,在每天結束時處理一天的資料,或者在每小時結束時處理一小時的資料。
|
||||
實際上,很多資料是 **無界限** 的,因為它隨著時間的推移而逐漸到達:你的使用者在昨天和今天產生了資料,明天他們將繼續產生更多的資料。除非你停業,否則這個過程永遠都不會結束,所以資料集從來就不會以任何有意義的方式 “完成”【1】。因此,批處理程式必須將資料人為地分成固定時間段的資料塊,例如,在每天結束時處理一天的資料,或者在每小時結束時處理一小時的資料。
|
||||
|
||||
日常批處理中的問題是,輸入的變更只會在一天之後的輸出中反映出來,這對於許多急躁的使用者來說太慢了。為了減少延遲,我們可以更頻繁地執行處理 —— 比如說,在每秒鐘的末尾 —— 或者甚至更連續一些,完全拋開固定的時間切片,當事件發生時就立即進行處理,這就是**流處理(stream processing)** 背後的想法。
|
||||
日常批處理中的問題是,輸入的變更只會在一天之後的輸出中反映出來,這對於許多急躁的使用者來說太慢了。為了減少延遲,我們可以更頻繁地執行處理 —— 比如說,在每秒鐘的末尾 —— 或者甚至更連續一些,完全拋開固定的時間切片,當事件發生時就立即進行處理,這就是 **流處理(stream processing)** 背後的想法。
|
||||
|
||||
一般來說,“流”是指隨著時間的推移逐漸可用的資料。這個概念出現在很多地方:Unix的stdin和stdout,程式語言(惰性列表)【2】,檔案系統API(如Java的`FileInputStream`),TCP連線,透過網際網路傳送音訊和影片等等。
|
||||
一般來說,“流” 是指隨著時間的推移逐漸可用的資料。這個概念出現在很多地方:Unix 的 stdin 和 stdout,程式語言(惰性列表)【2】,檔案系統 API(如 Java 的 `FileInputStream`),TCP 連線,透過網際網路傳送音訊和影片等等。
|
||||
|
||||
在本章中,我們將把**事件流(event stream)** 視為一種資料管理機制:無界限,增量處理,與上一章中的批次資料相對應。我們將首先討論怎樣表示、儲存、透過網路傳輸流。在“[資料庫與流](#資料庫與流)”中,我們將研究流和資料庫之間的關係。最後在“[流處理](#流處理)”中,我們將研究連續處理這些流的方法和工具,以及它們用於應用構建的方式。
|
||||
在本章中,我們將把 **事件流(event stream)** 視為一種資料管理機制:無界限,增量處理,與上一章中的批次資料相對應。我們將首先討論怎樣表示、儲存、透過網路傳輸流。在 “[資料庫與流](#資料庫與流)” 中,我們將研究流和資料庫之間的關係。最後在 “[流處理](#流處理)” 中,我們將研究連續處理這些流的方法和工具,以及它們用於應用構建的方式。
|
||||
|
||||
|
||||
## 傳遞事件流
|
||||
|
||||
在批處理領域,作業的輸入和輸出是檔案(也許在分散式檔案系統上)。流處理領域中的等價物看上去是什麼樣子的?
|
||||
|
||||
當輸入是一個檔案(一個位元組序列),第一個處理步驟通常是將其解析為一系列記錄。在流處理的上下文中,記錄通常被叫做 **事件(event)** ,但它本質上是一樣的:一個小的、自包含的、不可變的物件,包含某個時間點發生的某件事情的細節。一個事件通常包含一個來自日曆時鐘的時間戳,以指明事件發生的時間(請參閱“[單調鍾與日曆時鐘](ch8.md#單調鍾與日曆時鐘)”)。
|
||||
當輸入是一個檔案(一個位元組序列),第一個處理步驟通常是將其解析為一系列記錄。在流處理的上下文中,記錄通常被叫做 **事件(event)** ,但它本質上是一樣的:一個小的、自包含的、不可變的物件,包含某個時間點發生的某件事情的細節。一個事件通常包含一個來自日曆時鐘的時間戳,以指明事件發生的時間(請參閱 “[單調鍾與日曆時鐘](ch8.md#單調鍾與日曆時鐘)”)。
|
||||
|
||||
例如,發生的事件可能是使用者採取的行動,例如檢視頁面或進行購買。它也可能來源於機器,例如對溫度感測器或CPU利用率的週期性測量。在“[使用Unix工具的批處理](ch10.md#使用Unix工具的批處理)”的示例中,Web伺服器日誌的每一行都是一個事件。
|
||||
例如,發生的事件可能是使用者採取的行動,例如檢視頁面或進行購買。它也可能來源於機器,例如對溫度感測器或 CPU 利用率的週期性測量。在 “[使用 Unix 工具的批處理](ch10.md#使用Unix工具的批處理)” 的示例中,Web 伺服器日誌的每一行都是一個事件。
|
||||
|
||||
事件可能被編碼為文字字串或JSON,或者某種二進位制編碼,如[第四章](ch4.md)所述。這種編碼允許你儲存一個事件,例如將其追加到一個檔案,將其插入關係表,或將其寫入文件資料庫。它還允許你透過網路將事件傳送到另一個節點以進行處理。
|
||||
事件可能被編碼為文字字串或 JSON,或者某種二進位制編碼,如 [第四章](ch4.md) 所述。這種編碼允許你儲存一個事件,例如將其追加到一個檔案,將其插入關係表,或將其寫入文件資料庫。它還允許你透過網路將事件傳送到另一個節點以進行處理。
|
||||
|
||||
在批處理中,檔案被寫入一次,然後可能被多個作業讀取。類似地,在流處理術語中,一個事件由 **生產者(producer)** (也稱為 **釋出者(publisher)** 或 **傳送者(sender)** )生成一次,然後可能由多個 **消費者(consumer)** ( **訂閱者(subscribers)** 或 **接收者(recipients)** )進行處理【3】。在檔案系統中,檔名標識一組相關記錄;在流式系統中,相關的事件通常被聚合為一個 **主題(topic)** 或 **流(stream)** 。
|
||||
|
||||
@ -44,30 +44,30 @@
|
||||
|
||||
### 訊息傳遞系統
|
||||
|
||||
向消費者通知新事件的常用方式是使用**訊息傳遞系統(messaging system)**:生產者傳送包含事件的訊息,然後將訊息推送給消費者。我們之前在“[訊息傳遞中的資料流](ch4.md#訊息傳遞中的資料流)”中談到了這些系統,但現在我們將詳細介紹這些系統。
|
||||
向消費者通知新事件的常用方式是使用 **訊息傳遞系統(messaging system)**:生產者傳送包含事件的訊息,然後將訊息推送給消費者。我們之前在 “[訊息傳遞中的資料流](ch4.md#訊息傳遞中的資料流)” 中談到了這些系統,但現在我們將詳細介紹這些系統。
|
||||
|
||||
像生產者和消費者之間的Unix管道或TCP連線這樣的直接通道,是實現訊息傳遞系統的簡單方法。但是,大多數訊息傳遞系統都在這一基本模型上進行了擴充套件。特別的是,Unix管道和TCP將恰好一個傳送者與恰好一個接收者連線,而一個訊息傳遞系統允許多個生產者節點將訊息傳送到同一個主題,並允許多個消費者節點接收主題中的訊息。
|
||||
像生產者和消費者之間的 Unix 管道或 TCP 連線這樣的直接通道,是實現訊息傳遞系統的簡單方法。但是,大多數訊息傳遞系統都在這一基本模型上進行了擴充套件。特別的是,Unix 管道和 TCP 將恰好一個傳送者與恰好一個接收者連線,而一個訊息傳遞系統允許多個生產者節點將訊息傳送到同一個主題,並允許多個消費者節點接收主題中的訊息。
|
||||
|
||||
在這個**釋出/訂閱**模式中,不同的系統採取各種各樣的方法,並沒有針對所有目的的通用答案。為了區分這些系統,問一下這兩個問題會特別有幫助:
|
||||
在這個 **釋出 / 訂閱** 模式中,不同的系統採取各種各樣的方法,並沒有針對所有目的的通用答案。為了區分這些系統,問一下這兩個問題會特別有幫助:
|
||||
|
||||
1. **如果生產者傳送訊息的速度比消費者能夠處理的速度快會發生什麼?** 一般來說,有三種選擇:系統可以丟掉訊息,將訊息放入緩衝佇列,或使用**背壓**(backpressure,也稱為**流量控制**,即flow control:阻塞生產者,以免其傳送更多的訊息)。例如Unix管道和TCP就使用了背壓:它們有一個固定大小的小緩衝區,如果填滿,傳送者會被阻塞,直到接收者從緩衝區中取出資料(請參閱“[網路擁塞和排隊](ch8.md#網路擁塞和排隊)”)。
|
||||
1. **如果生產者傳送訊息的速度比消費者能夠處理的速度快會發生什麼?** 一般來說,有三種選擇:系統可以丟掉訊息,將訊息放入緩衝佇列,或使用 **背壓**(backpressure,也稱為 **流量控制**,即 flow control:阻塞生產者,以免其傳送更多的訊息)。例如 Unix 管道和 TCP 就使用了背壓:它們有一個固定大小的小緩衝區,如果填滿,傳送者會被阻塞,直到接收者從緩衝區中取出資料(請參閱 “[網路擁塞和排隊](ch8.md#網路擁塞和排隊)”)。
|
||||
|
||||
如果訊息被快取在佇列中,那麼理解佇列增長會發生什麼是很重要的。當佇列裝不進記憶體時系統會崩潰嗎?還是將訊息寫入磁碟?如果是這樣,磁碟訪問又會如何影響訊息傳遞系統的效能【6】?
|
||||
|
||||
2. **如果節點崩潰或暫時離線,會發生什麼情況? —— 是否會有訊息丟失?** 與資料庫一樣,永續性可能需要寫入磁碟和/或複製的某種組合(請參閱“[複製與永續性](ch7.md#複製與永續性)”),這是有代價的。如果你能接受有時訊息會丟失,則可能在同一硬體上獲得更高的吞吐量和更低的延遲。
|
||||
2. **如果節點崩潰或暫時離線,會發生什麼情況? —— 是否會有訊息丟失?** 與資料庫一樣,永續性可能需要寫入磁碟和 / 或複製的某種組合(請參閱 “[複製與永續性](ch7.md#複製與永續性)”),這是有代價的。如果你能接受有時訊息會丟失,則可能在同一硬體上獲得更高的吞吐量和更低的延遲。
|
||||
|
||||
是否可以接受訊息丟失取決於應用。例如,對於週期傳輸的感測器讀數和指標,偶爾丟失的資料點可能並不重要,因為更新的值會在短時間內發出。但要注意,如果大量的訊息被丟棄,可能無法立刻意識到指標已經不正確了【7】。如果你正在對事件計數,那麼它們能夠可靠送達是更重要的,因為每個丟失的訊息都意味著使計數器的錯誤擴大。
|
||||
|
||||
我們在[第十章](ch10.md)中探討的批處理系統的一個很好的特性是,它們提供了強大的可靠性保證:失敗的任務會自動重試,失敗任務的部分輸出會自動丟棄。這意味著輸出與沒有發生故障一樣,這有助於簡化程式設計模型。在本章的後面,我們將研究如何在流處理的上下文中提供類似的保證。
|
||||
我們在 [第十章](ch10.md) 中探討的批處理系統的一個很好的特性是,它們提供了強大的可靠性保證:失敗的任務會自動重試,失敗任務的部分輸出會自動丟棄。這意味著輸出與沒有發生故障一樣,這有助於簡化程式設計模型。在本章的後面,我們將研究如何在流處理的上下文中提供類似的保證。
|
||||
|
||||
#### 直接從生產者傳遞給消費者
|
||||
|
||||
許多訊息傳遞系統使用生產者和消費者之間的直接網路通訊,而不透過中間節點:
|
||||
|
||||
* UDP組播廣泛應用於金融行業,例如股票市場,其中低時延非常重要【8】。雖然UDP本身是不可靠的,但應用層的協議可以恢復丟失的資料包(生產者必須記住它傳送的資料包,以便能按需重新發送資料包)。
|
||||
* 無代理的訊息庫,如ZeroMQ 【9】和nanomsg採取類似的方法,透過TCP或IP多播實現釋出/訂閱訊息傳遞。
|
||||
* StatsD 【10】和Brubeck 【7】使用不可靠的UDP訊息傳遞來收集網路中所有機器的指標並對其進行監控。 (在StatsD協議中,只有接收到所有訊息,才認為計數器指標是正確的;使用UDP將使得指標處在一種最佳近似狀態【11】。另請參閱“[TCP與UDP](ch8.md#TCP與UDP)”
|
||||
* 如果消費者在網路上公開了服務,生產者可以直接傳送HTTP或RPC請求(請參閱“[服務中的資料流:REST與RPC](ch4.md#服務中的資料流:REST與RPC)”)將訊息推送給使用者。這就是webhooks背後的想法【12】,一種服務的回撥URL被註冊到另一個服務中,並且每當事件發生時都會向該URL發出請求。
|
||||
* UDP 組播廣泛應用於金融行業,例如股票市場,其中低時延非常重要【8】。雖然 UDP 本身是不可靠的,但應用層的協議可以恢復丟失的資料包(生產者必須記住它傳送的資料包,以便能按需重新發送資料包)。
|
||||
* 無代理的訊息庫,如 ZeroMQ 【9】和 nanomsg 採取類似的方法,透過 TCP 或 IP 多播實現釋出 / 訂閱訊息傳遞。
|
||||
* StatsD 【10】和 Brubeck 【7】使用不可靠的 UDP 訊息傳遞來收集網路中所有機器的指標並對其進行監控。 (在 StatsD 協議中,只有接收到所有訊息,才認為計數器指標是正確的;使用 UDP 將使得指標處在一種最佳近似狀態【11】。另請參閱 “[TCP 與 UDP](ch8.md#TCP與UDP)”
|
||||
* 如果消費者在網路上公開了服務,生產者可以直接傳送 HTTP 或 RPC 請求(請參閱 “[服務中的資料流:REST 與 RPC](ch4.md#服務中的資料流:REST與RPC)”)將訊息推送給使用者。這就是 webhooks 背後的想法【12】,一種服務的回撥 URL 被註冊到另一個服務中,並且每當事件發生時都會向該 URL 發出請求。
|
||||
|
||||
儘管這些直接訊息傳遞系統在設計它們的環境中執行良好,但是它們通常要求應用程式碼意識到訊息丟失的可能性。它們的容錯程度極為有限:即使協議檢測到並重傳在網路中丟失的資料包,它們通常也只是假設生產者和消費者始終線上。
|
||||
|
||||
@ -75,54 +75,54 @@
|
||||
|
||||
#### 訊息代理
|
||||
|
||||
一種廣泛使用的替代方法是透過**訊息代理**(message broker,也稱為**訊息佇列**,即message queue)傳送訊息,訊息代理實質上是一種針對處理訊息流而最佳化的資料庫。它作為伺服器執行,生產者和消費者作為客戶端連線到伺服器。生產者將訊息寫入代理,消費者透過從代理那裡讀取來接收訊息。
|
||||
一種廣泛使用的替代方法是透過 **訊息代理**(message broker,也稱為 **訊息佇列**,即 message queue)傳送訊息,訊息代理實質上是一種針對處理訊息流而最佳化的資料庫。它作為伺服器執行,生產者和消費者作為客戶端連線到伺服器。生產者將訊息寫入代理,消費者透過從代理那裡讀取來接收訊息。
|
||||
|
||||
透過將資料集中在代理上,這些系統可以更容易地容忍來來去去的客戶端(連線,斷開連線和崩潰),而永續性問題則轉移到代理的身上。一些訊息代理只將訊息儲存在記憶體中,而另一些訊息代理(取決於配置)將其寫入磁碟,以便在代理崩潰的情況下不會丟失。針對緩慢的消費者,它們通常會允許無上限的排隊(而不是丟棄訊息或背壓),儘管這種選擇也可能取決於配置。
|
||||
|
||||
排隊的結果是,消費者通常是**非同步(asynchronous)** 的:當生產者傳送訊息時,通常只會等待代理確認訊息已經被快取,而不等待訊息被消費者處理。向消費者遞送訊息將發生在未來某個未定的時間點 —— 通常在幾分之一秒之內,但有時當訊息堆積時會顯著延遲。
|
||||
排隊的結果是,消費者通常是 **非同步(asynchronous)** 的:當生產者傳送訊息時,通常只會等待代理確認訊息已經被快取,而不等待訊息被消費者處理。向消費者遞送訊息將發生在未來某個未定的時間點 —— 通常在幾分之一秒之內,但有時當訊息堆積時會顯著延遲。
|
||||
|
||||
#### 訊息代理與資料庫的對比
|
||||
|
||||
有些訊息代理甚至可以使用XA或JTA參與兩階段提交協議(請參閱“[實踐中的分散式事務](ch9.md#實踐中的分散式事務)”)。這個功能與資料庫在本質上非常相似,儘管訊息代理和資料庫之間仍存在實踐上很重要的差異:
|
||||
有些訊息代理甚至可以使用 XA 或 JTA 參與兩階段提交協議(請參閱 “[實踐中的分散式事務](ch9.md#實踐中的分散式事務)”)。這個功能與資料庫在本質上非常相似,儘管訊息代理和資料庫之間仍存在實踐上很重要的差異:
|
||||
|
||||
* 資料庫通常保留資料直至顯式刪除,而大多數訊息代理在訊息成功遞送給消費者時會自動刪除訊息。這樣的訊息代理不適合長期的資料儲存。
|
||||
* 由於它們很快就能刪除訊息,大多數訊息代理都認為它們的工作集相當小—— 即佇列很短。如果代理需要緩衝很多訊息,比如因為消費者速度較慢(如果記憶體裝不下訊息,可能會溢位到磁碟),每個訊息需要更長的處理時間,整體吞吐量可能會惡化【6】。
|
||||
* 由於它們很快就能刪除訊息,大多數訊息代理都認為它們的工作集相當小 —— 即佇列很短。如果代理需要緩衝很多訊息,比如因為消費者速度較慢(如果記憶體裝不下訊息,可能會溢位到磁碟),每個訊息需要更長的處理時間,整體吞吐量可能會惡化【6】。
|
||||
* 資料庫通常支援次級索引和各種搜尋資料的方式,而訊息代理通常支援按照某種模式匹配主題,訂閱其子集。雖然機制並不一樣,但對於客戶端選擇想要了解的資料的一部分,都是基本的方式。
|
||||
* 查詢資料庫時,結果通常基於某個時間點的資料快照;如果另一個客戶端隨後向資料庫寫入一些改變了查詢結果的內容,則第一個客戶端不會發現其先前結果現已過期(除非它重複查詢或輪詢變更)。相比之下,訊息代理不支援任意查詢,但是當資料發生變化時(即新訊息可用時),它們會通知客戶端。
|
||||
|
||||
這是關於訊息代理的傳統觀點,它被封裝在諸如JMS 【14】和AMQP 【15】的標準中,並且被諸如RabbitMQ、ActiveMQ、HornetQ、Qpid、TIBCO企業訊息服務、IBM MQ、Azure Service Bus和Google Cloud Pub/Sub所實現 【16】。
|
||||
這是關於訊息代理的傳統觀點,它被封裝在諸如 JMS 【14】和 AMQP 【15】的標準中,並且被諸如 RabbitMQ、ActiveMQ、HornetQ、Qpid、TIBCO 企業訊息服務、IBM MQ、Azure Service Bus 和 Google Cloud Pub/Sub 所實現 【16】。
|
||||
|
||||
#### 多個消費者
|
||||
|
||||
當多個消費者從同一主題中讀取訊息時,有兩種主要的訊息傳遞模式,如[圖11-1](../img/fig11-1.png)所示:
|
||||
當多個消費者從同一主題中讀取訊息時,有兩種主要的訊息傳遞模式,如 [圖 11-1](../img/fig11-1.png) 所示:
|
||||
|
||||
* 負載均衡(load balancing)
|
||||
|
||||
每條訊息都被傳遞給消費者**之一**,所以處理該主題下訊息的工作能被多個消費者共享。代理可以為消費者任意分配訊息。當處理訊息的代價高昂,希望能並行處理訊息時,此模式非常有用(在AMQP中,可以透過讓多個客戶端從同一個佇列中消費來實現負載均衡,而在JMS中則稱之為**共享訂閱**,即shared subscription)。
|
||||
每條訊息都被傳遞給消費者 **之一**,所以處理該主題下訊息的工作能被多個消費者共享。代理可以為消費者任意分配訊息。當處理訊息的代價高昂,希望能並行處理訊息時,此模式非常有用(在 AMQP 中,可以透過讓多個客戶端從同一個佇列中消費來實現負載均衡,而在 JMS 中則稱之為 **共享訂閱**,即 shared subscription)。
|
||||
|
||||
* 扇出(fan-out)
|
||||
|
||||
每條訊息都被傳遞給**所有**消費者。扇出允許幾個獨立的消費者各自“收聽”相同的訊息廣播,而不會相互影響 —— 這個流處理中的概念對應批處理中多個不同批處理作業讀取同一份輸入檔案 (JMS中的主題訂閱與AMQP中的交叉繫結提供了這一功能)。
|
||||
每條訊息都被傳遞給 **所有** 消費者。扇出允許幾個獨立的消費者各自 “收聽” 相同的訊息廣播,而不會相互影響 —— 這個流處理中的概念對應批處理中多個不同批處理作業讀取同一份輸入檔案 (JMS 中的主題訂閱與 AMQP 中的交叉繫結提供了這一功能)。
|
||||
|
||||
![](../img/fig11-1.png)
|
||||
|
||||
**圖11-1 (a)負載平衡:在消費者間共享消費主題;(b)扇出:將每條訊息傳遞給多個消費者。**
|
||||
**圖 11-1 (a)負載平衡:在消費者間共享消費主題;(b)扇出:將每條訊息傳遞給多個消費者。**
|
||||
|
||||
兩種模式可以組合使用:例如,兩個獨立的消費者組可以每組各訂閱同一個主題,每一組都共同收到所有訊息,但在每一組內部,每條訊息僅由單個節點處理。
|
||||
|
||||
#### 確認與重新傳遞
|
||||
|
||||
消費者隨時可能會崩潰,所以有一種可能的情況是:代理向消費者遞送訊息,但消費者沒有處理,或者在消費者崩潰之前只進行了部分處理。為了確保訊息不會丟失,訊息代理使用**確認(acknowledgments)**:客戶端必須顯式告知代理訊息處理完畢的時間,以便代理能將訊息從佇列中移除。
|
||||
消費者隨時可能會崩潰,所以有一種可能的情況是:代理向消費者遞送訊息,但消費者沒有處理,或者在消費者崩潰之前只進行了部分處理。為了確保訊息不會丟失,訊息代理使用 **確認(acknowledgments)**:客戶端必須顯式告知代理訊息處理完畢的時間,以便代理能將訊息從佇列中移除。
|
||||
|
||||
如果與客戶端的連線關閉,或者代理超出一段時間未收到確認,代理則認為訊息沒有被處理,因此它將訊息再遞送給另一個消費者。 (請注意可能發生這樣的情況,訊息**實際上是**處理完畢的,但**確認**在網路中丟失了。需要一種原子提交協議才能處理這種情況,正如在“[實踐中的分散式事務](ch9.md#實踐中的分散式事務)”中所討論的那樣)
|
||||
如果與客戶端的連線關閉,或者代理超出一段時間未收到確認,代理則認為訊息沒有被處理,因此它將訊息再遞送給另一個消費者。 (請注意可能發生這樣的情況,訊息 **實際上是** 處理完畢的,但 **確認** 在網路中丟失了。需要一種原子提交協議才能處理這種情況,正如在 “[實踐中的分散式事務](ch9.md#實踐中的分散式事務)” 中所討論的那樣)
|
||||
|
||||
當與負載均衡相結合時,這種重傳行為對訊息的順序有種有趣的影響。在[圖11-2](../img/fig11-2.png)中,消費者通常按照生產者傳送的順序處理訊息。然而消費者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 在處理m3時消費者2崩潰,因此稍後重傳至消費者1**
|
||||
**圖 11-2 在處理 m3 時消費者 2 崩潰,因此稍後重傳至消費者 1**
|
||||
|
||||
即使訊息代理試圖保留訊息的順序(如JMS和AMQP標準所要求的),負載均衡與重傳的組合也不可避免地導致訊息被重新排序。為避免此問題,你可以讓每個消費者使用單獨的佇列(即不使用負載均衡功能)。如果訊息是完全獨立的,則訊息順序重排並不是一個問題。但正如我們將在本章後續部分所述,如果訊息之間存在因果依賴關係,這就是一個很重要的問題。
|
||||
即使訊息代理試圖保留訊息的順序(如 JMS 和 AMQP 標準所要求的),負載均衡與重傳的組合也不可避免地導致訊息被重新排序。為避免此問題,你可以讓每個消費者使用單獨的佇列(即不使用負載均衡功能)。如果訊息是完全獨立的,則訊息順序重排並不是一個問題。但正如我們將在本章後續部分所述,如果訊息之間存在因果依賴關係,這就是一個很重要的問題。
|
||||
|
||||
### 分割槽日誌
|
||||
|
||||
@ -130,38 +130,38 @@
|
||||
|
||||
資料庫和檔案系統採用截然相反的方法論:至少在某人顯式刪除前,通常寫入資料庫或檔案的所有內容都要被永久記錄下來。
|
||||
|
||||
這種思維方式上的差異對建立衍生資料的方式有巨大影響。如[第十章](ch10.md)所述,批處理過程的一個關鍵特性是,你可以反覆執行它們,試驗處理步驟,不用擔心損壞輸入(因為輸入是隻讀的)。而 AMQP/JMS風格的訊息傳遞並非如此:收到訊息是具有破壞性的,因為確認可能導致訊息從代理中被刪除,因此你不能期望再次運行同一個消費者能得到相同的結果。
|
||||
這種思維方式上的差異對建立衍生資料的方式有巨大影響。如 [第十章](ch10.md) 所述,批處理過程的一個關鍵特性是,你可以反覆執行它們,試驗處理步驟,不用擔心損壞輸入(因為輸入是隻讀的)。而 AMQP/JMS 風格的訊息傳遞並非如此:收到訊息是具有破壞性的,因為確認可能導致訊息從代理中被刪除,因此你不能期望再次運行同一個消費者能得到相同的結果。
|
||||
|
||||
如果你將新的消費者新增到訊息傳遞系統,通常只能接收到消費者註冊之後開始傳送的訊息。先前的任何訊息都隨風而逝,一去不復返。作為對比,你可以隨時為檔案和資料庫新增新的客戶端,且能讀取任意久遠的資料(只要應用沒有顯式覆蓋或刪除這些資料)。
|
||||
|
||||
為什麼我們不能把它倆雜交一下,既有資料庫的持久儲存方式,又有訊息傳遞的低延遲通知?這就是**基於日誌的訊息代理(log-based message brokers)** 背後的想法。
|
||||
為什麼我們不能把它倆雜交一下,既有資料庫的持久儲存方式,又有訊息傳遞的低延遲通知?這就是 **基於日誌的訊息代理(log-based message brokers)** 背後的想法。
|
||||
|
||||
#### 使用日誌進行訊息儲存
|
||||
|
||||
日誌只是磁碟上簡單的僅追加記錄序列。我們先前在[第三章](ch3.md)中日誌結構儲存引擎和預寫式日誌的上下文中討論了日誌,在[第五章](ch5.md)複製的上下文裡也討論了它。
|
||||
日誌只是磁碟上簡單的僅追加記錄序列。我們先前在 [第三章](ch3.md) 中日誌結構儲存引擎和預寫式日誌的上下文中討論了日誌,在 [第五章](ch5.md) 複製的上下文裡也討論了它。
|
||||
|
||||
同樣的結構可以用於實現訊息代理:生產者透過將訊息追加到日誌末尾來發送訊息,而消費者透過依次讀取日誌來接收訊息。如果消費者讀到日誌末尾,則會等待新訊息追加的通知。 Unix工具`tail -f` 能監視檔案被追加寫入的資料,基本上就是這樣工作的。
|
||||
同樣的結構可以用於實現訊息代理:生產者透過將訊息追加到日誌末尾來發送訊息,而消費者透過依次讀取日誌來接收訊息。如果消費者讀到日誌末尾,則會等待新訊息追加的通知。 Unix 工具 `tail -f` 能監視檔案被追加寫入的資料,基本上就是這樣工作的。
|
||||
|
||||
為了伸縮超出單個磁碟所能提供的更高吞吐量,可以對日誌進行**分割槽**(按[第六章](ch6.md)的定義)。不同的分割槽可以託管在不同的機器上,使得每個分割槽都有一份能獨立於其他分割槽進行讀寫的日誌。一個主題可以定義為一組攜帶相同型別訊息的分割槽。這種方法如[圖11-3](../img/fig11-3.png)所示。
|
||||
為了伸縮超出單個磁碟所能提供的更高吞吐量,可以對日誌進行 **分割槽**(按 [第六章](ch6.md) 的定義)。不同的分割槽可以託管在不同的機器上,使得每個分割槽都有一份能獨立於其他分割槽進行讀寫的日誌。一個主題可以定義為一組攜帶相同型別訊息的分割槽。這種方法如 [圖 11-3](../img/fig11-3.png) 所示。
|
||||
|
||||
在每個分割槽內,代理為每個訊息分配一個單調遞增的序列號或**偏移量**(offset,在[圖11-3](../img/fig11-3.png)中,框中的數字是訊息偏移量)。這種序列號是有意義的,因為分割槽是僅追加寫入的,所以分割槽內的訊息是完全有序的。沒有跨不同分割槽的順序保證。
|
||||
在每個分割槽內,代理為每個訊息分配一個單調遞增的序列號或 **偏移量**(offset,在 [圖 11-3](../img/fig11-3.png) 中,框中的數字是訊息偏移量)。這種序列號是有意義的,因為分割槽是僅追加寫入的,所以分割槽內的訊息是完全有序的。沒有跨不同分割槽的順序保證。
|
||||
|
||||
![](../img/fig11-3.png)
|
||||
|
||||
**圖11-3 生產者透過將訊息追加寫入主題分割槽檔案來發送訊息,消費者依次讀取這些檔案**
|
||||
**圖 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】。
|
||||
|
||||
#### 日誌與傳統的訊息傳遞相比
|
||||
|
||||
基於日誌的方法天然支援扇出式訊息傳遞,因為多個消費者可以獨立讀取日誌,而不會相互影響 —— 讀取訊息不會將其從日誌中刪除。為了在一組消費者之間實現負載平衡,代理可以將整個分割槽分配給消費者組中的節點,而不是將單條訊息分配給消費者客戶端。
|
||||
|
||||
然後每個客戶端將消費被指派分割槽中的**所有**訊息。通常情況下,當一個使用者被指派了一個日誌分割槽時,它會以簡單的單執行緒方式順序地讀取分割槽中的訊息。這種粗粒度的負載均衡方法有一些缺點:
|
||||
然後每個客戶端將消費被指派分割槽中的 **所有** 訊息。通常情況下,當一個使用者被指派了一個日誌分割槽時,它會以簡單的單執行緒方式順序地讀取分割槽中的訊息。這種粗粒度的負載均衡方法有一些缺點:
|
||||
|
||||
* 共享消費主題工作的節點數,最多為該主題中的日誌分割槽數,因為同一個分割槽內的所有訊息被遞送到同一個節點[^i]。
|
||||
* 如果某條訊息處理緩慢,則它會阻塞該分割槽中後續訊息的處理(一種行首阻塞的形式;請參閱“[描述效能](ch1.md#描述效能)”)。
|
||||
* 共享消費主題工作的節點數,最多為該主題中的日誌分割槽數,因為同一個分割槽內的所有訊息被遞送到同一個節點 [^i]。
|
||||
* 如果某條訊息處理緩慢,則它會阻塞該分割槽中後續訊息的處理(一種行首阻塞的形式;請參閱 “[描述效能](ch1.md#描述效能)”)。
|
||||
|
||||
因此在訊息處理代價高昂,希望逐條並行處理,以及訊息的順序並沒有那麼重要的情況下,JMS/AMQP風格的訊息代理是可取的。另一方面,在訊息吞吐量很高,處理迅速,順序很重要的情況下,基於日誌的方法表現得非常好。
|
||||
因此在訊息處理代價高昂,希望逐條並行處理,以及訊息的順序並沒有那麼重要的情況下,JMS/AMQP 風格的訊息代理是可取的。另一方面,在訊息吞吐量很高,處理迅速,順序很重要的情況下,基於日誌的方法表現得非常好。
|
||||
|
||||
[^i]: 要設計一種負載均衡方案也是有可能的,在這種方案中,兩個消費者透過讀取全部訊息來共享分割槽處理的工作,但是其中一個只考慮具有偶數偏移量的訊息,而另一個消費者只處理奇數編號的偏移量。或者你可以將訊息攤到一個執行緒池中來處理,但這種方法會使消費者偏移量管理變得複雜。一般來說,單執行緒處理單分割槽是合適的,可以透過增加更多分割槽來提高並行度。
|
||||
|
||||
@ -169,7 +169,7 @@ Apache Kafka 【17,18】,Amazon Kinesis Streams 【19】和Twitter的Distribut
|
||||
|
||||
順序消費一個分割槽使得判斷訊息是否已經被處理變得相當容易:所有偏移量小於消費者的當前偏移量的訊息已經被處理,而具有更大偏移量的訊息還沒有被看到。因此,代理不需要跟蹤確認每條訊息,只需要定期記錄消費者的偏移即可。這種方法減少了額外簿記開銷,而且在批處理和流處理中採用這種方法有助於提高基於日誌的系統的吞吐量。
|
||||
|
||||
實際上,這種偏移量與單領導者資料庫複製中常見的日誌序列號非常相似,我們在“[設定新從庫](ch5.md#設定新從庫)”中討論了這種情況。在資料庫複製中,日誌序列號允許跟隨者斷開連線後,重新連線到領導者,並在不跳過任何寫入的情況下恢復複製。這裡原理完全相同:訊息代理表現得像一個主庫,而消費者就像一個從庫。
|
||||
實際上,這種偏移量與單領導者資料庫複製中常見的日誌序列號非常相似,我們在 “[設定新從庫](ch5.md#設定新從庫)” 中討論了這種情況。在資料庫複製中,日誌序列號允許跟隨者斷開連線後,重新連線到領導者,並在不跳過任何寫入的情況下恢復複製。這裡原理完全相同:訊息代理表現得像一個主庫,而消費者就像一個從庫。
|
||||
|
||||
如果消費者節點失效,則失效消費者的分割槽將指派給其他節點,並從最後記錄的偏移量開始消費訊息。如果消費者已經處理了後續的訊息,但還沒有記錄它們的偏移量,那麼重啟後這些訊息將被處理兩次。我們將在本章後面討論這個問題的處理方法。
|
||||
|
||||
@ -177,25 +177,25 @@ Apache Kafka 【17,18】,Amazon Kinesis Streams 【19】和Twitter的Distribut
|
||||
|
||||
如果只追加寫入日誌,則磁碟空間終究會耗盡。為了回收磁碟空間,日誌實際上被分割成段,並不時地將舊段刪除或移動到歸檔儲存。 (我們將在後面討論一種更為複雜的磁碟空間釋放方式)
|
||||
|
||||
這就意味著如果一個慢消費者跟不上訊息產生的速率而落後得太多,它的消費偏移量指向了刪除的段,那麼它就會錯過一些訊息。實際上,日誌實現了一個有限大小的緩衝區,當緩衝區填滿時會丟棄舊訊息,它也被稱為**迴圈緩衝區(circular buffer)** 或**環形緩衝區(ring buffer)**。不過由於緩衝區在磁碟上,因此緩衝區可能相當的大。
|
||||
這就意味著如果一個慢消費者跟不上訊息產生的速率而落後得太多,它的消費偏移量指向了刪除的段,那麼它就會錯過一些訊息。實際上,日誌實現了一個有限大小的緩衝區,當緩衝區填滿時會丟棄舊訊息,它也被稱為 **迴圈緩衝區(circular buffer)** 或 **環形緩衝區(ring buffer)**。不過由於緩衝區在磁碟上,因此緩衝區可能相當的大。
|
||||
|
||||
讓我們做個簡單計算。在撰寫本文時,典型的大型硬碟容量為6TB,順序寫入吞吐量為150MB/s。如果以最快的速度寫訊息,則需要大約11個小時才能填滿磁碟。因而磁碟可以緩衝11個小時的訊息,之後它將開始覆蓋舊的訊息。即使使用多個磁碟和機器,這個比率也是一樣的。實踐中的部署很少能用滿磁碟的寫入頻寬,所以通常可以儲存一個幾天甚至幾周的日誌緩衝區。
|
||||
讓我們做個簡單計算。在撰寫本文時,典型的大型硬碟容量為 6TB,順序寫入吞吐量為 150MB/s。如果以最快的速度寫訊息,則需要大約 11 個小時才能填滿磁碟。因而磁碟可以緩衝 11 個小時的訊息,之後它將開始覆蓋舊的訊息。即使使用多個磁碟和機器,這個比率也是一樣的。實踐中的部署很少能用滿磁碟的寫入頻寬,所以通常可以儲存一個幾天甚至幾周的日誌緩衝區。
|
||||
|
||||
不管保留多長時間的訊息,日誌的吞吐量或多或少保持不變,因為無論如何,每個訊息都會被寫入磁碟【18】。這種行為與預設將訊息儲存在記憶體中,僅當佇列太長時才寫入磁碟的訊息傳遞系統形成鮮明對比。當佇列很短時,這些系統非常快;而當這些系統開始寫入磁碟時,就要慢的多,所以吞吐量取決於保留的歷史數量。
|
||||
|
||||
#### 當消費者跟不上生產者時
|
||||
|
||||
在“[訊息傳遞系統](#訊息傳遞系統)”中,如果消費者無法跟上生產者傳送資訊的速度時,我們討論了三種選擇:丟棄資訊,進行緩衝或施加背壓。在這種分類法裡,基於日誌的方法是緩衝的一種形式,具有很大但大小固定的緩衝區(受可用磁碟空間的限制)。
|
||||
在 “[訊息傳遞系統](#訊息傳遞系統)” 中,如果消費者無法跟上生產者傳送資訊的速度時,我們討論了三種選擇:丟棄資訊,進行緩衝或施加背壓。在這種分類法裡,基於日誌的方法是緩衝的一種形式,具有很大但大小固定的緩衝區(受可用磁碟空間的限制)。
|
||||
|
||||
如果消費者遠遠落後,而所要求的資訊比保留在磁碟上的資訊還要舊,那麼它將不能讀取這些資訊,所以代理實際上丟棄了比緩衝區容量更大的舊資訊。你可以監控消費者落後日誌頭部的距離,如果落後太多就發出報警。由於緩衝區很大,因而有足夠的時間讓運維人員來修復慢消費者,並在訊息開始丟失之前讓其趕上。
|
||||
|
||||
即使消費者真的落後太多開始丟失訊息,也只有那個消費者受到影響;它不會中斷其他消費者的服務。這是一個巨大的運維優勢:你可以實驗性地消費生產日誌,以進行開發,測試或除錯,而不必擔心會中斷生產服務。當消費者關閉或崩潰時,會停止消耗資源,唯一剩下的只有消費者偏移量。
|
||||
|
||||
這種行為也與傳統的訊息代理形成了鮮明對比,在那種情況下,你需要小心地刪除那些消費者已經關閉的佇列—— 否則那些佇列就會累積不必要的訊息,從其他仍活躍的消費者那裡佔走記憶體。
|
||||
這種行為也與傳統的訊息代理形成了鮮明對比,在那種情況下,你需要小心地刪除那些消費者已經關閉的佇列 —— 否則那些佇列就會累積不必要的訊息,從其他仍活躍的消費者那裡佔走記憶體。
|
||||
|
||||
#### 重播舊訊息
|
||||
|
||||
我們之前提到,使用AMQP和JMS風格的訊息代理,處理和確認訊息是一個破壞性的操作,因為它會導致訊息在代理上被刪除。另一方面,在基於日誌的訊息代理中,使用訊息更像是從檔案中讀取資料:這是隻讀操作,不會更改日誌。
|
||||
我們之前提到,使用 AMQP 和 JMS 風格的訊息代理,處理和確認訊息是一個破壞性的操作,因為它會導致訊息在代理上被刪除。另一方面,在基於日誌的訊息代理中,使用訊息更像是從檔案中讀取資料:這是隻讀操作,不會更改日誌。
|
||||
|
||||
除了消費者的任何輸出之外,處理的唯一副作用是消費者偏移量的前進。但偏移量是在消費者的控制之下的,所以如果需要的話可以很容易地操縱:例如你可以用昨天的偏移量跑一個消費者副本,並將輸出寫到不同的位置,以便重新處理最近一天的訊息。你可以使用各種不同的處理程式碼重複任意次。
|
||||
|
||||
@ -206,158 +206,158 @@ Apache Kafka 【17,18】,Amazon Kinesis Streams 【19】和Twitter的Distribut
|
||||
|
||||
我們已經在訊息代理和資料庫之間進行了一些比較。儘管傳統上它們被視為單獨的工具類別,但是我們看到基於日誌的訊息代理已經成功地從資料庫中獲取靈感並將其應用於訊息傳遞。我們也可以反過來:從訊息傳遞和流中獲取靈感,並將它們應用於資料庫。
|
||||
|
||||
我們之前曾經說過,事件是某個時刻發生的事情的記錄。發生的事情可能是使用者操作(例如鍵入搜尋查詢)或讀取感測器,但也可能是**寫入資料庫**。某些東西被寫入資料庫的事實是可以被捕獲、儲存和處理的事件。這一觀察結果表明,資料庫和資料流之間的聯絡不僅僅是磁碟日誌的物理儲存 —— 而是更深層的聯絡。
|
||||
我們之前曾經說過,事件是某個時刻發生的事情的記錄。發生的事情可能是使用者操作(例如鍵入搜尋查詢)或讀取感測器,但也可能是 **寫入資料庫**。某些東西被寫入資料庫的事實是可以被捕獲、儲存和處理的事件。這一觀察結果表明,資料庫和資料流之間的聯絡不僅僅是磁碟日誌的物理儲存 —— 而是更深層的聯絡。
|
||||
|
||||
事實上,複製日誌(請參閱“[複製日誌的實現](ch5.md#複製日誌的實現)”)是一個由資料庫寫入事件組成的流,由主庫在處理事務時生成。從庫將寫入流應用到它們自己的資料庫副本,從而最終得到相同資料的精確副本。複製日誌中的事件描述發生的資料更改。
|
||||
事實上,複製日誌(請參閱 “[複製日誌的實現](ch5.md#複製日誌的實現)”)是一個由資料庫寫入事件組成的流,由主庫在處理事務時生成。從庫將寫入流應用到它們自己的資料庫副本,從而最終得到相同資料的精確副本。複製日誌中的事件描述發生的資料更改。
|
||||
|
||||
我們還在“[全序廣播](ch9.md#全序廣播)”中遇到了狀態機複製原理,其中指出:如果每個事件代表對資料庫的寫入,並且每個副本按相同的順序處理相同的事件,則副本將達到相同的最終狀態 (假設事件處理是一個確定性的操作)。這是事件流的又一種場景!
|
||||
我們還在 “[全序廣播](ch9.md#全序廣播)” 中遇到了狀態機複製原理,其中指出:如果每個事件代表對資料庫的寫入,並且每個副本按相同的順序處理相同的事件,則副本將達到相同的最終狀態 (假設事件處理是一個確定性的操作)。這是事件流的又一種場景!
|
||||
|
||||
在本節中,我們將首先看看異構資料系統中出現的一個問題,然後探討如何透過將事件流的想法帶入資料庫來解決這個問題。
|
||||
|
||||
### 保持系統同步
|
||||
|
||||
正如我們在本書中所看到的,沒有一個系統能夠滿足所有的資料儲存、查詢和處理需求。在實踐中,大多數重要應用都需要組合使用幾種不同的技術來滿足所有的需求:例如,使用OLTP資料庫來為使用者請求提供服務,使用快取來加速常見請求,使用全文索引來處理搜尋查詢,使用資料倉庫用於分析。每一種技術都有自己的資料副本,並根據自己的目的進行儲存方式的最佳化。
|
||||
正如我們在本書中所看到的,沒有一個系統能夠滿足所有的資料儲存、查詢和處理需求。在實踐中,大多數重要應用都需要組合使用幾種不同的技術來滿足所有的需求:例如,使用 OLTP 資料庫來為使用者請求提供服務,使用快取來加速常見請求,使用全文索引來處理搜尋查詢,使用資料倉庫用於分析。每一種技術都有自己的資料副本,並根據自己的目的進行儲存方式的最佳化。
|
||||
|
||||
由於相同或相關的資料出現在了不同的地方,因此相互間需要保持同步:如果某個專案在資料庫中被更新,它也應當在快取、搜尋索引和資料倉庫中被更新。對於資料倉庫,這種同步通常由ETL程序執行(請參閱“[資料倉庫](ch3.md#資料倉庫)”),通常是先取得資料庫的完整副本,然後執行轉換,並批次載入到資料倉庫中 —— 換句話說,批處理。我們在“[批處理工作流的輸出](ch10.md#批處理工作流的輸出)”中同樣看到了如何使用批處理建立搜尋索引、推薦系統和其他衍生資料系統。
|
||||
由於相同或相關的資料出現在了不同的地方,因此相互間需要保持同步:如果某個專案在資料庫中被更新,它也應當在快取、搜尋索引和資料倉庫中被更新。對於資料倉庫,這種同步通常由 ETL 程序執行(請參閱 “[資料倉庫](ch3.md#資料倉庫)”),通常是先取得資料庫的完整副本,然後執行轉換,並批次載入到資料倉庫中 —— 換句話說,批處理。我們在 “[批處理工作流的輸出](ch10.md#批處理工作流的輸出)” 中同樣看到了如何使用批處理建立搜尋索引、推薦系統和其他衍生資料系統。
|
||||
|
||||
如果週期性的完整資料庫轉儲過於緩慢,有時會使用的替代方法是**雙寫(dual write)**,其中應用程式碼在資料變更時明確寫入每個系統:例如,首先寫入資料庫,然後更新搜尋索引,然後使快取項失效(甚至同時執行這些寫入)。
|
||||
如果週期性的完整資料庫轉儲過於緩慢,有時會使用的替代方法是 **雙寫(dual write)**,其中應用程式碼在資料變更時明確寫入每個系統:例如,首先寫入資料庫,然後更新搜尋索引,然後使快取項失效(甚至同時執行這些寫入)。
|
||||
|
||||
但是,雙寫有一些嚴重的問題,其中一個是競爭條件,如[圖11-4](../img/fig11-4.png)所示。在這個例子中,兩個客戶端同時想要更新一個專案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,而在搜尋索引處,寫入以相反的順序到達**
|
||||
**圖 11-4 在資料庫中 X 首先被設定為 A,然後被設定為 B,而在搜尋索引處,寫入以相反的順序到達**
|
||||
|
||||
除非有一些額外的併發檢測機制,例如我們在“[檢測併發寫入](ch5.md#檢測併發寫入)”中討論的版本向量,否則你甚至不會意識到發生了併發寫入 —— 一個值將簡單地以無提示方式覆蓋另一個值。
|
||||
除非有一些額外的併發檢測機制,例如我們在 “[檢測併發寫入](ch5.md#檢測併發寫入)” 中討論的版本向量,否則你甚至不會意識到發生了併發寫入 —— 一個值將簡單地以無提示方式覆蓋另一個值。
|
||||
|
||||
雙重寫入的另一個問題是,其中一個寫入可能會失敗,而另一個成功。這是一個容錯問題,而不是一個併發問題,但也會造成兩個系統互相不一致的結果。確保它們要麼都成功要麼都失敗,是原子提交問題的一個例子,解決這個問題的代價是昂貴的(請參閱“[原子提交與兩階段提交](ch7.md#原子提交與兩階段提交)”)。
|
||||
雙重寫入的另一個問題是,其中一個寫入可能會失敗,而另一個成功。這是一個容錯問題,而不是一個併發問題,但也會造成兩個系統互相不一致的結果。確保它們要麼都成功要麼都失敗,是原子提交問題的一個例子,解決這個問題的代價是昂貴的(請參閱 “[原子提交與兩階段提交](ch7.md#原子提交與兩階段提交)”)。
|
||||
|
||||
如果你只有一個單領導者複製的資料庫,那麼這個領導者決定了寫入順序,而狀態機複製方法可以在資料庫副本上工作。然而,在[圖11-4](../img/fig11-4.png)中,沒有單個主庫:資料庫可能有一個領導者,搜尋索引也可能有一個領導者,但是兩者都不追隨對方,所以可能會發生衝突(請參閱“[多主複製](ch5.md#多主複製)“)。
|
||||
如果你只有一個單領導者複製的資料庫,那麼這個領導者決定了寫入順序,而狀態機複製方法可以在資料庫副本上工作。然而,在 [圖 11-4](../img/fig11-4.png) 中,沒有單個主庫:資料庫可能有一個領導者,搜尋索引也可能有一個領導者,但是兩者都不追隨對方,所以可能會發生衝突(請參閱 “[多主複製](ch5.md#多主複製)“)。
|
||||
|
||||
如果實際上只有一個領導者 —— 例如,資料庫 —— 而且我們能讓搜尋索引成為資料庫的追隨者,情況要好得多。但這在實踐中可能嗎?
|
||||
|
||||
### 變更資料捕獲
|
||||
|
||||
大多數資料庫的複製日誌的問題在於,它們一直被當做資料庫的內部實現細節,而不是公開的API。客戶端應該透過其資料模型和查詢語言來查詢資料庫,而不是解析複製日誌並嘗試從中提取資料。
|
||||
大多數資料庫的複製日誌的問題在於,它們一直被當做資料庫的內部實現細節,而不是公開的 API。客戶端應該透過其資料模型和查詢語言來查詢資料庫,而不是解析複製日誌並嘗試從中提取資料。
|
||||
|
||||
數十年來,許多資料庫根本沒有記錄在檔的獲取變更日誌的方式。由於這個原因,捕獲資料庫中所有的變更,然後將其複製到其他儲存技術(搜尋索引、快取或資料倉庫)中是相當困難的。
|
||||
|
||||
最近,人們對**變更資料捕獲(change data capture, CDC)** 越來越感興趣,這是一種觀察寫入資料庫的所有資料變更,並將其提取並轉換為可以複製到其他系統中的形式的過程。 CDC是非常有意思的,尤其是當變更能在被寫入後立刻用於流時。
|
||||
最近,人們對 **變更資料捕獲(change data capture, CDC)** 越來越感興趣,這是一種觀察寫入資料庫的所有資料變更,並將其提取並轉換為可以複製到其他系統中的形式的過程。 CDC 是非常有意思的,尤其是當變更能在被寫入後立刻用於流時。
|
||||
|
||||
例如,你可以捕獲資料庫中的變更,並不斷將相同的變更應用至搜尋索引。如果變更日誌以相同的順序應用,則可以預期搜尋索引中的資料與資料庫中的資料是匹配的。搜尋索引和任何其他衍生資料系統只是變更流的消費者,如[圖11-5](../img/fig11-5.png)所示。
|
||||
例如,你可以捕獲資料庫中的變更,並不斷將相同的變更應用至搜尋索引。如果變更日誌以相同的順序應用,則可以預期搜尋索引中的資料與資料庫中的資料是匹配的。搜尋索引和任何其他衍生資料系統只是變更流的消費者,如 [圖 11-5](../img/fig11-5.png) 所示。
|
||||
|
||||
![](../img/fig11-5.png)
|
||||
|
||||
**圖11-5 將資料按順序寫入一個數據庫,然後按照相同的順序將這些更改應用到其他系統**
|
||||
**圖 11-5 將資料按順序寫入一個數據庫,然後按照相同的順序將這些更改應用到其他系統**
|
||||
|
||||
#### 變更資料捕獲的實現
|
||||
|
||||
我們可以將日誌消費者叫做**衍生資料系統**,正如在[第三部分](part-iii.md)的介紹中所討論的:儲存在搜尋索引和資料倉庫中的資料,只是**記錄系統**資料的額外檢視。變更資料捕獲是一種機制,可確保對記錄系統所做的所有更改都反映在衍生資料系統中,以便衍生系統具有資料的準確副本。
|
||||
我們可以將日誌消費者叫做 **衍生資料系統**,正如在 [第三部分](part-iii.md) 的介紹中所討論的:儲存在搜尋索引和資料倉庫中的資料,只是 **記錄系統** 資料的額外檢視。變更資料捕獲是一種機制,可確保對記錄系統所做的所有更改都反映在衍生資料系統中,以便衍生系統具有資料的準確副本。
|
||||
|
||||
從本質上說,變更資料捕獲使得一個數據庫成為領導者(被捕獲變化的資料庫),並將其他元件變為追隨者。基於日誌的訊息代理非常適合從源資料庫傳輸變更事件,因為它保留了訊息的順序(避免了[圖11-2](../img/fig11-2.png)的重新排序問題)。
|
||||
從本質上說,變更資料捕獲使得一個數據庫成為領導者(被捕獲變化的資料庫),並將其他元件變為追隨者。基於日誌的訊息代理非常適合從源資料庫傳輸變更事件,因為它保留了訊息的順序(避免了 [圖 11-2](../img/fig11-2.png) 的重新排序問題)。
|
||||
|
||||
資料庫觸發器可用來實現變更資料捕獲(請參閱“[基於觸發器的複製](ch5.md#基於觸發器的複製)”),透過註冊觀察所有變更的觸發器,並將相應的變更項寫入變更日誌表中。但是它們往往是脆弱的,而且有顯著的效能開銷。解析複製日誌可能是一種更穩健的方法,但它也很有挑戰,例如如何應對模式變更。
|
||||
資料庫觸發器可用來實現變更資料捕獲(請參閱 “[基於觸發器的複製](ch5.md#基於觸發器的複製)”),透過註冊觀察所有變更的觸發器,並將相應的變更項寫入變更日誌表中。但是它們往往是脆弱的,而且有顯著的效能開銷。解析複製日誌可能是一種更穩健的方法,但它也很有挑戰,例如如何應對模式變更。
|
||||
|
||||
LinkedIn的Databus【25】,Facebook的Wormhole【26】和Yahoo!的Sherpa【27】大規模地應用這個思路。 Bottled Water使用解碼WAL的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 使用解碼 WAL 的 API 實現了 PostgreSQL 的 CDC【28】,Maxwell 和 Debezium 透過解析 binlog 對 MySQL 做了類似的事情【29,30,31】,Mongoriver 讀取 MongoDB oplog【32,33】,而 GoldenGate 為 Oracle 提供類似的功能【34,35】。
|
||||
|
||||
像訊息代理一樣,變更資料捕獲通常是非同步的:記錄資料庫系統不會等待消費者應用變更再進行提交。這種設計具有的運維優勢是,新增緩慢的消費者不會過度影響記錄系統。不過,所有複製延遲可能有的問題在這裡都可能出現(請參閱“[複製延遲問題](ch5.md#複製延遲問題)”)。
|
||||
像訊息代理一樣,變更資料捕獲通常是非同步的:記錄資料庫系統不會等待消費者應用變更再進行提交。這種設計具有的運維優勢是,新增緩慢的消費者不會過度影響記錄系統。不過,所有複製延遲可能有的問題在這裡都可能出現(請參閱 “[複製延遲問題](ch5.md#複製延遲問題)”)。
|
||||
|
||||
#### 初始快照
|
||||
|
||||
如果你擁有**所有**對資料庫進行變更的日誌,則可以透過重播該日誌,來重建資料庫的完整狀態。但是在許多情況下,永遠保留所有更改會耗費太多磁碟空間,且重播過於費時,因此日誌需要被截斷。
|
||||
如果你擁有 **所有** 對資料庫進行變更的日誌,則可以透過重播該日誌,來重建資料庫的完整狀態。但是在許多情況下,永遠保留所有更改會耗費太多磁碟空間,且重播過於費時,因此日誌需要被截斷。
|
||||
|
||||
例如,構建新的全文索引需要整個資料庫的完整副本 —— 僅僅應用最近變更的日誌是不夠的,因為這樣會丟失最近未曾更新的專案。因此,如果你沒有完整的歷史日誌,則需要從一個一致的快照開始,如先前的“[設定新從庫](ch5.md#設定新從庫)”中所述。
|
||||
例如,構建新的全文索引需要整個資料庫的完整副本 —— 僅僅應用最近變更的日誌是不夠的,因為這樣會丟失最近未曾更新的專案。因此,如果你沒有完整的歷史日誌,則需要從一個一致的快照開始,如先前的 “[設定新從庫](ch5.md#設定新從庫)” 中所述。
|
||||
|
||||
資料庫的快照必須與變更日誌中的已知位置或偏移量相對應,以便在處理完快照後知道從哪裡開始應用變更。一些CDC工具集成了這種快照功能,而其他工具則把它留給你手動執行。
|
||||
資料庫的快照必須與變更日誌中的已知位置或偏移量相對應,以便在處理完快照後知道從哪裡開始應用變更。一些 CDC 工具集成了這種快照功能,而其他工具則把它留給你手動執行。
|
||||
|
||||
#### 日誌壓縮
|
||||
|
||||
如果你只能保留有限的歷史日誌,則每次要新增新的衍生資料系統時,都需要做一次快照。但**日誌壓縮(log compaction)** 提供了一個很好的備選方案。
|
||||
如果你只能保留有限的歷史日誌,則每次要新增新的衍生資料系統時,都需要做一次快照。但 **日誌壓縮(log compaction)** 提供了一個很好的備選方案。
|
||||
|
||||
我們之前在“[雜湊索引](ch3.md#雜湊索引)”中關於日誌結構儲存引擎的上下文中討論了日誌壓縮(請參閱[圖3-2](../img/fig3-2.png)的示例)。原理很簡單:儲存引擎定期在日誌中查詢具有相同鍵的記錄,丟掉所有重複的內容,並只保留每個鍵的最新更新。這個壓縮與合併過程在後臺執行。
|
||||
我們之前在 “[雜湊索引](ch3.md#雜湊索引)” 中關於日誌結構儲存引擎的上下文中討論了日誌壓縮(請參閱 [圖 3-2](../img/fig3-2.png) 的示例)。原理很簡單:儲存引擎定期在日誌中查詢具有相同鍵的記錄,丟掉所有重複的內容,並只保留每個鍵的最新更新。這個壓縮與合併過程在後臺執行。
|
||||
|
||||
在日誌結構儲存引擎中,具有特殊值NULL(**墓碑**,即tombstone)的更新表示該鍵被刪除,並會在日誌壓縮過程中被移除。但只要鍵不被覆蓋或刪除,它就會永遠留在日誌中。這種壓縮日誌所需的磁碟空間僅取決於資料庫的當前內容,而不取決於資料庫中曾經發生的寫入次數。如果相同的鍵經常被覆蓋寫入,則先前的值將最終將被垃圾回收,只有最新的值會保留下來。
|
||||
在日誌結構儲存引擎中,具有特殊值 NULL(**墓碑**,即 tombstone)的更新表示該鍵被刪除,並會在日誌壓縮過程中被移除。但只要鍵不被覆蓋或刪除,它就會永遠留在日誌中。這種壓縮日誌所需的磁碟空間僅取決於資料庫的當前內容,而不取決於資料庫中曾經發生的寫入次數。如果相同的鍵經常被覆蓋寫入,則先前的值將最終將被垃圾回收,只有最新的值會保留下來。
|
||||
|
||||
在基於日誌的訊息代理與變更資料捕獲的上下文中也適用相同的想法。如果CDC系統被配置為,每個變更都包含一個主鍵,且每個鍵的更新都替換了該鍵以前的值,那麼只需要保留對鍵的最新寫入就足夠了。
|
||||
在基於日誌的訊息代理與變更資料捕獲的上下文中也適用相同的想法。如果 CDC 系統被配置為,每個變更都包含一個主鍵,且每個鍵的更新都替換了該鍵以前的值,那麼只需要保留對鍵的最新寫入就足夠了。
|
||||
|
||||
現在,無論何時需要重建衍生資料系統(如搜尋索引),你可以從壓縮日誌主題的零偏移量處啟動新的消費者,然後依次掃描日誌中的所有訊息。日誌能保證包含資料庫中每個鍵的最新值(也可能是一些較舊的值)—— 換句話說,你可以使用它來獲取資料庫內容的完整副本,而無需從CDC源資料庫取一個快照。
|
||||
現在,無論何時需要重建衍生資料系統(如搜尋索引),你可以從壓縮日誌主題的零偏移量處啟動新的消費者,然後依次掃描日誌中的所有訊息。日誌能保證包含資料庫中每個鍵的最新值(也可能是一些較舊的值)—— 換句話說,你可以使用它來獲取資料庫內容的完整副本,而無需從 CDC 源資料庫取一個快照。
|
||||
|
||||
Apache Kafka支援這種日誌壓縮功能。正如我們將在本章後面看到的,它允許訊息代理被當成永續性儲存使用,而不僅僅是用於臨時訊息。
|
||||
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 中,它就可以用於更新衍生資料系統,比如搜尋索引,也可以用於本章稍後討論的流處理系統。
|
||||
|
||||
### 事件溯源
|
||||
|
||||
我們在這裡討論的想法和**事件溯源(Event Sourcing)** 之間有一些相似之處,這是一個在 **領域驅動設計(domain-driven design, DDD)** 社群中折騰出來的技術。我們將簡要討論事件溯源,因為它包含了一些關於流處理系統的有用想法。
|
||||
我們在這裡討論的想法和 **事件溯源(Event Sourcing)** 之間有一些相似之處,這是一個在 **領域驅動設計(domain-driven design, DDD)** 社群中折騰出來的技術。我們將簡要討論事件溯源,因為它包含了一些關於流處理系統的有用想法。
|
||||
|
||||
與變更資料捕獲類似,事件溯源涉及到**將所有對應用狀態的變更**儲存為變更事件日誌。最大的區別是事件溯源將這一想法應用到了一個不同的抽象層次上:
|
||||
與變更資料捕獲類似,事件溯源涉及到 **將所有對應用狀態的變更** 儲存為變更事件日誌。最大的區別是事件溯源將這一想法應用到了一個不同的抽象層次上:
|
||||
|
||||
* 在變更資料捕獲中,應用以**可變方式(mutable way)** 使用資料庫,可以任意更新和刪除記錄。變更日誌是從資料庫的底層提取的(例如,透過解析複製日誌),從而確保從資料庫中提取的寫入順序與實際寫入的順序相匹配,從而避免[圖11-4](../img/fig11-4.png)中的競態條件。寫入資料庫的應用不需要知道CDC的存在。
|
||||
* 在變更資料捕獲中,應用以 **可變方式(mutable way)** 使用資料庫,可以任意更新和刪除記錄。變更日誌是從資料庫的底層提取的(例如,透過解析複製日誌),從而確保從資料庫中提取的寫入順序與實際寫入的順序相匹配,從而避免 [圖 11-4](../img/fig11-4.png) 中的競態條件。寫入資料庫的應用不需要知道 CDC 的存在。
|
||||
* 在事件溯源中,應用邏輯顯式構建在寫入事件日誌的不可變事件之上。在這種情況下,事件儲存是僅追加寫入的,更新與刪除是不鼓勵的或禁止的。事件被設計為旨在反映應用層面發生的事情,而不是底層的狀態變更。
|
||||
|
||||
事件溯源是一種強大的資料建模技術:從應用的角度來看,將使用者的行為記錄為不可變的事件更有意義,而不是在可變資料庫中記錄這些行為的影響。事件溯源使得應用隨時間演化更為容易,透過更容易理解事情發生的原因來幫助除錯的進行,並有利於防止應用Bug(請參閱“[不可變事件的優點](#不可變事件的優點)”)。
|
||||
事件溯源是一種強大的資料建模技術:從應用的角度來看,將使用者的行為記錄為不可變的事件更有意義,而不是在可變資料庫中記錄這些行為的影響。事件溯源使得應用隨時間演化更為容易,透過更容易理解事情發生的原因來幫助除錯的進行,並有利於防止應用 Bug(請參閱 “[不可變事件的優點](#不可變事件的優點)”)。
|
||||
|
||||
例如,儲存“學生取消選課”事件以中性的方式清楚地表達了單個行為的意圖,而其副作用“從登記表中刪除了一個條目,而一條取消原因的記錄被新增到學生反饋表“則嵌入了很多有關稍後對資料的使用方式的假設。如果引入一個新的應用功能,例如“將位置留給等待列表中的下一個人” —— 事件溯源方法允許將新的副作用輕鬆地從現有事件中脫開。
|
||||
例如,儲存 “學生取消選課” 事件以中性的方式清楚地表達了單個行為的意圖,而其副作用 “從登記表中刪除了一個條目,而一條取消原因的記錄被新增到學生反饋表 “則嵌入了很多有關稍後對資料的使用方式的假設。如果引入一個新的應用功能,例如 “將位置留給等待列表中的下一個人” —— 事件溯源方法允許將新的副作用輕鬆地從現有事件中脫開。
|
||||
|
||||
事件溯源類似於**編年史(chronicle)** 資料模型【45】,事件日誌與星型模式中的事實表之間也存在相似之處(請參閱“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”) 。
|
||||
事件溯源類似於 **編年史(chronicle)** 資料模型【45】,事件日誌與星型模式中的事實表之間也存在相似之處(請參閱 “[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”) 。
|
||||
|
||||
諸如Event Store【46】這樣的專業資料庫已經被開發出來,供使用事件溯源的應用使用,但總的來說,這種方法獨立於任何特定的工具。傳統的資料庫或基於日誌的訊息代理也可以用來構建這種風格的應用。
|
||||
諸如 Event Store【46】這樣的專業資料庫已經被開發出來,供使用事件溯源的應用使用,但總的來說,這種方法獨立於任何特定的工具。傳統的資料庫或基於日誌的訊息代理也可以用來構建這種風格的應用。
|
||||
|
||||
#### 從事件日誌中派生出當前狀態
|
||||
|
||||
事件日誌本身並不是很有用,因為使用者通常期望看到的是系統的當前狀態,而不是變更歷史。例如,在購物網站上,使用者期望能看到他們購物車裡的當前內容,而不是他們購物車所有變更的一個僅追加列表。
|
||||
|
||||
因此,使用事件溯源的應用需要拉取事件日誌(表示**寫入**系統的資料),並將其轉換為適合向用戶顯示的應用狀態(從系統**讀取**資料的方式【47】)。這種轉換可以使用任意邏輯,但它應當是確定性的,以便能再次執行,並從事件日誌中衍生出相同的應用狀態。
|
||||
因此,使用事件溯源的應用需要拉取事件日誌(表示 **寫入** 系統的資料),並將其轉換為適合向用戶顯示的應用狀態(從系統 **讀取** 資料的方式【47】)。這種轉換可以使用任意邏輯,但它應當是確定性的,以便能再次執行,並從事件日誌中衍生出相同的應用狀態。
|
||||
|
||||
與變更資料捕獲一樣,重播事件日誌允許讓你重新構建系統的當前狀態。不過,日誌壓縮需要採用不同的方式處理:
|
||||
|
||||
* 用於記錄更新的CDC事件通常包含記錄的**完整新版本**,因此主鍵的當前值完全由該主鍵的最近事件確定,而日誌壓縮可以丟棄相同主鍵的先前事件。
|
||||
* 用於記錄更新的 CDC 事件通常包含記錄的 **完整新版本**,因此主鍵的當前值完全由該主鍵的最近事件確定,而日誌壓縮可以丟棄相同主鍵的先前事件。
|
||||
* 另一方面,事件溯源在更高層次進行建模:事件通常表示使用者操作的意圖,而不是因為操作而發生的狀態更新機制。在這種情況下,後面的事件通常不會覆蓋先前的事件,所以你需要完整的歷史事件來重新構建最終狀態。這裡進行同樣的日誌壓縮是不可能的。
|
||||
|
||||
使用事件溯源的應用通常有一些機制,用於儲存從事件日誌中匯出的當前狀態快照,因此它們不需要重複處理完整的日誌。然而這只是一種效能最佳化,用來加速讀取,提高從崩潰中恢復的速度;真正的目的是系統能夠永久儲存所有原始事件,並在需要時重新處理完整的事件日誌。我們將在“[不變性的侷限性](#不變性的侷限性)”中討論這個假設。
|
||||
使用事件溯源的應用通常有一些機制,用於儲存從事件日誌中匯出的當前狀態快照,因此它們不需要重複處理完整的日誌。然而這只是一種效能最佳化,用來加速讀取,提高從崩潰中恢復的速度;真正的目的是系統能夠永久儲存所有原始事件,並在需要時重新處理完整的事件日誌。我們將在 “[不變性的侷限性](#不變性的侷限性)” 中討論這個假設。
|
||||
|
||||
#### 命令和事件
|
||||
|
||||
事件溯源的哲學是仔細區分**事件(event)** 和**命令(command)**【48】。當來自使用者的請求剛到達時,它一開始是一個命令:在這個時間點上它仍然可能可能失敗,比如,因為違反了一些完整性條件。應用必須首先驗證它是否可以執行該命令。如果驗證成功並且命令被接受,則它變為一個持久化且不可變的事件。
|
||||
事件溯源的哲學是仔細區分 **事件(event)** 和 **命令(command)**【48】。當來自使用者的請求剛到達時,它一開始是一個命令:在這個時間點上它仍然可能可能失敗,比如,因為違反了一些完整性條件。應用必須首先驗證它是否可以執行該命令。如果驗證成功並且命令被接受,則它變為一個持久化且不可變的事件。
|
||||
|
||||
例如,如果使用者試圖註冊特定使用者名稱,或預定飛機或劇院的座位,則應用需要檢查使用者名稱或座位是否已被佔用。(先前在“[容錯共識](ch8.md#容錯共識)”中討論過這個例子)當檢查成功時,應用可以生成一個事件,指示特定的使用者名稱是由特定的使用者ID註冊的,或者座位已經預留給特定的顧客。
|
||||
例如,如果使用者試圖註冊特定使用者名稱,或預定飛機或劇院的座位,則應用需要檢查使用者名稱或座位是否已被佔用。(先前在 “[容錯共識](ch8.md#容錯共識)” 中討論過這個例子)當檢查成功時,應用可以生成一個事件,指示特定的使用者名稱是由特定的使用者 ID 註冊的,或者座位已經預留給特定的顧客。
|
||||
|
||||
在事件生成的時刻,它就成為了**事實(fact)**。即使客戶稍後決定更改或取消預訂,他們之前曾預定了某個特定座位的事實仍然成立,而更改或取消是之後新增的單獨的事件。
|
||||
在事件生成的時刻,它就成為了 **事實(fact)**。即使客戶稍後決定更改或取消預訂,他們之前曾預定了某個特定座位的事實仍然成立,而更改或取消是之後新增的單獨的事件。
|
||||
|
||||
事件流的消費者不允許拒絕事件:當消費者看到事件時,它已經成為日誌中不可變的一部分,並且可能已經被其他消費者看到了。因此任何對命令的驗證,都需要在它成為事件之前同步完成。例如,透過使用一個可以原子性地自動驗證命令併發布事件的可序列事務。
|
||||
|
||||
或者,預訂座位的使用者請求可以拆分為兩個事件:第一個是暫時預約,第二個是驗證預約後的獨立的確認事件(如“[使用全序廣播實現線性一致的儲存](ch9.md#使用全序廣播實現線性一致的儲存)”中所述) 。這種分割方式允許驗證發生在一個非同步的過程中。
|
||||
或者,預訂座位的使用者請求可以拆分為兩個事件:第一個是暫時預約,第二個是驗證預約後的獨立的確認事件(如 “[使用全序廣播實現線性一致的儲存](ch9.md#使用全序廣播實現線性一致的儲存)” 中所述) 。這種分割方式允許驗證發生在一個非同步的過程中。
|
||||
|
||||
### 狀態、流和不變性
|
||||
|
||||
我們在[第十章](ch10.md)中看到,批處理因其輸入檔案不變性而受益良多,你可以在現有輸入檔案上執行實驗性處理作業,而不用擔心損壞它們。這種不變性原則也是使得事件溯源與變更資料捕獲如此強大的原因。
|
||||
我們在 [第十章](ch10.md) 中看到,批處理因其輸入檔案不變性而受益良多,你可以在現有輸入檔案上執行實驗性處理作業,而不用擔心損壞它們。這種不變性原則也是使得事件溯源與變更資料捕獲如此強大的原因。
|
||||
|
||||
我們通常將資料庫視為應用程式當前狀態的儲存 —— 這種表示針對讀取進行了最佳化,而且通常對於服務查詢而言是最為方便的表示。狀態的本質是,它會變化,所以資料庫才會支援資料的增刪改。這又該如何匹配不變性呢?
|
||||
|
||||
只要你的狀態發生了變化,那麼這個狀態就是這段時間中事件修改的結果。例如,當前可用的座位列表是你已處理的預訂所產生的結果,當前帳戶餘額是帳戶中的借與貸的結果,而Web伺服器的響應時間圖,是所有已發生Web請求的獨立響應時間的聚合結果。
|
||||
只要你的狀態發生了變化,那麼這個狀態就是這段時間中事件修改的結果。例如,當前可用的座位列表是你已處理的預訂所產生的結果,當前帳戶餘額是帳戶中的借與貸的結果,而 Web 伺服器的響應時間圖,是所有已發生 Web 請求的獨立響應時間的聚合結果。
|
||||
|
||||
無論狀態如何變化,總是有一系列事件導致了這些變化。即使事情已經執行與回滾,這些事件出現是始終成立的。關鍵的想法是:可變的狀態與不可變事件的僅追加日誌相互之間並不矛盾:它們是一體兩面,互為陰陽的。所有變化的日誌—— **變化日誌(changelog)**,表示了隨時間演變的狀態。
|
||||
無論狀態如何變化,總是有一系列事件導致了這些變化。即使事情已經執行與回滾,這些事件出現是始終成立的。關鍵的想法是:可變的狀態與不可變事件的僅追加日誌相互之間並不矛盾:它們是一體兩面,互為陰陽的。所有變化的日誌 —— **變化日誌(changelog)**,表示了隨時間演變的狀態。
|
||||
|
||||
如果你傾向於數學表示,那麼你可能會說,應用狀態是事件流對時間求積分得到的結果,而變更流是狀態對時間求微分的結果,如[圖11-6](../img/fig11-6.png)所示【49,50,51】。這個比喻有一些侷限性(例如,狀態的二階導似乎沒有意義),但這是考慮資料的一個實用出發點。
|
||||
如果你傾向於數學表示,那麼你可能會說,應用狀態是事件流對時間求積分得到的結果,而變更流是狀態對時間求微分的結果,如 [圖 11-6](../img/fig11-6.png) 所示【49,50,51】。這個比喻有一些侷限性(例如,狀態的二階導似乎沒有意義),但這是考慮資料的一個實用出發點。
|
||||
$$
|
||||
state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\
|
||||
stream(t) = \frac{d\ state(t)}{dt}
|
||||
$$
|
||||
![](../img/fig11-6.png)
|
||||
|
||||
**圖11-6 應用當前狀態與事件流之間的關係**
|
||||
**圖 11-6 應用當前狀態與事件流之間的關係**
|
||||
|
||||
如果你持久儲存了變更日誌,那麼重現狀態就非常簡單。如果你認為事件日誌是你的記錄系統,而所有的衍生狀態都從它派生而來,那麼系統中的資料流動就容易理解的多。正如帕特·赫蘭(Pat Helland)所說的【52】:
|
||||
如果你持久儲存了變更日誌,那麼重現狀態就非常簡單。如果你認為事件日誌是你的記錄系統,而所有的衍生狀態都從它派生而來,那麼系統中的資料流動就容易理解的多。正如帕特・赫蘭(Pat Helland)所說的【52】:
|
||||
|
||||
> 事務日誌記錄了資料庫的所有變更。高速追加是更改日誌的唯一方法。從這個角度來看,資料庫的內容其實是日誌中記錄最新值的快取。日誌才是真相,資料庫是日誌子集的快取,這一快取子集恰好來自日誌中每條記錄與索引值的最新值。
|
||||
|
||||
日誌壓縮(如“[日誌壓縮](#日誌壓縮)”中所述)是連線日誌與資料庫狀態之間的橋樑:它只保留每條記錄的最新版本,並丟棄被覆蓋的版本。
|
||||
日誌壓縮(如 “[日誌壓縮](#日誌壓縮)” 中所述)是連線日誌與資料庫狀態之間的橋樑:它只保留每條記錄的最新版本,並丟棄被覆蓋的版本。
|
||||
|
||||
#### 不可變事件的優點
|
||||
|
||||
@ -365,43 +365,43 @@ $$
|
||||
|
||||
如果發生錯誤,會計師不會刪除或更改分類帳中的錯誤交易 —— 而是新增另一筆交易以補償錯誤,例如退還一筆不正確的費用。不正確的交易將永遠保留在分類帳中,對於審計而言可能非常重要。如果從不正確的分類賬衍生出的錯誤數字已經公佈,那麼下一個會計週期的數字就會包括一個更正。這個過程在會計事務中是很常見的【54】。
|
||||
|
||||
儘管這種可審計性只在金融系統中尤其重要,但對於不受這種嚴格監管的許多其他系統,也是很有幫助的。如“[批處理輸出的哲學](ch10.md#批處理輸出的哲學)”中所討論的,如果你意外地部署了將錯誤資料寫入資料庫的錯誤程式碼,當代碼會破壞性地覆寫資料時,恢復要困難得多。使用不可變事件的僅追加日誌,診斷問題與故障恢復就要容易的多。
|
||||
儘管這種可審計性只在金融系統中尤其重要,但對於不受這種嚴格監管的許多其他系統,也是很有幫助的。如 “[批處理輸出的哲學](ch10.md#批處理輸出的哲學)” 中所討論的,如果你意外地部署了將錯誤資料寫入資料庫的錯誤程式碼,當代碼會破壞性地覆寫資料時,恢復要困難得多。使用不可變事件的僅追加日誌,診斷問題與故障恢復就要容易的多。
|
||||
|
||||
不可變的事件也包含了比當前狀態更多的資訊。例如在購物網站上,顧客可以將物品新增到他們的購物車,然後再將其移除。雖然從履行訂單的角度,第二個事件取消了第一個事件,但對分析目的而言,知道客戶考慮過某個特定項而之後又反悔,可能是很有用的。也許他們會選擇在未來購買,或者他們已經找到了替代品。這個資訊被記錄在事件日誌中,但對於移出購物車就刪除記錄的資料庫而言,這個資訊在移出購物車時可能就丟失了【42】。
|
||||
|
||||
#### 從同一事件日誌中派生多個檢視
|
||||
|
||||
此外,透過從不變的事件日誌中分離出可變的狀態,你可以針對不同的讀取方式,從相同的事件日誌中衍生出幾種不同的表現形式。效果就像一個流的多個消費者一樣([圖11-5](../img/fig11-5.png)):例如,分析型資料庫Druid使用這種方式直接從Kafka攝取資料【55】,Pistachio是一個分散式的鍵值儲存,使用Kafka作為提交日誌【56】,Kafka Connect能將來自Kafka的資料匯出到各種不同的資料庫與索引【41】。這對於許多其他儲存和索引系統(如搜尋伺服器)來說是很有意義的,當系統要從分散式日誌中獲取輸入時亦然(請參閱“[保持系統同步](#保持系統同步)”)。
|
||||
此外,透過從不變的事件日誌中分離出可變的狀態,你可以針對不同的讀取方式,從相同的事件日誌中衍生出幾種不同的表現形式。效果就像一個流的多個消費者一樣([圖 11-5](../img/fig11-5.png)):例如,分析型資料庫 Druid 使用這種方式直接從 Kafka 攝取資料【55】,Pistachio 是一個分散式的鍵值儲存,使用 Kafka 作為提交日誌【56】,Kafka Connect 能將來自 Kafka 的資料匯出到各種不同的資料庫與索引【41】。這對於許多其他儲存和索引系統(如搜尋伺服器)來說是很有意義的,當系統要從分散式日誌中獲取輸入時亦然(請參閱 “[保持系統同步](#保持系統同步)”)。
|
||||
|
||||
新增從事件日誌到資料庫的顯式轉換,能夠使應用更容易地隨時間演進:如果你想要引入一個新功能,以新的方式表示現有資料,則可以使用事件日誌來構建一個單獨的、針對新功能的讀取最佳化檢視,無需修改現有系統而與之共存。並行執行新舊系統通常比在現有系統中執行復雜的模式遷移更容易。一旦不再需要舊的系統,你可以簡單地關閉它並回收其資源【47,57】。
|
||||
|
||||
如果你不需要擔心如何查詢與訪問資料,那麼儲存資料通常是非常簡單的。模式設計、索引和儲存引擎的許多複雜性,都是希望支援某些特定查詢和訪問模式的結果(請參閱[第三章](ch3.md))。出於這個原因,透過將資料寫入的形式與讀取形式相分離,並允許幾個不同的讀取檢視,你能獲得很大的靈活性。這個想法有時被稱為**命令查詢責任分離(command query responsibility segregation, CQRS)**【42,58,59】。
|
||||
如果你不需要擔心如何查詢與訪問資料,那麼儲存資料通常是非常簡單的。模式設計、索引和儲存引擎的許多複雜性,都是希望支援某些特定查詢和訪問模式的結果(請參閱 [第三章](ch3.md))。出於這個原因,透過將資料寫入的形式與讀取形式相分離,並允許幾個不同的讀取檢視,你能獲得很大的靈活性。這個想法有時被稱為 **命令查詢責任分離(command query responsibility segregation, CQRS)**【42,58,59】。
|
||||
|
||||
資料庫和模式設計的傳統方法是基於這樣一種謬論,資料必須以與查詢相同的形式寫入。如果可以將資料從針對寫入最佳化的事件日誌轉換為針對讀取最佳化的應用狀態,那麼有關規範化和非規範化的爭論就變得無關緊要了(請參閱“[多對一和多對多的關係](ch2.md#多對一和多對多的關係)”):在針對讀取最佳化的檢視中對資料進行非規範化是完全合理的,因為翻譯過程提供了使其與事件日誌保持一致的機制。
|
||||
資料庫和模式設計的傳統方法是基於這樣一種謬論,資料必須以與查詢相同的形式寫入。如果可以將資料從針對寫入最佳化的事件日誌轉換為針對讀取最佳化的應用狀態,那麼有關規範化和非規範化的爭論就變得無關緊要了(請參閱 “[多對一和多對多的關係](ch2.md#多對一和多對多的關係)”):在針對讀取最佳化的檢視中對資料進行非規範化是完全合理的,因為翻譯過程提供了使其與事件日誌保持一致的機制。
|
||||
|
||||
在“[描述負載](ch1.md#描述負載)”中,我們討論了推特主頁時間線,它是特定使用者關注的人群所發推特的快取(類似郵箱)。這是**針對讀取最佳化的狀態**的又一個例子:主頁時間線是高度非規範化的,因為你的推文與你所有粉絲的時間線都構成了重複。然而,扇出服務保持了這種重複狀態與新推特以及新關注關係的同步,從而保證了重複的可管理性。
|
||||
在 “[描述負載](ch1.md#描述負載)” 中,我們討論了推特主頁時間線,它是特定使用者關注的人群所發推特的快取(類似郵箱)。這是 **針對讀取最佳化的狀態** 的又一個例子:主頁時間線是高度非規範化的,因為你的推文與你所有粉絲的時間線都構成了重複。然而,扇出服務保持了這種重複狀態與新推特以及新關注關係的同步,從而保證了重複的可管理性。
|
||||
|
||||
#### 併發控制
|
||||
|
||||
事件溯源和變更資料捕獲的最大缺點是,事件日誌的消費者通常是非同步的,所以可能會出現這樣的情況:使用者會寫入日誌,然後從日誌衍生檢視中讀取,結果發現他的寫入還沒有反映在讀取檢視中。我們之前在“[讀己之寫](ch5.md#讀己之寫)”中討論了這個問題以及可能的解決方案。
|
||||
事件溯源和變更資料捕獲的最大缺點是,事件日誌的消費者通常是非同步的,所以可能會出現這樣的情況:使用者會寫入日誌,然後從日誌衍生檢視中讀取,結果發現他的寫入還沒有反映在讀取檢視中。我們之前在 “[讀己之寫](ch5.md#讀己之寫)” 中討論了這個問題以及可能的解決方案。
|
||||
|
||||
一種解決方案是將事件追加到日誌時同步執行讀取檢視的更新。而將這些寫入操作合併為一個原子單元需要**事務**,所以要麼將事件日誌和讀取檢視儲存在同一個儲存系統中,要麼就需要跨不同系統進行分散式事務。或者,你也可以使用在“[使用全序廣播實現線性一致的儲存](ch9.md#使用全序廣播實現線性一致的儲存)”中討論的方法。
|
||||
一種解決方案是將事件追加到日誌時同步執行讀取檢視的更新。而將這些寫入操作合併為一個原子單元需要 **事務**,所以要麼將事件日誌和讀取檢視儲存在同一個儲存系統中,要麼就需要跨不同系統進行分散式事務。或者,你也可以使用在 “[使用全序廣播實現線性一致的儲存](ch9.md#使用全序廣播實現線性一致的儲存)” 中討論的方法。
|
||||
|
||||
另一方面,從事件日誌匯出當前狀態也簡化了併發控制的某些部分。許多對於多物件事務的需求(請參閱“[單物件和多物件操作](ch7.md#單物件和多物件操作)”)源於單個使用者操作需要在多個不同的位置更改資料。透過事件溯源,你可以設計一個自包含的事件以表示一個使用者操作。然後使用者操作就只需要在一個地方進行單次寫入操作 —— 即將事件附加到日誌中 —— 這個還是很容易使原子化的。
|
||||
另一方面,從事件日誌匯出當前狀態也簡化了併發控制的某些部分。許多對於多物件事務的需求(請參閱 “[單物件和多物件操作](ch7.md#單物件和多物件操作)”)源於單個使用者操作需要在多個不同的位置更改資料。透過事件溯源,你可以設計一個自包含的事件以表示一個使用者操作。然後使用者操作就只需要在一個地方進行單次寫入操作 —— 即將事件附加到日誌中 —— 這個還是很容易使原子化的。
|
||||
|
||||
如果事件日誌與應用狀態以相同的方式分割槽(例如,處理分割槽3中的客戶事件只需要更新分割槽3中的應用狀態),那麼直接使用單執行緒日誌消費者就不需要寫入併發控制了。它從設計上一次只處理一個事件(請參閱“[真的序列執行](ch7.md#真的序列執行)”)。日誌透過在分割槽中定義事件的序列順序,消除了併發性的不確定性【24】。如果一個事件觸及多個狀態分割槽,那麼需要做更多的工作,我們將在[第十二章](ch12.md)討論。
|
||||
如果事件日誌與應用狀態以相同的方式分割槽(例如,處理分割槽 3 中的客戶事件只需要更新分割槽 3 中的應用狀態),那麼直接使用單執行緒日誌消費者就不需要寫入併發控制了。它從設計上一次只處理一個事件(請參閱 “[真的序列執行](ch7.md#真的序列執行)”)。日誌透過在分割槽中定義事件的序列順序,消除了併發性的不確定性【24】。如果一個事件觸及多個狀態分割槽,那麼需要做更多的工作,我們將在 [第十二章](ch12.md) 討論。
|
||||
|
||||
#### 不變性的侷限性
|
||||
|
||||
許多不使用事件溯源模型的系統也還是依賴不可變性:各種資料庫在內部使用不可變的資料結構或多版本資料來支援時間點快照(請參閱“[索引和快照隔離](ch7.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】,而備份通常是特意做成不可變的,防止意外刪除或損壞。刪除操作更多的是指“使取回資料更困難”,而不是指“使取回資料不可能”。無論如何,有時你必須得嘗試,正如我們在“[立法與自律](ch12.md#立法與自律)”中所看到的。
|
||||
真正刪除資料是非常非常困難的【64】,因為副本可能存在於很多地方:例如,儲存引擎,檔案系統和 SSD 通常會向一個新位置寫入,而不是原地覆蓋舊資料【52】,而備份通常是特意做成不可變的,防止意外刪除或損壞。刪除操作更多的是指 “使取回資料更困難”,而不是指 “使取回資料不可能”。無論如何,有時你必須得嘗試,正如我們在 “[立法與自律](ch12.md#立法與自律)” 中所看到的。
|
||||
|
||||
|
||||
## 流處理
|
||||
@ -410,15 +410,15 @@ $$
|
||||
|
||||
剩下的就是討論一下你可以用流做什麼 —— 也就是說,你可以處理它。一般來說,有三種選項:
|
||||
|
||||
1. 你可以將事件中的資料寫入資料庫、快取、搜尋索引或類似的儲存系統,然後能被其他客戶端查詢。如[圖11-5](../img/fig11-5.png)所示,這是資料庫與系統其他部分所發生的變更保持同步的好方法 —— 特別是當流消費者是寫入資料庫的唯一客戶端時。如“[批處理工作流的輸出](ch10.md#批處理工作流的輸出)”中所討論的,它是寫入儲存系統的流等價物。
|
||||
1. 你可以將事件中的資料寫入資料庫、快取、搜尋索引或類似的儲存系統,然後能被其他客戶端查詢。如 [圖 11-5](../img/fig11-5.png) 所示,這是資料庫與系統其他部分所發生的變更保持同步的好方法 —— 特別是當流消費者是寫入資料庫的唯一客戶端時。如 “[批處理工作流的輸出](ch10.md#批處理工作流的輸出)” 中所討論的,它是寫入儲存系統的流等價物。
|
||||
2. 你能以某種方式將事件推送給使用者,例如傳送報警郵件或推送通知,或將事件流式傳輸到可實時顯示的儀表板上。在這種情況下,人是流的最終消費者。
|
||||
3. 你可以處理一個或多個輸入流,併產生一個或多個輸出流。流可能會經過由幾個這樣的處理階段組成的流水線,最後再輸出(選項1或2)。
|
||||
3. 你可以處理一個或多個輸入流,併產生一個或多個輸出流。流可能會經過由幾個這樣的處理階段組成的流水線,最後再輸出(選項 1 或 2)。
|
||||
|
||||
在本章的剩餘部分中,我們將討論選項3:處理流以產生其他衍生流。處理這樣的流的程式碼片段,被稱為**運算元(operator)** 或**作業(job)**。它與我們在[第十章](ch10.md)中討論過的Unix程序和MapReduce作業密切相關,資料流的模式是相似的:一個流處理器以只讀的方式使用輸入流,並將其輸出以僅追加的方式寫入一個不同的位置。
|
||||
在本章的剩餘部分中,我們將討論選項 3:處理流以產生其他衍生流。處理這樣的流的程式碼片段,被稱為 **運算元(operator)** 或 **作業(job)**。它與我們在 [第十章](ch10.md) 中討論過的 Unix 程序和 MapReduce 作業密切相關,資料流的模式是相似的:一個流處理器以只讀的方式使用輸入流,並將其輸出以僅追加的方式寫入一個不同的位置。
|
||||
|
||||
流處理中的分割槽和並行化模式也非常類似於[第十章](ch10.md)中介紹的MapReduce和資料流引擎,因此我們不再重複這些主題。基本的Map操作(如轉換和過濾記錄)也是一樣的。
|
||||
流處理中的分割槽和並行化模式也非常類似於 [第十章](ch10.md) 中介紹的 MapReduce 和資料流引擎,因此我們不再重複這些主題。基本的 Map 操作(如轉換和過濾記錄)也是一樣的。
|
||||
|
||||
與批次作業相比的一個關鍵區別是,流不會結束。這種差異會帶來很多隱含的結果。正如本章開始部分所討論的,排序對無界資料集沒有意義,因此無法使用**排序合併連線**(請參閱“[Reduce側連線與分組](ch10.md#Reduce側連線與分組)”)。容錯機制也必須改變:對於已經運行了幾分鐘的批處理作業,可以簡單地從頭開始重啟失敗任務,但是對於已經執行數年的流作業,重啟後從頭開始跑可能並不是一個可行的選項。
|
||||
與批次作業相比的一個關鍵區別是,流不會結束。這種差異會帶來很多隱含的結果。正如本章開始部分所討論的,排序對無界資料集沒有意義,因此無法使用 **排序合併連線**(請參閱 “[Reduce 側連線與分組](ch10.md#Reduce側連線與分組)”)。容錯機制也必須改變:對於已經運行了幾分鐘的批處理作業,可以簡單地從頭開始重啟失敗任務,但是對於已經執行數年的流作業,重啟後從頭開始跑可能並不是一個可行的選項。
|
||||
|
||||
### 流處理的應用
|
||||
|
||||
@ -433,100 +433,100 @@ $$
|
||||
|
||||
#### 複合事件處理
|
||||
|
||||
**複合事件處理(complex event processing, CEP)** 是20世紀90年代為分析事件流而開發出的一種方法,尤其適用於需要搜尋某些事件模式的應用【65,66】。與正則表示式允許你在字串中搜索特定字元模式的方式類似,CEP允許你指定規則以在流中搜索某些事件模式。
|
||||
**複合事件處理(complex event processing, CEP)** 是 20 世紀 90 年代為分析事件流而開發出的一種方法,尤其適用於需要搜尋某些事件模式的應用【65,66】。與正則表示式允許你在字串中搜索特定字元模式的方式類似,CEP 允許你指定規則以在流中搜索某些事件模式。
|
||||
|
||||
CEP系統通常使用高層次的宣告式查詢語言,比如SQL,或者圖形使用者介面,來描述應該檢測到的事件模式。這些查詢被提交給處理引擎,該引擎消費輸入流,並在內部維護一個執行所需匹配的狀態機。當發現匹配時,引擎發出一個**複合事件**(即complex event,CEP因此得名),並附有檢測到的事件模式詳情【67】。
|
||||
CEP 系統通常使用高層次的宣告式查詢語言,比如 SQL,或者圖形使用者介面,來描述應該檢測到的事件模式。這些查詢被提交給處理引擎,該引擎消費輸入流,並在內部維護一個執行所需匹配的狀態機。當發現匹配時,引擎發出一個 **複合事件**(即 complex event,CEP 因此得名),並附有檢測到的事件模式詳情【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】。
|
||||
|
||||
#### 流分析
|
||||
|
||||
使用流處理的另一個領域是對流進行分析。 CEP與流分析之間的邊界是模糊的,但一般來說,分析往往對找出特定事件序列並不關心,而更關注大量事件上的聚合與統計指標 —— 例如:
|
||||
使用流處理的另一個領域是對流進行分析。 CEP 與流分析之間的邊界是模糊的,但一般來說,分析往往對找出特定事件序列並不關心,而更關注大量事件上的聚合與統計指標 —— 例如:
|
||||
|
||||
* 測量某種型別事件的速率(每個時間間隔內發生的頻率)
|
||||
* 滾動計算一段時間視窗內某個值的平均值
|
||||
* 將當前的統計值與先前的時間區間的值對比(例如,檢測趨勢,當指標與上週同比異常偏高或偏低時報警)
|
||||
|
||||
這些統計值通常是在固定時間區間內進行計算的,例如,你可能想知道在過去5分鐘內服務每秒查詢次數的均值,以及此時間段內響應時間的第99百分位點。在幾分鐘內取平均,能抹平秒和秒之間的無關波動,且仍然能向你展示流量模式的時間圖景。聚合的時間間隔稱為**視窗(window)**,我們將在“[時間推理](#時間推理)”中更詳細地討論視窗。
|
||||
這些統計值通常是在固定時間區間內進行計算的,例如,你可能想知道在過去 5 分鐘內服務每秒查詢次數的均值,以及此時間段內響應時間的第 99 百分位點。在幾分鐘內取平均,能抹平秒和秒之間的無關波動,且仍然能向你展示流量模式的時間圖景。聚合的時間間隔稱為 **視窗(window)**,我們將在 “[時間推理](#時間推理)” 中更詳細地討論視窗。
|
||||
|
||||
流分析系統有時會使用概率演算法,例如Bloom filter(我們在“[效能最佳化](ch3.md#效能最佳化)”中遇到過)來管理成員資格,HyperLogLog【72】用於基數估計以及各種百分比估計演算法(請參閱“[實踐中的百分位點](ch1.md#實踐中的百分位點)“)。概率演算法產出近似的結果,但比起精確演算法的優點是記憶體使用要少得多。使用近似演算法有時讓人們覺得流處理系統總是有損的和不精確的,但這是錯誤看法:流處理並沒有任何內在的近似性,而概率演算法只是一種最佳化【73】。
|
||||
流分析系統有時會使用概率演算法,例如 Bloom filter(我們在 “[效能最佳化](ch3.md#效能最佳化)” 中遇到過)來管理成員資格,HyperLogLog【72】用於基數估計以及各種百分比估計演算法(請參閱 “[實踐中的百分位點](ch1.md#實踐中的百分位點)“)。概率演算法產出近似的結果,但比起精確演算法的優點是記憶體使用要少得多。使用近似演算法有時讓人們覺得流處理系統總是有損的和不精確的,但這是錯誤看法:流處理並沒有任何內在的近似性,而概率演算法只是一種最佳化【73】。
|
||||
|
||||
許多開源分散式流處理框架的設計都是針對分析設計的:例如Apache Storm,Spark Streaming,Flink,Concord,Samza和Kafka Streams 【74】。託管服務包括Google Cloud Dataflow和Azure Stream Analytics。
|
||||
許多開源分散式流處理框架的設計都是針對分析設計的:例如 Apache Storm、Spark Streaming、Flink、Concord、Samza 和 Kafka Streams 【74】。託管服務包括 Google Cloud Dataflow 和 Azure Stream Analytics。
|
||||
|
||||
#### 維護物化檢視
|
||||
|
||||
我們在“[資料庫與流](#資料庫與流)”中看到,資料庫的變更流可以用於維護衍生資料系統(如快取、搜尋索引和資料倉庫),並使其與源資料庫保持最新。我們可以將這些示例視作維護**物化檢視(materialized view)** 的一種具體場景(請參閱“[聚合:資料立方體和物化檢視](ch3.md#聚合:資料立方體和物化檢視)”):在某個資料集上衍生出一個替代檢視以便高效查詢,並在底層資料變更時更新檢視【50】。
|
||||
我們在 “[資料庫與流](#資料庫與流)” 中看到,資料庫的變更流可以用於維護衍生資料系統(如快取、搜尋索引和資料倉庫),並使其與源資料庫保持最新。我們可以將這些示例視作維護 **物化檢視(materialized view)** 的一種具體場景(請參閱 “[聚合:資料立方體和物化檢視](ch3.md#聚合:資料立方體和物化檢視)”):在某個資料集上衍生出一個替代檢視以便高效查詢,並在底層資料變更時更新檢視【50】。
|
||||
|
||||
同樣,在事件溯源中,應用程式的狀態是透過應用事件日誌來維護的;這裡的應用程式狀態也是一種物化檢視。與流分析場景不同的是,僅考慮某個時間視窗內的事件通常是不夠的:構建物化檢視可能需要任意時間段內的**所有**事件,除了那些可能由日誌壓縮丟棄的過時事件(請參閱“[日誌壓縮](#日誌壓縮)“)。實際上,你需要一個可以一直延伸到時間開端的視窗。
|
||||
同樣,在事件溯源中,應用程式的狀態是透過應用事件日誌來維護的;這裡的應用程式狀態也是一種物化檢視。與流分析場景不同的是,僅考慮某個時間視窗內的事件通常是不夠的:構建物化檢視可能需要任意時間段內的 **所有** 事件,除了那些可能由日誌壓縮丟棄的過時事件(請參閱 “[日誌壓縮](#日誌壓縮)“)。實際上,你需要一個可以一直延伸到時間開端的視窗。
|
||||
|
||||
原則上講,任何流處理元件都可以用於維護物化檢視,儘管“永遠執行”與一些面向分析的框架假設的“主要在有限時間段視窗上執行”背道而馳, Samza和Kafka Streams支援這種用法,建立在Kafka對日誌壓縮的支援上【75】。
|
||||
原則上講,任何流處理元件都可以用於維護物化檢視,儘管 “永遠執行” 與一些面向分析的框架假設的 “主要在有限時間段視窗上執行” 背道而馳, Samza 和 Kafka Streams 支援這種用法,建立在 Kafka 對日誌壓縮的支援上【75】。
|
||||
|
||||
#### 在流上搜索
|
||||
|
||||
除了允許搜尋由多個事件構成模式的CEP外,有時也存在基於複雜標準(例如全文搜尋查詢)來搜尋單個事件的需求。
|
||||
除了允許搜尋由多個事件構成模式的 CEP 外,有時也存在基於複雜標準(例如全文搜尋查詢)來搜尋單個事件的需求。
|
||||
|
||||
例如,媒體監測服務可以訂閱新聞文章Feed與來自媒體的播客,搜尋任何關於公司、產品或感興趣的話題的新聞。這是透過預先構建一個搜尋查詢來完成的,然後不斷地將新聞項的流與該查詢進行匹配。在一些網站上也有類似的功能:例如,當市場上出現符合其搜尋條件的新房產時,房地產網站的使用者可以要求網站通知他們。Elasticsearch的這種過濾器功能,是實現這種流搜尋的一種選擇【76】。
|
||||
例如,媒體監測服務可以訂閱新聞文章 Feed 與來自媒體的播客,搜尋任何關於公司、產品或感興趣的話題的新聞。這是透過預先構建一個搜尋查詢來完成的,然後不斷地將新聞項的流與該查詢進行匹配。在一些網站上也有類似的功能:例如,當市場上出現符合其搜尋條件的新房產時,房地產網站的使用者可以要求網站通知他們。Elasticsearch 的這種過濾器功能,是實現這種流搜尋的一種選擇【76】。
|
||||
|
||||
傳統的搜尋引擎首先索引檔案,然後在索引上跑查詢。相比之下,搜尋一個數據流則反了過來:查詢被儲存下來,文件從查詢中流過,就像在CEP中一樣。最簡單的情況就是,你可以為每個文件測試每個查詢。但是如果你有大量查詢,這可能會變慢。為了最佳化這個過程,可以像對文件一樣,為查詢建立索引。因而收窄可能匹配的查詢集合【77】。
|
||||
傳統的搜尋引擎首先索引檔案,然後在索引上跑查詢。相比之下,搜尋一個數據流則反了過來:查詢被儲存下來,文件從查詢中流過,就像在 CEP 中一樣。最簡單的情況就是,你可以為每個文件測試每個查詢。但是如果你有大量查詢,這可能會變慢。為了最佳化這個過程,可以像對文件一樣,為查詢建立索引。因而收窄可能匹配的查詢集合【77】。
|
||||
|
||||
#### 訊息傳遞和RPC
|
||||
|
||||
在“[訊息傳遞中的資料流](ch4.md#訊息傳遞中的資料流)”中我們討論過,訊息傳遞系統可以作為RPC的替代方案,即作為一種服務間通訊的機制,比如在Actor模型中所使用的那樣。儘管這些系統也是基於訊息和事件,但我們通常不會將其視作流處理元件:
|
||||
在 “[訊息傳遞中的資料流](ch4.md#訊息傳遞中的資料流)” 中我們討論過,訊息傳遞系統可以作為 RPC 的替代方案,即作為一種服務間通訊的機制,比如在 Actor 模型中所使用的那樣。儘管這些系統也是基於訊息和事件,但我們通常不會將其視作流處理元件:
|
||||
|
||||
* Actor框架主要是管理模組通訊的併發和分散式執行的一種機制,而流處理主要是一種資料管理技術。
|
||||
* Actor之間的交流往往是短暫的、一對一的;而事件日誌則是持久的、多訂閱者的。
|
||||
* Actor可以以任意方式進行通訊(包括迴圈的請求/響應模式),但流處理通常配置在無環流水線中,其中每個流都是一個特定作業的輸出,由良好定義的輸入流中派生而來。
|
||||
* Actor 框架主要是管理模組通訊的併發和分散式執行的一種機制,而流處理主要是一種資料管理技術。
|
||||
* Actor 之間的交流往往是短暫的、一對一的;而事件日誌則是持久的、多訂閱者的。
|
||||
* Actor 可以以任意方式進行通訊(包括迴圈的請求 / 響應模式),但流處理通常配置在無環流水線中,其中每個流都是一個特定作業的輸出,由良好定義的輸入流中派生而來。
|
||||
|
||||
也就是說,RPC類系統與流處理之間有一些交叉領域。例如,Apache Storm有一個稱為**分散式RPC**的功能,它允許將使用者查詢分散到一系列也處理事件流的節點上;然後這些查詢與來自輸入流的事件交織,而結果可以被彙總併發回給使用者【78】(另請參閱“[多分割槽資料處理](ch12.md#多分割槽資料處理)”)。
|
||||
也就是說,RPC 類系統與流處理之間有一些交叉領域。例如,Apache Storm 有一個稱為 **分散式 RPC** 的功能,它允許將使用者查詢分散到一系列也處理事件流的節點上;然後這些查詢與來自輸入流的事件交織,而結果可以被彙總併發回給使用者【78】(另請參閱 “[多分割槽資料處理](ch12.md#多分割槽資料處理)”)。
|
||||
|
||||
也可以使用Actor框架來處理流。但是,很多這樣的框架在崩潰時不能保證訊息的傳遞,除非你實現了額外的重試邏輯,否則這種處理不是容錯的。
|
||||
也可以使用 Actor 框架來處理流。但是,很多這樣的框架在崩潰時不能保證訊息的傳遞,除非你實現了額外的重試邏輯,否則這種處理不是容錯的。
|
||||
|
||||
### 時間推理
|
||||
|
||||
流處理通常需要與時間打交道,尤其是用於分析目的時候,會頻繁使用時間視窗,例如“過去五分鐘的平均值”。“過去五分鐘”的含義看上去似乎是清晰而無歧義的,但不幸的是,這個概念非常棘手。
|
||||
流處理通常需要與時間打交道,尤其是用於分析目的時候,會頻繁使用時間視窗,例如 “過去五分鐘的平均值”。“過去五分鐘” 的含義看上去似乎是清晰而無歧義的,但不幸的是,這個概念非常棘手。
|
||||
|
||||
在批處理中過程中,大量的歷史事件被快速地處理。如果需要按時間來分析,批處理器需要檢查每個事件中嵌入的時間戳。讀取執行批處理機器的系統時鐘沒有任何意義,因為處理執行的時間與事件實際發生的時間無關。
|
||||
|
||||
批處理可以在幾分鐘內讀取一年的歷史事件;在大多數情況下,感興趣的時間線是歷史中的一年,而不是處理中的幾分鐘。而且使用事件中的時間戳,使得處理是**確定性**的:在相同的輸入上再次執行相同的處理過程會得到相同的結果(請參閱“[容錯](ch10.md#容錯)”)。
|
||||
批處理可以在幾分鐘內讀取一年的歷史事件;在大多數情況下,感興趣的時間線是歷史中的一年,而不是處理中的幾分鐘。而且使用事件中的時間戳,使得處理是 **確定性** 的:在相同的輸入上再次執行相同的處理過程會得到相同的結果(請參閱 “[容錯](ch10.md#容錯)”)。
|
||||
|
||||
另一方面,許多流處理框架使用處理機器上的本地系統時鐘(**處理時間**,即processing time)來確定**視窗(windowing)**【79】。這種方法的優點是簡單,如果事件建立與事件處理之間的延遲可以忽略不計,那也是合理的。然而,如果存在任何顯著的處理延遲 —— 即,事件處理顯著地晚於事件實際發生的時間,這種處理方式就失效了。
|
||||
另一方面,許多流處理框架使用處理機器上的本地系統時鐘(**處理時間**,即 processing time)來確定 **視窗(windowing)**【79】。這種方法的優點是簡單,如果事件建立與事件處理之間的延遲可以忽略不計,那也是合理的。然而,如果存在任何顯著的處理延遲 —— 即,事件處理顯著地晚於事件實際發生的時間,這種處理方式就失效了。
|
||||
|
||||
#### 事件時間與處理時間
|
||||
|
||||
很多原因都可能導致處理延遲:排隊,網路故障(請參閱“[不可靠的網路](ch8.md#不可靠的網路)”),效能問題導致訊息代理/訊息處理器出現爭用,流消費者重啟,從故障中恢復時重新處理過去的事件(請參閱“[重播舊訊息](#重播舊訊息)”),或者在修復程式碼BUG之後。
|
||||
很多原因都可能導致處理延遲:排隊,網路故障(請參閱 “[不可靠的網路](ch8.md#不可靠的網路)”),效能問題導致訊息代理 / 訊息處理器出現爭用,流消費者重啟,從故障中恢復時重新處理過去的事件(請參閱 “[重播舊訊息](#重播舊訊息)”),或者在修復程式碼 BUG 之後。
|
||||
|
||||
而且,訊息延遲還可能導致無法預測訊息順序。例如,假設使用者首先發出一個Web請求(由Web伺服器A處理),然後發出第二個請求(由伺服器B處理)。 A和B發出描述它們所處理請求的事件,但是B的事件在A的事件發生之前到達訊息代理。現在,流處理器將首先看到B事件,然後看到A事件,即使它們實際上是以相反的順序發生的。
|
||||
而且,訊息延遲還可能導致無法預測訊息順序。例如,假設使用者首先發出一個 Web 請求(由 Web 伺服器 A 處理),然後發出第二個請求(由伺服器 B 處理)。 A 和 B 發出描述它們所處理請求的事件,但是 B 的事件在 A 的事件發生之前到達訊息代理。現在,流處理器將首先看到 B 事件,然後看到 A 事件,即使它們實際上是以相反的順序發生的。
|
||||
|
||||
有一個類比也許能幫助理解,“星球大戰”電影:第四集於1977年發行,第五集於1980年,第六集於1983年,緊隨其後的是1999年的第一集,2002年的第二集,和2005年的第三集,以及2015年的第七集【80】[^ii]。如果你按照按照它們上映的順序觀看電影,你處理電影的順序與它們敘事的順序就是不一致的。 (集數編號就像事件時間戳,而你觀看電影的日期就是處理時間)作為人類,我們能夠應對這種不連續性,但是流處理演算法需要專門編寫,以適應這種時序與順序的問題。
|
||||
有一個類比也許能幫助理解,“星球大戰” 電影:第四集於 1977 年發行,第五集於 1980 年,第六集於 1983 年,緊隨其後的是 1999 年的第一集,2002 年的第二集,和 2005 年的第三集,以及 2015 年的第七集【80】[^ii]。如果你按照按照它們上映的順序觀看電影,你處理電影的順序與它們敘事的順序就是不一致的。 (集數編號就像事件時間戳,而你觀看電影的日期就是處理時間)作為人類,我們能夠應對這種不連續性,但是流處理演算法需要專門編寫,以適應這種時序與順序的問題。
|
||||
|
||||
[^ii]: 感謝Flink社群的Kostas Kloudas提出這個比喻。
|
||||
[^ii]: 感謝 Flink 社群的 Kostas Kloudas 提出這個比喻。
|
||||
|
||||
將事件時間和處理時間搞混會導致錯誤的資料。例如,假設你有一個流處理器用於測量請求速率(計算每秒請求數)。如果你重新部署流處理器,它可能會停止一分鐘,並在恢復之後處理積壓的事件。如果你按處理時間來衡量速率,那麼在處理積壓日誌時,請求速率看上去就像有一個異常的突發尖峰,而實際上請求速率是穩定的([圖11-7](../img/fig11-7.png))。
|
||||
將事件時間和處理時間搞混會導致錯誤的資料。例如,假設你有一個流處理器用於測量請求速率(計算每秒請求數)。如果你重新部署流處理器,它可能會停止一分鐘,並在恢復之後處理積壓的事件。如果你按處理時間來衡量速率,那麼在處理積壓日誌時,請求速率看上去就像有一個異常的突發尖峰,而實際上請求速率是穩定的([圖 11-7](../img/fig11-7.png))。
|
||||
|
||||
![](../img/fig11-7.png)
|
||||
|
||||
**圖11-7 按處理時間分窗,會因為處理速率的變動引入人為因素**
|
||||
**圖 11-7 按處理時間分窗,會因為處理速率的變動引入人為因素**
|
||||
|
||||
#### 知道什麼時候準備好了
|
||||
|
||||
用事件時間來定義視窗的一個棘手的問題是,你永遠也無法確定是不是已經收到了特定視窗的所有事件,還是說還有一些事件正在來的路上。
|
||||
|
||||
例如,假設你將事件分組為一分鐘的視窗,以便統計每分鐘的請求數。你已經計數了一些帶有本小時內第37分鐘時間戳的事件,時間流逝,現在進入的主要都是本小時內第38和第39分鐘的事件。什麼時候才能宣佈你已經完成了第37分鐘的視窗計數,並輸出其計數器值?
|
||||
例如,假設你將事件分組為一分鐘的視窗,以便統計每分鐘的請求數。你已經計數了一些帶有本小時內第 37 分鐘時間戳的事件,時間流逝,現在進入的主要都是本小時內第 38 和第 39 分鐘的事件。什麼時候才能宣佈你已經完成了第 37 分鐘的視窗計數,並輸出其計數器值?
|
||||
|
||||
在一段時間沒有看到任何新的事件之後,你可以超時並宣佈一個視窗已經就緒,但仍然可能發生這種情況:某些事件被緩衝在另一臺機器上,由於網路中斷而延遲。你需要能夠處理這種在視窗宣告完成之後到達的 **滯留(straggler)** 事件。大體上,你有兩種選擇【1】:
|
||||
|
||||
1. 忽略這些滯留事件,因為在正常情況下它們可能只是事件中的一小部分。你可以將丟棄事件的數量作為一個監控指標,並在出現大量丟訊息的情況時報警。
|
||||
2. 釋出一個**更正(correction)**,一個包括滯留事件的更新視窗值。你可能還需要收回以前的輸出。
|
||||
2. 釋出一個 **更正(correction)**,一個包括滯留事件的更新視窗值。你可能還需要收回以前的輸出。
|
||||
|
||||
在某些情況下,可以使用特殊的訊息來指示“從現在開始,不會有比t更早時間戳的訊息了”,消費者可以使用它來觸發視窗【81】。但是,如果不同機器上的多個生產者都在生成事件,每個生產者都有自己的最小時間戳閾值,則消費者需要分別跟蹤每個生產者。在這種情況下,新增和刪除生產者都是比較棘手的。
|
||||
在某些情況下,可以使用特殊的訊息來指示 “從現在開始,不會有比 t 更早時間戳的訊息了”,消費者可以使用它來觸發視窗【81】。但是,如果不同機器上的多個生產者都在生成事件,每個生產者都有自己的最小時間戳閾值,則消費者需要分別跟蹤每個生產者。在這種情況下,新增和刪除生產者都是比較棘手的。
|
||||
|
||||
#### 你用的是誰的時鐘?
|
||||
|
||||
當事件可能在系統內多個地方進行緩衝時,為事件分配時間戳更加困難了。例如,考慮一個移動應用向伺服器上報關於用量的事件。該應用可能會在裝置處於離線狀態時被使用,在這種情況下,它將在裝置本地緩衝事件,並在下一次網際網路連線可用時向伺服器上報這些事件(可能是幾小時甚至幾天)。對於這個流的任意消費者而言,它們就如延遲極大的滯留事件一樣。
|
||||
|
||||
在這種情況下,事件上的事件戳實際上應當是使用者交互發生的時間,取決於移動裝置的本地時鐘。然而使用者控制的裝置上的時鐘通常是不可信的,因為它可能會被無意或故意設定成錯誤的時間(請參閱“[時鐘同步與準確性](ch8.md#時鐘同步與準確性)”)。伺服器收到事件的時間(取決於伺服器的時鐘)可能是更準確的,因為伺服器在你的控制之下,但在描述使用者互動方面意義不大。
|
||||
在這種情況下,事件上的事件戳實際上應當是使用者交互發生的時間,取決於移動裝置的本地時鐘。然而使用者控制的裝置上的時鐘通常是不可信的,因為它可能會被無意或故意設定成錯誤的時間(請參閱 “[時鐘同步與準確性](ch8.md#時鐘同步與準確性)”)。伺服器收到事件的時間(取決於伺服器的時鐘)可能是更準確的,因為伺服器在你的控制之下,但在描述使用者互動方面意義不大。
|
||||
|
||||
要校正不正確的裝置時鐘,一種方法是記錄三個時間戳【82】:
|
||||
|
||||
@ -544,58 +544,58 @@ CEP的實現包括Esper【69】,IBM InfoSphere Streams【70】,Apama,TIBCO
|
||||
|
||||
* 滾動視窗(Tumbling Window)
|
||||
|
||||
滾動視窗有著固定的長度,每個事件都僅能屬於一個視窗。例如,假設你有一個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 Window)
|
||||
|
||||
跳動視窗也有著固定的長度,但允許視窗重疊以提供一些平滑。例如,一個帶有1分鐘跳躍步長的5分鐘視窗將包含`10:03:00`至`10:07:59`之間的事件,而下一個視窗將覆蓋`10:04:00`至`10:08:59`之間的事件,等等。透過首先計算1分鐘的滾動視窗(tunmbling window),然後在幾個相鄰視窗上進行聚合,可以實現這種跳動視窗。
|
||||
跳動視窗也有著固定的長度,但允許視窗重疊以提供一些平滑。例如,一個帶有 1 分鐘跳躍步長的 5 分鐘視窗將包含 `10:03:00` 至 `10:07:59` 之間的事件,而下一個視窗將覆蓋 `10:04:00` 至 `10:08:59` 之間的事件,等等。透過首先計算 1 分鐘的滾動視窗(tunmbling window),然後在幾個相鄰視窗上進行聚合,可以實現這種跳動視窗。
|
||||
|
||||
* 滑動視窗(Sliding Window)
|
||||
|
||||
滑動視窗包含了彼此間距在特定時長內的所有事件。例如,一個5分鐘的滑動視窗應當覆蓋`10:03:39`和`10:08:12`的事件,因為它們相距不超過5分鐘(注意滾動視窗與步長5分鐘的跳動視窗可能不會把這兩個事件分組到同一個視窗中,因為它們使用固定的邊界)。透過維護一個按時間排序的事件緩衝區,並不斷從視窗中移除過期的舊事件,可以實現滑動視窗。
|
||||
滑動視窗包含了彼此間距在特定時長內的所有事件。例如,一個 5 分鐘的滑動視窗應當覆蓋 `10:03:39` 和 `10:08:12` 的事件,因為它們相距不超過 5 分鐘(注意滾動視窗與步長 5 分鐘的跳動視窗可能不會把這兩個事件分組到同一個視窗中,因為它們使用固定的邊界)。透過維護一個按時間排序的事件緩衝區,並不斷從視窗中移除過期的舊事件,可以實現滑動視窗。
|
||||
|
||||
* 會話視窗(Session window)
|
||||
|
||||
與其他視窗型別不同,會話視窗沒有固定的持續時間,而定義為:將同一使用者出現時間相近的所有事件分組在一起,而當用戶一段時間沒有活動時(例如,如果30分鐘內沒有事件)視窗結束。會話切分是網站分析的常見需求(請參閱“[分組](ch10.md#分組)”)。
|
||||
與其他視窗型別不同,會話視窗沒有固定的持續時間,而定義為:將同一使用者出現時間相近的所有事件分組在一起,而當用戶一段時間沒有活動時(例如,如果 30 分鐘內沒有事件)視窗結束。會話切分是網站分析的常見需求(請參閱 “[分組](ch10.md#分組)”)。
|
||||
|
||||
### 流連線
|
||||
|
||||
在[第十章](ch10.md)中,我們討論了批處理作業如何透過鍵來連線資料集,以及這種連線是如何成為資料管道的重要組成部分的。由於流處理將資料管道泛化為對無限資料集進行增量處理,因此對流進行連線的需求也是完全相同的。
|
||||
在 [第十章](ch10.md) 中,我們討論了批處理作業如何透過鍵來連線資料集,以及這種連線是如何成為資料管道的重要組成部分的。由於流處理將資料管道泛化為對無限資料集進行增量處理,因此對流進行連線的需求也是完全相同的。
|
||||
|
||||
然而,新事件隨時可能出現在一個流中,這使得流連線要比批處理連線更具挑戰性。為了更好地理解情況,讓我們先來區分三種不同型別的連線:**流-流**連線,**流-表**連線,與**表-表**連線【84】。我們將在下面的章節中透過例子來說明。
|
||||
然而,新事件隨時可能出現在一個流中,這使得流連線要比批處理連線更具挑戰性。為了更好地理解情況,讓我們先來區分三種不同型別的連線:**流 - 流** 連線,**流 - 表** 連線,與 **表 - 表** 連線【84】。我們將在下面的章節中透過例子來說明。
|
||||
|
||||
#### 流流連線(視窗連線)
|
||||
|
||||
假設你的網站上有搜尋功能,而你想要找出搜尋URL的近期趨勢。每當有人鍵入搜尋查詢時,都會記錄下一個包含查詢與其返回結果的事件。每當有人點選其中一個搜尋結果時,就會記錄另一個記錄點選事件。為了計算搜尋結果中每個URL的點選率,你需要將搜尋動作與點選動作的事件連在一起,這些事件透過相同的會話ID進行連線。廣告系統中需要類似的分析【85】。
|
||||
假設你的網站上有搜尋功能,而你想要找出搜尋 URL 的近期趨勢。每當有人鍵入搜尋查詢時,都會記錄下一個包含查詢與其返回結果的事件。每當有人點選其中一個搜尋結果時,就會記錄另一個記錄點選事件。為了計算搜尋結果中每個 URL 的點選率,你需要將搜尋動作與點選動作的事件連在一起,這些事件透過相同的會話 ID 進行連線。廣告系統中需要類似的分析【85】。
|
||||
|
||||
如果使用者丟棄了搜尋結果,點選可能永遠不會發生,即使它出現了,搜尋與點選之間的時間可能是高度可變的:在很多情況下,它可能是幾秒鐘,但也可能長達幾天或幾周(如果使用者執行搜尋,忘掉了這個瀏覽器頁面,過了一段時間後重新回到這個瀏覽器頁面上,並點選了一個結果)。由於可變的網路延遲,點選事件甚至可能先於搜尋事件到達。你可以選擇合適的連線視窗 —— 例如,如果點選與搜尋之間的時間間隔在一小時內,你可能會選擇連線兩者。
|
||||
|
||||
請注意,在點選事件中嵌入搜尋詳情與事件連線並不一樣:這樣做的話,只有當用戶點選了一個搜尋結果時你才能知道,而那些沒有點選的搜尋就無能為力了。為了衡量搜尋質量,你需要準確的點選率,為此搜尋事件和點選事件兩者都是必要的。
|
||||
|
||||
為了實現這種型別的連線,流處理器需要維護**狀態**:例如,按會話ID索引最近一小時內發生的所有事件。無論何時發生搜尋事件或點選事件,都會被新增到合適的索引中,而流處理器也會檢查另一個索引是否有具有相同會話ID的事件到達。如果有匹配事件就會發出一個表示搜尋結果被點選的事件;如果搜尋事件直到過期都沒看見有匹配的點選事件,就會發出一個表示搜尋結果未被點選的事件。
|
||||
為了實現這種型別的連線,流處理器需要維護 **狀態**:例如,按會話 ID 索引最近一小時內發生的所有事件。無論何時發生搜尋事件或點選事件,都會被新增到合適的索引中,而流處理器也會檢查另一個索引是否有具有相同會話 ID 的事件到達。如果有匹配事件就會發出一個表示搜尋結果被點選的事件;如果搜尋事件直到過期都沒看見有匹配的點選事件,就會發出一個表示搜尋結果未被點選的事件。
|
||||
|
||||
#### 流表連線(流擴充)
|
||||
|
||||
在“[示例:使用者活動事件分析](ch10.md#示例:使用者活動事件分析)”([圖10-2](../img/fig10-2.png))中,我們看到了連線兩個資料集的批處理作業示例:一組使用者活動事件和一個使用者檔案資料庫。將使用者活動事件視為流,並在流處理器中連續執行相同的連線是很自然的想法:輸入是包含使用者ID的活動事件流,而輸出還是活動事件流,但其中使用者ID已經被擴充套件為使用者的檔案資訊。這個過程有時被稱為使用資料庫的資訊來**擴充(enriching)** 活動事件。
|
||||
在 “[示例:使用者活動事件分析](ch10.md#示例:使用者活動事件分析)”([圖 10-2](../img/fig10-2.png))中,我們看到了連線兩個資料集的批處理作業示例:一組使用者活動事件和一個使用者檔案資料庫。將使用者活動事件視為流,並在流處理器中連續執行相同的連線是很自然的想法:輸入是包含使用者 ID 的活動事件流,而輸出還是活動事件流,但其中使用者 ID 已經被擴充套件為使用者的檔案資訊。這個過程有時被稱為使用資料庫的資訊來 **擴充(enriching)** 活動事件。
|
||||
|
||||
要執行此連線,流處理器需要一次處理一個活動事件,在資料庫中查詢事件的使用者ID,並將檔案資訊新增到活動事件中。資料庫查詢可以透過查詢遠端資料庫來實現。但正如在“[示例:使用者活動事件分析](ch10.md#示例:使用者活動事件分析)”一節中討論的,此類遠端查詢可能會很慢,並且有可能導致資料庫過載【75】。
|
||||
要執行此連線,流處理器需要一次處理一個活動事件,在資料庫中查詢事件的使用者 ID,並將檔案資訊新增到活動事件中。資料庫查詢可以透過查詢遠端資料庫來實現。但正如在 “[示例:使用者活動事件分析](ch10.md#示例:使用者活動事件分析)” 一節中討論的,此類遠端查詢可能會很慢,並且有可能導致資料庫過載【75】。
|
||||
|
||||
另一種方法是將資料庫副本載入到流處理器中,以便在本地進行查詢而無需網路往返。這種技術與我們在“[Map側連線](ch10.md#Map側連線)”中討論的雜湊連線非常相似:如果資料庫的本地副本足夠小,則可以是記憶體中的散列表,比較大的話也可以是本地磁碟上的索引。
|
||||
另一種方法是將資料庫副本載入到流處理器中,以便在本地進行查詢而無需網路往返。這種技術與我們在 “[Map 側連線](ch10.md#Map側連線)” 中討論的雜湊連線非常相似:如果資料庫的本地副本足夠小,則可以是記憶體中的散列表,比較大的話也可以是本地磁碟上的索引。
|
||||
|
||||
與批處理作業的區別在於,批處理作業使用資料庫的時間點快照作為輸入,而流處理器是長時間執行的,且資料庫的內容可能隨時間而改變,所以流處理器資料庫的本地副本需要保持更新。這個問題可以透過變更資料捕獲來解決:流處理器可以訂閱使用者檔案資料庫的更新日誌,如同活動事件流一樣。當增添或修改檔案時,流處理器會更新其本地副本。因此,我們有了兩個流之間的連線:活動事件和檔案更新。
|
||||
|
||||
流表連線實際上非常類似於流流連線;最大的區別在於對於表的變更日誌流,連線使用了一個可以回溯到“時間起點”的視窗(概念上是無限的視窗),新版本的記錄會覆蓋更早的版本。對於輸入的流,連線可能壓根兒就沒有維護任何視窗。
|
||||
流表連線實際上非常類似於流流連線;最大的區別在於對於表的變更日誌流,連線使用了一個可以回溯到 “時間起點” 的視窗(概念上是無限的視窗),新版本的記錄會覆蓋更早的版本。對於輸入的流,連線可能壓根兒就沒有維護任何視窗。
|
||||
|
||||
#### 表表連線(維護物化檢視)
|
||||
|
||||
我們在“[描述負載](ch1.md#描述負載)”中討論的推特時間線例子時說過,當用戶想要檢視他們的主頁時間線時,迭代使用者所關注人群的推文併合並它們是一個開銷巨大的操作。
|
||||
我們在 “[描述負載](ch1.md#描述負載)” 中討論的推特時間線例子時說過,當用戶想要檢視他們的主頁時間線時,迭代使用者所關注人群的推文併合並它們是一個開銷巨大的操作。
|
||||
|
||||
相反,我們需要一個時間線快取:一種每個使用者的“收件箱”,在傳送推文的時候寫入這些資訊,因而讀取時間線時只需要簡單地查詢即可。物化與維護這個快取需要處理以下事件:
|
||||
相反,我們需要一個時間線快取:一種每個使用者的 “收件箱”,在傳送推文的時候寫入這些資訊,因而讀取時間線時只需要簡單地查詢即可。物化與維護這個快取需要處理以下事件:
|
||||
|
||||
* 當用戶u傳送新的推文時,它將被新增到每個關注使用者u的時間線上。
|
||||
* 當用戶 u 傳送新的推文時,它將被新增到每個關注使用者 u 的時間線上。
|
||||
* 使用者刪除推文時,推文將從所有使用者的時間表中刪除。
|
||||
* 當用戶$u_1$開始關注使用者$u_2$時,$u_2$最近的推文將被新增到$u_1$的時間線上。
|
||||
* 當用戶$u_1$取消關注使用者$u_2$時,$u_2$的推文將從$u_1$的時間線中移除。
|
||||
* 當用戶 $u_1$ 開始關注使用者 $u_2$ 時,$u_2$ 最近的推文將被新增到 $u_1$ 的時間線上。
|
||||
* 當用戶 $u_1$ 取消關注使用者 $u_2$ 時,$u_2$ 的推文將從 $u_1$ 的時間線中移除。
|
||||
|
||||
要在流處理器中實現這種快取維護,你需要推文事件流(傳送與刪除)和關注關係事件流(關注與取消關注)。流處理需要維護一個數據庫,包含每個使用者的粉絲集合。以便知道當一條新推文到達時,需要更新哪些時間線【86】。
|
||||
|
||||
@ -609,9 +609,9 @@ JOIN follows ON follows.followee_id = tweets.sender_id
|
||||
GROUP BY follows.follower_id
|
||||
```
|
||||
|
||||
流連線直接對應於這個查詢中的表連線。時間線實際上是這個查詢結果的快取,每當底層的表發生變化時都會更新[^iii]。
|
||||
流連線直接對應於這個查詢中的表連線。時間線實際上是這個查詢結果的快取,每當底層的表發生變化時都會更新 [^iii]。
|
||||
|
||||
[^iii]: 如果你將流視作表的衍生物,如[圖11-6](../img/fig11-6.png)所示,而把一個連線看作是兩個表的乘法u·v,那麼會發生一些有趣的事情:物化連線的變化流遵循乘積法則:(u·v)'= u'v + uv'。 換句話說,任何推文的變化量都與當前的關注聯絡在一起,任何關注的變化量都與當前的推文相連線【49,50】。
|
||||
[^iii]: 如果你將流視作表的衍生物,如 [圖 11-6](../img/fig11-6.png) 所示,而把一個連線看作是兩個表的乘法u·v,那麼會發生一些有趣的事情:物化連線的變化流遵循乘積法則:(u·v)'= u'v + uv'。 換句話說,任何推文的變化量都與當前的關注聯絡在一起,任何關注的變化量都與當前的推文相連線【49,50】。
|
||||
|
||||
#### 連線的時間依賴性
|
||||
|
||||
@ -621,80 +621,80 @@ GROUP BY follows.follower_id
|
||||
|
||||
這就產生了一個問題:如果不同流中的事件發生在近似的時間範圍內,則應該按照什麼樣的順序進行處理?在流表連線的例子中,如果使用者更新了它們的檔案,哪些活動事件與舊檔案連線(在檔案更新前處理),哪些又與新檔案連線(在檔案更新之後處理)?換句話說:你需要對一些狀態做連線,如果狀態會隨著時間推移而變化,那應當使用什麼時間點來連線呢【45】?
|
||||
|
||||
這種時序依賴可能出現在很多地方。例如銷售東西需要對發票應用適當的稅率,這取決於所處的國家/州,產品型別,銷售日期(因為稅率時不時會變化)。當連線銷售額與稅率表時,你可能期望的是使用銷售時的稅率參與連線。如果你正在重新處理歷史資料,銷售時的稅率可能和現在的稅率有所不同。
|
||||
這種時序依賴可能出現在很多地方。例如銷售東西需要對發票應用適當的稅率,這取決於所處的國家 / 州,產品型別,銷售日期(因為稅率時不時會變化)。當連線銷售額與稅率表時,你可能期望的是使用銷售時的稅率參與連線。如果你正在重新處理歷史資料,銷售時的稅率可能和現在的稅率有所不同。
|
||||
|
||||
如果跨越流的事件順序是未定的,則連線會變為不確定性的【87】,這意味著你在同樣輸入上重跑相同的作業未必會得到相同的結果:當你重跑任務時,輸入流上的事件可能會以不同的方式交織。
|
||||
|
||||
在資料倉庫中,這個問題被稱為**緩慢變化的維度(slowly changing dimension, SCD)**,通常透過對特定版本的記錄使用唯一的識別符號來解決:例如,每當稅率改變時都會獲得一個新的識別符號,而發票在銷售時會帶有稅率的識別符號【88,89】。這種變化使連線變為確定性的,但也會導致日誌壓縮無法進行:表中所有的記錄版本都需要保留。
|
||||
在資料倉庫中,這個問題被稱為 **緩慢變化的維度(slowly changing dimension, SCD)**,通常透過對特定版本的記錄使用唯一的識別符號來解決:例如,每當稅率改變時都會獲得一個新的識別符號,而發票在銷售時會帶有稅率的識別符號【88,89】。這種變化使連線變為確定性的,但也會導致日誌壓縮無法進行:表中所有的記錄版本都需要保留。
|
||||
|
||||
### 容錯
|
||||
|
||||
在本章的最後一節中,讓我們看一看流處理是如何容錯的。我們在[第十章](ch10.md)中看到,批處理框架可以很容易地容錯:如果MapReduce作業中的任務失敗,可以簡單地在另一臺機器上再次啟動,並且丟棄失敗任務的輸出。這種透明的重試是可能的,因為輸入檔案是不可變的,每個任務都將其輸出寫入到HDFS上的獨立檔案中,而輸出僅當任務成功完成後可見。
|
||||
在本章的最後一節中,讓我們看一看流處理是如何容錯的。我們在 [第十章](ch10.md) 中看到,批處理框架可以很容易地容錯:如果 MapReduce 作業中的任務失敗,可以簡單地在另一臺機器上再次啟動,並且丟棄失敗任務的輸出。這種透明的重試是可能的,因為輸入檔案是不可變的,每個任務都將其輸出寫入到 HDFS 上的獨立檔案中,而輸出僅當任務成功完成後可見。
|
||||
|
||||
特別是,批處理容錯方法可確保批處理作業的輸出與沒有出錯的情況相同,即使實際上某些任務失敗了。看起來好像每條輸入記錄都被處理了恰好一次 —— 沒有記錄被跳過,而且沒有記錄被處理兩次。儘管重啟任務意味著實際上可能會多次處理記錄,但輸出中的可見效果看上去就像只處理過一次。這個原則被稱為**恰好一次語義(exactly-once semantics)**,儘管**等效一次(effectively-once)** 可能會是一個更寫實的術語【90】。
|
||||
特別是,批處理容錯方法可確保批處理作業的輸出與沒有出錯的情況相同,即使實際上某些任務失敗了。看起來好像每條輸入記錄都被處理了恰好一次 —— 沒有記錄被跳過,而且沒有記錄被處理兩次。儘管重啟任務意味著實際上可能會多次處理記錄,但輸出中的可見效果看上去就像只處理過一次。這個原則被稱為 **恰好一次語義(exactly-once semantics)**,儘管 **等效一次(effectively-once)** 可能會是一個更寫實的術語【90】。
|
||||
|
||||
在流處理中也出現了同樣的容錯問題,但是處理起來沒有那麼直觀:等待某個任務完成之後再使其輸出可見並不是一個可行選項,因為你永遠無法處理完一個無限的流。
|
||||
|
||||
#### 微批次與存檔點
|
||||
|
||||
一個解決方案是將流分解成小塊,並像微型批處理一樣處理每個塊。這種方法被稱為**微批次(microbatching)**,它被用於Spark Streaming 【91】。批次的大小通常約為1秒,這是對效能妥協的結果:較小的批次會導致更大的排程與協調開銷,而較大的批次意味著流處理器結果可見之前的延遲要更長。
|
||||
一個解決方案是將流分解成小塊,並像微型批處理一樣處理每個塊。這種方法被稱為 **微批次(microbatching)**,它被用於 Spark Streaming 【91】。批次的大小通常約為 1 秒,這是對效能妥協的結果:較小的批次會導致更大的排程與協調開銷,而較大的批次意味著流處理器結果可見之前的延遲要更長。
|
||||
|
||||
微批次也隱式提供了一個與批次大小相等的滾動視窗(按處理時間而不是事件時間戳分窗)。任何需要更大視窗的作業都需要顯式地將狀態從一個微批次轉移到下一個微批次。
|
||||
|
||||
Apache Flink則使用不同的方法,它會定期生成狀態的滾動存檔點並將其寫入持久儲存【92,93】。如果流運算元崩潰,它可以從最近的存檔點重啟,並丟棄從最近檢查點到崩潰之間的所有輸出。存檔點會由訊息流中的**壁障(barrier)** 觸發,類似於微批次之間的邊界,但不會強制一個特定的視窗大小。
|
||||
Apache Flink 則使用不同的方法,它會定期生成狀態的滾動存檔點並將其寫入持久儲存【92,93】。如果流運算元崩潰,它可以從最近的存檔點重啟,並丟棄從最近檢查點到崩潰之間的所有輸出。存檔點會由訊息流中的 **壁障(barrier)** 觸發,類似於微批次之間的邊界,但不會強制一個特定的視窗大小。
|
||||
|
||||
在流處理框架的範圍內,微批次與存檔點方法提供了與批處理一樣的**恰好一次語義**。但是,只要輸出離開流處理器(例如,寫入資料庫,向外部訊息代理傳送訊息,或傳送電子郵件),框架就無法拋棄失敗批次的輸出了。在這種情況下,重啟失敗任務會導致外部副作用發生兩次,只有微批次或存檔點不足以阻止這一問題。
|
||||
在流處理框架的範圍內,微批次與存檔點方法提供了與批處理一樣的 **恰好一次語義**。但是,只要輸出離開流處理器(例如,寫入資料庫,向外部訊息代理傳送訊息,或傳送電子郵件),框架就無法拋棄失敗批次的輸出了。在這種情況下,重啟失敗任務會導致外部副作用發生兩次,只有微批次或存檔點不足以阻止這一問題。
|
||||
|
||||
#### 原子提交再現
|
||||
|
||||
為了在出現故障時表現出恰好處理一次的樣子,我們需要確保事件處理的所有輸出和副作用**當且僅當**處理成功時才會生效。這些影響包括傳送給下游運算元或外部訊息傳遞系統(包括電子郵件或推送通知)的任何訊息,任何資料庫寫入,對運算元狀態的任何變更,以及對輸入訊息的任何確認(包括在基於日誌的訊息代理中將消費者偏移量前移)。
|
||||
為了在出現故障時表現出恰好處理一次的樣子,我們需要確保事件處理的所有輸出和副作用 **當且僅當** 處理成功時才會生效。這些影響包括傳送給下游運算元或外部訊息傳遞系統(包括電子郵件或推送通知)的任何訊息,任何資料庫寫入,對運算元狀態的任何變更,以及對輸入訊息的任何確認(包括在基於日誌的訊息代理中將消費者偏移量前移)。
|
||||
|
||||
這些事情要麼都原子地發生,要麼都不發生,但是它們不應當失去同步。如果這種方法聽起來很熟悉,那是因為我們在分散式事務和兩階段提交的上下文中討論過它(請參閱“[恰好一次的訊息處理](ch9.md#恰好一次的訊息處理)”)。
|
||||
這些事情要麼都原子地發生,要麼都不發生,但是它們不應當失去同步。如果這種方法聽起來很熟悉,那是因為我們在分散式事務和兩階段提交的上下文中討論過它(請參閱 “[恰好一次的訊息處理](ch9.md#恰好一次的訊息處理)”)。
|
||||
|
||||
在[第九章](ch9.md)中,我們討論了分散式事務傳統實現中的問題(如XA)。然而在限制更為嚴苛的環境中,也是有可能高效實現這種原子提交機制的。 Google Cloud Dataflow【81,92】和VoltDB 【94】中使用了這種方法,Apache Kafka有計劃加入類似的功能【95,96】。與XA不同,這些實現不會嘗試跨異構技術提供事務,而是透過在流處理框架中同時管理狀態變更與訊息傳遞來內化事務。事務協議的開銷可以透過在單個事務中處理多個輸入訊息來分攤。
|
||||
在 [第九章](ch9.md) 中,我們討論了分散式事務傳統實現中的問題(如 XA)。然而在限制更為嚴苛的環境中,也是有可能高效實現這種原子提交機制的。 Google Cloud Dataflow【81,92】和 VoltDB 【94】中使用了這種方法,Apache Kafka 有計劃加入類似的功能【95,96】。與 XA 不同,這些實現不會嘗試跨異構技術提供事務,而是透過在流處理框架中同時管理狀態變更與訊息傳遞來內化事務。事務協議的開銷可以透過在單個事務中處理多個輸入訊息來分攤。
|
||||
|
||||
#### 冪等性
|
||||
|
||||
我們的目標是丟棄任何失敗任務的部分輸出,以便能安全地重試,而不會生效兩次。分散式事務是實現這個目標的一種方式,而另一種方式是依賴**冪等性(idempotence)**【97】。
|
||||
我們的目標是丟棄任何失敗任務的部分輸出,以便能安全地重試,而不會生效兩次。分散式事務是實現這個目標的一種方式,而另一種方式是依賴 **冪等性(idempotence)**【97】。
|
||||
|
||||
冪等操作是多次重複執行與單次執行效果相同的操作。例如,將鍵值儲存中的某個鍵設定為某個特定值是冪等的(再次寫入該值,只是用同樣的值替代),而遞增一個計數器不是冪等的(再次執行遞增意味著該值遞增兩次)。
|
||||
|
||||
即使一個操作不是天生冪等的,往往可以透過一些額外的元資料做成冪等的。例如,在使用來自Kafka的訊息時,每條訊息都有一個持久的、單調遞增的偏移量。將值寫入外部資料庫時可以將這個偏移量帶上,這樣你就可以判斷一條更新是不是已經執行過了,因而避免重複執行。
|
||||
即使一個操作不是天生冪等的,往往可以透過一些額外的元資料做成冪等的。例如,在使用來自 Kafka 的訊息時,每條訊息都有一個持久的、單調遞增的偏移量。將值寫入外部資料庫時可以將這個偏移量帶上,這樣你就可以判斷一條更新是不是已經執行過了,因而避免重複執行。
|
||||
|
||||
Storm的Trident基於類似的想法來處理狀態【78】。依賴冪等性意味著隱含了一些假設:重啟一個失敗的任務必須以相同的順序重播相同的訊息(基於日誌的訊息代理能做這些事),處理必須是確定性的,沒有其他節點能同時更新相同的值【98,99】。
|
||||
Storm 的 Trident 基於類似的想法來處理狀態【78】。依賴冪等性意味著隱含了一些假設:重啟一個失敗的任務必須以相同的順序重播相同的訊息(基於日誌的訊息代理能做這些事),處理必須是確定性的,沒有其他節點能同時更新相同的值【98,99】。
|
||||
|
||||
當從一個處理節點故障切換到另一個節點時,可能需要進行**防護**(fencing,請參閱“[領導者和鎖](ch8.md#領導者和鎖)”),以防止被假死節點干擾。儘管有這麼多注意事項,冪等操作是一種實現**恰好一次語義**的有效方式,僅需很小的額外開銷。
|
||||
當從一個處理節點故障切換到另一個節點時,可能需要進行 **防護**(fencing,請參閱 “[領導者和鎖](ch8.md#領導者和鎖)”),以防止被假死節點干擾。儘管有這麼多注意事項,冪等操作是一種實現 **恰好一次語義** 的有效方式,僅需很小的額外開銷。
|
||||
|
||||
#### 失敗後重建狀態
|
||||
|
||||
任何需要狀態的流處理 —— 例如,任何視窗聚合(例如計數器,平均值和直方圖)以及任何用於連線的表和索引,都必須確保在失敗之後能恢復其狀態。
|
||||
|
||||
一種選擇是將狀態儲存在遠端資料儲存中,並進行復制,然而正如在“[流表連線(流擴充)](#流表連線(流擴充))”中所述,每個訊息都要查詢遠端資料庫可能會很慢。另一種方法是在流處理器本地儲存狀態,並定期複製。然後當流處理器從故障中恢復時,新任務可以讀取狀態副本,恢復處理而不丟失資料。
|
||||
一種選擇是將狀態儲存在遠端資料儲存中,並進行復制,然而正如在 “[流表連線(流擴充)](#流表連線(流擴充))” 中所述,每個訊息都要查詢遠端資料庫可能會很慢。另一種方法是在流處理器本地儲存狀態,並定期複製。然後當流處理器從故障中恢復時,新任務可以讀取狀態副本,恢復處理而不丟失資料。
|
||||
|
||||
例如,Flink定期捕獲運算元狀態的快照,並將它們寫入HDFS等持久儲存中【92,93】。 Samza和Kafka Streams透過將狀態變更傳送到具有日誌壓縮功能的專用Kafka主題來複制狀態變更,這與變更資料捕獲類似【84,100】。 VoltDB透過在多個節點上對每個輸入訊息進行冗餘處理來複制狀態(請參閱“[真的序列執行](ch7.md#真的序列執行)”)。
|
||||
例如,Flink 定期捕獲運算元狀態的快照,並將它們寫入 HDFS 等持久儲存中【92,93】。 Samza 和 Kafka Streams 透過將狀態變更傳送到具有日誌壓縮功能的專用 Kafka 主題來複制狀態變更,這與變更資料捕獲類似【84,100】。 VoltDB 透過在多個節點上對每個輸入訊息進行冗餘處理來複制狀態(請參閱 “[真的序列執行](ch7.md#真的序列執行)”)。
|
||||
|
||||
在某些情況下,甚至可能都不需要複製狀態,因為它可以從輸入流重建。例如,如果狀態是從相當短的視窗中聚合而成,則簡單地重播該視窗中的輸入事件可能是足夠快的。如果狀態是透過變更資料捕獲來維護的資料庫的本地副本,那麼也可以從日誌壓縮的變更流中重建資料庫(請參閱“[日誌壓縮](#日誌壓縮)”)。
|
||||
在某些情況下,甚至可能都不需要複製狀態,因為它可以從輸入流重建。例如,如果狀態是從相當短的視窗中聚合而成,則簡單地重播該視窗中的輸入事件可能是足夠快的。如果狀態是透過變更資料捕獲來維護的資料庫的本地副本,那麼也可以從日誌壓縮的變更流中重建資料庫(請參閱 “[日誌壓縮](#日誌壓縮)”)。
|
||||
|
||||
然而,所有這些權衡取決於底層基礎架構的效能特徵:在某些系統中,網路延遲可能低於磁碟訪問延遲,網路頻寬也可能與磁碟頻寬相當。沒有針對所有情況的普適理想權衡,隨著儲存和網路技術的發展,本地狀態與遠端狀態的優點也可能會互換。
|
||||
|
||||
|
||||
## 本章小結
|
||||
|
||||
在本章中,我們討論了事件流,它們所服務的目的,以及如何處理它們。在某些方面,流處理非常類似於在[第十章](ch10.md) 中討論的批處理,不過是在無限的(永無止境的)流而不是固定大小的輸入上持續進行。從這個角度來看,訊息代理和事件日誌可以視作檔案系統的流式等價物。
|
||||
在本章中,我們討論了事件流,它們所服務的目的,以及如何處理它們。在某些方面,流處理非常類似於在 [第十章](ch10.md) 中討論的批處理,不過是在無限的(永無止境的)流而不是固定大小的輸入上持續進行。從這個角度來看,訊息代理和事件日誌可以視作檔案系統的流式等價物。
|
||||
|
||||
我們花了一些時間比較兩種訊息代理:
|
||||
|
||||
* AMQP/JMS風格的訊息代理
|
||||
* AMQP/JMS 風格的訊息代理
|
||||
|
||||
代理將單條訊息分配給消費者,消費者在成功處理單條訊息後確認訊息。訊息被確認後從代理中刪除。這種方法適合作為一種非同步形式的RPC(另請參閱“[訊息傳遞中的資料流](ch4.md#訊息傳遞中的資料流)”),例如在任務佇列中,訊息處理的確切順序並不重要,而且訊息在處理完之後,不需要回頭重新讀取舊訊息。
|
||||
代理將單條訊息分配給消費者,消費者在成功處理單條訊息後確認訊息。訊息被確認後從代理中刪除。這種方法適合作為一種非同步形式的 RPC(另請參閱 “[訊息傳遞中的資料流](ch4.md#訊息傳遞中的資料流)”),例如在任務佇列中,訊息處理的確切順序並不重要,而且訊息在處理完之後,不需要回頭重新讀取舊訊息。
|
||||
|
||||
* 基於日誌的訊息代理
|
||||
|
||||
代理將一個分割槽中的所有訊息分配給同一個消費者節點,並始終以相同的順序傳遞訊息。並行是透過分割槽實現的,消費者透過存檔最近處理訊息的偏移量來跟蹤工作進度。訊息代理將訊息保留在磁碟上,因此如有必要的話,可以回跳並重新讀取舊訊息。
|
||||
|
||||
基於日誌的方法與資料庫中的複製日誌(請參閱[第五章](ch5.md))和日誌結構儲存引擎(請參閱[第三章](ch3.md))有相似之處。我們看到,這種方法對於消費輸入流,併產生衍生狀態或衍生輸出資料流的系統而言特別適用。
|
||||
基於日誌的方法與資料庫中的複製日誌(請參閱 [第五章](ch5.md))和日誌結構儲存引擎(請參閱 [第三章](ch3.md))有相似之處。我們看到,這種方法對於消費輸入流,併產生衍生狀態或衍生輸出資料流的系統而言特別適用。
|
||||
|
||||
就流的來源而言,我們討論了幾種可能性:使用者活動事件,定期讀數的感測器,和Feed資料(例如,金融中的市場資料)能夠自然地表示為流。我們發現將資料庫寫入視作流也是很有用的:我們可以捕獲變更日誌 —— 即對資料庫所做的所有變更的歷史記錄 —— 隱式地透過變更資料捕獲,或顯式地透過事件溯源。日誌壓縮允許流也能保有資料庫內容的完整副本。
|
||||
就流的來源而言,我們討論了幾種可能性:使用者活動事件,定期讀數的感測器,和 Feed 資料(例如,金融中的市場資料)能夠自然地表示為流。我們發現將資料庫寫入視作流也是很有用的:我們可以捕獲變更日誌 —— 即對資料庫所做的所有變更的歷史記錄 —— 隱式地透過變更資料捕獲,或顯式地透過事件溯源。日誌壓縮允許流也能保有資料庫內容的完整副本。
|
||||
|
||||
將資料庫表示為流為系統整合帶來了很多強大機遇。透過消費變更日誌並將其應用至衍生系統,你能使諸如搜尋索引、快取以及分析系統這類衍生資料系統不斷保持更新。你甚至能從頭開始,透過讀取從創世至今的所有變更日誌,為現有資料建立全新的檢視。
|
||||
|
||||
@ -706,7 +706,7 @@ Storm的Trident基於類似的想法來處理狀態【78】。依賴冪等性意
|
||||
|
||||
* 流流連線
|
||||
|
||||
兩個輸入流都由活動事件組成,而連線運算元在某個時間視窗內搜尋相關的事件。例如,它可能會將同一個使用者30分鐘內進行的兩個活動聯絡在一起。如果你想要找出一個流內的相關事件,連線的兩側輸入可能實際上都是同一個流(**自連線**,即self-join)。
|
||||
兩個輸入流都由活動事件組成,而連線運算元在某個時間視窗內搜尋相關的事件。例如,它可能會將同一個使用者 30 分鐘內進行的兩個活動聯絡在一起。如果你想要找出一個流內的相關事件,連線的兩側輸入可能實際上都是同一個流(**自連線**,即 self-join)。
|
||||
|
||||
* 流表連線
|
||||
|
||||
|
492
zh-tw/ch12.md
492
zh-tw/ch12.md
File diff suppressed because it is too large
Load Diff
386
zh-tw/ch8.md
386
zh-tw/ch8.md
@ -10,40 +10,40 @@
|
||||
>
|
||||
> 無食我數
|
||||
>
|
||||
> —— Kyle Kingsbury, Carly Rae Jepsen 《網路分割槽的危害》(2013年)[^譯著1]
|
||||
> —— Kyle Kingsbury, Carly Rae Jepsen 《網路分割槽的危害》(2013 年)[^譯著1]
|
||||
|
||||
---------
|
||||
|
||||
[TOC]
|
||||
|
||||
最近幾章中反覆出現的主題是,系統如何處理錯誤的事情。例如,我們討論了**副本故障切換**(“[處理節點中斷](ch5.md#處理節點宕機)”),**複製延遲**(“[複製延遲問題](ch5.md#複製延遲問題)”)和事務控制(“[弱隔離級別](ch7.md#弱隔離級別)”)。當我們瞭解可能在實際系統中出現的各種邊緣情況時,我們會更好地處理它們。
|
||||
最近幾章中反覆出現的主題是,系統如何處理錯誤的事情。例如,我們討論了 **副本故障切換**(“[處理節點中斷](ch5.md#處理節點宕機)”),**複製延遲**(“[複製延遲問題](ch5.md#複製延遲問題)”)和事務控制(“[弱隔離級別](ch7.md#弱隔離級別)”)。當我們瞭解可能在實際系統中出現的各種邊緣情況時,我們會更好地處理它們。
|
||||
|
||||
但是,儘管我們已經談了很多錯誤,但之前幾章仍然過於樂觀。現實更加黑暗。我們現在將悲觀主義最大化,假設任何可能出錯的東西**都會**出錯[^i]。(經驗豐富的系統運維會告訴你,這是一個合理的假設。如果你問得好,他們可能會一邊治療心理創傷一邊告訴你一些可怕的故事)
|
||||
但是,儘管我們已經談了很多錯誤,但之前幾章仍然過於樂觀。現實更加黑暗。我們現在將悲觀主義最大化,假設任何可能出錯的東西 **都會** 出錯 [^i]。(經驗豐富的系統運維會告訴你,這是一個合理的假設。如果你問得好,他們可能會一邊治療心理創傷一邊告訴你一些可怕的故事)
|
||||
|
||||
[^i]: 除了一個例外:我們將假定故障是非拜占庭式的(請參閱“[拜占庭故障](#拜占庭故障)”)。
|
||||
[^i]: 除了一個例外:我們將假定故障是非拜占庭式的(請參閱 “[拜占庭故障](#拜占庭故障)”)。
|
||||
|
||||
使用分散式系統與在一臺計算機上編寫軟體有著根本的區別,主要的區別在於,有許多新穎和刺激的方法可以使事情出錯【1,2】。在這一章中,我們將瞭解實踐中出現的問題,理解我們能夠依賴,和不可以依賴的東西。
|
||||
|
||||
最後,作為工程師,我們的任務是構建能夠完成工作的系統(即滿足使用者期望的保證),儘管一切都出錯了。 在[第九章](ch9.md)中,我們將看看一些可以在分散式系統中提供這種保證的演算法的例子。 但首先,在本章中,我們必須瞭解我們面臨的挑戰。
|
||||
最後,作為工程師,我們的任務是構建能夠完成工作的系統(即滿足使用者期望的保證),儘管一切都出錯了。 在 [第九章](ch9.md) 中,我們將看看一些可以在分散式系統中提供這種保證的演算法的例子。 但首先,在本章中,我們必須瞭解我們面臨的挑戰。
|
||||
|
||||
本章對分散式系統中可能出現的問題進行徹底的悲觀和沮喪的總結。 我們將研究網路的問題(“[不可靠的網路](#不可靠的網路)”); 時鐘和時序問題(“[不可靠的時鐘](#不可靠的時鐘)”); 我們將討論他們可以避免的程度。 所有這些問題的後果都是困惑的,所以我們將探索如何思考一個分散式系統的狀態,以及如何推理發生的事情(“[知識、真相與謊言](#知識、真相與謊言)”)。
|
||||
|
||||
|
||||
## 故障與部分失效
|
||||
|
||||
當你在一臺計算機上編寫一個程式時,它通常會以一種相當可預測的方式執行:無論是工作還是不工作。充滿錯誤的軟體可能會讓人覺得電腦有時候也會有“糟糕的一天”(這種問題通常是重新啟動就恢復了),但這主要是軟體寫得不好的結果。
|
||||
當你在一臺計算機上編寫一個程式時,它通常會以一種相當可預測的方式執行:無論是工作還是不工作。充滿錯誤的軟體可能會讓人覺得電腦有時候也會有 “糟糕的一天”(這種問題通常是重新啟動就恢復了),但這主要是軟體寫得不好的結果。
|
||||
|
||||
單個計算機上的軟體沒有根本性的不可靠原因:當硬體正常工作時,相同的操作總是產生相同的結果(這是確定性的)。如果存在硬體問題(例如,記憶體損壞或聯結器鬆動),其後果通常是整個系統故障(例如,核心恐慌,“藍色畫面宕機”,啟動失敗)。裝有良好軟體的個人計算機通常要麼功能完好,要麼完全失效,而不是介於兩者之間。
|
||||
|
||||
這是計算機設計中的一個有意的選擇:如果發生內部錯誤,我們寧願電腦完全崩潰,而不是返回錯誤的結果,因為錯誤的結果很難處理。因為計算機隱藏了模糊不清的物理實現,並呈現出一個理想化的系統模型,並以數學一樣的完美的方式運作。 CPU指令總是做同樣的事情;如果你將一些資料寫入記憶體或磁碟,那麼這些資料將保持不變,並且不會被隨機破壞。從第一臺數字計算機開始,*始終正確地計算*這個設計目標貫穿始終【3】。
|
||||
這是計算機設計中的一個有意的選擇:如果發生內部錯誤,我們寧願電腦完全崩潰,而不是返回錯誤的結果,因為錯誤的結果很難處理。因為計算機隱藏了模糊不清的物理實現,並呈現出一個理想化的系統模型,並以數學一樣的完美的方式運作。 CPU 指令總是做同樣的事情;如果你將一些資料寫入記憶體或磁碟,那麼這些資料將保持不變,並且不會被隨機破壞。從第一臺數字計算機開始,*始終正確地計算* 這個設計目標貫穿始終【3】。
|
||||
|
||||
當你編寫執行在多臺計算機上的軟體時,情況有本質上的區別。在分散式系統中,我們不再處於理想化的系統模型中,我們別無選擇,只能面對現實世界的混亂現實。而在現實世界中,各種各樣的事情都可能會出現問題【4】,如下面的軼事所述:
|
||||
|
||||
> 在我有限的經驗中,我已經和很多東西打過交道:單個**資料中心(DC)** 中長期存在的網路分割槽,配電單元PDU故障,交換機故障,整個機架的意外重啟,整個資料中心主幹網路故障,整個資料中心的電源故障,以及一個低血糖的司機把他的福特皮卡撞在資料中心的HVAC(加熱,通風和空調)系統上。而且我甚至不是一個運維。
|
||||
> 在我有限的經驗中,我已經和很多東西打過交道:單個 **資料中心(DC)** 中長期存在的網路分割槽,配電單元 PDU 故障,交換機故障,整個機架的意外重啟,整個資料中心主幹網路故障,整個資料中心的電源故障,以及一個低血糖的司機把他的福特皮卡撞在資料中心的 HVAC(加熱,通風和空調)系統上。而且我甚至不是一個運維。
|
||||
>
|
||||
> —— 柯達黑爾
|
||||
|
||||
在分散式系統中,儘管系統的其他部分工作正常,但系統的某些部分可能會以某種不可預知的方式被破壞。這被稱為**部分失效(partial failure)**。難點在於部分失效是**不確定性的(nonderterministic)**:如果你試圖做任何涉及多個節點和網路的事情,它有時可能會工作,有時會出現不可預知的失敗。正如我們將要看到的,你甚至不知道是否成功了,因為訊息透過網路傳播的時間也是不確定的!
|
||||
在分散式系統中,儘管系統的其他部分工作正常,但系統的某些部分可能會以某種不可預知的方式被破壞。這被稱為 **部分失效(partial failure)**。難點在於部分失效是 **不確定性的(nonderterministic)**:如果你試圖做任何涉及多個節點和網路的事情,它有時可能會工作,有時會出現不可預知的失敗。正如我們將要看到的,你甚至不知道是否成功了,因為訊息透過網路傳播的時間也是不確定的!
|
||||
|
||||
這種不確定性和部分失效的可能性,使得分散式系統難以工作【5】。
|
||||
|
||||
@ -51,27 +51,27 @@
|
||||
|
||||
關於如何構建大型計算系統有一系列的哲學:
|
||||
|
||||
* 一個極端是高效能運算(HPC)領域。具有數千個CPU的超級計算機通常用於計算密集型科學計算任務,如天氣預報或分子動力學(模擬原子和分子的運動)。
|
||||
* 另一個極端是**雲端計算(cloud computing)**,雲端計算並不是一個良好定義的概念【6】,但通常與多租戶資料中心,連線IP網路(通常是乙太網)的商用計算機,彈性/按需資源分配以及計量計費等相關聯。
|
||||
* 一個極端是高效能運算(HPC)領域。具有數千個 CPU 的超級計算機通常用於計算密集型科學計算任務,如天氣預報或分子動力學(模擬原子和分子的運動)。
|
||||
* 另一個極端是 **雲端計算(cloud computing)**,雲端計算並不是一個良好定義的概念【6】,但通常與多租戶資料中心,連線 IP 網路(通常是乙太網)的商用計算機,彈性 / 按需資源分配以及計量計費等相關聯。
|
||||
* 傳統企業資料中心位於這兩個極端之間。
|
||||
|
||||
不同的哲學會導致不同的故障處理方式。在超級計算機中,作業通常會不時地會將計算的狀態存檔到持久儲存中。如果一個節點出現故障,通常的解決方案是簡單地停止整個叢集的工作負載。故障節點修復後,計算從上一個檢查點重新開始【7,8】。因此,超級計算機更像是一個單節點計算機而不是分散式系統:透過讓部分失敗升級為完全失敗來處理部分失敗——如果系統的任何部分發生故障,只是讓所有的東西都崩潰(就像單臺機器上的核心恐慌一樣)。
|
||||
不同的哲學會導致不同的故障處理方式。在超級計算機中,作業通常會不時地會將計算的狀態存檔到持久儲存中。如果一個節點出現故障,通常的解決方案是簡單地停止整個叢集的工作負載。故障節點修復後,計算從上一個檢查點重新開始【7,8】。因此,超級計算機更像是一個單節點計算機而不是分散式系統:透過讓部分失敗升級為完全失敗來處理部分失敗 —— 如果系統的任何部分發生故障,只是讓所有的東西都崩潰(就像單臺機器上的核心恐慌一樣)。
|
||||
|
||||
在本書中,我們將重點放在實現網際網路服務的系統上,這些系統通常與超級計算機看起來有很大不同:
|
||||
|
||||
* 許多與網際網路有關的應用程式都是**線上(online)** 的,因為它們需要能夠隨時以低延遲服務使用者。使服務不可用(例如,停止叢集以進行修復)是不可接受的。相比之下,像天氣模擬這樣的離線(批處理)工作可以停止並重新啟動,影響相當小。
|
||||
* 許多與網際網路有關的應用程式都是 **線上(online)** 的,因為它們需要能夠隨時以低延遲服務使用者。使服務不可用(例如,停止叢集以進行修復)是不可接受的。相比之下,像天氣模擬這樣的離線(批處理)工作可以停止並重新啟動,影響相當小。
|
||||
|
||||
* 超級計算機通常由專用硬體構建而成,每個節點相當可靠,節點透過共享記憶體和**遠端直接記憶體訪問(RDMA)** 進行通訊。另一方面,雲服務中的節點是由商用機器構建而成的,由於規模經濟,可以以較低的成本提供相同的效能,而且具有較高的故障率。
|
||||
* 超級計算機通常由專用硬體構建而成,每個節點相當可靠,節點透過共享記憶體和 **遠端直接記憶體訪問(RDMA)** 進行通訊。另一方面,雲服務中的節點是由商用機器構建而成的,由於規模經濟,可以以較低的成本提供相同的效能,而且具有較高的故障率。
|
||||
|
||||
* 大型資料中心網路通常基於IP和乙太網,以CLOS拓撲排列,以提供更高的對分(bisection)頻寬【9】。超級計算機通常使用專門的網路拓撲結構,例如多維網格和Torus網路 【10】,這為具有已知通訊模式的HPC工作負載提供了更好的效能。
|
||||
* 大型資料中心網路通常基於 IP 和乙太網,以 CLOS 拓撲排列,以提供更高的對分(bisection)頻寬【9】。超級計算機通常使用專門的網路拓撲結構,例如多維網格和 Torus 網路 【10】,這為具有已知通訊模式的 HPC 工作負載提供了更好的效能。
|
||||
|
||||
* 系統越大,其元件之一就越有可能壞掉。隨著時間的推移,壞掉的東西得到修復,新的東西又壞掉,但是在一個有成千上萬個節點的系統中,有理由認為總是有一些東西是壞掉的【7】。當錯誤處理的策略只由簡單放棄組成時,一個大的系統最終會花費大量時間從錯誤中恢復,而不是做有用的工作【8】。
|
||||
|
||||
* 如果系統可以容忍發生故障的節點,並繼續保持整體工作狀態,那麼這對於運營和維護非常有用:例如,可以執行滾動升級(請參閱[第四章](ch4.md)),一次重新啟動一個節點,同時繼續給使用者提供不中斷的服務。在雲環境中,如果一臺虛擬機器執行不佳,可以殺死它並請求一臺新的虛擬機器(希望新的虛擬機器速度更快)。
|
||||
* 如果系統可以容忍發生故障的節點,並繼續保持整體工作狀態,那麼這對於運營和維護非常有用:例如,可以執行滾動升級(請參閱 [第四章](ch4.md)),一次重新啟動一個節點,同時繼續給使用者提供不中斷的服務。在雲環境中,如果一臺虛擬機器執行不佳,可以殺死它並請求一臺新的虛擬機器(希望新的虛擬機器速度更快)。
|
||||
|
||||
* 在地理位置分散的部署中(保持資料在地理位置上接近使用者以減少訪問延遲),通訊很可能透過網際網路進行,與本地網路相比,通訊速度緩慢且不可靠。超級計算機通常假設它們的所有節點都靠近在一起。
|
||||
|
||||
如果要使分散式系統工作,就必須接受部分故障的可能性,並在軟體中建立容錯機制。換句話說,我們需要從不可靠的元件構建一個可靠的系統(正如“[可靠性](ch1.md#可靠性)”中所討論的那樣,沒有完美的可靠性,所以我們需要理解我們可以實際承諾的極限)。
|
||||
如果要使分散式系統工作,就必須接受部分故障的可能性,並在軟體中建立容錯機制。換句話說,我們需要從不可靠的元件構建一個可靠的系統(正如 “[可靠性](ch1.md#可靠性)” 中所討論的那樣,沒有完美的可靠性,所以我們需要理解我們可以實際承諾的極限)。
|
||||
|
||||
即使在只有少數節點的小型系統中,考慮部分故障也是很重要的。在一個小系統中,很可能大部分元件在大部分時間都正常工作。然而,遲早會有一部分系統出現故障,軟體必須以某種方式處理。故障處理必須是軟體設計的一部分,並且作為軟體的運維,你需要知道在發生故障的情況下,軟體可能會表現出怎樣的行為。
|
||||
|
||||
@ -79,74 +79,74 @@
|
||||
|
||||
> #### 從不可靠的元件構建可靠的系統
|
||||
>
|
||||
> 你可能想知道這是否有意義——直觀地看來,系統只能像其最不可靠的元件(最薄弱的環節)一樣可靠。事實並非如此:事實上,從不太可靠的潛在基礎構建更可靠的系統是計算機領域的一個古老思想【11】。例如:
|
||||
> 你可能想知道這是否有意義 —— 直觀地看來,系統只能像其最不可靠的元件(最薄弱的環節)一樣可靠。事實並非如此:事實上,從不太可靠的潛在基礎構建更可靠的系統是計算機領域的一個古老思想【11】。例如:
|
||||
>
|
||||
> * 糾錯碼允許數字資料在通訊通道上準確傳輸,偶爾會出現一些錯誤,例如由於無線網路上的無線電干擾【12】。
|
||||
> * **網際網路協議(Internet Protocol, IP)** 不可靠:可能丟棄、延遲、重複或重排資料包。 傳輸控制協議(Transmission Control Protocol, TCP)在網際網路協議(IP)之上提供了更可靠的傳輸層:它確保丟失的資料包被重新傳輸,消除重複,並且資料包被重新組裝成它們被傳送的順序。
|
||||
>
|
||||
> 雖然這個系統可以比它的底層部分更可靠,但它的可靠性總是有限的。例如,糾錯碼可以處理少量的單位元錯誤,但是如果你的訊號被幹擾所淹沒,那麼透過通道可以得到多少資料,是有根本性的限制的【13】。 TCP可以隱藏資料包的丟失,重複和重新排序,但是它不能神奇地消除網路中的延遲。
|
||||
> 雖然這個系統可以比它的底層部分更可靠,但它的可靠性總是有限的。例如,糾錯碼可以處理少量的單位元錯誤,但是如果你的訊號被幹擾所淹沒,那麼透過通道可以得到多少資料,是有根本性的限制的【13】。 TCP 可以隱藏資料包的丟失,重複和重新排序,但是它不能神奇地消除網路中的延遲。
|
||||
>
|
||||
> 雖然更可靠的高階系統並不完美,但它仍然有用,因為它處理了一些棘手的低階錯誤,所以其餘的錯誤通常更容易推理和處理。我們將在“[資料庫的端到端原則](ch12.md#資料庫的端到端原則)”中進一步探討這個問題。
|
||||
> 雖然更可靠的高階系統並不完美,但它仍然有用,因為它處理了一些棘手的低階錯誤,所以其餘的錯誤通常更容易推理和處理。我們將在 “[資料庫的端到端原則](ch12.md#資料庫的端到端原則)” 中進一步探討這個問題。
|
||||
|
||||
|
||||
## 不可靠的網路
|
||||
|
||||
正如在[第二部分](part-ii.md)的介紹中所討論的那樣,我們在本書中關注的分散式系統是無共享的系統,即透過網路連線的一堆機器。網路是這些機器可以通訊的唯一途徑——我們假設每臺機器都有自己的記憶體和磁碟,一臺機器不能訪問另一臺機器的記憶體或磁碟(除了透過網路向伺服器發出請求)。
|
||||
正如在 [第二部分](part-ii.md) 的介紹中所討論的那樣,我們在本書中關注的分散式系統是無共享的系統,即透過網路連線的一堆機器。網路是這些機器可以通訊的唯一途徑 —— 我們假設每臺機器都有自己的記憶體和磁碟,一臺機器不能訪問另一臺機器的記憶體或磁碟(除了透過網路向伺服器發出請求)。
|
||||
|
||||
**無共享**並不是構建系統的唯一方式,但它已經成為構建網際網路服務的主要方式,其原因如下:相對便宜,因為它不需要特殊的硬體,可以利用商品化的雲端計算服務,透過跨多個地理分佈的資料中心進行冗餘可以實現高可靠性。
|
||||
**無共享** 並不是構建系統的唯一方式,但它已經成為構建網際網路服務的主要方式,其原因如下:相對便宜,因為它不需要特殊的硬體,可以利用商品化的雲端計算服務,透過跨多個地理分佈的資料中心進行冗餘可以實現高可靠性。
|
||||
|
||||
網際網路和資料中心(通常是乙太網)中的大多數內部網路都是**非同步分組網路(asynchronous packet networks)**。在這種網路中,一個節點可以向另一個節點發送一個訊息(一個數據包),但是網路不能保證它什麼時候到達,或者是否到達。如果你傳送請求並期待響應,則很多事情可能會出錯(其中一些如[圖8-1](../img/fig8-1.png)所示):
|
||||
網際網路和資料中心(通常是乙太網)中的大多數內部網路都是 **非同步分組網路(asynchronous packet networks)**。在這種網路中,一個節點可以向另一個節點發送一個訊息(一個數據包),但是網路不能保證它什麼時候到達,或者是否到達。如果你傳送請求並期待響應,則很多事情可能會出錯(其中一些如 [圖 8-1](../img/fig8-1.png) 所示):
|
||||
|
||||
1. 請求可能已經丟失(可能有人拔掉了網線)。
|
||||
2. 請求可能正在排隊,稍後將交付(也許網路或接收方過載)。
|
||||
3. 遠端節點可能已經失效(可能是崩潰或關機)。
|
||||
4. 遠端節點可能暫時停止了響應(可能會遇到長時間的垃圾回收暫停;請參閱“[程序暫停](#程序暫停)”),但稍後會再次響應。
|
||||
4. 遠端節點可能暫時停止了響應(可能會遇到長時間的垃圾回收暫停;請參閱 “[程序暫停](#程序暫停)”),但稍後會再次響應。
|
||||
5. 遠端節點可能已經處理了請求,但是網路上的響應已經丟失(可能是網路交換機配置錯誤)。
|
||||
6. 遠端節點可能已經處理了請求,但是響應已經被延遲,並且稍後將被傳遞(可能是網路或者你自己的機器過載)。
|
||||
|
||||
![](../img/fig8-1.png)
|
||||
|
||||
**圖8-1 如果傳送請求並沒有得到響應,則無法區分(a)請求是否丟失,(b)遠端節點是否關閉,或(c)響應是否丟失。**
|
||||
**圖 8-1 如果傳送請求並沒有得到響應,則無法區分(a)請求是否丟失,(b)遠端節點是否關閉,或(c)響應是否丟失。**
|
||||
|
||||
傳送者甚至不能分辨資料包是否被傳送:唯一的選擇是讓接收者傳送響應訊息,這可能會丟失或延遲。這些問題在非同步網路中難以區分:你所擁有的唯一資訊是,你尚未收到響應。如果你向另一個節點發送請求並且沒有收到響應,則不可能判斷是什麼原因。
|
||||
|
||||
處理這個問題的通常方法是**超時(Timeout)**:在一段時間之後放棄等待,並且認為響應不會到達。但是,當發生超時時,你仍然不知道遠端節點是否收到了請求(如果請求仍然在某個地方排隊,那麼即使傳送者已經放棄了該請求,仍然可能會將其傳送給接收者)。
|
||||
處理這個問題的通常方法是 **超時(Timeout)**:在一段時間之後放棄等待,並且認為響應不會到達。但是,當發生超時時,你仍然不知道遠端節點是否收到了請求(如果請求仍然在某個地方排隊,那麼即使傳送者已經放棄了該請求,仍然可能會將其傳送給接收者)。
|
||||
|
||||
### 真實世界的網路故障
|
||||
|
||||
我們幾十年來一直在建設計算機網路——有人可能希望現在我們已經找出了使網路變得可靠的方法。但是現在似乎還沒有成功。
|
||||
我們幾十年來一直在建設計算機網路 —— 有人可能希望現在我們已經找出了使網路變得可靠的方法。但是現在似乎還沒有成功。
|
||||
|
||||
有一些系統的研究和大量的軼事證據表明,即使在像一家公司運營的資料中心那樣的受控環境中,網路問題也可能出乎意料地普遍。在一家中型資料中心進行的一項研究發現,每個月大約有12個網路故障,其中一半斷開一臺機器,一半斷開整個機架【15】。另一項研究測量了架頂式交換機,匯聚交換機和負載平衡器等元件的故障率【16】。它發現新增冗餘網路裝置不會像你所希望的那樣減少故障,因為它不能防範人為錯誤(例如,錯誤配置的交換機),這是造成中斷的主要原因。
|
||||
有一些系統的研究和大量的軼事證據表明,即使在像一家公司運營的資料中心那樣的受控環境中,網路問題也可能出乎意料地普遍。在一家中型資料中心進行的一項研究發現,每個月大約有 12 個網路故障,其中一半斷開一臺機器,一半斷開整個機架【15】。另一項研究測量了架頂式交換機,匯聚交換機和負載平衡器等元件的故障率【16】。它發現新增冗餘網路裝置不會像你所希望的那樣減少故障,因為它不能防範人為錯誤(例如,錯誤配置的交換機),這是造成中斷的主要原因。
|
||||
|
||||
諸如EC2之類的公有云服務因頻繁的暫態網路故障而臭名昭著【14】,管理良好的私有資料中心網路可能是更穩定的環境。儘管如此,沒有人不受網路問題的困擾:例如,交換機軟體升級過程中的一個問題可能會引發網路拓撲重構,在此期間網路資料包可能會延遲超過一分鐘【17】。鯊魚可能咬住海底電纜並損壞它們 【18】。其他令人驚訝的故障包括網路介面有時會丟棄所有入站資料包,但是成功傳送出站資料包 【19】:僅僅因為網路連結在一個方向上工作,並不能保證它也在相反的方向工作。
|
||||
諸如 EC2 之類的公有云服務因頻繁的暫態網路故障而臭名昭著【14】,管理良好的私有資料中心網路可能是更穩定的環境。儘管如此,沒有人不受網路問題的困擾:例如,交換機軟體升級過程中的一個問題可能會引發網路拓撲重構,在此期間網路資料包可能會延遲超過一分鐘【17】。鯊魚可能咬住海底電纜並損壞它們 【18】。其他令人驚訝的故障包括網路介面有時會丟棄所有入站資料包,但是成功傳送出站資料包 【19】:僅僅因為網路連結在一個方向上工作,並不能保證它也在相反的方向工作。
|
||||
|
||||
> #### 網路分割槽
|
||||
>
|
||||
> 當網路的一部分由於網路故障而被切斷時,有時稱為**網路分割槽(network partition)** 或**網路斷裂(netsplit)**。在本書中,我們通常會堅持使用更一般的術語**網路故障(network fault)**,以避免與[第六章](ch6.md)討論的儲存系統的分割槽(分片)相混淆。
|
||||
> 當網路的一部分由於網路故障而被切斷時,有時稱為 **網路分割槽(network partition)** 或 **網路斷裂(netsplit)**。在本書中,我們通常會堅持使用更一般的術語 **網路故障(network fault)**,以避免與 [第六章](ch6.md) 討論的儲存系統的分割槽(分片)相混淆。
|
||||
|
||||
即使網路故障在你的環境中非常罕見,故障可能發生的事實,意味著你的軟體需要能夠處理它們。無論何時透過網路進行通訊,都可能會失敗,這是無法避免的。
|
||||
|
||||
如果網路故障的錯誤處理沒有定義與測試,武斷地講,各種錯誤可能都會發生:例如,即使網路恢復【20】,叢集可能會發生**死鎖**,永久無法為請求提供服務,甚至可能會刪除所有的資料【21】。如果軟體被置於意料之外的情況下,它可能會做出出乎意料的事情。
|
||||
如果網路故障的錯誤處理沒有定義與測試,武斷地講,各種錯誤可能都會發生:例如,即使網路恢復【20】,叢集可能會發生 **死鎖**,永久無法為請求提供服務,甚至可能會刪除所有的資料【21】。如果軟體被置於意料之外的情況下,它可能會做出出乎意料的事情。
|
||||
|
||||
處理網路故障並不意味著容忍它們:如果你的網路通常是相當可靠的,一個有效的方法可能是當你的網路遇到問題時,簡單地向用戶顯示一條錯誤資訊。但是,你確實需要知道你的軟體如何應對網路問題,並確保系統能夠從中恢復。有意識地觸發網路問題並測試系統響應(這是Chaos Monkey背後的想法;請參閱“[可靠性](ch1.md#可靠性)”)。
|
||||
處理網路故障並不意味著容忍它們:如果你的網路通常是相當可靠的,一個有效的方法可能是當你的網路遇到問題時,簡單地向用戶顯示一條錯誤資訊。但是,你確實需要知道你的軟體如何應對網路問題,並確保系統能夠從中恢復。有意識地觸發網路問題並測試系統響應(這是 Chaos Monkey 背後的想法;請參閱 “[可靠性](ch1.md#可靠性)”)。
|
||||
|
||||
### 檢測故障
|
||||
|
||||
許多系統需要自動檢測故障節點。例如:
|
||||
|
||||
* 負載平衡器需要停止向已死亡的節點轉發請求(從輪詢列表移出,即out of rotation)。
|
||||
* 在單主複製功能的分散式資料庫中,如果主庫失效,則需要將從庫之一升級為新主庫(請參閱“[處理節點宕機](ch5.md#處理節點宕機)”)。
|
||||
* 負載平衡器需要停止向已死亡的節點轉發請求(從輪詢列表移出,即 out of rotation)。
|
||||
* 在單主複製功能的分散式資料庫中,如果主庫失效,則需要將從庫之一升級為新主庫(請參閱 “[處理節點宕機](ch5.md#處理節點宕機)”)。
|
||||
|
||||
不幸的是,網路的不確定性使得很難判斷一個節點是否工作。在某些特定的情況下,你可能會收到一些反饋資訊,明確告訴你某些事情沒有成功:
|
||||
|
||||
* 如果你可以連線到執行節點的機器,但沒有程序正在偵聽目標埠(例如,因為程序崩潰),作業系統將透過傳送FIN或RST來關閉並重用TCP連線。但是,如果節點在處理請求時發生崩潰,則無法知道遠端節點實際處理了多少資料【22】。
|
||||
* 如果節點程序崩潰(或被管理員殺死),但節點的作業系統仍在執行,則指令碼可以通知其他節點有關該崩潰的資訊,以便另一個節點可以快速接管,而無需等待超時到期。例如,HBase就是這麼做的【23】。
|
||||
* 如果你可以連線到執行節點的機器,但沒有程序正在偵聽目標埠(例如,因為程序崩潰),作業系統將透過傳送 FIN 或 RST 來關閉並重用 TCP 連線。但是,如果節點在處理請求時發生崩潰,則無法知道遠端節點實際處理了多少資料【22】。
|
||||
* 如果節點程序崩潰(或被管理員殺死),但節點的作業系統仍在執行,則指令碼可以通知其他節點有關該崩潰的資訊,以便另一個節點可以快速接管,而無需等待超時到期。例如,HBase 就是這麼做的【23】。
|
||||
* 如果你有權訪問資料中心網路交換機的管理介面,則可以透過它們檢測硬體級別的鏈路故障(例如,遠端機器是否關閉電源)。如果你透過網際網路連線,或者如果你處於共享資料中心而無法訪問交換機,或者由於網路問題而無法訪問管理介面,則排除此選項。
|
||||
* 如果路由器確認你嘗試連線的IP地址不可用,則可能會使用ICMP目標不可達資料包回覆你。但是,路由器不具備神奇的故障檢測能力——它受到與網路其他參與者相同的限制。
|
||||
* 如果路由器確認你嘗試連線的 IP 地址不可用,則可能會使用 ICMP 目標不可達資料包回覆你。但是,路由器不具備神奇的故障檢測能力 —— 它受到與網路其他參與者相同的限制。
|
||||
|
||||
關於遠端節點關閉的快速反饋很有用,但是你不能指望它。即使TCP確認已經傳送了一個數據包,應用程式在處理之前可能已經崩潰。如果你想確保一個請求是成功的,你需要應用程式本身的正確響應【24】。
|
||||
關於遠端節點關閉的快速反饋很有用,但是你不能指望它。即使 TCP 確認已經傳送了一個數據包,應用程式在處理之前可能已經崩潰。如果你想確保一個請求是成功的,你需要應用程式本身的正確響應【24】。
|
||||
|
||||
相反,如果出了什麼問題,你可能會在堆疊的某個層次上得到一個錯誤響應,但總的來說,你必須假設你可能根本就得不到任何迴應。你可以重試幾次(TCP重試是透明的,但是你也可以在應用程式級別重試),等待超時過期,並且如果在超時時間內沒有收到響應,則最終宣告節點已經死亡。
|
||||
相反,如果出了什麼問題,你可能會在堆疊的某個層次上得到一個錯誤響應,但總的來說,你必須假設你可能根本就得不到任何迴應。你可以重試幾次(TCP 重試是透明的,但是你也可以在應用程式級別重試),等待超時過期,並且如果在超時時間內沒有收到響應,則最終宣告節點已經死亡。
|
||||
|
||||
### 超時與無窮的延遲
|
||||
|
||||
@ -154,87 +154,87 @@
|
||||
|
||||
長時間的超時意味著長時間等待,直到一個節點被宣告死亡(在這段時間內,使用者可能不得不等待,或者看到錯誤資訊)。短的超時可以更快地檢測到故障,但有更高地風險誤將一個節點宣佈為失效,而該節點實際上只是暫時地變慢了(例如由於節點或網路上的負載峰值)。
|
||||
|
||||
過早地宣告一個節點已經死了是有問題的:如果這個節點實際上是活著的,並且正在執行一些動作(例如,傳送一封電子郵件),而另一個節點接管,那麼這個動作可能會最終執行兩次。我們將在“[知識、真相與謊言](#知識、真相與謊言)”以及[第九章](ch9.md)和[第十一章](ch11.md)中更詳細地討論這個問題。
|
||||
過早地宣告一個節點已經死了是有問題的:如果這個節點實際上是活著的,並且正在執行一些動作(例如,傳送一封電子郵件),而另一個節點接管,那麼這個動作可能會最終執行兩次。我們將在 “[知識、真相與謊言](#知識、真相與謊言)” 以及 [第九章](ch9.md) 和 [第十一章](ch11.md) 中更詳細地討論這個問題。
|
||||
|
||||
當一個節點被宣告死亡時,它的職責需要轉移到其他節點,這會給其他節點和網路帶來額外的負擔。如果系統已經處於高負荷狀態,則過早宣告節點死亡會使問題更嚴重。特別是如果節點實際上沒有死亡,只是由於過載導致其響應緩慢;這時將其負載轉移到其他節點可能會導致**級聯失效**(即cascading failure,表示在極端情況下,所有節點都宣告對方死亡,所有節點都將停止工作)。
|
||||
當一個節點被宣告死亡時,它的職責需要轉移到其他節點,這會給其他節點和網路帶來額外的負擔。如果系統已經處於高負荷狀態,則過早宣告節點死亡會使問題更嚴重。特別是如果節點實際上沒有死亡,只是由於過載導致其響應緩慢;這時將其負載轉移到其他節點可能會導致 **級聯失效**(即 cascading failure,表示在極端情況下,所有節點都宣告對方死亡,所有節點都將停止工作)。
|
||||
|
||||
設想一個虛構的系統,其網路可以保證資料包的最大延遲——每個資料包要麼在一段時間內傳送,要麼丟失,但是傳遞永遠不會比$d$更長。此外,假設你可以保證一個非故障節點總是在一段時間內處理一個請求$r$。在這種情況下,你可以保證每個成功的請求在$2d + r$時間內都能收到響應,如果你在此時間內沒有收到響應,則知道網路或遠端節點不工作。如果這是成立的,$2d + r$ 會是一個合理的超時設定。
|
||||
設想一個虛構的系統,其網路可以保證資料包的最大延遲 —— 每個資料包要麼在一段時間內傳送,要麼丟失,但是傳遞永遠不會比 $d$ 更長。此外,假設你可以保證一個非故障節點總是在一段時間內處理一個請求 $r$。在這種情況下,你可以保證每個成功的請求在 $2d + r$ 時間內都能收到響應,如果你在此時間內沒有收到響應,則知道網路或遠端節點不工作。如果這是成立的,$2d + r$ 會是一個合理的超時設定。
|
||||
|
||||
不幸的是,我們所使用的大多數系統都沒有這些保證:非同步網路具有無限的延遲(即儘可能快地傳送資料包,但資料包到達可能需要的時間沒有上限),並且大多數伺服器實現並不能保證它們可以在一定的最大時間內處理請求(請參閱“[響應時間保證](#響應時間保證)”)。對於故障檢測,即使系統大部分時間快速執行也是不夠的:如果你的超時時間很短,往返時間只需要一個瞬時尖峰就可以使系統失衡。
|
||||
不幸的是,我們所使用的大多數系統都沒有這些保證:非同步網路具有無限的延遲(即儘可能快地傳送資料包,但資料包到達可能需要的時間沒有上限),並且大多數伺服器實現並不能保證它們可以在一定的最大時間內處理請求(請參閱 “[響應時間保證](#響應時間保證)”)。對於故障檢測,即使系統大部分時間快速執行也是不夠的:如果你的超時時間很短,往返時間只需要一個瞬時尖峰就可以使系統失衡。
|
||||
|
||||
#### 網路擁塞和排隊
|
||||
|
||||
在駕駛汽車時,由於交通擁堵,道路交通網路的通行時間往往不盡相同。同樣,計算機網路上資料包延遲的可變性通常是由於排隊【25】:
|
||||
|
||||
* 如果多個不同的節點同時嘗試將資料包傳送到同一目的地,則網路交換機必須將它們排隊並將它們逐個送入目標網路鏈路(如[圖8-2](../img/fig8-2.png)所示)。在繁忙的網路鏈路上,資料包可能需要等待一段時間才能獲得一個插槽(這稱為網路擁塞)。如果傳入的資料太多,交換機佇列填滿,資料包將被丟棄,因此需要重新發送資料包 - 即使網路執行良好。
|
||||
* 當資料包到達目標機器時,如果所有CPU核心當前都處於繁忙狀態,則來自網路的傳入請求將被作業系統排隊,直到應用程式準備好處理它為止。根據機器上的負載,這可能需要一段任意的時間。
|
||||
* 在虛擬化環境中,正在執行的作業系統經常暫停幾十毫秒,因為另一個虛擬機器正在使用CPU核心。在這段時間內,虛擬機器不能從網路中消耗任何資料,所以傳入的資料被虛擬機器監視器 【26】排隊(緩衝),進一步增加了網路延遲的可變性。
|
||||
* TCP執行**流量控制**(flow control,也稱為**擁塞避免**,即congestion avoidance,或**背壓**,即backpressure),其中節點會限制自己的傳送速率以避免網路鏈路或接收節點過載【27】。這意味著甚至在資料進入網路之前,在傳送者處就需要進行額外的排隊。
|
||||
* 如果多個不同的節點同時嘗試將資料包傳送到同一目的地,則網路交換機必須將它們排隊並將它們逐個送入目標網路鏈路(如 [圖 8-2](../img/fig8-2.png) 所示)。在繁忙的網路鏈路上,資料包可能需要等待一段時間才能獲得一個插槽(這稱為網路擁塞)。如果傳入的資料太多,交換機佇列填滿,資料包將被丟棄,因此需要重新發送資料包 - 即使網路執行良好。
|
||||
* 當資料包到達目標機器時,如果所有 CPU 核心當前都處於繁忙狀態,則來自網路的傳入請求將被作業系統排隊,直到應用程式準備好處理它為止。根據機器上的負載,這可能需要一段任意的時間。
|
||||
* 在虛擬化環境中,正在執行的作業系統經常暫停幾十毫秒,因為另一個虛擬機器正在使用 CPU 核心。在這段時間內,虛擬機器不能從網路中消耗任何資料,所以傳入的資料被虛擬機器監視器 【26】排隊(緩衝),進一步增加了網路延遲的可變性。
|
||||
* TCP 執行 **流量控制**(flow control,也稱為 **擁塞避免**,即 congestion avoidance,或 **背壓**,即 backpressure),其中節點會限制自己的傳送速率以避免網路鏈路或接收節點過載【27】。這意味著甚至在資料進入網路之前,在傳送者處就需要進行額外的排隊。
|
||||
|
||||
![](../img/fig8-2.png)
|
||||
|
||||
**圖8-2 如果有多臺機器將網路流量傳送到同一目的地,則其交換機佇列可能會被填滿。在這裡,埠1,2和4都試圖傳送資料包到埠3**
|
||||
**圖 8-2 如果有多臺機器將網路流量傳送到同一目的地,則其交換機佇列可能會被填滿。在這裡,埠 1,2 和 4 都試圖傳送資料包到埠 3**
|
||||
|
||||
而且,如果TCP在某個超時時間內沒有被確認(這是根據觀察的往返時間計算的),則認為資料包丟失,丟失的資料包將自動重新發送。儘管應用程式沒有看到資料包丟失和重新傳輸,但它看到了延遲(等待超時到期,然後等待重新傳輸的資料包得到確認)。
|
||||
而且,如果 TCP 在某個超時時間內沒有被確認(這是根據觀察的往返時間計算的),則認為資料包丟失,丟失的資料包將自動重新發送。儘管應用程式沒有看到資料包丟失和重新傳輸,但它看到了延遲(等待超時到期,然後等待重新傳輸的資料包得到確認)。
|
||||
|
||||
|
||||
> #### TCP與UDP
|
||||
>
|
||||
> 一些對延遲敏感的應用程式,比如影片會議和IP語音(VoIP),使用了UDP而不是TCP。這是在可靠性和和延遲變化之間的折衷:由於UDP不執行流量控制並且不重傳丟失的分組,所以避免了網路延遲變化的一些原因(儘管它仍然易受切換佇列和排程延遲的影響)。
|
||||
> 一些對延遲敏感的應用程式,比如影片會議和 IP 語音(VoIP),使用了 UDP 而不是 TCP。這是在可靠性和和延遲變化之間的折衷:由於 UDP 不執行流量控制並且不重傳丟失的分組,所以避免了網路延遲變化的一些原因(儘管它仍然易受切換佇列和排程延遲的影響)。
|
||||
>
|
||||
> 在延遲資料毫無價值的情況下,UDP是一個不錯的選擇。例如,在VoIP電話呼叫中,可能沒有足夠的時間重新發送丟失的資料包,並在揚聲器上播放資料。在這種情況下,重發資料包沒有意義——應用程式必須使用靜音填充丟失資料包的時隙(導致聲音短暫中斷),然後在資料流中繼續。重試發生在人類層。 (“你能再說一遍嗎?聲音剛剛斷了一會兒。“)
|
||||
> 在延遲資料毫無價值的情況下,UDP 是一個不錯的選擇。例如,在 VoIP 電話呼叫中,可能沒有足夠的時間重新發送丟失的資料包,並在揚聲器上播放資料。在這種情況下,重發資料包沒有意義 —— 應用程式必須使用靜音填充丟失資料包的時隙(導致聲音短暫中斷),然後在資料流中繼續。重試發生在人類層。 (“你能再說一遍嗎?聲音剛剛斷了一會兒。“)
|
||||
|
||||
所有這些因素都會造成網路延遲的變化。當系統接近其最大容量時,排隊延遲的變化範圍特別大:擁有足夠備用容量的系統可以輕鬆排空佇列,而在高利用率的系統中,很快就能積累很長的佇列。
|
||||
|
||||
在公共雲和多租戶資料中心中,資源被許多客戶共享:網路連結和交換機,甚至每個機器的網絡卡和CPU(在虛擬機器上執行時)。批處理工作負載(如MapReduce,請參閱[第十章](ch10.md))能夠很容易使網路連結飽和。由於無法控制或瞭解其他客戶對共享資源的使用情況,如果附近的某個人(嘈雜的鄰居)正在使用大量資源,則網路延遲可能會發生劇烈變化【28,29】。
|
||||
在公共雲和多租戶資料中心中,資源被許多客戶共享:網路連結和交換機,甚至每個機器的網絡卡和 CPU(在虛擬機器上執行時)。批處理工作負載(如 MapReduce,請參閱 [第十章](ch10.md))能夠很容易使網路連結飽和。由於無法控制或瞭解其他客戶對共享資源的使用情況,如果附近的某個人(嘈雜的鄰居)正在使用大量資源,則網路延遲可能會發生劇烈變化【28,29】。
|
||||
|
||||
在這種環境下,你只能透過實驗方式選擇超時:在一段較長的時期內、在多臺機器上測量網路往返時間的分佈,以確定延遲的預期變化。然後,考慮到應用程式的特性,可以確定**故障檢測延遲**與**過早超時風險**之間的適當折衷。
|
||||
在這種環境下,你只能透過實驗方式選擇超時:在一段較長的時期內、在多臺機器上測量網路往返時間的分佈,以確定延遲的預期變化。然後,考慮到應用程式的特性,可以確定 **故障檢測延遲** 與 **過早超時風險** 之間的適當折衷。
|
||||
|
||||
更好的一種做法是,系統不是使用配置的常量超時時間,而是連續測量響應時間及其變化(抖動),並根據觀察到的響應時間分佈自動調整超時時間。這可以透過Phi Accrual故障檢測器【30】來完成,該檢測器在例如Akka和Cassandra 【31】中使用。 TCP的超時重傳機制也是以類似的方式工作【27】。
|
||||
更好的一種做法是,系統不是使用配置的常量超時時間,而是連續測量響應時間及其變化(抖動),並根據觀察到的響應時間分佈自動調整超時時間。這可以透過 Phi Accrual 故障檢測器【30】來完成,該檢測器在例如 Akka 和 Cassandra 【31】中使用。 TCP 的超時重傳機制也是以類似的方式工作【27】。
|
||||
|
||||
### 同步網路與非同步網路
|
||||
|
||||
如果我們可以依靠網路來傳遞一些**最大延遲固定**的資料包,而不是丟棄資料包,那麼分散式系統就會簡單得多。為什麼我們不能在硬體層面上解決這個問題,使網路可靠,使軟體不必擔心呢?
|
||||
如果我們可以依靠網路來傳遞一些 **最大延遲固定** 的資料包,而不是丟棄資料包,那麼分散式系統就會簡單得多。為什麼我們不能在硬體層面上解決這個問題,使網路可靠,使軟體不必擔心呢?
|
||||
|
||||
為了回答這個問題,將資料中心網路與非常可靠的傳統固定電話網路(非蜂窩,非VoIP)進行比較是很有趣的:延遲音訊幀和掉話是非常罕見的。一個電話需要一個很低的端到端延遲,以及足夠的頻寬來傳輸你聲音的音訊取樣資料。在計算機網路中有類似的可靠性和可預測性不是很好嗎?
|
||||
為了回答這個問題,將資料中心網路與非常可靠的傳統固定電話網路(非蜂窩,非 VoIP)進行比較是很有趣的:延遲音訊幀和掉話是非常罕見的。一個電話需要一個很低的端到端延遲,以及足夠的頻寬來傳輸你聲音的音訊取樣資料。在計算機網路中有類似的可靠性和可預測性不是很好嗎?
|
||||
|
||||
當你透過電話網路撥打電話時,它會建立一個電路:在兩個呼叫者之間的整個路線上為呼叫分配一個固定的,有保證的頻寬量。這個電路會保持至通話結束【32】。例如,ISDN網路以每秒4000幀的固定速率執行。呼叫建立時,每個幀內(每個方向)分配16位空間。因此,在通話期間,每一方都保證能夠每250微秒傳送一個精確的16位音訊資料【33,34】。
|
||||
當你透過電話網路撥打電話時,它會建立一個電路:在兩個呼叫者之間的整個路線上為呼叫分配一個固定的,有保證的頻寬量。這個電路會保持至通話結束【32】。例如,ISDN 網路以每秒 4000 幀的固定速率執行。呼叫建立時,每個幀內(每個方向)分配 16 位空間。因此,在通話期間,每一方都保證能夠每 250 微秒傳送一個精確的 16 位音訊資料【33,34】。
|
||||
|
||||
這種網路是同步的:即使資料經過多個路由器,也不會受到排隊的影響,因為呼叫的16位空間已經在網路的下一跳中保留了下來。而且由於沒有排隊,網路的最大端到端延遲是固定的。我們稱之為**有限延遲(bounded delay)**。
|
||||
這種網路是同步的:即使資料經過多個路由器,也不會受到排隊的影響,因為呼叫的 16 位空間已經在網路的下一跳中保留了下來。而且由於沒有排隊,網路的最大端到端延遲是固定的。我們稱之為 **有限延遲(bounded delay)**。
|
||||
|
||||
#### 我們不能簡單地使網路延遲可預測嗎?
|
||||
|
||||
請注意,電話網路中的電路與TCP連線有很大不同:電路是固定數量的預留頻寬,在電路建立時沒有其他人可以使用,而TCP連線的資料包**機會性地**使用任何可用的網路頻寬。你可以給TCP一個可變大小的資料塊(例如,一個電子郵件或一個網頁),它會盡可能在最短的時間內傳輸它。 TCP連線空閒時,不使用任何頻寬[^ii]。
|
||||
請注意,電話網路中的電路與 TCP 連線有很大不同:電路是固定數量的預留頻寬,在電路建立時沒有其他人可以使用,而 TCP 連線的資料包 **機會性地** 使用任何可用的網路頻寬。你可以給 TCP 一個可變大小的資料塊(例如,一個電子郵件或一個網頁),它會盡可能在最短的時間內傳輸它。 TCP 連線空閒時,不使用任何頻寬 [^ii]。
|
||||
|
||||
[^ii]: 除了偶爾的keepalive資料包,如果TCP keepalive被啟用。
|
||||
[^ii]: 除了偶爾的 keepalive 資料包,如果 TCP keepalive 被啟用。
|
||||
|
||||
如果資料中心網路和網際網路是電路交換網路,那麼在建立電路時就可以建立一個受保證的最大往返時間。但是,它們並不是:乙太網和IP是**分組交換協議**,不得不忍受排隊的折磨,及其導致的網路無限延遲。這些協議沒有電路的概念。
|
||||
如果資料中心網路和網際網路是電路交換網路,那麼在建立電路時就可以建立一個受保證的最大往返時間。但是,它們並不是:乙太網和 IP 是 **分組交換協議**,不得不忍受排隊的折磨,及其導致的網路無限延遲。這些協議沒有電路的概念。
|
||||
|
||||
為什麼資料中心網路和網際網路使用分組交換?答案是,它們針對**突發流量(bursty traffic)** 進行了最佳化。一個電路適用於音訊或影片通話,在通話期間需要每秒傳送相當數量的位元。另一方面,請求網頁,傳送電子郵件或傳輸檔案沒有任何特定的頻寬要求——我們只是希望它儘快完成。
|
||||
為什麼資料中心網路和網際網路使用分組交換?答案是,它們針對 **突發流量(bursty traffic)** 進行了最佳化。一個電路適用於音訊或影片通話,在通話期間需要每秒傳送相當數量的位元。另一方面,請求網頁,傳送電子郵件或傳輸檔案沒有任何特定的頻寬要求 —— 我們只是希望它儘快完成。
|
||||
|
||||
如果想透過電路傳輸檔案,你得預測一個頻寬分配。如果你猜的太低,傳輸速度會不必要的太慢,導致網路容量閒置。如果你猜的太高,電路就無法建立(因為如果無法保證其頻寬分配,網路不能建立電路)。因此,將電路用於突發資料傳輸會浪費網路容量,並且使傳輸不必要地緩慢。相比之下,TCP動態調整資料傳輸速率以適應可用的網路容量。
|
||||
如果想透過電路傳輸檔案,你得預測一個頻寬分配。如果你猜的太低,傳輸速度會不必要的太慢,導致網路容量閒置。如果你猜的太高,電路就無法建立(因為如果無法保證其頻寬分配,網路不能建立電路)。因此,將電路用於突發資料傳輸會浪費網路容量,並且使傳輸不必要地緩慢。相比之下,TCP 動態調整資料傳輸速率以適應可用的網路容量。
|
||||
|
||||
已經有一些嘗試去建立同時支援電路交換和分組交換的混合網路,比如ATM[^iii]。InfiniBand有一些相似之處【35】:它在鏈路層實現了端到端的流量控制,從而減少了在網路中排隊的需要,儘管它仍然可能因鏈路擁塞而受到延遲【36】。透過仔細使用**服務質量**(quality of service,即QoS,資料包的優先順序和排程)和**准入控制**(admission control,限速傳送器),可以在分組網路上類比電路交換,或提供統計上的**有限延遲**【25,32】。
|
||||
已經有一些嘗試去建立同時支援電路交換和分組交換的混合網路,比如 ATM [^iii]。InfiniBand 有一些相似之處【35】:它在鏈路層實現了端到端的流量控制,從而減少了在網路中排隊的需要,儘管它仍然可能因鏈路擁塞而受到延遲【36】。透過仔細使用 **服務質量**(quality of service,即 QoS,資料包的優先順序和排程)和 **准入控制**(admission control,限速傳送器),可以在分組網路上類比電路交換,或提供統計上的 **有限延遲**【25,32】。
|
||||
|
||||
[^iii]: **非同步傳輸模式(Asynchronous Transfer Mode, ATM)** 在20世紀80年代是乙太網的競爭對手【32】,但在電話網核心交換機之外並沒有得到太多的採用。它與自動櫃員機(也稱為自動取款機)無關,儘管共用一個縮寫詞。或許,在一些平行的世界裡,網際網路是基於像ATM這樣的東西,因此它們的網際網路影片通話可能比我們的更可靠,因為它們不會遭受包的丟失和延遲。
|
||||
[^iii]: **非同步傳輸模式(Asynchronous Transfer Mode, ATM)** 在 20 世紀 80 年代是乙太網的競爭對手【32】,但在電話網核心交換機之外並沒有得到太多的採用。它與自動櫃員機(也稱為自動取款機)無關,儘管共用一個縮寫詞。或許,在一些平行的世界裡,網際網路是基於像 ATM 這樣的東西,因此它們的網際網路影片通話可能比我們的更可靠,因為它們不會遭受包的丟失和延遲。
|
||||
|
||||
但是,目前在多租戶資料中心和公共雲或透過網際網路[^iv]進行通訊時,此類服務質量尚未啟用。當前部署的技術不允許我們對網路的延遲或可靠性作出任何保證:我們必須假設網路擁塞,排隊和無限的延遲總是會發生。因此,超時時間沒有“正確”的值——它需要透過實驗來確定。
|
||||
但是,目前在多租戶資料中心和公共雲或透過網際網路 [^iv] 進行通訊時,此類服務質量尚未啟用。當前部署的技術不允許我們對網路的延遲或可靠性作出任何保證:我們必須假設網路擁塞,排隊和無限的延遲總是會發生。因此,超時時間沒有 “正確” 的值 —— 它需要透過實驗來確定。
|
||||
|
||||
[^iv]: 網際網路服務提供商之間的對等協議和透過**BGP閘道器協議(BGP)** 建立的路由,與IP協議相比,更接近於電路交換。在這個級別上,可以購買專用頻寬。但是,網際網路路由在網路級別執行,而不是主機之間的單獨連線,而且執行時間要長得多。
|
||||
[^iv]: 網際網路服務提供商之間的對等協議和透過 **BGP 閘道器協議(BGP)** 建立的路由,與 IP 協議相比,更接近於電路交換。在這個級別上,可以購買專用頻寬。但是,網際網路路由在網路級別執行,而不是主機之間的單獨連線,而且執行時間要長得多。
|
||||
|
||||
> ### 延遲和資源利用
|
||||
>
|
||||
> 更一般地說,可以將**延遲變化**視為**動態資源分割槽**的結果。
|
||||
> 更一般地說,可以將 **延遲變化** 視為 **動態資源分割槽** 的結果。
|
||||
>
|
||||
> 假設兩臺電話交換機之間有一條線路,可以同時進行10,000個呼叫。透過此線路切換的每個電路都佔用其中一個呼叫插槽。因此,你可以將線路視為可由多達10,000個併發使用者共享的資源。資源以靜態方式分配:即使你現在是電話上唯一的電話,並且所有其他9,999個插槽都未使用,你的電路仍將分配與導線充分利用時相同的固定數量的頻寬。
|
||||
> 假設兩臺電話交換機之間有一條線路,可以同時進行 10,000 個呼叫。透過此線路切換的每個電路都佔用其中一個呼叫插槽。因此,你可以將線路視為可由多達 10,000 個併發使用者共享的資源。資源以靜態方式分配:即使你現在是電話上唯一的電話,並且所有其他 9,999 個插槽都未使用,你的電路仍將分配與導線充分利用時相同的固定數量的頻寬。
|
||||
>
|
||||
> 相比之下,網際網路動態分享網路頻寬。傳送者互相推擠和爭奪,以讓他們的資料包儘可能快地透過網路,並且網路交換機決定從一個時刻到另一個時刻傳送哪個分組(即,頻寬分配)。這種方法有排隊的缺點,但其優點是它最大限度地利用了電線。電線固定成本,所以如果你更好地利用它,你透過電線傳送的每個位元組都會更便宜。
|
||||
>
|
||||
> CPU也會出現類似的情況:如果你在多個執行緒間動態共享每個CPU核心,則一個執行緒有時必須在作業系統的執行佇列裡等待,而另一個執行緒正在執行,這樣每個執行緒都有可能被暫停一個不定的時間長度。但是,與為每個執行緒分配靜態數量的CPU週期相比,這會更好地利用硬體(請參閱“[響應時間保證](#響應時間保證)”)。更好的硬體利用率也是使用虛擬機器的重要動機。
|
||||
> CPU 也會出現類似的情況:如果你在多個執行緒間動態共享每個 CPU 核心,則一個執行緒有時必須在作業系統的執行佇列裡等待,而另一個執行緒正在執行,這樣每個執行緒都有可能被暫停一個不定的時間長度。但是,與為每個執行緒分配靜態數量的 CPU 週期相比,這會更好地利用硬體(請參閱 “[響應時間保證](#響應時間保證)”)。更好的硬體利用率也是使用虛擬機器的重要動機。
|
||||
>
|
||||
> 如果資源是靜態分割槽的(例如,專用硬體和專用頻寬分配),則在某些環境中可以實現**延遲保證**。但是,這是以降低利用率為代價的——換句話說,它是更昂貴的。另一方面,動態資源分配的多租戶提供了更好的利用率,所以它更便宜,但它具有可變延遲的缺點。
|
||||
> 如果資源是靜態分割槽的(例如,專用硬體和專用頻寬分配),則在某些環境中可以實現 **延遲保證**。但是,這是以降低利用率為代價的 —— 換句話說,它是更昂貴的。另一方面,動態資源分配的多租戶提供了更好的利用率,所以它更便宜,但它具有可變延遲的缺點。
|
||||
>
|
||||
> 網路中的可變延遲不是一種自然規律,而只是成本/收益權衡的結果。
|
||||
> 網路中的可變延遲不是一種自然規律,而只是成本 / 收益權衡的結果。
|
||||
|
||||
|
||||
## 不可靠的時鐘
|
||||
@ -242,7 +242,7 @@
|
||||
時鐘和時間很重要。應用程式以各種方式依賴於時鐘來回答以下問題:
|
||||
|
||||
1. 這個請求是否超時了?
|
||||
2. 這項服務的第99百分位響應時間是多少?
|
||||
2. 這項服務的第 99 百分位響應時間是多少?
|
||||
3. 在過去五分鐘內,該服務平均每秒處理多少個查詢?
|
||||
4. 使用者在我們的網站上花了多長時間?
|
||||
5. 這篇文章在何時釋出?
|
||||
@ -250,11 +250,11 @@
|
||||
7. 這個快取條目何時到期?
|
||||
8. 日誌檔案中此錯誤訊息的時間戳是什麼?
|
||||
|
||||
[例1-4](ch1.md)測量了**持續時間**(durations,例如,請求傳送與響應接收之間的時間間隔),而[例5-8](ch5.md)描述了**時間點**(point in time,在特定日期和和特定時間發生的事件)。
|
||||
[例 1-4](ch1.md) 測量了 **持續時間**(durations,例如,請求傳送與響應接收之間的時間間隔),而 [例 5-8](ch5.md) 描述了 **時間點**(point in time,在特定日期和和特定時間發生的事件)。
|
||||
|
||||
在分散式系統中,時間是一件棘手的事情,因為通訊不是即時的:訊息透過網路從一臺機器傳送到另一臺機器需要時間。收到訊息的時間總是晚於傳送的時間,但是由於網路中的可變延遲,我們不知道晚了多少時間。這個事實導致有時很難確定在涉及多臺機器時發生事情的順序。
|
||||
|
||||
而且,網路上的每臺機器都有自己的時鐘,這是一個實際的硬體裝置:通常是石英晶體振盪器。這些裝置不是完全準確的,所以每臺機器都有自己的時間概念,可能比其他機器稍快或更慢。可以在一定程度上同步時鐘:最常用的機制是**網路時間協議(NTP)**,它允許根據一組伺服器報告的時間來調整計算機時鐘【37】。伺服器則從更精確的時間源(如GPS接收機)獲取時間。
|
||||
而且,網路上的每臺機器都有自己的時鐘,這是一個實際的硬體裝置:通常是石英晶體振盪器。這些裝置不是完全準確的,所以每臺機器都有自己的時間概念,可能比其他機器稍快或更慢。可以在一定程度上同步時鐘:最常用的機制是 **網路時間協議(NTP)**,它允許根據一組伺服器報告的時間來調整計算機時鐘【37】。伺服器則從更精確的時間源(如 GPS 接收機)獲取時間。
|
||||
|
||||
### 單調鍾與日曆時鐘
|
||||
|
||||
@ -262,50 +262,50 @@
|
||||
|
||||
#### 日曆時鐘
|
||||
|
||||
日曆時鐘是你直觀地瞭解時鐘的依據:它根據某個日曆(也稱為**掛鐘時間**,即wall-clock time)返回當前日期和時間。例如,Linux上的`clock_gettime(CLOCK_REALTIME)`[^v]和Java中的`System.currentTimeMillis()`返回自epoch(UTC時間1970年1月1日午夜)以來的秒數(或毫秒),根據公曆(Gregorian)日曆,不包括閏秒。有些系統使用其他日期作為參考點。
|
||||
日曆時鐘是你直觀地瞭解時鐘的依據:它根據某個日曆(也稱為 **掛鐘時間**,即 wall-clock time)返回當前日期和時間。例如,Linux 上的 `clock_gettime(CLOCK_REALTIME)`[^v] 和 Java 中的 `System.currentTimeMillis()` 返回自 epoch(UTC 時間 1970 年 1 月 1 日午夜)以來的秒數(或毫秒),根據公曆(Gregorian)日曆,不包括閏秒。有些系統使用其他日期作為參考點。
|
||||
|
||||
[^v]: 雖然該時鐘被稱為實時時鐘,但它與實時作業系統無關,如“[響應時間保證](#響應時間保證)”中所述。
|
||||
[^v]: 雖然該時鐘被稱為實時時鐘,但它與實時作業系統無關,如 “[響應時間保證](#響應時間保證)” 中所述。
|
||||
|
||||
日曆時鐘通常與NTP同步,這意味著來自一臺機器的時間戳(理想情況下)與另一臺機器上的時間戳相同。但是如下節所述,日曆時鐘也具有各種各樣的奇特之處。特別是,如果本地時鐘在NTP伺服器之前太遠,則它可能會被強制重置,看上去好像跳回了先前的時間點。這些跳躍以及他們經常忽略閏秒的事實,使日曆時鐘不能用於測量經過時間(elapsed time)【38】。
|
||||
日曆時鐘通常與 NTP 同步,這意味著來自一臺機器的時間戳(理想情況下)與另一臺機器上的時間戳相同。但是如下節所述,日曆時鐘也具有各種各樣的奇特之處。特別是,如果本地時鐘在 NTP 伺服器之前太遠,則它可能會被強制重置,看上去好像跳回了先前的時間點。這些跳躍以及他們經常忽略閏秒的事實,使日曆時鐘不能用於測量經過時間(elapsed time)【38】。
|
||||
|
||||
歷史上的日曆時鐘還具有相當粗略的解析度,例如,在較早的Windows系統上以10毫秒為單位前進【39】。在最近的系統中這已經不是一個問題了。
|
||||
歷史上的日曆時鐘還具有相當粗略的解析度,例如,在較早的 Windows 系統上以 10 毫秒為單位前進【39】。在最近的系統中這已經不是一個問題了。
|
||||
|
||||
#### 單調鍾
|
||||
|
||||
單調鍾適用於測量持續時間(時間間隔),例如超時或服務的響應時間:Linux上的`clock_gettime(CLOCK_MONOTONIC)`,和Java中的`System.nanoTime()`都是單調時鐘。這個名字來源於他們保證總是往前走的事實(而日曆時鐘可以往回跳)。
|
||||
單調鍾適用於測量持續時間(時間間隔),例如超時或服務的響應時間:Linux 上的 `clock_gettime(CLOCK_MONOTONIC)`,和 Java 中的 `System.nanoTime()` 都是單調時鐘。這個名字來源於他們保證總是往前走的事實(而日曆時鐘可以往回跳)。
|
||||
|
||||
你可以在某個時間點檢查單調鐘的值,做一些事情,且稍後再次檢查它。這兩個值之間的差異告訴你兩次檢查之間經過了多長時間。但單調鐘的絕對值是毫無意義的:它可能是計算機啟動以來的納秒數,或類似的任意值。特別是比較來自兩臺不同計算機的單調鐘的值是沒有意義的,因為它們並不是一回事。
|
||||
|
||||
在具有多個CPU插槽的伺服器上,每個CPU可能有一個單獨的計時器,但不一定與其他CPU同步。作業系統會補償所有的差異,並嘗試嚮應用執行緒表現出單調鐘的樣子,即使這些執行緒被排程到不同的CPU上。當然,明智的做法是不要太把這種單調性保證當回事【40】。
|
||||
在具有多個 CPU 插槽的伺服器上,每個 CPU 可能有一個單獨的計時器,但不一定與其他 CPU 同步。作業系統會補償所有的差異,並嘗試嚮應用執行緒表現出單調鐘的樣子,即使這些執行緒被排程到不同的 CPU 上。當然,明智的做法是不要太把這種單調性保證當回事【40】。
|
||||
|
||||
如果NTP協議檢測到計算機的本地石英鐘比NTP伺服器要更快或更慢,則可以調整單調鍾向前走的頻率(這稱為**偏移(skewing)** 時鐘)。預設情況下,NTP允許時鐘速率增加或減慢最高至0.05%,但NTP不能使單調時鐘向前或向後跳轉。單調時鐘的解析度通常相當好:在大多數系統中,它們能在幾微秒或更短的時間內測量時間間隔。
|
||||
如果 NTP 協議檢測到計算機的本地石英鐘比 NTP 伺服器要更快或更慢,則可以調整單調鍾向前走的頻率(這稱為 **偏移(skewing)** 時鐘)。預設情況下,NTP 允許時鐘速率增加或減慢最高至 0.05%,但 NTP 不能使單調時鐘向前或向後跳轉。單調時鐘的解析度通常相當好:在大多數系統中,它們能在幾微秒或更短的時間內測量時間間隔。
|
||||
|
||||
在分散式系統中,使用單調鍾測量**經過時間**(elapsed time,比如超時)通常很好,因為它不假定不同節點的時鐘之間存在任何同步,並且對測量的輕微不準確性不敏感。
|
||||
在分散式系統中,使用單調鍾測量 **經過時間**(elapsed time,比如超時)通常很好,因為它不假定不同節點的時鐘之間存在任何同步,並且對測量的輕微不準確性不敏感。
|
||||
|
||||
### 時鐘同步與準確性
|
||||
|
||||
單調鐘不需要同步,但是日曆時鐘需要根據NTP伺服器或其他外部時間源來設定才能有用。不幸的是,我們獲取時鐘的方法並不像你所希望的那樣可靠或準確——硬體時鐘和NTP可能會變幻莫測。舉幾個例子:
|
||||
單調鐘不需要同步,但是日曆時鐘需要根據 NTP 伺服器或其他外部時間源來設定才能有用。不幸的是,我們獲取時鐘的方法並不像你所希望的那樣可靠或準確 —— 硬體時鐘和 NTP 可能會變幻莫測。舉幾個例子:
|
||||
|
||||
* 計算機中的石英鐘不夠精確:它會**漂移**(drifts,即執行速度快於或慢於預期)。時鐘漂移取決於機器的溫度。 Google假設其伺服器時鐘漂移為200 ppm(百萬分之一)【41】,相當於每30秒與伺服器重新同步一次的時鐘漂移為6毫秒,或者每天重新同步的時鐘漂移為17秒。即使一切工作正常,此漂移也會限制可以達到的最佳準確度。
|
||||
* 如果計算機的時鐘與NTP伺服器的時鐘差別太大,可能會拒絕同步,或者本地時鐘將被強制重置【37】。任何觀察重置前後時間的應用程式都可能會看到時間倒退或突然跳躍。
|
||||
* 如果某個節點被NTP伺服器的防火牆意外阻塞,有可能會持續一段時間都沒有人會注意到。有證據表明,這在實踐中確實發生過。
|
||||
* NTP同步只能和網路延遲一樣好,所以當你在擁有可變資料包延遲的擁塞網路上時,NTP同步的準確性會受到限制。一個實驗表明,當透過網際網路同步時,35毫秒的最小誤差是可以實現的,儘管偶爾的網路延遲峰值會導致大約一秒的誤差。根據配置,較大的網路延遲會導致NTP客戶端完全放棄。
|
||||
* 一些NTP伺服器是錯誤的或者配置錯誤的,報告的時間可能相差幾個小時【43,44】。還好NTP客戶端非常健壯,因為他們會查詢多個伺服器並忽略異常值。無論如何,依賴於網際網路上的陌生人所告訴你的時間來保證你的系統的正確性,這還挺讓人擔憂的。
|
||||
* 閏秒導致一分鐘可能有59秒或61秒,這會打破一些在設計之時未考慮閏秒的系統的時序假設【45】。閏秒已經使許多大型系統崩潰的事實【38,46】說明了,關於時鐘的錯誤假設是多麼容易偷偷溜入系統中。處理閏秒的最佳方法可能是讓NTP伺服器“撒謊”,並在一天中逐漸執行閏秒調整(這被稱為**拖尾**,即smearing)【47,48】,雖然實際的NTP伺服器表現各異【49】。
|
||||
* 在虛擬機器中,硬體時鐘被虛擬化,這對於需要精確計時的應用程式提出了額外的挑戰【50】。當一個CPU核心在虛擬機器之間共享時,每個虛擬機器都會暫停幾十毫秒,與此同時另一個虛擬機器正在執行。從應用程式的角度來看,這種停頓表現為時鐘突然向前跳躍【26】。
|
||||
* 計算機中的石英鐘不夠精確:它會 **漂移**(drifts,即執行速度快於或慢於預期)。時鐘漂移取決於機器的溫度。 Google 假設其伺服器時鐘漂移為 200 ppm(百萬分之一)【41】,相當於每 30 秒與伺服器重新同步一次的時鐘漂移為 6 毫秒,或者每天重新同步的時鐘漂移為 17 秒。即使一切工作正常,此漂移也會限制可以達到的最佳準確度。
|
||||
* 如果計算機的時鐘與 NTP 伺服器的時鐘差別太大,可能會拒絕同步,或者本地時鐘將被強制重置【37】。任何觀察重置前後時間的應用程式都可能會看到時間倒退或突然跳躍。
|
||||
* 如果某個節點被 NTP 伺服器的防火牆意外阻塞,有可能會持續一段時間都沒有人會注意到。有證據表明,這在實踐中確實發生過。
|
||||
* NTP 同步只能和網路延遲一樣好,所以當你在擁有可變資料包延遲的擁塞網路上時,NTP 同步的準確性會受到限制。一個實驗表明,當透過網際網路同步時,35 毫秒的最小誤差是可以實現的,儘管偶爾的網路延遲峰值會導致大約一秒的誤差。根據配置,較大的網路延遲會導致 NTP 客戶端完全放棄。
|
||||
* 一些 NTP 伺服器是錯誤的或者配置錯誤的,報告的時間可能相差幾個小時【43,44】。還好 NTP 客戶端非常健壯,因為他們會查詢多個伺服器並忽略異常值。無論如何,依賴於網際網路上的陌生人所告訴你的時間來保證你的系統的正確性,這還挺讓人擔憂的。
|
||||
* 閏秒導致一分鐘可能有 59 秒或 61 秒,這會打破一些在設計之時未考慮閏秒的系統的時序假設【45】。閏秒已經使許多大型系統崩潰的事實【38,46】說明了,關於時鐘的錯誤假設是多麼容易偷偷溜入系統中。處理閏秒的最佳方法可能是讓 NTP 伺服器 “撒謊”,並在一天中逐漸執行閏秒調整(這被稱為 **拖尾**,即 smearing)【47,48】,雖然實際的 NTP 伺服器表現各異【49】。
|
||||
* 在虛擬機器中,硬體時鐘被虛擬化,這對於需要精確計時的應用程式提出了額外的挑戰【50】。當一個 CPU 核心在虛擬機器之間共享時,每個虛擬機器都會暫停幾十毫秒,與此同時另一個虛擬機器正在執行。從應用程式的角度來看,這種停頓表現為時鐘突然向前跳躍【26】。
|
||||
* 如果你在沒有完整控制權的裝置(例如,移動裝置或嵌入式裝置)上執行軟體,則可能完全不能信任該裝置的硬體時鐘。一些使用者故意將其硬體時鐘設定為不正確的日期和時間,例如,為了規避遊戲中的時間限制,時鐘可能會被設定到很遠的過去或將來。
|
||||
|
||||
如果你足夠在乎這件事並投入大量資源,就可以達到非常好的時鐘精度。例如,針對金融機構的歐洲法規草案MiFID II要求所有高頻率交易基金在UTC時間100微秒內同步時鐘,以便除錯“閃崩”等市場異常現象,並幫助檢測市場操縱【51】。
|
||||
如果你足夠在乎這件事並投入大量資源,就可以達到非常好的時鐘精度。例如,針對金融機構的歐洲法規草案 MiFID II 要求所有高頻率交易基金在 UTC 時間 100 微秒內同步時鐘,以便除錯 “閃崩” 等市場異常現象,並幫助檢測市場操縱【51】。
|
||||
|
||||
透過GPS接收機,精確時間協議(PTP)【52】以及仔細的部署和監測可以實現這種精確度。然而,這需要很多努力和專業知識,而且有很多東西都會導致時鐘同步錯誤。如果你的NTP守護程序配置錯誤,或者防火牆阻止了NTP通訊,由漂移引起的時鐘誤差可能很快就會變大。
|
||||
透過 GPS 接收機,精確時間協議(PTP)【52】以及仔細的部署和監測可以實現這種精確度。然而,這需要很多努力和專業知識,而且有很多東西都會導致時鐘同步錯誤。如果你的 NTP 守護程序配置錯誤,或者防火牆阻止了 NTP 通訊,由漂移引起的時鐘誤差可能很快就會變大。
|
||||
|
||||
### 依賴同步時鐘
|
||||
|
||||
時鐘的問題在於,雖然它們看起來簡單易用,但卻具有令人驚訝的缺陷:一天可能不會有精確的86,400秒,**日曆時鐘**可能會前後跳躍,而一個節點上的時間可能與另一個節點上的時間完全不同。
|
||||
時鐘的問題在於,雖然它們看起來簡單易用,但卻具有令人驚訝的缺陷:一天可能不會有精確的 86,400 秒,**日曆時鐘** 可能會前後跳躍,而一個節點上的時間可能與另一個節點上的時間完全不同。
|
||||
|
||||
本章早些時候,我們討論了網路丟包和任意延遲包的問題。儘管網路在大多數情況下表現良好,但軟體的設計必須假定網路偶爾會出現故障,而軟體必須正常處理這些故障。時鐘也是如此:儘管大多數時間都工作得很好,但需要準備健壯的軟體來處理不正確的時鐘。
|
||||
|
||||
有一部分問題是,不正確的時鐘很容易被視而不見。如果一臺機器的CPU出現故障或者網路配置錯誤,很可能根本無法工作,所以很快就會被注意和修復。另一方面,如果它的石英時鐘有缺陷,或者它的NTP客戶端配置錯誤,大部分事情似乎仍然可以正常工作,即使它的時鐘逐漸偏離現實。如果某個軟體依賴於精確同步的時鐘,那麼結果更可能是悄無聲息的,僅有微量的資料丟失,而不是一次驚天動地的崩潰【53,54】。
|
||||
有一部分問題是,不正確的時鐘很容易被視而不見。如果一臺機器的 CPU 出現故障或者網路配置錯誤,很可能根本無法工作,所以很快就會被注意和修復。另一方面,如果它的石英時鐘有缺陷,或者它的 NTP 客戶端配置錯誤,大部分事情似乎仍然可以正常工作,即使它的時鐘逐漸偏離現實。如果某個軟體依賴於精確同步的時鐘,那麼結果更可能是悄無聲息的,僅有微量的資料丟失,而不是一次驚天動地的崩潰【53,54】。
|
||||
|
||||
因此,如果你使用需要同步時鐘的軟體,必須仔細監控所有機器之間的時鐘偏移。時鐘偏離其他時鐘太遠的節點應當被宣告死亡,並從叢集中移除。這樣的監控可以確保你在損失發生之前注意到破損的時鐘。
|
||||
|
||||
@ -313,55 +313,55 @@
|
||||
|
||||
讓我們考慮一個特別的情況,一件很有誘惑但也很危險的事情:依賴時鐘,在多個節點上對事件進行排序。 例如,如果兩個客戶端寫入分散式資料庫,誰先到達? 哪一個更近?
|
||||
|
||||
[圖8-3](../img/fig8-3.png)顯示了在具有多領導者複製的資料庫中對時鐘的危險使用(該例子類似於[圖5-9](../img/fig5-9.png))。 客戶端A在節點1上寫入`x = 1`;寫入被複制到節點3;客戶端B在節點3上增加x(我們現在有`x = 2`);最後這兩個寫入都被複制到節點2。
|
||||
[圖 8-3](../img/fig8-3.png) 顯示了在具有多領導者複製的資料庫中對時鐘的危險使用(該例子類似於 [圖 5-9](../img/fig5-9.png))。 客戶端 A 在節點 1 上寫入 `x = 1`;寫入被複制到節點 3;客戶端 B 在節點 3 上增加 x(我們現在有 `x = 2`);最後這兩個寫入都被複制到節點 2。
|
||||
|
||||
![](../img/fig8-3.png)
|
||||
|
||||
**圖8-3 客戶端B的寫入比客戶端A的寫入要晚,但是B的寫入具有較早的時間戳。**
|
||||
**圖 8-3 客戶端 B 的寫入比客戶端 A 的寫入要晚,但是 B 的寫入具有較早的時間戳。**
|
||||
|
||||
在[圖8-3](../img/fig8-3.png)中,當一個寫入被複制到其他節點時,它會根據發生寫入的節點上的日曆時鐘標記一個時間戳。在這個例子中,時鐘同步是非常好的:節點1和節點3之間的偏差小於3ms,這可能比你在實踐中能預期的更好。
|
||||
在 [圖 8-3](../img/fig8-3.png) 中,當一個寫入被複制到其他節點時,它會根據發生寫入的節點上的日曆時鐘標記一個時間戳。在這個例子中,時鐘同步是非常好的:節點 1 和節點 3 之間的偏差小於 3ms,這可能比你在實踐中能預期的更好。
|
||||
|
||||
儘管如此,[圖8-3](../img/fig8-3.png)中的時間戳卻無法正確排列事件:寫入`x = 1`的時間戳為42.004秒,但寫入`x = 2`的時間戳為42.003秒,即使`x = 2`在稍後出現。當節點2接收到這兩個事件時,會錯誤地推斷出`x = 1`是最近的值,而丟棄寫入`x = 2`。效果上表現為,客戶端B的增量操作會丟失。
|
||||
儘管如此,[圖 8-3](../img/fig8-3.png) 中的時間戳卻無法正確排列事件:寫入 `x = 1` 的時間戳為 42.004 秒,但寫入 `x = 2` 的時間戳為 42.003 秒,即使 `x = 2` 在稍後出現。當節點 2 接收到這兩個事件時,會錯誤地推斷出 `x = 1` 是最近的值,而丟棄寫入 `x = 2`。效果上表現為,客戶端 B 的增量操作會丟失。
|
||||
|
||||
這種衝突解決策略被稱為**最後寫入勝利(LWW)**,它在多領導者複製和無領導者資料庫(如Cassandra 【53】和Riak 【54】)中被廣泛使用(請參閱“[最後寫入勝利(丟棄併發寫入)](ch5.md#最後寫入勝利(丟棄併發寫入))”一節)。有些實現會在客戶端而不是伺服器上生成時間戳,但這並不能改變LWW的基本問題:
|
||||
這種衝突解決策略被稱為 **最後寫入勝利(LWW)**,它在多領導者複製和無領導者資料庫(如 Cassandra 【53】和 Riak 【54】)中被廣泛使用(請參閱 “[最後寫入勝利(丟棄併發寫入)](ch5.md#最後寫入勝利(丟棄併發寫入))” 一節)。有些實現會在客戶端而不是伺服器上生成時間戳,但這並不能改變 LWW 的基本問題:
|
||||
|
||||
* 資料庫寫入可能會神祕地消失:具有滯後時鐘的節點無法覆蓋之前具有快速時鐘的節點寫入的值,直到節點之間的時鐘偏差消逝【54,55】。此方案可能導致一定數量的資料被悄悄丟棄,而未嚮應用報告任何錯誤。
|
||||
* LWW無法區分**高頻順序寫入**(在[圖8-3](../img/fig8-3.png)中,客戶端B的增量操作**一定**發生在客戶端A的寫入之後)和**真正併發寫入**(寫入者意識不到其他寫入者)。需要額外的因果關係跟蹤機制(例如版本向量),以防止違背因果關係(請參閱“[檢測併發寫入](ch5.md#檢測併發寫入)”)。
|
||||
* 兩個節點很可能獨立地生成具有相同時間戳的寫入,特別是在時鐘僅具有毫秒解析度的情況下。為了解決這樣的衝突,還需要一個額外的**決勝值**(tiebreaker,可以簡單地是一個大隨機數),但這種方法也可能會導致違背因果關係【53】。
|
||||
* LWW 無法區分 **高頻順序寫入**(在 [圖 8-3](../img/fig8-3.png) 中,客戶端 B 的增量操作 **一定** 發生在客戶端 A 的寫入之後)和 **真正併發寫入**(寫入者意識不到其他寫入者)。需要額外的因果關係跟蹤機制(例如版本向量),以防止違背因果關係(請參閱 “[檢測併發寫入](ch5.md#檢測併發寫入)”)。
|
||||
* 兩個節點很可能獨立地生成具有相同時間戳的寫入,特別是在時鐘僅具有毫秒解析度的情況下。為了解決這樣的衝突,還需要一個額外的 **決勝值**(tiebreaker,可以簡單地是一個大隨機數),但這種方法也可能會導致違背因果關係【53】。
|
||||
|
||||
因此,儘管透過保留最“最近”的值並放棄其他值來解決衝突是很誘惑人的,但是要注意,“最近”的定義取決於本地的**日曆時鐘**,這很可能是不正確的。即使用嚴格同步的NTP時鐘,一個數據包也可能在時間戳100毫秒(根據傳送者的時鐘)時傳送,並在時間戳99毫秒(根據接收者的時鐘)處到達——看起來好像資料包在傳送之前已經到達,這是不可能的。
|
||||
因此,儘管透過保留最 “最近” 的值並放棄其他值來解決衝突是很誘惑人的,但是要注意,“最近” 的定義取決於本地的 **日曆時鐘**,這很可能是不正確的。即使用嚴格同步的 NTP 時鐘,一個數據包也可能在時間戳 100 毫秒(根據傳送者的時鐘)時傳送,並在時間戳 99 毫秒(根據接收者的時鐘)處到達 —— 看起來好像資料包在傳送之前已經到達,這是不可能的。
|
||||
|
||||
NTP同步是否能足夠準確,以至於這種不正確的排序不會發生?也許不能,因為NTP的同步精度本身,除了石英鐘漂移這類誤差源之外,還受到網路往返時間的限制。為了進行正確的排序,你需要一個比測量物件(即網路延遲)要精確得多的時鐘。
|
||||
NTP 同步是否能足夠準確,以至於這種不正確的排序不會發生?也許不能,因為 NTP 的同步精度本身,除了石英鐘漂移這類誤差源之外,還受到網路往返時間的限制。為了進行正確的排序,你需要一個比測量物件(即網路延遲)要精確得多的時鐘。
|
||||
|
||||
所謂的**邏輯時鐘(logic clock)**【56,57】是基於遞增計數器而不是振盪石英晶體,對於排序事件來說是更安全的選擇(請參閱“[檢測併發寫入](ch5.md#檢測併發寫入)”)。邏輯時鐘不測量一天中的時間或經過的秒數,而僅測量事件的相對順序(無論一個事件發生在另一個事件之前還是之後)。相反,用來測量實際經過時間的**日曆時鐘**和**單調鍾**也被稱為**物理時鐘(physical clock)**。我們將在“[順序保證](ch9.md#順序保證)”中來看順序問題。
|
||||
所謂的 **邏輯時鐘(logic clock)**【56,57】是基於遞增計數器而不是振盪石英晶體,對於排序事件來說是更安全的選擇(請參閱 “[檢測併發寫入](ch5.md#檢測併發寫入)”)。邏輯時鐘不測量一天中的時間或經過的秒數,而僅測量事件的相對順序(無論一個事件發生在另一個事件之前還是之後)。相反,用來測量實際經過時間的 **日曆時鐘** 和 **單調鍾** 也被稱為 **物理時鐘(physical clock)**。我們將在 “[順序保證](ch9.md#順序保證)” 中來看順序問題。
|
||||
|
||||
#### 時鐘讀數存在置信區間
|
||||
|
||||
你可能能夠以微秒或甚至納秒的精度讀取機器的時鐘。但即使可以得到如此細緻的測量結果,這並不意味著這個值對於這樣的精度實際上是準確的。實際上,大概率是不準確的——如前所述,即使你每分鐘與本地網路上的NTP伺服器進行同步,幾毫秒的時間漂移也很容易在不精確的石英時鐘上發生。使用公共網際網路上的NTP伺服器,最好的準確度可能達到幾十毫秒,而且當網路擁塞時,誤差可能會超過100毫秒【57】。
|
||||
你可能能夠以微秒或甚至納秒的精度讀取機器的時鐘。但即使可以得到如此細緻的測量結果,這並不意味著這個值對於這樣的精度實際上是準確的。實際上,大概率是不準確的 —— 如前所述,即使你每分鐘與本地網路上的 NTP 伺服器進行同步,幾毫秒的時間漂移也很容易在不精確的石英時鐘上發生。使用公共網際網路上的 NTP 伺服器,最好的準確度可能達到幾十毫秒,而且當網路擁塞時,誤差可能會超過 100 毫秒【57】。
|
||||
|
||||
因此,將時鐘讀數視為一個時間點是沒有意義的——它更像是一段時間範圍:例如,一個系統可能以95%的置信度認為當前時間處於本分鐘內的第10.3秒和10.5秒之間,它可能沒法比這更精確了【58】。如果我們只知道±100毫秒的時間,那麼時間戳中的微秒數字部分基本上是沒有意義的。
|
||||
因此,將時鐘讀數視為一個時間點是沒有意義的 —— 它更像是一段時間範圍:例如,一個系統可能以 95% 的置信度認為當前時間處於本分鐘內的第 10.3 秒和 10.5 秒之間,它可能沒法比這更精確了【58】。如果我們只知道 ±100 毫秒的時間,那麼時間戳中的微秒數字部分基本上是沒有意義的。
|
||||
|
||||
不確定性界限可以根據你的時間源來計算。如果你的GPS接收器或原子(銫)時鐘直接連線到你的計算機上,預期的錯誤範圍由製造商告知。如果從伺服器獲得時間,則不確定性取決於自上次與伺服器同步以來的石英鐘漂移的期望值,加上NTP伺服器的不確定性,再加上到伺服器的網路往返時間(只是獲取粗略近似值,並假設伺服器是可信的)。
|
||||
不確定性界限可以根據你的時間源來計算。如果你的 GPS 接收器或原子(銫)時鐘直接連線到你的計算機上,預期的錯誤範圍由製造商告知。如果從伺服器獲得時間,則不確定性取決於自上次與伺服器同步以來的石英鐘漂移的期望值,加上 NTP 伺服器的不確定性,再加上到伺服器的網路往返時間(只是獲取粗略近似值,並假設伺服器是可信的)。
|
||||
|
||||
不幸的是,大多數系統不公開這種不確定性:例如,當呼叫`clock_gettime()`時,返回值不會告訴你時間戳的預期錯誤,所以你不知道其置信區間是5毫秒還是5年。
|
||||
不幸的是,大多數系統不公開這種不確定性:例如,當呼叫 `clock_gettime()` 時,返回值不會告訴你時間戳的預期錯誤,所以你不知道其置信區間是 5 毫秒還是 5 年。
|
||||
|
||||
一個有趣的例外是Spanner中的Google TrueTime API 【41】,它明確地報告了本地時鐘的置信區間。當你詢問當前時間時,你會得到兩個值:[最早,最晚],這是最早可能的時間戳和最晚可能的時間戳。在不確定性估計的基礎上,時鐘知道當前的實際時間落在該區間內。區間的寬度取決於自從本地石英鐘最後與更精確的時鐘源同步以來已經過了多長時間。
|
||||
一個有趣的例外是 Spanner 中的 Google TrueTime API 【41】,它明確地報告了本地時鐘的置信區間。當你詢問當前時間時,你會得到兩個值:[最早,最晚],這是最早可能的時間戳和最晚可能的時間戳。在不確定性估計的基礎上,時鐘知道當前的實際時間落在該區間內。區間的寬度取決於自從本地石英鐘最後與更精確的時鐘源同步以來已經過了多長時間。
|
||||
|
||||
#### 全域性快照的同步時鐘
|
||||
|
||||
在“[快照隔離和可重複讀](ch7.md#快照隔離和可重複讀)”中,我們討論了快照隔離,這是資料庫中非常有用的功能,需要支援小型快速讀寫事務和大型長時間執行的只讀事務(用於備份或分析)。它允許只讀事務看到特定時間點的處於一致狀態的資料庫,且不會鎖定和干擾讀寫事務。
|
||||
在 “[快照隔離和可重複讀](ch7.md#快照隔離和可重複讀)” 中,我們討論了快照隔離,這是資料庫中非常有用的功能,需要支援小型快速讀寫事務和大型長時間執行的只讀事務(用於備份或分析)。它允許只讀事務看到特定時間點的處於一致狀態的資料庫,且不會鎖定和干擾讀寫事務。
|
||||
|
||||
快照隔離最常見的實現需要單調遞增的事務ID。如果寫入比快照晚(即,寫入具有比快照更大的事務ID),則該寫入對於快照事務是不可見的。在單節點資料庫上,一個簡單的計數器就足以生成事務ID。
|
||||
快照隔離最常見的實現需要單調遞增的事務 ID。如果寫入比快照晚(即,寫入具有比快照更大的事務 ID),則該寫入對於快照事務是不可見的。在單節點資料庫上,一個簡單的計數器就足以生成事務 ID。
|
||||
|
||||
但是當資料庫分佈在許多機器上,也許可能在多個數據中心中時,由於需要協調,(跨所有分割槽)全域性單調遞增的事務ID會很難生成。事務ID必須反映因果關係:如果事務B讀取由事務A寫入的值,則B必須具有比A更大的事務ID,否則快照就無法保持一致。在有大量的小規模、高頻率的事務情景下,在分散式系統中建立事務ID成為一個難以處理的瓶頸[^vi]。
|
||||
但是當資料庫分佈在許多機器上,也許可能在多個數據中心中時,由於需要協調,(跨所有分割槽)全域性單調遞增的事務 ID 會很難生成。事務 ID 必須反映因果關係:如果事務 B 讀取由事務 A 寫入的值,則 B 必須具有比 A 更大的事務 ID,否則快照就無法保持一致。在有大量的小規模、高頻率的事務情景下,在分散式系統中建立事務 ID 成為一個難以處理的瓶頸 [^vi]。
|
||||
|
||||
[^vi]: 存在分散式序列號生成器,例如Twitter的雪花(Snowflake),其以可伸縮的方式(例如,透過將ID空間的塊分配給不同節點)近似單調地增加唯一ID。但是,它們通常無法保證與因果關係一致的排序,因為分配的ID塊的時間範圍比資料庫讀取和寫入的時間範圍要長。另請參閱“[順序保證](ch9.md#順序保證)”。
|
||||
[^vi]: 存在分散式序列號生成器,例如 Twitter 的雪花(Snowflake),其以可伸縮的方式(例如,透過將 ID 空間的塊分配給不同節點)近似單調地增加唯一 ID。但是,它們通常無法保證與因果關係一致的排序,因為分配的 ID 塊的時間範圍比資料庫讀取和寫入的時間範圍要長。另請參閱 “[順序保證](ch9.md#順序保證)”。
|
||||
|
||||
我們可以使用同步時鐘的時間戳作為事務ID嗎?如果我們能夠獲得足夠好的同步性,那麼這種方法將具有很合適的屬性:更晚的事務會有更大的時間戳。當然,問題在於時鐘精度的不確定性。
|
||||
我們可以使用同步時鐘的時間戳作為事務 ID 嗎?如果我們能夠獲得足夠好的同步性,那麼這種方法將具有很合適的屬性:更晚的事務會有更大的時間戳。當然,問題在於時鐘精度的不確定性。
|
||||
|
||||
Spanner以這種方式實現跨資料中心的快照隔離【59,60】。它使用TrueTime API報告的時鐘置信區間,並基於以下觀察結果:如果你有兩個置信區間,每個置信區間包含最早和最晚可能的時間戳( $A = [A_{earliest}, A_{latest}]$, $B=[B_{earliest}, B_{latest}]$),這兩個區間不重疊(即:$A_{earliest} < A_{latest} < B_{earliest} < B_{latest}$)的話,那麼B肯定發生在A之後——這是毫無疑問的。只有當區間重疊時,我們才不確定A和B發生的順序。
|
||||
Spanner 以這種方式實現跨資料中心的快照隔離【59,60】。它使用 TrueTime API 報告的時鐘置信區間,並基於以下觀察結果:如果你有兩個置信區間,每個置信區間包含最早和最晚可能的時間戳($A = [A_{earliest}, A_{latest}]$, $B=[B_{earliest}, B_{latest}]$),這兩個區間不重疊(即:$A_{earliest} <A_{latest} <B_{earliest} <B_{latest}$)的話,那麼 B 肯定發生在 A 之後 —— 這是毫無疑問的。只有當區間重疊時,我們才不確定 A 和 B 發生的順序。
|
||||
|
||||
為了確保事務時間戳反映因果關係,在提交讀寫事務之前,Spanner在提交讀寫事務時,會故意等待置信區間長度的時間。透過這樣,它可以確保任何可能讀取資料的事務處於足夠晚的時間,因此它們的置信區間不會重疊。為了保持儘可能短的等待時間,Spanner需要保持儘可能小的時鐘不確定性,為此,Google在每個資料中心都部署了一個GPS接收器或原子鐘,這允許時鐘同步到大約7毫秒以內【41】。
|
||||
為了確保事務時間戳反映因果關係,在提交讀寫事務之前,Spanner 在提交讀寫事務時,會故意等待置信區間長度的時間。透過這樣,它可以確保任何可能讀取資料的事務處於足夠晚的時間,因此它們的置信區間不會重疊。為了保持儘可能短的等待時間,Spanner 需要保持儘可能小的時鐘不確定性,為此,Google 在每個資料中心都部署了一個 GPS 接收器或原子鐘,這允許時鐘同步到大約 7 毫秒以內【41】。
|
||||
|
||||
對分散式事務語義使用時鐘同步是一個活躍的研究領域【57,61,62】。這些想法很有趣,但是它們還沒有在谷歌之外的主流資料庫中實現。
|
||||
|
||||
@ -369,7 +369,7 @@ Spanner以這種方式實現跨資料中心的快照隔離【59,60】。它使
|
||||
|
||||
讓我們考慮在分散式系統中使用危險時鐘的另一個例子。假設你有一個數據庫,每個分割槽只有一個領導者。只有領導被允許接受寫入。一個節點如何知道它仍然是領導者(它並沒有被別人宣告為死亡),並且它可以安全地接受寫入?
|
||||
|
||||
一種選擇是領導者從其他節點獲得一個**租約(lease)**,類似一個帶超時的鎖【63】。任一時刻只有一個節點可以持有租約——因此,當一個節點獲得一個租約時,它知道它在某段時間內自己是領導者,直到租約到期。為了保持領導地位,節點必須週期性地在租約過期前續期。
|
||||
一種選擇是領導者從其他節點獲得一個 **租約(lease)**,類似一個帶超時的鎖【63】。任一時刻只有一個節點可以持有租約 —— 因此,當一個節點獲得一個租約時,它知道它在某段時間內自己是領導者,直到租約到期。為了保持領導地位,節點必須週期性地在租約過期前續期。
|
||||
|
||||
如果節點發生故障,就會停止續期,所以當租約過期時,另一個節點可以接管。
|
||||
|
||||
@ -378,7 +378,7 @@ Spanner以這種方式實現跨資料中心的快照隔離【59,60】。它使
|
||||
```java
|
||||
while (true) {
|
||||
request = getIncomingRequest();
|
||||
// 確保租約還剩下至少10秒
|
||||
// 確保租約還剩下至少 10 秒
|
||||
if (lease.expiryTimeMillis - System.currentTimeMillis() < 10000){
|
||||
lease = lease.renew();
|
||||
}
|
||||
@ -389,23 +389,23 @@ while (true) {
|
||||
}
|
||||
```
|
||||
|
||||
這個程式碼有什麼問題?首先,它依賴於同步時鐘:租約到期時間由另一臺機器設定(例如,當前時間加上30秒,計算到期時間),並將其與本地系統時鐘進行比較。如果時鐘不同步超過幾秒,這段程式碼將開始做奇怪的事情。
|
||||
這個程式碼有什麼問題?首先,它依賴於同步時鐘:租約到期時間由另一臺機器設定(例如,當前時間加上 30 秒,計算到期時間),並將其與本地系統時鐘進行比較。如果時鐘不同步超過幾秒,這段程式碼將開始做奇怪的事情。
|
||||
|
||||
其次,即使我們將協議更改為僅使用本地單調時鐘,也存在另一個問題:程式碼假定在執行剩餘時間檢查`System.currentTimeMillis()`和實際執行請求`process(request)`中間的時間間隔非常短。通常情況下,這段程式碼執行得非常快,所以10秒的緩衝區已經足夠確保**租約**在請求處理到一半時不會過期。
|
||||
其次,即使我們將協議更改為僅使用本地單調時鐘,也存在另一個問題:程式碼假定在執行剩餘時間檢查 `System.currentTimeMillis()` 和實際執行請求 `process(request)` 中間的時間間隔非常短。通常情況下,這段程式碼執行得非常快,所以 10 秒的緩衝區已經足夠確保 **租約** 在請求處理到一半時不會過期。
|
||||
|
||||
但是,如果程式執行中出現了意外的停頓呢?例如,想象一下,執行緒在`lease.isValid()`行周圍停止15秒,然後才繼續。在這種情況下,在請求被處理的時候,租約可能已經過期,而另一個節點已經接管了領導。然而,沒有什麼可以告訴這個執行緒已經暫停了這麼長時間了,所以這段程式碼不會注意到租約已經到期了,直到迴圈的下一個迭代 ——到那個時候它可能已經做了一些不安全的處理請求。
|
||||
但是,如果程式執行中出現了意外的停頓呢?例如,想象一下,執行緒在 `lease.isValid()` 行周圍停止 15 秒,然後才繼續。在這種情況下,在請求被處理的時候,租約可能已經過期,而另一個節點已經接管了領導。然而,沒有什麼可以告訴這個執行緒已經暫停了這麼長時間了,所以這段程式碼不會注意到租約已經到期了,直到迴圈的下一個迭代 —— 到那個時候它可能已經做了一些不安全的處理請求。
|
||||
|
||||
假設一個執行緒可能會暫停很長時間,這是瘋了嗎?不幸的是,這種情況發生的原因有很多種:
|
||||
|
||||
* 許多程式語言執行時(如Java虛擬機器)都有一個垃圾收集器(GC),偶爾需要停止所有正在執行的執行緒。這些“**停止所有處理(stop-the-world)**”GC暫停有時會持續幾分鐘【64】!甚至像HotSpot JVM的CMS這樣的所謂的“並行”垃圾收集器也不能完全與應用程式程式碼並行執行,它需要不時地停止所有處理【65】。儘管通常可以透過改變分配模式或調整GC設定來減少暫停【66】,但是如果我們想要提供健壯的保證,就必須假設最壞的情況發生。
|
||||
* 在虛擬化環境中,可以**掛起(suspend)** 虛擬機器(暫停執行所有程序並將記憶體內容儲存到磁碟)並恢復(恢復記憶體內容並繼續執行)。這個暫停可以在程序執行的任何時候發生,並且可以持續任意長的時間。這個功能有時用於虛擬機器從一個主機到另一個主機的實時遷移,而不需要重新啟動,在這種情況下,暫停的長度取決於程序寫入記憶體的速率【67】。
|
||||
* 許多程式語言執行時(如 Java 虛擬機器)都有一個垃圾收集器(GC),偶爾需要停止所有正在執行的執行緒。這些 “**停止所有處理(stop-the-world)**”GC 暫停有時會持續幾分鐘【64】!甚至像 HotSpot JVM 的 CMS 這樣的所謂的 “並行” 垃圾收集器也不能完全與應用程式程式碼並行執行,它需要不時地停止所有處理【65】。儘管通常可以透過改變分配模式或調整 GC 設定來減少暫停【66】,但是如果我們想要提供健壯的保證,就必須假設最壞的情況發生。
|
||||
* 在虛擬化環境中,可以 **掛起(suspend)** 虛擬機器(暫停執行所有程序並將記憶體內容儲存到磁碟)並恢復(恢復記憶體內容並繼續執行)。這個暫停可以在程序執行的任何時候發生,並且可以持續任意長的時間。這個功能有時用於虛擬機器從一個主機到另一個主機的實時遷移,而不需要重新啟動,在這種情況下,暫停的長度取決於程序寫入記憶體的速率【67】。
|
||||
* 在終端使用者的裝置(如膝上型電腦)上,執行也可能被暫停並隨意恢復,例如當用戶關閉膝上型電腦的蓋子時。
|
||||
* 當作業系統上下文切換到另一個執行緒時,或者當管理程式切換到另一個虛擬機器時(在虛擬機器中執行時),當前正在執行的執行緒可能在程式碼中的任意點處暫停。在虛擬機器的情況下,在其他虛擬機器中花費的CPU時間被稱為**竊取時間(steal time)**。如果機器處於沉重的負載下(即,如果等待執行的執行緒佇列很長),暫停的執行緒再次執行可能需要一些時間。
|
||||
* 如果應用程式執行同步磁碟訪問,則執行緒可能暫停,等待緩慢的磁碟I/O操作完成【68】。在許多語言中,即使程式碼沒有包含檔案訪問,磁碟訪問也可能出乎意料地發生——例如,Java類載入器在第一次使用時惰性載入類檔案,這可能在程式執行過程中隨時發生。 I/O暫停和GC暫停甚至可能合謀組合它們的延遲【69】。如果磁碟實際上是一個網路檔案系統或網路塊裝置(如亞馬遜的EBS),I/O延遲進一步受到網路延遲變化的影響【29】。
|
||||
* 如果作業系統配置為允許交換到磁碟(頁面交換),則簡單的記憶體訪問可能導致**頁面錯誤(page fault)**,要求將磁碟中的頁面裝入記憶體。當這個緩慢的I/O操作發生時,執行緒暫停。如果記憶體壓力很高,則可能需要將另一個頁面換出到磁碟。在極端情況下,作業系統可能花費大部分時間將頁面交換到記憶體中,而實際上完成的工作很少(這被稱為**抖動**,即thrashing)。為了避免這個問題,通常在伺服器機器上禁用頁面排程(如果你寧願幹掉一個程序來釋放記憶體,也不願意冒抖動風險)。
|
||||
* 可以透過傳送SIGSTOP訊號來暫停Unix程序,例如透過在shell中按下Ctrl-Z。 這個訊號立即阻止程序繼續執行更多的CPU週期,直到SIGCONT恢復為止,此時它將繼續執行。 即使你的環境通常不使用SIGSTOP,也可能由運維工程師意外發送。
|
||||
* 當作業系統上下文切換到另一個執行緒時,或者當管理程式切換到另一個虛擬機器時(在虛擬機器中執行時),當前正在執行的執行緒可能在程式碼中的任意點處暫停。在虛擬機器的情況下,在其他虛擬機器中花費的 CPU 時間被稱為 **竊取時間(steal time)**。如果機器處於沉重的負載下(即,如果等待執行的執行緒佇列很長),暫停的執行緒再次執行可能需要一些時間。
|
||||
* 如果應用程式執行同步磁碟訪問,則執行緒可能暫停,等待緩慢的磁碟 I/O 操作完成【68】。在許多語言中,即使程式碼沒有包含檔案訪問,磁碟訪問也可能出乎意料地發生 —— 例如,Java 類載入器在第一次使用時惰性載入類檔案,這可能在程式執行過程中隨時發生。 I/O 暫停和 GC 暫停甚至可能合謀組合它們的延遲【69】。如果磁碟實際上是一個網路檔案系統或網路塊裝置(如亞馬遜的 EBS),I/O 延遲進一步受到網路延遲變化的影響【29】。
|
||||
* 如果作業系統配置為允許交換到磁碟(頁面交換),則簡單的記憶體訪問可能導致 **頁面錯誤(page fault)**,要求將磁碟中的頁面裝入記憶體。當這個緩慢的 I/O 操作發生時,執行緒暫停。如果記憶體壓力很高,則可能需要將另一個頁面換出到磁碟。在極端情況下,作業系統可能花費大部分時間將頁面交換到記憶體中,而實際上完成的工作很少(這被稱為 **抖動**,即 thrashing)。為了避免這個問題,通常在伺服器機器上禁用頁面排程(如果你寧願幹掉一個程序來釋放記憶體,也不願意冒抖動風險)。
|
||||
* 可以透過傳送 SIGSTOP 訊號來暫停 Unix 程序,例如透過在 shell 中按下 Ctrl-Z。 這個訊號立即阻止程序繼續執行更多的 CPU 週期,直到 SIGCONT 恢復為止,此時它將繼續執行。 即使你的環境通常不使用 SIGSTOP,也可能由運維工程師意外發送。
|
||||
|
||||
所有這些事件都可以隨時**搶佔(preempt)** 正在執行的執行緒,並在稍後的時間恢復執行,而執行緒甚至不會注意到這一點。這個問題類似於在單個機器上使多執行緒程式碼執行緒安全:你不能對時序做任何假設,因為隨時可能發生上下文切換,或者出現並行執行。
|
||||
所有這些事件都可以隨時 **搶佔(preempt)** 正在執行的執行緒,並在稍後的時間恢復執行,而執行緒甚至不會注意到這一點。這個問題類似於在單個機器上使多執行緒程式碼執行緒安全:你不能對時序做任何假設,因為隨時可能發生上下文切換,或者出現並行執行。
|
||||
|
||||
當在一臺機器上編寫多執行緒程式碼時,我們有相當好的工具來實現執行緒安全:互斥量,訊號量,原子計數器,無鎖資料結構,阻塞佇列等等。不幸的是,這些工具並不能直接轉化為分散式系統操作,因為分散式系統沒有共享記憶體,只有透過不可靠網路傳送的訊息。
|
||||
|
||||
@ -413,19 +413,19 @@ while (true) {
|
||||
|
||||
#### 響應時間保證
|
||||
|
||||
在許多程式語言和作業系統中,執行緒和程序可能暫停一段無限制的時間,正如討論的那樣。如果你足夠努力,導致暫停的原因是**可以**消除的。
|
||||
在許多程式語言和作業系統中,執行緒和程序可能暫停一段無限制的時間,正如討論的那樣。如果你足夠努力,導致暫停的原因是 **可以** 消除的。
|
||||
|
||||
某些軟體的執行環境要求很高,不能在特定時間內響應可能會導致嚴重的損失:控制飛機、火箭、機器人、汽車和其他物體的計算機必須對其感測器輸入做出快速而可預測的響應。在這些系統中,軟體必須有一個特定的**截止時間(deadline)**,如果截止時間不滿足,可能會導致整個系統的故障。這就是所謂的**硬實時(hard real-time)** 系統。
|
||||
某些軟體的執行環境要求很高,不能在特定時間內響應可能會導致嚴重的損失:控制飛機、火箭、機器人、汽車和其他物體的計算機必須對其感測器輸入做出快速而可預測的響應。在這些系統中,軟體必須有一個特定的 **截止時間(deadline)**,如果截止時間不滿足,可能會導致整個系統的故障。這就是所謂的 **硬實時(hard real-time)** 系統。
|
||||
|
||||
> #### 實時是真的嗎?
|
||||
>
|
||||
> 在嵌入式系統中,實時是指系統經過精心設計和測試,以滿足所有情況下的特定時間保證。這個含義與Web上對實時術語的模糊使用相反,後者描述了伺服器將資料推送到客戶端以及沒有嚴格的響應時間限制的流處理(見[第十一章](ch11.md))。
|
||||
> 在嵌入式系統中,實時是指系統經過精心設計和測試,以滿足所有情況下的特定時間保證。這個含義與 Web 上對實時術語的模糊使用相反,後者描述了伺服器將資料推送到客戶端以及沒有嚴格的響應時間限制的流處理(見 [第十一章](ch11.md))。
|
||||
|
||||
例如,如果車載感測器檢測到當前正在經歷碰撞,你肯定不希望安全氣囊釋放系統因為GC暫停而延遲彈出。
|
||||
例如,如果車載感測器檢測到當前正在經歷碰撞,你肯定不希望安全氣囊釋放系統因為 GC 暫停而延遲彈出。
|
||||
|
||||
在系統中提供**實時保證**需要各級軟體棧的支援:一個實時作業系統(RTOS),允許在指定的時間間隔內保證CPU時間的分配。庫函式必須申明最壞情況下的執行時間;動態記憶體分配可能受到限制或完全不允許(實時垃圾收集器存在,但是應用程式仍然必須確保它不會給GC太多的負擔);必須進行大量的測試和測量,以確保達到保證。
|
||||
在系統中提供 **實時保證** 需要各級軟體棧的支援:一個實時作業系統(RTOS),允許在指定的時間間隔內保證 CPU 時間的分配。庫函式必須申明最壞情況下的執行時間;動態記憶體分配可能受到限制或完全不允許(實時垃圾收集器存在,但是應用程式仍然必須確保它不會給 GC 太多的負擔);必須進行大量的測試和測量,以確保達到保證。
|
||||
|
||||
所有這些都需要大量額外的工作,嚴重限制了可以使用的程式語言、庫和工具的範圍(因為大多數語言和工具不提供實時保證)。由於這些原因,開發實時系統非常昂貴,並且它們通常用於安全關鍵的嵌入式裝置。而且,“**實時**”與“**高效能**”不一樣——事實上,實時系統可能具有較低的吞吐量,因為他們必須讓及時響應的優先順序高於一切(另請參閱“[延遲和資源利用](#延遲和資源利用)“)。
|
||||
所有這些都需要大量額外的工作,嚴重限制了可以使用的程式語言、庫和工具的範圍(因為大多數語言和工具不提供實時保證)。由於這些原因,開發實時系統非常昂貴,並且它們通常用於安全關鍵的嵌入式裝置。而且,“**實時**” 與 “**高效能**” 不一樣 —— 事實上,實時系統可能具有較低的吞吐量,因為他們必須讓及時響應的優先順序高於一切(另請參閱 “[延遲和資源利用](#延遲和資源利用)“)。
|
||||
|
||||
對於大多數伺服器端資料處理系統來說,實時保證是不經濟或不合適的。因此,這些系統必須承受在非實時環境中執行的暫停和時鐘不穩定性。
|
||||
|
||||
@ -433,9 +433,9 @@ while (true) {
|
||||
|
||||
程序暫停的負面影響可以在不訴諸昂貴的實時排程保證的情況下得到緩解。語言執行時在計劃垃圾回收時具有一定的靈活性,因為它們可以跟蹤物件分配的速度和隨著時間的推移剩餘的空閒記憶體。
|
||||
|
||||
一個新興的想法是將GC暫停視為一個節點的短暫計劃中斷,並在這個節點收集其垃圾的同時,讓其他節點處理來自客戶端的請求。如果執行時可以警告應用程式一個節點很快需要GC暫停,那麼應用程式可以停止向該節點發送新的請求,等待它完成處理未完成的請求,然後在沒有請求正在進行時執行GC。這個技巧向客戶端隱藏了GC暫停,並降低了響應時間的高百分比【70,71】。一些對延遲敏感的金融交易系統【72】使用這種方法。
|
||||
一個新興的想法是將 GC 暫停視為一個節點的短暫計劃中斷,並在這個節點收集其垃圾的同時,讓其他節點處理來自客戶端的請求。如果執行時可以警告應用程式一個節點很快需要 GC 暫停,那麼應用程式可以停止向該節點發送新的請求,等待它完成處理未完成的請求,然後在沒有請求正在進行時執行 GC。這個技巧向客戶端隱藏了 GC 暫停,並降低了響應時間的高百分比【70,71】。一些對延遲敏感的金融交易系統【72】使用這種方法。
|
||||
|
||||
這個想法的一個變種是隻用垃圾收集器來處理短命物件(這些物件可以快速收集),並定期在積累大量長壽物件(因此需要完整GC)之前重新啟動程序【65,73】。一次可以重新啟動一個節點,在計劃重新啟動之前,流量可以從該節點移開,就像[第四章](ch4.md)裡描述的滾動升級一樣。
|
||||
這個想法的一個變種是隻用垃圾收集器來處理短命物件(這些物件可以快速收集),並定期在積累大量長壽物件(因此需要完整 GC)之前重新啟動程序【65,73】。一次可以重新啟動一個節點,在計劃重新啟動之前,流量可以從該節點移開,就像 [第四章](ch4.md) 裡描述的滾動升級一樣。
|
||||
|
||||
這些措施不能完全阻止垃圾回收暫停,但可以有效地減少它們對應用的影響。
|
||||
|
||||
@ -444,63 +444,63 @@ while (true) {
|
||||
|
||||
本章到目前為止,我們已經探索了分散式系統與執行在單臺計算機上的程式的不同之處:沒有共享記憶體,只有透過可變延遲的不可靠網路傳遞的訊息,系統可能遭受部分失效,不可靠的時鐘和處理暫停。
|
||||
|
||||
如果你不習慣於分散式系統,那麼這些問題的後果就會讓人迷惑不解。網路中的一個節點無法確切地知道任何事情——它只能根據它透過網路接收到(或沒有接收到)的訊息進行猜測。節點只能透過交換訊息來找出另一個節點所處的狀態(儲存了哪些資料,是否正確執行等等)。如果遠端節點沒有響應,則無法知道它處於什麼狀態,因為網路中的問題不能可靠地與節點上的問題區分開來。
|
||||
如果你不習慣於分散式系統,那麼這些問題的後果就會讓人迷惑不解。網路中的一個節點無法確切地知道任何事情 —— 它只能根據它透過網路接收到(或沒有接收到)的訊息進行猜測。節點只能透過交換訊息來找出另一個節點所處的狀態(儲存了哪些資料,是否正確執行等等)。如果遠端節點沒有響應,則無法知道它處於什麼狀態,因為網路中的問題不能可靠地與節點上的問題區分開來。
|
||||
|
||||
這些系統的討論與哲學有關:在系統中什麼是真什麼是假?如果感知和測量的機制都是不可靠的,那麼關於這些知識我們又能多麼確定呢?軟體系統應該遵循我們對物理世界所期望的法則,如因果關係嗎?
|
||||
|
||||
幸運的是,我們不需要去搞清楚生命的意義。在分散式系統中,我們可以陳述關於行為(系統模型)的假設,並以滿足這些假設的方式設計實際系統。演算法可以被證明在某個系統模型中正確執行。這意味著即使底層系統模型提供了很少的保證,也可以實現可靠的行為。
|
||||
|
||||
但是,儘管可以使軟體在不可靠的系統模型中表現良好,但這並不是可以直截了當實現的。在本章的其餘部分中,我們將進一步探討分散式系統中的知識和真相的概念,這將有助於我們思考我們可以做出的各種假設以及我們可能希望提供的保證。在[第九章](ch9.md)中,我們將著眼於分散式系統的一些例子,這些演算法在特定的假設條件下提供了特定的保證。
|
||||
但是,儘管可以使軟體在不可靠的系統模型中表現良好,但這並不是可以直截了當實現的。在本章的其餘部分中,我們將進一步探討分散式系統中的知識和真相的概念,這將有助於我們思考我們可以做出的各種假設以及我們可能希望提供的保證。在 [第九章](ch9.md) 中,我們將著眼於分散式系統的一些例子,這些演算法在特定的假設條件下提供了特定的保證。
|
||||
|
||||
### 真相由多數所定義
|
||||
|
||||
設想一個具有不對稱故障的網路:一個節點能夠接收發送給它的所有訊息,但是來自該節點的任何傳出訊息被丟棄或延遲【19】。即使該節點執行良好,並且正在接收來自其他節點的請求,其他節點也無法聽到其響應。經過一段時間後,其他節點宣佈它已經死亡,因為他們沒有聽到節點的訊息。這種情況就像夢魘一樣:**半斷開(semi-disconnected)** 的節點被拖向墓地,敲打尖叫道“我沒死!” ——但是由於沒有人能聽到它的尖叫,葬禮隊伍繼續以堅忍的決心繼續行進。
|
||||
設想一個具有不對稱故障的網路:一個節點能夠接收發送給它的所有訊息,但是來自該節點的任何傳出訊息被丟棄或延遲【19】。即使該節點執行良好,並且正在接收來自其他節點的請求,其他節點也無法聽到其響應。經過一段時間後,其他節點宣佈它已經死亡,因為他們沒有聽到節點的訊息。這種情況就像夢魘一樣:**半斷開(semi-disconnected)** 的節點被拖向墓地,敲打尖叫道 “我沒死!” —— 但是由於沒有人能聽到它的尖叫,葬禮隊伍繼續以堅忍的決心繼續行進。
|
||||
|
||||
在一個稍微不那麼夢魘的場景中,半斷開的節點可能會注意到它傳送的訊息沒有被其他節點確認,因此意識到網路中必定存在故障。儘管如此,節點被其他節點錯誤地宣告為死亡,而半連線的節點對此無能為力。
|
||||
|
||||
第三種情況,想象一個經歷了一個長時間**停止所有處理垃圾收集暫停(stop-the-world GC Pause)** 的節點。節點的所有執行緒被GC搶佔並暫停一分鐘,因此沒有請求被處理,也沒有響應被傳送。其他節點等待,重試,不耐煩,並最終宣佈節點死亡,並將其丟到靈車上。最後,GC完成,節點的執行緒繼續,好像什麼也沒有發生。其他節點感到驚訝,因為所謂的死亡節點突然從棺材中抬起頭來,身體健康,開始和旁觀者高興地聊天。GC後的節點最初甚至沒有意識到已經經過了整整一分鐘,而且自己已被宣告死亡。從它自己的角度來看,從最後一次與其他節點交談以來,幾乎沒有經過任何時間。
|
||||
第三種情況,想象一個經歷了一個長時間 **停止所有處理垃圾收集暫停(stop-the-world GC Pause)** 的節點。節點的所有執行緒被 GC 搶佔並暫停一分鐘,因此沒有請求被處理,也沒有響應被傳送。其他節點等待,重試,不耐煩,並最終宣佈節點死亡,並將其丟到靈車上。最後,GC 完成,節點的執行緒繼續,好像什麼也沒有發生。其他節點感到驚訝,因為所謂的死亡節點突然從棺材中抬起頭來,身體健康,開始和旁觀者高興地聊天。GC 後的節點最初甚至沒有意識到已經經過了整整一分鐘,而且自己已被宣告死亡。從它自己的角度來看,從最後一次與其他節點交談以來,幾乎沒有經過任何時間。
|
||||
|
||||
這些故事的寓意是,節點不一定能相信自己對於情況的判斷。分散式系統不能完全依賴單個節點,因為節點可能隨時失效,可能會使系統卡死,無法恢復。相反,許多分散式演算法都依賴於法定人數,即在節點之間進行投票(請參閱“[讀寫的法定人數](ch5.md#讀寫的法定人數)“):決策需要來自多個節點的最小投票數,以減少對於某個特定節點的依賴。
|
||||
這些故事的寓意是,節點不一定能相信自己對於情況的判斷。分散式系統不能完全依賴單個節點,因為節點可能隨時失效,可能會使系統卡死,無法恢復。相反,許多分散式演算法都依賴於法定人數,即在節點之間進行投票(請參閱 “[讀寫的法定人數](ch5.md#讀寫的法定人數)“):決策需要來自多個節點的最小投票數,以減少對於某個特定節點的依賴。
|
||||
|
||||
這也包括關於宣告節點死亡的決定。如果法定數量的節點宣告另一個節點已經死亡,那麼即使該節點仍感覺自己活著,它也必須被認為是死的。個體節點必須遵守法定決定並下臺。
|
||||
|
||||
最常見的法定人數是超過一半的絕對多數(儘管其他型別的法定人數也是可能的)。多數法定人數允許系統繼續工作,如果單個節點發生故障(三個節點可以容忍單節點故障;五個節點可以容忍雙節點故障)。系統仍然是安全的,因為在這個制度中只能有一個多數——不能同時存在兩個相互衝突的多數決定。當我們在[第九章](ch9.md)中討論**共識演算法(consensus algorithms)** 時,我們將更詳細地討論法定人數的應用。
|
||||
最常見的法定人數是超過一半的絕對多數(儘管其他型別的法定人數也是可能的)。多數法定人數允許系統繼續工作,如果單個節點發生故障(三個節點可以容忍單節點故障;五個節點可以容忍雙節點故障)。系統仍然是安全的,因為在這個制度中只能有一個多數 —— 不能同時存在兩個相互衝突的多數決定。當我們在 [第九章](ch9.md) 中討論 **共識演算法(consensus algorithms)** 時,我們將更詳細地討論法定人數的應用。
|
||||
|
||||
#### 領導者和鎖
|
||||
|
||||
通常情況下,一些東西在一個系統中只能有一個。例如:
|
||||
|
||||
* 資料庫分割槽的領導者只能有一個節點,以避免**腦裂**(即split brain,請參閱“[處理節點宕機](ch5.md#處理節點宕機)”)。
|
||||
* 特定資源的鎖或物件只允許一個事務/客戶端持有,以防同時寫入和損壞。
|
||||
* 資料庫分割槽的領導者只能有一個節點,以避免 **腦裂**(即 split brain,請參閱 “[處理節點宕機](ch5.md#處理節點宕機)”)。
|
||||
* 特定資源的鎖或物件只允許一個事務 / 客戶端持有,以防同時寫入和損壞。
|
||||
* 一個特定的使用者名稱只能被一個使用者所註冊,因為使用者名稱必須唯一標識一個使用者。
|
||||
|
||||
在分散式系統中實現這一點需要注意:即使一個節點認為它是“**天選者(the choosen one)**”(分割槽的負責人,鎖的持有者,成功獲取使用者名稱的使用者的請求處理程式),但這並不一定意味著有法定人數的節點同意!一個節點可能以前是領導者,但是如果其他節點在此期間宣佈它死亡(例如,由於網路中斷或GC暫停),則它可能已被降級,且另一個領導者可能已經當選。
|
||||
在分散式系統中實現這一點需要注意:即使一個節點認為它是 “**天選者(the choosen one)**”(分割槽的負責人,鎖的持有者,成功獲取使用者名稱的使用者的請求處理程式),但這並不一定意味著有法定人數的節點同意!一個節點可能以前是領導者,但是如果其他節點在此期間宣佈它死亡(例如,由於網路中斷或 GC 暫停),則它可能已被降級,且另一個領導者可能已經當選。
|
||||
|
||||
如果一個節點繼續表現為**天選者**,即使大多數節點已經宣告它已經死了,則在考慮不周的系統中可能會導致問題。這樣的節點能以自己賦予的權能向其他節點發送訊息,如果其他節點相信,整個系統可能會做一些不正確的事情。
|
||||
如果一個節點繼續表現為 **天選者**,即使大多數節點已經宣告它已經死了,則在考慮不周的系統中可能會導致問題。這樣的節點能以自己賦予的權能向其他節點發送訊息,如果其他節點相信,整個系統可能會做一些不正確的事情。
|
||||
|
||||
例如,[圖8-4](../img/fig8-4.png)顯示了由於不正確的鎖實現導致的資料損壞錯誤。 (這個錯誤不僅僅是理論上的:HBase曾經有這個問題【74,75】)假設你要確保一個儲存服務中的檔案一次只能被一個客戶訪問,因為如果多個客戶試圖對此寫入,該檔案將被損壞。你嘗試透過在訪問檔案之前要求客戶端從鎖定服務獲取租約來實現此目的。
|
||||
例如,[圖 8-4](../img/fig8-4.png) 顯示了由於不正確的鎖實現導致的資料損壞錯誤。 (這個錯誤不僅僅是理論上的:HBase 曾經有這個問題【74,75】)假設你要確保一個儲存服務中的檔案一次只能被一個客戶訪問,因為如果多個客戶試圖對此寫入,該檔案將被損壞。你嘗試透過在訪問檔案之前要求客戶端從鎖定服務獲取租約來實現此目的。
|
||||
|
||||
![](../img/fig8-4.png)
|
||||
|
||||
**圖8-4 分散式鎖的實現不正確:客戶端1認為它仍然具有有效的租約,即使它已經過期,從而破壞了儲存中的檔案**
|
||||
**圖 8-4 分散式鎖的實現不正確:客戶端 1 認為它仍然具有有效的租約,即使它已經過期,從而破壞了儲存中的檔案**
|
||||
|
||||
這個問題就是我們先前在“[程序暫停](#程序暫停)”中討論過的一個例子:如果持有租約的客戶端暫停太久,它的租約將到期。另一個客戶端可以獲得同一檔案的租約,並開始寫入檔案。當暫停的客戶端回來時,它認為(不正確)它仍然有一個有效的租約,並繼續寫入檔案。結果,客戶的寫入將產生衝突並損壞檔案。
|
||||
這個問題就是我們先前在 “[程序暫停](#程序暫停)” 中討論過的一個例子:如果持有租約的客戶端暫停太久,它的租約將到期。另一個客戶端可以獲得同一檔案的租約,並開始寫入檔案。當暫停的客戶端回來時,它認為(不正確)它仍然有一個有效的租約,並繼續寫入檔案。結果,客戶的寫入將產生衝突並損壞檔案。
|
||||
|
||||
#### 防護令牌
|
||||
|
||||
當使用鎖或租約來保護對某些資源(如[圖8-4](../img/fig8-4.png)中的檔案儲存)的訪問時,需要確保一個被誤認為自己是“天選者”的節點不能擾亂系統的其它部分。實現這一目標的一個相當簡單的技術就是**防護(fencing)**,如[圖8-5](../img/fig8-5.png)所示
|
||||
當使用鎖或租約來保護對某些資源(如 [圖 8-4](../img/fig8-4.png) 中的檔案儲存)的訪問時,需要確保一個被誤認為自己是 “天選者” 的節點不能擾亂系統的其它部分。實現這一目標的一個相當簡單的技術就是 **防護(fencing)**,如 [圖 8-5](../img/fig8-5.png) 所示
|
||||
|
||||
![](../img/fig8-5.png)
|
||||
|
||||
**圖8-5 只允許以增加防護令牌的順序進行寫操作,從而保證儲存安全**
|
||||
**圖 8-5 只允許以增加防護令牌的順序進行寫操作,從而保證儲存安全**
|
||||
|
||||
我們假設每次鎖定伺服器授予鎖或租約時,它還會返回一個**防護令牌(fencing token)**,這個數字在每次授予鎖定時都會增加(例如,由鎖定服務增加)。然後,我們可以要求客戶端每次向儲存服務傳送寫入請求時,都必須包含當前的防護令牌。
|
||||
我們假設每次鎖定伺服器授予鎖或租約時,它還會返回一個 **防護令牌(fencing token)**,這個數字在每次授予鎖定時都會增加(例如,由鎖定服務增加)。然後,我們可以要求客戶端每次向儲存服務傳送寫入請求時,都必須包含當前的防護令牌。
|
||||
|
||||
在[圖8-5](../img/fig8-5.png)中,客戶端1以33的令牌獲得租約,但隨後進入一個長時間的停頓並且租約到期。客戶端2以34的令牌(該數字總是增加)獲取租約,然後將其寫入請求傳送到儲存服務,包括34的令牌。稍後,客戶端1恢復生機並將其寫入儲存服務,包括其令牌值33。但是,儲存伺服器會記住它已經處理了一個具有更高令牌編號(34)的寫入,因此它會拒絕帶有令牌33的請求。
|
||||
在 [圖 8-5](../img/fig8-5.png) 中,客戶端 1 以 33 的令牌獲得租約,但隨後進入一個長時間的停頓並且租約到期。客戶端 2 以 34 的令牌(該數字總是增加)獲取租約,然後將其寫入請求傳送到儲存服務,包括 34 的令牌。稍後,客戶端 1 恢復生機並將其寫入儲存服務,包括其令牌值 33。但是,儲存伺服器會記住它已經處理了一個具有更高令牌編號(34)的寫入,因此它會拒絕帶有令牌 33 的請求。
|
||||
|
||||
如果將ZooKeeper用作鎖定服務,則可將事務標識`zxid`或節點版本`cversion`用作防護令牌。由於它們保證單調遞增,因此它們具有所需的屬性【74】。
|
||||
如果將 ZooKeeper 用作鎖定服務,則可將事務標識 `zxid` 或節點版本 `cversion` 用作防護令牌。由於它們保證單調遞增,因此它們具有所需的屬性【74】。
|
||||
|
||||
請注意,這種機制要求資源本身在檢查令牌方面發揮積極作用,透過拒絕使用舊的令牌,而不是已經被處理的令牌來進行寫操作——僅僅依靠客戶端檢查自己的鎖狀態是不夠的。對於不明確支援防護令牌的資源,可能仍然可以解決此限制(例如,在檔案儲存服務的情況下,可以將防護令牌包含在檔名中)。但是,為了避免在鎖的保護之外處理請求,需要進行某種檢查。
|
||||
請注意,這種機制要求資源本身在檢查令牌方面發揮積極作用,透過拒絕使用舊的令牌,而不是已經被處理的令牌來進行寫操作 —— 僅僅依靠客戶端檢查自己的鎖狀態是不夠的。對於不明確支援防護令牌的資源,可能仍然可以解決此限制(例如,在檔案儲存服務的情況下,可以將防護令牌包含在檔名中)。但是,為了避免在鎖的保護之外處理請求,需要進行某種檢查。
|
||||
|
||||
在伺服器端檢查一個令牌可能看起來像是一個缺點,但這可以說是一件好事:一個服務假定它的客戶總是守規矩並不明智,因為使用客戶端的人與執行服務的人優先順序非常不一樣【76】。因此,任何服務保護自己免受意外客戶的濫用是一個好主意。
|
||||
|
||||
@ -508,42 +508,42 @@ while (true) {
|
||||
|
||||
防護令牌可以檢測和阻止無意中發生錯誤的節點(例如,因為它尚未發現其租約已過期)。但是,如果節點有意破壞系統的保證,則可以透過使用假防護令牌傳送訊息來輕鬆完成此操作。
|
||||
|
||||
在本書中,我們假設節點是不可靠但誠實的:它們可能很慢或者從不響應(由於故障),並且它們的狀態可能已經過時(由於GC暫停或網路延遲),但是我們假設如果節點它做出了迴應,它正在說出“真相”:盡其所知,它正在按照協議的規則扮演其角色。
|
||||
在本書中,我們假設節點是不可靠但誠實的:它們可能很慢或者從不響應(由於故障),並且它們的狀態可能已經過時(由於 GC 暫停或網路延遲),但是我們假設如果節點它做出了迴應,它正在說出 “真相”:盡其所知,它正在按照協議的規則扮演其角色。
|
||||
|
||||
如果存在節點可能“撒謊”(傳送任意錯誤或損壞的響應)的風險,則分散式系統的問題變得更困難了——例如,如果節點可能聲稱其實際上沒有收到特定的訊息。這種行為被稱為**拜占庭故障(Byzantine fault)**,**在不信任的環境中達成共識的問題被稱為拜占庭將軍問題**【77】。
|
||||
如果存在節點可能 “撒謊”(傳送任意錯誤或損壞的響應)的風險,則分散式系統的問題變得更困難了 —— 例如,如果節點可能聲稱其實際上沒有收到特定的訊息。這種行為被稱為 **拜占庭故障(Byzantine fault)**,**在不信任的環境中達成共識的問題被稱為拜占庭將軍問題**【77】。
|
||||
|
||||
> ### 拜占庭將軍問題
|
||||
>
|
||||
> 拜占庭將軍問題是對所謂“兩將軍問題”的泛化【78】,它想象兩個將軍需要就戰鬥計劃達成一致的情況。由於他們在兩個不同的地點建立了營地,他們只能透過信使進行溝通,信使有時會被延遲或丟失(就像網路中的資訊包一樣)。我們將在[第九章](ch9.md)討論這個共識問題。
|
||||
> 拜占庭將軍問題是對所謂 “兩將軍問題” 的泛化【78】,它想象兩個將軍需要就戰鬥計劃達成一致的情況。由於他們在兩個不同的地點建立了營地,他們只能透過信使進行溝通,信使有時會被延遲或丟失(就像網路中的資訊包一樣)。我們將在 [第九章](ch9.md) 討論這個共識問題。
|
||||
>
|
||||
> 在這個問題的拜占庭版本里,有n位將軍需要同意,他們的努力因為有一些叛徒在他們中間而受到阻礙。大多數的將軍都是忠誠的,因而發出了真實的資訊,但是叛徒可能會試圖透過傳送虛假或不真實的資訊來欺騙和混淆他人(在試圖保持未被發現的同時)。事先並不知道叛徒是誰。
|
||||
> 在這個問題的拜占庭版本里,有 n 位將軍需要同意,他們的努力因為有一些叛徒在他們中間而受到阻礙。大多數的將軍都是忠誠的,因而發出了真實的資訊,但是叛徒可能會試圖透過傳送虛假或不真實的資訊來欺騙和混淆他人(在試圖保持未被發現的同時)。事先並不知道叛徒是誰。
|
||||
>
|
||||
> 拜占庭是後來成為君士坦丁堡的古希臘城市,現在在土耳其的伊斯坦布林。沒有任何歷史證據表明拜占庭將軍比其他地方更容易出現陰謀和陰謀。相反,這個名字來源於拜占庭式的過度複雜,官僚,迂迴等意義,早在計算機之前就已經在政治中被使用了【79】。Lamport想要選一個不會冒犯任何讀者的國家,他被告知將其稱為阿爾巴尼亞將軍問題並不是一個好主意【80】。
|
||||
> 拜占庭是後來成為君士坦丁堡的古希臘城市,現在在土耳其的伊斯坦布林。沒有任何歷史證據表明拜占庭將軍比其他地方更容易出現陰謀和陰謀。相反,這個名字來源於拜占庭式的過度複雜,官僚,迂迴等意義,早在計算機之前就已經在政治中被使用了【79】。Lamport 想要選一個不會冒犯任何讀者的國家,他被告知將其稱為阿爾巴尼亞將軍問題並不是一個好主意【80】。
|
||||
|
||||
當一個系統在部分節點發生故障、不遵守協議、甚至惡意攻擊、擾亂網路時仍然能繼續正確工作,稱之為**拜占庭容錯(Byzantine fault-tolerant)** 的,在特定場景下,這種擔憂在是有意義的:
|
||||
當一個系統在部分節點發生故障、不遵守協議、甚至惡意攻擊、擾亂網路時仍然能繼續正確工作,稱之為 **拜占庭容錯(Byzantine fault-tolerant)** 的,在特定場景下,這種擔憂在是有意義的:
|
||||
|
||||
* 在航空航天環境中,計算機記憶體或CPU暫存器中的資料可能被輻射破壞,導致其以任意不可預知的方式響應其他節點。由於系統故障非常昂貴(例如,飛機撞毀和炸死船上所有人員,或火箭與國際空間站相撞),飛行控制系統必須容忍拜占庭故障【81,82】。
|
||||
* 在航空航天環境中,計算機記憶體或 CPU 暫存器中的資料可能被輻射破壞,導致其以任意不可預知的方式響應其他節點。由於系統故障非常昂貴(例如,飛機撞毀和炸死船上所有人員,或火箭與國際空間站相撞),飛行控制系統必須容忍拜占庭故障【81,82】。
|
||||
* 在多個參與組織的系統中,一些參與者可能會試圖欺騙或欺騙他人。在這種情況下,節點僅僅信任另一個節點的訊息是不安全的,因為它們可能是出於惡意的目的而被傳送的。例如,像比特幣和其他區塊鏈一樣的對等網路可以被認為是讓互不信任的各方同意交易是否發生的一種方式,而不依賴於中心機構(central authority)【83】。
|
||||
|
||||
然而,在本書討論的那些系統中,我們通常可以安全地假設沒有拜占庭式的錯誤。在你的資料中心裡,所有的節點都是由你的組織控制的(所以他們可以信任),輻射水平足夠低,記憶體損壞不是一個大問題。製作拜占庭容錯系統的協議相當複雜【84】,而容錯嵌入式系統依賴於硬體層面的支援【81】。在大多數伺服器端資料系統中,部署拜占庭容錯解決方案的成本使其變得不切實際。
|
||||
|
||||
Web應用程式確實需要預期受終端使用者控制的客戶端(如Web瀏覽器)的任意和惡意行為。這就是為什麼輸入驗證,資料清洗和輸出轉義如此重要:例如,防止SQL注入和跨站點指令碼。然而,我們通常不在這裡使用拜占庭容錯協議,而只是讓伺服器有權決定是否允許客戶端行為。但在沒有這種中心機構的對等網路中,拜占庭容錯更為重要。
|
||||
Web 應用程式確實需要預期受終端使用者控制的客戶端(如 Web 瀏覽器)的任意和惡意行為。這就是為什麼輸入驗證,資料清洗和輸出轉義如此重要:例如,防止 SQL 注入和跨站點指令碼。然而,我們通常不在這裡使用拜占庭容錯協議,而只是讓伺服器有權決定是否允許客戶端行為。但在沒有這種中心機構的對等網路中,拜占庭容錯更為重要。
|
||||
|
||||
軟體中的一個錯誤(bug)可能被認為是拜占庭式的錯誤,但是如果你將相同的軟體部署到所有節點上,那麼拜占庭式的容錯演算法幫不到你。大多數拜占庭式容錯演算法要求超過三分之二的節點能夠正常工作(即,如果有四個節點,最多隻能有一個故障)。要使用這種方法對付bug,你必須有四個獨立的相同軟體的實現,並希望一個bug只出現在四個實現之一中。
|
||||
軟體中的一個錯誤(bug)可能被認為是拜占庭式的錯誤,但是如果你將相同的軟體部署到所有節點上,那麼拜占庭式的容錯演算法幫不到你。大多數拜占庭式容錯演算法要求超過三分之二的節點能夠正常工作(即,如果有四個節點,最多隻能有一個故障)。要使用這種方法對付 bug,你必須有四個獨立的相同軟體的實現,並希望一個 bug 只出現在四個實現之一中。
|
||||
|
||||
同樣,如果一個協議可以保護我們免受漏洞,安全滲透和惡意攻擊,那麼這將是有吸引力的。不幸的是,這也是不現實的:在大多數系統中,如果攻擊者可以滲透一個節點,那他們可能會滲透所有這些節點,因為它們可能都執行著相同的軟體。因此,傳統機制(認證,訪問控制,加密,防火牆等)仍然是抵禦攻擊者的主要保護措施。
|
||||
|
||||
#### 弱謊言形式
|
||||
|
||||
儘管我們假設節點通常是誠實的,但值得向軟體中新增防止“撒謊”弱形式的機制——例如,由硬體問題導致的無效訊息,軟體錯誤和錯誤配置。這種保護機制並不是完全的拜占庭容錯,因為它們不能抵擋決心堅定的對手,但它們仍然是簡單而實用的步驟,以提高可靠性。例如:
|
||||
儘管我們假設節點通常是誠實的,但值得向軟體中新增防止 “撒謊” 弱形式的機制 —— 例如,由硬體問題導致的無效訊息,軟體錯誤和錯誤配置。這種保護機制並不是完全的拜占庭容錯,因為它們不能抵擋決心堅定的對手,但它們仍然是簡單而實用的步驟,以提高可靠性。例如:
|
||||
|
||||
* 由於硬體問題或作業系統、驅動程式、路由器等中的錯誤,網路資料包有時會受到損壞。通常,損壞的資料包會被內建於TCP和UDP中的校驗和所俘獲,但有時它們也會逃脫檢測【85,86,87】 。要對付這種破壞通常使用簡單的方法就可以做到,例如應用程式級協議中的校驗和。
|
||||
* 由於硬體問題或作業系統、驅動程式、路由器等中的錯誤,網路資料包有時會受到損壞。通常,損壞的資料包會被內建於 TCP 和 UDP 中的校驗和所俘獲,但有時它們也會逃脫檢測【85,86,87】 。要對付這種破壞通常使用簡單的方法就可以做到,例如應用程式級協議中的校驗和。
|
||||
* 可公開訪問的應用程式必須仔細清理來自使用者的任何輸入,例如檢查值是否在合理的範圍內,並限制字串的大小以防止透過大記憶體分配的拒絕服務。防火牆後面的內部服務對於輸入也許可以只採取一些不那麼嚴格的檢查,但是採取一些基本的合理性檢查(例如,在協議解析中)仍然是一個好主意。
|
||||
* NTP客戶端可以配置多個伺服器地址。同步時,客戶端聯絡所有的伺服器,估計它們的誤差,並檢查大多數伺服器是否對某個時間範圍達成一致。只要大多數的伺服器沒問題,一個配置錯誤的NTP伺服器報告的時間會被當成特異值從同步中排除【37】。使用多個伺服器使NTP更健壯(比起只用單個伺服器來)。
|
||||
* NTP 客戶端可以配置多個伺服器地址。同步時,客戶端聯絡所有的伺服器,估計它們的誤差,並檢查大多數伺服器是否對某個時間範圍達成一致。只要大多數的伺服器沒問題,一個配置錯誤的 NTP 伺服器報告的時間會被當成特異值從同步中排除【37】。使用多個伺服器使 NTP 更健壯(比起只用單個伺服器來)。
|
||||
|
||||
### 系統模型與現實
|
||||
|
||||
已經有很多演算法被設計以解決分散式系統問題——例如,我們將在[第九章](ch9.md)討論共識問題的解決方案。為了有用,這些演算法需要容忍我們在本章中討論的分散式系統的各種故障。
|
||||
已經有很多演算法被設計以解決分散式系統問題 —— 例如,我們將在 [第九章](ch9.md) 討論共識問題的解決方案。為了有用,這些演算法需要容忍我們在本章中討論的分散式系統的各種故障。
|
||||
|
||||
演算法的編寫方式不應該過分依賴於執行的硬體和軟體配置的細節。這就要求我們以某種方式將我們期望在系統中發生的錯誤形式化。我們透過定義一個系統模型來做到這一點,這個模型是一個抽象,描述一個演算法可以假設的事情。
|
||||
|
||||
@ -559,30 +559,30 @@ Web應用程式確實需要預期受終端使用者控制的客戶端(如Web
|
||||
|
||||
* 非同步模型
|
||||
|
||||
在這個模型中,一個演算法不允許對時序做任何假設——事實上它甚至沒有時鐘(所以它不能使用超時)。一些演算法被設計為可用於非同步模型,但非常受限。
|
||||
在這個模型中,一個演算法不允許對時序做任何假設 —— 事實上它甚至沒有時鐘(所以它不能使用超時)。一些演算法被設計為可用於非同步模型,但非常受限。
|
||||
|
||||
|
||||
進一步來說,除了時序問題,我們還要考慮**節點失效**。三種最常見的節點系統模型是:
|
||||
進一步來說,除了時序問題,我們還要考慮 **節點失效**。三種最常見的節點系統模型是:
|
||||
|
||||
* 崩潰-停止故障
|
||||
* 崩潰 - 停止故障
|
||||
|
||||
在**崩潰停止(crash-stop)** 模型中,演算法可能會假設一個節點只能以一種方式失效,即透過崩潰。這意味著節點可能在任意時刻突然停止響應,此後該節點永遠消失——它永遠不會回來。
|
||||
在 **崩潰停止(crash-stop)** 模型中,演算法可能會假設一個節點只能以一種方式失效,即透過崩潰。這意味著節點可能在任意時刻突然停止響應,此後該節點永遠消失 —— 它永遠不會回來。
|
||||
|
||||
* 崩潰-恢復故障
|
||||
* 崩潰 - 恢復故障
|
||||
|
||||
我們假設節點可能會在任何時候崩潰,但也許會在未知的時間之後再次開始響應。在**崩潰-恢復(crash-recovery)** 模型中,假設節點具有穩定的儲存(即,非易失性磁碟儲存)且會在崩潰中保留,而記憶體中的狀態會丟失。
|
||||
我們假設節點可能會在任何時候崩潰,但也許會在未知的時間之後再次開始響應。在 **崩潰 - 恢復(crash-recovery)** 模型中,假設節點具有穩定的儲存(即,非易失性磁碟儲存)且會在崩潰中保留,而記憶體中的狀態會丟失。
|
||||
|
||||
* 拜占庭(任意)故障
|
||||
|
||||
節點可以做(絕對意義上的)任何事情,包括試圖戲弄和欺騙其他節點,如上一節所述。
|
||||
|
||||
對於真實系統的建模,具有**崩潰-恢復故障(crash-recovery)** 的**部分同步模型(partial synchronous)** 通常是最有用的模型。分散式演算法如何應對這種模型?
|
||||
對於真實系統的建模,具有 **崩潰 - 恢復故障(crash-recovery)** 的 **部分同步模型(partial synchronous)** 通常是最有用的模型。分散式演算法如何應對這種模型?
|
||||
|
||||
#### 演算法的正確性
|
||||
|
||||
為了定義演算法是正確的,我們可以描述它的屬性。例如,排序演算法的輸出具有如下特性:對於輸出列表中的任何兩個不同的元素,左邊的元素比右邊的元素小。這只是定義對列表進行排序含義的一種形式方式。
|
||||
|
||||
同樣,我們可以寫下我們想要的分散式演算法的屬性來定義它的正確含義。例如,如果我們正在為一個鎖生成防護令牌(請參閱“[防護令牌](#防護令牌)”),我們可能要求演算法具有以下屬性:
|
||||
同樣,我們可以寫下我們想要的分散式演算法的屬性來定義它的正確含義。例如,如果我們正在為一個鎖生成防護令牌(請參閱 “[防護令牌](#防護令牌)”),我們可能要求演算法具有以下屬性:
|
||||
|
||||
* 唯一性(uniqueness)
|
||||
|
||||
@ -590,7 +590,7 @@ Web應用程式確實需要預期受終端使用者控制的客戶端(如Web
|
||||
|
||||
* 單調序列(monotonic sequence)
|
||||
|
||||
如果請求 $x$ 返回了令牌 $t_x$,並且請求$y$返回了令牌$t_y$,並且 $x$ 在 $y$ 開始之前已經完成,那麼$t_x <t_y$。
|
||||
如果請求 $x$ 返回了令牌 $t_x$,並且請求 $y$ 返回了令牌 $t_y$,並且 $x$ 在 $y$ 開始之前已經完成,那麼 $t_x <t_y$。
|
||||
|
||||
* 可用性(availability)
|
||||
|
||||
@ -600,28 +600,28 @@ Web應用程式確實需要預期受終端使用者控制的客戶端(如Web
|
||||
|
||||
#### 安全性和活性
|
||||
|
||||
為了澄清這種情況,有必要區分兩種不同的屬性:**安全(safety)屬性**和**活性(liveness)屬性**。在剛剛給出的例子中,**唯一性**和**單調序列**是安全屬性,而**可用性**是活性屬性。
|
||||
為了澄清這種情況,有必要區分兩種不同的屬性:**安全(safety)屬性** 和 **活性(liveness)屬性**。在剛剛給出的例子中,**唯一性** 和 **單調序列** 是安全屬性,而 **可用性** 是活性屬性。
|
||||
|
||||
這兩種性質有什麼區別?一個試金石就是,活性屬性通常在定義中通常包括“**最終**”一詞(是的,你猜對了——最終一致性是一個活性屬性【89】)。
|
||||
這兩種性質有什麼區別?一個試金石就是,活性屬性通常在定義中通常包括 “**最終**” 一詞(是的,你猜對了 —— 最終一致性是一個活性屬性【89】)。
|
||||
|
||||
安全通常被非正式地定義為:**沒有壞事發生**,而活性通常就類似:**最終好事發生**。但是,最好不要過多地閱讀那些非正式的定義,因為好與壞的含義是主觀的。安全和活性的實際定義是精確的和數學的【90】:
|
||||
|
||||
* 如果安全屬性被違反,我們可以指向一個特定的安全屬性被破壞的時間點(例如,如果違反了唯一性屬性,我們可以確定重複的防護令牌被返回的特定操作)。違反安全屬性後,違規行為不能被撤銷——損失已經發生。
|
||||
* 如果安全屬性被違反,我們可以指向一個特定的安全屬性被破壞的時間點(例如,如果違反了唯一性屬性,我們可以確定重複的防護令牌被返回的特定操作)。違反安全屬性後,違規行為不能被撤銷 —— 損失已經發生。
|
||||
* 活性屬性反過來:在某個時間點(例如,一個節點可能傳送了一個請求,但還沒有收到響應),它可能不成立,但總是希望在未來能成立(即透過接受答覆)。
|
||||
|
||||
區分安全屬性和活性屬性的一個優點是可以幫助我們處理困難的系統模型。對於分散式演算法,在系統模型的所有可能情況下,要求**始終**保持安全屬性是常見的【88】。也就是說,即使所有節點崩潰,或者整個網路出現故障,演算法仍然必須確保它不會返回錯誤的結果(即保證安全屬性得到滿足)。
|
||||
區分安全屬性和活性屬性的一個優點是可以幫助我們處理困難的系統模型。對於分散式演算法,在系統模型的所有可能情況下,要求 **始終** 保持安全屬性是常見的【88】。也就是說,即使所有節點崩潰,或者整個網路出現故障,演算法仍然必須確保它不會返回錯誤的結果(即保證安全屬性得到滿足)。
|
||||
|
||||
但是,對於活性屬性,我們可以提出一些注意事項:例如,只有在大多數節點沒有崩潰的情況下,只有當網路最終從中斷中恢復時,我們才可以說請求需要接收響應。部分同步模型的定義要求系統最終返回到同步狀態——即任何網路中斷的時間段只會持續一段有限的時間,然後進行修復。
|
||||
但是,對於活性屬性,我們可以提出一些注意事項:例如,只有在大多數節點沒有崩潰的情況下,只有當網路最終從中斷中恢復時,我們才可以說請求需要接收響應。部分同步模型的定義要求系統最終返回到同步狀態 —— 即任何網路中斷的時間段只會持續一段有限的時間,然後進行修復。
|
||||
|
||||
#### 將系統模型對映到現實世界
|
||||
|
||||
安全屬性和活性屬性以及系統模型對於推理分散式演算法的正確性非常有用。然而,在實踐中實施演算法時,現實的混亂事實再一次地讓你咬牙切齒,很明顯系統模型是對現實的簡化抽象。
|
||||
|
||||
例如,在崩潰-恢復(crash-recovery)模型中的演算法通常假設穩定儲存器中的資料在崩潰後可以倖存。但是,如果磁碟上的資料被破壞,或者由於硬體錯誤或錯誤配置導致資料被清除,會發生什麼情況【91】?如果伺服器存在韌體錯誤並且在重新啟動時無法識別其硬碟驅動器,即使驅動器已正確連線到伺服器,那又會發生什麼情況【92】?
|
||||
例如,在崩潰 - 恢復(crash-recovery)模型中的演算法通常假設穩定儲存器中的資料在崩潰後可以倖存。但是,如果磁碟上的資料被破壞,或者由於硬體錯誤或錯誤配置導致資料被清除,會發生什麼情況【91】?如果伺服器存在韌體錯誤並且在重新啟動時無法識別其硬碟驅動器,即使驅動器已正確連線到伺服器,那又會發生什麼情況【92】?
|
||||
|
||||
法定人數演算法(請參閱“[讀寫法定人數](ch5.md#讀寫法定人數)”)依賴節點來記住它聲稱儲存的資料。如果一個節點可能患有健忘症,忘記了以前儲存的資料,這會打破法定條件,從而破壞演算法的正確性。也許需要一個新的系統模型,在這個模型中,我們假設穩定的儲存大多能在崩潰後倖存,但有時也可能會丟失。但是那個模型就變得更難以推理了。
|
||||
法定人數演算法(請參閱 “[讀寫法定人數](ch5.md#讀寫法定人數)”)依賴節點來記住它聲稱儲存的資料。如果一個節點可能患有健忘症,忘記了以前儲存的資料,這會打破法定條件,從而破壞演算法的正確性。也許需要一個新的系統模型,在這個模型中,我們假設穩定的儲存大多能在崩潰後倖存,但有時也可能會丟失。但是那個模型就變得更難以推理了。
|
||||
|
||||
演算法的理論描述可以簡單宣稱一些事是不會發生的——在非拜占庭式系統中,我們確實需要對可能發生和不可能發生的故障做出假設。然而,真實世界的實現,仍然會包括處理“假設上不可能”情況的程式碼,即使程式碼可能就是`printf("Sucks to be you")`和`exit(666)`,實際上也就是留給運維來擦屁股【93】。(這可以說是電腦科學和軟體工程間的一個差異)。
|
||||
演算法的理論描述可以簡單宣稱一些事是不會發生的 —— 在非拜占庭式系統中,我們確實需要對可能發生和不可能發生的故障做出假設。然而,真實世界的實現,仍然會包括處理 “假設上不可能” 情況的程式碼,即使程式碼可能就是 `printf("Sucks to be you")` 和 `exit(666)`,實際上也就是留給運維來擦屁股【93】。(這可以說是電腦科學和軟體工程間的一個差異)。
|
||||
|
||||
這並不是說理論上抽象的系統模型是毫無價值的,恰恰相反。它們對於將實際系統的複雜性提取成一個個我們可以推理的可處理的錯誤型別是非常有幫助的,以便我們能夠理解這個問題,並試圖系統地解決這個問題。我們可以證明演算法是正確的,透過表明它們的屬性在某個系統模型中總是成立的。
|
||||
|
||||
@ -633,24 +633,24 @@ Web應用程式確實需要預期受終端使用者控制的客戶端(如Web
|
||||
在本章中,我們討論了分散式系統中可能發生的各種問題,包括:
|
||||
|
||||
* 當你嘗試透過網路傳送資料包時,資料包可能會丟失或任意延遲。同樣,答覆可能會丟失或延遲,所以如果你沒有得到答覆,你不知道訊息是否傳送成功了。
|
||||
* 節點的時鐘可能會與其他節點顯著不同步(儘管你盡最大努力設定NTP),它可能會突然跳轉或跳回,依靠它是很危險的,因為你很可能沒有好的方法來測量你的時鐘的錯誤間隔。
|
||||
* 節點的時鐘可能會與其他節點顯著不同步(儘管你盡最大努力設定 NTP),它可能會突然跳轉或跳回,依靠它是很危險的,因為你很可能沒有好的方法來測量你的時鐘的錯誤間隔。
|
||||
* 一個程序可能會在其執行的任何時候暫停一段相當長的時間(可能是因為停止所有處理的垃圾收集器),被其他節點宣告死亡,然後再次復活,卻沒有意識到它被暫停了。
|
||||
|
||||
這類**部分失效(partial failure)** 可能發生的事實是分散式系統的決定性特徵。每當軟體試圖做任何涉及其他節點的事情時,偶爾就有可能會失敗,或者隨機變慢,或者根本沒有響應(最終超時)。在分散式系統中,我們試圖在軟體中建立**部分失效**的容錯機制,這樣整個系統在即使某些組成部分被破壞的情況下,也可以繼續執行。
|
||||
這類 **部分失效(partial failure)** 可能發生的事實是分散式系統的決定性特徵。每當軟體試圖做任何涉及其他節點的事情時,偶爾就有可能會失敗,或者隨機變慢,或者根本沒有響應(最終超時)。在分散式系統中,我們試圖在軟體中建立 **部分失效** 的容錯機制,這樣整個系統在即使某些組成部分被破壞的情況下,也可以繼續執行。
|
||||
|
||||
為了容忍錯誤,第一步是**檢測**它們,但即使這樣也很難。大多數系統沒有檢測節點是否發生故障的準確機制,所以大多數分散式演算法依靠**超時**來確定遠端節點是否仍然可用。但是,超時無法區分網路失效和節點失效,並且可變的網路延遲有時會導致節點被錯誤地懷疑發生故障。此外,有時一個節點可能處於降級狀態:例如,由於驅動程式錯誤,千兆網絡卡可能突然下降到1 Kb/s的吞吐量【94】。這樣一個“跛行”而不是死掉的節點可能比一個乾淨的失效節點更難處理。
|
||||
為了容忍錯誤,第一步是 **檢測** 它們,但即使這樣也很難。大多數系統沒有檢測節點是否發生故障的準確機制,所以大多數分散式演算法依靠 **超時** 來確定遠端節點是否仍然可用。但是,超時無法區分網路失效和節點失效,並且可變的網路延遲有時會導致節點被錯誤地懷疑發生故障。此外,有時一個節點可能處於降級狀態:例如,由於驅動程式錯誤,千兆網絡卡可能突然下降到 1 Kb/s 的吞吐量【94】。這樣一個 “跛行” 而不是死掉的節點可能比一個乾淨的失效節點更難處理。
|
||||
|
||||
一旦檢測到故障,使系統容忍它也並不容易:沒有全域性變數,沒有共享記憶體,沒有共同的知識,或機器之間任何其他種類的共享狀態。節點甚至不能就現在是什麼時間達成一致,就不用說更深奧的了。資訊從一個節點流向另一個節點的唯一方法是透過不可靠的網路傳送資訊。重大決策不能由一個節點安全地完成,因此我們需要一個能從其他節點獲得幫助的協議,並爭取達到法定人數以達成一致。
|
||||
|
||||
如果你習慣於在理想化的數學完美的單機環境(同一個操作總能確定地返回相同的結果)中編寫軟體,那麼轉向分散式系統的凌亂的物理現實可能會有些令人震驚。相反,如果能夠在單臺計算機上解決一個問題,那麼分散式系統工程師通常會認為這個問題是平凡的【5】,現在單個計算機確實可以做很多事情【95】。如果你可以避免開啟潘多拉的盒子,把東西放在一臺機器上,那麼通常是值得的。
|
||||
|
||||
但是,正如在[第二部分](part-ii.md)的介紹中所討論的那樣,可伸縮性並不是使用分散式系統的唯一原因。容錯和低延遲(透過將資料放置在距離使用者較近的地方)是同等重要的目標,而這些不能用單個節點實現。
|
||||
但是,正如在 [第二部分](part-ii.md) 的介紹中所討論的那樣,可伸縮性並不是使用分散式系統的唯一原因。容錯和低延遲(透過將資料放置在距離使用者較近的地方)是同等重要的目標,而這些不能用單個節點實現。
|
||||
|
||||
在本章中,我們也轉換了幾次話題,探討了網路、時鐘和程序的不可靠性是否是不可避免的自然規律。我們看到這並不是:有可能給網路提供硬實時的響應保證和有限的延遲,但是這樣做非常昂貴,且導致硬體資源的利用率降低。大多數非安全關鍵系統會選擇**便宜而不可靠**,而不是**昂貴和可靠**。
|
||||
在本章中,我們也轉換了幾次話題,探討了網路、時鐘和程序的不可靠性是否是不可避免的自然規律。我們看到這並不是:有可能給網路提供硬實時的響應保證和有限的延遲,但是這樣做非常昂貴,且導致硬體資源的利用率降低。大多數非安全關鍵系統會選擇 **便宜而不可靠**,而不是 **昂貴和可靠**。
|
||||
|
||||
我們還談到了超級計算機,它們採用可靠的元件,因此當元件發生故障時必須完全停止並重新啟動。相比之下,分散式系統可以永久執行而不會在服務層面中斷,因為所有的錯誤和維護都可以在節點級別進行處理——至少在理論上是如此。 (實際上,如果一個錯誤的配置變更被應用到所有的節點,仍然會使分散式系統癱瘓)。
|
||||
我們還談到了超級計算機,它們採用可靠的元件,因此當元件發生故障時必須完全停止並重新啟動。相比之下,分散式系統可以永久執行而不會在服務層面中斷,因為所有的錯誤和維護都可以在節點級別進行處理 —— 至少在理論上是如此。 (實際上,如果一個錯誤的配置變更被應用到所有的節點,仍然會使分散式系統癱瘓)。
|
||||
|
||||
本章一直在講存在的問題,給我們展現了一幅黯淡的前景。在[下一章](ch9.md)中,我們將繼續討論解決方案,並討論一些旨在解決分散式系統中所有問題的演算法。
|
||||
本章一直在講存在的問題,給我們展現了一幅黯淡的前景。在 [下一章](ch9.md) 中,我們將繼續討論解決方案,並討論一些旨在解決分散式系統中所有問題的演算法。
|
||||
|
||||
|
||||
## 參考文獻
|
||||
|
580
zh-tw/ch9.md
580
zh-tw/ch9.md
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user