这是我参与「第四届青训营 」笔记创作活动的第9天。
Flink Exactly-once 的实现方式
Checkpoint 机制和 Two-phase commit protocol 是实现Flink Exactly-once语义的两大关键机制
一致性保证语义:
- At-most-once:每条数据消费至多一次,处理延迟低
- At-least-once:每条数据消费至少一次,一条数据可能存在重复消费
- Exactly-once:每条数据都被消费且仅被消费一次,仿佛故障从未发生
Checkpoint机制能够保证作业出现 fail-over 后可以从最新的快照进行恢复,即分布式快照机制可以保证 Flink 系统内部的“精确一次”处理。但是我们在实际生产系统中,Flink 会对接各种各样的外部系统,比如 Kafka、HDFS 等,一旦 Flink 作业出现失败,作业会重新消费旧数据,这时候就会出现重新消费的情况,也就是重复消费。关于Checkpoint机制的详细分析可以参考这篇博客。
针对这种情况,Flink 1.4 版本引入了一个很重要的功能:两阶段提交,也就是 TwoPhaseCommitSinkFunction。两阶段搭配特定的 source 和 sink(特别是 0.11 版本 Kafka)使得“精确一次处理语义”成为可能。
在 Flink 中两阶段提交的实现方法被封装到了 TwoPhaseCommitSinkFunction 这个抽象类中,我们只需要实现其中的beginTransaction、preCommit、commit、abort 四个方法就可以实现“Exactly-once”的处理语义,实现的方式我们可以在官网中查到:
BeginTransaction,在开启事务之前,我们在目标文件系统的临时目录中创建一个临时文件,后面在处理数据时将数据写入此文件;
PreCommit,在预提交阶段,刷写(flush)文件,然后关闭文件,之后就不能写入到文件了,我们还将为属于下一个检查点的任何后续写入启动新事务;
Commit,在提交阶段,我们将预提交的文件原子性移动到真正的目标目录中,请注意,这会增加输出数据可见性的延迟;
Abort,在中止阶段,我们删除临时文件。
Checkpoint 和 两阶段提交协议(2PC)简单介绍
Checkpoint
- Checkpoint barrier 的下发
- 算子状态制作和 barrier 传递
- 多个上游的等待 barrier 对齐现象
- Checkpoint 并不阻塞算子数据处
- Checkpoint ACK和制作完成
两阶段提交协议(2PC)
- Coordinator:协作者,同步和协调所有节点处理逻辑的中心节点
- Participant:参与者,被中心节点调度的其他执行处理逻辑的业务节点
两阶段提交协议在 Flink 中的应用
- Flink 中协作者和参与者的角色分配
- 协作者(JobManager)发起阶段一提交
- 各算子 Checkpoint 的制作
- 提交阶段及 Checkpoint 的制作完成
Flink-Kafka Exactly-once语义实现全过程
如上图所示,我们用 Kafka-Flink-Kafka 这个案例来介绍一下实现“ 端到端 Exactly-Once”语义的过程,整个过程包括:
从 Kafka 读取数据
窗口聚合操作
将数据写回 Kafka
整个过程可以总结为下面四个阶段:
一旦 Flink 开始做 checkpoint 操作,那么就会进入 pre-commit 阶段,同时 Flink JobManager 会将检查点 Barrier 注入数据流中 ;
当所有的 barrier 在算子中成功进行一遍传递,并完成快照后,则 pre-commit 阶段完成;
等所有的算子完成“预提交”,就会发起一个“提交”动作,但是任何一个“预提交”失败都会导致 Flink 回滚到最近的 checkpoint;
pre-commit 完成,必须要确保 commit 也要成功,上图中的 Sink Operators 和 Kafka Sink 会共同来保证。
Checkpoint 和 两阶段提交协议(2PC)存在的问题
Checkpoint
Flink 的 Checkpoint 机制可以在不停止整个 application 的情况下,从流应用中生成一致性分布式的检查点。然而,它会增加application的处理延时(processing latency)。
在一个task对它的状态做检查点时,它会阻塞,并缓存它的输入。因为state可以变的很大,并且检查点的操作需要通过网络写入数据到一个远端存储系统,所以做检查点的操作可能会很容易就花费几秒到几分钟,这对于延时敏感的application来说,延时过长了。
尽管增量 checkpoint 在大状态场景下极大减少了 checkpoint 的制作时间,但背后存在一些权衡,也就带来一些问题:
- 每一次 checkpoint 仅生成增量文件,完整状态文件依赖多个 checkpoint
- 由于要从多个 checkpoint 中读取恢复数据,任务恢复时间变久
- 尽管 checkpoint 自身有清理机制,但由于 checkpoint 之间存在依赖关系,旧的 checkpoint 可能并不会被删除,文件数会膨胀
例如我们有一个大状态作业,整个 checkpoint 文件大小超过 10T,shared 共享文件数多达 20W 个,单个算子的文件大小超过 1T
两阶段提交协议(2PC)
- 性能问题:无论是在第一阶段的过程中,还是在第二阶段,所有的参与者资源和协调者资源都是被锁住的,只有当所有节点准备完毕,事务协调者才会通知进行全局提交,参与者进行本地事务提交后才会释放资源。这样的过程会比较漫长,对性能影响比较大。
- 单节点故障:由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(虽然协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)。
总结我们上面讨论的问题:2PC在四个方面使系统性能降低:延迟(协议的时间加上冲突事务的停顿时间),吞吐量(因为它需要防止在协议期间运行其他冲突的事务,banq注:只能让事务串行执行,区块链其实是一个事务链),可扩展性(更大)在系统中,事务变得多分区并且必须支付2PC的吞吐量和延迟成本以及可用性(我们上面讨论的阻塞问题)的可能性越大。
相应的优化方案
针对 Checkpoint 机制
一、设置最小时间间隔
当Flink应用开启Checkpoint功能,并配置Checkpoint时间间隔,应用中就会根据指定的时间间隔周期性地对应用进行Checkpoint操作。默认情况下Checkpoint操作都是同步进行,也就是说,当前面触发的Checkpoint动作没有完全结束时,之后的Checkpoint操作将不会被触发。在这种情况下,如果Checkpoint过程持续的时间超过了配置的时间间隔,就会出现排队的情况。如果有非常多的Checkpoint操作在排队,就会占用额外的系统资源用于Checkpoint,此时用于任务计算的资源将会减少,进而影响到整个应用的性能和正常执行。
在这种情况下,如果大状态数据确实需要很长的时间来进行Checkpoint,那么只能对Checkpoint的时间间隔进行优化,可以通过Checkpoint之间的最小间隔参数进行配置,让Checkpoint之间根据Checkpoint执行速度进行调整,前面的Checkpoint没有完全结束,后面的Checkpoint操作也不会触发。
streamExecutionEnvironment.getCheckpointConfig().setMinPauseBetweenCheckpoints(milliseconds)
复制
通过最小时间间隔参数配置,可以降低Checkpoint对系统的性能影响,但需要注意的事,对于非常大的状态数据,最小时间间隔只能减轻Checkpoint之间的堆积情况。如果不能有效快速地完成Checkpoint,将会导致系统Checkpoint频次越来越低,当系统出现问题时,没有及时对状态数据有效地持久化,可能会导致系统丢失数据。因此,对于非常大的状态数据而言,应该对Checkpoint过程进行优化和调整,例如采用增量Checkpoint的方法等。
用户也可以通过配置CheckpointConfig中setMaxConcurrentCheckpoints()方法设定并行执行的checkpoint数量,这种方法也能有效降低checkpoint堆积的问题,但会提高资源占用。同时,如果开始了并行checkpoint操作,当用户以手动方式触发savepoint的时候,checkpoint操作也将继续执行,这将影响到savepoint过程中对状态数据的持久化。
二、预估状态容量
除了对已经运行的任务进行checkpoint优化,对整个任务需要的状态数据量进行预估也非常重要,这样才能选择合适的checkpoint策略。对任务状态数据存储的规划依赖于如下基本规则:
- 正常情况下应该尽可能留有足够的资源来应对频繁的反压。
- 需要尽可能提供给额外的资源,以便在任务出现异常中断的情况下处理积压的数据。这些资源的预估都取决于任务停止过程中数据的积压量,以及对任务恢复时间的要求。
- 系统中出现临时性的反压没有太大的问题,但是如果系统中频繁出现临时性的反压,例如下游外部系统临时性变慢导致数据输出速率下降,这种情况就需要考虑给予算子一定的资源。
- 部分算子导致下游的算子的负载非常高,下游的算子完全是取决于上游算子的输出,因此对类似于窗口算子的估计也将会影响到整个任务的执行,应该尽可能给这些算子留有足够的资源以应对上游算子产生的影响。
三、异步Snapshot
默认情况下,应用中的checkpoint操作都是同步执行的,在条件允许的情况下应该尽可能地使用异步的snapshot,这样讲大幅度提升checkpoint的性能,尤其是在非常复杂的流式应用中,如多数据源关联、co-functions操作或windows操作等,都会有较好的性能改善。
Flink提供了异步快照(Asynchronous Snapshot)的机制。当实际执行快照时,Flink可以立即向下广播Checkpoint Barrier,表示自己已经执行完自己部分的快照。同时,Flink启动一个后台线程,它创建本地状态的一份拷贝,这个线程用来将本地状态的拷贝同步到State Backend上,一旦数据同步完成,再给Checkpoint Coordinator发送确认信息。拷贝一份数据肯定占用更多内存,这时可以利用写入时复制(Copy-on-Write)的优化策略。Copy-on-Write指:如果这份内存数据没有任何修改,那没必要生成一份拷贝,只需要有一个指向这份数据的指针,通过指针将本地数据同步到State Backend上;如果这份内存数据有一些更新,那再去申请额外的内存空间并维护两份数据,一份是快照时的数据,一份是更新后的数据。
在使用异步快照需要确认应用遵循以下两点要求:
- 首先必须是Flink托管状态,即使用Flink内部提供的托管状态所对应的数据结构,例如常用的有ValueState、ListState、ReducingState等类型状态。
- StateBackend必须支持异步快照,在Flink1.2的版本之前,只有RocksDB完整地支持异步的Snapshot操作,从Flink1.3版本以后可以在heap-based StateBackend中支持异步快照功能。
四、压缩状态数据
Flink中提供了针对checkpoint和savepoint的数据进行压缩的方法,目前Flink仅支持通过用snappy压缩算法对状态数据进行压缩,在未来的版本中Flink将支持其他压缩算法。在压缩过程中,Flink的压缩算法支持key-group层面压缩,也就是不同的key-group分别被压缩成不同的部分,因此解压缩过程可以并发执行,这对大规模数据的压缩和解压缩带来非常高的性能提升和较强的可扩展性。Flink中使用的压缩算法在ExecutionConfig中进行指定,通过将setUseSnapshotCompression方法中的值设定为true即可。
五、观察checkpoint延迟时间
checkpoint延迟启动时间并不会直接暴露在客户端中,而是需要通过以下公式计算得出。如果改时间过长,则表明算子在进行barrier对齐,等待上游的算子将数据写入到当前算子中,说明系统正处于一个反压状态下。checkpoint延迟时间可以通过整个端到端的计算时间减去异步持续的时间和同步持续的时间得出。
针对 两阶段提交协议(2PC)
针对两阶段提交协议(2PC)的优化方案仍在研究,提供一些思路作为参考:
- 底存存储需要暴露sharding细节,提供以分区为单位的事务上下文管理机制,使得在预处理过程中,行锁和数据修改为内存操作,避免持久化的代价。
- 简化协调者为无状态逻辑
- 减少2PC执行关键路径上的持久化和RPC次数
采用三阶段提交协议可以部分解决两阶段提交协议的问题:
执行步骤:
CanCommit阶段
- 事务询问,协调者向参与者发送precommit请求。询问是否可以执行事务。
- 响应反馈参与者接到Cancommit请求。
PreCommit阶段
- 协调者根据参与者的返回来决定是否继续事务的precommit操作。但是会出现两种情况
- 协调者从所有参与者获得反馈都是yes,那么事务会继续执行
- 如果有任何一个参与者向协调者发送了No响应,或者等待超时,协调者没有接收到参与者的响应那么事务中断。
DoCommit阶段
执行提交
- 发送提交请求协调接收到参与者发送的ACK响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送doCommit请求。
- 事务提交 参与者接收到doCommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
- 响应反馈 事务提交完之后,向协调者发送Ack响应。
- 完成事务 协调者接收到所有参与者的ack响应之后,完成事务。
事务中断,协调者没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。
- 发送中断请求 协调者向所有参与者发送abort请求
- 事务回滚 参与者接收到abort请求之后,利用其在阶段二记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
- 反馈结果 参与者完成事务回滚之后,向协调者发送ACK消息
- 中断事务 协调者接收到参与者反馈的ACK消息之后,执行事务的中断。
三阶段与二阶段的不同点在于。
- 对于协调者和参与者都设置了超时机制(2阶段中只有协调者有)。协调者如果在一定时间内没有收到参与者的消息则默认失败,参与者无法及时收到协调者的信息,他会默认执行commit,不会一直持有事务。
- 解决了单点故障,并减少了阻塞。但是也会带来数据不一致的问题。如果网络出错,协调者发送的请求没有及时被参与者收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。