上周在公司值班,手机连着弹了好几条告警,打开Flink的Web界面一看,作业挂了好几个。再翻到JobManager日志,最扎眼的一行就是Slot request bulk is not fulfillable!。做实时计算最怕遇到这种报错,因为它不像业务数据异常那样有一条清晰的因果链,你只知道资源调度在某个环节卡住了,至于为什么卡住,得顺着并行度、Slot、共享组一条线查下去。今天这篇就把这次排障的完整过程和背后的机制一起整理出来,顺便把最近社区里经常提到的“Flink JDBC连接器异常”为什么最后也会引出这个报错说清楚。无论你是刚接触Flink的新手,还是已经被资源类问题折磨过几轮的同学,应该都能从里面找到对应自己情况的排查路径。
1. 先搞清“Slot request bulk is not fulfillable!”到底是什么
1.1 报错现场还原与触发时机
先还原一下现场。这种报错通常长这样:
Caused by: org.apache.flink.runtime.JobException: Slot request bulk is not fulfillable! at org.apache.flink.runtime.scheduler.SchedulerBase.lambda... at org.apache.flink.runtime.scheduler.DefaultScheduler...具体堆栈在不同版本里会有差异,但关键字一定是这句Slot request bulk is not fulfillable!。它不出现在业务算子日志里,而是出现在JobManager的调度器日志里,意思是:作业向集群申请的一批slot,集群在当前状态下无论如何都凑不齐。
我整理了一下,最容易触发这个报错的场景有三个。
第一个是提交新作业。作业里配置的并行度总和超过了集群可用slot数,JobManager一开始调度就会发现“这批slot我怎么给都给不齐”,直接报错。最简单的情况就是集群一共8个slot,你提交的作业需要10个slot。
第二个是作业运行中手动调整并行度。比如通过命令行或者API触发rescale,Flink会尝试重新调度受影响的部分,如果新的并行度超过了当时空闲slot的总量,就会出现这个报错。
第三个是failover恢复。这是最隐蔽也最容易踩的一种。作业因为某个算子OOM、网络异常、数据库连接超时等原因挂了,Flink自动重启,重启时JobManager需要把整个ExecutionGraph重新部署一遍,一次性申请一整批slot。如果这时候其他作业还占着部分slot,而旧作业的slot又没来得及释放,一样会炸。
所以看到这条报错,先别急着改业务逻辑,它本质上是“资源分配失败”,不是“业务计算失败”。
1.2 为什么叫“bulk”:一次打包的Slot请求
很多刚接触Flink的人看到“bulk”这个词会觉得奇怪,为什么后面跟着“批量”两个字。这里就要说到Flink调度器的一个关键行为:当一个作业需要启动或者重启时,调度器会把这一轮所有需要部署的ExecutionVertex对应的slot请求,打包成一个集合,一次性提交给SlotManager去分配。
这个“批量申请”的设计本身是为了效率。如果一个一个去申请slot,每个请求都要经过一次资源匹配、反馈、确认的流程,几十个task就是几十轮交互,作业恢复速度会变得非常慢。批量提交可以让调度器一次性决定“这批位置是否整体可用”,减少了大量的协商开销。
但代价就是“整体满足”的约束。用食堂打饭来类比:你端着餐盘,需要同时取五道菜,结果窗口今天只有四道菜,你没法给自己端走四道然后剩下那道等会儿再补,只能整盘退回去。Flink也一样,如果这一批slot请求里的任何一个没办法落地,整个部署就会失败,接下来你会看到作业反复尝试分配、反复失败。
这一点对排查极有帮助。你在看日志时要注意,报错里常有“Not enough free slots”之类的前置日志,那时候要立刻意识到是“总量不够”,而不是某台机器调度出了问题。
1.3 先排除两个干扰项:不是OOM,也不是连接异常
我见过很多人在日志里同时看到几类报错,就搞不清到底该先处理哪一个。这里有必要把三个容易混淆的问题放一起对比一下,方便大家快速判断方向。
| 现象 | 典型日志关键字 | 根因方向 | 优先处理思路 |
|---|---|---|---|
| 内存不足 | OutOfMemoryError / PMD Memory consumption | TM堆内存或直接内存不足 | 调整内存配置,减少单slot内存负载 |
| 调度资源不够 | Slot request bulk is not fulfillable! | 并行度和可用slot不匹配,或共享组限制 | 扩容、降低并行度、检查slot sharing |
| 数据库连接异常 | SQLTransientConnectionException / Connection is not available | MySQL连接数或连接池被占满 | 优化sink并行度、检查数据库连接上限 |
注意,这三类问题往往不是孤立出现的。完美的“帮凶”链条是:连接异常导致task挂掉,挂掉触发failover,failover恢复需要重新申请大量slot,而资源不足就爆出Slot request bulk。这个关系链我在第4章会用一个真实复盘展开。现在你只需要记住:看到Slot相关报错,要先把它的时间戳和前后日志串起来看,判断它是“起因”还是“结果”。
2. 并行度、Slot与TaskManager:先把“工位模型”讲透
2.1 工位模型:一个Slot到底能放几个任务
要彻底理解这个报错,必须把Flink的运行模型掰开揉碎。我把Flink的资源调度比喻成公司租用办公区的过程。
TaskManager就是一间办公室,它是真正执行任务计算任务的进程,承担CPU、内存、网络开销。每个TaskManager启动时可以配置taskmanager.numberOfTaskSlots,也就是这间办公室物理上能放多少张工位。每个工位就是Slot,同一时刻可以坐一个员工,这里的员工就是作业中的一个并行子任务(Subtask)。
一个并行度为P的算子,会产生P个并行子任务,也就是说需要P个Slot同时在线工作。集群总共能提供的Slot数很简单:TaskManager数量 乘以 每台TaskManager的Slot数。比如3台TM、每台4个Slot,总共就是12个,任何作业申请的资源都不能超过这个数。
但有一点必须说明,Slot本身就是一段内存资源和执行线程的隔离单位,它不是CPU隔离的单位。一个Slot在同一个时刻确实只跑一个Subtask,但这张工位上的员工是可以换班的。一个Subtask执行完了、释放了Slot,下一个Subtask可以立刻坐进来。所以Slot数本质上决定的是“同一时刻最多能并发运行多少个任务副本”,而不是“一天下来能处理多少任务”。
2.2 为什么执行计划上的并行度总和,不等于Slot需求
这一节是理解整个报错的重中之重,也是大部分人栽跟头的地方。很多人潜意识里认为,作业所有算子的并行度加起来是多少,就需要多少Slot。这句话只对了一半,甚至很多时候完全不对。
Flink默认会把能串在一起的算子“链化”。比如一个简单的Source到Map再到Filter,如果上下游并行度一致且没有shuffle边界,它们会被合并成一条算子链,所有算子像一个任务一样跑在同一个Slot里。这时候你表面上看到三个算子、每个并行度10,实际上它们整体只占10个Slot,而不是30个。
但是,一旦两个算子之间存在shuffle边界,比如你调用了keyBy、rebalance、rescale,或者两个算子的并行度设置不一样且需要重新分发数据,上下游就必须同时运行才能一边产出数据一边消费数据。这种情况下,两个算子不能复用同一批Slot,计算总需求时需要把两边的并行度相加。
我举个例子,一个实时订单统计作业,Kafka Source并行度是8,中间做keyBy聚合的并行度是12,最后JDBC Sink并行度也是12。Source和聚合之间是keyBy,必然有shuffle边界,聚合和Sink之间如果要按key写入,也是shuffle边界。那么在调度阶段,Source的8个Subtask、聚合算子的12个Subtask、Sink的12个Subtask都需要各自的Slot,总共就是 8 + 12 + 12 = 32个Slot。看起来核心算子并行度只有12,可一旦链路里有多个并行度不同的阶段,Slot需求就是多个阶段的叠加。
所以在看报错之前,养成一个习惯:打开执行计划图,把每个存在shuffle边界的算子并行度分别列出来,按阶段相加,这才是你作业真实的Slot需求。
2.3 总Slot数够了,为什么还是失败
这就是最让人恼火的地方:明明集群总Slot数是够的,可作业还是报 fulfillable。问题出在哪?汇总下来有三类常见原因。
第一个原因是可用Slot分散。集群总共20个Slot,可用的有12个,但这12个分布在四台不同的TaskManager上,每台上只有3个。你作业某个并行阶段需要8个Slot,调度器会给这8个Subtask挑选合适的TM,结果有一台TM上只剩1个空位,而需要放到这台TM上的Subtask可能就有2个,分配关系对不上,整体就失败。
第二个原因是slot sharing group的设置。一个作业内部可以定义多个slot sharing group,默认情况是所有算子都在一个名为“default”的共享组里。但有些人会在代码里给某些算子单独设置slotSharingGroup,比如把Sink单独丢到一个自定义组里。这会导致这个分组内的算子独自占用一批Slot,其他分组不能复用。表面上Slot总数没变,但实际上集群资源被划分成了几个“隔间”,某个隔间里的Slot不够,照样报错。
第三个原因就是集群里不止跑这一个作业。Flink的SlotManager是按整个集群维度管理的,其他作业占用的Slot就是不可用。尤其是有些集群把几个作业混在一个Flink Session集群里跑,别的作业高峰期把Slot全占了,你这个作业大雪天提交上来自然分不到工位。
理解这三点之后,排查就有方向了。不管系统报告说得多隐晦,你最终要回答的问题只有一个:在这批申请发生的那个时间点,可用的、且能放进对应共享组的Slot数量,到底够不够。
3. 从看到报错到改完参数:一条完整排查路径
3.1 第一步:确认“申请量 vs 可用量”这两个数字
很多人拿到这个报错就开始猜,有的去调内存,有的去改SQL,运气好碰对了,运气不好折腾到半夜。我现在的习惯是先把数字摆出来。
打开Flink Web UI,先看概览页,Overview页会显示Registered Task Managers和Total Slots。比如显示Total Slots是12,Available Slots是12,那说明集群层面一个作业都没占多少资源。但具体作业需要多少,还得看作业详情页里的Task Slots标签,或者Task Deployment情况。
在生产环境不方便开Web UI的情况下,可以直接用REST API拉数据。
curl -s http://jobmanager:8081/overview | python3 -m json.tool curl -s http://jobmanager:8081/jobs/overview | python3 -m json.tool curl -s http://jobmanager:8081/jobs/<jobid> | python3 -m json.tool | head -100第一个命令看集群总Slot数,第二个命令看作业列表和状态,第三个命令看指定作业的详细信息,里面能找到每个ExecutionVertex的并行度和状态。很多版本还支持/jobs/<jobid>/exceptions,可以拿到完整的异常列表。
拿到数字后做减法。如果“请求需要的Slot数”大于“当前可用Slot数”,根因基本明确,直接走第3.4节的处置方案。如果“请求数”不大于“可用数”,那就得继续往下看共享组和分布情况。
3.2 第二步:把执行计划和并行度设置从头捋一遍
这一步的目的是搞清楚“为什么作业会申请这么多slot”。建议直接用Flink SQL里的EXPLAIN语法,或者DataStream作业里把JobGraph导出来看一眼。对于SQL作业,一个非常典型的坑是并行度不在你手里。
Kafka Source的并行度默认会跟随Topic分区数。假设你Topic有24个分区,Source并行度就是24。你在SQL里把parallelism.default设置成12,那只能影响下游聚合和Sink的并行度,Source仍然是24。如果Source和聚合之间存在shuffle,Slot需求就变成了24加12,比你以为的12高出一大截。
DataStream里同理,每个算子可以单独设置并行度,优先级排序为:算子单独设置 > env设置 > parallelism.default。建议把代码里的每个setParallelism都列出来,对照执行计划逐个过。
检查时重点看有没有“自动插入的重新分区”。当一个算子并行度与上下游都不一致时,Flink为了保持数据分发正确,会悄悄插入Forward或Rebalance的边,这些边就是shuffle边界。每多一个shuffle边界,Slot需求就可能多出一组并行度。
3.3 第三步:查Slot Sharing Group和TaskManager存活状况
排除掉并行度设置的问题后,接着看共享组。UI上查看作业的Execution Plan,点任意节点,可以看到它的Slot Sharing Group属性。如果所有节点都显示default,那说明共享组没被改动,可以排除这个原因。如果有些节点出现在自定义组里,就要认真算一下自定义组的Slot需求是否超出了预期。
同时还要确认TaskManager是不是真的都活着。尤其是在Kubernetes或YARN上跑Flink的集群,某个TaskManager可能因为内存压力被内核杀了、容器被回收了、或者节点异常被JobManager判定超时移除了。看一眼kubectl get pod | grep taskmanager或者YARN的Node Manager列表,确认实际存活的TM数量和你配置时假设的数量一致。
一个很容易被忽略的细节是:JobManager把TaskManager踢下线之后,该TM上原本的Slot要经过一定的超时时间才会被回收释放。在恢复期内,虽然UI上显示总Slot数减少了,但旧的作业状态信息还挂在那边,新的作业来申请资源时看到的可用数就会非常紧张。
3.4 第四步:处置三板斧,扩资源、降并行度、并共享组
排查到这里,解决方案基本就三类,我列成表格方便对号入座:
| 场景 | 推荐操作 | 说明 |
|---|---|---|
| 作业并行度远超集群能力 | 调低并行度到空闲Slot数以下 | 能临时救急,但可能牺牲吞吐 |
| 集群总Slot不够,业务又确实需要这么大并行度 | 增加TaskManager数量,或调大每台TM的Slot数 | 扩容前先算单Slot内存是否满足Subtask需要 |
| 自定义Slot Sharing Group导致资源浪费 | 去除多余共享组,把所有算子合并回default | 不花钱、不改业务,还能立刻见效 |
| 集群里其他作业长期抢占Slot | 给作业规划独立Flink集群,或用资源队列做隔离 | 会话集群混跑容易埋雷 |
具体怎么改配置?如果决定增加Slot,可以调整TM的启动参数:
taskmanager.numberOfTaskSlots: 8如果决定降低并行度,Flink SQL可以直接设置:
SET 'parallelism.default' = '16';DataStream作业则可以设置env.setParallelism(16)。
这里要提醒一句:不要一上来就把全局并行度调小,那样等于把所有算子的并发度都拉低。最合理的是只把出问题的那个阶段并行度降下来,或者调整Kafka Source的并行度上限,保住核心计算环节的吞吐。
4. 热词联动:JDBC连接器异常怎么和Slot不足扯上关系
4.1 复盘一次“写MySQL把作业写崩”的过程
最近在社区里经常看到“flink的jdbc连接器异常”这个热词,很多人跑来问:“我的SQL没问题,连接串没问题,数据库也没满,为什么JDBC Sink老报连接不可用?”这里我想分享一次实际排障,因为这看起来和Slot不足毫无关系,最后却一起炸了。
当时作业架构很简单,Flink从Kafka读订单数据,做完聚合后写入MySQL。高峰期一到,先是日志里出现了一堆异常:
Caused by: java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.连接池里拿不到连接,Sink任务开始连接超时,后面的写入数据部分堆积。紧接着,一部分Sink的Subtask因为超时异常失败,触发作业failover。就在恢复阶段,JobManager日志里紧跟其后地出现了那句:
Caused by: org.apache.flink.runtime.JobException: Slot request bulk is not fulfillable!两个八竿子打不着的错,在一条时间线上串成了因果链。
梳理一下发生了什么:高峰期数据库连接被Flink连接器本身和其他服务占满了,Flink Sink想要新的连接拿不到,任务反复失败。失败触发重启,重启时要重新申请整条链路的Slot,而这个时候旧作业的Slot还在等待清理、集群里还有别的作业占着资源,一次性申请量瞬间超出了可用数。于是真正的病根——连接池打满——反而被Slot报错盖在了后面。
4.2 判断“先有鸡还是先有蛋”:先看时序再动手
遇到这类混合报错,最大的敌人是急躁。很多人一看到“Connection is not available”就开始调sink并行度或者连接池,结果作业频繁重启,第二天又炸一次,而且每次炸出来的都是Slot报错。我的判断方法很简单,看时间戳。
先确认连接异常出现的时间点,再确认Slot fulfillable报错出现的时间点。如果连接异常在前、Slot报错在后,说明Slot报错只是恢复过程中的次生灾害,主线应该是“为什么连接不够用”。如果Slot报错在前、连接异常是因为task一直起不来导致的,那主线就是资源不足。
然后看持续状态。打开Flink Web UI的BackPressure面板,如果Sink上游一直处于High状态,问题大概率出在下游写入速度跟不上数据流入速度。再回头看MySQL的max_connections、当前连接数、以及慢查询,基本能锁死是数据库侧还是Flink侧的问题。
实际操作中,我习惯按这个顺序排查:
- 看作业是不是在“运行-失败-重启-再失败”的循环里
- 看背压是不是先出现在Sink上游
- 看数据库当前连接数和连接池可用连接数
- 对比两类报错的时间戳先后
这套顺序能保证在五分钟内找到主线,而不是被日志里五花八门的Caused by带偏。
4.3 可以从容调整的几个参数
解决这类问题不是一味增加并行度,不要误会。如果数据库已经被连接数打满了,你增加Sink并行度只会让每个任务去抢连接,情况更糟。合理的做法是把Sink的写入改成批量、低频,让固定数量的连接能发挥更高的吞吐。
Flink SQL的JDBC连接器一般支持批量写入参数,可以这样设置:
CREATE TABLE order_sink ( order_id STRING, amount DECIMAL(10,2), ts TIMESTAMP(3) ) WITH ( 'connector' = 'jdbc', 'url' = 'jdbc:mysql://your-host:3306/test', 'table-name' = 'order_sink', 'driver' = 'com.mysql.cj.jdbc.Driver', 'username' = 'your-user', 'password' = 'your-password', 'sink.buffer-flush.max-rows' = '1000', 'sink.buffer-flush.interval' = '2s' );max-rows=1000表示攒够1000条才写一批,interval=2s表示最多等2秒就写一次,这两个参数结合起来能大幅降低连接被频繁占用和释放的压力。
DataStream里如果直接使用JdbcSink,也可以配置批量执行:
JdbcExecutionOptions execOptions = JdbcExecutionOptions.builder() .withBatchSize(1000) .withBatchInterval(Duration.ofSeconds(2)) .build();另外,如果连接池用的是HikariCP,不要只调大maximumPoolSize,还要把connectionTimeout调短一点。宁可让任务快速失败触发重启,也不要让几十个连接请求全部挂在等待队列里占着线程。数据库侧的max_connections建议预留30%到40%的缓冲给其他服务和运维操作,而不是被Flink作业吃满。
5. 经验复盘:这类报错最容易忽悠人的地方
5.1 “总量够”和“能分配”之间隔着一层分布
做了这么多年运维和实时开发,我发现这个报错最著名的“谎话”就是:怎么看集群都够用。
有时候总Slot数确实够,但作业就是起不来,原因在于分配并不只看总量,而是看每个TaskManager上的分布是否合理。举一个很简单的例子,你有4台TaskManager,每台2个Slot,总共8个。现在另一个作业占用了其中一台机器的2个Slot,剩下6个。你的作业申请6个Slot,数字上刚好够,但如果要部署的6个Subtask里,有部分逻辑要求尽量发送到同一台机器(比如同一个Source的多个分片),而那台机器的空余Slot已经不足,调来调去都凑不齐,最终就报fulfillable。
Flink的SlotManager不是物理机上的资源池,它不会把所有空闲Slot打散后重新切块给你。每个ExecutionVertex还是要落到具体某一台TaskManager的某个Slot上,只要有一个落地不了,整批申请就失败。
所以在做集群容量规划时,不要只看“总Slot / 峰值并行度”这两个数,还要看单机Slot配比和作业内并行度关系。如果作业里单个阶段并行度是12,而每台TaskManager上可用Slot数长期只有4或5,那这个阶段天生就容易分配失败,要么重新规划TM的Slot分配,要么调整作业的资源请求。
5.2 failover恢复期:为什么平时看着没事,一出问题就炸
这个现象我见过太多次了:作业稳定运行几周,内存也没爆,数据库也正常,某天突然因为一条脏数据引发反压或者异常,紧接着failover,然后就报Slot不足。你第一反应是“之前不是一直够吗,怎么恢复的时候反而不够了”。
原因是恢复这个动作本身就比正常运行要更多瞬时资源。正常运行的时候,部分算子可能因为数据水位不齐、或者某些Subtask处理得快而提前释放Slot;但是failover恢复时,JobManager要把整个ExecutionGraph的所有Subtask一下子全铺开,而且旧Subtask占用的Slot还没完全释放,新一批又冲上来,瞬时需求直接翻倍。
面对这个问题,除了预留足够的Slot余量外,还可以把恢复节奏放平稳。常用的参数组合是这样的:
restart-strategy: fixed-delay restart-strategy.fixed-delay.attempts: 3 restart-strategy.fixed-delay.delay: 10s把restart-strategy.fixed-delay.delay从默认的秒级改成10秒以上,可以给旧Slot的清理释放留出缓冲时间。如果你用的是Flink 1.15以上的版本,还可以调整slotmanager.taskmanager-timeout,增加JobManager等待TaskManager注册或Slot释放的耐心。
但这里有一个反向提醒:如果物理上真的没有更多可用Slot,把等待超时调得再大也没有意义,作业只会多等几分钟然后照样失败。所以调参数的唯一前提是——你已经确认集群有Slot正在释放,只是需要时间。
5.3 我自己一直保留的三个排查习惯
排这类资源调度问题排多了,我给自己定了三条规矩,谈不上多高级,但每次都能让我快速冷静下来。
第一,任何作业上线前,先按目标并行度跑一次空载任务。不要等生产出问题再发现资源不够。现在Flink作业启动很快,空载跑5分钟,能够看到Slot申请是否顺利、有没有因为共享组配置导致分配失败,花不了多少成本。
第二,集群监控里一定要有Slot使用率指标。我自己的告警阈值是80%,超过这个数就发预警,给整个集群留出20%的余量。这20%不是浪费,是给failover恢复、临时扩并行度、以及旁边作业的突发流量准备的缓刑空间。
第三,看到这条报错时,第一反应不是改SQL也不是换连接器,而是把阻塞点的时间戳和数字量化出来。打开JobManager日志,把“什么时候申请的、申请了多少、当时可分配数量是多少”这三个问题回答清楚,再动手调任何参数。很多时候问题一眼就能看出来,根本不用碰业务代码。
如果你也在排障时看到“Slot request bulk is not fulfillable!”,建议顺着这条路径先稳住自己。实时计算里很多现象看着像业务问题,根子却在资源调度这层。只有把底层这个模型彻底想明白,遇到问题才不会慌。