1. 先搞清楚 ReduceJoin 到底在解决什么问题
1.1 从“为什么不能直接用 SQL 写关联”聊起
刚接触 Hadoop 那阵子,我最不适应的一件事就是:明明一个LEFT JOIN就能解决的需求,到了 MapReduce 里得自己写两个 Mapper、一个 Reducer,还要自定义 Bean、搞序列化、处理笛卡尔积。很多人第一次看 ReduceJoin 的代码会觉得绕,其实核心就一句话——MapReduce 没有元数据,也没有优化的查询引擎,它不知道哪张表小、哪张表大,更不知道哪些字段能对上,所以关联这件事必须由你自己在“分组”这个天然动作上做文章。
理解这一点很关键。关系型数据库做关联,靠的是优化器判断驱动表和被驱动表,然后走嵌套循环或者哈希连接。而 MapReduce 的计算模型本质是“按 key 分组”,Shuffle 阶段会把所有相同 key 的记录汇聚到同一个 Reducer 实例。这恰好就是关联需要的前置条件:只要是同一个关联键,不管是来自订单表还是商品表,Shuffle 之后必然出现在同一个 reduce 调用里。ReduceJoin 就是把这个特性用足——两端数据都按关联键发出去,让框架帮你把“能对上的记录”物理聚拢,剩下的拼装工作自己写。
所以 ReduceJoin 不是一种“取巧”,而是 MapReduce 模型下最直白、最通用的关联实现方式。它不依赖数据规模假设,不管两张表谁大谁小都能跑,逻辑上一定能得到正确结果。代价也很明显:所有数据都要过一遍网络 Shuffle,这是后面聊优化的出发点。
这篇内容适合三类人看:正在啃 MapReduce 编程模型、需要交一份多表关联作业的;工作里维护着老 Hadoop 批处理任务、想搞懂别人代码为什么这么写的;以及准备从 MapReduce 往 Spark、Flink 迁移、想弄明白旧逻辑边界的。下面我按“设计思路—实现细节—实操过程—踩坑排查—优化边界”这条线拆开讲,代码是可以直接抄去改的。
1.2 ReduceJoin 的通用套路:打标签、共键分发、分组拼装
整个 ReduceJoin 的实现骨架,我习惯概括成三步,这三步在任何业务场景下都成立。
第一步是打标签。因为两张表的字段结构不同,你要在 Mapper 端把每条记录包装成一个统一的对象,并额外加一个字段标明来源。这个标签字段是整个方案的基石,Reducer 靠它区分“这条是主表记录”还是“这条是从表记录”。
第二步是共键分发。两个 Mapper 输出的 key 必须是同一个关联键,类型也得一致(一般用Text)。只有 key 相同,Shuffle 才会把它们归到同一个 reduce 调用里。这里有个容易忽略的点:key 的类型决定了排序和分区,如果一边用Text一边用IntWritable,作业会直接报类型不匹配,连跑都跑不起来。
第三步是分组拼装。Reducer 拿到一个 key 和这个 key 下所有的 value 时,先把它们按标签拆成两个集合,然后做笛卡尔积。主表侧一般是多条(一个商品被多笔订单引用),从表侧通常只有一条(商品名唯一),拼装就是遍历主表集合,把从表侧的字段填进去。
用一个具体场景说更清楚:订单表每条记录是“订单号、商品 ID、数量”,商品表每条记录是“商品 ID、商品名”。关联键就是商品 ID。Reducer 拿到1001这个 key,发现下面有三条订单记录和一条商品记录,那就输出三条“订单号 + 商品名 + 数量”的结果。如果某个商品 ID 下面只有订单没有商品,说明商品信息缺失,要不要输出取决于业务(内连接丢弃,左连接保留并置空)。
1.3 和 MapJoin、SemiJoin 的取舍关系
选型这件事,我给自己的判断标准很简单:看从表能不能塞进内存。
ReduceJoin 的优势是“无脑正确”,不需要任何数据规模假设,代码一次写完全场景通用。缺点是全部数据参与 Shuffle,网络和磁盘 IO 开销大,而且如果关联键分布不均,单个 Reducer 会被拖死(也就是常说的数据倾斜)。
MapJoin 则是把小的那张表在作业启动前加载到每个 Mapper 的内存里,大表记录流过时直接查内存完成关联,Shuffle 阶段就只剩大表数据量,甚至必要时可以完全去掉 Reducer 阶段。它的前提是“小表足够小”,能放进堆内存,几十兆到一两百兆都没问题,但要是小表本身有几 G,那就等着 OOM。
SemiJoin 可以理解为 MapJoin 的变体,它不搬全表,只把关联键集合搬到内存里,Mapper 阶段直接过滤掉对不上的记录,起到预筛的作用。适合“只关心能不能对上、不关心从表字段”的场景。
我在教学和实际跑批里都不建议一上来就上 MapJoin。最稳的路径是先用 ReduceJoin 把逻辑跑通,验证结果正确,然后再根据数据量和倾斜情况决定要不要换成 MapJoin。因为 MapJoin 一旦踩到内存问题,报错信息往往很难定位,而 ReduceJoin 的失败原因相对直白。很多团队搞反了顺序,先追性能后追正确,最后两头都不踏实。
2. 数据模型与整体设计拆解
2.1 模拟数据集的字段与规模设计
为了让代码能直接跑,我自己准备一份小而全的数据集,字段用制表符分隔。订单表三列:订单 ID、商品 ID、数量。商品表两列:商品 ID、商品名。故意留两个“坑”进去:一是有一笔订单的商品 ID 在商品表里不存在,用来验证缺失商品信息的处理方式;二是让某个商品 ID 被多笔订单重复引用,用来验证一对多的拼装逻辑。
数据规模上,练习阶段几百行就够,但我会故意把某一个商品 ID 的订单量堆到几百条,模拟“热点 key”。这不是为了给自己找麻烦,而是让你早点看到 ReduceJoin 在什么情况下会变慢,这比事后在生产上撞墙便宜得多。
统一分隔符这件事值得单独提醒。MapReduce 里用split("\t")比split(",")安全,因为商品名里出现逗号的概率远比出现制表符高。如果数据源里确实用逗号,那要么在预处理阶段统一替换,要么老老实实按 CSV 规则解析,别指望一个split走天下。
2.2 自定义 Bean 的设计要点
ReduceJoin 几乎所有版本都会写一个自定义 Bean,实现Writable接口。这个 Bean 同时承载了两张表的字段,用不到的字段给默认值。设计上有几个点我认为必须提前定好,不然写到一半会返工。
字段顺序必须在write和readFields里严格一一对应。这是最容易出 bug 的地方,一旦写反,程序不报错,但输出的数据会串列,而且在不同机器上表现可能还不一样。我的习惯是把写和读两个方法紧挨着放,字段顺序在注释里标好序号,改一个必须改另一个。
不要用writeUTF写可能为 null 的字段。DataOutput.writeUTF遇到 null 直接抛空指针,而readFields的时候如果上游没写,读出来的字段就是空的。这会导致一种很诡异的现象:程序跑到一半崩了,堆栈指向序列化方法,你去看业务逻辑完全没问题。解决办法是所有字符串字段在 set 之前做一次空值兜底,给""。
数量这类数值字段,用writeInt而不是writeUTF,能省空间也避免解析开销。但要注意readFields顺序必须同步,不能一边writeInt一边readUTF。
还有一个设计取舍:Bean 要不要实现WritableComparable?在 ReduceJoin 里,key 用的是关联键(Text),Bean 只作为 value,所以只需要Writable就够了,不需要compareTo。只有当你想用 Bean 本身做 key 参与排序时才需要WritableComparable。我看到不少教程直接给 Bean 加compareTo,这在当前场景是多余的,加多了反而容易在比较逻辑里写错。
2.3 作业拓扑:两个 Mapper、一个 Reducer 的装配
整个作业的拓扑其实很简单:两个 Mapper 并行读取两份输入,输出 key 类型和 value 类型完全一致(Text和自定义 Bean),然后汇到同一个 Reducer。Reducer 输出时 Bean 做 key、NullWritable做 value,因为最终结果不需要再按 key 分组。
这里有一个约束必须理解:两个 Mapper 的输出类型必须完全一致,且和 Reducer 的输入类型匹配。Job上只能设置一套mapOutputKeyClass、mapOutputValueClass,所以你不能让一个 Mapper 输出Text, TableBean,另一个输出Text, AnotherBean,那样 Reducer 拿到的类型就对不上了。这也是为什么所有共性字段必须塞进同一个 Bean 里。
Reducer 输出用TableBean当 key 有什么后果?会触发一次额外排序,因为 reduce 输出会走OutputFormat之前按 key 排序(实际上 reduce 输出的 key 不参与排序,只有 map 输出到 reduce 输入之间才排序)。但为了打印好看,我还是倾向于把 Bean 放 key、NullWritable放 value,toString里拼好输出格式,读结果文件的时候一目了然。这个选择纯属便利性考虑,不影响正确性。
3. 核心实现细节逐个拆解
3.1 Mapper 侧:判断数据来源的两种写法
如何让同一个 Mapper 知道“我读到的是订单还是商品”?这个问题有两种常见解法,各有适用场景。
第一种是看文件名。在 Mapper 里通过context.getInputSplit()强转成FileSplit,拿到getPath().getName(),判断文件名里是否包含某个前缀,比如以order开头就当订单处理,以product开头就当商品处理。这种做法的好处是一个 Mapper 类搞定所有输入,代码量少。坏处也明显:逻辑和数据文件命名强绑定,文件一改名整个作业就出错,而且不同输入格式(比如有的输入是压缩包)拿到的 split 类型可能不是FileSplit,强转会抛异常。
第二种是用MultipleInputs配置不同 Mapper。Driver 里给每个输入路径单独指定 Mapper 类,两个 Mapper 各管一摊,逻辑彻底解耦。这是我在正式代码里更推荐的写法,因为职责清晰,加第三种输入只要再加一个 Mapper,不用动已有代码。缺点是类数量变多,对新手来说文件跳来跳去不太直观。
两种写法我都写过,结论是:练手和 demo 用文件名判断更快,正式任务用MultipleInputs。如果非要用文件名判断,一定要做两件事:一是判空前先判 split 类型,二是文件名匹配用startsWith而不是contains,避免路径里其它目录名误命中。
3.2 标签字段与字段默认值处理
标签字段我习惯用字符串"0"和"1",而不是布尔或者枚举。原因很实际:序列化时字符串最省心,writeUTF一行搞定,不会出现类型判断失误;而且日志里打印出来直观,调试时一眼能看出这条记录来自哪边。
不过用字符串比较有个小坑:"0".equals(bean.getFlag())比bean.getFlag().equals("0")安全,因为后者在 flag 为 null 时会抛空指针。这个细节看着无关紧要,但在 Reduce 端遍历几百条记录时一旦有一条 flag 为 null,整个 reduce 调用就崩了,而且报错位置离真正的出错点很远。
字段默认值这块,我的做法是每个 set 方法调用时都把不相关的字段显式置空或者置零。比如订单 Mapper 里,商品名设置成"";商品 Mapper 里,订单号和数量设置成""和0。为什么要显式设?因为如果你依赖 Java 的默认初始化,Bean 里字符串字段是 null,writeUTF立刻就炸。显式赋值虽然啰嗦,但把问题挡在了源头。
还有一点,数值字段用Integer.parseInt解析之前,最好trim()一下。从 Windows 环境导出的文本文件经常带\r,而\r不在split("\t")的分隔符里,会粘在最后一个字段末尾,导致解析失败。这个问题我遇到过不止一次,报错是NumberFormatException,看着像数据脏,其实是换行符差异。
3.3 Reducer 侧的拆包与笛卡尔积拼装
Reducer 是整个逻辑的核心,也是最容易写错的地方。标准写法是:遍历Iterable<TableBean>,按 flag 分成两个容器,然后遍历主表容器,把从表字段填进去输出。
第一个必须注意的点是对象拷贝。MapReduce 的 Reduce 端为了节省内存,传入的那个TableBean实例是复用的——迭代器每次 next 都往同一个对象里填新值。如果你直接把对象引用塞进ArrayList,循环结束后你会发现列表里所有元素内容一样,全是最后一条记录。这个坑几乎人人踩一次,正确做法是每次 new 一个 Bean,把字段拷进去再放进列表。拷贝可以手写 set 逐个赋,也可以用反射工具类,看项目习惯。
第二个点是容器清空。每次进入 reduce 方法,List和“从表对象”都必须先清掉上一轮的数据。因为 Reducer 实例是复用的,同一个 reduce 任务里会连续处理几百上千个 key。如果只在类成员变量声明时初始化一次,上一轮的商品信息会漏到下一个 key 的输出里,输出结果会出现“张冠李戴”的字段。这种错误最阴险——不报错、不崩溃,就是数据错,而且错得没有规律。
第三个点是拼装条件。从表侧通常只保留一条记录,用一个变量存着就够了,不用 List。遍历时如果发现从表侧有多条,说明从表本身对关联键不唯一,要么业务上允许(那种情况得做两层循环),要么就是数据有问题。所以我会在代码里加个判断,从表侧记录数大于 1 的时候打一条计数器或者日志,方便事后核查。
最后输出前一定要判空:如果某个 key 下面只有主表记录、没有从表记录,从表变量就是 null,此时直接遍历主表输出会把商品名写成 null,或者干脆 NPE。业务上怎么处理取决于需求,我一般按左连接处理——从表信息置空兜底,主表记录照常输出。
3.4 Driver 的多输入配置
Driver 里设置输入路径的写法,直接决定了上面选哪种 Mapper 方案。用MultipleInputs的版本大概是这样:
MultipleInputs.addInputPath(job, new Path(args[0]), TextInputFormat.class, OrderMapper.class); MultipleInputs.addInputPath(job, new Path(args[1]), TextInputFormat.class, ProductMapper.class); job.setReducerClass(JoinReducer.class); job.setMapOutputKeyClass(Text.class); job.setMapOutputValueClass(TableBean.class); job.setOutputKeyClass(TableBean.class); job.setOutputValueClass(NullWritable.class);这里有几个细节值得说。setMapOutputKeyClass和setOutputKeyClass是两套配置,前者管 map 到 reduce 之间的中间数据,后者管最终输出。新手经常只设一套,结果要么编译过、运行时报类型转换异常,要么 Reducer 里拿到的 key 类型不对。我的习惯是四行全部写全,一个不落。
setNumReduceTasks这块,调试阶段我一般设成 1,因为单 reduce 输出文件只有一个,结果好核对,日志也好排查。但在真实数据量下这样会浪费并行度,默认值(通常是 1)在集群上其实是个陷阱——数据量上去了,一个 Reducer 拖几个小时很正常。正式跑的时候,reduce 数量一般设成集群可用 slot 数的一个比例,具体多少要压测,没有通用数字。
4. 完整实操过程与运行记录
4.1 环境准备与工程骨架
环境上我用的是一套本地伪分布式,核心组件版本之间要对齐,尤其是 Hadoop 客户端和集群版本,不一致的时候常见的表现是提交作业后卡在ACCEPTED状态或者序列化报错。工程用 Maven 管理,依赖只需要hadoop-client,一定要写成provided作用域,因为集群上已经有这些包,打进 fat jar 里会引发类冲突,症状是莫名其妙的NoSuchMethodError。
工程骨架我拆成四个文件:两个 Mapper、一个 Reducer、一个 Driver,加上 Bean 共五个。包结构按功能分,Bean 单独一个包,方便复用。输出路径要保证每次都不同,因为 Hadoop 在目标目录已存在时会直接拒绝执行,这个设计看似麻烦,其实是在保护你——避免上一次的错误结果被覆盖后无从对比。我的做法是给输出目录加个时间戳后缀,或者跑之前手动删。
输入数据放到本地后用hdfs dfs -put上传,上传完记得-ls确认一下文件大小和行数。我曾经遇到过文件传了一半就中断的情况,作业照跑,结果少了几千条,查了半天以为逻辑有问题。现在我的习惯是上传后wc -l对一下行数,两分钟的事,能省两小时。
4.2 关键代码逐段说明
Bean 的序列化部分是整段代码里最枯燥但最不能错的:
public class TableBean implements Writable { private String orderId; private String pid; private int amount; private String pname; private String flag; public TableBean() { } @Override public void write(DataOutput out) throws IOException { out.writeUTF(orderId); out.writeUTF(pid); out.writeInt(amount); out.writeUTF(pname); out.writeUTF(flag); } @Override public void readFields(DataInput in) throws IOException { orderId = in.readUTF(); pid = in.readUTF(); amount = in.readInt(); pname = in.readUTF(); flag = in.readUTF(); } }注意writeUTF和readUTF必须严格按顺序配对,中间穿插的writeInt也一样。我见过有人把readFields里的顺序写反了,结果输出的商品名全是数字,查了很久以为是拼装逻辑的问题,实际上是反序列化串位了。
订单 Mapper 的核心部分:
String[] fields = value.toString().split("\t"); if (fields.length < 3) return; bean.setOrderId(fields[0]); bean.setPid(fields[1]); bean.setAmount(Integer.parseInt(fields[2].trim())); bean.setPname(""); bean.setFlag("0"); k.set(fields[1]); context.write(k, bean);fields.length < 3这个判断不能省。真实数据里的空行、脏行比你想象的多,少判断一次,ArrayIndexOutOfBoundsException就来了。而一旦有一条脏数据让 Mapper 抛异常,整个 task 会重试,重试几次后作业失败,前面几分钟的计算全部白费。
Reducer 的核心:
orderList.clear(); product = null; for (TableBean bean : values) { if ("0".equals(bean.getFlag())) { TableBean tmp = new TableBean(); tmp.setOrderId(bean.getOrderId()); tmp.setPid(bean.getPid()); tmp.setAmount(bean.getAmount()); tmp.setPname(""); tmp.setFlag("0"); orderList.add(tmp); } else { product = new TableBean(); product.setPname(bean.getPname()); product.setPid(bean.getPid()); product.setOrderId(""); product.setAmount(0); product.setFlag("1"); } }这段里orderList.clear()和product = null是两行“救命代码”,删掉任何一个,输出结果都会带着上一个 key 的残留数据。new TableBean()的位置也是关键——必须在循环体内部 new,放外面就等于没拷贝。
4.3 提交运行与日志解读
打包后提交,命令行大概是这样:
hadoop jar join-demo.jar com.example.join.JoinDriver \ /input/order /input/product /output/join_$(date +%s)提交后我一般会盯三个地方。第一是mapreduce.Job: Running job之后每个 map 的进度,如果某个 map 卡在 0% 很久,通常是输入分片有问题或者数据源读不动。第二是map 100% reduce 0%阶段停留多久,这个时间基本等于 Shuffle 加排序的时间,如果它占用了总时长的一大半,就说明 ReduceJoin 的网络开销成了瓶颈,这是判断要不要换 MapJoin 最直接的信号。第三是计数器里的Reduce input groups和Reduce input records,前者是关联键个数,后者是总记录条数,两个数字一比就能看出平均每个 key 下面挂了多少记录,倾斜程度一目了然。
有一次我跑的时候卡在 reduce 33%,日志里能看到某个 reduce 的任务在反复 GC,最后超时被杀。换成单 reduce 重跑,发现某一个商品 ID 下面挂了上万条订单,其余 key 只有几条。这就是典型的倾斜,不是代码错,是数据分布的问题。
4.4 输出结果校验
结果文件落地后,我建议至少做三层校验。
第一层是条数校验。内连接情况下,输出的条数应该等于“主表中能匹配到从表记录的条数”。这个数字可以提前用别的工具算出来做对照。第二层是抽样校验。挑几条典型记录——一个正常关联的、一个商品缺失的、一个被多次引用的——逐字段核对。第三层是边界校验,看看有没有商品名为空但订单号在的记录、有没有数量为 0 的记录,这些异常往往指向拼装逻辑写错。
用hdfs dfs -cat打印前几十行是最快的检查方式,但要注意结果文件可能被分成多个 part,只 cat 一个文件容易漏。-getmerge合并到本地再看更稳妥,不过大文件别这么干。
5. 踩过的坑与排查速查表
5.1 迭代器复用导致的数据丢失
这是 ReduceJoin 里最经典的坑,我第一次写的时候也中招了。现象是:代码逻辑看起来完全正确,但输出结果里同一批次的所有订单,商品名都对,订单号和数量却全变成了同一个值,或者是最后一次循环的那条记录。
根因在于 Reduce 端传入的 value 迭代器为了减少对象创建,每次 next 返回的都是同一个对象实例,只是内部字段被重新填充。你把它 add 进 List,存的只是引用,等循环结束后,所有引用指向的都是同一块内存,内容自然全部一样。
解决方式有两种。省事的是每次 new 一个对象做属性拷贝,代码多几行但绝对安全。另一种思路是既然只关心数量,那就只把需要的字段抽出来存成基本类型或者字符串放 List,不存整个 Bean。后者更省内存,但代码可读性差一些。我倾向第一种,因为逻辑直观,出问题好查。
这个坑还有变体:有人发现 list 里数据不对,就在循环里输出调试信息打印 Bean,结果打印出来是对的,因为打印发生在当次循环内,对象还没被复用。于是误以为逻辑没问题,越查越远。遇到这类“看起来对但结果错”的情况,先怀疑对象复用。
5.2 输出字段顺序错乱与空值问题
输出字段顺序错乱,绝大多数跟序列化读写不对应有关。我建议的自检方式是:写一个只有两三条数据的小测试集,跑完之后逐字段对照。大表跑出来的错乱数据没法逐条看,小表能一眼定位。
空值问题通常出现在 JOIN 键为空的记录上。如果关联键是 null,Text.set(null)会抛空指针。所以 Mapper 里在k.set(...)之前必须判断关联键是否为空,为空的记录直接丢弃或者给个占位符,别让它进入 Shuffle。有些业务确实需要保留空键记录,那就统一给个特殊字符串,在 Reducer 里单独处理,别让它混在正常数据里。
另外提醒一句:writeUTF对字符串长度有上限(65535 字节),虽然实际业务字段很难超过,但如果这个字段存的是超长描述文本,就要考虑换writeBytes加长度前缀的写法了。
5.3 常见问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 程序卡在 map 0% | 输入路径不存在或分片读不动 | 确认路径、-ls查看文件权限 |
| 报空指针,堆栈指向序列化 | 字符串字段为 null | 所有字段 set 前做空值兜底 |
| 输出数据串列 | write与readFields顺序不一致 | 逐字段对照,小数据集验证 |
| 同一批记录内容重复 | 迭代器对象复用,未做属性拷贝 | 循环体内 new 对象拷贝字段 |
| 结果里出现上一个 key 的数据 | 容器未清空 | reduce 方法开头 clear 并置 null |
| 部分订单丢失 | 只有主表无对应从表,被判空过滤 | 按业务需求决定是否左连接保留 |
| reduce 阶段耗时占大头 | 全部数据参与 Shuffle | 评估改 MapJoin 或加倾斜处理 |
| 单个 reduce 长时间不结束 | 个别关联键记录量过大 | 查看计数器的 groups 与 records 比例 |
报ArrayIndexOutOfBounds | 字段数不足 | split 后先判长度 |
6. ReduceJoin 的天花板与优化路径
6.1 数据倾斜为什么在这个模型里被放大
ReduceJoin 有一个绕不开的物理限制:同一个关联键的所有记录,只能进同一个 Reducer。这条规则在数据均匀时没问题,一旦某个 key 的记录量比平均值高几个数量级,那个 Reducer 就成了整个作业的木桶短板,其它 Reducer 早早跑完在那儿空等,整体耗时由最慢的那个决定。
这种倾斜在关联场景里特别常见。比如商品表里有一个“通用配件”类的商品 ID,几乎每笔订单都会引用它,那这个 key 下面挂的记录可能是其它 key 的几千倍。或者反过来,主表里存在大量关联键为空的记录,如果没处理好,它们会全部堆到一个 reduce 上。
观察倾斜最直接的工具还是计数器。跑完之后看Reduce input records除以Reduce input groups,如果平均值是 5,而你在日志里看到某个 reduce 处理了上百万条,那基本就锁定了。我也见过用-D mapreduce.job.reduces=1强制单 reduce 跑一次小样本,专门用来统计 key 分布,虽然慢但很准。
6.2 组合拳:缓冲表打散与二次聚合
针对倾斜,我在实际任务里试过几种做法,按成本从低到高排。
第一种是给热点键加随机前缀。在 Mapper 端判断关联键是否属于热点集合,是的话给它拼一个随机数后缀(比如1001_3),这样同一个键就被打散到多个 Reducer。Reducer 端输出时再把后缀去掉,然后启动第二个作业按原始键做一次聚合。这套方案要多跑一遍作业,但能显著压缩单点耗时。缺点是代码复杂度上升,热点集合怎么定也要靠前期的统计。
第二种是对大表侧做局部预聚合。如果主表里同一个键下的记录只需要汇总值(比如订单总金额),那完全可以在 map 端先用 Combiner 做一次合并,把几万条压成一条。但要注意,ReduceJoin 里用 Combiner 是有前提的——主表记录需要逐条输出时不能合并,只有在做“每个商品的总销量”这类聚合统计时才适用。而且 Combiner 只能作用于主表侧,从表侧不能乱用。
第三种是换 MapJoin。如果倾斜来源于从表侧数据量小但被大量引用,那把从表广播到内存就是根治手段。这个方案后面单独说。
6.3 什么时候该从 ReduceJoin 切到 MapJoin
我的判断标准有三条,满足任意两条我就会考虑切换。
第一条是从表能放进内存。实际操作中我会先看从表在 HDFS 上的原始大小,压缩后乘以 3 到 5 倍估算内存占用,再和 Mapper 堆大小比一比。如果估算值超过堆的一半,就放弃。
第二条是主表数据量远大于从表。如果两张表体量接近,MapJoin 省下来的 Shuffle 量有限,还不如保留 ReduceJoin 的通用性。
第三条是作业跑一次要很久,且瓶颈明确在 Shuffle 阶段。这种情况下优化收益最大。如果本身就是分钟级的小任务,折腾 MapJoin 的收益抵不上调试成本。
MapJoin 的实现思路上,早期版本用DistributedCache把文件分发到各节点,在setup方法里读进内存构建成一个HashMap,map方法里直接按关联键查表。较新的写法用job.addCacheFile加Configuration传路径。无论哪种,Mapper 里都要做好“缓存文件不存在”的兜底,因为节点分布式的特性决定了你没法保证每个节点的本地缓存都完好,HDFS路径读取更稳一些。
7. 我在这个案例里攒下的几条实操建议
先说调试节奏。我坚持的原则是先用一条 reduce 跑通逻辑,再放开并行度做性能调优。很多人一上来就设成几十个 reduce,结果输出被切成几十个 part 文件,核对结果时根本拼不起来,还容易掩盖“某个 key 数据集中”的问题。单 reduce 模式下所有记录都在一个文件里,肉眼扫一遍就能发现串列、重复、缺失这些硬伤。
再说日志定位。ReduceJoin 出问题时,我不看业务日志,先看计数器。Map input records、Map output records、Reduce input records、Reduce input groups这四个数能覆盖八成的问题定位。比如 map 输出比输入多,说明有 Mapper 做了展开;reduce 输入比 map 输出少,说明有记录在 Shuffle 前被过滤了;groups 数量异常少,说明关联键取值集中,倾斜风险高。这套方法比翻堆栈快得多。
最后一个心得跟代码结构有关。ReduceJoin 的代码写完之后,我建议把“哪些字段是关联键、哪些标签对应哪张表、空值怎么兜底”这三件事写在类注释里。半年后自己回来看,或者别人接手,这三行注释能省掉大量逆向推理。我自己就有过一次,半年后要改一个关联逻辑,看着flag是"0"和"1"却想不起来哪个是哪边,翻数据文件对了半天才好。这类在代码里顺手留下的痕迹,价值比任何事后文档都高。