核心知识—— RDD常用算子之数据转换

引入

通过上一篇Spark核心数据结构:RDD,我们深入了解了Spark最核心的RDD,并留下了梳理归类 Transformations 算子和Actions 算子的小作业,下面我把常用的 RDD 算子归类整理到了下面的表格中,结合每个算子的分类、用途和适用场景,这张表格可以让小伙伴们更快、更高效地选择合适的算子来实现业务逻辑。

算子类型 适用范围 算子用途 算子集合
Transformations 任意RDD 单个RDD数据转换 map mapPartitions mapPartitionsWithIndex filter flatMap
Transformations 任意RDD 与其他RDD进行组合 union intersection join cogroup cartesian
Transformations 任意RDD 数据采样 sample distinct
Transformations 任意RDD 数据分片的重分布 coalesce repartition repartitionAndSortWithinPartitions
Transformations Paired RDD 单个RDD数据聚合 groupByKey sortByKey reduceByKey aggregateByKey
Actions 任意RDD 收集数据到SparkDriver collect first take takeSample takeOrdered count
Actions 任意RDD 数据持久化到存储系统 saveAsTextFile saveAsSequenceFile saveAsObjectFile
Actions 任意RDD 函数式编程副作用操作 foreach

本文我们先来学习同一个 RDD 内部的数据转换。掌握 RDD 常用算子是做好 Spark 应用开发的基础,而数据转换类算子则是基础中的基础。

学习 RDD 算子也是一样,要想动手操作这些算子,咱们得先有 RDD 才行。所以,接下来我们就先看看 RDD 是怎么创建的。

创建 RDD

在 Spark 中,创建 RDD 的典型方式有两种:

  • 通过 SparkContext.parallelize 在内部数据之上创建 RDD;
  • 通过 SparkContext.textFile 等 API 从外部数据创建 RDD

这里的内部、外部是相对应用程序来说的。开发者在 Spark 应用中自定义的各类数据结构,如数组、列表、映射等,都属于"内部数据";而"外部数据"指代的,是 Spark 系统之外的所有数据形式,如本地文件系统或是分布式文件系统中的数据,再比如来自其他大数据组件*(Hive、Hbase、RDBMS 等)*的数据。

parallelize

第一种创建方式的用法非常简单,只需要用 parallelize 函数来封装内部数据即可。

Scala实现案例

java 复制代码
object TestTransformations {
  /**
   * 程序的入口点。
   *
   * @param args 命令行参数,在本程序中未使用。
   */
  def main(args: Array[String]): Unit = {
    // 创建一个 Spark 配置对象,设置应用程序名称为 "TestTransformations",并使用本地模式运行,使用所有可用的 CPU 核心
    val conf = new SparkConf().setAppName("TestTransformations").setMaster("local[*]")
    // 使用配置对象创建一个 Spark 上下文对象
    val sc = new SparkContext(conf)
    // 定义一个包含字符串的数组
    val words: Array[String] = Array("a","b","c","d")
    // 将数组并行化为一个 RDD(弹性分布式数据集),以便在 Spark 集群上进行分布式处理
    val rdd: RDD[String] = sc.parallelize(words)
  }
}

Python实现案例

python 复制代码
from pyspark import SparkConf, SparkContext

conf = SparkConf().setAppName("TestTransformations").setMaster("local[*]")
sc = SparkContext.getOrCreate(conf)

rdd=sc.parallelize(["a","b","c","d"])

print(rdd.collect())

Java实现案例

java 复制代码
public class TestTransformations {
    /**
     * 程序的主入口点。
     * 
     * @param args 命令行参数
     */
    public static void main(String[] args) {
        // 创建一个Spark配置对象
        SparkConf conf = new SparkConf();
        // 设置Spark应用程序的运行模式为本地模式
        conf.setMaster("local[*]");
        // 设置Spark应用程序的名称
        conf.setAppName("TestTransformations");
        // 使用配置对象创建一个JavaSparkContext实例
        JavaSparkContext sc = new JavaSparkContext(conf);
        // 使用SparkContext创建一个包含字符串元素的JavaRDD,并将其分区数设置为3
        JavaRDD<String> words = sc.parallelize(Arrays.asList("a", "b", "c", "d"), 3);
    }
}

textFile

通常来说,在 Spark 应用内定义体量超大的数据集,其实都是不太合适的,因为数据集完全由 Driver 端创建,且创建完成后,还要在全网范围内跨节点、跨进程地分发到其他 Executors,所以往往会带来性能问题。因此 parallelize API 的典型用法,是在"小数据"之上创建 RDD。

要想在真正的"大数据"之上创建 RDD,我们还得依赖第二种创建方式,也就是通过 SparkContext.textFile 等 API 从外部数据创建 RDD。由于 textFile API 比较简单,而且它在日常的开发中出现频率比较高,因此我们使用 textFile API 来创建 RDD。在后续对各类 RDD 算子讲解的过程中,我们都会使用 textFile API 从文件系统创建 RDD。

我们在Spark的Why&How就使用了textFile的方法去创建RDD,为了方便小伙伴们查看,下面我们仅将相关核心代码贴过来。

Scala实现案例

Scala 复制代码
// 读取 spark.txt 文件
val lines: RDD[String] = sc.textFile("D:\\SparkStudy\\src\\main\\resources\\spark.txt")

Python实现案例

python 复制代码
// 读取 spark.txt 文件
rdd=sc.textFile("D:\\SparkStudy\\src\\main\\resources\\spark.txt");

Java实现案例

java 复制代码
// 读取 spark.txt 文件
JavaRDD<String> lines = sc.textFile("D:\\SparkStudy\\src\\main\\resources\\spark.txt");

RDD 内的数据转换

首先,我们先来认识一下 map 算子。毫不夸张地说,在所有的 RDD 算子中,map的使用概率是最高的。因此我们必须要掌握 map 的用法与注意事项。

map:以元素为粒度的数据转换

我们先来说说 map 算子的用法:给定映射函数 f,map(f) 以元素为粒度对 RDD 做数据转换。其中 f 可以是带有明确签名的带名函数,也可以是匿名函数,它的形参类型必须与 RDD 的元素类型保持一致,而输出类型则任由开发者自行决定。

直接看介绍对新手不太友好,接下来我们用些小例子来更加直观地展示 map 的用法。

我们使用如下代码,把包含单词的 RDD 转换成元素为(Key,Value)对的 RDD,这类RDD被称为 Paired RDD。

Scala实现案例

Scala 复制代码
val kvRDD: RDD[(String,Int)] = rdd.map(word =>(word,1))

在上面的代码实现中,传递给 map 算子的形参,例如Scala代码中的 word => (word,1),就是我们上面说的映射函数 f。只不过,这里 f 是以匿名函数的方式进行定义的,其中左侧的 word

表示匿名函数 f 的输入形参,而右侧的(word,1)则代表函数 f 的输出结果。

如果我们把匿名函数变成带名函数的话,可能你会看的更清楚一些。这里我用一段Scala代码重新定义了带名函数 f。

Scala 复制代码
def f(word:String):(String,Int) = {
  return (word,1)
}
val kvRDD: RDD[(String,Int)] = rdd.map(f)

可以看到,我们使用 Scala 的 def 语法,明确定义了带名映射函数 f,它的计算逻辑与刚刚的匿名函数是一致的。在做 RDD 数据转换的时候,我们只需把函数 f 传递给 map 算子即可。不管 f 是匿名函数,还是带名函数,map 算子的转换逻辑都是一样的。

学习要学会举一反三,我们可以通过定义任意复杂的映射函数f,然后在 RDD 之上通过调用 map(f) 去翻着花样地做各种各样的数据转换。比如通过定义如下的映射函数 f,我们就可以改写 Word Count 的计数逻辑,也就是把"chaos"这个单词的统计计数权重提高一倍:

Scala 复制代码
def f(word:String):(String,Int) = {
  if(word.equals("chaos")){
    return (word,2)
  }
  return (word,1)
}
val kvRDD: RDD[(String,Int)] = rdd.map(f)

下面我们看看 Python 和 Java 是如何实现的。

Python实现案例

Python的实现和 Scala 是非常类似,如下所示:

python 复制代码
kv_rdd=rdd.map(lambda word:(word,1))

上面那个特殊案例,也可以如下实现:

python 复制代码
def f(word):
    if word == "chaos":
        return (word, 2)
    else:
        return (word, 1)
kv_rdd = rdd.map(f)

Java实现案例

Java的实现会更麻烦一些,因为是强类型语言,在Java 8以前,也没有Scala那样强大的类型推断能力,所以实现的话会麻烦一些,相比前面两种语言的实现,会需要考虑更多,如下,Java来实现的话,可以有如下两种写法:

java 复制代码
JavaRDD<Tuple2<String, Integer>> kvRDD = words.map(word -> new Tuple2<>(word, 1));

or

JavaPairRDD<String, Integer> kvRDD = rdd.mapToPair(word -> new Tuple2<>(word, 1));

当然,也可以如下实现:

java 复制代码
JavaPairRDD<String, Integer> kvRDD = rdd.mapToPair(new PairFunction<String, String, Integer>() {
    @Override
    public Tuple2<String, Integer> call(String word) throws Exception {
        return new Tuple2<>(word, 1);
    }
});

mapPartitions:以数据分区为粒度的数据转换

尽管 map 算子足够灵活,允许开发者自由定义转换逻辑。不过,就像我们刚刚说的,map(f) 是以元素为粒度对 RDD 做数据转换的,在某些计算场景下,这个特点会严重影响执行效率。为什么这么说呢?

我们来看一个具体的例子。

比方说,我们把 Word Count 的计数需求,从原来的对单词计数,改为对单词的哈希值计数,在这种情况下,我们的代码实现需要做哪些改动呢?

以Scala为例:

Scala 复制代码
import java.security.MessageDigest
val kvRDD: RDD[(String,Int)] = rdd.map(word => (MessageDigest.getInstance("MD5").digest(word.getBytes).mkString,1))

由于 map 是以元素为单元做转换的,那么对于 RDD 中的每一条数据记录,我们都需要实例化一个 MessageDigest 对象来计算这个元素的哈希值。

在生产环境中,一个 RDD 动辄包含上百万甚至是上亿级别的数据记录,如果处理每条记录都需要事先创建 MessageDigest,那么实例化对象的开销就会聚沙成塔,不知不觉地成为影响执行效率的罪魁祸首。

那么问题来了,有没有什么办法,能够让 Spark 在更粗的数据粒度上去处理数据呢?

有的,兄弟有的,mapPartitions 和 mapPartitionsWithIndex 这对"孪生兄弟"就是用来解决类似的问题。相比 mapPartitions,mapPartitionsWithIndex 仅仅多出了一个数据分区索引,因此接下来我们把重点放在 mapPartitions 上面。

还是老样子,我们先来说说 mapPartitions 的用法。mapPartitions,顾名思义,就是以数据分区为粒度,使用映射函数 f 对 RDD 进行数据转换。对于上述单词哈希值计数的例子,我们结合下面的代码,来看看如何使用 mapPartitions 来改善执行性能:

Scala实现案例

Scala 复制代码
import java.security.MessageDigest

val kvRDD: RDD[(String,Int)] = rdd.mapPartitions(partition => {
  // 注意!这里是以数据分区为粒度,获取MD5对象实例
  val md5 = MessageDigest.getInstance("MD5")
  val newPartition = partition.map(word =>{
    // 在处理每一条数据记录的时候,可以复用同一个Partition内的MD5对象
    (md5.digest(word.getBytes).mkString,1)
  })
  newPartition
})

在上面的改进代码中,mapPartitions 以数据分区*(匿名函数的形参 partition)*为粒度,对 RDD 进行数据转换。具体的数据处理逻辑,则由代表数据分区的形参 partition 进一步调用 map(f) 来完成。

这和前一个版本的实现的本质上区别在于,我们把实例化 MD5 对象的语句挪到了 map 算子之外。如此一来,以数据分区为单位,实例化对象的操作只需要执行一次,而同一个数据分区中所有的数据记录,都可以共享该 MD5 对象,从而完成单词到哈希值的转换。

这样以数据分区为单位,mapPartitions 只需实例化一次MD5 对象,而 map 算子却需要实例化多次,具体的次数则由分区内数据记录的数量来决定。

对于一个有着上百万条记录的 RDD 来说,其数据分区的划分往往是在百这个量级,因此相比 map 算子,mapPartitions 可以显著降低对象实例化的计算开销,这对于 Spark 作业端到端的执行性能来说,无疑是非常友好的。

实际上。除了计算哈希值以外,对于数据记录来说,凡是可以共享的操作,都可以用 mapPartitions 算子进行优化。这样的共享操作还有很多,比如创建用于连接远端数据库的 Connections 对象,或是用于连接 Amazon S3 的文件系统句柄,再比如用于在线推理的机器学习模型等等。

相比 mapPartitions,mapPartitionsWithIndex 仅仅多出了一个数据分区索引,这个数据分区索引可以为我们获取分区编号,当你的业务逻辑中需要使用到分区编号的时候,就可以考虑使用这个算子来实现代码。除了这个额外的分区索引以外,mapPartitionsWithIndex在其他方面与 mapPartitions 是完全一样的。

Python实现案例

Python的实现相比Scala会复杂一些,需要手动实现类似 map 的逻辑,如下代码所示:

python 复制代码
import hashlib

def map_partitions(partition):
    md5 = hashlib.md5()
    for word in partition:
        md5.update(word.encode('utf-8'))
        yield (md5.hexdigest(), 1)

kv_rdd = rdd.mapPartitions(map_partitions)

Java实现案例

Java也是与Python类似,实现也很复杂,参考代码如下:

java 复制代码
import java.security.MessageDigest

JavaPairRDD<String, Integer> kvRDD = rdd.mapPartitionsToPair(new PairFlatMapFunction<Iterator<String>, String, Integer>() {
    @Override
    public Iterator<Tuple2<String, Integer>> call(Iterator<String> iterator) throws Exception {
        MessageDigest md5 = MessageDigest.getInstance("MD5");
        List<Tuple2<String, Integer>> result = new ArrayList<>();
        while (iterator.hasNext()) {
            String word = iterator.next();
            result.add(new Tuple2<>(md5.digest(word.getBytes()).toString(), 1));
        }
        return result.iterator();
    }
});

从Python和Java的实现可以更清晰的看到,我们针对每个数据分区,只用实例化一次对象,让对应数据分区可以共享实例化的 MD5 对象,从而完成单词到哈希值的转换。

介绍完 map 与 mapPartitions 算子之后,接下来,我们再来看一个与这两者功能类似的算子:flatMap。

flatMap:从元素到集合、再从集合到元素

flatMap 其实和 map 与 mapPartitions 算子类似,在功能上,与 map 和 mapPartitions一样,flatMap 也是用来做数据映射的,在实现上,对于给定映射函数 f,flatMap(f) 以元素为粒度,对 RDD 进行数据转换。

不过,与前两者相比,flatMap 的映射函数 f 有着显著的不同。对于 map 和 mapPartitions 来说,其映射函数 f 的类型,都是(元素) => (元素),即元素到元素。而 flatMap 映射函数 f 的类型,是(元素) => (集合),即元素到集合(如数组、列表等)。因此,flatMap 的映射过程在逻辑上分为两步:

  • 以元素为单位,创建集合;
  • 去掉集合"外包装",提取集合元素。

这么说比较抽象,我们还是来举例说明。假设,我们再次改变 Word Count 的计算逻辑,由原来统计单词的计数,改为统计相邻单词共现的次数,例如:

对于这样的计算逻辑,我们可以使用 flatMap 去实现,这里我们先给出代码实现,然后再分阶段地分析 flatMap 的映射过程:

Scala实现案例

Scala 复制代码
val wordPairRDD: RDD[String] = rdd.flatMap(line =>{
  val words: Array[String] = line.split(" ")
  for (i <- 0 until words.length-1) yield words(i) + " " + words(i+1)
})

在上面的代码中,我们采用匿名函数的形式,来提供映射函数 f。这里 f 的形参是 String 类型的 line,也就是源文件中的一行文本,而 f 的返回类型是 Array[String],也就是String 类型的数组。在映射函数 f 的函数体中,我们先用 split 语句把 line 转化为单词数组,然后再用 for 循环结合 yield 语句,依次把单个的单词,转化为相邻单词词对。

通过案例我们就能很直观的理解前面的元素到集合,再从集合到元素的转换,下面我们看下Python和Java如何实现:

Python实现案例

python 复制代码
word_pair_rdd = line_rdd.flatMap(lambda line: [
    f"{words[i]} {words[i+1]}" for i in range(len(words)-1)
    if len(words) > 1
] if (words := line.split(" ")) else [])

Java实现案例

Java的实现看起来整个过程就会更清晰很多,代码如下:

java 复制代码
JavaRDD<String> wordPairRDD= rdd.flatMap(new FlatMapFunction<String, String>() {
    @Override
    public Iterator<String> call(String line) throws Exception {
        List<String> result = new ArrayList<>();
        String[] words = line.split(" ");
        for (int i = 0; i < words.length-1; i++) {
            result.add(words[i]+" "+words[i+1]);
        }
        return result.iterator();
    }
});

得到包含词对元素的 wordPairRDD 之后,我们就可以沿用 Word Count 的后续逻辑,去计算相邻词汇的共现次数。这里留个小作业,请实现完整版的"相邻词汇计数统计"。

filter:过滤 RDD

最后我们来学习一下,与 map 一样常用的算子:filter。

filter,顾名思义,这个算子的作用,是对 RDD 进行过滤。就像是 map 算子依赖其映射函数一样,filter 算子也需要借助一个判定函数 f,才能实现对 RDD 的过滤转换。

所谓判定函数,它指的是类型为(RDD 元素类型) => (Boolean)的函数。可以看到,判定函数 f 的形参类型,必须与 RDD 的元素类型保持一致,而 f 的返回结果,只能是True 或者 False。在任何一个 RDD 之上调用 filter(f),其作用是保留 RDD 中满足 f(也就是 f 返回 True)的数据元素,而过滤掉不满足 f(也就是 f 返回 False)的数据元素。

老规矩,我们还是结合示例来讲解 filter 算子与判定函数 f。

假如我们的数据里有用一些特殊符号,例如"&、|、#"等来标识这个数据是测试数据,我们要过滤掉所有包含特殊符号的内容,具体实现代码如下:

Scala实现案例

Scala 复制代码
val list: List[String] = List("&", "|", "#")
rdd.filter(word => !word.contains(list(0)) && !word.contains(list(1)) && !word.contains(list(2)))

Python实现案例

python 复制代码
list = ["&", "|", "#"]
rdd.filter(lambda words: not any(word in words for word in list))

Java实现案例

java 复制代码
JavaRDD<String> words = rdd.flatMap(line -> Arrays.stream(line.split(" ")).iterator());

List<String> list = Arrays.asList("&", "|", "#");
JavaRDD<String> filterWord = words.filter(word -> list.stream().noneMatch(word::contains));

掌握了 filter 算子的用法之后,就可以定义任意复杂的判定函数 f,然后在 RDD 之上通过调用 filter(f) 去变着花样地做数据过滤,从而满足不同的业务需求。

总结

本文我们重点讲解了同一个 RDD 内部数据转换的 map、mapPartitions、flatMap、filter。这 4 个算子几乎囊括了日常开发中 99% 的数据转换场景,剩下的就需要小伙伴们结合文章的案例,和实际业务需求去多多实践熟悉。

相关推荐
互联网之声3 小时前
“清凉海岛·创享一夏” 海南启动旅游线路产品创意设计大赛
大数据·网络·旅游
码界筑梦坊4 小时前
基于Spark的酒店数据分析系统
大数据·分布式·python·信息可视化·spark·毕业设计·个性化推荐
码界筑梦坊4 小时前
基于大数据的美团外卖数据可视化分析系统
大数据·python·信息可视化
和尚用0飘柔04 小时前
【中间件】使用ElasticSearch提供的RestClientAPI操作ES
大数据·elasticsearch·中间件
钡铼技术物联网关4 小时前
ARM架构+CODESYS:解锁嵌入式边缘计算的实时控制新范式
大数据·linux·arm开发·人工智能·边缘计算
W_chuanqi5 小时前
Windows环境下开发pyspark程序
windows·python·spark·conda
码界筑梦坊6 小时前
基于Spark的招聘数据预测分析推荐系统
大数据·分布式·python·信息可视化·spark·毕业设计
喻师傅7 小时前
横扫SQL面试——PV、UV问题
大数据·数据库·sql·面试·数据分析·uv
煤烦恼7 小时前
scala类与集合
java·大数据·开发语言·人工智能·scala
黄雪超12 小时前
Flink介绍——实时计算核心论文之S4论文总结
大数据·论文阅读·flink