Flink 也经历了多种 Shuffle 模式的演变,进行了多轮迭代和优化,实现了许多创新:从最初的 Pipelined Shuffle,到 Blocking Shuffle,再到创新性地提出 Hybrid Shuffle。
1.流的shuffle
(1) 低延迟的 Pipelined Shuffle
图中不同颜色代表了组件所负责的不同功能:
- 蓝色和黄色部分代表与Shuffle框架相关的组件
- 绿色部分则是与Shuffle框架无关的其他组件。
<1> Pluggable Shuffle Service
蓝色部分,称之为Pluggable Shuffle Service,即可扩展的 Shuffle 服务,它是在 Flink 社区的 Flip31 中被提出的。考虑到用户对 Shuffle 服务的扩展需求,从 Shuffle 的一些核心组件中提取了重要的扩展点,主要包括 Shuffle Master 和 Shuffle Environment 这两个接口。抽象出这两个扩展点有什么用呢?
举个例子,Flink 默认的 Shuffle 是基于 Netty 的,也就是基于 TCP/IP 协议栈的,如果可以摒弃掉 TCP/IP 协议栈,比如有些机器上有高性能的 RDMA 网卡硬件,我们可以通过 Pluggable Shuffle Service 自定义网络传输层,将其改造成有着更高吞吐的基于 RDMA 的 Shuffle。
接下来,看看这两个接口。
- ShuffleMaster 位于 JobManager 中,是一个中心化的控制组件,主要负责管理整个集群的 Shuffle 元数据信息。而每个 TM 中都会启动一个
- ShuffleEnvironment,它是为 Task 的网络层读写服务的,会为 Task 创建用于写的 ResultPartition 和用于读的 InputGate。
误区1:误认为 Shuffle 服务与作业相关,实际上 Flink 中 Shuffle Service 是集群粒度的。如果在 Session 集群中在 Job 粒度配置了不同的 Shuffle Service,其实是不生效的,因为 Session 集群在被拉起时 Shuffle Service 的类型就已经确定了。
<2> 框架组件
最后看黄色部分,这部分是框架提供的公共组件,其中有两个需要特别注意:BufferPool 和 PartitionManager。
- BufferPool :负责管理 TaskManager 上 Buffer 资源的。Flink 上下游之间交换数据是以 Buffer 为载体的,每个 Task 在写数据前必须从 BufferPool 申请内存用于写,读数据时同样需要从 BufferPool 申请内存用于读。
- PartitionManager :负责管理 TM 侧的本地 Shuffle 资源,也即与 ResultPartition 相关的资源。
详情请看:Flink-反压和Flink-Graph
在这样的架构下,当一个作业被提交后,它会在 JobManager 中创建一个 JobMaster 对象,并开始调度。同时会向 ShuffleMaster 注册自己的 ResultPartition,并请求一些关于上游的信息,主要包括上游的位置以及如何与上游建立连接。这些信息会随着任务的部署传递到 TaskManager。到达 TaskManager 后,Task 会通过 ShuffleEnvironment 创建出对应的 ResultPartition 和 InputGate 实例,并向 PartitionManager 注册。当 Task 开始运行后,它会向 BufferPool 申请用于读写的 Buffer,与上下游周而复始地进行数据交换。
与传统的批式 Shuffle 相比,Flink 能进行流计算的关键在于其拥有低延迟的 Shuffle 服务 — Pipelined Shuffle。它是 Flink 流作业默认的 Shuffle Service,也是 Flink 能在流计算领域取得成功的关键因素之一。
Flink 作为一个实时计算引擎,对数据处理的延迟非常敏感,为此做了很多努力:
- 增强调度约束:要求上下游任务同时运行。图中可以看到,当数据从 Map 任务发出到达下游的 Partition 时,下游的 Reduce 任务已经在运行了。而传统批式 Shuffle 引擎通常要求 Map 任务全部结束后,Reduce 任务才能开始。通过增强调度约束,增加了资源开销,但实现了更低的延迟。
- 基于内存的数据交换:避免了读写磁盘的开销。Pipeline Shuffle 的上下游之间仅通过内存和网络进行交互,没有批式 Shuffle 中巨大的磁盘 I/O 开销,这对于实时任务来说至关重要。
- 支持定时 Flush:在 Flink 中,数据通过 Buffer 传输。通常情况下,需要将一个 Buffer 写满后,下游才能读取。但在实时任务中,如果某些算子的处理逻辑很复杂,可能只产出了部分数据就开始了冗长的计算逻辑。这时可以启动一个定时的 Flush 线程,将未写满的 Buffer Flush 下去,使下游实时看到最新数据。
通过这三点优化,Flink 实现了非常低延迟的 Shuffle 服务。然而,在低延迟情况下,当下游处理达到瓶颈时,会产生新的问题:如何处理内存积压?解决:Flink-反压机制
2.批的shuffle
(1) 高吞吐的 Blocking Shuffle
历史问题如下图
主要问题有以下三点:
- 稳定性:每个 SubPartition 都会产生一个文件,这会对文件系统造成巨大压力,甚至可能导致操作系统中 inode 耗尽。而批作业往往并发度都比较高,在高并发作业中会尤为放大整个问题的影响。
- 性能:大量小文件和随机 IO 会严重拖慢作业执行的性能,尤其是在 HDD 磁盘上尤为明显。
- 资源:内存与并发耦合。由于每条数据都需要写入磁盘,为了提高 Buffer 利用率减少落盘开销,至少要等到一个 Buffer 写满后才将其写入磁盘。
但这引出了一个问题:Flink 无法预测下一条数据将会被写入哪个 SubPartition。为了避免资源死锁,必须为每个 SubPartition 都至少准备一个 Buffer。因此,所需的最小 Buffer 的数量与 SubPartition 的数量(即并发数)正相关。这带来的问题是很难配置 Network Memory 的大小,因为在 Flink 中,网络内存必须在作业启动之前就指定。今天运行一个作业,可能一切正常,但如果明天增加了并发度,作业可能会因为网络内存空间不足而无法运行。
为了解决这些问题,Flink 社区在 1.13 版本中提出了Sort-Merge Shuffle,其整体架构如下所示。
相较于 Bounded Blocking Shuffle,一个明显的不同之处在于每个 MapTask 的数据现在都只写入一个文件中。这个文件既包含了 SubPartition 1 的数据,也包含了 SubPartition 2 的数据。当然,它仍然是 Blocking 的,必须等到上游数据全部处理完毕,下游才能进行处理。
在每个 ResultPartition 里,都引入了一个叫做 SortBuffer 的数据结构,它是一个固定数量的 Buffer 集合。当 ResultPartition 向 SortBuffer 写数据时,不需要关心这些数据是属于哪个 SubPartition 的。如上图所示,数据可能是随机来自不同的 SubPartition,但都被追加到同一个 SortBuffer 中了。当 SortBuffer 写满,或者达到一些特定条件之后,会将这些 Buffer 进行按照 SubPartition 的顺序进行排序。排序完成后溢写到磁盘中的 ShuffleFile 中,这里是一个追加写。每个 SortBuffer 写入磁盘之后,会形成一个 Region,它只是一个逻辑上的概念,实际上在文件中不同轮次追加写入的数据是连续的。
(2) 相关问题
首先,来看稳定性问题,之前提到稳定性问题主要是由于产生的文件数量过多。之前同一个 ResultPartition 里每一个 SubPartition 都会产生独立的文件,而现在每个 ResultPartition 只会产生一个文件,这极大地减少了文件的数量。
其次,是资源问题,之前提到的资源问题主要是因为每个 SubPartition 都需要一个 Buffer,这就导致了它所需的内存数量实际上与并发数成正比。但在当前的方案中,我们只需要固定大小的 SortBuffer,并且它包含多少个 Buffer 是一个可配置的参数。Buffer 数对性能有影响,但与并发数无关。
最后,是最重要的性能问题。这里引入了一个名为 IO Scheduling 的机制。每个下游在拉取数据时,会在上游注册一个 Reader。多个 Reader 注册的先后时机是不同的,但是进行调度时,如果按照注册顺序去读取 Shuffle 文件,就会产生严重的随机读。
为了解决这个问题,Flink 中引入了一个名为 IO Scheduler 的组件。它包含一个线程,不断对读取操作进行调度,并决定哪个 Reader 先读,哪个后读。具体的算法类似于操作系统中对磁盘进行调度的电梯算法,这里以每个 Reader 在 Shuffle 文件中要消费的下一条数据对应的文件中的 Offset(下一个要读的位置) 为比较依据,一个 Reader 需要消费的 Offset 越小,意味着数据在文件中越靠前,它的优先级就越高。如图所示,Reader1 要消费的数据是图中绿线所指的部分。而 Reader3 由于注册上来的比较晚,它要消费的是黄线所指部分。Reader2 消费的最快,它可能已经消费到了蓝色部分,但是数据最靠后,他的Offset最大,因此优先级最低。
在这样的情况下,当 IO Scheduler 进行调度时,会优先让 Reader1 消费,其次是 Reader3,最后才是 Reader2。这种做法在很大程度上实现了顺序读取。
但是,Spark 的处理方式与 Flink 不同。Spark 的 Sort-Based Shuffle 也是将一个 Partition 的数据最终写入一个文件。但它每次发生溢写时都会先产生并写入一个临时文件,之后再进行归并,最后合成一个文件。因此,Spark 产生的文件严格保证了全局有序,而 Flink 产生的文件仅在 Region 内有序,全局来看是无序的。Spark 这种方式数据的顺序性好,但是多个下游同时拉取数据时会破环顺序读,而且需要额外的归并开销。经过 Flink 社区的测试,基于 IO 调度的方式在许多工作负载上表现得更为出色。
3.流批一体的 Hybrid Shuffle -- Flink1.18+
(1) Pipeliend和Blocking Shuffle的问题
首先,来看PipeLined Shuffle,他的优点是map和reduce同时执行,不需要谁等谁,在流处理很优秀,但是在批处理,有一个很严重的问题:要求上下游必须全部拿到资源,如果同时存在多个任务,每个任务只能拿到一部分资源,那就很容易出现死锁,如上图左下角,map1、map2、reduce1都拿到资源了,但是reduce2没有资源,这个任务就运行不了,就一直等reduce2拿到资源才可以。
试想一下,如果都没人放资源,那么一致等,不就出现死锁了吗?
其次,再看Blocking Shuffle,他的优点是批处理可用一个slot就能执行完所有任务,但是缺点就是所有数据必须全部落盘IO,如上图右下角,map1暂时没资源,map2已经运行完了,但是要等待map1运行完,才能运行reduce1和reduce2
(2) Hybrid Shuffle的出现
Flink 社区在 1.16 版本引入了 Hybrid Shuffle,它是 Blocking Shuffle 和 Pipelined Shuffle 的结合,让 Flink 批处理具备了更强大的能力。
Hybrid Shuffle 的核心思想是打破调度约束,根据可用资源的情况来决定是否需要调度下游任务,同时在条件允许时支持全内存不落盘的数据传输。
Hybrid Shuffle 将 Pipelined Shuffle 跟 Blocking Shuffle 的特点结合在一起
- 在资源充足的情况下,上下游的所有任务可以同时运行,它的性能跟流式 Pipeline Shuffle 类似。
- 在资源受限的条件下,Hybrid Shuffle 可以先让上游执行,将数据落到磁盘之后,下游再进行消费。
Hybrid Shuffle可以自适应地在内存和磁盘层之间进行切换,并不是静态的一次性切换。我们在数据消费的过程中,可以随时在内存写满的状态下,切换到磁盘模式。当内存中的数据被消费,留出更多的空间后,它又可以切换回内存进行消费。
我们来看 Hybrid Shuffle 架构,在 Hybrid Shuffle 中引入了自适应的分层存储架构。
简单一句话:在这套架构中,Shuffle上下游间的数据交换过程,被抽象为上游将数据写入某种存储中,然后下游再从该存储中读取数据的过程,相当于中间缓存
在分层自适应存储架构中,包含一个写端(Producer Client) 和一个读端 (Consumer Client),主要负责向不同的存储介质写数据和读数据。
在中间的存储层,隐藏了内部实现细节,具有统一的抽象。写端按照优先级,进行存储层的数据写入。如果遇到空间不足等问题,该存储层会反馈当前无法接收数据,然后继续写下一个优先级的存储层。通过分层存储加动态自适应的方式,我们将多种存储层的介质,进行融合和互补,满足我们在不同情况下的需求。其中,最常用的几个层是内存,磁盘和远程存储。
相比于上面两种Shuffle, Hybrid Shuffle 主要具备以下特点:
- 打破调度约束:Hybrid Shuffle 打破了 Pipelined Shuffle 所有 Task 必须同时调度,Blocking Shuffle 必须分 Stage 调度的约束:在资源充足时,上下游 Task 可以同时运行;在资源不足时,上下游 Task 可以分批先后执行。
- IO开销小:Hybrid Shuffle 打破了批作业所有数据必须全部落盘并从磁盘消费数据的约束,在上下游同时运行的情况下,它支持直接从内存消费数据,从而在提升作业性能的同时大幅减少磁盘 IO 带来的额外开销。
综合而言,Hybrid Shuffle 的核心优势包括:
- 动态决定存储介质:优先选择高性能的存储层,在满足一定条件时及时从低优先级的层切回高优先级的层,实现多层之间自适应切换。
- 灵活可扩展:提供统一的存储层抽象,支持插件化的存储层,用户可以灵活扩展到新的存储系统。
- 智能的 Task 调度:只要上游 Task 被调度,下游 Task 能够在任何时间进行调度,在资源充足时上下游同时运行,而在资源紧张时上下游串行执行。
4.使用建议
- 选择合适的Shuffle策略:
- Sort Shuffle (
execution.batch-shuffle-mode=ALL_EXCHANGES_BLOCKING) 仍然是大多数场景下稳定可靠的选择。 - 对于超大规模作业,或在本地磁盘成为瓶颈的弹性环境中,应配置
execution.batch-shuffle-mode=ALL_EXCHANGES_HYBRID_FULL或ALL_EXCHANGES_HYBRID_SELECTIVE启用Hybrid Shuffle 并配置远程存储 (taskmanager.network.hybrid-shuffle.remote.path)。
- Sort Shuffle (
- 调优网络内存: 适当增加网络内存占比(
taskmanager.memory.network.fraction建议至少为0.2),并调整浮动缓冲区(taskmanager.network.memory.floating-buffers-per-gate)和每通道缓冲区(taskmanager.network.memory.buffers-per-channel),可以有效解耦并行度与网络内存需求,减少“Insufficent number of network buffers”错误。