Spark—关于RDD的并行度和分区(Local环境下测试)

Spark—关于RDD的并行度和分区(Local环境下测试)

本文将会跟大家一起简单探讨Spark 中RDD的并行度和分区


前言

默认情况下,Spark 可以将一个作业切分多个任务后,发送给 Executor 节点并行计算,而能够并行计算的任务数量我们称之为并行度。这个数量可以在构建 RDD 时指定。切记,这里的并行执行的任务数量(Executor计算节点执行的),并不是指的切分任务的数量(Task任务)


提示:以下是本篇文章正文内容,下面案例可供参考

一、并发、并行和并行度

并发:是指有多个任务去抢占一个cpu核,即一个处理器处理多个任务
并行:是指多个任务有多个cpu核多同时处理,实现并行
有关并发与并行不做过多探讨,可以戳下面链接学习,毕竟每个人理解不同

可以看知乎上各路大神的解释 >> 戳这里

并行度:在分布式计算框架中一般都是多个任务同时执行,由于任务分布在不同的计算节点进行
计算,所以能够真正地实现多任务并行执行,记住,这里是并行,而不是并发。这里我们将整个集群并行执行任务的数量称之为并行度。那么一个作业到底并行度是多少呢?这个取决于框架的默认配置。应用程序也可以在运行过程中动态修改。

二、分区

  1. 我第一次了解到分区,是kafka中对消息的分区,为了达到负载均衡,提升性能,可以对消息进行分区,因为如果一个topic内的消息只存于一个broker,那这个broker会成为瓶颈,无法做到水平扩展;
  2. spark 在创建RDD时可以指定分区的数量,提升性能,因为通过分区可以实现并行计算,当然也可以对不同分区进行不同逻辑的计算

关于spark创建RDD的方式可以 >> Spark—RDD的创建(Local环境)

1. 从集合(内存)中创建 RDD时的分区

使用 makeRDD(seq, numSlices) 方法(如下):
在这里插入图片描述
通过源码可以发现,makeRDD()方法可以传入两个参数,一个是序列对象,一个分区数量。对于第二个参数有以下说明:

  1. 第二个参数可以不传递的,那么makeRDD方法会使用默认值 : defaultParallelism(默认并行度)
    spark在默认情况下,从配置对象中获取配置参数:spark.default.parallelism
    scheduler.conf.getInt(“spark.default.parallelism”, totalCores);
  2. 如果获取不到配置参数,那么使用totalCores属性,这个属性取值为当前运行环境的最大可用cpu核数
    在这里插入图片描述

设置配置参数(假设设置10个分区),代码测试如下(示例):

// 准备环境
val sparkConf = new SparkConf().setMaster("local[*]").setAppName("RDD")

// 设置分区数量
sparkConf.set("spark.default.parallelism", "10")
val sc = new SparkContext(sparkConf)

// 创建RDD
val rdd = sc.makeRDD(List(1,2,3,4))

// 将处理的数据保存成分区文件
rdd.saveAsTextFile("output")

// 关闭环境
sc.stop()

效果:
在这里插入图片描述

代码测试如下(示例):

// 准备环境
val sparkConf = new SparkConf().setMaster("local[*]").setAppName("RDD")
val sc = new SparkContext(sparkConf)

测试1:

// 创建RDD
// 【】代表一个分区
// seq:List(1,2,3,4), numSlices:2
// result:【1,2】,【3,4】
val rdd = sc.makeRDD(List(1,2,3,4), 2)

效果:
在这里插入图片描述
测试2:

// seq:List(1,2,3,4), numSlices:3
// result:【1】,【2】,【3,4】
val rdd = sc.makeRDD(List(1,2,3,4), 3)

效果:
在这里插入图片描述
测试3:

// seq:List(1,2,3,4,5), numSlices:3
// result:【1】,【2,3】,【4,5】
val rdd = sc.makeRDD(List(1,2,3,4,5), 3)

效果:
在这里插入图片描述

// 将处理的数据保存成分区文件
rdd.saveAsTextFile("output")

// TODO 关闭环境
sc.stop()

分区规则及每个分区内数据的确定:

Spark 核心源码如下:
def positions(length: Long, numSlices: Int): Iterator[(Int, Int)] = {
	 (0 until numSlices).iterator.map { i =>
		 val start = ((i * length) / numSlices).toInt
		 val end = (((i + 1) * length) / numSlices).toInt
		 (start, end)
	}
 }

解析:

  1. 我们可以看到positions()的底层源码解释,传入的是序列的长度和分区的个数,返回值是一个Iterator[(Int, Int)],
  2. Iterator中的元素是 (start, end) ,是一个区间,取得是左闭右开,在底层源码中,slice()方法的参数就是对应着(start until end) >>> (def slice(from: Int, until: Int): Repr = {…})
  3. 而最终每个分区保存的数据就是根据每个分区分配的区间去截取传入的数据源 List中的内容,分析如下:
以第3个测试数据为例:
List(1,2,3,4,5) // 数据
i = 0,1,2 // 
length = 5 // 序列的长度
numSlices = 3 // 分区的个数 分别为:part-00,part-01,part-02(start, end) 带入公式:
part-00: => (((i * length) / numSlices).toInt, (((i + 1) * length) / numSlices ).toInt) => ( (0*5)/3 , ((0+1)*5)/3) => (0,1) => [0,1) => 1
part-01: => (1,3) => [1,3) => 2,3
part-02: => (3,5) => [3,5) => 4,5

2. spark 读取文件数据的分区

读取文件数据时,数据是按照 Hadoop 文件读取的规则进行切片分区,而切片规则和数
据读取的规则有些差异

textFile()可以将文件作为数据处理的数据源,默认也可以设定分区。

minPartitions : 最小分区数量
math.min(defaultParallelism, 2)

在这里插入图片描述

// 如果不想使用默认的分区数量,可以通过第二个参数指定分区数
val rdd = sc.textFile("datas/1.txt",3) // 此时也是可以形成3个分区文件,因为指定了分区数

但如果把分区数量改为2,并且1.txt文件的内容为1,2,3,如图:

val rdd = sc.textFile("datas/1.txt",3)

在这里插入图片描述
效果:
却没有按照给定的参数2,生成两个分区,而是产生了三个分区
在这里插入图片描述

2.1 分区数量的计算

这是因为Spark读取文件,底层其实使用的就是Hadoop的读取方式,而在这里定义了分区数量的计算方式
具体 Spark 核心源码如下:
先是读取文件,统计文件总的字节数 totalSize,然后根据传进来的参数2,求每个分区需要存储的字节数goalSize,最后 (totalSize / goalSize) 求出实际分区的数量

public InputSplit[] getSplits(JobConf job, int numSplits)
	throws IOException {
	long totalSize = 0; // compute total size
	for (FileStatus file: files) { // check we have valid files
		if (file.isDirectory()) {
			throw new IOException("Not a file: "+ file.getPath());
		}
		totalSize += file.getLen();
	}

	long goalSize = totalSize / (numSplits == 0 ? 1 : numSplits);
	long minSize = Math.max(job.getLong(org.apache.hadoop.mapreduce.lib.input.FileInputFormat.SPLIT_MINSIZE, 1), minSplitSize);

	...
	
	for (FileStatus file: files) {
	
	...

	if (isSplitable(fs, path)) {
		long blockSize = file.getBlockSize();
		long splitSize = computeSplitSize(goalSize, minSize, blockSize);
	...

}

protected long computeSplitSize(long goalSize, long minSize,
	long blockSize) {
	return Math.max(minSize, Math.min(goalSize, blockSize));
}

分析:
我们可以查看数据源 1.txt 的字节大小,为 7字节,则:

分区数量的计算方式:
totalSize = 7 // 文件总的字节数
goalSize =  7 / 2 = 3(byte) // 每个分区存储的字节数

// 如果多出来的字节数大于一个分区字节数的10%,则另开一个分区
7 / 3 = 2...1 (1.1) + 1 = 3(分区)

2.2 每个分区内数据的分配

3个分区确定了,那么数据是如何分配到每个分区的呢,数据为1,2,3,是不是分区0保存1,分区1保存2,分区2保存3这样均匀保存?其实不是,如图:
在这里插入图片描述

确定了分区数量,那么针对数据源中内容,又是如何分配到每个分区的,这里也有它的分配规则:

  1. 数据以行为单位进行读取
    (1) spark读取文件,采用的是hadoop的方式读取,所以一行一行读取,和字节数没有关系
    (2) 这里的和字节数没有关系,是指例如一个汉字占2个字节,如果这一行有两个汉字,而每个分区是存储3个字节的,那么在分配到每个分区时,不会根据每个分区能存储的字节数去存储,毕竟此时3个字节不能表示数据源中的两个汉字,所以是一行一行读取的
  2. 数据读取时以偏移量为单位,偏移量不会被重复读取,这里偏移量是以1个字节为一个偏移量

可以看到 1.txt中 是存在换行符的,换行符是2个字节
在这里插入图片描述
所以数据分区的分配计算下(我们此处用@@代替特殊符合):

1. 已知 1.txt 有7个字节,则需要3个分区 012
2. 确定偏移量
	数据		   偏移量		
	1@@   => 	012
	2@@   => 	345
	3     => 	6

3. 数据分区的偏移量范围的计算
	分区		   每个分区保存的字节数	         每个分区保存偏移量范围
	part-01 >> 保存3个字节,那么保存的偏移量范围是:[0, 0+3] --> [0, 3]  --> 那么对应读取的偏移量有:0123 45  -----> 最终保存的数据 1, 2
	part-02 >> 保存3个字节,那么保存的偏移量范围是:[3, 3+3] --> [3, 6]  --> 那么对应读取的偏移量有:6        -----> 最终保存的数据 3
	part-03 >> 保存1个字节,那么保存的偏移量范围是:[6, 6+1] --> [6, 7]  --> 那么对应读取的偏移量有:[]       -----> 最终保存的数据 []

4. 解析
	0号分区,存放的偏移量范围是0123,而又因为读取文件是一行一行读取,所以45也一起读取了,所以读取的数据是 12
	1号分区,本应读取偏移量范围是3456,由于上一个分区读取了345,所以只读6,所以读取的数据是 3
	2号分区,因为上一步中读了偏移量为6的数据,所以不再读取,所以读取出来为空

可以自己再写个不同的文件测试验证下:
在这里插入图片描述

在这里插入图片描述

3. 自定义数据分区规则

Spark 目前支持 Hash 分区和 Range 分区,和用户自定义分区。
在这里插入图片描述
Hash 分区为当前的默认分区。分区器直接决定了 RDD 中分区的个数、RDD 中每条数据经过 Shuffle 后进入哪个分区,进而决定了 Reduce 的个数。

  1. 只有 Key-Value 类型的 RDD 才有分区器,非 Key-Value 类型的 RDD 分区的值是 None,因此需要对一些非Key-Value类型的RDD进行自定义分区时,需要将元素转为k-v类型,再通过自定义的分区规则进行操作
  2. 每个 RDD 的分区 ID 范围:0 ~ (numPartitions - 1),决定这个值是属于那个分区的
  3. 要实现自定义分区,需要继承 org.apache.spark.Partitioner 类,并实现其2个抽象方法
    abstract class Partitioner extends Serializable {
      def numPartitions: Int
      def getPartition(key: Any): Int
    }
    

代码测试如下(示例):

def main(args: Array[String]): Unit = {
    val sparConf = new SparkConf().setMaster("local").setAppName("WordCount")
    val sc = new SparkContext(sparConf)

    val rdd = sc.makeRDD(List(
        ("nba", "xxxxxxxxx"),
        ("cba", "xxxxxxxxx"),
        ("wwe", "xxxxxxxxx"),
        ("nba", "xxxxxxxxx"),
        ("cba", "xxxxxxxxx")
    ),3)
    val partRDD: RDD[(String, String)] = rdd.partitionBy( new MyPartitioner )

    partRDD.saveAsTextFile("output")

    sc.stop()
}

/**
  * 自定义分区器
  * 1. 继承Partitioner
  * 2. 重写方法
  */
class MyPartitioner extends Partitioner{
    // 分区数量
    override def numPartitions: Int = 3

    // 根据数据的key值返回数据所在的分区索引(从0开始)
    override def getPartition(key: Any): Int = {
        key match {
            case "nba" => 0
            case "wwe" => 1
            case _ => 2
        }
    }
}

结果:
在这里插入图片描述


总结

我们回到最开始的并行度和分区,这两者其实有一定的关系,因为每个分区都是相互独立的,那个每个分区是可以分配到一个Task,那么如果是有足够多的cpu核,也就意味着有足够的Executor同时去执行多个Task,那么这种就是并行,而此时分区数量就是并行度;当如果只有一个Executor,却又多个Task,那么这种就是并发。
以上是本人在对spark学习中的一丢丢总结,欢迎大家指错~~ 共同进步~~

  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值