如果一个系统受到单个人的影响太大,系统就不会成功。 一旦最初的设计完成并且相当健壮,真正的测试开始于有许多不同观点的人进行他们自己的实验。— Donald Knuth
在本书的前两部分中,我们讨论了很多关于请求和查询以及相应的响应或结果。 这种数据处理方式在很多现代数据系统中都是以假设作为前提:你请求一些内容,或者发送一个指令,一段时间后(希望)系统会给你一个答案。 数据库,缓存,搜索索引,Web服务器以及其他许多系统都以这种方式工作。
在这样的在线系统中,无论是浏览器请求页面还是调用远程服务的API,我们通常都假设请求是由人类用户触发的,并且用户正在等待响应。 他们不必等太久,所以我们非常重视这些系统的响应时间
Web和越来越多的基于HTTP / REST的API使交互的请求/响应风格变得如此普遍,以至于很容易将其视为理所当然。 但我们应该记住,这不是构建系统的唯一方式,其他方法也有其优点。 我们来区分三种不同类型的系统:
服务(在线系统)
服务等待客户的请求或指令到达。 当收到一个请求,服务试图尽快处理它,并发送一个响应。 响应时间通常是服务性能的主要衡量标准,可用性通常非常重要(如果客户端无法访问服务,用户可能会收到一个错误信息)。
批处理系统(离线系统)
一个批处理系统需要大量的输入数据,运行一个任务来处理它,并产生一些输出数据。 任务的运行往往需要一段时间(从几分钟到几天),所以通常不会有用户等待工作完成。 相反,批量作业通常会定期运行(例如,每天一次)。 批处理作业的主要性能指标通常是吞吐量(通过特定大小的输入数据集的时间)。 我们将在本章中讨论批处理。
流处理系统(近实时系统)
流处理位于在线和离线/批处理之间(因此有时称为近实时或近线处理)。 像批处理系统一样,流处理器消费输入数据并产生输出(而不是响应请求)。 但是,流式作业在事件发生后不久就会对事件进行处理,而批处理作业则处理固定的一组输入数据。 这种差异使流处理系统比等效的批处理系统具有更低的延迟。 由于流处理基于批处理,我们将在第11章讨论它。
正如我们将在本章中看到的,批量处理是我们构建可靠,可扩展和可维护应用程序的重要组成部分。 例如,2004年发布的批处理算法Map-Reduce(可能过度热衷)称为“使得Google具有如此大规模可扩展性的算法”。 该算法随后在各种开源数据系统中实施,包括Hadoop,CouchDB和MongoDB。
与多年前为数据仓库开发的并行处理系统相比,MapReduce是一个相当低级别的编程模型,但它使得在硬件上实现大规模的处理方面迈出了重要的一步。 虽然MapReduce的重要性正在下降,但它仍然值得我们去理解,因为它提供了批处理为什么以及如何有用的清晰画面。
实际上,批处理是一种非常古老的计算形式。 早在可编程数字计算机诞生之前,打孔卡制表机,例如1890年美国人口普查中使用的霍尔里斯机,实现了半机械化的批处理形式,以计算来自大量输入的汇总统计量。 Map-Reduce与1940年代和1950年代广泛用于商业数据处理的机电IBM卡片分类机器有着惊人的相似之处。像往常一样,历史有重演的趋势。
在本章中,我们将看看MapReduce和其他一些批处理算法和框架,并探讨它们在现代数据系统中的使用方式。 但首先,我们将看看使用标准Unix工具的数据处理。 即使你已经熟悉它们,但是提一下Unix的哲学也是值得的,因为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,并且它加载了该文件,因为该文件是在http://martin.kleppmann.com/的页面中引用的。
各种工具可以获取这些日志文件并生成有关您的网站流量的报告,但出于练习的缘故,让我们使用基本的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)
这一系列命令的输出结果如下所示:
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工具,上面的命令行可能看起来有些模糊,但它非常强大。 它将在几秒钟内处理千兆字节的日志文件,并且您可以轻松修改分析以适应您的需求。 例如,如果您想要从报告中省略CSS文件,请将awk参数更改为“$7!〜/.css$/ {print $7}”。 如果要计算出现次数最多的客户端IP地址而不是页面,请将awk参数更改为“{print $1}”等等。本书没有空间详细探讨Unix工具,但他们非常值得学习。 令人惊讶的是,许多数据分析可以在几分钟内使用awk,sed,grep,sort,uniq和xargs的组合来完成,并且它们的表现令人惊讶的好。
你可以写一个简单的程序来做同样的事情,而不是Unix命令链。 例如,在Ruby中,它可能看起来像这样:
counts = Hash.new(0)
File.open('/var/log/nginx/access.log') do |file|
file.each do |line|
url = line.split[6]
counts[url] += 1 end
end
top5 = counts.map{|url, count| [count, url] }.sort.reverse[0...5]
top5.each{|count, url| puts "#{count} #{url}" }
这个程序并不像Unix管道链一样简洁,但它的可读性很强,你喜欢的两个中的哪一个是品味的问题。 但是,除了两者之间的表面差异之外,执行流程有很大差异,如果您在大文件上运行此分析,这一点就会变得明显。
Ruby脚本在内存中保存一个URL哈希表,其中每个URL映射到它已被访问的次数。 Unix管道的例子没有这样的哈希表,而是依赖于对URL列表中重复出现的URL的次数进行排序。 哪种方法更好? 这取决于您有多少个不同的URL。 对于大多数中小型网站,可以满足所有不同的URL,并且每个URL的计数器占用1 GB的内存。 在此示例中,任务的工作集(任务需要随机访问的内存量)仅取决于不同URL的数量:如果单个URL有一百万条日志条目,则hash table中所需的空间仍然只有一个URL加上计数器的大小。 如果这个工作集足够小,则即使在笔记本电脑上,hash table在内存中也可以正常工作。
另一方面,如果任务的工作集大于可用内存,则排序方法的优点是可以高效地使用磁盘。 这与我们在第76页讨论的“SSTables和LSM-Trees”原理相同:数据块可以在内存中排序并作为段文件写入磁盘,然后多个排序的段可以合并为更大的排序文件。 Mergesort的顺序访问模式在磁盘上运行良好。(请记住,在顺序I / O中进行优化是第3章中反复出现的主题。这里再次出现相同的模式。) GNU Coreutils(Linux)中的排序程序通过溢出到磁盘,自动处理大于内存的数据集,并自动将排序的工作并行化到多个CPU内核。 这意味着我们之前看到的简单的Unix命令链很容易扩展为大型数据集,而且不会耗尽内存。 瓶颈可能是从磁盘读取输入文件的速度。
并非巧合的是,我们能够使用上例中的一系列命令轻松分析日志文件:这实际上是Unix的关键设计思想之一,并且它今天仍然非常有用。 让我们更深入地观察一下,以便我们可以从Unix中借鉴一些想法。
Unix管道的发明者Doug McIlroy在1964年首次描述了这种情况:“我们应该有一些连接程序的方法,就像花园软管,需要连接另一块去以另一种方式处理数据。I/O也是如此。”连接管道程序的想法成为了Unix哲学的一部分 - 这是一套在Unix开发人员和用户中流行的设计原则。 该哲学在1978年的描述如下:
这种方法 - 自动化,快速原型设计,增量迭代,对实验友好,并将大型项目分解为可管理的块 - 听起来非常像当今的敏捷开发和DevOps运动。 奇怪的是,四十年来变化不大。
sort工具是一个很好的例子来阐述把一件事做好。它可以说是一种比大多数编程语言在其标准库中都更好的排序实现(它不会溢出到磁盘并且不使用多个线程,即使这样做更有益)。然而,孤立来看,sort命令几乎没有任何用处。 它只能与其他Unix工具(如uniq)结合使用。
像bash这样的Unix shell可以让我们轻松地将这些小程序组合成令人惊讶的强大的数据处理任务。尽管这些程序中的很多都是由不同人群编写的,但它们可以灵活地结合在一起。 Unix如何实现这种可组合性?
如果您希望一个程序的输出成为另一个程序的输入,那意味着这些程序必须使用相同的数据格式 - 换句话说,就是一个兼容的接口。 如果您希望能够将任何程序的输出连接到任何程序的输入,那意味着所有程序都必须使用相同的输入/输出接口。
在Unix中,该接口是一个文件(更准确地说,是一个文件描述符)。 一个文件只是一个有序的字节序列。 因为这是一个非常简单的接口,所以可以使用相同的接口来表示许多不同的东西:文件系统上的实际文件,到另一个进程(Unix套接字,stdin,stdout)的通信通道,设备驱动程序(比如/dev/audio 或/dev/lp0),表示TCP连接的套接字等。 达成这样的共识很容易,但实际上不简单的是,这些非常不同的事物可以共享一个统一的接口,这样才可以很容易地将它们组合在一起。
按照惯例,许多(但不是全部)Unix程序将这个字节序列视为ASCII文本。 我们的日志分析示例使用了这个事实:awk,sort,uniq和head都将它们的输入文件视为由\ n(换行符,ASCII 0x0A)字符分隔的记录列表。 \ n的选择是任意的 - 可以说,ASCII的分隔符0x1E本来是一个更好的选择,因为它的存在就是为了这个目的 - 但无论如何,所有这些程序都使用相同的分隔符标准来允许它们进行互操作。
每个记录(即,一行输入)的解析更加模糊。 Unix工具通常通过空格或tab来分割字段,但也使用CSV(逗号分隔),管道分隔和其他编码。 即使像xargs这样一个相当简单的工具也有六个命令行选项,用于指定如何解析输入。
ASCII文本的统一接口大多可用,但它并不完美:我们的日志分析示例使用{print $7}来提取URL,可读性不好。 在一个理想的世界中,可以是{print $request_url}或类似的东西。 我们稍后会回到这个想法。
虽然它并不完美,但是几十年后,Unix的统一接口仍然是非凡的。 许多软件的交互和构成并不像Unix工具那样出色:您不能通过自定义分析工具轻松地将电子邮件帐户的内容和在线购物历史记录传送到电子表格中,并将结果发布到社交网络或wiki。 今天,程序还不能够像Unix一样顺畅地运行,还没有统一规范。
即使是具有相同数据模型的数据库,也往往不容易将数据从一个数据模型中移出或移入另一个数据模型。 这种缺乏整合导致数据的分裂。
Unix工具的另一个特征是它们使用标准输入(stdin)和标准输出(stdout)。 如果你运行一个程序并且没有指定其他的东西,标准输入来自键盘,标准输出进入屏幕。 但是,您也可以从文件输入或者将输出重定向到文件。 管道允许你将一个进程的标准输出附加到另一个进程的标准输入(使用一个小内存缓冲区,而不需要将整个中间数据流写入磁盘)。
如果需要的话,程序仍然可以直接读取和写入文件,但如果程序不关心特定的文件路径并简单地使用stdin和stdout,则Unix方法效果最佳。 这允许shell用户以任何他们想要的方式连接输入和输出; 该程序不知道或不关心输入来自哪里以及输出的位置。 (这是一种松散耦合,后期绑定或控制反转)。将输入/输出总线与程序逻辑分开,可以将小工具组合成更大的系统。
您甚至可以编写自己的程序,并将它们与操作系统提供的工具组合在一起。 你的程序只需要从stdin读取输入并将输出写入stdout,并且它还可以参与数据处理管道。 在日志分析示例中,您可以编写一个将user-agent字段转换为更灵敏的浏览器标识符的工具,或者一个将IP地址转换为国家代码的工具,并将其插入管道。 排序程序并不在乎它与操作系统的另一部分通信还是和你编写的程序通信。
但是,使用stdin和stdout具有一定的限制。 需要多个输入或输出的程序就比较麻烦。 您不能将程序的输出传送到网络连接。如果程序直接打开文件进行读取和写入,或者将另一个程序作为子进程启动,或者打开网络连接,那么 I / O由程序本身连接。 它仍然可以配置(例如通过命令行选项),但是将会减少在shell中连接输入和输出的灵活性。
使Unix工具如此成功的部分原因是它能很轻易得看到发生了什么:
因此,尽管Unix工具与关系数据库的查询优化器相比非常简单,它仍然非常有用,特别是对于实验而言。
然而,Unix工具的最大局限性在于它们只能在一台机器上运行,而这正是Hadoop出现的原因。
MapReduce有点像Unix工具,但分布在数千台机器上。 就像Unix工具一样,这是一个相当直接,蛮力但却令人惊讶的有效工具。 一个MapReduce任务可以与单个Unix进程相媲美:它需要一个或多个输入并生成一个或多个输出。
与大多数Unix工具一样,运行MapReduce任务通常不会修改输入的内容,也不会产生除输出外的任何副作用。 输出文件以顺序方式一次性写入(一旦写入文件,不会修改任何已存在的文件部分)。
相比较于Unix工具使用stdin和stdout作为输入和输出,MapReduce任务在分布式文件系统上读写文件。在Hadoop的Map-Reduce实现中,该文件系统被称为HDFS(Hadoop分布式文件系统),一个开源的谷歌文件系统(GFS)的重新实现。
除HDFS外,还有各种其他分布式文件系统,如GlusterFS和Quantcast File System(QFS)。 诸如Amazon S3,Azure Blob存储和OpenStack Swift 等对象存储服务在很多方面都是相似的。本章我们将主要使用HDFS作为运行示例,但这些原则适用于任何分布式文件系统。
与网络附加存储(NAS)和存储区域网络(SAN)体系结构的共享磁盘方法相比,HDFS基于无共享原则(参见第II部分的介绍)。 共享磁盘存储由集中式存储设备实施,通常使用定制硬件和专用网络基础设施(如光纤通道)。 另一方面,无共享方法不需要特殊硬件,只需要通过传统数据中心网络连接的计算机。
HDFS由在每台机器上运行的守护进程组成,它暴露了网络服务来允许其他节点访问该机器上存储的文件(假设数据中心中的每台通用计算机都附带有磁盘)。 NameNode作为中央服务器会跟踪哪些文件块存储在哪台机器上。 因此,HDFS在概念上创建一个可以使用磁盘上的空间的大文件系统,只要机器上可以运行这个守护进程。
为了容忍机器和磁盘故障,文件块被复制到多台机器上。 复制可能意味着多个机器上的相同数据存在多个副本(如第5章),或者像Reed-Solomon代码这样的擦除编码方案,它允许以比完全复制更低的存储开销恢复丢失的数据。 这些技术与RAID相似,RAID可以在连接到同一台计算机上的多个磁盘上提供冗余; 不同之处在于在分布式文件系统中,文件访问和复制是在传统数据中心网络上完成的,无需特殊硬件。
HDFS有很好的扩展性:在撰写本文时,最大的HDFS集群已经运行在数万台计算机上,总存储容量达数百peta字节。 如此大规模的应用已变得可行,因为使用商品硬件和开源软件的HDFS上的数据存储和访问成本远低于专用存储设备上同等容量的成本。
MapReduce是一个编程框架,您可以使用它编写代码来处理HDFS等分布式文件系统中的大型数据集。 理解它的最简单方法是参考第391页上的“简单日志分析”中的Web服务器日志分析示例。MapReduce中的数据处理模式与此示例非常相似:
这四个步骤可以由一个MapReduce任务执行。 步骤2(map)和4(reduce)是您编写自定义数据处理代码的地方。 步骤1(将文件分解成记录)由输入格式解析器处理。 步骤3中的排序步骤隐含在MapReduce中 - 您不必编写它,因为映射器的输出始终在给予reducer之前进行排序。
要创建MapReduce作业,您需要实现两个回调函数,mapper和reducer,其行为如下(另请参阅“MapReduce查询”第46页):
Mapper
每个输入记录都会调用一次mapper,其作用是从输入记录中提取键和值。 对于每个输入,它可以生成任意数量的键值对(包括none)。 它不会保留从一个输入记录到下一个输入记录的任何状态,因此每个记录都是独立处理的。
Reducer
MapReduce框架采用mappers产生的键值对,收集属于同一个键的所有值,并通过迭代器调用reducer来处理这些值的集合。 reducer可以产生输出记录(例如相同URL的出现次数)。
在Web服务器日志示例中,我们在第5步中第二次使用了sort命令,它按请求数对URL进行排序。 在MapReduce中,如果您需要第二个排序阶段,则可以通过编写第二个MapReduce作业并将第一个作业的输出用作第二个作业的输入来实现它。 如此看来,mapper的作用是通过将数据放入适合排序的表单中来准备数据,而reducer的作用是处理已排序的数据。
与Unix命令管道的主要区别在于,MapReduce可以在多台机器上并行执行计算,而无需编写代码来明确处理并行性。 mapper和reducer一次只能处理一条记录; 他们不需要知道他们的输入来自哪里或他们的输出是什么,所以框架可以处理机器之间复杂的移动性数据。
在分布式计算中可以使用标准Unix工具作为mappers和reducers,但更常见的是,它们被实现为传统编程语言的函数。 在Hadoop MapReduce中,mapper和reducer都是实现特定接口的Java类。 在MongoDB和CouchDB中,mappers和reducers都是JavaScript函数(请参阅第46页的“MapReduce查询”)。
图10-1显示了Hadoop MapReduce任务中的数据流。 它的并行化基于分区(参见第6章):任务的输入通常是HDFS中的一个目录,并且输入目录中的每个文件或文件块都被认为是一个单独的分区,可以由单独的mapper任务处理(由图10-1中的m1,m2和m3标记)。
每个输入文件的大小通常为数百兆字节。 MapReduce调度器(图中未显示)试图在存储输入文件副本的机器之一上运行每个mapper,前提是机器具有足够的备用RAM和CPU资源来运行map任务[26]。 这个原则被称为在数据存储的地方进行计算:它节省了将输入文件复制到网络上的过程,减少了网络负载并增加了局部性。
图10-1 拥有三个mapper和三个reducer的MapReduce任务
在大多数情况下,应该在映射任务中运行的应用程序代码,在被分配运行它的计算机上还不存在,所以MapReduce框架首先复制代码(例如,在Java程序的情况下为JAR文件 )到适当的机器。 然后它启动map任务并开始读取输入文件,一次将一条记录传递给mapper的回调。 映射器的输出由键值对组成。
计算的reduce侧也是分区的。 虽然map任务的数量由输入文件块的数量决定,但reduce任务的数量由任务的执行者配置(它可以与map任务的数量不同)。 为了确保具有相同key值的所有键值对在相同的reducer处结束,框架使用key的hash值来确定哪个reduce的任务应该接收特定的键值对(请参阅“通过key的hash分区” 第203页)。
键值对必须进行排序,但数据集可能太大而无法在单台计算机上使用常规排序算法进行排序。 排序是分阶段进行的。 首先,每个map任务都基于key的hash,通过reducer分割其输出。 使用与我们在第76页的“SSTables和LSM-树”中讨论的类似的技术,将这些分区中的每一个分区写入mapper本地磁盘上的排序文件中。
当一个mapper完成读取其输入文件并将排序后结果输出到文件,MapReduce调度程序就会通知reducers去获取该mapper的输出文件。 reducer连接到每个mapper,并为其分区下载排序后的键值对的文件。 reducer分区、排序以及从mapperd复制数据到reducers的过程,称为shuffle(这是一个令人困惑的术语 - 不像混洗一副纸牌,因为在MapReduce中它没有随机性)。
reduce任务从mapper获取文件并将它们合并在一起,并保持排序顺序。 因此,如果不同的映射器使用相同的键生成记录,则它们将被合并后输入到reducer中。
reducer被调用时,传入一个键和一个迭代器,该迭代器用相同的键逐渐扫描所有记录(在某些情况下可能不适合内存)。 reducer可以使用任意逻辑来处理这些记录,并可以生成任意数量的输出记录。 这些输出记录被写入分布式文件系统的文件上(通常是运行reducer的机器的本地磁盘上的一个副本,并将相同的副本拷贝到其他机器上)。
单个MapReduce任务可解决的问题范围有限。 参考日志分析示例,一个MapReduce任务可以确定每个URL页面的浏览次数,但不是最常用的URL,因为这需要第二轮排序。
因此,将MapReduce任务链接到工作流中非常常见,例如,一个任务的输出成为下一个任务的输入。 Hadoop Map-Reduce框架对工作流程没有任何特别的支持,所以这个链接是通过目录名隐式完成的:第一个任务必须配置其输出所要写入HDFS中的指定目录,第二个作业必须是配置相同读取目录名称来作为它的输入。 从MapReduce框架的角度来看,他们是两个独立的工作。
因此,链式MapReduce任务不像Unix命令的管道(它将一个进程的输出直接作为输入传递给另一个进程,只使用一个小内存缓冲区),更像是一系列命令,其中每个命令的输出都写入临时文件,下一个命令从临时文件中读取。 这种设计有优点和缺点,我们将在第419页的“中间状态的物化”中讨论。
批处理任务的输出仅在任务成功完成时被认为有效(MapReduce放弃了失败任务的部分输出)。 因此,工作流程中的一项任务只有在先前的任务 - 即直接生产输入的任务 - 成功完成时才能开始。 为了处理这些任务执行之间的依赖关系,Hadoop的各种工作流调度器已经开发完成,包括Oozie,Azkaban,Luigi,Airflow和Pinball。
这些调度程序还具有管理功能,在维护大量批任务时很有用。 包含50到100个MapReduce作业的工作流在构建推荐系统时很常见,而在大型组织中,许多不同的团队可能运行不同的任务来读取彼此的输出。 工具支持对管理这种复杂的数据流很重要。
Hadoop的各种高级工具(如Pig,Hive,Cascading,Crunch和FlumeJava)还设置了具有多个阶段的MapReduce工作流程,这些工作阶段将自适应地连接在一起。
我们在第2章的数据模型和查询语言中讨论了join,但是我们还没有深入研究如何实现join。 现在我们再回头看看。
在许多数据集中,通常一条记录与另一条记录有关联:关系模型中的外键,文档模型中的文档引用或图模型中的边。 只要有一些代码需要访问该关联两端的记录(包含引用的记录和被引用的记录),连接就是必需的。 正如第2章所讨论的,非规范化可以减少对连接的需求,但通常不会完全删除它。
在数据库中,如果您执行的查询只涉及少量记录,那么数据库通常会使用索引来快速查找感兴趣的记录(请参阅第3章)。 如果查询涉及连接,则可能需要多个索引查找。 然而,MapReduce没有索引的概念 - 至少不是通常意义上的。
当MapReduce作业被赋予一组文件作为输入时,它会读取所有这些文件的全部内容; 一个数据库会将这个操作称为全表扫描。 如果您只想读取少量记录,则与索引查找相比,全表扫描的代价非常昂贵。 但是,在分析查询中(请参阅第90页上的“事务处理或分析?”),通常需要计算大量记录的聚合。 在这种情况下,扫描整个输入可能是相当合理的事情,特别是如果您可以在多台机器上并行处理这些数据。
当我们在批处理的背景下讨论连接时,我们的意思是解决数据集内某些关联的所有事件。 例如,我们假设一项工作是同时为所有用户处理数据,而不仅仅是为一个特定用户查找数据(这可以通过索引更高效地完成)。
图10-2显示了批量作业中join的典型示例。 左边是事件日志,描述登录用户在网站上做的事情(称为活动事件或点击流数据),右侧是用户数据库。 您可以将此示例看作是星型模式的一部分(请参阅“星型模型和雪花模型:分析模式”(位于第93页)):事件日志是事实表,用户数据是其中一个维度。
分析任务可能需要将用户活动与用户个人资料信息相关联:例如,如果个人资料包含用户的年龄或出生日期,则系统可以确定哪些年龄组最受欢迎。 但是,活动事件仅包含用户标识,而不包含完整的用户配置文件信息。 在每个活动事件中嵌入该配置文件信息很可能太过浪费。 因此,活动事件需要与用户配置文件数据库连接。
这个join的最简单实现将逐个遍历活动事件并根据用户ID查询用户数据库(在远程服务器上)。 我们可以这么做,但它的性能会非常差:处理吞吐量将受到数据库服务器的往返时间的限制,本地缓存的有效性将非常依赖于数据分布, 并行运行大量的查询可能会轻易压跨数据库。
为了在批处理过程中实现良好的吞吐量,计算必须(尽可能)在一台机器上进行。 通过网络为每一个记录做随机查询的效率太低。 而且,查询远程数据库意味着批处理作业变得不确定,因为远程数据库中的数据可能会改变。
因此,更好的方法是获取用户数据库的副本(例如,使用ETL进程从数据库备份中提取数据 - 参见第91页上的“数据仓库”),并将其放入与用户行为日志相同的分布式文件系统中。 然后,您可以将用户数据库放在HDFS中的一组文件中,并将用户活动记录放在另一组文件中,然后就可以使用MapReduce将所有相关记录放在同一位置从而高效地处理它们。
回想一下,mapper的目的是从每个输入记录中提取一个键和值。 在图10-2的情况下,这个键就是用户ID:一组mapper会覆盖活动事件(提取用户ID作为键和活动事件作为值),而另一组mapper会检查用户数据库(提取用户ID作为键和用户的出生日期作为值)。 这个过程如图10-3所示。
图10-3 reducer侧根据用户ID进行排序合并 如果输入数据集被分成多个文件,每个文件可以被多个映射器并行处理。
当MapReduce框架根据key对mapper的输出进行分区并对键值对进行排序后,效果是所有活动事件和具有相同用户ID的用户记录在reducer的输入中彼此相邻。 Map-Reduce作业甚至可以指定要排序的记录,以便reducer始终首先从用户数据库中查看记录,然后按时间戳顺序查看活动事件 - 此技术被称为次级排序。
reducer可以轻松执行join逻辑:每个用户ID的记录都会调用一次reducer函数,并且由于二次排序,第一个值应该是来自用户数据库的出生日期记录。 reducer将出生日期存储在局部变量中,然后使用相同的用户ID遍历活动事件,输出访问的网址和访问者年龄对。 随后的Map- Reduce任务可以计算每个URL访问者的年龄分布,并按年龄进行分组。
由于reducer一次处理特定用户ID的所有记录,因此它只需要一次将一个用户记录保存在内存中,并且它永远不需要通过网络发出任何请求。 该算法被称为排序合并连接,mapper的输出按键排序,然后reducer将来自两侧的排序的记录列表join在一起。
在排序合并连接中,mapper和排序过程确保将要执行连接操作的特定用户ID的所有必需数据放在一起:一次调用reducer。 在预先排列了所有必需的数据之后,reducer可以是一个相当简单的单线程代码,可以通过高吞吐量和低内存开销的记录进行传播。
查看这种体系结构的一种方式是将mapper的“消息”发送给reducer。 当mapper发送出键值对时,键的作用类似于值应该传递到的目标地址。 即使key只是一个任意字符串(不是像IP地址和端口号那样的实际网络地址),但是它的行为就像一个地址:具有相同key的所有键值对对将被传送到同一个目标(同一个reducer)。
MapReduce编程模型已将计算的物理网络通信(从正确的计算机获取数据)与应用程序逻辑(处理完数据后进行处理)分开。 这种分离与数据库的经典用法形成了鲜明对比,从数据库中获取数据的请求经常发生在一段应用程序代码的底层。 由于MapReduce处理所有的网络通信,因此它也避免了应用程序代码会出现部分的故障导致任务失败,例如一个节点崩溃时,MapReduce会在不影响应用程序逻辑的情况下,直接重试失败的任务。
除了join之外,“将相关数据引入同一地点”模式的另一种常见用法是按某个键对记录进行分组(如SQL中的GROUP BY子句)。 所有使用相同key的记录一个组,然后在每个组中执行某种聚合 - 例如:
使用MapReduce实现这种分组操作的最简单方法是设置mapper,以便它们生成的键值对使用所希望的分组键。然后分区和排序过程将同一个key中的所有记录汇集到同一个reducer中。 因此,在MapReduce之上实现时,分组和连接看起来非常相似。
分组的另一个常见用途是整理特定用户会话的所有活动事件,以便找出用户采取的一系列操作 - 一种称为会话化的过程。 例如,可以使用此类分析来确定显示网站新版本的用户是否比那些显示旧版本(A / B测试)的用户更有可能进行购买,或者评估是否值得进行某些营销活动。
如果您有多个Web服务器处理用户请求,特定用户的活动事件很可能分散在各种不同服务器的日志文件中。 您可以使用会话cookie,用户ID或类似标识符作为分组键,并将特定用户的所有活动事件放在一起,同时在不同分区之间分发不同用户的活动,从而实现会话化。
如果与单个key对应的数据量很大,那么“将具有相同key的所有记录放到同一位置”的模式会崩溃。 例如,在社交网络中,大多数用户可能会和几百个人联系,但少数名人可能拥有数百万的关注者。 这种不成比例的活动数据库记录被称为关键对象或热键。
在单个reducer中收集与名人有关的所有活动(例如回复他们发布的内容)可能会导致严重的偏差(也称为热点) - 也就是说,一个reducer必须比其他reducer处理更多的记录(请参阅“ 倾斜的工作量和减轻热点“)。 由于MapReduce作业只在所有mapper和reducer完成时才完成,因此任何后续作业都必须等待最慢的reducer完成,然后才能继续。
如果join的输入具有热键,则可以使用几种算法进行补偿。 例如,Pig中的倾斜连接方法首先运行取样任务以确定哪些键是热键。 执行实际的join时,mapper会发送一个热键的相关的记录去多个reducer中,这个选择是随机的(与传统的MapReduce相比,它根据key的hash值确定地选择reducer)。 对于join的另一部分,需要将与热键相关的记录复制到处理该key的所有reducer中。
这种技术将处理热键的工作分散到多个reducers上,这使得它可以更好地并行化,代价是不得不将join的其余的部分复制到多个reducer上。 Crunch中的分片连接方法很相似,但需要明确指定热键,而不是使用采样作业。 这种技术也非常类似于我们在第205页的“倾斜工作负载和减轻热点”中讨论的技术,使用随机化缓解分区数据库中的热点。
Hive的join倾斜优化采用了另一种方法。 它需要在表的元数据中明确指定热键,并将与这些键相关的记录与其余文件分开存放。 在该表上执行join时,它将使用map侧的join(请参阅下一节)获取热键。
当通过热键对记录进行分组并聚合它们时,将分两个阶段执行分组。 第一个MapReduce阶段将记录发送到随机的reducer,以便每个reducer对热键记录的子集执行分组,并为每个键输出更紧凑的聚合值。 第二个Map-Reduce作业将来自所有第一阶段的reducer的值合并为每个键的单个值。
上一节描述的join算法在reducer中执行实际的join逻辑,因此被称为reduce侧的join。 mapper负责准备输入数据:从每个输入记录中提取键和值,将键值对分配给reducer分区,并按键排序。
reduce侧join的实现的优点是不需要对输入数据做任何假设:无论其属性和结构如何,mapper都可以准备好数据来做join。 然而,不利的一面是,排序,复制数据到reducer以及合并reducer的输入,这些操作的开销很大。 根据可用内存缓冲区的不同,当数据通过MapReduce阶段时,数据可能会多次写入磁盘。
另一方面,如果您可以对输入数据做出某些假设,则可以通过使用所谓的map端join来加快join速度。 这种方法使用了一个缩减的MapReduce任务,没有reducer,也没有排序。 相反,每个mapper只是从分布式文件系统中读取一个输入文件块,并输出文件写入文件系统 - 就是这样。
执行map-side join的最简单方法适用于大数据集与小数据集join的情况。 特别是,小数据集必须足够小,以便可以将其全部加载到每个reducer的内存中。
例如,假设在图10-2的情况下,用户数据库足够小可以放到内存中。 在这种情况下,当mapper程序启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的hash表中。 完成此操作后,mapper程序可以扫描用户活动事件,并在hash表中查找每个事件的用户标识。
这里仍然有几个map任务:一个用于join大输入的每个文件块(在图10-2的示例中,活动事件是大输入)。 这些mapper将小块输入加载到内存中。
这种简单但有效的算法被称为广播散列连接:广播一词反映了这样一个事实,即每个大输入分区的mapper都会读取整个小输入(所以小输入实际上“广播”到大的输入的所有分区),并且hash一词反映它使用哈希表。 此连接方法由Pig(名为“replicated join”),Hive(“MapJoin”),Cascading和Crunch支持。 它也用于数据仓库查询引擎,如Impala。
相比较于加载小的join的输入到内存的hash表中,另一种方法是将小的join的输入存储在本地磁盘的只读索引中。 该索引中经常使用的部分将保留在操作系统的页面缓存中,因此这种方法可以提供与内存中哈希表几乎一样快的随机访问查找,并不需要数据集存在内存中。
如果以相同的方式对map侧join的输入进行分区,则hash join的方法可以独立应用于每个分区。 在图10-2的情况下,您可以根据用户标识的最后一位十进制数字来安排活动事件和用户数据库的分区(因此任何一边都有10个分区)。 例如,mapper3首先将所有ID为3的用户加载到hash表中,然后扫描ID为3的每个用户的所有活动事件。
如果分区正确完成,可以确定的是所有需要join的记录都位于相同编号的分区中,因此每个mapper只能从每个输入数据集中读取一个分区就足够了。 这样做的优点是每个mapper都可以将较少量的数据加载到其hash表中。
这种方法仅适用于两个join的输入具有相同的分区数,数据根据相同的key和相同的hash函数分区。 如果输入由先前执行group操作的MapReduce任务生成,那么这将会是一个合理的假设。
分区hash join在Hive中称为bucketed映射join。
如果输入数据集不仅以相同的方式进行分区,而且还基于相同的键进行排序,则适用map join的另一种变体。在这种情况下,输入是否足够小以适应内存并不重要,因为mapper可以执行通常由reducer完成的合并操作:按照key的升序,增量地读取两个输入文件,来匹配同一个key的不同数据。
如果可以做map侧的合并join,则意味着先前的MapReduce任务首先将输入数据集导入此分区和并排序。 原则上,这种join已经在之前的renduce阶段完成。 但是,仍然可以在单独的map任务中执行合并join,例如,除了此特定的join之外,还需要将分区和排序的数据集用于其他用途。
当MapReduce的join结果输出到下游任务时,选择map端或reduce端join会影响输出的结构。 reduce-side join的输出按连接键进行分区和排序,而map-side join的输出按照大规模输入数据相同的方式进行分区和排序(因为无论是使用分区join还是广播join,操作的都是大文件块的每一个小文件)。
如前所述,map侧join也会对其输入数据集的大小,排序和分区做出更多假设。 在优化join策略时,了解分布式文件系统中数据集的物理布局变得非常重要:仅仅知道编码格式和数据存储目录的名称是不够的; 您还必须知道需要分区和排序的数据的分区数量和key值。
在Hadoop生态系统中,这种有关数据集分区的元数据通常在HCatalog和Hive Metastore中维护。
我们讨论了很多关于实现MapReduce任务工作流的算法,但是我们忽略了一个重要问题:一旦任务结束,最后的结果是什么? 为什么我们要执行这些任务?
在数据库查询的讨论中,我们区分了联机事务处理过程和联机分析处理。 我们看到OLTP查询通常使用索引根据key查找少量记录,以便将其呈现给用户(例如,在网页上)。 另一方面,分析查询通常扫描大量记录,执行分组和汇总,其输出通常具有报告的形式:一个图表,显示一段时间内度量标准的变化,或根据一些排名计算出的top10,或将大量的数据进行细分。 这样的报告的阅读者通常是需要做出商业决策的分析师或经理。
批处理适合用在哪里? 这不是事务处理,是分析处理吗? 它更接近分析,因为批处理通常扫描大量的输入数据集。 但是,MapReduce作业的工作流程与用于分析目的的SQL查询不同(请参阅第417页的“比较Hadoop与分布式数据库”)。 批处理过程的输出通常不是报告,而是其他的结构类型。
谷歌最初使用的MapReduce是为其搜索引擎建立索引,用了5到10个MapReduce的任务去实现。尽管Google不再使用MapReduce去建立索引,但是如果您通过构建搜索索引的初衷来了解MapReduce,那么它有助于理解MapReduce。(即使在今天,Hadoop MapReduce仍然是构建Lucene / Solr索引的好方法。)