上一篇
Spark Structured Streaming系列-执行过程
架构stuctured streaming是spark推出的新一代流式计算引擎,和spark steaming无关,是基于spark-sql框架封装得到的,提供给开发者DataFrame,RDD等高级api。总架构参考下图
其本意是通过sql的方式对数据进行低延迟的计算。structured streaming的核心除了sql就是checkpoint,无论是其宣传的fault-tolerant还是end-to-end exctly-once特性,都离不开checkpoint,下面重点分析一下。
checkpointspark streaming自带了checkpoint机制,我们只要声明一个checkpoint路径即可,spark甚至给出了默认路径。
aggDF .writeStream .outputMode("complete") .option("checkpointLocation", "path/to/HDFS/dir") .trigger(Trigger.ProcessingTime("10 seconds")) .format("memory") .start()
yarn模式下运行必须指定checkpointLocation,不指定会默认写到hdfs数据目录,且无权限写入。而StructuredKafkaWordCount example中增加了checkpointlocation默认选项,所有可用不用指定。
参见:https://issues.apache.org/jira/browse/SPARK-22403
设置checkpoint后在指定目录会生成相应文件,如果不设置Trigger在消费大量kafka数据时会生成大量小文件,导致hdfs告警超过DataNode块计数阈值,通过cloudera设置hdfs块计数阈值如下:
DataNode 块计数阈值 |
警告: 500000.0, 严重: 从不 默认值
|
DataNode 上块数的运行状况测试阈值 |
另外一个问题是checkpoint产生的大量小文件清理的问题,虽然设置了
trigger(Trigger.ProcessingTime("10 seconds"))
依旧会产生大量小文件,线上运行一段时间后发现小文件数量达到一定数量后不会再持续增加,查看hdfs文件目录(命令参考文章下面)发现超过一定数量后会进行文件清理,具体源码暂未分析,可以参考文章:Spark Streaming源码解读之数据清理机制解析,https://zhou-yuefei.iteye.com/blog/2308596
checkpoint目录结构
metadata:
目录结构:$checkpoint/metadata
这个文记录了这个query的id,id采用java的UUID来生成,在k中,一次完整的source-sink可以当成是一个query,代码而言就是再调用DataFrameWriter.start()的时候,创建一个query,并且把这个id存在checkpoint中,以后恢复继续使用这个id。参考类StreamExecution
soures
目录结构:$checkpoint/sources/$sourceId/$batchId
这个目录用于辅助source,方便source存储一些自己需要的数据,拿kafka来说,sources目录下存储了初始化的topic,partition,offset数据。啊、从实际作用上并不是必须的,因此这个功能也可以理解为方便再次获取这些数据,起到辅助作用。
offset:
目录结构:$checkpoint/offset/$batchId
这个目录记录了source的元数据,offset是针对消费数据的记录索引,并用batchId作为文件名,记录每个batch的offset,比如soure是kafka的时候,offset里就记录了消费的kafka的topic,partition以及offset。
commits:
目录结构:$checkpoint/commits/$batchId
这个目录记录了已经成功执行完成的batch,当一个batch开始执行时,spark会在hdfs上记录一个offset文件,当这个batch执行完成后,spark会在hdfs上记录一个commits文件,表明这个batch正常,不需要回滚。
state:
目录结构:$checkpoint/state/xxx.delta
| $checkpoint/state/xxx.snapshot
这个目录里存放sql的中间结果数据,针对几种规则做一些快照,参考IncrementalExecution.state
。比如聚合操作,就需要记录每个batch的聚合结果,试想一下,累加一个字段,那么recover后就需要把之前累加的值还原,否则最后的结果就会变小,这个checkpoint就形同虚设了。除了聚合,常见的还有去重,limit等操作。state也有很多限制,spark默认实现了一个基于HDFS的StateStore,HDFSBACKEDStateStoreProvider
,内部有两种类型的文件,.delta
和.snapshot
。这有点像hdfs的edit和image文件,.delta
文件是一些零碎的更细或添加操作,.snapshot
文件是整合之前的.snapshot
和.delta
后生成的文件。spark根据配置spark.sql.streaming.stateStore.maintenanceInterval
来周期性生成.snapshot
文件,默认是60s。
在数据结构方面相比上面几个目录会复杂一点,这两个文件格式是一样的,都是基于kv的文件,物理格式为:[key长度][key数据][value长度][value数据]。并且所有的对key的更新或者删除操作都是基于对文件的追加而不是直接修改,类似hbase的思路。然后在recover过程中导入.shnapshot
或者.delta
文件到内存的时候做统一合并,这个可以参考方法HDFSBackedStateStoreProvider.loadMap()
。
checkpoint recovery 流程
offset根据不同的状态分为三类,都以Map作为数据结构,key是source,因为offset是基于source,sink或者query都是基于对source的回放来实现checkpoint,这样是spark建议开发者采用幂等算法来完成自己的功能。好了接着说三种状态的offset,1.source处理过的batch的offset,称为offsets;2.执行完成的batch的offset,称为commits;3.下一个batch,source需要处理的batch,称为availableOffsets。其中,offsets和commits是为落盘的,availableOffsets缓存在内存作为中间状态。spark首先根据配置找到checkpoint的路径,读取offset目录下latest的batchId文件,当然,如果没有对应文件或者目录那么说明这个query是第一次执行,无需recovery。获取到latest的batchId后再去看commits目录下latest的batchId,对比得到nextBatchId以及nextOffset,最后将nextOffset传给source,构建出nextBatch。
checkpoint总结
可以看到,checkpoint的作用范围是一个query,而作用的模块是source,所谓的断点重算,自动恢复功能都是基于对source数据的重新拉取,对于不同的source抽象出offset接口,用于记录各个source的元数据,再统一序列化方式json,写入hdfs,从而实现有状态的计算。而对数据的重复拉取,大致是两种解决方案。1.采用类似事务的机制,将do something和commit something拆为两个步骤,只有执行完commit才是处理成功的数据;2.采用幂等算法,让开发者自己适配各个sink的幂等实现。针对幂等实现,还提出了end-to-end的exactly-once,大致思路是本来交给开发者来做的事情改由框架做,并结合具体的source和sink来完成真正的exactly-once。比如kafakSink,Kafka高版本支持了事务隔离机制,框架在自身commit之后再对kafka commit,从而实现端到端的幂等,这也就是结合其他组件的事务特性来实现exactly-once。
micro batch & continuous processing从spark2.3开始,spark支持新的一种流式计算策略,continuous processing。
先说一下为什么要加入这个心策略。对于MicroBatch策略来说,极限的处理延迟也要20ms左右,即使你配置了processTimes是0s,这是由框架导致的,每执行一个批,spark需要重新将这个批的task分发到各个executor,再进行计算,计算完成后还需要同步记录日志作为checkpoint,这里就会有毫秒级的延迟,毕竟spark出身是定位于批处理的,而且这样做的负担也会很大,spark不停的序列化反序列化任务,这些都是额外的开销。因此在MicroBatch的世界里,流式处理的延迟场景一般是秒级,亚秒级的。
在continuous模式下,spark通过维护一组long-running task集合来持续对数据进行read,process,write操作,这样就避免了task的创建销毁等操作,并且checkpoint的操作也优化为异步,这样就极大的减少了延迟。这种模型不再是用基于批去模拟流,而是基于事件流的思路。
那么问题来了,spark是怎么实现的呢,long-running还好说,直接在task的compute中写循环即可,那么checkpoint怎么做呢,要知道spark基于checkpoint实现容错,当一个批处理完后,spark会写一些offset,snapshot到hdfs。而现在没有批的概念。
没有批就要创造批,流是批的超集,我们需要定义一种规则在流中划分出一个个批即可。这里spark采用了Chandy-Lamport algorithm来做批的划分,从而实现分布式checkpoint。其原理是这样的,在每个task的数据流事件中注入epoch marker事件,在driver端做整体epoch的自增维护。当task处理到epoch marker事件后就通知driver,当driver发现收集到的epoch marker数量等同于source和sink的partition数量,那么就说明这一个epoch已经完成,driver端就可以把一个epoch的数据看做是一个批,从而进行checkpoint记录。
回到代码上,首先driver端启动一个EpochCoordinator
rpc服务,用于协调和exeutor的epoch。然后针对task级别的核心类ContinuousDataSourceRDD
和ContinuousWriteRDD
,spark做了如下设计。
ContinuousDataSourceRDD
在compute方法中调了的类ContinuousQueuedDataReader
,ContinuousQueuedDataReader
在next方法中实现了循环抓取非空数据,也就是使得task long-running,设计思路采用了生产者消费者模型,通过一个队列,3个线程来完成。其中,1个线程负责EpochMarker的生产,1个线程负责数据的生产,一个线程负责数据的计算。EpochMarker生产线程EpochMarkerGenerator
默认每过100ms注入一个EpochMarker
到队列,可以通过配置spark.sql.streaming.continuous.executorPollIntervalMs
修改,并且和driver端的EpochCoordinator
交互,获取同步当前的epoch。负责数据计算的线程在校验record类型的时候,如果发现是EpochMarker那么就表明该partition的数据到这个epoch已经结束,并向driver的EpochCoordinator
发出report命令。ContinuousDataSourceRDD
相对较为简单,直接在rdd.compute方法中写了一个循环保证task的long-running,并且在处理完一个epoch的数据后向driver的EpochCoordinator
发出commit命令。
这里解释一下report和commit命令的区别,还记得MicrBatch的offset和commit么,这里的report的功能可以看做是offset,用于记录offset日志,commit用于记录commit日志,这两个都是checkpoint的一部分。
官网给出性能指标,MicroBatch的最小延迟时间在100ms,Continuous processing的最小延迟时间在1ms。
hdfs统计某个目录下的文件数hadoop fs -count < hdfs path >
统计hdfs对应路径下的目录个数,文件个数,文件总计大小
显示为目录个数,文件个数,文件总计大小,输入路径
例如:
hadoop fs -rm -r -f /user/lscm/* 删除hdfs文件
hadoop fs -ls /user/lscm/*
hadoop fs -count /user/lscm/
207 23208 12744300 /user/lscm/100 获得23208个文件
参考:
http://spark.apache.org/docs/latest/structured-streaming-programming-guide.html#recovering-from-failures-with-checkpointing
https://github.com/apache/spark/blob/master/sql/core/src/main/scala/org/apache/spark/sql/streaming/StreamingQueryManager.scala#L198
http://ixiaosi.art/2019/02/18/spark/spark-structured-streaming%E5%88%86%E6%9E%90/