开发中常常对Spark程序的效率是比较重视的,笔者总结了Spark开发中十一种调优的思路
优化一:避免创建重复的RDD
通常来说,我们在开发一个Spark作业时,首先是基于某个数据源(比如Hive表或HDFS文件)创建一个初始的RDD;接着对这个RDD执行某个算子操作,然后得到下一个RDD;以此类推,循环往复,直到计算出最终我们需要的结果。在这个过程中,多个RDD会通过不同的算子操作(比如map、reduce等)串起来,这个“RDD串”,就是RDD lineage,也就是“RDD的血缘关系链”。
我们在开发过程中要注意:对于同一份数据,只应该创建一个RDD,不能创建多个RDD来代表同一份数据。
一些Spark初学者在刚开始开发Spark作业时,或者是有经验的工程师在开发RDD lineage极其冗长的Spark作业时,可能会忘了自己之前对于某一份数据已经创建过一个RDD了,从而导致对于同一份数据,创建了多个RDD。这就意味着,我们的Spark作业会进行多次重复计算来创建多个代表相同数据的RDD,进而增加了作业的性能开销。
优化二:尽可能复用同一个RDD
我们除了要避免在开发过程中对一份完全相同的数据创建多个RDD之外,在对不同的数据执行算子操作时还要尽可能地复用一个RDD。
比如说,有一个RDD的数据格式是key-value类型的,另一个是单value类型的,这两个RDD的value数据是完全一样的。那么此时我们可以只使用key-value类型的那个RDD,因为其中已经包含了另一个的数据。对于类似这种多个RDD的数据有重叠或者包含的情况,我们应该尽量复用一个RDD,这样可以尽可能地减少RDD的数量,从而尽可能减少算子执行的次数。
优化三:对多次使用的RDD进行持久化
1.持久化策略
当我们在Spark代码中多次对一个RDD做了算子操作后,恭喜,你已经实现Spark作业第一步的优化了,也就是尽可能复用RDD。此时就该在这个基础之上,进行第二步优化了,也就是要保证对一个RDD执行多次算子操作时,这个RDD本身仅仅被计算一次。
Spark中对于一个RDD执行多次算子的默认原理是这样的:每次你对一个RDD执行一个算子操作时,都会重新从源头处计算一遍,计算出那个RDD来,然后再对这个RDD执行你的算子操作。这种方式的性能是很差的。
因此对于这种情况,我们的建议是:对多次使用的RDD进行持久化。此时Spark就会根据你的持久化策略,将RDD中的数据保存到内存或者磁盘中。以后每次对这个RDD进行算子操作时,都会直接从内存或磁盘中提取持久化的RDD数据,然后执行算子,而不会从源头处重新计算一遍这个RDD,再执行算子操作。
2.持久化策略的选择
- 默认情况下,性能最高的当然是MEMORY_ONLY,但前提是你的内存必须足够足够大,可以绰绰有余地存放下整个RDD的所有数据。因为不进行序列化与反序列化操作,就避免了这部分的性能开销;对这个RDD的后续算子操作,都是基于纯内存中的数据的操作,不需要从磁盘文件中读取数据,性能也很高;而且不需要复制一份数据副本,并远程传送到其他节点上。但是这里必须要注意的是,在实际的生产环境中,恐怕能够直接用这种策略的场景还是有限的,如果RDD中数据比较多时(比如几十亿),直接用这种持久化级别,会导致JVM的OOM内存溢出异常。
- 如果使用MEMORY_ONLY级别时发生了内存溢出,那么建议尝试使用MEMORY_ONLY_SER级别。该级别会将RDD数据序列化后再保存在内存中,此时每个partition仅仅是一个字节数组而已,大大减少了对象数量,并降低了内存占用。这种级别比MEMORY_ONLY多出来的性能开销,主要就是序列化与反序列化的开销。但是后续算子可以基于纯内存进行操作,因此性能总体还是比较高的。此外,可能发生的问题同上,如果RDD中的数据量过多的话,还是可能会导致OOM内存溢出的异常。
- 如果纯内存的级别都无法使用,那么建议使用MEMORY_AND_DISK_SER策略,而不是MEMORY_AND_DISK策略。因为既然到了这一步,就说明RDD的数据量很大,内存无法完全放下。序列化后的数据比较少,可以节省内存和磁盘的空间开销。同时该策略会优先尽量尝试将数据缓存在内存中,内存缓存不下才会写入磁盘。
- 通常不建议使用DISK_ONLY和后缀为2的级别:因为完全基于磁盘文件进行数据的读写,会导致性能急剧降低,有时还不如重新计算一次所有RDD。后缀为_2的级别,必须将所有数据都复制一份副本,并发送到其他节点上,数据复制以及网络传输会导致较大的性能开销,除非是要求作业的高可用性,否则不建议使用。
3.RDD大小估算
一般情况下,加载外部数据集的时候,RDD的partition到底有多少个,到底每一个partition的大小是多少?
一般的思路,RDD中的partition的个数,一般和hdfs中文件block块的个数一一对应;在local模式下可不是这样,partition的个数,取决于我们制定master的参数local[N]中N。
优化四:量避免使用shuffle类算子
如果有可能的话,要尽量避免使用shuffle类算子。因为Spark作业运行过程中,最消耗性能的地方就是shuffle过程。shuffle过程,简单来说,就是将分布在集群中多个节点上的同一个key,拉取到同一个节点上,进行聚合或join等操作。比如reduceByKey、join等算子,都会触发shuffle操作。
shuffle过程中,各个节点上的相同key都会先写入本地磁盘文件中,然后其他节点需要通过网络传输拉取各个节点上的磁盘文件中的相同key。而且相同key都拉取到同一个节点进行聚合操作时,还有可能会因为一个节点上处理的key过多,导致内存不够存放,进而溢写到磁盘文件中。因此在shuffle过程中,可能会发生大量的磁盘文件读写的IO操作,以及数据的网络传输操作。磁盘IO和网络数据传输也是shuffle性能较差的主要原因。
因此在我们的开发过程中,能避免则尽可能避免使用reduceByKey、join、distinct、repartition等会进行shuffle的算子,尽量使用map类的非shuffle算子。这样的话,没有shuffle操作或者仅有较少shuffle操作的Spark作业,可以大大减少性能开销。
举例说明
通过一个非常经典的案例使用map+广播变量代替join操作
MapJoin.scala
package optimization
import org.apache.spark.broadcast.Broadcast
import org.apache.spark.{SparkConf, SparkContext}
/**
* @Author Daniel
* @Description 使用map+广播变量代替join操作
*
**/
object MapJoin {
def main(args: Array[String]): Unit = {
val conf = new SparkConf()
.setAppName(s"${MapJoin.getClass.getSimpleName}")
.setMaster("local[*]")
val sc = new SparkContext(conf)
unJoinOps(sc)
sc.stop()
}
//借助map类的算子和广播变量相当于mapreduce中的map join
def unJoinOps(sc: SparkContext): Unit = {
//sid, name, age, gender
val stuMap = List(
"1,Jacob,19,male",
"2,William,20,male",
"3,Emily,21,female",
"4,Daniel,20,male",
"5,Olivia,31,female"
).map(line => {
val index = line.indexOf(",")
(line.substring(0, index), line.substring(index + 1))
}).toMap
//转化为广播变量
val stuBC: Broadcast[Map[String, String]] = sc.broadcast(stuMap)
//sid, course, score
val scoreRDD = sc.parallelize(List(
"1,Math,88",
"2,Chinese,75",
"3,English,87",
"4,Math,100",
"6,Chinese,77"
))
scoreRDD.map(line => {
val index = line.indexOf(",")
//sid
val id = line.substring(0, index)
//course, score
val otherInfo = line.substring(index + 1)
//拿到广播变量中的id字段
val baseInfo = stuBC.value.get(id)
//如果id被定义(即有值)
if (baseInfo.isDefined) {
//拼接
(id, baseInfo.get + "," + otherInfo)
} else {
//否则设置为Null值
(id, null)
}
//过滤掉Null值
}).filter(_._2 != null).foreach(println)
}
}
优化五:用map-side预聚合的shuffle操作
如果因为业务需要,一定要使用shuffle操作,无法用map类的算子来替代,那么尽量使用可以map-side预聚合的算子。 所谓的map-side预聚合,说的是在每个节点本地对相同的key进行一次聚合操作,类似于MapReduce中的本地combiner。map-side预聚合之后,每个节点本地就只会有一条相同的key,因为多条相同的key都被聚合起来了。其他节点在拉取所有节点上的相同key时,就会大大减少需要拉取的数据数量,从而也就减少了磁盘IO以及网络传输开销。通常来说,在可能的情况下,建议使用reduceByKey或者aggregateByKey算子来替代掉groupByKey算子。因为reduceByKey和aggregateByKey算子都会使用用户自定义的函数对每个节点本地的相同key进行预聚合。而groupByKey算子是不会进行预聚合的,全量的数据会在集群的各个节点之间分发和传输,性能相对来说比较差。
优化六:使用高性能的算子
1.使用reduceByKey/aggregateByKey替代groupByKey
combineByKey模拟groupByKey的使用可以参考https://blog.csdn.net/a805814077/article/details/103063182#9combineByKey_315
aggregateBykey模拟groupByKey的使用可以参考https://blog.csdn.net/a805814077/article/details/103063182#10aggregateByKey_461
2.使用mapPartitions代替map操作
map(func)的操作就是对rdd中的每一条记录应用一次func函数,mapPartitions(func)是对每一个分区的所有的数据应用一次func函数;换句话说,map一次处理一条记录,mapPartitions一次处理一个分区的数据。有点批量处理的感觉。
def mapPartitions(): Unit = {
//集合*7
val listRDD = sc.parallelize(1 to 5)
//map每次处理partition中的一条记录
var ret = listRDD.map(_ * 7)
//mapPartitions每次处理一个partition中所有的数据
ret = listRDD.mapPartitions(nums => { //批量处理
nums.map(_ * 7)
})
ret.foreach(println)
}
3.使用foreachPartition代替foreach
- 普通的foreach操作
def foreach(): Unit = {
//普通的foreach操作
retRDD.foreach { case (word, count) => {
//加载驱动
Class.forName("com.mysql.jdbc.Driver")
val url = "jdbc:mysql://localhost:3306/db1"
//创建连接
val connection = DriverManager.getConnection(url, "root", "root")
//创建执行器statement,有sql注入问题
val ps = connection.createStatement()
//CREATE TABLE db1.wordcount ( word VARCHAR ( 100 ), count INT );
val sql =
s"""
|insert into wordcount(word, count) values ('$word', $count)
""".stripMargin
//执行sql,封装结果集
ps.execute(sql)
//释放资源
ps.close()
connection.close()
}
}
}
- foreachPartitions操作
def foreachPartitions(): Unit = {
//foreachPartition一次遍历一个分区
retRDD.foreachPartition(partition => {
//scala中特有的驱动注册方式
classOf[Driver]
val url = "jdbc:mysql://localhost:3306/db1"
val connection = DriverManager.getConnection(url, "root", "root")
val ps = connection.createStatement()
//再遍历每一个分区
partition.foreach { case (word, count) => {
val sql =
s"""
|insert into wordcount(word, count) values ('$word', $count)
""".stripMargin
ps.execute(sql)
}
}
ps.close()
connection.close()
})
}
- 优化版——数据库连接池
//使用数据库连接池与批次处理进行优化代码
def foreachPartitionsPool(): Unit = {
retRDD.foreachPartition(partition => {
val connection = JDBCConnectionPool.getConnection
val sql = "insert into wordcount(word, count) values (?, ?)"
//使用prepareStatement解决sql注入问题
val ps = connection.prepareStatement(sql)
partition.foreach { case (word, count) => {
//执行sql,封装结果集
ps.setString(1, word)
ps.setInt(2, count)
//ps.executeUpdate()
//addBatch()效率比executeUpdate()更高
ps.addBatch()
}
}
ps.executeBatch()
ps.close()
JDBCConnectionPool.release(connection)
})
}
- 完整代码
JDBCConnectionPool.java
package blog.util;
import java.sql.Connection;
import java.sql.DriverManager;
import java.util.LinkedList;
/**
* @Author Daniel
* @Description JDBC连接池
**/
public class JDBCConnectionPool {
//定义一个连接池
private static LinkedList<Connection> pool = new LinkedList<Connection>();
static {
try {
Class.forName("com.mysql.jdbc.Driver");
String url = "jdbc:mysql://localhost:3306/db1";
//建立十次连接
for (int i = 0; i < 10; i++) {
//添加到连接池
pool.push(DriverManager.getConnection(url, "root", "root"));
}
} catch (Exception e) {
e.printStackTrace();
}
}
public static Connection getConnection() {
while (pool.isEmpty()) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return pool.poll();
}
//回收Connection对象
public static void release(Connection connection) {
pool.push(connection);
}
}
HighPerformOperator.scala
package optimization
import java.sql.DriverManager
import blog.util.JDBCConnectionPool
import com.mysql.jdbc.Driver
import org.apache.spark.{SparkConf, SparkContext}
/**
* @Author Daniel
* @Description 高性能算子操作
**/
object HighPerformOperator {
def main(args: Array[String]): Unit = {
val conf = new SparkConf()
.setAppName(s"${HighPerformOperator.getClass.getSimpleName}")
.setMaster("local[*]")
val sc = new SparkContext(conf)
val lines = sc.textFile("accu.txt")
val words = lines.flatMap(_.split("\\s+"))
val retRDD = words.mapPartitions(partition => {
partition.map((_, 1))
}).reduceByKey(_ + _)
retRDD.foreach(println)
// mapPartitions
// foreach
// foreachPartitions
foreachPartitionsPool
//使用数据库连接池与批次处理进行优化代码
def foreachPartitionsPool(): Unit = {
retRDD.foreachPartition(partition => {
val connection = JDBCConnectionPool.getConnection
val sql = "insert into wordcount(word, count) values (?, ?)"
//使用prepareStatement解决sql注入问题
val ps = connection.prepareStatement(sql)
partition.foreach { case (word, count) => {
//执行sql,封装结果集
ps.setString(1, word)
ps.setInt(2, count)
//ps.executeUpdate()
//addBatch()效率比executeUpdate()更高
ps.addBatch()
}
}
ps.executeBatch()
ps.close()
JDBCConnectionPool.release(connection)
})
}
def foreachPartitions(): Unit = {
//foreachPartition一次遍历一个分区
retRDD.foreachPartition(partition => {
//scala中特有的驱动注册方式
classOf[Driver]
val url = "jdbc:mysql://localhost:3306/db1"
val connection = DriverManager.getConnection(url, "root", "root")
val ps = connection.createStatement()
//再遍历每一个分区
partition.foreach { case (word, count) => {
val sql =
s"""
|insert into wordcount(word, count) values ('$word', $count)
""".stripMargin
ps.execute(sql)
}
}
ps.close()
connection.close()
})
}
def foreach(): Unit = {
//普通的foreach操作
retRDD.foreach { case (word, count) => {
//加载驱动
Class.forName("com.mysql.jdbc.Driver")
val url = "jdbc:mysql://localhost:3306/db1"
//创建连接
val connection = DriverManager.getConnection(url, "root", "root")
//创建执行器statement,有sql注入问题
val ps = connection.createStatement()
//CREATE TABLE db1.wordcount ( word VARCHAR ( 100 ), count INT );
val sql =
s"""
|insert into wordcount(word, count) values ('$word', $count)
""".stripMargin
//执行sql,封装结果集
ps.execute(sql)
//释放资源
ps.close()
connection.close()
}
}
}
def mapPartitions(): Unit = {
//集合*7
val listRDD = sc.parallelize(1 to 5)
//map每次处理partition中的一条记录
var ret = listRDD.map(_ * 7)
//mapPartitions每次处理一个partition中所有的数据
ret = listRDD.mapPartitions(nums => { //批量处理
nums.map(_ * 7)
})
ret.foreach(println)
}
sc.stop()
}
}
4.在filter之后进行重分区
通常对一个RDD执行filter算子过滤掉RDD中较多数据后(比如30%以上的数据),建议使用coalesce算子,手动减少RDD的partition数量,将RDD中的数据压缩到更少的partition中去。因为filter之后,RDD的每个partition中都会有很多数据被过滤掉,此时如果照常进行后续的计算,其实每个task处理的partition中的数据量并不是很多,有一点资源浪费,而且此时处理的task越多,可能速度反而越慢。因此用coalesce减少partition数量,将RDD中的数据压缩到更少的partition之后,只要使用更少的task即可处理完所有的partition。在某些场景下,对于性能的提升会有一定的帮助。
Repartition.scala
package optimization
import org.apache.spark.{SparkConf, SparkContext}
/**
* @Author Daniel
* @Description 重分区操作
**/
object Repartition {
def main(args: Array[String]): Unit = {
val conf = new SparkConf()
.setAppName(s"${Repartition.getClass.getSimpleName}")
.setMaster("local[*]")
val sc = new SparkContext(conf)
val listRDD = sc.parallelize(1 to 100000)
//过滤掉不能被100整除的数
val filteredRDD = listRDD.filter(_ % 100 == 0)
println("count: " + filteredRDD.count() + ", partitions: " + filteredRDD.getNumPartitions)
/*
rdd数据在filter之后减少很多,还是用原先的配置的话显得有点资源浪费
所以进行分区压缩与合并 coalesce和repartition
*/
/* //使用coalesce算子只能减少分区,不建议增加分区,增加分区的时候,分区数不变,因为增加分区要走shuffle(hash取模),默认为窄依赖,相矛盾
var repartitionRDD = filterdRDD.coalesce(2)*/
var repartitionRDD = filteredRDD.coalesce(8)
println("after repartition >>>> count: " + repartitionRDD.count() + ", partitions: " + repartitionRDD.getNumPartitions)
//想增加分区,就使用repartition算子,repartition的本质是coalesce(numPartition, shuffle=true)
repartitionRDD = filteredRDD.repartition(8)
println("after repartition >>>> count: " + repartitionRDD.count() + ", partitions: " + repartitionRDD.getNumPartitions)
sc.stop()
}
}
5.使用repartitionAndSortWithinPartitions替代repartition与sort类操作
repartitionAndSortWithinPartitions是Spark官网推荐的一个算子,官方建议,如果需要在repartition重分区之后,还要进行排序,建议直接使用repartitionAndSortWithinPartitions算子。因为该算子可以一边进行重分区的shuffle操作,一边进行排序。shuffle与sort两个操作同时进行,比先shuffle再sort来说,性能可能是要高的。
优化七:广播大变量
不建议使用广播变量的情况
- 数据体积比较大,也就是广播大变量
- 频繁更新的不适合做广播变量
优化八:使用Kryo优化序列化性能
1.序列化简介
在任何一个分布式系统中,序列化都是扮演着一个重要的角色的。如果使用的序列化技术,在执行序列化操作的时候很慢,或者是序列化后的数据还是很大,那么会让分布式应用程序的性能下降很多。所以,进行Spark性能优化的第一步,就是进行序列化的性能优化。
Spark自身默认就会在一些地方对数据进行序列化,比如Shuffle。还有就是,如果我们的算子函数使用到了外部的数据(比如Java内置类型,或者自定义类型),那么也需要让其可序列化。
而Spark自身对于序列化的便捷性和性能进行了一个取舍和权衡。默认,Spark倾向于序列化的便捷性,使用了Java自身提供的序列化机制——基于ObjectInputStream和ObjectOutputStream的序列化机制。因为这种方式是Java原生提供的,很方便使用。
但是问题是,Java序列化机制的性能并不高。序列化的速度相对较慢,而且序列化以后的数据,还是相对来说比较大,还是比较占用内存空间。因此,如果你的Spark应用程序对内存很敏感,那么,实际上默认的Java序列化机制并不是最好的选择。
2.Spark的两种序列化机制
- Java序列化机制:默认情况下,Spark使用Java自身的ObjectInputStream和ObjectOutputStream机制进行对象的序列化。只要你的类实现了Serializable接口,那么都是可以序列化的。而且Java序列化机制是提供了自定义序列化支持的,只要你实现Externalizable接口即可实现自己的更高性能的序列化算法。Java序列化机制的速度比较慢,而且序列化后的数据占用的内存空间比较大。
- Kryo序列化机制:Spark也支持使用Kryo类库来进行序列化。Kryo序列化机制比Java序列化机制更快,而且序列化后的数据占用的空间更小,通常比Java序列化的数据占用的空间要小10倍。Kryo序列化机制之所以不是默认序列化机制的原因是,有些类型虽然实现了Seriralizable接口,但是它也不一定能够进行序列化;此外,如果你要得到最佳的性能,Kryo还要求你在Spark应用程序中,对所有你需要序列化的类型都进行注册。
3.Spark涉及序列化的三个地方
- 在算子函数中使用到外部变量时,该变量会被序列化后进行网络传输(见“优化方案七:广播大变量”这个的时候已经解决过,使用BroadCast进行广播,要使用序列化的机制传输!!)
- 将自定义的类型作为RDD的泛型类型时(比如JavaRDD,Student是自定义类型),所有自定义类型对象,都会进行序列化。因此这种情况下,也要求自定义的类必须实现Serializable接口。 如果说你实现了一个自定义的这种类型,那么必须注册让kryo知道,你要进行此类的一个序列化类
- 使用可序列化的持久化策略时(比如MEMORY_ONLY_SER),Spark会将RDD中的每个partition都序列化成一个大的字节数组。
对于这三种出现序列化的地方,我们都可以通过使用Kryo序列化类库,来优化序列化和反序列化的性能。Spark默认使用的是Java的序列化机制,也就是ObjectOutputStream/ObjectInputStream API来进行序列化和反序列化。但是Spark同时支持使用Kryo序列化库,Kryo序列化类库的性能比Java序列化类库的性能要高很多。官方介绍,Kryo序列化机制比Java序列化机制,性能高10倍左右。Spark之所以默认没有使用Kryo作为序列化类库,是因为Kryo要求最好要注册所有需要进行序列化的自定义类型,因此对于开发者来说,这种方式比较麻烦。
4.如何开启kryo序列化
以下是使用Kryo的代码示例,我们只要设置序列化类,再注册要序列化的自定义类型即可(比如算子函数中使用到的外部变量类型、作为RDD泛型类型的自定义类型等)
KryoSerialization.scala
package optimization
import org.apache.spark.rdd.RDD
import org.apache.spark.serializer.KryoSerializer
import org.apache.spark.{SparkConf, SparkContext}
import scala.reflect.ClassTag
/**
* @Author Daniel
* @Description Kryo序列化操作
**/
object KryoSerialization {
def main(args: Array[String]): Unit = {
val conf = new SparkConf()
.setAppName(s"${KryoSerialization.getClass.getSimpleName}")
.setMaster("local[*]")
// 设置序列化器为KryoSerializer
//.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.set("spark.serializer", classOf[KryoSerializer].getName)
.registerKryoClasses(Array(classOf[Person]))
val sc = new SparkContext(conf)
val list = List[Person](
new Person("Jacob", 19),
new Person("William", 20),
new Person("Emily", 21),
new Person("Daniel", 20),
new Person("Olivia", 31)
)
val personRDD: RDD[Person] = sc.parallelize(list)
//sortByKey 先年龄,后姓名
personRDD.sortBy(person => person, true, 1)(
new Ordering[Person]() {
override def compare(x: Person, y: Person) = {
var ret = y.age.compareTo(x.age)
if (ret == 0) {
ret = x.name.compareTo(y.name)
}
ret
}
},
ClassTag.Object.asInstanceOf[ClassTag[Person]]
).foreach(println)
sc.stop()
}
}
//case class Person(name:String, age:Int) //默认会提供序列化的操作,相当于class Person extends Serializable
class Person {
var name: String = _
var age: Int = _
def this(name: String, age: Int) {
this()
this.name = name
this.age = age
}
override def toString: String = this.name + "\t" + this.age
}
6.优化Kryo的使用
- 优化缓存大小
如果注册的要序列化的自定义的类型,本身特别大,比如包含了超过100个field。那么就会导致要序列化的对象过大。此时就需要对Kryo本身进行优化。因为Kryo内部的缓存可能不够存放那么大的class对象。此时就需要调用SparkConf.set()方法,设置spark.kryoserializer.buffer.mb参数的值,将其调大。
默认情况下它的值是2,就是说最大能缓存2M的对象,然后进行序列化。可以在必要时将其调大。比如设置为10。
- 预先注册自定义类型
虽然不注册自定义类型,Kryo类库也能正常工作,但是那样的话,对于它要序列化的每个对象,都会保存一份它的全限定类名。此时反而会耗费大量内存。因此通常都建议预先注册好要序列化的自定义的类。
7.Kryo的使用场景
这里针对的Kryo序列化类库的使用场景,就是算子函数使用到了外部的大数据的情况。比如,我们在外部定义了一个封装了应用所有配置的对象,比如自定义了一个黑名单blackRDD对象,里面包含了100w~1一个亿的数据。然后,在算子函数里面,使用到了这个外部的大对象。
此时呢,如果默认情况下,让Spark用java序列化机制来序列化这种外部的大对象,那么就会导致,序列化速度缓慢,并且序列化以后的数据还是比较大,比较占用内存空间。
因此,在这种情况下,比较适合,切换到Kryo序列化类库,来对外部的大对象进行序列化操作。一是,序列化速度会变快;二是,会减少序列化后的数据占用的内存空间。
优化九:优化数据结构
要减少内存的消耗,除了使用高效的序列化类库以外,还有一个很重要的事情,就是优化数据结构。从而避免Java语法特性中所导致的额外内存的开销,比如基于指针的Java数据结构,以及包装类型。
有一个关键的问题,就是优化什么数据结构?其实主要就是优化你的算子函数,内部使用到的局部数据,或者是算子函数外部的数据。都可以进行数据结构的优化。优化之后,都会减少其对内存的消耗和占用可能以及合适的情况下,使用占用内存较少的数据结构,但是前提是要保证代码的可维护性
Java中,有三种类型比较耗费内存:
- 对象,每个Java对象都有对象头、引用等额外的信息,因此比较占用内存空间。
- 字符串,每个字符串内部都有一个字符数组以及长度等额外信息。
- 集合类型,比如HashMap、LinkedList等,因为集合类型内部通常会使用一些内部类来封装集合元素,比如Map.Entry。
因此Spark官方建议,在Spark编码实现中,特别是对于算子函数中的代码,尽量不要使用上述三种数据结构,尽量使用字符串替代对象,使用原始类型(比如Int、Long)替代字符串,使用数组替代集合类型,这样尽可能地减少内存占用,从而降低GC频率,提升性能。
但是我们要做到这些,其实很难。因为我们同时要考虑到代码的可维护性,如果一个代码中,完全没有任何对象抽象,全部是字符串拼接的方式,那么对于后续的代码维护和修改,无疑是一场巨大的灾难。同理,如果所有操作都基于数组实现,而不使用HashMap、LinkedList等集合类型,那么对于我们的编码难度以及代码可维护性,也是一个极大的挑战。因此建议,在可能以及合适的情况下,使用占用内存较少的数据结构,但是前提是要保证代码的可维护性。
优化十:提高并行度
并行度是说同时有多少个线程来运行task作业,运行一个task自然需要一个线程。所以有时候我们也可以粗略的将并行度理解为同时能够运行的线程个数。
在我们提交我们的Spark程序时,Spark集群的资源并不一定会被充分使用,所以要设置合适的并行度,来充分利用我们集群的资源。
比如,Spark在读取HDFS文件时,默认情况下会根据每个block创建一个partition,也依据这个设置并行度。
2种方式设置并行度
- 手动使用textFile()、parallelize()等方法的第二个参数来设置并行度;
- 在sparkConf 或者Spark-submit中指定使用spark.default.parallelism参数,来设置统一的并行度。Spark官方的推荐是,给集群中的每个cpu core设置2~3倍个task。
比如说,spark-submit设置了executor数量是100个,每个executor要求分配5个core,那么我们的这个application总共会有500个core。此时可以设置new SparkConf().set("spark.default.parallelism", "1200")来设置合理的并行度,从而充分利用资源。
spark.default.parallelism saprk程序的默认的并行度,也就是程序当中最多的partition个数,也就是最多的task个数
默认值:
- 如果是reduceByKey或者join等shuffle操作,对应的并行度就是这些操作的父rdd中最大的分区数
- 如果是textFile或parallelize等输入算子操作,就要根据clustermanager设置来决定
- 本地模式就是local[N]中的N
- mesos的细粒度模式,就是8
- 其它情况Math.max(2, executor-num * executor-core)
优化十一:数据本地化
1.什么是数据本地性
指的就是数据和计算它的代码之间的距离,基于这个距离,数据本地性有以下几个级别:
1、PROCESS_LOCAL:数据和计算它的代码在同一个JVM进程中。 2、NODE_LOCAL:数据和计算它的代码在一个节点上,但是不在一个进程中,比如在不同的executor进程中,或者是数据在HDFS文件的block中。 3、NO_PREF:数据从哪里过来,性能都是一样的。 4、RACK_LOCAL:数据和计算它的代码在一个机架上。 5、ANY:数据可能在任意地方,比如其他网络环境内,或者其他机架上。
2.数据本地性的参数配置
Spark倾向于使用最好的本地化级别来调度task,但是这是不可能的。如果没有任何未处理的数据在空闲的executor上,那么Spark就会放低本地化级别。这时有两个选择:第一,等待,直到executor上的cpu释放出来,那么就分配task过去;第二,立即在任意一个executor上启动一个task。 Spark默认会等待一会儿,来期望task要处理的数据所在的节点上的executor空闲出一个cpu,从而将task分配过去。只要超过了时间,那么Spark就会将task分配到其他任意一个空闲的executor上。 可以设置参数,spark.locality系列参数,来调节Spark等待task可以进行数据本地化的时间。
- spark.locality.wait(3000毫秒)
- spark.locality.wait.node
- spark.locality.wait.process
- spark.locality.wait.rack
3.选择何种数据本地性
Spark中任务的处理需要考虑所涉及的数据的本地性的场合,基本就两种,一是数据的来源是HadoopRDD; 二是RDD的数据来源来自于RDD Cache(即由CacheManager从BlockManager中读取,或者Streaming数据源RDD)。其它情况下,如果不涉及shuffle操作的RDD,不构成划分Stage和Task的基准,不存在判断Locality本地性的问题,而如果是ShuffleRDD,其本地性始终为No Prefer,因此其实也无所谓Locality。
在理想的情况下,任务当然是分配在可以从本地读取数据的节点上时(同一个JVM内部或同一台物理机器内部)的运行时性能最佳。但是每个任务的执行速度无法准确估计,所以很难在事先获得全局最优的执行策略,当Spark应用得到一个计算资源的时候,如果没有可以满足最佳本地性需求的任务可以运行时,是退而求其次,运行一个本地性条件稍差一点的任务呢,还是继续等待下一个可用的计算资源已期望它能更好的匹配任务的本地性呢?
这几个参数一起决定了Spark任务调度在得到分配任务时,选择暂时不分配任务,而是等待获得满足进程内部/节点内部/机架内部这样的不同层次的本地性资源的最长等待时间。默认都是3000毫秒。
基本上,如果你的任务数量较大和单个任务运行时间比较长的情况下,单个任务是否在数据本地运行,代价区别可能比较显著,如果数据本地性不理想,那么调大这些参数对于性能优化可能会有一定的好处。反之如果等待的代价超过带来的收益,那就不要考虑了。