流计算引擎如何同时实现高性能和状态一致性(exactly once)?谷歌DataFlow平台与Flink的实现方式

参考:

《The dataflow model: a practical approach to balancing correctness, latency, and cost in massive-scale, unbounded, out-of-order data processing》-- DataFlow的论文

《Lightweight Asynchronous Snapshots for Distributed Dataflows》 – Flink的论文

《Streaming Systems》chap. 5 – 介绍谷歌DataFlow平台如何实现exactly once

《Stream processing with Apache Flink》chap. 2 – 流计算的基本概念

一般我们关心流计算引擎的什么性能?

一般我们说一个引擎“很快”,是一个非常笼统的说法。流计算引擎的性能通常被分为两种“快”。

时延(latency):从收到一条数据到返回该数据的处理结果需要的时间。

吞吐量(throughput):单位时间内能处理的数据量。

初次接触这两者可能会觉得有点奇怪。我举个例子:假如说我们要把一个土豆切成20片。如果我们每切一片土豆只用花很短的时间(低时延?),那我们在一分钟之内肯定能切很多个土豆(高吞吐)。为什么要把他们分开测量?这其实取决于如何定义“返回结果”这个操作。假如说我们想切点土豆放到冰箱里,那么“返回结果”的时间就是把土豆放进冰箱的那一刻。以流计算的逻辑,我们有两种方法来做这个事情:

  1. 我们可以切一片土豆就跑去冰箱那放下,再跑回来切第二片,再跑去冰箱。这样一来,我们实现了低时延(土豆一切出来马上就放进了冰箱),但是这样一来,我们要花很久才能把一个土豆切完,我们的吞吐量就很低。

  2. 我们可以拿一个盘子放在案板边,切一片就放一片到盘子里。等切完十片,再一起放进冰箱,然后回来切第二盘。这样一来,我们的时延很高,但是很快就能把一个土豆切完(吞吐量更高)。

上面的第一个例子大致就是早期Storm的做法,纯流式处理。来一条数据就处理一条,但是频繁的网络IO(拿着一片土豆跑去冰箱再跑回来)导致它的吞吐量非常低。而第二个例子就是后来的流计算引擎(如Spark streaming, Flink)所做的优化了:使用微批(micro-batch)将数据放入缓冲区,等攒够了一批再发出去(一次放一盘土豆片到冰箱)。我们可以通过调整微批的大小(一次放几片土豆)在吞吐和时延中寻求折中的方案。

  • 虽然FLink声称自己是一个纯流式引擎,并且确实使用的是纯流式的数据模型,但其底层的处理模型仍然是微批。纯流式的处理方式(来一条就处理一条)的巨大网络资源浪费是想要实现纯流式引擎的理想主义者们目前无法绕过的坎。

但是,这两者仍然有一些微妙的关联,因为吞吐量与输入数据的速度有关。如果输入一条数据的速度比处理一条数据的速度慢,比如一天就只来了一条数据,那吞吐量也是1/天,但这并不意味着这个流系统超垃圾,一天就只能处理一条数据。如果系统超负荷运作,即输入数据的速度比处理的速度快,那么就会有越来越多的数据在buffer里面排队,也就会导致之后结果的时延变长。所以我们一般关心的是在系统资源满负荷(输入和处理速度差不多)的情况下的吞吐量和时延。

在系统资源超负荷的情况下,增加吞吐量和时延是可以同时改善的。在我们增加了系统资源之后,我们可以更快地处理排队的数据,所以这些数据的时延就会更低。而排队的数据被处理得越快,相同时间内处理的数据量就越大,吞吐量也就越大。

为什么说高性能和一致性很难同时实现?

简单的情况:想要实现一致性,就要定期将本地的状态储存到远端,也就是所谓的检查点。怎么确定储存的时机才能保证存进去的状态是一致的?早期的流计算引擎的做法简单粗暴,就是全局停止,存完检查点之后再继续计算。这显然是一个低效的方式,已经跟不上我们对现在流计算引擎的性能需求了。之后虽然也陆续出现了更高明的做法(通常和引擎的架构有关),但当时流行的流计算引擎如Storm和Spark streaming都无法同时保证低时延和高吞吐,Storm底层更是只能保证at least once。

除了时机之外,存什么信息才能最大化地节省空间?现在我们处理的数据越来越大,如果我们将所有数据都事无巨细地备份,无疑会产生巨大的空间和IO压力,拖累系统性能。

大家可能都知道Flink提出了高效的分布式快照算法解决了这个问题,但再聊Flink之前,我们先来看看在没有这个快照算法的情况下,大佬们是怎么做的。

谷歌DataFlow平台的方案:upstream backup

谷歌的DataFlow平台与它设计的DataFlow模型同名。下面“DataFlow”都指平台,涉及到DataFlow模型时会直接用“DataFlow模型”。DataFlow平台的底层逻辑其实很简单,先实现at least once,再在这个基础上实现exactly once。

首先,对于每一条数据,如果数据的发送方没有收到接收方返回的“成功处理”的ack,就会一直重发这条数据。DataFlow会保证就算发送方挂了,也会重发。这个简单的机制提供了at least once的保证。

at least once与exactly once的区别就在于at least once存在重复数据,所以只要接收方能将它们分辨出来,就可以实现exactly once了。为了识别重复数据,DataFlow给系统中组件发送的每一条数据都指定了一个全局唯一的ID,并在发送之前将数据和ID一起备份到远端数据库(防止发送方挂在数据发送之后、备份之前)。如此就能保证就算发送方挂了,我们也可以重放这些数据。接收方会维护一个当前已处理的ID集合:每收到一条数据,就检查一下数据的ID是否存在于集合中。如果存在,则代表这一条数据是重复数据,将其丢弃。如果不存在,则处理数据并将ID加入集合。在计算的时候频繁的查询必然会带来较大的开销,想要为每个系统组件都维护这样的ID集合,其数据量必然是庞大的。因此,我们需要一个高性能,可扩展的key/value Store。此外,DataFlow还使用了一个非常巧妙的优化策略来加速对集合的查询。

维护一个海量数据的集合会产生巨大的开销——不管是增删改查的时间还是储存空间上。但是,一个健康的数据管道中,重复数据毕竟是少数(故障不会经常发生)。基于此观察,DataFlow使用Bloom Filter极大的改善了查询的性能。Bloom Filter是一个能利用少量空间来解决海量数据的集合隶属(set membership)问题的数据结构。像这种数据结构,都是针对无法存放在内存中的数据量的,因此很难在保证时间和空间复杂度的情况下提供完全准确的结果。除了时间和空间复杂度之外,他们还会多一个评判标准:结果的准确性。Bloom Filter有一个非常特殊的特性,就是它的错误结果中,只有False Positives(假阳性),没有False Negatives(假阴性)。也就是说,如果Bloom Filter中包含某个元素(Positive),这个元素不一定真的在集合里面。但是如果Bloom Filter中不含某个元素(Negative),那么这个元素一定不在集合里面。这个特性正好符合我们对重复数据的观察。

每个节点都会在本地用一个Bloom Filter记录所有出现过的ID。当节点接受到一条数据,就去Bloom Filter里面查询这个ID是否存在。如果不存在,我们就可以知道这个ID对应的数据不是重复的,就省去了去远端查询的昂贵开销。因为重复数据总是少见的,所以查询Bloom Filter时返回“true”的情况也会很少,我们就不会频繁的去远端查询。

这个架构也伴随着一些其他的细节问题,比如Bloom Filter随时间会被填满,增加出现的假阳性的概率,所以需要不断的开启新的Bloom Filter。又比如,流计算处理的可能是无限的数据,本地的空间不可能一直储存所有出现过的ID,必须隔一段时间就对旧的ID进行垃圾回收,释放内存空间等。再次不多赘述,有兴趣可以看书《Streaming Systems》chap. 5。

另外,DataFlow还会自动对计算任务的图结构进行优化。比如,可以将两个节点融合为一个节点(Flink借鉴了很多DataFlow的设计,Flink中也做了类似图优化的事情,称为Operator Chaining)。贴一下书上的图就一目了然了:在这里插入图片描述
总体来说,比起流计算引擎,我个人觉得这更像一个微服务架构,是从工程角度出发的解决方案。架构清晰,设计巧妙。好处也非常明显,就是一旦发生故障,不需要做重复的运算,运算结果都已经被储存到远端了。但是,对节点历史数据的储存也可能会带来不小的储存成本和IO压力。同时,DataFlow必须依赖其他系统(如高性能的、可靠的kv store)来实现exactly once,无法独立提供一致性保证。

Flink的检查点机制:轻量异步快照

FLink提出了革新式的解决方案——更高效的检查点/快照机制:Asynchronous barrier snapshots(ABS)。这个算法是简化版的Chandy-Lamport算法。为什么说是简化版的?流系统是一种特殊的分布式系统,数据分为上下游(单向从一个节点流向另一个节点),而分布式系统中节点之间会有双向通信。因此,Chandy-Lamport算法为了在更严苛的环境下保持检查点的一致性,施加了很多在流计算场景中可以被去掉的限制。

下图出自FLink的论文:

在这里插入图片描述

快照的过程简单来说就是:由central coordinator(也就是JobManager的CheckpointCoordinator)周期性地向所有source发出barrier。source收到barrier后会拍下snapshot,然后将barrier广播给它所有的output channel(图中黑色箭头)。非source节点在收到一个barrier之后,但在这个节点没有收到所有上游的barrier之前,会block掉已收到barrier的channel(图中红色箭头)中的数据不进行处理,以保证状态一致性。当它收到最后一个channel的barrier,这个节点才会拍下snapshot并开始处理之前被block的数据。拍完快照之后同样将barrier广播给它所有的output channel。

这个快照机制可以异步执行,无需暂停全局的运算,并且在运算图中无环时不需要储存发送到下游的数据。因此,它需要储存的数据非常精简,极大地避免了IO和储存压力。但是一旦发生故障,所有节点都需要恢复到上一个检查点,重新从数据源开始处理从上一个检查点之后到故障之前的数据(当然了,正常情况下,故障率都是很低的)。总的来说,这个机制非常的高效,Flink也因此能够在保证exactly once的情况下,同时实现低时延和高吞吐。
在这里插入图片描述

如何优雅地在有环的情况下执行一致性检查点,多年来一直是一个难题。如果支持环,就可以进行迭代操作。与许多检查点算法一样,对于ABS算法,如果不做特殊处理,则不会终止(barrier会在环中无限循环)。如果随意确定终止时间,很容易出现存进去的状态不一致的情况。Flink的ABS算法也不得不使用备份来解决环的问题:图中有环的情况下,在环的反向边(back-edge,上图中的C->B)的接收方(上图的B)处做备份。这些节点(B)为每个反向边(C->B)创建一个backup log,从向下游发出barrier开始,将从反向边中收到的数据写入对应的log。直到节点再次从反向边(C->B)中收到这个barrier后,关闭对应的log。log中的数据最终都会被记录到快照中。

我们来看一个简单的例子:B依次发给了C:1,2,barrier“||”,3。也就是说,B在发送barrier之后拍摄了自己的快照,然后发送了3。当C处理完1和2后发回B,并在收到barrier之后拍了C的快照。假设1和2还没有到达B之前系统挂掉了需要恢复。B恢复之后只会重发barrier之后的数据“3”。如果没有backup log,C->B通道中的1和2就丢失了,并没有被处理。
在这里插入图片描述

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值