MapReduce 应用广泛的原因之一在于它的易用性。它提供了一个因高度抽象化而变得 异常简单的编程模型。MapReduce 是在总结大量应用的共同特点的基础上抽象出来的 分布式计算框架,它适用的应用场景往往具有一个共同的特点 :任务可被分解成相互独立 的子问题。基于该特点,MapReduce 编程模型给出了其分布式编程方法,共分 5 个步骤:
1)迭代(iteration)。遍历输入数据,并将之解析成 key/value 对。
2)将输入 key/value 对映射(map)成另外一些 key/value 对。
3)依据 key 对中间数据进行分组(grouping)。 4)以组为单位对数据进行归约(reduce)。
5)迭代。将最终产生的 key/value 对保存到输出文件中。
MapReduce 将计算过程分解成以上 5 个步骤带来的最大好处是组件化与并行化。为了实现 MapReduce 编程模型,Hadoop 设计了一系列对外编程接口。用户可通过实现这些接口完成应用程序的开发。
MapReduce 编程接口体系结构
MapReduce 编程模型对外提供的编程接口体系结构如图 3-1 所示,整个编程模型位于 应用程序层和 MapReduce 执行器之间,可以分为两层。第一层是最基本的 Java API,主要 有5个可编程组件,分别是InputFormat、Mapper、Partitioner、Reducer和OutputFormat 。 Hadoop 自带了很多直接可用的 InputFormat、Partitioner 和 OutputFormat,大部分情况下, 用户只需编写 Mapper 和 Reducer 即可。第二层是工具层,位于基本 Java API 之上,主要是 为了方便用户编写复杂的 MapReduce 程序和利用其他编程语言增加 MapReduce 计算平台的 兼容性而提出来的
从 0.20.0 版本开始,Hadoop 同时提供了新旧两套 MapReduce API。新 API 在旧 API 基础上进行了封装,使得其在扩展性和易用性方面更好。
新版 API 将变量和函数封装成各种上下文(Context)类,使得 API 具有更好的易用性和扩展性。首先,函数参数列表经封装后变短,使得函数更容易使用 ;其次,当需要修改 或添加某些变量或函数时,只需修改封装后的上下文类即可,用户代码无须修改,这样保 证了向后兼容性,具有良好的扩展性。
图 3-2 展示了新版 API 中树形的 Context 类继承关系。这些 Context 各自封装了一种实 体的基本信息及对应的操作(setter 和 getter 函数),如 JobContext、TaskAttemptContext 分 别封装了 Job 和 Task 的基本信息,TaskInputOutputContext 封装了 Task 的各种输入输出操 作,MapContext 和 ReduceContext 分别封装了 Mapper 和 Reducer 对外的公共接口。
作业配置与提交
- Hadoop 配置文件介绍
在 Hadoop 中,Common、HDFS 和 MapReduce 各有对应的配置文件,用于保存对应模 块中可配置的参数。这些配置文件均为 XML 格式且由两部分构成:系统默认配置文件和管 理员自定义配置文件。其中,系统默认配置文件分别是 core-default.xml、hdfs-default.xml 和 mapred-default.xml,它们包含了所有可配置属性的默认值。而管理员自定义配置文件分 别是 core-site.xml、hdfs-site.xml 和 mapred-site.xml。它们由管理员设置,主要用于定义一些新的配置属性或者覆盖系统默认配置文件中的默认值。通常这些配置一旦确定,便不能 被修改(如果想修改,需重新启动 Hadoop)。需要注意的是,core-default.xml 和 core-site. xml 属于公共基础库的配置文件,默认情况下,Hadoop 总会优先加载它们。
MapReduce 作业配置与提交
在 MapReduce 中,每个作业由两部分组成 :应用程序和作业配置。其中,作业配置内 容包括环境配置和用户自定义配置两部分。环境配置由 Hadoop 自动添加,主要由 mapred- default.xml 和 mapred-site.xml 两个文件中的配置选项组合而成 ;用户自定义配置则由用 户自己根据作业特点个性化定制而成,比如用户可设置作业名称,以及 Mapper/Reducer、 Reduce Task 个数等。在新旧两套 API 中,作业配置接口发生了变化,首先通过一个例子感 受一下使用上的不同。
旧 API 作业配置实例:
JobConf job = new JobConf(new Configuration(), MyJob.class);
job.setJobName("myjob");
job.setMapperClass(MyJob.MyMapper.class);
job.setReducerClass(MyJob.MyReducer.class);
JobClient.runJob(job);
新 API 作业配置实例:
Configuration conf = new Configuration();
Job job = new Job(conf, "myjob ");
job.setJarByClass(MyJob.class);
job.setMapperClass(MyJob.MyMapper.class);
job.setReducerClass(MyJob.MyReducer.class);
System.exit(job.waitForCompletion(true) ? 0 : 1);
从以上两个实例可以看出,新版 API 用 Job 类代替了 JobConf 和 JobClient 两个类,这 样,仅使用一个类的同时可完成作业配置和作业提交相关功能,进一步简化了作业编写方 式.
旧 API 中的作业配置
MapReduce 配 置 模 块 代 码 结 构 如 图 3-6 所 示。 其 中,org.apache.hadoop.conf 中 的 Configuration 类是配置模块最底层的类。从图 3-6 中可以看出,该类支持以下两种基本操作
- 序列化 :序列化是将结构化数据转换成字节流,以便于传输或存储。Java 实现了自己的一套序列化框架。凡是需要支持序列化的类,均需要实现 Writable 接口。
-
迭代:为了方便遍历所有属性,它实现了 Java 开发包中的 Iterator 接口。
Configuration 类总会依次加载 core-default.xml 和 core-site.xml 两个基础配置文件,相关代码如下:
addDefaultResource("core-default.xml");
addDefaultResource("core-site.xml");
addDefaultResource 函数的参数为 XML 文件名,它能够将 XML 文件中的 name/value 加载到内存中。当连续调用多次该函数时,对于同一个配置选项,其后面的值会覆盖前面 的值。
Configuration 类中有大量针对常见数据类型的 getter/setter 函数,用于获取或者设置某 种数据类型属性的属性值。比如,对于 float 类型,提供了这样一对函数:
float getFloat(String name, float defaultValue)
void setFloat(String name, float value)
除了大量 getter/setter 函数外,Configuration 类中还有一个非常重要的函数:
void writeXml(OutputStream out)
该函数能够将当前 Configuration 对象中所有属性及属性值保存到一个 XML 文件中,以便于在节点之间传输.
JobConf 类描述了一个 MapReduce 作业运行时需要的所有信息,而 MapReduce 运行时环境正是根据 JobConf 提供的信息运行作业的。
JobConf 继承了 Configuration 类,并添加了一些设置 / 获取作业属性的 setter/getter 函数,以方便用户编写 MapReduce 程序,如设置 / 获取 Reduce Task 个数的函数为:
public int getNumReduceTasks() { return getInt("mapred.reduce.tasks", 1); }
public void setNumReduceTasks(int n) { setInt("mapred.reduce.tasks", n); }
JobConf 中添加的函数均是对 Configuration 类中函数的再次封装。由于它在这些函数 名中融入了作业属性的名字,因而更易于使用。默认情况下,JobConf 会自动加载配置文件 mapred-default.xml 和 mapred-site.xml,相 关代码如下:
static{
Configuration.addDefaultResource("mapred-default.xml");
Configuration.addDefaultResource("mapred-site.xml");
}
4. 新 API 中的作业配置
前面提到,与新 API 中的作业配置相关的类是 Job。该类同时具有作业配置和作业提交的功能.作业配置部分的类图如图 3-7 所示。Job 类继 承了一个新类 JobContext,而 Context 自身则包含一个 JobConf 类型的成员。
InputFormat 接口的设计与实现
InputFormat 主要用于描述输入数据的格式,它提供以下两个功能。
- 数据切分 :按照某个策略将输入数据切分成若干个 split,以便确定 Map Task 个数以及对应的 split。
- 为 Mapper 提供输入数据:给定某个 split,能将其解析成一个个 key/value 对。
旧版 API 的 InputFormat 解析
在旧版 API 中,InputFormat 是一个接口,它包含两种方法:
InputSplit[] getSplits(JobConf job, int numSplits) throws IOException;
RecordReader getRecordReader(InputSplit split,
JobConf job,
Reporter reporter) throws IOException;
getSplits 方法主要完成数据切分的功能,它会尝试着将输入数据切分成 numSplits 个 InputSplit。InputSplit 有以下两个特点
- 逻辑分片 :它只是在逻辑上对输入数据进行分片,并不会在磁盘上将其切分成分片 进行存储。InputSplit 只记录了分片的元数据信息,比如起始位置、长度以及所在的 节点列表等。
-
可序列化:在 Hadoop 中,对象序列化主要有两个作用:进程间通信和永久存储。此 处,InputSplit 支持序列化操作主要是为了进程间通信。作业被提交到 JobTracker 之 前,Client 会调用作业 InputFormat 中的 getSplits 函数,并将得到的 InputSplit 序列 化到文件中。这样,当作业提交到 JobTracker 端对作业初始化时,可直接读取该文 件,解析出所有 InputSplit,并创建对应的 Map Task。
getRecordReader 方 法 返 回 一 个 RecordReader 对 象,该 对 象 可 将 输 入 的 InputSplit 解析成若干个key/value对。MapReduce框架在Map Task执行过程中,会不断调用 RecordReader 对象中的方法,迭代获取 key/value 对并交给 map() 函数处理,主要代码(经 过简化)如下:
// 调用 InputSplit 的 getRecordReader 方法获取 RecordReader input ......
K1 key = input.createKey();
V1 value = input.createValue();
while (input.next(key, value)) { // 调用用户编写的 map() 函数
}
input.close();
前面分析了 InputFormat 接口的定义,接下来介绍系统自带的各种 InputFormat 实现。 为了方便用户编写 MapReduce 程序,Hadoop 自带了一些针对数据库和文件的 InputFormat 实现,具体如图 3-9 所示。通常而言,用户需要处理的数据均以文件形式存储到 HDFS 上, 所以我们重点针对文件的 InputFormat 实现进行讨论。
如图 3-9 所示,所有基于文件的 InputFormat 实现的基类是 FileInputFormat,并由此派 生出针对文本文件格式的 TextInputFormat、KeyValueTextInputFormat 和 NLineInputFormat, 针对二进制文件格式的 SequenceFileInputFormat 等。整个基于文件的 InputFormat 体 系的设计思路是,由公共基类 FileInputFormat 采用统一的方法对各种输入文件进行切 分,比如按照某个固定大小等分,而由各个派生 InputFormat 自己提供机制将进一步解析 InputSplit。对应到具体的实现是,基类 FileInputFormat 提供 getSplits 实现,而派生类提供 getRecordReader 实现。
为了帮助读者深入理解这些 InputFormat 的实现原理,我们选取 TextInputFormat 与 SequenceFileInputFormat 进行重点介绍。我们首先介绍基类 FileInputFormat 的实现。它最重要的功能是为各种 InputFormat 提 供统一的 getSplits 函数。该函数实现中最核心的两个算法是文件切分算法和 host 选择算法。
(1)文件切分算法
文件切分算法主要用于确定 InputSplit 的个数以及每个 InputSplit 对应的数据段。FileInputFormat 以文件为单位切分生成 InputSplit。对于每个文件,由以下三个属性值确定 其对应的 InputSplit 的个数。
- goalSize :它是根据用户期望的 InputSplit 数目计算出来的,即 totalSize/numSplits。 其中,totalSize 为文件总大小;numSplits 为用户设定的 Map Task 个数,默认情况下 是 1。
- minSize:InputSplit 的最小值,由配置参数 mapred.min.split.size 确定,默认是 1。
- blockSize:文件在 HDFS 中存储的 block 大小,不同文件可能不同,默认是 64 MB。
这三个参数共同决定 InputSplit 的最终大小,计算方法如下
splitSize = max{minSize, min{goalSize, blockSize}}
一旦确定 splitSize 值后,FileInputFormat 将文件依次切成大小为 splitSize 的 InputSplit, 最后剩下不足 splitSize 的数据块单独成为一个 InputSplit。
【实例】输入目录下有三个文件 file1、file2 和 file3,大小依次为 1 MB,32 MB 和 250 MB。若 blockSize 采用默认值 64 MB,则不同 minSize 和 goalSize 下,file3 切分结果 如表 3-1 所示(三种情况下,file1 与 file2 切分结果相同,均为 1 个 InputSplit)。
结合表和公式可以知道,如果想让 InputSplit 尺寸大于 block 尺寸,则直接增大配置参 数 mapred.min.split.size 即可。
(2)host 选择算法
待 InputSplit 切分方案确定后,下一步要确定每个 InputSplit 的元数据信息。这通常由四部分组成 :
InputSplit 的 host 列表选择策略直接影响到运行过程中的任务本地性.我们知道HDFS 上的文件是以 block 为单位组织的,一个大文件对应的 block 可能遍布整个 Hadoop 集群,而 InputSplit 的划分算法可能导致一个 InputSplit 对应多 个 block ,这些 block 可能位于不同节点上,这使得 Hadoop 不可能实现完全的数据本地性。为此,Hadoop 将数据本地性按照代价划分成三个等级 :node locality、rack locality 和 data center locality(Hadoop 还未实现该 locality 级别)。在进行任务调度时,会依次考虑这 3 个 节点的 locality,即优先让空闲资源处理本节点上的数据,如果节点上没有可处理的数据,则处理同一个机架上的数据,最差情况是处理其他机架上的数据(但是必须位于同一个数 据中心)。
虽然 InputSplit 对应的 block 可能位于多个节点上,但考虑到任务调度的效率,通常不 会把所有节点加到 InputSplit 的 host 列表中,而是选择包含(该 InputSplit)数据总量最大 的前几个节点(Hadoop 限制最多选择 10 个,多余的会过滤掉),以作为任务调度时判断任 务是否具有本地性的主要凭证。
举个例子,例如有一个切片P1,其对应着5个block分布在集群中的5个节点中。其中5个节点包含该切片的数据量分别是:500(slave1)、400(slave2)、100(salve3)、50(slave4)、50(slave5)(去掉重复的副本),如果将这5个节点都放到hosts列表作为任务本地性的判断标准的话,那么可能会出现这样的情况:当slave5有空闲的slot时,通过心跳包发送给jobTracker,请求任务分配。由于slave5的这个InputSplit的列表中,因此jobTracker将salve5视为该InputSplit的本地节点,创建Map task任务。很明显可以看出slave5只包含了改InputSplit 9%左右的数据量,其他91%的数据需要从其他节点中下载,本性性的效率十分低下。
为此,FileInputFormat 设计了一个简单有效的启发式算法 :首先按照 rack 包含的数据量对 rack 进行排序,然后在 rack 内部按照每个 node 包含的数据 量对 node 排序,最后取前 N 个 node 的 host 作为 InputSplit 的 host 列表,这里的 N 为 block 副本数。这样,当任务调度器调度 Task 时,只要将 Task 调度给位于 host 列表的节点,就 认为该 Task 满足本地性。
【实例】某个 Hadoop 集群的网络拓扑结构如图 3-10 所示,HDFS 中 block 副本数为 3,某个 InputSplit 包含 3 个 block,大小依次是 100、150 和 75(这里仅仅举例说明,block大小不会不一样,可以假设block为25),很容易计算,那么很容易统计出4个rack包含该InputSplit的数据量为:rack2[250]>rack1[175]>rack3[150]>rack4[75](统计时去掉同一个节点重复的数据),其rack内部节点包含的数据量为:rack2[node4(150)>node3(100)]>rack1[node1(175)>node2(100)]>rack3[node5(150)=node6(150)]>rack4[node7(75)=node8(75),那么依次选择的3个(block副本数)节点为node4、node3和node1作为改InputSplit的host列表。
从以上 host 选择算法可知,当 InputSplit 尺寸大于 block 尺寸时,Map Task 并不能实 现完全数据本地性,也就是说,总有一部分数据需要从远程节点上读取,因而可以得出以 下结论:
当使用基于 FileInputFormat 实现 InputFormat 时,为了提高 Map Task 的数据本地 性,应尽量使 InputSplit 大小与 block 大小相同。
分 析 完 FileInputFormat 实 现 方 法, 接 下 来 分 析 派 生 类 TextInputFormat 与 Sequence- FileInputFormat 的实现。
前面提到,由派生类实现 getRecordReader 函数,该函数返回一个 RecordReader 对象。 它实现了类似于迭代器的功能,将某个 InputSplit 解析成一个个 key/value 对。在具体实现 时,RecordReader 应考虑以下两点。
- 定位记录边界 :为了能够识别一条完整的记录,记录之间应该添加一些同步标识。 对于 TextInputFormat,每两条记录之间存在换行符;对于 SequenceFileInputFormat, 每隔若干条记录会添加固定长度的同步字符串。通过换行符或者同步字符串,它们 很容易定位到一个完整记录的起始位置。另外,由于 FileInputFormat 仅仅按照数据 量多少对文件进行切分,因而 InputSplit 的第一条记录和最后一条记录可能会被从 中间切开。为了解决这种记录跨越 InputSplit 的读取问题,RecordReader 规定每个 InputSplit 的第一条不完整记录划给前一个 InputSplit 处理。
- 解析 key/value :定位到一条新的记录后,需将该记录分解成 key 和 value 两部分。 对于 TextInputFormat,每一行的内容即为 value,而该行在整个文件中的偏移量为 key。对于 SequenceFileInputFormat,每条记录的格式为:
[record length] [key length] [key] [value]
其中,前两个字段分别是整条记录的长度和 key 的长度,均为 4 字节,后两个字段分 别是 key 和 value 的内容。知道每条记录的格式后,很容易解析出 key 和 value。
新版 API 的 InputFormat 解析
新版 API 的 InputFormat 类图如图 3-11 所示。新 API 与旧 API 比较,在形式上发生了 较大变化,但仔细分析,发现仅仅是对之前的一些类进行了封装.
此外,对于基类 FileInputFormat,新版 API 中有一个值得注意的改动 :InputSplit 划分 算法不再考虑用户设定的 Map Task 个数,而用 mapred.max.split.size(记为 maxSize)代替,即 InputSplit 大小的计算公式变为:
OutputFormat 接口的设计与实现
OutputFormat 主要用于描述输出数据的格式,它能够将用户提供的 key/value 对写入 特定格式的文件中。本小节将介绍 Hadoop 如何设计 OutputFormat 接口,以及一些常用的 OutputFormat 实现。
- 旧版 API 的 OutputFormat 解析
如图 3-12 所示,在旧版 API 中,OutputFormat 是一个接口,它包含两个方法:
RecordWriter getRecordWriter(FileSystem ignored, JobConf job,
String name, Progressable progress)
throws IOException;
void checkOutputSpecs(FileSystem ignored, JobConf job) throws IOException;
checkOutputSpecs 方法一般在用户作业被提交到 JobTracker 之前,由 JobClient 自动调 用,以检查输出目录是否合法。
getRecordWriter 方法返回一个 RecordWriter 类对象。该类中的方法 write 接收一个 key/value 对,并将之写入文件。在 Task 执行过程中,MapReduce 框架会将 map() 或者 reduce() 函数产生的结果传入 write 方法,主要代码(经过简化)如下。
假设用户编写的 map() 函数如下:
public void map(Text key, Text value,
OutputCollector output,
Reporter reporter) throws IOException { // 根据当前 key/value 产生新的输出 ,并输出
......
output.collect(newKey, newValue);
}
则函数 output.collect(newKey, newValue) 内部执行代码如下:
RecordWriter out = job.getOutputFormat().getRecordWriter(...);
out.write(newKey, newValue);
Hadoop 自带了很多 OutputFormat 实现,它们与 InputFormat 实现相对应,具体如图 3-13 所示。所有基于文件的 OutputFormat 实现的基类为 FileOutputFormat,并由此派生出 一些基于文本文件格式、二进制文件格式的或者多输出的实现。
为了深入分析 OutputFormat 的实现方法,我们选取比较有代表性的 FileOutputFormat 类进行分析。同介绍 InputFormat 实现的思路一样,我们先介绍基类 FileOutputFormat,再 介绍其派生类 TextOutputFormat。
基类 FileOutputFormat 需要提供所有基于文件的 OutputFormat 实现的公共功能,总结 起来,主要有以下两个:
- (1)实现 checkOutputSpecs 接口
该接口在作业运行之前被调用,默认功能是检查用户配置的输出目录是否存在,如果存在则抛出异常,以防止之前的数据被覆盖。
(2)处理 side-effect file
任务的 side-effect file 并不是任务的最终输出文件,而是具有特殊用途的任务专属文 件。它的典型应用是执行推测式任务。在 Hadoop 中,因为硬件老化、网络故障等原因,同 一个作业的某些任务执行速度可能明显慢于其他任务,这种任务会拖慢整个作业的执行速 度。为了对这种“慢任务”进行优化,Hadoop 会为之在另外一个节点上启动一个相同的 任务,该任务便被称为推测式任务,最先完成任务的计算结果便是这块数据对应的处理结 果。为防止这两个任务同时往一个输出文件中写入数据时发生写冲突,FileOutputFormat 会为每个 Task 的数据创建一个 side-effect file,并将产生的数据临时写入该文件,待 Task 完成后,再移动到最终输出目录中。这些文件的相关操作,比如创建、删除、移动等,均 由 OutputCommitter 完成。它是一个接口,Hadoop 提供了默认实现 FileOutputCommitter, 用户也可以根据自己的需求编写 OutputCommitter 实现,并通过参数 {mapred.output. committer.class} 指定。OutputCommitter 接口定义以及 FileOutputCommitter 对应的实现如 表 3-2 所示。
默认情况下,当作业成功运行完成后,会在最终结果目录 ${mapred.out.dir} 下生成 空文件 _SUCCESS。该文件主要为高层应用提供作业运行完成的标识,比如,Oozie 需要 通过检测结果目录下是否存在该文件判断作业是否运行完成
- 新版 API 的 OutputFormat 解析
如图 3-14 所示,除了接口变为抽象类外,新 API 中的 OutputFormat 增加了一个新的
方法:getOutputCommitter,以允许用户自己定制合适的 OutputCommitter 实现。
Mapper 与 Reducer 解析
- 旧版 API 的 Mapper/Reducer 解析
Mapper/Reducer 中封装了应用程序的数据处理逻辑。为了简化接口,MapReduce 要求所有存储在底层分布式文件系统上的数据均要解释成 key/value 的形式,并交给 Mapper/ Reducer 中的 map/reduce 函数处理,产生另外一些 key/value。
Mapper 与 Reducer 的类体系非常类似,我们以 Mapper 为例进行讲解。Mapper 的类图 如图 3-15 所示,包括初始化、Map 操作和清理三部分。
(1)初始化
Mapper 继承了 JobConfigurable 接口。该接口中的 configure 方法允许通过 JobConf 参数对 Mapper 进行初始化。
(2)Map 操作
MapReduce 框 架 会 通 过 InputFormat 中 RecordReader 从 InputSplit 获 取 一 个 个 key/ value 对,并交给下面的 map() 函数处理:
void map(K1 key, V1 value, OutputCollector output, Reporter reporter) throws IOException;
该函数的参数除了 key 和 value 之外,还包括 OutputCollector 和 Reporter 两个类型的 参数,分别用于输出结果和修改 Counter 值。
(3)清理
Mapper 通过继承 Closeable 接口(它又继承了 Java IO 中的 Closeable 接口)获得 close方法,用户可通过实现该方法对 Mapper 进行清理。
MapReduce 提供了很多 Mapper/Reducer 实现,但大部分功能比较简单,具体如图 3-16所示。它们对应的功能分别是:
对于一个 MapReduce 应用程序,不一定非要存在 Mapper。MapReduce 框架提供了 比 Mapper 更通用的接口 :MapRunnable,如图 3-17 所示。用户可以实现该接口以定制 Mapper 的调用方式或者自己实现 key/value 的处理逻辑,比如,Hadoop Pipes 自行实现了 MapRunnable,直接将数据通过 Socket 发送给其他进程处理。提供该接口的另外一个好处 是允许用户实现多线程 Mapper。
如 图 3-18 所 示,MapReduce 提 供 了 两 个 MapRunnable 实 现,分 别 是 MapRunner 和 MultithreadedMapRunner,其中 MapRunner 为默认实现。MultithreadedMapRunner 实现了 一种多线程的 MapRunnable。默认情况下,每个 Mapper 启动 10 个线程,通常用于非 CPU 类型的作业以提供吞吐率。
2. 新版 API 的 Mapper/Reducer 解析
从图 3-19 可知,新 API 在旧 API 基础上发生了以下几个变化:
- Mapper 由接口变为抽象类,且不再继承 JobConfigurable 和Closeable 两个接口,而 是直接在类中添加了 setup 和 cleanup 两个方法进行初始化和清理工作。
- 将参数封装到 Context 对象中,这使得接口具有良好的扩展性。
- 去掉 MapRunnable 接口,在 Mapper 中添加 run 方法,以方便用户定制 map() 函数的调用方法,run 默认实现与旧版本中 MapRunner 的 run 实现一样。
- 新 API 中 Reducer 遍历 value 的迭代器类型变为 java.lang.Iterable,使得用户可以采用“foreach”形式遍历所有 value,如下所示:
void reduce(KEYIN key, Iterable values, Context context
) throws IOException, InterruptedException {
for(VALUEIN value: values) { // 注意遍历方式 context.write((KEYOUT) key, (VALUEOUT) value);
} }
Partitioner 接口的设计与实现
Partitioner 的作用是对 Mapper 产生的中间结果进行分片,以便将同一分组的数据交给 同一个 Reducer 处理,它直接影响 Reduce 阶段的负载均衡。旧版 API 中 Partitioner 的类图如 图 3-20 所示。它继承了 JobConfigurable,可通过 configure 方法初始化。它本身只包含一 个待实现的方法 getPartition。该方法包含三个参数,均由框架自动传入,前面两个参数是 key/value,第三个参数 numPartitions 表示每个 Mapper 的分片数,也就是 Reducer 的个数。
MapReduce 提 供 了 两 个 Partitioner 实 现 :HashPartitioner 和 TotalOrderPartitioner。 其中 HashPartitioner 是默认实现,它实现了一种基于哈希值的分片方法,代码如下:
public int getPartition(K2 key, V2 value,
int numReduceTasks) {
return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks;
}
TotalOrderPartitioner 提供了一种基于区间的分片方法,通常用在数据全排序中。在 MapReduce 环境中,容易想到的全排序方案是归并排序
即在 Map 阶段,每个 Map Task 进行局部排序 ;在 Reduce 阶段,启动一个 Reduce Task 进行全局排序。由于作业只能有一 个 Reduce Task,因而 Reduce 阶段会成为作业的瓶颈。为了提高全局排序的性能和扩展性, MapReduce 提供了 TotalOrderPartitioner。它能够按照大小将数据分成若干个区间(分片), 并保证后一个区间的所有数据均大于前一个区间数据,这使得全排序的步骤如下:
步骤 1: 数据采样。在 Client 端通过采样获取分片的分割点。Hadoop 自带了几个采样 算 法, 如 IntercalSampler、RandomSampler、SplitSampler 等( 具 体 见 org.apache.hadoop. mapred.lib 包中的 InputSampler 类)。下面举例说明。
采样数据为:b,abc,abd,bcd,abcd,efg,hii,afd,rrr,mnk
经排序后得到:abc,abcd,abd,afd,b,bcd,efg,hii,mnk,rrr
如果 Reduce Task 个数为 4,则采样数据的四等分点为 abd、bcd、mnk,将这 3 个字符 串作为分割点.
步骤 2: Map 阶段。本阶段涉及两个组件,分别是 Mapper 和 Partitioner。其中,Mapper 可 采用 IdentityMapper,直接将输入数据输出,但 Partitioner 必须选用 TotalOrderPartitioner, 它将步骤 1 中获取的分割点保存到 trie 树中以便快速定位任意一个记录所在的区间,这样, 每个 Map Task 产生 R(Reduce Task 个数)个区间,且区间之间有序。
TotalOrderPartitioner 通过 trie 树查找每条记录所对应的 Reduce Task 编号。如图 3-21 所示,我们将分割点保存在深度为 2 的 trie 树中,假设输入数据中有两个字符串“abg” 和“mnz”,则字符串“abg”对应 partition1,即第 2 个 Reduce Task,字符串“mnz”对应 partition3,即第 4 个 Reduce Task。
步骤 3: Reduce 阶段。每个 Reducer 对分配到的区间数据进行局部排序,最终得到全 排序数据。
从以上步骤可以看出,基于 TotalOrderPartitioner 全排序的效率跟 key 分布规律和采样 算法有直接关系 ;key 值分布越均匀且采样越具有代表性,则 Reduce Task 负载越均衡,全 排序效率越高。
TotalOrderPartitioner 有两个典型的应用实例:TeraSort 和 HBase 批量数据导入。其中, TeraSort 是 Hadoop 自带的一个应用程序实例。它曾在 TB 级数据排序基准评估中赢得第一 名 ,而 TotalOrderPartitioner 正是从该实例中提炼出来的。HBase 是一个构建在 Hadoop 之上的 NoSQL 数据仓库。它以 Region 为单位划分数据,Region 内部数据有序(按 key 排 序),Region 之间也有序。很明显,一个 MapReduce 全排序作业的 R 个输出文件正好可对 应 HBase 的 R 个 Region。
新版 API 中的 Partitioner 类图如图 3-22 所示。它不再实现 JobConfigurable 接口。当用户需要让 Partitioner 通过某个 JobConf 对象初始化时,可自行实现 Configurable 接口,如:
Hadoop工作流
很多情况下,用户编写的作业比较复杂,相互 之间存在依赖关系,这种依赖关系可以用有向图表示,我们称之为“工作流”。本节将介绍 Hadoop 工作流的编写方法、设计原理以及实现。
JobControl 的实现原理
1. JobControl 编程实例
一个完整的贝叶斯分类算法可能需要 4 个有 依赖关系的 MapReduce 作业完成,传统的做法是 :为每个作业创建相应的 JobConf 对象, 并按照依赖关系依次(串行)提交各个作业,如下所示:
// 为 4 个作业分别创建 JobConf 对象
JobConf extractJobConf = new JobConf(ExtractJob.class);
JobConf classPriorJobConf = new JobConf(ClassPriorJob.class);
JobConf conditionalProbilityJobConf = new JobConf(ConditionalProbilityJob.class); JobConf predictJobConf = new JobConf(PredictJob.class);
...// 配置各个 JobConf
// 按照依赖关系依次提交作业
JobClient.runJob(extractJobConf);
JobClient.runJob(classPriorJobConf); JobClient.runJob(conditionalProbilityJobConf); JobClient.runJob(predictJobConf);
如果使用 JobControl,则用户只需使用 addDepending() 函数添加作业依赖关系接口, JobControl 会按照依赖关系调度各个作业,具体代码如下:
Configuration extractJobConf = new Configuration();
Configuration classPriorJobConf = new Configuration();
Configuration conditionalProbilityJobConf = new Configuration(); Configuration predictJobConf = new Configuration();
...// 设置各个 Configuration
// 创建 Job 对象。注意,JobControl 要求作业必须封装成 Job 对象
Job extractJob = new Job(extractJobConf);
Job classPriorJob = new Job(classPriorJobConf);
Job conditionalProbilityJob = new Job(conditionalProbilityJobConf); Job predictJob = new Job(predictJobConf);
// 设置依赖关系,构造一个 DAG 作业 classPriorJob.addDepending(extractJob); conditionalProbilityJob.addDepending(extractJob); predictJob.addDepending(classPriorJob); predictJob.addDepending(conditionalProbilityJob);
// 创建 JobControl 对象,由它对作业进行监控和调度
JobControl JC = new JobControl("Native Bayes"); JC.addJob(extractJob);// 把 4 个作业加入 JobControl 中 JC.addJob(classPriorJob);
JC.addJob(conditionalProbilityJob); JC.addJob(predictJob);
JC.run(); // 提交 DAG 作业
在实际运行过程中,不依赖于其他任何作业的 extractJob 会优先得到调度,一旦运行完 成,classPriorJob 和 conditionalProbilityJob 两个作业同时被调度,待它们全部运行完成后, predictJob 被调度。
对比以上两种方案,可以得到一个简单的结论:使用 JobControl 编写 DAG 作业更加简 便,且能使多个无依赖关系的作业并行运行。
JobControl 设计原理分析
JobControl 由两个类组成 :Job 和 JobControl。其中,Job 类封装了一个 MapReduce 作 业及其对应的依赖关系,主要负责监控各个依赖作业的运行状态,以此更新自己的状态, 其状态转移图如图 3-26 所示。作业刚开始处于 WAITING 状态。如果没有依赖作业或者所 有依赖作业均已运行完成,则进入 READY 状态。一旦进入 READY 状态,则作业可被提 交到 Hadoop 集群上运行,并进入 RUNNING 状态。在 RUNNING 状态下,根据作业运行 情况,可能进入 SUCCESS 或者 FAILED 状态。需要注意的是,如果一个作业的依赖作业 失败,则该作业也会失败,于是形成“多米诺骨牌效应”,后续所有作业均会失败。
JobControl 封装了一系列 MapReduce 作业及其对应的依赖关系。它将处于不同状态的 作业放入不同的哈希表中,并按照图 3-26 所示的状态转移作业,直到所有作业运行完成。 在实现的时候,JobControl 包含一个线程用于周期性地监控和更新各个作业的运行状态, 调度依赖作业运行完成的作业,提交处于 READY 状态的作业等。同时,它还提供了一些API 用于挂起、恢复和暂停该线程。