MapReduce排序实战:从底层机制到自定义排序与分组排序

📅 发布时间:2026/9/30 8:04:12
MapReduce排序实战:从底层机制到自定义排序与分组排序
1. 从实训卡壳说起你真的理解MapReduce在干什么吗如果你曾经在大数据实训平台上点开过“MapReduce排序”“自定义排序”“分组排序”这些关卡被报错信息折磨过一晚上那你应该能秒懂我接下来要聊的东西。MapReduce这个计算模型教材里通常讲得比较抽象一个map函数、一个reduce函数中间加个Shuffle完事。真到了动手写作业、跑实训、做网约车数据清洗这种综合项目时很多人会发现照着WordCount的模板改一改根本不够用。原因很简单MapReduce的核心竞争力根本不在map和reduce这两个函数本身而在于框架在两者之间自动完成的那一堆事情——分区、排序、分组、合并、归并。搞清楚这些底层机制你才能明白为什么同一个实训题有人半小时跑通有人折腾两天还在报“类型不匹配”或“输出结果不对”。这篇文章我就按自己实际折腾MapReduce的经验从底层数据流开始讲然后把排序、自定义排序、分组排序、倒排序索引这几个高频实训关卡逐个拆开最后聊一聊真实综合项目里的坑。内容不绕弯子适合正在刷实训关卡、准备面试、或者要做数据清洗类综合项目的人。2. 一张图看清MapReduce的数据流动从分片到落盘先别急着写代码。MapReduce之所以难调试就是因为数据在分布式环境下流转的路径很长中间任何一环出问题最终结果都是错的但报错信息往往只指向最后那一下。我建议你把下面这条数据链路背下来所有实训题都建立在这条链路上。一条数据从输入到输出的大致路径是输入分片InputSplit输入文件被框架切成若干个分片每个分片对应一个map任务。分片不是按行切的是按字节偏移切的所以一行很长的数据可能横跨两个分片框架会做边界处理但你在写代码时一般不用操心。RecordReader解析框架把分片解析成(key, value)对默认的TextInputFormat按行读key是行首字节偏移量value是这一行的内容。Map阶段你写的map函数处理每一对(key, value)输出新的(key, value)。环形缓冲区与Spillmap的输出不是直接写磁盘而是先写进内存里的环形缓冲区默认100MB。缓冲区写到阈值默认80%后后台线程开始把数据溢写spill到本地磁盘。关键点来了溢写之前框架会先做分区和排序。Merge归并一个map任务可能产生多个spill文件这些文件会在map结束前被归并merge成一个大文件。归并过程中如果配置了Combiner会在这里执行一次本地汇总。Shuffle拉取reduce任务启动后会从各个map节点拉取属于自己分区的数据。Reduce端Merge与排序拉过来的数据同样要经过归并和排序然后框架按key分组把同一key的所有value组成一个列表交给reduce函数。Reduce输出reduce函数处理完把结果写到HDFS上。仔细看第4步和第7步排序在map端和reduce端都会发生而且发生在reduce调用你的函数之前。这就是为什么“排序”会成为实训里的高频关卡——因为你根本没写任何排序代码但框架默认就做了而默认行为往往不满足题目要求于是你需要去改变它。下面用一个表格把map端和reduce端的行为对应起来阶段发生位置做了什么你能干预的入口分区map端spill前按key的哈希值决定进哪个reducePartitioner类排序map端spill前及merge时按key的compareTo方法升序排列key的compareTo方法合并map端merge时可选Combiner做本地汇总Combiner类拉取reduce端从map端拿属于自己分区的数据分区编号归并排序reduce端多个map输出按key归并排序同左分组reduce调用前相同key的value合并成迭代器GroupingComparator理解这张表你就理解了MapReduce整个骨架。后面所有自定义排序、分组排序的实训关卡本质上都是在改上表中某一行的默认行为。注意MapReduce的排序永远只针对key不针对value。你想让value也排个序要么把value塞进key里组成复合键要么在reduce里自己收集到列表再排序。没有第三种捷径。3. 默认排序与全套序列化机制为什么int和Text能比大小现在聊排序先要知道框架怎么判断两个key谁大谁小。Hadoop没有直接用Java的Comparable接口而是定义了一套自己的序列化体系——WritableComparable。这个接口同时继承了Writable序列化和Comparable比较所以一个对象既能在网络间传输又能直接参与排序。Hadoop内置的所有key类型比如IntWritable、LongWritable、Text、DoubleWritable都实现了compareTo方法。Hadoop的compareTo不走Java的String.compareTo反射那一套而是逐个字节比较序列化后的字节流速度很快。所以默认情况下IntWritable按数值升序排Text按字典序排。实训里常见的一个坑是你用了Text作为key去排序结果发现100排在99前面。因为Text比较的是字符串的字典序而不是数值大小。比如“100”和“99”先比第一位字符‘1’比‘9’小所以100排在99前面。如果你想按数值大小排key必须用IntWritable、LongWritable或者自己序列化一个能按数值比较的类型。再说序列化。Hadoop的序列化机制对比Java原生序列化最大的特点是紧凑和可控。Java的Serializable会把整个类结构信息、继承关系、对象图全部写进字节流又大又慢。Hadoop的Writable只要求你把业务字段按约定顺序写入DataOutput再按同样的顺序从DataInput读出来。这也是为什么实训中自定义key时write方法和readFields方法只要字段顺序不一致就立刻出现“反序列化出来的数据是乱的”这种诡异问题。再说一个很多人不重视但考试和实训常考的细节WritableComparable要求类型一致才能比较。比如你自定义了一个PairWritable在compareTo方法里跟另一个自定义类型比较时如果传入的对象不是同类型应该直接抛异常或返回一个约定值。这个检查不是可有可无的因为框架在merge排序时会频繁调用compareTo一旦类型混了比较结果不可预测排序就全乱了。我自己写自定义key时一般遵循三个原则所有字段写成private提供无参构造函数因为框架反射创建对象时必须调用无参构造函数你没写的话反序列化会抛InstantiationException。write和readFields的字段顺序严格一致。compareTo里逐字段比较决定优先级。这里有个开发时的常见习惯先用IntWritable.compareTo或Text.compareTo不要自己去拼接字符串后compareTo那样又慢又容易错。下面是框架内置类型对照表面试和实训都可能涉及Hadoop类型对应Java类型序列化字节数排序规则IntWritableint4字节数值升序LongWritablelong8字节数值升序FloatWritablefloat4字节数值升序DoubleWritabledouble8字节数值升序TextString变长UTF-8编码字典序BooleanWritableboolean1字节false trueNullWritablenull0字节无意义4. 自定义排序从能跑到能按需排关键在compareTo怎么写实训里最常见的关卡就是“自定义排序”。这类题通常不满足于简单的数值排序而是要求你按照“多个字段拼接起来的那种顺序”去排列数据。比如有个网约车综合项目要求先按城市分组组内按订单金额降序金额相同的再按完成时间升序。这在SQL里是一个ORDER BY city, amount DESC, time ASC一句话搞定但在MapReduce里你得自己构造一个复合key。这个问题的标准解法叫Composite Key模式——把多个排序字段封装成一个自定义key然后在这个key的compareTo方法里定义排序优先级。我直接给你一个完整可运行的例子。假设我们有一批出租车订单数据字段是city,amount,time。要求先按城市排在前面然后城市内金额大的排前面金额相同的时间早的排前面import org.apache.hadoop.io.WritableComparable; import java.io.DataInput; import java.io.DataOutput; import java.io.IOException; public class OrderKey implements WritableComparableOrderKey { private String city; private int amount; private long timestamp; public OrderKey() {} public OrderKey(String city, int amount, long timestamp) { this.city city; this.amount amount; this.timestamp timestamp; } Override public void write(DataOutput out) throws IOException { out.writeUTF(city); out.writeInt(amount); out.writeLong(timestamp); } Override public void readFields(DataInput in) throws IOException { this.city in.readUTF(); this.amount in.readInt(); this.timestamp in.readLong(); } Override public int compareTo(OrderKey o) { // 先比城市城市按字典序 int cityCmp this.city.compareTo(o.city); if (cityCmp ! 0) { return cityCmp; } // 再比金额金额降序 int amountCmp Integer.compare(o.amount, this.amount); if (amountCmp ! 0) { return amountCmp; } // 最后比时间时间升序 return Long.compare(this.timestamp, o.timestamp); } // 重写hashCode和equals也很重要分组时默认会用它们 Override public int hashCode() { return city.hashCode() * 31 amount; } Override public boolean equals(Object obj) { if (this obj) return true; if (obj null || getClass() ! obj.getClass()) return false; OrderKey o (OrderKey) obj; return city.equals(o.city) amount o.amount timestamp o.timestamp; } }然后map阶段的输出用这个keyimport org.apache.hadoop.io.LongWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Mapper; import java.io.IOException; public class OrderMapper extends MapperLongWritable, Text, OrderKey, Text { Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line value.toString(); // 假设每行是: city, amount, time String[] fields line.split(,); if (fields.length 3) { return; } String city fields[0].trim(); int amount Integer.parseInt(fields[1].trim()); long time Long.parseLong(fields[2].trim()); OrderKey orderKey new OrderKey(city, amount, time); context.write(orderKey, new Text(line)); } }reduce端直接原样输出就行了因为数据已经按你想要的顺序排好了。import org.apache.hadoop.io.NullWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Reducer; import java.io.IOException; public class OrderReducer extends ReducerOrderKey, Text, NullWritable, Text { Override protected void reduce(OrderKey key, IterableText values, Context context) throws IOException, InterruptedException { for (Text val : values) { context.write(NullWritable.get(), val); } } }到这里细心的读者一定会问**如果reduce收到的values是同一个订单key下的多行数据这没问题但如果我想把整个城市的所有数据作为一个分组依次输出呢**这里就暴露了默认分组的一个关键行为——Reduce阶段的分组完全按照key的相等性来而框架默认用compareTo返回0来判断两个key是否相等。所以在这个例子中OrderKey.compareTo返回0意味着三个字段全部相同只有这种情况下reduce才会把它们合并成一组values。这样会导致一个结果如果不同订单的时间戳不同哪怕城市和金额一样它们也不会在同一个reduce调用里被合并。大多数排序题只需要“有序输出”不要求“同属性值合并”所以问题不大。但如果题目要求“把同一个城市的记录输出在一起并且城市内按金额排好序”那上面的做法就不对了因为城市相同但金额不同的记录根本不会放到同一个reduce调用里。这种情况下你需要Partitioner按城市的hashCode分区保证同一个城市的数据进入同一个reduce然后reduce里自己遍历values得到有序列表。Partitioner的做法我在后面分组章节里一起说。提示自定义key只写了compareTo没重写hashCode和equals的毛病在数据量小的时候根本体现不出来一旦数据量上去了会出现极其诡异的分组错乱——两个逻辑上相等的key被分到了不同的reduce。所以只要重写了compareTo就把hashCode和equals一起写了这是好习惯。5. 分区不简单Partitioner如何决定数据去向并影响排序结果Partitioner决定每一条map输出记录被送到哪个reduce。默认实现是HashPartitioner就是key.hashCode() % reduceNum。这在很多场景下没问题但在排序类实训中往往会出岔子。举个例子。假设你有10个城市的数据设置了5个reduce。按HashPartitioner同一个城市的数据可能被分散到多个reduce里那么每个reduce的输出文件里就只有这个城市的部分数据按城市排序的目标就崩了。你需要在同一个reduce里拿到同一个城市的全部数据才能保证全局按城市分组、组内按金额排序。实现方式很简单自定义Partitionerimport org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Partitioner; public class CityPartitioner extends PartitionerOrderKey, Text { Override public int getPartition(OrderKey key, Text value, int numPartitions) { // 用城市字段的哈希值来分区保证同一城市去同一reduce return (key.getCity().hashCode() Integer.MAX_VALUE) % numPartitions; } }这里注意一个细节hashCode()可能返回负数直接取模会得到负分区号运行时会报错。所以先和Integer.MAX_VALUE做按位与会得到正数再取模。这一点网上很多代码都没写但实际实训时特别容易踩。然后在Driver里设置job.setPartitionerClass(CityPartitioner.class); job.setNumReduceTasks(5);分区设置好之后同一个城市的所有记录都会进入同一个reduce但你仍然面临一个问题在同一个reduce内部这些记录到达reduce函数时是按key有序的但reduce函数的values迭代器是“瞬时”的你无法在迭代过程中全局排序。如果你需要把整个城市数据放到一个列表里再统一处理最直接的方式是在reduce函数里先把values全部收集到一个List里然后对这个list按你的规则排序最后输出。因为reduce端本来就会把所有相同key的value拉到一个迭代器里在reduce函数里收集到内存再排序在数据量可控时是完全可行的。分区和排序的关系最后总结一句话分区管的是“哪条数据进哪个reduce”排序管的是“同一个reduce里的数据按什么顺序排列”。两个维度正交但组合起来才能实现“全局有序”的效果。实训和综合项目里最容易犯的错误是只改了排序没改分区结果每个reduce输出文件里的数据是排好序的但文件之间是无序的整体看结果完全不对。6. 分组排序GroupingComparator当“同一组”不等于“同一个key”时分组这个环节在实训里是单独的一关“第1关分组排序”这种题目卡住的人不在少数。原因是分组虽然在流程上发生在排序之后但它的判断依据不是compareTo而是单独的Comparator——默认情况下才是“compareTo相等即为一组”但你可以改。什么时候需要自定义分组典型场景是“按某个字段聚合但输出时保留其他字段最大值的完整记录”比如一组订单数据要求按城市分组每个城市输出订单金额最大的那条完整记录。如果按复合key排序的思路你把key设为(city, amount)compareTo先按city升序再按amount降序。这样同一个城市的所有记录会排在相邻位置。但默认分组的规则是“整个key都相同才分一组”所以(北京, 50000)和(北京, 30000)会被认为是两个组reduce会被调用两次你自己还得在reduce外部维护“最大值”状态非常别扭。自定义分组的做法是让分组比较器只比较city这一个字段只要城市相同就认为属于同一组。这样reduce会一次性收到同一个城市的所有记录而且这些记录到达reduce时已经按金额降序排好。你只要把values.iterator().next()取出来就是该城市金额最大的记录。实现分组比较器import org.apache.hadoop.io.WritableComparable; import org.apache.hadoop.io.WritableComparator; public class CityGroupingComparator extends WritableComparator { protected CityGroupingComparator() { super(OrderKey.class, true); } Override public int compare(WritableComparable a, WritableComparable b) { OrderKey oa (OrderKey) a; OrderKey ob (OrderKey) b; // 只比较城市字段 return oa.getCity().compareTo(ob.getCity()); } }然后在Driver里设置job.setGroupingComparatorClass(CityGroupingComparator.class);这样设置后reduce端的流程是输入记录按key的compareTo全排序先城市、再金额降序、再时间升序然后按分组比较器把key中城市相同的记录合成一组。reduce函数里取第一条就是金额最大的那条记录。这个机制很多人会跟“排序二次排序”混淆。实际上它们是同一套流程的两个开关key.compareTo管排序GroupingComparator管分组。你在实训里看到那种“第一关自定义排序、第二关自定义分组”的关卡设计本质上就是把这两个开关拆开考你。另一个高频考点是二次排序Secondary Sort。它要做的事情是按某个主键分组但组内按另一个字段有序比如按城市分组、组内按金额排序同时reduce只被调用一次就能拿到整组有序数据。实现方式就是本文前面讲的那一套——Composite Key 自定义Partitioner GroupingComparator。三者缺一不可Composite Key让排序时能按多字段顺序排列Partitioner保证同一个主键字段进同一个reduceGroupingComparator让reduce能按主键字段而不是完整key分组。很多网上教程把二次排序说得神乎其神拆开其实就是这个三个组件的组合拳。7. 倒排序索引Inverted Indexsearch引擎的新手教材级场景实训热词里出现频率最高的还有“倒排序索引”通常也叫“倒排索引”。这个概念是从搜索引擎来的搜索引擎要支持“某个词出现在哪些文档中”这种查询就预先建立一张词到文档列表的映射表查询时直接查表不用遍历全部文档。MapReduce实现倒排索引是个经典案例也是“第1关 MapReduce排序-倒排序索引”这类题目的核心。这个题目的数据一般是多个文档文件每个文件里有多行文本。目标输出形如hadoop doc1:3, doc2:1 spark doc2:2, doc3:4意思是hadoop这个词出现在doc1里3次、doc2里1次。要实现这个输出MapReduce的map阶段必须知道“当前这一行来自哪个文档”。注意默认的TextInputFormatmap的key是字节偏移量value是行内容它并不告诉你文件名称。所以第一步是从Context获取当前输入分片对应的文件路径。import org.apache.hadoop.io.LongWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Mapper; import org.apache.hadoop.mapreduce.lib.input.FileSplit; import java.io.IOException; import java.util.StringTokenizer; public class InvertedIndexMapper extends MapperLongWritable, Text, Text, Text { private Text word new Text(); private Text docName new Text(); Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { // 获取当前行所属的文件名 String fileName ((FileSplit) context.getInputSplit()).getPath().getName(); docName.set(fileName); String line value.toString(); StringTokenizer tokenizer new StringTokenizer(line); while (tokenizer.hasMoreTokens()) { word.set(tokenizer.nextToken()); context.write(word, docName); } } }map输出的是(单词, 文档名)。reduce阶段要做的事是把同一个单词的所有文档名统计起来统计每个文档里出现了多少次。import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Reducer; import java.io.IOException; import java.util.HashMap; import java.util.Map; public class InvertedIndexReducer extends ReducerText, Text, Text, Text { private Text result new Text(); Override protected void reduce(Text key, IterableText values, Context context) throws IOException, InterruptedException { MapString, Integer docCount new HashMap(); for (Text val : values) { String doc val.toString(); docCount.put(doc, docCount.getOrDefault(doc, 0) 1); } StringBuilder sb new StringBuilder(); for (Map.EntryString, Integer entry : docCount.entrySet()) { if (sb.length() 0) { sb.append(, ); } sb.append(entry.getKey()).append(:).append(entry.getValue()); } result.set(sb.toString()); context.write(key, result); } }这就是最基础的倒排索引实现。但很多实训关卡不会这么简单它会让你在reduce输出前再做一次排序比如“词频最高的文档排前面”这时就需要把MapString, Integer转成List按次数排序后再拼字符串。实现上完全可以在reduce函数内部完成不必动用MapReduce的sort机制因为这时数据量已经很小了一个单词对应的文档数不会太多。这是个经验判断不是所有排序都要用MapReduce的框架级排序数据量小的时候reduce内部直接排序更简单、更可控。在综合实训里倒排索引还会跟“数据清洗”结合。比如招聘数据清洗的实验你可能需要从一条招聘记录里提取岗位技能关键词然后建立技能到岗位id的倒排索引。这种场景的难度主要在清洗规则上MapReduce骨架跟上面的例子是一模一样的。8. Python版MapReduce与综合实训项目怎么把机制迁移到真实数据热词里出现“python版mapreduce基础实战”和“网约车大数据综合项目——基于MapReduce的数据清洗”说明很多人在脱离Java模板之后反而更有热情。如果你用的是Python首先得区分两条路一条路是直接用mrjob或者oozie这类Python框架底层仍然跑在Hadoop Streaming上另一条路是使用Hadoop Streaming把mapper和reducer写成Python脚本通过标准输入输出与框架通信。Hadoop Streaming的原理是框架把map阶段的输出序列化成key\tvalue的文本行然后启动你的Python脚本脚本从stdin逐行读取reduce阶段的输入同理框架保证同一个key的所有行在reduce脚本里是连续出现的。这个过程中默认的排序和分组行为跟Java模式完全一致——按key的字典序排序按key相等分组。所以Python版实训里有一个java版不常见的大坑key和value的拼接分隔符。默认的Hadoop Streaming输出分隔符是制表符\t如果你在map输出的value里不小心包含了制表符reduce端解析时就会错位。很多人用Python的split(\t)把自己坑了。我在招聘数据清洗项目里就遇到过招聘描述的JD文本里自带制表符清洗时忘了替换结果reduce端字段对不上后面所有逻辑全部错乱。Python版排序的经典示例是用mrjob实现排序。mrjob的FILESToCombine、SortValues这些参数能让你控制reduce端行为但它的底层仍然是Hadoop Streaming。如果你在实训平台里只能用命令行传mapper和reducer那本质上还是绕不开上面说的问题mapper脚本负责解析输入行输出key\tvalue框架按key排序reducer脚本负责聚合和输出。以网约车数据清洗为例。原始数据可能是这样的CSV行订单id,城市,上车点,下车点,行驶里程,费用,司机id,乘客id清洗任务一般是去掉字段数不对的行、去掉费用为负的异常行、把城市字段的空白统一、按城市统计订单数和总费用。映射到MapReduce里map阶段解析CSV行过滤异常输出(城市, 费用)如果有多个统计维度可以输出复合key。sort阶段默认按城市排序这就已经实现了“按城市分组”。reduce阶段对同一个城市的所有费用求和统计条数。这个案例里不需要自定义排序也不需要自定义分组用最朴素的MapReduce就能完成。它的价值在于让你明白数据清洗很多时候不是写多复杂的map和reduce函数而是把清洗规则拆解成“逐条判断”和“按维度聚合”两步前者是map后者是reduce。如果题目要求“按城市分组后组内费用降序”那就要把Composite Key、Partitioner、GroupingComparator三件套搬过来。具体做法key设为(city, cost)partitioner按city哈希grouping comparator只比较cityreduce里迭代values时由于组内已按cost降序排好第一条就是最高费用。这套逻辑在Java和Python里的差别只在于语法思想完全一致。9. 综合实训里高频踩坑复盘从结果对不上到环境问题最后这部分专门聊综合实训项目里的实际坑。我在带人做网约车清洗和招聘清洗这类项目时经常遇到“代码逻辑看着没问题但输出就是不对”的情况。下面几个排查方向建议按顺序过一遍。第一个坑输入格式与分隔符假设不一致。最典型的问题是map函数里写死了split(,)但实际数据里既有逗号又有制表符甚至同一行里某个字段内部还包含逗号比如描述文本“工作地点北京,朝阳”。清洗实训里最常见的修正方案不是用更复杂的正则而是先抽样查看原始数据。HDFS上的文件直接用hdfs dfs -cat拉几行出来看看比反复改代码高效得多。第二个坑空值与脏数据导致类型转换异常。网约车数据里经常有空字段Java里Integer.parseInt()直接抛异常reduce阶段整个task失败。很多实训平台不给你看完整日志只有一句Error: java.lang.NumberFormatException。解决方案是map阶段做了完整的字段校验凡是不符合解析规则的直接return跳过绝不让脏数据进入后续流程。注意不是continuemap函数处理的是单行数据直接return即可。第三个坑Combiner改变语义。很多人为了省流量给job设置了Combiner但Combiner不是随便用reduce类替换就可以的。Combiner的输入输出类型跟map输出类型保持一致而且在框架里“可能会被调用多次、也可能一次都不调”。如果你的reduce函数不是“可交换可结合”的聚合操作比如求平均值就不行那设置Combiner就会导致结果错误。实训里最安全的选择是除非明确知道Combiner的正确写法否则不设置。第四个坑本地跑与集群跑结果不一致。例如在本地IDE里跑MR程序默认LocalJobRunner只有1个reduce到集群上设了多个reducePartitioner的hash逻辑就开始起作用了。之前本地没问题集群上一跑结果错位十有八九是分区或分组的问题。遇到这种不一致先检查job.setNumReduceTasks和Partitioner是否匹配。第五个坑map端输出类型声明不统一。泛型声明MapperLongWritable, Text, Text, Text输出的时候却context.write(new Text(word), new IntWritable(1))编译期不一定报错运行期直接ClassCastException。这个坑在实训平台上出现频率极高因为大家都是从别的题目复制模板改的模板里的类型声明忘了同步改。第六个坑依赖版本冲突。实训项目里最常见的报错是NoSuchMethodError或ClassNotFoundException基本是Hadoop版本不一致造成的。比如用Hadoop 2.x的项目引了Hadoop 3.x的客户端依赖。排查思路很土但有效把pom或lib里所有hadoop相关依赖版本统一避免混用。综合实训项目做完后我建议你回头看一遍自己写的代码把每一处“为什么这样写”在旁边用注释标出来。特别是自定义key的compareTo逻辑、Partitioner的分区依据、GroupingComparator的比较字段这三处是最能体现你对MapReduce机制理解程度的地方。面试时如果被问到“MapReduce怎么实现二次排序”能把这几个类的职责和协作关系讲清楚的人远比背一段代码的人更有说服力。