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 用户自定义状态序列化

  https://ci.apache.org/projects/flink/flink-docs-release-1.10/dev/stream/state/custom_serialization.html

  导读中也说明了,如果使用的是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版本的,之前之后的版本有什么坑不在讨论范畴,本文仅提供思路,需要具体问题具体分析  

从udaf谈flink的state的更多相关文章

  1. Flink之state processor api实践

    前不久,Flink社区发布了FLink 1.9版本,在其中包含了一个很重要的新特性,即state processor api,这个框架支持对checkpoint和savepoint进行操作,包括读取. ...

  2. Flink之state processor api原理

    无论您是在生产环境中运行Apache Flink or还是在过去将Flink评估为计算框架,您都可能会问自己一个问题:如何在Flink保存点中访问,写入或更新状态?不再询问!Apache Flink ...

  3. Flink -- Keyed State

    /* <pre>{@code * DataStream<MyType> stream = ...; * KeyedStream<MyType> keyedStrea ...

  4. 一篇谈Flink不错的文章

    精华 : 在执行引擎这一层,流处理系统与批处理系统最大不同在于节点间的数据传输方式.对于一个流处理系统,其节点间数据传输的标准模型是:当一条数据被处理完成后,序列化到缓存中,然后立刻通过网络传输到下一 ...

  5. [源码解析] Flink UDAF 背后做了什么

    [源码解析] Flink UDAF 背后做了什么 目录 [源码解析] Flink UDAF 背后做了什么 0x00 摘要 0x01 概念 1.1 概念 1.2 疑问 1.3 UDAF示例代码 0x02 ...

  6. Flink - state

      public class StreamTaskState implements Serializable, Closeable { private static final long serial ...

  7. Flink - Working with State

    All transformations in Flink may look like functions (in the functional processing terminology), but ...

  8. Flink Streaming状态处理(Working with State)

    参考来源: https://www.jianshu.com/p/6ed0ef5e2b74 https://blog.csdn.net/Fenggms/article/details/102855159 ...

  9. 修改代码150万行!与 Blink 合并后的 Apache Flink 1.9.0 究竟有哪些重大变更?

    8月22日,Apache Flink 1.9.0 正式发布,早在今年1月,阿里便宣布将内部过去几年打磨的大数据处理引擎Blink进行开源并向 Apache Flink 贡献代码.当前 Flink 1. ...

随机推荐

  1. INS-40718 和 INS - 30516

    RAC  安装的时候报错, INS-40718 这个是自己填写的  scan name 和 /etc/hosts  里定义的不一致  可以cat   /etc/hosts   看一下 INS - 30 ...

  2. 记一次 Microsoft.Bcl.Async 使用经验

    起因: 由于公司项目使用场景存在很多的XP环境,导致使用.NET Framework版本不能大于4.0版本.最近开发新功能时:从nuget上下载一个开源dll(该dll 4.0 版本依赖 Micros ...

  3. 设计模式:prototype模式

    使用场景:在不能根据类创建对象的时候,根据已有的对象创建对象 不能根据类创建对象的情况: 创建一个类的对象时,需要根据多种对象来创建,创建的过程非常复杂 难以根据类生成对象 例子: class Pro ...

  4. Python对列表去重的各种方法

    一.循环去重   二.用 set() 去重 1.set()对list去重 2.list 是有序的,用 sort() 把顺序改回来  三.利用 dict 的属性来去重 1.用 dict 的 fromke ...

  5. MySQL之字段数据类型和列属性

    数据类型: 对数据进行统一的分类,从系统的角度出发,为了能够使用统一的方式进行管理,更好的利用有限的空间. SQL中将数据类型分成了三大类:数值类型.字符串类型.时间日期类型. 数值型: 数值型数据: ...

  6. C#程序员装机必备软件及软件地址

    装机必备 Visio2010 下载 http://gd.ddooo.com/visio2010_12530.rar Office 安装包 http://xz.cncrk.com:8080/soft/k ...

  7. three.js 制作魔方

    因为之前的几节讲了一些数学方法,例如Vector3.Matrix4.Euler还有Quaternion的知识.所以这篇郭先生就来说说用three.js怎么制作一个魔方.在线案例请点击博客原文 制作魔方 ...

  8. Airflow Dag可视化管理编辑工具Airflow Console

    Airflow Console: https://github.com/Ryan-Miao/airflow-console Apache Airflow扩展组件, 可以辅助生成dag, 并存储到git ...

  9. PHP popen() 函数

    定义和用法 popen() 函数使用 command 参数打开进程文件指针. 如果出错,该函数返回 FALSE. 语法 popen(command,mode) 参数 描述 command 必需.规定要 ...

  10. luogu CF125E MST Company wqs二分 构造

    LINK:CF125E MST Company 难点在于构造 前面说到了求最小值 可以二分出斜率k然后进行\(Kruskal\) 然后可以得到最小值.\(mx\)为值域. 得到最小值之后还有一个构造问 ...