- 在分布式架构中,当某个节点出现故障, 其他节点基本不受影响。这时只需要重启应用, 恢复之前某个时间点的状态继续处理就可以了,在实时流处理中,不仅需要保证故障后能够重启继续运行, 还要保证结果的正确性、故障恢复的速度、对处理性能的影响, 这就需要在架构上做出更加精巧的设计。在 Flink 中,有一套完整的容错机制(fault tolerance)来保证故障后的恢复, 其中最重要的就是检查点(checkpoint)。
- 检查点: 发生故障之后怎么办?最简单的想法当然是重启机器、重启应用。这里的问题在于, 流处理应用中的任务都是有状态的, 而为了快速访问这些状态一般会直接放在堆内存里;重启应用内存中的状态已经丢失。为了不浪费之前的计算 所以需要把之前的计算结果做个保存,这样重启之后就可以继续处理新数据、而不需要重新计算了。就是将之前某个时间点所有的状态保存下来, 这份“存档”就是所谓的“检查点” (checkpoint)。 遇到故障重启的时候,我们可以从检查点中恢复出之前的状态,这样就可以回到保存时的数据接着处理了。
检查点是 Flink 容错机制的核心。故障恢复之后继续处理的结果, 应该与发生故障前完全一致所以,有时又会把 checkpoint 叫作“一致性检查点”。
1)检查点的保存:
周期性的触发保存:
“随时存档”确实恢复起来方便, 可是需要不停地做存档操作。如果每处理一条数据就进行检查点的保存,当大量数据同时到来时,就会耗费很多资源来频繁做检查点,数据处理的速度就会受到影响。所以更好的方式是,每隔一段时间去做一次存档,这样既不会影响数据的正常处理,也不会有太大的延迟毕竟故障恢复的情况不是随时发生的。在 Flink 中,检 查点的保存是周期性触发的,间隔时间可以进行设置。
这里有一个关键问题:当检查点的保存被触发时, 任务有可能正在处理某个数据,这时该怎么办呢?最简单的办法是,可以在某个时刻让所有任务停止处理数据。这样状态就不再更改,可以一起复制保存;保存完之后再恢复数据处理就可以了。然而会发现有很多问题。这种想法其实是粗暴地中断任务来做检查点保存,这会造成很大的延迟。另一方面, 做快照的目的是为了故障恢复;现在的快照中,有些任务正在处理数据,那它保存的到底是处理到什么程度的状态呢? 假如在程序中某一步操作中自定义了一个ValueState,处理的逻辑是当遇到一个数据时,状态先加 1;而后经过一些其他步骤后再加 1。现在停止处理数据, 状态到底是被加了 1 还是加了 2 呢?
而且在分布式系统的节点之间需要通过网络通信来传递数据,如果我们保 存检查点的时候刚好有数据在网络传输的路上,那么下游任务是没法将数据保存起来的;故障重启之后,我们只能期待上游任务重新发送这个数据。然而上游任务是无法知道下游任务是否 收到数据的,只能盲目地重发, 这可能导致下游将数据处理两次, 结果就会出现错误。
所以最终的选择是: 当所有任务都恰好处理完一个相同的输入数据的时候,将它们的状态保存下来。首先,这样避免了除状态之外其他额外信息的存储,提高了检查点保存的效率。 其次, 一个数据要么就是被所有任务完整地处理完,状态得到了保存; 要么就是没处理完, 状态全部没保存:这就相当于构建了一个“事务”(transaction)。如果出现故障,我们恢复到之前保存的状态, 故障时正在处理的所有数据都需要重新处理; 所以我们只需要让源(source) 任务向数据源重新提交偏移量、请求重放数据就可以了。
当需要保存检查点(checkpoint)时,就是在所有任务处理完同一条数据后,对状态 做个快照保存下来。例如上图中,已经处理了 3 条数据:“hello”“world”“hello”,所以我们会看到Source 算子的偏移量为 3;后面的 Sum 算子处理完第三条数据“hello”之后,此时已经有 2 个“hello”和 1 个“world”,所以对应的状态为“hello”-> 2,“world”-> 1(这里 KeyedState底层会以 key-value 形式存储)。此时所有任务都已经处理完了前三个数据,所以可以把当前的状态保存成一个检查点,写入外部存储中。
检查点恢复:
在运行流处理程序时,Flink 会周期性地保存检查点。当发生故障时,就需要找到最近一 次保存的检查点来恢复状态,如图所示。
这里 Source 任务已经处理完毕,所以偏移量为 5;Map 任务也处理完成了。而 Sum 任务在处理中发生了故障,此时状态并未保存。 接下来就需要从检查点来恢复状态了。具体的步骤为:
(1)重启应用
遇到故障之后,第一步当然就是重启。我们将应用重新启动后,所有任务的状态会清空。(2)读取检查点,重置状态
找到最近一次保存的检查点,从中读出每个算子任务状态的快照,分别填充到对应的状态 中。这样,Flink 内部所有任务的状态,就恢复到了保存检查点的那一时刻,也就是刚好处理 完第三个数据的时候,如图所示。
(3)重放数据 为了不丢数据,应该从保存检查点后开始重新读取数据,这可以通过 Source 任务向 外部数据源重新提交偏移量( offset )来实现,如图 所示。
(4)继续处理数据
3)检查点算法:
Flink 保存检查点是所有任务都处理完同一个输入数据的时候。 但是不同的任务处理数据的速度不同,当第一个 Source 任务处理到某个数据时,后面的 Sum 任务可能还在处理之前的数据;而且数据经过任务处理之后类型和值都会发生变化,不同的任务怎么知道处理的是“同一个”呢?一个简单的想法是, 当接到 JobManager 发出的保存检查点的指令后,Source 算子任务处理完当前数据就暂停等待,不再读取新的数据了。这样我们就可以保证在流中只有需要保存到 检查点的数据, 只要把它们全部处理完, 就可以保证所有任务刚好处理完最后一个数据;这时 把所有状态保存起来,合并之后就是一个检查点了。但是先保存完状态的任务就只能等待其他任务这就导致了资源的闲置和性能的降低。所以 Flink 采用了基于 Chandy-Lamport 算法的分布式快照。
检查点分界线(Barrier):
现在的目标是在不暂停流处理的前提下,让每个任务识别触发检查点保存的那个数据。自然想到, 如果给数据添加一个特殊标识,任务就可以准确识别并开始保存状态了。这需要在 Source 任务收到触发检查点保存的指令后,立即在当前处理的数据中插入一个标识字段, 然后再向下游任务发出。但是假如 Source 任务此时并没有正在处理的数据, 这个操作就无法 实现了。所以可以借鉴水位线(watermark)的设计,在数据流中插入一个特殊的数据结构, 专门用来表示触发检查点保存的时间点。收到保存检查点的指令后, Source 任务可以在当前数据流中插入这个结构;之后的所有任务只要遇到它就开始对状态做持久化快照保存。由于数据流是保持顺序依次处理的, 因此遇到这个标识就代表之前的数据都处理完了,可以保存一个检查点;而在它之后的数据, 引起的状态改变就不会体现在这个检查点中, 而需要保存到下一个检查点。这种特殊的数据形式,把一条流上的数据按照不同的检查点分隔开,所以就叫作检查点的 “分界线”(Checkpoint Barrier)。与水位线很类似,检查点分界线也是一条特殊的数据, 由 Source 算子注入到常规的数据流中, 它的位置是限定好的, 不能超过其他数据, 也不能被后面的数据超过。检查点分界线中带有一个检查点 ID,这是当前要保存的检查点的唯一标识,这样, 分界线就将一条流逻辑上分成了两部分:分界线之前到来的数据导致的状态更改, 都会被包含在当前分界线所表示的检查点中;而基于分界线之后的数据导致的状态更改,则会被包含在之后的检查点中。
在 JobManager 中有一个“检查点协调器”(checkpoint coordinator),专门用来协调处理检 查点的相关工作。检查点协调器会定期向 TaskManager 发出指令,要求保存检查点(带着检查 点 ID); TaskManager 会让所有的 Source 任务把自己的偏移量(算子状态) 保存起来,并将带有检查点 ID 的分界线(barrier) 插入到当前的数据流中, 然后像正常的数据一样像下游传递; 之后 Source 任务就可以继续读入新的数据了。每个算子任务只要处理到这个 barrier,就把当前的状态进行快照; 在收到 barrier 之前, 还是正常地处理之前的数据,完全不受影响。
分布式快照算法:
通过在流中插入分界线(barrier),我们可以明确地指示触发检查点保存的时间。在一条 单一的流上,数据依次进行处理,顺序保持不变; 不过对于分布式流处理来说, 想要一直保持 数据的顺序就不是那么容易了。具体实现上Flink 使用了 Chandy-Lamport 算法的一种变体, 被称为“异步分界线快照” (asynchronous barrier snapshotting)算法。算法的核心就是两个原则:当上游任务向多个并行 下游任务发送 barrier 时, 需要广播出去;而当多个上游任务向同一个下游任务传递 barrier 时, 需要在下游任务执行“分界线对齐”(barrier alignment)操作, 也就是需要等到所有并行分区 的 barrier 都到齐,才可以开始状态的保存。如图所示。 有两个并行的 Source 任务,会分别读取两个数据流。此时第一条流Source1 读取了 3 个数据,偏移量为 3;而第二条流Source2只读取了一个“hello”数据,偏移量为1。第一条流中的第一个数据“hello”已经完全处理完毕,所以 Sum 任务的状态中 key 为 hello -> 1,而且已经发出了结果(hello, 1);第二个数据“world”经过了 Map 任务的转换,还在被 Sum 任务处理;第三个数据“hello”还在被 Map 任务处理。而第二条流的第一 个数据“hello”同样已经经过了 Map 转换,正在被 Sum 任务处理。检查点保存的算法具体过程如下:
(1)JobManager 发送指令,触发检查点的保存;Source 任务保存状态,插入分界线 JobManager 会周期性地向每个 TaskManager 发送一条带有新检查点 ID 的消息,通过这种方式来启动检查点。收到指令后,TaskManger 会在所有 Source 任务中插入一个分界线 (barrier),并将偏移量保存到远程的持久化存储中,如图所示。
并行的 Source 任务保存的状态为 3 和 1 ,表示当前的 1 号检查点应该包含:第一条流中 截至第三个数据、第二条流中截至第一个数据的所有状态更改。(2)状态快照保存完成,分界线向下游传递 状态存入持久化存储之后,会返回通知给 Source 任务; Source 任务就会向 JobManager 确认检查点完成,然后像数据一样把 barrier 向下游任务传递,如图 所示。
由于 Source 和 Map 之间是一对一 的传输关系所以 barrier 可以直接传递给对应的 Map 任务。之后 Source 任务就可以继续读取新的数据了。与此同时,Sum 1 已经将第二条流传来的 (hello , 1) 处理完毕,更新了状态。
(3)向下游多个并行子任务广播分界线,执行分界线对齐 Map 任务没有状态,所以直接将
barrier
继续向下游传递。这时由于进行了
keyBy
分区, 所以需要将 barrier
广播到下游并行的两个
Sum
任务,如图
所示。同时,
Sum
任务可能 收到来自上游两个并行 Map
任务的
barrier
,所以需要执行“分界线对齐”操作
此时的 Sum 2 收到了来自上游两个 Map 任务的barrier,说明第一条流第三个数据、第二条流第一个数据都已经处理完, 可以进行状态的保存了;而Sum1只收到了来自 Map2 的 barrier,所以这时需要等待分界线对齐。在等待的过程中,如果分界线尚未到达的分区任务 Map 1 又传来了数据(hello, 1) ,说明这是需要保存到检查点的, Sum 任务应该正常继续处理数据, 状态更新为 3;而如果分界线已经到达的分区任务 Map2 又传来数据, 这已经是下一个检 查点要保存的内容了,就不应立即处理, 而是要缓存起来、等到状态保存之后再做处理。
(4)分界线对齐后, 保存状态到持久化存储各个分区的分界线都对齐后, 就可以对当前状态做快照, 保存到持久化存储了。存储完成之后, 同样将 barrier 向下游继续传递,并通知 JobManager 保存完毕, 如图所示。
这个过程中,每个任务保存自己的状态都是相对独立的,互不影响。我们可以看到,当
Sum 将当前状态保存完毕时, Source 1 任务已经读取到第一条流的第五个数据了。
(5)先处理缓存数据,然后正常继续处理
完成检查点保存之后,任务就可以继续正常处理数据了。这时如果有等待分界线对齐时缓
存的数据,需要先做处理;然后再按照顺序依次处理新到的数据。 当 JobManager 收到所有任务成功保存状态的信息,就可以确认当前检查点成功保存。之后遇到故障就可以从这里恢复了。 由于分界线对齐要求先到达的分区做缓存等待,一定程度上会影响处理的速度;当出现背压(backpressure )时,下游任务会堆积大量的缓冲数据,检查点可能需要很久才可以保存完 毕。为了应对这种场景,Flink 1.11 之后提供了不对齐的检查点保存方式,可以将未处理的缓冲数据(in-flight data )也保存进检查点。
4)保存点:
除了检查点(checkpoint) 外, Flink 还提供了另一个镜像保存功能——保存点 (Savepoint)。它的原理和算法与检查点完全相同, 只是多 了一些额外的元数据。事实上, 保存点就是通过检查点的机制来创建流式作业状态的一致性镜像(consistent image)的。保存点中的状态快照,是以算子 ID 和状态名称组织起来的,相当于一个键值对。从保存点启动应用程序时, Flink 会将保存点的状态数据重新分配给相应的算子任务。
1. 保存点的用途
保存点与检查点最大的区别, 就是触发的时机。检查点是由 Flink 自动管理的,定期创建, 发生故障之后自动读取进行恢复,这是一个“自动存盘”的功能; 而保存点不会自动创建, 由用户手动触发保存操作。因此两者尽管原理一致,但用途 就有所差别了: 检查点主要用来做故障恢复, 是容错机制的核心; 保存点则更加灵活, 可以用来做有计划的手动备份和恢复。可以在需要的时候创建一个保存点,然后停止应用,做一些处理调整之后再从保存点重启。它适用的具体场景有:
1)版本管理和归档存储:
对重要的节点进行手动备份,设置为某一版本,归档(archive)存储应用程序的状态。
2)更新 Flink 版本:
目前 Flink 的底层架构已经非常稳定, 所以当 Flink 版本升级时, 程序本身一般是兼容的。 这时不需要重新执行所有的计算,只要创建一个保存点,停掉应用、升级 Flink 后,从保存点重启就可以继续处理了。
3) 更新应用程序:
我们不仅可以在应用程序不变的时候,更新 Flink 版本; 还可以直接更新应用程序。前提 是程序必须是兼容的,也就是说更改之后的程序, 状态的拓扑结构和数据类型都是不变的, 这 样才能正常从之前的保存点去加载。
4)调整并行度:
如果应用运行的过程中,发现需要的资源不足或已经有了大量剩余,也可以通过从保存点 重启的方式,将应用程序的并行度增大或减小。
5)暂停应用程序:
有时候我们不需要调整集群或者更新程序,只是单纯地希望把应用暂停、释放一些资源来处理更重要的应用程序。使用保存点就可以灵活实现应用的暂停和重启,可以对有限的集群资源做最好的优化配置。
- 状态一致性
检查点又叫作“一致性检查点”,是 Flink 容错机制的核心。在分布式系统中, 一致性(consistency) 是一个非常重要的概念;Flink 中一致性的概念, 主要用在故障恢复的描述中。对于分布式系统而言, 强调的是不同节点中相同数据的副本应该总是“一致的”,也就是从不同节点读取时总能得到相同的值;而对于事务而言,是要求提交更新操作后,能够读取到新的且正确的数据。对于 Flink 来说,多个节点并行处理不同的任务, 要保证计算结果是正确的,就必须不漏掉任何一个数据,而且也不会重复处理同一个数据。流式计算本身就是一个一个来的,所以正常处理的过程中结果肯定是正确的; 但在发生故障、需要恢复状态进行回滚时就需要更多的保障机制了。通过检查点的保存来保证状态恢复后结果的正确,也就是“状态的一致性”。
一般说来, 状态一致性有三种级别:
1)最多一次(AT-MOST-ONCE):
当任务发生故障时,最简单的做法就是直接重启,别的什么都不干;既不恢复丢失的状态, 也不重放丢失的数据。每个数据在正常情况下会被处理一次, 遇到故障时就会丢掉,就是 “最多处理一次”。看起来比较糟糕,不过如果主要诉求是“快”, 而对近似正确的结果也能接受, 这也是一种很好的解决方案。
2)至少一次(AT-LEAST-ONCE):
在实际应用中, 有时会希望至少不要丢掉数据。这种一致性级别就叫作“至少一次” (at-least-once),就是所有数据都不会丢, 肯定会被处理; 不过不能保证只处理一次,有些数据会被重复处理。在有些场景下, 重复处理数据是不影响结果的正确性的比如, 如果我们统计电商网站的 UV,需要对每个用户的访问数据进行去重处理, 所以即使同一个数 据被处理多次, 也不会影响最终的结果, 这时使用 at-least-once 语义是完全没问题的。当然, 如果重复数据对结果有影响, 比如统计的是 PV,或者之前的统计词频 word count,使用 at-least-once 语义就可能会导致结果的不一致了。
3)精确一次(EXACTLY-ONCE):
最严格的一致性保证,就是所谓的“精确一次”。 exactly-once 意味着所有数据不仅不会丢失,而且只被处 理一次,不会重复处理。也就是说对于每一个数据,最终体现在状态和输出结果上,只能有一次统计。 在发生故障恢复后, 就好像从未发生过故障一样。要做的 exactly-once,首先必须能达到 at-least-once 的要求,就是数据不丢。所以同样需要有数据重放机制来保证这一点。另外,还需要有专门的设计保证每个数据只被处理一 次。Flink 中使用的是一种轻量级快照机制——检查点(checkpoint) 来保证 exactly-once 语义。