Apache Flink(简称Flink)项目是大数据处理领域最近冉冉升起的一颗新星,其不同于其他大数据项目的诸多特性吸引了越来越多人的关注。本文将深入分析Flink的一些关键技术与特性,希望能够帮助读者对Flink有更加深入的了解,对其他大数据系统开发者也能有所裨益。本文假设读者已对MapReduce、Spark及Storm等大数据处理框架有所了解,同时熟悉流处理与批处理的基本概念。
Flink核心是一个流式的数据流执行引擎,其针对数据流的分布式就是那种提供了数据分布、数据通信以及容错机制等功能。基于流行执行引擎,Flink提供了诸多更高抽象层API一遍用户编写分布式任务:
此外,Flink还针对特定的应用领域提供了领域库,例如:
此外,Flink也可以方便地和Hadoop生态圈中其他项目集成,例如Flink可以读取存储在HDFS或HBase中的静态数据,以Kafka作为流式的数据源,直接重用MapReduce或Storm代码,或是通过YARN申请集群资源等。
在大数据处理领域,批处理任务与流处理任务一般被认为是两种不同的任务,一个大数据项目一般会被设计为只能处理其中一种任务,例如Apache Storm、Apache Smaza只支持流处理任务,而Apache MapReduce、Apache Tez、Apache Spark只支持批处理任务。Spark Streaming是Apache Spark之上支持流处理任务的子系统,看似一个特例,实则不然——Spark Streaming采用了一种micro-batch的架构,即把输入的数据流切分成细粒度的batch,并为每一个batch数据提交一个批处理的Spark任务,所以Spark Streaming本质上还是基于Spark批处理系统对流式数据进行处理,和Apache Storm、Apache Smaza等完全流式的数据处理方式完全不同。通过其灵活的执行引擎,Flink能够同时支持批处理任务与流处理任务。
在执行引擎这一层,流处理系统与批处理系统最大不同在于节点间的数据传输方式。
对于一个流处理系统,其节点间数据传输的标准模型是:
当一条数据被处理完成后,序列化到缓存中,然后立刻通过网络传输到下一个节点,由下一个节点继续处理。
而对于一个批处理系统,其节点间数据传输的标准模型是:
当一条数据被处理完成后,序列化到缓存中,并不会立刻通过网络传输到下一个节点,当缓存写满,就持久化到本地硬盘上,当所有数据都被处理完成后,才开始将处理后的数据通过网络传输到下一个节点。
这两种数据传输模式是两个极端,对应的是流处理系统对低延迟的要求和批处理系统对高吞吐量的要求。
Flink的执行引擎采用了一种十分灵活的方式,同时支持了这两种数据传输模型。Flink以固定的缓存块为单位进行网络数据传输,用户可以通过缓存块超时值指定缓存块的传输时机。如果缓存块的超时值为0,则Flink的数据传输方式类似于上文所提到流处理系统的标准模型,此时系统可以获得最低的处理延迟。如果缓存块的超时值为无限大,则Flink的数据传输方式类似于上文所提到批处理系统的标准模型,此时系统可以获得最高的吞吐量。同时缓存块的超时值也可以设置为0到无限大之间的任意值。缓存块的超时值阈值越小,则Flink流处理执行引擎的数据处理延迟越低,但吞吐量也会降低,反之亦然。通过调整缓存块的超时阈值,用户可根据需求灵活地权衡系统延迟和吞吐量。
Flink执行引擎数据传输模式。
在统一的流式执行引擎基础上,Flink同时支持了流计算和批处理,并对性能(延迟、吞吐量等)有所保障。相对于其他原生的流处理与批处理系统,并没有因为统一执行引擎而受到影响从而大幅度减轻了用户安装、部署、监控、维护等成本。
对于一个分布式系统来说,单个进程或是节点崩溃导致整个Job失败是经常发生的事情,在异常发生时不会丢失用户数据并能自动恢复才是分布式系统必须支持的特性之一。本节主要介绍Flink流处理系统任务级别的容错机制。
批处理系统比较容易实现容错机制,由于文件可以重复访问,当某个任务失败后,重启该任务即可。但是到了流处理系统,由于数据源是无限的数据流,从而导致一个流处理任务执行几个月的情况,将所有数据缓存或是持久化,留待以后重复访问基本是上不可行的。Flink基于分布式快照与可部分重发的数据源实现了容错。用户可以自定义对整个Job进行快照的时间间隔,当任务失败时,Flink会将整个Job恢复到最近一次快照,并从数据源重发快照之后的数据。Flink的分布式快照实现借鉴了Chandy和Lamport在1985年发表的一篇关于分布式快照的论文,其实现的主要思想如下:
按照用户自定义的分布式快照间隔时间,Flink会定时在所有数据源中插入一种特殊的快照标记信息,这些快照标记消息和其他信息一样在DAG中流动,但是不会被用户定义的业务逻辑所处理。每一个快照标记信息都将其所在的数据流分成两部分:本次快照数据和下次快照数据。
快照标记信息沿着DAG流经各个操作符,当操作符处理到快照标记信息时,会对自己的状态进行快照,并存储起来。当一个操作符有多个输入的时候,Flink会将先抵达的快照标记信息及其之后的消息缓存起来,当所有的输入中对应该次快照标记信息全部抵达后,操作符对自己的状态快照并存储,之后处理所有快照标记信息之后的已缓存信息。操作符对自己的状态快照并存储可以是异步与增量的操作,并不需要阻塞消息的处理。分布式快照的流程如图所示:
Flink分布式快照流程图
当所有的Data Sink(终点操作符)都收到快照标记信息并对自己的状态快照和存储后,整个分布式快照就完成了,同时通知数据流释放该快照标记消息之前的所有消息。若之后发生节点崩溃等一场情况时,只需要恢复之前存储的分布式快照状态,并从数据源重发该快照以后的消息就可以了。
Exactly-Once是流处理系统需要支持的一个非常重要的特性,它保证每一条消息只被流处理系统处理一次,许多流处理任务的业务逻辑都依赖于Exactly-Once特性。相对于At-Least-Onece或是At-Most-Once,Exactly-Once特性对流处理系统更为严格,实现也更加困难。Flink基于分布式快照实现了Exactly-Once特性。
相对于其他流处理系统的容错方案,Flink基于分布式快照的方案在功能和性能方面都具有很多优点,包括:
对于流处理系统来说,流入的消息不存在上限,所以对于聚合或是连接等操作,流处理系统需要对流入的消息进行分段,然后基于每一段数据进行聚合或是连接。消息的分段即称为窗口,流处理系统支持的窗口有很多类型,最常见的就是时间窗口,基于时间间隔对消息进行分段处理。本节主要介绍Flink流处理系统支持的各种时间窗口。
对于目前大部分流处理系统来说,时间窗口一般是根据Task所在节点的本地时钟进行切分,这种方式实现起来比较容易,不会产生阻塞。但是可能无法满足某些应用需求,比如:
消息本身带有时间戳,用户希望按照消息本身的时间特性进行分段处理。
由于不同节点的时钟可能不同,以及消息在流经各个节点的延迟不同,在某个节点属于同一个时间窗口处理的消息,流到下一个节点时可能被切分到不同的时间窗口中,从而产生不符合预期的结果。
Flink支持是三种类型的时间窗口,分别适用于用于对于时间窗口不同类型的要求:
Flink借鉴了Google的MillWheel项目,通过WaterMark来支持基于Event Time的时间窗口。
当操作符通过基于Event Time的时间窗口来处理数据时,它必须在确定所有属于该事件窗口的消息全部流入此操作符后才能开始数据处理。但是由于消息可能是乱序的,所以操作符无法直接确认何时所有属于该时间窗口的消息全部流入此操作符。WaterMark包含一个时间戳,Flink使用WaterMark标记所有小于该时间戳的消息都已流入,Flink的数据源在确认所有小于某个时间戳的消息都已输出到Flink流处理系统后,会生成一个包含该时间戳的WaterMark,插入到消息流中输出到Flink流处理系统中,Flink操作符按照时间窗口缓存所有流入的消息,当操作符处理到WaterMark时,它对所有小于该WaterMark时间戳的时间窗口数据进行处理并发送到下一个操作符节点,然后也将WaterMark发送到下一个操作符节点。
为了保证能够处理所有属于某个时间窗口的消息,操作符必须等到大于这个时间窗口的WaterMark之后才能开始对该时间窗口的消息进行处理,相对于基于Operator Time的时间窗口,Flink需要占用更多内存,且会直接影响消息处理的延迟时间。对此,一个可能的优化措施是,对于聚合类的操作符,可以提前对部分消息进行聚合操作,当有属于该时间窗口的新消息流入时,基于之前的部分聚合结果继续计算,这样的话,只需缓存中间计算结果即可,无序缓存该时间窗口的所有消息。
对于基于Event Time时间窗口的操作符来说,流入WaterMark的时间戳与当前节点的时钟一致是最简单理想的状态,但是实际环境中是不可能的,由于消息的乱序以及前面节点处理效率的不同,总是会有某些消息流入时间大于其本身的时间戳,真实WaterMark时间戳与理想情况下WaterMark时间戳的差别称为Time Skew,如下图所示:
WaterMark的Time Skew图
Time Skew决定了该WaterMark与上一个WaterMark之间的时间窗口所有数据需要缓存的时间,Time Skew时间越长,该时间窗口数据的延迟越长,占用内存的时间也越长,同时会对流处理系统的吞吐量产生负面影响。
在流处理系统中,由于流入的消息时无限的,所以对消息进行排序基本上被认为是不可行的。但是在Flink流处理系统中,基于WaterMark,Flink实现了基于时间戳的全局排序。排序的实现思路如下:排序操作符缓存所有流入的消息,当其接收到WaterMark时,对时间戳小于该WaterMark的消息进行排序,并发送到下一个节点,在此排序操作符中释放所有时间戳小于该WaterMark的消息,继续缓存流入的消息,等待下一个WaterMark触发下一次排序。
由于WaterMark保证了在其之后不会出现时间戳比它小的消息,所以可以保证排序的正确性。需要注意的是,如果排序操作符有多个节点,只能保证每个节点的流出消息是有序的,节点之间的消息不能保证有序,要实现全局有序,则只能有一个排序操作符节点。
通过支持基于Event Time的消息处理,Flink扩展了其流处理系统的应用范围,使得更多的流处理任务可以通过Flink来执行。
Flink项目基于Java及Scala等JVM语言,JVM本身作为一个各种类型应用的执行平台,其对Java对象的管理也是基于通用的处理策略,其垃圾回收器通过估算Java对象的生命周期对Java对象进行有效率的管理。
针对不同类型的应用,用户可能需要针对该类型应用的特点,配置针对性的JVM参数更加有效率的管理Java对象,从而提高性能。这种JVM调优的黑魔法需要用户对应用本身及JVM的各参数有深入了解,极大地提高了分布式计算平台的调优门槛。Flink框架本身了解计算逻辑每个步骤的数据传输,相比于JVM垃圾回收器,其了解更多的Java对象生命周期,从而为更有效率地管理Java对象提供了可能。
为了解决以上提到的问题,高性能分布式计算框架通常需要以下技术:
缓存友好的数据结构和算法。对于计算密级的数据结构和算法,直接操作序列化后的二进制数据,而不是将对象反序列化后在进行操作。同时,只将操作相关的数据连续存储,可以最大化利用L1/L2/L3缓存,减少Cache miss的概率,提升CPU计算的吞吐量。以排序为例,由于排序的主要操作是对Key进行对比,如果将所有排序数据的Key与Value分开并对Key连续存储,那么访问Key时的Cache命中率会大大提高。
分布式计算框架可以使用定制序列化工具的前提是要待处理数据流通常是同一类型,由于数据集对象的类型固定,从而可以只保存一份对象Schema信息,节省大量的存储空间。同时,对于固定大小的类型,也可通过固定的偏移位置存储。在需要访问某个对象成员变量时,通过定制的序列化工具,并不需要反序列化整个Java对象,而是直接通过偏移量,从而只需要反序列化特定的对象成员变量,如果对象的成员变量较多时,能够大大减少Java对象的创建开销,以及内存数据的拷贝大小。Flink数据集都支持任意Java或是Scala类型,通过自动生成定制序列化工具,既保证了API接口对用户友好(不用像Hadoop那样数据类型需要继承实现org.apache.hadoop.io.Writable接口),也达到了和Hadoop类似的序列化效率。
Flink对数据集的类型信息进行分析,然后自动生成定制的序列化工具类。Flink支持任意的Java或是Scala类型,通过调用Java Reflection框架分析基于Java的Flink程序UDF(User Define Function)的返回类型的类型信息,通过Scala Compiler分析基于Scala的Flink程序UDF的返回类型的类型信息。类型信息由TypeInformatica类表示,这个类有诸多具体实现类,例如:
前六种类型数据集几乎覆盖了绝大部分的Flink程序,针对前六种类型数据集,Flink皆可以自动生成对应的TypeSerializable定制序列化工具,非常有效率地对数据集进行序列化和反序列化。对于第七种类型,Flink使用Kryo进行序列化和反序列化。此外,对于可被用作Key的类型,Flink还同时自动生成TypeComparator,用来辅助直接对序列化后的二进制数据直接进行compare、hash等操作。对于Tuple、CaseClass、Pojo等组合类型,Flink自生成的TypeSerializer、TypeComparator同样是组合的,并把其成员的序列化/反序列化代理给其成员对应的TypeSerializer、TypeComparator,如图所示:
Flink组合类型序列化
此外如有需要,用户可通过集成TypeInformation接口定制实现自己的序列化工具。
垃圾回收是JVM内存管理回避不了的问题,JDK8的G1算法改善了JVM垃圾回收的效率和可用范围,但对于大数据处理实际环境还远远不够。这也和现在分布式框架的发展趋势有所冲突,越来越多的分布式计算框架希望尽可能多地将待处理数据集放入内存,而对于JVM垃圾回收来说,内存中Java对象越少、存货时间越短,其效率越高。通过JVM进行内存管理的话,OutOfMemoryError也是一个很难解决的问题。同时,在JVM内存管理中,Java对象有潜在的碎片化存储问题(Java对象所有信息可能在内存中里连续存储),也有可能在所有Java对象大小没有超过JVM分配内存时,出现OutOfMemoryError问题。Flink将聂村分为3个部分,每个部分都有不同用途:
Network buffers在Flink中主要基于Netty的网络传输,无需多讲。
Remaining Heap用于UDF中用户自己创建的Java对象,在UDF中,用户通常是流式的数据处理,并不需要很多内存,同时Flink也不鼓励用户在UDF中缓存跟多数据,因为这会引起前面提到的诸多问题。
Memory Manager pool(以后以内存池代指)通常会配置为最大的一块内存,接下来会详细介绍。
在Flink中,内存池由多个MemorySegment组成,每个MemorySegment代表一块连续的内存,底层存储是byte[],默认32KB大小。MemorySegment提供了根据偏移量访问数据的各种方法,如get/put int、long、float、double等,MemorySegment之间数据拷贝等方法和java.nio.ByteBuffer类似。对于Flink的数据结构,通常包括多个向内存池申请的MemorySegment,所有要存入的对象通过TypeSerializer序列化之后,将二进制数据存储在MemorySegment中,在取出时通过TypeSerializer反序列化。数据结构通过MemorySegment提供的set/get方法访问具体的二进制数据。Flink这种看起来比较复杂的内存管理方式带来的好处主要有:
Flink当前的内存管理在最底层是基于byte[],所以数据最终还是on-heap,最近Flink增加了off-heap的内存管理支持。Flink off-heap的内存管理相对于on-heap的优点主要在于:
磁盘IO和网络IO之前一直被认为是Hadoop系统的瓶颈,但是随着Spark、Flink等新一代分布式计算框架的发展,越来越多的趋势使得CPU/Memory逐渐成为瓶颈,这些趋势包括:
由于CPU处理速度和内存访问速度的差距,提升CPU的处理效率的关键在于最大化的利用L1/L2/L3/Memory,减少任何不必要的Cache miss。定制的序列化工具工具给Flink提供了可能,通过定制的序列化工具,Flink访问的二进制数据本身,因为占用内存较小,存储密度比较大,而且还可以在设计数据结构和算法时尽量连续存储,减少内存碎片化对Cache命中率的影响,甚至更进一步,Flink可以只是将需要操作的部分数据(如排序时的Key)连续存储,而将其他部分的数据存储在其他地方,从而最大可能地提升Cache命中的概率。
以Flink中的排序为例,排序通常是分布式计算框架中一个非常重要的操作,Flink通过特殊设计的排序算法获得了非常好的性能,其排序算法的实现如下:
这样实现的好处有:
通过定制的内存管理,Flink通过充分利用内存与CPU缓存,大大提高了CPU的执行效率,同时由于大部分内存都由框架自己控制,也很大程度提升了系统的健壮性,减少了OOM出现的可能。
本文主要介绍了Flink项目的一些关键特效型,Flink是一个拥有诸多特色的项目,包括其统一的批处理和流处理执行引擎,通用大数据计算框架与传统数据库系统的技术结合,以及流处理系统的诸多技术创新等。