marp: true headingDivider: 6 theme: python-mini-pptx book: 大数据技术原理与应用 title: MapReduce 自定义分区器 subtitle: 决定一条数据落进哪个输出文件 author: 屈庆涛 date: 2026-09-13 14:00:00 categories: - Hadoop tags: - Hadoop - MapReduce - Partitioner mode: slide keywords: Mapper, Reducer, Partitioner, Hash, MapReduce,Job, Key semicolons: true
── mdcode2run:文档级执行默认 ──────────────────────¶
code-exec: java: JAVA_HOME: "D:/Program Files/Java/jdk-17.0.20.1" class-path: "D:\bigdata\hadoop\hadoop-3.1.3\share\hadoop\common*;D:\bigdata\hadoop\hadoop-3.1.3\share\hadoop\common\lib*" python: PYTHON_PATH: "D:/ProgramData/anaconda3/python.exe" mysql: HOST: db.example.com # PASSWORD 不能放这里(秘密只在引擎层) DATABASE: bigdata mr-job: name: WordCount types: in: LongWritable, Text map-out: Text, IntWritable out: Text, IntWritable combine: reduce input: data/bigdata.txt output: out/wordcount
前置与后继¶
| 关系 | 案例 | 承接的知识点 |
|---|---|---|
| 前置 | 自定义序列化 | Hadoop Writable序列化 |
| 本案例 | 自定义分区器 | 分区函数只依赖 key |
| 后继 | 自定义排序 | 分区与排序的配合 |
遗留问题¶
默认情况下,一个MapReduce Job默认一个Reduce,数据会输出到一个文件中。
digraph MapReduce {
rankdir=LR;
compound=true;
newrank=true;
graph [fontname="Helvetica", pad=0.4, nodesep=0.4, ranksep=0.9,
splines=spline];
node [fontname="Helvetica", shape=box, style="rounded,filled",
fillcolor="#F8F9FA", color="#ADB5BD", penwidth=1.2,
margin="0.15,0.08"];
edge [fontname="Helvetica", color="#868E96", arrowsize=0.7];
subgraph cluster_in {
label="输入 bigdata.txt";
style="rounded,filled"; color="#74C0FC"; fillcolor="#E7F5FF";
I1 [label="第 1 行:a b c", group=g1];
I2 [label="第 2 行:a b", group=g2];
I3 [label="第 3 行:a", group=g3];
}
subgraph cluster_map {
label="Map 阶段";
style="rounded,filled"; color="#FFA94D"; fillcolor="#FFF4E6";
M1 [label=<<a,1> <b,1> <c,1>>, group=g1];
M2 [label=<<a,1> <b,1>>, group=g2];
M3 [label=<<a,1>>, group=g3];
}
subgraph cluster_shuffle {
label="Shuffle(框架负责)";
style="rounded,filled"; color="#FFD43B"; fillcolor="#FFF9DB";
G1 [label=<<a, [1,1,1]>>, group=g1];
G2 [label=<<b, [1,1]>>, group=g2];
G3 [label=<<c, [1]>>, group=g3];
}
subgraph cluster_reduce {
label="Reduce 阶段";
style="rounded,filled"; color="#51CF66"; fillcolor="#EBFBEE";
R1 [label="reduce:1+1+1", group=g1];
R2 [label="reduce:1+1", group=g2];
R3 [label="reduce:1", group=g3];
}
subgraph cluster_out {
label="输出 part-r-00000";
style="rounded,filled"; color="#B197FC"; fillcolor="#F3F0FF";
O1 [label="a → 3", group=g1];
O2 [label="b → 2", group=g2];
O3 [label="c → 1", group=g3];
}
// 真实数据流
I1 -> M1;
I2 -> M2;
I3 -> M3;
M1 -> G1;
M1 -> G2;
M1 -> G3;
M2 -> G1;
M2 -> G2;
M3 -> G1;
G1 -> R1 -> O1;
G2 -> R2 -> O2;
G3 -> R3 -> O3;
// 关键:不可见边,把每个 G 的入度补齐到 3 条
// G2 实际缺 M3,G3 实际缺 M2、M3
M3 -> G2 [style=invis, weight=1];
M2 -> G3 [style=invis, weight=1];
M3 -> G3 [style=invis, weight=1];
}
那我们怎么能根据Key来将数据分散到不同的文件中呢?比如根据部门号将员工分散到不同的文件中。
默认分区器¶
可以通过设置setNumReduceTasks(n)为MapReduce Job指定分区数,使用默认分区器。
digraph MapReduce {
rankdir=LR;
compound=true;
newrank=true;
graph [fontname="Helvetica", pad=0.4, nodesep=0.4, ranksep=0.9,
splines=spline];
node [fontname="Helvetica", shape=box, style="rounded,filled",
fillcolor="#F8F9FA", color="#ADB5BD", penwidth=1.2,
margin="0.15,0.08"];
edge [fontname="Helvetica", color="#868E96", arrowsize=0.7];
subgraph cluster_in {
label="输入 bigdata.txt";
style="rounded,filled"; color="#74C0FC"; fillcolor="#E7F5FF";
I1 [label="第 1 行:a b c", group=g1];
I2 [label="第 2 行:a b", group=g2];
I3 [label="第 3 行:a", group=g3];
}
subgraph cluster_map {
label="Map 阶段";
style="rounded,filled"; color="#FFA94D"; fillcolor="#FFF4E6";
M1 [label=<<a,1> <b,1> <c,1>>, group=g1];
M2 [label=<<a,1> <b,1>>, group=g2];
M3 [label=<<a,1>>, group=g3];
}
subgraph cluster_partition {
label="Partition 阶段(hash(key) % 3)";
style="rounded,filled"; color="#F783AC"; fillcolor="#FFF0F6";
Q1 [label=<P0:<b,1> P1:<c,1> P2:<a,1>>, group=g1];
Q2 [label=<P0:<b,1> P1:— P2:<a,1>>, group=g2];
Q3 [label=<P0:— P1:— P2:<a,1>>, group=g3];
}
subgraph cluster_shuffle {
label="Shuffle(框架负责)";
style="rounded,filled"; color="#FFD43B"; fillcolor="#FFF9DB";
G1 [label=<P2 → <a, [1,1,1]>>, group=g1];
G2 [label=<P0 → <b, [1,1]>>, group=g2];
G3 [label=<P1 → <c, [1]>>, group=g3];
}
subgraph cluster_reduce {
label="Reduce 阶段";
style="rounded,filled"; color="#51CF66"; fillcolor="#EBFBEE";
R0 [label="Reduce(P2):1+1+1", group=g1];
R1 [label="Reduce(P0):1+1", group=g2];
R2 [label="Reduce(P1):1", group=g3];
}
subgraph cluster_out {
label="输出目录";
style="rounded,filled"; color="#B197FC"; fillcolor="#F3F0FF";
O0 [label="part-r-00002:a → 3", group=g1];
O1 [label="part-r-00000:b → 2", group=g2];
O2 [label="part-r-00001:c → 1", group=g3];
}
// ---------- 真实数据流 ----------
// Input -> Map
I1 -> M1;
I2 -> M2;
I3 -> M3;
// Map -> Partition
M1 -> Q1;
M2 -> Q2;
M3 -> Q3;
// Partition -> Shuffle(P2 汇 a、P0 汇 b、P1 汇 c)
Q1 -> G1; // P2: a
Q1 -> G2; // P0: b
Q1 -> G3; // P1: c
Q2 -> G1; // P2: a
Q2 -> G2; // P0: b
Q3 -> G1; // P2: a
// Shuffle -> Reduce -> Output
G1 -> R0 -> O0; // P2 → part-r-00002
G2 -> R1 -> O1; // P0 → part-r-00000
G3 -> R2 -> O2; // P1 → part-r-00001
// ---------- 不可见边:补齐 G 的入度,保持横向对齐 ----------
Q2 -> G3 [style=invis, weight=1];
Q3 -> G2 [style=invis, weight=1];
Q3 -> G3 [style=invis, weight=1];
}
这里我们指定$n=3$,数据会被默认的
HashPartitioner打散到3个 reduce 任务中,输出到3个文件中。注意文件名与 key 并不是按字母顺序对应的——a落在part-r-00002,这是Text哈希取模的结果(详见后文「Hash值」一节)。
Hash值¶
Hash:它是一种把任意数据映射成一个固定长度整数(哈希值)的函数。
- 相同输入 → 永远得到相同哈希值(确定性)
- 不同输入 → 尽量分散到不同整数(打散)
- 哈希值本身没有业务含义,只是一把"分桶用的钥匙"
注意:哈希冲突是必然存在的——不同 key 也可能算出相同的 hashCode(key 空间远大于 32 位整数)。
字符语义 vs 字节语义¶
字符侧有四层概念(只有这一层才存在"字符"):
- 字符:要表示的符号,如 a、中。
- 码点:Unicode 给这个符号分配的整数编号,写法如 U+0061。
- 码元:某种 UTF 编码形式装码点的固定宽度单元(UTF-8 为 1 字节,UTF-16 为 2 字节)。
- 编码字节:码元落地成存储/网络上的实际字节。
| 编码 | 1 码元 | 表示全集 | 一个码点占 | 空间特点 |
|---|---|---|---|---|
| UTF-8 | 1 字节 | 能 | 1~4 个码元 | ASCII 极省,非 ASCII 多字节 |
| UTF-16 | 2 字节 | 能 | 1~2 个码元 | CJK 常比 UTF-8 紧凑 |
| UTF-32 | 4 字节 | 能 | 恒 1 个码元 | 定长、可 O(1) 下标,最费空间 |
Java String 的哈希¶
单字符时,hashCode() 恰好等于该字符的码点值(ASCII 字符的码点就是 ASCII 码)——因为递推 h = 31·h + c 在只有一个字符时前面没有累乘项,退化为 c 自身:
97, 98, 99
多字符时是递推式 $h_i = 31\,h_{i-1} + c_i,\; h_0 = 0$ 的逐次求值($c_i$ 为第 $i$ 个字符的码点值):
97, 3105, 96354
$$ \begin{aligned} h_1 &= 31\cdot 0 + 97 = 97 \ h_2 &= 31\cdot 97 + 98 = 3105 \ h_3 &= 31\cdot 3105 + 99 = 96354 \end{aligned} $$
把递推式一层层代入,先各用前一项表示,即可推出多项式:
$$ \begin{aligned} h_1 &= 31\,h_0+c_1=c_1 \ h_2 &= 31\,h_1 + c_2+h=31c_1+c_2 \ h_3 &= 31\,h_2 + c_3 = 31\,(31\,h_1 + c_2) + c_3 = 31^2\,h_1 + 31\,c_2 + c_3= 31^2\,c_1 + 31\,c_2 + c_3 \end{aligned} $$
$h_0 = 0$代入,得只含字符码点的形式,推广到 $n$ 个字符,即得多项式闭形式(可视为以 $31$ 为基的字符串"数值"):
$$ h_n = \sum_{i=1}^{n} c_i \cdot 31^{\,n-i} $$
每一步都"吃进"一个新字符,把上一步的哈希先乘 $31$ 再累加当前字符码点。因此哈希不仅与字符内容有关,还与顺序和长度有关——
"ab"与"ba"哈希不同。
Java String 哈希的分区结果(易错示范)¶
如果拿 String 的哈希去推算分区,会得到:
(new String("a").hashCode() & Integer.MAX_VALUE) % 3;
(new String("b").hashCode() & Integer.MAX_VALUE) % 3;
(new String("c").hashCode() & Integer.MAX_VALUE) % 3;
1, 2, 0
注意:这是错的。 MapReduce 的 key 是
Writable(Text),不是String——用String推算会得出错误桶号。真实的分区见下一节。
Hadoop Text 的哈希¶
因为 Text 内存里就是 UTF-8 字节,它的 hashCode() 自然也遍历这些字节——与 String 两点不同:遍历 UTF-8 字节(而非 UTF-16 码元),且种子为 1(而非 0):
// org.apache.hadoop.io.WritableComparator(Hadoop 源码,非可直接运行的片段)
public static int hashBytes(byte[] bytes, int length) {
int hash = 1;
for (int i = 0; i < length; i++)
hash = (31 * hash) + bytes[i]; // bytes[i] 为 byte,有符号(-128~127)
return hash;
}
import org.apache.hadoop.io.Text;
new Text("a").hashCode();
new Text("b").hashCode();
new Text("c").hashCode();
128, 129, 130
对 ASCII 字符,UTF-8 字节值 = 码点值,所以 Text 与 String 只差种子那一项:
$$128 = 31\cdot1 + 97 = 97 + 31$$
非 ASCII 时两者"原料"不同,哈希彻底分叉——下面这段可验证 Text 内存里确实是 UTF-8 字节:
import org.apache.hadoop.io.Text;
import org.apache.hadoop.io.WritableComparator;
import java.util.Arrays;
Text t2 = new Text("中");
t2.getLength(); // 预期 3(UTF-16 应为 2)→ 证明是 UTF-8
Arrays.toString(t2.copyBytes()); // 预期 [-28, -72, -83],即 E4 B8 AD
WritableComparator.hashBytes(t2.getBytes(), t2.getLength()); // 预期 568,即 Text("中").hashCode()
new String("中").length(); // 1(UTF-16 一个 char)
new String("中").hashCode(); // 20013
Hadoop Text 哈希的分区结果¶
import org.apache.hadoop.io.Text;
(new Text("a").hashCode() & Integer.MAX_VALUE) % 3;
(new Text("b").hashCode() & Integer.MAX_VALUE) % 3;
(new Text("c").hashCode() & Integer.MAX_VALUE) % 3;
2, 0, 1
| key | String 哈希 → 分区 |
Text 哈希 → 分区 |
|---|---|---|
| a | 97 → 1 | 128 → 2 |
| b | 98 → 2 | 129 → 0 |
| c | 99 → 0 | 130 → 1 |
MapReduce 的 key 是
Writable,分区实际用的是Text的哈希——所以真正的输出是a→part-r-00002、b→part-r-00000、c→part-r-00001。
String 与 Text 的一切差异,都源于一条根因:String 是字符语义,Text 是字节语义。
String:内存里是 UTF-16 码元(char),全程认识"字符"。Text:内存里就是 UTF-8 字节(private byte[] bytes),不认识"字符",只认 8 位一组的字节。
Text 只在边界关心字符,其余全是纯字节操作:
| 操作 | 实际做的事 | 关心字符语义吗 |
|---|---|---|
new Text(String) / set() |
String → UTF-8 编码 | ← 关心(仅一次) |
toString() |
UTF-8 → String 解码 | ← 关心 |
hashCode() |
h = 31h + byte |
不关心 |
compareTo() |
逐字节比大小 | 不关心 |
write() 序列化 |
原样写出字节 | 不关心 |
正因
Text内存里已是 UTF-8,序列化时零转码——而 MapReduce 的 key 要在 map → shuffle → reduce 之间反复序列化(溢写磁盘、跨网络),这个收益被放大成千上万倍。
默认分区器¶
digraph MapReduce {
rankdir=LR;
compound=true;
newrank=true;
graph [fontname="Helvetica", pad=0.4, nodesep=0.4, ranksep=0.9,
splines=spline];
node [fontname="Helvetica", shape=box, style="rounded,filled",
fillcolor="#F8F9FA", color="#ADB5BD", penwidth=1.2,
margin="0.15,0.08"];
edge [fontname="Helvetica", color="#868E96", arrowsize=0.7];
subgraph cluster_in {
label="输入 bigdata.txt";
style="rounded,filled"; color="#74C0FC"; fillcolor="#E7F5FF";
I1 [label="第 1 行:a b c"];
I2 [label="第 2 行:a b"];
I3 [label="第 3 行:a"];
}
subgraph cluster_map {
label="Map 阶段";
style="rounded,filled"; color="#FFA94D"; fillcolor="#FFF4E6";
M1 [label=<<a,1> <b,1> <c,1>>];
M2 [label=<<a,1> <b,1>>];
M3 [label=<<a,1>>];
}
P [label="分区函数(按 numReduceTasks 划分)", shape=box,
style="rounded,filled", color="#F783AC", fillcolor="#FFF0F6"];
subgraph cluster_reduce {
label="Reduce 阶段(任务数 = 分区数)";
style="rounded,filled"; color="#51CF66"; fillcolor="#EBFBEE";
R0 [label="Reduce 0"];
R1 [label="Reduce 1"];
R2 [label="Reduce 2"];
}
subgraph cluster_out {
label="输出目录(文件数 = 分区数)";
style="rounded,filled"; color="#B197FC"; fillcolor="#F3F0FF";
O0 [label="part-r-00000"];
O1 [label="part-r-00001"];
O2 [label="part-r-00002"];
}
I1 -> M1;
I2 -> M2;
I3 -> M3;
M1 -> P;
M2 -> P;
M3 -> P;
P -> R0 -> O0;
P -> R1 -> O1;
P -> R2 -> O2;
}
默认分区器只有一行核心逻辑:
这行代码的目的是:根据 key 的哈希值,决定这条记录交给哪个 Reducer(即哪个分区)处理。结果范围是 [0, numPartitions - 1]。
key.hashCode()¶
获取 key 对象的哈希值,返回一个 int(32 位有符号整数),范围是 -2^31 ~ 2^31-1,可能是负数。
& Integer.MAX_VALUE¶
Integer.MAX_VALUE 的值是 0x7FFFFFFF,即二进制:
& 是按位与。它的效果是:保留低 31 位,把最高位(符号位)清零。
xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx (hashCode)
& 0111 1111 1111 1111 1111 1111 1111 1111 (Integer.MAX_VALUE)
-----------------------------------------
0xxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx (结果一定是非负数)
作用:把可能为负的 hashCode 变成一个非负整数(范围 0 ~ 2^31-1),避免负数取模得到负分区号。
% numPartitions¶
对分区数取模,把结果映射到 [0, numPartitions-1] 区间,得到最终的分区编号。
digraph MapReduce {
rankdir=LR;
compound=true;
newrank=true;
graph [fontname="Helvetica", pad=0.4, nodesep=0.4, ranksep=0.9,
splines=spline];
node [fontname="Helvetica", shape=box, style="rounded,filled",
fillcolor="#F8F9FA", color="#ADB5BD", penwidth=1.2,
margin="0.15,0.08"];
edge [fontname="Helvetica", color="#868E96", arrowsize=0.7];
subgraph cluster_in {
label="输入 bigdata.txt";
style="rounded,filled"; color="#74C0FC"; fillcolor="#E7F5FF";
I1 [label="第 1 行:a b c", group=g1];
I2 [label="第 2 行:a b", group=g2];
I3 [label="第 3 行:a", group=g3];
}
subgraph cluster_map {
label="Map 阶段";
style="rounded,filled"; color="#FFA94D"; fillcolor="#FFF4E6";
M1 [label=<<a,1> <b,1> <c,1>>, group=g1];
M2 [label=<<a,1> <b,1>>, group=g2];
M3 [label=<<a,1>>, group=g3];
}
subgraph cluster_partition {
label="Partition 阶段";
style="rounded,filled"; color="#F783AC"; fillcolor="#FFF0F6";
Q1 [label=<P0:<a,1> P1:<b,1> P2:<c,1>>, group=g1];
Q2 [label=<P0:<a,1> P1:<b,1>>, group=g2];
Q3 [label=<P0:<a,1>>, group=g3];
}
subgraph cluster_shuffle {
label="Shuffle(框架负责)";
style="rounded,filled"; color="#FFD43B"; fillcolor="#FFF9DB";
G1 [label=<P0 → <a, [1,1,1]>>, group=g1];
G2 [label=<P1 → <b, [1,1]>>, group=g2];
G3 [label=<P2 → <c, [1]>>, group=g3];
}
subgraph cluster_reduce {
label="Reduce 阶段";
style="rounded,filled"; color="#51CF66"; fillcolor="#EBFBEE";
R0 [label="Reduce(P0):1+1+1", group=g1];
R1 [label="Reduce(P1):1+1", group=g2];
R2 [label="Reduce(P2):1", group=g3];
}
subgraph cluster_out {
label="输出目录";
style="rounded,filled"; color="#B197FC"; fillcolor="#F3F0FF";
O0 [label="part-r-00000:a → 3", group=g1];
O1 [label="part-r-00001:b → 2", group=g2];
O2 [label="part-r-00002:c → 1", group=g3];
}
// ---------- 真实数据流 ----------
// Input -> Map
I1 -> M1;
I2 -> M2;
I3 -> M3;
// Map -> Partition(每个 Map 内部按 key 分区)
M1 -> Q1;
M2 -> Q2;
M3 -> Q3;
// Partition -> Shuffle(按分区号汇集到对应 Reduce 的数据流)
Q1 -> G1;
Q1 -> G2;
Q1 -> G3;
Q2 -> G1;
Q2 -> G2;
Q3 -> G1;
// Shuffle -> Reduce -> Output
G1 -> R0 -> O0;
G2 -> R1 -> O1;
G3 -> R2 -> O2;
// ---------- 不可见边:把每个 G 的入度补齐到 3 条,保证横向对齐 ----------
Q2 -> G3 [style=invis, weight=1];
Q3 -> G2 [style=invis, weight=1];
Q3 -> G3 [style=invis, weight=1];
}
自定义分区器¶
默认分区器把 a/b/c 按 hash 打散,文件名和 key 对不上——part-r-00000 里装的是 b 而不是 a(a 落在 part-r-00002)。那我们能不能自己说了算,让 a 进文件 0、b 进文件 1、c 进文件 2?
这就是自定义分区器的职责:用一个分区函数,显式决定每个 key 去哪个文件。
先用这个最精简的 a/b/c 例子看清楚"分区器到底能决定什么",再拿 emp.csv 的部门号做实战。
digraph MapReduce {
rankdir=LR;
compound=true;
newrank=true;
graph [fontname="Helvetica", pad=0.4, nodesep=0.4, ranksep=0.9,
splines=spline];
node [fontname="Helvetica", shape=box, style="rounded,filled",
fillcolor="#F8F9FA", color="#ADB5BD", penwidth=1.2,
margin="0.15,0.08"];
edge [fontname="Helvetica", color="#868E96", arrowsize=0.7];
subgraph cluster_in {
label="输入 bigdata.txt";
style="rounded,filled"; color="#74C0FC"; fillcolor="#E7F5FF";
I1 [label="第 1 行:a b c", group=g1];
I2 [label="第 2 行:a b", group=g2];
I3 [label="第 3 行:a", group=g3];
}
subgraph cluster_map {
label="Map 阶段";
style="rounded,filled"; color="#FFA94D"; fillcolor="#FFF4E6";
M1 [label=<<a,1> <b,1> <c,1>>, group=g1];
M2 [label=<<a,1> <b,1>>, group=g2];
M3 [label=<<a,1>>, group=g3];
}
subgraph cluster_partition {
label="Partition 阶段(hash(key) % 3)";
style="rounded,filled"; color="#F783AC"; fillcolor="#FFF0F6";
Q1 [label=<P0:<a,1> P1:<b,1> P2:<c,1>>, group=g1];
Q2 [label=<P0:<a,1> P1:<b,1>>, group=g2];
Q3 [label=<P0:<a,1>>, group=g3];
}
subgraph cluster_shuffle {
label="Shuffle(框架负责:按分区拉取并分组)";
style="rounded,filled"; color="#FFD43B"; fillcolor="#FFF9DB";
G1 [label=<P0 → <a, [1,1,1]>>, group=g1];
G2 [label=<P1 → <b, [1,1]>>, group=g2];
G3 [label=<P2 → <c, [1]>>, group=g3];
}
subgraph cluster_reduce {
label="Reduce 阶段(3 个 Reduce Task)";
style="rounded,filled"; color="#51CF66"; fillcolor="#EBFBEE";
R0 [label="Reduce(P0):1+1+1", group=g1];
R1 [label="Reduce(P1):1+1", group=g2];
R2 [label="Reduce(P2):1", group=g3];
}
subgraph cluster_out {
label="输出目录(3 个 part 文件)";
style="rounded,filled"; color="#B197FC"; fillcolor="#F3F0FF";
O0 [label="part-r-00000:a → 3", group=g1];
O1 [label="part-r-00001:b → 2", group=g2];
O2 [label="part-r-00002:c → 1", group=g3];
}
// ---------- 真实数据流 ----------
// Input -> Map
I1 -> M1;
I2 -> M2;
I3 -> M3;
// Map -> Partition(每个 Map 内部按 key 分区)
M1 -> Q1;
M2 -> Q2;
M3 -> Q3;
// Partition -> Shuffle(按分区号汇集到对应 Reduce 的数据流)
Q1 -> G1;
Q1 -> G2;
Q1 -> G3;
Q2 -> G1;
Q2 -> G2;
Q3 -> G1;
// Shuffle -> Reduce -> Output
G1 -> R0 -> O0;
G2 -> R1 -> O1;
G3 -> R2 -> O2;
// ---------- 不可见边:把每个 G 的入度补齐到 3 条,保证横向对齐 ----------
Q2 -> G3 [style=invis, weight=1];
Q3 -> G2 [style=invis, weight=1];
Q3 -> G3 [style=invis, weight=1];
}
继承Partitioner¶
```java demo
import org.apache.hadoop.mapreduce.Partitioner;
public class WordCountPartitioner extends Partitioner
@Override
public int getPartition(Text key, IntWritable value, int numPartitions) {
if(Objects.equals(key.toString(), "a")){
return 0;
}else if (Objects.equals(key.toString(), "b")){
return 1;
}else {
return 2;
}
}
} ```
案例概览¶
本案例使用员工表,让每个部门的数据落进独立的输出文件——3 个部门得到 3 个文件,而不是默认哈希分区的结果。
输入数据与预期输出¶
输入 emp.csv,14 行,部门号在第 8 列:
csv
empno,ename,job,mgr,hiredate,sal,comm,deptno
7369,SMITH,CLERK,7902,1980/12/17,800,,20
7499,ALLEN,SALESMAN,7698,1981/2/20,1600,300,30
7521,WARD,SALESMAN,7698,1981/2/22,1250,500,30
7566,JONES,MANAGER,7839,1981/4/2,2975,,20
7654,MARTIN,SALESMAN,7698,1981/9/28,1250,1400,30
7698,BLAKE,MANAGER,7839,1981/5/1,2850,,30
7782,CLARK,MANAGER,7839,1981/6/9,2450,,10
7788,SCOTT,ANALYST,7566,1987/4/19,3000,,20
7839,KING,PRESIDENT,,1981/11/17,5000,,10
7844,TURNER,SALESMAN,7698,1981/9/8,1500,0,30
7876,ADAMS,CLERK,7788,1987/5/23,1100,,20
7900,JAMES,CLERK,7698,1981/12/3,950,,30
7902,FORD,ANALYST,7566,1981/12/3,3000,,20
7934,MILLER,CLERK,7782,1982/1/23,1300,,10
预期得到 3 个文件,每个装一个部门:
| 输出文件 | 部门号 | 员工数 |
|---|---|---|
| part-r-00000 | 10 | 3 |
| part-r-00001 | 20 | 5 |
| part-r-00002 | 30 | 6 |
注意文件名与部门号的对应关系是由分区器决定的,不是自动的。
理论篇:分区是 Shuffle 的第一道门¶
数据从 Map 到 Reduce 之前,必须先决定"去哪个 reduce 任务"。这个决定由分区函数做出,而且一旦决定就不会改。
部门号HashPartition¶
对 IntWritable 这类数值 key,hashCode() 返回的就是数值本身。所以对部门号 10、20、30 来说,取模 3 的结果是:
| 部门号 | % 3 的结果 |
落到分区 |
|---|---|---|
| 10 | 1 | 1 |
| 20 | 2 | 2 |
| 30 | 0 | 0 |
三个部门确实分散到了三个分区,但文件名和部门号对不上:part-r-00000 里装的是 30 号部门。业务方拿到文件后无法凭名字判断内容。
一个"看起来自定义、实际没有"的反例¶
本项目原来的分区器实现是这样的:
if (key.get() % numPartitions == 0) return 0;
else if (key.get() % numPartitions == 1) return 1;
else return 2;
在 numPartitions = 3 的前提下,key % 3 的取值只可能是 0、1、2,三个分支恰好覆盖且返回值就是 key % 3 本身——与默认的 HashPartitioner 逐字节等价。写了 8 行,什么也没改变。
这一条值得单独记住:判断一个自定义分区器是否真的生效,不要看它写没写,要看它的返回值是否和默认实现不同。
分区函数必须满足两条约束¶
| 约束 | 要求 | 违反后的现象 |
|---|---|---|
| 值域 | 返回值必须落在 [0, numPartitions) |
任务抛 IllegalArgumentException |
| 稳定性 | 同一个 key 必须总是返回同一个分区 | 同一部门的数据被拆到多个文件 |
第二条是分区器的根本职责,也是它只能依赖 key 的原因。如果把 value 也纳入计算,同一个部门号配上不同员工就会得到不同分区,"一个部门一个文件"立刻失效。
显式映射:让分区号与部门顺序对应¶
要让 part-r-00000 装 10 号部门,就得做一次显式映射。部门号是 10 的倍数,可以先归一化再取模:
| 部门号 | /10 - 1 |
落到分区 | 说明 |
|---|---|---|---|
| 10 | 0 | 0 | 第一个文件 |
| 20 | 1 | 1 | 第二个文件 |
| 30 | 2 | 2 | 第三个文件 |
取模是为了兜住"部门数超过 reduce 数"的情况——返回值越界会直接让任务失败,这一步不能省。
算术技巧的边界¶
deptId / 10 - 1 只在部门号是连续整数倍时成立。真实场景里部门号可能是 1001、2057 这种,算术公式立刻失效,而且冲突会静默发生(两个部门挤进同一个文件,不报错)。
| 做法 | 适用 | 缺点 |
|---|---|---|
| 算术归一化 | 编号规整、数量固定 | 编号一变就静默冲突 |
| 查表映射 | 部门数量有限 | 需要维护映射表 |
| 从配置读入 | 生产环境 | 多一层配置管理 |
reduce 个数决定文件个数¶
分区与文件是一一对应的:numPartitions 就是 numReduceTasks 的值。
| 配置 | 结果 |
|---|---|
setNumReduceTasks(3) |
3 个文件,分区号 0/1/2 |
setNumReduceTasks(1) |
1 个文件 |
| 只设 Partitioner 不设 reduce 数 | 分区器完全不生效 |
最后一行是最常见的问题:默认 reduce 个数是 1,此时所有数据都去分区 0,自定义分区器根本没机会被调用。
分区不当会导致数据倾斜¶
分区器不只是"分文件",它直接决定各 reduce 任务的负载:
| 分区策略 | 负载 | 后果 |
|---|---|---|
| 均匀哈希 | 各任务相近 | 正常 |
| 按业务键切分 | 取决于数据分布 | 大部门所在任务成为瓶颈 |
| 全部挤进一个分区 | 单任务扛全量 | 其他任务空转 |
如果销售部有 100 万员工而其他部门只有几百人,按部门分区就会让一个 reduce 任务独自处理绝大部分数据——这就是数据倾斜。分区器是它的成因之一,也是它的解法之一。
实训篇:实现按部门分区¶
整个改动只有两处:写一个 Partitioner 子类,然后在 Job 里注册它并配上对应的 reduce 个数。
步骤一:准备数据¶
数据与 fsort、gdistinct 用的是同一份,只是本案例关心第 8 列的部门号。
步骤二:实现 Partitioner¶
继承 Partitioner,泛型必须和 Mapper 的输出类型完全一致——这里是 <IntWritable, Employee>。
public class DeptPartitioner extends Partitioner<IntWritable, Employee> {
@Override
public int getPartition(IntWritable key, Employee value, int numPartitions) {
int deptId = key.get();
int partition = Math.abs(deptId / 10 - 1);
return partition % numPartitions;
}
}
方法签名里的 value 参数在这里没有用到。这不是疏忽——分区函数不应读取 value,否则同 key 分裂的问题马上出现。同理,Math.abs 与末尾的取模都是为了守住"返回值必须落在合法区间"这条约束。
步骤三:注册到 Job¶
两行配置必须成对出现,缺一不可。
setNumReduceTasks(3) 不只是"开 3 个任务",它同时定义了分区号的值域是 [0, 3)。少了这一行,reduce 个数为 1,分区器不会被调用;分区数小于部门数时,多出来的部门只能挤进已有分区。
步骤四:运行与验证¶
hadoop jar hadoop-1.0-SNAPSHOT.jar \
com.studybigdata.hadoop.mapred.dpartition.DeptMain \
/input/emp.csv /output/partition
检查输出目录,应当有 3 个文件且行数分别是 3、5、6:
分区器是否生效,有一个直接的判据:每个文件里的部门号必须唯一。如果 part-r-00000 里同时出现 10 和 40,说明取模那一步把两个部门合并了。
步骤五:变式训练¶
| 变式 | 做法 | 观察到的现象 |
|---|---|---|
1. 去掉 % numPartitions |
直接返回 deptId/10 - 1 |
reduce 数为 3 时部门 40 触发越界异常 |
| 2. 只设 Partitioner | 把 setNumReduceTasks(3) 注释掉 |
只剩 1 个文件,分区器没被调用 |
| 3. 按部门号奇偶分区 | 用 deptId % 2 当分区号 |
需配 2 个 reduce,文件内容与部门顺序无关 |
变式 1 演示约束一(值域),变式 2 演示最常见的失效场景。两个都亲手跑一遍,比记住结论有效。
常见问题¶
分区器的代码量很少,出问题基本都集中在下面五处。
问题一:输出只有 1 个文件¶
默认 reduce 个数是 1,此时所有 key 都落分区 0。
| 现象 | 原因 | 修法 |
|---|---|---|
| 输出只有 1 个文件 | 未设 reduce 个数 | setNumReduceTasks(n) |
问题二:出现 0 行的空文件¶
numReduceTasks 与分区逻辑是耦合的。
| 现象 | 原因 | 修法 |
|---|---|---|
| 出现 0 行的空文件 | 分区数多于业务键数 | 让两者匹配 |
问题三:以为分区器能解决排序¶
分区只决定"去哪",不决定"顺序"。
| 现象 | 原因 | 修法 |
|---|---|---|
| 每个文件内顺序与预期不符 | 分区器不负责排序 | 在 key 上定义排序规则 |
| 想全局有序 | 分区与排序是两件事 | 用单 reduce |
这也正是与下一案例的衔接点:分区把数据分开,排序决定每组内部的顺序,两者配合才能产出既分好类又有序的结果。
小结¶
分区器的全部职责只有一个:给定 key,返回一个分区号。
三句话回顾¶
- 分区函数只能依赖 key,返回值必须落在
[0, numPartitions),否则任务直接失败。 - 自定义分区器只有在返回值与
HashPartitioner不同时才有意义——本项目原实现恰好等价于默认行为。 - 分区器决定文件的划分方式,同时决定各 reduce 任务的负载,因而是数据倾斜的成因之一。
与下一案例的衔接¶
本案例关心"数据去哪个文件",下一案例"自定义排序"关心"文件内的顺序"。两者都是 Shuffle 阶段的机制,组合起来才能同时控制输出的划分与次序。
自定义分区器
下一案例:自定义排序