一致性的划分
通常来说,状态一致性分为三个级别
- at-most-once:至多一次,发生故障恢复后数据可能丢失
- at-least-once:至少一次,发生故障恢复后数据可能多算,绝对不会少算
- exactly-once:精确一次,发生故障恢复后数据不会丢失也不会多算
端到端的状态一致性
Flink 中使用的是一种轻量级快照机制——检查点(checkpoint)来保证 exactly-once 语义。但是我们的应用还包含了数据源和输出,每个组件都只是保证了自己的一致性,所以端到端级别的一致性取决于所有组件中一致性最弱的组件。
要满足端到端的状态一致性需要满足以下几点:
- source:需要外部数据源可以重新设置数据的读取位置
- 内部:通过checkpoint保证内部的一致性
- sink:从故障恢复时,数据不会重复写入到外部系统
source端保证
source端主要指的就是 Flink 读取的外部数据源。对于一些数据源来说,并不提供数据的缓
冲或是持久化保存,数据被消费之后就彻底不存在了。想要在故障恢复后不丢数据,外部数据源就必须拥有重放数据的能力。常见的做法就是对数据进行持久化保存,并且可以重设数据的读取位置。一个最经典的应用就是 Kafka。在 Flink的 Source 任务中将数据读取的偏移量保存为状态,这样就可以在故障恢复时从检查点中读取出来,对数据源重置偏移量,重新获取数据。这样就可以保证数据不丢。这是达到 exactly-once 的基本要求。
sink端保证
想要实现 exactly-once 却存在很大的困难:数据有可能重复写入外部系统。 因为检查点保存之后,继续到来的数据也会一一处理,任务的状态也会更新,最终通过 Sink 任务将计算结果输出到外部系统;只是状态改变还没有存到下一个检查点中。这时如果 出现故障,这些数据都会重新来一遍,就计算了两次。我们知道对 Flink 内部状态来说,重复计算的动作是没有影响的,因为状态已经回滚,最终改变只会发生一次;但对于外部系统来说,已经写入的结果就是泼出去的水,已经无法收回了,再次执行写入就会把同一个数据写入两次。 所以这时,我们只保证了端到端的 at-least-once 语义。 为了实现端到端 exactly-once,我们还需要对外部存储系统、以及 Sink 连接器有额外的要 求。能够保证 exactly-once 一致性的写入方式有两种:
幂等写入和事务写入。
幂等(idempotent)写入
所谓“幂等”操作,就是说一个操作可以重复执行很多次,但只导致一次结果更改。也就是说,后面再重复执行就不会对结果起作用了。
这相当于说,我们并没有真正解决数据重复计算、写入的问题;而是说,重复写入也没关 系,结果不会改变。所以这种方式主要的限制在于外部存储系统必须支持这样的幂等写入:比 如 Redis 中键值存储,或者关系型数据库(如 MySQL)中满足查询条件的更新操作。
对于幂等写入,遇到故障进行恢复时,有可能会出现短暂的不一致。因为保存 点完成之后到发生故障之间的数据,其实已经写入了一遍,回滚的时候并不能消除它们。
事务(transactional)写入
事务(transaction)是应用程序中一系列严密的操作,所有操作必须成功完成,否则在每个操作中所做的所有更改都会被撤消。事务写入的基本思想就是:用一个事务来进行数据向外部系统的写入,这个事务是与检查点绑定在一起的。当 Sink 任务 遇到 barrier 时,开始保存状态的同时就开启一个事务,接下来所有数据的写入都在这个事务中;待到当前检查点保存完毕时,将事务交,所有写入的数据就真正可用了。如果中间过程 出现故障,状态会回退到上一个检查点,而当前事务没有正常关闭(因为当前检查点没有保存 完),所以也会回滚,写入到外部的数据就被撤了。
具体来说,又有两种实现方式:预写日志(WAL)和两阶段提交(2PC)
(1)预写日志(write-ahead-log,WAL)
预写日志(WAL)就是一种非常简单的方式。具体步骤是:
①先把结果数据作为日志(log)状态保存起来
②进行检查点保存时,也会将这些结果数据一并做持久化存储
③在收到检查点完成的通知时,将所有结果一次性写入外部系统。
我们会发现,这种方式类似于检查点完成时做一个批处理,一次性的写入会带来一些性能上的问题;而优点就是比较简单,由于数据提前在状态后端中做了缓存,所以无论什么外部存 储系统,理论上都能用这种方式一批搞定。在 Flink 中 DataStream API 提供了GenericWriteAheadSink类,用来实现这种事务型的写入方式。
(2)两阶段提交(two-phase-commit,2PC)
顾名思义,它的想法是分成两个阶段:先做“预提交”,等检查点完成之后再正式提交。
这种提交方式是真正基于事务的,它需要外部系统提供事务支持。
具体的实现步骤为:
①当第一条数据到来时,或者收到检查点的分界线时,Sink 任务都会启动一个事务。
②接下来接收到的所有数据,都通过这个事务写入外部系统;这时由于事务没有提交,所
以数据尽管写入了外部系统,但是不可用,是“预提交”的状态。
③当 Sink 任务收到 JobManager 发来检查点完成的通知时,正式提交事务,写入的结果就
真正可用了。
当中间发生故障时,当前未提交的事务就会回滚,于是所有写入外部系统的数据也就实现了撤回。这种两阶段提交(2PC
)的方式充分利用了
Flink
现有的检查点机制:分界线的到来,就标志着开始一个新事务;而收到来自 JobManager
的
checkpoint
成功的消息,就是提交事务的指令。每个结果数据的写入,依然是流式的,不再有预写日志时批处理的性能问题;最终提交时,也只需要额外发送一个确认信息。所以 2PC
协议不仅真正意义上实现了
exactly-once
,而且通过搭载 Flink
的检查点机制来实现事务,只给系统增加了很少的开销。 Flink 提供
TwoPhaseCommitSinkFunction
接口,方便我们自定义实现两阶段提交的 SinkFunction 的实现,提供了真正端到端的
exactly-once
保证。
Flink 和 Kafka 连接时的精确一次保证
整体介绍
(1)Flink 内部
Flink
内部可以通过检查点机制保证状态和处理结果的
exactly-once
语义。
(2)输入端
输入数据源端的
Kafka
可以对数据进行持久化保存,并可以重置偏移量(
offset
)。所以我们可以在 Source
任务(
FlinkKafkaConsumer
)中将当前读取的偏移量保存为算子状态,写入到检查点中;当发生故障时,从检查点中读取恢复状态,并由连接器 FlinkKafkaConsumer
向
Kafka重新提交偏移量,就可以重新消费数据、保证结果的一致性了。
(3)输出端
输出端保证
exactly-once
的最佳实现,当然就是两阶段提交(
2PC
)。作为与
Flink
天生一对的 Kafka
,自然需要用最强有力的一致性保证来证明自己。
Flink
官方实现的
Kafka
连接器中,提供了写入到
Kafka
的
FlinkKafkaProducer
,它就实现
了
TwoPhaseCommitSinkFunction
接口:
实现端到端 exactly-once 的具体过程
- 启动检查点保存: Source 任务会将检查点分界线(barrier)注入数据流。这个 barrier 可以将数据流中的数据,分为进入当前检查点的集合和进入下一个检查点的集合。\
- 算子任务对状态做快照 :分界线(barrier)会在算子间传递下去。每个算子收到 barrier 时,会将当前的状态做个快照,保存到状态后端。
- Sink 任务开启事务,进行预提交:分界线(barrier)终于传到了 Sink 任务,这时 Sink 任务会开启一个事务。接下来到来的所有数据,Sink 任务都会通过这个事务来写入 Kafka。对于 Kafka 而言,提交的数据会被标记为“未确认”(uncommitted)。这个过程就是所谓的“预提交”(pre-commit)
- 检查点保存完成,提交事务:当 Sink 任务收到确认通知后,就会正式提交之前的事务,把之前“未确认”的数据标为“已确认”,接下来就可以正常消费了。
需要的配置
(1)必须启用检查点;
(2)在 FlinkKafkaProducer 的构造函数中传入参数 Semantic.EXACTLY_ONCE;
(3)配置 Kafka 读取数据的消费者的隔离级别;
预提交阶段数据已经写入,只是被标记为“未提交”(uncommitted),而 Kafka 中默认的隔离级别 isolation.level 是 read_uncommitted,也就是可以读取未提交的数据。这样一来,外部应用就可以直接消费未提交的数据,对于事务性的保证就失效了。所以应该将隔离级别配置为 read_committed,表示消费者遇到未提交的消息时,会停止从分区中消费数据,直到消息被标记为已提交才会再次恢复消费。当然,这样做的话,外部应用消费数据就会有显著的延
迟。
(4)事务超时配置
Flink 的 Kafka连接器中配置的事务超时时间 transaction.timeout.ms 默认是 1小时,而Kafka集群配置的事务最大超时时间 transaction.max.timeout.ms 默认是 15 分钟。所以在检查点保存时间很长时,有可能出现 Kafka 已经认为事务超时了,丢弃了预提交的数据;而 Sink 任务认为还可以继续等待。如果接下来检查点保存成功,发生故障后回滚到这个检查点的状态,这部分数据就被真正丢掉了。所以这两个超时时间,前者应该小于等于后者。