-
Flink 流处理为什么需要网络流控?
-
Flink V1.5 版之前网络流控介绍
-
Flink V1.5 版之前的反压策略存在的问题
-
Credit的反压策略实现原理,Credit是如何解决 Flink 1.5 之前的问题?
-
对比spark,都说flink延迟低,来一条处理一条,真是这样吗?其实Flink内部也有Buffer机制,Buffer机制具体是如何实现的?
-
Flink 如何在吞吐量和延迟之间做权衡?
如果一个平时正常的 Flink 任务突然出现延迟了,怎么来定位问题?到底是 Kafka 读取数据慢,还是中间某个计算环节比较消耗资源使得变慢,还是由于最后的写入外部存储时比较慢?
Flink 流处理为什么需要网络流控?
具体流程:首先从 Kafka 中读取数据、 map 算子对数据进行转换、keyBy 按照指定 key 对数据进行分区(相同 key 的数据经过 keyBy 后分到同一个 subtask 实例中),keyBy 后对数据接着进行 map 转换,然后使用 Sink 将数据输出到外部存储。
图中,任务被切分成 4 个可独立执行的 subtask( A0、A1、B0、B1),在数据处理过程中,就会存在 shuffle(数据传输)的过程。例如,subtask A0 处理完的数据经过 keyBy 后发送到 subtask B0、B1 所在节点去处理。
那么问题来了,下图中,上游 Producer 向下游 Consumer 发送数据,在发送端和接受端都有相应的 Send Buffer 和 Receive Buffer,但是上游 Producer 生成数据的速率比下游 Consumer 消费数据的速率快。Producer 生产数据 2MB/s, Consumer 消费数据 1MB/s,Receive Buffer 只有 5MB,所以过了5秒后,接收端的 Receive Buffer 满了。(可以把下图中的 Producer 当做上面案例中的 subtask A0,把下图中的 Consumer 当做上面案例中的 subtask B0)
下游接收区的 Receive Buffer 有限,如果上游一直有源源不断的数据,那么将会面临着以下两个情况:
-
下游消费者会丢弃新到达的数据,因为下游消费者的缓冲区放不下
-
为了不丢弃数据,所以下游消费者的 Receive Buffer 持续扩张,最后耗尽消费者的内存,OOM,程序挂掉
这两种情况在实际环境都是不能接受的,第一种会把数据丢弃、第二种会把我们的应用程序挂掉。
那么如何解决这个问题尼?
该问题的解决方案不应该是下游 Receive Buffer 一直累积数据,而是上游 Producer 发现下游 Consumer 处理比较慢之后,应该在 Producer 端做出限流的策略,防止在下游 Consumer 端无限制的数据堆积。
静态限速:
静态限速:提前已知下游 Consumer 的消费速率,然后通过在上游 Producer 端使用类似令牌桶的思想,限制 Producer 端生产数据的速率,从而控制上游 Producer 端向下游 Consumer 端发送数据的速率。
问题:下流消费速度不好确定控制
-
通常无法事先预估下游 Consumer 端能承受的最大速率
-
就算通过某种方式预估出下游 Consumer 端能承受的最大速率,下游应用程序也可能会因为网络抖动、 CPU 共享竞争、内存紧张、IO阻塞等原因造成下游应用程序的吞吐量降低,然后又会出现上面所说的下游接收区的 Receive Buffer 有限,上游一直有源源不断的数据发送到下游的问题,还是会造成下游要么丢数据,要么为了不丢数据 buffer 不断扩充导致下游 OOM的问题
动态限速:
下游 Consumer 端会频繁地向上游 Producer 端进行动态反馈,告诉 Producer 下游 Consumer 的负载能力,从而 Producer 端动态调整向下游 Consumer 发送数据的速率实现 Producer 端的动态限流。当 Consumer 端处理较慢时,Consumer 将负载反馈到 Producer 端,Producer端会根据反馈适当降低 Producer 自身从上游或者 Source 端读数据的速率来降低向下游 Consumer 发送数据的速率。当 Consumer 处理负载能力提升后,又及时向 Producer 端反馈,Producer 会通过提升从上游或 Source 端读数据的速率来提升向下游发送数据的速率。通过这个动态反馈来提升整个系统的吞吐量。
当下游 Task C 的 Receive Buffer 满了,如何告诉上游 Task B 应该降低数据发送速率
当下游 Task C 的 Receive Buffer 空了,如何告诉上游 Task B 应该提升数据发送速率
注:这里又分了两种情况,Task B 和 Task C 可能在同一台节点上运行,也有可能不在同一个台节点运行
Task B 和 Task C 在同一台节点上运行指的是:一台节点运行了一个或多个 TaskManager,包含了多个 Slot,Task B 和 Task C 都运行在这台节点上,且 Task B 是 Task C 的上游,给 Task C 发送数据。此时 Task B 给 Task C 发送数据实际上是同一个 JVM 内的数据发送,所以不存在网络通信
Task B 和 Task C 不在同一台节点上运行指的是:Task B 和 Task C 运行在不同的 TaskManager 中,且 Task B 是 Task C 的上游,给 Task C 发送数据。此时 Task B 给 Task C 发送数据是跨节点的,所以会存在网络通信
现在有两个 TaskManager A、B,TaskManager A 中 Producer Operator 处理完的数据由 TaskManager B 中 Consumer Operator 处理。那么 Producer Operator 处理完的数据是怎么到达 Consumer Operator 的?首先 Producer Operator 从自己的上游或者外部数据源读取到数据后,对一条条的数据进行处理,处理完的数据首先输出到 Producer Operator 对应的 NetWork Buffer 中。Buffer 写满或者超时后,就会触发将 NetWork Buffer 中的数据拷贝到 Producer 端 Netty 的 ChannelOutbound Buffer,之后又把数据拷贝到 Socket 的 Send Buffer 中,这里有一个从用户态拷贝到内核态的过程,最后通过 Socket 发送网络请求,把 Send Buffer 中的数据发送到 Consumer 端的 Receive Buffer。数据到达 Consumer 端后,再依次从 Socket 的 Receive Buffer 拷贝到 Netty 的 ChannelInbound Buffer,再拷贝到 Consumer Operator 的 NetWork Buffer,最后 Consumer Operator 就可以读到数据进行处理了。这就是两个 TaskManager 之间的数据传输过程,我们可以看到发送方和接收方各有三层的 Buffer。
Producer 端生产数据速率为 2,Consumer 消费数据速率为 1。持续下去,下游消费较慢,Buffer 容量又是有限的,那 Flink 反压是怎么做的?
场景:Producer 端生产数据速率为2,Consumer 端消费数据速率为1。数据从 Task A 的 ResultSubPartition 按照上面的流程最后传输到 Task B 的 InputChannel 供 Task B 读取并计算。持续一段时间后,由于 Task B 消费比较慢,导致 InputChannel 被占满了,所以 InputChannel 向 LocalBufferPool 申请新的 Buffer 空间,LocalBufferPool 分配给 InputChannel 一些 Buffer。
再持续一段时间后,InputChannel 重复向 LocalBufferPool 申请 Buffer 空间,导致 LocalBufferPool 也满了,所以 LocalBufferPool 向 NetWork BufferPool 申请 Buffer 空间,NetWork BufferPool 给 LocalBufferPool 分配 Buffer。
再持续下去,NetWork BufferPool 满了,或者说 NetWork BufferPool 不能把自己的 Buffer 全分配给 Task B 对应的 LocalBufferPool ,因为 TaskManager 上一般会运行了多个 Task,每个 Task 只能使用 NetWork BufferPool 中的一部分。所以,可以认为 Task B 把自己可以使用的 InputChannel 、 LocalBufferPool 和 NetWork BufferPool 都用完了。此时 Netty 还想把数据写入到 InputChannel,但是发现 InputChannel 满了,所以 Socket 层会把 Netty 的 autoRead disable,Netty 不会再从 Socket 中去读消息。数据已经不能往下游写了,发生了阻塞。
由于 Netty 不从 Socket 的 Receive Buffer 读数据了,所以很快 Socket 的 Receive Buffer 就会变满,TCP 的 Socket 通信有动态反馈的流控机制,会把容量为0的消息反馈给上游发送端,所以上游的 Socket 就不会往下游再发送数据 。
Task A 持续生产数据,发送端 Socket 的 Send Buffer 很快被打满,所以 Task A 端的 Netty 也会停止往 Socket 写数据。接下来,数据会在 Netty 的 Buffer 中缓存数据,但 Netty 的 Buffer 是无界的。但可以设置 Netty 的高水位,即:设置一个 Netty 中 Buffer 的上限。所以每次 ResultSubPartition 向 Netty 中写数据时,都会检测 Netty 是否已经到达高水位,如果达到高水位就不会再往 Netty 中写数据,防止 Netty 的 Buffer 无限制的增长。
接下来,数据会在 Task A 的 ResultSubPartition 中累积,ResultSubPartition 满了后,会向 LocalBufferPool 申请新的 Buffer 空间,LocalBufferPool 分配给 ResultSubPartition 一些 Buffer。
持续下去 LocalBufferPool 也会用完,LocalBufferPool 再向 NetWork BufferPool 申请 Buffer。
然后 NetWork BufferPool 也会用完,或者说 NetWork BufferPool 不能把自己的 Buffer 全分配给 Task A 对应的 LocalBufferPool ,因为 TaskManager 上一般会运行了多个 Task,每个 Task 只能使用 NetWork BufferPool 中的一部分。此时,Task A 已经申请不到任何的 Buffer 了,Task A 的 Record Writer 输出就被 wait ,Task A 不再生产数据。
Task 内部,反压如何向上游传播
假如 Task A 的下游所有 Buffer 都占满了,那么 Task A 的 Record Writer 会被 block,Task A 的 Record Reader、Operator、Record Writer 都属于同一个线程,所以 Task A 的 Record Reader 也会被 block。
然后可以把这里的 Task A 类比成上面所说的 Task B,Task A 上游持续高速率发送数据到 Task A 就会导致可用的 InputChannel、 LocalBufferPool 和 NetWork BufferPool 都会被用完。然后 Netty 、Socket 同理将压力传输到 Task A 的上游。
假设 Task A 的上游是 Task X,那么 Task A 将压力反馈给 Task X 的过程与 Task B 将压力反馈给 Task A 的过程是一样的。整个 Flink 的反压是从下游往上游传播的,一直传播到 Source Task,Source Task 有压力后,会降低从外部组件中读取数据的速率,例如:Source Task 会降低从 Kafka 中读取数据的速率,来降低整个 Flink Job 中缓存的数据,从而降低负载。
Credit的反压策略实现原理
反压机制作用于 Flink 的应用层,即在 ResultSubPartition 和 InputChannel 这一层引入了反压机制。每次上游 SubTask A.2 给下游 SubTask B.4 发送数据时,会把 Buffer 中的数据和上游 ResultSubPartition 堆积的数据量 Backlog size发给下游,下游会接收上游发来的数据,并向上游反馈目前下游现在的 Credit 值,Credit 值表示目前下游可以接收上游的 Buffer 量,1 个Buffer 等价于 1 个 Credit 。
例如,上游 SubTask A.2 发送完数据后,还有 5 个 Buffer 被积压,那么会把发送数据和 Backlog size = 5 一块发送给下游 SubTask B.4,下游接受到数据后,知道上游积压了 5 个Buffer,于是向 Buffer Pool 申请 Buffer,由于容量有限,下游 InputChannel 目前仅有 2 个 Buffer 空间,所以,SubTask B.4 会向上游 SubTask A.2 反馈 Channel Credit = 2。然后上游下一次最多只给下游发送 2 个 Buffer 的数据,这样每次上游发送的数据都是下游 InputChannel 的 Buffer 可以承受的数据量,所以通过这种反馈策略,保证了不会在公用的 Netty 和 TCP 这一层数据堆积而影响其他 SubTask 通信。
Credit 只是解决了反压的问题,并不能优化低延迟的吞吐量。
Flink 如何在吞吐量和延迟之间做权衡?
每个 SubTask 输出的数据并不是直接输出到下游,而是在 ResultSubPartition 中有一个 Buffer 用来缓存一批数据后,再 Flush 到 Netty 发送到下游 SubTask。那到底哪些情况会触发 Buffer Flush 到 Netty 呢?
-
Buffer 变满时
-
Buffer timeout 时
-
特殊事件来临时,例如:CheckPoint 的 barrier 来临时
Flink 在数据传输时,会把数据序列化成二进制然后写到 Buffer 中,当 Buffer 满了,需要 Flush(默认为32KiB,通过taskmanager.memory.segment-size设置)。但是当流量低峰或者测试环节,可能1分钟都没有 32 KB的数据,就会导致1分钟内的数据都积攒在 Buffer 中不会发送到下游 Task 去处理,从而导致数据出现延迟,这并不是我们想看到的。所以 Flink 有一个 Buffer timeout 的策略,意思是当数据量比较少,Buffer 一直没有变满时,后台的 Output flusher 线程会强制地将 Buffer 中的数据 Flush 到下游。Flink 中默认 timeout 时间是 100ms,即:Buffer 中的数据要么变满时 Flush,要么最多等 100ms 也会 Flush 来保证数据不会出现很大的延迟。当然这个可以通过 env.setBufferTimeout(timeoutMillis) 来控制超时时间。
- timeoutMillis > 0 表示最长等待 timeoutMillis 时间,就会flush
- timeoutMillis = 0 表示每条数据都会触发 flush,直接将数据发送到下游,相当于没有Buffer了(避免设置为0,可能导致性能下降)
- timeoutMillis = -1 表示只有等到 buffer满了或 CheckPoint的时候,才会flush。相当于取消了 timeout 策略