Apache Flink如何处理背压

Apache Flink如何处理背压

经常有人会问Flink如何处理背压问题。其实,答案很简单:Flink没用使用任何通用方案来解决这个问题,因为那根本不需要那样的方案。它利用自身作为一个纯数据流引擎的优势来优雅地响应背压问题。这篇文章,我们将介绍背压问题,然后我们将深挖Flink的运行时如何在task之间传输数据缓冲区内的数据以及流数据如何自然地两端降速来应对背压,最终将以一个小示例来演示它。

1.什么是背压

像Flink这样的流处理系统需要能够优雅地应对背压问题。背压通常产生于这样一种场景:当一个系统接收数据的速率高于它在一个瞬时脉冲内能处理的数据。许多日常问题都会导致背压。例如,垃圾回收卡顿可能会导致流入的数据快速堆积,或者一个数据源可能生产数据的速度过快。背压如果不能得到正确地处理,可能会导致资源被耗尽或者甚至出现更糟的情况导致数据丢失。

让我们来看一个简单的例子。假设存在一个数据流pipeline作为source,一个流处理job,以及一个sink以每秒500万条记录的速度处理数据,整个流处理程序处于稳定的状态。如下图所示(一个黑色的条状代表1百万个记录,该图是系统中其中1秒的快照):
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ExA3GZ5l-1612761411288)(https://www.da-platform.com/hs-fs/hubfs/Imported_Blog_Media/no_backpressure-1.png?width=1360&height=183&name=no_backpressure-1.png)]
在同一时间点,不管是流处理job还是sink,如果有1秒的卡顿,那么将导致至少500万条记录的积压。换句话说,source可能会产生一个脉冲,显示在一秒内数据的生产速度突然翻倍。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TKwLKMvq-1612761411290)(https://www.da-platform.com/hs-fs/hubfs/Imported_Blog_Media/backpressure-1.png?width=1360&height=185&name=backpressure-1.png)]
我们如何来应对类似这样的场景呢?当然,其中一种方案是删除这些元素。但数据丢失对许多流处理程序而言是不可接受的!这些应用要求exactly once的一致性。另一种方案是数据放在某个缓冲区内。缓冲区也需要被持久化,因为在失败的情况下,这些数据需要被重放 以防止数据丢失。理想情况下,这些数据应该被缓冲到某个持久化的channel里(例如,如果source本身提供持久化保证的情况下,可以是该source本身 – Apache Kafka是一个很不错的选择)。而理想的应对措施是:背压从sink到source的整个pipeline,同时对source进行限流来适配整个pipeline中最慢组件的速度,从而获得稳定状态: [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nQxSLoGH-1612761411291)(https://www.da-platform.com/hs-fs/hubfs/Imported_Blog_Media/steady_state-1.png?width=1360&height=185&name=steady_state-1.png)]

2.网络传输中的内存管理

那么 Flink 是怎么处理反压的呢?答案非常简单:Flink 没有使用任何复杂的机制来解决反压问题,因为根本不需要那样的方案!它利用自身作为纯数据流引擎的优势来优雅地响应反压问题。下面我们会深入分析 Flink 是如何在 Task 之间传输数据的,以及数据流如何实现自然降速的。

Flink 在运行时主要由 operators 和 streams 两大组件构成。每个 operator 会消费中间态的流,并在流上进行转换,然后生成新的流。对于 Flink 的网络机制一种形象的类比是,Flink 使用了高效有界的分布式阻塞队列,就像 Java 通用的阻塞队列(BlockingQueue)一样。还记得经典的线程间通信案例:生产者消费者模型吗?使用 BlockingQueue 的话,一个较慢的接受者会降低发送者的发送速率,因为一旦队列满了(有界队列)发送者会被阻塞。Flink 解决反压的方案就是这种感觉。

在 Flink 中,这些分布式阻塞队列就是这些逻辑流,而队列容量是通过缓冲池来(LocalBufferPool)实现的。每个被生产和被消费的流都会被分配一个缓冲池。缓冲池管理着一组缓冲(Buffer),缓冲在被消费后可以被回收循环利用。这很好理解:你从池子中拿走一个缓冲,填上数据,在数据消费完之后,又把缓冲还给池子,之后你可以再次使用它。

在解释 Flink 的反压原理之前,我们必须先对 Flink 中网络传输的内存管理有个了解。

如下图所示展示了 Flink 在网络传输场景下的内存管理。网络上传输的数据会写到 Task 的 InputGate(IG) 中,经过 Task 的处理后,再由 Task 写到 ResultPartition(RS) 中。每个 Task 都包括了输入和输入,输入和输出的数据存在 Buffer 中(都是字节数据)。Buffer 是 MemorySegment 的包装类。
image

  1. TaskManager(TM)在启动时,会先初始化NetworkEnvironment对象,TM 中所有与网络相关的东西都由该类来管理(如 Netty 连接),其中就包括NetworkBufferPool。根据配置,Flink 会在 NetworkBufferPool 中生成一定数量(默认2048个)的内存块 MemorySegment(关于 Flink 的内存管理,后续文章会详细谈到),内存块的总数量就代表了网络传输中所有可用的内存。NetworkEnvironment 和 NetworkBufferPool 是 Task 之间共享的,每个 TM 只会实例化一个。
  2. Task 线程启动时,会向 NetworkEnvironment 注册,NetworkEnvironment 会为 Task 的 InputGate(IG)和 ResultPartition(RP) 分别创建一个 LocalBufferPool(缓冲池)并设置可申请的 MemorySegment(内存块)数量。IG 对应的缓冲池初始的内存块数量与 IG 中 InputChannel 数量一致,RP 对应的缓冲池初始的内存块数量与 RP 中的 ResultSubpartition 数量一致。不过,每当创建或销毁缓冲池时,NetworkBufferPool 会计算剩余空闲的内存块数量,并平均分配给已创建的缓冲池。注意,这个过程只是指定了缓冲池所能使用的内存块数量,并没有真正分配内存块,只有当需要时才分配。为什么要动态地为缓冲池扩容呢?因为内存越多,意味着系统可以更轻松地应对瞬时压力(如GC),不会频繁地进入反压状态,所以我们要利用起那部分闲置的内存块。
  3. 在 Task 线程执行过程中,当 Netty 接收端收到数据时,为了将 Netty 中的数据拷贝到 Task 中,InputChannel(实际是 RemoteInputChannel)会向其对应的缓冲池申请内存块(上图中的①)。如果缓冲池中也没有可用的内存块且已申请的数量还没到池子上限,则会向 NetworkBufferPool 申请内存块(上图中的②)并交给 InputChannel 填上数据(上图中的③和④)。如果缓冲池已申请的数量达到上限了呢?或者 NetworkBufferPool 也没有可用内存块了呢?这时候,Task 的 Netty Channel 会暂停读取,上游的发送端会立即响应停止发送,拓扑会进入反压状态。当 Task 线程写数据到 ResultPartition 时,也会向缓冲池请求内存块,如果没有可用内存块时,会阻塞在请求内存块的地方,达到暂停写入的目的。
  4. 当一个内存块被消费完成之后(在输入端是指内存块中的字节被反序列化成对象了,在输出端是指内存块中的字节写入到 Netty Channel 了),会调用 Buffer.recycle() 方法,会将内存块还给 LocalBufferPool (上图中的⑤)。如果LocalBufferPool中当前申请的数量超过了池子容量(由于上文提到的动态容量,由于新注册的 Task 导致该池子容量变小),则LocalBufferPool会将该内存块回收给 NetworkBufferPool(上图中的⑥)。如果没超过池子容量,则会继续留在池子中,减少反复申请的开销。

3.Flink中的背压

Flink运行时的构造部件是operators以及streams。每一个operator消费一个中间/过渡状态的流,对它们进行转换,然后生产一个新的流。描述这种机制最好的类比是:Flink使用有效的分布式阻塞队列来作为有界的缓冲区。如同Java里通用的阻塞队列跟处理线程进行连接一样,一旦队列达到容量上限,一个相对较慢的接受者将拖慢发送者。

以下面这个示例(两个task组成的一个简单的flow)来看Flink如何应对背压:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NSFRVND9-1612761411294)(https://www.da-platform.com/hs-fs/hubfs/Imported_Blog_Media/buffer-pools-1.jpg?width=783&height=164&name=buffer-pools-1.jpg)]

  1. 记录“A”进入Flink,然后被Task 1处理
  2. 记录被序列化进缓冲区
  3. 缓冲区内的数据被移动到Task 2,task 2会从缓冲区内读取记录

这里有一个重要的事实:为了记录能被Flink处理,缓冲区必须是可用的。 在Flink中这些分布式的队列被认为是逻辑流,而它们的有界容量可以通过每一个生产、消费流管理的缓冲池获得。缓冲池是缓冲区的集合,它们都可以在被消费完之后循环利用。这个观点很好理解:你从池里获取一个缓冲区,填进数据,然后在数据被消费后,将该缓冲区返还回缓冲池,之后你还可以再次使用它。

这些缓冲池的大小在运行时能动态变化。在不同的发送者/接收者存在不同的处理速度的情况下,网络栈里的内存缓冲区的数量(等于队列的容量)决定了系统能够提供的缓冲区的数量。Flink保证总是有足够的缓冲区提供给应用程序,但处理的速度是由用户的程序以及可用内存的数量决定的。内存越多,意味着系统可以轻松应对一定的瞬时背压(short periods,short GC)。越少的内存意味着需要对背压进行更多的“即时”响应(意思是,如果内存少缓冲区就容易被填满,那么需要立即作出响应,消费走数据才能应对这个问题)。

回到上面那个简单的示例:Task 1在其输出端被分配了一个缓冲池,Task 2在其输入端也有一个。如果当前有一个缓冲区可供序列化的“A”使用,我们就序列化它然后分配该缓冲区。

我们来看两种场景:

  • 本地传输:如果task1和task2都运行在同一个工作节点(TaskManager),缓冲区可以被直接共享给下一个task,一旦task 2消费了数据它会被回收。如果task 2比task 1慢,buffer会以比task 1填充的速度更慢的速度进行回收从而迫使task 1降速。
  • 远程传输:如果task 1和task 2运行在不同的工作节点上。一旦缓冲区内的数据被发送出去(TCP Channel),它就会被回收。在接收端,数据被拷贝到输入缓冲池的缓冲区中,如果没有缓冲区可用,从TCP连接中的数据读取动作将会被中断。输出端通常以watermark机制来保证不会有太多的数据在传输途中。如果有足够的数据已经进入可发送状态,会等到情况稳定到阈值以下才会进行发送。这可以保证没有太多的数据在路上。如果新的数据在消费端没有被消费(因为没有可用的缓冲区),这种情况会降低发送者发送数据的速度。

这个在固定大小的缓冲池之间的流示例,保证了Flink健壮的背压机制,从而使得task生产数据的速度跟消费的速度对等。

我们描述的这个方案可以从两个task之间的数据传输自然地扩展到更复杂的pipeline中,并保证背压在整个pipeline上扩散。

Netty 水位值机制

下方的代码是初始化 NettyServer 时配置的水位值参数。

// 默认高水位值为2个buffer大小, 当接收端消费速度跟不上,发送端会立即感知到
bootstrap.childOption(ChannelOption.WRITE_BUFFER_LOW_WATER_MARK, config.getMemorySegmentSize() + 1);
bootstrap.childOption(ChannelOption.WRITE_BUFFER_HIGH_WATER_MARK, 2 * config.getMemorySegmentSize());

当输出缓冲中的字节数超过了高水位值, 则 Channel.isWritable() 会返回false。当输出缓存中的字节数又掉到了低水位值以下, 则 Channel.isWritable() 会重新返回true。Flink 中发送数据的核心代码在 PartitionRequestQueue 中,该类是 server channel pipeline 的最后一层。发送数据关键代码如下所示。

private void writeAndFlushNextMessageIfPossible(final Channel channel) throws IOException {
    if (fatalError) {
        return;
    }

    Buffer buffer = null;

    try {
        // channel.isWritable() 配合 WRITE_BUFFER_LOW_WATER_MARK 
        // 和 WRITE_BUFFER_HIGH_WATER_MARK 实现发送端的流量控制
        if (channel.isWritable()) {
            // 注意: 一个while循环也就最多只发送一个BufferResponse, 连续发送BufferResponse是通过writeListener回调实现的
            while (true) {
                if (currentPartitionQueue == null && (currentPartitionQueue = queue.poll()) == null) {
                    return;
                }

                buffer = currentPartitionQueue.getNextBuffer();

                if (buffer == null) {
                    // 跳过这部分代码
                    ...
                }
                else {
                    // 构造一个response返回给客户端
                    BufferResponse resp = new BufferResponse(buffer, currentPartitionQueue.getSequenceNumber(), currentPartitionQueue.getReceiverId());

                    if (!buffer.isBuffer() &&
                            EventSerializer.fromBuffer(buffer, getClass().getClassLoader()).getClass() == EndOfPartitionEvent.class) {
                        // 跳过这部分代码。batch 模式中 subpartition 的数据准备就绪,通知下游消费者。
                        ...
                    }

                    // 将该response发到netty channel, 当写成功后, 
                    // 通过注册的writeListener又会回调进来, 从而不断地消费 queue 中的请求
                    channel.writeAndFlush(resp).addListener(writeListener);

                    return;
                }
            }
        }
    }
    catch (Throwable t) {
        if (buffer != null) {
            buffer.recycle();
        }

        throw new IOException(t.getMessage(), t);
    }
}
// 当水位值降下来后(channel 再次可写),会重新触发发送函数
@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
    writeAndFlushNextMessageIfPossible(ctx.channel());
}

核心发送方法中如果channel不可写,则会跳过发送。当channel再次可写后,Netty 会调用该Handle的 channelWritabilityChanged 方法,从而重新触发发送函数。

让我们看一个简单的实验,它展示了Flink遇到背压问题后的表现:我们运行一个简单的生产者-消费者流拓扑,主要的功能是在本地的task之间传输数据,我们在task生产记录时改变它的速度。就本次测试而言,我们使用比默认配置更少的内存来使得背压问题得到凸显。我们为每个task配备两个大小为4096B(byte)的缓冲区。在通常的Flink部署场景中,task的缓冲区数量会比这更多,容量也会更大。另外,这个测试运行在单一的JVM中,但使用了完整的Flink功能栈。

下面这张图显示了:随着时间的改变,生产者(黄色线)和消费者(绿色线)基于所达到的最大吞吐(在单一JVM中每秒达到8百万条记录)的平均吞吐百分比。我们通过衡量task每5秒钟处理的记录数来衡量平均吞吐。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VYMFqW4U-1612761411295)(https://www.da-platform.com/hs-fs/hubfs/Imported_Blog_Media/backpressure-experiment-small-1.png?width=2141&name=backpressure-experiment-small-1.png)]
首先,我们运行生产者task到它最大生产速度的60%(我们通过Thread.sleep()来模拟降速)。消费者以同样的速度处理数据。然后,我们将消费task的速度降至其最高速度的30%。你就会看到背压问题产生了,正如我们所见,生产者的速度也自然降至其最高速度的30%。接着,我们对消费者停止人为降速,之后生产者和消费者task都达到了其最大的吞吐。接下来,我们再次将消费者的速度降至30%,pipeline给出了立即响应:生产者的速度也被自动降至30%。最后,我们再次停止限速,两个task也再次恢复100%的速度。这所有的迹象表明:生产者和消费者在pipeline中的处理都在跟随彼此的吞吐而进行适当的调整,这就是我们在流pipeline中描述的行为。

4.总结

Flink与持久化的source(例如kafka),能够为你提供即时的背压处理,而无需担心数据丢失。Flink不需要一个特殊的机制来处理背压,因为Flink中的数据传输相当于已经提供了应对背压的机制。因此,Flink所获得的最大吞吐量由其pipeline中最慢的部件决定。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值