mirror of
https://github.com/Vonng/ddia.git
synced 2024-12-06 15:20:12 +08:00
ch12 45%
This commit is contained in:
parent
9f481740a9
commit
0d7102774f
170
ch12.md
170
ch12.md
@ -10,37 +10,37 @@
|
||||
|
||||
[TOC]
|
||||
|
||||
到目前为止,本书主要描述的是目前的情况。在这最后一章中,我们将转向未来,讨论应该如何做:我将提出一些想法和方法,我相信这些方法可以从根本上改进我们设计和构建应用的方式。
|
||||
到目前为止,本书主要描述的是**实然**问题:现在事情**是**什么样的。在这最后一章中,我们将放眼未来,讨论**应然**的问题:事情**应该**是什么样子的。我将提出一些想法与方法,我相信它们能从根本上改进我们设计与构建应用的方式。
|
||||
|
||||
对未来的看法和猜测当然是主观的,所以我在写这篇个人意见的时候会用到第一人称。我们欢迎您不同意并形成自己的观点,但我希望本章的观点至少可以成为一个富有成效的讨论的出发点,并为经常混淆的概念提供一些清晰。
|
||||
对未来的看法与推测当然具有很大的主观性。所以在撰写本章时,当提及我个人的观点时会使用第一人称。您完全可以不同意这些观点并提出自己的看法,但我希望本章中的概念,至少能成为富有成效讨论的出发点,并澄清一些经常被混淆的概念。
|
||||
|
||||
[第1章](ch1.md)概述了本书的目标:探索如何创建可靠,可扩展和可维护的应用和系统。这些主题贯穿了所有的章节:例如,我们讨论了许多有助于提高可靠性的容错算法,提高可扩展性的分区,以及提高可维护性的演化和抽象机制。在本章中,我们将把所有这些想法结合在一起,并以这些想法为基础来设想未来。我们的目标是发现如何设计比今天更好的应用程序——强大,正确,可演化,并最终对人类有益。
|
||||
[第1章](ch1.md)概述了本书的目标:探索如何创建**可靠**,**可扩展**和**可维护**的应用与系统。这一主题贯穿了所有的章节:例如,我们讨论了许多有助于提高可靠性的容错算法,有助于提高可扩展性的分区,以及有助于提高可维护性的演化与抽象机制。在本章中,我们将把所有这些想法结合在一起,并在它们的基础上展望未来。我们的目标是,发现如何设计出比现有应用更好的应用 —— 健壮,正确,可演化,且最终对人类有益。
|
||||
|
||||
## 数据集成
|
||||
|
||||
本书中反复出现的主题是,对于任何给定的问题,都有几种解决方案,所有这些解决方案都有不同的优点,缺点和权衡。例如,在[第3章](ch3.md)讨论存储引擎时,我们看到了日志结构存储,B树和列存储。在[第5章](ch5.md)讨论复制时,我们看到了单主,多主和无主的方法。
|
||||
本书中反复出现的主题是,对于任何给定的问题都会有好几种解决方案,所有这些解决方案都有不同的优缺点与利弊权衡。例如在[第3章](ch3.md)讨论存储引擎时,我们看到了日志结构存储,B树,以及列存储。在[第5章](ch5.md)讨论复制时,我们看到了单领导者,多领导者,和无领导者的方法。
|
||||
|
||||
如果你有一个类似于“我想存储一些数据并稍后再查询”的问题,那么并没有一种正确的解决方案。但对于不同的具体环境,总会有不同的合适方法。软件实现通常必须选择一种特定的方法。使用单一代码路径,且能做到稳定健壮,表现良好是一件非常困难的事情 —— 尝试在单个软件中完成所有事情,几乎可以保证实现效果会非常差。
|
||||
如果你有一个类似于“我想存储一些数据并稍后再查询”的问题,那么并没有一种正确的解决方案。但对于不同的具体环境,总会有不同的合适方法。软件实现通常必须选择一种特定的方法。使单条代码路径能做到稳定健壮且表现良好已经是一件非常困难的事情了 —— 尝试在单个软件中完成所有事情,几乎可以保证,实现效果会很差。
|
||||
|
||||
因此,软件工具的最佳选择也取决于情况。每一个软件,甚至是所谓“通用”数据库,都是针对特定的使用模式而设计的。
|
||||
因此软件工具的最佳选择也取决于情况。每一种软件,甚至所谓的“通用”数据库,都是针对特定的使用模式设计的。
|
||||
|
||||
面对如此众多的替代品,第一个挑战就是弄清楚软件产品与其所适应环境之间的映射关系。供应商可以理解地不愿意告诉你他们的软件不适合的工作负载类型,但是希望以前的章节能够给你提供一些问题,以便在各行之间阅读并更好地理解这些权衡。
|
||||
面对让人眼花缭乱的诸多替代品,第一个挑战就是弄清软件与其适用环境的映射关系。供应商不愿告诉你他们软件不适用的工作负载,这是可以理解的。但是希望先前的章节能给你提供一些问题,让你读出字里行间的言外之意,并更好地理解这些权衡。
|
||||
|
||||
但是,即使您完全理解工具与其使用环境之间的映射,还有一个挑战:在复杂的应用程序中,数据通常以多种不同的方式使用。不太可能存在适用于所有不同数据使用环境的软件,因此您不可避免地需要拼凑几个不同的软件以提供您的应用程序的功能。
|
||||
但是,即使你已经完全理解各种工具与其适用环境间的关系,还有一个挑战:在复杂的应用中,数据的用法通常花样百出。不太可能存在适用于**所有**不同数据应用场景的软件,因此您不可避免地需要拼凑几个不同的软件来以提供应用所需的功能。
|
||||
|
||||
### 组合使用衍生数据的工具
|
||||
|
||||
例如,为了处理任意关键字的查询,需要将OLTP数据库与全文搜索索引集成在一起是很常见的。尽管一些数据库(如PostgreSQL)包含了全文索引功能,可以满足简单的应用需求【1】,但更复杂的搜索工具需要专业的信息检索工具。相反,搜索索引通常不适合作为一个持久的记录系统,因此许多应用程序需要结合两种不同的工具来满足所有要求。
|
||||
例如,为了处理任意关键词的搜索查询,将OLTP数据库与全文搜索索引集成在一起是很常见的的需求。尽管一些数据库(例如PostgreSQL)包含了全文索引功能,对于简单的应用完全够了【1】,但更复杂的搜索能力就需要专业的信息检索工具了。相反的是,搜索索引通常不适合作为持久的记录系统,因此许多应用需要组合这两种不同的工具以满足所有需求。
|
||||
|
||||
我们谈到了将数据系统集成到“[使系统保持同步]()”(第452页)的问题。随着数据的不同表示数量的增加,集成问题变得更加困难。除了数据库和搜索索引之外,也许您需要保留分析系统(数据仓库或批处理和流处理系统)中的数据副本。维护从原始数据衍生的对象的高速缓存或非规范化版本;通过机器学习,分类,排名或推荐系统传递数据;或根据数据变更发送通知。
|
||||
我们在“[使系统保持同步](ch11.md#使系统保持同步)”中接触过集成数据系统的问题。随着数据不同表示形式的增加,集成问题变得越来越困难。除了数据库和搜索索引之外,也许你需要在分析系统(数据仓库,或批处理和流处理系统)中维护数据副本;维护从原始数据中衍生的缓存,或反规范化的数据版本;将数据灌入机器学习,分类,排名,或推荐系统中;或者基于数据变更发送通知。
|
||||
|
||||
令人惊讶的是,我经常看到软件工程师做出如下陈述:“根据我的经验,99%的人只需要X”或者 “......不需要X”(对于各种各样的X)。我认为这样的陈述更多地是关于发言者自己的经验,而不是技术的实际有用性。对数据执行的各种操作,其范围可能极其宽广。某人认为鸡肋与毫无意义的功能,可能是别人的核心需求。当你拉高视角并考虑跨越整个组织范围的数据流时,对数据集成的需求往往就会变得明显。
|
||||
令人惊讶的是,我经常看到软件工程师做出这样的陈述:“根据我的经验,99%的人只需要X”或者 “......不需要X”(对于各种各样的X)。我认为这种陈述更像是发言人自己的经验,而不是技术实际上的实用性。可能对数据执行的操作,其范围极其宽广。某人认为鸡肋而毫无意义的功能可能是别人的核心需求。当你拉高视角,并考虑跨越整个组织范围的数据流时,数据集成的需求往往就会变得明显起来。
|
||||
|
||||
#### 推理数据流
|
||||
#### 理解数据流
|
||||
|
||||
当需要在多个存储系统中维护相同数据的副本以满足不同的访问模式时,您需要对输入和输出了如指掌:哪些数据先写入,哪些数据表示来自哪些来源?如何以正确的格式将数据导入所有正确的地方?
|
||||
当需要在多个存储系统中维护相同数据的副本以满足不同的访问模式时,你要对输入和输出了如指掌:哪些数据先写入,哪些数据表示衍生自哪些来源?如何以正确的格式,将所有数据导入正确的地方?
|
||||
|
||||
例如,您可能会首先将数据写入**记录数据库**系统,捕获对该数据库所做的变更(参阅“[捕获数据变更](ch11.md#捕获数据变更)”),然后将变更应用于数据库中的搜索索引相同的顺序。如果变更数据捕获(CDC)是更新索引的唯一方式,则可以确定该索引完全派生自记录系统,因此与其保持一致(除软件错误外)。写入数据库是向该系统提供新输入的唯一方式。
|
||||
例如,你可能会首先将数据写入**记录数据库**系统,捕获对该数据库所做的变更(参阅“[捕获数据变更](ch11.md#捕获数据变更)”),然后将变更应用于数据库中的搜索索引相同的顺序。如果变更数据捕获(CDC)是更新索引的唯一方式,则可以确定该索引完全派生自记录系统,因此与其保持一致(除软件错误外)。写入数据库是向该系统提供新输入的唯一方式。
|
||||
|
||||
允许应用程序直接写入搜索索引和数据库引入了如[图11-4](img/fig11-4.png)所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。
|
||||
|
||||
@ -198,7 +198,7 @@
|
||||
|
||||
有鉴于此,我认为整个组织的数据流开始像一个巨大的数据库【7】。每当批处理,流或ETL过程将数据从一个地方传输到另一个地方并组装时,它表现地就像数据库子系统一样,使索引或物化视图保持最新。
|
||||
|
||||
从这种角度来看,批处理和流处理器就像触发器,存储过程和物化视图维护例程的精细实现。他们维护的衍生数据系统就像不同的索引类型。例如,关系数据库可能支持B树索引,散列索引,空间索引(请参阅第79页的“[多列索引]()”)以及其他类型的索引。在新兴的衍生数据系统架构中,不是将这些设施作为单个集成数据库产品的功能实现,而是由各种不同的软件提供,运行在不同的机器上,由不同的团队管理。
|
||||
从这种角度来看,批处理和流处理器就像触发器,存储过程和物化视图维护例程的精细实现。它们维护的衍生数据系统就像不同的索引类型。例如,关系数据库可能支持B树索引,散列索引,空间索引(请参阅第79页的“[多列索引]()”)以及其他类型的索引。在新兴的衍生数据系统架构中,不是将这些设施作为单个集成数据库产品的功能实现,而是由各种不同的软件提供,运行在不同的机器上,由不同的团队管理。
|
||||
|
||||
这些发展在未来将会把我们带到哪里?如果我们从没有适合所有访问模式的单一数据模型或存储格式的前提出发,我推测有两种途径可以将不同的存储和处理工具组合成一个有凝聚力的系统:
|
||||
|
||||
@ -229,19 +229,19 @@
|
||||
|
||||
#### 分拆系统vs集成系统
|
||||
|
||||
如果分拆确实成为未来的方式,它也不会取代目前形式的数据库 —— 它们仍然会像以往一样需要。为了维护流处理组件中的状态,数据库仍然是需要的,并且为批处理和流处理器的输出提供查询服务(请参阅第419页上的“[批处理工作流的输出]()”和第464页上的“[处理流]()”)。专用查询引擎对于特定的工作负载仍然非常重要:例如,MPP数据仓库中的查询引擎针对探索性分析查询进行了优化,并且能够很好地处理这种类型的工作负载(请参阅第417页的“[将Hadoop与分布式数据库进行比较]()” 。
|
||||
如果分拆确实成为未来的方式,它也不会取代目前形式的数据库 —— 它们仍然会像以往一样被需要。为了维护流处理组件中的状态,数据库仍然是需要的,并且为批处理和流处理器的输出提供查询服务(请参阅第419页上的“[批处理工作流的输出](ch10.md#批处理工作流的输出)”和第464页上的“[流处理](ch11.md#流处理)”)。专用查询引擎对于特定的工作负载仍然非常重要:例如,MPP数据仓库中的查询引擎针对探索性分析查询进行了优化,并且能够很好地处理这种类型的工作负载(请参阅第417页的“[对比Hadoop与分布式数据库](ch10.md#对比Hadoop与分布式数据库)” 。
|
||||
|
||||
运行几种不同基础设施的复杂性可能是一个问题:每种软件都有一个学习曲线,配置问题和操作怪癖,因此部署尽可能少的移动部件是很有必要的。比起使用应用代码拼接多个工具而成的系统,单一集成软件产品也可以在其设计应对的工作负载类型上实现更好,更可预测的性能【23】。正如我在前言中所说的那样,为了你不需要的规模建立系统是白费精力,而且可能会将你锁定在一个不灵活的设计中。实际上,这是一种过早优化的形式。
|
||||
运行几种不同基础设施的复杂性可能是一个问题:每种软件都有一个学习曲线,配置问题和操作怪癖,因此部署尽可能少的移动部件是很有必要的。比起使用应用代码拼接多个工具而成的系统,单一集成软件产品也可以在其设计应对的工作负载类型上实现更好,更可预测的性能【23】。正如在前言中所说的那样,为了不需要的规模而构建系统是白费精力,而且可能会将你锁死在一个不灵活的设计中。实际上,这是一种过早优化的形式。
|
||||
|
||||
分拆的目标不是要针对个别数据库与特定工作负载的性能进行竞争;我们的目标是允许您结合多个不同的数据库,以便在比单个软件可能实现的更广泛的工作负载范围内实现更好的性能。这是关于广度,而不是深度 —— 与我们在“[对比Hadoop与分布式数据库](ch10.md#对比Hadoop与分布式数据库)”中讨论的存储和处理模型的多样性一样。
|
||||
|
||||
因此,如果有一项技术可以满足您的所有需求,那么您最好使用该产品,而不是试图用低级组件重新实现它。只有当没有单一软件满足您的所有需求时,才会出现拆分和联合的优势。
|
||||
因此,如果有一项技术可以满足您的所有需求,那么最好使用该产品,而不是试图用低级组件重新实现它。只有当没有单一软件满足您的所有需求时,才会出现拆分和联合的优势。
|
||||
|
||||
#### 少了什么?
|
||||
|
||||
用于组成数据系统的工具正在变得越来越好,但我认为还缺少一个主要的东西:我们还没有与Unix shell类似的分拆数据库(即,一种声明式的,简单的,用于组装存储和处理系统的高级语言)。
|
||||
|
||||
例如,如果我们可以简单地声明`mysql |elasticsearch`,类似于Unix管道【22】,成为`CREATE INDEX`的分拆等价物:它将MySQL数据库中的所有文档并将其索引到Elasticsearch集群中。然后它会不断捕获对数据库所做的所有变更,并自动将它们应用于搜索索引,而无需编写自定义应用程序代码。几乎任何类型的存储或索引系统都可以实现这种集成。
|
||||
例如,如果我们可以简单地声明`mysql |elasticsearch`,类似于Unix管道【22】,成为`CREATE INDEX`的分拆等价物:它将MySQL数据库中的所有文档并将其索引到Elasticsearch集群中。然后它会不断捕获对数据库所做的所有变更,并自动将它们应用于搜索索引,而无需编写自定义应用代码。这种集成应当支持几乎任何类型的存储或索引系统。
|
||||
|
||||
同样,能够更容易地预先计算和更新缓存将是一件好事。回想一下,物化视图本质上是一个预先计算的缓存,所以您可以通过为复杂查询声明指定物化视图来创建缓存,包括图上的递归查询(参阅“[图数据模型](ch2.md#图数据模型)”)和应用逻辑。在这方面有一些有趣的早期研究,如**差分数据流(differential dataflow)**【24,25】,我希望这些想法能够在生产系统中找到自己的方法。
|
||||
|
||||
@ -253,7 +253,7 @@
|
||||
|
||||
即使是**电子表格**也在数据流编程能力上甩开大多数主流编程语言几条街【33】。在电子表格中,可以将公式放入一个单元格中(例如,另一列中的单元格求和值),并且只要公式的任何输入发生变更,公式的结果都会自动重新计算。这正是我们在数据系统层次所需要的:当数据库中的记录发生变更时,我们希望自动更新该记录的任何索引,并且自动刷新依赖于记录的任何缓存视图或聚合。您不必担心这种刷新如何发生的技术细节,但能够简单地相信它可以正常工作。
|
||||
|
||||
因此,我认为绝大多数数据系统仍然可以从VisiCalc在1979年已经具备的功能中学习【34】。与电子表格的不同之处在于,今天的数据系统需要具有容错性,可扩展性以及持久存储数据。他们还需要能够整合不同人群编写的不同技术,并重用现有的库和服务:期望使用某种特定语言,框架或工具开发所有软件是不切实际的。
|
||||
因此,我认为绝大多数数据系统仍然可以从VisiCalc在1979年已经具备的功能中学习【34】。与电子表格的不同之处在于,今天的数据系统需要具有容错性,可扩展性以及持久存储数据。它们还需要能够整合不同人群编写的不同技术,并重用现有的库和服务:期望使用某种特定语言,框架或工具开发所有软件是不切实际的。
|
||||
|
||||
在本节中,我将详细介绍这些想法,并探讨一些围绕分拆数据库和数据流的想法构建应用的方法。
|
||||
|
||||
@ -267,7 +267,7 @@
|
||||
* 缓存通常包含将以用户界面(UI)显示的形式的数据聚合。因此填充缓存需要知道UI中引用的字段;UI中的变更可能需要更新缓存填充方式的定义,并重建缓存。
|
||||
|
||||
用于次级索引的衍生函数是如此常用的需求,以致于它作为核心功能被内建至许多数据库中,你可以简单地通过`CREATE INDEX`来调用它。对于全文索引,常见语言的基本语言特征可能内置到数据库中,但更复杂的特征通常需要领域特定的调整。在机器学习中,特征工程是众所周知的特定于应用的特征,通常需要包含很多关于用户交互与应用部署的详细知识【35】。
|
||||
当创建衍生数据集的函数不是像创建二级索引那样的标准搬砖函数时,需要自定义代码来处理特定于应用程序的东西。而这个自定义代码是让许多数据库挣扎的地方,虽然关系数据库通常支持触发器,存储过程和用户定义的函数,它们可以用来在数据库中执行应用代码,但它们有点像数据库设计中的事后反思。(参阅“[传输事件流](ch11.md#传输事件流)”(第447页))。
|
||||
当创建衍生数据集的函数不是像创建二级索引那样的标准搬砖函数时,需要自定义代码来处理特定于应用的东西。而这个自定义代码是让许多数据库挣扎的地方,虽然关系数据库通常支持触发器,存储过程和用户定义的函数,它们可以用来在数据库中执行应用代码,但它们有点像数据库设计里的事后反思。(参阅“[传输事件流](ch11.md#传输事件流)”)。
|
||||
|
||||
#### 应用代码和状态的分离
|
||||
|
||||
@ -277,7 +277,7 @@
|
||||
|
||||
现在大多数Web应用程序都是作为无状态服务部署的,其中任何用户请求都可以路由到任何应用程序服务器,并且服务器在发送响应后会忘记所有请求。这种部署方式很方便,因为可以随意添加或删除服务器,但状态必须到某个地方:通常是数据库。趋势是将无状态应用程序逻辑与状态管理(数据库)分开:不将应用程序逻辑放入数据库中,也不将持久状态置于应用程序中【36】。正如职能规划界人士喜欢开玩笑说的那样,“我们相信**教会(Church)**与**国家(state)**的分离”【37】 [^i]
|
||||
|
||||
[^i]: 解释笑话很少能让人感觉更好,但我不想让任何人感到被遗漏。 在这里,Church指代的是数学家的阿隆佐·邱奇,他创立了lambda演算,这是计算的早期形式,是大多数函数式编程语言的基础。 lambda演算不具有可变状态(即没有变量可以被覆盖),所以可以说可变状态与Church的工作是分离的。
|
||||
[^i]: 解释笑话很少会让人感觉更好,但我不想让任何人感到被遗漏。 在这里,Church指代的是数学家的阿隆佐·邱奇,他创立了lambda演算,这是计算的早期形式,是大多数函数式编程语言的基础。 lambda演算不具有可变状态(即没有变量可以被覆盖),所以可以说可变状态与Church的工作是分离的。
|
||||
|
||||
在这个典型的Web应用模型中,数据库充当一种可以通过网络同步访问的可变共享变量。应用程序可以读取和更新变量,而数据库负责维持它的持久性,提供一些诸如并发控制和容错的功能。
|
||||
|
||||
@ -285,56 +285,56 @@
|
||||
|
||||
数据库继承了这种可变数据的被动方法:如果你想知道数据库的内容是否发生了变化,通常你唯一的选择就是轮询(即定期重复你的查询)。 订阅变更只是刚刚开始出现的功能(参阅“[变更流的API支持](ch11.md#变更流的API支持)”)。
|
||||
|
||||
#### 数据流:状态变化和应用代码间的相互作用
|
||||
#### 数据流:应用代码与状态变化的交互
|
||||
|
||||
从数据流的角度思考应用,意味着重新协商应用代码和状态管理之间的关系。将数据库视为被应用程序操纵的被动变量,取而代之的是,而是更多地考虑状态,状态变更和处理它们的代码之间的相互作用与协作。应用代码通过在另一个地方触发状态变更来响应状态变更。
|
||||
从数据流的角度思考应用,意味着重新协调应用代码和状态管理之间的关系。将数据库视作被应用操纵的被动变量,取而代之的是更多地考虑状态,状态变更和处理它们的代码之间的相互作用与协同关系。应用代码通过在另一个地方触发状态变更来响应状态变更。
|
||||
|
||||
我们在第451页的“[数据库和数据流]()”中看到了这一思路,我们讨论了将数据库的变更日志视为一种我们可以订阅的事件流。消息传递系统(如Actor)(请参阅第136页的“[消息传递数据流]()”)也具有响应事件的概念。早在20世纪80年代,**元组空间**模型探索表示分布式计算的过程,观察状态变化并对它们做出反应【38,39】。
|
||||
我们在“[流与数据库](ch11.md#流与数据库)”中看到了这一思路,我们讨论了将数据库的变更日志视为一种我们可以订阅的事件流。诸如Actor的消息传递系统(参阅“[消息传递数据流](ch4.md#消息传递数据流)”)也具有响应事件的概念。早在20世纪80年代,**元组空间(tuple space)**模型就已经探索了表达分布式计算的方式:观察状态变更并作出反应【38,39】。
|
||||
|
||||
如前所述,当触发器由于数据变更而触发时,或者次级索引更新以反映索引表中的变更时,数据库内部发生着类似的情况。分拆数据库意味着将这个想法应用于在主数据库之外创建衍生数据集:缓存,全文搜索索引,机器学习或分析系统。我们可以为此使用流处理和消息传递系统。
|
||||
如前所述,当触发器由于数据变更而被触发时,或次级索引更新以反映索引表中的变更时,数据库内部也发生着类似的情况。分拆数据库意味着将这个想法应用于在主数据库之外,用于创建衍生数据集:缓存,全文搜索索引,机器学习或分析系统。我们可以为此使用流处理和消息传递系统。
|
||||
|
||||
需要记住的重要一点是,维护衍生数据不同于执行异步任务。传统消息系统通常是为执行异步任务设计的(参阅“[与传统消息传递相比的日志](ch11.md#与传统消息传递相比的日志)”):
|
||||
|
||||
* 在维护衍生数据时,状态变更的顺序通常很重要(如果多个视图是从事件日志衍生的,则需要按照相同的顺序处理事件,以便它们之间保持一致)。如第445页上的“[确认和重新传递]()”中所述,许多消息代理在重传未确认消息时没有此属性,双写也被排除在外(请参阅第454页上的“[保持系统同步]()”)。
|
||||
* 在维护衍生数据时,状态变更的顺序通常很重要(如果多个视图是从事件日志衍生的,则需要按照相同的顺序处理事件,以便它们之间保持一致)。如“[确认与重传](ch11.md#确认与重传)”中所述,许多消息代理在重传未确认消息时没有此属性,双写也被排除在外(参阅“[保持系统同步](ch11.md#保持系统同步)”)。
|
||||
|
||||
|
||||
* 容错是衍生数据的关键:仅丢失单个消息会导致衍生数据集永远与其数据源失去同步。消息传递和衍生状态更新都必须可靠。例如,许多Actor系统默认在内存中维护角色状态和消息,所以如果运行Actor的机器崩溃,状态和消息就会丢失。
|
||||
* 容错是衍生数据的关键:仅仅丢失单个消息就会导致衍生数据集永远与其数据源失去同步。消息传递和衍生状态更新都必须可靠。例如,许多Actor系统默认在内存中维护Actor的状态和消息,所以如果运行Actor的机器崩溃,状态和消息就会丢失。
|
||||
|
||||
|
||||
稳定的消息排序和容错消息处理是相当严格的要求,但与分布式事务相比,它们更廉价,操作更稳定。现代流处理组件可以提供这些排序和可靠性保证,并允许应用代码作为流操作符运行。此应用程序代码可以执行数据库中内置的衍生函数通常不提供的任意处理。就像管道链接的Unix工具一样,流操作符可以组成数据流周围的大型系统。每个运算符将状态变化流作为输入,并产生其他状态变化流作为输出。
|
||||
稳定的消息排序和容错消息处理是相当严格的要求,但与分布式事务相比,它们开销更小,运行更稳定。现代流处理组件可以提供这些排序和可靠性保证,并允许应用代码以流算子的形式运行。
|
||||
|
||||
这些应用代码可以执行任意处理,包括数据库内置衍生函数通常不提供的功能。就像通过管道链接的Unix工具一样,流算子可以围绕着数据流构建大型系统。每个算子接受状态变更的流作为输入,并产生其他状态变化的流作为输出。
|
||||
|
||||
#### 流处理器和服务
|
||||
|
||||
当前流行的应用开发风格涉及将功能分解为一组通过同步网络请求(如REST API)进行通信的**服务(service)**(请参阅第121页的“[通过服务实现数据流:REST和RPC]()”)。这种面向服务的架构优于单一庞大应用的优势主要在于:通过松散耦合来提供组织上的可扩展性:不同的团队可以专职于不同的服务上,从而减少团队之间的协调工作(只要服务可以独立部署和更新)。
|
||||
当今流行的应用开发风格涉及将功能分解为一组通过同步网络请求(如REST API)进行通信的**服务(service)**(参阅“[通过服务实现数据流:REST和RPC](ch4.md#通过服务实现数据流:REST和RPC)”)。这种面向服务的架构优于单一庞大应用的优势主要在于:通过松散耦合来提供组织上的可扩展性:不同的团队可以专职于不同的服务上,从而减少团队之间的协调工作(因为服务可以独立部署和更新)。
|
||||
|
||||
将流操作符合并到数据流系统中,与微服务方法有很多相似的特征【40】。但是,底层的通信机制是非常不同的:单向异步消息流,而不是同步请求/响应式交互。
|
||||
在数据流中组装流算子与微服务方法有很多相似之处【40】。但底层通信机制是有很大区别:数据流采用单向异步消息流,而不是同步的请求/响应式交互。
|
||||
|
||||
除了第136页上的“[消息传递数据流]()”中列出的优点(如更好的容错性),数据流系统还可以获得更好的性能。例如,假设客户正在购买以一种货币定价但以另一种货币支付的商品。为了执行货币换算,您需要知道当前的汇率。这个操作可以通过两种方式实现【40,41】:
|
||||
除了在“[消息传递数据流](ch4.md#消息传递数据流)”中列出的优点(如更好的容错性),数据流系统还能实现更好的性能。例如,假设客户正在购买以一种货币定价,但以另一种货币支付的商品。为了执行货币换算,你需要知道当前的汇率。这个操作可以通过两种方式实现【40,41】:
|
||||
|
||||
1. 在微服务方法中,处理购买的代码可能会查询汇率服务或数据库,以获取特定货币的当前汇率。
|
||||
2. 在数据流方法中,处理排序的代码将提前订阅汇率更新流,并在汇率发生变动时将当前汇率存储在本地数据库中。处理排序时,只需查询本地数据库即可。
|
||||
2. 在数据流方法中,处理订单的代码会提前订阅汇率变更流,并在汇率发生变动时将当前汇率存储在本地数据库中。处理订单时只需查询本地数据库即可。
|
||||
|
||||
|
||||
|
||||
第二种方法能将对另一服务的同步网络请求替换为对本地数据库的查询(可能在同一台机器甚至同一个进程中)[^ii]。数据流方法不仅更快,而且当其他服务失效时也更稳健。最快且最可靠的网络请求就是压根没有网络请求!我们现在不使用RPC,而是在购买事件和汇率更新事件之间建立流联接(请参阅第473页的“[流表联接(流增强)]()”)。
|
||||
第二种方法能将对另一服务的同步网络请求替换为对本地数据库的查询(可能在同一台机器甚至同一个进程中)[^ii]。数据流方法不仅更快,而且当其他服务失效时也更稳健。最快且最可靠的网络请求就是压根没有网络请求!我们现在不再使用RPC,而是在购买事件和汇率更新事件之间建立流联接(参阅“[流表联接](ch11.md#流表联接)”)。
|
||||
|
||||
[^ii]: 在微服务方法中,您可以通过在处理购买的服务中本地缓存汇率来避免同步网络请求。 但是,为了保鲜缓存,你需要定期轮询汇率以获取其更新,或订阅变更流 —— 这正是数据流方法中发生的情况。
|
||||
|
||||
连接是时间相关的:如果购买事件在稍后的时间点被重新处理,汇率将会改变。如果要重建原始输出,则需要获取原始购买时的历史汇率。无论是查询服务还是订阅汇率更新流,你都需要处理这种时间依赖性(请参阅第475页的“[连接的时间依赖性]()”)。
|
||||
|
||||
订阅变更流,而不是在需要时查询当前状态,使我们更接近类似电子表格的计算模型:当某些数据发生变更时,依赖于此的所有衍生数据都可以快速更新。还有很多未解决的问题,例如围绕时间依赖连接等问题,但我认为围绕数据流想法构建应用程序是一个非常有希望的方向。
|
||||
[^ii]: 在微服务方法中,你也可以通过在处理购买的服务中本地缓存汇率来避免同步网络请求。 但是为了保证缓存的新鲜度,你需要定期轮询汇率以获取其更新,或订阅变更流 —— 这恰好是数据流方法中发生的事情。
|
||||
|
||||
连接是时间相关的:如果购买事件在稍后的时间点被重新处理,汇率可能已经改变。如果要重建原始输出,则需要获取原始购买时的历史汇率。无论是查询服务还是订阅汇率更新流,你都需要处理这种时间相关性(参阅“[连接的时间相关性](ch11.md#连接的时间相关性)”)。
|
||||
|
||||
订阅变更流,而不是在需要时查询当前状态,使我们更接近类似电子表格的计算模型:当某些数据发生变更时,依赖于此的所有衍生数据都可以快速更新。还有很多未解决的问题,例如关于时间相关连接等问题,但我认为围绕数据流构建应用的想法是一个非常有希望的方向。
|
||||
|
||||
### 观察衍生数据状态
|
||||
|
||||
在抽象层面,上一节讨论的数据流系统为您提供了创建衍生数据集(例如搜索索引,物化视图和预测模型)并使其保持最新的过程。让我们称这个过程为**写路径(write path)**:只要某些信息被写入系统,它可能会经历批处理和流处理的多个阶段,并且最终每个衍生数据集都会更新,以适配写入的数据。[图12-1](img/fig12-1.png)显示了更新搜索索引的示例。
|
||||
在抽象层面,上一节讨论的数据流系统提供了创建衍生数据集(例如搜索索引,物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为**写路径(write path)**:只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个衍生数据集都会被更新,以适配写入的数据。[图12-1](img/fig12-1.png)显示了一个更新搜索索引的例子。
|
||||
|
||||
![](img/fig12-1.png)
|
||||
|
||||
**图12-1 在搜索索引中,写(文档更新)遇上读(查询)**
|
||||
|
||||
但你为什么最开始要创建衍生数据集?很可能是因为你想在以后再次查询它。这是**读路径(read path)**:在提供从衍生数据集中读取的用户请求时,可能会对结果执行一些更多处理,然后构建对用户的响应。
|
||||
但你为什么一开始就要创建衍生数据集?很可能是因为你想在以后再次查询它。这就是**读路径(read path)**:当服务用户请求时,你需要从衍生数据集中读取,也许还要对结果进行一些额外处理,然后构建给用户的响应。
|
||||
|
||||
总而言之,写路径和读路径涵盖了数据的整个旅程,从收集数据开始,到使用数据结束(可能是由另一个人)。写路径是预计算过程的一部分 —— 即,一旦数据进入,即刻完成,无论是否有人需要看它。读路径是这个过程中只有当有人请求时才会发生的部分。如果你熟悉函数式编程语言,则可能会注意到写路径类似于立即求值,读路径类似于惰性求值。
|
||||
|
||||
@ -342,103 +342,103 @@
|
||||
|
||||
#### 物化视图和缓存
|
||||
|
||||
全文搜索索引就是一个很好的例子:写路径更新索引,读路径在索引中搜索关键字。读写都需要做一些工作。写入需要更新文档中出现的所有术语的索引条目。读取需要搜索查询中的每个单词,并应用布尔逻辑来查找包含查询中所有单词(AND运算符)的文档,或者每个单词(OR运算符)的任何同义词。
|
||||
全文搜索索引就是一个很好的例子:写路径更新索引,读路径在索引中搜索关键字。读写都需要做一些工作。写入需要更新文档中出现的所有关键词的索引条目。读取需要搜索查询中的每个单词,并应用布尔逻辑来查找包含查询中所有单词(AND运算符)的文档,或者每个单词(OR运算符)的任何同义词。
|
||||
|
||||
如果您没有索引,搜索查询将不得不扫描所有文档(如grep),如果您有大量文档,这样做开销巨大。没有索引意味着写入路径上的工作量较少(没有要更新的索引),但是在读取路径上需要更多工作。
|
||||
如果没有索引,搜索查询将不得不扫描所有文档(如grep),如果有着大量文档,这样做的开销巨大。没有索引意味着写入路径上的工作量较少(没有要更新的索引),但是在读取路径上需要更多工作。
|
||||
|
||||
另一方面,你可以想象为所有可能的查询预先计算搜索结果。在这种情况下,读取路径上的工作量会减少:不需要布尔逻辑,只需查找查询结果并返回即可。但是,写路径会更加昂贵:可能要求的可能的搜索查询集合是无限的,因此预先计算所有可能的搜索结果将需要无限的时间和存储空间。那肯定没戏[^iii]。
|
||||
另一方面,可以想象为所有可能的查询预先计算搜索结果。在这种情况下,读路径上的工作量会减少:不需要布尔逻辑,只需查找查询结果并返回即可。但写路径会更加昂贵:可能的搜索查询集合是无限大的,因此预先计算所有可能的搜索结果将需要无限的时间和存储空间。那肯定没戏[^iii]。
|
||||
|
||||
[^iii]: 假设一个有限的语料库,那么返回非空搜索结果的搜索查询集合是有限的。然而,它是与语料库中的术语数量呈指数关系,这仍是一个坏消息。
|
||||
|
||||
另一个选择是预先计算搜索结果,只对一组固定的最常见的查询进行计算,以便它们可以快速地服务而不必去索引。不寻常的查询仍然可以从索引提供。这通常被称作常见查询的**缓存**,尽管我们也可以称之为**物化视图**,因为当新文档出现时,需要更新这些文档,这些文档应该包含在其中一个常见查询的结果中。
|
||||
另一个选择是只为一组固定的最常见的查询预先计算搜索结果,以便它们可以快速地服务而不必去走索引。不常见的查询仍然可以从走索引。这通常被称为常见查询的**缓存(cache)**,尽管我们也可以称之为**物化视图(materialized view)**,因为当新文档出现,且需要被包含在这些常见查询的搜索结果之中时,这些索引就需要更新。
|
||||
|
||||
从这个例子中我们可以看到,索引不是写路径和读路径之间唯一可能的边界。常见搜索结果的缓存也是可能的,并且在少量文档上也可以使用没有索引的类似grep的扫描。如此看来,缓存,索引和物化视图的作用很简单:它们改变了读路径和写路径之间的边界。通过预先计算结果,它们允许我们在写路径上做更多的工作,以节省读取路径上的工作量。
|
||||
从这个例子中我们可以看到,索引不是写路径和读路径之间唯一可能的边界;缓存常见搜索结果也是可行的;而在少量文档上使用没有索引的类grep扫描也是可行的。由此来看,缓存,索引和物化视图的作用很简单:它们改变了读路径与写路径之间的边界。通过预先计算结果,从而允许我们在写路径上做更多的工作,以节省读取路径上的工作量。
|
||||
|
||||
在写路径上完成的工作和读路径之间的界限,实际上是本书开始处的Twitter例子的主题,在“[描述负载](ch1.md#描述负载)”中。在该示例中,我们还看到了与普通用户相比,名人的写路径和读路径可能会有所不同。在500页之后,我们已经走完一个大循环!
|
||||
在写路径上完成的工作和读路径之间的界限,实际上是本书开始处在“[描述负载](ch1.md#描述负载)”中推特例子里谈到的主题。在该例中,我们还看到了与普通用户相比,名流的写路径和读路径可能有所不同。在500页之后,我们已经走完一个大循环!
|
||||
|
||||
#### 有状态,可离线的客户端
|
||||
|
||||
我发现写和读路径之间的边界很有趣,因为我们可以讨论移动这个边界并探讨实际意义上的这种转变意味着什么。我们来看看不同情况下的想法。
|
||||
我发现写和读路径之间的边界很有趣,因为我们可以试着改变这个边界,并探讨这种改变的实际意义。我们来看看不同上下文中的这一想法。
|
||||
|
||||
过去二十年来,Web应用程序的巨大流行使我们对应用程序开发有了一定的假设,这很容易被视为理所当然。特别是,客户机/服务器模型(客户端主要是无状态的,服务器拥有权威数的据)非常普遍,我们几乎忘记了其他任何东西都存在。但是,技术不断发展,我认为不时质疑现状非常重要。
|
||||
过去二十年来,Web应用的火热让我们对应用开发作出了一些很容易视作理所当然的假设。具体来说就是,客户端/服务器模型 —— 客户端大多是无状态的,而服务器拥有数据的权威 —— 已经普遍到我们几乎忘掉了还有其他任何模型的存在。但是技术在不断地发展,我认为不时地质疑现状非常重要。
|
||||
|
||||
传统上,网络浏览器是无状态的客户端,只有在您连接到互联网时才能做有用的事情(只有您可以离线执行的唯一的事情是在您之前在线加载的页面上下滚动)。然而,最近的“单页面”JavaScript Web应用程序已经获得了很多有状态的功能,包括客户端用户界面交互和Web浏览器中的持久本地存储。移动应用程序可以类似地在设备上存储大量状态,并且不需要往返于大多数用户交互的服务器。
|
||||
传统上,网络浏览器是无状态的客户端,只有当连接到互联网时才能做一些有用的事情(能离线执行的唯一事情基本上就是上下滚动之前在线时加载好的页面)。然而,最近的“单页面”JavaScript Web应用已经获得了很多有状态的功能,包括客户端用户界面交互,以及Web浏览器中的持久化本地存储。移动应用可以类似地在设备上存储大量状态,而且大多数用户交互都不需要与服务器往返交互。
|
||||
|
||||
这些不断变化的功能引发了对**离线优先(offline-first)**应用程序的重新兴趣,这些应用程序尽可能地在同一设备上使用本地数据库,无需连接互联网,并且在网络连接时与后台远程服务器同步可用【42】。由于移动设备通常具有缓慢且不可靠的蜂窝互联网连接,因此,如果用户的用户界面不必等待同步网络请求,并且应用程序大多离线工作,则对用户来说是一大优势(请参阅“[具有离线操作的客户端](ch5.md#具有离线操作的客户端)”第170页)。
|
||||
这些不断变化的功能重新引发了对**离线优先(offline-first)**应用的兴趣,这些应用尽可能地在同一设备上使用本地数据库,无需连接互联网,并在后台网络连接可用时与远程服务器同步【42】。由于移动设备通常具有缓慢且不可靠的蜂窝网络连接,因此,如果用户的用户界面不必等待同步网络请求,且应用主要是离线工作的,则这是一个巨大优势(参阅“[具有离线操作的客户端](ch5.md#具有离线操作的客户端)”)。
|
||||
|
||||
当我们摆脱无状态客户端与中央数据库交互的假设,并转向终端用户设备上维护的状态时,这就开启了新世界的大门。特别是,我们可以将设备上的状态视为**服务器状态的缓存**。屏幕上的像素是客户端应用程序中模型对象的物化视图;模型对象是远程数据中心的本地状态副本【27】。
|
||||
当我们摆脱无状态客户端与中央数据库交互的假设,并转向在终端用户设备上维护状态时,这就开启了新世界的大门。特别是,我们可以将设备上的状态视为**服务器状态的缓存**。屏幕上的像素是客户端应用中模型对象的物化视图;模型对象是远程数据中心的本地状态副本【27】。
|
||||
|
||||
#### 将状态变更推送给客户端
|
||||
|
||||
在典型的网页中,如果您在Web浏览器中加载页面,并且随后服务器上的数据发生变更,则浏览器在重新加载页面之前不会查找有关变更。浏览器只能在一个时间点读取数据,假设它是静态的 —— 它不会订阅来自服务器的更新。因此,设备上的状态是一个陈旧的缓存,除非你显式轮询变更,否则不会更新。 (像RSS这样的基于HTTP的订阅源订阅协议实际上只是一种基本的轮询形式。)
|
||||
在典型的网页中,如果你在Web浏览器中加载页面,并且随后服务器上的数据发生变更,则浏览器在重新加载页面之前对此一无所知。浏览器只能在一个时间点读取数据,假设它是静态的 —— 它不会订阅来自服务器的更新。因此设备上的状态是陈旧的缓存,除非你显式轮询变更否则不会更新。(像RSS这样基于HTTP的Feed订阅协议实际上只是一种基本的轮询形式)
|
||||
|
||||
最近的协议已经超越了HTTP的基本请求/响应模式:服务器发送的事件(EventSource API)和WebSockets提供了通信渠道,通过这些通信渠道,Web浏览器可以与服务器保持打开的TCP连接,服务器可以只要保持连接状态,就会主动将消息推送到浏览器。这为服务器提供了一个机会,主动通知最终用户客户端本地存储状态的任何变化,从而减少客户端状态的陈旧程度。
|
||||
最近的协议已经超越了HTTP的基本请求/响应模式:服务端发送的事件(EventSource API)和WebSockets提供了通信信道,通过这些信道,Web浏览器可以与服务器保持打开的TCP连接,只要浏览器仍然连接着,服务器就能主动向浏览器推送信息。这为服务器提供了主动通知终端用户客户端的机会,服务器能告知客户端其本地存储状态的任何变化,从而减少客户端状态的陈旧程度。
|
||||
|
||||
就我们的写路径和读路径模型而言,主动将状态变更推至到客户端设备,意味着将写路径一直延伸到终端用户。当客户端首次初始化时,它仍然需要使用读路径来获取其初始状态,但此后可能依赖于服务器发送的状态变更流。我们在流处理和消息传递方面讨论的想法并不局限于仅在数据中心运行:我们可以进一步采纳这些想法,并将它们一直延伸到终端用户设备【43】。
|
||||
用我们的写路径与读路径模型来讲,主动将状态变更推至到客户端设备,意味着将写路径一直延伸到终端用户。当客户端首次初始化时,它仍然需要使用读路径来获取其初始状态,但此后它就可能依赖于服务器发送的状态变更流了。我们在流处理和消息传递部分讨论的想法并不局限于数据中心中:我们可以进一步采纳这些想法,并将它们一直延伸到终端用户设备【43】。
|
||||
|
||||
这些设备有时会脱机,并且在此期间无法收到服务器状态变更的任何通知。但是我们已经解决了这个问题:在第449页的“[消费者偏移量](ch11.md#消费者偏移量)”中,我们讨论了基于日志的消息代理用户在失败或断开连接后可以重新连接,并确保它不会错过掉线期间任何到达的消息。同样的技术适用于单个用户,每个设备都是小事件流的小订阅者。
|
||||
这些设备有时会离线,并在此期间无法收到服务器状态变更的任何通知。但是我们已经解决了这个问题:在“[消费者偏移量](ch11.md#消费者偏移量)”中,我们讨论了基于日志的消息代理的消费者能在失败或断开连接后重连,并确保它不会错过掉线期间任何到达的消息。同样的技术适用于单个用户,每个设备都是一个小事件流的小小订阅者。
|
||||
|
||||
#### 端到端的事件流
|
||||
|
||||
最近用于开发有状态客户端和用户界面的工具,例如如Elm语言【30】和Facebook的React,Flux和Redux工具链,已经通过订阅表示用户输入和服务器响应的事件流来管理客户端的内部状态,其结构与事件源相似(请参阅第457页的“[事件源]()”)。
|
||||
最近用于开发带状态客户端与用户界面的工具,例如如Elm语言【30】和Facebook的React,Flux和Redux工具链,已经通过订阅表示用户输入和服务器响应的事件流,来管理客户端的内部状态,其结构与事件溯源相似(请参阅第457页的“[事件溯源](ch11.md#事件溯源)”)。
|
||||
|
||||
将这种编程模型扩展为允许服务器将状态改变事件推送到客户端事件管道中是非常自然的。因此,状态变化可以通过端到端的写路径流动:从触发状态改变的一个设备上的交互,通过事件日志以及通过多个衍生的数据系统和流处理器,一直到用户界面在另一台设备上观察状态的人。这些状态变化可以以相当低的延迟传播——比如说,在一秒内结束。
|
||||
将这种编程模型扩展为:允许服务器将状态变更事件,推送到客户端的事件管道中,是非常自然的。因此,状态变化可以通过**端到端(end-to-end)**的写路径流动:从一个设备上的交互触发状态变更开始,经由事件日志,并穿过几个衍生数据系统与流处理器,一直到另一台设备上的用户界面,而有人正在观察用户界面上的状态变化。这些状态变化能以相当低的延迟传播 —— 比如说,在一秒内从一端到另一端。
|
||||
|
||||
一些应用程序(如即时消息传递和在线游戏)已经具有这种“实时”体系结构(从低延迟的交互意义上说,不是“响应时间保证”在本页中的含义)。但为什么我们不用这种方式构建所有的应用程序?
|
||||
一些应用(如即时消息传递与在线游戏)已经具有这种“实时”架构(在低延迟交互的意义上,不是在“[响应时间保证](ch8.md#响应时间保证)”中的意义上)。但我们为什么不用这种方式构建所有的应用?
|
||||
|
||||
挑战在于无状态客户端和请求/响应交互的假设在我们的数据库,库,框架和协议中非常深入。许多数据存储支持读取和写入操作,请求返回一个响应,但是少得多提供订阅变更的能力 —— 即随着时间的推移返回响应流的请求(请参阅“变更流的API支持” 。
|
||||
挑战在于,关于无状态客户端和请求/响应交互的假设已经根深蒂固地植入在在我们的数据库,库,框架,以及协议之中。许多数据存储支持读取与写入操作,为请求返回一个响应,但只有极少数提供订阅变更的能力 —— 为请求返回一个随时间推移返回响应的流(请参阅“[变更流的API支持](ch11.md#变更流的API支持)” )。
|
||||
|
||||
为了将写入路径扩展到最终用户,我们需要从根本上重新思考我们构建这些系统的方式:从请求/响应交互转向发布/订阅数据流【27】。我认为更具响应性的用户界面和更好的离线支持的优势将使其值得付出努力。如果您正在设计数据系统,我希望您会记住订阅变更的选项,而不只是查询当前状态。
|
||||
为了将写路径延伸至终端用户,我们需要从根本上重新思考我们构建这些系统的方式:从请求/响应交互转向发布/订阅数据流【27】。更具响应性的用户界面与更好的离线支持,我认为这些优势值得我们付出努力。如果你正在设计数据系统,我希望您对订阅变更的选项留有印象,而不只是查询当前状态。
|
||||
|
||||
#### 读也是事件
|
||||
|
||||
我们讨论过,当流处理器将衍生数据写入存储(数据库,缓存或索引)时,以及当用户请求查询该存储时,存储将充当写路径和读路径之间的边界。该商店允许对数据进行随机访问读取查询,否则这些查询将需要扫描整个事件日志。
|
||||
我们讨论过,当流处理器将衍生数据写入存储(数据库,缓存或索引)时,以及当用户请求查询该存储时,存储将充当写路径和读路径之间的边界。该存储应当允许对数据进行随机访问的读取查询,否则这些查询将需要扫描整个事件日志。
|
||||
|
||||
在很多情况下,数据存储与流式传输系统是分开的。但请记住,流处理器还需要维护状态以执行聚合和连接(请参阅第472页的“[流连接]()”)。这种状态通常隐藏在流处理器内部,但是一些框架允许它也被外部客户端查询【45】,将流处理器本身变成一种简单的数据库。
|
||||
在很多情况下,数据存储与流处理系统是分开的。但回想一下,流处理器还是需要维护状态以执行聚合和连接的(参阅“[流连接](ch11.md#流连接)”)。这种状态通常隐藏在流处理器内部,但一些框架也允许这些状态被外部客户端查询【45】,将流处理器本身变成一种简单的数据库。
|
||||
|
||||
我想进一步考虑这个想法。正如到目前为止所讨论的那样,对商店的写入是通过事件日志进行的,而读取是瞬时网络请求,直接进入存储被查询数据的节点。这是一个合理的设计,但不是唯一可能的设计。也可以将读取请求表示为事件流,并通过流处理器发送读取事件和写入事件;处理器通过将读取结果发送到输出流来响应读取事件【46】。
|
||||
我愿意进一步思考这个想法。正如到目前为止所讨论的那样,对存储的写入是通过事件日志进行的,而读取是临时的网络请求,直接流向存储着待查数据的节点。这是一个合理的设计,但不是唯一可行的设计。也可以将读取请求表示为事件流,并同时将读事件与写事件送往流处理器;流处理器通过将读取结果发送到输出流来响应读取事件【46】。
|
||||
|
||||
当写入和读取都被表示为事件,并且被路由到同一个流操作符以便处理时,我们实际上是在读查询流和数据库之间执行流表连接。读取事件需要发送到保存数据的数据库分区(请参阅第214页的“[请求路由]()”),就像批处理和流处理器在连接时需要在同一个键上共同输入一样(请参阅“[Reduce端连接和分组]()“)。
|
||||
当写入和读取都被表示为事件,并且被路由到同一个流算子以便处理时,我们实际上是在读取查询流和数据库之间执行流表连接。读取事件需要被送往保存数据的数据库分区(参阅“[请求路由](ch6.md#请求路由)”),就像批处理和流处理器在连接时需要在同一个键上对输入分区一样(请参阅“[Reduce端连接与分组](ch10.md#Reduce端连接与分组)“)。
|
||||
|
||||
服务请求和正在执行的连接之间的这种对应关系是非常重要的【47】。一次性读取请求只是通过连接运算符传递请求,然后立即忘记它;订阅请求是与连接另一端的过去和未来事件的持续连接。
|
||||
服务请求与执行连接之间的这种相似之处是非常关键的【47】。一次性读取请求只是将请求传过连接算子,然后请求马上就被忘掉了;而一个订阅请求,则是与连接另一侧过去与未来事件的持久化连接。
|
||||
|
||||
记录读取事件的日志可能对于追踪整个系统中的因果关系和数据来源也有好处:它可以让您在做出特定决策之前重建用户看到的内容。例如,在网上商店,向客户显示的预测出货日期和库存状态可能影响他们是否选择购买物品【4】。要分析此连接,您需要记录用户查询运输和库存状态的结果。
|
||||
记录读取事件的日志可能对于追踪整个系统中的因果关系与数据来源也有好处:它可以让你重现出当用户做出特定决策之前看见了什么。例如在网商中,向客户显示的预测送达日期与库存状态,可能会影响他们是否选择购买一件商品【4】。要分析这种联系,则需要记录用户查询运输与库存状态的结果。
|
||||
|
||||
将读取事件写入持久存储器可以更好地跟踪因果关系(请参阅第493页的“[顺序事件以捕获因果关系]()”),但会产生额外的存储和I/O成本。优化这些系统以减少开销仍然是一个开放的研究问题【2】。但是,如果您已经为了操作目的而记录了读取请求,作为请求处理的副作用,将日志作为请求的来源并不是什么大的改变。
|
||||
将读取事件写入持久存储可以更好地跟踪因果关系(参阅“[排序事件以捕获因果关系](ch9.md#排序事件以捕获因果关系)”),但会产生额外的存储与I/O成本。优化这些系统以减少开销仍然是一个开放的研究问题【2】。但如果你已经出于运维目的留下了读取请求日志,将其作为请求处理的副作用,那么将这份日志作为请求事件源并不是什么特别大的变更。
|
||||
|
||||
#### 多分区数据处理
|
||||
|
||||
对于只涉及单个分区的查询,通过流发送查询和收集响应流的努力可能是过度的。然而,这个想法打开了分布式执行复杂查询的可能性,这需要合并来自多个分区的数据,利用流处理器已经提供的消息路由,分区和加入的基础设施。
|
||||
对于只涉及单个分区的查询,通过流来发送查询与收集响应可能是杀鸡用牛刀了。然而,这个想法开启了分布式执行复杂查询的可能性,这需要合并来自多个分区的数据,利用流处理器已经提供的消息路由,分区和连接的基础设施。
|
||||
|
||||
Storm的分布式RPC功能支持这种使用模式(请参阅第468页的“[消息传递和RPC]()”)。例如,它已被用于计算在Twitter上看到过网址的人数 —— 即,每个人都推送了该网址的跟随者集合【48】。由于Twitter用户组是分区的,因此这种计算需要合并来自多个分区的结果
|
||||
Storm的分布式RPC功能支持这种使用模式(参阅“[消息传递和RPC](ch11.md#消息传递和RPC)”)。例如,它已经被用来计算浏览过某个推特URL的人数 —— 即,转推该URL的粉丝集合的并集【48】。由于推特的用户是分区的,因此这种计算需要合并来自多个分区的结果。
|
||||
|
||||
这种模式的另一个例子是欺诈预防:为了评估特定购买事件是否具有欺诈风险,您可以检查用户的IP地址,电子邮件地址,帐单地址,送货地址等的信誉分数。这些信誉数据库中的每一个都是自身分区的,因此为特定购买事件收集分数需要一系列具有不同分区数据集的联合【49】。
|
||||
这种模式的另一个例子是欺诈预防:为了评估特定购买事件是否具有欺诈风险,你可以检查该用户IP地址,电子邮件地址,帐单地址,送货地址的信用分。这些信用数据库中的每一个自己都是一个分区,因此为特定购买事件采集分数需要连接一系列不同的分区数据集【49】。
|
||||
|
||||
MPP数据库的内部查询执行图具有相似的特征(请参阅第417页的“[比较Hadoop与分布式数据库]()”)。如果您需要执行这种多分区连接,使用提供此功能的数据库可能比使用流处理器实现它更简单。但是,将查询视为流提供了一个选项,可以实现大规模应用程序,这些应用程序可以在传统的现成解决方案的限制下运行。
|
||||
MPP数据库的内部查询执行图有着类似的特征(参阅“[比较Hadoop与分布式数据库](ch10.md#比较Hadoop与分布式数据库)”)。如果需要执行这种多分区连接,则直接使用提供此功能的数据库,可能要比使用流处理器实现它要更简单。然而将查询视为流提供了一种选项,可以用于实现超出传统现成解决方案的大规模应用。
|
||||
|
||||
|
||||
|
||||
## 将事情做正确
|
||||
|
||||
对于只读取数据的无状态服务,出现问题时不会造成什么大问题:您可以修复该错误并重新启动服务,并且一切都恢复正常。像数据库这样的有状态的系统并不是那么简单:它们被设计成永远记住事物(或多或少),所以如果出现问题,效果也可能永远持续下去,这意味着它们需要更仔细的思考【50】。
|
||||
对于只读取数据的无状态服务,出问题也没什么大不了的:你可以修复该错误并重启服务,而一切都恢复正常。像数据库这样的有状态系统就没那么简单了:它们被设计为永远记住事物(或多或少),所以如果出现问题,这种(错误的)效果也将潜在地永远持续下去,这意味着它们需要更仔细的思考【50】。
|
||||
|
||||
我们希望构建可靠和正确的应用程序(即,即使面对各种故障,其语义也能很好地定义和理解的程序)。大约四十年来,原子性,隔离性和耐久性(第7章)的交易特性一直是构建正确应用的首选工具。但是,这些基础比看起来更弱:例如见证弱隔离级别的混合(请参见“[弱隔离级别]()”(第233页))。
|
||||
我们希望构建可靠且**正确**的应用(即使面对各种故障,程序的语义也能被很好地定义与理解)。约四十年来,原子性,隔离性和持久性([第7章](ch7.md))等事务特性一直是构建正确应用的首选工具。然而这些地基没有看上去那么牢固:例如弱隔离级别带来的困惑可以佐证(请参见“[弱隔离级别](ch7.md#弱隔离级别)”)。
|
||||
|
||||
在某些领域,事务被完全抛弃,并被提供更好性能和可伸缩性的模型取代,但是更复杂的语义(例如,请参阅第167页上的“[无主复制]()”)。一致性经常被讨论,但定义不明确(参见第224页的“[一致性]()”和[第9章](ch9.md))。有些人主张我们应该“为了更好的可用性而拥抱弱一致性”,而对实际上的实际意义缺乏清晰的认识。
|
||||
事务在某些领域被完全抛弃,并被提供更好性能与可扩展性的模型取代,但更复杂的语义(例如,参阅“[无领导者复制](ch5.md#无领导者复制)”)。**一致性(Consistency)**经常被谈起,但其定义并不明确(“[一致性](ch5.md#一致性)”和[第9章](ch9.md))。有些人断言我们应当为了高可用而“拥抱弱一致性”,但却对这些概念实际上意味着什么缺乏清晰的认识。
|
||||
|
||||
对于如此重要的话题,我们的理解和我们的工程方法是惊人的片状。例如,确定在特定事务隔离级别或复制配置下运行特定应用程序是否安全是非常困难的【51,52】。通常简单的解决方案似乎在并发性低的情况下正常工作,并且没有错误,但是在要求更高的情况下会出现许多细微的错误。
|
||||
对于如此重要的话题,我们的理解,以及我们的工程方法却是惊人地薄弱。例如,确定在特定事务隔离等级或复制配置下运行特定应用是否安全是非常困难的【51,52】。通常简单的解决方案似乎在低并发性的情况下工作正常,并且没有错误,但在要求更高的情况下却会出现许多微妙的错误。
|
||||
|
||||
例如,凯尔金斯伯里(Kyle Kingsbury)的杰普森(Jepsen)实验【53】强调了一些产品声称的安全保证与存在网络问题和崩溃时的实际行为之间的明显差异。即使像数据库这样的基础设施产品没有问题,应用程序代码仍然需要正确使用它们提供的功能,如果配置很难理解,这是很容易出错的(这是弱隔离级别,法定配置, 等等)。
|
||||
例如,凯尔金斯伯里(Kyle Kingsbury)的杰普森(Jepsen)实验【53】标出了一些产品声称的安全保证与其在网络问题与崩溃时的实际行为之间的明显差异。即使像数据库这样的基础设施产品没有问题,应用代码仍然需要正确使用它们提供的功能才行,如果配置很难理解,这是很容易出错的(在这种情况下指的是弱隔离级别,法定人数配置等)。
|
||||
|
||||
如果您的应用程序可以容忍偶尔以不可预测的方式破坏或丢失数据,那么生活就会简单得多,您可能只需要阿弥陀佛就能逃脱,希望获得最好的效果。另一方面,如果您需要更强的正确性保证,那么可序列化和原子提交就是建立的方法,但是它们是有代价的:它们通常只在单个数据中心(排除地理分布式体系结构)中工作,您可以实现的规模和容错性能。
|
||||
如果你的应用可以容忍偶尔的崩溃,以及以不可预料的方式损坏或丢失数据,那生活就要简单得多,而你可能只要双手合十念阿弥陀佛,期望佛祖能保佑最好的结果。另一方面,如果你需要更强的正确性保证,那么可序列化与原子提交就是久经考验的方法,但它们是有代价的:它们通常只在单个数据中心中工作(排除地理散布式架构),并限制了系统能够实现的规模与容错特性。
|
||||
|
||||
虽然传统的交易方式并没有消失,但我也相信,在使应用程序正确和灵活地处理错误方面,并不是最后一句话。在本节中,我将提出一些关于数据流架构中正确性的思考方法。
|
||||
虽然传统的事务方法并没有走远,但我也相信在使应用正确而灵活地处理错误方面上,事务并不是最后的遗言。在本节中,我将提出一些在数据流架构中考量正确性的方式。
|
||||
|
||||
### 数据库端到端的争论
|
||||
|
||||
仅仅因为应用程序使用提供比较强的安全属性的数据系统(例如可序列化的事务),并不意味着应用程序可以保证没有数据丢失或损坏。例如,如果一个应用程序有一个错误导致它写入不正确的数据,或者从数据库中删除数据,那么可序列化的事务不会为你节省。
|
||||
仅仅因为应用使用提供比较强的安全属性的数据系统(例如可序列化的事务),并不意味着应用可以保证没有数据丢失或损坏。例如,如果一个应用有一个错误导致它写入不正确的数据,或者从数据库中删除数据,那么可序列化的事务不会有什么帮助。
|
||||
|
||||
这个例子可能看起来很无聊,但值得认真对待:应用程序错误发生,人们犯错误。我在第459页的“[状态,流和不可变性]()”中使用了这个例子来支持不可变和只能追加的数据,因为如果删除错误代码的能力来破坏好的数据,更容易从这些错误中恢复数据。
|
||||
这个例子可能看起来很无聊,但值得认真对待:应用会出Bug,而人也会犯错误。我在“[状态,流与不可变性](ch11.md#状态,流与不可变性)”中使用了这个例子来支持不可变和只能追加的数据,因为如果删除错误代码的能力来破坏好的数据,更容易从这些错误中恢复数据。
|
||||
|
||||
虽然不变性是有用的,但它本身并非万能的。让我们看看可能发生的数据损坏的一个更为简单的例子。
|
||||
|
||||
@ -865,9 +865,11 @@ COMMIT;
|
||||
|
||||
我们究竟能做到这一点是一个悬而未决的问题。首先,我们不应该永久保留数据,但一旦不再需要就立即清除数据【111,112】。清除数据与不变性的想法背道而驰(请参阅第463页的“不变性的限制”),但可以解决该问题。我所看到的一种很有前途的方法是通过加密协议来实施访问控制,而不仅仅是通过策略【113,114】。总的来说,文化和态度的变化是必要的。
|
||||
|
||||
|
||||
|
||||
## 本章小结
|
||||
|
||||
在本章中,我们讨论了设计数据系统的新方法,并且包括了我对未来的个人意见和猜测。我们从观察开始,即没有一种工具可以有效地服务于所有可能的用例,因此应用程序必须编写几个不同的软件才能实现其目标。我们讨论了如何使用批处理和事件流来解决这个数据集成问题,以便让数据变化在不同系统之间流动。
|
||||
在本章中,我们讨论了设计数据系统的新方法,并包括了我对未来的个人意见和猜测。我们从观察开始,即没有一种工具可以有效地服务于所有可能的用例,因此应用程序必须编写几个不同的软件才能实现其目标。我们讨论了如何使用批处理和事件流来解决这个数据集成问题,以便让数据变化在不同系统之间流动。
|
||||
|
||||
在这种方法中,某些系统被指定为记录系统,而其他数据则通过转换从中得出。通过这种方式,我们可以维护索引,物化视图,机器学习模型,统计摘要等等。通过使这些衍生和转换异步和松散耦合,防止一个区域中的问题扩散到系统的不相关部分,从而增加整个系统的稳健性和容错性。
|
||||
|
||||
@ -875,10 +877,10 @@ COMMIT;
|
||||
|
||||
这些过程与内部数据库已经完成的过程非常相似,因此我们重新构思了数据流应用程序的概念,将数据库的组件分开,并通过组合这些松散耦合的组件来构建应用程序。
|
||||
|
||||
衍生状态可以通过观察底层数据的变化来更新。此外,下游消费者可以进一步观察衍生状态本身。我们甚至可以将此数据流一直传送到显示数据的最终用户设备,从而构建可动态更新以反映数据变更并继续脱机工作的用户界面。
|
||||
衍生状态可以通过观察底层数据的变化来更新。此外,下游消费者可以进一步观察衍生状态本身。我们甚至可以将此数据流一直传送到显示数据的众泰用户设备,从而构建可动态更新以反映数据变更并继续离线工作的用户界面。
|
||||
接下来,我们讨论了如何确保所有这些处理在出现故障时保持正确。我们看到强大的完整性保证可以通过异步事件处理可视化地实现,通过使用端到端操作标识符使操作幂等并异步检查约束。客户可以等到检查通过,或者不用等待就行,但是可能会有违反约束的道歉风险。这种方法比使用分布式事务的传统方法更具可扩展性和可靠性,并且适合于实践中有多少业务流程工作。
|
||||
|
||||
通过构建围绕数据流的应用程序并同步检查约束条件,我们可以避免大多数协调,并创建维护完整性但仍能很好地运行的系统,即使在地理分布的情况下和出现故障时也是如此。然后,我们谈了一些关于使用审计来验证数据的完整性并检测腐败的问题。
|
||||
通过构建围绕数据流的应用程序并同步检查约束条件,我们可以避免大多数协调,并创建维护完整性但仍能很好地运行的系统,即使在地理散布的情况下和出现故障时也是如此。然后,我们谈了一些关于使用审计来验证数据的完整性并检测腐败的问题。
|
||||
|
||||
最后,我们退后一步,审查了构建数据密集型应用程序的一些道德问题。我们看到,虽然数据可以用来做好事,但它也可能造成重大损害:作出严重影响人们生活并难以申诉的决定的正当理由,导致歧视和剥削,规范监督以及揭露私密信息。我们也冒着数据泄露的风险,并且我们可能会发现善意使用数据会产生意想不到的后果。
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user