remove the leading TAB character in almost all paragraphs, suggested by @XIJINIAN

This commit is contained in:
Gang Yin 2021-11-22 19:43:33 +08:00
parent b7769f6073
commit f4034041fb
26 changed files with 3108 additions and 3106 deletions

186
ch1.md
View File

@ -22,51 +22,51 @@
如果这些功能听上去平淡无奇,那是因为这些 **数据系统data system** 是非常成功的抽象:我们一直不假思索地使用它们并习以为常。绝大多数工程师不会幻想从零开始编写存储引擎,因为在开发应用时,数据库已经是足够完美的工具了。 如果这些功能听上去平淡无奇,那是因为这些 **数据系统data system** 是非常成功的抽象:我们一直不假思索地使用它们并习以为常。绝大多数工程师不会幻想从零开始编写存储引擎,因为在开发应用时,数据库已经是足够完美的工具了。
但现实没有这么简单。不同的应用有着不同的需求,因而数据库系统也是百花齐放,有着各式各样的特性。实现缓存有很多种手段,创建搜索索引也有好几种方法,诸如此类。因此在开发应用前,我们依然有必要先弄清楚最适合手头工作的工具和方法。而且当单个工具解决不了你的问题时,组合使用这些工具可能还是有些难度的。 但现实没有这么简单。不同的应用有着不同的需求,因而数据库系统也是百花齐放,有着各式各样的特性。实现缓存有很多种手段,创建搜索索引也有好几种方法,诸如此类。因此在开发应用前,我们依然有必要先弄清楚最适合手头工作的工具和方法。而且当单个工具解决不了你的问题时,组合使用这些工具可能还是有些难度的。
本书将是一趟关于数据系统原理、实践与应用的旅程,并讲述了设计数据密集型应用的方法。我们将探索不同工具之间的共性与特性,以及各自的实现原理。 本书将是一趟关于数据系统原理、实践与应用的旅程,并讲述了设计数据密集型应用的方法。我们将探索不同工具之间的共性与特性,以及各自的实现原理。
本章将从我们所要实现的基础目标开始:可靠、可伸缩、可维护的数据系统。我们将澄清这些词语的含义,概述考量这些目标的方法。并回顾一些后续章节所需的基础知识。在接下来的章节中我们将抽丝剥茧,研究设计数据密集型应用时可能遇到的设计决策。 本章将从我们所要实现的基础目标开始:可靠、可伸缩、可维护的数据系统。我们将澄清这些词语的含义,概述考量这些目标的方法。并回顾一些后续章节所需的基础知识。在接下来的章节中我们将抽丝剥茧,研究设计数据密集型应用时可能遇到的设计决策。
## 关于数据系统的思考 ## 关于数据系统的思考
我们通常认为,数据库、消息队列、缓存等工具分属于几个差异显著的类别。虽然数据库和消息队列表面上有一些相似性——它们都会存储一段时间的数据——但它们有迥然不同的访问模式,这意味着迥异的性能特征和实现手段。 我们通常认为,数据库、消息队列、缓存等工具分属于几个差异显著的类别。虽然数据库和消息队列表面上有一些相似性——它们都会存储一段时间的数据——但它们有迥然不同的访问模式,这意味着迥异的性能特征和实现手段。
那我们为什么要把这些东西放在 **数据系统data system** 的总称之下混为一谈呢? 那我们为什么要把这些东西放在 **数据系统data system** 的总称之下混为一谈呢?
近些年来出现了许多新的数据存储工具与数据处理工具。它们针对不同应用场景进行优化因此不再适合生硬地归入传统类别【1】。类别之间的界限变得越来越模糊例如数据存储可以被当成消息队列用Redis消息队列则带有类似数据库的持久保证Apache Kafka 近些年来出现了许多新的数据存储工具与数据处理工具。它们针对不同应用场景进行优化因此不再适合生硬地归入传统类别【1】。类别之间的界限变得越来越模糊例如数据存储可以被当成消息队列用Redis消息队列则带有类似数据库的持久保证Apache Kafka
其次,越来越多的应用程序有着各种严格而广泛的要求,单个工具不足以满足所有的数据处理和存储需求。取而代之的是,总体工作被拆分成一系列能被单个工具高效完成的任务,并通过应用代码将它们缝合起来。 其次,越来越多的应用程序有着各种严格而广泛的要求,单个工具不足以满足所有的数据处理和存储需求。取而代之的是,总体工作被拆分成一系列能被单个工具高效完成的任务,并通过应用代码将它们缝合起来。
例如如果将缓存应用管理的缓存层Memcached或同类产品和全文搜索全文搜索服务器例如Elasticsearch或Solr功能从主数据库剥离出来那么使缓存/索引与主数据库保持同步通常是应用代码的责任。[图1-1](img/fig1-1.png) 给出了这种架构可能的样子(细节将在后面的章节中详细介绍)。 例如如果将缓存应用管理的缓存层Memcached或同类产品和全文搜索全文搜索服务器例如Elasticsearch或Solr功能从主数据库剥离出来那么使缓存/索引与主数据库保持同步通常是应用代码的责任。[图1-1](img/fig1-1.png) 给出了这种架构可能的样子(细节将在后面的章节中详细介绍)。
![](img/fig1-1.png) ![](img/fig1-1.png)
**图1-1 一个可能的组合使用多个组件的数据系统架构** **图1-1 一个可能的组合使用多个组件的数据系统架构**
当你将多个工具组合在一起提供服务时,服务的接口或**应用程序编程接口API, Application Programming Interface** 通常向客户端隐藏这些实现细节。现在,你基本上已经使用较小的通用组件创建了一个全新的、专用的数据系统。这个新的复合数据系统可能会提供特定的保证,例如:缓存在写入时会作废或更新,以便外部客户端获取一致的结果。现在你不仅是应用程序开发人员,还是数据系统设计人员了。 当你将多个工具组合在一起提供服务时,服务的接口或**应用程序编程接口API, Application Programming Interface** 通常向客户端隐藏这些实现细节。现在,你基本上已经使用较小的通用组件创建了一个全新的、专用的数据系统。这个新的复合数据系统可能会提供特定的保证,例如:缓存在写入时会作废或更新,以便外部客户端获取一致的结果。现在你不仅是应用程序开发人员,还是数据系统设计人员了。
设计数据系统或服务时可能会遇到很多棘手的问题例如当系统出问题时如何确保数据的正确性和完整性当部分系统退化降级时如何为客户提供始终如一的良好性能当负载增加时如何扩容应对什么样的API才是好的API 设计数据系统或服务时可能会遇到很多棘手的问题例如当系统出问题时如何确保数据的正确性和完整性当部分系统退化降级时如何为客户提供始终如一的良好性能当负载增加时如何扩容应对什么样的API才是好的API
影响数据系统设计的因素很多,包括参与人员的技能和经验、历史遗留问题、系统路径依赖、交付时限、公司的风险容忍度、监管约束等,这些因素都需要具体问题具体分析。 影响数据系统设计的因素很多,包括参与人员的技能和经验、历史遗留问题、系统路径依赖、交付时限、公司的风险容忍度、监管约束等,这些因素都需要具体问题具体分析。
本书着重讨论三个在大多数软件系统中都很重要的问题: 本书着重讨论三个在大多数软件系统中都很重要的问题:
***可靠性Reliability*** ***可靠性Reliability***
系统在**困境adversity**(硬件故障、软件故障、人为错误)中仍可正常工作(正确完成功能,并能达到期望的性能水准)。请参阅“[可靠性](#可靠性)”。 系统在**困境adversity**(硬件故障、软件故障、人为错误)中仍可正常工作(正确完成功能,并能达到期望的性能水准)。请参阅“[可靠性](#可靠性)”。
***可伸缩性Scalability*** ***可伸缩性Scalability***
有合理的办法应对系统的增长(数据量、流量、复杂性)。请参阅“[可伸缩性](#可伸缩性)”。 有合理的办法应对系统的增长(数据量、流量、复杂性)。请参阅“[可伸缩性](#可伸缩性)”。
***可维护性Maintainability*** ***可维护性Maintainability***
许多不同的人(工程师、运维)在不同的生命周期,都能高效地在系统上工作(使系统保持现有行为,并适应新的应用场景)。请参阅“[可维护性](#可维护性)”。 许多不同的人(工程师、运维)在不同的生命周期,都能高效地在系统上工作(使系统保持现有行为,并适应新的应用场景)。请参阅“[可维护性](#可维护性)”。
人们经常追求这些词汇,却没有清楚理解它们到底意味着什么。为了工程的严谨性,本章的剩余部分将探讨可靠性、可伸缩性、可维护性的含义。为实现这些目标而使用的各种技术,架构和算法将在后续的章节中研究。 人们经常追求这些词汇,却没有清楚理解它们到底意味着什么。为了工程的严谨性,本章的剩余部分将探讨可靠性、可伸缩性、可维护性的含义。为实现这些目标而使用的各种技术,架构和算法将在后续的章节中研究。
## 可靠性 ## 可靠性
@ -80,35 +80,35 @@
如果所有这些在一起意味着“正确工作”,那么可以把可靠性粗略理解为“即使出现问题,也能继续正确工作”。 如果所有这些在一起意味着“正确工作”,那么可以把可靠性粗略理解为“即使出现问题,也能继续正确工作”。
造成错误的原因叫做**故障fault**,能预料并应对故障的系统特性可称为**容错fault-tolerant** 或**韧性resilient**。“**容错**”一词可能会产生误导,因为它暗示着系统可以容忍所有可能的错误,但在实际中这是不可能的。比方说,如果整个地球(及其上的所有服务器)都被黑洞吞噬了,想要容忍这种错误,需要把网络托管到太空中——这种预算能不能批准就祝你好运了。所以在讨论容错时,只有谈论特定类型的错误才有意义。 造成错误的原因叫做**故障fault**,能预料并应对故障的系统特性可称为**容错fault-tolerant** 或**韧性resilient**。“**容错**”一词可能会产生误导,因为它暗示着系统可以容忍所有可能的错误,但在实际中这是不可能的。比方说,如果整个地球(及其上的所有服务器)都被黑洞吞噬了,想要容忍这种错误,需要把网络托管到太空中——这种预算能不能批准就祝你好运了。所以在讨论容错时,只有谈论特定类型的错误才有意义。
注意**故障fault** 不同于**失效failure**【2】。**故障**通常定义为系统的一部分状态偏离其标准,而**失效**则是系统作为一个整体停止向用户提供服务。故障的概率不可能降到零,因此最好设计容错机制以防因**故障**而导致**失效**。本书中我们将介绍几种用不可靠的部件构建可靠系统的技术。 注意**故障fault** 不同于**失效failure**【2】。**故障**通常定义为系统的一部分状态偏离其标准,而**失效**则是系统作为一个整体停止向用户提供服务。故障的概率不可能降到零,因此最好设计容错机制以防因**故障**而导致**失效**。本书中我们将介绍几种用不可靠的部件构建可靠系统的技术。
反直觉的是,在这类容错系统中,通过故意触发来**提高**故障率是有意义的例如在没有警告的情况下随机地杀死单个进程。许多高危漏洞实际上是由糟糕的错误处理导致的【3】因此我们可以通过故意引发故障来确保容错机制不断运行并接受考验从而提高故障自然发生时系统能正确处理的信心。Netflix公司的*Chaos Monkey*【4】就是这种方法的一个例子。 反直觉的是,在这类容错系统中,通过故意触发来**提高**故障率是有意义的例如在没有警告的情况下随机地杀死单个进程。许多高危漏洞实际上是由糟糕的错误处理导致的【3】因此我们可以通过故意引发故障来确保容错机制不断运行并接受考验从而提高故障自然发生时系统能正确处理的信心。Netflix公司的*Chaos Monkey*【4】就是这种方法的一个例子。
尽管比起**阻止错误prevent error**,我们通常更倾向于**容忍错误**。但也有**预防胜于治疗**的情况(比如不存在治疗方法时)。安全问题就属于这种情况。例如,如果攻击者破坏了系统,并获取了敏感数据,这种事是撤销不了的。但本书主要讨论的是可以恢复的故障种类,正如下面几节所述。 尽管比起**阻止错误prevent error**,我们通常更倾向于**容忍错误**。但也有**预防胜于治疗**的情况(比如不存在治疗方法时)。安全问题就属于这种情况。例如,如果攻击者破坏了系统,并获取了敏感数据,这种事是撤销不了的。但本书主要讨论的是可以恢复的故障种类,正如下面几节所述。
### 硬件故障 ### 硬件故障
当想到系统失效的原因时,**硬件故障hardware faults** 总会第一个进入脑海。硬盘崩溃、内存出错、机房断电、有人拔错网线……任何与大型数据中心打过交道的人都会告诉你:一旦你拥有很多机器,这些事情**总**会发生! 当想到系统失效的原因时,**硬件故障hardware faults** 总会第一个进入脑海。硬盘崩溃、内存出错、机房断电、有人拔错网线……任何与大型数据中心打过交道的人都会告诉你:一旦你拥有很多机器,这些事情**总**会发生!
据报道称,硬盘的 **平均无故障时间MTTF mean time to failure** 约为10到50年【5】【6】。因此从数学期望上讲在拥有10000个磁盘的存储集群上平均每天会有1个磁盘出故障。 据报道称,硬盘的 **平均无故障时间MTTF mean time to failure** 约为10到50年【5】【6】。因此从数学期望上讲在拥有10000个磁盘的存储集群上平均每天会有1个磁盘出故障。
为了减少系统的故障率第一反应通常都是增加单个硬件的冗余度例如磁盘可以组建RAID服务器可能有双路电源和热插拔CPU数据中心可能有电池和柴油发电机作为后备电源某个组件挂掉时冗余组件可以立刻接管。这种方法虽然不能完全防止由硬件问题导致的系统失效但它简单易懂通常也足以让机器不间断运行很多年。 为了减少系统的故障率第一反应通常都是增加单个硬件的冗余度例如磁盘可以组建RAID服务器可能有双路电源和热插拔CPU数据中心可能有电池和柴油发电机作为后备电源某个组件挂掉时冗余组件可以立刻接管。这种方法虽然不能完全防止由硬件问题导致的系统失效但它简单易懂通常也足以让机器不间断运行很多年。
直到最近,硬件冗余对于大多数应用来说已经足够了,它使单台机器完全失效变得相当罕见。只要你能快速地把备份恢复到新机器上,故障停机时间对大多数应用而言都算不上灾难性的。只有少量高可用性至关重要的应用才会要求有多套硬件冗余。 直到最近,硬件冗余对于大多数应用来说已经足够了,它使单台机器完全失效变得相当罕见。只要你能快速地把备份恢复到新机器上,故障停机时间对大多数应用而言都算不上灾难性的。只有少量高可用性至关重要的应用才会要求有多套硬件冗余。
但是随着数据量和应用计算需求的增加,越来越多的应用开始大量使用机器,这会相应地增加硬件故障率。此外在一些云平台(**如亚马逊网络服务AWS, Amazon Web Services**虚拟机实例不可用却没有任何警告也是很常见的【7】因为云平台的设计就是优先考虑**灵活性flexibility** 和**弹性elasticity**[^i],而不是单机可靠性。 但是随着数据量和应用计算需求的增加,越来越多的应用开始大量使用机器,这会相应地增加硬件故障率。此外在一些云平台(**如亚马逊网络服务AWS, Amazon Web Services**虚拟机实例不可用却没有任何警告也是很常见的【7】因为云平台的设计就是优先考虑**灵活性flexibility** 和**弹性elasticity**[^i],而不是单机可靠性。
如果在硬件冗余的基础上进一步引入软件容错机制,那么系统在容忍整个(单台)机器故障的道路上就更进一步了。这样的系统也有运维上的便利,例如:如果需要重启机器(例如应用操作系统安全补丁),单服务器系统就需要计划停机。而允许机器失效的系统则可以一次修复一个节点,无需整个系统停机。 如果在硬件冗余的基础上进一步引入软件容错机制,那么系统在容忍整个(单台)机器故障的道路上就更进一步了。这样的系统也有运维上的便利,例如:如果需要重启机器(例如应用操作系统安全补丁),单服务器系统就需要计划停机。而允许机器失效的系统则可以一次修复一个节点,无需整个系统停机。
[^i]: 在[应对负载的方法](#应对负载的方法)一节定义 [^i]: 在[应对负载的方法](#应对负载的方法)一节定义
### 软件错误 ### 软件错误
我们通常认为硬件故障是随机的、相互独立的:一台机器的磁盘失效并不意味着另一台机器的磁盘也会失效。大量硬件组件不可能同时发生故障,除非它们存在比较弱的相关性(同样的原因导致关联性错误,例如服务器机架的温度)。 我们通常认为硬件故障是随机的、相互独立的:一台机器的磁盘失效并不意味着另一台机器的磁盘也会失效。大量硬件组件不可能同时发生故障,除非它们存在比较弱的相关性(同样的原因导致关联性错误,例如服务器机架的温度)。
另一类错误是内部的**系统性错误systematic error**【7】。这类错误难以预料而且因为是跨节点相关的所以比起不相关的硬件故障往往可能造成更多的**系统失效**【5】。例子包括 另一类错误是内部的**系统性错误systematic error**【7】。这类错误难以预料而且因为是跨节点相关的所以比起不相关的硬件故障往往可能造成更多的**系统失效**【5】。例子包括
* 接受特定的错误输入便导致所有应用服务器实例崩溃的BUG。例如2012年6月30日的闰秒由于Linux内核中的一个错误许多应用同时挂掉了。 * 接受特定的错误输入便导致所有应用服务器实例崩溃的BUG。例如2012年6月30日的闰秒由于Linux内核中的一个错误许多应用同时挂掉了。
* 失控进程会用尽一些共享资源包括CPU时间、内存、磁盘空间或网络带宽。 * 失控进程会用尽一些共享资源包括CPU时间、内存、磁盘空间或网络带宽。
@ -117,13 +117,13 @@
导致这类软件故障的BUG通常会潜伏很长时间直到被异常情况触发为止。这种情况意味着软件对其环境做出了某种假设——虽然这种假设通常来说是正确的但由于某种原因最后不再成立了【11】。 导致这类软件故障的BUG通常会潜伏很长时间直到被异常情况触发为止。这种情况意味着软件对其环境做出了某种假设——虽然这种假设通常来说是正确的但由于某种原因最后不再成立了【11】。
虽然软件中的系统性故障没有速效药,但我们还是有很多小办法,例如:仔细考虑系统中的假设和交互;彻底的测试;进程隔离;允许进程崩溃并重启;测量、监控并分析生产环境中的系统行为。如果系统能够提供一些保证(例如在一个消息队列中,进入与发出的消息数量相等),那么系统就可以在运行时不断自检,并在出现**差异discrepancy** 时报警【12】。 虽然软件中的系统性故障没有速效药,但我们还是有很多小办法,例如:仔细考虑系统中的假设和交互;彻底的测试;进程隔离;允许进程崩溃并重启;测量、监控并分析生产环境中的系统行为。如果系统能够提供一些保证(例如在一个消息队列中,进入与发出的消息数量相等),那么系统就可以在运行时不断自检,并在出现**差异discrepancy** 时报警【12】。
### 人为错误 ### 人为错误
设计并构建了软件系统的工程师是人类维持系统运行的运维也是人类。即使他们怀有最大的善意人类也是不可靠的。举个例子一项关于大型互联网服务的研究发现运维配置错误是导致服务中断的首要原因而硬件故障服务器或网络仅导致了10-25的服务中断【13】。 设计并构建了软件系统的工程师是人类维持系统运行的运维也是人类。即使他们怀有最大的善意人类也是不可靠的。举个例子一项关于大型互联网服务的研究发现运维配置错误是导致服务中断的首要原因而硬件故障服务器或网络仅导致了10-25的服务中断【13】。
尽管人类不可靠,但怎么做才能让系统变得可靠?最好的系统会组合使用以下几种办法: 尽管人类不可靠,但怎么做才能让系统变得可靠?最好的系统会组合使用以下几种办法:
* 以最小化犯错机会的方式设计系统。例如精心设计的抽象、API和管理后台使做对事情更容易搞砸事情更困难。但如果接口限制太多人们就会忽略它们的好处而想办法绕开。很难正确把握这种微妙的平衡。 * 以最小化犯错机会的方式设计系统。例如精心设计的抽象、API和管理后台使做对事情更容易搞砸事情更困难。但如果接口限制太多人们就会忽略它们的好处而想办法绕开。很难正确把握这种微妙的平衡。
* 将人们最容易犯错的地方与可能导致失效的地方**解耦decouple**。特别是提供一个功能齐全的非生产环境**沙箱sandbox**,使人们可以在不影响真实用户的情况下,使用真实数据安全地探索和实验。 * 将人们最容易犯错的地方与可能导致失效的地方**解耦decouple**。特别是提供一个功能齐全的非生产环境**沙箱sandbox**,使人们可以在不影响真实用户的情况下,使用真实数据安全地探索和实验。
@ -134,37 +134,37 @@
### 可靠性有多重要? ### 可靠性有多重要?
可靠性不仅仅是针对核电站和空中交通管制软件而言,我们也期望更多平凡的应用能可靠地运行。商务应用中的错误会导致生产力损失(也许数据报告不完整还会有法律风险),而电商网站的中断则可能会导致收入和声誉的巨大损失。 可靠性不仅仅是针对核电站和空中交通管制软件而言,我们也期望更多平凡的应用能可靠地运行。商务应用中的错误会导致生产力损失(也许数据报告不完整还会有法律风险),而电商网站的中断则可能会导致收入和声誉的巨大损失。
即使在“非关键”应用中我们也对用户负有责任。试想一位家长把所有的照片和孩子的视频储存在你的照片应用里【15】。如果数据库突然损坏他们会感觉如何他们可能会知道如何从备份恢复吗 即使在“非关键”应用中我们也对用户负有责任。试想一位家长把所有的照片和孩子的视频储存在你的照片应用里【15】。如果数据库突然损坏他们会感觉如何他们可能会知道如何从备份恢复吗
在某些情况下,我们可能会选择牺牲可靠性来降低开发成本(例如为未经证实的市场开发产品原型)或运营成本(例如利润率极低的服务),但我们偷工减料时,应该清楚意识到自己在做什么。 在某些情况下,我们可能会选择牺牲可靠性来降低开发成本(例如为未经证实的市场开发产品原型)或运营成本(例如利润率极低的服务),但我们偷工减料时,应该清楚意识到自己在做什么。
## 可伸缩性 ## 可伸缩性
系统今天能可靠运行,并不意味未来也能可靠运行。服务 **降级degradation** 的一个常见原因是负载增加,例如:系统负载已经从一万个并发用户增长到十万个并发用户,或者从一百万增长到一千万。也许现在处理的数据量级要比过去大得多。 系统今天能可靠运行,并不意味未来也能可靠运行。服务 **降级degradation** 的一个常见原因是负载增加,例如:系统负载已经从一万个并发用户增长到十万个并发用户,或者从一百万增长到一千万。也许现在处理的数据量级要比过去大得多。
**可伸缩性Scalability** 是用来描述系统应对负载增长能力的术语。但是请注意这不是贴在系统上的一维标签说“X可伸缩”或“Y不可伸缩”是没有任何意义的。相反讨论可伸缩性意味着考虑诸如“如果系统以特定方式增长有什么选项可以应对增长”和“如何增加计算资源来处理额外的负载”等问题。 **可伸缩性Scalability** 是用来描述系统应对负载增长能力的术语。但是请注意这不是贴在系统上的一维标签说“X可伸缩”或“Y不可伸缩”是没有任何意义的。相反讨论可伸缩性意味着考虑诸如“如果系统以特定方式增长有什么选项可以应对增长”和“如何增加计算资源来处理额外的负载”等问题。
### 描述负载 ### 描述负载
在讨论增长问题(如果负载加倍会发生什么?)前,首先要能简要描述系统的当前负载。负载可以用一些称为 **负载参数load parameters** 的数字来描述。参数的最佳选择取决于系统架构它可能是每秒向Web服务器发出的请求、数据库中的读写比率、聊天室中同时活跃的用户数量、缓存命中率或其他东西。除此之外也许平均情况对你很重要也许你的瓶颈是少数极端场景。 在讨论增长问题(如果负载加倍会发生什么?)前,首先要能简要描述系统的当前负载。负载可以用一些称为 **负载参数load parameters** 的数字来描述。参数的最佳选择取决于系统架构它可能是每秒向Web服务器发出的请求、数据库中的读写比率、聊天室中同时活跃的用户数量、缓存命中率或其他东西。除此之外也许平均情况对你很重要也许你的瓶颈是少数极端场景。
为了使这个概念更加具体我们以推特在2012年11月发布的数据【16】为例。推特的两个主要业务是 为了使这个概念更加具体我们以推特在2012年11月发布的数据【16】为例。推特的两个主要业务是
***发布推文*** ***发布推文***
用户可以向其粉丝发布新消息(平均 4.6k请求/秒,峰值超过 12k请求/秒)。 用户可以向其粉丝发布新消息(平均 4.6k请求/秒,峰值超过 12k请求/秒)。
***主页时间线*** ***主页时间线***
用户可以查阅他们关注的人发布的推文300k请求/秒)。 用户可以查阅他们关注的人发布的推文300k请求/秒)。
处理每秒12,000次写入发推文的速率峰值还是很简单的。然而推特的伸缩性挑战并不是主要来自推特量而是来自**扇出fan-out**——每个用户关注了很多人,也被很多人关注。 处理每秒12,000次写入发推文的速率峰值还是很简单的。然而推特的伸缩性挑战并不是主要来自推特量而是来自**扇出fan-out**——每个用户关注了很多人,也被很多人关注。
[^ii]: 扇出:从电子工程学中借用的术语,它描述了输入连接到另一个门输出的逻辑门数量。 输出需要提供足够的电流来驱动所有连接的输入。 在事务处理系统中,我们使用它来描述为了服务一个传入请求而需要执行其他服务的请求数量。 [^ii]: 扇出:从电子工程学中借用的术语,它描述了输入连接到另一个门输出的逻辑门数量。 输出需要提供足够的电流来驱动所有连接的输入。 在事务处理系统中,我们使用它来描述为了服务一个传入请求而需要执行其他服务的请求数量。
@ -191,11 +191,11 @@
推特的第一个版本使用了方法1但系统很难跟上主页时间线查询的负载。所以公司转向了方法2方法2的效果更好因为发推频率比查询主页时间线的频率几乎低了两个数量级所以在这种情况下最好在写入时做更多的工作而在读取时做更少的工作。 推特的第一个版本使用了方法1但系统很难跟上主页时间线查询的负载。所以公司转向了方法2方法2的效果更好因为发推频率比查询主页时间线的频率几乎低了两个数量级所以在这种情况下最好在写入时做更多的工作而在读取时做更少的工作。
然而方法2的缺点是发推现在需要大量的额外工作。平均来说一条推文会发往约75个关注者所以每秒4.6k的发推写入变成了对主页时间线缓存每秒345k的写入。但这个平均值隐藏了用户粉丝数差异巨大这一现实一些用户有超过3000万的粉丝这意味着一条推文就可能会导致主页时间线缓存的3000万次写入及时完成这种操作是一个巨大的挑战 —— 推特尝试在5秒内向粉丝发送推文。 然而方法2的缺点是发推现在需要大量的额外工作。平均来说一条推文会发往约75个关注者所以每秒4.6k的发推写入变成了对主页时间线缓存每秒345k的写入。但这个平均值隐藏了用户粉丝数差异巨大这一现实一些用户有超过3000万的粉丝这意味着一条推文就可能会导致主页时间线缓存的3000万次写入及时完成这种操作是一个巨大的挑战 —— 推特尝试在5秒内向粉丝发送推文。
在推特的例子中,每个用户粉丝数的分布(可能按这些用户的发推频率来加权)是探讨可伸缩性的一个关键负载参数,因为它决定了扇出负载。你的应用程序可能具有非常不同的特征,但可以采用相似的原则来考虑它的负载。 在推特的例子中,每个用户粉丝数的分布(可能按这些用户的发推频率来加权)是探讨可伸缩性的一个关键负载参数,因为它决定了扇出负载。你的应用程序可能具有非常不同的特征,但可以采用相似的原则来考虑它的负载。
推特轶事的最终转折现在已经稳健地实现了方法2推特逐步转向了两种方法的混合。大多数用户发的推文会被扇出写入其粉丝主页时间线缓存中。但是少数拥有海量粉丝的用户即名流会被排除在外。当用户读取主页时间线时分别地获取出该用户所关注的每位名流的推文再与用户的主页时间线缓存合并如方法1所示。这种混合方法能始终如一地提供良好性能。在[第十二章](ch12.md)中我们将重新讨论这个例子,这在覆盖更多技术层面之后。 推特轶事的最终转折现在已经稳健地实现了方法2推特逐步转向了两种方法的混合。大多数用户发的推文会被扇出写入其粉丝主页时间线缓存中。但是少数拥有海量粉丝的用户即名流会被排除在外。当用户读取主页时间线时分别地获取出该用户所关注的每位名流的推文再与用户的主页时间线缓存合并如方法1所示。这种混合方法能始终如一地提供良好性能。在[第十二章](ch12.md)中我们将重新讨论这个例子,这在覆盖更多技术层面之后。
### 描述性能 ### 描述性能
@ -206,7 +206,7 @@
这两个问题都需要性能数据,所以让我们简单地看一下如何描述系统性能。 这两个问题都需要性能数据,所以让我们简单地看一下如何描述系统性能。
对于Hadoop这样的批处理系统通常关心的是**吞吐量throughput**,即每秒可以处理的记录数量,或者在特定规模数据集上运行作业的总时间[^iii]。对于在线系统,通常更重要的是服务的**响应时间response time**,即客户端发送请求到接收响应之间的时间。 对于Hadoop这样的批处理系统通常关心的是**吞吐量throughput**,即每秒可以处理的记录数量,或者在特定规模数据集上运行作业的总时间[^iii]。对于在线系统,通常更重要的是服务的**响应时间response time**,即客户端发送请求到接收响应之间的时间。
[^iii]: 理想情况下,批量作业的运行时间是数据集的大小除以吞吐量。 在实践中由于数据倾斜(数据不是均匀分布在每个工作进程中),需要等待最慢的任务完成,所以运行时间往往更长。 [^iii]: 理想情况下,批量作业的运行时间是数据集的大小除以吞吐量。 在实践中由于数据倾斜(数据不是均匀分布在每个工作进程中),需要等待最慢的任务完成,所以运行时间往往更长。
@ -214,31 +214,31 @@
> >
> **延迟latency****响应时间response time** 经常用作同义词,但实际上它们并不一样。响应时间是客户所看到的,除了实际处理请求的时间( **服务时间service time** )之外,还包括网络延迟和排队延迟。延迟是某个请求等待处理的**持续时长**,在此期间它处于 **休眠latent** 状态并等待服务【17】。 > **延迟latency****响应时间response time** 经常用作同义词,但实际上它们并不一样。响应时间是客户所看到的,除了实际处理请求的时间( **服务时间service time** )之外,还包括网络延迟和排队延迟。延迟是某个请求等待处理的**持续时长**,在此期间它处于 **休眠latent** 状态并等待服务【17】。
即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值**分布distribution**,而不是单个数值。 即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值**分布distribution**,而不是单个数值。
在[图1-4](img/fig1-4.png)中每个灰条代表一次对服务的请求其高度表示请求花费了多长时间。大多数请求是相当快的但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大例如它们可能会处理更多的数据。但即使你认为所有请求都花费相同时间的情况下随机的附加延迟也会导致结果变化例如上下文切换到后台进程网络数据包丢失与TCP重传垃圾收集暂停强制从磁盘读取的页面错误服务器机架中的震动【18】还有很多其他原因。 在[图1-4](img/fig1-4.png)中每个灰条代表一次对服务的请求其高度表示请求花费了多长时间。大多数请求是相当快的但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大例如它们可能会处理更多的数据。但即使你认为所有请求都花费相同时间的情况下随机的附加延迟也会导致结果变化例如上下文切换到后台进程网络数据包丢失与TCP重传垃圾收集暂停强制从磁盘读取的页面错误服务器机架中的震动【18】还有很多其他原因。
![](img/fig1-4.png) ![](img/fig1-4.png)
**图1-4 展示了一个服务100次请求响应时间的均值与百分位数** **图1-4 展示了一个服务100次请求响应时间的均值与百分位数**
通常报表都会展示服务的平均响应时间。 (严格来讲“平均”一词并不指代任何特定公式,但实际上它通常被理解为**算术平均值arithmetic mean**:给定 n 个值,加起来除以 n )。然而如果你想知道“**典型typical**”响应时间,那么平均值并不是一个非常好的指标,因为它不能告诉你有多少用户实际上经历了这个延迟。 通常报表都会展示服务的平均响应时间。 (严格来讲“平均”一词并不指代任何特定公式,但实际上它通常被理解为**算术平均值arithmetic mean**:给定 n 个值,加起来除以 n )。然而如果你想知道“**典型typical**”响应时间,那么平均值并不是一个非常好的指标,因为它不能告诉你有多少用户实际上经历了这个延迟。
通常使用**百分位点percentiles** 会更好。如果将响应时间列表按最快到最慢排序,那么**中位数median** 就在正中间举个例子如果你的响应时间中位数是200毫秒这意味着一半请求的返回时间少于200毫秒另一半比这个要长。 通常使用**百分位点percentiles** 会更好。如果将响应时间列表按最快到最慢排序,那么**中位数median** 就在正中间举个例子如果你的响应时间中位数是200毫秒这意味着一半请求的返回时间少于200毫秒另一半比这个要长。
如果想知道典型场景下用户需要等待多长时间那么中位数是一个好的度量标准一半用户请求的响应时间少于响应时间的中位数另一半服务时间比中位数长。中位数也被称为第50百分位点有时缩写为p50。注意中位数是关于单个请求的如果用户同时发出几个请求在一个会话过程中或者由于一个页面中包含了多个资源则至少一个请求比中位数慢的概率远大于50 如果想知道典型场景下用户需要等待多长时间那么中位数是一个好的度量标准一半用户请求的响应时间少于响应时间的中位数另一半服务时间比中位数长。中位数也被称为第50百分位点有时缩写为p50。注意中位数是关于单个请求的如果用户同时发出几个请求在一个会话过程中或者由于一个页面中包含了多个资源则至少一个请求比中位数慢的概率远大于50
为了弄清异常值有多糟糕可以看看更高的百分位点例如第95、99和99.9百分位点缩写为p95p99和p999。它们意味着9599或99.9的请求响应时间要比该阈值快例如如果第95百分位点响应时间是1.5秒则意味着100个请求中的95个响应时间快于1.5秒而100个请求中的5个响应时间超过1.5秒。如[图1-4](img/fig1-4.png)所示。 为了弄清异常值有多糟糕可以看看更高的百分位点例如第95、99和99.9百分位点缩写为p95p99和p999。它们意味着9599或99.9的请求响应时间要比该阈值快例如如果第95百分位点响应时间是1.5秒则意味着100个请求中的95个响应时间快于1.5秒而100个请求中的5个响应时间超过1.5秒。如[图1-4](img/fig1-4.png)所示。
响应时间的高百分位点(也称为**尾部延迟tail latencies**非常重要因为它们直接影响用户的服务体验。例如亚马逊在描述内部服务的响应时间要求时以99.9百分位点为准,即使它只影响一千个请求中的一个。这是因为请求响应最慢的客户往往也是数据最多的客户,也可以说是最有价值的客户 —— 因为他们掏钱了【19】。保证网站响应迅速对于保持客户的满意度非常重要亚马逊观察到响应时间增加100毫秒销售量就减少1【20】而另一些报告说慢 1 秒钟会让客户满意度指标减少16%【2122】。 响应时间的高百分位点(也称为**尾部延迟tail latencies**非常重要因为它们直接影响用户的服务体验。例如亚马逊在描述内部服务的响应时间要求时以99.9百分位点为准,即使它只影响一千个请求中的一个。这是因为请求响应最慢的客户往往也是数据最多的客户,也可以说是最有价值的客户 —— 因为他们掏钱了【19】。保证网站响应迅速对于保持客户的满意度非常重要亚马逊观察到响应时间增加100毫秒销售量就减少1【20】而另一些报告说慢 1 秒钟会让客户满意度指标减少16%【2122】。
另一方面优化第99.99百分位点(一万个请求中最慢的一个)被认为太昂贵了,不能为亚马逊的目标带来足够好处。减小高百分位点处的响应时间相当困难,因为它很容易受到随机事件的影响,这超出了控制范围,而且效益也很小。 另一方面优化第99.99百分位点(一万个请求中最慢的一个)被认为太昂贵了,不能为亚马逊的目标带来足够好处。减小高百分位点处的响应时间相当困难,因为它很容易受到随机事件的影响,这超出了控制范围,而且效益也很小。
百分位点通常用于**服务级别目标SLO, service level objectives** 和**服务级别协议SLA, service level agreements**,即定义服务预期性能和可用性的合同。 SLA可能会声明如果服务响应时间的中位数小于200毫秒且99.9百分位点低于1秒则认为服务工作正常如果响应时间更长就认为服务不达标。这些指标为客户设定了期望值并允许客户在SLA未达标的情况下要求退款。 百分位点通常用于**服务级别目标SLO, service level objectives** 和**服务级别协议SLA, service level agreements**,即定义服务预期性能和可用性的合同。 SLA可能会声明如果服务响应时间的中位数小于200毫秒且99.9百分位点低于1秒则认为服务工作正常如果响应时间更长就认为服务不达标。这些指标为客户设定了期望值并允许客户在SLA未达标的情况下要求退款。
**排队延迟queueing delay** 通常占了高百分位点处响应时间的很大一部分。由于服务器只能并行处理少量的事务如受其CPU核数的限制所以只要有少量缓慢的请求就能阻碍后续请求的处理这种效应有时被称为 **头部阻塞head-of-line blocking** 。即使后续请求在服务器上处理的非常迅速,由于需要等待先前请求完成,客户端最终看到的是缓慢的总体响应时间。因为存在这种效应,测量客户端的响应时间非常重要。 **排队延迟queueing delay** 通常占了高百分位点处响应时间的很大一部分。由于服务器只能并行处理少量的事务如受其CPU核数的限制所以只要有少量缓慢的请求就能阻碍后续请求的处理这种效应有时被称为 **头部阻塞head-of-line blocking** 。即使后续请求在服务器上处理的非常迅速,由于需要等待先前请求完成,客户端最终看到的是缓慢的总体响应时间。因为存在这种效应,测量客户端的响应时间非常重要。
为测试系统的可伸缩性而人为产生负载时产生负载的客户端要独立于响应时间不断发送请求。如果客户端在发送下一个请求之前等待先前的请求完成这种行为会产生人为排队的效果使得测试时的队列比现实情况更短使测量结果产生偏差【23】。 为测试系统的可伸缩性而人为产生负载时产生负载的客户端要独立于响应时间不断发送请求。如果客户端在发送下一个请求之前等待先前的请求完成这种行为会产生人为排队的效果使得测试时的队列比现实情况更短使测量结果产生偏差【23】。
> #### 实践中的百分位点 > #### 实践中的百分位点
> >
@ -254,53 +254,53 @@
### 应对负载的方法 ### 应对负载的方法
现在我们已经讨论了用于描述负载的参数和用于衡量性能的指标。可以开始认真讨论可伸缩性了:当负载参数增加时,如何保持良好的性能? 现在我们已经讨论了用于描述负载的参数和用于衡量性能的指标。可以开始认真讨论可伸缩性了:当负载参数增加时,如何保持良好的性能?
适应某个级别负载的架构不太可能应付10倍于此的负载。如果你正在开发一个快速增长的服务那么每次负载发生数量级的增长时你可能都需要重新考虑架构——或者更频繁。 适应某个级别负载的架构不太可能应付10倍于此的负载。如果你正在开发一个快速增长的服务那么每次负载发生数量级的增长时你可能都需要重新考虑架构——或者更频繁。
人们经常讨论**纵向伸缩scaling up****垂直伸缩vertical scaling**,转向更强大的机器)和**横向伸缩scaling out** **水平伸缩horizontal scaling**,将负载分布到多台小机器上)之间的对立。跨多台机器分配负载也称为“**无共享shared-nothing**”架构。可以在单台机器上运行的系统通常更简单,但高端机器可能非常贵,所以非常密集的负载通常无法避免地需要横向伸缩。现实世界中的优秀架构需要将这两种方法务实地结合,因为使用几台足够强大的机器可能比使用大量的小型虚拟机更简单也更便宜。 人们经常讨论**纵向伸缩scaling up****垂直伸缩vertical scaling**,转向更强大的机器)和**横向伸缩scaling out** **水平伸缩horizontal scaling**,将负载分布到多台小机器上)之间的对立。跨多台机器分配负载也称为“**无共享shared-nothing**”架构。可以在单台机器上运行的系统通常更简单,但高端机器可能非常贵,所以非常密集的负载通常无法避免地需要横向伸缩。现实世界中的优秀架构需要将这两种方法务实地结合,因为使用几台足够强大的机器可能比使用大量的小型虚拟机更简单也更便宜。
有些系统是 **弹性elastic** 的,这意味着可以在检测到负载增加时自动增加计算资源,而其他系统则是手动伸缩(人工分析容量并决定向系统添加更多的机器)。如果负载**极难预测highly unpredictable**,则弹性系统可能很有用,但手动伸缩系统更简单,并且意外操作可能会更少(请参阅“[分区再平衡](ch6.md#分区再平衡)”)。 有些系统是 **弹性elastic** 的,这意味着可以在检测到负载增加时自动增加计算资源,而其他系统则是手动伸缩(人工分析容量并决定向系统添加更多的机器)。如果负载**极难预测highly unpredictable**,则弹性系统可能很有用,但手动伸缩系统更简单,并且意外操作可能会更少(请参阅“[分区再平衡](ch6.md#分区再平衡)”)。
跨多台机器部署 **无状态服务stateless services** 非常简单,但将带状态的数据系统从单节点变为分布式配置则可能引入许多额外复杂度。出于这个原因,常识告诉我们应该将数据库放在单个节点上(纵向伸缩),直到伸缩成本或可用性需求迫使其改为分布式。 跨多台机器部署 **无状态服务stateless services** 非常简单,但将带状态的数据系统从单节点变为分布式配置则可能引入许多额外复杂度。出于这个原因,常识告诉我们应该将数据库放在单个节点上(纵向伸缩),直到伸缩成本或可用性需求迫使其改为分布式。
随着分布式系统的工具和抽象越来越好,至少对于某些类型的应用而言,这种常识可能会改变。可以预见分布式数据系统将成为未来的默认设置,即使对不处理大量数据或流量的场景也如此。本书的其余部分将介绍多种分布式数据系统,不仅讨论它们在可伸缩性方面的表现,还包括易用性和可维护性。 随着分布式系统的工具和抽象越来越好,至少对于某些类型的应用而言,这种常识可能会改变。可以预见分布式数据系统将成为未来的默认设置,即使对不处理大量数据或流量的场景也如此。本书的其余部分将介绍多种分布式数据系统,不仅讨论它们在可伸缩性方面的表现,还包括易用性和可维护性。
大规模的系统架构通常是应用特定的—— 没有一招鲜吃遍天的通用可伸缩架构(不正式的叫法:**万金油magic scaling sauce** )。应用的问题可能是读取量、写入量、要存储的数据量、数据的复杂度、响应时间要求、访问模式或者所有问题的大杂烩。 大规模的系统架构通常是应用特定的—— 没有一招鲜吃遍天的通用可伸缩架构(不正式的叫法:**万金油magic scaling sauce** )。应用的问题可能是读取量、写入量、要存储的数据量、数据的复杂度、响应时间要求、访问模式或者所有问题的大杂烩。
举个例子用于处理每秒十万个请求每个大小为1 kB的系统与用于处理每分钟3个请求每个大小为2GB的系统看上去会非常不一样尽管两个系统有同样的数据吞吐量。 举个例子用于处理每秒十万个请求每个大小为1 kB的系统与用于处理每分钟3个请求每个大小为2GB的系统看上去会非常不一样尽管两个系统有同样的数据吞吐量。
一个良好适配应用的可伸缩架构,是围绕着**假设assumption** 建立的:哪些操作是常见的?哪些操作是罕见的?这就是所谓负载参数。如果假设最终是错误的,那么为伸缩所做的工程投入就白费了,最糟糕的是适得其反。在早期创业公司或非正式产品中,通常支持产品快速迭代的能力,要比可伸缩至未来的假想负载要重要的多。 一个良好适配应用的可伸缩架构,是围绕着**假设assumption** 建立的:哪些操作是常见的?哪些操作是罕见的?这就是所谓负载参数。如果假设最终是错误的,那么为伸缩所做的工程投入就白费了,最糟糕的是适得其反。在早期创业公司或非正式产品中,通常支持产品快速迭代的能力,要比可伸缩至未来的假想负载要重要的多。
尽管这些架构是应用程序特定的,但可伸缩的架构通常也是从通用的积木块搭建而成的,并以常见的模式排列。在本书中,我们将讨论这些构件和模式。 尽管这些架构是应用程序特定的,但可伸缩的架构通常也是从通用的积木块搭建而成的,并以常见的模式排列。在本书中,我们将讨论这些构件和模式。
## 可维护性 ## 可维护性
众所周知,软件的大部分开销并不在最初的开发阶段,而是在持续的维护阶段,包括修复漏洞、保持系统正常运行、调查失效、适配新的平台、为新的场景进行修改、偿还技术债、添加新的功能等等。 众所周知,软件的大部分开销并不在最初的开发阶段,而是在持续的维护阶段,包括修复漏洞、保持系统正常运行、调查失效、适配新的平台、为新的场景进行修改、偿还技术债、添加新的功能等等。
不幸的是,许多从事软件系统行业的人不喜欢维护所谓的**遗留legacy** 系统,——也许因为涉及修复其他人的错误、和过时的平台打交道,或者系统被迫使用于一些份外工作。每一个遗留系统都以自己的方式让人不爽,所以很难给出一个通用的建议来和它们打交道。 不幸的是,许多从事软件系统行业的人不喜欢维护所谓的**遗留legacy** 系统,——也许因为涉及修复其他人的错误、和过时的平台打交道,或者系统被迫使用于一些份外工作。每一个遗留系统都以自己的方式让人不爽,所以很难给出一个通用的建议来和它们打交道。
但是我们可以,也应该以这样一种方式来设计软件:在设计之初就尽量考虑尽可能减少维护期间的痛苦,从而避免自己的软件系统变成遗留系统。为此,我们将特别关注软件系统的三个设计原则: 但是我们可以,也应该以这样一种方式来设计软件:在设计之初就尽量考虑尽可能减少维护期间的痛苦,从而避免自己的软件系统变成遗留系统。为此,我们将特别关注软件系统的三个设计原则:
***可操作性Operability*** ***可操作性Operability***
便于运维团队保持系统平稳运行。 便于运维团队保持系统平稳运行。
***简单性Simplicity*** ***简单性Simplicity***
从系统中消除尽可能多的**复杂度complexity**,使新工程师也能轻松理解系统。(注意这和用户接口的简单性不一样。) 从系统中消除尽可能多的**复杂度complexity**,使新工程师也能轻松理解系统。(注意这和用户接口的简单性不一样。)
***可演化性evolability*** ***可演化性evolability***
使工程师在未来能轻松地对系统进行更改,当需求变化时为新应用场景做适配。也称为**可伸缩性extensibility****可修改性modifiability** 或**可塑性plasticity**。 使工程师在未来能轻松地对系统进行更改,当需求变化时为新应用场景做适配。也称为**可伸缩性extensibility****可修改性modifiability** 或**可塑性plasticity**。
和之前提到的可靠性、可伸缩性一样,实现这些目标也没有简单的解决方案。不过我们会试着想象具有可操作性,简单性和可演化性的系统会是什么样子。 和之前提到的可靠性、可伸缩性一样,实现这些目标也没有简单的解决方案。不过我们会试着想象具有可操作性,简单性和可演化性的系统会是什么样子。
### 可操作性:人生苦短,关爱运维 ### 可操作性:人生苦短,关爱运维
有人认为,“良好的运维经常可以绕开垃圾(或不完整)软件的局限性,而再好的软件摊上垃圾运维也没法可靠运行”。尽管运维的某些方面可以,而且应该是自动化的,但在最初建立正确运作的自动化机制仍然取决于人。 有人认为,“良好的运维经常可以绕开垃圾(或不完整)软件的局限性,而再好的软件摊上垃圾运维也没法可靠运行”。尽管运维的某些方面可以,而且应该是自动化的,但在最初建立正确运作的自动化机制仍然取决于人。
运维团队对于保持软件系统顺利运行至关重要。一个优秀运维团队的典型职责如下或者更多【29】 运维团队对于保持软件系统顺利运行至关重要。一个优秀运维团队的典型职责如下或者更多【29】
@ -329,50 +329,50 @@
### 简单性:管理复杂度 ### 简单性:管理复杂度
小型软件项目可以使用简单讨喜的、富表现力的代码,但随着项目越来越大,代码往往变得非常复杂,难以理解。这种复杂度拖慢了所有系统相关人员,进一步增加了维护成本。一个陷入复杂泥潭的软件项目有时被描述为 **烂泥潭a big ball of mud** 【30】。 小型软件项目可以使用简单讨喜的、富表现力的代码,但随着项目越来越大,代码往往变得非常复杂,难以理解。这种复杂度拖慢了所有系统相关人员,进一步增加了维护成本。一个陷入复杂泥潭的软件项目有时被描述为 **烂泥潭a big ball of mud** 【30】。
**复杂度complexity** 有各种可能的症状例如状态空间激增、模块间紧密耦合、纠结的依赖关系、不一致的命名和术语、解决性能问题的Hack、需要绕开的特例等等现在已经有很多关于这个话题的讨论【31,32,33】。 **复杂度complexity** 有各种可能的症状例如状态空间激增、模块间紧密耦合、纠结的依赖关系、不一致的命名和术语、解决性能问题的Hack、需要绕开的特例等等现在已经有很多关于这个话题的讨论【31,32,33】。
因为复杂度导致维护困难时,预算和时间安排通常会超支。在复杂的软件中进行变更,引入错误的风险也更大:当开发人员难以理解系统时,隐藏的假设、无意的后果和意外的交互就更容易被忽略。相反,降低复杂度能极大地提高软件的可维护性,因此简单性应该是构建系统的一个关键目标。 因为复杂度导致维护困难时,预算和时间安排通常会超支。在复杂的软件中进行变更,引入错误的风险也更大:当开发人员难以理解系统时,隐藏的假设、无意的后果和意外的交互就更容易被忽略。相反,降低复杂度能极大地提高软件的可维护性,因此简单性应该是构建系统的一个关键目标。
简化系统并不一定意味着减少功能;它也可以意味着消除**额外的accidental** 的复杂度。 Moseley和Marks【32】把 **额外复杂度** 定义为:由具体实现中涌现,而非(从用户视角看,系统所解决的)问题本身固有的复杂度。 简化系统并不一定意味着减少功能;它也可以意味着消除**额外的accidental** 的复杂度。 Moseley和Marks【32】把 **额外复杂度** 定义为:由具体实现中涌现,而非(从用户视角看,系统所解决的)问题本身固有的复杂度。
用于消除**额外复杂度** 的最好工具之一是**抽象abstraction**。一个好的抽象可以将大量实现细节隐藏在一个干净,简单易懂的外观下面。一个好的抽象也可以广泛用于各类不同应用。比起重复造很多轮子,重用抽象不仅更有效率,而且有助于开发高质量的软件。抽象组件的质量改进将使所有使用它的应用受益。 用于消除**额外复杂度** 的最好工具之一是**抽象abstraction**。一个好的抽象可以将大量实现细节隐藏在一个干净,简单易懂的外观下面。一个好的抽象也可以广泛用于各类不同应用。比起重复造很多轮子,重用抽象不仅更有效率,而且有助于开发高质量的软件。抽象组件的质量改进将使所有使用它的应用受益。
例如高级编程语言是一种抽象隐藏了机器码、CPU寄存器和系统调用。 SQL也是一种抽象隐藏了复杂的磁盘/内存数据结构、来自其他客户端的并发请求、崩溃后的不一致性。当然在用高级语言编程时,我们仍然用到了机器码;只不过没有**直接directly** 使用罢了,正是因为编程语言的抽象,我们才不必去考虑这些实现细节。 例如高级编程语言是一种抽象隐藏了机器码、CPU寄存器和系统调用。 SQL也是一种抽象隐藏了复杂的磁盘/内存数据结构、来自其他客户端的并发请求、崩溃后的不一致性。当然在用高级语言编程时,我们仍然用到了机器码;只不过没有**直接directly** 使用罢了,正是因为编程语言的抽象,我们才不必去考虑这些实现细节。
抽象可以帮助我们将系统的复杂度控制在可管理的水平,不过,找到好的抽象是非常困难的。在分布式系统领域虽然有许多好的算法,但我们并不清楚它们应该打包成什么样抽象。 抽象可以帮助我们将系统的复杂度控制在可管理的水平,不过,找到好的抽象是非常困难的。在分布式系统领域虽然有许多好的算法,但我们并不清楚它们应该打包成什么样抽象。
本书将紧盯那些允许我们将大型系统的部分提取为定义明确的、可重用的组件的优秀抽象。 本书将紧盯那些允许我们将大型系统的部分提取为定义明确的、可重用的组件的优秀抽象。
### 可演化性:拥抱变化 ### 可演化性:拥抱变化
系统的需求永远不变,基本是不可能的。更可能的情况是,它们处于常态的变化中,例如:你了解了新的事实、出现意想不到的应用场景、业务优先级发生变化、用户要求新功能、新平台取代旧平台、法律或监管要求发生变化、系统增长迫使架构变化等。 系统的需求永远不变,基本是不可能的。更可能的情况是,它们处于常态的变化中,例如:你了解了新的事实、出现意想不到的应用场景、业务优先级发生变化、用户要求新功能、新平台取代旧平台、法律或监管要求发生变化、系统增长迫使架构变化等。
在组织流程方面, **敏捷agile** 工作模式为适应变化提供了一个框架。敏捷社区还开发了对在频繁变化的环境中开发软件很有帮助的技术工具和模式,如 **测试驱动开发TDD, test-driven development****重构refactoring** 在组织流程方面, **敏捷agile** 工作模式为适应变化提供了一个框架。敏捷社区还开发了对在频繁变化的环境中开发软件很有帮助的技术工具和模式,如 **测试驱动开发TDD, test-driven development****重构refactoring**
这些敏捷技术的大部分讨论都集中在相当小的规模同一个应用中的几个代码文件。本书将探索在更大数据系统层面上提高敏捷性的方法可能由几个不同的应用或服务组成。例如为了将装配主页时间线的方法从方法1变为方法2你会如何“重构”推特的架构 这些敏捷技术的大部分讨论都集中在相当小的规模同一个应用中的几个代码文件。本书将探索在更大数据系统层面上提高敏捷性的方法可能由几个不同的应用或服务组成。例如为了将装配主页时间线的方法从方法1变为方法2你会如何“重构”推特的架构
修改数据系统并使其适应不断变化需求的容易程度,是与**简单性**和**抽象性**密切相关的:简单易懂的系统通常比复杂系统更容易修改。但由于这是一个非常重要的概念,我们将用一个不同的词来指代数据系统层面的敏捷性: **可演化性evolvability** 【34】。 修改数据系统并使其适应不断变化需求的容易程度,是与**简单性**和**抽象性**密切相关的:简单易懂的系统通常比复杂系统更容易修改。但由于这是一个非常重要的概念,我们将用一个不同的词来指代数据系统层面的敏捷性: **可演化性evolvability** 【34】。
## 本章小结 ## 本章小结
本章探讨了一些关于数据密集型应用的基本思考方式。这些原则将指导我们阅读本书的其余部分,那里将会深入技术细节。 本章探讨了一些关于数据密集型应用的基本思考方式。这些原则将指导我们阅读本书的其余部分,那里将会深入技术细节。
一个应用必须满足各种需求才称得上有用。有一些**功能需求functional requirements**(它应该做什么,比如允许以各种方式存储,检索,搜索和处理数据)以及一些**非功能性需求nonfunctional **(通用属性,例如安全性,可靠性,合规性,可伸缩性,兼容性和可维护性)。在本章详细讨论了可靠性,可伸缩性和可维护性。 一个应用必须满足各种需求才称得上有用。有一些**功能需求functional requirements**(它应该做什么,比如允许以各种方式存储,检索,搜索和处理数据)以及一些**非功能性需求nonfunctional **(通用属性,例如安全性,可靠性,合规性,可伸缩性,兼容性和可维护性)。在本章详细讨论了可靠性,可伸缩性和可维护性。
**可靠性Reliability** 意味着即使发生故障系统也能正常工作。故障可能发生在硬件通常是随机的和不相关的软件通常是系统性的Bug很难处理和人类不可避免地时不时出错**容错技术** 可以对终端用户隐藏某些类型的故障。 **可靠性Reliability** 意味着即使发生故障系统也能正常工作。故障可能发生在硬件通常是随机的和不相关的软件通常是系统性的Bug很难处理和人类不可避免地时不时出错**容错技术** 可以对终端用户隐藏某些类型的故障。
**可伸缩性Scalability** 意味着即使在负载增加的情况下也有保持性能的策略。为了讨论可伸缩性,我们首先需要定量描述负载和性能的方法。我们简要了解了推特主页时间线的例子,介绍描述负载的方法,并将响应时间百分位点作为衡量性能的一种方式。在可伸缩的系统中可以添加 **处理容量processing capacity** 以在高负载下保持可靠。 **可伸缩性Scalability** 意味着即使在负载增加的情况下也有保持性能的策略。为了讨论可伸缩性,我们首先需要定量描述负载和性能的方法。我们简要了解了推特主页时间线的例子,介绍描述负载的方法,并将响应时间百分位点作为衡量性能的一种方式。在可伸缩的系统中可以添加 **处理容量processing capacity** 以在高负载下保持可靠。
**可维护性Maintainability** 有许多方面,但实质上是关于工程师和运维团队的生活质量的。良好的抽象可以帮助降低复杂度,并使系统易于修改和适应新的应用场景。良好的可操作性意味着对系统的健康状态具有良好的可见性,并拥有有效的管理手段。 **可维护性Maintainability** 有许多方面,但实质上是关于工程师和运维团队的生活质量的。良好的抽象可以帮助降低复杂度,并使系统易于修改和适应新的应用场景。良好的可操作性意味着对系统的健康状态具有良好的可见性,并拥有有效的管理手段。
不幸的是,使应用可靠、可伸缩或可维护并不容易。但是某些模式和技术会不断重新出现在不同的应用中。在接下来的几章中,我们将看到一些数据系统的例子,并分析它们如何实现这些目标。 不幸的是,使应用可靠、可伸缩或可维护并不容易。但是某些模式和技术会不断重新出现在不同的应用中。在接下来的几章中,我们将看到一些数据系统的例子,并分析它们如何实现这些目标。
在本书后面的[第三部分](part-iii.md)中,我们将看到一种模式:几个组件协同工作以构成一个完整的系统(如[图1-1](img/fig1-1.png)中的例子) 在本书后面的[第三部分](part-iii.md)中,我们将看到一种模式:几个组件协同工作以构成一个完整的系统(如[图1-1](img/fig1-1.png)中的例子)

436
ch10.md
View File

@ -10,37 +10,37 @@
[TOC] [TOC]
在本书的前两部分中,我们讨论了很多关于**请求**和**查询**以及相应的**响应**或**结果**。许多现有数据系统中都采用这种数据处理方式:你发送请求指令,一段时间后(我们期望)系统会给出一个结果。数据库、缓存、搜索索引、Web服务器以及其他一些系统都以这种方式工作。 在本书的前两部分中,我们讨论了很多关于**请求**和**查询**以及相应的**响应**或**结果**。许多现有数据系统中都采用这种数据处理方式:你发送请求指令,一段时间后(我们期望)系统会给出一个结果。数据库、缓存、搜索索引、Web服务器以及其他一些系统都以这种方式工作。
像这样的**在线online** 系统无论是浏览器请求页面还是调用远程API的服务我们通常认为请求是由人类用户触发的并且正在等待响应。他们不应该等太久所以我们非常关注系统的响应时间请参阅“[描述性能](ch1.md#描述性能)”)。 像这样的**在线online** 系统无论是浏览器请求页面还是调用远程API的服务我们通常认为请求是由人类用户触发的并且正在等待响应。他们不应该等太久所以我们非常关注系统的响应时间请参阅“[描述性能](ch1.md#描述性能)”)。
Web和越来越多的基于HTTP/REST的API使交互的请求/响应风格变得如此普遍,以至于很容易将其视为理所当然。但我们应该记住,这不是构建系统的唯一方式,其他方法也有其优点。我们来看看三种不同类型的系统: Web和越来越多的基于HTTP/REST的API使交互的请求/响应风格变得如此普遍,以至于很容易将其视为理所当然。但我们应该记住,这不是构建系统的唯一方式,其他方法也有其优点。我们来看看三种不同类型的系统:
***服务(在线系统)*** ***服务(在线系统)***
服务等待客户的请求或指令到达。每收到一个,服务会试图尽快处理它,并发回一个响应。响应时间通常是服务性能的主要衡量指标,可用性通常非常重要(如果客户端无法访问服务,用户可能会收到错误消息)。 服务等待客户的请求或指令到达。每收到一个,服务会试图尽快处理它,并发回一个响应。响应时间通常是服务性能的主要衡量指标,可用性通常非常重要(如果客户端无法访问服务,用户可能会收到错误消息)。
***批处理系统(离线系统)*** ***批处理系统(离线系统)***
一个批处理系统有大量的输入数据,跑一个**作业job** 来处理它,并生成一些输出数据,这往往需要一段时间(从几分钟到几天),所以通常不会有用户等待作业完成。相反,批量作业通常会定期运行(例如,每天一次)。批处理作业的主要性能衡量标准通常是吞吐量(处理特定大小的输入所需的时间)。本章中讨论的就是批处理。 一个批处理系统有大量的输入数据,跑一个**作业job** 来处理它,并生成一些输出数据,这往往需要一段时间(从几分钟到几天),所以通常不会有用户等待作业完成。相反,批量作业通常会定期运行(例如,每天一次)。批处理作业的主要性能衡量标准通常是吞吐量(处理特定大小的输入所需的时间)。本章中讨论的就是批处理。
***流处理系统(准实时系统)*** ***流处理系统(准实时系统)***
流处理介于在线和离线(批处理)之间,所以有时候被称为**准实时near-real-time** 或**准在线nearline** 处理。像批处理系统一样,流处理消费输入并产生输出(并不需要响应请求)。但是,流式作业在事件发生后不久就会对事件进行操作,而批处理作业则需等待固定的一组输入数据。这种差异使流处理系统比起批处理系统具有更低的延迟。由于流处理基于批处理,我们将在[第十一章](ch11.md)讨论它。 流处理介于在线和离线(批处理)之间,所以有时候被称为**准实时near-real-time** 或**准在线nearline** 处理。像批处理系统一样,流处理消费输入并产生输出(并不需要响应请求)。但是,流式作业在事件发生后不久就会对事件进行操作,而批处理作业则需等待固定的一组输入数据。这种差异使流处理系统比起批处理系统具有更低的延迟。由于流处理基于批处理,我们将在[第十一章](ch11.md)讨论它。
正如我们将在本章中看到的那样批处理是构建可靠、可伸缩和可维护应用程序的重要组成部分。例如2004年发布的批处理算法Map-Reduce可能被过分热情地被称为“造就Google大规模可伸缩性的算法”【2】。随后在各种开源数据系统中得到应用包括HadoopCouchDB和MongoDB。 正如我们将在本章中看到的那样批处理是构建可靠、可伸缩和可维护应用程序的重要组成部分。例如2004年发布的批处理算法Map-Reduce可能被过分热情地被称为“造就Google大规模可伸缩性的算法”【2】。随后在各种开源数据系统中得到应用包括HadoopCouchDB和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工具的批处理 ## 使用Unix工具的批处理
我们从一个简单的例子开始。假设您有一台Web服务器每次处理请求时都会在日志文件中附加一行。例如使用nginx默认的访问日志格式日志的一行可能如下所示 我们从一个简单的例子开始。假设您有一台Web服务器每次处理请求时都会在日志文件中附加一行。例如使用nginx默认的访问日志格式日志的一行可能如下所示
```bash ```bash
216.58.210.78 - - [27/Feb/2015:17:55:11 +0000] "GET /css/typography.css HTTP/1.1" 216.58.210.78 - - [27/Feb/2015:17:55:11 +0000] "GET /css/typography.css HTTP/1.1"
@ -55,13 +55,13 @@ AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 Safari/537.36"
$status $body_bytes_sent "$http_referer" "$http_user_agent" $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 40URL `http://martin.kleppmann.com/` 的页面中的引用导致该文件被加载。 日志的这一行表明在2015年2月27日17:55:11 UTC服务器从客户端IP地址`216.58.210.78`接收到对文件`/css/typography.css`的请求。用户没有被认证,所以`$remote_user`被设置为连字符(`-` 。响应状态是200即请求成功响应的大小是3377字节。网页浏览器是Chrome 40URL `http://martin.kleppmann.com/` 的页面中的引用导致该文件被加载。
### 简单日志分析 ### 简单日志分析
很多工具可以从这些日志文件生成关于网站流量的漂亮的报告但为了练手让我们使用基本的Unix功能创建自己的工具。 例如,假设你想在你的网站上找到五个最受欢迎的网页。 则可以在Unix shell中这样做[^i] 很多工具可以从这些日志文件生成关于网站流量的漂亮的报告但为了练手让我们使用基本的Unix功能创建自己的工具。 例如,假设你想在你的网站上找到五个最受欢迎的网页。 则可以在Unix shell中这样做[^i]
[^i]: 有些人认为`cat`这里并没有必要因为输入文件可以直接作为awk的参数。 但这种写法让线性管道更为显眼。 [^i]: 有些人认为`cat`这里并没有必要因为输入文件可以直接作为awk的参数。 但这种写法让线性管道更为显眼。
@ -89,9 +89,9 @@ cat /var/log/nginx/access.log | #1
915 /css/typography.css 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工具但是它非常值得学习。令人惊讶的是使用awksedgrepsortuniq和xargs的组合可以在几分钟内完成许多数据分析并且它们的性能相当的好【8】。 我们不会在这里详细探索Unix工具但是它非常值得学习。令人惊讶的是使用awksedgrepsortuniq和xargs的组合可以在几分钟内完成许多数据分析并且它们的性能相当的好【8】。
#### 命令链与自定义程序 #### 命令链与自定义程序
@ -120,21 +120,21 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
#### 排序 VS 内存中的聚合 #### 排序 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 CoreutilsLinux中的`sort `程序通过溢出至磁盘的方式来自动应对大于内存的数据集并能同时使用多个CPU核进行并行排序【9】。这意味着我们之前看到的简单的Unix命令链很容易伸缩至大数据集且不会耗尽内存。瓶颈可能是从磁盘读取输入文件的速度。 GNU CoreutilsLinux中的`sort `程序通过溢出至磁盘的方式来自动应对大于内存的数据集并能同时使用多个CPU核进行并行排序【9】。这意味着我们之前看到的简单的Unix命令链很容易伸缩至大数据集且不会耗尽内存。瓶颈可能是从磁盘读取输入文件的速度。
### 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. 期待每个程序的输出成为另一个程序的输入。不要将无关信息混入输出。避免使用严格的列数据或二进制输入格式。不要坚持交互式输入。 2. 期待每个程序的输出成为另一个程序的输入。不要将无关信息混入输出。避免使用严格的列数据或二进制输入格式。不要坚持交互式输入。
@ -143,27 +143,27 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
这种方法 —— 自动化,快速原型设计,增量式迭代,对实验友好,将大型项目分解成可管理的块 —— 听起来非常像今天的敏捷开发和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套接字stdinstdout的通信通道设备驱动程序比如`/dev/audio`或`/dev/lp0`表示TCP连接的套接字等等。很容易将这些设计视为理所当然的但实际上能让这些差异巨大的东西共享一个统一的接口是非常厉害的这使得它们可以很容易地连接在一起[^ii]。 在Unix中这种接口是一个**文件file**更准确地说是一个文件描述符。一个文件只是一串有序的字节序列。因为这是一个非常简单的接口所以可以使用相同的接口来表示许多不同的东西文件系统上的真实文件到另一个进程Unix套接字stdinstdout的通信通道设备驱动程序比如`/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** 是一个常带有贬义的地缘政治学术语,其定义为:一个国家或政区分裂成多个互相敌对的国家或政区的过程。 [^译注i]: **巴尔干化Balkanization** 是一个常带有贬义的地缘政治学术语,其定义为:一个国家或政区分裂成多个互相敌对的国家或政区的过程。
@ -171,13 +171,13 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
#### 逻辑与布线相分离 #### 逻辑与布线相分离
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】。
@ -199,27 +199,27 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
## MapReduce和分布式文件系统 ## MapReduce和分布式文件系统
MapReduce有点像Unix工具但分布在数千台机器上。像Unix工具一样它相当简单粗暴但令人惊异地管用。一个MapReduce作业可以和一个Unix进程相类比它接受一个或多个输入并产生一个或多个输出。 MapReduce有点像Unix工具但分布在数千台机器上。像Unix工具一样它相当简单粗暴但令人惊异地管用。一个MapReduce作业可以和一个Unix进程相类比它接受一个或多个输入并产生一个或多个输出。
和大多数Unix工具一样运行MapReduce作业通常不会修改输入除了生成输出外没有任何副作用。输出文件以连续的方式一次性写入一旦写入文件不会修改任何现有的文件部分 和大多数Unix工具一样运行MapReduce作业通常不会修改输入除了生成输出外没有任何副作用。输出文件以连续的方式一次性写入一旦写入文件不会修改任何现有的文件部分
虽然Unix工具使用`stdin`和`stdout`作为输入和输出但MapReduce作业在分布式文件系统上读写文件。在Hadoop的MapReduce实现中该文件系统被称为**HDFSHadoop分布式文件系统**一个Google文件系统GFS的开源实现【19】。 虽然Unix工具使用`stdin`和`stdout`作为输入和输出但MapReduce作业在分布式文件系统上读写文件。在Hadoop的MapReduce实现中该文件系统被称为**HDFSHadoop分布式文件系统**一个Google文件系统GFS的开源实现【19】。
除HDFS外还有各种其他分布式文件系统如GlusterFS和Quantcast File SystemQFS【20】。诸如Amazon S3Azure Blob存储和OpenStack Swift【21】等对象存储服务在很多方面都是相似的[^iv]。在本章中我们将主要使用HDFS作为示例但是这些原则适用于任何分布式文件系统。 除HDFS外还有各种其他分布式文件系统如GlusterFS和Quantcast File SystemQFS【20】。诸如Amazon S3Azure 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作业执行
MapReduce是一个编程框架你可以使用它编写代码来处理HDFS等分布式文件系统中的大型数据集。理解它的最简单方法是参考“[简单日志分析](#简单日志分析)”中的Web服务器日志分析示例。MapReduce中的数据处理模式与此示例非常相似 MapReduce是一个编程框架你可以使用它编写代码来处理HDFS等分布式文件系统中的大型数据集。理解它的最简单方法是参考“[简单日志分析](#简单日志分析)”中的Web服务器日志分析示例。MapReduce中的数据处理模式与此示例非常相似
1. 读取一组输入文件,并将其分解成**记录records**。在Web服务器日志示例中每条记录都是日志中的一行即`\n`是记录分隔符)。 1. 读取一组输入文件,并将其分解成**记录records**。在Web服务器日志示例中每条记录都是日志中的一行即`\n`是记录分隔符)。
2. 调用Mapper函数从每条输入记录中提取一对键值。在前面的例子中Mapper函数是`awk '{print $7}'`它提取URL`$7`)作为键,并将值留空。 2. 调用Mapper函数从每条输入记录中提取一对键值。在前面的例子中Mapper函数是`awk '{print $7}'`它提取URL`$7`)作为键,并将值留空。
@ -232,108 +232,108 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
***Mapper*** ***Mapper***
Mapper会在每条输入记录上调用一次其工作是从输入记录中提取键值。对于每个输入它可以生成任意数量的键值对包括None。它不会保留从一个输入记录到下一个记录的任何状态因此每个记录都是独立处理的。 Mapper会在每条输入记录上调用一次其工作是从输入记录中提取键值。对于每个输入它可以生成任意数量的键值对包括None。它不会保留从一个输入记录到下一个记录的任何状态因此每个记录都是独立处理的。
***Reducer*** ***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
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)中的m1m2和m3标记 [图10-1](img/fig10-1.png)显示了Hadoop MapReduce作业中的数据流。其并行化基于分区请参阅[第六章](ch6.md)作业的输入通常是HDFS中的一个目录输入目录中的每个文件或文件块都被认为是一个单独的分区可以单独处理map任务[图10-1](img/fig10-1.png)中的m1m2和m3标记
每个输入文件的大小通常是数百兆字节。 MapReduce调度器图中未显示试图在其中一台存储输入文件副本的机器上运行每个Mapper只要该机器有足够的备用RAM和CPU资源来运行Mapper任务【26】。这个原则被称为**将计算放在数据附近**【27】它节省了通过网络复制输入文件的开销减少网络负载并增加局部性。 每个输入文件的大小通常是数百兆字节。 MapReduce调度器图中未显示试图在其中一台存储输入文件副本的机器上运行每个Mapper只要该机器有足够的备用RAM和CPU资源来运行Mapper任务【26】。这个原则被称为**将计算放在数据附近**【27】它节省了通过网络复制输入文件的开销减少网络负载并增加局部性。
![](img/fig10-1.png) ![](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作业可以解决的问题范围很有限。以日志分析为例单个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的工作流调度器被开发出来包括OozieAzkabanLuigiAirflow和Pinball 【28】。 只有当作业成功完成后批处理作业的输出才会被视为有效的MapReduce会丢弃失败作业的部分输出。因此工作流中的一项作业只有在先前的作业 —— 即生产其输入的作业 —— 成功完成后才能开始。为了处理这些作业之间的依赖有很多针对Hadoop的工作流调度器被开发出来包括OozieAzkabanLuigiAirflow和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侧连接与分组 ### 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) ![](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) ![](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(*)`聚合) - 统计每个组中记录的数量例如在统计PV的例子中在SQL中表示为`COUNT(*)`聚合)
- 对某个特定字段求和SQL中的`SUM(fieldname)` - 对某个特定字段求和SQL中的`SUM(fieldname)`
@ -341,121 +341,121 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
使用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侧连接 ### 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】。索引中经常使用的部分将保留在操作系统的页面缓存中因而这种方法可以提供与内存散列表几乎一样快的随机查找性能但实际上并不需要数据集能放入内存中。 除了将较小的连接输入加载到内存散列表中另一种方法是将较小输入存储在本地磁盘上的只读索引中【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侧合并连接
如果输入数据集不仅以相同的方式进行分区,而且还基于相同的键进行**排序**则可适用另一种Map侧连接的变体。在这种情况下输入是否小到能放入内存并不重要因为这时候Mapper同样可以执行归并操作通常由Reducer执行的归并操作按键递增的顺序依次读取两个输入文件将具有相同键的记录配对。 如果输入数据集不仅以相同的方式进行分区,而且还基于相同的键进行**排序**则可适用另一种Map侧连接的变体。在这种情况下输入是否小到能放入内存并不重要因为这时候Mapper同样可以执行归并操作通常由Reducer执行的归并操作按键递增的顺序依次读取两个输入文件将具有相同键的记录配对。
如果能进行Map侧合并连接这通常意味着前一个MapReduce作业可能一开始就已经把输入数据做了分区并进行了排序。原则上这个连接就可以在前一个作业的Reduce阶段进行。但使用独立的仅Map作业有时也是合适的例如分好区且排好序的中间数据集可能还会用于其他目的。 如果能进行Map侧合并连接这通常意味着前一个MapReduce作业可能一开始就已经把输入数据做了分区并进行了排序。原则上这个连接就可以在前一个作业的Reduce阶段进行。但使用独立的仅Map作业有时也是合适的例如分好区且排好序的中间数据集可能还会用于其他目的。
#### MapReduce工作流与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】 搜索索引只是批处理工作流可能输出的一个例子。批处理的另一个常见用途是构建机器学习系统例如分类器比如垃圾邮件过滤器异常检测图像识别与推荐系统例如你可能认识的人你可能感兴趣的产品或相关的搜索【29】
这些批处理作业的输出通常是某种数据库例如可以通过给定用户ID查询该用户推荐好友的数据库或者可以通过产品ID查询相关产品的数据库【45】。 这些批处理作业的输出通常是某种数据库例如可以通过给定用户ID查询该用户推荐好友的数据库或者可以通过产品ID查询相关产品的数据库【45】。
这些数据库需要被处理用户请求的Web应用所查询而它们通常是独立于Hadoop基础设施的。那么批处理过程的输出如何回到Web应用可以查询的数据库中呢 这些数据库需要被处理用户请求的Web应用所查询而它们通常是独立于Hadoop基础设施的。那么批处理过程的输出如何回到Web应用可以查询的数据库中呢
最直接的选择可能是直接在Mapper或Reducer中使用你最爱的数据库的客户端库并从批处理作业直接写入数据库服务器一次写入一条记录。它能工作假设你的防火墙规则允许从你的Hadoop环境直接访问你的生产数据库但这并不是一个好主意出于以下几个原因 最直接的选择可能是直接在Mapper或Reducer中使用你最爱的数据库的客户端库并从批处理作业直接写入数据库服务器一次写入一条记录。它能工作假设你的防火墙规则允许从你的Hadoop环境直接访问你的生产数据库但这并不是一个好主意出于以下几个原因
- 正如前面在连接的上下文中讨论的那样,为每条记录发起一个网络请求,要比批处理任务的正常吞吐量慢几个数量级。即使客户端库支持批处理,性能也可能很差。 - 正如前面在连接的上下文中讨论的那样,为每条记录发起一个网络请求,要比批处理任务的正常吞吐量慢几个数量级。即使客户端库支持批处理,性能也可能很差。
- MapReduce作业经常并行运行许多任务。如果所有Mapper或Reducer都同时写入相同的输出数据库并以批处理的预期速率工作那么该数据库很可能被轻易压垮其查询性能可能变差。这可能会导致系统其他部分的运行问题【35】。 - MapReduce作业经常并行运行许多任务。如果所有Mapper或Reducer都同时写入相同的输出数据库并以批处理的预期速率工作那么该数据库很可能被轻易压垮其查询性能可能变差。这可能会导致系统其他部分的运行问题【35】。
- 通常情况下MapReduce为作业输出提供了一个干净利落的“全有或全无”保证如果作业成功则结果就是每个任务恰好执行一次所产生的输出即使某些任务失败且必须一路重试。如果整个作业失败则不会生成输出。然而从作业内部写入外部系统会产生外部可见的副作用这种副作用是不能以这种方式被隐藏的。因此你不得不去操心对其他系统可见的部分完成的作业结果并需要理解Hadoop任务尝试与预测执行的复杂性。 - 通常情况下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】 - 如果在代码中引入了一个错误,而输出错误或损坏了,则可以简单地回滚到代码的先前版本,然后重新运行该作业,输出将重新被纠正。或者,甚至更简单,你可以将旧的输出保存在不同的目录中,然后切换回原来的目录。具有读写事务的数据库没有这个属性:如果你部署了错误的代码,将错误的数据写入数据库,那么回滚代码将无法修复数据库中的数据。 (能够从错误代码中恢复的概念被称为**人类容错human fault tolerance**【50】
- 由于回滚很容易,比起在错误意味着不可挽回的伤害的环境,功能开发进展能快很多。这种**最小化不可逆性minimizing irreversibility** 的原则有利于敏捷软件开发【51】。 - 由于回滚很容易,比起在错误意味着不可挽回的伤害的环境,功能开发进展能快很多。这种**最小化不可逆性minimizing irreversibility** 的原则有利于敏捷软件开发【51】。
@ -463,94 +463,94 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
- 同一组文件可用作各种不同作业的输入,包括计算指标的监控作业并且评估作业的输出是否具有预期的性质(例如,将其与前一次运行的输出进行比较并测量差异) 。 - 同一组文件可用作各种不同作业的输入,包括计算指标的监控作业并且评估作业的输出是否具有预期的性质(例如,将其与前一次运行的输出进行比较并测量差异) 。
- 与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与分布式数据库的对比
正如我们所看到的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 machineTeradata和Tandem NonStop SQL就是这方面的先驱【52】。 当MapReduce论文发表时【1】它从某种意义上来说 —— 并不新鲜。我们在前几节中讨论的所有处理和并行连接算法已经在十多年前所谓的**大规模并行处理MPP massively parallel processing** 数据库中实现了【3,40】。比如Gamma database machineTeradata和Tandem NonStop SQL就是这方面的先驱【52】。
最大的区别是MPP数据库专注于在一组机器上并行执行分析SQL查询而MapReduce和分布式文件系统【19】的组合则更像是一个可以运行任意程序的通用操作系统。 最大的区别是MPP数据库专注于在一组机器上并行执行分析SQL查询而MapReduce和分布式文件系统【19】的组合则更像是一个可以运行任意程序的通用操作系统。
#### 存储多样性 #### 存储多样性
数据库要求你根据特定的模型(例如关系或文档)来构造数据,而分布式文件系统中的文件只是字节序列,可以使用任何数据模型和编码来编写。它们可能是数据库记录的集合,但同样可以是文本,图像,视频,传感器读数,稀疏矩阵,特征向量,基因组序列或任何其他类型的数据。 数据库要求你根据特定的模型(例如关系或文档)来构造数据,而分布式文件系统中的文件只是字节序列,可以使用任何数据模型和编码来编写。它们可能是数据库记录的集合,但同样可以是文本,图像,视频,传感器读数,稀疏矩阵,特征向量,基因组序列或任何其他类型的数据。
说白了Hadoop开放了将数据不加区分地转储到HDFS的可能性允许后续再研究如何进一步处理【53】。相比之下在将数据导入数据库专有存储格式之前MPP数据库通常需要对数据和查询模式进行仔细的前期建模。 说白了Hadoop开放了将数据不加区分地转储到HDFS的可能性允许后续再研究如何进一步处理【53】。相比之下在将数据导入数据库专有存储格式之前MPP数据库通常需要对数据和查询模式进行仔细的前期建模。
在纯粹主义者看来,这种仔细的建模和导入似乎是可取的,因为这意味着数据库的用户有更高质量的数据来处理。然而实践经验表明,简单地使数据快速可用 —— 即使它很古怪,难以使用,使用原始格式 —— 也通常要比事先决定理想数据模型要更有价值【54】。 在纯粹主义者看来,这种仔细的建模和导入似乎是可取的,因为这意味着数据库的用户有更高质量的数据来处理。然而实践经验表明,简单地使数据快速可用 —— 即使它很古怪,难以使用,使用原始格式 —— 也通常要比事先决定理想数据模型要更有价值【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】但在编写本文时YARNMesos或Kubernetes不支持通用的优先级抢占【60】。在任务不经常被终止的环境中MapReduce的这一设计决策就没有多少意义了。在下一节中我们将研究一些与MapReduce设计决策相异的替代方案。 在开源的集群调度器中,抢占的使用较少。 YARN的CapacityScheduler支持抢占以平衡不同队列的资源分配【58】但在编写本文时YARNMesos或Kubernetes不支持通用的优先级抢占【60】。在任务不经常被终止的环境中MapReduce的这一设计决策就没有多少意义了。在下一节中我们将研究一些与MapReduce设计决策相异的替代方案。
## MapReduce之后 ## MapReduce之后
虽然MapReduce在2000年代后期变得非常流行并受到大量的炒作但它只是分布式系统的许多可能的编程模型之一。对于不同的数据量数据结构和处理类型其他工具可能更适合表示计算。 虽然MapReduce在2000年代后期变得非常流行并受到大量的炒作但它只是分布式系统的许多可能的编程模型之一。对于不同的数据量数据结构和处理类型其他工具可能更适合表示计算。
不管如何我们在这一章花了大把时间来讨论MapReduce因为它是一种有用的学习工具它是分布式文件系统的一种相当简单明晰的抽象。在这里**简单**意味着我们能理解它在做什么而不是意味着使用它很简单。恰恰相反使用原始的MapReduce API来实现复杂的处理工作实际上是非常困难和费力的 —— 例如任意一种连接算法都需要你从头开始实现【37】。 不管如何我们在这一章花了大把时间来讨论MapReduce因为它是一种有用的学习工具它是分布式文件系统的一种相当简单明晰的抽象。在这里**简单**意味着我们能理解它在做什么而不是意味着使用它很简单。恰恰相反使用原始的MapReduce API来实现复杂的处理工作实际上是非常困难和费力的 —— 例如任意一种连接算法都需要你从头开始实现【37】。
针对直接使用MapReduce的困难在MapReduce上有很多高级编程模型PigHiveCascadingCrunch被创造出来作为建立在MapReduce之上的抽象。如果你了解MapReduce的原理那么它们学起来相当简单。而且它们的高级结构能显著简化许多常见批处理任务的实现。 针对直接使用MapReduce的困难在MapReduce上有很多高级编程模型PigHiveCascadingCrunch被创造出来作为建立在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管道连接的进程会同时启动输出一旦生成就会被消费。不同机器上的数据偏斜或负载不均意味着一个作业往往会有一些掉队的任务比其他任务要慢得多才能完成。必须等待至前驱作业的所有任务完成拖慢了整个工作流程的执行。 - MapReduce作业只有在前驱作业生成其输入中的所有任务都完成时才能启动而由Unix管道连接的进程会同时启动输出一旦生成就会被消费。不同机器上的数据偏斜或负载不均意味着一个作业往往会有一些掉队的任务比其他任务要慢得多才能完成。必须等待至前驱作业的所有任务完成拖慢了整个工作流程的执行。
- Mapper通常是多余的它们仅仅是读取刚刚由Reducer写入的同样文件为下一个阶段的分区和排序做准备。在许多情况下Mapper代码可能是前驱Reducer的一部分如果Reducer和Mapper的输出有着相同的分区与排序方式那么Reducer就可以直接串在一起而不用与Mapper相互交织。 - Mapper通常是多余的它们仅仅是读取刚刚由Reducer写入的同样文件为下一个阶段的分区和排序做准备。在许多情况下Mapper代码可能是前驱Reducer的一部分如果Reducer和Mapper的输出有着相同的分区与排序方式那么Reducer就可以直接串在一起而不用与Mapper相互交织。
@ -558,17 +558,17 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
#### 数据流引擎 #### 数据流引擎
为了解决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和Reduce阶段之间出现。
- 没有不必要的Map任务因为Mapper所做的工作通常可以合并到前面的Reduce算子中因为Mapper不会更改数据集的分区 - 没有不必要的Map任务因为Mapper所做的工作通常可以合并到前面的Reduce算子中因为Mapper不会更改数据集的分区
@ -577,43 +577,43 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
- 算子可以在输入就绪后立即开始执行;后续阶段无需等待前驱阶段整个完成后再开始。 - 算子可以在输入就绪后立即开始执行;后续阶段无需等待前驱阶段整个完成后再开始。
- 与MapReduce为每个任务启动一个新的JVM相比现有Java虚拟机JVM进程可以重用来运行新算子从而减少启动开销。 - 与MapReduce为每个任务启动一个新的JVM相比现有Java虚拟机JVM进程可以重用来运行新算子从而减少启动开销。
你可以使用数据流引擎执行与MapReduce工作流同样的计算而且由于此处所述的优化通常执行速度要明显快得多。既然算子是Map和Reduce的泛化那么相同的处理代码就可以在任一执行引擎上运行PigHive或Cascading中实现的工作流可以无需修改代码可以通过修改配置简单地从MapReduce切换到Tez或Spark【64】。 你可以使用数据流引擎执行与MapReduce工作流同样的计算而且由于此处所述的优化通常执行速度要明显快得多。既然算子是Map和Reduce的泛化那么相同的处理代码就可以在任一执行引擎上运行PigHive或Cascading中实现的工作流可以无需修改代码可以通过修改配置简单地从MapReduce切换到Tez或Spark【64】。
Tez是一个相当薄的库它依赖于YARN shuffle服务来实现节点间数据的实际复制【58】而Spark和Flink则是包含了独立网络通信层调度器及用户向API的大型框架。我们将简要讨论这些高级API。 Tez是一个相当薄的库它依赖于YARN shuffle服务来实现节点间数据的实际复制【58】而Spark和Flink则是包含了独立网络通信层调度器及用户向API的大型框架。我们将简要讨论这些高级API。
#### 容错 #### 容错
完全物化中间状态至分布式文件系统的一个优点是它具有持久性这使得MapReduce中的容错相当容易如果一个任务失败它可以在另一台机器上重新启动并从文件系统重新读取相同的输入。 完全物化中间状态至分布式文件系统的一个优点是它具有持久性这使得MapReduce中的容错相当容易如果一个任务失败它可以在另一台机器上重新启动并从文件系统重新读取相同的输入。
SparkFlink和Tez避免将中间状态写入HDFS因此它们采取了不同的方法来容错如果一台机器发生故障并且该机器上的中间状态丢失则它会从其他仍然可用的数据重新计算在可行的情况下是先前的中间状态要么就只能是原始输入数据通常在HDFS上 SparkFlink和Tez避免将中间状态写入HDFS因此它们采取了不同的方法来容错如果一台机器发生故障并且该机器上的中间状态丢失则它会从其他仍然可用的数据重新计算在可行的情况下是先前的中间状态要么就只能是原始输入数据通常在HDFS上
为了实现这种重新计算,框架必须跟踪一个给定的数据是如何计算的 —— 使用了哪些输入分区?应用了哪些算子? Spark使用**弹性分布式数据集RDDResilient Distributed Dataset** 的抽象来跟踪数据的谱系【61】而Flink对算子状态存档允许恢复运行在执行过程中遇到错误的算子【66】。 为了实现这种重新计算,框架必须跟踪一个给定的数据是如何计算的 —— 使用了哪些输入分区?应用了哪些算子? Spark使用**弹性分布式数据集RDDResilient Distributed Dataset** 的抽象来跟踪数据的谱系【61】而Flink对算子状态存档允许恢复运行在执行过程中遇到错误的算子【66】。
在重新计算数据时,重要的是要知道计算是否是**确定性的**:也就是说,给定相同的输入数据,算子是否始终产生相同的输出?如果一些丢失的数据已经发送给下游算子,这个问题就很重要。如果算子重新启动,重新计算的数据与原有的丢失数据不一致,下游算子很难解决新旧数据之间的矛盾。对于不确定性算子来说,解决方案通常是杀死下游算子,然后再重跑新数据。 在重新计算数据时,重要的是要知道计算是否是**确定性的**:也就是说,给定相同的输入数据,算子是否始终产生相同的输出?如果一些丢失的数据已经发送给下游算子,这个问题就很重要。如果算子重新启动,重新计算的数据与原有的丢失数据不一致,下游算子很难解决新旧数据之间的矛盾。对于不确定性算子来说,解决方案通常是杀死下游算子,然后再重跑新数据。
为了避免这种级联故障,最好让算子具有确定性。但需要注意的是,非确定性行为很容易悄悄溜进来:例如,许多编程语言在迭代哈希表的元素时不能对顺序作出保证,许多概率和统计算法显式依赖于使用随机数,以及用到系统时钟或外部数据源,这些都是都不确定性的行为。为了能可靠地从故障中恢复,需要消除这种不确定性因素,例如使用固定的种子生成伪随机数。 为了避免这种级联故障,最好让算子具有确定性。但需要注意的是,非确定性行为很容易悄悄溜进来:例如,许多编程语言在迭代哈希表的元素时不能对顺序作出保证,许多概率和统计算法显式依赖于使用随机数,以及用到系统时钟或外部数据源,这些都是都不确定性的行为。为了能可靠地从故障中恢复,需要消除这种不确定性因素,例如使用固定的种子生成伪随机数。
通过重算数据来从故障中恢复并不总是正确的答案:如果中间状态数据要比源数据小得多,或者如果计算量非常大,那么将中间数据物化为文件可能要比重新计算廉价的多。 通过重算数据来从故障中恢复并不总是正确的答案:如果中间状态数据要比源数据小得多,或者如果计算量非常大,那么将中间数据物化为文件可能要比重新计算廉价的多。
#### 关于物化的讨论 #### 关于物化的讨论
回到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】它试图根据链接到某个网页的其他网页来估计该网页的流行度。它作为配方的一部分用于确定网络搜索引擎呈现结果的顺序。
> 像SparkFlink和Tez这样的数据流引擎请参阅“[物化中间状态](#物化中间状态)”)通常将算子作为**有向无环图DAG** 的一部分安排在作业中。这与图处理不一样:在数据流引擎中,**从一个算子到另一个算子的数据流**被构造成一个图,而数据本身通常由关系型元组构成。在图处理中,数据本身具有图的形式。又一个不幸的命名混乱! > 像SparkFlink和Tez这样的数据流引擎请参阅“[物化中间状态](#物化中间状态)”)通常将算子作为**有向无环图DAG** 的一部分安排在作业中。这与图处理不一样:在数据流引擎中,**从一个算子到另一个算子的数据流**被构造成一个图,而数据本身通常由关系型元组构成。在图处理中,数据本身具有图的形式。又一个不幸的命名混乱!
许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在[图2-6](img/fig2-6.png)中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为**传递闭包transitive closure**)。 许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在[图2-6](img/fig2-6.png)中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为**传递闭包transitive closure**)。
可以在分布式文件系统中存储图包含顶点和边的列表的文件但是这种“重复至完成”的想法不能用普通的MapReduce来表示因为它只扫过一趟数据。这种算法因此经常以**迭代**的风格实现: 可以在分布式文件系统中存储图包含顶点和边的列表的文件但是这种“重复至完成”的想法不能用普通的MapReduce来表示因为它只扫过一趟数据。这种算法因此经常以**迭代**的风格实现:
1. 外部调度程序运行批处理来计算算法的一个步骤。 1. 外部调度程序运行批处理来计算算法的一个步骤。
2. 当批处理过程完成时,调度器检查它是否完成(基于完成条件 —— 例如,没有更多的边要跟进,或者与上次迭代相比的变化低于某个阈值)。 2. 当批处理过程完成时,调度器检查它是否完成(基于完成条件 —— 例如,没有更多的边要跟进,或者与上次迭代相比的变化低于某个阈值)。
@ -623,113 +623,113 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
#### Pregel处理模型 #### Pregel处理模型
针对图批处理的优化 —— **批量同步并行BSPBulk Synchronous Parallel** 计算模型【70】已经开始流行起来。其中Apache Giraph 【37】Spark的GraphX API和Flink的Gelly API 【71】实现了它。它也被称为**Pregel**模型因为Google的Pregel论文推广了这种处理图的方法【72】。 针对图批处理的优化 —— **批量同步并行BSPBulk 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】。 这种容错是通过在迭代结束时定期存档所有顶点的状态来实现的即将其全部状态写入持久化存储。如果某个节点发生故障并且其内存中的状态丢失则最简单的解决方法是将整个图计算回滚到上一个存档点然后重启计算。如果算法是确定性的且消息记录在日志中那么也可以选择性地只恢复丢失的分区就像之前讨论过的数据流引擎【72】。
#### 并行执行 #### 并行执行
顶点不需要知道它在哪台物理机器上执行当它向其他顶点发送消息时它只是简单地将消息发往某个顶点ID。图的分区取决于框架 —— 即,确定哪个顶点运行在哪台机器上,以及如何通过网络路由消息,以便它们到达正确的地方。 顶点不需要知道它在哪台物理机器上执行当它向其他顶点发送消息时它只是简单地将消息发往某个顶点ID。图的分区取决于框架 —— 即,确定哪个顶点运行在哪台机器上,以及如何通过网络路由消息,以便它们到达正确的地方。
由于编程模型一次仅处理一个顶点(有时称为“像顶点一样思考”),所以框架可以以任意方式对图分区。理想情况下如果顶点需要进行大量的通信,那么它们最好能被分区到同一台机器上。然而找到这样一种优化的分区方法是很困难的 —— 在实践中图经常按照任意分配的顶点ID分区而不会尝试将相关的顶点分组在一起。 由于编程模型一次仅处理一个顶点(有时称为“像顶点一样思考”),所以框架可以以任意方式对图分区。理想情况下如果顶点需要进行大量的通信,那么它们最好能被分区到同一台机器上。然而找到这样一种优化的分区方法是很困难的 —— 在实践中图经常按照任意分配的顶点ID分区而不会尝试将相关的顶点分组在一起。
因此,图算法通常会有很多跨机器通信的额外开销,而中间状态(节点之间发送的消息)往往比原始图大。通过网络发送消息的开销会显著拖慢分布式图算法的速度。 因此,图算法通常会有很多跨机器通信的额外开销,而中间状态(节点之间发送的消息)往往比原始图大。通过网络发送消息的开销会显著拖慢分布式图算法的速度。
出于这个原因如果你的图可以放入一台计算机的内存中那么单机甚至可能是单线程算法很可能会超越分布式批处理【73,74】。图比内存大也没关系只要能放入单台计算机的磁盘使用GraphChi等框架进行单机处理是就一个可行的选择【75】。如果图太大不适合单机处理那么像Pregel这样的分布式方法是不可避免的。高效的并行图算法是一个进行中的研究领域【76】。 出于这个原因如果你的图可以放入一台计算机的内存中那么单机甚至可能是单线程算法很可能会超越分布式批处理【73,74】。图比内存大也没关系只要能放入单台计算机的磁盘使用GraphChi等框架进行单机处理是就一个可行的选择【75】。如果图太大不适合单机处理那么像Pregel这样的分布式方法是不可避免的。高效的并行图算法是一个进行中的研究领域【76】。
### 高级API和语言 ### 高级API和语言
自MapReduce开始流行的这几年以来分布式批处理的执行引擎已经很成熟了。到目前为止基础设施已经足够强大能够存储和处理超过10,000台机器集群上的数PB的数据。由于在这种规模下物理执行批处理的问题已经被认为或多或少解决了所以关注点已经转向其他领域改进编程模型提高处理效率扩大这些技术可以解决的问题集。 自MapReduce开始流行的这几年以来分布式批处理的执行引擎已经很成熟了。到目前为止基础设施已经足够强大能够存储和处理超过10,000台机器集群上的数PB的数据。由于在这种规模下物理执行批处理的问题已经被认为或多或少解决了所以关注点已经转向其他领域改进编程模型提高处理效率扩大这些技术可以解决的问题集。
如前所述HivePigCascading和Crunch等高级语言和API变得越来越流行因为手写MapReduce作业实在是个苦力活。随着Tez的出现这些高级语言还有一个额外好处可以迁移到新的数据流执行引擎而无需重写作业代码。 Spark和Flink也有它们自己的高级数据流API通常是从FlumeJava中获取的灵感【34】。 如前所述HivePigCascading和Crunch等高级语言和API变得越来越流行因为手写MapReduce作业实在是个苦力活。随着Tez的出现这些高级语言还有一个额外好处可以迁移到新的数据流执行引擎而无需重写作业代码。 Spark和Flink也有它们自己的高级数据流API通常是从FlumeJava中获取的灵感【34】。
这些数据流API通常使用关系型构建块来表达一个计算按某个字段连接数据集按键对元组做分组按某些条件过滤并通过计数求和或其他函数来聚合元组。在内部这些操作是使用本章前面讨论过的各种连接和分组算法来实现的。 这些数据流API通常使用关系型构建块来表达一个计算按某个字段连接数据集按键对元组做分组按某些条件过滤并通过计数求和或其他函数来聚合元组。在内部这些操作是使用本章前面讨论过的各种连接和分组算法来实现的。
除了少写代码的明显优势之外这些高级接口还支持交互式用法在这种交互式使用中你可以在Shell中增量式编写分析代码频繁运行来观察它做了什么。这种开发风格在探索数据集和试验处理方法时非常有用。这也让人联想到Unix哲学我们在“[Unix哲学](#Unix哲学)”中讨论过这个问题。 除了少写代码的明显优势之外这些高级接口还支持交互式用法在这种交互式使用中你可以在Shell中增量式编写分析代码频繁运行来观察它做了什么。这种开发风格在探索数据集和试验处理方法时非常有用。这也让人联想到Unix哲学我们在“[Unix哲学](#Unix哲学)”中讨论过这个问题。
此外,这些高级接口不仅提高了人类的工作效率,也提高了机器层面的作业执行效率。 此外,这些高级接口不仅提高了人类的工作效率,也提高了机器层面的作业执行效率。
#### 向声明式查询语言的转变 #### 向声明式查询语言的转变
与硬写执行连接的代码相比,指定连接关系算子的优点是,框架可以分析连接输入的属性,并自动决定哪种上述连接算法最适合当前任务。 HiveSpark和Flink都有基于代价的查询优化器可以做到这一点甚至可以改变连接顺序最小化中间状态的数量【66,77,78,79】。 与硬写执行连接的代码相比,指定连接关系算子的优点是,框架可以分析连接输入的属性,并自动决定哪种上述连接算法最适合当前任务。 HiveSpark和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** —— 也就是说,你还有活儿要干,然而它的输入是永无止境的数据流。在这种情况下,作业永无完成之日。因为在任何时候都可能有更多的工作涌入。我们将看到,在某些方面上,流处理和批处理是相似的。但是关于无尽数据流的假设也对我们构建系统的方式产生了很多改变。

448
ch11.md
View File

@ -10,45 +10,45 @@
[TOC] [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** 在批处理中,文件被写入一次,然后可能被多个作业读取。类似地,在流处理术语中,一个事件由 **生产者producer** (也称为 **发布者publisher****发送者sender** )生成一次,然后可能由多个 **消费者consumer** **订阅者subscribers****接收者recipients** 进行处理【3】。在文件系统中文件名标识一组相关记录在流式系统中相关的事件通常被聚合为一个 **主题topic****流stream**
原则上讲,文件或数据库就足以连接生产者和消费者:生产者将其生成的每个事件写入数据存储,且每个消费者定期轮询数据存储,检查自上次运行以来新出现的事件。这实际上正是批处理在每天结束时处理当天数据时所做的事情。 原则上讲,文件或数据库就足以连接生产者和消费者:生产者将其生成的每个事件写入数据存储,且每个消费者定期轮询数据存储,检查自上次运行以来新出现的事件。这实际上正是批处理在每天结束时处理当天数据时所做的事情。
但当我们想要进行低延迟的连续处理时,如果数据存储不是为这种用途专门设计的,那么轮询开销就会很大。轮询的越频繁,能返回新事件的请求比例就越低,而额外开销也就越高。相比之下,最好能在新事件出现时直接通知消费者。 但当我们想要进行低延迟的连续处理时,如果数据存储不是为这种用途专门设计的,那么轮询开销就会很大。轮询的越频繁,能返回新事件的请求比例就越低,而额外开销也就越高。相比之下,最好能在新事件出现时直接通知消费者。
数据库在传统上对这种通知机制支持的并不好,关系型数据库通常有 **触发器trigger** 它们可以对变化插入表中的一行作出反应但是它们的功能非常有限并且在数据库设计中有些后顾之忧【4,5】。相应的是已经开发了专门的工具来提供事件通知。 数据库在传统上对这种通知机制支持的并不好,关系型数据库通常有 **触发器trigger** 它们可以对变化插入表中的一行作出反应但是它们的功能非常有限并且在数据库设计中有些后顾之忧【4,5】。相应的是已经开发了专门的工具来提供事件通知。
### 消息传递系统 ### 消息传递系统
向消费者通知新事件的常用方式是使用**消息传递系统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#网络拥塞和排队)”)。
@ -56,9 +56,9 @@
2. **如果节点崩溃或暂时脱机,会发生什么情况? —— 是否会有消息丢失?** 与数据库一样,持久性可能需要写入磁盘和/或复制的某种组合(请参阅“[复制与持久性](ch7.md#复制与持久性)”),这是有代价的。如果你能接受有时消息会丢失,则可能在同一硬件上获得更高的吞吐量和更低的延迟。 2. **如果节点崩溃或暂时脱机,会发生什么情况? —— 是否会有消息丢失?** 与数据库一样,持久性可能需要写入磁盘和/或复制的某种组合(请参阅“[复制与持久性](ch7.md#复制与持久性)”),这是有代价的。如果你能接受有时消息会丢失,则可能在同一硬件上获得更高的吞吐量和更低的延迟。
是否可以接受消息丢失取决于应用。例如对于周期传输的传感器读数和指标偶尔丢失的数据点可能并不重要因为更新的值会在短时间内发出。但要注意如果大量的消息被丢弃可能无法立刻意识到指标已经不正确了【7】。如果你正在对事件计数那么它们能够可靠送达是更重要的因为每个丢失的消息都意味着使计数器的错误扩大。 是否可以接受消息丢失取决于应用。例如对于周期传输的传感器读数和指标偶尔丢失的数据点可能并不重要因为更新的值会在短时间内发出。但要注意如果大量的消息被丢弃可能无法立刻意识到指标已经不正确了【7】。如果你正在对事件计数那么它们能够可靠送达是更重要的因为每个丢失的消息都意味着使计数器的错误扩大。
我们在[第十章](ch10.md)中探讨的批处理系统的一个很好的特性是,它们提供了强大的可靠性保证:失败的任务会自动重试,失败任务的部分输出会自动丢弃。这意味着输出与没有发生故障一样,这有助于简化编程模型。在本章的后面,我们将研究如何在流处理的上下文中提供类似的保证。 我们在[第十章](ch10.md)中探讨的批处理系统的一个很好的特性是,它们提供了强大的可靠性保证:失败的任务会自动重试,失败任务的部分输出会自动丢弃。这意味着输出与没有发生故障一样,这有助于简化编程模型。在本章的后面,我们将研究如何在流处理的上下文中提供类似的保证。
#### 直接从生产者传递给消费者 #### 直接从生产者传递给消费者
@ -69,94 +69,94 @@
* StatsD 【10】和Brubeck 【7】使用不可靠的UDP消息传递来收集网络中所有机器的指标并对其进行监控。 在StatsD协议中只有接收到所有消息才认为计数器指标是正确的使用UDP将使得指标处在一种最佳近似状态【11】。另请参阅“[TCP与UDP](ch8.md#TCP与UDP)” * 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发出请求。 * 如果消费者在网络上公开了服务生产者可以直接发送HTTP或RPC请求请参阅“[服务中的数据流REST与RPC](ch4.md#服务中的数据流REST与RPC)”将消息推送给使用者。这就是webhooks背后的想法【12】一种服务的回调URL被注册到另一个服务中并且每当事件发生时都会向该URL发出请求。
尽管这些直接消息传递系统在设计它们的环境中运行良好,但是它们通常要求应用代码意识到消息丢失的可能性。它们的容错程度极为有限:即使协议检测到并重传在网络中丢失的数据包,它们通常也只是假设生产者和消费者始终在线。 尽管这些直接消息传递系统在设计它们的环境中运行良好,但是它们通常要求应用代码意识到消息丢失的可能性。它们的容错程度极为有限:即使协议检测到并重传在网络中丢失的数据包,它们通常也只是假设生产者和消费者始终在线。
如果消费者处于脱机状态,则可能会丢失其不可达时发送的消息。一些协议允许生产者重试失败的消息传递,但当生产者崩溃时,它可能会丢失消息缓冲区及其本应发送的消息,这种方法可能就没用了。 如果消费者处于脱机状态,则可能会丢失其不可达时发送的消息。一些协议允许生产者重试失败的消息传递,但当生产者崩溃时,它可能会丢失消息缓冲区及其本应发送的消息,这种方法可能就没用了。
#### 消息代理 #### 消息代理
一种广泛使用的替代方法是通过**消息代理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*** ***负载均衡load balancing***
每条消息都被传递给消费者**之一**所以处理该主题下消息的工作能被多个消费者共享。代理可以为消费者任意分配消息。当处理消息的代价高昂希望能并行处理消息时此模式非常有用在AMQP中可以通过让多个客户端从同一个队列中消费来实现负载均衡而在JMS中则称之为**共享订阅shared subscription**)。 每条消息都被传递给消费者**之一**所以处理该主题下消息的工作能被多个消费者共享。代理可以为消费者任意分配消息。当处理消息的代价高昂希望能并行处理消息时此模式非常有用在AMQP中可以通过让多个客户端从同一个队列中消费来实现负载均衡而在JMS中则称之为**共享订阅shared subscription**)。
***扇出fan-out*** ***扇出fan-out***
每条消息都被传递给**所有**消费者。扇出允许几个独立的消费者各自“收听”相同的消息广播,而不会相互影响 —— 这个流处理中的概念对应批处理中多个不同批处理作业读取同一份输入文件 JMS中的主题订阅与AMQP中的交叉绑定提供了这一功能 每条消息都被传递给**所有**消费者。扇出允许几个独立的消费者各自“收听”相同的消息广播,而不会相互影响 —— 这个流处理中的概念对应批处理中多个不同批处理作业读取同一份输入文件 JMS中的主题订阅与AMQP中的交叉绑定提供了这一功能
![](img/fig11-1.png) ![](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按照m4m3m5的顺序处理消息。因此m3和m4的交付顺序与生产者1的发送顺序不同。 当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在[图11-2](img/fig11-2.png)中消费者通常按照生产者发送的顺序处理消息。然而消费者2在处理消息m3时崩溃与此同时消费者1正在处理消息m4。未确认的消息m3随后被重新发送给消费者1结果消费者1按照m4m3m5的顺序处理消息。因此m3和m4的交付顺序与生产者1的发送顺序不同。
![](img/fig11-2.png) ![](img/fig11-2.png)
**图11-2 在处理m3时消费者2崩溃因此稍后重传至消费者1** **图11-2 在处理m3时消费者2崩溃因此稍后重传至消费者1**
即使消息代理试图保留消息的顺序如JMS和AMQP标准所要求的负载均衡与重传的组合也不可避免地导致消息被重新排序。为避免此问题你可以让每个消费者使用单独的队列即不使用负载均衡功能。如果消息是完全独立的则消息顺序重排并不是一个问题。但正如我们将在本章后续部分所述如果消息之间存在因果依赖关系这就是一个很重要的问题。 即使消息代理试图保留消息的顺序如JMS和AMQP标准所要求的负载均衡与重传的组合也不可避免地导致消息被重新排序。为避免此问题你可以让每个消费者使用单独的队列即不使用负载均衡功能。如果消息是完全独立的则消息顺序重排并不是一个问题。但正如我们将在本章后续部分所述如果消息之间存在因果依赖关系这就是一个很重要的问题。
### 分区日志 ### 分区日志
通过网络发送数据包或向网络服务发送请求通常是短暂的操作,不会留下永久的痕迹。尽管可以永久记录(通过抓包与日志),但我们通常不这么做。即使是将消息持久地写入磁盘的消息代理,在送达给消费者之后也会很快删除消息,因为它们建立在短暂消息传递的思维方式上。 通过网络发送数据包或向网络服务发送请求通常是短暂的操作,不会留下永久的痕迹。尽管可以永久记录(通过抓包与日志),但我们通常不这么做。即使是将消息持久地写入磁盘的消息代理,在送达给消费者之后也会很快删除消息,因为它们建立在短暂消息传递的思维方式上。
数据库和文件系统采用截然相反的方法论:至少在某人显式删除前,通常写入数据库或文件的所有内容都要被永久记录下来。 数据库和文件系统采用截然相反的方法论:至少在某人显式删除前,通常写入数据库或文件的所有内容都要被永久记录下来。
这种思维方式上的差异对创建衍生数据的方式有巨大影响。如[第十章](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) ![](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]。 * 共享消费主题工作的节点数,最多为该主题中的日志分区数,因为同一个分区内的所有消息被递送到同一个节点[^i]。
* 如果某条消息处理缓慢,则它会阻塞该分区中后续消息的处理(一种行首阻塞的形式;请参阅“[描述性能](ch1.md#描述性能)”)。 * 如果某条消息处理缓慢,则它会阻塞该分区中后续消息的处理(一种行首阻塞的形式;请参阅“[描述性能](ch1.md#描述性能)”)。
@ -167,85 +167,85 @@
#### 消费者偏移量 #### 消费者偏移量
顺序消费一个分区使得判断消息是否已经被处理变得相当容易:所有偏移量小于消费者的当前偏移量的消息已经被处理,而具有更大偏移量的消息还没有被看到。因此,代理不需要跟踪确认每条消息,只需要定期记录消费者的偏移即可。这种方法减少了额外簿记开销,而且在批处理和流处理中采用这种方法有助于提高基于日志的系统的吞吐量。 顺序消费一个分区使得判断消息是否已经被处理变得相当容易:所有偏移量小于消费者的当前偏移量的消息已经被处理,而具有更大偏移量的消息还没有被看到。因此,代理不需要跟踪确认每条消息,只需要定期记录消费者的偏移即可。这种方法减少了额外簿记开销,而且在批处理和流处理中采用这种方法有助于提高基于日志的系统的吞吐量。
实际上,这种偏移量与单领导者数据库复制中常见的日志序列号非常相似,我们在“[设置新从库](ch5.md#设置新从库)”中讨论了这种情况。在数据库复制中,日志序列号允许跟随者断开连接后,重新连接到领导者,并在不跳过任何写入的情况下恢复复制。这里原理完全相同:消息代理表现得像一个主库,而消费者就像一个从库。 实际上,这种偏移量与单领导者数据库复制中常见的日志序列号非常相似,我们在“[设置新从库](ch5.md#设置新从库)”中讨论了这种情况。在数据库复制中,日志序列号允许跟随者断开连接后,重新连接到领导者,并在不跳过任何写入的情况下恢复复制。这里原理完全相同:消息代理表现得像一个主库,而消费者就像一个从库。
如果消费者节点失效,则失效消费者的分区将指派给其他节点,并从最后记录的偏移量开始消费消息。如果消费者已经处理了后续的消息,但还没有记录它们的偏移量,那么重启后这些消息将被处理两次。我们将在本章后面讨论这个问题的处理方法。 如果消费者节点失效,则失效消费者的分区将指派给其他节点,并从最后记录的偏移量开始消费消息。如果消费者已经处理了后续的消息,但还没有记录它们的偏移量,那么重启后这些消息将被处理两次。我们将在本章后面讨论这个问题的处理方法。
#### 磁盘空间使用 #### 磁盘空间使用
如果只追加写入日志,则磁盘空间终究会耗尽。为了回收磁盘空间,日志实际上被分割成段,并不时地将旧段删除或移动到归档存储。 (我们将在后面讨论一种更为复杂的磁盘空间释放方式) 如果只追加写入日志,则磁盘空间终究会耗尽。为了回收磁盘空间,日志实际上被分割成段,并不时地将旧段删除或移动到归档存储。 (我们将在后面讨论一种更为复杂的磁盘空间释放方式)
这就意味着如果一个慢消费者跟不上消息产生的速率而落后得太多,它的消费偏移量指向了删除的段,那么它就会错过一些消息。实际上,日志实现了一个有限大小的缓冲区,当缓冲区填满时会丢弃旧消息,它也被称为**循环缓冲区circular buffer** 或**环形缓冲区ring buffer**。不过由于缓冲区在磁盘上,因此缓冲区可能相当的大。 这就意味着如果一个慢消费者跟不上消息产生的速率而落后得太多,它的消费偏移量指向了删除的段,那么它就会错过一些消息。实际上,日志实现了一个有限大小的缓冲区,当缓冲区填满时会丢弃旧消息,它也被称为**循环缓冲区circular buffer** 或**环形缓冲区ring buffer**。不过由于缓冲区在磁盘上,因此缓冲区可能相当的大。
让我们做个简单计算。在撰写本文时典型的大型硬盘容量为6TB顺序写入吞吐量为150MB/s。如果以最快的速度写消息则需要大约11个小时才能填满磁盘。因而磁盘可以缓冲11个小时的消息之后它将开始覆盖旧的消息。即使使用多个磁盘和机器这个比率也是一样的。实践中的部署很少能用满磁盘的写入带宽所以通常可以保存一个几天甚至几周的日志缓冲区。 让我们做个简单计算。在撰写本文时典型的大型硬盘容量为6TB顺序写入吞吐量为150MB/s。如果以最快的速度写消息则需要大约11个小时才能填满磁盘。因而磁盘可以缓冲11个小时的消息之后它将开始覆盖旧的消息。即使使用多个磁盘和机器这个比率也是一样的。实践中的部署很少能用满磁盘的写入带宽所以通常可以保存一个几天甚至几周的日志缓冲区。
不管保留多长时间的消息日志的吞吐量或多或少保持不变因为无论如何每个消息都会被写入磁盘【18】。这种行为与默认将消息保存在内存中仅当队列太长时才写入磁盘的消息传递系统形成鲜明对比。当队列很短时这些系统非常快而当这些系统开始写入磁盘时就要慢的多所以吞吐量取决于保留的历史数量。 不管保留多长时间的消息日志的吞吐量或多或少保持不变因为无论如何每个消息都会被写入磁盘【18】。这种行为与默认将消息保存在内存中仅当队列太长时才写入磁盘的消息传递系统形成鲜明对比。当队列很短时这些系统非常快而当这些系统开始写入磁盘时就要慢的多所以吞吐量取决于保留的历史数量。
#### 当消费者跟不上生产者时 #### 当消费者跟不上生产者时
在“[消息传递系统](#消息传递系统)”中,如果消费者无法跟上生产者发送信息的速度时,我们讨论了三种选择:丢弃信息,进行缓冲或施加背压。在这种分类法里,基于日志的方法是缓冲的一种形式,具有很大但大小固定的缓冲区(受可用磁盘空间的限制)。 在“[消息传递系统](#消息传递系统)”中,如果消费者无法跟上生产者发送信息的速度时,我们讨论了三种选择:丢弃信息,进行缓冲或施加背压。在这种分类法里,基于日志的方法是缓冲的一种形式,具有很大但大小固定的缓冲区(受可用磁盘空间的限制)。
如果消费者远远落后,而所要求的信息比保留在磁盘上的信息还要旧,那么它将不能读取这些信息,所以代理实际上丢弃了比缓冲区容量更大的旧信息。你可以监控消费者落后日志头部的距离,如果落后太多就发出报警。由于缓冲区很大,因而有足够的时间让运维人员来修复慢消费者,并在消息开始丢失之前让其赶上。 如果消费者远远落后,而所要求的信息比保留在磁盘上的信息还要旧,那么它将不能读取这些信息,所以代理实际上丢弃了比缓冲区容量更大的旧信息。你可以监控消费者落后日志头部的距离,如果落后太多就发出报警。由于缓冲区很大,因而有足够的时间让运维人员来修复慢消费者,并在消息开始丢失之前让其赶上。
即使消费者真的落后太多开始丢失消息,也只有那个消费者受到影响;它不会中断其他消费者的服务。这是一个巨大的运维优势:你可以实验性地消费生产日志,以进行开发,测试或调试,而不必担心会中断生产服务。当消费者关闭或崩溃时,会停止消耗资源,唯一剩下的只有消费者偏移量。 即使消费者真的落后太多开始丢失消息,也只有那个消费者受到影响;它不会中断其他消费者的服务。这是一个巨大的运维优势:你可以实验性地消费生产日志,以进行开发,测试或调试,而不必担心会中断生产服务。当消费者关闭或崩溃时,会停止消耗资源,唯一剩下的只有消费者偏移量。
这种行为也与传统的消息代理形成了鲜明对比,在那种情况下,你需要小心地删除那些消费者已经关闭的队列—— 否则那些队列就会累积不必要的消息,从其他仍活跃的消费者那里占走内存。 这种行为也与传统的消息代理形成了鲜明对比,在那种情况下,你需要小心地删除那些消费者已经关闭的队列—— 否则那些队列就会累积不必要的消息,从其他仍活跃的消费者那里占走内存。
#### 重播旧消息 #### 重播旧消息
我们之前提到使用AMQP和JMS风格的消息代理处理和确认消息是一个破坏性的操作因为它会导致消息在代理上被删除。另一方面在基于日志的消息代理中使用消息更像是从文件中读取数据这是只读操作不会更改日志。 我们之前提到使用AMQP和JMS风格的消息代理处理和确认消息是一个破坏性的操作因为它会导致消息在代理上被删除。另一方面在基于日志的消息代理中使用消息更像是从文件中读取数据这是只读操作不会更改日志。
除了消费者的任何输出之外,处理的唯一副作用是消费者偏移量的前进。但偏移量是在消费者的控制之下的,所以如果需要的话可以很容易地操纵:例如你可以用昨天的偏移量跑一个消费者副本,并将输出写到不同的位置,以便重新处理最近一天的消息。你可以使用各种不同的处理代码重复任意次。 除了消费者的任何输出之外,处理的唯一副作用是消费者偏移量的前进。但偏移量是在消费者的控制之下的,所以如果需要的话可以很容易地操纵:例如你可以用昨天的偏移量跑一个消费者副本,并将输出写到不同的位置,以便重新处理最近一天的消息。你可以使用各种不同的处理代码重复任意次。
这一方面使得基于日志的消息传递更像上一章的批处理其中衍生数据通过可重复的转换过程与输入数据显式分离。它允许进行更多的实验更容易从错误和漏洞中恢复使其成为在组织内集成数据流的良好工具【24】。 这一方面使得基于日志的消息传递更像上一章的批处理其中衍生数据通过可重复的转换过程与输入数据显式分离。它允许进行更多的实验更容易从错误和漏洞中恢复使其成为在组织内集成数据流的良好工具【24】。
## 数据库与流 ## 数据库与流
我们已经在消息代理和数据库之间进行了一些比较。尽管传统上它们被视为单独的工具类别,但是我们看到基于日志的消息代理已经成功地从数据库中获取灵感并将其应用于消息传递。我们也可以反过来:从消息传递和流中获取灵感,并将它们应用于数据库。 我们已经在消息代理和数据库之间进行了一些比较。尽管传统上它们被视为单独的工具类别,但是我们看到基于日志的消息代理已经成功地从数据库中获取灵感并将其应用于消息传递。我们也可以反过来:从消息传递和流中获取灵感,并将它们应用于数据库。
我们之前曾经说过,事件是某个时刻发生的事情的记录。发生的事情可能是用户操作(例如键入搜索查询)或读取传感器,但也可能是**写入数据库**。某些东西被写入数据库的事实是可以被捕获、存储和处理的事件。这一观察结果表明,数据库和数据流之间的联系不仅仅是磁盘日志的物理存储 —— 而是更深层的联系。 我们之前曾经说过,事件是某个时刻发生的事情的记录。发生的事情可能是用户操作(例如键入搜索查询)或读取传感器,但也可能是**写入数据库**。某些东西被写入数据库的事实是可以被捕获、存储和处理的事件。这一观察结果表明,数据库和数据流之间的联系不仅仅是磁盘日志的物理存储 —— 而是更深层的联系。
事实上,复制日志(请参阅“[复制日志的实现](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) ![](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) ![](img/fig11-5.png)
@ -253,99 +253,99 @@
#### 变更数据捕获的实现 #### 变更数据捕获的实现
我们可以将日志消费者叫做**衍生数据系统**,正如在[第三部分](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支持 #### 变更流的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} \\ state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\
stream(t) = \frac{d\ state(t)}{dt} stream(t) = \frac{d\ state(t)}{dt}
@ -354,55 +354,55 @@ $$
**图11-6 应用当前状态与事件流之间的关系** **图11-6 应用当前状态与事件流之间的关系**
如果你持久存储了变更日志那么重现状态就非常简单。如果你认为事件日志是你的记录系统而所有的衍生状态都从它派生而来那么系统中的数据流动就容易理解的多。正如帕特·赫兰Pat Helland所说的【52】 如果你持久存储了变更日志那么重现状态就非常简单。如果你认为事件日志是你的记录系统而所有的衍生状态都从它派生而来那么系统中的数据流动就容易理解的多。正如帕特·赫兰Pat Helland所说的【52】
> 事务日志记录了数据库的所有变更。高速追加是更改日志的唯一方法。从这个角度来看,数据库的内容其实是日志中记录最新值的缓存。日志才是真相,数据库是日志子集的缓存,这一缓存子集恰好来自日志中每条记录与索引值的最新值。 > 事务日志记录了数据库的所有变更。高速追加是更改日志的唯一方法。从这个角度来看,数据库的内容其实是日志中记录最新值的缓存。日志才是真相,数据库是日志子集的缓存,这一缓存子集恰好来自日志中每条记录与索引值的最新值。
日志压缩(如“[日志压缩](#日志压缩)”中所述)是连接日志与数据库状态之间的桥梁:它只保留每条记录的最新版本,并丢弃被覆盖的版本。 日志压缩(如“[日志压缩](#日志压缩)”中所述)是连接日志与数据库状态之间的桥梁:它只保留每条记录的最新版本,并丢弃被覆盖的版本。
#### 不可变事件的优点 #### 不可变事件的优点
数据库中的不变性是一个古老的概念。例如会计在几个世纪以来一直在财务记账中应用不变性。一笔交易发生时它被记录在一个仅追加写入的分类帐中实质上是描述货币、商品或服务转手的事件日志。账目比如利润、亏损、资产负债表是从分类账中的交易求和衍生而来【53】。 数据库中的不变性是一个古老的概念。例如会计在几个世纪以来一直在财务记账中应用不变性。一笔交易发生时它被记录在一个仅追加写入的分类帐中实质上是描述货币、商品或服务转手的事件日志。账目比如利润、亏损、资产负债表是从分类账中的交易求和衍生而来【53】。
如果发生错误,会计师不会删除或更改分类帐中的错误交易 —— 而是添加另一笔交易以补偿错误例如退还一笔不正确的费用。不正确的交易将永远保留在分类帐中对于审计而言可能非常重要。如果从不正确的分类账衍生出的错误数字已经公布那么下一个会计周期的数字就会包括一个更正。这个过程在会计事务中是很常见的【54】。 如果发生错误,会计师不会删除或更改分类帐中的错误交易 —— 而是添加另一笔交易以补偿错误例如退还一笔不正确的费用。不正确的交易将永远保留在分类帐中对于审计而言可能非常重要。如果从不正确的分类账衍生出的错误数字已经公布那么下一个会计周期的数字就会包括一个更正。这个过程在会计事务中是很常见的【54】。
尽管这种可审计性只在金融系统中尤其重要,但对于不受这种严格监管的许多其他系统,也是很有帮助的。如“[批处理输出的哲学](ch10.md#批处理输出的哲学)”中所讨论的,如果你意外地部署了将错误数据写入数据库的错误代码,当代码会破坏性地覆写数据时,恢复要困难得多。使用不可变事件的仅追加日志,诊断问题与故障恢复就要容易的多。 尽管这种可审计性只在金融系统中尤其重要,但对于不受这种严格监管的许多其他系统,也是很有帮助的。如“[批处理输出的哲学](ch10.md#批处理输出的哲学)”中所讨论的,如果你意外地部署了将错误数据写入数据库的错误代码,当代码会破坏性地覆写数据时,恢复要困难得多。使用不可变事件的仅追加日志,诊断问题与故障恢复就要容易的多。
不可变的事件也包含了比当前状态更多的信息。例如在购物网站上顾客可以将物品添加到他们的购物车然后再将其移除。虽然从履行订单的角度第二个事件取消了第一个事件但对分析目的而言知道客户考虑过某个特定项而之后又反悔可能是很有用的。也许他们会选择在未来购买或者他们已经找到了替代品。这个信息被记录在事件日志中但对于移出购物车就删除记录的数据库而言这个信息在移出购物车时可能就丢失了【42】。 不可变的事件也包含了比当前状态更多的信息。例如在购物网站上顾客可以将物品添加到他们的购物车然后再将其移除。虽然从履行订单的角度第二个事件取消了第一个事件但对分析目的而言知道客户考虑过某个特定项而之后又反悔可能是很有用的。也许他们会选择在未来购买或者他们已经找到了替代品。这个信息被记录在事件日志中但对于移出购物车就删除记录的数据库而言这个信息在移出购物车时可能就丢失了【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】。 添加从事件日志到数据库的显式转换能够使应用更容易地随时间演进如果你想要引入一个新功能以新的方式表示现有数据则可以使用事件日志来构建一个单独的、针对新功能的读取优化视图无需修改现有系统而与之共存。并行运行新旧系统通常比在现有系统中执行复杂的模式迁移更容易。一旦不再需要旧的系统你可以简单地关闭它并回收其资源【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#索引和快照隔离)” )。 GitMercurial和Fossil等版本控制系统也依靠不可变的数据来保存文件的版本历史记录。 许多不使用事件溯源模型的系统也还是依赖不可变性:各种数据库在内部使用不可变的数据结构或多版本数据来支持时间点快照(请参阅“[索引和快照隔离](ch7.md#索引和快照隔离)” )。 GitMercurial和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#立法与自律)”中所看到的。
@ -416,11 +416,11 @@ $$
2. 你能以某种方式将事件推送给用户,例如发送报警邮件或推送通知,或将事件流式传输到可实时显示的仪表板上。在这种情况下,人是流的最终消费者。 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侧连接与分组)”)。容错机制也必须改变:对于已经运行了几分钟的批处理作业,可以简单地从头开始重启失败任务,但是对于已经运行数年的流作业,重启后从头开始跑可能并不是一个可行的选项。
### 流处理的应用 ### 流处理的应用
@ -435,47 +435,47 @@ $$
#### 复合事件处理 #### 复合事件处理
**复合事件处理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】ApamaTIBCO StreamBase和SQLstream。像Samza这样的分布式流处理组件支持使用SQL在流上进行声明式查询【71】。 CEP的实现包括Esper【69】IBM InfoSphere Streams【70】ApamaTIBCO 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 StormSpark StreamingFlinkConcordSamza和Kafka Streams 【74】。托管服务包括Google Cloud Dataflow和Azure Stream Analytics。 许多开源分布式流处理框架的设计都是针对分析设计的例如Apache StormSpark StreamingFlinkConcordSamza和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 #### 消息传递和RPC
在“[消息传递中的数据流](ch4.md#消息传递中的数据流)”中我们讨论过消息传递系统可以作为RPC的替代方案即作为一种服务间通信的机制比如在Actor模型中所使用的那样。尽管这些系统也是基于消息和事件但我们通常不会将其视作流处理组件 在“[消息传递中的数据流](ch4.md#消息传递中的数据流)”中我们讨论过消息传递系统可以作为RPC的替代方案即作为一种服务间通信的机制比如在Actor模型中所使用的那样。尽管这些系统也是基于消息和事件但我们通常不会将其视作流处理组件
* Actor框架主要是管理模块通信的并发和分布式执行的一种机制而流处理主要是一种数据管理技术。 * Actor框架主要是管理模块通信的并发和分布式执行的一种机制而流处理主要是一种数据管理技术。
* Actor之间的交流往往是短暂的、一对一的而事件日志则是持久的、多订阅者的。 * Actor之间的交流往往是短暂的、一对一的而事件日志则是持久的、多订阅者的。
@ -483,29 +483,29 @@ $$
也就是说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) ![](img/fig11-7.png)
@ -513,11 +513,11 @@ $$
#### 知道什么时候准备好了 #### 知道什么时候准备好了
用事件时间来定义窗口的一个棘手的问题是,你永远也无法确定是不是已经收到了特定窗口的所有事件,还是说还有一些事件正在来的路上。 用事件时间来定义窗口的一个棘手的问题是,你永远也无法确定是不是已经收到了特定窗口的所有事件,还是说还有一些事件正在来的路上。
例如假设你将事件分组为一分钟的窗口以便统计每分钟的请求数。你已经计数了一些带有本小时内第37分钟时间戳的事件时间流逝现在进入的主要都是本小时内第38和第39分钟的事件。什么时候才能宣布你已经完成了第37分钟的窗口计数并输出其计数器值 例如假设你将事件分组为一分钟的窗口以便统计每分钟的请求数。你已经计数了一些带有本小时内第37分钟时间戳的事件时间流逝现在进入的主要都是本小时内第38和第39分钟的事件。什么时候才能宣布你已经完成了第37分钟的窗口计数并输出其计数器值
在一段时间没有看到任何新的事件之后,你可以超时并宣布一个窗口已经就绪,但仍然可能发生这种情况:某些事件被缓冲在另一台机器上,由于网络中断而延迟。你需要能够处理这种在窗口宣告完成之后到达的 **滞留straggler** 事件。大体上你有两种选择【1】 在一段时间没有看到任何新的事件之后,你可以超时并宣布一个窗口已经就绪,但仍然可能发生这种情况:某些事件被缓冲在另一台机器上,由于网络中断而延迟。你需要能够处理这种在窗口宣告完成之后到达的 **滞留straggler** 事件。大体上你有两种选择【1】
1. 忽略这些滞留事件,因为在正常情况下它们可能只是事件中的一小部分。你可以将丢弃事件的数量作为一个监控指标,并在出现大量丢消息的情况时报警。 1. 忽略这些滞留事件,因为在正常情况下它们可能只是事件中的一小部分。你可以将丢弃事件的数量作为一个监控指标,并在出现大量丢消息的情况时报警。
2. 发布一个**更正correction**,一个包括滞留事件的更新窗口值。你可能还需要收回以前的输出。 2. 发布一个**更正correction**,一个包括滞留事件的更新窗口值。你可能还需要收回以前的输出。
@ -526,9 +526,9 @@ $$
#### 你用的是谁的时钟? #### 你用的是谁的时钟?
当事件可能在系统内多个地方进行缓冲时,为事件分配时间戳更加困难了。例如,考虑一个移动应用向服务器上报关于用量的事件。该应用可能会在设备处于脱机状态时被使用,在这种情况下,它将在设备本地缓冲事件,并在下一次互联网连接可用时向服务器上报这些事件(可能是几小时甚至几天)。对于这个流的任意消费者而言,它们就如延迟极大的滞留事件一样。 当事件可能在系统内多个地方进行缓冲时,为事件分配时间戳更加困难了。例如,考虑一个移动应用向服务器上报关于用量的事件。该应用可能会在设备处于脱机状态时被使用,在这种情况下,它将在设备本地缓冲事件,并在下一次互联网连接可用时向服务器上报这些事件(可能是几小时甚至几天)。对于这个流的任意消费者而言,它们就如延迟极大的滞留事件一样。
在这种情况下,事件上的事件戳实际上应当是用户交互发生的时间,取决于移动设备的本地时钟。然而用户控制的设备上的时钟通常是不可信的,因为它可能会被无意或故意设置成错误的时间(请参阅“[时钟同步与准确性](ch8.md#时钟同步与准确性)”)。服务器收到事件的时间(取决于服务器的时钟)可能是更准确的,因为服务器在你的控制之下,但在描述用户交互方面意义不大。 在这种情况下,事件上的事件戳实际上应当是用户交互发生的时间,取决于移动设备的本地时钟。然而用户控制的设备上的时钟通常是不可信的,因为它可能会被无意或故意设置成错误的时间(请参阅“[时钟同步与准确性](ch8.md#时钟同步与准确性)”)。服务器收到事件的时间(取决于服务器的时钟)可能是更准确的,因为服务器在你的控制之下,但在描述用户交互方面意义不大。
要校正不正确的设备时钟一种方法是记录三个时间戳【82】 要校正不正确的设备时钟一种方法是记录三个时间戳【82】
@ -538,61 +538,61 @@ $$
通过从第三个时间戳中减去第二个时间戳,可以估算设备时钟和服务器时钟之间的偏移(假设网络延迟与所需的时间戳精度相比可忽略不计)。然后可以将该偏移应用于事件时间戳,从而估计事件实际发生的真实时间(假设设备时钟偏移在事件发生时与送往服务器之间没有变化)。 通过从第三个时间戳中减去第二个时间戳,可以估算设备时钟和服务器时钟之间的偏移(假设网络延迟与所需的时间戳精度相比可忽略不计)。然后可以将该偏移应用于事件时间戳,从而估计事件实际发生的真实时间(假设设备时钟偏移在事件发生时与送往服务器之间没有变化)。
这并不是流处理独有的问题,批处理有着完全一样的时 间推理问题。只是在流处理的上下文中,我们更容易意识到时间的流逝。 这并不是流处理独有的问题,批处理有着完全一样的时 间推理问题。只是在流处理的上下文中,我们更容易意识到时间的流逝。
#### 窗口的类型 #### 窗口的类型
当你知道如何确定一个事件的时间戳后下一步就是如何定义时间段的窗口。然后窗口就可以用于聚合例如事件计数或计算窗口内值的平均值。有几种窗口很常用【79,83】 当你知道如何确定一个事件的时间戳后下一步就是如何定义时间段的窗口。然后窗口就可以用于聚合例如事件计数或计算窗口内值的平均值。有几种窗口很常用【79,83】
***滚动窗口Tumbling Window*** ***滚动窗口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*** ***跳动窗口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*** ***滑动窗口Sliding Window***
滑动窗口包含了彼此间距在特定时长内的所有事件。例如一个5分钟的滑动窗口应当覆盖`10:03:39`和`10:08:12`的事件因为它们相距不超过5分钟注意滚动窗口与步长5分钟的跳动窗口可能不会把这两个事件分组到同一个窗口中因为它们使用固定的边界。通过维护一个按时间排序的事件缓冲区并不断从窗口中移除过期的旧事件可以实现滑动窗口。 滑动窗口包含了彼此间距在特定时长内的所有事件。例如一个5分钟的滑动窗口应当覆盖`10:03:39`和`10:08:12`的事件因为它们相距不超过5分钟注意滚动窗口与步长5分钟的跳动窗口可能不会把这两个事件分组到同一个窗口中因为它们使用固定的边界。通过维护一个按时间排序的事件缓冲区并不断从窗口中移除过期的旧事件可以实现滑动窗口。
***会话窗口Session window*** ***会话窗口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的时间线上。
* 用户删除推文时,推文将从所有用户的时间表中删除。 * 用户删除推文时,推文将从所有用户的时间表中删除。
@ -611,113 +611,113 @@ JOIN follows ON follows.followee_id = tweets.sender_id
GROUP BY follows.follower_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】。
#### 连接的时间依赖性 #### 连接的时间依赖性
这里描述的三种连接(流流,流表,表表)有很多共通之处:它们都需要流处理器维护连接一侧的一些状态(搜索与点击事件,用户档案,关注列表),然后当连接另一侧的消息到达时查询该状态。 这里描述的三种连接(流流,流表,表表)有很多共通之处:它们都需要流处理器维护连接一侧的一些状态(搜索与点击事件,用户档案,关注列表),然后当连接另一侧的消息到达时查询该状态。
用于维护状态的事件顺序是很重要的(先关注然后取消关注,或者其他类似操作)。在分区日志中,单个分区内的事件顺序是保留下来的。但典型情况下是没有跨流或跨分区的顺序保证的。 用于维护状态的事件顺序是很重要的(先关注然后取消关注,或者其他类似操作)。在分区日志中,单个分区内的事件顺序是保留下来的。但典型情况下是没有跨流或跨分区的顺序保证的。
这就产生了一个问题如果不同流中的事件发生在近似的时间范围内则应该按照什么样的顺序进行处理在流表连接的例子中如果用户更新了它们的档案哪些活动事件与旧档案连接在档案更新前处理哪些又与新档案连接在档案更新之后处理换句话说你需要对一些状态做连接如果状态会随着时间推移而变化那应当使用什么时间点来连接呢【45】 这就产生了一个问题如果不同流中的事件发生在近似的时间范围内则应该按照什么样的顺序进行处理在流表连接的例子中如果用户更新了它们的档案哪些活动事件与旧档案连接在档案更新前处理哪些又与新档案连接在档案更新之后处理换句话说你需要对一些状态做连接如果状态会随着时间推移而变化那应当使用什么时间点来连接呢【45】
这种时序依赖可能出现在很多地方。例如销售东西需要对发票应用适当的税率,这取决于所处的国家/州,产品类型,销售日期(因为税率时不时会变化)。当连接销售额与税率表时,你可能期望的是使用销售时的税率参与连接。如果你正在重新处理历史数据,销售时的税率可能和现在的税率有所不同。 这种时序依赖可能出现在很多地方。例如销售东西需要对发票应用适当的税率,这取决于所处的国家/州,产品类型,销售日期(因为税率时不时会变化)。当连接销售额与税率表时,你可能期望的是使用销售时的税率参与连接。如果你正在重新处理历史数据,销售时的税率可能和现在的税率有所不同。
如果跨越流的事件顺序是未定的则连接会变为不确定性的【87】这意味着你在同样输入上重跑相同的作业未必会得到相同的结果当你重跑任务时输入流上的事件可能会以不同的方式交织。 如果跨越流的事件顺序是未定的则连接会变为不确定性的【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数据例如金融中的市场数据能够自然地表示为流。我们发现将数据库写入视作流也是很有用的我们可以捕获变更日志 —— 即对数据库所做的所有变更的历史记录 —— 隐式地通过变更数据捕获,或显式地通过事件溯源。日志压缩允许流也能保有数据库内容的完整副本。
将数据库表示为流为系统集成带来了很多强大机遇。通过消费变更日志并将其应用至衍生系统,你能使诸如搜索索引、缓存以及分析系统这类衍生数据系统不断保持更新。你甚至能从头开始,通过读取从创世至今的所有变更日志,为现有数据创建全新的视图。 将数据库表示为流为系统集成带来了很多强大机遇。通过消费变更日志并将其应用至衍生系统,你能使诸如搜索索引、缓存以及分析系统这类衍生数据系统不断保持更新。你甚至能从头开始,通过读取从创世至今的所有变更日志,为现有数据创建全新的视图。
像流一样维护状态以及消息重播的基础设施,是在各种流处理框架中实现流连接和容错的基础。我们讨论了流处理的几种目的,包括搜索事件模式(复杂事件处理),计算分窗聚合(流分析),以及保证衍生数据系统处于最新状态(物化视图)。 像流一样维护状态以及消息重播的基础设施,是在各种流处理框架中实现流连接和容错的基础。我们讨论了流处理的几种目的,包括搜索事件模式(复杂事件处理),计算分窗聚合(流分析),以及保证衍生数据系统处于最新状态(物化视图)。
然后我们讨论了在流处理中对时间进行推理的困难,包括处理时间与事件时间戳之间的区别,以及当你认为窗口已经完事之后,如何处理到达的掉队事件的问题。 然后我们讨论了在流处理中对时间进行推理的困难,包括处理时间与事件时间戳之间的区别,以及当你认为窗口已经完事之后,如何处理到达的掉队事件的问题。
我们区分了流处理中可能出现的三种连接类型: 我们区分了流处理中可能出现的三种连接类型:
***流流连接*** ***流流连接***
两个输入流都由活动事件组成而连接算子在某个时间窗口内搜索相关的事件。例如它可能会将同一个用户30分钟内进行的两个活动联系在一起。如果你想要找出一个流内的相关事件连接的两侧输入可能实际上都是同一个流**自连接self-join**)。 两个输入流都由活动事件组成而连接算子在某个时间窗口内搜索相关的事件。例如它可能会将同一个用户30分钟内进行的两个活动联系在一起。如果你想要找出一个流内的相关事件连接的两侧输入可能实际上都是同一个流**自连接self-join**)。
***流表连接*** ***流表连接***
一个输入流由活动事件组成,另一个输入流是数据库变更日志。变更日志保证了数据库的本地副本是最新的。对于每个活动事件,连接算子将查询数据库,并输出一个扩展的活动事件。 一个输入流由活动事件组成,另一个输入流是数据库变更日志。变更日志保证了数据库的本地副本是最新的。对于每个活动事件,连接算子将查询数据库,并输出一个扩展的活动事件。
***表表连接*** ***表表连接***
两个输入流都是数据库变更日志。在这种情况下,一侧的每一个变化都与另一侧的最新状态相连接。结果是两表连接所得物化视图的变更流。 两个输入流都是数据库变更日志。在这种情况下,一侧的每一个变化都与另一侧的最新状态相连接。结果是两表连接所得物化视图的变更流。
最后,我们讨论了在流处理中实现容错和恰好一次语义的技术。与批处理一样,我们需要放弃任何失败任务的部分输出。然而由于流处理长时间运行并持续产生输出,所以不能简单地丢弃所有的输出。相反,可以使用更细粒度的恢复机制,基于微批次、存档点、事务或幂等写入。 最后,我们讨论了在流处理中实现容错和恰好一次语义的技术。与批处理一样,我们需要放弃任何失败任务的部分输出。然而由于流处理长时间运行并持续产生输出,所以不能简单地丢弃所有的输出。相反,可以使用更细粒度的恢复机制,基于微批次、存档点、事务或幂等写入。

598
ch12.md

File diff suppressed because it is too large Load Diff

2
ch3.md
View File

@ -110,7 +110,7 @@ $ cat database
***文件格式*** ***文件格式***
CSV不是日志的最佳格式。使用二进制格式更快更简单首先以字节为单位对字符串的长度进行编码然后使用原始字符串不需要转义 CSV不是日志的最佳格式。使用二进制格式更快更简单首先以字节为单位对字符串的长度进行编码然后使用原始字符串不需要转义
***删除记录*** ***删除记录***

4
ch4.md
View File

@ -26,11 +26,11 @@
***向后兼容 (backward compatibility)*** ***向后兼容 (backward compatibility)***
新代码可以读旧数据。 新代码可以读旧数据。
***向前兼容 (forward compatibility)*** ***向前兼容 (forward compatibility)***
旧代码可以读新数据。 旧代码可以读新数据。
向后兼容性通常并不难实现:新代码的作者当然知道由旧代码使用的数据格式,因此可以显示地处理它(最简单的办法是,保留旧代码即可读取旧数据)。 向后兼容性通常并不难实现:新代码的作者当然知道由旧代码使用的数据格式,因此可以显示地处理它(最简单的办法是,保留旧代码即可读取旧数据)。

356
ch5.md
View File

@ -10,7 +10,7 @@
[TOC] [TOC]
复制意味着在通过网络连接的多台机器上保留相同数据的副本。正如在[第二部分](part-ii.md)的介绍中所讨论的那样,我们希望能复制数据,可能出于各种各样的原因: 复制意味着在通过网络连接的多台机器上保留相同数据的副本。正如在[第二部分](part-ii.md)的介绍中所讨论的那样,我们希望能复制数据,可能出于各种各样的原因:
* 使得数据与用户在地理上接近(从而减少延迟) * 使得数据与用户在地理上接近(从而减少延迟)
* 即使系统的一部分出现故障,系统也能继续工作(从而提高可用性) * 即使系统的一部分出现故障,系统也能继续工作(从而提高可用性)
@ -18,17 +18,17 @@
本章将假设你的数据集非常小,每台机器都可以保存整个数据集的副本。在[第六章](ch6.md)中将放宽这个假设,讨论对单个机器来说太大的数据集的分割(分片)。在后面的章节中,我们将讨论复制数据系统中可能发生的各种故障,以及如何处理这些故障。 本章将假设你的数据集非常小,每台机器都可以保存整个数据集的副本。在[第六章](ch6.md)中将放宽这个假设,讨论对单个机器来说太大的数据集的分割(分片)。在后面的章节中,我们将讨论复制数据系统中可能发生的各种故障,以及如何处理这些故障。
如果复制中的数据不会随时间而改变,那复制就很简单:将数据复制到每个节点一次就万事大吉。复制的困难之处在于处理复制数据的**变更change**,这就是本章所要讲的。我们将讨论三种流行的变更复制算法:**单领导者single leader****多领导者multi leader** 和**无领导者leaderless**。几乎所有分布式数据库都使用这三种方法之一。 如果复制中的数据不会随时间而改变,那复制就很简单:将数据复制到每个节点一次就万事大吉。复制的困难之处在于处理复制数据的**变更change**,这就是本章所要讲的。我们将讨论三种流行的变更复制算法:**单领导者single leader****多领导者multi leader** 和**无领导者leaderless**。几乎所有分布式数据库都使用这三种方法之一。
在复制时需要进行许多权衡:例如,使用同步复制还是异步复制?如何处理失败的副本?这些通常是数据库中的配置选项,细节因数据库而异,但原理在许多不同的实现中都类似。本章会讨论这些决策的后果。 在复制时需要进行许多权衡:例如,使用同步复制还是异步复制?如何处理失败的副本?这些通常是数据库中的配置选项,细节因数据库而异,但原理在许多不同的实现中都类似。本章会讨论这些决策的后果。
数据库的复制算得上是老生常谈了 ——70年代研究得出的基本原则至今没有太大变化【1】因为网络的基本约束仍保持不变。然而在研究之外许多开发人员仍然假设一个数据库只有一个节点。分布式数据库变为主流只是最近发生的事。许多程序员都是这一领域的新手因此对于诸如 **最终一致性eventual consistency** 等问题存在许多误解。在“[复制延迟问题](#复制延迟问题)”一节,我们将更加精确地了解最终的一致性,并讨论诸如 **读己之写read-your-writes****单调读monotonic read** 保证等内容。 数据库的复制算得上是老生常谈了 ——70年代研究得出的基本原则至今没有太大变化【1】因为网络的基本约束仍保持不变。然而在研究之外许多开发人员仍然假设一个数据库只有一个节点。分布式数据库变为主流只是最近发生的事。许多程序员都是这一领域的新手因此对于诸如 **最终一致性eventual consistency** 等问题存在许多误解。在“[复制延迟问题](#复制延迟问题)”一节,我们将更加精确地了解最终的一致性,并讨论诸如 **读己之写read-your-writes****单调读monotonic read** 保证等内容。
## 领导者与追随者 ## 领导者与追随者
存储数据库副本的每个节点称为 **副本replica** 。当存在多个副本时,会不可避免的出现一个问题:如何确保所有数据都落在了所有的副本上? 存储数据库副本的每个节点称为 **副本replica** 。当存在多个副本时,会不可避免的出现一个问题:如何确保所有数据都落在了所有的副本上?
每一次向数据库的写入操作都需要传播到所有副本上,否则副本就会包含不一样的数据。最常见的解决方案被称为 **基于领导者的复制leader-based replication** (也称 **主动/被动active/passive****主/从master/slave** 复制),如[图5-1](#fig5-1.png)所示。它的工作原理如下: 每一次向数据库的写入操作都需要传播到所有副本上,否则副本就会包含不一样的数据。最常见的解决方案被称为 **基于领导者的复制leader-based replication** (也称 **主动/被动active/passive****主/从master/slave** 复制),如[图5-1](#fig5-1.png)所示。它的工作原理如下:
1. 副本之一被指定为 **领导者leader**,也称为 **主库master|primary** 。当客户端要向数据库写入时,它必须将请求发送给**领导者**,领导者会将新数据写入其本地存储。 1. 副本之一被指定为 **领导者leader**,也称为 **主库master|primary** 。当客户端要向数据库写入时,它必须将请求发送给**领导者**,领导者会将新数据写入其本地存储。
2. 其他副本被称为**追随者followers**,亦称为**只读副本read replicas****从库slaves****备库( secondaries****热备hot-standby**[^i]。每当领导者将新数据写入本地存储时,它也会将数据变更发送给所有的追随者,称之为**复制日志replication log** 记录或**变更流change stream**。每个跟随者从领导者拉取日志,并相应更新其本地数据库副本,方法是按照领导者处理的相同顺序应用所有写入。 2. 其他副本被称为**追随者followers**,亦称为**只读副本read replicas****从库slaves****备库( secondaries****热备hot-standby**[^i]。每当领导者将新数据写入本地存储时,它也会将数据变更发送给所有的追随者,称之为**复制日志replication log** 记录或**变更流change stream**。每个跟随者从领导者拉取日志,并相应更新其本地数据库副本,方法是按照领导者处理的相同顺序应用所有写入。
@ -39,30 +39,30 @@
![](img/fig5-1.png) ![](img/fig5-1.png)
**图5-1 基于领导者(主-从)的复制** **图5-1 基于领导者(主-从)的复制**
这种复制模式是许多关系数据库的内置功能如PostgreSQL从9.0版本开始MySQLOracle Data Guard 【2】和SQL Server的AlwaysOn可用性组【3】。 它也被用于一些非关系数据库包括MongoDBRethinkDB和Espresso 【4】。 最后基于领导者的复制并不仅限于数据库像Kafka 【5】和RabbitMQ高可用队列【6】这样的分布式消息代理也使用它。 某些网络文件系统例如DRBD这样的块复制设备也与之类似。 这种复制模式是许多关系数据库的内置功能如PostgreSQL从9.0版本开始MySQLOracle Data Guard 【2】和SQL Server的AlwaysOn可用性组【3】。 它也被用于一些非关系数据库包括MongoDBRethinkDB和Espresso 【4】。 最后基于领导者的复制并不仅限于数据库像Kafka 【5】和RabbitMQ高可用队列【6】这样的分布式消息代理也使用它。 某些网络文件系统例如DRBD这样的块复制设备也与之类似。
### 同步复制与异步复制 ### 同步复制与异步复制
复制系统的一个重要细节是:复制是 **同步synchronously** 发生还是 **异步asynchronously** 发生。 (在关系型数据库中这通常是一个配置项,其他系统通常硬编码为其中一个)。 复制系统的一个重要细节是:复制是 **同步synchronously** 发生还是 **异步asynchronously** 发生。 (在关系型数据库中这通常是一个配置项,其他系统通常硬编码为其中一个)。
想象[图5-1](fig5-1.png)中发生的情况,网站的用户更新他们的个人头像。在某个时间点,客户向主库发送更新请求;不久之后主库就收到了请求。在某个时刻,主库又会将数据变更转发给自己的从库。最后,主库通知客户更新成功。 想象[图5-1](fig5-1.png)中发生的情况,网站的用户更新他们的个人头像。在某个时间点,客户向主库发送更新请求;不久之后主库就收到了请求。在某个时刻,主库又会将数据变更转发给自己的从库。最后,主库通知客户更新成功。
[图5-2](img/fig5-2.png)显示了系统各个组件之间的通信:用户客户端,主库和两个从库。时间从左到右流动。请求或响应消息用粗箭头表示。 [图5-2](img/fig5-2.png)显示了系统各个组件之间的通信:用户客户端,主库和两个从库。时间从左到右流动。请求或响应消息用粗箭头表示。
![](img/fig5-2.png) ![](img/fig5-2.png)
**图5-2 基于领导者的复制:一个同步从库和一个异步从库** **图5-2 基于领导者的复制:一个同步从库和一个异步从库**
在[图5-2](img/fig5-2.png)的示例中从库1的复制是同步的在向用户报告写入成功并使结果对其他用户可见之前主库需要等待从库1的确认确保从库1已经收到写入操作。以及在使写入对其他客户端可见之前接收到写入。跟随者2的复制是异步的主库发送消息但不等待从库的响应。 在[图5-2](img/fig5-2.png)的示例中从库1的复制是同步的在向用户报告写入成功并使结果对其他用户可见之前主库需要等待从库1的确认确保从库1已经收到写入操作。以及在使写入对其他客户端可见之前接收到写入。跟随者2的复制是异步的主库发送消息但不等待从库的响应。
在这幅图中从库2处理消息前存在一个显著的延迟。通常情况下复制的速度相当快大多数数据库系统能在一秒向从库应用变更但它们不能提供复制用时的保证。有些情况下从库可能落后主库几分钟或更久例如从库正在从故障中恢复系统在最大容量附近运行或者如果节点间存在网络问题。 在这幅图中从库2处理消息前存在一个显著的延迟。通常情况下复制的速度相当快大多数数据库系统能在一秒向从库应用变更但它们不能提供复制用时的保证。有些情况下从库可能落后主库几分钟或更久例如从库正在从故障中恢复系统在最大容量附近运行或者如果节点间存在网络问题。
同步复制的优点是,从库保证有与主库一致的最新数据副本。如果主库突然失效,我们可以确信这些数据仍然能在从库上上找到。缺点是,如果同步从库没有响应(比如它已经崩溃,或者出现网络故障,或其它任何原因),主库就无法处理写入操作。主库必须阻止所有写入,并等待同步副本再次可用。 同步复制的优点是,从库保证有与主库一致的最新数据副本。如果主库突然失效,我们可以确信这些数据仍然能在从库上上找到。缺点是,如果同步从库没有响应(比如它已经崩溃,或者出现网络故障,或其它任何原因),主库就无法处理写入操作。主库必须阻止所有写入,并等待同步副本再次可用。
因此,将所有从库都设置为同步的是不切实际的:任何一个节点的中断都会导致整个系统停滞不前。实际上,如果在数据库上启用同步复制,通常意味着其中**一个**跟随者是同步的,而其他的则是异步的。如果同步从库变得不可用或缓慢,则使一个异步从库同步。这保证你至少在两个节点上拥有最新的数据副本:主库和同步从库。 这种配置有时也被称为 **半同步semi-synchronous**【7】。 因此,将所有从库都设置为同步的是不切实际的:任何一个节点的中断都会导致整个系统停滞不前。实际上,如果在数据库上启用同步复制,通常意味着其中**一个**跟随者是同步的,而其他的则是异步的。如果同步从库变得不可用或缓慢,则使一个异步从库同步。这保证你至少在两个节点上拥有最新的数据副本:主库和同步从库。 这种配置有时也被称为 **半同步semi-synchronous**【7】。
通常情况下,基于领导者的复制都配置为完全异步。 在这种情况下,如果主库失效且不可恢复,则任何尚未复制给从库的写入都会丢失。 这意味着即使已经向客户端确认成功,写入也不能保证 **持久Durable** 。 然而,一个完全异步的配置也有优点:即使所有的从库都落后了,主库也可以继续处理写入。 通常情况下,基于领导者的复制都配置为完全异步。 在这种情况下,如果主库失效且不可恢复,则任何尚未复制给从库的写入都会丢失。 这意味着即使已经向客户端确认成功,写入也不能保证 **持久Durable** 。 然而,一个完全异步的配置也有优点:即使所有的从库都落后了,主库也可以继续处理写入。
弱化的持久性可能听起来像是一个坏的折衷,然而异步复制已经被广泛使用了,特别当有很多追随者,或追随者异地分布时。 稍后将在“[复制延迟问题](#复制延迟问题)”中回到这个问题。 弱化的持久性可能听起来像是一个坏的折衷,然而异步复制已经被广泛使用了,特别当有很多追随者,或追随者异地分布时。 稍后将在“[复制延迟问题](#复制延迟问题)”中回到这个问题。
> ### 关于复制的研究 > ### 关于复制的研究
> >
@ -73,11 +73,11 @@
### 设置新从库 ### 设置新从库
有时候需要设置一个新的从库:也许是为了增加副本的数量,或替换失败的节点。如何确保新的从库拥有主库数据的精确副本? 有时候需要设置一个新的从库:也许是为了增加副本的数量,或替换失败的节点。如何确保新的从库拥有主库数据的精确副本?
简单地将数据文件从一个节点复制到另一个节点通常是不够的:客户端不断向数据库写入数据,数据总是在不断变化,标准的数据副本会在不同的时间点总是不一样。复制的结果可能没有任何意义。 简单地将数据文件从一个节点复制到另一个节点通常是不够的:客户端不断向数据库写入数据,数据总是在不断变化,标准的数据副本会在不同的时间点总是不一样。复制的结果可能没有任何意义。
可以通过锁定数据库(使其不可用于写入)来使磁盘上的文件保持一致,但是这会违背高可用的目标。幸运的是,拉起新的从库通常并不需要停机。从概念上讲,过程如下所示: 可以通过锁定数据库(使其不可用于写入)来使磁盘上的文件保持一致,但是这会违背高可用的目标。幸运的是,拉起新的从库通常并不需要停机。从概念上讲,过程如下所示:
1. 在某个时刻获取主库的一致性快照如果可能而不必锁定整个数据库。大多数数据库都具有这个功能因为它是备份必需的。对于某些场景可能需要第三方工具例如MySQL的innobackupex 【12】。 1. 在某个时刻获取主库的一致性快照如果可能而不必锁定整个数据库。大多数数据库都具有这个功能因为它是备份必需的。对于某些场景可能需要第三方工具例如MySQL的innobackupex 【12】。
2. 将快照复制到新的从库节点。 2. 将快照复制到新的从库节点。
@ -88,19 +88,19 @@
### 处理节点宕机 ### 处理节点宕机
系统中的任何节点都可能宕机,可能因为意外的故障,也可能由于计划内的维护(例如,重启机器以安装内核安全补丁)。对运维而言,能在系统不中断服务的情况下重启单个节点好处多多。我们的目标是,即使个别节点失效,也能保持整个系统运行,并尽可能控制节点停机带来的影响。 系统中的任何节点都可能宕机,可能因为意外的故障,也可能由于计划内的维护(例如,重启机器以安装内核安全补丁)。对运维而言,能在系统不中断服务的情况下重启单个节点好处多多。我们的目标是,即使个别节点失效,也能保持整个系统运行,并尽可能控制节点停机带来的影响。
如何通过基于主库的复制实现高可用? 如何通过基于主库的复制实现高可用?
#### 从库失效:追赶恢复 #### 从库失效:追赶恢复
在其本地磁盘上,每个从库记录从主库收到的数据变更。如果从库崩溃并重新启动,或者,如果主库和从库之间的网络暂时中断,则比较容易恢复:从库可以从日志中知道,在发生故障之前处理的最后一个事务。因此,从库可以连接到主库,并请求在从库断开连接时发生的所有数据变更。当应用完所有这些变化后,它就赶上了主库,并可以像以前一样继续接收数据变更流。 在其本地磁盘上,每个从库记录从主库收到的数据变更。如果从库崩溃并重新启动,或者,如果主库和从库之间的网络暂时中断,则比较容易恢复:从库可以从日志中知道,在发生故障之前处理的最后一个事务。因此,从库可以连接到主库,并请求在从库断开连接时发生的所有数据变更。当应用完所有这些变化后,它就赶上了主库,并可以像以前一样继续接收数据变更流。
#### 主库失效:故障切换 #### 主库失效:故障切换
主库失效处理起来相当棘手:其中一个从库需要被提升为新的主库,需要重新配置客户端,以将它们的写操作发送给新的主库,其他从库需要开始拉取来自新主库的数据变更。这个过程被称为**故障切换failover**。 主库失效处理起来相当棘手:其中一个从库需要被提升为新的主库,需要重新配置客户端,以将它们的写操作发送给新的主库,其他从库需要开始拉取来自新主库的数据变更。这个过程被称为**故障切换failover**。
故障切换可以手动进行(通知管理员主库挂了,并采取必要的步骤来创建新的主库)或自动进行。自动故障切换过程通常由以下步骤组成: 故障切换可以手动进行(通知管理员主库挂了,并采取必要的步骤来创建新的主库)或自动进行。自动故障切换过程通常由以下步骤组成:
1. 确认主库失效。有很多事情可能会出错:崩溃,停电,网络问题等等。没有万无一失的方法来检测出现了什么问题,所以大多数系统只是简单使用 **超时Timeout** 节点频繁地相互来回传递消息并且如果一个节点在一段时间内例如30秒没有响应就认为它挂了因为计划内维护而故意关闭主库不算 1. 确认主库失效。有很多事情可能会出错:崩溃,停电,网络问题等等。没有万无一失的方法来检测出现了什么问题,所以大多数系统只是简单使用 **超时Timeout** 节点频繁地相互来回传递消息并且如果一个节点在一段时间内例如30秒没有响应就认为它挂了因为计划内维护而故意关闭主库不算
2. 选择一个新的主库。这可以通过选举过程(主库由剩余副本以多数选举产生)来完成,或者可以由之前选定的**控制器节点controller node** 来指定新的主库。主库的最佳人选通常是拥有旧主库最新数据副本的从库(最小化数据损失)。让所有的节点同意一个新的领导者,是一个**共识**问题,将在[第九章](ch9.md)详细讨论。 2. 选择一个新的主库。这可以通过选举过程(主库由剩余副本以多数选举产生)来完成,或者可以由之前选定的**控制器节点controller node** 来指定新的主库。主库的最佳人选通常是拥有旧主库最新数据副本的从库(最小化数据损失)。让所有的节点同意一个新的领导者,是一个**共识**问题,将在[第九章](ch9.md)详细讨论。
@ -128,7 +128,7 @@
#### 基于语句的复制 #### 基于语句的复制
在最简单的情况下,主库记录下它执行的每个写入请求(**语句statement**)并将该语句日志发送给其从库。对于关系数据库来说,这意味着每个`INSERT``UPDATE`或`DELETE`语句都被转发给每个从库每个从库解析并执行该SQL语句就像从客户端收到一样。 在最简单的情况下,主库记录下它执行的每个写入请求(**语句statement**)并将该语句日志发送给其从库。对于关系数据库来说,这意味着每个`INSERT``UPDATE`或`DELETE`语句都被转发给每个从库每个从库解析并执行该SQL语句就像从客户端收到一样。
虽然听上去很合理,但有很多问题会搞砸这种复制方式: 虽然听上去很合理,但有很多问题会搞砸这种复制方式:
@ -138,7 +138,7 @@
的确有办法绕开这些问题 ——例如,当语句被记录时,主库可以用固定的返回值替换任何不确定的函数调用,以便从库获得相同的值。但是由于边缘情况实在太多了,现在通常会选择其他的复制方法。 的确有办法绕开这些问题 ——例如,当语句被记录时,主库可以用固定的返回值替换任何不确定的函数调用,以便从库获得相同的值。但是由于边缘情况实在太多了,现在通常会选择其他的复制方法。
基于语句的复制在5.1版本前的MySQL中使用。因为它相当紧凑现在有时候也还在用。但现在在默认情况下如果语句中存在任何不确定性MySQL会切换到基于行的复制稍后讨论。 VoltDB使用了基于语句的复制但要求事务必须是确定性的以此来保证安全【15】。 基于语句的复制在5.1版本前的MySQL中使用。因为它相当紧凑现在有时候也还在用。但现在在默认情况下如果语句中存在任何不确定性MySQL会切换到基于行的复制稍后讨论。 VoltDB使用了基于语句的复制但要求事务必须是确定性的以此来保证安全【15】。
#### 传输预写式日志WAL #### 传输预写式日志WAL
@ -149,15 +149,15 @@
在任何一种情况下,日志都是包含所有数据库写入的仅追加字节序列。可以使用完全相同的日志在另一个节点上构建副本:除了将日志写入磁盘之外,主库还可以通过网络将其发送给其从库。 在任何一种情况下,日志都是包含所有数据库写入的仅追加字节序列。可以使用完全相同的日志在另一个节点上构建副本:除了将日志写入磁盘之外,主库还可以通过网络将其发送给其从库。
当从库应用这个日志时,它会建立和主库一模一样数据结构的副本。 当从库应用这个日志时,它会建立和主库一模一样数据结构的副本。
PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志记录的数据非常底层WAL包含哪些磁盘块中的哪些字节发生了更改。这使复制与存储引擎紧密耦合。如果数据库将其存储格式从一个版本更改为另一个版本通常不可能在主库和从库上运行不同版本的数据库软件。 PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志记录的数据非常底层WAL包含哪些磁盘块中的哪些字节发生了更改。这使复制与存储引擎紧密耦合。如果数据库将其存储格式从一个版本更改为另一个版本通常不可能在主库和从库上运行不同版本的数据库软件。
看上去这可能只是一个微小的实现细节但却可能对运维产生巨大的影响。如果复制协议允许从库使用比主库更新的软件版本则可以先升级从库然后执行故障切换使升级后的节点之一成为新的主库从而执行数据库软件的零停机升级。如果复制协议不允许版本不匹配传输WAL经常出现这种情况则此类升级需要停机。 看上去这可能只是一个微小的实现细节但却可能对运维产生巨大的影响。如果复制协议允许从库使用比主库更新的软件版本则可以先升级从库然后执行故障切换使升级后的节点之一成为新的主库从而执行数据库软件的零停机升级。如果复制协议不允许版本不匹配传输WAL经常出现这种情况则此类升级需要停机。
#### 逻辑日志复制(基于行) #### 逻辑日志复制(基于行)
另一种方法是,复制和存储引擎使用不同的日志格式,这样可以使复制日志从存储引擎内部分离出来。这种复制日志被称为逻辑日志,以将其与存储引擎的(物理)数据表示区分开来。 另一种方法是,复制和存储引擎使用不同的日志格式,这样可以使复制日志从存储引擎内部分离出来。这种复制日志被称为逻辑日志,以将其与存储引擎的(物理)数据表示区分开来。
关系数据库的逻辑日志通常是以行的粒度描述对数据库表的写入的记录序列: 关系数据库的逻辑日志通常是以行的粒度描述对数据库表的写入的记录序列:
@ -167,49 +167,49 @@
修改多行的事务会生成多个这样的日志记录,后面跟着一条记录,指出事务已经提交。 MySQL的二进制日志当配置为使用基于行的复制时使用这种方法【17】。 修改多行的事务会生成多个这样的日志记录,后面跟着一条记录,指出事务已经提交。 MySQL的二进制日志当配置为使用基于行的复制时使用这种方法【17】。
由于逻辑日志与存储引擎内部分离,因此可以更容易地保持向后兼容,从而使领导者和跟随者能够运行不同版本的数据库软件甚至不同的存储引擎。 由于逻辑日志与存储引擎内部分离,因此可以更容易地保持向后兼容,从而使领导者和跟随者能够运行不同版本的数据库软件甚至不同的存储引擎。
对于外部应用程序来说逻辑日志格式也更容易解析。如果要将数据库的内容发送到外部系统这一点很有用例如复制到数据仓库进行离线分析或建立自定义索引和缓存【18】。 这种技术被称为 **数据变更捕获change data capture**[第十一章](ch11.md)将重新讲到它。 对于外部应用程序来说逻辑日志格式也更容易解析。如果要将数据库的内容发送到外部系统这一点很有用例如复制到数据仓库进行离线分析或建立自定义索引和缓存【18】。 这种技术被称为 **数据变更捕获change data capture**[第十一章](ch11.md)将重新讲到它。
#### 基于触发器的复制 #### 基于触发器的复制
到目前为止描述的复制方法是由数据库系统实现的,不涉及任何应用程序代码。在很多情况下,这就是你想要的。但在某些情况下需要更多的灵活性。例如,如果您只想复制数据的一个子集,或者想从一种数据库复制到另一种数据库,或者如果您需要冲突解决逻辑(请参阅“[处理写入冲突](#处理写入冲突)”),则可能需要将复制移动到应用程序层。 到目前为止描述的复制方法是由数据库系统实现的,不涉及任何应用程序代码。在很多情况下,这就是你想要的。但在某些情况下需要更多的灵活性。例如,如果您只想复制数据的一个子集,或者想从一种数据库复制到另一种数据库,或者如果您需要冲突解决逻辑(请参阅“[处理写入冲突](#处理写入冲突)”),则可能需要将复制移动到应用程序层。
一些工具如Oracle Golden Gate 【19】可以通过读取数据库日志使得其他应用程序可以使用数据。另一种方法是使用许多关系数据库自带的功能触发器和存储过程。 一些工具如Oracle Golden Gate 【19】可以通过读取数据库日志使得其他应用程序可以使用数据。另一种方法是使用许多关系数据库自带的功能触发器和存储过程。
触发器允许您注册在数据库系统中发生数据更改写入事务时自动执行的自定义应用程序代码。触发器有机会将更改记录到一个单独的表中使用外部程序读取这个表再加上任何业务逻辑处理会后将数据变更复制到另一个系统去。例如Databus for Oracle 【20】和Bucardo for Postgres 【21】就是这样工作的。 触发器允许您注册在数据库系统中发生数据更改写入事务时自动执行的自定义应用程序代码。触发器有机会将更改记录到一个单独的表中使用外部程序读取这个表再加上任何业务逻辑处理会后将数据变更复制到另一个系统去。例如Databus for Oracle 【20】和Bucardo for Postgres 【21】就是这样工作的。
基于触发器的复制通常比其他复制方法具有更高的开销,并且比数据库的内置复制更容易出错,也有很多限制。然而由于其灵活性,仍然是很有用的。 基于触发器的复制通常比其他复制方法具有更高的开销,并且比数据库的内置复制更容易出错,也有很多限制。然而由于其灵活性,仍然是很有用的。
## 复制延迟问题 ## 复制延迟问题
容忍节点故障只是需要复制的一个原因。正如在[第二部分](part-ii.md)的介绍中提到的,另一个原因是可伸缩性(处理比单个机器更多的请求)和延迟(让副本在地理位置上更接近用户)。 容忍节点故障只是需要复制的一个原因。正如在[第二部分](part-ii.md)的介绍中提到的,另一个原因是可伸缩性(处理比单个机器更多的请求)和延迟(让副本在地理位置上更接近用户)。
基于主库的复制要求所有写入都由单个节点处理但只读查询可以由任何副本处理。所以对于读多写少的场景Web上的常见模式一个有吸引力的选择是创建很多从库并将读请求分散到所有的从库上去。这样能减小主库的负载并允许向最近的副本发送读请求。 基于主库的复制要求所有写入都由单个节点处理但只读查询可以由任何副本处理。所以对于读多写少的场景Web上的常见模式一个有吸引力的选择是创建很多从库并将读请求分散到所有的从库上去。这样能减小主库的负载并允许向最近的副本发送读请求。
在这种伸缩体系结构中,只需添加更多的追随者,就可以提高只读请求的服务容量。但是,这种方法实际上只适用于异步复制——如果尝试同步复制到所有追随者,则单个节点故障或网络中断将使整个系统无法写入。而且越多的节点越有可能会被关闭,所以完全同步的配置是非常不可靠的。 在这种伸缩体系结构中,只需添加更多的追随者,就可以提高只读请求的服务容量。但是,这种方法实际上只适用于异步复制——如果尝试同步复制到所有追随者,则单个节点故障或网络中断将使整个系统无法写入。而且越多的节点越有可能会被关闭,所以完全同步的配置是非常不可靠的。
不幸的是,当应用程序从异步从库读取时,如果从库落后,它可能会看到过时的信息。这会导致数据库中出现明显的不一致:同时对主库和从库执行相同的查询,可能得到不同的结果,因为并非所有的写入都反映在从库中。这种不一致只是一个暂时的状态——如果停止写入数据库并等待一段时间,从库最终会赶上并与主库保持一致。出于这个原因,这种效应被称为 **最终一致性eventual consistency**[^iii]【22,23】 不幸的是,当应用程序从异步从库读取时,如果从库落后,它可能会看到过时的信息。这会导致数据库中出现明显的不一致:同时对主库和从库执行相同的查询,可能得到不同的结果,因为并非所有的写入都反映在从库中。这种不一致只是一个暂时的状态——如果停止写入数据库并等待一段时间,从库最终会赶上并与主库保持一致。出于这个原因,这种效应被称为 **最终一致性eventual consistency**[^iii]【22,23】
[^iii]: 道格拉斯·特里Douglas Terry等人创造了术语最终一致性。 【24】 并经由Werner Vogels 【22】推广成为许多NoSQL项目的口号。 然而不只有NoSQL数据库是最终一致的关系型数据库中的异步复制追随者也有相同的特性。 [^iii]: 道格拉斯·特里Douglas Terry等人创造了术语最终一致性。 【24】 并经由Werner Vogels 【22】推广成为许多NoSQL项目的口号。 然而不只有NoSQL数据库是最终一致的关系型数据库中的异步复制追随者也有相同的特性。
“最终”一词故意含糊不清:总的来说,副本落后的程度是没有限制的。在正常的操作中,**复制延迟replication lag**,即写入主库到反映至从库之间的延迟,可能仅仅是几分之一秒,在实践中并不显眼。但如果系统在接近极限的情况下运行,或网络中存在问题,延迟可以轻而易举地超过几秒,甚至几分钟。 “最终”一词故意含糊不清:总的来说,副本落后的程度是没有限制的。在正常的操作中,**复制延迟replication lag**,即写入主库到反映至从库之间的延迟,可能仅仅是几分之一秒,在实践中并不显眼。但如果系统在接近极限的情况下运行,或网络中存在问题,延迟可以轻而易举地超过几秒,甚至几分钟。
因为滞后时间太长引入的不一致性,可不仅是一个理论问题,更是应用设计中会遇到的真实问题。本节将重点介绍三个由复制延迟问题的例子,并简述解决这些问题的一些方法。 因为滞后时间太长引入的不一致性,可不仅是一个理论问题,更是应用设计中会遇到的真实问题。本节将重点介绍三个由复制延迟问题的例子,并简述解决这些问题的一些方法。
### 读己之写 ### 读己之写
许多应用让用户提交一些数据,然后查看他们提交的内容。可能是用户数据库中的记录,也可能是对讨论主题的评论,或其他类似的内容。提交新数据时,必须将其发送给领导者,但是当用户查看数据时,可以从追随者读取。如果数据经常被查看,但只是偶尔写入,这是非常合适的。 许多应用让用户提交一些数据,然后查看他们提交的内容。可能是用户数据库中的记录,也可能是对讨论主题的评论,或其他类似的内容。提交新数据时,必须将其发送给领导者,但是当用户查看数据时,可以从追随者读取。如果数据经常被查看,但只是偶尔写入,这是非常合适的。
但对于异步复制,问题就来了。如[图5-3](fig5-3.png)所示:如果用户在写入后马上就查看数据,则新数据可能尚未到达副本。对用户而言,看起来好像是刚提交的数据丢失了,用户会不高兴,可以理解。 但对于异步复制,问题就来了。如[图5-3](fig5-3.png)所示:如果用户在写入后马上就查看数据,则新数据可能尚未到达副本。对用户而言,看起来好像是刚提交的数据丢失了,用户会不高兴,可以理解。
![](img/fig5-3.png) ![](img/fig5-3.png)
**图5-3 用户写入后从旧副本中读取数据。需要写后读(read-after-write)的一致性来防止这种异常** **图5-3 用户写入后从旧副本中读取数据。需要写后读(read-after-write)的一致性来防止这种异常**
在这种情况下,我们需要 **读写一致性read-after-write consistency**,也称为 **读己之写一致性read-your-writes consistency**【24】。这是一个保证如果用户重新加载页面他们总会看到他们自己提交的任何更新。它不会对其他用户的写入做出承诺其他用户的更新可能稍等才会看到。它保证用户自己的输入已被正确保存。 在这种情况下,我们需要 **读写一致性read-after-write consistency**,也称为 **读己之写一致性read-your-writes consistency**【24】。这是一个保证如果用户重新加载页面他们总会看到他们自己提交的任何更新。它不会对其他用户的写入做出承诺其他用户的更新可能稍等才会看到。它保证用户自己的输入已被正确保存。
如何在基于领导者的复制系统中实现读后一致性?有各种可能的技术,这里说一些: 如何在基于领导者的复制系统中实现读后一致性?有各种可能的技术,这里说一些:
@ -234,17 +234,17 @@
### 单调读 ### 单调读
从异步从库读取第二个异常例子是,用户可能会遇到 **时光倒流moving backward in time** 从异步从库读取第二个异常例子是,用户可能会遇到 **时光倒流moving backward in time**
如果用户从不同从库进行多次读取,就可能发生这种情况。例如,[图5-4](img/fig5-4.png)显示了用户2345两次进行相同的查询首先查询了一个延迟很小的从库然后是一个延迟较大的从库。 如果用户刷新网页而每个请求被路由到一个随机的服务器这种情况是很有可能的。第一个查询返回最近由用户1234添加的评论但是第二个查询不返回任何东西因为滞后的从库还没有拉取写入内容。在效果上相比第一个查询第二个查询是在更早的时间点来观察系统。如果第一个查询没有返回任何内容那问题并不大因为用户2345可能不知道用户1234最近添加了评论。但如果用户2345先看见用户1234的评论然后又看到它消失那么对于用户2345就很让人头大了。 如果用户从不同从库进行多次读取,就可能发生这种情况。例如,[图5-4](img/fig5-4.png)显示了用户2345两次进行相同的查询首先查询了一个延迟很小的从库然后是一个延迟较大的从库。 如果用户刷新网页而每个请求被路由到一个随机的服务器这种情况是很有可能的。第一个查询返回最近由用户1234添加的评论但是第二个查询不返回任何东西因为滞后的从库还没有拉取写入内容。在效果上相比第一个查询第二个查询是在更早的时间点来观察系统。如果第一个查询没有返回任何内容那问题并不大因为用户2345可能不知道用户1234最近添加了评论。但如果用户2345先看见用户1234的评论然后又看到它消失那么对于用户2345就很让人头大了。
![](img/fig5-4.png) ![](img/fig5-4.png)
**图5-4 用户首先从新副本读取,然后从旧副本读取。时光倒流。为了防止这种异常,我们需要单调的读取。** **图5-4 用户首先从新副本读取,然后从旧副本读取。时光倒流。为了防止这种异常,我们需要单调的读取。**
**单调读Monotonic reads**【23】保证这种异常不会发生。这是一个比 **强一致性strong consistency** 更弱,但比 **最终一致性eventual consistency** 更强的保证。当读取数据时,您可能会看到一个旧值;单调读取仅意味着如果一个用户顺序地进行多次读取,则他们不会看到时间后退,即,如果先前读取到较新的数据,后续读取不会得到更旧的数据。 **单调读Monotonic reads**【23】保证这种异常不会发生。这是一个比 **强一致性strong consistency** 更弱,但比 **最终一致性eventual consistency** 更强的保证。当读取数据时,您可能会看到一个旧值;单调读取仅意味着如果一个用户顺序地进行多次读取,则他们不会看到时间后退,即,如果先前读取到较新的数据,后续读取不会得到更旧的数据。
实现单调读取的一种方式是确保每个用户总是从同一个副本进行读取不同的用户可以从不同的副本读取。例如可以基于用户ID的散列来选择副本而不是随机选择副本。但是如果该副本失败用户的查询将需要重新路由到另一个副本。 实现单调读取的一种方式是确保每个用户总是从同一个副本进行读取不同的用户可以从不同的副本读取。例如可以基于用户ID的散列来选择副本而不是随机选择副本。但是如果该副本失败用户的查询将需要重新路由到另一个副本。
@ -260,7 +260,7 @@
这两句话之间有因果关系Cake夫人听到了Poons先生的问题并回答了这个问题。 这两句话之间有因果关系Cake夫人听到了Poons先生的问题并回答了这个问题。
现在,想象第三个人正在通过从库来听这个对话。 Cake夫人说的内容是从一个延迟很低的从库读取的但Poons先生所说的内容从库的延迟要大的多见[图5-5](img/fig5-5.png))。 于是,这个观察者会听到以下内容: 现在,想象第三个人正在通过从库来听这个对话。 Cake夫人说的内容是从一个延迟很低的从库读取的但Poons先生所说的内容从库的延迟要大的多见[图5-5](img/fig5-5.png))。 于是,这个观察者会听到以下内容:
> *Mrs. Cake* > *Mrs. Cake*
> 通常约十秒钟Mr. Poons. > 通常约十秒钟Mr. Poons.
@ -275,43 +275,43 @@
**图5-5 如果某些分区的复制速度慢于其他分区,那么观察者在看到问题之前可能会看到答案。** **图5-5 如果某些分区的复制速度慢于其他分区,那么观察者在看到问题之前可能会看到答案。**
防止这种异常,需要另一种类型的保证:**一致前缀读consistent prefix reads**【23】。 这个保证说:如果一系列写入按某个顺序发生,那么任何人读取这些写入时,也会看见它们以同样的顺序出现。 防止这种异常,需要另一种类型的保证:**一致前缀读consistent prefix reads**【23】。 这个保证说:如果一系列写入按某个顺序发生,那么任何人读取这些写入时,也会看见它们以同样的顺序出现。
这是**分区partitioned****分片sharded**)数据库中的一个特殊问题,将在[第六章](ch6.md)中讨论。如果数据库总是以相同的顺序应用写入,则读取总是会看到一致的前缀,所以这种异常不会发生。但是在许多分布式数据库中,不同的分区独立运行,因此不存在**全局写入顺序**:当用户从数据库中读取数据时,可能会看到数据库的某些部分处于较旧的状态,而某些处于较新的状态。 这是**分区partitioned****分片sharded**)数据库中的一个特殊问题,将在[第六章](ch6.md)中讨论。如果数据库总是以相同的顺序应用写入,则读取总是会看到一致的前缀,所以这种异常不会发生。但是在许多分布式数据库中,不同的分区独立运行,因此不存在**全局写入顺序**:当用户从数据库中读取数据时,可能会看到数据库的某些部分处于较旧的状态,而某些处于较新的状态。
一种解决方案是,确保任何因果相关的写入都写入相同的分区。对于某些无法高效完成这种操作的应用,还有一些显式跟踪因果依赖关系的算法,本书将在“[“此前发生”的关系和并发](#“此前发生”的关系和并发)”一节中返回这个主题。 一种解决方案是,确保任何因果相关的写入都写入相同的分区。对于某些无法高效完成这种操作的应用,还有一些显式跟踪因果依赖关系的算法,本书将在“[“此前发生”的关系和并发](#“此前发生”的关系和并发)”一节中返回这个主题。
### 复制延迟的解决方案 ### 复制延迟的解决方案
在使用最终一致的系统时,如果复制延迟增加到几分钟甚至几小时,则应该考虑应用程序的行为。如果答案是“没问题”,那很好。但如果结果对于用户来说是不好体验,那么设计系统来提供更强的保证是很重要的,例如**写后读**。明明是异步复制却假设复制是同步的,这是很多麻烦的根源。 在使用最终一致的系统时,如果复制延迟增加到几分钟甚至几小时,则应该考虑应用程序的行为。如果答案是“没问题”,那很好。但如果结果对于用户来说是不好体验,那么设计系统来提供更强的保证是很重要的,例如**写后读**。明明是异步复制却假设复制是同步的,这是很多麻烦的根源。
如前所述,应用程序可以提供比底层数据库更强有力的保证,例如通过主库进行某种读取。但在应用程序代码中处理这些问题是复杂的,容易出错。 如前所述,应用程序可以提供比底层数据库更强有力的保证,例如通过主库进行某种读取。但在应用程序代码中处理这些问题是复杂的,容易出错。
如果应用程序开发人员不必担心微妙的复制问题,并可以信赖他们的数据库“做了正确的事情”,那该多好呀。这就是 **事务transaction** 存在的原因:**数据库通过事务提供强大的保证**,所以应用程序可以更加简单。 如果应用程序开发人员不必担心微妙的复制问题,并可以信赖他们的数据库“做了正确的事情”,那该多好呀。这就是 **事务transaction** 存在的原因:**数据库通过事务提供强大的保证**,所以应用程序可以更加简单。
单节点事务已经存在了很长时间。然而在走向分布式(复制和分区)数据库时,许多系统放弃了事务。声称事务在性能和可用性上的代价太高,并断言在可伸缩系统中最终一致性是不可避免的。这个叙述有一些道理,但过于简单了,本书其余部分将提出更为细致的观点。第七章和第九章将回到事务的话题,并讨论一些替代机制。 单节点事务已经存在了很长时间。然而在走向分布式(复制和分区)数据库时,许多系统放弃了事务。声称事务在性能和可用性上的代价太高,并断言在可伸缩系统中最终一致性是不可避免的。这个叙述有一些道理,但过于简单了,本书其余部分将提出更为细致的观点。第七章和第九章将回到事务的话题,并讨论一些替代机制。
## 多主复制 ## 多主复制
本章到目前为止,我们只考虑使用单个领导者的复制架构。 虽然这是一种常见的方法,但也有一些有趣的选择。 本章到目前为止,我们只考虑使用单个领导者的复制架构。 虽然这是一种常见的方法,但也有一些有趣的选择。
基于领导者的复制有一个主要的缺点:只有一个主库,而所有的写入都必须通过它[^iv]。如果出于任何原因(例如和主库之间的网络连接中断)无法连接到主库, 就无法向数据库写入。 基于领导者的复制有一个主要的缺点:只有一个主库,而所有的写入都必须通过它[^iv]。如果出于任何原因(例如和主库之间的网络连接中断)无法连接到主库, 就无法向数据库写入。
[^iv]: 如果数据库被分区(见[第六章](ch6.md)),每个分区都有一个领导。 不同的分区可能在不同的节点上有其领导者,但是每个分区必须有一个领导者节点。 [^iv]: 如果数据库被分区(见[第六章](ch6.md)),每个分区都有一个领导。 不同的分区可能在不同的节点上有其领导者,但是每个分区必须有一个领导者节点。
基于领导者的复制模型的自然延伸是允许多个节点接受写入。 复制仍然以同样的方式发生:处理写入的每个节点都必须将该数据更改转发给所有其他节点。 称之为**多领导者配置**(也称多主、多活复制)。 在这种情况下,每个领导者同时扮演其他领导者的追随者。 基于领导者的复制模型的自然延伸是允许多个节点接受写入。 复制仍然以同样的方式发生:处理写入的每个节点都必须将该数据更改转发给所有其他节点。 称之为**多领导者配置**(也称多主、多活复制)。 在这种情况下,每个领导者同时扮演其他领导者的追随者。
### 多主复制的应用场景 ### 多主复制的应用场景
在单个数据中心内部使用多个主库没有太大意义,因为复杂性已经超过了能带来的好处。 但在一些情况下,多活配置是也合理的。 在单个数据中心内部使用多个主库没有太大意义,因为复杂性已经超过了能带来的好处。 但在一些情况下,多活配置是也合理的。
#### 运维多个数据中心 #### 运维多个数据中心
假如你有一个数据库,副本分散在好几个不同的数据中心(也许这样可以容忍单个数据中心的故障,或地理上更接近用户)。 使用常规的基于领导者的复制设置,主库必须位于其中一个数据中心,且所有写入都必须经过该数据中心。 假如你有一个数据库,副本分散在好几个不同的数据中心(也许这样可以容忍单个数据中心的故障,或地理上更接近用户)。 使用常规的基于领导者的复制设置,主库必须位于其中一个数据中心,且所有写入都必须经过该数据中心。
多领导者配置中可以在每个数据中心都有主库。 [图5-6](img/fig5-6.png)展示了这个架构的样子。 在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。 多领导者配置中可以在每个数据中心都有主库。 [图5-6](img/fig5-6.png)展示了这个架构的样子。 在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。
![](img/fig5-6.png) ![](img/fig5-6.png)
@ -321,47 +321,47 @@
***性能*** ***性能***
在单主配置中,每个写入都必须穿过互联网,进入主库所在的数据中心。这可能会增加写入时间,并可能违背了设置多个数据中心的初心。在多主配置中,每个写操作都可以在本地数据中心进行处理,并与其他数据中心异步复制。因此,数据中心之间的网络延迟对用户来说是透明的,这意味着感觉到的性能可能会更好。 在单主配置中,每个写入都必须穿过互联网,进入主库所在的数据中心。这可能会增加写入时间,并可能违背了设置多个数据中心的初心。在多主配置中,每个写操作都可以在本地数据中心进行处理,并与其他数据中心异步复制。因此,数据中心之间的网络延迟对用户来说是透明的,这意味着感觉到的性能可能会更好。
***容忍数据中心停机*** ***容忍数据中心停机***
在单主配置中,如果主库所在的数据中心发生故障,故障切换必须使另一个数据中心里的追随者成为领导者。在多主配置中,每个数据中心可以独立于其他数据中心继续运行,并且当发生故障的数据中心归队时,复制会自动赶上。 在单主配置中,如果主库所在的数据中心发生故障,故障切换必须使另一个数据中心里的追随者成为领导者。在多主配置中,每个数据中心可以独立于其他数据中心继续运行,并且当发生故障的数据中心归队时,复制会自动赶上。
***容忍网络问题*** ***容忍网络问题***
数据中心之间的通信通常穿过公共互联网,这可能不如数据中心内的本地网络可靠。单主配置对这数据中心间的连接问题非常敏感,因为通过这个连接进行的写操作是同步的。采用异步复制功能的多主配置通常能更好地承受网络问题:临时的网络中断并不会妨碍正在处理的写入。 数据中心之间的通信通常穿过公共互联网,这可能不如数据中心内的本地网络可靠。单主配置对这数据中心间的连接问题非常敏感,因为通过这个连接进行的写操作是同步的。采用异步复制功能的多主配置通常能更好地承受网络问题:临时的网络中断并不会妨碍正在处理的写入。
有些数据库默认情况下支持多主配置但使用外部工具实现也很常见例如用于MySQL的Tungsten Replicator 【26】用于PostgreSQL的BDR【27】以及用于Oracle的GoldenGate 【19】。 有些数据库默认情况下支持多主配置但使用外部工具实现也很常见例如用于MySQL的Tungsten Replicator 【26】用于PostgreSQL的BDR【27】以及用于Oracle的GoldenGate 【19】。
尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如[图5-6](img/fig5-6.png)中“冲突解决conflict resolution。本书将在“[处理写入冲突](#处理写入冲突)”中详细讨论这个问题。 尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如[图5-6](img/fig5-6.png)中“冲突解决conflict resolution。本书将在“[处理写入冲突](#处理写入冲突)”中详细讨论这个问题。
由于多主复制在许多数据库中都属于改装的功能所以常常存在微妙的配置缺陷且经常与其他数据库功能之间出现意外的反应。例如自增主键、触发器、完整性约束等都可能会有麻烦。因此多主复制往往被认为是危险的领域应尽可能避免【28】。 由于多主复制在许多数据库中都属于改装的功能所以常常存在微妙的配置缺陷且经常与其他数据库功能之间出现意外的反应。例如自增主键、触发器、完整性约束等都可能会有麻烦。因此多主复制往往被认为是危险的领域应尽可能避免【28】。
#### 需要离线操作的客户端 #### 需要离线操作的客户端
多主复制的另一种适用场景是:应用程序在断网之后仍然需要继续工作。 多主复制的另一种适用场景是:应用程序在断网之后仍然需要继续工作。
例如,考虑手机,笔记本电脑和其他设备上的日历应用。无论设备目前是否有互联网连接,你需要能随时查看你的会议(发出读取请求),输入新的会议(发出写入请求)。如果在离线状态下进行任何更改,则设备下次上线时,需要与服务器和其他设备同步。 例如,考虑手机,笔记本电脑和其他设备上的日历应用。无论设备目前是否有互联网连接,你需要能随时查看你的会议(发出读取请求),输入新的会议(发出写入请求)。如果在离线状态下进行任何更改,则设备下次上线时,需要与服务器和其他设备同步。
在这种情况下,每个设备都有一个充当领导者的本地数据库(它接受写请求),并且在所有设备上的日历副本之间同步时,存在异步的多主复制过程。复制延迟可能是几小时甚至几天,具体取决于何时可以访问互联网。 在这种情况下,每个设备都有一个充当领导者的本地数据库(它接受写请求),并且在所有设备上的日历副本之间同步时,存在异步的多主复制过程。复制延迟可能是几小时甚至几天,具体取决于何时可以访问互联网。
从架构的角度来看,这种设置实际上与数据中心之间的多领导者复制类似,每个设备都是一个“数据中心”,而它们之间的网络连接是极度不可靠的。从历史上各类日历同步功能的破烂实现可以看出,想把多活配好是多么困难的一件事。 从架构的角度来看,这种设置实际上与数据中心之间的多领导者复制类似,每个设备都是一个“数据中心”,而它们之间的网络连接是极度不可靠的。从历史上各类日历同步功能的破烂实现可以看出,想把多活配好是多么困难的一件事。
有一些工具旨在使这种多领导者配置更容易。例如CouchDB就是为这种操作模式而设计的【29】。 有一些工具旨在使这种多领导者配置更容易。例如CouchDB就是为这种操作模式而设计的【29】。
#### 协同编辑 #### 协同编辑
实时协作编辑应用程序允许多个人同时编辑文档。例如Etherpad 【30】和Google Docs 【31】允许多人同时编辑文本文档或电子表格该算法在“[自动冲突解决](#自动冲突解决)”中简要讨论。我们通常不会将协作式编辑视为数据库复制问题但与前面提到的离线编辑用例有许多相似之处。当一个用户编辑文档时所做的更改将立即应用到其本地副本Web浏览器或客户端应用程序中的文档状态并异步复制到服务器和编辑同一文档的任何其他用户。 实时协作编辑应用程序允许多个人同时编辑文档。例如Etherpad 【30】和Google Docs 【31】允许多人同时编辑文本文档或电子表格该算法在“[自动冲突解决](#自动冲突解决)”中简要讨论。我们通常不会将协作式编辑视为数据库复制问题但与前面提到的离线编辑用例有许多相似之处。当一个用户编辑文档时所做的更改将立即应用到其本地副本Web浏览器或客户端应用程序中的文档状态并异步复制到服务器和编辑同一文档的任何其他用户。
如果要保证不会发生编辑冲突,则应用程序必须先取得文档的锁定,然后用户才能对其进行编辑。如果另一个用户想要编辑同一个文档,他们首先必须等到第一个用户提交修改并释放锁定。这种协作模式相当于主从复制模型下在主节点上执行事务操作。 如果要保证不会发生编辑冲突,则应用程序必须先取得文档的锁定,然后用户才能对其进行编辑。如果另一个用户想要编辑同一个文档,他们首先必须等到第一个用户提交修改并释放锁定。这种协作模式相当于主从复制模型下在主节点上执行事务操作。
但是为了加速协作您可能希望将更改的单位设置得非常小例如一个按键并避免锁定。这种方法允许多个用户同时进行编辑但同时也带来了多领导者复制的所有挑战包括需要解决冲突【32】。 但是为了加速协作您可能希望将更改的单位设置得非常小例如一个按键并避免锁定。这种方法允许多个用户同时进行编辑但同时也带来了多领导者复制的所有挑战包括需要解决冲突【32】。
### 处理写入冲突 ### 处理写入冲突
多领导者复制的最大问题是可能发生写冲突,这意味着需要解决冲突。 多领导者复制的最大问题是可能发生写冲突,这意味着需要解决冲突。
例如,考虑一个由两个用户同时编辑的维基页面,如[图5-7](img/fig5-7.png)所示。用户1将页面的标题从A更改为B并且用户2同时将标题从A更改为C。每个用户的更改已成功应用到其本地主库。但当异步复制时会发现冲突【33】。单主数据库中不会出现此问题。 例如,考虑一个由两个用户同时编辑的维基页面,如[图5-7](img/fig5-7.png)所示。用户1将页面的标题从A更改为B并且用户2同时将标题从A更改为C。每个用户的更改已成功应用到其本地主库。但当异步复制时会发现冲突【33】。单主数据库中不会出现此问题。
![](img/fig5-7.png) ![](img/fig5-7.png)
@ -369,25 +369,25 @@
#### 同步与异步冲突检测 #### 同步与异步冲突检测
在单主数据库中,第二个写入将被阻塞,并等待第一个写入完成,或中止第二个写入事务,强制用户重试。另一方面,在多主配置中,两个写入都是成功的,并且在稍后的时间点仅仅异步地检测到冲突。那时要求用户解决冲突可能为时已晚。 在单主数据库中,第二个写入将被阻塞,并等待第一个写入完成,或中止第二个写入事务,强制用户重试。另一方面,在多主配置中,两个写入都是成功的,并且在稍后的时间点仅仅异步地检测到冲突。那时要求用户解决冲突可能为时已晚。
原则上,可以使冲突检测同步 - 即等待写入被复制到所有副本,然后再告诉用户写入成功。但是,通过这样做,您将失去多主复制的主要优点:允许每个副本独立接受写入。如果您想要同步冲突检测,那么您可以使用单主程序复制。 原则上,可以使冲突检测同步 - 即等待写入被复制到所有副本,然后再告诉用户写入成功。但是,通过这样做,您将失去多主复制的主要优点:允许每个副本独立接受写入。如果您想要同步冲突检测,那么您可以使用单主程序复制。
#### 避免冲突 #### 避免冲突
处理冲突的最简单的策略就是避免它们如果应用程序可以确保特定记录的所有写入都通过同一个领导者那么冲突就不会发生。由于许多的多领导者复制实现在处理冲突时处理得相当不好避免冲突是一个经常推荐的方法【34】。 处理冲突的最简单的策略就是避免它们如果应用程序可以确保特定记录的所有写入都通过同一个领导者那么冲突就不会发生。由于许多的多领导者复制实现在处理冲突时处理得相当不好避免冲突是一个经常推荐的方法【34】。
例如,在用户可以编辑自己的数据的应用程序中,可以确保来自特定用户的请求始终路由到同一数据中心,并使用该数据中心的领导者进行读写。不同的用户可能有不同的“家庭”数据中心(可能根据用户的地理位置选择),但从任何用户的角度来看,配置基本上都是单一的领导者。 例如,在用户可以编辑自己的数据的应用程序中,可以确保来自特定用户的请求始终路由到同一数据中心,并使用该数据中心的领导者进行读写。不同的用户可能有不同的“家庭”数据中心(可能根据用户的地理位置选择),但从任何用户的角度来看,配置基本上都是单一的领导者。
但是,有时您可能需要更改指定的记录的主库——可能是因为一个数据中心出现故障,您需要将流量重新路由到另一个数据中心,或者可能是因为用户已经迁移到另一个位置,现在更接近不同的数据中心。在这种情况下,冲突避免会中断,你必须处理不同主库同时写入的可能性。 但是,有时您可能需要更改指定的记录的主库——可能是因为一个数据中心出现故障,您需要将流量重新路由到另一个数据中心,或者可能是因为用户已经迁移到另一个位置,现在更接近不同的数据中心。在这种情况下,冲突避免会中断,你必须处理不同主库同时写入的可能性。
#### 收敛至一致的状态 #### 收敛至一致的状态
单主数据库按顺序进行写操作:如果同一个字段有多个更新,则最后一个写操作将决定该字段的最终值。 单主数据库按顺序进行写操作:如果同一个字段有多个更新,则最后一个写操作将决定该字段的最终值。
在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在[图5-7](img/fig5-7.png)中在主库1中标题首先更新为B而后更新为C在主库2中首先更新为C然后更新为B。两个顺序都不是“更正确”的。 在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在[图5-7](img/fig5-7.png)中在主库1中标题首先更新为B而后更新为C在主库2中首先更新为C然后更新为B。两个顺序都不是“更正确”的。
如果每个副本只是按照它看到写入的顺序写入那么数据库最终将处于不一致的状态最终值将是在主库1的C和主库2的B。这是不可接受的每个复制方案都必须确保数据在所有副本中最终都是相同的。因此数据库必须以一种 **收敛convergent** 的方式解决冲突,这意味着所有副本必须在所有变更复制完成时收敛至一个相同的最终值。 如果每个副本只是按照它看到写入的顺序写入那么数据库最终将处于不一致的状态最终值将是在主库1的C和主库2的B。这是不可接受的每个复制方案都必须确保数据在所有副本中最终都是相同的。因此数据库必须以一种 **收敛convergent** 的方式解决冲突,这意味着所有副本必须在所有变更复制完成时收敛至一个相同的最终值。
实现冲突合并解决有多种途径: 实现冲突合并解决有多种途径:
@ -400,17 +400,17 @@
#### 自定义冲突解决逻辑 #### 自定义冲突解决逻辑
作为解决冲突最合适的方法可能取决于应用程序,大多数多主复制工具允许使用应用程序代码编写冲突解决逻辑。该代码可以在写入或读取时执行: 作为解决冲突最合适的方法可能取决于应用程序,大多数多主复制工具允许使用应用程序代码编写冲突解决逻辑。该代码可以在写入或读取时执行:
***写时执行*** ***写时执行***
只要数据库系统检测到复制更改日志中存在冲突就会调用冲突处理程序。例如Bucardo允许您为此编写一段Perl代码。这个处理程序通常不能提示用户——它在后台进程中运行并且必须快速执行。 只要数据库系统检测到复制更改日志中存在冲突就会调用冲突处理程序。例如Bucardo允许您为此编写一段Perl代码。这个处理程序通常不能提示用户——它在后台进程中运行并且必须快速执行。
***读时执行*** ***读时执行***
当检测到冲突时所有冲突写入被存储。下一次读取数据时会将这些多个版本的数据返回给应用程序。应用程序可能会提示用户或自动解决冲突并将结果写回数据库。例如CouchDB以这种方式工作。 当检测到冲突时所有冲突写入被存储。下一次读取数据时会将这些多个版本的数据返回给应用程序。应用程序可能会提示用户或自动解决冲突并将结果写回数据库。例如CouchDB以这种方式工作。
请注意冲突解决通常适用于单个行或文档层面而不是整个事务【36】。因此如果您有一个事务会原子性地进行几次不同的写入请参阅[第七章](ch7.md),对于冲突解决而言,每个写入仍需分开单独考虑。 请注意冲突解决通常适用于单个行或文档层面而不是整个事务【36】。因此如果您有一个事务会原子性地进行几次不同的写入请参阅[第七章](ch7.md),对于冲突解决而言,每个写入仍需分开单独考虑。
@ -431,97 +431,97 @@
#### 什么是冲突? #### 什么是冲突?
有些冲突是显而易见的。在[图5-7](img/fig5-7.png)的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。 有些冲突是显而易见的。在[图5-7](img/fig5-7.png)的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。
其他类型的冲突可能更为微妙,难以发现。例如,考虑一个会议室预订系统:它记录谁订了哪个时间段的哪个房间。应用需要确保每个房间只有一组人同时预定(即不得有相同房间的重叠预订)。在这种情况下,如果同时为同一个房间创建两个不同的预订,则可能会发生冲突。即使应用程序在允许用户进行预订之前检查可用性,如果两次预订是由两个不同的领导者进行的,则可能会有冲突。 其他类型的冲突可能更为微妙,难以发现。例如,考虑一个会议室预订系统:它记录谁订了哪个时间段的哪个房间。应用需要确保每个房间只有一组人同时预定(即不得有相同房间的重叠预订)。在这种情况下,如果同时为同一个房间创建两个不同的预订,则可能会发生冲突。即使应用程序在允许用户进行预订之前检查可用性,如果两次预订是由两个不同的领导者进行的,则可能会有冲突。
现在还没有一个现成的答案,但在接下来的章节中,我们将更好地了解这个问题。我们将在[第七章](ch7.md)中看到更多的冲突示例,在[第十二章](ch12.md)中我们将讨论用于检测和解决复制系统中冲突的可伸缩方法。 现在还没有一个现成的答案,但在接下来的章节中,我们将更好地了解这个问题。我们将在[第七章](ch7.md)中看到更多的冲突示例,在[第十二章](ch12.md)中我们将讨论用于检测和解决复制系统中冲突的可伸缩方法。
### 多主复制拓扑 ### 多主复制拓扑
**复制拓扑**replication topology描述写入从一个节点传播到另一个节点的通信路径。如果你有两个领导者如[图5-7](img/fig5-7.png)所示只有一个合理的拓扑结构领导者1必须把他所有的写到领导者2反之亦然。当有两个以上的领导各种不同的拓扑是可能的。[图5-8](img/fig5-8.png)举例说明了一些例子。 **复制拓扑**replication topology描述写入从一个节点传播到另一个节点的通信路径。如果你有两个领导者如[图5-7](img/fig5-7.png)所示只有一个合理的拓扑结构领导者1必须把他所有的写到领导者2反之亦然。当有两个以上的领导各种不同的拓扑是可能的。[图5-8](img/fig5-8.png)举例说明了一些例子。
![](img/fig5-8.png) ![](img/fig5-8.png)
**图5-8 三个可以设置多领导者复制的示例拓扑。** **图5-8 三个可以设置多领导者复制的示例拓扑。**
最普遍的拓扑是全部到全部([图5-8 (c)](img/fig5-8.png)其中每个领导者将其写入每个其他领导。但是也会使用更多受限制的拓扑例如默认情况下MySQL仅支持**环形拓扑circular topology**【34】其中每个节点接收来自一个节点的写入并将这些写入加上自己的任何写入转发给另一个节点。另一种流行的拓扑结构具有星形的形状[^v]。一个指定的根节点将写入转发给所有其他节点。星形拓扑可以推广到树。 最普遍的拓扑是全部到全部([图5-8 (c)](img/fig5-8.png)其中每个领导者将其写入每个其他领导。但是也会使用更多受限制的拓扑例如默认情况下MySQL仅支持**环形拓扑circular topology**【34】其中每个节点接收来自一个节点的写入并将这些写入加上自己的任何写入转发给另一个节点。另一种流行的拓扑结构具有星形的形状[^v]。一个指定的根节点将写入转发给所有其他节点。星形拓扑可以推广到树。
[^v]: 不要与星型模式混淆(请参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”),其中描述了数据模型的结构,而不是节点之间的通信拓扑。 [^v]: 不要与星型模式混淆(请参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”),其中描述了数据模型的结构,而不是节点之间的通信拓扑。
在环形和星形拓扑中写入可能需要在到达所有副本之前通过多个节点。因此节点需要转发从其他节点收到的数据更改。为了防止无限复制循环每个节点被赋予一个唯一的标识符并且在复制日志中每个写入都被标记了所有已经过的节点的标识符【43】。当一个节点收到用自己的标识符标记的数据更改时该数据更改将被忽略因为节点知道它已经被处理过。 在环形和星形拓扑中写入可能需要在到达所有副本之前通过多个节点。因此节点需要转发从其他节点收到的数据更改。为了防止无限复制循环每个节点被赋予一个唯一的标识符并且在复制日志中每个写入都被标记了所有已经过的节点的标识符【43】。当一个节点收到用自己的标识符标记的数据更改时该数据更改将被忽略因为节点知道它已经被处理过。
环形和星形拓扑的问题是,如果只有一个节点发生故障,则可能会中断其他节点之间的复制消息流,导致它们无法通信,直到节点修复。拓扑结构可以重新配置为在发生故障的节点上工作,但在大多数部署中,这种重新配置必须手动完成。更密集连接的拓扑结构(例如全部到全部)的容错性更好,因为它允许消息沿着不同的路径传播,避免单点故障。 环形和星形拓扑的问题是,如果只有一个节点发生故障,则可能会中断其他节点之间的复制消息流,导致它们无法通信,直到节点修复。拓扑结构可以重新配置为在发生故障的节点上工作,但在大多数部署中,这种重新配置必须手动完成。更密集连接的拓扑结构(例如全部到全部)的容错性更好,因为它允许消息沿着不同的路径传播,避免单点故障。
另一方面,全部到全部的拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如,由于网络拥塞),结果是一些复制消息可能“超过”其他复制消息,如[图5-9](img/fig5-9.png)所示。 另一方面,全部到全部的拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如,由于网络拥塞),结果是一些复制消息可能“超过”其他复制消息,如[图5-9](img/fig5-9.png)所示。
![](img/fig5-9.png) ![](img/fig5-9.png)
**图5-9 使用多主程序复制时,可能会在某些副本中写入错误的顺序。** **图5-9 使用多主程序复制时,可能会在某些副本中写入错误的顺序。**
在[图5-9](img/fig5-9.png)中客户端A向主库1的表中插入一行客户端B在主库3上更新该行。然而主库2可以以不同的顺序接收写入它可以首先接收更新从它的角度来看是对数据库中不存在的行的更新并且仅在稍后接收到相应的插入其应该在更新之前 在[图5-9](img/fig5-9.png)中客户端A向主库1的表中插入一行客户端B在主库3上更新该行。然而主库2可以以不同的顺序接收写入它可以首先接收更新从它的角度来看是对数据库中不存在的行的更新并且仅在稍后接收到相应的插入其应该在更新之前
这是一个因果关系的问题,类似于我们在“[一致前缀读](#一致前缀读)”中看到的更新取决于先前的插入所以我们需要确保所有节点先处理插入然后再处理更新。仅仅在每一次写入时添加一个时间戳是不够的因为时钟不可能被充分地同步以便在主库2处正确地排序这些事件见[第八章](ch8.md))。 这是一个因果关系的问题,类似于我们在“[一致前缀读](#一致前缀读)”中看到的更新取决于先前的插入所以我们需要确保所有节点先处理插入然后再处理更新。仅仅在每一次写入时添加一个时间戳是不够的因为时钟不可能被充分地同步以便在主库2处正确地排序这些事件见[第八章](ch8.md))。
要正确排序这些事件,可以使用一种称为 **版本向量version vectors** 的技术,本章稍后将讨论这种技术(请参阅“[检测并发写入](#检测并发写入)”。然而冲突检测技术在许多多领导者复制系统中执行得不好。例如在撰写本文时PostgreSQL BDR不提供写入的因果排序【27】而Tungsten Replicator for MySQL甚至不尝试检测冲突【34】。 要正确排序这些事件,可以使用一种称为 **版本向量version vectors** 的技术,本章稍后将讨论这种技术(请参阅“[检测并发写入](#检测并发写入)”。然而冲突检测技术在许多多领导者复制系统中执行得不好。例如在撰写本文时PostgreSQL BDR不提供写入的因果排序【27】而Tungsten Replicator for MySQL甚至不尝试检测冲突【34】。
如果您正在使用具有多领导者复制功能的系统,那么应该了解这些问题,仔细阅读文档,并彻底测试您的数据库,以确保它确实提供了您认为具有的保证。 如果您正在使用具有多领导者复制功能的系统,那么应该了解这些问题,仔细阅读文档,并彻底测试您的数据库,以确保它确实提供了您认为具有的保证。
## 无主复制 ## 无主复制
我们在本章到目前为止所讨论的复制方法 ——单主复制、多主复制——都是这样的想法:客户端向一个主库发送写请求,而数据库系统负责将写入复制到其他副本。主库决定写入的顺序,而从库按相同顺序应用主库的写入。 我们在本章到目前为止所讨论的复制方法 ——单主复制、多主复制——都是这样的想法:客户端向一个主库发送写请求,而数据库系统负责将写入复制到其他副本。主库决定写入的顺序,而从库按相同顺序应用主库的写入。
一些数据存储系统采用不同的方法,放弃主库的概念,并允许任何副本直接接受来自客户端的写入。最早的一些的复制数据系统是**无领导的leaderless**【1,44】但是在关系数据库主导的时代这个想法几乎已被忘却。在亚马逊将其用于其内部的Dynamo系统[^vi]之后它再一次成为数据库的一种时尚架构【37】。 RiakCassandra和Voldemort是由Dynamo启发的无领导复制模型的开源数据存储所以这类数据库也被称为*Dynamo风格*。 一些数据存储系统采用不同的方法,放弃主库的概念,并允许任何副本直接接受来自客户端的写入。最早的一些的复制数据系统是**无领导的leaderless**【1,44】但是在关系数据库主导的时代这个想法几乎已被忘却。在亚马逊将其用于其内部的Dynamo系统[^vi]之后它再一次成为数据库的一种时尚架构【37】。 RiakCassandra和Voldemort是由Dynamo启发的无领导复制模型的开源数据存储所以这类数据库也被称为*Dynamo风格*。
[^vi]: Dynamo不适用于Amazon以外的用户。 令人困惑的是AWS提供了一个名为DynamoDB的托管数据库产品它使用了完全不同的体系结构它基于单领导者复制。 [^vi]: Dynamo不适用于Amazon以外的用户。 令人困惑的是AWS提供了一个名为DynamoDB的托管数据库产品它使用了完全不同的体系结构它基于单领导者复制。
在一些无领导者的实现中,客户端直接将写入发送到几个副本中,而另一些情况下,一个 **协调者coordinator** 节点代表客户端进行写入。但与主库数据库不同,协调者不执行特定的写入顺序。我们将会看到,这种设计上的差异对数据库的使用方式有着深远的影响。 在一些无领导者的实现中,客户端直接将写入发送到几个副本中,而另一些情况下,一个 **协调者coordinator** 节点代表客户端进行写入。但与主库数据库不同,协调者不执行特定的写入顺序。我们将会看到,这种设计上的差异对数据库的使用方式有着深远的影响。
### 当节点故障时写入数据库 ### 当节点故障时写入数据库
假设你有一个带有三个副本的数据库,而其中一个副本目前不可用,或许正在重新启动以安装系统更新。在基于主机的配置中,如果要继续处理写入,则可能需要执行故障切换(请参阅「[处理节点宕机](#处理节点宕机)」)。 假设你有一个带有三个副本的数据库,而其中一个副本目前不可用,或许正在重新启动以安装系统更新。在基于主机的配置中,如果要继续处理写入,则可能需要执行故障切换(请参阅「[处理节点宕机](#处理节点宕机)」)。
另一方面,在无领导配置中,故障切换不存在。[图5-10](img/fig5-10.png)显示了发生了什么事情客户端用户1234并行发送写入到所有三个副本并且两个可用副本接受写入但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的在用户1234已经收到两个确定的响应之后我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。 另一方面,在无领导配置中,故障切换不存在。[图5-10](img/fig5-10.png)显示了发生了什么事情客户端用户1234并行发送写入到所有三个副本并且两个可用副本接受写入但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的在用户1234已经收到两个确定的响应之后我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。
![](img/fig5-10.png) ![](img/fig5-10.png)
**图5-10 法定写入,法定读取,并在节点中断后读修复。** **图5-10 法定写入,法定读取,并在节点中断后读修复。**
现在想象一下,不可用的节点重新联机,客户端开始读取它。节点关闭时发生的任何写入都从该节点丢失。因此,如果您从该节点读取数据,则可能会将陈旧(过时)值视为响应。 现在想象一下,不可用的节点重新联机,客户端开始读取它。节点关闭时发生的任何写入都从该节点丢失。因此,如果您从该节点读取数据,则可能会将陈旧(过时)值视为响应。
为了解决这个问题,当一个客户端从数据库中读取数据时,它不仅仅发送它的请求到一个副本:读请求也被并行地发送到多个节点。客户可能会从不同的节点获得不同的响应。即来自一个节点的最新值和来自另一个节点的陈旧值。版本号用于确定哪个值更新(请参阅“[检测并发写入](#检测并发写入)”)。 为了解决这个问题,当一个客户端从数据库中读取数据时,它不仅仅发送它的请求到一个副本:读请求也被并行地发送到多个节点。客户可能会从不同的节点获得不同的响应。即来自一个节点的最新值和来自另一个节点的陈旧值。版本号用于确定哪个值更新(请参阅“[检测并发写入](#检测并发写入)”)。
#### 读修复和反熵 #### 读修复和反熵
复制方案应确保最终将所有数据复制到每个副本。在一个不可用的节点重新联机之后,它如何赶上它错过的写入? 复制方案应确保最终将所有数据复制到每个副本。在一个不可用的节点重新联机之后,它如何赶上它错过的写入?
在Dynamo风格的数据存储中经常使用两种机制 在Dynamo风格的数据存储中经常使用两种机制
***读修复Read repair*** ***读修复Read repair***
当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在[图5-10](img/fig5-10.png)中用户2345获得了来自副本3的版本6值和来自副本1和2的版本7值。客户端发现副本3具有陈旧值并将新值写回到该副本。这种方法适用于读频繁的值。 当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在[图5-10](img/fig5-10.png)中用户2345获得了来自副本3的版本6值和来自副本1和2的版本7值。客户端发现副本3具有陈旧值并将新值写回到该副本。这种方法适用于读频繁的值。
***反熵过程Anti-entropy process*** ***反熵过程Anti-entropy process***
此外,一些数据存储具有后台进程,该进程不断查找副本之间的数据差异,并将任何缺少的数据从一个副本复制到另一个副本。与基于领导者的复制中的复制日志不同,此反熵过程不会以任何特定的顺序复制写入,并且在复制数据之前可能会有显著的延迟。 此外,一些数据存储具有后台进程,该进程不断查找副本之间的数据差异,并将任何缺少的数据从一个副本复制到另一个副本。与基于领导者的复制中的复制日志不同,此反熵过程不会以任何特定的顺序复制写入,并且在复制数据之前可能会有显著的延迟。
并不是所有的系统都实现了这两个例如Voldemort目前没有反熵过程。请注意如果没有反熵过程某些副本中很少读取的值可能会丢失从而降低了持久性因为只有在应用程序读取值时才执行读修复。 并不是所有的系统都实现了这两个例如Voldemort目前没有反熵过程。请注意如果没有反熵过程某些副本中很少读取的值可能会丢失从而降低了持久性因为只有在应用程序读取值时才执行读修复。
#### 读写的法定人数 #### 读写的法定人数
在[图5-10](img/fig5-10.png)的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?以此类推,究竟多少个副本完成才可以认为写成功? 在[图5-10](img/fig5-10.png)的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?以此类推,究竟多少个副本完成才可以认为写成功?
如果我们知道,每个成功的写操作意味着在三个副本中至少有两个出现,这意味着至多有一个副本可能是陈旧的。因此,如果我们从至少两个副本读取,我们可以确定至少有一个是最新的。如果第三个副本停机或响应速度缓慢,则读取仍可以继续返回最新值。 如果我们知道,每个成功的写操作意味着在三个副本中至少有两个出现,这意味着至多有一个副本可能是陈旧的。因此,如果我们从至少两个副本读取,我们可以确定至少有一个是最新的。如果第三个副本停机或响应速度缓慢,则读取仍可以继续返回最新值。
更一般地说如果有n个副本每个写入必须由w节点确认才能被认为是成功的并且我们必须至少为每个读取查询r个节点。 (在我们的例子中,$n = 3w = 2r = 2$)。只要$w + r> n$我们期望在读取时获得最新的值因为r个读取中至少有一个节点是最新的。遵循这些r值w值的读写称为**法定人数quorum**[^vii]的读和写【44】。你可以认为r和w是有效读写所需的最低票数。 更一般地说如果有n个副本每个写入必须由w节点确认才能被认为是成功的并且我们必须至少为每个读取查询r个节点。 (在我们的例子中,$n = 3w = 2r = 2$)。只要$w + r> n$我们期望在读取时获得最新的值因为r个读取中至少有一个节点是最新的。遵循这些r值w值的读写称为**法定人数quorum**[^vii]的读和写【44】。你可以认为r和w是有效读写所需的最低票数。
[^vii]: 有时候这种法定人数被称为严格的法定人数,相对“宽松的法定人数”而言(见“[宽松的法定人数与提示移交](#宽松的法定人数与提示移交)”) [^vii]: 有时候这种法定人数被称为严格的法定人数,相对“宽松的法定人数”而言(见“[宽松的法定人数与提示移交](#宽松的法定人数与提示移交)”)
在Dynamo风格的数据库中参数nw和r通常是可配置的。一个常见的选择是使n为奇数通常为3或5并设置 $w = r =n + 1/ 2$(向上取整)。但是可以根据需要更改数字。例如,设置$w = n$和$r = 1$的写入很少且读取次数较多的工作负载可能会受益。这使得读取速度更快,但具有只有一个失败节点导致所有数据库写入失败的缺点。 在Dynamo风格的数据库中参数nw和r通常是可配置的。一个常见的选择是使n为奇数通常为3或5并设置 $w = r =n + 1/ 2$(向上取整)。但是可以根据需要更改数字。例如,设置$w = n$和$r = 1$的写入很少且读取次数较多的工作负载可能会受益。这使得读取速度更快,但具有只有一个失败节点导致所有数据库写入失败的缺点。
> 集群中可能有多于n的节点。集群的机器数可能多于副本数目但是任何给定的值只能存储在n个节点上。这允许对数据集进行分区从而可以支持比单个节点的存储能力更大的数据集。我们将在[第六章](ch6.md)继续讨论分区。 > 集群中可能有多于n的节点。集群的机器数可能多于副本数目但是任何给定的值只能存储在n个节点上。这允许对数据集进行分区从而可以支持比单个节点的存储能力更大的数据集。我们将在[第六章](ch6.md)继续讨论分区。
> >
@ -538,19 +538,19 @@
**图5-11 如果$w + r > n$读取r个副本至少有一个r副本必然包含了最近的成功写入** **图5-11 如果$w + r > n$读取r个副本至少有一个r副本必然包含了最近的成功写入**
如果少于所需的w或r节点可用则写入或读取将返回错误。 由于许多原因,节点可能不可用:因为执行操作的错误(由于磁盘已满而无法写入),因为节点关闭(崩溃,关闭电源),由于客户端和服务器节点之间的网络中断,或任何其他原因。 我们只关心节点是否返回了成功的响应,而不需要区分不同类型的错误。 如果少于所需的w或r节点可用则写入或读取将返回错误。 由于许多原因,节点可能不可用:因为执行操作的错误(由于磁盘已满而无法写入),因为节点关闭(崩溃,关闭电源),由于客户端和服务器节点之间的网络中断,或任何其他原因。 我们只关心节点是否返回了成功的响应,而不需要区分不同类型的错误。
### 法定人数一致性的局限性 ### 法定人数一致性的局限性
如果你有n个副本并且你选择w和r使得$w + r> n$,你通常可以期望每个键的读取都能返回最近写入的值。情况就是这样,因为你写入的节点集合和你读取的节点集合必须重叠。也就是说,您读取的节点中必须至少有一个具有最新值的节点(如[图5-11](img/fig5-11.png)所示)。 如果你有n个副本并且你选择w和r使得$w + r> n$,你通常可以期望每个键的读取都能返回最近写入的值。情况就是这样,因为你写入的节点集合和你读取的节点集合必须重叠。也就是说,您读取的节点中必须至少有一个具有最新值的节点(如[图5-11](img/fig5-11.png)所示)。
通常r和w被选为多数超过 $n/2$ )节点,因为这确保了$w + r> n$,同时仍然容忍多达$n/2$个节点故障。但是法定人数不一定必须是大多数只是读写使用的节点交集至少需要包括一个节点。其他法定人数的配置是可能的这使得分布式算法的设计有一定的灵活性【45】。 通常r和w被选为多数超过 $n/2$ )节点,因为这确保了$w + r> n$,同时仍然容忍多达$n/2$个节点故障。但是法定人数不一定必须是大多数只是读写使用的节点交集至少需要包括一个节点。其他法定人数的配置是可能的这使得分布式算法的设计有一定的灵活性【45】。
您也可以将w和r设置为较小的数字以使$w + r≤n$即法定条件不满足。在这种情况下读取和写入操作仍将被发送到n个节点但操作成功只需要少量的成功响应。 您也可以将w和r设置为较小的数字以使$w + r≤n$即法定条件不满足。在这种情况下读取和写入操作仍将被发送到n个节点但操作成功只需要少量的成功响应。
较小的w和r更有可能会读取过时的数据因为您的读取更有可能不包含具有最新值的节点。另一方面这种配置允许更低的延迟和更高的可用性如果存在网络中断并且许多副本变得无法访问则可以继续处理读取和写入的机会更大。只有当可达副本的数量低于w或r时数据库才分别变得不可用于写入或读取。 较小的w和r更有可能会读取过时的数据因为您的读取更有可能不包含具有最新值的节点。另一方面这种配置允许更低的延迟和更高的可用性如果存在网络中断并且许多副本变得无法访问则可以继续处理读取和写入的机会更大。只有当可达副本的数量低于w或r时数据库才分别变得不可用于写入或读取。
但是,即使在$w + r> n$的情况下,也可能存在返回陈旧值的边缘情况。这取决于实现,但可能的情况包括: 但是,即使在$w + r> n$的情况下,也可能存在返回陈旧值的边缘情况。这取决于实现,但可能的情况包括:
@ -563,52 +563,52 @@
因此,尽管法定人数似乎保证读取返回最新的写入值,但在实践中并不那么简单。 Dynamo风格的数据库通常针对可以忍受最终一致性的用例进行优化。允许通过参数w和r来调整读取陈旧值的概率但把它们当成绝对的保证是不明智的。 因此,尽管法定人数似乎保证读取返回最新的写入值,但在实践中并不那么简单。 Dynamo风格的数据库通常针对可以忍受最终一致性的用例进行优化。允许通过参数w和r来调整读取陈旧值的概率但把它们当成绝对的保证是不明智的。
尤其是,因为通常没有得到“[复制延迟问题](#复制延迟问题)”中讨论的保证(读己之写,单调读,一致前缀读),前面提到的异常可能会发生在应用程序中。更强有力的保证通常需要**事务**或**共识**。我们将在[第七章](ch7.md)和[第九章](ch9.md)回到这些话题。 尤其是,因为通常没有得到“[复制延迟问题](#复制延迟问题)”中讨论的保证(读己之写,单调读,一致前缀读),前面提到的异常可能会发生在应用程序中。更强有力的保证通常需要**事务**或**共识**。我们将在[第七章](ch7.md)和[第九章](ch9.md)回到这些话题。
#### 监控陈旧度 #### 监控陈旧度
从运维的角度来看,监视你的数据库是否返回最新的结果是很重要的。即使应用可以容忍陈旧的读取,您也需要了解复制的健康状况。如果显著落后,应该提醒您,以便您可以调查原因(例如,网络中的问题或超载节点)。 从运维的角度来看,监视你的数据库是否返回最新的结果是很重要的。即使应用可以容忍陈旧的读取,您也需要了解复制的健康状况。如果显著落后,应该提醒您,以便您可以调查原因(例如,网络中的问题或超载节点)。
对于基于领导者的复制,数据库通常会公开复制滞后的度量标准,您可以将其提供给监视系统。这是可能的,因为写入按照相同的顺序应用于领导者和追随者,并且每个节点在复制日志中具有一个位置(在本地应用的写入数量)。通过从领导者的当前位置中减去追随者的当前位置,您可以测量复制滞后量。 对于基于领导者的复制,数据库通常会公开复制滞后的度量标准,您可以将其提供给监视系统。这是可能的,因为写入按照相同的顺序应用于领导者和追随者,并且每个节点在复制日志中具有一个位置(在本地应用的写入数量)。通过从领导者的当前位置中减去追随者的当前位置,您可以测量复制滞后量。
然而,在无领导者复制的系统中,没有固定的写入顺序,这使得监控变得更加困难。而且,如果数据库只使用读修复(没有反熵过程),那么对于一个值可能会有多大的限制是没有限制的 - 如果一个值很少被读取,那么由一个陈旧副本返回的值可能是古老的。 然而,在无领导者复制的系统中,没有固定的写入顺序,这使得监控变得更加困难。而且,如果数据库只使用读修复(没有反熵过程),那么对于一个值可能会有多大的限制是没有限制的 - 如果一个值很少被读取,那么由一个陈旧副本返回的值可能是古老的。
已经有一些关于衡量无主复制数据库中的复制陈旧度的研究并根据参数nw和r来预测陈旧读取的预期百分比【48】。不幸的是这还不是很常见的做法但是将陈旧测量值包含在数据库的度量标准集中是一件好事。虽然最终一致性是一种有意模糊的保证但是从可操作性角度来说能够量化“最终”也是很重要的。 已经有一些关于衡量无主复制数据库中的复制陈旧度的研究并根据参数nw和r来预测陈旧读取的预期百分比【48】。不幸的是这还不是很常见的做法但是将陈旧测量值包含在数据库的度量标准集中是一件好事。虽然最终一致性是一种有意模糊的保证但是从可操作性角度来说能够量化“最终”也是很重要的。
### 宽松的法定人数与提示移交 ### 宽松的法定人数与提示移交
合理配置的法定人数可以使数据库无需故障切换即可容忍个别节点的故障。也可以容忍个别节点变慢因为请求不必等待所有n个节点响应——当w或r节点响应时它们可以返回。对于需要高可用、低延时、且能够容忍偶尔读到陈旧值的应用场景来说这些特性使无主复制的数据库很有吸引力。 合理配置的法定人数可以使数据库无需故障切换即可容忍个别节点的故障。也可以容忍个别节点变慢因为请求不必等待所有n个节点响应——当w或r节点响应时它们可以返回。对于需要高可用、低延时、且能够容忍偶尔读到陈旧值的应用场景来说这些特性使无主复制的数据库很有吸引力。
然而法定人数如迄今为止所描述的并不像它们可能的那样具有容错性。网络中断可以很容易地将客户端从大量的数据库节点上切断。虽然这些节点是活着的而其他客户端可能能够连接到它们但是从数据库节点切断的客户端来看它们也可能已经死亡。在这种情况下剩余的可用节点可能会少于w或r因此客户端不再能达到法定人数。 然而法定人数如迄今为止所描述的并不像它们可能的那样具有容错性。网络中断可以很容易地将客户端从大量的数据库节点上切断。虽然这些节点是活着的而其他客户端可能能够连接到它们但是从数据库节点切断的客户端来看它们也可能已经死亡。在这种情况下剩余的可用节点可能会少于w或r因此客户端不再能达到法定人数。
在一个大型的集群中节点数量明显多于n个网络中断期间客户端可能仍能连接到一些数据库节点但又不足以组成一个特定值的法定人数。在这种情况下数据库设计人员需要权衡一下 在一个大型的集群中节点数量明显多于n个网络中断期间客户端可能仍能连接到一些数据库节点但又不足以组成一个特定值的法定人数。在这种情况下数据库设计人员需要权衡一下
* 对于所有无法达到w或r节点法定人数的请求是否返回错误是更好的 * 对于所有无法达到w或r节点法定人数的请求是否返回错误是更好的
* 或者我们是否应该接受写入然后将它们写入一些可达的节点但不在这些值通常所存在的n个节点上 * 或者我们是否应该接受写入然后将它们写入一些可达的节点但不在这些值通常所存在的n个节点上
后者被认为是一个**宽松的法定人数sloppy quorum**【37】写和读仍然需要w和r成功的响应但这些响应可能来自不在指定的n个“主”节点中的其它节点。比方说如果你把自己锁在房子外面你可能会敲开邻居的门问你是否可以暂时呆在沙发上。 后者被认为是一个**宽松的法定人数sloppy quorum**【37】写和读仍然需要w和r成功的响应但这些响应可能来自不在指定的n个“主”节点中的其它节点。比方说如果你把自己锁在房子外面你可能会敲开邻居的门问你是否可以暂时呆在沙发上。
一旦网络中断得到解决,代表另一个节点临时接受的一个节点的任何写入都被发送到适当的“主”节点。这就是所谓的**提示移交hinted handoff**。 (一旦你再次找到你的房子的钥匙,你的邻居礼貌地要求你离开沙发回家。) 一旦网络中断得到解决,代表另一个节点临时接受的一个节点的任何写入都被发送到适当的“主”节点。这就是所谓的**提示移交hinted handoff**。 (一旦你再次找到你的房子的钥匙,你的邻居礼貌地要求你离开沙发回家。)
宽松的法定人数对写入可用性的提高特别有用只要有任何w节点可用数据库就可以接受写入。然而这意味着即使当$w + r> n$时也不能确定读取某个键的最新值因为最新的值可能已经临时写入了n之外的某些节点【47】。 宽松的法定人数对写入可用性的提高特别有用只要有任何w节点可用数据库就可以接受写入。然而这意味着即使当$w + r> n$时也不能确定读取某个键的最新值因为最新的值可能已经临时写入了n之外的某些节点【47】。
因此在传统意义上一个宽松的法定人数实际上不是一个法定人数。这只是一个保证即数据存储在w节点的地方。但不能保证r节点的读取直到提示移交已经完成。 因此在传统意义上一个宽松的法定人数实际上不是一个法定人数。这只是一个保证即数据存储在w节点的地方。但不能保证r节点的读取直到提示移交已经完成。
在所有常见的Dynamo实现中宽松的法定人数是可选的。在Riak中它们默认是启用的而在Cassandra和Voldemort中它们默认是禁用的【46,49,50】。 在所有常见的Dynamo实现中宽松的法定人数是可选的。在Riak中它们默认是启用的而在Cassandra和Voldemort中它们默认是禁用的【46,49,50】。
#### 运维多个数据中心 #### 运维多个数据中心
我们先前讨论了跨数据中心复制作为多主复制的用例(请参阅“[多主复制](#多主复制)”)。无主复制也适用于多数据中心操作,因为它旨在容忍冲突的并发写入,网络中断和延迟尖峰。 我们先前讨论了跨数据中心复制作为多主复制的用例(请参阅“[多主复制](#多主复制)”)。无主复制也适用于多数据中心操作,因为它旨在容忍冲突的并发写入,网络中断和延迟尖峰。
Cassandra和Voldemort在正常的无主模型中实现了他们的多数据中心支持副本的数量n包括所有数据中心的节点在配置中您可以指定每个数据中心中您想拥有的副本的数量。无论数据中心如何每个来自客户端的写入都会发送到所有副本但客户端通常只等待来自其本地数据中心内的法定节点的确认从而不会受到跨数据中心链路延迟和中断的影响。对其他数据中心的高延迟写入通常被配置为异步发生尽管配置有一定的灵活性【50,51】。 Cassandra和Voldemort在正常的无主模型中实现了他们的多数据中心支持副本的数量n包括所有数据中心的节点在配置中您可以指定每个数据中心中您想拥有的副本的数量。无论数据中心如何每个来自客户端的写入都会发送到所有副本但客户端通常只等待来自其本地数据中心内的法定节点的确认从而不会受到跨数据中心链路延迟和中断的影响。对其他数据中心的高延迟写入通常被配置为异步发生尽管配置有一定的灵活性【50,51】。
Riak将客户端和数据库节点之间的所有通信保持在一个数据中心本地因此n描述了一个数据中心内的副本数量。数据库集群之间的跨数据中心复制在后台异步发生其风格类似于多领导者复制【52】。 Riak将客户端和数据库节点之间的所有通信保持在一个数据中心本地因此n描述了一个数据中心内的副本数量。数据库集群之间的跨数据中心复制在后台异步发生其风格类似于多领导者复制【52】。
### 检测并发写入 ### 检测并发写入
Dynamo风格的数据库允许多个客户端同时写入相同的Key这意味着即使使用严格的法定人数也会发生冲突。这种情况与多领导者复制相似请参阅“[处理写入冲突](#处理写入冲突)”但在Dynamo样式的数据库中在**读修复**或**提示移交**期间也可能会产生冲突。 Dynamo风格的数据库允许多个客户端同时写入相同的Key这意味着即使使用严格的法定人数也会发生冲突。这种情况与多领导者复制相似请参阅“[处理写入冲突](#处理写入冲突)”但在Dynamo样式的数据库中在**读修复**或**提示移交**期间也可能会产生冲突。
问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,[图5-12](img/fig5-12.png)显示了两个客户机A和B同时写入三节点数据存储区中的键X 问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,[图5-12](img/fig5-12.png)显示了两个客户机A和B同时写入三节点数据存储区中的键X
* 节点 1 接收来自 A 的写入,但由于暂时中断,未接收到来自 B 的写入。 * 节点 1 接收来自 A 的写入,但由于暂时中断,未接收到来自 B 的写入。
* 节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入。 * 节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入。
@ -618,25 +618,25 @@
**图5-12 并发写入Dynamo风格的数据存储没有明确定义的顺序。** **图5-12 并发写入Dynamo风格的数据存储没有明确定义的顺序。**
如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如[图5-12](img/fig5-12.png)中的最终获取请求所示节点2认为 X 的最终值是 B而其他节点认为值是 A 。 如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如[图5-12](img/fig5-12.png)中的最终获取请求所示节点2认为 X 的最终值是 B而其他节点认为值是 A 。
为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你(应用程序开发人员)需要知道很多有关数据库冲突处理的内部信息。 为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你(应用程序开发人员)需要知道很多有关数据库冲突处理的内部信息。
在“[处理写入冲突](#处理写入冲突)”一节中已经简要介绍了一些解决冲突的技术。在总结本章之前,让我们来更详细地探讨这个问题。 在“[处理写入冲突](#处理写入冲突)”一节中已经简要介绍了一些解决冲突的技术。在总结本章之前,让我们来更详细地探讨这个问题。
#### 最后写入胜利(丢弃并发写入) #### 最后写入胜利(丢弃并发写入)
实现最终融合的一种方法是声明每个副本只需要存储最 **“最近”** 的值,并允许 **“更旧”** 的值被覆盖和抛弃。然后,只要我们有一种明确的方式来确定哪个写是“最近的”,并且每个写入最终都被复制到每个副本,那么复制最终会收敛到相同的值。 实现最终融合的一种方法是声明每个副本只需要存储最 **“最近”** 的值,并允许 **“更旧”** 的值被覆盖和抛弃。然后,只要我们有一种明确的方式来确定哪个写是“最近的”,并且每个写入最终都被复制到每个副本,那么复制最终会收敛到相同的值。
正如 **“最近”** 的引号所表明的,这个想法其实颇具误导性。在[图5-12](img/fig5-12.png)的例子中,当客户端向数据库节点发送写入请求时,客户端都不知道另一个客户端,因此不清楚哪一个先发生了。事实上,说“发生”是没有意义的:我们说写入是 **并发concurrent** 的,所以它们的顺序是不确定的。 正如 **“最近”** 的引号所表明的,这个想法其实颇具误导性。在[图5-12](img/fig5-12.png)的例子中,当客户端向数据库节点发送写入请求时,客户端都不知道另一个客户端,因此不清楚哪一个先发生了。事实上,说“发生”是没有意义的:我们说写入是 **并发concurrent** 的,所以它们的顺序是不确定的。
即使写入没有自然的排序,我们也可以强制任意排序。例如,可以为每个写入附加一个时间戳,挑选最 **“最近”** 的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为 **最后写入胜利LWW, last write wins**是Cassandra 【53】唯一支持的冲突解决方法也是Riak 【35】中的一个可选特征。 即使写入没有自然的排序,我们也可以强制任意排序。例如,可以为每个写入附加一个时间戳,挑选最 **“最近”** 的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为 **最后写入胜利LWW, last write wins**是Cassandra 【53】唯一支持的冲突解决方法也是Riak 【35】中的一个可选特征。
LWW实现了最终收敛的目标但以**持久性**为代价如果同一个Key有多个并发写入即使它们报告给客户端的都是成功因为它们被写入 w 个副本也只有一个写入将存活而其他写入将被静默丢弃。此外LWW甚至可能会删除不是并发的写入我们将在的“[有序事件的时间戳](ch8.md#有序事件的时间戳)”中讨论。 LWW实现了最终收敛的目标但以**持久性**为代价如果同一个Key有多个并发写入即使它们报告给客户端的都是成功因为它们被写入 w 个副本也只有一个写入将存活而其他写入将被静默丢弃。此外LWW甚至可能会删除不是并发的写入我们将在的“[有序事件的时间戳](ch8.md#有序事件的时间戳)”中讨论。
有一些情况如缓存其中丢失的写入可能是可以接受的。如果丢失数据不可接受LWW是解决冲突的一个很烂的选择。 有一些情况如缓存其中丢失的写入可能是可以接受的。如果丢失数据不可接受LWW是解决冲突的一个很烂的选择。
与LWW一起使用数据库的唯一安全方法是确保一个键只写入一次然后视为不可变从而避免对同一个键进行并发更新。例如Cassandra推荐使用的方法是使用UUID作为键从而为每个写操作提供一个唯一的键【53】。 与LWW一起使用数据库的唯一安全方法是确保一个键只写入一次然后视为不可变从而避免对同一个键进行并发更新。例如Cassandra推荐使用的方法是使用UUID作为键从而为每个写操作提供一个唯一的键【53】。
#### “此前发生”的关系和并发 #### “此前发生”的关系和并发
@ -647,7 +647,7 @@
如果操作B了解操作A或者依赖于A或者以某种方式构建于操作A之上则操作A在另一个操作B之前发生。在另一个操作之前是否发生一个操作是定义什么并发的关键。事实上我们可以简单地说如果两个操作都不在另一个之前发生那么两个操作是并发的两个操作都不知道另一个【54】。 如果操作B了解操作A或者依赖于A或者以某种方式构建于操作A之上则操作A在另一个操作B之前发生。在另一个操作之前是否发生一个操作是定义什么并发的关键。事实上我们可以简单地说如果两个操作都不在另一个之前发生那么两个操作是并发的两个操作都不知道另一个【54】。
因此只要有两个操作A和B就有三种可能性A在B之前发生或者B在A之前发生或者A和B并发。我们需要的是一个算法来告诉我们两个操作是否是并发的。如果一个操作发生在另一个操作之前则后面的操作应该覆盖较早的操作但是如果这些操作是并发的则存在需要解决的冲突。 因此只要有两个操作A和B就有三种可能性A在B之前发生或者B在A之前发生或者A和B并发。我们需要的是一个算法来告诉我们两个操作是否是并发的。如果一个操作发生在另一个操作之前则后面的操作应该覆盖较早的操作但是如果这些操作是并发的则存在需要解决的冲突。
@ -663,7 +663,7 @@
#### 捕获"此前发生"关系 #### 捕获"此前发生"关系
来看一个算法,它确定两个操作是否为并发的,还是一个在另一个之前。为了简单起见,我们从一个只有一个副本的数据库开始。一旦我们已经制定了如何在单个副本上完成这项工作,我们可以将该方法推广到具有多个副本的无领导者数据库。 来看一个算法,它确定两个操作是否为并发的,还是一个在另一个之前。为了简单起见,我们从一个只有一个副本的数据库开始。一旦我们已经制定了如何在单个副本上完成这项工作,我们可以将该方法推广到具有多个副本的无领导者数据库。
[图5-13](img/fig5-13.png)显示了两个客户端同时向同一购物车添加项目。 (如果这样的例子让你觉得太麻烦了,那么可以想象,两个空中交通管制员同时把飞机添加到他们正在跟踪的区域)最初,购物车是空的。在它们之间,客户端向数据库发出五次写入: [图5-13](img/fig5-13.png)显示了两个客户端同时向同一购物车添加项目。 (如果这样的例子让你觉得太麻烦了,那么可以想象,两个空中交通管制员同时把飞机添加到他们正在跟踪的区域)最初,购物车是空的。在它们之间,客户端向数据库发出五次写入:
@ -677,13 +677,13 @@
**图5-13 捕获两个客户端之间的因果关系,同时编辑购物车。** **图5-13 捕获两个客户端之间的因果关系,同时编辑购物车。**
[图5-13](img/fig5-13.png)中的操作之间的数据流如[图5-14](img/fig5-14.png)所示。 箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。 在这个例子中,客户端永远不会完全掌握服务器上的数据,因为总是有另一个操作同时进行。 但是,旧版本的值最终会被覆盖,并且不会丢失任何写入。 [图5-13](img/fig5-13.png)中的操作之间的数据流如[图5-14](img/fig5-14.png)所示。 箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。 在这个例子中,客户端永远不会完全掌握服务器上的数据,因为总是有另一个操作同时进行。 但是,旧版本的值最终会被覆盖,并且不会丢失任何写入。
![](img/fig5-14.png) ![](img/fig5-14.png)
**图5-14 图5-13中的因果依赖关系图。** **图5-14 图5-13中的因果依赖关系图。**
请注意,服务器可以通过查看版本号来确定两个操作是否是并发的——它不需要解释该值本身(因此该值可以是任何数据结构)。该算法的工作原理如下: 请注意,服务器可以通过查看版本号来确定两个操作是否是并发的——它不需要解释该值本身(因此该值可以是任何数据结构)。该算法的工作原理如下:
* 服务器为每个键保留一个版本号,每次写入键时都增加版本号,并将新版本号与写入的值一起存储。 * 服务器为每个键保留一个版本号,每次写入键时都增加版本号,并将新版本号与写入的值一起存储。
* 当客户端读取键时,服务器将返回所有未覆盖的值以及最新的版本号。客户端在写入前必须读取。 * 当客户端读取键时,服务器将返回所有未覆盖的值以及最新的版本号。客户端在写入前必须读取。
@ -694,27 +694,27 @@
#### 合并同时写入的值 #### 合并同时写入的值
这种算法可以确保没有数据被无声地丢弃,但不幸的是,客户端需要做一些额外的工作:如果多个操作并发发生,则客户端必须通过合并并发写入的值来擦屁股。 Riak称这些并发值**兄弟siblings**。 这种算法可以确保没有数据被无声地丢弃,但不幸的是,客户端需要做一些额外的工作:如果多个操作并发发生,则客户端必须通过合并并发写入的值来擦屁股。 Riak称这些并发值**兄弟siblings**。
合并兄弟值,本质上是与多领导者复制中的冲突解决相同的问题,我们先前讨论过(请参阅“[处理写入冲突](#处理写入冲突)”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中做更聪明的事情。 合并兄弟值,本质上是与多领导者复制中的冲突解决相同的问题,我们先前讨论过(请参阅“[处理写入冲突](#处理写入冲突)”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中做更聪明的事情。
以购物车为例,一种合理的合并兄弟方法就是集合求并集。在[图5-14](img/fig5-14.png)中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋同时出现在两个兄弟里,即使他们每个只被写过一次。合并的值可以是[牛奶,面粉,鸡蛋,培根,火腿],没有重复。 以购物车为例,一种合理的合并兄弟方法就是集合求并集。在[图5-14](img/fig5-14.png)中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋同时出现在两个兄弟里,即使他们每个只被写过一次。合并的值可以是[牛奶,面粉,鸡蛋,培根,火腿],没有重复。
然而,如果你想让人们也可以从他们的购物车中**删除**东西而不是仅仅添加东西那么把兄弟求并集可能不会产生正确的结果如果你合并了两个兄弟购物车并且只在其中一个兄弟值里删掉了它那么被删除的项目会重新出现在并集终值中【37】。为了防止这个问题一个项目在删除时不能简单地从数据库中删除相反系统必须留下一个具有合适版本号的标记以指示合并兄弟时该项目已被删除。这种删除标记被称为**墓碑tombstone**。 (我们之前在“[哈希索引”](ch3.md#哈希索引)中的日志压缩的上下文中看到了墓碑。) 然而,如果你想让人们也可以从他们的购物车中**删除**东西而不是仅仅添加东西那么把兄弟求并集可能不会产生正确的结果如果你合并了两个兄弟购物车并且只在其中一个兄弟值里删掉了它那么被删除的项目会重新出现在并集终值中【37】。为了防止这个问题一个项目在删除时不能简单地从数据库中删除相反系统必须留下一个具有合适版本号的标记以指示合并兄弟时该项目已被删除。这种删除标记被称为**墓碑tombstone**。 (我们之前在“[哈希索引”](ch3.md#哈希索引)中的日志压缩的上下文中看到了墓碑。)
因为在应用程序代码中合并兄弟是复杂且容易出错的,所以有一些数据结构被设计出来用于自动执行这种合并,如“[自动冲突解决](#自动冲突解决)”中讨论的。例如Riak的数据类型支持使用称为CRDT的数据结构家族【38,39,55】可以以合理的方式自动合并兄弟包括保留删除。 因为在应用程序代码中合并兄弟是复杂且容易出错的,所以有一些数据结构被设计出来用于自动执行这种合并,如“[自动冲突解决](#自动冲突解决)”中讨论的。例如Riak的数据类型支持使用称为CRDT的数据结构家族【38,39,55】可以以合理的方式自动合并兄弟包括保留删除。
#### 版本向量 #### 版本向量
[图5-13](img/fig5-13.png)中的示例只使用一个副本。当有多个副本但没有领导者时,算法如何修改? [图5-13](img/fig5-13.png)中的示例只使用一个副本。当有多个副本但没有领导者时,算法如何修改?
[图5-13](img/fig5-13.png)使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键使用版本号之外,还需要在**每个副本**中使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值作为兄弟。 [图5-13](img/fig5-13.png)使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键使用版本号之外,还需要在**每个副本**中使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值作为兄弟。
所有副本的版本号集合称为**版本向量version vector**【56】。这个想法的一些变体正在被使用但最有趣的可能是在Riak 2.0 【58,59】中使用的**分散版本矢量dotted version vector**【57】。我们不会深入细节但是它的工作方式与我们在购物车示例中看到的非常相似。 所有副本的版本号集合称为**版本向量version vector**【56】。这个想法的一些变体正在被使用但最有趣的可能是在Riak 2.0 【58,59】中使用的**分散版本矢量dotted version vector**【57】。我们不会深入细节但是它的工作方式与我们在购物车示例中看到的非常相似。
与[图5-13](img/fig5-13.png)中的版本号一样当读取值时版本向量会从数据库副本发送到客户端并且随后写入值时需要将其发送回数据库。Riak将版本向量编码为一个字符串它称为**因果上下文causal context**)。版本向量允许数据库区分覆盖写入和并发写入。 与[图5-13](img/fig5-13.png)中的版本号一样当读取值时版本向量会从数据库副本发送到客户端并且随后写入值时需要将其发送回数据库。Riak将版本向量编码为一个字符串它称为**因果上下文causal context**)。版本向量允许数据库区分覆盖写入和并发写入。
另外,就像在单个副本的例子中,应用程序可能需要合并兄弟。版本向量结构确保从一个副本读取并随后写回到另一个副本是安全的。这样做可能会创建兄弟,但只要兄弟姐妹合并正确,就不会丢失数据。 另外,就像在单个副本的例子中,应用程序可能需要合并兄弟。版本向量结构确保从一个副本读取并随后写回到另一个副本是安全的。这样做可能会创建兄弟,但只要兄弟姐妹合并正确,就不会丢失数据。
> #### 版本向量和向量时钟 > #### 版本向量和向量时钟
> >
@ -727,47 +727,47 @@
***高可用性*** ***高可用性***
即使在一台机器(或多台机器,或整个数据中心)停机的情况下也能保持系统正常运行 即使在一台机器(或多台机器,或整个数据中心)停机的情况下也能保持系统正常运行
***断开连接的操作*** ***断开连接的操作***
允许应用程序在网络中断时继续工作 允许应用程序在网络中断时继续工作
***延迟*** ***延迟***
将数据放置在距离用户较近的地方,以便用户能够更快地与其交互 将数据放置在距离用户较近的地方,以便用户能够更快地与其交互
***可伸缩性*** ***可伸缩性***
通过在副本上读,能够处理比单机更大的读取量 通过在副本上读,能够处理比单机更大的读取量
尽管是一个简单的目标 - 在几台机器上保留相同数据的副本,但复制却是一个非常棘手的问题。它需要仔细考虑并发和所有可能出错的事情,并处理这些故障的后果。至少,我们需要处理不可用的节点和网络中断(这还不包括更隐蔽的故障,例如由于软件错误导致的静默数据损坏)。 尽管是一个简单的目标 - 在几台机器上保留相同数据的副本,但复制却是一个非常棘手的问题。它需要仔细考虑并发和所有可能出错的事情,并处理这些故障的后果。至少,我们需要处理不可用的节点和网络中断(这还不包括更隐蔽的故障,例如由于软件错误导致的静默数据损坏)。
我们讨论了复制的三种主要方法: 我们讨论了复制的三种主要方法:
***单主复制*** ***单主复制***
客户端将所有写入操作发送到单个节点(领导者),该节点将数据更改事件流发送到其他副本(追随者)。读取可以在任何副本上执行,但从追随者读取可能是陈旧的。 客户端将所有写入操作发送到单个节点(领导者),该节点将数据更改事件流发送到其他副本(追随者)。读取可以在任何副本上执行,但从追随者读取可能是陈旧的。
***多主复制*** ***多主复制***
客户端发送每个写入到几个领导节点之一,其中任何一个都可以接受写入。领导者将数据更改事件流发送给彼此以及任何跟随者节点。 客户端发送每个写入到几个领导节点之一,其中任何一个都可以接受写入。领导者将数据更改事件流发送给彼此以及任何跟随者节点。
***无主复制*** ***无主复制***
客户端发送每个写入到几个节点,并从多个节点并行读取,以检测和纠正具有陈旧数据的节点。 客户端发送每个写入到几个节点,并从多个节点并行读取,以检测和纠正具有陈旧数据的节点。
每种方法都有优点和缺点。单主复制是非常流行的,因为它很容易理解,不需要担心冲突解决。在出现故障节点,网络中断和延迟峰值的情况下,多领导者和无领导者复制可以更加稳健,但以更难以推理并仅提供非常弱的一致性保证为代价。 每种方法都有优点和缺点。单主复制是非常流行的,因为它很容易理解,不需要担心冲突解决。在出现故障节点,网络中断和延迟峰值的情况下,多领导者和无领导者复制可以更加稳健,但以更难以推理并仅提供非常弱的一致性保证为代价。
复制可以是同步的,也可以是异步的,这在发生故障时对系统行为有深远的影响。尽管在系统运行平稳时异步复制速度很快,但是要弄清楚在复制滞后增加和服务器故障时会发生什么,这一点很重要。如果一个领导者失败了,并且你提升了一个异步更新的追随者成为新的领导者,那么最近提交的数据可能会丢失。 复制可以是同步的,也可以是异步的,这在发生故障时对系统行为有深远的影响。尽管在系统运行平稳时异步复制速度很快,但是要弄清楚在复制滞后增加和服务器故障时会发生什么,这一点很重要。如果一个领导者失败了,并且你提升了一个异步更新的追随者成为新的领导者,那么最近提交的数据可能会丢失。
我们研究了一些可能由复制滞后引起的奇怪效应,我们也讨论了一些有助于决定应用程序在复制滞后时的行为的一致性模型: 我们研究了一些可能由复制滞后引起的奇怪效应,我们也讨论了一些有助于决定应用程序在复制滞后时的行为的一致性模型:
***写后读*** ***写后读***
用户应该总是看到自己提交的数据。 用户应该总是看到自己提交的数据。
***单调读*** ***单调读***
@ -775,13 +775,13 @@
***一致前缀读*** ***一致前缀读***
用户应该看到数据处于一种具有因果意义的状态:例如,按正确的顺序看到一个问题和对应的回答。 用户应该看到数据处于一种具有因果意义的状态:例如,按正确的顺序看到一个问题和对应的回答。
最后,我们讨论了多领导者和无领导者复制方法所固有的并发问题:因为他们允许多个写入并发发生,这可能会导致冲突。我们研究了一个数据库可能使用的算法来确定一个操作是否发生在另一个操作之前,或者它们是否同时发生。我们还谈到了通过合并并发更新来解决冲突的方法。 最后,我们讨论了多领导者和无领导者复制方法所固有的并发问题:因为他们允许多个写入并发发生,这可能会导致冲突。我们研究了一个数据库可能使用的算法来确定一个操作是否发生在另一个操作之前,或者它们是否同时发生。我们还谈到了通过合并并发更新来解决冲突的方法。
在下一章中,我们将继续研究分布在多个机器上的数据,通过复制的同僚:将大数据集分割成分区。 在下一章中,我们将继续研究分布在多个机器上的数据,通过复制的同僚:将大数据集分割成分区。

182
ch6.md
View File

@ -11,7 +11,7 @@
[TOC] [TOC]
在[第五章](ch5.md)中,我们讨论了复制——即数据在不同节点上的副本,对于非常大的数据集,或非常高的吞吐量,仅仅进行复制是不够的:我们需要将数据进行**分区partitions**,也称为**分片sharding**[^i]。 在[第五章](ch5.md)中,我们讨论了复制——即数据在不同节点上的副本,对于非常大的数据集,或非常高的吞吐量,仅仅进行复制是不够的:我们需要将数据进行**分区partitions**,也称为**分片sharding**[^i]。
[^i]: 正如本章所讨论的,分区是一种有意将大型数据库分解成小型数据库的方式。它与 **网络分区network partitions, netsplits** 无关,这是节点之间网络故障的一种。我们将在[第八章](ch8.md)讨论这些错误。 [^i]: 正如本章所讨论的,分区是一种有意将大型数据库分解成小型数据库的方式。它与 **网络分区network partitions, netsplits** 无关,这是节点之间网络故障的一种。我们将在[第八章](ch8.md)讨论这些错误。
@ -20,21 +20,21 @@
> 上文中的**分区(partition)**在MongoDBElasticsearch和Solr Cloud中被称为**分片(shard)**在HBase中称之为**区域(Region)**Bigtable中则是 **表块tablet**Cassandra和Riak中是**虚节点vnode)**Couchbase中叫做**虚桶(vBucket)**。但是**分区(partitioning)** 是最约定俗成的叫法。 > 上文中的**分区(partition)**在MongoDBElasticsearch和Solr Cloud中被称为**分片(shard)**在HBase中称之为**区域(Region)**Bigtable中则是 **表块tablet**Cassandra和Riak中是**虚节点vnode)**Couchbase中叫做**虚桶(vBucket)**。但是**分区(partitioning)** 是最约定俗成的叫法。
> >
通常情况下,每条数据(每条记录,每行或每个文档)属于且仅属于一个分区。有很多方法可以实现这一点,本章将进行深入讨论。实际上,每个分区都是自己的小型数据库,尽管数据库可能支持同时进行多个分区的操作。 通常情况下,每条数据(每条记录,每行或每个文档)属于且仅属于一个分区。有很多方法可以实现这一点,本章将进行深入讨论。实际上,每个分区都是自己的小型数据库,尽管数据库可能支持同时进行多个分区的操作。
分区主要是为了**可伸缩性**。不同的分区可以放在不共享集群中的不同节点上(请参阅[第二部分](part-ii.md)关于[无共享架构](part-ii.md#无共享架构)的定义)。因此,大数据集可以分布在多个磁盘上,并且查询负载可以分布在多个处理器上。 分区主要是为了**可伸缩性**。不同的分区可以放在不共享集群中的不同节点上(请参阅[第二部分](part-ii.md)关于[无共享架构](part-ii.md#无共享架构)的定义)。因此,大数据集可以分布在多个磁盘上,并且查询负载可以分布在多个处理器上。
对于在单个分区上运行的查询,每个节点可以独立执行对自己的查询,因此可以通过添加更多的节点来扩大查询吞吐量。大型,复杂的查询可能会跨越多个节点并行处理,尽管这也带来了新的困难。 对于在单个分区上运行的查询,每个节点可以独立执行对自己的查询,因此可以通过添加更多的节点来扩大查询吞吐量。大型,复杂的查询可能会跨越多个节点并行处理,尽管这也带来了新的困难。
分区数据库在20世纪80年代由Teradata和NonStop SQL【1】等产品率先推出最近因为NoSQL数据库和基于Hadoop的数据仓库重新被关注。有些系统是为事务性工作设计的有些系统则用于分析请参阅“[事务处理还是分析](ch3.md#事务处理还是分析)”):这种差异会影响系统的运作方式,但是分区的基本原理均适用于这两种工作方式。 分区数据库在20世纪80年代由Teradata和NonStop SQL【1】等产品率先推出最近因为NoSQL数据库和基于Hadoop的数据仓库重新被关注。有些系统是为事务性工作设计的有些系统则用于分析请参阅“[事务处理还是分析](ch3.md#事务处理还是分析)”):这种差异会影响系统的运作方式,但是分区的基本原理均适用于这两种工作方式。
在本章中,我们将首先介绍分割大型数据集的不同方法,并观察索引如何与分区配合。然后我们将讨论[分区再平衡rebalancing](#分区再平衡),如果想要添加或删除集群中的节点,则必须进行再平衡。最后,我们将概述数据库如何将请求路由到正确的分区并执行查询。 在本章中,我们将首先介绍分割大型数据集的不同方法,并观察索引如何与分区配合。然后我们将讨论[分区再平衡rebalancing](#分区再平衡),如果想要添加或删除集群中的节点,则必须进行再平衡。最后,我们将概述数据库如何将请求路由到正确的分区并执行查询。
## 分区与复制 ## 分区与复制
分区通常与复制结合使用,使得每个分区的副本存储在多个节点上。 这意味着,即使每条记录属于一个分区,它仍然可以存储在多个不同的节点上以获得容错能力。 分区通常与复制结合使用,使得每个分区的副本存储在多个节点上。 这意味着,即使每条记录属于一个分区,它仍然可以存储在多个不同的节点上以获得容错能力。
一个节点可能存储多个分区。 如果使用主从复制模型,则分区和复制的组合如[图6-1](img/fig6-1.png)所示。 每个分区领导者(主)被分配给一个节点,追随者(从)被分配给其他节点。 每个节点可能是某些分区的领导者,同时是其他分区的追随者。 一个节点可能存储多个分区。 如果使用主从复制模型,则分区和复制的组合如[图6-1](img/fig6-1.png)所示。 每个分区领导者(主)被分配给一个节点,追随者(从)被分配给其他节点。 每个节点可能是某些分区的领导者,同时是其他分区的追随者。
我们在[第五章](ch5.md)讨论的关于数据库复制的所有内容同样适用于分区的复制。 大多数情况下,分区方案的选择与复制方案的选择是独立的,为简单起见,本章中将忽略复制。 我们在[第五章](ch5.md)讨论的关于数据库复制的所有内容同样适用于分区的复制。 大多数情况下,分区方案的选择与复制方案的选择是独立的,为简单起见,本章中将忽略复制。
![](img/fig6-1.png) ![](img/fig6-1.png)
@ -43,49 +43,49 @@
## 键值数据的分区 ## 键值数据的分区
假设你有大量数据并且想要分区,如何决定在哪些节点上存储哪些记录呢? 假设你有大量数据并且想要分区,如何决定在哪些节点上存储哪些记录呢?
分区目标是将数据和查询负载均匀分布在各个节点上。如果每个节点公平分享数据和负载那么理论上10个节点应该能够处理10倍的数据量和10倍的单个节点的读写吞吐量暂时忽略复制 分区目标是将数据和查询负载均匀分布在各个节点上。如果每个节点公平分享数据和负载那么理论上10个节点应该能够处理10倍的数据量和10倍的单个节点的读写吞吐量暂时忽略复制
如果分区是不公平的,一些分区比其他分区有更多的数据或查询,我们称之为**偏斜skew**。数据偏斜的存在使分区效率下降很多。在极端的情况下所有的负载可能压在一个分区上其余9个节点空闲的瓶颈落在这一个繁忙的节点上。不均衡导致的高负载的分区被称为**热点hot spot**。 如果分区是不公平的,一些分区比其他分区有更多的数据或查询,我们称之为**偏斜skew**。数据偏斜的存在使分区效率下降很多。在极端的情况下所有的负载可能压在一个分区上其余9个节点空闲的瓶颈落在这一个繁忙的节点上。不均衡导致的高负载的分区被称为**热点hot spot**。
避免热点最简单的方法是将记录随机分配给节点。这将在所有节点上平均分配数据,但是它有一个很大的缺点:当你试图读取一个特定的值时,你无法知道它在哪个节点上,所以你必须并行地查询所有的节点。 避免热点最简单的方法是将记录随机分配给节点。这将在所有节点上平均分配数据,但是它有一个很大的缺点:当你试图读取一个特定的值时,你无法知道它在哪个节点上,所以你必须并行地查询所有的节点。
我们可以做得更好。现在假设您有一个简单的键值数据模型,其中您总是通过其主键访问记录。例如,在一本老式的纸质百科全书中,你可以通过标题来查找一个条目;由于所有条目按字母顺序排序,因此您可以快速找到您要查找的条目。 我们可以做得更好。现在假设您有一个简单的键值数据模型,其中您总是通过其主键访问记录。例如,在一本老式的纸质百科全书中,你可以通过标题来查找一个条目;由于所有条目按字母顺序排序,因此您可以快速找到您要查找的条目。
### 根据键的范围分区 ### 根据键的范围分区
一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸质百科全书的卷([图6-2](img/fig6-2.png))。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果您还知道分区所在的节点,那么可以直接向相应的节点发出请求(对于百科全书而言,就像从书架上选取正确的书籍)。 一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸质百科全书的卷([图6-2](img/fig6-2.png))。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果您还知道分区所在的节点,那么可以直接向相应的节点发出请求(对于百科全书而言,就像从书架上选取正确的书籍)。
![](img/fig6-2.png) ![](img/fig6-2.png)
**图6-2 印刷版百科全书按照关键字范围进行分区** **图6-2 印刷版百科全书按照关键字范围进行分区**
键的范围不一定均匀分布,因为数据也很可能不均匀分布。例如在[图6-2](img/fig6-2.png)中第1卷包含以A和B开头的单词但第12卷则包含以TUVXY和Z开头的单词。只是简单的规定每个卷包含两个字母会导致一些卷比其他卷大。为了均匀分配数据分区边界需要依据数据调整。 键的范围不一定均匀分布,因为数据也很可能不均匀分布。例如在[图6-2](img/fig6-2.png)中第1卷包含以A和B开头的单词但第12卷则包含以TUVXY和Z开头的单词。只是简单的规定每个卷包含两个字母会导致一些卷比其他卷大。为了均匀分配数据分区边界需要依据数据调整。
分区边界可以由管理员手动选择,也可以由数据库自动选择(我们会在“[分区再平衡](#分区再平衡)”中更详细地讨论分区边界的选择)。 Bigtable使用了这种分区策略以及其开源等价物HBase 【2, 3】RethinkDB和2.4版本之前的MongoDB 【4】。 分区边界可以由管理员手动选择,也可以由数据库自动选择(我们会在“[分区再平衡](#分区再平衡)”中更详细地讨论分区边界的选择)。 Bigtable使用了这种分区策略以及其开源等价物HBase 【2, 3】RethinkDB和2.4版本之前的MongoDB 【4】。
在每个分区中,我们可以按照一定的顺序保存键(请参阅“[SSTables和LSM树](ch3.md#SSTables和LSM树)”)。好处是进行范围扫描非常简单,您可以将键作为联合索引来处理,以便在一次查询中获取多个相关记录(请参阅“[多列索引](ch3.md#多列索引)”)。例如,假设我们有一个程序来存储传感器网络的数据,其中主键是测量的时间戳(年月日时分秒)。范围扫描在这种情况下非常有用,因为我们可以轻松获取某个月份的所有数据。 在每个分区中,我们可以按照一定的顺序保存键(请参阅“[SSTables和LSM树](ch3.md#SSTables和LSM树)”)。好处是进行范围扫描非常简单,您可以将键作为联合索引来处理,以便在一次查询中获取多个相关记录(请参阅“[多列索引](ch3.md#多列索引)”)。例如,假设我们有一个程序来存储传感器网络的数据,其中主键是测量的时间戳(年月日时分秒)。范围扫描在这种情况下非常有用,因为我们可以轻松获取某个月份的所有数据。
然而Key Range分区的缺点是某些特定的访问模式会导致热点。 如果主键是时间戳,则分区对应于时间范围,例如,给每天分配一个分区。 不幸的是由于我们在测量发生时将数据从传感器写入数据库因此所有写入操作都会转到同一个分区即今天的分区这样分区可能会因写入而过载而其他分区则处于空闲状态【5】。 然而Key Range分区的缺点是某些特定的访问模式会导致热点。 如果主键是时间戳,则分区对应于时间范围,例如,给每天分配一个分区。 不幸的是由于我们在测量发生时将数据从传感器写入数据库因此所有写入操作都会转到同一个分区即今天的分区这样分区可能会因写入而过载而其他分区则处于空闲状态【5】。
为了避免传感器数据库中的这个问题,需要使用除了时间戳以外的其他东西作为主键的第一个部分。 例如,可以在每个时间戳前添加传感器名称,这样会首先按传感器名称,然后按时间进行分区。 假设有多个传感器同时运行,写入负载将最终均匀分布在不同分区上。 现在,当想要在一个时间范围内获取多个传感器的值时,您需要为每个传感器名称执行一个单独的范围查询。 为了避免传感器数据库中的这个问题,需要使用除了时间戳以外的其他东西作为主键的第一个部分。 例如,可以在每个时间戳前添加传感器名称,这样会首先按传感器名称,然后按时间进行分区。 假设有多个传感器同时运行,写入负载将最终均匀分布在不同分区上。 现在,当想要在一个时间范围内获取多个传感器的值时,您需要为每个传感器名称执行一个单独的范围查询。
### 根据键的散列分区 ### 根据键的散列分区
由于偏斜和热点的风险,许多分布式数据存储使用散列函数来确定给定键的分区。 由于偏斜和热点的风险,许多分布式数据存储使用散列函数来确定给定键的分区。
一个好的散列函数可以将偏斜的数据均匀分布。假设你有一个32位散列函数,无论何时给定一个新的字符串输入它将返回一个0到$2^{32}$ -1之间的“随机”数。即使输入的字符串非常相似它们的散列也会均匀分布在这个数字范围内。 一个好的散列函数可以将偏斜的数据均匀分布。假设你有一个32位散列函数,无论何时给定一个新的字符串输入它将返回一个0到$2^{32}$ -1之间的“随机”数。即使输入的字符串非常相似它们的散列也会均匀分布在这个数字范围内。
出于分区的目的散列函数不需要多么强壮的加密算法例如Cassandra和MongoDB使用MD5Voldemort使用Fowler-Noll-Vo函数。许多编程语言都有内置的简单哈希函数它们用于哈希表但是它们可能不适合分区例如在Java的`Object.hashCode()`和Ruby的`Object#hash`同一个键可能在不同的进程中有不同的哈希值【6】。 出于分区的目的散列函数不需要多么强壮的加密算法例如Cassandra和MongoDB使用MD5Voldemort使用Fowler-Noll-Vo函数。许多编程语言都有内置的简单哈希函数它们用于哈希表但是它们可能不适合分区例如在Java的`Object.hashCode()`和Ruby的`Object#hash`同一个键可能在不同的进程中有不同的哈希值【6】。
一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如[图6-3](img/fig6-3.png)所示。 一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如[图6-3](img/fig6-3.png)所示。
![](img/fig6-3.png) ![](img/fig6-3.png)
**图6-3 按哈希键分区** **图6-3 按哈希键分区**
这种技术擅长在分区之间公平地分配键。分区边界可以是均匀间隔的,也可以是伪随机选择的(在这种情况下,该技术有时也被称为**一致性哈希consistent hashing**)。 这种技术擅长在分区之间公平地分配键。分区边界可以是均匀间隔的,也可以是伪随机选择的(在这种情况下,该技术有时也被称为**一致性哈希consistent hashing**)。
> #### 一致性哈希 > #### 一致性哈希
> >
@ -93,41 +93,41 @@
> >
> 正如我们将在“[分区再平衡](#分区再平衡)”中所看到的,这种特殊的方法对于数据库实际上并不是很好,所以在实际中很少使用(某些数据库的文档仍然会使用一致性哈希的说法,但是它往往是不准确的)。 因为有可能产生混淆,所以最好避免使用一致性哈希这个术语,而只是把它称为**散列分区hash partitioning**。 > 正如我们将在“[分区再平衡](#分区再平衡)”中所看到的,这种特殊的方法对于数据库实际上并不是很好,所以在实际中很少使用(某些数据库的文档仍然会使用一致性哈希的说法,但是它往往是不准确的)。 因为有可能产生混淆,所以最好避免使用一致性哈希这个术语,而只是把它称为**散列分区hash partitioning**。
不幸的是通过使用键散列进行分区我们失去了键范围分区的一个很好的属性高效执行范围查询的能力。曾经相邻的键现在分散在所有分区中所以它们之间的顺序就丢失了。在MongoDB中如果您使用了基于散列的分区模式则任何范围查询都必须发送到所有分区【4】。Riak 【9】Couchbase 【10】或Voldemort不支持主键上的范围查询。 不幸的是通过使用键散列进行分区我们失去了键范围分区的一个很好的属性高效执行范围查询的能力。曾经相邻的键现在分散在所有分区中所以它们之间的顺序就丢失了。在MongoDB中如果您使用了基于散列的分区模式则任何范围查询都必须发送到所有分区【4】。Riak 【9】Couchbase 【10】或Voldemort不支持主键上的范围查询。
Cassandra采取了折衷的策略【11, 12, 13】。 Cassandra中的表可以使用由多个列组成的复合主键来声明。键中只有第一列会作为散列的依据而其他列则被用作Casssandra的SSTables中排序数据的连接索引。尽管查询无法在复合主键的第一列中按范围扫表但如果第一列已经指定了固定值则可以对该键的其他列执行有效的范围扫描。 Cassandra采取了折衷的策略【11, 12, 13】。 Cassandra中的表可以使用由多个列组成的复合主键来声明。键中只有第一列会作为散列的依据而其他列则被用作Casssandra的SSTables中排序数据的连接索引。尽管查询无法在复合主键的第一列中按范围扫表但如果第一列已经指定了固定值则可以对该键的其他列执行有效的范围扫描。
组合索引方法为一对多关系提供了一个优雅的数据模型。例如,在社交媒体网站上,一个用户可能会发布很多更新。如果更新的主键被选择为`(user_id, update_timestamp)`,那么您可以有效地检索特定用户在某个时间间隔内按时间戳排序的所有更新。不同的用户可以存储在不同的分区上,对于每个用户,更新按时间戳顺序存储在单个分区上。 组合索引方法为一对多关系提供了一个优雅的数据模型。例如,在社交媒体网站上,一个用户可能会发布很多更新。如果更新的主键被选择为`(user_id, update_timestamp)`,那么您可以有效地检索特定用户在某个时间间隔内按时间戳排序的所有更新。不同的用户可以存储在不同的分区上,对于每个用户,更新按时间戳顺序存储在单个分区上。
### 负载偏斜与热点消除 ### 负载偏斜与热点消除
如前所述,哈希分区可以帮助减少热点。但是,它不能完全避免它们:在极端情况下,所有的读写操作都是针对同一个键的,所有的请求都会被路由到同一个分区。 如前所述,哈希分区可以帮助减少热点。但是,它不能完全避免它们:在极端情况下,所有的读写操作都是针对同一个键的,所有的请求都会被路由到同一个分区。
这种场景也许并不常见但并非闻所未闻例如在社交媒体网站上一个拥有数百万追随者的名人用户在做某事时可能会引发一场风暴【14】。这个事件可能导致同一个键的大量写入键可能是名人的用户ID或者人们正在评论的动作的ID。哈希策略不起作用因为两个相同ID的哈希值仍然是相同的。 这种场景也许并不常见但并非闻所未闻例如在社交媒体网站上一个拥有数百万追随者的名人用户在做某事时可能会引发一场风暴【14】。这个事件可能导致同一个键的大量写入键可能是名人的用户ID或者人们正在评论的动作的ID。哈希策略不起作用因为两个相同ID的哈希值仍然是相同的。
如今大多数数据系统无法自动补偿这种高度偏斜的负载因此应用程序有责任减少偏斜。例如如果一个主键被认为是非常火爆的一个简单的方法是在主键的开始或结尾添加一个随机数。只要一个两位数的十进制随机数就可以将主键分散为100种不同的主键,从而存储在不同的分区中。 如今大多数数据系统无法自动补偿这种高度偏斜的负载因此应用程序有责任减少偏斜。例如如果一个主键被认为是非常火爆的一个简单的方法是在主键的开始或结尾添加一个随机数。只要一个两位数的十进制随机数就可以将主键分散为100种不同的主键,从而存储在不同的分区中。
然而将主键进行分割之后任何读取都必须要做额外的工作因为他们必须从所有100个主键分布中读取数据并将其合并。此技术还需要额外的记录只需要对少量热点附加随机数对于写入吞吐量低的绝大多数主键来说是不必要的开销。因此您还需要一些方法来跟踪哪些键需要被分割。 然而将主键进行分割之后任何读取都必须要做额外的工作因为他们必须从所有100个主键分布中读取数据并将其合并。此技术还需要额外的记录只需要对少量热点附加随机数对于写入吞吐量低的绝大多数主键来说是不必要的开销。因此您还需要一些方法来跟踪哪些键需要被分割。
也许在将来,数据系统将能够自动检测和补偿偏斜的工作负载;但现在,您需要自己来权衡。 也许在将来,数据系统将能够自动检测和补偿偏斜的工作负载;但现在,您需要自己来权衡。
## 分区与次级索引 ## 分区与次级索引
到目前为止,我们讨论的分区方案依赖于键值数据模型。如果只通过主键访问记录,我们可以从该键确定分区,并使用它来将读写请求路由到负责该键的分区。 到目前为止,我们讨论的分区方案依赖于键值数据模型。如果只通过主键访问记录,我们可以从该键确定分区,并使用它来将读写请求路由到负责该键的分区。
如果涉及次级索引,情况会变得更加复杂(参考“[其他索引结构](ch3.md#其他索引结构)”。次级索引通常并不能唯一地标识记录而是一种搜索记录中出现特定值的方式查找用户123的所有操作查找包含词语`hogwash`的所有文章,查找所有颜色为红色的车辆等等。 如果涉及次级索引,情况会变得更加复杂(参考“[其他索引结构](ch3.md#其他索引结构)”。次级索引通常并不能唯一地标识记录而是一种搜索记录中出现特定值的方式查找用户123的所有操作查找包含词语`hogwash`的所有文章,查找所有颜色为红色的车辆等等。
次级索引是关系型数据库的基础并且在文档数据库中也很普遍。许多键值存储如HBase和Volde-mort为了减少实现的复杂度而放弃了次级索引但是一些如Riak已经开始添加它们因为它们对于数据模型实在是太有用了。并且次级索引也是Solr和Elasticsearch等搜索服务器的基石。 次级索引是关系型数据库的基础并且在文档数据库中也很普遍。许多键值存储如HBase和Volde-mort为了减少实现的复杂度而放弃了次级索引但是一些如Riak已经开始添加它们因为它们对于数据模型实在是太有用了。并且次级索引也是Solr和Elasticsearch等搜索服务器的基石。
次级索引的问题是它们不能整齐地映射到分区。有两种用次级索引对数据库进行分区的方法:**基于文档的分区document-based** 和**基于关键词term-based的分区**。 次级索引的问题是它们不能整齐地映射到分区。有两种用次级索引对数据库进行分区的方法:**基于文档的分区document-based** 和**基于关键词term-based的分区**。
### 基于文档的次级索引进行分区 ### 基于文档的次级索引进行分区
假设你正在经营一个销售二手车的网站(如[图6-4](img/fig6-4.png)所示)。 每个列表都有一个唯一的ID——称之为文档ID——并且用文档ID对数据库进行分区例如分区0中的ID 0到499分区1中的ID 500到999等 假设你正在经营一个销售二手车的网站(如[图6-4](img/fig6-4.png)所示)。 每个列表都有一个唯一的ID——称之为文档ID——并且用文档ID对数据库进行分区例如分区0中的ID 0到499分区1中的ID 500到999等
你想让用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要一个在颜色和厂商上的次级索引(文档数据库中这些是**字段field**,关系数据库中这些是**列column** )。 如果您声明了索引,则数据库可以自动执行索引[^ii]。例如,无论何时将红色汽车添加到数据库,数据库分区都会自动将其添加到索引条目`color:red`的文档ID列表中。 你想让用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要一个在颜色和厂商上的次级索引(文档数据库中这些是**字段field**,关系数据库中这些是**列column** )。 如果您声明了索引,则数据库可以自动执行索引[^ii]。例如,无论何时将红色汽车添加到数据库,数据库分区都会自动将其添加到索引条目`color:red`的文档ID列表中。
[^ii]: 如果数据库仅支持键值模型则你可能会尝试在应用程序代码中创建从值到文档ID的映射来实现次级索引。 如果沿着这条路线走下去,请万分小心,确保您的索引与底层数据保持一致。 竞争条件和间歇性写入失败(其中一些更改已保存,但其他更改未保存)很容易导致数据不同步 - 请参阅“[多对象事务的需求](ch7.md#多对象事务的需求)”。 [^ii]: 如果数据库仅支持键值模型则你可能会尝试在应用程序代码中创建从值到文档ID的映射来实现次级索引。 如果沿着这条路线走下去,请万分小心,确保您的索引与底层数据保持一致。 竞争条件和间歇性写入失败(其中一些更改已保存,但其他更改未保存)很容易导致数据不同步 - 请参阅“[多对象事务的需求](ch7.md#多对象事务的需求)”。
@ -135,35 +135,35 @@
**图6-4 基于文档的次级索引进行分区** **图6-4 基于文档的次级索引进行分区**
在这种索引方法中每个分区是完全独立的每个分区维护自己的次级索引仅覆盖该分区中的文档。它不关心存储在其他分区的数据。无论何时您需要写入数据库添加删除或更新文档只需处理包含您正在编写的文档ID的分区即可。出于这个原因**文档分区索引**也被称为**本地索引local index**(而不是将在下一节中描述的**全局索引global index**)。 在这种索引方法中每个分区是完全独立的每个分区维护自己的次级索引仅覆盖该分区中的文档。它不关心存储在其他分区的数据。无论何时您需要写入数据库添加删除或更新文档只需处理包含您正在编写的文档ID的分区即可。出于这个原因**文档分区索引**也被称为**本地索引local index**(而不是将在下一节中描述的**全局索引global index**)。
但是从文档分区索引中读取需要注意除非您对文档ID做了特别的处理否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在[图6-4](img/fig6-4.png)中红色汽车出现在分区0和分区1中。因此如果要搜索红色汽车则需要将查询发送到所有分区并合并所有返回的结果。 但是从文档分区索引中读取需要注意除非您对文档ID做了特别的处理否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在[图6-4](img/fig6-4.png)中红色汽车出现在分区0和分区1中。因此如果要搜索红色汽车则需要将查询发送到所有分区并合并所有返回的结果。
这种查询分区数据库的方法有时被称为**分散/聚集scatter/gather**,并且可能会使次级索引上的读取查询相当昂贵。即使并行查询分区,分散/聚集也容易导致尾部延迟放大(请参阅“[实践中的百分位点](ch1.md#实践中的百分位点)”。然而它被广泛使用MongoDBRiak 【15】Cassandra 【16】Elasticsearch 【17】SolrCloud 【18】和VoltDB 【19】都使用文档分区次级索引。大多数数据库供应商建议您构建一个能从单个分区提供次级索引查询的分区方案但这并不总是可行尤其是当在单个查询中使用多个次级索引时例如同时需要按颜色和制造商查询 这种查询分区数据库的方法有时被称为**分散/聚集scatter/gather**,并且可能会使次级索引上的读取查询相当昂贵。即使并行查询分区,分散/聚集也容易导致尾部延迟放大(请参阅“[实践中的百分位点](ch1.md#实践中的百分位点)”。然而它被广泛使用MongoDBRiak 【15】Cassandra 【16】Elasticsearch 【17】SolrCloud 【18】和VoltDB 【19】都使用文档分区次级索引。大多数数据库供应商建议您构建一个能从单个分区提供次级索引查询的分区方案但这并不总是可行尤其是当在单个查询中使用多个次级索引时例如同时需要按颜色和制造商查询
### 基于关键词(Term)的次级索引进行分区 ### 基于关键词(Term)的次级索引进行分区
我们可以构建一个覆盖所有分区数据的**全局索引**,而不是给每个分区创建自己的次级索引(本地索引)。但是,我们不能只把这个索引存储在一个节点上,因为它可能会成为瓶颈,违背了分区的目的。全局索引也必须进行分区,但可以采用与主键不同的分区方式。 我们可以构建一个覆盖所有分区数据的**全局索引**,而不是给每个分区创建自己的次级索引(本地索引)。但是,我们不能只把这个索引存储在一个节点上,因为它可能会成为瓶颈,违背了分区的目的。全局索引也必须进行分区,但可以采用与主键不同的分区方式。
[图6-5](img/fig6-5.png)描述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从`a`到`r`的颜色在分区0中`s`到`z`的在分区1。汽车制造商的索引也与之类似分区边界在`f`和`h`之间)。 [图6-5](img/fig6-5.png)描述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从`a`到`r`的颜色在分区0中`s`到`z`的在分区1。汽车制造商的索引也与之类似分区边界在`f`和`h`之间)。
![](img/fig6-5.png) ![](img/fig6-5.png)
**图6-5 基于关键词对次级索引进行分区** **图6-5 基于关键词对次级索引进行分区**
我们将这种索引称为**关键词分区term-partitioned**,因为我们寻找的关键词决定了索引的分区方式。例如,一个关键词可能是:`color:red`。**关键词(Term)** 这个名称来源于全文搜索索引(一种特殊的次级索引),指文档中出现的所有单词。 我们将这种索引称为**关键词分区term-partitioned**,因为我们寻找的关键词决定了索引的分区方式。例如,一个关键词可能是:`color:red`。**关键词(Term)** 这个名称来源于全文搜索索引(一种特殊的次级索引),指文档中出现的所有单词。
和之前一样,我们可以通过**关键词**本身或者它的散列进行索引分区。根据关键词本身来分区对于范围扫描非常有用(例如对于数值类的属性,像汽车的报价),而对关键词的哈希分区提供了负载均衡的能力。 和之前一样,我们可以通过**关键词**本身或者它的散列进行索引分区。根据关键词本身来分区对于范围扫描非常有用(例如对于数值类的属性,像汽车的报价),而对关键词的哈希分区提供了负载均衡的能力。
关键词分区的全局索引优于文档分区索引的地方点是它可以使读取更有效率:不需要**分散/收集**所有分区,客户端只需要向包含关键词的分区发出请求。全局索引的缺点在于写入速度较慢且较为复杂,因为写入单个文档现在可能会影响索引的多个分区(文档中的每个关键词可能位于不同的分区或者不同的节点上) 。 关键词分区的全局索引优于文档分区索引的地方点是它可以使读取更有效率:不需要**分散/收集**所有分区,客户端只需要向包含关键词的分区发出请求。全局索引的缺点在于写入速度较慢且较为复杂,因为写入单个文档现在可能会影响索引的多个分区(文档中的每个关键词可能位于不同的分区或者不同的节点上) 。
理想情况下,索引总是最新的,写入数据库的每个文档都会立即反映在索引中。但是,在关键词分区索引中,这需要跨分区的分布式事务,并不是所有数据库都支持(请参阅[第七章](ch7.md)和[第九章](ch9.md))。 理想情况下,索引总是最新的,写入数据库的每个文档都会立即反映在索引中。但是,在关键词分区索引中,这需要跨分区的分布式事务,并不是所有数据库都支持(请参阅[第七章](ch7.md)和[第九章](ch9.md))。
在实践中,对全局次级索引的更新通常是**异步**的也就是说如果在写入之后不久读取索引刚才所做的更改可能尚未反映在索引中。例如Amazon DynamoDB声称在正常情况下其全局次级索引会在不到一秒的时间内更新但在基础架构出现故障的情况下可能会有延迟【20】。 在实践中,对全局次级索引的更新通常是**异步**的也就是说如果在写入之后不久读取索引刚才所做的更改可能尚未反映在索引中。例如Amazon DynamoDB声称在正常情况下其全局次级索引会在不到一秒的时间内更新但在基础架构出现故障的情况下可能会有延迟【20】。
全局关键词分区索引的其他用途包括Riak的搜索功能【21】和Oracle数据仓库它允许您在本地和全局索引之间进行选择【22】。我们将在[第十二章](ch12.md)中继续关键词分区次级索引实现的话题。 全局关键词分区索引的其他用途包括Riak的搜索功能【21】和Oracle数据仓库它允许您在本地和全局索引之间进行选择【22】。我们将在[第十二章](ch12.md)中继续关键词分区次级索引实现的话题。
## 分区再平衡 ## 分区再平衡
@ -188,73 +188,73 @@
#### 反面教材hash mod N #### 反面教材hash mod N
我们在前面说过([图6-3](img/fig6-3.png)),最好将可能的散列分成不同的范围,并将每个范围分配给一个分区(例如,如果$0≤hash(key)<b_0$则将键分配给分区0如果$b_0 hash(key) <b_1$则分配给分区1 我们在前面说过([图6-3](img/fig6-3.png)),最好将可能的散列分成不同的范围,并将每个范围分配给一个分区(例如,如果$0≤hash(key)<b_0$则将键分配给分区0如果$b_0 hash(key) <b_1$则分配给分区1
也许你想知道为什么我们不使用 ***取模mod***(许多编程语言中的%运算符)。例如,`hash(key) mod 10`会返回一个介于0和9之间的数字如果我们将散列写为十进制数散列模10将是最后一个数字。如果我们有10个节点编号为0到9这似乎是将每个键分配给一个节点的简单方法。 也许你想知道为什么我们不使用 ***取模mod***(许多编程语言中的%运算符)。例如,`hash(key) mod 10`会返回一个介于0和9之间的数字如果我们将散列写为十进制数散列模10将是最后一个数字。如果我们有10个节点编号为0到9这似乎是将每个键分配给一个节点的简单方法。
模N$mod N$方法的问题是如果节点数量N发生变化大多数键将需要从一个节点移动到另一个节点。例如假设$hash(key)=123456$。如果最初有10个节点那么这个键一开始放在节点6上因为$123456\ mod\ 10 = 6$。当您增长到11个节点时键需要移动到节点3$123456\ mod\ 11 = 3$当您增长到12个节点时需要移动到节点0$123456\ mod\ 12 = 0$)。这种频繁的举动使得重新平衡过于昂贵。 模N$mod N$方法的问题是如果节点数量N发生变化大多数键将需要从一个节点移动到另一个节点。例如假设$hash(key)=123456$。如果最初有10个节点那么这个键一开始放在节点6上因为$123456\ mod\ 10 = 6$。当您增长到11个节点时键需要移动到节点3$123456\ mod\ 11 = 3$当您增长到12个节点时需要移动到节点0$123456\ mod\ 12 = 0$)。这种频繁的举动使得重新平衡过于昂贵。
我们需要一种只移动必需数据的方法。 我们需要一种只移动必需数据的方法。
#### 固定数量的分区 #### 固定数量的分区
幸运的是有一个相当简单的解决方案创建比节点更多的分区并为每个节点分配多个分区。例如运行在10个节点的集群上的数据库可能会从一开始就被拆分为1,000个分区因此大约有100个分区被分配给每个节点。 幸运的是有一个相当简单的解决方案创建比节点更多的分区并为每个节点分配多个分区。例如运行在10个节点的集群上的数据库可能会从一开始就被拆分为1,000个分区因此大约有100个分区被分配给每个节点。
现在,如果一个节点被添加到集群中,新节点可以从当前每个节点中**窃取**一些分区,直到分区再次公平分配。这个过程如[图6-6](img/fig6-6.png)所示。如果从集群中删除一个节点,则会发生相反的情况。 现在,如果一个节点被添加到集群中,新节点可以从当前每个节点中**窃取**一些分区,直到分区再次公平分配。这个过程如[图6-6](img/fig6-6.png)所示。如果从集群中删除一个节点,则会发生相反的情况。
只有分区在节点之间的移动。分区的数量不会改变,键所指定的分区也不会改变。唯一改变的是分区所在的节点。这种变更并不是即时的 — 在网络上传输大量的数据需要一些时间 — 所以在传输过程中,原有分区仍然会接受读写操作。 只有分区在节点之间的移动。分区的数量不会改变,键所指定的分区也不会改变。唯一改变的是分区所在的节点。这种变更并不是即时的 — 在网络上传输大量的数据需要一些时间 — 所以在传输过程中,原有分区仍然会接受读写操作。
![](img/fig6-6.png) ![](img/fig6-6.png)
**图6-6 将新节点添加到每个节点具有多个分区的数据库集群。** **图6-6 将新节点添加到每个节点具有多个分区的数据库集群。**
原则上您甚至可以解决集群中的硬件不匹配问题通过为更强大的节点分配更多的分区可以强制这些节点承载更多的负载。在Riak 【15】Elasticsearch 【24】Couchbase 【10】和Voldemort 【25】中使用了这种再平衡的方法。 原则上您甚至可以解决集群中的硬件不匹配问题通过为更强大的节点分配更多的分区可以强制这些节点承载更多的负载。在Riak 【15】Elasticsearch 【24】Couchbase 【10】和Voldemort 【25】中使用了这种再平衡的方法。
在这种配置中,分区的数量通常在数据库第一次建立时确定,之后不会改变。虽然原则上可以分割和合并分区(请参阅下一节),但固定数量的分区在操作上更简单,因此许多固定分区数据库选择不实施分区分割。因此,一开始配置的分区数就是您可以拥有的最大节点数量,所以您需要选择足够多的分区以适应未来的增长。但是,每个分区也有管理开销,所以选择太大的数字会适得其反。 在这种配置中,分区的数量通常在数据库第一次建立时确定,之后不会改变。虽然原则上可以分割和合并分区(请参阅下一节),但固定数量的分区在操作上更简单,因此许多固定分区数据库选择不实施分区分割。因此,一开始配置的分区数就是您可以拥有的最大节点数量,所以您需要选择足够多的分区以适应未来的增长。但是,每个分区也有管理开销,所以选择太大的数字会适得其反。
如果数据集的总大小难以预估(例如,可能它开始很小,但随着时间的推移会变得更大),选择正确的分区数是困难的。由于每个分区包含了总数据量固定比率的数据,因此每个分区的大小与集群中的数据总量成比例增长。如果分区非常大,再平衡和从节点故障恢复变得昂贵。但是,如果分区太小,则会产生太多的开销。当分区大小“恰到好处”的时候才能获得很好的性能,如果分区数量固定,但数据量变动很大,则难以达到最佳性能。 如果数据集的总大小难以预估(例如,可能它开始很小,但随着时间的推移会变得更大),选择正确的分区数是困难的。由于每个分区包含了总数据量固定比率的数据,因此每个分区的大小与集群中的数据总量成比例增长。如果分区非常大,再平衡和从节点故障恢复变得昂贵。但是,如果分区太小,则会产生太多的开销。当分区大小“恰到好处”的时候才能获得很好的性能,如果分区数量固定,但数据量变动很大,则难以达到最佳性能。
#### 动态分区 #### 动态分区
对于使用键范围分区的数据库(请参阅“[根据键的范围分区](#根据键的范围分区)”),具有固定边界的固定数量的分区将非常不便:如果出现边界错误,则可能会导致一个分区中的所有数据或者其他分区中的所有数据为空。手动重新配置分区边界将非常繁琐。 对于使用键范围分区的数据库(请参阅“[根据键的范围分区](#根据键的范围分区)”),具有固定边界的固定数量的分区将非常不便:如果出现边界错误,则可能会导致一个分区中的所有数据或者其他分区中的所有数据为空。手动重新配置分区边界将非常繁琐。
出于这个原因按键的范围进行分区的数据库如HBase和RethinkDB会动态创建分区。当分区增长到超过配置的大小时在HBase上默认值是10GB会被分成两个分区每个分区约占一半的数据【26】。与之相反如果大量数据被删除并且分区缩小到某个阈值以下则可以将其与相邻分区合并。此过程与B树顶层发生的过程类似请参阅“[B树](ch3.md#B树)”)。 出于这个原因按键的范围进行分区的数据库如HBase和RethinkDB会动态创建分区。当分区增长到超过配置的大小时在HBase上默认值是10GB会被分成两个分区每个分区约占一半的数据【26】。与之相反如果大量数据被删除并且分区缩小到某个阈值以下则可以将其与相邻分区合并。此过程与B树顶层发生的过程类似请参阅“[B树](ch3.md#B树)”)。
每个分区分配给一个节点每个节点可以处理多个分区就像固定数量的分区一样。大型分区拆分后可以将其中的一半转移到另一个节点以平衡负载。在HBase中分区文件的传输通过HDFS底层使用的分布式文件系统来实现【3】。 每个分区分配给一个节点每个节点可以处理多个分区就像固定数量的分区一样。大型分区拆分后可以将其中的一半转移到另一个节点以平衡负载。在HBase中分区文件的传输通过HDFS底层使用的分布式文件系统来实现【3】。
动态分区的一个优点是分区数量适应总数据量。如果只有少量的数据少量的分区就足够了所以开销很小如果有大量的数据每个分区的大小被限制在一个可配置的最大值【23】。 动态分区的一个优点是分区数量适应总数据量。如果只有少量的数据少量的分区就足够了所以开销很小如果有大量的数据每个分区的大小被限制在一个可配置的最大值【23】。
需要注意的是一个空的数据库从一个分区开始因为没有关于在哪里绘制分区边界的先验信息。数据集开始时很小直到达到第一个分区的分割点所有写入操作都必须由单个节点处理而其他节点则处于空闲状态。为了解决这个问题HBase和MongoDB允许在一个空的数据库上配置一组初始分区这被称为**预分割pre-splitting**。在键范围分区的情况中预分割需要提前知道键是如何进行分配的【4,26】。 需要注意的是一个空的数据库从一个分区开始因为没有关于在哪里绘制分区边界的先验信息。数据集开始时很小直到达到第一个分区的分割点所有写入操作都必须由单个节点处理而其他节点则处于空闲状态。为了解决这个问题HBase和MongoDB允许在一个空的数据库上配置一组初始分区这被称为**预分割pre-splitting**。在键范围分区的情况中预分割需要提前知道键是如何进行分配的【4,26】。
动态分区不仅适用于数据的范围分区而且也适用于散列分区。从版本2.4开始MongoDB同时支持范围和散列分区并且都支持动态分割分区。 动态分区不仅适用于数据的范围分区而且也适用于散列分区。从版本2.4开始MongoDB同时支持范围和散列分区并且都支持动态分割分区。
#### 按节点比例分区 #### 按节点比例分区
通过动态分区,分区的数量与数据集的大小成正比,因为拆分和合并过程将每个分区的大小保持在固定的最小值和最大值之间。另一方面,对于固定数量的分区,每个分区的大小与数据集的大小成正比。在这两种情况下,分区的数量都与节点的数量无关。 通过动态分区,分区的数量与数据集的大小成正比,因为拆分和合并过程将每个分区的大小保持在固定的最小值和最大值之间。另一方面,对于固定数量的分区,每个分区的大小与数据集的大小成正比。在这两种情况下,分区的数量都与节点的数量无关。
Cassandra和Ketama使用的第三种方法是使分区数与节点数成正比——换句话说每个节点具有固定数量的分区【23,27,28】。在这种情况下每个分区的大小与数据集大小成比例地增长而节点数量保持不变但是当增加节点数时分区将再次变小。由于较大的数据量通常需要较大数量的节点进行存储因此这种方法也使每个分区的大小较为稳定。 Cassandra和Ketama使用的第三种方法是使分区数与节点数成正比——换句话说每个节点具有固定数量的分区【23,27,28】。在这种情况下每个分区的大小与数据集大小成比例地增长而节点数量保持不变但是当增加节点数时分区将再次变小。由于较大的数据量通常需要较大数量的节点进行存储因此这种方法也使每个分区的大小较为稳定。
当一个新节点加入集群时它随机选择固定数量的现有分区进行拆分然后占有这些拆分分区中每个分区的一半同时将每个分区的另一半留在原地。随机化可能会产生不公平的分割但是平均在更大数量的分区上时在Cassandra中默认情况下每个节点有256个分区新节点最终从现有节点获得公平的负载份额。 Cassandra 3.0引入了另一种再平衡的算法来避免不公平的分割【29】。 当一个新节点加入集群时它随机选择固定数量的现有分区进行拆分然后占有这些拆分分区中每个分区的一半同时将每个分区的另一半留在原地。随机化可能会产生不公平的分割但是平均在更大数量的分区上时在Cassandra中默认情况下每个节点有256个分区新节点最终从现有节点获得公平的负载份额。 Cassandra 3.0引入了另一种再平衡的算法来避免不公平的分割【29】。
随机选择分区边界要求使用基于散列的分区可以从散列函数产生的数字范围中挑选边界。实际上这种方法最符合一致性哈希的原始定义【7】请参阅“[一致性哈希](#一致性哈希)”。最新的哈希函数可以在较低元数据开销的情况下达到类似的效果【8】。 随机选择分区边界要求使用基于散列的分区可以从散列函数产生的数字范围中挑选边界。实际上这种方法最符合一致性哈希的原始定义【7】请参阅“[一致性哈希](#一致性哈希)”。最新的哈希函数可以在较低元数据开销的情况下达到类似的效果【8】。
### 运维:手动还是自动再平衡 ### 运维:手动还是自动再平衡
关于再平衡有一个重要问题:自动还是手动进行? 关于再平衡有一个重要问题:自动还是手动进行?
在全自动重新平衡系统自动决定何时将分区从一个节点移动到另一个节点无须人工干预和完全手动分区指派给节点由管理员明确配置仅在管理员明确重新配置时才会更改之间有一个权衡。例如CouchbaseRiak和Voldemort会自动生成建议的分区分配但需要管理员提交才能生效。 在全自动重新平衡系统自动决定何时将分区从一个节点移动到另一个节点无须人工干预和完全手动分区指派给节点由管理员明确配置仅在管理员明确重新配置时才会更改之间有一个权衡。例如CouchbaseRiak和Voldemort会自动生成建议的分区分配但需要管理员提交才能生效。
全自动重新平衡可以很方便,因为正常维护的操作工作较少。但是,这可能是不可预测的。再平衡是一个昂贵的操作,因为它需要重新路由请求并将大量数据从一个节点移动到另一个节点。如果没有做好,这个过程可能会使网络或节点负载过重,降低其他请求的性能。 全自动重新平衡可以很方便,因为正常维护的操作工作较少。但是,这可能是不可预测的。再平衡是一个昂贵的操作,因为它需要重新路由请求并将大量数据从一个节点移动到另一个节点。如果没有做好,这个过程可能会使网络或节点负载过重,降低其他请求的性能。
这种自动化与自动故障检测相结合可能十分危险。例如,假设一个节点过载,并且对请求的响应暂时很慢。其他节点得出结论:过载的节点已经死亡,并自动重新平衡集群,使负载离开它。这会对已经超负荷的节点,其他节点和网络造成额外的负载,从而使情况变得更糟,并可能导致级联失败。 这种自动化与自动故障检测相结合可能十分危险。例如,假设一个节点过载,并且对请求的响应暂时很慢。其他节点得出结论:过载的节点已经死亡,并自动重新平衡集群,使负载离开它。这会对已经超负荷的节点,其他节点和网络造成额外的负载,从而使情况变得更糟,并可能导致级联失败。
出于这个原因,再平衡的过程中有人参与是一件好事。这比完全自动的过程慢,但可以帮助防止运维意外。 出于这个原因,再平衡的过程中有人参与是一件好事。这比完全自动的过程慢,但可以帮助防止运维意外。
## 请求路由 ## 请求路由
现在我们已经将数据集分割到多个机器上运行的多个节点上。但是仍然存在一个悬而未决的问题当客户想要发出请求时如何知道要连接哪个节点随着分区重新平衡分区对节点的分配也发生变化。为了回答这个问题需要有人知晓这些变化如果我想读或写键“foo”需要连接哪个IP地址和端口号 现在我们已经将数据集分割到多个机器上运行的多个节点上。但是仍然存在一个悬而未决的问题当客户想要发出请求时如何知道要连接哪个节点随着分区重新平衡分区对节点的分配也发生变化。为了回答这个问题需要有人知晓这些变化如果我想读或写键“foo”需要连接哪个IP地址和端口号
这个问题可以概括为 **服务发现(service discovery)** 它不仅限于数据库。任何可通过网络访问的软件都有这个问题特别是如果它的目标是高可用性在多台机器上运行冗余配置。许多公司已经编写了自己的内部服务发现工具其中许多已经作为开源发布【30】。 这个问题可以概括为 **服务发现(service discovery)** 它不仅限于数据库。任何可通过网络访问的软件都有这个问题特别是如果它的目标是高可用性在多台机器上运行冗余配置。许多公司已经编写了自己的内部服务发现工具其中许多已经作为开源发布【30】。
概括来说这个问题有几种不同的方案如图6-7所示: 概括来说这个问题有几种不同的方案如图6-7所示:
@ -268,47 +268,47 @@
**图6-7 将请求路由到正确节点的三种不同方式。** **图6-7 将请求路由到正确节点的三种不同方式。**
这是一个具有挑战性的问题,因为重要的是所有参与者都达成共识 - 否则请求将被发送到错误的节点,得不到正确的处理。 在分布式系统中有达成共识的协议,但很难正确地实现(见[第九章](ch9.md))。 这是一个具有挑战性的问题,因为重要的是所有参与者都达成共识 - 否则请求将被发送到错误的节点,得不到正确的处理。 在分布式系统中有达成共识的协议,但很难正确地实现(见[第九章](ch9.md))。
许多分布式数据系统都依赖于一个独立的协调服务比如ZooKeeper来跟踪集群元数据如[图6-8](img/fig6-8.png)所示。 每个节点在ZooKeeper中注册自己ZooKeeper维护分区到节点的可靠映射。 其他参与者如路由层或分区感知客户端可以在ZooKeeper中订阅此信息。 只要分区分配发生了改变或者集群中添加或删除了一个节点ZooKeeper就会通知路由层使路由信息保持最新状态。 许多分布式数据系统都依赖于一个独立的协调服务比如ZooKeeper来跟踪集群元数据如[图6-8](img/fig6-8.png)所示。 每个节点在ZooKeeper中注册自己ZooKeeper维护分区到节点的可靠映射。 其他参与者如路由层或分区感知客户端可以在ZooKeeper中订阅此信息。 只要分区分配发生了改变或者集群中添加或删除了一个节点ZooKeeper就会通知路由层使路由信息保持最新状态。
![](img/fig6-8.png) ![](img/fig6-8.png)
**图6-8 使用ZooKeeper跟踪分区分配给节点。** **图6-8 使用ZooKeeper跟踪分区分配给节点。**
例如LinkedIn的Espresso使用Helix 【31】进行集群管理依靠ZooKeeper实现了如[图6-8](img/fig6-8.png)所示的路由层。 HBaseSolrCloud和Kafka也使用ZooKeeper来跟踪分区分配。 MongoDB具有类似的体系结构但它依赖于自己的**配置服务器config server** 实现和mongos守护进程作为路由层。 例如LinkedIn的Espresso使用Helix 【31】进行集群管理依靠ZooKeeper实现了如[图6-8](img/fig6-8.png)所示的路由层。 HBaseSolrCloud和Kafka也使用ZooKeeper来跟踪分区分配。 MongoDB具有类似的体系结构但它依赖于自己的**配置服务器config server** 实现和mongos守护进程作为路由层。
Cassandra和Riak采取不同的方法他们在节点之间使用**流言协议gossip protocol** 来传播集群状态的变化。请求可以发送到任意节点,该节点会转发到包含所请求的分区的适当节点([图6-7](img/fig6-7.png)中的方法1。这个模型在数据库节点中增加了更多的复杂性但是避免了对像ZooKeeper这样的外部协调服务的依赖。 Cassandra和Riak采取不同的方法他们在节点之间使用**流言协议gossip protocol** 来传播集群状态的变化。请求可以发送到任意节点,该节点会转发到包含所请求的分区的适当节点([图6-7](img/fig6-7.png)中的方法1。这个模型在数据库节点中增加了更多的复杂性但是避免了对像ZooKeeper这样的外部协调服务的依赖。
Couchbase不会自动重新平衡这简化了设计。通常情况下它配置了一个名为moxi的路由层它会从集群节点了解路由变化【32】。 Couchbase不会自动重新平衡这简化了设计。通常情况下它配置了一个名为moxi的路由层它会从集群节点了解路由变化【32】。
当使用路由层或向随机节点发送请求时客户端仍然需要找到要连接的IP地址。这些地址并不像分区的节点分布变化的那么快所以使用DNS通常就足够了。 当使用路由层或向随机节点发送请求时客户端仍然需要找到要连接的IP地址。这些地址并不像分区的节点分布变化的那么快所以使用DNS通常就足够了。
### 执行并行查询 ### 执行并行查询
到目前为止,我们只关注读取或写入单个键的非常简单的查询(加上基于文档分区的次级索引场景下的分散/聚集查询。这也是大多数NoSQL分布式数据存储所支持的访问层级。 到目前为止,我们只关注读取或写入单个键的非常简单的查询(加上基于文档分区的次级索引场景下的分散/聚集查询。这也是大多数NoSQL分布式数据存储所支持的访问层级。
然而,通常用于分析的**大规模并行处理MPP, Massively parallel processing** 关系型数据库产品在其支持的查询类型方面要复杂得多。一个典型的数据仓库查询包含多个连接,过滤,分组和聚合操作。 MPP查询优化器将这个复杂的查询分解成许多执行阶段和分区其中许多可以在数据库集群的不同节点上并行执行。涉及扫描大规模数据集的查询特别受益于这种并行执行。 然而,通常用于分析的**大规模并行处理MPP, Massively parallel processing** 关系型数据库产品在其支持的查询类型方面要复杂得多。一个典型的数据仓库查询包含多个连接,过滤,分组和聚合操作。 MPP查询优化器将这个复杂的查询分解成许多执行阶段和分区其中许多可以在数据库集群的不同节点上并行执行。涉及扫描大规模数据集的查询特别受益于这种并行执行。
数据仓库查询的快速并行执行是一个专门的话题,由于分析有很重要的商业意义,可以带来很多利益。我们将在[第十章](ch10.md)讨论并行查询执行的一些技巧。有关并行数据库中使用的技术的更详细的概述请参阅参考文献【1,33】。 数据仓库查询的快速并行执行是一个专门的话题,由于分析有很重要的商业意义,可以带来很多利益。我们将在[第十章](ch10.md)讨论并行查询执行的一些技巧。有关并行数据库中使用的技术的更详细的概述请参阅参考文献【1,33】。
## 本章小结 ## 本章小结
在本章中,我们探讨了将大数据集划分成更小的子集的不同方法。数据量非常大的时候,在单台机器上存储和处理不再可行,而分区则十分必要。分区的目标是在多台机器上均匀分布数据和查询负载,避免出现热点(负载不成比例的节点)。这需要选择适合于您的数据的分区方案,并在将节点添加到集群或从集群删除时进行分区再平衡。 在本章中,我们探讨了将大数据集划分成更小的子集的不同方法。数据量非常大的时候,在单台机器上存储和处理不再可行,而分区则十分必要。分区的目标是在多台机器上均匀分布数据和查询负载,避免出现热点(负载不成比例的节点)。这需要选择适合于您的数据的分区方案,并在将节点添加到集群或从集群删除时进行分区再平衡。
我们讨论了两种主要的分区方法: 我们讨论了两种主要的分区方法:
***键范围分区*** ***键范围分区***
其中键是有序的,并且分区拥有从某个最小值到某个最大值的所有键。排序的优势在于可以进行有效的范围查询,但是如果应用程序经常访问相邻的键,则存在热点的风险。 其中键是有序的,并且分区拥有从某个最小值到某个最大值的所有键。排序的优势在于可以进行有效的范围查询,但是如果应用程序经常访问相邻的键,则存在热点的风险。
在这种方法中,当分区变得太大时,通常将分区分成两个子分区,动态地再平衡分区。 在这种方法中,当分区变得太大时,通常将分区分成两个子分区,动态地再平衡分区。
***散列分区*** ***散列分区***
散列函数应用于每个键,分区拥有一定范围的散列。这种方法破坏了键的排序,使得范围查询效率低下,但可以更均匀地分配负载。 散列函数应用于每个键,分区拥有一定范围的散列。这种方法破坏了键的排序,使得范围查询效率低下,但可以更均匀地分配负载。
通过散列进行分区时,通常先提前创建固定数量的分区,为每个节点分配多个分区,并在添加或删除节点时将整个分区从一个节点移动到另一个节点。也可以使用动态分区。 通过散列进行分区时,通常先提前创建固定数量的分区,为每个节点分配多个分区,并在添加或删除节点时将整个分区从一个节点移动到另一个节点。也可以使用动态分区。
@ -321,7 +321,7 @@
最后,我们讨论了将查询路由到适当的分区的技术,从简单的分区负载平衡到复杂的并行查询执行引擎。 最后,我们讨论了将查询路由到适当的分区的技术,从简单的分区负载平衡到复杂的并行查询执行引擎。
按照设计,多数情况下每个分区是独立运行的 — 这就是分区数据库可以伸缩到多台机器的原因。但是,需要写入多个分区的操作结果可能难以预料:例如,如果写入一个分区成功,但另一个分区失败,会发生什么情况?我们将在下面的章节中讨论这个问题。 按照设计,多数情况下每个分区是独立运行的 — 这就是分区数据库可以伸缩到多台机器的原因。但是,需要写入多个分区的操作结果可能难以预料:例如,如果写入一个分区成功,但另一个分区失败,会发生什么情况?我们将在下面的章节中讨论这个问题。

50
ch7.md
View File

@ -21,23 +21,23 @@
为了实现可靠性,系统必须处理这些故障,确保它们不会导致整个系统的灾难性故障。但是实现容错机制工作量巨大。需要仔细考虑所有可能出错的事情,并进行大量的测试,以确保解决方案真正管用。 为了实现可靠性,系统必须处理这些故障,确保它们不会导致整个系统的灾难性故障。但是实现容错机制工作量巨大。需要仔细考虑所有可能出错的事情,并进行大量的测试,以确保解决方案真正管用。
数十年来,**事务transaction** 一直是简化这些问题的首选机制。事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式。从概念上讲,事务中的所有读写操作被视作单个操作来执行:整个事务要么成功(**提交commit**)要么失败(**中止abort****回滚rollback**)。如果失败,应用程序可以安全地重试。对于事务来说,应用程序的错误处理变得简单多了,因为它不用再担心部分失败的情况了,即某些操作成功,某些失败(无论出于何种原因)。 数十年来,**事务transaction** 一直是简化这些问题的首选机制。事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式。从概念上讲,事务中的所有读写操作被视作单个操作来执行:整个事务要么成功(**提交commit**)要么失败(**中止abort****回滚rollback**)。如果失败,应用程序可以安全地重试。对于事务来说,应用程序的错误处理变得简单多了,因为它不用再担心部分失败的情况了,即某些操作成功,某些失败(无论出于何种原因)。
和事务打交道时间长了,你可能会觉得它显而易见。但我们不应将其视为理所当然。事务不是天然存在的;它们是为了**简化应用编程模型**而创建的。通过使用事务,应用程序可以自由地忽略某些潜在的错误情况和并发问题,因为数据库会替应用处理好这些。(我们称之为**安全保证safety guarantees**)。 和事务打交道时间长了,你可能会觉得它显而易见。但我们不应将其视为理所当然。事务不是天然存在的;它们是为了**简化应用编程模型**而创建的。通过使用事务,应用程序可以自由地忽略某些潜在的错误情况和并发问题,因为数据库会替应用处理好这些。(我们称之为**安全保证safety guarantees**)。
并不是所有的应用都需要事务,有时候弱化事务保证、或完全放弃事务也是有好处的(例如,为了获得更高性能或更高可用性)。一些安全属性也可以在没有事务的情况下实现。 并不是所有的应用都需要事务,有时候弱化事务保证、或完全放弃事务也是有好处的(例如,为了获得更高性能或更高可用性)。一些安全属性也可以在没有事务的情况下实现。
怎样知道你是否需要事务?为了回答这个问题,首先需要确切理解事务可以提供的安全保障,以及它们的代价。尽管乍看事务似乎很简单,但实际上有许多微妙但重要的细节在起作用。 怎样知道你是否需要事务?为了回答这个问题,首先需要确切理解事务可以提供的安全保障,以及它们的代价。尽管乍看事务似乎很简单,但实际上有许多微妙但重要的细节在起作用。
本章将研究许多出错案例,并探索数据库用于防范这些问题的算法。尤其会深入**并发控制**的领域,讨论各种可能发生的竞争条件,以及数据库如何实现**读已提交read committed****快照隔离snapshot isolation** 和**可串行化serializability** 等隔离级别。 本章将研究许多出错案例,并探索数据库用于防范这些问题的算法。尤其会深入**并发控制**的领域,讨论各种可能发生的竞争条件,以及数据库如何实现**读已提交read committed****快照隔离snapshot isolation** 和**可串行化serializability** 等隔离级别。
本章同时适用于单机数据库与分布式数据库;在[第八章](ch8.md)中将重点讨论仅出现在分布式系统中的特殊挑战。 本章同时适用于单机数据库与分布式数据库;在[第八章](ch8.md)中将重点讨论仅出现在分布式系统中的特殊挑战。
## 事务的棘手概念 ## 事务的棘手概念
现今,几乎所有的关系型数据库和一些非关系数据库都支持**事务**。其中大多数遵循IBM System R第一个SQL数据库在1975年引入的风格【1,2,3】。40年里尽管一些实现细节发生了变化但总体思路大同小异MySQLPostgreSQLOracleSQL Server等数据库中的事务支持与System R异乎寻常地相似。 现今,几乎所有的关系型数据库和一些非关系数据库都支持**事务**。其中大多数遵循IBM System R第一个SQL数据库在1975年引入的风格【1,2,3】。40年里尽管一些实现细节发生了变化但总体思路大同小异MySQLPostgreSQLOracleSQL Server等数据库中的事务支持与System R异乎寻常地相似。
2000年以后非关系NoSQL数据库开始普及。它们的目标是在关系数据库的现状基础上通过提供新的数据模型选择请参阅[第二章](ch2.md)并默认包含复制第五章和分区第六章来进一步提升。事务是这次运动的主要牺牲品这些新一代数据库中的许多数据库完全放弃了事务或者重新定义了这个词描述比以前所理解的更弱得多的一套保证【4】。 2000年以后非关系NoSQL数据库开始普及。它们的目标是在关系数据库的现状基础上通过提供新的数据模型选择请参阅[第二章](ch2.md)并默认包含复制第五章和分区第六章来进一步提升。事务是这次运动的主要牺牲品这些新一代数据库中的许多数据库完全放弃了事务或者重新定义了这个词描述比以前所理解的更弱得多的一套保证【4】。
@ -304,11 +304,11 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
***备份*** ***备份***
进行备份需要复制整个数据库,对大型数据库而言可能需要花费数小时才能完成。备份进程运行时,数据库仍然会接受写入操作。因此备份可能会包含一些旧的部分和一些新的部分。如果从这样的备份中恢复,那么不一致(如消失的钱)就会变成永久的。 进行备份需要复制整个数据库,对大型数据库而言可能需要花费数小时才能完成。备份进程运行时,数据库仍然会接受写入操作。因此备份可能会包含一些旧的部分和一些新的部分。如果从这样的备份中恢复,那么不一致(如消失的钱)就会变成永久的。
***分析查询和完整性检查*** ***分析查询和完整性检查***
有时,您可能需要运行一个查询,扫描大部分的数据库。这样的查询在分析中很常见(请参阅“[事务处理还是分析?](ch3.md#事务处理还是分析?)”),也可能是定期完整性检查(即监视数据损坏)的一部分。如果这些查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果。 有时,您可能需要运行一个查询,扫描大部分的数据库。这样的查询在分析中很常见(请参阅“[事务处理还是分析?](ch3.md#事务处理还是分析?)”),也可能是定期完整性检查(即监视数据损坏)的一部分。如果这些查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果。
**快照隔离snapshot isolation**【28】是这个问题最常见的解决方案。想法是每个事务都从数据库的**一致快照consistent snapshot** 中读取——也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。 **快照隔离snapshot isolation**【28】是这个问题最常见的解决方案。想法是每个事务都从数据库的**一致快照consistent snapshot** 中读取——也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
@ -613,15 +613,15 @@ COMMIT;
#### 在存储过程中封装事务 #### 在存储过程中封装事务
在数据库的早期阶段,意图是数据库事务可以包含整个用户活动流程。例如,预订机票是一个多阶段的过程(搜索路线,票价和可用座位,决定行程,在每段行程的航班上订座,输入乘客信息,付款)。数据库设计者认为,如果整个过程是一个事务,那么它就可以被原子化地执行。 在数据库的早期阶段,意图是数据库事务可以包含整个用户活动流程。例如,预订机票是一个多阶段的过程(搜索路线,票价和可用座位,决定行程,在每段行程的航班上订座,输入乘客信息,付款)。数据库设计者认为,如果整个过程是一个事务,那么它就可以被原子化地执行。
不幸的是人类做出决定和回应的速度非常缓慢。如果数据库事务需要等待来自用户的输入则数据库需要支持潜在的大量并发事务其中大部分是空闲的。大多数数据库不能高效完成这项工作因此几乎所有的OLTP应用程序都避免在事务中等待交互式的用户输入以此来保持事务的简短。在Web上这意味着事务在同一个HTTP请求中被提交——一个事务不会跨越多个请求。一个新的HTTP请求开始一个新的事务。 不幸的是人类做出决定和回应的速度非常缓慢。如果数据库事务需要等待来自用户的输入则数据库需要支持潜在的大量并发事务其中大部分是空闲的。大多数数据库不能高效完成这项工作因此几乎所有的OLTP应用程序都避免在事务中等待交互式的用户输入以此来保持事务的简短。在Web上这意味着事务在同一个HTTP请求中被提交——一个事务不会跨越多个请求。一个新的HTTP请求开始一个新的事务。
即使已经将人类从关键路径中排除,事务仍然以交互式的客户端/服务器风格执行,一次一个语句。应用程序进行查询,读取结果,可能根据第一个查询的结果进行另一个查询,依此类推。查询和结果在应用程序代码(在一台机器上运行)和数据库服务器(在另一台机器上)之间来回发送。 即使已经将人类从关键路径中排除,事务仍然以交互式的客户端/服务器风格执行,一次一个语句。应用程序进行查询,读取结果,可能根据第一个查询的结果进行另一个查询,依此类推。查询和结果在应用程序代码(在一台机器上运行)和数据库服务器(在另一台机器上)之间来回发送。
在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。 在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。
出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如[图7-9](img/fig7-9.png) 所示。如果事务所需的所有数据都在内存中则存储过程可以非常快地执行而不用等待任何网络或磁盘I/O。 出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如[图7-9](img/fig7-9.png) 所示。如果事务所需的所有数据都在内存中则存储过程可以非常快地执行而不用等待任何网络或磁盘I/O。
![](img/fig7-9.png) ![](img/fig7-9.png)
@ -836,45 +836,45 @@ WHERE room_id = 123 AND
***脏读*** ***脏读***
一个客户端读取到另一个客户端尚未提交的写入。**读已提交**或更强的隔离级别可以防止脏读。 一个客户端读取到另一个客户端尚未提交的写入。**读已提交**或更强的隔离级别可以防止脏读。
***脏写*** ***脏写***
一个客户端覆盖写入了另一个客户端尚未提交的写入。几乎所有的事务实现都可以防止脏写。 一个客户端覆盖写入了另一个客户端尚未提交的写入。几乎所有的事务实现都可以防止脏写。
***读取偏差(不可重复读)*** ***读取偏差(不可重复读)***
在同一个事务中,客户端在不同的时间点会看见数据库的不同状态。**快照隔离**经常用于解决这个问题,它允许事务从一个特定时间点的一致性快照中读取数据。快照隔离通常使用**多版本并发控制MVCC** 来实现。 在同一个事务中,客户端在不同的时间点会看见数据库的不同状态。**快照隔离**经常用于解决这个问题,它允许事务从一个特定时间点的一致性快照中读取数据。快照隔离通常使用**多版本并发控制MVCC** 来实现。
***更新丢失*** ***更新丢失***
两个客户端同时执行**读取-修改-写入序列**。其中一个写操作,在没有合并另一个写入变更情况下,直接覆盖了另一个写操作的结果。所以导致数据丢失。快照隔离的一些实现可以自动防止这种异常,而另一些实现则需要手动锁定(`SELECT FOR UPDATE`)。 两个客户端同时执行**读取-修改-写入序列**。其中一个写操作,在没有合并另一个写入变更情况下,直接覆盖了另一个写操作的结果。所以导致数据丢失。快照隔离的一些实现可以自动防止这种异常,而另一些实现则需要手动锁定(`SELECT FOR UPDATE`)。
***写偏差*** ***写偏差***
一个事务读取一些东西,根据它所看到的值作出决定,并将该决定写入数据库。但是,写入时,该决定的前提不再是真实的。只有可串行化的隔离才能防止这种异常。 一个事务读取一些东西,根据它所看到的值作出决定,并将该决定写入数据库。但是,写入时,该决定的前提不再是真实的。只有可串行化的隔离才能防止这种异常。
***幻读*** ***幻读***
事务读取符合某些搜索条件的对象。另一个客户端进行写入,影响搜索结果。快照隔离可以防止直接的幻像读取,但是写入偏差上下文中的幻读需要特殊处理,例如索引范围锁定。 事务读取符合某些搜索条件的对象。另一个客户端进行写入,影响搜索结果。快照隔离可以防止直接的幻像读取,但是写入偏差上下文中的幻读需要特殊处理,例如索引范围锁定。
弱隔离级别可以防止其中一些异常情况,但要求你,也就是应用程序开发人员手动处理剩余那些(例如,使用显式锁定)。只有可串行化的隔离才能防范所有这些问题。我们讨论了实现可串行化事务的三种不同方法: 弱隔离级别可以防止其中一些异常情况,但要求你,也就是应用程序开发人员手动处理剩余那些(例如,使用显式锁定)。只有可串行化的隔离才能防范所有这些问题。我们讨论了实现可串行化事务的三种不同方法:
***字面意义上的串行执行*** ***字面意义上的串行执行***
如果每个事务的执行速度非常快并且事务吞吐量足够低足以在单个CPU核上处理这是一个简单而有效的选择。 如果每个事务的执行速度非常快并且事务吞吐量足够低足以在单个CPU核上处理这是一个简单而有效的选择。
***两阶段锁定*** ***两阶段锁定***
数十年来,两阶段锁定一直是实现可串行化的标准方式,但是许多应用出于性能问题的考虑避免使用它。 数十年来,两阶段锁定一直是实现可串行化的标准方式,但是许多应用出于性能问题的考虑避免使用它。
***可串行化快照隔离SSI*** ***可串行化快照隔离SSI***
一个相当新的算法,避免了先前方法的大部分缺点。它使用乐观的方法,允许事务执行而无需阻塞。当一个事务想要提交时,它会进行检查,如果执行不可串行化,事务就会被中止。 一个相当新的算法,避免了先前方法的大部分缺点。它使用乐观的方法,允许事务执行而无需阻塞。当一个事务想要提交时,它会进行检查,如果执行不可串行化,事务就会被中止。
本章中的示例主要是在关系数据模型的上下文中。但是,正如在讨论中,无论使用哪种数据模型,如“**[多对象事务的需求](#多对象事务的需求)**”中所讨论的,事务都是有价值的数据库功能。 本章中的示例主要是在关系数据模型的上下文中。但是,正如在讨论中,无论使用哪种数据模型,如“**[多对象事务的需求](#多对象事务的需求)**”中所讨论的,事务都是有价值的数据库功能。
本章主要是在单机数据库的上下文中,探讨了各种想法和算法。分布式数据库中的事务,则引入了一系列新的困难挑战,我们将在接下来的两章中讨论。 本章主要是在单机数据库的上下文中,探讨了各种想法和算法。分布式数据库中的事务,则引入了一系列新的困难挑战,我们将在接下来的两章中讨论。

320
ch8.md
View File

@ -16,36 +16,36 @@
[TOC] [TOC]
最近几章中反复出现的主题是,系统如何处理错误的事情。例如,我们讨论了**副本故障切换**(“[处理节点中断](ch5.md#处理节点宕机)”),**复制延迟**(“[复制延迟问题](ch5.md#复制延迟问题)”)和事务控制(“[弱隔离级别](ch7.md#弱隔离级别)”)。当我们了解可能在实际系统中出现的各种边缘情况时,我们会更好地处理它们。 最近几章中反复出现的主题是,系统如何处理错误的事情。例如,我们讨论了**副本故障切换**(“[处理节点中断](ch5.md#处理节点宕机)”),**复制延迟**(“[复制延迟问题](ch5.md#复制延迟问题)”)和事务控制(“[弱隔离级别](ch7.md#弱隔离级别)”)。当我们了解可能在实际系统中出现的各种边缘情况时,我们会更好地处理它们。
但是,尽管我们已经谈了很多错误,但之前几章仍然过于乐观。现实更加黑暗。我们现在将悲观主义最大化,假设任何可能出错的东西**都会**出错[^i]。(经验丰富的系统运维会告诉你,这是一个合理的假设。如果你问得好,他们可能会一边治疗心理创伤一边告诉你一些可怕的故事) 但是,尽管我们已经谈了很多错误,但之前几章仍然过于乐观。现实更加黑暗。我们现在将悲观主义最大化,假设任何可能出错的东西**都会**出错[^i]。(经验丰富的系统运维会告诉你,这是一个合理的假设。如果你问得好,他们可能会一边治疗心理创伤一边告诉你一些可怕的故事)
[^i]: 除了一个例外:我们将假定故障是非拜占庭式的(请参阅“[拜占庭故障](#拜占庭故障)”)。 [^i]: 除了一个例外:我们将假定故障是非拜占庭式的(请参阅“[拜占庭故障](#拜占庭故障)”)。
使用分布式系统与在一台计算机上编写软件有着根本的区别主要的区别在于有许多新颖和刺激的方法可以使事情出错【1,2】。在这一章中我们将了解实践中出现的问题理解我们能够依赖和不可以依赖的东西。 使用分布式系统与在一台计算机上编写软件有着根本的区别主要的区别在于有许多新颖和刺激的方法可以使事情出错【1,2】。在这一章中我们将了解实践中出现的问题理解我们能够依赖和不可以依赖的东西。
最后,作为工程师,我们的任务是构建能够完成工作的系统(即满足用户期望的保证),尽管一切都出错了。 在[第九章](ch9.md)中,我们将看看一些可以在分布式系统中提供这种保证的算法的例子。 但首先,在本章中,我们必须了解我们面临的挑战。 最后,作为工程师,我们的任务是构建能够完成工作的系统(即满足用户期望的保证),尽管一切都出错了。 在[第九章](ch9.md)中,我们将看看一些可以在分布式系统中提供这种保证的算法的例子。 但首先,在本章中,我们必须了解我们面临的挑战。
本章对分布式系统中可能出现的问题进行彻底的悲观和沮丧的总结。 我们将研究网络的问题(“[不可靠的网络](#不可靠的网络)”); 时钟和时序问题(“[不可靠的时钟](#不可靠的时钟)”); 我们将讨论他们可以避免的程度。 所有这些问题的后果都是困惑的,所以我们将探索如何思考一个分布式系统的状态,以及如何推理发生的事情(“[知识、真相与谎言](#知识、真相与谎言)”)。 本章对分布式系统中可能出现的问题进行彻底的悲观和沮丧的总结。 我们将研究网络的问题(“[不可靠的网络](#不可靠的网络)”); 时钟和时序问题(“[不可靠的时钟](#不可靠的时钟)”); 我们将讨论他们可以避免的程度。 所有这些问题的后果都是困惑的,所以我们将探索如何思考一个分布式系统的状态,以及如何推理发生的事情(“[知识、真相与谎言](#知识、真相与谎言)”)。
## 故障与部分失效 ## 故障与部分失效
当你在一台计算机上编写一个程序时,它通常会以一种相当可预测的方式运行:无论是工作还是不工作。充满错误的软件可能会让人觉得电脑有时候也会有“糟糕的一天”(这种问题通常是重新启动就恢复了),但这主要是软件写得不好的结果。 当你在一台计算机上编写一个程序时,它通常会以一种相当可预测的方式运行:无论是工作还是不工作。充满错误的软件可能会让人觉得电脑有时候也会有“糟糕的一天”(这种问题通常是重新启动就恢复了),但这主要是软件写得不好的结果。
单个计算机上的软件没有根本性的不可靠原因:当硬件正常工作时,相同的操作总是产生相同的结果(这是确定性的)。如果存在硬件问题(例如,内存损坏或连接器松动),其后果通常是整个系统故障(例如,内核恐慌,“蓝屏死机”,启动失败)。装有良好软件的个人计算机通常要么功能完好,要么完全失效,而不是介于两者之间。 单个计算机上的软件没有根本性的不可靠原因:当硬件正常工作时,相同的操作总是产生相同的结果(这是确定性的)。如果存在硬件问题(例如,内存损坏或连接器松动),其后果通常是整个系统故障(例如,内核恐慌,“蓝屏死机”,启动失败)。装有良好软件的个人计算机通常要么功能完好,要么完全失效,而不是介于两者之间。
这是计算机设计中的一个有意的选择:如果发生内部错误,我们宁愿电脑完全崩溃,而不是返回错误的结果,因为错误的结果很难处理。因为计算机隐藏了模糊不清的物理实现,并呈现出一个理想化的系统模型,并以数学一样的完美的方式运作。 CPU指令总是做同样的事情如果您将一些数据写入内存或磁盘那么这些数据将保持不变并且不会被随机破坏。从第一台数字计算机开始*始终正确地计算*这个设计目标贯穿始终【3】。 这是计算机设计中的一个有意的选择:如果发生内部错误,我们宁愿电脑完全崩溃,而不是返回错误的结果,因为错误的结果很难处理。因为计算机隐藏了模糊不清的物理实现,并呈现出一个理想化的系统模型,并以数学一样的完美的方式运作。 CPU指令总是做同样的事情如果您将一些数据写入内存或磁盘那么这些数据将保持不变并且不会被随机破坏。从第一台数字计算机开始*始终正确地计算*这个设计目标贯穿始终【3】。
当你编写运行在多台计算机上的软件时情况有本质上的区别。在分布式系统中我们不再处于理想化的系统模型中我们别无选择只能面对现实世界的混乱现实。而在现实世界中各种各样的事情都可能会出现问题【4】如下面的轶事所述 当你编写运行在多台计算机上的软件时情况有本质上的区别。在分布式系统中我们不再处于理想化的系统模型中我们别无选择只能面对现实世界的混乱现实。而在现实世界中各种各样的事情都可能会出现问题【4】如下面的轶事所述
> 在我有限的经验中,我已经和很多东西打过交道:单个**数据中心DC** 中长期存在的网络分区配电单元PDU故障交换机故障整个机架的意外重启整个数据中心主干网络故障整个数据中心的电源故障以及一个低血糖的司机把他的福特皮卡撞在数据中心的HVAC加热通风和空调系统上。而且我甚至不是一个运维。 > 在我有限的经验中,我已经和很多东西打过交道:单个**数据中心DC** 中长期存在的网络分区配电单元PDU故障交换机故障整个机架的意外重启整个数据中心主干网络故障整个数据中心的电源故障以及一个低血糖的司机把他的福特皮卡撞在数据中心的HVAC加热通风和空调系统上。而且我甚至不是一个运维。
> >
> ——柯达黑尔 > ——柯达黑尔
在分布式系统中,尽管系统的其他部分工作正常,但系统的某些部分可能会以某种不可预知的方式被破坏。这被称为**部分失效partial failure**。难点在于部分失效是**不确定性的nonderterministic**:如果你试图做任何涉及多个节点和网络的事情,它有时可能会工作,有时会出现不可预知的失败。正如我们将要看到的,你甚至不知道是否成功了,因为消息通过网络传播的时间也是不确定的! 在分布式系统中,尽管系统的其他部分工作正常,但系统的某些部分可能会以某种不可预知的方式被破坏。这被称为**部分失效partial failure**。难点在于部分失效是**不确定性的nonderterministic**:如果你试图做任何涉及多个节点和网络的事情,它有时可能会工作,有时会出现不可预知的失败。正如我们将要看到的,你甚至不知道是否成功了,因为消息通过网络传播的时间也是不确定的!
这种不确定性和部分失效的可能性使得分布式系统难以工作【5】。 这种不确定性和部分失效的可能性使得分布式系统难以工作【5】。
### 云计算与超级计算机 ### 云计算与超级计算机
@ -73,9 +73,9 @@
如果要使分布式系统工作,就必须接受部分故障的可能性,并在软件中建立容错机制。换句话说,我们需要从不可靠的组件构建一个可靠的系统。 (正如“[可靠性](ch1.md#可靠性)”中所讨论的那样,没有完美的可靠性,所以我们需要理解我们可以实际承诺的极限。) 如果要使分布式系统工作,就必须接受部分故障的可能性,并在软件中建立容错机制。换句话说,我们需要从不可靠的组件构建一个可靠的系统。 (正如“[可靠性](ch1.md#可靠性)”中所讨论的那样,没有完美的可靠性,所以我们需要理解我们可以实际承诺的极限。)
即使在只有少数节点的小型系统中,考虑部分故障也是很重要的。在一个小系统中,很可能大部分组件在大部分时间都正常工作。然而,迟早会有一部分系统出现故障,软件必须以某种方式处理。故障处理必须是软件设计的一部分,并且作为软件的运维,您需要知道在发生故障的情况下,软件可能会表现出怎样的行为。 即使在只有少数节点的小型系统中,考虑部分故障也是很重要的。在一个小系统中,很可能大部分组件在大部分时间都正常工作。然而,迟早会有一部分系统出现故障,软件必须以某种方式处理。故障处理必须是软件设计的一部分,并且作为软件的运维,您需要知道在发生故障的情况下,软件可能会表现出怎样的行为。
简单地假设缺陷很罕见并希望始终保持最好的状况是不明智的。考虑一系列可能的错误(甚至是不太可能的错误),并在测试环境中人为地创建这些情况来查看会发生什么是非常重要的。在分布式系统中,怀疑,悲观和偏执狂是值得的。 简单地假设缺陷很罕见并希望始终保持最好的状况是不明智的。考虑一系列可能的错误(甚至是不太可能的错误),并在测试环境中人为地创建这些情况来查看会发生什么是非常重要的。在分布式系统中,怀疑,悲观和偏执狂是值得的。
> #### 从不可靠的组件构建可靠的系统 > #### 从不可靠的组件构建可靠的系统
> >
@ -92,11 +92,11 @@
## 不可靠的网络 ## 不可靠的网络
正如在[第二部分](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. 请求可能已经丢失(可能有人拔掉了网线)。 1. 请求可能已经丢失(可能有人拔掉了网线)。
2. 请求可能正在排队,稍后将交付(也许网络或接收方过载)。 2. 请求可能正在排队,稍后将交付(也许网络或接收方过载)。
@ -109,27 +109,27 @@
**图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#可靠性)”)。
### 检测故障 ### 检测故障
@ -147,25 +147,25 @@
关于远程节点关闭的快速反馈很有用但是你不能指望它。即使TCP确认已经传送了一个数据包应用程序在处理之前可能已经崩溃。如果你想确保一个请求是成功的你需要应用程序本身的正确响应【24】。 关于远程节点关闭的快速反馈很有用但是你不能指望它。即使TCP确认已经传送了一个数据包应用程序在处理之前可能已经崩溃。如果你想确保一个请求是成功的你需要应用程序本身的正确响应【24】。
相反如果出了什么问题你可能会在堆栈的某个层次上得到一个错误响应但总的来说你必须假设你可能根本就得不到任何回应。您可以重试几次TCP重试是透明的但是您也可以在应用程序级别重试等待超时过期并且如果在超时时间内没有收到响应则最终声明节点已经死亡。 相反如果出了什么问题你可能会在堆栈的某个层次上得到一个错误响应但总的来说你必须假设你可能根本就得不到任何回应。您可以重试几次TCP重试是透明的但是您也可以在应用程序级别重试等待超时过期并且如果在超时时间内没有收到响应则最终声明节点已经死亡。
### 超时与无穷的延迟 ### 超时与无穷的延迟
如果超时是检测故障的唯一可靠方法,那么超时应该等待多久?不幸的是没有简单的答案。 如果超时是检测故障的唯一可靠方法,那么超时应该等待多久?不幸的是没有简单的答案。
长时间的超时意味着长时间等待,直到一个节点被宣告死亡(在这段时间内,用户可能不得不等待,或者看到错误信息)。短的超时可以更快地检测到故障,但有更高地风险误将一个节点宣布为失效,而该节点实际上只是暂时地变慢了(例如由于节点或网络上的负载峰值)。 长时间的超时意味着长时间等待,直到一个节点被宣告死亡(在这段时间内,用户可能不得不等待,或者看到错误信息)。短的超时可以更快地检测到故障,但有更高地风险误将一个节点宣布为失效,而该节点实际上只是暂时地变慢了(例如由于节点或网络上的负载峰值)。
过早地声明一个节点已经死了是有问题的:如果这个节点实际上是活着的,并且正在执行一些动作(例如,发送一封电子邮件),而另一个节点接管,那么这个动作可能会最终执行两次。我们将在“[知识、真相与谎言](#知识、真相与谎言)”以及[第九章](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】 在驾驶汽车时由于交通拥堵道路交通网络的通行时间往往不尽相同。同样计算机网络上数据包延迟的可变性通常是由于排队【25】
* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如[图8-2](img/fig8-2.png)所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络拥塞)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。 * 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如[图8-2](img/fig8-2.png)所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络拥塞)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。
* 当数据包到达目标机器时如果所有CPU内核当前都处于繁忙状态则来自网络的传入请求将被操作系统排队直到应用程序准备好处理它为止。根据机器上的负载这可能需要一段任意的时间。 * 当数据包到达目标机器时如果所有CPU内核当前都处于繁忙状态则来自网络的传入请求将被操作系统排队直到应用程序准备好处理它为止。根据机器上的负载这可能需要一段任意的时间。
@ -176,7 +176,7 @@
**图8-2 如果有多台机器将网络流量发送到同一目的地则其交换机队列可能会被填满。在这里端口1,2和4都试图发送数据包到端口3** **图8-2 如果有多台机器将网络流量发送到同一目的地则其交换机队列可能会被填满。在这里端口1,2和4都试图发送数据包到端口3**
而且如果TCP在某个超时时间内没有被确认这是根据观察的往返时间计算的则认为数据包丢失丢失的数据包将自动重新发送。尽管应用程序没有看到数据包丢失和重新传输但它看到了延迟等待超时到期然后等待重新传输的数据包得到确认 而且如果TCP在某个超时时间内没有被确认这是根据观察的往返时间计算的则认为数据包丢失丢失的数据包将自动重新发送。尽管应用程序没有看到数据包丢失和重新传输但它看到了延迟等待超时到期然后等待重新传输的数据包得到确认
> ### TCP与UDP > ### TCP与UDP
@ -185,41 +185,41 @@
> >
> 在延迟数据毫无价值的情况下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协议相比更接近于电路交换。在这个级别上可以购买专用带宽。但是互联网路由在网络级别运行而不是主机之间的单独连接而且运行时间要长得多。
@ -253,39 +253,39 @@
[例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接收机获取时间。
### 单调钟与日历时钟 ### 单调钟与日历时钟
现代计算机至少有两种不同的时钟日历时钟time-of-day clock和单调钟monotonic clock。尽管它们都衡量时间但区分这两者很重要因为它们有不同的目的。 现代计算机至少有两种不同的时钟日历时钟time-of-day clock和单调钟monotonic clock。尽管它们都衡量时间但区分这两者很重要因为它们有不同的目的。
#### 日历时钟 #### 日历时钟
日历时钟是您直观地了解时钟的依据:它根据某个日历(也称为**挂钟时间wall-clock time**返回当前日期和时间。例如Linux上的`clock_gettime(CLOCK_REALTIME)`[^v]和Java中的`System.currentTimeMillis()`返回自epochUTC时间1970年1月1日午夜以来的秒数或毫秒根据公历Gregorian日历不包括闰秒。有些系统使用其他日期作为参考点。 日历时钟是您直观地了解时钟的依据:它根据某个日历(也称为**挂钟时间wall-clock time**返回当前日期和时间。例如Linux上的`clock_gettime(CLOCK_REALTIME)`[^v]和Java中的`System.currentTimeMillis()`返回自epochUTC时间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秒。即使一切工作正常此漂移也会限制可以达到的最佳准确度。 * 计算机中的石英钟不够精确:它会**漂移drifts**(运行速度快于或慢于预期)。时钟漂移取决于机器的温度。 Google假设其服务器时钟漂移为200 ppm百万分之一【41】相当于每30秒与服务器重新同步一次的时钟漂移为6毫秒或者每天重新同步的时钟漂移为17秒。即使一切工作正常此漂移也会限制可以达到的最佳准确度。
* 如果计算机的时钟与NTP服务器的时钟差别太大可能会拒绝同步或者本地时钟将被强制重置【37】。任何观察重置前后时间的应用程序都可能会看到时间倒退或突然跳跃。 * 如果计算机的时钟与NTP服务器的时钟差别太大可能会拒绝同步或者本地时钟将被强制重置【37】。任何观察重置前后时间的应用程序都可能会看到时间倒退或突然跳跃。
@ -298,33 +298,33 @@
如果你足够在乎这件事并投入大量资源就可以达到非常好的时钟精度。例如针对金融机构的欧洲法规草案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】。
因此,如果你使用需要同步时钟的软件,必须仔细监控所有机器之间的时钟偏移。时钟偏离其他时钟太远的节点应当被宣告死亡,并从集群中移除。这样的监控可以确保你在损失发生之前注意到破损的时钟。 因此,如果你使用需要同步时钟的软件,必须仔细监控所有机器之间的时钟偏移。时钟偏离其他时钟太远的节点应当被宣告死亡,并从集群中移除。这样的监控可以确保你在损失发生之前注意到破损的时钟。
#### 有序事件的时间戳 #### 有序事件的时间戳
让我们考虑一个特别的情况,一件很有诱惑但也很危险的事情:依赖时钟,在多个节点上对事件进行排序。 例如,如果两个客户端写入分布式数据库,谁先到达? 哪一个更近? 让我们考虑一个特别的情况,一件很有诱惑但也很危险的事情:依赖时钟,在多个节点上对事件进行排序。 例如,如果两个客户端写入分布式数据库,谁先到达? 哪一个更近?
[图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) ![](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】。此方案可能导致一定数量的数据被悄悄丢弃而未向应用报告任何错误。 * 数据库写入可能会神秘地消失具有滞后时钟的节点无法覆盖之前具有快速时钟的节点写入的值直到节点之间的时钟偏差消逝【54,55】。此方案可能导致一定数量的数据被悄悄丢弃而未向应用报告任何错误。
* LWW无法区分**高频顺序写入**(在[图8-3](img/fig8-3.png)中客户端B的增量操作**一定**发生在客户端A的写入之后和**真正并发写入**(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止违背因果关系(请参阅“[检测并发写入](ch5.md#检测并发写入)”)。 * LWW无法区分**高频顺序写入**(在[图8-3](img/fig8-3.png)中客户端B的增量操作**一定**发生在客户端A的写入之后和**真正并发写入**(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止违背因果关系(请参阅“[检测并发写入](ch5.md#检测并发写入)”)。
@ -332,49 +332,49 @@
因此,尽管通过保留最“最近”的值并放弃其他值来解决冲突是很诱惑人的,但是要注意,“最近”的定义取决于本地的**日历时钟**这很可能是不正确的。即使用严格同步的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以这种方式实现跨数据中心的快照隔离【5960】。它使用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以这种方式实现跨数据中心的快照隔离【5960】。它使用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】。这些想法很有趣但是它们还没有在谷歌之外的主流数据库中实现。 对分布式事务语义使用时钟同步是一个活跃的研究领域【57,61,62】。这些想法很有趣但是它们还没有在谷歌之外的主流数据库中实现。
### 进程暂停 ### 进程暂停
让我们考虑在分布式系统中使用危险时钟的另一个例子。假设你有一个数据库,每个分区只有一个领导者。只有领导被允许接受写入。一个节点如何知道它仍然是领导者(它并没有被别人宣告为死亡),并且它可以安全地接受写入? 让我们考虑在分布式系统中使用危险时钟的另一个例子。假设你有一个数据库,每个分区只有一个领导者。只有领导被允许接受写入。一个节点如何知道它仍然是领导者(它并没有被别人宣告为死亡),并且它可以安全地接受写入?
一种选择是领导者从其他节点获得一个**租约lease**类似一个带超时的锁【63】。任一时刻只有一个节点可以持有租约——因此当一个节点获得一个租约时它知道它在某段时间内自己是领导者直到租约到期。为了保持领导地位节点必须周期性地在租约过期前续期。 一种选择是领导者从其他节点获得一个**租约lease**类似一个带超时的锁【63】。任一时刻只有一个节点可以持有租约——因此当一个节点获得一个租约时它知道它在某段时间内自己是领导者直到租约到期。为了保持领导地位节点必须周期性地在租约过期前续期。
如果节点发生故障,就会停止续期,所以当租约过期时,另一个节点可以接管。 如果节点发生故障,就会停止续期,所以当租约过期时,另一个节点可以接管。
可以想象,请求处理循环看起来像这样: 可以想象,请求处理循环看起来像这样:
```java ```java
while (true) { while (true) {
@ -390,13 +390,13 @@ 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】但是如果我们想要提供健壮的保证就必须假设最坏的情况发生。 * 许多编程语言运行时如Java虚拟机都有一个垃圾收集器GC偶尔需要停止所有正在运行的线程。这些“**停止所有处理stop-the-world**”GC暂停有时会持续几分钟【64】甚至像HotSpot JVM的CMS这样的所谓的“并行”垃圾收集器也不能完全与应用程序代码并行运行它需要不时地停止所有处理【65】。尽管通常可以通过改变分配模式或调整GC设置来减少暂停【66】但是如果我们想要提供健壮的保证就必须假设最坏的情况发生。
* 在虚拟化环境中,可以**挂起suspend** 虚拟机暂停执行所有进程并将内存内容保存到磁盘并恢复恢复内存内容并继续执行。这个暂停可以在进程执行的任何时候发生并且可以持续任意长的时间。这个功能有时用于虚拟机从一个主机到另一个主机的实时迁移而不需要重新启动在这种情况下暂停的长度取决于进程写入内存的速率【67】。 * 在虚拟化环境中,可以**挂起suspend** 虚拟机暂停执行所有进程并将内存内容保存到磁盘并恢复恢复内存内容并继续执行。这个暂停可以在进程执行的任何时候发生并且可以持续任意长的时间。这个功能有时用于虚拟机从一个主机到另一个主机的实时迁移而不需要重新启动在这种情况下暂停的长度取决于进程写入内存的速率【67】。
@ -408,15 +408,15 @@ while (true) {
所有这些事件都可以随时**抢占preempt** 正在运行的线程,并在稍后的时间恢复运行,而线程甚至不会注意到这一点。这个问题类似于在单个机器上使多线程代码线程安全:你不能对时序做任何假设,因为随时可能发生上下文切换,或者出现并行运行。 所有这些事件都可以随时**抢占preempt** 正在运行的线程,并在稍后的时间恢复运行,而线程甚至不会注意到这一点。这个问题类似于在单个机器上使多线程代码线程安全:你不能对时序做任何假设,因为随时可能发生上下文切换,或者出现并行运行。
当在一台机器上编写多线程代码时,我们有相当好的工具来实现线程安全:互斥量,信号量,原子计数器,无锁数据结构,阻塞队列等等。不幸的是,这些工具并不能直接转化为分布式系统操作,因为分布式系统没有共享内存,只有通过不可靠网络发送的消息。 当在一台机器上编写多线程代码时,我们有相当好的工具来实现线程安全:互斥量,信号量,原子计数器,无锁数据结构,阻塞队列等等。不幸的是,这些工具并不能直接转化为分布式系统操作,因为分布式系统没有共享内存,只有通过不可靠网络发送的消息。
分布式系统中的节点,必须假定其执行可能在任意时刻暂停相当长的时间,即使是在一个函数的中间。在暂停期间,世界的其它部分在继续运转,甚至可能因为该节点没有响应,而宣告暂停节点的死亡。最终暂停的节点可能会继续运行,在再次检查自己的时钟之前,甚至可能不会意识到自己进入了睡眠。 分布式系统中的节点,必须假定其执行可能在任意时刻暂停相当长的时间,即使是在一个函数的中间。在暂停期间,世界的其它部分在继续运转,甚至可能因为该节点没有响应,而宣告暂停节点的死亡。最终暂停的节点可能会继续运行,在再次检查自己的时钟之前,甚至可能不会意识到自己进入了睡眠。
#### 响应时间保证 #### 响应时间保证
在许多编程语言和操作系统中,线程和进程可能暂停一段无限制的时间,正如讨论的那样。如果你足够努力,导致暂停的原因是**可以**消除的。 在许多编程语言和操作系统中,线程和进程可能暂停一段无限制的时间,正如讨论的那样。如果你足够努力,导致暂停的原因是**可以**消除的。
某些软件的运行环境要求很高,不能在特定时间内响应可能会导致严重的损失:控制飞机、火箭、机器人、汽车和其他物体的计算机必须对其传感器输入做出快速而可预测的响应。在这些系统中,软件必须有一个特定的**截止时间deadline**,如果截止时间不满足,可能会导致整个系统的故障。这就是所谓的**硬实时hard real-time** 系统。 某些软件的运行环境要求很高,不能在特定时间内响应可能会导致严重的损失:控制飞机、火箭、机器人、汽车和其他物体的计算机必须对其传感器输入做出快速而可预测的响应。在这些系统中,软件必须有一个特定的**截止时间deadline**,如果截止时间不满足,可能会导致整个系统的故障。这就是所谓的**硬实时hard real-time** 系统。
> #### 实时是真的吗? > #### 实时是真的吗?
> >
@ -424,49 +424,49 @@ while (true) {
例如如果车载传感器检测到当前正在经历碰撞你肯定不希望安全气囊释放系统因为GC暂停而延迟弹出。 例如如果车载传感器检测到当前正在经历碰撞你肯定不希望安全气囊释放系统因为GC暂停而延迟弹出。
在系统中提供**实时保证**需要各级软件栈的支持一个实时操作系统RTOS允许在指定的时间间隔内保证CPU时间的分配。库函数必须申明最坏情况下的执行时间动态内存分配可能受到限制或完全不允许实时垃圾收集器存在但是应用程序仍然必须确保它不会给GC太多的负担必须进行大量的测试和测量以确保达到保证。 在系统中提供**实时保证**需要各级软件栈的支持一个实时操作系统RTOS允许在指定的时间间隔内保证CPU时间的分配。库函数必须申明最坏情况下的执行时间动态内存分配可能受到限制或完全不允许实时垃圾收集器存在但是应用程序仍然必须确保它不会给GC太多的负担必须进行大量的测试和测量以确保达到保证。
所有这些都需要大量额外的工作,严重限制了可以使用的编程语言、库和工具的范围(因为大多数语言和工具不提供实时保证)。由于这些原因,开发实时系统非常昂贵,并且它们通常用于安全关键的嵌入式设备。而且,“**实时**”与“**高性能**”不一样——事实上,实时系统可能具有较低的吞吐量,因为他们必须让及时响应的优先级高于一切(另请参阅“[延迟和资源利用](#延迟和资源利用)“)。 所有这些都需要大量额外的工作,严重限制了可以使用的编程语言、库和工具的范围(因为大多数语言和工具不提供实时保证)。由于这些原因,开发实时系统非常昂贵,并且它们通常用于安全关键的嵌入式设备。而且,“**实时**”与“**高性能**”不一样——事实上,实时系统可能具有较低的吞吐量,因为他们必须让及时响应的优先级高于一切(另请参阅“[延迟和资源利用](#延迟和资源利用)“)。
对于大多数服务器端数据处理系统来说,实时保证是不经济或不合适的。因此,这些系统必须承受在非实时环境中运行的暂停和时钟不稳定性。 对于大多数服务器端数据处理系统来说,实时保证是不经济或不合适的。因此,这些系统必须承受在非实时环境中运行的暂停和时钟不稳定性。
#### 限制垃圾收集的影响 #### 限制垃圾收集的影响
进程暂停的负面影响可以在不诉诸昂贵的实时调度保证的情况下得到缓解。语言运行时在计划垃圾回收时具有一定的灵活性,因为它们可以跟踪对象分配的速度和随着时间的推移剩余的空闲内存。 进程暂停的负面影响可以在不诉诸昂贵的实时调度保证的情况下得到缓解。语言运行时在计划垃圾回收时具有一定的灵活性,因为它们可以跟踪对象分配的速度和随着时间的推移剩余的空闲内存。
一个新兴的想法是将GC暂停视为一个节点的短暂计划中断并在这个节点收集其垃圾的同时让其他节点处理来自客户端的请求。如果运行时可以警告应用程序一个节点很快需要GC暂停那么应用程序可以停止向该节点发送新的请求等待它完成处理未完成的请求然后在没有请求正在进行时执行GC。这个技巧向客户端隐藏了GC暂停并降低了响应时间的高百分比【70,71】。一些对延迟敏感的金融交易系统【72】使用这种方法。 一个新兴的想法是将GC暂停视为一个节点的短暂计划中断并在这个节点收集其垃圾的同时让其他节点处理来自客户端的请求。如果运行时可以警告应用程序一个节点很快需要GC暂停那么应用程序可以停止向该节点发送新的请求等待它完成处理未完成的请求然后在没有请求正在进行时执行GC。这个技巧向客户端隐藏了GC暂停并降低了响应时间的高百分比【70,71】。一些对延迟敏感的金融交易系统【72】使用这种方法。
这个想法的一个变种是只用垃圾收集器来处理短命对象这些对象可以快速收集并定期在积累大量长寿对象因此需要完整GC之前重新启动进程【65,73】。一次可以重新启动一个节点在计划重新启动之前流量可以从该节点移开就像[第四章](ch4.md)里描述的滚动升级一样。 这个想法的一个变种是只用垃圾收集器来处理短命对象这些对象可以快速收集并定期在积累大量长寿对象因此需要完整GC之前重新启动进程【65,73】。一次可以重新启动一个节点在计划重新启动之前流量可以从该节点移开就像[第四章](ch4.md)里描述的滚动升级一样。
这些措施不能完全阻止垃圾回收暂停,但可以有效地减少它们对应用的影响。 这些措施不能完全阻止垃圾回收暂停,但可以有效地减少它们对应用的影响。
## 知识、真相与谎言 ## 知识、真相与谎言
本章到目前为止,我们已经探索了分布式系统与运行在单台计算机上的程序的不同之处:没有共享内存,只有通过可变延迟的不可靠网络传递的消息,系统可能遭受部分失效,不可靠的时钟和处理暂停。 本章到目前为止,我们已经探索了分布式系统与运行在单台计算机上的程序的不同之处:没有共享内存,只有通过可变延迟的不可靠网络传递的消息,系统可能遭受部分失效,不可靠的时钟和处理暂停。
如果你不习惯于分布式系统,那么这些问题的后果就会让人迷惑不解。网络中的一个节点无法确切地知道任何事情——它只能根据它通过网络接收到(或没有接收到)的消息进行猜测。节点只能通过交换消息来找出另一个节点所处的状态(存储了哪些数据,是否正确运行等等)。如果远程节点没有响应,则无法知道它处于什么状态,因为网络中的问题不能可靠地与节点上的问题区分开来。 如果你不习惯于分布式系统,那么这些问题的后果就会让人迷惑不解。网络中的一个节点无法确切地知道任何事情——它只能根据它通过网络接收到(或没有接收到)的消息进行猜测。节点只能通过交换消息来找出另一个节点所处的状态(存储了哪些数据,是否正确运行等等)。如果远程节点没有响应,则无法知道它处于什么状态,因为网络中的问题不能可靠地与节点上的问题区分开来。
这些系统的讨论与哲学有关:在系统中什么是真什么是假?如果感知和测量的机制都是不可靠的,那么关于这些知识我们又能多么确定呢?软件系统应该遵循我们对物理世界所期望的法则,如因果关系吗? 这些系统的讨论与哲学有关:在系统中什么是真什么是假?如果感知和测量的机制都是不可靠的,那么关于这些知识我们又能多么确定呢?软件系统应该遵循我们对物理世界所期望的法则,如因果关系吗?
幸运的是,我们不需要去搞清楚生命的意义。在分布式系统中,我们可以陈述关于行为(系统模型)的假设,并以满足这些假设的方式设计实际系统。算法可以被证明在某个系统模型中正确运行。这意味着即使底层系统模型提供了很少的保证,也可以实现可靠的行为。 幸运的是,我们不需要去搞清楚生命的意义。在分布式系统中,我们可以陈述关于行为(系统模型)的假设,并以满足这些假设的方式设计实际系统。算法可以被证明在某个系统模型中正确运行。这意味着即使底层系统模型提供了很少的保证,也可以实现可靠的行为。
但是,尽管可以使软件在不可靠的系统模型中表现良好,但这并不是可以直截了当实现的。在本章的其余部分中,我们将进一步探讨分布式系统中的知识和真相的概念,这将有助于我们思考我们可以做出的各种假设以及我们可能希望提供的保证。在[第九章](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** 时,我们将更详细地讨论法定人数的应用。
#### 领导者和锁 #### 领导者和锁
@ -478,41 +478,41 @@ while (true) {
在分布式系统中实现这一点需要注意:即使一个节点认为它是“**天选者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) ![](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) ![](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】。因此任何服务保护自己免受意外客户的滥用是一个好主意。 在服务器端检查一个令牌可能看起来像是一个缺点但这可以说是一件好事一个服务假定它的客户总是守规矩并不明智因为使用客户端的人与运行服务的人优先级非常不一样【76】。因此任何服务保护自己免受意外客户的滥用是一个好主意。
### 拜占庭故障 ### 拜占庭故障
防护令牌可以检测和阻止无意中发生错误的节点(例如,因为它尚未发现其租约已过期)。但是,如果节点有意破坏系统的保证,则可以通过使用假防护令牌发送消息来轻松完成此操作。 防护令牌可以检测和阻止无意中发生错误的节点(例如,因为它尚未发现其租约已过期)。但是,如果节点有意破坏系统的保证,则可以通过使用假防护令牌发送消息来轻松完成此操作。
在本书中我们假设节点是不可靠但诚实的它们可能很慢或者从不响应由于故障并且它们的状态可能已经过时由于GC暂停或网络延迟但是我们假设如果节点它做出了回应它正在说出“真相”尽其所知它正在按照协议的规则扮演其角色。 在本书中我们假设节点是不可靠但诚实的它们可能很慢或者从不响应由于故障并且它们的状态可能已经过时由于GC暂停或网络延迟但是我们假设如果节点它做出了回应它正在说出“真相”尽其所知它正在按照协议的规则扮演其角色。
如果存在节点可能“撒谎”(发送任意错误或损坏的响应)的风险,则分布式系统的问题变得更困难了——例如,如果节点可能声称其实际上没有收到特定的消息。这种行为被称为**拜占庭故障Byzantine fault****在不信任的环境中达成共识的问题被称为拜占庭将军问题**【77】。 如果存在节点可能“撒谎”(发送任意错误或损坏的响应)的风险,则分布式系统的问题变得更困难了——例如,如果节点可能声称其实际上没有收到特定的消息。这种行为被称为**拜占庭故障Byzantine fault****在不信任的环境中达成共识的问题被称为拜占庭将军问题**【77】。
> ### 拜占庭将军问题 > ### 拜占庭将军问题
> >
@ -522,22 +522,22 @@ while (true) {
> >
> 拜占庭是后来成为君士坦丁堡的古希腊城市现在在土耳其的伊斯坦布尔。没有任何历史证据表明拜占庭将军比其他地方更容易出现阴谋和阴谋。相反这个名字来源于拜占庭式的过度复杂官僚迂回等意义早在计算机之前就已经在政治中被使用了【79】。Lamport想要选一个不会冒犯任何读者的国家他被告知将其称为阿尔巴尼亚将军问题并不是一个好主意【80】。 > 拜占庭是后来成为君士坦丁堡的古希腊城市现在在土耳其的伊斯坦布尔。没有任何历史证据表明拜占庭将军比其他地方更容易出现阴谋和阴谋。相反这个名字来源于拜占庭式的过度复杂官僚迂回等意义早在计算机之前就已经在政治中被使用了【79】。Lamport想要选一个不会冒犯任何读者的国家他被告知将其称为阿尔巴尼亚将军问题并不是一个好主意【80】。
当一个系统在部分节点发生故障、不遵守协议、甚至恶意攻击、扰乱网络时仍然能继续正确工作,称之为**拜占庭容错Byzantine fault-tolerant** 的,在特定场景下,这种担忧在是有意义的: 当一个系统在部分节点发生故障、不遵守协议、甚至恶意攻击、扰乱网络时仍然能继续正确工作,称之为**拜占庭容错Byzantine fault-tolerant** 的,在特定场景下,这种担忧在是有意义的:
* 在航空航天环境中计算机内存或CPU寄存器中的数据可能被辐射破坏导致其以任意不可预知的方式响应其他节点。由于系统故障非常昂贵例如飞机撞毁和炸死船上所有人员或火箭与国际空间站相撞飞行控制系统必须容忍拜占庭故障【81,82】。 * 在航空航天环境中计算机内存或CPU寄存器中的数据可能被辐射破坏导致其以任意不可预知的方式响应其他节点。由于系统故障非常昂贵例如飞机撞毁和炸死船上所有人员或火箭与国际空间站相撞飞行控制系统必须容忍拜占庭故障【81,82】。
* 在多个参与组织的系统中一些参与者可能会试图欺骗或欺骗他人。在这种情况下节点仅仅信任另一个节点的消息是不安全的因为它们可能是出于恶意的目的而被发送的。例如像比特币和其他区块链一样的对等网络可以被认为是让互不信任的各方同意交易是否发生的一种方式而不依赖于中心机构central authority【83】。 * 在多个参与组织的系统中一些参与者可能会试图欺骗或欺骗他人。在这种情况下节点仅仅信任另一个节点的消息是不安全的因为它们可能是出于恶意的目的而被发送的。例如像比特币和其他区块链一样的对等网络可以被认为是让互不信任的各方同意交易是否发生的一种方式而不依赖于中心机构central authority【83】。
然而在本书讨论的那些系统中我们通常可以安全地假设没有拜占庭式的错误。在你的数据中心里所有的节点都是由你的组织控制的所以他们可以信任辐射水平足够低内存损坏不是一个大问题。制作拜占庭容错系统的协议相当复杂【84】而容错嵌入式系统依赖于硬件层面的支持【81】。在大多数服务器端数据系统中部署拜占庭容错解决方案的成本使其变得不切实际。 然而在本书讨论的那些系统中我们通常可以安全地假设没有拜占庭式的错误。在你的数据中心里所有的节点都是由你的组织控制的所以他们可以信任辐射水平足够低内存损坏不是一个大问题。制作拜占庭容错系统的协议相当复杂【84】而容错嵌入式系统依赖于硬件层面的支持【81】。在大多数服务器端数据系统中部署拜占庭容错解决方案的成本使其变得不切实际。
Web应用程序确实需要预期受终端用户控制的客户端如Web浏览器的任意和恶意行为。这就是为什么输入验证数据清洗和输出转义如此重要例如防止SQL注入和跨站点脚本。然而我们通常不在这里使用拜占庭容错协议而只是让服务器有权决定是否允许客户端行为。但在没有这种中心机构的对等网络中拜占庭容错更为重要。 Web应用程序确实需要预期受终端用户控制的客户端如Web浏览器的任意和恶意行为。这就是为什么输入验证数据清洗和输出转义如此重要例如防止SQL注入和跨站点脚本。然而我们通常不在这里使用拜占庭容错协议而只是让服务器有权决定是否允许客户端行为。但在没有这种中心机构的对等网络中拜占庭容错更为重要。
软件中的一个错误bug可能被认为是拜占庭式的错误但是如果您将相同的软件部署到所有节点上那么拜占庭式的容错算法帮不到你。大多数拜占庭式容错算法要求超过三分之二的节点能够正常工作如果有四个节点最多只能有一个故障。要使用这种方法对付bug你必须有四个独立的相同软件的实现并希望一个bug只出现在四个实现之一中。 软件中的一个错误bug可能被认为是拜占庭式的错误但是如果您将相同的软件部署到所有节点上那么拜占庭式的容错算法帮不到你。大多数拜占庭式容错算法要求超过三分之二的节点能够正常工作如果有四个节点最多只能有一个故障。要使用这种方法对付bug你必须有四个独立的相同软件的实现并希望一个bug只出现在四个实现之一中。
同样,如果一个协议可以保护我们免受漏洞,安全渗透和恶意攻击,那么这将是有吸引力的。不幸的是,这也是不现实的:在大多数系统中,如果攻击者可以渗透一个节点,那他们可能会渗透所有这些节点,因为它们可能都运行着相同的软件。因此,传统机制(认证,访问控制,加密,防火墙等)仍然是抵御攻击者的主要保护措施。 同样,如果一个协议可以保护我们免受漏洞,安全渗透和恶意攻击,那么这将是有吸引力的。不幸的是,这也是不现实的:在大多数系统中,如果攻击者可以渗透一个节点,那他们可能会渗透所有这些节点,因为它们可能都运行着相同的软件。因此,传统机制(认证,访问控制,加密,防火墙等)仍然是抵御攻击者的主要保护措施。
#### 弱谎言形式 #### 弱谎言形式
尽管我们假设节点通常是诚实的,但值得向软件中添加防止“撒谎”弱形式的机制——例如,由硬件问题导致的无效消息,软件错误和错误配置。这种保护机制并不是完全的拜占庭容错,因为它们不能抵挡决心坚定的对手,但它们仍然是简单而实用的步骤,以提高可靠性。例如: 尽管我们假设节点通常是诚实的,但值得向软件中添加防止“撒谎”弱形式的机制——例如,由硬件问题导致的无效消息,软件错误和错误配置。这种保护机制并不是完全的拜占庭容错,因为它们不能抵挡决心坚定的对手,但它们仍然是简单而实用的步骤,以提高可靠性。例如:
* 由于硬件问题或操作系统、驱动程序、路由器等中的错误网络数据包有时会受到损坏。通常损坏的数据包会被内建于TCP和UDP中的校验和所俘获但有时它们也会逃脱检测【85,86,87】 。要对付这种破坏通常使用简单的方法就可以做到,例如应用程序级协议中的校验和。 * 由于硬件问题或操作系统、驱动程序、路由器等中的错误网络数据包有时会受到损坏。通常损坏的数据包会被内建于TCP和UDP中的校验和所俘获但有时它们也会逃脱检测【85,86,87】 。要对付这种破坏通常使用简单的方法就可以做到,例如应用程序级协议中的校验和。
* 可公开访问的应用程序必须仔细清理来自用户的任何输入,例如检查值是否在合理的范围内,并限制字符串的大小以防止通过大内存分配的拒绝服务。防火墙后面的内部服务对于输入也许可以只采取一些不那么严格的检查,但是采取一些基本的合理性检查(例如,在协议解析中)仍然是一个好主意。 * 可公开访问的应用程序必须仔细清理来自用户的任何输入,例如检查值是否在合理的范围内,并限制字符串的大小以防止通过大内存分配的拒绝服务。防火墙后面的内部服务对于输入也许可以只采取一些不那么严格的检查,但是采取一些基本的合理性检查(例如,在协议解析中)仍然是一个好主意。
@ -545,89 +545,89 @@ while (true) {
### 系统模型与现实 ### 系统模型与现实
已经有很多算法被设计以解决分布式系统问题——例如,我们将在[第九章](ch9.md)讨论共识问题的解决方案。为了有用,这些算法需要容忍我们在本章中讨论的分布式系统的各种故障。 已经有很多算法被设计以解决分布式系统问题——例如,我们将在[第九章](ch9.md)讨论共识问题的解决方案。为了有用,这些算法需要容忍我们在本章中讨论的分布式系统的各种故障。
算法的编写方式不应该过分依赖于运行的硬件和软件配置的细节。这就要求我们以某种方式将我们期望在系统中发生的错误形式化。我们通过定义一个系统模型来做到这一点,这个模型是一个抽象,描述一个算法可以假设的事情。 算法的编写方式不应该过分依赖于运行的硬件和软件配置的细节。这就要求我们以某种方式将我们期望在系统中发生的错误形式化。我们通过定义一个系统模型来做到这一点,这个模型是一个抽象,描述一个算法可以假设的事情。
关于时序假设,三种系统模型是常用的: 关于时序假设,三种系统模型是常用的:
***同步模型*** ***同步模型***
**同步模型synchronous model** 假设网络延迟、进程暂停和和时钟误差都是受限的。这并不意味着完全同步的时钟或零网络延迟这只意味着你知道网络延迟、暂停和时钟漂移将永远不会超过某个固定的上限【88】。同步模型并不是大多数实际系统的现实模型因为如本章所讨论的无限延迟和暂停确实会发生。 **同步模型synchronous model** 假设网络延迟、进程暂停和和时钟误差都是受限的。这并不意味着完全同步的时钟或零网络延迟这只意味着你知道网络延迟、暂停和时钟漂移将永远不会超过某个固定的上限【88】。同步模型并不是大多数实际系统的现实模型因为如本章所讨论的无限延迟和暂停确实会发生。
***部分同步模型*** ***部分同步模型***
**部分同步partial synchronous** 意味着一个系统在大多数情况下像一个同步系统一样运行但有时候会超出网络延迟进程暂停和时钟漂移的界限【88】。这是很多系统的现实模型大多数情况下网络和进程表现良好否则我们永远无法完成任何事情但是我们必须承认在任何时刻都存在时序假设偶然被破坏的事实。发生这种情况时网络延迟、暂停和时钟错误可能会变得相当大。 **部分同步partial synchronous** 意味着一个系统在大多数情况下像一个同步系统一样运行但有时候会超出网络延迟进程暂停和时钟漂移的界限【88】。这是很多系统的现实模型大多数情况下网络和进程表现良好否则我们永远无法完成任何事情但是我们必须承认在任何时刻都存在时序假设偶然被破坏的事实。发生这种情况时网络延迟、暂停和时钟错误可能会变得相当大。
***异步模型*** ***异步模型***
在这个模型中,一个算法不允许对时序做任何假设——事实上它甚至没有时钟(所以它不能使用超时)。一些算法被设计为可用于异步模型,但非常受限。 在这个模型中,一个算法不允许对时序做任何假设——事实上它甚至没有时钟(所以它不能使用超时)。一些算法被设计为可用于异步模型,但非常受限。
进一步来说,除了时序问题,我们还要考虑**节点失效**。三种最常见的节点系统模型是: 进一步来说,除了时序问题,我们还要考虑**节点失效**。三种最常见的节点系统模型是:
***崩溃-停止故障*** ***崩溃-停止故障***
在**崩溃停止crash-stop** 模型中,算法可能会假设一个节点只能以一种方式失效,即通过崩溃。这意味着节点可能在任意时刻突然停止响应,此后该节点永远消失——它永远不会回来。 在**崩溃停止crash-stop** 模型中,算法可能会假设一个节点只能以一种方式失效,即通过崩溃。这意味着节点可能在任意时刻突然停止响应,此后该节点永远消失——它永远不会回来。
***崩溃-恢复故障*** ***崩溃-恢复故障***
我们假设节点可能会在任何时候崩溃,但也许会在未知的时间之后再次开始响应。在**崩溃-恢复crash-recovery** 模型中,假设节点具有稳定的存储(即,非易失性磁盘存储)且会在崩溃中保留,而内存中的状态会丢失。 我们假设节点可能会在任何时候崩溃,但也许会在未知的时间之后再次开始响应。在**崩溃-恢复crash-recovery** 模型中,假设节点具有稳定的存储(即,非易失性磁盘存储)且会在崩溃中保留,而内存中的状态会丢失。
***拜占庭(任意)故障*** ***拜占庭(任意)故障***
节点可以做(绝对意义上的)任何事情,包括试图戏弄和欺骗其他节点,如上一节所述。 节点可以做(绝对意义上的)任何事情,包括试图戏弄和欺骗其他节点,如上一节所述。
对于真实系统的建模,具有**崩溃-恢复故障crash-recovery** 的**部分同步模型partial synchronous** 通常是最有用的模型。分布式算法如何应对这种模型? 对于真实系统的建模,具有**崩溃-恢复故障crash-recovery** 的**部分同步模型partial synchronous** 通常是最有用的模型。分布式算法如何应对这种模型?
#### 算法的正确性 #### 算法的正确性
为了定义算法是正确的,我们可以描述它的属性。例如,排序算法的输出具有如下特性:对于输出列表中的任何两个不同的元素,左边的元素比右边的元素小。这只是定义对列表进行排序含义的一种形式方式。 为了定义算法是正确的,我们可以描述它的属性。例如,排序算法的输出具有如下特性:对于输出列表中的任何两个不同的元素,左边的元素比右边的元素小。这只是定义对列表进行排序含义的一种形式方式。
同样,我们可以写下我们想要的分布式算法的属性来定义它的正确含义。例如,如果我们正在为一个锁生成防护令牌(请参阅“[防护令牌](#防护令牌)”),我们可能要求算法具有以下属性: 同样,我们可以写下我们想要的分布式算法的属性来定义它的正确含义。例如,如果我们正在为一个锁生成防护令牌(请参阅“[防护令牌](#防护令牌)”),我们可能要求算法具有以下属性:
***唯一性uniqueness*** ***唯一性uniqueness***
没有两个防护令牌请求返回相同的值。 没有两个防护令牌请求返回相同的值。
***单调序列monotonic sequence*** ***单调序列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*** ***可用性availability***
请求防护令牌并且不会崩溃的节点,最终会收到响应。 请求防护令牌并且不会崩溃的节点,最终会收到响应。
如果一个系统模型中的算法总是满足它在所有我们假设可能发生的情况下的性质,那么这个算法是正确的。但这如何有意义?如果所有的节点崩溃,或者所有的网络延迟突然变得无限长,那么没有任何算法能够完成任何事情。 如果一个系统模型中的算法总是满足它在所有我们假设可能发生的情况下的性质,那么这个算法是正确的。但这如何有意义?如果所有的节点崩溃,或者所有的网络延迟突然变得无限长,那么没有任何算法能够完成任何事情。
#### 安全性和活性 #### 安全性和活性
为了澄清这种情况,有必要区分两种不同的属性:**安全safety属性**和**活性liveness属性**。在刚刚给出的例子中,**唯一性**和**单调序列**是安全属性,而**可用性**是活性属性。 为了澄清这种情况,有必要区分两种不同的属性:**安全safety属性**和**活性liveness属性**。在刚刚给出的例子中,**唯一性**和**单调序列**是安全属性,而**可用性**是活性属性。
这两种性质有什么区别?一个试金石就是,活性属性通常在定义中通常包括“**最终**”一词。 是的你猜对了——最终一致性是一个活性属性【89】。 这两种性质有什么区别?一个试金石就是,活性属性通常在定义中通常包括“**最终**”一词。 是的你猜对了——最终一致性是一个活性属性【89】。
安全通常被非正式地定义为:**没有坏事发生**,而活性通常就类似:**最终好事发生**。但是最好不要过多地阅读那些非正式的定义因为好与坏的含义是主观的。安全和活性的实际定义是精确的和数学的【90】 安全通常被非正式地定义为:**没有坏事发生**,而活性通常就类似:**最终好事发生**。但是最好不要过多地阅读那些非正式的定义因为好与坏的含义是主观的。安全和活性的实际定义是精确的和数学的【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】。这可以说是计算机科学和软件工程间的一个差异
这并不是说理论上抽象的系统模型是毫无价值的,恰恰相反。它们对于将实际系统的复杂性提取成一个个我们可以推理的可处理的错误类型是非常有帮助的,以便我们能够理解这个问题,并试图系统地解决这个问题。我们可以证明算法是正确的,通过表明它们的属性在某个系统模型中总是成立的。 这并不是说理论上抽象的系统模型是毫无价值的,恰恰相反。它们对于将实际系统的复杂性提取成一个个我们可以推理的可处理的错误类型是非常有帮助的,以便我们能够理解这个问题,并试图系统地解决这个问题。我们可以证明算法是正确的,通过表明它们的属性在某个系统模型中总是成立的。
证明算法正确并不意味着它在真实系统上的实现必然总是正确的。但这迈出了很好的第一步,因为理论分析可以发现算法中的问题,这种问题可能会在现实系统中长期潜伏,直到你的假设(例如,时序)因为不寻常的情况被打破。理论分析与经验测试同样重要。 证明算法正确并不意味着它在真实系统上的实现必然总是正确的。但这迈出了很好的第一步,因为理论分析可以发现算法中的问题,这种问题可能会在现实系统中长期潜伏,直到你的假设(例如,时序)因为不寻常的情况被打破。理论分析与经验测试同样重要。
## 本章小结 ## 本章小结
@ -640,19 +640,19 @@ while (true) {
这类**部分失效partial failure** 可能发生的事实是分布式系统的决定性特征。每当软件试图做任何涉及其他节点的事情时,偶尔就有可能会失败,或者随机变慢,或者根本没有响应(最终超时)。在分布式系统中,我们试图在软件中建立**部分失效**的容错机制,这样整个系统在即使某些组成部分被破坏的情况下,也可以继续运行。 这类**部分失效partial failure** 可能发生的事实是分布式系统的决定性特征。每当软件试图做任何涉及其他节点的事情时,偶尔就有可能会失败,或者随机变慢,或者根本没有响应(最终超时)。在分布式系统中,我们试图在软件中建立**部分失效**的容错机制,这样整个系统在即使某些组成部分被破坏的情况下,也可以继续运行。
为了容忍错误,第一步是**检测**它们,但即使这样也很难。大多数系统没有检测节点是否发生故障的准确机制,所以大多数分布式算法依靠**超时**来确定远程节点是否仍然可用。但是超时无法区分网络失效和节点失效并且可变的网络延迟有时会导致节点被错误地怀疑发生故障。此外有时一个节点可能处于降级状态例如由于驱动程序错误千兆网卡可能突然下降到1 Kb/s的吞吐量【94】。这样一个“跛行”而不是死掉的节点可能比一个干净的失效节点更难处理。 为了容忍错误,第一步是**检测**它们,但即使这样也很难。大多数系统没有检测节点是否发生故障的准确机制,所以大多数分布式算法依靠**超时**来确定远程节点是否仍然可用。但是超时无法区分网络失效和节点失效并且可变的网络延迟有时会导致节点被错误地怀疑发生故障。此外有时一个节点可能处于降级状态例如由于驱动程序错误千兆网卡可能突然下降到1 Kb/s的吞吐量【94】。这样一个“跛行”而不是死掉的节点可能比一个干净的失效节点更难处理。
一旦检测到故障,使系统容忍它也并不容易:没有全局变量,没有共享内存,没有共同的知识,或机器之间任何其他种类的共享状态。节点甚至不能就现在是什么时间达成一致,就不用说更深奥的了。信息从一个节点流向另一个节点的唯一方法是通过不可靠的网络发送信息。重大决策不能由一个节点安全地完成,因此我们需要一个能从其他节点获得帮助的协议,并争取达到法定人数以达成一致。 一旦检测到故障,使系统容忍它也并不容易:没有全局变量,没有共享内存,没有共同的知识,或机器之间任何其他种类的共享状态。节点甚至不能就现在是什么时间达成一致,就不用说更深奥的了。信息从一个节点流向另一个节点的唯一方法是通过不可靠的网络发送信息。重大决策不能由一个节点安全地完成,因此我们需要一个能从其他节点获得帮助的协议,并争取达到法定人数以达成一致。
如果你习惯于在理想化的数学完美的单机环境同一个操作总能确定地返回相同的结果中编写软件那么转向分布式系统的凌乱的物理现实可能会有些令人震惊。相反如果能够在单台计算机上解决一个问题那么分布式系统工程师通常会认为这个问题是平凡的【5】现在单个计算机确实可以做很多事情【95】。如果你可以避免打开潘多拉的盒子把东西放在一台机器上那么通常是值得的。 如果你习惯于在理想化的数学完美的单机环境同一个操作总能确定地返回相同的结果中编写软件那么转向分布式系统的凌乱的物理现实可能会有些令人震惊。相反如果能够在单台计算机上解决一个问题那么分布式系统工程师通常会认为这个问题是平凡的【5】现在单个计算机确实可以做很多事情【95】。如果你可以避免打开潘多拉的盒子把东西放在一台机器上那么通常是值得的。
但是,正如在[第二部分](part-ii.md)的介绍中所讨论的那样,可伸缩性并不是使用分布式系统的唯一原因。容错和低延迟(通过将数据放置在距离用户较近的地方)是同等重要的目标,而这些不能用单个节点实现。 但是,正如在[第二部分](part-ii.md)的介绍中所讨论的那样,可伸缩性并不是使用分布式系统的唯一原因。容错和低延迟(通过将数据放置在距离用户较近的地方)是同等重要的目标,而这些不能用单个节点实现。
在本章中,我们也转换了几次话题,探讨了网络、时钟和进程的不可靠性是否是不可避免的自然规律。我们看到这并不是:有可能给网络提供硬实时的响应保证和有限的延迟,但是这样做非常昂贵,且导致硬件资源的利用率降低。大多数非安全关键系统会选择**便宜而不可靠**,而不是**昂贵和可靠**。 在本章中,我们也转换了几次话题,探讨了网络、时钟和进程的不可靠性是否是不可避免的自然规律。我们看到这并不是:有可能给网络提供硬实时的响应保证和有限的延迟,但是这样做非常昂贵,且导致硬件资源的利用率降低。大多数非安全关键系统会选择**便宜而不可靠**,而不是**昂贵和可靠**。
我们还谈到了超级计算机,它们采用可靠的组件,因此当组件发生故障时必须完全停止并重新启动。相比之下,分布式系统可以永久运行而不会在服务层面中断,因为所有的错误和维护都可以在节点级别进行处理——至少在理论上是如此。 (实际上,如果一个错误的配置变更被应用到所有的节点,仍然会使分布式系统瘫痪)。 我们还谈到了超级计算机,它们采用可靠的组件,因此当组件发生故障时必须完全停止并重新启动。相比之下,分布式系统可以永久运行而不会在服务层面中断,因为所有的错误和维护都可以在节点级别进行处理——至少在理论上是如此。 (实际上,如果一个错误的配置变更被应用到所有的节点,仍然会使分布式系统瘫痪)。
本章一直在讲存在的问题,给我们展现了一幅黯淡的前景。在[下一章](ch9.md)中,我们将继续讨论解决方案,并讨论一些旨在解决分布式系统中所有问题的算法。 本章一直在讲存在的问题,给我们展现了一幅黯淡的前景。在[下一章](ch9.md)中,我们将继续讨论解决方案,并讨论一些旨在解决分布式系统中所有问题的算法。

509
ch9.md

File diff suppressed because it is too large Load Diff

View File

@ -53,27 +53,27 @@ In this chapter, we went particularly deep into the topic of concurrency control
***Dirty reads*** ***Dirty reads***
One client reads another clients writes before they have been committed. The read committed isolation level and stronger levels prevent dirty reads. One client reads another clients writes before they have been committed. The read committed isolation level and stronger levels prevent dirty reads.
***Dirty writes*** ***Dirty writes***
One client overwrites data that another client has written, but not yet committed. Almost all transaction implementations prevent dirty writes. One client overwrites data that another client has written, but not yet committed. Almost all transaction implementations prevent dirty writes.
***Read skew (nonrepeatable reads)*** ***Read skew (nonrepeatable reads)***
A client sees different parts of the database at different points in time. This issue is most commonly prevented with snapshot isolation, which allows a transaction to read from a consistent snapshot at one point in time. It is usually implemented with *multi-version concurrency control* (MVCC). A client sees different parts of the database at different points in time. This issue is most commonly prevented with snapshot isolation, which allows a transaction to read from a consistent snapshot at one point in time. It is usually implemented with *multi-version concurrency control* (MVCC).
***Lost updates*** ***Lost updates***
Two clients concurrently perform a read-modify-write cycle. One overwrites the others write without incorporating its changes, so data is lost. Some implemen tations of snapshot isolation prevent this anomaly automatically, while others require a manual lock (SELECT FOR UPDATE). Two clients concurrently perform a read-modify-write cycle. One overwrites the others write without incorporating its changes, so data is lost. Some implemen tations of snapshot isolation prevent this anomaly automatically, while others require a manual lock (SELECT FOR UPDATE).
***Write skew*** ***Write skew***
A transaction reads something, makes a decision based on the value it saw, and writes the decision to the database. However, by the time the write is made, the premise of the decision is no longer true. Only serializable isolation prevents this anomaly. A transaction reads something, makes a decision based on the value it saw, and writes the decision to the database. However, by the time the write is made, the premise of the decision is no longer true. Only serializable isolation prevents this anomaly.
***Phantom reads*** ***Phantom reads***
A transaction reads objects that match some search condition. Another client makes a write that affects the results of that search. Snapshot isolation prevents straightforward phantom reads, but phantoms in the context of write skew require special treatment, such as index-range locks. A transaction reads objects that match some search condition. Another client makes a write that affects the results of that search. Snapshot isolation prevents straightforward phantom reads, but phantoms in the context of write skew require special treatment, such as index-range locks.

View File

@ -41,27 +41,27 @@ We saw that achieving consensus means deciding something in such a way that all
***Linearizable compare-and-set registers*** ***Linearizable compare-and-set registers***
The register needs to atomically *decide* whether to set its value, based on whether its current value equals the parameter given in the operation. The register needs to atomically *decide* whether to set its value, based on whether its current value equals the parameter given in the operation.
***Atomic transaction commit*** ***Atomic transaction commit***
A database must *decide* whether to commit or abort a distributed transaction. A database must *decide* whether to commit or abort a distributed transaction.
***Total order broadcast*** ***Total order broadcast***
The messaging system must *decide* on the order in which to deliver messages. The messaging system must *decide* on the order in which to deliver messages.
***Locks and leases*** ***Locks and leases***
When several clients are racing to grab a lock or lease, the lock *decides* which one successfully acquired it. When several clients are racing to grab a lock or lease, the lock *decides* which one successfully acquired it.
***Membership/coordination service*** ***Membership/coordination service***
Given a failure detector (e.g., timeouts), the system must *decide* which nodes are alive, and which should be considered dead because their sessions timed out. Given a failure detector (e.g., timeouts), the system must *decide* which nodes are alive, and which should be considered dead because their sessions timed out.
***Uniqueness constraint*** ***Uniqueness constraint***
When several transactions concurrently try to create conflicting records with the same key, the constraint must *decide* which one to allow and which should fail with a constraint violation. When several transactions concurrently try to create conflicting records with the same key, the constraint must *decide* which one to allow and which should fail with a constraint violation.

View File

@ -53,11 +53,11 @@
***复制Replication*** ***复制Replication***
在几个不同的节点上保存数据的相同副本,可能放在不同的位置。 复制提供了冗余:如果一些节点不可用,剩余的节点仍然可以提供数据服务。 复制也有助于改善性能。 [第五章](ch5.md)将讨论复制。 在几个不同的节点上保存数据的相同副本,可能放在不同的位置。 复制提供了冗余:如果一些节点不可用,剩余的节点仍然可以提供数据服务。 复制也有助于改善性能。 [第五章](ch5.md)将讨论复制。
***分区 (Partitioning)*** ***分区 (Partitioning)***
将一个大型数据库拆分成较小的子集(称为**分区partitions**),从而不同的分区可以指派给不同的**节点node**(亦称**分片shard**)。 [第六章](ch6.md)将讨论分区。 将一个大型数据库拆分成较小的子集(称为**分区partitions**),从而不同的分区可以指派给不同的**节点node**(亦称**分片shard**)。 [第六章](ch6.md)将讨论分区。
复制和分区是不同的机制,但它们经常同时使用。如[图II-1](img/figii-1.png)所示。 复制和分区是不同的机制,但它们经常同时使用。如[图II-1](img/figii-1.png)所示。

View File

@ -22,51 +22,51 @@
如果這些功能聽上去平淡無奇,那是因為這些 **資料系統data system** 是非常成功的抽象:我們一直不假思索地使用它們並習以為常。絕大多數工程師不會幻想從零開始編寫儲存引擎,因為在開發應用時,資料庫已經是足夠完美的工具了。 如果這些功能聽上去平淡無奇,那是因為這些 **資料系統data system** 是非常成功的抽象:我們一直不假思索地使用它們並習以為常。絕大多數工程師不會幻想從零開始編寫儲存引擎,因為在開發應用時,資料庫已經是足夠完美的工具了。
但現實沒有這麼簡單。不同的應用有著不同的需求,因而資料庫系統也是百花齊放,有著各式各樣的特性。實現快取有很多種手段,建立搜尋索引也有好幾種方法,諸如此類。因此在開發應用前,我們依然有必要先弄清楚最適合手頭工作的工具和方法。而且當單個工具解決不了你的問題時,組合使用這些工具可能還是有些難度的。 但現實沒有這麼簡單。不同的應用有著不同的需求,因而資料庫系統也是百花齊放,有著各式各樣的特性。實現快取有很多種手段,建立搜尋索引也有好幾種方法,諸如此類。因此在開發應用前,我們依然有必要先弄清楚最適合手頭工作的工具和方法。而且當單個工具解決不了你的問題時,組合使用這些工具可能還是有些難度的。
本書將是一趟關於資料系統原理、實踐與應用的旅程,並講述了設計資料密集型應用的方法。我們將探索不同工具之間的共性與特性,以及各自的實現原理。 本書將是一趟關於資料系統原理、實踐與應用的旅程,並講述了設計資料密集型應用的方法。我們將探索不同工具之間的共性與特性,以及各自的實現原理。
本章將從我們所要實現的基礎目標開始:可靠、可伸縮、可維護的資料系統。我們將澄清這些詞語的含義,概述考量這些目標的方法。並回顧一些後續章節所需的基礎知識。在接下來的章節中我們將抽絲剝繭,研究設計資料密集型應用時可能遇到的設計決策。 本章將從我們所要實現的基礎目標開始:可靠、可伸縮、可維護的資料系統。我們將澄清這些詞語的含義,概述考量這些目標的方法。並回顧一些後續章節所需的基礎知識。在接下來的章節中我們將抽絲剝繭,研究設計資料密集型應用時可能遇到的設計決策。
## 關於資料系統的思考 ## 關於資料系統的思考
我們通常認為,資料庫、訊息佇列、快取等工具分屬於幾個差異顯著的類別。雖然資料庫和訊息隊列表面上有一些相似性——它們都會儲存一段時間的資料——但它們有迥然不同的訪問模式,這意味著迥異的效能特徵和實現手段。 我們通常認為,資料庫、訊息佇列、快取等工具分屬於幾個差異顯著的類別。雖然資料庫和訊息隊列表面上有一些相似性——它們都會儲存一段時間的資料——但它們有迥然不同的訪問模式,這意味著迥異的效能特徵和實現手段。
那我們為什麼要把這些東西放在 **資料系統data system** 的總稱之下混為一談呢? 那我們為什麼要把這些東西放在 **資料系統data system** 的總稱之下混為一談呢?
近些年來出現了許多新的資料儲存工具與資料處理工具。它們針對不同應用場景進行最佳化因此不再適合生硬地歸入傳統類別【1】。類別之間的界限變得越來越模糊例如資料儲存可以被當成訊息佇列用Redis訊息佇列則帶有類似資料庫的持久保證Apache Kafka 近些年來出現了許多新的資料儲存工具與資料處理工具。它們針對不同應用場景進行最佳化因此不再適合生硬地歸入傳統類別【1】。類別之間的界限變得越來越模糊例如資料儲存可以被當成訊息佇列用Redis訊息佇列則帶有類似資料庫的持久保證Apache Kafka
其次,越來越多的應用程式有著各種嚴格而廣泛的要求,單個工具不足以滿足所有的資料處理和儲存需求。取而代之的是,總體工作被拆分成一系列能被單個工具高效完成的任務,並透過應用程式碼將它們縫合起來。 其次,越來越多的應用程式有著各種嚴格而廣泛的要求,單個工具不足以滿足所有的資料處理和儲存需求。取而代之的是,總體工作被拆分成一系列能被單個工具高效完成的任務,並透過應用程式碼將它們縫合起來。
例如如果將快取應用管理的快取層Memcached或同類產品和全文搜尋全文搜尋伺服器例如Elasticsearch或Solr功能從主資料庫剝離出來那麼使快取/索引與主資料庫保持同步通常是應用程式碼的責任。[圖1-1](../img/fig1-1.png) 給出了這種架構可能的樣子(細節將在後面的章節中詳細介紹)。 例如如果將快取應用管理的快取層Memcached或同類產品和全文搜尋全文搜尋伺服器例如Elasticsearch或Solr功能從主資料庫剝離出來那麼使快取/索引與主資料庫保持同步通常是應用程式碼的責任。[圖1-1](../img/fig1-1.png) 給出了這種架構可能的樣子(細節將在後面的章節中詳細介紹)。
![](../img/fig1-1.png) ![](../img/fig1-1.png)
**圖1-1 一個可能的組合使用多個元件的資料系統架構** **圖1-1 一個可能的組合使用多個元件的資料系統架構**
當你將多個工具組合在一起提供服務時,服務的介面或**應用程式程式設計介面API, Application Programming Interface** 通常向客戶端隱藏這些實現細節。現在,你基本上已經使用較小的通用元件建立了一個全新的、專用的資料系統。這個新的複合資料系統可能會提供特定的保證,例如:快取在寫入時會作廢或更新,以便外部客戶端獲取一致的結果。現在你不僅是應用程式開發人員,還是資料系統設計人員了。 當你將多個工具組合在一起提供服務時,服務的介面或**應用程式程式設計介面API, Application Programming Interface** 通常向客戶端隱藏這些實現細節。現在,你基本上已經使用較小的通用元件建立了一個全新的、專用的資料系統。這個新的複合資料系統可能會提供特定的保證,例如:快取在寫入時會作廢或更新,以便外部客戶端獲取一致的結果。現在你不僅是應用程式開發人員,還是資料系統設計人員了。
設計資料系統或服務時可能會遇到很多棘手的問題例如當系統出問題時如何確保資料的正確性和完整性當部分系統退化降級時如何為客戶提供始終如一的良好效能當負載增加時如何擴容應對什麼樣的API才是好的API 設計資料系統或服務時可能會遇到很多棘手的問題例如當系統出問題時如何確保資料的正確性和完整性當部分系統退化降級時如何為客戶提供始終如一的良好效能當負載增加時如何擴容應對什麼樣的API才是好的API
影響資料系統設計的因素很多,包括參與人員的技能和經驗、歷史遺留問題、系統路徑依賴、交付時限、公司的風險容忍度、監管約束等,這些因素都需要具體問題具體分析。 影響資料系統設計的因素很多,包括參與人員的技能和經驗、歷史遺留問題、系統路徑依賴、交付時限、公司的風險容忍度、監管約束等,這些因素都需要具體問題具體分析。
本書著重討論三個在大多數軟體系統中都很重要的問題: 本書著重討論三個在大多數軟體系統中都很重要的問題:
***可靠性Reliability*** ***可靠性Reliability***
系統在**困境adversity**(硬體故障、軟體故障、人為錯誤)中仍可正常工作(正確完成功能,並能達到期望的效能水準)。請參閱“[可靠性](#可靠性)”。 系統在**困境adversity**(硬體故障、軟體故障、人為錯誤)中仍可正常工作(正確完成功能,並能達到期望的效能水準)。請參閱“[可靠性](#可靠性)”。
***可伸縮性Scalability*** ***可伸縮性Scalability***
有合理的辦法應對系統的增長(資料量、流量、複雜性)。請參閱“[可伸縮性](#可伸縮性)”。 有合理的辦法應對系統的增長(資料量、流量、複雜性)。請參閱“[可伸縮性](#可伸縮性)”。
***可維護性Maintainability*** ***可維護性Maintainability***
許多不同的人(工程師、運維)在不同的生命週期,都能高效地在系統上工作(使系統保持現有行為,並適應新的應用場景)。請參閱“[可維護性](#可維護性)”。 許多不同的人(工程師、運維)在不同的生命週期,都能高效地在系統上工作(使系統保持現有行為,並適應新的應用場景)。請參閱“[可維護性](#可維護性)”。
人們經常追求這些詞彙,卻沒有清楚理解它們到底意味著什麼。為了工程的嚴謹性,本章的剩餘部分將探討可靠性、可伸縮性、可維護性的含義。為實現這些目標而使用的各種技術,架構和演算法將在後續的章節中研究。 人們經常追求這些詞彙,卻沒有清楚理解它們到底意味著什麼。為了工程的嚴謹性,本章的剩餘部分將探討可靠性、可伸縮性、可維護性的含義。為實現這些目標而使用的各種技術,架構和演算法將在後續的章節中研究。
## 可靠性 ## 可靠性
@ -80,35 +80,35 @@
如果所有這些在一起意味著“正確工作”,那麼可以把可靠性粗略理解為“即使出現問題,也能繼續正確工作”。 如果所有這些在一起意味著“正確工作”,那麼可以把可靠性粗略理解為“即使出現問題,也能繼續正確工作”。
造成錯誤的原因叫做**故障fault**,能預料並應對故障的系統特性可稱為**容錯fault-tolerant** 或**韌性resilient**。“**容錯**”一詞可能會產生誤導,因為它暗示著系統可以容忍所有可能的錯誤,但在實際中這是不可能的。比方說,如果整個地球(及其上的所有伺服器)都被黑洞吞噬了,想要容忍這種錯誤,需要把網路託管到太空中——這種預算能不能批准就祝你好運了。所以在討論容錯時,只有談論特定型別的錯誤才有意義。 造成錯誤的原因叫做**故障fault**,能預料並應對故障的系統特性可稱為**容錯fault-tolerant** 或**韌性resilient**。“**容錯**”一詞可能會產生誤導,因為它暗示著系統可以容忍所有可能的錯誤,但在實際中這是不可能的。比方說,如果整個地球(及其上的所有伺服器)都被黑洞吞噬了,想要容忍這種錯誤,需要把網路託管到太空中——這種預算能不能批准就祝你好運了。所以在討論容錯時,只有談論特定型別的錯誤才有意義。
注意**故障fault** 不同於**失效failure**【2】。**故障**通常定義為系統的一部分狀態偏離其標準,而**失效**則是系統作為一個整體停止向用戶提供服務。故障的概率不可能降到零,因此最好設計容錯機制以防因**故障**而導致**失效**。本書中我們將介紹幾種用不可靠的部件構建可靠系統的技術。 注意**故障fault** 不同於**失效failure**【2】。**故障**通常定義為系統的一部分狀態偏離其標準,而**失效**則是系統作為一個整體停止向用戶提供服務。故障的概率不可能降到零,因此最好設計容錯機制以防因**故障**而導致**失效**。本書中我們將介紹幾種用不可靠的部件構建可靠系統的技術。
反直覺的是,在這類容錯系統中,透過故意觸發來**提高**故障率是有意義的例如在沒有警告的情況下隨機地殺死單個程序。許多高危漏洞實際上是由糟糕的錯誤處理導致的【3】因此我們可以透過故意引發故障來確保容錯機制不斷執行並接受考驗從而提高故障自然發生時系統能正確處理的信心。Netflix公司的*Chaos Monkey*【4】就是這種方法的一個例子。 反直覺的是,在這類容錯系統中,透過故意觸發來**提高**故障率是有意義的例如在沒有警告的情況下隨機地殺死單個程序。許多高危漏洞實際上是由糟糕的錯誤處理導致的【3】因此我們可以透過故意引發故障來確保容錯機制不斷執行並接受考驗從而提高故障自然發生時系統能正確處理的信心。Netflix公司的*Chaos Monkey*【4】就是這種方法的一個例子。
儘管比起**阻止錯誤prevent error**,我們通常更傾向於**容忍錯誤**。但也有**預防勝於治療**的情況(比如不存在治療方法時)。安全問題就屬於這種情況。例如,如果攻擊者破壞了系統,並獲取了敏感資料,這種事是撤銷不了的。但本書主要討論的是可以恢復的故障種類,正如下面幾節所述。 儘管比起**阻止錯誤prevent error**,我們通常更傾向於**容忍錯誤**。但也有**預防勝於治療**的情況(比如不存在治療方法時)。安全問題就屬於這種情況。例如,如果攻擊者破壞了系統,並獲取了敏感資料,這種事是撤銷不了的。但本書主要討論的是可以恢復的故障種類,正如下面幾節所述。
### 硬體故障 ### 硬體故障
當想到系統失效的原因時,**硬體故障hardware faults** 總會第一個進入腦海。硬碟崩潰、記憶體出錯、機房斷電、有人拔錯網線……任何與大型資料中心打過交道的人都會告訴你:一旦你擁有很多機器,這些事情**總**會發生! 當想到系統失效的原因時,**硬體故障hardware faults** 總會第一個進入腦海。硬碟崩潰、記憶體出錯、機房斷電、有人拔錯網線……任何與大型資料中心打過交道的人都會告訴你:一旦你擁有很多機器,這些事情**總**會發生!
據報道稱,硬碟的 **平均無故障時間MTTF mean time to failure** 約為10到50年【5】【6】。因此從數學期望上講在擁有10000個磁碟的儲存叢集上平均每天會有1個磁碟出故障。 據報道稱,硬碟的 **平均無故障時間MTTF mean time to failure** 約為10到50年【5】【6】。因此從數學期望上講在擁有10000個磁碟的儲存叢集上平均每天會有1個磁碟出故障。
為了減少系統的故障率第一反應通常都是增加單個硬體的冗餘度例如磁碟可以組建RAID伺服器可能有雙路電源和熱插拔CPU資料中心可能有電池和柴油發電機作為後備電源某個元件掛掉時冗餘元件可以立刻接管。這種方法雖然不能完全防止由硬體問題導致的系統失效但它簡單易懂通常也足以讓機器不間斷執行很多年。 為了減少系統的故障率第一反應通常都是增加單個硬體的冗餘度例如磁碟可以組建RAID伺服器可能有雙路電源和熱插拔CPU資料中心可能有電池和柴油發電機作為後備電源某個元件掛掉時冗餘元件可以立刻接管。這種方法雖然不能完全防止由硬體問題導致的系統失效但它簡單易懂通常也足以讓機器不間斷執行很多年。
直到最近,硬體冗餘對於大多數應用來說已經足夠了,它使單臺機器完全失效變得相當罕見。只要你能快速地把備份恢復到新機器上,故障停機時間對大多數應用而言都算不上災難性的。只有少量高可用性至關重要的應用才會要求有多套硬體冗餘。 直到最近,硬體冗餘對於大多數應用來說已經足夠了,它使單臺機器完全失效變得相當罕見。只要你能快速地把備份恢復到新機器上,故障停機時間對大多數應用而言都算不上災難性的。只有少量高可用性至關重要的應用才會要求有多套硬體冗餘。
但是隨著資料量和應用計算需求的增加,越來越多的應用開始大量使用機器,這會相應地增加硬體故障率。此外在一些雲平臺(**如亞馬遜網路服務AWS, Amazon Web Services**虛擬機器例項不可用卻沒有任何警告也是很常見的【7】因為雲平臺的設計就是優先考慮**靈活性flexibility** 和**彈性elasticity**[^i],而不是單機可靠性。 但是隨著資料量和應用計算需求的增加,越來越多的應用開始大量使用機器,這會相應地增加硬體故障率。此外在一些雲平臺(**如亞馬遜網路服務AWS, Amazon Web Services**虛擬機器例項不可用卻沒有任何警告也是很常見的【7】因為雲平臺的設計就是優先考慮**靈活性flexibility** 和**彈性elasticity**[^i],而不是單機可靠性。
如果在硬體冗餘的基礎上進一步引入軟體容錯機制,那麼系統在容忍整個(單臺)機器故障的道路上就更進一步了。這樣的系統也有運維上的便利,例如:如果需要重啟機器(例如應用作業系統安全補丁),單伺服器系統就需要計劃停機。而允許機器失效的系統則可以一次修復一個節點,無需整個系統停機。 如果在硬體冗餘的基礎上進一步引入軟體容錯機制,那麼系統在容忍整個(單臺)機器故障的道路上就更進一步了。這樣的系統也有運維上的便利,例如:如果需要重啟機器(例如應用作業系統安全補丁),單伺服器系統就需要計劃停機。而允許機器失效的系統則可以一次修復一個節點,無需整個系統停機。
[^i]: 在[應對負載的方法](#應對負載的方法)一節定義 [^i]: 在[應對負載的方法](#應對負載的方法)一節定義
### 軟體錯誤 ### 軟體錯誤
我們通常認為硬體故障是隨機的、相互獨立的:一臺機器的磁碟失效並不意味著另一臺機器的磁碟也會失效。大量硬體元件不可能同時發生故障,除非它們存在比較弱的相關性(同樣的原因導致關聯性錯誤,例如伺服器機架的溫度)。 我們通常認為硬體故障是隨機的、相互獨立的:一臺機器的磁碟失效並不意味著另一臺機器的磁碟也會失效。大量硬體元件不可能同時發生故障,除非它們存在比較弱的相關性(同樣的原因導致關聯性錯誤,例如伺服器機架的溫度)。
另一類錯誤是內部的**系統性錯誤systematic error**【7】。這類錯誤難以預料而且因為是跨節點相關的所以比起不相關的硬體故障往往可能造成更多的**系統失效**【5】。例子包括 另一類錯誤是內部的**系統性錯誤systematic error**【7】。這類錯誤難以預料而且因為是跨節點相關的所以比起不相關的硬體故障往往可能造成更多的**系統失效**【5】。例子包括
* 接受特定的錯誤輸入便導致所有應用伺服器例項崩潰的BUG。例如2012年6月30日的閏秒由於Linux核心中的一個錯誤許多應用同時掛掉了。 * 接受特定的錯誤輸入便導致所有應用伺服器例項崩潰的BUG。例如2012年6月30日的閏秒由於Linux核心中的一個錯誤許多應用同時掛掉了。
* 失控程序會用盡一些共享資源包括CPU時間、記憶體、磁碟空間或網路頻寬。 * 失控程序會用盡一些共享資源包括CPU時間、記憶體、磁碟空間或網路頻寬。
@ -117,13 +117,13 @@
導致這類軟體故障的BUG通常會潛伏很長時間直到被異常情況觸發為止。這種情況意味著軟體對其環境做出了某種假設——雖然這種假設通常來說是正確的但由於某種原因最後不再成立了【11】。 導致這類軟體故障的BUG通常會潛伏很長時間直到被異常情況觸發為止。這種情況意味著軟體對其環境做出了某種假設——雖然這種假設通常來說是正確的但由於某種原因最後不再成立了【11】。
雖然軟體中的系統性故障沒有速效藥,但我們還是有很多小辦法,例如:仔細考慮系統中的假設和互動;徹底的測試;程序隔離;允許程序崩潰並重啟;測量、監控並分析生產環境中的系統行為。如果系統能夠提供一些保證(例如在一個訊息佇列中,進入與發出的訊息數量相等),那麼系統就可以在執行時不斷自檢,並在出現**差異discrepancy** 時報警【12】。 雖然軟體中的系統性故障沒有速效藥,但我們還是有很多小辦法,例如:仔細考慮系統中的假設和互動;徹底的測試;程序隔離;允許程序崩潰並重啟;測量、監控並分析生產環境中的系統行為。如果系統能夠提供一些保證(例如在一個訊息佇列中,進入與發出的訊息數量相等),那麼系統就可以在執行時不斷自檢,並在出現**差異discrepancy** 時報警【12】。
### 人為錯誤 ### 人為錯誤
設計並構建了軟體系統的工程師是人類維持系統執行的運維也是人類。即使他們懷有最大的善意人類也是不可靠的。舉個例子一項關於大型網際網路服務的研究發現運維配置錯誤是導致服務中斷的首要原因而硬體故障伺服器或網路僅導致了10-25的服務中斷【13】。 設計並構建了軟體系統的工程師是人類維持系統執行的運維也是人類。即使他們懷有最大的善意人類也是不可靠的。舉個例子一項關於大型網際網路服務的研究發現運維配置錯誤是導致服務中斷的首要原因而硬體故障伺服器或網路僅導致了10-25的服務中斷【13】。
儘管人類不可靠,但怎麼做才能讓系統變得可靠?最好的系統會組合使用以下幾種辦法: 儘管人類不可靠,但怎麼做才能讓系統變得可靠?最好的系統會組合使用以下幾種辦法:
* 以最小化犯錯機會的方式設計系統。例如精心設計的抽象、API和管理後臺使做對事情更容易搞砸事情更困難。但如果介面限制太多人們就會忽略它們的好處而想辦法繞開。很難正確把握這種微妙的平衡。 * 以最小化犯錯機會的方式設計系統。例如精心設計的抽象、API和管理後臺使做對事情更容易搞砸事情更困難。但如果介面限制太多人們就會忽略它們的好處而想辦法繞開。很難正確把握這種微妙的平衡。
* 將人們最容易犯錯的地方與可能導致失效的地方**解耦decouple**。特別是提供一個功能齊全的非生產環境**沙箱sandbox**,使人們可以在不影響真實使用者的情況下,使用真實資料安全地探索和實驗。 * 將人們最容易犯錯的地方與可能導致失效的地方**解耦decouple**。特別是提供一個功能齊全的非生產環境**沙箱sandbox**,使人們可以在不影響真實使用者的情況下,使用真實資料安全地探索和實驗。
@ -134,37 +134,37 @@
### 可靠性有多重要? ### 可靠性有多重要?
可靠性不僅僅是針對核電站和空中交通管制軟體而言,我們也期望更多平凡的應用能可靠地執行。商務應用中的錯誤會導致生產力損失(也許資料報告不完整還會有法律風險),而電商網站的中斷則可能會導致收入和聲譽的巨大損失。 可靠性不僅僅是針對核電站和空中交通管制軟體而言,我們也期望更多平凡的應用能可靠地執行。商務應用中的錯誤會導致生產力損失(也許資料報告不完整還會有法律風險),而電商網站的中斷則可能會導致收入和聲譽的巨大損失。
即使在“非關鍵”應用中我們也對使用者負有責任。試想一位家長把所有的照片和孩子的影片儲存在你的照片應用裡【15】。如果資料庫突然損壞他們會感覺如何他們可能會知道如何從備份恢復嗎 即使在“非關鍵”應用中我們也對使用者負有責任。試想一位家長把所有的照片和孩子的影片儲存在你的照片應用裡【15】。如果資料庫突然損壞他們會感覺如何他們可能會知道如何從備份恢復嗎
在某些情況下,我們可能會選擇犧牲可靠性來降低開發成本(例如為未經證實的市場開發產品原型)或運營成本(例如利潤率極低的服務),但我們偷工減料時,應該清楚意識到自己在做什麼。 在某些情況下,我們可能會選擇犧牲可靠性來降低開發成本(例如為未經證實的市場開發產品原型)或運營成本(例如利潤率極低的服務),但我們偷工減料時,應該清楚意識到自己在做什麼。
## 可伸縮性 ## 可伸縮性
系統今天能可靠執行,並不意味未來也能可靠執行。服務 **降級degradation** 的一個常見原因是負載增加,例如:系統負載已經從一萬個併發使用者增長到十萬個併發使用者,或者從一百萬增長到一千萬。也許現在處理的資料量級要比過去大得多。 系統今天能可靠執行,並不意味未來也能可靠執行。服務 **降級degradation** 的一個常見原因是負載增加,例如:系統負載已經從一萬個併發使用者增長到十萬個併發使用者,或者從一百萬增長到一千萬。也許現在處理的資料量級要比過去大得多。
**可伸縮性Scalability** 是用來描述系統應對負載增長能力的術語。但是請注意這不是貼在系統上的一維標籤說“X可伸縮”或“Y不可伸縮”是沒有任何意義的。相反討論可伸縮性意味著考慮諸如“如果系統以特定方式增長有什麼選項可以應對增長”和“如何增加計算資源來處理額外的負載”等問題。 **可伸縮性Scalability** 是用來描述系統應對負載增長能力的術語。但是請注意這不是貼在系統上的一維標籤說“X可伸縮”或“Y不可伸縮”是沒有任何意義的。相反討論可伸縮性意味著考慮諸如“如果系統以特定方式增長有什麼選項可以應對增長”和“如何增加計算資源來處理額外的負載”等問題。
### 描述負載 ### 描述負載
在討論增長問題(如果負載加倍會發生什麼?)前,首先要能簡要描述系統的當前負載。負載可以用一些稱為 **負載引數load parameters** 的數字來描述。引數的最佳選擇取決於系統架構它可能是每秒向Web伺服器發出的請求、資料庫中的讀寫比率、聊天室中同時活躍的使用者數量、快取命中率或其他東西。除此之外也許平均情況對你很重要也許你的瓶頸是少數極端場景。 在討論增長問題(如果負載加倍會發生什麼?)前,首先要能簡要描述系統的當前負載。負載可以用一些稱為 **負載引數load parameters** 的數字來描述。引數的最佳選擇取決於系統架構它可能是每秒向Web伺服器發出的請求、資料庫中的讀寫比率、聊天室中同時活躍的使用者數量、快取命中率或其他東西。除此之外也許平均情況對你很重要也許你的瓶頸是少數極端場景。
為了使這個概念更加具體我們以推特在2012年11月釋出的資料【16】為例。推特的兩個主要業務是 為了使這個概念更加具體我們以推特在2012年11月釋出的資料【16】為例。推特的兩個主要業務是
***釋出推文*** ***釋出推文***
使用者可以向其粉絲髮布新訊息(平均 4.6k請求/秒,峰值超過 12k請求/秒)。 使用者可以向其粉絲髮布新訊息(平均 4.6k請求/秒,峰值超過 12k請求/秒)。
***主頁時間線*** ***主頁時間線***
使用者可以查閱他們關注的人釋出的推文300k請求/秒)。 使用者可以查閱他們關注的人釋出的推文300k請求/秒)。
處理每秒12,000次寫入發推文的速率峰值還是很簡單的。然而推特的伸縮性挑戰並不是主要來自推特量而是來自**扇出fan-out**——每個使用者關注了很多人,也被很多人關注。 處理每秒12,000次寫入發推文的速率峰值還是很簡單的。然而推特的伸縮性挑戰並不是主要來自推特量而是來自**扇出fan-out**——每個使用者關注了很多人,也被很多人關注。
[^ii]: 扇出:從電子工程學中借用的術語,它描述了輸入連線到另一個門輸出的邏輯閘數量。 輸出需要提供足夠的電流來驅動所有連線的輸入。 在事務處理系統中,我們使用它來描述為了服務一個傳入請求而需要執行其他服務的請求數量。 [^ii]: 扇出:從電子工程學中借用的術語,它描述了輸入連線到另一個門輸出的邏輯閘數量。 輸出需要提供足夠的電流來驅動所有連線的輸入。 在事務處理系統中,我們使用它來描述為了服務一個傳入請求而需要執行其他服務的請求數量。
@ -191,11 +191,11 @@
推特的第一個版本使用了方法1但系統很難跟上主頁時間線查詢的負載。所以公司轉向了方法2方法2的效果更好因為發推頻率比查詢主頁時間線的頻率幾乎低了兩個數量級所以在這種情況下最好在寫入時做更多的工作而在讀取時做更少的工作。 推特的第一個版本使用了方法1但系統很難跟上主頁時間線查詢的負載。所以公司轉向了方法2方法2的效果更好因為發推頻率比查詢主頁時間線的頻率幾乎低了兩個數量級所以在這種情況下最好在寫入時做更多的工作而在讀取時做更少的工作。
然而方法2的缺點是發推現在需要大量的額外工作。平均來說一條推文會發往約75個關注者所以每秒4.6k的發推寫入變成了對主頁時間線快取每秒345k的寫入。但這個平均值隱藏了使用者粉絲數差異巨大這一現實一些使用者有超過3000萬的粉絲這意味著一條推文就可能會導致主頁時間線快取的3000萬次寫入及時完成這種操作是一個巨大的挑戰 —— 推特嘗試在5秒內向粉絲髮送推文。 然而方法2的缺點是發推現在需要大量的額外工作。平均來說一條推文會發往約75個關注者所以每秒4.6k的發推寫入變成了對主頁時間線快取每秒345k的寫入。但這個平均值隱藏了使用者粉絲數差異巨大這一現實一些使用者有超過3000萬的粉絲這意味著一條推文就可能會導致主頁時間線快取的3000萬次寫入及時完成這種操作是一個巨大的挑戰 —— 推特嘗試在5秒內向粉絲髮送推文。
在推特的例子中,每個使用者粉絲數的分佈(可能按這些使用者的發推頻率來加權)是探討可伸縮性的一個關鍵負載引數,因為它決定了扇出負載。你的應用程式可能具有非常不同的特徵,但可以採用相似的原則來考慮它的負載。 在推特的例子中,每個使用者粉絲數的分佈(可能按這些使用者的發推頻率來加權)是探討可伸縮性的一個關鍵負載引數,因為它決定了扇出負載。你的應用程式可能具有非常不同的特徵,但可以採用相似的原則來考慮它的負載。
推特軼事的最終轉折現在已經穩健地實現了方法2推特逐步轉向了兩種方法的混合。大多數使用者發的推文會被扇出寫入其粉絲主頁時間線快取中。但是少數擁有海量粉絲的使用者即名流會被排除在外。當用戶讀取主頁時間線時分別地獲取出該使用者所關注的每位名流的推文再與使用者的主頁時間線快取合併如方法1所示。這種混合方法能始終如一地提供良好效能。在[第十二章](ch12.md)中我們將重新討論這個例子,這在覆蓋更多技術層面之後。 推特軼事的最終轉折現在已經穩健地實現了方法2推特逐步轉向了兩種方法的混合。大多數使用者發的推文會被扇出寫入其粉絲主頁時間線快取中。但是少數擁有海量粉絲的使用者即名流會被排除在外。當用戶讀取主頁時間線時分別地獲取出該使用者所關注的每位名流的推文再與使用者的主頁時間線快取合併如方法1所示。這種混合方法能始終如一地提供良好效能。在[第十二章](ch12.md)中我們將重新討論這個例子,這在覆蓋更多技術層面之後。
### 描述效能 ### 描述效能
@ -206,7 +206,7 @@
這兩個問題都需要效能資料,所以讓我們簡單地看一下如何描述系統性能。 這兩個問題都需要效能資料,所以讓我們簡單地看一下如何描述系統性能。
對於Hadoop這樣的批處理系統通常關心的是**吞吐量throughput**,即每秒可以處理的記錄數量,或者在特定規模資料集上執行作業的總時間[^iii]。對於線上系統,通常更重要的是服務的**響應時間response time**,即客戶端傳送請求到接收響應之間的時間。 對於Hadoop這樣的批處理系統通常關心的是**吞吐量throughput**,即每秒可以處理的記錄數量,或者在特定規模資料集上執行作業的總時間[^iii]。對於線上系統,通常更重要的是服務的**響應時間response time**,即客戶端傳送請求到接收響應之間的時間。
[^iii]: 理想情況下,批次作業的執行時間是資料集的大小除以吞吐量。 在實踐中由於資料傾斜(資料不是均勻分佈在每個工作程序中),需要等待最慢的任務完成,所以執行時間往往更長。 [^iii]: 理想情況下,批次作業的執行時間是資料集的大小除以吞吐量。 在實踐中由於資料傾斜(資料不是均勻分佈在每個工作程序中),需要等待最慢的任務完成,所以執行時間往往更長。
@ -214,31 +214,31 @@
> >
> **延遲latency****響應時間response time** 經常用作同義詞,但實際上它們並不一樣。響應時間是客戶所看到的,除了實際處理請求的時間( **服務時間service time** )之外,還包括網路延遲和排隊延遲。延遲是某個請求等待處理的**持續時長**,在此期間它處於 **休眠latent** 狀態並等待服務【17】。 > **延遲latency****響應時間response time** 經常用作同義詞,但實際上它們並不一樣。響應時間是客戶所看到的,除了實際處理請求的時間( **服務時間service time** )之外,還包括網路延遲和排隊延遲。延遲是某個請求等待處理的**持續時長**,在此期間它處於 **休眠latent** 狀態並等待服務【17】。
即使不斷重複傳送同樣的請求,每次得到的響應時間也都會略有不同。現實世界的系統會處理各式各樣的請求,響應時間可能會有很大差異。因此我們需要將響應時間視為一個可以測量的數值**分佈distribution**,而不是單個數值。 即使不斷重複傳送同樣的請求,每次得到的響應時間也都會略有不同。現實世界的系統會處理各式各樣的請求,響應時間可能會有很大差異。因此我們需要將響應時間視為一個可以測量的數值**分佈distribution**,而不是單個數值。
在[圖1-4](../img/fig1-4.png)中每個灰條代表一次對服務的請求其高度表示請求花費了多長時間。大多數請求是相當快的但偶爾會出現需要更長的時間的異常值。這也許是因為緩慢的請求實質上開銷更大例如它們可能會處理更多的資料。但即使你認為所有請求都花費相同時間的情況下隨機的附加延遲也會導致結果變化例如上下文切換到後臺程序網路資料包丟失與TCP重傳垃圾收集暫停強制從磁碟讀取的頁面錯誤伺服器機架中的震動【18】還有很多其他原因。 在[圖1-4](../img/fig1-4.png)中每個灰條代表一次對服務的請求其高度表示請求花費了多長時間。大多數請求是相當快的但偶爾會出現需要更長的時間的異常值。這也許是因為緩慢的請求實質上開銷更大例如它們可能會處理更多的資料。但即使你認為所有請求都花費相同時間的情況下隨機的附加延遲也會導致結果變化例如上下文切換到後臺程序網路資料包丟失與TCP重傳垃圾收集暫停強制從磁碟讀取的頁面錯誤伺服器機架中的震動【18】還有很多其他原因。
![](../img/fig1-4.png) ![](../img/fig1-4.png)
**圖1-4 展示了一個服務100次請求響應時間的均值與百分位數** **圖1-4 展示了一個服務100次請求響應時間的均值與百分位數**
通常報表都會展示服務的平均響應時間。 (嚴格來講“平均”一詞並不指代任何特定公式,但實際上它通常被理解為**算術平均值arithmetic mean**:給定 n 個值,加起來除以 n )。然而如果你想知道“**典型typical**”響應時間,那麼平均值並不是一個非常好的指標,因為它不能告訴你有多少使用者實際上經歷了這個延遲。 通常報表都會展示服務的平均響應時間。 (嚴格來講“平均”一詞並不指代任何特定公式,但實際上它通常被理解為**算術平均值arithmetic mean**:給定 n 個值,加起來除以 n )。然而如果你想知道“**典型typical**”響應時間,那麼平均值並不是一個非常好的指標,因為它不能告訴你有多少使用者實際上經歷了這個延遲。
通常使用**百分位點percentiles** 會更好。如果將響應時間列表按最快到最慢排序,那麼**中位數median** 就在正中間舉個例子如果你的響應時間中位數是200毫秒這意味著一半請求的返回時間少於200毫秒另一半比這個要長。 通常使用**百分位點percentiles** 會更好。如果將響應時間列表按最快到最慢排序,那麼**中位數median** 就在正中間舉個例子如果你的響應時間中位數是200毫秒這意味著一半請求的返回時間少於200毫秒另一半比這個要長。
如果想知道典型場景下使用者需要等待多長時間那麼中位數是一個好的度量標準一半使用者請求的響應時間少於響應時間的中位數另一半服務時間比中位數長。中位數也被稱為第50百分位點有時縮寫為p50。注意中位數是關於單個請求的如果使用者同時發出幾個請求在一個會話過程中或者由於一個頁面中包含了多個資源則至少一個請求比中位數慢的概率遠大於50 如果想知道典型場景下使用者需要等待多長時間那麼中位數是一個好的度量標準一半使用者請求的響應時間少於響應時間的中位數另一半服務時間比中位數長。中位數也被稱為第50百分位點有時縮寫為p50。注意中位數是關於單個請求的如果使用者同時發出幾個請求在一個會話過程中或者由於一個頁面中包含了多個資源則至少一個請求比中位數慢的概率遠大於50
為了弄清異常值有多糟糕可以看看更高的百分位點例如第95、99和99.9百分位點縮寫為p95p99和p999。它們意味著9599或99.9的請求響應時間要比該閾值快例如如果第95百分位點響應時間是1.5秒則意味著100個請求中的95個響應時間快於1.5秒而100個請求中的5個響應時間超過1.5秒。如[圖1-4](../img/fig1-4.png)所示。 為了弄清異常值有多糟糕可以看看更高的百分位點例如第95、99和99.9百分位點縮寫為p95p99和p999。它們意味著9599或99.9的請求響應時間要比該閾值快例如如果第95百分位點響應時間是1.5秒則意味著100個請求中的95個響應時間快於1.5秒而100個請求中的5個響應時間超過1.5秒。如[圖1-4](../img/fig1-4.png)所示。
響應時間的高百分位點(也稱為**尾部延遲tail latencies**非常重要因為它們直接影響使用者的服務體驗。例如亞馬遜在描述內部服務的響應時間要求時以99.9百分位點為準,即使它隻影響一千個請求中的一個。這是因為請求響應最慢的客戶往往也是資料最多的客戶,也可以說是最有價值的客戶 —— 因為他們掏錢了【19】。保證網站響應迅速對於保持客戶的滿意度非常重要亞馬遜觀察到響應時間增加100毫秒銷售量就減少1【20】而另一些報告說慢 1 秒鐘會讓客戶滿意度指標減少16%【2122】。 響應時間的高百分位點(也稱為**尾部延遲tail latencies**非常重要因為它們直接影響使用者的服務體驗。例如亞馬遜在描述內部服務的響應時間要求時以99.9百分位點為準,即使它隻影響一千個請求中的一個。這是因為請求響應最慢的客戶往往也是資料最多的客戶,也可以說是最有價值的客戶 —— 因為他們掏錢了【19】。保證網站響應迅速對於保持客戶的滿意度非常重要亞馬遜觀察到響應時間增加100毫秒銷售量就減少1【20】而另一些報告說慢 1 秒鐘會讓客戶滿意度指標減少16%【2122】。
另一方面最佳化第99.99百分位點(一萬個請求中最慢的一個)被認為太昂貴了,不能為亞馬遜的目標帶來足夠好處。減小高百分位點處的響應時間相當困難,因為它很容易受到隨機事件的影響,這超出了控制範圍,而且效益也很小。 另一方面最佳化第99.99百分位點(一萬個請求中最慢的一個)被認為太昂貴了,不能為亞馬遜的目標帶來足夠好處。減小高百分位點處的響應時間相當困難,因為它很容易受到隨機事件的影響,這超出了控制範圍,而且效益也很小。
百分位點通常用於**服務級別目標SLO, service level objectives** 和**服務級別協議SLA, service level agreements**,即定義服務預期效能和可用性的合同。 SLA可能會宣告如果服務響應時間的中位數小於200毫秒且99.9百分位點低於1秒則認為服務工作正常如果響應時間更長就認為服務不達標。這些指標為客戶設定了期望值並允許客戶在SLA未達標的情況下要求退款。 百分位點通常用於**服務級別目標SLO, service level objectives** 和**服務級別協議SLA, service level agreements**,即定義服務預期效能和可用性的合同。 SLA可能會宣告如果服務響應時間的中位數小於200毫秒且99.9百分位點低於1秒則認為服務工作正常如果響應時間更長就認為服務不達標。這些指標為客戶設定了期望值並允許客戶在SLA未達標的情況下要求退款。
**排隊延遲queueing delay** 通常佔了高百分位點處響應時間的很大一部分。由於伺服器只能並行處理少量的事務如受其CPU核數的限制所以只要有少量緩慢的請求就能阻礙後續請求的處理這種效應有時被稱為 **頭部阻塞head-of-line blocking** 。即使後續請求在伺服器上處理的非常迅速,由於需要等待先前請求完成,客戶端最終看到的是緩慢的總體響應時間。因為存在這種效應,測量客戶端的響應時間非常重要。 **排隊延遲queueing delay** 通常佔了高百分位點處響應時間的很大一部分。由於伺服器只能並行處理少量的事務如受其CPU核數的限制所以只要有少量緩慢的請求就能阻礙後續請求的處理這種效應有時被稱為 **頭部阻塞head-of-line blocking** 。即使後續請求在伺服器上處理的非常迅速,由於需要等待先前請求完成,客戶端最終看到的是緩慢的總體響應時間。因為存在這種效應,測量客戶端的響應時間非常重要。
為測試系統的可伸縮性而人為產生負載時產生負載的客戶端要獨立於響應時間不斷髮送請求。如果客戶端在傳送下一個請求之前等待先前的請求完成這種行為會產生人為排隊的效果使得測試時的佇列比現實情況更短使測量結果產生偏差【23】。 為測試系統的可伸縮性而人為產生負載時產生負載的客戶端要獨立於響應時間不斷髮送請求。如果客戶端在傳送下一個請求之前等待先前的請求完成這種行為會產生人為排隊的效果使得測試時的佇列比現實情況更短使測量結果產生偏差【23】。
> #### 實踐中的百分位點 > #### 實踐中的百分位點
> >
@ -254,53 +254,53 @@
### 應對負載的方法 ### 應對負載的方法
現在我們已經討論了用於描述負載的引數和用於衡量效能的指標。可以開始認真討論可伸縮性了:當負載引數增加時,如何保持良好的效能? 現在我們已經討論了用於描述負載的引數和用於衡量效能的指標。可以開始認真討論可伸縮性了:當負載引數增加時,如何保持良好的效能?
適應某個級別負載的架構不太可能應付10倍於此的負載。如果你正在開發一個快速增長的服務那麼每次負載發生數量級的增長時你可能都需要重新考慮架構——或者更頻繁。 適應某個級別負載的架構不太可能應付10倍於此的負載。如果你正在開發一個快速增長的服務那麼每次負載發生數量級的增長時你可能都需要重新考慮架構——或者更頻繁。
人們經常討論**縱向伸縮scaling up****垂直伸縮vertical scaling**,轉向更強大的機器)和**橫向伸縮scaling out** **水平伸縮horizontal scaling**,將負載分佈到多臺小機器上)之間的對立。跨多臺機器分配負載也稱為“**無共享shared-nothing**”架構。可以在單臺機器上執行的系統通常更簡單,但高階機器可能非常貴,所以非常密集的負載通常無法避免地需要橫向伸縮。現實世界中的優秀架構需要將這兩種方法務實地結合,因為使用幾臺足夠強大的機器可能比使用大量的小型虛擬機器更簡單也更便宜。 人們經常討論**縱向伸縮scaling up****垂直伸縮vertical scaling**,轉向更強大的機器)和**橫向伸縮scaling out** **水平伸縮horizontal scaling**,將負載分佈到多臺小機器上)之間的對立。跨多臺機器分配負載也稱為“**無共享shared-nothing**”架構。可以在單臺機器上執行的系統通常更簡單,但高階機器可能非常貴,所以非常密集的負載通常無法避免地需要橫向伸縮。現實世界中的優秀架構需要將這兩種方法務實地結合,因為使用幾臺足夠強大的機器可能比使用大量的小型虛擬機器更簡單也更便宜。
有些系統是 **彈性elastic** 的,這意味著可以在檢測到負載增加時自動增加計算資源,而其他系統則是手動伸縮(人工分析容量並決定向系統新增更多的機器)。如果負載**極難預測highly unpredictable**,則彈性系統可能很有用,但手動伸縮系統更簡單,並且意外操作可能會更少(請參閱“[分割槽再平衡](ch6.md#分割槽再平衡)”)。 有些系統是 **彈性elastic** 的,這意味著可以在檢測到負載增加時自動增加計算資源,而其他系統則是手動伸縮(人工分析容量並決定向系統新增更多的機器)。如果負載**極難預測highly unpredictable**,則彈性系統可能很有用,但手動伸縮系統更簡單,並且意外操作可能會更少(請參閱“[分割槽再平衡](ch6.md#分割槽再平衡)”)。
跨多臺機器部署 **無狀態服務stateless services** 非常簡單,但將帶狀態的資料系統從單節點變為分散式配置則可能引入許多額外複雜度。出於這個原因,常識告訴我們應該將資料庫放在單個節點上(縱向伸縮),直到伸縮成本或可用性需求迫使其改為分散式。 跨多臺機器部署 **無狀態服務stateless services** 非常簡單,但將帶狀態的資料系統從單節點變為分散式配置則可能引入許多額外複雜度。出於這個原因,常識告訴我們應該將資料庫放在單個節點上(縱向伸縮),直到伸縮成本或可用性需求迫使其改為分散式。
隨著分散式系統的工具和抽象越來越好,至少對於某些型別的應用而言,這種常識可能會改變。可以預見分散式資料系統將成為未來的預設設定,即使對不處理大量資料或流量的場景也如此。本書的其餘部分將介紹多種分散式資料系統,不僅討論它們在可伸縮性方面的表現,還包括易用性和可維護性。 隨著分散式系統的工具和抽象越來越好,至少對於某些型別的應用而言,這種常識可能會改變。可以預見分散式資料系統將成為未來的預設設定,即使對不處理大量資料或流量的場景也如此。本書的其餘部分將介紹多種分散式資料系統,不僅討論它們在可伸縮性方面的表現,還包括易用性和可維護性。
大規模的系統架構通常是應用特定的—— 沒有一招鮮吃遍天的通用可伸縮架構(不正式的叫法:**萬金油magic scaling sauce** )。應用的問題可能是讀取量、寫入量、要儲存的資料量、資料的複雜度、響應時間要求、訪問模式或者所有問題的大雜燴。 大規模的系統架構通常是應用特定的—— 沒有一招鮮吃遍天的通用可伸縮架構(不正式的叫法:**萬金油magic scaling sauce** )。應用的問題可能是讀取量、寫入量、要儲存的資料量、資料的複雜度、響應時間要求、訪問模式或者所有問題的大雜燴。
舉個例子用於處理每秒十萬個請求每個大小為1 kB的系統與用於處理每分鐘3個請求每個大小為2GB的系統看上去會非常不一樣儘管兩個系統有同樣的資料吞吐量。 舉個例子用於處理每秒十萬個請求每個大小為1 kB的系統與用於處理每分鐘3個請求每個大小為2GB的系統看上去會非常不一樣儘管兩個系統有同樣的資料吞吐量。
一個良好適配應用的可伸縮架構,是圍繞著**假設assumption** 建立的:哪些操作是常見的?哪些操作是罕見的?這就是所謂負載引數。如果假設最終是錯誤的,那麼為伸縮所做的工程投入就白費了,最糟糕的是適得其反。在早期創業公司或非正式產品中,通常支援產品快速迭代的能力,要比可伸縮至未來的假想負載要重要的多。 一個良好適配應用的可伸縮架構,是圍繞著**假設assumption** 建立的:哪些操作是常見的?哪些操作是罕見的?這就是所謂負載引數。如果假設最終是錯誤的,那麼為伸縮所做的工程投入就白費了,最糟糕的是適得其反。在早期創業公司或非正式產品中,通常支援產品快速迭代的能力,要比可伸縮至未來的假想負載要重要的多。
儘管這些架構是應用程式特定的,但可伸縮的架構通常也是從通用的積木塊搭建而成的,並以常見的模式排列。在本書中,我們將討論這些構件和模式。 儘管這些架構是應用程式特定的,但可伸縮的架構通常也是從通用的積木塊搭建而成的,並以常見的模式排列。在本書中,我們將討論這些構件和模式。
## 可維護性 ## 可維護性
眾所周知,軟體的大部分開銷並不在最初的開發階段,而是在持續的維護階段,包括修復漏洞、保持系統正常執行、調查失效、適配新的平臺、為新的場景進行修改、償還技術債、新增新的功能等等。 眾所周知,軟體的大部分開銷並不在最初的開發階段,而是在持續的維護階段,包括修復漏洞、保持系統正常執行、調查失效、適配新的平臺、為新的場景進行修改、償還技術債、新增新的功能等等。
不幸的是,許多從事軟體系統行業的人不喜歡維護所謂的**遺留legacy** 系統,——也許因為涉及修復其他人的錯誤、和過時的平臺打交道,或者系統被迫使用於一些份外工作。每一個遺留系統都以自己的方式讓人不爽,所以很難給出一個通用的建議來和它們打交道。 不幸的是,許多從事軟體系統行業的人不喜歡維護所謂的**遺留legacy** 系統,——也許因為涉及修復其他人的錯誤、和過時的平臺打交道,或者系統被迫使用於一些份外工作。每一個遺留系統都以自己的方式讓人不爽,所以很難給出一個通用的建議來和它們打交道。
但是我們可以,也應該以這樣一種方式來設計軟體:在設計之初就儘量考慮儘可能減少維護期間的痛苦,從而避免自己的軟體系統變成遺留系統。為此,我們將特別關注軟體系統的三個設計原則: 但是我們可以,也應該以這樣一種方式來設計軟體:在設計之初就儘量考慮儘可能減少維護期間的痛苦,從而避免自己的軟體系統變成遺留系統。為此,我們將特別關注軟體系統的三個設計原則:
***可操作性Operability*** ***可操作性Operability***
便於運維團隊保持系統平穩執行。 便於運維團隊保持系統平穩執行。
***簡單性Simplicity*** ***簡單性Simplicity***
從系統中消除儘可能多的**複雜度complexity**,使新工程師也能輕鬆理解系統。(注意這和使用者介面的簡單性不一樣。) 從系統中消除儘可能多的**複雜度complexity**,使新工程師也能輕鬆理解系統。(注意這和使用者介面的簡單性不一樣。)
***可演化性evolability*** ***可演化性evolability***
使工程師在未來能輕鬆地對系統進行更改,當需求變化時為新應用場景做適配。也稱為**可伸縮性extensibility****可修改性modifiability** 或**可塑性plasticity**。 使工程師在未來能輕鬆地對系統進行更改,當需求變化時為新應用場景做適配。也稱為**可伸縮性extensibility****可修改性modifiability** 或**可塑性plasticity**。
和之前提到的可靠性、可伸縮性一樣,實現這些目標也沒有簡單的解決方案。不過我們會試著想象具有可操作性,簡單性和可演化性的系統會是什麼樣子。 和之前提到的可靠性、可伸縮性一樣,實現這些目標也沒有簡單的解決方案。不過我們會試著想象具有可操作性,簡單性和可演化性的系統會是什麼樣子。
### 可操作性:人生苦短,關愛運維 ### 可操作性:人生苦短,關愛運維
有人認為,“良好的運維經常可以繞開垃圾(或不完整)軟體的侷限性,而再好的軟體攤上垃圾運維也沒法可靠執行”。儘管運維的某些方面可以,而且應該是自動化的,但在最初建立正確運作的自動化機制仍然取決於人。 有人認為,“良好的運維經常可以繞開垃圾(或不完整)軟體的侷限性,而再好的軟體攤上垃圾運維也沒法可靠執行”。儘管運維的某些方面可以,而且應該是自動化的,但在最初建立正確運作的自動化機制仍然取決於人。
運維團隊對於保持軟體系統順利執行至關重要。一個優秀運維團隊的典型職責如下或者更多【29】 運維團隊對於保持軟體系統順利執行至關重要。一個優秀運維團隊的典型職責如下或者更多【29】
@ -329,50 +329,50 @@
### 簡單性:管理複雜度 ### 簡單性:管理複雜度
小型軟體專案可以使用簡單討喜的、富表現力的程式碼,但隨著專案越來越大,程式碼往往變得非常複雜,難以理解。這種複雜度拖慢了所有系統相關人員,進一步增加了維護成本。一個陷入複雜泥潭的軟體專案有時被描述為 **爛泥潭a big ball of mud** 【30】。 小型軟體專案可以使用簡單討喜的、富表現力的程式碼,但隨著專案越來越大,程式碼往往變得非常複雜,難以理解。這種複雜度拖慢了所有系統相關人員,進一步增加了維護成本。一個陷入複雜泥潭的軟體專案有時被描述為 **爛泥潭a big ball of mud** 【30】。
**複雜度complexity** 有各種可能的症狀例如狀態空間激增、模組間緊密耦合、糾結的依賴關係、不一致的命名和術語、解決效能問題的Hack、需要繞開的特例等等現在已經有很多關於這個話題的討論【31,32,33】。 **複雜度complexity** 有各種可能的症狀例如狀態空間激增、模組間緊密耦合、糾結的依賴關係、不一致的命名和術語、解決效能問題的Hack、需要繞開的特例等等現在已經有很多關於這個話題的討論【31,32,33】。
因為複雜度導致維護困難時,預算和時間安排通常會超支。在複雜的軟體中進行變更,引入錯誤的風險也更大:當開發人員難以理解系統時,隱藏的假設、無意的後果和意外的互動就更容易被忽略。相反,降低複雜度能極大地提高軟體的可維護性,因此簡單性應該是構建系統的一個關鍵目標。 因為複雜度導致維護困難時,預算和時間安排通常會超支。在複雜的軟體中進行變更,引入錯誤的風險也更大:當開發人員難以理解系統時,隱藏的假設、無意的後果和意外的互動就更容易被忽略。相反,降低複雜度能極大地提高軟體的可維護性,因此簡單性應該是構建系統的一個關鍵目標。
簡化系統並不一定意味著減少功能;它也可以意味著消除**額外的accidental** 的複雜度。 Moseley和Marks【32】把 **額外複雜度** 定義為:由具體實現中湧現,而非(從使用者視角看,系統所解決的)問題本身固有的複雜度。 簡化系統並不一定意味著減少功能;它也可以意味著消除**額外的accidental** 的複雜度。 Moseley和Marks【32】把 **額外複雜度** 定義為:由具體實現中湧現,而非(從使用者視角看,系統所解決的)問題本身固有的複雜度。
用於消除**額外複雜度** 的最好工具之一是**抽象abstraction**。一個好的抽象可以將大量實現細節隱藏在一個乾淨,簡單易懂的外觀下面。一個好的抽象也可以廣泛用於各類不同應用。比起重複造很多輪子,重用抽象不僅更有效率,而且有助於開發高質量的軟體。抽象元件的質量改進將使所有使用它的應用受益。 用於消除**額外複雜度** 的最好工具之一是**抽象abstraction**。一個好的抽象可以將大量實現細節隱藏在一個乾淨,簡單易懂的外觀下面。一個好的抽象也可以廣泛用於各類不同應用。比起重複造很多輪子,重用抽象不僅更有效率,而且有助於開發高質量的軟體。抽象元件的質量改進將使所有使用它的應用受益。
例如高階程式語言是一種抽象隱藏了機器碼、CPU暫存器和系統呼叫。 SQL也是一種抽象隱藏了複雜的磁碟/記憶體資料結構、來自其他客戶端的併發請求、崩潰後的不一致性。當然在用高階語言程式設計時,我們仍然用到了機器碼;只不過沒有**直接directly** 使用罷了,正是因為程式語言的抽象,我們才不必去考慮這些實現細節。 例如高階程式語言是一種抽象隱藏了機器碼、CPU暫存器和系統呼叫。 SQL也是一種抽象隱藏了複雜的磁碟/記憶體資料結構、來自其他客戶端的併發請求、崩潰後的不一致性。當然在用高階語言程式設計時,我們仍然用到了機器碼;只不過沒有**直接directly** 使用罷了,正是因為程式語言的抽象,我們才不必去考慮這些實現細節。
抽象可以幫助我們將系統的複雜度控制在可管理的水平,不過,找到好的抽象是非常困難的。在分散式系統領域雖然有許多好的演算法,但我們並不清楚它們應該打包成什麼樣抽象。 抽象可以幫助我們將系統的複雜度控制在可管理的水平,不過,找到好的抽象是非常困難的。在分散式系統領域雖然有許多好的演算法,但我們並不清楚它們應該打包成什麼樣抽象。
本書將緊盯那些允許我們將大型系統的部分提取為定義明確的、可重用的元件的優秀抽象。 本書將緊盯那些允許我們將大型系統的部分提取為定義明確的、可重用的元件的優秀抽象。
### 可演化性:擁抱變化 ### 可演化性:擁抱變化
系統的需求永遠不變,基本是不可能的。更可能的情況是,它們處於常態的變化中,例如:你瞭解了新的事實、出現意想不到的應用場景、業務優先順序發生變化、使用者要求新功能、新平臺取代舊平臺、法律或監管要求發生變化、系統增長迫使架構變化等。 系統的需求永遠不變,基本是不可能的。更可能的情況是,它們處於常態的變化中,例如:你瞭解了新的事實、出現意想不到的應用場景、業務優先順序發生變化、使用者要求新功能、新平臺取代舊平臺、法律或監管要求發生變化、系統增長迫使架構變化等。
在組織流程方面, **敏捷agile** 工作模式為適應變化提供了一個框架。敏捷社群還開發了對在頻繁變化的環境中開發軟體很有幫助的技術工具和模式,如 **測試驅動開發TDD, test-driven development****重構refactoring** 在組織流程方面, **敏捷agile** 工作模式為適應變化提供了一個框架。敏捷社群還開發了對在頻繁變化的環境中開發軟體很有幫助的技術工具和模式,如 **測試驅動開發TDD, test-driven development****重構refactoring**
這些敏捷技術的大部分討論都集中在相當小的規模同一個應用中的幾個程式碼檔案。本書將探索在更大資料系統層面上提高敏捷性的方法可能由幾個不同的應用或服務組成。例如為了將裝配主頁時間線的方法從方法1變為方法2你會如何“重構”推特的架構 這些敏捷技術的大部分討論都集中在相當小的規模同一個應用中的幾個程式碼檔案。本書將探索在更大資料系統層面上提高敏捷性的方法可能由幾個不同的應用或服務組成。例如為了將裝配主頁時間線的方法從方法1變為方法2你會如何“重構”推特的架構
修改資料系統並使其適應不斷變化需求的容易程度,是與**簡單性**和**抽象性**密切相關的:簡單易懂的系統通常比複雜系統更容易修改。但由於這是一個非常重要的概念,我們將用一個不同的詞來指代資料系統層面的敏捷性: **可演化性evolvability** 【34】。 修改資料系統並使其適應不斷變化需求的容易程度,是與**簡單性**和**抽象性**密切相關的:簡單易懂的系統通常比複雜系統更容易修改。但由於這是一個非常重要的概念,我們將用一個不同的詞來指代資料系統層面的敏捷性: **可演化性evolvability** 【34】。
## 本章小結 ## 本章小結
本章探討了一些關於資料密集型應用的基本思考方式。這些原則將指導我們閱讀本書的其餘部分,那裡將會深入技術細節。 本章探討了一些關於資料密集型應用的基本思考方式。這些原則將指導我們閱讀本書的其餘部分,那裡將會深入技術細節。
一個應用必須滿足各種需求才稱得上有用。有一些**功能需求functional requirements**(它應該做什麼,比如允許以各種方式儲存,檢索,搜尋和處理資料)以及一些**非功能性需求nonfunctional **(通用屬性,例如安全性,可靠性,合規性,可伸縮性,相容性和可維護性)。在本章詳細討論了可靠性,可伸縮性和可維護性。 一個應用必須滿足各種需求才稱得上有用。有一些**功能需求functional requirements**(它應該做什麼,比如允許以各種方式儲存,檢索,搜尋和處理資料)以及一些**非功能性需求nonfunctional **(通用屬性,例如安全性,可靠性,合規性,可伸縮性,相容性和可維護性)。在本章詳細討論了可靠性,可伸縮性和可維護性。
**可靠性Reliability** 意味著即使發生故障系統也能正常工作。故障可能發生在硬體通常是隨機的和不相關的軟體通常是系統性的Bug很難處理和人類不可避免地時不時出錯**容錯技術** 可以對終端使用者隱藏某些型別的故障。 **可靠性Reliability** 意味著即使發生故障系統也能正常工作。故障可能發生在硬體通常是隨機的和不相關的軟體通常是系統性的Bug很難處理和人類不可避免地時不時出錯**容錯技術** 可以對終端使用者隱藏某些型別的故障。
**可伸縮性Scalability** 意味著即使在負載增加的情況下也有保持效能的策略。為了討論可伸縮性,我們首先需要定量描述負載和效能的方法。我們簡要了解了推特主頁時間線的例子,介紹描述負載的方法,並將響應時間百分位點作為衡量效能的一種方式。在可伸縮的系統中可以新增 **處理容量processing capacity** 以在高負載下保持可靠。 **可伸縮性Scalability** 意味著即使在負載增加的情況下也有保持效能的策略。為了討論可伸縮性,我們首先需要定量描述負載和效能的方法。我們簡要了解了推特主頁時間線的例子,介紹描述負載的方法,並將響應時間百分位點作為衡量效能的一種方式。在可伸縮的系統中可以新增 **處理容量processing capacity** 以在高負載下保持可靠。
**可維護性Maintainability** 有許多方面,但實質上是關於工程師和運維團隊的生活質量的。良好的抽象可以幫助降低複雜度,並使系統易於修改和適應新的應用場景。良好的可操作性意味著對系統的健康狀態具有良好的可見性,並擁有有效的管理手段。 **可維護性Maintainability** 有許多方面,但實質上是關於工程師和運維團隊的生活質量的。良好的抽象可以幫助降低複雜度,並使系統易於修改和適應新的應用場景。良好的可操作性意味著對系統的健康狀態具有良好的可見性,並擁有有效的管理手段。
不幸的是,使應用可靠、可伸縮或可維護並不容易。但是某些模式和技術會不斷重新出現在不同的應用中。在接下來的幾章中,我們將看到一些資料系統的例子,並分析它們如何實現這些目標。 不幸的是,使應用可靠、可伸縮或可維護並不容易。但是某些模式和技術會不斷重新出現在不同的應用中。在接下來的幾章中,我們將看到一些資料系統的例子,並分析它們如何實現這些目標。
在本書後面的[第三部分](part-iii.md)中,我們將看到一種模式:幾個元件協同工作以構成一個完整的系統(如[圖1-1](../img/fig1-1.png)中的例子) 在本書後面的[第三部分](part-iii.md)中,我們將看到一種模式:幾個元件協同工作以構成一個完整的系統(如[圖1-1](../img/fig1-1.png)中的例子)

View File

@ -10,37 +10,37 @@
[TOC] [TOC]
在本書的前兩部分中,我們討論了很多關於**請求**和**查詢**以及相應的**響應**或**結果**。許多現有資料系統中都採用這種資料處理方式:你傳送請求指令,一段時間後(我們期望)系統會給出一個結果。資料庫、快取、搜尋索引、Web伺服器以及其他一些系統都以這種方式工作。 在本書的前兩部分中,我們討論了很多關於**請求**和**查詢**以及相應的**響應**或**結果**。許多現有資料系統中都採用這種資料處理方式:你傳送請求指令,一段時間後(我們期望)系統會給出一個結果。資料庫、快取、搜尋索引、Web伺服器以及其他一些系統都以這種方式工作。
像這樣的**線上online** 系統無論是瀏覽器請求頁面還是呼叫遠端API的服務我們通常認為請求是由人類使用者觸發的並且正在等待響應。他們不應該等太久所以我們非常關注系統的響應時間請參閱“[描述效能](ch1.md#描述效能)”)。 像這樣的**線上online** 系統無論是瀏覽器請求頁面還是呼叫遠端API的服務我們通常認為請求是由人類使用者觸發的並且正在等待響應。他們不應該等太久所以我們非常關注系統的響應時間請參閱“[描述效能](ch1.md#描述效能)”)。
Web和越來越多的基於HTTP/REST的API使互動的請求/響應風格變得如此普遍,以至於很容易將其視為理所當然。但我們應該記住,這不是構建系統的唯一方式,其他方法也有其優點。我們來看看三種不同型別的系統: Web和越來越多的基於HTTP/REST的API使互動的請求/響應風格變得如此普遍,以至於很容易將其視為理所當然。但我們應該記住,這不是構建系統的唯一方式,其他方法也有其優點。我們來看看三種不同型別的系統:
***服務(線上系統)*** ***服務(線上系統)***
服務等待客戶的請求或指令到達。每收到一個,服務會試圖儘快處理它,併發回一個響應。響應時間通常是服務效能的主要衡量指標,可用性通常非常重要(如果客戶端無法訪問服務,使用者可能會收到錯誤訊息)。 服務等待客戶的請求或指令到達。每收到一個,服務會試圖儘快處理它,併發回一個響應。響應時間通常是服務效能的主要衡量指標,可用性通常非常重要(如果客戶端無法訪問服務,使用者可能會收到錯誤訊息)。
***批處理系統(離線系統)*** ***批處理系統(離線系統)***
一個批處理系統有大量的輸入資料,跑一個**作業job** 來處理它,並生成一些輸出資料,這往往需要一段時間(從幾分鐘到幾天),所以通常不會有使用者等待作業完成。相反,批次作業通常會定期執行(例如,每天一次)。批處理作業的主要效能衡量標準通常是吞吐量(處理特定大小的輸入所需的時間)。本章中討論的就是批處理。 一個批處理系統有大量的輸入資料,跑一個**作業job** 來處理它,並生成一些輸出資料,這往往需要一段時間(從幾分鐘到幾天),所以通常不會有使用者等待作業完成。相反,批次作業通常會定期執行(例如,每天一次)。批處理作業的主要效能衡量標準通常是吞吐量(處理特定大小的輸入所需的時間)。本章中討論的就是批處理。
***流處理系統(準實時系統)*** ***流處理系統(準實時系統)***
流處理介於線上和離線(批處理)之間,所以有時候被稱為**準實時near-real-time** 或**準線上nearline** 處理。像批處理系統一樣,流處理消費輸入併產生輸出(並不需要響應請求)。但是,流式作業在事件發生後不久就會對事件進行操作,而批處理作業則需等待固定的一組輸入資料。這種差異使流處理系統比起批處理系統具有更低的延遲。由於流處理基於批處理,我們將在[第十一章](ch11.md)討論它。 流處理介於線上和離線(批處理)之間,所以有時候被稱為**準實時near-real-time** 或**準線上nearline** 處理。像批處理系統一樣,流處理消費輸入併產生輸出(並不需要響應請求)。但是,流式作業在事件發生後不久就會對事件進行操作,而批處理作業則需等待固定的一組輸入資料。這種差異使流處理系統比起批處理系統具有更低的延遲。由於流處理基於批處理,我們將在[第十一章](ch11.md)討論它。
正如我們將在本章中看到的那樣批處理是構建可靠、可伸縮和可維護應用程式的重要組成部分。例如2004年釋出的批處理演算法Map-Reduce可能被過分熱情地被稱為“造就Google大規模可伸縮性的演算法”【2】。隨後在各種開源資料系統中得到應用包括HadoopCouchDB和MongoDB。 正如我們將在本章中看到的那樣批處理是構建可靠、可伸縮和可維護應用程式的重要組成部分。例如2004年釋出的批處理演算法Map-Reduce可能被過分熱情地被稱為“造就Google大規模可伸縮性的演算法”【2】。隨後在各種開源資料系統中得到應用包括HadoopCouchDB和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工具的批處理 ## 使用Unix工具的批處理
我們從一個簡單的例子開始。假設您有一臺Web伺服器每次處理請求時都會在日誌檔案中附加一行。例如使用nginx預設的訪問日誌格式日誌的一行可能如下所示 我們從一個簡單的例子開始。假設您有一臺Web伺服器每次處理請求時都會在日誌檔案中附加一行。例如使用nginx預設的訪問日誌格式日誌的一行可能如下所示
```bash ```bash
216.58.210.78 - - [27/Feb/2015:17:55:11 +0000] "GET /css/typography.css HTTP/1.1" 216.58.210.78 - - [27/Feb/2015:17:55:11 +0000] "GET /css/typography.css HTTP/1.1"
@ -55,13 +55,13 @@ AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 Safari/537.36"
$status $body_bytes_sent "$http_referer" "$http_user_agent" $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 40URL `http://martin.kleppmann.com/` 的頁面中的引用導致該檔案被載入。 日誌的這一行表明在2015年2月27日17:55:11 UTC伺服器從客戶端IP地址`216.58.210.78`接收到對檔案`/css/typography.css`的請求。使用者沒有被認證,所以`$remote_user`被設定為連字元(`-` 。響應狀態是200即請求成功響應的大小是3377位元組。網頁瀏覽器是Chrome 40URL `http://martin.kleppmann.com/` 的頁面中的引用導致該檔案被載入。
### 簡單日誌分析 ### 簡單日誌分析
很多工具可以從這些日誌檔案生成關於網站流量的漂亮的報告但為了練手讓我們使用基本的Unix功能建立自己的工具。 例如,假設你想在你的網站上找到五個最受歡迎的網頁。 則可以在Unix shell中這樣做[^i] 很多工具可以從這些日誌檔案生成關於網站流量的漂亮的報告但為了練手讓我們使用基本的Unix功能建立自己的工具。 例如,假設你想在你的網站上找到五個最受歡迎的網頁。 則可以在Unix shell中這樣做[^i]
[^i]: 有些人認為`cat`這裡並沒有必要因為輸入檔案可以直接作為awk的引數。 但這種寫法讓線性管道更為顯眼。 [^i]: 有些人認為`cat`這裡並沒有必要因為輸入檔案可以直接作為awk的引數。 但這種寫法讓線性管道更為顯眼。
@ -89,9 +89,9 @@ cat /var/log/nginx/access.log | #1
915 /css/typography.css 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工具但是它非常值得學習。令人驚訝的是使用awksedgrepsortuniq和xargs的組合可以在幾分鐘內完成許多資料分析並且它們的效能相當的好【8】。 我們不會在這裡詳細探索Unix工具但是它非常值得學習。令人驚訝的是使用awksedgrepsortuniq和xargs的組合可以在幾分鐘內完成許多資料分析並且它們的效能相當的好【8】。
#### 命令鏈與自定義程式 #### 命令鏈與自定義程式
@ -120,21 +120,21 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
#### 排序 VS 記憶體中的聚合 #### 排序 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 CoreutilsLinux中的`sort `程式透過溢位至磁碟的方式來自動應對大於記憶體的資料集並能同時使用多個CPU核進行並行排序【9】。這意味著我們之前看到的簡單的Unix命令鏈很容易伸縮至大資料集且不會耗盡記憶體。瓶頸可能是從磁碟讀取輸入檔案的速度。 GNU CoreutilsLinux中的`sort `程式透過溢位至磁碟的方式來自動應對大於記憶體的資料集並能同時使用多個CPU核進行並行排序【9】。這意味著我們之前看到的簡單的Unix命令鏈很容易伸縮至大資料集且不會耗盡記憶體。瓶頸可能是從磁碟讀取輸入檔案的速度。
### 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. 期待每個程式的輸出成為另一個程式的輸入。不要將無關資訊混入輸出。避免使用嚴格的列資料或二進位制輸入格式。不要堅持互動式輸入。 2. 期待每個程式的輸出成為另一個程式的輸入。不要將無關資訊混入輸出。避免使用嚴格的列資料或二進位制輸入格式。不要堅持互動式輸入。
@ -143,27 +143,27 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
這種方法 —— 自動化,快速原型設計,增量式迭代,對實驗友好,將大型專案分解成可管理的塊 —— 聽起來非常像今天的敏捷開發和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套接字stdinstdout的通訊通道裝置驅動程式比如`/dev/audio`或`/dev/lp0`表示TCP連線的套接字等等。很容易將這些設計視為理所當然的但實際上能讓這些差異巨大的東西共享一個統一的介面是非常厲害的這使得它們可以很容易地連線在一起[^ii]。 在Unix中這種介面是一個**檔案file**更準確地說是一個檔案描述符。一個檔案只是一串有序的位元組序列。因為這是一個非常簡單的介面所以可以使用相同的介面來表示許多不同的東西檔案系統上的真實檔案到另一個程序Unix套接字stdinstdout的通訊通道裝置驅動程式比如`/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** 是一個常帶有貶義的地緣政治學術語,其定義為:一個國家或政區分裂成多個互相敵對的國家或政區的過程。 [^譯註i]: **巴爾幹化Balkanization** 是一個常帶有貶義的地緣政治學術語,其定義為:一個國家或政區分裂成多個互相敵對的國家或政區的過程。
@ -171,13 +171,13 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
#### 邏輯與佈線相分離 #### 邏輯與佈線相分離
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】。
@ -199,27 +199,27 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
## MapReduce和分散式檔案系統 ## MapReduce和分散式檔案系統
MapReduce有點像Unix工具但分佈在數千臺機器上。像Unix工具一樣它相當簡單粗暴但令人驚異地管用。一個MapReduce作業可以和一個Unix程序相類比它接受一個或多個輸入併產生一個或多個輸出。 MapReduce有點像Unix工具但分佈在數千臺機器上。像Unix工具一樣它相當簡單粗暴但令人驚異地管用。一個MapReduce作業可以和一個Unix程序相類比它接受一個或多個輸入併產生一個或多個輸出。
和大多數Unix工具一樣執行MapReduce作業通常不會修改輸入除了生成輸出外沒有任何副作用。輸出檔案以連續的方式一次性寫入一旦寫入檔案不會修改任何現有的檔案部分 和大多數Unix工具一樣執行MapReduce作業通常不會修改輸入除了生成輸出外沒有任何副作用。輸出檔案以連續的方式一次性寫入一旦寫入檔案不會修改任何現有的檔案部分
雖然Unix工具使用`stdin`和`stdout`作為輸入和輸出但MapReduce作業在分散式檔案系統上讀寫檔案。在Hadoop的MapReduce實現中該檔案系統被稱為**HDFSHadoop分散式檔案系統**一個Google檔案系統GFS的開源實現【19】。 雖然Unix工具使用`stdin`和`stdout`作為輸入和輸出但MapReduce作業在分散式檔案系統上讀寫檔案。在Hadoop的MapReduce實現中該檔案系統被稱為**HDFSHadoop分散式檔案系統**一個Google檔案系統GFS的開源實現【19】。
除HDFS外還有各種其他分散式檔案系統如GlusterFS和Quantcast File SystemQFS【20】。諸如Amazon S3Azure Blob儲存和OpenStack Swift【21】等物件儲存服務在很多方面都是相似的[^iv]。在本章中我們將主要使用HDFS作為示例但是這些原則適用於任何分散式檔案系統。 除HDFS外還有各種其他分散式檔案系統如GlusterFS和Quantcast File SystemQFS【20】。諸如Amazon S3Azure 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作業執行
MapReduce是一個程式設計框架你可以使用它編寫程式碼來處理HDFS等分散式檔案系統中的大型資料集。理解它的最簡單方法是參考“[簡單日誌分析](#簡單日誌分析)”中的Web伺服器日誌分析示例。MapReduce中的資料處理模式與此示例非常相似 MapReduce是一個程式設計框架你可以使用它編寫程式碼來處理HDFS等分散式檔案系統中的大型資料集。理解它的最簡單方法是參考“[簡單日誌分析](#簡單日誌分析)”中的Web伺服器日誌分析示例。MapReduce中的資料處理模式與此示例非常相似
1. 讀取一組輸入檔案,並將其分解成**記錄records**。在Web伺服器日誌示例中每條記錄都是日誌中的一行即`\n`是記錄分隔符)。 1. 讀取一組輸入檔案,並將其分解成**記錄records**。在Web伺服器日誌示例中每條記錄都是日誌中的一行即`\n`是記錄分隔符)。
2. 呼叫Mapper函式從每條輸入記錄中提取一對鍵值。在前面的例子中Mapper函式是`awk '{print $7}'`它提取URL`$7`)作為鍵,並將值留空。 2. 呼叫Mapper函式從每條輸入記錄中提取一對鍵值。在前面的例子中Mapper函式是`awk '{print $7}'`它提取URL`$7`)作為鍵,並將值留空。
@ -232,108 +232,108 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
***Mapper*** ***Mapper***
Mapper會在每條輸入記錄上呼叫一次其工作是從輸入記錄中提取鍵值。對於每個輸入它可以生成任意數量的鍵值對包括None。它不會保留從一個輸入記錄到下一個記錄的任何狀態因此每個記錄都是獨立處理的。 Mapper會在每條輸入記錄上呼叫一次其工作是從輸入記錄中提取鍵值。對於每個輸入它可以生成任意數量的鍵值對包括None。它不會保留從一個輸入記錄到下一個記錄的任何狀態因此每個記錄都是獨立處理的。
***Reducer*** ***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
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)中的m1m2和m3標記 [圖10-1](../img/fig10-1.png)顯示了Hadoop MapReduce作業中的資料流。其並行化基於分割槽請參閱[第六章](ch6.md)作業的輸入通常是HDFS中的一個目錄輸入目錄中的每個檔案或檔案塊都被認為是一個單獨的分割槽可以單獨處理map任務[圖10-1](../img/fig10-1.png)中的m1m2和m3標記
每個輸入檔案的大小通常是數百兆位元組。 MapReduce排程器圖中未顯示試圖在其中一臺儲存輸入檔案副本的機器上執行每個Mapper只要該機器有足夠的備用RAM和CPU資源來執行Mapper任務【26】。這個原則被稱為**將計算放在資料附近**【27】它節省了透過網路複製輸入檔案的開銷減少網路負載並增加區域性性。 每個輸入檔案的大小通常是數百兆位元組。 MapReduce排程器圖中未顯示試圖在其中一臺儲存輸入檔案副本的機器上執行每個Mapper只要該機器有足夠的備用RAM和CPU資源來執行Mapper任務【26】。這個原則被稱為**將計算放在資料附近**【27】它節省了透過網路複製輸入檔案的開銷減少網路負載並增加區域性性。
![](../img/fig10-1.png) ![](../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作業可以解決的問題範圍很有限。以日誌分析為例單個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的工作流排程器被開發出來包括OozieAzkabanLuigiAirflow和Pinball 【28】。 只有當作業成功完成後批處理作業的輸出才會被視為有效的MapReduce會丟棄失敗作業的部分輸出。因此工作流中的一項作業只有在先前的作業 —— 即生產其輸入的作業 —— 成功完成後才能開始。為了處理這些作業之間的依賴有很多針對Hadoop的工作流排程器被開發出來包括OozieAzkabanLuigiAirflow和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側連線與分組 ### 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) ![](../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) ![](../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(*)`聚合) - 統計每個組中記錄的數量例如在統計PV的例子中在SQL中表示為`COUNT(*)`聚合)
- 對某個特定欄位求和SQL中的`SUM(fieldname)` - 對某個特定欄位求和SQL中的`SUM(fieldname)`
@ -341,121 +341,121 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
使用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側連線 ### 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】。索引中經常使用的部分將保留在作業系統的頁面快取中因而這種方法可以提供與記憶體散列表幾乎一樣快的隨機查詢效能但實際上並不需要資料集能放入記憶體中。 除了將較小的連線輸入載入到記憶體散列表中另一種方法是將較小輸入儲存在本地磁碟上的只讀索引中【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側合併連線
如果輸入資料集不僅以相同的方式進行分割槽,而且還基於相同的鍵進行**排序**則可適用另一種Map側連線的變體。在這種情況下輸入是否小到能放入記憶體並不重要因為這時候Mapper同樣可以執行歸併操作通常由Reducer執行的歸併操作按鍵遞增的順序依次讀取兩個輸入檔案將具有相同鍵的記錄配對。 如果輸入資料集不僅以相同的方式進行分割槽,而且還基於相同的鍵進行**排序**則可適用另一種Map側連線的變體。在這種情況下輸入是否小到能放入記憶體並不重要因為這時候Mapper同樣可以執行歸併操作通常由Reducer執行的歸併操作按鍵遞增的順序依次讀取兩個輸入檔案將具有相同鍵的記錄配對。
如果能進行Map側合併連線這通常意味著前一個MapReduce作業可能一開始就已經把輸入資料做了分割槽並進行了排序。原則上這個連線就可以在前一個作業的Reduce階段進行。但使用獨立的僅Map作業有時也是合適的例如分好區且排好序的中間資料集可能還會用於其他目的。 如果能進行Map側合併連線這通常意味著前一個MapReduce作業可能一開始就已經把輸入資料做了分割槽並進行了排序。原則上這個連線就可以在前一個作業的Reduce階段進行。但使用獨立的僅Map作業有時也是合適的例如分好區且排好序的中間資料集可能還會用於其他目的。
#### MapReduce工作流與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】 搜尋索引只是批處理工作流可能輸出的一個例子。批處理的另一個常見用途是構建機器學習系統例如分類器比如垃圾郵件過濾器異常檢測影象識別與推薦系統例如你可能認識的人你可能感興趣的產品或相關的搜尋【29】
這些批處理作業的輸出通常是某種資料庫例如可以透過給定使用者ID查詢該使用者推薦好友的資料庫或者可以透過產品ID查詢相關產品的資料庫【45】。 這些批處理作業的輸出通常是某種資料庫例如可以透過給定使用者ID查詢該使用者推薦好友的資料庫或者可以透過產品ID查詢相關產品的資料庫【45】。
這些資料庫需要被處理使用者請求的Web應用所查詢而它們通常是獨立於Hadoop基礎設施的。那麼批處理過程的輸出如何回到Web應用可以查詢的資料庫中呢 這些資料庫需要被處理使用者請求的Web應用所查詢而它們通常是獨立於Hadoop基礎設施的。那麼批處理過程的輸出如何回到Web應用可以查詢的資料庫中呢
最直接的選擇可能是直接在Mapper或Reducer中使用你最愛的資料庫的客戶端庫並從批處理作業直接寫入資料庫伺服器一次寫入一條記錄。它能工作假設你的防火牆規則允許從你的Hadoop環境直接訪問你的生產資料庫但這並不是一個好主意出於以下幾個原因 最直接的選擇可能是直接在Mapper或Reducer中使用你最愛的資料庫的客戶端庫並從批處理作業直接寫入資料庫伺服器一次寫入一條記錄。它能工作假設你的防火牆規則允許從你的Hadoop環境直接訪問你的生產資料庫但這並不是一個好主意出於以下幾個原因
- 正如前面在連線的上下文中討論的那樣,為每條記錄發起一個網路請求,要比批處理任務的正常吞吐量慢幾個數量級。即使客戶端庫支援批處理,效能也可能很差。 - 正如前面在連線的上下文中討論的那樣,為每條記錄發起一個網路請求,要比批處理任務的正常吞吐量慢幾個數量級。即使客戶端庫支援批處理,效能也可能很差。
- MapReduce作業經常並行執行許多工。如果所有Mapper或Reducer都同時寫入相同的輸出資料庫並以批處理的預期速率工作那麼該資料庫很可能被輕易壓垮其查詢效能可能變差。這可能會導致系統其他部分的執行問題【35】。 - MapReduce作業經常並行執行許多工。如果所有Mapper或Reducer都同時寫入相同的輸出資料庫並以批處理的預期速率工作那麼該資料庫很可能被輕易壓垮其查詢效能可能變差。這可能會導致系統其他部分的執行問題【35】。
- 通常情況下MapReduce為作業輸出提供了一個乾淨利落的“全有或全無”保證如果作業成功則結果就是每個任務恰好執行一次所產生的輸出即使某些任務失敗且必須一路重試。如果整個作業失敗則不會生成輸出。然而從作業內部寫入外部系統會產生外部可見的副作用這種副作用是不能以這種方式被隱藏的。因此你不得不去操心對其他系統可見的部分完成的作業結果並需要理解Hadoop任務嘗試與預測執行的複雜性。 - 通常情況下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】 - 如果在程式碼中引入了一個錯誤,而輸出錯誤或損壞了,則可以簡單地回滾到程式碼的先前版本,然後重新執行該作業,輸出將重新被糾正。或者,甚至更簡單,你可以將舊的輸出儲存在不同的目錄中,然後切換回原來的目錄。具有讀寫事務的資料庫沒有這個屬性:如果你部署了錯誤的程式碼,將錯誤的資料寫入資料庫,那麼回滾程式碼將無法修復資料庫中的資料。 (能夠從錯誤程式碼中恢復的概念被稱為**人類容錯human fault tolerance**【50】
- 由於回滾很容易,比起在錯誤意味著不可挽回的傷害的環境,功能開發進展能快很多。這種**最小化不可逆性minimizing irreversibility** 的原則有利於敏捷軟體開發【51】。 - 由於回滾很容易,比起在錯誤意味著不可挽回的傷害的環境,功能開發進展能快很多。這種**最小化不可逆性minimizing irreversibility** 的原則有利於敏捷軟體開發【51】。
@ -463,94 +463,94 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
- 同一組檔案可用作各種不同作業的輸入,包括計算指標的監控作業並且評估作業的輸出是否具有預期的性質(例如,將其與前一次執行的輸出進行比較並測量差異) 。 - 同一組檔案可用作各種不同作業的輸入,包括計算指標的監控作業並且評估作業的輸出是否具有預期的性質(例如,將其與前一次執行的輸出進行比較並測量差異) 。
- 與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與分散式資料庫的對比
正如我們所看到的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 machineTeradata和Tandem NonStop SQL就是這方面的先驅【52】。 當MapReduce論文發表時【1】它從某種意義上來說 —— 並不新鮮。我們在前幾節中討論的所有處理和並行連線演算法已經在十多年前所謂的**大規模並行處理MPP massively parallel processing** 資料庫中實現了【3,40】。比如Gamma database machineTeradata和Tandem NonStop SQL就是這方面的先驅【52】。
最大的區別是MPP資料庫專注於在一組機器上並行執行分析SQL查詢而MapReduce和分散式檔案系統【19】的組合則更像是一個可以執行任意程式的通用作業系統。 最大的區別是MPP資料庫專注於在一組機器上並行執行分析SQL查詢而MapReduce和分散式檔案系統【19】的組合則更像是一個可以執行任意程式的通用作業系統。
#### 儲存多樣性 #### 儲存多樣性
資料庫要求你根據特定的模型(例如關係或文件)來構造資料,而分散式檔案系統中的檔案只是位元組序列,可以使用任何資料模型和編碼來編寫。它們可能是資料庫記錄的集合,但同樣可以是文字,影象,影片,感測器讀數,稀疏矩陣,特徵向量,基因組序列或任何其他型別的資料。 資料庫要求你根據特定的模型(例如關係或文件)來構造資料,而分散式檔案系統中的檔案只是位元組序列,可以使用任何資料模型和編碼來編寫。它們可能是資料庫記錄的集合,但同樣可以是文字,影象,影片,感測器讀數,稀疏矩陣,特徵向量,基因組序列或任何其他型別的資料。
說白了Hadoop開放了將資料不加區分地轉儲到HDFS的可能性允許後續再研究如何進一步處理【53】。相比之下在將資料匯入資料庫專有儲存格式之前MPP資料庫通常需要對資料和查詢模式進行仔細的前期建模。 說白了Hadoop開放了將資料不加區分地轉儲到HDFS的可能性允許後續再研究如何進一步處理【53】。相比之下在將資料匯入資料庫專有儲存格式之前MPP資料庫通常需要對資料和查詢模式進行仔細的前期建模。
在純粹主義者看來,這種仔細的建模和匯入似乎是可取的,因為這意味著資料庫的使用者有更高質量的資料來處理。然而實踐經驗表明,簡單地使資料快速可用 —— 即使它很古怪,難以使用,使用原始格式 —— 也通常要比事先決定理想資料模型要更有價值【54】。 在純粹主義者看來,這種仔細的建模和匯入似乎是可取的,因為這意味著資料庫的使用者有更高質量的資料來處理。然而實踐經驗表明,簡單地使資料快速可用 —— 即使它很古怪,難以使用,使用原始格式 —— 也通常要比事先決定理想資料模型要更有價值【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】但在編寫本文時YARNMesos或Kubernetes不支援通用的優先順序搶佔【60】。在任務不經常被終止的環境中MapReduce的這一設計決策就沒有多少意義了。在下一節中我們將研究一些與MapReduce設計決策相異的替代方案。 在開源的叢集排程器中,搶佔的使用較少。 YARN的CapacityScheduler支援搶佔以平衡不同佇列的資源分配【58】但在編寫本文時YARNMesos或Kubernetes不支援通用的優先順序搶佔【60】。在任務不經常被終止的環境中MapReduce的這一設計決策就沒有多少意義了。在下一節中我們將研究一些與MapReduce設計決策相異的替代方案。
## MapReduce之後 ## MapReduce之後
雖然MapReduce在2000年代後期變得非常流行並受到大量的炒作但它只是分散式系統的許多可能的程式設計模型之一。對於不同的資料量資料結構和處理型別其他工具可能更適合表示計算。 雖然MapReduce在2000年代後期變得非常流行並受到大量的炒作但它只是分散式系統的許多可能的程式設計模型之一。對於不同的資料量資料結構和處理型別其他工具可能更適合表示計算。
不管如何我們在這一章花了大把時間來討論MapReduce因為它是一種有用的學習工具它是分散式檔案系統的一種相當簡單明晰的抽象。在這裡**簡單**意味著我們能理解它在做什麼而不是意味著使用它很簡單。恰恰相反使用原始的MapReduce API來實現複雜的處理工作實際上是非常困難和費力的 —— 例如任意一種連線演算法都需要你從頭開始實現【37】。 不管如何我們在這一章花了大把時間來討論MapReduce因為它是一種有用的學習工具它是分散式檔案系統的一種相當簡單明晰的抽象。在這裡**簡單**意味著我們能理解它在做什麼而不是意味著使用它很簡單。恰恰相反使用原始的MapReduce API來實現複雜的處理工作實際上是非常困難和費力的 —— 例如任意一種連線演算法都需要你從頭開始實現【37】。
針對直接使用MapReduce的困難在MapReduce上有很多高階程式設計模型PigHiveCascadingCrunch被創造出來作為建立在MapReduce之上的抽象。如果你瞭解MapReduce的原理那麼它們學起來相當簡單。而且它們的高階結構能顯著簡化許多常見批處理任務的實現。 針對直接使用MapReduce的困難在MapReduce上有很多高階程式設計模型PigHiveCascadingCrunch被創造出來作為建立在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管道連線的程序會同時啟動輸出一旦生成就會被消費。不同機器上的資料偏斜或負載不均意味著一個作業往往會有一些掉隊的任務比其他任務要慢得多才能完成。必須等待至前驅作業的所有任務完成拖慢了整個工作流程的執行。 - MapReduce作業只有在前驅作業生成其輸入中的所有任務都完成時才能啟動而由Unix管道連線的程序會同時啟動輸出一旦生成就會被消費。不同機器上的資料偏斜或負載不均意味著一個作業往往會有一些掉隊的任務比其他任務要慢得多才能完成。必須等待至前驅作業的所有任務完成拖慢了整個工作流程的執行。
- Mapper通常是多餘的它們僅僅是讀取剛剛由Reducer寫入的同樣檔案為下一個階段的分割槽和排序做準備。在許多情況下Mapper程式碼可能是前驅Reducer的一部分如果Reducer和Mapper的輸出有著相同的分割槽與排序方式那麼Reducer就可以直接串在一起而不用與Mapper相互交織。 - Mapper通常是多餘的它們僅僅是讀取剛剛由Reducer寫入的同樣檔案為下一個階段的分割槽和排序做準備。在許多情況下Mapper程式碼可能是前驅Reducer的一部分如果Reducer和Mapper的輸出有著相同的分割槽與排序方式那麼Reducer就可以直接串在一起而不用與Mapper相互交織。
@ -558,17 +558,17 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
#### 資料流引擎 #### 資料流引擎
為了解決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和Reduce階段之間出現。
- 沒有不必要的Map任務因為Mapper所做的工作通常可以合併到前面的Reduce運算元中因為Mapper不會更改資料集的分割槽 - 沒有不必要的Map任務因為Mapper所做的工作通常可以合併到前面的Reduce運算元中因為Mapper不會更改資料集的分割槽
@ -577,43 +577,43 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
- 運算元可以在輸入就緒後立即開始執行;後續階段無需等待前驅階段整個完成後再開始。 - 運算元可以在輸入就緒後立即開始執行;後續階段無需等待前驅階段整個完成後再開始。
- 與MapReduce為每個任務啟動一個新的JVM相比現有Java虛擬機器JVM程序可以重用來執行新運算元從而減少啟動開銷。 - 與MapReduce為每個任務啟動一個新的JVM相比現有Java虛擬機器JVM程序可以重用來執行新運算元從而減少啟動開銷。
你可以使用資料流引擎執行與MapReduce工作流同樣的計算而且由於此處所述的最佳化通常執行速度要明顯快得多。既然運算元是Map和Reduce的泛化那麼相同的處理程式碼就可以在任一執行引擎上執行PigHive或Cascading中實現的工作流可以無需修改程式碼可以透過修改配置簡單地從MapReduce切換到Tez或Spark【64】。 你可以使用資料流引擎執行與MapReduce工作流同樣的計算而且由於此處所述的最佳化通常執行速度要明顯快得多。既然運算元是Map和Reduce的泛化那麼相同的處理程式碼就可以在任一執行引擎上執行PigHive或Cascading中實現的工作流可以無需修改程式碼可以透過修改配置簡單地從MapReduce切換到Tez或Spark【64】。
Tez是一個相當薄的庫它依賴於YARN shuffle服務來實現節點間資料的實際複製【58】而Spark和Flink則是包含了獨立網路通訊層排程器及使用者向API的大型框架。我們將簡要討論這些高階API。 Tez是一個相當薄的庫它依賴於YARN shuffle服務來實現節點間資料的實際複製【58】而Spark和Flink則是包含了獨立網路通訊層排程器及使用者向API的大型框架。我們將簡要討論這些高階API。
#### 容錯 #### 容錯
完全物化中間狀態至分散式檔案系統的一個優點是它具有永續性這使得MapReduce中的容錯相當容易如果一個任務失敗它可以在另一臺機器上重新啟動並從檔案系統重新讀取相同的輸入。 完全物化中間狀態至分散式檔案系統的一個優點是它具有永續性這使得MapReduce中的容錯相當容易如果一個任務失敗它可以在另一臺機器上重新啟動並從檔案系統重新讀取相同的輸入。
SparkFlink和Tez避免將中間狀態寫入HDFS因此它們採取了不同的方法來容錯如果一臺機器發生故障並且該機器上的中間狀態丟失則它會從其他仍然可用的資料重新計算在可行的情況下是先前的中間狀態要麼就只能是原始輸入資料通常在HDFS上 SparkFlink和Tez避免將中間狀態寫入HDFS因此它們採取了不同的方法來容錯如果一臺機器發生故障並且該機器上的中間狀態丟失則它會從其他仍然可用的資料重新計算在可行的情況下是先前的中間狀態要麼就只能是原始輸入資料通常在HDFS上
為了實現這種重新計算,框架必須跟蹤一個給定的資料是如何計算的 —— 使用了哪些輸入分割槽?應用了哪些運算元? Spark使用**彈性分散式資料集RDDResilient Distributed Dataset** 的抽象來跟蹤資料的譜系【61】而Flink對運算元狀態存檔允許恢復執行在執行過程中遇到錯誤的運算元【66】。 為了實現這種重新計算,框架必須跟蹤一個給定的資料是如何計算的 —— 使用了哪些輸入分割槽?應用了哪些運算元? Spark使用**彈性分散式資料集RDDResilient Distributed Dataset** 的抽象來跟蹤資料的譜系【61】而Flink對運算元狀態存檔允許恢復執行在執行過程中遇到錯誤的運算元【66】。
在重新計算資料時,重要的是要知道計算是否是**確定性的**:也就是說,給定相同的輸入資料,運算元是否始終產生相同的輸出?如果一些丟失的資料已經發送給下游運算元,這個問題就很重要。如果運算元重新啟動,重新計算的資料與原有的丟失資料不一致,下游運算元很難解決新舊資料之間的矛盾。對於不確定性運算元來說,解決方案通常是殺死下游運算元,然後再重跑新資料。 在重新計算資料時,重要的是要知道計算是否是**確定性的**:也就是說,給定相同的輸入資料,運算元是否始終產生相同的輸出?如果一些丟失的資料已經發送給下游運算元,這個問題就很重要。如果運算元重新啟動,重新計算的資料與原有的丟失資料不一致,下游運算元很難解決新舊資料之間的矛盾。對於不確定性運算元來說,解決方案通常是殺死下游運算元,然後再重跑新資料。
為了避免這種級聯故障,最好讓運算元具有確定性。但需要注意的是,非確定性行為很容易悄悄溜進來:例如,許多程式語言在迭代雜湊表的元素時不能對順序作出保證,許多概率和統計演算法顯式依賴於使用隨機數,以及用到系統時鐘或外部資料來源,這些都是都不確定性的行為。為了能可靠地從故障中恢復,需要消除這種不確定性因素,例如使用固定的種子生成偽隨機數。 為了避免這種級聯故障,最好讓運算元具有確定性。但需要注意的是,非確定性行為很容易悄悄溜進來:例如,許多程式語言在迭代雜湊表的元素時不能對順序作出保證,許多概率和統計演算法顯式依賴於使用隨機數,以及用到系統時鐘或外部資料來源,這些都是都不確定性的行為。為了能可靠地從故障中恢復,需要消除這種不確定性因素,例如使用固定的種子生成偽隨機數。
透過重算資料來從故障中恢復並不總是正確的答案:如果中間狀態資料要比源資料小得多,或者如果計算量非常大,那麼將中間資料物化為檔案可能要比重新計算廉價的多。 透過重算資料來從故障中恢復並不總是正確的答案:如果中間狀態資料要比源資料小得多,或者如果計算量非常大,那麼將中間資料物化為檔案可能要比重新計算廉價的多。
#### 關於物化的討論 #### 關於物化的討論
回到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】它試圖根據連結到某個網頁的其他網頁來估計該網頁的流行度。它作為配方的一部分用於確定網路搜尋引擎呈現結果的順序。
> 像SparkFlink和Tez這樣的資料流引擎請參閱“[物化中間狀態](#物化中間狀態)”)通常將運算元作為**有向無環圖DAG** 的一部分安排在作業中。這與圖處理不一樣:在資料流引擎中,**從一個運算元到另一個運算元的資料流**被構造成一個圖,而資料本身通常由關係型元組構成。在圖處理中,資料本身具有圖的形式。又一個不幸的命名混亂! > 像SparkFlink和Tez這樣的資料流引擎請參閱“[物化中間狀態](#物化中間狀態)”)通常將運算元作為**有向無環圖DAG** 的一部分安排在作業中。這與圖處理不一樣:在資料流引擎中,**從一個運算元到另一個運算元的資料流**被構造成一個圖,而資料本身通常由關係型元組構成。在圖處理中,資料本身具有圖的形式。又一個不幸的命名混亂!
許多圖演算法是透過一次遍歷一條邊來表示的,將一個頂點與近鄰的頂點連線起來,以傳播一些資訊,並不斷重複,直到滿足一些條件為止 —— 例如,直到沒有更多的邊要跟進,或直到一些指標收斂。我們在[圖2-6](../img/fig2-6.png)中看到一個例子,它透過重複跟進標明地點歸屬關係的邊,生成了資料庫中北美包含的所有地點列表(這種演算法被稱為**傳遞閉包transitive closure**)。 許多圖演算法是透過一次遍歷一條邊來表示的,將一個頂點與近鄰的頂點連線起來,以傳播一些資訊,並不斷重複,直到滿足一些條件為止 —— 例如,直到沒有更多的邊要跟進,或直到一些指標收斂。我們在[圖2-6](../img/fig2-6.png)中看到一個例子,它透過重複跟進標明地點歸屬關係的邊,生成了資料庫中北美包含的所有地點列表(這種演算法被稱為**傳遞閉包transitive closure**)。
可以在分散式檔案系統中儲存圖包含頂點和邊的列表的檔案但是這種“重複至完成”的想法不能用普通的MapReduce來表示因為它只掃過一趟資料。這種演算法因此經常以**迭代**的風格實現: 可以在分散式檔案系統中儲存圖包含頂點和邊的列表的檔案但是這種“重複至完成”的想法不能用普通的MapReduce來表示因為它只掃過一趟資料。這種演算法因此經常以**迭代**的風格實現:
1. 外部排程程式執行批處理來計算演算法的一個步驟。 1. 外部排程程式執行批處理來計算演算法的一個步驟。
2. 當批處理過程完成時,排程器檢查它是否完成(基於完成條件 —— 例如,沒有更多的邊要跟進,或者與上次迭代相比的變化低於某個閾值)。 2. 當批處理過程完成時,排程器檢查它是否完成(基於完成條件 —— 例如,沒有更多的邊要跟進,或者與上次迭代相比的變化低於某個閾值)。
@ -623,113 +623,113 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
#### Pregel處理模型 #### Pregel處理模型
針對圖批處理的最佳化 —— **批次同步並行BSPBulk Synchronous Parallel** 計算模型【70】已經開始流行起來。其中Apache Giraph 【37】Spark的GraphX API和Flink的Gelly API 【71】實現了它。它也被稱為**Pregel**模型因為Google的Pregel論文推廣了這種處理圖的方法【72】。 針對圖批處理的最佳化 —— **批次同步並行BSPBulk 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】。 這種容錯是透過在迭代結束時定期存檔所有頂點的狀態來實現的即將其全部狀態寫入持久化儲存。如果某個節點發生故障並且其記憶體中的狀態丟失則最簡單的解決方法是將整個圖計算回滾到上一個存檔點然後重啟計算。如果演算法是確定性的且訊息記錄在日誌中那麼也可以選擇性地只恢復丟失的分割槽就像之前討論過的資料流引擎【72】。
#### 並行執行 #### 並行執行
頂點不需要知道它在哪臺物理機器上執行當它向其他頂點發送訊息時它只是簡單地將訊息發往某個頂點ID。圖的分割槽取決於框架 —— 即,確定哪個頂點執行在哪臺機器上,以及如何透過網路路由訊息,以便它們到達正確的地方。 頂點不需要知道它在哪臺物理機器上執行當它向其他頂點發送訊息時它只是簡單地將訊息發往某個頂點ID。圖的分割槽取決於框架 —— 即,確定哪個頂點執行在哪臺機器上,以及如何透過網路路由訊息,以便它們到達正確的地方。
由於程式設計模型一次僅處理一個頂點(有時稱為“像頂點一樣思考”),所以框架可以以任意方式對圖分割槽。理想情況下如果頂點需要進行大量的通訊,那麼它們最好能被分割槽到同一臺機器上。然而找到這樣一種最佳化的分割槽方法是很困難的 —— 在實踐中圖經常按照任意分配的頂點ID分割槽而不會嘗試將相關的頂點分組在一起。 由於程式設計模型一次僅處理一個頂點(有時稱為“像頂點一樣思考”),所以框架可以以任意方式對圖分割槽。理想情況下如果頂點需要進行大量的通訊,那麼它們最好能被分割槽到同一臺機器上。然而找到這樣一種最佳化的分割槽方法是很困難的 —— 在實踐中圖經常按照任意分配的頂點ID分割槽而不會嘗試將相關的頂點分組在一起。
因此,圖演算法通常會有很多跨機器通訊的額外開銷,而中間狀態(節點之間傳送的訊息)往往比原始圖大。透過網路傳送訊息的開銷會顯著拖慢分散式圖演算法的速度。 因此,圖演算法通常會有很多跨機器通訊的額外開銷,而中間狀態(節點之間傳送的訊息)往往比原始圖大。透過網路傳送訊息的開銷會顯著拖慢分散式圖演算法的速度。
出於這個原因如果你的圖可以放入一臺計算機的記憶體中那麼單機甚至可能是單執行緒演算法很可能會超越分散式批處理【73,74】。圖比記憶體大也沒關係只要能放入單臺計算機的磁碟使用GraphChi等框架進行單機處理是就一個可行的選擇【75】。如果圖太大不適合單機處理那麼像Pregel這樣的分散式方法是不可避免的。高效的並行圖演算法是一個進行中的研究領域【76】。 出於這個原因如果你的圖可以放入一臺計算機的記憶體中那麼單機甚至可能是單執行緒演算法很可能會超越分散式批處理【73,74】。圖比記憶體大也沒關係只要能放入單臺計算機的磁碟使用GraphChi等框架進行單機處理是就一個可行的選擇【75】。如果圖太大不適合單機處理那麼像Pregel這樣的分散式方法是不可避免的。高效的並行圖演算法是一個進行中的研究領域【76】。
### 高階API和語言 ### 高階API和語言
自MapReduce開始流行的這幾年以來分散式批處理的執行引擎已經很成熟了。到目前為止基礎設施已經足夠強大能夠儲存和處理超過10,000臺機器叢集上的數PB的資料。由於在這種規模下物理執行批處理的問題已經被認為或多或少解決了所以關注點已經轉向其他領域改進程式設計模型提高處理效率擴大這些技術可以解決的問題集。 自MapReduce開始流行的這幾年以來分散式批處理的執行引擎已經很成熟了。到目前為止基礎設施已經足夠強大能夠儲存和處理超過10,000臺機器叢集上的數PB的資料。由於在這種規模下物理執行批處理的問題已經被認為或多或少解決了所以關注點已經轉向其他領域改進程式設計模型提高處理效率擴大這些技術可以解決的問題集。
如前所述HivePigCascading和Crunch等高階語言和API變得越來越流行因為手寫MapReduce作業實在是個苦力活。隨著Tez的出現這些高階語言還有一個額外好處可以遷移到新的資料流執行引擎而無需重寫作業程式碼。 Spark和Flink也有它們自己的高階資料流API通常是從FlumeJava中獲取的靈感【34】。 如前所述HivePigCascading和Crunch等高階語言和API變得越來越流行因為手寫MapReduce作業實在是個苦力活。隨著Tez的出現這些高階語言還有一個額外好處可以遷移到新的資料流執行引擎而無需重寫作業程式碼。 Spark和Flink也有它們自己的高階資料流API通常是從FlumeJava中獲取的靈感【34】。
這些資料流API通常使用關係型構建塊來表達一個計算按某個欄位連線資料集按鍵對元組做分組按某些條件過濾並透過計數求和或其他函式來聚合元組。在內部這些操作是使用本章前面討論過的各種連線和分組演算法來實現的。 這些資料流API通常使用關係型構建塊來表達一個計算按某個欄位連線資料集按鍵對元組做分組按某些條件過濾並透過計數求和或其他函式來聚合元組。在內部這些操作是使用本章前面討論過的各種連線和分組演算法來實現的。
除了少寫程式碼的明顯優勢之外這些高階介面還支援互動式用法在這種互動式使用中你可以在Shell中增量式編寫分析程式碼頻繁執行來觀察它做了什麼。這種開發風格在探索資料集和試驗處理方法時非常有用。這也讓人聯想到Unix哲學我們在“[Unix哲學](#Unix哲學)”中討論過這個問題。 除了少寫程式碼的明顯優勢之外這些高階介面還支援互動式用法在這種互動式使用中你可以在Shell中增量式編寫分析程式碼頻繁執行來觀察它做了什麼。這種開發風格在探索資料集和試驗處理方法時非常有用。這也讓人聯想到Unix哲學我們在“[Unix哲學](#Unix哲學)”中討論過這個問題。
此外,這些高階介面不僅提高了人類的工作效率,也提高了機器層面的作業執行效率。 此外,這些高階介面不僅提高了人類的工作效率,也提高了機器層面的作業執行效率。
#### 向宣告式查詢語言的轉變 #### 向宣告式查詢語言的轉變
與硬寫執行連線的程式碼相比,指定連線關係運算元的優點是,框架可以分析連線輸入的屬性,並自動決定哪種上述連線演算法最適合當前任務。 HiveSpark和Flink都有基於代價的查詢最佳化器可以做到這一點甚至可以改變連線順序最小化中間狀態的數量【66,77,78,79】。 與硬寫執行連線的程式碼相比,指定連線關係運算元的優點是,框架可以分析連線輸入的屬性,並自動決定哪種上述連線演算法最適合當前任務。 HiveSpark和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** —— 也就是說,你還有活兒要幹,然而它的輸入是永無止境的資料流。在這種情況下,作業永無完成之日。因為在任何時候都可能有更多的工作湧入。我們將看到,在某些方面上,流處理和批處理是相似的。但是關於無盡資料流的假設也對我們構建系統的方式產生了很多改變。

View File

@ -10,45 +10,45 @@
[TOC] [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** 在批處理中,檔案被寫入一次,然後可能被多個作業讀取。類似地,在流處理術語中,一個事件由 **生產者producer** (也稱為 **釋出者publisher****傳送者sender** )生成一次,然後可能由多個 **消費者consumer** **訂閱者subscribers****接收者recipients** 進行處理【3】。在檔案系統中檔名標識一組相關記錄在流式系統中相關的事件通常被聚合為一個 **主題topic****流stream**
原則上講,檔案或資料庫就足以連線生產者和消費者:生產者將其生成的每個事件寫入資料儲存,且每個消費者定期輪詢資料儲存,檢查自上次執行以來新出現的事件。這實際上正是批處理在每天結束時處理當天資料時所做的事情。 原則上講,檔案或資料庫就足以連線生產者和消費者:生產者將其生成的每個事件寫入資料儲存,且每個消費者定期輪詢資料儲存,檢查自上次執行以來新出現的事件。這實際上正是批處理在每天結束時處理當天資料時所做的事情。
但當我們想要進行低延遲的連續處理時,如果資料儲存不是為這種用途專門設計的,那麼輪詢開銷就會很大。輪詢的越頻繁,能返回新事件的請求比例就越低,而額外開銷也就越高。相比之下,最好能在新事件出現時直接通知消費者。 但當我們想要進行低延遲的連續處理時,如果資料儲存不是為這種用途專門設計的,那麼輪詢開銷就會很大。輪詢的越頻繁,能返回新事件的請求比例就越低,而額外開銷也就越高。相比之下,最好能在新事件出現時直接通知消費者。
資料庫在傳統上對這種通知機制支援的並不好,關係型資料庫通常有 **觸發器trigger** 它們可以對變化插入表中的一行作出反應但是它們的功能非常有限並且在資料庫設計中有些後顧之憂【4,5】。相應的是已經開發了專門的工具來提供事件通知。 資料庫在傳統上對這種通知機制支援的並不好,關係型資料庫通常有 **觸發器trigger** 它們可以對變化插入表中的一行作出反應但是它們的功能非常有限並且在資料庫設計中有些後顧之憂【4,5】。相應的是已經開發了專門的工具來提供事件通知。
### 訊息傳遞系統 ### 訊息傳遞系統
向消費者通知新事件的常用方式是使用**訊息傳遞系統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#網路擁塞和排隊)”)。
@ -56,9 +56,9 @@
2. **如果節點崩潰或暫時離線,會發生什麼情況? —— 是否會有訊息丟失?** 與資料庫一樣,永續性可能需要寫入磁碟和/或複製的某種組合(請參閱“[複製與永續性](ch7.md#複製與永續性)”),這是有代價的。如果你能接受有時訊息會丟失,則可能在同一硬體上獲得更高的吞吐量和更低的延遲。 2. **如果節點崩潰或暫時離線,會發生什麼情況? —— 是否會有訊息丟失?** 與資料庫一樣,永續性可能需要寫入磁碟和/或複製的某種組合(請參閱“[複製與永續性](ch7.md#複製與永續性)”),這是有代價的。如果你能接受有時訊息會丟失,則可能在同一硬體上獲得更高的吞吐量和更低的延遲。
是否可以接受訊息丟失取決於應用。例如對於週期傳輸的感測器讀數和指標偶爾丟失的資料點可能並不重要因為更新的值會在短時間內發出。但要注意如果大量的訊息被丟棄可能無法立刻意識到指標已經不正確了【7】。如果你正在對事件計數那麼它們能夠可靠送達是更重要的因為每個丟失的訊息都意味著使計數器的錯誤擴大。 是否可以接受訊息丟失取決於應用。例如對於週期傳輸的感測器讀數和指標偶爾丟失的資料點可能並不重要因為更新的值會在短時間內發出。但要注意如果大量的訊息被丟棄可能無法立刻意識到指標已經不正確了【7】。如果你正在對事件計數那麼它們能夠可靠送達是更重要的因為每個丟失的訊息都意味著使計數器的錯誤擴大。
我們在[第十章](ch10.md)中探討的批處理系統的一個很好的特性是,它們提供了強大的可靠性保證:失敗的任務會自動重試,失敗任務的部分輸出會自動丟棄。這意味著輸出與沒有發生故障一樣,這有助於簡化程式設計模型。在本章的後面,我們將研究如何在流處理的上下文中提供類似的保證。 我們在[第十章](ch10.md)中探討的批處理系統的一個很好的特性是,它們提供了強大的可靠性保證:失敗的任務會自動重試,失敗任務的部分輸出會自動丟棄。這意味著輸出與沒有發生故障一樣,這有助於簡化程式設計模型。在本章的後面,我們將研究如何在流處理的上下文中提供類似的保證。
#### 直接從生產者傳遞給消費者 #### 直接從生產者傳遞給消費者
@ -69,94 +69,94 @@
* StatsD 【10】和Brubeck 【7】使用不可靠的UDP訊息傳遞來收集網路中所有機器的指標並對其進行監控。 在StatsD協議中只有接收到所有訊息才認為計數器指標是正確的使用UDP將使得指標處在一種最佳近似狀態【11】。另請參閱“[TCP與UDP](ch8.md#TCP與UDP)” * 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發出請求。 * 如果消費者在網路上公開了服務生產者可以直接傳送HTTP或RPC請求請參閱“[服務中的資料流REST與RPC](ch4.md#服務中的資料流REST與RPC)”將訊息推送給使用者。這就是webhooks背後的想法【12】一種服務的回撥URL被註冊到另一個服務中並且每當事件發生時都會向該URL發出請求。
儘管這些直接訊息傳遞系統在設計它們的環境中執行良好,但是它們通常要求應用程式碼意識到訊息丟失的可能性。它們的容錯程度極為有限:即使協議檢測到並重傳在網路中丟失的資料包,它們通常也只是假設生產者和消費者始終線上。 儘管這些直接訊息傳遞系統在設計它們的環境中執行良好,但是它們通常要求應用程式碼意識到訊息丟失的可能性。它們的容錯程度極為有限:即使協議檢測到並重傳在網路中丟失的資料包,它們通常也只是假設生產者和消費者始終線上。
如果消費者處於離線狀態,則可能會丟失其不可達時傳送的訊息。一些協議允許生產者重試失敗的訊息傳遞,但當生產者崩潰時,它可能會丟失訊息緩衝區及其本應傳送的訊息,這種方法可能就沒用了。 如果消費者處於離線狀態,則可能會丟失其不可達時傳送的訊息。一些協議允許生產者重試失敗的訊息傳遞,但當生產者崩潰時,它可能會丟失訊息緩衝區及其本應傳送的訊息,這種方法可能就沒用了。
#### 訊息代理 #### 訊息代理
一種廣泛使用的替代方法是透過**訊息代理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*** ***負載均衡load balancing***
每條訊息都被傳遞給消費者**之一**所以處理該主題下訊息的工作能被多個消費者共享。代理可以為消費者任意分配訊息。當處理訊息的代價高昂希望能並行處理訊息時此模式非常有用在AMQP中可以透過讓多個客戶端從同一個佇列中消費來實現負載均衡而在JMS中則稱之為**共享訂閱shared subscription**)。 每條訊息都被傳遞給消費者**之一**所以處理該主題下訊息的工作能被多個消費者共享。代理可以為消費者任意分配訊息。當處理訊息的代價高昂希望能並行處理訊息時此模式非常有用在AMQP中可以透過讓多個客戶端從同一個佇列中消費來實現負載均衡而在JMS中則稱之為**共享訂閱shared subscription**)。
***扇出fan-out*** ***扇出fan-out***
每條訊息都被傳遞給**所有**消費者。扇出允許幾個獨立的消費者各自“收聽”相同的訊息廣播,而不會相互影響 —— 這個流處理中的概念對應批處理中多個不同批處理作業讀取同一份輸入檔案 JMS中的主題訂閱與AMQP中的交叉繫結提供了這一功能 每條訊息都被傳遞給**所有**消費者。扇出允許幾個獨立的消費者各自“收聽”相同的訊息廣播,而不會相互影響 —— 這個流處理中的概念對應批處理中多個不同批處理作業讀取同一份輸入檔案 JMS中的主題訂閱與AMQP中的交叉繫結提供了這一功能
![](../img/fig11-1.png) ![](../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按照m4m3m5的順序處理訊息。因此m3和m4的交付順序與生產者1的傳送順序不同。 當與負載均衡相結合時,這種重傳行為對訊息的順序有種有趣的影響。在[圖11-2](../img/fig11-2.png)中消費者通常按照生產者傳送的順序處理訊息。然而消費者2在處理訊息m3時崩潰與此同時消費者1正在處理訊息m4。未確認的訊息m3隨後被重新發送給消費者1結果消費者1按照m4m3m5的順序處理訊息。因此m3和m4的交付順序與生產者1的傳送順序不同。
![](../img/fig11-2.png) ![](../img/fig11-2.png)
**圖11-2 在處理m3時消費者2崩潰因此稍後重傳至消費者1** **圖11-2 在處理m3時消費者2崩潰因此稍後重傳至消費者1**
即使訊息代理試圖保留訊息的順序如JMS和AMQP標準所要求的負載均衡與重傳的組合也不可避免地導致訊息被重新排序。為避免此問題你可以讓每個消費者使用單獨的佇列即不使用負載均衡功能。如果訊息是完全獨立的則訊息順序重排並不是一個問題。但正如我們將在本章後續部分所述如果訊息之間存在因果依賴關係這就是一個很重要的問題。 即使訊息代理試圖保留訊息的順序如JMS和AMQP標準所要求的負載均衡與重傳的組合也不可避免地導致訊息被重新排序。為避免此問題你可以讓每個消費者使用單獨的佇列即不使用負載均衡功能。如果訊息是完全獨立的則訊息順序重排並不是一個問題。但正如我們將在本章後續部分所述如果訊息之間存在因果依賴關係這就是一個很重要的問題。
### 分割槽日誌 ### 分割槽日誌
透過網路傳送資料包或向網路服務傳送請求通常是短暫的操作,不會留下永久的痕跡。儘管可以永久記錄(透過抓包與日誌),但我們通常不這麼做。即使是將訊息持久地寫入磁碟的訊息代理,在送達給消費者之後也會很快刪除訊息,因為它們建立在短暫訊息傳遞的思維方式上。 透過網路傳送資料包或向網路服務傳送請求通常是短暫的操作,不會留下永久的痕跡。儘管可以永久記錄(透過抓包與日誌),但我們通常不這麼做。即使是將訊息持久地寫入磁碟的訊息代理,在送達給消費者之後也會很快刪除訊息,因為它們建立在短暫訊息傳遞的思維方式上。
資料庫和檔案系統採用截然相反的方法論:至少在某人顯式刪除前,通常寫入資料庫或檔案的所有內容都要被永久記錄下來。 資料庫和檔案系統採用截然相反的方法論:至少在某人顯式刪除前,通常寫入資料庫或檔案的所有內容都要被永久記錄下來。
這種思維方式上的差異對建立衍生資料的方式有巨大影響。如[第十章](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) ![](../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]。 * 共享消費主題工作的節點數,最多為該主題中的日誌分割槽數,因為同一個分割槽內的所有訊息被遞送到同一個節點[^i]。
* 如果某條訊息處理緩慢,則它會阻塞該分割槽中後續訊息的處理(一種行首阻塞的形式;請參閱“[描述效能](ch1.md#描述效能)”)。 * 如果某條訊息處理緩慢,則它會阻塞該分割槽中後續訊息的處理(一種行首阻塞的形式;請參閱“[描述效能](ch1.md#描述效能)”)。
@ -167,85 +167,85 @@
#### 消費者偏移量 #### 消費者偏移量
順序消費一個分割槽使得判斷訊息是否已經被處理變得相當容易:所有偏移量小於消費者的當前偏移量的訊息已經被處理,而具有更大偏移量的訊息還沒有被看到。因此,代理不需要跟蹤確認每條訊息,只需要定期記錄消費者的偏移即可。這種方法減少了額外簿記開銷,而且在批處理和流處理中採用這種方法有助於提高基於日誌的系統的吞吐量。 順序消費一個分割槽使得判斷訊息是否已經被處理變得相當容易:所有偏移量小於消費者的當前偏移量的訊息已經被處理,而具有更大偏移量的訊息還沒有被看到。因此,代理不需要跟蹤確認每條訊息,只需要定期記錄消費者的偏移即可。這種方法減少了額外簿記開銷,而且在批處理和流處理中採用這種方法有助於提高基於日誌的系統的吞吐量。
實際上,這種偏移量與單領導者資料庫複製中常見的日誌序列號非常相似,我們在“[設定新從庫](ch5.md#設定新從庫)”中討論了這種情況。在資料庫複製中,日誌序列號允許跟隨者斷開連線後,重新連線到領導者,並在不跳過任何寫入的情況下恢復複製。這裡原理完全相同:訊息代理表現得像一個主庫,而消費者就像一個從庫。 實際上,這種偏移量與單領導者資料庫複製中常見的日誌序列號非常相似,我們在“[設定新從庫](ch5.md#設定新從庫)”中討論了這種情況。在資料庫複製中,日誌序列號允許跟隨者斷開連線後,重新連線到領導者,並在不跳過任何寫入的情況下恢復複製。這裡原理完全相同:訊息代理表現得像一個主庫,而消費者就像一個從庫。
如果消費者節點失效,則失效消費者的分割槽將指派給其他節點,並從最後記錄的偏移量開始消費訊息。如果消費者已經處理了後續的訊息,但還沒有記錄它們的偏移量,那麼重啟後這些訊息將被處理兩次。我們將在本章後面討論這個問題的處理方法。 如果消費者節點失效,則失效消費者的分割槽將指派給其他節點,並從最後記錄的偏移量開始消費訊息。如果消費者已經處理了後續的訊息,但還沒有記錄它們的偏移量,那麼重啟後這些訊息將被處理兩次。我們將在本章後面討論這個問題的處理方法。
#### 磁碟空間使用 #### 磁碟空間使用
如果只追加寫入日誌,則磁碟空間終究會耗盡。為了回收磁碟空間,日誌實際上被分割成段,並不時地將舊段刪除或移動到歸檔儲存。 (我們將在後面討論一種更為複雜的磁碟空間釋放方式) 如果只追加寫入日誌,則磁碟空間終究會耗盡。為了回收磁碟空間,日誌實際上被分割成段,並不時地將舊段刪除或移動到歸檔儲存。 (我們將在後面討論一種更為複雜的磁碟空間釋放方式)
這就意味著如果一個慢消費者跟不上訊息產生的速率而落後得太多,它的消費偏移量指向了刪除的段,那麼它就會錯過一些訊息。實際上,日誌實現了一個有限大小的緩衝區,當緩衝區填滿時會丟棄舊訊息,它也被稱為**迴圈緩衝區circular buffer** 或**環形緩衝區ring buffer**。不過由於緩衝區在磁碟上,因此緩衝區可能相當的大。 這就意味著如果一個慢消費者跟不上訊息產生的速率而落後得太多,它的消費偏移量指向了刪除的段,那麼它就會錯過一些訊息。實際上,日誌實現了一個有限大小的緩衝區,當緩衝區填滿時會丟棄舊訊息,它也被稱為**迴圈緩衝區circular buffer** 或**環形緩衝區ring buffer**。不過由於緩衝區在磁碟上,因此緩衝區可能相當的大。
讓我們做個簡單計算。在撰寫本文時典型的大型硬碟容量為6TB順序寫入吞吐量為150MB/s。如果以最快的速度寫訊息則需要大約11個小時才能填滿磁碟。因而磁碟可以緩衝11個小時的訊息之後它將開始覆蓋舊的訊息。即使使用多個磁碟和機器這個比率也是一樣的。實踐中的部署很少能用滿磁碟的寫入頻寬所以通常可以儲存一個幾天甚至幾周的日誌緩衝區。 讓我們做個簡單計算。在撰寫本文時典型的大型硬碟容量為6TB順序寫入吞吐量為150MB/s。如果以最快的速度寫訊息則需要大約11個小時才能填滿磁碟。因而磁碟可以緩衝11個小時的訊息之後它將開始覆蓋舊的訊息。即使使用多個磁碟和機器這個比率也是一樣的。實踐中的部署很少能用滿磁碟的寫入頻寬所以通常可以儲存一個幾天甚至幾周的日誌緩衝區。
不管保留多長時間的訊息日誌的吞吐量或多或少保持不變因為無論如何每個訊息都會被寫入磁碟【18】。這種行為與預設將訊息儲存在記憶體中僅當佇列太長時才寫入磁碟的訊息傳遞系統形成鮮明對比。當佇列很短時這些系統非常快而當這些系統開始寫入磁碟時就要慢的多所以吞吐量取決於保留的歷史數量。 不管保留多長時間的訊息日誌的吞吐量或多或少保持不變因為無論如何每個訊息都會被寫入磁碟【18】。這種行為與預設將訊息儲存在記憶體中僅當佇列太長時才寫入磁碟的訊息傳遞系統形成鮮明對比。當佇列很短時這些系統非常快而當這些系統開始寫入磁碟時就要慢的多所以吞吐量取決於保留的歷史數量。
#### 當消費者跟不上生產者時 #### 當消費者跟不上生產者時
在“[訊息傳遞系統](#訊息傳遞系統)”中,如果消費者無法跟上生產者傳送資訊的速度時,我們討論了三種選擇:丟棄資訊,進行緩衝或施加背壓。在這種分類法裡,基於日誌的方法是緩衝的一種形式,具有很大但大小固定的緩衝區(受可用磁碟空間的限制)。 在“[訊息傳遞系統](#訊息傳遞系統)”中,如果消費者無法跟上生產者傳送資訊的速度時,我們討論了三種選擇:丟棄資訊,進行緩衝或施加背壓。在這種分類法裡,基於日誌的方法是緩衝的一種形式,具有很大但大小固定的緩衝區(受可用磁碟空間的限制)。
如果消費者遠遠落後,而所要求的資訊比保留在磁碟上的資訊還要舊,那麼它將不能讀取這些資訊,所以代理實際上丟棄了比緩衝區容量更大的舊資訊。你可以監控消費者落後日誌頭部的距離,如果落後太多就發出報警。由於緩衝區很大,因而有足夠的時間讓運維人員來修復慢消費者,並在訊息開始丟失之前讓其趕上。 如果消費者遠遠落後,而所要求的資訊比保留在磁碟上的資訊還要舊,那麼它將不能讀取這些資訊,所以代理實際上丟棄了比緩衝區容量更大的舊資訊。你可以監控消費者落後日誌頭部的距離,如果落後太多就發出報警。由於緩衝區很大,因而有足夠的時間讓運維人員來修復慢消費者,並在訊息開始丟失之前讓其趕上。
即使消費者真的落後太多開始丟失訊息,也只有那個消費者受到影響;它不會中斷其他消費者的服務。這是一個巨大的運維優勢:你可以實驗性地消費生產日誌,以進行開發,測試或除錯,而不必擔心會中斷生產服務。當消費者關閉或崩潰時,會停止消耗資源,唯一剩下的只有消費者偏移量。 即使消費者真的落後太多開始丟失訊息,也只有那個消費者受到影響;它不會中斷其他消費者的服務。這是一個巨大的運維優勢:你可以實驗性地消費生產日誌,以進行開發,測試或除錯,而不必擔心會中斷生產服務。當消費者關閉或崩潰時,會停止消耗資源,唯一剩下的只有消費者偏移量。
這種行為也與傳統的訊息代理形成了鮮明對比,在那種情況下,你需要小心地刪除那些消費者已經關閉的佇列—— 否則那些佇列就會累積不必要的訊息,從其他仍活躍的消費者那裡佔走記憶體。 這種行為也與傳統的訊息代理形成了鮮明對比,在那種情況下,你需要小心地刪除那些消費者已經關閉的佇列—— 否則那些佇列就會累積不必要的訊息,從其他仍活躍的消費者那裡佔走記憶體。
#### 重播舊訊息 #### 重播舊訊息
我們之前提到使用AMQP和JMS風格的訊息代理處理和確認訊息是一個破壞性的操作因為它會導致訊息在代理上被刪除。另一方面在基於日誌的訊息代理中使用訊息更像是從檔案中讀取資料這是隻讀操作不會更改日誌。 我們之前提到使用AMQP和JMS風格的訊息代理處理和確認訊息是一個破壞性的操作因為它會導致訊息在代理上被刪除。另一方面在基於日誌的訊息代理中使用訊息更像是從檔案中讀取資料這是隻讀操作不會更改日誌。
除了消費者的任何輸出之外,處理的唯一副作用是消費者偏移量的前進。但偏移量是在消費者的控制之下的,所以如果需要的話可以很容易地操縱:例如你可以用昨天的偏移量跑一個消費者副本,並將輸出寫到不同的位置,以便重新處理最近一天的訊息。你可以使用各種不同的處理程式碼重複任意次。 除了消費者的任何輸出之外,處理的唯一副作用是消費者偏移量的前進。但偏移量是在消費者的控制之下的,所以如果需要的話可以很容易地操縱:例如你可以用昨天的偏移量跑一個消費者副本,並將輸出寫到不同的位置,以便重新處理最近一天的訊息。你可以使用各種不同的處理程式碼重複任意次。
這一方面使得基於日誌的訊息傳遞更像上一章的批處理其中衍生資料透過可重複的轉換過程與輸入資料顯式分離。它允許進行更多的實驗更容易從錯誤和漏洞中恢復使其成為在組織內整合資料流的良好工具【24】。 這一方面使得基於日誌的訊息傳遞更像上一章的批處理其中衍生資料透過可重複的轉換過程與輸入資料顯式分離。它允許進行更多的實驗更容易從錯誤和漏洞中恢復使其成為在組織內整合資料流的良好工具【24】。
## 資料庫與流 ## 資料庫與流
我們已經在訊息代理和資料庫之間進行了一些比較。儘管傳統上它們被視為單獨的工具類別,但是我們看到基於日誌的訊息代理已經成功地從資料庫中獲取靈感並將其應用於訊息傳遞。我們也可以反過來:從訊息傳遞和流中獲取靈感,並將它們應用於資料庫。 我們已經在訊息代理和資料庫之間進行了一些比較。儘管傳統上它們被視為單獨的工具類別,但是我們看到基於日誌的訊息代理已經成功地從資料庫中獲取靈感並將其應用於訊息傳遞。我們也可以反過來:從訊息傳遞和流中獲取靈感,並將它們應用於資料庫。
我們之前曾經說過,事件是某個時刻發生的事情的記錄。發生的事情可能是使用者操作(例如鍵入搜尋查詢)或讀取感測器,但也可能是**寫入資料庫**。某些東西被寫入資料庫的事實是可以被捕獲、儲存和處理的事件。這一觀察結果表明,資料庫和資料流之間的聯絡不僅僅是磁碟日誌的物理儲存 —— 而是更深層的聯絡。 我們之前曾經說過,事件是某個時刻發生的事情的記錄。發生的事情可能是使用者操作(例如鍵入搜尋查詢)或讀取感測器,但也可能是**寫入資料庫**。某些東西被寫入資料庫的事實是可以被捕獲、儲存和處理的事件。這一觀察結果表明,資料庫和資料流之間的聯絡不僅僅是磁碟日誌的物理儲存 —— 而是更深層的聯絡。
事實上,複製日誌(請參閱“[複製日誌的實現](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) ![](../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) ![](../img/fig11-5.png)
@ -253,99 +253,99 @@
#### 變更資料捕獲的實現 #### 變更資料捕獲的實現
我們可以將日誌消費者叫做**衍生資料系統**,正如在[第三部分](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支援 #### 變更流的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} \\ state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\
stream(t) = \frac{d\ state(t)}{dt} stream(t) = \frac{d\ state(t)}{dt}
@ -354,55 +354,55 @@ $$
**圖11-6 應用當前狀態與事件流之間的關係** **圖11-6 應用當前狀態與事件流之間的關係**
如果你持久儲存了變更日誌那麼重現狀態就非常簡單。如果你認為事件日誌是你的記錄系統而所有的衍生狀態都從它派生而來那麼系統中的資料流動就容易理解的多。正如帕特·赫蘭Pat Helland所說的【52】 如果你持久儲存了變更日誌那麼重現狀態就非常簡單。如果你認為事件日誌是你的記錄系統而所有的衍生狀態都從它派生而來那麼系統中的資料流動就容易理解的多。正如帕特·赫蘭Pat Helland所說的【52】
> 事務日誌記錄了資料庫的所有變更。高速追加是更改日誌的唯一方法。從這個角度來看,資料庫的內容其實是日誌中記錄最新值的快取。日誌才是真相,資料庫是日誌子集的快取,這一快取子集恰好來自日誌中每條記錄與索引值的最新值。 > 事務日誌記錄了資料庫的所有變更。高速追加是更改日誌的唯一方法。從這個角度來看,資料庫的內容其實是日誌中記錄最新值的快取。日誌才是真相,資料庫是日誌子集的快取,這一快取子集恰好來自日誌中每條記錄與索引值的最新值。
日誌壓縮(如“[日誌壓縮](#日誌壓縮)”中所述)是連線日誌與資料庫狀態之間的橋樑:它只保留每條記錄的最新版本,並丟棄被覆蓋的版本。 日誌壓縮(如“[日誌壓縮](#日誌壓縮)”中所述)是連線日誌與資料庫狀態之間的橋樑:它只保留每條記錄的最新版本,並丟棄被覆蓋的版本。
#### 不可變事件的優點 #### 不可變事件的優點
資料庫中的不變性是一個古老的概念。例如會計在幾個世紀以來一直在財務記賬中應用不變性。一筆交易發生時它被記錄在一個僅追加寫入的分類帳中實質上是描述貨幣、商品或服務轉手的事件日誌。賬目比如利潤、虧損、資產負債表是從分類賬中的交易求和衍生而來【53】。 資料庫中的不變性是一個古老的概念。例如會計在幾個世紀以來一直在財務記賬中應用不變性。一筆交易發生時它被記錄在一個僅追加寫入的分類帳中實質上是描述貨幣、商品或服務轉手的事件日誌。賬目比如利潤、虧損、資產負債表是從分類賬中的交易求和衍生而來【53】。
如果發生錯誤,會計師不會刪除或更改分類帳中的錯誤交易 —— 而是新增另一筆交易以補償錯誤例如退還一筆不正確的費用。不正確的交易將永遠保留在分類帳中對於審計而言可能非常重要。如果從不正確的分類賬衍生出的錯誤數字已經公佈那麼下一個會計週期的數字就會包括一個更正。這個過程在會計事務中是很常見的【54】。 如果發生錯誤,會計師不會刪除或更改分類帳中的錯誤交易 —— 而是新增另一筆交易以補償錯誤例如退還一筆不正確的費用。不正確的交易將永遠保留在分類帳中對於審計而言可能非常重要。如果從不正確的分類賬衍生出的錯誤數字已經公佈那麼下一個會計週期的數字就會包括一個更正。這個過程在會計事務中是很常見的【54】。
儘管這種可審計性只在金融系統中尤其重要,但對於不受這種嚴格監管的許多其他系統,也是很有幫助的。如“[批處理輸出的哲學](ch10.md#批處理輸出的哲學)”中所討論的,如果你意外地部署了將錯誤資料寫入資料庫的錯誤程式碼,當代碼會破壞性地覆寫資料時,恢復要困難得多。使用不可變事件的僅追加日誌,診斷問題與故障恢復就要容易的多。 儘管這種可審計性只在金融系統中尤其重要,但對於不受這種嚴格監管的許多其他系統,也是很有幫助的。如“[批處理輸出的哲學](ch10.md#批處理輸出的哲學)”中所討論的,如果你意外地部署了將錯誤資料寫入資料庫的錯誤程式碼,當代碼會破壞性地覆寫資料時,恢復要困難得多。使用不可變事件的僅追加日誌,診斷問題與故障恢復就要容易的多。
不可變的事件也包含了比當前狀態更多的資訊。例如在購物網站上顧客可以將物品新增到他們的購物車然後再將其移除。雖然從履行訂單的角度第二個事件取消了第一個事件但對分析目的而言知道客戶考慮過某個特定項而之後又反悔可能是很有用的。也許他們會選擇在未來購買或者他們已經找到了替代品。這個資訊被記錄在事件日誌中但對於移出購物車就刪除記錄的資料庫而言這個資訊在移出購物車時可能就丟失了【42】。 不可變的事件也包含了比當前狀態更多的資訊。例如在購物網站上顧客可以將物品新增到他們的購物車然後再將其移除。雖然從履行訂單的角度第二個事件取消了第一個事件但對分析目的而言知道客戶考慮過某個特定項而之後又反悔可能是很有用的。也許他們會選擇在未來購買或者他們已經找到了替代品。這個資訊被記錄在事件日誌中但對於移出購物車就刪除記錄的資料庫而言這個資訊在移出購物車時可能就丟失了【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】。 新增從事件日誌到資料庫的顯式轉換能夠使應用更容易地隨時間演進如果你想要引入一個新功能以新的方式表示現有資料則可以使用事件日誌來構建一個單獨的、針對新功能的讀取最佳化檢視無需修改現有系統而與之共存。並行執行新舊系統通常比在現有系統中執行復雜的模式遷移更容易。一旦不再需要舊的系統你可以簡單地關閉它並回收其資源【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#索引和快照隔離)” )。 GitMercurial和Fossil等版本控制系統也依靠不可變的資料來儲存檔案的版本歷史記錄。 許多不使用事件溯源模型的系統也還是依賴不可變性:各種資料庫在內部使用不可變的資料結構或多版本資料來支援時間點快照(請參閱“[索引和快照隔離](ch7.md#索引和快照隔離)” )。 GitMercurial和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#立法與自律)”中所看到的。
@ -416,11 +416,11 @@ $$
2. 你能以某種方式將事件推送給使用者,例如傳送報警郵件或推送通知,或將事件流式傳輸到可實時顯示的儀表板上。在這種情況下,人是流的最終消費者。 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側連線與分組)”)。容錯機制也必須改變:對於已經運行了幾分鐘的批處理作業,可以簡單地從頭開始重啟失敗任務,但是對於已經執行數年的流作業,重啟後從頭開始跑可能並不是一個可行的選項。
### 流處理的應用 ### 流處理的應用
@ -435,47 +435,47 @@ $$
#### 複合事件處理 #### 複合事件處理
**複合事件處理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】ApamaTIBCO StreamBase和SQLstream。像Samza這樣的分散式流處理元件支援使用SQL在流上進行宣告式查詢【71】。 CEP的實現包括Esper【69】IBM InfoSphere Streams【70】ApamaTIBCO 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 StormSpark StreamingFlinkConcordSamza和Kafka Streams 【74】。託管服務包括Google Cloud Dataflow和Azure Stream Analytics。 許多開源分散式流處理框架的設計都是針對分析設計的例如Apache StormSpark StreamingFlinkConcordSamza和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 #### 訊息傳遞和RPC
在“[訊息傳遞中的資料流](ch4.md#訊息傳遞中的資料流)”中我們討論過訊息傳遞系統可以作為RPC的替代方案即作為一種服務間通訊的機制比如在Actor模型中所使用的那樣。儘管這些系統也是基於訊息和事件但我們通常不會將其視作流處理元件 在“[訊息傳遞中的資料流](ch4.md#訊息傳遞中的資料流)”中我們討論過訊息傳遞系統可以作為RPC的替代方案即作為一種服務間通訊的機制比如在Actor模型中所使用的那樣。儘管這些系統也是基於訊息和事件但我們通常不會將其視作流處理元件
* Actor框架主要是管理模組通訊的併發和分散式執行的一種機制而流處理主要是一種資料管理技術。 * Actor框架主要是管理模組通訊的併發和分散式執行的一種機制而流處理主要是一種資料管理技術。
* Actor之間的交流往往是短暫的、一對一的而事件日誌則是持久的、多訂閱者的。 * Actor之間的交流往往是短暫的、一對一的而事件日誌則是持久的、多訂閱者的。
@ -483,29 +483,29 @@ $$
也就是說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) ![](../img/fig11-7.png)
@ -513,11 +513,11 @@ $$
#### 知道什麼時候準備好了 #### 知道什麼時候準備好了
用事件時間來定義視窗的一個棘手的問題是,你永遠也無法確定是不是已經收到了特定視窗的所有事件,還是說還有一些事件正在來的路上。 用事件時間來定義視窗的一個棘手的問題是,你永遠也無法確定是不是已經收到了特定視窗的所有事件,還是說還有一些事件正在來的路上。
例如假設你將事件分組為一分鐘的視窗以便統計每分鐘的請求數。你已經計數了一些帶有本小時內第37分鐘時間戳的事件時間流逝現在進入的主要都是本小時內第38和第39分鐘的事件。什麼時候才能宣佈你已經完成了第37分鐘的視窗計數並輸出其計數器值 例如假設你將事件分組為一分鐘的視窗以便統計每分鐘的請求數。你已經計數了一些帶有本小時內第37分鐘時間戳的事件時間流逝現在進入的主要都是本小時內第38和第39分鐘的事件。什麼時候才能宣佈你已經完成了第37分鐘的視窗計數並輸出其計數器值
在一段時間沒有看到任何新的事件之後,你可以超時並宣佈一個視窗已經就緒,但仍然可能發生這種情況:某些事件被緩衝在另一臺機器上,由於網路中斷而延遲。你需要能夠處理這種在視窗宣告完成之後到達的 **滯留straggler** 事件。大體上你有兩種選擇【1】 在一段時間沒有看到任何新的事件之後,你可以超時並宣佈一個視窗已經就緒,但仍然可能發生這種情況:某些事件被緩衝在另一臺機器上,由於網路中斷而延遲。你需要能夠處理這種在視窗宣告完成之後到達的 **滯留straggler** 事件。大體上你有兩種選擇【1】
1. 忽略這些滯留事件,因為在正常情況下它們可能只是事件中的一小部分。你可以將丟棄事件的數量作為一個監控指標,並在出現大量丟訊息的情況時報警。 1. 忽略這些滯留事件,因為在正常情況下它們可能只是事件中的一小部分。你可以將丟棄事件的數量作為一個監控指標,並在出現大量丟訊息的情況時報警。
2. 釋出一個**更正correction**,一個包括滯留事件的更新視窗值。你可能還需要收回以前的輸出。 2. 釋出一個**更正correction**,一個包括滯留事件的更新視窗值。你可能還需要收回以前的輸出。
@ -526,9 +526,9 @@ $$
#### 你用的是誰的時鐘? #### 你用的是誰的時鐘?
當事件可能在系統內多個地方進行緩衝時,為事件分配時間戳更加困難了。例如,考慮一個移動應用向伺服器上報關於用量的事件。該應用可能會在裝置處於離線狀態時被使用,在這種情況下,它將在裝置本地緩衝事件,並在下一次網際網路連線可用時向伺服器上報這些事件(可能是幾小時甚至幾天)。對於這個流的任意消費者而言,它們就如延遲極大的滯留事件一樣。 當事件可能在系統內多個地方進行緩衝時,為事件分配時間戳更加困難了。例如,考慮一個移動應用向伺服器上報關於用量的事件。該應用可能會在裝置處於離線狀態時被使用,在這種情況下,它將在裝置本地緩衝事件,並在下一次網際網路連線可用時向伺服器上報這些事件(可能是幾小時甚至幾天)。對於這個流的任意消費者而言,它們就如延遲極大的滯留事件一樣。
在這種情況下,事件上的事件戳實際上應當是使用者交互發生的時間,取決於移動裝置的本地時鐘。然而使用者控制的裝置上的時鐘通常是不可信的,因為它可能會被無意或故意設定成錯誤的時間(請參閱“[時鐘同步與準確性](ch8.md#時鐘同步與準確性)”)。伺服器收到事件的時間(取決於伺服器的時鐘)可能是更準確的,因為伺服器在你的控制之下,但在描述使用者互動方面意義不大。 在這種情況下,事件上的事件戳實際上應當是使用者交互發生的時間,取決於移動裝置的本地時鐘。然而使用者控制的裝置上的時鐘通常是不可信的,因為它可能會被無意或故意設定成錯誤的時間(請參閱“[時鐘同步與準確性](ch8.md#時鐘同步與準確性)”)。伺服器收到事件的時間(取決於伺服器的時鐘)可能是更準確的,因為伺服器在你的控制之下,但在描述使用者互動方面意義不大。
要校正不正確的裝置時鐘一種方法是記錄三個時間戳【82】 要校正不正確的裝置時鐘一種方法是記錄三個時間戳【82】
@ -538,61 +538,61 @@ $$
透過從第三個時間戳中減去第二個時間戳,可以估算裝置時鐘和伺服器時鐘之間的偏移(假設網路延遲與所需的時間戳精度相比可忽略不計)。然後可以將該偏移應用於事件時間戳,從而估計事件實際發生的真實時間(假設裝置時鐘偏移在事件發生時與送往伺服器之間沒有變化)。 透過從第三個時間戳中減去第二個時間戳,可以估算裝置時鐘和伺服器時鐘之間的偏移(假設網路延遲與所需的時間戳精度相比可忽略不計)。然後可以將該偏移應用於事件時間戳,從而估計事件實際發生的真實時間(假設裝置時鐘偏移在事件發生時與送往伺服器之間沒有變化)。
這並不是流處理獨有的問題,批處理有著完全一樣的時 間推理問題。只是在流處理的上下文中,我們更容易意識到時間的流逝。 這並不是流處理獨有的問題,批處理有著完全一樣的時 間推理問題。只是在流處理的上下文中,我們更容易意識到時間的流逝。
#### 視窗的型別 #### 視窗的型別
當你知道如何確定一個事件的時間戳後下一步就是如何定義時間段的視窗。然後視窗就可以用於聚合例如事件計數或計算視窗內值的平均值。有幾種視窗很常用【79,83】 當你知道如何確定一個事件的時間戳後下一步就是如何定義時間段的視窗。然後視窗就可以用於聚合例如事件計數或計算視窗內值的平均值。有幾種視窗很常用【79,83】
***滾動視窗Tumbling Window*** ***滾動視窗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*** ***跳動視窗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*** ***滑動視窗Sliding Window***
滑動視窗包含了彼此間距在特定時長內的所有事件。例如一個5分鐘的滑動視窗應當覆蓋`10:03:39`和`10:08:12`的事件因為它們相距不超過5分鐘注意滾動視窗與步長5分鐘的跳動視窗可能不會把這兩個事件分組到同一個視窗中因為它們使用固定的邊界。透過維護一個按時間排序的事件緩衝區並不斷從視窗中移除過期的舊事件可以實現滑動視窗。 滑動視窗包含了彼此間距在特定時長內的所有事件。例如一個5分鐘的滑動視窗應當覆蓋`10:03:39`和`10:08:12`的事件因為它們相距不超過5分鐘注意滾動視窗與步長5分鐘的跳動視窗可能不會把這兩個事件分組到同一個視窗中因為它們使用固定的邊界。透過維護一個按時間排序的事件緩衝區並不斷從視窗中移除過期的舊事件可以實現滑動視窗。
***會話視窗Session window*** ***會話視窗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的時間線上。
* 使用者刪除推文時,推文將從所有使用者的時間表中刪除。 * 使用者刪除推文時,推文將從所有使用者的時間表中刪除。
@ -611,113 +611,113 @@ JOIN follows ON follows.followee_id = tweets.sender_id
GROUP BY follows.follower_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】。
#### 連線的時間依賴性 #### 連線的時間依賴性
這裡描述的三種連線(流流,流表,表表)有很多共通之處:它們都需要流處理器維護連線一側的一些狀態(搜尋與點選事件,使用者檔案,關注列表),然後當連線另一側的訊息到達時查詢該狀態。 這裡描述的三種連線(流流,流表,表表)有很多共通之處:它們都需要流處理器維護連線一側的一些狀態(搜尋與點選事件,使用者檔案,關注列表),然後當連線另一側的訊息到達時查詢該狀態。
用於維護狀態的事件順序是很重要的(先關注然後取消關注,或者其他類似操作)。在分割槽日誌中,單個分割槽內的事件順序是保留下來的。但典型情況下是沒有跨流或跨分割槽的順序保證的。 用於維護狀態的事件順序是很重要的(先關注然後取消關注,或者其他類似操作)。在分割槽日誌中,單個分割槽內的事件順序是保留下來的。但典型情況下是沒有跨流或跨分割槽的順序保證的。
這就產生了一個問題如果不同流中的事件發生在近似的時間範圍內則應該按照什麼樣的順序進行處理在流表連線的例子中如果使用者更新了它們的檔案哪些活動事件與舊檔案連線在檔案更新前處理哪些又與新檔案連線在檔案更新之後處理換句話說你需要對一些狀態做連線如果狀態會隨著時間推移而變化那應當使用什麼時間點來連線呢【45】 這就產生了一個問題如果不同流中的事件發生在近似的時間範圍內則應該按照什麼樣的順序進行處理在流表連線的例子中如果使用者更新了它們的檔案哪些活動事件與舊檔案連線在檔案更新前處理哪些又與新檔案連線在檔案更新之後處理換句話說你需要對一些狀態做連線如果狀態會隨著時間推移而變化那應當使用什麼時間點來連線呢【45】
這種時序依賴可能出現在很多地方。例如銷售東西需要對發票應用適當的稅率,這取決於所處的國家/州,產品型別,銷售日期(因為稅率時不時會變化)。當連線銷售額與稅率表時,你可能期望的是使用銷售時的稅率參與連線。如果你正在重新處理歷史資料,銷售時的稅率可能和現在的稅率有所不同。 這種時序依賴可能出現在很多地方。例如銷售東西需要對發票應用適當的稅率,這取決於所處的國家/州,產品型別,銷售日期(因為稅率時不時會變化)。當連線銷售額與稅率表時,你可能期望的是使用銷售時的稅率參與連線。如果你正在重新處理歷史資料,銷售時的稅率可能和現在的稅率有所不同。
如果跨越流的事件順序是未定的則連線會變為不確定性的【87】這意味著你在同樣輸入上重跑相同的作業未必會得到相同的結果當你重跑任務時輸入流上的事件可能會以不同的方式交織。 如果跨越流的事件順序是未定的則連線會變為不確定性的【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資料例如金融中的市場資料能夠自然地表示為流。我們發現將資料庫寫入視作流也是很有用的我們可以捕獲變更日誌 —— 即對資料庫所做的所有變更的歷史記錄 —— 隱式地透過變更資料捕獲,或顯式地透過事件溯源。日誌壓縮允許流也能保有資料庫內容的完整副本。
將資料庫表示為流為系統整合帶來了很多強大機遇。透過消費變更日誌並將其應用至衍生系統,你能使諸如搜尋索引、快取以及分析系統這類衍生資料系統不斷保持更新。你甚至能從頭開始,透過讀取從創世至今的所有變更日誌,為現有資料建立全新的檢視。 將資料庫表示為流為系統整合帶來了很多強大機遇。透過消費變更日誌並將其應用至衍生系統,你能使諸如搜尋索引、快取以及分析系統這類衍生資料系統不斷保持更新。你甚至能從頭開始,透過讀取從創世至今的所有變更日誌,為現有資料建立全新的檢視。
像流一樣維護狀態以及訊息重播的基礎設施,是在各種流處理框架中實現流連線和容錯的基礎。我們討論了流處理的幾種目的,包括搜尋事件模式(複雜事件處理),計算分窗聚合(流分析),以及保證衍生資料系統處於最新狀態(物化檢視)。 像流一樣維護狀態以及訊息重播的基礎設施,是在各種流處理框架中實現流連線和容錯的基礎。我們討論了流處理的幾種目的,包括搜尋事件模式(複雜事件處理),計算分窗聚合(流分析),以及保證衍生資料系統處於最新狀態(物化檢視)。
然後我們討論了在流處理中對時間進行推理的困難,包括處理時間與事件時間戳之間的區別,以及當你認為視窗已經完事之後,如何處理到達的掉隊事件的問題。 然後我們討論了在流處理中對時間進行推理的困難,包括處理時間與事件時間戳之間的區別,以及當你認為視窗已經完事之後,如何處理到達的掉隊事件的問題。
我們區分了流處理中可能出現的三種連線型別: 我們區分了流處理中可能出現的三種連線型別:
***流流連線*** ***流流連線***
兩個輸入流都由活動事件組成而連線運算元在某個時間視窗內搜尋相關的事件。例如它可能會將同一個使用者30分鐘內進行的兩個活動聯絡在一起。如果你想要找出一個流內的相關事件連線的兩側輸入可能實際上都是同一個流**自連線self-join**)。 兩個輸入流都由活動事件組成而連線運算元在某個時間視窗內搜尋相關的事件。例如它可能會將同一個使用者30分鐘內進行的兩個活動聯絡在一起。如果你想要找出一個流內的相關事件連線的兩側輸入可能實際上都是同一個流**自連線self-join**)。
***流表連線*** ***流表連線***
一個輸入流由活動事件組成,另一個輸入流是資料庫變更日誌。變更日誌保證了資料庫的本地副本是最新的。對於每個活動事件,連線運算元將查詢資料庫,並輸出一個擴充套件的活動事件。 一個輸入流由活動事件組成,另一個輸入流是資料庫變更日誌。變更日誌保證了資料庫的本地副本是最新的。對於每個活動事件,連線運算元將查詢資料庫,並輸出一個擴充套件的活動事件。
***表表連線*** ***表表連線***
兩個輸入流都是資料庫變更日誌。在這種情況下,一側的每一個變化都與另一側的最新狀態相連線。結果是兩表連線所得物化檢視的變更流。 兩個輸入流都是資料庫變更日誌。在這種情況下,一側的每一個變化都與另一側的最新狀態相連線。結果是兩表連線所得物化檢視的變更流。
最後,我們討論了在流處理中實現容錯和恰好一次語義的技術。與批處理一樣,我們需要放棄任何失敗任務的部分輸出。然而由於流處理長時間執行並持續產生輸出,所以不能簡單地丟棄所有的輸出。相反,可以使用更細粒度的恢復機制,基於微批次、存檔點、事務或冪等寫入。 最後,我們討論了在流處理中實現容錯和恰好一次語義的技術。與批處理一樣,我們需要放棄任何失敗任務的部分輸出。然而由於流處理長時間執行並持續產生輸出,所以不能簡單地丟棄所有的輸出。相反,可以使用更細粒度的恢復機制,基於微批次、存檔點、事務或冪等寫入。

File diff suppressed because it is too large Load Diff

View File

@ -110,7 +110,7 @@ $ cat database
***檔案格式*** ***檔案格式***
CSV不是日誌的最佳格式。使用二進位制格式更快更簡單首先以位元組為單位對字串的長度進行編碼然後使用原始字串不需要轉義 CSV不是日誌的最佳格式。使用二進位制格式更快更簡單首先以位元組為單位對字串的長度進行編碼然後使用原始字串不需要轉義
***刪除記錄*** ***刪除記錄***

View File

@ -26,11 +26,11 @@
***向後相容 (backward compatibility)*** ***向後相容 (backward compatibility)***
新程式碼可以讀舊資料。 新程式碼可以讀舊資料。
***向前相容 (forward compatibility)*** ***向前相容 (forward compatibility)***
舊程式碼可以讀新資料。 舊程式碼可以讀新資料。
向後相容性通常並不難實現:新程式碼的作者當然知道由舊程式碼使用的資料格式,因此可以顯示地處理它(最簡單的辦法是,保留舊程式碼即可讀取舊資料)。 向後相容性通常並不難實現:新程式碼的作者當然知道由舊程式碼使用的資料格式,因此可以顯示地處理它(最簡單的辦法是,保留舊程式碼即可讀取舊資料)。

View File

@ -10,7 +10,7 @@
[TOC] [TOC]
複製意味著在透過網路連線的多臺機器上保留相同資料的副本。正如在[第二部分](part-ii.md)的介紹中所討論的那樣,我們希望能複製資料,可能出於各種各樣的原因: 複製意味著在透過網路連線的多臺機器上保留相同資料的副本。正如在[第二部分](part-ii.md)的介紹中所討論的那樣,我們希望能複製資料,可能出於各種各樣的原因:
* 使得資料與使用者在地理上接近(從而減少延遲) * 使得資料與使用者在地理上接近(從而減少延遲)
* 即使系統的一部分出現故障,系統也能繼續工作(從而提高可用性) * 即使系統的一部分出現故障,系統也能繼續工作(從而提高可用性)
@ -18,17 +18,17 @@
本章將假設你的資料集非常小,每臺機器都可以儲存整個資料集的副本。在[第六章](ch6.md)中將放寬這個假設,討論對單個機器來說太大的資料集的分割(分片)。在後面的章節中,我們將討論複製資料系統中可能發生的各種故障,以及如何處理這些故障。 本章將假設你的資料集非常小,每臺機器都可以儲存整個資料集的副本。在[第六章](ch6.md)中將放寬這個假設,討論對單個機器來說太大的資料集的分割(分片)。在後面的章節中,我們將討論複製資料系統中可能發生的各種故障,以及如何處理這些故障。
如果複製中的資料不會隨時間而改變,那複製就很簡單:將資料複製到每個節點一次就萬事大吉。複製的困難之處在於處理複製資料的**變更change**,這就是本章所要講的。我們將討論三種流行的變更復制演算法:**單領導者single leader****多領導者multi leader** 和**無領導者leaderless**。幾乎所有分散式資料庫都使用這三種方法之一。 如果複製中的資料不會隨時間而改變,那複製就很簡單:將資料複製到每個節點一次就萬事大吉。複製的困難之處在於處理複製資料的**變更change**,這就是本章所要講的。我們將討論三種流行的變更復制演算法:**單領導者single leader****多領導者multi leader** 和**無領導者leaderless**。幾乎所有分散式資料庫都使用這三種方法之一。
在複製時需要進行許多權衡:例如,使用同步複製還是非同步複製?如何處理失敗的副本?這些通常是資料庫中的配置選項,細節因資料庫而異,但原理在許多不同的實現中都類似。本章會討論這些決策的後果。 在複製時需要進行許多權衡:例如,使用同步複製還是非同步複製?如何處理失敗的副本?這些通常是資料庫中的配置選項,細節因資料庫而異,但原理在許多不同的實現中都類似。本章會討論這些決策的後果。
資料庫的複製算得上是老生常談了 ——70年代研究得出的基本原則至今沒有太大變化【1】因為網路的基本約束仍保持不變。然而在研究之外許多開發人員仍然假設一個數據庫只有一個節點。分散式資料庫變為主流只是最近發生的事。許多程式設計師都是這一領域的新手因此對於諸如 **最終一致性eventual consistency** 等問題存在許多誤解。在“[複製延遲問題](#複製延遲問題)”一節,我們將更加精確地瞭解最終的一致性,並討論諸如 **讀己之寫read-your-writes****單調讀monotonic read** 保證等內容。 資料庫的複製算得上是老生常談了 ——70年代研究得出的基本原則至今沒有太大變化【1】因為網路的基本約束仍保持不變。然而在研究之外許多開發人員仍然假設一個數據庫只有一個節點。分散式資料庫變為主流只是最近發生的事。許多程式設計師都是這一領域的新手因此對於諸如 **最終一致性eventual consistency** 等問題存在許多誤解。在“[複製延遲問題](#複製延遲問題)”一節,我們將更加精確地瞭解最終的一致性,並討論諸如 **讀己之寫read-your-writes****單調讀monotonic read** 保證等內容。
## 領導者與追隨者 ## 領導者與追隨者
儲存資料庫副本的每個節點稱為 **副本replica** 。當存在多個副本時,會不可避免的出現一個問題:如何確保所有資料都落在了所有的副本上? 儲存資料庫副本的每個節點稱為 **副本replica** 。當存在多個副本時,會不可避免的出現一個問題:如何確保所有資料都落在了所有的副本上?
每一次向資料庫的寫入操作都需要傳播到所有副本上,否則副本就會包含不一樣的資料。最常見的解決方案被稱為 **基於領導者的複製leader-based replication** (也稱 **主動/被動active/passive****主/從master/slave** 複製),如[圖5-1](#fig5-1.png)所示。它的工作原理如下: 每一次向資料庫的寫入操作都需要傳播到所有副本上,否則副本就會包含不一樣的資料。最常見的解決方案被稱為 **基於領導者的複製leader-based replication** (也稱 **主動/被動active/passive****主/從master/slave** 複製),如[圖5-1](#fig5-1.png)所示。它的工作原理如下:
1. 副本之一被指定為 **領導者leader**,也稱為 **主庫master|primary** 。當客戶端要向資料庫寫入時,它必須將請求傳送給**領導者**,領導者會將新資料寫入其本地儲存。 1. 副本之一被指定為 **領導者leader**,也稱為 **主庫master|primary** 。當客戶端要向資料庫寫入時,它必須將請求傳送給**領導者**,領導者會將新資料寫入其本地儲存。
2. 其他副本被稱為**追隨者followers**,亦稱為**只讀副本read replicas****從庫slaves****備庫( secondaries****熱備hot-standby**[^i]。每當領導者將新資料寫入本地儲存時,它也會將資料變更傳送給所有的追隨者,稱之為**複製日誌replication log** 記錄或**變更流change stream**。每個跟隨者從領導者拉取日誌,並相應更新其本地資料庫副本,方法是按照領導者處理的相同順序應用所有寫入。 2. 其他副本被稱為**追隨者followers**,亦稱為**只讀副本read replicas****從庫slaves****備庫( secondaries****熱備hot-standby**[^i]。每當領導者將新資料寫入本地儲存時,它也會將資料變更傳送給所有的追隨者,稱之為**複製日誌replication log** 記錄或**變更流change stream**。每個跟隨者從領導者拉取日誌,並相應更新其本地資料庫副本,方法是按照領導者處理的相同順序應用所有寫入。
@ -39,30 +39,30 @@
![](../img/fig5-1.png) ![](../img/fig5-1.png)
**圖5-1 基於領導者(主-從)的複製** **圖5-1 基於領導者(主-從)的複製**
這種複製模式是許多關係資料庫的內建功能如PostgreSQL從9.0版本開始MySQLOracle Data Guard 【2】和SQL Server的AlwaysOn可用性組【3】。 它也被用於一些非關係資料庫包括MongoDBRethinkDB和Espresso 【4】。 最後基於領導者的複製並不僅限於資料庫像Kafka 【5】和RabbitMQ高可用佇列【6】這樣的分散式訊息代理也使用它。 某些網路檔案系統例如DRBD這樣的塊複製裝置也與之類似。 這種複製模式是許多關係資料庫的內建功能如PostgreSQL從9.0版本開始MySQLOracle Data Guard 【2】和SQL Server的AlwaysOn可用性組【3】。 它也被用於一些非關係資料庫包括MongoDBRethinkDB和Espresso 【4】。 最後基於領導者的複製並不僅限於資料庫像Kafka 【5】和RabbitMQ高可用佇列【6】這樣的分散式訊息代理也使用它。 某些網路檔案系統例如DRBD這樣的塊複製裝置也與之類似。
### 同步複製與非同步複製 ### 同步複製與非同步複製
複製系統的一個重要細節是:複製是 **同步synchronously** 發生還是 **非同步asynchronously** 發生。 (在關係型資料庫中這通常是一個配置項,其他系統通常硬編碼為其中一個)。 複製系統的一個重要細節是:複製是 **同步synchronously** 發生還是 **非同步asynchronously** 發生。 (在關係型資料庫中這通常是一個配置項,其他系統通常硬編碼為其中一個)。
想象[圖5-1](fig5-1.png)中發生的情況,網站的使用者更新他們的個人頭像。在某個時間點,客戶向主庫傳送更新請求;不久之後主庫就收到了請求。在某個時刻,主庫又會將資料變更轉發給自己的從庫。最後,主庫通知客戶更新成功。 想象[圖5-1](fig5-1.png)中發生的情況,網站的使用者更新他們的個人頭像。在某個時間點,客戶向主庫傳送更新請求;不久之後主庫就收到了請求。在某個時刻,主庫又會將資料變更轉發給自己的從庫。最後,主庫通知客戶更新成功。
[圖5-2](../img/fig5-2.png)顯示了系統各個元件之間的通訊:使用者客戶端,主庫和兩個從庫。時間從左到右流動。請求或響應訊息用粗箭頭表示。 [圖5-2](../img/fig5-2.png)顯示了系統各個元件之間的通訊:使用者客戶端,主庫和兩個從庫。時間從左到右流動。請求或響應訊息用粗箭頭表示。
![](../img/fig5-2.png) ![](../img/fig5-2.png)
**圖5-2 基於領導者的複製:一個同步從庫和一個非同步從庫** **圖5-2 基於領導者的複製:一個同步從庫和一個非同步從庫**
在[圖5-2](../img/fig5-2.png)的示例中從庫1的複製是同步的在向用戶報告寫入成功並使結果對其他使用者可見之前主庫需要等待從庫1的確認確保從庫1已經收到寫入操作。以及在使寫入對其他客戶端可見之前接收到寫入。跟隨者2的複製是非同步的主庫傳送訊息但不等待從庫的響應。 在[圖5-2](../img/fig5-2.png)的示例中從庫1的複製是同步的在向用戶報告寫入成功並使結果對其他使用者可見之前主庫需要等待從庫1的確認確保從庫1已經收到寫入操作。以及在使寫入對其他客戶端可見之前接收到寫入。跟隨者2的複製是非同步的主庫傳送訊息但不等待從庫的響應。
在這幅圖中從庫2處理訊息前存在一個顯著的延遲。通常情況下複製的速度相當快大多數資料庫系統能在一秒向從庫應用變更但它們不能提供複製用時的保證。有些情況下從庫可能落後主庫幾分鐘或更久例如從庫正在從故障中恢復系統在最大容量附近執行或者如果節點間存在網路問題。 在這幅圖中從庫2處理訊息前存在一個顯著的延遲。通常情況下複製的速度相當快大多數資料庫系統能在一秒向從庫應用變更但它們不能提供複製用時的保證。有些情況下從庫可能落後主庫幾分鐘或更久例如從庫正在從故障中恢復系統在最大容量附近執行或者如果節點間存在網路問題。
同步複製的優點是,從庫保證有與主庫一致的最新資料副本。如果主庫突然失效,我們可以確信這些資料仍然能在從庫上上找到。缺點是,如果同步從庫沒有響應(比如它已經崩潰,或者出現網路故障,或其它任何原因),主庫就無法處理寫入操作。主庫必須阻止所有寫入,並等待同步副本再次可用。 同步複製的優點是,從庫保證有與主庫一致的最新資料副本。如果主庫突然失效,我們可以確信這些資料仍然能在從庫上上找到。缺點是,如果同步從庫沒有響應(比如它已經崩潰,或者出現網路故障,或其它任何原因),主庫就無法處理寫入操作。主庫必須阻止所有寫入,並等待同步副本再次可用。
因此,將所有從庫都設定為同步的是不切實際的:任何一個節點的中斷都會導致整個系統停滯不前。實際上,如果在資料庫上啟用同步複製,通常意味著其中**一個**跟隨者是同步的,而其他的則是非同步的。如果同步從庫變得不可用或緩慢,則使一個非同步從庫同步。這保證你至少在兩個節點上擁有最新的資料副本:主庫和同步從庫。 這種配置有時也被稱為 **半同步semi-synchronous**【7】。 因此,將所有從庫都設定為同步的是不切實際的:任何一個節點的中斷都會導致整個系統停滯不前。實際上,如果在資料庫上啟用同步複製,通常意味著其中**一個**跟隨者是同步的,而其他的則是非同步的。如果同步從庫變得不可用或緩慢,則使一個非同步從庫同步。這保證你至少在兩個節點上擁有最新的資料副本:主庫和同步從庫。 這種配置有時也被稱為 **半同步semi-synchronous**【7】。
通常情況下,基於領導者的複製都配置為完全非同步。 在這種情況下,如果主庫失效且不可恢復,則任何尚未複製給從庫的寫入都會丟失。 這意味著即使已經向客戶端確認成功,寫入也不能保證 **持久Durable** 。 然而,一個完全非同步的配置也有優點:即使所有的從庫都落後了,主庫也可以繼續處理寫入。 通常情況下,基於領導者的複製都配置為完全非同步。 在這種情況下,如果主庫失效且不可恢復,則任何尚未複製給從庫的寫入都會丟失。 這意味著即使已經向客戶端確認成功,寫入也不能保證 **持久Durable** 。 然而,一個完全非同步的配置也有優點:即使所有的從庫都落後了,主庫也可以繼續處理寫入。
弱化的永續性可能聽起來像是一個壞的折衷,然而非同步複製已經被廣泛使用了,特別當有很多追隨者,或追隨者異地分佈時。 稍後將在“[複製延遲問題](#複製延遲問題)”中回到這個問題。 弱化的永續性可能聽起來像是一個壞的折衷,然而非同步複製已經被廣泛使用了,特別當有很多追隨者,或追隨者異地分佈時。 稍後將在“[複製延遲問題](#複製延遲問題)”中回到這個問題。
> ### 關於複製的研究 > ### 關於複製的研究
> >
@ -73,11 +73,11 @@
### 設定新從庫 ### 設定新從庫
有時候需要設定一個新的從庫:也許是為了增加副本的數量,或替換失敗的節點。如何確保新的從庫擁有主庫資料的精確副本? 有時候需要設定一個新的從庫:也許是為了增加副本的數量,或替換失敗的節點。如何確保新的從庫擁有主庫資料的精確副本?
簡單地將資料檔案從一個節點複製到另一個節點通常是不夠的:客戶端不斷向資料庫寫入資料,資料總是在不斷變化,標準的資料副本會在不同的時間點總是不一樣。複製的結果可能沒有任何意義。 簡單地將資料檔案從一個節點複製到另一個節點通常是不夠的:客戶端不斷向資料庫寫入資料,資料總是在不斷變化,標準的資料副本會在不同的時間點總是不一樣。複製的結果可能沒有任何意義。
可以透過鎖定資料庫(使其不可用於寫入)來使磁碟上的檔案保持一致,但是這會違背高可用的目標。幸運的是,拉起新的從庫通常並不需要停機。從概念上講,過程如下所示: 可以透過鎖定資料庫(使其不可用於寫入)來使磁碟上的檔案保持一致,但是這會違背高可用的目標。幸運的是,拉起新的從庫通常並不需要停機。從概念上講,過程如下所示:
1. 在某個時刻獲取主庫的一致性快照如果可能而不必鎖定整個資料庫。大多數資料庫都具有這個功能因為它是備份必需的。對於某些場景可能需要第三方工具例如MySQL的innobackupex 【12】。 1. 在某個時刻獲取主庫的一致性快照如果可能而不必鎖定整個資料庫。大多數資料庫都具有這個功能因為它是備份必需的。對於某些場景可能需要第三方工具例如MySQL的innobackupex 【12】。
2. 將快照複製到新的從庫節點。 2. 將快照複製到新的從庫節點。
@ -88,19 +88,19 @@
### 處理節點宕機 ### 處理節點宕機
系統中的任何節點都可能宕機,可能因為意外的故障,也可能由於計劃內的維護(例如,重啟機器以安裝核心安全補丁)。對運維而言,能在系統不中斷服務的情況下重啟單個節點好處多多。我們的目標是,即使個別節點失效,也能保持整個系統執行,並儘可能控制節點停機帶來的影響。 系統中的任何節點都可能宕機,可能因為意外的故障,也可能由於計劃內的維護(例如,重啟機器以安裝核心安全補丁)。對運維而言,能在系統不中斷服務的情況下重啟單個節點好處多多。我們的目標是,即使個別節點失效,也能保持整個系統執行,並儘可能控制節點停機帶來的影響。
如何透過基於主庫的複製實現高可用? 如何透過基於主庫的複製實現高可用?
#### 從庫失效:追趕恢復 #### 從庫失效:追趕恢復
在其本地磁碟上,每個從庫記錄從主庫收到的資料變更。如果從庫崩潰並重新啟動,或者,如果主庫和從庫之間的網路暫時中斷,則比較容易恢復:從庫可以從日誌中知道,在發生故障之前處理的最後一個事務。因此,從庫可以連線到主庫,並請求在從庫斷開連線時發生的所有資料變更。當應用完所有這些變化後,它就趕上了主庫,並可以像以前一樣繼續接收資料變更流。 在其本地磁碟上,每個從庫記錄從主庫收到的資料變更。如果從庫崩潰並重新啟動,或者,如果主庫和從庫之間的網路暫時中斷,則比較容易恢復:從庫可以從日誌中知道,在發生故障之前處理的最後一個事務。因此,從庫可以連線到主庫,並請求在從庫斷開連線時發生的所有資料變更。當應用完所有這些變化後,它就趕上了主庫,並可以像以前一樣繼續接收資料變更流。
#### 主庫失效:故障切換 #### 主庫失效:故障切換
主庫失效處理起來相當棘手:其中一個從庫需要被提升為新的主庫,需要重新配置客戶端,以將它們的寫操作傳送給新的主庫,其他從庫需要開始拉取來自新主庫的資料變更。這個過程被稱為**故障切換failover**。 主庫失效處理起來相當棘手:其中一個從庫需要被提升為新的主庫,需要重新配置客戶端,以將它們的寫操作傳送給新的主庫,其他從庫需要開始拉取來自新主庫的資料變更。這個過程被稱為**故障切換failover**。
故障切換可以手動進行(通知管理員主庫掛了,並採取必要的步驟來建立新的主庫)或自動進行。自動故障切換過程通常由以下步驟組成: 故障切換可以手動進行(通知管理員主庫掛了,並採取必要的步驟來建立新的主庫)或自動進行。自動故障切換過程通常由以下步驟組成:
1. 確認主庫失效。有很多事情可能會出錯:崩潰,停電,網路問題等等。沒有萬無一失的方法來檢測出現了什麼問題,所以大多數系統只是簡單使用 **超時Timeout** 節點頻繁地相互來回傳遞訊息並且如果一個節點在一段時間內例如30秒沒有響應就認為它掛了因為計劃內維護而故意關閉主庫不算 1. 確認主庫失效。有很多事情可能會出錯:崩潰,停電,網路問題等等。沒有萬無一失的方法來檢測出現了什麼問題,所以大多數系統只是簡單使用 **超時Timeout** 節點頻繁地相互來回傳遞訊息並且如果一個節點在一段時間內例如30秒沒有響應就認為它掛了因為計劃內維護而故意關閉主庫不算
2. 選擇一個新的主庫。這可以透過選舉過程(主庫由剩餘副本以多數選舉產生)來完成,或者可以由之前選定的**控制器節點controller node** 來指定新的主庫。主庫的最佳人選通常是擁有舊主庫最新資料副本的從庫(最小化資料損失)。讓所有的節點同意一個新的領導者,是一個**共識**問題,將在[第九章](ch9.md)詳細討論。 2. 選擇一個新的主庫。這可以透過選舉過程(主庫由剩餘副本以多數選舉產生)來完成,或者可以由之前選定的**控制器節點controller node** 來指定新的主庫。主庫的最佳人選通常是擁有舊主庫最新資料副本的從庫(最小化資料損失)。讓所有的節點同意一個新的領導者,是一個**共識**問題,將在[第九章](ch9.md)詳細討論。
@ -128,7 +128,7 @@
#### 基於語句的複製 #### 基於語句的複製
在最簡單的情況下,主庫記錄下它執行的每個寫入請求(**語句statement**)並將該語句日誌傳送給其從庫。對於關係資料庫來說,這意味著每個`INSERT``UPDATE`或`DELETE`語句都被轉發給每個從庫每個從庫解析並執行該SQL語句就像從客戶端收到一樣。 在最簡單的情況下,主庫記錄下它執行的每個寫入請求(**語句statement**)並將該語句日誌傳送給其從庫。對於關係資料庫來說,這意味著每個`INSERT``UPDATE`或`DELETE`語句都被轉發給每個從庫每個從庫解析並執行該SQL語句就像從客戶端收到一樣。
雖然聽上去很合理,但有很多問題會搞砸這種複製方式: 雖然聽上去很合理,但有很多問題會搞砸這種複製方式:
@ -138,7 +138,7 @@
的確有辦法繞開這些問題 ——例如,當語句被記錄時,主庫可以用固定的返回值替換任何不確定的函式呼叫,以便從庫獲得相同的值。但是由於邊緣情況實在太多了,現在通常會選擇其他的複製方法。 的確有辦法繞開這些問題 ——例如,當語句被記錄時,主庫可以用固定的返回值替換任何不確定的函式呼叫,以便從庫獲得相同的值。但是由於邊緣情況實在太多了,現在通常會選擇其他的複製方法。
基於語句的複製在5.1版本前的MySQL中使用。因為它相當緊湊現在有時候也還在用。但現在在預設情況下如果語句中存在任何不確定性MySQL會切換到基於行的複製稍後討論。 VoltDB使用了基於語句的複製但要求事務必須是確定性的以此來保證安全【15】。 基於語句的複製在5.1版本前的MySQL中使用。因為它相當緊湊現在有時候也還在用。但現在在預設情況下如果語句中存在任何不確定性MySQL會切換到基於行的複製稍後討論。 VoltDB使用了基於語句的複製但要求事務必須是確定性的以此來保證安全【15】。
#### 傳輸預寫式日誌WAL #### 傳輸預寫式日誌WAL
@ -149,15 +149,15 @@
在任何一種情況下,日誌都是包含所有資料庫寫入的僅追加位元組序列。可以使用完全相同的日誌在另一個節點上構建副本:除了將日誌寫入磁碟之外,主庫還可以透過網路將其傳送給其從庫。 在任何一種情況下,日誌都是包含所有資料庫寫入的僅追加位元組序列。可以使用完全相同的日誌在另一個節點上構建副本:除了將日誌寫入磁碟之外,主庫還可以透過網路將其傳送給其從庫。
當從庫應用這個日誌時,它會建立和主庫一模一樣資料結構的副本。 當從庫應用這個日誌時,它會建立和主庫一模一樣資料結構的副本。
PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌記錄的資料非常底層WAL包含哪些磁碟塊中的哪些位元組發生了更改。這使複製與儲存引擎緊密耦合。如果資料庫將其儲存格式從一個版本更改為另一個版本通常不可能在主庫和從庫上執行不同版本的資料庫軟體。 PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌記錄的資料非常底層WAL包含哪些磁碟塊中的哪些位元組發生了更改。這使複製與儲存引擎緊密耦合。如果資料庫將其儲存格式從一個版本更改為另一個版本通常不可能在主庫和從庫上執行不同版本的資料庫軟體。
看上去這可能只是一個微小的實現細節但卻可能對運維產生巨大的影響。如果複製協議允許從庫使用比主庫更新的軟體版本則可以先升級從庫然後執行故障切換使升級後的節點之一成為新的主庫從而執行資料庫軟體的零停機升級。如果複製協議不允許版本不匹配傳輸WAL經常出現這種情況則此類升級需要停機。 看上去這可能只是一個微小的實現細節但卻可能對運維產生巨大的影響。如果複製協議允許從庫使用比主庫更新的軟體版本則可以先升級從庫然後執行故障切換使升級後的節點之一成為新的主庫從而執行資料庫軟體的零停機升級。如果複製協議不允許版本不匹配傳輸WAL經常出現這種情況則此類升級需要停機。
#### 邏輯日誌複製(基於行) #### 邏輯日誌複製(基於行)
另一種方法是,複製和儲存引擎使用不同的日誌格式,這樣可以使複製日誌從儲存引擎內部分離出來。這種複製日誌被稱為邏輯日誌,以將其與儲存引擎的(物理)資料表示區分開來。 另一種方法是,複製和儲存引擎使用不同的日誌格式,這樣可以使複製日誌從儲存引擎內部分離出來。這種複製日誌被稱為邏輯日誌,以將其與儲存引擎的(物理)資料表示區分開來。
關係資料庫的邏輯日誌通常是以行的粒度描述對資料庫表的寫入的記錄序列: 關係資料庫的邏輯日誌通常是以行的粒度描述對資料庫表的寫入的記錄序列:
@ -167,49 +167,49 @@
修改多行的事務會生成多個這樣的日誌記錄,後面跟著一條記錄,指出事務已經提交。 MySQL的二進位制日誌當配置為使用基於行的複製時使用這種方法【17】。 修改多行的事務會生成多個這樣的日誌記錄,後面跟著一條記錄,指出事務已經提交。 MySQL的二進位制日誌當配置為使用基於行的複製時使用這種方法【17】。
由於邏輯日誌與儲存引擎內部分離,因此可以更容易地保持向後相容,從而使領導者和跟隨者能夠執行不同版本的資料庫軟體甚至不同的儲存引擎。 由於邏輯日誌與儲存引擎內部分離,因此可以更容易地保持向後相容,從而使領導者和跟隨者能夠執行不同版本的資料庫軟體甚至不同的儲存引擎。
對於外部應用程式來說邏輯日誌格式也更容易解析。如果要將資料庫的內容傳送到外部系統這一點很有用例如複製到資料倉庫進行離線分析或建立自定義索引和快取【18】。 這種技術被稱為 **資料變更捕獲change data capture**[第十一章](ch11.md)將重新講到它。 對於外部應用程式來說邏輯日誌格式也更容易解析。如果要將資料庫的內容傳送到外部系統這一點很有用例如複製到資料倉庫進行離線分析或建立自定義索引和快取【18】。 這種技術被稱為 **資料變更捕獲change data capture**[第十一章](ch11.md)將重新講到它。
#### 基於觸發器的複製 #### 基於觸發器的複製
到目前為止描述的複製方法是由資料庫系統實現的,不涉及任何應用程式程式碼。在很多情況下,這就是你想要的。但在某些情況下需要更多的靈活性。例如,如果您只想複製資料的一個子集,或者想從一種資料庫複製到另一種資料庫,或者如果您需要衝突解決邏輯(請參閱“[處理寫入衝突](#處理寫入衝突)”),則可能需要將複製移動到應用程式層。 到目前為止描述的複製方法是由資料庫系統實現的,不涉及任何應用程式程式碼。在很多情況下,這就是你想要的。但在某些情況下需要更多的靈活性。例如,如果您只想複製資料的一個子集,或者想從一種資料庫複製到另一種資料庫,或者如果您需要衝突解決邏輯(請參閱“[處理寫入衝突](#處理寫入衝突)”),則可能需要將複製移動到應用程式層。
一些工具如Oracle Golden Gate 【19】可以透過讀取資料庫日誌使得其他應用程式可以使用資料。另一種方法是使用許多關係資料庫自帶的功能觸發器和儲存過程。 一些工具如Oracle Golden Gate 【19】可以透過讀取資料庫日誌使得其他應用程式可以使用資料。另一種方法是使用許多關係資料庫自帶的功能觸發器和儲存過程。
觸發器允許您註冊在資料庫系統中發生資料更改寫入事務時自動執行的自定義應用程式程式碼。觸發器有機會將更改記錄到一個單獨的表中使用外部程式讀取這個表再加上任何業務邏輯處理會後將資料變更復制到另一個系統去。例如Databus for Oracle 【20】和Bucardo for Postgres 【21】就是這樣工作的。 觸發器允許您註冊在資料庫系統中發生資料更改寫入事務時自動執行的自定義應用程式程式碼。觸發器有機會將更改記錄到一個單獨的表中使用外部程式讀取這個表再加上任何業務邏輯處理會後將資料變更復制到另一個系統去。例如Databus for Oracle 【20】和Bucardo for Postgres 【21】就是這樣工作的。
基於觸發器的複製通常比其他複製方法具有更高的開銷,並且比資料庫的內建複製更容易出錯,也有很多限制。然而由於其靈活性,仍然是很有用的。 基於觸發器的複製通常比其他複製方法具有更高的開銷,並且比資料庫的內建複製更容易出錯,也有很多限制。然而由於其靈活性,仍然是很有用的。
## 複製延遲問題 ## 複製延遲問題
容忍節點故障只是需要複製的一個原因。正如在[第二部分](part-ii.md)的介紹中提到的,另一個原因是可伸縮性(處理比單個機器更多的請求)和延遲(讓副本在地理位置上更接近使用者)。 容忍節點故障只是需要複製的一個原因。正如在[第二部分](part-ii.md)的介紹中提到的,另一個原因是可伸縮性(處理比單個機器更多的請求)和延遲(讓副本在地理位置上更接近使用者)。
基於主庫的複製要求所有寫入都由單個節點處理但只讀查詢可以由任何副本處理。所以對於讀多寫少的場景Web上的常見模式一個有吸引力的選擇是建立很多從庫並將讀請求分散到所有的從庫上去。這樣能減小主庫的負載並允許向最近的副本傳送讀請求。 基於主庫的複製要求所有寫入都由單個節點處理但只讀查詢可以由任何副本處理。所以對於讀多寫少的場景Web上的常見模式一個有吸引力的選擇是建立很多從庫並將讀請求分散到所有的從庫上去。這樣能減小主庫的負載並允許向最近的副本傳送讀請求。
在這種伸縮體系結構中,只需新增更多的追隨者,就可以提高只讀請求的服務容量。但是,這種方法實際上只適用於非同步複製——如果嘗試同步複製到所有追隨者,則單個節點故障或網路中斷將使整個系統無法寫入。而且越多的節點越有可能會被關閉,所以完全同步的配置是非常不可靠的。 在這種伸縮體系結構中,只需新增更多的追隨者,就可以提高只讀請求的服務容量。但是,這種方法實際上只適用於非同步複製——如果嘗試同步複製到所有追隨者,則單個節點故障或網路中斷將使整個系統無法寫入。而且越多的節點越有可能會被關閉,所以完全同步的配置是非常不可靠的。
不幸的是,當應用程式從非同步從庫讀取時,如果從庫落後,它可能會看到過時的資訊。這會導致資料庫中出現明顯的不一致:同時對主庫和從庫執行相同的查詢,可能得到不同的結果,因為並非所有的寫入都反映在從庫中。這種不一致只是一個暫時的狀態——如果停止寫入資料庫並等待一段時間,從庫最終會趕上並與主庫保持一致。出於這個原因,這種效應被稱為 **最終一致性eventual consistency**[^iii]【22,23】 不幸的是,當應用程式從非同步從庫讀取時,如果從庫落後,它可能會看到過時的資訊。這會導致資料庫中出現明顯的不一致:同時對主庫和從庫執行相同的查詢,可能得到不同的結果,因為並非所有的寫入都反映在從庫中。這種不一致只是一個暫時的狀態——如果停止寫入資料庫並等待一段時間,從庫最終會趕上並與主庫保持一致。出於這個原因,這種效應被稱為 **最終一致性eventual consistency**[^iii]【22,23】
[^iii]: 道格拉斯·特里Douglas Terry等人創造了術語最終一致性。 【24】 並經由Werner Vogels 【22】推廣成為許多NoSQL專案的口號。 然而不只有NoSQL資料庫是最終一致的關係型資料庫中的非同步複製追隨者也有相同的特性。 [^iii]: 道格拉斯·特里Douglas Terry等人創造了術語最終一致性。 【24】 並經由Werner Vogels 【22】推廣成為許多NoSQL專案的口號。 然而不只有NoSQL資料庫是最終一致的關係型資料庫中的非同步複製追隨者也有相同的特性。
“最終”一詞故意含糊不清:總的來說,副本落後的程度是沒有限制的。在正常的操作中,**複製延遲replication lag**,即寫入主庫到反映至從庫之間的延遲,可能僅僅是幾分之一秒,在實踐中並不顯眼。但如果系統在接近極限的情況下執行,或網路中存在問題,延遲可以輕而易舉地超過幾秒,甚至幾分鐘。 “最終”一詞故意含糊不清:總的來說,副本落後的程度是沒有限制的。在正常的操作中,**複製延遲replication lag**,即寫入主庫到反映至從庫之間的延遲,可能僅僅是幾分之一秒,在實踐中並不顯眼。但如果系統在接近極限的情況下執行,或網路中存在問題,延遲可以輕而易舉地超過幾秒,甚至幾分鐘。
因為滯後時間太長引入的不一致性,可不僅是一個理論問題,更是應用設計中會遇到的真實問題。本節將重點介紹三個由複製延遲問題的例子,並簡述解決這些問題的一些方法。 因為滯後時間太長引入的不一致性,可不僅是一個理論問題,更是應用設計中會遇到的真實問題。本節將重點介紹三個由複製延遲問題的例子,並簡述解決這些問題的一些方法。
### 讀己之寫 ### 讀己之寫
許多應用讓使用者提交一些資料,然後檢視他們提交的內容。可能是使用者資料庫中的記錄,也可能是對討論主題的評論,或其他類似的內容。提交新資料時,必須將其傳送給領導者,但是當用戶檢視資料時,可以從追隨者讀取。如果資料經常被檢視,但只是偶爾寫入,這是非常合適的。 許多應用讓使用者提交一些資料,然後檢視他們提交的內容。可能是使用者資料庫中的記錄,也可能是對討論主題的評論,或其他類似的內容。提交新資料時,必須將其傳送給領導者,但是當用戶檢視資料時,可以從追隨者讀取。如果資料經常被檢視,但只是偶爾寫入,這是非常合適的。
但對於非同步複製,問題就來了。如[圖5-3](fig5-3.png)所示:如果使用者在寫入後馬上就檢視資料,則新資料可能尚未到達副本。對使用者而言,看起來好像是剛提交的資料丟失了,使用者會不高興,可以理解。 但對於非同步複製,問題就來了。如[圖5-3](fig5-3.png)所示:如果使用者在寫入後馬上就檢視資料,則新資料可能尚未到達副本。對使用者而言,看起來好像是剛提交的資料丟失了,使用者會不高興,可以理解。
![](../img/fig5-3.png) ![](../img/fig5-3.png)
**圖5-3 使用者寫入後從舊副本中讀取資料。需要寫後讀(read-after-write)的一致性來防止這種異常** **圖5-3 使用者寫入後從舊副本中讀取資料。需要寫後讀(read-after-write)的一致性來防止這種異常**
在這種情況下,我們需要 **讀寫一致性read-after-write consistency**,也稱為 **讀己之寫一致性read-your-writes consistency**【24】。這是一個保證如果使用者重新載入頁面他們總會看到他們自己提交的任何更新。它不會對其他使用者的寫入做出承諾其他使用者的更新可能稍等才會看到。它保證使用者自己的輸入已被正確儲存。 在這種情況下,我們需要 **讀寫一致性read-after-write consistency**,也稱為 **讀己之寫一致性read-your-writes consistency**【24】。這是一個保證如果使用者重新載入頁面他們總會看到他們自己提交的任何更新。它不會對其他使用者的寫入做出承諾其他使用者的更新可能稍等才會看到。它保證使用者自己的輸入已被正確儲存。
如何在基於領導者的複製系統中實現讀後一致性?有各種可能的技術,這裡說一些: 如何在基於領導者的複製系統中實現讀後一致性?有各種可能的技術,這裡說一些:
@ -234,17 +234,17 @@
### 單調讀 ### 單調讀
從非同步從庫讀取第二個異常例子是,使用者可能會遇到 **時光倒流moving backward in time** 從非同步從庫讀取第二個異常例子是,使用者可能會遇到 **時光倒流moving backward in time**
如果使用者從不同從庫進行多次讀取,就可能發生這種情況。例如,[圖5-4](../img/fig5-4.png)顯示了使用者2345兩次進行相同的查詢首先查詢了一個延遲很小的從庫然後是一個延遲較大的從庫。 如果使用者重新整理網頁而每個請求被路由到一個隨機的伺服器這種情況是很有可能的。第一個查詢返回最近由使用者1234新增的評論但是第二個查詢不返回任何東西因為滯後的從庫還沒有拉取寫入內容。在效果上相比第一個查詢第二個查詢是在更早的時間點來觀察系統。如果第一個查詢沒有返回任何內容那問題並不大因為使用者2345可能不知道使用者1234最近添加了評論。但如果使用者2345先看見使用者1234的評論然後又看到它消失那麼對於使用者2345就很讓人頭大了。 如果使用者從不同從庫進行多次讀取,就可能發生這種情況。例如,[圖5-4](../img/fig5-4.png)顯示了使用者2345兩次進行相同的查詢首先查詢了一個延遲很小的從庫然後是一個延遲較大的從庫。 如果使用者重新整理網頁而每個請求被路由到一個隨機的伺服器這種情況是很有可能的。第一個查詢返回最近由使用者1234新增的評論但是第二個查詢不返回任何東西因為滯後的從庫還沒有拉取寫入內容。在效果上相比第一個查詢第二個查詢是在更早的時間點來觀察系統。如果第一個查詢沒有返回任何內容那問題並不大因為使用者2345可能不知道使用者1234最近添加了評論。但如果使用者2345先看見使用者1234的評論然後又看到它消失那麼對於使用者2345就很讓人頭大了。
![](../img/fig5-4.png) ![](../img/fig5-4.png)
**圖5-4 使用者首先從新副本讀取,然後從舊副本讀取。時光倒流。為了防止這種異常,我們需要單調的讀取。** **圖5-4 使用者首先從新副本讀取,然後從舊副本讀取。時光倒流。為了防止這種異常,我們需要單調的讀取。**
**單調讀Monotonic reads**【23】保證這種異常不會發生。這是一個比 **強一致性strong consistency** 更弱,但比 **最終一致性eventual consistency** 更強的保證。當讀取資料時,您可能會看到一箇舊值;單調讀取僅意味著如果一個使用者順序地進行多次讀取,則他們不會看到時間後退,即,如果先前讀取到較新的資料,後續讀取不會得到更舊的資料。 **單調讀Monotonic reads**【23】保證這種異常不會發生。這是一個比 **強一致性strong consistency** 更弱,但比 **最終一致性eventual consistency** 更強的保證。當讀取資料時,您可能會看到一箇舊值;單調讀取僅意味著如果一個使用者順序地進行多次讀取,則他們不會看到時間後退,即,如果先前讀取到較新的資料,後續讀取不會得到更舊的資料。
實現單調讀取的一種方式是確保每個使用者總是從同一個副本進行讀取不同的使用者可以從不同的副本讀取。例如可以基於使用者ID的雜湊來選擇副本而不是隨機選擇副本。但是如果該副本失敗使用者的查詢將需要重新路由到另一個副本。 實現單調讀取的一種方式是確保每個使用者總是從同一個副本進行讀取不同的使用者可以從不同的副本讀取。例如可以基於使用者ID的雜湊來選擇副本而不是隨機選擇副本。但是如果該副本失敗使用者的查詢將需要重新路由到另一個副本。
@ -260,7 +260,7 @@
這兩句話之間有因果關係Cake夫人聽到了Poons先生的問題並回答了這個問題。 這兩句話之間有因果關係Cake夫人聽到了Poons先生的問題並回答了這個問題。
現在,想象第三個人正在透過從庫來聽這個對話。 Cake夫人說的內容是從一個延遲很低的從庫讀取的但Poons先生所說的內容從庫的延遲要大的多見[圖5-5](../img/fig5-5.png))。 於是,這個觀察者會聽到以下內容: 現在,想象第三個人正在透過從庫來聽這個對話。 Cake夫人說的內容是從一個延遲很低的從庫讀取的但Poons先生所說的內容從庫的延遲要大的多見[圖5-5](../img/fig5-5.png))。 於是,這個觀察者會聽到以下內容:
> *Mrs. Cake* > *Mrs. Cake*
> 通常約十秒鐘Mr. Poons. > 通常約十秒鐘Mr. Poons.
@ -275,43 +275,43 @@
**圖5-5 如果某些分割槽的複製速度慢於其他分割槽,那麼觀察者在看到問題之前可能會看到答案。** **圖5-5 如果某些分割槽的複製速度慢於其他分割槽,那麼觀察者在看到問題之前可能會看到答案。**
防止這種異常,需要另一種型別的保證:**一致字首讀consistent prefix reads**【23】。 這個保證說:如果一系列寫入按某個順序發生,那麼任何人讀取這些寫入時,也會看見它們以同樣的順序出現。 防止這種異常,需要另一種型別的保證:**一致字首讀consistent prefix reads**【23】。 這個保證說:如果一系列寫入按某個順序發生,那麼任何人讀取這些寫入時,也會看見它們以同樣的順序出現。
這是**分割槽partitioned****分片sharded**)資料庫中的一個特殊問題,將在[第六章](ch6.md)中討論。如果資料庫總是以相同的順序應用寫入,則讀取總是會看到一致的字首,所以這種異常不會發生。但是在許多分散式資料庫中,不同的分割槽獨立執行,因此不存在**全域性寫入順序**:當用戶從資料庫中讀取資料時,可能會看到資料庫的某些部分處於較舊的狀態,而某些處於較新的狀態。 這是**分割槽partitioned****分片sharded**)資料庫中的一個特殊問題,將在[第六章](ch6.md)中討論。如果資料庫總是以相同的順序應用寫入,則讀取總是會看到一致的字首,所以這種異常不會發生。但是在許多分散式資料庫中,不同的分割槽獨立執行,因此不存在**全域性寫入順序**:當用戶從資料庫中讀取資料時,可能會看到資料庫的某些部分處於較舊的狀態,而某些處於較新的狀態。
一種解決方案是,確保任何因果相關的寫入都寫入相同的分割槽。對於某些無法高效完成這種操作的應用,還有一些顯式跟蹤因果依賴關係的演算法,本書將在“[“此前發生”的關係和併發](#“此前發生”的關係和併發)”一節中返回這個主題。 一種解決方案是,確保任何因果相關的寫入都寫入相同的分割槽。對於某些無法高效完成這種操作的應用,還有一些顯式跟蹤因果依賴關係的演算法,本書將在“[“此前發生”的關係和併發](#“此前發生”的關係和併發)”一節中返回這個主題。
### 複製延遲的解決方案 ### 複製延遲的解決方案
在使用最終一致的系統時,如果複製延遲增加到幾分鐘甚至幾小時,則應該考慮應用程式的行為。如果答案是“沒問題”,那很好。但如果結果對於使用者來說是不好體驗,那麼設計系統來提供更強的保證是很重要的,例如**寫後讀**。明明是非同步複製卻假設複製是同步的,這是很多麻煩的根源。 在使用最終一致的系統時,如果複製延遲增加到幾分鐘甚至幾小時,則應該考慮應用程式的行為。如果答案是“沒問題”,那很好。但如果結果對於使用者來說是不好體驗,那麼設計系統來提供更強的保證是很重要的,例如**寫後讀**。明明是非同步複製卻假設複製是同步的,這是很多麻煩的根源。
如前所述,應用程式可以提供比底層資料庫更強有力的保證,例如透過主庫進行某種讀取。但在應用程式程式碼中處理這些問題是複雜的,容易出錯。 如前所述,應用程式可以提供比底層資料庫更強有力的保證,例如透過主庫進行某種讀取。但在應用程式程式碼中處理這些問題是複雜的,容易出錯。
如果應用程式開發人員不必擔心微妙的複製問題,並可以信賴他們的資料庫“做了正確的事情”,那該多好呀。這就是 **事務transaction** 存在的原因:**資料庫透過事務提供強大的保證**,所以應用程式可以更加簡單。 如果應用程式開發人員不必擔心微妙的複製問題,並可以信賴他們的資料庫“做了正確的事情”,那該多好呀。這就是 **事務transaction** 存在的原因:**資料庫透過事務提供強大的保證**,所以應用程式可以更加簡單。
單節點事務已經存在了很長時間。然而在走向分散式(複製和分割槽)資料庫時,許多系統放棄了事務。聲稱事務在效能和可用性上的代價太高,並斷言在可伸縮系統中最終一致性是不可避免的。這個敘述有一些道理,但過於簡單了,本書其餘部分將提出更為細緻的觀點。第七章和第九章將回到事務的話題,並討論一些替代機制。 單節點事務已經存在了很長時間。然而在走向分散式(複製和分割槽)資料庫時,許多系統放棄了事務。聲稱事務在效能和可用性上的代價太高,並斷言在可伸縮系統中最終一致性是不可避免的。這個敘述有一些道理,但過於簡單了,本書其餘部分將提出更為細緻的觀點。第七章和第九章將回到事務的話題,並討論一些替代機制。
## 多主複製 ## 多主複製
本章到目前為止,我們只考慮使用單個領導者的複製架構。 雖然這是一種常見的方法,但也有一些有趣的選擇。 本章到目前為止,我們只考慮使用單個領導者的複製架構。 雖然這是一種常見的方法,但也有一些有趣的選擇。
基於領導者的複製有一個主要的缺點:只有一個主庫,而所有的寫入都必須透過它[^iv]。如果出於任何原因(例如和主庫之間的網路連線中斷)無法連線到主庫, 就無法向資料庫寫入。 基於領導者的複製有一個主要的缺點:只有一個主庫,而所有的寫入都必須透過它[^iv]。如果出於任何原因(例如和主庫之間的網路連線中斷)無法連線到主庫, 就無法向資料庫寫入。
[^iv]: 如果資料庫被分割槽(見[第六章](ch6.md)),每個分割槽都有一個領導。 不同的分割槽可能在不同的節點上有其領導者,但是每個分割槽必須有一個領導者節點。 [^iv]: 如果資料庫被分割槽(見[第六章](ch6.md)),每個分割槽都有一個領導。 不同的分割槽可能在不同的節點上有其領導者,但是每個分割槽必須有一個領導者節點。
基於領導者的複製模型的自然延伸是允許多個節點接受寫入。 複製仍然以同樣的方式發生:處理寫入的每個節點都必須將該資料更改轉發給所有其他節點。 稱之為**多領導者配置**(也稱多主、多活複製)。 在這種情況下,每個領導者同時扮演其他領導者的追隨者。 基於領導者的複製模型的自然延伸是允許多個節點接受寫入。 複製仍然以同樣的方式發生:處理寫入的每個節點都必須將該資料更改轉發給所有其他節點。 稱之為**多領導者配置**(也稱多主、多活複製)。 在這種情況下,每個領導者同時扮演其他領導者的追隨者。
### 多主複製的應用場景 ### 多主複製的應用場景
在單個數據中心內部使用多個主庫沒有太大意義,因為複雜性已經超過了能帶來的好處。 但在一些情況下,多活配置是也合理的。 在單個數據中心內部使用多個主庫沒有太大意義,因為複雜性已經超過了能帶來的好處。 但在一些情況下,多活配置是也合理的。
#### 運維多個數據中心 #### 運維多個數據中心
假如你有一個數據庫,副本分散在好幾個不同的資料中心(也許這樣可以容忍單個數據中心的故障,或地理上更接近使用者)。 使用常規的基於領導者的複製設定,主庫必須位於其中一個數據中心,且所有寫入都必須經過該資料中心。 假如你有一個數據庫,副本分散在好幾個不同的資料中心(也許這樣可以容忍單個數據中心的故障,或地理上更接近使用者)。 使用常規的基於領導者的複製設定,主庫必須位於其中一個數據中心,且所有寫入都必須經過該資料中心。
多領導者配置中可以在每個資料中心都有主庫。 [圖5-6](../img/fig5-6.png)展示了這個架構的樣子。 在每個資料中心內使用常規的主從複製;在資料中心之間,每個資料中心的主庫都會將其更改複製到其他資料中心的主庫中。 多領導者配置中可以在每個資料中心都有主庫。 [圖5-6](../img/fig5-6.png)展示了這個架構的樣子。 在每個資料中心內使用常規的主從複製;在資料中心之間,每個資料中心的主庫都會將其更改複製到其他資料中心的主庫中。
![](../img/fig5-6.png) ![](../img/fig5-6.png)
@ -321,47 +321,47 @@
***效能*** ***效能***
在單主配置中,每個寫入都必須穿過網際網路,進入主庫所在的資料中心。這可能會增加寫入時間,並可能違背了設定多個數據中心的初心。在多主配置中,每個寫操作都可以在本地資料中心進行處理,並與其他資料中心非同步複製。因此,資料中心之間的網路延遲對使用者來說是透明的,這意味著感覺到的效能可能會更好。 在單主配置中,每個寫入都必須穿過網際網路,進入主庫所在的資料中心。這可能會增加寫入時間,並可能違背了設定多個數據中心的初心。在多主配置中,每個寫操作都可以在本地資料中心進行處理,並與其他資料中心非同步複製。因此,資料中心之間的網路延遲對使用者來說是透明的,這意味著感覺到的效能可能會更好。
***容忍資料中心停機*** ***容忍資料中心停機***
在單主配置中,如果主庫所在的資料中心發生故障,故障切換必須使另一個數據中心裡的追隨者成為領導者。在多主配置中,每個資料中心可以獨立於其他資料中心繼續執行,並且當發生故障的資料中心歸隊時,複製會自動趕上。 在單主配置中,如果主庫所在的資料中心發生故障,故障切換必須使另一個數據中心裡的追隨者成為領導者。在多主配置中,每個資料中心可以獨立於其他資料中心繼續執行,並且當發生故障的資料中心歸隊時,複製會自動趕上。
***容忍網路問題*** ***容忍網路問題***
資料中心之間的通訊通常穿過公共網際網路,這可能不如資料中心內的本地網路可靠。單主配置對這資料中心間的連線問題非常敏感,因為透過這個連線進行的寫操作是同步的。採用非同步複製功能的多主配置通常能更好地承受網路問題:臨時的網路中斷並不會妨礙正在處理的寫入。 資料中心之間的通訊通常穿過公共網際網路,這可能不如資料中心內的本地網路可靠。單主配置對這資料中心間的連線問題非常敏感,因為透過這個連線進行的寫操作是同步的。採用非同步複製功能的多主配置通常能更好地承受網路問題:臨時的網路中斷並不會妨礙正在處理的寫入。
有些資料庫預設情況下支援多主配置但使用外部工具實現也很常見例如用於MySQL的Tungsten Replicator 【26】用於PostgreSQL的BDR【27】以及用於Oracle的GoldenGate 【19】。 有些資料庫預設情況下支援多主配置但使用外部工具實現也很常見例如用於MySQL的Tungsten Replicator 【26】用於PostgreSQL的BDR【27】以及用於Oracle的GoldenGate 【19】。
儘管多主複製有這些優勢,但也有一個很大的缺點:兩個不同的資料中心可能會同時修改相同的資料,寫衝突是必須解決的(如[圖5-6](../img/fig5-6.png)中“衝突解決conflict resolution。本書將在“[處理寫入衝突](#處理寫入衝突)”中詳細討論這個問題。 儘管多主複製有這些優勢,但也有一個很大的缺點:兩個不同的資料中心可能會同時修改相同的資料,寫衝突是必須解決的(如[圖5-6](../img/fig5-6.png)中“衝突解決conflict resolution。本書將在“[處理寫入衝突](#處理寫入衝突)”中詳細討論這個問題。
由於多主複製在許多資料庫中都屬於改裝的功能所以常常存在微妙的配置缺陷且經常與其他資料庫功能之間出現意外的反應。例如自增主鍵、觸發器、完整性約束等都可能會有麻煩。因此多主複製往往被認為是危險的領域應儘可能避免【28】。 由於多主複製在許多資料庫中都屬於改裝的功能所以常常存在微妙的配置缺陷且經常與其他資料庫功能之間出現意外的反應。例如自增主鍵、觸發器、完整性約束等都可能會有麻煩。因此多主複製往往被認為是危險的領域應儘可能避免【28】。
#### 需要離線操作的客戶端 #### 需要離線操作的客戶端
多主複製的另一種適用場景是:應用程式在斷網之後仍然需要繼續工作。 多主複製的另一種適用場景是:應用程式在斷網之後仍然需要繼續工作。
例如,考慮手機,膝上型電腦和其他裝置上的日曆應用。無論裝置目前是否有網際網路連線,你需要能隨時檢視你的會議(發出讀取請求),輸入新的會議(發出寫入請求)。如果在離線狀態下進行任何更改,則裝置下次上線時,需要與伺服器和其他裝置同步。 例如,考慮手機,膝上型電腦和其他裝置上的日曆應用。無論裝置目前是否有網際網路連線,你需要能隨時檢視你的會議(發出讀取請求),輸入新的會議(發出寫入請求)。如果在離線狀態下進行任何更改,則裝置下次上線時,需要與伺服器和其他裝置同步。
在這種情況下,每個裝置都有一個充當領導者的本地資料庫(它接受寫請求),並且在所有裝置上的日曆副本之間同步時,存在非同步的多主複製過程。複製延遲可能是幾小時甚至幾天,具體取決於何時可以訪問網際網路。 在這種情況下,每個裝置都有一個充當領導者的本地資料庫(它接受寫請求),並且在所有裝置上的日曆副本之間同步時,存在非同步的多主複製過程。複製延遲可能是幾小時甚至幾天,具體取決於何時可以訪問網際網路。
從架構的角度來看,這種設定實際上與資料中心之間的多領導者複製類似,每個裝置都是一個“資料中心”,而它們之間的網路連線是極度不可靠的。從歷史上各類日曆同步功能的破爛實現可以看出,想把多活配好是多麼困難的一件事。 從架構的角度來看,這種設定實際上與資料中心之間的多領導者複製類似,每個裝置都是一個“資料中心”,而它們之間的網路連線是極度不可靠的。從歷史上各類日曆同步功能的破爛實現可以看出,想把多活配好是多麼困難的一件事。
有一些工具旨在使這種多領導者配置更容易。例如CouchDB就是為這種操作模式而設計的【29】。 有一些工具旨在使這種多領導者配置更容易。例如CouchDB就是為這種操作模式而設計的【29】。
#### 協同編輯 #### 協同編輯
實時協作編輯應用程式允許多個人同時編輯文件。例如Etherpad 【30】和Google Docs 【31】允許多人同時編輯文字文件或電子表格該演算法在“[自動衝突解決](#自動衝突解決)”中簡要討論。我們通常不會將協作式編輯視為資料庫複製問題但與前面提到的離線編輯用例有許多相似之處。當一個使用者編輯文件時所做的更改將立即應用到其本地副本Web瀏覽器或客戶端應用程式中的文件狀態並非同步複製到伺服器和編輯同一文件的任何其他使用者。 實時協作編輯應用程式允許多個人同時編輯文件。例如Etherpad 【30】和Google Docs 【31】允許多人同時編輯文字文件或電子表格該演算法在“[自動衝突解決](#自動衝突解決)”中簡要討論。我們通常不會將協作式編輯視為資料庫複製問題但與前面提到的離線編輯用例有許多相似之處。當一個使用者編輯文件時所做的更改將立即應用到其本地副本Web瀏覽器或客戶端應用程式中的文件狀態並非同步複製到伺服器和編輯同一文件的任何其他使用者。
如果要保證不會發生編輯衝突,則應用程式必須先取得文件的鎖定,然後使用者才能對其進行編輯。如果另一個使用者想要編輯同一個文件,他們首先必須等到第一個使用者提交修改並釋放鎖定。這種協作模式相當於主從複製模型下在主節點上執行事務操作。 如果要保證不會發生編輯衝突,則應用程式必須先取得文件的鎖定,然後使用者才能對其進行編輯。如果另一個使用者想要編輯同一個文件,他們首先必須等到第一個使用者提交修改並釋放鎖定。這種協作模式相當於主從複製模型下在主節點上執行事務操作。
但是為了加速協作您可能希望將更改的單位設定得非常小例如一個按鍵並避免鎖定。這種方法允許多個使用者同時進行編輯但同時也帶來了多領導者複製的所有挑戰包括需要解決衝突【32】。 但是為了加速協作您可能希望將更改的單位設定得非常小例如一個按鍵並避免鎖定。這種方法允許多個使用者同時進行編輯但同時也帶來了多領導者複製的所有挑戰包括需要解決衝突【32】。
### 處理寫入衝突 ### 處理寫入衝突
多領導者複製的最大問題是可能發生寫衝突,這意味著需要解決衝突。 多領導者複製的最大問題是可能發生寫衝突,這意味著需要解決衝突。
例如,考慮一個由兩個使用者同時編輯的維基頁面,如[圖5-7](../img/fig5-7.png)所示。使用者1將頁面的標題從A更改為B並且使用者2同時將標題從A更改為C。每個使用者的更改已成功應用到其本地主庫。但當非同步複製時會發現衝突【33】。單主資料庫中不會出現此問題。 例如,考慮一個由兩個使用者同時編輯的維基頁面,如[圖5-7](../img/fig5-7.png)所示。使用者1將頁面的標題從A更改為B並且使用者2同時將標題從A更改為C。每個使用者的更改已成功應用到其本地主庫。但當非同步複製時會發現衝突【33】。單主資料庫中不會出現此問題。
![](../img/fig5-7.png) ![](../img/fig5-7.png)
@ -369,25 +369,25 @@
#### 同步與非同步衝突檢測 #### 同步與非同步衝突檢測
在單主資料庫中,第二個寫入將被阻塞,並等待第一個寫入完成,或中止第二個寫入事務,強制使用者重試。另一方面,在多主配置中,兩個寫入都是成功的,並且在稍後的時間點僅僅非同步地檢測到衝突。那時要求使用者解決衝突可能為時已晚。 在單主資料庫中,第二個寫入將被阻塞,並等待第一個寫入完成,或中止第二個寫入事務,強制使用者重試。另一方面,在多主配置中,兩個寫入都是成功的,並且在稍後的時間點僅僅非同步地檢測到衝突。那時要求使用者解決衝突可能為時已晚。
原則上,可以使衝突檢測同步 - 即等待寫入被複制到所有副本,然後再告訴使用者寫入成功。但是,透過這樣做,您將失去多主複製的主要優點:允許每個副本獨立接受寫入。如果您想要同步衝突檢測,那麼您可以使用單主程式複製。 原則上,可以使衝突檢測同步 - 即等待寫入被複制到所有副本,然後再告訴使用者寫入成功。但是,透過這樣做,您將失去多主複製的主要優點:允許每個副本獨立接受寫入。如果您想要同步衝突檢測,那麼您可以使用單主程式複製。
#### 避免衝突 #### 避免衝突
處理衝突的最簡單的策略就是避免它們如果應用程式可以確保特定記錄的所有寫入都透過同一個領導者那麼衝突就不會發生。由於許多的多領導者複製實現在處理衝突時處理得相當不好避免衝突是一個經常推薦的方法【34】。 處理衝突的最簡單的策略就是避免它們如果應用程式可以確保特定記錄的所有寫入都透過同一個領導者那麼衝突就不會發生。由於許多的多領導者複製實現在處理衝突時處理得相當不好避免衝突是一個經常推薦的方法【34】。
例如,在使用者可以編輯自己的資料的應用程式中,可以確保來自特定使用者的請求始終路由到同一資料中心,並使用該資料中心的領導者進行讀寫。不同的使用者可能有不同的“家庭”資料中心(可能根據使用者的地理位置選擇),但從任何使用者的角度來看,配置基本上都是單一的領導者。 例如,在使用者可以編輯自己的資料的應用程式中,可以確保來自特定使用者的請求始終路由到同一資料中心,並使用該資料中心的領導者進行讀寫。不同的使用者可能有不同的“家庭”資料中心(可能根據使用者的地理位置選擇),但從任何使用者的角度來看,配置基本上都是單一的領導者。
但是,有時您可能需要更改指定的記錄的主庫——可能是因為一個數據中心出現故障,您需要將流量重新路由到另一個數據中心,或者可能是因為使用者已經遷移到另一個位置,現在更接近不同的資料中心。在這種情況下,衝突避免會中斷,你必須處理不同主庫同時寫入的可能性。 但是,有時您可能需要更改指定的記錄的主庫——可能是因為一個數據中心出現故障,您需要將流量重新路由到另一個數據中心,或者可能是因為使用者已經遷移到另一個位置,現在更接近不同的資料中心。在這種情況下,衝突避免會中斷,你必須處理不同主庫同時寫入的可能性。
#### 收斂至一致的狀態 #### 收斂至一致的狀態
單主資料庫按順序進行寫操作:如果同一個欄位有多個更新,則最後一個寫操作將決定該欄位的最終值。 單主資料庫按順序進行寫操作:如果同一個欄位有多個更新,則最後一個寫操作將決定該欄位的最終值。
在多主配置中,沒有明確的寫入順序,所以最終值應該是什麼並不清楚。在[圖5-7](../img/fig5-7.png)中在主庫1中標題首先更新為B而後更新為C在主庫2中首先更新為C然後更新為B。兩個順序都不是“更正確”的。 在多主配置中,沒有明確的寫入順序,所以最終值應該是什麼並不清楚。在[圖5-7](../img/fig5-7.png)中在主庫1中標題首先更新為B而後更新為C在主庫2中首先更新為C然後更新為B。兩個順序都不是“更正確”的。
如果每個副本只是按照它看到寫入的順序寫入那麼資料庫最終將處於不一致的狀態最終值將是在主庫1的C和主庫2的B。這是不可接受的每個複製方案都必須確保資料在所有副本中最終都是相同的。因此資料庫必須以一種 **收斂convergent** 的方式解決衝突,這意味著所有副本必須在所有變更復制完成時收斂至一個相同的最終值。 如果每個副本只是按照它看到寫入的順序寫入那麼資料庫最終將處於不一致的狀態最終值將是在主庫1的C和主庫2的B。這是不可接受的每個複製方案都必須確保資料在所有副本中最終都是相同的。因此資料庫必須以一種 **收斂convergent** 的方式解決衝突,這意味著所有副本必須在所有變更復制完成時收斂至一個相同的最終值。
實現衝突合併解決有多種途徑: 實現衝突合併解決有多種途徑:
@ -400,17 +400,17 @@
#### 自定義衝突解決邏輯 #### 自定義衝突解決邏輯
作為解決衝突最合適的方法可能取決於應用程式,大多數多主複製工具允許使用應用程式程式碼編寫衝突解決邏輯。該程式碼可以在寫入或讀取時執行: 作為解決衝突最合適的方法可能取決於應用程式,大多數多主複製工具允許使用應用程式程式碼編寫衝突解決邏輯。該程式碼可以在寫入或讀取時執行:
***寫時執行*** ***寫時執行***
只要資料庫系統檢測到複製更改日誌中存在衝突就會呼叫衝突處理程式。例如Bucardo允許您為此編寫一段Perl程式碼。這個處理程式通常不能提示使用者——它在後臺程序中執行並且必須快速執行。 只要資料庫系統檢測到複製更改日誌中存在衝突就會呼叫衝突處理程式。例如Bucardo允許您為此編寫一段Perl程式碼。這個處理程式通常不能提示使用者——它在後臺程序中執行並且必須快速執行。
***讀時執行*** ***讀時執行***
當檢測到衝突時所有衝突寫入被儲存。下一次讀取資料時會將這些多個版本的資料返回給應用程式。應用程式可能會提示使用者或自動解決衝突並將結果寫回資料庫。例如CouchDB以這種方式工作。 當檢測到衝突時所有衝突寫入被儲存。下一次讀取資料時會將這些多個版本的資料返回給應用程式。應用程式可能會提示使用者或自動解決衝突並將結果寫回資料庫。例如CouchDB以這種方式工作。
請注意衝突解決通常適用於單個行或文件層面而不是整個事務【36】。因此如果您有一個事務會原子性地進行幾次不同的寫入請參閱[第七章](ch7.md),對於衝突解決而言,每個寫入仍需分開單獨考慮。 請注意衝突解決通常適用於單個行或文件層面而不是整個事務【36】。因此如果您有一個事務會原子性地進行幾次不同的寫入請參閱[第七章](ch7.md),對於衝突解決而言,每個寫入仍需分開單獨考慮。
@ -431,97 +431,97 @@
#### 什麼是衝突? #### 什麼是衝突?
有些衝突是顯而易見的。在[圖5-7](../img/fig5-7.png)的例子中,兩個寫操作併發地修改了同一條記錄中的同一個欄位,並將其設定為兩個不同的值。毫無疑問這是一個衝突。 有些衝突是顯而易見的。在[圖5-7](../img/fig5-7.png)的例子中,兩個寫操作併發地修改了同一條記錄中的同一個欄位,並將其設定為兩個不同的值。毫無疑問這是一個衝突。
其他型別的衝突可能更為微妙,難以發現。例如,考慮一個會議室預訂系統:它記錄誰訂了哪個時間段的哪個房間。應用需要確保每個房間只有一組人同時預定(即不得有相同房間的重疊預訂)。在這種情況下,如果同時為同一個房間建立兩個不同的預訂,則可能會發生衝突。即使應用程式在允許使用者進行預訂之前檢查可用性,如果兩次預訂是由兩個不同的領導者進行的,則可能會有衝突。 其他型別的衝突可能更為微妙,難以發現。例如,考慮一個會議室預訂系統:它記錄誰訂了哪個時間段的哪個房間。應用需要確保每個房間只有一組人同時預定(即不得有相同房間的重疊預訂)。在這種情況下,如果同時為同一個房間建立兩個不同的預訂,則可能會發生衝突。即使應用程式在允許使用者進行預訂之前檢查可用性,如果兩次預訂是由兩個不同的領導者進行的,則可能會有衝突。
現在還沒有一個現成的答案,但在接下來的章節中,我們將更好地瞭解這個問題。我們將在[第七章](ch7.md)中看到更多的衝突示例,在[第十二章](ch12.md)中我們將討論用於檢測和解決複製系統中衝突的可伸縮方法。 現在還沒有一個現成的答案,但在接下來的章節中,我們將更好地瞭解這個問題。我們將在[第七章](ch7.md)中看到更多的衝突示例,在[第十二章](ch12.md)中我們將討論用於檢測和解決複製系統中衝突的可伸縮方法。
### 多主複製拓撲 ### 多主複製拓撲
**複製拓撲**replication topology描述寫入從一個節點傳播到另一個節點的通訊路徑。如果你有兩個領導者如[圖5-7](../img/fig5-7.png)所示只有一個合理的拓撲結構領導者1必須把他所有的寫到領導者2反之亦然。當有兩個以上的領導各種不同的拓撲是可能的。[圖5-8](../img/fig5-8.png)舉例說明了一些例子。 **複製拓撲**replication topology描述寫入從一個節點傳播到另一個節點的通訊路徑。如果你有兩個領導者如[圖5-7](../img/fig5-7.png)所示只有一個合理的拓撲結構領導者1必須把他所有的寫到領導者2反之亦然。當有兩個以上的領導各種不同的拓撲是可能的。[圖5-8](../img/fig5-8.png)舉例說明了一些例子。
![](../img/fig5-8.png) ![](../img/fig5-8.png)
**圖5-8 三個可以設定多領導者複製的示例拓撲。** **圖5-8 三個可以設定多領導者複製的示例拓撲。**
最普遍的拓撲是全部到全部([圖5-8 (c)](../img/fig5-8.png)其中每個領導者將其寫入每個其他領導。但是也會使用更多受限制的拓撲例如預設情況下MySQL僅支援**環形拓撲circular topology**【34】其中每個節點接收來自一個節點的寫入並將這些寫入加上自己的任何寫入轉發給另一個節點。另一種流行的拓撲結構具有星形的形狀[^v]。一個指定的根節點將寫入轉發給所有其他節點。星形拓撲可以推廣到樹。 最普遍的拓撲是全部到全部([圖5-8 (c)](../img/fig5-8.png)其中每個領導者將其寫入每個其他領導。但是也會使用更多受限制的拓撲例如預設情況下MySQL僅支援**環形拓撲circular topology**【34】其中每個節點接收來自一個節點的寫入並將這些寫入加上自己的任何寫入轉發給另一個節點。另一種流行的拓撲結構具有星形的形狀[^v]。一個指定的根節點將寫入轉發給所有其他節點。星形拓撲可以推廣到樹。
[^v]: 不要與星型模式混淆(請參閱“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”),其中描述了資料模型的結構,而不是節點之間的通訊拓撲。 [^v]: 不要與星型模式混淆(請參閱“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”),其中描述了資料模型的結構,而不是節點之間的通訊拓撲。
在環形和星形拓撲中寫入可能需要在到達所有副本之前透過多個節點。因此節點需要轉發從其他節點收到的資料更改。為了防止無限複製迴圈每個節點被賦予一個唯一的識別符號並且在複製日誌中每個寫入都被標記了所有已經過的節點的識別符號【43】。當一個節點收到用自己的識別符號標記的資料更改時該資料更改將被忽略因為節點知道它已經被處理過。 在環形和星形拓撲中寫入可能需要在到達所有副本之前透過多個節點。因此節點需要轉發從其他節點收到的資料更改。為了防止無限複製迴圈每個節點被賦予一個唯一的識別符號並且在複製日誌中每個寫入都被標記了所有已經過的節點的識別符號【43】。當一個節點收到用自己的識別符號標記的資料更改時該資料更改將被忽略因為節點知道它已經被處理過。
環形和星形拓撲的問題是,如果只有一個節點發生故障,則可能會中斷其他節點之間的複製訊息流,導致它們無法通訊,直到節點修復。拓撲結構可以重新配置為在發生故障的節點上工作,但在大多數部署中,這種重新配置必須手動完成。更密集連線的拓撲結構(例如全部到全部)的容錯性更好,因為它允許訊息沿著不同的路徑傳播,避免單點故障。 環形和星形拓撲的問題是,如果只有一個節點發生故障,則可能會中斷其他節點之間的複製訊息流,導致它們無法通訊,直到節點修復。拓撲結構可以重新配置為在發生故障的節點上工作,但在大多數部署中,這種重新配置必須手動完成。更密集連線的拓撲結構(例如全部到全部)的容錯性更好,因為它允許訊息沿著不同的路徑傳播,避免單點故障。
另一方面,全部到全部的拓撲也可能有問題。特別是,一些網路連結可能比其他網路連結更快(例如,由於網路擁塞),結果是一些複製訊息可能“超過”其他複製訊息,如[圖5-9](../img/fig5-9.png)所示。 另一方面,全部到全部的拓撲也可能有問題。特別是,一些網路連結可能比其他網路連結更快(例如,由於網路擁塞),結果是一些複製訊息可能“超過”其他複製訊息,如[圖5-9](../img/fig5-9.png)所示。
![](../img/fig5-9.png) ![](../img/fig5-9.png)
**圖5-9 使用多主程式複製時,可能會在某些副本中寫入錯誤的順序。** **圖5-9 使用多主程式複製時,可能會在某些副本中寫入錯誤的順序。**
在[圖5-9](../img/fig5-9.png)中客戶端A向主庫1的表中插入一行客戶端B在主庫3上更新該行。然而主庫2可以以不同的順序接收寫入它可以首先接收更新從它的角度來看是對資料庫中不存在的行的更新並且僅在稍後接收到相應的插入其應該在更新之前 在[圖5-9](../img/fig5-9.png)中客戶端A向主庫1的表中插入一行客戶端B在主庫3上更新該行。然而主庫2可以以不同的順序接收寫入它可以首先接收更新從它的角度來看是對資料庫中不存在的行的更新並且僅在稍後接收到相應的插入其應該在更新之前
這是一個因果關係的問題,類似於我們在“[一致字首讀](#一致字首讀)”中看到的更新取決於先前的插入所以我們需要確保所有節點先處理插入然後再處理更新。僅僅在每一次寫入時新增一個時間戳是不夠的因為時鐘不可能被充分地同步以便在主庫2處正確地排序這些事件見[第八章](ch8.md))。 這是一個因果關係的問題,類似於我們在“[一致字首讀](#一致字首讀)”中看到的更新取決於先前的插入所以我們需要確保所有節點先處理插入然後再處理更新。僅僅在每一次寫入時新增一個時間戳是不夠的因為時鐘不可能被充分地同步以便在主庫2處正確地排序這些事件見[第八章](ch8.md))。
要正確排序這些事件,可以使用一種稱為 **版本向量version vectors** 的技術,本章稍後將討論這種技術(請參閱“[檢測併發寫入](#檢測併發寫入)”。然而衝突檢測技術在許多多領導者複製系統中執行得不好。例如在撰寫本文時PostgreSQL BDR不提供寫入的因果排序【27】而Tungsten Replicator for MySQL甚至不嘗試檢測衝突【34】。 要正確排序這些事件,可以使用一種稱為 **版本向量version vectors** 的技術,本章稍後將討論這種技術(請參閱“[檢測併發寫入](#檢測併發寫入)”。然而衝突檢測技術在許多多領導者複製系統中執行得不好。例如在撰寫本文時PostgreSQL BDR不提供寫入的因果排序【27】而Tungsten Replicator for MySQL甚至不嘗試檢測衝突【34】。
如果您正在使用具有多領導者複製功能的系統,那麼應該瞭解這些問題,仔細閱讀文件,並徹底測試您的資料庫,以確保它確實提供了您認為具有的保證。 如果您正在使用具有多領導者複製功能的系統,那麼應該瞭解這些問題,仔細閱讀文件,並徹底測試您的資料庫,以確保它確實提供了您認為具有的保證。
## 無主複製 ## 無主複製
我們在本章到目前為止所討論的複製方法 ——單主複製、多主複製——都是這樣的想法:客戶端向一個主庫傳送寫請求,而資料庫系統負責將寫入複製到其他副本。主庫決定寫入的順序,而從庫按相同順序應用主庫的寫入。 我們在本章到目前為止所討論的複製方法 ——單主複製、多主複製——都是這樣的想法:客戶端向一個主庫傳送寫請求,而資料庫系統負責將寫入複製到其他副本。主庫決定寫入的順序,而從庫按相同順序應用主庫的寫入。
一些資料儲存系統採用不同的方法,放棄主庫的概念,並允許任何副本直接接受來自客戶端的寫入。最早的一些的複製資料系統是**無領導的leaderless**【1,44】但是在關係資料庫主導的時代這個想法幾乎已被忘卻。在亞馬遜將其用於其內部的Dynamo系統[^vi]之後它再一次成為資料庫的一種時尚架構【37】。 RiakCassandra和Voldemort是由Dynamo啟發的無領導複製模型的開源資料儲存所以這類資料庫也被稱為*Dynamo風格*。 一些資料儲存系統採用不同的方法,放棄主庫的概念,並允許任何副本直接接受來自客戶端的寫入。最早的一些的複製資料系統是**無領導的leaderless**【1,44】但是在關係資料庫主導的時代這個想法幾乎已被忘卻。在亞馬遜將其用於其內部的Dynamo系統[^vi]之後它再一次成為資料庫的一種時尚架構【37】。 RiakCassandra和Voldemort是由Dynamo啟發的無領導複製模型的開源資料儲存所以這類資料庫也被稱為*Dynamo風格*。
[^vi]: Dynamo不適用於Amazon以外的使用者。 令人困惑的是AWS提供了一個名為DynamoDB的託管資料庫產品它使用了完全不同的體系結構它基於單領導者複製。 [^vi]: Dynamo不適用於Amazon以外的使用者。 令人困惑的是AWS提供了一個名為DynamoDB的託管資料庫產品它使用了完全不同的體系結構它基於單領導者複製。
在一些無領導者的實現中,客戶端直接將寫入傳送到幾個副本中,而另一些情況下,一個 **協調者coordinator** 節點代表客戶端進行寫入。但與主庫資料庫不同,協調者不執行特定的寫入順序。我們將會看到,這種設計上的差異對資料庫的使用方式有著深遠的影響。 在一些無領導者的實現中,客戶端直接將寫入傳送到幾個副本中,而另一些情況下,一個 **協調者coordinator** 節點代表客戶端進行寫入。但與主庫資料庫不同,協調者不執行特定的寫入順序。我們將會看到,這種設計上的差異對資料庫的使用方式有著深遠的影響。
### 當節點故障時寫入資料庫 ### 當節點故障時寫入資料庫
假設你有一個帶有三個副本的資料庫,而其中一個副本目前不可用,或許正在重新啟動以安裝系統更新。在基於主機的配置中,如果要繼續處理寫入,則可能需要執行故障切換(請參閱「[處理節點宕機](#處理節點宕機)」)。 假設你有一個帶有三個副本的資料庫,而其中一個副本目前不可用,或許正在重新啟動以安裝系統更新。在基於主機的配置中,如果要繼續處理寫入,則可能需要執行故障切換(請參閱「[處理節點宕機](#處理節點宕機)」)。
另一方面,在無領導配置中,故障切換不存在。[圖5-10](../img/fig5-10.png)顯示了發生了什麼事情客戶端使用者1234並行傳送寫入到所有三個副本並且兩個可用副本接受寫入但是不可用副本錯過了它。假設三個副本中的兩個承認寫入是足夠的在使用者1234已經收到兩個確定的響應之後我們認為寫入成功。客戶簡單地忽略了其中一個副本錯過了寫入的事實。 另一方面,在無領導配置中,故障切換不存在。[圖5-10](../img/fig5-10.png)顯示了發生了什麼事情客戶端使用者1234並行傳送寫入到所有三個副本並且兩個可用副本接受寫入但是不可用副本錯過了它。假設三個副本中的兩個承認寫入是足夠的在使用者1234已經收到兩個確定的響應之後我們認為寫入成功。客戶簡單地忽略了其中一個副本錯過了寫入的事實。
![](../img/fig5-10.png) ![](../img/fig5-10.png)
**圖5-10 法定寫入,法定讀取,並在節點中斷後讀修復。** **圖5-10 法定寫入,法定讀取,並在節點中斷後讀修復。**
現在想象一下,不可用的節點重新聯機,客戶端開始讀取它。節點關閉時發生的任何寫入都從該節點丟失。因此,如果您從該節點讀取資料,則可能會將陳舊(過時)值視為響應。 現在想象一下,不可用的節點重新聯機,客戶端開始讀取它。節點關閉時發生的任何寫入都從該節點丟失。因此,如果您從該節點讀取資料,則可能會將陳舊(過時)值視為響應。
為了解決這個問題,當一個客戶端從資料庫中讀取資料時,它不僅僅傳送它的請求到一個副本:讀請求也被並行地傳送到多個節點。客戶可能會從不同的節點獲得不同的響應。即來自一個節點的最新值和來自另一個節點的陳舊值。版本號用於確定哪個值更新(請參閱“[檢測併發寫入](#檢測併發寫入)”)。 為了解決這個問題,當一個客戶端從資料庫中讀取資料時,它不僅僅傳送它的請求到一個副本:讀請求也被並行地傳送到多個節點。客戶可能會從不同的節點獲得不同的響應。即來自一個節點的最新值和來自另一個節點的陳舊值。版本號用於確定哪個值更新(請參閱“[檢測併發寫入](#檢測併發寫入)”)。
#### 讀修復和反熵 #### 讀修復和反熵
複製方案應確保最終將所有資料複製到每個副本。在一個不可用的節點重新聯機之後,它如何趕上它錯過的寫入? 複製方案應確保最終將所有資料複製到每個副本。在一個不可用的節點重新聯機之後,它如何趕上它錯過的寫入?
在Dynamo風格的資料儲存中經常使用兩種機制 在Dynamo風格的資料儲存中經常使用兩種機制
***讀修復Read repair*** ***讀修復Read repair***
當客戶端並行讀取多個節點時,它可以檢測到任何陳舊的響應。例如,在[圖5-10](../img/fig5-10.png)中使用者2345獲得了來自副本3的版本6值和來自副本1和2的版本7值。客戶端發現副本3具有陳舊值並將新值寫回到該副本。這種方法適用於讀頻繁的值。 當客戶端並行讀取多個節點時,它可以檢測到任何陳舊的響應。例如,在[圖5-10](../img/fig5-10.png)中使用者2345獲得了來自副本3的版本6值和來自副本1和2的版本7值。客戶端發現副本3具有陳舊值並將新值寫回到該副本。這種方法適用於讀頻繁的值。
***反熵過程Anti-entropy process*** ***反熵過程Anti-entropy process***
此外,一些資料儲存具有後臺程序,該程序不斷查詢副本之間的資料差異,並將任何缺少的資料從一個副本複製到另一個副本。與基於領導者的複製中的複製日誌不同,此反熵過程不會以任何特定的順序複製寫入,並且在複製資料之前可能會有顯著的延遲。 此外,一些資料儲存具有後臺程序,該程序不斷查詢副本之間的資料差異,並將任何缺少的資料從一個副本複製到另一個副本。與基於領導者的複製中的複製日誌不同,此反熵過程不會以任何特定的順序複製寫入,並且在複製資料之前可能會有顯著的延遲。
並不是所有的系統都實現了這兩個例如Voldemort目前沒有反熵過程。請注意如果沒有反熵過程某些副本中很少讀取的值可能會丟失從而降低了永續性因為只有在應用程式讀取值時才執行讀修復。 並不是所有的系統都實現了這兩個例如Voldemort目前沒有反熵過程。請注意如果沒有反熵過程某些副本中很少讀取的值可能會丟失從而降低了永續性因為只有在應用程式讀取值時才執行讀修復。
#### 讀寫的法定人數 #### 讀寫的法定人數
在[圖5-10](../img/fig5-10.png)的示例中,我們認為即使僅在三個副本中的兩個上進行處理,寫入仍然是成功的。如果三個副本中只有一個接受了寫入,會怎樣?以此類推,究竟多少個副本完成才可以認為寫成功? 在[圖5-10](../img/fig5-10.png)的示例中,我們認為即使僅在三個副本中的兩個上進行處理,寫入仍然是成功的。如果三個副本中只有一個接受了寫入,會怎樣?以此類推,究竟多少個副本完成才可以認為寫成功?
如果我們知道,每個成功的寫操作意味著在三個副本中至少有兩個出現,這意味著至多有一個副本可能是陳舊的。因此,如果我們從至少兩個副本讀取,我們可以確定至少有一個是最新的。如果第三個副本停機或響應速度緩慢,則讀取仍可以繼續返回最新值。 如果我們知道,每個成功的寫操作意味著在三個副本中至少有兩個出現,這意味著至多有一個副本可能是陳舊的。因此,如果我們從至少兩個副本讀取,我們可以確定至少有一個是最新的。如果第三個副本停機或響應速度緩慢,則讀取仍可以繼續返回最新值。
更一般地說如果有n個副本每個寫入必須由w節點確認才能被認為是成功的並且我們必須至少為每個讀取查詢r個節點。 (在我們的例子中,$n = 3w = 2r = 2$)。只要$w + r> n$我們期望在讀取時獲得最新的值因為r個讀取中至少有一個節點是最新的。遵循這些r值w值的讀寫稱為**法定人數quorum**[^vii]的讀和寫【44】。你可以認為r和w是有效讀寫所需的最低票數。 更一般地說如果有n個副本每個寫入必須由w節點確認才能被認為是成功的並且我們必須至少為每個讀取查詢r個節點。 (在我們的例子中,$n = 3w = 2r = 2$)。只要$w + r> n$我們期望在讀取時獲得最新的值因為r個讀取中至少有一個節點是最新的。遵循這些r值w值的讀寫稱為**法定人數quorum**[^vii]的讀和寫【44】。你可以認為r和w是有效讀寫所需的最低票數。
[^vii]: 有時候這種法定人數被稱為嚴格的法定人數,相對“寬鬆的法定人數”而言(見“[寬鬆的法定人數與提示移交](#寬鬆的法定人數與提示移交)”) [^vii]: 有時候這種法定人數被稱為嚴格的法定人數,相對“寬鬆的法定人數”而言(見“[寬鬆的法定人數與提示移交](#寬鬆的法定人數與提示移交)”)
在Dynamo風格的資料庫中引數nw和r通常是可配置的。一個常見的選擇是使n為奇數通常為3或5並設定 $w = r =n + 1/ 2$(向上取整)。但是可以根據需要更改數字。例如,設定$w = n$和$r = 1$的寫入很少且讀取次數較多的工作負載可能會受益。這使得讀取速度更快,但具有隻有一個失敗節點導致所有資料庫寫入失敗的缺點。 在Dynamo風格的資料庫中引數nw和r通常是可配置的。一個常見的選擇是使n為奇數通常為3或5並設定 $w = r =n + 1/ 2$(向上取整)。但是可以根據需要更改數字。例如,設定$w = n$和$r = 1$的寫入很少且讀取次數較多的工作負載可能會受益。這使得讀取速度更快,但具有隻有一個失敗節點導致所有資料庫寫入失敗的缺點。
> 叢集中可能有多於n的節點。叢集的機器數可能多於副本數目但是任何給定的值只能儲存在n個節點上。這允許對資料集進行分割槽從而可以支援比單個節點的儲存能力更大的資料集。我們將在[第六章](ch6.md)繼續討論分割槽。 > 叢集中可能有多於n的節點。叢集的機器數可能多於副本數目但是任何給定的值只能儲存在n個節點上。這允許對資料集進行分割槽從而可以支援比單個節點的儲存能力更大的資料集。我們將在[第六章](ch6.md)繼續討論分割槽。
> >
@ -538,19 +538,19 @@
**圖5-11 如果$w + r > n$讀取r個副本至少有一個r副本必然包含了最近的成功寫入** **圖5-11 如果$w + r > n$讀取r個副本至少有一個r副本必然包含了最近的成功寫入**
如果少於所需的w或r節點可用則寫入或讀取將返回錯誤。 由於許多原因,節點可能不可用:因為執行操作的錯誤(由於磁碟已滿而無法寫入),因為節點關閉(崩潰,關閉電源),由於客戶端和伺服器節點之間的網路中斷,或任何其他原因。 我們只關心節點是否返回了成功的響應,而不需要區分不同型別的錯誤。 如果少於所需的w或r節點可用則寫入或讀取將返回錯誤。 由於許多原因,節點可能不可用:因為執行操作的錯誤(由於磁碟已滿而無法寫入),因為節點關閉(崩潰,關閉電源),由於客戶端和伺服器節點之間的網路中斷,或任何其他原因。 我們只關心節點是否返回了成功的響應,而不需要區分不同型別的錯誤。
### 法定人數一致性的侷限性 ### 法定人數一致性的侷限性
如果你有n個副本並且你選擇w和r使得$w + r> n$,你通常可以期望每個鍵的讀取都能返回最近寫入的值。情況就是這樣,因為你寫入的節點集合和你讀取的節點集合必須重疊。也就是說,您讀取的節點中必須至少有一個具有最新值的節點(如[圖5-11](../img/fig5-11.png)所示)。 如果你有n個副本並且你選擇w和r使得$w + r> n$,你通常可以期望每個鍵的讀取都能返回最近寫入的值。情況就是這樣,因為你寫入的節點集合和你讀取的節點集合必須重疊。也就是說,您讀取的節點中必須至少有一個具有最新值的節點(如[圖5-11](../img/fig5-11.png)所示)。
通常r和w被選為多數超過 $n/2$ )節點,因為這確保了$w + r> n$,同時仍然容忍多達$n/2$個節點故障。但是法定人數不一定必須是大多數只是讀寫使用的節點交集至少需要包括一個節點。其他法定人數的配置是可能的這使得分散式演算法的設計有一定的靈活性【45】。 通常r和w被選為多數超過 $n/2$ )節點,因為這確保了$w + r> n$,同時仍然容忍多達$n/2$個節點故障。但是法定人數不一定必須是大多數只是讀寫使用的節點交集至少需要包括一個節點。其他法定人數的配置是可能的這使得分散式演算法的設計有一定的靈活性【45】。
您也可以將w和r設定為較小的數字以使$w + r≤n$即法定條件不滿足。在這種情況下讀取和寫入操作仍將被髮送到n個節點但操作成功只需要少量的成功響應。 您也可以將w和r設定為較小的數字以使$w + r≤n$即法定條件不滿足。在這種情況下讀取和寫入操作仍將被髮送到n個節點但操作成功只需要少量的成功響應。
較小的w和r更有可能會讀取過時的資料因為您的讀取更有可能不包含具有最新值的節點。另一方面這種配置允許更低的延遲和更高的可用性如果存在網路中斷並且許多副本變得無法訪問則可以繼續處理讀取和寫入的機會更大。只有當可達副本的數量低於w或r時資料庫才分別變得不可用於寫入或讀取。 較小的w和r更有可能會讀取過時的資料因為您的讀取更有可能不包含具有最新值的節點。另一方面這種配置允許更低的延遲和更高的可用性如果存在網路中斷並且許多副本變得無法訪問則可以繼續處理讀取和寫入的機會更大。只有當可達副本的數量低於w或r時資料庫才分別變得不可用於寫入或讀取。
但是,即使在$w + r> n$的情況下,也可能存在返回陳舊值的邊緣情況。這取決於實現,但可能的情況包括: 但是,即使在$w + r> n$的情況下,也可能存在返回陳舊值的邊緣情況。這取決於實現,但可能的情況包括:
@ -563,52 +563,52 @@
因此,儘管法定人數似乎保證讀取返回最新的寫入值,但在實踐中並不那麼簡單。 Dynamo風格的資料庫通常針對可以忍受最終一致性的用例進行最佳化。允許透過引數w和r來調整讀取陳舊值的概率但把它們當成絕對的保證是不明智的。 因此,儘管法定人數似乎保證讀取返回最新的寫入值,但在實踐中並不那麼簡單。 Dynamo風格的資料庫通常針對可以忍受最終一致性的用例進行最佳化。允許透過引數w和r來調整讀取陳舊值的概率但把它們當成絕對的保證是不明智的。
尤其是,因為通常沒有得到“[複製延遲問題](#複製延遲問題)”中討論的保證(讀己之寫,單調讀,一致字首讀),前面提到的異常可能會發生在應用程式中。更強有力的保證通常需要**事務**或**共識**。我們將在[第七章](ch7.md)和[第九章](ch9.md)回到這些話題。 尤其是,因為通常沒有得到“[複製延遲問題](#複製延遲問題)”中討論的保證(讀己之寫,單調讀,一致字首讀),前面提到的異常可能會發生在應用程式中。更強有力的保證通常需要**事務**或**共識**。我們將在[第七章](ch7.md)和[第九章](ch9.md)回到這些話題。
#### 監控陳舊度 #### 監控陳舊度
從運維的角度來看,監視你的資料庫是否返回最新的結果是很重要的。即使應用可以容忍陳舊的讀取,您也需要了解複製的健康狀況。如果顯著落後,應該提醒您,以便您可以調查原因(例如,網路中的問題或超載節點)。 從運維的角度來看,監視你的資料庫是否返回最新的結果是很重要的。即使應用可以容忍陳舊的讀取,您也需要了解複製的健康狀況。如果顯著落後,應該提醒您,以便您可以調查原因(例如,網路中的問題或超載節點)。
對於基於領導者的複製,資料庫通常會公開復制滯後的度量標準,您可以將其提供給監視系統。這是可能的,因為寫入按照相同的順序應用於領導者和追隨者,並且每個節點在複製日誌中具有一個位置(在本地應用的寫入數量)。透過從領導者的當前位置中減去追隨者的當前位置,您可以測量複製滯後量。 對於基於領導者的複製,資料庫通常會公開復制滯後的度量標準,您可以將其提供給監視系統。這是可能的,因為寫入按照相同的順序應用於領導者和追隨者,並且每個節點在複製日誌中具有一個位置(在本地應用的寫入數量)。透過從領導者的當前位置中減去追隨者的當前位置,您可以測量複製滯後量。
然而,在無領導者複製的系統中,沒有固定的寫入順序,這使得監控變得更加困難。而且,如果資料庫只使用讀修復(沒有反熵過程),那麼對於一個值可能會有多大的限制是沒有限制的 - 如果一個值很少被讀取,那麼由一個陳舊副本返回的值可能是古老的。 然而,在無領導者複製的系統中,沒有固定的寫入順序,這使得監控變得更加困難。而且,如果資料庫只使用讀修復(沒有反熵過程),那麼對於一個值可能會有多大的限制是沒有限制的 - 如果一個值很少被讀取,那麼由一個陳舊副本返回的值可能是古老的。
已經有一些關於衡量無主複製資料庫中的複製陳舊度的研究並根據引數nw和r來預測陳舊讀取的預期百分比【48】。不幸的是這還不是很常見的做法但是將陳舊測量值包含在資料庫的度量標準集中是一件好事。雖然最終一致性是一種有意模糊的保證但是從可操作性角度來說能夠量化“最終”也是很重要的。 已經有一些關於衡量無主複製資料庫中的複製陳舊度的研究並根據引數nw和r來預測陳舊讀取的預期百分比【48】。不幸的是這還不是很常見的做法但是將陳舊測量值包含在資料庫的度量標準集中是一件好事。雖然最終一致性是一種有意模糊的保證但是從可操作性角度來說能夠量化“最終”也是很重要的。
### 寬鬆的法定人數與提示移交 ### 寬鬆的法定人數與提示移交
合理配置的法定人數可以使資料庫無需故障切換即可容忍個別節點的故障。也可以容忍個別節點變慢因為請求不必等待所有n個節點響應——當w或r節點響應時它們可以返回。對於需要高可用、低延時、且能夠容忍偶爾讀到陳舊值的應用場景來說這些特性使無主複製的資料庫很有吸引力。 合理配置的法定人數可以使資料庫無需故障切換即可容忍個別節點的故障。也可以容忍個別節點變慢因為請求不必等待所有n個節點響應——當w或r節點響應時它們可以返回。對於需要高可用、低延時、且能夠容忍偶爾讀到陳舊值的應用場景來說這些特性使無主複製的資料庫很有吸引力。
然而法定人數如迄今為止所描述的並不像它們可能的那樣具有容錯性。網路中斷可以很容易地將客戶端從大量的資料庫節點上切斷。雖然這些節點是活著的而其他客戶端可能能夠連線到它們但是從資料庫節點切斷的客戶端來看它們也可能已經死亡。在這種情況下剩餘的可用節點可能會少於w或r因此客戶端不再能達到法定人數。 然而法定人數如迄今為止所描述的並不像它們可能的那樣具有容錯性。網路中斷可以很容易地將客戶端從大量的資料庫節點上切斷。雖然這些節點是活著的而其他客戶端可能能夠連線到它們但是從資料庫節點切斷的客戶端來看它們也可能已經死亡。在這種情況下剩餘的可用節點可能會少於w或r因此客戶端不再能達到法定人數。
在一個大型的叢集中節點數量明顯多於n個網路中斷期間客戶端可能仍能連線到一些資料庫節點但又不足以組成一個特定值的法定人數。在這種情況下資料庫設計人員需要權衡一下 在一個大型的叢集中節點數量明顯多於n個網路中斷期間客戶端可能仍能連線到一些資料庫節點但又不足以組成一個特定值的法定人數。在這種情況下資料庫設計人員需要權衡一下
* 對於所有無法達到w或r節點法定人數的請求是否返回錯誤是更好的 * 對於所有無法達到w或r節點法定人數的請求是否返回錯誤是更好的
* 或者我們是否應該接受寫入然後將它們寫入一些可達的節點但不在這些值通常所存在的n個節點上 * 或者我們是否應該接受寫入然後將它們寫入一些可達的節點但不在這些值通常所存在的n個節點上
後者被認為是一個**寬鬆的法定人數sloppy quorum**【37】寫和讀仍然需要w和r成功的響應但這些響應可能來自不在指定的n個“主”節點中的其它節點。比方說如果你把自己鎖在房子外面你可能會敲開鄰居的門問你是否可以暫時呆在沙發上。 後者被認為是一個**寬鬆的法定人數sloppy quorum**【37】寫和讀仍然需要w和r成功的響應但這些響應可能來自不在指定的n個“主”節點中的其它節點。比方說如果你把自己鎖在房子外面你可能會敲開鄰居的門問你是否可以暫時呆在沙發上。
一旦網路中斷得到解決,代表另一個節點臨時接受的一個節點的任何寫入都被髮送到適當的“主”節點。這就是所謂的**提示移交hinted handoff**。 (一旦你再次找到你的房子的鑰匙,你的鄰居禮貌地要求你離開沙發回家。) 一旦網路中斷得到解決,代表另一個節點臨時接受的一個節點的任何寫入都被髮送到適當的“主”節點。這就是所謂的**提示移交hinted handoff**。 (一旦你再次找到你的房子的鑰匙,你的鄰居禮貌地要求你離開沙發回家。)
寬鬆的法定人數對寫入可用性的提高特別有用只要有任何w節點可用資料庫就可以接受寫入。然而這意味著即使當$w + r> n$時也不能確定讀取某個鍵的最新值因為最新的值可能已經臨時寫入了n之外的某些節點【47】。 寬鬆的法定人數對寫入可用性的提高特別有用只要有任何w節點可用資料庫就可以接受寫入。然而這意味著即使當$w + r> n$時也不能確定讀取某個鍵的最新值因為最新的值可能已經臨時寫入了n之外的某些節點【47】。
因此在傳統意義上一個寬鬆的法定人數實際上不是一個法定人數。這只是一個保證即資料儲存在w節點的地方。但不能保證r節點的讀取直到提示移交已經完成。 因此在傳統意義上一個寬鬆的法定人數實際上不是一個法定人數。這只是一個保證即資料儲存在w節點的地方。但不能保證r節點的讀取直到提示移交已經完成。
在所有常見的Dynamo實現中寬鬆的法定人數是可選的。在Riak中它們預設是啟用的而在Cassandra和Voldemort中它們預設是禁用的【46,49,50】。 在所有常見的Dynamo實現中寬鬆的法定人數是可選的。在Riak中它們預設是啟用的而在Cassandra和Voldemort中它們預設是禁用的【46,49,50】。
#### 運維多個數據中心 #### 運維多個數據中心
我們先前討論了跨資料中心複製作為多主複製的用例(請參閱“[多主複製](#多主複製)”)。無主複製也適用於多資料中心操作,因為它旨在容忍衝突的併發寫入,網路中斷和延遲尖峰。 我們先前討論了跨資料中心複製作為多主複製的用例(請參閱“[多主複製](#多主複製)”)。無主複製也適用於多資料中心操作,因為它旨在容忍衝突的併發寫入,網路中斷和延遲尖峰。
Cassandra和Voldemort在正常的無主模型中實現了他們的多資料中心支援副本的數量n包括所有資料中心的節點在配置中您可以指定每個資料中心中您想擁有的副本的數量。無論資料中心如何每個來自客戶端的寫入都會發送到所有副本但客戶端通常只等待來自其本地資料中心內的法定節點的確認從而不會受到跨資料中心鏈路延遲和中斷的影響。對其他資料中心的高延遲寫入通常被配置為非同步發生儘管配置有一定的靈活性【50,51】。 Cassandra和Voldemort在正常的無主模型中實現了他們的多資料中心支援副本的數量n包括所有資料中心的節點在配置中您可以指定每個資料中心中您想擁有的副本的數量。無論資料中心如何每個來自客戶端的寫入都會發送到所有副本但客戶端通常只等待來自其本地資料中心內的法定節點的確認從而不會受到跨資料中心鏈路延遲和中斷的影響。對其他資料中心的高延遲寫入通常被配置為非同步發生儘管配置有一定的靈活性【50,51】。
Riak將客戶端和資料庫節點之間的所有通訊保持在一個數據中心本地因此n描述了一個數據中心內的副本數量。資料庫叢集之間的跨資料中心複製在後臺非同步發生其風格類似於多領導者複製【52】。 Riak將客戶端和資料庫節點之間的所有通訊保持在一個數據中心本地因此n描述了一個數據中心內的副本數量。資料庫叢集之間的跨資料中心複製在後臺非同步發生其風格類似於多領導者複製【52】。
### 檢測併發寫入 ### 檢測併發寫入
Dynamo風格的資料庫允許多個客戶端同時寫入相同的Key這意味著即使使用嚴格的法定人數也會發生衝突。這種情況與多領導者複製相似請參閱“[處理寫入衝突](#處理寫入衝突)”但在Dynamo樣式的資料庫中在**讀修復**或**提示移交**期間也可能會產生衝突。 Dynamo風格的資料庫允許多個客戶端同時寫入相同的Key這意味著即使使用嚴格的法定人數也會發生衝突。這種情況與多領導者複製相似請參閱“[處理寫入衝突](#處理寫入衝突)”但在Dynamo樣式的資料庫中在**讀修復**或**提示移交**期間也可能會產生衝突。
問題在於,由於可變的網路延遲和部分故障,事件可能在不同的節點以不同的順序到達。例如,[圖5-12](../img/fig5-12.png)顯示了兩個客戶機A和B同時寫入三節點資料儲存區中的鍵X 問題在於,由於可變的網路延遲和部分故障,事件可能在不同的節點以不同的順序到達。例如,[圖5-12](../img/fig5-12.png)顯示了兩個客戶機A和B同時寫入三節點資料儲存區中的鍵X
* 節點 1 接收來自 A 的寫入,但由於暫時中斷,未接收到來自 B 的寫入。 * 節點 1 接收來自 A 的寫入,但由於暫時中斷,未接收到來自 B 的寫入。
* 節點 2 首先接收來自 A 的寫入,然後接收來自 B 的寫入。 * 節點 2 首先接收來自 A 的寫入,然後接收來自 B 的寫入。
@ -618,25 +618,25 @@
**圖5-12 併發寫入Dynamo風格的資料儲存沒有明確定義的順序。** **圖5-12 併發寫入Dynamo風格的資料儲存沒有明確定義的順序。**
如果每個節點只要接收到來自客戶端的寫入請求就簡單地覆蓋了某個鍵的值,那麼節點就會永久地不一致,如[圖5-12](../img/fig5-12.png)中的最終獲取請求所示節點2認為 X 的最終值是 B而其他節點認為值是 A 。 如果每個節點只要接收到來自客戶端的寫入請求就簡單地覆蓋了某個鍵的值,那麼節點就會永久地不一致,如[圖5-12](../img/fig5-12.png)中的最終獲取請求所示節點2認為 X 的最終值是 B而其他節點認為值是 A 。
為了最終達成一致,副本應該趨於相同的值。如何做到這一點?有人可能希望複製的資料庫能夠自動處理,但不幸的是,大多數的實現都很糟糕:如果你想避免丟失資料,你(應用程式開發人員)需要知道很多有關資料庫衝突處理的內部資訊。 為了最終達成一致,副本應該趨於相同的值。如何做到這一點?有人可能希望複製的資料庫能夠自動處理,但不幸的是,大多數的實現都很糟糕:如果你想避免丟失資料,你(應用程式開發人員)需要知道很多有關資料庫衝突處理的內部資訊。
在“[處理寫入衝突](#處理寫入衝突)”一節中已經簡要介紹了一些解決衝突的技術。在總結本章之前,讓我們來更詳細地探討這個問題。 在“[處理寫入衝突](#處理寫入衝突)”一節中已經簡要介紹了一些解決衝突的技術。在總結本章之前,讓我們來更詳細地探討這個問題。
#### 最後寫入勝利(丟棄併發寫入) #### 最後寫入勝利(丟棄併發寫入)
實現最終融合的一種方法是宣告每個副本只需要儲存最 **“最近”** 的值,並允許 **“更舊”** 的值被覆蓋和拋棄。然後,只要我們有一種明確的方式來確定哪個寫是“最近的”,並且每個寫入最終都被複制到每個副本,那麼複製最終會收斂到相同的值。 實現最終融合的一種方法是宣告每個副本只需要儲存最 **“最近”** 的值,並允許 **“更舊”** 的值被覆蓋和拋棄。然後,只要我們有一種明確的方式來確定哪個寫是“最近的”,並且每個寫入最終都被複制到每個副本,那麼複製最終會收斂到相同的值。
正如 **“最近”** 的引號所表明的,這個想法其實頗具誤導性。在[圖5-12](../img/fig5-12.png)的例子中,當客戶端向資料庫節點發送寫入請求時,客戶端都不知道另一個客戶端,因此不清楚哪一個先發生了。事實上,說“發生”是沒有意義的:我們說寫入是 **併發concurrent** 的,所以它們的順序是不確定的。 正如 **“最近”** 的引號所表明的,這個想法其實頗具誤導性。在[圖5-12](../img/fig5-12.png)的例子中,當客戶端向資料庫節點發送寫入請求時,客戶端都不知道另一個客戶端,因此不清楚哪一個先發生了。事實上,說“發生”是沒有意義的:我們說寫入是 **併發concurrent** 的,所以它們的順序是不確定的。
即使寫入沒有自然的排序,我們也可以強制任意排序。例如,可以為每個寫入附加一個時間戳,挑選最 **“最近”** 的最大時間戳,並丟棄具有較早時間戳的任何寫入。這種衝突解決演算法被稱為 **最後寫入勝利LWW, last write wins**是Cassandra 【53】唯一支援的衝突解決方法也是Riak 【35】中的一個可選特徵。 即使寫入沒有自然的排序,我們也可以強制任意排序。例如,可以為每個寫入附加一個時間戳,挑選最 **“最近”** 的最大時間戳,並丟棄具有較早時間戳的任何寫入。這種衝突解決演算法被稱為 **最後寫入勝利LWW, last write wins**是Cassandra 【53】唯一支援的衝突解決方法也是Riak 【35】中的一個可選特徵。
LWW實現了最終收斂的目標但以**永續性**為代價如果同一個Key有多個併發寫入即使它們報告給客戶端的都是成功因為它們被寫入 w 個副本也只有一個寫入將存活而其他寫入將被靜默丟棄。此外LWW甚至可能會刪除不是併發的寫入我們將在的“[有序事件的時間戳](ch8.md#有序事件的時間戳)”中討論。 LWW實現了最終收斂的目標但以**永續性**為代價如果同一個Key有多個併發寫入即使它們報告給客戶端的都是成功因為它們被寫入 w 個副本也只有一個寫入將存活而其他寫入將被靜默丟棄。此外LWW甚至可能會刪除不是併發的寫入我們將在的“[有序事件的時間戳](ch8.md#有序事件的時間戳)”中討論。
有一些情況如快取其中丟失的寫入可能是可以接受的。如果丟失資料不可接受LWW是解決衝突的一個很爛的選擇。 有一些情況如快取其中丟失的寫入可能是可以接受的。如果丟失資料不可接受LWW是解決衝突的一個很爛的選擇。
與LWW一起使用資料庫的唯一安全方法是確保一個鍵只寫入一次然後視為不可變從而避免對同一個鍵進行併發更新。例如Cassandra推薦使用的方法是使用UUID作為鍵從而為每個寫操作提供一個唯一的鍵【53】。 與LWW一起使用資料庫的唯一安全方法是確保一個鍵只寫入一次然後視為不可變從而避免對同一個鍵進行併發更新。例如Cassandra推薦使用的方法是使用UUID作為鍵從而為每個寫操作提供一個唯一的鍵【53】。
#### “此前發生”的關係和併發 #### “此前發生”的關係和併發
@ -647,7 +647,7 @@
如果操作B瞭解操作A或者依賴於A或者以某種方式構建於操作A之上則操作A在另一個操作B之前發生。在另一個操作之前是否發生一個操作是定義什麼併發的關鍵。事實上我們可以簡單地說如果兩個操作都不在另一個之前發生那麼兩個操作是併發的兩個操作都不知道另一個【54】。 如果操作B瞭解操作A或者依賴於A或者以某種方式構建於操作A之上則操作A在另一個操作B之前發生。在另一個操作之前是否發生一個操作是定義什麼併發的關鍵。事實上我們可以簡單地說如果兩個操作都不在另一個之前發生那麼兩個操作是併發的兩個操作都不知道另一個【54】。
因此只要有兩個操作A和B就有三種可能性A在B之前發生或者B在A之前發生或者A和B併發。我們需要的是一個演算法來告訴我們兩個操作是否是併發的。如果一個操作發生在另一個操作之前則後面的操作應該覆蓋較早的操作但是如果這些操作是併發的則存在需要解決的衝突。 因此只要有兩個操作A和B就有三種可能性A在B之前發生或者B在A之前發生或者A和B併發。我們需要的是一個演算法來告訴我們兩個操作是否是併發的。如果一個操作發生在另一個操作之前則後面的操作應該覆蓋較早的操作但是如果這些操作是併發的則存在需要解決的衝突。
@ -663,7 +663,7 @@
#### 捕獲"此前發生"關係 #### 捕獲"此前發生"關係
來看一個演算法,它確定兩個操作是否為併發的,還是一個在另一個之前。為了簡單起見,我們從一個只有一個副本的資料庫開始。一旦我們已經制定了如何在單個副本上完成這項工作,我們可以將該方法推廣到具有多個副本的無領導者資料庫。 來看一個演算法,它確定兩個操作是否為併發的,還是一個在另一個之前。為了簡單起見,我們從一個只有一個副本的資料庫開始。一旦我們已經制定了如何在單個副本上完成這項工作,我們可以將該方法推廣到具有多個副本的無領導者資料庫。
[圖5-13](../img/fig5-13.png)顯示了兩個客戶端同時向同一購物車新增專案。 (如果這樣的例子讓你覺得太麻煩了,那麼可以想象,兩個空中交通管制員同時把飛機新增到他們正在跟蹤的區域)最初,購物車是空的。在它們之間,客戶端向資料庫發出五次寫入: [圖5-13](../img/fig5-13.png)顯示了兩個客戶端同時向同一購物車新增專案。 (如果這樣的例子讓你覺得太麻煩了,那麼可以想象,兩個空中交通管制員同時把飛機新增到他們正在跟蹤的區域)最初,購物車是空的。在它們之間,客戶端向資料庫發出五次寫入:
@ -677,13 +677,13 @@
**圖5-13 捕獲兩個客戶端之間的因果關係,同時編輯購物車。** **圖5-13 捕獲兩個客戶端之間的因果關係,同時編輯購物車。**
[圖5-13](../img/fig5-13.png)中的操作之間的資料流如[圖5-14](../img/fig5-14.png)所示。 箭頭表示哪個操作發生在其他操作之前,意味著後面的操作知道或依賴於較早的操作。 在這個例子中,客戶端永遠不會完全掌握伺服器上的資料,因為總是有另一個操作同時進行。 但是,舊版本的值最終會被覆蓋,並且不會丟失任何寫入。 [圖5-13](../img/fig5-13.png)中的操作之間的資料流如[圖5-14](../img/fig5-14.png)所示。 箭頭表示哪個操作發生在其他操作之前,意味著後面的操作知道或依賴於較早的操作。 在這個例子中,客戶端永遠不會完全掌握伺服器上的資料,因為總是有另一個操作同時進行。 但是,舊版本的值最終會被覆蓋,並且不會丟失任何寫入。
![](../img/fig5-14.png) ![](../img/fig5-14.png)
**圖5-14 圖5-13中的因果依賴關係圖。** **圖5-14 圖5-13中的因果依賴關係圖。**
請注意,伺服器可以透過檢視版本號來確定兩個操作是否是併發的——它不需要解釋該值本身(因此該值可以是任何資料結構)。該演算法的工作原理如下: 請注意,伺服器可以透過檢視版本號來確定兩個操作是否是併發的——它不需要解釋該值本身(因此該值可以是任何資料結構)。該演算法的工作原理如下:
* 伺服器為每個鍵保留一個版本號,每次寫入鍵時都增加版本號,並將新版本號與寫入的值一起儲存。 * 伺服器為每個鍵保留一個版本號,每次寫入鍵時都增加版本號,並將新版本號與寫入的值一起儲存。
* 當客戶端讀取鍵時,伺服器將返回所有未覆蓋的值以及最新的版本號。客戶端在寫入前必須讀取。 * 當客戶端讀取鍵時,伺服器將返回所有未覆蓋的值以及最新的版本號。客戶端在寫入前必須讀取。
@ -694,27 +694,27 @@
#### 合併同時寫入的值 #### 合併同時寫入的值
這種演算法可以確保沒有資料被無聲地丟棄,但不幸的是,客戶端需要做一些額外的工作:如果多個操作併發發生,則客戶端必須透過合併併發寫入的值來擦屁股。 Riak稱這些併發值**兄弟siblings**。 這種演算法可以確保沒有資料被無聲地丟棄,但不幸的是,客戶端需要做一些額外的工作:如果多個操作併發發生,則客戶端必須透過合併併發寫入的值來擦屁股。 Riak稱這些併發值**兄弟siblings**。
合併兄弟值,本質上是與多領導者複製中的衝突解決相同的問題,我們先前討論過(請參閱“[處理寫入衝突](#處理寫入衝突)”)。一個簡單的方法是根據版本號或時間戳(最後寫入勝利)選擇一個值,但這意味著丟失資料。所以,你可能需要在應用程式程式碼中做更聰明的事情。 合併兄弟值,本質上是與多領導者複製中的衝突解決相同的問題,我們先前討論過(請參閱“[處理寫入衝突](#處理寫入衝突)”)。一個簡單的方法是根據版本號或時間戳(最後寫入勝利)選擇一個值,但這意味著丟失資料。所以,你可能需要在應用程式程式碼中做更聰明的事情。
以購物車為例,一種合理的合併兄弟方法就是集合求並集。在[圖5-14](../img/fig5-14.png)中,最後的兩個兄弟是[牛奶,麵粉,雞蛋,燻肉]和[雞蛋,牛奶,火腿]。注意牛奶和雞蛋同時出現在兩個兄弟裡,即使他們每個只被寫過一次。合併的值可以是[牛奶,麵粉,雞蛋,培根,火腿],沒有重複。 以購物車為例,一種合理的合併兄弟方法就是集合求並集。在[圖5-14](../img/fig5-14.png)中,最後的兩個兄弟是[牛奶,麵粉,雞蛋,燻肉]和[雞蛋,牛奶,火腿]。注意牛奶和雞蛋同時出現在兩個兄弟裡,即使他們每個只被寫過一次。合併的值可以是[牛奶,麵粉,雞蛋,培根,火腿],沒有重複。
然而,如果你想讓人們也可以從他們的購物車中**刪除**東西而不是僅僅新增東西那麼把兄弟求並集可能不會產生正確的結果如果你合併了兩個兄弟購物車並且只在其中一個兄弟值裡刪掉了它那麼被刪除的專案會重新出現在並集終值中【37】。為了防止這個問題一個專案在刪除時不能簡單地從資料庫中刪除相反系統必須留下一個具有合適版本號的標記以指示合併兄弟時該專案已被刪除。這種刪除標記被稱為**墓碑tombstone**。 (我們之前在“[雜湊索引”](ch3.md#雜湊索引)中的日誌壓縮的上下文中看到了墓碑。) 然而,如果你想讓人們也可以從他們的購物車中**刪除**東西而不是僅僅新增東西那麼把兄弟求並集可能不會產生正確的結果如果你合併了兩個兄弟購物車並且只在其中一個兄弟值裡刪掉了它那麼被刪除的專案會重新出現在並集終值中【37】。為了防止這個問題一個專案在刪除時不能簡單地從資料庫中刪除相反系統必須留下一個具有合適版本號的標記以指示合併兄弟時該專案已被刪除。這種刪除標記被稱為**墓碑tombstone**。 (我們之前在“[雜湊索引”](ch3.md#雜湊索引)中的日誌壓縮的上下文中看到了墓碑。)
因為在應用程式程式碼中合併兄弟是複雜且容易出錯的,所以有一些資料結構被設計出來用於自動執行這種合併,如“[自動衝突解決](#自動衝突解決)”中討論的。例如Riak的資料型別支援使用稱為CRDT的資料結構家族【38,39,55】可以以合理的方式自動合併兄弟包括保留刪除。 因為在應用程式程式碼中合併兄弟是複雜且容易出錯的,所以有一些資料結構被設計出來用於自動執行這種合併,如“[自動衝突解決](#自動衝突解決)”中討論的。例如Riak的資料型別支援使用稱為CRDT的資料結構家族【38,39,55】可以以合理的方式自動合併兄弟包括保留刪除。
#### 版本向量 #### 版本向量
[圖5-13](../img/fig5-13.png)中的示例只使用一個副本。當有多個副本但沒有領導者時,演算法如何修改? [圖5-13](../img/fig5-13.png)中的示例只使用一個副本。當有多個副本但沒有領導者時,演算法如何修改?
[圖5-13](../img/fig5-13.png)使用單個版本號來捕獲操作之間的依賴關係,但是當多個副本併發接受寫入時,這是不夠的。相反,除了對每個鍵使用版本號之外,還需要在**每個副本**中使用版本號。每個副本在處理寫入時增加自己的版本號,並且跟蹤從其他副本中看到的版本號。這個資訊指出了要覆蓋哪些值,以及保留哪些值作為兄弟。 [圖5-13](../img/fig5-13.png)使用單個版本號來捕獲操作之間的依賴關係,但是當多個副本併發接受寫入時,這是不夠的。相反,除了對每個鍵使用版本號之外,還需要在**每個副本**中使用版本號。每個副本在處理寫入時增加自己的版本號,並且跟蹤從其他副本中看到的版本號。這個資訊指出了要覆蓋哪些值,以及保留哪些值作為兄弟。
所有副本的版本號集合稱為**版本向量version vector**【56】。這個想法的一些變體正在被使用但最有趣的可能是在Riak 2.0 【58,59】中使用的**分散版本向量dotted version vector**【57】。我們不會深入細節但是它的工作方式與我們在購物車示例中看到的非常相似。 所有副本的版本號集合稱為**版本向量version vector**【56】。這個想法的一些變體正在被使用但最有趣的可能是在Riak 2.0 【58,59】中使用的**分散版本向量dotted version vector**【57】。我們不會深入細節但是它的工作方式與我們在購物車示例中看到的非常相似。
與[圖5-13](../img/fig5-13.png)中的版本號一樣當讀取值時版本向量會從資料庫副本傳送到客戶端並且隨後寫入值時需要將其傳送回資料庫。Riak將版本向量編碼為一個字串它稱為**因果上下文causal context**)。版本向量允許資料庫區分覆蓋寫入和併發寫入。 與[圖5-13](../img/fig5-13.png)中的版本號一樣當讀取值時版本向量會從資料庫副本傳送到客戶端並且隨後寫入值時需要將其傳送回資料庫。Riak將版本向量編碼為一個字串它稱為**因果上下文causal context**)。版本向量允許資料庫區分覆蓋寫入和併發寫入。
另外,就像在單個副本的例子中,應用程式可能需要合併兄弟。版本向量結構確保從一個副本讀取並隨後寫回到另一個副本是安全的。這樣做可能會建立兄弟,但只要兄弟姐妹合併正確,就不會丟失資料。 另外,就像在單個副本的例子中,應用程式可能需要合併兄弟。版本向量結構確保從一個副本讀取並隨後寫回到另一個副本是安全的。這樣做可能會建立兄弟,但只要兄弟姐妹合併正確,就不會丟失資料。
> #### 版本向量和向量時鐘 > #### 版本向量和向量時鐘
> >
@ -727,47 +727,47 @@
***高可用性*** ***高可用性***
即使在一臺機器(或多臺機器,或整個資料中心)停機的情況下也能保持系統正常執行 即使在一臺機器(或多臺機器,或整個資料中心)停機的情況下也能保持系統正常執行
***斷開連線的操作*** ***斷開連線的操作***
允許應用程式在網路中斷時繼續工作 允許應用程式在網路中斷時繼續工作
***延遲*** ***延遲***
將資料放置在距離使用者較近的地方,以便使用者能夠更快地與其互動 將資料放置在距離使用者較近的地方,以便使用者能夠更快地與其互動
***可伸縮性*** ***可伸縮性***
透過在副本上讀,能夠處理比單機更大的讀取量 透過在副本上讀,能夠處理比單機更大的讀取量
儘管是一個簡單的目標 - 在幾臺機器上保留相同資料的副本,但複製卻是一個非常棘手的問題。它需要仔細考慮併發和所有可能出錯的事情,並處理這些故障的後果。至少,我們需要處理不可用的節點和網路中斷(這還不包括更隱蔽的故障,例如由於軟體錯誤導致的靜默資料損壞)。 儘管是一個簡單的目標 - 在幾臺機器上保留相同資料的副本,但複製卻是一個非常棘手的問題。它需要仔細考慮併發和所有可能出錯的事情,並處理這些故障的後果。至少,我們需要處理不可用的節點和網路中斷(這還不包括更隱蔽的故障,例如由於軟體錯誤導致的靜默資料損壞)。
我們討論了複製的三種主要方法: 我們討論了複製的三種主要方法:
***單主複製*** ***單主複製***
客戶端將所有寫入操作傳送到單個節點(領導者),該節點將資料更改事件流傳送到其他副本(追隨者)。讀取可以在任何副本上執行,但從追隨者讀取可能是陳舊的。 客戶端將所有寫入操作傳送到單個節點(領導者),該節點將資料更改事件流傳送到其他副本(追隨者)。讀取可以在任何副本上執行,但從追隨者讀取可能是陳舊的。
***多主複製*** ***多主複製***
客戶端傳送每個寫入到幾個領導節點之一,其中任何一個都可以接受寫入。領導者將資料更改事件流傳送給彼此以及任何跟隨者節點。 客戶端傳送每個寫入到幾個領導節點之一,其中任何一個都可以接受寫入。領導者將資料更改事件流傳送給彼此以及任何跟隨者節點。
***無主複製*** ***無主複製***
客戶端傳送每個寫入到幾個節點,並從多個節點並行讀取,以檢測和糾正具有陳舊資料的節點。 客戶端傳送每個寫入到幾個節點,並從多個節點並行讀取,以檢測和糾正具有陳舊資料的節點。
每種方法都有優點和缺點。單主複製是非常流行的,因為它很容易理解,不需要擔心衝突解決。在出現故障節點,網路中斷和延遲峰值的情況下,多領導者和無領導者複製可以更加穩健,但以更難以推理並僅提供非常弱的一致性保證為代價。 每種方法都有優點和缺點。單主複製是非常流行的,因為它很容易理解,不需要擔心衝突解決。在出現故障節點,網路中斷和延遲峰值的情況下,多領導者和無領導者複製可以更加穩健,但以更難以推理並僅提供非常弱的一致性保證為代價。
複製可以是同步的,也可以是非同步的,這在發生故障時對系統行為有深遠的影響。儘管在系統執行平穩時非同步複製速度很快,但是要弄清楚在複製滯後增加和伺服器故障時會發生什麼,這一點很重要。如果一個領導者失敗了,並且你提升了一個非同步更新的追隨者成為新的領導者,那麼最近提交的資料可能會丟失。 複製可以是同步的,也可以是非同步的,這在發生故障時對系統行為有深遠的影響。儘管在系統執行平穩時非同步複製速度很快,但是要弄清楚在複製滯後增加和伺服器故障時會發生什麼,這一點很重要。如果一個領導者失敗了,並且你提升了一個非同步更新的追隨者成為新的領導者,那麼最近提交的資料可能會丟失。
我們研究了一些可能由複製滯後引起的奇怪效應,我們也討論了一些有助於決定應用程式在複製滯後時的行為的一致性模型: 我們研究了一些可能由複製滯後引起的奇怪效應,我們也討論了一些有助於決定應用程式在複製滯後時的行為的一致性模型:
***寫後讀*** ***寫後讀***
使用者應該總是看到自己提交的資料。 使用者應該總是看到自己提交的資料。
***單調讀*** ***單調讀***
@ -775,13 +775,13 @@
***一致字首讀*** ***一致字首讀***
使用者應該看到資料處於一種具有因果意義的狀態:例如,按正確的順序看到一個問題和對應的回答。 使用者應該看到資料處於一種具有因果意義的狀態:例如,按正確的順序看到一個問題和對應的回答。
最後,我們討論了多領導者和無領導者複製方法所固有的併發問題:因為他們允許多個寫入併發發生,這可能會導致衝突。我們研究了一個數據庫可能使用的演算法來確定一個操作是否發生在另一個操作之前,或者它們是否同時發生。我們還談到了透過合併併發更新來解決衝突的方法。 最後,我們討論了多領導者和無領導者複製方法所固有的併發問題:因為他們允許多個寫入併發發生,這可能會導致衝突。我們研究了一個數據庫可能使用的演算法來確定一個操作是否發生在另一個操作之前,或者它們是否同時發生。我們還談到了透過合併併發更新來解決衝突的方法。
在下一章中,我們將繼續研究分佈在多個機器上的資料,透過複製的同僚:將大資料集分割成分割槽。 在下一章中,我們將繼續研究分佈在多個機器上的資料,透過複製的同僚:將大資料集分割成分割槽。

View File

@ -11,7 +11,7 @@
[TOC] [TOC]
在[第五章](ch5.md)中,我們討論了複製——即資料在不同節點上的副本,對於非常大的資料集,或非常高的吞吐量,僅僅進行復制是不夠的:我們需要將資料進行**分割槽partitions**,也稱為**分片sharding**[^i]。 在[第五章](ch5.md)中,我們討論了複製——即資料在不同節點上的副本,對於非常大的資料集,或非常高的吞吐量,僅僅進行復制是不夠的:我們需要將資料進行**分割槽partitions**,也稱為**分片sharding**[^i]。
[^i]: 正如本章所討論的,分割槽是一種有意將大型資料庫分解成小型資料庫的方式。它與 **網路分割槽network partitions, netsplits** 無關,這是節點之間網路故障的一種。我們將在[第八章](ch8.md)討論這些錯誤。 [^i]: 正如本章所討論的,分割槽是一種有意將大型資料庫分解成小型資料庫的方式。它與 **網路分割槽network partitions, netsplits** 無關,這是節點之間網路故障的一種。我們將在[第八章](ch8.md)討論這些錯誤。
@ -20,21 +20,21 @@
> 上文中的**分割槽(partition)**在MongoDBElasticsearch和Solr Cloud中被稱為**分片(shard)**在HBase中稱之為**區域(Region)**Bigtable中則是 **表塊tablet**Cassandra和Riak中是**虛節點vnode)**Couchbase中叫做**虛桶(vBucket)**。但是**分割槽(partitioning)** 是最約定俗成的叫法。 > 上文中的**分割槽(partition)**在MongoDBElasticsearch和Solr Cloud中被稱為**分片(shard)**在HBase中稱之為**區域(Region)**Bigtable中則是 **表塊tablet**Cassandra和Riak中是**虛節點vnode)**Couchbase中叫做**虛桶(vBucket)**。但是**分割槽(partitioning)** 是最約定俗成的叫法。
> >
通常情況下,每條資料(每條記錄,每行或每個文件)屬於且僅屬於一個分割槽。有很多方法可以實現這一點,本章將進行深入討論。實際上,每個分割槽都是自己的小型資料庫,儘管資料庫可能支援同時進行多個分割槽的操作。 通常情況下,每條資料(每條記錄,每行或每個文件)屬於且僅屬於一個分割槽。有很多方法可以實現這一點,本章將進行深入討論。實際上,每個分割槽都是自己的小型資料庫,儘管資料庫可能支援同時進行多個分割槽的操作。
分割槽主要是為了**可伸縮性**。不同的分割槽可以放在不共享叢集中的不同節點上(請參閱[第二部分](part-ii.md)關於[無共享架構](part-ii.md#無共享架構)的定義)。因此,大資料集可以分佈在多個磁碟上,並且查詢負載可以分佈在多個處理器上。 分割槽主要是為了**可伸縮性**。不同的分割槽可以放在不共享叢集中的不同節點上(請參閱[第二部分](part-ii.md)關於[無共享架構](part-ii.md#無共享架構)的定義)。因此,大資料集可以分佈在多個磁碟上,並且查詢負載可以分佈在多個處理器上。
對於在單個分割槽上執行的查詢,每個節點可以獨立執行對自己的查詢,因此可以透過新增更多的節點來擴大查詢吞吐量。大型,複雜的查詢可能會跨越多個節點並行處理,儘管這也帶來了新的困難。 對於在單個分割槽上執行的查詢,每個節點可以獨立執行對自己的查詢,因此可以透過新增更多的節點來擴大查詢吞吐量。大型,複雜的查詢可能會跨越多個節點並行處理,儘管這也帶來了新的困難。
分割槽資料庫在20世紀80年代由Teradata和NonStop SQL【1】等產品率先推出最近因為NoSQL資料庫和基於Hadoop的資料倉庫重新被關注。有些系統是為事務性工作設計的有些系統則用於分析請參閱“[事務處理還是分析](ch3.md#事務處理還是分析)”):這種差異會影響系統的運作方式,但是分割槽的基本原理均適用於這兩種工作方式。 分割槽資料庫在20世紀80年代由Teradata和NonStop SQL【1】等產品率先推出最近因為NoSQL資料庫和基於Hadoop的資料倉庫重新被關注。有些系統是為事務性工作設計的有些系統則用於分析請參閱“[事務處理還是分析](ch3.md#事務處理還是分析)”):這種差異會影響系統的運作方式,但是分割槽的基本原理均適用於這兩種工作方式。
在本章中,我們將首先介紹分割大型資料集的不同方法,並觀察索引如何與分割槽配合。然後我們將討論[分割槽再平衡rebalancing](#分割槽再平衡),如果想要新增或刪除叢集中的節點,則必須進行再平衡。最後,我們將概述資料庫如何將請求路由到正確的分割槽並執行查詢。 在本章中,我們將首先介紹分割大型資料集的不同方法,並觀察索引如何與分割槽配合。然後我們將討論[分割槽再平衡rebalancing](#分割槽再平衡),如果想要新增或刪除叢集中的節點,則必須進行再平衡。最後,我們將概述資料庫如何將請求路由到正確的分割槽並執行查詢。
## 分割槽與複製 ## 分割槽與複製
分割槽通常與複製結合使用,使得每個分割槽的副本儲存在多個節點上。 這意味著,即使每條記錄屬於一個分割槽,它仍然可以儲存在多個不同的節點上以獲得容錯能力。 分割槽通常與複製結合使用,使得每個分割槽的副本儲存在多個節點上。 這意味著,即使每條記錄屬於一個分割槽,它仍然可以儲存在多個不同的節點上以獲得容錯能力。
一個節點可能儲存多個分割槽。 如果使用主從複製模型,則分割槽和複製的組合如[圖6-1](../img/fig6-1.png)所示。 每個分割槽領導者(主)被分配給一個節點,追隨者(從)被分配給其他節點。 每個節點可能是某些分割槽的領導者,同時是其他分割槽的追隨者。 一個節點可能儲存多個分割槽。 如果使用主從複製模型,則分割槽和複製的組合如[圖6-1](../img/fig6-1.png)所示。 每個分割槽領導者(主)被分配給一個節點,追隨者(從)被分配給其他節點。 每個節點可能是某些分割槽的領導者,同時是其他分割槽的追隨者。
我們在[第五章](ch5.md)討論的關於資料庫複製的所有內容同樣適用於分割槽的複製。 大多數情況下,分割槽方案的選擇與複製方案的選擇是獨立的,為簡單起見,本章中將忽略複製。 我們在[第五章](ch5.md)討論的關於資料庫複製的所有內容同樣適用於分割槽的複製。 大多數情況下,分割槽方案的選擇與複製方案的選擇是獨立的,為簡單起見,本章中將忽略複製。
![](../img/fig6-1.png) ![](../img/fig6-1.png)
@ -43,49 +43,49 @@
## 鍵值資料的分割槽 ## 鍵值資料的分割槽
假設你有大量資料並且想要分割槽,如何決定在哪些節點上儲存哪些記錄呢? 假設你有大量資料並且想要分割槽,如何決定在哪些節點上儲存哪些記錄呢?
分割槽目標是將資料和查詢負載均勻分佈在各個節點上。如果每個節點公平分享資料和負載那麼理論上10個節點應該能夠處理10倍的資料量和10倍的單個節點的讀寫吞吐量暫時忽略複製 分割槽目標是將資料和查詢負載均勻分佈在各個節點上。如果每個節點公平分享資料和負載那麼理論上10個節點應該能夠處理10倍的資料量和10倍的單個節點的讀寫吞吐量暫時忽略複製
如果分割槽是不公平的,一些分割槽比其他分割槽有更多的資料或查詢,我們稱之為**偏斜skew**。資料偏斜的存在使分割槽效率下降很多。在極端的情況下所有的負載可能壓在一個分割槽上其餘9個節點空閒的瓶頸落在這一個繁忙的節點上。不均衡導致的高負載的分割槽被稱為**熱點hot spot**。 如果分割槽是不公平的,一些分割槽比其他分割槽有更多的資料或查詢,我們稱之為**偏斜skew**。資料偏斜的存在使分割槽效率下降很多。在極端的情況下所有的負載可能壓在一個分割槽上其餘9個節點空閒的瓶頸落在這一個繁忙的節點上。不均衡導致的高負載的分割槽被稱為**熱點hot spot**。
避免熱點最簡單的方法是將記錄隨機分配給節點。這將在所有節點上平均分配資料,但是它有一個很大的缺點:當你試圖讀取一個特定的值時,你無法知道它在哪個節點上,所以你必須並行地查詢所有的節點。 避免熱點最簡單的方法是將記錄隨機分配給節點。這將在所有節點上平均分配資料,但是它有一個很大的缺點:當你試圖讀取一個特定的值時,你無法知道它在哪個節點上,所以你必須並行地查詢所有的節點。
我們可以做得更好。現在假設您有一個簡單的鍵值資料模型,其中您總是透過其主鍵訪問記錄。例如,在一本老式的紙質百科全書中,你可以透過標題來查詢一個條目;由於所有條目按字母順序排序,因此您可以快速找到您要查詢的條目。 我們可以做得更好。現在假設您有一個簡單的鍵值資料模型,其中您總是透過其主鍵訪問記錄。例如,在一本老式的紙質百科全書中,你可以透過標題來查詢一個條目;由於所有條目按字母順序排序,因此您可以快速找到您要查詢的條目。
### 根據鍵的範圍分割槽 ### 根據鍵的範圍分割槽
一種分割槽的方法是為每個分割槽指定一塊連續的鍵範圍(從最小值到最大值),如紙質百科全書的卷([圖6-2](../img/fig6-2.png))。如果知道範圍之間的邊界,則可以輕鬆確定哪個分割槽包含某個值。如果您還知道分割槽所在的節點,那麼可以直接向相應的節點發出請求(對於百科全書而言,就像從書架上選取正確的書籍)。 一種分割槽的方法是為每個分割槽指定一塊連續的鍵範圍(從最小值到最大值),如紙質百科全書的卷([圖6-2](../img/fig6-2.png))。如果知道範圍之間的邊界,則可以輕鬆確定哪個分割槽包含某個值。如果您還知道分割槽所在的節點,那麼可以直接向相應的節點發出請求(對於百科全書而言,就像從書架上選取正確的書籍)。
![](../img/fig6-2.png) ![](../img/fig6-2.png)
**圖6-2 印刷版百科全書按照關鍵字範圍進行分割槽** **圖6-2 印刷版百科全書按照關鍵字範圍進行分割槽**
鍵的範圍不一定均勻分佈,因為資料也很可能不均勻分佈。例如在[圖6-2](../img/fig6-2.png)中第1捲包含以A和B開頭的單詞但第12卷則包含以TUVXY和Z開頭的單詞。只是簡單的規定每個捲包含兩個字母會導致一些卷比其他卷大。為了均勻分配資料分割槽邊界需要依據資料調整。 鍵的範圍不一定均勻分佈,因為資料也很可能不均勻分佈。例如在[圖6-2](../img/fig6-2.png)中第1捲包含以A和B開頭的單詞但第12卷則包含以TUVXY和Z開頭的單詞。只是簡單的規定每個捲包含兩個字母會導致一些卷比其他卷大。為了均勻分配資料分割槽邊界需要依據資料調整。
分割槽邊界可以由管理員手動選擇,也可以由資料庫自動選擇(我們會在“[分割槽再平衡](#分割槽再平衡)”中更詳細地討論分割槽邊界的選擇)。 Bigtable使用了這種分割槽策略以及其開源等價物HBase 【2, 3】RethinkDB和2.4版本之前的MongoDB 【4】。 分割槽邊界可以由管理員手動選擇,也可以由資料庫自動選擇(我們會在“[分割槽再平衡](#分割槽再平衡)”中更詳細地討論分割槽邊界的選擇)。 Bigtable使用了這種分割槽策略以及其開源等價物HBase 【2, 3】RethinkDB和2.4版本之前的MongoDB 【4】。
在每個分割槽中,我們可以按照一定的順序儲存鍵(請參閱“[SSTables和LSM樹](ch3.md#SSTables和LSM樹)”)。好處是進行範圍掃描非常簡單,您可以將鍵作為聯合索引來處理,以便在一次查詢中獲取多個相關記錄(請參閱“[多列索引](ch3.md#多列索引)”)。例如,假設我們有一個程式來儲存感測器網路的資料,其中主鍵是測量的時間戳(年月日時分秒)。範圍掃描在這種情況下非常有用,因為我們可以輕鬆獲取某個月份的所有資料。 在每個分割槽中,我們可以按照一定的順序儲存鍵(請參閱“[SSTables和LSM樹](ch3.md#SSTables和LSM樹)”)。好處是進行範圍掃描非常簡單,您可以將鍵作為聯合索引來處理,以便在一次查詢中獲取多個相關記錄(請參閱“[多列索引](ch3.md#多列索引)”)。例如,假設我們有一個程式來儲存感測器網路的資料,其中主鍵是測量的時間戳(年月日時分秒)。範圍掃描在這種情況下非常有用,因為我們可以輕鬆獲取某個月份的所有資料。
然而Key Range分割槽的缺點是某些特定的訪問模式會導致熱點。 如果主鍵是時間戳,則分割槽對應於時間範圍,例如,給每天分配一個分割槽。 不幸的是由於我們在測量發生時將資料從感測器寫入資料庫因此所有寫入操作都會轉到同一個分割槽即今天的分割槽這樣分割槽可能會因寫入而過載而其他分割槽則處於空閒狀態【5】。 然而Key Range分割槽的缺點是某些特定的訪問模式會導致熱點。 如果主鍵是時間戳,則分割槽對應於時間範圍,例如,給每天分配一個分割槽。 不幸的是由於我們在測量發生時將資料從感測器寫入資料庫因此所有寫入操作都會轉到同一個分割槽即今天的分割槽這樣分割槽可能會因寫入而過載而其他分割槽則處於空閒狀態【5】。
為了避免感測器資料庫中的這個問題,需要使用除了時間戳以外的其他東西作為主鍵的第一個部分。 例如,可以在每個時間戳前新增感測器名稱,這樣會首先按感測器名稱,然後按時間進行分割槽。 假設有多個感測器同時執行,寫入負載將最終均勻分佈在不同分割槽上。 現在,當想要在一個時間範圍內獲取多個感測器的值時,您需要為每個感測器名稱執行一個單獨的範圍查詢。 為了避免感測器資料庫中的這個問題,需要使用除了時間戳以外的其他東西作為主鍵的第一個部分。 例如,可以在每個時間戳前新增感測器名稱,這樣會首先按感測器名稱,然後按時間進行分割槽。 假設有多個感測器同時執行,寫入負載將最終均勻分佈在不同分割槽上。 現在,當想要在一個時間範圍內獲取多個感測器的值時,您需要為每個感測器名稱執行一個單獨的範圍查詢。
### 根據鍵的雜湊分割槽 ### 根據鍵的雜湊分割槽
由於偏斜和熱點的風險,許多分散式資料儲存使用雜湊函式來確定給定鍵的分割槽。 由於偏斜和熱點的風險,許多分散式資料儲存使用雜湊函式來確定給定鍵的分割槽。
一個好的雜湊函式可以將偏斜的資料均勻分佈。假設你有一個32位雜湊函式,無論何時給定一個新的字串輸入它將返回一個0到$2^{32}$ -1之間的“隨機”數。即使輸入的字串非常相似它們的雜湊也會均勻分佈在這個數字範圍內。 一個好的雜湊函式可以將偏斜的資料均勻分佈。假設你有一個32位雜湊函式,無論何時給定一個新的字串輸入它將返回一個0到$2^{32}$ -1之間的“隨機”數。即使輸入的字串非常相似它們的雜湊也會均勻分佈在這個數字範圍內。
出於分割槽的目的雜湊函式不需要多麼強壯的加密演算法例如Cassandra和MongoDB使用MD5Voldemort使用Fowler-Noll-Vo函式。許多程式語言都有內建的簡單雜湊函式它們用於雜湊表但是它們可能不適合分割槽例如在Java的`Object.hashCode()`和Ruby的`Object#hash`同一個鍵可能在不同的程序中有不同的雜湊值【6】。 出於分割槽的目的雜湊函式不需要多麼強壯的加密演算法例如Cassandra和MongoDB使用MD5Voldemort使用Fowler-Noll-Vo函式。許多程式語言都有內建的簡單雜湊函式它們用於雜湊表但是它們可能不適合分割槽例如在Java的`Object.hashCode()`和Ruby的`Object#hash`同一個鍵可能在不同的程序中有不同的雜湊值【6】。
一旦你有一個合適的鍵雜湊函式,你可以為每個分割槽分配一個雜湊範圍(而不是鍵的範圍),每個透過雜湊雜湊落在分割槽範圍內的鍵將被儲存在該分割槽中。如[圖6-3](../img/fig6-3.png)所示。 一旦你有一個合適的鍵雜湊函式,你可以為每個分割槽分配一個雜湊範圍(而不是鍵的範圍),每個透過雜湊雜湊落在分割槽範圍內的鍵將被儲存在該分割槽中。如[圖6-3](../img/fig6-3.png)所示。
![](../img/fig6-3.png) ![](../img/fig6-3.png)
**圖6-3 按雜湊鍵分割槽** **圖6-3 按雜湊鍵分割槽**
這種技術擅長在分割槽之間公平地分配鍵。分割槽邊界可以是均勻間隔的,也可以是偽隨機選擇的(在這種情況下,該技術有時也被稱為**一致性雜湊consistent hashing**)。 這種技術擅長在分割槽之間公平地分配鍵。分割槽邊界可以是均勻間隔的,也可以是偽隨機選擇的(在這種情況下,該技術有時也被稱為**一致性雜湊consistent hashing**)。
> #### 一致性雜湊 > #### 一致性雜湊
> >
@ -93,41 +93,41 @@
> >
> 正如我們將在“[分割槽再平衡](#分割槽再平衡)”中所看到的,這種特殊的方法對於資料庫實際上並不是很好,所以在實際中很少使用(某些資料庫的文件仍然會使用一致性雜湊的說法,但是它往往是不準確的)。 因為有可能產生混淆,所以最好避免使用一致性雜湊這個術語,而只是把它稱為**雜湊分割槽hash partitioning**。 > 正如我們將在“[分割槽再平衡](#分割槽再平衡)”中所看到的,這種特殊的方法對於資料庫實際上並不是很好,所以在實際中很少使用(某些資料庫的文件仍然會使用一致性雜湊的說法,但是它往往是不準確的)。 因為有可能產生混淆,所以最好避免使用一致性雜湊這個術語,而只是把它稱為**雜湊分割槽hash partitioning**。
不幸的是透過使用鍵雜湊進行分割槽我們失去了鍵範圍分割槽的一個很好的屬性高效執行範圍查詢的能力。曾經相鄰的鍵現在分散在所有分割槽中所以它們之間的順序就丟失了。在MongoDB中如果您使用了基於雜湊的分割槽模式則任何範圍查詢都必須傳送到所有分割槽【4】。Riak 【9】Couchbase 【10】或Voldemort不支援主鍵上的範圍查詢。 不幸的是透過使用鍵雜湊進行分割槽我們失去了鍵範圍分割槽的一個很好的屬性高效執行範圍查詢的能力。曾經相鄰的鍵現在分散在所有分割槽中所以它們之間的順序就丟失了。在MongoDB中如果您使用了基於雜湊的分割槽模式則任何範圍查詢都必須傳送到所有分割槽【4】。Riak 【9】Couchbase 【10】或Voldemort不支援主鍵上的範圍查詢。
Cassandra採取了折衷的策略【11, 12, 13】。 Cassandra中的表可以使用由多個列組成的複合主鍵來宣告。鍵中只有第一列會作為雜湊的依據而其他列則被用作Casssandra的SSTables中排序資料的連線索引。儘管查詢無法在複合主鍵的第一列中按範圍掃表但如果第一列已經指定了固定值則可以對該鍵的其他列執行有效的範圍掃描。 Cassandra採取了折衷的策略【11, 12, 13】。 Cassandra中的表可以使用由多個列組成的複合主鍵來宣告。鍵中只有第一列會作為雜湊的依據而其他列則被用作Casssandra的SSTables中排序資料的連線索引。儘管查詢無法在複合主鍵的第一列中按範圍掃表但如果第一列已經指定了固定值則可以對該鍵的其他列執行有效的範圍掃描。
組合索引方法為一對多關係提供了一個優雅的資料模型。例如,在社交媒體網站上,一個使用者可能會發布很多更新。如果更新的主鍵被選擇為`(user_id, update_timestamp)`,那麼您可以有效地檢索特定使用者在某個時間間隔內按時間戳排序的所有更新。不同的使用者可以儲存在不同的分割槽上,對於每個使用者,更新按時間戳順序儲存在單個分割槽上。 組合索引方法為一對多關係提供了一個優雅的資料模型。例如,在社交媒體網站上,一個使用者可能會發布很多更新。如果更新的主鍵被選擇為`(user_id, update_timestamp)`,那麼您可以有效地檢索特定使用者在某個時間間隔內按時間戳排序的所有更新。不同的使用者可以儲存在不同的分割槽上,對於每個使用者,更新按時間戳順序儲存在單個分割槽上。
### 負載偏斜與熱點消除 ### 負載偏斜與熱點消除
如前所述,雜湊分割槽可以幫助減少熱點。但是,它不能完全避免它們:在極端情況下,所有的讀寫操作都是針對同一個鍵的,所有的請求都會被路由到同一個分割槽。 如前所述,雜湊分割槽可以幫助減少熱點。但是,它不能完全避免它們:在極端情況下,所有的讀寫操作都是針對同一個鍵的,所有的請求都會被路由到同一個分割槽。
這種場景也許並不常見但並非聞所未聞例如在社交媒體網站上一個擁有數百萬追隨者的名人使用者在做某事時可能會引發一場風暴【14】。這個事件可能導致同一個鍵的大量寫入鍵可能是名人的使用者ID或者人們正在評論的動作的ID。雜湊策略不起作用因為兩個相同ID的雜湊值仍然是相同的。 這種場景也許並不常見但並非聞所未聞例如在社交媒體網站上一個擁有數百萬追隨者的名人使用者在做某事時可能會引發一場風暴【14】。這個事件可能導致同一個鍵的大量寫入鍵可能是名人的使用者ID或者人們正在評論的動作的ID。雜湊策略不起作用因為兩個相同ID的雜湊值仍然是相同的。
如今大多數資料系統無法自動補償這種高度偏斜的負載因此應用程式有責任減少偏斜。例如如果一個主鍵被認為是非常火爆的一個簡單的方法是在主鍵的開始或結尾新增一個隨機數。只要一個兩位數的十進位制隨機數就可以將主鍵分散為100種不同的主鍵,從而儲存在不同的分割槽中。 如今大多數資料系統無法自動補償這種高度偏斜的負載因此應用程式有責任減少偏斜。例如如果一個主鍵被認為是非常火爆的一個簡單的方法是在主鍵的開始或結尾新增一個隨機數。只要一個兩位數的十進位制隨機數就可以將主鍵分散為100種不同的主鍵,從而儲存在不同的分割槽中。
然而將主鍵進行分割之後任何讀取都必須要做額外的工作因為他們必須從所有100個主鍵分佈中讀取資料並將其合併。此技術還需要額外的記錄只需要對少量熱點附加隨機數對於寫入吞吐量低的絕大多數主鍵來說是不必要的開銷。因此您還需要一些方法來跟蹤哪些鍵需要被分割。 然而將主鍵進行分割之後任何讀取都必須要做額外的工作因為他們必須從所有100個主鍵分佈中讀取資料並將其合併。此技術還需要額外的記錄只需要對少量熱點附加隨機數對於寫入吞吐量低的絕大多數主鍵來說是不必要的開銷。因此您還需要一些方法來跟蹤哪些鍵需要被分割。
也許在將來,資料系統將能夠自動檢測和補償偏斜的工作負載;但現在,您需要自己來權衡。 也許在將來,資料系統將能夠自動檢測和補償偏斜的工作負載;但現在,您需要自己來權衡。
## 分割槽與次級索引 ## 分割槽與次級索引
到目前為止,我們討論的分割槽方案依賴於鍵值資料模型。如果只通過主鍵訪問記錄,我們可以從該鍵確定分割槽,並使用它來將讀寫請求路由到負責該鍵的分割槽。 到目前為止,我們討論的分割槽方案依賴於鍵值資料模型。如果只通過主鍵訪問記錄,我們可以從該鍵確定分割槽,並使用它來將讀寫請求路由到負責該鍵的分割槽。
如果涉及次級索引,情況會變得更加複雜(參考“[其他索引結構](ch3.md#其他索引結構)”。次級索引通常並不能唯一地標識記錄而是一種搜尋記錄中出現特定值的方式查詢使用者123的所有操作查詢包含詞語`hogwash`的所有文章,查詢所有顏色為紅色的車輛等等。 如果涉及次級索引,情況會變得更加複雜(參考“[其他索引結構](ch3.md#其他索引結構)”。次級索引通常並不能唯一地標識記錄而是一種搜尋記錄中出現特定值的方式查詢使用者123的所有操作查詢包含詞語`hogwash`的所有文章,查詢所有顏色為紅色的車輛等等。
次級索引是關係型資料庫的基礎並且在文件資料庫中也很普遍。許多鍵值儲存如HBase和Volde-mort為了減少實現的複雜度而放棄了次級索引但是一些如Riak已經開始新增它們因為它們對於資料模型實在是太有用了。並且次級索引也是Solr和Elasticsearch等搜尋伺服器的基石。 次級索引是關係型資料庫的基礎並且在文件資料庫中也很普遍。許多鍵值儲存如HBase和Volde-mort為了減少實現的複雜度而放棄了次級索引但是一些如Riak已經開始新增它們因為它們對於資料模型實在是太有用了。並且次級索引也是Solr和Elasticsearch等搜尋伺服器的基石。
次級索引的問題是它們不能整齊地對映到分割槽。有兩種用次級索引對資料庫進行分割槽的方法:**基於文件的分割槽document-based** 和**基於關鍵詞term-based的分割槽**。 次級索引的問題是它們不能整齊地對映到分割槽。有兩種用次級索引對資料庫進行分割槽的方法:**基於文件的分割槽document-based** 和**基於關鍵詞term-based的分割槽**。
### 基於文件的次級索引進行分割槽 ### 基於文件的次級索引進行分割槽
假設你正在經營一個銷售二手車的網站(如[圖6-4](../img/fig6-4.png)所示)。 每個列表都有一個唯一的ID——稱之為文件ID——並且用文件ID對資料庫進行分割槽例如分割槽0中的ID 0到499分割槽1中的ID 500到999等 假設你正在經營一個銷售二手車的網站(如[圖6-4](../img/fig6-4.png)所示)。 每個列表都有一個唯一的ID——稱之為文件ID——並且用文件ID對資料庫進行分割槽例如分割槽0中的ID 0到499分割槽1中的ID 500到999等
你想讓使用者搜尋汽車,允許他們透過顏色和廠商過濾,所以需要一個在顏色和廠商上的次級索引(文件資料庫中這些是**欄位field**,關係資料庫中這些是**列column** )。 如果您聲明瞭索引,則資料庫可以自動執行索引[^ii]。例如,無論何時將紅色汽車新增到資料庫,資料庫分割槽都會自動將其新增到索引條目`color:red`的文件ID列表中。 你想讓使用者搜尋汽車,允許他們透過顏色和廠商過濾,所以需要一個在顏色和廠商上的次級索引(文件資料庫中這些是**欄位field**,關係資料庫中這些是**列column** )。 如果您聲明瞭索引,則資料庫可以自動執行索引[^ii]。例如,無論何時將紅色汽車新增到資料庫,資料庫分割槽都會自動將其新增到索引條目`color:red`的文件ID列表中。
[^ii]: 如果資料庫僅支援鍵值模型則你可能會嘗試在應用程式程式碼中建立從值到文件ID的對映來實現次級索引。 如果沿著這條路線走下去,請萬分小心,確保您的索引與底層資料保持一致。 競爭條件和間歇性寫入失敗(其中一些更改已儲存,但其他更改未儲存)很容易導致資料不同步 - 請參閱“[多物件事務的需求](ch7.md#多物件事務的需求)”。 [^ii]: 如果資料庫僅支援鍵值模型則你可能會嘗試在應用程式程式碼中建立從值到文件ID的對映來實現次級索引。 如果沿著這條路線走下去,請萬分小心,確保您的索引與底層資料保持一致。 競爭條件和間歇性寫入失敗(其中一些更改已儲存,但其他更改未儲存)很容易導致資料不同步 - 請參閱“[多物件事務的需求](ch7.md#多物件事務的需求)”。
@ -135,35 +135,35 @@
**圖6-4 基於文件的次級索引進行分割槽** **圖6-4 基於文件的次級索引進行分割槽**
在這種索引方法中每個分割槽是完全獨立的每個分割槽維護自己的次級索引僅覆蓋該分割槽中的文件。它不關心儲存在其他分割槽的資料。無論何時您需要寫入資料庫新增刪除或更新文件只需處理包含您正在編寫的文件ID的分割槽即可。出於這個原因**文件分割槽索引**也被稱為**本地索引local index**(而不是將在下一節中描述的**全域性索引global index**)。 在這種索引方法中每個分割槽是完全獨立的每個分割槽維護自己的次級索引僅覆蓋該分割槽中的文件。它不關心儲存在其他分割槽的資料。無論何時您需要寫入資料庫新增刪除或更新文件只需處理包含您正在編寫的文件ID的分割槽即可。出於這個原因**文件分割槽索引**也被稱為**本地索引local index**(而不是將在下一節中描述的**全域性索引global index**)。
但是從文件分割槽索引中讀取需要注意除非您對文件ID做了特別的處理否則沒有理由將所有具有特定顏色或特定品牌的汽車放在同一個分割槽中。在[圖6-4](../img/fig6-4.png)中紅色汽車出現在分割槽0和分割槽1中。因此如果要搜尋紅色汽車則需要將查詢傳送到所有分割槽併合並所有返回的結果。 但是從文件分割槽索引中讀取需要注意除非您對文件ID做了特別的處理否則沒有理由將所有具有特定顏色或特定品牌的汽車放在同一個分割槽中。在[圖6-4](../img/fig6-4.png)中紅色汽車出現在分割槽0和分割槽1中。因此如果要搜尋紅色汽車則需要將查詢傳送到所有分割槽併合並所有返回的結果。
這種查詢分割槽資料庫的方法有時被稱為**分散/聚集scatter/gather**,並且可能會使次級索引上的讀取查詢相當昂貴。即使並行查詢分割槽,分散/聚集也容易導致尾部延遲放大(請參閱“[實踐中的百分位點](ch1.md#實踐中的百分位點)”。然而它被廣泛使用MongoDBRiak 【15】Cassandra 【16】Elasticsearch 【17】SolrCloud 【18】和VoltDB 【19】都使用文件分割槽次級索引。大多數資料庫供應商建議您構建一個能從單個分割槽提供次級索引查詢的分割槽方案但這並不總是可行尤其是當在單個查詢中使用多個次級索引時例如同時需要按顏色和製造商查詢 這種查詢分割槽資料庫的方法有時被稱為**分散/聚集scatter/gather**,並且可能會使次級索引上的讀取查詢相當昂貴。即使並行查詢分割槽,分散/聚集也容易導致尾部延遲放大(請參閱“[實踐中的百分位點](ch1.md#實踐中的百分位點)”。然而它被廣泛使用MongoDBRiak 【15】Cassandra 【16】Elasticsearch 【17】SolrCloud 【18】和VoltDB 【19】都使用文件分割槽次級索引。大多數資料庫供應商建議您構建一個能從單個分割槽提供次級索引查詢的分割槽方案但這並不總是可行尤其是當在單個查詢中使用多個次級索引時例如同時需要按顏色和製造商查詢
### 基於關鍵詞(Term)的次級索引進行分割槽 ### 基於關鍵詞(Term)的次級索引進行分割槽
我們可以構建一個覆蓋所有分割槽資料的**全域性索引**,而不是給每個分割槽建立自己的次級索引(本地索引)。但是,我們不能只把這個索引儲存在一個節點上,因為它可能會成為瓶頸,違背了分割槽的目的。全域性索引也必須進行分割槽,但可以採用與主鍵不同的分割槽方式。 我們可以構建一個覆蓋所有分割槽資料的**全域性索引**,而不是給每個分割槽建立自己的次級索引(本地索引)。但是,我們不能只把這個索引儲存在一個節點上,因為它可能會成為瓶頸,違背了分割槽的目的。全域性索引也必須進行分割槽,但可以採用與主鍵不同的分割槽方式。
[圖6-5](../img/fig6-5.png)描述了這可能是什麼樣子:來自所有分割槽的紅色汽車在紅色索引中,並且索引是分割槽的,首字母從`a`到`r`的顏色在分割槽0中`s`到`z`的在分割槽1。汽車製造商的索引也與之類似分割槽邊界在`f`和`h`之間)。 [圖6-5](../img/fig6-5.png)描述了這可能是什麼樣子:來自所有分割槽的紅色汽車在紅色索引中,並且索引是分割槽的,首字母從`a`到`r`的顏色在分割槽0中`s`到`z`的在分割槽1。汽車製造商的索引也與之類似分割槽邊界在`f`和`h`之間)。
![](../img/fig6-5.png) ![](../img/fig6-5.png)
**圖6-5 基於關鍵詞對次級索引進行分割槽** **圖6-5 基於關鍵詞對次級索引進行分割槽**
我們將這種索引稱為**關鍵詞分割槽term-partitioned**,因為我們尋找的關鍵詞決定了索引的分割槽方式。例如,一個關鍵詞可能是:`color:red`。**關鍵詞(Term)** 這個名稱來源於全文搜尋索引(一種特殊的次級索引),指文件中出現的所有單詞。 我們將這種索引稱為**關鍵詞分割槽term-partitioned**,因為我們尋找的關鍵詞決定了索引的分割槽方式。例如,一個關鍵詞可能是:`color:red`。**關鍵詞(Term)** 這個名稱來源於全文搜尋索引(一種特殊的次級索引),指文件中出現的所有單詞。
和之前一樣,我們可以透過**關鍵詞**本身或者它的雜湊進行索引分割槽。根據關鍵詞本身來分割槽對於範圍掃描非常有用(例如對於數值類的屬性,像汽車的報價),而對關鍵詞的雜湊分割槽提供了負載均衡的能力。 和之前一樣,我們可以透過**關鍵詞**本身或者它的雜湊進行索引分割槽。根據關鍵詞本身來分割槽對於範圍掃描非常有用(例如對於數值類的屬性,像汽車的報價),而對關鍵詞的雜湊分割槽提供了負載均衡的能力。
關鍵詞分割槽的全域性索引優於文件分割槽索引的地方點是它可以使讀取更有效率:不需要**分散/收集**所有分割槽,客戶端只需要向包含關鍵詞的分割槽發出請求。全域性索引的缺點在於寫入速度較慢且較為複雜,因為寫入單個文件現在可能會影響索引的多個分割槽(文件中的每個關鍵詞可能位於不同的分割槽或者不同的節點上) 。 關鍵詞分割槽的全域性索引優於文件分割槽索引的地方點是它可以使讀取更有效率:不需要**分散/收集**所有分割槽,客戶端只需要向包含關鍵詞的分割槽發出請求。全域性索引的缺點在於寫入速度較慢且較為複雜,因為寫入單個文件現在可能會影響索引的多個分割槽(文件中的每個關鍵詞可能位於不同的分割槽或者不同的節點上) 。
理想情況下,索引總是最新的,寫入資料庫的每個文件都會立即反映在索引中。但是,在關鍵詞分割槽索引中,這需要跨分割槽的分散式事務,並不是所有資料庫都支援(請參閱[第七章](ch7.md)和[第九章](ch9.md))。 理想情況下,索引總是最新的,寫入資料庫的每個文件都會立即反映在索引中。但是,在關鍵詞分割槽索引中,這需要跨分割槽的分散式事務,並不是所有資料庫都支援(請參閱[第七章](ch7.md)和[第九章](ch9.md))。
在實踐中,對全域性次級索引的更新通常是**非同步**的也就是說如果在寫入之後不久讀取索引剛才所做的更改可能尚未反映在索引中。例如Amazon DynamoDB聲稱在正常情況下其全域性次級索引會在不到一秒的時間內更新但在基礎架構出現故障的情況下可能會有延遲【20】。 在實踐中,對全域性次級索引的更新通常是**非同步**的也就是說如果在寫入之後不久讀取索引剛才所做的更改可能尚未反映在索引中。例如Amazon DynamoDB聲稱在正常情況下其全域性次級索引會在不到一秒的時間內更新但在基礎架構出現故障的情況下可能會有延遲【20】。
全域性關鍵詞分割槽索引的其他用途包括Riak的搜尋功能【21】和Oracle資料倉庫它允許您在本地和全域性索引之間進行選擇【22】。我們將在[第十二章](ch12.md)中繼續關鍵詞分割槽次級索引實現的話題。 全域性關鍵詞分割槽索引的其他用途包括Riak的搜尋功能【21】和Oracle資料倉庫它允許您在本地和全域性索引之間進行選擇【22】。我們將在[第十二章](ch12.md)中繼續關鍵詞分割槽次級索引實現的話題。
## 分割槽再平衡 ## 分割槽再平衡
@ -188,73 +188,73 @@
#### 反面教材hash mod N #### 反面教材hash mod N
我們在前面說過([圖6-3](../img/fig6-3.png)),最好將可能的雜湊分成不同的範圍,並將每個範圍分配給一個分割槽(例如,如果$0≤hash(key)<b_0$則將鍵分配給分割槽0如果$b_0 hash(key) <b_1$則分配給分割槽1 我們在前面說過([圖6-3](../img/fig6-3.png)),最好將可能的雜湊分成不同的範圍,並將每個範圍分配給一個分割槽(例如,如果$0≤hash(key)<b_0$則將鍵分配給分割槽0如果$b_0 hash(key) <b_1$則分配給分割槽1
也許你想知道為什麼我們不使用 ***取模mod***(許多程式語言中的%運算子)。例如,`hash(key) mod 10`會返回一個介於0和9之間的數字如果我們將雜湊寫為十進位制數雜湊模10將是最後一個數字。如果我們有10個節點編號為0到9這似乎是將每個鍵分配給一個節點的簡單方法。 也許你想知道為什麼我們不使用 ***取模mod***(許多程式語言中的%運算子)。例如,`hash(key) mod 10`會返回一個介於0和9之間的數字如果我們將雜湊寫為十進位制數雜湊模10將是最後一個數字。如果我們有10個節點編號為0到9這似乎是將每個鍵分配給一個節點的簡單方法。
模N$mod N$方法的問題是如果節點數量N發生變化大多數鍵將需要從一個節點移動到另一個節點。例如假設$hash(key)=123456$。如果最初有10個節點那麼這個鍵一開始放在節點6上因為$123456\ mod\ 10 = 6$。當您增長到11個節點時鍵需要移動到節點3$123456\ mod\ 11 = 3$當您增長到12個節點時需要移動到節點0$123456\ mod\ 12 = 0$)。這種頻繁的舉動使得重新平衡過於昂貴。 模N$mod N$方法的問題是如果節點數量N發生變化大多數鍵將需要從一個節點移動到另一個節點。例如假設$hash(key)=123456$。如果最初有10個節點那麼這個鍵一開始放在節點6上因為$123456\ mod\ 10 = 6$。當您增長到11個節點時鍵需要移動到節點3$123456\ mod\ 11 = 3$當您增長到12個節點時需要移動到節點0$123456\ mod\ 12 = 0$)。這種頻繁的舉動使得重新平衡過於昂貴。
我們需要一種只移動必需資料的方法。 我們需要一種只移動必需資料的方法。
#### 固定數量的分割槽 #### 固定數量的分割槽
幸運的是有一個相當簡單的解決方案建立比節點更多的分割槽併為每個節點分配多個分割槽。例如執行在10個節點的叢集上的資料庫可能會從一開始就被拆分為1,000個分割槽因此大約有100個分割槽被分配給每個節點。 幸運的是有一個相當簡單的解決方案建立比節點更多的分割槽併為每個節點分配多個分割槽。例如執行在10個節點的叢集上的資料庫可能會從一開始就被拆分為1,000個分割槽因此大約有100個分割槽被分配給每個節點。
現在,如果一個節點被新增到叢集中,新節點可以從當前每個節點中**竊取**一些分割槽,直到分割槽再次公平分配。這個過程如[圖6-6](../img/fig6-6.png)所示。如果從叢集中刪除一個節點,則會發生相反的情況。 現在,如果一個節點被新增到叢集中,新節點可以從當前每個節點中**竊取**一些分割槽,直到分割槽再次公平分配。這個過程如[圖6-6](../img/fig6-6.png)所示。如果從叢集中刪除一個節點,則會發生相反的情況。
只有分割槽在節點之間的移動。分割槽的數量不會改變,鍵所指定的分割槽也不會改變。唯一改變的是分割槽所在的節點。這種變更並不是即時的 — 在網路上傳輸大量的資料需要一些時間 — 所以在傳輸過程中,原有分割槽仍然會接受讀寫操作。 只有分割槽在節點之間的移動。分割槽的數量不會改變,鍵所指定的分割槽也不會改變。唯一改變的是分割槽所在的節點。這種變更並不是即時的 — 在網路上傳輸大量的資料需要一些時間 — 所以在傳輸過程中,原有分割槽仍然會接受讀寫操作。
![](../img/fig6-6.png) ![](../img/fig6-6.png)
**圖6-6 將新節點新增到每個節點具有多個分割槽的資料庫叢集。** **圖6-6 將新節點新增到每個節點具有多個分割槽的資料庫叢集。**
原則上您甚至可以解決叢集中的硬體不匹配問題透過為更強大的節點分配更多的分割槽可以強制這些節點承載更多的負載。在Riak 【15】Elasticsearch 【24】Couchbase 【10】和Voldemort 【25】中使用了這種再平衡的方法。 原則上您甚至可以解決叢集中的硬體不匹配問題透過為更強大的節點分配更多的分割槽可以強制這些節點承載更多的負載。在Riak 【15】Elasticsearch 【24】Couchbase 【10】和Voldemort 【25】中使用了這種再平衡的方法。
在這種配置中,分割槽的數量通常在資料庫第一次建立時確定,之後不會改變。雖然原則上可以分割和合並分割槽(請參閱下一節),但固定數量的分割槽在操作上更簡單,因此許多固定分割槽資料庫選擇不實施分割槽分割。因此,一開始配置的分割槽數就是您可以擁有的最大節點數量,所以您需要選擇足夠多的分割槽以適應未來的增長。但是,每個分割槽也有管理開銷,所以選擇太大的數字會適得其反。 在這種配置中,分割槽的數量通常在資料庫第一次建立時確定,之後不會改變。雖然原則上可以分割和合並分割槽(請參閱下一節),但固定數量的分割槽在操作上更簡單,因此許多固定分割槽資料庫選擇不實施分割槽分割。因此,一開始配置的分割槽數就是您可以擁有的最大節點數量,所以您需要選擇足夠多的分割槽以適應未來的增長。但是,每個分割槽也有管理開銷,所以選擇太大的數字會適得其反。
如果資料集的總大小難以預估(例如,可能它開始很小,但隨著時間的推移會變得更大),選擇正確的分割槽數是困難的。由於每個分割槽包含了總資料量固定比率的資料,因此每個分割槽的大小與叢集中的資料總量成比例增長。如果分割槽非常大,再平衡和從節點故障恢復變得昂貴。但是,如果分割槽太小,則會產生太多的開銷。當分割槽大小“恰到好處”的時候才能獲得很好的效能,如果分割槽數量固定,但資料量變動很大,則難以達到最佳效能。 如果資料集的總大小難以預估(例如,可能它開始很小,但隨著時間的推移會變得更大),選擇正確的分割槽數是困難的。由於每個分割槽包含了總資料量固定比率的資料,因此每個分割槽的大小與叢集中的資料總量成比例增長。如果分割槽非常大,再平衡和從節點故障恢復變得昂貴。但是,如果分割槽太小,則會產生太多的開銷。當分割槽大小“恰到好處”的時候才能獲得很好的效能,如果分割槽數量固定,但資料量變動很大,則難以達到最佳效能。
#### 動態分割槽 #### 動態分割槽
對於使用鍵範圍分割槽的資料庫(請參閱“[根據鍵的範圍分割槽](#根據鍵的範圍分割槽)”),具有固定邊界的固定數量的分割槽將非常不便:如果出現邊界錯誤,則可能會導致一個分割槽中的所有資料或者其他分割槽中的所有資料為空。手動重新配置分割槽邊界將非常繁瑣。 對於使用鍵範圍分割槽的資料庫(請參閱“[根據鍵的範圍分割槽](#根據鍵的範圍分割槽)”),具有固定邊界的固定數量的分割槽將非常不便:如果出現邊界錯誤,則可能會導致一個分割槽中的所有資料或者其他分割槽中的所有資料為空。手動重新配置分割槽邊界將非常繁瑣。
出於這個原因按鍵的範圍進行分割槽的資料庫如HBase和RethinkDB會動態建立分割槽。當分割槽增長到超過配置的大小時在HBase上預設值是10GB會被分成兩個分割槽每個分割槽約佔一半的資料【26】。與之相反如果大量資料被刪除並且分割槽縮小到某個閾值以下則可以將其與相鄰分割槽合併。此過程與B樹頂層發生的過程類似請參閱“[B樹](ch3.md#B樹)”)。 出於這個原因按鍵的範圍進行分割槽的資料庫如HBase和RethinkDB會動態建立分割槽。當分割槽增長到超過配置的大小時在HBase上預設值是10GB會被分成兩個分割槽每個分割槽約佔一半的資料【26】。與之相反如果大量資料被刪除並且分割槽縮小到某個閾值以下則可以將其與相鄰分割槽合併。此過程與B樹頂層發生的過程類似請參閱“[B樹](ch3.md#B樹)”)。
每個分割槽分配給一個節點每個節點可以處理多個分割槽就像固定數量的分割槽一樣。大型分割槽拆分後可以將其中的一半轉移到另一個節點以平衡負載。在HBase中分割槽檔案的傳輸透過HDFS底層使用的分散式檔案系統來實現【3】。 每個分割槽分配給一個節點每個節點可以處理多個分割槽就像固定數量的分割槽一樣。大型分割槽拆分後可以將其中的一半轉移到另一個節點以平衡負載。在HBase中分割槽檔案的傳輸透過HDFS底層使用的分散式檔案系統來實現【3】。
動態分割槽的一個優點是分割槽數量適應總資料量。如果只有少量的資料少量的分割槽就足夠了所以開銷很小如果有大量的資料每個分割槽的大小被限制在一個可配置的最大值【23】。 動態分割槽的一個優點是分割槽數量適應總資料量。如果只有少量的資料少量的分割槽就足夠了所以開銷很小如果有大量的資料每個分割槽的大小被限制在一個可配置的最大值【23】。
需要注意的是一個空的資料庫從一個分割槽開始因為沒有關於在哪裡繪製分割槽邊界的先驗資訊。資料集開始時很小直到達到第一個分割槽的分割點所有寫入操作都必須由單個節點處理而其他節點則處於空閒狀態。為了解決這個問題HBase和MongoDB允許在一個空的資料庫上配置一組初始分割槽這被稱為**預分割pre-splitting**。在鍵範圍分割槽的情況中預分割需要提前知道鍵是如何進行分配的【4,26】。 需要注意的是一個空的資料庫從一個分割槽開始因為沒有關於在哪裡繪製分割槽邊界的先驗資訊。資料集開始時很小直到達到第一個分割槽的分割點所有寫入操作都必須由單個節點處理而其他節點則處於空閒狀態。為了解決這個問題HBase和MongoDB允許在一個空的資料庫上配置一組初始分割槽這被稱為**預分割pre-splitting**。在鍵範圍分割槽的情況中預分割需要提前知道鍵是如何進行分配的【4,26】。
動態分割槽不僅適用於資料的範圍分割槽而且也適用於雜湊分割槽。從版本2.4開始MongoDB同時支援範圍和雜湊分割槽並且都支援動態分割分割槽。 動態分割槽不僅適用於資料的範圍分割槽而且也適用於雜湊分割槽。從版本2.4開始MongoDB同時支援範圍和雜湊分割槽並且都支援動態分割分割槽。
#### 按節點比例分割槽 #### 按節點比例分割槽
透過動態分割槽,分割槽的數量與資料集的大小成正比,因為拆分和合並過程將每個分割槽的大小保持在固定的最小值和最大值之間。另一方面,對於固定數量的分割槽,每個分割槽的大小與資料集的大小成正比。在這兩種情況下,分割槽的數量都與節點的數量無關。 透過動態分割槽,分割槽的數量與資料集的大小成正比,因為拆分和合並過程將每個分割槽的大小保持在固定的最小值和最大值之間。另一方面,對於固定數量的分割槽,每個分割槽的大小與資料集的大小成正比。在這兩種情況下,分割槽的數量都與節點的數量無關。
Cassandra和Ketama使用的第三種方法是使分割槽數與節點數成正比——換句話說每個節點具有固定數量的分割槽【23,27,28】。在這種情況下每個分割槽的大小與資料集大小成比例地增長而節點數量保持不變但是當增加節點數時分割槽將再次變小。由於較大的資料量通常需要較大數量的節點進行儲存因此這種方法也使每個分割槽的大小較為穩定。 Cassandra和Ketama使用的第三種方法是使分割槽數與節點數成正比——換句話說每個節點具有固定數量的分割槽【23,27,28】。在這種情況下每個分割槽的大小與資料集大小成比例地增長而節點數量保持不變但是當增加節點數時分割槽將再次變小。由於較大的資料量通常需要較大數量的節點進行儲存因此這種方法也使每個分割槽的大小較為穩定。
當一個新節點加入叢集時它隨機選擇固定數量的現有分割槽進行拆分然後佔有這些拆分分割槽中每個分割槽的一半同時將每個分割槽的另一半留在原地。隨機化可能會產生不公平的分割但是平均在更大數量的分割槽上時在Cassandra中預設情況下每個節點有256個分割槽新節點最終從現有節點獲得公平的負載份額。 Cassandra 3.0引入了另一種再平衡的演算法來避免不公平的分割【29】。 當一個新節點加入叢集時它隨機選擇固定數量的現有分割槽進行拆分然後佔有這些拆分分割槽中每個分割槽的一半同時將每個分割槽的另一半留在原地。隨機化可能會產生不公平的分割但是平均在更大數量的分割槽上時在Cassandra中預設情況下每個節點有256個分割槽新節點最終從現有節點獲得公平的負載份額。 Cassandra 3.0引入了另一種再平衡的演算法來避免不公平的分割【29】。
隨機選擇分割槽邊界要求使用基於雜湊的分割槽可以從雜湊函式產生的數字範圍中挑選邊界。實際上這種方法最符合一致性雜湊的原始定義【7】請參閱“[一致性雜湊](#一致性雜湊)”。最新的雜湊函式可以在較低元資料開銷的情況下達到類似的效果【8】。 隨機選擇分割槽邊界要求使用基於雜湊的分割槽可以從雜湊函式產生的數字範圍中挑選邊界。實際上這種方法最符合一致性雜湊的原始定義【7】請參閱“[一致性雜湊](#一致性雜湊)”。最新的雜湊函式可以在較低元資料開銷的情況下達到類似的效果【8】。
### 運維:手動還是自動再平衡 ### 運維:手動還是自動再平衡
關於再平衡有一個重要問題:自動還是手動進行? 關於再平衡有一個重要問題:自動還是手動進行?
在全自動重新平衡系統自動決定何時將分割槽從一個節點移動到另一個節點無須人工干預和完全手動分割槽指派給節點由管理員明確配置僅在管理員明確重新配置時才會更改之間有一個權衡。例如CouchbaseRiak和Voldemort會自動生成建議的分割槽分配但需要管理員提交才能生效。 在全自動重新平衡系統自動決定何時將分割槽從一個節點移動到另一個節點無須人工干預和完全手動分割槽指派給節點由管理員明確配置僅在管理員明確重新配置時才會更改之間有一個權衡。例如CouchbaseRiak和Voldemort會自動生成建議的分割槽分配但需要管理員提交才能生效。
全自動重新平衡可以很方便,因為正常維護的操作工作較少。但是,這可能是不可預測的。再平衡是一個昂貴的操作,因為它需要重新路由請求並將大量資料從一個節點移動到另一個節點。如果沒有做好,這個過程可能會使網路或節點負載過重,降低其他請求的效能。 全自動重新平衡可以很方便,因為正常維護的操作工作較少。但是,這可能是不可預測的。再平衡是一個昂貴的操作,因為它需要重新路由請求並將大量資料從一個節點移動到另一個節點。如果沒有做好,這個過程可能會使網路或節點負載過重,降低其他請求的效能。
這種自動化與自動故障檢測相結合可能十分危險。例如,假設一個節點過載,並且對請求的響應暫時很慢。其他節點得出結論:過載的節點已經死亡,並自動重新平衡叢集,使負載離開它。這會對已經超負荷的節點,其他節點和網路造成額外的負載,從而使情況變得更糟,並可能導致級聯失敗。 這種自動化與自動故障檢測相結合可能十分危險。例如,假設一個節點過載,並且對請求的響應暫時很慢。其他節點得出結論:過載的節點已經死亡,並自動重新平衡叢集,使負載離開它。這會對已經超負荷的節點,其他節點和網路造成額外的負載,從而使情況變得更糟,並可能導致級聯失敗。
出於這個原因,再平衡的過程中有人参與是一件好事。這比完全自動的過程慢,但可以幫助防止運維意外。 出於這個原因,再平衡的過程中有人参與是一件好事。這比完全自動的過程慢,但可以幫助防止運維意外。
## 請求路由 ## 請求路由
現在我們已經將資料集分割到多個機器上執行的多個節點上。但是仍然存在一個懸而未決的問題當客戶想要發出請求時如何知道要連線哪個節點隨著分割槽重新平衡分割槽對節點的分配也發生變化。為了回答這個問題需要有人知曉這些變化如果我想讀或寫鍵“foo”需要連線哪個IP地址和埠號 現在我們已經將資料集分割到多個機器上執行的多個節點上。但是仍然存在一個懸而未決的問題當客戶想要發出請求時如何知道要連線哪個節點隨著分割槽重新平衡分割槽對節點的分配也發生變化。為了回答這個問題需要有人知曉這些變化如果我想讀或寫鍵“foo”需要連線哪個IP地址和埠號
這個問題可以概括為 **服務發現(service discovery)** 它不僅限於資料庫。任何可透過網路訪問的軟體都有這個問題特別是如果它的目標是高可用性在多臺機器上執行冗餘配置。許多公司已經編寫了自己的內部服務發現工具其中許多已經作為開源釋出【30】。 這個問題可以概括為 **服務發現(service discovery)** 它不僅限於資料庫。任何可透過網路訪問的軟體都有這個問題特別是如果它的目標是高可用性在多臺機器上執行冗餘配置。許多公司已經編寫了自己的內部服務發現工具其中許多已經作為開源釋出【30】。
概括來說這個問題有幾種不同的方案如圖6-7所示: 概括來說這個問題有幾種不同的方案如圖6-7所示:
@ -268,47 +268,47 @@
**圖6-7 將請求路由到正確節點的三種不同方式。** **圖6-7 將請求路由到正確節點的三種不同方式。**
這是一個具有挑戰性的問題,因為重要的是所有參與者都達成共識 - 否則請求將被髮送到錯誤的節點,得不到正確的處理。 在分散式系統中有達成共識的協議,但很難正確地實現(見[第九章](ch9.md))。 這是一個具有挑戰性的問題,因為重要的是所有參與者都達成共識 - 否則請求將被髮送到錯誤的節點,得不到正確的處理。 在分散式系統中有達成共識的協議,但很難正確地實現(見[第九章](ch9.md))。
許多分散式資料系統都依賴於一個獨立的協調服務比如ZooKeeper來跟蹤叢集元資料如[圖6-8](../img/fig6-8.png)所示。 每個節點在ZooKeeper中註冊自己ZooKeeper維護分割槽到節點的可靠對映。 其他參與者如路由層或分割槽感知客戶端可以在ZooKeeper中訂閱此資訊。 只要分割槽分配發生了改變或者叢集中新增或刪除了一個節點ZooKeeper就會通知路由層使路由資訊保持最新狀態。 許多分散式資料系統都依賴於一個獨立的協調服務比如ZooKeeper來跟蹤叢集元資料如[圖6-8](../img/fig6-8.png)所示。 每個節點在ZooKeeper中註冊自己ZooKeeper維護分割槽到節點的可靠對映。 其他參與者如路由層或分割槽感知客戶端可以在ZooKeeper中訂閱此資訊。 只要分割槽分配發生了改變或者叢集中新增或刪除了一個節點ZooKeeper就會通知路由層使路由資訊保持最新狀態。
![](../img/fig6-8.png) ![](../img/fig6-8.png)
**圖6-8 使用ZooKeeper跟蹤分割槽分配給節點。** **圖6-8 使用ZooKeeper跟蹤分割槽分配給節點。**
例如LinkedIn的Espresso使用Helix 【31】進行叢集管理依靠ZooKeeper實現瞭如[圖6-8](../img/fig6-8.png)所示的路由層。 HBaseSolrCloud和Kafka也使用ZooKeeper來跟蹤分割槽分配。 MongoDB具有類似的體系結構但它依賴於自己的**配置伺服器config server** 實現和mongos守護程序作為路由層。 例如LinkedIn的Espresso使用Helix 【31】進行叢集管理依靠ZooKeeper實現瞭如[圖6-8](../img/fig6-8.png)所示的路由層。 HBaseSolrCloud和Kafka也使用ZooKeeper來跟蹤分割槽分配。 MongoDB具有類似的體系結構但它依賴於自己的**配置伺服器config server** 實現和mongos守護程序作為路由層。
Cassandra和Riak採取不同的方法他們在節點之間使用**流言協議gossip protocol** 來傳播叢集狀態的變化。請求可以傳送到任意節點,該節點會轉發到包含所請求的分割槽的適當節點([圖6-7](../img/fig6-7.png)中的方法1。這個模型在資料庫節點中增加了更多的複雜性但是避免了對像ZooKeeper這樣的外部協調服務的依賴。 Cassandra和Riak採取不同的方法他們在節點之間使用**流言協議gossip protocol** 來傳播叢集狀態的變化。請求可以傳送到任意節點,該節點會轉發到包含所請求的分割槽的適當節點([圖6-7](../img/fig6-7.png)中的方法1。這個模型在資料庫節點中增加了更多的複雜性但是避免了對像ZooKeeper這樣的外部協調服務的依賴。
Couchbase不會自動重新平衡這簡化了設計。通常情況下它配置了一個名為moxi的路由層它會從叢集節點了解路由變化【32】。 Couchbase不會自動重新平衡這簡化了設計。通常情況下它配置了一個名為moxi的路由層它會從叢集節點了解路由變化【32】。
當使用路由層或向隨機節點發送請求時客戶端仍然需要找到要連線的IP地址。這些地址並不像分割槽的節點分佈變化的那麼快所以使用DNS通常就足夠了。 當使用路由層或向隨機節點發送請求時客戶端仍然需要找到要連線的IP地址。這些地址並不像分割槽的節點分佈變化的那麼快所以使用DNS通常就足夠了。
### 執行並行查詢 ### 執行並行查詢
到目前為止,我們只關注讀取或寫入單個鍵的非常簡單的查詢(加上基於文件分割槽的次級索引場景下的分散/聚集查詢。這也是大多數NoSQL分散式資料儲存所支援的訪問層級。 到目前為止,我們只關注讀取或寫入單個鍵的非常簡單的查詢(加上基於文件分割槽的次級索引場景下的分散/聚集查詢。這也是大多數NoSQL分散式資料儲存所支援的訪問層級。
然而,通常用於分析的**大規模並行處理MPP, Massively parallel processing** 關係型資料庫產品在其支援的查詢型別方面要複雜得多。一個典型的資料倉庫查詢包含多個連線,過濾,分組和聚合操作。 MPP查詢最佳化器將這個複雜的查詢分解成許多執行階段和分割槽其中許多可以在資料庫叢集的不同節點上並行執行。涉及掃描大規模資料集的查詢特別受益於這種並行執行。 然而,通常用於分析的**大規模並行處理MPP, Massively parallel processing** 關係型資料庫產品在其支援的查詢型別方面要複雜得多。一個典型的資料倉庫查詢包含多個連線,過濾,分組和聚合操作。 MPP查詢最佳化器將這個複雜的查詢分解成許多執行階段和分割槽其中許多可以在資料庫叢集的不同節點上並行執行。涉及掃描大規模資料集的查詢特別受益於這種並行執行。
資料倉庫查詢的快速並行執行是一個專門的話題,由於分析有很重要的商業意義,可以帶來很多利益。我們將在[第十章](ch10.md)討論並行查詢執行的一些技巧。有關並行資料庫中使用的技術的更詳細的概述請參閱參考文獻【1,33】。 資料倉庫查詢的快速並行執行是一個專門的話題,由於分析有很重要的商業意義,可以帶來很多利益。我們將在[第十章](ch10.md)討論並行查詢執行的一些技巧。有關並行資料庫中使用的技術的更詳細的概述請參閱參考文獻【1,33】。
## 本章小結 ## 本章小結
在本章中,我們探討了將大資料集劃分成更小的子集的不同方法。資料量非常大的時候,在單臺機器上儲存和處理不再可行,而分割槽則十分必要。分割槽的目標是在多臺機器上均勻分佈資料和查詢負載,避免出現熱點(負載不成比例的節點)。這需要選擇適合於您的資料的分割槽方案,並在將節點新增到叢集或從叢集刪除時進行分割槽再平衡。 在本章中,我們探討了將大資料集劃分成更小的子集的不同方法。資料量非常大的時候,在單臺機器上儲存和處理不再可行,而分割槽則十分必要。分割槽的目標是在多臺機器上均勻分佈資料和查詢負載,避免出現熱點(負載不成比例的節點)。這需要選擇適合於您的資料的分割槽方案,並在將節點新增到叢集或從叢集刪除時進行分割槽再平衡。
我們討論了兩種主要的分割槽方法: 我們討論了兩種主要的分割槽方法:
***鍵範圍分割槽*** ***鍵範圍分割槽***
其中鍵是有序的,並且分割槽擁有從某個最小值到某個最大值的所有鍵。排序的優勢在於可以進行有效的範圍查詢,但是如果應用程式經常訪問相鄰的鍵,則存在熱點的風險。 其中鍵是有序的,並且分割槽擁有從某個最小值到某個最大值的所有鍵。排序的優勢在於可以進行有效的範圍查詢,但是如果應用程式經常訪問相鄰的鍵,則存在熱點的風險。
在這種方法中,當分割槽變得太大時,通常將分割槽分成兩個子分割槽,動態地再平衡分割槽。 在這種方法中,當分割槽變得太大時,通常將分割槽分成兩個子分割槽,動態地再平衡分割槽。
***雜湊分割槽*** ***雜湊分割槽***
雜湊函式應用於每個鍵,分割槽擁有一定範圍的雜湊。這種方法破壞了鍵的排序,使得範圍查詢效率低下,但可以更均勻地分配負載。 雜湊函式應用於每個鍵,分割槽擁有一定範圍的雜湊。這種方法破壞了鍵的排序,使得範圍查詢效率低下,但可以更均勻地分配負載。
透過雜湊進行分割槽時,通常先提前建立固定數量的分割槽,為每個節點分配多個分割槽,並在新增或刪除節點時將整個分割槽從一個節點移動到另一個節點。也可以使用動態分割槽。 透過雜湊進行分割槽時,通常先提前建立固定數量的分割槽,為每個節點分配多個分割槽,並在新增或刪除節點時將整個分割槽從一個節點移動到另一個節點。也可以使用動態分割槽。
@ -321,7 +321,7 @@
最後,我們討論了將查詢路由到適當的分割槽的技術,從簡單的分割槽負載平衡到複雜的並行查詢執行引擎。 最後,我們討論了將查詢路由到適當的分割槽的技術,從簡單的分割槽負載平衡到複雜的並行查詢執行引擎。
按照設計,多數情況下每個分割槽是獨立執行的 — 這就是分割槽資料庫可以伸縮到多臺機器的原因。但是,需要寫入多個分割槽的操作結果可能難以預料:例如,如果寫入一個分割槽成功,但另一個分割槽失敗,會發生什麼情況?我們將在下面的章節中討論這個問題。 按照設計,多數情況下每個分割槽是獨立執行的 — 這就是分割槽資料庫可以伸縮到多臺機器的原因。但是,需要寫入多個分割槽的操作結果可能難以預料:例如,如果寫入一個分割槽成功,但另一個分割槽失敗,會發生什麼情況?我們將在下面的章節中討論這個問題。

View File

@ -21,23 +21,23 @@
為了實現可靠性,系統必須處理這些故障,確保它們不會導致整個系統的災難性故障。但是實現容錯機制工作量巨大。需要仔細考慮所有可能出錯的事情,並進行大量的測試,以確保解決方案真正管用。 為了實現可靠性,系統必須處理這些故障,確保它們不會導致整個系統的災難性故障。但是實現容錯機制工作量巨大。需要仔細考慮所有可能出錯的事情,並進行大量的測試,以確保解決方案真正管用。
數十年來,**事務transaction** 一直是簡化這些問題的首選機制。事務是應用程式將多個讀寫操作組合成一個邏輯單元的一種方式。從概念上講,事務中的所有讀寫操作被視作單個操作來執行:整個事務要麼成功(**提交commit**)要麼失敗(**中止abort****回滾rollback**)。如果失敗,應用程式可以安全地重試。對於事務來說,應用程式的錯誤處理變得簡單多了,因為它不用再擔心部分失敗的情況了,即某些操作成功,某些失敗(無論出於何種原因)。 數十年來,**事務transaction** 一直是簡化這些問題的首選機制。事務是應用程式將多個讀寫操作組合成一個邏輯單元的一種方式。從概念上講,事務中的所有讀寫操作被視作單個操作來執行:整個事務要麼成功(**提交commit**)要麼失敗(**中止abort****回滾rollback**)。如果失敗,應用程式可以安全地重試。對於事務來說,應用程式的錯誤處理變得簡單多了,因為它不用再擔心部分失敗的情況了,即某些操作成功,某些失敗(無論出於何種原因)。
和事務打交道時間長了,你可能會覺得它顯而易見。但我們不應將其視為理所當然。事務不是天然存在的;它們是為了**簡化應用程式設計模型**而建立的。透過使用事務,應用程式可以自由地忽略某些潛在的錯誤情況和併發問題,因為資料庫會替應用處理好這些。(我們稱之為**安全保證safety guarantees**)。 和事務打交道時間長了,你可能會覺得它顯而易見。但我們不應將其視為理所當然。事務不是天然存在的;它們是為了**簡化應用程式設計模型**而建立的。透過使用事務,應用程式可以自由地忽略某些潛在的錯誤情況和併發問題,因為資料庫會替應用處理好這些。(我們稱之為**安全保證safety guarantees**)。
並不是所有的應用都需要事務,有時候弱化事務保證、或完全放棄事務也是有好處的(例如,為了獲得更高效能或更高可用性)。一些安全屬性也可以在沒有事務的情況下實現。 並不是所有的應用都需要事務,有時候弱化事務保證、或完全放棄事務也是有好處的(例如,為了獲得更高效能或更高可用性)。一些安全屬性也可以在沒有事務的情況下實現。
怎樣知道你是否需要事務?為了回答這個問題,首先需要確切理解事務可以提供的安全保障,以及它們的代價。儘管乍看事務似乎很簡單,但實際上有許多微妙但重要的細節在起作用。 怎樣知道你是否需要事務?為了回答這個問題,首先需要確切理解事務可以提供的安全保障,以及它們的代價。儘管乍看事務似乎很簡單,但實際上有許多微妙但重要的細節在起作用。
本章將研究許多出錯案例,並探索資料庫用於防範這些問題的演算法。尤其會深入**併發控制**的領域,討論各種可能發生的競爭條件,以及資料庫如何實現**讀已提交read committed****快照隔離snapshot isolation** 和**可序列化serializability** 等隔離級別。 本章將研究許多出錯案例,並探索資料庫用於防範這些問題的演算法。尤其會深入**併發控制**的領域,討論各種可能發生的競爭條件,以及資料庫如何實現**讀已提交read committed****快照隔離snapshot isolation** 和**可序列化serializability** 等隔離級別。
本章同時適用於單機資料庫與分散式資料庫;在[第八章](ch8.md)中將重點討論僅出現在分散式系統中的特殊挑戰。 本章同時適用於單機資料庫與分散式資料庫;在[第八章](ch8.md)中將重點討論僅出現在分散式系統中的特殊挑戰。
## 事務的棘手概念 ## 事務的棘手概念
現今,幾乎所有的關係型資料庫和一些非關係資料庫都支援**事務**。其中大多數遵循IBM System R第一個SQL資料庫在1975年引入的風格【1,2,3】。40年裡儘管一些實現細節發生了變化但總體思路大同小異MySQLPostgreSQLOracleSQL Server等資料庫中的事務支援與System R異乎尋常地相似。 現今,幾乎所有的關係型資料庫和一些非關係資料庫都支援**事務**。其中大多數遵循IBM System R第一個SQL資料庫在1975年引入的風格【1,2,3】。40年裡儘管一些實現細節發生了變化但總體思路大同小異MySQLPostgreSQLOracleSQL Server等資料庫中的事務支援與System R異乎尋常地相似。
2000年以後非關係NoSQL資料庫開始普及。它們的目標是在關係資料庫的現狀基礎上透過提供新的資料模型選擇請參閱[第二章](ch2.md)並預設包含複製第五章和分割槽第六章來進一步提升。事務是這次運動的主要犧牲品這些新一代資料庫中的許多資料庫完全放棄了事務或者重新定義了這個詞描述比以前所理解的更弱得多的一套保證【4】。 2000年以後非關係NoSQL資料庫開始普及。它們的目標是在關係資料庫的現狀基礎上透過提供新的資料模型選擇請參閱[第二章](ch2.md)並預設包含複製第五章和分割槽第六章來進一步提升。事務是這次運動的主要犧牲品這些新一代資料庫中的許多資料庫完全放棄了事務或者重新定義了這個詞描述比以前所理解的更弱得多的一套保證【4】。
@ -304,11 +304,11 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
***備份*** ***備份***
進行備份需要複製整個資料庫,對大型資料庫而言可能需要花費數小時才能完成。備份程序執行時,資料庫仍然會接受寫入操作。因此備份可能會包含一些舊的部分和一些新的部分。如果從這樣的備份中恢復,那麼不一致(如消失的錢)就會變成永久的。 進行備份需要複製整個資料庫,對大型資料庫而言可能需要花費數小時才能完成。備份程序執行時,資料庫仍然會接受寫入操作。因此備份可能會包含一些舊的部分和一些新的部分。如果從這樣的備份中恢復,那麼不一致(如消失的錢)就會變成永久的。
***分析查詢和完整性檢查*** ***分析查詢和完整性檢查***
有時,您可能需要執行一個查詢,掃描大部分的資料庫。這樣的查詢在分析中很常見(請參閱“[事務處理還是分析?](ch3.md#事務處理還是分析?)”),也可能是定期完整性檢查(即監視資料損壞)的一部分。如果這些查詢在不同時間點觀察資料庫的不同部分,則可能會返回毫無意義的結果。 有時,您可能需要執行一個查詢,掃描大部分的資料庫。這樣的查詢在分析中很常見(請參閱“[事務處理還是分析?](ch3.md#事務處理還是分析?)”),也可能是定期完整性檢查(即監視資料損壞)的一部分。如果這些查詢在不同時間點觀察資料庫的不同部分,則可能會返回毫無意義的結果。
**快照隔離snapshot isolation**【28】是這個問題最常見的解決方案。想法是每個事務都從資料庫的**一致快照consistent snapshot** 中讀取——也就是說,事務可以看到事務開始時在資料庫中提交的所有資料。即使這些資料隨後被另一個事務更改,每個事務也只能看到該特定時間點的舊資料。 **快照隔離snapshot isolation**【28】是這個問題最常見的解決方案。想法是每個事務都從資料庫的**一致快照consistent snapshot** 中讀取——也就是說,事務可以看到事務開始時在資料庫中提交的所有資料。即使這些資料隨後被另一個事務更改,每個事務也只能看到該特定時間點的舊資料。
@ -613,15 +613,15 @@ COMMIT;
#### 在儲存過程中封裝事務 #### 在儲存過程中封裝事務
在資料庫的早期階段,意圖是資料庫事務可以包含整個使用者活動流程。例如,預訂機票是一個多階段的過程(搜尋路線,票價和可用座位,決定行程,在每段行程的航班上訂座,輸入乘客資訊,付款)。資料庫設計者認為,如果整個過程是一個事務,那麼它就可以被原子化地執行。 在資料庫的早期階段,意圖是資料庫事務可以包含整個使用者活動流程。例如,預訂機票是一個多階段的過程(搜尋路線,票價和可用座位,決定行程,在每段行程的航班上訂座,輸入乘客資訊,付款)。資料庫設計者認為,如果整個過程是一個事務,那麼它就可以被原子化地執行。
不幸的是人類做出決定和迴應的速度非常緩慢。如果資料庫事務需要等待來自使用者的輸入則資料庫需要支援潛在的大量併發事務其中大部分是空閒的。大多數資料庫不能高效完成這項工作因此幾乎所有的OLTP應用程式都避免在事務中等待互動式的使用者輸入以此來保持事務的簡短。在Web上這意味著事務在同一個HTTP請求中被提交——一個事務不會跨越多個請求。一個新的HTTP請求開始一個新的事務。 不幸的是人類做出決定和迴應的速度非常緩慢。如果資料庫事務需要等待來自使用者的輸入則資料庫需要支援潛在的大量併發事務其中大部分是空閒的。大多數資料庫不能高效完成這項工作因此幾乎所有的OLTP應用程式都避免在事務中等待互動式的使用者輸入以此來保持事務的簡短。在Web上這意味著事務在同一個HTTP請求中被提交——一個事務不會跨越多個請求。一個新的HTTP請求開始一個新的事務。
即使已經將人類從關鍵路徑中排除,事務仍然以互動式的客戶端/伺服器風格執行,一次一個語句。應用程式進行查詢,讀取結果,可能根據第一個查詢的結果進行另一個查詢,依此類推。查詢和結果在應用程式程式碼(在一臺機器上執行)和資料庫伺服器(在另一臺機器上)之間來回傳送。 即使已經將人類從關鍵路徑中排除,事務仍然以互動式的客戶端/伺服器風格執行,一次一個語句。應用程式進行查詢,讀取結果,可能根據第一個查詢的結果進行另一個查詢,依此類推。查詢和結果在應用程式程式碼(在一臺機器上執行)和資料庫伺服器(在另一臺機器上)之間來回傳送。
在這種互動式的事務方式中,應用程式和資料庫之間的網路通訊耗費了大量的時間。如果不允許在資料庫中進行併發處理,且一次只處理一個事務,則吞吐量將會非常糟糕,因為資料庫大部分的時間都花費在等待應用程式發出當前事務的下一個查詢。在這種資料庫中,為了獲得合理的效能,需要同時處理多個事務。 在這種互動式的事務方式中,應用程式和資料庫之間的網路通訊耗費了大量的時間。如果不允許在資料庫中進行併發處理,且一次只處理一個事務,則吞吐量將會非常糟糕,因為資料庫大部分的時間都花費在等待應用程式發出當前事務的下一個查詢。在這種資料庫中,為了獲得合理的效能,需要同時處理多個事務。
出於這個原因,具有單執行緒序列事務處理的系統不允許互動式的多語句事務。取而代之,應用程式必須提前將整個事務程式碼作為儲存過程提交給資料庫。這些方法之間的差異如[圖7-9](../img/fig7-9.png) 所示。如果事務所需的所有資料都在記憶體中則儲存過程可以非常快地執行而不用等待任何網路或磁碟I/O。 出於這個原因,具有單執行緒序列事務處理的系統不允許互動式的多語句事務。取而代之,應用程式必須提前將整個事務程式碼作為儲存過程提交給資料庫。這些方法之間的差異如[圖7-9](../img/fig7-9.png) 所示。如果事務所需的所有資料都在記憶體中則儲存過程可以非常快地執行而不用等待任何網路或磁碟I/O。
![](../img/fig7-9.png) ![](../img/fig7-9.png)
@ -836,45 +836,45 @@ WHERE room_id = 123 AND
***髒讀*** ***髒讀***
一個客戶端讀取到另一個客戶端尚未提交的寫入。**讀已提交**或更強的隔離級別可以防止髒讀。 一個客戶端讀取到另一個客戶端尚未提交的寫入。**讀已提交**或更強的隔離級別可以防止髒讀。
***髒寫*** ***髒寫***
一個客戶端覆蓋寫入了另一個客戶端尚未提交的寫入。幾乎所有的事務實現都可以防止髒寫。 一個客戶端覆蓋寫入了另一個客戶端尚未提交的寫入。幾乎所有的事務實現都可以防止髒寫。
***讀取偏差(不可重複讀)*** ***讀取偏差(不可重複讀)***
在同一個事務中,客戶端在不同的時間點會看見資料庫的不同狀態。**快照隔離**經常用於解決這個問題,它允許事務從一個特定時間點的一致性快照中讀取資料。快照隔離通常使用**多版本併發控制MVCC** 來實現。 在同一個事務中,客戶端在不同的時間點會看見資料庫的不同狀態。**快照隔離**經常用於解決這個問題,它允許事務從一個特定時間點的一致性快照中讀取資料。快照隔離通常使用**多版本併發控制MVCC** 來實現。
***更新丟失*** ***更新丟失***
兩個客戶端同時執行**讀取-修改-寫入序列**。其中一個寫操作,在沒有合併另一個寫入變更情況下,直接覆蓋了另一個寫操作的結果。所以導致資料丟失。快照隔離的一些實現可以自動防止這種異常,而另一些實現則需要手動鎖定(`SELECT FOR UPDATE`)。 兩個客戶端同時執行**讀取-修改-寫入序列**。其中一個寫操作,在沒有合併另一個寫入變更情況下,直接覆蓋了另一個寫操作的結果。所以導致資料丟失。快照隔離的一些實現可以自動防止這種異常,而另一些實現則需要手動鎖定(`SELECT FOR UPDATE`)。
***寫偏差*** ***寫偏差***
一個事務讀取一些東西,根據它所看到的值作出決定,並將該決定寫入資料庫。但是,寫入時,該決定的前提不再是真實的。只有可序列化的隔離才能防止這種異常。 一個事務讀取一些東西,根據它所看到的值作出決定,並將該決定寫入資料庫。但是,寫入時,該決定的前提不再是真實的。只有可序列化的隔離才能防止這種異常。
***幻讀*** ***幻讀***
事務讀取符合某些搜尋條件的物件。另一個客戶端進行寫入,影響搜尋結果。快照隔離可以防止直接的幻像讀取,但是寫入偏差上下文中的幻讀需要特殊處理,例如索引範圍鎖定。 事務讀取符合某些搜尋條件的物件。另一個客戶端進行寫入,影響搜尋結果。快照隔離可以防止直接的幻像讀取,但是寫入偏差上下文中的幻讀需要特殊處理,例如索引範圍鎖定。
弱隔離級別可以防止其中一些異常情況,但要求你,也就是應用程式開發人員手動處理剩餘那些(例如,使用顯式鎖定)。只有可序列化的隔離才能防範所有這些問題。我們討論了實現可序列化事務的三種不同方法: 弱隔離級別可以防止其中一些異常情況,但要求你,也就是應用程式開發人員手動處理剩餘那些(例如,使用顯式鎖定)。只有可序列化的隔離才能防範所有這些問題。我們討論了實現可序列化事務的三種不同方法:
***字面意義上的序列執行*** ***字面意義上的序列執行***
如果每個事務的執行速度非常快並且事務吞吐量足夠低足以在單個CPU核上處理這是一個簡單而有效的選擇。 如果每個事務的執行速度非常快並且事務吞吐量足夠低足以在單個CPU核上處理這是一個簡單而有效的選擇。
***兩階段鎖定*** ***兩階段鎖定***
數十年來,兩階段鎖定一直是實現可序列化的標準方式,但是許多應用出於效能問題的考慮避免使用它。 數十年來,兩階段鎖定一直是實現可序列化的標準方式,但是許多應用出於效能問題的考慮避免使用它。
***可序列化快照隔離SSI*** ***可序列化快照隔離SSI***
一個相當新的演算法,避免了先前方法的大部分缺點。它使用樂觀的方法,允許事務執行而無需阻塞。當一個事務想要提交時,它會進行檢查,如果執行不可序列化,事務就會被中止。 一個相當新的演算法,避免了先前方法的大部分缺點。它使用樂觀的方法,允許事務執行而無需阻塞。當一個事務想要提交時,它會進行檢查,如果執行不可序列化,事務就會被中止。
本章中的示例主要是在關係資料模型的上下文中。但是,正如在討論中,無論使用哪種資料模型,如“**[多物件事務的需求](#多物件事務的需求)**”中所討論的,事務都是有價值的資料庫功能。 本章中的示例主要是在關係資料模型的上下文中。但是,正如在討論中,無論使用哪種資料模型,如“**[多物件事務的需求](#多物件事務的需求)**”中所討論的,事務都是有價值的資料庫功能。
本章主要是在單機資料庫的上下文中,探討了各種想法和演算法。分散式資料庫中的事務,則引入了一系列新的困難挑戰,我們將在接下來的兩章中討論。 本章主要是在單機資料庫的上下文中,探討了各種想法和演算法。分散式資料庫中的事務,則引入了一系列新的困難挑戰,我們將在接下來的兩章中討論。

View File

@ -16,36 +16,36 @@
[TOC] [TOC]
最近幾章中反覆出現的主題是,系統如何處理錯誤的事情。例如,我們討論了**副本故障切換**(“[處理節點中斷](ch5.md#處理節點宕機)”),**複製延遲**(“[複製延遲問題](ch5.md#複製延遲問題)”)和事務控制(“[弱隔離級別](ch7.md#弱隔離級別)”)。當我們瞭解可能在實際系統中出現的各種邊緣情況時,我們會更好地處理它們。 最近幾章中反覆出現的主題是,系統如何處理錯誤的事情。例如,我們討論了**副本故障切換**(“[處理節點中斷](ch5.md#處理節點宕機)”),**複製延遲**(“[複製延遲問題](ch5.md#複製延遲問題)”)和事務控制(“[弱隔離級別](ch7.md#弱隔離級別)”)。當我們瞭解可能在實際系統中出現的各種邊緣情況時,我們會更好地處理它們。
但是,儘管我們已經談了很多錯誤,但之前幾章仍然過於樂觀。現實更加黑暗。我們現在將悲觀主義最大化,假設任何可能出錯的東西**都會**出錯[^i]。(經驗豐富的系統運維會告訴你,這是一個合理的假設。如果你問得好,他們可能會一邊治療心理創傷一邊告訴你一些可怕的故事) 但是,儘管我們已經談了很多錯誤,但之前幾章仍然過於樂觀。現實更加黑暗。我們現在將悲觀主義最大化,假設任何可能出錯的東西**都會**出錯[^i]。(經驗豐富的系統運維會告訴你,這是一個合理的假設。如果你問得好,他們可能會一邊治療心理創傷一邊告訴你一些可怕的故事)
[^i]: 除了一個例外:我們將假定故障是非拜占庭式的(請參閱“[拜占庭故障](#拜占庭故障)”)。 [^i]: 除了一個例外:我們將假定故障是非拜占庭式的(請參閱“[拜占庭故障](#拜占庭故障)”)。
使用分散式系統與在一臺計算機上編寫軟體有著根本的區別主要的區別在於有許多新穎和刺激的方法可以使事情出錯【1,2】。在這一章中我們將瞭解實踐中出現的問題理解我們能夠依賴和不可以依賴的東西。 使用分散式系統與在一臺計算機上編寫軟體有著根本的區別主要的區別在於有許多新穎和刺激的方法可以使事情出錯【1,2】。在這一章中我們將瞭解實踐中出現的問題理解我們能夠依賴和不可以依賴的東西。
最後,作為工程師,我們的任務是構建能夠完成工作的系統(即滿足使用者期望的保證),儘管一切都出錯了。 在[第九章](ch9.md)中,我們將看看一些可以在分散式系統中提供這種保證的演算法的例子。 但首先,在本章中,我們必須瞭解我們面臨的挑戰。 最後,作為工程師,我們的任務是構建能夠完成工作的系統(即滿足使用者期望的保證),儘管一切都出錯了。 在[第九章](ch9.md)中,我們將看看一些可以在分散式系統中提供這種保證的演算法的例子。 但首先,在本章中,我們必須瞭解我們面臨的挑戰。
本章對分散式系統中可能出現的問題進行徹底的悲觀和沮喪的總結。 我們將研究網路的問題(“[不可靠的網路](#不可靠的網路)”); 時鐘和時序問題(“[不可靠的時鐘](#不可靠的時鐘)”); 我們將討論他們可以避免的程度。 所有這些問題的後果都是困惑的,所以我們將探索如何思考一個分散式系統的狀態,以及如何推理髮生的事情(“[知識、真相與謊言](#知識、真相與謊言)”)。 本章對分散式系統中可能出現的問題進行徹底的悲觀和沮喪的總結。 我們將研究網路的問題(“[不可靠的網路](#不可靠的網路)”); 時鐘和時序問題(“[不可靠的時鐘](#不可靠的時鐘)”); 我們將討論他們可以避免的程度。 所有這些問題的後果都是困惑的,所以我們將探索如何思考一個分散式系統的狀態,以及如何推理髮生的事情(“[知識、真相與謊言](#知識、真相與謊言)”)。
## 故障與部分失效 ## 故障與部分失效
當你在一臺計算機上編寫一個程式時,它通常會以一種相當可預測的方式執行:無論是工作還是不工作。充滿錯誤的軟體可能會讓人覺得電腦有時候也會有“糟糕的一天”(這種問題通常是重新啟動就恢復了),但這主要是軟體寫得不好的結果。 當你在一臺計算機上編寫一個程式時,它通常會以一種相當可預測的方式執行:無論是工作還是不工作。充滿錯誤的軟體可能會讓人覺得電腦有時候也會有“糟糕的一天”(這種問題通常是重新啟動就恢復了),但這主要是軟體寫得不好的結果。
單個計算機上的軟體沒有根本性的不可靠原因:當硬體正常工作時,相同的操作總是產生相同的結果(這是確定性的)。如果存在硬體問題(例如,記憶體損壞或聯結器鬆動),其後果通常是整個系統故障(例如,核心恐慌,“藍色畫面宕機”,啟動失敗)。裝有良好軟體的個人計算機通常要麼功能完好,要麼完全失效,而不是介於兩者之間。 單個計算機上的軟體沒有根本性的不可靠原因:當硬體正常工作時,相同的操作總是產生相同的結果(這是確定性的)。如果存在硬體問題(例如,記憶體損壞或聯結器鬆動),其後果通常是整個系統故障(例如,核心恐慌,“藍色畫面宕機”,啟動失敗)。裝有良好軟體的個人計算機通常要麼功能完好,要麼完全失效,而不是介於兩者之間。
這是計算機設計中的一個有意的選擇:如果發生內部錯誤,我們寧願電腦完全崩潰,而不是返回錯誤的結果,因為錯誤的結果很難處理。因為計算機隱藏了模糊不清的物理實現,並呈現出一個理想化的系統模型,並以數學一樣的完美的方式運作。 CPU指令總是做同樣的事情如果您將一些資料寫入記憶體或磁碟那麼這些資料將保持不變並且不會被隨機破壞。從第一臺數字計算機開始*始終正確地計算*這個設計目標貫穿始終【3】。 這是計算機設計中的一個有意的選擇:如果發生內部錯誤,我們寧願電腦完全崩潰,而不是返回錯誤的結果,因為錯誤的結果很難處理。因為計算機隱藏了模糊不清的物理實現,並呈現出一個理想化的系統模型,並以數學一樣的完美的方式運作。 CPU指令總是做同樣的事情如果您將一些資料寫入記憶體或磁碟那麼這些資料將保持不變並且不會被隨機破壞。從第一臺數字計算機開始*始終正確地計算*這個設計目標貫穿始終【3】。
當你編寫執行在多臺計算機上的軟體時情況有本質上的區別。在分散式系統中我們不再處於理想化的系統模型中我們別無選擇只能面對現實世界的混亂現實。而在現實世界中各種各樣的事情都可能會出現問題【4】如下面的軼事所述 當你編寫執行在多臺計算機上的軟體時情況有本質上的區別。在分散式系統中我們不再處於理想化的系統模型中我們別無選擇只能面對現實世界的混亂現實。而在現實世界中各種各樣的事情都可能會出現問題【4】如下面的軼事所述
> 在我有限的經驗中,我已經和很多東西打過交道:單個**資料中心DC** 中長期存在的網路分割槽配電單元PDU故障交換機故障整個機架的意外重啟整個資料中心主幹網路故障整個資料中心的電源故障以及一個低血糖的司機把他的福特皮卡撞在資料中心的HVAC加熱通風和空調系統上。而且我甚至不是一個運維。 > 在我有限的經驗中,我已經和很多東西打過交道:單個**資料中心DC** 中長期存在的網路分割槽配電單元PDU故障交換機故障整個機架的意外重啟整個資料中心主幹網路故障整個資料中心的電源故障以及一個低血糖的司機把他的福特皮卡撞在資料中心的HVAC加熱通風和空調系統上。而且我甚至不是一個運維。
> >
> ——柯達黑爾 > ——柯達黑爾
在分散式系統中,儘管系統的其他部分工作正常,但系統的某些部分可能會以某種不可預知的方式被破壞。這被稱為**部分失效partial failure**。難點在於部分失效是**不確定性的nonderterministic**:如果你試圖做任何涉及多個節點和網路的事情,它有時可能會工作,有時會出現不可預知的失敗。正如我們將要看到的,你甚至不知道是否成功了,因為訊息透過網路傳播的時間也是不確定的! 在分散式系統中,儘管系統的其他部分工作正常,但系統的某些部分可能會以某種不可預知的方式被破壞。這被稱為**部分失效partial failure**。難點在於部分失效是**不確定性的nonderterministic**:如果你試圖做任何涉及多個節點和網路的事情,它有時可能會工作,有時會出現不可預知的失敗。正如我們將要看到的,你甚至不知道是否成功了,因為訊息透過網路傳播的時間也是不確定的!
這種不確定性和部分失效的可能性使得分散式系統難以工作【5】。 這種不確定性和部分失效的可能性使得分散式系統難以工作【5】。
### 雲端計算與超級計算機 ### 雲端計算與超級計算機
@ -73,9 +73,9 @@
如果要使分散式系統工作,就必須接受部分故障的可能性,並在軟體中建立容錯機制。換句話說,我們需要從不可靠的元件構建一個可靠的系統。 (正如“[可靠性](ch1.md#可靠性)”中所討論的那樣,沒有完美的可靠性,所以我們需要理解我們可以實際承諾的極限。) 如果要使分散式系統工作,就必須接受部分故障的可能性,並在軟體中建立容錯機制。換句話說,我們需要從不可靠的元件構建一個可靠的系統。 (正如“[可靠性](ch1.md#可靠性)”中所討論的那樣,沒有完美的可靠性,所以我們需要理解我們可以實際承諾的極限。)
即使在只有少數節點的小型系統中,考慮部分故障也是很重要的。在一個小系統中,很可能大部分元件在大部分時間都正常工作。然而,遲早會有一部分系統出現故障,軟體必須以某種方式處理。故障處理必須是軟體設計的一部分,並且作為軟體的運維,您需要知道在發生故障的情況下,軟體可能會表現出怎樣的行為。 即使在只有少數節點的小型系統中,考慮部分故障也是很重要的。在一個小系統中,很可能大部分元件在大部分時間都正常工作。然而,遲早會有一部分系統出現故障,軟體必須以某種方式處理。故障處理必須是軟體設計的一部分,並且作為軟體的運維,您需要知道在發生故障的情況下,軟體可能會表現出怎樣的行為。
簡單地假設缺陷很罕見並希望始終保持最好的狀況是不明智的。考慮一系列可能的錯誤(甚至是不太可能的錯誤),並在測試環境中人為地建立這些情況來檢視會發生什麼是非常重要的。在分散式系統中,懷疑,悲觀和偏執狂是值得的。 簡單地假設缺陷很罕見並希望始終保持最好的狀況是不明智的。考慮一系列可能的錯誤(甚至是不太可能的錯誤),並在測試環境中人為地建立這些情況來檢視會發生什麼是非常重要的。在分散式系統中,懷疑,悲觀和偏執狂是值得的。
> #### 從不可靠的元件構建可靠的系統 > #### 從不可靠的元件構建可靠的系統
> >
@ -92,11 +92,11 @@
## 不可靠的網路 ## 不可靠的網路
正如在[第二部分](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. 請求可能已經丟失(可能有人拔掉了網線)。 1. 請求可能已經丟失(可能有人拔掉了網線)。
2. 請求可能正在排隊,稍後將交付(也許網路或接收方過載)。 2. 請求可能正在排隊,稍後將交付(也許網路或接收方過載)。
@ -109,27 +109,27 @@
**圖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#可靠性)”)。
### 檢測故障 ### 檢測故障
@ -147,25 +147,25 @@
關於遠端節點關閉的快速反饋很有用但是你不能指望它。即使TCP確認已經傳送了一個數據包應用程式在處理之前可能已經崩潰。如果你想確保一個請求是成功的你需要應用程式本身的正確響應【24】。 關於遠端節點關閉的快速反饋很有用但是你不能指望它。即使TCP確認已經傳送了一個數據包應用程式在處理之前可能已經崩潰。如果你想確保一個請求是成功的你需要應用程式本身的正確響應【24】。
相反如果出了什麼問題你可能會在堆疊的某個層次上得到一個錯誤響應但總的來說你必須假設你可能根本就得不到任何迴應。您可以重試幾次TCP重試是透明的但是您也可以在應用程式級別重試等待超時過期並且如果在超時時間內沒有收到響應則最終宣告節點已經死亡。 相反如果出了什麼問題你可能會在堆疊的某個層次上得到一個錯誤響應但總的來說你必須假設你可能根本就得不到任何迴應。您可以重試幾次TCP重試是透明的但是您也可以在應用程式級別重試等待超時過期並且如果在超時時間內沒有收到響應則最終宣告節點已經死亡。
### 超時與無窮的延遲 ### 超時與無窮的延遲
如果超時是檢測故障的唯一可靠方法,那麼超時應該等待多久?不幸的是沒有簡單的答案。 如果超時是檢測故障的唯一可靠方法,那麼超時應該等待多久?不幸的是沒有簡單的答案。
長時間的超時意味著長時間等待,直到一個節點被宣告死亡(在這段時間內,使用者可能不得不等待,或者看到錯誤資訊)。短的超時可以更快地檢測到故障,但有更高地風險誤將一個節點宣佈為失效,而該節點實際上只是暫時地變慢了(例如由於節點或網路上的負載峰值)。 長時間的超時意味著長時間等待,直到一個節點被宣告死亡(在這段時間內,使用者可能不得不等待,或者看到錯誤資訊)。短的超時可以更快地檢測到故障,但有更高地風險誤將一個節點宣佈為失效,而該節點實際上只是暫時地變慢了(例如由於節點或網路上的負載峰值)。
過早地宣告一個節點已經死了是有問題的:如果這個節點實際上是活著的,並且正在執行一些動作(例如,傳送一封電子郵件),而另一個節點接管,那麼這個動作可能會最終執行兩次。我們將在“[知識、真相與謊言](#知識、真相與謊言)”以及[第九章](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】 在駕駛汽車時由於交通擁堵道路交通網路的通行時間往往不盡相同。同樣計算機網路上資料包延遲的可變性通常是由於排隊【25】
* 如果多個不同的節點同時嘗試將資料包傳送到同一目的地,則網路交換機必須將它們排隊並將它們逐個送入目標網路鏈路(如[圖8-2](../img/fig8-2.png)所示)。在繁忙的網路鏈路上,資料包可能需要等待一段時間才能獲得一個插槽(這稱為網路擁塞)。如果傳入的資料太多,交換機佇列填滿,資料包將被丟棄,因此需要重新發送資料包 - 即使網路執行良好。 * 如果多個不同的節點同時嘗試將資料包傳送到同一目的地,則網路交換機必須將它們排隊並將它們逐個送入目標網路鏈路(如[圖8-2](../img/fig8-2.png)所示)。在繁忙的網路鏈路上,資料包可能需要等待一段時間才能獲得一個插槽(這稱為網路擁塞)。如果傳入的資料太多,交換機佇列填滿,資料包將被丟棄,因此需要重新發送資料包 - 即使網路執行良好。
* 當資料包到達目標機器時如果所有CPU核心當前都處於繁忙狀態則來自網路的傳入請求將被作業系統排隊直到應用程式準備好處理它為止。根據機器上的負載這可能需要一段任意的時間。 * 當資料包到達目標機器時如果所有CPU核心當前都處於繁忙狀態則來自網路的傳入請求將被作業系統排隊直到應用程式準備好處理它為止。根據機器上的負載這可能需要一段任意的時間。
@ -176,7 +176,7 @@
**圖8-2 如果有多臺機器將網路流量傳送到同一目的地則其交換機佇列可能會被填滿。在這裡埠1,2和4都試圖傳送資料包到埠3** **圖8-2 如果有多臺機器將網路流量傳送到同一目的地則其交換機佇列可能會被填滿。在這裡埠1,2和4都試圖傳送資料包到埠3**
而且如果TCP在某個超時時間內沒有被確認這是根據觀察的往返時間計算的則認為資料包丟失丟失的資料包將自動重新發送。儘管應用程式沒有看到資料包丟失和重新傳輸但它看到了延遲等待超時到期然後等待重新傳輸的資料包得到確認 而且如果TCP在某個超時時間內沒有被確認這是根據觀察的往返時間計算的則認為資料包丟失丟失的資料包將自動重新發送。儘管應用程式沒有看到資料包丟失和重新傳輸但它看到了延遲等待超時到期然後等待重新傳輸的資料包得到確認
> ### TCP與UDP > ### TCP與UDP
@ -185,41 +185,41 @@
> >
> 在延遲資料毫無價值的情況下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協議相比更接近於電路交換。在這個級別上可以購買專用頻寬。但是網際網路路由在網路級別執行而不是主機之間的單獨連線而且執行時間要長得多。
@ -253,39 +253,39 @@
[例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接收機獲取時間。
### 單調鍾與日曆時鐘 ### 單調鍾與日曆時鐘
現代計算機至少有兩種不同的時鐘日曆時鐘time-of-day clock和單調鍾monotonic clock。儘管它們都衡量時間但區分這兩者很重要因為它們有不同的目的。 現代計算機至少有兩種不同的時鐘日曆時鐘time-of-day clock和單調鍾monotonic clock。儘管它們都衡量時間但區分這兩者很重要因為它們有不同的目的。
#### 日曆時鐘 #### 日曆時鐘
日曆時鐘是您直觀地瞭解時鐘的依據:它根據某個日曆(也稱為**掛鐘時間wall-clock time**返回當前日期和時間。例如Linux上的`clock_gettime(CLOCK_REALTIME)`[^v]和Java中的`System.currentTimeMillis()`返回自epochUTC時間1970年1月1日午夜以來的秒數或毫秒根據公曆Gregorian日曆不包括閏秒。有些系統使用其他日期作為參考點。 日曆時鐘是您直觀地瞭解時鐘的依據:它根據某個日曆(也稱為**掛鐘時間wall-clock time**返回當前日期和時間。例如Linux上的`clock_gettime(CLOCK_REALTIME)`[^v]和Java中的`System.currentTimeMillis()`返回自epochUTC時間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秒。即使一切工作正常此漂移也會限制可以達到的最佳準確度。 * 計算機中的石英鐘不夠精確:它會**漂移drifts**(執行速度快於或慢於預期)。時鐘漂移取決於機器的溫度。 Google假設其伺服器時鐘漂移為200 ppm百萬分之一【41】相當於每30秒與伺服器重新同步一次的時鐘漂移為6毫秒或者每天重新同步的時鐘漂移為17秒。即使一切工作正常此漂移也會限制可以達到的最佳準確度。
* 如果計算機的時鐘與NTP伺服器的時鐘差別太大可能會拒絕同步或者本地時鐘將被強制重置【37】。任何觀察重置前後時間的應用程式都可能會看到時間倒退或突然跳躍。 * 如果計算機的時鐘與NTP伺服器的時鐘差別太大可能會拒絕同步或者本地時鐘將被強制重置【37】。任何觀察重置前後時間的應用程式都可能會看到時間倒退或突然跳躍。
@ -298,33 +298,33 @@
如果你足夠在乎這件事並投入大量資源就可以達到非常好的時鐘精度。例如針對金融機構的歐洲法規草案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】。
因此,如果你使用需要同步時鐘的軟體,必須仔細監控所有機器之間的時鐘偏移。時鐘偏離其他時鐘太遠的節點應當被宣告死亡,並從叢集中移除。這樣的監控可以確保你在損失發生之前注意到破損的時鐘。 因此,如果你使用需要同步時鐘的軟體,必須仔細監控所有機器之間的時鐘偏移。時鐘偏離其他時鐘太遠的節點應當被宣告死亡,並從叢集中移除。這樣的監控可以確保你在損失發生之前注意到破損的時鐘。
#### 有序事件的時間戳 #### 有序事件的時間戳
讓我們考慮一個特別的情況,一件很有誘惑但也很危險的事情:依賴時鐘,在多個節點上對事件進行排序。 例如,如果兩個客戶端寫入分散式資料庫,誰先到達? 哪一個更近? 讓我們考慮一個特別的情況,一件很有誘惑但也很危險的事情:依賴時鐘,在多個節點上對事件進行排序。 例如,如果兩個客戶端寫入分散式資料庫,誰先到達? 哪一個更近?
[圖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) ![](../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】。此方案可能導致一定數量的資料被悄悄丟棄而未嚮應用報告任何錯誤。 * 資料庫寫入可能會神祕地消失具有滯後時鐘的節點無法覆蓋之前具有快速時鐘的節點寫入的值直到節點之間的時鐘偏差消逝【54,55】。此方案可能導致一定數量的資料被悄悄丟棄而未嚮應用報告任何錯誤。
* LWW無法區分**高頻順序寫入**(在[圖8-3](../img/fig8-3.png)中客戶端B的增量操作**一定**發生在客戶端A的寫入之後和**真正併發寫入**(寫入者意識不到其他寫入者)。需要額外的因果關係跟蹤機制(例如版本向量),以防止違背因果關係(請參閱“[檢測併發寫入](ch5.md#檢測併發寫入)”)。 * LWW無法區分**高頻順序寫入**(在[圖8-3](../img/fig8-3.png)中客戶端B的增量操作**一定**發生在客戶端A的寫入之後和**真正併發寫入**(寫入者意識不到其他寫入者)。需要額外的因果關係跟蹤機制(例如版本向量),以防止違背因果關係(請參閱“[檢測併發寫入](ch5.md#檢測併發寫入)”)。
@ -332,49 +332,49 @@
因此,儘管透過保留最“最近”的值並放棄其他值來解決衝突是很誘惑人的,但是要注意,“最近”的定義取決於本地的**日曆時鐘**這很可能是不正確的。即使用嚴格同步的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以這種方式實現跨資料中心的快照隔離【5960】。它使用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以這種方式實現跨資料中心的快照隔離【5960】。它使用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】。這些想法很有趣但是它們還沒有在谷歌之外的主流資料庫中實現。 對分散式事務語義使用時鐘同步是一個活躍的研究領域【57,61,62】。這些想法很有趣但是它們還沒有在谷歌之外的主流資料庫中實現。
### 程序暫停 ### 程序暫停
讓我們考慮在分散式系統中使用危險時鐘的另一個例子。假設你有一個數據庫,每個分割槽只有一個領導者。只有領導被允許接受寫入。一個節點如何知道它仍然是領導者(它並沒有被別人宣告為死亡),並且它可以安全地接受寫入? 讓我們考慮在分散式系統中使用危險時鐘的另一個例子。假設你有一個數據庫,每個分割槽只有一個領導者。只有領導被允許接受寫入。一個節點如何知道它仍然是領導者(它並沒有被別人宣告為死亡),並且它可以安全地接受寫入?
一種選擇是領導者從其他節點獲得一個**租約lease**類似一個帶超時的鎖【63】。任一時刻只有一個節點可以持有租約——因此當一個節點獲得一個租約時它知道它在某段時間內自己是領導者直到租約到期。為了保持領導地位節點必須週期性地在租約過期前續期。 一種選擇是領導者從其他節點獲得一個**租約lease**類似一個帶超時的鎖【63】。任一時刻只有一個節點可以持有租約——因此當一個節點獲得一個租約時它知道它在某段時間內自己是領導者直到租約到期。為了保持領導地位節點必須週期性地在租約過期前續期。
如果節點發生故障,就會停止續期,所以當租約過期時,另一個節點可以接管。 如果節點發生故障,就會停止續期,所以當租約過期時,另一個節點可以接管。
可以想象,請求處理迴圈看起來像這樣: 可以想象,請求處理迴圈看起來像這樣:
```java ```java
while (true) { while (true) {
@ -390,13 +390,13 @@ 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】但是如果我們想要提供健壯的保證就必須假設最壞的情況發生。 * 許多程式語言執行時如Java虛擬機器都有一個垃圾收集器GC偶爾需要停止所有正在執行的執行緒。這些“**停止所有處理stop-the-world**”GC暫停有時會持續幾分鐘【64】甚至像HotSpot JVM的CMS這樣的所謂的“並行”垃圾收集器也不能完全與應用程式程式碼並行執行它需要不時地停止所有處理【65】。儘管通常可以透過改變分配模式或調整GC設定來減少暫停【66】但是如果我們想要提供健壯的保證就必須假設最壞的情況發生。
* 在虛擬化環境中,可以**掛起suspend** 虛擬機器暫停執行所有程序並將記憶體內容儲存到磁碟並恢復恢復記憶體內容並繼續執行。這個暫停可以在程序執行的任何時候發生並且可以持續任意長的時間。這個功能有時用於虛擬機器從一個主機到另一個主機的實時遷移而不需要重新啟動在這種情況下暫停的長度取決於程序寫入記憶體的速率【67】。 * 在虛擬化環境中,可以**掛起suspend** 虛擬機器暫停執行所有程序並將記憶體內容儲存到磁碟並恢復恢復記憶體內容並繼續執行。這個暫停可以在程序執行的任何時候發生並且可以持續任意長的時間。這個功能有時用於虛擬機器從一個主機到另一個主機的實時遷移而不需要重新啟動在這種情況下暫停的長度取決於程序寫入記憶體的速率【67】。
@ -408,15 +408,15 @@ while (true) {
所有這些事件都可以隨時**搶佔preempt** 正在執行的執行緒,並在稍後的時間恢復執行,而執行緒甚至不會注意到這一點。這個問題類似於在單個機器上使多執行緒程式碼執行緒安全:你不能對時序做任何假設,因為隨時可能發生上下文切換,或者出現並行執行。 所有這些事件都可以隨時**搶佔preempt** 正在執行的執行緒,並在稍後的時間恢復執行,而執行緒甚至不會注意到這一點。這個問題類似於在單個機器上使多執行緒程式碼執行緒安全:你不能對時序做任何假設,因為隨時可能發生上下文切換,或者出現並行執行。
當在一臺機器上編寫多執行緒程式碼時,我們有相當好的工具來實現執行緒安全:互斥量,訊號量,原子計數器,無鎖資料結構,阻塞佇列等等。不幸的是,這些工具並不能直接轉化為分散式系統操作,因為分散式系統沒有共享記憶體,只有透過不可靠網路傳送的訊息。 當在一臺機器上編寫多執行緒程式碼時,我們有相當好的工具來實現執行緒安全:互斥量,訊號量,原子計數器,無鎖資料結構,阻塞佇列等等。不幸的是,這些工具並不能直接轉化為分散式系統操作,因為分散式系統沒有共享記憶體,只有透過不可靠網路傳送的訊息。
分散式系統中的節點,必須假定其執行可能在任意時刻暫停相當長的時間,即使是在一個函式的中間。在暫停期間,世界的其它部分在繼續運轉,甚至可能因為該節點沒有響應,而宣告暫停節點的死亡。最終暫停的節點可能會繼續執行,在再次檢查自己的時鐘之前,甚至可能不會意識到自己進入了睡眠。 分散式系統中的節點,必須假定其執行可能在任意時刻暫停相當長的時間,即使是在一個函式的中間。在暫停期間,世界的其它部分在繼續運轉,甚至可能因為該節點沒有響應,而宣告暫停節點的死亡。最終暫停的節點可能會繼續執行,在再次檢查自己的時鐘之前,甚至可能不會意識到自己進入了睡眠。
#### 響應時間保證 #### 響應時間保證
在許多程式語言和作業系統中,執行緒和程序可能暫停一段無限制的時間,正如討論的那樣。如果你足夠努力,導致暫停的原因是**可以**消除的。 在許多程式語言和作業系統中,執行緒和程序可能暫停一段無限制的時間,正如討論的那樣。如果你足夠努力,導致暫停的原因是**可以**消除的。
某些軟體的執行環境要求很高,不能在特定時間內響應可能會導致嚴重的損失:控制飛機、火箭、機器人、汽車和其他物體的計算機必須對其感測器輸入做出快速而可預測的響應。在這些系統中,軟體必須有一個特定的**截止時間deadline**,如果截止時間不滿足,可能會導致整個系統的故障。這就是所謂的**硬實時hard real-time** 系統。 某些軟體的執行環境要求很高,不能在特定時間內響應可能會導致嚴重的損失:控制飛機、火箭、機器人、汽車和其他物體的計算機必須對其感測器輸入做出快速而可預測的響應。在這些系統中,軟體必須有一個特定的**截止時間deadline**,如果截止時間不滿足,可能會導致整個系統的故障。這就是所謂的**硬實時hard real-time** 系統。
> #### 實時是真的嗎? > #### 實時是真的嗎?
> >
@ -424,49 +424,49 @@ while (true) {
例如如果車載感測器檢測到當前正在經歷碰撞你肯定不希望安全氣囊釋放系統因為GC暫停而延遲彈出。 例如如果車載感測器檢測到當前正在經歷碰撞你肯定不希望安全氣囊釋放系統因為GC暫停而延遲彈出。
在系統中提供**實時保證**需要各級軟體棧的支援一個實時作業系統RTOS允許在指定的時間間隔內保證CPU時間的分配。庫函式必須申明最壞情況下的執行時間動態記憶體分配可能受到限制或完全不允許實時垃圾收集器存在但是應用程式仍然必須確保它不會給GC太多的負擔必須進行大量的測試和測量以確保達到保證。 在系統中提供**實時保證**需要各級軟體棧的支援一個實時作業系統RTOS允許在指定的時間間隔內保證CPU時間的分配。庫函式必須申明最壞情況下的執行時間動態記憶體分配可能受到限制或完全不允許實時垃圾收集器存在但是應用程式仍然必須確保它不會給GC太多的負擔必須進行大量的測試和測量以確保達到保證。
所有這些都需要大量額外的工作,嚴重限制了可以使用的程式語言、庫和工具的範圍(因為大多數語言和工具不提供實時保證)。由於這些原因,開發實時系統非常昂貴,並且它們通常用於安全關鍵的嵌入式裝置。而且,“**實時**”與“**高效能**”不一樣——事實上,實時系統可能具有較低的吞吐量,因為他們必須讓及時響應的優先順序高於一切(另請參閱“[延遲和資源利用](#延遲和資源利用)“)。 所有這些都需要大量額外的工作,嚴重限制了可以使用的程式語言、庫和工具的範圍(因為大多數語言和工具不提供實時保證)。由於這些原因,開發實時系統非常昂貴,並且它們通常用於安全關鍵的嵌入式裝置。而且,“**實時**”與“**高效能**”不一樣——事實上,實時系統可能具有較低的吞吐量,因為他們必須讓及時響應的優先順序高於一切(另請參閱“[延遲和資源利用](#延遲和資源利用)“)。
對於大多數伺服器端資料處理系統來說,實時保證是不經濟或不合適的。因此,這些系統必須承受在非實時環境中執行的暫停和時鐘不穩定性。 對於大多數伺服器端資料處理系統來說,實時保證是不經濟或不合適的。因此,這些系統必須承受在非實時環境中執行的暫停和時鐘不穩定性。
#### 限制垃圾收集的影響 #### 限制垃圾收集的影響
程序暫停的負面影響可以在不訴諸昂貴的實時排程保證的情況下得到緩解。語言執行時在計劃垃圾回收時具有一定的靈活性,因為它們可以跟蹤物件分配的速度和隨著時間的推移剩餘的空閒記憶體。 程序暫停的負面影響可以在不訴諸昂貴的實時排程保證的情況下得到緩解。語言執行時在計劃垃圾回收時具有一定的靈活性,因為它們可以跟蹤物件分配的速度和隨著時間的推移剩餘的空閒記憶體。
一個新興的想法是將GC暫停視為一個節點的短暫計劃中斷並在這個節點收集其垃圾的同時讓其他節點處理來自客戶端的請求。如果執行時可以警告應用程式一個節點很快需要GC暫停那麼應用程式可以停止向該節點發送新的請求等待它完成處理未完成的請求然後在沒有請求正在進行時執行GC。這個技巧向客戶端隱藏了GC暫停並降低了響應時間的高百分比【70,71】。一些對延遲敏感的金融交易系統【72】使用這種方法。 一個新興的想法是將GC暫停視為一個節點的短暫計劃中斷並在這個節點收集其垃圾的同時讓其他節點處理來自客戶端的請求。如果執行時可以警告應用程式一個節點很快需要GC暫停那麼應用程式可以停止向該節點發送新的請求等待它完成處理未完成的請求然後在沒有請求正在進行時執行GC。這個技巧向客戶端隱藏了GC暫停並降低了響應時間的高百分比【70,71】。一些對延遲敏感的金融交易系統【72】使用這種方法。
這個想法的一個變種是隻用垃圾收集器來處理短命物件這些物件可以快速收集並定期在積累大量長壽物件因此需要完整GC之前重新啟動程序【65,73】。一次可以重新啟動一個節點在計劃重新啟動之前流量可以從該節點移開就像[第四章](ch4.md)裡描述的滾動升級一樣。 這個想法的一個變種是隻用垃圾收集器來處理短命物件這些物件可以快速收集並定期在積累大量長壽物件因此需要完整GC之前重新啟動程序【65,73】。一次可以重新啟動一個節點在計劃重新啟動之前流量可以從該節點移開就像[第四章](ch4.md)裡描述的滾動升級一樣。
這些措施不能完全阻止垃圾回收暫停,但可以有效地減少它們對應用的影響。 這些措施不能完全阻止垃圾回收暫停,但可以有效地減少它們對應用的影響。
## 知識、真相與謊言 ## 知識、真相與謊言
本章到目前為止,我們已經探索了分散式系統與執行在單臺計算機上的程式的不同之處:沒有共享記憶體,只有透過可變延遲的不可靠網路傳遞的訊息,系統可能遭受部分失效,不可靠的時鐘和處理暫停。 本章到目前為止,我們已經探索了分散式系統與執行在單臺計算機上的程式的不同之處:沒有共享記憶體,只有透過可變延遲的不可靠網路傳遞的訊息,系統可能遭受部分失效,不可靠的時鐘和處理暫停。
如果你不習慣於分散式系統,那麼這些問題的後果就會讓人迷惑不解。網路中的一個節點無法確切地知道任何事情——它只能根據它透過網路接收到(或沒有接收到)的訊息進行猜測。節點只能透過交換訊息來找出另一個節點所處的狀態(儲存了哪些資料,是否正確執行等等)。如果遠端節點沒有響應,則無法知道它處於什麼狀態,因為網路中的問題不能可靠地與節點上的問題區分開來。 如果你不習慣於分散式系統,那麼這些問題的後果就會讓人迷惑不解。網路中的一個節點無法確切地知道任何事情——它只能根據它透過網路接收到(或沒有接收到)的訊息進行猜測。節點只能透過交換訊息來找出另一個節點所處的狀態(儲存了哪些資料,是否正確執行等等)。如果遠端節點沒有響應,則無法知道它處於什麼狀態,因為網路中的問題不能可靠地與節點上的問題區分開來。
這些系統的討論與哲學有關:在系統中什麼是真什麼是假?如果感知和測量的機制都是不可靠的,那麼關於這些知識我們又能多麼確定呢?軟體系統應該遵循我們對物理世界所期望的法則,如因果關係嗎? 這些系統的討論與哲學有關:在系統中什麼是真什麼是假?如果感知和測量的機制都是不可靠的,那麼關於這些知識我們又能多麼確定呢?軟體系統應該遵循我們對物理世界所期望的法則,如因果關係嗎?
幸運的是,我們不需要去搞清楚生命的意義。在分散式系統中,我們可以陳述關於行為(系統模型)的假設,並以滿足這些假設的方式設計實際系統。演算法可以被證明在某個系統模型中正確執行。這意味著即使底層系統模型提供了很少的保證,也可以實現可靠的行為。 幸運的是,我們不需要去搞清楚生命的意義。在分散式系統中,我們可以陳述關於行為(系統模型)的假設,並以滿足這些假設的方式設計實際系統。演算法可以被證明在某個系統模型中正確執行。這意味著即使底層系統模型提供了很少的保證,也可以實現可靠的行為。
但是,儘管可以使軟體在不可靠的系統模型中表現良好,但這並不是可以直截了當實現的。在本章的其餘部分中,我們將進一步探討分散式系統中的知識和真相的概念,這將有助於我們思考我們可以做出的各種假設以及我們可能希望提供的保證。在[第九章](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** 時,我們將更詳細地討論法定人數的應用。
#### 領導者和鎖 #### 領導者和鎖
@ -478,41 +478,41 @@ while (true) {
在分散式系統中實現這一點需要注意:即使一個節點認為它是“**天選者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) ![](../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) ![](../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】。因此任何服務保護自己免受意外客戶的濫用是一個好主意。 在伺服器端檢查一個令牌可能看起來像是一個缺點但這可以說是一件好事一個服務假定它的客戶總是守規矩並不明智因為使用客戶端的人與執行服務的人優先順序非常不一樣【76】。因此任何服務保護自己免受意外客戶的濫用是一個好主意。
### 拜占庭故障 ### 拜占庭故障
防護令牌可以檢測和阻止無意中發生錯誤的節點(例如,因為它尚未發現其租約已過期)。但是,如果節點有意破壞系統的保證,則可以透過使用假防護令牌傳送訊息來輕鬆完成此操作。 防護令牌可以檢測和阻止無意中發生錯誤的節點(例如,因為它尚未發現其租約已過期)。但是,如果節點有意破壞系統的保證,則可以透過使用假防護令牌傳送訊息來輕鬆完成此操作。
在本書中我們假設節點是不可靠但誠實的它們可能很慢或者從不響應由於故障並且它們的狀態可能已經過時由於GC暫停或網路延遲但是我們假設如果節點它做出了迴應它正在說出“真相”盡其所知它正在按照協議的規則扮演其角色。 在本書中我們假設節點是不可靠但誠實的它們可能很慢或者從不響應由於故障並且它們的狀態可能已經過時由於GC暫停或網路延遲但是我們假設如果節點它做出了迴應它正在說出“真相”盡其所知它正在按照協議的規則扮演其角色。
如果存在節點可能“撒謊”(傳送任意錯誤或損壞的響應)的風險,則分散式系統的問題變得更困難了——例如,如果節點可能聲稱其實際上沒有收到特定的訊息。這種行為被稱為**拜占庭故障Byzantine fault****在不信任的環境中達成共識的問題被稱為拜占庭將軍問題**【77】。 如果存在節點可能“撒謊”(傳送任意錯誤或損壞的響應)的風險,則分散式系統的問題變得更困難了——例如,如果節點可能聲稱其實際上沒有收到特定的訊息。這種行為被稱為**拜占庭故障Byzantine fault****在不信任的環境中達成共識的問題被稱為拜占庭將軍問題**【77】。
> ### 拜占庭將軍問題 > ### 拜占庭將軍問題
> >
@ -522,22 +522,22 @@ while (true) {
> >
> 拜占庭是後來成為君士坦丁堡的古希臘城市現在在土耳其的伊斯坦布林。沒有任何歷史證據表明拜占庭將軍比其他地方更容易出現陰謀和陰謀。相反這個名字來源於拜占庭式的過度複雜官僚迂迴等意義早在計算機之前就已經在政治中被使用了【79】。Lamport想要選一個不會冒犯任何讀者的國家他被告知將其稱為阿爾巴尼亞將軍問題並不是一個好主意【80】。 > 拜占庭是後來成為君士坦丁堡的古希臘城市現在在土耳其的伊斯坦布林。沒有任何歷史證據表明拜占庭將軍比其他地方更容易出現陰謀和陰謀。相反這個名字來源於拜占庭式的過度複雜官僚迂迴等意義早在計算機之前就已經在政治中被使用了【79】。Lamport想要選一個不會冒犯任何讀者的國家他被告知將其稱為阿爾巴尼亞將軍問題並不是一個好主意【80】。
當一個系統在部分節點發生故障、不遵守協議、甚至惡意攻擊、擾亂網路時仍然能繼續正確工作,稱之為**拜占庭容錯Byzantine fault-tolerant** 的,在特定場景下,這種擔憂在是有意義的: 當一個系統在部分節點發生故障、不遵守協議、甚至惡意攻擊、擾亂網路時仍然能繼續正確工作,稱之為**拜占庭容錯Byzantine fault-tolerant** 的,在特定場景下,這種擔憂在是有意義的:
* 在航空航天環境中計算機記憶體或CPU暫存器中的資料可能被輻射破壞導致其以任意不可預知的方式響應其他節點。由於系統故障非常昂貴例如飛機撞毀和炸死船上所有人員或火箭與國際空間站相撞飛行控制系統必須容忍拜占庭故障【81,82】。 * 在航空航天環境中計算機記憶體或CPU暫存器中的資料可能被輻射破壞導致其以任意不可預知的方式響應其他節點。由於系統故障非常昂貴例如飛機撞毀和炸死船上所有人員或火箭與國際空間站相撞飛行控制系統必須容忍拜占庭故障【81,82】。
* 在多個參與組織的系統中一些參與者可能會試圖欺騙或欺騙他人。在這種情況下節點僅僅信任另一個節點的訊息是不安全的因為它們可能是出於惡意的目的而被髮送的。例如像比特幣和其他區塊鏈一樣的對等網路可以被認為是讓互不信任的各方同意交易是否發生的一種方式而不依賴於中心機構central authority【83】。 * 在多個參與組織的系統中一些參與者可能會試圖欺騙或欺騙他人。在這種情況下節點僅僅信任另一個節點的訊息是不安全的因為它們可能是出於惡意的目的而被髮送的。例如像比特幣和其他區塊鏈一樣的對等網路可以被認為是讓互不信任的各方同意交易是否發生的一種方式而不依賴於中心機構central authority【83】。
然而在本書討論的那些系統中我們通常可以安全地假設沒有拜占庭式的錯誤。在你的資料中心裡所有的節點都是由你的組織控制的所以他們可以信任輻射水平足夠低記憶體損壞不是一個大問題。製作拜占庭容錯系統的協議相當複雜【84】而容錯嵌入式系統依賴於硬體層面的支援【81】。在大多數伺服器端資料系統中部署拜占庭容錯解決方案的成本使其變得不切實際。 然而在本書討論的那些系統中我們通常可以安全地假設沒有拜占庭式的錯誤。在你的資料中心裡所有的節點都是由你的組織控制的所以他們可以信任輻射水平足夠低記憶體損壞不是一個大問題。製作拜占庭容錯系統的協議相當複雜【84】而容錯嵌入式系統依賴於硬體層面的支援【81】。在大多數伺服器端資料系統中部署拜占庭容錯解決方案的成本使其變得不切實際。
Web應用程式確實需要預期受終端使用者控制的客戶端如Web瀏覽器的任意和惡意行為。這就是為什麼輸入驗證資料清洗和輸出轉義如此重要例如防止SQL注入和跨站點指令碼。然而我們通常不在這裡使用拜占庭容錯協議而只是讓伺服器有權決定是否允許客戶端行為。但在沒有這種中心機構的對等網路中拜占庭容錯更為重要。 Web應用程式確實需要預期受終端使用者控制的客戶端如Web瀏覽器的任意和惡意行為。這就是為什麼輸入驗證資料清洗和輸出轉義如此重要例如防止SQL注入和跨站點指令碼。然而我們通常不在這裡使用拜占庭容錯協議而只是讓伺服器有權決定是否允許客戶端行為。但在沒有這種中心機構的對等網路中拜占庭容錯更為重要。
軟體中的一個錯誤bug可能被認為是拜占庭式的錯誤但是如果您將相同的軟體部署到所有節點上那麼拜占庭式的容錯演算法幫不到你。大多數拜占庭式容錯演算法要求超過三分之二的節點能夠正常工作如果有四個節點最多隻能有一個故障。要使用這種方法對付bug你必須有四個獨立的相同軟體的實現並希望一個bug只出現在四個實現之一中。 軟體中的一個錯誤bug可能被認為是拜占庭式的錯誤但是如果您將相同的軟體部署到所有節點上那麼拜占庭式的容錯演算法幫不到你。大多數拜占庭式容錯演算法要求超過三分之二的節點能夠正常工作如果有四個節點最多隻能有一個故障。要使用這種方法對付bug你必須有四個獨立的相同軟體的實現並希望一個bug只出現在四個實現之一中。
同樣,如果一個協議可以保護我們免受漏洞,安全滲透和惡意攻擊,那麼這將是有吸引力的。不幸的是,這也是不現實的:在大多數系統中,如果攻擊者可以滲透一個節點,那他們可能會滲透所有這些節點,因為它們可能都執行著相同的軟體。因此,傳統機制(認證,訪問控制,加密,防火牆等)仍然是抵禦攻擊者的主要保護措施。 同樣,如果一個協議可以保護我們免受漏洞,安全滲透和惡意攻擊,那麼這將是有吸引力的。不幸的是,這也是不現實的:在大多數系統中,如果攻擊者可以滲透一個節點,那他們可能會滲透所有這些節點,因為它們可能都執行著相同的軟體。因此,傳統機制(認證,訪問控制,加密,防火牆等)仍然是抵禦攻擊者的主要保護措施。
#### 弱謊言形式 #### 弱謊言形式
儘管我們假設節點通常是誠實的,但值得向軟體中新增防止“撒謊”弱形式的機制——例如,由硬體問題導致的無效訊息,軟體錯誤和錯誤配置。這種保護機制並不是完全的拜占庭容錯,因為它們不能抵擋決心堅定的對手,但它們仍然是簡單而實用的步驟,以提高可靠性。例如: 儘管我們假設節點通常是誠實的,但值得向軟體中新增防止“撒謊”弱形式的機制——例如,由硬體問題導致的無效訊息,軟體錯誤和錯誤配置。這種保護機制並不是完全的拜占庭容錯,因為它們不能抵擋決心堅定的對手,但它們仍然是簡單而實用的步驟,以提高可靠性。例如:
* 由於硬體問題或作業系統、驅動程式、路由器等中的錯誤網路資料包有時會受到損壞。通常損壞的資料包會被內建於TCP和UDP中的校驗和所俘獲但有時它們也會逃脫檢測【85,86,87】 。要對付這種破壞通常使用簡單的方法就可以做到,例如應用程式級協議中的校驗和。 * 由於硬體問題或作業系統、驅動程式、路由器等中的錯誤網路資料包有時會受到損壞。通常損壞的資料包會被內建於TCP和UDP中的校驗和所俘獲但有時它們也會逃脫檢測【85,86,87】 。要對付這種破壞通常使用簡單的方法就可以做到,例如應用程式級協議中的校驗和。
* 可公開訪問的應用程式必須仔細清理來自使用者的任何輸入,例如檢查值是否在合理的範圍內,並限制字串的大小以防止透過大記憶體分配的拒絕服務。防火牆後面的內部服務對於輸入也許可以只採取一些不那麼嚴格的檢查,但是採取一些基本的合理性檢查(例如,在協議解析中)仍然是一個好主意。 * 可公開訪問的應用程式必須仔細清理來自使用者的任何輸入,例如檢查值是否在合理的範圍內,並限制字串的大小以防止透過大記憶體分配的拒絕服務。防火牆後面的內部服務對於輸入也許可以只採取一些不那麼嚴格的檢查,但是採取一些基本的合理性檢查(例如,在協議解析中)仍然是一個好主意。
@ -545,89 +545,89 @@ while (true) {
### 系統模型與現實 ### 系統模型與現實
已經有很多演算法被設計以解決分散式系統問題——例如,我們將在[第九章](ch9.md)討論共識問題的解決方案。為了有用,這些演算法需要容忍我們在本章中討論的分散式系統的各種故障。 已經有很多演算法被設計以解決分散式系統問題——例如,我們將在[第九章](ch9.md)討論共識問題的解決方案。為了有用,這些演算法需要容忍我們在本章中討論的分散式系統的各種故障。
演算法的編寫方式不應該過分依賴於執行的硬體和軟體配置的細節。這就要求我們以某種方式將我們期望在系統中發生的錯誤形式化。我們透過定義一個系統模型來做到這一點,這個模型是一個抽象,描述一個演算法可以假設的事情。 演算法的編寫方式不應該過分依賴於執行的硬體和軟體配置的細節。這就要求我們以某種方式將我們期望在系統中發生的錯誤形式化。我們透過定義一個系統模型來做到這一點,這個模型是一個抽象,描述一個演算法可以假設的事情。
關於時序假設,三種系統模型是常用的: 關於時序假設,三種系統模型是常用的:
***同步模型*** ***同步模型***
**同步模型synchronous model** 假設網路延遲、程序暫停和和時鐘誤差都是受限的。這並不意味著完全同步的時鐘或零網路延遲這隻意味著你知道網路延遲、暫停和時鐘漂移將永遠不會超過某個固定的上限【88】。同步模型並不是大多數實際系統的現實模型因為如本章所討論的無限延遲和暫停確實會發生。 **同步模型synchronous model** 假設網路延遲、程序暫停和和時鐘誤差都是受限的。這並不意味著完全同步的時鐘或零網路延遲這隻意味著你知道網路延遲、暫停和時鐘漂移將永遠不會超過某個固定的上限【88】。同步模型並不是大多數實際系統的現實模型因為如本章所討論的無限延遲和暫停確實會發生。
***部分同步模型*** ***部分同步模型***
**部分同步partial synchronous** 意味著一個系統在大多數情況下像一個同步系統一樣執行但有時候會超出網路延遲程序暫停和時鐘漂移的界限【88】。這是很多系統的現實模型大多數情況下網路和程序表現良好否則我們永遠無法完成任何事情但是我們必須承認在任何時刻都存在時序假設偶然被破壞的事實。發生這種情況時網路延遲、暫停和時鐘錯誤可能會變得相當大。 **部分同步partial synchronous** 意味著一個系統在大多數情況下像一個同步系統一樣執行但有時候會超出網路延遲程序暫停和時鐘漂移的界限【88】。這是很多系統的現實模型大多數情況下網路和程序表現良好否則我們永遠無法完成任何事情但是我們必須承認在任何時刻都存在時序假設偶然被破壞的事實。發生這種情況時網路延遲、暫停和時鐘錯誤可能會變得相當大。
***非同步模型*** ***非同步模型***
在這個模型中,一個演算法不允許對時序做任何假設——事實上它甚至沒有時鐘(所以它不能使用超時)。一些演算法被設計為可用於非同步模型,但非常受限。 在這個模型中,一個演算法不允許對時序做任何假設——事實上它甚至沒有時鐘(所以它不能使用超時)。一些演算法被設計為可用於非同步模型,但非常受限。
進一步來說,除了時序問題,我們還要考慮**節點失效**。三種最常見的節點系統模型是: 進一步來說,除了時序問題,我們還要考慮**節點失效**。三種最常見的節點系統模型是:
***崩潰-停止故障*** ***崩潰-停止故障***
在**崩潰停止crash-stop** 模型中,演算法可能會假設一個節點只能以一種方式失效,即透過崩潰。這意味著節點可能在任意時刻突然停止響應,此後該節點永遠消失——它永遠不會回來。 在**崩潰停止crash-stop** 模型中,演算法可能會假設一個節點只能以一種方式失效,即透過崩潰。這意味著節點可能在任意時刻突然停止響應,此後該節點永遠消失——它永遠不會回來。
***崩潰-恢復故障*** ***崩潰-恢復故障***
我們假設節點可能會在任何時候崩潰,但也許會在未知的時間之後再次開始響應。在**崩潰-恢復crash-recovery** 模型中,假設節點具有穩定的儲存(即,非易失性磁碟儲存)且會在崩潰中保留,而記憶體中的狀態會丟失。 我們假設節點可能會在任何時候崩潰,但也許會在未知的時間之後再次開始響應。在**崩潰-恢復crash-recovery** 模型中,假設節點具有穩定的儲存(即,非易失性磁碟儲存)且會在崩潰中保留,而記憶體中的狀態會丟失。
***拜占庭(任意)故障*** ***拜占庭(任意)故障***
節點可以做(絕對意義上的)任何事情,包括試圖戲弄和欺騙其他節點,如上一節所述。 節點可以做(絕對意義上的)任何事情,包括試圖戲弄和欺騙其他節點,如上一節所述。
對於真實系統的建模,具有**崩潰-恢復故障crash-recovery** 的**部分同步模型partial synchronous** 通常是最有用的模型。分散式演算法如何應對這種模型? 對於真實系統的建模,具有**崩潰-恢復故障crash-recovery** 的**部分同步模型partial synchronous** 通常是最有用的模型。分散式演算法如何應對這種模型?
#### 演算法的正確性 #### 演算法的正確性
為了定義演算法是正確的,我們可以描述它的屬性。例如,排序演算法的輸出具有如下特性:對於輸出列表中的任何兩個不同的元素,左邊的元素比右邊的元素小。這只是定義對列表進行排序含義的一種形式方式。 為了定義演算法是正確的,我們可以描述它的屬性。例如,排序演算法的輸出具有如下特性:對於輸出列表中的任何兩個不同的元素,左邊的元素比右邊的元素小。這只是定義對列表進行排序含義的一種形式方式。
同樣,我們可以寫下我們想要的分散式演算法的屬性來定義它的正確含義。例如,如果我們正在為一個鎖生成防護令牌(請參閱“[防護令牌](#防護令牌)”),我們可能要求演算法具有以下屬性: 同樣,我們可以寫下我們想要的分散式演算法的屬性來定義它的正確含義。例如,如果我們正在為一個鎖生成防護令牌(請參閱“[防護令牌](#防護令牌)”),我們可能要求演算法具有以下屬性:
***唯一性uniqueness*** ***唯一性uniqueness***
沒有兩個防護令牌請求返回相同的值。 沒有兩個防護令牌請求返回相同的值。
***單調序列monotonic sequence*** ***單調序列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*** ***可用性availability***
請求防護令牌並且不會崩潰的節點,最終會收到響應。 請求防護令牌並且不會崩潰的節點,最終會收到響應。
如果一個系統模型中的演算法總是滿足它在所有我們假設可能發生的情況下的性質,那麼這個演算法是正確的。但這如何有意義?如果所有的節點崩潰,或者所有的網路延遲突然變得無限長,那麼沒有任何演算法能夠完成任何事情。 如果一個系統模型中的演算法總是滿足它在所有我們假設可能發生的情況下的性質,那麼這個演算法是正確的。但這如何有意義?如果所有的節點崩潰,或者所有的網路延遲突然變得無限長,那麼沒有任何演算法能夠完成任何事情。
#### 安全性和活性 #### 安全性和活性
為了澄清這種情況,有必要區分兩種不同的屬性:**安全safety屬性**和**活性liveness屬性**。在剛剛給出的例子中,**唯一性**和**單調序列**是安全屬性,而**可用性**是活性屬性。 為了澄清這種情況,有必要區分兩種不同的屬性:**安全safety屬性**和**活性liveness屬性**。在剛剛給出的例子中,**唯一性**和**單調序列**是安全屬性,而**可用性**是活性屬性。
這兩種性質有什麼區別?一個試金石就是,活性屬性通常在定義中通常包括“**最終**”一詞。 是的你猜對了——最終一致性是一個活性屬性【89】。 這兩種性質有什麼區別?一個試金石就是,活性屬性通常在定義中通常包括“**最終**”一詞。 是的你猜對了——最終一致性是一個活性屬性【89】。
安全通常被非正式地定義為:**沒有壞事發生**,而活性通常就類似:**最終好事發生**。但是最好不要過多地閱讀那些非正式的定義因為好與壞的含義是主觀的。安全和活性的實際定義是精確的和數學的【90】 安全通常被非正式地定義為:**沒有壞事發生**,而活性通常就類似:**最終好事發生**。但是最好不要過多地閱讀那些非正式的定義因為好與壞的含義是主觀的。安全和活性的實際定義是精確的和數學的【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】。這可以說是電腦科學和軟體工程間的一個差異
這並不是說理論上抽象的系統模型是毫無價值的,恰恰相反。它們對於將實際系統的複雜性提取成一個個我們可以推理的可處理的錯誤型別是非常有幫助的,以便我們能夠理解這個問題,並試圖系統地解決這個問題。我們可以證明演算法是正確的,透過表明它們的屬性在某個系統模型中總是成立的。 這並不是說理論上抽象的系統模型是毫無價值的,恰恰相反。它們對於將實際系統的複雜性提取成一個個我們可以推理的可處理的錯誤型別是非常有幫助的,以便我們能夠理解這個問題,並試圖系統地解決這個問題。我們可以證明演算法是正確的,透過表明它們的屬性在某個系統模型中總是成立的。
證明演算法正確並不意味著它在真實系統上的實現必然總是正確的。但這邁出了很好的第一步,因為理論分析可以發現演算法中的問題,這種問題可能會在現實系統中長期潛伏,直到你的假設(例如,時序)因為不尋常的情況被打破。理論分析與經驗測試同樣重要。 證明演算法正確並不意味著它在真實系統上的實現必然總是正確的。但這邁出了很好的第一步,因為理論分析可以發現演算法中的問題,這種問題可能會在現實系統中長期潛伏,直到你的假設(例如,時序)因為不尋常的情況被打破。理論分析與經驗測試同樣重要。
## 本章小結 ## 本章小結
@ -640,19 +640,19 @@ while (true) {
這類**部分失效partial failure** 可能發生的事實是分散式系統的決定性特徵。每當軟體試圖做任何涉及其他節點的事情時,偶爾就有可能會失敗,或者隨機變慢,或者根本沒有響應(最終超時)。在分散式系統中,我們試圖在軟體中建立**部分失效**的容錯機制,這樣整個系統在即使某些組成部分被破壞的情況下,也可以繼續執行。 這類**部分失效partial failure** 可能發生的事實是分散式系統的決定性特徵。每當軟體試圖做任何涉及其他節點的事情時,偶爾就有可能會失敗,或者隨機變慢,或者根本沒有響應(最終超時)。在分散式系統中,我們試圖在軟體中建立**部分失效**的容錯機制,這樣整個系統在即使某些組成部分被破壞的情況下,也可以繼續執行。
為了容忍錯誤,第一步是**檢測**它們,但即使這樣也很難。大多數系統沒有檢測節點是否發生故障的準確機制,所以大多數分散式演算法依靠**超時**來確定遠端節點是否仍然可用。但是超時無法區分網路失效和節點失效並且可變的網路延遲有時會導致節點被錯誤地懷疑發生故障。此外有時一個節點可能處於降級狀態例如由於驅動程式錯誤千兆網絡卡可能突然下降到1 Kb/s的吞吐量【94】。這樣一個“跛行”而不是死掉的節點可能比一個乾淨的失效節點更難處理。 為了容忍錯誤,第一步是**檢測**它們,但即使這樣也很難。大多數系統沒有檢測節點是否發生故障的準確機制,所以大多數分散式演算法依靠**超時**來確定遠端節點是否仍然可用。但是超時無法區分網路失效和節點失效並且可變的網路延遲有時會導致節點被錯誤地懷疑發生故障。此外有時一個節點可能處於降級狀態例如由於驅動程式錯誤千兆網絡卡可能突然下降到1 Kb/s的吞吐量【94】。這樣一個“跛行”而不是死掉的節點可能比一個乾淨的失效節點更難處理。
一旦檢測到故障,使系統容忍它也並不容易:沒有全域性變數,沒有共享記憶體,沒有共同的知識,或機器之間任何其他種類的共享狀態。節點甚至不能就現在是什麼時間達成一致,就不用說更深奧的了。資訊從一個節點流向另一個節點的唯一方法是透過不可靠的網路傳送資訊。重大決策不能由一個節點安全地完成,因此我們需要一個能從其他節點獲得幫助的協議,並爭取達到法定人數以達成一致。 一旦檢測到故障,使系統容忍它也並不容易:沒有全域性變數,沒有共享記憶體,沒有共同的知識,或機器之間任何其他種類的共享狀態。節點甚至不能就現在是什麼時間達成一致,就不用說更深奧的了。資訊從一個節點流向另一個節點的唯一方法是透過不可靠的網路傳送資訊。重大決策不能由一個節點安全地完成,因此我們需要一個能從其他節點獲得幫助的協議,並爭取達到法定人數以達成一致。
如果你習慣於在理想化的數學完美的單機環境同一個操作總能確定地返回相同的結果中編寫軟體那麼轉向分散式系統的凌亂的物理現實可能會有些令人震驚。相反如果能夠在單臺計算機上解決一個問題那麼分散式系統工程師通常會認為這個問題是平凡的【5】現在單個計算機確實可以做很多事情【95】。如果你可以避免開啟潘多拉的盒子把東西放在一臺機器上那麼通常是值得的。 如果你習慣於在理想化的數學完美的單機環境同一個操作總能確定地返回相同的結果中編寫軟體那麼轉向分散式系統的凌亂的物理現實可能會有些令人震驚。相反如果能夠在單臺計算機上解決一個問題那麼分散式系統工程師通常會認為這個問題是平凡的【5】現在單個計算機確實可以做很多事情【95】。如果你可以避免開啟潘多拉的盒子把東西放在一臺機器上那麼通常是值得的。
但是,正如在[第二部分](part-ii.md)的介紹中所討論的那樣,可伸縮性並不是使用分散式系統的唯一原因。容錯和低延遲(透過將資料放置在距離使用者較近的地方)是同等重要的目標,而這些不能用單個節點實現。 但是,正如在[第二部分](part-ii.md)的介紹中所討論的那樣,可伸縮性並不是使用分散式系統的唯一原因。容錯和低延遲(透過將資料放置在距離使用者較近的地方)是同等重要的目標,而這些不能用單個節點實現。
在本章中,我們也轉換了幾次話題,探討了網路、時鐘和程序的不可靠性是否是不可避免的自然規律。我們看到這並不是:有可能給網路提供硬實時的響應保證和有限的延遲,但是這樣做非常昂貴,且導致硬體資源的利用率降低。大多數非安全關鍵系統會選擇**便宜而不可靠**,而不是**昂貴和可靠**。 在本章中,我們也轉換了幾次話題,探討了網路、時鐘和程序的不可靠性是否是不可避免的自然規律。我們看到這並不是:有可能給網路提供硬實時的響應保證和有限的延遲,但是這樣做非常昂貴,且導致硬體資源的利用率降低。大多數非安全關鍵系統會選擇**便宜而不可靠**,而不是**昂貴和可靠**。
我們還談到了超級計算機,它們採用可靠的元件,因此當元件發生故障時必須完全停止並重新啟動。相比之下,分散式系統可以永久執行而不會在服務層面中斷,因為所有的錯誤和維護都可以在節點級別進行處理——至少在理論上是如此。 (實際上,如果一個錯誤的配置變更被應用到所有的節點,仍然會使分散式系統癱瘓)。 我們還談到了超級計算機,它們採用可靠的元件,因此當元件發生故障時必須完全停止並重新啟動。相比之下,分散式系統可以永久執行而不會在服務層面中斷,因為所有的錯誤和維護都可以在節點級別進行處理——至少在理論上是如此。 (實際上,如果一個錯誤的配置變更被應用到所有的節點,仍然會使分散式系統癱瘓)。
本章一直在講存在的問題,給我們展現了一幅黯淡的前景。在[下一章](ch9.md)中,我們將繼續討論解決方案,並討論一些旨在解決分散式系統中所有問題的演算法。 本章一直在講存在的問題,給我們展現了一幅黯淡的前景。在[下一章](ch9.md)中,我們將繼續討論解決方案,並討論一些旨在解決分散式系統中所有問題的演算法。

File diff suppressed because it is too large Load Diff

View File

@ -53,11 +53,11 @@
***複製Replication*** ***複製Replication***
在幾個不同的節點上儲存資料的相同副本,可能放在不同的位置。 複製提供了冗餘:如果一些節點不可用,剩餘的節點仍然可以提供資料服務。 複製也有助於改善效能。 [第五章](ch5.md)將討論複製。 在幾個不同的節點上儲存資料的相同副本,可能放在不同的位置。 複製提供了冗餘:如果一些節點不可用,剩餘的節點仍然可以提供資料服務。 複製也有助於改善效能。 [第五章](ch5.md)將討論複製。
***分割槽 (Partitioning)*** ***分割槽 (Partitioning)***
將一個大型資料庫拆分成較小的子集(稱為**分割槽partitions**),從而不同的分割槽可以指派給不同的**節點node**(亦稱**分片shard**)。 [第六章](ch6.md)將討論分割槽。 將一個大型資料庫拆分成較小的子集(稱為**分割槽partitions**),從而不同的分割槽可以指派給不同的**節點node**(亦稱**分片shard**)。 [第六章](ch6.md)將討論分割槽。
複製和分割槽是不同的機制,但它們經常同時使用。如[圖II-1](../img/figii-1.png)所示。 複製和分割槽是不同的機制,但它們經常同時使用。如[圖II-1](../img/figii-1.png)所示。