随着近来越来越多的业务迁移到 Flink 上,对 Flink 作业的准确性要求也随之进一步提高,其中最为关键的是如何在不同业务场景下保证 exactly-once 的投递语义。虽然不少实时系统(e.g. 实时计算/消息队列)都宣称支持 exactly-once,exactly-once 投递似乎是一个已被解决的问题,但是其实它们更多是针对内部模块之间的信息投递,比如 Kafka 生产(producer 到 Kafka broker)和消费(broker 到 consumer)的 exactly-once。而 Flink 作为实时计算引擎,在实际场景业务会涉及到很多不同组件,由于组件特性和定位的不同,Flink 并不是对所有组件都支持 exactly-once(见[1]),而且不同组件实现 exactly-once 的方法也有所差异,有些实现或许会带来副作用或者用法上的局限性,因此深入了解 Flink exactly-once 的实现机制对于设计稳定可靠的架构有十分重要的意义。
下文将基于 Flink 详细分析 exactly-once 的难点所在以及实现方案,而这些结论也可以推广到其他实时系统,特别是流式计算系统。 Exactly-Once 难点分析 由于在分布式系统的进程间协调需要通过网络,而网络情况在很多情况下是不可预知的,通常发送消息要考虑三种情况: 正常返回、错误返回和超时,其中错误返回又可以分为可重试错误返回(e.g. 数据库维护暂时不可用)和不可重试错误返回(e.g. 认证错误),而可重试错误返回和超时都会导致重发消息,导致下游可能接收到重复的消息,也就是 at-least-once 的投递语义。 而 exactly-once 是在 at-least-once 的基础之上加上了可以识别出重发数据或者将消息包装为为幂等操作的机制。 其实消息的 exactly-once 投递并不是一个分布式系统产生的新课题(虽然它一般特指分布式领域的 exactly-once),早在计算网络发展初期的 TCP 协议已经实现了网络的可靠传输。 TCP 协议的 exactly-once 实现方式是将消息传递变为有状态的: 首先同步建立连接,然后发送的每个数据包加上递增的序列号(sequence number),发送完毕后再同步释放连接。 由于发送端和接受端都保存了状态信息(已发送数据包的序列号/已接收数据包的序列号),它们可以知道哪些数据包是缺失或重复的。 而在分布式环境下 exactly-once 则更为复杂,最大的不同点在于分布式系统需要容忍进程崩溃和节点丢失,这会带来许多问题,比如下面常见的几个:
进程状态需要持续化到可靠的分布式存储,以防止节点丢失带来状态的丢失。
由于发送消息是一个两阶段的操作(即发送消息和收到对方的确认),重启之后的进程没有办法判断崩溃前是否已经使用当前序列号发送过消息,因此可能会导致重复使用序列号的问题。
被认为崩溃的进程有可能并没有退出,随后再次连上来变为 zombie 进程继续发送数据。
幂等 Sink
幂等性是分布式领域里十分有用的特性,它意味着相同的操作执行一次和执行多次可以获得相同的结果,因此 at-least-once 自然等同于 exactly-once。 如此一来,在从快照恢复的时候幂等 sink 便不需要对外部系统撤回已发消息,相当于回避了外部系统的状态回滚问题。 比如写入 KV 数据库的 sink,由于插入一行的操作是幂等的,因此 sink 可以无状态的,在错误恢复时也不需要关心外部系统的状态。 从某种意义来讲,上文提到的 TCP 协议也是利用了发送数据包幂等性来保证 exactly-once。 然而幂等 sink 的适用场景依赖于业务逻辑,如果下游业务本来就无法保证幂等性,这时就需要应用事务性 sink。事务性 Sink
事务性 sink 顾名思义类似于传统 DBMS 的事务,将一系列(一般是一个 checkpoint 内)的所有输出包装为一个逻辑单元,理想的情况下提供 ACID 的事务保证。 之所以说是“理想的情况下”,主要是因为 sink 依赖于目标输出系统的事务保证,而分布式系统对于事务的支持并不一定很完整,比如 HBase 就不支持跨行事务,再比如 HDFS 等文件系统是不提供事务的,这种情况下 sink 只可以在客户端的基础上再包装一层来尽最大努力地提供事务保证。 然而仅有下游系统本身提供的事务保证对于 exactly-once sink 来说是不够的,因为同一个 sink 的子任务(subtask)会有多个,对于下游系统来说它们是处在不同会话和事务中的,并不能保证操作的原子性,因此 exactly-once sink 还需要实现分布式事务来达到所有 subtask 的一致 commit 或 rollback。 由于 sink 事务生命周期是与 checkpoint 一一对应的,或者说 checkpoint 本来就是实现作业状态持久化的分布式事务,sink 的分布式事务也理所当然可以通过 checkpoint 机制提供的 hook 来实现。 Checkpoint 提供给算子的 hook 有 CheckpointedFunction 和 CheckpointListener 两个,前者在算子进行 checkpoint 快照时被调用,后者在 checkpoint 成功后调用。 为了简单起见 Flink 结合上述两个接口抽象出 exactly-once sink 的通用逻辑抽象TwoPhaseCommitSinkFunction
接口,从命名即可看出这是对两阶段提交协议的一个实现,其主要方法如下:
beginTransaction: 初始化一个事务。在有新数据到达并且当前事务为空时调用。
preCommit: 预提交数据,即不再写入当前事务并准好提交当前事务。在 sink 算子进行快照的时候调用。
commit: 正式提交数据,将准备好的事务提交。在作业的 checkpoint 完成时调用。
abort: 放弃事务。在作业 checkpoint 失败的时候调用。
在 sink 端缓存未 commit 数据,等 checkpoint 完成以后将缓存的数据 flush 到下游。这种方式可以提供 read-committed 的事务隔离级别,但同时由于未 commit 的数据不会发往下游(与 checkpoint 同步),sink 端缓存会带来一定的延迟,相当于退化为与 checkpoint 同步的 micro-batching 模式。
在下游系统缓存未 commit 数据,等 checkpoint 完成后通知下游 commit。这样的好处是数据是流式发往下游的,不会在每次 checkpoint 完成后出现网络 IO 的高峰,并且事务隔离级别可以由下游设置,下游可以选择低延迟弱一致性的 read-uncommitted 或高延迟强一致性的 read-committed。
2.An Overview of End-to-End Exactly-Once Processing in Apache Flink
3.State Management in Apache Flink
4.An Overview of End-to-End Exactly-Once Processing in Apache Flink (with Apache Kafka, too!) — THE END —