第13章 流式系统哲学
如果船长的最高目标是保全他的船,他就永远把它留在港口。
——圣托马斯·阿奎那,《神学大全》(1265–1274)
原书第539页
在第2章中,我们讨论了创建可靠、可扩展且可维护的应用程序与系统的目标。这些主题贯穿了所有章节——例如,我们讨论了许多提升可靠性的容错算法、提升可扩展性的分片方法,以及提升可维护性的演进与抽象机制。
在本章中,我们将汇集所有这些思想,并特别建立在第12章的流式/事件驱动架构理念之上,以提出一种实现上述目标的应用程序开发哲学。本章比前几章更具倾向性,深入探讨一种特定的哲学,而非比较多种方法。
数据集成
本书反复出现的一个主题是:对于任何给定的问题,都有多种解决方案,每种方案各有优缺点和权衡。例如,在讨论第4章的存储引擎时,我们考察了日志结构存储、B树和列式存储。在讨论第6章的复制时,我们考虑了单主、多主和无主方法。
原书第539页(接上)
如果你有一个像“我想存储一些数据并在以后检索它”这样的问题,没有唯一正确的解决方案,而是有许多适用于不同场景的方法。软件实现通常必须选择一种特定的方法。让一条代码路径健壮且高效已经很困难;试图通过大量特性来满足太多用例,往往会导致这些特性的实现不如专用工具。
因此,软件工具的最恰当选择也取决于具体场景。每一款软件——即使是所谓的“通用”数据库——都是为特定的使用模式而设计的。
面对如此多的备选方案,第一个挑战是找出软件产品与其良好适配场景之间的映射。供应商当然不愿意告诉你他们的软件不适合哪些工作负载,但希望前面章节已经让你具备了提出关键问题的能力,能帮助你读懂言外之意,更好地理解各种权衡。
然而,即使你完全理解工具与使用场景之间的映射,还有另一个挑战:在复杂的应用中,数据通常以多种方式被使用,一款软件不太可能适合所有情况。因此,你最终不可避免地需要拼凑多款软件来提供应用的功能。
通过衍生数据组合专用工具
举一个例子,通常需要将在线事务处理(OLTP)数据库与全文搜索索引集成,以处理任意关键字的查询。尽管某些数据库(如PostgreSQL)包含全文索引功能,足以满足简单应用 [1],但更复杂的搜索功能需要专业的信息检索工具。相反,搜索索引通常不适合作为持久化的记录系统。因此,许多应用需要结合两种工具来满足所有需求。
我们在第501页的“保持系统同步”中提到了数据系统集成的问题。随着数据表示形式的增加,集成问题变得更加困难。除了数据库和搜索索引,你可能还需要在分析系统(数据仓库、批处理或流处理系统)中保留数据的副本;维护从原始数据衍生的缓存或反规范化对象;将数据传递给机器学习、分类、排序或推荐系统;或者基于数据变更发送通知。
数据流推理
当为了满足多种访问模式而需要在多个存储系统中维护同一数据的副本时,你需要非常清楚输入和输出。数据首先写在哪里?哪些表示是从哪些源衍生出来的?如何将数据以正确格式放入所有正确的位置?
原书第540页
例如,你可以安排数据首先写入记录系统数据库,然后对该数据库的变更被捕获(参见第503页的“变更数据捕获”),并按相同顺序应用到搜索索引。如果变更数据捕获(CDC)是更新索引的唯一方式,你就可以确信索引完全从记录系统衍生而来,因此与记录系统一致(除非软件存在缺陷)。写入数据库是向该系统提供新输入的唯一方式。
允许应用程序直接同时写入搜索索引和数据库会引入图12-4所示的问题:两个客户端并发发送冲突的写入,而两个存储系统以不同顺序处理它们。这种情况下,数据库和搜索索引都无法“决定”写入顺序,它们可能做出矛盾的决定,并永久性地不一致。
如果你能将所有用户输入都通过一个决定所有写入顺序的单一系统来输送,那么通过按相同顺序处理写入来衍生数据的其他表示就会变得容易得多。这是我们在第433页“实践中的共识”中看到的状态机复制方法的一种应用。无论你使用CDC还是事件溯源日志,关键原则都是确定一个全序。
基于事件日志更新衍生数据系统通常可以做到确定性和幂等性(参见第528页的“幂等性”),这使得从故障中恢复变得非常简单。
衍生数据与分布式事务
保持数据系统之间一致性的经典方法涉及分布式事务,如第324页的“两阶段提交”中所讨论的。与分布式事务相比,使用衍生数据系统的方法表现如何?
从抽象层面看,它们通过不同手段实现类似目标。分布式事务使用原子提交协议来确保变更原子性地应用,而基于日志的系统通过确定性重试和幂等性来保证正确性。
原书第541页
最大的区别在于:事务系统通常保证在值写入之后,你可以立即读取到最新的值(参见第210页的“读取自己写入”)。而衍生数据系统通常是异步更新的,因此默认情况下不保证读取是最新的。
分布式事务在愿意吸收其性能和运维代价的环境中被成功使用。然而,XA的容错性和性能特性较差(参见第328页的“跨不同系统的分布式事务”),严重限制了其实用性。或许可以设计出更好的分布式事务协议,但要使其被广泛采用并集成到现有工具中将是困难的,而且短期内不太可能实现。
在缺乏对良好分布式事务协议广泛支持的情况下,基于日志的衍生数据是集成不同数据系统最有前景的方法。然而,像“读取自己写入”这样的保证非常有用,简单地告诉所有人“最终一致性是不可避免的——接受并学会处理它”并不富有成效(至少在没有给出如何处理的良好指导时是这样)。
在本章后面,我们将讨论一些在异步衍生系统之上实现更强保证的方法,并朝着分布式事务与异步日志系统之间的中间地带努力。
全序的局限
在足够小的系统中,构建一个全序的事件日志是完全可行的(正如采用单主复制的数据库的流行所证明的,这类数据库恰恰构建了这样的日志)。然而,当系统规模增大并承载更复杂的工作负载时,局限性开始显现:
- 在大多数情况下,构建全序日志需要所有事件都通过一个决定顺序的单一主节点。如果事件吞吐量超过单机处理能力,就需要将日志分片到多台机器上。此时两个分片中事件的顺序是不明确的。
- 如果服务器分布在地理上分散的多个区域——例如,为了容忍整个数据中心离线——通常每个数据中心有一个独立的主节点,因为跨数据中心的同步协调因网络延迟而效率低下。这意味着来自两个不同数据中心的事件没有明确的顺序。
- 当应用以微服务方式部署时,常见的设计选择是将每个服务及其持久状态作为独立单元部署,服务之间不共享持久状态。当两个事件来自不同服务时,这些事件没有定义的顺序。
原书第542页
(以上对应原文第539-542页内容,包括数据集成部分的全部论述。)
第十三章:流式系统的哲学
- 当应用程序以微服务形式部署时,一个常见的设计选择是:将每个服务及其持久化状态部署为独立单元,服务之间不共享持久化状态。当两个事件源自不同的服务时,这些事件没有明确的顺序。
542
- 某些应用程序维护客户端状态,该状态在用户输入后立即更新(无需等待服务器确认),甚至可以在离线状态下继续工作。对于此类应用,客户端和服务器极有可能以不同的顺序看到事件。
从形式上讲,确定事件的全序被称为全序广播,正如我们在“共识的多种面貌”(第427页)中所见,这等价于共识。大多数共识算法设计用于单个节点的吞吐量足以处理整个事件流的场景,并且这些算法没有提供让多个节点分担排序工作(即事件排序)的机制。
对事件进行排序以捕捉因果关系
如果事件之间不存在因果关系,那么缺乏全序并不是一个大问题,因为并发事件可以任意排序。某些其他情况很容易处理——例如,同一个对象的多次更新可以通过将所有针对某个对象 ID 的更新路由到同一个日志分片来实现全序。然而,因果依赖有时会以更微妙的方式出现。
例如,考虑一个社交网络服务,以及两个曾经恋爱但刚刚分手的用户。用户 A 将用户 B 从好友列表中移除,然后向剩余的朋友发送一条抱怨前任的消息。用户的意图是前任不应该看到这条粗鲁的消息,因为消息是在对方的好友状态被撤销之后发送的。
然而,在一个将好友关系存储在一处、消息存储在另一处的系统中,这种在“取消好友”事件和“发送消息”事件之间的排序依赖可能会丢失。如果没有捕捉到这种因果依赖,负责发送新消息通知的服务可能会先处理“发送消息”事件,再处理“取消好友”事件,从而错误地向前任发送通知。
在这个例子中,通知实际上相当于消息与好友列表之间的连接(join),这使得它与我们之前讨论过的连接时序问题相关联(参见“连接的时间依赖性”,第525页)。不幸的是,这个问题似乎没有简单的答案[2,3]。以下是一些可能的起点:
- 逻辑时间戳可以在不需要协调的情况下提供全序(参见“ID 生成器与逻辑时钟”,第417页),因此当全序广播不可行时,它们可能会有所帮助。然而,它们仍然要求接收者处理乱序交付的事件,并且需要传递额外的元数据。
- 如果你能够记录一个事件来捕获用户在做决定之前所看到的系统状态,并赋予该事件一个唯一标识符,那么任何后续事件都可以引用该事件标识符来记录因果依赖关系[4]。
- 冲突解决算法(参见“自动冲突解决”,第226页)有助于处理以意外顺序交付的事件。它们对于维护状态很有用,但如果操作具有外部副作用(例如向用户发送通知),则无济于事。
或许未来会出现一些应用开发模式,能够在不强迫所有事件通过全序广播这个瓶颈的情况下,有效地捕捉因果依赖,并正确地维护衍生状态。
批处理与流处理
数据集成的目标是确保数据最终以正确的形式出现在所有正确的位置。这需要消费输入、转换、连接、过滤、聚合、训练模型、评估,最终写入适当的输出。批处理器和流处理器是实现这个目标的工具。批处理和流处理的输出是衍生数据集,例如搜索索引、物化视图、推荐给用户的内容、聚合指标等。
正如我们在第11章和第12章中所见,批处理和流处理有很多共同的原理。主要的根本区别在于:流处理器处理的是无界数据集,而批处理的输入已知且有限。
维护衍生状态
批处理具有很强的函数式风格(即使代码不是用函数式编程语言编写的)。它鼓励确定性、纯函数,其输出仅取决于输入,并且除了显式输出外没有其他副作用,将输入视为不可变的,输出视为追加式的。流处理类似,但它扩展了运算符,允许管理可容错的状态。
确定性函数具有明确定义的输入和输出,这一原则不仅有利于容错,也简化了对组织中数据流的推理[5]。无论衍生数据是搜索索引、统计模型还是缓存,从数据管道的角度思考都是有益的:通过函数式应用代码,将一个系统中的状态变化推送到衍生系统中。
原则上,衍生数据系统可以同步维护,就像关系数据库在写入被索引的表时,同步更新二级索引(在同一事务中)。然而,异步性使得基于事件日志的系统更为稳健。它允许系统某部分的故障被局部隔离,而分布式事务会在任何一个参与者失败时中止,因此它们倾向于将故障扩散到系统的其余部分,从而放大故障。
544
我们在“分片与二级索引”(第268页)中看到,二级索引通常跨越分片边界。一个带有二级索引的分片系统需要要么将写入发送到多个分片(如果索引是词条分区),要么将读取发送到所有分片(如果索引是文档分区)。这种跨分片的通信也是异步维护索引时最可靠和可扩展的[6]。
为应用演化而重新处理数据
在维护衍生数据时,批处理和流处理都很有用。流处理允许输入的变化以低延迟反映到衍生视图中,而批处理则允许重新处理大量累积的历史数据,以便在现有数据集上派生新的视图。
特别地,重新处理现有数据为维护系统、支持新功能以及应对需求变化提供了一种良好的机制。如果没有重新处理,模式演化仅限于简单的变更,例如为记录添加新的可选字段或添加新类型的记录。另一方面,通过重新处理,可以将数据集重构为完全不同的模型,以更好地满足新需求。
铁路上的模式迁移
大型的“模式迁移”也发生在非计算机系统中。例如,在19世纪早期英国的铁路建设中,存在多种相互竞争的轨距(两条铁轨之间的距离)标准。为一种轨距建造的列车无法在另一种轨距的轨道上运行,这限制了铁路网络中可能的互连[7]。
在1846年最终确定单一标准轨距之后,其他轨距的轨道需要进行转换——但如何在不关闭铁路线数月或数年的情况下做到这一点?解决方案是首先通过添加第三条轨距将轨道转换为双轨距或混合轨距。这种转换可以逐步进行,完成后,两种轨距的列车都可以在线路上运行,使用三条轨道中的两条。最终,当所有列车都转换为标准轨距后,提供非标准轨距的那条轨道就可以被移除。
通过这种方式“重新处理”现有轨道,允许新旧版本并存,使得轨距的更改可以在数年内逐步完成。尽管如此,这项工程仍然昂贵,这就是为什么至今仍存在非标准轨距。例如,旧金山湾区的BART系统使用的轨距与美国大部分地区不同。
衍生视图允许逐步演化
如果你想重构一个数据集,不必将其作为一次性的突然切换来执行。相反,你可以将旧模式和新模式作为同一底层数据的两个独立衍生视图,并行维护。然后,你可以开始将少量用户迁移到新视图,以测试其性能并发现任何错误,同时大多数用户继续路由到旧视图。逐步地,你可以增加访问新视图的用户比例,最终弃用旧视图[8,9]。
这种渐进式迁移的美妙之处在于:如果出现问题,过程中的每个阶段都容易回滚;你始终有一个可以回退的工作系统。降低不可逆损害的风险,让你更有信心继续推进,从而更快地改进系统[10]。
统一批处理和流处理
统一批处理和流处理的早期提议是Lambda架构[11],但它存在许多问题[12],目前已不再使用。较新的系统允许将批处理计算(重新处理历史数据)和流计算(事件到达时处理)实现在同一个系统中[13]——这种方法有时被称为Kappa架构[12]。
在一个系统中统一批处理和流处理需要以下特性:
- 能够通过同一处理引擎重放历史事件,该引擎也处理近期事件的流。例如,基于日志的消息代理具有重放消息的能力,而某些流处理器可以从分布式文件系统或对象存储中读取输入。
- 流处理器的恰好一次语义——即确保输出与没有发生故障时相同,即使实际上发生了故障。与批处理一样,这需要丢弃任何失败任务的部分输出。
- 按事件时间(而非处理时间)进行窗口化处理的工具,因为在重新处理历史事件时,处理时间是毫无意义的。例如,Apache Beam 提供了一个用于表达此类计算的 API,然后可以在 Apache Flink 或 Google Cloud Dataflow 上运行。
解绑数据库
在抽象层面上,数据库、批处理/流处理器和操作系统都执行相同的功能:它们存储一些数据,并允许你处理和查询这些数据[14,15]。数据库将数据存储在某种数据模型的记录中(表中的行、文档、图中的顶点等),而操作系统的文件系统将数据存储在文件中——但它们的核心都是“信息管理”系统[16]。正如我们在第11章中所见,批处理器就像是 Unix 的分布式版本。
实际上,它们之间存在许多实际差异。例如,许多文件系统无法很好地处理包含1000万个小文件的目录,而数据库则可以。
546
第十三章:流式系统的哲学
组合数据存储技术
在本书中,我们讨论了数据库提供的各种特性及其工作原理,包括:
- 二级索引,允许你根据字段的值高效搜索记录
- 物化视图,一种预计算的查询结果缓存
- 复制日志,保持其他节点上的数据副本为最新状态
- 全文搜索索引,允许在文本中进行关键词搜索,内置于某些关系数据库中 [1]
在第11章和第12章中,类似的主题出现了;我们讨论了构建全文搜索索引、维护物化视图以及通过CDC将数据库的变更复制到派生数据系统。
数据库内建特性与人们使用批处理和流处理器构建的派生数据系统之间存在许多相似之处。
创建索引
想一想当你运行 CREATE INDEX 在关系数据库中创建新索引时发生了什么。数据库必须扫描表的一致性快照,挑出所有被索引的字段值,排序并写出索引。然后,它必须处理自一致性快照被获取以来产生的写入积压(假设创建索引时表未被锁定,因此写入可以继续)。一旦完成,每当事务写入表时,数据库都必须持续保持索引的更新。
这个过程与设置一个新的从副本(参见“设置新的从副本”第201页)非常相似,也非常类似于在流式系统中引导CDC(参见“初始快照”第504页)。
每当你运行 CREATE INDEX,数据库本质上重新处理现有数据集,并将索引推导为现有数据的一个新视图。现有数据可能是一个状态快照,而不是所有过往变更的日志,但两者紧密相关。
关于一切的无数据库
从这个角度来看,整个组织中的数据流开始看起来像一个巨大的数据库 [5]。每当一个批处理、流处理或ETL过程将数据从一个位置和形式传输到另一个位置和形式,它就像数据库子系统那样工作,保持索引或物化视图的更新。
这样看来,批处理器和流处理器就像是触发器、存储过程和物化视图维护算法的复杂实现。它们维护的派生数据系统则像是不同的索引类型。例如,一个关系数据库可能支持B树索引、哈希索引、空间索引和其他类型的索引。在派生的数据系统这种新兴架构中,这些功能不再是单一集成数据库产品的特性,而是由不同的软件提供,运行在不同的机器上,由不同的团队管理。
这些发展将带领我们走向何方?如果我们从这样的前提出发——没有单一的数据模型或存储格式能适合所有访问模式——那么有两种途径可以让不同的存储和处理工具组合成一个内聚的系统:
-
联邦数据库(统一读取): 提供一个统一的查询接口,适用于多种底层存储引擎和处理方式——这种方法称为联邦数据库或多模式数据库 [17, 18]。例如,PostgreSQL的外部数据封装器特性符合这种模式,联邦查询引擎如Trino、Hoptimator和Xorq也类似。需要专用数据模型或查询接口的应用程序仍然可以直接访问底层存储引擎,而希望组合来自不同位置数据的用户可以通过联邦接口轻松实现。
联邦查询接口遵循关系数据库的传统:一个集成的系统,具有高级查询语言和优雅的语义,但实现复杂。
-
解绑数据库(统一写入): 虽然联邦能够实现跨多个系统的只读查询,但它对于同步这些系统之间的写入却没有好的答案。我们说过,在单个数据库中,创建一致性索引是内建特性。当我们组合多个存储系统时,同样需要确保所有数据变更最终出现在正确的位置,即使面对故障也不例外。使存储系统的可靠插拔更加容易(例如,通过CDC和事件日志),就像是将数据库的索引维护特性解绑出来,以便能够同步不同技术之间的写入 [5, 19]。
解绑方法遵循Unix的传统:小工具做好一件事 [20],通过统一的底层API(管道)进行通信,并且可以使用高级语言(shell)进行组合 [14]。
使解绑工作
联邦和解绑是同一枚硬币的两面:从多样化的组件构建出一个可靠、可扩展且可维护的系统。联邦的只读查询需要将一种数据模型映射到另一种,这需要一些思考,但最终是一个相当可控的问题。保持对多个存储系统的写入同步是更困难的工程问题,因此我们将重点放在这里。
传统的同步写入方法需要跨异构存储系统的分布式事务 [17],正如前面讨论的那样,这存在问题。在单个存储或流处理系统内的事务是可行的,但当数据跨越不同技术的边界时,带有幂等写入的异步事件日志是一种更健壮、更实用的方法。
例如,一些流处理器内部使用分布式事务来实现精确一次语义,这可以工作得很好。然而,当事务需要涉及由不同人群编写的系统时(例如,数据从流处理器写入分布式键值存储或搜索索引),缺乏标准化的事务协议会使集成困难得多。带有幂等消费者的事件的有序日志是一种更简单的抽象,因此更容易在异构系统中实现 [5]。
基于日志的集成的一大优势是各组件之间的松耦合,这体现在两个方面:
-
系统层面:异步事件流使整个系统在面对单个组件的中断或性能下降时更加健壮。如果消费者运行缓慢或失败,事件日志可以缓冲消息,允许生产者和任何其他消费者不受影响地继续运行。故障消费者在修复后可以追赶,不会丢失任何数据,并且故障被隔离。相比之下,分布式事务的同步交互往往将局部故障升级为大规模故障。
-
人员层面:解绑数据系统允许软件组件和服务由不同团队独立开发、改进和维护。专业化使每个团队能够专注于做好一件事,并具有与其他团队系统定义良好的接口。事件日志提供了一个接口,它足够强大以捕获相当强的一致性特性(由于事件的持久性和顺序),同时也足够通用,几乎适用于任何类型的数据。
解绑系统与集成系统之比较
如果解绑确实成为未来的方向,它并不会取代当前形式的数据库。数据库仍然像以前一样需要,用于在流处理器中维护状态,以及为批处理和流处理器的输出提供查询服务。专门的查询引擎对于特定的工作负载仍然很重要——例如,数据仓库中的查询引擎针对探索性分析查询进行了优化,并能很好地处理这类负载。
运行多个基础设施组件的复杂性可能是一个问题。每款软件都有学习曲线、自身的配置问题和运维怪癖,因此值得尽可能部署少数活动部件。一个集成软件产品可能在其设计的工作负载上实现更好、更可预测的性能,而由应用程序代码组合的几个工具构成的系统则未必 [21]。
为不需要的规模进行构建是浪费精力,并且可能将你锁定在一个僵化的设计中。实际上,这是一种过早优化。
解绑的目标不是在特定工作负载上与单个数据库竞争性能;解绑的目标是允许你组合多个数据库,以便在比单一软件所能支持的更广泛工作负载上实现良好性能。这是关于广度,而不是深度。
因此,如果一个单一技术能满足你所有需求,你最可能的选择是直接使用该产品,而不是尝试从底层工具重新实现它。
第十三章:流式系统的哲学
只有当单一软件无法满足所有需求时,解绑与组合的优势才得以显现。
组合数据系统的工具正日益成熟。Debezium 可从众多数据库中提取变更流,Kafka 的协议正成为事件流的去事实标准,而增量视图维护引擎(参见第 516 页的“增量视图维护”)使得预计算和更新复杂查询的缓存成为可能。
围绕数据流设计应用程序
当底层数据发生变化时更新派生数据这一通用理念并非新事物。例如,电子表格拥有强大的数据流编程能力 [22]:你可以在一个单元格中放置公式(例如,另一列中单元格的总和),每当公式的任何输入发生变化时,公式的结果都会自动重新计算。这正是我们在数据系统层面所期望的。当数据库中的记录发生变化时,我们希望该记录的任何索引都能自动更新,并且依赖于该记录的任何缓存视图或聚合都能自动刷新。我们不必担心如何实现这种刷新的技术细节,只需信任它能正确工作即可。
因此,大多数数据系统仍有待向 VisiCalc 早在 1979 年就已具备的特性学习 [23]。与电子表格的不同之处在于,当今的数据系统需要具备容错性、可扩展性以及持久存储数据的能力。它们还必须能够集成由不同团队在不同时期编写的异构技术,并利用现有的库和服务。期望所有软件都使用同一种语言、框架或工具来开发是不现实的。
在本节中,我们将扩展这些想法,并探讨围绕解绑数据库和数据流来构建应用程序的一些方法。
作为派生函数的应用程序代码
当一个数据集从另一个数据集派生而来时,它会经过某种转换函数。例如:
- 二级索引 是一种派生数据集,其转换函数相对直接——对于基表中的每一行或每个文档,它提取被索引的列或字段的值,并按这些值排序(假设是 SSTable 或 B-tree 索引,它们都按键排序)。
- 全文搜索索引 是通过应用多种自然语言处理函数(例如语言检测、分词、词干提取或词形还原、拼写纠正和同义词识别)创建的,然后构建用于高效查找的数据结构(例如倒排索引)。
- 在机器学习系统中,我们可以将模型视为从训练数据中通过应用各种特征提取和统计分析函数而派生而来的。当模型应用于新的输入数据时,其输出是从该输入及其学习到的参数(从而间接地从训练数据)派生得到的。
- 缓存 通常包含以最终在 UI 中显示的形式聚合的数据。因此,填充缓存需要知道 UI 中引用了哪些字段;UI 的变化可能需要更新缓存填充的定义并重建缓存。
二级索引的派生函数是如此常用,以至于许多数据库将其作为核心功能内置,你只需运行 CREATE INDEX 即可调用它。对于全文索引,常见语言的基本语言特征可能内置于数据库中,但更复杂的特征通常需要特定领域的调优。在机器学习中,特征工程是出了名的与应用高度相关,并且通常需要融入有关用户交互和应用程序部署的详细知识 [24]。
当创建派生数据集的函数不是创建二级索引那样的标准“一刀切”函数时,就需要自定义代码来处理特定应用方面。这种自定义代码正是许多数据库难以应对之处。尽管关系数据库通常支持触发器、存储过程和用户自定义函数,可用于在数据库内部执行应用程序代码,但这些功能在数据库设计中多少有些事后添加的性质。
应用程序代码与状态的分离
理论上,数据库可以成为任意应用程序代码的部署环境,就像操作系统一样。然而在实践中,它们被证明并不适合这个目的。它们与现代应用程序开发的要求(如依赖管理和包管理、版本控制、滚动升级、可演进性、监控、指标、网络服务调用以及外部系统集成)不太契合。
另一方面,诸如 Kubernetes、Docker、Mesos、YARN 等部署和集群管理工具正是为运行应用程序代码而设计的。通过专注于做好一件特定事情,它们能够比那些将用户自定义函数执行作为众多功能之一的数据库做得更好。
如今大多数 Web 应用程序都作为无状态服务部署,其中任何用户请求都可以路由到任意应用服务器,服务器在发送响应后会忘记关于该请求的一切。这种部署风格允许随意添加或移除服务器,十分便捷——但状态必须放在某个地方(通常是数据库)。趋势一直是将无状态的应用逻辑与状态管理(数据库)分开:不在数据库中放入应用逻辑,也不在应用中放入持久状态 [25]。正如函数式编程社区的人喜欢开玩笑说的那样:“我们相信‘教会与国家的分离’”[26]。
TIP
解释一个笑话通常会破坏它,但这里还是解释一下,以免有人感到被排除在外。Church 指的是数学家 Alonzo Church,他创建了 λ 演算,这是一种早期的计算形式,也是大多数函数式编程语言的基础。λ 演算没有可变状态(即没有可被覆盖的变量),因此可以说可变状态与 Church 的工作是分离的。
在这种典型的 Web 应用模型中,数据库充当一种可在网络上同步访问的可变共享变量。应用程序可以读取和更新该变量,数据库则负责使其持久化,并提供一定的并发控制和容错能力。
然而,在大多数编程语言中,你无法订阅可变变量的变化——只能定期读取它。与电子表格不同,变量的读取者不会在变量值变化时收到通知。(你可以在自己的代码中实现此类通知——这被称为观察者模式——但大多数语言并未将此模式作为内置特性。)
数据库继承了这种对可变数据的被动处理方式。如果你想了解数据库内容是否发生了变化,通常唯一的选择就是轮询(即定期重复查询)。订阅变化才刚刚开始作为一项特性出现。
数据流:状态变更与应用程序代码之间的相互作用
从数据流的角度思考应用程序意味着重新协商应用程序代码与状态管理之间的关系。我们不再将数据库视为由应用程序操作的被动变量,而是更多地考虑状态、状态变更以及处理它们的代码之间的相互作用与协作。应用程序代码通过触发一处的状态变更来响应另一处的状态变更。
我们已经在 CDC、Actor 模型、触发器和增量视图维护中看到了这一思想。解绑数据库意味着将其应用于在主数据库之外创建派生数据集:缓存、全文搜索索引、机器学习或分析系统。我们可以为此目的使用流处理和消息系统。
维护派生数据需要以下属性,而这些属性正是基于日志的消息代理所能提供的:
- 在维护派生数据时,状态变更的顺序通常很重要(如果多个视图是从同一个事件日志派生出来的,它们需要以相同顺序处理事件,以便彼此保持一致)。
- 容错性至关重要——丢失一条消息就会导致派生数据集与其数据源永久失步。消息传递和派生状态更新都必须可靠。
稳定的消息排序和容错的消息处理是相当严格的要求,但它们比分布式事务成本更低,操作上也更健壮。现代流处理器可以在大规模下提供这些排序和可靠性保证,并允许应用程序代码作为流运算符运行。
这些应用程序代码可以执行数据库内置派生函数通常无法提供的任意处理。就像通过管道链接的 Unix 工具一样,流运算符可以组合起来,围绕数据流构建大型系统。每个运算符将状态变更流作为输入,并产生其他状态变更流作为输出。
流处理器与服务
当前主流的应用程序开发风格将功能拆分为一组通过同步网络请求(例如 REST API)进行通信的服务。这种面向服务的架构相比单一单体应用的优势主要在于通过松散耦合实现的组织可扩展性。不同的团队可以处理不同的服务,这减少了团队间的协调工作量(只要这些服务可以独立部署和更新)。
将流运算符组合成数据流系统与微服务方法有许多相似之处 [27, 28]。然而,底层的通信机制截然不同:是单向的、异步的消息流,而不是同步的请求/响应交互。
除了第 189 页“事件驱动架构”中列出的优点(例如更好的容错性)之外,数据流系统相比传统 REST API 或 RPC 还可以实现更好的性能。例如,假设客户正在购买一件商品,该商品以一种货币定价,但以另一种货币支付。要进行货币转换,你需要知道当前的汇率。此操作可以通过两种方式实现 [27, 29]:
(注意:原文在此处被截断,后续内容未提供。根据上下文,这里应继续列举两种实现方式,但原始文本以“554 |”和章节标题结束,可能属于页面分隔。由于给定文本到此为止,我们保留原样。)
第13章:流式系统的哲学
解除数据库绑定
观察派生状态
在抽象层面上,前一节讨论的数据流系统为你提供了创建派生数据集(例如搜索索引、物化视图和预测模型)并使其保持最新状态的过程。我们将这个过程称为写入路径。每当一条信息被写入系统时,它可能会经历多个批处理和流处理阶段,最终每个派生数据集都会更新以包含写入的数据。图13-1展示了更新搜索索引的示例。
图13-1. 在搜索索引中,写入(文档更新)与读取(查询)交汇。
但是,你首先为什么要创建派生数据集呢?很可能是因为你希望稍后再次查询它。这就是读取路径:在服务用户请求时,你从派生数据集中读取数据,可能对结果进行更多处理,然后构建对用户的响应。
总体而言,写入路径和读取路径涵盖了数据的整个旅程,从数据被收集到被消费(可能由另一个人)的点。写入路径是旅程中预先计算的部分;它在数据到达时立即执行,无论是否有人要求查看它。读取路径是旅程中仅在有人要求时才发生的部分。如果你熟悉函数式编程语言,你可能会注意到写入路径类似于急切求值,而读取路径类似于惰性求值。
派生数据集是写入路径和读取路径交汇的地方,如图13-1所示。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。
物化视图和缓存
全文搜索索引是一个很好的例子:写入路径更新索引,而读取路径搜索索引中的关键字。读取和写入都需要做一些工作。写入需要为文档中出现的所有词条更新索引条目。读取需要搜索查询中的每个单词,并应用布尔逻辑来找到包含查询中所有单词(AND 运算符)或每个单词的任何同义词(OR 运算符)的文档。
如果没有索引,搜索查询就必须扫描所有文档(类似于 grep),如果你有大量文档,这会变得非常昂贵。没有索引意味着写入路径上的工作量较少(无需更新索引),但读取路径上的工作量却大得多。
另一方面,你可以想象预先计算所有可能查询的搜索结果。在这种情况下,读取路径上的工作量会减少:不需要布尔逻辑,只需找到你查询的结果并返回即可。然而,写入路径会昂贵得多。可能被提出的搜索查询集合是无限的(或者至少是文档集中词条数的指数级),因此预先计算所有可能的搜索结果是不可能的。
另一种选择是仅为最常用查询的固定集合预先计算搜索结果,这样它们可以快速服务,而无需访问索引。不常见的查询仍然可以通过索引来服务。这通常被称为常见查询缓存,尽管我们也可以将其称为物化视图,因为当出现应该包含在某个常见查询结果中的新文档时,它需要被更新。
从这个例子中,我们可以看到索引并不是写入路径和读取路径之间唯一可能的边界。常见搜索结果的缓存是可能的,而如果文档数量很少,不使用索引的类似 grep 的扫描也是可能的。这样看来,缓存、索引和物化视图的作用很简单:它们改变了读取路径和写入路径之间的边界。它们允许我们在写入路径上做更多的工作,通过预先计算结果,从而节省读取路径上的努力。
事实上,我们在“案例研究:社交网络主页时间线”(第34页)中看到的社会网络示例的主题就是改变写入路径和读取路径之间工作的边界。在该示例中,我们还看到了名人与普通用户之间的写入路径和读取路径边界是如何不同的。在500页之后,我们又回到了原点!
有状态的、支持离线的客户端
关于写入路径和读取路径之间边界的概念很有趣,因为我们可以讨论移动该边界,并探讨这种移动在实际中意味着什么。让我们在不同的上下文中考虑它。
在过去,Web浏览器是无状态客户端,只有当你有互联网连接时才能做有用的事情(离线时你唯一能做的事情就是在之前在线时加载的页面中上下滚动)。然而,单页面的JavaScript Web应用现在拥有许多有状态的能力,包括客户端的UI交互和Web浏览器中的持久性本地存储。移动应用同样可以在设备上存储大量状态,并且大多数用户交互不需要往返服务器。
在“同步引擎和本地优先软件”(第220页)中,我们看到了持久性本地状态如何支持一类应用,用户可以在没有互联网连接的情况下离线工作,并在网络连接可用时在后台与远程服务器同步[30]。由于移动设备有时具有缓慢且不可靠的蜂窝网络连接,如果UI不必等待同步网络请求,并且应用在离线时大多也能工作,这对用户来说是一个很大的优势。
当我们摆脱关于无状态客户端与中央数据库通信的假设,转向在最终用户设备上维护状态时,一个充满新机遇的世界就展开了。特别是,我们可以将设备上的状态视为服务器上状态的缓存。屏幕上的像素是客户端应用中模型对象的物化视图;模型对象是远程数据中心中状态的本地副本[31]。
将状态变更推送到客户端
如果你在Web浏览器中加载一个典型的网页,然后数据在服务器端发生更改,浏览器在重新加载页面之前不会发现更改。浏览器只在某个时间点读取数据,假设它是静态的;它不会订阅来自服务器的更新。因此,浏览器中的状态是一个过时的缓存,除非你显式轮询更改,否则不会更新。(基于HTTP的订阅协议如RSS实际上只是轮询的基本形式。)
较新的协议已经超越了HTTP基本的请求/响应模式。服务器发送事件(EventSource API)和WebSocket提供了通信通道,通过它们,Web浏览器可以保持与服务器的打开TCP连接,并且服务器可以在连接保持期间主动向浏览器推送消息。这为服务器主动通知最终用户客户端关于其本地存储状态的任何更改提供了机会,从而减少了客户端状态的过时性。
根据我们的写入路径和读取路径模型,将状态变更一直主动推送到客户端设备意味着将写入路径延伸到最终用户。当客户端首次初始化时,它仍然需要使用读取路径来获取其初始状态,但此后它可以依赖由服务器发送的状态变更流。因此,我们围绕流处理和消息传递讨论的想法并不局限于在数据中心内运行;我们可以将它们进一步扩展,一直延伸到最终用户设备[32]。
设备有时会离线,在那段时间内将无法收到服务器发送的任何状态变更通知。但我们已经解决了这个问题;在“消费者偏移量”(第498页)中,我们讨论了基于日志的消息代理的消费者如何在失败或断开连接后重新连接,并确保它不会错过在断开连接期间到达的任何消息。
第13章:流式系统的哲学
同样的技术也适用于个人用户,其中每个设备都是一个订阅少量事件流的小型订阅者。
端到端事件流
用于开发有状态客户端和UI的工具(如React和Elm [33])已经具备了根据底层状态变化更新渲染UI的能力。将这种编程模型扩展为允许服务器将状态变更事件推送到此客户端事件管道中,会非常自然。
状态变更随后可以通过端到端的写路径流动:从触发变更的一个设备上的交互开始,经过事件日志和各种衍生数据系统及流处理器,最终到达另一个设备上的用户界面。这些状态变更可以以相当低的延迟传播——例如,端到端低于一秒。
某些应用(如即时通讯和在线游戏)已经拥有这样的“实时”架构(指低延迟交互,而非响应时间保证)。为什么我们不把所有应用都按这种方式构建呢?
挑战在于无状态客户端和请求/响应交互的假设已经深深植根于我们的数据库、库、框架和协议中。许多数据存储支持返回单个响应的读取和写入操作,但支持随时间返回响应流(即订阅更改的能力)的操作则少得多。
为了将写路径一直延伸到最终用户,我们需要从根本上重新思考许多系统的构建方式,从请求/响应交互转向发布/订阅数据流[31]。这需要付出努力,但好处是使UI更具响应性,并提供更好的离线支持。
读取也是事件
我们之前讨论过,当流处理器将衍生数据写入存储(数据库、缓存或索引)并且该存储被查询时,该存储充当写路径和读路径之间的边界。它允许对数据进行随机访问读取查询,否则就需要扫描整个事件日志。
在许多情况下,数据存储是与流式系统分开的。然而,回想一下,流处理器也需要维护状态以执行聚合和连接。这个状态通常隐藏在流处理器内部,但一些框架允许外部客户端查询它[34],从而将流处理器本身转变为一种简单的数据库。
让我们进一步探讨这个想法。在我们目前讨论的模型中,对存储的写入通过事件日志进行,而读取则是直接发送到存储被查询数据的节点的瞬态网络请求。这是一个合理的设计,但不是唯一可能的设计。也可以将读取请求表示为事件流,并通过流处理器发送读取事件和写入事件。处理器通过将读取结果发送到输出流来响应读取事件[35]。
当写入和读取都被表示为事件并路由到同一个流操作符时,我们实际上是在执行读取查询流与数据库之间的流表连接。每个读取事件都需要发送到持有相关数据的数据库分片,就像批处理处理器和流处理器在执行连接时对同一键进行共分区一样。
服务请求与连接之间的这种对应关系非常基础[36]。一个一次性读取请求通过连接操作符,然后立即忘记该请求;一个订阅请求是一个持久的连接,与连接另一侧的过去和未来事件保持连接。
记录读取事件日志也可能在跟踪系统中的因果依赖性和数据来源方面带来好处。日志可以让你重建用户在做出特定决定之前看到的内容。例如,在线商店中,显示给客户的预测发货日期和库存状态可能会影响他们是否选择购买某件商品[4]。要分析这种关联,你需要记录用户对发货和库存状态的查询结果。
因此,将读取请求写入持久存储可以更好地跟踪因果关系,但也会产生额外的存储和I/O成本。优化此类系统以减少开销仍然是一个开放的研究问题[2],但如果你已经出于操作目的将读取请求作为请求处理的副作用进行日志记录,那么将该日志作为请求的来源并不是一个大的改变。
多分片数据处理
对于只涉及单个分片的查询,通过流发送它们并收集响应流的工作量可能过于繁琐。然而,这个想法为需要组合来自多个分片的数据的复杂查询的分布式执行打开了可能性,利用了流处理器已经提供的消息路由、分片和连接基础设施。
Storm的分布式RPC功能支持这种使用模式。例如,它已被用于计算社交网络上看到某个URL的人数——即发布该URL的每个人的关注者集合的并集[37]。由于用户集合是分片的,这种计算需要合并来自许多分片的结果。
这种模式的另一个例子出现在欺诈预防中。为了评估某个购买事件是否存在欺诈风险,你可以检查用户IP地址、电子邮件地址、账单地址、送货地址等的信誉评分。这些信誉数据库各自是分片的,因此为某个特定购买事件收集评分需要与不同分片的数据集进行一系列连接[38]。
数据仓库查询引擎的内部查询执行图具有类似的特征。如果你需要执行这种多分片连接,使用提供此功能的数据库可能比使用流处理器实现更简单。然而,将查询视为流为构建超越传统现成解决方案限制的大规模应用提供了一种选择。
追求正确性
对于只读取数据的无状态服务,如果出现问题,影响不大;你可以修复错误并重新启动服务,一切恢复正常。而有状态系统(如数据库)则不那么简单。它们被设计为永久记住事物(或多或少),因此如果出现错误,影响也可能永久存在——这意味着需要更仔细的思考[39]。
我们希望构建可靠且正确的应用程序(即即使在各种故障下,其语义也被明确定义和理解)。大约四十年来,原子性、隔离性和持久性的事务属性一直是构建正确应用程序的首选工具。然而,这些基础比它们看起来要弱:例如,弱隔离级别的混淆(见第288页的“弱隔离级别”)。
在某些领域,事务已被完全放弃,转而采用提供更好性能和可伸缩性但语义更混乱的模型。一致性经常被讨论,但定义得很模糊。有些人声称我们应该“拥抱弱一致性”以获得更好的可用性,但对其在实践中意味着什么缺乏清晰的认识。
对于如此重要的话题,我们的理解和工程方法出奇地脆弱。例如,很难确定运行特定应用程序使用特定事务隔离级别或复制配置是否安全[40, 41]。通常,简单的解决方案在并发性低且没有故障时似乎工作正常,但在更苛刻的情况下却暴露出许多微妙的错误。
例如,Kyle Kingsbury的Jepsen实验[42]突出了某些产品声称的安全保证与其在网络问题和崩溃情况下的实际行为之间的巨大差异。即使像数据库这样的基础设施产品没有问题,应用程序代码仍然需要正确使用它们提供的功能,如果配置难以理解(如弱隔离级别、仲裁配置等),则容易出错。
如果你的应用可以容忍偶尔以不可预测的方式损坏或丢失数据,那么生活要简单得多,你也许可以只是祈祷好运。如果你需要更强的正确性保证,可序列化和原子提交是公认的方法,但它们是有代价的。它们通常只在单个数据中心内工作(排除了地理分布式架构),并限制了你能达到的可伸缩性和容错特性。
虽然传统的事务方法不会消失,但它并不是使应用程序正确并抵御故障的终结方案。在本节中,我们将探讨在数据流架构背景下思考正确性的其他方式。
数据库的端到端论证
仅仅因为一个应用程序使用了提供相对较强安全属性(如可序列化事务)的数据系统,并不能保证该应用免于数据丢失或损坏。例如,如果应用程序有一个导致写入错误数据或从数据库中删除数据的错误,可序列化事务无法拯救你。这是一个支持不可变和仅追加数据的论点,因为如果消除了错误代码破坏好数据的能力,从这类错误中恢复会更容易。
尽管不可变性很有用,但它本身并不是万能药。让我们看一个更微妙的数据损坏发生例子。
操作的恰好一次执行
在第328页的“跨不同系统的分布式事务”中,我们在消息处理的背景下引入了恰好一次(或有效一次)语义的概念。这个想法是:如果在处理消息时出现问题,你可以放弃(通过丢弃消息导致数据丢失)或重试。如果重试,则存在第一次处理实际上成功但你未收到确认的风险,因此消息最终被处理两次。
处理两次是一种数据损坏形式:对一个客户收取两次相同的服务费用(多收费)或对计数器加两次(夸大度量)是不受欢迎的。在这种背景下,恰好一次意味着安排计算,使得最终效果与没有发生故障时相同,即使操作因故障而被重试。我们讨论了几种实现此目标的方法。
最有效的方法之一是使操作幂等——即确保无论执行一次还是多次,其效果相同。
第13章:流式系统的哲学
本章主题
目标正确性 (Aiming for Correctness)
重复抑制 (Duplicate suppression)
需要抑制重复的数据模式不仅出现在流处理中。例如,TCP使用数据包上的序列号来在接收端正确排序,并确定网络上是否丢失或重复了任何数据包。任何丢失的数据包都会被重传,重复数据包在TCP栈将数据交给应用程序之前被移除。然而,这种重复抑制仅在单个TCP连接上下文中有效。
想象一下,TCP连接是客户端到数据库的连接,并且它正在执行示例13-1中的事务。在许多数据库中,事务与客户端连接绑定(如果客户端发送多个查询,数据库知道它们属于同一个事务,因为它们在同一个TCP连接上发送)。如果客户端在发送COMMIT之后但在收到数据库服务器的响应之前遭遇网络中断和连接超时,它不知道事务是已提交还是已中止(我们在图9-1中看到过这种情况)。
示例13-1. 从一个账户向另一个账户的非幂等转账
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance + 11.00 WHERE account_id = 1234;
UPDATE accounts SET balance = balance - 11.00 WHERE account_id = 4321;
COMMIT;客户端可以重新连接到数据库并重试事务,但它现在处于TCP重复抑制的范围之外。由于示例13-1中的事务不是幂等的,可能转账22美元而不是期望的11美元。因此,即使这样的代码是事务原子性的标准示例,它也是不正确的,真正的银行不会这样工作 [3]。
2PC协议(参见第324页的“两阶段提交”)打破了TCP连接与事务之间的一一映射,因为它们必须允许事务协调器在网络故障后重新连接到数据库,并告知它是否要提交或中止一个存疑的事务。这足以确保事务只执行一次吗?不幸的是,不能。
即使我们能够抑制数据库客户端和服务器之间的重复事务,我们仍然需要担心最终用户设备与应用服务器之间的网络。例如,如果最终用户客户端是Web浏览器,它可能使用HTTP POST请求向服务器提交指令。也许用户的蜂窝数据连接很弱,他们成功发送了POST请求,但在收到服务器响应之前失去了信号。在这种情况下,用户可能会看到错误消息,并可能手动重试。Web浏览器会警告:“您确定要再次提交此表单吗?”——用户回答“是”,因为他们希望操作发生。(Post/Redirect/Get模式 [43] 在正常操作中避免了此警告消息,但如果POST请求超时,它也无济于事。)从Web服务器的角度看,重试是一个单独的请求;从数据库的角度看,它是一个单独的事务。通常的去重机制不起作用。
唯一标识请求 (Uniquely identifying requests)
要使一个请求经过多跳网络通信后幂等,仅依赖数据库提供的事务机制是不够的。你需要考虑请求的端到端流程。
例如,你可以为每个请求生成一个唯一标识符(如UUID),并将其作为隐藏表单字段包含在客户端应用程序中,或者计算所有相关表单字段的哈希值来派生请求ID [3]。如果Web浏览器提交了两次POST请求,这两个请求将具有相同的请求ID。然后你可以将该请求ID一直传递到数据库,并检查是否始终只执行具有给定ID的一个请求,如示例13-2所示。
示例13-2. 通过使用唯一ID抑制重复请求
ALTER TABLE requests ADD UNIQUE (request_id);
BEGIN TRANSACTION;
INSERT INTO requests
(request_id, from_account, to_account, amount)
VALUES('0286FDB8-D7E1-423F-B40B-792B3608036C', 4321, 1234, 11.00);
UPDATE accounts SET balance = balance + 11.00 WHERE account_id = 1234;
UPDATE accounts SET balance = balance - 11.00 WHERE account_id = 4321;
COMMIT;这段代码依赖于request_id列的唯一性约束。如果事务尝试插入一个已存在的ID,INSERT会失败,事务被中止,从而防止其生效两次。关系数据库通常能够正确维护唯一性约束,即使在弱隔离级别下也是如此(而应用级别的检查-然后-插入在非可串行化隔离下可能会失败,如第303页的“写偏斜与幻读”所述)。
除了抑制重复请求,示例13-2中的requests表还作为一种事件日志,可用于事件溯源或变更数据捕获。账户余额的更新不必与事件的插入发生在同一个事务中,因为它们是冗余的,可以从下游消费者中的请求事件推导出来——只要该事件被精确处理一次,这又可以通过请求ID来强制执行。
端到端的论证 (The end-to-end argument)
这种抑制重复事务的场景只是更一般原则的一个例子,即端到端论证,由Saltzer、Reed和Clark在1984年提出 [44]:
所述功能只能凭借通信系统端点处应用程序的知识和帮助才能完全且正确地实现。因此,将该功能作为通信系统本身的一个特性来提供是不可能的。(有时,通信系统提供的该功能的不完整版本可以作为性能增强而有用。)
在我们的例子中,所讨论的功能是重复抑制。我们看到TCP在TCP连接层面抑制重复数据包,一些流处理器在消息处理层面提供所谓的恰好一次语义,但这不足以防止用户在第一个请求超时时提交重复请求。TCP、数据库事务和流处理器本身不能完全排除这些重复。解决问题需要一个端到端的解决方案:一个从最终用户客户端一直传递到数据库的事务标识符。
端到端论证也适用于数据完整性的检查。内置于以太网、TCP和TLS中的校验和可以检测网络中数据包的损坏,但无法检测网络连接发送端和接收端软件中的错误导致的损坏,也无法检测数据存储磁盘上的损坏。如果你想捕获所有可能的数据损坏来源,你还需要端到端的校验和。
类似的论证适用于加密 [44]。你家WiFi网络上的密码可以防止他人窥探你的WiFi流量,但无法防止互联网上其他地方的攻击者;客户端与服务器之间的TLS/SSL可以防止网络攻击者,但无法防止服务器被攻破。只有端到端的加密和身份验证才能防范所有这些威胁。
尽管低层特性(TCP重复抑制、以太网校验和、WiFi加密)本身无法提供所需的端到端特性,但它们仍然有用,因为它们减少了高层出现问题的概率。例如,如果没有TCP将数据包按正确顺序排列,HTTP请求常常会被损坏。我们只需要记住,低层的可靠性特性本身不足以确保端到端的正确性。
在数据系统中应用端到端思维 (Applying end-to-end thinking in data systems)
这让我们回到最初的论点:仅仅因为一个应用程序使用了提供相对较强安全特性(如可串行化事务)的数据系统,并不意味着该应用程序保证没有数据丢失或损坏。应用程序本身也需要采取端到端措施,例如重复抑制。
这很遗憾,因为容错机制很难正确实现。低层可靠性机制(如TCP中的机制)工作得很好,因此剩余的高层故障发生得相当罕见。如果能将高层容错机制封装成一个抽象,使得应用程序代码不必担心它,那就太好了——但似乎我们还没有找到正确的抽象。
事务长期以来一直被视为一种有用的抽象。如第8章所述,它们将各种各样可能的问题(并发写入、约束违反、崩溃、网络中断、磁盘故障)归结为两种可能的结果:提交或中止。这极大地简化了编程模型,但还不够。
事务代价昂贵,尤其是当涉及异构存储技术时(参见第328页的“跨不同系统的分布式事务”)。当我们因为分布式事务太昂贵而拒绝使用时,我们最终不得不在应用程序代码中重新实现容错机制。正如本书中众多示例所示,推理并发和部分失败是困难且反直觉的,因此大多数应用层机制都不能正确工作。其后果就是数据丢失或损坏。
出于这些原因,值得探索那些既易于提供应用程序特定的端到端正确性特性,又能在大规模分布式环境中保持良好性能和操作特性的容错抽象。
强制约束 (Enforcing Constraints)
让我们围绕数据库解绑的思想来思考正确性。我们看到,通过使用从客户端一直传递到记录写入的数据库的请求ID,可以实现端到端的重复抑制。那其他类型的约束呢?
特别是,让我们关注唯一性约束,例如我们在示例13-2中依赖的约束。在第409页的“约束与唯一性保证”中,我们看到了几个需要强制执行唯一性的应用程序功能的其他示例:用户名或电子邮件地址必须唯一标识一个用户,文件存储服务不能
第13章:流式系统的哲学
追求正确性
唯一性约束需要共识
在第10章中我们了解到,在分布式环境中实施唯一性约束需要共识。如果多个并发请求具有相同的值,系统需要以某种方式决定哪个冲突操作被接受,并拒绝其他违反约束的操作。
实现这种共识最常见的方式是让单个节点担任领导者,由它负责做出所有决定。只要你不介意将所有请求通过单个节点(即使客户端位于世界另一端)传递,并且该节点不会发生故障,这种方法就能正常工作。像Raft这样的共识算法则解决了在现任领导者发生故障(或由于网络问题被认为发生故障)时安全选举新领导者并防止脑裂的问题。
可以通过基于需要唯一的值进行分片来扩展唯一性检查。例如,如果需要像示例13-2那样按请求ID确保唯一性,可以确保所有具有相同请求ID的请求被路由到同一个分片。如果需要用户名唯一,可以按用户名的哈希值进行分片。
但是,异步多主复制是不可行的,因为不同的主节点可能同时接受冲突写入,从而破坏唯一性。如果希望能够立即拒绝任何违反约束的写入,同步协调是不可避免的[45]。
基于日志的消息传递中的唯一性
共享日志确保所有消费者以相同顺序看到消息(即我们在第427页“共识的多种面貌”中确认的全序广播保证,这等价于共识)。在基于日志消息的解耦数据库方法中,我们可以使用非常相似的方法来实施唯一性约束。
流处理器在单线程上顺序地消费日志分片中的所有消息。因此,如果日志基于需要唯一的值进行分片,流处理器就能明确且确定性地决定多个冲突操作中哪个在日志中排在最前面。例如,多个用户试图抢占同一用户名的场景[46]:
- 每个用户名的请求被编码为一条消息,并追加到由用户名哈希值确定的分片中。
- 流处理器顺序读取日志中的请求,使用本地数据库跟踪已占用的用户名。对于每个尚未被占用的用户名请求,它将名称记录为已占用,并将成功消息发送到输出流。对于每个已被占用的用户名请求,它将拒绝消息发送到输出流。
- 请求用户名的客户端监视输出流,并等待与其请求对应的成功或拒绝消息。
该算法与第10章中看到的利用共享日志实现共识的构造相同。通过增加分片数量可以轻松扩展以处理大量请求吞吐量,因为每个分片可以独立处理。
这种方法不仅适用于唯一性约束,还适用于许多其他类型的约束。其基本原则是:任何可能冲突的写入都被路由到同一分片并顺序处理。冲突的定义可能取决于应用,但流处理器可以使用任意逻辑来验证请求。
多分片请求处理
当涉及多个分片时,确保操作原子执行并满足约束会变得更有趣。在示例13-2中,潜在有三个分片:一个包含请求ID,一个包含收款账户,一个包含付款账户。这三者没有理由必须在同一分片中,因为它们彼此独立。
在传统数据库方法中,执行此事务需要跨所有三个分片的原子提交,这本质上迫使它与这些分片上所有其他事务的总序保持一致。由于现在存在跨分片协调,不同分片无法再独立处理,吞吐量很可能会受到影响。
然而,通过使用分片日志和流处理器,可以在没有跨分片事务的情况下实现等效的正确性。图13-2展示了一个支付事务的示例,该事务需要检查源账户是否有足够资金,如果有,则原子地将金额转移到目标账户,同时扣除费用。
图13-2. 使用事件日志和流处理器检查源账户是否有足够资金并原子地将资金转移到目标账户和费用账户
该流程如下[47]:
- 用户客户端为从源账户到目标账户的转账请求分配唯一请求ID,并将其追加到基于源账户ID的日志分片中。
- 流处理器读取请求日志,并维护一个包含源账户状态及其已处理请求ID的数据库。该数据库的内容完全源自日志。当流处理器遇到先前未见过的请求ID时,它会检查本地数据库中源账户是否有足够资金执行转账。
如果有足够资金,它会更新本地数据库以在源账户上预留支付金额,并向其他几个日志发送事件:一个支付外出事件到源账户的日志分片(它自己的输入日志),一个支付进入事件到目标账户的日志分片,以及一个支付进入事件到费用账户的日志分片。原始请求ID包含在这些发出的事件中。
- 最终,支付外出事件被送回到源账户处理器(在此期间它可能已经收到其他不相关的事件)。流处理器根据请求ID识别出这是先前预留的支付,并执行支付,再次更新源账户的本地状态。它根据请求ID忽略重复项。
- 目标账户和费用账户的日志分片由独立的流处理任务消费。当它们收到支付进入事件时,它们更新本地状态以反映支付,并根据请求ID对事件进行去重。
图13-2将三个账户显示为三个独立的分片,但它们也可以位于同一分片中——这无关紧要。唯一的要求是:任何给定账户的事件严格按照日志顺序处理,具有至少一次语义,并且流处理器是确定性的。
例如,考虑源账户处理器在处理支付请求时崩溃的情况。输出消息可能在崩溃发生之前或之后发出。崩溃恢复后,处理器将再次处理相同的请求(因为至少一次语义),并且它将做出相同的支付批准决策(因为它是确定性的)。因此,它将向外出、进入和费用账户分片发出具有相同请求ID的相同输出消息。如果消息是重复的,下游消费者将根据请求ID忽略它们。
此系统中的原子性并非来自事务,而是来自将初始请求事件写入源账户日志这一原子操作。一旦该事件进入日志,所有下游事件最终也将被写入——可能是在流处理器从崩溃中恢复之后,可能带有重复项,但它们最终会出现。
使用恰好一次语义,此示例变得更容易实现,因为这些语义确保流处理器的本地状态与其已处理的消息集一致。因此,如果它崩溃并重新处理某些消息,其本地状态也会重置为处理这些消息之前的状态。
如果图13-2中的用户想了解其转账是否获批,他们可以订阅源账户日志分片并等待支付外出事件。若要明确通知用户余额不足,流处理器可以向该日志分片发出“支付被拒绝”事件。
通过将多分片事务拆分为多个不同分片的阶段,并使用端到端请求ID,我们实现了相同的正确性属性(每个请求恰好一次应用于付款方和收款方账户),即使在存在故障的情况下也是如此,而无需使用原子提交协议。
第十三章:流式系统的哲学
时效性与完整性
许多事务系统有一个便利的特性:一旦一个事务提交,其写入立即可被其他事务看到。这一特性被形式化为严格可串行化(参见第407页的“线性一致性与可串行化”)。
当将一个操作解耦到多个流处理器阶段时,情况并非如此。日志的消费者在设计上是异步的,因此生产者不会等待其消息被消费者处理完毕。然而,客户端可以等待消息出现在输出流上,例如图13-2中等待“付款成功”或“付款被拒”事件的用户,这取决于源账户是否有足够的资金。
在这个例子中,源账户余额检查的正确性并不取决于发起请求的用户是否等待结果。等待的唯一目的是同步告知用户付款是否成功;这种通知与请求处理的效果是解耦的。
更一般地说,“一致性”这个术语混淆了两个值得分别考虑的需求:
-
时效性:时效性意味着确保用户观察到系统处于最新状态。我们之前看到,如果用户读取的是数据的过期副本,他们可能会观察到不一致的状态(参见第209页的“复制滞后问题”)。然而,这种不一致是暂时的,只需等待并重试即可最终解决。 CAP定理使用“一致性”来指代线性一致性,这是实现时效性的一种强方式。较弱的时效性属性,如读-写一致性,也很有用。
-
完整性:完整性意味着没有损坏——没有数据丢失,没有矛盾或虚假数据。特别是,如果派生数据集是作为底层数据的视图来维护的,那么派生过程必须正确。例如,数据库索引必须正确反映数据库的内容——缺少记录的索引没什么用处。 如果完整性被破坏,不一致是永久性的;在大多数情况下,等待和重试无法修复数据库损坏。相反,需要显式的检查和修复。在ACID事务的上下文中,“一致性”通常被理解为某种特定于应用程序的完整性概念。原子性和持久性是维护完整性的重要工具。
标语形式
在标语形式中:在最终一致性下,允许违反时效性;而违反完整性则会导致永久性的不一致。
在大多数应用中,完整性远比时效性重要。违反时效性可能令人烦恼和困惑,但违反完整性可能是灾难性的。
例如,在你的信用卡账单上,如果过去24小时内进行的一笔交易尚未显示,这并不奇怪。这些系统存在一定的滞后是正常的。我们知道银行会异步地对账和结算交易,时效性在这里并不十分重要[3]。但是,如果账单余额不等于交易总额加上之前的账单余额(计算错误),或者一笔交易向你收取了费用但没有支付给商家(钱消失了),那将非常糟糕。此类问题就是违反了系统的完整性。
数据流系统的正确性
ACID事务通常同时提供时效性(例如线性一致性)和完整性(例如原子提交)保证。因此,如果你从ACID事务的角度来看待应用的正确性,时效性和完整性之间的区别并不重要。
另一方面,我们在本章中讨论的基于事件的数据流系统有一个有趣的特性:它们将时效性和完整性解耦。当异步处理事件流时,除非你显式地构建在返回之前等待消息到达的消费者,否则无法保证时效性。例如,用户可能请求付款,然后在流处理器执行该请求之前读取其账户状态;用户将看不到他们刚刚请求的付款。
然而,完整性实际上对于流式系统至关重要。正如我们所看到的,恰好一次或有效一次语义是维护完整性的一种机制。如果事件丢失或生效两次,数据系统的完整性可能被破坏。因此,容错的消息传递和重复抑制(例如幂等操作)对于在故障情况下维护数据系统的完整性非常重要。
正如我们在上一节中看到的,可靠的流处理系统可以在不要求分布式事务和原子提交协议的情况下维护完整性,这意味着它们有可能以更好的性能和操作鲁棒性实现可比的正确性。我们通过以下机制的组合实现了这种完整性:
-
将写入操作的内容表示为单个消息,该消息可以轻松地原子写入——这种方法非常适合事件溯源
-
通过确定性派生函数从该单个消息派生所有其他状态更新,类似于存储过程
-
通过所有处理级别传递客户端生成的请求ID,实现端到端的重复抑制和幂等性
-
使消息不可变,并允许派生数据定期重新处理,从而更容易从错误中恢复
宽松解释的约束
如前所述,强制唯一性约束需要共识,通常通过将所有事件通过单个节点汇集到特定分片来实现。如果我们想要传统形式的唯一性约束,这种限制是不可避免的,流处理无法绕过它。
然而,许多实际应用在业务上允许违反你可能认为是硬约束的情况:
-
如果客户订购的商品数量超过仓库库存,你可以补货、对延迟道歉并为他们提供折扣。这与你需要做的事情相同,比如,如果叉车撞坏了仓库中的一些商品,导致实际库存比你认为的少,你也要这样做[3]。因此,道歉工作流程已经需要成为业务流程的一部分以处理此类事件,而硬性的库存数量约束可能是不必要的。
-
类似地,许多航空公司超额预订飞机,预期一些乘客会错过航班;许多酒店超额预订房间,预期一些客人会取消预订。在这些情况下,“一人一座”的约束因业务原因被故意违反,并建立了补偿流程(退款、升级、在邻近酒店提供免费房间)来处理超出供应量的需求。即使没有超额预订,也需要道歉和补偿流程来处理诸如航班因恶劣天气取消或员工罢工等事件——从这类问题中恢复只是业务正常的一部分[3]。
-
如果有人从账户中提取的金额超过余额,银行可以向他们收取透支费并要求他们偿还欠款。通过限制每日总取款额,银行的风险是可控的。
-
在跨组织集成数据的系统中,不一致性不可避免地会出现,需要修正机制来处理它们。如第476页的“批量用例”所述,银行之间的支付结算就是一个例子。
补偿交易
在许多业务上下文中,暂时违反约束并在事后通过道歉来修复是可以接受的。这种纠正错误的变更称为补偿交易[48, 49]。道歉的成本(金钱或声誉方面)各不相同,但通常相当低;你不能撤回已发送的邮件,但可以发送后续邮件进行更正。如果你意外地两次扣款信用卡,你可以退还一笔费用,成本只是手续费和可能的客户投诉。一旦钱从ATM机支付出去,你无法直接收回,尽管原则上如果账户透支且客户不归还,你可以派讨债员追回。
道歉的成本是否可以接受是一个商业决策。如果可以接受,传统的在写入数据之前检查所有约束的模型就过于严格了。以乐观方式写入并在事后检查约束可能是合理的。你仍然可以确保验证发生在采取难以恢复的行动之前,但这并不意味着你必须在写入数据之前就进行验证。
这些应用确实需要完整性。你不会希望丢失预订或因为借贷不匹配而导致资金消失。但它们并不要求在约束强制执行上具有时效性。如果你卖出的商品多于仓库库存,你可以在事后修补问题。这类似于我们在第222页“处理冲突写入”中讨论的冲突解决方法。
避免协调的数据系统
我们现在有两个有趣的观察:
-
数据流系统可以在不需要原子提交、线性一致性或同步跨分片协调的情况下维护派生数据的完整性保证。
-
尽管严格的唯一性约束需要时效性和协调,但许多应用可以接受宽松的约束,这些约束可以暂时违反并在事后修复,只要完整性始终保持。
综合这些观察,数据流系统可以为许多应用提供数据管理服务,无需协调,同时仍提供强大的完整性保证。这种避免协调的数据系统非常有吸引力;它们可以实现比需要执行同步协调的系统更好的性能和容错性[45]。
例如,这样的系统可以以多主配置跨多个数据中心运行,在区域之间进行异步复制。任何一个数据中心都可以独立于其他数据中心继续运行,因为不需要同步的跨区域协调。这样的系统将具有较弱的
第13章:流式系统的哲学
…它可以在跨多个数据中心的多主配置中运行,在区域之间进行异步复制。任何一个数据中心都可以独立于其他数据中心继续运行,因为不需要同步的跨区域协调。这样的系统会提供较弱的及时性保证——如果不引入协调,它无法实现线性一致性——但它仍然可以提供强大的完整性保证。
在这种情况下,可序列化事务作为维护派生状态的一部分仍然有用,但它们可以在小范围内运行,在这些范围内表现良好[6]。不需要异构分布式事务(如XA事务)。同步协调仍然可以在需要的地方引入(例如,在执行无法恢复的操作之前强制执行严格的约束),但如果只有应用程序的一小部分需要协调,则没有必要让所有内容都承担协调的成本[32]。
协调和约束的另一种视角是:它们可以减少你因不一致而不得不道歉的次数,但同时也可能降低系统的性能和可用性,从而可能增加你因故障而道歉的次数。你无法将道歉次数减少到零,但可以尝试找到最适合你需求的折中方案——既没有太多不一致,也没有太多可用性问题。
信任,但需验证
我们关于正确性、完整性和容错性的所有讨论都基于一个假设:某些事情可能会出错,但其他事情不会。我们将这样的假设称为我们的系统模型(参见第380页的“系统模型与现实”)。例如,我们应该假设进程可能崩溃、机器可能突然断电、网络可能随意延迟或丢弃消息。我们可能还假设写入磁盘的数据在fsync之后不会丢失、内存中的数据不会损坏、CPU的乘法指令总是返回正确的结果。
这些假设相当合理,因为它们在大多数时候是正确的,如果我们必须不断担心计算机出错,那么将很难做成任何事情。传统上,系统模型采用二元方法对待故障:我们假设某些事情可能发生,而其他事情永远不会发生。实际上,这更像是一个概率问题:有些事情更可能发生,其他事情不太可能发生。问题在于,我们的假设被违反的频率是否高到我们在实践中会遇到它。
我们已经看到数据可能在内存(参见第44页的“硬件和软件故障”)、磁盘(参见第283页的“复制与持久性”)和网络(参见第379页的“较弱的说谎形式”)上被损坏。也许我们应该对此给予更多关注?如果你的系统规模足够大,即使是非常不可能发生的事情也会发生。
面对软件错误维护完整性
除了此类硬件问题,始终存在软件错误的风险,这些错误不会被底层的网络、内存或文件系统校验和捕获。即使是广泛使用的数据库软件也有错误——例如,过去的MySQL版本未能正确维护唯一约束[50],PostgreSQL的可序列化隔离级别在过去曾出现写倾斜异常[51],尽管MySQL和PostgreSQL是经过许多人多年实战考验的稳健且受尊敬的数据库。在不太成熟的软件中,情况可能更糟。
尽管在精心设计、测试和审查方面付出了大量努力,错误仍然会偷偷潜入。虽然它们很罕见,而且最终会被发现和修复,但在这些错误能够破坏数据的那段时间仍然存在。
对于应用程序代码,我们必须假设有更多的错误,因为大多数应用程序所接受的审查和测试远不及数据库代码。许多应用程序甚至没有正确使用数据库提供的用于维护完整性的功能,例如外键或唯一约束[25]。
ACID意义上的一致性基于这样一个概念:数据库从一致状态开始,事务将一个一致状态转换为另一个一致状态。因此,我们期望数据库始终处于一致状态。然而,只有当我们假设事务没有错误时,这个概念才有意义。如果应用程序以某种方式错误地使用了数据库——例如,不安全地使用弱隔离级别——数据库的完整性就无法得到保证。
不要盲目相信他们的承诺
由于硬件和软件都不总是能达到我们的理想,数据损坏似乎迟早是不可避免的。因此,我们至少应该有一种方法来发现数据是否已损坏,以便修复它并尝试追踪错误的根源。检查数据的完整性被称为审计。
正如第509页“不可变事件的优点”中所讨论的,审计不仅适用于金融应用程序。然而,可审计性在金融领域非常重要,正是因为每个人都知道错误会发生,我们都认识到需要能够检测和修复问题。
成熟的系统同样倾向于考虑不太可能出错的可能性并管理这种风险。例如,像HDFS和Amazon S3这样的大规模存储系统并不完全信任磁盘。这些系统运行后台进程,持续回读文件,与其他副本进行比较,并将文件从一个磁盘移动到另一个磁盘,以减轻静默损坏的风险[52, 53]。
如果你想确保你的数据仍然存在,你必须读取并检查它。大多数情况下它仍然存在,但如果没有,你希望尽早发现而不是晚发现。基于同样的道理,定期尝试从备份中恢复很重要——否则,你可能在备份损坏时才知道,但为时已晚,数据已经丢失。不要盲目相信一切正常工作。
像HDFS和S3这样的系统仍然需要假设磁盘大部分时间正常工作——这是一个合理的假设,但这与假设它们始终正常工作不同。然而,目前没有多少系统采用这种“信任,但需验证”的持续自我审计方法。许多系统假设正确性保证是绝对的,并且不为罕见数据损坏的可能性做任何准备。未来,我们可能会看到更多的自我验证或自我审计系统,它们持续检查自己的完整性,而不是依赖盲目信任[54]。
为可审计性而设计
如果事务修改了数据库中的多个对象,事后很难看出根本原因。即使你捕获了事务日志,各个表中的插入、更新和删除也并不一定能清晰地说明为什么执行了这些修改。决定这些修改的应用程序逻辑的调用是瞬态的,无法重现。
相比之下,基于事件的系统可以提供更好的可审计性。在事件溯源方法中,用户对系统的输入被表示为单个不可变事件,任何结果状态更新都是从该事件派生的。派可以做到确定性和可重复性,因此通过同一版本派生代码重放相同的事件日志将产生相同的状态更新。
明确数据流使数据的出处更加清晰,从而使完整性检查更加可行。对于事件日志,我们可以使用哈希来检查事件存储是否已被损坏。对于任何派生状态,我们可以重新运行从事件日志派生出它的批处理和流处理器,以检查是否得到相同的结果,甚至并行运行一个冗余派生。
确定且定义良好的数据流也使得调试和跟踪系统的执行以确定其为何执行了某些操作变得更加容易[4, 55]。如果发生了意外情况,具备诊断能力以重现导致意外事件的确切场景是非常有价值的——一种时间旅行调试能力。
端到端论证再次出现
如果我们不能完全信任系统中的每个单独组件都不会损坏——每个硬件都无故障,每个软件都无错误——那么我们必须至少定期检查数据的完整性。如果我们不检查,我们将直到为时已晚、损坏已经导致下游损害时才发现,届时追踪问题将变得更加困难和昂贵。
检查数据系统的完整性最好以端到端的方式进行。我们能在完整性检查中包含的系统越多,损坏在过程的某个阶段未被注意的机会就越少。如果我们能检查整个派生数据管道是端到端正确的,那么路径上的任何磁盘、网络、服务和算法都隐含地被包含在检查中。
拥有持续的端到端完整性检查能增强你对系统正确性的信心,从而让你能够更快地行动[56]。像自动化测试一样,审计增加了错误被快速发现的机会,从而降低了系统变更或新存储技术造成损害的风险。如果你不害怕做出改变,你就可以更好地演进应用程序以满足变化的需求。
可审计数据系统的工具
目前,没有多少数据系统将可审计性作为首要关注点。一些应用程序实现了自己的审计机制——例如,将所有更改记录到单独的审计表中——但保证审计日志和数据库状态的完整性仍然困难。事务日志可以通过使用硬件安全模块定期签名来防篡改,但这并不能保证正确的交易首先进入了日志。
像比特币和以太坊这样的区块链是共享的仅追加日志,带有密码学一致性检查;它们存储的交易是事件,智能合约基本上是流处理器。它们使用的共识协议确保所有节点同意相同的事件序列。与第10章的共识协议的不同之处在于,区块链是拜占庭容错的——也就是说,如果某些参与节点有损坏数据,系统仍然工作,因为副本持续相互检查完整性。
对于大多数应用程序来说,区块链的开销过高,无法实用。然而,它们的一些密码学工具也可以在较轻量级的上下文中使用。例如,默克尔树[57]是哈希树,可以用于高效地证明记录出现在数据集中(以及其他一些事情)。证书透明度使用密码学验证的仅追加日志和默克尔树来检查TLS/SSL证书的有效性[58, 59];它通过每个日志有一个领导者来避免需要共识协议。
完整性检查和审计算法,如证书透明度和分布式账本中的那些,未来可能会更广泛地应用于一般的数据系统。需要一些工作来使它们像没有密码审计的系统一样可扩展,并尽可能保持性能开销低,但尽管如此,它们仍然很有趣。
核心原则
- 信任,但验证:不要盲目依赖硬件或软件承诺的正确性;通过持续审计和端到端检查来确认数据完整性。
- 为可审计性设计:使用事件溯源和确定性数据流,使数据出处清晰,便于事后追踪和错误恢复。
- 端到端检查:包含整个派生数据管道的完整性检查,减少损坏未被发现的机会。
- 考虑新兴工具:密码学审计(如默克尔树、证书透明度)可能成为未来数据系统的标准组件。
第13章:流式系统的哲学
总结
在本章中,我们讨论了基于流处理思想设计数据系统的新方法。我们首先观察到,没有任何单一工具能高效地满足所有可能的用例,因此应用程序必须组合多种软件来实现其目标。我们讨论了如何通过使用批处理和事件流来让数据变更在系统之间流动,从而解决数据集成问题。
在这种方法中,某些系统被指定为记录系统,其他数据则通过转换从它们衍生而来。通过这种方式,我们可以维护索引、物化视图、机器学习模型、统计摘要等。使这些衍生和转换异步化、松耦合,有助于防止一个问题扩散到无关区域,从而提高整个系统的健壮性和容错性。
将数据流表达为从数据集到数据集的转换,也有助于应用的演进。如果你想更改某个处理步骤(例如改变索引或缓存的结构),只需在整个输入数据集上重新运行新的转换代码以重新衍生输出。同样,如果出现问题,你可以修复代码并重新处理数据以恢复。
这些过程与数据库内部已有的操作非常相似,因此我们将数据流应用的概念重新诠释为:解绑数据库的组件,并通过组合这些松耦合的组件来构建应用。
衍生状态可以通过观察底层数据的变化来更新。该状态也可以被下游消费者观察。我们甚至可以将这种数据流一直延伸到显示数据的最终用户设备,从而构建能够动态更新以反映数据变化、并且能离线工作的用户界面。
接着,我们讨论了如何在存在故障的情况下确保所有这些处理正确无误。我们看到,通过使用端到端请求标识符使操作幂等,以及异步检查约束,可以以异步事件处理的方式实现可扩展的强完整性保证。客户端可以等待检查通过,也可以不等候直接继续,但需承担违反约束后道歉的风险。这种方法比传统的分布式事务方法更具可扩展性和健壮性,并且与许多实际业务流程的工作方式相符。
通过围绕数据流构建应用并异步检查约束,我们可以避免大部分协调,创建出即使在多地分布式场景和存在故障的情况下也能保持完整性并良好运行的系统。最后,我们简要讨论了如何使用审计来验证数据的完整性和检测损坏,并观察到区块链所使用的技术也与基于事件的系统有相似之处。
(第579页)
参考文献
[1] Rachid Belaid. “Postgres Full-Text Search Is Good Enough!” rachbelaid.com, July 2015. Archived at perma.cc/ZVP9-YDCB
[2] Philippe Ajoux, Nathan Bronson, Sanjeev Kumar, Wyatt Lloyd, and Kaushik Veeraraghavan. “Challenges to Adopting Stronger Consistency at Scale.” At 15th USENIX Workshop on Hot Topics in Operating Systems (HotOS), May 2015.
[3] Pat Helland and Dave Campbell. “Building on Quicksand.” At 4th Biennial Conference on Innovative Data Systems Research (CIDR), January 2009. Archived at arxiv.org
[4] Jessica Kerr. “Provenance and Causality in Distributed Systems.” jessitron.com, September 2016. Archived at perma.cc/DTD2-F8ZM
[5] Jay Kreps. “The Log: What Every Software Engineer Should Know About Real-Time Data’s Unifying Abstraction.” engineering.linkedin.com, December 2013. Archived at perma.cc/2JHR-FR64
[6] Pat Helland. “Life Beyond Distributed Transactions: An Apostate’s Opinion.” At 3rd Biennial Conference on Innovative Data Systems Research (CIDR), January 2007. Archived at perma.cc/2GZG-UZ65
[7] Lionel A. Smith. “The Broad Gauge Story.” Journal of the Monmouthshire Railway Society, Summer 1985. Archived at perma.cc/DDK9-JA6X
[8] Jacqueline Xu. “Online Migrations at Scale.” stripe.com, February 2017. Archived at perma.cc/ZQY2-EAU2
[9] Flavio Santos and Robert Stephenson. “Changing the Wheels on a Moving Bus—Spotify’s Event Delivery Migration.” engineering.atspotify.com, October 2021. Archived at perma.cc/5C4V-G8EV
[10] Molly Bartlett Dishman and Martin Fowler. “Agile Architecture.” At O’Reilly Software Architecture Conference, March 2015.
[11] Nathan Marz and James Warren. Big Data: Principles and Best Practices of Scalable Real-Time Data Systems. Manning, 2015. ISBN: 9781617290343
[12] Jay Kreps. “Questioning the Lambda Architecture.” oreilly.com, July 2014. Archived at perma.cc/PGH6-XUCH
[13] Raul Castro Fernandez, Peter Pietzuch, Jay Kreps, Neha Narkhede, Jun Rao, Joel Koshy, Dong Lin, Chris Riccomini, and Guozhang Wang. “Liquid: Unifying Nearline and Offline Big Data Integration.” At 7th Biennial Conference on Innovative Data Systems Research (CIDR), January 2015. Archived at perma.cc/QMA9-8PKL
(第580页)
[14] Dennis M. Ritchie and Ken Thompson. “The UNIX Time-Sharing System.” Communications of the ACM, volume 17, issue 7, pages 365–375, July 1974. doi:10.1145/361011.361061
[15] Wes McKinney. “The Road to Composable Data Systems: Thoughts on the Last 15 Years and the Future.” wesmckinney.com, September 2023. Archived at perma.cc/J9SJ-886N
[16] Eric A. Brewer and Joseph M. Hellerstein. “CS262a: Advanced Topics in Computer Systems.” Lecture notes, University of California, Berkeley, cs.berkeley.edu, August 2011. Archived at perma.cc/TE79-LGWU
[17] Michael Stonebraker. “The Case for Polystores.” wp.sigmod.org, July 2015. Archived at perma.cc/G7J2-KR45
[18] Jennie Duggan, Aaron J. Elmore, Michael Stonebraker, Magda Balazinska, Bill Howe, Jeremy Kepner, Sam Madden, David Maier, Tim Mattson, and Stan Zdonik. “The BigDAWG Polystore System.” ACM SIGMOD Record, volume 44, issue 2, pages 11–16, June 2015. doi:10.1145/2814710.2814713
[19] David B. Lomet, Alan Fekete, Gerhard Weikum, and Mike Zwilling. “Unbundling Transaction Services in the Cloud.” At 4th Biennial Conference on Innovative Data Systems Research (CIDR), January 2009. Archived at arxiv.org
[20] Martin Kleppmann and Jay Kreps. “Kafka, Samza and the Unix Philosophy of Distributed Data.” IEEE Data Engineering Bulletin, volume 38, issue 4, pages 4–14, December 2015. Archived at perma.cc/BJM5-TJ4Z
[21] John Hugg. “Winning Now and in the Future: Where Volt Active Data Shines.” voltactivedata.com, March 2016. Archived at perma.cc/44MP-3MWM
[22] Felienne Hermans. “Spreadsheets Are Code.” At Code Mesh, November 2015.
[23] Dan Bricklin and Bob Frankston. “VisiCalc: Information from Its Creators.” danbricklin.com. Archived at archive.org
[24] D. Sculley, Gary Holt, Daniel Golovin, Eugene Davydov, Todd Phillips, Dietmar Ebner, Vinay Chaudhary, and Michael Young. “Machine Learning: The High-Interest Credit Card of Technical Debt.” At NIPS Workshop on Software Engineering for Machine Learning (SE4ML), December 2014. Archived at perma.cc/M3MD-U7WL
[25] Peter Bailis, Alan Fekete, Michael J. Franklin, Ali Ghodsi, Joseph M. Hellerstein, and Ion Stoica. “Feral Concurrency Control: An Empirical Investigation of Modern Application Integrity.” At ACM International Conference on Management of Data (SIGMOD), June 2015. doi:10.1145/2723372.2737784
[26] Guy Steele. “Re: Need for Macros (Was Re: Icon).” Email to ll1-discuss mailing list, people.csail.mit.edu, December 2001. Archived at perma.cc/K9X8-CJ65
(第581页)
[27] Ben Stopford. “Microservices in a Streaming World.” At QCon London, March 2016.
[28] Adam Bellemare. Building Event-Driven Microservices, 2nd edition. O’Reilly Media, 2025. ISBN: 9798341622180
[29] Christian Posta. “Why Microservices Should Be Event Driven: Autonomy vs Authority.” blog.christianposta.com, May 2016. Archived at perma.cc/E6N9-3X92
[30] Alex Feyerke. “Designing Offline-First Web Apps.” alistapart.com, December 2013. Archived at perma.cc/WH7R-S2DS
[31] Martin Kleppmann. “Turning the Database Inside-out with Apache Samza.” At Strange Loop, September 2014. Archived at perma.cc/U6E8-A9MT
[32] Sebastian Burckhardt, Daan Leijen, Jonathan Protzenko, and Manuel Fähndrich. “Global Sequence Protocol: A Robust Abstraction for Replicated Shared State.” At 29th European Conference on Object-Oriented Programming (ECOOP), July 2015. doi:10.4230/LIPIcs.ECOOP.2015.568
[33] Evan Czaplicki and Stephen Chong. “Asynchronous Functional Reactive Programming for GUIs.” At 34th ACM SIGPLAN Conference on Programming Language Design and Implementation (PLDI), June 2013. doi:10.1145/2491956.2462161
[34] Eno Thereska, Damian Guy, Michael Noll, and Neha Narkhede. “Unifying Stream Processing and Interactive Queries in Apache Kafka.” confluent.io, October 2016. Archived at perma.cc/W8JG-EAZF
[35] Frank McSherry. “Dataflow as Database.” github.com, July 2016. Archived at perma.cc/384D-DUFH
[36] Peter Alvaro. “I See What You Mean.” At Strange Loop, September 2015.
[37] Nathan Marz. “Trident: A High-Level Abstraction for Realtime Computation.” blog.x.com, August 2012. Archived at archive.org
[38] Edi Bice. “Low Latency Web Scale Fraud Prevention with Apache Samza, Kafka and Friends.” At Merchant Risk Council MRC Vegas Conference, March 2016. Archived at perma.cc/T3H5-QN3R
[39] Charity Majors. “The Accidental DBA.” charity.wtf, October 2016. Archived at perma.cc/6ANP-ARB6
[40] Arthur J. Bernstein, Philip M. Lewis, and Shiyong Lu. “Semantic Conditions for Correctness at Different Isolation Levels.” At 16th International Conference on Data Engineering (ICDE), February 2000. doi:10.1109/ICDE.2000.839387
[41] Sudhir Jorwekar, Alan Fekete, Krithi Ramamritham, and S. Sudarshan. “Automating the Detection of Snapshot Isolation Anomalies.” At 33rd International Conference on Very Large Data Bases (VLDB), September 2007.
(第582页)
第13章:流式系统的哲学
参考文献
[42] Kyle Kingsbury. “Distributed Systems Safety Research.” jepsen.io.
[43] Michael Jouravlev. “Redirect After Post.” theserverside.com, 2004年8月。存档于 archive.org
[44] Jerome H. Saltzer, David P. Reed, 和 David D. Clark. “End-to-End Arguments in System Design.” ACM Transactions on Computer Systems, 第2卷, 第4期, 第277–288页, 1984年11月。doi:10.1145/357401.357402
[45] Peter Bailis, Alan Fekete, Michael J. Franklin, Ali Ghodsi, Joseph M. Hellerstein, 和 Ion Stoica. “Coordination Avoidance in Database Systems.” Proceedings of the VLDB Endowment, 第8卷, 第3期, 第185–196页, 2014年11月。doi:10.14778/2735508.2735509, 扩展版本发表于 arXiv:1402.2237
[46] Alex Yarmula. “Strong Consistency in Manhattan.” blog.x.com, 2016年3月。存档于 archive.org
[47] Martin Kleppmann, Alastair R. Beresford, 和 Boerge Svingen. “Online Event Processing: Achieving Consistency Where Distributed Transactions Have Failed.” Communications of the ACM, 第62卷, 第5期, 第43–49页, 2019年5月。doi:10.1145/3312527
[48] Jim Gray. “The Transaction Concept: Virtues and Limitations.” 在第7届国际超大型数据库会议(VLDB)上, 1981年9月。存档于 perma.cc/8VPT-N5H6
[49] Hector Garcia-Molina 和 Kenneth Salem. “Sagas.” 在ACM国际数据管理会议(SIGMOD)上, 1987年5月。doi:10.1145/38713.38742
[50] Annamalai Gurusami 和 Daniel Price. “Bug #73170: Duplicates in Unique Secondary Index Because of Fix of Bug#68021.” bugs.mysql.com, 2014年7月。存档于 perma.cc/P6BV-W7JJ
[51] Gary Fredericks. “Postgres Serializability Bug.” github.com, 2015年9月。存档于 perma.cc/N8UP-2822
[52] Xiao Chen. “HDFS DataNode Scanners and Disk Checker Explained.” blog.cloudera.com, 2016年12月。存档于 perma.cc/6S36-X98L
[53] Daniel Persson. “How Does Ceph Scrubbing Work?” youtube.com, 2022年3月。
[54] Jay Kreps. “Getting Real About Distributed System Reliability.” blog.empathybox.com, 2012年3月。存档于 perma.cc/9B5Q-AEBW
[55] Martin Fowler. “The LMAX Architecture.” martinfowler.com, 2011年7月。存档于 perma.cc/5AV4-N6RJ
[56] Sam Stokes. “Move Fast with Confidence.” five-eights.com, 2016年7月。存档于 perma.cc/J8C6-DHXB
总结
[57] Ralph C. Merkle. “A Digital Signature Based on a Conventional Encryption Function.” 在CRYPTO ’87上, 1987年8月。doi:10.1007/3-540-48184-2_32
[58] Ben Laurie. “Certificate Transparency.” ACM Queue, 第12卷, 第8期, 第10–19页, 2014年8月。doi:10.1145/2668152.2668154
[59] Mark D. Ryan. “Enhanced Certificate Transparency and End-to-End Encrypted Mail.” 在网络与分布式系统安全研讨会(NDSS)上, 2014年2月。doi:10.14722/ndss.2014.23379
图片说明
图片位置信息
以下图片在原始文档中出现,但视觉模型无法解析其内容。此处保留原位置描述。
-
第577页,图片1:
图片内容无法识别,请提供有效的图片数据以进行分析。 -
第580页,图片1:
无法分析图片,因为未提供有效图片内容。 -
第593页,图片1:
图片内容不可见,无法进行分析。