Spark分布式计算原理
- 一、RDD依赖与DAG工作原理
- 1、RDD的依赖关系
- 2、DAG工作原理
- 二、RDD优化
- 1、RDD持久化
- 1.1、RDD缓存机制cache
- 1.2 检查点
- 2、RDD共享变量
- 2.1、广播变量
- 2.2、累加器
- 3、RDD分区设计
- 4、数据倾斜
- 三、装载常见数据源
- 3.1、装载CSV数据源
- 3.1.1 使用SparkContext
- 3.1.2使用SparkSession
- 3.2、装载JSON数据源
一、RDD依赖与DAG工作原理
1、RDD的依赖关系
- 1.1 Lineage:血统、遗传
- RDD最重要的特性之一,保存了RDD的依赖关系
- RDD实现了基于Lineage的容错机制
- 1.2 依赖关系
- 宽依赖,一个父RDD的分区被子RDD的多个分区使用
- 窄依赖,一个父RDD的分区被子RDD的一个分区使用
- 1.3 宽依赖对比窄依赖
- 宽依赖对应shuffle操作,需要在运行时将同一个父RDD的分区传入到不同的子RDD分区中,不同的分区可能位于不同的节点,就可能涉及多个节点间数据传输
- 当RDD分区丢失时,Spark会对数据进行重新计算,对于窄依赖只需重新计算一次子RDD的父RDD分区
- 宽依赖容错图
- 通过上图能看出来,当b1分区宕机后,不会影响b2的正常操作,所以得出结论:相比于宽依赖,窄依赖对优化更有利
2、DAG工作原理
- 2.1 原理
- 根据RDD之间的依赖关系,形成一个DAG(有向无环图)
- DAGScheduler将DAG划分为多个Stage
- 划分依据:是否发生宽依赖(Shuffle)
- 划分规则:从后往前,遇到宽依赖切割为新的Stage
- 每个Stage由一组并行的Task组成
- 2.2 Stage
- 数据本地化
- 移动计算,而不是移动数据
- 保证一个Stage内不会发生数据移动
- 最佳实践
- 尽量避免Shuffle
- 提前部分聚合减少数据移动
- 2.3 Spark Shuffle过程
- 在分区之间重新分配数据
- 父RDD中同一分区中的数据按照算子要求重新进入子RDD的不同分区中
- 中间结果写入磁盘
- 由子RDD拉取数据,而不是由父RDD推送
- 默认情况下,Shuffle不会改变分区数量
- 尽量使用窄依赖,避免使用宽依赖以减少shuffle过程,减少内存的消耗
二、RDD优化
1、RDD持久化
1.1、RDD缓存机制cache
- 1.1.1 RDD缓存机制:缓存数据至内存/磁盘,可大幅度提升Spark应用性能
- cache=persist(MEMORY)
- persist
- 缓存在遇到动作算子后,才会执行
val conf = new SparkConf().setMaster("local[*]").setAppName("cachedemo")
val sc = new SparkContext(conf)
val rdd1 = sc.textFile("in/users.csv")
rdd1.cache()
println("不设置缓存,进行第一次读取")
var start = System.currentTimeMillis()
println(rdd1.count())
var end = System.currentTimeMillis()
println("读取时间:",end-start)
println("第二次读取,将数据user.csv缓存后读取")
start = System.currentTimeMillis()
println(rdd1.count())
end = System.currentTimeMillis()
println("读取时间:",end-start)
println("第三次读取,设置unpersist")
rdd1.unpersist()
start = System.currentTimeMillis()
println(rdd1.count())
end = System.currentTimeMillis()
println("读取时间:",end-start)
/*查看结果
不设置缓存,进行第一次读取
(文件行数:,305680)
(读取时间:,695)
第二次读取,将数据user.csv缓存后读取
(文件行数:,305680)
(读取时间:,51)
第三次读取,设置unpersist
(文件行数:,305680)
(读取时间:,95)
*/
我们从上面的读取结果来看,在未进行缓存前,读取文件的速度会非常慢;
而我们第二次读取,在经过了第一次的rdd.count()
的动作算子后,执行了cache的缓存操作,所以读取速度变得非常快;
而我们第三次的时候,设置了unpersist
,删除了缓存后,我们来看读取速度相较于缓存后的也有了较为明显的降低。我们这边的示例文件有点小是主要原因,文件大了后会变得更加明显。
- 1.1.2 缓存策略StorageLevel
rdd1.cache() //与下列代码等效
rdd1.persist(StorageLevel.MEMORY_ONLY)
- StorageLevel内容
级别 | 使用空间 | CPU时间 | 是否在内存中 | 是否在磁盘上 | 备注 |
MEMORY_ONLY | 高 | 低 | 是 | 否 | |
MEMORY_ONLY_2 | 高 | 低 | 是 | 否 | 数据存2份 |
MEMORY_ONLY_SER | 低 | 高 | 是 | 否 | 数据序列化 |
MEMORY_ONLY_SER_2 | 低 | 高 | 是 | 否 | 数据序列化,数据存2份 |
MEMORY_AND_DISK | 高 | 中等 | 部分 | 部分 | 如果数据在内存中放不下,则溢写到磁盘 |
MEMORY_AND_DISK_2 | 高 | 中等 | 部分 | 部分 | 数据存2份 |
MEMORY_AND_DISK_SER | 低 | 高 | 部分 | 部分 | |
MEMORY_AND_DISK_SER_2 | 低 | 高 | 部分 | 部分 | 数据存2份 |
DISK_ONLY | 低 | 高 | 否 | 是 | |
DISK_ONLY_2 | 低 | 高 | 否 | 是 | 数据存2份 |
NONE | |||||
OFF_HEAP |
- 1.1.3 缓存应用场景
- 从文件加载数据之后,因为重新获取文件成本较高
- 经过较多的算子变换之后,重新计算成本较高
- 单个非常消耗资源的算子之后
- 1.1.4 使用注意事项
- cache()或persist()后不能再有其他算子
- cache()或persist()遇到Action算子完成后才生效
1.2 检查点
- 1.2.1检查点:类似于快照
val conf = new SparkConf().setMaster("local[*]").setAppName("checkpointdemo")
val sc = new SparkContext(conf)
sc.setCheckpointDir("file:///E:/test/KB09checkpoint") //设置检查点的路径
val rdd1: RDD[(String, Int)] = sc.parallelize(List(("a",1),("a",2),("b",3),("b",4)))
rdd1.checkpoint()
rdd1.collect() //动作算子,生成快照
println(rdd1.isCheckpointed) //判断是否为快照
println(rdd1.getCheckpointFile)
/*查看结果
true
Some(file:/E:/test/KB09checkpoint/b52d6c45-5d4e-4e56-bf55-ad789dff08ae/rdd-0)
*/
我们在去到设置的路径查看,可以看到在该路径已经生成了该检查点文件。
- 1.2.2检查点与缓存的区别
- 检查点会删除RDD lineage,而缓存不会
- SparkContext被销毁后,检查点数据不会被删除
2、RDD共享变量
2.1、广播变量
- 广播变量:允许开发者将一个只读变量(Driver端)缓存到每个节点(Executor)上,而不是每个任务传递一个副本
我们通过上面的图简单看一下
现在存在三台workerNode,每个workerNode默认存在一个Executor,可以看到每个Executor中都有很多的Task任务
而我们存在一个Array("hello","hi","good afternoon")
,我们把他变成广播变量broadcast
后,那么这个变量在每一个Executor中都有且只保存了一份,在每个Task中没有进行单独的保存,可以防止资源的浪费。
val conf = new SparkConf().setMaster("local[1]").setAppName("broadcastdemo")
val sc = new SparkContext(conf)
//定义广播变量
val arr = Array("hello","hi","good afternoon")
val hei: Broadcast[Array[String]] = sc.broadcast(arr)
val rdd1 = sc.parallelize(List((1,"zhangsan"),(2,"lisi"),(3,"wangwu")))
val rdd2: RDD[(Int, String)] = rdd1.mapValues(x => {
println(x)
println(hei.value.toList)
hei.value(2) + ":" + x // .value的方式访问
})
rdd2.foreach(println)
/*查看结果
zhangsan
List(hello, hi, good afternoon)
(1,hi:zhangsan)
lisi
List(hello, hi, good afternoon)
(2,hi:lisi)
wangwu
List(hello, hi, good afternoon)
(3,hi:wangwu)
*/
注意事项:
- 1、Driver端变量在每个Executor每个Task保存一个变量副本
- 2、Driver端广播变量在每个Executor只保存一个变量副本
2.2、累加器
累加器:只允许added操作,常用于实现计数
val conf = new SparkConf().setMaster("local[2]").setAppName("AccumulatorDemo")
val sc = new SparkContext(conf)
val acumValue = sc.accumulator(0,"My Accumulator")
val rdd1 = sc.makeRDD(List(1,2,3,4))
rdd1.foreach(x=>acumValue+=x)
println(acumValue.value)
//查看结果 10
3、RDD分区设计
- 分区大小限制为2GB
- 分区太少
- 不利于并发
- 更容易受数据倾斜影响
- groupBy, reduceByKey, sortByKey等内存压力增大
- 分区过多
- Shuffle开销越大
- 创建任务开销越大
- 经验
- 每个分区大约128MB,与hdfs的DataNode的块大小一致
- 如果分区小于但接近2000,则设置为大于2000
4、数据倾斜
- 指分区中的数据分配不均匀,数据集中在少数分区中
- 严重影响性能
- 通常发生在groupBy,join等之后
- 解决方案
- 使用新的Hash值(如对key加盐)重新分区
三、装载常见数据源
3.1、装载CSV数据源
- 查看示例文件,可以看到该文件是有表头的csv文件
3.1.1 使用SparkContext
val conf = new SparkConf().setMaster("local[*]").setAppName("Csvdemo")
val sc = new SparkContext(conf)
val lines = sc.textFile("in/users.csv")
println("lines:",lines.count()) //查看文件总条数
val field = lines.mapPartitionsWithIndex((i, v) => {
if (i == 0)
v.drop(1)
else
v
}).map(x => x.split(","))
println("field:",field.count())
//等同于上面的方法,个人感觉下面用filter过滤的方法会更好理解和更好用
val fields: RDD[Array[String]] = lines.filter(v=>v.startsWith("user_id")==false).map(x=>x.split(","))
println("fields:",fields.count())
/*查看结果
(lines:,305680)
(field:,305679)
(fields:,305679)
*/
3.1.2使用SparkSession
- 个人将SparkSession理解为把文件当作是一种类似于表的文件。
val spark: SparkSession = SparkSession.builder().config(conf).getOrCreate()
//加载文件,这边的option中,我们把表头设为true
val df: DataFrame = spark.read.format("csv").option("header"true).load("in/users.csv")
//查看结构
println("查看文件结构")
df.printSchema()
//查看数据
println("查看文件前3条数据")
df.show(3) //可传入参数,设置查看多少条数据,默认20条,不足则全部显示
//查看特定列的数据
println("查看user_id和locale两列的前3条数据")
df.select("user_id","locale").show(3)
/*查看结果
查看文件结构
root
|-- user_id: string (nullable = true)
|-- locale: string (nullable = true)
|-- birthyear: string (nullable = true)
|-- gender: string (nullable = true)
|-- joinedAt: string (nullable = true)
|-- location: string (nullable = true)
|-- timezone: string (nullable = true)
查看文件前3条数据
+----------+------+---------+------+--------------------+------------------+--------+
| user_id|locale|birthyear|gender| joinedAt| location|timezone|
+----------+------+---------+------+--------------------+------------------+--------+
|3197468391| id_ID| 1993| male|2012-10-02T06:40:...| Medan Indonesia| 480|
|3537982273| id_ID| 1992| male|2012-09-29T18:03:...| Medan Indonesia| 420|
| 823183725| en_US| 1975| male|2012-10-06T03:14:...|Stratford Ontario| -240|
+----------+------+---------+------+--------------------+------------------+--------+
only showing top 3 rows
查看user_id和locale两列的前3条数据
+----------+------+
| user_id|locale|
+----------+------+
|3197468391| id_ID|
|3537982273| id_ID|
| 823183725| en_US|
+----------+------+
*/
- 我们在上面的过程中将表头设为了true,下面我们设为false来看看效果。
val spark: SparkSession = SparkSession.builder().config(conf).getOrCreate()
val df: DataFrame = spark.read.format("csv").option("header",false).load("in/users.csv")
println("查看文件结构")
df.printSchema()
println("查看文件前3条数据")
df.show(3)
/*查看结果
查看文件结构
root
|-- _c0: string (nullable = true)
|-- _c1: string (nullable = true)
|-- _c2: string (nullable = true)
|-- _c3: string (nullable = true)
|-- _c4: string (nullable = true)
|-- _c5: string (nullable = true)
|-- _c6: string (nullable = true)
查看文件前3条数据
+----------+------+---------+------+--------------------+----------------+--------+
| _c0| _c1| _c2| _c3| _c4| _c5| _c6|
+----------+------+---------+------+--------------------+----------------+--------+
| user_id|locale|birthyear|gender| joinedAt| location|timezone|
|3197468391| id_ID| 1993| male|2012-10-02T06:40:...|Medan Indonesia| 480|
|3537982273| id_ID| 1992| male|2012-09-29T18:03:...|Medan Indonesia| 420|
+----------+------+---------+------+--------------------+----------------+--------+
*/
- 我们可以看到,将表头设为false后,他就将原本表头的那一行当成了数据进行输出,而列名也改为了默认的_c0等。
- withColumnRenamed和withColumn,对列进行操作,我们以表头为false为例进行下面的操作。
修改列名
println("df原文件结构")
df.printSchema()
val frame = df.withColumnRenamed("_c0","id")
println("改名后文件结构")
frame.printSchema()
/*查看结构
df原文件结构
root
|-- _c0: string (nullable = true)
|-- _c1: string (nullable = true)
|-- _c2: string (nullable = true)
|-- _c3: string (nullable = true)
|-- _c4: string (nullable = true)
|-- _c5: string (nullable = true)
|-- _c6: string (nullable = true)
改名后文件结构
root
|-- id: string (nullable = true)
|-- _c1: string (nullable = true)
|-- _c2: string (nullable = true)
|-- _c3: string (nullable = true)
|-- _c4: string (nullable = true)
|-- _c5: string (nullable = true)
|-- _c6: string (nullable = true)
修改列数据类型
println("df原文件结构")
df.printSchema()
val frame = df.withColumnRenamed("_c0","id")
val frame1 = frame.withColumn("id",frame.col("id").cast("long"))
println("修改后文件结构")
frame1.printSchema()
/*查看结果
df原文件结构
root
|-- _c0: string (nullable = true)
|-- _c1: string (nullable = true)
|-- _c2: string (nullable = true)
|-- _c3: string (nullable = true)
|-- _c4: string (nullable = true)
|-- _c5: string (nullable = true)
|-- _c6: string (nullable = true)
修改后文件结构
root
|-- id: long (nullable = true)
|-- _c1: string (nullable = true)
|-- _c2: string (nullable = true)
|-- _c3: string (nullable = true)
|-- _c4: string (nullable = true)
|-- _c5: string (nullable = true)
|-- _c6: string (nullable = true)
*/
/*下面代码效果等同上面的操作
val frame2 = df.withColumn("id", df.col("_c0")
.cast("long")).drop("_c0")
*/
3.2、装载JSON数据源
基本等同与上面csv装载操作,这边也就不多做叙述。