什么是恰好一次处理
恰好一次处理(exactly-once)简而言之就是保证数据记录(record)在整个计算过程中恰好被处理一次。显然,每条数据恰好处理一次是整个计算结果正确性的必要条件。
由于分布式系统天然的不可靠性,数据是否发送成功是不可知的(false-negative 是可能的)。因此,几乎总是需要某种程度的重试和去重逻辑。我们在流式计算系统中所说的恰好一次处理强调的是从结果来看,数据恰好被处理一次,或者说结果是数据恰好被处理一次时产生的结果。
为了支持恰好一次处理,需要实现网络传输层面的容错和作业层面的容错。网络传输层面,在 Flink 的实现中,Task 上下游之间通过 TCP 传输,我们可以认为在 TCP 层面解决了消息重发和消息去重的问题;对于其他不使用 TCP 传输的实现,也需要在应用层面实现类似的重发和去重逻辑,方法和 TCP 采用的方法类似。作业层面的容错则是本文的核心内容。
对于批处理系统,例如 Spark,恰好一次处理并不是一个复杂的问题。由于数据是有限的,上游总有一天会终止。如果下游失败,只需要重新启动一个实例并从上游重新拉取数据消费即可。而对于流处理系统,例如 Flink,数据理论上是无限的,上游无法无限地缓存中间结果,因此需要其他方案来实现恰好一次的处理。
Flink 基于算子上的 state 和 checkpoint 机制实现了流处理系统上的恰好一次处理,下面我们介绍其实现方式。
checkpoint 与 state
我们先简单介绍一下 checkpoint 和 state 的含义。
state 指的是与算子绑定的持久化状态。Flink 的作业表示为一个有向无环图,图上的节点即各个不同功能的算子,例如 Map 算子,Reduce 算子,Source 算子和 Sink 算子等等。应用层的算子可以通过实现 CheckpointedFunction
接口,获取 Flink 框架层面支持的状态读写。典型的 state 例如处理函数调用次数的计数器,Window 算子当前窗口的中间状态和 Source 算子当前读取数据源的 offset 等等。
checkpoint 指的是 Flink 对所有算子的 state 做分布式快照的动作。checkpoint 成功时将产生一个序号全局单调递增的分布式快照,这个快照是所有算子的 state 的一致性记录。Flink 采用的分布式快照算法是Chandy-Lamport算法的一个变体,我们会在后面介绍 Flink 产生分布式快照的细节。
state 与 exactly-once
我们先认为通过 checkpoint 机制能够产生最新版本的全局一致的 state 的分布式快照,基于这个知识我们来介绍 state 是如何支持 Flink 实现恰好一次处理的。
回顾前文,我们定义的恰好一次处理,指的是数据记录在整个计算过程中恰好被处理一次。由于分布式系统天然的不可知性,我们实际上说的是从结果来看,产生的结果是数据记录恰好被处理一次的结果。state 就是数据记录被处理之后产生的结果,因此 state 都应该是数据记录恰好被处理一次的结果。由于 Flink 的传输基于 TCP,在作业没有发生错误的情况下,这一点我们可以认为是成立的。在作业发生错误的情况下,一个典型的流处理作业会进行全图重启,从上一个 checkpoint 开始重新计算,Flink 是怎么保证这样的计算从结果来看是恰好一次处理的呢?
首先,我们需要让 Source 算子从上一个 checkpoint 记录的位置开始重新发送数据,这就要求数据源是可重播的。如果数据源是不可重播的,例如无缓存的实时 socket,在容错场景下,我们无法再次取得先前的数据,恰好一次处理是无法支持的。前面举例说明 state 的应用的时候提到了 Source 算计当前读取数据源的 offset 就是可重播数据源支持恰好一次处理的一种方式。例如数据源是 Kafka,我们在 checkpoint 的时候将当前读取到的 Kafka partition offset 作为 Source 算子的 state 高可靠的持久化,在容错场景下,Source 算子即可恢复出上次读取的 offset,从正确的位置开始产生输出。
对于其他算子,它们的输入来自于上游。现在 Source 节点产生的输出是正确不重复的,下游算子先前持久化的 state 也已经被成功加载,只需要再次消费上游发送的数据,即可保证 state 是数据恰好被处理一次的结果。再下游的算子以此类推,就能够推出所有算子的 state 都将是数据恰好被处理一次的结果。
另外值得强调的是 Sink 算子,Sink 算子在写出到外部存储时,要实现用户层面上的恰好一次处理,也需要通过实现 CheckpointedFunction
接口来配合。目前 Flink 原生地支持 Kafka 和 HDFS 作为 Sink 的恰好一次写出,需要扩展时推荐实现自己的 TwoPhaseCommitSinkFunction
来达到目的。Sink 算子的特别之处在于它的处理跨越了 Flink 框架和外部存储的边界,因此为了实现用户层面的恰好一次处理,需要和外部存储互相配合。
这里提一下恰好处理一次的两个需要注意的点。如果数据处理函数有副作用,或者输出是非确定性的,实际容错效果可能与直觉上的恰好一次处理会有出入。
前一个问题的例子,例如在数据处理函数中打印数据,或者更改外部世界的状态,由于这样的副作用不像上面提到的 Sink 算子的跨越边界的一致性处理时那样被管理起来,当 Flink 框架进行容错的时候,是有可能多次执行副作用的,从用户角度来看,就是数据不止被处理了一次。这是无法避免的。
后一个问题的例子,例如在数据处理函数中获取随机数用于计算,容错前后的随机数取值很可能是不同的,从理论上说这不算是数据真的从头到尾仅处理一次的结果。另一个不太直观的例子是,数据处理函数中获取配置中心当前时刻的配置,容错前后配置可能被其他程序更改,这样两次取值可能是不同的。对于非确定性的输出,用户可能在用户透明的容错场景下观察到输出发生非预期的抖动。
checkpoint 与 exactly-once
本节将展开介绍上一节中作为先验知识的 checkpoint 机制,介绍 Flink 的 checkpoint 机制如何产生最新版本的全局一致的 state 的分布式快照。
checkpoint 的完整过程由 JobManager 上的 CheckpointCoordinator 组件,TaskManager 上的 Task 和高可靠的(分布式)存储系统协同完成。
假设现在要产生一个新的 checkpoint,这一过程首先由 CheckpointCoordinator 上的周期性作业触发,触发时获取所有 Source 算子对应的 Execution,并分别向对应的 Task 所在的 TaskManager 发起 triggerCheckpoint 调用。TaskManager 收到调用请求后,会触发对应的 Task(必定是 Source Task)的 triggerCheckpoint 方法,向 Mailbox 插入一条 triggerCheckpoint 的消息,随后在消息被处理时先向下游传播 CheckpointBarrier,然后触发本地 state 的快照,根据配置的不同,快照可能存储在内存中,HDFS 上,或 RocksDB 上(再存储到 HDFS 上以保证高可靠)。在快照成功生成之后,向 CheckpointCoordinator 汇报快照完成。
这一部分关键源码位置
CheckpointCoordinator#triggerCheckpoint
→Execution#triggerCheckpoint
TaskExecutor#triggerCheckpoint
→TaskExecutor#triggerCheckpointBarrier
→SourceStreamTask#triggerCheckpointAsync
→StreamTask#performCheckpoint
对于其他算子,它们会陆续收到上游发送的 CheckpointBarrier,如果仅有一个上游,在收到 CheckpointBarrier 之后就开始触发下发 CheckpointBarrier 和快照相关的逻辑;如果有多个上游,则在对齐所有上游的 CheckpointBarrier 之后才开始触发,在对齐之前的其他输入将被缓存(恰好一次语义,在至少一次语义下不会缓存,会继续处理,因此 state 可能是数据被处理多次的结果)。同样的,在快照成功生成之后,向 CheckpointCoordinator 汇报快照完成。
这个对齐的过程可参考社区文档中的图片。
![496fbdbe6f7f766fc12a45e107b98151.png](https://i-blog.csdnimg.cn/blog_migrate/4a504e391894570cdbf0c6f5514076ba.jpeg)
这一部分关键源码位置
CheckpointBarrierAligner#processBarrier
→CheckpointBarrierHandler#notifyCheckpoint
→StreamTask#triggerCheckpointOnBarrier
→StreamTask#performCheckpoint
继续上面的过程,我们提到快照生成成功后,Task 会向 CheckpointCoordinator 汇报,汇报内容核心是 state 做快照时存储在高可靠的(分布式)存储系统上的位置信息。CheckpointCoordinator 收集到所有 state 的汇报后,将上述位置信息等元数据信息打包为 CompletedCheckpoint 数据结构,一致地存储到外部存储系统中(具体到 Flink 的当前实现,是存到 HDFS 上,然后把 HDFS 的句柄存到 ZK 上),随后便可宣告 Checkpoint 完成。这样我们就完成了一次分布式快照。
这个汇报和提交的过程可参考社区文档中的图片。
![8c4afcfa6d0478703487e7b2d25c26eb.png](https://i-blog.csdnimg.cn/blog_migrate/63e32b9b85a1863dffa84d9660b942dd.jpeg)
这一部分关键源码位置
StreamTask.AsyncCheckpointRunnable#reportCompletedSnapshotStates
→TaskStateManager#reportTaskStateSnapshots
CheckpointCoordinator#receiveAcknowledgeMessage
CheckpointCoordinator#completePendingCheckpoint
其他流式计算系统的实现方案
《流式系统(Streaming Systems)》一书中提及 Google Dataflow 早期的实现方案主要着重于上下游之间数据记录(record)的重发。Google Dataflow 早期并不支持状态化的处理,因此仅考虑了单个中间算子失败的情况下向上游算子重新拉取输入的情况。具体的实现细节与 TCP 类似,把数据记录编码,并将相关的元数据存储到分布式键值存储系统中。上游会反复重试发送信息,下游会比对分布式键值存储系统中的记录,对已经接受过的信息去重。当多个算子级联失败时,递归地等待上游推送新的数据。书中并未提及 Source 算子失败时如何处理,现有公开资料也无从得知支持状态化计算后如何处理状态的存储,猜想好的实现方式应该与 Flink 类似。Google Dataflow 的采用者甚少,在此列出作为前文略过的网络层面的容错机制的补充。
Spark Streaming 的容错方案与其所基于的批处理系统 Spark 息息相关,基本是微批的产生数据集并与本文第一节提及的微批完全重试结合。这一点的实现有赖于批处理系统久经考验的正确地 shuffle 中间结果的方案。Spark Streaming 也有 checkpoint,但这更多的是作为一种优化,避免开销巨大的计算重新计算。
Flink 在批流统一的方向上也借鉴了 Spark Streaming 的思路,即需要把中间结果存储起来,以支持上游终止的情况下仍然能够正确的拉取数据。另一方面,这也是一个本地恢复优化的入手点。当然,作为这一优化的权衡(trade-off),将中间结果落盘将导致不可避免的(显著的)额外开销。结合具体业务场景特点选择相应的模式和配置是业务开发人员需要注意的。