Spark处理大规模数据优化实战

Spark任务优化

本节主要从内存调优、高性能算子、数据结构优化、广播大变量和小表调优、动态并行度调优、Spark文件切分策略调优来介绍Spark处理大规模数据的一些优化实践。

1 内存调优

由于任务数据量大且会发生数据膨胀,如果内存参数设置不合理,任务容易出现OOM,分析Spark1.6.2内存管理模型如下图所示,知道Spark如何管理自己的内存我们才能进行更好的调优。

内存调优详见:Spark统一内存管理:UnifiedMemoryManager

任务任务内存参数配置:

 
  1. spark.driver.memory=6g(存在广播所以Driver设置的较大)

  2. spark.executor.memory=13G

  3. spark.memory.fraction=0.4

内存参数配置计算公式:
Execution Memory 2.5G =(Heap size(13G)- Reserve Memory(450M))* spark.memory.fraction 0.4 * spark.memory.storageFraction 0.5
用户主导的空间:User Memory 7.5G =(Heap size(13G)- Reserve Memory(450M))* (1 - spark.memory.fraction 0.4)
安全因子:0.9,考虑到内存空间使用和预估的准确度,实际应用过程中会考虑加入一个安全因子。
可用用户主导空间:User Memory * 0.9 = 6.8G(根据实际情况,数据条数 * 每条数据任务后占用内存最大值,基于此评估一个最大值,如果超过这个值就会出现OOM)
效果:解决程序OOM问题,因为任务过程维护了大的数据结构,其主要使用了User Memory的空间,用Spark默认内存配置会导致用户空间OOM。

2 高性能算子

任务是同一个用户的行为数据,分布式处理需要把一个用户的数据抓取到一个节点上处理,有Shuffle操作,如下图所示同源数据采用groupByKey时Shuffle Write数据量3.5T,aggregateByKey时Shuffle Write数据量3T,相比节省时间2~3min。
分析数据分布的特征,同一个设备的数据一般在一个文件出现的概率较大,将groupByKey算子改成 aggregateByKey,首先进行了一个Map端的聚合,减少了网络传输的数据量。

 
  1. 模拟代码:

  2. val initialSeq = mutable.Seq.empty[Row]

  3. val addToSeq = (s: mutable.Seq[Row], v: Row) => s :+ v // Map端本地聚合

  4. val mergePartitionSeqs = (p1: mutable.Seq[Row], p2: mutable.Seq[Row]) => p1 ++ p2 // Reduce端聚合

  5. kv.aggregateByKey(initialSeq)(addToSeq, mergePartitionSeqs)

效果:减少网络传输数据量,时效性提升了2~5min,降低网络异常导致任务失败的风险。

3 数据结构优化

任务代码中,采用更加节省内存的数据结构,例如聚合的key、最短路径中的索引(如下模拟代码所示)等多处采用字符串拼接实现,避免自定义对象封装数据,尽可能使用轻量的Array而不是HashMap等
效果:节省内存。

4 广播大变量和小表调优

任务任务为什么需要广播?我们先看一下广播的原理,如下图Executor端用到了Driver的List,如果广播List则每个Executor中只有一份Driver端的变量副本。如果不广播List,Executor有多少task就有多少Driver端的变量副本。如果对小表广播能实现本地Join,避免sortMergeJoin(如果使用SparkSQL发现不广播可以加上这个参数:spark.sql.statistics.fallBackToHdfs=true)。

任务过程需要关联一些小的维表或定义一些大的变量,并存在大量task,所以需要广播。
效果(1)降低网络传输的数据量;(2)降低内存的使用;(3)加快程序的运行速度。
通过如上四种Spark任务优化,使任务运行更加稳定,同时也节省了内存。

5 动态并行度调优

数据量节假日数据量明显增大,是正常值的1~2倍,为了保障数据稳定生产,任务链条包括“数据清洗任务”和“任务任务”,“数据清洗任务”主要是从百亿条数据清洗出任务需要的50亿~100亿数据,并且把上游上万个大小不同的文件合并成固定个数和大小,这个任务产出的文件个数和大小对“任务任务”是有影响的,如何确定这个文件大小和个数?
在数据量相同且资源配置相同条件下,要保证任务在1h内完成,测试单Task处理不同文件大小或不同数据条数的执行情况:
(1)处理文件大小200M~256M左右,每个Task处理条数大概60万左右,Task平均执行时长8~10min;如图:每个Task处理文件大小为247M,其中75%的Task执行时长为10min,而且max值为17min,就算max失败,Task失败重新执行也不会影响到任务整体结束时间。


(2)处理文件大小256~285M左右,每个Task处理条数大概70万左右,Task平均执行时长12min左右;如图:每个Task处理文件大小为271M,其中75%的Task执行时长为12min,而且max值20min,Task失败重新执行对任务整体时效性有影响。

(3)处理文件大小300M~350M时,每个Task处理数据条数80万左右,Task平均执行时长17min左右。如图:每个Task处理文件大小313M,其中75%的Task执行时长为17min,而且max值太大,这样就拖慢了整个运行过程。

通过大量测试发现,Task平均执行时长8~10min,每个Task处理条数大概60万左右时任务任务遇到失败Task、慢节点、某台机器故障等在开启慢任务推测情况下表现较优。

 
  1. 慢任务推测参数配置:

  2. spark.speculation=true

  3. spark.speculation.interval=60s

  4. spark.speculation.multiplier=1.3

  5. spark.speculation.quantile=0.99

基于如上测试,对“数据清洗任务”产出文件数量进行动态调整,让文件大小尽量在200M~256M左右,文件数量和大小,可以根据历史数据条数、文件个数、每个文件大小,考虑节假日等情况去预估,当然也可以采用机器学习等算法去预测。比如简单计算:本周日文件个数 = 上周日数据总条数/60万 ,因为数据清洗任务后数据量大概是50亿~100亿条,所以文件个数阈值为[7000,16000]
依据每个Core处理2~3个Task,每个Task处理60万条数据文件大小为200~256M表现较好,开启了Executor动态资源分配功能如下:

 
  1. spark.dynamicAllocation.minExecutors=1000

  2. spark.dynamicAllocation.maxExecutors=1600

参数:spark.default.parallelism是控制Shuffle并行度的,从而会影响Spark Task个数,间接影响文件产出个数。
“数据清洗任务”是一个离线按天执行的任务,通过动态调整spark.default.parallelism的值保证产出文件个数和大小。
“核心任务”是一个离线按天执行的任务,通过动态调整spark.default.parallelism的值,进一步保证任务过程每个Task处理的文件维持在256M左右,数据条数维持在60万左右。
效果:避免了节假日任务执行超时/任务失败,保证生产耗时的相对平稳。
通过如上参数调整,提高并缩短了生产耗时稳定性,主要是扩大Executor个数(资源才是王道),生产耗时缩短到了31min左右,如下图所示

6 Spark文件切分策略调优

ORC文件切分详见:spark 读取ORC文件时间太长(计算Partition时间太长)且产出orc单个文件中stripe个数太多问题解决方案

任务任务上游的“数据清洗任务”,会清洗出任务需要的有效数据,并且对上游上万个小文件进行合并压缩成ORC文件,其文件大小在256M左右。

1)任务任务遇到问题:作业提交后ApplicationMaster(Driver)启动了,Spark任务长时间占用资源,SparkUI看不到DAG图、Stage、Partition和Task相关的信息。
2)问题分析:Driver启动,但是Executor没干活,说明问题出在了Driver,Driver干什么呢?定位到Driver在计算Partition,发生了Full GC,于是问题定位到了Spark读取文件的方法OrcInputFormat.java。
3)通俗描述:老大(Driver)管理小弟(worker)干活,本来是老大把活分给小弟就可以了,但是老大一直在了解小弟的情况,自己很忙小弟很闲。
4)问题跟踪:查看OrcInputFormat.java发现Spark读取ORC文件有三种策略,默认采用HYBRID策略(HiveConf.java有相关配置信息):Spark Driver启动的时候,会去nameNode读取元数据,根据文件总大小和文件个数计算一个文件的平均大小,如果这个平均值大于默认256M的时候就会触发ETL策略。ETL策略就会去DataNode上读取orc文件的head等信息,如果stripe个数多或元数据信息太大就会导致Driver 产生FUll GC,这个时候就会表现为Driver启动到Task执行间隔时间太久的现象。
5)解决方案:控制文件大小为256M左右,改变文件切分策略为BI,控制stripe大小。

 
  1. // 创建一个支持Hive的SparkSession

  2. val sparkSession = SparkSession

  3. .builder()

  4. .appName("PvMvToBase")

  5. // 默认64M,即代表在压缩前数据量累计到64M就会产生一个stripe。与之对应的hive.exec.orc.default.row.index.stride=10000可以控制有多少行是产生一个stripe。

  6. // 调整这个参数可控制单个文件中stripe的个数,不配置单个文件stripe过多,影响下游使用,如果配置了ETL切分策略或启发式触发了ETL切分策略,就会使得Driver读取DataNode元数据太大,进而导致频繁GC,使得计算Partition的时间太长难以接受。

  7. .config("hive.exec.orc.default.stripe.size", 268435456L)

  8. // 总共有三种策略{"HYBRID", "BI", "ETL"}), 默认是"HYBRID","This is not a user level config. BI strategy is used when the requirement is to spend less time in split generation as opposed to query execution (split generation does not read or cache file footers). ETL strategy is used when spending little more time in split generation is acceptable (split generation reads and caches file footers). HYBRID chooses between the above strategies based on heuristics."),

  9. // 如果不配置,当orc文件大小大于spark框架估算的平均值256M时,会触发ETL策略,导致Driver读取DataNode数据切分split花费大量的时间。

  10. .config("hive.exec.orc.split.strategy", "BI")

  11. .enableHiveSupport()

  12. .getOrCreate()

调整这个参数可控制单个文件中stripe的个数,不配置单个文件stripe过多,影响下游使用,如果配置了ETL切分策略或启发式触发了ETL切分策略,就会使得Driver读取DataNode元数据太大,进而导致频繁GC,使得计算Partition的时间太长难以接受。
问题根源Driver压力太大,Worker启动了也只能闲等Driver忙完了,进行分配调度。
效果:提升了任务稳定性。

7 使用Kryo序列器(真正上线未用,发现有时候表现好有时候表现不好,存在不稳定性)

使用Kryo序列化类库,来优化序列化和反序列化的性能。Spark默认使用的是Java的序列化机制,也就是ObjectOutputStream/ObjectInputStream API来进行序列化和反序列化。但是Spark同时支持使用Kryo序列化库,Kryo序列化类库的性能比Java序列化类库的性能要高很多。官方介绍,Kryo序列化机制比Java序列化机制,性能高10倍左右。Spark之所以默认没有使用Kryo作为序列化类库,是因为Kryo要求最好要注册所有需要进行序列化的自定义类型,因此对于开发者来说,这种方式比较麻烦。
以下是使用Kryo的代码示例,我们只要设置序列化类,再注册要序列化的自定义类型即可(比如算子函数中使用到的外部变量类型、作为RDD泛型类型的自定义类型等):

 
  1. // 创建SparkConf对象。

  2. val conf = new SparkConf().setMaster(...).setAppName(...)

  3. // 设置序列化器为KryoSerializer。

  4. conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")

  5. // 注册要序列化的自定义类型。

  6. conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))

算法优化

优化方案
核心理念:归因过程数据膨胀,如何以节省内存的方式去归因,是算法优化的关键。
核心思想:数学分而治之思想+索引技术灵活运用。
算法描述:不变的字段单独维护且只维护一份,供查询使用(如Array1),任务新增的字段单独封装维护在(Array2),轻量级的Array2参与任务的计算过程,任务完成,通过Array1和Array2之间的索引把数据打通落盘。

猜你喜欢

转载自blog.csdn.net/yue_2018/article/details/89243476