mapreduce二次排序详解
什么是二次排序
待排序的数据具有多个字段,首先对第一个字段排序,再对第一字段相同的行按照第二字段排序,第二次排序不破坏第一次排序的结果,这个过程就称为二次排序。
如何在mapreduce中实现二次排序
mapreduce的工作原理
MR的工作原理如下图(如果看不清可右键新标签页查看):
图片部分数据参考自:https://www.bbsmax.com/A/KE5Qjg6qdL/
相关重点:
- 分区(partitioning):使得具有相同Key值的键值对可以被划分到一起,并且保证对应单个Key值的所有键值对会被发送到同一个Reducer上去处理(更准确的说,应该是在同一个Reducer上的一次reduce函数调用上被处理)
- 分组(grouping):合并对应单个Key值的键值对 (<key, value_1>, <key, value_2>, ..., <key, value_n>) 为 <key, (value_1, value_2, ..., value_n)> 并作为一次reduce函数调用的输入参数
- 总结:分区和分组两个应该结合起来看待,两者的目标都是 使得对应单个key值的所有键值对会在同一个Reducer上的一次reduce函数调用上被处理,而分区提供前提,分组实现操作。
- Shuffle:MR的核心,经常被称为MR中“magic”发生的地方
注意:在本篇文章中,笔者说的 单个key值 是比较广义的概念,比如对于复合键<0,1>和<0,0>,当我们只考虑第一个值(也就是0)的时候这两个key值就视为 相同的。
示例数据以及需求
为了方便分析整个过程,这里以如下数据作为示例,现在假设给出如下的数据,数据有两列,每列都是整形数据,中间以空格划分。
7 5
-9999 1
3 95
-9999 5
2 7
1 2
4 62
4 13
2 99
1 8
7 8888
要求输出结果为:
------------------------------------------------
-9999 1
-9999 5
------------------------------------------------
1 2
1 8
------------------------------------------------
2 7
2 99
------------------------------------------------
3 95
------------------------------------------------
4 13
4 62
------------------------------------------------
7 5
7 8888
可以看到这就是一个二次排序的过程。
思路一:简单粗暴版
假设每一行以空格划分的两个Int型数据分别为Int1、Int2,那么最简单的思路是:Mapper以每一行数据作为输入,输出键值对为<Int1, Int2>,由于我们知道在reducer运行之前,数据会先按照Key也就是Int1排序,那么Int1相同的数据就将合并到一起供同一个Reducer进行处理,那么我们便可以在reduce函数中对输入的<Int1, [Int2-list]>按照Int2升序操作即可。
现在来分析一下,在这个思路下,一个Reducer要接收一个给定Key的所有值并对其进行内部排序,如果数据量大的话,那显然这会耗尽机器的内存,对于实际运用,这是不可取的思路。
思路二:进阶版
仔细观察MR的原理图就可以发现,MR的分区、排序、分组等操作都是针对Key进行的,既然我们想要对两个字段都进行排序,那么可以将Int1和Int2组合成新的Key,原来的Value保持不变(不过这时候Value其实都不重要了,因为Key就包含了原来键值对的所有信息了,所以Value其实也可以设置为Null,这里就选择保持Value不变的方式进行操作),这样一来按照MR的原理图来看,对于新Key,
- 其分区逻辑为:只对Int1进行分区(默认的分区操作是以整个Key进行哈希操作的,这就可能把有同样Int1的组合Key发送给不同的Reducer,这显然不是我们想要的);
- 其排序逻辑为:先对Int1排序,在Int1相同的基础上对Int2排序(即是二次排序的逻辑);
- 其分组逻辑为:只对Int1进行分组(默认的分组操作是相同的Key才会被分到同一个组,这里只对Int1分组就可保证与原逻辑一致,使得Int1相同的数据可以在一次reduce函数被调用时一同被处理)。
下面开始讲解要实现的代码。
一、自定义组合Key
在MR中,所有的Key值类型都必须实现WritableComparable接口,使其支持可序列化(用于写入磁盘)和可比较(用于排序)。
- 要是可序列化的就得实现readFiels()和write()这两个序列化和反序列化函数
- 要是可比较的就得实现compareTo()函数,该函数即是排序规则的实现
代码实现如下:
public static class IntPair implements WritableComparable<IntPair> { private int first = 0; private int second = 0; public void set(int left, int right) { first = left; second = right; } public int getFirst() { return first; } public int getSecond() { return second; } @Override public void readFields(DataInput in) throws IOException { first = in.readInt(); second = in.readInt(); }
@Override public void write(DataOutput out) throws IOException { out.writeInt(first); out.writeInt(second); } @Override public int compareTo(IntPair other) { if (first != other.first) { return first < other.first ? -1 : 1; } else if (second != other.second) { return second < other.second ? -1 : 1; } else { return 0; } } }
- 如果调用job的setSortComparatorClass()设置的mapred.output.key.comparator.class对应的comparator
- 否则,使用key已经登记的comparator
- 否则,实现接口WritableComparable的compareTo()函数来操作
- 在下面第三部分讲分组逻辑的实现时就有讨论相关的问题,这里笔者认为相比自定义Key类的compareTo函数,setSortComparator设置的comparator如果是用RawComparator实现的话,那么后者是基于直接在字节上进行比较的操作(不用反序列化),而前者还需要反序列化后再进行比较,显然后者的实现效率要高一点。所以如果要追求Key值间的更高效率的比较的话,就有了后者存在的意义了。
这里再思考一个问题,上面讲到 “在MR中,所有的Key值类型都必须实现WritableComparable接口,使其支持可序列化(用于写入磁盘)和可比较(用于排序)”,但是其实java本身就实现了序列化机制,那么为什么hadoop不直接用而是要引入一个Writable接口呢?
- 既然hadoop不用,那肯定是有它的原理的(废话)。那就要来看看hadoop对序列化的需求是怎样的以及java本身的序列化到底有什么缺点。
- hadoop对序列化的需求:hadoop在集群之间在进行节点间的通讯(使用RPC调用)、存储数据的时候,都需要序列化,而且要求序列化要快,且体积要小,占用带宽要小。
- 计算量开销大,且序列化的结果体积大太,有时能达到对象大小的数倍乃至十倍。它的引用机制也会导致大文件不能分割的问题。(这显然就不适合hadoop了)
关于Writable和WritableComparable的区别可参考:Hadoop中Writable和WritableComparable区别
二、实现组合Key的分区逻辑
这里有两种实现方式,实现其一就可以实现目的。
实现方式一:自定义分区类
public static class FirstPartitioner extends Partitioner<IntPair,IntWritable>{ @Override public int getPartition(IntPair key, IntWritable value, int numPartitions) { return Math.abs(key.getFirst() % numPartitions);
}
}
由于分区只针对Int1,所以这里进行哈希时只使用到了Key.getFirst()。由于分区的标号只能是0到numPartitions-1的整数,所以getPartition()函数中就要个取模操作。同时为了保证分区的结果为正,这里最后要取最绝对值。如果不在0到numPartitions-1范围内就会报Illegal partition的错误。
这样在通过添加 job.setPartitionerClass(FirstPartitioner.class); 就可以实现设置了。
实现方式二:重载组合Key的hashCode()函数以及equals()函数
以下代码在组合Key——IntPair中实现。
@Override public int hashCode() { return first; } @Override public boolean equals(Object other) { if (other instanceof IntPair) { IntPair o = (IntPair) other; return o.first == first && o.second == second; } else { return false; } }
在Java中hashCode()函数和equals函数基本上是成对实现的,关于hashCode()函数的设计方式可参考:hashCode 方法及 equals 方法的规范,一般对于Int型数据,其哈希值就是其本来的值,所以这里直接返回first而不需要进行什么乘法或取模运算。
若选择用这种方式实现,则默认使用HashPartitioner作为分区类,这里组合键实现的hashCode()函数就在这里被调用了。
public class HashPartitioner<K2, V2> implements Partitioner<K2, V2> { public void configure(JobConf job) {} /** Use {@link Object#hashCode()} to partition. */ public int getPartition(K2 key, V2 value, int numReduceTasks) { return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks; } }
关于为什么要将key的哈希值和Int最大值相与可参考 what does that mean for Text.hashCode() & Interger.MAX_VALUE?
其实仔细观察后就可以发现上面这两种方式最后得到的结果都是相同的。
但是这里有个小问题:感觉equals函数在整个过程中貌似没有用上,就算去掉这个函数,程序还是能不报错执行,不过看网上很多关于二次排序的博客文章都提到要实现这个函数,这里我的理解是,除非我们确实需要用到Key的equals函数,否则在这篇范围内是可以不用实现它的,而网上的许多关于二次排序的文章中其实也没有用到这个函数。但是貌似hashcode和equals这两个函数经常是成对出现的,为了保险起见,重载实现一下也无妨。
注意点:默认情况下Reducer的个数只有一个,即numReduceTasks=1,分区数为1,这时候也就没有必要进行分区操作了(因为反正数据最终都是到同一个Reducer上去,分区的本意就是为了划分数据到不同的Reducer上去的),所以当Reducer的个数为1时(或者默认时),实现方式一重载的getPartition函数就不会被执行,同理实现方式二重载的hashCode()函数也不会被执行。
三、实现分组逻辑
这里也有两种实现方式,实现其一就可以实现目的。
实现方式一:继承(extends)writableComparator 类
实现方式很简单,直接比较复合键的第一个值即可。
public static class FirstGroupingComparator extends WritableComparator{ protected FirstGroupingComparator() { super(IntPair.class, true); } @Override public int compare(WritableComparable w1, WritableComparable w2) { IntPair key1 = (IntPair) w1; IntPair key2 = (IntPair) w2; int l = key1.getFirst(); int r = key2.getFirst(); return l == r ? 0 : (l < r ? -1 : 1); } }
这里出现了WritableComparator,上面组合Key继承的是WritableComparable,两者的区别可参考:Java 中 Comparable 和 Comparator 比较
实现方式二:实现(implements)RawComparator 接口
public static class FirstGroupingComparator implements RawComparator<IntPair> { @Override public int compare(byte[] b1, int s1, int l1, byte[] b2, int s2, int l2) { return WritableComparator.compareBytes(b1, s1, Integer.SIZE/8, b2, s2, Integer.SIZE/8); } @Override public int compare(IntPair o1, IntPair o2) { int l = o1.getFirst(); int r = o2.getFirst(); return l == r ? 0 : (l < r ? -1 : 1); } }
这里只要重载两个输入参数不同的compare函数即可。
- 对于第一个compare函数,其直接进行的是字节流上的比较,省去了反序列化的操作,比较效率会高一点,但是也相对难懂了一点,首先查看其在父类的参数的定义说明:
/** * Compare two objects in binary. * b1[s1:l1] is the first object, and b2[s2:l2] is the second object. * * @param b1 The first byte array. * @param s1 The position index in b1. The object under comparison's starting index. * @param l1 The length of the object in b1. * @param b2 The second byte array. * @param s2 The position index in b2. The object under comparison's starting index. * @param l2 The length of the object under comparison in b2. * @return An integer result of the comparison. */ public int compare(byte[] b1, int s1, int l1, byte[] b2, int s2, int l2);
其进一步调用了WriteableComparator.compareBytes函数(该函数实现如下)
/** Lexicographic order of binary data. */ public static int compareBytes(byte[] b1, int s1, int l1, byte[] b2, int s2, int l2) { return FastByteComparisons.compareTo(b1, s1, l1, b2, s2, l2); }
观察compare函数以及其调用的compareBytes函数,这两者的输入参数的命名都是一样的,所以它们对应的意义也应该一样,而对于传给compareBytes函数的参数 l1 和 l2 只需要设置为Integer.SIZE/8(也就是4个字节的长度,刚好是一个int型数据的字节长度数目,这样就达到了只比较IntPair的 first 部分的值的目的,从而实现分组的逻辑)。
-
PS:IntPair在class中依次定义了first和second两个int型变量,这类对象在转换成bytes数组时会依次将first、second转换为字节数据然后顺序拼接起来,因此假设现在有个IntPair变量A,其转换为bytes数组的变量为B,那么B的长度就为8,B中前4个值就对应A.first,B中后4个值就对应A.second。可以使用如下函数输出验证这一想法。
public static int bytes2Int(byte[] b, int start, int len) { int sum = 0; int end = start + len; for (int i = start; i < end; i++) { int n = ((int)b[i]) & 0xff; n <<= (--len) * 8; sum += n; } return sum; }
-
- 对于第二个compare函数,其直接比较IntPair的第一个值,思路简单,但是有个问题时,这个函数必须重载但是实际上在二次排序中并没有运行该函数,不知道重载了有什么用。
关于以上两种方式的总结
首先,这两种方式要起效通过 job.setGroupingComparatorClass(FirstGroupingComparator.class); 即可。
回顾下GroupingComparator的作用,其在Reducer中被使用,对Key值相同的数据归类成同个组,Key值不同的数组归类到不同的组,而同个组的数据会在一次reduce函数调用中一次性处理,所以其只需要涉及到键值间的比较而不用涉及到键值的序列化等操作。
再回顾上面的两种实现方式,GroupingComparator皆可通过继承writableComparable类也可以通过实现RawComparator接口来实现,而前者writableComparable也是后者RawComparator接口的一个实现,前者相对来说多了键值对的序列化功能,而再进行键值间的比较时,前者需要先反序列化后才可以进行比较,而后者直接在字节数组层面上进行比较,显然后者的效率要高点,所以在实践中如果追求效率的话,用RawComparator实现GroupingComparator效率相对会比较高。
四、实现Mapper
/** * Read two integers from each line and generate a key, value pair * as ((left, right), right). */ public static class MapClass extends Mapper<LongWritable, Text, IntPair, IntWritable> { private final IntPair key = new IntPair(); private final IntWritable value = new IntWritable(); @Override public void map(LongWritable inKey, Text inValue, Context context) throws IOException, InterruptedException { StringTokenizer itr = new StringTokenizer(inValue.toString()); int left = 0; int right = 0; if (itr.hasMoreTokens()) { left = Integer.parseInt(itr.nextToken()); if (itr.hasMoreTokens()) { right = Integer.parseInt(itr.nextToken()); } key.set(left, right); value.set(right); context.write(key, value); } } }
实现思路很简单,就是读取每一行的数据,得到其中的两个int数据left和right,进一步得到键值对<(left, right), right>。
但是实现起来还是有一点要注意的:Mapper的map函数在实践中会被调用很多次,所以一些能够声明在map函数之外的变量就不要声明在map函数里面,比如这里的private final的int型变量key和value就声明在map函数之外,在map函数调用的过程中它们每一次都设置新的值,而新的值通过context.write函数执行之后这两个变量又可以复用了。而如果声明在map函数里面,则可能会存在频繁地调用map函数处理每一行输入的数据,这个过程中不断地new变量不断地delete变量,效率上有点影响的。
五、实现Reducer
/** * A reducer class that just emits the sum of the input values. */ public static class Reduce extends Reducer<IntPair, IntWritable, Text, IntWritable> { private static final Text SEPARATOR = new Text("------------------------------------------------"); private final Text first = new Text(); @Override public void reduce(IntPair key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException { context.write(SEPARATOR, null); first.set(Integer.toString(key.getFirst())); for(IntWritable value: values) { context.write(first, value); } } }
经过了Mapper的map、分区排序、合并等操作,到达Reducer的时候其实已经是二次排序完成了,所以这里就只需要将数据输出出来即可。
六、全部代码
package test.linzch3; import java.io.DataInput; import java.io.DataOutput; import java.io.IOException; import java.util.Comparator; import java.util.StringTokenizer; import java.util.function.Function; import java.util.function.ToDoubleFunction; import java.util.function.ToIntFunction; import java.util.function.ToLongFunction; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; import org.apache.hadoop.io.IntWritable; import org.apache.hadoop.io.LongWritable; import org.apache.hadoop.io.RawComparator; import org.apache.hadoop.io.Text; import org.apache.hadoop.io.WritableComparable; import org.apache.hadoop.io.WritableComparator; import org.apache.hadoop.mapreduce.lib.input.FileInputFormat; import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat; import org.apache.hadoop.mapreduce.Job; import org.apache.hadoop.mapreduce.MRJobConfig; import org.apache.hadoop.mapreduce.Mapper; import org.apache.hadoop.mapreduce.Partitioner; import org.apache.hadoop.mapreduce.Reducer; import org.apache.hadoop.util.GenericOptionsParser; /** * This is an example Hadoop Map/Reduce application. * It reads the text input files that must contain two integers per a line. * The output is sorted by the first and second number and grouped on the * first number. * * To run: bin/hadoop jar build/hadoop-examples.jar secondarysort * <i>in-dir</i> <i>out-dir</i> */ public class SecondarySort { /** * Define a pair of integers that are writable. * They are serialized in a byte comparable format. */ public static class IntPair implements WritableComparable<IntPair> { private int first = 0; private int second = 0; public void set(int left, int right) { first = left; second = right; } public int getFirst() { return first; } public int getSecond() { return second; } @Override public void readFields(DataInput in) throws IOException { first = in.readInt(); second = in.readInt(); } @Override public void write(DataOutput out) throws IOException { out.writeInt(first); out.writeInt(second); } @Override public int compareTo(IntPair other) { if (first != other.first) { return first < other.first ? -1 : 1; } else if (second != other.second) { return second < other.second ? -1 : 1; } else { return 0; } } // @Override // public int hashCode() { // return first; // } // // @Override // public boolean equals(Object other) { // if (other instanceof IntPair) { // IntPair o = (IntPair) other; // return o.first == first && o.second == second; // } else { // return false; // } // } } /** * Partition based on the first part of the pair. */ public static class FirstPartitioner extends Partitioner<IntPair,IntWritable>{ @Override public int getPartition(IntPair key, IntWritable value, int numPartitions) { return Math.abs(key.getFirst() % numPartitions); } } /** * Compare only the first part of the pair, so that reduce is called once * for each value of the first part. */ // public static class FirstGroupingComparator extends WritableComparator{ // protected FirstGroupingComparator() // { // super(IntPair.class, true); // } // @Override // public int compare(WritableComparable w1, WritableComparable w2) // { // IntPair key1 = (IntPair) w1; // IntPair key2 = (IntPair) w2; // int l = key1.getFirst(); // int r = key2.getFirst(); // return l == r ? 0 : (l < r ? -1 : 1); // } // } public static class FirstGroupingComparator implements RawComparator<IntPair> { @Override public int compare(byte[] b1, int s1, int l1, byte[] b2, int s2, int l2) { return WritableComparator.compareBytes(b1, s1, Integer.SIZE/8, b2, s2, Integer.SIZE/8); } @Override public int compare(IntPair o1, IntPair o2) { int l = o1.getFirst(); int r = o2.getFirst(); return l == r ? 0 : (l < r ? -1 : 1); } } /** * Read two integers from each line and generate a key, value pair * as ((left, right), right). */ public static class MapClass extends Mapper<LongWritable, Text, IntPair, IntWritable> { private final IntPair key = new IntPair(); private final IntWritable value = new IntWritable(); @Override public void map(LongWritable inKey, Text inValue, Context context) throws IOException, InterruptedException { StringTokenizer itr = new StringTokenizer(inValue.toString()); int left = 0; int right = 0; if (itr.hasMoreTokens()) { left = Integer.parseInt(itr.nextToken()); if (itr.hasMoreTokens()) { right = Integer.parseInt(itr.nextToken()); } key.set(left, right); value.set(right); context.write(key, value); } } } /** * A reducer class that just emits the sum of the input values. */ public static class Reduce extends Reducer<IntPair, IntWritable, Text, IntWritable> { private static final Text SEPARATOR = new Text("------------------------------------------------"); private final Text first = new Text(); @Override public void reduce(IntPair key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException { context.write(SEPARATOR, null); first.set(Integer.toString(key.getFirst())); for(IntWritable value: values) { context.write(first, value); } } } public static void main(String[] args) throws Exception { Configuration conf = new Configuration(); String[] otherArgs = new GenericOptionsParser(conf, args).getRemainingArgs(); if (otherArgs.length != 2) { System.err.println("Usage: secondarysort <in> <out>"); System.exit(2); } Job job = Job.getInstance(conf, "secondary sort"); job.setJarByClass(SecondarySort.class); job.setMapperClass(MapClass.class); job.setReducerClass(Reduce.class); // group and partition by the first int in the pair job.setPartitionerClass(FirstPartitioner.class); job.setGroupingComparatorClass(FirstGroupingComparator.class); // the map output is IntPair, IntWritable job.setMapOutputKeyClass(IntPair.class); job.setMapOutputValueClass(IntWritable.class); // the reduce output is Text, IntWritable job.setOutputKeyClass(Text.class); job.setOutputValueClass(IntWritable.class); FileInputFormat.addInputPath(job, new Path(otherArgs[0])); FileOutputFormat.setOutputPath(job, new Path(otherArgs[1])); /*delete the output directory if exists*/ Path out = new Path(otherArgs[otherArgs.length - 1]); FileSystem fileSystem = FileSystem.get(conf); if (fileSystem.exists(out)) { fileSystem.delete(out, true); } System.exit(job.waitForCompletion(true) ? 0 : 1); } }