第 13 章 流系统的哲学
若一物以另一物为其归宿之目的,则其终极目的便不在自身之保全。是故船长不以保全所托付之船为终极目的,盖船另有旨归——即航行。 (常被转述为:若船长以保船为最高目标,他便会让船永远停泊在港。)
——圣托马斯·阿奎那,《神学大全》(1265–1274)
在第 2 章中,我们讨论了构建可靠、可扩展、可维护的应用与系统这一目标。这些主题贯穿全书每一章——例如,我们讨论过大量有助于改善可靠性的容错算法、用于改善可扩展性的分片,以及支持演化与抽象、从而改善可维护性的若干机制。
在本章里,我们将把上述思想串联起来,并在第 12 章所述的流式与事件驱动架构之上,发展出一种契合上述目标的应用开发哲学。本章比此前的章节更带主张性——它不再罗列与对比多种方案,而是就一种特定的哲学做深入剖析。
数据集成
本书的一条贯穿主题是:对任何给定问题,往往存在多种解法,每种各有优劣与取舍。例如,第 4 章讨论存储引擎时,我们考察了日志结构存储、B 树以及列式存储;第 6 章讨论复制时,我们又考察了单主、多主与无主三种思路。
如果你的问题是"我想存些数据,过一会儿再把它取回来",并不存在唯一正确的解法,但有许多在不同情境下各自得当的方案。一个具体的软件实现通常只能选择一种特定的方案。把单一代码路径打磨得健壮且高性能已属不易;若试图用太多功能去满足太多用例,相比专门的工具,最终很可能得到那些功能的拙劣实现。
因此,最得当的软件工具选择也取决于具体情境。每一款软件——哪怕是所谓的"通用"数据库——都是为某种特定的使用模式而设计的。
面对这种琳琅满目的备选方案,第一项挑战是搞清楚软件产品与适用情境之间的对应关系。供应商出于可以理解的理由不愿告诉你他们的软件不擅长哪种工作负载,但希望前面的章节已经武装了你一些恰当的提问思路,让你能读懂言外之意,更好地理解其中的取舍。
然而,纵使你对工具与使用情境之间的对应关系了如指掌,还会面临另一项挑战:在复杂的应用里,数据常常以多种方式被使用,没有哪一款软件可能适合所有这些方式。因此,你不可避免地要把若干软件拼装在一起,才能为应用提供完整的功能。
通过派生数据组合专用工具
举一个例子:常见的需求是把 OLTP 数据库与全文搜索索引集成起来,以处理任意关键字查询。虽然有些数据库(如 PostgreSQL)内置全文索引功能,对简单应用足够用 [1],但更复杂的搜索能力还得仰仗专门的信息检索工具。反过来,搜索索引一般也不太适合作为持久的真实数据源。因此,许多应用需要把两种工具结合起来才能满足全部需求。
我们在第 501 页"保持系统同步"中触及过整合数据系统的问题。当数据的表征数量增多,整合问题就更棘手。除了数据库与搜索索引,你也许还需要在分析系统(数据仓库、批/流处理系统)中保留数据副本;维护从原始数据派生出来的对象的缓存或反规范化版本;把数据送入机器学习、分类、排序或推荐系统;或基于数据变更发送通知。
关于数据流的推理
当同一份数据需要在多个存储系统中维持副本以支持多种访问模式时,你必须把输入与输出的方向梳理得一清二楚。数据先写到哪里?哪些表征是从哪些来源派生出来的?又如何把数据以正确的格式送到所有正确的位置?
例如,你可以让数据先写入一个真实数据源数据库,随后通过变更数据捕获(见第 503 页"变更数据捕获")把对该库的修改捕获出来,再以同样的顺序应用到搜索索引上。如果 CDC 是更新索引的唯一途径,你便可以放心:索引完全派生自真实数据源,因此与之保持一致(除非软件本身有 bug)。要往这个系统输入新数据,唯一的入口就是写入数据库。
如果允许应用同时直接写入搜索索引和数据库,就会出现图 12-4 所示的问题:两个客户端同时发送相互冲突的写入,两个存储系统按不同顺序处理它们。这种情况下,无论数据库还是搜索索引,都没有"决定写入顺序"的权威,于是它们各自做出相互矛盾的判断,最终永久性地不一致。
如果你能让所有用户输入都经由一个能为所有写入决定一致顺序的系统漏斗式地汇入,那么按相同顺序处理写入来派生其他数据表征就容易得多了。这正是状态机复制方法的应用——我们在第 433 页"实践中的共识"中见过它。至于使用 CDC 还是事件溯源日志,就远不如"对一个全序达成一致"这一原则重要。
基于事件日志来更新派生数据系统,通常可以做到确定且幂等(见第 528 页"幂等性"),从而很容易从故障中恢复。
派生数据 vs. 分布式事务
让数据系统彼此保持一致的经典做法是分布式事务,第 324 页"两阶段提交"中已讨论。基于派生数据系统的方法与分布式事务相比表现如何?
在抽象层面上,二者以不同手段达成相似目标。分布式事务通过原子提交协议确保变更被原子地应用,而基于日志的系统则借助确定性重试与幂等性来达到正确性。
最大的差别在于:事务系统通常保证一旦写入某个值,立刻就能读到最新值(见第 210 页"读自己的写入");而派生数据系统往往以异步方式更新,因此默认并不保证读到的是最新数据。
分布式事务在愿意承担其性能与运维代价的环境中已被成功使用,然而 XA 在容错与性能上的表现欠佳(见第 328 页"跨异构系统的分布式事务"),严重制约了它的实用性。或许可以设计出更好的分布式事务协议,但要让它被广泛采纳并与现有工具集成则颇具挑战,且短期内不太可能发生。
在缺乏一种被广泛支持的优秀分布式事务协议的情况下,基于日志的派生数据是整合不同数据系统最有希望的路径。诸如"读自己的写入"这类保证仍然有用;告诉所有人"最终一致性不可避免——你只能咬牙接受"也无济于事(至少在没有一份关于如何应对它的良好指引时如此)。
本章稍后将讨论一些方案,它们在异步派生系统之上提供更强的保障,朝着分布式事务与异步基于日志的系统之间的中间地带努力。
全序的局限
对于足够小的系统,构造一个完全有序的事件日志是完全可行的(单主复制数据库的流行就证明了这一点——它正是构造此类日志的方式)。然而当系统朝更大、更复杂的工作负载扩展时,局限便开始显现:
- 大多数情况下,构造一个完全有序的日志要求所有事件经过单一主节点来决定顺序。如果事件吞吐量超出单台机器的处理能力,就需要把日志分到多台机器上分片。两个分片之间的事件顺序便变得含糊不清。
- 如果服务器跨多个地理分布的区域部署——例如为了容忍整个数据中心宕机——你通常会在每个数据中心都设置一个独立的主节点,因为网络延迟使得跨数据中心的同步协调效率低下。这意味着源自两个不同数据中心的事件顺序未被定义。
- 当应用以微服务方式部署时,常见的设计是把每个服务及其持久状态作为独立单元部署,服务之间不共享持久状态。两个事件源自不同服务时,这两个事件之间也没有定义的顺序。
- 一些应用在客户端维护状态,并在用户输入时即刻更新(无须等待服务器确认),甚至在离线时仍能继续工作。对这类应用,客户端与服务器很可能会以不同顺序看到事件。
从形式化的角度看,对所有事件的全序达成一致这一过程被称为全序广播;正如第 427 页"共识的诸多面孔"所述,它等同于共识。多数共识算法是为单节点足以处理整个事件流吞吐的情境设计的,并不提供让多个节点共同分担排序工作的机制。
用事件顺序来捕获因果
如果事件之间没有因果联系,缺少全序就不算大问题,因为可以任意排序并发事件。还有些情况也容易处理——例如,对同一对象的多次更新可以通过把对该对象 ID 的所有更新路由到同一个日志分片来实现全序。然而,因果依赖有时会以更隐蔽的方式出现。
举例来说,考虑一个社交网络服务以及一对刚刚分手的恋人。一个用户把另一个从好友里删掉,然后给剩下的好友发消息抱怨自己的前任。该用户的意图是:前任不应该看到这条出言不逊的消息,因为消息是在好友状态被解除之后发出的。
然而,如果某个系统把好友关系存在一处,把消息存在另一处,那么取消好友事件与发送消息事件之间的顺序依赖就可能丢失。如果因果依赖未被捕获,发送新消息通知的服务可能在处理取消好友事件之前就处理了发送消息事件,因而错误地把通知发给了前任。
在这个例子里,通知实际上是消息与好友列表之间的连接,因此与之前讨论过的连接的时序问题相关(见第 525 页"连接的时间依赖")。可惜这个问题似乎没有简单答案 [2, 3]。可以考虑的起点包括:
- 逻辑时间戳可以在不需要协调的情况下提供全序(见第 417 页"ID 生成器与逻辑时钟"),因此在全序广播不可行时也许有用。然而它们仍然要求接收方处理乱序到达的事件,并且需要传递额外的元数据。
- 如果你能把"用户在做出决定前所看到的系统状态"以一个事件的形式记下来,并赋予该事件一个唯一标识,那么后续任何事件都可以引用该事件标识,以记录因果依赖 [4]。
- 冲突解决算法(见第 226 页"自动冲突解决")有助于处理以意外顺序到达的事件。它们对维护状态有用,但当行动产生外部副作用(如向用户发送通知)时就不顶用。
也许在未来会出现一些应用开发模式,能够高效地捕获因果依赖、正确地维持派生状态,而无需把所有事件都强行挤过全序广播这条瓶颈。
批处理与流处理
数据集成的目标在于:让数据以正确的形式出现在所有正确的位置。要做到这一点,需要消费输入,做转换、连接、过滤、聚合、训练模型、求值,并最终写出到合适的输出位置。批处理器和流处理器正是为此而生的工具。批处理与流处理的输出是派生的数据集——例如搜索索引、物化视图、给用户的推荐、汇总指标等。
正如我们在第 11 章和第 12 章所见,批处理与流处理在原则上有许多共同点。最根本的差别在于:流处理器作用于无界数据集,而批处理的输入是已知且有限的大小。
维护派生状态
批处理带有相当强的函数式色彩(即使代码并非用函数式语言写就)。它鼓励确定性、纯函数——其输出仅依赖于输入,除了显式的输出之外没有副作用,把输入视作不可变,把输出当作只追加。流处理与之相似,但它的算子被扩展,可以管理一份带容错保障的状态。
输入与输出定义良好的确定性函数原则不仅对容错有利,也简化了对组织内数据流的推理 [5]。无论派生数据是搜索索引、统计模型还是缓存,从数据管道的视角去思考都很有帮助:用一物派生另一物,把状态变更通过函数式应用代码从一个系统推送到另一个系统,把效果应用到派生系统上。
原则上,派生数据系统可以同步维护,就像关系数据库会在写入被索引表的同一事务内同步更新二级索引一样。然而,正是异步使得基于事件日志的系统更鲁棒:它允许故障局部化在系统的某一部分内;而分布式事务在任何参与者失败时都会中止,这反而倾向于把故障放大、扩散到系统其余部分。
第 268 页"分片与二级索引"指出,二级索引常常跨越分片边界。具有二级索引的分片系统要么把写入发送给多个分片(若索引按词条分片),要么把读请求发送给所有分片(若索引按文档分片)。如果索引是异步维护的,这种跨分片通信也最为可靠且具可扩展性 [6]。
为应用演化重新处理数据
在维护派生数据时,批处理与流处理都很有用。流处理允许输入的变更以低延迟反映在派生视图中;批处理则允许大量积累的历史数据被重新处理,从而在已有数据集上派生出新视图。
特别地,重新处理已有数据为系统的维护与演化提供了一个良好机制——可以演化它来支持新功能与变更后的需求。如果不重新处理,模式演化就被局限在向记录添加可选字段或加入一种新记录类型这样的小改动;而有了重新处理,就能把数据集重组为完全不同的模型,以更好地服务新需求。
铁路上的"模式迁移"
大规模的"模式迁移"在非计算机系统中也会发生。例如,19 世纪英国早期修建铁路时,曾存在多种相互竞争的轨距标准(两条钢轨之间的距离)。为某种轨距修造的列车不能在另一种轨距的轨道上运行,这制约了铁路网络可能的互联 [7]。
1846 年终于敲定单一标准轨距之后,其他轨距的轨道也必须随之改造——然而怎样在不让铁路停运数月乃至数年的前提下完成?办法是先把轨道改造为双轨距或混合轨距,加铺第三条钢轨。改造可以渐进完成;改造后,两种轨距的列车都能在线路上行驶,因为它们各自使用三条钢轨中的两条。最终,所有列车都改造为标准轨距后,再拆除多余的非标准钢轨。
以这种方式"重新处理"既有的轨道,让新旧两种版本并存,使得轨距能够在多年间逐步切换。即便如此,这项工程依旧昂贵,这也是非标准轨距至今仍然存在的原因。例如,旧金山湾区的 BART 系统使用的轨距就与美国大多数铁路不同。
派生视图允许渐进演化。如果你想重组一个数据集,并非必须做一次性切换;相反,你可以让旧模式与新模式作为基于同一份底层数据的两个独立派生视图并存。随后可以先把一小部分用户切到新视图,去检验它的性能并发现 bug;其余多数用户仍然走旧视图。逐步提高访问新视图的用户比例,最终便能弃用旧视图 [8, 9]。
这种渐进迁移的好处在于:每个阶段都很容易回退——若出问题,你始终有一个可用的系统可以退回。降低不可逆破坏的风险,让你更有信心继续推进,从而更快地改进系统 [10]。
统一批处理与流处理
把批处理与流处理统一起来的早期提案是lambda 架构 [11]。它存在若干问题 [12],已逐渐淡出。更晚近的系统允许在同一个系统里同时实现批量计算(重新处理历史数据)与流式计算(在事件到达时处理它们)[13]——这种思路有时被称作 kappa 架构 [12]。
要在一个系统中统一批与流处理,需要具备以下能力:
- 能够通过同一个处理引擎重放历史事件——也就是处理近期事件流的那个引擎。例如,基于日志的消息代理具备消息重放能力;某些流处理器可以从分布式文件系统或对象存储中读取输入。
- 流处理器具备恰好一次语义——也就是说,确保输出与"未发生过任何故障时的输出"一致,即使确实发生过故障。与批处理一样,这要求丢弃失败任务的部分输出。
- 提供按事件时间而非处理时间开窗的工具,因为重放历史事件时,处理时间没有意义。例如 Apache Beam 提供了表达此类计算的 API,可以由 Apache Flink 或 Google Cloud Dataflow 来运行。
解绑数据库
抽象地讲,数据库、批/流处理器与操作系统都执行相同的功能:它们存储一些数据,并允许你处理与查询它 [14, 15]。数据库以某种数据模型(表里的行、文档、图里的顶点等)的记录形式存储数据,操作系统的文件系统则把数据存为文件——但本质上两者都是"信息管理"系统 [16]。如同第 11 章所述,批处理器就像 Unix 的分布式版本。
实际上,二者间存在许多差异。例如,许多文件系统不太能应付一个含有 1000 万个小文件的目录,而装着 1000 万条小记录的数据库则完全正常、毫不出奇。尽管如此,操作系统与数据库之间的异同仍值得探讨。
Unix 与关系数据库以截然不同的哲学应对信息管理问题。Unix 认为自己的目的是给程序员呈现一个逻辑上的、相对低层的硬件抽象,而关系数据库则希望给应用程序员一个高层抽象,把磁盘上数据结构、并发、崩溃恢复等的复杂性都隐藏起来。Unix 发明了仅仅是字节序列的管道与文件,关系数据库则发展出了 SQL 与事务。
哪种方法更好?看你想要什么。Unix"更简单",因为它是对硬件资源相对薄的封装;关系数据库"也更简单",因为一条简短的声明式查询可以调动大量强大的基础设施(查询优化、索引、连接方法、并发控制、复制等),而不需要查询作者了解实现细节。
这两种哲学之间的张力延续了数十年(Unix 与关系模型都出现在 1970 年代初),至今未解。例如,NoSQL 运动可被解读为:希望把 Unix 风格的低层抽象方法应用到分布式 OLTP 数据存储领域。
本节尝试调和这两种思路,希望能把两者之长结合起来。
组合数据存储技术
纵观本书,我们讨论过数据库提供的若干特性以及它们如何工作,包括:
- 二级索引,使你能够根据某个字段的值高效地搜索记录
- 物化视图,是一种预先计算好的查询结果缓存
- 复制日志,把数据副本同步到其他节点
- 全文搜索索引,允许在文本中做关键字搜索;某些关系数据库也内置了它 [1]
在第 11 章和第 12 章中,类似主题再度浮现:我们谈到了构建全文搜索索引,谈到了维护物化视图,谈到了通过 CDC 把变更从数据库复制到派生数据系统。
可以看出,数据库内置的特性与人们用批/流处理器构建的派生数据系统之间,存在很多对应之处。
创建索引
想想当你在关系数据库里运行 CREATE INDEX 时会发生什么。数据库必须扫描表的一份一致快照,挑出所有要建索引的字段值,对它们排序,再写出索引。然后它必须处理自一致快照建立之后的写入积压(假定建索引时没有锁表,因此写入仍可继续)。完成之后,每当事务向表中写入时,数据库还必须持续把索引保持为最新。
这一过程与建立新的从节点副本(见第 201 页"建立新从节点")相当相似,也与在流式系统中引导 CDC(见第 504 页"初始快照")相似。
无论何时运行 CREATE INDEX,数据库本质上都是在重新处理已有数据集,把索引派生为这份数据上的一种新视图。已有数据可能是状态的快照而非所有变更的日志,但二者关系密切。
"万物皆元数据库"
从这个角度看,整个组织内的数据流开始像是一个巨型数据库 [5]。每当批处理、流处理或 ETL 流程把数据从一处搬到另一处、从一种形式变成另一种形式,它扮演的就是数据库子系统的角色,负责让索引或物化视图保持最新。
如此看来,批处理器与流处理器就像是触发器、存储过程与物化视图维护算法的精巧实现。它们维护的派生数据系统就像不同的索引类型。例如,关系数据库可能支持 B 树索引、哈希索引、空间索引等多种索引。在派生数据系统这种新兴架构里,这些设施不是由单个集成的数据库产品作为特性提供,而是由不同团队管理、运行在不同机器上的不同软件块来提供。
这些发展会把我们带向何方?如果我们从"没有任何单一的数据模型或存储格式适合所有访问模式"这个前提出发,那么不同的存储与处理工具仍然有两条途径可以组成一个有机系统:
联邦数据库(统一读取) 可以为各种底层存储引擎与处理方法提供一个统一的查询接口——这种做法被称为联邦数据库或polystore [17, 18]。例如,PostgreSQL 的外部数据包装器就符合这种模式,Trino、Hoptimator、Xorq 等联邦查询引擎也是如此。需要专用数据模型或查询接口的应用仍然可以直接访问底层存储引擎,而希望从异构来源组合数据的用户也能通过联邦接口轻松完成。
联邦查询接口沿袭关系传统:单一集成的系统、一个高层查询语言、优雅的语义;但实现上颇为复杂。
解绑数据库(统一写入) 联邦解决了跨多个系统的只读查询问题,但对跨这些系统同步写入并无良策。我们已经说过,单个存储系统内部的索引一致性是内置特性。当我们组合多个存储系统时,同样需要确保所有数据变更都落到所有正确的位置——即便有故障也要做到。让多个存储系统的写入可靠地拼装在一起(例如通过 CDC 与事件日志),就像把数据库的索引维护功能解绑为一种能跨异构技术同步写入的形式。
解绑方法承袭 Unix 传统:把秉持"一件事做到极好"的小工具组合在一起 [20],让它们通过统一的低层 API(管道)通信 [20],并可以用更高层语言(shell)来组合 [14]。
让解绑成为可能
联邦与解绑是同一枚硬币的两面:用形形色色的组件搭建一个可靠、可扩展、可维护的系统。联邦的只读查询要求把一种数据模型映射到另一种,这需要费些心思,但终究是个可控问题。让多个存储系统的写入保持同步则是更难的工程问题,本节将聚焦于此。
让异构存储系统之间的写入保持同步的传统做法是分布式事务 [17],它存在前文讨论过的问题。在单个存储或流处理系统内部的事务可行;但当数据跨越不同技术的边界时,带幂等写入的异步事件日志才是更鲁棒、更可行的方法。
例如,分布式事务被一些流处理器内部用来达成恰好一次语义,效果可以相当好。然而,当事务需要跨越由不同团队编写的系统(如从流处理器写入分布式键值存储或搜索索引)时,缺乏标准化的事务协议使集成困难得多。基于带幂等消费者的有序事件日志是简单得多的抽象,因此跨异构系统也可行得多 [5]。
基于日志集成的最大优势是各组件之间的松耦合,体现在两方面:
- 在系统层面,异步事件流让整个系统对单个组件的故障或性能下降更具弹性。如果一个消费者运行变慢或失败,事件日志可以缓冲消息,让生产者与其他消费者继续不受影响地运行。出问题的消费者修好后可以追赶进度,从而既不会漏数据,又把故障局限在一处。相反,分布式事务的同步交互倾向于把局部故障放大为大规模失败。
- 在人的层面,解绑数据系统让软件组件与服务可以由不同团队独立开发、改进、维护。专业化让每个团队都能专注于把一件事做好,并通过定义良好的接口与其他团队的系统交互。事件日志提供的接口足够强大,能够保证相当强的一致性属性(因为持久且有序),同时也足够通用,几乎适用于任何类型的数据。
解绑系统 vs. 集成系统
如果解绑确实成为未来的方向,它并不会取代当前形态的数据库。数据库一如既往地不可或缺:流处理器的状态需要它来维护,批/流处理器的输出也需要它来服务查询。专用查询引擎对特定工作负载也仍然重要——例如,数据仓库的查询引擎为探索性分析查询做了优化,能够极好地处理这类工作负载。
要运维多块基础设施带来的复杂性是个问题。每块软件都有学习曲线、配置问题与运维怪癖,因此应该尽量少部署活动部件。一款集成的软件产品对它所设计的工作负载,可能比一个由若干工具加应用代码拼起来的系统获得更好且更可预测的性能。为不需要的可扩展性而构建是浪费精力,而且会把你锁死在一个不灵活的设计里。事实上,这是一种过早优化。
解绑的目标不是在特定工作负载上与单个数据库较量性能;它的目标是让你能够组合多个数据库,从而在比单一软件能覆盖的更广泛的工作负载上获得良好性能。它关乎广度,而非深度。
因此,如果某项单一技术能满足你全部所需,那你最好就用它,而不是从更低层级的组件去自行实现。解绑与组合的优势只有在没有任何单一软件能满足你全部需求时才会显现。
组合数据系统的工具正变得越来越好。Debezium 能从许多数据库中提取变更流,Kafka 协议正在成为事件流的事实标准,增量视图维护引擎(见第 516 页"增量视图维护")则使得对复杂查询的预计算与缓存更新成为可能。
围绕数据流设计应用
当底层数据变化时去更新派生数据,这一思路并不新。例如,电子表格早就有强大的数据流编程能力 [22]:你可以在某单元格里写一个公式(比如另一列单元格之和),无论这个公式的任何输入发生改变,公式的结果都会自动重算。这正是我们希望在数据系统层面拥有的能力。当数据库中的某条记录发生变化时,我们希望该记录的任何索引被自动更新,依赖它的任何缓存视图或聚合也被自动刷新。我们不必操心这种刷新在技术上怎样发生,应当能简单信任它会正确发生。
因此,多数数据系统仍能从 1979 年 VisiCalc 已具备的特性中学到东西 [23]。数据系统与电子表格的差别在于:今天的数据系统需要容错、可扩展,并能持久存储数据;它们也需要整合由不同团队、不同时期写就的异构技术,并利用既有的库与服务。指望所有软件都用一种语言、框架或工具开发是不现实的。
本节将沿着这些思路展开,探讨围绕解绑数据库与数据流构建应用的一些路径。
应用代码作为派生函数
当一个数据集从另一个派生而来时,会经过某种转换函数。例如:
- 二级索引是一种带有直观转换函数的派生数据:基础表里的每一行或每个文档,挑出索引列或字段中的值,并按这些值排序(假设索引是 SSTable 或 B 树这种按键排序的索引)。
- 全文搜索索引由若干自然语言处理函数(语种识别、分词、词干或词形还原、拼写纠正、近义词识别)创建出来,再构建一种用于高效查找的数据结构(如倒排索引)。
- 在 ML 系统里,模型可以视作通过特征提取与统计分析等函数从训练数据派生出来的。当模型应用于新输入数据时,其输出便从该输入与已学习到的参数派生而来(因而也间接地源自训练数据)。
- 缓存常常包含数据的某种聚合,其形式与将要在 UI 中显示的形式一致。因此填充缓存需要知道 UI 中引用了哪些字段;UI 的变化可能要求更新缓存的填充定义并重建缓存。
二级索引的派生函数太常用,许多数据库直接把它内建为核心特性,仅需运行 CREATE INDEX 便能调用。对全文索引,常见语种的基本语言学特性可能内建在数据库里,但更精巧的特性常需领域特定的调优。机器学习中的特征工程则是出了名的应用相关,常常要把对用户交互与应用部署的细节认知融合进来 [24]。
当创建派生数据集的函数不是诸如二级索引这种"标准模板"时,就需要自定义代码来处理应用特有的方面。这正是许多数据库力不从心的地方。虽然关系数据库通常支持触发器、存储过程与用户自定义函数,可以在数据库内部执行应用代码,但在数据库设计中它们多少有些"事后追加"的味道。
应用代码与状态的分离
理论上,数据库可以像操作系统那样作为任意应用代码的部署环境。然而实际上,它们对此用途相当不适。它们不太契合现代应用开发的诉求——依赖与包管理、版本控制、滚动升级、可演化性、监控、指标、网络服务调用、与外部系统集成等等。
另一方面,部署与集群管理工具(如 Kubernetes、Docker、Mesos、YARN 等)就是专为运行应用代码而设计的。它们专注于把一件事做好,因此比那些只把"用户自定义函数执行"当作众多特性之一的数据库做得好得多。
如今多数 Web 应用以无状态服务的形式部署:任何用户请求都可以路由到任意应用服务器,服务器在发回响应后就忘掉关于这次请求的一切。这种部署方式便于按需添加或移除服务器——但状态总得放在某处(通常是数据库)。这一趋势是把无状态的应用逻辑与状态管理(数据库)相分离:不把应用逻辑放进数据库,也不把持久状态放进应用 [25]。如同函数式编程社区里那句玩笑:"我们信奉教会与国家的分离" [26]。
解释笑话往往会破坏笑点,但这里还是解释一下,免得有谁错过了。Church 指的是数学家阿隆佐·邱奇,他创立了 lambda 演算——一种早期的计算形式,是大多数函数式编程语言的基础。lambda 演算没有可变状态(即没有可被覆盖的变量),因此可以说可变状态与邱奇的工作"相分离"。(译注:英文谚语"separation of Church and state"原指"政教分离",此处借 Church 与教会同名作双关。)
在这种典型的 Web 应用模型里,数据库充当一种可变共享变量的角色,可以通过网络同步访问。应用可以读写这个变量,由数据库负责让它持久,并提供一定的并发控制与容错。
然而,多数编程语言都不能让你订阅可变变量的变化——你只能定期读取它。这与电子表格不同:变量值变化时,读者并不会被通知。(你可以在自己的代码里实现这种通知——这被称为观察者模式——但多数语言并没有把它作为内建特性。)
数据库对可变数据沿袭了这种被动姿态。要想知道数据库内容是否变化,你常常只能轮询(即周期性地重复你的查询)。订阅变更才刚开始作为一种特性出现。
数据流:状态变化与应用代码的相互作用
从数据流视角思考应用,意味着重新审视应用代码与状态管理之间的关系。我们不再把数据库视作被应用操纵的被动变量,而是更多去思考状态、状态变更以及处理它们的代码三者之间的相互作用与协作。应用代码通过在另一处触发状态变更,来响应一处的状态变更。
这一思路我们已经在 CDC、actor 模型、触发器以及增量视图维护中见过。解绑数据库则意味着把这一思路应用到主数据库之外、派生数据集(缓存、全文搜索索引、机器学习或分析系统等)的创建上。我们可以借助流处理与消息系统达成此事。
要维护派生数据需要满足以下属性,而基于日志的消息代理可以提供:
- 维护派生数据时,状态变更的顺序往往很重要(如果多个视图都派生自一个事件日志,它们必须以相同顺序处理事件以彼此保持一致)。
- 容错至关重要——丢一条消息就足以让派生数据集与其数据源永久脱节。消息投递与派生状态更新都必须可靠。
稳定的消息顺序与容错的消息处理是相当严苛的要求,但它们比分布式事务更便宜、运维上更稳健。现代流处理器能够在大规模下提供这些顺序与可靠性保证,并允许应用代码以流算子的形式运行。
这些应用代码可以做数据库内置派生函数一般不做的任意处理。如同 Unix 工具用管道串联起来一样,流算子可以组合在一起,围绕数据流构建出大型系统。每个算子以状态变更流为输入,产出另一种状态变更流为输出。
流处理器与服务
当前主流的应用开发风格是把功能拆成一组通过 REST API 等同步网络请求通信的服务。相对单体应用,这种面向服务的架构主要的好处是通过松耦合达成组织上的可扩展性:不同团队可以分别开发不同服务,从而减少团队间的协调(只要服务能独立部署与更新)。
把流算子组合成数据流系统具有很多与微服务方法相似的特征 [27, 28]。然而,底层通信机制差别很大:单向、异步的消息流,而非同步的请求/响应交互。
除了第 189 页"事件驱动架构"中列出的优点(如更好的容错性)之外,数据流系统在性能上也能优于传统的 REST API 或 RPC。例如,假设一位顾客在购买一件以一种货币标价、却以另一种货币付款的商品。要执行货币兑换,你需要知道当前汇率。这一操作有两种实现方式 [27, 29]:
- 在微服务方法里,处理这次购买的代码大概会查询某个汇率服务或数据库以获取该货币的当前汇率。
- 在数据流方法里,处理购买的代码会预先订阅一份汇率更新流,并在汇率变化时把当前汇率记入本地数据库。处理购买时,代码可以直接查询这份本地数据库。
第二种方法用对本地数据库的查询替代了对另一服务的同步网络请求(本地数据库可能就在同一台机器、甚至同一进程中)。在微服务方法中,你也可以通过把汇率缓存到处理购买的服务本地来避免同步网络请求。然而要让该缓存保持新鲜,就得周期性轮询新汇率或订阅一份变更流——后者正是数据流方法所做的事。
数据流方法不仅更快,也对其他服务的故障更鲁棒。最快、最可靠的网络请求就是没有网络请求!我们不再依赖 RPC,而是在购买事件流与汇率更新事件流之间获得了一次流连接。
这个连接是与时间相关的:如果稍后重处理购买事件,那时的汇率可能已经变了。如果你想重建原始输出,你需要拿到购买发生时的历史汇率。无论是查询服务还是订阅汇率更新流,你都要处理这种时间依赖(见第 525 页"连接的时间依赖")。
订阅一份变更流而不是按需查询当前状态,让我们更接近电子表格式的计算模型。当一份数据变化时,依赖它的任何派生数据都能被迅速更新。许多悬而未决的问题仍在——例如时间依赖的连接——但围绕数据流思想构建应用是一个值得探索的有希望的方向。
观察派生状态
抽象地讲,前一节讨论的数据流系统给了你一种创建派生数据集(搜索索引、物化视图、预测模型)并保持其最新的过程。我们把这一过程称作写路径:每当一条信息写入系统,它可能会经过多级批/流处理,最终每个派生数据集都会被更新以纳入新写入。图 13-1 展示了更新搜索索引的一个例子。

图 13-1. 搜索索引中,写入(文档更新)与读取(查询)相遇。 但你最初为何要创建这个派生数据集?多半是因为你打算稍后再次查询它。这就是读路径:当为用户请求服务时,你从派生数据集读取,可能再做些处理,最终构造出对用户的响应。
把写路径与读路径合在一起,便涵盖了数据从被采集到被消费(多半由另一个人)的整个旅程。写路径是该旅程中预先完成的部分——数据一进来就急切地完成,不管此时是否有人在请求。读路径则是这段旅程中只在有人提出请求时才发生的部分。如果你熟悉函数式编程语言,便会注意到写路径类似急切求值,读路径类似惰性求值。
派生数据集是写路径与读路径相遇之处,如图 13-1 所示。它代表了"在写入时该做多少工作"与"在读取时该做多少工作"之间的取舍。
物化视图与缓存
全文搜索索引是个好例子:写路径更新索引,读路径在索引里搜索关键词。读写两侧都得做些事。写入需要更新文档中所有出现词项的索引项。读取需要在查询里搜索每个词,并应用布尔逻辑找出包含查询里所有词的文档(AND 操作符)或包含每个词任一同义词的文档(OR 操作符)。
如果没有索引,搜索查询就得扫描所有文档(像 grep 那样),文档数量一大就会非常昂贵。没有索引意味着写路径上工作量更少(无索引可更新),但读路径上工作量大得多。
另一方面,你可以设想预先计算所有可能查询的搜索结果。这样读路径上工作就少了:不再需要布尔逻辑,按查询找出结果即可。然而写路径就贵得多:可能的搜索查询集合是无限的(或者说至少与语料词项数呈指数级),因此预计算所有搜索结果是不可能的。
另一种选择是只为一组固定的最常见查询预计算结果,让它们能够无须访问索引就被快速服务。罕见的查询仍然由索引来响应。这通常被称为常见查询的缓存——尽管也可以叫物化视图,因为它需要在新出现的文档应当包含到某个常见查询的结果中时被更新。
从这个例子可以看出:写路径与读路径之间唯一可能的边界并不只有索引。在小量文档上做缓存常见搜索结果是可以的,没有索引的 grep 式扫描在小量文档上也行得通。这样看来,缓存、索引与物化视图的角色就很简单:它们移动读路径与写路径之间的边界。它们让我们在写路径上多做工作(预计算结果),从而在读路径上省力。
把工作在读路径与写路径之间转移的边界,正是第 34 页"案例研究:社交网络主页时间线"那个社交网络例子的主题。在那个例子里,我们也看到了对名人与普通用户,写路径与读路径之间的边界可能划得不一样。翻过 500 页之后,我们终于绕回了原点!
有状态、可离线工作的客户端
读路径与写路径之间存在一条边界这一思路很有意思,因为我们可以讨论"移动这条边界"以及这种移动在实践中意味着什么。让我们换个语境来思考这个问题。
过去,Web 浏览器是无状态客户端,只在你有网络连接时才能干一些有用的事(基本上离线时唯一能做的就是上下滚动一页之前在线时加载过的页面)。然而最近的单页 JavaScript Web 应用已经获得了大量有状态能力,包括客户端 UI 交互与浏览器内的持久化本地存储。移动应用同样可以在设备上存储大量状态,绝大多数用户交互不必往返服务器。
第 220 页"同步引擎与本地优先软件"中,我们看到持久的本地状态如何让一类应用成为可能:用户可以离线工作、不需要互联网连接,并在网络可用时与远端服务器在后台同步 [30]。由于移动设备的蜂窝网络连接有时缓慢且不可靠,UI 不必等同步网络请求、绝大多数时候都能离线工作——对用户来说,这是个巨大优势。
当我们从"无状态客户端对话中央数据库"的假设转向"状态在终端设备上维护",新的可能性就打开了。具体而言,我们可以把设备上的状态视作"服务器上状态的一份缓存"。屏幕上的像素是客户端应用中模型对象的物化视图;模型对象则是远程数据中心中状态的一份本地副本 [31]。
把状态变更推送到客户端
在 Web 浏览器里加载一个典型网页,如果数据后续在服务器端变了,浏览器在你下次重新载入页面之前都不会知道。浏览器只在某一个时刻读取数据,并默认它静止;浏览器不会订阅服务器的更新。因此浏览器里的状态是一份过时缓存——除非你显式地轮询变更,否则它就不会更新。(HTTP 上的订阅协议,如 RSS,本质上不过是一种基本的轮询。)
晚近的协议已经超越了 HTTP 简单的请求/响应模式。Server-sent events(EventSource API)与 WebSockets 提供了通信通道:浏览器可以与服务器保持一条 TCP 长连接,只要连接还在,服务器就可以主动向浏览器推送消息。这给了服务器一个机会,主动告知终端客户端它本地保存的状态发生的任何变化,从而减少客户端状态的过期时间。
按照写路径/读路径的模型来看,主动把状态变更一直推到客户端设备意味着把写路径延伸到了终端用户那里。客户端首次初始化时,仍然要走一次读路径来获取初始状态,但此后就可以依赖服务器发送的状态变更流了。我们围绕流处理与消息所讨论的那些思想,因此就不局限在数据中心里运行——可以把它们一路延伸到终端用户设备 [32]。
设备会有一段时间处于离线状态,那段时间里它们无法接收来自服务器的状态变更通知。但这个问题我们已经解决了;在第 498 页"消费者偏移量"中我们讨论过:基于日志的消息代理的消费者在失败或断连后可以重新连接,并确保不漏掉断连期间到达的消息。同样的技术对个体用户也适用——每台设备就是订阅某条小事件流的一个小消费者。
端到端事件流
开发有状态客户端与 UI 的工具——比如 React 与 Elm [33]——已经可以根据底层状态的变化来更新呈现给用户的 UI。把这种编程模型扩展到允许服务器把状态变更事件推入这条客户端事件管道,将十分自然。
状态变更可以经由一条端到端的写路径流动:从一台设备上触发该变更的交互开始,经过事件日志、若干派生数据系统与流处理器,一路到达另一台设备的用户界面。这些状态变更可以以相当低的延迟传播——比方说端到端 1 秒以内。
某些应用,如即时通讯与在线游戏,已经具备这种"实时"架构(这里的"实时"指交互的低延迟,而非响应时间保证)。我们为什么不把所有应用都建成这样?
挑战在于:无状态客户端与请求/响应交互的假设深深扎根于我们的数据库、库、框架与协议中。许多数据存储支持读写操作(一次请求返回单个响应),而支持"一次请求随时间返回一系列响应"(即订阅变更)操作的就少得多。
要把写路径一路延伸到终端用户,我们需要从根本上重新思考很多系统的构建方式:从请求/响应交互转向发布/订阅式的数据流 [31]。这需要一些努力,但好处是 UI 反应更快,也能更好地支持离线。
读取也是事件
我们讨论过,当流处理器把派生数据写到一个存储(数据库、缓存或索引)、而该存储被查询时,该存储就在写路径与读路径之间充当边界。它支持对那些"否则就要扫描整个事件日志才能拿到"的数据做随机访问的读查询。
很多情况下,数据存储与流式系统是分离的。然而别忘了,流处理器自身也需要维护状态来执行聚合与连接。这种状态通常隐藏在流处理器内部,但有些框架允许外部客户端查询它 [34],从而让流处理器自身成为某种简易数据库。
让我们把这个想法再推进一步。在我们目前讨论的模型里,对存储的写入要走事件日志,而读取则是直接打到存储该数据节点的瞬时网络请求。这是合情合理的设计,但并非唯一可能。也可以把读请求表示为事件流,把读事件与写事件都通过流处理器来发送。处理器响应读事件的方式,是把读取的结果发到一个输出流上 [35]。
当写入与读取都被表示为事件,并被路由到同一个流算子时,我们其实就是在对查询流与数据库之间执行一次流-表连接。每个读事件需要被发到持有相关数据的那个数据库分片,正如批/流处理器执行连接时按相同键对输入做共分区。
服务请求与执行连接之间的这种对应相当根本 [36]。一次性的读请求经过连接算子后,算子立刻就忘掉了它;订阅请求则是与连接的另一边过去与未来事件之间的持久连接。
记录读事件日志在追踪因果依赖与跨系统数据来源方面也潜在有好处。这份日志能让你重构某个用户在做出特定决定前看到了什么。例如,在网店里,向顾客展示的预计发货日期与库存状态可能会影响他们是否决定购买 [4]。要分析这种联系,就需要把用户对发货与库存状态的查询结果记下来。
把读请求写到持久化存储能更好地追踪因果关系,但带来了额外的存储与 I/O 成本。如何优化此类系统以降低开销仍是一个开放研究问题 [2],但如果你已经把读请求当成请求处理的副产品记录下来了用作运维目的,那么把日志当作请求源头也并非什么大改动。
多分片数据处理
对于只触及单个分片的查询,把它们送过一条流再收集回一条响应流也许有点小题大做。然而,这种思路也开启了对需要组合多个分片数据的复杂查询执行分布式执行的可能——它复用了流处理器已经提供的消息路由、分片与连接基础设施。
Storm 的分布式 RPC特性支持这种使用模式。例如,它被用来计算社交网络中"看到过某条 URL 的用户数"——也就是发过该 URL 的所有用户的关注者并集 [37]。由于用户集合是分片的,这一计算需要合并来自许多分片的结果。
反欺诈中也有类似模式。要评估某次购买事件是否欺诈,可以检查该用户的 IP 地址、邮箱地址、账单地址、配送地址等的信誉评分。这些信誉数据库各自是分片的,因此为某次购买事件收集这些评分需要一系列对不同分片数据集的连接 [38]。
数据仓库查询引擎的内部查询执行图也具有类似特征。如果你需要做这种多分片连接,使用一个提供该特性的数据库可能比用流处理器实现要简单。然而把查询当作流来处理,提供了一种实现方式——可以承载那些超出常规现成方案能力上限的大规模应用。
力求正确
对于只读数据的无状态服务,出错并非大事;修复 bug、重启服务,便能恢复正常。但像数据库这样的有状态系统就没那么简单了。它们生来要把事情记住(多多少少永久地),所以一旦出错,影响也潜在地永久存在——这意味着它们需要更细致的思考 [39]。
我们想构建可靠而正确的应用——即便面对各种故障,其语义也清晰、易于理解。大约四十年来,原子性、隔离性与持久性等事务属性一直是构建正确应用的首选工具。然而这些基础并没有看上去那么坚实:弱隔离级别引发的混乱便是一个例证(见第 288 页"弱隔离级别")。
在某些领域,事务被彻底放弃,被一些性能与可扩展性更好但语义更杂乱的模型所取代。一致性经常被谈及,但定义往往含糊。一些人主张为了更好的可用性而"拥抱弱一致性",却对它在实践中究竟意味着什么并无清晰认识。
对于一个如此重要的话题,我们的理解与工程方法却出奇地不牢靠。例如,要判断在某种事务隔离级别或复制配置下运行某个特定应用是否安全,往往非常困难 [40, 41]。一些看似简单的解决方案在并发量低、无故障时似乎工作正常,却在更苛刻的场景下暴露出许多微妙的 bug。
例如 Kyle Kingsbury 的 Jepsen 实验 [42] 就揭示了一些产品宣称的安全保证与它们在网络问题与崩溃下的实际行为之间的显著差距。即便基础设施产品(如数据库)没有问题,应用代码仍然需要正确地使用它们提供的特性——若配置不易理解(弱隔离级别、quorum 配置等就是如此),就极易出错。
如果你的应用能容忍偶尔以不可预知方式损坏或丢失数据,那日子要简单得多——只要别把"碰运气、求老天保佑"当方法论就行。如果你需要更强的正确性保证,可串行化与原子提交是已确立的方法,但代价不低。它们通常只能在单个数据中心内工作(排除了地理分布式架构),而且会限制你能达到的规模与容错属性。
虽然传统事务方法不会消失,但在让应用正确并对故障有弹性这件事上,它并不是最后的话。本节将探讨在数据流架构的语境下,关于正确性的其他思路。
数据库的端到端论证
仅仅因为应用使用了一个提供相对强安全属性(如可串行化事务)的数据系统,并不意味着应用就能保证免于数据丢失或损坏。例如,如果应用的某个 bug 写入了错误数据或从数据库删除了数据,可串行化事务并不能救你。这正是支持不可变与只追加数据的一个理由:把"破坏好数据的能力"从有缺陷代码手中拿走,从这种错误中恢复就更容易了。
虽然不可变性有用,但它本身不是包治百病的灵药。让我们看看一个更微妙的数据损坏例子。
操作的恰好一次执行
在第 328 页"跨异构系统的分布式事务"中,我们引入了消息处理语境下的恰好一次(或实质上恰好一次)语义。其思想是:处理消息时若出错,要么放弃(丢消息并承受数据丢失),要么再试。再试存在风险——首次处理可能其实成功了、只是你没收到确认,于是消息最终被处理两次。
处理两次是一种数据损坏:不应该向客户重复收费两次(多收钱),也不应该把计数器加两次(高估指标)。在这种语境下,恰好一次意味着把计算安排得使最终效果与"未发生过任何故障"相同——即便操作因故障被重试。我们讨论过几种达成这一目标的方法。
最有效的方法之一是让操作幂等——即无论执行一次还是多次,效果都相同。然而把一个本不天然幂等的操作变得幂等,就需要小心与心思。你可能要维护额外的元数据(如已更新过某个值的操作 ID 集合),并在节点故障转移时使用 fencing(见第 373 页"分布式锁与租约")。
抑制重复
除流处理外,在许多其他地方也存在抑制重复的需求。例如 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 请求向服务器提交一条指令。也许用户的蜂窝数据信号很弱,请求发出去了,但还没收到服务器响应就丢了信号。
这种情况下,用户大概会看到错误信息,然后他们可能手动重试。Web 浏览器会警告:"你确定要再次提交此表单吗?"——用户说是,因为他们就想让操作发生。(Post/Redirect/Get 模式 [43] 在正常操作下避免了这条警告,但 POST 请求超时时也帮不上忙。)从 Web 服务器的视角看,重试是一次单独的请求;从数据库的视角看,它是一笔单独的事务。常规的去重机制派不上用场。
唯一标识请求
要让一个请求在跨多跳网络通信时仍幂等,仅依赖数据库提供的事务机制是不够的。你要从端到端的请求流来考虑。
例如,你可以为每个请求生成一个唯一标识(如 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 表还充当了某种事件日志,对事件溯源或 CDC 也很有用。账户余额的更新不必在同一事务中、与事件插入一起进行,因为它们是冗余的——下游消费者可以从请求事件中派生它们,只要事件被恰好一次处理(这一点同样可以借助请求 ID 来强制保证)。
端到端论证
抑制重复事务这个场景,只是被称作端到端论证的更普遍原则的一个例子,由 Saltzer、Reed 与 Clark 在 1984 年提出 [44]:
所讨论的功能,只能在站在通信系统两端的应用的知识与帮助下,才能被完整、正确地实现。因此,把这一功能作为通信系统本身的特性来提供是不可能的。(有时由通信系统提供该功能的一个不完整版本作为性能增强,也可能有用。)
在我们的例子里,"所讨论的功能"是抑制重复。我们看到 TCP 在 TCP 连接级别抑制重复包,一些流处理器在消息处理级别提供所谓的恰好一次语义,但若用户首次提交超时后又交了一次重复请求,仅靠这些是不够的。TCP、数据库事务与流处理器单凭自己都不能完全排除这些重复。解决该问题需要端到端的方案:把一个事务标识从终端用户客户端一路传到数据库。
端到端论证也适用于校验数据完整性。Ethernet、TCP 与 TLS 内置的校验和能检测网络中包损坏,但它们检测不到收发双方软件 bug 引起的损坏,也检测不到数据存放磁盘上的损坏。如果你想抓住一切可能的损坏来源,还需要端到端校验和。
类似的论证也适用于加密 [44]。家用 WiFi 网络的密码可以防别人窃听你的 WiFi 流量,但防不了互联网别处的攻击者;客户端与服务器之间的 TLS/SSL 防外部网络攻击者,但防不了服务器的入侵。只有端到端的加密与认证才能防住所有这些。
虽然低层特性(TCP 重复抑制、Ethernet 校验和、WiFi 加密)无法独立提供期望的端到端属性,它们仍然有用,因为它们降低了高层出问题的概率。例如,若没有 TCP 把包按正确顺序排回去,HTTP 请求经常会被弄乱;只是要记住:低层可靠性特性本身不足以保证端到端正确性。
把端到端思想用在数据系统里
这把我们带回最初的论点:仅仅因为应用使用了一个提供相对强安全属性(如可串行化事务)的数据系统,并不意味着应用免于数据丢失或损坏。应用本身也得采取端到端措施,比如重复抑制。
可惜这事情确实让人头疼,因为容错机制本就不容易做对。低层可靠性机制(如 TCP)效果不错,因此剩下的高层故障相当少见。要是能把高层容错机制包进一个抽象,让应用代码不必操心,那真是太好了——但看起来我们还没找到这种正确的抽象。
长期以来,人们把事务视作一种有用的抽象。如第 8 章所述,它们把广泛的可能问题(并发写、违反约束、崩溃、网络中断、磁盘故障)合并为两种可能结果:提交或中止。这是对编程模型的巨大简化,但还不够。
事务代价高昂,尤其在涉及异构存储技术时(见第 328 页"跨异构系统的分布式事务")。当我们因代价过高而拒绝使用分布式事务时,最终就得在应用代码里重做容错机制。如同书中无数例子所示,对并发与部分失效的推理违反直觉、十分困难,因此大多数应用层机制并不能正常工作。结果便是数据丢失或损坏。
正因如此,值得探索这样的容错抽象:能让应用提供特定的端到端正确性属性,同时在大规模分布式环境中保持良好的性能与运维特性。
强制约束
让我们把正确性放到解绑数据库的思路语境里。我们看到了端到端的重复抑制可以靠把一个请求 ID 从客户端一路带到记录写入的数据库来实现。其他类型的约束又如何?
具体来说,让我们关注唯一性约束——例 13-2 就依赖了这种约束。第 409 页"约束与唯一性保证"中我们见过另几个需要强制唯一性的应用特性的例子:用户名或邮箱地址必须唯一标识一个用户、文件存储服务里同名文件不能同时存在、两个人不能预订航班或剧场的同一个座位。
其他约束也很相似——比如确保账户余额不会变成负数、不要卖出超过仓库库存的商品、会议室预订不重叠。强制唯一性的技术常常也能用于这些约束。
唯一性约束需要共识
第 10 章中我们看到:分布式环境下强制唯一性约束需要共识。如果有若干并发请求带相同值,系统必须以某种方式决定接受其中之一、把其他视作违反约束而拒绝。
实现这种共识最常见的办法是把一个节点定为主节点,由它做所有决定。只要你不介意把所有请求漏斗过单个节点(即便客户端在地球另一端)、并且该节点不出故障,这就能正常工作。Raft 之类的共识算法解决了在当前主节点失败(或被认为因网络问题失败)时安全选举新主节点、并避免脑裂的问题。
唯一性检查可以根据需要唯一的值做分片,从而横向扩展。例如,若你需要靠请求 ID 保证唯一(如例 13-2),就可以确保所有带相同请求 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 页"线性一致性 vs. 可串行化")。
把一个操作解绑到流处理器多个阶段时,情况就不同了。日志的消费者是异步的,所以发送方默认不会等到它的消息被消费者处理之后才返回。然而,让客户端等待某条消息出现在输出流上是可能的——就像图 13-2 中的用户在等对外付款或付款被拒事件,等待结果取决于源账户余额是否充足。
这个例子里,源账户余额检查的正确性并不取决于"做出请求的用户是否等到结果"。等待的目的只是同步地告知用户付款是否成功——这一通知与请求处理本身的效果相互解耦。
更普遍地说,一致性这个词混淆了两种值得分别考虑的需求:
时效性 时效性意味着确保用户观察到的系统是最新状态。我们之前看到,如果用户从一份过时的数据副本读取,可能观察到不一致状态(见第 209 页"复制延迟带来的问题")。然而这种不一致是暂时的,靠等待与重试就会被解决。
CAP 定理使用线性一致性意义上的"一致性",是达成时效性的强方式。较弱的时效性属性(如读自己的写一致性)也可能有用。
完整性 完整性意味着没有损坏——不丢失数据、没有自相矛盾或虚假数据。具体来说,如果一个派生数据集是被维护为底层数据的视图,派生过程必须正确。例如,数据库索引必须正确反映数据库内容——一个缺失记录的索引并不顶用。
一旦完整性被破坏,不一致就是永久的;多数情况下,等待重试也修不好数据库损坏。需要的是显式检查与修复。在 ACID 事务的语境里,"一致性"通常被理解为某种应用特定的完整性概念。原子性与持久性是保护完整性的重要工具。
概括成一句口号:违反时效性在最终一致性下尚可接受,违反完整性则会导致永久不一致。
在多数应用里,完整性远比时效性重要。违反时效性令人烦恼、令人困惑;违反完整性则可能是灾难性的。
例如,信用卡账单上 24 小时内做的一笔交易还没出现并不奇怪——这些系统有一定的延迟。我们知道银行间的交易是异步对账与结算的,时效性在此并不太重要 [3]。然而,账单余额若不等于交易金额加上前一份账单余额(求和错误),或者你被收了款而商家未收到(钱凭空消失),那就糟透了。这就是违反系统完整性。
数据流系统的正确性
ACID 事务通常同时提供时效性(如线性一致性)与完整性(如原子提交)保证。因此,从 ACID 事务视角看待应用正确性,时效性与完整性的区分并不重要。
另一方面,本章讨论的基于事件的数据流系统的一个有意思的特性是:它们解耦了时效性与完整性。当异步处理事件流时,没有时效性保证——除非你显式构造在返回前等待某条消息到达的消费者。然而完整性其实是流式系统的核心。如我们所见,恰好一次或实质上恰好一次语义就是一种保持完整性的机制。如果一个事件丢失或生效两次,数据系统的完整性就可能被破坏。因此,可容错的消息投递与重复抑制(如幂等操作)对于在故障下保持数据系统完整性至关重要。
如前一节所示,可靠的流处理系统能够在不需要分布式事务与原子提交协议的前提下保持完整性,这意味着它们能以更好的性能与运维稳健性达到同等的正确性。我们通过若干机制的组合达成这种完整性:
- 把写操作的内容表示为单条消息——一次原子写入即可完成;这与事件溯源非常契合。
- 通过确定性的派生函数从该单条消息派生出所有其他状态更新,类似存储过程。
- 把客户端生成的请求 ID 一路向下传递所有处理层级,启用端到端的重复抑制与幂等。
- 把消息保持为不可变,并允许派生数据时不时被重新处理,这让从 bug 中恢复更容易。
宽松解释的约束
如前所述,强制唯一性约束需要共识,通常通过把所有事件漏斗过特定分片中的单个节点来实现。如果我们想要传统意义上的唯一性约束,这一限制不可避免,流处理无法绕过它。
然而,许多真实应用其实可以以宽松得多的方式来满足"硬约束":
- 如果顾客订购的物品超过了你仓库的库存,你可以补订库存、为延迟道歉、并给他们打折优惠。如果叉车撞翻了仓库里一些货品、致使你实际持有的库存少于以为的库存量 [3],你也得这么做。所以,因应这类事件的"道歉工作流"本就是业务流程的一部分,对在售物品数量加硬约束反倒可能是多余的。
- 类似地,许多航空公司会在预期有些乘客错过航班的前提下超售机票;许多酒店超售房间,预期有些客人会取消。这些情况里,"一座一人"的约束被刻意以业务理由违反,并配套有补偿流程(退款、升舱、提供邻近酒店免费房间)来应对超出供给的需求。即便没有超售,也需要有道歉与补偿流程来处理诸如航班因恶劣天气或员工罢工而取消的事件——从这些问题中恢复其实是商业的常态 [3]。
- 如果有人取的钱多于账户余额,银行可以收他们透支费、并要求他们补还所欠款项。靠限制每天的总取款额,银行的风险也是有限的。
- 对跨组织整合数据的系统,不一致性必然会出现,需要修正机制。如第 476 页"批处理用例"所述,银行间款项结算就是一个例子。
在许多商业语境下,临时违反一个约束并稍后通过道歉来弥补是可以接受的。这种纠错变更被称作补偿事务 [48, 49]。道歉的成本(在金钱或声誉上)有所不同,但通常很低;电子邮件不能撤回,但你可以发一封跟进邮件做更正。如果你不小心把一张信用卡刷了两次,你可以退掉其中一笔,代价不过是处理费与一个客户投诉。一旦钱从 ATM 取走,你不能直接收回;但原则上你可以在透支用户不还钱时派催收员追回。
道歉成本是否可接受是商业决策。若可接受,那么"在写数据前检查所有约束"的传统模型反倒过度限制;先做乐观地写入再事后检查约束也许就够了。你仍能确保在采取昂贵且难以恢复的行动之前先校验,但这并不意味着你必须在写入前就校验。
这些应用的确需要完整性:你不希望一份预订单消失,也不希望钱在不匹配的借贷间凭空消失。但它们不需要把约束的强制做得即时。如果你卖出了仓库里没有的物品,可以事后补救——这与第 222 页"处理冲突写入"中的冲突解决方法是类似的。
避免协调的数据系统
至此我们做了两个有趣的观察:
- 数据流系统可以在不需要原子提交、线性一致性或同步跨分片协调的情况下,为派生数据维持完整性保证。
- 虽然严格的唯一性约束需要时效性与协调,但许多应用对宽松约束没问题——只要在过程中保持了完整性,约束可以被临时违反、稍后补救。
合在一起,这些观察意味着数据流系统可以在不要求协调的情况下为许多应用提供数据管理服务,同时还给出强完整性保证。这种避免协调的数据系统颇有吸引力:它们可以比那些需要做同步协调的系统获得更好的性能与容错 [45]。
例如,这种系统可以在跨多个数据中心的多主配置中分布式运行,以异步方式跨区域复制。任一数据中心都可以独立于其他中心运行,因为不需要同步的跨区域协调。这种系统会有较弱的时效性保证——若不引入协调便不能线性一致——但仍然可以有强完整性保证。
在这种语境下,可串行化事务作为维护派生状态的一部分仍然有用,但它们可以在小作用域内运行,效果就很好 [6]。XA 事务这类异构分布式事务并非必需。同步协调仍可在需要的地方引入(例如,要在某个无法恢复的操作前强制严格约束),但若只有应用一小部分需要协调,并不需要让一切都付出协调代价 [32]。
另一种看待协调与约束的方法是:它们减少了你为不一致而必须做的道歉次数,但也潜在地降低了系统的性能与可用性,从而潜在地增加了你为停机而必须做的道歉次数。你不可能把道歉次数降到零,但可以努力为你的需要找到最佳折中——既不过多不一致、也不过多可用性问题的那个甜点。
信任,但要核验
我们关于正确性、完整性与容错的全部讨论都建立在某种假设之上:某些事可能出错,但其他事不会出错。这些假设我们称作系统模型(见第 380 页"系统模型与现实")。例如我们应当假定进程可能崩溃、机器可能突然断电、网络可能任意延迟或丢消息;同时也可以假定写到磁盘的数据在 fsync 之后不会丢失、内存里的数据不会损坏、CPU 的乘法指令永远返回正确结果。
这些假设相当合理,多数时间是真的;我们如果总担心计算机可能犯错,就什么事都做不成。传统上,系统模型对故障采取二元态度:假设某些事会发生、其他事永不会发生。在现实中,这更是一个概率问题:有些事更可能、有些事更不太可能。问题在于:我们的假设被违反的频率,是否高到我们在实践中会遇到?
我们见过:内存中数据可能损坏(见第 44 页"硬件与软件故障"),磁盘上数据可能损坏(见第 283 页"复制与持久性"),网络上数据也可能损坏(见第 379 页"弱形式的撒谎")。也许这些是我们应当更多关注的事?如果你的运维规模足够大,即便极不可能的事也会发生。
在软件 bug 面前维护完整性
除了硬件问题外,还总有软件 bug 的风险——这些 bug 不会被低层网络、内存、文件系统校验所捕获。即便广泛使用的数据库软件也有 bug——例如 MySQL 过去版本曾未能正确维护唯一性约束 [50],PostgreSQL 的可串行化隔离级别也曾出现写偏斜异常 [51],尽管 MySQL 与 PostgreSQL 都是经多年实战检验、由很多人维护的稳健且口碑良好的数据库。在不那么成熟的软件里,情况大概更糟。
尽管在精心设计、测试与评审上投入了大量精力,bug 仍会潜入。它们罕见、最终会被发现并修复,但这之前总有一段时间这种 bug 可能损坏数据。
至于应用代码,我们必须假定有更多 bug——多数应用得到的评审与测试,远比不上数据库代码所获。许多应用甚至没有正确使用数据库为保持完整性而提供的特性,例如外键或唯一性约束 [25]。
ACID 意义上的"一致性"基于这样的设想:数据库以一致状态开始,事务把它从一个一致状态变换到另一个一致状态。因此我们期望数据库始终处于一致状态。然而,这种概念只在我们假定事务无 bug 的前提下才有意义。如果应用以某种方式错误使用数据库(例如不安全地使用了弱隔离级别),数据库的完整性就无从保障。
别盲信他们承诺的事
由于硬件与软件并不总能达到我们的理想,数据损坏迟早不可避免。因此我们至少应当有办法发现数据是否被损坏,以便修复并追查错误来源。检查数据完整性叫做审计。
如第 509 页"不可变事件的优势"所述,审计不只是金融应用的事。然而可审计性在金融领域格外重要,正是因为人人都知道错误会发生,并且都认识到必须能发现并修复问题。
成熟系统也倾向于把"不太可能的事可能出错"考虑进去并管理风险。例如 HDFS、Amazon S3 这类大规模存储系统并不完全信任磁盘:它们运行后台进程持续读回文件、与其他副本比较、把文件从一个磁盘搬到另一个,以减小静默损坏的风险 [52, 53]。
要确认数据还在,你必须读取并核验。多数时间它会在那里;但若不在,你也想尽早发现,而不是等太久才察觉。同理,时不时从备份恢复一次也很重要——否则你可能会发现备份坏了,而那时已经太晚、数据已丢失。不要盲目相信"它在工作"。
像 HDFS、S3 这样的系统仍要假定磁盘多数时间正常工作——这是一个合理假设,但与"假定它们永远正常工作"不同。但目前还没有多少系统会以这种"信任但核验"的方式持续地自我审计。许多系统假定正确性保证是绝对的,并不为罕见的数据损坏可能性做准备。未来我们可能看到更多自验证或自审计的系统,它们持续检查自身完整性,而不依赖盲目信任 [54]。
为可审计性而设计
如果一个事务在数据库中改了若干对象,事后判断这些改动背后的原因可能很难。即便你能拿到事务日志,对各表的插入、更新、删除也未必清楚地说明这些变更为何被执行。决定执行这些变更的应用逻辑的调用是瞬时的、不可重现的。
相比之下,基于事件的系统能提供更好的可审计性。在事件溯源方法里,对系统的用户输入被表示为单个不可变事件,任何随之而来的状态更新都派生自该事件。派生过程可以做成确定且可重复的,因此用同一份派生代码在同一份事件日志上跑,会产生同样的状态更新。
把数据流明确化让数据来源更清晰,从而让完整性检查更可行。对事件日志,我们可以用哈希来检查事件存储未被损坏;对任何派生状态,我们可以重新跑批/流处理器(它们派生这些状态),看是否得到相同结果,或者甚至并行运行一份冗余派生。
确定且定义良好的数据流也让调试与追查系统执行、确定它为何做了某事更容易 [4, 55]。如果出现意料之外的事件,能够诊断地重现导致这一事件的确切情境就很有价值——某种意义上的"时光旅行调试"。
端到端论证再次出现
如果我们不能完全相信系统的每个组件都不损坏——每块硬件无故障、每段软件无 bug——那么我们至少必须周期性检查数据完整性。如果不查,就要等到造成下游损害后才发现损坏,那时追查问题会困难得多、成本也高得多。
数据系统的完整性检查最好以端到端方式进行。我们能纳入完整性检查的系统越多,损坏在过程某一阶段被忽略的机会就越少。如果能端到端检查整条派生数据管道是正确的,那么沿途的所有磁盘、网络、服务与算法就都被隐式纳入检查了。
具备持续的端到端完整性检查会让你对系统正确性更有信心,从而能更快地推进 [56]。如同自动化测试一样,审计提高了 bug 被快速发现的概率,从而降低了一次系统改动或新存储技术导致损害的风险。如果你不畏改动,就能更好地演化应用以满足不断变化的需求。
可审计数据系统的工具
目前没有多少数据系统把可审计性当作头等关切。一些应用实现了自己的审计机制——例如把所有变更记到一张单独的审计表——但保证审计日志与数据库状态本身的完整性仍然困难。事务日志可通过定期用硬件安全模块对其签名而做到防篡改,但这并不能保证最初进入日志的就是正确的事务。
像比特币、以太坊这样的区块链是带密码学一致性检查的共享只追加日志:所存储的事务就是事件,智能合约本质上就是流处理器。它们使用的共识协议确保所有节点对相同事件序列达成一致。与第 10 章共识协议的不同在于:区块链是拜占庭容错的——也就是说,即便部分参与者节点的数据被损坏,它仍能工作,因为副本会持续相互核验完整性。
对多数应用来说,区块链开销过高,并不实用。然而它们的某些密码学工具也可以在更轻量的语境里使用。例如,Merkle 树 [57] 是哈希树,可用于高效证明某条记录出现在某个数据集中(以及其他一些事)。证书透明度使用经密码学验证的只追加日志与 Merkle 树来检查 TLS/SSL 证书的有效性 [58, 59];它通过每个日志只有一个签发者来避免共识协议。
像证书透明度与分布式账本所用的那种完整性检查与审计算法,未来在数据系统中可能得到更广泛的应用。要让它们的可扩展性与无密码学审计的系统相当、并把性能损耗降到最低,还需做些工作;但它们仍然值得关注。
小结
本章我们讨论了基于流处理思想的数据系统设计的新方法。我们从这一观察出发:没有任何单一工具能高效地服务所有可能用例,因此应用必须组合若干软件块来达成目标。我们讨论了如何用批处理与事件流让数据变更在系统间流动,从而解决数据集成问题。
在这种方法里,某些系统被指定为真实数据源,其他数据通过转换从中派生。这样我们便能维护索引、物化视图、机器学习模型、统计汇总等等。把这些派生与转换做成异步的、松耦合的,有助于把一处问题局限在那里、避免它扩散到无关领域,从而提升整个系统的稳健性与容错。
把数据流表达为从一个数据集到另一个数据集的转换,也有助于演化应用。如果你想改变某个处理步骤——例如改变索引或缓存的结构——可以在整个输入数据集上重跑新的转换代码以重新派生输出。同样,若出问题,你可以修代码并对数据再处理来恢复。
这些过程与数据库内部已经在做的事相当类似,因此我们重新把数据流应用的思想表述为:把数据库的组件解绑,并通过组合这些松耦合组件来构建应用。
派生状态可以通过观察底层数据的变更来更新;下游消费者也可以观察这种状态。我们甚至可以把这种数据流一路推到展示数据的终端用户设备,从而构建出动态反映数据变更并能持续离线工作的 UI。
接下来我们讨论了如何在故障下确保所有这些处理依然正确。我们看到强完整性保证可以靠异步事件处理可扩展地实现:用端到端请求标识让操作幂等、对约束做异步检查。客户端可以选择等到检查通过再前进,或者承受违反约束就要事后道歉的风险继续。相比传统的分布式事务方法,这种方法更具可扩展性、更稳健,与许多商业流程在实践中的运转方式更契合。
通过围绕数据流构建应用、并以异步方式检查约束,我们可以避免大多数协调,从而构建出在地理分布与故障情境下仍然保持完整性、且性能良好的系统。最后,我们简要讨论了用审计来核验数据完整性、检测损坏,并观察到区块链所用的技术与基于事件的系统也有相似之处。