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

一、背压机制实现总结

  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,并判断是否有足够的缓存

java 复制代码
 /**
* 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.请求浮动缓存区,如果请求不到,注册监听器

java 复制代码
 /**
* 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对象。

java 复制代码
 /**
* 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

相关推荐
儿时可乖了4 分钟前
使用 Java 操作 SQLite 数据库
java·数据库·sqlite
ruleslol5 分钟前
java基础概念37:正则表达式2-爬虫
java
智慧化智能化数字化方案11 分钟前
华为IPD流程管理体系L1至L5最佳实践-解读
大数据·华为
xmh-sxh-131422 分钟前
jdk各个版本介绍
java
天天扭码41 分钟前
五天SpringCloud计划——DAY2之单体架构和微服务架构的选择和转换原则
java·spring cloud·微服务·架构
程序猿进阶41 分钟前
堆外内存泄露排查经历
java·jvm·后端·面试·性能优化·oom·内存泄露
FIN技术铺1 小时前
Spring Boot框架Starter组件整理
java·spring boot·后端
小曲程序1 小时前
vue3 封装request请求
java·前端·typescript·vue
陈王卜1 小时前
django+boostrap实现发布博客权限控制
java·前端·django
小码的头发丝、1 小时前
Spring Boot 注解
java·spring boot