设计数据密集型应用——数据系统的未来(12 上)

如果船长的终极目标是保护船只,他应该永远待在港口。

——圣托马斯·阿奎那《神学大全》

到本章节为止,本书主要描述的是现状。在最后一章中,将放眼未来,目标是,发现如何设计出比现有应用更好的应用——健壮,正确,可演化,且最终对人类有益。

本书中反复出现的主题是,对于任何给定的问题都会有好几种解决方案,所有这些解决方案都有不同的优缺点与利弊权衡。

思考:如何实现一个类似于「我想存储一些数据并稍后再查询」的问题?

答案:并没有一种正确的解决方案。但对于不同的具体环境,总会有不同的合适方法。软件实现通常必须选择一种特定的方法。使单条代码路径能做到稳定健壮且表现良好已经是一件非常困难的事情了——尝试在单个软件中完成所有事情,几乎可以保证,实现效果会很差。

所以,不可避免的需要拼凑几个不同的软件以提供应用所需的功能。

1. 组合使用衍生数据的工具

为了处理任意关键词的搜索查询,将 OLTP 数据库与全文搜索索引集成在一起是很常见的需求。尽管一些数据库包含了全文索引功能,对于简单的应用完全够用,但更复杂的搜索能力就需要专业的信息检索工具。

令人惊讶的是,经常看到软件工程师做出这样的陈述:「根据我的经验,99% 的人只需要 X 或者 …… 不需要 X」。这种陈述更像是发言人自己经验,而不是技术实际上的实用性。某个认为鸡肋而毫无意义的功能可能是别人的核心需求。

1.1 理解数据流

当需要在多个存储系统中维护相同数据的副本以满足不同的访问模式时,你要对输入和输出了如指掌:

  • 哪些数据先写入?

    扫描二维码关注公众号,回复: 13471636 查看本文章
  • 哪些「数据表示」衍生自「哪些来源」?

  • 如何以正确的格式,将所有数据导入正确的地方?

注:通过单个系统来提供所有用户输入,从而决定所有写入的排序,则通过按相同顺序处理写入,可以更容易地衍生出其他数据表示。这是状态机复制方法的一个应用,我们在「全序广播」中看到。

基于事件日志来更新衍生数据的系统,通常可以做到确定性和幂等性,使得从故障中恢复相当容易。

1.2 衍生数据与分布式事务

保持不同数据系统彼此一致的经典方法涉及分布式事务,如「原子提交与两阶段提交」中所述。与分布式事务相比,使用衍生数据系统的方法如何?

在抽象层面上,它们通过不同的方式达到类似的目的。

  • 分布式事务通过锁进行互斥来决定写入的顺序(两阶段锁定);分布式事务使用原子提交来确保变更只生效一次。

  • 事件捕获和事件溯源使用日志进行排序。基于日志的系统通常具有确定性的重试和幂等性。

注:最大的不同之处在于事务系统通常提供线性一致性,这包含着有用的保证,例如读己之写。另一方面,衍生数据系统通常是异步更新的,因此它们默认不会提供相同的时序保证。

告诉所有人「最终一致性是不可避免的——忍一忍并学会和它打交道」是没有什么建设性的。缺乏如何应对的良好指导。

1.3 全序的限制

对于足够小的系统,构建一个完全有序的事件日志是可行的。但是,随着系统向更大更复杂的工作负载伸缩,限制开始出现:

  • 如果事件吞吐量大于单台计算机的处理能力,则需要将其分区到多台计算机上。然后两个分区中的事件顺序关系就不明确了。

  • 如果服务器分布在多个地理位置分散的数据中心上,例如为了容忍整个数据中心掉线,通常在每个数据中心都有单独的主库。这意味着源自两个不同数据中心的事件顺序未定义。

  • 当应用程序部署为微服务时,常见的设计选择是将每个服务及其持久状态作为独立单元进行部署,服务之间不共享持久状态。当两个事件来自不同的服务时,这些事件间的顺序未定义。

  • 某些应用程序在客户端保存状态,该状态在用户输入时立即更新(无需等待服务器确认),甚至可以继续脱机工作。对于这样的应用程序,客户端和服务器很可能以不同的顺序看到事件。

在形式上,决定事件的全局顺序称为全序广播,相当于共识。**大多数共识算法都是针对单个节点的吞吐量以处理整个事件流的情况而设计的,**并且这些算法不提供多个节点共识事件排序工作的机制。

注:设计可以伸缩至单个节点的吞吐量之上,且在地理位置分散的环境中仍然工作良好的共识算法仍然是一个开放的研究问题。

1.4 排序事件以捕获因果关系

在事件之间不存在因果关系的情况下,全序的缺少并不是一个大问题,因为并发事件可以任意排序。其他一些情况很容易处理:例如,当同一个对象有多个更新时,它们可以通过将特定对象 ID 的所有更新路由到相同的日志分区来完全排序。然而,因果关系有时会以更微秒的方式出现。

考虑一下场景:

  • 拉黑前任,以及跟闺蜜抱怨前任

该场景下如果丢失因果关系,会导致消息错误发送给前任。不幸的是,这个问题并没有很好的解决方案:

  • 逻辑时间戳可以提供无需协调的全局顺序,因此它们可能有助于全序广播不可行的情况。但是,他们仍然要求收件人处理不按顺序发送的事件,并且需要传递其他元数据。

  • 可以记录一个事件来记录用户在做出决定之前所看到的系统状态,并给该事件一个唯一的标识符,那么后面的任何事件都可以引用该事件标识符来记录因果关系。

  • 冲突解决算法有助于处理意外顺序传递的事件。

2. 批处理与流处理

数据集成的目的是,确保数据最终能在所有正确的地方表示出正确的形式。这样做需要消费输入,转换、连接、过滤、聚合、训练模型、评估,以及最终写出适当的输出。

注:批处理和流处理是数据集成的两种方式

批处理和流处理的输出是衍生数据集,例如搜索索引,物化视图,向用户显示的建议,聚合指标等。批处理和流处理有许多共同的原则,主要的根本区别在于流处理器在无限数据集上运行,而批处理输入是已知的有限大小。

2.1 维护衍生状态

批处理有着很强的函数式风格:它鼓励确定性的纯函数,其输出仅依赖于输入,除了显示输出外没有副作用,将输入视为不可变的,且输出仅依赖于输入,除了显示输出外没有副作用,将输入视作不可变的,且输出是仅追加的。

流处理与之类似,但它扩展了算子以允许受管理的、容错的状态。

具有良好定义的输入和输出的确定性函数的原理不仅有利于容错,也简化了有关组织中数据流的推理。

注:无论衍生数据是搜索引擎、统计模型还是缓存,采用以上观点都是很有帮助的:将其视为从一种东西衍生出来另一个数据管道,通过函数式应用代码推送一个系统的状态变更,并将其效果应用至衍生系统中。

原则上,衍生数据系统可以同步地维护,就像关系数据库在与索引表写入操作相同的事务中同步更新次级索引一样。然而:

  • 异步是使基于事件日志中的系统稳健的原因:它允许系统的一部分故障被抑制在本地。

  • 如果任何一个参与者失败,分布式事务将中止,因此它们倾向于通过将故障传播到系统的其余部分来放大故障。

2.2 应用演化后重新处理数据

在维护衍生数据时,批处理和流处理都是有用的。流处理允许将输入中的变化以低延迟反映在衍生视图中,而批处理允许重新处理大量积累的历史数据以便将新视图导出到现有数据集上。

注:特别是,重新处理现有数据为维护系统、演化并支持新功能和需求变更提供了一个良好的机制。

衍生视图允许渐进演化。如果想重新构建数据集,不需要执行突然切换的迁移。取而代之的是:

  • 可以将旧架构和新架构并排维护为相同基础数据上的两个独立衍生视图。

  • 然后可以开始将少量用户转移到新视图,以测试其性能并发现错误,与此同时,大多数用户仍然会被路由到旧视图。

  • 逐渐增加访问新视图的用户比例,最终删除旧视图。

注:这种逐渐迁移的美妙之处在于,如果出现问题,每个阶段的过程都是很容易逆转,始终有一个可以回滚的可用系统。

2.2.1 参考—铁路上的模式迁移

大规模的「模式迁移」也发生在非计算机系统中。例如,在 19 世纪英国铁路建设初期,轨距(两轨之间的距离)就有了各种各样的竞争标准。为一种轨距而建的列车不能再另一种轨距的轨道上运行,这限制了火车网络中更可能的互相连接。

在 1846 年最终确定了一个标准轨距之后,其他轨距的轨道必须转换——但是如何在不停运火车线路的情况下进行数月甚至数年的迁移?解决的办法是首先通过添加第三条轨道将轨道转换为双轨距或混合轨距。这种转换可以逐渐完成,当完成时,两种轨距的列车都可以在线路上跑,使用三条轨距中的两条。事实上,一旦所有的列车都转换成标准轨距,那么可以移除提供非标准轨距的轨道。

以这种方式「再加工」现有的轨道,让新旧版本并存,可以在几年的时间内逐渐改变轨距。然而,这是一项昂贵的事业,这就是今天非标准轨距仍然存在的原因。例如,旧金山湾区的 BART 系统使用了与美国大部分地区不同的轨距。

2.3 Lambda 架构

如果批处理用于重新处理历史数据,而流处理用于处理最新的更新,那么如何将这两者结合起来?

Lambda 架构的核心思想是通过将不可变事件附加到不断增长的数据集来记录传入数据,这类似事件溯源。为了从这些事件中衍生读取优化的视图,Lambda 架构建议并行运行两个不同的系统:

  • 批处理系统(如 Hadoop MapReduce)

  • 流处理系统(如 Storm)

在 Lambda 方法中,流处理器消耗事件并快速生成对视图的近似更新,批处理器稍后将使用同一组事件并生成衍生视图的更正版本。这个设计背后的原因是批处理更简单,因此不易出错,而流处理器被认为是不太可靠和难以容错的。同时,流处理可以使用快速近似算法,而批处理使用较慢的精确算法。

Lambda 架构是一种有影响力的想法,它将数据系统的设计变得更好,但其也存在一些实际性问题:

  • 在批处理和流处理框架中维护相同的逻辑是很显著的额外工作。

  • 由于流管道和批处理管道产生独立的输出,因此需要合并它们以响应用户请求。

  • 尽管有能力重新处理整个历史数据集市很好的,但在大型数据集上这样做经常会开销巨大。

2.4 统一批处理和流处理

有一种方式使得 Lambda 架构的优点在没有缺点的情况下得以实现,允许批处理计算(重新处理历史数据)和流计算(在事件到达时即处理)在同一个系统中实现。

在一个系统中统一批处理和流处理需要以下功能,这些功能也正在越来越广泛被提供:

  • 通过处理最近事件流的相同处理引擎来重播历史事件的能力。例如,基于日志的消息代理可以重播消息,某些流处理器可以从 HDFS 等分布式文件系统读取输入

  • 对于流处理器来说,恰好一次语义——即确保输出与未发生故障的输出相同,即使事实上发生故障。与批处理一样,这需要丢弃失败任务的部分输出。

  • 按事件时间进行窗口化的工具,而不是按处理时间进行窗口化,因为处理历史事件时,处理时间毫无意义。例如,Apache Beam 提供了用于表示这种计算的 API,可以在 Apache Flink 或 Google Cloud Dataflow 使用。

3. 分拆数据库

在最抽象的层面上,数据库、Hadoop 和操作系统都发挥相同的功能:它们存储一些数据,并允许你处理和查询这些数据。数据库将数据存储为特定数据模型的记录(表中行、文档、图中的顶点等),而操作系统的文件系统则将数据存储在文件中——但其核心都是「信息管理」系统。

注:正如在第十章看到的,Hadoop 生态系统有点像 Unix 的分布式版本。

许多文件系统不能很好地处理包含 1000 万个小文件的目录,而包含 1000 万个小记录的数据库完全是寻常而的。无论如何,操作系统和数据库之间的形似之处和差异值值得讨论。

二者的区别包括:

  • Unix 是为使用者提供一种相当低层次的硬件的逻辑抽象,而关系数据库则提供了一种高层次的抽象,以隐藏磁盘上数据结构的复杂性,并发性,崩溃恢复等等。Unix 发展出的管道和文件只是字节序列,而数据库则发展出了 SQL 和事务。

哪种方法更好?

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

3.1 组合使用数据存储技术

之前讨论的数据库提供的各种功能及其工作原理,其中包括:

  • 次级索引,可以根据字段的值有效地搜索记录

  • 物化视图,这是一种预计算查询结果缓存

  • 复制日志,保持其他节点上数据的副本最新

  • 全文搜索索引,允许在文本中进行关键字搜索

3.1.1 创建索引

思考下在运行 CREATE INDEX 在关系数据库中创建一个新索引时会发生什么?

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

注:此过程非常类似于设置新的从库副本,也非常类似于流处理系统中的引导变更数据捕获。

无论何时运行 CREATE INDEX ,数据库都会重新处理现有数据集,并将该索引作为新视图导出到现有数据上。

3.1.2 一切的元数据库

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

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

这些系统的发展未来将会把我们带到哪里?

如果从没有适合所有访问模式的单一数据模型或存储格式前提出发,推测有两种途径可以将不同的存储和处理工具组合成一个有凝聚力的系统。

3.1.3 联合数据库:统一读取

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

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

3.1.4 分拆数据库:统一写入

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

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

3.1.5 开展分拆工作

联合和分拆是一个硬币的两面:用不同的组件构成可靠、可伸缩和可维护的系统。联合只读查询需要将一个数据模型映射到另一个数据模型,这需要一些思考,但最终仍然是一个可解决的问题。同步写入到几个存储系统是更困难的工程问题。

传统的同步写入方式需要跨异构存储系统的分布式事务,单个存储或流处理系统内的事务是可行的,但当数据跨越不同技术之间的边界时,具有幂等写入的异步事件日志是一种更加健壮和实用的方法。

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

  • 在系统级别,异步事件流使整个系统在个别组件的中断或性能下降时更加稳健。如果消费者运行缓慢或失败,那么事件日志可以缓冲消息,以便生产者和任何其他消费者可以继续不受影响地运行。

  • 在人力方面,分拆数据系统允许不同的团队独立开发,改进和维护不同的软件组件和服务。

3.1.6 分拆系统 vs 集成系统

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

分拆的目标不是要针对个数据库与特定工作负载的性能进行竞争:其目标是运行你结合不同的数据库,以便在比单个软件可能实现的更广泛的工作负载范围内实现更好的性能。这是关于广度,而不是深度。

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

3.17 少了什么?

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

例如,如果可以简单地声明 mysql | elasticsearch,类似于 Unix 管道,成为 CREATE INDEX 的分拆等价物:它将读取 MySQL 数据库中的所有文档并将其索引到 Elasticsearch 集群中。然后它会不断捕获数据库所做的所有变更,并自动将它们应用于搜索索引,而无需编写自定义应用代码。

3.2 围绕数据流设计应用

使用应用代码组合专用存储与处理系统来分拆数据库的方法,也被称为「数据库由内而外」方法。该想法是很多人的思想融合,这些思想很值得我们学习。比如:

  • 以 Oz 和 Juttle 为代表的数据流语言

  • 以 Elm 为代表的函数式响应式编程

  • 以 Bloom 为代表的逻辑编程语言

以下将会探讨一些围绕分拆数据库和数据流的想法构建应用的方法。

3.2.1 应用代码作为衍生函数

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

  • 次级索引是由一种直白的转换函数生成的衍生数据集:对于基础表中的每行或每个文档,它挑选被索引的列或字段中的值,并按这些值排序(假设使用 B 树或 SSTable 索引,按键排序)

  • 全文搜索索引是通过应用各种自然语言处理函数而创建的,诸如语言检测、分词、词干或词汇化、拼写纠正和同义词识别,然后构建用于高效查找的数据结构(例如倒排索引)

  • 在机器学习系统中,我们可以将模型视作从训练数据通过应用各种特征提取、统计分析函数衍生的数据,当模型应用于新的输入数据时,模型的输出是从输入和模型中衍生的

  • 缓存通常包含将以用户界面(UI)显示的形式的数据聚合。因此填充缓存需要知道 UI 中引用的字段;UI 中的变更可能需要更新缓存填充方式的定义,并重建缓存

用于次级索引的衍生函数是如此常用的需求,以致于它作为核心功能被内建至许多数据库中,可以简单地通过 CREATE INDEX 来调用它。

注:当创建衍生数据集的函数不是像创建次级索引那样的标准板砖函数时,需要自定义代码来处理特定于应用的东西。而这个自定义代码使让许多数据库挣扎的地方,虽然关系数据库通常支持触发器、存储过程和用户定义的函数,可以用它们在数据库中执行应用代码,但它们有点像数据库设计里的事后反思。

3.2.2 应用代码和状态分离

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

另一方面,Mesos,YARN,Docker,Kubernetes 等部署和集群管理工具专为运行应用代码而设计。通过专注于做好一件事情,他们能够做得比将数据库作为其众多功能之一执行用户定义的功能要好得多。

让系统的某些部分专门用于持久数据存储并让其他部分专门运行应用程序代码是有意义的。这两者可以在保持独立的同时互动。

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

注:在大多数编程语言中,无法订阅可变变量中的变更——只能定期读取它。

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

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

注:在「数据库与流」中看到了这一思路,讨论了将数据库的变更日志视作为一种我们可以订阅的事件流。

需要记住重要的一点,维护衍生数据不同于执行异步任务。传统的消息传递系统通常是为执行异步任务设计的。

  • 在维护衍生数据时,状态变更的顺序通常很重要(如果多个视图是从事件日志衍生的,则需要按照相同的顺序处理事件,以便它们之间保持一致)。如「确认与重新传递」中所述,许多消息代理在重传未确认时没有此属性。

  • 容错是衍生数据的关键:仅仅丢失单个消息就会导致衍生数据集永远与其数据源失去同步。消息传递和衍生状态的更新都必须可靠。

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

3.2.4 流处理器和服务

当今流行的应用开发风格涉及将功能分解为一组通过同步网络请求(如 Rest API)进行通信的服务(Service)。这种面向服务的架构由于单一庞大应用的优势主要在于:通过松散耦合来提供组织上的可伸缩性:不同团队可以专职于不同的服务上,从而减少团队之间的协调工作。

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

数据流系统还能实现更好的性能。例如,假设客户正在购买以一种货币定价,但以另一种货币支付的商品。为了执行货币换算,需要知道当前的汇率。这个操作可以通过两种方式实现:

  • 在微服务方法中,处理购买的代码可能会查询汇率服务或数据库,以获取特定货币的当前汇率。

  • 在数据流方法中,处理订单的代码会提前订阅汇率变更流,并在汇率发生变动时将当前汇率存储在本地数据库中。处理订单时只需要查询本地数据库即可。

第二种方法能将对另一个服务的同步网络请求替换为对本地数据库的查询。数据流方法不仅更快,而且当其他服务失效时也更稳健。

最快最稳定的网络请求就是压根没有网络请求!不再使用 RPC, 而是在购买事件和汇率变更事件之间建立流连接。

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

  • 订阅变更流,而不是在需要时查询当前状态,使我们更接近类似电子表格的计算模型:当某些数据发生变更时,依赖于此的所有衍生数据都可以快速更新。

3.3 观察衍生数据状态

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

为什么一开始就要创建衍生数据集?

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

总结下来,写路径和读路径涵盖了数据的整个旅程,从收集数据开始,到使用数据结束。写路径是预结算过程的一部分——即,一旦数据进入,即可完成,无论是否有人需要看它。读路径是这个过程中只有当有人请求时才会发生的部分。

注:如果熟悉函数式编程语言,则可能会注意到写路径类似于立即求值,读路径类似于惰性求值。

衍生数据集时写路径和读路径相遇的地方。它代表了写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。

3.3.1 物化视图和缓存

全文搜索索引是一个很好的例子:写路径更新索引,读路径在索引中搜索关键字。

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

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

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

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

3.3.2 有状态、可离线的客户端

Web 应用的火热让我们对应用开发做出了一些很容易视作理所当然的假设。具体来说就是,客户端/服务端模型——客户端大多是无状态的,而服务器拥有数据的权威——已经普遍到我们几乎忘掉还有其他任何模型的存在。

注:技术在不断发展,要学会不时的质疑现状。

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

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

注:我们可以将设备上的状态视为服务器状态的缓存。屏幕上的像素是客户端应用中模型对象的物化视图:模型对象时远程数据中心的本地状态副本。

3.3.3 将状态变更推送给客户端

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

基于写路径与读路径模型来讲,主动将状态变更推至客户端设备,意味着将写路径一致延伸到终端用户。当客户端首次初始化时,它仍然需要使用读路径来获取其初始化状态,但此后它就能够依赖服务器发送的状态变更流。

3.3.4 端到端的事件流

用于开发有状态的客户端与用户界面的工具,例如 Elm 语言和 Facebook 的 React、Flux 和 Redux 工具链,已经通过订阅表示用户输入或服务器响应的事件流来管理客户端的内部状态,其结构与事件溯源相似。

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

注:这些状态变化能以相当低的延迟传播

思考:为什么不用这种方式构建所有的应用?

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

注:希望对于订阅变更的选型留有印象,而不只是查询当前状态

3.3.5 读也是事件

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

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

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

  • 记录读取事件的日志可能对于追踪整个系统中的因果关系与数据来源是有好处的:它可以重现用户做出了特定决策之前看到了什么。

  • 将读取事件写入持久存储可以很好地跟踪因果关系,但会产生额外的存储与 I/O 成本。 优化这些系统以减小开销仍然是一个开放的研究问题

3.3.6 多分区数据处理

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

Storm 的分布式 RPC 功能支持这种使用模式。例如,它已经被用来计算浏览过某个推特 URL 的人数 —— 即,发推包含该 URL 的所有人的粉丝集合的并集。由于推特的用户是分区的,因此这种计算需要合并多个分区的结果。

注:MPP 数据库的内部查询执行图有着类似的特征。如果需要执行这种多分区连接,则直接使用提供此功能的数据库,可能要比使用流处理实现它要更简单。

4. 碎碎念

最后一章的知识点是真的多,上半章就总结了近一万字,还是分两篇总结靠谱点。

  • 如果一个人影响到了你的情绪,你的焦点应该放在控制自己的情绪上,而不是影响你情绪的人身上。只有这样,才能真正的自信起来。

  • 我依旧敢和生活顶撞,敢在逆境里撒野,直面生活的污水,永远乐意为新的一轮月亮和日落欢呼。

  • 一直觉得自己是个笨拙的人,愿意用很长的时间去完成别人觉得毫无意义的事情,但是「意义」这件事情不是自己赋予的嘛,所有「管他呢」自己开心就可以啦

略微不完美的完结撒花,毕竟还有一个长长的下篇。

猜你喜欢

转载自juejin.im/post/7037431119955902478