1.前言
哈喽,大家好。在第二章节中我聊了聊状态后端和检查点相关的内容,如果你仔细看完就能够很清楚的知道,Flink是如何保存自己引以为傲的状态的了。并且我也在上一章节中说了,Flink能够依赖检查点机制来实现自身的精确一致性,所以这篇文章咱们就简单聊聊它所谓的精准一次性是如何实现的。(检查点是根据在数据中插入“分界线”标志来触发的)
2.精准一次性
从本文的标题能够看出,今天要聊的是端到端的状态一致性,那么首先要先知道这个端到端是什么意思。我们试想一下,Flink处理数据,肯定是要有数据来源和数据发送点的,也就是source和sink。那么,如果要聊端到端的精准一次性,就要对这个两个“端”字进行拆解,分为输入端与Flink之间的精准一次性,和Flink与输出端之间的精准一次性。
2.1.1 输入端的精准一次性
输入端的精准一次性比较好理解,你只需要明确一点,只要向Flink发送数据的source端具备数据重放的功能就好。还记得上一小节提到的检查点机制吗?它是对程序运行时的某个时间点所有状态的一次快照,在做快照的时候,source算子会读取数据源的偏移量信息,一起保存在检查点中,这就能够在故障恢复的时候让source算子根据自己保存的这个偏移量信息,去数据源中重新读取数据。所以数据源头这方面,精准一次性比较好搞,因为Flink内部干了很多事情。当然了,这个source端一定要具备数据保存和数据回放的能力,如果不存数的话,就算真记录了偏移量也是白搭。
2.1.2 输出端的精准一次性
输入端说完了,接下来就说说输出端。这一块其实就不太好弄了,因为Flink将数据发送到sink方。它的角色就仿若souce到flink一样。(ps:souce->flink === flink -->sink,都是一个东西向另外一个东西发送数据)。但是sink端的外部存储系统没办法像Flink一样,通过保存状态的方式来完成精准一次性。但是在开发过程中,精准一次性很多场景都非常重要,所以为了能够实现Flink到sink端的精准一次性,还是需要对sink端有一些要求的。
主要的要求内容一共有两个,分别是“事务”和“幂等性”:
我们首先来聊一聊幂等,幂等这个东西很神奇。它就像一个不允许重复的集合一样,在幂等的环境下,一个操作可以重复多次,但是次操作所导致的结果就只有一次。有了幂等性,数据无论写多少次,都不会影响到sink端的结果,但是如果想要进行幂等性写出,那就要求sink端需要支持幂等,还是有一些场景上的限制的。
幂等性聊完了就聊聊事务,事务相对于幂等性的要求它的限制要少一些。其实在聊精准一次性之前,大家要明确一个点,那就是sink端面临的问题不是数据会不会丢失,而是数据会不会被重复计算。这是因为即使是数据因为故障丢失,source端也会通过检查点来对数据进行重新读写,所以数据丢失的几率被降得很低。所以重复计算才是sink端的难题。
我们都知道,事务的特性是:如果我本次的活干到一半突然出现故障,那么这次干的内容我会全部收回。当然这是粗俗的说法,如果严格一点,那就是:事务具有四个基本特性:ACID,即原子、一致、隔离、持久这四大特性。通过使用这四大特性,就能够保证如果本次的操作没有完成,那这次操作过程中完成的部分也会被撤销。
那就用这个理念,来套入Flink与sink之间的处理。如果我们写一个事务,用这个事务和检查点绑定在一起,通过这个事务向外部写出数据,当Sink算子触发检查点保存的时候,开启保存状态的同时就开启一个事务,接下来的数据都写在这个事务里面。只有检查点完成了保存,那么事务就可以提交,数据就算是成功写出,可以使用了。如果程序出现故障了,状态保存失败,就会根据检查点中的内容对数据重新读写,事务也肯定是失败了,就会回退。之前的操作也会作废,也就证明了数据没有多余写出了。
3.事务的两种实现方式(预写日志、两阶段提交)
为了能够满足Flink与sink端之间的状态精准一次性,也明确了在两种实现理论中事务比较牛,那就再来聊一聊事务实现的两种方式:预写日志、两阶段提交。当然了,这个TMD数据写出端也需要支持事务才行。就像我之前使用到的Clickhouse,它既不支持事务也不支持幂等,只能通过本身的副本合并树来实现虚假的精准一次性(我现阶段就这个水平,可能有的人会手写更牛叉的自定义Sink也说不定)。
3.1 预写日志
预写日志首先需要把数据作为日志状态保存起来,既然它是日志状态了,那就证明程序在触发检查点的时候就能够将它一起以快照的方式存储到外部系统中做持久化存储。当检查点成功保存之后,再把结果一次性写出就好。这么搞是不是就算是完成精准一次性了呢?答案是,是的!但是预写日志有个问题,那就是它是把一个小阶段之间的数据全量写出,虽然在宏观角度去看这一个阶段很小,但是Flink处理读取数据的能力贼强,就这一小阶段就会产生大量数据,一次性写出就会对集群造成一定的压力。就相当于把流处理变成了批处理(spark震怒!!!)
这种方法看起来有点美好,但是实际上它有问题。它把流处理变成了批处理,那必然要知道这次批操作成没成功,所以在数据写出完成之后,会还给sink一个成功的信号,只有明确这个信息是真的完成了,才能证明Checkpoint是成功的,同时也要把这个返回的成功信息做一个保存,用来证明这次批处理的成功。这种情况会面临的问题就是,当数据成功写出了,返回成功信号的时候出事儿了,那故障恢复后Flink确认不了这次成没成功,就会重新发送数据,数据就又会重复发送了。
这个功能DateSteam API提供了一个模板类来帮忙完成。
GenericWriteAheadSink
3.2两阶段提交
预写日志说完了,就来谈谈两阶段提交吧。它比预写日志还要牛一点,但是限制也是一大堆,比较难搞。在正式说两阶段提交之前,要明确两个概念。第一个是两阶段是由预提交和正式提交两部分组成,预提交阶段的数据内容是不能够使用的。
当第一条数据来时,或者是sink接收到检查点的“分界线”时,就会开启一个事务,这个事务会代替sink来进行数据的写出操作,但是在这个阶段,所有写出的操作都是不可用的,也就是预提交阶段。而sink这个时候会进入一个等待状态,它需要接收JobManager发送给它的一个检查点成功保存的信号,一旦它接收到这个信息,那么sink端的这个事务就会提交,预提交阶段所有不可用的数据便会变成可使用的了。
Flink同样提供了一个模板sink:
TwoPhaseCommitSinkFunction
4.结语
在这篇文章里面,我聊了聊我理解的有关于状态一致性的内容,总的来说得到了一个结论,那就是精准一次性代价颇高,逼事贼多。而我目前用到的clickhouse既不支持事务、也不支持幂等。所以我还没有解除过重写上述两个接口的过程。但是,在进行实时数据处理的过程中,大多都是在Kafka与Flink之间进行数据的来回传递,分层操作也是如此。所以,我们在下一篇文章中就聊聊,有关于 Kafka -> Flink -> Kafka 之间的状态一致性吧。