Spark-RDD常见算子实践

2,003 阅读26分钟

image-20210415210027675

转换算子

RDD 根据数据处理方式的不同,将算子整体上分为 Value 类型、双 Value 类型 和 Key-Value类型。

Value类型

map

def map(f: T => U): RDD[U]

说明:将RDD中类型为T的元素,一对一地映射为类型为U的元素,这里的转换可以是类型的转换,也可以是值的转换

val rdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4))
// 值的转换 ==> List(2, 4, 6, 8)
val mapRDD: RDD[Int] = rdd.map( _ * 2 )
mapRDD.collect().foreach(println)

// 类型的转换 ==> List("1", "2", "3", "4")
val mapRDD1: RDD[String] = rdd.map( _ + "" )
mapRDD1.collect().foreach(println)
  • 技巧:当map转换复杂的数据类型时,通过 模式匹配 简洁表达
val rdd: RDD[(String, Int)] = sc.makeRDD(List(("a", 1), ("b", 2), ("c", 3), ("d", 4)))

val mapRdd: RDD[String] = rdd.map(
    (tuple: (String, Int)) => {
       tuple._1 + tuple._2 // "a1","b2"...
    }
)

val mapRdd1: RDD[String] = rdd.map {	//最外层是 { }
    case (str, num) => {
       str + num
    }
}

mapPartitions

def mapPartitions(
      f: Iterator[T] => Iterator[U],
      preservesPartitioning: Boolean = false): RDD[U]

说明:将待处理的数据 以分区为单位 发送到计算节点进行处理,输入参数为RDD中每一个分区的迭代器。参数二preservesPartitioning是否保留父RDD的分区信息。

示例:获取每个数据分区的最大值

val rdd = sc.makeRDD(List(1, 2, 3, 4), 2)

// 传入 f: Iterator => Iterator
val mpRDD: RDD[Int] = rdd.mapPartitions(
    iterator => {
        List(iterator.max).iterator
    }
)
mpRDD.collect().foreach(println)

output: 
2
4

思考:map 和 mapPartitions 的区别?

  • 数据处理角度 Map 算子是读一个record计算一个record,类似于串行操作。而 mapPartitions 算子是以分区为单位进行批处理操作。

  • 功能的角度 Map 算子主要目的将数据源中的数据进行转换,不会减少或增多数据,映射前后维度不变。 MapPartitions 算子需要传递一个迭代器,返回一个迭代器,没有要求的元素的个数保持不变,可以增加或减少数据

  • 性能的角度 Map 算子因为类似于串行操作,所以性能比较低,而 mapPartitions 算子类似于批处理,所以性能较高。

    但是 mapPartitions 算子会将整个分区的数据加载到内存进行引用,那么这样会导致内存可能不够用,出现内存溢出的错误。所以在内存有限的情况下,不推荐使用MapPartitions

思考:mapPartitions 使用场景?

如果在映射过程中需要频繁创建额外的对象,mapPartitions 可以使 RDD中各个分区可以共享同一个对象以提高性能。

val mapPartRDD2: RDD[Int] = rdd.mapPartitions(
  iter => {
    val connection = "JDBC...."
    iter.foreach (
      u => println(s"调用连接${connection}....元素为${u}")
    )
    Nil.iterator // 空集合的迭代器
  }
)

  • 小结:

map的实现:同一分区内的数据,必须等待全部的逻辑执行完毕,才会加载下一个数据,这就是串行;

mapPartitions的实现: 会先将分区内的全部数据加载到内存中,然后执行逻辑。

image-20210426105803745

mapPartitionsWithIndex

def mapPartitionsWithIndex[U: ClassTag](
    f: (Int, Iterator[T]) => Iterator[U],
    preservesPartitioning: Boolean = false): RDD[U]

说明:将待处理的数据 以分区为单位 发送到计算节点进行处理,在处理时同时可以获取当前分区索引

示例:获取第二个数据分区的数据

val rdd = sc.makeRDD(List(1, 2, 3, 4, 5), 3)
//分区结果 ==> 【1】,【2,3】,【4,5】

//获取第2个分区的数据
val mpiRDD = rdd.mapPartitionsWithIndex(
    (index, iterator) => {
        if (index == 1) {
            iterator
        } else {
            Nil.iterator
        }
    }
)

mpiRDD.collect().foreach(println)// 【2,3】

flatMap

def flatMap(f: T => TraversableOnce[U]): RDD[U]

说明:将RDD中的每一个元素进行一对多转换,然后扁平化

强调:f: T => TraversableOnce[U]的返回值必须是可遍历集合,不能是标量

// 先映射后打散,只需传入映射逻辑
val rdd1: RDD[String] = sc.makeRDD(List("hello spark", "hello scala"))

// "hello spark" ==> Array["hello", "spark"] ==> "hello", "spark"
val flatRDD1: RDD[String] = rdd1.flatMap(
    s => s.split(" ")		
)

flatRDD1.collect().foreach(println)
//    hello
//    spark
//    hello
//    scala

示例:将 List(List(1,2),3,List(4,5)) 进行扁平化操作

val rdd: RDD[Any] = sc.makeRDD(List(
    List(1, 2), 3, List(4, 5)
))

// List中元素类型不同,需模式匹配
val flatRDD = rdd.flatMap{
    case list: List[_] => list
    case a: Int 	   => List(a)
}

flatRDD.collect().foreach(println)	// 1 2 3 4 5

flatMap可以同时完成filter + map,不满足的line返回Nil即可。

glom

def glom(): RDD[Array[T]]

说明:将 同一个分区 的数据直接转换为相同类型的内存数组进行处理,分区不变,每个分区只有一个数组元素。

示例: 计算所有分区最大值求和(分区内取最大值,分区间求和)

val rdd: RDD[Int] = sc.makeRDD(List(1,2,3,4,5), 2)
// 【1,2】,【3,4,5】

// List(1,2,3,4) => RDD(Array(1,2), Array(3,4,5))
val glomRDD: RDD[Array[Int]] = rdd.glom()

// Array(1,2) -> 2;  Array(3,4,5) -> 5
val maxRDD: RDD[Int] = glomRDD.map(
    arr => arr.max
)
// RDD(2, 5) => 归约(相加)
val res: Int = maxRDD.reduce(_ + _)

println(res) // res = 7
  • 补充:使用行动算子aggregate一步实现
val res: Int = rdd.aggregate(0)(math.max(_, _), _ + _)
  • 思考:如何理解分区不变性?
// 将RDD保存到目录下,以观察分区情况
val rdd: RDD[Int] = sc.makeRDD(List(1,2,3,4), 2)
rdd.saveAsTextFile("output1")

val mapRDD: RDD[Int] = rdd.map(_ * 2)
mapRDD.saveAsTextFile("output2")

/*
    原始rdd的每条record会有分区号,经过map操作后,依然在相同的分区中

    output1  part-00000   1 2
             part-00001   3 4

    output2  part-00000   2 4
             part-00001   6 8

*/

image-20210416083003969

与分区不变性相对立的是shuffle,下面介绍的算子groupBy涉及到shuffle过程。

groupBy

def groupBy
	(f: T => K): RDD[(K, Iterable[T])]

说明:

/**
 *  groupBy(f: T => K ) 将数据源中的元素映射到key上
 *  T是数据源元素的类型,K为任意类型
 *
 *  groupBy将数据源中的每一个数据进行f映射,根据返回的分组key进行分组
 *  相同的key值的数据会放置在一个可迭代的集合中,即Iterable()中
 */

示例1:按奇偶分组

val intRdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4, 5, 6), 2)
// 按奇偶分组
val groupRDD1: RDD[(Int, Iterable[Int])] = intRdd.groupBy(
    (num: Int) => {
       num % 2
    }
)

groupRDD1.collect().foreach(println)
//    (0,CompactBuffer(2, 4, 6))
//    (1,CompactBuffer(1, 3, 5))

示例2:按单词首字母分组

val strRdd: RDD[String] = sc.makeRDD(List("Hello", "Spark", "Scala", "Hadoop"), 2)
//按首字母分组
val groupRDD2: RDD[(Char, Iterable[String])] = strRdd.groupBy(
    word => word.charAt(0)
)

groupRDD2.collect().foreach(println)
//    (H,CompactBuffer(Hello, Hadoop))
//    (S,CompactBuffer(Spark, Scala))
  • 思考:分组和分区有什么关系?

分组后,一个组的数据会在一个分区中,但是并不是说一个分区中只有一个组,一句话:分组和分区没有必然的关系。

val intRdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4, 5, 6), 2)
// 原集合分区
intRdd.saveAsTextFile("output1")

val groupRDD: RDD[(Int, Iterable[Int])] = intRdd.groupBy(
    (num: Int) => {
        num % 2
    }
)
// 分组后
groupRDD.saveAsTextFile("output2")

结果:

output1:  part-00000  1 2 3
		  part-00001  4 5 6

output2:  part-00000  (0,CompactBuffer(2, 4, 6))
		  part-00001  (1,CompactBuffer(1, 3, 5))

image-20210415185417081

filter

def filter(f: T => Boolean): RDD[T]

说明:将数据根据指定的规则进行筛选过滤,符合规则的数据保留,不符合规则的数据丢弃(返回True的保留,False丢弃)

示例:过滤,只保留偶数

val rdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4, 5, 6))
val filterRdd: RDD[Int] = rdd.filter(num => num % 2 == 0)
filterRdd.collect().foreach(println)	// 2 4 6

当数据进行筛选过滤后,分区不变,但是分区内的数据可能不均衡,即数据倾斜。

val rdd1: RDD[Int] = sc.makeRDD(List(2, 4, 6, 8, 10, 1, 3, 5, 8, 9), 3)
// 原始三个分区:【2,4,6】、【8,10,1】、【3,5,8,9】

// 过滤:偶数留下,其余剔除
val filterRDD1: RDD[Int] = rdd1.filter(num => num % 2 == 0)
filterRDD1.saveAsTextFile("spark-course/src/main/resources/output1")
// 过滤后,分区不变,可能导致数据分布不均衡:【2,4,6】、【8,10】、【8】

// 分区过滤后进行缩减,缓解数据分布不均衡:filter + coalesce
filterRDD1
  .coalesce(2)
  .saveAsTextFile("spark-course/src/main/resources/output2")
// 2个分区:【2,4,6】、【8,10,8】

distinct

def distinct(): RDD[T]
def distinct(numPartitions: Int): RDD[T]

说明:将数据集中重复的数据去重

/**
*  空参 distinct() 调用的实际是 distinct(partitions.length)
*  其中,distinct(numPartitions: Int) 去重原理为
*  map(x => (x, null)).reduceByKey((x, _) => x, numPartitions).map(_._1)
*/

示例:去重

val rdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4, 1, 2, 3, 4))
rdd.distinct()
	.collect().foreach(println)	// 1 2 3 4

coalesce

def coalesce(numPartitions: Int, 
             shuffle: Boolean = false): RDD[T]

说明:根据数据量增减分区,用于大数据集过滤后,提高小数据集的执行效率。

当 spark 程序中,存在过多的小任务的时候,可以通过 coalesce 方法,收缩分区,减少分区的个数,减小任务调度成本。

  • 缩小分区(N > M)且 N 和 M 相差不多的两种形式
/*
   1. coalesce方法默认情况下不会将分区的数据打乱重新组合
   例如元素3和4原本同一分区,那么缩减后仍会处于同一分区(窄依赖)
   这种情况下的缩减分区可能会导致数据倾斜   
 */
val rdd = sc.makeRDD(List(1,2,3,4,5,6), 3)
val newRDD: RDD[Int] = rdd.coalesce(2)
newRDD.saveAsTextFile("output")
// 产生两个分区:分别为【1,2】、【3,4,5,6】

image-20210416102520353

// 2. 如果想要让数据均衡,可以进行shuffle处理,第二个参数为True(宽依赖)
val newRDD: RDD[Int] = rdd.coalesce(2, true)
newRDD.saveAsTextFile("output")

image-20210416102626349

  • 缩小分区( N > M)且N和M差距悬殊(比如N=1000,M=1)

如果不进行shuffle,由于父子RDD是窄依赖,他们同处于一个Stage中,就可能造成Spark程序运行的并行度不够(Task个数由Stage的最后一个RDD的分区个数决定)。比如M=1时,由于只有一个分区,所以只会有一个Task运行,为了使coalesce之前的操作有更好的并行度,可以将shuffle参数设为true。

image-20210423105554847

  • 我想要扩大分区(N < M),怎么办?

一般情况下N个分区由于数据分布不均,利用HashPartitioner函数将数据重新分区为M个,这时必须将shuffle参数设为为True。

扩大分区个数,如果不进行shuffle操作,是没有意义的,无法改变RDD分区数目

val rdd = sc.makeRDD(List(1,2,3,4,5,6), 2)
val newRDD: RDD[Int] = rdd.coalesce(3, shuffle = true)

spark提供了一个简化的操作repartition,专门用于扩大分区, 底层代码调用的就是coalesce,而且采用shuffle。

repartition

def repartition(numPartitions: Int): RDD[T] = coalesce(numPartitions, shuffle = true)

示例:

val rdd = sc.makeRDD(List(1,2,3,4,5,6), 2)
rdd.repartition(3)
   .saveAsTextFile("output")
// 【1,6】 【2,5】 【3,4】
  • 思考:coalesce 和 repartition 区别?

coalesce 和 repartition 本质是相同的,后者底层代码调用的就是coalesce,且一定要经过shuffle。

习惯上减少分区使用coalesce, 扩大分区使用repartition 。

sortBy

def sortBy(
      f: T => K,
      ascending: Boolean = true,
      numPartitions: Int = this.partitions.length): RDD[T]

说明:该操作用于排序数据。在排序之前,可以将数据通过 f 函数进行处理,之后按照 f 函数处理的结果进行排序,默认为升序排列,第二个参数为False为降序。

默认排序前后 RDD 的分区数一致

示例1:

val rdd: RDD[Int] = sc.makeRDD(List(2, 1, 6, 5, 4, 3), 2)
val newRDD: RDD[Int] = rdd.sortBy(num=>num)
newRDD.saveAsTextFile("output")
// 两个分区为 【1,2,3】 【4,5,6】,所以经历了shuffle 

示例2:

val rdd = sc.makeRDD(List(("1", 1), ("11", 2), ("2", 3)), 2)
// 按元组的第一个元素,降序
val sortRDD: RDD[(String, Int)] = rdd.sortBy(t => t._1, false)
sortRDD.collect().foreach(println)	// ("2",3) ("11",2) ("1",1)

sample*

def sample(
      withReplacement: Boolean,
      fraction: Double,
      seed: Long = Utils.random.nextLong): RDD[T]

说明:根据指定的规则从数据集中抽取数据

  • 不放回
val rdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10))

/*   抽取数据不放回(伯努利算法)
     具体实现:根据种子和随机算法算出一个数和第二个参数设置几率比较,小于第二个参数要,大于不要
     第一个参数:抽取的数据是否放回,false:不放回
     第二个参数:抽取的几率,范围在[0,1]之间,0:全不取;1:全取;
     第三个参数:随机数种子,种子相同随机结果也是相同的,不传递的话默认值为当前系统时间
     */
println(rdd.sample(
    false,
    0.3
).collect().mkString(","))
// 7,8,9
  • 放回
/* 	 抽取数据放回(泊松算法)
     第一个参数:抽取的数据是否放回,true:放回
     第二个参数:表示数据源中的每条数据被抽取的可能次数
     第三个参数:随机数种子
 */
println(rdd.sample(
    true,
    2
).collect().mkString(","))
// 1,1,2,2,4,6,6,6,6,6,7,8,8,9,10
  • 思考:抽样函数有什么用呢?

对发生数据倾斜的分区数据集,进行多次抽样,从样本中分析数据的分布。

双 Value 类型

方法签名:

// 交集
def intersection(other: RDD[T]): RDD[T]
// 并集
def union(other: RDD[T]): RDD[T]
// 差集
def subtract(other: RDD[T]): RDD[T]
// 拉链,形成元组
def zip(other: RDD[U]): RDD[(T, U)]

示例:

val rdd1: RDD[Int] = sc.makeRDD(List(1,2,3,4))
val rdd2: RDD[Int] = sc.makeRDD(List(3,4,5,6))

// 交集 : 【3,4】,会去重
val rdd3: RDD[Int] = rdd1.intersection(rdd2)
println(rdd3.collect().mkString(","))

// 并集 : 【1,2,3,4,3,4,5,6】,不会去重
val rdd4: RDD[Int] = rdd1.union(rdd2)
println(rdd4.collect().mkString(","))

// 差集 : 【1,2】
val rdd5: RDD[Int] = rdd1.subtract(rdd2)
println(rdd5.collect().mkString(","))

// 拉链 : (1,3),(2,4),(3,5),(4,6)
val rdd6: RDD[(Int, Int)] = rdd1.zip(rdd2)
println(rdd6.collect().mkString(","))

特点:

  • 交集、并集 和 差集要求两个数据源数据类型一致
  • 拉链操作:两个数据源的类型可以不一致
val rdd7 = sc.makeRDD(List("a","b","c","d"))
val rdd8 = rdd1.zip(rdd7)
println(rdd8.collect().mkString(","))
// (1,a),(2,b),(3,c),(4,d)
  • 拉链操作:两个RDD要求分区数量要保持一致,分区中数据量保持一致
val rdd1 = sc.makeRDD(List(1,2,3,4),2)

val rdd2 = sc.makeRDD(List(3,4,5,6),3)
// val rdd3: RDD[(Int, Int)] = rdd1.zip(rdd2) 分区数量不一致,异常

val rdd4 = sc.makeRDD(List(3,4,5,6,7,8), 2)
// val rdd5: RDD[(Int, Int)] = rdd1.zip(rdd5) 分区中数据量不一致,异常

注:scala语法中,两个集合zip操作,不要求元素个数相同。

  • 是否存在shuffle?

一般情况下,intersection和subtract都会有shuffle过程;而union是窄依赖(RangeDependency ),不存在shuffle,如下图所示。

image-20210423111244937

Key - Value 类型

Value 类型 与 Key - Value 类型区别在于,前者更为广泛,单值RDD[U]与键值RDD[(K,V)]都适用;后者只适用于RDD[(K,V)]

Key - Value 类型的算子如partitionBy()是PairRDDFunctions类中的方法,那么RDD为何可以调用呢?

/* 因为存在 隐式转换(二次编译),RDD => PairRDDFunctions  */
abstract class RDD {...}
object RDD{
    implicit def rddToPairRDDFunctions(rdd: RDD) = new PairRDDFunctions(rdd)
    ...
}

Key-value中涉及Shuffle的算子,都可以传入分区器,即按照指定策略分区+计算,这一点在预分区、减少Shuffle次数上非常有用!

partitionBy

def partitionBy(partitioner: Partitioner): RDD[(K, V)]

说明:将数据 基于 key 按照指定 Partitioner 重分区。Spark 默认的分区器是 HashPartitioner。

按照指定的分区器,对Key进行计算得到新的分区号,从而对数据重新分区。

val rdd: RDD[(Int, String)] = sc.makeRDD(
    Array((1,"aaa"),(2,"bbb"),(3,"ccc"), (4,"ddd")),
    2)

/* HashPartitioner(2) 传入分区数为2,也可以与原分区数不同 */
val value: RDD[(Int, String)] = rdd.partitionBy(new HashPartitioner(2))

value.saveAsTextFile("output")  
// Key 按照哈希分区器划定分区,【(1,"aaa"),(3,"ccc")】 【(2,"bbb"),(4,"ddd")】
  • 思考:如果重分区的分区器和当前 RDD 的分区器一样怎么办?
/*	当【分区器类别 + 分区数量】相同时,就不会创建新的RDD,返回当前RDD
	二者有任一不同,将创建新的RDD返回 */

val value: RDD[(Int, String)] = rdd.partitionBy(new HashPartitioner(2))
val value1: RDD[(Int, String)] = value.partitionBy(new HashPartitioner(2))
println(value1 == value)  // true
  • 思考:Spark 还有其他分区器吗?

常见的有 HashPartitioner、RangePartitioner

  • 思考:如果想按照自己的方法进行数据分区怎么办?

自定义分区器,继承 Partitioner

mapValues

def mapValues(f: V => U): RDD[(K, U)] 

说明:针对KV类型的映射map,当K不变,只对V进行映射时,可采用mapValues简化

示例:wordCount

val rdd = sc.makeRDD(List("Hello Scala", "Hello Spark"))
val flatRdd: RDD[String] = rdd.flatMap(_.split(" "))
val groupRdd: RDD[(String, Iterable[String])] = flatRdd.groupBy(str => str)

// 使用map,(k1,V1) -> (k2,v2)
val value: RDD[(String, Int)] = groupRdd.map{
    case (a, b) => {
        (a, b.size)
    }
}

// 使用mapValues,(K,V) -> (K,U)
val value: RDD[(String, Int)] = groupRdd.mapValues(
    iter => iter.size
)

outout:
// (Spark,1)
// (Hello,2)
// (Scala,1)

reduceByKey

// 泛型为[K, V],V代表value的类型
def reduceByKey(func: (V, V) => V): RDD[(K, V)] 
def reduceByKey(func: (V, V) => V, numPartitions: Int): RDD[(K, V)]
def reduceByKey(partitioner: Partitioner, func: (V, V) => V)

说明:相同的key的数据进行value的聚合操作(两两聚合),传入的func表示两个val的聚合逻辑。如果key的数据只有一个,是不会参与运算的,直接返回。

示例:wordCount

val rdd = sc.makeRDD(List(
    ("a", 1), ("a", 2), ("a", 3), ("b", 4)
))

// 【1,2,3】-> 【3,3】 -> 【6】
val value: RDD[(String, Int)] = rdd.reduceByKey(
    (x: Int, y: Int) => {
        x + y
    }
)

value.collect().foreach(println)
// (a,6)
// (b,4)

groupByKey

def groupByKey(): RDD[(K, Iterable[V])]
def groupByKey(numPartitions: Int): RDD[(K, Iterable[V])]
def groupByKey(partitioner: Partitioner): RDD[(K, Iterable[V])]

说明:将数据源的数据按照key ,对 value 进行分组

 val rdd = sc.makeRDD(List(
     ("a", 1), ("a", 2), ("a", 3), ("b", 4)
 ))

// groupByKey : 针对[K,V]类型,将数据源中的数据,相同key的数据分在一个组中,形成一个对偶元组
//              元组中的第一个元素就是key,元组中的第二个元素就是相同key的value的集合
val groupRDD1: RDD[(String, Iterable[Int])] = rdd.groupByKey()

groupRDD1.collect().foreach(println)
//    (a,CompactBuffer(1, 2, 3))
//    (b,CompactBuffer(4))

println(groupRDD1.partitioner)
// Some(org.apache.spark.HashPartitioner@8) 默认使用哈希分区器,8个分区
  • groupByKey的RDD依赖关系:

image-20210423211812304

  • 思考:groupByKey 与 groupBy的区别?
groupByKeygroupBy
适用集合类型必须是RDD[(K, V)]任意RDD[T]
分组逻辑按照Key分组自定义f:T->key,需传入
返回值k ->Iterable(v1, v2, ...)k -> Iterable(T1, T2, ...)
  • 经典考题:reduceByKey 和 groupByKey 的区别?
    • 从 shuffle 的角度:reduceByKey 和 groupByKey 都存在 shuffle 的操作,但是 reduceByKey 可以在 shuffle 前对分区内相同 key 的数据进行预聚合(combine)功能,这样会减少落盘的数据量,而 groupByKey 只是进行分组,不存在数据量减少的问题,reduceByKey 性能比较高。

    • 从功能的角度:reduceByKey 其实包含分组和聚合的功能。GroupByKey 只能分组,不能聚合,所以在分组聚合的场合下,推荐使用 reduceByKey,如果仅仅是分组而不需要聚合,只能使用 groupByKey。

image-20210426095149931

image-20210416153333586

reduceByKey针对分区内与分区间,计算规则是相同的。如果分区内与分区间的计算规则不同,可以使用aggregateByKey。

aggregateByKey

def aggregateByKey(zeroValue: U)
			(seqOp: (U, V) => U, combOp: (U, U) => U): RDD[(K, U)] 

说明:将数据根据 不同的规则 进行分区内计算和分区间计算

/**
  * aggregateByKey存在函数柯里化,有两个参数列表
  * 第一个参数列表,需要传递一个参数,表示为初始值(只用于分区内计算)
  * 		用于当碰见key第一个value时,与它进行分区内计算
  * 第二个参数列表需要传递2个参数
  * 		参数1表示分区内计算规则
  * 		参数2表示分区间计算规则
  */

示例:取出每个分区内相同 key 的最大值然后分区间相加

val rdd = sc.makeRDD(List(
    ("a", 1), ("a", 2), ("b", 3),
    ("b", 4), ("b", 5), ("a", 6)
),2)

rdd.aggregateByKey(0)(
    (x, y) => math.max(x, y),
    (x, y) => x + y
).collect().foreach(println)	
//(b,8)
//(a,8)

image-20210416161934580

初始值zeroValue的选取是重要的,如果给的值不合适,将会是不同的结果:

rdd.aggregateByKey(5)(
    (x, y) => math.max(x, y),
    (x, y) => x + y
).collect().foreach(println)
//(b,10)
//(a,11)

image-20210416162149008

aggregateByKey中初始值的类型与原本值的类型 可以不同,而最终的返回数据结果应该和初始值的类型保持一致,重温一下方法签名:

// 键值对的泛型[K, V],输出为[K, U]
def aggregateByKey(zeroValue: U)
			(seqOp: (U, V) => U, combOp: (U, U) => U): RDD[(K, U)]

示例:获取相同key的数据的平均值

val rdd = sc.makeRDD(List(
    ("a", 1), ("a", 2), ("b", 3),
    ("b", 4), ("b", 5), ("a", 6)
),2)


// 获取相同key的数据的平均值 => (a, 3),(b, 4)
val newRDD : RDD[(String, (Int, Int))] = rdd.aggregateByKey( (0,0) )(
    // (Tuple, Int) => Tuple 
    // a: t=(0,0),v=1 => (1,1) => t=(1,1),v=2 => (3,2)
    ( t, v ) => {
        (t._1 + v, t._2 + 1)
    },
    // (Tuple, Tuple) => Tuple 
    // a: t1=(3,2),t2=(6,1) => (9,3)
    (t1, t2) => {
        (t1._1 + t2._1, t1._2 + t2._2)
    }
)

// 对[K,V]做映射时,若K保持不动,仅对V做映射,可使用mapValues(f: V => U)
val resultRDD: RDD[(String, Int)] = newRDD.mapValues {
    case (num, cnt) => {
        num / cnt
    }
}
resultRDD.collect().foreach(println) // (a, 3) (b, 4)

image-20210417092443015

foldByKey

def foldByKey(zeroValue: V)(func: (V, V) => V): RDD[(K, V)]

说明:当分区内计算规则和分区间计算规则相同时,aggregateByKey 就可以简化为 foldByKey

示例:

val rdd = sc.makeRDD(List(
    ("a", 1), ("a", 2), ("b", 3),
    ("b", 4), ("b", 5), ("a", 6)
),2)

// 二者等价: 结果为 (b,12),(a,9)
rdd.aggregateByKey(0)(_+_, _+_)

rdd.foldByKey(0)(_+_)

注:值得注意的是,foldByKey保持键值对的泛型不变((k,v)->(k,v)),而aggregateByKey可能会改变输出的值类型((k,v)->(k,u))。

  • 问题:当分区内计算规则和分区间计算规则相同时,foldByKey和reduceByKey都能实现,二者有什么区别呢?
def reduceByKey(func: (V, V) => V): RDD[(K, V)] 
def foldByKey(zeroValue: V)(func: (V, V) => V): RDD[(K, V)]

相同点:不会改变键值对类型(K,V)->(K,V),针对相同的Key,对Value做两两聚合操作

不同点reduceByKey没有初始值,如果key的数据只有一个,是不会参与运算的,直接返回;而foldByKey要给定初始值,如果key的数据只有一个,就会与初始值进行计算。

combineByKey

def combineByKey(
      createCombiner: V => C,
      mergeValue: (C, V) => C,
      mergeCombiners: (C, C) => C): RDD[(K, C)]

说明:它是对aggregateByKey的另一种实现,它不直接给定初始值,而是将相同key的第一个数据进行结构的转换,作为初始值。

/**
 * combineByKey : 方法需要三个参数
 * 1. createCombiner:将相同key的第一个数据进行结构的转换,实现操作
 * 2. mergeValue:分区内的计算规则
 * 3. mergeCombiners:分区间的计算规则
 */

示例:获取相同key的数据的平均值

val rdd = sc.makeRDD(List(
      ("a", 1), ("a", 2), ("b", 3),
      ("b", 4), ("b", 5), ("a", 6)
    ),2)

//注:因为第一个参数返回值类型是动态的,所以计算规则需加上泛型限定
val newRDD : RDD[(String, (Int, Int))] = rdd.combineByKey(
    t => (t, 1),				//"a":   1 => (1, 1)形成初始值
    ( t: (Int, Int), v) => {
        (t._1 + v, t._2 + 1)
    },
    (t1: (Int, Int), t2: (Int, Int)) => {
        (t1._1 + t2._1, t1._2 + t2._2)
    }
)

val resultRDD: RDD[(String, Int)] = newRDD.mapValues {
    case (num, cnt) => {
        num / cnt
    }
}

resultRDD.collect().foreach(println)	// (a, 4) (b, 4)

执行流程(初始值 -> 分区内 -> 分区间)如图所示: image-20210417094002929

  • groupByKey、reduceByKey、foldByKey、aggregateByKey这四种算子,最终都归结为对combineByKey 的调用
  • combineByKey 共有五个参数如下:

image-20210423142640753

  • 值得注意的是:groupByKey的参数mapSideCombine=false,不会在map端进行combine操作,其余四种算子该参数为mapSideCombine=true

  • 归约算子的内部实现:

image-20210423143117494

转换操作生成RDD的类型
combineByKey
(reduceByKey、foldByKey、aggregateByKey)
MapParitionsRDD(预聚合)-> ShuffledRDD -> MapParitionsRDD
groupByKeyShuffledRDD -> MapParitionsRDD

其中, ShuffledRDD 进行 reduce(通过 aggregate + mapPartitions() 操作来实现)得到 MapPartitionsRDD。

  • 对比:reduceByKey、foldByKey、aggregateByKey、combineByKey 的区别?
初始值相同的Key第一个值分区内&分区间的计算规则
reduceByKey相同 key 的第一个数据不进行任何计算计算规则相同
foldByKey给定相同 key 的第一个数据和初始值进行分区内计算计算规则相同
aggregateByKey给定相同 key 的第一个数据和初始值进行分区内计算计算规则可以不同
combineByKey相同 key 的第一个数据结构转换,作为初始值计算规则可以不同

重要相同点:四个算子均具有“预聚合”功能,即在shuffle落盘之前,在内存中先聚合数据,再写入磁盘,减少数据落盘量

示例:实现wordCount

val rdd = sc.makeRDD(List(
    ("a", 1), ("a", 2), ("b", 3),
    ("b", 4), ("b", 5), ("a", 6)
),2)

//实现wordCount的四种方式:(b,12)、(a,9)
rdd.reduceByKey(_+_)
rdd.aggregateByKey(0)(_+_, _+_)
rdd.foldByKey(0)(_+_)
rdd.combineByKey(v=>v, (v1: Int, v2) => v1+v2, (v1: Int, v2: Int)=> v1+v2)

join

def join(other: RDD[(K, W)]): RDD[(K, (V, W))]

说明:在类型为(K,V)(K,W)的 RDD 上调用,返回一个相同 key 对应的所有元素连接在一起的(K,(V,W))的 RDD

val rdd1 = sc.makeRDD(List( ("a", 1), ("b", 2), ("c", 3) ))

val rdd2 = sc.makeRDD(List( ("a", 4), ("a", 5), ("c", 6) ))

val joinRDD: RDD[(String, (Int, Int))] = rdd1.join(rdd2)
joinRDD.collect().foreach(println)
//(a,(1,5))
//(a,(1,4))
//(c,(3,6))

如果两个数据源中key没有匹配上,那么数据不会出现在结果中(内连接,取交集);

如果两个数据源中key有多个相同的,会逐个匹配,可能会出现笛卡尔乘积,且会发生shuffle,故不推荐使用。

leftOuterJoin

def leftOuterJoin(other: RDD[(K, W)]): RDD[(K, (V, Option[W]))]

说明:类似于左外连接,保留主表的所有数据,从表数据会由Option封装。

val rdd1 = sc.makeRDD(List(("a", 1), ("b", 2), ("c", 3)))
val rdd2 = sc.makeRDD(List(("a", 4), ("b", 5)))
val leftRDD: RDD[(String, (Int, Option[Int]))] = rdd1.leftOuterJoin(rdd2)
//(a,(1,Some(4)))
//(b,(2,Some(5)))
//(c,(3,None))

相应的,还有右外连接rightOuterJoin

cogroup

def cogroup(other: RDD[(K, W)]): RDD[(K, (Iterable[V], Iterable[W]))]

说明:在类型为(K,V)(K,W)的 RDD 上调用,返回一个(K,(Iterable<V>,Iterable<W>))类型的 RDD

val rdd1 = sc.makeRDD(List(("a", 1), ("a", 2), ("b", 3)))
val rdd2 = sc.makeRDD(List(("a", 4), ("b", 5),("c", 6),("c", 7)))

val value: RDD[(String, (Iterable[Int], Iterable[Int]))] = rdd1.cogroup(rdd2)
// (a,(CompactBuffer(1, 2),CompactBuffer(4)))
// (b,(CompactBuffer(3),CompactBuffer(5)))
// (c,(CompactBuffer(),CompactBuffer(6, 7)))

它的Join的区别在于:Join返回的是两侧RDD公共的Key,而cogroup可以返回仅一侧出现的Key,类似于全外连接

join等连接操作的底层,使用的是cogroup实现,Join内部机制如图:

image-20210423143811486

sortByKey

def sortByKey(ascending: Boolean = true, 
              numPartitions: Int = self.partitions.length): RDD[(K, V)]

说明:在一个(K,V)的 RDD 上调用,K 必须实现 Ordered 特质,返回一个按照 key 进行排序的(K, V)

val dataRDD1 = sc.makeRDD(List(("a",3),("b",2),("c",1)))

val sortRdd1: RDD[(String, Int)] = dataRDD1.sortByKey()
// 按Key升序 (a,3),(b,2),(c,1)

val sortRdd2: RDD[(String, Int)] = dataRDD1.sortByKey(false)
// 按Key降序 (c,1),(b,2),(a,3)

经典案例

数据准备:

agent.log [时间戳,省份,城市,用户,广告],中间字段使用空格分隔。

功能实现:统计出 每一个省份 每个广告被点击数量排行的 Top3

分析:

提取有效数据:通过map只保留有效数据,比如省份,广告,减少数据传输量

建立有效键Key:省份与广告均为分组关键词,应将元组(省份,广告)作为Key

归约 ( ( 省份,广告 ), 1 ) => ( ( 省份,广告 ), sum )

结构转换:为了查询每一个省份的TOP,做转换 ( ( 省份,广告 ), sum ) => ( 省份, ( 广告, sum ) )

分组:按照省份进行分组,每个省份对应多干个( 广告, sum )

排序:对sum降序排序,取前三

image-20210417142712263

// 1. 获取原始数据:时间戳,省份,城市,用户,广告
val rdd: RDD[String] = sc.textFile("data/agent.log")

// 2. 将原始数据进行结构的转换。方便统计
//    时间戳,省份,城市,用户,广告
//    =>
//    ( ( 省份,广告 ), 1 )
val mapRDD: RDD[((String, String), Int)] = rdd.map(
    line => {
        val words: Array[String] = line.split(" ")
        ((words(1), words(4)), 1)
    }
)

// 3. 将转换结构后的数据,进行分组聚合
//    ( ( 省份,广告 ), 1 ) => ( ( 省份,广告 ), sum )
val reduceRDD: RDD[((String, String), Int)] = mapRDD.reduceByKey(_ + _)

// 4. 将聚合的结果进行结构的转换
//    ( ( 省份,广告 ), sum ) => ( 省份, ( 广告, sum ) )
val mapRdd1: RDD[(String, (String, Int))] = reduceRDD.map {
    case ((prv, ad), sum) => {
        (prv, (ad, sum))
    }
    /*
      case (tuple, cnt) => {
      (tuple._1, (tuple._2, cnt))
    }*/
}

// 5. 将转换结构后的数据根据省份进行分组
//    ( 省份, 【( 广告A, sumA ),( 广告B, sumB )】 )
val groupRDD: RDD[(String, Iterable[(String, Int)])] = mapRdd1.groupByKey()

// 6. 将分组后的数据组内排序(降序),取前3名
val resultRDD: RDD[(String, List[(String, Int)])] = groupRDD.mapValues(
    iter => {
        iter.toList.sortBy(_._2)(Ordering.Int.reverse).take(3)
    }
)

// 7. 采集数据打印在控制台
resultRDD.collect().foreach(println)

行动算子

如何理解行动算子?

val rdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4))

// 转换算子:将旧的RDD封装为新的RDD,形成transform chain,不会执行任何Job
val mapRdd: RDD[Int] = rdd.map(_ * 2)

// 行动算子:其实就是触发作业(Job)执行的方法,返回值不再是RDD
mapRdd.collect()

collect()等行动算子在底层调用环境对象的runJob方法,会创建ActiveJob,并提交执行。

如果只有转换算子,而没有行动算子,那么Job不会执行,只是功能上的封装拓展。

转换算子将功能不断封装,最终由行动算子执行Job,这比封装一次执行一次,更加高效。

还有一个重要特点,转换算子的返回值是RDD,行动算子的返回值是scala集合或标量。

collect

def collect(): Array[T]

说明:会将不同分区的数据按照分区顺序采集到Driver端内存中,形成数组

val rdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4))
val arr: Array[Int] = rdd.collect()
println(arr.mkString(","))	
//1,2,3,4  ==> 保持顺序
  • 其他:
val rdd = sc.makeRDD(List(4,2,3,1))

// count : 数据源中数据的个数
val cnt: Long = rdd.count()

// first : 获取数据源中数据的第一个元素
val first: Int = rdd.first()

// take : 返回一个由 RDD 的前 n 个元素组成的数组 Array(4, 2, 3)
val ints: Array[Int] = rdd.take(3)

// takeOrdered : 返回该 RDD 排序后的前 n 个元素组成的数组 Array(1, 2, 3)
val ints1: Array[Int] = rdd.takeOrdered(3)

// top: 与takeOrdered正好反序
val ints2: Array[Int] = rdd.top(3)
// Array(4, 3, 2)

以count为例,说明action算子执行的流程:

  • 每个 task 统计每个 partition 里 records 的个数,比如 partition 0 里含有 5 个 records,partition 1 里含有 10 个 records 等 。
  • task 执行完后,driver 收集每个 task 的执行结果,然后进行 sum()。

总结:分区内计算(并行),分区间汇总(Driver)

reduce

def reduce(f: (T, T) => T): T

说明:对 RDD 中的元素进行二元计算,分区内与分区间计算规则相同。

// 单值类型 f:(Int, Int) => Int
val rdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4))
val reduceRDD: Int = rdd.reduce((x, y) => x + y)
// 10


// KV类型: f:((string,Int), (string,Int)) => (string,Int)
val rdd1: RDD[(String, Int)] = sc.makeRDD(List(("hello", 2), ("hello", 3)))
val reduceRDD1: (String, Int) = rdd1.reduce(
    (t1, t2) => {
        (t1._1, t1._2 + t2._2)
    }
)
// ("hello", 5)

aggregate

def aggregate(zeroValue: U)
			 (seqOp: (U, T) => U, combOp: (U, U) => U): U

说明:分区的数据通过初始值先和分区内的数据进行聚合,然后再和初始值进行分区间的数据聚合

val rdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4), 2)

val res: Int = rdd.aggregate(0)(_ + _, _ + _)
//res = 10

val res1: Int = rdd.aggregate(10)(_ + _, _ + _)
//分区内:13 和 17;分区间 13 + 17 + 10 = 40,故 res = 40
  • 区别:

aggregateByKey : 初始值只会参与分区内计算;仅适用于 KV 类型

aggregate : 初始值会参与分区内计算,并且和参与分区间计算;可使用任意类型

fold

def fold(zeroValue: T)(op: (T, T) => T): T

说明:当分区内与分区间的计算规则相同时,它是aggregate 的简化版操作

val rdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4), 2)

val result = rdd.aggregate(0)(_+_, _+_)
val result1 = rdd.fold(0)(_+_)
//res=10

countByKey

def countByKey(): Map[K, Long]

说明:统计每种 key 的个数

// 针对KV类型,计算每个Key出现的个数(并不是聚合!)
val rdd = sc.makeRDD(List(
    ("a", 1),("a", 2),("a", 3),("b",2)
))

val countKeyRdd: collection.Map[String, Long] = rdd.countByKey()
println(countKeyRdd)	// Map(a -> 3, b -> 1)
  • 补充:countByValue
// 任意类型的集合
val rdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4), 2)
val countValRdd: collection.Map[Int, Long] = rdd.countByValue()
println(countValRdd)
// Map(4 -> 1, 2 -> 1, 1 -> 1, 3 -> 1)

foreach

def foreach(f: T => Unit): Unit 

说明:分布式遍历 RDD 中的每一个元素,调用指定函数

val rdd = sc.makeRDD(List(1,2,3,4), 2)
// 先collect(),在Driver端内存 循环遍历
rdd.collect().foreach(println)
1
2
3
4
val rdd = sc.makeRDD(List(1,2,3,4), 2)
// rdd.foreach 其实是Executor端内存数据打印(分布式打印)
rdd.foreach(println)
3
1
4
2

图解:

image-20210417164704690

类似的,行动算子 foreachPartition(f: Iterator[T] => Unit) 针对RDD的每个分区执行一次。

save

将数据保存到不同格式的文件中

val rdd = sc.makeRDD(List(("a", 1),("a", 2),("a", 3)))

/**
 * saveAsTextFile: 以文本形式存储,支持本地与HDFS路径
 */
rdd.saveAsTextFile("spark-course/src/main/resources/textOutput")
rdd.saveAsTextFile("viewfs://58-cluster/home/hdp_ershouche_growthhack/resultdata/zhuqisong/textOutput")


/**
 * saveAsObjectFile: 将RDD中的元素序列化成对象存储,支持本地与HDFS路径
 */
rdd.saveAsObjectFile("spark-course/src/main/resources/objectOutput")
rdd.saveAsObjectFile("viewfs://58-cluster/home/hdp_ershouche_growthhack/resultdata/zhuqisong/objOutput")


/**
 * saveAsSequenceFile: 将RDD以SequenceFile文件格式存储,支持本地与HDFS路径
 */
rdd.saveAsSequenceFile("spark-course/src/main/resources/seqOutput")
rdd.saveAsSequenceFile("viewfs://58-cluster/home/hdp_ershouche_growthhack/resultdata/zhuqisong/seqOutput")

该方法可以用来查看分区结果:

val rdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4), 2)
rdd.saveAsTextFile("output")

image-20210417163030341