一致性其实就是结果的正确性。对于分布式系统而言,从不同节点读取时总能得到相同的值;而对于事务而言,是要求提交更新操作后,能够读取到新的数据。
对于Flink内部来说,检查点机制可以保证故障恢复之后数据不丢(在能够重复放的情况下),并且只处理一次,所以已经可以做到exactly-once的一致性语义了。故端到端一致性的关键点,就在于输入的数据源端和输出的外部存储端。
输入端主要指的就是Flink读取的外部数据源。想要在故障恢复后不丢数据,外部数据源就必须拥有重放数据的能力。常见做法就是对数据进行持久化保存,并且可以重设数据的读取位置。一个最经典的应用就是Kafka。在Flink的Source任务中将数据读取的偏移量保存为状态,这样就可以在故障恢复时从检查点中读取出来,对数据源重置偏移量,重新获取数据。
输出端主要指的就是Flink处理完数据,通过sink任务输出到外部系统。对于输出端,需要保证在故障恢复时,数据不会重复写入外部系统。对于输出端,有两种 具体的实现方式:幂(Idempotent)写入和事务性(Transactional)写入。
所谓“幂等”操作,就是说一个操作可以重复执行很多次,但只导致一次结果更改。这种方式主要限制在于外部存储系统必须支持这样的幂等写入。
对于幂等写入,在遇到故障恢复时,可能会出现短暂的不一致。因为保存点完成之后到发生故障之间的数据,其实已经写入了一遍,回滚的时候并不能消除它们。不过当数据的重放逐渐超过发生故障的点的时候,最终的结果还是一致的。
输出端最大的问题,就是写入到外部系统的数据难以撤回。而利用事务就可以实现对已写入数据的撤回。在Flink流处理的结果写入外部系统时,如果能够构建一个事务,让写入操作可以随着检查点来提交和回滚,这样就解决了重复写入的问题。 事务与检查点是绑定在一起的,当Sink任务遇到barrier时,开始保存状态的同时就开启一个事务,后续所有数据的写入都在该事务中;待到当前检查点保存完毕时,将事务提交,所有写入的数据就真正可用了。如果中间过程出现故障,状态会回退到上一个检查点,而当前事务没有正常关闭(因为当前检查点没有保存完),所以也会回滚,写入到外部的数据就被撤销了。对于事务写入有两种实现方式:预写日志(WAL)和两阶段提交(2PC)。
预写日志(write-ahead-log,WAL)
实现步骤:
预写日志这种批写入的方式,有可能会写入失败;所以在写入动作之后,必须等待发送成功的返回确认消息。在成功写入所有数据后,内部再次确认相应的检查点,此时代表着检查点的真正完成。同时也需要确认信息也进行持久化保存,在故障恢复时,只有存在对应的确认信息,才能保证这批数据已经写入,可以恢复到对应的检查点位置。
两阶段提交(two-phase-commit,2PC)
两阶段提交,顾名思义,先做“预提交”,等检查点完成之后再正式提交。这种提交方式是真正基于事务的,它需要外部系统提供事务支持。
实现步骤:
当中间发生故障时,当前未提交的事务就会回滚,于是所有写入外部系统的数据也就实现了撤回。两阶段提交的方式充分利用了Flink现有的检查点机制:分界线的到来,就标志着开始一个新事务;而收到来自JobManager的checkpoint成功的消息,就是提交事务的指令。
两阶段提交对外部系统有要求:
对于端到端的精准一次而言:
系统 | 要求 |
---|---|
source端 | 可重设数据的读取位置 |
Flink内部 | 依赖checkpoint |
sink端 | 从故障恢复时,数据不会重复写入外部系统 |
Flink与Kafka连接时属于端到端精准一次,可以从三个组件的角度进行分析:
情景: 由Flink从Kafka读取数据、并将处理结果写入Kafka,如图所示
Flink与Kafka连接的两阶段提交,离不开检查点的配合,这个过程需要JobManager协调各个TaskManager进行状态快照,而检查点具体存储位置则是由状态后端(State Backend)来配置管理的。一般情况,我们会将检查点存储到分布式文件系统上。
(1) 启动检查点保存
检查点保存的启动,标志着进入了两阶段提交协议的“预提交”阶段。当然,现在还没有具体提交的数据。
JobManager通知各个TaskManager启动检查点保存,Source任务会将检查点分界线(barrier)注入数据流。这个barrier可以将数据流中的数据,分为进入当前检查点的集合和进入下一个检查点的集合。
(2) 算子任务对状态做快照
分界线(barrier)会在算子间传递下去。每个算子收到barrier时,会将当前的状态做个快照,保存到状态后端。
Source任务将barrier插入数据流后,也会将当前读取数据的偏移量作为状态写入检查点,存入状态后端;然后把barrier向下游传递,自己就可以继续读取数据了。接下来barrier传递到了内部的Window算子,它同样会对自己的状态进行快照保存,写入远程的持久化存储。
(3) Sink任务开启事务,进行预提交
分界线(barrier)终于传到了Sink任务,这时Sink任务会开启一个事务。后续到来的所有数据,Sink任务都会通过该事务来写入Kafka。这里barrier是检查点的分界线,也是事务的分界线。由于之前的检查点可能尚未完成,因此上一个事务也可能尚未提交;此时barrier的到来开启了新的事务,上一个事务尽管可能没有被提交,但也不再接收新的数据了。
对于Kafka而言,提交的数据会被标记为“未确认”(uncommitted)。这个过程就是所谓的“预提交”(pre-commit)。
(4) 检查点保存完成,提交事务
当所有算子的快照都完成,也就是这次的检查点保存最终完成时,JobManager会向所有任务发确认通知,告诉TaskManager当前检查点已成功保存。
当Sink任务收到确认通知后,就会正式提交之前的事务,把之前“未确认”的数据标为“已确认”,接下来就可以正常消费了。