借 WordCount 说 Spark

172 阅读7分钟

MapReduce 与 Spark RDD 的工作原理

MapReduce 工作流程(WordCount 示例)

MapReduce 是一种分布式计算模型,主要分为 Map、Shuffle 和 Reduce 三个阶段:

1. 输入数据

"Hello World"
"Hello MapReduce"

2. Map 阶段:拆分

作用:将输入数据拆分成键值对,表示"某个单词出现了1次"

输出

  • ("Hello", 1)
  • ("World", 1)
  • ("Hello", 1)
  • ("MapReduce", 1)

3. Shuffle 阶段:分组

作用:将具有相同 key 的数据从不同 Map 任务输出中收集到同一个 Reduce 任务(有点类似 groupby)

输出

  • ("Hello", [1, 1])
  • ("World", [1])
  • ("MapReduce", [1])
3.1 Shuffle 分区过程

假设我们设置了 2个 Reduce 任务,那么就会有 2个分区。系统会使用默认的分区器(Partitioner)对 Map 输出的键值对进行分区:

分区计算方式partition = hash(key) % numReduceTasks

例如:

  • hash("Hello") % 2 = 1,分配到分区1
  • hash("World") % 2 = 0,分配到分区0
  • hash("MapReduce") % 2 = 0,分配到分区0

分区结果

  • 分区0:("World", 1), ("MapReduce", 1)
  • 分区1:("Hello", 1), ("Hello", 1)
3.2 分区与 Reduce 任务的对应

关键点:每个分区的数据会被发送到唯一对应的 Reduce 任务

  • 分区0的所有数据 → Reduce任务0
  • 分区1的所有数据 → Reduce任务1

4. Reduce 阶段:汇总

每个 Reduce 任务只处理分配给它的那个分区的数据:

Reduce任务0

  • 接收:("World", 1), ("MapReduce", 1)
  • 按键分组:("World", [1]), ("MapReduce", [1])
  • 聚合结果:("World", 1), ("MapReduce", 1)

Reduce任务1

  • 接收:("Hello", 1), ("Hello", 1)
  • 按键分组:("Hello", [1, 1])
  • 聚合结果:("Hello", 2)

5. 最终输出

合并所有 Reduce 任务的输出:

输出

  • ("Hello", 2)
  • ("World", 1)
  • ("MapReduce", 1)

image.png

Spark RDD 工作原理

RDD (弹性分布式数据集) 是 Spark 的核心数据抽象,它是分布式存储的、可并行操作的数据集。

RDD 的特点

  • 分布式:数据被拆分成多个分区,分布在不同节点上
  • 弹性:可以从失败中恢复
  • 不可变:一旦创建,不能修改,只能通过转换生成新的 RDD
  • 惰性计算:转换操作不会立即执行,直到遇到行动操作

WordCount 示例中的 RDD 处理流程

输入文本"hello world hello spark"

1. 创建 RDD 并分区

  • 分区1:["hello", "world"]
  • 分区2:["hello", "spark"]

2. RDD 转换操作

flatMap → map → reduceByKey
  • map 后

    • 分区1:[("hello",1), ("world",1)]
    • 分区2:[("hello",1), ("spark",1)]
  • reduceByKey 后

    • 最终结果:[("hello",2), ("world",1), ("spark",1)]

RDD 的本质

RDD 不直接存储数据,而是记录了:

  1. 数据的来源(血缘关系)
  2. 如何获取数据的指令
  3. 数据的分区规则

打个比方:RDD就像一张“购物清单”,清单上写着“牛奶买2瓶(分两个袋子装)、面包买1个”,但清单本身不是牛奶和面包,只是记录了“买什么、怎么分”。你需要的时候,根据清单去拿(计算),才能得到实际的牛奶和面包(数据)。

  • “两个袋子装”就是RDD的分区规则(分区1和分区2);
  • 每个袋子里的东西(分区1:hello、world;分区2:hello、spark)就是RDD的具体分区数据;
  • 而RDD本身就像“知道每个袋子里装了啥、怎么装的”那个“认知”——它既包含了“分袋规则”,也关联着“每个袋子里的实际内容”。

WordCount 的 Java Spark 实现

import org.apache.spark.SparkConf;
import org.apache.spark.api.java.JavaPairRDD;
import org.apache.spark.api.java.JavaRDD;
import org.apache.spark.api.java.JavaSparkContext;
import scala.Tuple2;

import java.util.Arrays;

public class WordCount {
    public static void main(String[] args) {
        // 1. 创建 Spark 配置
        SparkConf conf = new SparkConf()
                .setAppName("WordCount")
                .setMaster("local[*]"); // 本地模式,使用所有可用核心
        
        // 2. 创建 Spark 上下文
        JavaSparkContext sc = new JavaSparkContext(conf);
        
        try {
            // 3. 加载输入文本文件创建 RDD
            // 假设输入文件包含 "Hello World" 和 "Hello MapReduce" 两行
            JavaRDD<String> lines = sc.textFile("input.txt");
            
            // 4. 将每行文本拆分为单词 (flatMap 转换)
            // flatMap 将每行文本转换为多个单词
            JavaRDD<String> words = lines.flatMap(line -> 
                Arrays.asList(line.split(" ")).iterator()
            );
            
            // 5. 将每个单词映射为 (单词, 1) 的键值对 (map 转换)
            JavaPairRDD<String, Integer> wordOnes = words.mapToPair(word -> 
                new Tuple2<>(word, 1)
            );
            
            // 6. 按单词聚合计数 (reduceByKey 转换)
            // reduceByKey 会触发 shuffle 操作,将相同 key 的数据发送到同一个分区
            JavaPairRDD<String, Integer> wordCounts = wordOnes.reduceByKey((count1, count2) -> 
                count1 + count2
            );
            
            // 7. 收集并打印结果 (collect 动作)
            // 注意:在实际生产环境中,对于大数据集应避免使用 collect
            System.out.println("Word Count Results:");
            for (Tuple2<String, Integer> result : wordCounts.collect()) {
                System.out.println(result._1 + ": " + result._2);
            }
            
            // 8. 将结果保存到文件 (saveAsTextFile 动作)
            wordCounts.saveAsTextFile("output");
            
        } finally {
            // 9. 关闭 Spark 上下文
            sc.close();
        }
    }
}

Shuffle 的详细过程

当执行 reduceByKey 操作时,Spark 会执行以下 Shuffle 步骤:

1. Map 阶段 (Shuffle Write)

  • 对于每个分区中的 ("单词", 1) 键值对,Spark 会:

    • 计算每个键的分区号:partitionId = partitioner.getPartition(key)
    • 在每个执行器(Executor)上,按目标分区对数据进行组织
    • 将这些数据写入内存缓冲区,当缓冲区满时溢写到磁盘
    • 生成包含元数据的索引文件,记录每个键值对的位置

2. Shuffle 阶段 (Shuffle Read)

  • 当 reduceByKey 操作开始执行时:

    • 每个执行器负责处理一个或多个输出分区
    • 执行器从所有上游任务获取属于自己负责分区的数据
    • 这一过程涉及网络传输,将分散在不同节点上的相同 key 的数据汇集到一起
    • 数据按键进行排序或哈希聚合

3. Reduce 阶段

  • 当所有相同 key 的数据都被收集到相应的分区后:

    • 对每个 key 应用 reduce 函数(在这个例子中是 (count1, count2) -> count1 + count2
    • 生成最终的 ("单词", 总次数) 结果

广播变量 & 累加器

Spark 的共享变量就像是两种特殊工具:

  1. 广播变量:就像是一份图纸的复印件,每个人都能看,但不能改
  2. 累加器:就像是一个计数器,每个人都能加数,但只有组长能看总数

广播变量:人手一份参考资料

生活中的例子

想象你和朋友们一起统计一本书中单词出现的次数。但有个规则:某些常见词(如"the"、"a"、"an")不需要统计。

不用广播变量的方式

  • 你把这些"不统计词"的清单分别告诉每个朋友
  • 浪费时间,容易出错,每个人都要记住

使用广播变量的方式

  • 你打印多份"不统计词"的清单,每人一份
  • 大家随时查阅,不用记忆,也不会出错

WordCount 中的广播变量例子

假设我们要统计文章中的单词,但不计算常见的停用词(如"the"、"a"等):

java
// 不使用广播变量的 WordCount
JavaRDD<String> lines = sc.textFile("article.txt");
Set<String> stopWords = new HashSet<>(Arrays.asList("the", "a", "an", "of", "in"));

JavaPairRDD<String, Integer> wordCounts = lines
    .flatMap(line -> Arrays.asList(line.toLowerCase().split(" ")).iterator())
    .filter(word -> !stopWords.contains(word)) // 每个任务都会复制一份 stopWords
    .mapToPair(word -> new Tuple2<>(word, 1))
    .reduceByKey((a, b) -> a + b);

问题:如果停用词列表很大(比如5000个词),那么Spark会为每个任务复制一份,非常浪费。

java
// 使用广播变量的 WordCount
JavaRDD<String> lines = sc.textFile("article.txt");
Set<String> stopWords = new HashSet<>(Arrays.asList("the", "a", "an", "of", "in"));

// 创建广播变量 - 就像打印了一份参考资料
final Broadcast<Set<String>> broadcastStopWords = sc.broadcast(stopWords);

JavaPairRDD<String, Integer> wordCounts = lines
    .flatMap(line -> Arrays.asList(line.toLowerCase().split(" ")).iterator())
    .filter(word -> !broadcastStopWords.value().contains(word)) // 每台机器只有一份
    .mapToPair(word -> new Tuple2<>(word, 1))
    .reduceByKey((a, b) -> a + b);

好处

  • 每台计算机只保存一份停用词列表,所有任务共享
  • 节省内存,提高效率
  • 特别适合大型查找表、规则集等只读数据

累加器:收集每个人的统计结果

生活中的例子

继续用统计单词的例子:除了计算单词出现次数,你还想知道:

  • 总共处理了多少个单词
  • 遇到了多少个拼写错误的单词
  • 遇到了多少个特别长(超过10个字母)的单词

不用累加器的方式

  • 每个朋友都要记录自己处理的单词数、错误数等
  • 最后大家一起报数,你再手动加起来
  • 容易遗漏,也很麻烦

使用累加器的方式

  • 放三个计数器在桌子中间
  • 每个朋友处理时顺手点击对应的计数器
  • 最后你直接看计数器上的总数

WordCount 中的累加器例子

假设我们在统计单词的同时,还想收集一些额外信息:

java
// 创建三个累加器
LongAccumulator totalWords = sc.sc().longAccumulator("总单词数");
LongAccumulator longWords = sc.sc().longAccumulator("长单词数");
LongAccumulator numericWords = sc.sc().longAccumulator("包含数字的单词数");

// WordCount 实现,同时收集统计信息
JavaRDD<String> lines = sc.textFile("article.txt");
JavaPairRDD<String, Integer> wordCounts = lines
    .flatMap(line -> Arrays.asList(line.toLowerCase().split(" ")).iterator())
    .map(word -> {
        // 计数总单词数
        totalWords.add(1);
        
        // 检查是否是长单词(超过10个字母)
        if (word.length() > 10) {
            longWords.add(1);
        }
        
        // 检查是否包含数字
        if (word.matches(".*\d.*")) {
            numericWords.add(1);
        }
        
        return word;
    })
    .mapToPair(word -> new Tuple2<>(word, 1))
    .reduceByKey((a, b) -> a + b);

// 强制执行计算
wordCounts.count();

// 打印统计结果
System.out.println("文章统计信息:");
System.out.println("总单词数: " + totalWords.value());
System.out.println("长单词数: " + longWords.value());
System.out.println("包含数字的单词数: " + numericWords.value());

好处

  • 自动从所有计算机收集统计信息
  • 不需要额外的数据处理逻辑
  • 驱动程序可以直接获取汇总结果