《设计数据密集型应用》第三部分:衍生数据 | 笔记

182 阅读1小时+

第三部分 衍生数据

第三部分研究多个不同数据系统集成为一个协调一致的应用框架时会遇到的问题。

存储和处理数据的系统分为两大类:记录系统和衍生系统:

  • 记录系统(System of record):也称真相源(source of truth) ,持有数据的权威版本。当新数据进入时首先会记录在此,若数据与其他系统发生冲突那么判记录系统的值是正确的。
  • 衍生数据系统(Derived data systems):通常是另一个系统中的现有数据以某种方式进行转换或处理的结果。若丢失衍生数据,可以从原始来源重新创建。典型的例子是缓存(cache):如果数据在缓存中,就可以由缓存提供服务;如果缓存不包含所需数据,则降级由底层数据库提供。非规范化的值,索引和物化视图亦属此类。在推荐系统中,预测汇总数据通常衍生自用户日志。

从技术上讲,衍生数据是冗余的(redundant),和已有的信息是重复的。但是衍生数据能获得良好的只读查询性能。

大多数数据库,存储引擎和查询语言,本质上既不是记录系统也不是衍生系统。数据库只是一个工具:如何使用它取决于你自己。记录系统和衍生数据系统之间的区别不在于工具,而在于应用程序中的使用方式。

第十章 批处理

用于调用远程API的在线系统不是构建系统的唯一方式

  • 服务(在线系统):服务等待客户请求或指令到达,收到后尽快返回响应。响应时间是服务性能的主要衡量指标,可用性非常重要。
  • 批处理系统(离线系统):输入大量数据使用作业(job) 来处理它并生成输出数据,不会有用户等待作业完成因为时间较长。批处理作业通常定期完成,主要性能衡量指标是吞吐量。
  • 流处理系统(准实时系统):介于在线和离线之间,有时称为准实时或准在线处理。流处理像批处理系统一样消费输入并产生输出,但是流式作业在事件发生后不久就会对事件进行操作,而批处理作业则需等待固定的一组输入数据。即流处理比批处理延迟低,且基于批处理。

批处理是构建可靠、可扩展和可维护应用程序的重要组成部分。如Google的Map-Reduce,还有后来的Hadoop、CouchDB和MongoDB。

批处理实际上是一种古老的计算方式,MapReduce是一个相当低级别的编程模型。本章中我们将了解MapReduce和其他一些批处理算法和框架。首先我们看看使用标准Unix工具的数据处理。

1 使用Unix工具的批处理

我们从一个简单的例子开始。假设您有一台Web服务器,每次处理请求时都会在日志文件中附加一行。例如,使用nginx默认访问日志格式,日志的一行可能如下所示:

216.58.210.78 - - [27/Feb/2015:17:55:11 +0000] "GET /css/typography.css HTTP/1.1" 200 3377 "http://martin.kleppmann.com/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 Safari/537.36"

(实际上这只是一行,分成多行只是为了便于阅读。)这一行中有很多信息。为了解释它,你需要了解日志格式的定义,如下所示:

 $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"

日志的这一行表明在2015年2月27日17:55:11 UTC,服务器从客户端IP地址216.58.210.78接收到对文件/css/typography.css的请求。用户没有被认证,所以$remote_user被设置为连字符(- )。响应状态是200(即请求成功),响应的大小是3377字节。网页浏览器是Chrome 40,URL http://martin.kleppmann.com/ 的页面中的引用导致该文件被加载。

1.1 分析简单日志

使用基本的Unix功能创建自己的工具。假设想在网站上找到五个最受欢迎的网页,我们可以在Unix shell中这样做:

cat /var/log/nginx/access.log | #1
    awk '{print $7}' | #2
    sort             | #3
    uniq -c          | #4
    sort -r -n       | #5
    head -n 5          #6
  1. 读取日志文件
  2. 将每一行按空格分割成不同字段,每行只输出第七个字段,即URL。
  3. 按字母顺序排列URL,若某个URL被请求n次,那么排序后将连续出现n次该URL。
  4. uniq命令通过检查两个相邻行是否相同来过滤掉输入中的重复行。-c表示还输出一个计数器。
  5. 第二种排序按每行起始处的数字(-n)排序,这是URL的请求次数。然后逆序(-r)返回结果,大的数字在前。
  6. 最后,只输出前五行(-n 5),并丢弃其余的。该系列命令的输出如下所示:
    4189 /favicon.ico
    3631 /2013/05/24/improving-security-of-ssh-private-keys.html
    2124 /2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html
    1369 /
    915 /css/typography.css

Unix工具的命令非常强大,它能在几秒钟内处理几GB的日志文件,且可以根据需要轻松修改命令。

1.2 Unix哲学

管道是Unix的关键设计思想之一,即通过管道连接程序,它的设计哲学如下:

  1. 让每个程序都做好一件事。要做一件新的工作,写一个新程序,而不是通过添加“功能”让老程序复杂化。
  2. 期待每个程序的输出成为另一个程序的输入。不要将无关信息混入输出。避免使用严格的列数据或二进制输入格式。不要坚持交互式输入。
  3. 设计和构建软件,甚至是操作系统,要尽早尝试,最好在几周内完成。不要犹豫,扔掉笨拙的部分,重建它们。
  4. 优先使用工具来减轻编程任务,即使必须曲线救国编写工具,且在用完后很可能要扔掉大部分。

这种方法的目的是:自动化,快速原型设计,增量式迭代,对实验友好,将大型项目分解成可管理的块。

这些程序由不同人编写,却可以灵活的组成在一起,例如sort和uniq的结合。Unix是如何实现这种可组合性的?

统一的接口

Unix使用相同的数据格式,相同的I/O接口,这让一个程序的输出能成为另一个程序的输入。统一接口的另一个例子是URL和HTTP。

在Unix中,这种接口是一个文件(file) 。一个文件是一串有序的字节序列,可以使用相同的接口来表示许多不同的东西:文件系统上的真实文件、到另一个进程的通信通道、设备驱动程序、表示TCP连接的套接字等等。

许多Unix程序将这个字节序列视为ASCII文本,这样所有的程序才能使用相同的分隔符记录列表,它们才能相互操作。处理每行的输入时,Unix工具通常通过空白或制表符将行分割成字段。

逻辑与布线分离

Unix工具的另一个特点是使用标准输入(stdin)和标准输出(stdout)。默认情况下,标准输入来自键盘,标准输出指向屏幕。我们也可以将输入或输出重定向到文件。

Unix方法允许shell用户以任何他们想要的方式连接输入和输出,程序不关心或不知道输入的来源和输出的指向。将输入/输出布线与程序逻辑分开,可以将小工具组合成更大的系统。

透明度和实验

Unix工具成功的原因之一是,更容易查看正在发生的事:

  • Unix命令的输入文件通常不会被程序修改,这样我们才可以随意运行尝试命令,而不会损坏输入文件。
  • 我们可以在任何时候结束管道,将管道输出到less。这种检查能力对调试非常有用。
  • 我们可以将一个流水线阶段的输出写入文件,并作为下一阶段的输入。这使得可以重启后面的阶段而不是整个管道。

与关系数据库查询优化器相比,Unix工具简单且有效。但是却只能在一台机器上运行,所以Hadoop这样的工具应运而生。

2 MapReduce和分布式文件系统

MapReduce有点像Unix工具,但分布在数千台机器上。MapReduce作业在分布式文件系统上读写文件,且不会修改输入,输出文件以连续方式一次性写入。

在Hadoop的Map-Reduce实现中,该文件系统被称为HDFS(Hadoop分布式文件系统) 。 除了HDFS外,还有其他分布式文件系统,本章主要用HDFS作为示例。

与网络连接存储(NAS)和存储区域网络(SAN)架构的共享磁盘方法相比,HDFS基于无共享原则(参见第二部分前言)。共享磁盘需要集中式存储设备和定制的硬件和专用的网络设施(如光纤)。而无共享只需要计算机联网。

HDFS包含在每台机器上运行的守护进程,对外暴露网络服务,允许其他节点访问存储在该机器上的文件。名为NameNode的中央服务器会跟踪哪个文件块存储在哪台机器上。因此,HDFS在概念上创建了一个大型文件系统,可以使用所有运行有守护进程的机器的磁盘。且文件块被复制到多台机器上以支持容灾。与RAID的区别在于,文件访问通过网络而非硬件。

2.1 MapReduce作业执行

MapReduce是一个编程框架,可以使用它编写代码处理HDFS中的大型数据集。数据处理模式与上一节的“简单日志分析”中的示例相似。

创建MapReduce作业,需要实现两个回调函数,Mapper和Reducer

  • Mapper:会在每条输入记录上调用一次,对每个记录进度独立处理。其工作是从输入记录中提取键值,然后生成任意数量的键值对。
  • Reducer:框架拉取Mapper生成的键值对,手机属于同一个键的所有值,在这组值的列表上迭代调用Reducer。Reducer可以产生输出记录。

简单来说,Mapper的作用是将数据放入一个适合排序的表单中,而Reducer的作用是处理已排序的数据。如果要用MapReduce完成上一节中的例子,我们可以写两个作业,第一个作业统计数量,第二个作业排序。

分布式执行MapReduce

下图显示了Hadoop MapReduce作业中的数据流。其并行化基于分区:作业的输入通常是HDFS中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理map任务(图中的m1、m2和m3)。

image-20240123161512711

调度器会试图在存储着输入文件副本的机器上运行每个Mapper(如果该机器的资源够的话),这个原则叫将计算放在数据附近,可以节省网络负载并增加局部性。

开始时,框架会将代码复制到适当的机器,然后启动Map任务并开始读取输入文件,一次将一条记录传入Mapper回调函数。Mapper的输出由键值对组成。

计算的Reduce端也被分区,虽然Map任务的数量由输入文件块的数量决定,但Reducer的任务的数量是由作业作者配置的。框架使用键的散列值来确定哪个Reduce任务应该接收到特定的键值对。

键值对必须进行排序,但数据集可能大得无法在单台机器上排序。分类是分阶段进行的,每个Map任务都按照Reducer对输出进行分区然后写入Mapper的本地磁盘。

当Mapper写完排序后的输出文件,MapReduce调度器通知Reducer,随后Reducer连接到每个Mapper,并下载自己相应分区的键值对文件且保留其有序特性。按Reducer分区、排序、复制分区数据的这个过程称为混洗(shuffle) 。如果不同的Mapper生成了相同的记录,那么在Reducer输入中他们会相邻。

Reducer调用时会收到一个键,和一个迭代器作为参数,迭代器会顺序地扫过所有具有该键的记录。Reducer的输出会写入分布式文件系统上的文件中,通常是有备份的。

MapReduce工作流

单个MapReduce作业能做的事有限,所以经常将作业链接成为工作流(workflow) ,即一个作业的输出是下一个的输入。

Hadoop对工作流没有特殊支持,所以链是通过目录名隐式实现的:手动设置第二个作业的输入目录为第一个的输出目录。从框架的角度来看,这是俩独立作业。因此,工作流没那么像Unix命令管道,更像一系列命令。

只有当批处理作业成功完成后,输出才能被视为有效。因此,工作流中只有前一个作业做完后面的作业才能开始,为此出现了很多针对工作流的调度器,包括Oozie,Azkaban,Luigi,Airflow和Pinball。

这些调度程序还具有管理功能,在维护大量批处理作业时非常有用。 Hadoop的各种高级工具也能自动布线组装多个MapReduce阶段,生成合适的工作流。

2.2 Reduce端连接与分组

MapReduce会读取输入文件的全部内容,这被称为全表扫描,在分析查询中需要用到。与索引查询相比效率低,但是hadoop可以在多台机器上并行读取输入。

当我们在批处理的语境中讨论连接时,我们指的是在数据集中解析某种关联的全量存在。

示例:分析用户活动事件

如图,左侧是事件日志,右侧是用户数据库。Mapper从每个输入记录中提取一对键值,在下图中,一组Mapper扫过User activity events(键是user ID,值是活动事件);另一组Mapper扫过User database(user_id为键,用户信息是值)。

image-20240123190510848

分析任务可能需要将用户活动与用户档案相关联,这样才可以进行“大数据推荐”。然而活动事件仅包含用户ID,而没有包含完整的用户档案信息,且如果要在事件中嵌入信息会非常浪费,因此需要将活动事件和用户档案数据库相连接。

该怎样实现连接?1. 遍历活动然后查询用户数据库——性能太差。2. 获取用户数据库副本将它与用户行为日志放入同一个分布式文件系统中,然后用MapReduce将所有相关记录集中到同一个地方进行高效处理——这是更好的办法。

排序合并连接

上一张图中的Mapper是如何提取键值对的呢?如图所示:

image-20240123191251070

Mapper输出后,相同ID则相邻。MapReduce甚至能二次排序(secondary sort) ,以该例子为例,即先以用户ID排序,然后同ID的时间戳再排序。

随后,每个用户ID都会被调用一次Reducer函数,因此一次只需要将一条用户记录保存在内存中, 而不需要通过网络发出请求。这个算法被称为排序合并连接(sort-merge join) 。因为Mapper的输出是按键排序的,然后Reducer将来自连接两侧的有序记录列表合并在一起。

把相关数据放在一起

排序合并连接中,Mapper和排序过程确保了数据都被放在调用Reducer的地方。这种架构可以看作Mapper将”消息“发送给Reducer——所有相同键的键值对被发送到相同的目标(一次Reduce调用)。

使用MapReduce编程模型,能将计算的物理网络通信层面从应用逻辑中剥离出来。而传统数据库的请求在代码内部,这样MapReduce就不用用代码来处理故障了,它在不影响应用逻辑的情况下能透明地重试失败的任务。

2.3 GROUP BY

Mapper做到的和SQL中的GROUP BY差不多,能按某个键对记录分组,然后就能对每个组内进行聚合操作。

会话化(sessionization) 是分组的另一个常见用途:整理特定用户会话的所有活动事件,以找出用户进行的一系列操作。

处理倾斜

单个键有大量数据被称为关键对象(linchpin object)热键(hot key) 。比如大V的几百万粉丝。单个Reducer中收集到热键相关的所有活动可能导致严重倾斜,这称为热点(hot spot)

可以用算法进行补偿。例如Pig中的倾斜连接(skewed join) ,它会先运行一个抽样作业确定热键,连接的时候Mapper将热键的关联记录随机发送到几个Reducer。代价是需要将连接另一侧的输入记录复制到多个Reducer上。Crunch中的分片连接(sharded join) 方法与之类似,但需要显式指定热键而不是使用采样作业。

按照热键进行分组并聚合时,可以将分组分两个作业进行。第一个阶段将记录发送到随机Reducer,然后第二个作业将所有来自第一阶段Reducer的中间聚合结果合并为每个键一个值。

2.4 Map端连接

上一节描述的连接算法在Reducer中执行实际的连接逻辑,因此被称为Reduce端连接。其中,Mapper作为预处理输入数据的角色。

Reducer端的优点是不需要对输入数据做任何假设,缺点是排序、复制至Reducer、合并Reducer输入这些操作可能开销巨大。内存缓冲区不够大的话,数据通过作业时可能落盘好几次。

如果我们能对输入数据做出某些假设,就能通过Map端连接来加快连接速度。这种方法去掉了Reduce和排序,Mapper读取输入后输出直接写入文件。

广播散列连接(broadcast hash join)

广播散列连接连接适用于大数据集和小数据集连接的情况。小数据集需要足够小,才能全部加载到每个Mapper的内存中。Mapper启动时,会将用户数据库从分布式文件系统中读到内存中的散列中。这样程序就可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户ID

广播:每个连接较大输入端分区的Mapper都会将较小输入端数据集整个读入内存中。

分区散列连接

如果我们将Map端连接进行分区,那么可以在每个分区进行散列。比如我们可以根据用户ID最后一位10进制数字来对活动事件和用户数据库进行分区,例如Mapper3把以3结尾的ID加载到散列表中。分区散列连接在Hive中称为Map端桶连接(bucketed map joins)。

这种方法只有当连接两端输入有相同的分区数,且两侧的记录都是使用相同的键与相同的哈希函数做分区时才适用。

Map端合并连接

若输入数据集不仅以相同方式进行分区,而且还基于相同的键进行排序,就可以使用Map端合并连接。输入是否小得内存放得下不重要,因为Mapper同样可以执行归并操作:按键递增的顺序依次读取两个输入文件,将具有相同键的记录配对。‘

能用Map端合并连接意味着前一个作业可能一开始就把输入数据做了分区进行排序。也就是说这个连接其实上一个作业的Reduce阶段就可以进行。

MapReduce工作流与Map端连接

当下游作业使用MapReduce连接的输出时,选择Map端连接或Reduce端连接会影响输出的结构。Reduce端连接的输出是按照连接键进行分区和排序的,而Map端连接的输出则按照与较大输入相同的方式进行分区和排序。

如前所述,Map端连接对输入数据集的大小,有序性和分区方式做出了更多假设。在优化连接策略时,了解分布式文件系统中数据集的物理布局变得非常重要:仅仅知道编码格式和数据存储目录的名称是不够的;你还必须知道数据是按哪些键做的分区和排序,以及分区的数量。

在Hadoop生态系统中,这种关于数据集分区的元数据通常在HCatalog和Hive Metastore中维护。

2.5 批处理工作流的输出

作业完成后的最终结果是什么?我们最开始为什么要跑这些作业?

数据库查询的场景中分为事务处理(OLTP)与分析查询。OLTP查询通常根据键查找少量记录,使用索引呈现给用户。分析查询通常扫描大量记录,执行分组与聚合,输出通常有着报告的形式。

批处理离分析比较近,因为批处理通常会扫过输入数据集的绝大部分。但是输出不是报表,而是其他类型的结构。

建立搜索索引

Google最初使用MapReduce是为其搜索引擎建立索引,用了由5到10个MapReduce作业组成的工作流实现。直至今日,Hadoop MapReduce仍然是为Lucene/Solr构建索引的好方法。

若需要对一组固定文档执行全文搜索,那么批处理是高效方法。但如果索引文件修改的话就不准确了。可以定期重跑整个索引工作流,或者是增量建立索引:例如Lucene,它会写新的段文件,并在后台异步合并压缩段文件。

键值存储作为批处理输出

批处理另一个常见用途是构建机器学习系统。例如分类器与推荐系统。

这些批处理作业的输出通常是某种数据库,这些数据库需要可以被Web应用查询,但是数据库是独立与Hadoop的,那么如何输出到数据库中呢?

  1. 错误的做法:直接在Reducer中使用客户端库,让作业直接写入数据库,一次写入一条记录。这会带来几个问题:

    • 每条记录一个网络请求,吞吐量小。
    • 作业经常并行,数据库可能被批处理压垮。
    • 对于MapReduce来说一次批处理中有一次失败全部都会回滚,但如果Reducer直接写入数据库的话,数据已经保存到数据库里了,失败了也回滚不了。
  2. 正确的做法:在批处理作业内创建一个新的数据库,将其作为文件写入分布式系统中作业的输出目录。这样就可以批量加载到处理只读查询的服务器中

批处理输出的哲学

Unix哲学鼓励以显示指明数据流的方式进行实验:程序读取输入并写入输出。在这一过程中,输入保持不变,任何先前的输出都被新输出完全替换,且没有其他副作用。这意味着你可以随心所欲地重新运行一个命令,略做改动或进行调试,而不会搅乱系统的状态。

MapReduce作业的输出处理遵循同样的原理。通过将输出视为不可变(如写入外部数据库),批处理作业不仅实现了良好的性能,而且比数据库更容易维护:

  • 数据库中我们可以用事务回滚代码,但是如果你代码就运行错了那就无法回滚错误的数据。(能从错误代码中恢复称为人类容错(human fault tolerance) )’
  • 如果不能回滚,功能很难开发,回滚使得功能开发更快。这种最小化不可逆性(minimizing irreversibility) 的原则有利于敏捷软件开发。
  • 若Map或Reduce任务失败,MapReduce框架将自动重新调度,并在同样的输入上再次运行它。因为输入不可变,这种自动重试是安全的。
  • 同一组文件可用作各种不同作业的输入,包括计算指标的监控作业就可以评估作业的输出是否具有预期的性质。
  • 与Unix工具类似,MapReduce作业将逻辑与布线(配置输入和输出目录)分离,这使得关注点分离,可以重用代码。

Unix需要做大量的输入解析工作。Hadoop上就可以用更结构化的文件格式:比如Avro和Parquet(见第四章)。

2.6 Hadoop与分布式数据库的对比

Hadoop有点像Unix的分布式版本,其中HDFS是文件系统,而MapReduce是Unix进程的怪异实现。

且MapReduce论文发表的时候其实早已有了大规模并行处理(MPP) 。最大的区别是,MPP数据库专注于在一组机器上并行执行分析SQL查询,而MapReduce和分布式文件系统的组合则更像是一个可以运行任意程序的通用操作系统。

存储多样性
  • 数据库:根据特定模型(关系或文档)来构造数据
  • 分布式文件系统:字节序列,可以用任何数据模型和编码来编写。

Hadoop让数据能不加区分地转储到HDFS,允许后续在研究如何进一步处理。实践经验证明,使用原始格式通常比先决定数据模型更有价值。

这个想法与数据仓库类似:将大型组织的所有数据集中在一起,使得可以跨越以前相分离的数据集进行连接。以原始形式收集数据,稍后再操心模式的设计,能使数据收集速度加快(有时被称为“数据湖(data lake) ”或“企业数据中心(enterprise data hub) ”)

数据存储不加区分,那么解释数据的负担就落到了消费者身上:数据集的生产者不需要强制将其转化为标准格式。以原始形式简单地转储数据,可以允许数据向多种数据模型转换。这种方法被称为寿司原则(sushi principle) :“原始数据更好”。

因此,Hadoop经常被用于实现ETL过程(参阅“数据仓库”):事务处理系统中的数据以原始形式转储到分布式文件系统中,然后用MapReduce将其转化为关系形式,然后导到MMP数据仓库加以分析。数据建模在一个单独的步骤中进行,与数据收集相解耦。

处理模型多样性

MMP数据库是单体的,紧密集成的软件,负责磁盘上的存储布局、查询计划、调度和执行。这些组件都可以针对数据库的特定需求进行调整和优化,且SQL让使业务分析师可以用可视化工具访问。

有些类型的处理用SQL查询不合适。例如机器学习和推荐系统、图像分析、全文搜索索引等。这些类型的处理通常针对特定应用,因此需要写代码。MapReduce使工程师能在大数据集上运行自己的代码。HDFS和MapReduce上可以建立一个SQL查询执行引擎(Hive项目)。

MapReduce对某些类型的处理效率不高。因此出现了Hadoop之上的其他处理模型。最重要的是,处理模型都可以在共享的单个机器集群上运行,这些机器都可以访问分布式文件系统上的相同文件。Hadoop方法中,不需要将数据导入到几个不同的专用系统中进行不同类型的处理:系统足够灵活,可以支持同一个群集内不同的工作负载。不需要移动数据,使得从数据中挖掘价值变得容易得多,也使采用新的处理模型容易的多。

针对频繁故障设计

当比较MapReduce和MPP数据库时,两种不同的设计思路出现了:处理故障和使用内存与磁盘的方式。与在线系统相比,批处理对故障不太敏感,因为他们总能重试。

若一个节点在查询时崩溃,MMP会中止整个查询,并让用户重新提交查询或自动重新运行。MMP倾向于在内存中保留尽可能多的数据以避免读盘开销。

MapReduce可以容忍单个Map或Reduce任务的失败,而不会影响作业的整体,它会以单个任务的粒度重试工作。但它会直接将数据写入磁盘,一方面为了容错,另一方面是它假设了数据集太大了放不进内存。

MapReduce方式更适用于较大的作业,数据量大处理的事件很长,如果全部重试非常浪费。以单个任务的粒度恢复,导致了无故障处理更慢,但如果失败率够高就有收益。

但现实中可能没那么多机器故障,为了容错值得带来这么大开销吗?为什么MapReduce会节约使用内存,而在任务的层次进行恢复呢?:

一开始,Google有混用的数据中心,在线生产服务和离线批处理作业在同样机器上运行。每个任务有各自的优先级与资源配给,优先级高的任务可以抢占低优先级任务的资源。同时,团队必须为他们使用的资源付费,而优先级高的进程花费更多。

这种架构允许非生产(低优先级)计算资源被过量使用(overcommitted) ,因为系统知道必要时它可以回收资源,这可以更好利用机器并提高效率。但由于MapReduce作业以低优先级运行,容易被抢占资源而终止。所以MapReduce需要能任意终止进程的自由,以提高计算集群中的资源利用率。

在开源的集群调度器中,抢占的使用较少,支持通用优先级抢占的更是没有,所以MapReduce这一设计决策就没什么意义了。下一节中,我们将研究一些与MapReduce设计决策相异的替代方案。

3 MapReduce之后

MapReduce是一种有用的学习工具,它是分布式文件系统的简单抽象,简单的意思是它易于理解。但其实它使用困难,于是在MapReduce上出现很多高级编程模型。

但是它的一些问题无法通过增加另一个抽象层次而解决:一方面,它非常稳健,另一方面,它处理某些类型远不如其他工具。对于不同的数据量、数据结构和处理类型,其他工具可能更适合标识计算。

3.1 物化中间状态

前面说过,MapReduce作业独立,要连接的话要把第一个作业的输出目录作为第二个作业的输入。将数据发布到分布式文件众所周知的位置可以带来松耦合,这样作业就不需要知道谁在提供输入。

很多情况下,一个作业的输出只能作为另一个作业的输入,这种情况被称为中间状态(intermediate state) 。将这个中间状态写入文件的过程称为物化(materialization)

而在Unix中,例如开头的日志分析,管道没有完全物化中间状态,而只是用一个小的内存缓冲区将输出流(stream) 向输入。与Unix管道相比,MapReduce完全物化中间状态的方法存在不足:

  • MapReduce作业只有当全部前驱作业完成才能启动,而Unix管道连接的进程会同时启动,输出一旦生成就会被消费。必须等前驱作业完成拖慢了整个工作流程的进度。
  • Mapper通常是多余的,只是用来刚又Reducer写入的文件,为下一阶段的分区和排序做准备。这种情况下Reducer直接串在一起更好。
  • 将中间状态存储在分布式文件系统,意味着这些文件要被复制到多个节点,大量的临时文件复制很占资源。
数据流引擎

为解决以上问题,几种新的分布式批处理执行引擎被开发出来。最著名的是Spark、Tez、Flink。它们有一个共同点:把整个工作流作为单个作业来处理,而不是分解为多个独立子作业。

数据流引擎(dataflow engines):将工作流显式建模为数据从几个处理阶段穿过。它们在一条线上反复调用用户定义的函数来一次处理一条记录,通过输入分区来并行化载荷,通过网络将一个函数的输出复制到另一个函数的输入。

这些函数称为算子(operators) ,用算子代替了严格的Map和Reduce。数据流引擎提供了几种不同的选项来将一个算子的输出连接到另一个算子的输入:

  • 对记录按键重新分区并排序,就像在MapReduce的混洗阶段一样,用于实现排序合并连接和分组。
  • 接受多个输入,并以相同方式进行分区,但跳过排序。如果记录的顺序不重要的话,这种方法省去了分区散列连接的工作。
  • 对于广播散列连接,可以将一个算子的输出,发送到连接算子的所有分区。

这种类型的处理引擎是基于像Dryad和Nephele这样的研究系统,与MapReduce模型相比有几个优点:

  • 排序等昂贵工作只有需要才进行。
  • 去掉了不必要的Map任务,因为Mapper工作通常可以合并到Reduce算子中。
  • 工作流中所有连接和数据依赖是透明的,因此调度程序能总揽全局,知道哪里需要哪些数据。这使得能够利用局部性进行优化,比如将产生和消费数据的任务放在同一台机器上,从而直接通过共享内存缓冲区而不是通过网络复制。
  • 算子的中间状态通常保存到内存或写入本地磁盘,这比复制到多台机器要好。
  • 算子能在输入就绪后立即执行,不用整个前驱阶段完成后再开始。
  • MapReduce会为每个任务启动一个JVM,启动开销很大。而数据流引擎会将现有JVM进程重新用来运行新算子。

相同的处理代码在数据流引擎和MapReduce工作流上都能运行,因为算子是Map和Reduce的泛化。所以我们可以通过修改配置,简单的从MapReduce切换到Tez或Spark。

Spark和Flink则是包含了独立网络通信层,调度器,及用户向API的大型框架。我们将简要讨论这些高级API。

容错

完全物化中间状态到分布式文件系统有个优点:具有持久性。这能提高容错,一台机器上失败了,另一台机器上可以重启。

Spark,Flink和Tez避免将中间状态写入HDFS,于是使用另一种方法来容错:若一台机器故障且中间状态丢失,它会往前找中间状态重新计算,直到使用原始数据。

为实现这种重新计算,框架必须跟踪一个给定的数据的计算过程——使用了哪些分区?应用哪些算子?Spark使用弹性分布式数据集(RDD) 的抽象来跟踪数据的谱系,而Flink对算子状态存档,允许恢复运行在执行过程中遇到错误的算子。

重新计算时,重要的是直到计算是否是确定性的:给定相同输入数据,算子是否始终产生相同输出?若一些丢失的数据已经发送给下游算子,这个问题就很重要——重启后,重新计算的数据和丢失的数据不一致,下游算子不知道得用哪个。这种情况得杀死下游算子,然后重新跑数据。

为了避免这种级联故障,最好让算子具有确定性。需要注意,非确定性行为很容易悄悄溜进来:例如哈希、概率、统计等算法可能用到随机数。因此不能使用系统时钟,且得用固定种子生成伪随机数。

如果中间状态的数据比源数据小得多,那么为应对容错,直接将中间数据物化或许更好。

关于物化的讨论

数据流引擎更像Unix管道:将算子输出增量地传递给其他算子,不待输入完成便开始处理。

大部分工作流的算子以流水线方式执行,但是排序算子不可以——排序需要消费所有输入才能生成任何输出。

作业完成后,他的输出持久化到分布式文件系统,所以,使用数据流引擎时,HDFS上的物化数据集通常仍是作业的输入和输出。和MapReduce一样,输入不可变,输出被完全替换。

3.2 图与迭代处理

之前对于图的讨论集中在OLTP风格的应用场景:快速执行查询来查找少量符合特定条件的顶点(参阅第二章)。

批处理上下文中的图也很有趣,其目标是在整个图上执行某种离线处理或分析。这种需求经常出现在机器学习应用(如推荐引擎)或排序系统中。例如,最着名的图形分析算法之一是PageRank,它根据指向该网页的链接数来估算网页流行度,用于确定网络搜索引擎结果的呈现顺序。

许多图算法将一个顶点与近邻的顶点连接以传播一些信息,不断重复直到满足某些条件——例如直到没有更多边要跟进。可用在分布式文件系统中存储图(包含顶点和边的列表文件),但是这种“重复至完成”的操作普通MapReduce无法表示,因为它只扫过一趟数据。这种算法通常以迭代风格实现:

  1. 外部调度程序运行批处理来计算算法的一个步骤。
  2. 批处理完成后,调度器检查它是否完成
  3. 若尚未完成,则调度程序返回到步骤1并运行另一轮批处理。
Pregel处理模型

批量同步并行(BSP) 计算模型是针对图批处理的优化,它也被称为Pregel模型。Pregel让一个顶点向另一个顶点发送消息,通常这些消息沿着图的边发送。

在每次迭代中,为每个顶点调用一个函数,将所有发送给它的消息传递给他(就像调用Reducer一样)。顶点在一次迭代到下一次迭代的过程中会记住它的状态,所以这个函数只需要处理新的传入消息。如果图的某个部分没有被发送消息,那里就不需要做任何工作。

这与Actor模型有些相似(参阅”Actor“):顶点状态和顶点之间的消息具有容错性和耐久性,且通信以固定方式进行,即每次迭代中,框架递送上次迭代中发送的所有消息。

并行执行

顶点不需要知道它在哪台机器上执行,发送消息时只是将消息发到对应顶点ID。图的分区取决于框架——即确定哪个顶点运行在哪台机器上,以及如何通过网络路由消息让它们到达正确的地方。

由于编程模型一次只处理一个顶点,所以框架可以以任意方式对图分区。若顶点需要进行大量通信,那么它们最好能在同一台机器上,然而找到这样一种优化的分区方法是困难的——实践中,图一般不把顶点分组到一起,通常用ID分区。

因此,图算法通常由很多跨机器通信的额外开销,而中间状态(节点之间发送的消息)往往比原始图大。通过网络发消息会大大降低效率。因此,图可以放入一台计算机内存中,这样会比分布式快很多,例如使用GraphChi等框架。图比内存大也没关系,可以放到磁盘里,但是如果图大到不适合单机,那么Pregel这样的分布式是不可避免的。

3.3 高级API和语言

分布式批处理执行引擎现在已经很成熟了,现在关注点已经转向其他领域:改进编程模型、提高处理效率、扩大这些技术等。

Hive,Pig,Cascading和Crunch等高级语言和API变得越来越流行,随着Tez的出现,这些高级语言还可以迁移到新的数据流执行引擎,而无需重写作业代码。而Spark和Flink也有它们自己的高级数据流API。

这些数据流API通常使用关系型构建块来表达一个计算:按某个字段连接数据集;按键对元组做分组;按某些条件过滤;并通过计数求和或其他函数来聚合元组。这些操作使用本章前面说过的各种连接和分组算法实现。

高级接口还支持交互式用法。我们可以在Shell中增量式编写分析代码,频繁运行来观察它做了什么。高级接口同时提高了人类和机器层面的作业执行效率。

向声明式查询语言的转变

与硬写执行连接的代码相比,指定连接关系算子的优点是,框架可以分析连接输入的属性,并自动决定哪种连接算法最适合当前任务。Hive,Spark和Flink都有基于代价的查询优化器可以做到这一点,甚至可以改变连接顺序,最小化中间状态的数量。

连接算法会大幅影响批处理作业的性能,而查询优化器会决定如何最好的执行连接。如果连接是以声明式方式指定的,我们就不需要知道算法的原理。但是与SQL的完全声明式查询模型有很大区别,MapReduce是围绕着回调函数的概念建立的,在函数里用户可以自由调用代码来决定输出什么,这样可以基于大量已有库的生态系统进行创作。

能否自由运行任意代码,可以看作是MapReduce批处理系统和MMP数据库的区别所在。(参见“比较Hadoop和分布式数据库”)。数据库写函数通常比较麻烦,且和很多语言的依赖管理器兼容不好(比如Maven)。

声明式特性还有其他优势。例如,如果回调函数做的事很少或者只是从一条记录中选择了一些字段,那么为每一条记录调用函数就会浪费大量资源。若以声明方式表示这些简单的过滤和映射操作,那么查询优化器可以用列存储以提高读取效率(参与“列存储”)。

Hive,Spark DataFrames和Impala还使用了向量化执行(参阅“内存带宽和向量处理”):在对CPU缓存友好的内部循环中迭代数据,避免函数调用。Spark生成JVM字节码,Impala使用LLVM为这些内部循环生成本机代码。

在高级API中引入声明式部分和查询优化器,批处理框架看着就像个MMP数据库。同时,通过拥有运行任意代码和以任意格式读取数据的可扩展性,它们十分灵活。

专业化的不同领域

MPP数据库满足了商业智能分析和业务报表的需求。统计和数值分析算法是机器学习所需要的,现在逐渐变得重要。例如,Mahout在MapReduce,Spark和Flink之上实现了用于机器学习的各种算法,而MADlib在关系型MPP数据库(Apache HAWQ)中实现了类似的功能。

空间算法能在一些多维空间中搜索与给定最近的项目,例如最近邻搜索(k-nearest neghbors, kNN)。近似搜索对于基因组分析算法很重要,它们需要找到相似但不相同的字符串。

批处理引擎正被用于分布式执行日益广泛的各领域算法。批处理系统和MMP数据库在互相汲取对方的优点,它们都是用于存储和处理数据的系统。

第十一章 流处理

批处理有一个很大的假设:输入是有界的,即已知和有限的大小,所以批处理知道它何时完成输入的读取。但实际上,很多数据是无界限的,随着时间的推移而逐渐增加。这种情况下,批处理程序必须将数据人为分成固定时间段的数据块来处理。

流处理:为了减少延迟,我们可以更频繁的运行处理,例如在每秒的末尾,或者当事件发生时立即处理。

“流”指随时间推移逐渐可用的数据。这个概念出现在很多地方:Unix的stdin和stdout、文件系统API、TCP连接、互联网传送音视频等。本章中,我们将事件流(event stream) 视为一种数据管理机制:无界限、增量处理。

1 传递事件流

当输入是一个文件(字节序列),首先要将其解析为一系列记录。流处理的上下文中,记录通常称为事件(event) ,本质是:一个小的,自包含的,不可变的对象,包含某个时间点发生的事件的细节。一个事件通常包含一个来自时钟的时间戳,以指明事件发生的时间。

事件可能被编码为文本字符串或JSON或二进制编码。这允许我们将其附加到一个文件、插入关系表、写入文档数据库,也能通过网络将事件发送到另一个节点进行处理。

流处理中,一个事件由生产者(producer) (也称发布者或发送者)生成一次,可能由多个 消费者(consumer) ( 订阅者或接收者 )进行处理。相关事件通常被聚合为一个 主题(topic)流(stream)

原则上讲,文件或数据库就足以连接生产者和消费者:生产者将其生成的每个事件写入数据存储,且每个消费者定期轮询数据存储,检查出新事件。

当我们想要进行低延迟连续处理时,若数据存储不是为这种用途专门设计的,那么轮询开销会很大。轮询越频繁,能返回新事件的请求比例就越低,额外开销也越高。所以,最好能在新事件出现时直接通知消费者。

传统数据库做不好这些事,关系型数据库的触发器可用对变化做出反应,但是功能非常有限。现在已经有专门的工具来提供事件通知。

1.1 消息系统

消息传递系统(messaging system) :用于向消费者通知新事件。

Unix管道或TCP连接这样的直接信道,是实现消息传递系统的简单方法。但是它们只能让一个发送者和一个接收者连接,而消息传递系统允许多个生产者将节点消息发送到同一个主题,并允许多个消费者节点接收主题中的消息。

发布/订阅模式中,不同系统采取了不同的方法,没有通用答案。可以用以下两个问题区分这些系统:

  1. 如果生产者发送消息的速度比消费者能够处理的速度快会发生什么?一般有三种选择:

    • 丢掉消息
    • 将消息放入缓冲队列
    • 使用背压(backpressure) (也称为流量控制,阻塞生产者以免发送更多消息)
  2. 如果节点崩溃或暂时脱机,会发生什么情况? —— 是否会有消息丢失?

直接从生产者传递给消费者

许多消息传递系统使用生产者和消费者之间的直接网络通信,而不通过中间节点,例如UPD和无代理消息库,还有StatsD等使用UDP监控网络中机器的,还有webhooks:当消费者在网络上公开了服务,生产者可以直接发送HTTP或RPC请求将消息推送给使用者(参阅“REST和RPC”)。

这些消息传递系统在设计它们的环境中运行良好,但它们通常要求代码意识到消息丢失的可能性。它们容错程度极为有限:无法知道生产者和消费者是否还在线。

若消费者脱机,则消息会丢失。生产者崩溃时,会丢失消息缓冲区及其本该发送的消息。

消息队列

消息代理(message broker) 也称消息队列(message queue) ,实质上是一种针对处理消息流而优化的数据库。它作为服务器运行,生产者和消费者作为客户端连接到服务器。生产者将消息写入代理,消费者通过从代理那里读取来接收消息。

通过将数据集中在代理上,将持久性问题转移到代理身上,系统可以容忍客户端的频繁断开与连接。消息是否保存在内存中取决于配置,写入磁盘可以让代理在崩溃时不会丢失。针对缓慢的消费者,默认设置下代理一般会允许无上限的排队。

排队会导致异步(asynchronous) :生产者发到代理就不管了,不会等消息被消费者处理。

消息代理与数据库对比

同:有些消息代理能用XA或JTA参与两阶段提交协议。

异:

  • 消息代理会在递给消费者时自动删除消息,而数据库会保留至显示删除。
  • 由于它们很快就能删除消息,所以大多数代理假设它们的队列很短。若消息处理时间变长,整体吞吐量会恶化。
  • 数据库通常支持二级索引和搜索数据,而消息代理只支持按照某种模式匹配主题,订阅其子集。
  • 查询数据库时,结果通常基于某个时间点的数据快照。而消息代理不支持查询,但是当数据发生变化时(即新消息可用时),它们会通知客户端。
多个消费者

多个消费者从同一主题中读取消息时,有使用两种主要的消息传递模式

  • 负载均衡(load balance):每条消息都被传给消费者之一。工作能被多个消费者共享,代理可以为消费者任意分配消息。用于并行处理消息。
  • 扇出(fan-out):每条消息被传给所有消费者。扇出允许消费者收听相同广播而不相互影响。

image-20240127171414834

两种模式可以组合使用:例如,两个独立消费者组可以各订阅一个主题,每个组都共同收到所有消息,但每一组内部,每条消息仅由单个节点处理。

确认与重新支付

消费随时可能崩溃,代理向消费者发送消息,消费者可能因崩溃而没有处理或只做了部分处理。消息代理使用确认来确保消息不丢失:客户端处理完毕时通知代理,然后代理才将消息从队列中移除。若与连接关闭或超时,代理会将消息递送给另一个消费者。(会有特殊情况:确认在网络中丢失,则需要一种原子提交协议)

当与负载均衡相结合时,重传会对消息的顺序发生影响。如图,未确认消息m3随后被发给消费者1,于是消费者1按m4、m3、m5的顺序处理消息,即交付顺序与发送顺序不同。

传递事件流 - 图2

负载均衡不可避免的打乱消息排序,若想按顺序,可以让每个消费者使用单独队列。

1.2 分区日志

不会建立日志:通过网络发送数据包、发送请求,消息代理。

会建立日志:数据库、文件系统。

既有数据库的持久存储,又能有消息传递的低延迟通知:基于日志的消息代理(log-based message brokers)

使用日志进行消息存储

日志只是磁盘上简单的仅追加记录序列(参见第三章-日志结构... 、第五章-复制的上下文)。

这个结构可以用于实现消息代理:生产者通过将消息追加到日志末尾来发送消息,而消费者通过依次读取日志来接收消息。若消费者读到日志末尾,则会等待新消息追加。Unix工具tail -f 能监视文件被追加写入的数据,基本上就是这样工作的。

为提高吞吐量可以对日志进行分区:如图所示,不同分区托管在不同的机器上,且每个分区都拆分出一份独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。

传递事件流 - 图3

每个分区内,代理为每个消息分配单调递增的序列号或偏移量(offset) 。分区是追加写入,所以分区内消息完全有序。但是没有跨不同分区的顺序保证。

Apache Kafka就是基于日志的消息大力。每秒百万条消息吞吐量,通过复制实现容错。

日志与传统消息相比

基于日志的消息代理天然支持扇出,因为多个消费者可以独立读取日志且不相互影响。负载均衡时,代理可以将整个分区分配给某个消费者。

每个客户端消费指派分区中所有消息,然后分配分区中的所有消息。用户会序读取被指派的分区中的消息。这种粗粒度的负载均衡有一些缺点:

  • 共享消费主题工作的节点数,最多为该主题中的日志分区数,因为一个分区对应一个节点。
  • 若某条消息处理得慢,会阻塞后续消息。

我们可以得出结论:

  • 当消息处理代价高昂、需逐条并行处理、顺序不重要时:使用JMS/AMQP风格。
  • 当消息吞吐量高、处理迅速、顺序重要时:使用基于日志的方法。
消费者偏移量

顺序消费可以很简单的判断消息是否被处理:所有偏移量小于消费者的当前偏移量的消息已经被处理。因此,代理不需要跟踪确认每条消息,而只需要定期记录消费者的偏移,这会减少开销和提高吞吐量。

这种偏移量类似于单领导者数据库复制中的日志序列号(参见“设置新从库”):日志序列号允许跟随着断开重连后,不跳过任何写入的情况下恢复复制。原理完全相同——消息代理像个主库,消费者则是从库。

若消费者节点失效,则失效消费者分区将指派给其他节点,并从最后记录的偏移量开始消费消息。但是:当消费者1处理完但是没记录偏移量,那么重启后会发现消费者2也处理了一次,那么消息被处理了两次。

磁盘空间使用

若只追加写入日志,则磁盘空间终究耗尽。日志实际上被分割成段,并不时将旧段删除或移动到归档存储。那么,日志可以看作一个在磁盘上的大缓冲区,缓冲区填满时丢弃旧消息——也被称为循环缓冲区(circular buffer)环形缓冲区(ring buffer) 。实践中的部署很少能用满磁盘的写入带宽,所以通常可以保存几天甚至几周的日志缓冲区。

吞吐量对比:

  • 基于日志的消息代理:吞吐量基本保持不变,因为无论如何消息都会被写入磁盘。
  • 将消息保存在内存中:队列很短则系统很快,开始写入磁盘时变得很慢。
当消费者跟不上生产者

若一个消费者太慢,以至于消费偏移量指向了被删除的段,那么它会错过一些消息。

在“消息传递系统"中说到过,消费者跟不上生产者时有三种方法:丢弃信息、缓冲 或 背压。而日志就是这里的”缓冲“的一种形式。

但若消费者远远落后,以至于磁盘将消息删除的话,我们可以监控消费者落后日志头部的距离,若落后太多就发出报警。由于缓冲区很大,因此有足够时间等到运维人员来。

即使消费者真的开始丢失消息,也不会影响其他消费者,这有利于运维:可以实验性地消费生产日志以进行开发、测试、调试,而不担心中断生产服务。消费者关闭崩溃时会停止消耗资源,只剩下消费偏移量。这与传统消息代理不同,传统的话需要小心删除消费者已经关闭的队列,否则队列就会积累不必要的信息,与其他活着的消费者抢占内存。

重播旧信息
  • AMQP和JMS风格消息代理:处理和确认消息是破坏性操作,会导致消息在代理上被删除。
  • 基于日志的消息代理:使用消息是从文件中读数据,只读而不更改日志。

唯一的副作用是增加消费者偏移量,但是偏移量在消费者控制下可以很容易操纵:我们可以用昨天的偏移量跑一个消费者副本。这方面很像上一章的批处理,衍生数据通过可重复的转换过程与输入数据显示分离。它允许进行更多实验,更容易从错误和漏洞中恢复,使其成为在组织内集成数据流的良好工具。

2 流与数据库

可以从消息传递和流中获取灵感,并应用于数据库。

事件是某个时刻发生的事情的记录,而事实上,复制日志可以看作 数据库写入事件 的流:主库在处理事务时生成,而从库将写入流应用到它们自己的数据库副本。日志中的事件描述发生的数据更改。

本节中,我们讨论如何通过事件流的想法解决异构数据系统中的一个问题。

2.1 保持系统同步

我们知道,没有任何系统能同时满足所有数据存储、查询和处理请求,实践中通常组合几种不同技术来满足所有需求。由于相同或相关数据出现在不同地方,因此相互间要保持同步:如果某个项目在数据库中被更新,它也应当在缓存,搜索索引和数据仓库中被更新。

对于数据仓库,通常用ETL进程执行同步(也就是批处理)。但是,若周期性的完整数据库转储过于缓慢,有时会使用双写(dual write) 来代替。代码在数据变更时明确写入每个系统:例如,先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。

然而,双写会导致并发问题,7通过如下图:俩客户端同时更新X,它们先将新值写入数据库,然后再写入搜索索引。运气不好的是——这些请求的时序是交错的。最终这导致了两个系统的永久不一致。流与数据库 - 图1

除非有一些额外的并发检测机制(参见“检测并发写入”),否则甚至不会意识到发生了并发写入。双写时,还有可能两个写入没有同时成功或失败,这是一个容错问题而不是并发问题,需要原子提交但是代价昂贵。

如果改成单领导者模式——例如数据库是领导者,搜索索引是从库,那么情况会好很多。这在实践中能否实现?

2.2 变更数据捕获

几十年来,数据库根本就没有能获取它日志的方式。复制日志一直被当做数据库的内部实现细节,而非公开的API,即客户端无法通过解析复制日志来提取数据。

于是,变更数据捕获(change data capture, CDC) 诞生了:观察并提取数据库的数据变更,然后将变更转换为可以复制到其他系统中形式。如图,将数据按顺序写入一个数据库,然后按相同顺序将这些变更应用到其他系统。

流与数据库 - 图2

变更数据捕获的实现

可以将日志消费者称为衍生数据系统,即存在搜索索引和数据仓库中的数据,只是记录系统的额外视图。变更数据捕获(CDC)能确保对记录系统做的所有更改都反映在衍生数据系统中。本质上,CDC使得被捕获变化的数据库成为领导者,而基于日志的消息代理适合从源数据库传输变更事件,因为它保留了消息的顺序。

CDC通常是异步的:记录数据库系统不会等待消费者应用变更再进行提交。优点是,添加缓慢的消费者不会过度影响记录系统。缺点是,能产生所有复制延迟问题。

初始快照

如果我们有所有变更日志,那么重放日志可以重建数据库的完整状态。但是日志保留费空间,重放又费时间,所以日志需要被截断。数据库快照必须与日志变更中的偏移量相对应,才能知道从哪里开始应用变更。

日志压缩

若只能保留有限的历史日志,那么每次日志更新都要做一次快照。日志压缩(log compaction) 能解决这个问题。

我们在日志结构存储引擎的上下文中讨论了“Hash索引”中的日志压缩,原理是定期在日志中查找具有相同键的记录,丢掉重复内容,只保留每个键的最新更新。

日志结构存储引擎中,NULL(墓碑(tombstone) )的更新表示改键被删除,它会在日志压缩过程中被移除。但只要键不被覆盖或删除,它就会永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容——之前的值会被覆盖。

这个想法也适用于基于日志的消息代理,以及变更数据捕获的上下文。我们可以使用它来获取数据库的完整副本,而无需从CDC源数据库取一个快照。Kafka支持这种日志压缩功能,它允许消息代理被当成持久性存储使用而不是临时消息。

变更流的API支持

数据库开始将变更流作为第一类接口,而不是费工夫逆向工程一个CDC。

例如RethinkDB允许订阅通知,VoltDB允许以流的形式连续从数据库中到处数据,Kafka Connect将CDC与Kafka集成。

2.3 事件溯源

事件溯源( Event Sourcing) 包含了一些关于流处理系统的有用想法。这是一个诞生于 领域驱动设计(domain-driven design, DDD) 社区中的技术。

事件溯源将所有对应用状态的变更存储为变更事件日志。与变更数据捕获(CDC)最大的区别就是将这一想法应哟到了不同抽象层次上:

  • CDC中,应用以可变方式(mutable way) 使用数据库,任意更新和删除记录。变更日志从数据库底层获取,以确保日志中的写入顺序是对的。写入数据库的应用不需要知道CDC的存在。
  • 在事件溯源中,应用逻辑显式构建在写入事件日志的不可变事件上。在这种情况下,事件存储是仅追加写入的,更新与删除是不鼓励的或禁止的。事件被设计为旨在反映应用层面发生的事情,而不是底层的状态变更。

事件源将用户行为记录为不可变的事件,而不是在可变数据库中记录这些行为的影响。事件代理使得应用随时间演化更为容易,通过事实更容易理解事情发生的原因,这利于调试和防止应用Bug。

事件溯源类似于编年史(chronicle) 数据模型,事件日志与星型模式中的事实表之间也存在相似之处。

从事件日志中派生出当前状态

事件日志本身没什么用,因为用户不需要变更历史。因此,使用事件溯源的应用需要拉取事件日志,然后转换为适合向用户显示的应用状态。转换必须是确定的,以便再次运行能产生相同的应用状态。

与CDC一样,重放时间日志允许重新构建系统当前状态。不过,日志压缩需要采用不同方式处理:

  • 用于记录更新的CDC事件通常包含记录的完整新版本,因此主键的当前值完全由该主键的最近事件确定,而日志压缩可以丢弃相同主键的先前事件。
  • 事件溯源在更高层次进行建模:事件通常表示用户操作的意图,而不是因为操作而发生的状态更新机制。

使用事件溯源的应用通常有些机制,能从事件日志中导出当前状态快照,因此它们不需要重复处理完整日志,这可以加速读取,提高崩溃恢复速度。

命令与事件

事件溯源的哲学是仔细区分事件(event)命令(command) 。来自用户的请求到达时,一开始是一个命令,应用得先验证它是否可以执行该命令,验证成功才变为一个持久化不可变的事件

事件生成的时刻,它就成为了事实(fact) 。事件流的消费者不允许拒绝事件:当消费者看到事件时,它已经是日志中不可变的一部分。因此,对任何命令的验证,都需要在它成为事件之前同步完成。例如通过一个可自动验证命令的可序列化事务来发布事件。

状态,流和不变性

批处理中提到过输入文件不变性,而不变性原则使得事件溯源与变更数据捕获十分强大。我们通常将数据库视为应用程序当前状态的存储,状态的本质是,它会变化,所以数据库才会支持数据的增删改。这又是如何符合不变性的呢?

只要你的状态发生了变化,那么这个状态就是这段时间中事件修改的结果。是一系列不可变的事件导致了状态的变化,因此可变状态与不可变事件的仅追加日志之间并不矛盾。变化日志(change log) ,表示了随时间演变的状态。

用数学表示,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果。如图

流与数据库 - 图3

如果你持久存储了变更日志,那么重现状态就非常简单。如果你认为事件日志是你的记录系统,而所有的衍生状态都从它派生而来,那么系统中的数据流动就容易理解的多。正如帕特·赫兰(Pat Helland)所说的:

  • 事务日志记录了数据库的所有变更。高速追加是更改日志的唯一方法。从这个角度来看,数据库的内容其实是日志中记录最新值的缓存。日志才是真相,数据库是日志子集的缓存,这一缓存子集恰好来自日志中每条记录与索引值的最新值。

而日志压缩是连接日志与数据库状态之间的桥梁:只保留每条记录的最新版本,丢弃被覆盖的版本。

不可变事件的优点

可审计性在金融系统中尤其重要,会计当然不能修改以前资金。

错误代码破坏数据库时,使用不可变的仅追加日志可以很容易的进行故障恢复。

从同一事件日志中派生多个视图

可以针对不同的读取方式,从相同的事件日志中衍生出不同的表现形式。效果就像一个流的多个消费者一样。例如,分析型数据库Druid用这种方式直接从Kafka摄取数据,Pistachio是一个分布式的键值存储,使用Kafka作为提交日志,Kafka Connect能将来自Kafka的数据导出到各种不同的数据库与索引。这对于其他存储系统、索引系统、从分布式日志中获取输入 来说十分重要。

添加从事件日志到数据库的显示转换,能使应用更容易随时间演进:可以使用事件日志构建一个单独的,针对新功能的读取优化视图,无需修改现有系统而与之共存。并行运行新旧系统通常比模式迁移更容易:当我们不需要旧系统时直接关闭并回收资源即可。

数据库都希望支持某些特定查询和访问模式,这导致很多模式设计,索引和存储引擎的许多复杂性。因此,将数据的读写形式分离,并允许几个不同的读取视图,可以获得很大的灵活性。这就是命令查询责任分离(command query responsibility segregation, CQRS) 。此外,针对读取进行优化时,“读取优化的视图中的数据”可以和“写入数据库时的数据”的形式不同,因为翻译过程有使其与事件日志保持一致的机制。

并发控制

事件溯源和变更数据捕获的最大缺点是:事件日志的消费者通常是异步的,这可能导致”读己之写“的问题(参见“读己之写”)。例:用户写入日志,然后从日志衍生视图中读取,结果发现他的写入还没有反映在读取视图中。

解决方案:将事件附加到日志时同步执行读取视图的更新。将这些写入操作合并为一个原子单元需要事务,所以只能将事件日志和读取视图保存在同一个存储系统中,或是跨不同系统进行分布式事务,亦或是使用全序广播(参见“全序广播实现线性化存储”)。

从事件日志导出当前状态简化了并发控制的某些部分。许多对于多对象事务的需求(参阅“单对象和多对象操作”)源于单个用户操作需要在多个不同的位置更改数据。通过事件溯源,你可以设计一个自包含的事件以表示一个用户操作。然后用户操作就只需要在一个地方执行单次写入操作——即将事件附加到日志中——这是很容易原子化的。

如果事件日志与应用状态以相同的方式分区(例如,处理分区3中的客户事件只需要更新分区3中的应用状态),那么直接使用单线程日志消费者就不需要写入并发控制了,因为它从设计上一次只处理一个事件(参阅“真的的串行执行”)。日志通过在分区中定义事件的序列顺序,消除了并发性的不确定性。如果一个事件触及多个状态分区,那么需要做更多的工作,我们将在第12章讨论。

不变性的限制

许多不使用事件溯源模型的系统也依赖不可变性:数据库中用来支持时间点快照,Git也是依靠不可变数据来保存版本历史记录。

能否永远保持所有变更的不变历史,取决于数据集的流失率:如果更新/删除率高的话,不可变的历史可能增至难以接受的巨大,碎片化就会成为问题,这时就需要压缩与垃圾收集。

除性能外,还有管理等方面因素需要删除数据,例如隐私条例要求自动删除个人信息。而在这种情况下,只把数据标记为删除是不够的——我们是想假装数据一开始没有写入。例如,Datomic管这个特性叫切除(excision) ,而Fossil版本控制系统有一个类似的概念叫避免(shunning)

真正删除数据非常困难,因为副本存在于很多地方:存储引擎、文件系统、SSD通常会向一个新位置写入,而不是原地覆盖旧数据,而备份通常是不可变的,防止意外删除或损坏。删除更多的是“使取回数据更困难”,而不是“使取回数据不可能”。

3 流处理

目前为止,本章讨论了流的来源和流如何传输。这一节来讨论我们可以用流做什么,即怎么处理它。一般来说有三种选项:

  1. 将事件中的数据写入存储系统(数据库、缓存、搜索索引等)。这能很好的让数据库与系统的其他部分保持同步。
  2. 将事件推送给客户,或将事件流式传输到可实时显示的仪表板上。这种情况下,人是流的最终消费者。
  3. 可以处理一个或多个输入流,并产生一个或多个输出流。流可能经过以上两个过程组成的流水线,最后再输出。

本章的剩余部分将讨论上述选项3:处理流以产生其他衍生流。处理这样的流的代码成为算子(operator)作业(job) 。它与MapReduce模式相似:输入流只读,输出流仅追加;使用分区和并行化模式;基本的Map操作(转换、过滤)。

流处理与批处理相比,一个关键的区别是:流不会结束。这会带来很多差异:

  • 无法排序:无法使用排序合并联接。
  • 容错机制改变:无法重跑作业。

3.1 流处理的应用

流处理长期被用于监控。例如:

  • 欺诈检测系统,确定信用卡、账号使用模式是否意外变化,检测盗刷盗号。
  • 交易系统检查金融市场的价格变化,根据指定的规则进行交易。
  • 制造系统监控工厂中机器的状态。
  • 军事和情报系统跟踪潜在侵略者的活动,袭击征兆时发出警报。

随着时代进步,流处理的其他用途开始出现。

复合事件处理

复合事件处理(complex, event processing, CEP) 允许指定规则以再流中搜索某些事件模式。用于分析事件流,尤其适用于需要搜索某些事件模式的应用。

CEP系统通常用更高层次的声明式查询语言(比如SQL)或图形用户界面来描述应该检测到的事件模式。引擎在内部维护一个执行所需匹配的状态机,发现匹配时发出一个复合事件(complex event) ,并附有检测到的事件模式详情。

CEP中,查询和数据之间的关系与普通数据库相比是颠倒的:通常情况下,数据库持久存储数据并临时进行查询,而CEP引擎是长期查询的,来自输入流的事件不断流过它们,搜索匹配事件模式的查询。

流分析

CEP与流分析之间的边界是模糊的,分析一般不关注找出特定事件序列,而是关注大量事件上的聚合与统计指标:

  • 测量某种类型事件的速率(每个时间间隔内发生的频率)
  • 滚动计算一段时间窗口内某个值的平均值
  • 将当前的统计值与先前的时间区间的值对比(例如,检测趋势,当指标与上周同比异常偏高或偏低时报警)

一般在固定时间区间内计算,几分钟内区平均,能抹平秒和秒之间的无关波动,且仍然能向你展示流量模式的时间图景。聚合的时间间隔称为窗口(window) ,将在接下来详细讨论。

有时会使用概率算法进行优化,减少内存使用。同时这不会损失精确:流处理没有任何内在的近似性。

许多开源分布式流处理框架针对分析设计:Apache Storm、Spark Streaming等。

维护物化视图

前面提到,数据库的变更流可以用于维护衍生数据系统(如缓存,搜索索引和数据仓库),使其与源数据库保持最新(参见”数据库和数据流“)。我们可以将这些示例视作维护物化视图(materialized view) 的一种具体场景(参见”聚合:数据立方体和物化视图“):在数据集上衍生一个视图以便高效查询,当底层数据变更时更新视图。

事件溯源中,应用程序的状态是通过应用(apply) 事件日志来维护的,这里的应用状态也是一种物化视图。与CEP不同的是,只考虑某个时间窗口内的事件是不够的,构建物化视图需要所有事件,需要一个一直延伸到时间开端的窗口。

原则上讲,任何流处理组件都能用于维护物化视图,尽管“永远运行”与一些面向分析的框架假设的“主要在有限时间段窗口上运行”背道而驰。Kafka Streams支持这种用法,建立在Kafka对日志压缩comp的支持上。

在流上搜索

除了允许搜索由多个事件构成模式的CEP外,有时也存在基于复杂标准(例如全文搜索查询)来搜索单个事件的需求。

例如,媒体监测服务可以订阅新闻、搜索新闻。原理是先构建一个搜索查询,然后不断将新闻项的流与该查询进行匹配。

传统的搜索引擎先索引文件,再在索引文件上跑查询。而搜索数据流则相反(与CEP相似):查询被存储下来,文档从查询中流过。

消息传递和RPC

消息传递系统可以作为RPC的替代方案(参见“消息传递数据流”),即作为一种服务间通信的机制,就像在Actor模型中使用的那样。尽管这些RPC类系统也基于消息和事件,且与流处理间有交叉领域,但通常不视作流处理组件:

  • Actor框架主要是管理模块通信的并发和分布式执行的一种机制,而流处理主要是一种数据管理技术。
  • Actor之间的交流往往是短暂的,一对一的;而事件日志则是持久的,多订阅者的。
  • Actor可以以任意方式进行通信(允许包括循环的请求/响应),但流处理通常配置在无环流水线中,其中每个流都是一个特定作业的输出,由良好定义的输入流中派生而来。

也可以用Actor框架处理流。但是它的容错很低,崩溃时不能保证消息传递,除非实现了额外的重试逻辑。Apache Storm有分布式RPC功能,允许用户查询分散到一系列也是处理事件流的节点上,这些查询与来自输入流的事件交织,将结果汇总并发回给用户。

3.2 时间推理

流处理通常需要和时间打交道,尤其是用于分析目的的时候,会频繁使用时间窗口。而”最后五分钟“的含义其实是非常棘手的。

批处理中,大量历史时间迅速收缩,可以在几分钟内读取一年的历史时间。批处理检查每个事件中嵌入的时间戳,时间戳固定使得使得处理是确定性的,相同的输入产生相同的结果。

流处理中,许多框架使用本地系统时钟(处理时间(processing time) )来确定窗口。这种方法的优点是简单,事件创建与事件处理之间的延迟可以忽略不计。然而,当有任何显著的处理延迟时,处理就失效了。

事件时间与处理时间

很多原因都可能导致处理延迟:排队,网络故障(参阅“不可靠的网络”),性能问题导致消息代理/消息处理器出现争用,流消费者重启,重新处理过去的事件(参阅“重放旧消息”),或者在修复代码BUG之后从故障中恢复。消息延迟还会导致无法预测消息顺序,流处理算法需要专门编写,以适应这种时机与顺序的问题。

将事件时间和处理时间搞混会导致错误的数据。当重新部署流处理器时,流处理器会停止一小段时间,并在恢复后处理积压时间。如果按照处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的

流处理 - 图1

知道什么时候准备好了

用事件时间定义窗口,会导致永远无法确定是否受到特定窗口的所有事件(是否还有事件在来的路上)。例如,将事件分组为1分钟的接口以统计每分钟的请求数,就算现在进入的主要都是第二分钟和第三分钟的事件,我们也无法确定第一分钟的事件全部收到了。

我们需要能处理这种在窗口宣告完成后到达的滞留(straggler) 事件,大致有两种选择:

  1. 忽略滞留事件,因为正常情况下它们只是事件中的一小部分。可以将丢弃事件的数量作为一个监控指标,当大量丢消息时报警。
  2. 发布一个更正(correction) ,一个包括滞留事件的更新窗口值。更新的窗口与包含散兵队员的价值。你可能还需要收回以前的输出。
你用的是谁的时钟?

当事件可能在系统内多个地方缓冲时,为事件分配时间戳变得困难。应用可能在脱机时使用,重新连上网时才上报所有事件,对于这个流的消费者来说来说,它们像是延迟极大的滞留事件。这种情况下:

  • 有意义的时间戳应该是设备上用户交互的时间,但是用户的时钟通常是不可信的(参照”时钟同步与准确性“)。
  • 服务器时钟在描述用户交互方面意义不大。

要校准不正确的设备时钟,一种方法是记录三个时间戳:

  • 事件发生的时间,取决于设备时钟
  • 事件发送往服务器的时间,取决于设备时钟
  • 事件被服务器接收的时间,取决于服务器时钟

通过从第三个时间戳中减去第二个时间戳,可以估算设备时钟和服务器时钟之间的偏移,然后将其应用于事件时间戳,从而估计事件实际发生时间。

批处理也有这样的时间问题,但是在流处理的上下文中,我们更容易意识到时间的流逝。

窗口的类型
  • 滚动窗口(Tumbling Window):固定长度,每个事件只能属于一个窗口。例如,一个一分钟的滚动窗口,第一个在0:1:00-0:1:59,第二个在0:2:00-0:2:59
  • 跳动窗口(Hopping Window):固定长度,允许窗口重叠以提供一些平滑。例如,一个跳跃1分钟步长5分钟的窗口,第一个窗口覆盖0:1:00-0:4:59分,第二个覆盖2:00-6:59分。
  • 滑动窗口(Sliding Window):包含彼此间距在特定时长内的所有事件,边界是移动的。例如,有一个5分钟的滑动窗口,随着时间推进,窗口内5分钟之前的事件会被不断移除,而新事件不断加入。
  • 会话窗口(Session Window):没有固定持续时间。将同一用户出现时间相近的所有事件分组在一起,当用户一段时间没有活动时窗口结束。会话切分是网站分析的常见需求。

3.3流式连接

批处理通过键来连接数据集(详见第十章),这种连接是数据管道的重要组成部分。流处理将数据管道泛化为对无限数据集进行增量处理,因此对流进行连接的需求也完全相同。然而,新事件随时可能出现在一个流中,这使得流连接比批处理连接更具有挑战性。

流流连接(窗口连接)

假设网站要找出搜索URL的趋势。进行搜索时,记录这个包含查询及其返回结果的事件。每当有人点击一个搜索结果时,记录另一个点击事件。为了计算搜索结果中每个URL的点击率,需要将搜索动作与点击动作的事件的两个流联接在一起,这些事件通过相同的会话ID进行连接。

可能会产生以下情况:

  • 用户丢弃了搜索结果(啥都没点就把网页关了):点击事件则永远不会发生。

  • 搜索结果没有被丢弃:

    • 搜索与点击之间的时间是高度可变的:一般是几分钟内,但也可能长达几天(即用户没关网页 ,一段时间后重新回到浏览器页面上,并点击了一个结果)。
    • 网络延迟:点击事件可能比搜索事件先到达。这时,可以选择合适的窗口,例如连接点击和搜索间隔一小时内的事件。

为了实现这种链接,流处理器需要维护状态:例如,按会话ID搜索最近一小时内发生的所有事件。无论何时发生搜索事件或点击事件,都会被添加到合适的索引中,而流处理器也会检查另一个索引是否有具有相同会话ID的事件到达。若有匹配事件就会发出一个表示搜索结果被点击的事件;如果搜索事件直到过期都没看见有匹配的点击事件,就会发出一个表示搜索结果未被点击的事件。

流表连接(流扩展)

可以用数据库的信息来扩充(enriching) 活动事件。例如流入用户ID,输出的时候将用户ID扩展为用户的档案信息。为了执行此联接,流处理器应为每个活动事件在数据库中查找其对应ID,然后将获得到的用户信息添加到活动事件中。

  • 通过查询远程数据库来实现,但可能很慢甚至导致数据库过载。
  • 将数据库副本加载到流处理器中本地查询。这与在”Map端连接“中讨论的散列连接十分相似:如果数据库本地副本足够小,则可以是内存中的散列表,比较大的话也可以是本地磁盘上的索引。

与批处理相比:批处理作业使用数据库的时间点快照作为输入,而流处理器长时间运行且数据库内容随时间而改变,所以流处理器数据库的本地副本需要保持更新。这可以通过变更数据捕获来解决档案更新,因此我们有了两个流的连接:活动事件和档案更新。

流表连接与流流连接最大的区别在于——对于表的变更日志流,连接了一个可以回溯到”时间起点“的窗口(概念上是无限的窗口),新版本的记录会覆盖更早的版本。

表表连接(维护物化视图)

在”描述负载“中说过,用户要查看他们主页时间线时,迭代用户所关注人群的推文合并它们是一个开销巨大的操作。我们可以用一个时间线缓存:一种每个用户的“收件箱”,在发送推文的时候写入,读取时间线时简单地查询即可。物化与维护这个缓存需要处理以下事件:

  • 用户发送推文时,将推文添加到每个关注该用户的时间线上。
  • 用户删除推文时,从所有用户的时间表中删除。
  • 用户a关注b时,a最近的推文添加到b的时间线上。
  • 用户a取消关注b时,a将推文从b的时间线中删除。

这需要两个事件流:推文事件流、关注事件流。维护一个数据库:包含每个用户的粉丝集合,以便知道当一个新推文到达时,需要更新哪些时间线。

可以说,这个流处理维护了一个连接了这两个表(推文与关注)的物化视图,时间线其实是这个视图的缓存,每当基础表发生变化时都会更新。这个物化连接的变化甚至遵循乘积法则,将一个连接看成u*v,会发现 (u·v)’= u’v + uv’(u·v)’= u’v + uv’ 。任何推文的变化量都与当前的关注联系在一起,任何关注的变化量都与当前的推文相连接。

连接的时间依赖性

这三种连接有很多共通之处:都需要流处理器维护连接一侧的一些状态,当连接另一侧的消息到达时查询该状态。

时许依赖很重要——用于维护状态的事件顺序会出现在很多地方。而分区日志中,单个分区内的事件顺序是保留下来的,但跨分区就不一定。这就会产生问题:如果不同流中的几个事件几乎同时发生,应该按照什么顺序处理?流表连接中用户档案更新时,哪些应该连接新档案?

由于跨越流的事件顺序未定,连接是不确定的:输入上次相同的作业不一定得到相同的结果。在数据仓库中,这个问题被称为缓慢变化的维度(slowly changing dimension, SCD) ,通常通过对特定版本的记录使用唯一的标识符来解决。这使得连接变为确定,但会导致日志压缩无法进行:表中所有的记录版本都需要保留。

3.4 容错

流处理是如何处理容错的?第10章我们提到,批处理容错很强:MapReduce作业中任务失败,很简单地在另一台机器上再次启动,并丢弃失败任务的输出。因为输入不可变,输出写入HDFS中,而输出仅在任务成功完成后可见。

流处理中出现了同样的容错问题,但处理起来没那么直观:无法处理完一个无限的流,所以无法等待某个任务完成后再使其输出可见。

微批量与存档点

将流分解成小块,并像微型批处理一样处理每个块。这种方法被称为微批次(microbatching) ,它被用于Spark Streaming。批次大小通常为1秒,因为批次更小则调度和协调的开销大,批次越大延迟越大。微批次相当于一个与批次大小相等的滚动窗口,作业需要更大的窗口时需要显式地将状态转移到下一个批次。

Apache Flink使用了存档点。若流算子崩溃,则从最近的存档点重启,并丢弃从最近的存档点到崩溃之间的所有输出。存档点会由消息流中的 壁障(barrier) 触发,类似于微批次之间的边界,但不会强制一个特定的窗口大小。

流处理框架的范围内,微批次与存档点方法提供了与批处理一样的恰好一次语义。但是,只要输出离开流处理器,框架就无法抛弃失败批次的输出。这种情况下,重启失败任务会导致外部副作用发生两次,微批量和存档点不足以阻止这一问题。

原子提交再现

为在出现故障时表现出恰好处理一次的样子,要确保事件的所有输出当且仅当成功才生效。输出包括:发送给下游算子、发送给外部消息传递系统、数据库写入、对变更算子状态、确认输入的消息。这些事情要么原子性发生,要么不发生,但它们不应该失去同步,我们在分布式事务和两阶段提交的上下文中讨论过。

幂等性

我们的目标是丢弃任何失败任务的部分输出,以便安全重试而不会生效两次。分布式事务是实现这个目标的一种方式,另一种方式是依赖幂等性(idempotence)

幂等,即用户对于同一操作,发起一次请求和发起多次请求的结果是一致的。幂等操作是一种实现恰好一次语义的有效方式,额外开销很小。

幂等,即用户对于同一操作,发起一次请求和发起多次请求的结果是一致的。不是幂等的操作往往也可以通过一些额外元数据做成幂等。例如Kafka消息带有一个偏移量,写入外部数据库时带上偏移量,可以判断一条更新是否执行过,避免重复执行。

当从一个处理节点故障切换到另一个节点时,可能需要进行防护(fencing) ,以防止被假死节点干扰。

失败后重建状态

任何需要状态的流处理,都必须确保失败后能恢复状态。有两种方法:

  • 将状态保存在远程数据存储中,会很慢

  • 在流处理器本地保存状态。流处理器从故障状态恢复时,新任务读取状态副本,恢复处理而不丢失数据。

    • Flink定期捕获算子状态的快照,写入HDFS
    • Samza和Kafka Streams将状态变更发送到具有日志压缩功能的专用Kafka主题(有点像变更数据捕获)

某些情况下甚至不需要复制状态,因为它可以从输入流重建。如果状态从相当短的窗口中聚合而成,那么能很快地重放该窗口中的输入事件。若状态是通过变更数据捕获来维护的数据库本地副本,也可以从日志压缩的变更流中重建数据库(参阅”日志压缩“)。

所有权衡取决于底层基础架构的性能特征:某些系统中,网络延迟可能小于等于磁盘访问延迟。

第十二章 数据系统的未来

本章放眼未来,提出作者的一些想法和方法。

1 数据集成

本书一直强调,对于任何给定的问题都会有好几种解决方案,所有这些解决方案都有不同的优缺点与利弊权衡。对于不同的具体环境,总会有不同的合适方法,软件实现通常必须选择一种特定的方法。

软件工具的最佳选择也取决于情况,每一种软件都是针对特定使用模式设计的,供应商不会告诉我们他软件的工作负载。我们需要弄清软件与其适用环境的映射关系。并且,复杂的应用中数据的用法花样百出,不太可能存在适用于所有不同数据应用场景的软件,因此需要拼凑不同软件来提供应用所需功能。

1.1 组合使用衍生数据的工具

为处理任意关键词的搜索查询,经常将OLTP数据库与全文搜索索引集成在一起,以获得更复杂的搜索能力。而搜索索引通常不适合作为持久的记录系统,因此许多应用需要组合这两种工具。

随着数据不同表示形式的增加,集成问题越来越困难(参阅”使系统保持同步“)。我们可能需要:在分析系统中维护数据副本、维护从原始数据中衍生的缓存、将数据灌入机器学习或推荐系统中、基于数据变更发送通知。

对于数据执行的操作来说,范围极其宽广,我认为鸡肋而毫无意义的功能可能是别人的核心需求。当我们拉高视角,考虑跨越整个组织范围的数据流时,数据集成的需求往往变得明显起来。

理解数据流

需要在多个存储系统中维护相同数据的副本以满足不同访问模式时,要对输入和输出了如指掌:数据来源、格式、去向。可能要先将数据写入记录数据库系统,捕获对该数据库所做的变更。

如果可以通过单个系统提供所有用户输入,从而决定所有写入顺序,则可以更容易衍生出其他数据表示。这是状态机复制方法的一个应用。我们可以看到,在全序广播中,无论使用变更数据捕获还是事件源日志,都不如全局顺序共识重要。

基于事件日志来更新衍生数据系统,通常可以做到确定性与幂等性。

衍生数据与分布式事务

保持不同数据系统彼此一致的经典方法涉及分布式事务,与之相比,使用衍生数据系统的方法如何?

分布式事务通过进行互斥来决定写入顺序(参阅”两阶段锁定“),而CDC和事件溯源使用日志进行排序。分布式事务使用原子提交来确保变更只生效一次,而基于日志的系统通常基于确定性重试幂等性

最大不同在于:事务系统通常提供线性一致性(保证了读己之写等)。而衍生数据系统通常异步更新,因此默认不会提供相同的时序保证。

XA的容错能力差到限制了实用性(参阅”实践中的分布式事务“)。未来需要有更好的分布式协议。在没有好协议的情况下,基于日志的衍生数据是集成不同数据系统的最有前途的方法。

全局有序的限制

对于足够小的系统,可以构建一个完全有序的事件日志。随着体量增大会开始出现问题:

  • 大多数情况下,构建完全有序日志,需要所有事件汇集到单个领导节点。为了提高吞吐量而分割到多台计算机上,会使分区中事件顺序不明确。
  • 若服务器分布在多个地理位置分散的数据中心上,网络延迟会导致同步的跨数据中心协调效率低下。源自两个数据中心的事件顺序未定义。
  • 应用部署为微服务时,一般将每个服务器及其持久状态作为独立单元进行部署,服务之间不共享持久状态。两个事件来自不同服务时,事件顺序未定义。
  • 某些应用在客户端保存状态,该状态在用户输入时立即更新,甚至可以继续脱机工作。有了这样的应用程序,客户端和服务器可能以不同顺序看到事件。

决定事件的全局顺序称为全序广播,相当于共识。大多数共识算法是针对单个节点吞吐量足以处理整个事件流的情况而设计的,无法给多个节点共享事件排序。未来需要能在分布式环境、大于单个节点的吞吐量的环境中,仍工作良好的共识算法。

排序事件以捕捉因果关系

事件间不存在因果关系的情况下,缺乏全局顺序是个小问题。但因果关系可能以更微妙方式出现,例如:好友关系与消息存储在不同地方,删除好友事件与发送消息事件之间因果依赖丢失。服务可能在删除好友事件之前处理发送消息事件,导致向被删除的好友发送信息。

  • 时间戳可以提供无需协调的全局顺序。但仍要求收件人处理不按顺序发送的事件,且需要传输其他元数据。
  • 若能记录一个事件来记录用户在做出决定前看到的系统状态,并给该事件一个唯一标识符,那么后面任何事件都可以引用该事件标识符来记录因果关系。(参阅”读也是事件“)
  • 冲突解决算法有助于处理以意外顺序传递的事件。(参阅”自动冲突解决“)

1.2 批处理与流处理

数据集成的目标是,确保数据最终能在所有正确地方表现出正确形式。这样做需要消费输入,转换,连接,过滤,聚合,训练模型,评估,以及最终写出适当的输出。批处理和流处理是实现这一目标的工具。批处理和流处理的输出是衍生数据集,例如搜索索引,物化视图,向用户显示的建议,聚合指标等。

批处理和流处理有许多共同原则,根本区别在于:流处理的数据集无限,而批处理的输入有限大小。处理引擎的实现方式上的区别已经开始模糊。

维护衍生状态

批处理有很强的函数式风格:鼓励确定性的纯函数,输出仅依赖输入且没有副作用,输入不可变输出仅追加。流处理与之类似,但扩展了算子以允许受管理的、容错的状态。

管道的思维是有帮助的:将函数视为一个东西衍生出另一个的数据管道,将一个系统的状态变更推送至函数式应用代码中,并将其效果应用至衍生系统中。利于容错且简化数据流推理。

虽然衍生数据系统可以同步地维护,但是异步是基于事件日志的系统文件稳健的原因:它允许系统一部分故障被抑制在本地,而如果任何一个参与者失败,分布式事务将中止。即通过将故障传播到系统其余部分来放大故障。

二级索引经常跨越分区边界(参阅”分区与次级索引“)。索引按照关键词分区时,需要将写入发送到多个分区或将读取发送到所有分区(索引按文档分区时)。若索引是异步维护的,这种交叉分区通信也是最可靠和最可扩展的。

应用演化后重新处理数据

维护衍生数据时,批处理和流处理都是有用的。流处理允许将输入中的变化低延迟反映在衍生视图中,批处理允许重新处理大量积累的历史数据以便将新视图导出到现有视图上。

重新处理现有数据,不仅使系统更好的演化并支持新功能和需求变更(参阅第四章),还可以将数据集重组为一个完全不同的模型,以便更好地满足新要求。

衍生视图允许渐进演化(gradual evolution) 。如果你想重新构建数据集,不需要执行迁移(例如突然切换),可以将旧架构和新架构并排维护为相同基础数据上的两个独立衍生视图,从而进行金丝雀发布

Lambda架构

若批处理用于重新处理历史数据,并且流处理用于处理最近更新,那么如何将这两者结合起来?Lambda架构是这方面的一个建议。

Lambda架构的核心思想是通过将不可变事件附加到不断增长的数据集来记录传入数据,这类似于事件溯源。为了从这些事件中衍生出读取优化的视图,架构并行运行了批处理和流处理系统。

流处理器消耗事件并快速生成对视图的近似更新;批处理器稍后将使用同一组事件并生成衍生视图的更正版本。这么设计的原因是,批处理简单且不易出错,流处理器不靠谱且难以容错。而且,流处理可以使用快速近似算法,批处理可以用较慢的精确算法。

Lambda架构将数据系统设计变得更好,它的原则是:不可变事件流上建立衍生视图,并在需要时重新处理事件。

但它也有一些实际问题:

  • 在批处理和流处理框架中维护相同的逻辑是显著的额外工作。
  • 流管道和批处理各自产生独立的输出,需要合并它们以响应用户请求。
  • 在大型数据集上重新处理整个历史数据集开销巨大。批处理流水线通常要设置为处理增量批处理而不是重新处理所有内容。而增加批量计算会增加复杂性,使其更像流式传输,这与批处理保持尽可能简单的目标背道而驰。
统一批处理和流处理

Lambda架构的升级使得批处理计算和流计算在同一个系统中实现。这需要以下功能:

  • 通过处理最近事件流的相同处理引擎来重放历史事件的能力。
  • 对于流处理来说,恰好一次语义与批处理一样,这需要丢弃任何失败任务的部分输出。
  • 按事件时间进行窗口化的工具,而不是按处理时间进行窗口化。因为处理历史事件时,处理时间毫无意义。

2 分拆数据库

在最抽象的层面上,数据库、Hadoop和操作系统发挥相同的功能:存储一些数据,允许处理和查询这些数据。它们之间的相似和差异值得探讨。

Unix和关系数据库以非常不同的哲学来处理信息管理问题。Unix目的是提供一种相当低层次的硬件的逻辑抽象,而关系数据库则希望提供一种高层次的抽象,以隐藏磁盘上数据结构的复杂性、并发性、崩溃恢复等等。Unix发展出的管道和文件只是字节序列,而数据库发展出了SQL和事务。

Unix是”简单的“,它是硬件资源相当薄的包装;关系数据库是”更简单“的,因为一个简短的声明性查询可以利用很多强大的基础设施而查询者不需要理解实现细节。

2.1 组合使用数据存储技术

本书中讨论了数据库提供的各种功能及其工作原理,包括:

  • 次级索引(参阅”其他索引结构“)
  • 物化视图(参阅”聚合:数据立方体和物化视图“)
  • 复制日志(参阅”复制日志的实现“)
  • 全文搜索引擎(参阅”全文搜索和模糊索引“)

在10、11章中讨论了类似主题:如何构建全文搜索索引(参阅”批处理工作流的输出“)、了解有关实例化视图维护(参阅”维护实例化视图“)、有关将变更从数据库复制到衍生数据系统(参阅”变更数据捕获“)。数据库中内置的功能与人们用批处理和流处理器构建的衍生数据系统似乎有相似之处。

创建索引

数据库创建索引的过程类似于设置新的从库副本(参阅"设置新的数据副本"),也非常类似于流处理系统中的引导(bootstrap) 变更数据捕获(参阅”初始快照“)。

无论何时创建索引,数据库都会重新处理现有数据集(参阅”重新处理应用程序数据的演变数据“),并将该索引作为新视图导出到现有数据上。现有数据可能是状态的快照,而不是所有发生变化的日志,但两者密切相关(参阅”状态,数据流和不变性“)。

一切的元数据

整个组织的数据流像一个巨大的数据库,每当批处理、流或ETL过程将数据从一个地方传输到另一个地方并组装时,它表现地就像数据库子系统一样,使索引或物化视图保持最新。

从这个角度讲,批处理和流处理器就像触发器、存储过程和物化视图维护例程的精细实现,它们维护的衍生数据系统就像不同的索引类型。现在的衍生数据系统让它们由不同软件提供,运行在不同机器上,由不同团队管理。

有两种途径或许能将不同的存储和处理工具合成一个有凝聚力的系统:

  • 联合数据库:统一读取

    • 这是一种多态存储方法,能解决跨多个不同系统的只读查询问题。
    • 为各种各样的底层存储引擎和处理方法提供一个统一的查询接口。
    • 应用程序仍能访问底层存储引擎,组合来自不同位置的数据的用户可以通过联合接口轻松完成操作。
    • 联合接口遵循单一集成和关系模型的传统,带有高级查询语言。
    • 实现起来很复杂,且无法解决跨系统同步写入问题。
  • 分拆数据库:统一写入

    • 通过统一的低级API(管道)进行通信,并且可以用更高级的语言进行组合(shell)
    • 能确保所有数据变更都在正确的位置结束,即使发生故障。
    • 更容易实现,只是将存储系统插接在一起。
开展分拆工作

同步写入到几个存储系统是更困难的工程问题。传统同步写入需要跨异构存储系统的分布式事务,作者认为这是错误的解决方案(参阅”导出的数据与分布式事务“),具有幂等写入的异步事件日志才是更健壮和实用的方法。

分布式事务在流处理组件内使用以匹配恰好一次语义(”重新访问原子提交“),但当事务设计由不同人群编写的系统时,缺乏标准化的事务协议会使集成困难。有幂等消费者的事件的有序事件日志是一种更简单的抽象,因此在异构系统中更容易实现。

基于日志的集成达成了各个组件之间的松散耦合(loose coupling) ,体现在两方面:

  1. 在系统级别,异步事件流使整个系统对各个组件的中断或性能下降更加稳健。若使用者运行缓慢或失败,则事件日志可以缓冲消息(”磁盘空间使用情况“),让生产者和其他使用者继续不受影响地运行。而分布式事务的同步交互会将本地故障升级成大规模故障。
  2. 人力方面,分拆数据系统允许不同的团队独立开发、改进和维护不同的软件组件和服务。并使团队专业化做一件事。事件日志提供一个足够强大的接口,以捕获相当强的一致性属性,但也足够普适于几乎任何类型的数据。
分拆系统vs集成系统

数据库仍然是需要的:维护流处理组件中的状态,为批处理和流处理器的输出提供查询服务。专用的查询引擎对于特定工作负载仍然非常重要,例如MMP数据仓库中的查询引擎针对探索性分析查询进行优化,并能够很好地处理这种类型的工作负载。

运行好几种基础设施会带来复杂性问题,单一集成软件产品也可以在其设计应对的工作负载类型上实现更好且更可预测的性能。为了不需要的规模而构建系统是白费精力,还可能会将我们锁死在一个不灵活的设计中。

分拆的目的是允许结合多个不同的数据库以获得更好的性能,这是关于广度而非深度。当单一软件无法满足所有需求时,拆分和联合才有优势。若一项技术可以满足所有需求,直接用它就行。

少了什么?

目前还没有与Unix shell类似的分拆数据库,这是一种声明式的、简单的、用于组装存储和处理系统的高级语言。

能够预先计算和更新缓存是一件好事,物化视图的本质上就是一个预先计算的缓存,所以可以通过复杂查询声明指定物化视图来创建缓存,包括图的递归查询和应用逻辑。

2.2 围绕数据流设计应用

使用应用代码组合专用存储与处理系统来分拆数据库的方法,也被称为“数据库由内而外”方法。可以看作是一种设计模式,是很多人的思想的融合。

即使是电子表格也在数据流编程能力上比主流编程语言强。电子表格中,将公式放入一个单元格,只要公式的输入发生变更,公式的结果也会自动重新计算。这正是数据系统层次所需要的,我们希望:当记录发生变更时,自动更新该记录的任何索引,并且自动刷新依赖于该记录的任何缓存视图或聚合。

与电子表格不同的是,数据系统需要容错、可扩展、持久存储,还要能整合不同人群编写的不同技术并重用现有的库和服务。期望使用某种特定语言、框架或工具开发所有软件是不切实际的。

本节将探讨一些围绕分拆数据库和数据流的想法构建应用的方法。

应用代码作为衍生函数

当一个数据集衍生自另一个数据集时,它会经历某种转换函数。例如:

  • 次级索引:由一种直白的转换函数生成的衍生数据集,挑选被索引的列或字段中的值直接排序。用于次级索引的衍函数作为核心功能被内建到许多数据库中,可以通过CREATE INDEX调用。
  • 全文搜索索引:通过应用各种自然语言处理函数创建用于搞笑查找的数据结构。常见的基本语言特征可能内置到数据库中,但更复杂的特征通常需要领域特定的调整。
  • 机器学习系统:从训练数据通过应用各种特征提取,统计分析函数衍生的数据。特征工程是众所周知的特定于应用的特征,通常包含很多关于用户交互与应用部署的详细知识。
  • 缓存:填充进前端UI的数据集合,填充缓存需要知道UI中引用的字段。
应用代码和状态的分离

理论上,数据库可以是任意应用代码的数据环境,就像操作系统一样。但实践中它们对此的适配性很差,它们不满足于现代应用开发的要求,例如依赖性和软件包管理,版本控制,滚动升级,可演化性,监控,指标,对网络服务的调用以及与外部系统的集成。

现在大多数Web应用是作为无状态服务部署的,任何用户请求可以路由到任何应用程序服务器,并在服务器发送响应后会忘记所有请求。这种部署的可扩展性很高,可以随意添加或删除服务器,但状态必须到某个地方:通常是数据库。现在的趋势是将无状态应用程序逻辑与状态管理(数据库)分开:不将应用程序逻辑放入数据库中,也不将持久状态置于应用程序中。

大多数编程语言和数据库中,你无法订阅而只能定期读取变量的变更。与电子表格不同,如果变量的值发生变化,变量的读者不会收到通知。 (可以在自己的代码中实现这样的通知,这称为观察者模式,但大多数语言没有将这种模式作为内置功能。)

数据流:应用代码与状态变化的交互

从数据流的角度思考应用,意味着重新协调应用代码与状态管理之间的关系。更多的考虑数据状态、状态变更和处理它们的代码之间的相互作用与协同关系。应用代码通过在另一个地方触发状态变更来响应状态变更。

我们讨论过将数据库的变更日志视为一种我们可订阅的事件流(参阅“流与数据库”),诸如Actor的消息传递系统也具有响应事件的概念。早在20世纪80年代,元组空间(tuple space) 模型就已经探索了表达分布式计算的方式:观察状态变更并作出反应。

当触发器由于数据变更而被触发时,或次级索引更新以反映索引表中的变更时,数据库内部也发生着类似的情况。分拆数据库意味着将这个想法应用于在主数据库之外,用于创建衍生数据集:缓存,全文搜索索引,机器学习或分析系统。我们可以为此使用流处理和消息传递系统。

传统消息系统通常是为执行异步任务设计的(参阅“与传统消息传递相比的日志”):

  • 维护衍生数据时,状态变更的顺序通常很重要,许多消息代理在重传未确认消息时没有此属性,双写也被排除在外。
  • 容错是衍生数据的关键:仅丢失单个消息会导致衍生数据集永远与其数据源失去同步。消息传递和衍生状态更新都必须可靠。

稳定的消息排序和容错消息处理是相当严格的要求,但与分布式事务相比开销更小且更稳定。现代流处理组件可以提供这些排序和可靠性保证,并允许应用代码以流算子形式运行。

这些应用代码可以执行任意处理,包括数据库内置衍生函数通常不提供的功能。流算子可以围绕着数据流构建大型系统(就像通过管道链接的Unix工具),每个算子接受状态变更的流作为输入,并产生其他状态变化的流作为输出。

流处理器和服务

现在流行的开发风格是将功能分解为一组通过同步网络请求(如REST API)进行通信的服务(service) (参阅”通过服务实现数据流:REST和RPC“)。这种架构有点在于通过松耦合能提供组织上的可扩展性,让不同团队专职于不同服务,减少团队协调工作。

在底层通信机制上,微服务使用同步的请求/响应式交互,而数据流中组装流算子采用单向异步消息流。数据流系统还能实现更好的性能,例如:假设客户正在购买以一种货币定价但以另一种货币支付的商品,我们需要知道当前汇率,这个操作可以这么实现:

  1. 微服务方法中,代码可能查询汇率服务或数据库,以获取特定货币的当前汇率。
  2. 数据流方法中,处理订单的代码提取订阅汇率变更流,并在汇率发生变动时将当前汇率存储在本地数据库中。处理订单时查询本地数据库即可。

数据流方法更快且可用性更高,我们现在不再使用RPC,而是之间建立流联接(参阅”流表联接“)。微服务方法中,也可以将汇率缓存在本地,并且轮询以保证新鲜度,从而模拟数据流方法。

2.3 观察衍生数据状态

写路径(write path) :数据流系统提供的创建衍生数据集并使其保持更新的过程。某些信息写入系统后,可能经历批处理和流处理等多个阶段,而最终每个衍生数据集都会被更新,以适配写入的数据。如下图。

分拆数据库 - 图1

读路径(read path) :当服务用户请求时,从衍生数据集中读取,对结果进行一些额外处理,然后构建给用户的响应。

写路径和读路径涵盖了数据的整个旅程——从收集数据开始,到使用数据结束。写路径是预计算——数据进入即刻完成。而读路径需要有人请求才发生,类似于惰性求值。

衍生数据集是写路径和读路径相遇的地方,代表了在写入时需要完成的工作量在读取时需要完成的工作量之间的权衡。

物化视图和缓存

全文搜索索引是一个很好的例子:写路径更新索引,读路径在搜索中搜索关键字。写入需要更新文档中出现的所有关键词的索引条目。读取需要搜索查询中的每个单词,并应用语句来找到目标词。

如果没有索引,搜索查询将扫描所有文档。没有索引意味着写入路径上的工作量少,但是增加了读取路径的工作量。

可以为一组固定的最常见的查询预先计算搜索结果,从而快速搜索而不必去索引。这就是常见查询的缓存,也称物化视图(materialized view)

从这个例子中我们可以看到,索引不是写路径和读路径之间唯一可能的边界。由此来看,缓存,索引和物化视图的作用很简单:它们改变了读路径与写路径之间的边界。通过预先计算结果,从而允许我们在写路径上做更多的工作,以节省读取路径上的工作量。

有状态,可离线的客户端

我们可以尝试改变写路径和读路径之间的边界,并探讨这种改变的实际意义。

过去二十年来,客户端/服务器模型已经普遍到我们几乎忘记了还有其他模型的存在,但是技术不断发展,不时的质疑现状是很重要的。传统上,浏览器是无状态的客户端,连上互联网才能做事。而最近,JavaScript Web应用出现很多有状态的功能,包括客户端用户界面交互,以及Web浏览器中的持久化本地存储。移动应用可以类似地在设备上存储大量状态,且用户交互不需要与服务器交互。

这些变化引起了人们对离线优先(offline-first) 应用的兴趣。这些应用尽可能在设备上使用本地数据库,无需连接互联网,并在后台网络连接可用时与远程服务器同步。

摆脱无状态客户端与中央数据库交互的假设,转向在终端用户设备上维护状态。我们可以将设备上的状态视为服务器状态的缓存。屏幕上的像素是客户端应用中模型对象的物化视图;模型对象是远程数据中心的本地状态副本。

将状态变更推送给客户端

典型的网页中,浏览器只能在一个时间点读取数据,不会订阅来自服务器的更新。最近的协议已经超越了HTTP的基本请求/响应模式:EventSource API和WebSockets提供了通信信道,通过这些信道,Web浏览器与服务器保持打开的TCP连接。服务器从而能主动向浏览器推送信息,这会减少客户端状态的陈旧程度。

主动将状态变更推送至客户端设备,这意味着将写路径一直延伸到终端用户。客户端首次初始化时需要使用读路径读取状态,之后便可依赖于服务器发送的状态变更流。设备相当于一个事件流的订阅者,也能使用断线重连的技术(参阅”消费者偏移量“)。

端到端的事件流

最近,用于开发带状态客户端与用户界面的工具(React、Flux、Redux等),已经通过订阅表示用户输入和服务器响应的事件流,来管理客户端的内部状态,其结构与事件溯源相似(参阅”事件溯源“)。

可以将这种编程模型扩展为:允许服务器将状态变更事件,推送到客户端的事件管道中。因此,状态变化可以通过端到端(end-to-end) 的写路径流动:从一个设备上的交互触发状态变更开始,经由事件日志,并穿过几个衍生数据系统与流处理器,一直到另一台设备上的用户界面。

这种方式使得状态变化的传播延迟相当低。但也有缺点:关于无状态客户端和请求/响应交互的假设根深蒂固的植入于数据库、库、框架、协议之中。很少有数据存储提供订阅变更——即为请求返回一个随时间推移返回响应的流。

为了将写路径延伸到终端用户,需要从根本上重新思考我们构建这些系统的方式:从请求/响应转向发布/订阅数据流。更具响应性的用户界面与更好的离线支持。

读也是事件

我们说过,当流处理器将衍生数据写入存储时,以及当用户请求查询该存储时,存储将充当写路径和读路径之间的边界。该存储应当允许对数据进行随机访问的读取查询,否则这些查询将需要扫描整个事件日志。

很多情况下,数据存储与流处理系统是分开的。但回想一下,流处理器还是需要维护状态以执行聚合和连接的(参阅“流连接”)。这种状态通常隐藏在流处理器内部,但一些框架也允许这些状态被外部客户端查询,将流处理器本身变成一种简单的数据库。

通常,我们通过事件日志写入存储,用临时的网络请求读取。用"读也是事件"的思想,我们可以将读取请求表示为事件流,同时将读事件与写事件送往流处理器,流处理器通过将读取结果发送到输出流来响应读取事件。

当写入和读取都被表示为事件,并且被路由到同一个流算子以便处理时,我们实际上是在读取查询流和数据库之间执行流表连接。读取事件需要被送往保存数据的数据库分区(参阅“请求路由”),就像批处理和流处理器在连接时需要在同一个键上对输入分区一样(参阅”Reduce端连接与分组“)。

服务请求与执行连接之间的这种相似之处非常关键。一次性读取请求只是将请求传过连接算子,然后请求马上就被忘掉了;而一个订阅请求,则是与连接另一侧过去与未来事件的持久化连接。

记录读取事件的日志可能对于追踪整个系统的因果关系与数据来源有好处:可以让你重现出当用户做出特定决策之前看见了什么。这可以用于大数据分析等,但也会带来额外的存储与I/O成本。优化这些系统以减少开销仍然是一个开放的研究问题。

多分区数据处理

单个分区的查询也使用流可能是杀鸡用牛刀。但是我们可以得到启发:合并来自多个分区的数据,利用流处理器已经提供的消息路由,分区和连接基础设施。

Storm的分布式RPC功能支持这种使用模式(参阅”消息传递和RPC“),有些计算需要合并来自多个分区的结果,例如查找转发URL的推特粉丝的并集。

MPP数据库的内部查询表执行图有着类似的特征,若需要执行这种多分区连接,那么不如直接使用提供此功能的数据库。

我们可以将这视为一种实现超出传统解决方案的关于流的大规模应用。

3 将事情做正确

对于只读数据的无状态服务,出问题可以随时重启服务,而对于数据库这样的有状态系统出问题,就没那么简单了。

我们希望构建可靠且正确的应用,四十年来,原子隔离持久等事务特性是构建正确应用的首选工具:

  • 然而这些地基没有那么牢固,例如弱隔离级别带来困惑。
  • 一致性定义并不明确(参见”第九章“)。
  • 事务在某些领域被完全抛弃,被更好的模型取代。
  • 如果数据库的配置很难理解那么容易出错,代码需要正确使用它们的功能才能不出问题。

传统事务无法走远,本节将提出数据流框架中考量正确性的方式。

3.1 为数据库使用端到端的参数

使用有相对强的安全属性的数据系统(例如可序列化事务),不意味着能保证没有数据丢失或损坏。应用会出Bug,而人会犯错误。不可变和仅追加错误也没法解决一些问题。

正好执行一次操作

我们介绍过恰好一次语义(参阅”容错“)。如果处理消息时出现问题而重试,可能会发生:第一次处理实际上成功了,而重试让这个消息被处理了两次。

处理两次是数据损坏的一种形式,最有效的方法是使操作幂等(idempotent) (参阅”,幂等性“)。可能需要维护额外的元数据,并在从一个节点故障切换至另一个节点时做好防护(参阅”领导与锁定“)。

抑制重复

流处理外的许多地方也需要抑制重复的模式。例如TCP使用数据包上的序列号,用于排序、检测丢包和重复。但是这种重复抑制只适用于单条TCP连接的场景中,若事务中,客户端发送COMMIT但是网络断开,那么客户端不知道事务是否被提交。客户端可以重联然后重试,但现在已经处于TCP重复抑制的范围之外了,因为事务可能不是幂等的,就可能造成数据库的数据损坏。

两阶段提交(参阅”原子提交与两阶段提交“)会破坏TCP连接与事务之间的1:1映射,因为它们必须在故障后允许事务协调器重连到数据库,告诉数据库将存疑事务提交还是中止。这不足以确保事务恰好执行一次。

即使我们能抑制重复事务,我们也得担心用户设备与服务器之间的网络,用户可能发出请求却收不到响应,这就可能会向用户显示错误信息。

操作标识符

要在通过几跳的网络通信上使操作具有幂等性,仅仅依赖数据库提供的事务机制是不够的 —— 你需要考虑端到端(end-to-end) 的请求流。例如,可以为操作生成一个唯一标识符,通过在数据库中检查这个标识符是否存在来判断是否进行过操作。

端到端原则

抑制重复事务的这种情况只是一个更普遍的原则的一个例子,这个原则被称为端到端的原则(end-to-end argument) :只有在通信系统两端应用的帮助下,所讨论的功能才能完全正确的实现。

重复抑制无法阻止用户亲自提交重复的请求,解决这个问题需要一个端到端的解决方案:从终端用户的客户端一路传递到数据库的事务标识符。

端到端参数也适用于检查数据的完整性:以太网,TCP和TLS中内置的校验和可以检测网络中数据包的损坏情况,但是它们无法检测到由连接两端发送/接收软件中Bug导致的损坏,以及数据存储所在磁盘上的损坏。如果要捕获数据所有可能的损坏来源,需要端到端的校验和。加密也差不多,TLS/SSL可以阻挡攻击者但无法阻止恶意服务器。

低级别的可靠性功能不足以确保端到端的正确性,但是它们仍然有用,可以降低较高层级出现问题的可能性。

在数据系统中应用端到端思考

仅仅因为应用使用了提供相对较强安全属性的数据系统,例如可序列化的事务,并不意味着应用的数据就不会丢失或损坏了。应用本身也需要采取端到端的措施,例如除重。

事务是一个很好的抽象,将各种问题合并为两种可能结果:提交或中止。这对编程模型而言是一种巨大的简化。事务代价高昂,特别是当涉及异构存储技术时。我们拒绝使用分布式事务是因为它开销巨大,结果不得不在代码中重新实现容错机制。出于这些原因,探索对容错的抽象是很有价值的。

3.2 强制约束

端到端的除重可以通过从客户端一路透传到数据库的请求ID实现,那么其他类型的约束呢?

唯一性约束(参阅”约束和唯一性保证“)保证了一些需要强制实施唯一性的功能正确运行,例如用户名和电子邮件地址必须唯一标识用户,文件存储服务不能包含多个重名文件,两个人不能再航班或剧院预定同一个座位。也能用于其他约束,例如余额不会变负数,会议室不能重复预定。

唯一性约束需要达成共识

分布式中强制执行唯一性约束需要共识:若存在多个有相同值的并发请求,系统需要决定冲突操作中哪一个被接受,并拒绝其他操作(参阅“单领导者复制与共识”)。

唯一性检查可以通过对唯一性字段分区做横向扩展。例如,如果需要通过请求ID确保唯一性,我们可以确保所有具有相同请求ID的请求都被路由到同一分区。若需要让用户名是唯一的,则可以按用户名的散列值做分区。

异步多主复制可能会发生不同主库同时接受冲突写的情况,因而这些值不唯一。如果想立即拒绝任何违背约束的写入,同步协调是不可避免的。

基于日志消息传递中的唯一性

全序广播(total order boardcast) 并且等价于共识:日志确保所有消费者以相同顺序看见消息(参阅“全序广播”)。基于日志的消息传递的分拆数据库中,可以使用类似的方法达成唯一性约束。

流处理器在单线程上按顺序消费单个日志分区中的所有消息。因此,当日志按唯一的值做的分区时,流处理器可以无歧义地决定哪个操作先到达。例如,多个用户尝试宣告相同用户名的情况下:

  1. 每个对用户名的请求都被编码为一条信息,追加到按用户名散列值确定的分区。
  2. 流处理器用本地数据库来追踪哪些用户名被占用了。对于所有申请可用用户名的操作,成功则记录用户名并发送成功消息,若用户名已被占用则发送拒绝消息。
  3. 请求用户名的客户端监视输出流,等待对应的成功或拒绝消息。

该算法基本与“使用全序广播实现线性一致性的存储”中的算法相同。可以简单地通过增加分区数扩展至较大的请求吞吐量,因为每个分区可以被独立处理。

该方法也适用于其他类型的约束,原理是让任何可能的冲突写入路由到相同分区按顺序处理。

多分区请求处理

涉及多个分区时,很难确保操作以原子方式执行并同时满足约束。传统方法中,执行事务需要跨多个分区进行原子提交,这实际上是将该事务嵌入一个全序,需要跨分区协调,不同的分区将无法独立处理从而影响吞吐量。

分区日志可以达到等价的正确性而无需原子提交:例,收付款中,有三个分区分别包含请求ID、收款人账户、付款人账户。使用分区日志时

  1. 账户A向账户B转账的请求由客户端提供一个唯一的请求ID,并按请求ID追加写入相应日志分区。
  2. 流处理器读取请求日志。对每个消息请求,向输出流发出两条消息:付款人A的借记指令(按A分区)、收款人B的贷记指令(按B分区)。被发出的消息中会带有原始的请求ID。
  3. 后续处理器消费借记/贷记指令流,按照请求ID除重,并将变更应用至账户余额。

如果直接扣款和加钱,则需要在这两个分区之间原子提交。这一通操作是为了将持久化记录为单条消息,从而避免对分布式事务的需要。单条消息请求要么出现在日志中,要么不出现,无需多分区原子提交。

如果流处理器在步骤2崩溃,那么会从上一个存档点恢复处理。这样做时不会跳过任何请求消息,但可能生成重复借贷指令,而步骤3中的处理器可以通过请求ID去重。

若想确保付款人余额够,可以用另一个流处理器来维护账户余额并校验事务,只有有效的事务会被记录在步骤1的请求日志中。

3.3 及时性与完整性

事务的一个便利是,通常都是线性一致的(参阅“线性一致性")。然而,当我们将一个操作拆分成跨越多个阶段的流处理器时却并非如此:日志的消费者在设计上是异步的,发送者不会等其消息被消费者处理完。但是,客户端等待输出流中特定消息是可能的。

在这个例子中,唯一性检查的正确性不取决于消息发送者是否等待结果。等待是为了同步通知发送者唯一性检查是否成功。但该通知可以与消息处理的结果相解耦。

我(作者)认为一致性(consistency) 这个术语混淆了两个需求:

  • 及时性(Timeliness):确保用户观察到系统的最新状态。如果存在复制延迟(参阅”复制延迟问题“),这种不一致是暂时的。
  • 完整性(Integrity):没有数据丢失,没有矛盾或错误的数据。如果完整性被违背,这种不一致是永久的,需要显示检查和修复。完整性通常比及时性重要得多,后果是灾难性的。
数据流系统的正确性

ACID事务通常同时提供及时性(例如线性一致性)和完整性(例如原子提交),因此对于ACID事务来说,它们之间的区别无关紧要。

恰好一次等效一次语义是一种保持完整性的机制。若事件丢失或生效两次,就有可能违背数据系统的完整性。因此面对故障时,容错消息传递与重复抑制(例如幂等)对于维护数据系统的完整性至关重要。

异步事件流不能保证及时性,除非显示构建一个返回之前就明确等待特定消息到达的消费者。但是流处理系统核心是完整性,正如上一节所说,可靠的流处理系统可以在无需分布式事务与原子提交的情况下保持完整性,即达成正确性时性能更好。为了达成正确性,我们组合了多种机制:

  • 将写入操作的内容表示为单条消息,从而可以轻松地被原子写入——可以事件溯源搭配(参阅”事件溯源“)。
  • 使用与存储过程类似的确定性衍生函数,从这一消息中衍生出所有其他状态变更(参见”真的串行执行”和“作为衍生函数的应用代码”)。
  • 将客户端生成的请求ID传递通过所有的处理层次,从而启动端到端除重,带来幂等性。
  • 使消息不可变,并允许衍生数据能随时被重新处理,这使从错误中恢复更加容易(参阅“不可变事件的优点”)

这些机制的组合在我(作者)看来是很有前景的方向。

宽松地解释约束

执行传统的唯一性约束需要共识,通常通过在单个节点中汇集特定分区中所有事件来实现,流处理也不例外。但现实中许多应用能摆脱这种形式,接受弱得多的唯一性——补偿性事务(compensating transaction)

  • 若两人同时注册相同用户名,可以向其中一个发送消息并道歉。
  • 若客户下单而库存不够了,可以下单补仓,并为延误向客户道歉或提供折扣。将道歉工作变成商业过程的一部分,那么就没必要对库存物品数量添加线性一致约束。
  • 旅馆抱着部分客人取消预订的期望超卖客房,当需求超过供给时再进入补偿流程。
  • 从银行账户中超额取款,银行再收取欠款+透支费用。银行可以限制每天的提款总额以降低风险。

类似以上的许多商业场景确实需要完整性,但执行约束时并不需要及时性,因此可以临时违背约束然后通过道歉来修复,这种做法与“处理写入冲突”中的冲突解决方法类似。而道歉的成本能否被接受是一个商业决策,传统模型在写入数据之前检查所有约束,反而会带来不必要的限制。线性一致也不是必须的,可以先乐观写入再事后检查。

无协调数据系统

以上两小节提到:

  1. 数据流系统可以维持衍生数据的完整性保证,而无需原子提交,线性一致性,或者同步跨分区协调。
  2. 严格的唯一性约束要求及时性和协调,但许多应用实际上可以接受宽松的约束:只要整个过程保持完整性,这些约束可能会被临时违反并在稍后被修复。

这些观察意味着,数据流系统可以为许多应用提供无需协调的数据管理服务,且仍能给出很强的完整性保证。这种无协调(coordination-avoiding) 的数据系统比起需要执行同步协调的系统,有着达到更好的性能与更强的容错能力。

例如,这种系统可以使用多领导者配置运维,跨越多个数据中心,在区域间异步复制。不需要同步的跨区域协调,使得数据中心可以独立运行。这种系统提供的时效性很弱但完整性很强。

这种情况下:

  • 可序列化事务作为维护衍生状态的一部分仍然有用,它们可以在小范围内运行。
  • 异构分布式事务(如XA)事务不是必需的。
  • 同步协调仍然可以在需要的地方引入。

另一种审视协调与约束的角度是:减少了由于不一致而必须做出的道歉数量,但也可能降低系统的性能和可用性,从而可能增加由于宕机中断而需要做出的道歉数量。这就得根据需求寻找能让道歉数量最少的平衡点。

3.4 信任但验证

我们所有关于正确性、完整性和容错的讨论,都基于系统模型(system model) (参阅“将系统模型映射到现实事件"):某些事情会出错,但其他事情永远不会。问题在于违反我们假设的情况经常发生,系统会在硬件方面出错和在软件方面出Bug。

我们不能盲目信任硬件和软件管用,如果想确保数据仍然存在,必须读取并检查。同时,不时尝试从备份中恢复是很重要的,防止备份损坏。

检查数据完整性称为审计(auditing) :查明数据是否已经损坏,以便修复它,并尝试追查错误的来源。可审计性在财务中至关重要。

验证的文化

目前没有多少系统采用:”信任但是验证“的方式来持续审计自己。未来需要更多能自我验证(self-validating)自我审计(self-auditing) 系统,不断检查自己的完整性,而不是依赖盲目的信任。

ACID数据库上开发的应用经常忽视可审计性,事务在大多数情况下工作良好所以认为审计机制不值得投资。但是在NoSQL的发展下,更弱的一致性和更不成熟的存储技术使用越来越广泛,我们就需要针对可审计性设计。

为可审计性而设计
  • 基于事件的系统可以提供更好的可审计性。
  • 显式处理数据流可以使数据的来龙去脉(provenance) 更加清晰,从而使完整性检查更具可行性。
  • 可以使用散列来检查事件存储有没有被损坏。
  • 对于任何衍生状态,可以直接运行一遍,从事件日志中衍生它的批处理器和流处理器,以检查是否获得相同结果,或者可以并行运行冗余的衍生流程。
  • 具有确定性且定义良好的数据流,使得调试与跟踪系统的执行变得容易,可以更好的确定它为什么做了某些事情。重现导致意外事件的事故现场是很有价值的。
端到端原则重现

检查数据系统的完整性,最好是以端到端方式进行(参阅”数据库的端到端争论“):我们能在完整性检查中涵盖的系统越多,某些处理阶段中出现不被察觉损坏的几率就越小。

自动化测试一样,审计提高了快速发现错误的可能性,从而降低了系统变更或新存储技术可能导致损失的风险。如果你不害怕进行变更,就可以更好地充分演化一个应用,使其满足不断变化的需求。

用于可审计数据系统的工具

使用密码学工具来证明系统的完整性是十分有趣的,这种方式对于宽泛的硬件与软件问题、潜在的恶意行为都很有效。加密货币和区块链等技术已经迅速出现在这一领域。

实质上它们是分布式数据库,具有数据模型与事务机制,不同副本可以由互不信任的组织托管。副本不断检查其他副本的完整性,并用共识协议对应当执行的事务达成一致。缺点是工作证明(proof of work)技术非常浪费。

密码学审计与完整性检查通常依赖默克尔树(Merkle tree) ,这是一颗散列值的树,能够用于高效地证明一条记录出现在一个数据集中(以及其他一些特性)。除了加密货币外,证书透明性(certificate transparency) 也是一种依赖Merkle树的安全技术,用来检查TLS/SSL证书的有效性。

未来,完整性检查和审计算法将会在通用数据系统中得到越来越广泛的应用。

4 做正确的事情

本书考察了各种不同的数据系统架构,评价了它们的优点与缺点,并探讨了构建可靠,可扩展,可维护应用的技术。但是,我们忽略了讨论中一个重要而基础的部分,现在补充一下。

每个系统都服务于一个目的;我们采取的每个举措都会同时产生期望的后果与意外的后果。而后果可能会远远超出最初的目的。我们这些建立系统的工程师,有责任去仔细考虑这些后果。

我们将数据当成一种抽象的东西来讨论,但请记住,许多数据集都是关于人的:对待这些数据,我们必须怀着人性与尊重。用户也是人类,人类的尊严是至关重要的。软件开发越来越多的涉及到道德抉择,但实践中很少讨论,更不用说强制执行。技术本身并无好坏之分 —— 关键在于它被如何使用,以及它如何影响人们。

4.1 预测性分析

预测性分析是”大数据“主要内容之一,随着算法决策变得越来越普遍,被某种算法(准确地或错误地)标记为某个标签的某人可能会受到大量相同的决定。这是一种对个体自由的极大约束,因此被称为“算法监狱”。例如短视频平台的信息茧房。

偏见与歧视

算法做出的决定不一定比人类更好或更差。每个人都或多或少存在偏见,人们希望根据数据做出决定,而不是通过人的主观评价与直觉,希望这样能更加公平。

当我们开发预测性分析系统时,那些决策规则是从数据中推断出来的,而这些系统学到的模式是个黑盒——如果算法的输入中存在系统性的偏见,则系统很有可能会在输出中学习并放大这种偏见。

预测性分析系统只是基于过去进行推断;如果过去是歧视性的,它们就会将这种歧视归纳为规律。如果我们希望未来比过去更好,那么就需要道德想象力,而这是只有人类才能提供的东西。

责任与问责

如果算法出错,谁来负责?基于机器学习的评分算法通常会使用更宽泛的输入,并且更不透明;因而很难理解特定决策是怎样作出的,以及是否有人被不公正地,歧视性地对待。很多数据本质上是统计性的,这意味着即使概率分布在总体上是正确的,对于个例也可能是错误的。

收集关于人的数据并进行决策,信用评级机构是一个很经典的例子。信用分总结了“你过去的表现如何?”,而预测性分析通常是基于“谁与你类似,以及与你类似的人过去表现的如何?”。

盲目相信数据决策至高无上,是一种有切实危险的妄想。随着数据驱动的决策变得普遍,我们需要弄清楚,如何使算法更负责任且更加透明,如何避免加强现有的偏见,以及如何在它们不可避免地出错时加以修复。此外,我们还得弄清楚如何避免数据被用于害人,如何认识数据的积极潜力。

反馈循环

当服务变得善于预测用户想要看到什么内容时,它最终可能只会向人们展示他们已经同意的观点,将人们带入滋生刻板印象,误导信息,与极端思想的回音室(信息茧房)。

当预测性分析影响人们的生活时,自我强化的反馈循环会导致非常有害的问题。例如,A突然下岗而没法还款,那么信用分就会下降,就会导致更难找工作,然后更加没钱。在数据与数学严谨性的伪装背后,隐藏的是由恶毒假设导致的恶性循环。

使用系统思维(systems thinkin) 对整个系统进行整体思考,许多后果是可以预测的。我们可以尝试理解数据分析系统如何响应不同行为、结构或特性——该系统是否增大了现实的矛盾?

4.2 隐私和追踪

数据收集本身也可能有道德问题。系统是在为用户提供服务,但是当用户的活动被跟踪并记录,作为他们正在做的事情的副作用时,该服务是服务于它自己,而这可能与用户的利益相冲突。

追踪用户行为数据对于许多面向用户的在线服务而言,变得越来越重要——可以帮助用户找到自己喜欢的东西,这些功能需要一定量的用户行为追踪,而用户也可能从中受益。但是,如果服务通过广告盈利,那么广告才是真正的客户,而用户的利益排第二,数据就会用于建立画像以用于营销 —— 与用户的关系就变得像是监视(surveilance)

监视

如果放任不管,智能设备会无时不刻的收集我们的设备,但是数据由资本家而不是政府机构保存。

当监视被用于推荐算法,似乎企业和用户之间没有剧烈的矛盾。但当被用于生活的重要方面,例如保险和就业时,数据分析可以揭示出令人惊讶的私密事务:例如运动手表能精确的计算你在输入的内容(比如密码)。而分析算法只会越来越精确。

同意与选择的自由

虽然用户同意了服务条款与隐私政策,因此他们同意数据收集,但是用户几乎不知道他们提供给我们的是什么数据,哪些数据被放进了数据库,数据又是怎样被保留与处理的 —— 大多数隐私政策都是模棱两可的,忽悠用户而不敢打开天窗说亮话。如果用户不了解他们的数据会发生什么,就无法给出任何有意义的同意。

而且从用户身上挖掘数据是一个单向过程,而不是真正的互惠关系,也不是公平的价值交换。用户对能用多少数据换来什么样的服务,既没有没有发言权也没有选择权:服务与用户之间的关系是非常不对称与单边的。这些条款是由服务提出的,而不是由用户提出的。

同时,不使用服务几乎是不可能的。例如,一个人拒绝使用手机聊天软件进行社交,会产生很多的社会成本。对于普通人而言,没有真正意义上的选择,监控是不可避免的。

隐私与数据使用

人们通常对隐私(privacy) 一词存在误解。拥有隐私并不意味着保密一切东西,而是意味着拥有选择向谁展示哪些东西的自由,要公开什么,以及要保密什么——隐私权是一项决定权,这是一个人自由与自主的重要方面。

互联网服务使得在未经有意义的同意下收集大量敏感信息变得容易得多,而且无需用户理解他们的私人数据到底发生了什么。当通过监控基础设施从人身上提取数据时,隐私权不一定受到损害,而是转移到了数据收集者手中。这意味着我们已经失去了披露一些私密信息的能动性,决定要透露什么和保密什么的权利从个体手中转移到了公司手中,而公司以利润最大化为目标行使隐私权。

许多公司都有一个目标,不要让人感觉到毛骨悚然,因此公司会保密的监视。用户的私密信息只会间接地披露,例如针对特定人群定向投放广告的工具。

允许用户控制其隐私设置,例如选择其他用户能看到哪些东西。这是将一些控制交还给用户的第一步,但服务本身仍然可以不受限制地访问和使用这些数据。

数据资产与权力

行为数据是用户与服务交互的副产品,因此有时被称为“数据废气” —— 暗示数据是毫无价值的废料。从这个角度来看,行为和预测性分析是从数据中提取价值的回收形式。

更准确的看法恰恰相反:从经济的角度来看,如果定向广告是服务的金主,那么关于人的行为数据就是服务的核心资产。在这种情况下,应用是一种诱骗用户将更多的个人信息提供给监控设施的手段。

个人数据是珍贵资产的说法因为数据中介的存在得到支持,这是阴影中的秘密行业——交易个人数据用于市场营销。当公司破产时,个人数据就是被出售的资产之一。如果数据落入恶人手中,不仅危害个人安全也可能危害国家安全。

回顾工业革命

两次时代进步或许有共同规律。工业革命一开始有许多问题,例如污染、工人权益等,这些问题经过了很长时间的发展才得到改善。这些改善使得生产成本增加,但社会从中受益,例如禁止排放污水、雇佣童工等。

就像工业革命的早期一样,转向信息时代的我们也需要完善许多问题,而数据的收集与使用就是重大问题之一。

用布鲁斯·施奈尔的话来说:数据是信息时代的污染问题,保护隐私是环境挑战。几乎所有的电脑都能生产信息。它堆积在周围,开始溃烂。我们如何处理它 —— 我们如何控制它,以及如何摆脱它 —— 是信息经济健康发展的核心议题。正如我们今天回顾工业时代的早期年代,并想知道我们的祖先在忙于建设工业世界的过程时怎么能忽略污染问题;我们的孙辈在回望信息时代的早期年代时,将会就我们如何应对数据收集和滥用的挑战来评断我们。

立法和自律

数据保护法有助于维护个人权利,但是在互联网环境下它们很多并不有效。这些规则会直接否定了大数据的哲学——最大限度的收集数据,将其与其他数据集结合起来进行试验和探索,以便产生新的发现。

那些收集了大量有关人的数据的公司反对监管,认为这是创新的负担与阻碍。在某种程度上,这种反对是有道理的。例如疾病数据的过度监管会导致研发变缓,从而导致更多人死亡。

我们究竟能做到哪一步,是一个开放的问题。首先,我们不应该永久保留数据,而是一旦不再需要就立即清除数据,但这与不变性的想法背道而驰。这是可以解决的问题,目前较有前景的办法是通过加密协议来实施访问控制。

从根本上来说,我们应该停止将用户视作待优化的指标数据,并记住他们是值得尊重,有尊严和能动性的人。我们应该允许每个人保留自己的隐私 —— 即,对自己数据的控制,而不是通过监视来窃取这种控制权。