Hadoop-实践指南第二版-全-
Hadoop 实践指南第二版(全)
原文:Hadoop in Practice 2e
译者:飞龙
第一部分. 背景 和 基础
第一部分 本书的由 第一章 和 第二章 组成,涵盖了重要的 Hadoop 基础知识。
第一章 介绍了 Hadoop 的组件及其生态系统,并提供了在单个主机上安装伪分布式 Hadoop 设置的说明,以及一个能够让您运行本书中所有示例的系统。第一章 还涵盖了 Hadoop 配置的基础知识,并指导您如何在新配置上编写和运行 MapReduce 作业。
第二章 介绍了 YARN,这是 Hadoop 2 版本中的一个新且令人兴奋的发展,将 Hadoop 从仅支持 MapReduce 的系统转变为支持多个执行引擎的系统。鉴于 YARN 对社区来说是新的,本章的目标是探讨一些基础知识,例如其组件、配置如何工作,以及 MapReduce 作为 YARN 应用程序的工作方式。第二章 还概述了一些 YARN 在 Hadoop 上启用执行的应用程序,例如 Spark 和 Storm。
第一章. Hadoop 的心跳
本章涵盖
-
检查核心 Hadoop 系统的工作原理
-
理解 Hadoop 生态系统
-
运行 MapReduce 作业
我们生活在大数据时代,我们每天需要处理的数据量已经超过了单个主机的存储和处理能力。大数据带来了两个基本挑战:如何存储和处理大量数据,更重要的是,如何理解数据并将其转化为竞争优势。
Hadoop 通过有效地存储和提供大量数据的计算能力,在市场上填补了一个空白。它由一个分布式文件系统组成,并提供了一种在机器集群上并行执行程序的方法(参见图 1.1)。您可能已经接触过 Hadoop,因为它已被雅虎、Facebook 和 Twitter 等技术巨头采用,以解决它们的大数据需求,并且它在所有工业领域都取得了进展。
图 1.1. Hadoop 环境是一个运行在通用硬件上的分布式系统。

因为您来到这本书是为了获得一些 Hadoop 和 Java 的实践经验,^([1]) 我将从简要概述开始,然后向您展示如何安装 Hadoop 并运行 MapReduce 作业。到本章结束时,您将对 Hadoop 的基本原理有一个基本的复习,这将使您能够继续学习与之相关的更具挑战性的方面。
¹ 为了从本书中受益,你应该有一些 Hadoop 的实际经验,并理解 MapReduce 和 HDFS 的基本概念(在 Chuck Lam 的 2010 年出版的 Manning 的Hadoop in Action中介绍)。此外,你应该具备 Java 的中级知识—Joshua Bloch 的Effective Java,第 2 版(Addison-Wesley,2008 年)是这方面的优秀资源。
让我们从详细概述开始。
1.1. 什么是 Hadoop?
Hadoop 是一个提供分布式存储和计算能力的平台。Hadoop 最初是为了解决 Nutch 中存在的可扩展性问题而构思的,Nutch 是一个开源的爬虫和搜索引擎。当时,谷歌发表了描述其新颖的分布式文件系统 Google 文件系统(GFS)和用于并行处理的计算框架 MapReduce 的论文。这些论文概念在 Nutch 中的成功实现导致它被分割成两个独立的项目,其中第二个项目成为 Hadoop,一个一流的 Apache 项目。
² Nutch 项目,以及由此扩展的 Hadoop,由 Doug Cutting 和 Mike Cafarella 领导。
在本节中,我们将从架构的角度来看 Hadoop,考察行业如何使用它,并考虑一些其弱点。一旦我们了解了这些背景知识,我们将探讨如何安装 Hadoop 并运行一个 MapReduce 作业。
如图 1.2 所示,Hadoop 本身是一个分布式主从架构^([3)),它由以下主要组件组成:
³ 一种通信模型,其中一个进程,称为主进程,控制一个或多个其他进程,称为从进程。
图 1.2. 高级 Hadoop 2 主从架构

-
Hadoop 分布式文件系统(HDFS)用于数据存储。
-
Yet Another Resource Negotiator (YARN),在 Hadoop 2 中引入,是一个通用调度器和资源管理器。任何 YARN 应用程序都可以在 Hadoop 集群上运行。
-
MapReduce,一种基于批处理的计算引擎。在 Hadoop 2 中,MapReduce 被实现为一个 YARN 应用程序。
Hadoop 固有的特性是数据分区和大数据集的并行计算。其存储和计算能力随着 Hadoop 集群中主机数量的增加而扩展;拥有数百个主机的集群可以轻松达到 PB 级的数据量。
在本节的第一个步骤中,我们将检查 HDFS、YARN 和 MapReduce 架构。
1.1.1. 核心 Hadoop 组件
要理解 Hadoop 的架构,我们将首先查看 HDFS 的基本知识。
HDFS
HDFS 是 Hadoop 的存储组件。它是一个基于 Google 文件系统(GFS)论文的分布式文件系统^([4))。HDFS 针对高吞吐量进行了优化,在读取和写入大文件(千兆字节及以上)时表现最佳。为了支持这种吞吐量,HDFS 使用异常大的(对于文件系统而言)块大小和数据局部性优化来减少网络输入/输出(I/O)。
⁴ 请参阅“谷歌文件系统”,
research.google.com/archive/gfs.html。
可扩展性和可用性也是 HDFS 的关键特性,部分得益于数据复制和容错。HDFS 会根据配置的次数复制文件,对软件和硬件故障都具有容错性,并在节点失败时自动重新复制数据块。
图 1.3 展示了 HDFS 组件的逻辑表示:Name-Node 和 DataNode。它还显示了一个使用 Hadoop 文件系统库来访问 HDFS 的应用程序。
图 1.3. 一个与主 NameNode 和从属 DataNode 通信的 HDFS 客户端

Hadoop 2 为 HDFS 引入了两个重要的新特性——联邦和可用性(HA):
-
联邦允许 HDFS 元数据在多个 NameNode 主机之间共享,这有助于 HDFS 的可扩展性,同时也提供了数据隔离,允许不同的应用程序或团队运行自己的 NameNode,而不用担心会影响到同一集群中的其他 NameNode。
-
HDFS 的高可用性消除了 Hadoop 1 中存在的单点故障,即 NameNode 灾难会导致集群故障。HDFS HA 还提供了故障转移(备用 Name-Node 从失败的 NameNode 接管工作的过程)自动化的能力。
现在你已经对 HDFS 有了一些了解,是时候看看 YARN,Hadoop 的调度器了。
YARN
YARN 是 Hadoop 的分布式资源调度器。YARN 是 Hadoop 版本 2 中的新功能,旨在解决 Hadoop 1 架构的挑战:
-
大于 4,000 节点的部署遇到了可扩展性问题,增加额外的节点并没有带来预期的线性可扩展性改进。
-
只支持 MapReduce 工作负载,这意味着它不适合运行需要迭代计算的学习算法等执行模型。
对于 Hadoop 2,这些问题通过从 MapReduce 中提取调度功能并将其重构为一个通用的应用程序调度器(称为 YARN)来解决。这一变化使得 Hadoop 集群不再仅限于运行 MapReduce 工作负载;YARN 允许在 Hadoop 上原生支持一系列新的工作负载,并允许不同的处理模型,如图处理和流处理,与 MapReduce 共存。第二章 和 第十章 讲述了 YARN 以及如何编写 YARN 应用程序。
YARN 的架构很简单,因为它的主要角色是在 Hadoop 集群中调度和管理资源。图 1.4 展示了 YARN 核心组件的逻辑表示:ResourceManager 和 NodeManager。还展示了特定于 YARN 应用程序的组件,即 YARN 应用程序客户端、ApplicationMaster 和容器。
图 1.4. 显示核心 YARN 组件和 YARN 应用程序组件之间典型通信的逻辑 YARN 架构

为了完全实现通用分布式平台的梦想,Hadoop 2 引入了另一个变化——能够在各种配置中分配容器。Hadoop 1 有“槽位”的概念,这是在单个节点上允许运行的固定数量的 map 和 reduce 进程。这在集群利用率方面是浪费的,并在 MapReduce 操作期间导致资源未充分利用,并且它还为 map 和 reduce 任务设定了内存限制。使用 YARN,ApplicationMaster 请求的每个容器都可以具有不同的内存和 CPU 特性,这使得 YARN 应用程序对其所需资源有完全的控制权。
你将在第二章和 10 章中更详细地了解 YARN,那里你将学习 YARN 的工作原理以及如何编写 YARN 应用程序。接下来是对 MapReduce 的考察,这是 Hadoop 的计算引擎。
MapReduce
MapReduce 是一个基于批处理的、分布式计算框架,其模式是模仿 Google 的 MapReduce 论文。^([5)] 它允许你在大量原始数据上并行化工作,例如将 Web 日志与来自 OLTP 数据库的关系数据相结合,以模拟用户如何与你的网站互动。这种类型的工作,如果使用传统的串行编程技术,可能需要几天或更长时间,但使用 Hadoop 集群上的 MapReduce 可以将其缩短到几分钟。
⁵ 请参阅“MapReduce:在大型集群上简化的数据处理”,
research.google.com/archive/mapreduce.html。
MapReduce 模型通过抽象掉与分布式系统工作相关的复杂性(如计算并行化、工作分配和处理不可靠的硬件和软件)来简化并行处理。通过这种抽象,MapReduce 允许程序员专注于解决业务需求,而不是陷入分布式系统复杂性的泥潭。
MapReduce 将客户端提交的工作分解成小的并行化 map 和 reduce 任务,如图 1.5 所示。6] MapReduce 中使用的 map 和 reduce 构造是从 Lisp 函数式编程语言中借用的,并且它们使用无共享模型来消除任何可能添加不必要同步点或状态共享的并行执行依赖关系。
⁶ 无共享架构是一种分布式计算概念,它代表了每个节点都是独立和自给自足的观点。
图 1.5. 客户端向 MapReduce 提交作业,将工作分解成小的 map 和 reduce 任务

程序员的角色是定义 map 和 reduce 函数,其中 map 函数输出键/值元组,这些元组由 reduce 函数处理以生成最终输出。图 1.6 显示了关于其输入和输出的 map 函数的伪代码定义。
图 1.6. 以键/值对作为输入的 map 函数的逻辑视图

MapReduce 的强大之处在于洗牌和排序阶段中映射输出和减少输入之间,如图图 1.7 所示。
图 1.7. MapReduce 的洗牌和排序阶段

图 1.8 显示了 reduce 函数的伪代码定义。
图 1.8. 生成输出文件、NoSQL 行或任何数据目的地的 reduce 函数的逻辑视图

随着 Hadoop 2 中 YARN 的出现,MapReduce 已被重写为 YARN 应用程序,现在被称为 MapReduce 2(或 MRv2)。从开发者的角度来看,Hadoop 2 中的 MapReduce 与 Hadoop 1 中的工作方式几乎相同,为 Hadoop 1 编写的代码在版本 2 上无需代码更改即可执行.^([7]) MRv2 中的物理架构和内部管道有所变化,这些变化在第二章中进行了更详细的探讨。
⁷ 一些代码可能需要针对 Hadoop 2 二进制文件重新编译才能与 MRv2 一起工作;有关更多详细信息,请参阅第二章。
在掌握了一些 Hadoop 基础知识之后,是时候看看 Hadoop 生态系统以及本书涵盖的项目了。
1.1.2. Hadoop 生态系统
Hadoop 生态系统多样且每天都在增长。跟踪所有与 Hadoop 以某种形式交互的各种项目是不可能的。本书的重点是用户目前采用率最高的工具,如图图 1.9 所示。
图 1.9. 本书涵盖的 Hadoop 和相关技术

MapReduce 和 YARN 并非易事,这意味着许多这些与 Hadoop 相关项目的目标是为程序员和非程序员提高 Hadoop 的可访问性。本书将涵盖图 1.9 中列出的许多技术,并在各自的章节中详细描述它们。此外,附录还包括了本书涵盖的技术描述和安装说明。
本书对 Hadoop 生态系统的覆盖范围
Hadoop 生态系统每天都在增长,通常有多种具有重叠功能和优势的工具。本书的目标是提供涵盖核心 Hadoop 技术的实用技术,以及一些普遍且对 Hadoop 至关重要的生态系统技术。
让我们看看您集群的硬件要求。
1.1.3. 硬件要求
术语通用硬件常用来描述 Hadoop 的硬件要求。确实,Hadoop 可以在你能够找到的任何旧服务器上运行,但你仍然希望你的集群性能良好,并且你不想让你的运维部门忙于诊断和修复硬件问题。因此,通用指的是具有双插槽的中端机架服务器,尽可能多的纠错 RAM,以及针对 RAID 存储优化的 SATA 驱动器。由于 HDFS 已经内置了复制和错误检查,因此强烈不建议在用于存储 HDFS 内容的 DataNode 文件系统上使用 RAID;在 NameNode 上,强烈建议使用 RAID 以提供额外的安全性.^([8])
⁸ HDFS 使用磁盘来持久存储关于文件系统的元数据。
从网络拓扑的角度来看,关于交换机和防火墙,所有主节点和从节点都必须能够相互打开连接。对于小型集群,所有主机都会运行 1 GB 网络卡,连接到单个高质量交换机。对于大型集群,请考虑具有至少多个 1 GB 上行链路的 10 GB 机架交换机,这些交换机连接到双中央交换机。客户端节点也需要能够与所有主节点和从节点通信,但如果需要,这种访问可以从防火墙后面进行,该防火墙仅允许从客户端建立连接。
从软件和硬件的角度回顾了 Hadoop 之后,你可能已经对谁可能从使用它中受益有了很好的了解。一旦你开始使用 Hadoop,你将需要选择一个发行版来使用,这是下一个话题。
1.1.4. Hadoop 发行版
Hadoop 是一个 Apache 开源项目,软件的常规版本可以直接从 Apache 项目的网站(hadoop.apache.org/releases.html#Download)下载。你可以从网站上下载并安装 Hadoop,或者使用来自商业发行版的快速启动虚拟机,这对于你是 Hadoop 新手且希望快速启动和运行来说通常是一个很好的起点。
在你用 Hadoop 开胃之后并决定在生产中使用它之后,你需要回答的下一个问题是使用哪个发行版。你可以继续使用纯 Hadoop 发行版,但你将需要建立内部专业知识来管理你的集群。这不是一个微不足道的工作,通常只有在那些对拥有专门负责运行和管理其集群的 Hadoop DevOps 工程师感到舒适的组织中才能成功。
或者,您可以转向 Hadoop 的商业发行版,这将为您提供企业级管理软件的额外好处,当您规划集群或遇到夜间问题需要帮助时,可以咨询的支持团队,以及快速修复您遇到的软件问题的可能性。当然,这一切都不是免费的(或便宜的!),但如果您在 Hadoop 上运行关键任务服务,并且没有专门的团队来支持您的基础设施和服务,那么选择商业 Hadoop 发行版是明智的。
选择适合您的分布
非常推荐您与主要供应商合作,从功能、支持和成本的角度了解哪种分布适合您的需求。请记住,每个供应商都会强调他们的优势,同时也会暴露其竞争对手的劣势,因此与两个或更多供应商交谈将使您对分布提供的内容有更现实的了解。确保您下载并测试这些分布,并验证它们是否可以与您现有的软件和硬件堆栈集成和运行。
有许多分布可供选择,在本节中,我将简要总结每种分布并突出其一些优点。
Apache
Apache 是维护 Hadoop 核心代码和分布的组织,由于所有代码都是开源的,您可以使用您最喜欢的 IDE 打开源代码,了解底层的工作原理。从历史上看,Apache 分布的挑战在于支持仅限于开源社区的良好意愿,并且无法保证您的問題会被调查和修复。话虽如此,Hadoop 社区是一个非常支持性的社区,对问题的响应通常是快速的,即使实际的修复可能需要更长的时间,您可能无法承担。
随着 Apache Ambari 的出现,管理得到了简化,Apache Hadoop 分布现在更具吸引力,它提供了一个 GUI 来帮助配置和管理您的集群。尽管 Ambari 非常有用,但将其与商业供应商的产品进行比较是值得的,因为商业工具通常更为复杂。
Cloudera
Cloudera 是最资深的 Hadoop 分布,它雇佣了大量的 Hadoop(以及 Hadoop 生态系统)提交者。与 Mike Cafarella 共同最初创建 Hadoop 的 Doug Cutting 现在是 Cloudera 的首席架构师。总的来说,这意味着与提交者较少的 Hadoop 分布相比,在 Cloudera 中解决错误修复和功能请求的机会更大。
除了维护和支持 Hadoop 之外,Cloudera 通过开发解决 Hadoop 弱点领域的项目,在 Hadoop 空间进行了创新。一个典型的例子是 Impala,它提供了一个基于 Hadoop 的 SQL 系统,类似于 Hive,但专注于近似实时用户体验,而 Hive 传统上是一个高延迟系统。Cloudera 还在进行许多其他项目:亮点包括 Flume,一个日志收集和分发系统;Sqoop,用于在 Hadoop 中移动关系型数据;以及 Cloudera Search,它提供近似实时的搜索索引。
Hortonworks
Hortonworks 由大量 Hadoop 提交者组成,它在快速解决核心 Hadoop 及其生态系统项目的问题和功能请求方面提供了与 Cloudera 相同的优势。
从创新的角度来看,Hortonworks 采取了与 Cloudera 略有不同的方法。一个例子是 Hive:Cloudera 的方法是开发一个全新的基于 Hadoop 的 SQL 系统,但 Hortonworks 则着眼于在 Hive 内部进行创新,以去除其高延迟的束缚,并添加新的功能,如对 ACID 的支持。Hortonworks 也是下一代 YARN 平台的主要推动者,这是保持 Hadoop 相关性的关键战略部分。同样,Hortonworks 使用 Apache Ambari 作为其管理工具,而不是开发内部专有管理工具,这是其他发行版所采取的道路。Hortonworks 专注于开发和扩展 Apache 生态系统工具,这对社区有直接的好处,因为它使其工具对所有用户可用,无需支持合同。
MapR
MapR 在其团队中的 Hadoop 提交者比这里讨论的其他发行版要少,因此其修复和塑造 Hadoop 未来的能力可能比其同行更有限。
从创新的角度来看,MapR 在 Hadoop 支持方面采取了与同行截然不同的方法。从一开始,它就决定 HDFS 不是一个企业级文件系统,因此开发了它自己的专有文件系统,该系统提供了引人注目的功能,如 POSIX 兼容性(提供随机写入支持和原子操作)、高可用性、NFS 挂载、数据镜像和快照。其中一些功能已引入到 Hadoop 2 中,但 MapR 从一开始就提供了这些功能,因此可以预期这些功能是稳健的。
作为评估标准的一部分,需要注意的是,MapR 堆栈的部分,如其文件系统和其 HBase 提供的产品,是封闭源代码和专有性质。这影响了您的工程师浏览、修复并向社区贡献补丁的能力。相比之下,Cloudera 和 Hortonworks 的堆栈大多是开源的,尤其是 Hortonworks,它在整个堆栈,包括管理平台,都是开源的。
MapR 的显著亮点包括作为 Amazon 自家的 Elastic MapReduce 的替代方案在 Amazon 的云平台上提供,以及与 Google 的 Compute Cloud 集成。
我只是刚刚触及了各种 Hadoop 发行版提供的优势的表面;你的下一步可能将是联系供应商,并开始自己尝试这些发行版。
接下来,让我们看看目前使用 Hadoop 的公司,以及它们使用 Hadoop 的方式。
1.1.5. 谁在使用 Hadoop?
Hadoop 在高科技公司中的渗透率很高,它开始进入包括企业(Booz Allen Hamilton、J.P. Morgan)、政府(NSA)和医疗保健在内的广泛领域。
Facebook 使用 Hadoop、Hive 和 HBase 进行数据仓库和实时应用程序服务。9 Facebook 的数据仓库集群规模达到千兆字节,拥有数千个节点,并且它们使用基于 HBase 的独立实时集群进行消息传递和实时分析。
^(9) 请参阅 Dhruba Borthakur 在 Facebook 上发表的“查看我们使用 Apache Hadoop 的代码背后的情况”文章,“
mng.bz/4cMc”。Facebook 还开发了其自己的 SQL-on-Hadoop 工具 Presto,并正在从 Hive 迁移(参见 Martin Traverso 的“Presto:在 Facebook 上交互 PB 级数据”,mng.bz/p0Xz)。
Yahoo! 使用 Hadoop 进行数据分析、机器学习、搜索排名、电子邮件反垃圾邮件、广告优化、ETL 等。总计,它拥有超过 40,000 台运行 Hadoop 的服务器,存储容量达到 170 PB。Yahoo! 还在运行第一个大规模的 YARN 部署,集群节点数高达 4,000 个。11
^(10) 提取、转换和加载(ETL)是从外部源提取数据,将其转换为满足项目需求,并将其加载到目标数据存储的过程。ETL 是数据仓库中的常见过程。
^(11) 关于 YARN 及其在 Yahoo! 的使用的更多细节,请参阅 Vinod Kumar Vavilapalli 等人撰写的《Apache Hadoop YARN:另一个资源协调器》,“www.cs.cmu.edu/~garth/15719/papers/yarn.pdf”。
Twitter 是一个主要的大数据创新者,它通过 Scalding(Cascading 的 Scala API)、Summingbird(可用于实现 Nathan Marz 的 lambda 架构的部分组件)以及其他各种宝石(如 Bijection、Algebird 和 Elephant Bird)等项目对 Hadoop 做出了显著的贡献。
eBay、Samsung、Rackspace、J.P. Morgan、Groupon、LinkedIn、AOL、Spotify 和 StumbleUpon 等其他组织也在 Hadoop 上进行了大量投资。微软与 Hortonworks 合作,以确保 Hadoop 在其平台上运行。
谷歌在其 MapReduce 论文中指出,它使用自己的 MapReduce 版本 Caffeine^(12) 从爬取数据创建其网页索引。谷歌还强调了 MapReduce 的应用,包括分布式 grep、URL 访问频率(来自日志数据)和术语向量算法,该算法确定主机的热门关键词。
^(12) 2010 年,谷歌转向了一个名为 Caffeine 的实时索引系统;请参阅谷歌博客上的“我们的新搜索索引:Caffeine”(2010 年 6 月 8 日),
googleblog.blogspot.com/2010/06/our-new-search-index-caffeine.html。
每天使用 Hadoop 的组织数量都在增长,如果你在一家财富 500 强公司工作,你几乎肯定会在某种程度上使用 Hadoop 集群。很明显,随着 Hadoop 的不断成熟,其采用率将继续增长。
与所有技术一样,能够有效地使用 Hadoop 的关键部分是了解其不足之处,并设计和架构你的解决方案以尽可能多地减轻这些不足。
1.1.6. Hadoop 的局限性
高可用性和安全性通常是人们提到 Hadoop 时最关心的几个问题之一。许多这些问题在 Hadoop 2 中都得到了解决;让我们更详细地看看截至 2.2.0 版本的一些弱点。
使用 Hadoop 1 及更早版本的企业组织对高可用性和安全性缺乏感到担忧。在 Hadoop 1 中,所有主进程都是单点故障,这意味着主进程的故障会导致系统停机。在 Hadoop 2 中,HDFS 现在有了高可用性支持,并且 Map-Reduce 与 YARN 的重新架构消除了单点故障。安全性是另一个存在问题的领域,并且正在受到关注。
高可用性
高可用性通常在企业组织中强制执行,这些组织有高正常运行时间 SLA 要求,以确保系统始终处于运行状态,即使在节点因计划内或计划外的情况而宕机的情况下也是如此。在 Hadoop 2 之前,主 HDFS 进程只能在单个节点上运行,这导致了单点故障^(13)。Hadoop 2 带来了 NameNode 高可用性(HA)支持,这意味着同一个 Hadoop 集群可以有多个 NameNode 运行。按照当前的设计,一个 NameNode 是活动的,另一个 NameNode 被指定为备用进程。如果活动 NameNode 发生计划内或计划外的停机,备用 NameNode 将接管作为活动 NameNode,这个过程称为 故障转移。这个故障转移可以被配置为自动进行,从而无需人工干预。NameNode 故障转移的发生对 Hadoop 客户端是透明的。
^(13) 实际上,HDFS 的单点故障可能并不特别严重;请参阅 Suresh Srinivas 和 Aaron T. Myers 的“NameNode HA”,
goo.gl/1iSab。
MapReduce 主进程(JobTracker)在 Hadoop 2 中没有高可用性支持,但现在每个 MapReduce 作业都有自己的 JobTracker 进程(一个独立的 YARN ApplicationMaster),因此高可用性支持的重要性可能有所降低。
YARN 主进程(ResourceManager)中的高可用性支持很重要,目前正在进行开发,以将此功能添加到 Hadoop 中.^([14])
^(14)有关 YARN 高可用性支持的详细信息,请参阅标题为“ResourceManager (RM) 高可用性 (HA)”的 JIRA 工单
issues.apache.org/jira/browse/YARN-149。
多数据中心
多数据中心支持是企业软件中越来越被期待的关键特性之一,因为它通过在多个数据中心复制数据提供了强大的数据保护和地域属性。Apache Hadoop 及其大多数商业发行版从未支持过多数据中心,这对在多个数据中心运行软件的组织构成了挑战。WANdisco 是目前唯一可用的 Hadoop 多数据中心支持解决方案。
安全性
Hadoop 提供了一个安全模型,但默认情况下是禁用的。安全模型禁用时,Hadoop 中存在的唯一安全特性是 HDFS 文件和目录级别的所有权和权限。但恶意用户很容易绕过并假设其他用户的身份。默认情况下,所有其他 Hadoop 服务都是开放的,允许任何用户执行任何类型的操作,例如终止另一个用户的 MapReduce 作业。
Hadoop 可以配置为使用 Kerberos,这是一种网络认证协议,它要求 Hadoop 守护进程对客户端进行认证,包括用户和其他 Hadoop 组件。Kerberos 可以与组织的现有 Active Directory 集成,因此为用户提供单一登录体验。启用 Kerberos 时需要小心,因为任何希望与您的集群交互的 Hadoop 工具都需要支持 Kerberos。
在 Hadoop 2 中可以配置网络级别的加密,允许跨网络传输的数据(包括 HDFS 传输[1]和 MapReduce shuffle 数据[2])被加密。目前 Hadoop 中缺少对静态数据(HDFS 存储在磁盘上的数据)的加密。
^(15)有关添加对加密数据传输协议的支持的详细信息,请参阅标题为“添加对加密数据传输协议的支持”的 JIRA 工单
issues.apache.org/jira/browse/HDFS-3637。^(16)有关 YARN 高可用性支持的详细信息,请参阅标题为“添加对加密 shuffle 的支持”的 JIRA 工单
issues.apache.org/jira/browse/MAPREDUCE-4417。
让我们来看看一些个别系统的局限性。
HDFS
HDFS 的弱点主要是其缺乏高可用性(在 Hadoop 1.x 及更早版本中),对小文件处理效率低下,^([17]) 以及缺乏透明的压缩。HDFS 不支持对文件的随机写入(仅支持追加),并且通常设计为支持对大文件的顺序读写,具有高吞吐量。
^(17) 尽管 Hadoop 2 中的 HDFS 联邦引入了多个 NameNode 共享文件元数据的方法,但事实仍然是元数据存储在内存中。
MapReduce
MapReduce 是一种基于批处理的架构,这意味着它不适合需要实时数据访问的使用场景。需要全局同步或共享可变数据的任务不适合 MapReduce,因为它是一个无共享架构,这可能会对某些算法造成挑战。
版本不兼容性
Hadoop 2 版本的发布带来了与 MapReduce API 运行时兼容性的一些问题,尤其是在 org.hadoop.mapreduce 包中。这些问题通常会导致针对 Hadoop 1(及更早版本)编译的代码在运行时出现问题。解决方案通常是重新编译以针对 Hadoop 2,或者考虑在 第二章 中概述的技术,该技术介绍了一个兼容性库,以便在不重新编译代码的情况下针对两个 Hadoop 版本。
Hive 和 Hadoop 之间也存在其他挑战,其中 Hive 可能需要重新编译才能与构建时使用的 Hadoop 版本以外的版本兼容。Pig 也存在兼容性问题。例如,Pig 0.8 版本与 Hadoop 0.20.203 不兼容,需要手动干预才能解决这个问题。使用 Apache 之外的 Hadoop 发行版的一个优点是,这些兼容性问题已经得到解决。如果希望使用纯 Apache 发行版,那么查看 Bigtop (bigtop.apache.org/) 是值得的,这是一个 Apache 开源自动化构建和合规性系统。它包括所有主要的 Hadoop 生态系统组件,并运行一系列集成测试,以确保它们可以协同工作。
在解决 Hadoop 架构及其弱点之后,您可能已经准备好卷起袖子,亲自动手使用 Hadoop,因此让我们看看如何运行本书中的第一个示例。
1.2. 深入了解 MapReduce
本节向您展示如何在您的主机上运行 MapReduce 作业。
安装 Hadoop 和构建示例
要运行本节中的代码示例,您需要遵循附录中的说明,这些说明解释了如何安装 Hadoop 以及下载和运行本书附带示例。
假设您想构建一个倒排索引。MapReduce 对于这个任务是一个不错的选择,因为它可以并行创建索引(这是 MapReduce 的常见用途)。您的输入是一系列文本文件,您的输出是一个元组列表,其中每个元组是一个单词和包含该单词的文件列表。使用标准处理技术,这需要您找到一种机制来连接所有单词。一个简单的方法是在内存中执行这个连接,但您可能会因为具有大量唯一键而耗尽内存。您可以使用中间数据存储,例如数据库,但这将是不高效的。
一个更好的方法是逐行分词,并生成一个包含每行一个单词的中间文件。然后,可以对这些中间文件进行排序。最后一步是打开所有排序后的中间文件,并对每个唯一单词调用一个函数。这正是 MapReduce 所做的事情,尽管是以分布式的方式。
图 1.10 带您了解 MapReduce 中简单倒排索引的示例。首先,定义您的 mapper。您的 reducers 需要能够为输入中的每个单词生成一行,因此您的 map 输出键应该是输入文件中的每个单词,以便 MapReduce 可以将它们全部连接起来。每个键的值将是包含的文件名,即您的文档 ID。
图 1.10. MapReduce 中创建倒排索引的示例

这是 mapper 代码:

这个 reducer 的目标是为每个单词和出现该单词的文档 ID 列表创建一个输出行。MapReduce 框架将负责为 mappers 输出的每个唯一键调用 reducer 一次,以及一个文档 ID 列表。在 reducer 中,您需要做的就是将所有文档 ID 合并在一起,并在 reducer 中一次性输出,如下面的代码所示:

最后一步是编写驱动代码,该代码将设置所有必要的属性以配置 MapReduce 作业的运行。您需要让框架知道应该使用哪些类来处理 map 和 reduce 函数,并且还需要让它知道输入和输出数据的位置。默认情况下,MapReduce 假设您正在处理文本;如果您正在处理更复杂的文本结构或完全不同的数据存储技术,您需要告诉 MapReduce 它应该如何从这些数据源和接收器中读取和写入。以下示例显示了完整的驱动代码^([18]).
^([18]) GitHub 源:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch1/InvertedIndexJob.java.

让我们看看这段代码是如何工作的。首先,您需要在 HDFS 中创建两个简单的输入文件:

接下来,运行 MapReduce 代码。你将使用 shell 脚本来运行它,将两个输入文件作为参数传递,以及作业输出目录:
$ hip hip.ch1.InvertedIndexJob --input hip1/input --output hip1/output
执行书中的代码示例
附录包含了下载和安装本书附带二进制文件和代码的说明。大多数示例都是通过位于 bin 目录中的hip脚本启动的。为了方便,建议将书的 bin 目录添加到你的路径中,这样你就可以直接复制粘贴所有示例命令。附录有如何设置你的环境的说明。
当你的作业完成后,你可以检查 HDFS 中的作业输出文件并查看其内容:
$ hadoop fs -ls output/
Found 3 items
output/_SUCCESS
output/_logs
output/part-r-00000
$ hadoop fs -cat output/part*
cat 1.txt
dog 2.txt
lay 2.txt
mat 2.txt,1.txt
sat 1.txt
这就完成了你对如何运行 Hadoop 的快速浏览。
1.3. 章节总结
Hadoop 是一个分布式系统,旨在处理、生成和存储大数据集。其 MapReduce 实现为你提供了一个容错机制,用于大规模分析异构的半结构化和非结构化数据源,而 YARN 现在支持在同一 Hadoop 集群上运行多租户不同应用。
在本章中,我们从功能和物理架构的角度分析了 Hadoop。你还安装了 Hadoop 并运行了一个 MapReduce 作业。
本书剩余部分致力于介绍解决你在使用 Hadoop 时遇到的一些常见问题的实际技术。你将接触到广泛的主题领域,从 YARN、HDFS 和 MapReduce 开始,再到 Hive。你还将了解数据分析技术,并探索如 Mahout 和 Rhipe 等技术。
在第二章中,你旅程的第一站,你将发现 YARN,它预示着 Hadoop 新时代的到来,将 Hadoop 转变为分布式处理内核。无需多言,让我们开始吧。
第二章. YARN 简介
本章涵盖
-
理解 YARN 的工作原理
-
MapReduce 作为 YARN 应用的工作原理
-
其他 YARN 应用的概述
想象一下,你买了一辆第一辆车,交付时方向盘不工作,刹车也不工作。哦,而且它只能用一档行驶。在蜿蜒的山路上不能超速!对于那些想使用 Hadoop 1 运行一些酷炫的新技术,如图或实时数据处理的人来说,这种空虚、悲伤的感觉是熟悉的,只会提醒我们,我们强大的 Hadoop 集群只擅长一件事,那就是 MapReduce。
¹ 虽然你可以在 Hadoop 1 中进行图处理,但这并不是原生的,这意味着你可能在图的每次迭代之间都要承受多个磁盘屏障的低效,或者通过 MapReduce 进行修改以避免这些屏障。
幸运的是,Hadoop 的贡献者将这些以及其他限制因素铭记在心,并构想出一个将 Hadoop 转变为超越 MapReduce 的愿景。YARN 就是这个梦想的实现,它是一个令人兴奋的新发展,将 Hadoop 转变为可以支持任何类型工作负载的分布式计算内核。^([2)] 这使得可以在 Hadoop 上运行的应用程序类型得以扩展,以有效地支持机器学习、图处理和其他通用计算项目(如 Tez)的计算模型,这些内容将在本章后面讨论。
² 在 YARN 之前,Hadoop 只支持 MapReduce 进行计算工作。
所有这些的最终结果是,你现在可以在单个 Hadoop 集群上运行 MapReduce、Storm 和 HBase。这不仅为计算多租户提供了新的可能性,而且还有效地共享数据的能力。
由于 YARN 是一项新技术,我们将从本章的开头部分开始,探讨 YARN 的工作原理,随后将介绍如何从命令行和用户界面与 YARN 交互。这两部分结合起来,将帮助你更好地理解 YARN 是什么以及如何使用它。
一旦你了解了 YARN 的工作原理,你将看到 MapReduce 如何被重写为 YARN 应用程序(称为 MapReduce 2,或 MRv2),并查看 MapReduce 中发生的某些架构和系统变化,以实现这一目标。这将帮助你更好地理解如何在 Hadoop 2 中使用 MapReduce,并为你提供一些关于为什么 MapReduce 在版本 2 中某些方面发生变化的原因背景。
YARN 开发
如果你正在寻找如何编写 YARN 应用的详细信息,可以自由地跳转到第十章。chapter 10。但如果你是 YARN 的初学者,我建议你在继续阅读第十章 chapter 10 之前先阅读本章。
在本章的最后部分,你将检查几个 YARN 应用程序及其实际用途。
让我们从 YARN 的概述开始。
2.1. YARN 概述
在 Hadoop 1 和更早的版本中,你只能运行 MapReduce 作业。如果你执行的工作类型非常适合 MapReduce 处理模型,这很好,但对于想要执行图处理、迭代计算或其他类型工作的人来说,这很受限制。
在 Hadoop 2 中,MapReduce 的调度部分被外部化并重新设计为一个名为 YARN 的新组件,YARN 是 Yet Another Resource Negotiator 的缩写。YARN 对你在 Hadoop 上执行的工作类型是中立的——它只要求希望运行在 Hadoop 上的应用程序以 YARN 应用程序的形式实现。因此,MapReduce 现在是一个 YARN 应用程序。旧的和新的 Hadoop 堆栈可以在图 2.1 中看到。
图 2.1. Hadoop 1 和 2 架构,显示 YARN 作为通用调度器和各种 YARN 应用程序

这种架构变化带来了多方面的好处,你将在下一节中对其进行探讨。
2.1.1. 为什么是 YARN?
我们已经提到了 YARN 如何使除了 MapReduce 之外的工作能够在 Hadoop 上执行,但让我们进一步探讨这一点,并看看 YARN 带来的其他优势。
MapReduce 是一个强大的分布式框架和编程模型,它允许在多个节点集群上执行基于批次的并行化工作。尽管它在所做的工作上非常高效,但 MapReduce 也有一些缺点;主要缺点是它是基于批次的,因此不适合实时或甚至接近实时的数据处理。从历史上看,这意味着图、迭代和实时数据处理等处理模型并不是 MapReduce 的自然选择.^([3])
³ HBase 是一个例外;它使用 HDFS 进行存储,但不使用 MapReduce 作为处理引擎。
底线是,Hadoop 版本 1 限制了你可以运行令人兴奋的新处理框架。
YARN 通过接管 MapReduce 的调度部分,改变了这一切,而仅此而已。在其核心,YARN 是一个分布式调度器,负责两项活动:
-
响应客户端创建容器的请求 —容器本质上是一个进程,有一个合同规定了它被允许使用的物理资源。
-
监控正在运行的容器,并在需要时终止它们 —如果 YARN 调度器想要释放资源以便其他应用程序的容器可以运行,或者如果容器使用了超过其分配的资源,容器可以被终止。
表 2.1 比较了 MapReduce 1 和 YARN(在 Hadoop 版本 1 和 2 中),以展示为什么 YARN 是一个如此革命性的飞跃。
表 2.1. MapReduce 1 和 YARN 的比较
| 功能 | MapReduce 1 | YARN |
|---|---|---|
| 执行模型 | 在 Hadoop 1 中仅支持 MapReduce,这限制了你可以执行的活动类型,只能是在 MapReduce 处理模型范围内的基于批次的流程。 | YARN 对在 Hadoop 中可以执行的工作类型没有任何限制;你可以选择你需要哪种执行引擎(无论是使用 Spark 进行实时处理,使用 Giraph 进行图处理,还是使用 MapReduce 进行批量处理),并且它们都可以在同一个集群上并行执行。 |
| 并发进程 | MapReduce 有“槽位”的概念,这些是节点特定的静态配置,决定了每个节点上可以同时运行的 map 和 reduce 进程的最大数量。根据 MapReduce 应用程序的生命周期阶段,这通常会导致集群利用率不足。 | YARN 允许更灵活的资源分配,进程的数量仅受每个节点配置的最大内存和 CPU 数量的限制。 |
| 内存限制 | Hadoop 1 中的槽位也有最大限制,因此通常 Hadoop 1 集群配置得使得槽位数量乘以每个槽位配置的最大内存小于可用的 RAM。这通常会导致小于期望的最大槽位内存大小,从而阻碍了你运行内存密集型作业的能力。[3] MRv1 的另一个缺点是,内存密集型和 I/O 密集型作业在同一集群或机器上共存变得更加困难。要么你有更多的槽位来提升 I/O 作业,要么有更少的槽位但更多的 RAM 用于 RAM 作业。再次,这些槽位的静态性质使得为混合工作负载调整集群成为一项挑战。YARN 允许应用程序请求不同内存大小的资源。YARN 有最小和最大内存限制,但由于槽位数量不再固定,最大值可以大得多,以支持内存密集型工作负载。因此,YARN 提供了一个更加动态的调度模型,不会限制进程的数量或进程请求的 RAM 量。 | |
| 可扩展性 | Job-Tracker 存在并发问题,这限制了 Hadoop 集群中的节点数量在 3,000-4,000 个之间。 | 通过将 MapReduce 的调度部分分离到 YARN 中,并通过将容错委托给 YARN 应用程序来使其轻量级,YARN 可以扩展到比 Hadoop 早期版本大得多的数量。[4] |
| 执行 | 在集群中同一时间只能支持一个版本的 MapReduce。这在大型多租户环境中是个问题,因为希望升级到 MapReduce 新版本的产品团队必须说服所有其他用户。这通常会导致巨大的协调和集成工作,使得这样的升级成为巨大的基础设施项目。MapReduce 已不再是 Hadoop 的核心,现在是一个运行在用户空间中的 YARN 应用程序。这意味着你现在可以在同一集群上同时运行不同版本的 MapReduce。这在大型多租户环境中是一个巨大的生产力提升,并允许你组织上解耦产品团队和路线图。 |
^a 对于使用 Mahout 等工具运行机器学习任务的人来说,MapReduce 中的这种限制尤其痛苦,因为这些工具通常需要大量的 RAM 进行处理——通常比 MapReduce 中配置的最大槽位大小还要大。
^b YARN 的目标是能够扩展到 10,000 个节点;超过这个数量可能会导致 ResourceManager 成为瓶颈,因为它是一个单一进程。
现在你已经了解了 YARN 的关键优势,是时候查看 YARN 的主要组件并检查它们的作用了。
2.1.2. YARN 概念和组件
YARN 包含一个负责资源调度和监控的框架,以及一些在集群中执行特定逻辑的应用程序。让我们更详细地考察 YARN 的概念和组件,从 YARN 框架组件开始。
YARN 框架
YARN 框架执行一个主要功能,即在集群中调度资源(在 YARN 术语中称为 容器)。集群中的应用程序与 YARN 框架通信,请求分配特定应用的容器,YARN 框架评估这些请求并尝试满足它们。YARN 调度的另一个重要部分包括监控当前正在执行的容器。容器监控之所以重要,有两个原因:一旦容器完成,调度器就可以使用释放出的容量来调度更多的工作。此外,每个容器都有一个合同,指定了它允许使用的系统资源,在容器超出这些界限的情况下,调度器可以终止容器以避免恶意容器影响其他应用程序。
YARN 框架被有意设计得尽可能简单;因此,它不知道或关心正在运行的应用程序类型。它也不关心保留关于集群上执行的历史信息。这些设计决策是 YARN 能够超越 MapReduce 层级扩展的主要原因。
YARN 框架由两个主要组件组成,即 ResourceManager 和 NodeManager,这在 图 2.2 中可以看到。
图 2.2. YARN 框架组件及其交互。未显示特定应用组件,如 YARN 客户端、ApplicationMaster 和容器。

-
ResourceManager —一个 Hadoop 集群中有一个用于整个集群的单一 ResourceManager(RM)。ResourceManager 是 YARN 的主进程,其唯一功能是在 Hadoop 集群中仲裁资源。它响应客户端创建容器的请求,调度器根据调度器特定的多租户规则确定何时何地可以创建容器,这些规则规定了谁可以在何时何地创建容器。就像 Hadoop 1 一样,ResourceManager 的调度器部分是可插拔的,这意味着你可以选择最适合你环境的调度器。实际创建容器的任务委托给 NodeManager。
-
NodeManager —NodeManager 是在每个集群节点上运行的从进程。其任务是创建、监控和终止容器。它服务 ResourceManager 和 ApplicationMaster 的容器创建请求,并向 ResourceManager 报告容器的状态。ResourceManager 使用这些状态消息中包含的数据来为新容器请求做出调度决策。
在非 HA 模式下,只存在一个资源管理器的实例。4
⁴ 在撰写本文时,YARN 资源管理器的高可用性(HA)仍在积极开发中,其进展可以在名为“资源管理器(RM)高可用性(HA)”的 JIRA 工单上跟踪,
issues.apache.org/jira/browse/YARN-149。
YARN 框架的存在是为了管理应用程序,因此让我们看看一个 YARN 应用程序由哪些组件组成。
YARN 应用程序
一个 YARN 应用程序实现了一个在 Hadoop 上运行的具体功能。MapReduce 是 YARN 应用程序的一个例子,Hoya 项目也是如此,它允许多个 HBase 实例在单个集群上运行,以及 storm-yarn,它允许 Storm 在 Hadoop 集群内部运行。你将在本章后面看到这些项目和其它 YARN 应用程序的更多细节。
一个 YARN 应用程序涉及三个组件——客户端、应用程序主控(ApplicationMaster,简称 AM)和容器,这些可以在图 2.3 中看到。
图 2.3. YARN 应用程序的典型交互

启动一个新的 YARN 应用程序从 YARN 客户端与资源管理器通信以创建一个新的 YARN 应用程序主控实例开始。这个过程的一部分涉及 YARN 客户端通知资源管理器应用程序主控的物理资源需求。
应用程序主控是 YARN 应用程序的主进程。它不执行任何特定于应用程序的工作,因为这些功能被委托给容器。相反,它负责管理特定于应用程序的容器:向资源管理器表明其创建容器的意图,然后与节点管理器协商以实际执行容器创建。
作为这个过程的一部分,应用程序主控必须指定每个容器所需的资源,包括哪个主机应该启动容器以及容器的内存和 CPU 需求。5 资源管理器根据确切资源需求调度工作的能力是 YARN 灵活性的关键,它使得主机能够运行容器的混合配置,如图 2.4 所示。
⁵ Hadoop 的未来版本可能允许指定网络、磁盘和 GPU 的要求。
图 2.4. 在单个 YARN 管理的 Hadoop 节点上运行的多种容器配置

应用程序主控还负责应用程序的具体容错行为。当其容器失败时,它会从资源管理器接收状态消息,并且可以根据这些事件(通过请求资源管理器创建一个新的容器)采取行动,或者忽略这些事件。6
⁶ 容器可能因多种原因失败,包括节点故障、YARN 杀死容器以允许启动另一个应用的容器,或者当容器超出其配置的物理/虚拟内存时,YARN 杀死容器。
容器是由 NodeManager 代表 ApplicationMaster 创建的应用特定进程。ApplicationManager 本身也是一个容器,由 ResourceManager 创建。由 ApplicationManager 创建的容器可以是一个任意进程——例如,容器进程可能只是一个 Linux 命令,如awk,一个 Python 应用程序,或任何操作系统可以启动的进程。这是 YARN 的力量——能够在 Hadoop 集群的任何节点上启动和管理任何进程。
到目前为止,你应该对 YARN 组件及其功能有一个高层次的理解。接下来,我们将探讨常见的 YARN 可配置项。
2.1.3. YARN 配置
YARN 带来了大量针对各种组件的配置,例如 UI、远程过程调用(RPCs)、调度器等。[5] 在本节中,你将了解如何快速访问运行中的集群的配置。
⁷ 默认 YARN 配置的详细信息可以在
hadoop.apache.org/docs/r2.2.0/hadoop-yarn/hadoop-yarn-common/yarn-default.xml中查看。
技巧 1 确定集群的配置
确定运行中的 Hadoop 集群的配置可能很麻烦——通常需要查看多个配置文件,包括默认配置文件,以确定你感兴趣的属性的值。在这个技巧中,你将看到如何绕过通常需要跳过的圈子,并专注于如何迅速获取运行中的 Hadoop 集群的配置。
问题
你想访问运行中的 Hadoop 集群的配置。
解决方案
使用 ResourceManager UI 查看配置。
讨论
ResourceManager UI 显示了你的 Hadoop 集群的配置;图 2.5 显示了如何导航到这些信息。
图 2.5. YARN ResourceManager UI 显示集群的配置

这个特性的有用之处在于,UI 不仅显示了属性值,还显示了它来自哪个文件。如果值没有在
此 UI 的另一个有用功能是它会显示来自多个文件的配置,包括核心、HDFS、YARN 和 MapReduce 文件。
可以通过 NodeManager UI 以相同的方式导航到单个 Hadoop 从节点上的配置。这在处理由异构节点组成的 Hadoop 集群时非常有用,您经常需要不同的配置来适应不同的硬件资源。
到目前为止,您应该对 YARN 组件有一个高级的了解,包括它们的功能以及如何为您的集群配置它们。下一步是实际通过命令行和 UI 来查看 YARN 的实际运行情况。
2.1.4. 与 YARN 交互
默认情况下,Hadoop 2 捆绑了两个 YARN 应用程序——MapReduce 2 和 DistributedShell。您将在本章后面了解更多关于 MapReduce 2 的内容,但就目前而言,您可以通过查看一个更简单的 YARN 应用程序示例来尝试一下:DistributedShell。您将了解如何运行第一个 YARN 应用程序以及在哪里检查日志。
如果您不知道集群的配置值,您有两个选择:
-
检查 yarn-site.xml 的内容以查看属性值。如果条目不存在,则默认值将生效.^([8])
⁸ 访问以下网址以获取 YARN 默认值:
hadoop.apache.org/docs/r2.2.0/hadoop-yarn/hadoop-yarn-common/yarn-default.xml。 -
更好的是,使用 ResourceManager UI,它提供了关于运行配置的更详细信息,包括默认值是什么以及它们是否生效。
现在我们来看看如何快速查看运行中的 Hadoop 集群的 YARN 配置。
技巧 2 在您的 YARN 集群上运行命令
当您开始使用新的 YARN 集群时,在集群上运行命令是一个很好的第一步。如果您愿意,这是 YARN 中的“hello world”。
问题
您想在 Hadoop 集群中的某个节点上运行 Linux 命令。
解决方案
使用 Hadoop 捆绑的 DistributedShell 示例应用程序。
讨论
YARN 捆绑了 DistributedShell 应用程序,它有两个主要用途——它是一个参考 YARN 应用程序,也是一个方便的实用程序,可以在您的 Hadoop 集群上并行运行命令。首先,在一个容器中发出 Linux find 命令:

如果您的集群一切正常,那么执行前面的命令将导致以下日志消息:
INFO distributedshell.Client: Application completed successfully
在此行之前,您将在命令的输出中看到各种其他的日志语句,但您会注意到它们都不包含您find命令的实际结果。这是因为 DistributedShell ApplicationMaster 在单独的容器中启动find命令,并且find命令的标准输出(和标准错误)被重定向到容器的日志输出目录。要查看命令的输出,您需要访问该目录。这正是下一个技巧要介绍的内容!
技巧 3 访问容器日志
当尝试诊断行为异常的应用程序或简单地了解更多关于应用程序的信息时,转向日志文件是最常见的第一步。在这个技巧中,你将学习如何访问这些应用程序日志文件。
问题
你想要访问容器日志文件。
解决方案
使用 YARN 的 UI 和命令行来访问日志。
讨论
每个在 YARN 中运行的容器都有自己的输出目录,其中标准输出、标准错误以及任何其他输出文件都会被写入。展示了输出目录在从节点上的位置,包括日志数据保留的详细信息。
图 2.6. 容器日志位置和保留

访问容器日志并不像应该的那样简单——让我们看看如何使用 CLI 和 UI 来访问日志。
使用 YARN 命令行访问容器日志
YARN 提供了一个用于访问 YARN 应用程序日志的命令行界面(CLI)。要使用 CLI,你需要知道你的应用程序 ID。
我该如何找到应用程序 ID?
大多数 YARN 客户端都会在其输出和日志中显示应用程序 ID。例如,你在上一个技巧中执行的 DistributedShell 命令将应用程序 ID 回显到标准输出:
$ hadoop o.a.h.y.a.d.Client ...
...
INFO impl.YarnClientImpl:
Submitted application application_1388257115348_0008 to
ResourceManager at /0.0.0.0:8032
...
或者,你可以使用 CLI(使用yarn application -list)或 ResourceManager UI 来浏览并找到你的应用程序 ID。
如果你尝试在应用程序仍在运行时使用 CLI,你会看到以下错误信息:
$ yarn logs -applicationId application_1398974791337_0070
Application has not completed. Logs are only available after
an application completes
消息已经说明了一切——CLI 仅在应用程序完成后才有用。当应用程序运行时,你需要使用 UI 来访问容器日志,我们将在稍后介绍。
一旦应用程序完成,如果你再次运行该命令,你可能会看到以下输出:
$ yarn logs -applicationId application_1400286711208_0001
Logs not available at /tmp/.../application_1400286711208_0001
Log aggregation has not completed or is not enabled.
基本上,YARN CLI 仅在应用程序完成并且启用了日志聚合时才有效。日志聚合将在下一个技巧中介绍。如果你启用了日志聚合,CLI 将为你提供应用程序中所有容器的日志,如下一个示例所示:
$ yarn logs -applicationId application_1400287920505_0002
client.RMProxy: Connecting to ResourceManager at /0.0.0.0:8032
Container: container_1400287920505_0002_01_000002
on localhost.localdomain_57276
=================================================
LogType: stderr
LogLength: 0
Log Contents:
LogType: stdout
LogLength: 1355
Log Contents:
/tmp
default_container_executor.sh
/launch_container.sh
/.launch_container.sh.crc
/.default_container_executor.sh.crc
/.container_tokens.crc
/AppMaster.jar
/container_tokens
Container: container_1400287920505_0002_01_000001
on localhost.localdomain_57276
=================================================
LogType: AppMaster.stderr
LogLength: 17170
Log Contents:
distributedshell.ApplicationMaster: Initializing ApplicationMaster
...
LogType: AppMaster.stdout
LogLength: 8458
Log Contents:
System env: key=TERM, val=xterm-256color
...
前面的输出显示了你在上一个技巧中运行的 DistributedShell 示例的日志内容。输出中有两个容器——一个用于执行的find命令,另一个用于 ApplicationMaster,它也在容器内执行。
使用 YARN UI 访问日志
YARN 通过 ResourceManager UI 提供对 ApplicationMaster 日志的访问。在伪分布式设置中,将您的浏览器指向 http://localhost:8088/cluster。如果您在与多节点 Hadoop 集群一起工作,请将您的浏览器指向$yarn.resourcemanager.webapp.address/cluster。点击您感兴趣的应用程序,然后选择日志链接,如图 2.7 所示。
图 2.7. 显示 ApplicationMaster 容器的 YARN ResourceManager UI

很好,但如何访问除了 ApplicationMaster 之外的容器日志?不幸的是,这里的事情变得有些模糊。ResourceManager 不会跟踪 YARN 应用程序的容器,因此它不能为您提供列出和导航到容器日志的方法。因此,责任在于个别 YARN 应用程序为用户提供访问容器日志的方法。
嘿,ResourceManager,我的容器 ID 是什么?
为了保持 ResourceManager 轻量级,它不会跟踪应用程序的容器 ID。因此,ResourceManager UI 只为访问应用程序的 ApplicationMaster 日志提供了一种方式。
以 DistributedShell 应用程序为例。这是一个简单的应用程序,它不提供 ApplicationMaster UI 或跟踪它启动的容器。因此,除了使用前面介绍的方法(使用 CLI)之外,没有简单的方法来查看容器日志。
幸运的是,MapReduce YARN 应用程序提供了一个 ApplicationMaster UI,您可以使用它来访问容器(映射和减少任务)日志,以及一个 Job-History UI,可以在 MapReduce 作业完成后访问日志。当您运行 MapReduce 作业时,ResourceManager UI 会为您提供链接到 MapReduce ApplicationMaster UI,如图 2.8 所示,您可以使用它来访问映射和减少日志(类似于 MapReduce 1 中的 JobTracker)。
图 2.8. 访问正在运行作业的 MapReduce UI

如果您的 YARN 应用程序提供了一种方法来识别容器 ID 和它们执行的宿主机,您可以使用 NodeManager UI 访问容器日志,或者使用 shell 通过ssh到执行容器的从节点。
访问容器日志的 NodeManager URL 是[<nodemanager-host>:8042/node/containerlogs/<container-id>/<username>](http://ssh到 NodeManager 主机并访问$yarn.nodemanager.log-dirs/
真的,我能给出的最好建议是您应该启用日志聚合,这将允许您使用 CLI、HDFS 和 UI,例如 MapReduce ApplicationMaster 和 JobHistory,来访问应用程序日志。继续阅读以获取如何操作的详细信息。
技术四:聚合容器日志文件
日志聚合是 Hadoop 1 中缺失的功能,这使得归档和访问任务日志变得具有挑战性。幸运的是,Hadoop 2 内置了这个功能,并且你有多种方式可以访问聚合的日志文件。在这个技术中,你将学习如何配置你的集群以归档日志文件进行长期存储和访问。
问题
你想要将容器日志文件聚合到 HDFS 并管理它们的保留策略。
解决方案
使用 YARN 的内置日志聚合功能。
讨论
在 Hadoop 1 中,你的日志被存储在每个从节点上,JobTracker 和 TaskTracker 是获取这些日志的唯一机制。这很麻烦,并且不容易支持对这些日志的编程访问。此外,由于存在旨在防止从节点上的本地磁盘填满的积极的日志保留策略,日志文件通常会丢失。
因此,Hadoop 2 中的日志聚合是一个受欢迎的功能,如果启用,它会在 YARN 应用程序完成后将容器日志文件复制到 Hadoop 文件系统(如 HDFS)中。默认情况下,此行为是禁用的,你需要将yarn.log-aggregation-enable设置为true来启用此功能。图 2.9 显示了容器日志文件的数据流。
图 2.9. 从本地文件系统到 HDFS 的日志文件聚合

现在你已经知道了日志聚合的工作原理,让我们看看你如何可以访问聚合日志。
使用 CLI 访问日志文件
在你手头有应用程序 ID 的情况下(有关如何获取它的详细信息,请参阅技术 3),你可以使用命令行来获取所有日志并将它们写入控制台:
$ yarn logs -applicationId application_1388248867335_0003
启用日志聚合
如果前面的yarn logs命令产生以下输出,那么很可能你没有启用 YARN 日志聚合:
Log aggregation has not completed or is not enabled.
这将输出 YARN 应用程序中所有容器的所有日志。每个容器的输出由一个标题分隔,标题指示容器 ID,然后是容器输出目录中每个文件的详细信息。例如,如果你运行了一个执行ls -l的 DistributedShell 命令,那么yarn logs命令的输出将类似于以下内容:
Container: container_1388248867335_0003_01_000002 on localhost
==============================================================
LogType: stderr
LogLength: 0
Log Contents:
LogType: stdoutLogLength: 268
Log Contents:
total 32
-rw-r--r-- 1 aholmes 12:29 container_tokens
-rwx------ 1 aholmes 12:29 default_container_executor.sh
-rwx------ 1 aholmes launch_container.sh
drwx--x--- 2 aholmes tmp
Container: container_1388248867335_0003_01_000001 on localhost
==============================================================
LogType: AppMaster.stderr
(the remainder of the ApplicationMaster logs removed for brevity)
stdout 文件包含ls进程当前目录的目录列表,这是一个容器特定的工作目录。
通过 UI 访问聚合日志
具有完整功能的 YARN 应用程序,如 MapReduce,提供了一个 ApplicationMaster UI,可以用来访问容器日志。同样,作业历史 UI 也可以访问聚合日志。
UI 聚合日志渲染
如果启用了日志聚合,你需要更新 yarn-site.xml 并将yarn.log.server.url设置为指向作业历史服务器,以便 ResourceManager UI 可以渲染日志。
在 HDFS 中访问日志文件
默认情况下,聚合的日志文件会放入以下 HDFS 目录中:
/tmp/logs/${user}/logs/application_<appid>
目录前缀可以通过yarn.nodemanager.remote-app-log-dir属性进行配置;同样,用户名之后的路径名(在之前的例子中是“logs”,默认值)可以通过yarn.nodemanager.remote-app-log-dir-suffix进行自定义。
本地文件系统和 HDFS 中日志文件之间的差异
如你之前所见,每个容器在本地文件系统中会产生两个日志文件:一个用于标准输出,另一个用于标准错误。作为聚合过程的一部分,给定节点的所有文件都会被连接成一个特定于节点的日志文件。例如,如果你在三个节点上运行了五个容器,你最终会在 HDFS 中得到三个日志文件。
压缩
默认情况下,聚合日志的压缩是禁用的,但你可以通过将yarn.nodemanager.log-aggregation.compression-type的值设置为lzo或gzip来启用它,具体取决于你的压缩需求。截至 Hadoop 2.2,这两个是唯一支持的压缩编解码器。
日志保留
当关闭日志聚合时,本地主机上的容器日志文件会保留yarn.nodemanager.log.retain-seconds秒,默认为 10,800 秒(3 小时)。
当开启日志聚合时,yarn.nodemanager.log.retain-seconds的可配置设置会被忽略,并且一旦本地容器日志文件被复制到 HDFS,它们就会被删除。但如果你想在本地文件系统中保留它们,只需将yarn.nodemanager.delete.debug-delay-sec设置为想要保留文件的时间即可。请注意,这不仅适用于日志文件,也适用于与容器相关联的所有其他元数据(如 JAR 文件)。
HDFS 中文件的保留时间是通过不同的设置yarn.log-aggregation.retain-seconds进行配置的。
NameNode 注意事项
在大规模部署时,你可能需要考虑一个激进的日志保留设置,以避免因所有日志文件元数据而使 NameNode 过载。NameNode 将元数据保存在内存中,在一个大型活跃集群中,日志文件的数量可能会迅速超过 NameNode 的处理能力。
NameNode 影响的真实案例
查看 Bobby Evans 的“我们在规模上运行 YARN 的经验”(www.slideshare.net/Hadoop_Summit/evans-june27-230pmroom210c),了解雅虎如何利用 30%的 NameNode 存储七天累积日志的实际情况。
其他解决方案
这种技术中突出的解决方案对于将日志放入 HDFS 很有用,但如果你需要自己组织任何日志挖掘或可视化活动,还有其他选项可用,例如 Hunk,它支持从 Hadoop 1 和 2 聚合日志,并提供与常规 Splunk 一样的一流查询、可视化和监控功能。如果你想要拥有日志管理流程,你也可以使用 Logstash、ElasticSearch 和 Kibana 等工具设置查询和可视化管道。其他工具如 Loggly 也值得调查。
至此,我们关于 YARN 的实战考察就结束了。然而,这并不是故事的结束。第 2.2 节将探讨 MapReduce 作为 YARN 应用程序的工作方式,而在第十章的后面,你将学习如何编写自己的 YARN 应用程序。
2.1.5. YARN 挑战
在 YARN 中需要注意一些陷阱:
-
YARN 目前尚未设计为与长时间运行的过程很好地协同工作。 这给像 Impala 和 Tez 这样的项目带来了挑战,这些项目将受益于这样的功能。目前正在进行将此功能引入 YARN 的工作,并在名为“在 YARN 中滚动长期服务”的 JIRA 票据中跟踪,
issues.apache.org/jira/browse/YARN-896。 -
编写 YARN 应用程序相当复杂,因为你需要实现容器管理和容错性。 这可能需要一些复杂的 Application-Master 和容器状态管理,以便在失败后可以从某个已知的状态继续工作。有几个框架的目标是简化开发——更多细节请参阅第十章。
-
群组调度,即能够快速并行启动大量容器的能力,目前尚不支持。 这是像 Impala 和 Hamster(OpenMPI)这样的项目需要用于原生 YARN 集成的一个特性。Hadoop 提交者目前正在努力添加对群组调度的支持,这已在名为“在 AM RM 协议中支持群组调度”的 JIRA 票据中跟踪,
issues.apache.org/jira/browse/YARN-624。
到目前为止,我们一直关注核心 YARN 系统的功能。接下来,让我们看看 MapReduce 作为 YARN 应用程序是如何工作的。
2.2. YARN 和 MapReduce
在 Hadoop 1 中,MapReduce 是唯一在 Hadoop 中本地处理数据的方式。YARN 的创建是为了让 Hadoop 集群能够运行任何类型的工作,其唯一要求是应用程序遵循 YARN 规范。这意味着 Map-Reduce 必须成为 YARN 应用程序,并要求 Hadoop 开发者重写 MapReduce 的关键部分。
由于 MapReduce 需要经历一些“心脏手术”才能作为一个 YARN 应用程序运行,本节的目标是揭开 MapReduce 在 Hadoop 2 中的工作原理。您将看到 MapReduce 2 如何在 Hadoop 集群中执行,同时也会了解配置更改以及与 MapReduce 1 的向后兼容性。在本节的最后,您将学习如何运行和监控作业,并了解小型作业是如何快速执行的。
有很多内容需要介绍,所以让我们将 MapReduce 带入实验室,看看其内部的工作情况。
2.2.1. 解构 YARN MapReduce 应用程序
为了将 MapReduce 移植到 YARN,必须对其进行架构上的修改。图 2.10 展示了 MRv2 中涉及的过程以及它们之间的一些交互。
图 2.10. MapReduce 2 YARN 应用程序的交互

每个 MapReduce 作业都作为一个独立的 YARN 应用程序执行。当您启动一个新的 MapReduce 作业时,客户端计算输入拆分并将它们与其他作业资源一起写入 HDFS(步骤 1)。然后客户端与 ResourceManager 通信以创建 MapReduce 作业的应用程序主(步骤 2)。实际上,应用程序主是一个容器,因此当集群上有可用资源时,ResourceManager 将分配容器,然后与 NodeManager 通信以创建应用程序主容器(步骤 3-4).^([9])
⁹ 如果没有可用资源来创建容器,ResourceManager 可能会选择杀死一个或多个现有的容器以腾出空间。
MapReduce 应用程序主(MRAM)负责创建 map 和 reduce 容器并监控它们的状态。MRAM 从 HDFS 中拉取输入拆分(步骤 5),这样当它与 ResourceManager 通信(步骤 6)时,它可以请求在输入数据所在的节点上启动 map 容器。
向 ResourceManager 发送的容器分配请求附加在应用程序主和 ResourceManager 之间流动的常规心跳消息上。心跳响应可能包含有关为应用程序分配的容器的详细信息。数据局部性作为架构的一个重要部分得到维护——当它请求 map 容器时,MapReduce 应用程序管理器将使用输入拆分的位置细节请求将这些容器分配给包含输入拆分的节点之一,并且 ResourceManager 将在这组输入拆分节点上尽力进行容器分配。
一旦 MapReduce ApplicationManager 分配到容器,它就会与 NodeManager 通信以启动 map 或 reduce 任务(步骤 7-8)。在这个阶段,map/reduce 进程的工作方式与 MRv1 非常相似。
混洗
MapReduce 中的洗牌阶段,负责对映射器输出进行排序并将它们分发到减少器,在 MapReduce 2 中没有发生根本性的变化。主要区别在于映射输出是通过 ShuffleHandlers 获取的,这些是运行在每个从节点上的辅助 YARN 服务。10 对洗牌实现进行了一些微小的内存管理调整;例如,io.sort.record.percent 现在不再使用。
^((10)) ShuffleHandler 必须配置在您的 yarn-site.xml 中;属性名为
yarn.nodemanager.aux-services,其值为mapreduce_shuffle。
JobTracker 呢?
您会注意到,在这个架构中 JobTracker 已不再存在。JobTracker 的调度部分被移动到 YARN ResourceManager 中的通用资源调度器。JobTracker 的剩余部分,主要是关于运行中和已完成作业的元数据,被分成两部分。每个 MapReduce ApplicationMaster 都托管一个 UI,用于显示当前作业的详细信息,一旦作业完成,它们的详细信息将被推送到 JobHistoryServer,该服务器聚合并显示所有已完成作业的详细信息。有关详细信息,包括如何访问 MapReduce ApplicationMaster UI,请参阅第 2.2.5 节。
希望现在您对 MapReduce 2 的工作方式有了更好的理解。MapReduce 配置在迁移到 YARN 时并未未受影响,因此让我们看看哪些是热门的,哪些不是。
2.2.2. 配置
MapReduce 2 转换到 YARN 带来了一些 Map-Reduce 属性的重大变化。在本节中,我们将介绍一些受影响的常用属性。
新属性
MapReduce 2 中有几个新特性,在表 2.2 中进行了识别。
表 2.2. 新的 MapReduce 2 属性
| 属性名称 | 默认值 | 描述 |
|---|
| mapreduce.framework.name | local | 确定运行 MapReduce 作业时应使用哪个框架。有三个可能的值:
-
local,表示使用 LocalJobRunner(整个 MapReduce 作业在单个 JVM 中运行)。
-
classic,表示作业将在 MapReduce 1 集群上启动。在这种情况下,将使用 mapreduce.jobtracker.address 属性来检索作业提交到的 JobTracker。
-
yarn,表示在 YARN 中运行 MapReduce 作业。这可以是伪分布式或完整的 YARN 集群。
|
| mapreduce.job.ubertask.enable | false | Uber 作业是可以在 MapReduce ApplicationMaster 进程内部运行的较小作业,以避免启动映射和减少容器的开销。Uber 作业在第 2.2.6 节中有更详细的介绍。 |
|---|---|---|
| mapreduce.shuffle.max.connections | 0 | shuffle 允许的最大连接数。设置为 0(零)表示不对连接数进行限制。这与旧的(现在已弃用)MapReduce 1 属性 tasktracker.http.threads 类似,该属性定义了用于服务 reducer 对 map 输出的请求的 TaskTracker 线程数。 |
| yarn.resourcemanager.am.max-attempts | 2 | 最大应用尝试次数。这是所有 ApplicationMasters 的全局设置。每个应用程序主可以通过 API 指定其单独的最大应用尝试次数,但单独的数字不能超过全局上限。如果超过,资源管理器将覆盖它。默认值为 2,以允许至少重试一次 AM。 |
| yarn.resourcemanager.recovery.enabled | false | 启用 RM 在启动后恢复状态。如果为 true,则必须指定 yarn.resourcemanager.store.class。Hadoop 2.4.0 还引入了一种基于 ZooKeeper 的机制来存储 RM 状态(类 org.apache.hadoop.yarn.server.resourcemanager.recovery.ZKRMStateStore)。 |
| yarn.resourcemanager.store.class | org.apache.hadoop.yarn.server.resourcemanager.recovery.FileSystem-RMStateStore | 将资源管理器状态写入文件系统以用于恢复目的。 |
容器属性
表 2.3 显示了与运行任务的 map 和 reduce 进程相关的 MapReduce 属性。
表 2.3. 影响容器(map/reduce 任务)的 MapReduce 2 属性
| 属性名 | 默认值 | 描述 |
|---|---|---|
| mapreduce.map.memory.mb | 1024 | 分配给运行 mappers 的容器(进程)的内存量,以兆字节为单位。YARN 调度器使用此信息来确定集群中节点上的可用容量。旧属性名 mapred.job.map.memory.mb 已被弃用。 |
| mapreduce.reduce.memory.mb | 1024 | 分配给运行 reducer 的容器(进程)的内存量,以兆字节为单位。YARN 调度器使用此信息来确定集群中节点上的可用容量。旧属性名 mapreduce.reduce.memory.mb 已被弃用。 |
| mapreduce.map.cpu.vcores | 1 | 分配给 map 进程的虚拟核心数。 |
| mapreduce.reduce.cpu.vcores | 1 | 分配给 reduce 进程的虚拟核心数。 |
| mapred.child.java.opts | -Xmx200m | Map 和 Reduce 进程的 Java 选项。如果存在@taskid@符号,它将被当前的 TaskID 所替换。任何其他@的出现将保持不变。例如,为了将详细的垃圾回收日志记录到以 TaskID 命名的文件中(位于/tmp 目录下),并将堆最大值设置为 1GB,请传递值-Xmx1024m -verbose:gc -Xloggc:/tmp/@taskid@.gc。使用-Djava.library.path 可能会导致程序无法正常工作,如果使用了 Hadoop 原生库。这些值应该作为 LD_LIBRARY_PATH 的一部分在 map/reduce JVM 环境中设置,使用 mapreduce.map.env 和 mapreduce.reduce.env 配置设置。 |
| mapred.map.child.java.opts | None | Map 进程特定的 JVM 参数。旧的属性名称 mapred.map.child.java.opts 已被弃用。 |
| mapreduce.reduce.java.opts | None | Reduce 进程特定的 JVM 参数。旧的属性名称 mapred.reduce.child.java.opts 已被弃用。 |
已不再有效的配置
MapReduce 1 中常见的属性在 MapReduce 2 中不再有效,这些属性在表 2.4 中列出,同时解释了为什么它们不再存在。
表 2.4. 已不再使用的旧 MapReduce 1 属性
| 属性名称 | 描述 |
|---|---|
| mapred.job.tracker mapred.job.tracker.http.address | 在 YARN 中,JobTracker 不再存在;它已被 ApplicationMaster UI 和 JobHistory UI 所取代。 |
| mapred.task.tracker.http.address mapred.task.tracker.report.address | TaskTracker 在 YARN 中也不再存在——它已被 YARN NodeManager 所取代。 |
| mapred.local.dir | 这曾经是存储 MapReduce 作业中间数据的本地目录。这个属性已经被弃用,新的属性名称是 mapreduce.jobtracker.system.dir。它的使用也仅限于 LocalJob-Runner,如果你在运行本地作业(不在 YARN 集群上)时,它才会发挥作用。 |
| mapred.system.dir | 与 mapred.local.dir 类似,它在运行 LocalJobRunner 时才会被使用。 |
| mapred.tasktracker.map.tasks.maximum mapred.tasktracker.reduce.tasks.maximum | 这用于控制一个节点上可以运行的 map 和 reduce 任务进程的最大数量。这些被称为“槽位”,在 Hadoop 1 中是静态的。在 Hadoop 2 中,YARN 不对节点上并发容器的数量施加静态限制,因此这些属性不再需要。 |
| mapred.job.reuse.jvm.num.tasks | 你曾经能够在同一个 JVM 中顺序运行多个任务,这对于生命周期短的任务(以及减少为每个任务创建单独进程的开销)是有用的。这在 YARN 中不再被支持。 |
| tasktracker.http.threads | 在 MRv2 中不再使用。现在从新的 ShuffleHandler 服务中获取 Map 输出,该服务基于 NIO,默认配置为无连接数上限(通过 mapreduce.shuffle.max.connections 配置)。 |
| io.sort.record.percent | 这个洗牌属性曾经用来控制 map 端排序缓冲区(io.sort.mb)中使用的会计空间量。MapReduce 2 在如何填充 io.sort.mb 方面更智能。^([a)] |
^a “Map-side sort is hampered by io.sort.record.percent” 和详细信息可以在
issues.apache.org/jira/browse/MAPREDUCE-64查看。
已弃用的属性
大多数 MapReduce 1(以及许多 HDFS)的属性已被弃用,以支持更好的组织结构的属性名称。^([11)] 目前 Hadoop 2 支持已弃用和新属性名称,但您最好更新您的属性,因为没有保证 Hadoop 3 及以后的版本将支持已弃用的属性。幸运的是,当您运行 MapReduce 作业时,您会在标准输出上获得所有已弃用的配置属性的转储,以下是一个示例:
(11) 请参阅网页 "已弃用的属性",其中列出了已弃用的属性及其新名称。
hadoop.apache.org/docs/stable/hadoop-project-dist/hadoop-common/DeprecatedProperties.html
Configuration.deprecation: mapred.cache.files is deprecated.
Instead, use mapreduce.job.cache.files
很明显,MapReduce 属性有很多变化。您可能想知道 MapReduce 的其余部分是如何变化的,以及哪些部分设法保持了强大的向后兼容性。MapReduce API 和二进制文件在 Hadoop 主版本号增加时是否安然无恙?^([12)]
(12) 语义版本控制 (
semver.org/) 允许在主版本号增加时以破坏向后兼容性的方式更改 API。
2.2.3. 向后兼容性
对于拥有大量、稳定用户基础的系统,向后兼容性是一个重要的考虑因素,因为它确保它们可以快速迁移到系统的全新版本,而几乎不需要或不需要进行更改。本节涵盖了 MapReduce 系统的各个部分,并帮助您确定您是否需要更改系统以使其能够在 MapReduce 2 上运行。
脚本兼容性
与 Hadoop 一起捆绑的脚本保持不变。这意味着您可以使用 hadoop jar ... 来启动作业,并且所有其他对主 hadoop 脚本的使用以及与 Hadoop 一起捆绑的其他脚本都将继续工作。
配置
随着 YARN 的引入和 MapReduce 成为一个应用程序,MapReduce 1 中的许多属性名称现在在 MapReduce 2 中已被弃用,其中一些已不再有效。第 2.2.2 节 涵盖了一些常用属性的变化。
API 向后兼容性
在将 MapReduce 移植到 YARN 的过程中,开发人员尽力保持现有 MapReduce 应用程序的向后兼容性。他们能够实现代码兼容性,但在某些情况下无法保持二进制兼容性:
-
代码兼容性意味着任何今天存在的 MapReduce 代码,只要重新编译,就可以在 YARN 上良好运行。这很好,这意味着你不需要修改代码就能使其在 YARN 上工作。
-
二进制兼容性意味着 MapReduce 的字节码可以在 YARN 上不变地运行。换句话说,你不需要重新编译你的代码——你可以使用在 Hadoop 1 上工作过的相同的类和 JAR 文件,它们在 YARN 上也能正常工作。
使用“旧”的 MapReduce API(org.apache.hadoop.mapreduce 包)的代码是二进制兼容的,所以如果你的现有 MapReduce 代码只使用旧 API,你就没问题——不需要重新编译你的代码。
对于“新”的 MapReduce API(org.apache.hadoop.mapreduce)的某些使用情况,情况并非如此。如果你使用新的 API,可能你正在使用 API 的一些已更改的功能;即,一些类被更改为接口。以下是一些这样的类的例子:
-
JobContext -
TaskAttemptContext -
Counter
这引发了一个问题:如果你使用新的 MapReduce API 并且有需要在 Hadoop 两个版本上运行的代码,你会怎么做。
技巧 5:编写在 Hadoop 版本 1 和 2 上都能运行的代码
如果你使用“新”的 MapReduce API 并且有自己的 Input/OutputFormat 类或使用计数器(仅举几个在 MapReduce 版本之间不兼容的操作),那么你可能有需要重新编译以与 MapReduce 2 兼容的 JAR 文件。如果你必须同时支持 MapReduce 1 和 2,这将是一个麻烦事。你可以为每个 MapReduce 版本创建两组 JAR 文件,但你可能需要向你的构建团队支付几杯啤酒,并最终拥有更复杂的构建和部署系统。或者,你可以使用这个技巧中的提示,继续分发单个 JAR 文件。
问题
你正在使用与 MapReduce 2 不二进制兼容的 MapReduce 代码,并且你想要以能够与两个 MapReduce 版本兼容的方式更新你的代码。
解决方案
使用一个处理 API 差异的 Hadoop 兼容性库。
讨论
Elephant Bird 项目包含一个 HadoopCompat 类,它可以动态地确定你正在运行哪个版本的 Hadoop,并使用 Java 反射来调用适当的方法调用以与你的 Hadoop 版本一起工作。以下代码展示了其使用的一个例子,其中在一个InputFormat实现中,TaskAttemptContext从类更改为接口,并且正在使用HadoopCompat类来提取Configuration对象:
import com.alexholmes.hadooputils.util.HadoopCompat;
import org.apache.hadoop.mapreduce.InputSplit;
import org.apache.hadoop.mapreduce.RecordReader;
import org.apache.hadoop.mapreduce.TaskAttemptContext;
public class MyInputFormat implements InputFormat {
@Override
public RecordReader createRecordReader(InputSplit split,
TaskAttemptContext context)
throws IOException {
final Configuration conf = HadoopCompat.getConfiguration(context);
...
}
}
Hadoop 2 中哪些类被改为接口?一些值得注意的类包括 TaskAttemptContext、JobContext 和 MapContext。表 2.5 展示了 HadoopCompat 类中可用的一些方法。
表 2.5. 在 MapReduce 版本之间不兼容的二进制类和方法
| Hadoop 类和方法 | HadoopCompat 调用 | 你会遇到这个接口的地方 |
|---|---|---|
| JobContext.getConfiguration | HadoopCompat.getConfiguration | 这可能是最常用的类(现在是一个接口)。你可能会遇到这个接口,因为它是你获取 map 或 reduce 任务的配置的方式。 |
| TaskAttemptContext.setStatus | HadoopCompat.setStatus | 如果你有一个自定义的 InputFormat、OutputFormat、RecordReader 或 RecordWriter,你会遇到这个接口。 |
| TaskAttemptContext.getTaskAttemptID | HadoopCompat.getTaskAttemptID | 如果你有一个自定义的 InputFormat、OutputFormat、RecordReader 或 RecordWriter,你会使用这个接口。 |
| TaskAttemptContext.getCounter | HadoopCompat.getCounter | 如果你有一个自定义的 InputFormat、OutputFormat、RecordReader 或 RecordWriter,你会遇到这个接口。 |
| Counter.incrementCounter | HadoopCompat.incrementCounter | 如果你使用计数器在你的作业中,你需要使用 HadoopCompat 调用。 |
HadoopCompat 类还有一个方便的方法叫做 isVersion2x,它返回一个布尔值,如果类已经确定你的运行时正在运行 Hadoop 的 2 版本。
这只是这个类上方法的一个示例——对于完整详情,请参阅 Elephant Bird 项目在 GitHub 上的 HadoopCompat 页面:github.com/kevinweil/elephant-bird/blob/master/hadoop-compat/src/main/java/com/twitter/elephantbird/util/HadoopCompat.java。
Maven Central 包含了这个库的包,你可以在 Maven 仓库的“Elephant Bird Hadoop 兼容性”页面查看示例条目,并将其添加到你的 Maven 文件中。mvnrepository.com/artifact/com.twitter.elephantbird/elephant-bird-hadoop-compat
如你之前所见,Hadoop 1 中的主要脚本 hadoop 在 Hadoop 2 中继续存在且未做更改。在下一节中,你将看到如何使用较新版本的脚本不仅运行 MapReduce 作业,还可以发出 YARN 命令。
2.2.4. 运行一个作业
是时候运行一个 MapReduce 2 作业了。别担心,这样做几乎与你在 MapReduce 1 中做的一样。
技巧 6 使用命令行运行作业
在这个技巧中,你将学习如何使用命令行来运行一个 MapReduce 作业。
问题
你想使用 YARN 命令行来运行一个 MapReduce 作业。
解决方案
使用 yarn 命令。
讨论
在 Hadoop 1 中,hadoop 命令是用于启动作业的命令。出于向后兼容的原因,此命令仍然有效,但此命令的 YARN 形式是 yarn 脚本,它的工作方式与旧的 hadoop 脚本非常相似。例如,这是如何在 Hadoop 示例 JAR 中运行捆绑的 pi 作业的方法:^([13])
¹³ 此示例使用准蒙特卡洛方法计算π的值。
$ yarn jar ${HADOOP_HOME}/share/hadoop/mapreduce/*-examples-*.jar pi 2 10
Estimated value of Pi is 3.1428000
如果你习惯使用 hadoop 来运行你的作业,考虑将其替换为 yarn 命令。目前尚不清楚是否有计划废弃并移除 hadoop 命令,但可以肯定的是,yarn 的对应命令将会持续存在。
在版本 2 中,你可以启动 MapReduce 作业的方式已经改变,查看运行中和已完成作业的状态和细节的机制也发生了变化。
2.2.5. 监控运行中的作业和查看归档作业
在运行 MapReduce 作业时,为了监控和调试的目的,能够查看作业及其任务的状态,并访问任务日志非常重要。在 MapReduce 1 中,所有这些操作都是通过 JobTracker UI 完成的,它可以用来查看运行中、已完成或归档作业的详细信息。
如 2.2.1 节中所述,JobTracker 在 MapReduce 2 中不再存在;它已被特定于 ApplicationMaster 的 UI 和已完成作业的 JobHistoryServer 所取代。ApplicationMaster UI 可以在图 2.11 中看到。对于获取映射和减少任务日志,UI 将重定向到 NodeManager。
图 2.11。YARN ResourceManager UI,显示当前正在执行的应用程序

确定 ResourceManager UI 的运行位置
你可以通过检查 yarn.resourcemanager.webapp.address 的值(如果需要 HTTPS 访问,则为 yarn.resourcemanager.webapp.https.address)来获取 ResourceManager 的主机和端口。在伪分布式安装的情况下,这将是指定的 http://localhost:8088(或 HTTPS 的端口 8090)。将主机和端口复制到浏览器中就足以访问 UI,因为不需要 URL 路径。
JobHistoryServer 可以在图 2.12 中看到。
图 2.12。JobHistory UI,显示已完成的 MapReduce 应用程序

MapReduce 2 改变了作业的执行、配置和监控方式。它还引入了新的功能,例如 uber 作业,接下来将介绍。
2.2.6. Uber 作业
在运行小型 MapReduce 作业时,资源调度和进程分叉所需的时间通常是总体运行时间的一个大比例。在 MapReduce 1 中,你对此开销没有任何选择,但 MapReduce 2 变得更加智能,现在可以满足你尽快运行轻量级作业的需求。
技巧 7 运行小型 MapReduce 作业
这种技术探讨了如何在 MapReduce ApplicationMaster 内部运行 MapReduce 作业。当你处理少量数据时,这很有用,因为它减少了 MapReduce 通常花费在启动和关闭映射和减少进程上的额外时间。
问题
你有一个在小型数据集上运行的 MapReduce 作业,并且你想要避免调度和创建映射和减少进程的开销。
解决方案
配置你的作业以启用 uber 作业;这将使映射器和减少器在 ApplicationMaster 的同一进程中运行。
讨论
Uber 作业是在 MapReduce ApplicationMaster 内部执行的作业。而不是与 ResourceManager 协商来创建映射和减少容器,ApplicationMaster 在其自己的进程中运行映射和减少任务,从而避免了启动和与远程容器通信的开销。
要启用 uber 作业,你需要设置以下属性:
mapreduce.job.ubertask.enable=true
表 2.6 列出了一些控制作业是否适合 uber 化的额外属性。
表 2.6. 个性化 uber 作业的属性
| 属性 | 默认值 | 描述 |
|---|---|---|
| mapreduce.job.ubertask.maxmaps | 9 | 一个作业的映射器数量必须小于或等于此值,作业才能被 uber 化。 |
| mapreduce.job.ubertask.maxreduces | 1 | 一个作业的减少器数量必须小于或等于此值,作业才能被 uber 化。 |
| mapreduce.job.ubertask.maxbytes | 默认块大小 | 作业的总输入大小必须小于或等于此值,作业才能被 uber 化。 |
在运行 uber 作业时,MapReduce 禁用了推测执行,并将任务的最大尝试次数设置为1。
Reducer 限制
目前只支持 map-only 作业和只有一个减少器的作业进行 uber 化。
Uber 作业是 MapReduce 功能的一个方便的新增功能,并且它们只在 YARN 上工作。这标志着我们对 YARN 上的 MapReduce 的探讨结束。接下来,你将看到其他在 YARN 上运行的系统的示例。
2.3. YARN 应用程序
到目前为止,你已经看到了 YARN 是什么,它是如何工作的,以及 MapReduce 2 作为 YARN 应用程序是如何工作的。但这只是 YARN 旅程的第一步;已经有几个项目在 YARN 上工作,随着时间的推移,你应该期待看到 YARN 生态系统的快速增长。
在这一点上,你可能想知道为什么 YARN 应用程序具有吸引力,为什么 Hadoop 社区在 YARN 架构和将 MapReduce 移植到 YARN 应用程序上投入了如此多的工作。我们在本章开头提到了许多原因,但 Hadoop 这一革命性变革背后的最重要的原因是开放平台。想想看,我们现在的系统是如何工作的——那些在单体系统中工作的日子已经过去了;相反,我们生活在一个在我们的数据中心运行多个不同系统的世界里,如图 2.13 所示。
图 2.13. 我们今天运行的一些常见系统。它们是孤立的,这增加了数据和资源共享的复杂性。

这就是很多系统!而且很可能你现在已经在生产中运行了许多这样的系统。如果你是一名工程师,你可能对拥有所有这些系统感到兴奋,但系统管理员和架构师在思考支持所有这些系统带来的挑战时可能会感到头痛:
-
他们必须建立内部知识来管理和维护系统,使其保持健康。系统会失败,尤其是复杂的分布式系统,而且作为开源项目,许多这些系统没有工具来简化管理。
-
系统间的数据交换很痛苦,主要是因为数据量庞大,以及缺乏数据移动工具。随之而来的是大型、昂贵的项目。14。
^(14) LinkedIn 通过 Jay Kreps 在其博客文章“日志:每位软件工程师都应该了解关于实时数据统一抽象的知识”——查看“统一日志”图像及其周围的文本,了解一个有助于减少这些痛点的架构解决方案:
engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying。 -
每个系统都必须解决相同的一些分布式问题,例如容错、分布式存储、日志处理和资源调度。
YARN 承诺提供一个可以统一管理资源的单一集群,支持多租户应用程序和用户,并在共享存储上提供弹性计算。HBase 与 Hoya 的结合为我们提供了一个关于未来可能形态的预览:利用强大的数据局部性属性,高效地在 HBase 内外移动数据;Hoya 通过其与 YARN 的集成,提供了弹性、按需计算,能够在单个 YARN 集群上运行多个 HBase 集群。
在接下来的几节中,你将了解到基于 YARN 的多个系统,这些系统跨越了广泛的技术领域。我们将查看一些使用 YARN 兼容性构建的技术示例。
2.3.1. NoSQL
NoSQL 涵盖了广泛的技术,但简而言之,它们是提供实时 CRUD 操作但不神圣化 ACID 属性的系统。这些系统是为了克服单体 OLAP 系统的不足而创建的,这些系统阻碍了系统架构扩展和提供响应性服务的能力。
现在有许多 NoSQL 系统,但没有哪一个比 HBase 与 Hadoop 的集成更紧密。甚至在 YARN 出现之前,HBase 的目标就是使用 HDFS 进行存储,并且 HBase 通过与 MapReduce 的紧密集成受益,这为批量处理提供了竞争对手通常难以实现的功能。
YARN 为 HBase 解决了两个挑战。HBase 和 MapReduce 1 在同一个集群中共存带来了资源管理挑战,因为没有简单的方法可以保证同时为这两个系统提供 SLA。YARN 利用 Linux 中的 cgroups,为并发执行的过程提供保证其所需资源的访问。YARN 给 HBase 带来的第二个机会是能够在同一个 Hadoop 集群上运行多个 HBase 集群。这个支持正在一个名为 Hoya 的项目中实施,Hoya 是 HBase on Yarn 的简称。
2.3.2. 交互式 SQL
直到最近,在 Hadoop 上运行 SQL 一直是一项需要耐心的练习——启动 Hive shell,输入查询,然后等待,通常需要几分钟,才能得到结果.^(15) 数据科学家和分析师可能不会认为这是一个快速探索和实验数据的最佳环境。
^(15) Hive 查询之所以以前需要很长时间,是因为它们会被转换为一个或多个 MapReduce 作业,因此作业启动时间(加上将中间输出写入和从磁盘读取)导致了查询时间过长。
已经有几项举措来解决这个问题。Cloudera 的解决方案是创建 Impala 项目,该项目完全绕过 MapReduce,通过在每个从节点上运行自己的守护进程(与 HDFS 从守护进程、DataNode 一起本地化,以实现数据局部性)来运行。为了帮助 YARN 集群上的多租户,Cloudera 开发了 Llama(cloudera.github.io/llama/),旨在以这种方式与 YARN 协同工作,使得 YARN 能够理解 Impala 守护进程在集群上使用的资源。
Hortonworks 采取了一种不同的方法——他们专注于对 Hive 进行改进,并已迈出了使 Hive 更具交互性的重要步伐。他们将改进成果整合在一个名为 Stinger 的项目下(hortonworks.com/labs/stinger/),其中最显著的变化是绕过 Map-Reduce,使用 Tez,一个 YARN DAG 处理框架来执行工作。
Apache Drill 是另一个承诺能够在多个持久存储上工作的 SQL-on-Hadoop 解决方案,例如 Cassandra 和 MongoDB。他们有一个开放的任务单,旨在向项目中添加 YARN 支持(issues.apache.org/jira/browse/DRILL-142)。
Facebook Presto 也属于 SQL-on-Hadoop 阵营,但到目前为止还没有关于是否会支持 YARN 的消息。
2.3.3. 图处理
现代图处理系统允许分布式图算法在包含数十亿个节点和数万亿条边的庞大图上执行。使用传统 MapReduce 的图操作通常会导致每个迭代一个作业,^([16]) 这很慢且繁琐,因为它需要在每个迭代中将整个图数据结构序列化到磁盘上。
¹⁶ Giraph 在其 MapReduce 1 实现中通过使用与 ZooKeeper 交换状态的长运行 map 任务以及相互传递消息来解决这个问题。
Apache Giraph 是一个流行的图处理项目,自版本 1 及更早版本以来一直在 Hadoop 上工作,提交者还更新了 Giraph,使其能够作为一个原生 YARN 应用程序运行。
Apache Hama 也在 YARN 上具备一些图处理能力。
2.3.4. 实时数据处理
实时数据处理系统是处理无界数据流的工作计算系统。这些系统的特性与 MapReduce 类似,因为它们允许过滤、投影、连接和聚合等操作。这些系统的典型用途是处理系统中发生的实时事件,进行一些聚合,然后将结果推送到 NoSQL 存储中,以便其他系统检索。
不可否认,在撰写本文时,最具吸引力的实时数据处理系统是 Apache Storm,它最初由 Nathan Marz 构建,是 Lambda 架构的关键部分^([17])。为了将 Storm 引入 YARN,Yahoo 创建了一个名为 storm-yarn 的项目。该项目提供了几个优点——不仅允许多个 Storm 集群在 YARN 上运行,而且它还承诺为 Storm 集群提供弹性:能够快速为 Storm 分配额外资源。有关该项目的更多详细信息,请参阅 github.com/yahoo/storm-yarn。
¹⁷ Lambda 架构利用了批处理和实时处理的优势。更多内容请参阅 Nathan Marz 的著作 Big Data(Manning,2014)。
Spark Streaming 是另一个值得注意的实时数据处理项目,它是作为 Spark API 的扩展开发的,并支持消费诸如 HDFS、Kafka、Flume 等数据源。Spark 也支持在 YARN 上运行。Spark Streaming 可能会成为 Storm 的强大竞争对手,尤其是因为一旦你掌握了 Spark,你也知道如何进行 Spark Streaming,反之亦然。这意味着你有一个单一的编程范式,既可以用于离线数据分析,也可以用于实时数据分析。
其他与 YARN 集成的实时数据处理系统包括 Apache S4、Apache Samza(源自 LinkedIn)和 DataTorrent。
2.3.5. 批量同步并行
批量同步并行(BSP)是一种分布式处理方法,其中多个并行工作者独立地对整体问题的一个子集进行工作,之后他们相互交换数据,然后使用全局同步机制等待所有工作者完成,然后再重复该过程。Google Pregel 发布了他们的图处理框架如何受到 BSP 启发,Apache Giraph 使用类似的 BSP 模型进行图迭代。
Apache Hama 是一个通用的 BSP(Bulk Synchronous Parallel)实现,它可以在 YARN 上运行。它还具备内置的图处理能力。
2.3.6. MPI
MPI(消息传递接口)是一种机制,允许在主机集群之间交换消息。Open MPI 是一个开源的 MPI 实现。目前有一个开放工单来完成将 Open MPI 支持集成到 Hadoop 的工作 (issues.apache.org/jira/browse/MAPREDUCE-2911)。为此集成已完成的工作在 mpich2-yarn 的 github.com/clarkyzl/mpich2-yarn。
2.3.7. 内存中
内存计算利用我们系统中不断增长的内存占用,快速执行迭代处理和交互式数据挖掘等计算活动。
Apache Spark 是来自伯克利的一个流行例子。它是包括 Shark 用于 SQL 操作和 GraphX 用于图处理在内的整体解决方案的关键部分。Cloudera 的 CDH5 发行版包括在 YARN 上运行的 Spark。
关于如何在 YARN 上运行 Spark 的更多详细信息,请参阅 Spark 的“在 YARN 上启动 Spark”页面,spark.apache.org/docs/0.9.0/running-on-yarn.html。
2.3.8. DAG 执行
有向无环图(DAG)执行引擎允许您将数据处理逻辑建模为 DAG,然后在大型数据集上并行执行。
Apache Tez 是 DAG(Directed Acyclic Graph)执行引擎的一个例子;它诞生于提供更通用化的 MapReduce 系统的需求,该系统将保留 MapReduce 的并行性和吞吐量,同时支持 MapReduce 提供的额外处理模型和优化。Tez 的能力示例包括不强制使用特定的数据模型,因此既支持 MapReduce 的键/值模型,也支持 Hive 和 Pig 的基于元组的模型。
与 MapReduce 相比,Tez 提供了许多优势,包括消除 MapReduce 中存在于多个作业之间的复制写屏障——这是像 Hive 和 Pig 这样的系统的主要性能瓶颈。Tez 还可以在不需要 MapReduce 所需的排序开销的情况下支持 reduce 操作,从而在不需要排序的应用程序中实现更高效的管道。Tez 还支持复杂的操作,如 Map-Map-Reduce 或任何任意操作图,让开发者能够更自然地表达他们的数据管道。Tez 还可以在执行时进行动态数据流选择——例如,根据你流程中中间数据的大小,你可能决定将其存储在内存中或在 HDFS 或本地磁盘上。
所有这些的结果是 Tez 可以摆脱 Map-Reduce 仅支持批处理的束缚,并支持交互式用例。以一个例子来说,Tez 最初的范围是实现 Hortonworks 使 Hive 交互式目标的一大步——从 Map-Reduce 迁移到 Tez 是这项工作的关键部分。
2.4. 章节总结
Hadoop 2 版本颠覆了 Hadoop 中工作方式的传统。你不再局限于在集群上运行 MapReduce。本章涵盖了开始使用 YARN 所需的基本知识。你了解了为什么 YARN 在 Hadoop 中很重要,看到了架构的高级概述,并学习了你需要使用的一些显著的 YARN 配置属性。
YARN 的出现也带来了 MapReduce 工作方式的重要变化。MapReduce 已被移植到 YARN 应用程序中,在第 2.2 节中,你看到了 MapReduce 在 Hadoop 2 中的执行方式,了解了哪些配置属性发生了变化,还了解了一些新特性,例如 uber 作业。
本章的最后部分介绍了一些新兴 YARN 应用程序的精彩示例,以让你对在 YARN 集群上可以期待释放的能力有所了解。如需了解更多关于 YARN 的内容,请随意跳转到第十章并查看如何开发你自己的 YARN 应用程序!
现在你已经了解了 YARN 的布局,是时候转向查看 Hadoop 中的数据存储了。下一章的重点是处理常见的文件格式,如 XML 和 JSON,以及选择更适合 Hadoop 生活的文件格式,如 Parquet 和 Avro。
第二部分. 数据物流
如果你一直在思考如何在生产环境中使用 Hadoop,那么这本书的这一部分将对你有所帮助,它涵盖了你需要跨越的第一个障碍。这些章节详细介绍了经常被忽视但至关重要的主题,这些主题涉及 Hadoop 中的数据管理。
第三章探讨了如何处理存储在不同格式中的数据,例如 XML 和 JSON,为更广泛地研究数据格式如 Avro 和 Parquet 铺平了道路,这些格式与大数据和 Hadoop 配合得最好。
第四章探讨了在 HDFS 中布局你的数据、分区和压缩数据的一些策略。本章还涵盖了处理小文件的方法,以及压缩如何帮助你避免许多存储和计算上的麻烦。
第五章探讨了如何管理将大量数据移动到 Hadoop 中以及从 Hadoop 中移出的方法。示例包括在 RDBMS 中处理关系数据、结构化文件和 HBase。
第三章. 数据序列化——处理文本及其他
本章涵盖
-
处理文本、XML 和 JSON
-
理解 SequenceFile、Avro、Protocol Buffers 和 Parquet
-
处理自定义数据格式
MapReduce 为处理简单的数据格式(如日志文件)提供了简单、文档齐全的支持。但是,MapReduce 已经从日志文件扩展到更复杂的数据序列化格式——如文本、XML 和 JSON——以至于其文档和内置支持已经用尽。本章的目标是记录如何处理常见的数据序列化格式,以及检查更结构化的序列化格式并比较它们与 MapReduce 使用的适用性。
假设你想要处理无处不在的 XML 和 JSON 数据序列化格式。这些格式在大多数编程语言中都以直接的方式工作,有多个工具可以帮助你进行序列化、反序列化和验证(如果适用)。然而,在 MapReduce 中使用 XML 和 JSON 却提出了两个同样重要的挑战。首先,MapReduce 需要能够支持读取和写入特定数据序列化格式的类;如果你正在使用自定义文件格式,那么它很可能没有支持你所使用的序列化格式的类。其次,MapReduce 的强大之处在于其并行读取输入数据的能力。如果你的输入文件很大(想想几百兆或更多),那么能够将你的大文件分割以便多个 map 任务并行读取的类至关重要。
我们将从这个章节开始,解决如何处理如 XML 和 JSON 这样的序列化格式的问题。然后我们将比较和对比更适合处理大数据的数据序列化格式,例如 Avro 和 Parquet。最后的挑战是当你需要处理一个专有格式或 MapReduce 中没有读写绑定的较少见格式时。我会向你展示如何编写自己的类来读取/写入你的文件格式。
XML 和 JSON 格式
本章假设你熟悉 XML 和 JSON 数据格式。如果需要,维基百科提供了关于 XML 和 JSON 的一些很好的背景文章。你还应该有一些编写 MapReduce 程序的经验,并应理解 HDFS 和 MapReduce 输入输出的基本概念。Chuck Lam 的书籍《Hadoop in Action》(Manning,2010)是这个主题的一个很好的资源。
MapReduce 中的数据序列化支持是读取和写入 MapReduce 数据的输入和输出类的属性。让我们从 MapReduce 如何支持数据输入和输出的概述开始。
3.1. 理解 MapReduce 中的输入和输出
你的数据可能是位于多个 FTP 服务器后面的 XML 文件,位于中央 Web 服务器上的文本日志文件,或者 HDFS 中的 Lucene 索引。1。MapReduce 是如何支持通过不同的存储机制读取和写入这些不同的序列化结构的?
¹ Apache Lucene 是一个信息检索项目,它使用优化了全文搜索的倒排索引数据结构来存储数据。更多信息请访问
lucene.apache.org/。
图 3.1 展示了通过 MapReduce 的高级数据流,并确定了负责流中各个部分的演员。在输入端,你可以看到一些工作(创建分割)在映射阶段之外执行,而其他工作作为映射阶段的一部分执行(读取分割)。所有的输出工作都在归约阶段(写入输出)执行。
图 3.1. MapReduce 中的高级输入和输出演员

图 3.2 展示了仅映射作业的相同流程。在仅映射作业中,Map-Reduce 框架仍然使用OutputFormat和RecordWriter类直接将输出写入数据接收器。
图 3.2. 没有归约器的 MapReduce 中的输入和输出演员

让我们遍历数据流并讨论各个演员的责任。在这个过程中,我们还将查看内置的TextInputFormat和TextOutputFormat类中的相关代码,以更好地理解概念。TextInputFormat和TextOutputFormat类读取和写入面向行的文本文件。
3.1.1. 数据输入
支持在 MapReduce 中进行数据输入的两个类是 InputFormat 和 RecordReader。InputFormat 类用于确定输入数据应该如何分区以供 map 任务使用,而 RecordReader 执行从输入读取数据。
InputFormat
在 MapReduce 中,每个作业都必须根据 InputFormat 抽象类中指定的合同定义其输入。InputFormat 实现者必须满足三个合同:他们描述了 map 输入键和值的类型信息,他们指定了如何对输入数据进行分区,并且指出了应该从源读取数据的 RecordReader 实例。图 3.3 展示了 InputFormat 类以及这三个合同是如何定义的。
图 3.3. 带注释的 InputFormat 类及其三个合同

毫无疑问,最重要的合同是确定如何划分输入数据。在 MapReduce 术语中,这些划分被称为 输入分割。输入分割直接影响 map 并行性,因为每个分割由单个 map 任务处理。如果使用无法在单个数据源(如文件)上创建多个输入分割的 InputFormat,将会导致 map 阶段变慢,因为文件将按顺序处理。
TextInputFormat 类(查看源代码位于 mng.bz/h728)为 InputFormat 类的 createRecordReader 方法提供了一个实现,但它将输入分割的计算委托给其父类 FileInputFormat。以下代码展示了 TextInputFormat 类的相关部分:

在 FileInputFormat 中的代码(源代码位于 mng.bz/CZB8)用于确定输入分割,这部分代码稍微复杂一些。以下示例展示了 getSplits 方法的简化形式,以展示其主要元素:

以下代码展示了如何指定 MapReduce 作业使用的 InputFormat:
job.setInputFormatClass(TextInputFormat.class);
RecordReader
你将在 map 任务中创建和使用 RecordReader 类来从输入分割中读取数据,并将每个记录以键/值对的形式提供给 mappers 使用。通常为每个输入分割创建一个任务,并且每个任务都有一个单独的 RecordReader 负责读取该输入分割的数据。图 3.4 展示了你必须实现的抽象方法。
图 3.4. 带注释的 RecordReader 类及其抽象方法

如前所述,TextInputFormat类创建一个LineRecordReader来从输入拆分中读取记录。LineRecordReader直接扩展了RecordReader类,并使用LineReader类从输入拆分中读取行。LineRecordReader使用文件中的字节偏移量作为 map 键,并使用行的内容作为 map 值。以下示例展示了LineRecordReader的简化版本(源代码见mng.bz/mYO7):

由于LineReader类很简单,我们将跳过那段代码。下一步是看看 MapReduce 如何支持数据输出。
3.1.2. 数据输出
MapReduce 使用类似的过程来支持输出和输入数据。必须存在两个类:一个OutputFormat和一个RecordWriter。OutputFormat执行一些基本的数据目标属性验证,而RecordWriter将每个 reducer 输出写入数据目标。
OutputFormat
就像InputFormat类一样,如图 3.5 所示的OutputFormat类定义了实现者必须满足的契约:检查与作业输出相关的信息,提供一个RecordWriter,并指定一个输出提交者,这允许在任务或作业成功后将写入分阶段并最终“永久化”。(输出提交将在 3.5.2 节中讨论。)
图 3.5. 注释的 OutputFormat 类

就像TextInputFormat一样,TextOutputFormat也扩展了一个基类FileOutputFormat,它负责一些复杂的物流,例如输出提交,我们将在本章后面讨论。现在,让我们看看TextOutputFormat执行的工作(源代码见mng.bz/lnR0):

以下代码展示了如何指定 MapReduce 作业应使用的OutputFormat:
job.setOutputFormatClass(TextOutputFormat.class);
RecordWriter
你将使用RecordWriter将 reducer 输出写入目标数据目标。它是一个简单的类,如图 3.6 所示。
图 3.6. 注释的 RecordWriter 类概述

TextOutputFormat返回一个LineRecordWriter对象,它是Text-OutputFormat的内部类,用于执行文件写入。以下示例展示了该类的简化版本(源代码见mng.bz/lnR0):

在 map 端,是InputFormat决定了执行多少个 map 任务,而在 reducer 端,任务的数量完全基于客户端设置的mapred.reduce.tasks值(如果没有设置,则从 mapred-site.xml 中获取值,如果该文件不存在于站点文件中,则从 mapred-default.xml 中获取值)。
现在你已经了解了在 Map-Reduce 中处理输入和输出数据所涉及的内容,现在是时候将这项知识应用到解决一些常见的数据序列化问题上了。在这个旅程的第一步,你需要学习如何处理常见的文件格式,如 XML。
3.2. 处理常见的序列化格式
XML 和 JSON 是行业标准的数据交换格式。它们在技术行业中的普遍性体现在它们在数据存储和交换中的广泛采用。在本节中,我们将探讨如何在 MapReduce 中读取和写入这些数据格式。
3.2.1. XML
XML 自 1998 年以来作为一种机制存在,可以由机器和人类 alike 读取数据。它成为系统间数据交换的通用语言,并被许多标准采用,如 SOAP 和 RSS,它还被用作 Microsoft Office 等产品作为开放数据格式。
技巧 8 MapReduce 和 XML
MapReduce 附带了一个与文本一起工作的InputFormat,但它没有附带支持 XML 的InputFormat。在 MapReduce 中并行处理单个 XML 文件是棘手的,因为 XML 在其数据格式中不包含同步标记.^([2])
² 同步标记通常是用于界定记录边界的二进制数据。它允许读者在文件中进行随机查找,并通过读取直到找到同步标记来确定下一个记录的开始位置。
问题
你想在 MapReduce 中处理大型 XML 文件,并且能够并行地分割和处理它们。
解决方案
Mahout 的XMLInputFormat可用于使用 MapReduce 在 HDFS 中处理 XML 文件。它读取由特定的 XML 开始和结束标签分隔的记录。这项技术还解释了如何在 MapReduce 输出中发出 XML。
讨论

MapReduce 不包含对 XML 的内置支持,因此我们将转向另一个 Apache 项目——Mahout,一个机器学习系统,以提供 XML InputFormat。为了展示 XML InputFormat,你可以编写一个 MapReduce 作业,使用 Mahout 的 XML 输入格式从 Hadoop 的配置文件中读取属性名称和值。第一步是设置作业配置:
Mahout 的 XML 输入格式是基本的;你需要告诉它将在文件中搜索的确切开始和结束 XML 标签,并且文件使用以下方法分割(并提取记录):
1. 文件沿 HDFS 块边界分割成离散部分,以实现数据局部性。
2. 每个 map 任务在特定的输入分割上操作。map 任务定位到输入分割的开始,然后继续处理文件,直到遇到第一个
xmlinput.start。3. 在
xmlinput.start和xmlinput.end之间的内容会重复发出,直到输入分割的末尾。
接下来,您需要编写一个 mapper 来消费 Mahout 的 XML 输入格式。XML 元素以Text形式提供,因此您需要使用 XML 解析器从 XML 中提取内容。³
³ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/xml/XMLMapReduceReader.java。
列表 3.1. 使用 Java 的 STAX 解析器提取内容
public static class Map extends Mapper<LongWritable, Text,
Text, Text> {
@Override
protected void map(LongWritable key, Text value,
Mapper.Context context)
throws
IOException, InterruptedException {
String document = value.toString();
System.out.println("'" + document + "'");
try {
XMLStreamReader reader =
XMLInputFactory.newInstance().createXMLStreamReader(new
ByteArrayInputStream(document.getBytes()));
String propertyName = ";
String propertyValue = ";
String currentElement = ";
while (reader.hasNext()) {
int code = reader.next();
switch (code) {
case START_ELEMENT:
currentElement = reader.getLocalName();
break;
case CHARACTERS:
if (currentElement.equalsIgnoreCase("name")) {
propertyName += reader.getText();
} else if (currentElement.equalsIgnoreCase("value")) {
propertyValue += reader.getText();
}
break;
}
}
reader.close();
context.write(propertyName.trim(), propertyValue.trim());
} catch (Exception e) {
log.error("Error processing '" + document + "'", e);
}
}
}
Map 被赋予一个Text实例,它包含起始和结束标签之间的数据字符串表示。在此代码中,您使用 Java 内置的 XML(StAX)解析器 API 提取每个属性的键和值,并将它们输出。
如果您运行 MapReduce 作业针对 Cloudera 的 core-site.xml,并使用 HDFS 的cat命令显示输出,您将看到以下内容:
$ hadoop fs -put $HADOOP_HOME/conf/core-site.xml core-site.xml
$ hip hip.ch3.xml.XMLMapReduceReader \
--input core-site.xml \
--output output
$ hadoop fs -cat output/part*
fs.default.name hdfs://localhost:8020
hadoop.tmp.dir /usr/local/hadoop/tmp
...
此输出表明您已成功使用 MapReduce 将 XML 作为输入序列化格式进行处理。不仅如此,您还可以支持巨大的 XML 文件,因为输入格式支持分割 XML。
写入 XML
成功读取 XML 后,下一个问题是如何写入 XML。在您的 reducer 中,在调用主 reduce 方法之前和之后发生回调,您可以使用这些回调来发射起始和结束标签,如下例所示。⁴
⁴ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/xml/XmlMapReduceWriter.java。
列表 3.2. 发射起始和结束标签的 reducer
![ch03ex02-0.jpg]
![ch03ex02-1.jpg]
这也可以嵌入到OutputFormat中,但我会把它留给你作为一个实验项目。编写OutputFormat类的介绍在第 3.5.1 节。⁷
Pig
如果您想在 Pig 中处理 XML,Piggy Bank 库⁵(一个有用的 Pig 代码的用户贡献库)包含一个XMLLoader。它的工作方式与这种技术类似,并捕获起始和结束标签之间的所有内容,将其作为 Pig 元组中的一个单独的字节数组字段提供。
⁵ Piggy Bank—用户定义的 Pig 函数:
cwiki.apache.org/confluence/display/PIG/PiggyBank。
Hive
目前,Hive 中还没有处理 XML 的方法。您必须编写一个自定义的 SerDe,我们将在第九章中介绍。⁶
⁶ SerDe 是序列化/反序列化(Serializer/Deserializer)的缩写;它是 Hive 读取和写入 HDFS 数据的机制。
摘要
Mahout 的XmlInputFormat当然可以帮助你处理 XML。但它对起始和结束元素名称的精确字符串匹配很敏感。如果元素标签可以包含具有可变值的属性,或者如果元素的生成无法控制,可能会使用 XML 命名空间限定符,那么这种方法可能不适合你。还有可能的问题是,你指定的元素名称被用作子元素。
如果你控制着输入中的 XML 布局,这个练习可以通过每行一个 XML 元素来简化。这将允许你使用内置的基于文本的 Map-Reduce 输入格式(如TextInputFormat),这些格式将每一行视为一个记录,并分割以保留这种分隔。
另一个值得考虑的选项是预处理步骤,其中你可以将原始 XML 转换为每个 XML 元素一行,或者将其转换为完全不同的数据格式,如 SequenceFile 或 Avro,这两种格式都能为你解决分割问题。
现在你已经掌握了如何处理 XML,让我们来处理另一种流行的序列化格式,JSON。
3.2.2. JSON
JSON 与 XML 共享机器和人类可读的特性,自 2000 年代初以来就存在。它比 XML 更简洁,并且没有 XML 中可用的丰富类型和验证功能。
技巧 9 MapReduce 和 JSON
想象一下你有一些代码,它从流式 REST 服务中下载 JSON 数据,并且每小时将一个文件写入 HDFS。下载的数据量很大,所以每个生成的文件大小都是多吉字节。
你被要求编写一个 MapReduce 作业,它可以接受这些大型 JSON 文件作为输入。这里的问题是两个部分:首先,MapReduce 没有与 JSON 一起工作的InputFormat;其次,如何分割 JSON?
图 3.7 展示了分割 JSON 的问题。想象一下 MapReduce 创建了一个如图所示的分割。在这个输入分割上运行的 map 任务将执行一个到输入分割开始的查找,然后需要确定下一个记录的开始。对于 JSON 和 XML 这样的文件格式,由于缺乏同步标记或任何其他标识记录开始的指示器,很难知道下一个记录的开始。
图 3.7. JSON 和多个输入分割的问题示例

与 XML 这样的格式相比,JSON 更难以分割成不同的段,因为 JSON 没有标记(如 XML 中的结束标签)来表示记录的开始或结束。
问题
你想在 MapReduce 中处理 JSON 输入,并确保输入 JSON 文件可以被分区以进行并发读取。
解决方案
Elephant Bird 的 LzoJsonInputFormat 输入格式被用作创建一个用于处理 JSON 元素的输入格式类的基准。这种技术还讨论了使用我的开源项目来处理多行 JSON 的另一种方法。
讨论
Elephant Bird (github.com/kevinweil/elephant-bird) 是一个开源项目,它包含用于处理 LZOP 压缩的有用工具,它有一个 LzoJsonInputFormat 可以读取 JSON,尽管它要求输入文件是 LZOP 压缩的。你可以使用 Elephant Bird 代码作为模板来创建自己的 JSON InputFormat,该模板不需要 LZOP 压缩要求。
这个解决方案假设每个 JSON 记录都在单独的一行上。你的 JsonRecordFormat 简单,除了构建和返回一个 JsonRecordFormat 之外什么都不做,所以我们略过那段代码。JsonRecordFormat 向映射器发射 LongWritable、MapWritable 键/值对,其中 MapWritable 是 JSON 元素名称及其值的映射。
让我们看看这个 RecordReader 是如何工作的。它使用 LineRecordReader,这是一个内置的 MapReduce 读取器,为每一行输出一个记录。为了将行转换为 MapWritable,读取器使用以下方法:^([7])
⁷ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/json/JsonInputFormat.java.
public static boolean decodeLineToJson(JSONParser parser, Text line,
MapWritable value) {
try {
JSONObject jsonObj = (JSONObject)parser.parse(line.toString());
for (Object key: jsonObj.keySet()) {
Text mapKey = new Text(key.toString());
Text mapValue = new Text();
if (jsonObj.get(key) != null) {
mapValue.set(jsonObj.get(key).toString());
}
value.put(mapKey, mapValue);
}
return true;
} catch (ParseException e) {
LOG.warn("Could not json-decode string: " + line, e);
return false;
} catch (NumberFormatException e) {
LOG.warn("Could not parse field into number: " + line, e);
return false;
}
}
读者使用 json-simple 解析器 (code.google.com/p/json-simple/) 将行解析为 JSON 对象,然后遍历 JSON 对象中的键,并将它们及其相关值放入一个 MapWritable 中。映射器以 LongWritable、MapWritable 对的形式获得 JSON 数据,并相应地处理数据。
以下展示了一个示例 JSON 对象:
{
"results" :
[
{
"created_at" : "Thu, 29 Dec 2011 21:46:01 +0000",
"from_user" : "grep_alex",
"text" : "RT @kevinweil: After a lot of hard work by ..."
},
{
"created_at" : "Mon, 26 Dec 2011 21:18:37 +0000",
"from_user" : "grep_alex",
"text" : "@miguno pull request has been merged, thanks again!"
}
]
}
这种技术假设每行一个 JSON 对象。以下代码显示了你在本例中将要处理的 JSON 文件:
{"created_at" : "Thu, 29 Dec 2011 21:46:01 +0000","from_user" : ...
{"created_at" : "Mon, 26 Dec 2011 21:18:37 +0000","from_user" : ...
现在,将 JSON 文件复制到 HDFS 并运行你的 MapReduce 代码。MapReduce 代码将每个 JSON 键/值对写入输出:
$ hadoop fs -put test-data/json/tweets.json tweets.json
$ hip hip.ch3.json.JsonMapReduce \
--input tweets.json \
--output output
$ hadoop fs -cat output/part*
text RT @kevinweil: After a lot of hard work by ...
from_user grep_alex
created_at Thu, 29 Dec 2011 21:46:01 +0000
text @miguno pull request has been merged, thanks again!
from_user grep_alex
created_at Mon, 26 Dec 2011 21:18:37 +0000
编写 JSON
一种类似于我们在 第 3.2.1 节 中查看的用于编写 XML 的方法也可以用来编写 JSON。
Pig
Elephant Bird 包含一个 JsonLoader 和一个 LzoJsonLoader,你可以使用它们在 Pig 中处理 JSON。这些加载器处理基于行的 JSON。每个 Pig 元组包含一行中每个 JSON 元素的一个 chararray 字段。
Hive
Hive 包含一个 DelimitedJSONSerDe 类,它可以序列化 JSON,但遗憾的是不能反序列化它,因此你不能使用这个 SerDe 将数据加载到 Hive 中。
摘要
此解决方案假设 JSON 输入是按每行一个 JSON 对象的结构化。您将如何处理跨越多行的 JSON 对象?GitHub 上的一个实验性项目(^[8)在单个 JSON 文件上处理多个输入拆分。这种方法搜索特定的 JSON 成员并检索包含的对象。
⁸ 多行 JSON
InputFormat:github.com/alexholmes/json-mapreduce。
您还可以查看一个名为 hive-json-serde 的 Google Code 项目(code.google.com/p/hive-json-serde/),该项目可以支持序列化和反序列化。
如您所见,在 MapReduce 中使用 XML 和 JSON 是笨拙的,并且对数据的布局有严格的要求。MapReduce 中对这两种格式的支持也复杂且容易出错,因为它们都不自然地适合拆分。显然,您需要考虑具有内置拆分支持的替代文件格式。
下一步是查看更复杂的文件格式,这些格式更适合与 MapReduce 一起使用,例如 Avro 和 SequenceFile。
3.3. 大数据序列化格式
当您处理标量或表格数据时,非结构化文本效果良好。半结构化文本格式,如 XML 和 JSON,可以模拟更复杂的数据结构,包括复合字段或层次数据。但是,当您处理大量大数据时,您将需要具有紧凑序列化形式、原生支持分区和具有模式演变功能的序列化格式。
在本节中,我们将比较与 MapReduce 配合使用效果最佳的数据序列化格式,并随后介绍如何使用它们与 MapReduce 结合。
3.3.1. 比较 SequenceFile、Protocol Buffers、Thrift 和 Avro
根据我的经验,在选择数据序列化格式时,以下特性很重要:
-
代码生成 —一些序列化格式伴随着具有代码生成能力的库,这允许您生成丰富的对象,使您更容易与数据交互。生成的代码还提供了额外的类型安全优势,以确保您的消费者和生成器正在使用正确的数据类型。
-
模式演变 —数据模型会随着时间的推移而演变,因此您的数据格式需要支持您修改数据模型的需求。模式演变允许您添加、修改,在某些情况下删除属性,同时为读取器和写入器提供向前和向后兼容性。
-
语言支持 —您可能需要使用多种编程语言访问您的数据,并且主流语言需要支持数据格式。
-
透明压缩 —考虑到你将处理的数据量,数据压缩非常重要,一个理想的数据格式能够在写入和读取时内部压缩和解压缩数据。如果数据格式不支持压缩,那么作为程序员,这将给你带来更大的麻烦,因为它意味着你将不得不将压缩和解压缩作为数据管道的一部分来管理(就像你在处理基于文本的文件格式时那样)。
-
可分割性 —新的数据格式理解到支持多个并行读取器的重要性,这些读取器正在读取和处理大文件的不同部分。文件格式包含同步标记(从而支持读取器执行随机查找和扫描到下一个记录的开始)是至关重要的。
-
MapReduce 和 Hadoop 生态系统中的支持 —你选择的数据格式必须在 MapReduce 和其他关键 Hadoop 生态系统项目中具有支持,例如 Hive。如果没有这种支持,你将负责编写代码使文件格式与这些系统一起工作。
表 3.1 比较了更流行的数据序列化框架,以了解它们如何相互比较。以下讨论提供了这些技术的更多背景信息。
表 3.1. 数据序列化框架特性比较
| 库 | 代码生成 | 架构演变 | 语言支持 | 透明压缩 | 可分割 | MapReduce 中原生支持 | Pig 和 Hive 支持 |
|---|---|---|---|---|---|---|---|
| SequenceFile | 否 | 否 | Java, Python | 是 | 是 | 是 | 是 |
| Protocol Buffers | 是(可选) | 是 | C++, Java, Python, Perl, Ruby | 否 | 否 | 否 | 否 |
| Thrift | 是(强制) | 是 | C, C++, Java, Python, Ruby, Perl | 否^([a]) | 否 | 否 | 否 |
| Avro | 是(可选) | 是 | C, C++, Java, Python, Ruby, C# | 是 | 是 | 是 | 是 |
| Parquet | 否 | 是 | Java, Python (C++ 计划在 2.0 中实现) | 是 | 是 | 是 | 是 |
^a Thrift 支持压缩,但不在 Java 库中。
让我们更详细地看看这些格式。
SequenceFile
SequenceFile 格式是为了与 MapReduce、Pig 和 Hive 一起使用而创建的,因此与所有这些工具都很好地集成。其缺点主要是缺乏代码生成和版本支持,以及有限的语言支持。
Protocol Buffers
Google 严重使用了 Protocol Buffers 格式来实现互操作性。其优势在于其版本支持和小型二进制格式。缺点包括它在 MapReduce(或任何第三方软件)中不支持由 Protocol Buffers 序列化生成的文件。然而,并非一切都已失去;我们将在第 3.3.3 节中探讨 Elephant Bird 如何在高级容器文件中使用 Protocol Buffers 序列化。
Thrift
Thrift 是在 Facebook 开发的数据序列化和 RPC 框架。它对其原生数据序列化格式在 MapReduce 中没有支持,但它可以支持不同的底层数据表示,包括 JSON 和各种二进制编码。Thrift 还包括一个具有各种类型服务器的 RPC 层,包括非阻塞实现。在本章中,我们将忽略 RPC 功能,专注于数据序列化。
Avro
Avro 格式是 Doug Cutting 为了解决 SequenceFile 的不足而创造的。
Parquet
Parquet 是一种具有丰富 Hadoop 系统支持的列式文件格式,并且与 Avro、Protocol Buffers 和 Thrift 等数据模型配合良好。Parquet 在 第 3.4 节 中有详细介绍。
根据某些评估标准,Avro 似乎是最适合作为 Hadoop 数据序列化框架的。由于与 Hadoop 的固有兼容性(它是为与 Hadoop 一起使用而设计的),SequenceFile 排名第二。
您可以在 github.com/eishay/jvm-serializers/wiki/ 上查看一个有用的 jvm-serializers 项目,该项目运行各种基准测试,以比较基于序列化和反序列化时间等项目的文件格式。它包含 Avro、Protocol Buffers 和 Thrift 的基准测试,以及许多其他框架。
在查看各种数据序列化框架的比较后,我们将用接下来的几节内容来介绍如何使用它们。我们将从查看 SequenceFile 开始。
3.3.2. SequenceFile
由于 SequenceFile 是为与 MapReduce 一起使用而创建的,因此这种格式与 MapReduce、Pig 和 Hive 一起提供了最高级别的集成支持。SequenceFile 是一种可分割的二进制文件格式,以键/值对的形式存储数据。所有 SequenceFiles 都共享相同的头部格式,如图 3.8 图 所示。
图 3.8. SequenceFile 头部格式

SequenceFiles 有三种类型,它们根据您应用的压缩方式而有所不同。此外,每种类型都有自己的相应 Writer 类。
未压缩
未压缩 SequenceFiles 使用 SequenceFile.Writer 类编写。与压缩格式相比,这种格式没有优势,因为压缩通常可以减少您的存储占用,并且在读写方面更高效。文件格式如图 3.9 图 所示。
图 3.9. 记录压缩和非压缩 SequenceFiles 的文件格式

记录压缩
记录压缩 SequenceFiles 使用 SequenceFile.RecordCompressWriter 类编写。当记录被添加到 SequenceFile 中时,它会立即压缩并写入文件。这种方法的缺点是,与块压缩相比,您的压缩率会受到影响。这种文件格式与未压缩 SequenceFiles 的格式基本相同,如图 3.9 图 所示。
块压缩
块压缩的 SequenceFiles 使用SequenceFile.BlockCompressWriter类编写。默认情况下,块大小与 HDFS 块大小相同,尽管这可以被覆盖。这种压缩的优势在于它更加激进;整个块被压缩,而不是在记录级别上进行压缩。数据只有在达到块大小时才会写入,此时整个块被压缩,从而实现良好的整体压缩。文件格式如图 3.10 所示。
图 3.10. 块压缩的 SequenceFile 格式

你只需要一个Reader类(SequenceFile.Reader)来读取所有三种类型的 SequenceFiles。即使是Writer也是抽象的,因为你可以调用SequenceFile.createWriter来选择首选格式,它返回一个基类,可以用于无论压缩与否的写入。
SequenceFiles 拥有一个可插拔的序列化框架。写入的键和值必须有一个相关的org.apache.hadoop.io.serializer.Serializer和Deserializer用于序列化和反序列化。Hadoop 自带四种序列化器:Avro、Java、Tether(用于包含在TetherData类中的二进制数据),以及Writable(默认序列化器).^([9])
⁹
Writable是 Hadoop 中用于支持通用数据序列化的一个接口,它用于在 Hadoop 组件之间发送数据。Yahoo 在developer.yahoo.com/hadoop/tutorial/module5.html#writable有一个关于Writable的良好介绍。
自定义 SequenceFile 序列化
如果你希望你的 SequenceFile 包含不是Writable或Serializable的对象,你需要实现自己的序列化器并将其注册。你可以通过更新 core-site.xml 并将自定义序列化实现的类名追加到io.serializations属性来注册它。
SequenceFiles 是可分割的,因为对于基于记录的文件,大约每 6 KiB(1 kibibyte = 1024 bytes)写入一个同步标记,而对于基于块的文件,在每个块之前写入。
现在我们来看看如何在 MapReduce 中使用 SequenceFiles。
技巧 10 使用 SequenceFiles
当你需要在 MapReduce 中支持复杂的数据类型,包括非标量数据类型,如列表或字典时,处理文本可能会变得复杂。此外,如果 Map-Reduce 的数据局部性属性对你很重要,那么处理大型压缩文本文件需要一些额外的处理。使用如 SequenceFile 这样的文件格式可以克服这些挑战。
问题
你想在 MapReduce 中使用一个结构化文件格式,可以用来建模复杂的数据结构,同时也支持压缩和可分割的输入。
解决方案
这种技术探讨了如何从独立应用程序和 MapReduce 中使用 SequenceFile 文件格式。
讨论
SequenceFile 格式与计算工具(如 MapReduce)提供了高级别的集成,并且还可以模拟复杂的数据结构。我们将探讨如何读取和写入 SequenceFiles,以及如何与 MapReduce、Pig 和 Hive 一起使用。
我们将使用股票数据来应用这项技术。与 SequenceFiles 一起使用的最常见序列化方法是Writable,因此你需要创建一个Writable来表示股票数据。编写复杂Writable的关键要素是扩展Writable类并定义序列化和反序列化方法,如下所示:^([10])
^([10]) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/StockPriceWritable.java.
列表 3.3. 表示股票价格的Writable实现


现在你有了你的Writable,你需要编写一些代码来创建一个 SequenceFile。你将从本地磁盘读取股票文件,创建StockWritable,并将其写入你的 SequenceFile,使用股票代码作为你的键:^([11])
^([11]) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/seqfile/writable/SequenceFileStockWriter.java.

太好了!现在你该如何阅读使用你的作者创建的文件呢?^([12])
^([11]) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/seqfile/writable/SequenceFileStockReader.java.

现在你需要通过编写和读取文件来证明它的工作:
$ cat test-data/stocks.txt
AAPL,2009-01-02,85.88,91.04,85.16,90.75,26643400,90.75
AAPL,2008-01-02,199.27,200.26,192.55,194.84,38542100,194.84
AAPL,2007-01-03,86.29,86.58,81.90,83.80,44225700,83.80
...
$ hip hip.ch3.seqfile.writable.SequenceFileStockWriter \
--input test-data/stocks.txt \
--output stocks.seqfile
$ hip hip.ch3.seqfile.writable.SequenceFileStockReader \
--input stocks.seqfile
AAPL,StockPriceWritable[symbol=AAPL,date=2009-01-02,open=85.88,...]
AAPL,StockPriceWritable[symbol=AAPL,date=2008-01-02,open=199.27,...]
AAPL,StockPriceWritable[symbol=AAPL,date=2007-01-03,open=86.29,...]
...
你会如何在 MapReduce 中处理这个 SequenceFile?幸运的是,SequenceFileInputFormat和SequenceFileOutputFormat与 MapReduce 很好地集成。记得在本章前面我们讨论过默认的 SequenceFile 序列化支持序列化Writable类吗?因为Writable是 MapReduce 中的原生数据格式,所以使用 SequenceFile 与 MapReduce 集成是完全透明的。看看你是否同意。以下代码展示了具有身份映射器和还原器的 MapReduce 作业:^([13]), ^([14])
^([13]) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/seqfile/writable/SequenceFileStockMapReduce.java.^(14)恒等函数是一个数学术语,用来表示返回其参数相同值的函数。在 MapReduce 中,这意味着相同的意思——map 恒等函数会发出它所提供的所有键/值对,就像 reducer 一样,没有任何转换或过滤。未显式设置 map 或 reduce 类的作业会导致 Hadoop 使用内置的恒等函数。

现在,你可以运行一个身份 MapReduce 作业,针对你在此技术中之前创建的股票 SequenceFile:
$ hip hip.ch3.seqfile.writable.SequenceFileStockMapReduce \
--input stocks.seqfile \
--output output
因为它所做的只是将输入回显到输出,所以你应该在两个文件中看到相同的内容。你可以通过读取作业输出文件来确保这一点。
首先,你如何验证输出是一个 SequenceFile?很简单,只需使用 cat 命令查看——SequenceFile 的前三个字节是 SEQ,然后是一个包含 SequenceFile 版本的第四个字节,接着是键和值类:

看起来不错。现在尝试使用你之前编写的 SequenceFile 读取器代码将其转储到标准输出:
$ hip hip.ch3.seqfile.writable.SequenceFileStockReader \
--input output/part-r-00000
AAPL,StockPriceWritable[symbol=AAPL,date=2008-01-02,open=199.27,...]
AAPL,StockPriceWritable[symbol=AAPL,date=2007-01-03,open=86.29,...]
AAPL,StockPriceWritable[symbol=AAPL,date=2009-01-02,open=85.88,...]
...
这很简单。因为 SequenceFiles 是基于键/值的,并且 SequenceFiles 的默认序列化数据格式是 Writable,所以 SequenceFiles 的使用对你的 map 和 reduce 类是完全透明的。我们通过使用 Map-Reduce 内置的恒等 map 和 reduce 类,并将 SequenceFile 作为输入来演示这一点。你唯一需要做的工作是告诉 MapReduce 使用 SequenceFile 特定的输入和输出格式类,这些类是内置在 MapReduce 中的。
在 Pig 中读取 SequenceFiles
通过编写自己的 Writable,你在 Pig 等非 MapReduce 工具上为自己增加了更多的工作。Pig 与 Hadoop 内置的标量 Writable,如 Text 和 IntWritable,配合得很好,但它不支持自定义 Writable。你需要编写自己的 LoadFunc 来支持 StockPriceWritable。这将很好地与 MapReduce 一起工作,但 Pig 的 SequenceFileLoader 不会与你的自定义 Writable 一起工作,这意味着你需要编写自己的 Pig 加载器来处理你的文件。附录中包含了安装 Pig 的详细信息。
Pig 的 LoadFunc 很简单,如下所示。([15])
^(15)GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/seqfile/writable/SequenceFileStockLoader.java。
列表 3.4. 将 StockPriceWritable 转换为 Pig 元组的 Pig 加载器函数
public class SequenceFileStockLoader extends FileInputLoadFunc {
private SequenceFileRecordReader<Text, StockPriceWritable> reader;
@Override
public Tuple getNext() throws IOException {
boolean next;
try {
next = reader.nextKeyValue();
} catch (InterruptedException e) {
throw new IOException(e);
}
if (!next) return null;
Object value = reader.getCurrentValue();
if (value == null) {
return null;
}
if (!(value instanceof StockPriceWritable)) {
return null;
}
StockPriceWritable w = (StockPriceWritable) value;
return TupleFactory.getInstance().newTuple(Arrays.asList(
w.getSymbol(), w.getDate(), w.getOpen(),
w.getHigh(), w.getLow(), w.getClose(),
w.getVolume(), w.getAdjClose()
));
}
@SuppressWarnings("unchecked")
@Override
public InputFormat getInputFormat() throws IOException {
return new SequenceFileInputFormat<Text, StockPriceWritable>();
}
@SuppressWarnings("unchecked")
@Override
public void prepareToRead(RecordReader reader, PigSplit split)
throws IOException {
this.reader = (SequenceFileRecordReader) reader;
}
@Override
public void setLocation(String location, Job job)
throws IOException {
FileInputFormat.setInputPaths(job, location);
}
}
现在,你可以在 Pig 中尝试加载和转储股票 SequenceFile:
$ pig
grunt> REGISTER $HIP_HOME/*.jar;
grunt> REGISTER $HIP_HOME/lib/*.jar;
grunt> DEFINE SequenceFileStockLoader
hip.ch3.seqfile.writable.SequenceFileStockLoader();
grunt> stocks = LOAD 'stocks.seqfile' USING SequenceFileStockLoader;
grunt> dump stocks;
(AAPL,2009-01-02,85.88,91.04,85.16,90.75,26643400,90.75)
(AAPL,2008-01-02,199.27,200.26,192.55,194.84,38542100,194.84)
(AAPL,2007-01-03,86.29,86.58,81.9,83.8,44225700,83.8)
(AAPL,2006-01-03,72.38,74.75,72.25,74.75,28829800,74.75)
(AAPL,2005-01-03,64.78,65.11,62.6,63.29,24714000,31.65)
...
Hive
Hive 包含对 SequenceFiles 的内置支持,但它有两个限制。首先,它忽略了每个记录的关键部分。其次,它默认只与 Writable 类型的 SequenceFile 值一起工作,并通过执行 toString() 将值转换为 Text 形式来支持它们。
在我们的例子中,你有一个自定义的 Writable,因此你必须编写一个 Hive SerDe,将你的 Writable 反序列化为 Hive 能够理解的形式。结果的数据定义语言(DDL)语句如下:^(16)
^(16)
StockWritableSerDe的代码在 GitHub 上,github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/StockWritableSerDe.java。
$ export HADOOP_CLASSPATH=$HIP_HOME/hip-<version>.jar
$ hive
hive> CREATE TABLE stocks (
symbol string,
dates string,
open double,
high double,
low double,
close double,
volume int,
adjClose double
)
ROW FORMAT SERDE 'hip.ch3.StockWritableSerDe'
STORED AS SEQUENCEFILE;
hive> LOAD DATA INPATH 'stocks.seqfile' INTO TABLE stocks;
hive> select * from stocks;
AAPL 2009-01-02 85.88 91.04 85.16 90.75 26643400 90.75
AAPL 2008-01-02 199.27 200.26 192.55 194.84 38542100 194.84
AAPL 2007-01-03 86.29 86.58 81.9 83.8 44225700 83.8
AAPL 2006-01-03 72.38 74.75 72.25 74.75 28829800 74.75
AAPL 2005-01-03 64.78 65.11 62.6 63.29 24714000 31.65
...
我们将在第九章中更详细地介绍自定义 Hive SerDe 示例。章节 9。
摘要
SequenceFiles 有用,因为它们解决了使用 MapReduce 时遇到的两个问题:它们是本地可分割的,并且它们还内置了对压缩的支持,这使得对用户来说是透明的。它们也适用于作为其他文件格式的容器,这些文件格式与 MapReduce 的集成不是很好。SequenceFiles 的痛点是它们缺乏多语言支持,这限制了可以与你的数据交互的工具范围。但如果你大部分数据都留在 HDFS 中,并且使用 MapReduce(或 Hive/Pig)进行处理,SequenceFiles 可能正是你所需要的。
对于 SequenceFiles 来说,另一个挑战是它们在与 Writables 一起工作时缺乏模式演变——除非你在实现中构建它,否则对 Writable 的更改不会向前或向后兼容。这可以通过使用 Protocol Buffers 作为你的键/值类型来解决。
这种技术探讨了如何使用 SequenceFiles 与 Writables,SequenceFile 知道如何在文件格式内对其进行编码和解码。那么,让 SequenceFiles 与除 Writables 之外的数据一起工作呢?
技巧 11 使用 SequenceFiles 编码 Protocol Buffers
Writables 是 SequenceFiles 的一等公民,APIs 有特定的方法来读取和写入 Writable 实例,这在之前的技巧中已经看到。这并不意味着 SequenceFiles 限于与 Writables 一起工作——实际上,只要你的数据类型有适合插入 Hadoop 序列化框架的序列化实现,它们就可以与任何数据类型一起工作。
Protocol Buffers 是一种复杂的数据格式,由 Google 开源;它提供了模式演变和高效的数据编码能力。(关于 Protocol Buffers 的更多细节请参阅第 3.3.3 节)。在本技巧中,你将实现 Protocol Buffers 序列化,并了解它如何允许你在 MapReduce 中使用本地的 Protocol Buffers 对象。
问题
你想在 MapReduce 中处理 Protocol Buffers 数据。
解决方案
编写一个 Protocol Buffers 序列化器,它使你能够在 SequenceFiles 中编码 Protocol Buffers 序列化数据。
讨论
Hadoop 使用自己的序列化框架来序列化和反序列化数据,以提高性能。这个框架的一个例子是在洗牌阶段将映射输出写入磁盘。所有映射输出都必须有一个相应的 Hadoop 序列化类,该类知道如何将数据读取和写入流。Writable,是 MapReduce 中最常用的数据类型,有一个 WritableSerialization 类,它使用 Writable 接口上的 readFields 和 writeFields 方法来执行序列化。
SequenceFiles 使用相同的序列化框架在它们的键/值记录中序列化和反序列化数据,这就是为什么 SequenceFiles 可以直接支持 Writable。因此,将数据类型编码到 SequenceFile 中只是编写自己的 Hadoop 序列化实例的问题。
要使 Protocol Buffers 与 SequenceFiles 一起工作,你的第一步是编写自己的序列化类。每个序列化类都必须支持序列化和反序列化,所以让我们从序列化器开始,其任务是向输出流写入记录。
以下代码使用 MessageLite 类作为类型;它是所有生成的 Protocol Buffers 类的超类。MessageLite 接口提供了将 Protocol Buffers 写入输出流和从输入流读取它们的方法,正如你将在以下代码中看到的:^(17)。
^(17) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/seqfile/protobuf/ProtobufSerialization.java。

接下来是反序列化器,其任务是使用输入流填充 Protocol Buffers 对象。与序列化器相比,这里的事情要复杂一些,因为 Protocol Buffers 对象只能通过它们的构建器类来构建:^(18)。
^(18) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/seqfile/protobuf/ProtobufSerialization.java。

现在,你需要配置 Hadoop 的序列化框架以使用你的新序列化器。这是通过将你的新序列化器附加到 io.serializations 属性来完成的。通常,编写一个辅助方法来简化客户端的使用是很好的。以下示例显示了与 Hadoop 2 一起捆绑的标准序列化器,以及你刚刚创建的序列化类。ProtobufSerialization 的源代码在此处未显示,但它只是返回 ProtobufSerializer 和 ProtobufDeserializer 的实例:^(19)。
^([19]) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/seqfile/protobuf/ProtobufSerialization.java.
public static void register(Configuration conf) {
String[] serializations = conf.getStrings("io.serializations");
if (ArrayUtils.isEmpty(serializations)) {
serializations = new String[] {
WritableSerialization.class.getName(),
AvroSpecificSerialization.class.getName(),
AvroReflectSerialization.class.getName()
};
}
serializations = (String[]) ArrayUtils.add(
serializations,
ProtobufSerialization.class.getName()
);
conf.setStrings("io.serializations", serializations);
}
接下来,你需要生成一个新的 Protocol Buffers 编码的 SequenceFile。这里的关键是,在使用 SequenceFile 编写器之前,你调用了register方法(如前所述):^([20])
^([20]) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/seqfile/protobuf/SequenceFileProtobufWriter.java.
Configuration conf = super.getConf();
ProtobufSerialization.register(conf);
SequenceFile.Writer writer =
SequenceFile.createWriter(conf,
SequenceFile.Writer.file(outputPath),
SequenceFile.Writer.keyClass(Text.class),
SequenceFile.Writer.valueClass(Stock.class),
SequenceFile.Writer.compression(
SequenceFile.CompressionType.BLOCK,
new DefaultCodec())
);
Text key = new Text();
for (Stock stock : StockUtils.fromCsvFile(inputFile)) {
key.set(stock.getSymbol());
writer.append(key, stock);
}
接下来是 MapReduce 代码。你新序列化器的优点在于 map 和 reduce 类可以直接与 Protocol Buffers 对象一起工作。再次强调,这里的关键是你正在配置作业以使 Protocol Buffers 序列化器可用。在下面的示例中,你使用一个身份函数来演示当在 SequenceFiles 中编码时,Protocol Buffers 对象可以作为 MapReduce 中的第一类公民使用:^([21])
^([21]) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/seqfile/protobuf/SequenceFileProtobufMapReduce.java.

现在,你可以编写一个包含 Protocol Buffers 值的 SequenceFile,在数据上运行一个身份 MapReduce 作业,然后转储作业输出的内容:
$ hip hip.ch3.seqfile.protobuf.SequenceFileProtobufWriter \
--input test-data/stocks.txt \
--output stocks.pb
$ hip hip.ch3.seqfile.protobuf.SequenceFileProtobufMapReduce \
--input stocks.pb \
--output output
$ hip hip.ch3.seqfile.protobuf.SequenceFileProtobufReader \
--input output/part-r-00000
AAPL,symbol: "AAPL"
date: "2008-01-02"
open: 199.27
...
接下来,我们将探讨将 Protocol Buffers 集成到 MapReduce 中的其他方法。
3.3.3. Protocol Buffers
Google 开发者发明了 Protocol Buffers,以帮助他们以紧凑和高效的方式在多种语言编写的服务之间交换数据。现在,Protocol Buffers 是 Google 的事实上的数据格式——在 Google 中,有超过 48,000 种不同的消息类型定义在超过 12,000 个.proto 文件中.^([22])
^([22]) 从 Google 的 Protocol Buffers 开发者指南中获取的 Protocol Buffers 使用统计信息:
code.google.com/apis/protocolbuffers/docs/overview.html.
自 2008 年以来,有一个工单的目标是在 MapReduce 中添加对 Protocol Buffers 的原生支持.^([23]) 因此,你需要转向在 Hadoop 中处理 Protocol Buffers 的替代方法。之前的技术介绍了一种可以使用的方法,即在 SequenceFiles 中编码 Protocol Buffers。其他选项包括使用 Elephant Bird^([24])或 Avro,它们通过将 Protocol Buffers 包装在其自己的文件格式中来支持 Protocol Buffers。最终,这些都是权宜之计,直到我们在 Hadoop 中得到对 Protocol Buffers 的全面支持。
^(23)参见
issues.apache.org/jira/browse/MAPREDUCE-377。^(24)使用 Elephant Bird 意味着你必须使用 LZOP;表面上,可以推导出他们类的一个版本并移除 LZOP 依赖,但如果你还没有使用 LZOP,那么可能值得在其他地方寻找。
你有几种方式可以在 Hadoop 中使用 Protocol Buffers:
-
你可以在 SequenceFiles 中以二进制形式序列化 Protocol Buffers 对象,就像在先前的技术中展示的那样。
-
Elephant Bird (
github.com/kevinweil/elephant-bird) 是 Twitter 中的一个开源项目,支持他们自己的二进制文件格式中的 Protocol Buffers。 -
Parquet,一种列式文件格式,在第 3.4 节中介绍,支持 Protocol Buffers 对象模型,并允许你有效地将 Protocol Buffers 写入和读取到列式形式。
在这些选项中,Parquet 是推荐与 Protocol Buffers 一起工作的方式——它不仅允许你以原生方式与 Protocol Buffers 一起工作,而且还打开了可以与你的数据一起工作的工具数量(由于 Parquet 的广泛 Hadoop 工具支持)。本章对 Parquet 的介绍包括如何使用 Avro 与 Parquet 一起使用,以及 Parquet 可以以类似的方式支持 Protocol Buffers。
Thrift 是另一种数据格式,与 Protocol Buffers 一样,它没有与 MapReduce 的开箱即用支持。同样,你必须依赖其他工具在 Hadoop 中处理 Thrift 数据,正如你将在下一节中发现的那样。
3.3.4. Thrift
Facebook 创建了 Thrift 来帮助提高数据表示和传输的效率。Facebook 使用 Thrift 用于包括搜索、日志记录和其广告平台在内的多个应用程序。
与 Protocol Buffers 一起工作的相同三个选项也适用于 Thrift,并且再次推荐使用 Parquet 作为文件格式。前往 Parquet 的章节(第 3.4 节),了解更多关于 Parquet 如何与这些不同的数据模型集成的信息。
让我们来看看我们所有选项中最有可能是最强大的数据序列化格式,那就是 Avro。
3.3.5. Avro
Doug Cutting 创建了 Avro,一个数据序列化和 RPC 库,以帮助改善 MapReduce 中的数据交换、互操作性和版本控制。Avro 使用一种紧凑的二进制数据格式——你可以选择压缩——这导致快速序列化时间。尽管它有类似于 Protocol Buffers 的模式概念,但 Avro 在 Protocol Buffers 之上进行了改进,因为它的代码生成是可选的,并且它将模式嵌入到容器文件格式中,允许动态发现和数据交互。Avro 有一种使用通用数据类型(例如,在 第四章 中可以看到的示例)与模式数据一起工作的机制。
Avro 文件格式如图 3.11 所示。模式作为头部的一部分进行序列化,这使得反序列化变得简单,并放宽了对用户必须在外部维护和访问与 Avro 数据文件交互的模式方面的限制。每个数据块包含多个 Avro 记录,默认大小为 16 KB。
图 3.11. Avro 容器文件格式

数据序列化的圣杯支持代码生成、版本控制和压缩,并且与 MapReduce 有高度集成。同样重要的是模式演变,这也是为什么 Hadoop SequenceFiles 不吸引人的原因——它们不支持模式或任何形式的数据演变。
在本节中,你将了解 Avro 的模式和代码生成能力、如何读取和写入 Avro 容器文件,以及 Avro 如何与 MapReduce 集成的各种方式。最后,我们还将探讨 Avro 在 Hive 和 Pig 中的支持。
让我们通过查看 Avro 的模式和代码生成来开始吧。
技巧 12 Avro 的模式和代码生成
Avro 有通用数据和具体数据的概念:
-
通用数据 允许你在不了解模式具体信息的情况下以低级别处理数据。
-
具体数据 允许你使用代码生成的 Avro 原语与 Avro 一起工作,这支持了一种简单且类型安全的处理 Avro 数据的方法。
这种技术探讨了如何在 Avro 中处理具体数据。
问题
你想定义一个 Avro 模式并生成代码,以便在 Java 中处理你的 Avro 记录。
解决方案
以 JSON 格式编写你的模式,然后使用 Avro 工具生成丰富的 API 以与你的数据交互。
讨论
你可以使用两种方式之一使用 Avro:要么使用代码生成的类,要么使用其通用类。在本技术中,我们将使用代码生成的类,但你可以在第四章的第 29 个技巧中看到 Avro 通用记录的使用示例。章节链接.
获取 Avro
附录中包含如何获取 Avro 的说明。
在代码生成方法中,一切从模式开始。第一步是创建一个 Avro 模式来表示股票数据中的一个条目:^([25])
²⁵ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/avro/stock.avsc.
{
"name": "Stock",
"type": "record",
"namespace": "hip.ch3.avro.gen",
"fields": [
{"name": "symbol", "type": "string"},
{"name": "date", "type": "string"},
{"name": "open", "type": "double"},
{"name": "high", "type": "double"},
{"name": "low", "type": "double"},
{"name": "close", "type": "double"},
{"name": "volume", "type": "int"},
{"name": "adjClose", "type": "double"}
]
}
Avro 支持对模式数据和 RPC 消息(本书未涵盖)进行代码生成。要为模式生成 Java 代码,请使用以下 Avro 工具 JAR:

生成的代码将被放入 hip.ch3.avro.gen 包中。现在你已经生成了代码,如何使用它来读取和写入 Avro 容器文件?^([26])
²⁶ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/avro/AvroStockFileWrite.java.
列表 3.5. 从 MapReduce 外部写入 Avro 文件

如你所见,你可以指定用于压缩数据的压缩编解码器。在这个例子中,你使用的是 Snappy,正如第四章中所示,这是读写速度最快的编解码器。
以下代码示例显示了如何从输入文件的一行中序列化一个Stock对象。正如你所见,生成的Stock类是一个 POJO,包含许多 setter(以及相应的 getter):
public static Stock fromCsv(String line) throws IOException {
String parts[] = parser.parseLine(line);
Stock stock = new Stock();
stock.setSymbol(parts[0]);
stock.setDate(parts[1]);
stock.setOpen(Double.valueOf(parts[2]));
stock.setHigh(Double.valueOf(parts[3]));
stock.setLow(Double.valueOf(parts[4]));
stock.setClose(Double.valueOf(parts[5]));
stock.setVolume(Integer.valueOf(parts[6]));
stock.setAdjClose(Double.valueOf(parts[7]));
return stock;
}
现在,关于读取你刚刚写入的文件呢?^([27])
²⁷ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/avro/AvroStockFileRead.java.

继续执行这个写入器和读取器对:

Avro 附带了一些工具,可以轻松检查 Avro 文件的内容。要查看 Avro 文件的内容作为 JSON,只需运行此命令:
$ java -jar $HIP_HOME/lib/avro-tools-1.7.4.jar tojson stocks.avro
{"symbol":"AAPL","date":"2009-01-02","open":85.88,"high":91.04,...
{"symbol":"AAPL","date":"2008-01-02","open":199.27,"high":200.26,...
{"symbol":"AAPL","date":"2007-01-03","open":86.29,"high":86.58,...
...
这假设文件存在于本地文件系统中。同样,你可以使用以下命令获取 Avro 文件的 JSON 表示:
$ java -jar $HIP_HOME/lib/avro-tools-1.7.4.jar getschema stocks.avro
{
"type" : "record",
"name" : "Stock",
"namespace" : "hip.ch3.avro.gen",
"fields" : [ {
"name" : "symbol",
"type" : "string"
}, {
"name" : "date",
"type" : "string"
}, {
"name" : "open",
"type" : "double"
}, {
"name" : "high",
"type" : "double"
}, {
"name" : "low",
"type" : "double"
}, {
"name" : "close",
"type" : "double"
}, {
"name" : "volume",
"type" : "int"
}, {
"name" : "adjClose",
"type" : "double"
} ]
}
你可以不使用任何选项运行 Avro 工具,以查看你可以使用的所有工具:
$ java -jar $HIP_HOME/lib/avro-tools-1.7.4.jar
compile Generates Java code for the given schema.
concat Concatenates avro files without re-compressing.
fragtojson Renders a binary-encoded Avro datum as JSON.
fromjson Reads JSON records and writes an Avro data file.
fromtext Imports a text file into an avro data file.
getmeta Prints out the metadata of an Avro data file.
getschema Prints out schema of an Avro data file.
idl Generates a JSON schema from an Avro IDL file
induce Induce schema/protocol from Java class/interface
via reflection.
jsontofrag Renders a JSON-encoded Avro datum as binary.
recodec Alters the codec of a data file.
rpcprotocol Output the protocol of a RPC service
rpcreceive Opens an RPC Server and listens for one message.
rpcsend Sends a single RPC message.
tether Run a tethered mapreduce job.
tojson Dumps an Avro data file as JSON, one record per line.
totext Converts an Avro data file to a text file.
trevni_meta Dumps a Trevni file's metadata as JSON.
trevni_random Create a Trevni file filled with random instances
of a schema.
trevni_tojson Dumps a Trevni file as JSON.
tojson工具的一个缺点是它不支持在 HDFS 中读取数据。因此,我在本书的代码中捆绑了一个名为 AvroDump 的实用程序,它可以导出 Avro 数据在 HDFS 中的文本表示,我们将很快使用它来检查 Avro MapReduce 作业的输出:
$ hip hip.util.AvroDump --file stocks.avro
此实用程序支持多个文件(它们需要是 CSV 分隔的)和通配符匹配,因此你可以使用通配符。以下示例显示了如何将 MapReduce 作业生成的 Avro 输出内容导出到名为 mr-output-dir 的目录中:
$ hip hip.util.AvroDump --file mr-output-dir/part*
让我们看看 Avro 如何与 MapReduce 集成。
技巧 13 在 MapReduce 中选择适当的 Avro 使用方式
Avro 支持在 MapReduce 中处理你的 Avro 数据的方式不止一种。这项技术列举了你可以使用数据的不同方式,并提供了关于在何种情况下应采用哪种方法的指导。
问题
你想在 MapReduce 作业中使用 Avro,但不确定应该选择哪种可用的集成选项。
解决方案
了解每个集成选项的更多信息,并选择最适合你用例的一个。
讨论
你可以在 MapReduce 中使用 Avro 的三种方式,每种方式的具体使用方法将在接下来的技巧中讨论。这些是三种方法:
-
混合模式 —当你想在作业中混合 Avro 数据和非 Avro 数据时适用
-
基于记录的 —当数据以非键/值方式提供时很有用
-
基于键/值 — 当你的数据必须符合特定模型时
让我们更详细地介绍每种方法。
混合模式
这种用例适用于以下任一条件成立的情况:
-
你的 mapper 输入数据不是 Avro 格式。
-
你不希望在 mapper 和 reducer 之间使用 Avro 发射中间数据。
-
你的作业输出数据不是 Avro 格式。
在任何这些情况下,Avro mapper 和 reducer 类都不会帮助你,因为它们的设计假设 Avro 数据在 MapReduce 作业中端到端流动。在这种情况下,你将想要使用常规的 MapReduce mapper 和 reducer 类,并以一种允许你仍然处理 Avro 数据的方式构建你的作业。
基于记录
Avro 数据是基于记录的,与基于键/值(key/value)的 MapReduce 相比,这会导致阻抗不匹配。为了支持 Avro 的基于记录的根源,Avro 随带了一个 mapper 类,它不是基于键/值的,而是只向派生类提供一个记录。
基于键/值
如果你的 Avro 数据内部遵循键/值结构,你可以使用一些 Avro 提供的 mapper 类,这些类将转换你的 Avro 记录,并以键/值形式将它们提供给 mapper。使用这种方法,你将限于具有“键”和“值”元素的架构。
摘要
选择与 Avro 集成的正确级别取决于你的输入和输出,以及你如何在 Avro 内部处理数据。这项技术探讨了三种与 Avro 集成的途径,以便你可以为你的用例选择正确的方法。在以下技术中,我们将探讨如何在 MapReduce 作业中使用这些集成方法。
技术第十四章:在 MapReduce 中混合 Avro 和非 Avro 数据
在 MapReduce 中,这种级别的 Avro 集成适用于你有非 Avro 输入并生成 Avro 输出,或反之亦然的情况,在这种情况下,Avro mapper 和 reducer 类不适用。在这项技术中,我们将探讨如何以混合模式与 Avro 一起工作。
问题
你想在 MapReduce 作业中使用 Avro 的混合模式,而 Avro 随带的 mapper 和 reducer 类不支持这种模式。
解决方案
使用低级方法设置你的作业,并使用常规 Hadoop mapper 和 reducer 类通过 Map-Reduce 作业驱动 Avro 数据。
讨论
Avro 随带了一些 mapper 和 reducer 类,你可以对它们进行子类化以处理 Avro。在你想让你的 mapper 和 reducer 交换 Avro 对象的情况下,它们很有用。但是,如果你没有在 map 和 reduce 任务之间传递 Avro 对象的要求,你最好直接使用 Avro 输入和输出格式类,正如你将在以下代码中看到的那样,它产生所有开盘价值的平均值。
我们首先来看一下作业配置。你的任务是消费股票数据并生成股票平均值,这些数据都将以 Avro 格式输出.^([28]) 要完成这个任务,你需要设置包含两个模式信息的作业配置。你还需要指定 Avro 的输入和输出格式类:^([29])
²⁸ 尽管这项技术是关于在你的作业中将 Avro 和非 Avro 数据混合在一起,但我展示了在整个作业中使用 Avro,这样你可以选择你希望集成到作业中的哪个方面。例如,如果你有文本输入和 Avro 输出,你会使用一个常规的
TextInputFormat,并设置 Avro 输出格式。²⁹ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/avro/AvroMixedMapReduce.java.

接下来是Map类。整个 Avro 记录作为输入键提供给你的映射函数,因为 Avro 支持记录,而不是键/值对(尽管,如你稍后所见,Avro 确实有一种方式可以通过键/值对提供数据给你的映射函数,如果你的 Avro 模式中有名为key和value的字段)。从实现的角度来看,你的map函数从股票记录中提取必要的字段并将它们以股票符号和开盘价作为键/值对的形式输出到 reducer:^([30])
³⁰ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/avro/AvroMixedMapReduce.java.

为什么使用“旧”的 MapReduce API?
你可能已经注意到,这个技术示例使用了较旧的org.apache.hadoop.mapred API。这是因为在这个技术中使用的AvroInputFormat和AvroOutputFormat类只支持旧 API。
最后,reduce 函数将每个股票的所有股票价格加在一起并输出平均价格:^([31])
³¹ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/avro/AvroMixedMapReduce.java.

你可以按照以下方式运行 MapReduce 代码:
$ hip hip.ch3.avro.AvroMixedMapReduce \
--input stocks.avro \
--output output
你的 MapReduce 作业正在输出与作业输入不同的 Avro 对象(StockAvg)。你可以通过编写一些代码(未列出)来转储你的 Avro 对象来验证作业是否生成了你预期的输出:
$ hip hip.util.AvroDump --file output/part*
{"symbol": "AAPL", "avg": 68.631}
{"symbol": "CSCO", "avg": 31.147999999999996}
{"symbol": "GOOG", "avg": 417.47799999999995}
{"symbol": "MSFT", "avg": 44.63100000000001}
{"symbol": "YHOO", "avg": 69.333}
摘要
这种技术在以下情况下很有用:你不想在 Avro 格式中保留中间映射输出,或者你有非 Avro 输入或输出。接下来,我们将探讨在 MapReduce 中使用 Avro 原生方式处理数据。
技巧 15 在 MapReduce 中使用 Avro 记录
与 SequenceFile 不同,Avro 不是一个本地的键/值序列化格式,因此它可能需要一些调整才能与 MapReduce 一起工作。在这个技术中,你将检查特定的 Avro mapper 和 reducer 类,这些类提供了一个基于记录的接口,你可以用它来输入和输出数据。
问题
你想在 MapReduce 作业中从头到尾使用 Avro,并且你还希望以记录形式与你的输入和输出数据交互。
解决方案
扩展AvroMapper和AvroReducer类以实现你的 MapReduce 作业。
讨论
Avro 提供了两个类,它们抽象了 MapReduce 的键/值特性,而是暴露了一个基于记录的 API。在这个技术中,你将实现与先前技术相同的 MapReduce 作业(计算每个股票代码的平均开盘价),并在整个作业中使用 Avro。
首先,让我们看看Mapper类,它将扩展AvroMapper:^([32])
³² GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/avro/AvroRecordMapReduce.java.

首先,要注意的是,在类定义中定义了两种类型,而不是 MapReduce 中常见的四种。AvroMapper抽象了 mapper 输入和输出的键/值特性,用单一类型替换了每个特性。
如果你有一个只映射的作业,你将定义的类型将是输入和输出类型。但是,如果你正在运行一个完整的 MapReduce 作业,你需要使用Pair类,这样你就可以定义映射输出键/值对。Pair类要求键和值部分存在 Avro 模式,这就是为什么使用Utf8类而不是直接的 Java 字符串。
现在让我们看看 AvroReducer 的实现。这次你需要定义三种类型——映射输出键和值类型,以及减少输出类型:^([33])
³³ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/avro/AvroRecordMapReduce.java.

现在你可以一起在驱动程序中配置它。在这里,你将定义输入和输出类型以及所需的输出压缩(如果有):^([34])
³⁴ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/avro/AvroRecordMapReduce.java.
AvroJob.setInputSchema(job, Stock.SCHEMA$);
AvroJob.setMapOutputSchema(job,
Pair.getPairSchema(Schema.create(Schema.Type.STRING), Stock.SCHEMA$));
AvroJob.setOutputSchema(job, StockAvg.SCHEMA$);
AvroJob.setMapperClass(job, Mapper.class);
AvroJob.setReducerClass(job, Reducer.class);
FileOutputFormat.setCompressOutput(job, true);
AvroJob.setOutputCodec(job, SNAPPY_CODEC);
完成!试一试,并在作业完成后检查输出:
$ hip hip.ch3.avro.AvroRecordMapReduce \
--input stocks.avro \
--output output
...
$ hip hip.util.AvroDump --file output/part*
{"symbol": "AAPL", "avg": 68.631}
{"symbol": "CSCO", "avg": 31.147999999999996}
{"symbol": "GOOG", "avg": 417.47799999999995}
{"symbol": "MSFT", "avg": 44.63100000000001}
{"symbol": "YHOO", "avg": 69.333}
摘要
这种技术在你想在整个 MapReduce 作业中保持数据以 Avro 格式,并且没有要求你的输入或输出数据基于键/值的情况下非常有用。
但如果你确实需要基于键/值的数据,并且仍然想使用 Avro 的优点,如紧凑的序列化大小和内置压缩呢?
技巧 16 在 MapReduce 中使用 Avro 键/值对
MapReduce 的原生数据模型是键/值对,正如我之前提到的,Avro 的是基于记录的。Avro 没有原生支持键/值数据,但 Avro 中存在一些辅助类来帮助建模键/值数据,并在 MapReduce 中原生使用。
问题
你希望使用 Avro 作为数据格式和容器,但你想在 Avro 中使用键/值对来建模你的数据,并将它们用作 MapReduce 中的原生键/值对。
解决方案
使用 AvroKeyValue、AvroKey 和 AvroValue 类来处理 Avro 键/值数据。
讨论
Avro 有一个 AvroKeyValue 类,它封装了一个包含两个名为 key 和 value 的记录的泛型 Avro 记录。AvroKeyValue 作为一个辅助类,使你能够轻松地读取和写入键/值数据。这些记录的类型由你定义。
在这个技巧中,你将重复平均股票 MapReduce 作业,但这次使用 Avro 的键/值框架。你首先需要为你的作业生成输入数据。在这种情况下,我们将股票代码放在键中,将 Stock 对象放在值中:^([35])
^(35) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/avro/AvroKeyValueFileWrite.java。

继续在 HDFS 中生成一个包含股票数据的键/值格式文件:
$ hip hip.ch3.avro.AvroKeyValueFileWrite \
--input test-data/stocks.txt \
--output stocks.kv.avro
如果你好奇你刚刚生成的文件的 Avro 模式定义,请使用技巧 12 中突出显示的提示从文件中提取模式。此外,你可以使用 AvroDump 实用程序来显示文件的内容:
# the "getschema" tool only works with data in the local filesystem,
# so first copy the stocks file from HDFS to local disk
$ hadoop fs -get stocks.kv.avro .
$ java -jar $HIP_HOME/lib/avro-tools-1.7.4.jar getschema stocks.kv.avro
{
"type" : "record",
"name" : "KeyValuePair",
"namespace" : "org.apache.avro.mapreduce",
"doc" : "A key/value pair",
"fields" : [ {
"name" : "key",
"type" : "string",
"doc" : "The key"
}, {
"name" : "value",
"type" : {
"type" : "record",
"name" : "Stock",
"namespace" : "hip.ch3.avro.gen",
"fields" : [ {
"name" : "symbol",
"type" : "string"
}, {
"name" : "date",
"type" : "string"
}, {
"name" : "open",
"type" : "double"
}, {
"name" : "high",
"type" : "double"
}, {
"name" : "low",
"type" : "double"
}, {
"name" : "close",
"type" : "double"
}, {
"name" : "volume",
"type" : "int"
}, {
"name" : "adjClose",
"type" : "double"
} ]
},
"doc" : "The value"
} ]
}
$ hip hip.util.AvroDump --file stocks.kv.avro
{"key": "AAPL", "value": {"symbol": "AAPL", "date": "2009-01-02", ...
{"key": "AAPL", "value": {"symbol": "AAPL", "date": "2008-01-02", ...
{"key": "AAPL", "value": {"symbol": "AAPL", "date": "2007-01-03", ...
现在是时候展示一些 MapReduce 代码了——你将一次性定义你的映射器、归约器和驱动程序:^([36])
^(36) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/avro/AvroKeyValueMapReduce.java。
public int run(final String[] args) throws Exception {
....
job.setInputFormatClass(AvroKeyValueInputFormat.class);
AvroJob.setInputKeySchema(job, Schema.create(Schema.Type.STRING));
AvroJob.setInputValueSchema(job, Stock.SCHEMA$);
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(DoubleWritable.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(AvroValue.class);
job.setOutputFormatClass(AvroKeyValueOutputFormat.class);
AvroJob.setOutputValueSchema(job, StockAvg.SCHEMA$);
...
}
public static class Map extends
Mapper<AvroKey<CharSequence>, AvroValue<Stock>,
Text, DoubleWritable> {
@Override
public void map(AvroKey<CharSequence> key,
AvroValue<Stock> value,
Context context) {
context.write(new Text(key.toString()),
new DoubleWritable(value.datum().getOpen()));
}
}
public static class Reduce extends
Reducer<Text, DoubleWritable, Text, AvroValue<StockAvg>> {
@Override
protected void reduce(Text key,
Iterable<DoubleWritable> values,
Context context) {
double total = 0.0;
double count = 0;
for (DoubleWritable val: values) {
total += val.get();
count++;
}
StockAvg avg = new StockAvg();
avg.setSymbol(key.toString());
avg.setAvg(total / count);
context.write(key, new AvroValue<StockAvg>(avg));
}
}
如您所见,AvroKey 和 AvroValue 包装器用于在映射器中提供输入数据,以及在归约器中的输出数据。这里很酷的一点是,Avro 足够智能,可以支持 Hadoop Writable 对象,并自动将它们转换为它们的 Avro 对应物,这就是为什么你不需要告诉 Avro 输出键的模式类型。
你可以使用以下命令运行 MapReduce 作业:
$ hip hip.ch3.avro.AvroKeyValueMapReduce \
--input stocks.kv.avro \
--output output
再次,你可以使用 AvroDump 工具查看输出:
$ hip hip.util.AvroDump --file output/part*
{"key": "AAPL", "value": {"symbol": "AAPL", "avg": 68.631}}
{"key": "CSCO", "value": {"symbol": "CSCO", "avg": 31.148}}
{"key": "GOOG", "value": {"symbol": "GOOG", "avg": 417.478}}
{"key": "MSFT", "value": {"symbol": "MSFT", "avg": 44.631}}
{"key": "YHOO", "value": {"symbol": "YHOO", "avg": 69.333}}
摘要
这就结束了我们对在 MapReduce 中使用 Avro 的三种方法的介绍。每种方法都适合特定的任务,你可以选择最适合你需求的任何一种。
让我们总结一下关于 Avro 和 MapReduce 的内容,看看如何在 MapReduce 中自定义 Avro 数据的排序特性。
技巧 17 控制在 MapReduce 中排序的工作方式
如果你决定使用 Avro 数据作为中间映射输出,你可能想知道你如何控制分区、排序和分组的工作方式。
问题
你希望控制 MapReduce 对你的 reducer 输入的排序方式。
解决方案
修改 Avro 架构以改变排序行为。
讨论
如果一个 Avro 对象被用作 mapper 的键输出,默认情况下会发生以下情况:
-
Avro 对象中的所有字段都用于分区、排序和分组。
-
字段将按照它们在架构中的序号位置进行排序。这意味着如果你有一个包含两个元素的架构,架构中的第一个元素将首先用于排序,然后是第二个元素。
-
在一个元素内部,排序是通过特定于该类型的比较来进行的。因此,如果正在比较字符串,排序将是字典序的,如果正在比较数字,则使用数值比较。
一些这种行为是可以改变的。以下是对 Stock 架构的修改版本:
{
"name": "Stock",
"type": "record",
"namespace": "hip.ch3.avro.gen",
"fields": [
{"name": "symbol", "type": "string"},
{"name": "date", "type": "string"},
{"name": "open", "type": "double", "order": "descending"},
{"name": "high", "type": "double", "order": "ignore"}
]
}
你可以通过添加一个 order 属性并指定使用降序来修改字段的排序行为。或者,你可以通过将顺序设置为 ignore 来排除字段进行分区、排序和分组。
注意,这些是架构级别的设置,没有简单的方法可以指定每个作业的定制分区/排序/分组设置。你可以继续编写自己的分区、排序和分组函数(就像为 Writable 做的那样),但如果 Avro 有助于简化此过程的辅助函数将会很有用。
技巧 18 Avro 和 Hive
直到最近,Hive 项目才内置了对 Avro 的支持。这项技术探讨了如何在 Hive 中处理 Avro 数据。
问题
你想在 Hive 中处理 Avro 数据。
解决方案
使用 Hive 的 Avro 序列化/反序列化器。
讨论
Hive 版本 0.9.1 及更高版本附带了一个 Avro SerDe(序列化/反序列化器),这使得 Hive 能够从表中读取数据并将其写回到表中。附录中包含了如何安装 Hive 的说明。
你需要将本书附带捆绑的 Avro 架构复制到 HDFS 中,并创建一个包含一些示例 Avro 股票记录的目录:
$ hadoop fs -put $HIP_HOME/schema schema
$ hadoop fs -mkdir stock_hive
$ hip hip.ch3.avro.AvroStockFileWrite \
--input test-data/stocks.txt \
--output stock_hive/stocks.avro
接下来,启动 Hive 控制台并为刚刚创建的目录创建一个外部 Hive 表。你还需要指定 HDFS 中 Avro 架构的位置。将 YOUR-HDFS-USERNAME 替换为你的 HDFS 用户名:
hive> CREATE EXTERNAL TABLE stocks
COMMENT "An Avro stocks table"
ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.avro.AvroSerDe'
STORED AS
INPUTFORMAT
'org.apache.hadoop.hive.ql.io.avro.AvroContainerInputFormat'
OUTPUTFORMAT
'org.apache.hadoop.hive.ql.io.avro.AvroContainerOutputFormat'
LOCATION '/user/YOUR-HDFS-USERNAME/stock_hive/'
TBLPROPERTIES (
'avro.schema.url'='hdfs:///user/YOUR-HDFS-USERNAME/schema/stock.avsc'
);
AvroSerDe 实际上支持三种定义 Avro 表模式的方法——对于这个技巧,我选择了你可能在生产中最可能想要使用的方法,但有关指定模式的其他方式的更多详细信息,请参阅 AvroSerDe 网站:cwiki.apache.org/confluence/display/Hive/AvroSerDe.
就像任何 Hive 表一样,你可以查询 Hive 来描述表的模式:
hive> describe stocks;
symbol string
date string
open double
high double
low double
close double
volume int
adjclose double
运行一个查询以验证一切是否正常工作。以下 Hive 查询语言(HiveQL)将计算每个股票符号的股票记录数:
hive> SELECT symbol, count(*) FROM stocks GROUP BY symbol;
AAPL 10
CSCO 10
GOOG 5
MSFT 10
YHOO 10
如果你想将数据写入由 Avro 支持的 Hive 表中?以下示例展示了如何复制股票表中的记录子集并将其插入到新表中。此示例还突出了如何使用 Snappy 压缩编解码器对写入新表中的任何数据进行压缩:
hive> SET hive.exec.compress.output=true;
hive> SET avro.output.codec=snappy;
hive> CREATE TABLE google_stocks
COMMENT "An Avro stocks table containing just Google stocks"
ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.avro.AvroSerDe'
STORED AS
INPUTFORMAT
'org.apache.hadoop.hive.ql.io.avro.AvroContainerInputFormat'
OUTPUTFORMAT
'org.apache.hadoop.hive.ql.io.avro.AvroContainerOutputFormat'
TBLPROPERTIES (
'avro.schema.url'='hdfs:///user/YOUR-USERNAME/schema/stock.avsc'
);
OK
hive> INSERT OVERWRITE TABLE google_stocks
SELECT * FROM stocks WHERE symbol = 'GOOG';
OK
hive> select * from google_stocks limit 5;
OK
GOOG 2009-01-02 308.6 321.82 305.5 321.32 3610500 321.32
GOOG 2008-01-02 692.87 697.37 677.73 685.19 4306900 685.19
GOOG 2007-01-03 466.0 476.66 461.11 467.59 7706500 467.59
GOOG 2006-01-03 422.52 435.67 418.22 435.23 13121200 435.23
GOOG 2005-01-03 197.4 203.64 195.46 202.71 15844200 202.71
关于 Hive 的更多详细信息,请参阅第九章。接下来,我们将看看如何在 Pig 中执行相同的操作序列。
技巧 19 Avro 和 Pig
就像 Hive 一样,Pig 也内置了对 Avro 的支持,这在本技巧中有所介绍。
问题
你想使用 Pig 读写 Avro 数据。
解决方案
在 Pig 的 Piggy Bank 库中使用 AvroStorage 类。
讨论
Piggy Bank 是一个包含有用 Pig 工具集合的库,其中之一是你可以用来在 HDFS 中读写 Avro 数据的 AvroStorage 类。在这个技巧中,你将复制之前在 Hive 技巧中采取的步骤——你将读取一些股票数据,执行一些简单的聚合,并将一些过滤后的数据存储回 HDFS。
在开始之前,将一些 Avro 股票数据加载到 HDFS 中的一个目录中:
$ hadoop fs -put $HIP_HOME/schema schema
$ hadoop fs -mkdir stock_pig
$ hip hip.ch3.avro.AvroStockFileWrite \
--input test-data/stocks.txt \
--output stock_pig/stocks.avro
在 Pig-land 中,你的第一步是注册 AvroStorage 工作所需的 JAR 文件。你可能需要找到你使用的 Hadoop 分发版中捆绑的 JAR 文件的特定位置。以下代码中的位置假设 Apache Hadoop 和 Pig 安装在 /usr/local 下:
$ pig
REGISTER /usr/local/pig/contrib/piggybank/java/piggybank.jar;
REGISTER /usr/local/hadoop/share/hadoop/common/lib/avro-*.jar;
REGISTER /usr/local/hadoop/share/hadoop/common/lib/jackson-*.jar;
REGISTER /usr/local/hadoop/share/hadoop/common/lib/snappy-*.jar;
REGISTER /usr/local/hadoop/share/hadoop/httpfs/tomcat/webapps/
webhdfs/WEB-INF/lib/json-*.jar;
接下来,将股票加载到 Pig 关系中,然后使用 LOAD 和 DESCRIBE 操作符显示模式细节:
grunt> stocks = LOAD 'stock_pig/' USING
org.apache.pig.piggybank.storage.avro.AvroStorage();
grunt> DESCRIBE stocks;
records: {symbol: chararray,date: chararray,open: double,
high: double,low: double,close: double,volume: int,
adjClose: double}
注意,你不必提供关于 Avro 模式的详细信息。这是因为你使用的 Avro 容器格式在头部中嵌入了模式。如果你的文件没有嵌入模式,AvroStorage 仍然可以支持你的数据,但你需要将 Avro 模式上传到 HDFS(就像你在 Hive 中做的那样)并使用“schema_file”选项——查看 Pig 文档以获取更多详细信息.^([37])
^(37)更多 Avro 和 Pig 集成细节可以在
AvroStorage页面上找到:cwiki.apache.org/confluence/display/PIG/AvroStorage.
为了验证 Avro 和 Pig 是否协同工作,你可以执行一个简单的聚合操作,并计算每个股票符号的股票记录数:
grunt> by_symbol = GROUP stocks BY symbol;
grunt> symbol_count = foreach by_symbol generate group, COUNT($1);
grunt> dump symbol_count;
(AAPL,10)
(CSCO,10)
(GOOG,5)
(MSFT,10)
(YHOO,10)
以下示例展示了您如何在 Pig 中写出 Avro 数据。该示例从输入数据中过滤出谷歌股票,并将它们写入 HDFS 中的新输出目录。这也展示了您如何使用 Snappy 压缩作业输出:
grunt> SET mapred.compress.map.output true;
grunt> SET mapred.output.compress true;
grunt> SET mapred.output.compression.codec
org.apache.hadoop.io.compress.SnappyCodec
grunt> SET avro.output.codec snappy;
grunt> google_stocks = FILTER stocks BY symbol == 'GOOG';
grunt> STORE google_stocks INTO 'stock_pig_output/'
USING org.apache.pig.piggybank.storage.avro.AvroStorage(
'no_schema_check',
'data', 'stock_pig/');
当将 Avro 数据写入 HDFS 时,您需要指定您要持久化的数据的 Avro 模式。前面的示例使用 data 选项告诉 AvroStorage 使用输入目录下文件中嵌入的 Avro 模式。
与加载文件一样,有各种其他方法可以告诉 AvroStorage 您的模式细节,这些在 Pig 的维基百科上有记录。^(38)
^(38)有关 AvroStorage 的附加资源请参阅
cwiki.apache.org/confluence/display/PIG/AvroStorage。
摘要
最后几种技术展示了如何轻松且直接地使用 Avro 与 MapReduce、Hive 和 Pig 一起使用。使用 Avro 存储您的数据为您提供了许多有用的免费功能,例如版本支持、压缩、可分割性和代码生成。Avro 与 MapReduce、Hive、Pig 以及众多其他工具(如 Impala 和 Flume)的强大集成意味着它值得考虑作为您首选的数据格式。
到目前为止,我们一直专注于基于行的文件格式,这并不总是布局数据的最佳方式。在下一节中,您将了解列式存储的优势,并看到 Parquet(一种列式存储)的实际应用示例。
3.4. 列式存储
当数据写入 I/O 设备(例如平面文件或关系型数据库中的表)时,最常见的数据布局方式是基于行的,这意味着首先写入第一行的所有字段,然后是第二行的所有字段,依此类推。这是大多数关系型数据库默认写入表的方式,对于大多数数据序列化格式(如 XML、JSON 和 Avro 容器文件)也是如此。
列式存储的工作方式不同——它首先按列布局数据,然后按行。首先写入所有记录的第一个字段的值,然后是第二个字段,依此类推。图 3.12 强调了两种存储方案在数据布局方面的差异。
图 3.12. 行和列存储系统如何布局其数据

存储数据在列式形式中有两个主要好处:
-
读取列式数据的系统可以有效地提取列的子集,从而减少 I/O。基于行的系统通常需要读取整个行,即使只需要一两个列也是如此。
-
在编写列式数据时可以进行优化,例如运行长度编码和位打包,以有效地压缩正在写入的数据的大小。通用的压缩方案也适用于压缩列式数据,因为压缩在具有大量重复数据的数据上效果最佳,而当列式数据在物理上集中时,这种情况就会发生。
因此,在处理大型数据集时,当您希望过滤或投影数据时,列式文件格式工作得最好,这正是 OLAP 类用例以及 MapReduce 常见的工作类型。
Hadoop 中使用的多数数据格式,如 JSON 和 Avro,都是行顺序的,这意味着在读取和写入这些文件时无法应用之前提到的优化。想象一下,图 3.12 中的数据在一个 Hive 表中,您要执行以下查询:
SELECT AVG(price) FROM stocks;
如果数据以行格式排列,则必须读取每一行,尽管只操作的是 price 列。在列式存储中,只需读取 price 列,这在处理大型数据集时可能会显著减少处理时间。
在 Hadoop 中可以使用多种列式存储选项:
-
RCFile 是 Hadoop 中第一个可用的列式格式;它源于 2009 年 Facebook 与学术界之间的合作。39 RCFile 是一种基本的列式存储,支持单独的列存储和列压缩。它可以在读取时支持投影,但缺少了诸如运行长度编码等更高级的技术。因此,Facebook 已经开始从 RCFile 转向 ORC 文件。40
(39) Yongqiang He, et al., “RCFile: A Fast and Space-efficient Data Placement Structure in MapReduce-based Warehouse Systems,” ICDE Conference 2011: www.cse.ohio-state.edu/hpcs/WWW/HTML/publications/papers/TR-11-4.pdf.
(40) Facebook 工程师博客, “Scaling the Facebook data warehouse to 300 PB,”
code.facebook.com/posts/229861827208629/scaling-the-facebook-data-warehouse-to-300-pb/. -
ORC 文件 由 Facebook 和 Hortonworks 创建,旨在解决 RCFile 的不足,其序列化优化与 RCFile 相比产生了更小的数据大小。41 它还使用索引来启用谓词下推以优化查询,这样就可以跳过不符合过滤谓词的列。ORC 文件也与 Hive 的类型系统完全集成,并可以支持嵌套结构。
(41) Owen O’Malley, “ORC File Introduction,” www.slideshare.net/oom65/orc-fileintro.
-
Parquet 是 Twitter 和 Cloudera 的合作成果,并采用了 ORC 文件用于生成压缩文件所使用的一些技巧。42 Parquet 是一种与语言无关的格式,具有正式的规范。
(42) 列统计和索引等特性计划在 Parquet 2 版本中推出。
RCFile 和 ORC 文件被设计为支持 Hive 作为它们的主要用途,而 Parquet 则独立于任何其他 Hadoop 工具,并试图最大化与 Hadoop 生态系统的兼容性。表 3.2 展示了这些列式格式如何与各种工具和语言集成。
表 3.2. Hadoop 支持的列式存储格式
| 格式 | Hadoop 支持 | 支持的对象模型 | 支持的编程语言 | 高级压缩支持 |
|---|---|---|---|---|
| RCFile | MapReduce, Pig, Hive (0.4+), Impala | Thrift, Protocol Buffers^([a]) | Java | 否 |
| ORC 文件 | MapReduce, Pig, Hive (0.11+) | 无 | Java | 是 |
| Parquet | MapReduce, Pig, Hive, Impala | Avro, Protocol Buffers, Thrift | Java, C++, Python | 是 |
^a Elephant Bird 提供了使用 Thrift 和 Protocol Buffers 与 RCFile 一起使用的能力。
对于本节,我将专注于 Parquet,因为它与 Avro 等对象模型具有兼容性。
3.4.1. 理解对象模型和存储格式
在我们开始介绍技术之前,我们将介绍一些重要的 Parquet 概念,这些概念对于理解 Parquet 与 Avro(以及 Thrift 和 Protocol Buffers)之间的交互至关重要:
-
对象模型 是数据的内存表示。Parquet 提供了一个简单的对象模型,这更多的是作为一个示例,而不是其他任何东西。Avro、Thrift 和 Protocol Buffers 都是功能齐全的对象模型。一个例子是 Avro 的
Stock类,它是通过 Avro 生成的,用于丰富地使用 Java POJOs 模型化模式。 -
存储格式 是数据模型的序列化表示。Parquet 是一种以列式形式序列化数据的存储格式。Avro、Thrift 和 Protocol Buffers 也都有自己的存储格式,它们以行导向格式序列化数据.^([43]) 存储格式可以被视为数据的静态表示。
[6].
-
Parquet 对象模型转换器 负责将对象模型转换为 Parquet 的数据类型,反之亦然。Parquet 随带了许多转换器,以最大化互操作性和 Parquet 的采用。
图 3.13 展示了这些概念在 Parquet 上下文中的工作方式。
图 3.13. Parquet 存储格式和对象模型转换器

Parquet 的独特之处在于它具有转换器,允许它支持常见的对象模型,如 Avro。在幕后,数据以 Parquet 二进制形式存储,但当你处理数据时,你使用的是你首选的对象模型,例如 Avro 对象。这给你带来了两全其美的效果:你可以继续使用像 Avro 这样的丰富对象模型与你的数据交互,而这些数据将使用 Parquet 在磁盘上高效地布局。
存储格式互操作性
存储格式通常是不可互操作的。当你结合 Avro 和 Parquet 时,你是在结合 Avro 的对象模型和 Parquet 的存储格式。因此,如果你有使用 Avro 存储格式序列化的现有 Avro 数据存储在 HDFS 中,你不能使用 Parquet 的存储格式来读取这些数据,因为它们是两种非常不同的数据编码方式。反之亦然——Parquet 不能使用正常的 Avro 方法(如 MapReduce 中的 AvroInputFormat)来读取;你必须使用 Parquet 的输入格式实现和 Hive SerDes 来处理 Parquet 数据。
总结来说,如果你想以列式形式序列化你的数据,请选择 Parquet。一旦你选择了 Parquet,你将需要决定你将使用哪种对象模型。我建议你选择在你组织中最受欢迎的对象模型。否则,我建议选择 Avro(第 3.3.5 节解释了为什么 Avro 可以是一个好的选择)。
Parquet 文件格式
Parquet 文件格式超出了本书的范围;对于更多细节,请查看 Parquet 项目的页面 github.com/Parquet/parquet-format。
3.4.2. Parquet 和 Hadoop 生态系统
Parquet 的目标是最大化在整个 Hadoop 生态系统中的支持。它目前支持 MapReduce、Hive、Pig、Impala 和 Spark,并希望我们能看到它被其他系统(如 Sqoop)支持。
由于 Parquet 是一种标准文件格式,因此任何这些技术中的一种所编写的 Parquet 文件也可以被其他技术读取。在 Hadoop 生态系统中最大化支持对于文件格式的成功至关重要,而 Parquet 有望成为大数据中的通用文件格式。
令人欣慰的是,Parquet 并没有专注于特定的技术子集——正如 Parquet 主页上所说,“在生态系统支持方面,我们并不偏袒任何一方”(parquet.io)。这表明项目的主要目标是在你可能会使用的工具中最大化其支持,这对于新工具不断出现在我们的雷达上非常重要。
3.4.3. Parquet 块和页面大小
图 3.14 展示了 Parquet 文件格式的概要表示,并突出了关键概念。
图 3.14. Parquet 的文件格式

更详细的文件格式概述可以在项目的主页上查看:github.com/Parquet/parquet-format。
技巧 20 通过命令行读取 Parquet 文件
Parquet 是一种二进制存储格式,因此使用标准的 hadoop fs -cat 命令将在命令行上产生垃圾。在这个技术中,我们将探讨如何使用命令行不仅查看 Parquet 文件的 内容,还可以检查 Parquet 文件中包含的模式和附加元数据。
问题
你希望使用命令行来检查 Parquet 文件的内容。
解决方案
使用 Parquet 工具捆绑的实用程序。
讨论
Parquet 附带一个包含一些有用实用程序的工具 JAR,可以将 Parquet 文件中的信息转储到标准输出。
在你开始之前,你需要创建一个 Parquet 文件,这样你就可以测试这些工具。以下示例通过写入 Avro 记录来创建一个 Parquet 文件:
$ hip hip.ch3.parquet.ParquetAvroStockWriter \
--input test-data/stocks.txt \
--output stocks.parquet
你将使用的第一个 Parquet 工具是cat,它将 Parquet 文件中的数据简单地转储到标准输出:
$ hip --nolib parquet.tools.Main cat stocks.parquet
symbol = AAPL
date = 2009-01-02
open = 85.88
...
你可以使用 Parquet 的head命令代替前面的cat,只输出前五个记录。还有一个dump命令,允许你指定要转储的列的子集,尽管输出不是那么易于阅读。
Parquet 有其自己的内部数据类型和模式,这些类型通过转换器映射到外部对象模型。可以使用schema选项查看内部 Parquet 模式:
$ hip --nolib parquet.tools.Main schema stocks.parquet
message hip.ch3.avro.gen.Stock {
required binary symbol (UTF8);
required binary date (UTF8);
required double open;
required double high;
required double low;
required double close;
required int32 volume;
required double adjClose;
}
Parquet 还允许对象模型使用元数据来存储反序列化所需的信息。例如,Avro 使用元数据来存储 Avro 模式,如下面的命令输出所示:
$ hip --nolib parquet.tools.Main meta stocks.parquet
creator: parquet-mr (build 3f25ad97f20...)
extra: avro.schema = {"type":"record","name":"Stock","namespace" ...
file schema: hip.ch3.avro.gen.Stock
---------------------------------------------------------------------
symbol: REQUIRED BINARY O:UTF8 R:0 D:0
date: REQUIRED BINARY O:UTF8 R:0 D:0
open: REQUIRED DOUBLE R:0 D:0
high: REQUIRED DOUBLE R:0 D:0
low: REQUIRED DOUBLE R:0 D:0
close: REQUIRED DOUBLE R:0 D:0
volume: REQUIRED INT32 R:0 D:0
adjClose: REQUIRED DOUBLE R:0 D:0
row group 1: RC:45 TS:2376
---------------------------------------------------------------------
symbol: BINARY SNAPPY DO:0 FPO:4 SZ:85/84/0.99 VC:45 ENC:PD ...
date: BINARY SNAPPY DO:0 FPO:89 SZ:127/198/1.56 VC:45 ENC ...
open: DOUBLE SNAPPY DO:0 FPO:216 SZ:301/379/1.26 VC:45 EN ...
high: DOUBLE SNAPPY DO:0 FPO:517 SZ:297/379/1.28 VC:45 EN ...
low: DOUBLE SNAPPY DO:0 FPO:814 SZ:292/379/1.30 VC:45 EN ...
close: DOUBLE SNAPPY DO:0 FPO:1106 SZ:299/379/1.27 VC:45 E ...
volume: INT32 SNAPPY DO:0 FPO:1405 SZ:203/199/0.98 VC:45 EN ...
adjClose: DOUBLE SNAPPY DO:0 FPO:1608 SZ:298/379/1.27 VC:45 E ...
接下来,让我们看看你如何可以写入和读取 Parquet 文件。
技巧 21:使用 Java 在 Parquet 中读取和写入 Avro 数据
当你开始处理一个新的文件格式时,你首先想要做的一件事是了解一个独立的 Java 应用程序如何读取和写入数据。这个技术展示了你如何可以将 Avro 数据写入 Parquet 文件并读取出来。
问题
你希望直接从 Java 代码中读取和写入 Parquet 数据,而不使用 Hadoop,并使用 Avro 对象模型。
解决方案
使用AvroParquetWriter和AvroParquetReader类。
讨论
Parquet 是 Hadoop 的列式存储格式,它支持 Avro,这允许你使用 Avro 类来处理数据,并使用 Parquet 的文件格式有效地编码数据,以便你可以利用数据的列式布局。混合这种数据格式听起来很奇怪,所以让我们调查一下为什么你想这样做以及它是如何工作的。
Parquet 是一种存储格式,它有一个正式的、与编程语言无关的规范。你可以直接使用 Parquet 而无需任何其他支持数据格式,如 Avro,但 Parquet 本质上是一种简单的数据格式,不支持如映射或联合等复杂类型。这就是 Avro 发挥作用的地方,因为它支持这些更丰富的类型以及代码生成和模式演变等功能。因此,将 Parquet 与像 Avro 这样的丰富数据格式结合起来,就形成了一个复杂的模式能力与高效数据编码的完美匹配。
对于这个技术,我们将继续使用 Avro 股票模式。首先,让我们看看你如何可以使用这些Stock对象来写入 Parquet 文件.^([44])
^((44) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/parquet/ParquetAvroStockWriter.java)。

以下命令通过执行前面的代码生成一个 Parquet 文件:
$ hip hip.ch3.parquet.ParquetAvroStockWriter \
--input test-data/stocks.txt \
--output stocks.parquet
之前的技术展示了如何使用 Parquet 工具将文件转储到标准输出。但如果你想在 Java 中读取文件呢?^([45])。
^((45) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/parquet/ParquetAvroStockReader.java)。
ParquetReader<Stock> reader = new AvroParquetReader<Stock>(inputFile);
Stock stock;
while((stock = reader.read()) != null) {
System.out.println(stock);
}
reader.close()
以下命令显示了前面代码的输出:
$ hip hip.ch3.parquet.ParquetAvroStockReader \
--input stocks.parquet
AAPL,2009-01-02,85.88,91.04,85.16,90.75,26643400,90.75
AAPL,2008-01-02,199.27,200.26,192.55,194.84,38542100,194.84
AAPL,2007-01-03,86.29,86.58,81.9,83.8,44225700,83.8
...
技术二十二:Parquet 和 MapReduce
这种技术探讨了如何在 MapReduce 中处理 Parquet 文件。将 Parquet 作为 MapReduce 中的数据源和数据汇将得到介绍。
问题
你想在 MapReduce 中处理序列化为 Parquet 的 Avro 数据。
解决方案
使用AvroParquetInputFormat和AvroParquetOutputFormat类。
讨论
Parquet 中的 Avro 子项目附带 MapReduce 输入和输出格式,让你可以使用 Parquet 作为存储格式来读取和写入你的 Avro 数据。以下示例计算每个符号的平均股票价格:^([46])。
^((46) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/parquet/AvroParquetMapReduce.java)。

在 Parquet 中处理 Avro 非常简单,并且可以说比处理 Avro 序列化数据更容易.^([47]) 你可以运行示例:
^((47) Avro 提供的输入和输出格式用于支持 Avro 的存储格式,它封装了 Avro 对象,需要一定程度的间接引用。
$ hip hip.ch3.parquet.AvroParquetMapReduce \
--input stocks.parquet \
--output output
Parquet 附带一些工具来帮助你处理 Parquet 文件,其中之一允许你将内容转储到标准输出:
$ hdfs -ls output
output/_SUCCESS
output/_metadata
output/part-r-00000.parquet
$ hip --nolib parquet.tools.Main cat output/part-r-00000.parquet
symbol = AAPL
avg = 68.631
symbol = CSCO
avg = 31.148000000000003
symbol = GOOG
avg = 417.47799999999995
symbol = MSFT
avg = 44.63100000000001
symbol = YHOO
avg = 69.333
你可能已经注意到输出目录中有一个名为 _metadata 的额外文件。当 Parquet 的OutputComitter在作业完成后运行时,它会读取所有输出文件的尾部(包含文件元数据)并生成这个汇总的元数据文件。这个文件被后续的 MapReduce(或 Pig/Hive)作业用来减少作业启动时间.^([48])。
^((48) 当需要读取大量输入文件的尾部时,计算输入拆分可能需要很长时间,因此能够读取单个汇总文件是一种有用的优化。
摘要
在这个技术中,你看到了如何使用代码生成的 Avro 对象文件与 Parquet 一起使用。如果你不想处理 Avro 对象文件,你有一些选项允许你使用 Avro 的GenericData类通用地处理 Avro 数据:
-
如果你使用
GenericData对象编写了 Avro 数据,那么 Avro 将以这种格式将它们提供给你的 mapper。 -
排除包含你的 Avro 生成代码的 JAR 文件,也会导致
GenericData对象被喂给你的 mapper。 -
你可以通过修改输入模式来欺骗 Avro,使其无法加载特定的类,从而迫使它提供
GenericData实例。
以下代码展示了如何执行第三种选择——你实际上是在复制原始模式,但在过程中提供了不同的类名,Avro 将无法加载(参见第一行的"foobar"):^([49])
^(49)GitHub 源代码
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/parquet/AvroGenericParquetMapReduce.java。
Schema schema = Schema.createRecord("foobar",
Stock.SCHEMA$.getDoc(), Stock.SCHEMA$.getNamespace(), false);
List<Schema.Field> fields = Lists.newArrayList();
for (Schema.Field field : Stock.SCHEMA$.getFields()) {
fields.add(new Schema.Field(field.name(), field.schema(),
field.doc(), field.defaultValue(), field.order()));
}
schema.setFields(fields);
AvroParquetInputFormat.setAvroReadSchema(job, schema);
如果你想以原生方式处理 Parquet 数据呢?Parquet 附带了一个示例对象模型,允许你处理任何 Parquet 数据,无论使用什么对象模型来写入数据。它使用Group类来表示记录,并提供了一些基本的 getter 和 setter 来检索字段。
以下代码再次展示了如何计算股票平均值。输入是 Avro/Parquet 数据,输出是一个全新的 Parquet 模式:^([50])
^(50)GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/parquet/ExampleParquetMapReduce.java.
private final static String writeSchema = "message stockavg {\n" +
"required binary symbol;\n" +
"required double avg;\n" +
"}";
public void run(Path inputPath, Path outputPath) {
Configuration conf = super.getConf();
Job job = new Job(conf);
job.setJarByClass(ExampleParquetMapReduce.class);
job.setInputFormatClass(ExampleInputFormat.class);
FileInputFormat.setInputPaths(job, inputPath);
job.setMapperClass(Map.class);
job.setReducerClass(Reduce.class);
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(DoubleWritable.class);
job.setOutputFormatClass(ExampleOutputFormat.class);
FileOutputFormat.setOutputPath(job, outputPath);
ExampleOutputFormat.setSchema(
job,
MessageTypeParser.parseMessageType(writeSchema));
}
public static class Map extends Mapper<Void, Group,
Text, DoubleWritable> {
@Override
public void map(Void key, Group value, Context context) {
context.write(new Text(value.getString("symbol", 0)),
new DoubleWritable(Double.valueOf(
value.getValueToString(2, 0))));
}
}
public static class Reduce extends Reducer<Text, DoubleWritable,
Void, Group> {
private SimpleGroupFactory factory;
@Override
protected void setup(Context context) {
factory = new SimpleGroupFactory(
GroupWriteSupport.getSchema(
ContextUtil.getConfiguration(context)));
}
@Override
protected void reduce(Text key, Iterable<DoubleWritable> values,
Context context) {
Mean mean = new Mean();
for (DoubleWritable val : values) {
mean.increment(val.get());
}
Group group = factory.newGroup()
.append("symbol", key.toString())
.append("avg", mean.getResult());
context.write(null, group);
}
}
示例对象模型相当基础,目前缺少一些功能——例如,没有为 double 类型提供 getter,这就是为什么前面的代码使用getValueToString方法访问股票价值。但正在努力提供更好的对象模型,包括 POJO 适配器。^([51])
^(51)请参阅 GitHub 上的问题单号 325,标题为“Parquet 的 POJO 支持”的
github.com/Parquet/parquet-mr/pull/325。
技术篇 23:Parquet 与 Hive/Impala
当 Parquet 在 Hive 和 Impala 中使用时,其优势得以体现。由于其能够利用下推(pushdowns)来优化读取路径,列式存储是这些系统的自然选择。这项技术展示了如何在这些系统中使用 Parquet。[7]) 该技术展示了如何在这些系统中使用 Parquet。
^(52)下一种技术中会更详细地介绍下推(Pushdowns)。
问题
你希望能够在 Hive 和 Impala 中处理你的 Parquet 数据。
解决方案
使用 Hive 和 Impala 内置对 Parquet 的支持。
讨论
Hive 要求数据存在于目录中,因此你首先需要创建一个目录并将股票 Parquet 文件复制到其中:
$ hadoop fs -mkdir parquet_avro_stocks
$ hadoop fs -cp stocks.parquet parquet_avro_stocks
接下来,你将创建一个外部 Hive 表并定义其模式。如果你不确定模式的结构,可以使用之前的技术查看你正在处理的 Parquet 文件中的模式信息(在 Parquet 工具中使用schema命令):
hive> CREATE EXTERNAL TABLE parquet_stocks(
symbol string,
date string,
open double,
high double,
low double,
close double,
volume int,
adjClose double
) STORED AS PARQUET
LOCATION '/user/YOUR_USERNAME/parquet_avro_stocks';
Hive 0.13
仅在 Hive 0.13 中添加了对 Parquet 作为本地 Hive 存储的支持(见issues.apache.org/jira/browse/HIVE-5783)。如果你使用的是 Hive 的旧版本,你需要使用ADD JAR命令手动加载所有 Parquet JARs,并使用 Parquet 输入和输出格式。Cloudera 在其博客上有一个示例;请参阅“如何:在 Impala、Hive、Pig 和 Map-Reduce 中使用 Parquet”,blog.cloudera.com/blog/2014/03/how-to-use-parquet-with-impala-hive-pig-mapreduce/。
你可以运行一个简单的查询来从数据中提取唯一的股票代码:
hive> select distinct(symbol) from parquet_stocks;
AAPL
CSCO
GOOG
MSFT
YHOO
你可以使用相同的语法在 Impala 中创建表。
技巧 24:使用 Parquet 进行下推谓词和投影
投影和谓词下推涉及执行引擎将投影和谓词下推到存储格式,以尽可能优化最低级别的操作。这带来了空间和时间上的优势,因为不需要查询的列不需要被检索并提供给执行引擎。
这对于列式存储尤其有用,因为下推允许存储格式跳过查询中不需要的整个列组,并且列式格式可以非常高效地执行此操作。
在这个技巧中,你将查看在 Hadoop 管道中使用这些下推所需的步骤。
问题
你想在 Hadoop 中使用下推来优化你的作业。
解决方案
使用 Hive 和 Pig 与 Parquet 结合使用提供开箱即用的投影下推。在 MapReduce 中,你需要在驱动代码中执行一些手动步骤来启用下推。
讨论
再次强调,我们使用此技术的重点是 Avro。AvroParquetInputFormat有两个你可以用于谓词和投影下推的方法。在下面的示例中,只投影了Stock对象的两个字段,并添加了一个谓词,以便只选择谷歌股票:^([53])
(53) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/parquet/AvroProjectionParquetMapReduce.java.


谓词过滤器 null 值
当你提供的谓词过滤掉一个记录时,你的 mapper 会接收到一个null值。这就是为什么在处理 mapper 输入之前你必须检查null的原因。
如果你运行作业并检查输出,你只会找到谷歌股票的平均值,这表明谓词是有效的:
$ hip hip.ch3.parquet.AvroProjectionParquetMapReduce \
--input stocks.parquet \
--output output
$ hip --nolib parquet.tools.Main cat output/part-r-00000.parquet
symbol = GOOG
avg = 417.47799999999995
摘要
这种技术不包括任何 Hive 或 Pig 向下推送的细节,因为这两个工具在执行时会自动执行这些向下推送。向下推送是您作业优化工作的重要组成部分,如果您使用的是不暴露向下推送的第三方库或工具,您可以通过提出功能请求来帮助社区。
3.4.4. Parquet 限制
在使用 Parquet 时,你应该注意以下几点:
-
Parquet 在写入文件时需要大量的内存,因为它在内存中缓冲写入以优化数据的编码和压缩。如果遇到写入 Parquet 文件时的内存问题,可以增加堆大小(建议 2 GB),或者减少可配置的
parquet.block.size。 -
使用 Parquet 的深度嵌套数据结构可能会限制 Parquet 在向下推送时的一些优化。如果可能的话,尽量简化你的模式。
-
Hive 在处理 Parquet 时还不支持
decimal和timestamp数据类型,因为 Parquet 不支持它们作为原生类型。相关工作正在 JIRA 票据“在 Parquet 中实现所有 Hive 数据类型”中跟踪(issues.apache.org/jira/browse/HIVE-6384)。 -
Impala 不支持 Parquet 中的嵌套数据或复杂数据类型,如 maps、structs 或 arrays。这应该在 Impala 2.x 版本中得到修复。
-
当 Parquet 文件包含单个行组且整个文件适合 HDFS 块时,工具如 Impala 工作得最好。在现实世界中,当你使用 MapReduce 等系统写入 Parquet 文件时很难实现这一目标,但当你生成 Parquet 文件时,记住这一点是好的。
我们已经介绍了处理常见文件格式和使用各种数据序列化工具以实现与 MapReduce 更紧密兼容性的方法。现在是时候看看你如何支持可能属于你组织的专有文件格式,或者对于 MapReduce 没有输入或输出格式的公共文件格式。
3.5. 自定义文件格式
在任何组织中,你通常都会发现大量的自定义或非标准文件格式散布在其数据中心。可能有后端服务器以专有格式输出审计文件,或者旧的代码或系统使用不再通用的格式写入文件。如果你想在 MapReduce 中处理此类数据,你需要编写自己的输入和输出格式类来处理你的数据。本节将指导你完成这个过程。
3.5.1. 输入和输出格式
在本章开头,我们概述了 MapReduce 中输入和输出格式类的作用。输入和输出类是向 map 函数提供数据和写入 reduce 函数输出的必需品。
技巧 25:为 CSV 编写输入和输出格式
想象一下,你有一堆数据存储在 CSV 文件中,你正在编写多个 MapReduce 作业,这些作业以 CSV 形式读取和写入数据。因为 CSV 是文本格式,你可以使用内置的TextInputFormat和TextOutputFormat,并在 MapReduce 代码中处理 CSV 的解析。然而,这可能会很快变得令人疲惫,并且导致相同的解析代码被复制粘贴到所有的作业中。
如果你认为 MapReduce 有任何内置的 CSV 输入和输出格式可以处理这种解析,那么你可能运气不佳——没有。
问题
你想在 MapReduce 中使用 CSV,并且希望以比使用TextInputFormat提供的字符串行表示更丰富的格式来展示 CSV 记录。
解决方案
编写一个与 CSV 一起工作的输入和输出格式。
讨论
我们将涵盖编写自己的格式类以与 CSV 输入和输出一起工作的所有步骤。CSV 是易于处理的一种文件格式,这将使得在不太多考虑文件格式的情况下,更容易关注 MapReduce 格式细节。
你的自定义InputFormat和RecordReader类将解析 CSV 文件,并以用户友好的格式向 mapper 提供数据。你还将支持非逗号分隔符的自定义字段分隔符。因为你不想重新发明轮子,你将使用开源 OpenCSV 项目中的 CSV 解析器(opencsv.sourceforge.net/),它将处理引号字段并忽略引号字段中的分隔符。
InputFormat和OutputFormat概述
我在本章开头提供了对InputFormat和OutputFormat及其相关类的详细概述。在查看本技术中的代码之前,回顾一下那个讨论可能是有价值的。
输入格式
你的第一步是定义InputFormat。InputFormat的功能是验证作业提供的输入集,识别输入分割,并创建一个RecordReader类来从源读取输入。以下代码从作业配置中读取分隔符(如果提供)并构建一个CSVRecordReader类:^([54])
[8](https://github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/csv/CSVInputFormat.java)

InputFormat和压缩文件
在前面的代码中,你看到当数据被压缩时,会返回一个标志来指示它不能被分割。这样做的原因是压缩编解码器是不可分割的,除了 LZOP。但是可分割的 LZOP 不能与常规的InputFormat类一起工作——它需要特殊的 LZOP InputFormat类。这些细节在第四章中有详细说明。
您的 InputFormat 类已经完成。您扩展了 FileInputFormat 类,其中包含计算输入分片沿 HDFS 块边界的代码,这样您就无需自己处理计算输入分片。FileInputFormat 会为您管理所有输入文件和分片。现在让我们继续到 RecordReader,这需要更多的努力。
RecordReader 执行两个主要功能。它必须首先根据提供的输入分片打开输入源,并且可以选择在该输入分片中的特定偏移量处进行查找。RecordReader 的第二个功能是从输入源读取单个记录。
在这个例子中,逻辑记录等同于 CSV 文件中的一行,所以您将使用现有的 LineRecordReader 类在 MapReduce 中处理文件。当 RecordReader 使用 InputSplit 初始化时,它将打开输入文件,跳转到输入分片的开头,并继续读取字符,直到它到达下一个记录的开始,在这种情况下,行意味着换行符。以下代码显示了 LineRecordReader.initialize 方法的简化版本:

LineRecordReader 为 LongWritable/Text 形式的每一行返回键值对。因为您希望在 Record Reader 中提供一些功能,所以您需要将 LineRecordReader 封装在您的类中。RecordReader 需要向 mapper 提供记录的键值对表示,在这种情况下,键是文件中的字节偏移量,值是包含 CSV 行分词部分的数组:^([55])
^(55) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/csv/CSVInputFormat.java。

接下来,您需要提供读取下一个记录和获取该记录键值的方法:^([56])
^(56) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/csv/CSVInputFormat.java。

到目前为止,您已经创建了一个可以处理 CSV 文件的 InputFormat 和 RecordReader。现在您已经完成了 InputFormat,是时候转向 OutputFormat 了。
OutputFormat
OutputFormat 类遵循与 InputFormat 类相似的模式;OutputFormat 类处理创建输出流的相关后勤工作,然后将流写入委托给 RecordWriter。
CSVOutputFormat 间接扩展了 FileOutputFormat 类(通过 TextOutputFormat),它处理与创建输出文件名、创建压缩编解码器实例(如果启用了压缩)以及输出提交相关的所有后勤工作,我们将在稍后讨论。
这就留下了 OutputFormat 类,它负责支持 CSV 输出文件的定制字段分隔符,并在需要时创建压缩的 OutputStream。它还必须返回你的 CSVRecordWriter,该 CSVRecordWriter 将 CSV 行写入输出流:^([57])
^([57) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/csv/CSVOutputFormat.java).

在以下代码中,你的 RecordWriter 将归约器发出的每个记录写入输出目的地。你需要归约器输出键以数组形式表示 CSV 行中的每个标记,并指定归约器输出值必须是 NullWritable,这意味着你不在乎输出值部分。
让我们看看 CSVRecordWriter 类。构造函数,它只设置字段分隔符和输出流,被省略了,如下所示:^([58])
^([58) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/csv/CSVOutputFormat.java).
列表 3.6. 一个生成 MapReduce CSV 输出的 RecordWriter


现在你需要在 MapReduce 作业中应用新的输入和输出格式类。
MapReduce
你的 MapReduce 作业将以 CSV 作为输入,并生成以冒号分隔的 CSV,而不是逗号分隔。作业将执行恒等映射和归约函数,这意味着你不会在 MapReduce 传输过程中更改数据。你的输入文件将以制表符为分隔符,你的输出文件将以逗号分隔。你的输入和输出格式类将通过 Hadoop 配置属性支持自定义分隔符的概念。
MapReduce 代码如下:^([59])
^([59) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/csv/CSVMapReduce.java).

映射和归约函数除了将输入回显到输出之外,没有做太多的事情,但包括它们,以便你可以看到如何在 MapReduce 代码中处理 CSV:^([60])
^([60) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch3/csv/CSVMapReduce.java).

如果你运行这个示例 MapReduce 作业针对制表符分隔的文件,你可以检查映射器的输出,看看结果是否符合预期:
$ hadoop fs -put test-data/stocks.txt stocks.txt
$ hip hip.ch3.csv.CSVMapReduce \
--input stocks.txt \
--output output
$ hadoop fs -cat output/part*
AAPL:2009-01-02:85.88:91.04:85.16:90.75:26643400:90.75
AAPL:2008-01-02:199.27:200.26:192.55:194.84:38542100:194.84
AAPL:2007-01-03:86.29:86.58:81.90:83.80:44225700:83.80
...
现在你已经有一个功能性的 InputFormat 和 OutputFormat,可以在 MapReduce 中消费和生成 CSV。
猪肉
Pig 的 piggybank 库包含一个CSVLoader,它可以用来将 CSV 文件加载到元组中。它支持 CSV 记录中的双引号字段,并将每个项目作为字节数组提供。
有一个名为 csv-serde (github.com/ogrodnek/csv-serde)的 GitHub 项目,它有一个 Hive SerDe,可以序列化和反序列化 CSV。像之前的InputFormat示例一样,它也使用 OpenCSV 项目来读取和写入 CSV。
摘要
这种技术展示了如何编写自己的 MapReduce 格式类来处理基于文本的数据。目前 MapReduce 正在进行添加 CSV 输入格式的工作(见issues.apache.org/jira/browse/MAPREDUCE-2208)。
争议性地讲,使用TextInputFormat并在映射器中分割行可能更简单。但如果你需要多次这样做,你很可能是受到了复制粘贴反模式的困扰,因为用于标记 CSV 的相同代码可能存在于多个位置。如果代码是考虑到代码复用而编写的,那么你将得到保障。
我们已经探讨了如何编写自己的 I/O 格式类来与 MapReduce 中的自定义文件格式一起工作。现在我们需要关注与输出格式一起工作的一个关键方面——输出提交。
3.5.2. 输出提交的重要性
在前一种技术中的 CSVOutputFormat示例中,你扩展了FileOutputFormat,它在任务成功后负责提交输出。为什么在 MapReduce 中需要提交,你为什么应该关心这个问题?
在作业及其任务执行过程中,它们将在某个时刻开始写入作业输出。任务和作业可能会失败,它们可以被重新启动,也可以进行推测性执行.^([61]) 为了允许OutputFormats 正确处理这些场景,MapReduce 有一个名为OutputCommitter的概念,这是一种机制,MapReduce 在单个任务以及整个作业完成时调用回调。
⁶¹ 推测性执行是指 MapReduce 对相同输入数据执行多个任务,以防止缓慢或行为异常的节点减慢整体作业的速度。默认情况下,映射端和减少端的推测性执行都是启用的。
mapred.map.tasks.speculative.execution和mapred.reduce.tasks.speculative.execution控制这种行为。
MapReduce 中大多数 OutputFormat 都使用 FileOutputFormat,它使用 FileOutputCommitter 来处理输出提交。当 FileOutputFormat 首次查询输出文件的存储位置时,它将决定输出文件应该存储在哪里的决策委托给 FileOutputCommitter,后者指定输出应该放在作业输出目录下的一个临时目录中(FileOutputCommitter 才会被通知,此时临时输出会被移动到作业输出目录。当整体作业成功完成后,FileOutputCommitter 再次被通知,这次它在作业输出目录中创建一个 _SUCCESS 文件,以帮助下游处理器知道作业已成功完成。
如果你的数据目的地是 HDFS,你可以使用 FileOutputFormat 和其提交机制,这很好。当你处理文件以外的数据源,如数据库时,事情开始变得复杂。在这种情况下,如果需要幂等写操作(即相同的操作可以多次应用而不改变结果),你需要将其考虑进目标数据存储或 OutputFormat 的设计中。
这个主题在 第五章 中有更详细的探讨,该章涵盖了从 Hadoop 导出数据到数据库。
3.6. 章节总结
本章的目标是向你展示如何在 MapReduce 中处理常见的文件格式,如 XML 和 JSON。我们还探讨了更复杂的文件格式,如 SequenceFile、Avro 和 Parquet,它们为处理大数据提供了有用的功能,例如版本控制、压缩和复杂的数据结构。我们还介绍了处理自定义文件格式的流程,以确保它们能在 MapReduce 中正常工作。
到目前为止,你已经准备好在 MapReduce 中处理任何文件格式了。现在,是时候看看一些存储模式,以便你能够有效地处理你的数据并优化存储和磁盘/网络 I/O。
第四章. 在 HDFS 中组织和优化数据
本章涵盖
-
数据布局和组织技巧
-
优化读写数据的数据访问模式
-
压缩的重要性以及选择最适合你需求的编解码器
在上一章中,我们探讨了如何在 MapReduce 中处理不同的文件格式,以及哪些格式最适合存储你的数据。一旦你确定了将要使用的数据格式,就需要开始考虑如何在 HDFS 中组织你的数据。在设计你的 Hadoop 系统的早期阶段,给自己留出足够的时间来理解数据如何被访问,这样你就可以针对你将要支持的更重要用例进行优化。
有许多因素会影响你的数据组织决策,例如你是否需要提供 SQL 访问你的数据(很可能你需要),哪些字段将用于查找数据,以及你需要支持什么访问时间的 SLA。同时,你需要确保不会因为大量的小文件而对 HDFS NameNode 施加不必要的堆压力,你还需要学习如何处理巨大的输入数据集。
本章致力于探讨在 HDFS 中高效存储和访问大数据的方法。我首先会介绍你如何在 HDFS 中布局数据,并展示一些用于分区和合并数据以减轻 NameNode 堆压力的方法。然后,我会讨论一些数据访问模式,以帮助你处理不同的数据以及大量数据集。最后,我们将探讨压缩作为大数据模式,以最大化你的存储和处理能力。
章节先决条件
本章假设你已对 HDFS 概念有基本的了解,并且你有直接与 HDFS 工作的经验。如果你需要熟悉这个主题,Chuck Lam 的《Hadoop 实战》(Manning, 2010)提供了你需要的关于 HDFS 的背景信息。
我们将从如何组织和管理工作数据开始。
4.1. 数据组织
数据组织是使用 Hadoop 时最具挑战性的方面之一。你的组织中的不同群体,如数据科学家和你的集群管理员,都会向你提出竞争性的要求。更重要的是,这些要求通常在数据应用投入生产并积累了大量数据之后才会出现。
Hadoop 中的数据组织有多个维度。首先,你需要决定如何在 HDFS 中组织你的数据,之后你将面临如何分区和压缩数据等操作问题。你需要决定是否启用 Kerberos 来保护你的集群,以及如何管理和沟通数据变更。这些都是复杂的问题,本章的目标是专注于数据组织的一些更具挑战性的方面,包括数据分区和压缩,从如何在 HDFS 中结构化你的数据开始。
4.1.1. 目录和文件布局
在集群范围内有一个定义数据组织方式的标准化是值得追求的,因为它使得发现数据所在位置变得更容易,同时也帮助你在一般的数据存储中应用结构和管理工作需要解决的问题。由于我们是在文件系统可以表达的范围内工作,组织数据的一种常见方法就是创建一个与你的组织或功能结构相一致的多级层次结构。例如,如果你在分析团队工作,并且你将一个新的数据集带到集群中,那么组织你的目录的一种方法可以如图 4.1 所示。
图 4.1. 一个示例 HDFS 目录布局

数据革命
希望你已经确定了一种数据格式,例如 Avro,它允许你在时间上演变你的模式。这很好,但当你支持迁移到下一个大数据格式时,这个格式无疑会在每个人都迁移到 Avro 之后到来,你该如何应对呢?嗯,你可以参考其他软件领域,其中语义版本控制概念渗透到接口,如 URL,并在你的目录结构中采用类似的策略。通过在你的结构中添加版本号,你可以给自己提供灵活性,以便迁移到明天的数据格式,并使用目录路径来传达不同的文件格式。
一旦你接受了在目录中放置版本号的做法,剩下的唯一挑战就是向你的数据消费者传达未来的变化。如果这成为一个挑战,你可能想看看 HCatalog 作为一种将数据格式从客户端抽象出来的方法。
按日期和其他字段分区
你可能需要你的目录结构来模拟你的组织和数据演变需求,但你为什么还需要按日期进一步分区呢?这是一种 Hive 在早期用来帮助加速查询的技术。如果你把所有数据放入一个单独的目录中,每次需要访问数据时,你实际上是在做 Hadoop 的全表扫描。相反,更明智的做法是根据你预期如何访问数据来对数据进行分区。
在事先很难确切知道数据将如何被访问的情况下,一个合理的初步分区尝试是按数据生成日期来分割数据。如果你的数据没有日期,那么与数据生产者谈谈添加一个日期,因为事件或记录被创建的时间是一个关键的数据点,应该始终被捕获。
4.1.2. 数据层级
在 2012 年的 Strata 演讲中,Eric Sammer 提出了存储不同层级数据的想法.^([1]) 这是一个强大的概念,并且它与 Nathan Marz 的 Lambda 架构的一个主要原则——永不删除或修改原始数据——很好地结合在一起。
¹ Eric Sammer,“使用 Hadoop 进行大规模 ETL”,www.slideshare.net/OReillyStrata/large-scale-etl-with-hadoop。
乍一看,这似乎没有意义——当然,一旦你提取了数据源的重要部分,你可以丢弃其余部分!虽然保留原始数据可能看起来很浪费,尤其是如果有些部分没有被积极使用,但问问自己这个问题——未来能否从数据中提取一些组织价值?很难给出一个响亮的“不”。
我们的软件偶尔也会有错误。想象一下,您正在从 Twitter 的数据流中提取数据,生成一些聚合数据并丢弃源数据。如果您发现聚合逻辑中的错误怎么办?您将无法回溯并重新生成聚合数据。
因此,建议您将数据视为以下层级:
-
原始数据 是第一层。这是您从源捕获的未修改数据。这一层级的数据不应被修改,因为您的逻辑可能存在产生派生数据或聚合数据的错误,如果您丢弃原始数据,您将失去在发现错误后重新生成派生数据的能力。
-
派生数据 是从原始数据创建的。在这里,您可以执行去重、净化以及其他任何清理工作。
-
聚合数据 是从派生数据计算得出的,并可能被输入到 HBase 或您选择的 NoSQL 系统中,以便在生产和分析目的上实时访问您的数据。
数据层级也应反映在目录布局中,以便用户可以轻松区分层级。
一旦您为数据分区确定了目录布局,下一步就是确定如何将数据放入这些分区中。这一点将在下一部分介绍。
4.1.3. 分区
分区是将数据集分割成不同部分的过程。这些部分是分区,它们代表了您数据的有意义划分。数据中常见的分区示例是时间,因为它允许查询数据的人缩小到特定的时间窗口。上一节将时间作为决定如何在 HDFS 中布局数据的关键元素。
太好了!您在 HDFS 中有一个大型数据集,并且需要对其进行分区。您将如何进行?在本节中,我将介绍您可以使用两种方法来分区您的数据。
技巧 26 使用 MultipleOutputs 进行数据分区
想象一下这种情况:您有股票价格被流式传输到 HDFS,并且您想编写一个 MapReduce 作业来根据股票报价的日期对股票数据进行分区。为此,您需要在单个任务中写入多个输出文件。让我们看看如何实现这一点。
问题
您需要分区数据,但大多数输出格式在每个任务中只创建一个输出文件。
解决方案
使用与 MapReduce 捆绑的MultipleOutputs类。
讨论
Hadoop 中的MultipleOutputs类绕过了在 Hadoop 中产生输出的正常通道。它提供了一个单独的 API 来写入分区输出,并且直接将输出写入 HDFS 中的任务尝试目录。这非常强大,因为你可以继续使用提供给作业的Context对象的标准写入方法来收集输出,同时也可以使用MultipleOutputs来写入分区输出。当然,你也可以选择只使用MultipleOutputs类并忽略基于Context的标准输出。
在这个技术中,你将使用MultipleOutputs根据股票报价日期对股票进行分区。第一步是为你的作业设置MultipleOutputs。在你的驱动程序中,你将指示输出格式以及键和值类型:

为什么你需要在驱动程序中命名输出?
你可能想知道为什么MultipleOutputs要求你指定一个输出名称(在先前的例子中是partition)。这是因为MultipleOutputs支持两种操作模式——静态分区和动态分区。
静态分区在你知道分区名称的情况下效果很好;这为你提供了额外的灵活性,可以为每个分区指定不同的输出格式(你只需对MultipleOutputs.addNamedOutput进行多次调用,使用不同的命名输出)。在静态分区中,你在调用addNamedOutput时指定的输出名称与你在 mapper 或 reducer 中发出输出时使用的名称相同。
这种技术侧重于动态分区,这可能会更实用,因为在大多数情况下,你事先不知道分区。在这种情况下,你仍然需要提供一个输出名称,但就实际目的而言,它被忽略,因为你可以动态地在你的 mapper 或 reducer 中指定分区名称。
如以下代码所示,你的 map(或 reduce)类将获取一个MultipleOutputs实例的句柄,然后使用其写入方法来写入分区输出。注意,第三个参数是分区名称,即股票日期:^([2])
² GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch4/MultipleOutputsJob.java.

不要忘记调用 close 方法!
在你的任务清理方法中调用MultipleOutputs的 close 方法非常重要。否则,你的输出中可能会缺少数据,甚至可能出现损坏的文件。
让我们看看这个类在实际中的应用。如以下输出所示,运行前面的示例为单个 mapper 生成了多个分区文件。你还可以看到原始的 map 输出文件,它是空的,因为你没有使用Context对象发出任何记录:
$ hip hip.ch4.MultipleOutputsJob --input stocks.txt --output out1
$ hadoop fs -ls -R out1
out1/2000-01-03-m-00000
out1/2001-01-02-m-00000
out1/2002-01-02-m-00000
out1/2003-01-02-m-00000
out1/2004-01-02-m-00000
out1/2005-01-03-m-00000
out1/2006-01-03-m-00000
out1/2007-01-03-m-00000
out1/2008-01-02-m-00000
out1/2009-01-02-m-00000
out1/_SUCCESS
out1/part-m-00000
在这个例子中,你使用了仅映射的作业,但在生产中你可能希望限制创建分区的任务数量。你可以通过两种方式来实现:
-
使用
CombineFileInputFormat或自定义输入格式来限制你的作业中的 mapper 数量。 -
在可以显式指定合理数量的 reducer 的 reducer 中使用。
摘要
MultipleOutputs 有许多值得喜欢的地方:它支持“旧”和“新”的 MapReduce API,以及支持多个输出格式类。但使用 MultipleOutputs 也伴随着一些你应该注意到的限制:
-
在 mapper 中使用
MultipleOutputs时要小心——记住,你最终会得到 NumberOfMappers * NumberOfPartition 输出文件,这在我的经验中可能会因为大量值而使集群崩溃! -
每个分区都会在任务执行期间产生一个 HDFS 文件句柄的开销。
-
你可能会最终得到大量的小文件,这些文件会在多次使用你的分区器时累积。你可能需要确保你有一个压缩策略来减轻这个问题(更多详情请见第 4.1.4 节)。
-
尽管 Avro 随带
AvroMultipleOutputs类,但由于代码中的一些低效,它相当慢。
除了 MultipleOutputs 方法之外,Hadoop 还提供了一个具有类似功能的 MultipleOutputFormat 类。它主要的缺点是它只支持旧的 MapReduce API,并且所有分区只能使用一个输出格式。
你还可以采用另一种分区策略,即使用 MapReduce 分区器,这可以帮助减轻使用 MultipleOutputs 可能产生的文件数量过多的问题。
技巧 27 使用自定义 MapReduce 分区器
另一种分区方法是使用 Map-Reduce 内置的分区功能。默认情况下,MapReduce 使用一个哈希分区器,它计算每个映射输出键的哈希值,并对 reducer 的数量进行取模运算,以确定记录应该发送到哪个 reducer。你可以通过编写自己的自定义分区器来控制分区方式,然后根据你的分区方案路由记录。
与前一种技术相比,这种技术有一个额外的优点,那就是你通常会得到更少的输出文件,因为每个 reducer 只会创建一个输出文件,而 MultipleOutputs 则是每个映射或减少任务都会生成 N 个输出文件——每个分区一个。
问题
你想要对输入数据进行分区。
解决方案
编写一个自定义分区器,将记录分区到适当的 reducer。
讨论
首先让我们看看自定义分区器。它向 Map-Reduce 驱动程序提供了一个辅助方法,允许你定义一个从日期到分区的映射,并将这个映射写入作业配置。然后,当 MapReduce 加载分区器时,MapReduce 会调用 setConf 方法;在这个分区器中,你会将映射读入一个映射中,这个映射随后在分区时会被使用.^([3])
³ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch4/CustomPartitionerJob.java.

你的驱动代码需要设置自定义分区器配置。在这个例子中,分区是日期,你需要确保每个减少器都对应一个唯一的日期。股票示例数据有 10 个唯一的日期,所以你配置你的作业使用 10 个减少器。你还调用之前定义的分区辅助函数来设置配置,将每个唯一的日期映射到一个唯一的减少器上.^([4])
⁴ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch4/CustomPartitionerJob.java.

映射器除了从输入数据中提取股票日期并将其作为输出键发出之外,几乎不做其他事情:^([5])
⁵ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch4/CustomPartitionerJob.java.

运行前面示例的命令如下:
$ hip hip.ch4.CustomPartitionerJob --input stocks.txt --output output
这个作业将生成 10 个输出文件,每个文件包含该天的股票数据。
摘要
使用 MapReduce 框架自然地分区你的数据为你带来了一些优势:
-
你分区中的数据将会被排序,因为洗牌会确保所有发送到减少器的数据流都是排序的。这允许你使用优化的连接策略来处理你的数据。
-
你可以在减少器中去除重复数据,这同样是洗牌阶段的益处。
使用这种技术时需要注意的主要问题是数据倾斜。你想要确保尽可能地将负载分散到减少器上,如果数据中存在自然倾斜,这可能是一个挑战。例如,如果你的分区是按天的话,那么可能大多数记录都是某一天的数据,而你可能只有少量记录是前一天或后一天的数据。在这种情况下,你理想的做法是按记录分区,将大多数减少器分配给某一天,然后可能为前一天或后一天分配一个或两个。你也可以采样你的输入,并根据样本数据动态确定最优的减少器数量。
一旦你生成了分区输出,下一个挑战是如何处理分区后可能产生的大量小文件。
4.1.4. 压缩
有时候在 HDFS 中拥有小文件是无法避免的——也许你正在使用与之前描述类似的数据分区技术,或者也许你的数据自然地以小文件大小落在 HDFS 中。无论如何,你都会暴露 HDFS 和 MapReduce 的一些弱点,包括以下内容:
-
Hadoop 的 NameNode 将所有 HDFS 元数据保留在内存中,以便快速进行元数据操作。雅虎估计,每个文件平均占用 600 字节的内存空间,^([6]) 这相当于 10 亿个文件导致的 60GB 元数据开销,所有这些都需要存储在 NameNode 的内存中。即使是今天的中端服务器 RAM 容量,这也需要大量的内存来处理单个进程。
⁶ 根据雅虎的统计,每个块或文件 inode 使用的内存不到 200 字节,平均每个文件占用 1.5 个块,复制因子为 3。参见雅虎页面“Hadoop 分布式文件系统的可扩展性”,
developer.yahoo.com/blogs/hadoop/posts/2010/05/scalability_of_the_hadoop_dist/和一个标题为“Name-node 内存大小估计和优化建议”的 JIRA 工单,issues.apache.org/jira/browse/HADOOP-1687。 -
如果你提交给 MapReduce 作业的输入是大量文件,将要运行的 mapper 数量(假设你的文件是文本或可分割的)将与这些文件占用的块数量相当。如果你运行一个输入是数千或数百万个文件的 MapReduce 作业,你的作业将花费更多的时间在内核层处理创建和销毁你的 map 任务进程,而不是在它的工作上。
-
最后,如果你在一个有调度器的受控环境中运行,你的 MapReduce 作业可能对可用的任务数量有限制。因为每个文件(默认情况下)至少会产生一个 map 任务,这可能导致你的作业被调度器拒绝。
如果你认为你不会遇到这个问题,再想想看。你有多少比例的文件大小小于 HDFS 块大小?^([7]) 它们又小了多少——50%,70%,90%?如果你的大数据项目突然起飞,你需要能够扩展以处理比现在大几个数量级的数据集呢?这难道不是你最初使用 Hadoop 的原因吗?为了扩展,你希望能够添加更多的节点,然后回到你的早晨咖啡。你不想不得不回去重新设计你的 Hadoop 使用方式,处理文件迁移。思考和准备这种可能性最好在设计的早期阶段进行。
⁷ 默认的块大小为 1,238 MB。检查您的集群中
dfs.block.size的值以查看其设置。
本节探讨了您可以用于在 HDFS 中合并数据的某些技术。我将从一个名为 filecrush 的工具开始讨论,该工具可以将小文件压缩在一起以创建更少的较大文件。我还会向您展示如何使用 Avro 作为容器格式来存储难以压缩的文件,例如二进制文件。
技巧 28:使用 filecrush 压缩数据
压缩是将小文件合并成大文件的行为——这有助于减轻 NameNode 的堆压力。在此技术中,您将了解一个开源工具,您可以使用它来压缩数据并帮助您的集群管理员保持愉快。
与 Hadoop 版本的兼容性
目前,filecrush 工具仅与 Hadoop 版本 1 兼容。我在github.com/alexholmes/hdfscompact编写了一个简单的文件压缩器,该压缩器与 Hadoop 2 兼容。
问题
您希望合并小文件以减少 NameNode 需要保留在内存中的元数据。
解决方案
使用 filecrush 工具。
讨论
filecrush 工具^([8])将多个小文件合并或压缩成大文件。该工具相当复杂,并赋予您以下能力:
⁸ filecrush GitHub 项目页面位于
github.com/edwardcapriolo/filecrush。
-
确定文件压缩的阈值大小,低于此大小的文件将被压缩(并且相应地,将保留足够大的文件)
-
指定压缩文件的最大大小
-
与不同的输入和输出格式以及不同的输入和输出压缩编解码器(对于迁移到不同的文件格式或压缩编解码器很有用)
-
在原地用新的压缩文件替换较小的文件
我们将在一个简单的示例中使用 filecrush——我们将压缩单个目录中的小文本文件,并用 gzip 压缩的 SequenceFiles 替换它们。
首先,在 HDFS 中的一个目录中人工创建 10 个输入文件:
$ hadoop fs -mkdir crusher-dir
$ for i in `seq 1 10`; do
hadoop fs -put test-data/stocks.txt crusher-dir/stocks$i.txt
done
现在运行 filecrush。在此示例中,您将用新的大文件替换小文件,并将文本文件转换为压缩的 SequenceFile:

运行 filecrush 后,您会观察到输入目录中的文件已被单个 SequenceFile 替换:
$ hadoop fs -ls -R crusher-dir
crusher-dir/crushed_file-20140713162739-0-0
您还可以运行text Hadoop 命令来查看 SequenceFile 的文本表示:
$ hadoop fs -text crusher-dir/crushed_file-20140713162739-0-0
您还会注意到,原始的小文件已经全部移动到您在命令中指定的输出目录中:
$ hadoop fs -ls -R crusher-out
crusher-out/user/aholmes/crusher-dir/stocks1.txt
crusher-out/user/aholmes/crusher-dir/stocks10.txt
crusher-out/user/aholmes/crusher-dir/stocks2.txt
...
如果您没有使用--clone选项运行 filecrush,输入文件将保持完整,压缩文件将被写入输出目录。
输入和输出文件大小阈值
filecrush 如何确定文件是否需要被压缩?它会查看输入目录中的每个文件,并将其与块大小(或在 Hadoop 2 中,你在命令中指定的-Ddfs.block.size的大小)进行比较。如果文件小于块大小的 75%,它将被压缩。可以通过提供--threshold参数来自定义此阈值——例如,如果你想将值提高到 85%,你将指定--threshold 0.85。
类似地,filecrush 使用块大小来确定输出文件的大小。默认情况下,它不会创建占用超过八个块的输出文件,但可以通过--max-file-blocks参数进行自定义。
摘要
Filecrush 是一种简单快捷地将小文件合并在一起的方法。只要存在相关的输入格式和输出格式类,它就支持任何类型的输入或输出文件。不幸的是,它不与 Hadoop 2 兼容,而且过去几年中项目活动不多,因此这些点可能排除这个实用工具在你的环境中使用。
本技术中展示的示例在以下情况下效果良好:正在压缩的目录是一个外部 Hive 表,或者如果你正在对标准位置中的目录运行它,而集群中的其他用户预期你的数据将存在于该位置。
目前,filecrush 项目不与 Hadoop 2 兼容。如果你正在寻找 Hadoop 2 的解决方案,请查看我目前正在github.com/alexholmes/hdfscompact上开发的其他 HDFS 压缩器。
由于 filecrush 需要输入和输出格式,一个它表现不佳的用例是如果你正在处理二进制数据,并且需要一种方法来合并小二进制文件。
技巧 29 使用 Avro 存储多个小二进制文件
假设你正在从事一个类似于谷歌图片的项目,你从网站上爬取网页并下载图片文件。你的项目是互联网规模的,因此你正在下载数百万个文件,并将它们分别存储在 HDFS 中。你已经知道 HDFS 不适合处理大量的小文件,但你现在处理的是二进制数据,所以之前的技术不适合你的需求。
这种技术展示了你如何使用 Avro 作为 HDFS 中二进制数据的容器文件格式。
问题
你想在 HDFS 中存储大量二进制文件,并且在不触及 NameNode 内存限制的情况下做到这一点。
解决方案
在 HDFS 中处理小二进制文件的最简单方法是将它们打包到一个更大的容器文件中。对于这个技巧,你将读取存储在本地磁盘上的目录中的所有文件,并将它们保存为 HDFS 中的一个单独的 Avro 文件。你还将了解如何使用 Avro 文件在 MapReduce 中处理原始文件的内容。
讨论
图 4.2 展示了这种技术的第一部分,其中你在 HDFS 中创建 Avro 文件。这样做可以减少 HDFS 中的文件数量,这意味着存储在 NameNode 内存中的数据更少,这也意味着你可以存储更多东西。
图 4.2. 在 Avro 中存储小文件可以使你存储更多。

Avro 是由 Hadoop 的创造者 Doug Cutting 发明的一种数据序列化和 RPC 库。Avro 具有强大的模式演变能力,使其在 SequenceFile 等竞争对手中具有优势。在第三章中详细介绍了 Avro 及其竞争对手。
查看以下列表中的 Java 代码,该代码将创建 Avro 文件.^([9])
⁹ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch4/SmallFilesWrite.java.
列表 4.1. 读取包含小文件的目录并生成 HDFS 中的单个 Avro 文件


压缩依赖
要运行本章中的代码,你需要在你的主机上安装 Snappy 和 LZOP 压缩编解码器。请参阅附录以获取有关如何安装和配置它们的详细信息。
让我们看看当你运行此脚本针对 Hadoop 的配置目录(将$HADOOP_CONF_DIR替换为包含你的 Hadoop 配置文件的目录)时会发生什么:
$ hip hip.ch4.SmallFilesWrite $HADOOP_CONF_DIR test.avro
/etc/hadoop/conf/ssl-server.xml.example: cb6f1b218...
/etc/hadoop/conf/log4j.properties: 6920ca49b9790cb...
/etc/hadoop/conf/fair-scheduler.xml: b3e5f2bbb1d6c...
...
看起来很有希望——让我们确保输出文件在 HDFS 中:
$ hadoop fs -ls test.avro
2011-08-20 12:38 /user/aholmes/test.avro
为了确保一切按预期工作,你也可以编写一些代码来从 HDFS 读取 Avro 文件并输出每个文件内容的 MD5 哈希值:^([10])
¹⁰ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch4/SmallFilesRead.java.

这段代码比写入简单。因为 Avro 将模式写入每个 Avro 文件,所以在反序列化过程中你不需要向 Avro 提供任何关于模式的信息。给这段代码试一下:

到目前为止,你已经在 HDFS 中有 Avro 文件。尽管本章是关于 HDFS 的,但接下来你很可能会想要处理你在 MapReduce 中编写的文件。让我们看看如何做到这一点,编写一个只包含 map 的 MapReduce 作业,它可以读取 Avro 记录作为输入,并输出包含文件名和文件内容 MD5 哈希值的文本文件,如图 4.3 所示。
图 4.3. 将 Map 作业映射到读取 Avro 文件并输出文本文件

下一个列表显示了此 MapReduce 作业的代码.^([11])
¹¹ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch4/SmallFilesMapReduce.java.
列表 4.2. 一个以包含小文件的 Avro 文件为输入的 MapReduce 作业


如果你在这个之前创建的 Avro 文件上运行这个 MapReduce 作业,作业日志文件将包含你的文件名和哈希值:

在这种技术中,假设你正在处理一个无法将单独文件连接在一起的文件格式(例如图像文件)。如果你的文件可以连接,你应该考虑这个选项。如果你选择这条路,尽量确保文件大小至少与 HDFS 块大小一样大,以最小化存储在 NameNode 中的数据。
摘要
你本可以使用 Hadoop 的 SequenceFile 作为存储小文件的机制。SequenceFile 是一种更成熟的技术,它的历史比 Avro 文件更长。但是 SequenceFiles 是 Java 特定的,并且它们不提供与 Avro 相同的丰富互操作性和版本语义。
Google 的 Protocol Buffers 以及 Apache Thrift(起源于 Facebook)也可以用来存储小文件。但它们都没有与原生 Thrift 或 Protocol Buffers 文件一起工作的输入格式。
你还可以使用将文件写入 zip 文件的方法。这种方法的不利之处首先在于,你必须编写一个自定义输入格式^([12])来处理 zip 文件,其次在于 zip 文件是不可分割的(与 Avro 文件和 SequenceFiles 相反)。这可以通过生成多个 zip 文件并尝试使它们接近 HDFS 块大小来缓解。
¹² 自 2008 年以来,一直有一个请求 zip 输入格式实现的工单;请参阅
issues.apache.org/jira/browse/MAPREDUCE-210。
Hadoop 还有一个CombineFileInputFormat,可以将多个输入拆分(跨越多个文件)输入到单个 map 任务中,这大大减少了运行所需的 map 任务数量。
你还可以创建一个包含所有文件的 tarball 文件,然后生成一个包含 tarball 文件在 HDFS 中的位置的单独文本文件。这个文本文件将被提供给 MapReduce 作业,mapper 将直接打开 tarball。但这种方法将绕过 Map-Reduce 中的局部性,因为 mapper 将被调度在包含文本文件的节点上执行,因此很可能需要从远程 HDFS 节点读取 tarball 块,从而产生不必要的网络 I/O。
Hadoop 存档文件(HARs)是专门为解决小文件问题而创建的 Hadoop 文件。它们是一个虚拟文件系统,位于 HDFS 之上。HAR 文件的不利之处在于,它们不能在 Map-Reduce 中对本地磁盘访问进行优化,并且不能压缩。
Hadoop 2 版本支持 HDFS 联邦,其中 HDFS 被划分为多个不同的命名空间,每个命名空间由一个单独的 NameNode 独立管理。实际上,这意味着将块信息保留在内存中的总体影响可以分散到多个 NameNode 上,从而支持更多的少量文件。Hortonworks 有一篇很好的博客文章,其中包含有关 HDFS 联邦的更多详细信息(“HDFS 联邦简介” [2011 年 8 月 23 日],hortonworks.com/an-introduction-to-hdfs-federation/)。
最后,MapR,它提供了一个 Hadoop 发行版,有自己的分布式文件系统,支持大量的小文件。使用 MapR 进行分布式存储对你的系统来说是一个很大的改变,因此你不太可能迁移到 MapR 来缓解 HDFS 中的这个问题。
你可能会遇到想要在 Hadoop 中处理少量文件的情况,直接使用它们会导致 NameNode 内存使用量增加,并且 MapReduce 作业运行缓慢。这项技术通过将小文件打包到更大的容器文件中来帮助你缓解这些问题。我选择 Avro 来使用这项技术,因为它支持可分割文件和压缩,以及其表达式的模式语言,这有助于版本控制。
如果你有相反的问题,即你的文件很大,你想要更有效地存储数据,会发生什么?在我们的 Hadoop 中关于压缩的覆盖(第 4.2 节)将在这些情况下帮助你。但在我们到达该部分之前,让我们继续关注数据组织,并发现一些关于如何在 HDFS 中原子性地移动数据的技巧。
4.1.5. 原子数据移动
活动如分区和压缩往往遵循类似的模式——它们在临时目录中生成输出文件,然后一旦所有输出文件都成功放置,就需要将它们原子性地移动到最终目的地。这可能会引发一些问题:
-
你使用什么触发器来确定你准备好执行原子移动?
-
你如何在 HDFS 中原子性地移动数据?
-
你的数据移动对最终数据的任何读者有什么影响?
可能会诱使你在 MapReduce 驱动程序中作为一个后处理步骤执行原子移动,但如果客户端进程在 Map-Reduce 应用程序完成之前死亡,会发生什么?这就是在 Hadoop 中使用OutputCommitter有用的地方,因为你可以将任何原子文件移动作为作业的一部分执行,而不是使用驱动程序。OutputCommitter的一个示例在第 3.5.2 节中展示。
接下来的问题是您如何在 HDFS 中进行原子性数据移动。长期以来,人们认为 DistributedFileSystem 类(HDFS 的具体实现支持)上的 rename 方法是原子的。但事实并非如此,在某些情况下,这并不是一个原子操作。这在 HADOOP-6240 中得到了修复,但由于向后兼容性的原因,rename 方法没有被更新。因此,rename 方法仍然不是真正原子的;相反,您需要使用一个新的 API。如您所见,代码相当繁琐,并且仅适用于 Hadoop 的新版本:
DistributedFileSystem fs = (DistributedFileSystem) FileSystem.get(new Configuration());
fs.rename(src, dest, Options.Rename.NONE);
HDFS 缺少的一项功能是原子性地交换目录的能力。这在诸如压缩等场景中非常有用,比如需要替换由其他进程(如 Hive)使用的目录的全部内容。有一个名为“原子目录交换操作”的开放 JIRA 工单(issues.apache.org/jira/browse/HDFS-5902),希望未来能提供这项功能。
将这里讨论的点纳入您系统的设计中非常重要。如果您使用第三方实用程序或库,尝试确定它是否进行原子性数据移动。
这就结束了我们对数据组织技术的探讨。让我们转向 Hadoop 中另一个重要的数据管理主题——数据压缩。
4.2 使用压缩进行高效存储
数据压缩是一种将数据压缩成更紧凑的形式以节省存储空间并提高数据传输效率的机制。压缩是处理文件的重要方面,当处理 Hadoop 支持的数据大小时,这一点尤为重要。使用 Hadoop 的目标是尽可能高效地处理数据,选择合适的压缩编解码器可以使您的作业运行得更快,并允许您在集群中存储更多的数据。^([13)]
^([13)] 压缩编解码器是一种能够读取和写入特定压缩格式的编程实现。
技巧 30 选择适合您数据的压缩编解码器
使用 HDFS 进行压缩并不像在 ZFS 等文件系统上那样透明,尤其是在处理可以分割的压缩文件时(关于这一点将在本章后面详细说明)。与 Avro 和 SequenceFile 等文件格式一起工作时,其内置的压缩支持使得压缩对用户来说几乎完全透明。但是,当与文本等文件格式一起工作时,您就失去了这种便利。
^([14)] ZFS,即 Z 文件系统,是由 Sun Microsystems 开发的一种文件系统,它提供了一些创新功能来增强数据完整性。
问题
您希望评估和确定最适合您数据使用的压缩编解码器。
解决方案
Snappy,来自谷歌的压缩编解码器,提供了压缩大小和读写执行时间的最佳组合。但 LZOP 是处理必须支持可分割性的大压缩文件时最佳的编解码器。
讨论
让我们从快速查看 Hadoop 中可用的压缩编解码器开始,如 表 4.1 所示。
表 4.1. 压缩编解码器
| 编解码器 | 背景 |
|---|---|
| Deflate | Deflate 与 zlib 类似,这是与 gzip 使用相同的压缩算法,但没有 gzip 头部。 |
| gzip | gzip 文件格式由一个头部和一个主体组成,其中包含一个 Deflate 压缩的有效负载。 |
| bzip2 | bzip2 是一种节省空间的压缩编解码器。 |
| LZO | LZO 是一种基于块的压缩算法,允许压缩数据被分割。 |
| LZOP | LZOP 是带有额外头部的 LZO。曾经,LZO/LZOP 与 Hadoop 一起捆绑提供,但由于 GPL 许可证限制,它们已经被移除。 |
| LZ4 | LZ4 是基于与 LZO 相同压缩算法的快速衍生版本。 |
| Snappy | Snappy (code.google.com/p/hadoop-snappy/) 是 Hadoop 编解码器选项中的最新成员。它是谷歌的开源压缩算法。谷歌在 MapReduce 和 BigTable 中使用它进行数据压缩。a Snappy 的主要缺点是它不可分割。如果你正在处理支持分割的文件格式,如 Avro 或 Parquet,或者你的文件大小小于或等于你的 HDFS 块大小,你可以忽略这个缺点。 |
^a BigTable 是谷歌的专有数据库系统;参见 Fay Chang 等人,“Bigtable:一种用于结构化数据的分布式存储系统”,
research.google.com/archive/bigtable.html。
为了正确评估编解码器,你首先需要指定你的评估标准,这些标准应该基于功能和性能特性。对于压缩,你的标准可能包括以下内容:
-
空间/时间权衡 —一般来说,计算成本更高的压缩编解码器会产生更好的压缩比率,从而产生更小的压缩输出。
-
可分割性 —一个压缩文件能否被分割以供多个映射器使用?如果一个压缩文件不能被分割,那么只有一个映射器能够处理它。如果该文件跨越多个块,你将失去数据局部性,因为映射器可能不得不从远程数据节点读取块,从而产生网络 I/O 的开销。
-
本地压缩支持 —是否存在执行压缩和解压缩的本地库?这通常会比没有底层本地库支持的用 Java 编写的压缩编解码器表现更好。
表 4.2 比较了目前可用的压缩编解码器(我们将在下一节中介绍空间/时间比较)。
表 4.2. 压缩编解码器比较
| Codec | 扩展名 | 许可证 | 可分割 | 仅 Java 压缩支持 | 原生压缩支持 |
|---|---|---|---|---|---|
| Deflate | .deflate | zlib | 否 | 是 | 是 |
| gzip | .gz | GNU GPL | 否 | 是 | 是 |
| bzip2 | .gz | BSD | 是 ^([a]) | 是 | 是^([b]) |
| LZO | .lzo_deflate | GNU GPL | 否 | 否 | 是 |
| LZOP | .lzo | GNU GPL | 是 ^([c]) | 否 | 是 |
| LZ4 | .lz4 | 新 BSD | 否 | 否 | 是 |
| Snappy | .gz | 新 BSD | 否 | 否 | 是 |
^a Hadoop 2 及其 1.1.0 版本及以后的 Java 版本 bzip2 支持分割(参见
issues.apache.org/jira/browse/HADOOP-4012)。原生版本目前不支持分割。^b 原生 bzip2 支持是在 Hadoop 2.1 中添加的(参见
issues.apache.org/jira/browse/HADOOP-8462)。^c LZOP 文件本身不支持分割。您需要预处理它们以创建索引文件,然后由相应的 CompressionCodec 实现使用该索引文件来确定文件分割。我们将在技术 32 中介绍如何实现这一点。
原生与 Java bzip2
Hadoop 最近添加了对 bzip2 的原生支持(从 2.0 版本和 1.1.0 版本开始)。原生 bzip2 支持是默认的,但它不支持分割性。如果您需要与 bzip2 一起使用分割性,您需要启用 Java bzip2,可以通过设置 io.compression.codec.bzip2.library 为 java-builtin 来指定。
现在您已经了解了编解码器,它们在空间/时间权衡方面表现如何?我使用了一个 100 MB(10⁸)的维基百科 XML 文件(来自 mattmahoney.net/dc/textdata.html 的 enwik8.zip),来比较编解码器的运行时间和它们的压缩大小。这些测试的结果可以在 表 4.3 中看到。
表 4.3. 100 MB 文本文件上压缩编解码器的性能比较
| Codec | 压缩时间(秒) | 解压缩时间(秒) | 压缩文件大小 | 压缩百分比 |
|---|---|---|---|---|
| Deflate | 9.21 | 1.02 | 36,548,921 | 36.55% |
| gzip | 9.09 | 0.90 | 36,548,933 | 36.55% |
| bzip2 (Java) | 47.33 | 6.45 | 29,007,274 | 29.01% |
| bzip2 (native) | 11.59 | 4.66 | 29,008,758 | 29.01% |
| LZO | 2.10 | 0.46 | 53,926,001 | 53.93% |
| LZOP | 2.09 | 0.45 | 53,926,043 | 53.93% |
| LZ4 | 1.72 | 0.28 | 57,337,587 | 57.34% |
| Snappy | 1.75 | 0.29 | 58,493,673 | 58.49% |
运行自己的测试
当您进行自己的评估时,我建议您使用自己的数据,并在尽可能与您的生产节点相似的主机上运行测试。这样,您将能够很好地了解编解码器的预期压缩和运行时间。
确保您的集群已启用原生编解码器。您可以通过运行以下命令来检查:
$ hadoop checknative -a
图 4.4 以条形图形式显示了压缩大小。
图 4.4. 单个 100 MB 文本文件的压缩文件大小(值越小越好)

图 4.5 以条形图形式显示了压缩时间。这些时间将根据硬件的不同而有很大差异,这里只提供这些数据,以给出它们之间相互关系的概念。
图 4.5. 单个 100 MB 文本文件的压缩和解压缩时间(值越小越好)

空间和时间结果告诉你什么?如果你将尽可能多地挤压数据到你的集群作为首要任务,并且你可以忍受较长的压缩时间,那么 bzip2 可能是你正确的编解码器。如果你想压缩你的数据,但在读取和写入压缩文件时引入最少的 CPU 开销,你应该看看 LZ4。任何寻求压缩和执行时间之间平衡的人都必须从图中排除 Java 版本的 bzip2。
能够分割你的压缩文件很重要,在这里你必须在 bzip2 和 LZOP 之间做出选择。原生的 bzip2 编解码器不支持分割,而 Java bzip2 的时间可能会让大多数人犹豫。bzip2 比 LZOP 的唯一优势是它的 Hadoop 集成比 LZOP 更容易处理。尽管 LZOP 在这里自然是赢家,但它需要一些努力才能与之合作,正如你在技术 32 中将看到的。
摘要
适合你的最佳编解码器将取决于你的标准。如果你不介意分割你的文件,LZ4 是最有希望的编解码器;如果你想得到可分割的文件,你应该看看 LZOP。
另一个需要考虑的因素是数据长期存储保留所需的时间。如果你要保留数据很长时间,你可能希望最大化文件的压缩,对于这一点,我会推荐基于 zlib 的编解码器(如 gzip)。由于 gzip 不可分割,因此明智的做法是将其与基于块的文件格式(如 Avro 或 Parquet)结合使用,以便你的数据仍然可以分割。或者,你可以调整你的输出大小,使它们在 HDFS 中占用单个块,这样可分割性就不再是问题。
请记住,压缩大小将根据你的文件是文本还是二进制以及其内容而有所不同。为了获得准确的数据,你应该对你的数据进行类似的测试。
在 HDFS 中压缩数据有许多好处,包括减少文件大小和加快 MapReduce 作业的运行时间。Hadoop 中有多个编解码器可供使用,我根据功能和性能对它们进行了评估。现在你准备好开始使用压缩了。让我们看看你如何压缩文件以及如何使用 MapReduce、Pig 和 Hive 等工具使用它们。
技术篇 31 使用 HDFS、MapReduce、Pig 和 Hive 进行压缩
由于 HDFS 没有提供内置的压缩支持,因此在 Hadoop 中使用压缩可能是一个挑战。责任在于你自己找出如何处理压缩文件。此外,可分割的压缩对于新手来说可能有些困难,因为它不是 Hadoop 的默认功能。15 如果你处理的是压缩后接近 HDFS 块大小的中等大小文件,这种技术将是获得 Hadoop 压缩优势最快、最简单的方法。
[9] 技术上,你可以直接使用 bzip2 获得可分割的压缩,但如本节前面所示,其性能特性使其不适合作为主要的压缩编解码器。
问题
你想在 HDFS 中读取和写入压缩文件,并使用 Map-Reduce、Pig 和 Hive。
解决方案
在 MapReduce 中处理压缩文件需要更新 MapReduce 配置文件 mapred-site.xml 并注册你使用的压缩编解码器。完成此操作后,在 MapReduce 中处理压缩输入文件无需额外步骤,生成压缩 MapReduce 输出只需设置mapred.output.compress和mapred.output.compression.codec MapReduce 属性。
讨论
第一步是确定如何使用本章前面评估的任何编解码器读取和写入文件。本章详细介绍的编解码器都包含在 Hadoop 中,除了 LZO/LZOP 和 Snappy,所以如果你想使用这三个,你需要自己下载和构建它们(我将在本节后面带你了解如何使用 LZO/LZOP)。
要使用压缩编解码器,你需要知道它们的类名,这些类名在表 4.4 中列出。
表 4.4. 编解码器类
| 编解码器 | 类 | 默认扩展名 |
|---|---|---|
| Deflate | org.apache.hadoop.io.compress.DeflateCodec | deflate |
| gzip | org.apache.hadoop.io.compress.GzipCodec | gz |
| bzip2 | org.apache.hadoop.io.compress.BZip2Codec | bz2 |
| LZO | com.hadoop.compression.lzo.LzoCodec | lzo_deflate |
| LZOP | com.hadoop.compression.lzo.LzopCodec | lzo |
| LZ4 | org.apache.hadoop.io.compress.Lz4Codec | lz4 |
| Snappy | org.apache.hadoop.io.compress.SnappyCodec | snappy |
在 HDFS 中使用压缩
你将如何使用前面表中提到的任何编解码器压缩 HDFS 中的现有文件?以下代码支持这样做:[10]
[11] GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch4/CompressedFileWrite.java。
![164fig01_alt.jpg]
编解码器缓存
使用压缩编解码器的一个开销是它们可能很昂贵来创建。当你使用 Hadoop 的ReflectionUtils类时,与创建实例相关的部分反射开销将被缓存到ReflectionUtils中,这应该会加快后续编解码器的创建。更好的选择是使用CompressionCodecFactory,它提供了编解码器的缓存。
读取这个压缩文件就像写入它一样简单:[12])
[13] GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch4/CompressedFileRead.java.

非常简单。现在你能够创建压缩文件了,让我们看看如何在 MapReduce 中处理它们。
在 MapReduce 中使用压缩
要在 MapReduce 中处理压缩文件,你需要为你的作业设置一些配置选项。为了简洁起见,让我们假设在这个例子中使用了身份映射器和 reducer[18]):[19]
[14] 一个身份任务是指它将接收到的所有输入作为输出发射,没有任何转换或过滤。
[15] GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch4/CompressedMapReduce.java.

与未压缩的 I/O 相比,MapReduce 作业的唯一区别是前一个示例中的三个注解行。
不仅一个工作的输入和输出可以被压缩,中间映射输出也可以,因为它首先被写入磁盘,然后最终通过网络发送到 reducer。压缩映射输出的有效性最终将取决于被发射的数据类型,但作为一个一般规则,你应该看到通过这个改变而使工作速度有所提升。
为什么在前面的代码中你不需要指定输入文件的压缩编解码器?默认情况下,FileInputFormat类使用CompressionCodecFactory来确定输入文件扩展名是否与已注册的编解码器匹配。如果它找到一个与该文件扩展名关联的编解码器,它将自动使用该编解码器来解压缩输入文件。
MapReduce 是如何知道使用哪些编解码器的?你需要指定 mapred-site.xml 中的编解码器。以下代码显示了如何注册我们评估的所有编解码器。记住,除了 gzip、Deflate 和 bzip2 之外,所有压缩编解码器都需要在你可以注册它们之前在你的集群上构建并使其可用:
<property>
<name>io.compression.codecs</name>
<value>
org.apache.hadoop.io.compress.GzipCodec,
org.apache.hadoop.io.compress.DefaultCodec,
org.apache.hadoop.io.compress.BZip2Codec,
com.hadoop.compression.lzo.LzoCodec,
com.hadoop.compression.lzo.LzopCodec,
org.apache.hadoop.io.compress.SnappyCodec
</value>
</property>
<property>
<name>
io.compression.codec.lzo.class
</name>
<value>
com.hadoop.compression.lzo.LzoCodec
</value>
</property>
现在您已经掌握了 MapReduce 中的压缩,是时候查看 Hadoop 堆栈中的更高层次了。因为压缩也可以与 Pig 和 Hive 一起使用,让我们看看您如何使用 Pig 和 Hive 来复制您的 MapReduce 压缩成就。(正如我将在第九章中展示的,Hive 是一种高级语言,它抽象了一些 MapReduce 的复杂细节。)
在 Pig 中使用压缩
如果您使用 Pig,则无需额外努力即可使用压缩输入文件。您需要确保文件扩展名映射到适当的压缩编解码器(见表 4.4)。以下示例将本地密码文件 gzip 压缩,然后将其加载到 Pig 中,并导出用户名:

将 gzip 文件写入的过程相同——确保指定压缩编解码器的扩展名。以下示例将 Pig 关系B的结果存储在 HDFS 中的一个文件中,然后将它们复制到本地文件系统以检查内容:
grunt> STORE B INTO 'passwd-users.gz';
# Ctrl+C to break out of Pig shell
$ hadoop fs -get passwd-users.gz/part-m-00000.gz .
$ gunzip -c part-m-00000.gz
root
bin
daemon
...
这很简单——让我们希望事情在 Hive 中同样顺利。
在 Hive 中使用压缩
与 Pig 类似,您只需在定义文件名时指定编解码器扩展名:

之前的示例将一个 gzip 压缩的文件加载到 Hive 中。在这种情况下,Hive 将正在加载的文件移动到 Hive 的仓库目录中,并继续使用原始文件作为表的存储。
如果您想创建另一个表并指定它应该被压缩,以下示例通过设置一些 Hive 配置来启用 MapReduce 压缩(因为最后一条语句将执行 MapReduce 作业来加载新表)来实现这一点:
hive> SET hive.exec.compress.output=true;
hive> SET hive.exec.compress.intermediate = true;
hive> SET mapred.output.compression.codec =
org.apache.hadoop.io.compress.GzipCodec;
hive> CREATE TABLE apachelog_backup (...);
hive> INSERT OVERWRITE TABLE apachelog_backup SELECT * FROM apachelog;
您可以通过在 HDFS 中查看它来验证 Hive 确实正在压缩新创建的 apachelog_backup 表的存储:
$ hadoop fs -ls /user/hive/warehouse/apachelog_backup
/user/hive/warehouse/apachelog_backup/000000_0.gz
应注意,Hive 建议使用 SequenceFile 作为表的输出格式,因为 SequenceFile 块可以单独压缩。
摘要
该技术提供了一种快速简单的方法来在 Hadoop 中启用压缩。它适用于不太大的文件,因为它提供了一种相当透明的方式来处理压缩。
如果您的压缩文件大小远大于 HDFS 块大小,请继续阅读,了解可以分割文件的压缩技术。
技巧 32:使用 MapReduce、Hive 和 Pig 的 Splittable LZOP
假设您正在处理大型文本文件,即使压缩后,其大小也远大于 HDFS 块大小。为了避免一个 map 任务处理整个大型压缩文件,您需要选择一个可以支持分割该文件的压缩编解码器。
LZOP 符合要求,但与之前技术示例中的操作相比,使用 LZOP 更为复杂,因为 LZOP 本身并不是可分割的。“等等,”你可能正在想,“你之前不是说过 LZOP 是可分割的吗?”LZOP 是基于块的,但你不能在 LZOP 文件中随机查找并确定下一个块的起始点。这正是我们将在这个技术中解决的问题。
问题
你希望使用一种压缩编解码器,使得 MapReduce 可以在单个压缩文件上并行工作。
解决方案
在 MapReduce 中,将大型的 LZOP 压缩输入文件分割需要使用 LZOP 特定的输入格式类,例如LzoInputFormat。在 Pig 和 Hive 中处理 LZOP 压缩输入文件时,同样适用这个原则。
讨论
LZOP 压缩编解码器是仅有的两个允许分割压缩文件并因此允许多个 reducer 并行工作的编解码器之一。另一个编解码器 bzip2,其压缩时间非常慢,以至于可以说该编解码器不可用。LZOP 在压缩和速度之间提供了一个良好的折衷方案。
LZO 和 LZOP 之间的区别是什么?
LZO 和 LZOP 编解码器都可用于与 Hadoop 一起使用。LZO 是一种基于流的压缩存储,它没有块或头部的概念。LZOP 有块的概念(这些块是经过校验和的),因此是你要使用的编解码器,尤其是如果你想使压缩输出可分割时。令人困惑的是,Hadoop 编解码器默认将文件扩展名为.lzo 的文件视为 LZOP 编码,将文件扩展名为.lzo_deflate 的文件视为 LZO 编码。此外,大部分文档似乎都将 LZO 和 LZOP 互换使用。
为 LZOP 准备你的集群
不幸的是,由于许可原因,Hadoop 没有捆绑 LZOP。^([20)]
[16](https://issues.apache.org/jira/browse/HADOOP-4874) LZOP 曾经包含在 Hadoop 中,但由于 LZOP 的 GPL 许可限制了其再分发,它在 Hadoop 0.20 版本及更高版本中被移除。
在你的集群上编译和安装所有先决条件是一项繁重的工作,但请放心,附录中有详细的说明。要编译和运行本节中的代码,你需要遵循附录中的说明。
在 HDFS 中读取和写入 LZOP 文件
我们在第 4.2 节中介绍了如何读取和写入压缩文件。要使用 LZOP 执行相同的活动,你需要指定代码中的 LZOP 编解码器。以下列表显示了此代码。^([21)]
[17](https://github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch4/LzopFileReadWrite.java) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch4/LzopFileReadWrite.java。
列表 4.3. 在 HDFS 中读取和写入 LZOP 文件的方法
public static Path compress(Path src,
Configuration config)
throws IOException {
Path destFile =
new Path(
src.toString() +
new LzopCodec().getDefaultExtension());
LzopCodec codec = new LzopCodec();
codec.setConf(config);
FileSystem hdfs = FileSystem.get(config);
InputStream is = null;
OutputStream os = null;
try {
is = hdfs.open(src);
os = codec.createOutputStream(hdfs.create(destFile));
IOUtils.copyBytes(is, os, config);
} finally {
IOUtils.closeStream(os);
IOUtils.closeStream(is);
}
return destFile;
}
public static void decompress(Path src, Path dest,
Configuration config)
throws IOException {
LzopCodec codec = new LzopCodec();
codec.setConf(config);
FileSystem hdfs = FileSystem.get(config);
InputStream is = null;
OutputStream os = null;
try {
is = codec.createInputStream(hdfs.open(src));
os = hdfs.create(dest);
IOUtils.copyBytes(is, os, config);
} finally {
IOUtils.closeStream(os);
IOUtils.closeStream(is);
}
}
让我们写一个 LZOP 文件并读取它,然后确保 LZOP 工具可以与生成的文件一起工作(将 $HADOOP_CONF_HOME 替换为你 Hadoop 配置目录的位置):
$ hadoop fs -put $HADOOP_CONF_DIR/core-site.xml core-site.xml
$ hip hip.ch4.LzopFileReadWrite core-site.xml
上述代码将在 HDFS 中生成一个 core-site.xml.lzo 文件。
现在确保你可以使用这个 LZOP 文件与 lzop 二进制文件一起使用。在你的主机上安装一个 lzop 二进制文件。^([22)] 将 LZOP 文件从 HDFS 复制到本地磁盘,使用本地的 lzop 二进制文件解压缩,并与原始文件进行比较:
[^([22])] 对于 RedHat 和 Centos,你可以从
pkgs.repoforge.org/lzop/lzop-1.03-1.el5.rf.x86_64.rpm安装 rpm。
$ hadoop fs -get core-site.xml.lzo /tmp/core-site.xml.lzo
$ lzop -l /tmp/core-site.xml.lzo
method compressed uncompr. ratio uncompressed_name
LZO1X-1 454 954 47.6% core-site.xml
# cd /tmp
$ lzop -d core-site.xml.lzo
$ ls -ltr
-rw-r--r-- 1 aholmes aholmes 954 May 5 09:05 core-site.xml
-rw-r--r-- 1 aholmes aholmes 504 May 5 09:05 core-site.xml.lzo
$ diff core-site.xml $HADOOP_CONF_DIR/conf/core-site.xml
$
diff 命令验证了使用 LZOP 编解码器压缩的文件可以用 lzop 二进制文件解压缩。
现在你有了你的 LZOP 文件,你需要对其进行索引以便它可以被分割。
为你的 LZOP 文件创建索引
之前我做出了一个矛盾的陈述,即 LZOP 文件可以被分割,但它们不是原生可分割的。让我澄清一下这意味着什么——缺乏块定界同步标记意味着你不能在 LZOP 文件中进行随机查找并开始读取块。但是因为内部确实使用了块,你需要的只是一个预处理步骤,它可以生成包含块偏移量的索引文件。
LZOP 文件被完整读取,并且随着读取的进行,块偏移量被写入索引文件。索引文件格式,如 图 4.6 所示,是一个包含一系列连续 64 位数字的二进制文件,这些数字表示 LZOP 文件中每个块的字节偏移量。
图 4.6. LZOP 索引文件是一个包含一系列连续 64 位数字的二进制文件。

你可以通过以下两种方式之一创建索引文件,如以下两个代码片段所示。如果你想为单个 LZOP 文件创建索引文件,这里有一个简单的库调用,这将为你完成这项工作:
shell$ hadoop com.hadoop.compression.lzo.LzoIndexer core-site.xml.lzo
如果你有大量 LZOP 文件并且想要一种更有效的方法来生成索引文件,以下选项将工作得很好。索引器运行一个 MapReduce 作业来创建索引文件。支持文件和目录(递归扫描以查找 LZOP 文件):
shell$ hadoop \
com.hadoop.compression.lzo.DistributedLzoIndexer \
core-site.xml.lzo
图 4.6 中展示的两种方法都会在 LZOP 文件相同的目录下生成索引文件。索引文件的名称是原始 LZOP 文件名后缀为 .index。运行前面的命令会产生文件名为 core-site.xml.lzo.index。
现在,让我们看看你如何在 Java 代码中使用 LzoIndexer。以下代码(来自 LzoIndexer 的 main 方法)将导致索引文件同步创建:
LzoIndexer lzoIndexer = new LzoIndexer(new Configuration());
for (String arg: args) {
try {
lzoIndexer.index(new Path(arg));
} catch (IOException e) {
LOG.error("Error indexing " + arg, e);
}
使用DistributedLzoIndexer,MapReduce 作业将以N个 mapper 启动和运行,每个 mapper 对应一个.lzo 文件。不会运行任何 reducer,因此通过自定义的LzoSplitInputFormat和LzoIndexOutputFormat,(身份)mapper 直接写入索引文件。如果您想从自己的 Java 代码中运行 MapReduce 作业,您可以使用DistributedLzoIndexer代码作为示例。
您需要 LZOP 索引文件,以便您可以在 MapReduce、Pig 和 Hive 作业中分割 LZOP 文件。现在您已经拥有了上述 LZOP 索引文件,让我们看看您如何可以使用它们与 MapReduce 一起使用。
MapReduce 和 LZOP
在您为 LZOP 文件创建了索引文件之后,是时候开始使用您的 LZOP 文件与 MapReduce 一起使用了。不幸的是,这带我们来到了下一个挑战:现有的所有内置基于 Hadoop 文件的输入格式都无法与可分割的 LZOP 一起工作,因为它们需要专门的逻辑来处理使用 LZOP 索引文件进行输入分割。您需要特定的输入格式类来与可分割的 LZOP 一起工作。
LZOP 库为具有伴随索引文件的行定向 LZOP 压缩文本文件提供了一个LzoTextInputFormat实现。^([23])
^([23]) LZOP 输入格式也与没有索引文件的 LZOP 文件很好地工作。
以下代码显示了配置 MapReduce 作业以与 LZOP 一起工作的步骤。您将为具有文本 LZOP 输入和输出的 MapReduce 作业执行这些步骤:^([24])
^([24]) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch4/LzopMapReduce.java。
job.setInputFormatClass(LzoTextInputFormat.class);
job.setOutputFormatClass(TextOutputFormat.class);
job.getConfiguration().setBoolean("mapred.output.compress", true);
job.getConfiguration().setClass("mapred.output.compression.codec",
LzopCodec.class, CompressionCodec.class);
压缩中间映射输出也将加快您 MapReduce 作业的整体执行时间:
conf.setBoolean("mapred.compress.map.output", true);
conf.setClass("mapred.map.output.compression.codec",
LzopCodec.class,
CompressionCodec.class);
您可以轻松配置您的集群始终压缩您的映射输出,通过编辑 hdfs-site.xml:
<property>
<name>mapred.compress.map.output</name>
<value>true</value>
</property>
<property>
<name>mapred.map.output.compression.codec</name>
<value>com.hadoop.compression.lzo.LzopCodec</value>
</property>
每个 LZOP 文件的分片数量是文件占用的 LZOP 块数量的函数,而不是文件占用的 HDFS 块数量的函数。
现在我们已经涵盖了 MapReduce,让我们看看 Pig 和 Hive 如何与可分割的 LZOP 一起工作。
Pig 和 Hive
Elephant Bird,^([25)),一个包含用于与 LZOP 一起工作的实用工具的 Twitter 项目,提供了一些有用的 MapReduce 和 Pig 类。Elephant Bird 有一个LzoPigStorage类,可以在 Pig 中与基于文本的、LZOP 压缩的数据一起工作。
^([25]) 有关 Elephant Bird 的更多详细信息,请参阅附录。
Hive 可以通过使用 LZO 库中找到的com.hadoop.mapred.DeprecatedLzoTextInputFormat输入格式类来与 LZOP 压缩的文本文件一起工作。
摘要
在 Hadoop 中处理可分割压缩很棘手。如果您有幸能够将数据存储在 Avro 或 Parquet 中,它们提供了处理可以轻松压缩和分割的文件的最简单方法。如果您想压缩其他文件格式并且需要它们被分割,LZOP 是唯一真正的候选者。
如我之前提到的,Elephant Bird 项目提供了一些有用的 LZOP 输入格式,这些格式可以与 LZOP 压缩文件格式(如 XML 和平文)一起使用。如果你需要处理 LZOP 项目或 Elephant Bird 不支持的其他 LZOP 压缩文件格式,你必须编写自己的输入格式。这对开发者来说是一个巨大的障碍。我希望 Hadoop 在某个时刻能够支持具有自定义拆分逻辑的压缩文件,这样最终用户就不必为压缩编写自己的输入格式。
在资源总是稀缺的任何生产环境中,压缩可能是一个硬性要求。压缩还允许计算作业的执行时间更快,因此它是存储的一个有吸引力的方面。在前一节中,我展示了如何评估和选择最适合你的数据的编解码器。我们还涵盖了如何使用 HDFS、MapReduce、Pig 和 Hive 进行压缩。最后,我们解决了可拆分 LZOP 压缩的棘手问题。
4.3. 章节总结
以大量小文件形式存在的大数据在 HDFS 中暴露出了一些限制,在本章中,我们通过探讨如何将小文件打包成更大的 Avro 容器来解决这个问题。
压缩是任何大型集群的关键部分,我们评估并比较了不同的压缩编解码器。我根据各种标准推荐了编解码器,并展示了如何在 Map-Reduce、Pig 和 Hive 中使用压缩文件以及如何与这些压缩文件一起工作。我们还探讨了如何使用 LZOP 实现压缩以及通过多个输入拆分实现快速计算。
本章和上一章致力于探讨选择合适的文件格式以及如何在 MapReduce 和 HDFS 中有效地处理大数据的技术。现在是时候应用这些知识,看看如何将数据移动到 Hadoop 中及从中移出了。这将在下一章中介绍。
第五章. 将数据移动到 Hadoop 中及从中移出
本章涵盖
-
理解数据导入和导出工具的关键设计考虑因素
-
将数据移动到 Hadoop 中及从中移出的低级方法
-
将日志文件、关系型数据库和 NoSQL 数据,以及 Kafka 中的数据在 HDFS 中移动的技术
数据移动是那些你不太可能过多思考的事情之一,直到你完全承诺在一个项目中使用 Hadoop,这时它变成了一个巨大的、令人恐惧的未知数,必须去应对。你如何将分布在不同数千个主机上的日志数据移动到 Hadoop 中?将你的数据从关系型数据库和 No/NewSQL 系统中高效地移动到 Hadoop 中有什么最佳方法?你如何将 Hadoop 中生成的 Lucene 索引移动到你的服务器上?以及如何自动化这些流程?
欢迎来到第五章,目标是回答这些问题,并让你走上无忧无虑的数据移动之路。在本章中,你将首先看到如何将来自广泛位置和格式的数据移动到 Hadoop 中,然后你将看到如何将数据从 Hadoop 中移动出去。
本章首先强调关键的数据移动属性,以便你在阅读本章的其余部分时可以评估各种工具的适用性。接着,它将探讨用于移动数据的低级和高级工具。我们将从一些简单的技术开始,例如使用命令行和 Java 进行入口,^([1]) 但很快我们将转向更高级的技术,如使用 NFS 和 DistCp。
¹ 入口和出口分别指数据进入和离开系统。
一旦低级工具被排除在外,我们将调查高级工具,这些工具简化了将数据传输到 Hadoop 的过程。我们将探讨如何使用 Flume 自动化日志文件的移动,以及如何使用 Sqoop 移动关系型数据。为了不忽略一些新兴的数据系统,你还将了解到可以将数据从 HBase 和 Kafka 移动到 Hadoop 的方法。
本章将涵盖大量内容,你可能会遇到需要处理的具体数据类型。如果情况如此,请直接跳转到提供所需详细信息的部分。
让我们从查看关键入口和出口系统考虑因素开始。
5.1. 数据移动的关键要素
在 Hadoop 中移动大量数据会带来物流挑战,包括一致性保证以及对数据源和目的地的资源影响。然而,在我们深入探讨技术之前,我们需要讨论在设计元素方面你应该注意的事项,当你在处理数据移动时。
幂等性
幂等操作无论执行多少次都会产生相同的结果。在关系型数据库中,插入通常不是幂等的,因为多次执行不会产生相同的数据库状态。另一方面,更新通常是幂等的,因为它们会产生相同的结果。
任何数据写入时,都应该考虑幂等性,Hadoop 中的数据入口和出口也不例外。分布式日志收集框架处理数据重传的能力如何?你如何在多个任务并行向数据库插入的 MapReduce 作业中确保幂等行为?我们将在本章中探讨并回答这些问题。
聚合
数据聚合过程结合多个数据元素。在数据导入的背景下,这可能很有用,因为将大量小文件移动到 HDFS 可能会转化为 NameNode 内存问题,以及 MapReduce 执行时间变慢。能够聚合文件或数据可以减轻这个问题,并且是一个值得考虑的功能。
数据格式转换
数据格式转换过程将一种数据格式转换为另一种格式。通常,您的源数据可能不是 Map-Reduce 等工具处理的最理想格式。例如,如果您的源数据是多行 XML 或 JSON 形式,您可能需要考虑一个预处理步骤。这将把数据转换成可以分割的形式,比如每行一个 JSON 或 XML 元素,或者转换成 Avro 等格式。第三章包含了关于这些数据格式的更多详细信息。
压缩
压缩不仅有助于减少静态数据的大小,而且在读取和写入数据时也有 I/O 优势。
可用性和可恢复性
可恢复性允许在操作失败的情况下,导入或导出工具重试。由于任何数据源、接收器或 Hadoop 本身都不太可能达到 100%的可用性,因此,在失败的情况下重试导入或导出操作非常重要。
可靠的数据传输和数据验证
在数据传输的背景下,检查正确性是您验证数据在传输过程中没有发生数据损坏的方法。当您与像 Hadoop 数据导入和导出这样的异构系统一起工作时,数据需要在不同的主机、网络和协议之间传输的事实,只会增加数据传输过程中出现问题的可能性。检查原始数据(如存储设备)正确性的常见方法是对称冗余检查(CRCs),这是 HDFS 内部用于维护块级完整性的方法。
此外,源数据本身可能由于生成数据的软件中的错误而存在问题。在导入时进行这些检查允许您进行一次性检查,而不是处理所有下游数据消费者,这些消费者必须更新以处理数据中的错误。
资源消耗和性能
资源消耗和性能分别是系统资源利用率和系统效率的衡量标准。除非您有可观的的数据量,否则导入和导出工具通常不会对系统造成显著的负载(资源消耗)。对于性能,需要问的问题包括工具是否并行执行导入和导出活动,如果是的话,它提供了什么机制来调整并行度。例如,如果您的数据源是生产数据库,您正在使用 MapReduce 导入数据,请不要使用大量的并发映射任务来导入数据。
监控
监控确保在自动化系统中函数按预期执行。对于数据入口和出口,监控分为两个要素:确保参与入口和出口的过程处于活跃状态,并验证源和目标数据是否按预期产生。监控还应包括验证正在移动的数据量是否处于预期水平;数据量的意外下降或上升会提醒您潜在的系统问题或软件中的错误。
投机执行
MapReduce 有一个名为 投机执行 的功能,在作业接近结束时启动重复任务,用于仍在执行的任务。这有助于防止慢速硬件影响作业执行时间。但是,如果您正在使用地图任务将数据插入到关系数据库中,例如,您应该意识到您可能有两个并行过程插入相同的数据.^([2])
在 Hadoop 2 中,可以通过
mapreduce.map.speculative和mapreduce.reduce.speculative配置项来禁用地图和减少侧的投机执行。
接下来是技术。让我们从如何利用 Hadoop 内置的入口机制开始。
5.2. 将数据移动到 Hadoop
在 Hadoop 中处理数据的第一个步骤是使其对 Hadoop 可用。有两种主要方法可以将数据移动到 Hadoop:在 HDFS 层面上写入外部数据(数据推送),或在 MapReduce 层面上读取外部数据(更像是拉取)。在 MapReduce 中读取数据具有操作易于并行化和容错的优势。然而,并非所有数据都可以从 MapReduce 访问,例如日志文件,这时需要依赖其他系统进行传输,包括 HDFS 进行最终的数据跳跃。
在本节中,我们将探讨将源数据移动到 Hadoop 的方法。我将使用上一节中的设计考虑因素作为检查和理解不同工具的标准。
我们将从查看一些可以用于将数据移动到 Hadoop 的低级方法开始。
5.2.1. 自行构建摄入
Hadoop 随带了许多将数据导入 HDFS 的方法。本节将探讨这些内置工具的各种用法,以满足您的数据移动需求。您可以使用的第一个可能也是最容易的工具是 HDFS 命令行。
选择合适的摄入工具以完成任务
本节中的低级工具适用于一次性文件移动活动,或当与基于文件的旧数据源和目标一起工作时。但是,随着 Flume 和 Kafka 等工具(本章后面将介绍)的可用性,以这种方式移动数据正迅速变得过时,这些工具提供了自动数据移动管道。
Kafka 是从 A 到 B 获取数据(B 可以是一个 Hadoop 集群)的一个比传统的“让我们复制文件!”更好的平台。使用 Kafka,你只需将数据泵入 Kafka,就有能力实时(例如通过 Storm)或离线/批量作业(例如通过 Camus)消费数据。
基于文件的摄取流程,至少对我来说,是过去的遗迹(因为每个人都知道 scp 的工作原理 😛),它们主要存在于遗留原因——上游数据源可能已有现有工具来创建文件快照(例如数据库的转储工具),并且没有基础设施来迁移或移动数据到实时消息系统,如 Kafka。
技巧 33 使用 CLI 加载文件
如果你有一个需要手动执行的活动,例如将本书附带示例移动到 HDFS,那么 HDFS 命令行界面(CLI)就是你的工具。它将允许你执行在常规 Linux 文件系统上执行的大多数操作。在本节中,我们将重点介绍将数据从本地文件系统复制到 HDFS 的操作。
问题
你想使用 shell 将文件复制到 HDFS 中。
解决方案
HDFS 命令行界面可用于一次性移动,或者可以将其集成到脚本中以进行一系列移动。
讨论
将文件从本地磁盘复制到 HDFS 使用 hadoop 命令完成:
$ hadoop fs -put local-file.txt hdfs-file.txt
Hadoop -put 命令的行为与 Linux cp 命令不同——在 Linux 中,如果目标已存在,则会覆盖它;在 Hadoop 中,复制操作会失败并显示错误:
put: `hdfs-file.txt': File exists
必须添加 -f 选项来强制覆盖文件:
$ hadoop fs -put -f local-file.txt hdfs-file.txt
与 Linux cp 命令类似,可以使用相同的命令复制多个文件。在这种情况下,最后一个参数必须是本地文件要复制到的 HDFS 目录:
$ hadoop fs -put local-file1.txt local-file2.txt /hdfs/dest/
你还可以使用 Linux 管道将命令的输出重定向到 HDFS 文件中——使用相同的 -put 命令,并在其后添加一个单独的连字符,这告诉 Hadoop 从标准输入读取输入:
$ echo "the cat sat on the mat" | hadoop fs -put - hdfs-file.txt
要测试文件或目录是否存在,请使用 -test 命令并选择 -e 或 -d 选项分别测试文件或目录的存在性。如果文件或目录存在,命令的退出码为 0,如果不存在,则为 1:
$ hadoop fs -test -e hdfs-file.txt
$ echo $?
1
$ hadoop fs -touchz hdfs-file.txt
$ hadoop fs -test -e hdfs-file.txt
$ echo $?
0
$ hadoop fs -test -d hdfs-file.txt
$ echo $?
1
如果你只想在 HDFS 中“touch”一个文件(创建一个新空文件),那么 touchz 选项就是你要找的:
$ hadoop fs -touchz hdfs-file.txt
fs 命令支持许多更多操作——要查看完整列表,请在没有任何选项的情况下运行该命令:
$ hadoop fs
Usage: hadoop fs [generic options]
[-appendToFile <localsrc> ... <dst>]
[-cat [-ignoreCrc] <src> ...]
[-checksum <src> ...]
[-chgrp [-R] GROUP PATH...]
[-chmod [-R] <MODE[,MODE]... | OCTALMODE> PATH...]
[-chown [-R] [OWNER][:[GROUP]] PATH...]
[-copyFromLocal [-f] [-p] <localsrc> ... <dst>]
[-copyToLocal [-p] [-ignoreCrc] [-crc] <src> ... <localdst>]
[-count [-q] <path> ...]
[-cp [-f] [-p] <src> ... <dst>]
[-createSnapshot <snapshotDir> [<snapshotName>]]
[-deleteSnapshot <snapshotDir> <snapshotName>]
[-df [-h] [<path> ...]]
[-du [-s] [-h] <path> ...]
[-expunge]
[-get [-p] [-ignoreCrc] [-crc] <src> ... <localdst>]
[-getmerge [-nl] <src> <localdst>]
[-help [cmd ...]]
[-ls [-d] [-h] [-R] [<path> ...]]
[-mkdir [-p] <path> ...]
[-moveFromLocal <localsrc> ... <dst>]
[-moveToLocal <src> <localdst>]
[-mv <src> ... <dst>]
[-put [-f] [-p] <localsrc> ... <dst>]
[-renameSnapshot <snapshotDir> <oldName> <newName>]
[-rm [-f] [-r|-R] [-skipTrash] <src> ...]
[-rmdir [--ignore-fail-on-non-empty] <dir> ...]
[-setrep [-R] [-w] <rep> <path> ...]
[-stat [format] <path> ...]
[-tail [-f] <file>]
[-test -[defsz] <path>]
[-text [-ignoreCrc] <src> ...]
[-touchz <path> ...]
[-usage [cmd ...]]
命令行界面(CLI)是为交互式 HDFS 活动设计的,它也可以集成到脚本中以自动化一些任务。CLI 的缺点是它是低级的,并且没有内置任何自动化机制,所以如果你有这个目标,你可能需要另寻他法。它还需要为每个命令进行一次分叉,如果你在 bash 脚本中使用它,这可能没问题,但如果你试图将 HDFS 功能集成到 Python 或 Java 应用程序中,这很可能不是你想要的。在这种情况下,为每个命令启动外部进程的开销,以及启动和与外部进程交互的脆弱性,可能是你想要避免的。
下一个技术更适合在 Python 等编程语言中与 HDFS 一起工作。
技术篇 34 使用 REST 加载文件
CLI 对于快速运行命令和脚本来说很方便。然而,它需要为每个命令分叉一个单独的进程,这是你可能想要避免的开销,尤其是如果你正在用编程语言与 HDFS 交互。本技术涵盖了在 Java(将在下一节中介绍)之外的语言中与 HDFS 一起工作。
问题
你希望能够从没有 HDFS 原生接口的编程语言中与 HDFS 交互。
解决方案
使用 Hadoop 的 WebHDFS 接口,它为 HDFS 操作提供了一个功能齐全的 REST API。
讨论
在你开始之前,你需要确保你的集群启用了 WebHDFS(默认情况下是未启用的)。这由dfs.webhdfs.enabled属性控制。如果它没有启用,你需要更新 hdfs-site.xml 并添加以下内容:
<property>
<name>dfs.webhdfs.enabled</name>
<value>true</value>
</property>
在这个技术中,我们将介绍在未加密的 Hadoop 集群上运行 WebHDFS。[18] 如果你在一个安全的 Hadoop 集群上工作,你不会提供user.name参数;相反,你将在与 WebHDFS 交互之前使用kinit通过 Kerberos 进行身份验证,然后在 curl 命令行中提供--negotiate -u:youruser。
³ 在一个未加密的 Hadoop 集群(默认设置),任何用户都可以伪装成集群中的另一个用户。这对于 WebHDFS 来说尤其成问题,因为它直接在 URL 中暴露用户名,这使得输入其他用户的名称变得非常简单。Hadoop 的安全机制 Kerberos 将防止这种情况发生,因为它要求用户在与 Hadoop 交互之前通过 LDAP 或 Active Directory 进行身份验证。
警告:在未加密的集群上运行 WebHDFS
如果在一个关闭了安全性的集群中启用了 WebHDFS,那么它可以很容易地用来以任意用户身份在集群中运行命令(只需在 URL 中更改用户名即可)。建议你只在启用了安全性的情况下运行 WebHDFS。
因为您使用 HTTP 与 NameNode 通信,所以您需要知道 NameNode RPC 服务正在运行的宿主和端口。这通过 dfs.namenode.http-address 属性进行配置。在伪分布式设置中,这最可能是设置为 0.0.0.0:50070。我们将假设伪分布式设置用于本技术的其余部分——替换您设置中适当的主机和端口。
您可以使用 CLI 在 HDFS 中创建文件:
$ echo "the cat sat on the mat" | hadoop fs -put - /tmp/hdfs-file.txt
您可以使用 WebHDFS 获取有关文件的各种有趣元数据(在以下 URL 中的 aholmes 替换为您的用户名):
$ curl -L "http://0.0.0.0:50070/webhdfs/v1/tmp/hdfs-file.txt?
op=GETFILESTATUS&user.name=aholmes"
{"FileStatus":{
"accessTime":1389106989995,
"blockSize":134217728,
"childrenNum":0,
"fileId":21620,
"group":"supergroup",
"length":23,
"modificationTime":1389106990223,
"owner":"aholmes",
"pathSuffix":"",
"permission":"644",
"replication":1,
"type":"FILE"
}}
命令的语法由两部分组成:首先是路径,然后是执行的操作。您还需要提供要执行操作的用户的用户名;否则,HDFS 将假设您是一个具有受限访问权限的匿名用户。图 5.1 强调了 URL 路径的这些部分。
图 5.1. 解构 WebHDFS URL 路径

从 HDFS 读取文件只需指定 OPEN 作为操作:
$ curl -L "http://0.0.0.0:50070/webhdfs/v1/tmp/hdfs-file.txt?
op=OPEN&user.name=aholmes"
the cat sat on the mat
使用 WebHDFS 写入文件是一个两步过程。第一步通知 NameNode 您要创建新文件的意图。您可以通过 HTTP PUT 命令来完成:
$ echo "the cat sat on the mat" > local.txt
$ curl -i -X PUT "http://0.0.0.0:50070/webhdfs/v1/tmp/new-file.txt?
op=CREATE&user.name=aholmes"
HTTP/1.1 307 TEMPORARY_REDIRECT
...
Location: http://localhost.localdomain:50075/webhdfs/v1/tmp/
new-file.txt?op=CREATE&user.name=aholmes&
namenoderpcaddress=localhost:8020&overwrite=false
...
在这一点上,文件尚未写入——您只是给了 NameNode 确定您将写入哪个 DataNode 的机会,这已在响应中的“Location”标题中指定。您需要获取该 URL,然后发出第二个 HTTP PUT 来执行实际的写入:
$ curl -i -X PUT -T local.txt \
"http://localhost.localdomain:50075/webhdfs/v1/tmp/new-file.txt?
op=CREATE&user.name=aholmes&namenoderpcaddress=localhost:8020
&overwrite=false"
您可以通过读取文件来验证写入是否成功:
$ hadoop fs -cat /tmp/new-file.txt
the cat sat on the mat
WebHDFS 支持您使用常规命令行执行的所有 HDFS 操作,^([4]) 并且更有用,因为它以结构化的 JSON 形式提供对元数据的访问,这使得解析数据更容易。
⁴ 请参阅 WebHDFS REST API 页面 (
hadoop.apache.org/docs/stable/hadoop-project-dist/hadoop-hdfs/WebHDFS.html),了解您可以执行的全部操作集。
值得注意的是,WebHDFS 提供的一些附加功能。首先,文件的第一块具有数据局部性。NameNode 将客户端重定向到托管第一块数据的 DataNode,为您提供强大的数据局部性。对于文件中的后续块,DataNode 充当代理,并将数据从持有块数据的节点流式传输到节点。
WebHDFS 也集成了 Hadoop 的安全认证,这意味着您可以在 HTTP 请求中启用 Kerberos 并使用委托令牌。此外,API 将在 Hadoop 版本之间保持网络级别的兼容性,这意味着您今天发出的命令将与 Hadoop 的未来版本(反之亦然)兼容。这对于访问运行不同版本 Hadoop 的多个集群是一个有用的工具。
有几个项目提供了多种语言的 WebHDFS 库(列于表 5.1 中),以便您更容易地开始使用它.^([5])
⁵ 事实上,为了利用 WebHDFS,在 Hadoop 2 中编写了一个新的 C 客户端,名为 libwebhdfs。请参阅
issues.apache.org/jira/browse/HDFS-2656。
表 5.1. WebHDFS 库
| 语言 | 链接 |
|---|---|
| C | libwebhdfs (与 Hadoop 一起打包) |
| Python | github.com/drelu/webhdfs-py |
| Ruby | github.com/kzk/webhdfs rubygems.org/gems/webhdfs |
| Perl | search.cpan.org/~afaris/Apache-Hadoop-WebHDFS-0.04/lib/Apache/Hadoop/WebHDFS.pm |
当客户端可以访问所有 NameNodes 和数据 Nodes 时,WebHDFS 非常有用。在受限环境中,情况可能并非如此,你可能需要考虑使用 HttpFS。
技巧 35 从防火墙后面访问 HDFS
生产环境中的 Hadoop 通常会被锁定以保护这些集群中的数据。安全程序的一部分可能包括将您的集群置于防火墙之后,如果您试图从防火墙外部读取或写入 HDFS,这将会是一个麻烦。这项技术探讨了 HttpFS 网关,它可以通过 HTTP(通常在防火墙上开放)提供对 HDFS 的访问。
问题
你想向 HDFS 写入数据,但有一个防火墙限制了访问 NameNode 和/或 DataNodes。
解决方案
使用 HttpFS 网关,这是一个独立的服务器,它通过 HTTP 提供对 HDFS 的访问。因为它是一个独立的服务,并且使用 HTTP,所以它可以配置在任何可以访问 Hadoop 节点的宿主上运行,并且你可以开启防火墙规则以允许流量访问该服务。
讨论
HttpFS 非常有用,因为它不仅允许你使用 REST 访问 HDFS,而且还拥有完整的 Hadoop 文件系统实现,这意味着你可以使用 CLI 和本地的 HDFS Java 客户端与 HDFS 进行通信,如图 5.2 所示。figure 5.2。
图 5.2. HttpFS 网关架构

要使 HttpFS 运行起来,您需要指定一个代理用户。这个用户将运行 HttpFS 进程,并且这个用户也将被配置在 Hadoop 中作为代理用户。

一旦您对 core-site.xml 进行了更改,您将不得不重启 Hadoop。接下来,您需要启动 HttpFS 进程:
$ sbin/httpfs.sh start
现在,您可以使用与之前技术中相同的 curl 命令来使用 WebHDFS。这是 HttpFS 网关的一个优点——语法完全相同。要在根目录上执行目录列表,您将执行以下操作:
$ curl -i "http://localhost:14000/webhdfs/v1/?user.name=poe&
op=LISTSTATUS"
HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Set-Cookie: hadoop.auth="u=poe&p=poe&t=simple&e=13...
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 10 Jan 2014 00:09:00 GMT
{"FileStatuses":{"FileStatus":[
{"pathSuffix":"input",
"type":"DIRECTORY","length":0,"owner":"aholmes",
"group":"supergroup","permission":"755","accessTime":0,
"modificationTime":1388197364463,"blockSize":0,"replication":0},
{"pathSuffix":"tmp","type":"DIRECTORY","length":0,"owner":"aholmes",
"group":"supergroup","permission":"755","accessTime":0,
"modificationTime":1389108717540,"blockSize":0,"replication":0},
{"pathSuffix":"user","type":"DIRECTORY","length":0,"owner":"aholmes",
"group":"supergroup","permission":"755","accessTime":0,
"modificationTime":1388199399483,"blockSize":0,"replication":0}]}}
与您在之前技术中使用的 curl 命令相比,唯一的区别是端口号。HttpFS 默认运行在端口 14000 上,但可以通过编辑httpfs-env.sh来更改。文件中可以更改的一些有趣属性在表 5.2 中显示。
表 5.2. HttpFS 属性
| 属性 | 默认值 | 描述 |
|---|---|---|
| HTTPFS_HTTP_PORT | 14000 | HttpFS 监听的 HTTP 端口。 |
| HTTPFS_ADMIN_PORT | 14001 | HttpFS 的管理端口。 |
| HTTPFS_LOG | ${HTTPFS_HOME}/logs | HttpFS 的日志目录。 |
| HTTPFS_HTTP_HOSTNAME | hostname -f |
用于确定 HttpFS 运行在哪个主机上的命令。此信息传递给 NameNode,以便它可以将其与您在 core-site.xml 中之前配置的 hadoop.proxyuser.${user}.hosts 的值进行比较。 |
在 httpfs-site.xml 中还可以配置额外的 Kerberos 和用户及组级别设置。6
⁶ 请参考“HttpFS 配置属性”网页
hadoop.apache.org/docs/stable/hadoop-hdfs-httpfs/httpfs-default.html。
WebHDFS 和 HttpFS 之间的差异
WebHDFS 和 HttpFS 之间的主要区别是客户端对所有数据节点的可访问性。如果您的客户端可以访问所有数据节点,那么 WebHDFS 将适用于您,因为读写文件涉及客户端直接与数据节点进行数据传输。另一方面,如果您在防火墙后面,您的客户端可能无法访问所有数据节点,在这种情况下,HttpFS 选项将最适合您。使用 HttpFS 时,服务器将与数据节点通信,而您的客户端只需要与单个 HttpFS 服务器通信。
如果您有选择,请选择 WebHDFS,因为客户端直接与数据节点通信具有固有的优势——它允许您轻松地跨多个主机扩展并发客户端的数量,而不会遇到所有数据通过 HttpFS 服务器流过的网络瓶颈。这尤其适用于您的客户端在数据节点上运行的情况,因为您将通过直接从本地文件系统流式传输任何本地托管的 HDFS 数据块,而不是通过网络,来使用 WebHDFS 的数据本地化优势。
技巧 36 使用 NFS 挂载 Hadoop
如果 Hadoop 数据可以通过常规挂载访问你的文件系统,那么与 Hadoop 数据交互通常会容易得多。这允许你使用现有的脚本、工具和编程语言,以及与 HDFS 中的数据交互。本节将探讨如何使用 NFS 挂载轻松地将数据复制到 HDFS 和从 HDFS 中复制出来。
问题
你希望将 HDFS 视为一个常规的 Linux 文件系统,并使用标准的 Linux 工具与 HDFS 交互。
解决方案
使用 Hadoop 的 NFS 实现来访问 HDFS 中的数据。
讨论
在 Hadoop 2.1 之前,唯一可以 NFS 挂载 HDFS 的方式是使用 FUSE。由于各种性能和可靠性问题,它不建议用于通用用途。它还引入了额外的负担,即需要在任何客户端机器上安装驱动程序(换句话说,它没有提供 NFS 网关)。
Hadoop 中新的 NFS 实现解决了旧 FUSE 系统所有的不足。它是一个正确的 NFSv3 实现,并允许你运行一个或多个 NFS 网关以增加可用性和吞吐量。
图 5.3 显示了各种 Hadoop NFS 组件正在运行。
图 5.3. Hadoop NFS

要启动 NFS 服务,你首先需要停止主机上运行的 NFS 服务。在 Linux 系统上,可以使用以下命令实现:
$ service portmap stop
$ service nfs stop
$ service rpcbind stop
接下来你需要启动 Hadoop NFS 服务。你将启动的第一个服务是 portmap,它为协议及其相关的传输和端口提供注册服务。它运行在受限端口上,因此需要以 root 用户身份启动:
$ sudo hadoop-daemon.sh start portmap
接下来你需要启动实际的 NFS 服务。重要的是运行此服务的用户必须与运行 HDFS 的用户相同:
$ hadoop-daemon.sh start nfs3
通过运行rpcinfo和showmount来验证服务是否正在运行——你应该看到类似于以下输出的内容:
$ /usr/sbin/rpcinfo -p localhost
program vers proto port
100005 1 tcp 4242 mountd
100000 2 udp 111 portmapper
100005 3 tcp 4242 mountd
100005 2 udp 4242 mountd
100003 3 tcp 2049 nfs
100000 2 tcp 111 portmapper
100005 3 udp 4242 mountd
100005 1 udp 4242 mountd
100005 2 tcp 4242 mountd
$ /usr/sbin/showmount -e localhost
Export list for localhost:
/ *
现在你需要在主机上的一个目录上挂载 HDFS。在下面的示例中,我选择了/hdfs 作为挂载目录。第二个挂载命令验证挂载已创建:
$ sudo mkdir /hdfs
$ sudo mount -t nfs -o vers=3,proto=tcp,nolock localhost:/ /hdfs
$ mount | grep hdfs
localhost:/ on /hdfs type nfs (rw,nfsvers=3,proto=tcp,nolock,
addr=127.0.0.1)
你已经准备好了!现在你可以直接使用挂载的文件系统来操作 HDFS。
在使用 NFS 网关时,有一些事情需要考虑:
-
HDFS 是一个只允许追加的文件系统。你可以向文件追加内容,但不能执行随机写入。如果你有严格的要求,需要使用支持随机写入的文件系统来与 Hadoop 一起工作,你应该看看 MapR 的 Hadoop 发行版。
-
Hadoop 2.2 版本不支持安全的 Hadoop(Kerberos),并且有一个开放的任务单来添加该支持.^([7])
⁷ 在 NFS 网关中跟踪 Kerberos 支持的任务单是
issues.apache.org/jira/browse/HDFS-5539。 -
代理用户的支持直到 Hadoop 2.4(或 3)才可用。这基本上意味着 Hadoop 的早期版本将执行所有命令作为超级用户,因为 NFS 网关需要以与 HDFS 相同的用户身份运行。
由于这些限制,建议将 NFS 网关保留用于实验用途,或者用于用户级安全不是关注点的单个租户集群。
技术编号 37:使用 DistCp 在集群内部和之间复制数据
假设你有一大批数据想要移动到或从 Hadoop 中移动。在本节的大部分技术中,你有一个瓶颈,因为你正在通过单个主机(即运行进程的主机)将数据通过管道传输。为了尽可能优化数据移动,你想要利用 MapReduce 并行复制数据。这就是 DistCp 发挥作用的地方,这项技术探讨了你可以使用 DistCp 在 Hadoop 集群之间以及到和从 NFS 挂载中高效复制数据的几种方法。
问题
你想要在 Hadoop 集群之间高效地复制大量数据,并且具有增量复制的功能。
解决方案
使用内置在 Hadoop 中的并行文件复制工具 DistCp。
讨论
在本节中,我们将首先介绍 DistCp 的重要配置方面。之后,我们将探讨你想要使用 DistCp 的具体场景,以及配置和运行它的最佳方式。
DistCp 版本 2
这项技术涵盖了 Hadoop 2 中可用的 DistCp 的新版本,称为 DistCp 2。此代码被回滚到 Hadoop 1.2.0,可以通过使用 distcp2 作为命令来使用——在 Hadoop 2 中,它替换了现有的 DistCp,因此可以使用正常的 distcp 命令。
DistCp 2 支持与旧版 DistCp 相同的命令行参数集,但它带来了一系列有用的优势:
-
当处理大量文件时,减少了设置和执行时间,因为驱动程序不再需要预处理所有输入(现在已推迟到映射器)。
-
现在它有一个完整的 Java 接口,并消除了 Java 客户端将参数序列化为字符串的需求。
-
原子提交允许所有或无的复制语义。
-
使用
-update选项跳过目标中已存在的文件将导致文件属性发生变化,如果它们与源文件不同。 -
空目录不再在复制过程中被跳过。
DistCp 使用仅映射的 MapReduce 作业来执行复制。以下是一个非常简单的示例,它在一个单独的 Hadoop 集群中使用,将源目录 /hello 复制到目标目录 /world:
$ hadoop distcp /hello /world
此命令将在不存在的情况下创建 /world 目录,然后将 /hello 目录(及其所有文件和子目录)的内容复制到 /world。你可能想知道 DistCp 如何处理目标中已存在的文件——继续阅读以获取详细信息。
处理已存在的目标文件
目标中已存在的文件和目录保持不变(即使文件不同)。你可以通过添加 表 5.3 中显示的参数来更改此行为。
表 5.3. 影响文件复制位置以及目标文件是否已存在的 DistCp 参数
| 参数 | 描述 |
|---|---|
无(既不使用-update也不使用-overwrite) |
如果目标已存在,则永远不会重新复制源文件。 |
| -update | 如果以下任何一项为真,则重新复制源文件:
-
源文件和目标文件的大小不同。
-
源文件和目标文件的 CRC 不匹配.^([a])
^a 可以使用
-skipcrccheck参数关闭文件 CRC 检查。 -
源文件和目标文件的块大小不匹配。
|
| -overwrite | 如果目标文件已存在,则始终重新复制源文件。 |
|---|
您可以通过查看作业完成后输出到标准输出的 SKIP 计数器来查看跳过的文件数量:
org.apache.hadoop.tools.mapred.CopyMapper$Counter
BYTESSKIPPED=24
SKIP=2
关于-update和-overwrite参数的另一个需要理解的因素是,它们微妙地改变了复制行为。没有这些选项时,如果源是目录,则该目录将在目标目录下创建。使用-update或-overwrite参数之一时,仅复制文件和子目录,而不是源目录。这最好通过以下示例来说明:
# create a source directory and file
$ hadoop fs -mkdir /src
$ hadoop fs -touchz /src/file1.txt
# create a destination directory
$ hadoop fs -mkdir /dest
# run a distcp without any options
$ hadoop distcp /src /dest
$ hadoop fs -ls -R /dest
/dest/src
/dest/src/file1.txt
# now run the same command again with
# the -update argument
$ hadoop distcp -update /src /dest
$ hadoop fs -ls -R /dest
/dest/file1.txt
/dest/src
/dest/src/file1.txt
忽略错误
当您使用 DistCp 复制大量文件时,使用-i标志执行命令以忽略错误是明智的。这样,单个错误不会导致您的整个复制过程失败,并且您可以通过重新发出带有-update选项的相同 DistCp 命令来重新尝试复制任何失败的文件。
动态复制策略
DistCp 的默认行为是为每个 mapper 平均分配所有文件的工作,以便所有 mapper 复制大约相同数量的字节。从理论上讲,这似乎是一种公平分配工作的好方法,但在现实中,由于不同的硬件、硬件错误和配置不当等因素,往往会导致长尾作业执行,其中少数几个落后 mapper 的执行时间比其他 mapper 长得多。
使用 DistCp 2,您可以使用一种替代策略,其中 mapper 直接获取工作,而不是预先分配。这被称为动态复制策略,可以通过使用-strategy dynamic参数来激活。添加此参数的净效果是提高了复制时间,因为速度较快的 mapper 可以弥补速度较慢 mapper 的不足。
原子提交
DistCp 2 中的另一个有用功能是原子提交的概念。DistCp 的默认行为是每个文件都写入一个临时文件,然后移动到最终目标。这意味着在作业中遇到错误之前,无法撤销已复制的任何文件。
因此,原子提交允许您将实际的“提交”推迟到作业结束时,此时所有文件都已复制,这样在遇到错误时就不会看到任何部分写入。此功能可以通过使用-atomic参数来启用。
并行性和 mapper 数量
目前 DistCp 最细粒度的作业单位是文件级别。因此,无论文件有多大,每个文件都只使用一个 mapper 进行复制。增加作业的 mapper 数量不会对加快复制速度有任何影响。
默认情况下,DistCp 使用 20 个 mapper,每个 mapper 复制的哪些文件由你选择的复制策略决定。Hadoop 开发者对 mapper 数量的默认设置进行了深思熟虑——选择正确的值取决于你想要利用多少网络带宽(将在下文讨论),以及你希望在复制过程中占用多少任务。
你可以通过指定-m后跟你的期望值来更改 mapper 的数量。
带宽
值得注意的是,在复制过程中使用的网络带宽。大容量复制可能会使集群之间的网络饱和并超负荷。为了保持你组织中网络操作人员的良好关系,可以使用-bandwidth参数来指定每个 map 任务在复制过程中消耗的带宽上限。此参数的值以每秒兆字节(MBps)为单位。
其他选项
到目前为止,我们已经探讨了 DistCp 中一些更有趣的选项。要查看完整的选项列表,可以在没有任何选项的情况下运行distcp命令,或者直接访问在线 Hadoop 文档hadoop.apache.org/docs/r1.2.1/distcp2.html。
从 NFS 挂载复制数据到 HDFS
如果你有一些文件位于文件系统或 NAS 上,并且想要将它们复制到 HDFS 中,DistCp 可能是一个不错的选择。这仅当所有 DataNode 都挂载数据时才有效,因为运行在 DataNode 上的 DistCp mapper 需要访问源和目标。以下示例显示了如何执行复制。注意用于告诉 Hadoop 使用本地文件系统作为源的file方案:
$ hadoop distcp file://my_filer/source /dest
在同一集群内复制数据
在什么情况下你会使用 DistCp 代替常规的hadoop fs -cp命令?常规的cp命令是一种单线程的复制数据方法——它逐个文件进行,从服务器流数据到客户端,然后再从客户端流回服务器。与之相比,DistCp 会启动一个 MapReduce 作业,使用多个 mapper 来执行复制。一般来说,当处理数十 GB 的数据时,应使用常规复制过程;当处理数百 GB 或更多数据时,应考虑使用 DistCp。
当同一个集群既是源也是目标时,不需要对源或目标进行特殊指定:
$ hadoop distcp /source /dest
在运行相同版本 Hadoop 的两个集群之间复制
现在,让我们看看在运行相同版本 Hadoop 的两个集群之间复制数据。这种方法通过使用 Hadoop 原生文件系统的读写操作来优化它们都运行相同版本 Hadoop 的事实,这强调了数据局部性。不幸的是,Hadoop RPC 对客户端和服务器版本相同的事实很敏感,因此如果版本不同,则此方法将不起作用。在这种情况下,你需要跳到下一个小节。
假设你有两个 HDFS 设置,一个运行在 nn1 上,另一个运行在 nn2 上,并且两个 NameNode 都运行在默认 RPC 端口上.^([8]) 在集群之间从 /source 到 /dest 目录复制文件可以使用以下命令:
⁸ 要确定每个 NameNode 的实际主机和端口,请检查 core-site.xml 中
fs.default.name或fs.defaultFS的值。
$ hadoop distcp hdfs://nn1:8020/source hdfs://nn2:8020/dest
当涉及到两个集群时,你可能想知道应该使用哪个集群来运行 DistCp。如果你在集群之间有一个防火墙,并且端口只能单向打开,那么你必须在具有对另一个集群的读取或写入访问权限的集群上运行作业。
接下来,让我们看看如何在运行不同 Hadoop 版本的集群之间运行 DistCp。
在运行不同版本 Hadoop 的集群之间复制
当你的集群运行不同版本的 Hadoop 时,先前的这种方法将不起作用。Hadoop 的 RPC 没有内置向前或向后兼容性,因此较新版本的 Hadoop 客户端无法与较旧版本的 Hadoop 集群通信,反之亦然。
使用最近版本的 Hadoop,你有两种复制选项:较旧的 HFTP 和较新的 WebHDFS。让我们首先看看传统方法,HFTP。
HFTP 是 HDFS 上的一个版本无关的接口,它使用 HTTP 作为传输机制。它提供了对 HDFS 的只读视图,因此从定义上讲,这意味着你将不得不始终将其用作 DistCp 中的源。它通过 NameNode URI 中的 hftp 方案启用,如下例所示:
$ hadoop distcp hftp://nn1:50070/source hdfs://nn2:8020/dest
查看 hdfs-site.xml(如果你在 hdfs-site.xml 中看不到它,请查看 hdfs-default.xml)以确定用于 HFTP 的主机和端口(特别是 dfs.http.port,如果未设置,则为 dfs.namenode.http-address)。如果你认为在传输过程中保护数据很重要,请考虑使用 HFTPS 方案,该方案使用 HTTPS 进行传输(配置或检查 dfs.hftp.https.port,如果未设置,则默认为 dfs.https.port)。
使用 HFTP(S),你必须在目标集群上运行 DistCp 命令,以便使用与目标相同的 Hadoop 客户端版本来写入 HDFS。但如果这在你的环境中过于受限——如果你的防火墙不允许你在目标上运行 DistCp,那该怎么办?这就是 WebHDFS 发挥作用的地方。
WebHDFS 比起 HFTP 的优势在于提供了读写接口。你可以将其用作 DistCp 中的源或目标,如下所示:
$ hadoop distcp hdfs://nn1:50070/source webhdfs://nn2:50070/dest
WebHDFS 具有额外的优势,即数据本地性——在读取和写入数据时使用 HTTP 重定向,以便使用实际存储数据的 DataNode 进行读写。强烈建议您使用 WebHDFS 而不是 HFTP,因为 WebHDFS 不仅具有出色的写入能力,而且性能也得到了提升。
检查dfs.namenode.http-address的值,以确定您应该与 WebHDFS 一起使用的宿主和端口。
其他目的地
DistCp 与任何 Hadoop 文件系统接口的实现都兼容;表 5.4 显示了与 Hadoop 捆绑的最流行的实现。
表 5.4. URI 方案及其相关的 Hadoop 文件系统实现
| Scheme | 详情 |
|---|---|
| hdfs | 提供对 Hadoop 自己的 HDFS 的本地访问。唯一的缺点是不支持前后兼容性。 |
| file | 用于从本地文件系统读取和写入。 |
| hftp 和 hsftp | 在 HDFS 之上提供了一种传统的只读视图,强调 API 兼容性以支持任何版本的 Hadoop。这是在运行不同版本 Hadoop 的集群之间复制数据的老式方法。hsftp 提供了一个使用 HTTPS 进行传输的实现,以增加安全性。 |
| webhdfs | 如果您的客户端可以访问 Hadoop 集群,则可以与 WebHDFS(见技术 34)一起使用,并且可以通过 HttpFS 网关(见技术 35)从防火墙后面访问 HDFS。这是 read-only hftp 实现的替代品。它支持对 HDFS 的读写接口。此外,此文件系统可以用于在不同版本的 Hadoop 之间读取和写入。 |
| ftp | 使用 FTP 作为存储实现。 |
| s3 和 s3n | 提供对 Amazon 的 S3 文件系统的访问。s3n 提供对 S3 的本地访问,而 s3 方案以块为基础存储数据,以绕过 S3 的最大文件大小限制。 |
摘要
DistCp 是一个在 Hadoop 文件系统之间移动数据的强大工具。增量复制等特性使其能够以近乎连续的方式使用,以同步两个系统上的目录。而且,它能够在不同版本的 Hadoop 之间复制数据,这意味着它是跨多个 Hadoop 集群同步数据的一种非常流行的方式。
执行 DistCp
当您运行 DistCp 命令时,建议您在 screen 会话中执行它,^([9]) 或者至少使用nohup将输出重定向到本地文件。
⁹ Screen 是一个 Linux 实用工具,用于管理虚拟外壳,并允许它们在父外壳终止后仍然持续存在。马特·库茨在他的网站上有一个关于“Screen 快速教程”的优秀概述,www.mattcutts.com/blog/a-quick-tutorial-on-screen/。
DistCp 的一个限制是它支持多个源目录,但只有一个目标目录。这意味着你不能使用单个 DistCp 作业在集群之间执行单向同步(除非你只需要同步单个目录)。在这种情况下,你可以运行多个 DistCp 作业,或者你可以运行一个作业并将数据同步到一个临时目录,然后使用fs -mv命令将临时文件移动到最终目标。
技术篇 38 使用 Java 加载文件
假设你已经在 HDFS 中生成了多个 Lucene 索引,并且你想将它们拉取到外部主机。也许,在拉取数据的过程中,你希望使用 Java 以某种方式操作文件。这项技术展示了如何使用 Java HDFS API 在 HDFS 中读取和写入数据。
问题
你想在 Java 应用程序中集成写入 HDFS 的功能。
解决方案
使用 Hadoop Java API 访问 HDFS 中的数据。
讨论
HDFS Java API 与 Java 的 I/O 模型很好地集成,这意味着你可以使用常规的InputStream和OutputStream进行 I/O 操作。为了执行文件系统级别的操作,如创建、打开和删除文件,Hadoop 有一个名为FileSystem的抽象类,它被扩展并实现为可以在 Hadoop 中利用的特定文件系统。
之前,在技术 33 中,你看到了一个例子,展示了如何使用命令行界面(CLI)将标准输入中的数据流式传输到 HDFS 中的文件:
$ echo "hello world" | hadoop fs -put - hdfs-file.txt
让我们探讨如何在 Java 中实现这一点。实现这一功能的代码主要有两个部分:获取FileSystem的句柄和创建文件,然后将数据从标准输入复制到OutputStream:

你可以通过运行以下命令来查看这段代码在实际中的工作方式:
$ echo "the cat" | hip hip.ch5.CopyStreamToHdfs --output test.txt
$ hadoop fs -cat test.txt
the cat
让我们回到代码中,了解它是如何工作的。以下代码片段用于获取FileSystem的句柄。但 Hadoop 是如何知道返回哪个具体文件系统的呢?
FileSystem fs = FileSystem.get(conf);
关键在于传递给get方法的conf对象。发生的情况是FileSystem类检查fs.defaultFS属性的值,该值包含一个 URI,用于标识应使用的文件系统。默认情况下,它配置为本地文件系统(file:///),这就是为什么如果你尝试在没有任何配置的情况下运行 Hadoop,你会使用本地文件系统而不是 HDFS。
在附录中描述的伪分布式设置中,你首先会做的事情之一是配置 core-site.xml 以使用 HDFS 文件系统:
<property>
<name>fs.default.name</name>
<value>hdfs://localhost:8020</value>
</property>
Hadoop 从 URL(前例中的hdfs)中获取方案,并执行查找以发现具体的文件系统。文件系统可以通过两种方式被发现:
-
内置的文件系统会自动被发现,并调用它们的
getScheme方法来确定它们的方案。在 HDFS 的例子中,实现类是org.apache.hadoop.hdfs.DistributedFileSystem,而getScheme方法返回hdfs。 -
没有内置到 Hadoop 中的文件系统可以通过更新 coresite.xml 中的
fs.$scheme.impl来识别,其中$scheme将被 URI 中识别的方案所替换。
FileSystem 类有多个用于操作文件系统的方法——一些更常用的方法在此列出:
static FileSystem get(Configuration conf)
static LocalFileSystem getLocal(Configuration conf)
static FSDataOutputStream create(FileSystem fs, Path file)
FSDataInputStream open(Path f, int bufferSize)
boolean delete(Path f, boolean recursive)
boolean mkdirs(Path f)
void copyFromLocalFile(Path src, Path dst)
void copyToLocalFile(Path src, Path dst)
FileStatus getFileStatus(Path f)
void close()
5.2.2. 将日志和二进制文件持续移动到 HDFS
日志数据长期以来在所有应用程序中都很普遍,但随着 Hadoop 的出现,处理生产系统产生的海量日志数据的能力也随之而来。从网络设备、操作系统到 Web 服务器和应用程序,各种系统都会产生日志数据。这些日志文件都提供了深入了解系统和应用程序如何运行以及如何被使用的宝贵见解。统一日志文件的是,它们通常以文本形式和面向行的方式存在,这使得它们易于处理。
在上一节中,我们介绍了您可以使用的方法来将数据复制到 Hadoop 的低级方法。而不是使用这些方法构建自己的数据移动工具,本节介绍了一些高级工具,这些工具简化了将日志和二进制数据移动到 Hadoop 的过程。像 Flume、Sqoop 和 Oozie 这样的工具提供了机制,可以定期(或持续)将数据从各种数据源(如文件、关系数据库和消息系统)移动到 Hadoop,并且它们已经解决了处理分布在不同主机上的多个数据源时遇到的许多难题。
让我们从查看 Flume 如何将日志文件导入 HDFS 开始。
偏好的数据移动方法
如果您在一个需要自动将文件移动到 HDFS 的受限传统环境中工作,本节中的技术效果很好。
另一种架构方案是使用 Kafka 作为传输数据的机制,这将允许您将生产者与消费者解耦,同时使多个消费者能够以不同的方式处理数据。在这种情况下,您将使用 Kafka 将数据加载到 Hadoop,并为实时数据流系统(如 Storm 或 Spark Streaming)提供数据源,然后您可以使用这些系统执行近实时计算。这种方案的一个场景是 Lambda 架构,它允许您以小增量实时计算聚合数据,并使用批量层执行错误纠正和添加新数据点等功能,从而发挥实时和批量系统的优势。
技巧 39 使用 Flume 将系统日志消息推送到 HDFS
多个应用程序和系统在多个服务器上生成大量的日志文件。毫无疑问,这些日志中包含着有价值的信息,但您的第一个挑战是将这些日志移动到您的 Hadoop 集群中,以便您可以进行一些分析。
版本说明
本节介绍了 Flume 的 1.4 版本。与所有软件一样,这里介绍的技术、代码和配置并不能保证在 Flume 的不同版本上都能直接使用。此外,Flume 1.4 版本需要一些更新才能与 Hadoop 2 版本兼容——有关更多详细信息,请参阅附录中的 Flume 部分。
问题
您希望将所有生产服务器的系统日志文件推送到 HDFS。
解决方案
对于这个技术,您将使用 Flume,一个数据收集系统,将 Linux 日志文件推送到 HDFS。
讨论
Flume 在本质上是一个日志文件收集和分发系统,收集系统日志并将它们传输到 HDFS 是它的本职工作。在这个技术步骤中,您的第一步将涉及捕获追加到 /var/log/messages 的所有数据并将其传输到 HDFS。您将运行一个单独的 Flume 代理(稍后将有更多关于这意味什么的详细信息),它将为您完成所有这些工作。
Flume 代理需要一个配置文件来告诉它要做什么,因此让我们为这个用例定义一个:
# define source, channel and sink
agent1.sources = tail_source1
agent1.channels = ch1
agent1.sinks = hdfs_sink1
# define tail source
agent1.sources.tail_source1.type = exec
agent1.sources.tail_source1.channels = ch1
agent1.sources.tail_source1.shell = /bin/bash -c
agent1.sources.tail_source1.command = tail -F /var/log/messages
agent1.sources.tail_source1.interceptors = ts
agent1.sources.tail_source1.interceptors.ts.type = timestamp
# define in-memory channel
agent1.channels.ch1.type = memory
agent1.channels.ch1.capacity = 100000
agent1.channels.ch1.transactionCapacity = 1000
# define HDFS sink properties
agent1.sinks.hdfs_sink1.type = hdfs
agent1.sinks.hdfs_sink1.hdfs.path = /flume/%y%m%d/%H%M%S
agent1.sinks.hdfs_sink1.hdfs.fileType = DataStream
agent1.sinks.hdfs_sink1.channel = ch1
我们将在稍后检查该文件的内容,但在做之前,让我们看看 Flume 的实际应用。
系统先决条件
为了使以下示例正常工作,您需要确保您正在使用一个可以访问 Hadoop 集群的宿主机(如果您需要启动一个集群,请参阅附录),并且您的 HADOOP_HOME 已经正确配置。您还需要下载并安装 Flume,并将 FLUME_HOME 设置为指向安装目录。
使用文件名 tail-hdfs-part1.conf 将前面的文件复制到您的 Flume 配置目录中。一旦完成,您就可以启动一个 Flume 代理实例:
$ ${FLUME_HOME}/bin/flume-ng agent \
--conf ${FLUME_HOME}/conf/ \
-f ${FLUME_HOME}/conf/tail-hdfs-part1.conf \
-Dflume.root.logger=DEBUG,console \
-n agent1
这应该会生成大量输出,但最终您应该看到类似于以下输出,表明一切正常:
Component type: CHANNEL, name: ch1 started
Exec source starting with command:tail -F /var/log/messages
Component type: SINK, name: hdfs_sink1 started
到目前为止,你应该开始在 HDFS 中看到一些数据出现:
$ hadoop fs -lsr /flume
/flume/140120/195155/FlumeData.1390265516304.tmp
.tmp 后缀表示 Flume 已经打开了文件,并将继续向其中写入。一旦完成,它将重命名文件并移除后缀:
/flume/140120/195155/FlumeData.1390265516304
您可以使用 cat 命令来检查文件内容——内容应该与 tail /var/log/messages 的输出一致。
如果您已经走到这一步,您已经使用 Flume 完成了您的第一次数据迁移!
分析 Flume 代理
让我们回顾一下您所做的工作。您的工作有两个主要部分:定义 Flume 配置文件,并运行 Flume 代理。Flume 配置文件包含有关您的 数据源、通道 和 接收器 的详细信息。这些都是影响 Flume 数据流不同部分的 Flume 概念。图 5.4 展示了这些概念在 Flume 代理中的实际应用。
图 5.4. 在代理上下文中展示的 Flume 组件

让我们逐步了解这些 Flume 概念,并查看它们的目的以及它们是如何工作的。
数据源
Flume 数据源 负责从外部客户端或其他 Flume 源读取数据。Flume 中的数据单元定义为 事件,它本质上是一个有效载荷和可选的元数据集。Flume 数据源将这些事件发送到一个或多个 Flume 通道,这些通道处理存储和缓冲。
Flume 具有一系列内置的数据源,包括 HTTP、JMS 和 RPC,您在几分钟前就遇到了其中一个.^([11]) 让我们看看您设置的特定于源配置属性:
¹¹ Flume 的完整数据源集合可以在
flume.apache.org/FlumeUserGuide.html#flume-sources查看。
agent1.sources = tail_source1
# define tail source
agent1.sources.tail_source1.type = exec
agent1.sources.tail_source1.channels = ch1
agent1.sources.tail_source1.shell = /bin/bash -c
agent1.sources.tail_source1.command = tail -F /var/log/messages
agent1.sources.tail_source1.interceptors = ts
agent1.sources.tail_source1.interceptors.ts.type = timestamp
exec 数据源允许您执行 Unix 命令,并且标准输出中发出的每一行都被捕获为一个事件(默认情况下忽略标准错误)。在上面的示例中,使用 tail -F 命令来捕获系统消息,就像它们被生成时一样.^([12]) 如果您对文件有更多控制权(例如,您可以在所有写入完成后将它们移动到目录中),请考虑使用 Flume 的轮询目录数据源(称为 spooldir),因为它提供了 exec 数据源所不具备的可靠性语义。
¹² 在
tail中使用大写F表示tail将继续尝试打开文件,这在文件被轮换的情况下很有用。
仅在测试时使用 tail
除了测试之外,不建议使用 tail。
在此配置中强调的另一个功能是拦截器,它允许您向事件添加元数据。回想一下,HDFS 中的数据是根据时间戳组织的——第一部分是日期,第二部分是时间:
/flume/140120/195155/FlumeData.1390265516304
您能够做到这一点是因为您使用时间戳拦截器修改了每个事件,该拦截器将源处理事件时的毫秒时间戳插入到事件头中。然后,Flume HDFS 源使用此时间戳来确定事件被写入的位置。
为了总结我们对 Flume 数据源的简要探讨,让我们总结一下它们提供的一些有趣功能:
-
事务语义,允许数据以至少一次语义可靠地移动。并非所有数据源都支持此功能.^([13])
^(13)在此技术中使用的
exec源是一个不提供任何数据可靠性保证的源的示例。 -
拦截器提供了修改或丢弃事件的能力。它们对于使用主机、时间和唯一标识符等注释事件非常有用,这些信息对于去重很有帮助。
-
选择器允许事件以各种方式分发或多路复用。您可以通过将事件复制到多个通道来分发事件,或者根据事件标题将它们路由到不同的通道。
通道
Flume 的通道在代理内部提供数据存储设施。源将事件添加到通道中,而端点则从通道中移除事件。通道在 Flume 内部提供持久性属性,并且您可以根据应用程序所需的持久性和吞吐量级别选择通道。
Flume 附带三个通道:
-
内存通道将事件存储在内存队列中。这对于高吞吐量数据流非常有用,但它们没有持久性保证,这意味着如果代理关闭,您将丢失数据。
-
文件通道将事件持久化到磁盘。该实现使用高效的预写日志,并具有强大的持久性属性。
-
JDBC 通道将事件存储在数据库中。这提供了最强的持久性和可恢复性属性,但会牺牲性能。
在上一个示例中,您使用了一个内存通道,并限制了其存储的事件数量为 100,000。一旦内存通道中的事件达到最大数量,它将开始拒绝来自源添加更多事件的额外请求。根据源的类型,这意味着源将重试或丢弃事件(exec源将丢弃事件):
agent1.channels = ch1
# define in-memory channel
agent1.channels.ch1.type = memory
agent1.channels.ch1.capacity = 100000
agent1.channels.ch1.transactionCapacity = 1000
关于 Flume 通道的更多详细信息,请参阅flume.apache.org/FlumeUserGuide.html#flume-channels。
端点
Flume 的sink从一个或多个 Flume 通道中提取事件,并将这些事件转发到另一个 Flume 源(在多跳流中),或者以特定于端点的方式处理这些事件。Flume 内置了多个端点,包括 HDFS、HBase、Solr 和 Elasticsearch。
在上一个示例中,您已配置流使用 HDFS 端点:
agent1.sinks = hdfs_sink1
# define HDFS sink properties
agent1.sinks.hdfs_sink1.type = hdfs
agent1.sinks.hdfs_sink1.hdfs.path = /flume/%y%m%d/%H%M%S
agent1.sinks.hdfs_sink1.hdfs.fileType = DataStream
agent1.sinks.hdfs_sink1.channel = ch1
您已配置端点根据时间戳写入文件(注意%y和其他时间戳别名)。您之所以能够这样做,是因为在exec源中您已经用时间戳拦截器装饰了事件。实际上,您可以使用任何标题值来确定事件的输出位置(例如,您可以添加一个主机拦截器,然后根据产生事件的哪个主机来写入文件)。
HDFS 溢出可以通过各种方式配置,以确定文件如何滚动。当溢出读取第一个事件时,它将打开一个新文件(如果尚未打开),并向其写入。默认情况下,溢出将保持文件打开,并将事件写入其中 30 秒,之后将其关闭。可以通过 表 5.5 中的属性更改滚动行为。
表 5.5. Flume 的 HDFS 溢出滚动属性
| 属性 | 默认值 | 描述 |
|---|---|---|
| hdfs.rollInterval | 30 | 在滚动当前文件之前等待的秒数(0 = 不根据时间间隔进行滚动) |
| hdfs.rollSize | 1024 | 触发滚动操作的文件大小,以字节为单位(0 = 不根据文件大小进行滚动) |
| hdfs.rollCount | 10 | 在文件滚动之前写入文件的事件数(0 = 不根据事件数进行滚动) |
| hdfs.idleTimeout | 0 | 在文件不活跃后关闭超时时间(0 = 禁用自动关闭不活跃文件) |
| hdfs.batchSize | 100 | 在将事件刷新到 HDFS 之前写入文件的事件数 |
HDFS 溢出的默认设置
在生产环境中不应使用 HDFS 溢出的默认设置,因为它们会导致大量可能很小的文件。建议您提高这些值或使用下游压缩作业将这些小文件合并。
HDFS 溢出允许您指定在写入文件时事件如何序列化。默认情况下,它们以文本格式序列化,不添加任何拦截器添加的标题。例如,如果您想以 Avro 格式写入数据,它还包括事件标题,您可以使用序列化配置来完成此操作。这样做时,您还可以指定 Avro 内部用于压缩数据的 Hadoop 压缩编解码器:
agent1.sinks.hdfs_sink1.serializer = avro_event
agent1.sinks.hdfs_sink1.serializer.compressionCodec = snappy
摘要
Flume 的可靠性取决于您使用的通道类型,您的数据源是否有重新传输事件的能力,以及您是否将事件多路复用到多个源以减轻不可恢复节点故障的影响。在此技术中,使用了内存通道和 exec 源,但它们在面临故障时都不提供可靠性。增加这种可靠性的方法之一是将 exec 源替换为轮转目录源,并将内存通道替换为磁盘通道。
您已经在单个机器上使用单个代理、单个源、单个通道和单个溢出运行了 Flume。但 Flume 可以支持完全分布式的设置,其中代理在多个主机上运行,源和最终目的地之间存在多个代理跳转。图 5.5 展示了 Flume 在分布式环境中如何工作的一个示例。
图 5.5. 使用负载均衡和扇入将 log4j 日志移动到 HDFS 的 Flume 设置

本技术的目标是移动数据到 HDFS。然而,Flume 支持各种数据接收器,包括 HBase、文件滚动、Elasticsearch 和 Solr。使用 Flume 将数据写入 Elasticsearch 或 Solr 可以实现强大的近实时索引策略。
因此,Flume 是一个非常强大的数据移动项目,可以轻松支持将数据移动到 HDFS 以及许多其他位置。它持续移动数据,并支持各种级别的弹性以应对系统中的故障。而且,它是一个简单易配置和运行的系统。
Flume 并非特别优化处理二进制数据。它可以支持移动二进制数据,但会将整个二进制事件加载到内存中,因此对于大小为吉字节或更大的文件,移动操作将无法进行。接下来的技术将探讨如何将此类数据移动到 HDFS。
技术篇 40:自动将文件复制到 HDFS 的机制
你已经学会了如何使用 Flume 等日志收集工具自动化地将数据移动到 HDFS。但是,这些工具并不支持直接处理半结构化或二进制数据。在这个技术中,我们将探讨如何自动化将此类文件移动到 HDFS。
生产网络通常有网络隔离,你的 Hadoop 集群被分割在其他生产应用之外。在这种情况下,你的 Hadoop 集群可能无法从其他数据源拉取数据,你将别无选择,只能将数据推送到 Hadoop。
你需要一个机制来自动化将任何格式的文件复制到 HDFS 的过程,类似于 Linux 工具 rsync。该机制应能够压缩 HDFS 中写入的文件,并提供一种动态确定数据分区目的地的 HDFS 目标的方法。
现有的文件传输机制,如 Flume、Scribe 和 Chukwa,主要是为了支持日志文件。如果你的文件格式不同,比如半结构化或二进制格式,怎么办?如果文件以某种方式隔离,使得 Hadoop 从节点无法直接访问,那么你也无法使用 Oozie 来帮助进行文件导入。
问题
你需要自动化远程服务器上的文件复制到 HDFS 的过程。
解决方案
开源 HDFS File Slurper 项目可以将任何格式的文件复制到和从 HDFS 中。本技术涵盖了如何配置和使用它来复制数据到 HDFS。
讨论
你可以使用我编写的 HDFS File Slurper 项目(github.com/alexholmes/hdfs-file-slurper)来帮助你自动化。HDFS File Slurper 是一个简单的实用工具,支持将文件从本地目录复制到 HDFS,反之亦然。
图 5.6 提供了 Slurper(我为这个项目取的昵称)的高级概述,以及如何使用它来复制文件的示例。Slurper 读取源目录中存在的任何文件,并可选地与脚本协商以确定目标目录中的文件位置。然后,它将文件写入目标位置,之后有一个可选的验证步骤。最后,在所有前面的步骤成功完成后,Slurper 将源文件移动到完成文件夹。
图 5.6. HDFS File Slurper 文件复制数据流

使用这种技术,有几个挑战您需要确保解决:
-
你如何有效地分区你的 HDFS 写入,以便你不把所有东西都堆叠到单个目录中?
-
你如何确定 HDFS 中的数据已经准备好处理(以避免读取正在复制中的文件)?
-
你如何自动化常规执行你的实用工具?
您的第一步是从 github.com/alexholmes/hdfs-file-slurper/releases 下载最新的 HDFS File Slurper tarball,并将其安装在一个可以访问 Hadoop 集群和本地 Hadoop 安装的宿主机上:
$ sudo tar -xzf target/hdfs-slurper-<version>-package.tar.gz \
-C /usr/local/
$ sudo ln -s /usr/local/hdfs-slurper-<version> \
/usr/local/hdfs-slurper
配置
在运行代码之前,您需要编辑 /usr/local/hdfs-slurper/conf/slurper-env.sh 并设置 hadoop 脚本的位置。以下代码是如果遵循附录中的 Hadoop 安装说明,slurper-env.sh 文件可能看起来像的示例:
$ cat /usr/local/hdfs-slurper/conf/slurper-env.sh
export HADOOP_BIN=/usr/local/hadoop/bin/hadoop
Slurper 包含一个 /usr/local/hdfs-slurper/conf/slurper.conf 文件,其中包含源目录和目标目录的详细信息,以及其他选项。该文件包含以下默认设置,您可以根据需要更改:

让我们更仔细地看看这些设置:
-
DATASOURCE_NAME— 这指定了正在传输的数据的名称。当通过 Linuxinit守护进程管理系统启动时,它用于日志文件名,我们将在稍后介绍。 -
SRC_DIR— 这指定了源目录。任何移动到这里的文件都会自动复制到目标目录(通过中间的暂存目录进行跳转)。 -
WORK_DIR— 这是工作目录。在开始复制到目标之前,源目录的文件会被移动到这里。 -
COMPLETE_DIR— 这指定了完整目录。复制完成后,文件将从工作目录移动到这个目录。或者,可以使用--remove-after-copy选项来删除源文件,在这种情况下不应提供--complete-dir选项。 -
ERROR_DIR— 这是错误目录。在复制过程中遇到的任何错误都会导致源文件被移动到这个目录。 -
DEST_DIR— 这设置源文件的最终目标目录。 -
DEST_STAGING_DIR— 这指定了临时目录。文件首先被复制到这个目录,一旦复制成功,Slurper 就会将副本移动到目标目录,以避免目标目录可能包含部分写入的文件(在失败的情况下)。
你会注意到所有目录名都是 HDFS URI。HDFS 以这种方式区分不同的文件系统。file:/ URI 表示本地文件系统上的路径,而 hdfs:/ URI 表示 HDFS 中的路径。实际上,Slurper 支持任何 Hadoop 文件系统,只要配置 Hadoop 使用它。
运行
让我们创建一个名为 /tmp/slurper/in 的本地目录,向其中写入一个空文件,然后运行 Slurper:
$ mkdir -p /tmp/slurper/in
$ touch /tmp/slurper/in/test-file.txt
$ cd /usr/local/hdfs-slurper/
$ bin/slurper.sh --config-file conf/slurper.conf
Copying source file 'file:/tmp/slurper/work/test-file.txt'
to staging destination 'hdfs:/tmp/slurper/stage/1354823335'
Moving staging file 'hdfs:/tmp/slurper/stage/1354823335'
to destination 'hdfs:/tmp/slurper/dest/test-file.txt'
File copy successful, moving source
file:/tmp/slurper/work/test-file.txt to completed file
file:/tmp/slurper/complete/test-file.txt
$ hadoop fs -ls /tmp/slurper/dest
/tmp/slurper/dest/test-file.txt
Slurper 设计中的一个关键特性是它不与部分写入的文件一起工作。文件必须原子性地移动到源目录中(Linux 和 HDFS 文件系统中的文件移动都是原子的)。^([14]) 或者,你可以写入一个以点(.)开头的文件名,Slurper 会忽略它,文件写入完成后,你可以将文件重命名为不带点前缀的名称。
¹⁴ 只有当源和目标都在同一分区上时,移动文件才是原子的。换句话说,将文件从 NFS 挂载移动到本地磁盘会导致复制,这不是原子的。
请注意,使用相同文件名的多个文件进行复制会导致目标被覆盖——确保文件唯一性的责任在于用户,以防止这种情况发生。
动态目标路径
如果你每天只是将少量文件移动到 HDFS 上,那么之前的方法效果很好。但是,如果你处理的是大量文件,你可能需要考虑将它们分区到不同的目录中。这样做的好处是,你可以对 MapReduce 作业的输入数据有更细粒度的控制,同时也有助于在文件系统中对数据进行整体组织(你不会希望电脑上的所有文件都位于单个扁平目录中)。
你如何能够对 Slurper 使用的目标目录和文件名有更多的动态控制?Slurper 配置文件有一个 SCRIPT 选项(与 DEST_DIR 选项互斥),你可以指定一个脚本,该脚本提供源文件到目标文件的动态映射。
假设你正在处理的文件包含在文件名中的日期,并且你已经决定想要按照日期在 HDFS 中组织你的数据。你可以编写一个脚本来执行这个映射活动。以下是一个执行此操作的 Python 脚本示例:
#!/usr/bin/python
import sys, os, re
# read the local file from standard input
input_file=sys.stdin.readline()
# extract the filename from the file
filename = os.path.basename(input_file)
# extract the date from the filename
match=re.search(r'([0-9]{4})([0-9]{2})([0-9]{2})', filename)
year=match.group(1)
mon=match.group(2)
day=match.group(3)
# construct our destination HDFS file
hdfs_dest="hdfs:/data/%s/%s/%s/%s" % (year, mon, day, filename)
# write it to standard output
print hdfs_dest,
现在,你可以更新 /usr/local/hdfs-slurper/conf/slurper.conf,设置 SCRIPT,并注释掉 DEST_DIR,这将导致文件中以下条目:
# DEST_DIR = hdfs:/tmp/slurper/dest
SCRIPT = /usr/local/hdfs-slurper/bin/sample-python.py
如果你再次运行 Slurper,你会注意到目标路径现在已经被 Python 脚本按日期分区:
$ touch /tmp/slurper/in/apache-20110202.log
$ bin/slurper.sh --config-file conf/slurper.conf
Launching script '/usr/local/hdfs-slurper/bin/sample-python.py' and
piping the following to stdin 'file:/tmp/slurper/work/apache-20110202.log'
...
Moving staging file 'hdfs:/tmp/slurper/stage/675861557' to destination
'hdfs:/data/2011/02/02/apache-20110202.log'
压缩和验证
如果你想压缩 HDFS 中的输出文件并验证复制是否正确,你需要使用COMPRESSION_CODEC选项,其值是一个实现CompressionCodec接口的类。如果你的压缩编解码器是 LZO 或 LZOP,你还可以添加一个CREATE_LZO_INDEX选项,以便创建 LZOP 索引。如果你不知道这是什么意思,请参阅第四章中的 LZO 覆盖章节 4。
此外,还有一个验证功能,它在复制完成后重新读取目标文件,并确保目标文件的校验和与源文件匹配。这会导致处理时间更长,但它为复制成功添加了一个额外的保证级别。
下面的配置片段显示了启用了 LZOP 编解码器、LZO 索引和文件验证:
COMPRESSION_CODEC = com.hadoop.compression.lzo.LzopCodec
CREATE_LZO_INDEX = true
VERIFY = true
让我们再次运行 Slurper:
$ touch /tmp/slurper/in/apache-20110202.log
$ bin/slurper.sh --config-file conf/slurper.conf
Verifying files
CRC's match (0)
Moving staging file 'hdfs:/tmp/slurper/stage/535232571'
to destination 'hdfs:/data/2011/02/02/apache-20110202.log.snappy'
持续运行
现在你已经设置了基本机制,你的最后一步是将工具作为守护进程运行,以便它持续查找要传输的文件。为此,你可以使用名为 bin/slurper-inittab.sh 的脚本,该脚本旨在与inittab重生一起工作.^([15])
^([15]) Inittab 是一个 Linux 进程管理工具,你可以配置它来监督和重启一个进程,如果它崩溃。请参阅 Linux 系统管理员手册中的 INITTAB(5):
unixhelp.ed.ac.uk/CGI/mancgi?inittab+5。
此脚本不会创建 PID 文件或执行nohup——在重生上下文中,这两者都没有意义,因为 inittab 正在管理进程。它使用DATASOURCE_NAME配置值来创建日志文件名。这意味着多个 Slurper 实例都可以使用不同的配置文件启动,并将日志记录到不同的日志文件中。
摘要
Slurper 是一个方便的工具,可以从本地文件系统到 HDFS 进行数据导入。它还支持通过从 HDFS 复制到本地文件系统进行数据导出。在 MapReduce 无法访问文件系统且传输的文件格式不适合 Flume 等工具的情况下,它可能很有用。
现在让我们看看在 MapReduce 或 HDFS 可以访问您的数据源的情况下,自动提取的情况。
技巧 41 使用 Oozie 安排定期的数据导入活动
如果你的数据位于文件系统、Web 服务器或任何可以从你的 Hadoop 集群访问的系统上,你需要一种方法来定期将数据拉入 Hadoop。存在一些工具可以帮助推送日志文件和从数据库中提取数据(我们将在本章中介绍),但如果你需要与某些其他系统接口,你可能需要自己处理数据导入过程。
Oozie 版本
这种技术涵盖了使用 Oozie 版本 4.0.0。
这个数据导入过程有两个部分:如何将数据从另一个系统导入到 Hadoop,以及如何定期安排数据传输。
问题
你想自动化一个每日任务,从 HTTP 服务器下载内容到 HDFS。
解决方案
Oozie 可以用来将数据移动到 HDFS,它也可以用来执行后入活动,例如启动一个 MapReduce 作业来处理导入的数据。现在是一个 Apache 项目,Oozie 最初诞生于 Yahoo1!它是一个 Hadoop 工作流引擎,用于管理数据处理活动。Oozie 还有一个协调器引擎,可以根据数据和时间触发器启动工作流。
讨论
在这个技术中,你将每 24 小时从多个 URL 进行下载,使用 Oozie 来管理工作流和调度。这个技术的流程在图 5.7 中展示。你将使用 Oozie 的触发能力每 24 小时启动一个 MapReduce 作业。附录包含 Oozie 安装说明。
图 5.7.本 Oozie 技术的数据流

第一步是查看协调器 XML 配置文件。这个文件由 Oozie 的协调引擎用来确定何时启动工作流。Oozie 使用模板引擎和表达式语言来进行参数化,正如你将在下面的代码中看到的。创建一个名为 coordinator.xml 的文件,内容如下:^([16])
^(16)GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/oozie/http-download/coordinator.xml.
列表 5.1.使用模板引擎在 Oozie 中进行参数化


Oozie 的协调器可能会让人感到困惑的是,开始和结束时间并不与作业实际执行的时间相关。相反,它们指的是为每个工作流执行创建(“实现”)的日期。这在有数据以周期性间隔生成,并且你想能够回到过去某个时间点对那些数据进行一些工作的场景中很有用。在这个例子中,你不想回到过去,而是想每 24 小时调度一个作业。但你不想等到第二天,因此你可以将开始日期设置为昨天,结束日期设置为未来的某个遥远日期。
接下来,你需要定义实际的流程,这个流程将在过去的每个间隔执行,并且,当系统时钟达到一个间隔时,也将向前执行。为此,创建一个名为 workflow.xml 的文件,内容如下一节所示。^([17])
^(17)GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/oozie/http-download/workflow.xml.
列表 5.2.使用 Oozie 协调器定义过去的工作流


在 Oozie 中使用新的 MapReduce API
默认情况下,Oozie 预期你的 map 和 reduce 类使用的是“旧”的 MapReduce API。如果你想使用“新”的 API,你需要指定额外的属性:
<property>
<name>mapred.mapper.new-api</name>
<value>true</value>
</property>
<property>
<name>mapred.reducer.new-api</name>
<value>true</value>
</property>
<property>
<name>mapreduce.map.class</name>
<value>YOUR-NEW-API-MAP-CLASSNAME</value>
</property>
<property>
<name>mapreduce.reduce.class</name>
<value>YOUR-NEW-API-REDUCE-CLASSNAME</value>
</property>
最后一步是定义你的属性文件,该文件指定了如何访问 HDFS、MapReduce 以及之前在 HDFS 中确定的两个 XML 文件的位置。创建一个名为 job.properties 的文件,如下面的代码所示:

不同 Hadoop 版本的 JobTracker 属性
如果你针对的是 Hadoop 1,你应该在 jobTracker 属性中使用 JobTracker RPC 端口(默认为 8021)。否则使用 YARN ResourceManager RPC 端口(默认为 8032)。
在前面的片段中,HDFS 中的位置指示了你在本章前面编写的 coordinator.xml 和 workflow.xml 文件的位置。现在你需要将 XML 文件、你的输入文件以及包含你的 MapReduce 代码的 JAR 文件复制到 HDFS:
$ hadoop fs -put oozie/http-download http-download
$ hadoop fs -put test-data/ch5/http-download/input/* http-download/
$ hadoop fs -mkdir http-download/lib
$ hadoop fs -put hip-2.0.0.jar http-download/lib/
最后,在 Oozie 中运行你的作业:
$ oozie job -config src/main/oozie/http-download/job.properties \
-run
job: 0000006-140124164013396-oozie-ahol-C
你可以使用作业 ID 获取一些关于作业的信息:
$ oozie job -info 0000006-140124164013396-oozie-ahol-C
Job ID : 0000006-140124164013396-oozie-ahol-C
-----------------------------------------------
Job Name : http-download
App Path : hdfs://localhost:8020/user/aholmes/http-download
Status : RUNNING
Start Time : 2014-01-23 00:00 GMT
End Time : 2026-11-29 00:00 GMT
Pause Time : -
Concurrency : 1
-----------------------------------------------
ID Status Created Nominal Time
0000006-1401241... SUCCEEDED 2014-02-16 20:50 GMT 2014-01-23 00:00 GMT
-----------------------------------------------
此输出告诉你作业只运行了一次,你可以看到运行的正常时间。总体状态是 RUNNING,这意味着作业正在等待下一个间隔发生。当总体作业完成(在结束日期到达后),状态将过渡到 SUCCEEDED。
你可以确认在 HDFS 中存在与物化日期对应的输出目录:
$ hadoop fs -lsr http-download/output
http-download/output/2014/01/23
只要作业在运行,它将继续执行,直到结束日期,在这个例子中已经设置为 2026 年。如果你想停止作业,请使用 -suspend 选项:
$ oozie job -suspend 0000006-140124164013396-oozie-ahol-C
Oozie 还具有恢复挂起作业以及使用 -resume 和 -kill 选项分别终止工作流的能力。
摘要
我向你展示了 Oozie 协调器的一个使用示例,它提供了类似于 cron 的能力来启动定期的 Oozie 工作流。Oozie 协调器还可以根据数据可用性来触发工作流(如果没有数据可用,则不会触发工作流)。例如,如果你有一个外部进程,或者甚至是定期生成数据的 MapReduce,你可以使用 Oozie 的数据驱动协调器来触发工作流,该工作流可以聚合或处理这些数据。
在本节中,我们介绍了三种可用于数据导入目的的自动化机制。第一种技术介绍了 Flume,这是一个将日志数据传输到 Hadoop 的强大工具,第二种技术探讨了 HDFS 文件 Slurper,它自动化了将数据推入 HDFS 的过程。最后一种技术探讨了如何使用 Oozie 定期启动 MapReduce 作业以将数据拉入 HDFS 或 MapReduce。
在我们探索数据导入的过程中,我们已经探讨了推送日志文件、从常规文件系统中推送文件以及从 Web 服务器中拉取文件。对大多数组织来说,另一个有趣的数据源是位于 OLTP 数据库中的关系型数据。接下来,我们将看看如何访问这些数据。
5.2.3. 数据库
大多数组织的核心数据存在于多个 OLTP 数据库中。这些数据库中存储的数据包含有关用户、产品和许多其他有用信息。如果您想分析这些数据,传统的做法是定期将数据复制到一个 OLAP 数据仓库中。
Hadoop 在这个领域扮演了两个角色:作为数据仓库的替代品,以及作为结构化和非结构化数据以及数据仓库之间的桥梁。图 5.8 显示了第一个角色,其中 Hadoop 被用作在将数据导出到 OLAP 系统(商业智能应用的常用平台)之前的大规模连接和聚合机制。
图 5.8:使用 Hadoop 进行数据导入、连接和导出到 OLAP

Facebook 是一家成功利用 Hadoop 和 Hive 作为 OLAP 平台来处理 PB 级数据的组织示例。图 5.9 显示了与 Facebook 架构相似的一个架构。这个架构还包括一个反馈循环到 OLTP 系统,可以用来将 Hadoop 中发现的发现(如用户推荐)推送到 OLTP 系统。
图 5.9:使用 Hadoop 进行 OLAP 和反馈到 OLTP 系统

在任何使用模型中,您都需要一种将关系型数据引入 Hadoop 的方法,同时也需要将其导出到关系型数据库中。在本节中,您将使用 Sqoop 简化将关系型数据移动到 Hadoop 的过程。
技术编号 42:使用 Sqoop 从 MySQL 导入数据
Sqoop 是一个可以将关系型数据在 Hadoop 中导入和导出的项目。作为一个高级工具,它封装了与关系型数据移动到 Hadoop 相关的逻辑——您需要做的是向 Sqoop 提供将用于确定哪些数据被导出的 SQL 查询。本技术提供了如何使用 Sqoop 将一些 MySQL 中的库存数据移动到 HDFS 的详细信息。
版本控制
本节使用 Sqoop 的 1.4.4 版本。本技术中使用的代码和脚本可能与其他版本的 Sqoop 不兼容,尤其是作为 Web 应用程序实现的 Sqoop 2。
问题
您希望将关系型数据加载到您的集群中,并确保您的写入操作既高效又幂等。
解决方案
在这项技术中,我们将探讨如何使用 Sqoop 作为一个简单的机制将关系型数据引入 Hadoop 集群。我们将逐步介绍将数据从 MySQL 导入 Sqoop 的过程。我们还将介绍使用快速连接器(连接器是特定于数据库的组件,提供数据库的读写访问)进行批量导入。
讨论
Sqoop 是一个关系数据库导入和导出系统。它由 Cloudera 创建,目前处于 Apache 项目孵化状态。
当您执行导入时,Sqoop 可以写入 HDFS、Hive 和 HBase,对于导出,它可以执行相反的操作。导入分为两个活动:连接到数据源以收集一些统计信息,然后启动一个执行实际导入的 MapReduce 作业。图 5.10 展示了这些步骤。
图 5.10. Sqoop 导入概述:连接数据源和使用 MapReduce

Sqoop 有 连接器 的概念,它包含读取和写入外部系统所需的专业逻辑。Sqoop 包含两类连接器:通用连接器 用于常规的读取和写入,以及使用数据库专有批量机制的 快速连接器,以实现高效的导入。图 5.11 展示了这两类连接器及其支持的数据库。
图 5.11. 用于读取和写入外部系统的 Sqoop 连接器

在您继续之前,您需要访问一个 MySQL 数据库,并且 MySQL JDBC JAR 需要可用.^([18]) 以下脚本将创建必要的 MySQL 用户和模式,并为此技术加载数据。该脚本创建了一个 hip_sqoop_user MySQL 用户,并创建了一个包含三个表(stocks、stocks_export 和 stocks_staging)的 sqoop_test 数据库。然后,它将股票样本数据加载到 stocks 表中。所有这些步骤都是通过运行以下命令来执行的:
^((18) MySQL 安装说明可在附录中找到,如果您尚未安装,该部分还包括获取 JDBC JAR 的链接。
$ bin/prep-sqoop-mysql
下面简要介绍一下脚本做了什么:
$ mysql -u hip_sqoop_user -p
<enter "password" for the password>
mysql> use sqoop_test;
mysql> show tables;
+----------------------+
| Tables_in_sqoop_test |
+----------------------+
| stocks |
| stocks_export |
| stocks_staging |
+----------------------+
3 rows in set (0.00 sec)
mysql> select * from stocks;
+----+--------+------------+------------+------------+-----------+---
| id | symbol | quote_date | open_price | high_price | low_price |...
+----+--------+------------+------------+------------+-----------+---
| 1 | AAPL | 2009-01-02 | 85.88 | 91.04 | 85.16 |...
| 2 | AAPL | 2008-01-02 | 199.27 | 200.26 | 192.55 |...
| 3 | AAPL | 2007-01-03 | 86.29 | 86.58 | 81.9 |...
...
按照附录中的说明安装 Sqoop。这些说明还包含安装 Sqoop 依赖项的重要步骤,例如 MySQL JDBC 驱动程序。
您的第一个 Sqoop 命令将是一个基本的导入,您将指定 MySQL 数据库和您想要导出的表的联系信息:
$ sqoop import \
--username hip_sqoop_user \
--password password \
--connect jdbc:mysql://localhost/sqoop_test \
--table stocks
MySQL 表名
Linux 中的 MySQL 表名是区分大小写的。请确保您在 Sqoop 命令中提供的表名使用正确的大小写。
默认情况下,Sqoop 使用表名作为它在 HDFS 中启动的 MapReduce 作业的目标。如果您再次运行相同的命令,MapReduce 作业将失败,因为目录已经存在。
让我们来看看 HDFS 中的 stocks 目录:
$ hadoop fs -ls stocks
624 2011-11-24 11:07 /user/aholmes/stocks/part-m-00000
644 2011-11-24 11:07 /user/aholmes/stocks/part-m-00001
642 2011-11-24 11:07 /user/aholmes/stocks/part-m-00002
686 2011-11-24 11:07 /user/aholmes/stocks/part-m-00003
$ hadoop fs -cat stocks/part-m-00000
1,AAPL,2009-01-02,85.88,91.04,85.16,90.75,26643400,90.75
2,AAPL,2008-01-02,199.27,200.26,192.55,194.84,38542100,194.84
3,AAPL,2007-01-03,86.29,86.58,81.9,83.8,44225700,83.8
...
导入数据格式
Sqoop 已将数据导入为逗号分隔的文本文件。它支持多种其他文件格式,可以通过 表 5.6 中列出的参数激活。
表 5.6. 控制导入命令文件格式的 Sqoop 参数
| 参数 | 描述 |
|---|---|
| --as-avrodatafile | 数据以 Avro 文件的形式导入。 |
| --as-sequencefile | 数据以 SequenceFiles 格式导入。 |
| --as-textfile | 默认文件格式;数据以 CSV 文本文件导入。 |
如果您正在导入大量数据,您可能希望使用像 Avro 这样的紧凑数据格式,并与其结合压缩使用。以下示例使用 Snappy 压缩编解码器与 Avro 文件结合使用。它还使用--target-dir选项将输出写入与表名不同的目录,并使用--where选项指定应导入的行子集。可以使用--columns指定要提取的特定列:
$ sqoop import \
--username hip_sqoop_user \
--password password \
--as-avrodatafile \
--compress \
--compression-codec org.apache.hadoop.io.compress.SnappyCodec \
--connect jdbc:mysql://localhost/sqoop_test \
--table stocks \
--where "symbol = 'AAPL'" \
--columns "symbol,quote_date,close_price" \
--target-dir mystocks
注意,命令行上提供的压缩必须在配置文件 core-site.xml 中的io.compression.codecs属性下定义。Snappy 压缩编解码器要求您安装 Hadoop 原生库。有关压缩设置和配置的更多详细信息,请参阅第四章。
您可以使用在第 12 项技术中介绍的 AvroDump 工具来检查 Avro 文件的结构,以查看 Sqoop 如何布局记录。Sqoop 使用 Avro 的GenericRecord进行记录级存储(更多细节请参阅第三章)。如果您在 HDFS 中对由 Sqoop 生成的文件运行 AvroDump,您将看到以下内容:
$ hip hip.util.AvroDump --file mystocks/part-m-00000.avro
{"symbol": "AAPL", "quote_date": "2009-01-02", "close_price": 90.75}
{"symbol": "AAPL", "quote_date": "2008-01-02", "close_price": 194.84}
{"symbol": "AAPL", "quote_date": "2007-01-03", "close_price": 83.8}
结合 Sqoop 和 SequenceFiles 的使用
使 SequenceFiles 难以使用的一个原因是,没有通用的方式来访问 SequenceFile 中的数据。您必须有权访问用于写入数据的Writable类。在 Sqoop 的情况下,它通过代码生成此文件,这引入了一个主要问题:如果您切换到 Sqoop 的新版本,并且该版本修改了代码生成器,那么您的旧代码生成的类可能无法与使用新版本 Sqoop 生成的 SequenceFiles 一起工作。您可能需要将所有旧 SequenceFiles 迁移到新版本,或者拥有可以与这些 SequenceFiles 的不同版本一起工作的代码。由于这种限制,我不建议将 SequenceFiles 与 Sqoop 一起使用。如果您想了解更多关于 SequenceFiles 如何工作的信息,请运行 Sqoop 导入工具,并查看您工作目录内生成的 stocks.java 文件。
您可以进一步使用--query选项指定整个查询,如下所示:

保护密码
到目前为止,您一直在命令行上明文使用密码。这是一个安全漏洞,因为主机上的其他用户可以轻松列出正在运行的过程并看到您的密码。幸运的是,Sqoop 有几个机制可以帮助您避免泄露密码。
第一种方法是使用-P选项,这将导致 Sqoop 提示您输入密码。这是最安全的方法,因为它不需要您存储密码,但这也意味着您不能自动化您的 Sqoop 命令。
第二种方法是使用 --password-file 选项,其中您指定一个包含您密码的文件。请注意,此文件必须存在于配置的文件系统中(很可能是 HDFS),而不是在 Sqoop 客户端本地的磁盘上。您可能希望锁定该文件,以便只有您才有权读取此文件。但这还不是最安全的选项,因为文件系统上的 root 用户仍然能够窥探该文件,并且除非您运行的是安全的 Hadoop,否则即使是非 root 用户也很容易获得访问权限。
最后一个选项是使用选项文件。创建一个名为 ~/.sqoop-import-opts 的文件:
import
--username
hip_sqoop_user
--password
password
不要忘记锁定文件以防止他人窥探:
$ chmod 600 ~/.sqoop-import
然后,您可以通过 --options-file 选项将此文件名提供给您的 Sqoop 作业,Sqoop 将读取文件中指定的选项,这意味着您不需要在命令行上提供它们:
$ sqoop \
--options-file ~/.sqoop-import-opts \
--connect jdbc:mysql://localhost/sqoop_test \
--table stocks
数据拆分
Sqoop 如何能够在多个映射器之间并行化导入?^([19]) 在 图 5.10 中,我展示了 Sqoop 的第一步是从数据库中提取元数据。它检查要导入的表以确定主键,并运行一个查询以确定表中数据的上下限(见 图 5.12)。Sqoop 假设数据在最小和最大键之间分布得相对均匀,在划分 delta(最小和最大键之间的范围)时,将其除以映射器的数量。然后,每个映射器都会接收到一个包含主键范围的唯一查询。
¹⁹ 默认情况下,Sqoop 使用四个映射器。映射器的数量可以通过
--num-mappers参数来控制。
图 5.12. Sqoop 预处理步骤以确定查询拆分

您可以使用 --split-by 参数配置 Sqoop 使用非主键。这在主键在最小和最大值之间没有均匀分布值的情况下可能很有用。然而,对于大型表,您需要小心,确保在 --split-by 中指定的列已建立索引,以确保最佳导入时间。
您可以使用 --boundary-query 参数构建一个替代查询以确定最小和最大值。
增量导入
您还可以执行增量导入。Sqoop 支持两种类型:append 适用于随时间递增的数值数据,例如自增键;lastmodified 适用于带时间戳的数据。在这两种情况下,您都需要使用 --check-column 指定列,通过 --incremental 参数(值必须是 append 或 lastmodified)指定模式,并通过 --last-value 指定用于确定增量更改的实际值。
例如,如果您想导入 2005 年 1 月 1 日之后的新股票数据,您将执行以下操作:
$ sqoop import \
--username hip_sqoop_user \
--password password \
--check-column "quote_date" \
--incremental "lastmodified" \
--last-value "2005-01-01" \
--connect jdbc:mysql://localhost/sqoop_test \
--table stocks
...
tool.ImportTool: --incremental lastmodified
tool.ImportTool: --check-column quote_date
tool.ImportTool: --last-value 2014-02-17 07:58:39.0
tool.ImportTool: (Consider saving this with 'sqoop job --create')
...
假设还有另一个系统正在继续向股票表写入,你会使用此作业的--last-value输出作为后续 Sqoop 作业的输入,以便只导入比该日期新的行。
Sqoop 作业和元存储
你可以在命令输出中看到增量列的最后一个值。如何最好地自动化一个可以重用该值的过程?Sqoop 有一个名为作业的概念,它可以在后续执行中保存此信息并重用它:

执行前面的命令在 Sqoop 元存储中创建一个命名作业,它跟踪所有作业。默认情况下,元存储位于你的家目录下的.sqoop 中,并且仅用于你自己的作业。如果你想在不同用户和团队之间共享作业,你需要为 Sqoop 的元存储安装一个 JDBC 兼容的数据库,并在发出作业命令时使用--meta-connect参数指定其位置。
在前面的示例中执行的create作业命令除了将作业添加到元存储外,没有做任何事情。要运行作业,你需要明确地按照以下方式执行它:

--show参数显示的元数据包括增量列的最后一个值。这实际上是命令执行的时间,而不是表中的最后一个值。如果你使用此功能,请确保数据库服务器以及与服务器交互的任何客户端(包括 Sqoop 客户端)的时钟与网络时间协议(NTP)同步。
在运行作业时,Sqoop 将提示输入密码。为了使自动化脚本能够工作,你需要使用 Expect,一个 Linux 自动化工具,在检测到 Sqoop 提示输入密码时,从本地文件中提供密码。一个与 Sqoop 一起工作的 Expect 脚本可以在 GitHub 上找到:github.com/alexholmes/hadoop-book/blob/master/bin/sqoop-job.exp。
如此所示,Sqoop 作业也可以被删除:
$ sqoop job --delete stock_increment
快速 MySQL 导入
如果你想完全绕过 JDBC 并使用快速 MySQL Sqoop 连接器将高吞吐量数据加载到 HDFS,该怎么办?这种方法使用 MySQL 附带mysqldump实用程序来执行加载。你必须确保mysqldump在运行 MapReduce 作业的用户路径中。要启用快速连接器的使用,你必须指定--direct参数:
$ sqoop --options-file ~/.sqoop-import-opts \
--direct \
--connect jdbc:mysql://localhost/sqoop_test \
--table stocks
快速连接器的缺点是什么?快速连接器仅适用于文本输出文件——将 Avro 或 SequenceFile 指定为导入的输出格式将不起作用。
导入到 Hive
此技术的最后一步是使用 Sqoop 将你的数据导入到 Hive 表中。HDFS 导入和 Hive 导入之间的唯一区别是,Hive 导入有一个后处理步骤,其中创建并加载 Hive 表,如图 5.13 所示。
图 5.13. Sqoop Hive 导入事件序列

当数据从 HDFS 文件或目录加载到 Hive 中,例如在 Sqoop Hive 导入的情况下(图中的第 4 步),Hive 将目录移动到其仓库中,而不是复制数据(第 5 步),以提高效率。Sqoop MapReduce 作业写入的 HDFS 目录在导入后不会存在。
Hive 导入是通过 --hive-import 参数触发的。就像快速连接器一样,此选项与 --as-avrodatafile^([20]) 和 --as-sequencefile 选项不兼容:
(20) 请参阅
issues.apache.org/jira/browse/SQOOP-324以获取可能的未来修复。
$ sqoop --options-file ~/.sqoop-import-opts \
--hive-import \
--connect jdbc:mysql://localhost/sqoop_test \
--table stocks
$ hive
hive> select * from stocks;
OK
1 AAPL 2009-01-02 85.88 91.04 85.16 90.75 26643400 90.75
2 AAPL 2008-01-02 199.27 200.26 192.55 194.84 38542100 194.84
3 AAPL 2007-01-03 86.29 86.58 81.9 83.8 44225700 83.8
4 AAPL 2006-01-03 72.38 74.75 72.25 74.75 28829800 74.75
...
导入包含 Hive 分隔符的字符串
如果您正在导入可能包含 Hive 分隔符(\n、\r 和 \01 字符)的列,您可能会遇到下游处理问题。在这种情况下,您有两个选择:要么指定 --hive-drop-import-delims,这将作为导入过程的一部分删除冲突字符,要么指定 --hive-delims-replacement,这将用不同的字符替换它们。
如果 Hive 表已经存在,数据将被追加到现有表中。如果这不是您期望的行为,您可以使用 --hive-overwrite 参数来指示应使用导入数据替换现有表。
您还可以告诉 Sqoop 对写入 Hive 表的数据进行压缩。目前 Sqoop 只支持 Hive 的文本输出,因此 LZOP 压缩编解码器是这里最好的选择,因为它可以在 Hadoop 中分割(详情见第四章)。以下示例展示了如何结合使用 --hive-overwrite 和 LZOP 压缩。为了使此操作生效,您需要在您的集群上构建和安装 LZOP,因为它默认情况下并未包含在 Hadoop(或 CDH)中。更多详情请参考第四章:
(21) bzip2 也是一个可分割的压缩编解码器,可以在 Hadoop 中使用,但其写入性能非常差,因此在实践中很少使用。
$ hive
hive> drop table stocks;
$ hadoop fs -rmr stocks
$ sqoop --options-file ~/.sqoop-import-opts \
--hive-import \
--hive-overwrite \
--compress \
--compression-codec com.hadoop.compression.lzo.LzopCodec \
--connect jdbc:mysql://localhost/sqoop_test \
--table stocks
最后,您可以使用 --hive-partition-key 和 --hive-partition-value 参数根据导入列的值创建不同的 Hive 分区。例如,如果您想按股票名称分区输入,您可以这样做:
$ hive
hive> drop table stocks;
$ hadoop fs -rmr stocks
$ read -d '' query << "EOF"
SELECT id, quote_date, open_price
FROM stocks
WHERE symbol = "AAPL" AND $CONDITIONS
EOF
$ sqoop --options-file ~/.sqoop_import_options.txt \
--query "$query" \
--split-by id \
--hive-import \
--hive-table stocks \
--hive-overwrite \
--hive-partition-key symbol \
--hive-partition-value "AAPL" \
--connect jdbc:mysql://localhost/sqoop_test \
--target-dir stocks
$ hadoop fs -lsr /user/hive/warehouse
/user/hive/warehouse/stocks/symbol=AAPL/part-m-00000
/user/hive/warehouse/stocks/symbol=AAPL/part-m-00001
...
现在,前面的示例无论如何都不是最优的。理想情况下,单个导入应该能够创建多个 Hive 分区。由于您只能指定一个键和一个值,您需要为每个唯一的分区值运行一次导入,这非常耗时。您最好是将数据导入到一个非分区的 Hive 表中,然后在数据加载后回溯性地在表上创建分区。
此外,你提供给 Sqoop 的 SQL 查询还必须注意过滤结果,以确保只包含匹配分区的结果。换句话说,如果 Sqoop 能够用 symbol = "AAPL" 更新 WHERE 子句,而不是你自己这样做,那将是有用的。
连续 Sqoop 执行
如果你需要定期安排导入到 HDFS,Oozie 有 Sqoop 集成,这将允许你定期执行导入和导出。以下是一个 Oozie workflow.xml 示例:
<workflow-app name="sqoop-wf">
<start to="sqoop-node"/>
<action name="sqoop-node">
<sqoop >
<job-tracker>${jobTracker}</job-tracker>
<name-node>${nameNode}</name-node>
<prepare>
<delete path="${nameNode}/output-data/sqoop"/>
<mkdir path="${nameNode}/output-data/sqoop"/>
</prepare>
<command>import
--username hip_sqoop_user
--password password
--connect jdbc:mysql://localhost/sqoop_test
--table stocks --target-dir ${nameNode}/output-data/sqoop
-m 1
</command>
</sqoop>
<ok to="end"/>
<error to="fail"/>
</action>
<kill name="fail">
<message>Sqoop failed, error message
[${wf:errorMessage(wf:lastErrorNode())}]</message>
</kill>
<end name="end"/>
</workflow-app>
单引号和双引号在 <command> 元素内不受支持,所以如果你需要指定包含空格的参数,你需要使用 <arg> 元素代替:
<arg>import</arg>
<arg>--username</arg>
<arg>hip_sqoop_user</arg>
<arg>--password</arg>
...
当使用 Oozie 的 Sqoop 时,还需要考虑的另一点是,你需要将 JDBC 驱动 JAR 文件提供给 Oozie。你可以将 JAR 文件复制到工作流程的 lib/ 目录中,或者使用 JAR 更新你的 Hadoop 安装 lib 目录。
摘要
显然,为了使 Sqoop 能够工作,你的 Hadoop 集群节点需要能够访问 MySQL 数据库。常见的错误来源是配置错误或 Hadoop 节点之间的连接问题。登录到 Hadoop 节点之一并尝试使用 MySQL 客户端连接到 MySQL 服务器,或者使用 mysqldump 实用程序尝试访问(如果你使用的是快速连接器),这可能是一个明智的选择。
当使用快速连接器时,另一个重要点是假设 mysqldump 已安装在每个 Hadoop 节点上,并且位于运行 map 任务的用户的路径中。
这总结了使用 Sqoop 从关系数据库导入数据到 Hadoop 的回顾。我们现在将从关系存储转换到 NoSQL 存储 HBase,它在与 Hadoop 数据互操作性方面表现出色,因为它使用 HDFS 来存储其数据。
5.2.4. HBase
我们将数据移动到 Hadoop 的最终尝试包括查看 HBase。HBase 是一个实时、分布式、数据存储系统,它通常位于作为你的 Hadoop 集群硬件的同一位置,或者位于 Hadoop 集群附近。能够在 MapReduce 中直接处理 HBase 数据,或者将其推送到 HDFS,是选择 HBase 作为解决方案时的巨大优势之一。
在第一种技术中,我将向你展示如何使用 HBase 附带的工具将 HBase 表保存到 HDFS 中。
技巧 43 HBase 进入 HDFS
假设你有一些客户数据存储在 HBase 中,你希望将其与 HDFS 中的数据一起用于 MapReduce,你会怎么办?你可以编写一个 MapReduce 作业,它以 HDFS 数据集为输入,并在你的 map 或 reduce 代码中直接从 HBase 拉取数据。但在某些情况下,直接将 HBase 中的数据转储到 HDFS 可能更有用,特别是如果你计划在多个 Map-Reduce 作业中利用这些数据,并且 HBase 数据是不可变的或很少更改。
问题
你希望将 HBase 数据导入到 HDFS。
解决方案
HBase 包含一个 Export 类,可以用来以 SequenceFile 格式将 HBase 数据导入 HDFS。这项技术还介绍了可以用来读取导入的 HBase 数据的代码。
讨论
在我们开始这项技术之前,你需要让 HBase 运行起来.^(22])
^([22]) 附录包含使用 HBase 的安装说明和附加资源。
要从 HBase 导出数据,你首先需要将一些数据加载到 HBase 中。加载器创建了一个名为 stocks_example 的 HBase 表,它只有一个列族,details。你将存储 HBase 数据为 Avro 二进制序列化数据。这里不展示代码,但它在 GitHub 上有。^(23])
^([23]) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch5/hbase/HBaseWriter.java.
运行加载器并使用它将示例股票数据加载到 HBase 中:
$ hip hip.ch5.hbase.HBaseWriter \
--input test-data/stocks.txt
你可以使用 HBase shell 来查看加载的结果。没有参数的 list 命令将显示 HBase 中的所有表,而带有单个参数的 scan 命令将转储一个表的所有内容:
$ hbase shell
hbase(main):012:0> list
TABLE
stocks_example
1 row(s) in 0.0100 seconds
hbase(main):007:0> scan 'stocks_example'
ROW COLUMN+CELL
AAPL2000-01-03 column=details:stockAvro, timestamp=1322315975123,...
AAPL2001-01-02 column=details:stockAvro, timestamp=1322315975123,...
...
当你的数据就绪后,你可以将其导出到 HDFS。HBase 包含一个 org.apache.hadoop.hbase.mapreduce.Export 类,该类可以将 HBase 表导出。以下代码片段展示了如何使用 Export 类的示例。使用此命令,你可以导出整个 HBase 表:

Export 类还支持仅导出单个列族,并且它还可以压缩输出:

Export 类将 HBase 输出写入 SequenceFile 格式,其中使用 org.apache.hadoop.hbase.io.ImmutableBytesWritable 存储 HBase 行键在 SequenceFile 记录键中,并使用 org.apache.hadoop.hbase.client.Result 存储 HBase 值在 SequenceFile 记录值中。
如果你想在 HDFS 中处理这些导出的数据怎么办?以下列表展示了如何读取 HBase SequenceFile 并提取 Avro 股票记录的示例.^(24])
^([24]) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch5/hbase/ExportedReader.java.
列表 5.3. 读取 HBase SequenceFile 以提取 Avro 股票记录


你可以运行代码针对你用于导出的 HDFS 目录,并查看结果:
$ hip hip.ch5.hbase.ExportedReader \
--input output/part-m-00000
AAPL2000-01-03: AAPL,2000-01-03,104.87,...
AAPL2001-01-02: AAPL,2001-01-02,14.88,...
AAPL2002-01-02: AAPL,2002-01-02,22.05,...
...
HBaseExportedStockReader 类能够读取并转储 HBase 的 Export 类所使用的 SequenceFile 的内容。
使用内置的 HBase Export类将数据从 HBase 导出到 HDFS 变得更容易。但如果你不想将 HBase 数据写入 HDFS,而是想在 MapReduce 作业中直接处理它呢?让我们看看如何将 HBase 作为 MapReduce 作业的数据源。
技巧 44:使用 HBase 作为数据源的 MapReduce
内置的 HBase 导出器使用 SequenceFile 将 HBase 数据写入,这仅由 Java 以外的编程语言支持,并且不支持模式演变。它还仅支持 Hadoop 文件系统作为数据接收器。如果你想要对 HBase 数据提取有更多控制,你可能不得不超越内置的 HBase 功能。
问题
你想在 MapReduce 作业中直接操作 HBase,而不需要将数据复制到 HDFS 的中间步骤。
解决方案
HBase 有一个TableInputFormat类,可以在你的 MapReduce 作业中使用,直接从 HBase 拉取数据。
讨论
HBase 提供了一个名为TableInputFormat的InputFormat类,可以在 MapReduce 中使用 HBase 作为数据源。以下列表显示了一个使用此输入格式(通过TableMapReduceUtil.initTableMapperJob调用)从 HBase 读取数据的 MapReduce 作业.^(25)
^(25) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch5/hbase/ImportMapReduce.java.
列表 5.4:使用 MapReduce 将 HBase 数据导入 HDFS


您可以按以下方式运行此 MapReduce 作业:
$ hip hip.ch5.hbase.ImportMapReduce --output output
快速查看 HDFS 应该会告诉你 MapReduce 作业是否按预期工作:
$ hadoop fs -cat output/part*
AAPL 111.94
AAPL 14.88
AAPL 23.3
此输出确认 MapReduce 作业按预期工作。
摘要
TableInputFormat类检查 HBase 并为每个 HBase 表区域创建一个输入拆分。如果有 10 个 HBase 区域,将执行 10 个 map 任务。输入格式还包括输入拆分中托管区域的服务器,这意味着 map 任务将被调度在托管数据的 HRegionServer 相同的节点上执行。这为您在 HBase 级别,也在 HDFS 级别提供了局部性。从区域读取的数据可能来自本地磁盘,因为经过一段时间,一个区域的所有数据都将本地化。这一切都假设 HRegion-Servers 运行在与 DataNodes 相同的宿主上。
在过去几节中,我们的重点是持久化存储,包括关系数据库和 HBase,一个 NoSQL 存储。现在我们将改变方向,看看如何利用发布-订阅系统将数据移动到 Hadoop。
5.2.5. 从 Kafka 导入数据
Kafka,一个分布式发布-订阅系统,由于其强大的分布式和性能特性,正迅速成为我们数据管道的关键部分。它可以用于许多功能,如消息传递、指标收集、流处理和日志聚合。Kafka 的另一个有效用途是将数据移动到 Hadoop 中。这在以下情况下很有用:你有实时产生并希望存入 Hadoop 的数据。
使用 Kafka 的一个关键原因是它解耦了数据生产者和消费者。它特别允许你拥有多个独立的生产者(可能由不同的开发团队编写),同样,也有多个独立的消费者(可能由不同的团队编写)。此外,消费可以是实时/同步的,也可以是批量/离线/异步的。当你查看其他发布/订阅工具如 RabbitMQ 时,这种后者的属性是一个很大的区别点。
Kafka 有几个概念你需要理解:
-
主题 —主题是一系列相关的消息。
-
分区 —每个主题由一个或多个分区组成,这些分区是有序的消息序列,由日志文件支持。^([26)]
^([26]) 我在这里不是在谈论日志文件;Kafka 使用日志文件来存储通过 Kafka 流动的数据。
-
生产者和消费者 —生产者和消费者向分区写入消息并从中读取。
-
代理 —代理是管理主题和分区并处理生产者和消费者请求的 Kafka 进程。
Kafka 不保证主题的“完全”顺序—相反,它只保证组成主题的各个分区是有序的。如果需要,由消费者应用程序强制执行“全局”的每个主题顺序。
图 5.14 展示了 Kafka 工作原理的概念模型,图 5.15 展示了在实际 Kafka 部署中分区可能如何分布。
图 5.14. 概念 Kafka 模型,展示了生产者、主题、分区和消费者

图 5.15. 一个物理 Kafka 模型,展示了分区如何在代理之间分布

为了支持容错性,主题可以被复制,这意味着每个分区可以在不同的主机上配置可配置数量的副本。这提供了更高的容错性,并且意味着单个服务器的故障不会对你的数据或生产者和消费者的可用性造成灾难性影响。
版本控制
技巧 45 使用 Kafka 版本 0.8 和 Camus 的 0.8 分支。
这就结束了我们对 Kafka 工作原理的快速探索。更多详情,请参阅 Kafka 的在线文档。
技巧 45 使用 Camus 将 Avro 数据从 Kafka 复制到 HDFS
这种技术在以下情况下很有用:你已经有了其他目的的数据在 Kafka 中流动,并且你希望将那些数据存入 HDFS。
问题
你希望使用 Kafka 作为数据交付机制,将你的数据存入 HDFS。
解决方案
使用 Camus,这是 LinkedIn 开发的将 Kafka 中的数据复制到 HDFS 的解决方案。
讨论
Camus 是由 LinkedIn 开发的开源项目。LinkedIn 在 Kafka 上的部署非常广泛,Camus 被用作将数据从 Kafka 复制到 HDFS 的工具。
默认情况下,Camus 支持 Kafka 中的两种数据格式:JSON 和 Avro。在这个技术中,我们将使 Camus 与 Avro 数据一起工作。Camus 内置对 Avro 的支持要求 Kafka 发布者以专有方式写入 Avro 数据,因此,为了这项技术,我们将假设您想在 Kafka 中使用纯 Avro 序列化数据。
要使这项技术生效,有三个部分:首先,您将一些 Avro 数据写入 Kafka,然后您将编写一个简单的类来帮助 Camus 反序列化您的 Avro 数据,最后您将运行一个 Camus 作业以执行数据导入。
将数据写入 Kafka
要开始,您将一些 Avro 记录写入 Kafka。在以下代码中,您通过配置一些必需的 Kafka 属性设置 Kafka 生产者,从文件中加载一些 Avro 记录,并将它们写入 Kafka:^([27])
²⁷ 您可以设置的所有 Kafka 属性的完整集合可以在 Kafka 文档中查看:
kafka.apache.org/documentation.html。

您可以使用以下命令将示例股票数据加载到名为 test 的 Kafka 主题中:
$ hip hip.ch5.kafka.KafkaAvroWriter \
--stocksfile test-data/stocks.txt \
--broker-list localhost:9092 \
--topic test
Kafka 控制台消费者可以用来验证数据是否已写入 Kafka。这将将二进制 Avro 数据转储到您的控制台:
$ kafka-console-consumer.sh \
--zookeeper localhost:2181 \
--topic test \
--from-beginning
完成这些后,您就准备好进行下一部分了——编写一些 Camus 代码,以便您可以在 Camus 中读取这些 Avro 记录。
编写 Camus 解码器和模式注册表
您需要了解 Camus 的三个概念:
-
解码器 —解码器的任务是将从中拉取的原始数据转换为 Camus 格式。
-
编码器 —编码器将解码后的数据序列化成将在 HDFS 中存储的格式。
-
模式注册表 —模式注册表提供了正在编码的 Avro 数据的模式信息。
如前所述,Camus 支持 Avro 数据,但它以这种方式支持,要求 Kafka 发布者使用 Camus KafkaAvroMessageEncoder 类来写入数据,该类在 Avro 序列化的二进制数据前添加一些专有数据,这可能是为了让 Camus 中的解码器验证它是由该类写入的。
在这个示例中,您使用的是直接的 Avro 序列化,因此您需要编写自己的解码器。幸运的是,这很简单:

版本控制
您可能已经注意到我们向 Kafka 中写入了一个特定的 Avro 记录,但在 Camus 中我们是以通用 Avro 记录的形式读取记录,而不是特定的 Avro 记录。这是因为 CamusWrapper 类仅支持通用 Avro 记录。否则,特定的 Avro 记录会更容易处理,因为您可以使用生成的代码,并拥有随之而来的所有类型安全的好处。
CamusWrapper 对象是从 Kafka 提取的数据的包装器。这个类存在的原因是它允许您将元数据(如时间戳、服务器名称和服务详情)放入包装器中。强烈建议您处理的所有数据都与每个记录相关联一些有意义的时戳(通常这将是记录创建或生成的时间)。然后您可以使用接受时间戳作为参数的 CamusWrapper 构造函数:
public CamusWrapper(R record, long timestamp) { ... }
如果没有设置时间戳,那么 Camus 将在包装器创建时创建一个新的时间戳。这个时间戳和其他元数据用于 Camus 确定输出记录的 HDFS 位置。您很快就会看到这个示例。
接下来,您需要编写一个模式注册表,以便 Camus Avro 编码器知道写入 HDFS 的 Avro 记录的模式详情。在注册模式时,您还指定了从其中提取 Avro 记录的 Kafka 主题的名称:
import com.linkedin.camus.schemaregistry.AvroMemorySchemaRegistry;
import hip.ch5.avro.gen.Stock;
public class StockSchemaRegistry extends AvroMemorySchemaRegistry {
public StockSchemaRegistry() {
super();
// register the schema for the topic
super.register("test", Stock.SCHEMA$);
}
}
这就是关于编码方面的全部内容!让我们继续前进,看看加缪(Camus)的实际应用。
运行 Camus
Camus 作为 MapReduce 作业在您想要导入 Kafka 数据的 Hadoop 集群上运行。您需要向 Camus 提供一些属性,您可以通过命令行或使用属性文件来实现。我们将使用属性文件来实现这项技术:
# comma-separated brokers in "host:port" format
kafka.brokers=localhost:9092
# Name of the client as seen by kafka
kafka.client.name=hip
# Top-level data output directory in HDFS
etl.destination.path=/tmp/camus/dest
# HDFS location where you want to keep execution files,
# i.e. offsets, error logs, and count files
etl.execution.base.path=/tmp/camus/work
# Where completed Camus job output directories are kept,
# usually a sub-dir in the base.path
etl.execution.history.path=/tmp/camus/history
# The decoder class
camus.message.decoder.class=hip.ch5.kafka.camus.StockMessageDecoder
# The HDFS serializer
etl.record.writer.provider.class=\
com.linkedin.camus.etl.kafka.common.AvroRecordWriterProvider
# The schema registry
kafka.message.coder.schema.registry.class=
hip.ch5.kafka.camus.StockSchemaRegistry
# Max hadoop tasks to use, each task can pull multiple topic partitions
mapred.map.tasks=2
如您从属性中看到的,您不需要明确告诉 Camus 您想导入哪些主题。Camus 会自动与 Kafka 通信以发现主题(和分区),以及当前的起始和结束偏移量。
如果您想控制导入的确切主题,可以使用 kafka.whitelist.topics 和 kafka.blacklist.topics 分别进行白名单(限制主题)或黑名单(排除主题)。可以使用逗号作为分隔符指定多个主题。正则表达式也受到支持,如下面的示例所示,它匹配主题“topic1”或以“abc”开头后跟一个或多个数字的任何主题。可以使用相同的语法指定黑名单:
kafka.whitelist.topics=topic1,abc[0-9]+
一旦您的属性都设置好了,您就可以运行 Camus 作业了:
$ CAMUS_HOME=<your Camus directory>
$ HIP_HOME=<your Hadoop in Practice directory>
$ LIBJARS="$CAMUS_HOME/camus-example/target/
camus-example-0.1.0-SNAPSHOT-shaded.jar"
$ LIBJARS=$LIBJARS=",$HIP_HOME/target/hip-2.0.0.jar"
$ export HADOOP_CLASSPATH=`echo ${LIBJARS} | sed s/,/:/g`
hadoop com.linkedin.camus.etl.kafka.CamusJob \
-libjars ${LIBJARS} \
-P $HIP_HOME/conf/camus-avro.conf
这将导致 Avro 数据落在 HDFS 中。让我们看看 HDFS 中有什么:
$ hadoop fs -lsr /tmp/camus
/tmp/camus/dest/test/hourly/2014/03/03/01/test.0.0.45.100.avro
/tmp/camus/history/2014-03-03-09-36-02/errors-m-00000
/tmp/camus/history/2014-03-03-09-36-02/offsets-m-00000
/tmp/camus/history/2014-03-03-09-36-02/offsets-previous
/tmp/camus/history/2014-03-03-09-36-02/requests.previous
第一个文件是您感兴趣的文件,因为它包含已导入的数据。其他文件是 Camus 的维护文件。
使用 AvroDump 工具可以查看 HDFS 中的数据文件:
$ hip hip.util.AvroDump \
--file /tmp/camus/dest/test/hourly/2014/03/03/01/test.0.0.45.100.avro
那么当 Camus 作业运行时实际上发生了什么?Camus 导入过程作为一个 MapReduce 作业执行,如图图 5.16 所示。
图 5.16. 查看 Camus 作业的执行过程

当 Camus 任务在 MapReduce 中成功执行时,Camus OutputCommitter(一个允许在任务完成后执行自定义工作的 MapReduce 构造)将任务的数据文件原子性地移动到目标目录。OutputCommitter 还创建了所有任务正在工作的所有分区的偏移量文件。可能同一作业中的其他任务可能会失败,但这不会影响成功任务的州——成功任务的数据和偏移量输出仍然存在,这样后续的 Camus 执行将可以从最后一个已知的成功状态恢复处理。
接下来,让我们看看 Camus 将导入的数据写入何处以及如何控制其行为。
数据分区
在之前,你看到了 Camus 从 Kafka 导入 Avro 数据的位置。让我们更仔细地看看 HDFS 路径结构,如图图 5.17 所示,并看看你能做什么来确定位置。
图 5.17. 分析 HDFS 中导出数据的 Camus 输出路径

路径的日期/时间部分由从CamusWrapper中提取的时间戳确定。你可能会记得,在我们的早期讨论中,你可以在你的MessageDecoder中从你的 Kafka 记录中提取时间戳,并将它们提供给CamusWrapper,这将允许你的数据根据对你有意义的日期进行分区,而不是默认的,即 Kafka 记录在 MapReduce 中被读取的时间。
Camus 支持可插拔的分区器,这允许你控制图 5.18 中显示的路径部分。
图 5.18. Camus 分区器路径

Camus Partitioner接口提供了两个你必须实现的方法:
public interface Partitioner {
/**
* Encode partition values into a string, to be embedded
* into the working filename.
* Encoded values cannot use '/' or ':'.
*/
String encodePartition(JobContext context, IEtlKey etlKey);
/**
* Return a string representing the partitioned directory
* structure where the .avro files will be moved.
*
* For example, if you were using Hive style partitioning,
* a timestamp based partitioning scheme would return
* topic-name/year=2012/month=02/day=04/hour=12
*
*/
String generatePartitionedPath(JobContext context, String topic,
int brokerId, int partitionId, String encodedPartition);
}
例如,一个自定义分区器可以创建一个路径,可以用于 Hive 分区。
摘要
Camus 为将数据从 Kafka 加载到 HDFS 提供了一个完整的解决方案,并在出错时处理状态维护和错误处理。它可以很容易地通过将其与 Azkaban 或 Oozie 集成来自动化,并且它通过根据消息摄入的时间组织 HDFS 数据来提供一些简单的数据管理功能。值得注意的是,与 Flume 相比,在 ETL 方面,它的功能相对简单。
Kafka 附带了一个将数据拉入 HDFS 的机制。它有一个KafkaETLInputFormat输入格式类,可以在 MapReduce 作业中用于从 Kafka 拉取数据。它要求你编写 MapReduce 作业以执行导入,但优点是你可以直接在你的 MapReduce 流程中使用数据,而不是像使用 HDFS 作为数据的中介存储那样。
Flume 项目也正在添加 Kafka 源和目标,尽管在撰写本文时这项工作仍在进行中。^([28)] 一旦这项工作准备就绪,你将能够利用 Flume 提供的所有其他好处,例如 Morphlines 和 Solr 索引,作为将 Kafka 数据移动到 Hadoop 的一部分。
这就结束了我们对如何将数据移动到 Hadoop 的探讨。我们涵盖了广泛的数据类型、工具和技术。接下来,我们将反过来看看如何将驻留在 Hadoop 中的数据导出到其他系统,例如文件系统和其他存储系统。
5.3. 从 Hadoop 中移动数据
一旦你使用 Hadoop 执行了一些关键功能,无论是数据挖掘还是数据聚合,下一步通常是将其数据外部化到你的环境中的其他系统中。例如,依赖于 Hadoop 对从你的实时系统中提取的数据进行离线聚合是很常见的,然后将派生的数据反馈到你的实时系统中。一个更具体的例子是根据用户行为模式构建推荐。
本节将探讨一些更常见的场景,在这些场景中,你想要从 Hadoop 中导出数据,以及帮助你完成这项工作的工具。我们将从查看现有的底层工具开始,这些工具大多数都集成在 Hadoop 中,然后继续探讨如何将数据推送到关系数据库和 HBase。
首先,我们将探讨如何使用命令行从 Hadoop 中复制文件。
5.3.1. 自行实现出口
本节涵盖了 Hadoop 中用于从 HDFS 复制数据的内置机制。这些技术可以手动执行,或者你需要使用 Azkaban、Oozie 或甚至 cron 等调度系统来自动化它们。
技巧 46 使用 CLI 提取文件
假设你已经运行了一些 Hadoop 作业来聚合一些数据,现在你想要将其导出。你可以使用的一种方法是使用 HDFS 命令行界面(CLI)将目录和文件拉取到你的本地文件系统中。这项技术涵盖了可以帮助你的一些基本 CLI 命令。
问题
你想使用 shell 将文件从 HDFS 复制到本地文件系统。
解决方案
HDFS CLI 可用于一次性移动,或者可以将相同的命令集成到脚本中以实现更频繁的移动。
讨论
通过hadoop命令从 HDFS 复制文件到本地磁盘:
$ hadoop fs -get hdfs-file.txt local-file.txt
Hadoop 的put命令的行为与 Linux 的cp命令不同——在 Linux 中,如果目标已存在,则会覆盖它;在 Hadoop 中,复制会因错误而失败:
put: `hdfs-file.txt': File exists
必须添加-f选项来强制覆盖文件:
$ hadoop fs -get -f hdfs-file.txt local-file.txt
与 Linux cp 命令类似,可以使用相同的命令复制多个文件。在这种情况下,最后一个参数必须是本地文件系统中 HDFS 文件复制的目录:
$ hadoop fs -get hdfs-file1.txt hdfs-file2.txt /local/dest/
通常,人们会从 HDFS 复制大量文件到本地磁盘——一个例子是包含每个任务文件的 MapReduce 作业输出目录。如果您使用的是可以连接的文件格式,您可以使用 -getmerge 命令来合并多个文件。默认情况下,在连接每个文件时都会添加一个换行符:
$ hdfs fs -getmerge hdfs-dir/part* /local/output.txt
fs 命令支持许多更多操作——要查看完整列表,请在没有任何选项的情况下运行该命令。
使用 CLI 的挑战在于它非常底层,并且无法协助您满足自动化需求。当然,您可以在 shell 脚本中使用 CLI,但一旦您过渡到更复杂的编程语言,为每个 HDFS 命令启动一个进程并不是理想的选择。在这种情况下,您可能想考虑使用 REST、Java 或 C HDFS API。下一技巧将探讨 REST API。
技巧 47 使用 REST 提取文件
使用 CLI 对于快速运行命令和脚本来说很方便,但它会产生为每个命令启动一个单独进程的开销,这是您可能想要避免的开销,尤其是如果您正在用编程语言与 HDFS 交互。
问题
您希望能够使用没有 HDFS 原生接口的编程语言与 HDFS 交互。
解决方案
使用 Hadoop 的 WebHDFS 接口,它为 HDFS 操作提供了一整套功能的 REST API。
讨论
在您开始之前,您需要在您的集群上启用 WebHDFS——有关如何操作的详细信息,请参阅技巧 34。
让我们从使用 CLI 在 HDFS 中创建一个文件开始:
$ echo "the cat sat on the mat" | hadoop fs -put - /tmp/hdfs-file.txt
从 HDFS 读取文件只需指定 OPEN 作为操作:
$ curl -L "http://0.0.0.0:50070/webhdfs/v1/tmp/hdfs-file.txt?the cat sat on the mat
op=OPEN&user.name=aholmes"
有关使用 WebHDFS 的更多信息,包括如何在不同的编程语言中利用它,请参考技巧 34。
技巧 48 在防火墙后面从 HDFS 读取
生产 Hadoop 环境通常被锁定以保护这些集群中驻留的数据。安全程序的一部分可能包括将您的集群置于防火墙之后,如果您的 Hadoop 集群的目标位于防火墙之外,这可能会很麻烦。本技巧探讨了使用 HttpFS 网关通过端口 80 提供对 HDFS 的访问,端口 80 通常在防火墙上已打开。
问题
您想从 HDFS 中提取数据,但您坐在一个限制对 HDFS 访问的防火墙后面。
解决方案
使用 HttpFS 网关,这是一个独立的服务器,它通过 HTTP 提供对 HDFS 的访问。因为它是一个独立的服务,并且是 HTTP,所以它可以配置在任何可以访问 Hadoop 节点的宿主上运行,并且您可以打开防火墙规则以允许流量访问该服务。
讨论
HttpFS 很有用,因为它不仅可以通过 REST 访问 HDFS,而且它有一个完整的 Hadoop 文件系统实现,这意味着你可以使用 CLI 和本地的 HDFS Java 客户端与 HDFS 通信。有关如何设置 HttpFS 的说明,请参考技巧 35。
一旦运行起来,你就可以使用与之前技巧中 WebHDFS 相同的 curl 命令(唯一的区别是 URL 的主机和端口,需要指向你的 HttpFS 部署位置)。这是 HttpFS 网关的一个优点——语法完全相同。
要转储文件 /tmp/hdfs-file.txt 的内容,你会这样做:
$ curl -L "http://0.0.0.0:140000/webhdfs/v1/tmp/hdfs-file.txt?the cat sat on the mat
op=OPEN&user.name=aholmes"
想了解更多关于 HttpFS 如何工作的细节,请查看技巧 35。
技巧 49 使用 NFS 安装 Hadoop
如果 Hadoop 数据可以作为常规挂载访问你的文件系统,那么与 Hadoop 数据交互通常会容易得多。这允许你使用现有的脚本、工具和编程语言,并轻松地在 HDFS 中与数据交互。本节将探讨如何使用 NFS 挂载轻松地将数据从 HDFS 复制出来。
问题
你希望将 HDFS 视为一个常规的 Linux 文件系统,并使用标准的 Linux 工具与 HDFS 交互。
解决方案
使用 Hadoop 的 NFS 实现来访问 HDFS 中的数据。
讨论
技巧 36 提供了 NFS 访问 HDFS 的设置说明。一旦设置好,你就可以执行正常的文件系统操作,例如将文件从 HDFS 复制到本地文件系统。以下示例展示了这一点,假设 HDFS 挂载在 /hdfs 下:
$ cp /hdfs/tmp/foo.txt ~/
想了解更多关于 NFS 在 Hadoop 中如何工作的细节,请查看技巧 36。
技巧 50 使用 DistCp 从 Hadoop 中复制数据
假设你有一大批数据想要从 Hadoop 中移出。在本节的大多数技巧中,你会有一个瓶颈,因为你正在通过单个主机传输数据,这个主机是你运行进程的主机。为了尽可能优化数据传输,你想要利用 MapReduce 并行复制数据。这就是 DistCp 发挥作用的地方,本技巧将探讨一种将数据拉到 NFS 挂载的方法。
问题
你希望高效地从 Hadoop 中提取数据并并行复制。
解决方案
使用 DistCp。
讨论
技巧 37 详细介绍了 DistCp,并包括在不同 Hadoop 集群之间复制数据的详细信息。但是,DistCp 不能用来从 Hadoop 复制数据到本地文件系统(或反之亦然),因为 DistCp 作为一个 MapReduce 作业运行,你的集群将无法访问本地文件系统。根据你的情况,你有几个选择:
-
使用 HDFS File Slurper 复制本地文件。
-
将你的文件复制到一个对所有集群 DataNode 都可用的 NFS。
如果你选择第二个选项,你可以使用 DistCp 并将数据写入每个 DataNode 上可用的本地挂载的 NFS,以下是一个示例:
$ hadoop distcp \
hdfs://src \
file://mount1/dest
注意,你的 NFS 系统可能无法处理大量的并行读写操作,因此你可能希望使用比默认的 20 个更少的映射器数量来运行此操作——以下示例使用 5 个映射器运行:
$ hadoop distcp \
-m 5 \
hdfs://src \
file://mount1/dest
技巧 51 使用 Java 提取文件
假设你在 HDFS 中生成了一些 Lucene 索引,你想要将它们拉到一个外部主机上。也许你想用 Java 以某种方式操作这些文件。这项技术展示了如何使用 Java HDFS API 读取 HDFS 中的数据。
问题
你希望将 HDFS 中的文件复制到本地文件系统。
解决方案
使用 Hadoop 的文件系统 API 将数据从 HDFS 中复制出来。
讨论
HDFS Java API 与 Java 的 I/O 模型很好地集成,这意味着你可以使用常规输入流和输出流进行 I/O 操作。
首先,你需要使用命令行在 HDFS 中创建一个文件:
$ echo "hello world" | hadoop fs -put - hdfs-file.txt
现在请使用命令行将文件复制到本地文件系统:
$ hadoop fs -get hdfs-file.txt local-file.txt
让我们探索如何在 Java 中复制这个操作。编写执行此操作的代码有两个主要部分——第一部分是获取FileSystem句柄并创建文件,第二部分是将数据从标准输入复制到OutputStream:
![245fig01_alt.jpg]
你可以通过运行以下命令来查看此代码的实际工作方式:
$ echo "the cat" | hadoop fs -put - hdfs-file.txt
$ hip hip.ch5.CopyHdfsFileToLocal \
--input hdfs-file.txt \
--output local-file.txt
$ cat local-file.txt
the cat
到目前为止,我们已经介绍了 Hadoop 附带的一些低级工具,这些工具可以帮助你提取数据。接下来,我们将探讨一种将数据从 HDFS 到本地文件系统进行近似连续移动的方法。
5.3.2. 自动化文件出口
到目前为止,你已经看到了从 HDFS 中复制数据的不同选项。其中大部分机制没有自动化或调度功能;它们最终是访问数据的基础级方法。如果你想要自动化数据复制,你可以在 cron 或 Quartz 等调度引擎中包装这些低级技术之一。然而,如果你正在寻找即插即用的自动化,那么这一节就是为你准备的。
在本章早期,我们探讨了两种可以将半结构化和二进制数据移动到 HDFS 的机制:开源的 HDFS File Slurper 项目,以及触发数据入口工作流的 Oozie。使用本地文件系统进行出口(以及入口)的挑战在于,在集群上运行的映射和归约任务将无法访问特定服务器的文件系统。从 HDFS 到文件系统移动数据,你有三种广泛的选择:
-
你可以在服务器上托管一个代理层,例如 Web 服务器,然后使用 MapReduce 写入它。
-
你可以在 MapReduce 中写入本地文件系统,然后作为后处理步骤,在远程服务器上触发一个脚本以移动这些数据。
-
你可以在远程服务器上运行一个进程,直接从 HDFS 中提取数据。
第三种方法是首选方法,因为它最简单、最有效,因此本节将重点介绍。我们将探讨如何使用 HDFS 文件 Slurper 自动将文件从 HDFS 移动到本地文件系统。
技术篇 52 从 HDFS 自动导出文件的方法
假设你正在 HDFS 中通过 MapReduce 编写文件,并且想要自动化将它们提取到本地文件系统中。这种功能任何 Hadoop 工具都不支持,所以你必须另寻他法。
问题
你想要自动化将文件从 HDFS 移动到本地文件系统。
解决方案
HDFS 文件 Slurper 可以用来将文件从 HDFS 复制到本地文件系统。
讨论
这里的目标是使用 HDFS 文件 Slurper 项目 (github.com/alexholmes/hdfs-file-slurper) 来辅助自动化。我们在技术 40 中详细介绍了 HDFS 文件 Slurper,请在继续本技术之前阅读该部分。
除了你在技术 40 中使用的方法之外,HDFS Slurper 还支持将数据从 HDFS 移动到本地目录。你只需要交换源目录和目标目录,就像你可以在 Slurper 的配置文件以下子节中看到的那样:
SRC_DIR = hdfs:/tmp/slurper/in
WORK_DIR = hdfs:/tmp/slurper/work
COMPLETE_DIR = hdfs:/tmp/slurper/complete
ERROR_DIR = hdfs:/tmp/slurper/error
DEST_STAGING_DIR = file:/tmp/slurper/stage
DEST_DIR = file:/tmp/slurper/dest
你会注意到,不仅源目录在 HDFS 中,工作、完成和错误目录也在那里。这是因为你需要能够在目录之间原子性地移动文件,而不会产生跨文件系统复制文件的高昂开销。
摘要
到目前为止,你可能想知道如何触发 Slurper 复制一个刚刚通过 MapReduce 作业编写的目录。当 MapReduce 作业成功完成后,它会在作业输出目录中创建一个名为 _SUCCESS 的文件。这似乎是启动出口过程将内容复制到本地文件系统的完美触发器。实际上,Oozie 有一种机制可以在检测到这些 Hadoop “成功”文件时触发工作流,但同样,这里的挑战在于 Oozie 执行的所有工作都是在 MapReduce 中进行的,因此不能直接用于执行传输。
你可以编写自己的脚本来轮询 HDFS 中完成的目录,然后触发文件复制过程。这个文件复制过程可以是 Slurper 或简单的 hadoop fs -get 命令,如果需要保持源文件完整的话。
在下一个主题中,我们将探讨如何将数据从 Hadoop 写入关系型数据库。
5.3.3. 数据库
数据库通常在两种情况下成为 Hadoop 数据出口的目标:要么是将数据移回生产数据库以供生产系统使用,要么是将数据移入 OLAP 数据库以执行商业智能和分析功能。
在本节中,我们将使用 Apache Sqoop 将数据从 Hadoop 导出到 MySQL 数据库。Sqoop 是一个简化数据库导入和导出的工具。Sqoop 在技术 42 中有详细说明。
我们将逐步介绍从 HDFS 导出数据到 Sqoop 的过程。我们还将介绍使用常规连接器的方法,以及如何使用快速连接器执行批量导入。
技术编号 53 使用 Sqoop 将数据导出到 MySQL
Hadoop 擅长执行大多数关系型数据库无法应对的规模操作,因此通常会将 OLTP 数据提取到 HDFS 中,进行一些分析,然后再将其导出回数据库。
问题
您希望将数据写入关系型数据库,同时确保写入是幂等的。
解决方案
这种技术涵盖了如何使用 Sqoop 将文本文件导出到关系型数据库,以及如何配置 Sqoop 以与具有自定义字段和记录分隔符的文件一起工作。我们还将介绍幂等导出,以确保失败的导出不会使您的数据库处于不一致的状态。
讨论
本技术假设您已经按照技术 42 中的说明安装了 MySQL 并创建了模式。
Sqoop 导出要求您要导出的数据库表已经存在。Sqoop 可以支持表中行的插入和更新。
将数据导出到数据库与我们在导入部分中检查的许多参数相同。区别在于导出需要--export-dir参数来确定导出的 HDFS 目录。您还将为导出创建另一个选项文件,以避免在命令行上不安全地提供密码:
$ cat > ~/.sqoop_export_options.txt << EOF
export
--username
hip_sqoop_user
--password
password
--connect
jdbc:mysql://localhost/sqoop_test
EOF
$ chmod 700 ~/.sqoop_export_options.txt
您的第一步将是将数据从 MySQL 导出到 HDFS,以确保您有一个良好的起点,如下所示:
$ hadoop fs -rmr stocks
$ sqoop --options-file ~/.sqoop_import_options.txt \
--connect jdbc:mysql://localhost/sqoop_test --table stocks
如下代码所示,Sqoop 导入的结果是在 HDFS 中生成多个 CSV 文件:
$ hadoop fs -cat stocks/part-m-00000 | head
1,AAPL,2009-01-02,85.88,91.04,85.16,90.75,26643400,90.75
2,AAPL,2008-01-02,199.27,200.26,192.55,194.84,38542100,194.84
...
对于从 HDFS 到 MySQL 的 Sqoop 导出,您将指定目标表应为 stocks_export,并且应从 HDFS 的 stocks 目录导出数据:
$ sqoop --options-file ~/.sqoop_export_options.txt \
--export-dir stocks \
--table stocks_export
默认情况下,Sqoop 导出将向目标数据库表执行INSERT操作。它可以支持使用--update-mode参数进行更新。updateonly的值表示如果没有匹配的键,更新将失败。allowinsert的值表示如果不存在匹配的键,则进行插入。用于执行更新的表列名由--update-key参数提供。
以下示例表明,仅尝试使用更新主键进行更新:
$ sqoop --options-file ~/.sqoop_export_options.txt \
--update-mode updateonly \
--update-key id \
--export-dir stocks \
--table stocks_export
输入数据格式
您可以使用几种选项来覆盖默认的 Sqoop 设置以解析输入数据。表 5.7 列出了这些选项。
表 5.7. 输入数据的格式化选项
| 参数 | 默认值 | 描述 |
|---|---|---|
| --input-enclosed-by | (None) | 字段包围字符。每个字段都必须用此字符包围。(如果字段包围字符可以出现在字段内部,应使用 --input-optionally-enclosed-by 选项来包围该字段。) |
| --input-escaped-by | (None) | 转义字符,其中下一个字符被字面提取,不会被解析。 |
| --input-fields-terminated-by | , | 字段分隔符。 |
| --input-lines-terminated-by | \n | 行终止符。 |
| --input-optionally-enclosed-by | (None) | 字段包围字符。此参数与 --input-enclosed-by 相同,但仅应用于包含字段分隔符的字段。例如,在 CSV 中,字段通常仅在包含逗号时才用双引号包围。 |
幂等导出
执行导出的 Sqoop 映射任务使用多个事务进行数据库写入。如果 Sqoop 导出 MapReduce 作业失败,您的表可能包含部分写入。为了进行幂等数据库写入,Sqoop 可以被指示将 MapReduce 写入到临时表中。作业成功完成后,临时表将作为一个事务移动到目标表,这是幂等的。您可以在图 5.19 中查看事件序列。
图 5.19. Sqoop 事件序列的临时表,有助于确保幂等写入

在以下示例中,临时表是 stocks_staging,并且您还告诉 Sqoop 在 MapReduce 作业开始前使用 --clear-staging-table 参数将其清空:
$ sqoop --options-file ~/.sqoop_export_options.txt \
--export-dir stocks \
--table stocks_export \
--staging-table stocks_staging \
--clear-staging-table
直接导出
您在导入技术中使用了快速连接器,这是一种使用 mysqldump 工具的优化。Sqoop 导出也支持使用快速连接器,该连接器使用 mysqlimport 工具。与导入一样,您的集群中的所有节点都需要安装 mysqlimport 并在运行 MapReduce 任务的用户的路径中可用。并且与导入一样,--direct 参数启用快速连接器的使用:
$ sqoop --options-file ~/.sqoop_export_options.txt \
--direct \
--export-dir stocks \
--table stocks_export
使用 mysqlimport 进行幂等导出
Sqoop 不支持与临时表一起使用快速连接器,这是使用常规连接器实现幂等写入的方式。但通过您的一点点额外工作,仍然可以使用快速连接器实现幂等写入。您需要使用快速连接器将数据写入临时表,然后触发 INSERT 语句,将数据原子性地复制到目标表中。步骤如下:
$ sqoop --options-file ~/.sqoop_export_options.txt \
--direct \
--export-dir stocks \
--table stocks_staging
$ mysql --host=localhost \
--user=hip_sqoop_user \
--password=password \
-e "INSERT INTO stocks_export (SELECT * FROM stocks_staging)"\
sqoop_test
这打破了之前关于在命令行上暴露凭证的规则,但可以轻松编写一个包装脚本,从配置文件中读取这些设置。
摘要
Sqoop 相比于在 MapReduce 中使用提供的 DBInputFormat 格式类提供了简化的使用模型。但使用 DBInputFormat 类将为您提供在执行数据库导出的同一 MapReduce 作业中转换或预处理数据的额外灵活性。Sqoop 的优势在于它不需要您编写任何代码,并且有一些有用的概念,例如临时存储,以帮助您实现幂等目标。
本节和本章的最后一步是查看将数据导出到 HBase。
5.3.4. NoSQL
MapReduce 是将数据批量加载到外部系统的一种强大且高效的方式。到目前为止,我们已经介绍了如何使用 Sqoop 加载关系数据,现在我们将探讨 NoSQL 系统,特别是 HBase。
Apache HBase 是一个分布式键/值、列式数据存储。在本章早期,我们探讨了如何从 HBase 导入数据到 HDFS,以及如何将 HBase 作为 MapReduce 作业的数据源。
将数据加载到 HBase 中最有效的方法是通过其内置的大批量加载机制,这在 HBase wiki 页面上有详细描述,标题为“批量加载”,网址为 hbase.apache.org/book/arch.bulk.load.html。但这种方法绕过了预写日志(WAL),这意味着正在加载的数据不会被复制到从属 HBase 节点。
HBase 还附带了一个 org.apache.hadoop.hbase.mapreduce.Export 类,该类可以从 HDFS 加载 HBase 表,类似于本章早期等效的导入操作。但您必须以 SequenceFile 形式存储数据,这有一些缺点,包括不支持版本控制。
您也可以在您的 MapReduce 作业中使用 TableOutputFormat 类将数据导出到 HBase,但这种方法比批量加载工具慢。
我们现在已经完成了对 Hadoop 导出工具的考察。我们介绍了如何使用 HDFS 文件吞噬者将数据移动到文件系统,以及如何使用 Sqoop 对关系数据库进行幂等写入,并以查看将 Hadoop 数据移动到 HBase 的方法结束。
5.4. 章节总结
在 Hadoop 中移动数据进出是 Hadoop 架构的关键部分。在本章中,我们介绍了一系列技术,您可以使用这些技术执行数据导入和导出活动,并且与各种数据源兼容。值得注意的是,我们介绍了 Flume,一个数据收集和分发解决方案,Sqoop,一个用于在 Hadoop 中移动关系数据的工具,以及 Camus,一个用于将 Kafka 数据导入 HDFS 的工具。
现在您的数据已经存储在 HDFS 中,是时候查看一些可以应用于该数据的有趣处理模式了。
第三部分. 大数据模式
现在你已经了解了 Hadoop,并且知道如何在 Hadoop 中最佳地组织、移动和存储你的数据,你就可以探索这本书的第三部分,它将探讨你需要了解的技术,以简化你的大数据计算。
在第六章中,我们将探讨优化 MapReduce 操作的技术,例如在大数据集上进行连接和排序。这些技术使作业运行得更快,并允许更有效地使用计算资源。
第七章探讨了如何在 Map-Reduce 中表示和利用图来解决如朋友的朋友和 PageRank 等算法。它还涵盖了当常规数据结构无法扩展到你处理的数据大小时的 Bloom 过滤器和高斯日志数据结构的使用。
第八章探讨了如何衡量、收集和配置你的 MapReduce 作业,并确定可能导致作业运行时间超过预期的时间的代码和硬件中的区域。它还通过展示不同的单元测试方法来驯服 MapReduce 代码。最后,它探讨了如何调试任何 Map-Reduce 作业,并提供了一些你最好避免的反模式。
第六章. 将 MapReduce 模式应用于大数据
本章涵盖
-
学习如何使用 map 端和 reduce 端连接来连接数据
-
理解二级排序的工作原理
-
发现分区是如何工作的以及如何全局排序数据
当你的数据安全地存储在 HDFS 中时,是时候学习如何在 MapReduce 中处理这些数据了。前面的章节在处理数据序列化时向你展示了 MapReduce 的一些代码片段。在本章中,我们将探讨如何在 MapReduce 中有效地处理大数据以解决常见问题。
MapReduce 基础
如果你想要了解 Map-Reduce 的机制以及如何编写基本的 MapReduce 程序,花时间去阅读 Chuck Lam(Manning,2010 年)的《Hadoop 实战》是值得的。
MapReduce 包含许多强大的功能,在本章中,我们将重点关注连接、排序和采样。这三个模式很重要,因为它们是你将在大数据上自然执行的操作,你的集群的目标应该是从 MapReduce 作业中榨取尽可能多的性能。
能够连接不同和稀疏的数据是一个强大的 MapReduce 功能,但在实践中却有些尴尬,因此我们还将探讨优化大型数据集连接操作的先进技术。连接的例子包括将日志文件与数据库中的参考数据合并以及网页图上的入链计算。
排序在 MapReduce 中也是一种黑魔法,我们将深入 Map-Reduce 的深处,通过检查每个人都会遇到的两个技术来了解它是如何工作的:二级排序和全序排序。我们将通过查看 MapReduce 中的抽样来结束讨论,这通过处理数据的一个小子集提供了快速遍历大数据集的机会。
6.1. 连接
连接是用于组合关系的关联构造(您可能熟悉在数据库上下文中的它们)。在 MapReduce 中,当您有两个或更多想要组合的数据集时,连接是适用的。一个例子是当您想将用户(您从 OLTP 数据库中提取的)与日志文件(包含用户活动细节)结合起来。存在各种场景,其中结合这些数据集会有所帮助,例如这些:
-
您希望根据用户人口统计信息(例如用户习惯的差异,比较青少年和 30 多岁的用户)进行数据聚合。
-
您希望向那些在规定天数内未使用网站的用户发送电子邮件。
-
您希望创建一个反馈循环,该循环检查用户的浏览习惯,使您的系统能够向用户推荐之前未探索过的网站功能。
所有这些场景都需要您将数据集连接在一起,最常见的两种连接类型是内连接和外连接。内连接比较关系L和R中的所有元组,如果满足连接谓词则产生结果。相比之下,外连接不需要基于连接谓词匹配两个元组,并且即使没有匹配也可以保留L或R中的记录。图 6.1 展示了不同类型的连接。
图 6.1. 以维恩图形式展示的不同类型的连接关系,阴影区域显示在连接中保留的数据。

在本节中,我们将探讨 MapReduce 中的三种连接策略,这些策略支持两种最常见的连接类型(内连接和外连接)。这三种策略通过利用 MapReduce 的排序-合并架构,在 map 阶段或 reduce 阶段执行连接操作:
-
重新分区连接 —适用于您要连接两个或更多大型数据集的情况的 reduce 端连接
-
复制连接 —一种在其中一个数据集足够小以缓存的情况下工作的 map 端连接
-
半连接 —另一种 map 端连接,其中一个数据集最初太大而无法放入内存,但在一些过滤之后可以减小到可以放入内存的大小
在我们介绍完这些连接策略之后,我们将查看一个决策树,以便您可以确定最适合您情况的最佳连接策略。
连接数据
这些技术将利用两个数据集来执行连接操作——用户和日志。用户数据包含用户名、年龄和州。完整的数据集如下:
anne 22 NY
joe 39 CO
alison 35 NY
mike 69 VA
marie 27 OR
jim 21 OR
bob 71 CA
mary 53 NY
dave 36 VA
dude 50 CA
日志数据集显示了可以从应用程序或 Web 服务器日志中提取的一些基于用户的活动。数据包括用户名、操作和源 IP 地址。以下是完整的数据集:
jim logout 93.24.237.12
mike new_tweet 87.124.79.252
bob new_tweet 58.133.120.100
mike logout 55.237.104.36
jim new_tweet 93.24.237.12
marie view_user 122.158.130.90
jim login 198.184.237.49
marie login 58.133.120.100
让我们从查看根据您的数据应选择哪种连接方法开始。
技巧 54 为您的数据选择最佳连接策略
本节中涵盖的每种连接策略都有不同的优缺点,确定哪种最适合您正在处理的数据可能具有挑战性。这项技术将查看数据的不同特性,并使用这些信息来选择连接数据的最佳方法。
问题
您想要选择最佳方法来连接您的数据。
解决方案
使用数据驱动的决策树来选择最佳连接策略。
讨论
图 6.2 显示了您可以使用的决策树。^([1)]
¹ 此决策树是根据 Spyros Blanas 等人提出的决策树模型,在“MapReduce 中日志处理连接算法的比较”中提出的,
pages.cs.wisc.edu/~jignesh/publ/hadoopjoin.pdf。
图 6.2. 选择连接策略的决策树

决策树可以总结为以下三个要点:
-
如果您的数据集之一足够小,可以放入映射器的内存中,则仅映射的复制连接效率很高。
-
如果两个数据集都很大,并且可以通过预过滤不匹配另一个数据集的元素来显著减少其中一个数据集,那么半连接(semi-join)效果很好。
-
如果您无法预处理数据,并且数据大小太大而无法缓存——这意味着您必须在 reducer 中执行连接——则需要使用重新分区连接。
无论您选择哪种策略,您在连接操作中最基本的活动之一应该是使用过滤和投影。
技巧 55 过滤、投影和下推
在这项技术中,我们将探讨您如何有效地在映射器中使用过滤和投影来减少您正在处理的数据量,以及在 MapReduce 中的溢出。这项技术还探讨了更高级的优化,称为下推(pushdowns),这可以进一步提高您的数据管道效率。
问题
您正在处理大量数据,并且想要高效地管理您的输入数据以优化您的作业。
解决方案
过滤和投影您的数据,仅包括您将在工作中使用的数据点。
讨论
在连接数据以及一般处理数据时,过滤和投影数据是您可以做出的最大优化。这是一种适用于任何 OLAP 活动的技术,在 Hadoop 中同样有效。
为什么过滤和投影如此重要?它们减少了处理管道需要处理的数据量。处理更少的数据很重要,尤其是在你推动数据跨越网络和磁盘边界时。MapReduce 中的洗牌步骤很昂贵,因为数据正在写入本地磁盘和通过网络传输,所以需要推送的字节数越少,你的作业和 MapReduce 框架的工作量就越少,这转化为更快的作业和更少的 CPU、磁盘和网络设备的压力。
图 6.3 展示了过滤和投影如何工作的一个简单示例。
图 6.3. 使用过滤和投影来减少数据大小

过滤和投影应尽可能接近数据源执行;在 MapReduce 中,这项工作最好在映射器中完成。以下代码展示了排除 30 岁以下用户并仅投影其姓名和状态的过滤器的示例:

在连接中使用过滤器时面临的挑战是,你连接的数据集中可能并不包含你想要过滤的字段。如果情况如此,请查看技术 61,它讨论了使用布隆过滤器来帮助解决这个挑战。
推下式
投影和谓词推下式通过将投影和谓词推到存储格式来进一步扩展过滤功能。这甚至更加高效,尤其是在与可以基于推下式跳过记录或整个块的存储格式一起工作时。
表 6.1 列出了各种存储格式以及它们是否支持推下式。
表 6.1. 存储格式及其推下式支持
| 格式 | 支持投影推下式? | 支持谓词推下式? |
|---|---|---|
| 文本(CSV、JSON 等) | 否 | 否 |
| 协议缓冲区 | 否 | 否 |
| 节俭 | 否 | 否 |
| Avro^([a]) | 否 | 否 |
| Parquet | 是 | 是 |
^a Avro 具有行主序和列主序的存储格式。
推下式存储的进一步阅读
第三章 包含了有关如何在作业中使用 Parquet 推下式的更多详细信息。
很明显,Parquet 的一大优势是它能够支持这两种类型的推下式。如果你正在处理大量数据集,并且经常只处理记录和字段的一个子集,那么你应该考虑将 Parquet 作为你的存储格式。
是时候继续实际连接技术了。
6.1.1. 映射端连接
我们对连接技术的覆盖将从查看在映射器中执行连接开始。我们将首先介绍这些技术的原因是,如果你的数据可以支持映射端连接,那么它们是最佳连接策略。与在映射器和还原器之间洗牌数据造成的开销相比,减少大小连接更昂贵。作为一般原则,映射端连接更受欢迎。
在本节中,我们将探讨三种不同的 map-side 连接类型。技术 56 在其中一个数据集已经足够小,可以缓存到内存中的情况下效果良好。技术 57 更为复杂,并且还要求在过滤掉两个数据集中都存在的连接键的记录后,一个数据集可以适合内存。技术 58 在您的数据按某种方式排序并分布到文件中的情况下工作。
技术编号 56 在一个数据集可以适合到 mapper 的内存中执行连接
复制连接是一种 map-side 连接,其名称来源于其功能——数据集中最小的一个被复制到所有的 map 主机。复制连接依赖于这样一个事实,即要连接的数据集中有一个足够小,可以缓存到内存中。
问题
您想在可以适合您 mapper 的内存中的数据上执行连接操作。
解决方案
使用分布式缓存来缓存较小的数据集,并在较大的数据集被流式传输到 mapper 时执行连接。
讨论
您将使用分布式缓存将小数据集复制到运行 map 任务的节点,并使用每个 map 任务的初始化方法将其加载到散列表中。使用来自大数据集的每个记录的键来查找小数据集的散列表,并在大数据集的记录与小数据集中匹配连接值的所有记录之间执行连接。图 6.4 展示了在 MapReduce 中复制的连接是如何工作的。
² Hadoop 的分布式缓存会在任何 map 或 reduce 任务在节点上执行之前,将位于 MapReduce 客户端主机上的文件或 HDFS 中的文件复制到从节点。任务可以从它们的本地磁盘读取这些文件,作为它们工作的一部分。
图 6.4. 仅 map 的复制连接

以下代码执行此连接:^([3])
³ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/joins/replicated/simple/ReplicatedJoin.java。

要执行此连接,您首先需要将您要连接的两个文件复制到 HDFS 中的家目录:
$ hadoop fs -put test-data/ch6/users.txt .
$ hadoop fs -put test-data/ch6/user-logs.txt .
然后,运行作业,并在完成后检查其输出:
$ hip hip.ch6.joins.replicated.simple.ReplicatedJoin \
--users users.txt \
--user-logs user-logs.txt \
--output output
$ hadoop fs -cat output/part*
jim 21 OR jim logout 93.24.237.12
mike 69 VA mike new_tweet 87.124.79.252
bob 71 CA bob new_tweet 58.133.120.100
mike 69 VA mike logout 55.237.104.36
jim 21 OR jim new_tweet 93.24.237.12
marie 27 OR marie view_user 122.158.130.90
jim 21 OR jim login 198.184.237.49
marie 27 OR marie login 58.133.120.100
Hive
Hive 的连接操作可以通过在执行前配置作业来转换为 map-side 连接。重要的是最大的表应该是查询中的最后一个表,因为 Hive 会将这个表流式传输到 mapper 中(其他表将被缓存):
set hive.auto.convert.join=true;
SELECT /*+ MAPJOIN(l) */ u.*, l.*
FROM users u
JOIN user_logs l ON u.name = l.name;
减弱 map-join 提示
Hive 0.11 实现了一些变化,表面上消除了在 SELECT 语句中提供 map-join 提示的需要,但尚不清楚在哪些情况下提示不再需要(参见 issues.apache.org/jira/browse/HIVE-3784)。
在全连接或右外连接中,不支持在映射端进行连接;它们将作为重新分区连接(减少端连接)执行。
摘要
可以通过复制连接支持内部和外部连接。此技术实现了一个内部连接,因为只有两个数据集中具有相同键的记录才会被输出。要将此转换为外部连接,您可以输出被发送到映射器的值,这些值在散列表中没有相应的条目,并且您可以类似地跟踪与流式映射记录匹配的散列表条目,并在映射任务结束时使用清理方法输出散列表中未与任何映射输入匹配的记录。
在数据集足够小以至于可以缓存在内存的情况下,是否有进一步优化映射端连接的方法?是时候看看半连接了。
技巧 57 在大型数据集上执行半连接
想象一下这种情况:您正在处理两个大型数据集,您想将它们连接起来,例如用户日志和一个 OLTP 数据库中的用户数据。这两个数据集都不足以在映射任务的内存中缓存,所以您可能不得不接受执行减少端连接。但并不一定——问问自己这个问题:如果您删除了所有不匹配另一个数据集的记录,其中一个数据集是否可以放入内存中?
在我们的例子中,出现日志中的用户可能是您 OLTP 数据库中所有用户集的一小部分,因此通过删除所有未出现在日志中的 OLTP 用户,您可以将数据集的大小减少到适合内存的大小。如果是这种情况,半连接就是解决方案。图 6.5 显示了您需要执行以执行半连接的三个 MapReduce 作业。
图 6.5. 组成半连接的三个 MapReduce 作业

让我们看看编写半连接涉及哪些内容。
问题
您想将大型数据集连接起来,同时避免洗牌和排序阶段的开销。
解决方案
在这个技巧中,您将使用三个 MapReduce 作业将两个数据集连接起来,以避免在减少端连接中的开销。此技巧在处理大型数据集但可以通过过滤掉不匹配其他数据集的记录将作业减少到可以适应任务内存大小的情况中非常有用。
讨论
在这个技巧中,您将分解图 6.5 中所示的三个作业。
任务 1
第一个 MapReduce 作业的功能是生成存在于日志文件中的唯一用户名集合。你通过让 map 函数执行用户名的投影,然后通过 reducers 发射用户名来实现这一点。为了减少 map 和 reduce 阶段之间传输的数据量,你将在 map 任务中将所有用户名缓存到HashSet中,并在清理方法中发射HashSet的值。图 6.6 显示了该作业的流程。
图 6.6. 半连接的第一个作业生成了存在于日志文件中的唯一用户名集合。

以下代码显示了 MapReduce 作业:^([4])
⁴ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/joins/semijoin/UniqueHashedKeyJob.java.

第一个作业的结果是出现在日志文件中的唯一用户集合。
作业 2
第二步是一个复杂的过滤 MapReduce 作业,其目标是移除用户数据集中不存在于日志数据中的用户。这是一个仅使用 map 的作业,它使用复制连接来缓存出现在日志文件中的用户名,并将它们与用户数据集连接起来。作业 1 生成的唯一用户输出将比整个用户数据集小得多,这使得它成为缓存的理想选择。图 6.7 显示了该作业的流程。
图 6.7. 半连接中的第二个作业从用户数据集中移除了日志数据中缺失的用户。

这是一个复制连接,就像你在之前的技巧中看到的那样。因此,这里我不会包括代码,但你可以在 GitHub 上轻松访问它.^([5])
⁵ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/joins/semijoin/ReplicatedFilterJob.java.
作业 3
在这个最终步骤中,你将结合作业 2 生成的过滤用户与原始用户日志。现在过滤后的用户应该足够少,可以放入内存中,允许你将它们放入分布式缓存中。图 6.8 显示了该作业的流程。
图 6.8. 半连接中的第三个作业将作业 2 生成的用户与原始用户日志结合起来。

再次,你使用复制连接来执行这个连接,所以这里我不会展示相应的代码——请参考之前的技巧以获取更多关于复制连接的详细信息,或者直接访问 GitHub 获取这个作业的源代码.^([6])
⁶ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/joins/semijoin/FinalJoinJob.java.
运行代码并查看前一个步骤产生的输出:

输出显示了半连接作业的逻辑进展和最终的连接输出。
摘要
在这个技巧中,我们探讨了如何使用半连接将两个数据集合并在一起。半连接结构比其他连接涉及更多步骤,但即使处理大型数据集(前提是其中一个数据集必须减小到适合内存的大小),它也是一种使用 map-side join 的强大方式。
拥有这三种连接策略后,您可能会想知道在什么情况下应该使用哪一种。
技巧 58 在预排序和预分区数据上连接
Map-side joins 是最有效的方法,前两种 map-side 策略都要求其中一个数据集可以加载到内存中。如果您正在处理无法按前一种技术减小到更小大小的的大型数据集,该怎么办?在这种情况下,复合 map-side join 可能是可行的,但前提是满足以下所有要求:
-
没有任何一个数据集可以完整地加载到内存中。
-
所有数据集都是按连接键排序的。
-
每个数据集都有相同数量的文件。
-
每个数据集的文件 N 包含相同的连接键 K。
-
每个文件的大小都小于 HDFS 块的大小,因此分区不会被分割。或者,也可以说,数据的输入拆分不会分割文件。
图 6.9 展示了一个示例,说明如何使用排序和分区文件进行复合连接。这项技术将探讨如何在您的作业中使用复合连接。
图 6.9. 作为复合连接输入的排序文件示例

问题
您想在排序、分区数据上执行 map-side join。
解决方案
使用与 MapReduce 一起捆绑的 CompositeInputFormat。
讨论
CompositeInputFormat 非常强大,支持内连接和外连接。以下示例展示了如何在您的数据上执行内连接:^([7])
⁷ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/joins/composite/CompositeJoin.java。

复合连接要求输入文件按键排序(在我们的示例中是用户名),因此在运行示例之前,您需要排序这两个文件并将它们上传到 HDFS:
$ sort -k1,1 test-data/ch6/users.txt > users-sorted.txt
$ sort -k1,1 test-data/ch6/user-logs.txt > user-logs-sorted.txt
$ hadoop fs -put users-sorted.txt .
$ hadoop fs -put user-logs-sorted.txt .
接下来,运行作业并在完成后检查其输出:
$ hip hip.ch6.joins.composite.CompositeJoin \
--users users-sorted.txt \
--user-logs user-logs-sorted.txt \
--output output
$ hadoop fs -cat output/part*
bob 71 CA new_tweet 58.133.120.100
jim 21 OR login 198.184.237.49
jim 21 OR logout 93.24.237.12
jim 21 OR new_tweet 93.24.237.12
marie 27 OR login 58.133.120.100
marie 27 OR view_user 122.158.130.90
mike 69 VA logout 55.237.104.36
mike 69 VA new_tweet 87.124.79.252
Hive
Hive 支持一种称为 sort-merge join 的 map-side join,其操作方式与该技术非常相似。它还要求两个表中的所有键都必须排序,并且表必须划分成相同数量的桶。您需要指定一些可配置的参数,并使用 MAPJOIN 指示符来启用此行为:
set hive.input.format=
org.apache.hadoop.hive.ql.io.BucketizedHiveInputFormat;
set hive.optimize.bucketmapjoin = true;
set hive.optimize.bucketmapjoin.sortedmerge = true;
SELECT /*+ MAPJOIN(l) */ u.*, l.*
FROM users u
JOIN user_logs l ON u.name = l.name;
摘要
组合合并实际上支持N路合并,因此可以合并超过两个数据集。但是,所有数据集都必须符合在技术开始时讨论的限制。
由于每个 mapper 处理两个或多个数据输入,数据局部性只能存在于一个数据集中,因此其余的必须从其他数据节点流式传输。
这种连接在数据必须在运行连接之前存在的方式上确实有限制,但如果数据已经以这种方式布局,那么这是一种合并数据并避免基于 reducer 的连接中洗牌开销的好方法。
6.1.2. Reduce 端合并
如果地图端的技术对您的数据不起作用,您将需要使用 MapReduce 中的洗牌功能来排序和合并您的数据。以下技术提供了一些关于 reduce 端合并的技巧和窍门。
技巧 59 基本分区合并
第一种技术是基本的 reduce 端合并,允许您执行内连接和外连接。
问题
您想合并大型数据集。
解决方案
使用 reduce 端分区合并。
讨论
分区合并是一种 reduce 端合并,它利用 MapReduce 的排序合并功能来分组记录。它作为一个单独的 MapReduce 作业实现,并且可以支持N路合并,其中N是要合并的数据集数量。
map 阶段负责从各种数据集中读取数据,确定每条记录的 join 值,并将该 join 值作为输出键发出。输出值包含您在 reducer 中合并数据集以生成作业输出时希望包含的数据。
单个 reducer 调用接收由 map 函数发出的所有 join 键值,并将数据分成N个分区,其中N是要合并的数据集数量。在 reducer 读取所有输入记录并按内存中的分区进行分区后,它对所有分区执行笛卡尔积并发出每个合并的结果。图 6.10 展示了分区合并的高级视图。
图 6.10. 基本的 MapReduce 实现分区合并

您的 MapReduce 代码需要支持以下技术:
-
它需要支持多个 map 类,每个类处理不同的输入数据集。这是通过使用
MultipleInputs类来实现的。 -
它需要一种标记由 mapper 发出的记录的方法,以便可以将它们与它们的数据集关联起来。在这里,您将使用 htuple 项目来轻松地在 MapReduce 中处理复合数据.^([8])
⁸ htuple (
htuple.org)是一个开源项目,旨在使在 MapReduce 中处理元组更容易。它是为了简化 MapReduce 中的二级排序而创建的。
分区合并的代码如下:^([9])
⁹ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/joins/repartition/SimpleRepartitionJoin.java.


你可以使用以下命令来运行作业并查看作业输出:
$ hip hip.ch6.joins.repartition.SimpleRepartitionJoin \
--users users.txt \
--user-logs user-logs.txt \
--output output
$ hadoop fs -cat output/part*
jim 21 OR jim login 198.184.237.49
jim 21 OR jim new_tweet 93.24.237.12
jim 21 OR jim logout 93.24.237.12
mike 69 VA mike logout 55.237.104.36
mike 69 VA mike new_tweet 87.124.79.252
bob 71 CA bob new_tweet 58.133.120.100
marie 27 OR marie login 58.133.120.100
marie 27 OR marie view_user 122.158.130.90
摘要
Hadoop 附带了一个hadoop-datajoin模块,这是一个用于分区连接的框架。它包括处理多个输入数据集和执行连接的主要管道。
本技术中展示的示例以及hadoop-datajoin代码都是分区连接的最基本形式。两者都需要在执行笛卡尔积之前将连接键的所有数据加载到内存中。这可能适用于你的数据,但如果你的连接键的基数大于你的可用内存,那么你可能就无计可施了。接下来的技术将探讨一种可能绕过这个问题的方法。
技术篇 60:优化分区连接
之前的分区连接实现不是空间高效的;它需要在执行多路连接之前将给定连接值的所有输出值加载到内存中。将较小数据集加载到内存中,然后遍历较大数据集,沿途执行连接会更有效。
问题
你想在 MapReduce 中执行分区连接,但又不想承担在 reducer 中缓存所有记录的开销。
解决方案
这种技术使用一个优化的分区连接框架,它只缓存要连接的数据集中的一项,以减少在 reducer 中缓存的数据量。
讨论
这种优化的连接只缓存两个数据集中较小的一个的记录,以减少缓存所有记录的内存开销。图 6.11 显示了改进的分区连接的实际效果。
图 6.11. 优化后的 MapReduce 分区连接实现

与前一种技术中展示的简单分区连接相比,这里有一些不同。在这个技术中,你使用二次排序来确保所有来自小数据集的记录在所有来自大数据集的记录之前到达 reducer。为了实现这一点,你将从 mapper 中发出包含要连接的用户名和一个标识原始数据集的字段的元组输出键。
以下代码显示了一个包含元组将包含的字段的枚举。它还显示了用户 mapper 如何填充元组字段:^([10])
¹⁰ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/joins/repartition/StreamingRepartitionJoin.java.
enum KeyFields {
USER,
DATASET
}
Tuple outputKey = new Tuple();
outputKey.setString(KeyFields.USER, user.getName());
outputKey.setInt(KeyFields.DATASET, USERS);
MapReduce 驱动代码需要更新,以指示元组中哪些字段用于排序、分区和分组:^([11])
(11) 更详细的二次排序内容请见 第 6.2.1 节。
-
分区器应该只根据用户名进行分区,这样同一个用户的全部记录都会到达同一个 reducer。
-
排序应使用用户名和数据集指示符,以便首先对较小的数据集进行排序(由于
USERS常量比USER_LOGS常量小,因此用户记录会在用户日志之前排序)。 -
分组应基于用户进行,以便两个数据集都流式传输到同一个 reducer 调用:
ShuffleUtils.configBuilder() .setPartitionerIndices(KeyFields.USER) .setSortIndices(KeyFields.USER, KeyFields.DATASET) .setGroupIndices(KeyFields.USER) .configure(job.getConfiguration());
最后,你将修改 reducer 以缓存传入的用户记录,然后与用户日志进行连接:^([12])
(12) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/joins/repartition/StreamingRepartitionJoin.java.
@Override
protected void reduce(Tuple key, Iterable<Tuple> values,
Context context){
users = Lists.newArrayList();
for (Tuple tuple : values) {
switch (tuple.getInt(ValueFields.DATASET)) {
case USERS: {
users.add(tuple.getString(ValueFields.DATA));
break;
}
case USER_LOGS: {
String userLog = tuple.getString(ValueFields.DATA);
for (String user : users) {
context.write(new Text(user), new Text(userLog));
}
break;
}
}
}
}
你可以使用以下命令来运行作业并查看作业输出:
$ hip hip.ch6.joins.repartition.StreamingRepartitionJoin \
--users users.txt \
--user-logs user-logs.txt \
--output output
$ hadoop fs -cat output/part*
bob 71 CA bob new_tweet 58.133.120.100
jim 21 OR jim logout 93.24.237.12
jim 21 OR jim new_tweet 93.24.237.12
jim 21 OR jim login 198.184.237.49
marie 27 OR marie view_user 122.158.130.90
marie 27 OR marie login 58.133.120.1
mike 69 VA mike new_tweet 87.124.79.252
mike 69 VA mike logout 55.237.104.36
Hive
当执行重新分区连接时,Hive 可以支持类似的优化。Hive 可以缓存连接键的所有数据集,然后流式传输大型数据集,这样就不需要将其存储在内存中。
Hive 假设你在查询中最后指定的是最大的数据集。想象一下,你有两个表,名为 users 和 user_logs,其中 user_logs 表的数据量要大得多。为了连接这两个表,你需要确保在查询中最后引用的是 user_logs 表:
SELECT u.*, l.*
FROM users u
JOIN user_logs l ON u.name = l.name;
如果你不想重新排列你的查询,你可以使用 STREAMTABLE 指示来告诉 Hive 哪个表更大:
SELECT /*+ STREAMTABLE(l) */ u.*, l.*
FROM user_logs l
JOIN users u ON u.name = l.name;
摘要
这种连接实现通过仅缓冲较小数据集的值来改进早期技术。但它仍然存在所有数据在 map 和 reduce 阶段之间传输的问题,这是一个昂贵的网络成本。
此外,前面的技术可以支持 N-way 连接,但此实现仅支持双向连接。
通过在 map 函数中积极进行投影和过滤,可以进一步减少 reduce 端连接的内存占用,正如在技巧 55 中所讨论的那样。
技巧 61 使用 Bloom 过滤器减少洗牌数据
假设你想要根据某些谓词(例如“仅限居住在加利福尼亚的用户”)对数据子集执行连接操作。使用到目前为止所涵盖的重新分区作业技术,你将不得不在 reducer 中执行该过滤,因为只有一个数据集(用户)有关于州的信息——用户日志没有该信息。
在这种技术中,我们将探讨如何在 map 端使用 Bloom 过滤器,这可以大大影响作业执行时间。
问题
你想在重新分区连接中过滤数据,但要将该过滤器推送到 Mapper。
解决方案
使用预处理作业创建 Bloom 过滤器,然后在重新分区作业中加载 Bloom 过滤器以过滤 Mapper 中的记录。
讨论
Bloom 过滤器是一个有用的概率数据结构,它提供的成员特性类似于集合——区别在于成员查找只提供明确的“否”答案,因为可能会得到假阳性。尽管如此,与 Java 中的 HashSet 相比,它们需要的内存要少得多,因此非常适合与非常大的数据集一起使用。
更多关于 Bloom 过滤器的信息
第七章 提供了关于 Bloom 过滤器如何工作以及如何使用 MapReduce 并行创建 Bloom 过滤器的详细信息。
在这个技术中,你的目标是只对居住在加利福尼亚州的用户执行连接操作。这个解决方案有两个步骤——你首先运行一个作业来生成 Bloom 过滤器,该过滤器将操作用户数据并填充居住在加利福尼亚州的用户。然后,这个 Bloom 过滤器将在重新分区连接中使用,以丢弃不在 Bloom 过滤器中的用户。你需要这个 Bloom 过滤器的理由是,用户日志的 Mapper 没有关于用户州份的详细信息。
图 6.12 展示了该技术的步骤。
图 6.12. 在重新分区连接中使用 Bloom 过滤器的两步过程

第一步:创建 Bloom 过滤器
第一步是创建包含加利福尼亚州用户名称的 Bloom 过滤器。Mapper 生成中间 Bloom 过滤器,Reducer 将它们合并成一个单独的 Bloom 过滤器。作业输出是一个包含序列化 Bloom 过滤器的 Avro 文件:^([13])
(13) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/joins/bloom/BloomFilterCreator.java.

第二步:重新分区连接
重新分区连接与第 59 技术中介绍的重新分区连接相同——唯一的区别是 Mapper 现在加载第一步生成的 Bloom 过滤器,在处理 Map 记录时,它们会对 Bloom 过滤器执行成员查询,以确定记录是否应该发送到 Reducer。
Reducer 与原始重新分区连接没有变化,所以下面的代码展示了两个东西:一个抽象的 Mapper,它泛化了 Bloom 过滤器的加载、过滤和排放,以及支持两个要连接的数据集的两个子类:^([14])
(14) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/joins/bloom/BloomJoin.java.

以下命令运行两个作业并输出 join 的结果:

摘要
本技术提出了一种在两个数据集上执行 map-side 过滤的有效方法,以最小化 mappers 和 reducers 之间的网络 I/O。它还减少了在 shuffle 过程中 mappers 和 reducers 需要写入和读取磁盘的数据量。过滤器通常是加速和优化你的作业最简单和最有效的方法,它们对于 repartition joins 和其他 MapReduce 作业同样有效。
为什么不使用散列表而不是 Bloom 过滤器来表示用户?为了构建一个具有 1%误报率的 Bloom 过滤器,你只需要为数据结构中的每个元素分配 9.8 位。与包含整数的HashSet的最佳使用情况相比,它需要 8 字节。或者,如果你有一个只反映元素存在而忽略冲突的HashSet,你将得到一个只有一个散列的 Bloom 过滤器,导致更高的误报率。
Pig 的 0.10 版本将包括对 Bloom 过滤器的支持,其机制与这里展示的类似。详细信息可以在 JIRA 票据issues.apache.org/jira/browse/PIG-2328中查看。
在本节中,你了解到 Bloom 过滤器提供了良好的空间受限集合成员能力。我们探讨了如何在 Map-Reduce 中创建 Bloom 过滤器,并且你也应用了该代码到后续的技术中,这有助于你优化 MapReduce 半连接。
6.1.3. 数据倾斜在 reduce-side joins 中
本节涵盖了在连接大型数据集时遇到的一个常见问题——数据倾斜。你的数据中可能存在两种类型的数据倾斜:
-
高 join-key 基数,即某些 join keys 在一个或两个数据集中有大量记录。我称之为join-product 倾斜。
-
糟糕的 hash 分区,其中少数 reducers 接收了整体记录数的大比例。我称之为hash-partitioning 倾斜。
在严重的情况下,join-product 倾斜可能导致由于需要缓存的数据量而导致的堆耗尽问题。hash-partitioning 倾斜表现为一个耗时较长的 join,其中少数 reducers 的完成时间显著长于大多数 reducers。
本节中的技术检查了这两种情况,并提出了应对它们的建议。
技术编号 62:使用高 join-key 基数连接大型数据集
本技术解决了 join-product 倾斜的问题,下一个技术将检查 hash-partitioning 倾斜。
问题
一些你的 join keys 具有高基数,这导致一些 reducers 在尝试缓存这些键时内存不足。
解决方案
过滤掉这些键并单独连接它们,或者在 reducer 中将它们溢出并安排一个后续作业来连接它们。
讨论
如果你提前知道哪些键是高基数的,你可以将它们分离出来作为一个单独的连接作业,如图 6.13 所示。
图 6.13. 在提前知道高基数键的情况下处理偏斜

如果你不知道高基数键,你可能需要在你的 reducer 中构建一些智能来检测这些键并将它们写入一个副作用文件,随后由一个后续作业连接,如图 6.14 所示。
图 6.14. 在不知道高基数键的情况下处理偏斜

Hive 0.13
在 Hive 版本 0.13 之前,偏斜键实现有缺陷(issues.apache.org/jira/browse/HIVE-6041)。
Hive
Hive 支持一种与该技巧中提出的第二种方法类似的偏斜缓解策略。可以在运行作业之前通过指定以下可配置项来启用:

你可以可选地设置一些额外的可配置项来控制操作高基数键的 map-side join:

最后,如果你在 SQL 中使用GROUP BY,你可能还想考虑启用以下配置来处理分组数据中的偏斜:
SET hive.groupby.skewindata = true;
摘要
本技巧中提出的选项假设对于给定的连接键,只有一个数据集有高基数发生;因此使用了缓存较小数据集的 map-side join。如果两个数据集都是高基数的,那么你将面临一个昂贵的笛卡尔积操作,这将执行缓慢,因为它不适合 MapReduce 的工作方式(这意味着它不是固有的可分割和可并行化的)。在这种情况下,你在优化实际连接方面实际上没有其他选择。你应该重新审视是否有一些回归基础的技术,如过滤或投影你的数据,可以帮助减轻执行连接所需的时间。
下一个技术探讨的是由于使用默认的哈希分区器而可能引入到你的应用程序中的不同类型的偏斜。
技巧 63 处理由哈希分区器生成的偏斜
MapReduce 的默认分区器是一个哈希分区器,它对每个 map 输出键进行哈希处理,并对其与 reducer 数量的模数运算来确定键将被发送到的 reducer。哈希分区器作为一个通用分区器工作得很好,但有可能某些数据集会导致哈希分区器因不均衡数量的键被哈希到同一个 reducer 而使某些 reducer 过载。
这表现为少数拖沓的 reducer 完成时间比大多数 reducer 长得多。此外,当您检查拖沓 reducer 的计数器时,您会注意到发送给拖沓者的组数比其他已完成的其他组要高得多。
区分由高基数键引起的偏斜与哈希分区器引起的偏斜
您可以使用 MapReduce 的 reducer 计数器来识别作业中的数据偏斜类型。由性能不佳的哈希分区器引入的偏斜将导致发送到这些 reducer 的组(唯一键)数量大大增加,而高基数键引起偏斜的症状则体现在所有 reducer 的组数大致相等,但偏斜 reducer 的记录数却大大增加。
问题
您的 reduce 端连接完成时间较长,有几个拖沓的 reducer 完成时间比大多数 reducer 长得多。
解决方案
使用范围分区器或编写一个自定义分区器,将偏斜键引导到一组预留的 reducer。
讨论
这个解决方案的目的是放弃默认的哈希分区器,并替换为更适合您偏斜数据的东西。这里有您可以探索的两个选项:
-
您可以使用 Hadoop 附带的自定义采样器和
TotalOrderPartitioner,它用范围分区器替换了哈希分区器。 -
您可以编写一个自定义分区器,将具有数据偏斜的键路由到一组为偏斜键预留的 reducer。
让我们探索这两个选项,并看看您将如何使用它们。
范围分区
范围分区器将根据预定义的值范围分配 map 输出,其中每个范围映射到一个将接收该范围内所有输出的 reducer。这正是TotalOrderPartitioner的工作方式。事实上,TotalOrderPartitioner被 TeraSort 用于在所有 reducer 之间均匀分配单词,以最小化拖沓的 reducer。^(15)
^(15) TeraSort 是一个 Hadoop 基准测试工具,用于对 TB 级数据进行排序。
为了使范围分区器如TotalOrderPartitioner能够工作,它们需要知道给定作业的输出键范围。TotalOrderPartitioner附带一个采样器,该采样器采样输入数据并将这些范围写入 HDFS,然后由TotalOrderPartitioner在分区时使用。有关如何使用TotalOrderPartitioner和采样器的更多详细信息,请参阅第 6.2 节。
自定义分区器
如果您已经掌握了哪些键表现出数据偏斜,并且这组键是静态的,您可以编写一个自定义分区器将这些高基数连接键推送到一组预留的 reducer。想象一下,您正在运行一个有十个 reducer 的作业——您可以选择使用其中两个来处理偏斜的键,然后对所有其他键在剩余的 reducer 之间进行哈希分区。
摘要
在这里提出的两种方法中,范围分区可能是最好的解决方案,因为你可能不知道哪些键是倾斜的,而且随着时间的推移,表现出倾斜的键也可能发生变化。
在 MapReduce 中实现 reduce-side joins 是可能的,因为它们会排序并关联 map 输出的键。在下一节中,我们将探讨 MapReduce 中的常见排序技术。
6.2. 排序
MapReduce 的魔力发生在 mapper 和 reducer 之间,框架将具有相同键的所有 map 输出记录组合在一起。这个 MapReduce 特性允许你聚合和连接数据,并实现强大的数据处理管道。为了执行此功能,MapReduce 内部对数据进行分区、排序和合并(这是洗牌阶段的一部分),结果是每个 reducer 都会流式传输一个有序的键值对集合。
在本节中,我们将探讨两个特定的领域,你将想要调整 MapReduce 排序的行为。
首先,我们将探讨二次排序,它允许你对 reducer 键的值进行排序。二次排序在需要某些数据在其它数据之前到达 reducer 的情况下很有用,例如在技术 60 中的优化 repartition join。如果想要你的作业输出按二次键排序,二次排序也非常有用。一个例子是,如果你想要按股票代码对股票数据进行主要排序,然后在一天中按每个股票报价的时间进行二次排序。
在本节中,我们将介绍第二个场景,即对所有 reducer 输出进行排序数据。这在需要从数据集中提取前 N 个或后 N 个元素的情况下很有用。
这些是重要的领域,允许你执行本章前面查看的一些连接操作。但排序的应用不仅限于连接;排序还允许你对数据进行二次排序。二次排序在本书中的许多技术中都有应用,从优化 repartition join 到朋友的朋友这样的图算法。
6.2.1. 二次排序
正如你在第 6.1 节关于连接的讨论中所见,你需要进行二次排序以允许某些记录在其它记录之前到达 reducer。二次排序需要理解 MapReduce 中的数据排列和数据流。展示了影响数据排列和流(分区、排序和分组)的三个要素以及它们如何集成到 MapReduce 中。
图 6.15. MapReduce 中排序、分区和分组发生的位置概述

分区器在 map 输出收集过程中被调用,用于确定哪个 reducer 应该接收 map 输出。排序RawComparator用于对各自的分区内的 map 输出进行排序,并在 map 和 reduce 端都使用。最后,分组RawComparator负责确定排序记录之间的组边界。
在 MapReduce 中,默认行为是所有三个函数都操作由 map 函数发出的整个输出键。
技巧 64 实现二级排序
当您希望某些唯一 map 键的值在 reducer 端先于其他值到达时,二级排序非常有用。您可以在本书的其他技术中看到二级排序的价值,例如优化的 repartition join(技巧 60),以及在第七章中讨论的 friends-of-friends 算法(技巧 68)。
问题
您希望对每个键的单个 reducer 调用发送的值进行排序。
解决方案
这个技巧涵盖了编写您的分区器、排序比较器和分组比较器类,这些类对于二级排序的正常工作是必需的。
讨论
在这个技术中,我们将探讨如何使用二级排序来对人员姓名进行排序。您将使用主要排序来对人员的姓氏进行排序,并使用二级排序来对他们的名字进行排序。
为了支持二级排序,您需要创建一个复合输出键,该键将由您的 map 函数发出。复合键将包含两部分:
-
自然键,用于连接的键
-
二级键,是用于对自然键发送给 reducer 的所有值进行排序的键
图 6.16 展示了姓名的复合键。它还显示了一个复合值,它为 reducer 端提供了对二级键的访问。
图 6.16. 用户复合键和值

让我们逐一分析分区、排序和分组阶段,并实现它们以对姓名进行排序。但在那之前,您需要编写您的复合键类。
复合键
复合键包含姓氏和名字。它扩展了WritableComparable,这是推荐用于作为 map 函数键发出的Writable类的:^([16])
[21](https://github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/sort/secondary/Person.java).
public class Person implements WritableComparable<Person> {
private String firstName;
private String lastName;
@Override
public void readFields(DataInput in) throws IOException {
this.firstName = in.readUTF();
this.lastName = in.readUTF();
}
@Override
public void write(DataOutput out) throws IOException {
out.writeUTF(firstName);
out.writeUTF(lastName);
}
...
图 6.17 展示了您在代码中调用的配置名称和方法,以设置分区、排序和分组类。该图还显示了每个类使用的复合键的哪个部分。
图 6.17. 分区、排序和分组设置及键利用

让我们看看这些类的实现代码。
分区器
分区器用于确定哪个 reducer 应该接收一个 map 输出记录。默认的 MapReduce 分区器(HashPartitioner)调用输出键的hashCode方法,并使用 reducer 的数量进行取模运算,以确定哪个 reducer 应该接收输出。默认的分区器使用整个键,这对于你的复合键来说可能不起作用,因为它可能会将具有相同自然键值的键发送到不同的 reducer。相反,你需要编写自己的Partitioner,该分区器基于自然键进行分区。
以下代码显示了必须实现的Partitioner接口。getPartition方法接收键、值和分区数(也称为reducers):
public interface Partitioner<K2, V2> extends JobConfigurable {
int getPartition(K2 key, V2 value, int numPartitions);
}
你的分区器将根据Person类中的姓氏计算一个哈希值,并使用分区数(即 reducer 的数量)进行取模运算:^(17)
^(17) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/sort/secondary/PersonNamePartitioner.java.
public class PersonNamePartitioner extends
Partitioner<Person, Text> {
@Override
public int getPartition(Person key, Text value, int numPartitions) {
return Math.abs(key.getLastName().hashCode() * 127) %
numPartitions;
}
}
排序
map 端和 reduce 端都参与排序。map 端的排序是一种优化,有助于使 reducer 排序更高效。你希望 MapReduce 使用你的整个键进行排序,这将根据姓氏和名字对键进行排序。
在以下示例中,你可以看到WritableComparator的实现,它根据用户的姓氏和名字进行比较:^(18)
^(18) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/sort/secondary/PersonComparator.java.
public class PersonComparator extends WritableComparator {
protected PersonComparator() {
super(Person.class, true);
}
@Override
public int compare(WritableComparable w1, WritableComparable w2) {
Person p1 = (Person) w1;
Person p2 = (Person) w2;
int cmp = p1.getLastName().compareTo(p2.getLastName());
if (cmp != 0) {
return cmp;
}
return p1.getFirstName().compareTo(p2.getFirstName());
}
}
分组
分组发生在 reduce 阶段从本地磁盘流式传输 map 输出记录时。分组是你可以指定如何组合记录以形成一个逻辑记录序列的过程,用于 reducer 调用。
当你处于分组阶段时,所有记录已经按照二级排序顺序排列,分组比较器需要将具有相同姓氏的记录捆绑在一起:^(19)
^(19) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/sort/secondary/PersonNameComparator.java.
public class PersonNameComparator extends WritableComparator {
protected PersonNameComparator() {
super(Person.class, true);
}
@Override
public int compare(WritableComparable o1, WritableComparable o2) {
Person p1 = (Person) o1;
Person p2 = (Person) o2;
return p1.getLastName().compareTo(p2.getLastName());
}
}
MapReduce
最后的步骤包括告诉 MapReduce 使用分区器、排序比较器和分组比较器类:^(20)
^(20) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/sort/secondary/SortMapReduce.java.
job.setPartitionerClass(PersonNamePartitioner.class);
job.setSortComparatorClass(PersonComparator.class);
job.setGroupingComparatorClass(PersonNameComparator.class);
要完成这项技术,你需要编写 map 和 reduce 代码。mapper 创建复合键,并与之一起输出第一个名称作为输出值。reducer 产生与输入相同的输出:^([21])
^(21) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/sort/secondary/SortMapReduce.java。
public static class Map extends Mapper<Text, Text, Person, Text> {
private Person outputKey = new Person();
@Override
protected void map(Text lastName, Text firstName, Context context)
throws IOException, InterruptedException {
outputKey.set(lastName.toString(), firstName.toString());
context.write(outputKey, firstName);
}
}
public static class Reduce extends Reducer<Person, Text, Text, Text> {
Text lastName = new Text();
@Override
public void reduce(Person key, Iterable<Text> values,
Context context)
throws IOException, InterruptedException {
lastName.set(key.getLastName());
for (Text firstName : values) {
context.write(lastName, firstName);
}
}
}
要查看这种排序的实际效果,你可以上传一个包含无序名称的小文件,并测试二次排序代码是否产生按姓名排序的输出:
$ hadoop fs -put test-data/ch6/usernames.txt .
$ hadoop fs -cat usernames.txt
Smith John
Smith Anne
Smith Ken
$ hip hip.ch6.sort.secondary.SortMapReduce \
--input usernames.txt --output output
$ hadoop fs -cat output/part*
Smith Anne
Smith John
Smith Ken
输出按预期排序。
摘要
正如你在这种技术中看到的那样,使用二次排序并不简单。它要求你编写自定义的分区器、排序器和分组器。如果你正在处理简单的数据类型,可以考虑使用我开发的开源项目 htuple (htuple.org/),它简化了作业中的二次排序。
htuple 公开了一个Tuple类,它允许你存储一个或多个 Java 类型,并提供辅助方法,使你能够轻松定义用于分区、排序和分组的字段。以下代码展示了如何使用 htuple 在第一个名称上进行二次排序,就像在技术中一样:

接下来,我们将探讨如何在多个 reducer 之间排序输出。
6.2.2. 完全排序
你会发现许多情况下,你希望你的作业输出处于完全排序顺序.^([22]) 例如,如果你想从网页图中提取最受欢迎的 URL,你必须按某些度量标准(如 Page-Rank)对图进行排序。或者,如果你想在你网站的门户中显示最活跃用户的表格,你需要能够根据某些标准(如他们撰写的文章数量)对他们进行排序。
^(22) 完全排序是指 reducer 记录在所有 reducer 之间排序,而不仅仅是每个 reducer 内部。
技术篇 65 在多个 reducer 之间排序键
你知道 MapReduce 框架在将输出键传递给 reducer 之前会对 map 输出键进行排序。但这种排序仅在每个 reducer 内部得到保证,除非你为你的作业指定分区器,否则你将使用默认的 MapReduce 分区器HashPartitioner,它使用 map 输出键的哈希值进行分区。这确保了具有相同 map 输出键的所有记录都发送到同一个 reducer,但HashPartitioner不会对所有 reducer 的 map 输出键进行完全排序。了解这一点后,你可能想知道如何使用 Map-Reduce 在多个 reducer 之间对键进行排序,以便你可以轻松地从你的数据中提取最顶部和最底部的N条记录。
问题
你希望在作业输出中键的完全排序,但不需要运行单个 reducer 的开销。
解决方案
该技术涵盖了使用与 Hadoop 一起打包的 TotalOrderPartitioner 类,该分区器有助于对所有 reducers 的输出进行排序。该分区器确保发送给 reducers 的输出是完全有序的。
讨论
Hadoop 有一个内置的分区器,称为 TotalOrderPartitioner,它根据分区文件将键分配到特定的 reducers。分区文件是一个预计算的 SequenceFile,其中包含 N – 1 个键,其中 N 是 reducers 的数量。分区文件中的键按 map 输出键比较器排序,因此每个键代表一个逻辑键范围。为了确定哪个 reducer 应该接收输出记录,TotalOrderPartitioner 检查输出键,确定它所在的范围,并将该范围映射到特定的 reducer。
图 6.18 展示了该技术的两个部分。您需要创建分区文件,然后使用 TotalOrderPartitioner 运行您的 MapReduce 作业。
图 6.18. 使用采样和 TotalOrderPartitioner 对所有 reducers 的输出进行排序。

首先,您将使用 InputSampler 类,该类从输入文件中采样并创建分区文件。您可以使用两种采样器之一:名为 RandomSampler 的类,正如其名称所示,它从输入中随机选择记录,或者名为 IntervalSampler 的类,对于每条记录都将其包含在样本中。一旦提取了样本,它们将被排序,然后写入分区文件中的 N – 1 个键,其中 N 是 reducers 的数量。InputSampler 不是一个 MapReduce 作业;它从 InputFormat 中读取记录,并在调用代码的过程中生成分区。
以下代码显示了在调用 InputSampler 函数之前需要执行的步骤:^([23])
²³ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/sort/total/TotalSortMapReduce.java。

接下来,您需要指定您想要将 TotalOrderPartitioner 作为作业的分区器:
job.setPartitionerClass(TotalOrderPartitioner.class);
您不希望在 MapReduce 作业中进行任何处理,因此您不会指定 map 或 reduce 类。这意味着将使用身份 MapReduce 类,因此您可以运行代码:

您可以从 MapReduce 作业的结果中看到,map 输出键确实在所有输出文件中进行了排序。
摘要
该技术使用了 InputSampler 来创建分区文件,该文件随后被 TotalOrderPartitioner 用于对 map 输出键进行分区。
你也可以使用 MapReduce 生成分区文件。一种高效的方法是编写一个自定义的InputFormat类,该类执行抽样并将键输出到单个 reducer,然后 reducer 可以创建分区文件。这把我们带到了本章的最后一部分:抽样。
6.3. 抽样
想象你正在处理一个千兆级的数据集,并且你想要使用这个数据集测试你的 MapReduce 应用程序。运行你的 MapReduce 应用程序可能需要数小时,并且不断地对代码进行优化并重新运行大型数据集并不是一个最佳的工作流程。
要解决这个问题,你可以考虑使用抽样,这是一种从总体中提取相关子集的统计方法。在 MapReduce 的上下文中,抽样提供了一种在不等待整个数据集被读取和处理的情况下处理大型数据集的机会。这大大提高了你在开发和调试 MapReduce 代码时快速迭代的效率。
技巧 66 编写水库抽样 InputFormat
你正在迭代地使用大型数据集开发 MapReduce 作业,并且需要进行测试。使用整个数据集进行测试需要很长时间,并阻碍了你快速与代码工作的能力。
问题
你希望在开发 MapReduce 作业期间使用大型数据集的一个小子集。
解决方案
编写一个可以包装实际用于读取数据的输入格式的输入格式。你将要编写的输入格式可以配置为从包装的输入格式中提取的样本数量。
讨论
在这个技巧中,你将使用水库抽样来选择样本。水库抽样是一种策略,允许对数据流进行一次遍历以随机生成样本.^(24) 因此,它非常适合 MapReduce,因为输入记录是从输入源流式传输的。图 6.19 展示了水库抽样的算法。
^(24) 关于水库抽样的更多信息,请参阅维基百科上的文章:
en.wikipedia.org/wiki/Reservoir_sampling。
图 6.19。水库抽样算法允许对数据流进行一次遍历以随机生成样本。

输入拆分确定和记录读取将委托给包装的InputFormat和RecordReader类。你将编写提供抽样功能的类,然后包装委托的InputFormat和RecordReader类.^(25)图 6.20 展示了ReservoirSamplerRecordReader的工作方式。
^(25) 如果你需要对这些类进行复习,请参阅第三章以获取更多详细信息。chapter 3。
图 6.20。ReservoirSamplerRecordReader的实际操作

以下代码展示了ReservoirSamplerRecordReader:^(26)
^([26] GitHub 源:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/sampler/ReservoirSamplerInputFormat.java).

要在您的代码中使用ReservoirSamplerInputFormat类,您将使用便利方法来帮助设置输入格式和其他参数,如下面的代码所示:^(27])
^([27] GitHub 源:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/sampler/SamplerJob.java)

您可以通过运行一个针对包含名称的大文件的标识符作业来查看采样输入格式的实际效果。

您已配置ReservoirSamplerInputFormat以提取十个样本,输出文件包含相应数量的行。
总结
在 MapReduce 代码中的采样支持可以在工程师对生产规模数据集运行代码时成为一个有用的开发和测试特性。这引发了一个问题:将采样支持集成到现有代码库中的最佳方法是什么?一种方法可能是添加一个可配置的选项,用于切换采样输入格式的使用,类似于以下代码:
if(appConfig.isSampling()) {
ReservoirSamplerInputFormat.setInputFormat(job,
TextInputFormat.class);
...
} else {
job.setInputFormatClass(TextInputFormat.class);
}
您可以将这种采样技术应用于前面的任何部分,作为高效处理大数据集的一种方式。
6.4. 章节总结
连接和排序在 MapReduce 中是繁琐的任务,我们在本章讨论了优化和简化它们使用的方法。我们研究了三种不同的连接策略,其中两种在 map 端,一种在 reduce 端。目标是简化 MapReduce 中的连接,我介绍了两个减少连接所需用户代码量的框架。
我们还通过检查二级排序的工作原理以及如何对所有 reducer 的输出进行排序来介绍了 MapReduce 中的排序。我们通过查看如何采样数据以便快速遍历数据的小样本来结束讨论。
我们将在第八章中介绍多个性能模式和调整步骤,这将导致更快的连接和排序时间。但在我们到达那里之前,我们将探讨一些更高级的数据结构和算法,例如图处理和使用 Bloom 过滤器。
第七章. 在大规模数据中利用数据结构和算法
本章涵盖
-
在 MapReduce 中表示和使用数据结构,如图、HyperLogLog 和 Bloom 过滤器
-
将 PageRank 和半连接等算法应用于大量数据
-
学习社交网络公司如何推荐与网络外的人建立联系
在本章中,我们将探讨如何在 MapReduce 中实现算法以处理互联网规模的数据。我们将关注非平凡数据,这些数据通常使用图来表示。
我们还将探讨如何使用图来模拟实体之间的连接,例如社交网络中的关系。我们将运行一系列在图上可以执行的有用算法,如最短路径和好友好友(FoF),以帮助扩展网络的互联性,以及 PageRank,它研究如何确定网页的流行度。
你将学习如何使用布隆过滤器,它独特的空间节省特性使其在解决 P2P(对等)和分布式数据库中的分布式系统问题时变得很有用。我们还将创建 MapReduce 中的布隆过滤器,并探讨它们在过滤方面的有用性。
你还将了解另一种近似数据结构 HyperLogLog,它提供近似唯一计数,这在聚合管道中非常有价值。
一章关于可扩展算法的内容,如果没有提及排序和连接算法,那就不是完整的。这些算法在第六章中有详细讲解。
让我们从如何使用 MapReduce 来模拟图开始。
7.1。使用图建模数据和解决问题
图是表示一组相互连接的对象的数学结构。它们用于表示数据,例如互联网的超链接结构、社交网络(其中它们表示用户之间的关系)以及互联网路由,以确定转发数据包的最佳路径。
一个图由多个节点(正式称为顶点)和连接节点的链接(非正式称为边)组成。图 7.1 展示了带有节点和边的图。
图 7.1。一个带有突出显示的节点和边的简单图

边可以是定向的(意味着单向关系)或非定向的。例如,你会使用一个有向图来模拟社交网络中用户之间的关系,因为关系并不总是双向的。图 7.2 展示了有向图和非有向图的示例。
图 7.2。有向图和非有向图

有向图,其中边有方向,可以是循环的或非循环的。在循环图中,一个顶点可以通过遍历一系列边来达到自身。在一个非循环图中,一个顶点不可能通过遍历路径来达到自身。图 7.3 展示了循环图和非循环图的示例。
图 7.3。循环图和非循环图

要开始使用图,你需要在代码中能够表示它们。那么,表示这些图结构常用的方法有哪些呢?
7.1.1。图建模
表示图的两种常见方式是使用邻接矩阵和邻接表。
邻接矩阵
使用邻接矩阵,你将图表示为一个 N x N 的正方形矩阵 M,其中 N 是节点的数量,而 Mij 表示节点 i 和 j 之间的边。
图 7.4 展示了一个表示社交图中连接的有向图。箭头表示两个人之间的一对一关系。邻接矩阵显示了如何表示这个图。
图 7.4. 图的邻接矩阵表示

邻接矩阵的缺点是它们既表示了关系的存在,也表示了关系的缺失,这使得它们成为密集的数据结构,需要比邻接表更多的空间。
邻接表
邻接表与邻接矩阵类似,但它们不表示关系的缺失。图 7.5 展示了如何使用邻接表表示一个图。
图 7.5. 图的邻接表表示

邻接表的优点是它提供了数据的稀疏表示,这是好的,因为它需要更少的空间。它也适合在 Map-Reduce 中表示图,因为键可以表示一个顶点,而值是一个表示有向或无向关系节点的顶点列表。
接下来我们将介绍三个图算法,首先是最短路径算法。
7.1.2. 最短路径算法
最短路径算法是图论中一个常见问题,其目标是找到两个节点之间的最短路径。图 7.6 展示了在边没有权重的图上的该算法的一个示例,在这种情况下,最短路径是源节点和目标节点之间跳数或中间节点数最少的路径。
图 7.6. 节点 A 和 E 之间的最短路径示例

该算法的应用包括在交通映射软件中确定两个地址之间的最短路径,路由器计算每条路径的最短路径树,以及社交网络确定用户之间的连接。
技巧 67 查找两个用户之间的最短距离
Dijkstra 算法是计算机科学本科课程中常教的最短路径算法。一个基本的实现使用顺序迭代过程遍历整个图,从起始节点开始,如图 7.7 中展示的算法所示。
图 7.7. Dijkstra 算法的伪代码

基本算法不能扩展到超出你的内存大小的图,它也是顺序的,并且没有针对并行处理进行优化。
问题
你需要使用 MapReduce 在社交图中找到两个人之间的最短路径。
解答
使用邻接矩阵来建模图,并为每个节点存储从原始节点到该节点的距离,以及一个指向原始节点的回指针。使用映射器来传播到原始节点的距离,并使用归约器来恢复图的初始状态。迭代直到达到目标节点。
讨论
图 7.8 显示了用于此技术的一个小社交网络。你的目标是找到 Dee 和 Joe 之间的最短路径。从 Dee 到 Joe 有四条路径可以选择,但只有其中一条路径的跳数最少。
图 7.8. 用于此技术的社交网络

你将实现一个并行广度优先搜索算法来找到两个用户之间的最短路径。由于你在一个社交网络上操作,你不需要关心边的权重。该算法的伪代码可以在图 7.9 中看到。
图 7.9. 使用 MapReduce 在图上进行广度优先并行搜索的伪代码

图 7.10 显示了你的社交图中的算法迭代。就像 Dijkstra 算法一样,你将开始时将所有节点的距离设置为无限大,并将起始节点 Dee 的距离设置为零。随着每次 MapReduce 遍历,你将确定没有无限距离的节点,并将它们的距离值传播到它们的相邻节点。你将继续这样做,直到达到终点。
图 7.10. 通过网络的最短路径迭代

你首先需要创建起点。这是通过从文件中读取社交网络(存储为邻接表)并设置初始距离值来完成的。图 7.11 显示了两种文件格式,第二种是你在 MapReduce 代码中迭代使用的格式。
图 7.11. 原始社交网络文件格式和为算法优化的 MapReduce 格式

你的第一步是从原始文件创建 MapReduce 格式。以下列表显示了原始输入文件和由转换代码生成的 MapReduce 准备好的输入文件:

生成前面输出的代码在此处显示:^([1])
¹ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch7/shortestpath/Main.java.

MapReduce 数据结构在算法的迭代过程中没有改变;每个作业都产生相同的结构,这使得迭代变得容易,因为输入格式与输出格式相同。
您的 map 函数将执行两个主要任务。首先,它输出所有节点数据以保留图的原始结构。如果您不这样做,您就不能将其作为一个交互式过程,因为 reducer 将无法为下一个 map 阶段重新生成原始图结构。map 的第二个任务是输出具有距离和回溯指针的相邻节点(如果节点具有非无穷大的距离数)。回溯指针携带有关从起始节点访问的节点信息,因此当您到达终点节点时,您就知道到达那里的确切路径。以下是 map 函数的代码.^([2])
² GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch7/shortestpath/Map.java.

当输出原始输入节点以及相邻节点及其距离时,map 输出值的格式(而非内容)与 reducer 读取数据时相同,这使得 reducer 更容易读取数据。为此,您使用Node类来表示节点的概念、其相邻节点以及从起始节点的距离。它的toString方法生成数据的String形式,该形式用作 map 输出键,如下所示.^([3])
³ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch7/shortestpath/Node.java.
列表 7.1. Node类帮助在 MapReduce 代码中进行序列化
public class Node {
private int distance = INFINITE;
private String backpointer;
private String[] adjacentNodeNames;
public static int INFINITE = Integer.MAX_VALUE;
public static final char fieldSeparator = '\t';
...
public String constructBackpointer(String name) {
StringBuilder backpointer = new StringBuilder();
if (StringUtils.trimToNull(getBackpointer()) != null) {
backpointers.append(getBackpointer()).append(":");
}
backpointer.append(name);
return backpointer.toString();
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(distance)
.append(fieldSeparator)
.append(backpointer);
if (getAdjacentNodeNames() != null) {
sb.append(fieldSeparator)
.append(StringUtils
.join(getAdjacentNodeNames(), fieldSeparator));
}
return sb.toString();
}
public static Node fromMR(String value) throws IOException {
String[] parts = StringUtils.splitPreserveAllTokens(
value, fieldSeparator);
if (parts.length < 2) {
throw new IOException(
"Expected 2 or more parts but received " + parts.length);
}
Node node = new Node()
.setDistance(Integer.valueOf(parts[0]))
.setBackpointer(StringUtils.trimToNull(parts[1]));
if (parts.length > 2) {
node.setAdjacentNodeNames(Arrays.copyOfRange(parts, 2,
parts.length));
}
return node;
}
对于每个节点,都会调用 reducer,并为其提供一个包含所有相邻节点及其最短路径的列表。它遍历所有相邻节点,通过选择具有最小最短路径的相邻节点来确定当前节点的最短路径。然后,reducer 输出最小距离、回溯指针和原始相邻节点。以下列表显示了此代码.^([4])
⁴ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch7/shortestpath/Reduce.java.
列表 7.2. 最短路径算法的 reducer 代码


您已准备好运行代码。您需要将输入文件复制到 HDFS 中,然后启动 MapReduce 作业,指定起始节点名称(dee)和目标节点名称(joe):
$ hadoop fs -put \
test-data/ch7/friends-short-path.txt \
friends-short-path.txt
$ hip hip.ch7.shortestpath.Main \
--start dee \
--end joe \
--input friends-short-path.txt \
--output output
==========================================
= Shortest path found, details as follows.
=
= Start node: dee
= End node: joe
= Hops: 2
= Path: dee:ali
==========================================
$ hadoop fs -cat output/2/part*
ali 1 dee dee bob joe
bob 2 dee:kia kia ali joe
dee 0 kia ali
joe 2 dee:ali bob ali
kia 1 dee bob dee
您的工作输出显示,Dee 和 Joe 之间的最小跳数是 2,并且 Ali 是连接节点。
摘要
这个练习展示了如何使用最短路径算法来确定社交网络中两个人之间的最小跳数。与最短路径算法相关的一个算法,称为图直径估计,试图确定节点之间的平均跳数.^([5]) 这已被用于支持在具有数百万个节点的庞大社交网络图中的六度分隔概念.^([6))
⁵参见 U. Kang 等人,“HADI:使用 Hadoop 在大型图中进行快速直径估计和挖掘”(2008 年 12 月),
reports-archive.adm.cs.cmu.edu/anon/ml2008/CMU-ML-08-117.pdf。⁶参见 Lars Backstrom 等人,“四度分隔”,
arxiv.org/abs/1111.4570。
使用 MapReduce 进行迭代图处理的低效性
从 I/O 的角度来看,使用 Map-Reduce 进行图处理是低效的——每个图迭代都在单个 MapReduce 作业中执行。因此,整个图结构必须在作业之间写入 HDFS(三份,或者根据您的 HDFS 复制设置),然后由后续作业读取。可能需要大量迭代的图算法(如这个最短路径示例)最好使用 Giraph 执行,这在 7.1.4 节中有介绍。
最短路径算法有多个应用,但在社交网络中可能更有用且更常用的算法是朋友的朋友(FoF)。
7.1.3. 朋友的朋友算法
社交网站如 LinkedIn 和 Facebook 使用朋友的朋友(FoF)算法来帮助用户扩大他们的网络。
技巧 68 计算 FoF
朋友的朋友算法建议用户可能认识但不是他们直接网络一部分的朋友。对于这种技术,我们将 FoF 视为第二度分隔,如图 7.12 图 7.12 所示。
图 7.12. 一个 Joe 和 Jon 被认为是 Jim 的 FoF 的 FoF 示例

使用这种方法取得成功的关键是要按共同朋友的数量对 FoF 进行排序,这样可以增加用户知道 FoF 的可能性。
问题
您想使用 MapReduce 实现 FoF 算法。
解决方案
计算社交网络中每个用户的 FoF 需要两个 MapReduce 作业。第一个作业计算每个用户的共同朋友,第二个作业根据与朋友之间的连接数对共同朋友进行排序。然后,您可以根据这个排序列表选择顶级 FoF 来推荐新朋友。
讨论
您应该首先查看一个示例图,了解您正在寻找的结果。图 7.13 显示了 Jim,一个用户,被突出显示的人的网络。在这个图中,Jim 的 FoF 用粗圆圈表示,并且 FoF 和 Jim 共同拥有的朋友数量也被识别出来。
图 7.13。表示 Jim 的 FoFs 的图

你的目标是确定所有的好友好友(FoFs)并按共同好友的数量进行排序。在这种情况下,你预期的结果应该是将 Joe 作为第一个 FoF 推荐,其次是 Dee,然后是 Jon。
表示此技术的社会图的文本文件如下所示:
$ cat test-data/ch7/friends.txt
joe jon kia bob ali
kia joe jim dee
dee kia ali
ali dee jim bob joe jon
jon joe ali
bob joe ali jim
jim kia bob ali
此算法要求你编写两个 MapReduce 作业。第一个作业,其伪代码如图 7.14 所示,计算 FoFs,并为每个 FoF 计算共同好友的数量。作业的结果是每个 FoF 关系的行,不包括已经是朋友的人。
图 7.14。计算 FoFs 的第一个 MapReduce 作业

当你对此图(图 7.13)执行此作业时的输出如下所示:
ali kia 3
bob dee 1
bob jon 2
bob kia 2
dee jim 2
dee joe 2
dee jon 1
jim joe 3
jim jon 1
jon kia 1
第二个作业需要生成按共同好友数量排序的 FoFs 的输出。图 7.15 显示了算法。你正在使用辅助排序来按共同好友数量对用户的 FoFs 进行排序。
图 7.15。按共同好友数量排序 FoFs 的第二个 MapReduce 作业

执行此作业对前一个作业输出的输出可以在此处看到:
ali kia:3
bob kia:2,jon:2,dee:1
dee jim:2,joe:2,jon:1,bob:1
jim joe:3,dee:2,jon:1
joe jim:3,dee:2
jon bob:2,kia:1,dee:1,jim:1
kia ali:3,bob:2,jon:1
让我们深入代码。以下列表显示了第一个 MapReduce 作业,该作业计算每个用户的 FoFs.^([7])
GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch7/friendsofafriend/CalcMapReduce.java.
列表 7.3。FoF 计算的 Mapper 和 reducer 实现


下面的列表中第二个 MapReduce 作业的作业是对 FoFs 进行排序,以便你可以看到拥有更多共同好友的 FoFs 排在拥有较少共同好友的 FoFs 之前.^([8])
GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch7/friendsofafriend/SortMapReduce.java.
列表 7.4。排序 FoFs 的 Mapper 和 reducer 实现


我不会展示整个驱动代码,但为了启用辅助排序,我不得不编写几个额外的类,并通知作业使用这些类进行分区和排序的目的:^([9])
GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch7/friendsofafriend/Main.java.
job.setPartitionerClass(PersonNamePartitioner.class);
job.setSortComparatorClass(PersonComparator.class);
job.setGroupingComparatorClass(PersonNameComparator.class);
更多关于辅助排序如何工作的详细信息,请参阅第六章 kindle_split_018.html#ch06。
将包含朋友关系的输入文件复制到 HDFS 中,然后运行驱动代码来运行你的两个 MapReduce 作业。最后两个参数是两个 MapReduce 作业的输出目录:
$ hadoop fs -put test-data/ch7/friends.txt .
$ hip hip.ch7.friendsofafriend.Main \
--input friends.txt \
--calc-output outputcalc \
--sort-output outputsort
运行你的代码后,你可以在 HDFS 中查看输出:
$ hadoop fs -cat outputsort/part*
ali kia:3
bob kia:2,jon:2,dee:1
dee jim:2,joe:2,jon:1,bob:1
jim joe:3,dee:2,jon:1
joe jim:3,dee:2
jon bob:2,kia:1,dee:1,jim:1
kia ali:3,bob:2,jon:1
这个输出验证了你自己在图 7.13 中看到的内容。Jim 有三个 FoFs,它们按照共同朋友的数量排序。
摘要
这种方法不仅可以作为推荐引擎帮助用户扩展他们的网络,还可以在用户浏览社交网络网站时用于信息目的。例如,当你查看 LinkedIn 上的人时,你会看到你与被查看的人之间的分离度。这种方法可以用来预先计算两个跳的信息。为了复制三个跳(例如,显示朋友的朋友的朋友),你需要引入第三个 MapReduce 作业来从第一个作业的输出中计算第三个跳。
为了简化这种方法,我们使用了一个无向图,这意味着用户关系是双向的。大多数社交网络没有这样的概念,算法需要一些小的调整来模拟有向图。
这个例子需要两个 MapReduce 作业来完成算法,这意味着整个图在作业之间被写入 HDFS。考虑到作业的数量,这并不特别低效,但一旦你在图数据上的迭代次数超过两次,可能就是时候开始寻找更有效的工作方式来处理你的图数据了。你将在下一个技术中看到这一点,其中将使用 Giraph 来计算网页的流行度。
7.1.4. 使用 Giraph 在网页图上计算 PageRank
使用 MapReduce 进行迭代图处理引入了许多低效性,这在图 7.16 中被突出显示。
图 7.16. 使用 MapReduce 实现的迭代图算法

如果你的图算法只需要一两次迭代,这并不是你应该担心的事情,但超过这个范围,作业之间的连续 HDFS 屏障将开始累积,尤其是在大型图的情况下。到那时,是时候考虑图处理的替代方法了,比如 Giraph。
本节介绍了 Giraph 的概述,并将其应用于在网页图上计算 PageRank。PageRank 非常适合 Giraph,因为它是一个迭代图算法的例子,在图收敛之前可能需要多次迭代。
Giraph 简介
Giraph 是一个基于 Google 的 Pregel 的 Apache 项目,它描述了一个用于大规模图处理系统。Pregel 被设计用来减少使用 MapReduce 进行图处理的低效性,并提供一个以顶点为中心的编程模型。
为了克服 MapReduce 中存在的磁盘和网络屏障,Giraph 将所有顶点加载到多个工作进程的内存中,并在整个过程中保持它们在内存中。每个图迭代由工作者向他们管理的顶点提供输入、顶点执行其处理以及顶点随后发出消息组成,这些消息由框架路由到图中适当的相邻顶点(如图 7.17 所示)。
图 7.17. Giraph 消息传递

Giraph 使用批量同步通信(BSP)来支持工作者的通信。BSP 本质上是一种迭代消息传递算法,它在连续迭代之间使用全局同步屏障。图 7.18 显示了 Giraph 工作者,每个工作者包含多个顶点,以及通过屏障进行的工作者间通信和同步。
图 7.18. Giraph 工作者,消息传递和同步

技巧 69 将进一步深入细节,但在我们深入之前,让我们快速看一下 PageRank 是如何工作的。
PageRank 简述
PageRank 是由 Google 的创始人于 1998 年在斯坦福大学期间提出的公式。他们的论文讨论了爬取和索引整个网络的整体方法,其中包括他们称之为PageRank的计算,它为每个网页分配一个分数,表示网页的重要性。这不是第一个提出为网页引入评分机制的论文,^([10))但它是最先根据总出链数来权衡传播到每个出链的分数的。
^([10]) 参见 Sergey Brin 和 Lawrence Page,“大规模超文本搜索引擎的解剖学”,
infolab.stanford.edu/pub/papers/google.pdf。^([11]) 在 PageRank 之前,HITS 链接分析方法很流行;参见 Christopher D. Manning、Prabhakar Raghavan 和 Hinrich Schütze 的信息检索导论中的“枢纽和权威”页面,
nlp.stanford.edu/IR-book/html/htmledition/hubs-and-authorities-1.html。
基本上,PageRank 给具有大量入链的页面分配比具有较少入链的页面更高的分数。在评估页面的分数时,PageRank 使用所有入链的分数来计算页面的 PageRank。但它通过将出链 PageRank 除以出链数量来惩罚具有大量出链的个别入链。图 7.19 展示了具有三个页面及其相应 PageRank 值的简单网页图的一个简单示例。
图 7.19. 简单网页图的 PageRank 值

图 7.20 显示了 PageRank 公式。在公式中,|webGraph|是图中所有页面的计数,而d,设置为 0.85,是一个常数阻尼因子,用于两个部分。首先,它表示随机冲浪者在点击多个链接后到达页面的概率(这是一个等于总页面数除以 0.15 的常数),其次,它通过 85%的比例减弱了入站链接 PageRank 的影响。
图 7.20。PageRank 公式

技巧 69 在网络图上计算 PageRank
PageRank 是一种通常需要多次迭代的图算法,因此由于本节引言中讨论的磁盘屏障开销,它不适合在 MapReduce 中实现。这项技术探讨了如何使用 Giraph,它非常适合需要在大图上多次迭代的算法,来实现 PageRank。
问题
你想使用 Giraph 实现一个迭代的 PageRank 图算法。
解决方案
PageRank 可以通过迭代 MapReduce 作业直到图收敛来实现。映射器负责将节点 PageRank 值传播到其相邻节点,而归约器负责为每个节点计算新的 PageRank 值,并使用更新后的 PageRank 值重新创建原始图。
讨论
PageRank 的一个优点是它可以迭代计算并局部应用。每个顶点都从一个种子值开始,这个值是节点数量的倒数,并且随着每次迭代,每个节点都会将其值传播到它链接的所有页面。每个顶点随后将所有入站顶点的值加起来以计算一个新的种子值。这个迭代过程会一直重复,直到达到收敛。
收敛是衡量自上次迭代以来种子值变化程度的一个指标。如果收敛值低于某个阈值,这意味着变化很小,你可以停止迭代。对于收敛需要太多迭代的较大图,也常见限制迭代次数的做法。
图 7.21 显示了 PageRank 对之前在本章中看到的简单图进行的两次迭代。
图 7.21。PageRank 迭代的示例

图 7.22 显示了将 PageRank 算法表示为映射和归约阶段。映射阶段负责保留图以及向所有出站节点发出 PageRank 值。归约器负责为每个节点重新计算新的 PageRank 值,并将其包含在原始图的输出中。
图 7.22。PageRank 分解为映射和归约阶段

在这个技术中,你将操作图 7.23 中显示的图。在这个图中,所有节点都有入站和出站边。
图 7.23。该技术的示例网络图

Giraph 支持各种输入和输出数据格式。对于这项技术,我们将使用JsonLongDoubleFloatDouble-VertexInputFormat作为输入格式;它要求顶点以数值形式表示,并附带一个关联的权重,我们不会使用这项技术。我们将把顶点 A 映射到整数 0,B 映射到 1,依此类推,并为每个顶点识别相邻顶点。数据文件中的每一行代表一个顶点和到相邻顶点的有向边:
[<vertex id>,<vertex value>,[[<dest vertex id>,<vertex weight>][...]]]
以下输入文件表示图 7.23 中的图:
[0,0,[[1,0],[3,0]]]
[1,0,[[2,0]]]
[2,0,[[0,0],[1,0]]]
[3,0,[[1,0],[2,0]]]
将此数据复制到名为 webgraph.txt 的文件中,并将其上传到 HDFS:
$ hadoop fs -put webgraph.txt .
你的下一步是编写 Giraph 顶点类。Giraph 模型的好处在于它的简单性——它提供了一个基于顶点的 API,其中你需要实现该顶点上单次迭代的图处理逻辑。顶点类负责处理来自相邻顶点的传入消息,使用它们来计算节点的新 PageRank 值,并将更新的 PageRank 值(除以出度边数)传播到相邻顶点,如下面的列表所示.^([12])
¹² GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch7/pagerank/giraph/PageRankVertex.java。
列表 7.5. 页面排名顶点

安装 Giraph
Giraph 是一个 Java 库,并包含在这本书的代码分发中。因此,对于这项技术中的示例,不需要安装 Giraph。如果你想要进一步探索 Giraph,Giraph 网站giraph.apache.org/提供了下载和安装说明。
如果你将网页图推送到 HDFS 并运行你的作业,它将运行五次迭代,直到图收敛:

处理完成后,你可以在 HDFS 中查看输出,以查看每个顶点的 Page-Rank 值:
$ hadoop fs -cat output/art*
0 0.15472094578266
2 0.28902904137380575
1 0.25893832306149106
3 0.10043738978626424
根据输出,节点 C(顶点 2)具有最高的 PageRank,其次是节点 B(顶点 1)。考虑到 B 有三个入链,而 C 只有两个,这种观察可能令人惊讶。但如果你看看谁链接到 C,你可以看到节点 B,它也具有很高的 PageRank 值,只有一个出链指向 C,因此节点 C 除了从节点 D 获得的其它入链 PageRank 分数外,还获得了 B 的全部 PageRank 分数。因此,节点 C 的 PageRank 将始终高于 B 的。
摘要
当你将不得不为 MapReduce 编写的代码与 Giraph 的代码进行比较时,很明显,Giraph 提供了一个简单且抽象的模型,该模型丰富地表达了图的概念。Giraph 相对于 MapReduce 的效率使得 Giraph 成为满足你的图处理需求的一个有吸引力的解决方案。
Giraph 扩展到大型图的能力在 Facebook 的一篇文章中被强调,该文章讨论了 Facebook 如何使用 Giraph 处理一个拥有万亿条边的图。^([13)] 有其他图技术你可以根据你的需求进行评估:
^([13]) Avery Ching,“扩展 Apache Giraph 到万亿条边”,[
www.facebook.com/notes/facebook-engineering/scaling-apache-giraph-to-a-trillion-edges/10151617006153920]。
-
Faunus 是一个基于 Hadoop 的开源项目,支持 HDFS 和其他数据源。[
thinkaurelius.github.io/faunus/]] -
GraphX 是一个基于内存的 Spark 项目。目前,GraphX 不受任何商业 Hadoop 供应商的支持,尽管它将很快被包含在 Cloudera CDH 5.1 中。[
amplab.cs.berkeley.edu/publication/graphx-grades/]] -
GraphLab 是卡内基梅隆大学开发的一个基于 C++的、分布式的图处理框架。[
graphlab.com/]。
尽管你实现了 PageRank 公式,但由于你的图是高度连接的,并且每个节点都有出站链接,所以这使它变得简单。没有出站链接的页面被称为悬空页面,它们对 PageRank 算法构成了问题,因为它们成为PageRank 陷阱——它们的 PageRank 值不能通过图进一步传播。这反过来又导致收敛问题,因为不是强连通的图不能保证收敛。
解决这个问题有各种方法。你可以在你的 PageRank 迭代之前移除悬空节点,然后在图收敛后添加它们以进行最终的 Page-Rank 迭代。或者,你可以将所有悬空页面的 PageRank 总和相加,并将它们重新分配到图中的所有节点。有关处理悬空节点以及高级 PageRank 实践的详细考察,请参阅 Amy N. Langville 和 Carl Dean Meyer 所著的《Google 的 PageRank 及其超越》(普林斯顿大学出版社,2012 年)。
这部分关于图的讨论到此结束。正如你所学到的,图是表示社交网络中的人和组织网络中页面的有用机制。你使用这些模型来发现一些关于你的数据的有用信息,例如找到两点之间的最短路径以及哪些网页比其他网页更受欢迎。
这引出了下一节的主题,Bloom 过滤器。Bloom 过滤器是一种不同于图的数据结构。虽然图用于表示实体及其关系,但 Bloom 过滤器是一种用于建模集合并在其数据上执行成员查询的机制,正如你接下来会发现的那样。
7.2. Bloom 过滤器
布隆过滤器是一种数据结构,它提供了一个成员查询机制,其中查找的答案有两个值之一:一个确定的否,意味着正在查找的项目不在布隆过滤器中,或者一个可能,意味着该项目存在一定的概率。布隆过滤器因其空间效率高而受到欢迎——表示N个元素的存在所需的空間比数据结构中的N个位置要少得多,这就是为什么成员查询可能会产生假阳性结果。布隆过滤器中的假阳性数量可以调整,我们将在稍后讨论。
布隆过滤器在 BigTable 和 HBase 中使用,以消除从磁盘读取块以确定它们是否包含键的需求。它们还用于分布式网络应用程序,如 Squid,以在多个实例之间共享缓存细节,而无需复制整个缓存或在缓存未命中时产生网络 I/O 开销。
布隆过滤器的实现很简单。它们使用一个大小为m位的位阵列,其中最初每个位都设置为0。它们还包含k个哈希函数,这些函数用于将元素映射到位阵列中的k个位置。
要向布隆过滤器添加一个元素,它被哈希k次,然后使用哈希值的模和位阵列的大小来将哈希值映射到特定的位阵列位置。然后,位阵列中的该位被切换到1。图 7.24 展示了三个元素被添加到布隆过滤器及其在位阵列中的位置。
图 7.24。向布隆过滤器添加元素

要检查布隆过滤器中一个元素的成员资格,就像添加操作一样,该元素被哈希k次,每个哈希键都用于索引位阵列。只有当所有k个位阵列位置都设置为1时,才会返回成员查询的true响应。否则,查询的响应是false。
图 7.25 展示了一个成员查询的例子,其中项目之前已被添加到布隆过滤器中,因此所有位阵列位置都包含一个1。这是一个真正的阳性成员查询结果的例子。
图 7.25。一个布隆过滤器成员查询产生真正阳性结果的例子

图 7.26 展示了如何得到一个成员查询的假阳性结果。正在查询的元素是d,它尚未被添加到布隆过滤器中。碰巧的是,d的所有k个哈希都映射到由其他元素设置的1的位置。这是布隆过滤器中碰撞的例子,其结果是假阳性。
图 7.26。一个布隆过滤器成员查询产生假阳性结果的例子

误报的概率可以根据两个因素进行调整:m,位数组中的位数,和k,哈希函数的数量。或者用另一种方式表达,如果你有一个期望的误报率,并且你知道将要添加到布隆过滤器中的元素数量,你可以使用图 7.27 中的公式来计算位数组中所需的位数。
图 7.27. 计算布隆过滤器所需位数数的公式

图 7.28 中显示的公式假设最优的哈希数k和生成的哈希值在范围{1..m}上是随机的。
图 7.28. 计算最优哈希数目的公式

换句话说,如果你想在布隆过滤器中添加 100 万个元素,并且你的成员查询的误报率为 1%,你需要 95,850,588 位或 1.2 兆字节,使用七个哈希函数。这大约是每个元素 9.6 位。
表 7.1 显示了不同误报率下每个元素所需的位数计算结果。
表 7.1. 不同误报率下每个元素所需的位数
| 误报 | 每个元素所需的位数 |
|---|---|
| 2% | 8.14 |
| 1% | 9.58 |
| 0.1% | 14.38 |
在脑海中装满了所有这些理论之后,你现在需要将注意力转向如何利用布隆过滤器在 MapReduce 中应用的主题。
技巧 70:在 MapReduce 中并行创建布隆过滤器
MapReduce 非常适合并行处理大量数据,因此如果你想要基于大量输入数据创建布隆过滤器,它是一个很好的选择。例如,假设你是一家大型互联网社交媒体组织,拥有数亿用户,并且你想要为一定年龄段的用户子集创建一个布隆过滤器。你如何在 MapReduce 中做到这一点?
问题
你想在 MapReduce 中创建一个布隆过滤器。
解决方案
编写一个 MapReduce 作业,使用 Hadoop 内置的BloomFilter类创建并输出布隆过滤器。mapper 负责创建中间布隆过滤器,单个 reducer 将它们合并在一起以输出合并后的布隆过滤器。
讨论
图 7.29 展示了这项技术将做什么。你将编写一个 mapper,它将处理用户数据并创建包含一定年龄段用户的布隆过滤器。mapper 将输出它们的布隆过滤器,而单个 reducer 将它们合并在一起。最终结果是存储在 HDFS 中的单个布隆过滤器,格式为 Avro。
图 7.29. 创建布隆过滤器的 MapReduce 作业

Hadoop 随带了一个 Bloom 过滤器的实现,形式为 org.apache.hadoop.util.bloom.BloomFilter 类,如图 7.30 所示。figure 7.30。幸运的是,它是一个 Writable,这使得它在 MapReduce 中很容易传输。Key 类用于表示一个元素,它也是一个用于字节数组的 Writable 容器。
图 7.30. MapReduce 中的 BloomFilter 类

构造函数要求你指定要使用的哈希函数。你可以选择两种实现:Jenkins 和 Murmur。它们都比 SHA-1 这样的加密哈希器更快,并且产生良好的分布。基准测试表明 Murmur 的哈希时间比 Jenkins 快,所以我们在这里使用 Murmur。
让我们继续代码。你的 map 函数将操作你的用户信息,这是一个简单的键/值对,其中键是用户名,值是用户的年龄:^([14])
(14) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch7/bloom/BloomFilterCreator.java.

为什么你在 close 方法中输出 Bloom 过滤器,而不是在 map 方法处理每个记录时都输出?你这样做是为了减少 map 和 reduce 阶段之间的流量;如果你可以在 map 端自己伪合并它们,并每 map 输出一个单独的 BloomFilter,就没有必要输出大量数据。
你 reducer 的任务是合并所有 mapper 输出的 Bloom 过滤器到一个单独的 Bloom 过滤器。这些合并是通过 BloomFilter 类公开的位运算 OR 方法来执行的。在执行合并时,所有 BloomFilter 属性,如位数组大小和哈希数量,必须相同:^([15])
(15) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch7/bloom/BloomFilterCreator.java.

要尝试这个,上传你的样本用户文件并启动你的作业。当作业完成时,将 Avro 文件的内容导出以查看你的 BloomFilter 的内容:
$ hadoop fs -put test-data/ch7/user-ages.txt .
$ hadoop fs -cat user-ages.txt
anne 23
joe 45
alison 32
mike 18
marie 54
$ hip hip.ch7.bloom.BloomFilterCreator \
--input user-ages.txt \
--output output
$ hip hip.ch7.bloom.BloomFilterDumper output/part-00000.avro
{96, 285, 292, 305, 315, 323, 399, 446, 666, 667, 670,
703, 734, 749, 810}
BloomFilterDumper 代码从 Avro 文件中反序列化 BloomFilter 并调用 toString() 方法,该方法反过来调用 BitSet.toString() 方法,该方法输出每个“开启”位的偏移量。
摘要
你使用了 Avro 作为 Bloom 过滤器的序列化格式。你同样可以在你的 reducer 中输出 BloomFilter 对象,因为它是一个 Writable。
在这个技术中,你使用了单个 reducer,这可以很好地扩展到使用数千个映射任务和位数组大小在百万级的BloomFilter的工作。如果执行单个 reducer 所需的时间变得过长,你可以运行多个 reducer 来并行化 Bloom 过滤器联合,并在后处理步骤中将它们进一步合并成一个单一的 Bloom 过滤器。
创建 Bloom 过滤器的另一种分布式方法是将 reducer 集合视为整体位数组,并在映射阶段进行哈希并输出哈希值。分区器随后将输出分配给管理该部分位数组的相应 reducer。图 7.31 展示了这种方法。
图 7.31. 创建 Bloom 过滤器的另一种架构

为了代码的可读性,你在这个技术中硬编码了BloomFilter参数;实际上,你将希望动态计算它们或将它们移动到配置文件中。
这种技术导致了BloomFilter的创建。这个BloomFilter可以从 HDFS 中提取出来用于另一个系统,或者可以直接在 Hadoop 中使用,如图 61 所示,其中 Bloom 过滤器被用作过滤连接中 reducer 输出的数据的方式。
7.3. HyperLogLog
想象一下你正在构建一个网络分析系统,其中你计算的数据点之一是访问 URL 的唯一用户数量。你的问题域是网络规模的,因此你有数亿用户。一个简单的 Map-Reduce 聚合实现将涉及使用散列表来存储和计算唯一用户,但处理大量用户时这可能会耗尽你的 JVM 堆。一个更复杂的解决方案将使用二次排序,以便用户 ID 被排序,并且分组发生在 URL 级别,这样你就可以在不产生任何存储开销的情况下计算唯一用户。
当你能够一次性处理整个数据集时,这些解决方案工作得很好。但如果你有一个更复杂的聚合系统,你在时间桶中创建聚合,并且需要合并桶,那么你将需要存储每个时间桶中每个 URL 的整个唯一用户集合,这将爆炸性地增加你的数据存储需求。
为了解决这个问题,你可以使用一个概率算法,如 HyperLogLog,它比散列表有显著更小的内存占用。与这些概率数据结构相关的权衡是准确性,你可以调整它。在某种程度上,HyperLogLog 类似于 Bloom 过滤器,但关键区别在于 HyperLogLog 将估计一个计数,而 Bloom 过滤器只提供成员资格能力。
在本节中,你将了解 HyperLogLog 是如何工作的,并看到它如何在 MapReduce 中高效地计算唯一计数。
7.3.1. HyperLogLog 的简要介绍
HyperLogLog 首次在 2007 年的一篇论文中提出,用于“估计非常大的数据集合中不同元素的数量”。^(16) 潜在的应用包括基于链接的网页垃圾邮件检测和大型数据集的数据挖掘。
^(16) Philippe Flajolet 等人,“HyperLogLog:近最优基数估计算法的分析”,
algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf。
HyperLogLog 是一种概率性基数估计器——它放宽了精确计算集合中元素数量的约束,而是估计元素的数量。支持精确集合基数计算的数结构需要与元素数量成比例的存储空间,这在处理大数据集时可能不是最优的。概率性基数结构比它们的精确基数对应物占用更少的内存,并且适用于基数可能偏差几个百分点的场景。
HyperLogLog 可以使用 1.5 KB 的内存对超过 10⁹ 的计数进行基数估计,误差率为 2%。HyperLogLog 通过计算哈希中的最大连续零位数并使用概率来预测所有唯一项的基数来工作。图 7.32 展示了哈希值在 HyperLogLog 中的表示。有关更多详细信息,请参阅 HyperLogLog 论文。
图 7.32. HyperLogLog 的工作原理

在使用 HyperLogLog 时,你需要调整两个参数:
-
桶的数量,通常用数字 b 表示,然后通过计算 2^b* 来确定桶的数量。因此,b 的每次增加都会使桶的数量翻倍。b 的下限是 4,上限因实现而异。
-
用于表示桶中最大连续零位的位数。
因此,HyperLogLog 的大小通过 2^b* 每桶位来计算。在典型使用中,b 是 11,每桶的位数是 5,这导致 10,240 位,或 1.25 KB。
技巧 71 使用 HyperLogLog 计算唯一计数
在这个技巧中,你将看到一个 HyperLogLog 的简单示例。总结将展示如何将 HyperLogLog 集成到你的 MapReduce 流中的一些细节。
问题
你正在处理一个大型数据集,并且你想计算唯一计数。你愿意接受一小部分的误差。
解决方案
使用 HyperLogLog。
讨论
对于这个技巧,你将使用来自 GitHub 项目 java-hll 的 HyperLogLog Java 实现(github.com/aggregateknowledge/java-hll)。此代码提供了基本的 HyperLogLog 函数,以及允许你执行多个日志的并集和交集的有用函数。
以下示例展示了这样一个简单情况,您的数据由一个数字数组组成,并使用 Google 的 Guava 库为每个数字创建哈希并将其添加到 HyperLogLog 中:^([17])。
^(17) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch7/hyperloglog/Example.java。

运行此示例会得到预期的唯一项目数量:
$ hip hip.ch7.hyperloglog.Example
Distinct count = 5
这段代码可以轻松地改编成一个 Hadoop 作业,以对大型数据集执行不同的计数。例如,想象您正在编写一个 MapReduce 作业来计算访问您网站上每个页面的不同用户数量。在 MapReduce 中,您的映射器会输出 URL 和用户 ID 作为键和值,而您的归约器需要计算每个网页的唯一用户集。在这种情况下,您可以使用 HyperLogLog 结构来高效地计算用户的大致唯一计数,而无需使用哈希集带来的开销。
摘要
在本例中使用的 hll HyperLogLog 实现有一个 toBytes 方法,您可以使用它来序列化 HyperLogLog,同时它还有一个 fromBytes 方法用于反序列化。这使得它在 MapReduce 流程和持久化中使用起来相对简单。例如,Avro 有一个 bytes 字段,您可以将 hll 字节数据写入您的记录中。如果您在使用 SequenceFiles,也可以编写自己的 Writable。
如果您使用 Scalding 或 Summingbird,那么 Algebird 提供了一个 HyperLogLog 实现供您使用——更多详情请参阅 github.com/twitter/algebird。
7.4. 章节总结
本章中概述的大多数算法都很直接。使事情变得有趣的是,它们如何在 MapReduce 中应用,从而能够高效地处理大型数据集。
本章仅对如何建模和处理数据进行了初步探讨。有关排序和连接的算法将在其他章节中介绍。下一章将介绍诊断和调整 Hadoop 的技术,以从您的集群中榨取尽可能多的性能。
在 Bloom 过滤器的例子中,我们探讨了如何使用 MapReduce 并行创建一个 Bloom 过滤器,然后将其应用于优化 Map-Reduce 中的半连接操作。
我们在本章中只是触及了数据建模和处理的表面。有关排序和连接的算法将在其他章节中介绍。下一章将介绍诊断和调整 Hadoop 的技术,以从您的集群中榨取尽可能多的性能。
第八章. 调优、调试和测试
本章涵盖
-
测量和调整 MapReduce 执行时间
-
调试您的应用程序
-
提高代码质量的测试技巧
想象一下,你已经编写了一块新的 MapReduce 代码,并在你那崭新的集群上执行它。你惊讶地发现,尽管拥有一个规模不小的集群,但你的作业运行时间比你预期的要长得多。显然,你的作业遇到了性能问题,但你是如何确定问题所在的呢?
本章首先回顾了 Map-Reduce 中常见的性能问题,例如缺乏数据局部性和使用过多映射器。本节调优部分还检查了一些你可以对作业进行的增强,通过在洗牌阶段使用二进制比较器和使用紧凑的数据格式来最小化解析和数据传输时间,从而提高作业的效率。
本章的第二部分涵盖了帮助你调试应用程序的一些技巧,包括如何访问 YARN 容器启动脚本的操作说明,以及一些关于如何设计你的 MapReduce 作业以帮助未来调试工作的建议。
最后一个部分探讨了如何为 MapReduce 代码提供充分的单元测试,并检查了一些你可以使用的防御性编码技术,以最小化表现不佳的代码。无论准备和测试多么充分,都无法保证你不会遇到任何问题,如果确实遇到了问题,我们将探讨如何调试你的作业以找出出了什么问题。
Hadoop 2
本章中的技术适用于 Hadoop 2。由于不同主要版本的 Hadoop 之间存在不兼容性,因此其中一些技术无法与早期版本兼容。
8.1. 测量,测量,再测量
在开始性能调优之前,你需要有工具和流程来捕获系统指标。这些工具将帮助你收集和检查与你的应用程序相关的经验数据,并确定你是否遇到了性能问题。
在本节中,我们将探讨 Hadoop 提供的工具和指标,同时也会涉及到监控作为性能调优工具包中的附加工具。
重要的是要捕获集群的 CPU、内存、磁盘和网络利用率。如果可能的话,你也应该捕获 MapReduce(或任何其他 YARN 应用程序)的统计信息。拥有集群的历史和当前指标将允许你查看硬件和软件中的异常,并将它们与任何可能指向你的工作未按预期速率进行的观察结果相关联。
最终的目标是确保你不会过度使用或未充分利用你的硬件。如果你过度使用硬件,你的系统可能花费大量时间在争夺资源上,无论是 CPU 上下文切换还是内存页面交换。集群的未充分利用意味着你无法从硬件中获得全部潜力。
幸运的是,有大量工具可供您监控集群,从收集和报告系统活动的内置 Linux 工具 sar,到更复杂的工具如 Nagios 和 Ganglia。Nagios (www.nagios.org/) 和 Ganglia (ganglia.sourceforge.net/) 都是开源项目,旨在监控您的基础设施,特别是 Ganglia 提供了一个丰富的用户界面和有用的图表,其中一些可以在图 8.1 中看到。Ganglia 的额外优势在于能够从 Hadoop 中提取统计信息.^([2])
¹ 这篇 IBM 文章讨论了使用 sar 和 gnuplot 生成系统活动图:David Tansley,“使用 gnuplot 在您的网页中显示数据”,
www.ibm.com/developerworks/aix/library/au-gnuplot/index.html。² Hadoop 维基上有关于 Ganglia 和 Hadoop 集成的基本说明:GangliaMetrics,
wiki.apache.org/hadoop/GangliaMetrics。
图 8.1. 显示多个主机 CPU 利用率的 Ganglia 截图

如果您使用的是商业 Hadoop 发行版,它可能捆绑了包含监控的管理用户界面。如果您使用的是 Apache Hadoop 发行版,您应该使用 Apache Ambari,它简化了集群的配置、管理和监控。Ambari 在幕后使用 Ganglia 和 Nagios。
在您的监控工具就绪后,是时候看看如何调优和优化您的 MapReduce 作业了。
8.2. 调优 MapReduce
在本节中,我们将介绍影响 MapReduce 作业性能的常见问题,并探讨如何解决这些问题。在此过程中,我还会指出一些最佳实践,以帮助您优化作业。
我们将从查看一些阻碍 Map-Reduce 作业性能的更常见问题开始。
8.2.1. MapReduce 作业中的常见低效
在深入研究技巧之前,让我们从高层次上看看 MapReduce 作业,并确定可能影响其性能的各个区域。请参阅图 8.2。
图 8.2. MapReduce 作业中可能发生的各种低效情况

本节关于性能调优的其余部分涵盖了图 8.2 中确定的问题。但在我们开始调优之前,我们需要看看您如何轻松地获取作业统计信息,这将帮助您确定需要调优的区域。
技巧 72 查看作业统计信息
评估 MapReduce 作业性能的第一步是 Hadoop 为您的作业测量的指标。在本技巧中,您将学习如何访问这些指标。
问题
您想访问 MapReduce 作业的指标。
解决方案
使用作业历史记录 UI、Hadoop CLI 或自定义工具。
讨论
MapReduce 为每个作业收集各种系统和作业计数器,并将它们持久化到 HDFS 中。你可以以两种不同的方式提取这些统计数据:
-
使用作业历史界面。
-
使用 Hadoop 命令行界面(CLI)查看作业和任务计数器以及作业历史中的其他度量。
作业历史保留
默认情况下,作业历史保留一周。这可以通过更新mapreduce.jobhistory.max-age-ms来更改。
让我们检查这两个工具,从作业历史界面开始。
作业历史
在 Hadoop 2 中,作业历史是一个 MapReduce 特定的服务,它从完成的 MapReduce 作业中收集度量,并提供一个用户界面来查看它们。图 8.3 显示了如何在作业历史界面中访问作业统计信息。
³ 第二章 包含了如何访问作业历史用户界面的详细信息。
图 8.3. 在作业历史界面中访问作业计数器

此屏幕显示了映射任务、减少任务以及所有任务的聚合度量。此外,每个度量都允许你深入查看报告该度量的所有任务。在每个度量特定的屏幕中,你可以按度量值排序,以快速识别表现出异常高或低度量值的任务。
Hadoop 2 的度量改进
Hadoop 2 通过添加 CPU、内存和垃圾收集统计信息来改进作业度量,因此你可以很好地了解每个进程的系统利用率。
如果你无法访问作业历史界面,也不要灰心,因为你可以通过 Hadoop CLI 访问数据。
使用 CLI 访问作业历史
作业历史输出存储在由可配置的mapreduce.jobhistory.done-dir指定的目录中,默认位置为 Apache Hadoop 的/tmp/hadoop-yarn/staging/history/done/。在此目录中,作业根据提交日期进行分区。如果你知道你的作业 ID,你可以搜索你的目录:
⁴ 非 Apache Hadoop 发行版可能对
mapreduce.jobhistory.done-dir有自定义值——例如,在 CDH 中,此目录是/user/history/done。
$ hadoop fs -lsr /tmp/hadoop-yarn/staging/history/done/ \
| grep job_1398974791337_0037
此命令返回的文件之一应该是一个具有.jhist 后缀的文件,这是作业历史文件。使用 Hadoop history命令的完整路径来查看你的作业历史详细信息:
$ hadoop job -history <history file>
Hadoop job: job_1398974791337_0037
=====================================
User: aholmes
JobName: hip-2.0.0.jar
JobConf: hdfs://localhost:8020/tmp/hadoop-yarn/...
Submitted At: 11-May-2014 13:06:48
Launched At: 11-May-2014 13:07:07 (19sec)
Finished At: 11-May-2014 13:07:17 (10sec)
Status: SUCCEEDED
Counters:
|Group Name |Counter name |Map Value |Reduce |Total |
-----------------------------------------------------------------------
|File System |FILE: Number of bytes read |0 |288 |288
|File System |FILE: Number of bytes written |242,236 |121,304 |363,540
|File System |FILE: Number of read operations |0 |0 |0
|File System |FILE: Number of write operations|0 |0 |0
...
Task Summary
============================
Kind Total Successful Failed Killed StartTime FinishTime
Setup 0 0 0 0
Map 2 2 0 0 11-May-2014 13:07:09 11-May-2014 13:07:13
Reduce 1 1 0 0 11-May-2014 13:07:15 11-May-2014 13:07:17
============================
Analysis
=========
Time taken by best performing map task task_1398974791337_0037_m_000001:
3sec
Average time taken by map tasks: 3sec
Worse performing map tasks:
TaskId Timetaken
task_1398974791337_0037_m_000000 3sec
task_1398974791337_0037_m_000001 3sec
The last map task task_1398974791337_0037_m_000000 finished at
(relative to the Job launch time): 11-May-2014 13:07:13 (5sec)
Time taken by best performing shuffle task
task_1398974791337_0037_r_000000: 1sec
Average time taken by shuffle tasks: 1sec
Worse performing shuffle tasks:
TaskId Timetaken
task_1398974791337_0037_r_000000 1sec
The last shuffle task task_1398974791337_0037_r_000000 finished at
(relative to the Job launch time): 11-May-2014 13:07:17 (9sec)
Time taken by best performing reduce task
task_1398974791337_0037_r_000000: 0sec
Average time taken by reduce tasks: 0sec
Worse performing reduce tasks:
TaskId Timetaken
task_1398974791337_0037_r_000000 0sec
The last reduce task task_1398974791337_0037_r_000000 finished at
(relative to the Job launch time): 11-May-2014 13:07:17 (10sec)
=========
之前的输出只是命令产生的整体输出的小部分,值得你自己执行以查看它暴露的完整度量。此输出在快速评估平均和最坏的任务执行时间等度量方面很有用。
作业历史界面和 CLI 都可以用来识别作业中的许多性能问题。随着我们本节中技术的介绍,我将突出显示如何使用作业历史计数器来帮助识别问题。
让我们通过查看可以在映射端进行的优化来开始行动。
8.2.2. Map 优化
MapReduce 作业的 map 端优化通常与输入数据及其处理方式有关,或者与你的应用程序代码有关。你的 mapper 负责读取作业输入,因此诸如你的输入文件是否可分割、数据局部性和输入分割数量等变量都可能影响作业的性能。你的 mapper 代码中的低效也可能导致作业执行时间比预期更长。
本节涵盖了你的作业可能遇到的一些数据相关的问题。特定于应用程序的问题在 8.2.6 节中介绍。
技术编号 73 数据局部性
MapReduce 最大的性能特性之一是“将计算推送到数据”的概念,这意味着 map 任务被调度以从本地磁盘读取输入。然而,数据局部性并不保证,你的文件格式和集群利用率可能会影响数据局部性。在这个技术中,你将学习如何识别缺乏局部性的迹象,并了解一些解决方案。
问题
你想要检测是否有 map 任务正在通过网络读取输入。
解决方案
检查作业历史元数据中的几个关键计数器。
讨论
在作业历史中,有一些计数器你应该密切关注,以确保数据局部性在 mapper 中发挥作用。这些计数器在表 8.1 中列出。
表 8.1. 可以指示是否发生非本地读取的计数器
| 计数器名称 | 作业历史名称 | 如果...,你可能存在非本地读取 |
|---|---|---|
| HDFS_BYTES_READ | HDFS:读取的字节数 | ...这个数字大于输入文件的块大小。 |
| DATA_LOCAL_MAPS | 数据本地 map 任务 | ...任何 map 任务的此值设置为 0。 |
| RACK_LOCAL_MAPS | 机架本地 map 任务 | ...任何 map 任务的此值设置为 1。 |
非本地读取可能有多个原因:
-
你正在处理大文件和无法分割的文件格式,这意味着 mapper 需要从其他数据节点流式传输一些块。
-
文件格式支持分割,但你使用的是不支持分割的输入格式。例如,使用 LZOP 压缩文本文件,然后使用
TextInputFormat,它不知道如何分割文件。 -
YARN 调度器无法将 map 容器调度到节点。这可能发生在你的集群负载过重的情况下。
你可以考虑几种选项来解决这些问题:
-
当使用不可分割的文件格式时,将文件写入或接近 HDFS 块大小,以最小化非本地读取。
-
如果你使用容量调度器,将
yarn.scheduler.capacity.node-locality-delay设置为在调度器中引入更多延迟,从而增加 map 任务在数据本地节点上调度成功的几率。 -
如果你正在使用文本文件,切换到支持分割的压缩编解码器,如 LZO 或 bzip2。
接下来,让我们看看当您处理大型数据集时,另一个与数据相关的优化。
技巧 74 处理大量输入拆分
具有大量输入拆分的作业不是最优的,因为每个输入拆分都由单个映射器执行,每个映射器作为一个单独的进程执行。由于这些进程的派生对调度器和集群的总体压力导致作业执行时间缓慢。此技术检查了一些可以用来减少输入拆分数量并保持数据局部性的方法。
问题
您想优化一个运行数千个映射器的作业。
解决方案
使用 CombineFileInputFormat 来组合运行较少映射器的多个块。
讨论
两个主要问题会导致作业需要大量映射器:
-
您的输入数据由大量的小文件组成。所有这些文件的总大小可能很小,但 MapReduce 将为每个小文件启动一个映射器,因此您的作业将花费更多的时间来启动进程,而不是实际处理输入数据。
-
您的文件并不小(它们接近或超过 HDFS 块大小),但您的总数据量很大,跨越 HDFS 中的数千个块。每个块都分配给单个映射器。
如果您的问题与小型文件相关,您应该考虑将这些文件压缩在一起或使用容器格式,如 Avro 来存储您的文件。
在上述任何一种情况下,您都可以使用 CombineFileInputFormat,它将多个块组合成输入拆分,以减少整体输入拆分的数量。它是通过检查被输入文件占用的所有块,将每个块映射到存储它的数据节点集合,然后将位于同一数据节点上的块组合成一个单独的输入拆分来实现的,以保持数据局部性。这个抽象类有两个具体的实现:
-
CombineTextInputFormat与文本文件一起工作,并使用TextInputFormat作为底层输入格式来处理和向映射器输出记录。 -
CombineSequenceFileInputFormat与 SequenceFiles 一起工作。
图 8.4 比较了 TextInputFormat 生成的拆分与 CombineTextInputFormat 生成的拆分。
图 8.4. CombineTextInputFormat 与默认大小设置一起工作的一个示例

有一些可配置的选项允许您调整输入拆分的组成方式:
-
mapreduce.input.fileinputformat.split.minsize.per.node—指定每个输入拆分应在数据节点内包含的最小字节数。默认值是0,表示没有最小大小。 -
mapreduce.input.fileinputformat.split.minsize.per.rack—指定每个输入拆分应在单个机架内包含的最小字节数。默认值是0,表示没有最小大小。 -
mapreduce.input.fileinputformat.split.maxsize—指定输入拆分的最大大小。默认值是0,表示没有最大大小。
默认设置下,您将得到每个数据节点最多一个输入拆分。根据您集群的大小,这可能会妨碍您的并行性,在这种情况下,您可以调整 mapreduce.input.fileinputformat.split.maxsize 以允许一个节点有多个拆分。
如果一个作业的输入文件明显小于 HDFS 块大小,那么您的集群可能花费更多的时间在启动和停止 Java 进程上,而不是执行工作。如果您遇到这个问题,应该查阅第四章,我在那里解释了您可以采取的各种方法来高效地处理小文件。
技术编号 75:在 YARN 中在集群中生成输入拆分
如果提交 MapReduce 作业的客户端不在与您的 Hadoop 集群本地的网络上,那么输入拆分计算可能会很昂贵。在这个技术中,您将学习如何将输入拆分计算推送到 MapReduce ApplicationMaster。
仅在 YARN 上
这种技术仅适用于 YARN。
问题
您的客户端是远程的,输入拆分计算花费了很长时间。
解决方案
将 yarn.app.mapreduce.am.compute-splits-in-cluster 设置为 true。
讨论
默认情况下,输入拆分是在 MapReduce 驱动程序中计算的。当输入源是 HDFS 时,输入格式需要执行文件列表和文件状态命令等操作来检索块详情。当处理大量输入文件时,这可能会很慢,尤其是在驱动程序和 Hadoop 集群之间存在网络延迟时。
解决方案是将 yarn.app.mapreduce.am.compute-splits-in-cluster 设置为 true,将输入拆分计算推送到运行在 Hadoop 集群内部的 MapReduce ApplicationMaster,这样可以最小化计算输入拆分所需的时间,从而减少您整体作业执行时间。
从您的映射器中发出过多数据
尽可能避免从您的映射器输出大量数据,因为这会导致由于洗牌而产生大量的磁盘和网络 I/O。您可以在映射器中使用过滤器和平面投影来减少您正在处理的数据量,并在 MapReduce 中减少溢出。下推可以进一步改进您的数据管道。技术 55 包含了过滤器和下推的示例。
8.2.3. 洗牌优化
MapReduce 中的洗牌负责组织和交付您的映射器输出到您的归约器。洗牌有两个部分:映射端和归约端。映射端负责为每个归约器分区和排序数据。归约端从每个映射器获取数据,在提供给归约器之前将其合并。
因此,你可以在 shuffle 的两边进行优化,包括编写合并器,这在第一个技巧中已经介绍过。
技巧 76 使用合并器
合并器是一个强大的机制,它聚合 map 阶段的输入数据以减少发送给 reducer 的数据量。这是一个 map 端的优化,其中你的代码会根据相同的输出键调用多个 map 输出值。
问题
你正在过滤和投影你的数据,但你的 shuffle 和 sort 仍然比你想要的要长。你如何进一步减少它们?
解决方案
定义一个合并器,并使用setCombinerClass方法为你的作业设置它。
讨论
合并器在 spill 和 merge 阶段将 map 输出数据写入磁盘时被调用,作为图 8.5 所示,它是 map 任务上下文中调用合并器的一部分。为了帮助将值分组以最大化合并器的有效性,在调用合并器函数之前,两个阶段都应使用排序步骤。
图 8.5. 在 map 任务上下文中如何调用合并器

调用setCombinerClass设置作业的合并器,类似于如何设置 map 和 reduce 类:
job.setCombinerClass(Combine.class);
你的合并器实现必须符合 reducer 规范。在这个技巧中,你将编写一个简单的合并器,其任务是删除重复的 map 输出记录。当你遍历 map 输出值时,你只会发出那些连续唯一的值:^([5])
⁵ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch8/CombineJob.java.

如果你有合并器,函数必须是分配性的。在图 8.5 中,你看到合并器将多次对相同的输入键进行调用,并且当它们被发送到合并器时,关于输出值的组织没有保证(除了它们与合并器键配对之外)。一个分配性函数是指无论输入如何组合,最终结果都是相同的。
摘要
合并器是 MapReduce 工具箱中的强大工具,因为它有助于减少映射器和 reducer 之间通过网络传输的数据量。二进制比较器是另一个可以改善你的 MapReduce 作业执行时间的工具,我们将在下一节中探讨它们。
技巧 77 使用二进制比较器进行闪电般的快速排序
当 MapReduce 进行排序或合并时,它使用RawComparator来比较 map 输出键。内置的Writable类(如Text和IntWritable)具有字节级别的实现,因为它们不需要将对象的字节形式反序列化为Object形式进行比较,所以它们运行得很快。
当你编写自己的Writable时,可能会倾向于实现WritableComparable接口,但这可能会导致 shuffle 和 sort 阶段变长,因为它需要从字节形式反序列化Object以进行比较。
问题
你有自定义的Writable实现,并且你想要减少作业的排序时间。
解决方案
编写一个字节级别的Comparator以确保在排序过程中的最佳比较。
讨论
在 MapReduce 中,有多个阶段在数据排序时会对输出键进行比较。为了便于键排序,所有 map 输出键都必须实现WritableComparable接口:
public interface WritableComparable<T>
extends Writable, Comparable<T> {
}
在你根据技术 64(在实现二次排序时)创建的PersonWritable中,你的实现如下:^([6])
⁶ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch6/sort/secondary/Person.java.
public class Person implements WritableComparable<Person> {
private String firstName;
private String lastName;
@Override
public int compareTo(Person other) {
int cmp = this.lastName.compareTo(other.lastName);
if (cmp != 0) {
return cmp;
}
return this.firstName.compareTo(other.firstName);
}
...
这个Comparator的问题在于,MapReduce 将你的中间 map 输出数据以字节形式存储,每次它需要排序你的数据时,都必须将其反序列化为Writable形式以执行比较。这种反序列化是昂贵的,因为它会重新创建你的对象以进行比较目的。
如果你查看 Hadoop 中的内置Writable,你会发现它们不仅扩展了WritableComparable接口,还提供了它们自己的自定义Comparator,该Comparator扩展了WritableComparator类。以下代码展示了WritableComparator类的一个子集:

要编写一个字节级别的Comparator,需要重写compare方法。让我们看看IntWritable类是如何实现这个方法的:

内置的Writable类都提供了WritableComparator实现,这意味着只要你的 MapReduce 作业输出键使用这些内置的Writable,你就不需要担心优化Comparator。但是,如果你有一个用作输出键的自定义Writable,理想情况下你应该提供一个WritableComparator。我们现在将重新审视你的Person类,看看你如何做到这一点。
在你的Person类中,你有两个字段:名字和姓氏。你的实现将它们存储为字符串,并使用DataOutput的writeUTF方法将它们写入:
private String firstName;
private String lastName;
@Override
public void write(DataOutput out) throws IOException {
out.writeUTF(lastName);
out.writeUTF(firstName);
}
首先你需要理解的是,根据之前的代码,你的Person对象在字节形式中的表示。writeUTF方法写入包含字符串长度的两个字节,然后是字符串的字节形式。图 8.6 展示了这些信息在字节形式中的布局。
图 8.6. Person 的字节布局

你想要自然排序的记录,包括姓氏和名字,但无法直接使用字节数组完成,因为字符串长度也编码在数组中。相反,Comparator 需要足够智能,能够跳过字符串长度。以下代码展示了如何做到这一点:^([7])
⁷ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch8/Person-BinaryComparator.java.

摘要
writeUtf 方法有限制,因为它只能支持包含少于 65,536 个字符的字符串。这可能在处理人名的情况下是可行的,但如果你需要处理更大的字符串,你应该考虑使用 Hadoop 的 Text 类,它可以支持更大的字符串。如果你查看 Text 类中的 Comparator 内部类,你会看到它的二进制字符串比较器的工作方式与这里讨论的类似。这种方法可以很容易地扩展到使用 Text 对象而不是 Java String 对象表示的名称。
性能调整的下一个问题是,如何防止数据偏斜对 MapReduce 作业的影响。
使用范围分区器来避免数据偏斜
当任务执行时间较长时,通常有一小部分 reducer 由于默认的哈希分区器的工作方式而处于长尾。如果这影响了你的作业,那么请查看处理哈希分区器产生的偏斜的技术 63。
技巧 78 调整洗牌内部
洗牌阶段涉及从洗牌服务中获取映射输出数据并在后台合并。排序阶段,作为另一个合并过程,会将文件合并成更少的文件。
问题
你想要确定一个作业是否因为洗牌和排序阶段而运行缓慢。
解决方案
使用作业历史元数据提取与洗牌和排序执行时间相关的统计信息。
讨论
我们将查看洗牌的三个区域,并为每个区域确定可以调整以提高性能的区域。
调整映射端
当映射器输出记录时,它们首先被存储在内存缓冲区中。当缓冲区增长到一定大小时,数据会被溢写到磁盘上的新文件中。这个过程会一直持续到映射器完成所有输出记录的输出。展示了这个过程。
图 8.7. 映射端洗牌

映射端洗牌昂贵的地方是与溢写和合并溢写文件相关的 I/O。合并是昂贵的,因为所有映射输出都需要从溢写文件中读取并重写到合并的溢写文件中。
一个理想的映射器能够将其所有输出都适应内存缓冲区,这意味着只需要一个溢出文件。这样做就消除了合并多个溢出文件的需求。并非所有作业都可行,但如果你的映射器过滤或投影输入数据,使得输入数据可以适应内存,那么调整mapreduce.task.io.sort.mb以足够大以存储映射输出是值得的。
检查表 8.2 中显示的作业计数器,以了解和调整作业的混洗特性。
表 8.2. 映射混洗计数器
| 计数器 | 描述 |
|---|---|
| 映射输出字节数 | 使用 MAP_OUTPUT_BYTES 计数器来确定是否可以增加mapreduce.task.io.sort.mb,以便它可以存储所有映射输出。 |
| 溢出记录数 映射输出记录数 | 理想情况下,这两个值将相同,这表明只发生了一次溢出。 |
| 读取的字节数 写入的字节数 | 将这两个计数器与 MAP_OUTPUT_BYTES 进行比较,以了解由于溢出和合并而发生的额外读取和写入。 |
调整减少侧
在减少侧,映射器为减少器提供的输出是从每个从节点上运行的辅助混洗服务流出的。映射输出被写入一个内存缓冲区,一旦缓冲区达到一定大小,就会合并并写入磁盘。在后台,这些溢出文件会持续合并成更少的合并文件。一旦收集器收集了所有输出,就会进行最后一轮合并,之后合并文件中的数据会流出到减少器。图 8.8 显示了此过程。
图 8.8. 减少侧混洗

与映射侧类似,调整减少大小混洗的目标是尝试将所有映射输出适应内存,以避免溢出到磁盘并合并溢出文件。默认情况下,即使所有记录都可以适应内存,记录也会始终溢出到磁盘,因此为了启用内存到内存的合并,绕过磁盘,将mapreduce.reduce.merge.memtomem.enabled设置为true。
表 8.3 中的作业计数器可用于了解和调整作业的混洗特性。
表 8.3. 映射混洗计数器
| 计数器 | 描述 |
|---|---|
| 溢出记录数 | 写入磁盘的记录数。如果你的目标是映射输出永远不接触磁盘,则此值应为 0。 |
| 读取的字节数 写入的字节数 | 这些计数器将给你一个关于有多少数据被溢出和合并到磁盘的概览。 |
混洗设置
表 8.4 显示了此技术涵盖的属性。
表 8.4. 可调整的混洗配置
| 名称 | 默认值 | 映射侧或减少侧? | 描述 |
|---|---|---|---|
| mapreduce.task.io.sort.mb | 100 (MB) | Map | 缓冲映射输出的总缓冲内存量(以兆字节为单位)。这应该是映射任务堆大小的约 70%。 |
| mapreduce.map.sort.spill.percent | 0.8 (80%) | Map | 序列化缓冲区的软限制。一旦达到,线程将开始在后台将内容溢出到磁盘。请注意,如果溢出已经开始,则超过此阈值时收集不会阻塞,因此溢出可能大于此阈值,当设置为小于 0.5 时。 |
| mapreduce.task.io.sort.factor | 10 | Map and reduce | 排序文件时一次合并的流数。这决定了打开的文件句柄的数量。对于拥有 1,000 个或更多节点的较大集群,可以将此值提高到 100。 |
| mapreduce.reduce.shuffle.parallelcopies | 5 | Reduce | 在复制(洗牌)阶段在 reducer 端运行的并行传输的默认数量。对于拥有 1,000 个或更多节点的较大集群,可以将此值提高到 20。 |
| mapreduce.reduce.shuffle.input.buffer.percent | 0.70 | Reduce | 在洗牌过程中存储映射输出的最大堆大小的百分比。 |
| mapreduce.reduce.shuffle.merge.percent | 0.66 | Reduce | 内存合并开始的阈值使用率,以存储内存映射输出的总内存分配的百分比表示,如由 mapreduce.reduce.shuffle.input.buffer.percent 定义。 |
| mapreduce.reduce.merge.memtomem.enabled | false | Reduce | 如果每个 reducer 的所有映射输出都可以存储在内存中,则将此属性设置为 true。 |
摘要
减少洗牌和排序时间的最简单方法是积极过滤和投影你的数据,使用 combiner,并压缩你的映射输出。这些方法减少了映射和 reducer 任务之间流动的数据量,并减轻了与洗牌和排序阶段相关的网络和 CPU/磁盘负担。
如果你已经做了所有这些,你可以查看本技术中概述的一些提示,以确定你的工作是否可以被调整,使得正在洗牌的数据尽可能少地触及磁盘。
8.2.4. Reducer 优化
与映射任务类似,reducer 任务也有它们自己独特的问题,这些问题可能会影响性能。在本节中,我们将探讨常见问题如何影响 reducer 任务的性能。
技巧 79:reducer 数量过少或过多
对于大多数情况,映射端的并行性是自动设置的,并且是输入文件和所使用的输入格式的函数。但在 reducer 端,你对作业的 reducer 数量有完全的控制权,如果这个数字太小或太大,你可能无法从你的集群中获得最大价值。
问题
你想要确定作业运行缓慢是否由于 reducer 的数量。
解决方案
可以使用 JobHistory UI 来检查你作业运行中的 reducer 数量。
讨论
使用 JobHistory UI 查看您作业的 reducer 数量以及每个 reducer 的输入记录数。您可能正在使用过少或过多的 reducer。使用过少的 reducer 意味着您没有充分利用集群的可用并行性;使用过多的 reducer 意味着如果资源不足以并行执行 reducer,调度器可能需要错开 reducer 的执行。
有一些情况下您无法避免使用少量 reducer 运行,例如当您正在写入外部资源(如数据库)时,您不想使其过载。
MapReduce 中另一个常见的反模式是在您希望作业输出具有总顺序而不是在 reducer 输出范围内排序时使用单个 reducer。这个反模式可以通过使用我们在技巧 65 中提到的TotalOrderPartitioner来避免。
处理数据倾斜
数据倾斜可以很容易地识别——表现为一小部分 reduce 任务完成时间显著长于其他任务。这通常是由于以下两个原因之一——较差的 hash 分区或在高 join-key 基数的情况下执行连接操作。第六章(Chapter 6)第 6.1.5 节提供了这两个问题的解决方案。
8.2.5. 通用调整技巧
在本节中,我们将探讨可能影响 map 和 reduce 任务的问题。
压缩
压缩是优化 Hadoop 的重要部分。通过压缩中间 map 输出和作业输出,您可以获得实质性的空间和时间节省。压缩在第四章(chapter 4)中有详细的介绍。
使用紧凑的数据格式
与压缩类似,使用像 Avro 和 Parquet 这样的空间高效文件格式可以更紧凑地表示您的数据,并且与将数据存储为文本相比,可以显著提高序列化和反序列化时间。第三章(chapter 3)的大部分内容都致力于处理这些文件格式。
还应注意的是,文本是一种特别低效的数据格式——它空间效率低下,解析计算成本高,并且在大规模解析数据时可能会花费令人惊讶的时间,尤其是如果涉及到正则表达式的话。
即使 MapReduce 工作的最终结果是非二进制文件格式,将中间数据以二进制形式存储也是良好的实践。例如,如果您有一个涉及一系列 MapReduce 作业的 MapReduce 管道,您应考虑使用 Avro 或 SequenceFiles 来存储您的单个作业输出。产生最终结果的最后一个作业可以使用适用于您用例的任何输出格式,但中间作业应使用二进制输出格式以加快 MapReduce 的读写部分。
技巧 80:使用堆栈转储发现未优化的用户代码
想象你正在运行一个作业,它比你预期的耗时更长。你通常可以通过进行几次堆栈转储并检查输出以查看堆栈是否在相同的位置执行来确定这是否是由于代码低效。这项技术将指导你进行正在运行的 MapReduce 作业的堆栈转储。
问题
你想要确定作业运行缓慢是否是由于代码中的低效。
解决方案
确定当前执行任务的宿主机和进程 ID,进行多次堆栈转储,并检查它们以缩小代码中的瓶颈。
讨论
如果你的代码中有什么特别低效的地方,那么通过从任务进程中获取一些堆栈转储,你很可能能够发现它。图 8.9 展示了如何识别任务详情以便你可以进行堆栈转储。
图 8.9. 确定 MapReduce 任务的容器 ID 和主机

现在你已经知道了容器的 ID 以及它正在执行的主机,你可以对任务进程进行堆栈转储,如图 8.10 所示。
图 8.10. 取堆栈转储和访问输出

摘要
理解你的代码在做什么耗时最好的方法是分析你的代码,或者更新你的代码以测量你在每个任务上花费的时间。但如果你想要大致了解是否存在问题而不必更改代码,使用堆栈转储是有用的。
堆栈转储是一种原始但通常有效的方法,用于发现 Java 进程在哪里花费时间,尤其是如果该进程是 CPU 密集型的。显然,转储不如使用分析器有效,分析器可以更准确地确定时间花费的位置,但堆栈转储的优势在于它们可以在任何运行的 Java 进程中执行。如果你要使用分析器,你需要重新执行进程并使用所需的配置文件 JVM 设置,这在 MapReduce 中是一个麻烦。
在进行堆栈转储时,在连续转储之间暂停一段时间是有用的。这允许你直观地确定代码执行堆栈是否在多个转储中大致相同。如果是这样,代码中很可能就是导致缓慢的原因。
如果你的代码在不同的堆栈转储中不在同一位置,这并不一定意味着没有低效。在这种情况下,最好的方法是分析你的代码或在代码中添加一些测量并重新运行作业以获得更准确的时间花费分解。
技巧 81 分析你的映射和减少任务
分析独立的 Java 应用程序是简单且得到了大量工具的支持。在 MapReduce 中,你在一个运行多个映射和减少任务的分布式环境中工作,因此不太清楚你将如何进行代码分析。
问题
你怀疑你的 map 和 reduce 代码中存在低效之处,你需要确定它们在哪里。
解决方案
将 HPROF 与多个 MapReduce 作业方法结合使用,例如setProfileEnabled,以分析你的任务。
讨论
Hadoop 内置了对 HPROF 分析器的支持,这是 Oracle 的 Java 分析器,集成在 JVM 中。要开始使用,你不需要了解任何 HPROF 设置——你可以调用JobConf.setProfileEnabled(true),Hadoop 将使用以下设置运行 HPROF:
-agentlib:hprof=cpu=samples,heap=sites,force=n,
thread=y,verbose=n,file=%s
这将生成对象分配堆栈大小太小,没有实际用途,因此你可以程序化地设置自定义的 HPROF 参数:

被分析的任务示例相当简单。它解析包含 IP 地址的文件,从地址中提取第一个八位字节,并将其作为输出值输出:^([8])
⁸ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch8/SlowJob.java.
public void map(LongWritable key, Text value,
OutputCollector<LongWritable, Text> output,
Reporter reporter) throws IOException {
String[] parts = value.toString().split("\\.");
Text outputValue = new Text(parts[0]);
output.collect(key, outputValue);
}
你可以上传一个包含大量 IP 地址的大文件,并使用之前的分析选项运行你的作业:
$ hadoop fs -put test-data/ch8/large-ips.txt .
$ hip hip.ch8.SlowJob \
--input large-ips.txt \
--output output
你通过setProfileParams方法调用指定的 HPROF 选项将创建一个易于解析的文本文件。该文件写入容器的日志目录,文件名为 profile.out。有两种方法可以访问此文件:要么通过 JobHistory UI,要么通过使用 shell 来ssh到运行任务的节点。前面的技术向您展示了如何确定任务的主机和日志目录。
profile.out 文件包含多个堆栈跟踪,底部包含内存和 CPU 时间的累积,以及导致累积的堆栈跟踪的引用。在您运行的示例中,查看前两项,它们占用了最多的 CPU 时间,并将它们与代码相关联:

确定的第一个问题是使用String.split方法,它使用正则表达式对字符串进行标记化。正则表达式在计算上很昂贵,尤其是在处理数百万条记录时,这在处理与 MapReduce 典型数据量相当的数据时是正常的。一个解决方案是将String.split方法替换为 Apache Commons Lang 库中的任何StringUtils.split方法,后者不使用正则表达式。
为了避免与Text类构造函数相关的开销,构造实例一次,并反复调用set方法,这要高效得多。
摘要
运行 HPROF 会给 Java 的执行增加显著的开销;它通过在代码执行时对 Java 类进行仪器化来收集分析信息。这不是你希望在生产环境中定期运行的事情。
根据 Todd Lipcon 的优秀演示中建议的,通过向mapred.child.java.opts添加-Xprof来简化任务的分析。
⁹ 托德·利普康,“优化 MapReduce 作业性能”,
www.slideshare.net/cloudera/mr-perf。
实际上,理想的方式来分析你的代码是将其映射或归约代码以独立的方式隔离,这样就可以使用你选择的剖析器在 Hadoop 之外执行。然后你可以专注于快速迭代剖析,而不用担心 Hadoop 会阻碍你的工作。
这总结了我们可以使用的一些方法来调整作业的性能,并使作业尽可能高效。接下来,我们将探讨各种可以帮助你调试应用程序的机制。
8.3. 调试
在本节中,我们将介绍一些有助于调试工作的主题。我们将从查看任务日志开始。
8.3.1. 访问容器日志输出
访问你的任务日志是确定你的作业中存在哪些问题的第一步。
技术编号 82 检查任务日志
在这个技术中,我们将探讨在遇到需要调试的问题作业时如何访问任务日志。
问题
你的作业失败或生成意外的输出,你想确定日志是否可以帮助你找出问题。
解决方案
学习如何使用作业历史或应用程序主 UI 查看任务日志。或者,你也可以 SSH 到单个从节点并直接访问日志。
讨论
当作业失败时,查看日志以了解它们是否提供了关于失败的信息是有用的。对于 MapReduce 应用程序,每个映射和归约任务都在自己的容器中运行,并有自己的日志,因此你需要识别失败的作业。最简单的方法是使用作业历史或应用程序主 UI,在任务视图中提供链接到任务日志。
你还可以使用技术 80 中概述的步骤直接访问执行任务的从节点上的日志。
如果未启用日志聚合,YARN 将在yarn.nodemanager.log.retain-seconds秒后自动删除日志文件,如果启用日志聚合,则将在yarn.nodemanager.delete.debug-delay-sec秒后删除。
如果容器启动失败,你需要检查执行该任务的 NodeManager 日志。为此,使用作业历史或应用程序主 UI 确定哪个节点执行了你的任务,然后导航到 NodeManager UI 以检查其日志。
通常,当你的作业开始出现问题时,任务日志将包含有关失败原因的详细信息。接下来,我们将看看如何获取启动映射或归约任务的命令,这在怀疑与环境相关的问题时非常有用。
8.3.2. 访问容器启动脚本
这是一种在怀疑容器环境或启动参数存在问题时的有用技巧。例如,有时 JAR 的类路径顺序很重要,其问题可能导致类加载问题。此外,如果容器依赖于原生库,可以使用 JVM 参数来调试 java.library.path 的问题。
技巧 83 确定容器启动命令
检查启动容器所使用的各种论证能力对于调试容器启动问题非常有帮助。例如,假设你正在尝试使用原生的 Hadoop 压缩编解码器,但你的 MapReduce 容器失败了,错误信息抱怨原生压缩库无法加载。在这种情况下,请检查 JVM 启动参数,以确定是否所有必需的设置都存在,以便原生压缩能够工作。
问题
当任务启动时,你怀疑容器因缺少参数而失败,并希望检查容器启动参数。
解决方案
将 yarn.nodemanager.delete.debug-delay-sec YARN 配置参数设置为停止 Hadoop 清理容器元数据,并使用此元数据来查看用于启动容器的 shell 脚本。
讨论
当 NodeManager 准备启动容器时,它会创建一个随后执行的 shell 脚本以运行容器。问题是 YARN 默认情况下在作业完成后会删除这些脚本。在执行长时间运行的应用程序期间,你可以访问这些脚本,但如果应用程序是短暂的(如果你正在调试导致容器立即失败的错误,这种情况可能很常见),你需要将 yarn.nodemanager.delete.debug-delay-sec 设置为 true。
图 8.11 展示了获取任务 shell 脚本所需的所有步骤。
图 8.11. 如何获取到 launch_container.sh 脚本

检查尝试启动容器的 NodeManager 的日志也是有用的,因为它们可能包含容器启动错误。如果存在,请再次检查容器日志。
摘要
这种技巧在你想检查用于启动容器的参数时很有用。如果日志中的数据表明你的作业问题出在输入上(这可以通过解析异常表现出来),你需要找出导致问题的输入类型。
8.3.3. 调试 OutOfMemory 错误
OutOfMemory (OOM) 错误在具有内存泄漏或试图在内存中存储过多数据的 Java 应用程序中很常见。这些内存错误可能很难追踪,因为当容器退出时,通常不会提供足够的信息。
技巧 84 强制容器 JVM 生成堆转储
在这个技巧中,你会看到一些有用的 JVM 参数,当发生 OOM 错误时,这些参数会导致 Java 将堆转储写入磁盘。
问题
容器因内存不足错误而失败。
解决方案
将容器 JVM 参数更新为包含-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=<path>,其中<path>是跨所有从节点的一个常见目录。
讨论
如果您正在运行 MapReduce 应用程序,您可以使用前述 JVM 参数更新mapred.child.java.opts。对于非 MapReduce 应用程序,您需要找出如何为出现 OOM 错误的容器附加这些 JVM 启动参数。
一旦运行前述 JVM 参数的容器失败,您可以使用 jmap 或您喜欢的分析工具加载生成的转储文件。
8.3.4. 有效的调试的 MapReduce 编码指南
如果您遵循一些日志记录和异常处理最佳实践,则可以在生产环境中使 MapReduce 代码的调试变得更加容易。
技巧 85:增强 MapReduce 代码以更好地调试
调试编写不良的 MapReduce 作业会消耗大量时间,并且在集群资源访问受限的生产环境中可能具有挑战性。
问题
您想知道在编写 MapReduce 代码时应遵循的最佳实践。
解决方案
看看计数器和日志如何被用来增强您有效地调试和处理问题作业的能力。
讨论
将以下功能添加到您的代码中:
-
包含捕获与输入和输出相关的数据的日志,以帮助隔离问题所在。
-
捕获异常并提供有意义的日志输出,以帮助追踪问题数据输入和逻辑错误。
-
考虑您是否想在代码中重新抛出或吞没异常。
-
使用计数器和任务状态,这些可以被驱动代码和人类 alike 利用,以更好地理解作业执行期间发生的情况。
在以下代码中,您将看到许多之前描述的原则被应用。
列表 8.1. 应用了一些最佳实践以协助调试的 mapper 作业


减少任务应该添加类似的调试日志语句,以输出每个减少输入键和值以及输出键和值。这样做将有助于识别映射和减少之间的任何问题,或者在您的减少代码中,或者在OutputFormat或RecordWriter中。
应该吞没异常吗?
在之前的代码示例中,您在代码中捕获了任何异常,并将异常写入日志,同时尽可能多地包含上下文信息(例如,当前 reducer 正在处理的关键值)。主要问题是您是否应该重新抛出异常或吞没它。
重新抛出异常很诱人,因为您将立即意识到 MapReduce 代码中的任何问题。但如果您的代码在生产环境中运行,并且每次遇到问题(例如一些未正确处理的数据输入)时都会失败,那么操作、开发和 QA 团队将花费大量时间解决每个问题。
编写会吞掉异常的代码有其自身的问题——例如,如果您在作业的所有输入上遇到异常怎么办?如果您编写代码来吞掉异常,正确的方法是增加一个计数器(如代码示例所示),驱动类应在作业完成后使用它来确保大多数输入记录在可接受的阈值内都得到了成功处理。如果没有,正在处理的流程可能需要终止,并发出适当的警报以通知操作。
另一种方法是不要吞掉异常,并通过调用setMapperMaxSkipRecords或setReducerMaxSkipGroups来配置记录跳过,这表示在处理时抛出异常时可以容忍丢失的记录数量。这在 Chuck Lam 的《Hadoop in Action》(Manning, 2010)中有更详细的介绍。
您使用计数器来统计遇到的坏记录数量,并且可以使用 ApplicationMaster 或 JobHistory UI 来查看计数器值,如图 8.12 所示链接。
图 8.12. JobHistory 计数器页面上的计数器截图

根据您如何执行作业,您将在标准输出上看到计数器。如果您查看任务的日志,您也会看到一些与任务相关的信息性数据:

由于您也在代码中更新了任务状态,因此可以使用 ApplicationMaster 或 JobHistory UI 轻松地识别出有失败记录的任务,如图 8.13 所示链接。
图 8.13. 显示映射任务和状态的 JobTracker UI

摘要
我们探讨了适用于您的 MapReduce 代码的一些简单而实用的编码指南。如果它们得到应用,并且您在生产作业中遇到问题,您将能够快速缩小问题的根本原因。如果问题是与输入相关,您的日志将包含有关输入如何导致您的处理逻辑失败的具体细节。如果问题是与某些逻辑错误或序列化/反序列化错误相关,您可以启用调试级别的日志记录,更好地了解事情出错的地方。
8.4. 测试 MapReduce 作业
在本节中,我们将探讨测试 MapReduce 代码的最佳方法,以及编写 MapReduce 作业时需要考虑的设计方面,以帮助您在测试工作中。
8.4.1. 有效的单元测试的基本要素
确保单元测试易于编写,并确保它们覆盖了良好的正负场景范围,这是非常重要的。让我们看看测试驱动开发、代码设计和数据对编写有效的单元测试的影响。
测试驱动开发
当涉及到编写 Java 代码时,我强烈支持测试驱动开发(TDD),10],并且对于 MapReduce 来说,情况也没有不同。测试驱动开发强调在编写代码之前编写单元测试,并且随着快速开发周转时间成为常态而非例外,它最近的重要性有所增加。将测试驱动开发应用于 MapReduce 代码至关重要,尤其是当此类代码是关键生产应用程序的一部分时。
(10) 关于测试驱动开发的解释,请参阅维基百科文章:
en.wikipedia.org/wiki/Test-driven_development。
在编写代码之前编写单元测试迫使你以易于测试的方式结构化代码。
代码设计
当你编写代码时,重要的是要考虑最佳的结构方式以便于测试。使用抽象和依赖注入等概念将有助于实现这一目标。11]
(11) 关于依赖注入的解释,请参阅维基百科文章:
en.wikipedia.org/wiki/Dependency_injection。
当你编写 MapReduce 代码时,抽象出执行代码是个好主意,这意味着你可以在常规单元测试中测试该代码,而无需考虑如何与 Hadoop 特定的结构一起工作。这不仅适用于你的映射和减少函数,也适用于你的输入格式、输出格式、数据序列化和分区器代码。
让我们通过一个简单的例子来更好地说明这一点。以下代码展示了一个计算股票平均值的 reducer:
public static class Reduce
extends Reducer<Text, DoubleWritable, Text, DoubleWritable> {
DoubleWritable outValue = new DoubleWritable();
public void reduce(Text stockSymbol, Iterable<DoubleWritable> values,
Context context)
throws IOException, InterruptedException {
double total = 0;
int instances = 0;
for (DoubleWritable stockPrice : values) {
total += stockPrice.get();
instances++;
}
outValue.set(total / (double) instances);
context.write(stockSymbol, outValue);
}
}
这是一个简单的例子,但代码的结构意味着你无法在常规单元测试中轻松测试它,因为 MapReduce 有诸如Text、DoubleWritable和Context类等结构会阻碍你。如果你将代码结构化以抽象出工作,你就可以轻松测试执行工作的代码,如下面的代码所示:
public static class Reduce2
extends Reducer<Text, DoubleWritable, Text, DoubleWritable> {
SMA sma = new SMA();
DoubleWritable outValue = new DoubleWritable();
public void reduce(Text key, Iterable<DoubleWritable> values,
Context context)
throws IOException, InterruptedException {
sma.reset();
for (DoubleWritable stockPrice : values) {
sma.add(stockPrice.get());
}
outValue.set(sma.calculate());
context.write(key, outValue);
}
}
public static class SMA {
protected double total = 0;
protected int instances = 0;
public void add(double value) {
total += value;
instances ++;
}
public double calculate() {
return total / (double) instances;
}
public void reset() {
total = 0;
instances = 0;
}
}
使用这种改进的代码布局,你现在可以轻松地测试添加和计算简单移动平均数的SMA类,而无需 Hadoop 代码干扰。
数据最重要
当你编写单元测试时,你试图发现你的代码如何处理正负输入数据。在两种情况下,使用从生产中抽取的代表性样本数据进行测试都是最好的。
通常,无论你多么努力,生产中的代码问题都可能源于意外的输入数据。当你确实发现导致任务崩溃的输入数据时,你不仅需要修复代码以处理意外数据,还需要提取导致崩溃的数据,并在单元测试中使用它来证明代码现在可以正确处理该数据。
8.4.2. MRUnit
MRUnit 是一个你可以用来对 MapReduce 代码进行单元测试的测试框架。它是由 Cloudera(一个拥有自己 Hadoop 分发的供应商)开发的,目前是一个 Apache 项目。需要注意的是,MRUnit 支持旧的 (org.apache.hadoop.mapred) 和新的 (org.apache.hadoop.mapreduce) MapReduce API。
技巧 86 使用 MRUnit 进行 MapReduce 单元测试
在这个技术中,我们将查看编写使用 MRUnit 提供的四种测试类型之一的单元测试:
-
MapDriver类——一个仅测试 map 函数的 map 测试 -
ReduceDriver类——一个仅测试 reduce 函数的 reduce 测试 -
MapReduceDriver类——一个测试 map 和 reduce 函数的 map 和 reduce 测试 -
TestPipelineMapReduceDriver类——一个允许一系列 Map-Reduce 函数被测试的管道测试
问题
你想测试 map 和 reduce 函数,以及 MapReduce 管道。
解决方案
使用 MRUnit 的 MapDriver、ReduceDriver、MapReduceDriver 和 PipelineMapReduceDriver 类作为您单元测试的一部分来测试您的 MapReduce 代码。
讨论
MRUnit 有四种类型的单元测试——我们将从查看 map 测试开始。
Map 测试
让我们从编写一个测试来测试 map 函数开始。在开始之前,让我们看看你需要向 MRUnit 提供什么来执行测试,并在过程中了解 MRUnit 在幕后是如何工作的。
图 8.14 展示了单元测试与 MRUnit 的交互以及它如何反过来与你要测试的 mapper 交互。
图 8.14. 使用 MapDriver 的 MRUnit 测试

以下代码是对 Hadoop 中(身份)mapper 类的简单单元测试:^(12)
^(12) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/test/java/hip/ch8/mrunit/IdentityMapTest.java。

MRUnit 不依赖于任何特定的单元测试框架,因此如果它发现错误,它会记录错误并抛出异常。让我们看看如果您的单元测试指定了与 mapper 输出不匹配的输出会发生什么,如下代码所示:
driver.withInput(new Text("foo"), new Text("bar"))
.withOutput(new Text("foo"), new Text("bar2"))
.runTest();
如果你运行这个测试,你的测试将失败,你将看到以下日志输出:
ERROR Received unexpected output (foo, bar)
ERROR Missing expected output (foo, bar2) at position 0
MRUnit 日志配置
由于 MRUnit 使用 Apache Commons logging,默认使用 log4j,因此你需要在类路径中有一个配置为写入标准输出的 log4j.properties 文件,类似于以下内容:
log4j.rootLogger=WARN, stdout
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=
%-5p [%t][%d{ISO8601}] [%C.%M] - %m%n
JUnit 和其他测试框架的一个强大功能是,当测试失败时,失败信息包括导致失败的原因的详细信息。不幸的是,MRUnit 记录并抛出一个非描述性的异常,这意味着你需要挖掘测试输出以确定什么失败了。
如果你想使用 MRUnit 的强大功能,同时使用 JUnit 在断言失败时提供的有信息性的错误,你可以修改你的代码来实现这一点,并绕过 MRUnit 的测试代码:

使用这种方法,如果期望输出和实际输出之间有差异,你会得到一个更有意义的错误消息,报告生成工具可以使用它来轻松描述测试中失败的内容:
junit.framework.AssertionFailedError: expected:<bar2> but was:<bar>
为了减少使用这种方法不可避免地进行的复制粘贴活动,我编写了一个简单的辅助类,结合使用 MRUnit 驱动程序和 JUnit 断言.^([13]) 你的 JUnit 测试现在看起来像这样:
(13) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/test/java/hip/ch8/mrunit/MRUnitJUnitAsserts.java

这样做更干净,并消除了可能由复制粘贴反模式引起的任何错误。
Reduce 测试
现在我们已经看了 map 函数的测试,让我们看看 reduce 函数的测试。MRUnit 框架在 reduce 测试中采用类似的方法。图 8.15 展示了你的单元测试与 MRUnit 的交互,以及它如何反过来与你要测试的 reducer 交互。
图 8.15. 使用 ReduceDriver 的 MRUnit 测试

以下代码是测试 Hadoop 中(身份)reducer 类的简单单元测试:^([14])
(14) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/test/java/hip/ch8/mrunit/IdentityReduceTest.java.

现在我们已经完成了对单个 map 和 reduce 函数测试的查看,让我们看看如何一起测试 map 和 reduce 函数。
MapReduce 测试
MRUnit 也支持在同一测试中测试 map 和 reduce 函数。你将输入提供给 MRUnit,这些输入随后被传递给 mapper。你还需要告诉 MRUnit 你期望的 reducer 输出。
图 8.16 展示了你的单元测试与 MRUnit 的交互,以及它如何反过来与你要测试的 mapper 和 reducer 交互。
图 8.16. 使用 MapReduceDriver 的 MRUnit 测试

以下代码是测试 Hadoop 中(身份)mapper 和 reducer 类的简单单元测试:^([15])
(15) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/test/java/hip/ch8/mrunit/IdentityMapReduceTest.java.

现在我们将看看 MRUnit 支持的第四种和最后一种测试类型,即管道测试,它用于测试多个 MapReduce 作业。
管道测试
MRUnit 支持测试一系列的 map 和 reduce 函数——这些被称为 管道测试。你向 MRUnit 提供一个或多个 MapReduce 函数、第一个 map 函数的输入以及最后一个 reduce 函数的预期输出。
图 8.17 展示了你的单元测试与 MRUnit 管道驱动程序之间的交互。
图 8.17. 使用 PipelineMapReduceDriver 的 MRUnit 测试

以下代码是一个单元测试,用于测试包含两组(身份)映射器和减少器类的 Hadoop 中的管道:^([16])
¹⁶ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/test/java/hip/ch8/mrunit/PipelineTest.java。

注意,PipelineMapReduceDriver 是 MRUnit 中唯一一个既不提供旧版也不提供新版 MapReduce API 的驱动程序,这就是为什么前面的代码使用了旧版 MapReduce API。
摘要
你应该使用哪种类型的测试来测试你的代码?请查看 表 8.5 以获取一些提示。
表 8.5. MRUnit 测试及其使用情况
| 测试类型 | 在这些情况下表现良好 |
|---|---|
| Map | 你有一个只包含 map 的作业,并且你想要低级别的单元测试,其中框架负责测试你的测试 map 输入的预期 map 输出。 |
| Reduce | 你的作业在 reduce 函数中有很多复杂性,你想要隔离测试只针对该函数。 |
| MapReduce | 你想要测试 map 和 reduce 函数的组合。这些是更高级别的单元测试。 |
| 管道 | 你有一个 MapReduce 管道,其中每个 MapReduce 作业的输入是前一个作业的输出。 |
MRUnit 有一些限制,其中一些我们在本技术中提到了:
-
MRUnit 没有与提供丰富错误报告功能的单元测试框架集成,这有助于更快地确定错误。
-
管道测试仅适用于旧版 MapReduce API,因此使用新版 MapReduce API 的 MapReduce 代码无法使用管道测试进行测试。
-
没有支持测试数据序列化,或
InputFormat、RecordReader、OutputFormat或RecordWriter类。
尽管有这些限制,MRUnit 仍然是一个优秀的测试框架,你可以用它来测试单个 map 和 reduce 函数的粒度级别;MRUnit 还可以测试 MapReduce 作业的管道。而且因为它跳过了 InputFormat 和 OutputFormat 步骤,所以你的单元测试将快速执行。
接下来,我们将探讨如何使用 LocalJobRunner 来测试 MRUnit 忽略的一些 MapReduce 构造。
8.4.3. LocalJobRunner
在上一节中,我们探讨了 MRUnit,这是一个优秀的轻量级单元测试库。但如果你不仅想测试你的 map 和 reduce 函数,还想测试 InputFormat、RecordReader、OutputFormat 和 RecordWriter 代码,以及 map 和 reduce 阶段之间的数据序列化,那会怎样?如果你已经编写了自己的输入和输出格式类,这一点尤为重要,因为你想要确保你也测试了那段代码。
Hadoop 随带提供了 LocalJobRunner 类,Hadoop 和相关项目(如 Pig 和 Avro)使用它来编写和测试他们的 MapReduce 代码。LocalJobRunner 允许你测试 MapReduce 作业的所有方面,包括数据在文件系统中的读写。
技巧 87:使用 LocalJobRunner 进行重量级作业测试
像 MRUnit 这样的工具对于低级单元测试很有用,但你如何确保你的代码能够与整个 Hadoop 堆栈良好地协同工作?
问题
你想在单元测试中测试整个 Hadoop 堆栈。
解决方案
使用 Hadoop 中的 LocalJobRunner 类来扩展测试范围,包括与作业输入和输出相关的代码。
讨论
使用 LocalJobRunner 使得你的单元测试开始感觉更像集成测试,因为你正在测试你的代码如何与整个 MapReduce 堆栈结合工作。这很好,因为你可以使用这个来测试不仅你的 MapReduce 代码,还可以测试输入和输出格式、分区器以及高级排序机制。
下一个列表中的代码展示了如何在你的单元测试中使用 LocalJobRunner 的示例:^(17)
^(17)
github.com/alexholmes/hiped2/blob/master/src/test/java/hip/ch8/localjobrunner/IdentityTest.java.
列表 8.2:使用 LocalJobRunner 测试 MapReduce 作业


编写这个测试更复杂,因为你需要处理将输入写入到文件系统以及读取它们。这对于每个测试来说都是一大堆样板代码,这可能是你想要提取到可重用辅助类中的东西。
这里是一个实现该功能的实用类示例;以下代码展示了如何将 IdentityTest 代码压缩成更易于管理的尺寸:^(18)
^(18) GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/test/java/hip/ch8/localjobrunner/IdentityWithBuilderTest.java.

摘要
使用 LocalJobRunner 时需要注意哪些限制?
-
LocalJobRunner只运行单个 reduce 任务,因此你不能用它来测试分区器。 -
正如你所见,这同样需要更多的劳动强度;你需要将输入和输出数据读取和写入到文件系统中。
-
作业也运行缓慢,因为 MapReduce 堆栈的大部分都在被测试。
-
使用这种方法来测试非基于文件的输入和输出格式是有些棘手的。
下一个部分将介绍测试你代码的最全面的方法。它使用一个内存中的集群,可以运行多个映射器和减少器。
8.4.4. MiniMRYarnCluster
到目前为止,所有的单元测试技术都对 MapReduce 作业可以测试的部分有所限制。例如,LocalJobRunner 只能运行单个映射和减少任务,因此你不能模拟运行多个任务的作业。在本节中,你将了解 Hadoop 中允许你在全堆栈 Hadoop 上测试作业的内置机制。
技巧 88 使用 MiniMRYarnCluster 测试你的作业
MiniMRYarnCluster 类包含在 Hadoop 测试代码中,并支持需要执行完整 Hadoop 堆栈的测试用例。这包括需要测试输入和输出格式类,包括输出提交者,这些类不能使用 MRUnit 或 LocalTestRunner 进行测试。在这个技术中,你将看到如何使用 MiniMRYarnCluster。
问题
你希望针对实际的 Hadoop 集群执行你的测试,这为你提供了额外的保证,即你的作业按预期工作。
解决方案
使用 MiniMRYarnCluster 和 MiniDFSCluster,它们允许你启动内存中的 YARN 和 HDFS 集群。
讨论
MiniMRYarnCluster 和 MiniDFSCluster 是包含在 Hadoop 测试代码中的类,并被 Hadoop 中的各种测试所使用。它们提供了进程内的 YARN 和 HDFS 集群,为你提供了一个最真实的环境来测试你的代码。这些类封装了完整的 YARN 和 HDFS 进程,因此你实际上在你的测试过程中运行了完整的 Hadoop 堆栈。映射和减少容器作为测试过程外部的进程启动。
有一个有用的包装类 ClusterMapReduceTestCase,它封装了这些类,并使得快速编写单元测试变得容易。以下代码展示了一个简单的测试用例,该测试用例测试了身份映射器和减少器:^([19])
¹⁹ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/test/java/hip/ch8/minimrcluster/IdentityMiniTest.java.

概述
使用微型集群的唯一缺点是运行测试的开销——每个扩展 ClusterMapReduceTestCase 的测试类都会导致集群的启动和关闭,并且每个测试都有相当多的时间开销,因为完整的 Hadoop 堆栈正在执行。
但使用这些微型集群将为你提供最大的保证,即你的代码将在生产环境中按预期工作,这对于你组织中的关键作业来说值得考虑。
因此,测试你的代码的最佳方式是使用 MRUnit 对使用内置 Hadoop 输入和输出类的简单作业进行测试,并且只在这个技术用于测试那些你想测试输入和输出类以及输出提交者的测试用例。
8.4.5. 集成和 QA 测试
使用 TDD 方法,你使用本节中的技术编写了一些单元测试。接下来,你编写了 MapReduce 代码,并使其达到单元测试通过的程度。太好了!但在你打开香槟之前,你仍然想要确保 MapReduce 代码在投入生产运行之前是正常工作的。你最不希望的事情就是代码在生产中失败,然后不得不在那里进行调试。
但是,你可能会问,为什么我的作业会失败,尽管所有的单元测试都通过了?这是一个好问题,可能是由多种因素造成的:
-
你用于单元测试的数据并不包含在生产中使用的数据的所有异常和变化。
-
体积或数据倾斜问题可能会在你的代码中产生副作用。
-
Hadoop 和其他库之间的差异导致了与构建环境不同的行为。
-
你的构建主机和生产环境之间的 Hadoop 和操作系统配置差异可能会导致问题。
由于这些因素,当你构建集成或 QA 测试环境时,确保 Hadoop 版本和配置与生产集群相匹配至关重要。不同版本的 Hadoop 会有不同的行为,同样,以不同方式配置的同一版本的 Hadoop 也会有不同的行为。当你正在测试测试环境中的更改时,你将希望确保平稳过渡到生产,所以尽可能确保版本和配置尽可能接近生产。
在你的 MapReduce 作业成功运行在集成和 QA 之后,你可以将它们推入生产,知道你的作业有更高的概率按预期工作。
这就结束了我们对测试 MapReduce 代码的探讨。我们探讨了某些 TDD 和设计原则,以帮助你编写和测试 Java 代码,我们还介绍了一些单元测试库,这些库使得对 MapReduce 代码进行单元测试变得更加容易。
8.5. 章节总结
在调整、调试和测试方面,本章只是触及了表面。我们为如何调整、分析、调试和测试你的 Map-Reduce 代码奠定了基础。
对于性能调整来说,重要的是你能够收集和可视化你的集群和作业的性能。在本章中,我们介绍了一些可能影响作业性能的更常见问题。
如果你正在生产环境中运行任何关键的 MapReduce 代码,那么至少要遵循本章测试部分中的步骤,我在那里向你展示了如何最佳地设计你的代码,使其容易在 Hadoop 范围之外进行基本的单元测试。我们还介绍了你的代码中与 MapReduce 相关的部分如何在轻量级(MRUnit)和更重量级(LocalTestRunner)的设置中进行测试。
在第四部分中,我们将超越 MapReduce 的世界,探讨各种允许你使用 SQL 与你的数据交互的系统。大多数 SQL 系统已经超越了 MapReduce,转而使用 YARN,因此我们上一章探讨了如何编写自己的 YARN 应用程序。
第四部分. 超越 MapReduce
这本书的这一部分致力于检查语言、工具和流程,使使用 Hadoop 的工作更加容易。
第九章深入探讨了 Hive,这是一种类似于 SQL 的特定领域语言,它是与 Hadoop 中的数据进行交互的最易访问的接口之一。Impala 和 Spark SQL 也被展示为 Hadoop 上的替代 SQL 处理系统;它们提供了一些引人注目的特性,如比 Hive 更高的性能以及将 SQL 与 Spark 混合使用的能力。
第十章,最后一章,展示了如何编写基本的 YARN 应用程序,并继续探讨对您的 YARN 应用程序至关重要的关键特性。
第九章. Hadoop 上的 SQL
本章涵盖
-
学习 Hive 的 Hadoop 特性,包括用户定义的函数和性能调优技巧
-
了解 Impala 以及如何编写用户定义的函数
-
在你的 Spark 代码中嵌入 SQL 以结合两种语言并发挥它们的优势
假设现在是上午九点钟,有人要求你生成一份关于上个月产生访问流量的前 10 个国家的报告。并且需要在中午之前完成。你的日志数据已经存放在 HDFS 中,准备使用。你会打破你的 IDE 并开始编写 Java MapReduce 代码吗?不太可能。这就是高级语言如 Hive、Impala 和 Spark 发挥作用的地方。凭借它们的 SQL 语法,Hive 和 Impala 允许你在编写 Java main 方法所需的时间内编写并开始执行查询。
Hive 的一个主要优势是它不再需要 MapReduce 来执行查询——从 Hive 0.13 版本开始,Hive 可以使用 Tez,这是一个通用的 DAG 执行框架,它不像 MapReduce 那样在连续步骤之间强加 HDFS 和磁盘障碍。Impala 和 Spark 也从头开始构建,不使用 MapReduce 作为后台。
这些工具是快速开始使用 Hadoop 数据的最容易的方式。Hive 和 Impala 实质上是 Hadoop 数据仓库工具,在某些组织(如 Facebook)中已经取代了传统的基于 RDBMS 的数据仓库工具。它们的大部分流行归功于它们提供了一个 SQL 接口,并且因此对那些过去接触过 SQL 的人来说是可访问的。
我们将在这章的大部分内容中关注 Hive,因为它目前是使用最广泛的 SQL-on-Hadoop 工具。我还会介绍 Impala 作为 Hadoop 上的 MPP 数据库以及一些 Impala 独有的特性。最后,我们将涵盖 Spark SQL,它允许你在 Spark 代码中内联使用 SQL,这可能会为程序员、分析师和数据科学家创造一个全新的范式。
我们将从 Hive 开始,它一直是 SQL-on-Hadoop 的主要支柱。
9.1. Hive
Hive 最初是 Facebook 的一个内部项目,最终发展成为完整的 Apache 项目。它是为了简化对 MapReduce 的访问而创建的,通过公开一个基于 SQL 的数据操作语言。Hive 架构可以在 图 9.1 中看到。
图 9.1. Hive 高级架构

在本章中,我们将探讨如何使用 Hive 处理 Apache 网络服务器日志的实用示例。我们将探讨您可以在 Hive 中加载和安排数据的不同方式,以优化您访问这些数据的方式。我们还将探讨一些高级连接机制和其他关系操作,如分组和排序。我们将从对 Hive 的简要介绍开始。
学习更多关于 Hive 基础知识
要全面理解 Hive 基础知识,请参阅 Chuck Lam 的 Hadoop in Action (Manning, 2010)。在本节中,我们只需简要浏览一些 Hive 基础知识。
9.1.1. Hive 基础知识
让我们快速了解一下 Hive 的基础知识,包括其执行框架的最新发展。
安装 Hive
附录包含 Hive 的安装说明。本书中的所有示例都是在 Hive 0.13 上执行的,并且可能一些较旧的 Hive 版本不支持本书中我们将使用的一些功能。
Hive 元数据仓库
Hive 在一个元数据仓库中维护关于 Hive 的元数据,该仓库存储在关系型数据库中。这些元数据包含有关存在哪些表、它们的列、用户权限等信息。
默认情况下,Hive 使用 Derby,一个嵌入式的 Java 关系型数据库,来存储元数据仓库。因为它是嵌入式的,所以 Derby 不能在用户之间共享,因此它不能用于需要共享元数据仓库的多用户环境。
数据库、表、分区和存储
Hive 可以支持多个数据库,这可以用来避免表名冲突(两个具有相同表名的团队或用户)并允许为不同的用户或产品提供单独的数据库。
Hive 表是一个逻辑概念,在物理上由 HDFS 中的多个文件组成。表可以是内部的,其中 Hive 在仓库目录(由 hive.metastore.warehouse.dir 属性控制,默认值为 /user/hive/warehouse [在 HDFS 中])内组织它们,或者它们可以是外部的,在这种情况下 Hive 不管理它们。如果想要 Hive 管理数据的完整生命周期,包括删除,内部表很有用,而外部表在文件在 Hive 之外使用时很有用。
表可以是分区的,这是一种将数据物理安排到每个唯一分区键的独立子目录中的数据排列。分区可以是静态的或动态的,我们将在技术 92 中探讨这两种情况。
Hive 的数据模型
Hive 支持以下数据类型:
-
有符号整数 —
BIGINT(8 字节),INT(4 字节),SMALLINT(2 字节), 和TINYINT(1 字节) -
浮点数 —
FLOAT(单精度) 和DOUBLE(双精度) -
布尔值 —
TRUE或FALSE -
字符串 —指定字符集中的字符序列
-
映射 —包含键/值对集合的关联数组,其中键是唯一的
-
数组 —可索引的列表,其中所有元素必须是同一类型
-
结构体 —包含元素的复杂类型
Hive 的查询语言
Hive 的查询语言支持 SQL 规范的大部分内容,以及 Hive 特定的扩展,其中一些在本节中介绍。Hive 支持的所有语句的完整列表可以在 Hive 语言手册中查看:cwiki.apache.org/confluence/display/Hive/LanguageManual。
Tez
在 Hadoop 1 中,Hive 被限制使用 MapReduce 来执行大多数语句,因为 MapReduce 是 Hadoop 上唯一支持的处理器。这并不理想,因为从其他 SQL 系统来到 Hive 的用户已经习惯了高度交互的环境,其中查询通常在几秒钟内完成。MapReduce 是为高吞吐量批处理而设计的,因此其启动开销加上其有限的处理能力导致了非常高的查询延迟。
在 Hive 0.13 版本发布后,Hive 现在使用 YARN 上的 Tez 来执行其查询,因此它能够更接近交互式理想的工作方式。^([1)] Tez 基本上是一个通用的有向无环图(DAG)执行引擎,它不对你如何组合执行图施加任何限制(与 MapReduce 相反),并且还允许你在阶段之间保持数据在内存中,从而减少了 MapReduce 所需的磁盘和网络 I/O。你可以在以下链接中了解更多关于 Tez 的信息:
¹ Carter Shanklin,“Benchmarking Apache Hive 13 for Enterprise Hadoop”,
hortonworks.com/blog/benchmarking-apache-hive-13-enterprise-hadoop/。
-
Hive on Tez:
cwiki.apache.org/confluence/display/Hive/Hive+on+Tez -
Tez 孵化 Apache 项目页面:
incubator.apache.org/projects/tez.html
在 Hive 0.13 版本中,Tez 默认未启用,因此你需要遵循以下说明来启动它:
-
Tez 安装说明:
github.com/apache/incubator-tez/blob/branch-0.2.0/INSTALL.txt -
配置 Hive 以在 Tez 上工作:
issues.apache.org/jira/browse/HIVE-6098
交互式和非交互式 Hive
Hive shell 提供交互式界面:
$ hive
hive> SHOW DATABASES;
OK
default
Time taken: 0.162 seconds
Hive 在非交互模式下允许你执行包含 Hive 命令的脚本。以下示例使用了-S选项,以便只将 Hive 命令的输出写入控制台:
$ cat hive-script.ql
SHOW DATABASES;
$ hive -S -f hive-script.ql
default
另一个非交互式特性是 -e 选项,它允许你将 Hive 命令作为参数提供:
$ hive -S -e "SHOW DATABASES"
default
如果你正在调试 Hive 中的某个问题,并且希望在控制台上看到更详细的输出,你可以使用以下命令来运行 Hive:
$ hive -hiveconf hive.root.logger=INFO,console
这就结束了我们对 Hive 的简要介绍。接下来,我们将探讨如何使用 Hive 从你的日志文件中挖掘有趣的数据。
9.1.2. 读取和写入数据
本节涵盖了 Hive 中一些基本的数据输入和输出机制。我们将从一个简要的文本数据处理工作开始,然后跳转到如何处理 Avro 和 Parquet 数据,这些正在成为 Hadoop 中存储数据的一种常见方式。
本节还涵盖了额外的数据输入和输出场景,例如向表中写入和追加数据以及将数据导出到本地文件系统。一旦我们覆盖了这些基本功能,后续章节将涵盖更高级的主题,例如编写 UDF 和性能调优技巧。
技巧 89 处理文本文件
假设你有一系列 CSV 或 Apache 日志文件,你希望使用 Hive 加载和分析这些文件。在将它们复制到 HDFS(如果它们尚未在那里)之后,你需要在发出查询之前创建一个 Hive 表。如果你的工作结果也很大,你可能希望将其写入一个新的 Hive 表。本节涵盖了 Hive 中的这些文本 I/O 用例。
问题
你希望使用 Hive 加载和分析文本文件,然后保存结果。
解决方案
使用 Hive 中 contrib 库捆绑的 RegexSerDe 类,并定义一个可以用来解析 Apache 日志文件内容的正则表达式。这项技术还探讨了 Hive 中的序列化和反序列化工作方式,以及如何编写自己的 SerDe 来处理日志文件。
讨论
如果你发出一个没有任何行/存储格式选项的 CREATE TABLE 命令,Hive 假设数据是基于文本的,使用默认的行和字段分隔符,如 表 9.1 所示。
表 9.1. 默认文本文件分隔符
| 默认分隔符 | 修改默认分隔符的语法示例 | 描述 |
|---|---|---|
| \n | LINES TERMINATED BY '\n' | 记录分隔符。 |
| ^A | FIELDS TERMINATED BY '\t' | 字段分隔符。如果你想用另一个不可读字符替换 ^A,你可以用八进制表示,例如 '\001'。 |
| ^B | COLLECTION ITEMS TERMINATED BY ';' | ARRAY、STRUCT 和 MAP 数据类型的元素分隔符。 |
| ^C | MAP KEYS TERMINATED BY ':' | 在 MAP 数据类型中用作键/值分隔符。 |
因为大多数你将处理的数据文本将以更标准的方式结构化,例如 CSV,让我们看看如何处理 CSV。
首先,你需要将书中代码包含的股票 CSV 文件复制到 HDFS。在 HDFS 中创建一个目录,然后将股票文件复制到该目录中:^([2])
² Hive 不允许你在文件上创建表;它必须是一个目录。
$ hadoop fs -mkdir hive-stocks
$ hadoop fs -put test-data/stocks.txt hive-stocks
现在您可以在股票目录上创建一个外部 Hive 表:
hive> CREATE EXTERNAL TABLE stocks (
symbol STRING,
date STRING,
open FLOAT,
high FLOAT,
low FLOAT,
close FLOAT,
volume INT,
adj_close FLOAT
)
ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
LOCATION '/user/YOUR-USERNAME/hive-stocks';
使用 LOCATION 关键字创建托管表
当您创建一个外部(非托管)表时,Hive 会保留由LOCATION关键字指定的目录中的数据不变。但是,如果您执行相同的CREATE命令并删除EXTERNAL关键字,则该表将是一个托管表,Hive 会将LOCATION目录的内容移动到/user/hive/warehouse/stocks,这可能不是您期望的行为。
运行一个快速查询以验证一切看起来是否良好:
hive> SELECT symbol, count(*) FROM stocks GROUP BY symbol;
AAPL 10
CSCO 10
GOOG 5
MSFT 10
YHOO 10
太棒了!如果您想将结果保存到新表中并显示新表的架构怎么办?
hive> CREATE TABLE symbol_counts
ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
LOCATION '/user/YOUR-USERNAME/symbol_counts'
AS SELECT symbol, count(*) FROM stocks GROUP BY symbol;
hive> describe symbol_counts;
symbol string
创建-表-选择(CTAS)和外部表
前面示例中的 CAS 语句不允许您指定表是EXTERNAL。但是,因为您要从中选择的表已经是一个外部表,所以 Hive 确保新表也是一个外部表。
如果目标表已经存在,您有两个选择——您可以选择覆盖表的全部内容,或者您可以追加到表中:

您可以使用 Hadoop CLI 查看原始表数据:
$ hdfs -cat symbol_counts/*
AAPL,10
CSCO,10
GOOG,5
MSFT,10
YHOO,10
Hive 外部表的好处是您可以使用任何方法写入它们(不一定要通过 Hive 命令),Hive 将在您下次发出任何 Hive 语句时自动获取额外的数据。
使用正则表达式标记文件
让我们使事情更加复杂,并假设您想处理日志数据。这些数据以文本形式存在,但无法使用 Hive 的默认反序列化进行解析。相反,您需要一种方式来指定一个正则表达式以解析您的日志数据。Hive 附带了一个 contrib RegexSerDe类,可以标记您的日志。
首先,将一些日志数据复制到 HDFS 中:
$ hadoop fs -mkdir log-data
$ hadoop fs -put test-data/ch9/hive-log.txt log-data/
接下来,指定您想使用自定义反序列化器。RegexSerDe包含在 Hive contrib JAR 中,因此您需要将此 JAR 添加到 Hive 中:

一个快速测试将告诉您 SerDe 是否正确处理数据:
hive> SELECT host, request FROM logs LIMIT 10;
89.151.85.133 "GET /movie/127Hours HTTP/1.1"
212.76.137.2 "GET /movie/BlackSwan HTTP/1.1"
74.125.113.104 "GET /movie/TheFighter HTTP/1.1"
212.76.137.2 "GET /movie/Inception HTTP/1.1"
127.0.0.1 "GET /movie/TrueGrit HTTP/1.1"
10.0.12.1 "GET /movie/WintersBone HTTP/1.1"
如果您在输出中只看到NULL值,那可能是因为您的正则表达式中缺少一个空格。请确保CREATE语句中的正则表达式看起来像图 9.2。
图 9.2. CREATE table regex showing spaces

Hive 的 SerDe 是一个灵活的机制,可以用来扩展 Hive 以支持任何文件格式,只要存在一个可以处理该文件格式的InputFormat。有关 SerDes 的更多详细信息,请参阅 Hive 文档,网址为cwiki.apache.org/confluence/display/Hive/SerDe。
处理 Avro 和 Parquet
Avro 是一个简化数据处理的对象模型,Parquet 是一种列式存储格式,可以高效地支持诸如谓词下推等高级查询优化。结合使用,它们是一对很有吸引力的组合,可能会成为数据在 Hadoop 中存储的规范方式。我们在第三章中深入探讨了 Avro 和 Parquet,其中技术 23 展示了如何在 Hive 中使用 Avro 和 Parquet。
技术篇 90:将数据导出到本地磁盘
当你有准备拉入电子表格或其他分析软件的数据时,从 Hive 和 Hadoop 中提取数据是一个重要的功能,你需要能够执行。这个技术探讨了你可以使用的一些方法来拉取你的 Hive 数据。
问题
你有数据存储在 Hive 中,你想要将其拉取到本地文件系统。
解决方案
使用标准的 Hadoop CLI 工具或 Hive 命令来拉取你的数据。
讨论
如果你想要将整个 Hive 表拉取到本地文件系统,并且 Hive 为你表使用的数据格式与你想导出数据使用的格式相同,你可以使用 Hadoop CLI 并运行一个hadoop -get /user/hive/warehouse/...命令来拉取该表。
Hive 自带EXPORT(以及相应的IMPORT)命令,可以将 Hive 数据和元数据导出到 HDFS 中的一个目录。这对于在 Hadoop 集群之间复制 Hive 表很有用,但它并不能帮助你将数据导出到本地文件系统。
如果你想要过滤、投影并对你的数据进行一些聚合,然后将数据从 Hive 中拉取出来,你可以使用INSERT命令并指定结果应该写入本地目录:
hive> INSERT OVERWRITE LOCAL DIRECTORY 'local-stocks' SELECT * FROM stocks;
这将在你的本地文件系统中创建一个包含一个或多个文件的目录。如果你使用 vi 等编辑器查看这些文件,你会注意到 Hive 在写入文件时使用了默认的字段分隔符(^A)。并且如果你导出的任何列是复杂类型(例如STRUCT或 MAP),那么 Hive 将使用 JSON 来编码这些列。
幸运的是,Hive 的新版本(包括 0.13)允许你在导出表时指定自定义分隔符:
hive> INSERT OVERWRITE LOCAL DIRECTORY 'local-stocks'
ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
SELECT * FROM stocks;
在 Hive 的读写基础问题解决之后,让我们看看更复杂的话题,例如用户定义函数。
9.1.3. Hive 中的用户定义函数
我们已经了解了 Hive 如何读取和写入表,现在是时候开始对你的数据进行一些有用的操作了。由于我们想要覆盖更高级的技术,我们将看看如何编写一个自定义的 Hive 用户定义函数(UDF)来地理定位你的日志。如果你想在 Hive 查询中混合自定义代码,UDF 非常有用。
技术篇 91:编写 UDF
这个技术展示了你如何编写一个 Hive UDF,然后将其用于你的 Hive 查询语言(HiveQL)。
问题
你如何在 Hive 中编写自定义函数?
解决方案
扩展UDF类以实现你的用户定义函数,并在你的 HiveQL 中调用它作为函数。
讨论
您可以使用 MaxMind 提供的免费地理位置数据库从日志表中定位 IP 地址。
下载免费的国籍地理位置数据库,^([3]) 使用 gunzip 解压,并将 GeoIP.dat 文件复制到您的 /tmp/ 目录。接下来,使用 UDF 从您在技术 89 中创建的日志表中定位 IP 地址:
³ 请参阅 MaxMind 的“GeoIP 国家数据库安装说明”,
dev.maxmind.com/geoip/legacy/install/country/.

当编写一个用户定义函数(UDF)时,有两种实现选项:要么扩展 UDF 类,要么实现 GenericUDF 类。它们之间的主要区别在于 GenericUDF 类可以处理复杂类型的参数,因此扩展 GenericUDF 的 UDF 更有效率,因为 UDF 类需要 Hive 使用反射来发现和调用。图 9.3(#ch09fig03)显示了两个 Hive UDF 类,您需要扩展其中一个来实现您的 UDF。
图 9.3. Hive UDF 类 图

以下列表显示了地理位置 UDF,您将使用 GenericUDF 类来实现它.^([4]).
⁴ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch9/hive/Geoloc.java.
列表 9.1. 地理位置 UDF


使用 describe function 命令可以在 Hive shell 中查看 Description 注解:
hive> describe function country;
OK
country(ip, geolocfile) - Returns the geolocated country code
for the IP
摘要
尽管我们查看的 UDF 操作的是标量数据,但 Hive 还有一种称为用户定义的聚合函数(UDAF),它允许对聚合数据进行更复杂的处理。您可以在 Hive wiki 的“Hive 操作符和用户定义函数(UDFs)”页面(cwiki.apache.org/confluence/display/Hive/LanguageManual+UDF)上了解更多关于编写 UDAF 的信息。
Hive 还具有用户定义的表函数(UDTFs),它们操作标量数据,但可以为每个输入生成多个输出。有关更多详细信息,请参阅 GenericUDTF 类。
接下来,我们将探讨您可以在 Hive 中优化工作流程的方法。
9.1.4. Hive 性能
在本节中,我们将探讨一些您可以使用的方法来优化 Hive 中的数据管理和处理。这里提供的提示将帮助您确保在扩展数据时,Hive 的其他部分能够满足您的需求。
技术编号 92:分区
分区是 SQL 系统常用的一种技术,用于水平或垂直分割数据以加快数据访问速度。由于分区中的数据总体量减少,分区读取操作需要筛选的数据更少,因此可以执行得更快。
这个原则同样适用于 Hive,并且随着数据量的增长变得越来越重要。在本节中,你将探索 Hive 中的两种分区类型:静态分区和动态分区。
问题
你希望安排你的 Hive 文件,以优化对数据的查询。
解决方案
使用 PARTITIONED BY 来按你在查询数据时通常使用的列进行分区。
讨论
假设你正在处理日志数据。对日志进行分区的一种自然方式是按日期进行,这样你就可以在不进行全表扫描(读取表的所有内容)的情况下执行特定时间段的查询。Hive 支持分区表,并允许你控制确定哪些列是分区的。
Hive 支持两种类型的分区:静态分区和动态分区。它们在构造 INSERT 语句的方式上有所不同,你将在本技术中了解到这一点。
静态分区
为了本技术的目的,你将使用一个非常简单的日志结构。字段包括 IP 地址、年份、月份、日期和 HTTP 状态码:
$ cat test-data/ch9/logs-partition.txt
127.0.0.1,2014,06,21,500
127.0.0.1,2014,06,21,400
127.0.0.1,2014,06,21,300
127.0.0.1,2014,06,22,200
127.0.0.1,2014,06,22,210
127.0.0.1,2014,06,23,100
将它们加载到 HDFS 和外部表中:
$ hadoop fs -mkdir logspartext
$ hadoop fs -put test-data/ch9/logs-partition.txt logspartext/
hive> CREATE EXTERNAL TABLE logs_ext (
ip STRING,
year INT,
month INT,
day INT,
status INT
)
ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
LOCATION '/user/YOUR-USERNAME/logspartext';
现在你可以创建一个分区表,其中年份、月份和日期是分区:
CREATE EXTERNAL TABLE IF NOT EXISTS logs_static (
ip STRING,
status INT)
PARTITIONED BY (year INT, month INT, day INT)
ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t'
LOCATION '/user/YOUR-USERNAME/logs_static';
默认情况下,Hive 的插入遵循静态分区方法,要求所有插入操作不仅要明确枚举分区,还要枚举每个分区的列值。因此,单个 INSERT 语句只能插入到一天的分区中:
INSERT INTO TABLE logs_static
PARTITION (year = '2014', month = '06', day = '21')
SELECT ip, status FROM logs_ext WHERE year=2014 AND month=6 AND day=21;
幸运的是,Hive 有一种特殊的数据操作语言(DML)语句,允许你在一个语句中插入到多个分区。以下代码将所有样本数据(跨越三天)插入到三个分区中:
FROM logs_ext se
INSERT INTO TABLE logs_static
PARTITION (year = '2014', month = '6', day = '21')
SELECT ip, status WHERE year=2014 AND month=6 AND day=21
INSERT INTO TABLE logs_static
PARTITION (year = '2014', month = '6', day = '22')
SELECT ip, status WHERE year=2014 AND month=6 AND day=22
INSERT INTO TABLE logs_static
PARTITION (year = '2014', month = '6', day = '23')
SELECT ip, status WHERE year=2014 AND month=6 AND day=23;
这种方法还有一个额外的优点,那就是它只需对 logs_ext 表进行一次遍历即可执行插入操作——之前的方法需要针对源表进行 N 次查询,以处理 N 个分区。
单次遍历静态分区插入的灵活性
Hive 不限制目标表或查询条件是否需要与分区对齐。因此,你无法阻止你将数据插入到不同的表中,并在多个分区或表中出现重叠的行。
静态分区的一个缺点是,当你插入数据时,你必须明确指定要插入的分区。但 Hive 支持的分区类型不仅仅是静态分区。Hive 有动态分区的概念,这使得在插入数据时不需要指定分区,从而让生活变得更容易。
动态分区
与静态分区相比,动态分区更智能,因为它们可以在插入数据时自动确定记录需要写入哪个分区。
让我们创建一个全新的表来存储一些动态分区。注意创建使用动态分区的表的语法与静态分区表的语法完全相同:
CREATE EXTERNAL TABLE IF NOT EXISTS logs_dyn (
ip STRING,
status INT)
PARTITIONED BY (year INT, month INT, day INT)
ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t'
LOCATION '/user/YOUR-USERNAME/logs_dyn';
差异仅在INSERT时才会显现:

这好多了——你不再需要明确告诉 Hive 你要插入哪些分区。它将动态地解决这个问题。
在同一表中混合动态和静态分区
Hive 支持在表中混合静态和动态列。也没有什么阻止你从静态分区插入方法过渡到动态分区插入。
分区目录布局
分区表在 HDFS 中的布局与非分区表不同。每个分区值在 Hive 中占用一个单独的目录,包含分区列名称及其值。
这些是运行最近的INSERT后 HDFS 的内容:
logs_static/year=2014/month=6/day=21/000000_0
logs_static/year=2014/month=6/day=22/000000_0
logs_static/year=2014/month=6/day=23/000000_0
“000000_0”是包含行的文件。由于数据集较小(使用包含多个任务的大型数据集运行将导致多个文件),每个分区日只有一个。
自定义分区目录名称
正如你所看到的,Hive 会根据column=value格式创建分区目录名称。如果你想对目录有更多的控制,而不是你的分区目录看起来像这样,
logs_static/year=2014/month=6/day=27
你想让它看起来像这样:
logs_static/2014/6/27
你可以通过给 Hive 提供应用于存储分区的完整路径来实现这一点:
ALTER TABLE logs_static
ADD PARTITION(year=2014, month=6, day=27)
LOCATION '/user/YOUR-USERNAME/logs_static/2014/6/27';
你可以使用DESCRIBE命令查询单个分区的位置:
hive> DESCRIBE EXTENDED logs_static
PARTITION (year=2014, month=6, day=28);
...
location:hdfs://localhost:8020/user/YOUR-USERNAME/logs_static/2014/6/27
...
这可以是一个强大的工具,因为 Hive 不需要表的所有分区都在同一个集群或文件系统类型。因此,一个 Hive 表可以有一个分区位于 Hadoop 集群 A 中,另一个位于集群 B 中,第三个位于 Amazon S3 的集群中。这为将数据老化到其他文件系统提供了一些强大的策略。
从 Hive 查询分区
Hive 提供了一些命令,允许你查看表的当前分区:
hive> SHOW PARTITIONS logs_dyn;
year=2014/month=6/day=21
year=2014/month=6/day=22
year=2014/month=6/day=23
跳过 Hive 将数据加载到分区
假设你有一些新分区(2014/6/24)的数据,你想要使用 HDFS 命令(或某些其他机制,如 MapReduce)手动将其复制到你的分区 Hive 表中。
这里有一些示例数据(请注意,日期部分已被删除,因为 Hive 仅在目录名称中保留这些列的详细信息):
$ cat test-data/ch9/logs-partition-supplemental.txt
127.0.0.1 500
127.0.0.1 600
创建一个新的分区目录并将文件复制到其中:
$ hdfs -mkdir logs_dyn/year=2014/month=6/day=24
$ hdfs -put test-data/ch9/logs-partition-supplemental.txt \
logs_dyn/year=2014/month=6/day=24
现在转到你的 Hive shell,并尝试选择新数据:
hive> SELECT * FROM logs_dyn
WHERE year = 2014 AND month = 6 AND day = 24;
没有结果!这是因为 Hive 还不知道新的分区。你可以运行一个修复命令,让 Hive 检查 HDFS 以确定当前分区:
hive> msck repair table logs_dyn;
Partitions not in metastore: logs_dyn:year=2014/month=6/day=24
Repair: Added partition to metastore logs_dyn:year=2014/month=6/day=24
现在,你的SELECT将可以工作:
hive> SELECT * FROM logs_dyn
WHERE year = 2014 AND month = 6 AND day = 24;
127.0.0.1 500 2014 6 24
127.0.0.1 600 2014 6 24
或者,你可以明确通知 Hive 关于新的分区:
ALTER TABLE logs_dyn
ADD PARTITION (year=2014, month=6, day=24);
摘要
考虑到动态分区的灵活性,在什么情况下静态分区会提供优势?一个例子是,当您要插入的数据对分区列没有任何了解,而其他某个过程有了解时。
例如,假设您有一些日志数据要插入,但出于某种原因,日志数据不包含日期。在这种情况下,您可以按照以下方式创建一个静态分区插入:
$ hive -hiveconf year=2014 -hiveconf month=6 -hiveconf day=28
hive> INSERT INTO TABLE logs_static
PARTITION (year=${hiveconf:year},
month=${hiveconf:month},
day=${hiveconf:day})
SELECT ip, status FROM logs_ext;
接下来,让我们看看列式数据,这是另一种可以提供显著查询执行时间改进的数据分区形式。
列式数据
我们习惯处理的大多数数据都是以行顺序存储在磁盘上的,这意味着当数据在持久存储上静止存储时,一行中的所有列都是连续存放的。CSV、SequenceFiles 和 Avro 通常以行存储。
使用列导向的存储格式来保存您的数据可以在空间和执行时间方面提供巨大的性能优势。将列式数据连续存放在一起允许存储格式使用复杂的数据压缩方案,如运行长度编码,这些方案不能应用于行导向数据。此外,列式数据允许执行引擎如 Hive、Map-Reduce 和 Tez 将谓词和投影推送到存储格式,允许这些存储格式跳过不匹配推送标准的数据。
目前在 Hive(和 Hadoop)上对列式存储有两个热门选择:优化行列式(ORC)和 Parquet。它们分别来自 Hortonworks 和 Cloudera/Twitter,两者都提供了非常相似的空间和时间节省优化。唯一的真正优势来自于 Parquet 的目标是在 Hadoop 社区中最大化兼容性,因此在撰写本文时,Parquet 对 Hadoop 生态系统的支持更为广泛。
第三章 有一个专门介绍 Parquet 的部分,技术 23 包括了如何使用 Hive 与 Parquet 一起使用的说明。
技术 93 调优 Hive 联接
在 Hive 中执行联接操作并在一些大型数据集上等待数小时以完成它们并不罕见。在这个技术中,我们将探讨如何优化联接,就像我们在第四章 chapter 4 中为 MapReduce 所做的那样。
问题
您的 Hive 联接运行速度比预期慢,您想了解有哪些选项可以加快它们的速度。
解决方案
看看如何通过 repartition 联接、复制联接 和 半联接 来优化 Hive 联接。
讨论
我们将在 Hive 中介绍三种类型的联接:repartition 联接,这是标准的 reduce-side 联接;replication 联接,这是 map-side 联接;以及半联接,它只关心保留一个表中的数据。
在我们开始之前,让我们创建两个用于工作的表:
$ hadoop fs -mkdir stocks-mini
$ hadoop fs -put test-data/ch9/stocks-mini.txt stocks-mini
$ hadoop fs -mkdir symbol-names
$ hadoop fs -put test-data/ch9/symbol-names.txt symbol-names
hive> CREATE EXTERNAL TABLE stocks (
symbol STRING,
date STRING,
open FLOAT
)
ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
LOCATION '/user/YOUR-USERNAME/stocks-mini';
hive> CREATE EXTERNAL TABLE names (
symbol STRING,
name STRING
)
ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
LOCATION '/user/YOUR-USERNAME/symbol-names';
您已经创建了两个表。股票表包含三个列——股票符号、日期和价格。名称表包含股票符号和公司名称:
hive> select * from stocks;
AAPL 2009-01-02 85.88
AAPL 2008-01-02 199.27
CSCO 2009-01-02 16.41
CSCO 2008-01-02 27.0
GOOG 2009-01-02 308.6
GOOG 2008-01-02 692.87
MSFT 2009-01-02 19.53
MSFT 2008-01-02 35.79
YHOO 2009-01-02 12.17
YHOO 2008-01-02 23.8
hive> select * from names;
AAPL Apple
GOOG Google
YHOO Yahoo!
连接表排序
与任何类型的调整一样,了解系统的内部工作原理非常重要。当 Hive 执行连接时,它需要选择哪个表是流式传输的,哪个表是缓存的。Hive 选择JOIN语句中的最后一个表进行流式传输,因此你应该注意确保这是最大的表。
让我们看看我们两个表的例子。包含每日报价的股票表将随着时间的推移继续增长,但包含股票符号名称的名称表将基本上是静态的。因此,当这些表进行连接时,重要的是较大的表,即股票表,在查询中排在最后:
SELECT stocks.symbol, date, open, name
FROM names
JOIN stocks ON (names.symbol = stocks.symbol);
你也可以明确告诉 Hive 它应该流式传输哪个表:
SELECT /*+ STREAMTABLE(stocks) */ stocks.symbol, date, open, name
FROM names
JOIN stocks ON (names.symbol = stocks.symbol);
映射连接
复制连接是一种映射端连接,其中小表在内存中缓存,大表流式传输。你可以在图 9.4 中看到它是如何在 MapReduce 中工作的。
图 9.4. 复制连接

映射端连接可以用于执行内连接和外连接。当前的推荐是配置 Hive 自动尝试将连接转换为映射端连接:
hive> set hive.auto.convert.join = true;
hive> SET hive.auto.convert.join.noconditionaltask = true;
hvie> SET hive.auto.convert.join.noconditionaltask.size = 10000000;
前两个设置必须设置为true以启用将连接自动转换为映射端连接(在 Hive 0.13 中它们默认都是启用的)。最后一个设置由 Hive 用于确定是否可以将连接转换为映射端连接。想象一下你在连接中有N个表。如果最小的N - 1 个表在磁盘上的大小小于hive.auto.convert.join.noconditionaltask.size,则连接将转换为映射端连接。请注意,这个检查是基本的,并且只检查磁盘上表的大小,因此压缩、过滤器或投影等因素不计入等式。
映射连接提示
旧版本的 Hive 支持一个提示,你可以用它来指示 Hive 哪个表是最小的并且应该被缓存。以下是一个示例:
SELECT /*+ MAPJOIN(names) */ stocks.symbol, date, open, name
FROM names
JOIN stocks ON (names.symbol = stocks.symbol);
新版本的 Hive 忽略了此提示(hive.ignore.mapjoin.hint默认设置为true),因为它将责任放在查询作者身上,由他们确定较小的表,这可能导致由于用户错误而查询缓慢。
排序合并桶连接
Hive 表可以进行分桶和排序,这有助于你轻松采样数据,并且它也是一种有用的连接优化,因为它使得排序合并桶(SMB)连接成为可能。SMB 连接要求所有表都必须排序和分桶,在这种情况下,连接非常高效,因为它们只需要对预排序表进行简单的合并。
以下示例显示了如何创建排序和分桶的股票表:
CREATE TABLE stocks_bucketed (
symbol STRING,
date STRING,
open FLOAT
)
CLUSTERED BY(symbol) SORTED BY(symbol) INTO 32 BUCKETS;
向分桶表插入数据
你可以使用常规的INSERT语句向分桶表插入数据,但需要将hive.enforce.bucketing属性设置为true。这指示 Hive 在插入表时应查看表中的桶数以确定将使用的 reducer 数量(reducer 的数量必须等于桶的数量)。
要启用 SMB 连接,您必须设置以下属性:
set hive.auto.convert.sortmerge.join=true;
set hive.optimize.bucketmapjoin = true;
set hive.optimize.bucketmapjoin.sortedmerge = true;
set hive.auto.convert.sortmerge.join.noconditionaltask=true;
此外,您还需要确保以下条件成立:
-
所有要连接的表都按连接列进行分桶和排序。
-
每个连接表中的桶数必须相等,或者彼此是因数。
偏差
偏差可能导致 MapReduce 执行时间过长,因为少数几个 reducer 可能会接收到一些连接值的不成比例的大量记录。默认情况下,Hive 不会尝试做任何事情来解决这个问题,但它可以被配置为检测偏差并优化偏差键的连接:

当 Hive 检测到偏差时会发生什么?您可以在图 9.5 中看到 Hive 添加的额外步骤,其中偏差键被写入 HDFS 并在单独的 MapReduce 作业中处理。
图 9.5. Hive 偏差优化

应该注意的是,这种偏差优化仅适用于 reduce-side repartition joins,不适用于 map-side replication joins。
偏差表
如果你在创建表之前就知道有一些特定的键具有高偏差,你可以在创建表时告诉 Hive 这些键。如果你这样做,Hive 将将偏差键写入单独的文件中,从而允许它进一步优化查询,甚至在可能的情况下跳过这些文件。
假设您有两支股票(苹果和谷歌)的记录数量比其他股票多得多——在这种情况下,您需要修改您的 CREATE TABLE 语句,使用关键字 SKEWED BY,如下所示:
CREATE TABLE stocks_skewed (
symbol STRING,
date STRING,
open FLOAT
)
SKEWED BY (symbol) ON ('AAPL', 'GOOGL');
9.2. Impala
Impala 是一个低延迟、大规模并行查询引擎,其设计灵感来自 Google 的 Dremel 论文,该论文描述了一个可扩展且交互式的查询系统。5 Impala 是在 Cloudera 的背景下构思和开发的,它意识到在低延迟 SQL 环境中使用 MapReduce 来执行 SQL 是不可行的。
⁵ Sergey Melnik 等人,“Dremel: Interactive Analysis of Web-Scale Datasets”,
research.google.com/pubs/pub36632.html.
每个 Impala 守护进程都设计为自给自足的,客户端可以向任何 Impala 守护进程发送查询。Impala 确实有一些元数据服务,但即使它们不工作,它也可以继续运行,因为守护节点可以直接相互交谈以执行查询。Impala 架构的概述可以在图 9.6 中看到。
图 9.6. Impala 架构

Impala 允许您使用 SQL 语法查询 HDFS 或 HBase 中的数据,因此它支持通过 ODBC 访问。它使用 Hive 元数据存储,因此它可以读取现有的 Hive 表,并且通过 Impala 执行的 DDL 语句也会反映在 Hive 中。
在本节中,我将介绍 Impala 和 Hive 之间的一些差异,我们还将查看一些 Impala 的基本示例,包括如何使用 Hive UDFs。
9.2.1. Impala 与 Hive
Impala 和 Hive 之间存在一些差异:
-
Impala 从一开始就被设计为一个大规模并行查询引擎,不需要将 SQL 转换为另一个处理框架。Hive 依赖于 MapReduce(或更近期的 Tez)来执行。
-
Impala 和 Hive 都是开源的,但 Impala 是 Cloudera 控制下的精选项目。
-
Impala 不具有容错性。
-
Impala 不支持复杂类型,如映射、数组和结构(包括嵌套的 Avro 数据)。你基本上只能处理扁平数据.^([6])
⁶ Impala 和 Avro 嵌套类型支持计划在 Impala 2.0 中实现:
issues.cloudera.org/browse/IMPALA-345。 -
有各种文件格式和压缩编解码器组合,需要你使用 Hive 来创建和加载数据表。例如,你无法在 Impala 中创建或加载数据到 Avro 表,也无法在 Impala 中加载 LZO 压缩的文本文件。对于 Avro,你需要在 Impala 中使用它之前在 Hive 中创建表,而在 Avro 和 LZO 压缩的文本中,你需要在 Impala 中使用它们之前使用 Hive 将这些数据加载到这些表中。
-
Impala 不支持 Hive 用户定义的表生成函数(UDTS),尽管它支持 Hive UDF 和 UDAF,并且可以与包含这些 UDF 的现有 JAR 文件一起工作,而无需对 JAR 文件进行任何更改。
-
有些聚合函数和 HiveQL 语句在 Impala 中不受支持。
Impala 和 Hive 版本
此列表比较了 Hive 0.13 和 Impala 1.3.1,两者在撰写时都是当前的。需要注意的是,Impala 2 版本将解决这些问题中的某些问题。
Cloudera 有一个详细的 Impala 和 Hive 之间 SQL 差异列表:mng.bz/0c2F。
9.2.2. Impala 基础知识
本节涵盖了 Impala 可能最流行的两种数据格式——文本和 Parquet。
技巧 94:处理文本
文本通常是探索新工具时首先处理的文件格式,它也作为理解基础的良好学习工具。
问题
你有以文本形式存在的数据,你希望在 Impala 中处理。
解决方案
Impala 的文本支持与 Hive 相同。
讨论
Impala 的基本查询语言与 Hive 相同。让我们从将股票数据复制到 HDFS 中的一个目录开始:
$ hadoop fs -mkdir hive-stocks
$ hadoop fs -put test-data/stocks.txt hive-stocks
接下来,你将创建一个外部表并对数据进行简单的聚合操作:
$ impala-shell
> CREATE EXTERNAL TABLE stocks (
sym STRING,
dt STRING,
open FLOAT,
high FLOAT,
low FLOAT,
close FLOAT,
volume INT,
adj_close FLOAT
)
ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
LOCATION '/user/YOUR-USERNAME/hive-stocks';
> SELECT sym, min(close), max(close) FROM stocks GROUP BY sym;
+------+-------------------+-------------------+
| sym | min(close) | max(close) |
+------+-------------------+-------------------+
| MSFT | 20.32999992370605 | 116.5599975585938 |
| AAPL | 14.80000019073486 | 194.8399963378906 |
| GOOG | 202.7100067138672 | 685.1900024414062 |
| CSCO | 13.64000034332275 | 108.0599975585938 |
| YHOO | 12.85000038146973 | 475 |
+------+-------------------+-------------------+
在 Impala 中使用 Hive 表
技巧 94 中的示例展示了如何在 Impala 中创建一个名为 stocks 的表。如果你已经在 Hive 中创建了 stocks 表(如技巧 89 所示),那么你不需要在 Impala 中创建该表,而应该刷新 Impala 的元数据,然后使用该 Hive 表在 Impala 中。
在 Hive 中创建表后,在 Impala shell 中发出以下语句:
> INVALIDATE METADATA stocks;
到目前为止,你可以在 Impala shell 中对 stocks 表发出查询。
或者,如果您真的想在 Impala 中创建表并且您已经在 Hive 中创建了表,您需要在 Impala 中发出 CREATE TABLE 命令之前发出 DROP TABLE 命令。
就这些!您会注意到语法与 Hive 完全相同。唯一的区别是您不能使用 symbol 和 date 作为列名,因为它们是 Impala 中的保留符号(Hive 没有这样的限制)。
让我们看看如何处理一个更有趣的存储格式:Parquet。
技巧 95 使用 Parquet
非常推荐您使用 Parquet 作为存储格式,以获得各种空间和时间效率(有关 Parquet 优势的更多详细信息,请参阅第三章章节链接)。本技巧探讨了如何在 Impala 中创建 Parquet 表。
问题
您需要将数据保存为 Parquet 格式以加快查询速度并提高数据的压缩率。
解决方案
在创建表时使用 STORED AS PARQUET。
讨论
快速开始使用 Parquet 的一种方法是基于现有表(现有表不一定是 Parquet 表)创建一个新的 Parquet 表。以下是一个示例:
CREATE TABLE stocks_parquet LIKE stocks STORED AS PARQUET;
然后,您可以使用 INSERT 语句将旧表的内容复制到新的 Parquet 表中:
INSERT OVERWRITE TABLE stocks_parquet SELECT * FROM stocks;
现在,您可以丢弃旧表并开始使用您的新 Parquet 表!
> SHOW TABLE STATS stocks_parquet;
Query: show TABLE STATS stocks_parquet
+-------+--------+--------+---------+
| #Rows | #Files | Size | Format |
+-------+--------+--------+---------+
| -1 | 1 | 2.56KB | PARQUET |
+-------+--------+--------+---------+
或者,您可以从头创建一个新表:
CREATE TABLE stocks_parquet_internal (
sym STRING,
dt STRING,
open DOUBLE,
high DOUBLE,
low DOUBLE,
close DOUBLE,
volume INT,
adj_close DOUBLE
) STORED AS PARQUET;
Impala 的一个优点是它允许使用 INSERT ... VALUES 语法,因此您可以轻松地将数据放入表中:^([7])
⁷ 不推荐使用
INSERT ... VALUES进行大量数据加载。相反,将文件移动到表的 HDFS 目录中、使用LOAD DATA语句或使用INSERT INTO ... SELECT或CREATE TABLE AS SELECT ...语句会更有效率。前两种选项会将文件移动到表的 HDFS 目录中,后两种语句将并行加载数据。
INSERT INTO stocks_parquet_internal
VALUES ("YHOO","2000-01-03",442.9,477.0,429.5,475.0,38469600,118.7);
Parquet 是一种列式存储格式,因此您在查询中选择的列越少,查询执行速度越快。在以下示例中,选择所有列可以被认为是一种反模式,如果可能的话应避免:
SELECT * FROM stocks;
接下来,让我们看看如何处理在 Impala 外部修改表中数据的情况。
技巧 96 刷新元数据
如果您在 Impala 内部对表或数据进行更改,该信息将自动传播到所有其他 Impala 守护进程,以确保后续查询能够获取到新数据。但截至 1.3 版本,Impala 无法处理在 Impala 外部插入到表中的数据的情况。
Impala 还对表中文件的块放置敏感——如果 HDFS 调平器运行并将块重新定位到另一个节点,您需要发出刷新命令来强制 Impala 重置块位置缓存。
在这个技术中,你将学习如何刷新 Impala 中的表,以便它能够获取新数据。
问题
你已在 Impala 之外向 Hive 表插入了数据。
解决方案
使用REFRESH语句。
讨论
Impala 守护进程缓存 Hive 元数据,包括关于表和块位置的信息。因此,如果数据已加载到 Impala 之外的表,你需要使用REFRESH语句,以便 Impala 可以拉取最新的元数据。
让我们看看一个实际应用的例子;我们将使用你在技术 94 中创建的股票表。让我们向外部表的目录中添加一个包含全新股票代码报价的新文件:
echo "TSLA,2014-06-25,236,236,236,236,38469600,236" \
| hadoop fs -put - hive-stocks/append.txt
启动 Hive shell,你将立即能够看到股票:
hive> select * from stocks where sym = "TSLA";
TSLA 2014-06-25 236.0 236.0 236.0 236.0 38469600 236.0
在 Impala 中运行相同的查询,你将看不到任何结果:
> select * from stocks where sym = "TSLA";
Returned 0 row(s) in 0.33s
快速的REFRESH可以解决问题:
> REFRESH stocks;
> select * from stocks where sym = "TSLA";
+------+------------+------+------+-----+-------+----------+-----------+
| sym | dt | open | high | low | close | volume | adj_close |
+------+------------+------+------+-----+-------+----------+-----------+
| TSLA | 2014-06-25 | 236 | 236 | 236 | 236 | 38469600 | 236 |
+------+------------+------+------+-----+-------+----------+-----------+
REFRESH和INVALIDATE METADATA之间有什么区别?
在“在 Impala 中使用 Hive 表”侧边栏中(见技术 94),你使用了 Impala 中的INVALIDATE METADATA命令,以便你能看到在 Hive 中创建的表。这两个命令之间有什么区别?
INVALIDATE METADATA命令执行时资源消耗更大,当你使用 Hive 创建、删除或修改表后想要刷新 Impala 的状态时,它是必需的。一旦表在 Impala 中可见,如果新数据被加载、插入或更改,你应该使用REFRESH命令来更新 Impala 的状态。
摘要
当使用 Impala 插入和加载数据时,你不需要使用REFRESH,因为 Impala 有一个内部机制,通过该机制它共享元数据更改。因此,REFRESH仅在通过 Hive 加载数据或当你在外部操作 HDFS 中的文件时才是必需的。
9.2.3. Impala 中的用户定义函数
Impala 支持用 C++编写的本地 UDF,它们在性能上优于 Hive 的对应版本。本书不涵盖本地 UDF 的内容,但 Cloudera 有优秀的在线文档,全面涵盖了本地 UDF。^([8]) Impala 还支持使用 Hive UDF,我们将在下一个技术中探讨。
⁸ 关于 Impala UDF 的更多详细信息,请参阅 Cloudera 网站上的“用户定义函数”页面,网址为
mng.bz/319i。
技术编号 97 在 Impala 中执行 Hive UDF
如果你一直在使用 Hive,你很可能已经开发了一些你经常在查询中使用的 UDF。幸运的是,Impala 提供了对这些 Hive UDF 的支持,并允许你无需更改代码或 JAR 文件即可使用它们。
问题
你想在 Impala 中使用自定义或内置的 Hive UDF。
解决方案
在 Impala 中创建一个函数,引用包含 UDF 的 JAR 文件。
讨论
Impala 要求包含 UDF 的 JAR 文件位于 HDFS 中:
$ hadoop fs -put <PATH-TO-HIVE-LIB-DIR>/hive-exec.jar
接下来,在 Impala 命令行中,你需要定义一个新的函数,并指向 HDFS 上的 JAR 文件位置以及实现 UDF 的完全限定类。
对于这种技术,我们将使用一个与 Hive 打包在一起的 UDF,它将输入数据转换为十六进制形式。UDF 类是 UDFHex,以下示例创建了一个该类的函数,并给它一个逻辑名称 my_hex,以便在 SQL 中更容易引用它:
create function my_hex(string) returns string
location '/user/YOUR-USERNAME/hive-exec.jar'
symbol='org.apache.hadoop.hive.ql.udf.UDFHex';
到目前为止,你可以使用 UDF——以下是一个简单的示例:
> select my_hex("hello");
+-------------------------+
| default.my_hex('hello') |
+-------------------------+
| 68656C6C6F |
+-------------------------+
摘要
在 Hive 中使用 Hive UDF 与在 Impala 中使用它的区别是什么?
-
定义 UDF 的查询语言语法是不同的。
-
Impala 要求你定义函数的参数类型和返回类型。这意味着即使 UDF 设计为与任何 Hive 类型一起工作,如果定义的参数类型与你要操作的数据类型不同,那么进行类型转换的责任就落在你身上。
-
Impala 目前不支持复杂类型,因此你只能返回标量类型。
-
Impala 不支持用户定义的表函数。
这标志着我们对 Impala 的覆盖结束。要更详细地了解 Impala,请参阅 Richard L. Saltzer 和 Istvan Szegedi 的著作,《Impala in Action》(Manning,计划于 2015 年出版)。
接下来,让我们看看如何将 SQL 内联使用在 Spark 中,这可能成为你工具箱中最终的提取、转换和加载(ETL)和分析工具。
9.3. Spark SQL
新的 SQL-on-Hadoop 项目似乎每天都有出现,但很少有像 Spark SQL 那样有前途的。许多人认为,由于 Spark 简单的 API 和高效灵活的执行模型,Spark 是 Hadoop 处理的未来,而 Spark 1.0 版本中 Spark SQL 的引入进一步扩展了 Spark 工具包。
Apache Spark 是一个与 Hadoop 兼容的集群计算引擎。其主要卖点是通过在集群中将数据集固定到内存中来实现快速数据处理,并支持多种数据处理方式,包括 Map-Reduce 风格、迭代处理和图处理。
Spark 诞生于加州大学伯克利分校,并于 2014 年成为 Apache 项目。由于其表达性语言和允许你通过其 API(目前定义在 Java、Scala 和 Python 中)快速启动和运行,它正在产生巨大的动力。实际上,Apache Mahout,这个历史上在 MapReduce 中实现其并行化算法的机器学习项目,最近表示所有新的分布式算法都将使用 Spark 实现。
在 Spark 早期发展阶段,它使用了一个名为 Shark 的系统来为 Spark 引擎提供 SQL 接口。最近,在 Spark 1.0 版本中,我们介绍了 Spark SQL,它允许你在 Spark 代码中混合使用 SQL。这预示着一种新的 Hadoop 处理范式,即混合使用 SQL 和非 SQL 代码。
Spark SQL 和 Shark 之间的区别是什么?
Shark 是第一个在 Spark 中提供 SQL 能力的 Spark 系统。Shark 使用 Hive 进行查询规划,使用 Spark 进行查询执行。另一方面,Spark SQL 不使用 Hive 查询规划器,而是使用自己的规划器(和执行)引擎。目标是保持 Shark 作为 Spark 的 Hive 兼容部分,但计划在 Spark SQL 稳定后将其迁移到 Spark SQL 进行查询规划.^([9])
⁹ Michael Armbrust 和 Reynold Xin 讨论了 Shark 的未来,“Spark SQL:使用 Spark 操作结构化数据”,
mng.bz/9057。
在本节中,我们将探讨如何在 Spark 中处理 SQL,并查看其类似 SQL 的 API,这些 API 提供了一种流畅的风格来编写查询。
Spark SQL 的生产就绪性
在撰写本文时,Spark 1.0 已经发布,它首次引入了 Spark SQL。它目前被标记为 alpha 质量,并且正在积极开发中.^([10]) 因此,本节中的代码可能与生产就绪的 Spark SQL API 不同。
¹⁰ Michael Armbrust 和 Zongheng Yang,“Spark SQL 的前景令人兴奋的性能改进”,
mng.bz/efqV。
在我们开始学习 Spark SQL 之前,让我们通过查看一些简单的 Spark 示例来熟悉 Spark。
9.3.1. Spark 101
Spark 由一组核心 API 和执行引擎组成,在其之上存在其他 Spark 系统,这些系统提供 API 和针对特定活动的处理能力,例如设计流处理管道。核心 Spark 系统如图 9.7 所示。
图 9.7. Spark 系统

Spark 组件如图 9.8 所示。Spark 驱动程序负责与集群管理器通信以执行操作,而 Spark 执行器处理实际的操作执行和数据管理。
图 9.8. Spark 架构

Spark 中的数据使用 RDDs(弹性分布式数据集)表示,它是对一系列项目的抽象。RDDs 在集群上分布,以便每个集群节点将存储和管理 RDD 中一定范围内的项目。RDD 可以从多个来源创建,例如常规 Scala 集合或来自 HDFS(通过 Hadoop 输入格式类合成)的数据。RDD 可以是内存中的,也可以是磁盘上的,或者两者兼而有之.^([11])
¹¹ 关于 RDD 缓存和持久化的更多信息,可以在 Spark 编程指南中找到,网址为
spark.apache.org/docs/latest/programming-guide.html#rdd-persistence。
以下示例展示了如何从文本文件创建 RDD:
scala> val stocks = sc.textFile("stocks.txt")
stocks: org.apache.spark.rdd.RDD[String] = MappedRDD[122] at textFile
Spark RDD类具有各种可以在 RDD 上执行的操作。Spark 中的 RDD 操作分为两类——转换和动作:
-
转换 在 RDD 上操作以创建一个新的 RDD。转换函数的示例包括
map、flatMap、reduceByKey和distinct.^(12)^(12) 更完整的转换列表可以在 Spark 编程指南中找到,
spark.apache.org/docs/latest/programming-guide.html#transformations。 -
操作 在 RDD 上执行一些活动,之后将结果返回给驱动程序。例如,
collect函数将整个 RDD 内容返回给驱动进程,而take函数允许您选择数据集中的前 N 个项目.^(13)^(13) 更完整的操作列表可以在 Spark 编程指南中找到,
spark.apache.org/docs/latest/programming-guide.html#actions。
懒惰转换
Spark 会延迟评估转换,因此您实际上需要执行一个操作,Spark 才会执行您的操作。
让我们看看一个 Spark 应用程序的例子,该程序计算每个符号的平均股票价格。要运行此示例,您需要安装 Spark,^(14) 安装完成后,您可以启动 shell:
^(14) 在 YARN 上安装和配置 Spark,请遵循“在 YARN 上运行 Spark”页面上的说明,
spark.apache.org/docs/latest/running-on-yarn.html。

这是对 Spark 的一个非常简短的介绍——Spark 在线文档非常出色,值得探索以了解更多关于 Spark 的信息.^([15]) 现在我们来介绍 Spark 如何与 Hadoop 一起工作。
^(15) 学习 Spark 的一个好起点是 Spark 编程指南,
spark.apache.org/docs/latest/programming-guide.html。
9.3.2. Spark on Hadoop
Spark 支持多个集群管理器,其中之一是 YARN。在此模式下,Spark 执行器是 YARN 容器,Spark ApplicationMaster 负责管理 Spark 执行器并向它们发送命令。Spark 驱动程序位于客户端进程内部或 ApplicationMaster 内部,具体取决于您是在客户端模式还是集群模式下运行:
-
在 客户端模式 下,驱动程序位于客户端内部,这意味着在此模式下执行一系列 Spark 任务将会被中断,如果客户端进程被终止。这种模式适合 Spark shell,但不适合在 Spark 以非交互方式使用时使用。
-
在 集群模式 下,驱动程序在 ApplicationMaster 中执行,并且不需要客户端存在即可执行任务。这种模式最适合您有一些现有的 Spark 代码需要执行,且不需要您进行任何交互的情况。
图 9.9 展示了 Spark 在 YARN 上运行的架构。
图 9.9. Spark 在 YARN 上运行

Spark 的默认安装设置为独立模式,因此您必须配置 Spark 以使其与 YARN 一起工作。16 Spark 脚本和工具在运行在 YARN 上时不会改变,因此一旦您配置了 Spark 以使用 YARN,您就可以像上一个示例中那样运行 Spark shell。
^([16) 按照以下说明在
spark.apache.org/docs/latest/running-on-yarn.html中设置 Spark 以使用 YARN。
现在您已经了解了 Spark 的基础知识以及它在 YARN 上的工作方式,让我们看看您如何使用 Spark 执行 SQL。
9.3.3. 使用 Spark 的 SQL
本节介绍了 Spark SQL,它是 Spark 核心系统的一部分。Spark SQL 将涵盖三个领域:对 RDD 执行 SQL、使用集成查询语言特性以提供更丰富的数据处理方式,以及将 HiveQL 与 Spark 集成。
Spark SQL 的稳定性
Spark SQL 目前被标记为 alpha 质量,因此最好在它被标记为生产就绪之前不要在生产代码中使用它。
技巧 98 使用 Spark SQL 计算股票平均价
在这个技巧中,您将学习如何使用 Spark SQL 来计算每个股票符号的平均价格。
问题
您有一个 Spark 处理管道,使用 SQL 表达函数比使用 Spark API 更简单。
解决方案
将 RDD 注册为表,并使用 Spark 的 sql 函数对 RDD 执行 SQL。
讨论
本技巧的第一步是定义一个类,该类将代表 Spark 表中的每条记录。在这个例子中,您将计算股票价格的平均值,因此您只需要一个包含两个字段的类来存储股票符号和价格:
scala> case class Stock(symbol: String, price: Double)
为什么在 Spark 示例中使用 Scala?
在本节中,我们将使用 Scala 来展示 Spark 示例。直到最近,Scala API 比 Spark 的 Java API 更简洁,尽管随着 Spark 1.0 的发布,Spark 现在的 Java 支持使用 lambdas 来提供一个更简洁的 API。
接下来,您需要将这些 Stock 对象的 RDD 注册为表,以便您可以在其上执行 SQL 操作。您可以从任何 Spark RDD 创建一个表。以下示例展示了您如何从 HDFS 加载股票数据并将其注册为表:

现在,你可以对股票表发出查询。以下是如何计算每个符号的平均价格:
scala> val stock_averages = sql(
"SELECT symbol, AVG(price) FROM stocks GROUP BY symbol")
scala> stock_averages.collect().foreach(println)
[CSCO,31.564999999999998]
[GOOG,427.032]
[MSFT,45.281]
[AAPL,70.54599999999999]
[YHOO,73.29299999999999]
sql 函数返回一个 SchemaRDD,它支持标准的 RDD 操作。这正是 Spark SQL 的独特之处——将 SQL 和常规数据处理范式结合起来。您使用 SQL 创建一个 RDD,然后可以立即对那些数据执行常规的 Spark 转换。
除了支持标准的 Spark RDD 操作外,SchemaRDD还允许你在数据上执行类似 SQL 的函数,如where和join,这将在下一个技术中介绍.^([17])
(17) 允许以更自然语言表达查询的语言集成查询可以在
SchemaRDD类的 Scala 文档中看到,链接为spark.apache.org/docs/latest/api/scala/index.html#org.apache.spark.sql.SchemaRDD.
技术编号 99:语言集成查询
之前的技术展示了如何在 Spark 数据上执行 SQL。Spark 1.0 还引入了一个名为语言集成查询的功能,它将 SQL 结构作为函数暴露出来,允许你编写不仅流畅而且使用自然语言结构表达操作的代码。在这个技术中,你将看到如何在你自己的 RDD 上使用这些函数。
问题
尽管 Spark RDD 函数具有表现力,但它们生成的代码并不特别适合人类阅读。
解决方案
使用 Spark 的语言集成查询。
讨论
再次尝试计算平均股票价格,这次使用语言集成查询。此示例使用groupBy函数来计算平均股票价格:

上述代码利用了Average和First聚合函数——还有其他聚合函数,如Count、Min和Max等.^([18])
(18) 请参阅以下链接以获取完整列表的代码:
github.com/apache/spark/blob/master/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/aggregates.scala.
下一个示例更为直接;它只是简单地选择价值超过 100 美元的日期的所有报价:
scala> stocks.where('price >= 100).collect.foreach(println)
[AAPL,200.26]
[AAPL,112.5]
...
Spark SQL 的第三种选择是使用 HiveQL,这在需要执行更复杂的 SQL 语法时很有用。
技术编号 100:Hive 和 Spark SQL
你还可以在 Spark 中使用 Hive 表中的数据。这个技术探讨了如何对 Hive 表执行查询。
问题
你想在 Spark 中处理 Hive 数据。
解决方案
使用 Spark 的HiveContext发布 HiveQL 语句并在 Spark 中处理结果。
讨论
在本章的早期,你已经在 Hive 中创建了一个股票表(在技术编号 89 中)。现在让我们使用 HiveQL 在 Spark 中查询这个股票表,然后在 Spark 中进行一些额外的操作:

在 Spark 中,你可以访问完整的 HiveQL 语法,因为包裹在hql调用中的命令直接发送到 Hive。你可以加载数表,向表中插入数据,并执行任何所需的 Hive 命令,所有这些都可以直接从 Spark 完成。Spark 的 Hive 集成还包括在查询中使用 Hive UDFs、UDAFs 和 UDTFs 的支持。
这完成了我们对 Spark SQL 的简要了解。
9.4. 章节总结
对于组织来说,通过 Hadoop 访问数据是至关重要的,因为并非所有希望与数据交互的用户都是程序员。SQL 不仅是数据分析师的通用语言,也是您组织中的数据科学家和非技术成员的通用语言。
在本章中,我介绍了三个可以用来通过 SQL 处理数据的工具。Hive 存在时间最长,目前是您可以使用功能最全面的 SQL 引擎。如果 Hive 无法提供足够快的与数据交互的速度,Impala 则值得认真考虑。最后,Spark SQL 为未来提供了一个窗口,在这个窗口中,您组织中的技术成员,如程序员和数据科学家,可以将 SQL 和 Scala 融合在一起,构建复杂且高效的处理管道。
第十章. 编写 YARN 应用
本章涵盖
-
理解 YARN 应用的关键功能
-
如何编写基本的 YARN 应用
-
对 YARN 框架和应用的考察
查看任何合理规模的 YARN 应用的源代码通常会导致人们使用“复杂”和“低级”这样的词汇。从本质上讲,编写 YARN 应用并不复杂,正如您在本章中将发现的那样。YARN 的复杂性通常在您需要将更高级的功能构建到应用中时出现,例如支持安全的 Hadoop 集群或处理故障场景,这在分布式系统中无论框架如何都是复杂的。尽管如此,有一些新兴的框架抽象了 YARN API,并提供了您所需的一些常见功能。
在本章中,您将编写一个简单的 YARN 应用,该应用将在集群中的一个节点上运行 Linux 命令。一旦您运行了您的应用,您将了解您在 YARN 应用中可能需要的更高级的功能。最后,本章将探讨一些开源的 YARN 抽象,并检查它们的功能。
在我们开始之前,让我们通过查看 YARN 应用的构建块来逐步了解 YARN 编程。
10.1. 构建 YARN 应用的基础
本节提供了对 YARN 演员和您在 YARN 应用中需要支持的基本通信流程的简要概述。
10.1.1. 演员
YARN 应用由五个独立的部分组成,这些部分要么是 YARN 框架的一部分,要么是您必须自己创建的组件(我称之为“用户空间”),所有这些都在图 10.1 中展示。
图 10.1. YARN 应用中的主要演员和通信路径

YARN 应用中的演员和 YARN 框架包括
-
YARN 客户端 —在用户空间中,YARN 客户端负责启动 YARN 应用。它向 ResourceManager 发送
createApplication和submitApplication请求,也可以终止应用。 -
ResourceManager —在框架中,单个集群范围的 ResourceManager 负责接收容器分配请求,并在资源对容器可用时异步通知客户端。
-
ApplicationMaster —用户空间中的 ApplicationMaster 是应用程序的主要协调器,它与 ResourceManager 和 NodeManagers 协作请求和启动容器。
-
NodeManager —在框架中,每个节点运行一个 NodeManager,负责处理启动和终止容器的客户端请求。
-
Container —用户空间中的容器是代表应用程序执行工作的特定应用程序进程。容器可以是现有 Linux 进程的简单分支(例如,使用
find命令查找文件),也可以是应用程序开发的映射或减少任务,例如 MapReduce YARN 应用程序。
以下几节将讨论这些参与者及其在您的 Yarn 应用中的作用。
10.1.2. YARN 应用的力学原理
在实现 YARN 应用时,您需要支持多种交互。让我们检查每个交互以及组件之间传递的信息。
资源分配
当 YARN 客户端或 ApplicationMaster 向 ResourceManager 请求新的容器时,它们在一个Resource对象中指明容器需要的资源。此外,ApplicationMaster 在ResourceRequest中发送一些额外的属性,如图 10.2 所示。
图 10.2. 可请求的容器资源属性

resourceName指定容器应执行的主机和机架,并且可以用星号通配符表示,以通知 ResourceManager 容器可以在集群中的任何节点上启动。
ResourceManager 对资源请求响应时,返回一个表示单个执行单元(进程)的Container对象。容器包括一个 ID、resourceName和其他属性。一旦 YARN 客户端或 ApplicationMaster 从 ResourceManager 接收到此消息,它就可以与 NodeManager 通信以启动容器。
启动容器
一旦客户端从 ResourceManager 接收了Container,它就准备好与关联的 NodeManager 通信以启动容器。图 10.3 显示了客户端作为请求一部分发送给 NodeManager 的信息。
图 10.3. 容器请求元数据

NodeManager 负责从 HDFS 下载请求中标识的任何本地资源(包括应用程序所需的任何库或分布式缓存中的文件等)。一旦这些文件下载完成,NodeManager 将启动容器进程。
在这些 YARN 预备知识完成后,让我们继续编写一个 YARN 应用。
10.2. 构建用于收集集群统计信息的 YARN 应用程序
在本节中,你将构建一个简单的 YARN 应用程序,该程序将启动一个容器来执行 vmstat Linux 命令。在构建这个简单示例的过程中,我们将关注使 YARN 应用程序启动和运行所需的管道。下一节将介绍你可能在完整 YARN 应用程序中需要的高级功能。
展示了在本节中你将构建的各种组件以及它们与 YARN 框架的交互。

让我们从构建 YARN 客户端开始。
技巧 101 一个基本的 YARN 客户端
YARN 客户端的作用是与 ResourceManager 协商以创建和启动 YARN 应用程序实例。作为这项工作的部分,你需要向 ResourceManager 通知你的 ApplicationMaster 的系统资源需求。一旦 ApplicationMaster 启动并运行,客户端可以选择监控应用程序的状态。
这种技术将向你展示如何编写一个执行 图 10.5 中展示的三项活动的客户端。

问题
你正在构建一个 YARN 应用程序,因此你需要编写一个客户端来启动你的应用程序。
解决方案
使用 YarnClient 类创建和提交 YARN 应用程序。
讨论
让我们逐个分析 图 10.5 中突出显示的每个步骤的代码,从创建一个新的 YARN 应用程序开始。
创建 YARN 应用程序
你的 YARN 客户端需要做的第一件事是与 ResourceManager 通信,告知其启动新 YARN 应用程序的意图。ResourceManager 的响应是一个唯一的应用程序 ID,用于创建应用程序,并且 YARN 命令行也支持查询日志等操作。
以下代码展示了如何获取 YarnClient 实例的句柄,并使用它来创建应用程序^([1])。
¹ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch10/dstat/Client.java。

createApplication 方法将调用 ResourceManager,它将返回一个新的应用程序 ID。此外,YarnClientApplication 对象包含有关集群的信息,例如可用于预定义容器资源属性的可用资源能力。
在前面代码中使用的 YarnClient 类包含多个 API,这些 API 会导致对 ResourceManager 的 RPC 调用。其中一些方法在以下代码摘录中有所展示^([2])。
² 从
YarnClient类中省略了一些队列和安全 API——请参阅YarnClient的 Javadocs 以获取完整的 API:hadoop.apache.org/docs/stable/api/org/apache/hadoop/yarn/client/api/YarnClient.html.

在 YARN 中创建一个应用程序实际上并没有做任何事情,只是通知 ResourceManager 你打算启动应用程序的意图。下一步将展示你需要做什么才能让 ResourceManager 启动你的 ApplicationMaster。
提交 YARN 应用程序
提交 YARN 应用程序将在你的 YARN 集群中的新容器中启动你的 ApplicationMaster。但在提交应用程序之前,你需要配置几个项目,包括以下内容:
-
应用程序名称
-
启动 ApplicationMaster 的命令,包括类路径和环境设置
-
任何应用程序执行其工作所需的 JAR 文件、配置文件和其他文件
-
ApplicationMaster 的资源需求(内存和 CPU)
-
将应用程序提交到哪个调度器队列以及队列中的应用程序优先级
-
安全令牌
让我们看看启动一个基本的基于 Java 的 ApplicationMaster 所需的代码。我们将把这个代码分成两个小节:准备Container-LaunchContext对象,然后指定资源需求和提交应用程序。
首先是ContainerLaunchContext,这是你指定启动你的 ApplicationMaster 的命令以及任何其他应用程序执行所需的环境细节的地方:^([3])
³ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch10/dstat/Client.java.

最后的步骤是指定 ApplicationMaster 所需的内存和 CPU 资源,然后提交应用程序:^([4])
⁴ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch10/dstat/Client.java.

发送到 ResourceManager 的所有容器请求都是异步处理的,所以submitApplication返回并不意味着你的 ApplicationMaster 已经启动并运行。为了了解应用程序的状态,你需要轮询 ResourceManager 以获取应用程序状态,这将在下一部分介绍。
等待 YARN 应用程序完成
提交应用程序后,你可以轮询 ResourceManager 以获取有关 ApplicationMaster 状态的信息。结果将包含如下详细信息
-
应用程序的状态
-
应用程序主控运行的主机,以及(如果有)它监听客户端请求的 RPC 端口(在我们的示例中不适用)
-
如果应用程序主控支持,则可以提供一个跟踪 URL,该 URL 提供有关应用程序进度的详细信息(在我们的示例中不支持)
-
一般信息,如队列名称和容器启动时间
您的应用程序主控(ApplicationMaster)可以是图 10.6 中显示的任何一种状态(这些状态包含在枚举YarnApplicationState中)。
图 10.6. 应用程序主控状态

以下代码执行客户端的最终步骤,即定期轮询资源管理器,直到应用程序主控完成:^([5])
⁵ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch10/dstat/Client.java。

摘要
在本节中未探讨的许多更高级的客户端功能,例如安全性。第 10.3 节讨论了这一点以及您可能希望构建到客户端的其他功能。
在您的 YARN 客户端就绪后,是时候转向您的 YARN 应用程序的第二部分——应用程序主控。
技术篇 102:一个基础的应用程序主控
应用程序主控是 YARN 应用程序的协调器。它负责向资源管理器请求容器,然后通过节点管理器启动容器。图 10.7 显示了这些交互,您将在本技术中探索这些交互。
图 10.7. 您的应用程序主控将执行的基本功能

问题
您正在构建一个 YARN 应用程序,并需要实现一个应用程序主控。
解决方案
使用 YARN 应用程序主控 API 通过资源管理器和节点管理器协调您的工作。
讨论
与前一个技术一样,我们将分解应用程序主控需要执行的操作。
在资源管理器中注册
第一步是将应用程序主控注册到资源管理器。为此,您需要获取一个AMRMClient实例的句柄,您将使用它与资源管理器进行所有通信:^([6])
⁶ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch10/dstat/ApplicationMaster.java。

提交容器请求并在可用时启动它
接下来,您需要指定所有希望请求的容器。在这个简单的示例中,您将请求一个容器,并且不会指定它将在哪个特定的主机或机架上运行:^([7])
⁷ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch10/dstat/ApplicationMaster.java。

AMRMClient的allocate方法执行了多个重要功能:
-
它充当向 ResourceManager 的心跳消息。如果 ResourceManager 在 10 分钟后没有收到心跳消息,它将认为 ApplicationMaster 处于不良状态,并将终止进程。默认的超时值可以通过设置
yarn.am.liveness-monitor.expiry-interval-ms来更改。 -
它发送任何添加到客户端的容器分配请求。
-
它接收零个或多个由容器分配请求产生的分配容器。
在此代码中,第一次调用allocate时,容器请求将被发送到 ResourceManager。因为 ResourceManager 异步处理容器请求,所以响应不会包含分配的容器。相反,后续的allocate调用将返回分配的容器。
等待容器完成
在这一点上,你已经向 ResourceManager 请求了一个容器,从 ResourceManager 那里收到了容器分配,并与 NodeManager 通信以启动容器。现在你必须继续调用allocate方法,并从响应中提取任何完成的容器:^([8])
⁸ GitHub 源代码:
github.com/alexholmes/hiped2/blob/master/src/main/java/hip/ch10/dstat/ApplicationMaster.java。

摘要
在这个技术中,你使用了AMRMClient和NMClient类与 ResourceManager 和 NodeManagers 进行通信。这些客户端为 YARN 服务提供了同步 API。它们有异步对应物(AMRMClientAsync和NMClientAsync),这些对应物封装了心跳功能,并在接收到 ResourceManager 的消息时回调到你的代码中。异步 API 可能会使你更容易理解与 ResourceManager 的交互,因为 ResourceManager 异步处理所有内容。
ResourceManager 和 NodeManager 还向 ApplicationMasters 公开了一些其他功能:^([9])
⁹ 可以在
hadoop.apache.org/docs/stable/api/org/apache/hadoop/yarn/client/api/AMRMClient.html查看AMRMClient的完整 Javadocs。

类似地,NMClient API 公开了一些机制,你可以使用这些机制来控制和获取你的容器的元数据:^([10])
^(10)
NMClient的完整 Javadoc 可在hadoop.apache.org/docs/stable/api/org/apache/hadoop/yarn/client/api/NMClient.html找到。

到目前为止,你已经编写了一个完整的 YARN 应用程序的代码!接下来,你将在集群上执行你的应用程序。
技巧 103 运行应用程序和访问日志
到目前为止,你已经有了一个功能性的 YARN 应用程序。在本节中,你将了解如何运行应用程序并访问其输出。
问题
你想要运行你的 YARN 应用程序。
解决方案
使用常规 Hadoop 命令行来启动它并查看容器输出。
讨论
你一直在使用的 hip 脚本,用于启动本书中的所有示例,也适用于运行 YARN 应用程序。在幕后,hip 调用 hadoop 脚本来运行示例。
以下示例显示了运行在最后两个技巧中编写的 YARN 应用程序的输出。它在单个容器中运行 vmstat Linux 命令:
$ hip --nolib hip.ch10.dstat.basic.Client
client.RMProxy: Connecting to ResourceManager at /0.0.0.0:8032
Submitting application application_1398974791337_0055
impl.YarnClientImpl: Submitted application
application_1398974791337_0055 to ResourceManager at /0.0.0.0:8032
Application application_1398974791337_0055 finished with state FINISHED
如果你启用了日志聚合(有关更多详细信息,请参阅技巧 3),你可以使用以下命令查看应用程序主和 vmstat 容器的日志输出:

应用程序主将容器标准输出指向 stdout 文件,你可以在该文件中看到 vmstat 命令的输出。
容器启动失败时的日志访问
在开发你的 YARN 应用程序期间,应用程序主或你的某个容器可能会因为资源缺失或启动命令错误而无法启动。根据失败发生的位置,你的容器日志将包含与启动相关的错误,或者如果进程完全无法启动,你需要检查 NodeManager 日志。
保留本地化和日志目录
配置属性 yarn.nodemanager.delete.debug-delay-sec 控制应用程序本地化和日志目录保留的时间。本地化目录包含 NodeManager 执行以启动容器的命令(包括应用程序主和应用程序容器),以及应用程序为容器指定的任何 JAR 和其他本地化资源。
建议你将此属性设置为足够的时间来诊断失败。但不要设置得太高(比如,以天为单位),因为这可能会对你的存储造成压力。
作为寻找应用程序主启动问题的替代方案,可以运行一个未管理的应用程序主,这将在下一个技巧中介绍。
技巧 104 使用未管理的应用程序主进行调试
调试 YARN ApplicationMaster 是一个挑战,因为它是在远程节点上启动的,需要你从该节点拉取日志来排查你的代码。以这种方式由 ResourceManager 启动的 ApplicationMasters 被称为管理的 ApplicationMaster,如图 10.8 所示。
图 10.8. 一个管理的 ApplicationMaster

YARN 还支持一个名为非管理的 ApplicationMaster 的概念,其中 ApplicationMaster 在本地节点上启动,如图 10.9 所示。当 ApplicationMaster 在本地主机上运行时,诊断其问题更容易。
图 10.9. 一个非管理 ApplicationMaster

在本节中,你将了解如何运行非管理 ApplicationMaster,并学习它们如何被项目使用。
问题
你想运行一个 ApplicationMaster 的本地实例。
解决方案
运行一个非管理 ApplicationMaster。
讨论
YARN 附带了一个名为UnmanagedAMLauncher的应用程序,它启动一个非管理的 ApplicationMaster。非管理 ApplicationMaster 是指不由 ResourceManager 启动的 ApplicationMaster。相反,UnmanagedAMLauncher与 ResourceManager 协商以创建一个新的应用程序,但与管理的 ApplicationMaster 不同,UnmanagedAMLauncher启动该过程。
当使用UnmanagedAMLauncher时,你不需要定义 YARN 客户端,因此你只需要提供启动你的 ApplicationMaster 所需的详细信息。以下示例显示了如何执行你在上一技术中编写的 ApplicationMaster:

UnmanagedAMLauncher捕获 ApplicationMaster 的标准输出和标准错误,并将它们输出到其自己的标准输出。这在你的 ApplicationMaster 无法启动的情况下很有用,在这种情况下,错误将显示在先前的命令输出中,而不是被隐藏在 NodeManager 的日志中。
图 10.10 显示了UnmanagedAMLauncher与 ResourceManager 之间的交互。
图 10.10. 非管理启动器与 ResourceManager 协作启动非管理 ApplicationMaster

如果你认为UnmanagedAMLauncher中的功能过于有限,你可以编写自己的非管理 ApplicationMaster 启动器。以下代码显示了UnmanagedAMLauncher告诉 ResourceManagerApplicationMaster 是非管理的关键步骤:
ApplicationSubmissionContext appContext = ...;
appContext.setUnmanagedAM(true);
非管理 ApplicationMasters 很有用,因为它们提供了对 ApplicationMaster 的本地访问,这可以简化你的调试和性能分析工作。
接下来,让我们看看你可能希望在 YARN 应用程序中支持的一些更高级的功能。
10.3. YARN 应用程序的附加功能
到目前为止,在本章中,我们查看了一个基本的 YARN 应用程序,该应用程序在容器中启动 Linux 命令。然而,如果你正在开发 YARN 应用程序,你很可能需要支持更复杂的功能。本节突出了你可能需要在你的应用程序中支持的一些功能。
10.3.1. 组件间的 RPC
如果你有一个长时间运行的应用程序,你可能希望允许客户端与 ApplicationMaster 通信。你的 ApplicationMaster 也可能需要能够与容器通信,反之亦然。一个例子是一个 SQL-on-Hadoop 应用程序,它允许客户端向 ApplicationMaster 发送查询,然后 ApplicationMaster 协调容器执行工作。
YARN 在这里不提供任何管道服务,因此你需要选择一个 RPC 协议和相应的库。你有几个选择:
-
Thrift 或 Avro —这两个都提供了一个接口定义语言(IDL),你可以在这里定义端点和消息,这些消息被编译成具体的客户端和服务代码,可以轻松地集成到你的代码中。这些库的优点是代码生成和模式演变,允许你的服务随着时间的推移而发展。
-
Protocol Buffers —Google 没有开源 RPC 层,所以你需要自己实现。你可以使用 HTTP 上的 REST 作为传输,并使用 Jersey 的注解轻松实现所有这些。
-
Hadoop 的 RPC —在幕后,它使用 Protocol Buffers。
由于 YARN 不支持组件间的通信,你怎么知道你的服务监听在哪些主机或端口上?
10.3.2. 服务发现
YARN 可以在同一节点上调度多个容器,因此将任何服务在容器或 ApplicationMaster 中的监听端口硬编码并不是理想的选择。相反,你可以选择以下策略之一:
-
如果你的 ApplicationMaster 有一个内置的服务,将启动的容器的 ApplicationMaster 主机和端口详情传递给它,并让容器使用它们的端口号回调到 ApplicationMaster。
-
通过让容器将它们的宿主机和端口详情发布到 ZooKeeper,并让客户端在 ZooKeeper 中查找服务,使用 ZooKeeper 作为服务注册。这是 Apache Twill 采用的策略,该策略在本章的后面部分将进行介绍。
接下来,我们将探讨如何在你的应用程序中维护状态,以便在应用程序重启的情况下从已知状态恢复。
10.3.3. 检查点应用程序进度
如果你的应用程序运行时间较长,并在执行过程中维护和构建状态,你可能需要定期持久化该状态,以便在容器重启的情况下,容器或 ApplicationMaster 可以从上次停止的地方继续执行。容器可能因各种原因被终止,包括为其他用户和应用程序释放资源。ApplicationMaster 崩溃通常是由于应用程序逻辑错误、节点崩溃或集群重启造成的。
可以用于检查点的两个服务是 HDFS 和 ZooKeeper。Apache Twill,一个用于编写 YARN 应用程序的抽象框架,使用 ZooKeeper 来检查点容器和 ApplicationMaster 的状态。
在检查点中需要注意的一个领域是处理脑裂情况。
10.3.4. 避免脑裂
可能会出现网络问题,导致 ResourceManager 认为 ApplicationMaster 已关闭并启动一个新的 ApplicationMaster。如果你的应用程序以非幂等的方式产生输出或中间数据,这可能会导致不希望的结果。
这是在早期 MapReduce YARN 应用程序中的一个问题,其中任务和作业级别的提交可以执行多次,这对于不能重复执行的提交操作来说并不理想。解决方案是在提交时引入延迟,并使用 ResourceManager 心跳来验证 ApplicationMaster 是否仍然有效。有关更多详细信息,请参阅 JIRA 工单。^(11)
^(11) 请参阅标题为“MR AM 可能陷入脑裂情况”的 JIRA 工单:
issues.apache.org/jira/browse/MAPREDUCE-4832。
10.3.5. 长运行应用程序
一些 YARN 应用程序,如 Impala,是长运行的,因此它们的要求与更短暂的、性质上更短暂的应用程序不同。如果你的应用程序也是长运行的,你应该注意以下一些点,其中一些目前正在社区中工作:
-
群组调度,允许在短时间内调度大量容器(YARN-624)。
-
长生命周期容器支持,允许容器表明它们是长生命周期的,以便调度器可以做出更好的分配和管理决策(YARN-1039)。
-
反亲和性设置,以便应用程序可以指定多个容器不会分配在同一个节点上(YARN-397)。
-
在安全 Hadoop 集群上运行时,委托令牌的续订。Kerberos 令牌会过期,如果它们没有被续订,你将无法访问诸如 HDFS 等服务(YARN-941)。
有一个包含更多详细信息的综合 JIRA 工单:issues.apache.org/jira/browse/YARN-896。
尽管 Impala 是一个 YARN 应用程序,但它使用未管理的容器和自己的 gang-scheduling 机制来解决长运行应用程序的一些问题。因此,Cloudera 创建了一个名为 Llama(cloudera.github.io/llama/)的项目,该项目在 Impala 和 YARN 之间进行资源管理的中介,以提供这些功能。Llama 可能值得评估以符合你的需求。
10.3.6. 安全性
在安全 Hadoop 集群上运行的 YARN 应用程序需要向 ResourceManager 传递令牌,这些令牌将被传递到你的应用程序。这些令牌是访问如 HDFS 等服务所必需的。下一节中详细介绍的 Twill 提供了对安全 Hadoop 集群的支持。
这就结束了我们对 YARN 应用程序可能需要的附加功能的概述。接下来,我们将探讨 YARN 编程抽象,其中一些实现了本节中讨论的功能。
10.4. YARN 编程抽象
YARN 暴露了一个低级 API,并且学习曲线陡峭,尤其是如果你需要支持上一节中概述的许多功能。在 YARN 之上有一系列抽象,这些抽象简化了 YARN 应用程序的开发,并帮助你专注于实现应用程序逻辑,而无需担心 YARN 的机制。其中一些框架,如 Twill,还支持更高级的功能,例如将日志发送到 YARN 客户端和服务发现通过 ZooKeeper。
在本节中,我将简要介绍三个这样的抽象:Apache Twill、Spring 和 REEF。
10.4.1. Twill
Apache Twill(twill.incubator.apache.org/),以前称为 Weave,不仅提供了一个丰富且高级的编程抽象,还支持你可能在 YARN 应用程序中需要的许多功能,例如服务发现、日志传输和容错。
以下代码展示了使用 Twill 编写的示例 YARN 客户端。你会注意到构建YarnTwillRunnerService需要 ZooKeeper 连接 URL,该 URL 用于注册 YARN 应用程序。Twill 还支持将日志发送到客户端(通过 Kafka),在这里你正在添加一个日志处理程序,将容器和 ApplicationMaster 日志写入标准输出:

Twill 的编程模型使用诸如Runnable等众所周知的 Java 类型来模拟容器执行。以下代码展示了启动vmstat实用程序的容器:

图 10.11 展示了 Twill 如何使用 ZooKeeper 和 Kafka 来支持日志传输和服务发现等功能。
图 10.11. Twill 特性

你可以从 Terence Yim 的“使用 Apache Twill 释放 YARN 的力量”(www.slideshare.net/TerenceYim1/twill-apachecon-2014?ref=)中获得 Twill 的详细概述。Yim 还有几篇关于使用 Twill(以前称为 Weave)的博客文章。^([12)]
^([12]) Terence Yim, “使用 Weave 编程,第一部分,”
blog.continuuity.com/post/66694376303/programming-with-weave-part-i; “使用 Apache Twill,第二部分,”blog.continuuity.com/post/73969347586/programming-with-apache-twill-part-ii.
10.4.2. Spring
Spring for Hadoop 的 2.x 版本(projects.spring.io/spring-hadoop/)为简化 YARN 开发提供了支持。它与 Twill 不同,因为它专注于抽象 YARN API,而不是提供应用程序功能;相比之下,Twill 提供日志传输和服务发现。但是,你很可能不希望这些功能给 Twill 带来的额外复杂性,而是希望对 YARN 应用程序有更多的控制。如果是这样,Spring for Hadoop 可能是一个更好的选择。
Spring for Hadoop 提供了 YARN 客户端、ApplicationMaster 和容器的默认实现,这些实现可以被覆盖以提供特定于应用程序的功能。实际上,你可以不写任何代码就编写一个 YARN 应用程序!以下示例来自 Spring Hadoop 样本,展示了如何配置 YARN 应用程序以运行远程命令。^([13)] 以下代码片段显示了应用程序上下文,并配置了 HDFS、YARN 和应用程序 JAR 文件:
^([13]) “Spring Yarn 简单命令示例,”
github.com/spring-projects/spring-hadoop-samples/tree/master/yarn/yarn/simple-command.
<beans ...>
<context:property-placeholder location="hadoop.properties"
system-properties-mode="OVERRIDE"/>
<yarn:configuration>
fs.defaultFS=${hd.fs}
yarn.resourcemanager.address=${hd.rm}
fs.hdfs.impl=org.apache.hadoop.hdfs.DistributedFileSystem
</yarn:configuration>
<yarn:localresources>
<yarn:hdfs path="/app/simple-command/*.jar"/>
<yarn:hdfs path="/lib/*.jar"/>
</yarn:localresources>
<yarn:environment>
<yarn:classpath use-yarn-app-classpath="true"/>
</yarn:environment>
<util:properties id="arguments">
<prop key="container-count">4</prop>
</util:properties>
<yarn:client app-name="simple-command">
<yarn:master-runner arguments="arguments"/>
</yarn:client>
</beans>
以下代码定义了 ApplicationMaster 属性,并指示它运行vmstat命令:
<beans ...>
<context:property-placeholder location="hadoop.properties"/>
<bean id="taskScheduler" class="
org.springframework.scheduling.concurrent.ConcurrentTaskScheduler"/>
<bean id="taskExecutor" class="
org.springframework.core.task.SyncTaskExecutor"/>
<yarn:configuration>
fs.defaultFS=${SHDP_HD_FS}
yarn.resourcemanager.address=${SHDP_HD_RM}
yarn.resourcemanager.scheduler.address=${SHDP_HD_SCHEDULER}
</yarn:configuration>
<yarn:localresources>
<yarn:hdfs path="/app/simple-command/*.jar"/>
<yarn:hdfs path="/lib/*.jar"/>
</yarn:localresources>
<yarn:environment>
<yarn:classpath use-yarn-app-classpath="true" delimiter=":">
./*
</yarn:classpath>
</yarn:environment>
<yarn:master>
<yarn:container-allocator/>
<yarn:container-command>
<![CDATA[
vmstat
1><LOG_DIR>/Container.stdout
2><LOG_DIR>/Container.stderr
]]>
</yarn:container-command>
</yarn:master>
</beans>
样本还包括如何扩展客户端、ApplicationMaster 和容器的内容查看。^([14)]
^([14]) 扩展 Spring YARN 类的示例:“Spring Yarn 自定义 Application Master 服务示例,”
github.com/spring-projects/spring-hadoop-samples/tree/master/yarn/yarn/custom-amservice.
您可以在 GitHub 上找到一些 Spring for Hadoop 的示例应用程序(github.com/spring-projects/spring-hadoop-samples)。该项目也有一个维基页面:github.com/spring-projects/spring-hadoop/wiki.
10.4.3. REEF
REEF 是微软的一个框架,它简化了适用于各种计算模型的可扩展、容错运行环境,包括 YARN 和 Mesos(www.reef-project.org/; github.com/Microsoft-CISL/REEF)。REEF 有一些有趣的功能,如容器重用和数据缓存。
您可以在 GitHub 上找到 REEF 教程:github.com/Microsoft-CISL/REEF/wiki/How-to-download-and-compile-REEF.
10.4.4. 选择 YARN API 抽象
YARN 的抽象还处于早期阶段,因为 YARN 是一项新技术。本节简要概述了您可以使用来隐藏 YARN API 一些复杂性的三个抽象。但您应该为您的应用程序选择哪一个呢?
-
Apache Twill 看起来最有前途,因为它已经封装了您在应用程序中需要的许多功能。它选择了最佳的技术,如 Kafka 和 ZooKeeper 来支持这些功能。
-
如果您正在开发一个轻量级的应用程序,并且不想依赖 Kafka 或 ZooKeeper,Spring for Hadoop 可能更适合您。
-
如果您有一些复杂的应用程序需求,例如需要在多个执行框架上运行,或者需要支持更复杂的容器编排和容器之间的状态共享,REEF 可能很有用。
10.5. 章节总结
本章向您展示了如何编写一个简单的 YARN 应用程序,并介绍了您可能在 YARN 应用程序中需要的更高级的功能。它还探讨了使编写应用程序更简单的 YARN 抽象。现在,您已经准备好开始编写下一个重大的 YARN 应用程序了。
这不仅结束了本章,也结束了整本书!我希望您喜欢这次旅程,并且在旅途中学到了一些可以在您的 Hadoop 应用程序和环境中使用的小技巧。如果您对本书中涵盖的任何内容有任何疑问,请前往 Manning 为本书设立的论坛并提问.^(15)
^(15) Manning 论坛上的 Hadoop 实践:
www.manning-sandbox.com/forum.jspa?forumID=901.
附录. 安装 Hadoop 及相关工具
本附录包含有关如何安装 Hadoop 以及书中使用的其他工具的说明。
快速开始使用 Hadoop
使用预安装的虚拟机是快速开始使用 Hadoop 的最快方式之一。以下是一些流行的虚拟机列表:
-
Cloudera Quickstart VM—
www.cloudera.com/content/cloudera-content/cloudera-docs/DemoVMs/Cloudera-QuickStart-VM/cloudera_quickstart_vm.html -
Hortonworks Sandbox—
hortonworks.com/products/hortonworkssandbox/ -
MapR Sandbox for Hadoop—
doc.mapr.com/display/MapR/MapR+Sandbox+for+Hadoop
A.1. 书中的代码
在我们进入安装 Hadoop 的说明之前,让我们先为您设置好这本书所附带的代码。代码托管在 GitHub 上,网址为 github.com/alexholmes/hiped2。为了让您快速启动,有一些预包装的 tarball,您无需构建代码——只需安装即可。
下载
首先,您需要从 github.com/alexholmes/hiped2/releases 下载代码的最新版本。
安装
第二步是将 tarball 解压到您选择的目录中。例如,以下操作将代码解压到 /usr/local 目录,这是您将安装 Hadoop 的同一目录:
$ cd /usr/local
$ sudo tar -xzvf <download directory>/hip-<version>-package.tar.gz
将主目录添加到您的路径中
书中的所有示例都假设代码的主目录在您的路径中。完成此操作的方法因操作系统和 shell 而异。如果您使用的是 Linux Bash,则以下命令应该可以工作(第二个命令需要使用单引号以避免变量替换):
$ echo "export HIP_HOME=/usr/local/hip-<version>" >> ~/.bash_profile
$ echo 'export PATH=${PATH}:${HIP_HOME}/bin' >> ~/.bash_profile
运行示例作业
您可以使用以下命令来测试您的安装。这假设您有一个正在运行的 Hadoop 设置(如果您没有,请跳转到 章节 A.3):
# create two input files in HDFS
$ hadoop fs -mkdir -p hip/input
$ echo "cat sat mat" | hadoop fs -put - hip/input/1.txt
$ echo "dog lay mat" | hadoop fs -put - hip/input/2.txt
# run the inverted index example
$ hip hip.ch1.InvertedIndexJob --input hip/input --output hip/output
# examine the results in HDFS
$ hadoop fs -cat hip/output/part*
下载源代码并构建
有些技术(如 Avro 代码生成)需要访问完整源代码。首先,使用 git 检出源代码:
$ git clone git@github.com:alexholmes/hiped2.git
设置您的环境,以便某些技术知道源代码安装的位置:
$ echo "export HIP_SRC=<installation dir>/hiped2" >> ~/.bash_profile
您可以使用 Maven 构建项目:
$ cd hiped2
$ mvn clean validate package
这将生成一个 target/hip-
A.2. 推荐的 Java 版本
Hadoop 项目维护了一个推荐列表,列出了在生产环境中与 Hadoop 一起工作表现良好的 Java 版本。有关详细信息,请参阅 Hadoop Wiki 上的“Hadoop Java Versions”页面 wiki.apache.org/hadoop/HadoopJavaVersions。
A.3. Hadoop
本节涵盖 Apache Hadoop 分发的安装、配置和运行。如果你使用的是其他 Hadoop 分发版,请参考特定分发的说明。
Apache tarball 安装
以下说明适用于想要安装 Apache Hadoop 分发版 tarball 版本的用户。这是一个伪分布式设置,不适用于多节点集群.^([1])
¹ 伪分布式模式是指所有 Hadoop 组件都在单个主机上运行。
首先,你需要从 Apache 下载页面 hadoop.apache.org/common/releases.html#Download 下载 tarball,并在 /usr/local 下解压:
$ cd /usr/local
$ sudo tar -xzf <path-to-apache-tarball>
$ sudo ln -s hadoop-<version> hadoop
$ sudo chown -R <user>:<group> /usr/local/hadoop*
$ mkdir /usr/local/hadoop/tmp
没有 root 权限的用户安装目录
如果你没有主机上的 root 权限,你可以在不同的目录下安装 Hadoop,并在以下说明中将 /usr/local 替换为你的目录名。
Hadoop 1 及更早版本的伪分布式模式配置
以下说明适用于 Hadoop 1 及更早版本。如果你正在使用 Hadoop 2,请跳到下一节。
编辑文件 /usr/local/hadoop/conf/core-site.xml,并确保其看起来如下所示:
<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>
<configuration>
<property>
<name>hadoop.tmp.dir</name>
<value>/usr/local/hadoop/tmp</value>
</property>
<property>
<name>fs.default.name</name>
<value>hdfs://localhost:8020</value>
</property>
</configuration>
然后编辑文件 /usr/local/hadoop/conf/hdfs-site.xml,并确保其看起来如下所示:
<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>
<configuration>
<property>
<name>dfs.replication</name>
<value>1</value>
</property>
<property>
<!-- specify this so that running 'hadoop namenode -format'
formats the right dir -->
<name>dfs.name.dir</name>
<value>/usr/local/hadoop/cache/hadoop/dfs/name</value>
</property>
</configuration>
最后,编辑文件 /usr/local/hadoop/conf/mapred-site.xml,并确保其看起来如下所示(你可能首先需要将 mapred-site.xml.template 复制到 mapred-site.xml):
<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>
<configuration>
<property>
<name>mapred.job.tracker</name>
<value>localhost:8021</value>
</property>
</configuration>
Hadoop 2 的伪分布式模式配置
以下说明适用于 Hadoop 2。如果你正在使用 Hadoop 1 及更早版本,请参阅上一节。
编辑文件 /usr/local/hadoop/etc/hadoop/core-site.xml,并确保其看起来如下所示:
<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>
<configuration>
<property>
<name>hadoop.tmp.dir</name>
<value>/usr/local/hadoop/tmp</value>
</property>
<property>
<name>fs.default.name</name>
<value>hdfs://localhost:8020</value>
</property>
</configuration>
然后编辑文件 /usr/local/hadoop/etc/hadoop/hdfs-site.xml,并确保其看起来如下所示:
<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>
<configuration>
<property>
<name>dfs.replication</name>
<value>1</value>
</property>
</configuration>
接下来,编辑文件 /usr/local/hadoop/etc/hadoop/mapred-site.xml,并确保其看起来如下所示:
<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>
<configuration>
<property>
<name>mapreduce.framework.name</name>
<value>yarn</value>
</property>
</configuration>
最后,编辑文件 /usr/local/hadoop/etc/hadoop/yarn-site.xml,并确保其看起来如下所示:
<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>
<configuration>
<property>
<name>yarn.nodemanager.aux-services</name>
<value>mapreduce_shuffle</value>
<description>Shuffle service that needs to be set for
Map Reduce to run.</description>
</property>
<property>
<name>yarn.log-aggregation-enable</name>
<value>true</value>
</property>
<property>
<name>yarn.log-aggregation.retain-seconds</name>
<value>2592000</value>
</property>
<property>
<name>yarn.log.server.url</name>
<value>http://0.0.0.0:19888/jobhistory/logs/</value>
</property>
<property>
<name>yarn.nodemanager.delete.debug-delay-sec</name>
<value>-1</value>
<description>Amount of time in seconds to wait before
deleting container resources.</description>
</property>
</configuration>
设置 SSH
Hadoop 使用 Secure Shell (SSH) 在伪分布式模式下远程启动进程,如 Data-Node 和 TaskTracker,即使所有内容都在单个节点上运行。如果你还没有 SSH 密钥对,可以使用以下命令创建一个:
$ ssh-keygen -b 2048 -t rsa
你需要将 .ssh/id_rsa 文件复制到 authorized_keys 文件中:
$ cp ~/.ssh/id_rsa.pub ~/.ssh/authorized_keys
你还需要运行一个 SSH 代理,这样在启动和停止 Hadoop 时就不会被要求输入密码无数次。不同的操作系统有不同的运行 SSH 代理的方式,CentOS 和其他 Red Hat 衍生版^([2]) 以及 OS X 的详细信息可以在网上找到。如果你运行的是不同的系统,Google 是你的朋友。
² 请参阅 Red Hat 部署指南中关于“配置 ssh-agent”的www.centos.org/docs/5/html/5.2/Deployment_Guide/s3-openssh-config-ssh-agent.html部分。
³ 请参阅“在 Mac OS X Leopard 中使用 SSH Agent”的www-uxsup.csx.cam.ac.uk/~aia21/osx/leopard-ssh.html。
要验证代理正在运行并且已加载您的密钥,请尝试打开到本地系统的 SSH 连接:
$ ssh 127.0.0.1
如果您被提示输入密码,则代理没有运行或没有加载您的密钥。
Java
您需要在您的系统上安装当前版本的 Java(1.6 或更高版本)。您需要确保系统路径包括您的 Java 安装的二进制目录。或者,您可以编辑 /usr/local/hadoop/conf/hadoop-env.sh,取消注释 JAVA_HOME 行,并使用您的 Java 安装位置更新值。
环境设置
为了方便起见,建议您将 Hadoop 二进制目录添加到您的路径中。以下代码显示了您可以在 ~/.bash_profile(假设您正在运行 Bash)的底部添加的内容:
HADOOP_HOME=/usr/local/hadoop
PATH=$PATH:$HADOOP_HOME/bin:$HADOOP_HOME/sbin
export PATH
格式化 HDFS
接下来您需要格式化 HDFS。本节中此后的命令假设 Hadoop 二进制目录已存在于您的路径中,如前所述。在 Hadoop 1 及更早版本上,键入
$ hadoop namenode -format
在 Hadoop 2 及更高版本上,键入
$ hdfs namenode -format
在 HDFS 格式化后,您就可以开始使用 Hadoop 了。
启动 Hadoop 1 及更早版本
在版本 1 及更早版本上,可以使用单个命令启动 Hadoop:
$ start-all.sh
运行启动脚本后,使用 jps Java 工具检查所有进程是否正在运行。您应该看到以下输出(进程 ID 除外,将不同):
$ jps
23836 JobTracker
23475 NameNode
23982 TaskTracker
23619 DataNode
24024 Jps
23756 SecondaryNameNode
如果这些进程中的任何一个没有运行,请检查日志目录 (/usr/local/hadoop/logs),以查看进程为什么无法正确启动。前面提到的每个进程都有两个可以通过名称识别的输出文件,应该检查是否有错误。
最常见的错误是前面展示的 HDFS 格式化步骤被跳过了。
启动 Hadoop 2
启动 Hadoop 版本 2 需要以下命令:
$ yarn-daemon.sh start resourcemanager
$ yarn-daemon.sh start nodemanager
$ hadoop-daemon.sh start namenode
$ hadoop-daemon.sh start datanode
$ mr-jobhistory-daemon.sh start historyserver
运行启动脚本后,使用 jps Java 工具检查所有进程是否正在运行。您应该看到以下输出,尽管顺序和进程 ID 将不同:
$ jps
32542 NameNode
1085 Jps
32131 ResourceManager
32613 DataNode
32358 NodeManager
1030 JobHistoryServer
如果这些进程中的任何一个没有运行,请检查日志目录 (/usr/local/hadoop/logs),以查看进程为什么无法正确启动。前面提到的每个进程都有两个可以通过名称识别的输出文件,应该检查是否有错误。最常见错误是前面展示的 HDFS 格式化步骤被跳过了。
在 HDFS 上为您的用户创建一个家目录
一旦 Hadoop 启动并运行,您首先想要做的是为您自己的用户创建一个主目录。如果您在 Hadoop 1 上运行,命令是
$ hadoop fs -mkdir /user/<your-linux-username>
在 Hadoop 2 上,您将运行
$ hdfs dfs -mkdir -p /user/<your-linux-username>
验证安装
以下命令可以用来测试您的 Hadoop 安装。前两个命令在 HDFS 中创建一个目录并创建一个文件:
$ hadoop fs -mkdir /tmp
$ echo "the cat sat on the mat" | hadoop fs -put - /tmp/input.txt
接下来,您想运行一个单词计数 MapReduce 作业。在 Hadoop 1 及更早版本上,运行以下命令:
$ hadoop jar /usr/local/hadoop/*-examples*.jar wordcount \
/tmp/input.txt /tmp/output
在 Hadoop 2 上,运行以下命令:
$ hadoop jar /usr/local/hadoop/share/hadoop/mapreduce/*-examples*.jar \
wordcount /tmp/input.txt /tmp/output
检查并验证 HDFS 上的 MapReduce 作业输出(输出将根据您用于作业输入的配置文件内容而有所不同):
$ hadoop fs -cat /tmp/output/part*
at 1
mat 1
on 1
sat 1
the 2
停止 Hadoop 1
要停止 Hadoop 1,请使用以下命令:
$ stop-all.sh
停止 Hadoop 2
要停止 Hadoop 2,请使用以下命令:
$ mr-jobhistory-daemon.sh stop historyserver
$ hadoop-daemon.sh stop datanode
$ hadoop-daemon.sh stop namenode
$ yarn-daemon.sh stop nodemanager
$ yarn-daemon.sh stop resourcemanager
就像启动一样,jps命令可以用来验证所有 Hadoop 进程是否已停止。
Hadoop 1.x UI 端口
Hadoop 中有许多 Web 应用程序。表 A.1 列出了它们,包括它们运行的端口和 URL(假设它们运行在本地主机上,如果您有一个伪分布式安装运行,情况就是这样)。
表 A.1. Hadoop 1.x Web 应用程序和端口
| 组件 | 默认端口 | 配置参数 | 本地 URL |
|---|---|---|---|
| MapReduce 作业跟踪器 | 50030 | mapred.job.tracker.http.address | http://127.0.0.1:50030/ |
| MapReduce 任务跟踪器 | 50060 | mapred.task.tracker.http.address | http://127.0.0.1:50060/ |
| HDFS NameNode | 50070 | dfs.http.address | http://127.0.0.1:50070/ |
| HDFS 数据节点 | 50075 | dfs.datanode.http.address | http://127.0.0.1:50075/ |
| HDFS 辅助-NameNode | 50090 | dfs.secondary.http.address | http://127.0.0.1:50090/ |
| HDFS 备份和检查点节点 | 50105 | dfs.backup.http.address | http://127.0.0.1:50105/ |
每个这些 URL 都支持以下常见路径:
-
/logs — 这显示了 hadoop.log.dir 下所有文件的列表。默认情况下,这位于每个 Hadoop 节点的$HADOOP_HOME/logs 下。
-
/logLevel — 这可以用来查看和设置 Java 包的日志级别。
-
/metrics — 这显示了 JVM 和组件级别的统计信息。它在 Hadoop 0.21 及更高版本中可用(不在 1.0、0.20.x 或更早版本中)。
-
/stacks — 这显示了守护进程中所有当前 Java 线程的堆栈转储。
Hadoop 2.x UI 端口
Hadoop 中有许多 Web 应用程序。表 A.2 列出了它们,包括它们运行的端口和 URL(假设它们运行在本地主机上,如果您有一个伪分布式安装运行,情况就是这样)。
表 A.2. Hadoop 2.x Web 应用程序和端口
| 组件 | 默认端口 | 配置参数 | 本地 URL |
|---|---|---|---|
| YARN 资源管理器 | 8088 | yarn.resourcemanager.webapp.address | http://localhost:8088/cluster |
| YARN 节点管理器 | 8042 | yarn.nodemanager.webapp.address | http://localhost:8042/node |
| MapReduce 作业历史记录 | 19888 | mapreduce.jobhistory.webapp.address | http://localhost:19888/jobhistory |
| HDFS 名称节点 | 50070 | dfs.http.address | http://127.0.0.1:50070/ |
| HDFS 数据节点 | 50075 | dfs.datanode.http.address | http://127.0.0.1:50075/ |
A.4. Flume
Flume 是一个日志收集和分发系统,可以将数据传输到大量主机上的 HDFS。它是由 Cloudera 最初开发的 Apache 项目。
第五章 包含了关于 Flume 及其使用方法的章节。
获取更多信息
表 A.3 列出了一些有用的资源,以帮助您更熟悉 Flume。
表 A.3. 有用资源
| 资源 | 网址 |
|---|---|
| Flume 主页 | flume.apache.org/ |
| Flume 用户指南 | flume.apache.org/FlumeUserGuide.html |
| Flume 入门指南 | cwiki.apache.org/confluence/display/FLUME/Getting+Started |
在 Apache Hadoop 1.x 系统上的安装
按照资源中引用的入门指南进行操作。
在 Apache Hadoop 2.x 系统上的安装
如果您试图让 Flume 1.4 与 Hadoop 2 一起工作,请按照入门指南安装 Flume。接下来,您需要从 Flume 的 lib 目录中删除 protobuf 和 guava JARs,因为它们与 Hadoop 2 捆绑的版本冲突:
$ mv ${flume_bin}/lib/{protobuf-java-2.4.1.jar,guava-10.0.1.jar} ~/
A.5. Oozie
Oozie 是一个起源于雅虎的 Apache 项目。它是一个 Hadoop 工作流引擎,用于管理数据处理活动。
获取更多信息
表 A.4 列出了一些有用的资源,以帮助您更熟悉 Oozie。
表 A.4. 有用资源
| 资源 | 网址 |
|---|---|
| Oozie 项目页面 | oozie.apache.org/ |
| Oozie 快速入门 | oozie.apache.org/docs/4.0.0/DG_QuickStart.html |
| 其他 Oozie 资源 | oozie.apache.org/docs/4.0.0/index.html |
在 Hadoop 1.x 系统上的安装
按照快速入门指南安装 Oozie。Oozie 文档中包含安装说明。
如果您正在使用 Oozie 4.4.0 并针对 Hadoop 2.2.0,您需要运行以下命令来修补您的 Maven 文件并执行构建:
cd oozie-4.0.0/
find . -name pom.xml | xargs sed -ri 's/(2.2.0\-SNAPSHOT)/2.2.0/'
mvn -DskipTests=true -P hadoop-2 clean package assembly:single
在 Hadoop 2.x 系统上的安装
不幸的是,Oozie 4.0.0 与 Hadoop 2 不兼容。为了使 Oozie 与 Hadoop 一起工作,您首先需要从项目页面下载 4.0.0 的 tarball,然后解包它。接下来,运行以下命令以更改目标 Hadoop 版本:
$ cd oozie-4.0.0/
$ find . -name pom.xml | xargs sed -ri 's/(2.2.0\-SNAPSHOT)/2.2.0/'
现在您只需要在 Maven 中针对 hadoop-2 配置文件:
$ mvn -DskipTests=true -P hadoop-2 clean package assembly:single
A.6. Sqoop
Sqoop 是一种工具,用于将数据从关系型数据库导入到 Hadoop,反之亦然。它支持任何 JDBC 兼容的数据库,并且它还提供了用于高效数据传输到 MySQL 和 PostgreSQL 的本地连接器。
第五章 包含了使用 Sqoop 进行导入和导出的详细信息。
获取更多信息
表 A.5 列出了一些有用的资源,可以帮助您更熟悉 Sqoop。
表 A.5. 有用资源
| 资源 | URL |
|---|---|
| Sqoop 项目页面 | sqoop.apache.org/ |
| Sqoop 用户指南 | sqoop.apache.org/docs/1.4.4/SqoopUserGuide.html |
| 安装 |
从项目页面下载 Sqoop tarball。选择与您的 Hadoop 安装匹配的版本,并解压 tarball。以下说明假设您正在 /usr/local 下安装:
$ sudo tar -xzf \
sqoop-<version>.bin.hadoop-<hadoop-version>.tar.gz \
-C /usr/local/
$ ln -s /usr/local/sqoop-<version> /usr/local/sqoop
Sqoop 2
本书目前涵盖 Sqoop 版本 1。在选择要下载的 tarball 时,请注意,1.99.x 版本及更高版本是 Sqoop 2 版本,因此请确保选择一个较旧的版本。
如果您计划使用 Sqoop 与 MySQL 一起使用,您需要从 dev.mysql.com/downloads/connector/j/ 下载 MySQL JDBC 驱动程序的 tarball,将其解压到一个目录中,然后将 JAR 文件复制到 Sqoop lib 目录:
$ tar -xzf mysql-connector-java-<version>.tar.gz
$ cd mysql-connector-java-<version>
$ sudo cp mysql-connector-java-<version>-bin.jar \
/usr/local/sqoop/lib
运行 Sqoop 时,您可能需要设置一些环境变量。它们列在 表 A.6 中。
表 A.6. Sqoop 环境变量
| 环境变量 | 描述 |
|---|---|
| JAVA_HOME | Java 安装所在的目录。如果您在 Red Hat 上安装了 Sun JDK,这将是指 /usr/java/latest。 |
| HADOOP_HOME | 您 Hadoop 安装所在的目录。 |
| HIVE_HOME | 仅在您计划使用 Hive 与 Sqoop 一起使用时才需要。指 Hive 安装所在的目录。 |
| HBASE_HOME | 仅在您计划使用 Sqoop 与 HBase 一起使用时才需要。指 HBase 安装所在的目录。 |
/usr/local/sqoop/bin 目录包含 Sqoop 的二进制文件。第五章 包含了展示如何使用二进制文件进行导入和导出的多种技术。
A.7. HBase
HBase 是一个基于 Google 的 BigTable 模式的实时、键/值、分布式、基于列的数据库。
获取更多信息
表 A.7 列出了一些有用的资源,可以帮助您更熟悉 HBase。
表 A.7. 有用资源
| 资源 | URL |
|---|---|
| Apache HBase 项目页面 | hbase.apache.org/ |
| Apache HBase 快速入门 | hbase.apache.org/book/quickstart.html |
| Apache HBase 参考指南 | hbase.apache.org/book/book.html |
| Cloudera 关于 HBase 的“做与不做”博客文章 | blog.cloudera.com/blog/2011/04/hbase-dos-and-donts/ |
安装
按照位于hbase.apache.org/book/quickstart.html的快速入门指南中的安装说明进行操作。
A.8. Kafka
Kafka 是由 LinkedIn 构建的一个发布/订阅消息系统。
获取更多信息
表 A.8 列出了一些有用资源,以帮助您更熟悉 Kafka。
表 A.8. 有用资源
| 资源 | URL |
|---|---|
| Kafka 项目页面 | kafka.apache.org/ |
| Kafka 文档 | kafka.apache.org/documentation.html |
| Kafka 快速入门 | kafka.apache.org/08/quickstart.html |
安装
按照快速入门指南中的安装说明进行操作。
A.9. Camus
Camus 是一个将 Kafka 中的数据导入 Hadoop 的工具。
获取更多信息
表 A.9 列出了一些有用资源,以帮助您更熟悉 Camus。
表 A.9. 有用资源
| 资源 | URL |
|---|---|
| Camus 项目页面 | github.com/linkedin/camus |
| Camus 概述 | github.com/linkedin/camus/wiki/Camus-Overview |
在 Hadoop 1 上安装
从 GitHub 的 0.8 分支下载代码,并运行以下命令来构建它:
$ mvn clean package
在 Hadoop 2 上安装
在撰写本文时,Camus 的 0.8 版本不支持 Hadoop 2。您有几个选项可以使它工作——如果您只是对 Camus 进行实验,可以从我的 GitHub 项目中下载修补过的代码版本。或者,您可以修补 Maven 构建文件。
使用我的修补过的 GitHub 项目
从 GitHub 下载我克隆并修补过的 Camus 版本,并像 Hadoop 1 版本一样构建它:
$ wget https://github.com/alexholmes/camus/archive/camus-kafka-0.8.zip
$ unzip camus-kafka-0.8.zip
$ cd camus-camus-kafka-0.8
$ mvn clean package
修补 Maven 构建文件
如果您想修补原始的 Camus 文件,可以通过查看我自己的克隆中应用的补丁来完成:mng.bz/Q8GV。
A.10. Avro
Avro 是一个提供压缩、模式演进和代码生成等功能的数据序列化系统。它可以看作是 SequenceFile 的一个更复杂的版本,具有模式演进等附加功能。
第三章 包含了关于如何在 MapReduce 以及与基本输入/输出流中使用 Avro 的详细信息。
获取更多信息
表 A.10 列出了一些有用资源,以帮助您更熟悉 Avro。
表 A.10. 有用资源
| 资源 | URL |
|---|---|
| Avro 项目页面 | avro.apache.org/ |
| Avro 问题跟踪页面 | issues.apache.org/jira/browse/AVRO |
| Cloudera 关于 Avro 的博客 | blog.cloudera.com/blog/2011/12/apache-avro-at-richrelevance/ |
| CDH 使用 Avro 的页面 | www.cloudera.com/content/cloudera-content/cloudera-docs/CDH5/5.0/CDH5-Installation-Guide/cdh5ig_avro_usage.html |
安装
Avro 是一个完整的 Apache 项目,因此您可以从 Apache 项目页面上的下载链接下载二进制文件。
A.11. Apache Thrift
Apache Thrift 实质上是 Facebook 的 Protocol Buffers 版本。它提供了非常相似的数据序列化和 RPC 功能。在这本书中,我使用它与 Elephant Bird 一起支持 MapReduce 中的 Thrift。Elephant Bird 目前支持 Thrift 版本 0.7。
获取更多信息
Thrift 文档不足,这一点在项目页面上得到了证实。表 A.11 列出了一些有用的资源,以帮助您更熟悉 Thrift。
表 A.11. 有用资源
| 资源 | 网址 |
|---|---|
| Thrift 项目页面 | thrift.apache.org/ |
| 包含 Thrift 教程的博客文章 | bit.ly/vXpZ0z |
构建 Thrift 0.7
要构建 Thrift,请下载 0.7 版本的 tarball 并提取内容。您可能需要安装一些 Thrift 依赖项:
$ sudo yum install automake libtool flex bison pkgconfig gcc-c++ \
boost-devel libevent-devel zlib-devel python-devel \
ruby-devel php53.x86_64 php53-devel.x86_64 openssl-devel
构建并安装原生和 Java/Python 库及二进制文件:
$ ./configure
$ make
$ make check
$ sudo make install
构建 Java 库。此步骤需要安装 Ant,相关说明可在 Apache Ant 手册中找到,网址为 ant.apache.org/manual/index.html:
$ cd lib/java
$ ant
将 Java JAR 文件复制到 Hadoop 的 lib 目录。以下说明适用于 CDH:
# replace the following path with your actual
# Hadoop installation directory
#
# the following is the CDH Hadoop home dir
#
export HADOOP_HOME=/usr/lib/hadoop
$ cp lib/java/libthrift.jar $HADOOP_HOME/lib/
A.12. Protocol Buffers
Protocol Buffers 是 Google 的数据序列化和远程过程调用(RPC)库,在 Google 中被广泛使用。在这本书中,我们将与 Elephant Bird 和 Rhipe 一起使用它。Elephant Bird 需要 Protocol Buffers 的 2.3.0 版本(与其他版本不兼容),而 Rhipe 只与 Protocol Buffers 版本 2.4.0 及更高版本兼容。
获取更多信息
表 A.12 列出了一些有用的资源,以帮助您更熟悉 Protocol Buffers。
表 A.12. 有用资源
| 资源 | 网址 |
|---|---|
| Protocol Buffers 项目页面 | code.google.com/p/protobuf/ |
| Protocol Buffers 开发者指南 | developers.google.com/protocol-buffers/docs/overview?csw=1 |
| 协议缓冲区下载页面,包含 2.3.0 版本的链接(与 Elephant Bird 一起使用所需) | code.google.com/p/protobuf/downloads/list |
构建 Protocol Buffers
要构建 Protocol Buffers,从 code.google.com/p/protobuf/downloads 下载 2.3 或 2.4 版本的源 tarball(2.3 版本用于 Elephant Bird,2.4 版本用于 Rhipe)并提取内容。
您需要一个 C++ 编译器,可以在 64 位 RHEL 系统上使用以下命令安装:
sudo yum install gcc-c++.x86_64
构建和安装原生库和二进制文件:
$ cd protobuf-<version>/
$ ./configure
$ make
$ make check
$ sudo make install
构建 Java 库:
$ cd java
$ mvn package install
将 Java JAR 文件复制到 Hadoop 的 lib 目录中。以下说明适用于 CDH:
# replace the following path with your actual
# Hadoop installation directory
#
# the following is the CDH Hadoop home dir
#
export HADOOP_HOME=/usr/lib/hadoop
$ cp target/protobuf-java-2.3.0.jar $HADOOP_HOME/lib/
A.13. Snappy
Snappy 是由 Google 开发的原生压缩编解码器,提供快速的压缩和解压缩时间。它不能分割(与 LZOP 压缩相反)。在本书的代码示例中,由于不需要可分割的压缩,我们将使用 Snappy,因为它的时间效率更高。
Snappy 自 1.0.2 和 2 版本以来已集成到 Apache Hadoop 发行版中。
获取更多信息
表 A.13 列出了一些有用的资源,可以帮助您更熟悉 Snappy。
表 A.13. 有用资源
| 资源 | URL |
|---|---|
| Google 的 Snappy 项目页面 | code.google.com/p/snappy/ |
| Snappy 与 Hadoop 的集成 | code.google.com/p/hadoop-snappy/ |
A.14. LZOP
LZOP 是一种压缩编解码器,可用于在 MapReduce 中支持可分割压缩。第四章有一个专门介绍如何使用 LZOP 的部分。在本节中,我们将介绍如何构建和设置您的集群以使用 LZOP。
获取更多信息
表 A.14 展示了一个有用的资源,可以帮助您更熟悉 LZOP。
表 A.14. 有用资源
| 资源 | URL |
|---|---|
| 由 Twitter 维护的 Hadoop LZO 项目 | github.com/twitter/hadoop-lzo |
构建 LZOP
以下步骤将指导您配置 LZOP 压缩。在您这样做之前,有一些事情需要考虑:
-
强烈建议您在部署生产环境中使用的相同硬件上构建库。
-
所有安装和配置步骤都需要在所有将使用 LZOP 的客户端主机以及您集群中的所有 DataNode 上执行。
-
这些步骤适用于 Apache Hadoop 发行版。如果您使用的是其他发行版,请参阅特定发行版的说明。
Twitter 的 LZO 项目页面提供了有关如何下载依赖项和构建项目的说明。请遵循项目主页上的“构建和配置”部分。
配置 Hadoop
您需要配置 Hadoop 核心来使它了解您的新压缩编解码器。将以下行添加到您的 core-site.xml 中。确保删除换行符和空格,以便在逗号之间没有空白字符:
<property>
<name>mapred.compress.map.output</name>
<value>true</value>
</property>
<property>
<name>mapred.map.output.compression.codec</name>
<value>com.hadoop.compression.lzo.LzoCodec</value>
</property>
<property>
<name>io.compression.codecs</name>
<value>org.apache.hadoop.io.compress.GzipCodec,
org.apache.hadoop.io.compress.DefaultCodec,
org.apache.hadoop.io.compress.BZip2Codec,
com.hadoop.compression.lzo.LzoCodec,
com.hadoop.compression.lzo.LzopCodec,
org.apache.hadoop.io.compress.SnappyCodec</value>
</property>
<property>
<name>io.compression.codec.lzo.class</name>
<value>com.hadoop.compression.lzo.LzoCodec</value>
</property>
io.compression.codecs 的值假设您已经安装了 Snappy 压缩编解码器。如果没有,请从值中删除 org.apache.hadoop.io.compress.SnappyCodec。
A.15. Elephant Bird
Elephant Bird 是一个提供用于处理 LZOP 压缩数据的实用程序的项目。它还提供了一个容器格式,支持在 MapReduce 中使用 Protocol Buffers 和 Thrift。
获取更多信息
表 A.15 展示了一个有用的资源,以帮助您更熟悉 Elephant Bird。
表 A.15. 有用资源
| 资源 | URL |
|---|---|
| Elephant Bird 项目页面 | github.com/kevinweil/elephant-bird |
在撰写本文时,由于使用了不兼容版本的 Protocol Buffers,Elephant Bird(4.4)的当前版本与 Hadoop 2 不兼容。为了使 Elephant Bird 在本书中工作,我不得不从 trunk 构建一个与 Hadoop 2 兼容的项目版本(4.5 版本发布时也将如此)。
A.16. Hive
Hive 是 Hadoop 之上的 SQL 接口。
获取更多信息
表 A.16 列出了一些有用的资源,以帮助您更熟悉 Hive。
表 A.16. 有用资源
| 资源 | URL |
|---|---|
| Hive 项目页面 | hive.apache.org/ |
| 入门 | cwiki.apache.org/confluence/display/Hive/GettingStarted |
安装
按照 Hive 入门指南中的安装说明进行操作。
A.17. R
R 是一个用于统计编程和图形的开源工具。
获取更多信息
表 A.17 列出了一些有用的资源,以帮助您更熟悉 R。
表 A.17. 有用资源
| 资源 | URL |
|---|---|
| R 项目页面 | www.r-project.org/ |
| R 函数搜索引擎 | rseek.org/ |
在基于 Red Hat 的系统上安装
从 Yum 安装 R 使事情变得简单:它将确定 RPM 依赖关系并为您安装它们。
访问 www.r-project.org/,点击 CRAN,选择一个靠近您的下载区域,选择 Red Hat,并选择适合您系统的版本和架构。替换以下代码中的 baseurl URL 并执行命令以将 R 镜像仓库添加到您的 Yum 配置中:
$ sudo -s
$ cat << EOF > /etc/yum.repos.d/r.repo
# R-Statistical Computing
[R]
name=R-Statistics
baseurl=http://cran.mirrors.hoobly.com/bin/linux/redhat/el5/x86_64/
enabled=1
gpgcheck=0
EOF
在 64 位系统上可以使用简单的 Yum 命令安装 R:
$ sudo yum install R.x86_64
Perl-File-Copy-Recursive RPM
在 CentOS 上,Yum 安装可能会失败,并抱怨缺少依赖项。在这种情况下,你可能需要手动安装 perl-File-Copy-Recursive RPM(对于 CentOS,你可以从 mng.bz/n4C2 获取)。
非 Red Hat 系统上的安装
访问 www.r-project.org/,点击 CRAN,选择一个靠近你的下载区域,并选择适合你系统的相应二进制文件。
A.18. RHadoop
RHadoop 是由 Revolution Analytics 开发的一个开源工具,用于将 R 与 MapReduce 集成。
获取更多信息
表 A.18 列出了一些有用的资源,帮助你更熟悉 RHadoop。
表 A.18. 有用资源
| 资源 | URL |
|---|---|
| RHadoop 项目页面 | github.com/RevolutionAnalytics/RHadoop/wiki |
| RHadoop 下载和先决条件 | github.com/RevolutionAnalytics/RHadoop/wiki/Downloads |
rmr/rhdfs 安装
你的 Hadoop 集群中的每个节点都需要以下组件:
-
R(安装说明在 A.17 节 中)。
-
许多 RHadoop 和依赖包
RHadoop 需要你设置环境变量以指向 Hadoop 二进制文件和 streaming JAR 文件。最好将其存放在你的 .bash_profile(或等效文件)中。
$ export HADOOP_CMD=/usr/local/hadoop/bin/hadoop
$ export HADOOP_STREAMING=${HADOOP_HOME}/share/hadoop/tools/lib/
hadoop-streaming-<version>.jar
我们将重点关注 rmr 和 rhdfs RHadoop 包,它们提供了与 R 的 MapReduce 和 HDFS 集成。点击 github.com/RevolutionAnalytics/RHadoop/wiki/Downloads 上的 rmr 和 rhdfs 下载链接。然后执行以下命令:
$ sudo -s
yum install -y libcurl-devel java-1.7.0-openjdk-devel
$ export HADOOP_CMD=/usr/bin/hadoop
$ R CMD javareconf
$ R
> install.packages( c('rJava'),
repos='http://cran.revolutionanalytics.com')
> install.packages( c('RJSONIO', 'itertools', 'digest', 'Rcpp','httr',
'functional','devtools', 'reshape2', 'plyr', 'caTools'),
repos='http://cran.revolutionanalytics.com')
$ R CMD INSTALL /media/psf/Home/Downloads/rhdfs_1.0.8.tar.gz
$ R CMD INSTALL /media/psf/Home/Downloads/rmr2_3.1.1.tar.gz
$ R CMD INSTALL rmr_<version>.tar.gz
$ R CMD INSTALL rhdfs_<version>.tar.gz
如果你安装 rJava 时遇到错误,你可能需要在运行 rJava 安装之前设置 JAVA_HOME 并重新配置 R:
$ sudo -s
$ export JAVA_HOME=/usr/java/latest
$ R CMD javareconf
$ R
> install.packages("rJava")
通过运行以下命令来测试 rmr 包是否正确安装——如果没有生成错误消息,这意味着你已经成功安装了 RHadoop 包。
$ R
> library(rmr2)
A.19. Mahout
Mahout 是一个预测分析项目,它为其一些算法提供了 JVM 内部和 MapReduce 实现。
获取更多信息
表 A.19 列出了一些有用的资源,帮助你更熟悉 Mahout。
表 A.19. 有用资源
| 资源 | URL |
|---|---|
| Mahout 项目页面 | mahout.apache.org/ |
| Mahout 下载 | cwiki.apache.org/confluence/display/MAHOUT/Downloads |
安装
Mahout 应该安装在可以访问你的 Hadoop 集群的节点上。Mahout 是一个客户端库,不需要在 Hadoop 集群上安装。
构建 Mahout 发行版
要使 Mahout 与 Hadoop 2 一起工作,我不得不检出代码,修改构建文件,然后构建一个发行版。第一步是检出代码:
$ git clone https://github.com/apache/mahout.git
$ cd mahout
接下来,你需要修改 pom.xml 文件并从文件中删除以下部分:
<plugin>
<inherited>true</inherited>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<version>1.4</version>
<executions>
<execution>
<goals>
<goal>sign</goal>
</goals>
</execution>
</executions>
</plugin>
最后,构建一个发行版:
$ mvn -Dhadoop2.version=2.2.0 -DskipTests -Prelease
这将在 distribution/target/mahout-distribution-1.0-SNAPSHOT.tar.gz 位置生成一个 tarball,你可以使用下一节中的说明进行安装。
安装 Mahout
Mahout 以 tarball 的形式打包。以下说明适用于大多数 Linux 操作系统。
如果你正在安装官方的 Mahout 版本,请点击 Mahout 下载页面上的“官方版本”链接并选择当前版本。如果 Mahout 1 尚未发布,而你想要与 Hadoop 2 一起使用 Mahout,请按照上一节中的说明生成 tarball。
使用以下说明安装 Mahout:
$ cd /usr/local
$ sudo tar -xzf <path-to-mahout-tarball>
$ sudo ln -s mahout-distribution-<version> mahout
$ sudo chown -R <user>:<group> /usr/local/mahout*
为了方便,值得更新你的 ~/.bash_profile,将 MAHOUT_HOME 环境变量导出到你的安装目录。以下命令展示了如何在命令行上执行此操作(相同的命令可以复制到你的 bash 配置文件中):
$ export MAHOUT_HOME=/usr/local/mahout
15 ↩︎
16 ↩︎
[a] ↩︎
[b] ↩︎
[7] ↩︎
(43) Avro 确实有一个名为 Trevni 的列式存储格式:
avro.apache.org/docs/1.7.6/trevni/spec.html↩︎52 ↩︎
(54) GitHub 源代码: ↩︎
(15) ↩︎
(16) ↩︎
(16) ↩︎
17 ↩︎
17 ↩︎
18 ↩︎
19 ↩︎
(20) ↩︎
(21) ↩︎
[3] ↩︎
(10)
fs.default.name是 Hadoop 1 中使用的已弃用属性。 ↩︎(28) 关于 Flume 和 Kafka 的更多详细信息,请参阅 https://issues.apache.org/jira/browse/FLUME-2242 ↩︎
(16) GitHub 源代码 ↩︎


浙公网安备 33010602011771号