1.前言
本文主要基于实践过程中遇到的一系列问题,来详细说明Flink的状态后端是什么样的执行机制,以理解自定义函数应该怎么写比较合理,避免踩坑。
内容是基于Flink SQL的使用,主要说明自定义聚合函数的一些性能问题,状态后端是rocksdb。
2.Flink State
https://ci.apache.org/projects/flink/flink-docs-release-1.10/dev/stream/state/state.html
上面是官方文档,这里按照个人思路快速理解一下重要内容:
在Flink中,最底层的接口是Function, 往上就是Stateful Function。函数的具体实现可以理解为operator,分为Keyed Operator和一般的Operator,简单理解就是实时流数据需不需要分组处理。
正是因为有了两大类算子,所以状态也分成了Keyed State和Operator State。State是什么?个人理解就是一个临时存储的数据集,至于为什么需要临时存储很好理解:通常我们都需要实时统计一些结果,但是数据流是一条条处理的,必须保存中间状态。比如sum函数,要从state中get之前的结果,加上本次的结果,再put到state中。又比如join操作,需要尝试获取join对象存不存在,保存自己的本次对象,便于其他数据进行join。
通过上面描述,可以看出一般聚合等涉及到多条数据的操作,都是需要保存状态的,否则一条条记录处理(比如提取某个字段的值),前后没有关联,自然不需要状态了,前者就是Keyed State。那Operator State为什么存在?实际使用中,该状态主要是用于保存source的消费位点,以便failover重新启动的时候能够找到正确的消费位置,这是Flink的一致性很重要的地方。
临时的数据集临时的原因在于:流是没有边界的,数据会不断增大,不说内存,哪怕是磁盘容量,以及checkpoint操作性能问题,也无法做到无限状态。所以每个state都需要设置ttl时间,判断这个临时的数据需要保存多久。比如你要统计每天的数据,那可能要保存24个小时以上,A数据0点出现一次,24点出现一次,保存的时间小于ttl,第一次的数据就会被清除,导致最后结果错误,24小时以上需要考虑数据延迟到达的问题。
被管理的状态有以下几种:ValueState,ListState,ReducingState,AggregatingState,FoldingState(废弃),MapState。
Flink目前提供了3种状态后端:Memory, Fs,Rocksdb。这些状态后端必须实现上面所管理的状态,所以新增状态后端的时候一样需要。
3.udaf与Flink序列化
Flink允许用户编写UDAF自定义聚合函数来满足特殊的计算需求,这里就会存在一个令人疑惑的问题:用户的代码是无法控制的,那异常重启的时候怎么恢复用户代码的数据呢?其实很简单,将用户的代码生成对象,在checkpoint的时候一并序列化保存就好了。等到异常重启的时候,反序列化就可以了。下面谈谈flink是如何序列化对象的。
3.1 Flink的类型与序列化
https://ci.apache.org/projects/flink/flink-docs-release-1.10/dev/types_serialization.html
你可以在flink-core包中org.apache.flink.api.java.typeutils, org.apache.flink.api.common.typeutils中找到大量与之相关的内容
Flink实现了:
1.所有的java基础类型,包括封装类型,以及Void,String,Date,BigDecimal,BigInteger, org.apache.flink.api.common.typeutils
2.基本类型数组,及对象数组 org.apache.flink.api.common.typeutils
3.组合类型:Tuple, Row, Pojo, Scala。实现都在org.apache.flink.api.java.typeutils
4.辅助类型:List, Map等
5.一般类型:这些不会被Flink序列化,而是被Kryo。 所以不被flink识别的Class,以及没有自定义序列化器可以匹配的时候,都会使用Kryo进行序列化。但是牢记,Kryo不是万能的,所以最好自己定义。
如果你不想用kryo序列化,希望程序抛出异常,以便确定自定义序列化是否缺失,可以禁用kryo: env.getConfig().disableGenericTypes();
3.2 用户自定义状态序列化
导读中也说明了,如果使用的是Flink自己的序列化器,本节可以忽略掉,故不作更多说明。
4. rocksdb状态后端问题
通过上面的介绍,可以明白自定义的函数对象都是需要序列化和反序列化的,这样确保了异常重启后状态可以恢复。但是实际上不同的状态后端处理方式是不一样的,这也是本文想说明的内容。
上面提到flink提供了三种状态后端,分别是基于内存,文件和rocksdb,但只有rocksdb支持增量式存储。这其中是有什么不同之处呢?本文不讨论rocksdb如何实现增量的,主要集中在rocksdb状态后端相比于FsStateBackend有什么区别,会引发什么问题。
FsStateBackend是基于文件、全量存储的,简单猜测一下就可以知道其所有数据都在内存中,等到checkpoint的时候全量序列化写入文件。实际上也确实如此:createKeyedStateBackend创建的是HeapKeyedStateBackend,对应的都是HeapListState, HeapValueState等等内容,和MemoryStateBackend没什么区别。以HeapValueState为例,其value和update方法没有进行多余的操作,只是简单的从statTable中获取和放回。
RocksDBStateBackend可以用相同的逻辑查看,其用的是完全不同的体系:RocksDBKeyedStateBackend,RocksDBValueState,RocksDBListState等。以RocksDBValueState为例,它用来存储的并不是stateTable,而是rocksdb对象,每次获取都需要从rocksdb读取,然后反序列化成相应的对象,更新都需要序列化,然后更新rocksdb里面的内容。
通过上面的描述就会发现问题,rocksdb的状态每次使用都需要序列化和反序列化,如果对象状态太大,必然会带来性能问题。
4.1 udaf运行过程
我们都知道udaf都有一个accumulator,这个肯定是需要被Flink管理的,那么具体是如何做到的呢?通过程序断点可以看见执行过程:
1.自定义的聚合函数都被封装成了:GroupAggProcessFunction,执行processElement。
可以看见里面的调用逻辑,首先注册状态清除定时器,然后state.value()获取当前的accumulator,没有就会调用function的createAccumulators方法初始化。
然后调用accumulate方法计算,获取计算结果,后面就是更新accumulator和其他数据,输出本次计算结果了。
2.state.value()执行的是ValueState,这个取决于所使用的状态后端,这里探讨的就是RocksDBValueState。
其从rocksdb中获取序列化后的字符串,然后将其反序列化。这个就是问题所在。
通过上述过程我们发现,使用rocksdb状态后端的时候,执行每一条数据,其对象都是需要序列化和反序列化的,而FsStateBackend使用的是内存,不会做额外操作。
如果聚合函数状态对象过大,这个地方就可能成为性能瓶颈。
4.2 distinct
按照上述描述distinct去重函数也应该会是一个大对象,需要收集所有数据才对,实际使用过程中并没有感知到很慢,这是怎么做到的呢?
这里要介绍一个重点内容:MapView。Flink操作distinct是通过类DistinctAccumulator完成的,其内部使用的是MapView。
可以发现,MapView会被翻译成RocksDBMapState,accumulator序列化的时候会忽略掉这个字段,使用的时候都是操作的RocksDBMapState,对单条数据进行操作。
所以聚合函数对象不要使用大对象,尽量拆分成小对象,充分利用前面提到的ListState,MapState操作,否则在rocksdb做状态后端时会引发性能问题。
AggregationCodeGenerator这个就是用来包装聚合相关代码的了,其中有个函数addAccumulatorDataViews()会将MapView替换成StateMapView。
// create DataViews val descFieldTerm = s"${dataViewFieldTerm}_desc" val descClassQualifier = classOf[StateDescriptor[_, _]].getCanonicalName val descDeserializeCode = s""" | $descClassQualifier $descFieldTerm = ($descClassQualifier) | ${classOf[EncodingUtils].getCanonicalName}.decodeStringToObject( | "$serializedData", | $descClassQualifier.class, | $contextTerm.getUserCodeClassLoader()); |""".stripMargin val createDataView = if (dataViewField.getType == classOf[MapView[_, _]]) { s""" | $descDeserializeCode | $dataViewFieldTerm = new ${classOf[StateMapView[_, _]].getCanonicalName}( | $contextTerm.getMapState( | (${classOf[MapStateDescriptor[_, _]].getCanonicalName}) $descFieldTerm)); |""".stripMargin } else if (dataViewField.getType == classOf[ListView[_]]) { s""" | $descDeserializeCode | $dataViewFieldTerm = new ${classOf[StateListView[_]].getCanonicalName}( | $contextTerm.getListState( | (${classOf[ListStateDescriptor[_]].getCanonicalName}) $descFieldTerm)); |""".stripMargin } else { throw new CodeGenException(s"Unsupported dataview type: $dataViewTypeTerm") } reusableOpenStatements.add(createDataView)
这就是一个基本过程。
5. 尴尬的选择 BloomFilter
这是本人所遇到的一个问题:
面对大量数据的去重操作,有时候我们并不需要过于精准,如果去重内容是整型,可以使用bitmap进行精确去重
但是很多时候数据都是字符串,比如设备号,如果像Kylin一样存在类似Global dictionary,可以为设备号生成一一映射的整型id,使用精确去重,但大多数情况下,我们只能选择bloomFilter或者hyperloglog。
这里仅对bloomFilter进行讨论,因为hyperloglog的使用的内存太少了,状态后端FsStateBackend足够了。
BloomFilter不一样,单个BloomFilter也可能达到500MB,如果有几千个组的group by计算不同页面,坑位的数据,如果使用FsStateBackend是无法接受的。
我看到网上大部分使用BloomFilter都是使用ValueState<BloomFilter>,像我所说的,如果只有十几个组的,内存消耗也不过几个G,FsStateBackend足够胜任,但是几千个就不太适合了。
此处说明一些坑点,以及尴尬之处:
1.Guava提供的BloomFilter使用rocksdb时有严重的性能问题,可能需要自定义序列化方式,没有测试过,改为Stream-lib提供的
2.像上述描述的,BloomFilter其实是一个大状态,每次序列化全量是无法接受的。bloom filter本质上是一个Long[],由于ListState不能通过下标来获取对应的对象,所以使用MapState,键是index,值是对应的Long。
3.根据bloom原理,需要多次hash,导致读写放大了N倍,任务运行到后面越来越慢
4.改成FsStateBackend性能暴增,问题是checkpoint慢,内存消耗大,原本目的就是解决内存消耗问题,采取rocksdb的增量保存,使用FsStateBackend返回回到了起点。
通过上述描述:可以明白如果使用FsStateBackend,性能确实没问题,但是是全量内存使用,还是那个问题,几千个group,内存消耗还是过大。如果使用Rocksdb,会发现读写放大,memTable命中率不高,性能越往后越差。
上述已经尝试将大对象改成Map,减少全量序列化,性能比未改之前提升几十倍以上,但是还是很慢。直接原因就是多次hash对比DistinctAccumulator造成读写放大,实际上性能也是其的1/5不到。如果使用BloomFilter的FsStateBackend比Rocksdb下distinct耗费的内存更多(前提是distinct满足性能要求),那得不偿失,这就是目前我面临的问题。最后,如果存在混合使用的场景(部分字段需要精确去重),使用FsStateBackend就更尴尬了,这导致distinct的也是全量在内存之中,这也是我没有使用hyperloglog的原因之一,其在rocksdb状态下性能也很差(也许我应该自己开发hyperloglog的flink实现 -。- 暂未尝试,先解决bloomFilter的问题)。
后续优化测试中的内容:
1.stream-lib的bloom过滤器是可以merge的,只要hashcount相同。实验可以发现,初始化元素个数10w-3000w错误率0.01得到的hashCount都是5,但是表现不同。10w个使用了更少的容量,数据标记位更集中。
考虑到数据分布的不均衡,可以对其做动态的扩容,而不是每个group都使用最大值的那个,这样可以再提升一波性能。但是存在的问题是由于容量发生了改变,旧有的数据位置出现了变动,更容易发生误判,需要权衡。
2.对rocksdb的参数调优
https://issues.apache.org/jira/browse/FLINK-10993 社区也讨论过,不知道为什么后面就凉了。
另外,针对上面的这种场景,是否有更好的解决方法,请留言给我。
6.总结
如何写好一个udaf?
1.定义的accumulator尽量小,否则在rocksdb情况下每次序列化会消耗大量时间
2.需要明确自定义的accumulator使用的序列化规则,否则默认会使用kryo,而kryo不是万能的,在某些情况性能极差,当然大部分情况还是可以的。所以观察到性能瓶颈的时候,考虑这个地方。
3.accumulator无法变小,考虑使用MapView最终生成的MapState尽量减少序列化的内容
4.FsStateBackend和RocksdbBackend在某些情况下很尴尬,互有不足,需要权衡,或者自己开发一个适应场景的高效工具
5.上面内容都是基于1.8版本的,之前之后的版本有什么坑不在讨论范畴,本文仅提供思路,需要具体问题具体分析