数据集成

​ 本书中反复出现的主题是,对于任何给定的问题都会有好几种解决方案,所有这些解决方案都有不同的优缺点与利弊权衡。例如在第3章讨论存储引擎时,我们看到了日志结构存储,B树,以及列存储。在第5章讨论复制时,我们看到了单领导者,多领导者,和无领导者的方法。

​ 如果你有一个类似于“我想存储一些数据并稍后再查询”的问题,那么并没有一种正确的解决方案。但对于不同的具体环境,总会有不同的合适方法。软件实现通常必须选择一种特定的方法。使单条代码路径能做到稳定健壮且表现良好已经是一件非常困难的事情了 —— 尝试在单个软件中完成所有事情,几乎可以保证,实现效果会很差。

​ 因此软件工具的最佳选择也取决于情况。每一种软件,甚至所谓的“通用”数据库,都是针对特定的使用模式设计的。

​ 面对让人眼花缭乱的诸多替代品,第一个挑战就是弄清软件与其适用环境的映射关系。供应商不愿告诉你他们软件不适用的工作负载,这是可以理解的。但是希望先前的章节能给你提供一些问题,让你读出字里行间的言外之意,并更好地理解这些权衡。

​ 但是,即使你已经完全理解各种工具与其适用环境间的关系,还有一个挑战:在复杂的应用中,数据的用法通常花样百出。不太可能存在适用于所有不同数据应用场景的软件,因此您不可避免地需要拼凑几个不同的软件来以提供应用所需的功能。

组合使用衍生数据的工具

​ 例如,为了处理任意关键词的搜索查询,将OLTP数据库与全文搜索索引集成在一起是很常见的的需求。尽管一些数据库(例如PostgreSQL)包含了全文索引功能,对于简单的应用完全够了【1】,但更复杂的搜索能力就需要专业的信息检索工具了。相反的是,搜索索引通常不适合作为持久的记录系统,因此许多应用需要组合这两种不同的工具以满足所有需求。

​ 我们在“使系统保持同步”中接触过集成数据系统的问题。随着数据不同表示形式的增加,集成问题变得越来越困难。除了数据库和搜索索引之外,也许你需要在分析系统(数据仓库,或批处理和流处理系统)中维护数据副本;维护从原始数据中衍生的缓存,或反规范化的数据版本;将数据灌入机器学习,分类,排名,或推荐系统中;或者基于数据变更发送通知。

​ 令人惊讶的是,我经常看到软件工程师做出这样的陈述:“根据我的经验,99%的人只需要X”或者 “……不需要X”(对于各种各样的X)。我认为这种陈述更像是发言人自己的经验,而不是技术实际上的实用性。可能对数据执行的操作,其范围极其宽广。某人认为鸡肋而毫无意义的功能可能是别人的核心需求。当你拉高视角,并考虑跨越整个组织范围的数据流时,数据集成的需求往往就会变得明显起来。

理解数据流

​ 当需要在多个存储系统中维护相同数据的副本以满足不同的访问模式时,你要对输入和输出了如指掌:哪些数据先写入,哪些数据表示衍生自哪些来源?如何以正确的格式,将所有数据导入正确的地方?

​ 例如,你可能会首先将数据写入记录数据库系统,捕获对该数据库所做的变更(参阅“捕获数据变更”),然后将变更应用于数据库中的搜索索引相同的顺序。如果变更数据捕获(CDC)是更新索引的唯一方式,则可以确定该索引完全派生自记录系统,因此与其保持一致(除软件错误外)。写入数据库是向该系统提供新输入的唯一方式。

​ 允许应用程序直接写入搜索索引和数据库引入了如图11-4所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。

​ 如果您可以通过单个系统来提供所有用户输入,从而决定所有写入的排序,则通过按相同顺序处理写入,可以更容易地衍生出其他数据表示。 这是状态机复制方法的一个应用,我们在“全序广播”中看到。无论您使用变更数据捕获还是事件源日志,都不如仅对全局顺序达成共识更重要。

​ 基于事件日志来更新衍生数据的系统,通常可以做到确定性幂等性(参见第478页的“幂等性”),使得从故障中恢复相当容易。

衍生数据与分布式事务

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

​ 在抽象层面,它们通过不同的方式达到类似的目标。分布式事务通过进行互斥来决定写入的顺序(参阅“两阶段锁定(2PL)”),而CDC和事件溯源使用日志进行排序。分布式事务使用原子提交来确保变更只生效一次,而基于日志的系统通常基于确定性重试幂等性

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

​ 在愿意为分布式事务付出代价的有限场景中,它们已被成功应用。但是,我认为XA的容错能力和性能很差劲(参阅“实践中的分布式事务”),这严重限制了它的实用性。我相信为分布式事务设计一种更好的协议是可行的。但使这样一种协议被现有工具广泛接受是很有挑战的,且不是立竿见影的事。

​ 在没有广泛支持的良好分布式事务协议的情况下,我认为基于日志的衍生数据是集成不同数据系统的最有前途的方法。然而,诸如读己之写的保证是有用的,我认为告诉所有人“最终一致性是不可避免的 —— 忍一忍并学会和它打交道”是没有什么建设性的(至少在缺乏如何应对的良好指导时)。

​ 在“将事情做正确”中,我们将讨论一些在异步衍生系统之上实现更强保障的方法,并迈向分布式事务和基于日志的异步系统之间的中间地带。

全局有序的限制

​ 对于足够小的系统,构建一个完全有序的事件日志是完全可行的(正如单主复制数据库的流行所证明的那样,这正好建立了这样一种日志)。但是,随着系统向更大更复杂的工作负载扩展,限制开始出现:

  • 在大多数情况下,构建完全有序的日志,需要所有事件汇集于决定顺序的单个领导节点。如果事件吞吐量大于单台计算机的处理能力,则需要将其分割到多台计算机上(参见“分区日志”)。然后两个不同分区中的事件顺序关系就不明确了。
  • 如果服务器分布在多个地理位置分散的数据中心上,例如为了容忍整个数据中心掉线,您通常在每个数据中心都有单独的主库,因为网络延迟会导致同步的跨数据中心协调效率低下(请参阅“多主复制“)。这意味着源自两个不同数据中心的事件顺序未定义。
  • 将应用程序部署为微服务时(请参阅第125页上的“服务中的数据流:REST与RPC”),常见的设计选择是将每个服务及其持久状态作为独立单元进行部署,服务之间不共享持久状态。当两个事件来自不同的服务时,这些事件间的顺序未定义。
  • 某些应用程序在客户端保存状态,该状态在用户输入时立即更新(无需等待服务器确认),甚至可以继续脱机工作(参阅“脱机操作的客户端”)。有了这样的应用程序,客户端和服务器很可能以不同的顺序看到事件。

在形式上,决定事件的全局顺序称为全序广播,相当于共识(参阅“共识算法和全序广播”)。大多数共识算法都是针对单个节点的吞吐量足以处理整个事件流的情况而设计的,并且这些算法不提供多个节点共享事件排序工作的机制。设计可以扩展到单个节点的吞吐量之上,且在地理散布环境中仍然工作良好的的共识算法仍然是一个开放的研究问题。

排序事件以捕捉因果关系

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

​ 例如,考虑一个社交网络服务,以及一对曾处于恋爱关系但刚分手的用户。其中一个用户将另一个用户从好友中移除,然后向剩余的好友发送消息,抱怨他们的前任。用户的心思是他们的前任不应该看到这些粗鲁的消息,因为消息是在好友状态解除后发送的。

​ 但是如果好友关系状态与消息存储在不同的地方,在这样一个系统中,可能会出现解除好友事件与发送消息事件之间的因果依赖丢失的情况。如果因果依赖关系没有被捕捉到,则发送有关新消息的通知的服务可能会在解除好友事件之前处理发送消息事件,从而错误地向前任发送通知。

​ 在本例中,通知实际上是消息和好友列表之间的连接,使得它与我们先前讨论的连接的时间问题有关(请参阅第475页的“连接的时间依赖性”)。不幸的是,这个问题似乎并没有一个简单的答案【2,3】。起点包括:

  • 逻辑时间戳可以提供无需协调的全局顺序(参见“序列号排序”),因此它们可能有助于全序广播不可行的情况。但是,他们仍然要求收件人处理不按顺序发送的事件,并且需要传递其他元数据。
  • 如果你可以记录一个事件来记录用户在做出决定之前所看到的系统状态,并给该事件一个唯一的标识符,那么后面的任何事件都可以引用该事件标识符来记录因果关系【4】 。我们将在“读也是事件”中回到这个想法。
  • 冲突解决算法(请参阅“自动冲突解决”)有助于处理以意外顺序传递的事件。它们对于维护状态很有用,但如果行为有外部副作用(例如,也许,随着时间的推移,应用开发模式将出现,使得能够有效地捕获因果依赖关系,并且保持正确的衍生状态,而不会迫使所有事件经历全序广播的瓶颈)。

批处理与流处理

​ 我会说数据集成的目标是,确保数据最终能在所有正确的地方表现出正确的形式。这样做需要消费输入,转换,连接,过滤,聚合,训练模型,评估,以及最终写出适当的输出。批处理和流处理是实现这一目标的工具。

​ 批处理和流处理的输出是衍生数据集,例如搜索索引,物化视图,向用户显示的建议,聚合指标等(请参阅“批处理工作流的输出”和“流处理的用法”)。

​ 正如我们在第10章第11章中看到的,批处理和流处理有许多共同的原则,主要的根本区别在于流处理器在无限数据集上运行,而批处理输入是已知的有限大小。处理引擎的实现方式也有很多细节上的差异,但是这些区别已经开始模糊。

​ Spark在批处理引擎上执行流处理,将流分解为微批次(microbatches),而Apache Flink则在流处理引擎上执行批处理【5】。原则上,一种类型的处理可以用另一种类型来模拟,但是性能特征会有所不同:例如,在跳跃或滑动窗口上,微批次可能表现不佳【6】。

维护衍生状态

​ 批处理有着很强的函数式风格(即使其代码不是用函数式语言编写的):它鼓励确定性的纯函数,其输出仅依赖于输入,除了显式输出外没有副作用,将输入视作不可变的,且输出是仅追加的。流处理与之类似,但它扩展了算子以允许受管理的,容错的状态(参阅“失败后重建状态”)。

​ 具有良好定义的输入和输出的确定性函数的原理不仅有利于容错(参见“幂等性”),也简化了有关组织中数据流的推理【7】。无论衍生数据是搜索索引,统计模型还是缓存,采用这种观点思考都是很有帮助的:将其视为从一个东西衍生出另一个的数据管道,将一个系统的状态变更推送至函数式应用代码中,并将其效果应用至衍生系统中。

​ 原则上,衍生数据系统可以同步地维护,就像关系数据库在与被索引表写入操作相同的事务中同步更新辅助索引一样。然而,异步是基于事件日志的系统稳健的原因:它允许系统的一部分故障被抑制在本地,而如果任何一个参与者失败,分布式事务将中止,因此它们倾向于通过将故障传播到系统的其余部分来放大故障(请参阅“分布式事务的限制”)。

​ 我们在“分区与次级索引”中看到,二级索引经常跨越分区边界。具有二级索引的分区系统需要将写入发送到多个分区(如果索引按关键词分区的话)或将读取发送到所有分区(如果索引是按文档分区的话)。如果索引是异步维护的,这种交叉分区通信也是最可靠和最可扩展的【8】(另请参阅“多分区数据处理”)。

应用演化后重新处理数据

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

​ 特别是,重新处理现有数据为维护系统提供了一个良好的机制,演化并支持新功能和需求变更(参见第4章)。不需要重新进行处理,模式演化仅限于简单的变化,例如向记录中添加新的可选字段或添加新类型的记录。无论是在写模式还是在读模式中都是如此(参阅“文档模型中的模式灵活性”)。另一方面,通过重新处理,可以将数据集重组为一个完全不同的模型,以便更好地满足新的要求。

铁路上的模式迁移

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

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

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

​ 衍生视图允许渐进演化(gradual evolution)。如果你想重新构建数据集,不需要执行迁移,例如突然切换。取而代之的是,你可以将旧架构和新架构并排维护为相同基础数据上的两个独立衍生视图。然后可以开始将少量用户转移到新视图,以测试其性能并发现任何错误,而大多数用户仍然会被路由到旧视图。你可以逐渐地增加访问新视图的用户比例,最终可以删除旧视图【10】。

​ 这种逐渐迁移的美妙之处在于,如果出现问题,每个阶段的过程都很容易逆转:你始终有一个可以回滚的可用系统。通过降低不可逆损害的风险,你能对继续前进更有信心,从而更快地改善系统【11】。

Lambda架构

​ 如果批处理用于重新处理历史数据,并且流处理用于处理最近的更新,那么如何将这两者结合起来?Lambda架构【12】是这方面的一个建议,引起了很多关注。

​ Lambda架构的核心思想是通过将不可变事件附加到不断增长的数据集来记录传入数据,这类似于事件溯源(参阅“事件溯源”)。为了从这些事件中衍生出读取优化的视图, Lambda架构建议并行运行两个不同的系统:批处理系统(如Hadoop MapReduce)和独立的流处理系统(如Storm)。

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

​ Lambda架构是一种有影响力的想法,它将数据系统的设计变得更好,尤其是通过推广这样的原则:在不可变事件流上建立衍生视图,并在需要时重新处理事件。但是我也认为它有一些实际问题:

  • 在批处理和流处理框架中维护相同的逻辑是很显著的额外工作。虽然像Summingbird 【13】这样的库提供了一种可以在批处理和流处理的上下文中运行的计算抽象。调试,调整和维护两个不同系统的操作复杂性依然存在【14】。
  • 由于流管道和批处理管道产生独立的输出,因此需要合并它们以响应用户请求。如果计算是基于滚动窗口的简单聚合,则合并相当容易,但如果视图基于更复杂的操作(例如连接和会话化)而导出,或者输出不是时间序列,则会变得非常困难。
  • 尽管有能力重新处理整个历史数据集是很好的,但在大型数据集上这样做经常会开销巨大。因此,批处理流水线通常需要设置为处理增量批处理(例如,在每小时结束时处理一小时的数据),而不是重新处理所有内容。这引发了“关于时间的推理”中讨论的问题,例如处理分段器和处理跨批次边界的窗口。增加批量计算会增加复杂性,使其更类似于流式传输层,这与保持批处理层尽可能简单的目标背道而驰。

统一批处理和流处理

​ 最近的工作使得Lambda架构的优点在没有其缺点的情况下得以实现,允许批处理计算(重新处理历史数据)和流计算(处理事件到达时)在同一个系统中实现【15】。

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

  • 通过处理最近事件流的相同处理引擎来重放历史事件的能力。例如,基于日志的消息代理可以重放消息(参阅“重放旧消息”),某些流处理器可以从HDFS等分布式文件系统读取输入。
  • 对于流处理器来说,恰好一次语义 —— 即确保输出与未发生故障的输出相同,即使事实上发生故障(参阅“故障容错”)。与批处理一样,这需要丢弃任何失败任务的部分输出。
  • 按事件时间进行窗口化的工具,而不是按处理时间进行窗口化,因为处理历史事件时,处理时间毫无意义(参阅“时间推理”)。例如,Apache Beam提供了用于表达这种计算的API,然后可以使用Apache Flink或Google Cloud Dataflow运行。