Flink背压问题:从原理到源码

2 阅读14分钟

一、背压机制实现总结

  1. Flink对于背压的处理是通过在任务传递之间设置有界容量的数据缓冲区, 当整个管道中有一个下游任务速度变慢,会导致缓存区数据变满,上游任务获取不到可用的缓冲区,自然而然地被阻塞和降速, 这就实现了背压。

不同taskManager通信通过Netty, Netty的 Buffer 是无界的,但可以设置 Netty 的高水位

  1. 如果同一 Task 的不同 SubTask 被安排到同一个TaskManager,则它们与其他 TaskManager 的网络连接将被多路复用并共享一个TCP信道以减少资源使用。这样会导致其中一个子任务流程受阻,其余子任务也会被连累受阻。为此flink1.5后通过Credit的反压策略,即通过反馈Credit(即缓存区的数量)的方式,巧妙的跳过共享通道,将数据堆积在子任务缓存区中。

二、背压的定义

背压是指系统在临时负载峰值期间接收数据的速率高于其可以处理的速率的情况

三、如何处理背压

3.1 Flink任务流程天然的提供了应对背压能力

如果产生背压,理想的应对方法是flink整个任务流程会限制源以调整速度匹配管道中最慢的部分,达到稳态

源会被限制速度,与管道其他部分匹配,避免数据堆积。

根据介绍: Flink中的流在算子之间传输数据,就像连接线程的阻塞队列一样起到缓冲和限速的作用。当下游算子处理速度较慢时,上游算子会被限速。这种机制天然地为Flink提供了背压能力。

但是,为什么当下游算子处理速度较慢时,上游算子会被限速?

3.1.1 从flink任务执行流程看:

一个flink的任务流程如下:

  1. 记录“A”进入Flink,并由Task 1处理。

  2. 记录被序列化到一个缓冲区。

  3. 这个缓冲区被发给Task 2,然后Task 2从缓冲区读取记录。

flink通过在任务传递之间设置有界容量的数据缓冲区, 实现了流量控制和背压。如果下游任务的处理速度较慢,上游任务会被阻塞并降低速度,防止缓冲区溢出。这种机制天然地为Flink提供了背压能力。

当下游任务处理速度较慢时,上游任务获取不到可用的缓冲区,自然而然地被阻塞和降速,这就实现了背压。

Task 1在输出端有一个相关的缓冲池,Task 2在输入端有一个。如果有可用缓冲区对“A”进行序列化,我们就对其进行序列化并分发这个缓冲区。

3.1.2 flink任务执行的三种情况

  • 远程交换:如果Task 1和Task 2运行在不同的工作节点上,那么缓冲区可以在被发送到网络上时就回收(TCP通道)。在接收端Task 2,数据从网络复制到输入缓冲池的一个缓冲区。如果没有可用缓冲区,就中断从TCP连接的读取。输出端Task 1通过一个简单的水位线机制永远不会在网络上放太多数据。如果网络上有超过阈值的数据在等待传输,我们在输入数据进网络之前等待,直到数据量低于一个阈值。这保证了网络上永远不会有过多的数据。如果接收端由于没有可用缓冲区而不消费新数据,这会放慢发送者的速度。

TCP 的 Socket 通信有动态反馈的流控机制,会把容量为0的消息反馈给上游发送端,所以上游的 Socket 就不会往下游再发送数据 。

Netty 的 Buffer 是无界的,但可以设置 Netty 的高水位,即:设置一个 Netty 中 Buffer 的上限。所以每次 ResultSubPartition 向 Netty 中写数据时,都会检测 Netty 是否已经到达高水位,如果达到高水位就不会再往 Netty 中写数据,防止 Netty 的 Buffer 无限制的增长。

  • 本地交换:如果Task 1和Task 2都在同一个工作节点(TaskManager)上运行,缓冲区可以直接交给下一个任务,而不需要通过TCP通道传输,本地交换背压处理机制与远程交换一致。一旦Task 2消费完毕,缓冲区就会被回收。如果Task 2比Task 1慢,那么回收缓冲区的速度会低于Task 1填充它们的速度,这时所有的缓冲区都会被填满,从而导致Task 1获取不到可用的缓冲区而放慢速度
  • Task内部流程:与上两种情况相同。 假如 Task A 的下游所有 Buffer 都占满了,那么 Task A 的 Record Writer 会被 block,Task A 的 Record Reader、Operator、Record Writer 都属于同一个线程,所以 Task A 的 Record Reader 也会被 block。

3.1.3 总结

在固定大小的缓冲池之间简单流动缓冲区,使Flink能够具有一个健壮的背压机制,任务永远不会比可消费的速度更快地产生数据。我们描述的在两个任务之间发送数据的机制,自然地推广到了复杂的管道,保证了整个管道中的背压传播。

通过这种缓冲区流动和回收的机制,整个Flink流水线形成了一个闭环的流量控制系统。下游任务处理慢时会限制上游任务,背压遵循着数据流的反方向,自发地在整个任务流程中传播,使得上游任务永远不会产生过多的待消费数据而导致过载。这使Flink能够在保证吞吐量的同时优雅地应对负载的波动。

  1. 如果接收端Task 2消费数据的速度变慢,缓冲池中缓冲区占满,输出端Task 1输出数据的速度会因为获取不到缓存区或水位线机制而变慢
  2. 如果输出端Task 1输出数据的速度变慢,缓冲池中无可用缓冲区,接收端Task 2消费数据的速度会因为无数据可用而变慢

3.2 Credit的反压策略

3.2.1 flink通过有界缓存的背压策略存在的问题

如下图所示,我们的任务有4个 SubTask,SubTask A 是 SubTask B的上游,即 SubTask A 给 SubTask B 发送数据。Job 运行在两个 TaskManager中, TaskManager 1 运行着 SubTask A.1 和 SubTask A.2, TaskManager 2 运行着 SubTask B.3 和 SubTask B.4。

现在假如由于CPU共享或者内存紧张或者磁盘IO瓶颈造成 SubTask B.4 遇到瓶颈、处理速率有所下降,但是上游源源不断地生产数据,所以导致 SubTask A.2 与 SubTask B.4 这一条链路产生反压。

不同 Job 之间的每个远程网络连接将在 Flink 的网络堆栈中获得自己的TCP通道。 但是,如果同一 Task 的不同 SubTask 被安排到同一个TaskManager,则它们与其他 TaskManager 的网络连接将被多路复用并共享一个TCP信道以减少资源使用

例如,图中的 A.1 -> B.3、A.1 -> B.4、A.2 -> B.3、A.2 -> B.4 这四条将会多路复用共享一个 TCP 信道。

现在 SubTask B.3 并没有压力,但是当上图中 SubTask A.2 与 SubTask B.4 产生反压时,会把 TaskManager1 端该任务对应 Socket 的 Send Buffer 和 TaskManager2 端该任务对应 Socket 的 Receive Buffer 占满,共享的多路复用的 TCP 通道已经被占满了,会导致 SubTask A.1 和 SubTask A.2 要发送给 SubTask B.3 的数据也被阻塞了,从而导致本来没有压力的 SubTask B.3 现在接收不到数据了

3.2.2 Credit的反压策略原理

flink1.5之后采用了Credit的反压策略

Credit的反压机制作用于 Flink 的应用层,即在 上游任务输出端ResultSubPartition 和 下游任务输入端InputChannel 这一层引入了反压机制。

每次上游 SubTask A.2 给下游 SubTask B.4 发送数据时,会把 Buffer 中的数据和上游 ResultSubPartition 堆积的数据量 Backlog size发给下游,下游会接收上游发来的数据,并向上游反馈目前下游现在的 Credit 值,Credit 值表示目前下游可以接收上游的 Buffer 量,1 个Buffer 等价于 1 个 Credit 。

  1. 上游 SubTask A.2 发送完数据后,还有 5 个 Buffer 被积压,那么会把发送数据和 Backlog size = 5 一块发送给下游 SubTask B.4
  2. 下游接收到数据后,知道上游积压了 5 个Buffer,于是向 Buffer Pool 申请 Buffer,由于容量有限,下游 InputChannel 目前仅有 2 个 Buffer 空间,
  3. 所以,SubTask B.4 会向上游 SubTask A.2 反馈 Channel Credit = 2。然后上游下一次最多只给下游发送 2 个 Buffer 的数据,

这样每次上游发送的数据都是下游 InputChannel 的 Buffer 可以承受的数据量,所以通过这种反馈策略,保证了不会在公用的 Netty 和 TCP 这一层数据堆积而影响其他 SubTask 通信。

当上游接受到下游反馈的 credit = 0,然后上游就不会发送数据到 Netty,巧妙的避免了在公用的 Netty 和 TCP 这一层的数据堆积。当然,上游仍会会定期地仅发送 backlog size 给下游,直到下游反馈 credit > 0 时,上游就会继续发送真正的数据到下游了。

四、具体实现源码

4.1 参考源码(不知道具体版本)

当可用的buffer数 <(挤压的数据量 + 已经分配给信任Credit的buffer量) 时,就会向Pool中继续请求buffer,这里请求不到也会一直while形成柱塞反压

直到有足够的然后通过notifyCreditAvailable()方法发送Credit。

4.2 flink1.12 源码

具体方法类RemoteInputChannel.java

4.2.1.获取上游响应的backlog,并判断是否有足够的缓存

 /**
* Receives the backlog from the producer's buffer response. If the number of available buffers
* is less than backlog + initialCredit, it will request floating buffers from the buffer
* manager, and then notify unannounced credits to the producer.
* todo 当可用的buffer数 <(挤压的数据量 + 已经分配给信任Credit的buffer量) 时,就会向Pool中继续请求buffer,这里请求不到也会一直while形成反压
*  @param  backlog The number of unsent buffers in the producer's sub partition.
*/
void onSenderBacklog(int backlog) throws IOException {
    int numRequestedBuffers = bufferManager.requestFloatingBuffers(backlog + initialCredit);
    if (numRequestedBuffers > 0 && unannouncedCredit.getAndAdd(numRequestedBuffers) == 0) {
        notifyCreditAvailable();
    }
}

4.2.2.请求浮动缓存区,如果请求不到,注册监听器

 /**
* Requests floating buffers from the buffer pool based on the given required amount, and
* returns the actual requested amount. If the required amount is not fully satisfied, it will
* register as a listener.
* 根据给定的所需数量从缓冲池中请求浮动缓冲区,并返回实际请求的数量。如果没有完全满足所需的数量,它将注册为侦听器。
* todo
*/
int requestFloatingBuffers(int numRequired) {
    int numRequestedBuffers = 0;
    synchronized (bufferQueue) {
        // Similar to notifyBufferAvailable(), make sure that we never add a buffer after
        // channel
        // released all buffers via releaseAllResources().
        if (inputChannel.isReleased()) {
            return numRequestedBuffers;
        }

        numRequiredBuffers = numRequired;
        // 监听直到有足够的buffer
        while (bufferQueue.getAvailableBufferSize() < numRequiredBuffers
                && !isWaitingForFloatingBuffers) {
            BufferPool bufferPool = inputChannel.inputGate.getBufferPool();
            Buffer buffer = bufferPool.requestBuffer();
            if (buffer != null) {
                bufferQueue.addFloatingBuffer(buffer);
                numRequestedBuffers++;
            } else if (bufferPool.addBufferListener(this)) {
                isWaitingForFloatingBuffers = true;
                break;
            }
        }
    }
    return numRequestedBuffers;
}
  1. 首先,如果inputChannel已经释放,则直接返回,不再请求任何缓冲区。
  2. 将需要的缓冲区数量赋值给numRequiredBuffers变量。
  3. 然后进入一个循环,在循环中尝试从BufferPool获取缓冲区,并将获取到的缓冲区添加到bufferQueue中。循环条件是bufferQueue中可用缓冲区数量小于所需数量,并且当前不在等待浮动缓冲区的状态。
  4. 在循环中,首先从inputChannel.inputGate.getBufferPool()获取一个缓冲区。如果获取成功,则将缓冲区添加到bufferQueue中,并增加numRequestedBuffers计数器。
  5. 如果从BufferPool中获取缓冲区失败,则调用bufferPool.addBufferListener(this)方法,将当前对象注册为缓冲区监听器。如果注册成功,则设置isWaitingForFloatingBuffers标志为true,并退出循环。
  6. 最后,返回实际获取到的浮动缓冲区数量numRequestedBuffers

4.2.3 监听器通知

BufferPool有新的浮动缓冲区可用时,它会调用已注册监听器的notifyBufferAvailable方法,传入可用的Buffer对象。

 /**
* The buffer pool notifies this listener of an available floating buffer. If the listener is
* released or currently does not need extra buffers, the buffer should be returned to the
* buffer pool. Otherwise, the buffer will be added into the <tt>bufferQueue</tt>.
* 缓冲池通知此侦听器一个可用的浮动缓冲区。如果侦听器已释放或当前不需要额外的缓冲区,则应将缓冲区返回到缓冲池。否则,缓冲区将被添加到<tt>缓冲队列</tt>中。
* todo 监听器通知
*  @param  buffer Buffer that becomes available in buffer pool.
*  @return  NotificationResult indicates whether this channel accepts the buffer and is waiting
*     for more floating buffers.
*/
@Override
public BufferListener.NotificationResult notifyBufferAvailable(Buffer buffer) {
    BufferListener.NotificationResult notificationResult =
            BufferListener.NotificationResult.BUFFER_NOT_USED;

    // Assuming two remote channels with respective buffer managers as listeners inside
    // LocalBufferPool.
    // While canceler thread calling ch1#releaseAllResources, it might trigger
    // bm2#notifyBufferAvaialble.
    // Concurrently if task thread is recycling exclusive buffer, it might trigger
    // bm1#notifyBufferAvailable.
    // Then these two threads will both occupy the respective bufferQueue lock and wait for
    // other side's
    // bufferQueue lock to cause deadlock. So we check the isReleased state out of synchronized
    // to resolve it.
    
    if (inputChannel.isReleased()) {
        return notificationResult;
    }

    try {
        synchronized (bufferQueue) {
            checkState(
                    isWaitingForFloatingBuffers,
                    "This channel should be waiting for floating buffers.");

            // Important: make sure that we never add a buffer after releaseAllResources()
            // released all buffers. Following scenarios exist:
            // 1) releaseAllBuffers() already released buffers inside bufferQueue
            // -> while isReleased is set correctly in InputChannel
            // 2) releaseAllBuffers() did not yet release buffers from bufferQueue
            // -> we may or may not have set isReleased yet but will always wait for the
            // lock on bufferQueue to release buffers
            if (inputChannel.isReleased()
                    || bufferQueue.getAvailableBufferSize() >= numRequiredBuffers) {
                isWaitingForFloatingBuffers = false;
                return notificationResult;
            }

            bufferQueue.addFloatingBuffer(buffer);
            bufferQueue.notifyAll();

            if (bufferQueue.getAvailableBufferSize() == numRequiredBuffers) {
                isWaitingForFloatingBuffers = false;
                notificationResult = BufferListener.NotificationResult.BUFFER_USED_NO_NEED_MORE;
            } else {
                notificationResult = BufferListener.NotificationResult.BUFFER_USED_NEED_MORE;
            }
        }

        if (notificationResult != NotificationResult.BUFFER_NOT_USED) {
            inputChannel.notifyBufferAvailable(1);
        }
    } catch (Throwable t) {
        inputChannel.setError(t);
    }

    return notificationResult;
}
  1. 检查channel状态,确保channel未释放且正在等待浮动缓冲区。
  2. 将新的浮动缓冲区buffer添加到bufferQueue中。
  3. 通知等待在bufferQueue上的线程,即调用bufferQueue.notifyAll()
  4. 根据当前可用缓冲区数量是否满足需求,设置isWaitingForFloatingBuffers标志和返回NotificationResult
  5. 如果已获得足够的缓冲区,通过inputChannel.notifyBufferAvailable(1)进一步通知上层。

五、如何定位背压

可以在Web界面,从Sink到Source这样反向逐个Task排查,找到第一个出现反压的Task,一般上Task出现反压会出现如下现象:

当 Web 页面切换到某个 Task 的 BackPressure 页面时,才会对这个Task触发反压检测。BackPressure界面会周期性的对Task线程栈信息采样,通过线程被阻塞在请求Buffer的频率来判断节点是否处于反压状态(反压就是因为Buffer不够用了,也就是内存不够用了,所以Task暂时性的阻塞住了)。默认情况下,这个频率在 0.1 以下显示为 OK,0.1 至 0.5 显示 LOW,而超过 0.5 显示为 HIGH。

通过反压状态可以大致锁定反压可能存在的算子,但具体反压是由于当前Task自身处理速度慢还是由于下游Task处理慢导致的,需要通过metric监控进一步判断。因为反压存在两种可能性:

  1. 当前Task发送的速度跟不上其接受后产生数据的速度。比如一条输入flatmap或者collect多次处理成多条输出这种情况,导致当前Task发送端申请不到足够的内存。
  2. 当前Task处理数据的速度比较慢,比如每条数据都要进行算法调用之类的,而上游Task处理数据较快,从而导致上游发送端申请不到足够的内存。

六、背压处理实验

该图显示了生产者(黄色)和消费者(绿色)任务的平均吞吐量占最大吞吐量的百分比随时间的变化。为了测量平均吞吐量,我们每5秒测量一次任务处理的记录数。

  1. 首先,我们让生产者任务以最大速度的60%运行(通过Thread.sleep()调用模拟减速)。在没有人为减速的情况下,消费者任务以相同的速度处理数据,。
  2. 然后我们将消费者任务减速到最大速度的30%。这时,背压效应就发挥作用了,我们看到生产者也自然地降低到30%的满速。
  3. 接着我们停止对消费者的人为减速,两个任务都恢复到最大吞吐量。
  4. 我们再次将消费者减速到最大速度的30%,管道立即作出反应,生产者也降低到30%的满速。
  5. 最后,我们再次停止减速,两个任务继续以100%的满速运行。

总的来说,我们看到在管道中,生产者和消费者的吞吐量互相跟随,这正是流管道所期望的行为。

通过这个实验,我们看到Flink基于其天然的数据流缓冲和控制机制,能够在整个管道中传播背压,使生产者和消费者的吞吐量自动匹配较慢的一方,防止过载同时避免数据丢失。这种简单而健壮的背压处理机制是Flink作为流式处理引擎的一大优势。

七、参考文档

深入了解 Flink 网络栈(二):监控、指标和处理背压

How Apache Flink™ handles backpressure

一文搞懂 Flink 网络流控与反压机制

netty高低水位流控(yet) - silyvin - 博客园

Flink中接收端反压以及Credit机制 (源码分析) - ljygz - 博客园

cloud.tencent.com