mirror of
https://github.com/Vonng/ddia.git
synced 2024-12-06 15:20:12 +08:00
ch11 60%
This commit is contained in:
parent
af9f417165
commit
0e2da35bbe
192
ch1.md
192
ch1.md
@ -10,7 +10,7 @@
|
||||
|
||||
[TOC]
|
||||
|
||||
现今很多应用程序都是**数据密集型(data-intensive)**的,而非**计算密集型(compute-intensive)**的。因此CPU很少成为这类应用的瓶颈,更大的问题通常来自数据量、数据复杂性、以及数据的变更速度。
|
||||
现今很多应用程序都是**数据密集型(data-intensive)**的,而非**计算密集型(compute-intensive)**的。因此CPU很少成为这类应用的瓶颈,更大的问题通常来自数据量、数据复杂性、以及数据的变更速度。
|
||||
|
||||
数据密集型应用通常由标准组件构建而成,标准组件提供了很多通用的功能;例如,许多应用程序都需要:
|
||||
|
||||
@ -34,53 +34,57 @@
|
||||
|
||||
定期处理累积的大批量数据
|
||||
|
||||
如果这些功能听上去平淡无奇,那是因为这些**数据系统(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)
|
||||
|
||||
**图1-1 一个可能的组合使用多个组件的数据系统架构**
|
||||
|
||||
当你将多个工具组合在一起提供服务时,服务的接口或**应用程序编程接口(API, Application Programming Interface)**通常向客户端隐藏这些实现细节。现在,你基本上已经使用较小的通用组件创建了一个全新的、专用的数据系统。这个新的复合数据系统可能会提供特定的保证,例如:缓存在写入时会作废或更新,以便外部客户端获取一致的结果。现在你不仅是应用程序开发人员,还是数据系统设计人员了。
|
||||
当你将多个工具组合在一起提供服务时,服务的接口或**应用程序编程接口(API, Application Programming Interface)**通常向客户端隐藏这些实现细节。现在,你基本上已经使用较小的通用组件创建了一个全新的、专用的数据系统。这个新的复合数据系统可能会提供特定的保证,例如:缓存在写入时会作废或更新,以便外部客户端获取一致的结果。现在你不仅是应用程序开发人员,还是数据系统设计人员了。
|
||||
|
||||
设计数据系统或服务时可能会遇到很多棘手的问题,例如:当系统出问题时,如何确保数据的正确性和完整性?当部分系统退化降级时,如何为客户提供始终如一的良好性能?当负载增加时,如何扩容应对?什么样的API才是好的API?
|
||||
设计数据系统或服务时可能会遇到很多棘手的问题,例如:当系统出问题时,如何确保数据的正确性和完整性?当部分系统退化降级时,如何为客户提供始终如一的良好性能?当负载增加时,如何扩容应对?什么样的API才是好的API?
|
||||
|
||||
影响数据系统设计的因素很多,包括参与人员的技能和经验、历史遗留问题、系统路径依赖、交付时限、公司的风险容忍度、监管约束等,这些因素都需要具体问题具体分析。
|
||||
影响数据系统设计的因素很多,包括参与人员的技能和经验、历史遗留问题、系统路径依赖、交付时限、公司的风险容忍度、监管约束等,这些因素都需要具体问题具体分析。
|
||||
|
||||
本书着重讨论三个在大多数软件系统中都很重要的问题:
|
||||
本书着重讨论三个在大多数软件系统中都很重要的问题:
|
||||
|
||||
***可靠性(Reliability)***
|
||||
|
||||
系统在**困境(adversity)**(硬件故障、软件故障、人为错误)中仍可正常工作(正确完成功能,并能达到期望的性能水准)。
|
||||
系统在**困境(adversity)**(硬件故障、软件故障、人为错误)中仍可正常工作(正确完成功能,并能达到期望的性能水准)。
|
||||
|
||||
***可扩展性(Scalability)***
|
||||
|
||||
有合理的办法应对系统的增长(数据量、流量、复杂性)(参阅“[可扩展性](#可扩展性)”)
|
||||
有合理的办法应对系统的增长(数据量、流量、复杂性)(参阅“[可扩展性](#可扩展性)”)
|
||||
|
||||
***可维护性(Maintainability)***
|
||||
|
||||
许多不同的人(工程师、运维)在不同的生命周期,都能在高效地在系统上工作(使系统保持现有行为,并适应新的应用场景)。(参阅”[可维护性](#可维护性)“)
|
||||
许多不同的人(工程师、运维)在不同的生命周期,都能在高效地在系统上工作(使系统保持现有行为,并适应新的应用场景)。(参阅”[可维护性](#可维护性)“)
|
||||
|
||||
人们经常追求这些词汇,却没有清楚理解它们到底意味着什么。为了工程的严谨性,本章的剩余部分将探讨可靠性、可扩展性、可维护性的含义。为实现这些目标而使用的各种技术,架构和算法将在后续的章节中研究。
|
||||
|
||||
|
||||
人们经常追求这些词汇,却没有清楚理解它们到底意味着什么。为了工程的严谨性,本章的剩余部分将探讨可靠性、可扩展性、可维护性的含义。为实现这些目标而使用的各种技术,架构和算法将在后续的章节中研究。
|
||||
|
||||
|
||||
|
||||
@ -96,35 +100,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]: 在[应对负载的方法](#应对负载的方法)一节定义
|
||||
|
||||
### 软件错误
|
||||
|
||||
我们通常认为硬件故障是随机的、相互独立的:一台机器的磁盘失效并不意味着另一台机器的磁盘也会失效。大量硬件组件不可能同时发生故障,除非它们存在比较弱的相关性(同样的原因导致关联性错误,例如服务器机架的温度)。
|
||||
我们通常认为硬件故障是随机的、相互独立的:一台机器的磁盘失效并不意味着另一台机器的磁盘也会失效。大量硬件组件不可能同时发生故障,除非它们存在比较弱的相关性(同样的原因导致关联性错误,例如服务器机架的温度)。
|
||||
|
||||
另一类错误是内部的**系统性错误(systematic error)**【7】。这类错误难以预料,而且因为是跨节点相关的,所以比起不相关的硬件故障往往可能造成更多的**系统失效**【5】。例子包括:
|
||||
另一类错误是内部的**系统性错误(systematic error)**【7】。这类错误难以预料,而且因为是跨节点相关的,所以比起不相关的硬件故障往往可能造成更多的**系统失效**【5】。例子包括:
|
||||
|
||||
* 接受特定的错误输入,便导致所有应用服务器实例崩溃的BUG。例如2012年6月30日的闰秒,由于Linux内核中的一个错误,许多应用同时挂掉了。
|
||||
* 失控进程会占用一些共享资源,包括CPU时间、内存、磁盘空间或网络带宽。
|
||||
@ -133,13 +137,13 @@
|
||||
|
||||
导致这类软件故障的BUG通常会潜伏很长时间,直到被异常情况触发为止。这种情况意味着软件对其环境做出了某种假设——虽然这种假设通常来说是正确的,但由于某种原因最后不再成立了【11】。
|
||||
|
||||
虽然软件中的系统性故障没有速效药,但我们还是有很多小办法,例如:仔细考虑系统中的假设和交互;彻底的测试;进程隔离;允许进程崩溃并重启;测量、监控并分析生产环境中的系统行为。如果系统能够提供一些保证(例如在一个消息队列中,进入与发出的消息数量相等),那么系统就可以在运行时不断自检,并在出现**差异(discrepancy)**时报警【12】。
|
||||
虽然软件中的系统性故障没有速效药,但我们还是有很多小办法,例如:仔细考虑系统中的假设和交互;彻底的测试;进程隔离;允许进程崩溃并重启;测量、监控并分析生产环境中的系统行为。如果系统能够提供一些保证(例如在一个消息队列中,进入与发出的消息数量相等),那么系统就可以在运行时不断自检,并在出现**差异(discrepancy)**时报警【12】。
|
||||
|
||||
### 人为错误
|
||||
|
||||
设计并构建了软件系统的工程师是人类,维持系统运行的运维也是人类。即使他们怀有最大的善意,人类也是不可靠的。举个例子,一项关于大型互联网服务的研究发现,运维配置错误是导致服务中断的首要原因,而硬件故障(服务器或网络)仅导致了10-25%的服务中断【13】。
|
||||
设计并构建了软件系统的工程师是人类,维持系统运行的运维也是人类。即使他们怀有最大的善意,人类也是不可靠的。举个例子,一项关于大型互联网服务的研究发现,运维配置错误是导致服务中断的首要原因,而硬件故障(服务器或网络)仅导致了10-25%的服务中断【13】。
|
||||
|
||||
尽管人类不可靠,但怎么做才能让系统变得可靠?最好的系统会组合使用以下几种办法:
|
||||
尽管人类不可靠,但怎么做才能让系统变得可靠?最好的系统会组合使用以下几种办法:
|
||||
|
||||
* 以最小化犯错机会的方式设计系统。例如,精心设计的抽象、API和管理后台使做对事情更容易,搞砸事情更困难。但如果接口限制太多,人们就会忽略它们的好处而想办法绕开。很难正确把握这种微妙的平衡。
|
||||
* 将人们最容易犯错的地方与可能导致失效的地方**解耦(decouple)**。特别是提供一个功能齐全的非生产环境**沙箱(sandbox)**,使人们可以在不影响真实用户的情况下,使用真实数据安全地探索和实验。
|
||||
@ -150,25 +154,25 @@
|
||||
|
||||
### 可靠性有多重要?
|
||||
|
||||
可靠性不仅仅是针对核电站和空中交通管制软件而言,我们也期望更多平凡的应用能可靠地运行。商务应用中的错误会导致生产力损失(也许数据报告不完整还会有法律风险),而电商网站的中断则可能会导致收入和声誉的巨大损失。
|
||||
可靠性不仅仅是针对核电站和空中交通管制软件而言,我们也期望更多平凡的应用能可靠地运行。商务应用中的错误会导致生产力损失(也许数据报告不完整还会有法律风险),而电商网站的中断则可能会导致收入和声誉的巨大损失。
|
||||
|
||||
即使在“非关键”应用中,我们也对用户负有责任。试想一位家长把所有的照片和孩子的视频储存在你的照片应用里【15】。如果数据库突然损坏,他们会感觉如何?他们可能会知道如何从备份恢复吗?
|
||||
即使在“非关键”应用中,我们也对用户负有责任。试想一位家长把所有的照片和孩子的视频储存在你的照片应用里【15】。如果数据库突然损坏,他们会感觉如何?他们可能会知道如何从备份恢复吗?
|
||||
|
||||
在某些情况下,我们可能会选择牺牲可靠性来降低开发成本(例如为未经证实的市场开发产品原型)或运营成本(例如利润率极低的服务),但我们偷工减料时,应该清楚意识到自己在做什么。
|
||||
在某些情况下,我们可能会选择牺牲可靠性来降低开发成本(例如为未经证实的市场开发产品原型)或运营成本(例如利润率极低的服务),但我们偷工减料时,应该清楚意识到自己在做什么。
|
||||
|
||||
|
||||
|
||||
## 可扩展性
|
||||
|
||||
系统今天能可靠运行,并不意味未来也能可靠运行。服务**降级(degradation)**的一个常见原因是负载增加,例如:系统负载已经从一万个并发用户增长到十万个并发用户,或者从一百万增长到一千万。也许现在处理的数据量级要比过去大得多。
|
||||
系统今天能可靠运行,并不意味未来也能可靠运行。服务**降级(degradation)**的一个常见原因是负载增加,例如:系统负载已经从一万个并发用户增长到十万个并发用户,或者从一百万增长到一千万。也许现在处理的数据量级要比过去大得多。
|
||||
|
||||
**可扩展性(Scalability)**是用来描述系统应对负载增长能力的术语。但是请注意,这不是贴在系统上的一维标签:说“X可扩展”或“Y不可扩展”是没有任何意义的。相反,讨论可扩展性意味着考虑诸如“如果系统以特定方式增长,有什么选项可以应对增长?”和“如何增加计算资源来处理额外的负载?”等问题。
|
||||
**可扩展性(Scalability)**是用来描述系统应对负载增长能力的术语。但是请注意,这不是贴在系统上的一维标签:说“X可扩展”或“Y不可扩展”是没有任何意义的。相反,讨论可扩展性意味着考虑诸如“如果系统以特定方式增长,有什么选项可以应对增长?”和“如何增加计算资源来处理额外的负载?”等问题。
|
||||
|
||||
### 描述负载
|
||||
|
||||
在讨论增长问题(如果负载加倍会发生什么?)前,首先要能简要描述系统的当前负载。负载可以用一些称为**负载参数(load parameters)**的数字来描述。参数的最佳选择取决于系统架构,它可能是每秒向Web服务器发出的请求、数据库中的读写比率、聊天室中同时活跃的用户数量、缓存命中率或其他东西。除此之外,也许平均情况对你很重要,也许你的瓶颈是少数极端场景。
|
||||
在讨论增长问题(如果负载加倍会发生什么?)前,首先要能简要描述系统的当前负载。负载可以用一些称为**负载参数(load parameters)**的数字来描述。参数的最佳选择取决于系统架构,它可能是每秒向Web服务器发出的请求、数据库中的读写比率、聊天室中同时活跃的用户数量、缓存命中率或其他东西。除此之外,也许平均情况对你很重要,也许你的瓶颈是少数极端场景。
|
||||
|
||||
为了使这个概念更加具体,我们以推特在2012年11月发布的数据【16】为例。推特的两个主要业务是:
|
||||
为了使这个概念更加具体,我们以推特在2012年11月发布的数据【16】为例。推特的两个主要业务是:
|
||||
|
||||
***发布推文***
|
||||
|
||||
@ -178,7 +182,9 @@
|
||||
|
||||
用户可以查阅他们关注的人发布的推文(300k请求/秒)。
|
||||
|
||||
处理每秒12,000次写入(发推文的速率峰值)还是很简单的。然而推特的扩展性挑战并不是主要来自推特量,而是来自**扇出(fan-out)**——每个用户关注了很多人,也被很多人关注。
|
||||
|
||||
|
||||
处理每秒12,000次写入(发推文的速率峰值)还是很简单的。然而推特的扩展性挑战并不是主要来自推特量,而是来自**扇出(fan-out)**——每个用户关注了很多人,也被很多人关注。
|
||||
|
||||
[^ii]: 扇出:从电子工程学中借用的术语,它描述了输入连接到另一个门输出的逻辑门数量。 输出需要提供足够的电流来驱动所有连接的输入。 在事务处理系统中,我们使用它来描述为了服务一个传入请求而需要执行其他服务的请求数量。
|
||||
|
||||
@ -205,11 +211,11 @@
|
||||
|
||||
推特的第一个版本使用了方法1,但系统很难跟上主页时间线查询的负载。所以公司转向了方法2,方法2的效果更好,因为发推频率比查询主页时间线的频率几乎低了两个数量级,所以在这种情况下,最好在写入时做更多的工作,而在读取时做更少的工作。
|
||||
|
||||
然而方法2的缺点是,发推现在需要大量的额外工作。平均来说,一条推文会发往约75个关注者,所以每秒4.6k的发推写入,变成了对主页时间线缓存每秒345k的写入。但这个平均值隐藏了用户粉丝数差异巨大这一现实,一些用户有超过3000万的粉丝,这意味着一条推文就可能会导致主页时间线缓存的3000万次写入!及时完成这种操作是一个巨大的挑战 —— 推特尝试在5秒内向粉丝发送推文。
|
||||
然而方法2的缺点是,发推现在需要大量的额外工作。平均来说,一条推文会发往约75个关注者,所以每秒4.6k的发推写入,变成了对主页时间线缓存每秒345k的写入。但这个平均值隐藏了用户粉丝数差异巨大这一现实,一些用户有超过3000万的粉丝,这意味着一条推文就可能会导致主页时间线缓存的3000万次写入!及时完成这种操作是一个巨大的挑战 —— 推特尝试在5秒内向粉丝发送推文。
|
||||
|
||||
在推特的例子中,每个用户粉丝数的分布(可能按这些用户的发推频率来加权)是探讨可扩展性的一个关键负载参数,因为它决定了扇出负载。你的应用程序可能具有非常不同的特征,但可以采用相似的原则来考虑它的负载。
|
||||
在推特的例子中,每个用户粉丝数的分布(可能按这些用户的发推频率来加权)是探讨可扩展性的一个关键负载参数,因为它决定了扇出负载。你的应用程序可能具有非常不同的特征,但可以采用相似的原则来考虑它的负载。
|
||||
|
||||
推特轶事的最终转折:现在已经稳健地实现了方法2,推特逐步转向了两种方法的混合。大多数用户发的推文会被扇出写入其粉丝主页时间线缓存中。但是少数拥有海量粉丝的用户(即名流)会被排除在外。当用户读取主页时间线时,分别地获取出该用户所关注的每位名流的推文,再与用户的主页时间线缓存合并,如方法1所示。这种混合方法能始终如一地提供良好性能。在[第12章](ch12.md)中我们将重新讨论这个例子,这在覆盖更多技术层面之后。
|
||||
推特轶事的最终转折:现在已经稳健地实现了方法2,推特逐步转向了两种方法的混合。大多数用户发的推文会被扇出写入其粉丝主页时间线缓存中。但是少数拥有海量粉丝的用户(即名流)会被排除在外。当用户读取主页时间线时,分别地获取出该用户所关注的每位名流的推文,再与用户的主页时间线缓存合并,如方法1所示。这种混合方法能始终如一地提供良好性能。在[第12章](ch12.md)中我们将重新讨论这个例子,这在覆盖更多技术层面之后。
|
||||
|
||||
### 描述性能
|
||||
|
||||
@ -220,7 +226,7 @@
|
||||
|
||||
这两个问题都需要性能数据,所以让我们简单地看一下如何描述系统性能。
|
||||
|
||||
对于Hadoop这样的批处理系统,通常关心的是**吞吐量(throughput)**,即每秒可以处理的记录数量,或者在特定规模数据集上运行作业的总时间[^iii]。对于在线系统,通常更重要的是服务的**响应时间(response time)**,即客户端发送请求到接收响应之间的时间。
|
||||
对于Hadoop这样的批处理系统,通常关心的是**吞吐量(throughput)**,即每秒可以处理的记录数量,或者在特定规模数据集上运行作业的总时间[^iii]。对于在线系统,通常更重要的是服务的**响应时间(response time)**,即客户端发送请求到接收响应之间的时间。
|
||||
|
||||
[^iii]: 理想情况下,批量作业的运行时间是数据集的大小除以吞吐量。 在实践中由于数据倾斜(数据不是均匀分布在每个工作进程中),需要等待最慢的任务完成,所以运行时间往往更长。
|
||||
|
||||
@ -229,39 +235,39 @@
|
||||
> **延迟(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)
|
||||
|
||||
**图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百分位点(缩写为p95,p99和p999)。它们意味着95%,99%或99.9%的请求响应时间要比该阈值快,例如:如果第95百分位点响应时间是1.5秒,则意味着100个请求中的95个响应时间快于1.5秒,而100个请求中的5个响应时间超过1.5秒。如[图1-4](img/fig1-4.png)所示。
|
||||
为了弄清异常值有多糟糕,可以看看更高的百分位点,例如第95、99和99.9百分位点(缩写为p95,p99和p999)。它们意味着95%,99%或99.9%的请求响应时间要比该阈值快,例如:如果第95百分位点响应时间是1.5秒,则意味着100个请求中的95个响应时间快于1.5秒,而100个请求中的5个响应时间超过1.5秒。如[图1-4](img/fig1-4.png)所示。
|
||||
|
||||
响应时间的高百分位点(也称为**尾部延迟(tail latencies)**)非常重要,因为它们直接影响用户的服务体验。例如亚马逊在描述内部服务的响应时间要求时以99.9百分位点为准,即使它只影响一千个请求中的一个。这是因为请求响应最慢的客户往往也是数据最多的客户,也可以说是最有价值的客户 —— 因为他们掏钱了【19】。保证网站响应迅速对于保持客户的满意度非常重要,亚马逊观察到:响应时间增加100毫秒,销售量就减少1%【20】;而另一些报告说:慢 1 秒钟会让客户满意度指标减少16%【21,22】。
|
||||
响应时间的高百分位点(也称为**尾部延迟(tail latencies)**)非常重要,因为它们直接影响用户的服务体验。例如亚马逊在描述内部服务的响应时间要求时以99.9百分位点为准,即使它只影响一千个请求中的一个。这是因为请求响应最慢的客户往往也是数据最多的客户,也可以说是最有价值的客户 —— 因为他们掏钱了【19】。保证网站响应迅速对于保持客户的满意度非常重要,亚马逊观察到:响应时间增加100毫秒,销售量就减少1%【20】;而另一些报告说:慢 1 秒钟会让客户满意度指标减少16%【21,22】。
|
||||
|
||||
另一方面,优化第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】。
|
||||
|
||||
> #### 实践中的百分位点
|
||||
>
|
||||
> 在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行呼叫完成。如[图1-5](img/fig1-5.png)所示,只需要一个缓慢的呼叫就可以使整个最终用户请求变慢。即使只有一小部分后端呼叫速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(效果称为尾部延迟放大【24】)。
|
||||
> 在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行呼叫完成。如[图1-5](img/fig1-5.png)所示,只需要一个缓慢的呼叫就可以使整个最终用户请求变慢。即使只有一小部分后端呼叫速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(效果称为尾部延迟放大【24】)。
|
||||
>
|
||||
> 如果您想将响应时间百分点添加到您的服务的监视仪表板,则需要持续有效地计算它们。例如,您可能希望在最近10分钟内保持请求响应时间的滚动窗口。每一分钟,您都会计算出该窗口中的中值和各种百分数,并将这些度量值绘制在图上。
|
||||
> 如果您想将响应时间百分点添加到您的服务的监视仪表板,则需要持续有效地计算它们。例如,您可能希望在最近10分钟内保持请求响应时间的滚动窗口。每一分钟,您都会计算出该窗口中的中值和各种百分数,并将这些度量值绘制在图上。
|
||||
>
|
||||
> 简单的实现是在时间窗口内保存所有请求的响应时间列表,并且每分钟对列表进行排序。如果对你来说效率太低,那么有一些算法能够以最小的CPU和内存成本(如前向衰减【25】,t-digest【26】或HdrHistogram 【27】)来计算百分位数的近似值。请注意,平均百分比(例如,减少时间分辨率或合并来自多台机器的数据)在数学上没有意义 - 聚合响应时间数据的正确方法是添加直方图【28】。
|
||||
> 简单的实现是在时间窗口内保存所有请求的响应时间列表,并且每分钟对列表进行排序。如果对你来说效率太低,那么有一些算法能够以最小的CPU和内存成本(如前向衰减【25】,t-digest【26】或HdrHistogram 【27】)来计算百分位数的近似值。请注意,平均百分比(例如,减少时间分辨率或合并来自多台机器的数据)在数学上没有意义 - 聚合响应时间数据的正确方法是添加直方图【28】。
|
||||
|
||||
![](img/fig1-5.png)
|
||||
|
||||
@ -269,35 +275,35 @@
|
||||
|
||||
### 应对负载的方法
|
||||
|
||||
现在我们已经讨论了用于描述负载的参数和用于衡量性能的指标。可以开始认真讨论可扩展性了:当负载参数增加时,如何保持良好的性能?
|
||||
现在我们已经讨论了用于描述负载的参数和用于衡量性能的指标。可以开始认真讨论可扩展性了:当负载参数增加时,如何保持良好的性能?
|
||||
|
||||
适应某个级别负载的架构不太可能应付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)***
|
||||
|
||||
@ -311,11 +317,11 @@
|
||||
|
||||
使工程师在未来能轻松地对系统进行更改,当需求变化时为新应用场景做适配。也称为**可扩展性(extensibility)**,**可修改性(modifiability)**或**可塑性(plasticity)**。
|
||||
|
||||
和之前提到的可靠性、可扩展性一样,实现这些目标也没有简单的解决方案。不过我们会试着想象具有可操作性,简单性和可演化性的系统会是什么样子。
|
||||
和之前提到的可靠性、可扩展性一样,实现这些目标也没有简单的解决方案。不过我们会试着想象具有可操作性,简单性和可演化性的系统会是什么样子。
|
||||
|
||||
### 可操作性:人生苦短,关爱运维
|
||||
|
||||
有人认为,“良好的运维经常可以绕开垃圾(或不完整)软件的局限性,而再好的软件摊上垃圾运维也没法可靠运行”。尽管运维的某些方面可以,而且应该是自动化的,但在最初建立正确运作的自动化机制仍然取决于人。
|
||||
有人认为,“良好的运维经常可以绕开垃圾(或不完整)软件的局限性,而再好的软件摊上垃圾运维也没法可靠运行”。尽管运维的某些方面可以,而且应该是自动化的,但在最初建立正确运作的自动化机制仍然取决于人。
|
||||
|
||||
运维团队对于保持软件系统顺利运行至关重要。一个优秀运维团队的典型职责如下(或者更多)【29】:
|
||||
|
||||
@ -344,49 +350,49 @@
|
||||
|
||||
### 简单性:管理复杂度
|
||||
|
||||
小型软件项目可以使用简单讨喜的、富表现力的代码,但随着项目越来越大,代码往往变得非常复杂,难以理解。这种复杂度拖慢了所有系统相关人员,进一步增加了维护成本。一个陷入复杂泥潭的软件项目有时被描述为**烂泥潭(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)中的例子)
|
||||
|
||||
|
||||
|
||||
|
410
ch11.md
410
ch11.md
@ -10,28 +10,28 @@
|
||||
|
||||
[TOC]
|
||||
|
||||
在[第10章](ch10.md)中,我们讨论了批处理技术,它将一组文件读取为输入并生成一组新的输出文件。输出是**衍生数据(derived data)**的一种形式;也就是说,如果需要,可以通过再次运行批处理过程来重新创建数据集。我们看到了如何使用这个简单而强大的想法来建立搜索索引,推荐系统,做分析等等。
|
||||
在[第10章](ch10.md)中,我们讨论了批处理技术,它读取一组文件作为输入,并生成一组新的文件作为输出。输出是**衍生数据(derived data)**的一种形式;也就是说,如果需要,可以通过再次运行批处理过程来重新创建数据集。我们看到了如何使用这个简单而强大的想法来建立搜索索引,推荐系统,做分析等等。
|
||||
|
||||
然而,在[第10章](ch10.md)中仍然有一个很大的假设:即输入是有界的,即已知和有限的大小,所以批处理知道它何时完成了它的输入。例如,MapReduce核心的排序操作必须读取其全部输入,然后才能开始生成输出:可能发生这种情况:最后一条输入记录具有最小的键,因此需要第一个被输出,所以提早开始输出是不可行的。
|
||||
然而,在[第10章](ch10.md)中仍然有一个很大的假设:即输入是有界的,即已知和有限的大小,所以批处理知道它何时完成输入的读取。例如,MapReduce核心的排序操作必须读取其全部输入,然后才能开始生成输出:可能发生这种情况:最后一条输入记录具有最小的键,因此需要第一个被输出,所以提早开始输出是不可行的。
|
||||
|
||||
实际上,很多数据是无限的,因为它随着时间的推移而逐渐到达:你的用户昨天和今天产生了数据,明天他们将继续产生更多的数据。除非你停业,否则这个过程永远都不会结束,所以数据集从来就不会以任何有意义的方式“完成”【1】。因此,批处理程序必须将数据人为地分成固定时间段的数据块,例如,在每天结束时处理一天的数据,或者在每小时结束时处理一小时的数据。
|
||||
实际上,很多数据是**无界限**的,因为它随着时间的推移而逐渐到达:你的用户在昨天和今天产生了数据,明天他们将继续产生更多的数据。除非你停业,否则这个过程永远都不会结束,所以数据集从来就不会以任何有意义的方式“完成”【1】。因此,批处理程序必须将数据人为地分成固定时间段的数据块,例如,在每天结束时处理一天的数据,或者在每小时结束时处理一小时的数据。
|
||||
|
||||
日常批处理中的问题是,输入的更改只会在一天之后的输出中反映出来,这对于许多急躁的用户来说太慢了。为了减少延迟,我们可以更频繁地运行处理 —— 比如说,在每秒钟的末尾 —— 或者甚至更连续一些,完全抛开固定的时间切片,当事件发生时就立即进行处理,这就是**流处理(stream processing)**背后的想法。
|
||||
日常批处理中的问题是,输入的变更只会在一天之后的输出中反映出来,这对于许多急躁的用户来说太慢了。为了减少延迟,我们可以更频繁地运行处理 —— 比如说,在每秒钟的末尾 —— 或者甚至更连续一些,完全抛开固定的时间切片,当事件发生时就立即进行处理,这就是**流处理(stream processing)**背后的想法。
|
||||
|
||||
一般来说,“流”是指随着时间的推移逐渐可用的数据。这个概念出现在很多地方:Unix的stdin和stdout,编程语言(lazy lists)【2】,文件系统API(如Java的FileInputStream),TCP连接,通过互联网传送音频和视频等等。
|
||||
在本章中,我们将把**事件流(event stream)**视为一种数据管理机制:无界限,增量处理,与[上一章](ch10.md)中批量数据相对应的。我们将首先讨论怎样表示、存储、通过网络传输流。在“[数据库和流](#数据库和流)”中,我们将研究流和数据库之间的关系。最后在“[流处理](#流处理)”中,我们将研究**连续处理**这些流的方法和工具,以及它们用于应用构建的方式。
|
||||
一般来说,“流”是指随着时间的推移逐渐可用的数据。这个概念出现在很多地方:Unix的stdin和stdout,编程语言(惰性列表)【2】,文件系统API(如Java的`FileInputStream`),TCP连接,通过互联网传送音频和视频等等。
|
||||
在本章中,我们将把**事件流(event stream)**视为一种数据管理机制:无界限,增量处理,与[上一章](ch10.md)中批量数据相对应。我们将首先讨论怎样表示、存储、通过网络传输流。在“[数据库和流](#数据库和流)”中,我们将研究流和数据库之间的关系。最后在“[流处理](#流处理)”中,我们将研究连续处理这些流的方法和工具,以及它们用于应用构建的方式。
|
||||
|
||||
|
||||
|
||||
## 传递事件流
|
||||
|
||||
在批处理领域,作业的输入和输出是文件(也许在分布式文件系统上)。什么是类似的流媒体?
|
||||
在批处理领域,作业的输入和输出是文件(也许在分布式文件系统上)。流处理领域中的等价物看上去是什么样子的?
|
||||
|
||||
当输入是一个文件(一个字节序列)时,第一个处理步骤通常是将其解析为一系列记录。在流处理的上下文中,记录通常被称为事件,但它本质上是一样的:一个小的,自包含的,不可变的对象,包含某个时间点发生的事情的细节。一个事件通常包含一个时间戳,指示何时根据时钟来发生(参见“[单调钟与时钟](ch8.md#单调钟与时钟)”)。
|
||||
当输入是一个文件(一个字节序列),第一个处理步骤通常是将其解析为一系列记录。在流处理的上下文中,记录通常被叫做**事件(event)**,但它本质上是一样的:一个小的,自包含的,不可变的对象,包含某个时间点发生的某件事情的细节。一个事件通常包含一个来自时钟的时间戳,以指明事件发生的时间(参见“[单调钟与时钟](ch8.md#单调钟与时钟)”)。
|
||||
|
||||
例如,发生的事情可能是用户采取的行动,例如查看页面或进行购买。它也可能来源于机器,例如来自温度传感器的周期性测量或者CPU利用率度量。在“[使用Unix工具进行批处理](ch10.md#使用Unix工具进行批处理)”的示例中,Web服务器日志的每一行都是一个事件。
|
||||
例如,发生的事件可能是用户采取的行动,例如查看页面或进行购买。它也可能来源于机器,例如对温度传感器或CPU利用率的周期性测量。在“[使用Unix工具进行批处理](ch10.md#使用Unix工具进行批处理)”的示例中,Web服务器日志的每一行都是一个事件。
|
||||
|
||||
事件可能被编码为文本字符串或JSON,或者以某种二进制形式编码,如第4章所述。这种编码允许您存储一个事件,例如将其附加到一个文件,将其插入关系表,或将其写入文档数据库。它还允许您通过网络将事件发送到另一个节点以进行处理。
|
||||
事件可能被编码为文本字符串或JSON,或者某种二进制编码,如[第4章](ch4.md)所述。这种编码允许你存储一个事件,例如将其附加到一个文件,将其插入关系表,或将其写入文档数据库。它还允许你通过网络将事件发送到另一个节点以进行处理。
|
||||
|
||||
在批处理领域,作业的输入和输出是文件(也许在分布式文件系统上)。什么是类似的流媒体?
|
||||
|
||||
@ -39,383 +39,379 @@
|
||||
|
||||
例如,发生的事情可能是用户采取的行动,例如查看页面或进行购买。它也可能来源于机器,例如来自温度传感器的周期性测量或者CPU利用率度量。在“[使用Unix工具进行批处理](ch10.md#使用Unix工具进行批处理)”的示例中,Web服务器日志的每一行都是一个事件。
|
||||
|
||||
事件可能被编码为文本字符串或JSON,或者以某种二进制形式编码,如[第4章](ch4.md)所述。这种编码允许您存储一个事件,例如将其附加到一个文件,将其插入关系表,或将其写入文档数据库。它还允许您通过网络将事件发送到另一个节点以进行处理。
|
||||
事件可能被编码为文本字符串或JSON,或者以某种二进制形式编码,如[第4章](ch4.md)所述。这种编码允许你存储一个事件,例如将其追加写入一个文件,将其插入关系型表,或将其写入文档数据库。它还允许你通过网络将事件发送到其他节点以进行处理。
|
||||
|
||||
在批处理中,文件被写入一次,然后可能被多个作业读取。类似地,在流媒体术语中,一个事件由生产者(也称为发布者或发送者)生成一次,然后由多个消费者(订阅者或接收者)进行处理【3】。在文件系统中,文件名标识一组相关记录;在流媒体系统中,相关的事件通常被组合成一个主题或流。
|
||||
在批处理中,文件被写入一次,然后可能被多个作业读取。类似地,在流处理术语中,一个事件由**生产者(producer)**(也称为**发布者(publisher)**或**发送者(sender)**)生成一次,然后可能由多个**消费者(consumer)**(**订阅者(subscribers)**或**接收者(recipients)**)进行处理【3】。在文件系统中,文件名标识一组相关记录;在流媒体系统中,相关的事件通常被聚合为一个**主题(topic)**或**流(stream)**。
|
||||
|
||||
原则上,文件或数据库足以连接生产者和消费者:生产者将其生成的每个事件写入数据存储,并且每个使用者定期轮询数据存储以检查自上次运行以来出现的事件。这实际上是批处理在每天结束时处理一天的数据的过程。
|
||||
原则上将,文件或数据库就足以连接生产者和消费者:生产者将其生成的每个事件写入数据存储,且每个消费者定期轮询数据存储,检查自上次运行以来新出现的事件。这实际上正是批处理在每天结束时处理当天数据时所做的事情。
|
||||
|
||||
但是,如果数据存储不是为这种用途而设计的,那么在延迟较小的情况下继续进行处理时,轮询将变得非常昂贵。您调查的次数越多,返回新事件的请求百分比越低,因此开销越高。相反,当新事件出现时,最好通知消费者。
|
||||
但当我们想要进行低延迟的连续处理时,如果数据存储不是为这种用途专门设计的,那么轮询开销就会很大。轮询的越频繁,能返回新事件的请求比例就越低,而额外开销也就越高。相比之下,最好能在新事件出现时直接通知消费者。
|
||||
|
||||
数据库传统上不太支持这种通知机制:关系数据库通常具有触发器,它们可以对变化作出反应(例如,将一行插入到表中),但是它们的功能非常有限,有点在数据库设计中是事后的【4,5】。相反,已经开发了专门的工具来提供事件通知。
|
||||
数据库在传统上对这种通知机制支持的并不好,关系型数据库通常有**触发器(trigger)**,它们可以对变化作出反应(如,插入表中的一行),但它们的功能非常有限,而且在数据库设计中算是一种事后反思【4,5】。相应的是,已经有为传递事件通知这一目开发的专用工具已经被开发出来。
|
||||
|
||||
|
||||
|
||||
### 消息系统
|
||||
|
||||
通知消费者有关新事件的常用方法是使用消息传递系统:生产者发送包含事件的消息,然后将消息推送给消费者。我们之前在“[消息传递中的数据流](ch4.md#消息传递中的数据流)”中介绍了这些系统,但现在我们将详细介绍这些系统。
|
||||
向消费者通知新事件的常用方式是使用**消息传递系统(messaging system)**:生产者发送包含事件的消息,然后将消息推送给消费者。我们之前在“[消息传递中的数据流](ch4.md#消息传递中的数据流)”中介绍了这些系统,但现在我们将详细介绍这些系统。
|
||||
|
||||
像生产者和消费者之间的Unix管道或TCP连接这样的直接通信渠道将是实现消息传递系统的简单方法。但是,大多数消息传递系统都在这个基本模型上扩展特别是,Unix管道和TCP将一个发送者与一个接收者完全连接,而一个消息传递系统允许多个生产者节点将消息发送到相同的主题,并允许多个消费者节点接收主题中的消息。
|
||||
像生产者和消费者之间的Unix管道或TCP连接这样的直接信道,是实现消息传递系统的简单方法。但是,大多数消息传递系统都在这一基本模型上进行扩展。特别的是,Unix管道和TCP将恰好一个发送者与恰好一个接收者连接,而一个消息传递系统允许多个生产者节点将消息发送到同一个主题,并允许多个消费者节点接收主题中的消息。
|
||||
|
||||
在这个发布/订阅模式中,不同的系统采取多种方法,对于所有目的都没有一个正确的答案。为了区分这些系统,询问以下两个问题特别有帮助:
|
||||
在这个**发布/订阅**模式中,不同的系统采取各种各样的方法,并没有针对所有目的的通用答案。为了区分这些系统,问一下这两个问题会特别有帮助:
|
||||
|
||||
1. 如果生产者发送消息的速度比消费者能够处理的速度快,会发生什么?一般来说,有三种选择:系统可以放置消息,缓冲队列中的消息或应用背压(也称为流量控制;即阻止生产者发送更多的消息)。例如,Unix管道和TCP使用背压:他们有一个小的固定大小的缓冲区,如果填满,发件人被阻塞,直到收件人从缓冲区中取出数据(参见“[网络拥塞和排队](ch8.md#网络拥塞和排队)”)。
|
||||
1. **如果生产者发送消息的速度比消费者能够处理的速度快会发生什么?**一般来说,有三种选择:系统可以丢掉消息,将消息放入缓冲队列,或使用**背压(backpressure)**(也称为**流量控制(flow control);**即阻塞生产者,以免其发送更多的消息)。例如Unix管道和TCP使用背压:它们有一个固定大小的小缓冲区,如果填满,发送者会被阻塞,直到接收者从缓冲区中取出数据(参见“[网络拥塞和排队](ch8.md#网络拥塞和排队)”)。
|
||||
|
||||
如果消息被缓存在队列中,那么了解该队列增长会发生什么很重要。如果队列不再适合内存,或者将消息写入磁盘,系统是否会崩溃?如果是这样,磁盘访问如何影响邮件系统的性能【6】?
|
||||
如果消息被缓存在队列中,那么理解队列增长会发生什么是很重要的。当队列装不进内存时系统会崩溃吗?还是将消息写入磁盘?如果是这样,磁盘访问又会如何影响消息传递系统的性能【6】?
|
||||
|
||||
2. 如果节点崩溃或暂时脱机,会发生什么情况 - 是否有消息丢失?与数据库一样,持久性可能需要写入磁盘和/或复制的某种组合(参阅“[复制和持久性](ch7.md#复制和持久性)”),这有成本。如果您有时可能会丢失消息,则可能在同一硬件上获得更高的吞吐量和更低的延迟。
|
||||
2. **如果节点崩溃或暂时脱机,会发生什么情况? —— 是否会有消息丢失?**与数据库一样,持久性可能需要写入磁盘**和/或**复制的某种组合(参阅“[复制和持久性](ch7.md#复制和持久性)”),这是有代价的。如果你能接受有时消息会丢失,则可能在同一硬件上获得更高的吞吐量和更低的延迟。
|
||||
|
||||
消息丢失是否可以接受取决于应用程序。例如,对于定期传输的传感器读数和指标,偶尔丢失的数据点可能并不重要,因为更新后的值将在短时间后发送。但是,要注意,如果大量的消息被丢弃,那么衡量标准是不正确的【7】。如果您正在计数事件,那么更重要的是它们可靠地传送,因为每个丢失的消息都意味着不正确的计数器。
|
||||
是否可以接受消息丢失取决于应用。例如,对于周期传输的传感器读数和指标,偶尔丢失的数据点可能并不重要,因为更新的值会在短时间内发出。但要注意,如果大量的消息被丢弃,可能无法立刻意识到指标已经不正确了【7】。如果你正在对事件计数,那么更重要的是它们能够可靠送达,因为每个丢失的消息都意味着使计数器的错误扩大。
|
||||
|
||||
我们在[第10章](ch10.md)中探讨的批处理系统的一个很好的特性是它们提供了强大的可靠性保证:失败的任务会自动重试,失败任务的部分输出会自动丢弃。这意味着输出与没有发生故障一样,这有助于简化编程模型。在本章的后面,我们将研究如何在流式上下文中提供类似的保证。
|
||||
我们在[第10章](ch10.md)中探讨的批处理系统的一个很好的特性是,它们提供了强大的可靠性保证:失败的任务会自动重试,失败任务的部分输出会自动丢弃。这意味着输出与没有发生故障一样,这有助于简化编程模型。在本章的后面,我们将研究如何在流处理的上下文中提供类似的保证。
|
||||
|
||||
#### 直接从生产者传递给消费者
|
||||
|
||||
许多消息传递系统使用生产者和消费者之间的直接网络通信,而不通过中间节点:
|
||||
|
||||
* UDP组播广泛应用于金融行业,例如股票市场,其中低时延非常重要【8】。虽然UDP本身是不可靠的,但应用程序级别的协议可以恢复丢失的数据包(生产者必须记住它发送的数据包,以便它可以按需重新发送数据包)。
|
||||
|
||||
* UDP组播广泛应用于金融行业,例如股票市场,其中低时延非常重要【8】。虽然UDP本身是不可靠的,但应用层的协议可以恢复丢失的数据包(生产者必须记住它发送的数据包,以便能按需重新发送数据包)。
|
||||
* 无代理的消息库,如ZeroMQ 【9】和nanomsg采取类似的方法,通过TCP或IP多播实现发布/订阅消息传递。
|
||||
* StatsD 【10】和Brubeck 【7】使用不可靠的UDP消息传递来收集网络中所有机器的指标并对其进行监控。 (在StatsD协议中,只有接收到所有消息,才认为计数器指标是正确的;使用UDP将使得指标处在一种最佳近似状态【11】。另请参阅“[TCP与UDP](ch8.md#TCP与UDP)”
|
||||
* 如果消费者在网络上公开了服务,生产者可以直接发送HTTP或RPC请求(参阅“[通过服务进行数据流:REST和RPC](ch4.md#通过服务进行数据流:REST和RPC)”)将消息推送给使用者。这就是webhooks背后的想法【12】,一种服务的回调URL被注册到另一个服务中,并且每当事件发生时都会向该URL发出请求。
|
||||
|
||||
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发出请求。
|
||||
|
||||
尽管这些直接消息传递系统在设计它们的情况下运行良好,但是它们通常要求应用程序代码知道消息丢失的可能性。他们可以容忍的错误是相当有限的:即使协议检测并重新传输在网络中丢失的数据包,他们通常假设生产者和消费者不断在线。
|
||||
|
||||
如果消费者处于脱机状态,则可能会丢失在无法访问时发送的消息。一些协议允许生产者重试失败的消息传递,但是如果生产者崩溃,这种方法可能会失效,失去了它应该重试的消息的缓冲区。
|
||||
如果消费者处于脱机状态,则可能会丢失其不可达时发送的消息。一些协议允许生产者重试失败的消息传递,但当生产者崩溃时,它可能会丢失消息缓冲区及其本应发送的消息,这种方法可能就没用了。
|
||||
|
||||
#### 消息代理
|
||||
|
||||
一个广泛使用的替代方法是通过消息代理(也称为消息队列)发送消息,消息代理实质上是一种针对处理消息流而优化的数据库。它作为服务器运行,生产者和消费者作为客户连接到服务器。生产者将消息写入代理,消费者通过从代理那里读取消息来接收他们。
|
||||
一种广泛使用的替代方法是通过**消息代理(message broker)**(也称为**消息队列(message queue)**)发送消息,消息代理实质上是一种针对处理消息流而优化的数据库。它作为服务器运行,生产者和消费者作为客户端连接到服务器。生产者将消息写入代理,消费者通过从代理那里读取来接收消息。
|
||||
|
||||
通过将数据集中在代理中,这些系统可以更容易地容忍来来去去的客户端(连接,断开连接和崩溃),而耐久性问题则转移到代理商。一些消息代理只将消息保存在内存中,而另一些消息代理(取决于配置)将其写入磁盘,以便在代理崩溃的情况下不会丢失。面对缓慢的消费者,他们通常会允许排除异常的排队(而不是丢弃消息或背压),尽管这种选择也可能取决于配置。
|
||||
通过将数据集中在代理上,这些系统可以更容易地容忍来来去去的客户端(连接,断开连接和崩溃),而持久性问题则转移到代理的身上。一些消息代理只将消息保存在内存中,而另一些消息代理(取决于配置)将其写入磁盘,以便在代理崩溃的情况下不会丢失。针对缓慢的消费者,它们通常会允许无上限的排队(而不是丢弃消息或背压),尽管这种选择也可能取决于配置。
|
||||
|
||||
排队的结果也是消费者通常是异步的:当生产者发送消息时,通常只等待代理确认已经缓存消息,不等待消息被消费者处理。向消费者的交付将发生在未来某个未定的时间点 - 通常在几分之一秒之内,但有时在队列积压之后显着延迟。
|
||||
排队的结果是,消费者通常是**异步(asynchronous)**的:当生产者发送消息时,通常只会等待代理确认消息已经被缓存,而不等待消息被消费者处理。向消费者递送消息将发生在未来某个未定的时间点 —— 通常在几分之一秒之内,但有时当消息堆积时会显著延迟。
|
||||
|
||||
#### 消息代理与数据库进行比较
|
||||
#### 消息代理与数据库对比
|
||||
|
||||
有些消息代理甚至可以使用XA或JTA参与两阶段提交协议(参阅“[实践中的分布式事务](ch9.md#实践中的分布式事务)”)。这个功能与数据库在本质上非常相似,尽管消息代理和数据库之间仍存在重要的实际差异:
|
||||
有些消息代理甚至可以使用XA或JTA参与两阶段提交协议(参阅“[实践中的分布式事务](ch9.md#实践中的分布式事务)”)。这个功能与数据库在本质上非常相似,尽管消息代理和数据库之间仍存在实践上很重要的差异:
|
||||
|
||||
* 数据库通常保留数据,直到被明确删除,而大多数消息代理在消息成功传递给消费者时自动删除消息。这样的消息代理不适合长期的数据存储。
|
||||
* 由于他们很快删除了邮件,大多数邮件代理都认为他们的工作集合相当小,即队列很短。如果代理需要缓冲很多消息,因为消费者速度较慢(如果消息不再适合内存,则可能会将消息泄漏到磁盘),每个消息需要更长的时间处理,整体吞吐量可能会降低【6】。
|
||||
* 数据库通常支持二级索引和各种搜索数据的方式,而消息代理通常支持某种方式订阅匹配某种模式的主题子集。机制是不同的,但是这两种方式都是客户选择想要了解的数据部分的根本途径。
|
||||
* 查询数据库时,结果通常基于数据的时间点快照;如果另一个客户端随后向数据库写入更改查询结果的内容,则第一个客户端不会发现其先前的结果现在已过期(除非它重复查询或轮询更改)。相比之下,消息代理不支持任意查询,但是当数据发生变化时(即新消息可用时),它们会通知客户端。
|
||||
* 数据库通常保留数据直至显式删除,而大多数消息代理在消息成功递送给消费者时会自动删除消息。这样的消息代理不适合长期的数据存储。
|
||||
* 由于它们很快就能删除消息,大多数消息代理都认为它们的工作集相当小—— 即队列很短。如果代理需要缓冲很多消息,比如因为消费者速度较慢(如果内存装不下消息,可能会溢出到磁盘),每个消息需要更长的处理时间,整体吞吐量可能会恶化【6】。
|
||||
* 数据库通常支持二级索引和各种搜索数据的方式,而消息代理通常支持按照某种模式匹配主题,订阅其子集。机制并不一样,对于客户端选择想要了解的数据的一部分,这是两种基本的方式。
|
||||
* 查询数据库时,结果通常基于某个时间点的数据快照;如果另一个客户端随后向数据库写入一些改变了查询结果的内容,则第一个客户端不会发现其先前结果现已过期(除非它重复查询或轮询变更)。相比之下,消息代理不支持任意查询,但是当数据发生变化时(即新消息可用时),它们会通知客户端。
|
||||
|
||||
这是消息代理的传统观点,它被封装在JMS 【14】和AMQP 【15】等标准中,并以RabbitMQ,ActiveMQ,HornetQ,Qpid,TIBCO企业消息服务,IBM MQ,Azure服务总线和Google Cloud Pub/Sub 【16】。
|
||||
这是关于消息代理的传统观点,它被封装在诸如JMS 【14】和AMQP 【15】的标准中,并且被诸如RabbitMQ,ActiveMQ,HornetQ,Qpid,TIBCO企业消息服务,IBM MQ,Azure Service Bus和Google Cloud Pub/Sub实现 【16】。
|
||||
|
||||
#### 多个消费者
|
||||
|
||||
当多个消费者阅读同一主题中的消息时,将使用两种主要的消息传递模式,如图11-1所示:
|
||||
当多个消费者从同一主题中读取消息时,有使用两种主要的消息传递模式,如[图11-1](img/fig11-1.png)所示:
|
||||
|
||||
***负载均衡***
|
||||
***负载均衡(load balance)***
|
||||
|
||||
每条消息都被传递给其中一个消费者,所以消费者可以共享处理消息的主题。代理可以任意分配消息给消费者。当处理消息的代价很高时,此模式非常有用,因此您希望能够添加消费者来并行处理消息。 (在AMQP中,可以通过让多个客户端从同一个队列中消费来实现负载均衡,而在JMS中则称为共享订阅)。
|
||||
每条消息都被传递给消费者**之一**,所以处理该主题下消息的工作能被多个消费者共享。代理可以为消费者任意分配消息。当处理消息的代价高昂,希望能并行处理消息时,此模式非常有用(在AMQP中,可以通过让多个客户端从同一个队列中消费来实现负载均衡,而在JMS中则称之为**共享订阅(shared subscription)**)。
|
||||
|
||||
***扇出***
|
||||
***扇出(fan-out)***
|
||||
|
||||
每条消息都被传递给所有的消费者。扇出允许几个独立的消费者每个“收听”相同的消息广播,而不会相互影响 —— 流式等同于具有读取相同输入文件的多个不同批处理作业。 (此功能由JMS中的主题订阅提供,并在AMQP中交换绑定。)
|
||||
每条消息都被传递给**所有**消费者。扇出允许几个独立的消费者各自“收听”相同的消息广播,而不会相互影响 —— 这个流处理中的概念对应批处理中多个不同批处理作业读取同一份输入文件 (JMS中的主题订阅与AMQP中的交叉绑定提供了这一功能)。
|
||||
|
||||
![](img/fig11-1.png)
|
||||
|
||||
**图11-1。 (a)负载平衡:分担消费者之间消费话题的工作; (b)扇出:将消息传递给多个消费者。**
|
||||
**图11-1 (a)负载平衡:在消费者间共享消费主题;(b)扇出:将每条消息传递给多个消费者。**
|
||||
|
||||
两种模式可以组合:例如,两个独立的消费者组可以每个订阅一个话题,使得每个组共同收到所有消息,但是在每个组内,只有一个节点接收每个消息。
|
||||
两种模式可以组合使用:例如,两个独立的消费者组可以每组各订阅一个主题,每一组组都共同收到所有消息,但在每一组内部,每条消息仅由单个节点处理。
|
||||
|
||||
#### 确认和重新交付
|
||||
#### 确认与重新交付
|
||||
|
||||
消费者可能会随时崩溃,所以可能发生的情况是代理向消费者提供消息,但消费者从不处理消费者,或者在消费者崩溃之前只处理消费者。为了确保消息不会丢失,消息代理使用确认:客户端必须明确告诉代理处理消息的时间,以便代理可以将其从队列中移除。
|
||||
消费随时可能会崩溃,所以有一种可能的情况是:代理向消费者递送消息,但消费者没有处理,或者在消费者崩溃之前只进行了部分处理。为了确保消息不会丢失,消息代理使用**确认(acknowledgments)**:客户端必须显式告知代理消息处理完毕的时间,以便代理能将消息从队列中移除。
|
||||
|
||||
如果与客户端的连接关闭或超时而没有代理收到确认,则认为消息未处理,因此它将消息再次传递给另一个消费者。 (请注意,可能发生这样的消息实际上是完全处理的,但网络中的确认丢失了,处理这种情况需要一个原子提交协议,正如在“[实践中的分布式事务](ch9.md#实践中的分布式事务)”中所讨论的那样)
|
||||
如果与客户端的连接关闭,或者代理超出一段时间未收到确认,代理则认为消息没有被处理,因此它将消息再递送给另一个消费者。 (请注意可能发生这样的情况,消息**实际上是**处理完毕的,但**确认**在网络中丢失了。需要一种原子提交协议才能处理这种情况,正如在“[实践中的分布式事务](ch9.md#实践中的分布式事务)”中所讨论的那样)
|
||||
|
||||
当与负载平衡相结合时,这种重新传递行为对消息的排序有一个有趣的影响。在[图11-2](img/fig11-2.png)中,消费者通常按照生产者发送的顺序处理消息。然而,消费者2在处理消息m3时崩溃,与此同时消费者1正在处理消息m4。未确认的消息m3随后被重新发送给消费者1,结果消费者1按照m4,m3,m5的顺序处理消息。因此,m3和m4不是以与生产者1发送的顺序相同的顺序交付的。
|
||||
当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在[图11-2](img/fig11-2.png)中,消费者通常按照生产者发送的顺序处理消息。然而消费者2在处理消息m3时崩溃,与此同时消费者1正在处理消息m4。未确认的消息m3随后被重新发送给消费者1,结果消费者1按照m4,m3,m5的顺序处理消息。因此m3和m4的交付顺序与以生产者1的发送顺序不同。
|
||||
|
||||
![](img/fig11-2.png)
|
||||
|
||||
**图11-2 消费者2在处理m3时崩溃,因此稍后再次向消费者1递送**
|
||||
**图11-2 在处理m3时消费者2崩溃,因此稍后重传至消费者1**
|
||||
|
||||
即使消息代理试图保留消息的顺序(如JMS和AMQP标准所要求的),负载平衡与重新传递的组合也不可避免地导致消息被重新排序。为避免此问题,您可以为每个使用者使用单独的队列(即不使用负载均衡功能)。如果消息是完全独立的,消息重新排序并不是一个问题,但是如果消息之间存在因果依赖关系,这一点很重要,我们将在后面的章节中看到。
|
||||
即使消息代理试图保留消息的顺序(如JMS和AMQP标准所要求的),负载均衡与重传的组合也不可避免地导致消息被重新排序。为避免此问题,你可以让每个消费者使用单独的队列(即不使用负载均衡功能)。如果消息是完全独立的,则消息顺序重排并不是一个问题。但正如我们将在本章后续部分所述,如果消息之间存在因果依赖关系,这就是一个很重要的问题。
|
||||
|
||||
### 分区日志
|
||||
|
||||
通过网络发送数据包或向网络服务发送请求通常是暂时的操作,不会留下永久的痕迹。尽管可以永久记录(使用数据包捕获和日志记录),但我们通常不这么想。即使是信息代理,他们将信息持久地写入磁盘,在被传递给消费者之后,很快就会将其删除,因为它们是以短暂的消息传递思想为基础构建的。
|
||||
通过网络发送数据包或向网络服务发送请求通常是短暂的操作,不会留下永久的痕迹。尽管可以永久记录(通过抓包与日志),但我们通常不这么做。即使是将消息持久地写入磁盘的消息代理,在送达给消费者之后也会很快删除消息,因为它们建立在短暂消息传递的思维方式上。
|
||||
|
||||
数据库和文件系统采用相反的方法:写入数据库或文件的所有内容通常都要被永久记录下来,至少在某些人明确选择将其删除之前。
|
||||
数据库和文件系统采用截然相反的方法论:至少在某人显式删除前,通常写入数据库或文件的所有内容都要被永久记录下来。
|
||||
|
||||
思维方式上的这种差异对如何创建派生数据有很大的影响。批处理过程的一个关键特征是,你可以反复运行它们,试验处理步骤,没有损坏输入的风险(因为输入是只读的)。 AMQP/JMS风格的消息并非如此:如果确认导致从代理中删除消息,则接收消息具有破坏性,因此您不能再次运行同一消费者,并期望得到相同的结果。
|
||||
这种思维方式上的差异对创建衍生数据的方式有巨大影响。如[第10章](ch10.md)所述,批处理过程的一个关键特性是,你可以反复运行它们,试验处理步骤,不用担心损坏输入(因为输入是只读的)。而 AMQP/JMS风格的消息传递并非如此:收到消息是具有破坏性的,因为确认可能导致消息从代理中被删除,因此你不能期望再次运行同一个消费者能得到相同的结果。
|
||||
|
||||
如果您将新消费者添加到消息系统,则通常只会开始接收在注册后发送的消息;任何先前的消息已经消失,无法恢复。将它与文件和数据库进行对比,您可以随时添加新客户端,并且可以读取过去任意写入的数据(只要应用程序没有明确覆盖或删除数据)。
|
||||
如果你将新的消费者添加到消息系统,通常只能接收到消费者注册之后开始发送的消息。先前的任何消息都随风而逝,一去不复返。作为对比,你可以随时为文件和数据库添加新的客户端,且能读取任意久远的数据(只要应用没有显式覆盖或删除这些数据)。
|
||||
|
||||
为什么我们不能混合使用数据库的持久存储方式和低延迟的消息通知功能呢?这是基于日志消息代理的思想。
|
||||
为什么我们不能把它俩杂交一下,既有数据库的持久存储方式,又有消息传递的低延迟通知?这就是**基于日志的消息代理(log-based message brokers)**背后的想法。
|
||||
|
||||
#### 使用日志进行消息存储
|
||||
|
||||
日志只是磁盘上只能追加记录的序列。我们先前在第3章中的日志结构存储引擎和预写日志的上下文中讨论了日志,在[第5章](ch5.md)中也讨论了复制的上下文中。
|
||||
日志只是磁盘上简单的仅追加记录序列。我们先前在[第3章](ch3.md)中日志结构存储引擎和预写式日志的上下文中讨论了日志,在[第5章](ch5.md)复制的上下文里也讨论了它。
|
||||
|
||||
可以使用相同的结构来实现消息代理:生产者通过将消息附加到日志的末尾来发送消息,并且消费者通过依次读取日志来接收消息。如果消费者到达日志的末尾,则等待通知新消息已被追加。 Unix工具`tail -f`监视数据被附加的文件,基本上是这样工作的。
|
||||
同样的结构可以用于实现消息代理:生产者通过将消息追加到日志末尾来发送消息,而消费者通过依次读取日志来接收消息。如果消费者读到日志末尾,则会等待新消息追加的通知。 Unix工具`tail -f` 能监视文件被追加写入的数据,基本上就是这样工作的。
|
||||
|
||||
为了扩展到比单个磁盘提供更高的吞吐量,可以对日志进行分区(在[第6章](ch6.md)的意义上)。然后可以在不同的机器上托管不同的分区,使每个分区成为一个单独的日志,可以独立于其他分区读取和写入。然后可以将一个话题定义为一组分段,它们都携带相同类型的消息。这种方法如[图11-3](img/fig11-3.png)所示。
|
||||
为了扩展到比单个磁盘所能提供的更高吞吐量,可以对日志进行**分区**(在[第6章](ch6.md)的意义上)。不同的分区可以托管在不同的机器上,且每个分区都拆分出一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如[图11-3](img/fig11-3.png)所示。
|
||||
|
||||
在每个分区中,代理为每个消息分配一个单调递增的序列号或偏移量(在[图11-3](img/fig11-3.png)中,框中的数字是消息偏移量)。这样的序列号是有意义的,因为分区是仅附加的,所以分区内的消息是完全有序的。没有跨不同分区的订购保证。
|
||||
在每个分区内,代理为每个消息分配一个单调递增的序列号或**偏移量(offset)**(在[图11-3](img/fig11-3.png)中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。
|
||||
|
||||
![](img/fig11-3.png)
|
||||
|
||||
**图11-3 生产者通过将消息附加到主题分区文件来发送消息,消费者依次读取这些文件**
|
||||
**图11-3 生产者通过将消息追加写入主题分区文件来发送消息,消费者依次读取这些文件**
|
||||
|
||||
Apache Kafka 【17,18】,Amazon Kinesis Streams 【19】和Twitter的DistributedLog 【20,21】都是基于日志的消息代理。 Google Cloud Pub / Sub在体系结构上相似,但是暴露了JMS风格的API而不是日志抽象【16】。尽管这些消息代理将所有消息写入磁盘,但通过在多台机器上进行分区,每秒能够实现数百万条消息的吞吐量,并通过复制消息来实现容错性【22,23】。
|
||||
Apache Kafka 【17,18】,Amazon Kinesis Streams 【19】和Twitter的DistributedLog 【20,21】都是基于日志的消息代理。 Google Cloud Pub/Sub在架构上类似,但对外暴露的是JMS风格的API,而不是日志抽象【16】。尽管这些消息代理将所有消息写入磁盘,但通过跨多台机器分区,每秒能够实现数百万条消息的吞吐量,并通过复制消息来实现容错性【22,23】。
|
||||
|
||||
#### 日志与传统消息相比
|
||||
|
||||
基于日志的方法平凡支持扇出消息传递,因为多个消费者可以独立读取日志,而不会相互影响 - 读取消息不会将其从日志中删除。为了在一组消费者之间实现负载平衡,代理人可以将整个分区分配给消费者组中的节点,而不是将消息分配给消费者客户端。
|
||||
基于日志的方法天然支持扇出式消息传递,因为多个消费者可以独立读取日志,而不会相互影响 —— 读取消息不会将其从日志中删除。为了在一组消费者之间实现负载平衡,代理可以将整个分区分配给消费者组中的节点,而不是将单条消息分配给消费者客户端。
|
||||
|
||||
每个客户端然后使用分配的分区中的所有消息。通常情况下,当一个用户被分配了一个日志分区时,它会以直接的单线程的方式顺序地读取分区中的消息。这种粗粒度的负载均衡方法有一些缺点:
|
||||
每个客户端消费指派分区中的**所有**消息。然后使用分配的分区中的所有消息。通常情况下,当一个用户被指派了一个日志分区时,它会以简单的单线程方式顺序地读取分区中的消息。这种粗粒度的负载均衡方法有一些缺点:
|
||||
|
||||
* 共享消费主题工作的节点数最多可以是该主题中的日志分区数,因为同一个分区内的消息被传递到同一个节点[^i]。
|
||||
* 如果单个消息处理缓慢,则会阻止处理该分区中的后续消息(一种行头阻塞形式;请参阅“[描述性能](ch1.md#描述性能)”)。
|
||||
* 共享消费主题工作的节点数,最多为该主题中的日志分区数,因为同一个分区内的所有消息被递送到同一个节点[^i]。
|
||||
* 如果某条消息处理缓慢,则它会阻塞该分区中后续消息的处理(一种行首阻塞的形式;请参阅“[描述性能](ch1.md#描述性能)”)。
|
||||
|
||||
因此,在处理消息可能代价高昂,并且希望逐个消息地平行处理以及消息排序并不那么重要的情况下,消息代理的JMS/AMQP风格是可取的。另一方面,在消息吞吐量高的情况下,每条消息的处理速度都很快,消息的排序也是重要的,基于日志的方法运行得很好。
|
||||
因此在消息处理代价高昂,希望逐条并行处理,以及消息的顺序并没有那么重要的情况下,JMS/AMQP风格的消息代理是可取的。另一方面,在消息吞吐量很高,处理迅速,顺序很重要的情况下,基于日志的方法表现得非常好。
|
||||
|
||||
[^i]: 有可能创建一个负载均衡方案,在这个方案中,两个消费者通过读取全部消息来共享处理分区的工作,但是其中一个只考虑具有偶数偏移量的消息,而另一个消费者处理奇数编号的偏移量。或者,您可以将消息处理扩展到线程池,但这种方法会使消费者偏移管理变得复杂。一般来说,分区的单线程处理是可取的,并行分区可以通过使用更多的分区来增加。
|
||||
[^i]: 设计一种负载均衡方案是可行的,在这种方案中,两个消费者通过读取全部消息来共享处理分区的工作,但是其中一个只考虑具有偶数偏移量的消息,而另一个消费者只处理奇数编号的偏移量。或者你可以将消息摊到一个线程池中来处理,但这种方法会使消费者偏移量管理变得复杂。一般来说,单线程处理单分区是合适的,可以通过增加更多分区来提高并行度。
|
||||
|
||||
#### 消费者偏移量
|
||||
|
||||
消耗一个分区依次可以很容易地判断哪些消息已经被处理:所有消息的偏移量小于消费者的当前偏移量已经被处理,并且具有更大偏移量的所有消息还没有被看到。因此,代理不需要跟踪每条消息的确认,只需要定期记录消费者的偏移。在这种方法中减少的簿记开销以及批处理和流水线的机会有助于提高基于日志的系统的吞吐量。
|
||||
顺序消费一个分区使得判断消息是否已经被处理变得相当容易:所有偏移量小于消费者的当前偏移量的消息已经被处理,而具有更大偏移量的消息还没有被看到。因此,代理不需要跟踪确认每条消息,只需要定期记录消费者的偏移即可。在这种方法减少了额外簿记开销,而且在批处理和流处理中采用这种方法有助于提高基于日志的系统的吞吐量。
|
||||
|
||||
实际上,这种偏移量与单领先数据库复制中常见的日志序列号非常相似,我们在“[设置新从库](ch5.md#设置新从库)”中讨论了这种情况。在数据库复制中,日志序列号允许跟随者断开连接后,重新连接到领导,并在不跳过任何写入的情况下恢复复制。这里使用的原则完全相同:消息代理的行为像一个领导者数据库,而消费者就像一个追随者。
|
||||
实际上,这种偏移量与单领导者数据库复制中常见的日志序列号非常相似,我们在“[设置新从库](ch5.md#设置新从库)”中讨论了这种情况。在数据库复制中,日志序列号允许跟随者断开连接后,重新连接到领导者,并在不跳过任何写入的情况下恢复复制。这里原理完全相同:消息代理的表现得像一个主库,而消费者就像一个从库。
|
||||
|
||||
如果消费者节点失败,则使用者组中的另一个节点将被分配失败的使用者分区,并开始以最后记录的偏移量使用消息。如果消费者已经处理了后续的消息,但还没有记录他们的偏移量,那么在重新启动后这些消息将被第二次处理。本章后面我们将讨论处理这个问题的方法。
|
||||
如果消费者节点失效,则失效消费者的分区将指派给其他节点,并从最后记录的偏移量开始消费消息。如果消费者已经处理了后续的消息,但还没有记录它们的偏移量,那么重启后这些消息将被处理两次。我们将在本章后面讨论这个问题的处理方法。
|
||||
|
||||
#### 磁盘空间使用情况
|
||||
#### 磁盘空间使用
|
||||
|
||||
如果您只附加到日志,则最终将耗尽磁盘空间。为了回收磁盘空间,日志实际上被分割成段,并且不时地将旧段删除或移动到归档存储器。 (我们将在后面讨论一种更为复杂的释放磁盘空间的方法。)
|
||||
如果只追加写入日志,则磁盘空间终究会耗尽。为了回收磁盘空间,日志实际上被分割成段,并不时地将旧段删除或移动到归档存储。 (我们将在后面讨论一种更为复杂的磁盘空间释放方式)
|
||||
|
||||
这就意味着,如果一个消费者的速度慢,消费者的消费速度落后于消费者的偏移量,那么消费者的偏移量就会指向一个已经被删除的消费者。实际上,日志实现了一个有限大小的缓冲区,当旧的消息满时,它也被称为循环缓冲区或环形缓冲区。但是,由于该缓冲区在磁盘上,因此可能相当大。
|
||||
这就意味着如果一个慢消费者跟不上消息产生的速率而落后的太多,它的消费偏移量指向了删除的段,那么它就会错过一些消息。实际上,日志实现了一个有限大小的缓冲区,当缓冲区填满时会丢弃旧消息,它也被称为**循环缓冲区(circular buffer)**或**环形缓冲区(ring buffer)**。不过由于缓冲区在磁盘上,因此可能相当的大。
|
||||
|
||||
让我们来做一个后台计算。在撰写本文时,典型的大型硬盘驱动器容量为6TB,顺序写入吞吐量为150MB/s。如果您以最快的速度写邮件,则需要大约11个小时才能填满驱动器。因此,磁盘可以缓存11个小时的消息,之后它将开始覆盖旧的消息。即使您使用多个硬盘驱动器和机器,这个比率也是一样的。在实践中,部署很少使用磁盘的完整写入带宽,所以日志通常可以保存几天甚至几周的缓冲区。
|
||||
让我们做个简单计算。在撰写本文时,典型的大型硬盘容量为6TB,顺序写入吞吐量为150MB/s。如果以最快的速度写消息,则需要大约11个小时才能填满磁盘。因而磁盘可以缓冲11个小时的消息,之后它将开始覆盖旧的消息。即使使用多个磁盘和机器,这个比率也是一样的。实践中的部署很少能用满磁盘的写入带宽,所以通常可以保存一个几天甚至几周的日志缓冲区。
|
||||
|
||||
不管你保留多长时间的消息,一个日志的吞吐量或多或少保持不变,因为无论如何每个消息都被写入磁盘【18】。这种行为与将邮件默认保存在内存中的消息传递系统形成鲜明对比,如果队列变得太大,只将其写入磁盘:当这些系统开始写入磁盘时,这些系统速度很快,并且变慢得多,所以吞吐量取决于保留的历史数量。
|
||||
不管保留多长时间的消息,日志的吞吐量或多或少保持不变,因为无论如何,每个消息都会被写入磁盘【18】。这种行为与默认将消息保存在内存中,仅当队列太长时才写入磁盘的消息传递系统形成鲜明对比。当队列很短时,这些系统非常快;而当这些系统开始写入磁盘时,就要慢的多,所以吞吐量取决于保留的历史数量。
|
||||
|
||||
#### 当消费者跟不上生产者时
|
||||
|
||||
在“[信息系统]()”第441页的开头,我们讨论了如果消费者无法跟上生产者发送信息的速度的三种选择:丢弃信息,缓冲或施加背压。在这个分类法中,基于日志的方法是一种缓冲形式,具有较大但固定大小的缓冲区(受可用磁盘空间的限制)。
|
||||
在“[消息传递系统](#消息传递系统)”中,如果消费者无法跟上生产者发送信息的速度时,我们讨论了三种选择:丢弃信息,进行缓冲或施加背压。在这种分类法里,基于日志的方法是缓冲的一种形式,具有很大,但大小固定的缓冲区(受可用磁盘空间的限制)。
|
||||
|
||||
如果消费者远远落后于它所要求的信息比保留在磁盘上的信息要旧,那么它将不能读取这些信息,所以代理人有效地丢弃了比缓冲区容量更大的旧信息。您可以监控消费者在日志头后面的距离,如果显着落后,则会发出警报。由于缓冲区很大,因此有足够的时间让人类操作员修复缓慢的消费者,并在消息开始丢失之前让其赶上。
|
||||
如果消费者远远落后,而所要求的信息比保留在磁盘上的信息还要旧,那么它将不能读取这些信息,所以代理实际上丢弃了比缓冲区容量更大的旧信息。你可以监控消费者落后日志头部的距离,如果落后太多就发出报警。由于缓冲区很大,因而有足够的时间让人类运维来修复慢消费者,并在消息开始丢失之前让其赶上。
|
||||
|
||||
即使消费者落后太多,开始丢失信息,也只有消费者受到影响;它不会中断其他消费者的服务。这是一个巨大的运营优势:您可以通过实验性的方式使用生产日志进行开发,测试或调试,而不必担心会中断生产服务。当消费者关闭或崩溃时,会停止消耗资源,唯一剩下的就是消费者抵消。
|
||||
即使消费者真的落后太多开始丢失消息,也只有那个消费者受到影响;它不会中断其他消费者的服务。这是一个巨大的运维优势:你可以实验性地消费生产日志,以进行开发,测试或调试,而不必担心会中断生产服务。当消费者关闭或崩溃时,会停止消耗资源,唯一剩下的只有消费者偏移量。
|
||||
|
||||
这种行为也与传统的信息代理形成了鲜明的对比,在这种情况下,您需要小心删除消费者已经关闭的任何队列,否则他们会继续不必要地积累消息,并从仍然活跃的消费者那里带走内存。
|
||||
这种行为也与传统的信息代理形成了鲜明对比,在那种情况下,你需要小心地删除那些消费者已经关闭的队列—— 否则那些队列就会累积不必要的消息,从其他仍活跃的消费者那里占走内存。
|
||||
|
||||
#### 重播旧信息
|
||||
|
||||
我们之前提到,使用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)**,其中应用代码在数据变更时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。
|
||||
|
||||
但是,双重写入有一些严重的问题,其中一个是[图11-4](img/fig11-4.png)所示的竞争条件。在这个例子中,两个客户端同时想要更新一个项目X:客户端1想要将值设置为A,客户端2想要将其设置为B.两个客户端首先将新值写入数据库,然后将其写入到搜索索引。由于运行时间不正确,这些请求是交错的:数据库首先看到从客户端1的写入将值设置为A,然后从客户端2写入将值设置为B,因此数据库中的最终值为B.搜索索引首先看到来自客户端2,然后是客户端1的写入,所以搜索索引中的最终值是A.这两个系统现在永久地不一致,即使没有发生错误。
|
||||
但是,双写有一些严重的问题,其中一个是竞争条件,如[图11-4](img/fig11-4.png)所示。在这个例子中,两个客户端同时想要更新一个项目X:客户端1想要将值设置为A,客户端2想要将其设置为B。两个客户端首先将新值写入数据库,然后将其写入到搜索索引。因为运气不好,这些请求的时序是交错的:数据库首先看到来自客户端1的写入将值设置为A,然后来自客户端2的写入将值设置为B,因此数据库中的最终值为B。搜索索引首先看到来自客户端2的写入,然后是客户端1的写入,所以搜索索引中的最终值是A。即使没发生错误,这两个系统现在也永久地不一致了。
|
||||
|
||||
![](img/fig11-4.png)
|
||||
|
||||
**图11-4 在数据库中,X首先被设置为A,然后被设置为B,而在搜索索引处,写入以相反的顺序到达**
|
||||
**图11-4 在数据库中X首先被设置为A,然后被设置为B,而在搜索索引处,写入以相反的顺序到达**
|
||||
|
||||
除非有一些额外的并发检测机制,例如我们在“[检测并发写入](ch5.md#检测并发写入)”中讨论的版本向量,否则您甚至不会注意到发生了并发写入 —— 一个值将简单地以无提示方式覆盖另一个值。
|
||||
除非有一些额外的并发检测机制,例如我们在“[检测并发写入](ch5.md#检测并发写入)”中讨论的版本向量,否则你甚至不会意识到发生了并发写入 —— 一个值将简单地以无提示方式覆盖另一个值。
|
||||
|
||||
双重写入的另一个问题是其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相矛盾的结果。确保它们都成功或者两者都失败是原子提交问题的一个例子,这个问题的解决是昂贵的(参阅“[原子提交和两阶段提交(2PC)](ch7.md#原子提交和两阶段提交(2PC))”)。
|
||||
双重写入的另一个问题是,其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的(参阅“[原子提交和两阶段提交(2PC)](ch7.md#原子提交和两阶段提交(2PC))”)。
|
||||
|
||||
如果只有一个复制的数据库和一个领导者,那么这个领导者决定了写入顺序,所以状态机复制方法可以在数据库的副本中工作。然而,在[图11-4](img/fig11-4.png)中,没有一个领导者:数据库可能有一个领导者,搜索索引可能有一个领导者,但是既不在另一个领导者之后,也可能发生冲突(参见“[多领导者复制](ch5.md#多领导者复制)“)。
|
||||
如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在[图11-4](img/fig11-4.png)中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(参见“[多领导者复制](ch5.md#多领导者复制)“)。
|
||||
|
||||
如果实际上只有一个领导者(例如数据库),并且如果我们可以使搜索索引成为数据库的追随者,情况会更好。但这在实践中可能吗?
|
||||
如果实际上只有一个领导者 —— 例如,数据库 —— 而且我们能让搜索索引成为数据库的追随者,情况要好得多。但这在实践中可能吗?
|
||||
|
||||
### 变更数据捕获
|
||||
|
||||
大多数数据库的复制日志的问题在于,它们一直被当做数据库的内部实现细节,而不是公开的API。客户端应该通过其数据模型和查询语言来查询数据库,而不是解析复制日志并尝试从中提取数据。
|
||||
|
||||
### 改变数据捕获
|
||||
数十年来,许多数据库根本没有记录在档的,获取变更日志的方式。由于这个原因,捕获数据库中所有的变更,然后将其复制到其他存储技术(搜索索引,缓存,数据仓库)中是相当困难的。
|
||||
|
||||
大多数数据库的复制日志的问题在于,它们一直被认为是数据库的内部实现细节,而不是公共API。客户端应该通过其数据模型和查询语言来查询数据库,而不是分析复制日志并尝试从中提取数据。
|
||||
最近,人们对**变更数据捕获(change data capture, CDC)**越来越感兴趣,这是一种观察写入数据库的所有数据变更,并将其提取并转换为可以复制到其他系统中的形式的过程。 CDC是非常有意思的,尤其是当变更能在被写入后立刻用于流时。
|
||||
|
||||
数十年来,许多数据库根本没有记录方式来获取写入到他们的变更日志。由于这个原因,很难将数据库中所做的所有更改复制到不同的存储技术,如搜索索引,缓存或数据仓库。
|
||||
|
||||
最近,人们对变更数据捕获(CDC)越来越感兴趣,它是观察写入数据库的所有数据变化并将其提取出来,并将其复制到其他系统中的过程。 CDC特别感兴趣的是,如果改变可以立即用于流,可以立即写入。
|
||||
|
||||
例如,您可以捕获数据库中的更改并不断将相同的更改应用于搜索索引。如果更改的日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据匹配。搜索索引和任何其他派生的数据系统只是变化流的消费者,如[图11-5](img/fig11-5.png)所示。
|
||||
例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如[图11-5](img/fig11-5.png)所示。
|
||||
|
||||
![](img/fig11-5.png)
|
||||
|
||||
**图11-5 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统**
|
||||
|
||||
#### 实现变更数据捕获
|
||||
#### 变更数据捕获的实现
|
||||
|
||||
我们可以调用日志消费者导出的数据系统,正如在第三部分的介绍中所讨论的:存储在搜索索引和数据仓库中的数据只是记录系统中数据的另一个视图。更改数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在派生数据系统中,以便派生系统具有数据的准确副本。
|
||||
我们可以将日志消费者叫做**衍生数据系统**,正如在第三部分的[介绍](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使用解码预写日志的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#复制延迟问题)”)。
|
||||
|
||||
#### 初始快照
|
||||
|
||||
如果具有对数据库所做的所有更改的日志,则可以通过重播日志来重新构建数据库的整个状态。但是,在许多情况下,永远保留所有更改将需要太多的磁盘空间,并且重播将花费太长时间,因此日志需要被截断。
|
||||
如果你拥有**所有**对数据库进行变更的日志,则可以通过重放该日志,来重建数据库的完整状态。但是在许多情况下,永远保留所有更改会耗费太多磁盘空间,且重放过于费时,因此日志需要被截断。
|
||||
|
||||
例如,构建新的全文索引需要整个数据库的完整副本 —— 仅仅应用最近更改的日志是不够的,因为它将丢失最近未更新的项目。因此,如果您没有完整的日志历史记录,则需要从一致的快照开始,如先前在第155页上的“设置新的追随者”中所述。
|
||||
例如,构建新的全文索引需要整个数据库的完整副本 —— 仅仅应用最近变更的日志是不够的,因为这样会丢失最近未曾更新的项目。因此,如果你没有完整的历史日志,则需要从一个一致的快照开始,如先前上的“[设置新的从库](ch5.md#设置新的从库)”中所述。
|
||||
|
||||
数据库的快照必须与更改日志中的已知位置或偏移量相对应,以便您知道在快照处理完成后,在哪一点开始应用更改。一些CDC工具集成了这个快照工具,而其他工具则将其作为手动操作。
|
||||
数据库的快照必须与变更日志中的已知位置或偏移量相对应,以便在处理完快照后知道从哪里开始应用变更。一些CDC工具集成了这种快照功能,而其他工具则把它留给你手动执行。
|
||||
|
||||
#### 日志压缩
|
||||
|
||||
如果只能保留有限的日志历史记录,则每次需要添加新的派生数据系统时都需要执行快照过程。但是,日志压缩提供了一个很好的选择。
|
||||
如果你只能保留有限的历史日志,则每次要添加新的衍生数据系统时,都需要做一次快照。但**日志压缩(log compaction)**提供了一个很好的备选方案。
|
||||
|
||||
在日志结构化的存储引擎的情况下,我们先讨论了“[Hash索引](ch3.md#Hash索引)”中的日志压缩(参见[图3-2](img/fig3-2.png)的示例)。原理很简单:存储引擎使用相同的密钥定期查找日志记录,丢弃任何重复内容,并且只保留每个密钥的最新更新。这个压缩和合并过程在后台运行。
|
||||
我们之前在日志结构存储引擎的上下文中讨论了“[Hash索引](ch3.md#Hash索引)”中的日志压缩(参见[图3-2](img/fig3-2.png)的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。
|
||||
|
||||
在日志结构存储引擎中,具有特殊空值(逻辑删除)的更新指示删除了一个密钥,并在日志压缩过程中将其删除。但只要密钥不被覆盖或删除,它就永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容,而不取决于数据库中发生的写入次数。如果相同的密钥经常被覆盖,则以前的值将最终被垃圾收集,并且只保留最新的值。
|
||||
在日志结构存储引擎中,具有特殊值NULL(**墓碑(tombstone)**)的更新表示该键被删除,并会在日志压缩过程中被移除。但只要键不被覆盖或删除,它就会永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容,而不取决于数据库中曾经发生的写入次数。如果相同的键经常被覆盖写入,则先前的值将最终将被垃圾回收,只有最新的值会保留下来。
|
||||
|
||||
在基于日志的消息代理和更改数据捕获方面,相同的想法也适用。如果CDC系统设置为每个更改都有一个主键,并且每个键的更新都替换了该键的以前的值,那么仅保留最近写入的特定键就足够了。
|
||||
在基于日志的消息代理与变更数据捕获的上下文中也适用相同的想法。如果CDC系统被配置为,每个变更都包含一个主键,且每个键的更新都替换了该键以前的值,那么只需要保留对键的最新写入就足够了。
|
||||
|
||||
现在,无论何时要重建派生数据系统(如搜索索引),都可以从日志压缩主题的偏移量0开始新的使用者,然后依次扫描日志中的所有消息。日志保证包含数据库中每个键的最新值(也可能是一些较旧的值)—— 换句话说,您可以使用它来获取数据库内容的完整副本,而无需获取CDC的另一个快照源数据库。
|
||||
现在,无论何时需要重建衍生数据系统(如搜索索引),你可以从压缩日志主题0偏移量处启动新的消费者,然后依次扫描日志中的所有消息。日志能保证包含数据库中每个键的最新值(也可能是一些较旧的值)—— 换句话说,你可以使用它来获取数据库内容的完整副本,而无需从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中,它就可以用于更新衍生数据系统,比如搜索索引,也可以用于本章稍后讨论的流处理系统。
|
||||
|
||||
### 事件源
|
||||
### 事件溯源
|
||||
|
||||
我 们在这里讨论的想法和事件代理之间有一些相似之处,这是一个在领域驱动设计(DDD)社区中开发的技术。我们将简要讨论事件源,因为它包含了一些有用的和相关的流式系统的想法。
|
||||
我们在这里讨论的想法和**事件溯源( Event Sourcing)**之间有一些相似之处,这是一个在**领域驱动设计(domain-driven design, DDD)**社区中折腾出来的技术。我们将简要讨论事件溯源,因为它包含了一些关于流处理系统的有用想法。
|
||||
|
||||
与更改数据捕获类似,事件代理涉及将所有对应用程序状态的更改存储为更改事件的日志。最大的区别是事件源代码在不同的抽象层次上应用了这个想法:
|
||||
与变更数据捕获类似,事件溯源涉及到**将所有对应用状态的变更**存储为变更事件日志。最大的区别是事件溯源将这一想法应用到了几个不同的抽象层次上:
|
||||
|
||||
* 在更改数据捕获中,应用程序以可变方式使用数据库,随意更新和删除记录。从数据库中提取较低级别的更改日志(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免[图11-4](img/fig11-4.png)中的竞争条件。写入数据库的应用程序不需要知道CDC正在发生。
|
||||
* 在事件源中,应用程序逻辑是基于写入事件日志的不可变事件而显式构建的。在这种情况下,事件存储是附加的,更新或删除是不鼓励或禁止的。事件旨在反映应用程序级别发生的事情,而不是低级状态更改。
|
||||
* 在变更数据捕获中,应用以**可变方式(mutable way)**使用数据库,任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免[图11-4](img/fig11-4.png)中的竞态条件。写入数据库的应用不需要知道CDC的存在。
|
||||
* 在事件溯源中,应用逻辑显式构建在写入事件日志的不可变事件之上。在这种情况下,事件存储是仅追加写入的,更新与删除是不鼓励的或禁止的。事件被设计为旨在反映应用层面发生的事情,而不是底层的状态变更。
|
||||
|
||||
事件源是一种强大的数据建模技术:从应用程序的角度来看,将用户的行为记录为不可变的事件更有意义,而不是记录这些行为对可变数据库的影响。事件代理使得随着时间的推移而逐渐发展应用程序变得更加容易,通过更容易理解事情发生的原因以及防范应用程序错误(请参阅“[不可变事件的优点](#不可变事件的优点)”),帮助进行调试。
|
||||
事件源是一种强大的数据建模技术:从应用的角度来看,将用户的行为记录为不可变的事件更有意义,而不是在可变数据库中记录这些行为的影响。事件代理使得应用随时间演化更为容易,通过事实更容易理解事情发生的原因,使得调试更为容易,并有利于防止应用Bug(请参阅“[不可变事件的优点](#不可变事件的优点)”)。
|
||||
|
||||
例如,存储“学生取消课程注册”事件清楚地表达了单一行为的中性意图,而副作用“从注册表中删除了一个条目,并且一个取消原因被添加到学生反馈表“嵌入了很多有关方式的假设数据稍后将被使用。如果引入新的应用程序功能,例如“将地点提供给等待列表中的下一个人” —— 事件顺序方法允许将新的副作用轻松地链接到现有事件上。
|
||||
例如,存储“学生取消选课”事件以中性的方式清楚地表达了单个行为的意图,而副作用“从注册表中删除了一个条目,而一条取消原因被添加到学生反馈表“则嵌入了很多有关稍后数据使用方式的假设。如果引入一个新的应用功能,例如“将位置留给等待列表中的下一个人” —— 事件溯源方法允许将新的副作用轻松地链接至现有事件之后。
|
||||
|
||||
事件顺序类似于编年史数据模型【45】,事件日志和事实表之间也有相似之处,您可以在星型模式中找到它(参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”) 。
|
||||
事件溯源类似于**编年史(chronicle)**数据模型【45】,事件日志与星型模式中的事实表之间也存在相似之处(参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”) 。
|
||||
|
||||
专门的数据库如Event Store 【46】已经被开发来支持使用事件代理的应用程序,但总的来说,这个方法是独立于任何特定的工具的。传统的数据库或基于日志的消息代理也可以用来构建这种风格的应用程序。
|
||||
诸如Event Store【46】这样的专业数据库已经被开发出来,供使用事件溯源的应用使用,但总的来说,这种方法独立于任何特定的工具。传统的数据库或基于日志的消息代理也可以用来构建这种风格的应用。
|
||||
|
||||
#### 从事件日志中导出当前状态
|
||||
#### 从事件日志中派生出当前状态
|
||||
|
||||
事件日志本身并不是很有用,因为用户通常期望看到系统的当前状态,而不是修改的历史。例如,在购物网站上,用户期望能够看到他们购物车的当前内容,而不是他们对购物车所做的所有改变的附加列表。
|
||||
事件日志本身并不是很有用,因为用户通常期望看到的是系统的当前状态,而不是变更历史。例如,在购物网站上,用户期望能看到他们购物车里的当前内容,而不是他们购物车所有变更的一个仅追加列表。
|
||||
|
||||
因此,使用事件源的应用程序需要记录事件的日志(表示写入系统的数据),并将其转换为适合向用户显示的应用程序状态(从系统读取数据的方式【47】)。这种转换可以使用任意的逻辑,但它应该是确定性的,以便您可以再次运行它并从事件日志中派生相同的应用程序状态。
|
||||
因此,使用事件溯源的应用需要拉取事件日志(表示**写入**系统的数据),并将其转换为适合向用户显示的应用状态(从系统**读取**数据的方式【47】)。这种转换可以使用任意逻辑,但它应当是确定性的,以便能再次运行,并从事件日志中衍生出相同的应用状态。
|
||||
|
||||
与更改数据捕获一样,重放事件日志可以让您重新构建系统的当前状态。但是,日志压缩需要以不同的方式处理:
|
||||
与变更数据捕获一样,重放事件日志允许让你重新构建系统的当前状态。不过,日志压缩需要采用不同的方式处理:
|
||||
|
||||
* 用于记录更新的CDC事件通常包含记录的全部新版本,因此主键的当前值完全由该主键的最近事件确定,并且日志压缩可以丢弃以前的事件同样的钥匙。
|
||||
* 另一方面,事件代理是将事件建模为更高的级别:事件通常表示用户操作的意图,而不是由于操作而发生的状态更新的机制。在这种情况下,以后的事件通常不会覆盖以前的事件,所以您需要事件的完整历史来重构最终状态。日志压缩不可能以相同的方式进行。
|
||||
* 用于记录更新的CDC事件通常包含记录的**完整新版本**,因此主键的当前值完全由该主键的最近事件确定,而日志压缩可以丢弃相同主键的先前事件。
|
||||
* 另一方面,事件溯源在更高层次进行建模:事件通常表示用户操作的意图,而不是因为操作而发生的状态更新机制。在这种情况下,后面的事件通常不会覆盖先前的事件,所以你需要完整的历史事件来重新构建最终状态。这里进行同样的日志压缩是不可能的。
|
||||
|
||||
使用事件源的应用程序通常有一些机制来存储从事件日志中导出的当前状态的快照,因此它们不需要重复处理完整的日志。但是,这只是一个性能优化,可以加快读取和恢复崩溃的速度;目的是系统能够永久存储所有原始事件,并在需要时重新处理完整的事件日志。我们在第463页的“不变性的限制”中讨论这个假设。
|
||||
使用事件溯源的应用通常有一些机制,用于存储从事件日志中导出的当前状态快照,因此它们不需要重复处理完整的日志。然而这只是一种性能优化,用来加速读取,提高从崩溃中恢复的速度;真正的目的是系统能够永久存储所有原始事件,并在需要时重新处理完整的事件日志。我们将在“[不变性的限制](#不变性的限制)”中讨论这个假设。
|
||||
|
||||
#### 命令和事件
|
||||
|
||||
事件代理哲学是仔细区分事件和命令【48】。当来自用户的请求首先到达时,它最初是一个命令:在这一点上它可能仍然失败,例如因为违反了一些完整性条件。应用程序必须首先验证它是否可以执行该命令。如果验证成功并且命令被接受,则它变成一个持久且不可变的事件。
|
||||
事件溯源的哲学是仔细区分**事件(event)**和**命令(command)**【48】。当来自用户的请求刚到达时,它一开始是一个命令:在这个时间点上它仍然可能可能失败,比如,因为违反了一些完整性条件。应用必须首先验证它是否可以执行该命令。如果验证成功并且命令被接受,则它变为一个持久化且不可变的事件。
|
||||
|
||||
例如,如果用户试图注册特定用户名,或在飞机上或剧院中预定座位,则应用程序需要检查用户名或座位是否已被占用。 (我们先前在第364页的“[容错概念](ch8.md#容错概念)”中讨论过这个例子。)当检查成功时,应用程序可以生成一个事件来指示特定的用户名是由特定的用户ID注册的,座位已经预留给特定的顾客。
|
||||
例如,如果用户试图注册特定用户名,或预定飞机或剧院的座位,则应用需要检查用户名或座位是否已被占用。 (先前在“[容错概念](ch8.md#容错概念)”中讨论过这个例子)当检查成功时,应用可以生成一个事件,指示特定的用户名是由特定的用户ID注册的,座位已经预留给特定的顾客。
|
||||
|
||||
在事件发生的时候,这成为事实。即使客户稍后决定更改或取消预订,事实仍然是事实,他们以前曾为某个特定的座位进行预订,而更改或取消是稍后添加的单独事件。
|
||||
在事件生成的时刻,它就成为了**事实(fact)**。即使客户稍后决定更改或取消预订,他们之前曾预定了某个特定座位的事实仍然成立,而更改或取消是之后添加的单独的事件。
|
||||
|
||||
事件流的消费者不允许拒绝事件:当消费者看到事件时,它已经是日志中不可变的部分,并且可能已经被其他消费者看到。因此,任何命令的验证都需要在事件成为事件之前同步发生,例如,通过使用可自动验证命令并发布事件的可序列化事务。
|
||||
事件流的消费者不允许拒绝事件:当消费者看到事件时,它已经成为日志中不可变的一部分,并且可能已经被其他消费者看到了。因此任何对命令的验证,都需要在它成为事件之前同步完成。例如,通过使用一个可自动验证命令的可序列化事务来发布事件。
|
||||
|
||||
或者,预订座位的用户请求可以分成两个事件:第一个是暂时预约,第二个是确认预约后的单独确认事件(如第350页上的“使用总订单广播实现线性化存储”中所述) 。这个分割允许验证发生在一个异步的过程中。
|
||||
或者,预订座位的用户请求可以拆分为两个事件:第一个是暂时预约,第二个是验证预约后的独立的确认事件(如“[使用全序广播实现线性一致存储](ch9.md#使用全序广播实现线性一致存储)”中所述) 。这种分割方式允许验证发生在一个异步的过程中。
|
||||
|
||||
#### 状态,流和不变性
|
||||
|
||||
我们在[第10章](ch10.md)中看到批量处理从其输入文件的不变性中受益,因此您可以在现有输入文件上运行实验性处理作业,而不用担心损坏它们。这种不变性原则也是使得事件代理和数据变化如此强大的原因。
|
||||
我们在[第10章](ch10.md)中看到,批处理因其输入文件不变性而受益良多,你可以在现有输入文件上运行实验性处理作业,而不用担心损坏它们。这种不变性原则也是使得事件溯源与变更数据捕获如此强大的原因。
|
||||
|
||||
我们通常将数据库视为存储应用程序的当前状态 —— 这种表示法针对读取进行了优化,而且通常对于服务查询来说是最方便的。状态的本质是它的变化,所以数据库支持更新和删除数据以及插入数据。这是如何符合不变性的?
|
||||
我们通常将数据库视为应用程序当前状态的存储 —— 这种表示针对读取进行了优化,而且通常对于服务查询而言是最为方便的表示。状态的本质是,它会变化,所以数据库才会支持数据的增删改。这又是如何符合不变性的呢?
|
||||
|
||||
只要你的状态发生了变化,那么这个状态是随着时间的推移而变化的事件的结果。例如,您当前可用的座位列表是您已经处理的预订的结果,当前帐户余额是帐户中的信用卡和借方的结果,而您的Web服务器的响应时间图是发生的所有Web请求的个别响应时间。
|
||||
只要你的状态发生了变化,那么这个状态就是这段时间中事件修改的结果。例如,当前可用的座位列表是已处理预订产生的结果,当前帐户余额是帐户中的借与贷的结果,而Web服务器的响应时间图,是所有已发生Web请求的独立响应时间的聚合结果。
|
||||
|
||||
无论状态如何变化,总会有一系列事件导致这些变化。即使事情已经解决,事实仍然是事实发生的事实。关键的想法是可变状态和不可变事件的附加日志不相互矛盾:它们是同一枚硬币的两面。所有变化的日志,变化日志,代表了随着时间的推移状态的演变。
|
||||
|
||||
如果您有数学上的倾向,那么您可能会说应用程序状态是随着时间的推移整合了一个事件流而得到的,而且当您按照时间区分状态时会得到一个更改流,如[图11-6](img/fig11-6.png)所示【49,50,51】。这个比喻有一定的局限性(例如,状态的二阶导数似乎没有意义),但这是考虑数据的一个有用的起点。
|
||||
无论状态如何变化,总是有一系列事件导致了这些变化。即使事情已经执行与回滚,这些事件出现是始终成立的。关键的想法是:可变的状态与不可变事件的仅追加日志相互之间并不矛盾:它们是一体两面,互为阴阳的。所有变化的日志—— **变化日志(change log)**,表示了随时间演变的状态。
|
||||
|
||||
如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如[图11-6](img/fig11-6.png)所示【49,50,51】。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。
|
||||
$$
|
||||
state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\
|
||||
stream(t) = \frac{d\ state(t)}{dt}
|
||||
$$
|
||||
![](img/fig11-6.png)
|
||||
|
||||
**图11-6 当前应用程序状态和事件流之间的关系**
|
||||
**图11-6 应用当前状态与事件流之间的关系**
|
||||
|
||||
如果你持久地存储更新日志,那么这只是使状态重现的效果。如果你认为事件的日志是你的记录系统,并且从它派生出任何可变状态,那么就更容易推断通过系统的数据流。正如帕特·赫兰(Pat Helland)所说的【52】:
|
||||
如果你持久存储了变更日志,那么重现状态就非常简单。如果你认为事件日志是你的记录系统,而所有的衍生状态都从它派生而来,那么系统中的数据流动就容易理解的多。正如帕特·赫兰(Pat Helland)所说的【52】:
|
||||
|
||||
> 事务日志记录对数据库所做的所有更改。高速追加是更改日志的唯一方法。从这个角度来看,数据库的内容会保存日志中最新记录值的缓存。事实是日志。数据库是日志子集的缓存。该缓存子集恰好是来自日志的每个记录和索引值的最新值。
|
||||
> 事务日志记录了数据库的所有变更。高速追加下入是更改日志的唯一方法。从这个角度来看,数据库的内容其实是日志中记录最新值的缓存。日志才是真相,数据库是日志子集的缓存,这一缓存子集恰好来自日志中每条记录与索引值的最新值。
|
||||
|
||||
日志压缩(如“[日志压缩](#日志压缩)”中所述)是一种桥接日志和数据库状态之间区别的方法:它只保留每条记录的最新版本,并丢弃被覆盖的版本。
|
||||
日志压缩(如“[日志压缩](#日志压缩)”中所述)是连接日志与数据库状态之间的桥梁:它只保留每条记录的最新版本,并丢弃被覆盖的版本。
|
||||
|
||||
#### 不可变事件的优点
|
||||
|
||||
数据库中的不变性是一个古老的想法。例如,会计师在数个世纪以来一直使用不变性财务簿记。当一笔交易发生时,它被记录在一个仅追加分类帐中,这本质上是描述货币,商品或服务已经转手的事件日志。账目,如损益或资产负债表,是从分类账中的交易中加起来得来的【53】。
|
||||
数据库中的不变性是一个古老的概念。例如,会计在几个世纪以来一直在财务记账中应用不变性。一笔交易发生时,它被记录在一个仅追加写入的分类帐中,实质上是描述货币,商品或服务转手的事件日志。账目,比如利润、亏损、资产负债表,是从分类账中的交易求和衍生而来【53】。
|
||||
|
||||
如果发生错误,会计师不会删除或更改分类帐中的错误交易 - 而是增加另一笔交易,以补偿错误,例如退还不正确的费用。不正确的交易将永远保留在分类帐中,因为审计原因可能很重要。如果从不正确的分类账导出的错误数字已经公布,那么下一个会计期间的数字就包括一个更正。这个过程在会计中是完全正常的【54】。
|
||||
如果发生错误,会计师不会删除或更改分类帐中的错误交易 —— 而是添加另一笔交易以补偿错误,例如退还一比不正确的费用。不正确的交易将永远保留在分类帐中,对于审计而言可能非常重要。如果从不正确的分类账衍生出的错误数字已经公布,那么下一个会计周期的数字就会包括一个更正。这个过程在会计事务中是很常见的【54】。
|
||||
|
||||
尽管这种可审计性在金融系统中尤其重要,但对于不受这种严格管制的许多其他系统也是有益的。如“批处理输出的哲学”(第439页)中所述,如果您意外地部署了将错误数据写入数据库的错误代码,那么如果代码能够破坏性地覆盖数据,恢复将更加困难。通过不可变事件的追加日志,诊断发生的事情和从问题中恢复起来要容易得多。
|
||||
尽管这种可审计性在金融系统中尤其重要,但对于不受这种严格监管的许多其他系统,也是很有帮助的。如“[批处理输出的哲学](ch10.md#批处理输出的哲学)”中所讨论的,如果你意外地部署了将错误数据写入数据库的错误代码,当代码会破坏性地覆写数据时,恢复要困难得多。使用不可变事件的仅追加日志,诊断问题与故障恢复就要容易的多。
|
||||
|
||||
不可变的事件也捕获比当前状态更多的信息。例如,在购物网站上,顾客可以将物品添加到他们的购物车,然后再将其移除。虽然第二个事件从订单履行角度取消了第一个事件,但为了分析目的,客户正在考虑某个特定项目,但是之后决定采取反对措施。也许他们会选择在未来购买,或者他们找到替代品。这个信息被记录在一个事件日志中,但是当它们从购物车中被删除时,这个信息会丢失在删除项目的数据库中【42】。
|
||||
不可变的事件也包含了比当前状态更多的信息。例如在购物网站上,顾客可以将物品添加到他们的购物车,然后再将其移除。虽然从履行订单的角度,第二个事件取消了第一个事件,但对分析目的而言,知道客户考虑过某个特定项而之后又反悔,可能是很有用的。也许他们会选择在未来购买,或者他们已经找到了替代品。这个信息被记录在事件日志中,但对于移出购物车就删除记录的数据库而言,这个信息在移出购物车时可能就丢失【42】。
|
||||
|
||||
#### 从同一事件日志中获取多个视图
|
||||
#### 从同一事件日志中派生多个视图
|
||||
|
||||
而且,通过从不变事件日志中分离可变状态,可以从事件的相同日志中派生出几个不同的面向读取的表示。这就像一个流的多个消费者一样工作([图11-5](img/fig11-5.png)):例如,分析数据库Druid使用这种方法从Kafka直接获取【55】,Pista chio是一个分布式的键值存储,使用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】。
|
||||
如果你不需要担心如何查询与访问数据,那么存储数据通常是非常简单的。模式设计,索引和存储引擎的许多复杂性,都是希望支持某些特定查询和访问模式的结果(参见[第3章](ch3.md))。出于这个原因,通过将数据写入的形式与读取形式相分离,并允许几个不同的读取视图,你能获得很大的灵活性。这个想法有时被称为**命令查询责任分离(command query responsibility segregation, CQRS)**【42,58,59】。
|
||||
|
||||
如果您不必担心如何查询和访问数据,那么存储数据通常是非常简单的。模式设计,索引和存储引擎的许多复杂性都是希望支持某些查询和访问模式的结果(参见[第3章](ch3.md))。出于这个原因,通过将数据写入的形式与读取形式分开,并允许几个不同的读取视图,可以获得很大的灵活性。这个想法有时被称为命令查询责任分离(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),则直接的单线程日志消费者不需要并发控制(write-by)构造,它一次只处理一个事件(参阅“[真的的串行执行](ch7.md#真的的串行执行)”)。该日志通过在分区中定义事件的串行顺序来消除并发性的不确定性【24】。如果一个事件触及多个状态分区,那么需要做更多的工作,我们将在[第12章](ch12.md)讨论。
|
||||
如果事件日志与应用状态以相同的方式分区(例如,处理分区3中的客户事件只需要更新分区3中的应用状态),那么直接使用单线程日志消费者就不需要写入并发控制了。它从设计上一次只处理一个事件(参阅“[真的的串行执行](ch7.md#真的的串行执行)”)。日志通过在分区中定义事件的序列顺序,消除了并发性的不确定性【24】。如果一个事件触及多个状态分区,那么需要做更多的工作,我们将在[第12章](ch12.md)讨论。
|
||||
|
||||
#### 不变性的限制
|
||||
|
||||
许多不使用事件源模型的系统依赖于不可变性:各种数据库在内部使用不可变的数据结构或多版本数据来支持时间点快照(参见“[索引和快照隔离](ch7.md#索引和快照隔离)” )。 Git,Mercurial和Fossil等版本控制系统也依靠不可变的数据来保存文件的版本历史记录。
|
||||
许多不使用事件溯源模型的系统也还是依赖不可变性:各种数据库在内部使用不可变的数据结构或多版本数据来支持时间点快照(参见“[索引和快照隔离](ch7.md#索引和快照隔离)” )。 Git,Mercurial和Fossil等版本控制系统也依靠不可变的数据来保存文件的版本历史记录。
|
||||
|
||||
永远保持所有变化的不变的历史在多大程度上是可行的?答案取决于数据集中的流失量。一些工作负载主要是添加数据,很少更新或删除;他们很容易使不变。其他工作负载在较小的数据集上有较高的更新和删除率;在这些情况下,不可改变的历史可能变得过于庞大,碎片化可能成为一个问题,压缩和垃圾收集的表现对于操作的鲁棒性变得至关重要【60,61】。
|
||||
永远保持所有变更的不变历史,在多大程度上是可行的?答案取决于数据集的流失率。一些工作负载主要是添加数据,很少更新或删除;它们很容易保持不变。其他工作负载在相对较小的数据集上有较高的更新/删除率;在这些情况下,不可变的历史可能增至难以接受的巨大,碎片化可能成为一个问题,压缩与垃圾收集的表现对于运维的稳健性变得至关重要【60,61】。
|
||||
|
||||
除了性能方面的原因外,也可能出于管理方面的原因需要删除数据的情况,尽管这些数据都是不可变的。例如,隐私条例可能要求在关闭帐户后删除用户的个人信息,数据保护立法可能要求删除错误的信息,或者可能需要包含敏感信息的意外泄露。
|
||||
除了性能方面的原因外,也可能有出于管理方面的原因需要删除数据的情况,尽管这些数据都是不可变的。例如,隐私条例可能要求在用户关闭帐户后删除他们的个人信息,数据保护立法可能要求删除错误的信息,或者可能需要阻止敏感信息的意外泄露。
|
||||
|
||||
在这种情况下,仅仅在日志中添加另一个事件来指示先前的数据应该被视为删除是不够的 —— 您实际上是想重写历史并假装数据从未写在第一位。例如,Datomic调用这个特性excision 【62】,而Fossil版本控制系统有一个类似的概念叫做shunning 【63】。
|
||||
在这种情况下,仅仅在日志中添加另一个事件来指明先前的数据应该被视为删除是不够的 —— 你实际上是想改写历史,并假装数据从一开始就没有写入。例如,Datomic管这个特性叫**切除(excision)** 【62】,而Fossil版本控制系统有一个类似的概念叫**避免(shunning)** 【63】。
|
||||
|
||||
真正的删除数据是非常困难的【64】,因为拷贝可以存在于很多地方:例如,存储引擎,文件系统和SSD通常写入一个新的位置,而不是覆盖到位【52】,而备份通常是故意不可改变的防止意外删除或腐败。删除更多的是“使检索数据更难”,而不是“使检索数据不可能”。无论如何,有时您必须尝试,正如我们在“[立法和自律](ch12.md#立法和自律)”中所看到的。
|
||||
真正删除数据是非常非常困难的【64】,因为副本可能存在于很多地方:例如,存储引擎,文件系统和SSD通常会向一个新位置写入,而不是原地覆盖旧数据【52】,而备份通常是特意做成不可变的,防止意外删除或损坏。删除更多的是“使取回数据更困难”,而不是“使取回数据不可能”。无论如何,有时你必须得尝试,正如我们在“[立法与自律](ch12.md#立法与自律)”中所看到的。
|
||||
|
||||
|
||||
|
||||
@ -425,9 +421,9 @@
|
||||
|
||||
剩下的就是讨论一下你可以用流做什么 —— 也就是说,你可以处理它。一般来说,有三种选择:
|
||||
|
||||
1. 您可以将事件中的数据写入数据库,缓存,搜索索引或类似的存储系统,然后由其他客户端查询。如[图11-5](img/fig11-5.png)所示,这是保持数据库与系统其他部分发生更改同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。写入存储系统的流程相当于我们在“批处理工作流程的输出”页面上讨论的内容。
|
||||
2. 您可以以某种方式将事件推送给用户,例如通过发送电子邮件警报或推送通知,或通过将事件流式传输到可实时显示的实时仪表板。在这种情况下,人是流的最终消费者。
|
||||
3. 您可以处理一个或多个输入流以产生一个或多个输出流。数据流可能会经过由几个这样的处理阶段组成的流水线,然后才会输出(选项1或2)。
|
||||
1. 你可以将事件中的数据写入数据库,缓存,搜索索引或类似的存储系统,然后由其他客户端查询。如[图11-5](img/fig11-5.png)所示,这是保持数据库与系统其他部分发生更改同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。写入存储系统的流程相当于我们在“批处理工作流程的输出”页面上讨论的内容。
|
||||
2. 你可以以某种方式将事件推送给用户,例如通过发送电子邮件警报或推送通知,或通过将事件流式传输到可实时显示的实时仪表板。在这种情况下,人是流的最终消费者。
|
||||
3. 你可以处理一个或多个输入流以产生一个或多个输出流。数据流可能会经过由几个这样的处理阶段组成的流水线,然后才会输出(选项1或2)。
|
||||
|
||||
在本章的其余部分中,我们将讨论选项3:处理流以产生其他派生流。处理这样的流的代码片段被称为操作员或作业。它与我们在[第10章](ch10.md)中讨论过的Unix进程和MapReduce作业密切相关,数据流的模式是相似的:一个流处理器以只读的方式使用输入流,并将其输出写入一个不同的位置时尚。
|
||||
|
||||
@ -448,7 +444,7 @@
|
||||
|
||||
#### 复杂的事件处理
|
||||
|
||||
复杂事件处理(CEP)是20世纪90年代为分析事件流而开发的一种方法,尤其适用于需要搜索某些事件模式的应用程序【65,66】。与正则表达式允许您在字符串中搜索特定字符模式的方式类似,CEP允许您指定规则以在流中搜索某些事件模式。
|
||||
复杂事件处理(CEP)是20世纪90年代为分析事件流而开发的一种方法,尤其适用于需要搜索某些事件模式的应用程序【65,66】。与正则表达式允许你在字符串中搜索特定字符模式的方式类似,CEP允许你指定规则以在流中搜索某些事件模式。
|
||||
|
||||
CEP系统通常使用高级声明式查询语言(如SQL或图形用户界面)来描述应该检测到的事件模式。这些查询被提交给一个处理引擎,该引擎使用输入流并在内部维护一个执行所需匹配的状态机。当发现匹配时,引擎发出一个复杂的事件(因此名字)与事件模式的细节【67】。
|
||||
|
||||
@ -464,7 +460,7 @@
|
||||
* 计算一段时间内某个值的滚动平均值
|
||||
* 将当前的统计数据与以前的时间间隔进行比较(例如,检测趋势或提醒与上周同期相比过高或过低的指标)
|
||||
|
||||
这些统计信息通常是在固定的时间间隔内进行计算的,例如,您可能想知道在过去5分钟内每秒对服务的平均查询次数,以及在此期间的第99百分位响应时间。在几分钟内平均,从一秒钟到下一秒钟平滑无关的波动,同时还能及时了解交通模式的任何变化。您汇总的时间间隔称为窗口,我们将在“[关于时间的推理](#关于时间的推理)”中更详细地讨论窗口。
|
||||
这些统计信息通常是在固定的时间间隔内进行计算的,例如,你可能想知道在过去5分钟内每秒对服务的平均查询次数,以及在此期间的第99百分位响应时间。在几分钟内平均,从一秒钟到下一秒钟平滑无关的波动,同时还能及时了解交通模式的任何变化。你汇总的时间间隔称为窗口,我们将在“[关于时间的推理](#关于时间的推理)”中更详细地讨论窗口。
|
||||
|
||||
流分析系统有时使用概率算法,例如Bloom filter(我们在“[性能优化](ch3.md#性能优化)”中遇到过),设置成员资格,HyperLogLog 【72】基数估计以及各种百分比估计算法(请参阅“[实践中的百分位点](ch1.md#实践中的百分位点)“第16页)。概率算法产生近似的结果,但是具有在流处理器中比精确算法需要少得多的存储器的优点。近似算法的使用有时会使人们相信流处理系统总是有损和不精确的,但这是错误的:流处理没有任何内在的近似,而概率算法只是一个优化【73】。
|
||||
|
||||
@ -474,7 +470,7 @@
|
||||
|
||||
我们在“[数据库和数据流](#数据库和数据流)”中看到,可以使用数据库更改流来保持派生数据系统(如缓存,搜索索引和数据仓库)与源数据库保持最新。我们可以将这些示例视为维护实体化视图的具体情况(请参阅“[聚合:数据立方体和物化视图](ch3.md#聚合:数据立方体和物化视图)”):导出某个数据集的替代视图,以便可以高效地查询它,并在底层数据更改【50】。
|
||||
|
||||
同样,在事件代理中,应用程序状态通过应用事件日志来维护;这里的应用状态也是一种物化视图。与流分析场景不同,在某个时间窗口内仅考虑事件通常是不够的:构建物化视图可能需要在任意时间段内的所有事件,除了可能由日志压缩丢弃的任何过时事件(请参阅“[日志压缩](#日志压缩)“)。实际上,您需要一个可以一直延伸到一开始的窗口。
|
||||
同样,在事件代理中,应用程序状态通过应用事件日志来维护;这里的应用状态也是一种物化视图。与流分析场景不同,在某个时间窗口内仅考虑事件通常是不够的:构建物化视图可能需要在任意时间段内的所有事件,除了可能由日志压缩丢弃的任何过时事件(请参阅“[日志压缩](#日志压缩)“)。实际上,你需要一个可以一直延伸到一开始的窗口。
|
||||
|
||||
原则上,任何流处理器都可以用于物化视图维护,尽管永久维护事件的需要与一些主要在有限持续时间的窗口上运行的面向分析的框架的假设背道而驰。 Samza和Kafka Streams支持这种用法,建立在Kafka对夯实的支持上【75】。
|
||||
|
||||
@ -484,7 +480,7 @@
|
||||
|
||||
例如,媒体监测服务可以订阅新闻文章和媒体广播,并搜索任何关于公司,产品或感兴趣的话题的新闻。这是通过预先制定一个搜索查询来完成的,然后不断地将新闻项目流与这个查询进行匹配。在一些网站上也有类似的功能:例如,房地产网站的用户在市场上出现符合其搜索条件的新房产时,可以要求通知。 Elasticsearch 【76】的渗滤器功能是实现这种流式搜索的一种选择。
|
||||
|
||||
传统的搜索引擎首先索引文件,然后在索引上运行查询。相比之下,搜索一个数据流将会处理它的头部:查询被存储,文档通过查询运行,就像CEP一样。在最简单的情况下,您可以针对每个查询来测试每个文档,但是如果您有大量查询,这可能会变慢。为了优化过程,可以对查询和文档进行索引,从而缩小可能匹配的查询集合【77】。
|
||||
传统的搜索引擎首先索引文件,然后在索引上运行查询。相比之下,搜索一个数据流将会处理它的头部:查询被存储,文档通过查询运行,就像CEP一样。在最简单的情况下,你可以针对每个查询来测试每个文档,但是如果你有大量查询,这可能会变慢。为了优化过程,可以对查询和文档进行索引,从而缩小可能匹配的查询集合【77】。
|
||||
|
||||
#### 消息传递和RPC
|
||||
|
||||
@ -511,7 +507,7 @@ Actor框架主要是管理通信模块的并发和分布式执行的机制,而
|
||||
|
||||
#### 事件时间与处理时间
|
||||
|
||||
有许多原因可能会延迟处理:排队,网络故障(请参阅第267页的“[不可靠的网络](ch8.md#不可靠的网络)”),导致消息代理或处理器中出现争用的性能问题,重新启动流消费者或重新处理过去的事件(参阅“[重播旧消息](#重播旧消息)”),或者在修复代码中的错误之后进行恢复。
|
||||
有许多原因可能会延迟处理:排队,网络故障(参阅“[不可靠的网络](ch8.md#不可靠的网络)”),导致消息代理或处理器中出现争用的性能问题,重新启动流消费者或重新处理过去的事件(参阅“[重放旧消息](#重放旧消息)”),或者在修复代码中的BUG之后进行恢复。
|
||||
|
||||
而且,消息延迟还可能导致消息的不可预知的排序。例如,假设用户首先发出一个Web请求(由Web服务器A处理),然后发出第二个请求(由服务器B处理)。 A和B发出描述他们处理的请求的事件,但是B的事件在A的事件发生之前到达消息代理。现在,流处理器将首先看到B事件,然后看到A事件,即使它们实际上是以相反的顺序发生的。
|
||||
|
||||
@ -519,7 +515,7 @@ Actor框架主要是管理通信模块的并发和分布式执行的机制,而
|
||||
|
||||
[^ii]: 感谢Flink社区的Kostas Kloudas提出这个比喻。
|
||||
|
||||
令人困惑的事件时间和处理时间导致错误的数据。例如,假设您有一个流处理器来测量请求率(计算每秒请求数)。如果您重新部署流处理器,则可能会关闭一分钟,并在事件恢复时处理积压的事件。如果您根据处理时间来衡量速率,那么看起来好像在处理积压时突然出现异常的请求高峰,而事实上请求的实际速率是稳定的([图11-7](img/fig11-7.png))。
|
||||
令人困惑的事件时间和处理时间导致错误的数据。例如,假设你有一个流处理器来测量请求率(计算每秒请求数)。如果你重新部署流处理器,则可能会关闭一分钟,并在事件恢复时处理积压的事件。如果你根据处理时间来衡量速率,那么看起来好像在处理积压时突然出现异常的请求高峰,而事实上请求的实际速率是稳定的([图11-7](img/fig11-7.png))。
|
||||
|
||||
![](img/fig11-7.png)
|
||||
|
||||
@ -529,12 +525,12 @@ Actor框架主要是管理通信模块的并发和分布式执行的机制,而
|
||||
|
||||
从事件时间的角度来定义窗口时,一个棘手的问题是,当你收到特定窗口的所有事件,或者是否还有事件发生时,你永远无法确定。
|
||||
|
||||
例如,假设您将事件分组为一分钟的窗口,以便您可以统计每分钟的请求数。你已经计算了一些事件,这些事件的时间戳是在第37分钟的时间落下的,时间已经推移了。现在大部分的事件都在一小时的第38和第39分钟之内。你什么时候宣布你已经完成了第37分钟的窗口,并输出其计数器值?
|
||||
例如,假设你将事件分组为一分钟的窗口,以便你可以统计每分钟的请求数。你已经计算了一些事件,这些事件的时间戳是在第37分钟的时间落下的,时间已经推移了。现在大部分的事件都在一小时的第38和第39分钟之内。你什么时候宣布你已经完成了第37分钟的窗口,并输出其计数器值?
|
||||
|
||||
在一段时间没有看到任何新的事件之后,您可以超时并宣布一个窗口,但仍然可能发生某些事件被缓存在另一台计算机上,由于网络中断而延迟。您需要能够处理窗口已经声明完成后到达的这样的滞留事件。大体上,你有两个选择【1】:
|
||||
在一段时间没有看到任何新的事件之后,你可以超时并宣布一个窗口,但仍然可能发生某些事件被缓存在另一台计算机上,由于网络中断而延迟。你需要能够处理窗口已经声明完成后到达的这样的滞留事件。大体上,你有两个选择【1】:
|
||||
|
||||
1. 忽略这些零散的事件,因为它们在正常情况下可能只是一小部分事件。您可以将丢弃事件的数量作为度量标准进行跟踪,并在您开始丢弃大量数据时发出警报。
|
||||
2. 发布一个更正,更新的窗口与包含散兵队员的价值。您可能还需要收回以前的输出。
|
||||
1. 忽略这些零散的事件,因为它们在正常情况下可能只是一小部分事件。你可以将丢弃事件的数量作为度量标准进行跟踪,并在你开始丢弃大量数据时发出警报。
|
||||
2. 发布一个更正,更新的窗口与包含散兵队员的价值。你可能还需要收回以前的输出。
|
||||
|
||||
在某些情况下,可以使用特殊的消息来指示“从现在开始,不会有比t更早的时间戳的消息”,消费者可以使用它来触发窗口【81】。但是,如果不同机器上的多个生产者正在生成事件,每个事件都有自己的最小时间戳阈值,则消费者需要分别跟踪每个生产者。在这种情况下添加和删除生产者是比较棘手的。
|
||||
|
||||
@ -542,7 +538,7 @@ Actor框架主要是管理通信模块的并发和分布式执行的机制,而
|
||||
|
||||
当事件可以在系统中的多个点缓冲时,为事件分配时间戳更加困难。例如,考虑将使用率度量的事件报告给服务器的移动应用程序。该应用程序可能会在设备处于脱机状态时使用,在这种情况下,它将在设备上本地缓冲事件,并在下一次可用的互联网连接(可能是几小时甚至几天)时将它们发送到服务器。对于这个流的任何消费者来说,这些事件将显示为极其滞后的落后者。
|
||||
|
||||
在这种情况下,根据移动设备的本地时钟,事件的时间戳实际上应该是发生用户交互的时间。但是,用户控制的设备上的时钟通常是不可信的,因为它可能会被意外或故意设置为错误的时间(请参见“[时钟同步与准确性](ch8.md#时钟同步与准确性)”)。服务器收到事件的时间(根据服务器的时钟)更可能是准确的,因为服务器在您的控制之下,但在描述用户交互方面意义不大。
|
||||
在这种情况下,根据移动设备的本地时钟,事件的时间戳实际上应该是发生用户交互的时间。但是,用户控制的设备上的时钟通常是不可信的,因为它可能会被意外或故意设置为错误的时间(请参见“[时钟同步与准确性](ch8.md#时钟同步与准确性)”)。服务器收到事件的时间(根据服务器的时钟)更可能是准确的,因为服务器在你的控制之下,但在描述用户交互方面意义不大。
|
||||
|
||||
要调整不正确的设备时钟,一种方法是记录三个时间戳【82】:
|
||||
|
||||
@ -550,7 +546,7 @@ Actor框架主要是管理通信模块的并发和分布式执行的机制,而
|
||||
* 根据设备时钟将事件发送到服务器的时间
|
||||
* 服务器根据服务器时钟收到事件的时间
|
||||
|
||||
通过从第三个时间戳中减去第二个时间戳,可以估算设备时钟和服务器时钟之间的偏移量(假设网络延迟与所需的时间戳精度相比可忽略不计)。然后,您可以将该偏移量应用于事件时间戳,从而估计事件实际发生的真实时间(假设设备时钟偏移在事件发生的时间与发送到服务器的时间之间没有变化)。
|
||||
通过从第三个时间戳中减去第二个时间戳,可以估算设备时钟和服务器时钟之间的偏移量(假设网络延迟与所需的时间戳精度相比可忽略不计)。然后,你可以将该偏移量应用于事件时间戳,从而估计事件实际发生的真实时间(假设设备时钟偏移在事件发生的时间与发送到服务器的时间之间没有变化)。
|
||||
|
||||
这个问题对于流处理来说并不是唯一的,批处理遇到了与时间推理完全相同的问题。在一个流式环境中,我们更加注意到时间的流逝。
|
||||
|
||||
@ -560,11 +556,11 @@ Actor框架主要是管理通信模块的并发和分布式执行的机制,而
|
||||
|
||||
***滚动窗口(Tumbling Window)***
|
||||
|
||||
一个滚动窗口有一个固定的长度,每个事件都属于一个窗口。例如,如果您有1分钟的翻滚窗口,则所有时间戳在`10:03:00`和`10:03:59`之间的事件会被分组到一个窗口中,`10:04:00`和`10:04:59`之间的事件下一个窗口,等等。您可以通过获取每个事件时间戳并将其四舍五入到最接近的分钟来确定它所属的窗口,从而实现1分钟的翻滚窗口。
|
||||
一个滚动窗口有一个固定的长度,每个事件都属于一个窗口。例如,如果你有1分钟的翻滚窗口,则所有时间戳在`10:03:00`和`10:03:59`之间的事件会被分组到一个窗口中,`10:04:00`和`10:04:59`之间的事件下一个窗口,等等。你可以通过获取每个事件时间戳并将其四舍五入到最接近的分钟来确定它所属的窗口,从而实现1分钟的翻滚窗口。
|
||||
|
||||
***跳动窗口(Hopping Window)***
|
||||
|
||||
跳频窗口也具有固定的长度,但允许窗口重叠以提供一些平滑。例如,1分钟跳跃大小的5分钟窗口将包含`10:03:00`至`10:07:59`之间的事件,则下一个窗口将覆盖`10:04:00`至`10:08`之间的事件: 59,等等。您可以通过首先计算1分钟滚动窗口,然后聚合在几个相邻的窗口上来实现此跳频窗口。
|
||||
跳频窗口也具有固定的长度,但允许窗口重叠以提供一些平滑。例如,1分钟跳跃大小的5分钟窗口将包含`10:03:00`至`10:07:59`之间的事件,则下一个窗口将覆盖`10:04:00`至`10:08`之间的事件: 59,等等。你可以通过首先计算1分钟滚动窗口,然后聚合在几个相邻的窗口上来实现此跳频窗口。
|
||||
|
||||
***滑动窗口***
|
||||
|
||||
@ -581,11 +577,11 @@ Actor框架主要是管理通信模块的并发和分布式执行的机制,而
|
||||
然而,新事件随时可能出现在一个流中,这使得加入流比批处理作业更具挑战性。为了更好地理解情况,我们来区分三种不同类型的连接:流-流连接,流表连接和表连接【84】。在下面的章节中,我们将通过例子来说明。
|
||||
流 - 流连接(窗口连接)
|
||||
|
||||
假设您的网站上有搜索功能,并且想要检测搜索到的网址的近期趋势。每次有人输入搜索查询时,都会记录包含查询和返回结果的事件。每当有人点击其中一个搜索结果时,就会记录另一个记录点击的事件。为了计算搜索结果中每个网址的点击率,您需要将搜索操作和点击操作的事件组合在一起,这些事件通过具有相同的会话ID进行连接。广告系统需要类似的分析【85】。
|
||||
假设你的网站上有搜索功能,并且想要检测搜索到的网址的近期趋势。每次有人输入搜索查询时,都会记录包含查询和返回结果的事件。每当有人点击其中一个搜索结果时,就会记录另一个记录点击的事件。为了计算搜索结果中每个网址的点击率,你需要将搜索操作和点击操作的事件组合在一起,这些事件通过具有相同的会话ID进行连接。广告系统需要类似的分析【85】。
|
||||
|
||||
如果用户放弃他们的搜索,点击可能永远不会到来,即使它到了,搜索和点击之间的时间可能是高度可变的:在很多情况下,它可能是几秒钟,但可能长达几天或几周(如果用户运行搜索,忘记关于该浏览器选项卡,然后返回到选项卡,稍后再单击一个结果)。由于可变的网络延迟,点击事件甚至可能在搜索事件之前到达。您可以选择合适的加入窗口,例如,如果间隔至多一小时发生一次搜索,您可以选择加入搜索。
|
||||
如果用户放弃他们的搜索,点击可能永远不会到来,即使它到了,搜索和点击之间的时间可能是高度可变的:在很多情况下,它可能是几秒钟,但可能长达几天或几周(如果用户运行搜索,忘记关于该浏览器选项卡,然后返回到选项卡,稍后再单击一个结果)。由于可变的网络延迟,点击事件甚至可能在搜索事件之前到达。你可以选择合适的加入窗口,例如,如果间隔至多一小时发生一次搜索,你可以选择加入搜索。
|
||||
|
||||
请注意,在click事件中嵌入搜索的细节并不等同于加入事件:这样做只会告诉您有关用户单击搜索结果的情况,而不是用户未点击任何搜索结果的搜索结果。为了衡量搜索质量,您需要准确的点击率,为此您需要搜索事件和点击事件。
|
||||
请注意,在click事件中嵌入搜索的细节并不等同于加入事件:这样做只会告诉你有关用户单击搜索结果的情况,而不是用户未点击任何搜索结果的搜索结果。为了衡量搜索质量,你需要准确的点击率,为此你需要搜索事件和点击事件。
|
||||
|
||||
为了实现这种类型的连接,流处理器需要维护状态:例如,在最后一小时发生的所有事件,都由会话标识索引。无论何时发生搜索事件或点击事件,都会将其添加到适当的索引,并且流处理器还检查另一个索引,以查看是否已经到达同一会话ID的另一个事件。如果有匹配的事件,则发出说明哪个搜索结果被点击的事件。如果搜索事件过期而没有看到匹配的点击事件,则会发出说明哪些搜索结果未被点击的事件。
|
||||
|
||||
@ -597,7 +593,7 @@ Actor框架主要是管理通信模块的并发和分布式执行的机制,而
|
||||
|
||||
另一种方法是将数据库副本加载到流处理器中,以便在本地进行查询而无需网络往返。这种技术与我们在“[Map端连接](ch10.md#Map端连接)”中讨论的散列连接非常相似:如果数据库的本地副本足够小,则本地副本可能是内存中的散列表,或者是本地磁盘上的索引。
|
||||
|
||||
与批处理作业的区别在于批处理作业使用数据库的时间点快照作为输入,而流处理器是长时间运行的,并且数据库的内容可能随时间而改变,所以流处理器数据库的本地副本需要保持最新。这个问题可以通过更改数据捕获来解决:流处理器可以订阅用户配置文件数据库的更新日志以及活动事件流。在创建或修改配置文件时,流处理器会更新其本地副本。因此,我们获得两个流之间的连接:活动事件和配置文件更新。
|
||||
与批处理作业的区别在于批处理作业使用数据库的时间点快照作为输入,而流处理器是长时间运行的,并且数据库的内容可能随时间而改变,所以流处理器数据库的本地副本需要保持最新。这个问题可以通过变更数据捕获来解决:流处理器可以订阅用户配置文件数据库的更新日志以及活动事件流。在创建或修改配置文件时,流处理器会更新其本地副本。因此,我们获得两个流之间的连接:活动事件和配置文件更新。
|
||||
|
||||
流表连接实际上非常类似于流 - 流连接;最大的区别在于对于表changelog流,连接使用一个可以回溯到“开始时间”(概念上是无限的窗口)的窗口,新版本的记录会覆盖较早的版本。对于流输入,连接可能根本没有维护窗口。
|
||||
|
||||
@ -632,7 +628,7 @@ GROUP BY follows.follower_id
|
||||
|
||||
这就产生了一个问题:如果不同的事件发生在相似的时间周围,他们按照何种顺序进行处理?在流表连接示例中,如果用户更新其配置文件,哪些活动事件与旧配置文件(在配置文件更新之前处理)结合,哪些与新配置文件结合(在配置文件更新之后处理)?换句话说:如果状态随着时间的推移而改变,并且你加入了某个状态,那么你使用什么时间点来加入【45】?
|
||||
|
||||
这种时间依赖性可能发生在许多地方。例如,如果您销售东西,则需要对发票进行适当的税率,这取决于国家或州,产品类型和销售日期(因为税率会随时变化)。将销售额加入税率表时,如果您正在重新处理历史数据,您可能希望加入销售时的税率,这可能与当前的税率不同。
|
||||
这种时间依赖性可能发生在许多地方。例如,如果你销售东西,则需要对发票进行适当的税率,这取决于国家或州,产品类型和销售日期(因为税率会随时变化)。将销售额加入税率表时,如果你正在重新处理历史数据,你可能希望加入销售时的税率,这可能与当前的税率不同。
|
||||
|
||||
如果跨流的事件排序是未确定的,那么这个连接变得不确定【87】,这意味着你不能在相同的输入上重新运行相同的工作,并且必然会得到相同的结果:输入流上的事件可能交织在当你再次运行这个工作时,采用不同的方式
|
||||
|
||||
@ -644,7 +640,7 @@ GROUP BY follows.follower_id
|
||||
|
||||
特别是,批处理容错方法可确保批处理作业的输出与没有出错的情况相同,即使事实上某些任务失败了。看起来好像每个输入记录都被处理了一次 —— 没有记录被跳过,而且没有处理两次。尽管重新启动任务意味着实际上可能会多次处理记录,但输出中的可见效果好像只处理过一次。这个原则被称为一次语义学,虽然有效 —— 一次将是一个更具描述性的术语【90】。
|
||||
|
||||
在流处理过程中也出现了同样的容错问题,但是处理起来不那么直观:等到某个任务完成之后才使其输出可见,因为流是无限的,因此您永远无法完成处理。
|
||||
在流处理过程中也出现了同样的容错问题,但是处理起来不那么直观:等到某个任务完成之后才使其输出可见,因为流是无限的,因此你永远无法完成处理。
|
||||
|
||||
#### 小批量和检查点
|
||||
|
||||
@ -664,13 +660,13 @@ GROUP BY follows.follower_id
|
||||
|
||||
在[第9章](ch9.md)中,我们讨论了分布式交易(如XA)的传统实现中的问题。然而,在更受限制的环境中,可以有效地实现这样的原子提交设施。 Google云数据流【81,92】和VoltDB 【94】中使用了这种方法,并计划在Apache Kafka 【95,96】中添加类似的功能。与XA不同,这些实现不会尝试跨异构技术提供事务,而是通过在流处理框架中管理状态更改和消息传递来保持内部事务。事务协议的开销可以通过在单个事务中处理几个输入消息来分摊。
|
||||
|
||||
#### 幂等
|
||||
#### 幂等性
|
||||
|
||||
我们的目标是放弃任何失败的任务的部分输出,以便他们可以安全地重试,而不会两次生效。分布式事务是实现这一目标的一种方式,但另一种方式是依赖幂等性【97】。
|
||||
|
||||
幂等操作是可以多次执行的操作,并且与只执行一次操作具有相同的效果。例如,将键值存储中的某个键设置为某个固定值是幂等的(再次写入该值会覆盖具有相同值的值),而递增计数器不是幂等的(再次执行递增意味着该值递增两次)。
|
||||
|
||||
即使一个操作不是天生的幂等,它往往可以与一些额外的元数据幂等。例如,在使用来自Kafka的消息时,每条消息都有一个持续的,单调递增的偏移量。将值写入外部数据库时,可以将触发上次写入的消息的偏移量与值包含在一起。因此,您可以判断是否已应用更新,并避免再次执行相同的更新。
|
||||
即使一个操作不是天生的幂等,它往往可以与一些额外的元数据幂等。例如,在使用来自Kafka的消息时,每条消息都有一个持续的,单调递增的偏移量。将值写入外部数据库时,可以将触发上次写入的消息的偏移量与值包含在一起。因此,你可以判断是否已应用更新,并避免再次执行相同的更新。
|
||||
|
||||
风暴三叉戟的状态处理基于类似的想法【78】。依赖幂等性意味着一些假设:重启一个失败的任务必须以相同的顺序重播相同的消息(一个基于日志的消息代理这样做),处理必须是确定性的,其他节点不能同时更新相同的值【98,99】。
|
||||
|
||||
@ -682,9 +678,9 @@ GROUP BY follows.follower_id
|
||||
|
||||
一种选择是将状态保持在远程数据存储中并复制它,尽管如每个单独消息的远程数据库查询速度可能会很慢,正如在“[流表连接](#流表连接)”中所述。另一种方法是保持流处理器的本地状态,并定期复制。然后,当流处理器从故障中恢复时,新任务可以读取复制状态并恢复处理而不丢失数据。
|
||||
|
||||
例如,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#真的串行执行)”)。
|
||||
|
||||
在某些情况下,甚至可能不需要复制状态,因为它可以从输入流重建。例如,如果状态由一个相当短的窗口中的聚合组成,则它可能足够快,以便重放与该窗口相对应的输入事件。如果状态是通过更改数据捕获维护的数据库的本地副本,那么也可以从日志压缩的更改流重建数据库(请参阅“[日志压缩](#日志压缩)”一节)。
|
||||
在某些情况下,甚至可能不需要复制状态,因为它可以从输入流重建。例如,如果状态由一个相当短的窗口中的聚合组成,则它可能足够快,以便重放与该窗口相对应的输入事件。如果状态是通过变更数据捕获维护的数据库的本地副本,那么也可以从日志压缩的更改流重建数据库(请参阅“[日志压缩](#日志压缩)”一节)。
|
||||
|
||||
但是,所有这些权衡取决于底层基础架构的性能特征:在某些系统中,网络延迟可能低于磁盘访问延迟,网络带宽可能与磁盘带宽相当。在所有情况下都没有普遍理想的权衡,随着存储和网络技术的发展,本地和远程状态的优点也可能会发生变化。
|
||||
|
||||
@ -692,7 +688,7 @@ GROUP BY follows.follower_id
|
||||
|
||||
## 本章小结
|
||||
|
||||
在本章中,我们讨论了事件流,他们所服务的目的以及如何处理它们。在某些方面,流处理非常类似于我们在[第10章](ch10.md)讨论的批处理,而是在无限的(永无止境的)流而不是固定大小的输入上持续进行。从这个角度来看,消息代理和事件日志可以作为文件系统的流媒体。
|
||||
在本章中,我们讨论了事件流,它们所服务的目的以及如何处理它们。在某些方面,流处理非常类似于我们在[第10章](ch10.md)讨论的批处理,而是在无限的(永无止境的)流而不是固定大小的输入上持续进行。从这个角度来看,消息代理和事件日志可以作为文件系统的流媒体。
|
||||
|
||||
我们花了一些时间比较两种消息代理:
|
||||
|
||||
@ -706,9 +702,9 @@ GROUP BY follows.follower_id
|
||||
|
||||
基于日志的方法与数据库中的复制日志(参见[第5章](ch5.md))和日志结构存储引擎(请参阅[第3章](ch3.md))具有相似之处。我们看到,这种方法特别适用于消耗输入流并生成派生状态或派生输出流的流处理系统。
|
||||
|
||||
就流的来源而言,我们讨论了几种可能性:用户活动事件,提供定期读数的传感器和数据馈送(例如金融市场数据)自然地表示为流。我们看到,将数据写入数据流也是有用的:我们可以捕获更改日志 —— 即对数据库所做的所有更改的历史记录 —— 隐式地通过更改数据捕获或通过事件明确地捕获代理。日志压缩允许流保留数据库 内容的完整副本。
|
||||
就流的来源而言,我们讨论了几种可能性:用户活动事件,提供定期读数的传感器和数据馈送(例如金融市场数据)自然地表示为流。我们看到,将数据写入数据流也是有用的:我们可以捕获更改日志 —— 即对数据库所做的所有更改的历史记录 —— 隐式地通过变更数据捕获或通过事件明确地捕获代理。日志压缩允许流保留数据库 内容的完整副本。
|
||||
|
||||
将数据库表示为流为系统集成提供了强大的机会。您可以通过使用更改日志并将其应用于派生系统,使派生的数据系统(如搜索索引,缓存和分析系统)保持最新。您甚至可以从头开始,从开始一直到现在消耗更改的日志,从而为现有数据构建新的视图。
|
||||
将数据库表示为流为系统集成提供了强大的机会。你可以通过使用更改日志并将其应用于派生系统,使派生的数据系统(如搜索索引,缓存和分析系统)保持最新。你甚至可以从头开始,从开始一直到现在消耗更改的日志,从而为现有数据构建新的视图。
|
||||
|
||||
将状态保持为流并重放消息的设施也是在各种流处理框架中实现流连接和容错的技术的基础。我们讨论了流处理的几个目的,包括搜索事件模式(复杂事件处理),计算加窗聚合(流分析)以及保持派生数据系统处于最新状态(物化视图)。
|
||||
|
||||
@ -718,7 +714,7 @@ GROUP BY follows.follower_id
|
||||
|
||||
***流式流连接***
|
||||
|
||||
两个输入流由活动事件组成,并且连接操作符搜索在某个时间窗口内发生的相关事件。例如,它可以匹配相同用户在30分钟内采取的两个动作。如果您想要在一个流中查找相关事件,则两个连接输入实际上可以是相同的流(自连接)。
|
||||
两个输入流由活动事件组成,并且连接操作符搜索在某个时间窗口内发生的相关事件。例如,它可以匹配相同用户在30分钟内采取的两个动作。如果你想要在一个流中查找相关事件,则两个连接输入实际上可以是相同的流(自连接)。
|
||||
|
||||
***流表连接***
|
||||
|
||||
|
6
ch12.md
6
ch12.md
@ -182,9 +182,9 @@
|
||||
* 复制日志,保持其他节点上数据的副本最新(参阅“[复制日志的实现](ch5.md#复制日志的实现)”)
|
||||
* 全文搜索索引,允许在文本中进行关键字搜索(参见“[全文搜索和模糊索引](ch3.md#全文搜索和模糊索引)”)内置于某些关系数据库【1】
|
||||
|
||||
在[第10章](ch10.md)和[第11章](ch11.md)中,出现了类似的主题。我们讨论了如何构建全文搜索索引(请参阅第357页上的“[批处理工作流的输出]()”),了解有关实例化视图维护(请参阅“[维护实例化视图]()”一节第437页)以及有关将变更从数据库复制到衍生数据系统(请参阅第454页的“[变更数据捕获]()”)。
|
||||
在[第10章](ch10.md)和[第11章](ch11.md)中,出现了类似的主题。我们讨论了如何构建全文搜索索引(请参阅第357页上的“[批处理工作流的输出]()”),了解有关实例化视图维护(请参阅“[维护实例化视图]()”一节第437页)以及有关将变更从数据库复制到衍生数据系统(请参阅第454页的“[变更数据捕获]()”)。
|
||||
|
||||
数据库中内置的功能与人们用批处理和流处理器构建的衍生数据系统似乎有相似之处。
|
||||
数据库中内置的功能与人们用批处理和流处理器构建的衍生数据系统似乎有相似之处。
|
||||
|
||||
#### 创建索引
|
||||
|
||||
@ -233,7 +233,7 @@
|
||||
|
||||
运行几种不同基础设施的复杂性可能是一个问题:每种软件都有一个学习曲线,配置问题和操作怪癖,因此部署尽可能少的移动部件是很有必要的。比起使用应用代码拼接多个工具而成的系统,单一集成软件产品也可以在其设计应对的工作负载类型上实现更好,更可预测的性能【23】。正如我在前言中所说的那样,为了你不需要的规模建立系统是白费精力,而且可能会将你锁定在一个不灵活的设计中。实际上,这是一种过早优化的形式。
|
||||
|
||||
分拆的目标不是要针对个别数据库与特定工作负载的性能进行竞争;我们的目标是允许您结合多个不同的数据库,以便在比单个软件可能实现的更广泛的工作负载范围内实现更好的性能。这是关于广度,而不是深度 —— 与我们在第414页上的“[比较Hadoop与分布式数据库]()”中讨论的存储和处理模型的多样性一样。
|
||||
分拆的目标不是要针对个别数据库与特定工作负载的性能进行竞争;我们的目标是允许您结合多个不同的数据库,以便在比单个软件可能实现的更广泛的工作负载范围内实现更好的性能。这是关于广度,而不是深度 —— 与我们在“[对比Hadoop与分布式数据库](ch10.md#对比Hadoop与分布式数据库)”中讨论的存储和处理模型的多样性一样。
|
||||
|
||||
因此,如果有一项技术可以满足您的所有需求,那么您最好使用该产品,而不是试图用低级组件重新实现它。只有当没有单一软件满足您的所有需求时,才会出现拆分和联合的优势。
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user