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)
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 不直接存储数据,而是记录了:
- 数据的来源(血缘关系)
- 如何获取数据的指令
- 数据的分区规则
打个比方: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) - 生成最终的
("单词", 总次数)结果
- 对每个 key 应用 reduce 函数(在这个例子中是
广播变量 & 累加器
Spark 的共享变量就像是两种特殊工具:
- 广播变量:就像是一份图纸的复印件,每个人都能看,但不能改
- 累加器:就像是一个计数器,每个人都能加数,但只有组长能看总数
广播变量:人手一份参考资料
生活中的例子
想象你和朋友们一起统计一本书中单词出现的次数。但有个规则:某些常见词(如"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());
好处:
- 自动从所有计算机收集统计信息
- 不需要额外的数据处理逻辑
- 驱动程序可以直接获取汇总结果