《数据密集型系统应用设计》小结

Administrator 239 2023-03-28

摘抄自原书各个章节的小结部分,并做部分精简

本书内容安排

全书分为三大部分:

  1. 第一部分,主要讨论有关增强数据密集型应用系统所需的若干基本原则。

  2. 第二部分,将从单机的数据存储转向跨机器的分布式系统,这是扩展性的重要一步,但随之而来的是各种挑战。

  3. 第三部分,主要针对产生派生数据的系统,所谓派生数据主要指在异构系统中,如果无法用一个数据源来解决所有问题,那么一种自然的方式就是集成多个不同的数据库、缓存模块以及索引模块。

第一部分 数据系统基础

第 1 章 可靠、可扩展与可维护的应用系统

一个应用必须完成多种预期的需求,主要包括功能性需求(即应该做什么,如各种存储、检索、搜索和处理数据)和非功能性需求(即常规需求,如安全性、可靠性、合规性、可伸缩性、兼容性和可维护性)。本章着重讨论可靠性、可扩展性和可维护性。

可靠性意味着即使发生故障,系统也可以正常工作。故障包括硬件(通常是随机的,不想关的)、软件(缺陷通常是系统的,更加难以处理的)以及人为(总是很难避免时不时会出错)方面。容错技术可以很好地隐藏某种类型故障,避免影响最终用户。

可扩展性是指负载增加时,有效保持系统性能的相关技术策略。为了讨论可扩展性,我们首先讨论了如何定量描述负载和性能。简单地以Twitter浏览时间线为例描述负载,并将响应时间百分位数作为衡量性能的有效方式。对于可扩展的系统,增加处理能力的同时,还可以在高负载情况下持续保持系统的高可靠性。

可维护性则意味着许多方面,但究其本质是为了让工程和运营团队更为轻松。良好的抽象可以帮助降低复杂性,并使系统更易于修改和适配新场景。良好的可操作性意味着对系统健康状态有良好的可观测性和有效的管理方式。

然而知易行难,使应用程序可靠、可扩展或可维护并不容易。考虑到一些重要的模式和技术在很多不同的应用中普遍适用,在接下来几章中,就一些数据密集型系统例子,分析它们如何实现上述这些目标。

第 2 章 数据模型与查询语言

数据模型是一个庞大的主题,本章快速介绍了各种不同的数据模型,所有这三种模型(文档模型、关系模型和图模型),如今都已经广泛使用。我们观察到,一个模型可以用另一个模型来模拟。这就是为什么不同的模型用于不同的目的,而不是一个万能的解决方案。

文档数据库和图数据库有一个共同点,那就是它们通常不会对存储的数据强加某个模式,这可以使应用程序更容易适应不断变化的需求。但是应用程序可能仍然假定数据具有一定的结构,只不过是模式是显式(写时强制)还是隐式(读时处理)的问题。

每个数据模型都有自己的查询语言或框架,我们讨论来几个例子:SQL、MapReduce、MongoDB的聚合管道、Cypher、SPARQL和Datalog。还讨论来CSS和XPath,它们并不属于数据库查询语言,但存在相似之处。

虽然已经覆盖了很广的范围,但仍有一些数据模型尚未提及。

下一章将讨论在实现所描述的数据模型过程中有哪些重要的权衡设计。

第 3 章 数据存储与检索

本章简单介绍了数据库内部如何处理存储与检索。诸如,数据库存储数据新数据时会发生什么,以及之后查询数据时,数据库会做什么?

概括来讲,存储引擎分为两大类:针对事务处理(OLTP)的优化架构,以及针对分析性(OLAP)的优化架构。它们典型的访问模式存在很大差异:

  • OLTP系统通常面向用户,这意味着它们可能收到大量的请求。为了处理负载,应用程序通常在每个查询中只涉及少量的记录。应用程序基于某种键来请求记录,而存储引擎使用索引来查找所请求键的数据。磁盘寻道时间往往是瓶颈
  • 由于不是直接面对最终用户,数据仓库和类似的分析型系统相比并不太广为人知,它们主要由业务分析师使用。处理的查询请求数目远低于OLTP系统,但是每个查询通常要求非常苛刻,需要在短时间内扫描数百万条记录。磁盘带宽(不是寻道时间)常常是瓶颈,而面向列的存储对于这种工作负载成为日益流行的解决方案。

在OLTP方面,有两个主要流派的存储引擎:

  • 日志结构流派,它只允许追加式更新文件和删除过时的文件,但不会修改已写入的文件。
  • 原地更新流派,将磁盘视为可以覆盖的一组固定大小的页。B-tree是一哲学的典型代表,它已用于所有主要的关系型数据库,以及大量的非关系型数据库。

日志结构的存储引擎是一个相对较新的方案。其关键思想是系统地将磁盘上随机访问写入转为顺序写入,由于硬盘驱动器和SSD的性能特性,可以实现更高的写入吞吐量。

此外,简要地介绍来一些更复杂的索引结构,以及为全内存而优化的数据库。

然后,从存储引擎的内部间接地探索来典型数据仓库的总体架构。由此说明为什么分析工作负载与OLTP如此不同:当查询需要在大量行中顺序扫描时,索引的关联性就会显著降低。相反,最重要的时非常紧凑地编码数据,以尽量减少磁盘读取的数据量。我们讨论来列存储如何帮助实现这一目标。

作为应用开发人员,掌握更多有关存储引擎内部的知识,可以更好地了解哪种工具最适合你的具体应用。如果还需要进一步调整数据库的可调参数,这些理解还可以帮助开发者正确评估调整参数所带来的影响。

尽管本章不能让你成为某个特定存储引擎的调优专家,但希望帮你获得足够的知识与见解,以充分理解所选择的数据库。

第 4 章 数据编码与演化

本章我们研究了将内存数据结构转换为网络或磁盘上的字节流的多种方法。我们看到这些编码的细节不仅影响其效率,更重要的是还影响应用程序的体系结构和部署时的支持选项。

特别地,许多服务需要支持滚动升级,即每次将新版本的服务逐步部署到几个节点,而不是同时部署到所有节点。滚动升级允许在不停机的情况下发布新版本的服务(因此鼓励频繁地发布小版本而不是大版本),并降低部署风险(允许错误版本在影响大量用户之前检测并回滚)。这些特性非常有利于应用程序的演化和更改。

在滚动升级期间,或者由于各种其他原因,必须假设不同的节点正在运行应用代码的不同版本。因此,在系统内流动的所有数据都以提供向后兼容性(新代码可以读取旧数据)和向前兼容性(旧代码可以读取新数据)的方式进行编码显得非常重要。

本章还讨论了多种数据编码格式及其兼容性情况:

  • 编程语言特定的编码仅限于某一种编程语言,往往无法提供向前和向后兼容性。
  • JSON、XML和CSV等文本格式非常普遍,其兼容性取决于你如何使用它们。
  • 像Thrift、Protocol Buffer是和Avro这样的二进制的模式驱动格式,支持使用清晰定义的向前和向后兼容性语义进行紧凑、高效的编码。这些模式对于静态类型语言中文档和代码生成非常有用。然而有一个缺点,即只有在数据解码后才是人类可读的,

还讨论了数据流的几种模型,说明了数据编码在不同的场景下非常重要:

  • 数据库、其中写入数据库的进程对数据进行编码,而读取数据库的进程对数据进行解码。
  • RPC和REST API,其中客户端对请求进行编码,服务器对请求进行解码并对响应进行编码,客户端最终对响应进行解码。
  • 异步消息传递(使用消息代理或Actor),节点之间通过互相发消息进行通信,消息有发送者编码并由接受者解码。

最后可以得出这样的结论,只要稍加小心,向后/向前兼容性和滚动升级是完全可以实现的。预祝所有人的应用程序可以快速迭代,顺利部署。

第二部分 分布式数据系统

第 5 章 数据复制

复制或多副本技术主要服务于以下目的:

  • 高可用性:即使某台机器(或多台机器,或整个数据中心)出现故障,系统也能保持正常运行。
  • 连接断开与容错:允许应用程序在出现网络中断时继续工作。
  • 低延迟:将数据放置在距离用户较近的地方,从而实现更快地交互。
  • 可扩展性:采用多副本读取,大幅提高系统读操作的吞吐量。

在多台机器上保存多份相同的数据副本是复制技术上一个非常烧脑的问题,需要仔细考虑并发以及所有可能出错的关节,并小心处理故障之后的各种情形。最基本的,要处理好节点不可用与网络中断的问题,文中还没专门考虑一些更隐蔽的失效场景,例如由于软硬件bug导致的无提示的数据损坏。

本章主要讨论了三种多副本方案:

  • 主从复制:所有的客户端写入操作都发送到某一个节点(主节点),由该节点负责将数据更改事件发送到其他副本(从节点)。每个副本都可以接收读取请求,但内容可能是过期值。
  • 多主节点复制:系统存在多个主节点,每个都可以接收写请求,客户端将写请求发送到其中的一个主节点上,由该主节点负责将数据更改事件同步到其他主节点和自己的从节点。
  • 无主节点复制:客户端将写请求发送到多个节点上,读取时从多个节点上并行读取,以此检测和纠正某些过期数据。

每种方法都有其优缺点。主从复制非常流行,主要是因为它很容易理解,也不需要担心冲突问题。而万一节点失效、网络中断或者延迟抖动等情况,多主节点和无主节点复制方案会更加可靠,不过背后的代价则是系统的复杂性和弱一致性的保证。

复制时可以同步的,也可以是异步的,而一旦发生故障,二者的变现差异会对系统行为产生深远的影响。在系统稳定状态下异步复制性能优秀,但仍须认真考虑一旦出现复制滞后和节点失效两种场景会导致何种影响。万一某个主节点发生故障,而一个异步更新的从节点被提升为新的主节点,要意识到最新确认的数据可能由丢失的风险。

本章还分析了由于复制滞后所引起的一些奇怪效应,并讨论了以下一致性模型,来帮助应用程序处理复制滞后:

  • 写后读一致性:保证用户总能看到自己所提交的最新数据。
  • 单调读:用户在某个事件点读到数据之后,保证此后不会出现比该时间点更早的数据。
  • 前缀一致读:保证数据之间的因果关系,例如:总是以正确的顺序先读取问题,然后看到回答。

最后本章讨论了多主节点和无主节点复制方案所引入的并发问题。即由于多个写可能同时发生,继而可能发生冲突。为此,研究了一个算法使得数据库系统可以判定某操作是否发生在另一个操作之前,或者是同时发生。接下来,探讨采用合并并发更新值的方法来解决冲突。

下一章将继续研究多节点上数据的分布问题,与本章不同的是,它是针对一个大型数据集而采用分区技术。

第 6 章 数据分区

本章探讨了将大规模数据集划分为更小子集的多种方法。数据量如果太大,单台机器进行存储和处理就会成为瓶颈,因此需要引入数据分区机制。分区的目的是通过多台机器均匀分布数据和查询负载,避免出现热点。这需要选择合适的数据分区方案,在节点添加或删除时重新动态平衡分区。

本章讨论了两种主要的分区方式:

  • 基于关键字区间的分区。先对关键字进行排序,每个分区只负责一段包含最小到最大关键字范围的一段关键字。对关键字排序的有点事可以支持高效的区间查询,但是如果应用程序经常访问与排序一致的某段关键字,就会存在热点的风险,采用这种方法,当分区太大时,通常将其分裂为两个子区间,从而动态地再平衡分区。
  • 哈希分区。将哈希函数作用于每个关键字,每个分区负责一定范围的哈希值。这种方法打破了原关键字的顺序关系,它的区间查询效率比较低,但可以更均匀地分配负载。采用哈希分区时,通常事先创建好足够多(但数量固定)的分区,让每个节点承担多个分区,当添加或删除节点时将某些分区从一个节点迁移到另一个节点,也可以只支持动态分区。

混合上述两种基本方法也是可行的,例如使用复合键:键的一部分来标识分区,而另一部分来记录排序后的顺序。

本章还讨论了分区和二级索引,二级索引也需要进去分区,有两种方法:

  • 基于文档来分区二级索引(本地索引)。二级索引存储在与关键相同的分区中,这意味着写入时只需要更新一个分区,但缺点时读取二级索引时需要在所有分区上执行scatter/gather。
  • 基于词条来分区二级索引(全局索引)。它是基于索引的值而进行的独立分区。二级索引中的条目可能包含来自关键字的多个分区里的记录。在写入时,不得不更新二级索引的多个分区;但读取时,则可以从单个分区直接快速读取数据。

最后讨论了如何将查询请求路由到正确的分区,包括简单的分区感知负载均衡器,以及复杂查询的并行查询执行引擎。

理论上,每个分区基本保持独立运行,这也是为什么我们试图将分区数据库分布、扩展到多台机器上。但是,如果写入需要跨多个分区,情况就会格外复杂,例如,如果其中一个分区写入成功,但另一个发生失败,接下来会发生什么?将在下面的章节中讨论类似这样的技术挑战。

第 7 章 事务

事务作为一个抽象层,使得应用程序可以忽略数据库内部一些复杂的并发问题,以及某些硬件、软件故障,从而简化应用层的处理逻辑,大量错误可以转化为简单的事务中止和应用层重试。

本章列举了很多事务能够预防的问题。尽管并非所有的应用程序都会面临这样问题,例如那些简单的访问模式,只读或者只写,可能根本无需事务的帮助。但对于复杂的访问模式,事务可以大大简化需要考虑潜在错误情况。

如果没有事务,各种错误情况(如进程崩溃、网络中断、停电、磁盘已满、并发竞争等)会导致数据可能出现各种不一致。例如,反序列化数据模式和相关操作会导致与源数据不同步。假如没有事务,处理那些复杂交互访问最后所导致的数据库混乱就会异常痛苦。

本章深入讨论了并发控制这一主题。介绍了多个广泛使用的隔离级别,特别是读-提交、快照隔离(或可重复读取)与可串行化。通过分析如何处理这些边界条件来阐述这些隔离级别的要点:

  • 脏读:客户端读到了其他客户端尚未提交的写入。读-提交以及更强的隔离级别可以防止脏读。
  • 脏写:客户端覆盖了另一个客户端尚未提交的写入。几乎所有的数据库实现都可以防止脏写。
  • 读倾斜(不可重复读):客户在不同的时间点看到了不同值。快照隔离时最有用的防范手段,即事务总是在某个时间点的一致性快照中读取数据。通常采用多版本并发控制(MVCC)来实现快照隔离。
  • 更新丢失:两个客户端同时执行读-修改-写入操作序列,出现了其中一个覆盖了另一个的写入,但又没有包含对方最新值的情况,最终导致了部分修改数据发生了丢失。快照隔离的一些实现可以自动防止这种异常,而另一些则需要手动锁定查询结果(SELECT FOR UPDATE)。
  • 写倾斜:事务首先查询数据,根据返回的结果而做出某些决定,然后修改数据库。当事务提交时,支持决定的前提条件已不再成立。只有可串行化的隔离才能防止这种异常。
  • 幻读:事务读取了某些符合查询条件的对象,同时另一个客户端执行写入,改变了先前的查询结果。快照隔离可以防止简单的幻读,但写倾斜情况则需要特殊处理,例如采用区间锁。

弱隔离级别可以防止上面的某些异常,但还需要应用开发人员手动处理其他复杂情况(例如,显式加锁)。只有可串行化的隔离可以防止所有这些问题。本章主要讨论了实现可串行化隔离的三种不同方法:

  • 严格串行执行事务:如果每个事务的执行速度非常快,且单个CPU核可以满足事务的吞吐量要求,严格串行执行是一个非常简单有效的方案。
  • 两阶段加锁:几十年来,这一直是实现可串行化的标准方式,但还是又很多系统出于性能原因而放弃使用它。
  • 可串行化的快照隔离(SSI):一种最新的算法,可以避免前面方法的大部分缺点。它秉持乐观预期的原则,允许许多个事务并发执行而不互相阻塞;仅当事务尝试提交时,才检查可能的冲突,如果发现违背了串行化,则某些事务会被中止。

本章中的示例都采用关系数据模型。但是,正如本章前面的"多对象事务的需求"所描述的,无论对哪种数据模型,事务都是非常有用的数据库功能。

本章所介绍的算法、方案主要针对单节点。对于分布式数据库,还会面临更多、更复杂的挑战,将在接下来的两章中继续展开讨论。

第 8 章 分布式系统的挑战

本章讨论了分布式系统中可能发生的各种典型问题,包括:

  • 当通过网络发送数据包时,数据包可能会丢失或者延迟;同样,回复也可能丢失或延迟,所以如果没有收到回复,并不能确定消息是否发送成功。
  • 节点的时钟可能会与其他节点存在明显的不同步(尽管尽最大努力设置了NTP服务器),时钟还可能会突然向前跳跃或倒退,依靠精确的时钟存在一些风险,没有特别简单的办法来精确测量时钟的范围偏差。
  • 进程可能在执行过程中的任意时候遭遇长度未知的暂停(一个重要的原因是垃圾回收),结果它被其他节点宣告为失效,尽管后来又恢复执行,却对中间的暂停毫无所知。

部分失效可能是分布式系统的关键特征。只要软件试图跨越节点做任何事情,就有可能出现失败,或者随机变慢,或者根本无应答(最终超时)。对于分布式环境,我们的目标是建立容忍部分失效的软件系统,这样即使某些部件发生失效,系统整体还可以继续运行。

为了容忍错误,第一步是检测错误,但即使这样也有很多挑战。多数系统没有检测节点是否发生故障的准确机制,因此分布式算法更多依靠超时来确定远程节点是否仍然可用。但是,超时无法区分网络和节点故障,且可变的网络延迟有时会导致节点被误认为发生崩溃。此外,节点可能处于一种降级状态:例如,由于驱动程序错误,千兆网络接口可能突然降到1kb/s的吞吐量。这样一个处于"残废"的节点比彻底挂掉的故障节点更难处理。

检测到错误之后,让系统容忍失效也不容易。在典型的分布式环境下,没有全局变量,没有共享内存,没有约定的尝试或者跨节点的共享状态。节点甚至不太清楚现在的准确时间,更不用说其他更高级的了。信息从一个节点流动到另一个节点只能是通过不可靠的网络来发送。单个节点无法安全的做出任何决策,而是需要多个节点之间的共识协议,并正确达到法定票数。

如果习惯于编写单节点理想化环境运行的软件(即同一个操作总是确定性返回相同的结果),当转向分布式系统时,种种看似凌乱的现实可能着实令人震惊。相反,如果在单节点上即可解决问题,那么对于一个分布式系统工程师通常会被认为该问题微不足道(现在单节点确实可以完成许多任务),如果可以避免打开潘多拉之盒,那么把所有的工作都放在一台机器也值得一试。

但正如在第二部分开头所讨论的那样,可扩展性并不是使用分布式系统的唯一原因。容错与低延迟(将数据放在距离用户较近的地方)也同样是重要的目标,而后两种无法靠单节点来实现。

本章也探讨了网络、时钟和进程的不可靠性是否是不可避免的自然规律。本章对此给出的结论时否定的:的确有可能在网络中提供硬件实时的延迟保证或具有上确界的延迟,但代价昂贵,且硬件资源利用率很低。除了安全关键场景,目前绝大多数都选择了低成本(和不可靠)。

本章还谈到了高性能计算,它们采用了更加可靠的组件,发生故障时完全停止系统,之后重新启动。相比之下,分布式系统会长时间不间断运行,以避免影响服务级别;故障处理和系统维护多以节点为单位进行处理,或者理论上如此(实际上,如果错误的配置不小心被应用到集群的所有节点,仍然会导致整个集群系统瘫痪)。

这样看起来本章揭露的全是问题,前景黯淡。那么在下一章,我们将讨论解决方案,重点是针对这些问题而设计相关的分布式算法。

第 9 章 一致性与共识

本章从多个不同的角度审视了一致性与共识问题。深入研究了线性化(一种流行的一致性模型):其目标是使多副本对外看起来好像是单一副本,然后所有操作以原子方式运行,就像一个单线程程序操作变量一样。线性化的概念简单,容易理解,看起来很有吸引力,但它的主要问题在于性能,特别是在网络延迟较大的环境中。

本章接下来探讨了因果关系,因果关系对事件进行了某种排序(根据事件发生的原因-结果依赖关系)。线性化是将所有操作都放在唯一的、全局有序时间线上,而因果性则不同,它为我们提供了一个弱一致性模型:允许存在某些并发事件,所以版本历史是一个包含多个分支与合并的时间线。因果一致性避免了线性化昂贵的开销,且对网络延迟的敏感性要低很多。

然而,即使意识到因果顺序(例如采用了Lamport时间戳),我们发现有时无法在实践中采用这种方法,在"时间戳排序还不够"一节有这样一个例子:确保用户名唯一并拒绝对同一用户名的并发注册请求。如果某个节点要同意请求,则必须以某种方式查询其他节点是否存在竞争请求。这个例子最终引导我们取探究系统的共识问题。

**共识意味着就某一项提议,所有节点做出一致的决定,而且决定不可撤销。**通过逐一分析,事实证明,多个广泛的问题最终都可以归结为共识,并且彼此等价(这就意味着,如果找到其中一个解决方案,就可以比较容易地将其转换为其他问题的解决方案)。这些等价问题包括:

  • 可线性化的比较-设置寄存器:寄存器需要根据当前值是否等于输入的参数,来自动决定接下来是否应该设置新值。
  • 原子事务提交:数据库需要决定是否提交或中止分布式事务。
  • 全序广播:消息系统要决定以何种顺序发送消息。
  • 锁与租约:当多个客户端争抢锁或租约时,要决定其中哪一个成功。
  • 成员/协调服务:对曰失败检测器(例如超时机制),系统要决定节点的存活状态(例如基于会话超时)。
  • 唯一性约束:当多个事务在相同的主键上试图并发创建冲突资源时,约束条件要决定哪一个被允许,哪些违反约束因而必须失败。

如果一个系统只存在一个节点,或者愿意把所有决策功能都委托给某一个节点,那么事情就变得简单。这和主从复制数据库的情形是一样的,即由主节点负责所有的决策事宜,正因如此,这样的数据库可以提供线性化操作、唯一性约束、完全有序的复制日志等,

然而,如果唯一的主节点发生故障,或者出现网络中断而导致主节点不可达,这样的系统就会陷入停顿状态。有以下三种基本思路来处理这种情况:

  1. 系统停止服务,并等待主节点恢复。许多XA/JTA事务协调者采用了该方法。本质上,这种方法并没有完全解决共识问题,因为它并不满足终止性条件,试想如果主节点没法恢复,则系统就会永远处于停顿状态。
  2. 认为介入来选择新的主节点,并重新配置系统使之生效。许多关系型数据库都采用这种方法。本质上它引入了一种"上帝旨意"的共识,即在计算机系统之外由人类来决定最终命运。故障切换的速度完全取决于人类的操作,通常比计算机慢。
  3. 采用算法来自动选择新的主节点。这需要一个共识算法,我们建议采用那些经过验证的共识系统来确保正确处理各种网络异常。

虽然主从数据库提供了线性化操作,且在写操作粒度级别上并不依赖于共识算法,但它仍然需要共识来维护主节点角色和处理主节点变更情况。因此,某种意义上说,唯一的主节点只是其中的一步,系统在其他环节依然需要共识(虽然不那么的频繁)。好在容错算法与共识的系统可以共存,在本章做了简要地介绍。

ZooKeeper等工具以一种类似外置方式为应用提供了重要的共识服务、故障检测或成员服务等。虽然用起来依然有挑战,但远比自己开发共识算法要好很多(正确处理好第8章的所有问题绝非易事)。如果面临的问题最终可以归结为共识,并且还有容错需求,那么这里给的建议是采用ZooKeeper等验证过的系统。

尽管如此,并不是每个系统都需要共识。例如无主复制和多主复制系统通常并不支持全局共识。正因如此,这些系统可能会发生冲突(参阅第5章"处理写冲突"),但或许也可以接受或者寻找其他方案,例如没有线性化保证时,就需要努力处理好数据多个冲突分支以及版本合并问题等。

本章引用了大量分布式系统理论方面的研究。虽然理论性文章和证明有时理解起来有些困难,有时甚至包含了一些不太合理的假设条件,但它们对于指导实际工作还是极有价值:例如帮助推理系统的边界在哪里;帮助发现一些违反直觉的分布式系统潜在的缺陷,如果有时间,建议仔细阅读这些参考资料。

至此完成了本书的第二部分,其中包括复制(第5章),分区(第6章),事务(第7章),分布式系统失效模型(第8章)以及最后的一致性共识(第9章)。相信我们已经奠定了坚实的理论基础,接下来第三部分将面向实际环境,讨论如何基于异构模块来构建强的的应用系统。

第三部分 派生数据

第 10 章 批处理系统

本章探讨了批处理这一主题。我们首先查看了诸如awk、grep和sort等UNIX工具,然后讨论了这些工具的设计理念时如何运用到MapReduce和最新的数据流引擎中。这些设计原则包括:输入是不可变的,输出是为了成为另一个(还未知的)程序的输入,而复杂工具可以通过编写"做好一件小事"的小工具来解决

在UNIX世界中,允许多个程序组合在一起的统一接口是文件和管道;在MapReduce中,该接口是分布式文件系统。我们看到数据流引擎添加了自己的管道式数据传输机制,以避免在分布式文件系统中将中间状态实体化,而作业的初始输入和最终输出通常仍然是HDFS。

分布式批处理框架需要解决的两个主要问题是:

  • 分区:在MapReduce中,mapper根据输入文件块进行分区。mapper的输出被重新分区、排序,合并成一个可配置数量的reducer分区。这个过程的目的是把所有的相关数据————例如具有相同关键字的所有记录都放在同一个地方。除非必需,后MapReduce的数据流引擎都尽量避免排序,但它们采取了大致类似的分区方法。
  • 容错:MapReduce需要频繁写入磁盘,这使得可以从单个失败任务中轻松恢复,而无需重新启动整个作业,但在无故障情况下则会减慢执行速度。数据流引擎执行较少的中间状态实体化并保留更多的内存,这意味着如果节点出现故障,它们需要重新计算更多的数据。确定性运算符减少了需要重新计算的数据量。

本章讨论了几种MapReduce的join算法,其中大部分也在MPP数据库和数据流引擎中使用。分区算法的示例包括:

  • 排序-合并join:每个将要join的输入都会由一个join关键字的mapper来处理。通过分区、排序与合并,具有相同关键字的所有记录最终都会进入对reducer的同一个调用。然后这个函数输出join记录。
  • 广播哈希join:两个join输入中的某一个数据集很小,所以不需要分区,而完全加载到哈希表中。因此,可以为大数据集的每个分区启动一个mapper,将小数据集的哈希表加载到每个mapper中,然后以此扫描大数据集的一条记录,对每条记录进行哈希表查询。
  • 分区哈希join:如果两个join的输入以相同的方式分区(使用相同的关键字,相同的哈希函数和相同数量的分区),则哈希表方法可以独立用于每个分区。

分布式批处理引擎有一个有意限制的编程模型:回调函数(如mapper和reducer)被设定为无状态,并且除了制定输出之外没有外部可见的任何副作用。这个限制使得框架隐藏了抽象背后的一些困难的分布式系统问题,从而在面对崩溃和网络问题时,可以安全地重试任务,并且丢弃任何失败任务的输出。如果针对某个分区的多个任务都成功了,则实际上只会有其中一个任务使其输出可见。

得益于这样的框架,在批处理作业中的代码无需考虑容错机制的实现:框架可以保证作业的最终输出与没有发生错误的情况相同(即使实际上各种任务可能不得不重试)。而在线服务在处理用户请求时,将写入数据库作为处理请求的副作用,与之相比,批处理的可靠性语义要强大的多。

批处理作业的显著特点时它读取一些输入数据并产生一些输出数据,而不修改输入。换句话说,输出时从输入派生而来。至关重要的是,输入数据是有界的:数据大小固定已知(例如它包含一些时间点的日志文件或数据库内容的快照)。因为它是有界的,所以一个作业总是可以知道何时完成了对整个输入的读取,何时作业最终完成。

下一章将转向流处理,其中输入是无界的。也就是说,作业的输入流是永无止境的数据流。在这种情况下,作业永远不会完成,因为在任何时候都有可能有更多的输入进来。我们将看到流处理和批处理在某些方面是相似的,但流数据无界的假设也会深刻改变我们设计系统的方法。

第 11 章 流处理系统

本章讨论了事件流的用途和处理方法。在某些方面,流处理与第10章中讨论的批处理非常相似,但是它是对无限的流而不是固定大小的输入进行持续的处理。从这个角度讲,消息代理和事件日志可以视为流处理系统中文件系统。

本章花了一些时间比较两种类型的消息代理:

  • AMQP/JMS风格的消息代理:代理将单个消息分配给消费者,消费者在成功处理后确认每条消息。消息被确认后从代理中删除。这种方法适合作为一种异步RPC(参阅第4章"基于消息传递的数据流"),例如在任务队列中,消息处理的确切顺序并不重要,并且不需要在它们处理完后返回并再次读取旧消息。
  • 基于日志的消息代理:代理将分区内的所有消息分配给相同的消费节点,并始终以相同的顺序发送消息。通过分区机制来实现并行,消费者通过检查出他们处理的最后一条消息的偏移量来跟踪进度。代理将消息保存在磁盘上,因此如果有必要,可以回退并重新读取旧消息。

基于日志的方法与在数据库(参阅第5章)和日志结构化存储引擎(参阅第3章)中的复制日志相似。我们可以看到,这种方法特别适合流处理系统,即不断汲取输入流并生成派生状态或派生输出流。

就流的来源而言,我们讨论了几种可能性,包括用户活动事件,提供定期读数的传感器,以及可订阅的数据源(如金融市场数据)等可以自然地表示为流。将对数据库的写入视为流也是有用的,可以捕获变更日志(即对数据库所做的所有更改的历史记录),包括隐式地通过变更数据捕获或显式地通过事件溯源两种方式。通过日志压缩可以保留数据库内容的完整副本。

将数据库表示为流,对高效集成系统提供了更多的机会。通过使用变更日志并将其应用到派生系统,可以不断更新搜索索引、缓存和分析系统等派生数据系统。甚至可以全新做起,将所有数据从始至终都视为变更日志,从而构建全新的试图。

将保持状态为流并支持重做消息也是在各种流处理框架中实现基于流的join和容错的基础。本章讨论了流处理的几个目标场景,包括基于事件模式的查询(复杂事件处理),计算窗口聚合(流分析)以及保持派生数据系统处于最新状态(物化视图)等。

接下来讨论了在流处理中有关时间方面的一些考虑,包括处理时间和事件时间之间的区别,以及滞后事件(在认为窗口已关闭之后才到达的事件)问题。

本章区分了流处理过程中可能出现的三种类型的join:

  • 流和流join:两个输入流都由活动事件组成,采用join操作用来搜索在特定时间窗口内发生的相关事件。例如可以匹配相同用户在30min内采取的两个动作。如果想要在一个流中查找相关事件,则两个join输入可以是相同的流。
  • 流和表join:一个输入流由活动事件组成,而另一个则是数据库变更日志。更新日志维护了数据库的本地最新副本。对于每个活动事件,join操作用来查询数据库并输出一个包含更多信息的事件。
  • 表和表join:两个输入流都是数据库更新日志。在这种情况下,一方的每一个变化都与另一方的最新状态相join,结果是对两个表之间join的物化视图进行持续更新。

最后讨论了在流处理器中实现容错和恰好一次语义的技术。与批处理一样,我们需要丢弃所有失败任务的部分输出,然而,由于流处理长时间运行并持续产生输出,所以不能把所有的输出都简单地丢弃掉。相反,基于微批处理、检查点、事务或幂等性写入等,可以实现更细粒度的恢复机制。

第 12 章 数据系统的未来

本章讨论了设计数据系统的新方法,以及包括了作者个人对未来的看法和猜测。从观察开始,即没有一种工具可以有效地服务于所有的场景,因此应用程序必须编排多个不同的软件来完成他们的目标。我们讨论了如何使用批处理和事件流来解决这个数据集成问题,以使数据在不同系统之间灵活流动。

基于这些方法,某些系统被指定为记录系统,而其他数据则是通过转换而来。通过这些方式,我们可以维护索引、实体化视图、机器学习模型】统计摘要等。通过使这些推到和变换异步松耦合,防止局部问题可能扩散到系统的不相关部分,从而提高了整个系统的鲁棒性和容错性。

将数据流表示为从一个数据集到另一个数据集的转换也有助于演化应用程序:如果要更改其中一个处理步骤,例如更改索引或缓存的结构,则可以在整个输入数据集上重新运行新的转换代码以便重新输出。同样,如果出现问题,可以修复代码然后重新处理数据来恢复执行。

这些过程与内部数据库完成的过程非常相似,因此我们数据流应用系统的概念重新分解为数据库组件,并通过组合这些松耦合的组件来构建应用程序。

派生状态可以通过观察基础数据的变化来不断更新。而且,下游消费者可以进一步观察派生的状态。我们甚至可以将此数据流一直传送到显示数据的最终用户设备,从而构建动态更新的用户界面以反应数据更改并支持离线使用。

接下来,本章讨论了在出现故障时如何确保处理正确。强的完整性保证可以通过异步事件处理,通过使用端到端操作标识使操作最终满足幂等性,并通过异步检查约束来实现。客户可以等到检查通过或不用等,但是如果万一违反约束则需要事后道歉。这种方法比使用分布式事务的传统方法更具有可扩展性和可靠性,并且适合于事件中有多个业务流程同时工作的场景。

围绕数据流来构建应用程序并异步检查约束,我们可以避免大多数协调工作,系统保证性并性能良好,即使在地理位置分散的情况下或是出现故障时也是如此。然后,我们简单介绍了使用审计来验证数据的完整性并检查数据是否发生破坏。

最后,我们又退后一步,审视了构建数据密集型应用系统在道德层面的一些问题。我们看到,虽然这些数据可以用来帮助人们,但是也可能造成重大的伤害:做出严重影响人们生活的貌似公平的决定,这种算法决定难以对其提起诉讼;导致歧视和剥削;使监视泛滥;暴露隐私信息等。我们也面临着数据泄露的风险,而且即使善意的数据使用也可能会产生某些意想不到的后果。

由于软件和数据对世界的影响如此之大,我们的工程师必须谨记,我们有责任为我们赖以生存的世界而努力:一个以人性和尊重来对待人的世界。我希望我们能够一起为实现这一目标而努力。