MapReduce 的Types 和 Formats

MapReduce有一种简单的数据处理模型:map和reduce的输入和输出都是key-value键值对。下面来看下各种格式的数据在该模型中的使用。

MapReduce Types

Hadoop MapReduce的map函数和reduce函数一般具有以下形式:

map:(K1, V1)   → list (K2, V2)

reduce:(K2, list(V2))   → list(K3, V3)

一般,map的输入的key和value的类型(K1, V1)和其输出的类型(K2, V2)是不相同的,但是reduce的输入必须要map的输出类型相匹配,虽然可能和reduce的输出类型不同(K3, V3)。JAVA API的镜像如下:

public class Mapper<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
  public class Context extends MapContext<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
    // ...
  }
  protected void map(KEYIN key, VALUEIN value, Context context) throws IOException, InterruptedException {
    // ...
  }
}
public class Reducer<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
  public class Context extends ReducerContext<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
    // ...
  }
  protected void reduce(KEYIN key, Iterable<VALUEIN> values, Context context) throws IOException, InterruptedException {
    // ...
  }
}
Context对象是用于发出键值对的,是通过其write方法发出的。

如果有combiner函数,因为它具有和reduce函数同样的形式(因为它实现了Reducer),除了它的输出类型为中间的key-value类型(K2, V2),所以它的输出可以传递给reduce函数:

map: (K1, V1) → list(K2, V2)
combiner: (K2, list(V2)) → list(K2, V2)
reduce: (K2, list(V2)) → list(K3, V3)

一般combiner函数和reduce函数是一样的,K2和K3一样、V2和V3一样。

partition函数的操作对象为中间的key和value类型(K2, V2),并且会返回一个分区索引。事实上,分区是仅由key决定的(与value无关):

partition: (K2, V2) → integer

或在JAVA中:

public abstract class Partitioner<KEY, VALUE> {
  public abstract int getPartition(KEY key, VALUE value, int numPartitions);
}
输入类型(input type)是由输入格式(input format)设置的,其它的类型可以通过显式调用Job的方法来设置(在老的API中是JobConf),如果没有显式设置,则中间的类型默认为LongWritable和Text。所以,如果K2和K3类型相同,就不需要调用setMapOutputKeyClass()方法,因为其类型会有setOutputKeyClass().方法设置。同样,如果V2和V3类型相同,仅调用setOutputValueClass()方法即可。

用这些方法设置中间和最终的输出类型看起来有些奇怪,为什么输出类型不能由mapper和reducer来确定?原因是Java泛型的限制:类型擦除,即在运行时,类型信息并不总是存在的,所以,Hadoop才不得不显式设置输出类型。这也意味着,可以为MapReduce Job配置一个不兼容了类型,因为在编译时是不检查改配置的。类型冲突是在job执行期间检测的,所以最好先运行小数据量的Job测试,来排除和修复类型不匹配的问题。

默认的输入格式为TextInputFormat,它的Key的类型为LongWritable,Value类型为Text。

mapper默认为Mapper 类,它输出的类型的和输入类型相同:

public class Mapper<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
  protected void map(KEYIN key, VALUEIN value, Context context) throws IOException, InterruptedException {
    context.write((KEYOUT) key, (VALUEOUT) value);
  }
}
Map是一个泛型,它可以处理任何类型的key和value。

默认的partitioner为HashPartitioner,它会计算每个key的hash值,来决定每个key属于哪个分区。每个分区有一个reduce task处理,所以job的分区数和reduce task是相等的:

public class HashPartitioner<K, V> extends Partitioner<K, V> {
  public int getPartition(K key, V value, int numReduceTasks) {
    return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks;
  }
}
Key的hash 值(即MD5值,可能为负数)通过与最大的整数Integer.MAX_VALUE(即0111111111111111)的按位与运算被转变为一个非负整数,然后和分区的个数求余,就可以得出key所属分区的索引,计算公式为:(key.hashCode() & Integer.MAX_VALUE) % numReduceTasks; 。

注:这个简单算法得到的结果可能不均匀,因为key毕竟不会那么线性连续,这时候可以自己写个测试类,计算出最优的hash算法。 假设有一个好的hash算法,那么所有的records将会被均匀的分配给reduce task,这样同一个key的所有记录就会被同一个reduce task处理。

由于reducer的数量和输入切片的数量是相等的,而输入切片的数量又是有block的大小决定的,所以并不需要设置reducer的数量。

如果选择reducer的数量?
大多数Job都会将reducer的数量设置为一个较大的数字,否则,job将会变得很慢,因为所有的中间数据都流入了一个reducer。
增加reducer的数量将缩短reduce阶段的运行时间,因为得到了更多的并行。但是如果增加的太多,将会有许多的小文件产生,这是次优。通常的做法是每个reducer运行5分钟左右,并产生至少一个块大小的输出。

reducer默认为Reducer类,也是一个泛型,它简单的将所有的输入写到输出中:

public class Reducer<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
  protected void reduce(KEYIN key, Iterable<VALUEIN> values, Context context) throws IOException, InterruptedException {
    for (VALUEIN value: values) {
      context.write((KEYOUT) key, (VALUEOUT) value);
    }
  }
}
大多数的MapReduce程序,不会始终使用用一种key或value类型,所以,你需要配置声明你要使用的类型。

Records在发送给reducer之前会被排序,key按数字顺序排序,它们从多个input文件交叉的合并到一个输出文件。

默认的输出格式(output format)为TextOutputFormat,它输出所有的records。

Input Formats

Input Splits and Records

每个输入切片就是被单个map处理的输入块,每个map处理一个split,每个split又被拆分为多个记录(record),然后,map依次处理每个record(一个键值对)。切片(split)和记录(record)都是一个逻辑概念:没有什么需要它们绑定到文件。在一个数据库的环境中,一个split可能来自表中的若干行,一个record对应表中的一行。

输入切片是由InputSplit类表示(位于org.apache.hadoop.mapreduce包下):

public abstract class InputSplit {
  public abstract long getLength() throws IOException, InterruptedException;
  public abstract String[] getLocations() throws IOException, InterruptedException;
}
InputSplit有一个以字节为单位的长度和一组存储位置(即主机名),值得注意的是split并不包含输入数据,只是对数据的一个引用。存储位置是为了让MapReduce尽可能将map task放置到离split 数据最近的地方。长度大小是为了排序split使最大的优先被处理,尽可能的减少job的运行时间(这是一个贪婪近似算法的实例)。

作为MapReduce应用的开发者,不需要直接处理InputSplit,因为它是由InputFormat创建的(InputFormat负责创建输入切片和将切片分割为record)。InputSplit的接口如下:

public abstract class InputFormat<K, V> {
  public abstract List<InputSplit> getSplits(JobContext context) throws IOException, InterruptedException;
  public abstract RecordReader<K, V> createRecordReader(InputSplit split, TaskAttemptContext context) throws IOException, InterruptedException;
}
客户端通过调用getSplits()方法来为运行的job计算切片,然后将它们发送个application master,application master根据切片(InputSplit)的存储位置来调度map task,map task 将切片传递个InputFormat的createRecordReader()方法创建切片的RecordReader对象,RecordReader就像是一个迭代器(iterator)遍历所有的record,每个record被传递给map函数以产生一个键值对。下面看下Mapper的run()方法:

public void run(Context context) throws IOException, InterruptedException {
  setup(context);
  while (context.nextKeyValue()) {
    map(context.getCurrentKey(), context.getCurrentValue(), context);
  }
  cleanup(context);
}

在运行setup()方法之后,将循环调用Context的nextKeyValue()方法(实际是委托RecordReader的同名方法),来为mapper产生key和value对象,然后通过context的getCurrentKey()方法和getCurrentValue()方法从RecordReader中获取当前的key和value值,并传递给map()方法。当RecordReader读取到最后,nextKeyValue()方法将返回false。然后运行cleanup()方法,完成。

下图为InputFormat的层次结构:


输入切片和HDFS块大小的关系:

通常,FileInputFormats的record并不完全匹配HDFS块的大小。例如,TextInputFormat的records是行,往往会超出HDFS块的边界,这并不影响程序的运行——超出的行不会被丢弃或折断,当这意味着尽可能使数据本地化的map将会执行一些远程读取的操作。

如下图例子,一个文件被拆分为多个行,行的边界并没有和HDFS块的边界对应。Split为逻辑record的边界(该例为行),可以看到第一个split包含了5行,尽管它跨越了第一和第二个块,第二个split从第6行开始:



Output Formats
Hadoop的数据输出格式和输入格式是对应的,下图为Output Formats的层次结构:








發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章