一、背景
最近看到了一篇文章,说的是spark小文件合并的问题
Spark 小文件合并优化实践:https://mp.weixin.qq.com/s/195nFBH0kpZEXekHiQAfrA(作者:偷闲小苑)
其实关于小文件合并我之前也写过类似的文章,大体的方案也就是
1、存之前做一个数据量的预估,然后repartition/coalesce
2、存完之后,触发一个merge合并小文件(可以是hive也可以是spark任务)
3、使用一些数据湖(delta lake,hudi,iceberg)方案,不直接写入hdfs,写入中间层,让中间层自己去管理hdfs上的文件样式
前两者,其实都需要拿到数据的metrics信息,才能做数据量的判断
拿metrics其实spark代码中有挺多地方都可以拿到的:
(1)、最常用的就是监听器(SparkListener)
(2)、其次在数据落盘的时候也能拿到(FileFormatWriter)
(3)、最后其实一些shuflle操作啥的也能拿到(BypassMergeSortShuffleWriter),但这个粒度太细了,要做的话改动会很大
二、metrics获取
1、监听器获取
spark有一个类:SparkListener,我们可以自定义监听类,来实现当触发了spark任务执行到某个阶段,触发你自定义的代码,有点像AOP
这种方式是最方便的,因为不需要修改源码,也不嵌入业务逻辑,只需要写上自己的一个新的类,注册到上下文中
直接上代码:
class ListenerDemo1(conf: SparkConf) extends SparkListener with Logging {
override def onApplicationStart(applicationStart: SparkListenerApplicationStart): Unit = {
println("onApplicationStart")
}
/**
* 一个Application可能会有多个job,一个action操作就是一个job
* 所以如果代码中,执行了count和save,那么起码会有2个job
* 每个job又有各自的stage
* @param jobStart
*/
override def onJobStart(jobStart: SparkListenerJobStart): Unit = {
println("onJobStart")
println("该任务总共有 : " + jobStart.stageIds.length+" 个stage")
}
/**
* 第一个stage提交,task开始,task结束,stage结束
* 然后开始下一个stage的提交。。。
* 如果有多个task,会执行多次task开始和task结束
*/
override def onStageSubmitted(stageSubmitted: SparkListenerStageSubmitted): Unit = {
println("onStageSubmitted")
println("stageId = "+stageSubmitted.stageInfo.stageId + " 已经提交")
}
override def onTaskStart(taskStart: SparkListenerTaskStart): Unit = {
println("在executorld = "+taskStart.taskInfo.executorId + "(" +taskStart.taskInfo.host +") " +
"上启动了一个task = "+taskStart.taskInfo.taskId +"," +
"taskLocality = "+taskStart.taskInfo.taskLocality)
println("onTaskStart")
}
override def onTaskEnd(taskEnd: SparkListenerTaskEnd): Unit = {
//区分task归属的stageId,taskid全局递增,不同stage的taskid也不会重复
println("stageId = "+taskEnd.stageId+",taskId = "+taskEnd.taskInfo.taskId)
println("TaskType = " + taskEnd.taskType)
/**
* taskinfo中会有一些internal的metrics
* 如果TaskType=ShuffleMapTask,证明是上游的task,那么就会有
* internal.metrics.shuffle.write.recordsWritten = 9
* internal.metrics.shuffle.write.bytesWritten = 636
* 如果TaskType=ResultTask,那就是下游的task,那么就会有
* internal.metrics.output.recordsWritten = 3
* internal.metrics.output.bytesWritten = 22
* internal.metrics.shuffle.read.recordsRead = 3
* 这些指标均是每个task处理的数据量的情况
*/
taskEnd.taskInfo.accumulables.foreach(
task => {
println(task.name.getOrElse("") +" = "+ task.value.getOrElse(""))
}
)
println("onTaskEnd")
}
override def onStageCompleted(stageCompleted: SparkListenerStageCompleted): Unit = {
println("onStageCompleted")
}
override def onJobEnd(jobEnd: SparkListenerJobEnd): Unit = {
println("onJobEnd")
}
override def onApplicationEnd(applicationEnd: SparkListenerApplicationEnd): Unit = {
println("onApplicationEnd")
}
}
然后在创建spark上下文的时候,做一个注册
2、数据落盘的时候拿到metrics
(1)、修改BasicWriteTaskStats代码
我其实没有很认真读FileFormatWriter里面的代码
但是里面有个write方法,会执行一个executeTask方法,然后会判断使用哪一种WriteTask来写数据(总共有三种),然后会调用WriteTask的execute方法,execute方法中又会用到statsTrackers对象,这个对象能够收集到FileFormatWriter中每个task的一些metrics信息,这个trait有一个具体实现:BasicWriteTaskStats
所以我关注到了这个类中的processStats方法,这个方法会将多个task的结果做一个累加
可以获得输出的文件的大小和条数
(2)、将metrics信息传递出去
然后这里有个重点,我想了挺久的
就是怎么把这里的值传递出去,使用spark的广播变量和累加器好像都实现不了
所以要么直接往外部的存储系统里面存,要么做成一个rdd,并且cache了通过sc的getPersistentRDDs来获取
具体实现:
在driver中,通过如下代码获取
但是这里我发现一个奇怪的问题,假设我在driver中存了调用了多次落盘数据,如果只在最后一次落盘之后,调用getPersistentRDDs获取数据,会发现只有最后一次落盘的Metrics(我也没看出是为什么),不过无伤大雅,因为这个方法,应该是在每次落盘的时候就调用一次,所以可以自己封装一个落盘方法,把Metrics信息的处理也加入其中
driver完整代码示例如下:
def main(args: Array[String]): Unit = {
val sparkSession = SparkSession.builder()
.appName("test")
.master("local[*]")
.config("spark.extraListeners", "com.xxx.ListenerDemo1")
.getOrCreate()
;
import sparkSession.implicits._
val student = sparkSession.sparkContext.parallelize(Seq(
("xxx",10),
("aaa",12),
...
("ll",2)),
3).toDF("name","age")
val student1 = sparkSession.sparkContext.parallelize(Seq(
("lsr",10),
("zvsvl",123),
("lzz",4),
("lasdl",32),
("lsr",10),
("zjl",12)),1).toDF("name","age")
student1.write.mode("overwrite")
.format("csv").save("D:\\sparktest\\file1");
handleMetrics(sparkSession)
val student2 = sparkSession.sparkContext.parallelize(Seq(
("lsr",110),
("zjl",12),
("lzasdz",4),
("ll",2),
("lswqr",10),
("lswqr",10),
("lswqr",10),
("lswqr",10),
("lswqr",10),
("lswqr",10),
("lswqr",10),
("zjl",12)),1).toDF("name","age")
student2.write.mode("overwrite")
.format("csv").save("D:\\sparktest\\file2");
handleMetrics(sparkSession)
val stuFilter = student
.filter("name <> 'xxx'")
stuFilter
.repartition(10)
.write.mode("overwrite")
.format("csv").save("D:\\sparktest\\file");
handleMetrics(sparkSession)
}
def handleMetrics(sparkSession: SparkSession): Unit ={
val rddMap: collection.Map[Int, RDD[_]] = sparkSession.sparkContext.getPersistentRDDs
rddMap.foreach{
case (id,rdd) =>{
/**
* 由于业务逻辑也可能有cache的rdd
* 必须确认当前这个rdd是metrices所属的rdd
* take(1),拿出一条数据出来,做类型匹配
* 如果是MyMetrice类型,再做相应的代码,否则跳过
*/
rdd.take(1).head match {
case metrics:MyMetrics =>{
val myMetrics = rdd.asInstanceOf[RDD[MyMetrics]]
myMetrics.collect().map(data =>{
println("==totalNumBytes== : "+data.totalNumBytes)
println("==totalNumOutput== : "+data.totalNumOutput)
println("==numFiles== : "+data.numFiles)
})
//用完释放
rdd.unpersist(true)
}
case _ =>
}
}
}
}
(3)、另外
如下的metrics信息,会展示到sparkui的
三、总结
通过如上的metrics信息,我们可以监控到spark每个阶段的信息,存储到外部空间,做后续的一些任务优化的分析,比如是否触发小文件合并,是否提示用户某些任务不够合理(比如同一阶段的部分task,输入的量和输出的量大大偏离了平均值),当然能做的事情应该不止这些,我觉得还是看痛点是什么?急迫要解决的问题是什么,是降本优化,还是监控告警,都是我们需要更深入思考的问题!
好了,菜鸡一只,要多思考要谦虚要努力
最近看了《觉醒年代》,点赞,确实好看
历史课本上的人物有血有肉的出现在银幕上,被他们的个人魅力所惊讶所震撼
看到1919年巴黎和会上的《凡尔赛和约》,五四青年运动,深深觉得“真理只在剑锋之上,尊严只在大炮射程之内”,中国一定会越来越好的!