第十三章:流式系统的哲学

如果船长的终极目标是保护船只,他应该永远待在港口。
—— 圣托马斯・阿奎那《神学大全》(1265-1274)
第二章 讨论了构建 可靠、可伸缩、可维护 应用与系统的目标。这些主题贯穿了全书:例如,我们讨论了提升可靠性的多种容错算法、提升可伸缩性的分区方法,以及提升可维护性的演化与抽象机制。
在本章中,我们将把这些想法整合起来,并特别基于 第十二章 的流式/事件驱动架构思路,提出一套满足这些目标的应用开发哲学。与前几章相比,本章立场更鲜明:不是并列比较多种方案,而是深入展开一种特定的设计哲学。
数据集成
本书中反复出现的主题是,对于任何给定的问题都会有好几种解决方案,所有这些解决方案都有不同的优缺点与利弊权衡。例如在 第四章 讨论存储引擎时,我们看到了日志结构存储、B 树以及列式存储。在 第六章 讨论复制时,我们看到了单领导者、多领导者和无领导者的方法。
如果你有一个类似于 “我想存储一些数据并稍后再查询” 的问题,那么并没有一种正确的解决方案。但对于不同的具体环境,总会有不同的合适方法。软件实现通常必须选择一种特定的方法。使单条代码路径能做到稳定健壮且表现良好已经是一件非常困难的事情了 —— 尝试在单个软件中完成所有事情,几乎可以保证,实现效果会很差。
因此软件工具的最佳选择也取决于情况。每一种软件,甚至所谓的 “通用” 数据库,都是针对特定的使用模式设计的。
面对让人眼花缭乱的诸多替代品,第一个挑战就是弄清软件与其适用环境的映射关系。供应商不愿告诉你他们软件不适用的工作负载,这是可以理解的。但是希望先前的章节能给你提供一些问题,让你读出字里行间的言外之意,并更好地理解这些权衡。
但是,即使你已经完全理解各种工具与其适用环境间的关系,还有一个挑战:在复杂的应用中,数据的用法通常花样百出。不太可能存在适用于 所有 不同数据应用场景的软件,因此你不可避免地需要拼凑几个不同的软件来以提供应用所需的功能。
组合使用派生数据的工具
例如,为了处理任意关键词的搜索查询,将 OLTP 数据库与全文检索索引集成在一起是很常见的需求。尽管一些数据库(例如 PostgreSQL)包含了全文索引功能,对于简单的应用完全够了1,但更复杂的搜索能力就需要专业的信息检索工具了。相反的是,搜索索引通常不适合作为持久的记录系统,因此许多应用需要组合这两种不同的工具以满足所有需求。
我们在 “保持系统同步” 中接触过集成数据系统的问题。随着数据不同表示形式的增加,集成问题变得越来越困难。除了数据库和搜索索引之外,也许你需要在分析系统(数据仓库,或批处理和流处理系统)中维护数据副本;维护从原始数据中派生的缓存,或反规范化的数据版本;将数据灌入机器学习、分类、排名或推荐系统中;或者基于数据变更发送通知。
理解数据流
当需要在多个存储系统中维护相同数据的副本以满足不同的访问模式时,你要对输入和输出了如指掌:哪些数据先写入,哪些数据表示派生自哪些来源?如何以正确的格式,将所有数据导入正确的地方?
例如,你可能会首先将数据写入 记录系统 数据库,捕获对该数据库所做的变更(请参阅 “变更数据捕获”),然后将变更以相同的顺序应用于搜索索引。如果变更数据捕获(CDC)是更新索引的唯一方式,则可以确定该索引完全派生自记录系统,因此与其保持一致(除软件错误外)。写入数据库是向该系统提供新输入的唯一方式。
允许应用程序直接写入搜索索引和数据库引入了如 图 12-4 所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。
如果你可以通过单个系统来提供所有用户输入,从而决定所有写入的排序,则通过按相同顺序处理写入,可以更容易地派生出其他数据表示。这是状态机复制方法的一个应用,我们在 “全序广播” 中看到。无论你使用变更数据捕获还是事件溯源日志,都不如简单的基于全序的决策原则更重要。
基于事件日志来更新派生数据的系统,通常可以做到 确定性 与 幂等性(请参阅 “幂等性”),使得从故障中恢复相当容易。
派生数据与分布式事务
保持不同数据系统彼此一致的经典方法涉及分布式事务,如 “原子提交与两阶段提交” 中所述。与分布式事务相比,使用派生数据系统的方法如何?
在抽象层面,它们通过不同的方式达到类似的目标。分布式事务通过 锁 进行互斥来决定写入的顺序(请参阅 “两阶段锁定”),而 CDC 和事件溯源使用日志进行排序。分布式事务使用原子提交来确保变更只生效一次,而基于日志的系统通常基于 确定性重试 和 幂等性。
最大的不同之处在于事务系统通常提供 线性一致性,这包含着有用的保证,例如 读己之写。另一方面,派生数据系统通常是异步更新的,因此它们默认不会提供相同的时序保证。
在愿意为分布式事务付出代价的有限场景中,它们已被成功应用。但是,我认为 XA 的容错能力和性能很差劲(请参阅 “实践中的分布式事务”),这严重限制了它的实用性。我相信为分布式事务设计一种更好的协议是可行的。但使这样一种协议被现有工具广泛接受是很有挑战的,且不是立竿见影的事。
在没有广泛支持的良好分布式事务协议的情况下,我认为基于日志的派生数据是集成不同数据系统的最有前途的方法。然而,诸如读己之写的保证是有用的,我认为告诉所有人 “最终一致性是不可避免的 —— 忍一忍并学会和它打交道” 是没有什么建设性的(至少在缺乏 如何 应对的良好指导时)。
在本章后文中,我们将讨论一些在异步派生系统之上实现更强保障的方法,并迈向分布式事务和基于日志的异步系统之间的中间地带。
全序的限制
对于足够小的系统,构建一个完全有序的事件日志是完全可行的(正如单主复制数据库的流行所证明的那样,它正好建立了这样一种日志)。但是,随着系统向更大更复杂的工作负载伸缩,限制开始出现:
- 在大多数情况下,构建完全有序的日志,需要所有事件汇集于决定顺序的 单个领导者节点。如果事件吞吐量大于单台计算机的处理能力,则需要将其分区到多台计算机上(请参阅 “分区日志”)。然后两个不同分区中的事件顺序关系就不明确了。
- 如果服务器分布在多个 地理位置分散 的数据中心上,例如为了容忍整个数据中心掉线,你通常在每个数据中心都有单独的主库,因为网络延迟会导致同步的跨数据中心协调效率低下(请参阅 “多主复制”)。这意味着源自两个不同数据中心的事件顺序未定义。
- 将应用程序部署为微服务时(请参阅 “服务中的数据流:REST 与 RPC”),常见的设计选择是将每个服务及其持久状态作为独立单元进行部署,服务之间不共享持久状态。当两个事件来自不同的服务时,这些事件间的顺序未定义。
- 某些应用程序在客户端保存状态,该状态在用户输入时立即更新(无需等待服务器确认),甚至可以继续脱机工作(请参阅 “需要离线操作的客户端”)。对于这样的应用程序,客户端和服务器很可能以不同的顺序看到事件。
在形式上,决定事件的全局顺序称为 全序广播,相当于 共识(请参阅 “共识算法和全序广播”)。大多数共识算法都是针对单个节点的吞吐量足以处理整个事件流的情况而设计的,并且这些算法不提供多个节点共享事件排序工作的机制。设计可以伸缩至单个节点的吞吐量之上,且在地理位置分散环境中仍能良好工作的共识算法仍然是一个开放研究问题。
排序事件以捕获因果关系
在事件之间不存在因果关系的情况下,全序的缺乏并不是一个大问题,因为并发事件可以任意排序。其他一些情况很容易处理:例如,当同一对象有多个更新时,它们可以通过将特定对象 ID 的所有更新路由到相同的日志分区来完全排序。然而,因果关系有时会以更微妙的方式出现(请参阅 “顺序与因果关系”)。
例如,考虑一个社交网络服务,以及一对曾处于恋爱关系但刚分手的用户。其中一个用户将另一个用户从好友中移除,然后向剩余的好友发送消息,抱怨他们的前任。用户的心思是他们的前任不应该看到这些粗鲁的消息,因为消息是在好友状态解除后发送的。
但是如果好友关系状态与消息存储在不同的地方,在这样一个系统中,可能会出现 解除好友 事件与 发送消息 事件之间的因果依赖丢失的情况。如果因果依赖关系没有被捕捉到,则发送有关新消息的通知的服务可能会在 解除好友 事件之前处理 发送消息 事件,从而错误地向前任发送通知。
在本例中,通知实际上是消息和好友列表之间的连接,使得它与我们先前讨论的连接的时序问题有关(请参阅 “连接的时间依赖性”)。不幸的是,这个问题似乎并没有一个简单的答案2 3。起点包括:
- 逻辑时间戳可以提供无需协调的全局顺序(请参阅 “序列号顺序”),因此它们可能有助于全序广播不可行的情况。但是,他们仍然要求收件人处理不按顺序发送的事件,并且需要传递其他元数据。
- 如果你可以记录一个事件来记录用户在做出决定之前所看到的系统状态,并给该事件一个唯一的标识符,那么后面的任何事件都可以引用该事件标识符来记录因果关系4。我们将在 “读也是事件” 中回到这个想法。
- 冲突解决算法(请参阅 “自动冲突解决”)有助于处理以意外顺序传递的事件。它们对于维护状态很有用,但如果行为有外部副作用(例如,给用户发送通知),就没什么帮助了。
也许,随着时间的推移,应用开发模式将出现,使得能够有效地捕获因果依赖关系,并且保持正确的派生状态,而不会迫使所有事件经历全序广播的瓶颈)。
批处理与流处理
我会说数据集成的目标是,确保数据最终能在所有正确的地方表现出正确的形式。这样做需要消费输入、转换、连接、过滤、聚合、训练模型、评估、以及最终写出适当的输出。批处理和流处理是实现这一目标的工具。
批处理和流处理的输出是派生数据集,例如搜索索引、物化视图、向用户显示的建议、聚合指标等(请参阅 “批处理工作流的输出” 和 “流处理的应用”)。
正如我们在 第十一章 和 第十二章 中看到的,批处理和流处理有许多共同的原则,主要的根本区别在于流处理器在无限数据集上运行,而批处理输入是已知的有限大小。
维护派生状态
批处理有着很强的函数式风格(即使其代码不是用函数式语言编写的):它鼓励确定性的纯函数,其输出仅依赖于输入,除了显式输出外没有副作用,将输入视作不可变的,且输出是仅追加的。流处理与之类似,但它扩展了算子以允许受管理的、容错的状态(请参阅 “失败后重建状态”)。
具有良好定义的输入和输出的确定性函数的原理不仅有利于容错(请参阅 “幂等性”),也简化了有关组织中数据流的推理5。无论派生数据是搜索索引、统计模型还是缓存,采用这种观点思考都是很有帮助的:将其视为从一个东西派生出另一个的数据管道,通过函数式应用代码推送一个系统的状态变更,并将其效果应用至派生系统中。
原则上,派生数据系统可以同步地维护,就像关系数据库在与索引表写入操作相同的事务中同步更新次级索引一样。然而,异步是使基于事件日志的系统稳健的原因:它允许系统的一部分故障被抑制在本地。而如果任何一个参与者失败,分布式事务将中止,因此它们倾向于通过将故障传播到系统的其余部分来放大故障(请参阅 “分布式事务的限制”)。
我们在 “分区与次级索引” 中看到,次级索引经常跨越分区边界。具有次级索引的分区系统需要将写入发送到多个分区(如果索引按关键词分区的话)或将读取发送到所有分区(如果索引是按文档分区的话)。如果索引是异步维护的,这种跨分区通信也是最可靠和最可伸缩的6(另请参阅 “多分区数据处理”)。
应用演化后重新处理数据
在维护派生数据时,批处理和流处理都是有用的。流处理允许将输入中的变化以低延迟反映在派生视图中,而批处理允许重新处理大量累积的历史数据以便将新视图导出到现有数据集上。
特别是,重新处理现有数据为维护系统、演化并支持新功能和需求变更提供了一个良好的机制(请参阅 第四章)。没有重新进行处理,模式演化将仅限于简单的变化,例如向记录中添加新的可选字段或添加新类型的记录。无论是在写时模式还是在读时模式中都是如此(请参阅 “文档模型中的模式灵活性”)。另一方面,通过重新处理,可以将数据集重组为一个完全不同的模型,以便更好地满足新的要求。
铁路上的模式迁移
大规模的 “模式迁移” 也发生在非计算机系统中。例如,在 19 世纪英国铁路建设初期,轨距(两轨之间的距离)就有了各种各样的竞争标准。为一种轨距而建的列车不能在另一种轨距的轨道上运行,这限制了火车网络中可能的相互连接7。
在 1846 年最终确定了一个标准轨距之后,其他轨距的轨道必须转换 —— 但是如何在不停运火车线路的情况下进行数月甚至数年的迁移?解决的办法是首先通过添加第三条轨道将轨道转换为 双轨距(dual gauge) 或 混合轨距。这种转换可以逐渐完成,当完成时,两种轨距的列车都可以在线路上跑,使用三条轨道中的两条。事实上,一旦所有的列车都转换成标准轨距,那么可以移除提供非标准轨距的轨道。
以这种方式 “再加工” 现有的轨道,让新旧版本并存,可以在几年的时间内逐渐改变轨距。然而,这是一项昂贵的事业,这就是今天非标准轨距仍然存在的原因。例如,旧金山湾区的 BART 系统使用了与美国大部分地区不同的轨距。
派生视图允许 渐进演化(gradual evolution)。如果你想重新构建数据集,不需要执行突然切换式的迁移。取而代之的是,你可以将旧架构和新架构并排维护为相同基础数据上的两个独立派生视图。然后可以开始将少量用户转移到新视图,以测试其性能并发现任何错误,而大多数用户仍然会被路由到旧视图。你可以逐渐地增加访问新视图的用户比例,最终可以删除旧视图8。
这种逐渐迁移的美妙之处在于,如果出现问题,每个阶段的过程都很容易逆转:你始终有一个可以回滚的可用系统。通过降低不可逆损害的风险,你能对继续前进更有信心,从而更快地改善系统9。
统一批处理和流处理
早期统一批处理与流处理的提案是 Lambda 架构10,但它有不少问题,并且已经逐渐淡出主流。更新的系统允许在同一个系统中同时实现批计算(重处理历史数据)和流计算(事件到达即处理)11。
在一个系统中统一批处理和流处理需要以下功能,这些功能也正在越来越广泛地被提供:
- 通过处理最近事件流的相同处理引擎来重播历史事件的能力。例如,基于日志的消息代理可以重播消息(请参阅 “重播旧消息”),某些流处理器可以从 HDFS 等分布式文件系统读取输入。
- 对于流处理器来说,恰好一次语义 —— 即确保输出与未发生故障的输出相同,即使事实上发生故障(请参阅 “容错”)。与批处理一样,这需要丢弃任何失败任务的部分输出。
- 按事件时间进行窗口化的工具,而不是按处理时间进行窗口化,因为处理历史事件时,处理时间毫无意义(请参阅 “时间推理”)。例如,Apache Beam 提供了用于表达这种计算的 API,可以在 Apache Flink 或 Google Cloud Dataflow 使用。
分拆数据库
在最抽象的层面上,数据库、批/流处理器和操作系统都在做相似的事情:存储数据,并允许你处理和查询这些数据12。数据库将数据存储为某种数据模型下的记录(例如表行、文档、图顶点等),而操作系统文件系统将数据存为文件;但它们本质上都可视作 “信息管理” 系统13。正如我们在 第十一章 中看到的,批处理系统在很多方面像是 Unix 的分布式版本。
当然,有很多实际的差异。例如,许多文件系统都不能很好地处理包含 1000 万个小文件的目录,而包含 1000 万个小记录的数据库完全是寻常而不起眼的。无论如何,操作系统和数据库之间的相似之处和差异值得探讨。
Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Unix 认为它的目的是为程序员提供一种相当低层次的硬件的逻辑抽象,而关系数据库则希望为应用程序员提供一种高层次的抽象,以隐藏磁盘上数据结构的复杂性、并发性、崩溃恢复等等。Unix 发展出的管道和文件只是字节序列,而数据库则发展出了 SQL 和事务。
哪种方法更好?当然这取决于你想要的是什么。Unix 是 “简单的”,因为它是对硬件资源相当薄的包装;关系数据库是 “更简单” 的,因为一个简短的声明性查询可以利用很多强大的基础设施(查询优化、索引、连接方法、并发控制、复制等),而不需要查询的作者理解其实现细节。
这些哲学之间的矛盾已经持续了几十年(Unix 和关系模型都出现在 70 年代初),仍然没有解决。例如,我将 NoSQL 运动解释为,希望将类 Unix 的低级别抽象方法应用于分布式 OLTP 数据存储的领域。
在这一部分我将试图调和这两个哲学,希望我们能各取其美。
组合使用数据存储技术
在本书的过程中,我们讨论了数据库提供的各种功能及其工作原理,其中包括:
- 次级索引,使你可以根据字段的值有效地搜索记录(请参阅 “其他索引结构”)
- 物化视图,这是一种预计算的查询结果缓存(请参阅 “聚合:数据立方体和物化视图”)
- 复制日志,保持其他节点上数据的副本最新(请参阅 “复制日志的实现”)
- 全文检索索引,允许在文本中进行关键字搜索(请参阅 “全文检索与模糊索引”),也内置于某些关系数据库1
在 第十一章 和 第十二章 中,出现了类似的主题。我们讨论了如何构建全文检索索引(请参阅 “批处理工作流的输出”),了解了如何维护物化视图(请参阅 “维护物化视图”)以及如何将变更从数据库复制到派生数据系统(请参阅 “变更数据捕获”)。
数据库中内置的功能与人们用批处理和流处理器构建的派生数据系统似乎有相似之处。
创建索引
想想当你运行 CREATE INDEX 在关系数据库中创建一个新的索引时会发生什么。数据库必须扫描表的一致性快照,挑选出所有被索引的字段值,对它们进行排序,然后写出索引。然后它必须处理自一致快照以来所做的写入操作(假设表在创建索引时未被锁定,所以写操作可能会继续)。一旦完成,只要事务写入表中,数据库就必须继续保持索引最新。
此过程非常类似于设置新的从库副本(请参阅 “设置新从库”),也非常类似于流处理系统中的 引导(bootstrap) 变更数据捕获(请参阅 “初始快照”)。
无论何时运行 CREATE INDEX,数据库都会重新处理现有数据集(如 “应用演化后重新处理数据” 中所述),并将该索引作为新视图导出到现有数据上。现有数据可能是状态的快照,而不是所有发生变化的日志,但两者密切相关(请参阅 “状态、流和不变性”)。
一切的元数据库
有鉴于此,我认为整个组织的数据流开始像一个巨大的数据库5。每当批处理、流处理或 ETL 过程将数据从一个地方传输并转换到另一个地方时,它都像数据库子系统在维护索引或物化视图。
从这种角度来看,批处理和流处理器就像精心实现的触发器、存储过程和物化视图维护例程。它们维护的派生数据系统就像不同的索引类型。例如,关系数据库可能支持 B 树索引、散列索引、空间索引(请参阅 “多列索引”)以及其他类型的索引。在新兴的派生数据系统架构中,不是将这些设施作为单个集成数据库产品的功能实现,而是由各种不同的软件提供,运行在不同的机器上,由不同的团队管理。
这些发展在未来将会把我们带到哪里?如果我们从没有适合所有访问模式的单一数据模型或存储格式的前提出发,我推测有两种途径可以将不同的存储和处理工具组合成一个有凝聚力的系统:
联合数据库:统一读取
可以为各种各样的底层存储引擎和处理方法提供一个统一的查询接口 —— 一种称为 联合数据库(federated database) 或 多态存储(polystore) 的方法14 15。例如,PostgreSQL 的 外部数据包装器(foreign data wrapper) 功能符合这种模式16。需要专用数据模型或查询接口的应用程序仍然可以直接访问底层存储引擎,而想要组合来自不同位置的数据的用户可以通过联合接口轻松完成操作。
联合查询接口遵循着单一集成系统的关系型传统,带有高级查询语言和优雅的语义,但实现起来非常复杂。
分拆数据库:统一写入
虽然联合能解决跨多个不同系统的只读查询问题,但它并没有很好的解决跨系统 同步 写入的问题。我们说过,在单个数据库中,创建一致的索引是一项内置功能。当我们构建多个存储系统时,我们同样需要确保所有数据变更都会在所有正确的位置结束,即使在出现故障时也是如此。想要更容易地将存储系统可靠地插接在一起(例如,通过变更数据捕获和事件日志),就像将数据库的索引维护功能以可以跨不同技术同步写入的方式分开5 17。
分拆方法遵循 Unix 传统的小型工具,它可以很好地完成一件事18,通过统一的低层级 API(管道)进行通信,并且可以使用更高层级的语言进行组合(shell)12 。
开展分拆工作
联合和分拆是一个硬币的两面:用不同的组件构成可靠、 可伸缩和可维护的系统。联合只读查询需要将一个数据模型映射到另一个数据模型,这需要一些思考,但最终还是一个可解决的问题。而我认为同步写入到几个存储系统是更困难的工程问题,所以我将重点关注它。
传统的同步写入方法需要跨异构存储系统的分布式事务14,我认为这是错误的解决方案(请参阅 “派生数据与分布式事务”)。单个存储或流处理系统内的事务是可行的,但是当数据跨越不同技术之间的边界时,我认为具有幂等写入的异步事件日志是一种更加健壮和实用的方法。
例如,分布式事务在某些流处理组件内部使用,以匹配 恰好一次(exactly-once) 语义(请参阅 “原子提交再现”),这可以很好地工作。然而,当事务需要涉及由不同人群编写的系统时(例如,当数据从流处理组件写入分布式键值存储或搜索索引时),缺乏标准化的事务协议会使集成更难。有幂等消费者的有序事件日志(请参阅 “幂等性”)是一种更简单的抽象,因此在异构系统中实现更加可行5。
基于日志的集成的一大优势是各个组件之间的 松散耦合(loose coupling),这体现在两个方面:
- 在系统级别,异步事件流使整个系统在个别组件的中断或性能下降时更加稳健。如果消费者运行缓慢或失败,那么事件日志可以缓冲消息(请参阅 “磁盘空间使用”),以便生产者和任何其他消费者可以继续不受影响地运行。有问题的消费者可以在问题修复后赶上,因此不会错过任何数据,并且包含故障。相比之下,分布式事务的同步交互往往会将本地故障升级为大规模故障(请参阅 “分布式事务的限制”)。
- 在人力方面,分拆数据系统允许不同的团队独立开发,改进和维护不同的软件组件和服务。专业化使得每个团队都可以专注于做好一件事,并与其他团队的系统以明确的接口交互。事件日志提供了一个足够强大的接口,以捕获相当强的一致性属性(由于持久性和事件的顺序),但也足够普适于几乎任何类型的数据。
分拆系统与集成系统
如果分拆确实成为未来的方式,它也不会取代目前形式的数据库 —— 它们仍然会像以往一样被需要。为了维护流处理组件中的状态,数据库仍然是需要的,并且为批处理和流处理器的输出提供查询服务(请参阅 “批处理工作流的输出” 与 “流处理”)。专用查询引擎对于特定的工作负载仍然非常重要:例如,MPP 数据仓库中的查询引擎针对探索性分析查询进行了优化,并且能够很好地处理这种类型的工作负载(请参阅 “Hadoop 与分布式数据库的对比”)。
运行几种不同基础设施的复杂性可能是一个问题:每种软件都有一个学习曲线,配置问题和操作怪癖,因此部署尽可能少的移动部件是很有必要的。比起使用应用代码拼接多个工具而成的系统,单一集成软件产品也可以在其设计应对的工作负载类型上实现更好、更可预测的性能19。正如在前言中所说的那样,为了不需要的规模而构建系统是白费精力,而且可能会将你锁死在一个不灵活的设计中。实际上,这是一种过早优化的形式。
分拆的目标不是要针对个别数据库与特定工作负载的性能进行竞争;我们的目标是允许你结合多个不同的数据库,以便在比单个软件可能实现的更广泛的工作负载范围内实现更好的性能。这是关于广度,而不是深度 —— 与我们在 “Hadoop 与分布式数据库的对比” 中讨论的存储和处理模型的多样性一样。
因此,如果有一项技术可以满足你的所有需求,那么最好使用该产品,而不是试图用更低层级的组件重新实现它。只有当没有单一软件满足你的所有需求时,才会出现拆分和联合的优势。
围绕数据流设计应用
当底层数据发生变化时去更新派生数据,这个思路并不新鲜。比如电子表格就有很强的数据流编程能力20:你可以在一个单元格写公式(例如对另一列求和),只要输入变化,结果就会自动重算。这正是我们希望数据系统具备的能力:数据库记录一旦变化,相关索引、缓存视图和聚合结果都应自动刷新,而不需要应用开发者关心刷新细节。
从这个意义上说,今天很多数据系统仍可以向 VisiCalc 在 1979 年就具备的特性学习21。与电子表格不同的是,现代数据系统还必须同时满足容错、可伸缩、持久化存储、跨团队异构技术集成等要求,也必须能够复用已有库与服务。指望所有软件都在一种语言、框架或工具上统一实现并不现实。
应用代码作为派生函数
当一个数据集派生自另一个数据集时,它会经历某种转换函数。例如:
- 次级索引是由一种直白的转换函数生成的派生数据集:对于基础表中的每行或每个文档,它挑选被索引的列或字段中的值,并按这些值排序(假设使用 B 树或 SSTable 索引,按键排序,如 第四章 所述)。
- 全文检索索引是通过应用各种自然语言处理函数而创建的,诸如语言检测、分词、词干或词汇化、拼写纠正和同义词识别,然后构建用于高效查找的数据结构(例如倒排索引)。
- 在机器学习系统中,我们可以将模型视作从训练数据通过应用各种特征提取、统计分析函数派生的数据,当模型应用于新的输入数据时,模型的输出是从输入和模型(因此间接地从训练数据)中派生的。
- 缓存通常包含将以用户界面(UI)显示的形式的数据聚合。因此填充缓存需要知道 UI 中引用的字段;UI 中的变更可能需要更新缓存填充方式的定义,并重建缓存。
用于次级索引的派生函数是如此常用的需求,以致于它作为核心功能被内建至许多数据库中,你可以简单地通过 CREATE INDEX 来调用它。对于全文索引,常见语言的基本语言特征可能内置到数据库中,但更复杂的特征通常需要领域特定的调整。在机器学习中,特征工程是众所周知的特定于应用的特征,通常需要包含很多关于用户交互与应用部署的详细知识22。
当创建派生数据集的函数不是像创建次级索引那样的标准搬砖函数时,需要自定义代码来处理特定于应用的东西。而这个自定义代码是让许多数据库挣扎的地方,虽然关系数据库通常支持触发器、存储过程和用户定义的函数,可以用它们来在数据库中执行应用代码,但它们有点像数据库设计里的事后反思。(请参阅 “传递事件流”)。
应用代码和状态的分离
理论上,数据库可以是任意应用代码的部署环境,就如同操作系统一样。然而实践中它们对这一目标适配的很差。它们不满足现代应用开发的要求,例如依赖和软件包管理、版本控制、滚动升级、可演化性、监控、指标、对网络服务的调用以及与外部系统的集成。
另一方面,Mesos、YARN、Docker、Kubernetes 等部署和集群管理工具专为运行应用代码而设计。通过专注于做好一件事情,他们能够做得比将数据库作为其众多功能之一执行用户定义的功能要好得多。
我认为让系统的某些部分专门用于持久数据存储并让其他部分专门运行应用程序代码是有意义的。这两者可以在保持独立的同时互动。
现在大多数 Web 应用程序都是作为无状态服务部署的,其中任何用户请求都可以路由到任何应用程序服务器,并且服务器在发送响应后会忘记所有请求。这种部署方式很方便,因为可以随意添加或删除服务器,但状态必须到某个地方:通常是数据库。趋势是将无状态应用程序逻辑与状态管理(数据库)分开:不将应用程序逻辑放入数据库中,也不将持久状态置于应用程序中23。正如函数式编程社区喜欢开玩笑说的那样,“我们相信 教会(Church) 与 国家(state) 的分离”24。
在这个典型的 Web 应用模型中,数据库充当一种可以通过网络同步访问的可变共享变量。应用程序可以读取和更新变量,而数据库负责维持它的持久性,提供一些诸如并发控制和容错的功能。
但是,在大多数编程语言中,你无法订阅可变变量中的变更 —— 你只能定期读取它。与电子表格不同,如果变量的值发生变化,变量的读者不会收到通知(你可以在自己的代码中实现这样的通知 —— 这被称为 观察者模式 —— 但大多数语言没有将这种模式作为内置功能)。
数据库继承了这种可变数据的被动方法:如果你想知道数据库的内容是否发生了变化,通常你唯一的选择就是轮询(即定期重复你的查询)。订阅变更只是刚刚开始出现的功能(请参阅 “变更流的 API 支持”)。
数据流:应用代码与状态变化的交互
从数据流的角度思考应用程序,意味着重新协调应用代码和状态管理之间的关系。我们不再将数据库视作被应用操纵的被动变量,取而代之的是更多地考虑状态,状态变更和处理它们的代码之间的相互作用与协同关系。应用代码通过在另一个地方触发状态变更来响应状态变更。
我们在 “数据库与流” 中看到了这一思路,我们讨论了将数据库的变更日志视为一种我们可以订阅的事件流。诸如 Actor 的消息传递系统(请参阅 “消息传递中的数据流”)也具有响应事件的概念。早在 20 世纪 80 年代,元组空间(tuple space) 模型就已经探索了表达分布式计算的方式:观察状态变更并作出反应的过程25 26。
如前所述,当触发器由于数据变更而被触发时,或次级索引更新以反映索引表中的变更时,数据库内部也发生着类似的情况。分拆数据库意味着将这个想法应用于在主数据库之外,用于创建派生数据集:缓存、全文检索索引、机器学习或分析系统。我们可以为此使用流处理和消息传递系统。
需要记住的重要一点是,维护派生数据不同于执行异步任务。传统的消息传递系统通常是为执行异步任务设计的(请参阅 “日志与传统的消息传递相比”):
- 在维护派生数据时,状态变更的顺序通常很重要(如果多个视图是从事件日志派生的,则需要按照相同的顺序处理事件,以便它们之间保持一致)。如 “确认与重新传递” 中所述,许多消息代理在重传未确认消息时没有此属性,双写也被排除在外(请参阅 “保持系统同步”)。
- 容错是派生数据的关键:仅仅丢失单个消息就会导致派生数据集永远与其数据源失去同步。消息传递和派生状态更新都必须可靠。例如,许多 Actor 系统默认在内存中维护 Actor 的状态和消息,所以如果运行 Actor 的机器崩溃,状态和消息就会丢失。
稳定的消息排序和容错消息处理是相当严格的要求,但与分布式事务相比,它们开销更小,运行更稳定。现代流处理组件可以提供这些排序和可靠性保证,并允许应用代码以流算子的形式运行。
这些应用代码可以执行任意处理,包括数据库内置派生函数通常不提供的功能。就像通过管道链接的 Unix 工具一样,流算子可以围绕着数据流构建大型系统。每个算子接受状态变更的流作为输入,并产生其他状态变化的流作为输出。
流处理器和服务
当今流行的应用开发风格涉及将功能分解为一组通过同步网络请求(如 REST API)进行通信的 服务(service,请参阅 “服务中的数据流:REST 与 RPC”)。这种面向服务的架构优于单一庞大应用的优势主要在于:通过松散耦合来提供组织上的可伸缩性:不同的团队可以专职于不同的服务上,从而减少团队之间的协调工作(因为服务可以独立部署和更新)。
在数据流中组装流算子与微服务方法有很多相似之处27。但底层通信机制是有很大区别:数据流采用单向异步消息流,而不是同步的请求 / 响应式交互。
除了在 “消息传递中的数据流” 中列出的优点(如更好的容错性),数据流系统还能实现更好的性能。例如,假设客户正在购买以一种货币定价,但以另一种货币支付的商品。为了执行货币换算,你需要知道当前的汇率。这个操作可以通过两种方式实现27 28:
- 在微服务方法中,处理购买的代码可能会查询汇率服务或数据库,以获取特定货币的当前汇率。
- 在数据流方法中,处理订单的代码会提前订阅汇率变更流,并在汇率发生变动时将当前汇率存储在本地数据库中。处理订单时只需查询本地数据库即可。
第二种方法能将对另一服务的同步网络请求替换为对本地数据库的查询(可能在同一台机器甚至同一个进程中)。数据流方法不仅更快,而且当其他服务失效时也更稳健。最快且最可靠的网络请求就是压根没有网络请求!我们现在不再使用 RPC,而是在购买事件和汇率更新事件之间建立流联接(请参阅 “流表连接(流扩充)”)。
连接是时间相关的:如果购买事件在稍后的时间点被重新处理,汇率可能已经改变。如果要重建原始输出,则需要获取原始购买时的历史汇率。无论是查询服务还是订阅汇率更新流,你都需要处理这种时间相关性(请参阅 “连接的时间依赖性”)。
订阅变更流,而不是在需要时查询当前状态,使我们更接近类似电子表格的计算模型:当某些数据发生变更时,依赖于此的所有派生数据都可以快速更新。还有很多未解决的问题,例如关于时间相关连接等问题,但我认为围绕数据流构建应用的想法是一个非常有希望的方向。
观察派生数据状态
在抽象层面,上一节讨论的数据流系统给出了创建并维护派生数据集(如搜索索引、物化视图、预测模型)的过程。我们把这称为 写路径(write path):当信息写入系统后,它可能经过多个批处理与流处理阶段,最终所有相关派生数据集都会被更新。图 13-1 展示了搜索索引更新的例子。

但你为什么一开始就要创建派生数据集?很可能是因为你想在以后再次查询它。这就是 读路径(read path):当服务用户请求时,你需要从派生数据集中读取,也许还要对结果进行一些额外处理,然后构建给用户的响应。
总而言之,写路径和读路径涵盖了数据的整个旅程,从收集数据开始,到使用数据结束(可能是由另一个人)。写路径是预计算过程的一部分 —— 即,一旦数据进入,即刻完成,无论是否有人需要看它。读路径是这个过程中只有当有人请求时才会发生的部分。如果你熟悉函数式编程语言,则可能会注意到写路径类似于立即求值,读路径类似于惰性求值。
如 图 13-1 所示,派生数据集是写路径和读路径相遇的地方。它代表了写入时工作量与读取时工作量之间的权衡。
物化视图和缓存
全文检索索引就是一个很好的例子:写路径更新索引,读路径在索引中搜索关键字。读写都需要做一些工作。写入需要更新文档中出现的所有关键词的索引条目。读取需要搜索查询中的每个单词,并应用布尔逻辑来查找包含查询中所有单词(AND 运算符)的文档,或者每个单词(OR 运算符)的任何同义词。
如果没有索引,搜索查询将不得不扫描所有文档(如 grep),如果有着大量文档,这样做的开销巨大。没有索引意味着写入路径上的工作量较少(没有要更新的索引),但是在读取路径上需要更多工作。
另一方面,可以想象为所有可能的查询预先计算搜索结果。在这种情况下,读路径上的工作量会减少:不需要布尔逻辑,只需查找查询结果并返回即可。但写路径会更加昂贵:可能的搜索查询集合是无限大的,因此预先计算所有可能的搜索结果将需要无限的时间和存储空间,这在实践中不可行。
另一种选择是预先计算一组固定的最常见查询的搜索结果,以便可以快速提供它们而无需转到索引。不常见的查询仍然可以通过索引来提供服务。这通常被称为常见查询的 缓存(cache),尽管我们也可以称之为 物化视图(materialized view),因为当新文档出现,且需要被包含在这些常见查询的搜索结果之中时,这些索引就需要更新。
从这个例子中我们可以看到,索引不是写路径和读路径之间唯一可能的边界;缓存常见搜索结果也是可行的;而在少量文档上使用没有索引的类 grep 扫描也是可行的。由此来看,缓存,索引和物化视图的作用很简单:它们改变了读路径与写路径之间的边界。通过预先计算结果,从而允许我们在写路径上做更多的工作,以节省读路径上的工作量。
在写路径上完成的工作和读路径之间的界限,实际上是本书开始处在 “描述负载” 中推特例子里谈到的主题。在该例中,我们还看到了与普通用户相比,名人的写路径和读路径可能有所不同。在 500 页之后,我们已经绕回了起点!
有状态、可离线的客户端
我发现写路径和读路径之间的边界很有趣,因为我们可以试着改变这个边界,并探讨这种改变的实际意义。我们来看看不同上下文中的这一想法。
过去二十年来,Web 应用的火热让我们对应用开发作出了一些很容易视作理所当然的假设。具体来说就是,客户端 / 服务器模型 —— 客户端大多是无状态的,而服务器拥有数据的权威 —— 已经普遍到我们几乎忘掉了还有其他任何模型的存在。但是技术在不断地发展,我认为不时地质疑现状非常重要。
传统上,网络浏览器是无状态的客户端,只有当连接到互联网时才能做一些有用的事情(能离线执行的唯一事情基本上就是上下滚动之前在线时加载好的页面)。然而,最近的 “单页面” JavaScript Web 应用已经获得了很多有状态的功能,包括客户端用户界面交互,以及 Web 浏览器中的持久化本地存储。移动应用可以类似地在设备上存储大量状态,而且大多数用户交互都不需要与服务器往返交互。
这些不断变化的功能重新引发了对 离线优先(offline-first) 应用的兴趣,这些应用尽可能地在同一设备上使用本地数据库,无需连接互联网,并在后台网络连接可用时与远程服务器同步29。由于移动设备通常具有缓慢且不可靠的蜂窝网络连接,因此,如果用户的用户界面不必等待同步网络请求,且应用主要是离线工作的,则这是一个巨大优势(请参阅 “需要离线操作的客户端”)。
当我们摆脱无状态客户端与中央数据库交互的假设,并转向在终端用户设备上维护状态时,这就开启了新世界的大门。特别是,我们可以将设备上的状态视为 服务器状态的缓存。屏幕上的像素是客户端应用中模型对象的物化视图;模型对象是远程数据中心的本地状态副本30。
将状态变更推送给客户端
在典型的网页中,如果你在 Web 浏览器中加载页面,并且随后服务器上的数据发生变更,则浏览器在重新加载页面之前对此一无所知。浏览器只能在一个时间点读取数据,假设它是静态的 —— 它不会订阅来自服务器的更新。因此设备上的状态是陈旧的缓存,除非你显式轮询变更否则不会更新。(像 RSS 这样基于 HTTP 的 Feed 订阅协议实际上只是一种基本的轮询形式)
最近的协议已经超越了 HTTP 的基本请求 / 响应模式:服务端发送的事件(EventSource API)和 WebSockets 提供了通信信道,通过这些信道,Web 浏览器可以与服务器保持打开的 TCP 连接,只要浏览器仍然连接着,服务器就能主动向浏览器推送信息。这为服务器提供了主动通知终端用户客户端的机会,服务器能告知客户端其本地存储状态的任何变化,从而减少客户端状态的陈旧程度。
用我们的写路径与读路径模型来讲,主动将状态变更推至到客户端设备,意味着将写路径一直延伸到终端用户。当客户端首次初始化时,它仍然需要使用读路径来获取其初始状态,但此后它就能够依赖服务器发送的状态变更流了。我们在流处理和消息传递部分讨论的想法并不局限于数据中心中:我们可以进一步采纳这些想法,并将它们一直延伸到终端用户设备31。
这些设备有时会离线,并在此期间无法收到服务器状态变更的任何通知。但是我们已经解决了这个问题:在 “消费者偏移量” 中,我们讨论了基于日志的消息代理的消费者能在失败或断开连接后重连,并确保它不会错过掉线期间任何到达的消息。同样的技术适用于单个用户,每个设备都是一个小事件流的小小订阅者。
端到端的事件流
最近用于开发有状态的客户端与用户界面的工具,例如如 Elm 语言32和 Facebook 的 React、Flux 和 Redux 工具链,已经通过订阅表示用户输入或服务器响应的事件流来管理客户端的内部状态,其结构与事件溯源相似(请参阅 “事件溯源”)。
将这种编程模型扩展为:允许服务器将状态变更事件推送到客户端的事件管道中,是非常自然的。因此,状态变化可以通过 端到端(end-to-end) 的写路径流动:从一个设备上的交互触发状态变更开始,经由事件日志,并穿过几个派生数据系统与流处理器,一直到另一台设备上的用户界面,而有人正在观察用户界面上的状态变化。这些状态变化能以相当低的延迟传播 —— 比如说,在一秒内从一端到另一端。
一些应用(如即时消息传递与在线游戏)已经具有这种 “实时” 架构(在低延迟交互的意义上,不是在 “响应时间保证” 中的意义上)。但我们为什么不用这种方式构建所有的应用?
挑战在于,关于无状态客户端和请求 / 响应交互的假设已经根深蒂固地植入在我们的数据库、库、框架以及协议之中。许多数据存储支持读取与写入操作,为请求返回一个响应,但只有极少数提供订阅变更的能力 —— 请求返回一个随时间推移的响应流(请参阅 “变更流的 API 支持” )。
为了将写路径延伸至终端用户,我们需要从根本上重新思考我们构建这些系统的方式:从请求 / 响应交互转向发布 / 订阅数据流30。更具响应性的用户界面与更好的离线支持,我认为这些优势值得我们付出努力。如果你正在设计数据系统,我希望你对订阅变更的选项留有印象,而不只是查询当前状态。
读也是事件
我们讨论过,当流处理器将派生数据写入存储(数据库,缓存或索引)时,以及当用户请求查询该存储时,存储将充当写路径和读路径之间的边界。该存储应当允许对数据进行随机访问的读取查询,否则这些查询将需要扫描整个事件日志。
在很多情况下,数据存储与流处理系统是分开的。但回想一下,流处理器还是需要维护状态以执行聚合和连接的(请参阅 “流连接”)。这种状态通常隐藏在流处理器内部,但一些框架也允许这些状态被外部客户端查询33,将流处理器本身变成一种简单的数据库。
我愿意进一步思考这个想法。正如到目前为止所讨论的那样,对存储的写入是通过事件日志进行的,而读取是临时的网络请求,直接流向存储着待查数据的节点。这是一个合理的设计,但不是唯一可行的设计。也可以将读取请求表示为事件流,并同时将读事件与写事件送往流处理器;流处理器通过将读取结果发送到输出流来响应读取事件34。
当写入和读取都被表示为事件,并且被路由到同一个流算子以便处理时,我们实际上是在读取查询流和数据库之间执行流表连接。读取事件需要被送往保存数据的数据库分区(请参阅 “请求路由”),就像批处理和流处理器在连接时需要在同一个键上对输入分区一样(请参阅 “Reduce 侧连接与分组”)。
服务请求与执行连接之间的这种相似之处是非常关键的35。一次性读取请求只是将请求传过连接算子,然后请求马上就被忘掉了;而一个订阅请求,则是与连接另一侧过去与未来事件的持久化连接。
记录读取事件的日志可能对于追踪整个系统中的因果关系与数据来源也有好处:它可以让你重现出当用户做出特定决策之前看见了什么。例如在网商中,向客户显示的预测送达日期与库存状态,可能会影响他们是否选择购买一件商品4。要分析这种联系,则需要记录用户查询运输与库存状态的结果。
将读取事件写入持久存储可以更好地跟踪因果关系(请参阅 “排序事件以捕获因果关系”),但会产生额外的存储与 I/O 成本。优化这些系统以减少开销仍然是一个开放的研究问题2。但如果你已经出于运维目的留下了读取请求日志,将其作为请求处理的副作用,那么将这份日志作为请求事件源并不是什么特别大的变更。
多分区数据处理
对于只涉及单个分区的查询,通过流来发送查询与收集响应可能是杀鸡用牛刀了。然而,这个想法开启了分布式执行复杂查询的可能性,这需要合并来自多个分区的数据,利用了流处理器已经提供的消息路由、分区和连接的基础设施。
Storm 的分布式 RPC 功能支持这种使用模式(请参阅 “消息传递和 RPC”)。例如,它已经被用来计算浏览过某个推特 URL 的人数 —— 即,发推包含该 URL 的所有人的粉丝集合的并集36。由于推特的用户是分区的,因此这种计算需要合并来自多个分区的结果。
这种模式的另一个例子是欺诈预防:为了评估特定购买事件是否具有欺诈风险,你可以检查该用户 IP 地址,电子邮件地址,帐单地址,送货地址的信用分。这些信用数据库中的每一个都是有分区的,因此为特定购买事件采集分数需要连接一系列不同的分区数据集37。
MPP 数据库的内部查询执行图有着类似的特征(请参阅 “Hadoop 与分布式数据库的对比”)。如果需要执行这种多分区连接,则直接使用提供此功能的数据库,可能要比使用流处理器实现它要更简单。然而将查询视为流提供了一种选项,可以用于实现超出传统现成解决方案的大规模应用。
追求正确性
对于只读取数据的无状态服务,出问题也没什么大不了的:你可以修复该错误并重启服务,而一切都恢复正常。像数据库这样的有状态系统就没那么简单了:它们被设计为永远记住事物(或多或少),所以如果出现问题,这种(错误的)效果也将潜在地永远持续下去,这意味着它们需要更仔细的思考38。
我们希望构建可靠且 正确 的应用(即使面对各种故障,程序的语义也能被很好地定义与理解)。约四十年来,原子性、隔离性和持久性(第八章)等事务特性一直是构建正确应用的首选工具。然而这些地基没有看上去那么牢固:例如弱隔离级别带来的困惑可以佐证(请参阅 “弱隔离级别”)。
事务在某些领域被完全抛弃,并被提供更好性能与可伸缩性的模型取代,但后者有更复杂的语义(例如,请参阅 “无主复制”)。一致性(Consistency) 经常被谈起,但其定义并不明确(请参阅 “一致性” 和 第十章)。有些人断言我们应当为了高可用而 “拥抱弱一致性”,但却对这些概念实际上意味着什么缺乏清晰的认识。
对于如此重要的话题,我们的理解,以及我们的工程方法却是惊人地薄弱。例如,确定在特定事务隔离等级或复制配置下运行特定应用是否安全是非常困难的39 40。通常简单的解决方案似乎在低并发性的情况下工作正常,并且没有错误,但在要求更高的情况下却会出现许多微妙的错误。
例如,Kyle Kingsbury 的 Jepsen 实验41标出了一些产品声称的安全保证与其在网络问题与崩溃时的实际行为之间的明显差异。即使像数据库这样的基础设施产品没有问题,应用代码仍然需要正确使用它们提供的功能才行,如果配置很难理解,这是很容易出错的(在这种情况下指的是弱隔离级别,法定人数配置等)。
如果你的应用可以容忍偶尔的崩溃,以及以不可预料的方式损坏或丢失数据,那生活就要简单得多,而你可能只要双手合十念阿弥陀佛,期望佛祖能保佑最好的结果。另一方面,如果你需要更强的正确性保证,那么可串行化与原子提交就是久经考验的方法,但它们是有代价的:它们通常只在单个数据中心中工作(这就排除了地理位置分散的架构),并限制了系统能够实现的规模与容错特性。
虽然传统的事务方法并没有走远,但我也相信在使应用正确而灵活地处理错误方面上,事务也不是最后一个可以谈的。在本节中,我将提出一些在数据流架构中考量正确性的方式。
数据库的端到端原则
仅仅因为一个应用程序使用了具有相对较强安全属性的数据系统(例如可串行化的事务),并不意味着就可以保证没有数据丢失或损坏。例如,如果某个应用有个 Bug,导致它写入不正确的数据,或者从数据库中删除数据,那么可串行化的事务也救不了你。
这个例子可能看起来很无聊,但值得认真对待:应用会出 Bug,而人也会犯错误。我在 “状态、流和不变性” 中使用了这个例子来支持不可变和仅追加的数据,阉割掉错误代码摧毁良好数据的能力,能让从错误中恢复更为容易。
虽然不变性很有用,但它本身并非万灵药。让我们来看一个可能发生的、非常微妙的数据损坏案例。
恰好执行一次操作
在 “容错” 中,我们见到了 恰好一次(或 等效一次)语义的概念。如果在处理消息时出现问题,你可以选择放弃(丢弃消息 —— 导致数据丢失)或重试。如果重试,就会有这种风险:第一次实际上成功了,只不过你没有发现。结果这个消息就被处理了两次。
处理两次是数据损坏的一种形式:为同样的服务向客户收费两次(收费太多)或增长计数器两次(夸大指标)都不是我们想要的。在这种情况下,恰好一次意味着安排计算,使得最终效果与没有发生错误的情况一样,即使操作实际上因为某种错误而重试。我们先前讨论过实现这一目标的几种方法。
最有效的方法之一是使操作 幂等(idempotent,请参阅 “幂等性”):即确保它无论是执行一次还是执行多次都具有相同的效果。但是,将不是天生幂等的操作变为幂等的操作需要一些额外的努力与关注:你可能需要维护一些额外的元数据(例如更新了值的操作 ID 集合),并在从一个节点故障切换至另一个节点时做好防护(请参阅 “领导者和锁”)。
抑制重复
除了流处理之外,其他许多地方也需要抑制重复的模式。例如,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。因此,尽管 例 13-1 是一个事务原子性的标准样例,但它实际上并不正确,而真正的银行并不会这样办事3。
两阶段提交(请参阅 “原子提交与两阶段提交”)协议会破坏 TCP 连接与事务之间的 1:1 映射,因为它们必须在故障后允许事务协调器重连到数据库,告诉数据库将存疑事务提交还是中止。这足以确保事务只被恰好执行一次吗?不幸的是,并不能。
即使我们可以抑制数据库客户端与服务器之间的重复事务,我们仍然需要担心终端用户设备与应用服务器之间的网络。例如,如果终端用户的客户端是 Web 浏览器,则它可能会使用 HTTP POST 请求向服务器提交指令。也许用户正处于一个信号微弱的蜂窝数据网络连接中,它们成功地发送了 POST,但却在能够从服务器接收响应之前没了信号。
在这种情况下,可能会向用户显示错误消息,而他们可能会手动重试。Web 浏览器警告说,“你确定要再次提交这个表单吗?” —— 用户选 “是”,因为他们希望操作发生(Post/Redirect/Get 模式42可以避免在正常操作中出现此警告消息,但 POST 请求超时就没办法了)。从 Web 服务器的角度来看,重试是一个独立的请求;从数据库的角度来看,这是一个独立的事务。通常的除重机制无济于事。
操作标识符
要在通过几跳的网络通信上使操作具有幂等性,仅仅依赖数据库提供的事务机制是不够的,你需要考虑 端到端(end-to-end) 的请求流。
例如,你可以为操作生成一个唯一标识符(例如 UUID),并将其作为隐藏表单字段包含在客户端应用中,或通过计算所有相关表单字段的哈希来生成操作 ID3。如果浏览器提交了两次 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;例 13-2 依赖于 request_id 列上的唯一约束。如果事务尝试插入已存在的 ID,INSERT 会失败并中止事务,从而避免重复生效。即使在较弱隔离级别下,关系数据库通常也能正确维护唯一性约束(而应用层的 “先检查再插入” 在不可串行化隔离下可能失败,见 “写入偏差与幻读”)。
除了抑制重复请求,例 13-2 中的 requests 表本身也像一份事件日志,可用于事件溯源或变更数据捕获。账户余额更新并不一定要与事件插入放在同一事务中,因为余额是可由下游消费者从请求事件派生出的冗余状态;只要请求事件被恰好处理一次(同样可通过请求 ID 保证),即可保持正确性。
端到端原则
抑制重复事务的这种情况只是一个更普遍的原则的一个例子,这个原则被称为 端到端原则(end-to-end argument),它在 1984 年由 Saltzer、Reed 和 Clark 阐述43:
只有在通信系统两端应用的知识与帮助下,所讨论的功能才能完全地正确地实现。因而将这种被质疑的功能作为通信系统本身的功能是不可能的(有时,通信系统可以提供这种功能的不完备版本,可能有助于提高性能)。
在我们的例子中 所讨论的功能 是重复抑制。我们看到 TCP 在 TCP 连接层次抑制了重复的数据包,一些流处理器在消息处理层次提供了所谓的恰好一次语义,但这些都无法阻止当一个请求超时时,用户亲自提交重复的请求。TCP,数据库事务,以及流处理器本身并不能完全排除这些重复。解决这个问题需要一个端到端的解决方案:从终端用户的客户端一路传递到数据库的事务标识符。
端到端原则也适用于检查数据的完整性:以太网,TCP 和 TLS 中内置的校验和可以检测网络中数据包的损坏情况,但是它们无法检测到由连接两端发送 / 接收软件中 Bug 导致的损坏。或数据存储所在磁盘上的损坏。如果你想捕获数据所有可能的损坏来源,你也需要端到端的校验和。
类似的原则也适用于加密43:家庭 WiFi 网络上的密码可以防止人们窃听你的 WiFi 流量,但无法阻止互联网上其他地方攻击者的窥探;客户端与服务器之间的 TLS/SSL 可以阻挡网络攻击者,但无法阻止恶意服务器。只有端到端的加密和认证可以防止所有这些事情。
尽管低层级的功能(TCP 重复抑制、以太网校验和、WiFi 加密)无法单独提供所需的端到端功能,但它们仍然很有用,因为它们能降低较高层级出现问题的可能性。例如,如果我们没有 TCP 来将数据包排成正确的顺序,那么 HTTP 请求通常就会被搅烂。我们只需要记住,低级别的可靠性功能本身并不足以确保端到端的正确性。
在数据系统中应用端到端思考
这将我带回最初的论点:仅仅因为应用使用了提供相对较强安全属性的数据系统,例如可串行化的事务,并不意味着应用的数据就不会丢失或损坏了。应用本身也需要采取端到端的措施,例如除重。
这实在是一个遗憾,因为容错机制很难弄好。低层级的可靠机制(比如 TCP 中的那些)运行的相当好,因而剩下的高层级错误基本很少出现。如果能将这些剩下的高层级容错机制打包成抽象,而应用不需要再去操心,那该多好呀 —— 但恐怕我们还没有找到这一正确的抽象。
长期以来,事务被认为是一个很好的抽象,我相信它们确实是很有用的。正如 第八章 中所讨论的,它们将各种可能的问题(并发写入、违背约束、崩溃、网络中断、磁盘故障)合并为两种可能结果:提交或中止。这是对编程模型而言的一种巨大简化,但这还不够。
事务是代价高昂的,当涉及异构存储技术时尤为甚(请参阅 “实践中的分布式事务”)。我们拒绝使用分布式事务是因为它开销太大,结果我们最后不得不在应用代码中重新实现容错机制。正如本书中大量的例子所示,对并发性与部分失败的推理是困难且违反直觉的,所以我怀疑大多数应用级别的机制都不能正确工作,最终结果是数据丢失或损坏。
出于这些原因,我认为探索对容错的抽象是很有价值的。它使提供应用特定的端到端的正确性属性变得更简单,而且还能在大规模分布式环境中提供良好的性能与运维特性。
强制约束
让我们思考一下在 分拆数据库 上下文中的 正确性(correctness)。我们看到端到端的除重可以通过从客户端一路透传到数据库的请求 ID 实现。那么其他类型的约束呢?
我们先来特别关注一下 唯一性约束 —— 例如我们在 例 13-2 中所依赖的约束。在 “约束和唯一性保证” 中,我们看到了几个其他需要强制实施唯一性的应用功能例子:用户名或电子邮件地址必须唯一标识用户,文件存储服务不能包含多个重名文件,两个人不能在航班或剧院预订同一个座位。
其他类型的约束也非常类似:例如,确保帐户余额永远不会变为负数,确保不会超卖库存,或者会议室没有重复的预订。执行唯一性约束的技术通常也可以用于这些约束。
唯一性约束需要达成共识
在 第十章 中我们看到,在分布式环境中,强制执行唯一性约束需要共识:如果存在多个具有相同值的并发请求,则系统需要决定冲突操作中的哪一个被接受,并拒绝其他违背约束的操作。
达成这一共识的最常见方式是使单个节点作为领导,并使其负责所有决策。只要你不介意所有请求都挤过单个节点(即使客户端位于世界的另一端),只要该节点没有失效,系统就能正常工作。如果你需要容忍领导者失效,那么就又回到了共识问题(请参阅 “单主复制与共识”)。
唯一性检查可以通过对唯一性字段分区做横向伸缩。例如,如果需要通过请求 ID 确保唯一性(如 例 13-2 所示),你可以确保所有具有相同请求 ID 的请求都被路由到同一分区(请参阅 第七章)。如果你需要让用户名是唯一的,则可以按用户名的散列值做分区。
但异步多主复制排除在外,因为可能会发生不同主库同时接受冲突写操作的情况,因而这些值不再是唯一的(请参阅 “实现线性一致的系统”)。如果你想立刻拒绝任何违背约束的写入,同步协调是无法避免的44。
基于日志消息传递中的唯一性
日志确保所有消费者以相同顺序看到消息,这在形式上称为 全序广播(total order broadcast),并且等价于共识(请参阅 “全序广播”)。在基于日志消息传递的分拆数据库方案中,我们可以用同样的思路来实施唯一性约束。
流处理器在单个线程上依次消费单个日志分区中的所有消息(请参阅 “日志与传统的消息传递相比”)。因此,如果日志是按需要确保唯一的值做的分区,则流处理器可以无歧义地、确定性地决定几个冲突操作中的哪一个先到达。例如,在多个用户尝试宣告相同用户名的情况下45:
- 每个对用户名的请求都被编码为一条消息,并追加到按用户名散列值确定的分区。
- 流处理器依序读取日志中的请求,并使用本地数据库来追踪哪些用户名已经被占用了。对于所有申请可用用户名的请求,它都会记录该用户名,并向输出流发送一条成功消息。对于所有申请已占用用户名的请求,它都会向输出流发送一条拒绝消息。
- 请求用户名的客户端监视输出流,等待与其请求相对应的成功或拒绝消息。
该算法基本上与 “使用全序广播实现线性一致的存储” 中的算法相同。它可以简单地通过增加分区数伸缩至较大的请求吞吐量,因为每个分区都可以被独立处理。
该方法不仅适用于唯一性约束,而且适用于许多其他类型的约束。其基本原理是,任何可能冲突的写入都会路由到相同的分区并按顺序处理。正如 “什么是冲突?” 与 “写入偏差与幻读” 中所述,冲突的定义可能取决于应用,但流处理器可以使用任意逻辑来验证请求。这个想法与 Bayou 在 90 年代开创的方法类似46。
多分区请求处理
当请求涉及多个分区时,如何在满足约束的同时保证原子效果,会更有挑战性。在 例 13-2 中,至少可能涉及三个分区:请求 ID 所在分区、收款账户所在分区、付款账户所在分区。它们彼此独立,并不必然位于同一分区。
在传统数据库方案里,这类事务通常需要跨分区原子提交;这会把事务强行纳入跨分区全序,从而引入同步协调开销并影响吞吐量。
但使用分区日志与流处理器,也可以在不使用跨分区原子提交的情况下达到等价正确性。

- 客户端为转账请求生成全局唯一请求 ID,并将请求按源账户 ID 路由到相应日志分区。
- 一个流处理器消费该请求日志,并维护源账户本地状态及已处理请求 ID 集。遇到新请求 ID 时,先检查余额是否充足;若充足,则在本地状态中预留金额,并发出多个后续事件:源账户的出账事件、目标账户的入账事件、手续费账户的入账事件。所有事件都携带同一请求 ID。
- 源账户处理器稍后会再次收到出账事件。它根据请求 ID 识别出这是先前预留过的支付,执行真正扣款并更新本地状态;若重复到达则忽略。
- 目标账户与手续费账户各自由独立处理任务消费。收到入账事件后更新本地状态,并基于请求 ID 去重。
图 13-2 虽然画成三个账户落在三个分区中,但即使在同一分区也同样成立。关键条件是:同一账户的事件必须按日志顺序处理,且消息投递具备至少一次语义,处理逻辑保持确定性。
如果源账户处理器在处理中崩溃,恢复后会重放相同请求并做出相同决策,发出相同请求 ID 的后续事件。下游消费者会基于请求 ID 去重,因此不会重复生效。
这个系统的原子性不来自分布式事务,而来自初始请求事件写入源账户日志这一原子动作。只要这个起点事件写入成功,后续事件最终都会出现:它们可能因故障恢复而延迟,也可能短暂重复,但最终可达。
通过把多分区事务拆成多个按不同键分区的阶段,并贯穿端到端请求 ID,我们在故障场景下依然能保证“每个请求对付款方与收款方都恰好生效一次”,同时避免使用原子提交协议。
及时性与完整性
事务的一个便利属性是,它们通常是线性一致的(请参阅 “线性一致性”),也就是说,写入者会等到事务提交,而之后其写入立刻对所有读取者可见。
当我们把一个操作拆分为跨越多个阶段的流处理器时,却并非如此:日志的消费者在设计上就是异步的,因此发送者不会等其消息被消费者处理完。但是,客户端等待输出流中的特定消息是可能的。这正是我们在 “基于日志消息传递中的唯一性” 一节中检查唯一性约束时所做的事情。
在这个例子中,唯一性检查的正确性不取决于消息发送者是否等待结果。等待的目的仅仅是同步通知发送者唯一性检查是否成功。但该通知可以与消息处理的结果相解耦。
更一般地来讲,我认为术语 一致性(consistency) 这个术语混淆了两个值得分别考虑的需求:
及时性(Timeliness)
及时性意味着确保用户观察到系统的最新状态。我们之前看到,如果用户从陈旧的数据副本中读取数据,它们可能会观察到系统处于不一致的状态(请参阅 “复制延迟问题”)。但这种不一致是暂时的,而最终会通过等待与重试简单地得到解决。
CAP 定理(请参阅 “线性一致性的代价”)使用 线性一致性(linearizability) 意义上的一致性,这是实现及时性的强有力方法。像 写后读 这样及时性更弱的一致性也很有用(请参阅 “读己之写”)。
完整性(Integrity)
完整性意味着没有损坏;即没有数据丢失,并且没有矛盾或错误的数据。尤其是如果某些派生数据集是作为底层数据之上的视图而维护的(请参阅 “从事件日志中派生出当前状态”),这种派生必须是正确的。例如,数据库索引必须正确地反映数据库的内容 —— 缺失某些记录的索引并不是很有用。
如果完整性被违背,这种不一致是永久的:在大多数情况下,等待与重试并不能修复数据库损坏。相反的是,需要显式地检查与修复。在 ACID 事务的上下文中(请参阅 “ACID 的含义”),一致性通常被理解为某种特定于应用的完整性概念。原子性和持久性是保持完整性的重要工具。
口号形式:违反及时性,“最终一致性”;违反完整性,“永无一致性”。
我断言在大多数应用中,完整性比及时性重要得多。违反及时性可能令人困惑与讨厌,但违反完整性的结果可能是灾难性的。
例如在你的信用卡对账单上,如果某一笔过去 24 小时内完成的交易尚未出现并不令人奇怪 —— 这些系统有一定的滞后是正常的。我们知道银行是异步核算与敲定交易的,这里的及时性并不是非常重要3。但如果当期对账单余额与上期对账单余额加交易总额对不上(求和错误),或者出现一笔向你收费但未向商家付款的交易(消失的钱),那就实在是太糟糕了,这样的问题就违背了系统的完整性。
数据流系统的正确性
ACID 事务通常既提供及时性(例如线性一致性)也提供完整性保证(例如原子提交)。因此如果你从 ACID 事务的角度来看待应用的正确性,那么及时性与完整性的区别是无关紧要的。
另一方面,对于在本章中讨论的基于事件的数据流系统而言,它们的一个有趣特性就是将及时性与完整性分开。在异步处理事件流时不能保证及时性,除非你显式构建一个在返回之前明确等待特定消息到达的消费者。但完整性实际上才是流处理系统的核心。
恰好一次 或 等效一次 语义(请参阅 “容错”)是一种保持完整性的机制。如果事件丢失或者生效两次,就有可能违背数据系统的完整性。因此在出现故障时,容错消息传递与重复抑制(例如,幂等操作)对于维护数据系统的完整性是很重要的。
正如我们在上一节看到的那样,可靠的流处理系统可以在无需分布式事务与原子提交协议的情况下保持完整性,这意味着它们有潜力达到与后者相当的正确性,同时还具备好得多的性能与运维稳健性。为了达成这种正确性,我们组合使用了多种机制:
- 将写入操作的内容表示为单条消息,从而可以轻松地被原子写入 —— 与事件溯源搭配效果拔群(请参阅 “事件溯源”)。
- 使用与存储过程类似的确定性派生函数,从这一消息中派生出所有其他的状态变更(请参阅 “真的串行执行” 和 “应用代码作为派生函数”)
- 将客户端生成的请求 ID 传递通过所有的处理层次,从而允许端到端的除重,带来幂等性。
- 使消息不可变,并允许派生数据能随时被重新处理,这使从错误中恢复更加容易(请参阅 “不可变事件的优点”)
这种机制组合在我看来,是未来构建容错应用的一个非常有前景的方向。
宽松地解释约束
如前所述,执行唯一性约束需要共识,通常通过在单个节点中汇集特定分区中的所有事件来实现。如果我们想要传统的唯一性约束形式,这种限制是不可避免的,流处理也不例外。
然而另一个需要了解的事实是,许多真实世界的应用实际上可以摆脱这种形式,接受弱得多的唯一性:
- 如果两个人同时注册了相同的用户名或预订了相同的座位,你可以给其中一个人发消息道歉,并要求他们换一个不同的用户名或座位。这种纠正错误的变化被称为 补偿性事务(compensating transaction)47 48。
- 如果客户订购的物品多于仓库中的物品,你可以下单补仓,并为延误向客户道歉,向他们提供折扣。实际上,这么说吧,如果叉车在仓库中轧过了你的货物,剩下的货物比你想象的要少,那么你也是得这么做49。因此,既然道歉工作流无论如何已经成为你商业过程中的一部分了,那么对库存物品数目添加线性一致的约束可能就没必要了。
- 与之类似,许多航空公司都会超卖机票,打着一些旅客可能会错过航班的算盘;许多旅馆也会超卖客房,抱着部分客人可能会取消预订的期望。在这些情况下,出于商业原因而故意违反了 “一人一座” 的约束;当需求超过供给的情况出现时,就会进入补偿流程(退款、升级舱位 / 房型、提供隔壁酒店的免费的房间)。即使没有超卖,为了应对由恶劣天气或员工罢工导致的航班取消,你还是需要道歉与补偿流程 —— 从这些问题中恢复仅仅是商业活动的正常组成部分。
- 如果有人从账户超额取款,银行可以向他们收取透支费用,并要求他们偿还欠款。通过限制每天的提款总额,银行的风险是有限的。
在许多商业场景中,临时违背约束并稍后通过道歉来修复,实际上是可以接受的。道歉的成本各不相同,但通常很低(以金钱或名声来算):你无法撤回已发送的电子邮件,但可以发送一封后续电子邮件进行更正。如果你不小心向信用卡收取了两次费用,则可以将其中一项收费退款,而代价仅仅是手续费,也许还有客户的投诉。尽管一旦 ATM 吐了钱,你无法直接取回,但原则上如果账户透支而客户拒不支付,你可以派催收员收回欠款。
道歉的成本是否能接受是一个商业决策。如果可以接受的话,在写入数据之前检查所有约束的传统模型反而会带来不必要的限制,而线性一致性的约束也不是必须的。乐观写入,事后检查可能是一种合理的选择。你仍然可以在做一些挽回成本高昂的事情前确保有相关的验证,但这并不意味着写入数据之前必须先进行验证。
这些应用 确实 需要完整性:你不会希望丢失预订信息,或者由于借方贷方不匹配导致资金消失。但是它们在执行约束时 并不需要 及时性:如果你销售的货物多于仓库中的库存,可以在事后道歉后并弥补问题。这种做法与我们在 “处理写入冲突” 中讨论的冲突解决方法类似。
无协调数据系统
我们现在已经做了两个有趣的观察:
- 数据流系统可以维持派生数据的完整性保证,而无需原子提交、线性一致性或者同步的跨分区协调。
- 虽然严格的唯一性约束要求及时性和协调,但许多应用实际上可以接受宽松的约束:只要整个过程保持完整性,这些约束可能会被临时违反并在稍后被修复。
总之这些观察意味着,数据流系统可以为许多应用提供无需协调的数据管理服务,且仍能给出很强的完整性保证。这种 无协调(coordination-avoiding) 的数据系统有着很大的吸引力:比起需要执行同步协调的系统,它们能达到更好的性能与更强的容错能力44。
例如,这种系统可以使用多领导者配置运维,跨越多个数据中心,在区域间异步复制。任何一个数据中心都可以持续独立运行,因为不需要同步的跨区域协调。这样的系统的及时性保证会很弱 —— 如果不引入协调它是不可能是线性一致的 —— 但它仍然可以提供有力的完整性保证。
在这种情况下,可串行化事务作为维护派生状态的一部分仍然是有用的,但它们只能在小范围内运行,在那里它们工作得很好6。异构分布式事务(如 XA 事务,请参阅 “实践中的分布式事务”)不是必需的。同步协调仍然可以在需要的地方引入(例如在无法恢复的操作之前强制执行严格的约束),但是如果只是应用的一小部分地方需要它,没必要让所有操作都付出协调的代价。31。
另一种审视协调与约束的角度是:它们减少了由于不一致而必须做出的道歉数量,但也可能会降低系统的性能和可用性,从而可能增加由于宕机中断而需要做出的道歉数量。你不可能将道歉数量减少到零,但可以根据自己的需求寻找最佳平衡点 —— 既不存在太多不一致性,又不存在太多可用性问题。
信任但验证
我们所有关于正确性,完整性和容错的讨论都基于一些假设,假设某些事情可能会出错,但其他事情不会。我们将这些假设称为我们的 系统模型(system model,请参阅 “将系统模型映射到现实世界”):例如,我们应该假设进程可能会崩溃,机器可能突然断电,网络可能会任意延迟或丢弃消息。但是我们也可能假设写入磁盘的数据在执行 fsync 后不会丢失,内存中的数据没有损坏,而 CPU 的乘法指令总是能返回正确的结果。
这些假设是相当合理的,因为大多数时候它们都是成立的,如果我们不得不经常担心计算机出错,那么基本上寸步难行。在传统上,系统模型采用二元方法处理故障:我们假设有些事情可能会发生,而其他事情 永远 不会发生。实际上,这更像是一个概率问题:有些事情更有可能,其他事情不太可能。问题在于违反我们假设的情况是否经常发生,以至于我们可能在实践中遇到它们。
我们已经看到,数据可能会在内存中、磁盘上、以及网络传输过程中出现损坏。也许这件事值得我们投入更多关注:当系统规模足够大时,哪怕概率再低的问题也会在现实中发生。
维护完整性,尽管软件有Bug
除了这些硬件问题之外,总是存在软件 Bug 的风险,这些错误不会被较低层次的网络、内存或文件系统校验和所捕获。即使广泛使用的数据库软件也有 Bug:即使像 MySQL 与 PostgreSQL 这样稳健、口碑良好、多年来被许多人充分测试过的软件,就我个人所见也有 Bug,比如 MySQL 未能正确维护唯一约束50,以及 PostgreSQL 的可串行化隔离等级存在特定的写入偏差异常51。对于不那么成熟的软件来说,情况可能要糟糕得多。
尽管在仔细设计,测试,以及审查上做出很多努力,但 Bug 仍然会在不知不觉中产生。尽管它们很少,而且最终会被发现并被修复,但总会有那么一段时间,这些 Bug 可能会损坏数据。
而对于应用代码,我们不得不假设会有更多的错误,因为绝大多数应用的代码经受的评审与测试远远无法与数据库的代码相比。许多应用甚至没有正确使用数据库提供的用于维持完整性的功能,例如外键或唯一性约束23。
ACID 意义下的一致性(请参阅 “一致性”)基于这样一种想法:数据库以一致的状态启动,而事务将其从一个一致状态转换至另一个一致的状态。因此,我们期望数据库始终处于一致状态。然而,只有当你假设事务没有 Bug 时,这种想法才有意义。如果应用以某种错误的方式使用数据库,例如,不安全地使用弱隔离等级,数据库的完整性就无法得到保证。
不要盲目信任承诺
由于硬件和软件并不总是符合我们的理想,所以数据损坏似乎早晚不可避免。因此,我们至少应该有办法查明数据是否已经损坏,以便我们能够修复它,并尝试追查错误的来源。检查数据完整性称为 审计(auditing)。
如 “不可变事件的优点” 一节中所述,审计不仅仅适用于财务应用程序。不过,可审计性在财务中是非常非常重要的,因为每个人都知道错误总会发生,我们也都认为能够检测和解决问题是合理的需求。
成熟的系统同样倾向于考虑不太可能的事情出错的可能性,并管理这种风险。例如,HDFS 和 Amazon S3 等大规模存储系统并不完全信任磁盘:它们运行后台进程持续回读文件,并将其与其他副本进行比较,并将文件从一个磁盘移动到另一个,以便降低静默损坏的风险52。
如果你想确保你的数据仍然存在,你必须真正读取它并进行检查。大多数时候它们仍然会在那里,但如果不是这样,你一定想尽早知道答案,而不是更晚。按照同样的原则,不时地尝试从备份中恢复是非常重要的 —— 否则当你发现备份损坏时,你可能已经遇到了数据丢失,那时候就真的太晚了。不要盲目地相信它们全都管用。
为可审计性而设计
如果一个事务在一个数据库中改变了多个对象,在这一事实发生后,很难说清这个事务到底意味着什么。即使你捕获了事务日志(请参阅 “变更数据捕获”),各种表中的插入、更新和删除操作并不一定能清楚地表明 为什么 要执行这些变更。决定这些变更的是应用逻辑中的调用,而这一应用逻辑稍纵即逝,无法重现。
相比之下,基于事件的系统可以提供更好的可审计性。在事件溯源方法中,系统的用户输入被表示为一个单一不可变事件,而任何其导致的状态变更都派生自该事件。派生可以实现为具有确定性与可重复性,因而相同的事件日志通过相同版本的派生代码时,会导致相同的状态变更。
显式处理数据流(请参阅 “批处理输出的哲学”)可以使数据的 来龙去脉(provenance) 更加清晰,从而使完整性检查更具可行性。对于事件日志,我们可以使用散列来检查事件存储没有被破坏。对于任何派生状态,我们可以重新运行从事件日志中派生它的批处理器与流处理器,以检查是否获得相同的结果,或者,甚至并行运行冗余的派生流程。
具有确定性且定义良好的数据流,也使调试与跟踪系统的执行变得容易,以便确定它 为什么 做了某些事情4 53。如果出现意想之外的事情,那么重现导致意外事件的确切事故现场的诊断能力 —— 一种时间旅行调试功能是非常有价值的。
端到端原则重现
如果我们不能完全相信系统的每个组件都不会损坏 —— 每一个硬件都没缺陷,每一个软件都没有 Bug —— 那我们至少必须定期检查数据的完整性。如果我们不检查,我们就不能发现损坏,直到无可挽回地导致对下游的破坏时,那时候再去追踪问题就要难得多,且代价也要高的多。
检查数据系统的完整性,最好是以端到端的方式进行(请参阅 “数据库的端到端原则”):我们能在完整性检查中涵盖的系统越多,某些处理阶中出现不被察觉损坏的几率就越小。如果我们能检查整个派生数据管道端到端的正确性,那么沿着这一路径的任何磁盘、网络、服务以及算法的正确性检查都隐含在其中了。
持续的端到端完整性检查可以不断提高你对系统正确性的信心,从而使你能更快地进步54。与自动化测试一样,审计提高了快速发现错误的可能性,从而降低了系统变更或新存储技术可能导致损失的风险。如果你不害怕进行变更,就可以更好地充分演化一个应用,使其满足不断变化的需求。
用于可审计数据系统的工具
目前,把可审计性作为一级目标的数据系统还不多。一些应用会实现自己的审计机制(例如把变更写入独立审计表),但要同时保证审计日志与主数据库状态都不可篡改仍然很难。
像 Bitcoin、Ethereum 这样的区块链,本质上是带密码学一致性校验的共享仅追加日志;交易可视作事件,智能合约可视作流处理器。它们通过共识协议让所有节点同意同一事件序列。与本书 第十章 的共识协议相比,区块链的一个差异是强调拜占庭容错:参与节点会持续相互校验完整性55 56 57。
对多数应用而言,区块链整体开销仍偏高;但其中一些密码学工具可在更轻量的场景复用。比如 默克尔树(Merkle tree)58可高效证明某条记录属于某数据集。证书透明性(certificate transparency) 使用可验证的仅追加日志与默克尔树来校验 TLS/SSL 证书有效性59 60。
未来,这类完整性校验与审计算法可能会在通用数据系统中更广泛应用。要把它们做到与无密码学审计系统同等级别的可伸缩性,同时把性能开销压到足够低,仍需要工程改进,但方向值得重视。
本章小结
在本章中,我们讨论了设计数据系统的新方式,而且也包括了我的个人观点,以及对未来的猜测。我们从这样一种观察开始:没有单种工具能高效服务所有可能的用例,因此应用必须组合使用几种不同的软件才能实现其目标。我们讨论了如何使用批处理与事件流来解决这一 数据集成(data integration) 问题,以便让数据变更在不同系统之间流动。
在这种方法中,某些系统被指定为记录系统,而其他数据则通过转换派生自记录系统。通过这种方式,我们可以维护索引、物化视图、机器学习模型、统计摘要等等。通过使这些派生和转换操作异步且松散耦合,能够防止一个区域中的问题扩散到系统中不相关部分,从而增加整个系统的稳健性与容错性。
将数据流表示为从一个数据集到另一个数据集的转换也有助于演化应用程序:如果你想变更其中一个处理步骤,例如变更索引或缓存的结构,则可以在整个输入数据集上重新运行新的转换代码,以便重新派生输出。同样,出现问题时,你也可以修复代码并重新处理数据以便恢复。
这些过程与数据库内部已经完成的过程非常类似,因此我们将数据流应用的概念重新改写为,分拆(unbundling) 数据库组件,并通过组合这些松散耦合的组件来构建应用程序。
派生状态可以通过观察底层数据的变更来更新。此外,派生状态本身可以进一步被下游消费者观察。我们甚至可以将这种数据流一路传送至显示数据的终端用户设备,从而构建可动态更新以反映数据变更,并在离线时能继续工作的用户界面。
接下来,我们讨论了如何确保所有这些处理在出现故障时保持正确。我们看到可伸缩的强完整性保证可以通过异步事件处理来实现,通过使用端到端操作标识符使操作幂等,以及通过异步检查约束。客户端可以等到检查通过,或者不等待继续前进,但是可能会冒有违反约束需要道歉的风险。这种方法比使用分布式事务的传统方法更具可伸缩性与可靠性,并且在实践中适用于很多业务流程。
通过围绕数据流构建应用,并异步检查约束,我们可以避免绝大多数协调,构建在地理分布和故障场景下依然保持完整性且性能良好的系统。随后我们还讨论了如何通过审计验证完整性、发现损坏,并指出区块链/分布式账本所使用的一些机制与事件驱动系统在思想上也存在共通之处。
Footnotes
References
Rachid Belaid: “Postgres Full-Text Search is Good Enough!,” rachbelaid.com, July 13, 2015. ↩︎ ↩︎
Philippe Ajoux, Nathan Bronson, Sanjeev Kumar, et al.: “Challenges to Adopting Stronger Consistency at Scale,” at 15th USENIX Workshop on Hot Topics in Operating Systems (HotOS), May 2015. ↩︎ ↩︎
Pat Helland and Dave Campbell: “Building on Quicksand,” at 4th Biennial Conference on Innovative Data Systems Research (CIDR), January 2009. ↩︎ ↩︎ ↩︎ ↩︎
Jessica Kerr: “Provenance and Causality in Distributed Systems,” blog.jessitron.com, September 25, 2016. ↩︎ ↩︎ ↩︎
Jay Kreps: “The Log: What Every Software Engineer Should Know About Real-Time Data’s Unifying Abstraction,” engineering.linkedin.com, December 16, 2013. ↩︎ ↩︎ ↩︎ ↩︎
Pat Helland: “Life Beyond Distributed Transactions: An Apostate’s Opinion,” at 3rd Biennial Conference on Innovative Data Systems Research (CIDR), January 2007. ↩︎ ↩︎
“Great Western Railway (1835–1948),” Network Rail Virtual Archive, networkrail.co.uk. ↩︎
Jacqueline Xu: “Online Migrations at Scale,” stripe.com, February 2, 2017. ↩︎
Molly Bartlett Dishman and Martin Fowler: “Agile Architecture,” at O’Reilly Software Architecture Conference, March 2015. ↩︎
Nathan Marz and James Warren: Big Data: Principles and Best Practices of Scalable Real-Time Data Systems. Manning, 2015. ISBN: 978-1-617-29034-3 ↩︎
Raul Castro Fernandez, Peter Pietzuch, Jay Kreps, et al.: “Liquid: Unifying Nearline and Offline Big Data Integration,” at 7th Biennial Conference on Innovative Data Systems Research (CIDR), January 2015. ↩︎
Dennis M. Ritchie and Ken Thompson: “The UNIX Time-Sharing System,” Communications of the ACM, volume 17, number 7, pages 365–375, July 1974. doi:10.1145/361011.361061 ↩︎ ↩︎
Eric A. Brewer and Joseph M. Hellerstein: “CS262a: Advanced Topics in Computer Systems,” lecture notes, University of California, Berkeley, cs.berkeley.edu, August 2011. ↩︎
Michael Stonebraker: “The Case for Polystores,” wp.sigmod.org, July 13, 2015. ↩︎ ↩︎
Jennie Duggan, Aaron J. Elmore, Michael Stonebraker, et al.: “The BigDAWG Polystore System,” ACM SIGMOD Record, volume 44, number 2, pages 11–16, June 2015. doi:10.1145/2814710.2814713 ↩︎
Patrycja Dybka: “Foreign Data Wrappers for PostgreSQL,” vertabelo.com, March 24, 2015. ↩︎
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. ↩︎
Martin Kleppmann and Jay Kreps: “Kafka, Samza and the Unix Philosophy of Distributed Data,” IEEE Data Engineering Bulletin, volume 38, number 4, pages 4–14, December 2015. ↩︎
John Hugg: “Winning Now and in the Future: Where VoltDB Shines,” voltdb.com, March 23, 2016. ↩︎
Felienne Hermans: “Spreadsheets Are Code,” at Code Mesh, November 2015. ↩︎
Dan Bricklin and Bob Frankston: “VisiCalc: Information from Its Creators,” danbricklin.com. ↩︎
D. Sculley, Gary Holt, Daniel Golovin, et al.: “Machine Learning: The High-Interest Credit Card of Technical Debt,” at NIPS Workshop on Software Engineering for Machine Learning (SE4ML), December 2014. ↩︎
Peter Bailis, Alan Fekete, Michael J Franklin, et al.: “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 ↩︎ ↩︎
Guy Steele: “Re: Need for Macros (Was Re: Icon),” email to ll1-discuss mailing list, people.csail.mit.edu, December 24, 2001. ↩︎
David Gelernter: “Generative Communication in Linda,” ACM Transactions on Programming Languages and Systems (TOPLAS), volume 7, number 1, pages 80–112, January 1985. doi:10.1145/2363.2433 ↩︎
Patrick Th. Eugster, Pascal A. Felber, Rachid Guerraoui, and Anne-Marie Kermarrec: “The Many Faces of Publish/Subscribe,” ACM Computing Surveys, volume 35, number 2, pages 114–131, June 2003. doi:10.1145/857076.857078 ↩︎
Ben Stopford: “Microservices in a Streaming World,” at QCon London, March 2016. ↩︎ ↩︎
Christian Posta: “Why Microservices Should Be Event Driven: Autonomy vs Authority,” blog.christianposta.com, May 27, 2016. ↩︎
Alex Feyerke: “Say Hello to Offline First,” hood.ie, November 5, 2013. ↩︎
Martin Kleppmann: “Turning the Database Inside-out with Apache Samza,” at Strange Loop, September 2014. ↩︎ ↩︎
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 ↩︎ ↩︎
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 ↩︎
Eno Thereska, Damian Guy, Michael Noll, and Neha Narkhede: “Unifying Stream Processing and Interactive Queries in Apache Kafka,” confluent.io, October 26, 2016. ↩︎
Frank McSherry: “Dataflow as Database,” github.com, July 17, 2016. ↩︎
Peter Alvaro: “I See What You Mean,” at Strange Loop, September 2015. ↩︎
Nathan Marz: “Trident: A High-Level Abstraction for Realtime Computation,” blog.twitter.com, August 2, 2012. ↩︎
Edi Bice: “Low Latency Web Scale Fraud Prevention with Apache Samza, Kafka and Friends,” at Merchant Risk Council MRC Vegas Conference, March 2016. ↩︎
Charity Majors: “The Accidental DBA,” charity.wtf, October 2, 2016. ↩︎
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 ↩︎
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. ↩︎
Kyle Kingsbury: Jepsen blog post series, aphyr.com, 2013–2016. ↩︎
Michael Jouravlev: “Redirect After Post,” theserverside.com, August 1, 2004. ↩︎
Jerome H. Saltzer, David P. Reed, and David D. Clark: “End-to-End Arguments in System Design,” ACM Transactions on Computer Systems, volume 2, number 4, pages 277–288, November 1984. doi:10.1145/357401.357402 ↩︎ ↩︎
Peter Bailis, Alan Fekete, Michael J. Franklin, et al.: “Coordination-Avoiding Database Systems,” Proceedings of the VLDB Endowment, volume 8, number 3, pages 185–196, November 2014. ↩︎ ↩︎
Alex Yarmula: “Strong Consistency in Manhattan,” blog.twitter.com, March 17, 2016. ↩︎
Douglas B Terry, Marvin M Theimer, Karin Petersen, et al.: “Managing Update Conflicts in Bayou, a Weakly Connected Replicated Storage System,” at 15th ACM Symposium on Operating Systems Principles (SOSP), pages 172–182, December 1995. doi:10.1145/224056.224070 ↩︎
Jim Gray: “The Transaction Concept: Virtues and Limitations,” at 7th International Conference on Very Large Data Bases (VLDB), September 1981. ↩︎
Hector Garcia-Molina and Kenneth Salem: “Sagas,” at ACM International Conference on Management of Data (SIGMOD), May 1987. doi:10.1145/38713.38742 ↩︎
Pat Helland: “Memories, Guesses, and Apologies,” blogs.msdn.com, May 15, 2007. ↩︎
Annamalai Gurusami and Daniel Price: “Bug #73170: Duplicates in Unique Secondary Index Because of Fix of Bug#68021,” bugs.mysql.com, July 2014. ↩︎
Gary Fredericks: “Postgres Serializability Bug,” github.com, September 2015. ↩︎
Xiao Chen: “HDFS DataNode Scanners and Disk Checker Explained,” blog.cloudera.com, December 20, 2016. ↩︎
Martin Fowler: “The LMAX Architecture,” martinfowler.com, July 12, 2011. ↩︎
Sam Stokes: “Move Fast with Confidence,” blog.samstokes.co.uk, July 11, 2016. ↩︎
“Hyperledger Sawtooth documentation,” Intel Corporation, sawtooth.hyperledger.org, 2017. ↩︎
Richard Gendal Brown: “Introducing R3 Corda™: A Distributed Ledger Designed for Financial Services,” gendal.me, April 5, 2016. ↩︎
Trent McConaghy, Rodolphe Marques, Andreas Müller, et al.: “BigchainDB: A Scalable Blockchain Database,” bigchaindb.com, June 8, 2016. ↩︎
Ralph C. Merkle: “A Digital Signature Based on a Conventional Encryption Function,” at CRYPTO ‘87, August 1987. doi:10.1007/3-540-48184-2_32 ↩︎
Ben Laurie: “Certificate Transparency,” ACM Queue, volume 12, number 8, pages 10-19, August 2014. doi:10.1145/2668152.2668154 ↩︎
Mark D. Ryan: “Enhanced Certificate Transparency and End-to-End Encrypted Mail,” at Network and Distributed System Security Symposium (NDSS), February 2014. doi:10.14722/ndss.2014.23379 ↩︎