从零开始手写 Spark 06|实现一个 Shuffle

72 阅读20分钟

上一章完成了一个重要进展: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()));
}

这段代码做了两件事:

  1. ensureMapPhase():触发全量 Map 端写入磁盘。它不关心当前计算的是哪个 Reduce 分区,而是强行遍历父 RDD 的所有 Map 分区,生成完整的中间文件(例如 3 个 Map 分区 × 2 个 Reduce 分区 = 6 个文件)。
  2. 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;
}

这里面有两个细节值得注意:

  1. & Integer.MAX_VALUE 是为了把哈希值可能为负的情况“转正”。
  2. % 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 端之间传递数据的唯一通道

整章的核心压缩成三个实现步骤:

  1. Map 端写入磁盘:遍历父 RDD 的每个分区,先把分区内的相同 key 合并(Map 端 Combine),再按哈希值决定它们进入哪个 Reduce 桶,最后把每个桶写成独立的文件。
  2. Reduce 端读取:每个 Reduce 分区只需找到所有以 reduce_该分区号 结尾的文件,逐个读取并再次合并相同 key。
  3. 依赖物理介质:一旦这些中间文件被删除,Reduce 端就会因为无数据可读而随即失败。这说明 Shuffle 不仅仅是逻辑上的“数据流动”,它本质上是“文件的生产与消费”。

至此,Shuffle 的本质已经清晰:它没有神秘机制,本质上是按规则写出的文件,等待下游读取。