关于这个问题其实从一开始很多人是存在质疑的,首先exactly-once语义指的是即使在出现故障的情况下,Flink流应用程序中的所有算子都保证事件只会被"精确一次"(恰好一次,不多不少)的处理.假设有下面一个场景,Flink在完成了一次checkpoint后,第二次checkpoint前(此时两个checkpoint中间的数据已经处理了一部分了)任务挂掉了,然后任务恢复的时候会从上一次成功的checkpoint处恢复(也即是checkpoint ID为1的位置)任务,那这个时候刚才被处理的数据又会被处理一次,这部分数据被处理了两次甚至可能是多次,那这就不能称为exactly-once语义了啊,这就是很多人存在疑惑的地方,这里先说一下结论:Flink是支持exactly-once语义的,但其针对的是Flink应用内部的数据流处理(也就是Flink的状态state来说的),换句话说,事件的处理可以发生多次,但是该处理的结果只在持久化后端状态存储中反映一次,Flink自身是无法保证端到端的exactly-once语义的.

Flink的失败恢复依赖于 检查点机制 + 可部分重放的数据源。

1,可重放的数据源这个很好理解,比如上游数据源是kafka,kafka是支持从指定的offest处重新开始消费数据的.

2,检查点机制是Flink的checkpoint定期触发,产生快照,快照里面记录了,

(1),当前检查点开始时数据源(例如Kafka)中消息的offset。

       (2),记录了所有有状态的operator当前的状态信息(例如sum中的数值)。

Flink是怎么保证容错恢复的时候保证数据没有丢失也没有数据的冗余呢?checkpoint是使Flink 能从故障恢复的一种内部机制。检查点是 Flink 应用状态的一个一致性副本,包括了输入的读取offest。在发生故障时,Flink 通过从检查点加载应用程序状态来恢复,并从恢复的offest位置继续处理,就好像什么事情都没发生一样。Flink的状态存储在Flink的内部,这样做的好处就是不再依赖外部系统,降低了对外部系统的依赖,在Flink的内部,通过自身的进程去访问状态变量.同时会定期的做checkpoint持久化,把checkpoint存储在一个分布式的持久化系统中,如果发生故障,就会从最近的一次checkpoint中将整个流的状态进行恢复.

Flink提供了Exactly once特性,是依赖于带有barrier的分布式快照+可部分重发的数据源功能实现的。而分布式快照中,就保存了operator的状态信息。

快照的核心概念之一是barrier。这些barrier被注入数据流并与记录一起作为数据流的一部分向下流动。barriers永远不会超过记录,数据流严格有序,barrier将数据流中的记录隔离成一系列的记录集合,并将一些集合中的数据加入到当前的快照中,而另一些数据加入到下一个快照中。

  每个barrier都带有快照的ID,并且barrier之前的记录都进入了该快照。barriers不会中断流处理,非常轻量级。来自不同快照的多个barrier可以同时在流中出现,这意味着多个快照可能并发地发生。

单流的barrier:

Flink到底能不能实现exactly-once语义_数据

barrier在数据流源处被注入并行数据流中。快照n的barriers被插入的位置(记之为Sn)是快照所包含的数据在数据源中最大位置。例如,在Apache Kafka中,此位置将是分区中最后一条记录的偏移量。将该位置Sn报告给checkpoint协调器(Flink的JobManager)。

  然后barriers向下游流动。当一个中间操作算子从其所有输入流中收到快照n的barriers时,它会为快照n发出barriers进入其所有输出流中。一旦sink操作算子(流式DAG的末端)从其所有输入流接收到barriers n,它就向checkpoint协调器确认快照n完成。在所有sink确认快照后,意味着快照已完成。

  一旦完成快照n,job将永远不再向数据源请求Sn之前的记录,因为此时这些记录(及其后续记录)将已经通过整个数据流拓扑,也即是已经被处理结束。

简单来说barrier就是标识数据是属于哪个checkpoint的,把不同时间的数据划分到了不同的checkpoint里,比如上图的checkpoint barrier n-1 到checkpoint barrier n中间的数据是属于checkpoint n的.

多流的barrier:

Flink到底能不能实现exactly-once语义_输入流_02

接收多个输入流的运算符需要基于快照barriers上对齐(align)输入流。上图说明了这一点:

1,一旦操作算子从一个输入流接收到快照barriers n,它就不能处理来自该流的任何记录,直到它从其他输入接收到barriers n为止。否则,它会搞混属于快照n的记录和属于快照n + 1的记录。

2,barriers n所属的流暂时会被搁置。从这些流接收的记录不会被处理,而是放入输入缓冲区。可以看到1,2,3会一直放在Input buffer,直到另一个输入流的快照到达Operator。

3,一旦从最后一个流接收到barriers n,操作算子就会发出所有挂起的向后传送的记录,然后自己发出快照n的barriers。

4,之后,它恢复处理来自所有输入流的记录,在处理来自流的记录之前优先处理来自输入缓冲区的记录。

这个是在任务有多个并行度并且设置的是exactly-once语义的时候才有的barrier对齐过程,其实barrier对齐也非常好理解,如果只有一个并行度,这个时候不存在对齐,因为只有自己,不需要和谁对齐,如果有多个并行度,假如不对齐,可能会出现某一个并行度已经处理到下一个checkpoint的数据了,这个时候state里面的结果也发生了改变,任务重启的时候,从上一次成功的checkpoint里记录的offest恢复,这个时候结果就会被多计算,就达不到exactly-once了,所以多个并行度的时候必须有对齐的过程,确保多个并行度处理的是同一个checkpoint的数据.这样才能保证数据的一致性.

理解了Flink实现exactly-once的原理,我们在来还原一下开头说的那个场景.

Flink到底能不能实现exactly-once语义_输入流_03

这就是Flink任务失败恢复的时候状态恢复的过程,为了简化刚才的问题,我们假设kafka的partition只有一个,并且数据中的key也只有一个.

任务在第一次checkpoint的时候,快照里面的状态是:

1, <1,<0,10>> checkpoint的ID是1,kafka的paritition 0 消费的offest是10.

2,operator的状态是<hello,10>(实际这里的结构是(keygroup+key+namespace,value)).

假设checkpoint1到checkpoint2中间有10条数据,任务消费到offest为12的时候,任务挂掉了,此时状态state里面的结果是<hello,12>.任务会从上一次成功的checkpoint1 开始恢复, kafka的offest还是从10开始,算子的状态是<hello,10>,这个时候数据虽然重复消费了一遍,但是结果是对的,因为operator的状态也恢复到之前的状态了.所以对于状态来说确实是exactly-once语义.

看到这里你就会明白Flink是怎么实现状态的exactly-once的,那Flink如何实现端到端的exactly-once呢?这个就需要sink端支持事务或者幂等,然后结合Flink的二段提交去实现.这里就不做讨论了,后面的文章会再详细分析.

总结:

在这篇文章中,主要消除大家对Flink支持exactly-once的误解,Flink的exactly-once不是指数据只消费一次,而是数据虽然可能处理多次,但是结果(算子的状态)精准一次.Flink端到端的exactly-once需要sink支持事务或者幂等,用二段提交的方式完成.

推荐阅读:

Flink1.10.0 SQL DDL 把计算结果写入kafka

JasonLee,公众号:JasonLee的博客Flink1.10.0 SQL DDL 把计算结果写入kafka

Flink 1.10.0 SQL DDL中如何定义watermark和计算列

JasonLee,公众号:JasonLee的博客Flink 1.10.0 SQL DDL中如何定义watermark和计算列

FlinkSQL使用DDL语句创建kafka源表

JasonLee,公众号:JasonLee的博客FlinkSQL使用DDL语句创建kafka源表

Flink 状态清除的演进之路

JasonLee,公众号:JasonLee的博客Flink 状态清除的演进之路

Flink动态写入kafka的多个topic

JasonLee,公众号:JasonLee的博客Flink动态写入kafka的多个topic

更多spark和flink的内容可以关注下面的公众号

Flink到底能不能实现exactly-once语义_数据_04