分拆数据库

​ 在最抽象的层面上,数据库,Hadoop和操作系统都发挥相同的功能:它们存储一些数据,并允许你处理和查询这些数据【16】。数据库将数据存储为特定数据模型的记录(表中的行、文档、图中的顶点等),而操作系统的文件系统则将数据存储在文件中 —— 但其核心都是“信息管理”系统【17】。正如我们在第10章中看到的,Hadoop生态系统有点像Unix的分布式版本。

​ 当然,有很多实际的差异。例如,许多文件系统都不能很好地处理包含1000万个小文件的目录,而包含1000万个小记录的数据库完全是寻常而不起眼的。无论如何,操作系统和数据库之间的相似之处和差异值得探讨。

​ Unix和关系数据库以非常不同的哲学来处理信息管理问题。 Unix认为它的目的是为程序员提供一种相当低层次的硬件的逻辑抽象,而关系数据库则希望为应用程序员提供一种高层次的抽象,以隐藏磁盘上数据结构的复杂性,并发性,崩溃恢复以及等等。 Unix发展出的管道和文件只是字节序列,而数据库则发展出了SQL和事务。

​ 哪种方法更好?当然这取决于你想要的是什么。 Unix是“简单的”,因为它是硬件资源相当薄的包装;关系数据库是“更简单”的,因为一个简短的声明性查询可以利用很多强大的基础设施(查询优化,索引,连接方法,并发控制,复制等),而不需要查询的作者理解其实现细节。

​ 这些哲学之间的矛盾已经持续了几十年(Unix和关系模型都出现在70年代初),仍然没有解决。例如,我将NoSQL运动解释为,希望将类Unix的低级别抽象方法应用于分布式OLTP数据存储的领域。

​ 在这一部分我将试图调和这两个哲学,希望我们能各取其美。

组合使用数据存储技术

在本书的过程中,我们讨论了数据库提供的各种功能及其工作原理,其中包括:

第10章第11章中,出现了类似的主题。我们讨论了如何构建全文搜索索引(请参阅第357页上的“批处理工作流的输出”),了解有关实例化视图维护(请参阅“维护实例化视图”一节第437页)以及有关将变更从数据库复制到衍生数据系统(请参阅第454页的“变更数据捕获”)。

数据库中内置的功能与人们用批处理和流处理器构建的衍生数据系统似乎有相似之处。

创建索引

​ 想想当你运行CREATE INDEX在关系数据库中创建一个新的索引时会发生什么。数据库必须扫描表的一致性快照,挑选出所有被索引的字段值,对它们进行排序,然后写出索引。然后它必须处理自一致快照以来所做的写入操作(假设表在创建索引时未被锁定,所以写操作可能会继续)。一旦完成,只要事务写入表中,数据库就必须继续保持索引最新。

​ 此过程非常类似于设置新的从库副本(参阅“设置新的追随者”),也非常类似于流处理系统中的引导(bootstrap) 变更数据捕获(请参阅第455页的“初始快照”)。

​ 无论何时运行CREATE INDEX,数据库都会重新处理现有数据集(如第494页的“重新处理应用程序数据的演变数据”中所述),并将该索引作为新视图导出到现有数据上。现有数据可能是状态的快照,而不是所有发生变化的日志,但两者密切相关(请参阅“状态,数据流和不变性”第459页)。

一切的元数据库

​ 有鉴于此,我认为整个组织的数据流开始像一个巨大的数据库【7】。每当批处理,流或ETL过程将数据从一个地方传输到另一个地方并组装时,它表现地就像数据库子系统一样,使索引或物化视图保持最新。

​ 从这种角度来看,批处理和流处理器就像触发器,存储过程和物化视图维护例程的精细实现。它们维护的衍生数据系统就像不同的索引类型。例如,关系数据库可能支持B树索引,散列索引,空间索引(请参阅第79页的“多列索引”)以及其他类型的索引。在新兴的衍生数据系统架构中,不是将这些设施作为单个集成数据库产品的功能实现,而是由各种不同的软件提供,运行在不同的机器上,由不同的团队管理。

​ 这些发展在未来将会把我们带到哪里?如果我们从没有适合所有访问模式的单一数据模型或存储格式的前提出发,我推测有两种途径可以将不同的存储和处理工具组合成一个有凝聚力的系统:

联合数据库:统一读取

​ 可以为各种各样的底层存储引擎和处理方法提供一个统一的查询接口 —— 一种称为联合数据库(federated database)多态存储(polystore) 的方法【18,19】。例如,PostgreSQL的外部数据包装器功能符合这种模式【20】。需要专用数据模型或查询接口的应用程序仍然可以直接访问底层存储引擎,而想要组合来自不同位置的数据的用户可以通过联合接口轻松完成操作。

​ 联合查询接口遵循着单一集成系统与关系型模型的传统,带有高级查询语言和优雅的语义,但实现起来非常复杂。

分拆数据库:统一写入

​ 虽然联合能解决跨多个不同系统的只读查询问题,但它并没有很好的解决跨系统同步写入的问题。我们说过,在单个数据库中,创建一致的索引是一项内置功能。当我们构建多个存储系统时,我们同样需要确保所有数据变更都会在所有正确的位置结束,即使在出现故障时也是如此。将存储系统可靠地插接在一起(例如,通过变更数据捕获和事件日志)更容易,就像将数据库的索引维护功能以可以跨不同技术同步写入的方式分开【7,21】。

​ 分拆方法遵循Unix传统的小型工具,它可以很好地完成一件事【22】,通过统一的低级API(管道)进行通信,并且可以使用更高级的语言进行组合(shell)【16】 。

开展分拆工作

​ 联合和分拆是一个硬币的两面:用不同的组件构成可靠,可扩展和可维护的系统。联合只读查询需要将一个数据模型映射到另一个数据模型,这需要一些思考,但最终还是一个可解决的问题。我认为同步写入到几个存储系统是更困难的工程问题,所以我将重点关注它。

​ 传统的同步写入方法需要跨异构存储系统的分布式事务【18】,我认为这是错误的解决方案(请参阅“导出的数据与分布式事务”第495页)。单个存储或流处理系统内的事务是可行的,但是当数据跨越不同技术之间的边界时,我认为具有幂等写入的异步事件日志是一种更加健壮和实用的方法。

​ 例如,分布式事务在某些流处理组件内部使用,以匹配恰好一次(exactly-once) 语义(请参阅第477页的“重新访问原子提交”),这可以很好地工作。然而,当事务需要涉及由不同人群编写的系统时(例如,当数据从流处理组件写入分布式键值存储或搜索索引时),缺乏标准化的事务协议会使集成更难。有幂等消费者的事件的有序事件日志(参见第478页的“幂等性”)是一种更简单的抽象,因此在异构系统中实现更加可行【7】。

基于日志的集成的一大优势是各个组件之间的松散耦合(loose coupling),这体现在两个方面:

  1. 在系统级别,异步事件流使整个系统对各个组件的中断或性能下降更加稳健。如果使用者运行缓慢或失败,那么事件日志可以缓冲消息(请参阅“磁盘空间使用情况”第369页),以便生产者和任何其他使用者可以继续不受影响地运行。有问题的消费者可以在固定时赶上,因此不会错过任何数据,并且包含故障。相比之下,分布式事务的同步交互往往会将本地故障升级为大规模故障(请参见第363页的“分布式事务的限制”)。
  2. 在人力方面,分拆数据系统允许不同的团队独立开发,改进和维护不同的软件组件和服务。专业化使得每个团队都可以专注于做好一件事,并与其他团队的系统以明确的接口交互。事件日志提供了一个足够强大的接口,以捕获相当强的一致性属性(由于持久性和事件的顺序),但也足够普适于几乎任何类型的数据。

分拆系统vs集成系统

​ 如果分拆确实成为未来的方式,它也不会取代目前形式的数据库 —— 它们仍然会像以往一样被需要。为了维护流处理组件中的状态,数据库仍然是需要的,并且为批处理和流处理器的输出提供查询服务(参阅“批处理工作流的输出”与“流处理”)。专用查询引擎对于特定的工作负载仍然非常重要:例如,MPP数据仓库中的查询引擎针对探索性分析查询进行了优化,并且能够很好地处理这种类型的工作负载(参阅“对比Hadoop与分布式数据库” 。

​ 运行几种不同基础设施的复杂性可能是一个问题:每种软件都有一个学习曲线,配置问题和操作怪癖,因此部署尽可能少的移动部件是很有必要的。比起使用应用代码拼接多个工具而成的系统,单一集成软件产品也可以在其设计应对的工作负载类型上实现更好,更可预测的性能【23】。正如在前言中所说的那样,为了不需要的规模而构建系统是白费精力,而且可能会将你锁死在一个不灵活的设计中。实际上,这是一种过早优化的形式。

​ 分拆的目标不是要针对个别数据库与特定工作负载的性能进行竞争;我们的目标是允许您结合多个不同的数据库,以便在比单个软件可能实现的更广泛的工作负载范围内实现更好的性能。这是关于广度,而不是深度 —— 与我们在“对比Hadoop与分布式数据库”中讨论的存储和处理模型的多样性一样。

​ 因此,如果有一项技术可以满足您的所有需求,那么最好使用该产品,而不是试图用低级组件重新实现它。只有当没有单一软件满足您的所有需求时,才会出现拆分和联合的优势。

少了什么?

​ 用于组成数据系统的工具正在变得越来越好,但我认为还缺少一个主要的东西:我们还没有与Unix shell类似的分拆数据库(即,一种声明式的,简单的,用于组装存储和处理系统的高级语言)。

​ 例如,如果我们可以简单地声明mysql |elasticsearch,类似于Unix管道【22】,成为CREATE INDEX的分拆等价物:它将MySQL数据库中的所有文档并将其索引到Elasticsearch集群中。然后它会不断捕获对数据库所做的所有变更,并自动将它们应用于搜索索引,而无需编写自定义应用代码。这种集成应当支持几乎任何类型的存储或索引系统。

​ 同样,能够更容易地预先计算和更新缓存将是一件好事。回想一下,物化视图本质上是一个预先计算的缓存,所以您可以通过为复杂查询声明指定物化视图来创建缓存,包括图上的递归查询(参阅“图数据模型”)和应用逻辑。在这方面有一些有趣的早期研究,如差分数据流(differential dataflow)【24,25】,我希望这些想法能够在生产系统中找到自己的方法。

围绕数据流设计应用

​ 使用应用代码组合专用存储与处理系统来分拆数据库的方法,也被称为“数据库由内而外”方法【26】,在我在2014年的一次会议演讲标题之后【27】。然而称它为“新架构”过于宏大。我将其看作是一种设计模式,一个讨论的起点,我们只是简单地给它起一个名字,以便我们能更好地讨论它。

​ 这些想法不是我的;它们是很多人的思想的融合,这些思想非常值得我们学习。尤其是,以Oz 【28】和Juttle 【29】为代表的数据流语言,以Elm【30,31】为代表的函数式响应式编程(functional reactive programming, FRP),以Bloom【32】为代表的逻辑编程语言。在这一语境中的术语分拆(unbundling) 是由Jay Kreps 提出的【7】。

​ 即使是电子表格也在数据流编程能力上甩开大多数主流编程语言几条街【33】。在电子表格中,可以将公式放入一个单元格中(例如,另一列中的单元格求和值),并且只要公式的任何输入发生变更,公式的结果都会自动重新计算。这正是我们在数据系统层次所需要的:当数据库中的记录发生变更时,我们希望自动更新该记录的任何索引,并且自动刷新依赖于记录的任何缓存视图或聚合。您不必担心这种刷新如何发生的技术细节,但能够简单地相信它可以正常工作。

​ 因此,我认为绝大多数数据系统仍然可以从VisiCalc在1979年已经具备的功能中学习【34】。与电子表格的不同之处在于,今天的数据系统需要具有容错性,可扩展性以及持久存储数据。它们还需要能够整合不同人群编写的不同技术,并重用现有的库和服务:期望使用某种特定语言,框架或工具开发所有软件是不切实际的。

​ 在本节中,我将详细介绍这些想法,并探讨一些围绕分拆数据库和数据流的想法构建应用的方法。

应用代码作为衍生函数

​ 当一个数据集衍生自另一个数据集时,它会经历某种转换函数。例如:

  • 次级索引是由一种直白的转换函数生成的衍生数据集:对于基础表中的每行或每个文档,它挑选被索引的列或字段中的值,并按这些值排序(假设使用B树或SSTable索引,按键排序,如第3章所述)。
  • 全文搜索索引是通过应用各种自然语言处理函数而创建的,诸如语言检测,分词,词干或词汇化,拼写纠正和同义词识别)创建全文搜索索引,然后构建用于高效查找的数据结构(例如倒排索引)。
  • 在机器学习系统中,我们可以将模型视作从训练数据通过应用各种特征提取,统计分析函数衍生的数据,当模型应用于新的输入数据时,模型的输出是从输入和模型(因此间接地从训练数据)中衍生的。
  • 缓存通常包含将以用户界面(UI)显示的形式的数据聚合。因此填充缓存需要知道UI中引用的字段;UI中的变更可能需要更新缓存填充方式的定义,并重建缓存。

用于次级索引的衍生函数是如此常用的需求,以致于它作为核心功能被内建至许多数据库中,你可以简单地通过CREATE INDEX来调用它。对于全文索引,常见语言的基本语言特征可能内置到数据库中,但更复杂的特征通常需要领域特定的调整。在机器学习中,特征工程是众所周知的特定于应用的特征,通常需要包含很多关于用户交互与应用部署的详细知识【35】。 当创建衍生数据集的函数不是像创建二级索引那样的标准搬砖函数时,需要自定义代码来处理特定于应用的东西。而这个自定义代码是让许多数据库挣扎的地方,虽然关系数据库通常支持触发器,存储过程和用户定义的函数,它们可以用来在数据库中执行应用代码,但它们有点像数据库设计里的事后反思。(参阅“传输事件流”)。

应用代码和状态的分离

​ 理论上,数据库可以是任意应用代码的部署环境,就如同操作系统一样。然而实践中它们对这一目标适配的很差。它们不满足现代应用开发的要求,例如依赖性和软件包管理,版本控制,滚动升级,可演化性,监控,指标,对网络服务的调用以及与外部系统的集成。

​ 另一方面,Mesos,YARN,Docker,Kubernetes等部署和集群管理工具专为运行应用代码而设计。通过专注于做好一件事情,他们能够做得比将数据库作为其众多功能之一执行用户定义的功能要好得多。我认为让系统的某些部分专门用于持久数据存储以及专门运行应用程序代码的其他部分是有意义的。这两者可以在保持独立的同时互动。

​ 现在大多数Web应用程序都是作为无状态服务部署的,其中任何用户请求都可以路由到任何应用程序服务器,并且服务器在发送响应后会忘记所有请求。这种部署方式很方便,因为可以随意添加或删除服务器,但状态必须到某个地方:通常是数据库。趋势是将无状态应用程序逻辑与状态管理(数据库)分开:不将应用程序逻辑放入数据库中,也不将持久状态置于应用程序中【36】。正如职能规划界人士喜欢开玩笑说的那样,“我们相信教会(Church)国家(state) 的分离”【37】 [^i]

[^i]: 解释笑话很少会让人感觉更好,但我不想让任何人感到被遗漏。 在这里,Church指代的是数学家的阿隆佐·邱奇,他创立了lambda演算,这是计算的早期形式,是大多数函数式编程语言的基础。 lambda演算不具有可变状态(即没有变量可以被覆盖),所以可以说可变状态与Church的工作是分离的。

​ 在这个典型的Web应用模型中,数据库充当一种可以通过网络同步访问的可变共享变量。应用程序可以读取和更新变量,而数据库负责维持它的持久性,提供一些诸如并发控制和容错的功能。

​ 但是,在大多数编程语言中,你无法订阅可变变量中的变更 —— 你只能定期读取它。与电子表格不同,如果变量的值发生变化,变量的读者不会收到通知。 (你可以在自己的代码中实现这样的通知 —— 这被称为观察者模式 —— 但大多数语言没有将这种模式作为内置功能。)

​ 数据库继承了这种可变数据的被动方法:如果你想知道数据库的内容是否发生了变化,通常你唯一的选择就是轮询(即定期重复你的查询)。 订阅变更只是刚刚开始出现的功能(参阅“变更流的API支持”)。

数据流:应用代码与状态变化的交互

​ 从数据流的角度思考应用,意味着重新协调应用代码和状态管理之间的关系。将数据库视作被应用操纵的被动变量,取而代之的是更多地考虑状态,状态变更和处理它们的代码之间的相互作用与协同关系。应用代码通过在另一个地方触发状态变更来响应状态变更。

​ 我们在“流与数据库”中看到了这一思路,我们讨论了将数据库的变更日志视为一种我们可以订阅的事件流。诸如Actor的消息传递系统(参阅“消息传递数据流”)也具有响应事件的概念。早在20世纪80年代,元组空间(tuple space) 模型就已经探索了表达分布式计算的方式:观察状态变更并作出反应【38,39】。

​ 如前所述,当触发器由于数据变更而被触发时,或次级索引更新以反映索引表中的变更时,数据库内部也发生着类似的情况。分拆数据库意味着将这个想法应用于在主数据库之外,用于创建衍生数据集:缓存,全文搜索索引,机器学习或分析系统。我们可以为此使用流处理和消息传递系统。

​ 需要记住的重要一点是,维护衍生数据不同于执行异步任务。传统消息系统通常是为执行异步任务设计的(参阅“与传统消息传递相比的日志”):

  • 在维护衍生数据时,状态变更的顺序通常很重要(如果多个视图是从事件日志衍生的,则需要按照相同的顺序处理事件,以便它们之间保持一致)。如“确认与重传”中所述,许多消息代理在重传未确认消息时没有此属性,双写也被排除在外(参阅“保持系统同步”)。
  • 容错是衍生数据的关键:仅仅丢失单个消息就会导致衍生数据集永远与其数据源失去同步。消息传递和衍生状态更新都必须可靠。例如,许多Actor系统默认在内存中维护Actor的状态和消息,所以如果运行Actor的机器崩溃,状态和消息就会丢失。

稳定的消息排序和容错消息处理是相当严格的要求,但与分布式事务相比,它们开销更小,运行更稳定。现代流处理组件可以提供这些排序和可靠性保证,并允许应用代码以流算子的形式运行。

​ 这些应用代码可以执行任意处理,包括数据库内置衍生函数通常不提供的功能。就像通过管道链接的Unix工具一样,流算子可以围绕着数据流构建大型系统。每个算子接受状态变更的流作为输入,并产生其他状态变化的流作为输出。

流处理器和服务

​ 当今流行的应用开发风格涉及将功能分解为一组通过同步网络请求(如REST API)进行通信的服务(service)(参阅“通过服务实现数据流:REST和RPC”)。这种面向服务的架构优于单一庞大应用的优势主要在于:通过松散耦合来提供组织上的可扩展性:不同的团队可以专职于不同的服务上,从而减少团队之间的协调工作(因为服务可以独立部署和更新)。

​ 在数据流中组装流算子与微服务方法有很多相似之处【40】。但底层通信机制是有很大区别:数据流采用单向异步消息流,而不是同步的请求/响应式交互。

​ 除了在“消息传递数据流”中列出的优点(如更好的容错性),数据流系统还能实现更好的性能。例如,假设客户正在购买以一种货币定价,但以另一种货币支付的商品。为了执行货币换算,你需要知道当前的汇率。这个操作可以通过两种方式实现【40,41】:

  1. 在微服务方法中,处理购买的代码可能会查询汇率服务或数据库,以获取特定货币的当前汇率。
  2. 在数据流方法中,处理订单的代码会提前订阅汇率变更流,并在汇率发生变动时将当前汇率存储在本地数据库中。处理订单时只需查询本地数据库即可。

​ 第二种方法能将对另一服务的同步网络请求替换为对本地数据库的查询(可能在同一台机器甚至同一个进程中)[^ii]。数据流方法不仅更快,而且当其他服务失效时也更稳健。最快且最可靠的网络请求就是压根没有网络请求!我们现在不再使用RPC,而是在购买事件和汇率更新事件之间建立流联接(参阅“流表联接”)。

[^ii]: 在微服务方法中,你也可以通过在处理购买的服务中本地缓存汇率来避免同步网络请求。 但是为了保证缓存的新鲜度,你需要定期轮询汇率以获取其更新,或订阅变更流 —— 这恰好是数据流方法中发生的事情。

​ 连接是时间相关的:如果购买事件在稍后的时间点被重新处理,汇率可能已经改变。如果要重建原始输出,则需要获取原始购买时的历史汇率。无论是查询服务还是订阅汇率更新流,你都需要处理这种时间相关性(参阅“连接的时间相关性”)。

​ 订阅变更流,而不是在需要时查询当前状态,使我们更接近类似电子表格的计算模型:当某些数据发生变更时,依赖于此的所有衍生数据都可以快速更新。还有很多未解决的问题,例如关于时间相关连接等问题,但我认为围绕数据流构建应用的想法是一个非常有希望的方向。

观察衍生数据状态

​ 在抽象层面,上一节讨论的数据流系统提供了创建衍生数据集(例如搜索索引,物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为写路径(write path):只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个衍生数据集都会被更新,以适配写入的数据。图12-1显示了一个更新搜索索引的例子。

分拆数据库 - 图1

图12-1 在搜索索引中,写(文档更新)遇上读(查询)

​ 但你为什么一开始就要创建衍生数据集?很可能是因为你想在以后再次查询它。这就是读路径(read path):当服务用户请求时,你需要从衍生数据集中读取,也许还要对结果进行一些额外处理,然后构建给用户的响应。

​ 总而言之,写路径和读路径涵盖了数据的整个旅程,从收集数据开始,到使用数据结束(可能是由另一个人)。写路径是预计算过程的一部分 —— 即,一旦数据进入,即刻完成,无论是否有人需要看它。读路径是这个过程中只有当有人请求时才会发生的部分。如果你熟悉函数式编程语言,则可能会注意到写路径类似于立即求值,读路径类似于惰性求值。

​ 如图12-1所示,衍生数据集是写路径和读路径相遇的地方。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。

物化视图和缓存

​ 全文搜索索引就是一个很好的例子:写路径更新索引,读路径在索引中搜索关键字。读写都需要做一些工作。写入需要更新文档中出现的所有关键词的索引条目。读取需要搜索查询中的每个单词,并应用布尔逻辑来查找包含查询中所有单词(AND运算符)的文档,或者每个单词(OR运算符)的任何同义词。

​ 如果没有索引,搜索查询将不得不扫描所有文档(如grep),如果有着大量文档,这样做的开销巨大。没有索引意味着写入路径上的工作量较少(没有要更新的索引),但是在读取路径上需要更多工作。

​ 另一方面,可以想象为所有可能的查询预先计算搜索结果。在这种情况下,读路径上的工作量会减少:不需要布尔逻辑,只需查找查询结果并返回即可。但写路径会更加昂贵:可能的搜索查询集合是无限大的,因此预先计算所有可能的搜索结果将需要无限的时间和存储空间。那肯定没戏^iii

​ 另一个选择是只为一组固定的最常见的查询预先计算搜索结果,以便它们可以快速地服务而不必去走索引。不常见的查询仍然可以从走索引。这通常被称为常见查询的缓存(cache),尽管我们也可以称之为物化视图(materialized view),因为当新文档出现,且需要被包含在这些常见查询的搜索结果之中时,这些索引就需要更新。

​ 从这个例子中我们可以看到,索引不是写路径和读路径之间唯一可能的边界;缓存常见搜索结果也是可行的;而在少量文档上使用没有索引的类grep扫描也是可行的。由此来看,缓存,索引和物化视图的作用很简单:它们改变了读路径与写路径之间的边界。通过预先计算结果,从而允许我们在写路径上做更多的工作,以节省读取路径上的工作量。

​ 在写路径上完成的工作和读路径之间的界限,实际上是本书开始处在“描述负载”中推特例子里谈到的主题。在该例中,我们还看到了与普通用户相比,名流的写路径和读路径可能有所不同。在500页之后,我们已经走完一个大循环!

有状态,可离线的客户端

​ 我发现写和读路径之间的边界很有趣,因为我们可以试着改变这个边界,并探讨这种改变的实际意义。我们来看看不同上下文中的这一想法。

​ 过去二十年来,Web应用的火热让我们对应用开发作出了一些很容易视作理所当然的假设。具体来说就是,客户端/服务器模型 —— 客户端大多是无状态的,而服务器拥有数据的权威 —— 已经普遍到我们几乎忘掉了还有其他任何模型的存在。但是技术在不断地发展,我认为不时地质疑现状非常重要。

​ 传统上,网络浏览器是无状态的客户端,只有当连接到互联网时才能做一些有用的事情(能离线执行的唯一事情基本上就是上下滚动之前在线时加载好的页面)。然而,最近的“单页面”JavaScript Web应用已经获得了很多有状态的功能,包括客户端用户界面交互,以及Web浏览器中的持久化本地存储。移动应用可以类似地在设备上存储大量状态,而且大多数用户交互都不需要与服务器往返交互。

​ 这些不断变化的功能重新引发了对离线优先(offline-first) 应用的兴趣,这些应用尽可能地在同一设备上使用本地数据库,无需连接互联网,并在后台网络连接可用时与远程服务器同步【42】。由于移动设备通常具有缓慢且不可靠的蜂窝网络连接,因此,如果用户的用户界面不必等待同步网络请求,且应用主要是离线工作的,则这是一个巨大优势(参阅“具有离线操作的客户端”)。

​ 当我们摆脱无状态客户端与中央数据库交互的假设,并转向在终端用户设备上维护状态时,这就开启了新世界的大门。特别是,我们可以将设备上的状态视为服务器状态的缓存。屏幕上的像素是客户端应用中模型对象的物化视图;模型对象是远程数据中心的本地状态副本【27】。

将状态变更推送给客户端

​ 在典型的网页中,如果你在Web浏览器中加载页面,并且随后服务器上的数据发生变更,则浏览器在重新加载页面之前对此一无所知。浏览器只能在一个时间点读取数据,假设它是静态的 —— 它不会订阅来自服务器的更新。因此设备上的状态是陈旧的缓存,除非你显式轮询变更否则不会更新。(像RSS这样基于HTTP的Feed订阅协议实际上只是一种基本的轮询形式)

​ 最近的协议已经超越了HTTP的基本请求/响应模式:服务端发送的事件(EventSource API)和WebSockets提供了通信信道,通过这些信道,Web浏览器可以与服务器保持打开的TCP连接,只要浏览器仍然连接着,服务器就能主动向浏览器推送信息。这为服务器提供了主动通知终端用户客户端的机会,服务器能告知客户端其本地存储状态的任何变化,从而减少客户端状态的陈旧程度。

​ 用我们的写路径与读路径模型来讲,主动将状态变更推至到客户端设备,意味着将写路径一直延伸到终端用户。当客户端首次初始化时,它仍然需要使用读路径来获取其初始状态,但此后它就可能依赖于服务器发送的状态变更流了。我们在流处理和消息传递部分讨论的想法并不局限于数据中心中:我们可以进一步采纳这些想法,并将它们一直延伸到终端用户设备【43】。

​ 这些设备有时会离线,并在此期间无法收到服务器状态变更的任何通知。但是我们已经解决了这个问题:在“消费者偏移量”中,我们讨论了基于日志的消息代理的消费者能在失败或断开连接后重连,并确保它不会错过掉线期间任何到达的消息。同样的技术适用于单个用户,每个设备都是一个小事件流的小小订阅者。

端到端的事件流

​ 最近用于开发带状态客户端与用户界面的工具,例如如Elm语言【30】和Facebook的React,Flux和Redux工具链,已经通过订阅表示用户输入和服务器响应的事件流,来管理客户端的内部状态,其结构与事件溯源相似(请参阅第457页的“事件溯源”)。

​ 将这种编程模型扩展为:允许服务器将状态变更事件,推送到客户端的事件管道中,是非常自然的。因此,状态变化可以通过端到端(end-to-end) 的写路径流动:从一个设备上的交互触发状态变更开始,经由事件日志,并穿过几个衍生数据系统与流处理器,一直到另一台设备上的用户界面,而有人正在观察用户界面上的状态变化。这些状态变化能以相当低的延迟传播 —— 比如说,在一秒内从一端到另一端。

​ 一些应用(如即时消息传递与在线游戏)已经具有这种“实时”架构(在低延迟交互的意义上,不是在“响应时间保证”中的意义上)。但我们为什么不用这种方式构建所有的应用?

​ 挑战在于,关于无状态客户端和请求/响应交互的假设已经根深蒂固地植入在在我们的数据库,库,框架,以及协议之中。许多数据存储支持读取与写入操作,为请求返回一个响应,但只有极少数提供订阅变更的能力 —— 为请求返回一个随时间推移返回响应的流(请参阅“变更流的API支持” )。

​ 为了将写路径延伸至终端用户,我们需要从根本上重新思考我们构建这些系统的方式:从请求/响应交互转向发布/订阅数据流【27】。更具响应性的用户界面与更好的离线支持,我认为这些优势值得我们付出努力。如果你正在设计数据系统,我希望您对订阅变更的选项留有印象,而不只是查询当前状态。

读也是事件

​ 我们讨论过,当流处理器将衍生数据写入存储(数据库,缓存或索引)时,以及当用户请求查询该存储时,存储将充当写路径和读路径之间的边界。该存储应当允许对数据进行随机访问的读取查询,否则这些查询将需要扫描整个事件日志。

​ 在很多情况下,数据存储与流处理系统是分开的。但回想一下,流处理器还是需要维护状态以执行聚合和连接的(参阅“流连接”)。这种状态通常隐藏在流处理器内部,但一些框架也允许这些状态被外部客户端查询【45】,将流处理器本身变成一种简单的数据库。

​ 我愿意进一步思考这个想法。正如到目前为止所讨论的那样,对存储的写入是通过事件日志进行的,而读取是临时的网络请求,直接流向存储着待查数据的节点。这是一个合理的设计,但不是唯一可行的设计。也可以将读取请求表示为事件流,并同时将读事件与写事件送往流处理器;流处理器通过将读取结果发送到输出流来响应读取事件【46】。

​ 当写入和读取都被表示为事件,并且被路由到同一个流算子以便处理时,我们实际上是在读取查询流和数据库之间执行流表连接。读取事件需要被送往保存数据的数据库分区(参阅“请求路由”),就像批处理和流处理器在连接时需要在同一个键上对输入分区一样(请参阅“Reduce端连接与分组“)。

​ 服务请求与执行连接之间的这种相似之处是非常关键的【47】。一次性读取请求只是将请求传过连接算子,然后请求马上就被忘掉了;而一个订阅请求,则是与连接另一侧过去与未来事件的持久化连接。

​ 记录读取事件的日志可能对于追踪整个系统中的因果关系与数据来源也有好处:它可以让你重现出当用户做出特定决策之前看见了什么。例如在网商中,向客户显示的预测送达日期与库存状态,可能会影响他们是否选择购买一件商品【4】。要分析这种联系,则需要记录用户查询运输与库存状态的结果。

​ 将读取事件写入持久存储可以更好地跟踪因果关系(参阅“排序事件以捕获因果关系”),但会产生额外的存储与I/O成本。优化这些系统以减少开销仍然是一个开放的研究问题【2】。但如果你已经出于运维目的留下了读取请求日志,将其作为请求处理的副作用,那么将这份日志作为请求事件源并不是什么特别大的变更。

多分区数据处理

​ 对于只涉及单个分区的查询,通过流来发送查询与收集响应可能是杀鸡用牛刀了。然而,这个想法开启了分布式执行复杂查询的可能性,这需要合并来自多个分区的数据,利用流处理器已经提供的消息路由,分区和连接的基础设施。

​ Storm的分布式RPC功能支持这种使用模式(参阅“消息传递和RPC”)。例如,它已经被用来计算浏览过某个推特URL的人数 —— 即,转推该URL的粉丝集合的并集【48】。由于推特的用户是分区的,因此这种计算需要合并来自多个分区的结果。

​ 这种模式的另一个例子是欺诈预防:为了评估特定购买事件是否具有欺诈风险,你可以检查该用户IP地址,电子邮件地址,帐单地址,送货地址的信用分。这些信用数据库中的每一个自己都是一个分区,因此为特定购买事件采集分数需要连接一系列不同的分区数据集【49】。

​ MPP数据库的内部查询执行图有着类似的特征(参阅“比较Hadoop与分布式数据库”)。如果需要执行这种多分区连接,则直接使用提供此功能的数据库,可能要比使用流处理器实现它要更简单。然而将查询视为流提供了一种选项,可以用于实现超出传统现成解决方案的大规模应用。