记得有一次在群里看到个问题,有人贴了张 Flink Web UI 截图:一个 TaskManager 的 CPU 干到 90% 多,背压都变黄了,另一个 TaskManager 闲到 20% 不到,Slot 也就占了俩。大家七嘴八舌说是数据倾斜,但仔细一看数据量并不大,真正的问题是——作业里的并行度不一致,导致 Task 在 TaskManager 上分布严重不均匀。
这类问题在 Flink 里其实有一个非常直接的解法开关:cluster.evenly-spread-out-slots。这篇文章就围绕 Balanced Tasks Scheduling 这个主题,把“并行度不一致时怎么让 TaskManager 压得更均匀”这件事掰开揉碎讲清楚,包括调度模型、配置原理、实操步骤,还有我实际踩过的一些坑。
1. 先搞懂问题:并行度不一致,TaskManager 怎么就“胖瘦不均”了
1.1 从 Flink 的 Slot 模型说起
很多人用 Flink 用很久,但对 Slot 模型的理解就停留在“一个 TaskManager 有几个 Slot 就能跑几个 Task”。这句话大方向对,但有一个关键细节:Flink 的 Slot 不是一个固定预先规划的 CPU 核心槽位,它更像一个资源容器,或者说一个“可重复使用的席位”。
我经常用餐厅来类比。TaskManager 相当于一个厨房,Slot 就是灶台。一个厨房可能摆了 4 个灶台,意味着同一时间最多同时炒 4 个菜。但注意,灶台本身不绑定某个固定厨师,而是谁要用、用多久,由调度员(JobManager 里的 Scheduler)安排。Flink 里每个算子子任务(Subtask)在运行前,都需要拿到一个 Slot 作为“岗位”,执行完就释放。
当作业的并行度一致时,比如整个作业 Source、Transform、Sink 都是 4 并行,那这个作业总共有大概 12 个 Subtask,每个 TaskManager 4 个 Slot,总数也是 12,正常情况下一个萝卜一个坑,看起来会非常整齐。
但现实往往不是这样。
Flink CDC 场景里,Source 并行度通常由表或库的数量决定,可能只有 2 个;后面接一个处理逻辑复杂的 FlatMap 算子,为了吞吐你给了 10 个并行度;再往后 JDBC Sink 到 MySQL,受连接数和资源限制,并行度可能又只能压回 2。这时候整个作业的 SubTask 数量分布就是 2 + 10 + 2,加上 Rebalance、KeyBy 之类的内部算子,总 Task 数可能达到二三十个。这些 Task 要抢占 Slot 的时候,一旦调度策略没有做全局均衡,就会出现开头说的那种“胖瘦不均”的局面。
1.2 并行度不一致会带来什么实际问题
并行度不一致本身不是错误,它源于真实业务需求。Source 并行度受外部系统分区数限制,Sink 并行度受下游写入能力限制,中间算子并行度受计算复杂度限制。这种做法完全合理,问题出在“不一致”产生的调度落点。
默认情况下,Flink 调度器在给任务分配 Slot 时,会优先考虑数据本地性。比如一个 Task 要读取的数据已经在某个 TaskManager 上处理过,那调度器可能倾向于把这个 Task 继续调度到同一个 TaskManager 或者同一台机器上,尽量减少网络 Shuffle。这个策略本身没错,但如果整个集群同时跑多个作业,或者单个作业的并行度组合比较极端,本地性优先的调度就会导致“先来先占、后来干看”的局面。
实际表现就是:
- 某个 TaskManager 上可能堆积了 7-8 个 Subtask,CPU 和内存双高,GC 频繁,反压一路飘红;
- 另一个 TaskManager 上只有 2 个 Subtask,大部分时间处于半空闲状态;
- 整个作业的吞吐被最忙的那个 TaskManager 拖住,加了并行度也上不去,因为资源都堆在一个进程里竞争。
更麻烦的是,一旦某个 TaskManager 成了热点,它上面的 Task 之间还会相互争抢资源,数据倾斜和调度不均叠加在一起,排查起来特别容易误判。我之前就遇到过一起线上问题,一开始认为是 KeyBy 分桶不均,加了一堆预聚合逻辑也没解决,最后才发现纯粹是调度导致的两个 TaskManager 上任务数量差异太大,其中一个进程已经处于饱和状态。
所以,不要把“并行度不一致”和“数据倾斜”混为一谈。数据倾斜是数据分布问题,调度不均则是资源分配问题。后者有一个更直接的手段解决,就是接下来要讲的 Balanced Tasks Scheduling 机制。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心方案:cluster.evenly-spread-out-slots 的原理与选型
2.1 这个配置到底改了什么
Flink 里负责“尽可能均匀分配 Slot”的开关,就是 cluster.evenly-spread-out-slots。这是一个在 conf/flink-conf.yaml 里的配置项,默认值在大部分版本里是 false。
名字很直白:均匀展开 Slot。它的作用是让调度器在分配 Task 到 TaskManager 时,不再一味追求“本地性优先”,而是先看哪些 TaskManager 当前已经占用的 Slot 数更少,优先把新 Task 分配到占用比例最低的 TaskManager 上,尽量让 Slot 占用率在整个集群里保持接近。
你可以理解成学校排座位。默认策略是:谁和谁关系好就坐一起,不管教室前几排是不是已经挤爆了;开启 evenly-spread-out-slots 之后,班主任改成“先把每个座位按顺序轮流转一圈”,保证每个区域的人数差不多。
它的实现原理是基于一个轮询分配算法:当调度器需要为某个 Task 申请 Slot 时,它会维护一个 TaskManager 列表,每次从当前 Slot 占用率最低的 TaskManager 开始扫描,找到能提供 Slot 的就分配。这样做的好处是,即使作业的并行度跨度很大——比如 Source 是 2,Transform 是 20——这些 Task 也会被打散到所有可用 TaskManager 上,而不是堆在集群前半段。
这里需要强调一点:这个配置解决的是“Slot 占用数量”层面的均衡,不直接解决每个 Task 的 CPU 负载差异。但如果 Task 之间的内存消耗和计算量差别不大,Slot 数均了,负载自然也就跟着均了。在绝大多数业务场景里,这一点成立。
2.2 适用场景与不适用场景
这个配置不是银弹,我把它在实际工作中的适用边界梳理一下。
适合开启的场景:
- 多作业共享集群。多个作业同时跑,每个作业并行度各不相同,默认调度很容易把 Task 集中在某几个 TaskManager 上。开启后能让不同作业的 Task 交错分布,提高整个集群的利用率。
- 单个作业并行度跨度大。比如刚才说的 CDC Source 低并行度、中间算子高并行度、Sink 又降下来,这种组合很容易造成局部堆积。
- TaskManager 数量多且算力同质。假设你有 20 台机器组成 Flink 集群,每个 TaskManager 资源都一样,那均匀分布是非常合理的策略。
- 弹性扩缩容场景。TaskManager 经常动态增减时,均衡分配能避免新加入的机器长时间空转。
不适合或者需要谨慎的场景:
- 单 TaskManager 集群。只有一个节点,均匀不均匀没意义。
- 数据本地性极其敏感。比如经典的 Join 场景,左右两路数据已经按照相同 Key 分区到了某些 TaskManager 上,调度器如果能保持 Task 和数据的亲和性,Shuffle 会小很多。开启均衡调度后,Task 可能被分配到没有预先获取数据的节点,导致网络传输量明显增加。
- Slot 总数远小于任务数。比如整个集群只有 6 个 Slot,但作业有 50 个 Task,反正每个 Slot 都会被塞满,开不开启区别不大。
- TaskManager 的资源配置差异非常大。一个 16 核 32G 的大 TaskManager 和一个 4 核 8G 的小 TaskManager 放在一起,单纯按 Slot 数量均匀分配反而会浪费大机器的处理能力。更合适的方式可能是通过不同 TaskManager 的 Slot 配置差异来体现权重。
所以我一般在生产环境里的策略是:如果集群是给团队内多业务共用,或者作业的算子并行度组合差异明显,我会直接开启 cluster.evenly-spread-out-slots;如果是某个特征鲜明的重度状态计算作业,我会单独做一个测试环境验证网络 Shuffle 的影响再决定。
3. 实操:把 TaskManager 压得更均匀的具体配置与效果对比
3.1 配置步骤与验证方法
配置本身不复杂,在 flink-conf.yaml 里加一行:
yaml复制cluster.evenly-spread-out-slots: true
注意,这个配置不是动态生效的。修改后需要重启 JobManager 才会让新提交的作业按照新的调度策略运行。对于已经在运行的作业,不会因为改了配置就立即重新调度。所以我通常的建议是:
- 先在测试集群改配置并重启;
- 提交一个模拟作业或者把核心作业灰度切过去;
- 观察一段时间,确认 Slot 分布符合预期再全量切换。
配置完之后怎么确认真的生效了?最直接的方式是打开 Flink Web UI 的 Task Managers 页面,看每个 TaskManager 的 Slots 数。
在开启之前,你可能会看到这样的分布:
| TaskManager | 总 Slot 数 | 已用 Slot 数 | 空闲 Slot 数 |
|---|---|---|---|
| TM-1 | 4 | 4 | 0 |
| TM-2 | 4 | 4 | 0 |
| TM-3 | 4 | 2 | 2 |
开启之后,如果总 Task 数是 10 个,三个 TaskManager 各 4 个 Slot 的集群,理想状态下已用 Slot 数会接近 4、3、3 而不是 4、4、2。
另一个更细的验证方式是在作业的 Job Graph 或 Running Jobs 页面点开某个算子,查看它的 Subtask 分别分布在哪个 TaskManager 上。比如一个 10 并行的 FlatMap,开启前可能 8 个 Subtask 都集中在前两个 TaskManager,开启后应该是均匀散落在三个 TaskManager 上,大致比例接近 3、3、4。
我一般会写一个小脚本周期性读取 Flink REST API 的 /taskmanagers 和 /jobs/{jobid}/vertices/{vertexid}/subtasks 接口,拿到每个 TaskManager 的负载直方图,这样对比“配置前”和“配置后”就不需要一直肉眼盯 UI 了。
3.2 一个可复现的调度对比案例
为了更直观地说明效果,我构造一个典型场景:
集群情况:3 个 TaskManager,每个 4 个 Slot,总共 12 个 Slot。
作业拓扑(简化):
- Source(并行度 2),模拟 CDC 读取两个分片;
- FlatMap(并行度 8),模拟核心清洗/补全逻辑;
- Sink(并行度 2),模拟写入下游存储;
- 中间插入一个 KeyBy 和一次 Rebalance,内部算子会让总 Task 数进一步增加。
粗略估算整个作业的 Subtask 数:Source 2 + FlatMap 8 + Sink 2,加上 KeyBy、Rebalance 这类内部算子,总任务数约 18-20 个。
默认调度下,我实际观察到的 Task 分布经常是这样:
- TM-1:8 个 Task,Slot 全占满;
- TM-2:7 个 Task,Slot 全占满;
- TM-3:3 个 Task,1 个 Slot 空闲。
为什么会这样?因为默认策略优先做本地化分配,Task 之间还有数据依赖关系,调度器会把链式算子尽量塞进同一个 TaskManager 以复用已打开的状态和连接。这在作业初始阶段看起来“效率高”,但最终导致前两个 TaskManager 被塞爆,第三个凉凉。
开启 cluster.evenly-spread-out-slots 之后,调度器会改为“哪个 TaskManager 的 Slot 占用率低就先往哪里放”。上面同一个作业,Task 分布大致会变成:
- TM-1:5 个 Task;
- TM-2:4 个 Task;
- TM-3:4 个 Task。
虽然不能说做到绝对均等(因为 Task 的调度还有依赖关系、Slot 申请顺序等约束),但从“一个节点 8 个任务、另一个 3 个”变成“5、4、4 这种级别”,负载曲线已经平滑非常多了。
这时候观察集群的指标也很明显:之前那个 CPU 90% 的 TaskManager 逐渐降下来,空闲的节点 CPU 上升,整体吞吐反而向上走了一截。原因很简单——热点消除后,原本被拖累的 Task 不再因为和一堆高负载 Task 挤在同一个 JVM 里而频繁 Full GC。
3.3 有没有其他“压均匀”的手段
cluster.evenly-spread-out-slots 是全局层面最省事的一招,但也不是唯一的手段。如果遇到这个配置解决不了的问题,我还会从下面几个方向组合调整。
第一,合理设置 Slot 数。很多人习惯 taskmanager.numberOfTaskSlots 直接等于 CPU 核数,但这样做的后果是物理资源被切得非常碎,调度器能腾挪的空间很小。我现在的习惯是设置在 CPU 核数的 1/2 到 2/3,让每个 Slot 拥有更强的单任务处理能力,同时减少 Slot 之间相互争抢。
第二,手动控制 SlotSharingGroup。默认情况下,整个作业链上的算子会共享一个 Slot,所以一个 TaskManager 上可能同时叠了 Source、Transform、Sink 三个层级的 Task,Slot 数被塞得很满。你可以通过 slotSharingGroup() 把算子拆分到不同的 Slot 组,让不同组落在不同的 TaskManager 上,等于手动做负载分割。
java复制DataStream<Event> stream = env
.addSource(new MySource())
.slotSharingGroup("src");
stream
.keyBy(e -> e.getKey())
.flatMap(new MyFlatMap())
.slotSharingGroup("compute");
这个手段尤其适合那种“某个算子的计算量特别大、但并行度又特别低”的场景。把重算子单独拆成一组,防止它和前面的 Source 链在同一个 Slot 里互相踩踏。
第三,调整并行度设计。有些并行度不一致的问题其实可以通过并行度本身的优化来缓解。比如你 Source 并行度只有 2,中间算子并行度给了 10,那 10 个并行子任务里,有 2 个可能还需要从 Source 拉取数据,另外 8 个反而处于空转等待状态。这种情况下不如把中间算子并行度调到接近 Slot 总数的值,或者干脆把 Source 的并行度也提上去,用多个 Source 均匀拉取。
第四,连接器侧优化。比如 Flink CDC 的 Source 并行度受分片数限制,有些表就一个主库,那并行度只能 1,这种时候我一般会在 Source 后面加一个 rebalance() 或者 rescale(),人为打散数据,让下游的高并行算子能用到多个 TaskManager。JDBC Sink 也是类似,Sink 并行度受下游连接数限制,那就尽量让 Sink 的并行度接近 Slot 数的一个有效因子,避免 Sink 吞吐成为瓶颈。
这些手段和 cluster.evenly-spread-out-slots 不是互斥关系,而是配合关系。全局均衡开关负责兜底,手动调整负责精细化。
4. 常见问题与排查技巧实录
4.1 开了一段时间之后,TaskManager 又不均匀了,怎么回事
有朋友配置了 cluster.evenly-spread-out-slots: true,一开始很有效,但跑了几个星期后,发现 TaskManager 的负载又歪了。这是正常的,因为这个配置只影响“新提交作业”的调度决策,不会对老作业做动态重平衡。
设想一个场景:早上有个作业 A 提交,它占用了 TM-1 和 TM-2 的大部分 Slot;到中午作业 A 的部分 Task 结束,Slot 释放出来;下午作业 B 提交,调度器确实会优先选择 TM-3(因为空闲 Slot 最多),但 B 的并行度不如 A,所以最终分布可能是 TM-1 还有残余任务、TM-3 被填满、TM-2 反而空下来。这种漂移在长期运行的多作业集群里非常常见。
排查思路:不要只看当前一秒的 Slot 分布,把时间线拉长,看看不同作业的提交时间和 Slot 生命周期。如果确认集群长期运行导致分布漂移,缓解手段有两个:一是定期对个别大作业做 cancel 再重新提交(尽量在低峰期);二是借助任务调度平台的维护窗口,统一重启集群并提交作业,让所有作业都在均衡策略下重新落位。
4.2 配置没有生效或效果不明显
如果你确认配置加了、JobManager 也重启了,但分布还是很歪,先排查以下几个点。
第一,确认版本。cluster.evenly-spread-out-slots 这个配置在比较老的 Flink 版本(1.5 之前)没有,在 1.5 到 1.13 之间行为也有细微差异。如果你用的是非常老的版本,建议升级到 Flink 1.13 以上,调度器在这之后重构得比较成熟。
第二,查 Slot 总量和 Task 数量的比值。如果集群总 Slot 数是 12,作业的总 Task 数也是 12,那么不管怎么调度,最终结果很可能是每个 Slot 都占满——你看到的不均匀可能是作业启动的中间阶段,等稳定之后会逐步变均匀。这种情况不算配置失效,只是总量足够导致“大家都满”,观察不到差异。
第三,检查是否存在 Slot 共享组的影响。不同 SlotSharingGroup 的 Task 不能共享 Slot,如果作业里有多个组,同时每个组的 Task 数又很少,调度器即使想均衡也没法跨组调配。这种场景下需要配合手动调组并行度,或者接受“组内均衡、组间不均”的现实。
第四,看日志确认调度器是否读到了配置。在 JobManager 日志的启动信息里,有一行类似于 Load configuration from ... 的内容,可以 grep 一下 evenly 或 spread 关键字,确认配置项是不是被识别了。
4.3 开启均衡调度后,网络和延迟变大了
这是开启 cluster.evenly-spread-out-slots 后最常见的副作用,原因之前也提到了:均匀分配会让一些原本能在本地完成的连接变成跨节点 Shuffle。
举个例子,一个作业里有两个算子要做 KeyBy 之后 Join。默认调度优先本地化时,左右两路的 Task 很可能被分到同一个 TaskManager,这样 Join 时数据不需要跨网络传输。开了均衡调度后,左右两路的 Task 可能分别落在不同的 TaskManager 上,Join 的输入数据就必须通过网络传输,单条数据延迟可能从几毫秒涨到几十毫秒。
这个副作用怎么权衡?我个人的经验是:
- 如果作业是延迟敏感的实时链路,比如秒级预警,那我建议优先保留数据本地性,不要全局开启,而是针对这个作业手动指定 SlotSharingGroup 和并行度。
- 如果作业是批流一体的分钟级任务,或者跑的是团队共用的集群,均匀分配带来的整体吞吐提升往往能覆盖延迟的增加。
另外,有一个折中办法:把集群中少数机器配置成“本地性优先”节点,然后让全局配置保持在 false,把需要均匀分配的作业单独通过提交参数覆盖掉。Flink 支持在作业提交时通过 -D 参数指定配置,比如:
bash复制flink run -m yarn-cluster \
-D cluster.evenly-spread-out-slots=true \
-c com.example.MyJob \
/path/to/job.jar
这样只对指定作业生效,不干扰其他作业的本地性优化。
4.4 配合监控来判断是否真的“压均匀”了
光看 Slot 总占用数其实不够准确,因为不同 Task 的内存消耗和 CPU 开销差异可能很大。我一般会配合使用 Flink 自带 Metrics 和外部监控系统。
重点看这些指标:
| Metric | 说明 | 判断标准 |
|---|---|---|
taskmanager.JobManager.taskSlotsAvailable |
每个 TM 的空闲 Slot 数 | 数值接近集群均值说明分布正常 |
taskmanager.Status.JVM.CPU.Load |
TM 所在 JVM 的 CPU 负载 | 各 TM 之间不应有数量级差异 |
taskmanager.Status.JVM.GarbageCollector.G1 Old Generation Time |
GC 时间统计 | 热点 TM 的 GC 时间会明显偏高 |
taskmanager.Status.Network.TotalMemoryUsage |
网络内存使用 | 开启均衡后,各 TM 的网络使用应更接近 |
我自己的经验是,调度不均导致的热点,最明显的特征是:CPU Load 在某一个 TM 上长期高于其余节点,且该 TM 对应的 GC 时间也在持续增加。此时去 Web UI 看这个 TM 上运行的任务数,如果确实多出一截,那基本可以判定调度问题,而不是数据倾斜。
4.5 给操作者的几点避坑建议
- 修改配置前一定要记录当前所有作业的 Slot 分布,方便配置后对比。
- 不要在业务高峰期重启集群。
cluster.evenly-spread-out-slots只影响新提交作业,如果把集群重启了,所有作业重新提交反而可能触发一次集中调度风暴。 - 如果集群是 YARN 或 K8s 部署,确认资源管理器层面是否限制了 TaskManager 在节点上的分布。有时 Flink 内部调均匀了,但 YARN 把所有 TM 都分配到了同一台物理机上,那照样是热点。
- 在多个作业并行运行的环境下,建议把
cluster.evenly-spread-out-slots作为默认配置打开,再通过作业级参数微调,比“全局关、个别开”更容易管理。
5. 延伸:从调度均衡到资源利用率的整体思考
聊完具体的配置和坑,最后再说一点我的体会。
调度均衡本质上是在“局部性”和“全局性”之间找平衡。Flink 默认调度策略偏向局部性,因为计算引擎天然希望减少网络开销;但真实集群是共享的,业务作业的并行度组合千奇百怪,cluster.evenly-spread-out-slots 能让你用一行配置,把资源分配的逻辑从“谁有关系谁坐一起”切换成“先保证教室不挤爆”。
在我维护过的集群里,这个配置救过很多次命。最典型的是一次数据平台架构调整,把原来两个大的 Flink 作业拆成了十几个小作业,每个作业并行度各不相同。没开启均衡调度之前,每天早上任务集中提交的时间段,总有两个 TaskManager 的 CPU 冲到 100%,其他节点负载不到 30%。开启之后,峰值时段各节点的 CPU 曲线变得平滑,部分作业的端到端延迟还降了 20% 以上。
但我也要提醒一句:这个配置改的是资源位置的分布,不改真正的资源需求。如果业务本身给某个算子设置的并行度就是 1,那无论怎么调度,它都只能在一个路径上处理数据。遇到这种情况,应该回头审视并行度设计,而不是指望调度器“变魔术”。
另外,如果你用的是 Kubernetes 部署方式,可以考虑给每个 Pod 里的 TaskManager 设置独立的资源请求和限制,而不是笼统按 Slot 数切分,这样即便调度分配出现轻微不均,容器的资源隔离也能起到兜底作用。
最后再分享一个小技巧:在测试环境调调调度策略时,可以故意把并行度设成“不是 Slot 总数的整数倍”,比如 12 个 Slot 的集群跑 10 并行的算子。这种方式最容易暴露出默认调度和均衡调度之间的差异。调试一次,你就知道为什么我前面反复说“别只看 Slot 总数”了。
