上一章完成了一个重要进展:RDD 的每个分区都可以变成一个 Task,交给 TaskScheduler 并行执行。
不过,到目前为止,所有流水线都有一个共同特点:
分区 0 只读分区 0
分区 1 只读分区 1
分区 2 只读分区 2
map、filter、flatMap 都是这样。一个分区从开始计算到结束,最多沿着窄依赖去读同号父分区,不需要知道其他分区发生了什么。
本章要处理第一个跨分区的算子:reduceByKey。
先看一组键值对:
分区 0: (hello,1), (world,1), (hello,1)
分区 1: (spark,1), (world,1), (hello,1)
分区 2: (java,1), (spark,1), (hello,1)
目标是得到词频:
hello -> 4
world -> 2
spark -> 2
java -> 1
问题在于:hello 分散在 3 个分区里。上一章的 Task 只会计算自己负责的分区,分区 0 的 Task 看不到分区 1 和分区 2。只靠原来的"同号父分区顺序计算",已经不够了。
这就是 Shuffle 要解决的问题:
把原来按输入分区摆放的数据,重新按 key 摆放,让相同 key 的值最终到达同一个 Reduce 分区。
代码上会保留上一章的分区调度结构,把注意力集中在三个新增类上:
| 新增类 | 职责 |
|---|---|
SparkContext | 保存调度器,并作为 RDD action 的统一入口 |
KeyValuePair<K, V> | 表示 (key, value) |
ShuffledRDD<K, V> | 实现硬编码版 reduceByKey |
本章按这个顺序展开:先看单个分区为什么算不出全局答案(6.1),再看 action 怎样通过 SparkContext 进入调度器(6.2);然后把键值对写成代码(6.3),分别看 Map 端怎么写文件(6.4)、Reduce 端怎么读文件(6.5);接着运行一次看到中间文件(6.6),删掉文件看 Reduce 端为什么立即失败(6.7);最后把哈希分桶抽象成 Partitioner(6.8)。
6.1 单个分区算不出全局答案
如果只看分区 0:
(hello,1), (world,1), (hello,1)
它能算出:
hello -> 2
world -> 1
这只是分区 0 的局部合计。hello 在分区 1、分区 2 里还有出现,world 在分区 1 里还有一次——这些数据分区 0 看不到。把它们也算进来,hello 才是 4、world 才是 2。单个分区算出的只是局部合计,算不出全局合计。
也就是说,reduceByKey 和 map 不一样。map 的每条输入记录只影响一条输出记录,分区之间互不影响;reduceByKey 要把相同 key 的值汇聚到一起,必然跨分区。
一个最初的想法是让几个 Task 把各自的 (key, 计数) 写进同一个共享的 HashMap——最后这个 HashMap 就是全局答案。
问题在于,这些 Task 由不同工作线程同时执行。多个线程同时往一个 HashMap 里写,会互相覆盖、丢失更新,甚至破坏 HashMap 的内部结构。在单机 JVM 里,这个并发问题能解决:给写入加一把锁,或者换成一个线程安全的 ConcurrentHashMap。
但这条路和上一章刚建立的 Task 边界冲突:每个 Task 只计算自己的分区,通过返回值交出结果,不写共享容器。这个约束让 Task 容易并行,也为后面跨机器执行留下空间。
更重要的是,一旦 Task 不在同一个 JVM 里,共享内存这条路就不存在了。线程之间还能共享对象,机器之间不能共享内存地址。
所以本章坚持一个规则:
Task 之间不互相通信。
要跨分区交换数据,就必须把上游产物放到一个下游能够重新读取的位置。先用最容易观察的办法:写本地文件。
6.2 action 如何进入调度器
上一节定下方向:跨分区交换数据,要把上游产物写成下游能读的文件。文件是在某个分区的计算过程中产生的——写文件和读文件都发生在 compute() 里。
所以在打开 compute() 之前,先要理清一条更靠前的主线:用户调一个 action(比如 collect()),是怎样走到 TaskScheduler、又怎样把每个分区的 compute() 触发的。本节只追这条主线;compute() 内部怎么写文件、怎么读文件,留到 6.4 和 6.5。
SparkContext是什么?它不是为了把代码"包装得整齐"才加的类,而是为了解决 action 入口的问题。
上一章的调用方式是从外面把 RDD 交给
TaskScheduler。到了这里,action 写在 RDD 自己身上:用户在 RDD 上调用collect()。那么collect()里面必须能找到一个调度器,否则它就不知道该把分区任务交给谁。
SparkContext就是这个连接点:源头 RDD 从它这里创建,并记住这个上下文;等collect()触发时,RDD 再通过同一个上下文进入runJob(...),最后由上下文里持有的TaskScheduler提交分区任务。
先把整体入口展示出来:
try (SparkContext sc = new SparkContext(2, true)) {
RDD<KeyValuePair<String, Integer>> rdd = sc.parallelize(lines, 3)
.flatMap(line -> Arrays.asList(line.split(" ")))
.map(word -> new KeyValuePair<>(word, 1));
ShuffledRDD<String, Integer> shuffled = rdd.reduceByKey(
Integer::sum, 2);
System.out.println("reduceByKey 结果: " + shuffled.collect());
}
上面这几行只是在构建 RDD 血缘。实际开始执行的是最后的 shuffled.collect()。
先看 RDD.collect():
public List<T> collect() {
List<List<T>> partitionResults = sparkContext.runJob(this, List::copyOf);
List<T> result = new ArrayList<>();
for (List<T> partitionResult : partitionResults) {
result.addAll(partitionResult);
}
return result;
}
它没有自己遍历分区,而是把当前 RDD 交给 SparkContext.runJob(...):
public <T, U> List<U> runJob(
RDD<T> rdd,
Function<List<T>, U> partitionFunction) {
List<List<T>> partitions = taskScheduler.collectPartitions(rdd);
List<U> result = new ArrayList<>();
for (List<T> partition : partitions) {
result.add(partitionFunction.apply(partition));
}
return result;
}
runJob 里有两步。第一步 taskScheduler.collectPartitions(rdd):TaskScheduler 给每个分区提交一个任务并行计算,收齐后返回 List<List<T>>,按分区顺序排好,每个元素是一个分区的结果 List。
第二步是那个 for 循环,对每个分区调一次 partitionFunction.apply(partition)。apply 就是"调用这个函数";而 partitionFunction 就是 RDD.collect() 调 runJob(this, List::copyOf) 时传进来的 List::copyOf。所以 partitionFunction.apply(partition) 等价于 List.copyOf(partition)——把这个分区的结果 List 复制成一份不可变副本。两步完成后,runJob 把这一串分区结果交回 collect(),由 collect() 合并成最终的 List<T>。
这几段代码连起来,执行顺序是:
sequenceDiagram
participant User as 用户代码
participant RDD as ShuffledRDD.collect()
participant SC as SparkContext.runJob()
participant TS as TaskScheduler
participant W as 工作线程
User->>RDD: collect()
RDD->>SC: runJob(this, List::copyOf)
SC->>TS: collectPartitions(shuffled)
TS->>W: 提交 Reduce 分区 0
TS->>W: 提交 Reduce 分区 1
W->>RDD: compute(reducePartition)
RDD-->>W: 返回当前 Reduce 分区结果
W-->>TS: 分区结果 List
TS-->>SC: 所有分区结果(List>)
loop 每个分区
SC->>SC: apply(partition) 即 List.copyOf(partition)
end
SC-->>RDD: 每个分区一个 List
RDD-->>User: 合并成最终 List
后面的小节沿着这条线展开:先把 (key, value) 写成代码,再看 reduceByKey 怎么写文件、怎么读文件。
6.3 把键值对写成代码
reduceByKey 的名字里有一个关键字:Key。
也就是说,系统不能只看到一个普通元素。它必须能回答两个问题:
这条记录按谁分组? -> key
分组以后合并什么? -> value
词频统计里,hello 是 key,1 是 value。Shuffle 会根据 key 决定这条记录去哪个 Reduce 分区;到了 Reduce 分区以后,再把同一个 key 的 value 合并起来。
所以先把一条输入记录定义成一个 record:
public record KeyValuePair<K, V>(K key, V value) implements Serializable {
}
完整实现见 KeyValuePair.java。
有了它,示例输入和源头 RDD 可以连在一起看:
List<String> lines = Arrays.asList(
"hello world hello",
"spark world hello",
"java spark hello");
RDD<KeyValuePair<String, Integer>> rdd = sc.parallelize(lines, 3)
.flatMap(line -> Arrays.asList(line.split(" ")))
.map(word -> new KeyValuePair<>(word, 1));
这段代码从文本走到键值对,分三步。
第一步,lines 是三行文本,和第 1 章 WordCount 的输入一样:每行是由空格隔开的单词。sc.parallelize(lines, 3) 把三行按连续区间切成 3 个 Map 分区,每个分区一行。
第二步,.flatMap(line -> Arrays.asList(line.split(" "))) 把每行切成单词:分区 0 得到 [hello, world, hello],分区 1 得到 [spark, world, hello],分区 2 得到 [java, spark, hello]。flatMap 沿用第 3 章的实现,是窄变换,不改变分区边界。
第三步,.map(word -> new KeyValuePair<>(word, 1)) 把每个单词封装成 (单词, 1)。这一步之后 rdd 的类型就是 RDD<KeyValuePair<String, Integer>>——key 是 String(按单词分组),value 是 Integer(合并计数)。map 也是窄变换,所以每个 Map 分区里仍是 3 条记录。
上一章使用 new ListRDD<>(data, 3)。现在多了 SparkContext,源头 RDD 也从上下文创建:
public <T> RDD<T> parallelize(List<T> data, int numberOfPartitions) {
return new ListRDD<>(this, data, numberOfPartitions);
}
parallelize 没有引入新的数据结构,只是把"从本地 List 创建 RDD"纳入 SparkContext 入口。
6.4 Map 端:按 key 哈希写文件
现在开始写 reduceByKey。
先看调用方式:
//Main.java
ShuffledRDD<String, Integer> shuffled = rdd.reduceByKey(Integer::sum, 2);
//RDD.java 中
public <K, V> ShuffledRDD<K, V> reduceByKey(
BinaryOperator<V> reduceFunction,
int numberOfReducePartitions) {
return new ShuffledRDD<>((RDD<KeyValuePair<K, V>>) this,
numberOfReducePartitions, reduceFunction);
}
两个参数分别说明。第一个 BinaryOperator<V> 是合并函数——类型是 (V, V) -> V,接收两个同类型的 value,返回合并后的 value。这里 V 是 Integer,Integer::sum 就是 (a, b) -> a + b,两个整数相加。这个函数后面会在两个地方被调用:Map 端把同一分区内同 key 的 value 合并,Reduce 端把不同分区的局部结果再合并。第二个参数 2 是 Reduce 分区数。
构造 ShuffledRDD 时不会立即计算,也不会写文件——reduceFunction 被存起来,等 compute() 执行时才用。它和前几章的 map、filter 一样,只是记录一份"将来怎么算"的配方。
在深入读写细节之前,先把 ShuffledRDD.compute() 的宏观骨架看清楚——它虽然入参是分区,但内部必须先触发全局 Map 端写入磁盘,再回到当前分区读取合并。这是 Shuffle 区别于窄依赖的核心入口。
//ShuffledRDD.java
public Iterator<KeyValuePair<K, V>> compute(Partition partition) {
ensureMapPhase();
return toKeyValuePairs(readAndMergeReducePartition(partition.index()));
}
这段代码做了两件事:
ensureMapPhase():触发全量 Map 端写入磁盘。它不关心当前计算的是哪个 Reduce 分区,而是强行遍历父 RDD 的所有 Map 分区,生成完整的中间文件(例如 3 个 Map 分区 × 2 个 Reduce 分区 = 6 个文件)。readAndMergeReducePartition(partition.index()):数据拉取。它只读取与当前传入的partition索引匹配的那些文件(例如 Reduce 分区 0 只读*_reduce_0),合并后返回。
核心就是保证 Map 阶段已经写完文件,再读取当前 Reduce 分区对应的文件并合并。整体结构清晰。后面再把这两步拆开看。
6.4.1 先保证 Map 阶段只执行一次
private void ensureMapPhase() {
if (!mapPhaseDone) {
synchronized (this) {
if (!mapPhaseDone) {
runMapPhase();
mapPhaseDone = true;
}
}
}
}
为什么这里要加 synchronized?因为上一章的 TaskScheduler 会并行计算多个 Reduce 分区。两个工作线程可能同时进入 compute(),Map 阶段只应该写一次文件,所以这里用一段临界区保证“只执行一次”。
6.4.2 再写出 Map 端文件
进入 runMapPhase() 后,先看完整代码:
private void runMapPhase() {
for (Partition mapPart : parent.partitions()) {
int mapId = mapPart.index();
List<Map<K, V>> buckets = new ArrayList<>();
for (int i = 0; i < numReducePartitions; i++) {
buckets.add(new HashMap<>());
}
Iterator<KeyValuePair<K, V>> it = parent.iterator(mapPart);
while (it.hasNext()) {
KeyValuePair<K, V> kv = it.next();
int bucketId = partition(kv.key(), numReducePartitions);
buckets.get(bucketId).merge(kv.key(), kv.value(), reduceFunc);
}
for (int reduceId = 0; reduceId < numReducePartitions; reduceId++) {
writeMapOutput(mapId, reduceId, buckets.get(reduceId));
}
}
}
这段代码分三层。
第一层,遍历父 RDD 的每个 Map 分区:
for (Partition mapPart : parent.partitions()) {
int mapId = mapPart.index();
// ...
}
第二层,对每个 Map 分区准备 N 个桶,N 等于 Reduce 分区数:
List<Map<K, V>> buckets = new ArrayList<>();
for (int i = 0; i < numReducePartitions; i++) {
buckets.add(new HashMap<>());
}
每读到一条键值对,就用 key 的哈希值决定它进入哪个桶:
static int partition(Object key, int numPartitions) {
return (key.hashCode() & Integer.MAX_VALUE) % numPartitions;
}
这行代码是 Shuffle 的核心:它按 key 计算目标 Reduce 分区,脱离了输入分区的限制。同一个 key 无论来自父 RDD 的哪个 Map 分区,只要哈希值相同,就会被路由到同一个 Reduce 分区。
然后把值放进对应桶里:
int bucketId = partition(kv.key(), numReducePartitions);
buckets.get(bucketId).merge(kv.key(), kv.value(), reduceFunc);
第二行是 Map 端 combine 的关键,拆成两步看。第一步 buckets.get(bucketId):按 key 的哈希值找到目标桶——一个 HashMap<K, V>。第二步 .merge(kv.key(), kv.value(), reduceFunc):这是 Map.merge 的标准用法,三个参数分别是 key、value、合并函数。它的行为分两种情况:
- key 不在桶里:放入
{key: value} - key 已在桶里:调
reduceFunc.apply(旧值, 新值),用返回值更新这个 key 对应的 value
reduceFunc 就是前面传入的 Integer::sum,也就是 (a, b) -> a + b。分区 0 依次读到 (hello,1), (world,1), (hello,1),桶的变化过程是:
读 (hello,1):桶里没有 hello → 放入 → {hello: 1}
读 (world,1):桶里没有 world → 放入 → {hello: 1, world: 1}
读 (hello,1):桶里已有 hello=1 → reduceFunc.apply(1, 1) = 1 + 1 = 2 → 更新为 {hello: 2, world: 1}
前两条走的是第一种情况(key 不在桶里,放入);第三条走的是第二种情况(key 已存在,调 reduceFunc 合并旧值 1 和新值 1,返回 2 更新桶)。
这叫 Map 端本地 combine。它的好处明显:同一个 Map 分区里重复出现的 key,无需逐条写入磁盘,而是先合并成一条再写出。
第三层,每个桶写成一个文件。文件名同时记录来源和去向:
for (int reduceId = 0; reduceId < numReducePartitions; reduceId++) {
writeMapOutput(mapId, reduceId, buckets.get(reduceId));
}
map_0_reduce_0
map_0_reduce_1
map_1_reduce_0
map_1_reduce_1
map_2_reduce_0
map_2_reduce_1
3 个 Map 分区,2 个 Reduce 分区,所以一共 6 个文件。空桶也会写一个表示 size=0 的文件。这样 Reduce 端可以清楚地区分“这个桶为空”和“这个文件丢了”。
至于中间文件的格式不是重点。我们选择了一种最直观的方式——使用 Java 自带的 ObjectOutputStream 进行对象序列化,将 key 和 value 按对象写入文件(类似于把数据逐行写入文本文件)。这样做的目的不是追求性能,而是让 Shuffle 的分区映射和文件读取逻辑不被格式细节干扰。具体代码如下所示:
private void writeMapOutput(int mapId, int reduceId, Map<K, V> data) {
File file = mapOutputFile(mapId, reduceId);
try (ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream(file)))) {
out.writeInt(data.size());
for (var entry : data.entrySet()) {
out.writeObject(entry.getKey());
out.writeObject(entry.getValue());
}
} catch (IOException e) {
throw new UncheckedIOException("写入 shuffle 文件失败: " + file, e);
}
}
先写一个整数 data.size(),表示这个桶里有几条记录;然后把每个 key 和 value 按对象写入。由于采用的是 Java 原生序列化,示例中的 key 和 value 必须是可序列化的——String 和 Integer 已默认实现序列化,如果换成自定义类型,则需要实现 Serializable。序列化的性能和跨语言兼容性不在本章讨论范围。我们关注的核心是 Shuffle 的物理过程:谁写文件、文件写到哪里、下游又由谁来读。
6.5 Reduce 端:读属于自己的那批文件
Reduce 端的整体逻辑也先看一眼:
private Map<K, V> readAndMergeReducePartition(int reduceId) {
Map<K, V> merged = new HashMap<>();
for (int mapId = 0; mapId < numMapPartitions; mapId++) {
Map<K, V> mapOutput = readMapOutput(mapId, reduceId);
for (var entry : mapOutput.entrySet()) {
merged.merge(entry.getKey(), entry.getValue(), reduceFunc);
}
}
return merged;
}
逻辑清晰:给定一个 Reduce 分区编号,把所有对应的 Map 文件都读出来,同时合并成一个结果 Map。具体实现往下看。
Map 端写完以后,Reduce 分区开始计算。
Reduce 分区 0 只读所有 *_reduce_0 文件:
map_0_reduce_0
map_1_reduce_0
map_2_reduce_0
Reduce 分区 1 只读所有 *_reduce_1 文件:
map_0_reduce_1
map_1_reduce_1
map_2_reduce_1
对应的实现也遵循同样的逻辑,readMapOutput(...) 会按 Map 端写入的格式读回来:
@SuppressWarnings("unchecked")
private Map<K, V> readMapOutput(int mapId, int reduceId) {
File file = mapOutputFile(mapId, reduceId);
Map<K, V> result = new HashMap<>();
try (ObjectInputStream in = new ObjectInputStream(
new BufferedInputStream(new FileInputStream(file)))) {
int size = in.readInt();
for (int i = 0; i < size; i++) {
K key = (K) in.readObject();
V value = (V) in.readObject();
result.put(key, value);
}
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException("读取 shuffle 文件失败: " + file, e);
}
return result;
}
这里先从文件头部读出一个整数 size(即文件中的 key/value 对的总数),再循环读取 size 次,依次取出每一组 key 和 value。换句话说,Map 端和 Reduce 端并不依赖隐式的数据边界来推测内容,而是遵循同一套确定的文件格式约定。
注意,readAndMergeReducePartition() 中,在读取每个 mapOutput 后,又用同样的 reduceFunc 做了一次合并。Map 端先按 key 合并同一分区内的记录,Reduce 端再把来自不同分区的局部结果合并成全局结果。也就是说,reduceByKey 在这套实现里就是两段合并:
Map 端:同一输入分区内先合并
Reduce 端:不同输入分区的局部结果再合并
这个两段合并模式之所以有效,是因为它要求 reduceFunc 满足结合律。先局部合并再全局合并,结果应该和从头到尾一次性合并一致。加法满足这个条件,所以 Integer::sum 是安全的;而减法不满足结合律,不能用于 reduceByKey。
6.6 运行示例并查看中间文件
先看演示入口。完整实现见 Main.java。main 调用 demonstrateShuffle,后者准备好输入数据,再交给 runDemo:
public static void main(String[] args) {
demonstrateShuffle();
}
private static void demonstrateShuffle() {
System.out.println("=== 1. 输入数据 ===");
List<String> lines = Arrays.asList(
"hello world hello",
"spark world hello",
"java spark hello");
System.out.println("文本输入: " + lines);
System.out.println("期望: hello→4, world→2, spark→2, java→1");
int numReducePartitions = 2;
try (SparkContext sc = new SparkContext(numReducePartitions, true)) {
runDemo(sc, lines, numReducePartitions);
}
}
runDemo 把这次演示分成 7 个步骤。本节先看前 5 步——输入、文本化为 (单词,1) 并查看 Map 分区、构造 ShuffledRDD、collect、查看中间文件;第 6、7 步(再次 collect、删除文件后再 collect)留到 6.7:
System.out.println("=== 2. 文本 → (单词,1):查看 Map 分区 ===");
RDD<KeyValuePair<String, Integer>> rdd = sc.parallelize(lines, 3)
.flatMap(line -> Arrays.asList(line.split(" ")))
.map(word -> new KeyValuePair<>(word, 1));
for (Partition partition : rdd.partitions()) {
List<KeyValuePair<String, Integer>> partData = new ArrayList<>();
rdd.iterator(partition).forEachRemaining(partData::add);
System.out.println("分区 " + partition.index() + ": " + partData);
}
System.out.println("=== 3. reduceByKey ===");
ShuffledRDD<String, Integer> shuffled = rdd.reduceByKey(
Integer::sum, numReducePartitions);
// 打印 Reduce 分区数,并提示“这里只是构造结果 RDD,还没有实际写文件”
System.out.println("=== 4. 并行 collect ===");
System.out.println("reduceByKey 结果: " + shuffled.collect());
System.out.println("=== 5. 中间文件 ===");
File dir = shuffled.shuffleDir();
File[] files = dir.listFiles();
// 排序后逐个打印 map_*_reduce_* 文件(共 6 个)
// 第 6、7 步见 6.7
第 3 步只是构造 ShuffledRDD,还没写文件;实际触发 Shuffle 的是第 4 步的 shuffled.collect()。
运行本章演示:
mvn -pl ch06-shuffle package
java -Dfile.encoding=UTF-8 -cp ch06-shuffle/target/classes com.sparklearn.Main
前半段会打印输入数据和 Map 分区:
分区 0: [KeyValuePair[key=hello, value=1], KeyValuePair[key=world, value=1], KeyValuePair[key=hello, value=1]]
分区 1: [KeyValuePair[key=spark, value=1], KeyValuePair[key=world, value=1], KeyValuePair[key=hello, value=1]]
分区 2: [KeyValuePair[key=java, value=1], KeyValuePair[key=spark, value=1], KeyValuePair[key=hello, value=1]]
然后构造 ShuffledRDD:
这里只是构造结果 RDD,还没有实际写文件
这行输出表示:reduceByKey 是 transformation,不是 action;实际触发计算的是后面的:
shuffled.collect()
这时可以把前面的调用链继续展开到 ShuffledRDD.compute() 内部:
sequenceDiagram
participant T0 as Reduce 分区 0 任务
participant T1 as Reduce 分区 1 任务
participant S as ShuffledRDD
participant M as Map 端写盘
participant F as shuffle 文件
T0->>S: compute(reduce 0)
S->>M: ensureMapPhase()
M->>F: 写出 3 x 2 个 map_*_reduce_* 文件
T1->>S: compute(reduce 1)
S-->>T1: Map 阶段已经完成
S->>F: 读取 *_reduce_0 文件
S-->>T0: 返回 reduce 0 结果
S->>F: 读取 *_reduce_1 文件
S-->>T1: 返回 reduce 1 结果
也就是说,TaskScheduler 只知道“有 2 个 Reduce 分区要并行计算”。至于第一次进入 ShuffledRDD.compute() 时为什么会先写 3 × 2 个文件,是 ShuffledRDD 在这个实现里的职责。
结果里 key 的顺序不重要,因为当前实现最后从 HashMap 取出结果:
private Iterator<KeyValuePair<K, V>> toKeyValuePairs(Map<K, V> merged) {
List<KeyValuePair<K, V>> result = new ArrayList<>();
for (var entry : merged.entrySet()) {
result.add(new KeyValuePair<>(entry.getKey(), entry.getValue()));
}
return result.iterator();
}
HashMap.entrySet() 不承诺固定顺序,所以输出顺序可能和前面列出的期望顺序不同。只要每个 key 的计数相符,就是正确结果。
演示还会打印 Shuffle 目录:
Shuffle 目录: /var/folders/.../spark-shuffle-...
map_0_reduce_0 (...)
map_0_reduce_1 (...)
map_1_reduce_0 (...)
map_1_reduce_1 (...)
map_2_reduce_0 (...)
map_2_reduce_1 (...)
临时目录的具体路径无需关注,关键在于这 6 个文件确实写到了磁盘上。
到这里,reduceByKey 的路线已经完整:
父 RDD 的 3 个 Map 分区
-> 按 key 哈希写成 3 × 2 个文件
-> 2 个 Reduce 分区分别读属于自己的文件
-> 合并相同 key
-> collect 收回结果
6.7 中间文件缺失导致 Reduce 失败
runDemo 的第 6、7 步做了一个看似“激进”的验证:先对同一个 ShuffledRDD 再 collect 一次,确认它会复用已经写出的中间文件;随后删除那 6 个中间文件,再第三次 collect。
对应代码在 Main.java 里:
System.out.println("=== 6. 再次 collect ===");
System.out.println("再次 collect 成功: " + shuffled.collect());
System.out.println("=== 7. 删除中间文件后再次 collect ===");
if (files != null) {
for (File file : files) {
file.delete();
}
}
dir.delete();
try {
shuffled.collect();
System.out.println("(不应该走到这里)");
} catch (RuntimeException e) {
String message = e.getCause() != null
? e.getCause().getMessage()
: e.getMessage();
System.out.println("失败: " + message);
}
第三次会失败。
这不是缺陷。相反,它证明 Shuffle 确实已经实现。
在这套实现里,ShuffledRDD 的 Map 阶段已经完成过一次,后面的 Reduce 分区不再重新遍历父 RDD 的迭代器,而是读磁盘上的 Map 输出文件。文件被删除后,下游便失去输入。
任何 shuffle 系统都必须先有 Map 输出,Reduce 端才能读取。如果中间文件丢失,当前实现会因为文件缺失随即失败。这意味着 Map 端和 Reduce 端之间存在着一种强依赖的时序关系:Map 端必须先写完,Reduce 端才能读;中间文件是它们之间传递数据的唯一通道,通道一旦缺失,下游便无数据可读。
Shuffle 并非抽象概念,本质上是那批按规则写出的中间文件。只要同 key 数据分布在多个分区,就必须先重新分布;重新分布就需要一个中间存储位置。
6.8 从哈希分桶到 Partitioner
在 6.4 节中,我们用一行代码决定了每条记录的去向:
int bucketId = partition(kv.key(), numReducePartitions);
这个方法虽然只有一行,但在 Shuffle 中扮演着最重要的角色——它定义了一套分区规则。我们把“给定一个 key,算出它要去哪个 Reduce 分区”这件事,抽象成下面的逻辑:
输入:一个 key(比如 "hello")
规则:计算 key 的哈希值,并限制到 0 ~ (分区数-1) 之间
输出:Reduce 分区编号
目前的实现中,这套规则被写成了 ShuffledRDD 里的一个静态方法:
static int partition(Object key, int numPartitions) {
return (key.hashCode() & Integer.MAX_VALUE) % numPartitions;
}
这里面有两个细节值得注意:
& Integer.MAX_VALUE是为了把哈希值可能为负的情况“转正”。% numPartitions确保结果一定处于当前合法的分区编号范围内。
正因为有了这个方法,无论一条数据原来待在 Map 分区 0 还是 Map 分区 2,只要它的 key 是 "hello",在本次计算中就一定会被分到同一个 Reduce 分区去。这也就实现了本章开头说的“把按输入摆放的数据,重新按 key 摆放”。
这个静态方法虽然只有几行,但它实际上承担了分区器的全部职责:给定一个 key,返回一个确定的分区编号。在更完整的分布式计算框架中,这部分逻辑会被抽象成独立的 Partitioner 接口,支持哈希分区、范围分区、自定义分区等不同策略。我们为了聚焦 Shuffle 的读写流程,没有引入这个抽象层,而是在 ShuffledRDD 中硬编码了哈希分桶规则。
Partitioner 的概念
“给定 key,算出它去哪个分区”这条规则,在生产级实现里被抽象成独立类型 Partitioner(分区器),核心就是两件事:一共有几个分区、给定 key 返回分区编号。
本书没有把分区规则抽象成一个独立的 Partitioner 类型,而是写成了
ShuffledRDD里的一个静态方法——因为这个场景只需要“算出分区号”,用不到更复杂的规则定义。但提前提一下这个概念,是为了在别处(比如生产级框架的源码)看到Partitioner这个名词时,知道它指的正是这里“根据 key 决定目标分区”的规则。
6.9 本章小结
本章在上一章的分区调度基础上,推进到跨分区数据的重新分布问题。
通过一个硬编码的 reduceByKey,本章构建了 Shuffle 的物理过程。这个过程中存在一个无法回避的物理事实:
同 key 数据分布在多个分区
-> 必须先重新分布
-> 重新分布就要有一批中间文件
-> 中间文件就是 Map 端和 Reduce 端之间传递数据的唯一通道
整章的核心压缩成三个实现步骤:
- Map 端写入磁盘:遍历父 RDD 的每个分区,先把分区内的相同 key 合并(Map 端 Combine),再按哈希值决定它们进入哪个 Reduce 桶,最后把每个桶写成独立的文件。
- Reduce 端读取:每个 Reduce 分区只需找到所有以
reduce_该分区号结尾的文件,逐个读取并再次合并相同 key。 - 依赖物理介质:一旦这些中间文件被删除,Reduce 端就会因为无数据可读而随即失败。这说明 Shuffle 不仅仅是逻辑上的“数据流动”,它本质上是“文件的生产与消费”。
至此,Shuffle 的本质已经清晰:它没有神秘机制,本质上是按规则写出的文件,等待下游读取。