数据密集型应用系统设计

699 阅读25分钟

本文主要写一些让自己关注的东西,并不是对整本书的总结。

github.com/Vonng/ddia?…

数据模型

对象-关系不匹配

现在大多数应用开发都采用面向对象的编程语言,由于兼容性问题,普遍对 SQL 数据模型存在抱怨,如果数据存储在关系表中,那么应用层代码中的对象与表、行和列的数据库模型之间需要一个笨拙的转换层。虽然 ORM 框架减少了此转换层的样板代码量,但他们并不能完全隐藏两个模型之间的差异。

之前提到的两种数据库的数据模型分别是文档模型与关系模型。以下是对于 LinkedIn 简历用两种模型的表示方式。

简历文档模型关系模型
image.pngimage.pngimage.png

虽然关系数据库中经常使用多对多的关系和联结,但文档数据库和 NoSQL 再次引发了关于如何最佳表示数据关系的争论。而类似争论不是第一次,事实上,它可以追溯到最早的计算机数据库系统。

层次模型

最早的数据模型称为层次模型,它与文档数据库使用的 JSON 模型有一些显著的相似之处,它将所有数据表示为嵌套在记录中记录树。层次模型可以很好地支持一对多关系,但支持多对多关系有些困难,而且不支持联结(join)。开发人员必须决定是复制(反规范化)多份数据,还是手动解析记录之间的引用。

文档数据库是某种方式的层次模型:即在其父记录中保存了嵌套记录,而不是存在单独的表中。

为了解决层次模型的局限性,之后又提出了多种解决方案。其中最著名的是关系模型和网络模型(当初有很很多支持者,可惜被人们最终淡忘)。

网络模型

网络模型也被称为 CODASYL 模型。CODASYL 模型是对层次模型的推广,在层次模型的树结构中,每个记录只有一个父节点,而在网络模型中,一个记录可能有多个父节点,例如“大西雅图地区”,居住在该地区的每个用户都链接指向它,从而支持多对一和多对多的关系。

在网络模型中,记录之间的链接不是外键,而更像是编程语言中的指针,访问记录的唯一方法是选择一条始于根记录的路径,并沿着相关链接依次访问。CODASYL 中的查询通过遍历记录列表并沿着访问路径在数据库中移动游标来执行。如果记录有多个父节点,则应用程序必须跟踪所有关系。甚至 CODASYL 委员会的成员也承认,这像在一个 nn 维数据空间中进行遍历。

image.png
网络模型示意图

关系模型

相比之下,关系模型所做的则是定义了所有数据的格式:关系(表)只是元组(行)的集合,仅此而已。没有复杂的嵌套结构,也没有复杂的访问路径。可以读取表中的任意一行或者所有行,支持任意条件查询。

在关系数据库中,查询优化器自动决定以何种顺序执行查询,以及使用哪些索引。这些选择实际上等价于“访问路径”,但最大的区别在于它们是由查询优化器自动生成的,而不是由开发人员维护,因此不用过多地考虑它们。

结论:在表示多对一和多对多的关系时,两种数据库并没有根本的区别,相关项都由唯一的标识符引用,该标识符在关系模型中被称为外键,在文档模型中被称为文档引用。

文档模型和关系模型的区别

在考虑使用哪种模型时,主要考虑以下几点,细节请参考原书。

  • 哪种数据模型的应用代码更简单?
  • 文档模型中的模式灵活性
  • 查询的数据局部性

数据复制

复制主要是指通过互联网络在多台机器上保存相同的数据副本。主要的复制模式有三种:

主节点与从节点多主节点复制无节点复制
  • 主节点(只有一个)接受写请求
  • 主节点将数据更改转发给其它节点
  • 所有节点都可以接受读请求
    • 每个主节点都可以接受写操作
    • 主节点将数据更改转发给其它节点
    • 需要处理写冲突的问题
    • 每个节点都可以接收写请求
    • 如果有n个副本,写入需要w个节点确认,
      读取必须至少查询r个节点:w + r > n

    ::主从复制::

    处理节点失败

    提到主从复制,很容易想到的是主从复制的过程和同步/异步方式,配置从节点的过程也比较简单,所以省略。如何处理节点失败是一个问题:

    从节点失效的解决方案是追赶式恢复,根据副本的复制日志,从节点可以知道发生故障之前所处理的最后一笔事务,然后连接到主节点,并请求自那个事务之后中断期间内所有的数据变更,将其应用到本地来追赶主节点。

    处理主节点故障的情况则比较棘手:选择某个从节点将其提升为主节点;客户端也需要更新,这样之后的写请求会发送给新的主节点,然后从其它从节点要接受来自新的节点上的变更数据,这一过程称之为切换。主从切换的充满了很多变数:

    • 如果使用了异步复制,且失效之前,新的主节点并未收到原主节点的所有数据;在选举之后,原主节点很快又重新上线并加入到集群,接下来的写操作会发生什么?新的主节点很可能会收到冲突的写请求,这是因为原主节点未意识的角色变化,还会尝试同步其它节点,但其中的一个现在已经接管成为现任主节点。常见的解决方案是,原主节点上未完成复制的写请求就此丢弃,但这可能会违背数据更新持久化的承诺。
    • 如果在数据库之外有其他系统依赖于数据库的内容并在一起协同使用,丢弃数据的方案就特别危险。例如,在GitHub 的一个事故中,某个数据并非完全同步的 MySQL 从节点被提升为主副本,数据库使用了自增计数器将主键分配给新创建的行,但是因为新的主节点计数器落后于原主节点(即二者并非完全同步),它重新使用了已被原主节点分配出去的某些主键,而恰好这些主键已被外部 Redis 所引用,结果出现 MySQL 和 Redis 之间的不一致,最后导致了某些私有数据被错误地泄露给了其他用户。
    • 在某些故障情况下,可能会发生两个节点同时都自认为是主节点。这种情况被称为脑裂,它非常危险:两个主节点都可能接受写请求,并且没有很好解决冲突的办法,最后数据可能会丢失或者破坏。作为一种安全应急方案,有些系统会采取措施来强制关闭其中一个节点。然而,如果设计或者实现考虑不周,可能会出现两个节点都被关闭的情况。
    • 如何设置合适的超时来检测主节点失效呢?主节点失效后,超时时间设置得越长也意味着总体恢复时间就越长。但如果超时设置太短,可能会导致很多不必要的切换。例如,突发的负载峰值会导致节点的响应时间变长甚至超时,或者由于网络故障导致延迟增加。如果系统此时已经处于高负载压力或网络已经出现严重拥塞,不必要的切换操作只会使总体情况变得更糟。

    复制滞后问题

    • 写后读:避免用户看到刚刚写入的数据丢失。下面是一些解决方案
      • 如果用户访问可能被修改内容,则从主节点读取。
      • 如果应用内的大部分内容可能被所有用户修改,可以追踪最近更新的时间,如果更新后一分钟之内,总是在主节点读取;并监控从节点的复制滞后程度,避免从那些滞后时间超过一分钟的从节点读取。
      • 客户端可以记住最近更新的时间戳,并附带在请求中,系统可以确保对该用户提供读服务时至少包含了该时间戳的更新。
    • 单调读:保证用户依次进行多次读取,不能看到回滚现象
      • 每个用户总是从固定的同一副本读取内容
    • 前缀一致读:保证对于一系列按照某个顺序发生的写请求,读取这些内容时也按照当时写入的顺序
      • 解决方案比较复杂,省略。

    多主节点复制和无主节点复制跳过。多主节点复制的一个常见应用是协作编辑。

    数据分区

    分区通常是这样定义的,即每一条数据只属于某个特定分区。实际上每个分区都可以视为一个完整的小型数据库,虽然数据库可能存在一些跨分区的操作。采用数据分区的目的提高扩展性。不同的分区可以放在一个无共享集群的不同节点上。这样一个大数据集可以分散在更多的磁盘上,查询负载也随之分布到更多的处理器上。

    键值数据的分区

    分区的主要目标是将数据和查询负载均匀分布在所有节点上。如果节点平均分担负载,那么理论上10个节点应该能够处理10数据量和10倍于单个节点的读写吞吐量。而如果分区不均匀,则会出现某些分区节点比其他分区承担更多的数据量或查询负载,称之为倾斜。倾斜会导致分区效率严重下降,在极端情况下所有的负载可能会集中在一个分区节点上。

    分类方法特点
    基于关键字区间分区为每个分区分配一个连续的关键字范围存在写入热点
    基于关键字哈希值分区基于哈希值进行分区不便于区间查询
    负载倾斜与热点分裂成多个关键字需要数据合并

    基于关键字区间分区

    一种分区方式是为每个分区分配一段连续的关键字或者关键字区间范围,如果知道关键字区间的上下限,就可以轻松确定哪个分区包含这些关键字,如果还知道哪个分区分配在哪个节点,就可以直接向该节点发出请求。

    关键字的区间不一定非要均匀分布,这主要是因为数据本身可能就是不均匀的。分区边界可以由管理员手动确定,或者由数据库自动选择。然而,基于关键字的区间分区的缺点是某些访问模式会导致热点。如果关键字是时间戳,则分区对应于一个时间范围,所有的写入操作都集中在同一个分区(即当天的分区),这会导致该分区在写入时负载过高。为了避免上述问题需要使用时间戳以外的其他内容作为关键字的第一项。例如,可以在时间戳前面加上传感器名称作为前缀。

    image.png
    百科全书按照关键字区间进行分区

    基于关键字哈希值进行分区

    一个好的哈希函数可以处理数据倾斜并使其均匀分布。然而通过关键字哈希进行分区,我们丧失了良好的区间查询特性。即使关键字相邻,但经过哈希之后会分散在不同的分区中,区间查询就失去了原有的有序相邻的特性。

    负载倾斜与热点

    基于哈希的分区方法可以减轻热点,但是如果所有的读写操作都针对同一关键词,则最终所有请求都将被路由到同一个分区。这种场景或许并不普遍,一种场景是,社交媒体网站上,一些名人用户有数百万的粉丝,当其发布一些热点事件时可能会引发一场访问风暴。

    如果某个关键字被确认为热点,一个简单的技术就是在关键字的开头或结尾处添加一个随机数,只需一个两位数的十进制随机数就可以将关键词的写操作分布到一百个不同的关键词上,从而分配到不同的分区上。但随之而来的问题是,之后的任何读取都需要些额外的工作,必须从所有100个关键字中读取数据然后进行合并。(原文表达的场景之一可能是对某个热点微博进行点赞计数)

    分区再平衡

    随着时间的推移,数据库可能总会出现某些变化:查询压力增加、数据规模增加、节点可能出现故障。所有这些变化都要求数据或请求可以从一个节点转移到另一个节点,这样一个迁移负载的过程称为再平衡或者动态平衡。分区再平衡通常需要满足以下要求:

    • 平衡之后,负载、数据存储、读写请求等应该在集群范围内更均匀地分布。
    • 再平衡执行过程中,数据库应该可以正常提供读写服务。
    • 避免不必要的负载迁移,以加快动态再平衡,并尽量减少网络和磁盘 I/O 的影响。
    分类方法
    基于关键字区间分区创建大量逻辑分区,通过迁移分区来迁移数据
    动态分区(仅适用于关键字区间分区)对较大或较小的分区进行合并和拆分
    负载倾斜与热点通过分裂节点,让每个节点拥有固定数量的分区

    为什么不用取模

    对 hash(key) mod 10 会返回一个介于 0 到 9 之间的数字,如果有 10 个节点,则依次对应节点 0 到 9,这似乎是将每个关键字分配到节点的最简单方法。对节点数取模方法的问题是,如果节点数 N 发生了变化,会导致很多关键字需要从现有的节点迁移到另一个节点。这种频繁地迁移操作大大增加了再平衡的成本。

    固定数量的分区

    幸运的是,有一个相当简单的解决方法:首先,创建远超实际节点数的分区数,然后为每个节点分配多个分区。例如,对于一个 10 节点的集群,数据库可以从一开始就逻辑划分为 1000 个分区,这样大约每个节点承担 100 个分区。

    接下来,如果集群中添加了一个节点,该新节点可以从每个现有节点上匀走几个分区,直到分区再次达到全局平衡。该过程如下图所示。如果从集群中删除节点,则采取相反的均衡措施。

    选中的整个分区会在节点之间迁移,但分区的总数量仍维持不变,也不会改变关键字到分区的映射关系。这里唯一需要调整的是分区与节点的对应关系。考虑到节点间通过网络传输数据总是需要些时间,这样调整可以逐步完成,在此期间,旧的分区仍然可以接收读写请求。

    image.png
    新节点加入到集群,每个节点上承担更多的分区数据

    动态分区

    对于采用关键字区间分区的数据库,如果边界设置有问题,最终可能会出现所有数据都挤在一个分区而其它分区基本为空,那么设置固定边界、固定数量的分区将非常不便:而手动去重新配置分区边界又非常繁琐。

    因此,一些数据库如 HBase 和 RethinkDB 等采用了动态创建分区。当分区的数据增长超过一个可配的参数阈值(HBase上默认值是 10GB),它就拆分为两个分区,每个承担一半的数据量。相反,如果大量数据被删除,并且分区缩小到某个阈值以下,则将其与相邻分区进行合并。该过程类似于 B 树的分裂操作。 每个分区总是分配给一个节点,而每个节点可以承载多个分区,这点与固定数量的分区一样。当一个大的分区发生分裂之后,可以将其中的一半转移到其他某节点以平衡负载。对于 HBase,分区文件的传输需要借助HDFS(底层分布式文件系统)。

    按节点比例分区

    另一种分区方式是使分区数与集群节点数成正比关系。换句话说,每个节点具有固定数量的分区。此时,当节点树不变时,每个分区的大小与数据集大小保持正比的增长关系;当节点数增加时,分区则会调整变得比较小。较大的数据量通常需要大量的节点来存储,因此这种方法也使每个分区大小稳定。

    当一个新节点加入集群时,它随机选择固定数量的现有分区进行分裂,然后拿走这些分区的一半数据量,将另一半数据留在原节点。随机选择可能会带来不太公平的分区分裂,但当平均分区数量较大时,新节点可以最终从现有节点中拿走相当数量的负载。

    批处理系统

    image.png
    带有3个mapper和3个reducer的MapReduce作业

    数据集成

    本书反复强调了一个主题,对于任何给定的问题,都有多种解决方案,而这些解决方案都有各自优缺点和折中之处。例如,在第3章中讨论存储引擎时,我们看到了日志结构存储,B-tree和列式存储。在第5章讨论复制时,我们介绍了主从复制,多主节点复制和无主节点复制。

    如果给你一个问题,比如“我要把数据保存起来,可以支持查询”,这样的问题其实不存在唯一正确的解决方案,或者说不同具体情况下有不同的方案。而软件的实现通常必须选择一种特定的方法。通常一种实现很难同时兼备鲁棒与性能,如果试图兼顾所有方面,那唯一可以肯定的是最终的实现一定很糟糕。

    因此,选择合适的软件组件也要视情况而定。每一个软件,即使是所谓的“通用”数据库,也都是针对特定的使用模式而设计的。面对如此众多的备选方案,第一个挑战就是弄清楚软件产品与适合运行环境之间的对应关系。软件厂商不愿意告诉你它们的软件不适合哪些负载,所以系统通过前面的章节可以帮你准备一些有针对性的问题,例如产品字里行间的含义和取舍之道。另一个挑战是,在复杂的应用程序中,数据通常以多种不同的方式被使用。不太可能存在适用于所有不同环境的软件,因此你不可避免地要将几个不同的软件组合在一起,以提供应用程序的功能性。

    复杂应用 \to 多个软件组合

    ::采用派生数据来组合工具::

    数据系统集成的复杂性和重要性:在数据系统中,我们经常需要整合不同类型的工具来满足需求,例如将OLTP数据库和全文搜索索引结合来处理关键字查询。有些数据库如PostgreSQL支持全文索引,但对于更复杂的查询则需要专门的信息检索工具。然而全文搜索索引不太适合作为持久记录系统。

    随着不同类型的数据的持续增加,数据集成问题将更加复杂。我们可能需要在分析系统中保存数据副本,从派生数据中维护数据缓存或非规范版本,将数据传递给机器学习系统等。

    对于数据的需求千变万化,覆盖范围广泛。有些人可能因为个人经验而一概而论,主张某些做法或认为某些特性是无意义的,实际上这可能是其他人的核心需求。因此,数据集成需求通常需要在考虑整个组织框架内的数据流的情况下才能明确。

    复杂应用 \to 多个软件组合 \to 不同软件有不同需求 \to 数据流

    为何需要数据流

    当同样数据的多个副本需要保存在不同的存储系统,以满足不同的访问模式需求时,你需要摸清楚数据输入和输出。数据第一是写在哪里?不同的数据拷贝来自哪些数据源?如何以正确的格式将数据导入到正确的位置?

    我们举个例子,比如可以将数据首先写入记录系统数据库,捕获对该数据库的更改,然后按相同顺序将更改应用到搜索索引中(见下面的图示)。如果变更数据捕获是更新索引的唯一方法,那么你就可以确信索引完全来自记录系统,因为它是一致的。写数据库是向系统提供新输入的唯一途径。

    数据数据库获取变更记录搜索索引\text{数据} \to \text{数据库} \xrightarrow {\text{获取变更记录}} \text{搜索索引}

    允许应用程序同时向搜索索引和数据库写入会带来问题,两个客户端同时发送冲突的写操作,而两个存储系统以不同的顺序来执行它。在这种情况下,负责确定写操作顺序的既不是数据库也不是搜索索引,因此它可能会做出相互矛盾的决定,以至于导致彼此之间永久的不一致。

    搜索索引写入数据写入数据库\text{搜索索引} \xleftarrow{\text{写入}} \text{数据} \xrightarrow {\text{写入}} \text{数据库}

    如果可以通过单个系统来决定所有输入的写入顺序,那么以相同的顺序处理写操作就可以更容易地派生出数据的其他表示形式。无论是使用变更数据捕获还是事件获取日志,都不如整体顺序的原则重要。

    根据事件日志来更新一个派生数据系统通常会更好实现,并且可以实现确定性和一致性,也因此使系统很容易从故障中恢复。

    复杂应用 \to 多个软件组合 \to 不同软件有不同需求 \to 数据流 \to 对比派生数据与分布式事务

    派生数据与分布式事务

    为了保持不同的数据系统彼此之间的一致性,经典的方法是通过分布式事务,我们在讨论过原子提交和两阶段提交机制(2P)。那么与分布式事务相比,派生数据系统的方法怎么样呢?

    抽象点说,它通过不同的方式达到类似的目标。分布式事务通过使用锁机制进行互斥来决定写操作的顺序,而CDC和事件源使用日志进行排序。分布式事务使用原子提交来确保更改只有一次,而基于日志的系统通常基于确定性重试和幂等性。

    最大的不同在于事务系统通常提供线性化,这意味着它可以保证读自己的写的一致性。另一方面,派生的数据系统通常是异步更新的,所以默认情况下它无法提供类似级别的保证。

    类别描述
    分布式事务通过锁机制进行互斥决定写操作的顺序。使用原子提交保证更改只有一次。提供线性化,保证写后读一致性。
    CDC和事件源通过使用日志进行排序。基于确定性重试和幂等性。通常异步更新。

    在能够承受付出分布式事务所带来开销的特定环境中,分布式事务已有成功案例。但是,我认为XA的容错性和性能不尽如人意,这严重限制了它的实用性。我相信为分布式事务未来可以有更好的协议,但是这样的协议被广泛采用而且能与现有工具集成将非常具有挑战性,并且不太可能在短期内发生。

    考虑到好的分布式事务协议尚未得到广泛支持,我认为基于日志的派生数据是集成不同数据系统的最有前途的方法。然而,一些保证如“读自己写”仍是非常有用,因此我不认为到处宣扬“最终的一致性是不可避免的,接受并学会处理它”有任何实际意义(至少在没有很好的指导来告诉人如何处理它的情况下是不太可行的)。

    在本章后面“正确性目标”中,我们将讨论在异步派生系统之上实现更强保证的方法,并在分布式事务和基于日志的异步系统之外找到一条中间的路。

    复杂应用 \to 多个软件组合 \to 不同软件有不同需求 \to 数据流 \to 对比派生数据与分布式事务:基于日志的派生数据更好

    全序的局限

    对于非常小的系统,构建一个完全有序的事件日志是完全可行的(流行的主从复制正是构造了这样的日志)。但是,随着系统越来越大,并且面对更为复杂的负载时,瓶颈就开始出现了:

    • 在大多数情况下,构建一个完全有序的日志需要所有事件都通过单个主节点来决定排序。如果事件吞吐量大于单台节点可处理的上限,那么需要将其分区到多台节点上,这就使得两个不同分区中的事件顺序变得不明确了。(单节点性能)
    • 如果服务器分布在多个不同地理位置的数据中心,为了避免整个数据中心不可用,且考虑到网络延迟使跨数据中心协调同步效率很低,因此通常在每个数据中心都有独立的主节点。这意味着来自两个不同数据中心的事件顺序不确定。(多主节点)
    • 将应用程序部署为微服务,常见的设计是将每个服务与其持久化的状态一起作为独立单元部署,而服务之间不共享持久化状态。当两个事件来自不同的服务时,这些事件没有明确的顺序。(不同微服务之间不共享存储)
    • 某些应用程序在客户端维护一些状态,当用户输入时会立即更新(无需等待服务器的确认),甚至可以继续离线工作。对于这样的应用程序,客户端和服务器很可能看到不同的事件顺序。(多主节点)

    从形式上讲,决定事件的全序关系称为全序关系广播,它等价于共识。大多数共识算法是针对单节点吞吐量足以处理整个事件流而设计的,并且这些算法不提供支持多节点共享事件排序的机制。设计突破单节点吞吐量甚至在广域地理环境分布的共识算法仍然是一一个有待研究的开放性问题。

    复杂应用 \to 多个软件组合 \to 不同软件有不同需求 \to 数据流 \to 对比派生数据与分布式事务:基于日志的派生数据更好 \to 派生数据无法实现完全有序的事件日志

    捕获事件与捕获因果关系

    如果事件之间不存在因果关系,则不支持全序排序并不是一个大问题,因为我并发事件可以任意排序。其他一些情况很容易处理:例如,当同一对象有多个更新时,可以通过特定对象ID的所有更新都路由到同一个日志分区上。然后,因果依赖有时候会有一些微妙的情况。

    例如在一个社交网络服务里,有两个以前相恋,但现在分手了的用户。女孩子将这个分手的男朋友删除后,发送消息给她其余的朋友抱怨她的前男友。这个女孩子以为,他的前男友不应该看到这些抱怨信息,因为该消息是她删除男朋友的朋友状态之后发送的。

    然而,如果朋友状态和消息分别存储在不同地方,那么可能会丢失“删除好友”事件和“抱怨”事件之间的顺序关系。如果没有正确捕获事件的因果关系,负责发送新消息通知的服务可能在“删除好友”之前就把“抱怨”消息广播给所有朋友,结果就是其前恋人还是会收到一条本不应该看到的消息。

    在这个例子中,通知实际上是消息和朋友列表的联结操作,这和之前讨论的 join 与时间问题有关系了(流式join操作)。不幸的是,似乎这个问题没有一个简单的答案。不幸的是,似乎这个问题并没有一个简单的答案。从头分析的话是这样的:

    • 逻辑时间戳可以在无协调者的情况下提供全序关系,所以当全序关系广播不可行时用得上。但是,它们仍然需要接收者去处理那些乱序事件。
    • 如果可以记录一条事件来标记用户在做决定以前所看到的系统状态,并给该事件一个唯一的标识符,那么任何后续的事件都可以通过引用该事件标识符来记录因果关系。
    • 冲突解决算法可以处理异常顺序的事件。他们可以帮助维护正确状态,但前提是行为不能外部可见的副作用(例如向用户发送通知)。

    也许将来应用程序开发的模式将会出现可喜变化,使得因果关系被有效地捕获,派生状态得到正确维护,而不会强制所有事件必须通过全区广播(系统瓶颈)。

    ::批处理和流处理集成::

    我认为数据整合的目标是确保数据在所有正确的地方以正确的形式结束。这样做涉及消费输入数据,转换, join,过滤,聚合,训练模型,评估并最终写入适当的输出。而批处理和流处理则是实现这一目标的有效工具。

    批处理和流处理的输出是派生数据集,如搜索索引、实体化视图、向用户展现的建议、聚合度量标准等。

    正如我们在第十章和第11章中所介绍的,批处理和流处理有许多共同的原则,而根本区别在于流处理器运行在无界数据集上,而批处理的数据是已知的有限大小。处理引擎的实现方式也有很多细节上的差异,但这些区别现在变得越来越模糊。

    Spark 通过将流分解为微批处理来在批处理引擎上执行流处理,而Flink则直接在流处理引擎上执行批处理。原则上,一种类型的处理可以通过另一种类型来模拟,尽管性能特征会有所不同:例如,微批处理可能在跳跃或滑动窗口上表现不佳。

    保持派生状态

    批处理的优势(容易实现数据流)

    批处理具有相当强的功能特性(即使代码不是用函数式编程语言编写的),包括倡导确定性、纯函数操作即输出仅依赖于输入,除了显示输出以外没有任何副作用,输入不可变,追加式输出结果等。流处理是类似的,但它扩展了操作来支持可管理的、容错的状态。(确定性函数的优势)

    具有良好定义的输入和输出的确定性函数原理上不仅有利于容错,而且还简化了组织中的数据流的推理。无论派生数据是搜索索引,统计模型还是缓存,从数据管道的角度来看,对于从一个事物派生出的另一个事物,通过功能应用程序代码推动一个系统中的状态更改以将这种效果应用到派生系统,都是有帮助的。(易于实现数据流)

    原则上,派生数据系统可以同步维护,就像关系数据库在同一个事务中同步的更新二级索引,就像写入被索引的表一样。然而,异步使基于事件日志的系统健壮的原因:它允许系统的一部分在本地包含故障,而如果任何参与者失败,则分布式事务中止,因此他们倾向于通过将故障扩展到其余部分来放大故障。(提高系统健壮性)

    二级索引经常跨越分区边界,具有二级索引的分区系统需要将写入发送到多个分区或者将读取发送到所有分区。如果索引是异步维护的,这种交叉分区通信也是最可靠的和最容易扩展的。(通过流可以提高多分区查询的效率)

    为应用程序演化而重新处理数据

    派生视图有利于应用程序的演化。

    在需要维护派生数据时,批处理和流处理都会用得上。流处理可以将输入的变化数据迅速反映在派生视图中,而批处理则可以反复处理大量的累积数据,以便将新视图导出到现有数据集上。

    特别是对现有数据进行重新处理,为维护系统提供了一个良好的机制,平滑支持新功能以及多变的需求。如果不进行重新处理,则只能支持有限简单的模式变化,比如将新的可选字段添加到记录中,或者添加新的记录类型。这些简单的模式比较常见于写时模式和读时模式。另一方面,通过重新处理,可以将数据集重组为一个完全不同的模型,以便更好地满足新要求。

    graph LR
    A["数据变更日志"] -->| 派生 | B["旧功能对应的数据视图"]
    A -->| 派生 | C["新功能对应的数据视图"]
    

    派生视图允许逐步演变。如果想重新构建数据集,无需采用高风险的陡然切换。而是可以在同一个基础数据上的两个独立派生视图来同时维护新老两种架构。然后,逐步开始将少量用户迁移到新视图中,以测试其性能并发现是否有错误,而大多数用户将继续路由到旧视图。之后,逐渐增加访问新视图的用户比例,最终放弃旧视图。

    这种逐渐迁移的美妙之处在于,如果出现问题,每个阶段的过程都是可以轻易反转:你总是有一个工作系统可以回退。通过减少不可逆损害的风险,可以更加自信的向前推进,从而更快地改善系统。

    Lamdba架构

    如果使用批处理来重新处理历史数据,并且使用流处理来处理最近的更新,那么如何将这两者结合起来呢?备受关注的Lambda结构是一个不错的解决方案。

    Lambda体系结构的核心思想是进来的数据以不可变事件形式追加写到不断增长的数据集,类似于事件源。基于这些总事件,可以派生出读优化的视图。Lambda结构建议并行运行两个不同的系统:一个批处理系统如Hadoop MapReduce,以及一个单独的流处理系统,如Storm。

    graph LR
    A["数据以不可变方式\n追加到不断增长的数据集"] -->| 派生 | B["流处理系统"] --> D["产生视图的近似更新"]
    A -->| 派生 | C["流处理系统"] --> E["产生派生视图的校正版本"]
    

    在Lambda方法中,流处理器处理事件并快速产生对视图的近似更新;批处理系统则处理同一组事件并产生派生视图的校正版本。这种设计背后的原因是,批处理更简单,不易出现错误,而流处理器则相对不太可靠,难以实现容错。而且流处理可以使用快速的近似算法,而批处理只能用较慢的精确算法。

    Lambda架构是一个很有影响力的想法,它将数据系统的设计变得更好,特别是推广了将派生视图用于不可变事件流以及必要时重新处理事件的原则。但是,另一方面,我认为它有一些实际问题:

    • 不得不在批处理和流处理框架中运行相同的处理逻辑,这是一项重大的额外工作。虽然像Summingbird这样的库提供了一个可以在批处理或流处理环境下运行的计算抽象,但调试/优化和维护两个不同系统的操作复杂性依然存在。
    • 由于流处理和批处理各自产生单独的输出,因此需要合并二者结果再响应给用户请求。如果计算是通过滚动窗口的简单聚合,则此合并相当容易,但如果包含更复杂的操作(例如连接和会话流程)导出视图,或者输出不是时间序列,则合并非常困难。
    • 能够重新处理整个历史数据集思路是好,但是在大型数据集上这样做往往代价太高。因此,批处理流水线通常需要设置为处理增量批处理(例如,在每小时结束时处理这一小时的数据),而不是重新处理所有事情。这会引发处理滞后消息和处理跨批量边界的窗口等问题。增加批量计算就增加了复杂性,使其更类似于流式,这与保持批处理层尽可能简单的目标背道而驰。

    统一批处理和流处理

    最近的发展使得lambda结构可以充分发挥其优点而规避其缺点,即允许批处理计算(重新处理历史数据)和流计算(事件到达时即处理)在同一个系统中实现。在一个系统中统一批处理和流处理需要以下功能,而这些功能目前正越来越普及:

    • 支持以相同的处理引擎来处理最新事件和处理历史回放事件。例如,基于日志的消息代理可以重放消息,某些流处理器可以从诸如HDFS等分布式文件系统读取输入。
    • 支持只处理一次语义。即使事实上发生了错误,流处理器依然确保最终的输出与未发生错误的输出相同。与批处理一样,这需要丢弃任何失败任务的部分输出。
    • 支持依据事件发生时间而不是处理时间进行窗口化。因为在重新处理历史事件时,处理时间毫无意义。例如,Apache Beam提供了用于表示这种计算的API,支持在Apache Flink或Google Cloud Dataflow引擎上运行。

    ::围绕数据流设计应用系统::

    分离式数据库:分离式方法遵循了UNIX传统,即单一任务做好一件事,内部通过统一的低级API进行通信,外部通过更高级别的语言进行组合。

    通过应用层代码来组合多个专门的存储与处理系统并实现分离式数据库的方法被称为“数据库由内向外”方法。这是我在2014年会议报告中使用的标题,但并非全新架构,而是一个设计模式,作为讨论的起点。

    这些想法并非我独创,而是融合了一些值得学习的他人想法。尤其是,它与数据流语言如Oz和Juttle,功能性反应式编程语言(FRP)如Elm,以及逻辑编程语言如Bloom等存在很多共同点。在此背景下,Jay Kreps提出了unbundling这个术语。

    电子表格也比大多数主流编程语言更早支持数据流编程功能。在电子表格中,可以在一个单元格中放入公式(例如,另一列中的单元格的总和),每当公式的任何输入发生更改时,都会自动重新计算公式的结果。这正是我们在数据系统级别所需要的:当数据库中的记录发生更改时,我们希望自动更新该记录的任何索引,并自动刷新依赖于该记录的任何缓存的视图或聚合。

    因此,我认为绝大多数的数据系统仍然可以从VisiCalc在1979年已经具备的功能中学习。与电子表格的不同之处在于,今天的数据系统需要具备容错性、可扩展性以及数据持久存储能力。它们还需要能够集成不同团队编写的不同技术,并重用现有的库和服务。

    在本节中,我将围绕分离式数据库和数据流思路来探索构建新型应用程序的一些方法。

    应用程序代码作为派生函数

    当某个数据集从另一个数据集派生而来时,它一定会经历某种转换函数。例如:(常见的派生数据的理由)

    • 二级索引是一种派生的数据集,它具有一个简单的转换函数:对于主表中的每一行或一个文档,挑选那些索引到的列或字段值,并按值排序(假设为B-tree或SSTable索引,按主键排序)。
    • 通过自然语言处理函数(如语言检测、自动分词、词干或词形识别、拼写检查和同义词识别)创建全文搜索索引,然后构建用于高效查找的数据结构(如反向索引)。
    • 在机器学习系统中,可以通过应用各种特征提取和统计分析功能从训练数据中导出模型。当应用于新输入数据时,模型的输出是基于输入数据和模型(因此间接地从训练数据)派生而来。(机器学习的输出是输入的派生)
    • 缓存通常包含即将显示在用户界面(UI)中的聚合数据。因此,填充缓存需要知道在UI中引用哪些字段,UI更改可能也需要相应地调整缓存填充方式和重建方式。(根据UI需求决定放入数据的缓存)

    为二级索引构造派生函数的需求非常普遍,几乎内置于很多数据库中,通过简单地调用CREATE INDEX即可使用。对于全文索引,常见的语言方面的函数也会内置于数据库中,但更复杂的特性通常需要针对特定领域进行调整。而机器学习中的特征工程与特定应用紧密相关,此外还需要集成很多用户交互和应用部署的详细知识。(复述上面的内容)

    如果创建派生数据集的函数并非创建二级索引那样的标准函数,就需要引入自定义代码来处理特定应用相关的问题。这是很多数据库在处理自定义代码时的一个挑战。虽然关系型数据库通常支持触发器、存储过程以及用户定义的函数,使数据库能够执行应用程序代码,但这些功能在数据库设计中多为后来添加的功能。(数据库在创建派生数据集的局限性)

    应用程序代码与状态分离

    理论上讲,数据库可以像操作系统那样成为任意应用程序代码的部署环境。然而,实际上它们并不太适合,主要是无法满足当今应用程序的很多开发要求,如依赖性和软件包管理,版本控制,滚动升级,可演化性,监控,指标,网络服务调用以及与外部系统的集成。

    另一方面,诸如Mesos,YARN,Docker,Kubernetes等用于部署和集群管理的工具是专门为运行应用程序代码而设计的。由于专注于做好一件事,它们能够做得比数据库更好,在众多特性中,都提供了用户定义函数的执行功能。

    我认为系统的某部分专注于持久性数据存储,同时有另外一部分专门负责运行应用程序代码是有道理的。这两部分会有交互,但是各自仍保持独立运行。

    现在大多数Web应用程序都被部署为无状态服务,用户请求都可以被路由到任意的应用程序服务器上,并且服务器发送响应之后就不会保留请求的任何状态信息。这种方式部署起来很方便,服务器可以随意添加或删除,然而有些状态势必需要保存在某个地方:通常是数据库。目前的趋势是将无状态应用程序逻辑与状态管理(数据库)分开:不把应用程序逻辑放在数据库中,也不把持久状态放在应用程序中。正如函数程序社区的人喜欢开玩笑说,“我们相信可变状态与Church的工作分离”。

    在这种典型的Web应用模型中,数据库充当一种可以通过网络同步访问的可变共享变量。应用程序可以读取或更新变量,数据库负责持久性,提供一些并发控制和容错功能。

    但是,在大多数编程语言中,无法订阅可变变量的更改信息,而只能定期不断地读取它。与电子表格不同,如果变量的值发生了变化,变量的读取者将不会收到通知(你可以在自己的代码中实现这样的通知,例如通过所谓的观察者模式,但是大多数编程语言级别没有内置该模式)。

    数据库继承了这种被动方法来处理可变数据:如果想知道数据库的内容是否发生了变化,唯一的选择就是轮询(即定期地执行查询)。订阅更改只是最近才出现的新功能。

    将应用程序逻辑与状态管理分开,数据库就如同一个可变的共享变量,可以通过网络同步访问。然而,大部分编程语言无法订阅可变变量的更改,这是数据库和数据订阅模型需要面临的挑战。

    数据流:状态变化和应用程序代码之间的相互影响

    从数据流的角度思考应用意味着重新协调应用代码和状态管理之间的关系。我们不是将数据库简单地视为被应用程序所操纵的被动变量,而是更多地考虑状态、状态变化以及处理代码之间的相互作用和协作关系。应用程序代码在某个地方会触发状态变化,而在另一个地方又要对状态变化做出响应。我们在第11章的“数据库与数据流”中看到了这样的思路,当时讨论了将数据库更改日志作为可订阅的事件流来处理。消息传递系统(如actors)也具有响应事件的概念。早在20世纪80年代,元组空间模型探索了将分布式计算的过程表示为一些进程负责观察状态的变化,同时一些进程作出响应。(应用程序触发和响应状态变化,见下图)

    如前所述,数据库内部也有类似情况,例如当触发器由于数据变化而触发时,或者数据发生变化而二级索引更新时。分离式数据库也采取这样的思路,并将其应用于在主数据库之外创建派生数据集,包括缓存、全文搜索索引、机器学习或分析系统。我们可以使用流处理结合消息传递系统来达到这个目的。

    要记住的重要一点是,维护派生数据与异步作业执行不同,后者主要是传统消息系统所针对的目标场景(请参阅第11章“对比日志机制与传统消息系统”相关内容)。

    • 当维护派生数据时,状态更改的顺序通常很重要,如果从事件日志中派生出了多个视图,每个视图都需要按照相同的顺序来处理这些事件,以使它们互相保持一致。如第11章“确认与重传”中所述,许多消息代理在重新发送未确认的消息时不支持该特性。而双重写入也被排除在外。
    • 容错性是派生数据的关键:丢失哪怕单个消息都会导致派生数据集永远无法与数据源同步。消息传递和派生状态更新都必须可靠。例如,默认情况下,许多actor系统默认在内存中维护actor状态和消息,所以如果机器崩溃,这些内存信息都会丢失。(派生数据要注意顺序和容错)

    对稳定的消息排序和可容错的消息处理的要求都非常严格,但是它们比分布式事务代价小很多,并且在操作上也更加稳健。现代流式处理可以对大规模环境提供排序和可靠性保证,并允许应用程序代码作为stream operator运行。

    这个应用程序代码可以完成那些数据库内置函数所不支持的任意处理逻辑,就像管道链接的UNIX工具一样,多个stream operators可以组装在一起构建大型数据流系统。每个operator以变化的状态为输入,并产生其他状态变化流作为输出。(生成派生数据的应用程序有很高的灵活性)

    流式处理与服务

    流式处理与微服务存在通信机制上的差别。

    当前流行的应用程序开发风格是将功能分解为一组通过同步网络请求(例如REST API)进行通信的服务。这种面向服务的结构优于单体应用程序之处在于松耦合所带来的组织伸缩性:不同的团队可以在不同的服务上工作,这减少了团队之间的协调工作(只要服务可以独立部署和更新)。

    将stream operator组合成数据流系统与微服务理念有很多相似的特征。但是,底层的通信机制差异很大:前者是单向、异步的消息流,而不是同步的请求/响应交互。(stream是单向、异步的数据流,微服务是请求/响应交互)

    除了第4章“基于消息传递的数据流”中所列出的优点(比如更好的容错性)之外,数据流系统还可以获得更好的性能。例如,假设客户正在购买一种商品,这种商品以某一种货币定价,但需要以另一种货币支付。为了执行货币转换,需要知道当前的汇率。这个操作可以通过两种方式来实现:

    1. 对于微服务方法,处理购汇的代码可能会查询汇率服务或数据库,以获取特定货币的当前汇率。
    2. 对于数据流方法,处理购汇的代码会预先订阅汇率更新流,并在本地数据库发生更改时记录当前的汇率。当有购汇请求时,只需查询本地数据库即可。
    graph LR
        C[汇率服务]
        A[客户端]
        B[入口服务]
        A --> |查询| B
        B --> |查询汇率| C
        C --> |返回汇率| B
        B --> |返回| A
    
    graph LR
        B[服务入口]
        C[汇率服务]
        A[客户端]
        B --> |返回| A
        C --> |订阅汇率| B
        A --> |查询| B
    

    第二种方法把向另一个服务发起的同步网络请求转换为本地数据库的查询(同一个进程或者同一台机器上)。数据流不仅方法更快,而且当另一个服务失败的话也更加健壮。最快和最可靠的网络请求就是根本没有网络请求!我们现在在购汇请求和汇率更新事件之间有一个事件流join,而不是RPC(请参阅第11章“流和表join操作”)。

    注意,这种join效果会随时间而变:如果购汇事件在稍后被重新处理,汇率已经发生改变。如果想重演当初的购买行为,则需要从原来购买的时间获得历史汇率。无论是查询服务还是订阅汇率更新流,都需要处理这种时间依赖性(请参阅第10章“join的时间依赖性”)。

    订阅变化的流,而不是在需要时去查询状态,使我们更接近类似电子表格那样的计算模型:当某些数据发生更改时,依赖于此的所有派生数据都可以快速更新。还有很多开放的问题,例如关于时间依赖join等,但我相信围绕数据流来构建应用程序是一个非常有前途的方向。

    image.png
    搜索索引场景中的写入路径(文档更新)和读取路径(稳定查询)

    AI确实很有用

    image.png

    image.png

    image.png