1. 凌晨两点的那次全集群提交失败
Flink 的 JobManager 过载这件事,我在一个 Session 模式的集群上完整经历了一次,从报警到彻底稳住前后折腾了将近两周。事情的发生很有代表性:集群规模不算大,一个 JobManager 配了 2 核 4G 的容器、堆内存只给了 2G,后面挂了十几台 TaskManager,上面跑着六十多个作业,绝大多数是"小作业"——每个作业只有一两个算子、占用一两个 slot、数据量也不大。真正开始出问题是在某次业务改动之后,平台侧把作业状态轮询从 15 秒一次改成了 3 秒一次,同时上线了一批新的定时任务,作业提交频率上去了,JobManager 就开始不对劲了。
最先暴露的症状是 Web UI 打不开。点进去转半天,REST 接口大面积超时,/jobs/overview这种本来毫秒级返回的接口要等好几秒,偶尔直接 500。紧接着是作业提交失败,报akka.pattern.AskTimeoutException之类的超时,现场的人一看提交失败就去重试,越重试越卡,很快形成了一个小型的重启风暴。再往后是 checkpoint 告警刷屏,一批作业的 checkpoint 持续超时,然后 JobManager 日志里开始出现Heartbeat of TaskManager with id xxx timed out,TaskManager 被反反复复地判定失联、重新注册。到这一步,整个集群实际上已经处于半瘫痪状态——跑着的作业勉强在跑,但谁也不敢动,因为一旦有作业失败重启,大概率起不来。
这篇文章我想把这次排查和治理的全过程拆开讲清楚:JobManager 到底在忙什么、为什么会 overuse、怎么用最小的成本定位到是哪个环节在吃资源、以及最后落地了哪些参数和架构层面的改造。如果你手上也管着 Flink 集群,尤其是用 Session 模式承载几十上百个作业的那种,这篇内容应该能帮你提前避开大部分坑。我会把踩过的坑、试错过的参数、以及那些文档里不会写的经验都放进去,涉及具体配置的地方会标注版本差异,因为 Flink 在 1.15 前后对 RPC 和内存模型的改动不小,照抄很容易翻车。
2. JobManager 到底在忙什么:先把职责边界理清楚
2.1 一个 JobManager 里其实塞了五六个组件
很多人对 JobManager 的印象停留在"管调度的大脑",但真到排查性能问题的时候,你必须知道这个"大脑"里具体有哪几个模块在跑,因为它们吃的是同一份 CPU 和内存,互相之间还会抢线程。在一个标准的 JobManager 进程里,通常同时存在这么几个东西:Dispatcher 负责接收作业提交、为每个作业创建一个 JobMaster;ResourceManager 负责 TaskManager 的注册和 slot 的分配回收;JobMaster 每个作业一个,负责生成和调度 ExecutionGraph、协调 checkpoint、处理失败恢复;WebMonitorEndpoint 对外提供 REST API 和 Web UI;BlobServer 负责接收和分发用户 jar 包及依赖文件;如果开了 HA,还有一套基于外部存储的选主和元数据服务。
这套结构在只有几个作业的时候完全没问题,但作业数量一上去,问题就来了。JobMaster 是"每作业一个实例",六十个作业就是六十个 JobMaster 对象,每个 JobMaster 内部还有自己的 checkpoint coordinator、调度线程池、定时器。这些对象本身占的内存不算夸张,但它们产生的调用最终都要汇到共享的 RPC 线程池和共享的 REST 线程池里去。也就是说,作业数量增长带来的负载不是线性摊薄的,而是在几个共享资源点上被放大。
2.2 共享线程池是第一个真正的瓶颈
真正卡住我们的第一个点就是 RPC 线程池。Flink 内部组件之间的通信走的是 RPC 框架,在 1.15 之前是基于 Akka 的 actor 模型,默认 dispatcher 的并行度有个下限值;1.15 之后换成了自己实现的 RPC 服务,配置项叫jobmanager.rpc.num-threads,印象里默认值只有 4。这个数值在单作业、低频交互的场景下够用,但在六十个作业同时跑、每个作业的 checkpoint 又都很频繁的情况下,就完全是杯水车薪了。
我抓线程栈的时候看得很清楚:大量线程卡在flink-akka.remote.default-remote-dispatcher或者 RPC handler 的调用栈上,栈顶是序列化、反序列化的方法,往下是 checkpoint 相关的消息处理。这说明线程池的队列已经堆满了,消息在排队等处理,处理不过来就会导致上游的调用超时,超时又会触发重试,重试再加重队列压力,典型的正反馈恶化。更麻烦的是心跳消息也走这条链路,RPC 一堵,心跳就延迟,JobManager 就会误判 TaskManager 失联,然后把上面的作业全部标记失败重新调度,重新调度又要走一遍资源申请和 ExecutionGraph 构建,负载进一步上升。
这里有个很反直觉的点:JobManager 的 CPU 打满并不一定是它在做"重计算",很可能只是线程在频繁地做上下文切换和对象序列化。所以看到 CPU 高先别急着加核,先抓栈看它在干什么,方向错了加多少核都是白搭。
2.3 Session 模式把资源争抢放大了好几倍
我们当时用的是 Session 模式,所有作业共享一个 JobManager。这个模式的好处是资源利用率高、集群启动一次就能跑很多作业,坏处是所有作业的命运绑在一起——一个作业疯狂触发 checkpoint,受影响的是全集群;一个作业提交了个超大 jar 包,BlobServer 的压力全集群承担;一个作业的 JobMaster 出现内存泄漏,整个 JobManager 进程跟着 OOM。
相比之下,Application 模式下每个应用一个集群,作业之间的隔离性好得多,代价是资源碎片化、集群数量变多、运维复杂度上升。这不是说 Session 模式不能用,而是说用 Session 模式就必须接受一个前提:你要对上面跑的作业有足够的约束能力,包括作业数量上限、checkpoint 频率上限、jar 包大小限制、提交频率限制。如果这些约束做不了,那 Session 模式迟早会变成事故现场,我们那次就是典型的约束缺位。
3. 四步定位法:从宏观指标一路挖到线程栈
3.1 第一步看 JVM 宏观指标,先排除内存和 GC
排查任何 Java 进程的过载问题,我的习惯都是先看 GC,因为 GC 问题会伪装成 CPU 问题,让你在错误的方向上浪费大量时间。具体操作就是拿到 JobManager 进程的 PID,然后用jstat连续采样一段时间:
# 每 1 秒打印一次 GC 统计,连续 30 次 jstat -gcutil <jm_pid> 1000 30重点看几个列:老年代使用率(O)、Full GC 次数(FGC)、Full GC 累计耗时(FGCT)。我们那次的数据是:老年代常年在 60% 到 80% 之间震荡,Full GC 大概每两三分钟来一次,单次耗时 400 到 800 毫秒。这个数据说明内存确实偏紧,但还没到"GC 把 CPU 吃干"的程度——Full GC 的累计耗时占总运行时间的比例不到 3%,显然不是主因。
顺手也看了下堆外内存和 native 内存:
jcmd <jm_pid> VM.native_memory summary如果没开-XX:NativeMemoryTracking=summary这个参数,这条命令会报错,那就得重启加参数,代价比较大。我们当时是直接看了容器的memory.usage_in_bytes和进程 RSS,发现 RSS 比堆上限高出将近 1G,这部分主要是 Metaspace、direct buffer、JVM 自身开销和线程栈。这也解释了为什么 2G 堆的容器经常逼近 4G 的内存上限,一旦超了就是 OOMKilled,容器重启,所有作业重来。
3.2 第二步抓线程栈,找热点线程名
GC 排除掉之后,下一步就是抓线程栈。这个方法看着原始,但在 Flink 这种组件众多的进程里极其有效,因为线程名本身就带语义。我的做法是连续抓五到十份,间隔两三秒,然后对比哪几个线程一直在忙:
for i in $(seq 1 10); do jcmd <jm_pid> Thread.print > /tmp/jstack_$i.txt sleep 2 done抓完以后用grep统计一下状态为RUNNABLE的线程都在哪些名字下面聚集:
grep -A 1 "RUNNABLE" /tmp/jstack_*.txt | grep "^\"" | awk -F'"' '{print $2}' | sed 's/-[0-9]*$//' | sort | uniq -c | sort -rn | head -20我们那次的统计结果非常集中,排在前面的线程名大概是这么几类:flink-akka.actor.default-dispatcher系列(RPC 调用处理)、checkpoint-thread系列(checkpoint 协调)、jobmanager-future-thread-pool(异步任务回调)、flink-metrics-reporter(指标上报)。其中 RPC dispatcher 那一类的数量最多,而且基本都处在 RUNNABLE 状态,栈顶停在序列化相关的方法上。这一下就锁定了方向:RPC 链路被打满了。
顺带还发现了一个细节:WebMonitorEndpoint相关的 handler 线程也一直是满负荷的,栈顶是 JSON 序列化。这跟我们平台侧改成 3 秒轮询一次对上了——轮询请求打到 REST 接口上,每个请求都要序列化一份作业状态快照,作业数量六十多个,等于每 3 秒要序列化六十份作业详情,这个开销在默认只有 4 个线程的 REST 服务里根本扛不住。
3.3 第三步用火焰图确认 CPU 时间去向
线程栈只能看到某个瞬间的快照,想看一段时间内的 CPU 分布还得靠火焰图。Flink 自己内置了火焰图功能,前提是在flink-conf.yaml里打开开关:
rest.flamegraph.enabled: true打开后可以通过 Web UI 或者 REST 接口触发一段采样,产物是 SVG。不过我们当时 UI 已经卡到打不开了,所以用的是 async-profiler 手动采:
# 采样 60 秒,输出 CPU 火焰图 ./profiler.sh -d 60 -e cpu -f /tmp/jm-cpu.html <jm_pid>火焰图出来的结果比线程栈更直观:最宽的那一块落在 JSON 序列化和 Akka 消息的编解码上,其次是 checkpoint 相关的状态处理,再往后是 metrics 的采集与上报。真正的"业务逻辑"(比如调度算法)占比非常小。这个结论其实很有价值——它说明 JobManager 的 CPU 不是在算东西,而是在"搬运和转换数据",那优化方向就很明确了:减少消息总量、减少序列化次数、减少无谓的轮询。
3.4 第四步翻日志,找高频事件的时间线
日志是所有证据里最耗时间但也最能说明因果关系的。我们当时重点搜了几个关键词:
# 心跳超时 grep -c "Heartbeat of TaskManager" jobmanager.log # checkpoint 相关异常 grep -c "Checkpoint.*timed out" jobmanager.log # 作业提交/取消 grep -cE "Submitting job|Job .* switched from state" jobmanager.log # 重启 grep -c "Restarting job" jobmanager.log统计下来触目惊心:一天之内心跳超时记录了一千多次,checkpoint 超时告警三千多条,而作业状态切换的记录有上万条。再按时间维度聚合一下,发现这些事件高度集中在几个时间段——正好跟平台侧的批量部署任务和定时任务触发时间重合。这就把"偶然抖动"的假设排除掉了,问题是结构性的、必然的。
4. 根因拆解:四个把 JobManager 拖垮的具体写法
4.1 高频 checkpoint 与小作业数量叠加
这是最主要的因素,也是最容易被忽视的。单个作业的 checkpoint 间隔设成 10 秒,看起来没什么问题,因为数据量小、状态也小,每次 checkpoint 完成得很快。但 checkpoint 的开销不只是"做快照"那一下,它包含一整套交互:JobManager 的 checkpoint coordinator 触发 checkpoint,给所有 source 发 RPC,source 把 barrier 往下游推,各个算子在完成后上报,JobManager 收集所有 ack、做统计、记录元数据。这一整套下来,单个作业每次 checkpoint 至少要产生几十条 RPC 消息。
六十个作业,每个 10 秒触发一次,意味着平均每秒有 6 次 checkpoint 在流水线上跑,换算成 RPC 消息就是每秒几百条。这些消息全部要经过共享的 RPC 线程池,而且 checkpoint 的状态元数据还要在 JobManager 的内存里保存和更新。数量一旦上去,RPC 线程池的队列开始积压,checkpoint 的完成时间被拉长,超时告警出现,然后重试机制又把负载往上推。这就是我们看到的"checkpoint 越积越多、越来越慢"的恶性循环。
4.2 提交和取消作业的频率远超预期
第二个因素是作业的提交/取消频率。我们平台侧的 CI 流水线做得很"勤快",每次代码合并都会触发一次全量作业的重新部署,一批作业先 cancel 再 submit。六十个作业批量重启一次,对 JobManager 来说就是一百二十次状态变更,每次都要创建或销毁 JobMaster、构建或清理 ExecutionGraph、向 ResourceManager 申请或释放 slot。
关键在于 Dispatcher 处理作业提交的能力是有限的。在某些版本里,Dispatcher 是用单个线程逐个处理提交请求的,也就是说六十个作业同时提交,是排队一个一个来的。每个作业提交的时候还要从 BlobServer 拉取 jar 包、解析、生成 JobGraph。我们的业务 jar 包带了一堆依赖,单个将近 200MB,六十个作业同时提交,等于瞬间要把十几 G 的文件流转一遍。BlobServer 的磁盘 IO 和网络带宽瞬间打满,提交超时,流水线重试,负载再翻一倍。
4.3 平台侧的 REST 轮询和指标上报在悄悄放血
第三个因素很隐蔽,因为它藏在"监控"这个看起来无害的需求里。平台侧为了展示作业状态,做了一套轮询机制,最初是 15 秒一次,后来为了"实时性"改成了 3 秒一次。轮询调的是/jobs/overview再加上每个作业的详情接口,等于每 3 秒要遍历一遍所有作业、序列化一份完整状态。
同时 Flink 自身的 metrics reporter 也在上报,默认间隔 10 秒,上报的指标包括每个作业、每个算子、每个 TaskManager 的几十上百个指标。这两股流量叠加在一起,WebMonitorEndpoint 的线程池被长期占满,进而影响到正常的管理请求——你想手动 cancel 一个作业,请求排在几百个轮询请求后面,等到超时都轮不上。
4.4 ExecutionGraph 规模失控带来的序列化开销
第四个因素跟作业本身的写法有关。有些作业为了图省事,把大量逻辑平铺在同一个作业里,算子数量做到了几百个,并行度还不低。ExecutionGraph 的规模跟算子数、并行度是乘法关系,几百个算子乘以并行度,顶点和边的数量能到几千甚至上万。这个图在 JobManager 内存里要完整保存一份,每次状态变更、每次 checkpoint 都要遍历和序列化。
我们在火焰图上看到的那个特别宽的序列化热点,有一部分就是它贡献的。而且这个问题很隐蔽,因为单个作业看起来"能跑",只是有点慢,你不会意识到它在持续消耗 JobManager 的资源。等到作业数量多了,这些开销叠加起来就非常可观了。
5. 治理方案:从止血到结构性改造
5.1 第一步先止血:把最要命的参数调上去
在没搞清楚根因之前,先做了一些成本最低的调整,保证集群能正常对外服务。这里列一下我们实际改的参数,注意版本差异,我标注的是 1.15 之后的配置名,更早的版本用的是 Akka 那套参数,名字完全不一样:
| 参数 | 原值 | 调整后 | 说明 |
|---|---|---|---|
jobmanager.memory.process.size | 4g | 8g | 容器总内存,给堆外留足空间 |
jobmanager.memory.heap.size | 2g | 4g | 堆内存翻倍,缓解 GC 压力 |
jobmanager.rpc.num-threads | 4(默认) | 16 | RPC 处理线程,直接缓解队列积压 |
rest.server.numThreads | 4(默认) | 16 | REST 处理线程,扛住平台轮询 |
heartbeat.timeout | 10000 | 60000 | 心跳超时放宽,避免误判失联 |
heartbeat.interval | 10000 | 20000 | 心跳间隔拉长,减少消息量 |
web.timeout | 10min | 5min | 缩短前端等待,快速失败 |
这里要重点说两个。jobmanager.rpc.num-threads这个参数调大之后效果立竿见影,提交超时的问题当场缓解了大半。但要注意它不能无脑调,线程数上去之后上下文切换和内存占用也会上去,一般给到 CPU 核数的两到四倍比较合理,我们给 16 是因为这个 JobManager 的负载确实重。heartbeat.timeout更是个双刃剑,放宽能减少误判,但如果真的有 TaskManager 挂了,发现时间也会延后,所以配合着把heartbeat.interval一起调,让心跳消息总量降下来,才是比较稳的做法。
有个细节值得提醒:调大堆内存之后一定要同步调大容器内存,而且要给堆外留足空间。我们第一次只把堆从 2G 调到 4G,容器还是 4G,结果跑了半天直接被 OOMKilled,因为 Metaspace 加上 direct memory 加上线程栈轻松超过 1G。后来容器给到 8G 才稳下来。
5.2 第二步做架构隔离:把鸡蛋从一个篮子里拿出来
参数调整只能延缓问题,治不了根。根本问题是六十个作业共享一个 JobManager,只要作业数量还在涨,早晚还会撞墙。所以我们做了集群拆分:按业务线拆成三个集群,每个集群承载二十个左右的作业;核心的、对延迟敏感的几个作业单独用 Application 模式部署,给独立集群;剩下的小作业、定时任务归到一个"杂项集群"里,这个集群的定位就是可以容忍一定程度的抖动。
拆分的时候有个取舍要想清楚:拆得越细,隔离性越好,但资源碎片和运维成本越高。我们的做法是先按"重要性"分层,再按"资源特征"分组——把 checkpoint 特别频繁的作业聚在一起,把 jar 包特别大的作业聚在一起,避免它们互相伤害。这个思路比单纯按业务线拆更有效,因为同类型的作业负载特征接近,放在一起反而好做针对性的参数优化。
拆完之后,单个 JobManager 上的作业数从六十多降到二十左右,RPC 消息量直接砍掉三分之二,UI 立刻就流畅了。这一步做完,我们才有余力去处理更深层的问题。
5.3 第三步统一 checkpoint 策略,把频率降下来
checkpoint 这块我们定了一套强制的规范,写进了作业模板,新作业默认就用这套:
| 配置项 | 规范值 | 出发点 |
|---|---|---|
execution.checkpointing.interval | 60s | 小作业 10 秒太奢侈,60 秒足够恢复 |
execution.checkpointing.min-pause | 30s | 两次之间留间隙,避免背靠背 |
execution.checkpointing.timeout | 5min | 超时设短,快速失败快速重试 |
execution.checkpointing.max-concurrent-checkpoints | 1 | 禁止并发,减少同时占用的资源 |
execution.checkpointing.unaligned | 视情况开 | 反压严重时开启,减少对齐等待 |
min-pause这个参数很多人不设,但它在新版本里其实很重要。不设的话,如果一次 checkpoint 做得快,下一次马上就来了,等于实际间隔远小于配置的 interval。设了 30 秒,能保证两次之间至少有半分钟喘息,对 JobManager 来说就是实打实地减少了触发次数。
另外我们还推行了"状态瘦身":定期检查作业的 state 大小,超过阈值的必须做 TTL 或者换用增量 checkpoint。有个作业的状态从 8G 降到 300M 之后,它的 checkpoint 耗时从四十多秒降到两秒,对 JobManager 的压力完全不是一个量级。
5.4 第四步掐掉平台侧的无效流量
这一块纯粹是自己人给自己挖的坑。我们把平台侧的轮询从 3 秒改回 10 秒,并且把"拉全量作业详情"改成了"拉一次列表 + 增量更新",只在状态真正变化时才去查详情。同时把 Flink 侧的 metrics 上报间隔从 10 秒放宽到 30 秒,关掉了一批没人看的指标。
# 只保留真正用到的 reporter 和指标范围 metrics.reporter.prom.interval: 30 SECONDS metrics.reporter.prom.factory.class: org.apache.flink.metrics.prometheus.PrometheusReporterFactory这里的心得是:监控本身是有成本的,而且成本经常被低估。每多一个指标、每缩短一次间隔,都是在向 JobManager 收税。做监控方案的时候一定要问自己一句"这个指标真的会有人看吗、出问题真的会用它定位吗",答案是否定的就别上。我们关掉的那批指标里,有一半从来没人查过,但它们每天都在稳定地消耗着 CPU。
6. 参数之外,那些真正决定稳定性的细节
6.1 jar 包管理和提交方式也影响很大
前面提到过 jar 包大小的问题,这块值得单独说。BlobServer 在 JobManager 里,负责接收和分发用户代码。一个 200MB 的 jar,六十个作业并发提交,瞬间就是十几 G 的 IO。我们的做法是把公共依赖抽出来,做成集群级别的依赖库,业务 jar 只保留自己的代码,体积从 200MB 降到 5MB 左右。这一招的效果比调参数还明显,提交耗时从平均四十多秒降到三秒以内。
具体操作是把公共依赖放到 Flink 安装目录的lib/下面,或者在提交的时候通过-C参数指定额外的 classpath。要注意的是,放在lib/下的包会被所有作业共享,版本冲突的风险要自己控制好,我们的做法是把版本锁死,业务侧不允许擅自升级公共依赖。
6.2 定期重启和状态清理不能省
JobManager 是个长期运行的进程,跑久了会有各种累积:元数据缓存、类加载器引用、metrics 对象、日志里的历史状态。这些东西不会一次性把内存吃光,但会让 GC 越来越吃力。我们的经验是,Session 集群的 JobManager 每周做一次滚动重启,挑业务低峰期,先把作业 savepoint 下来,重启后再恢复。
这个操作听着粗暴,但很有效。重启之后老年代使用率能从 70% 多回到 30% 以下,UI 响应速度也会明显改善。当然前提是你的作业都支持从 savepoint 恢复,而且恢复时间在可接受范围内。如果某个作业恢复要半小时,那它就不适合放在 Session 集群里,应该单独部署。
6.3 失败恢复策略的选择会放大或抑制雪崩
jobmanager.execution.failover-strategy这个参数默认是region,意思是失败时只重启受影响的区域。这个策略比full好,但在某些拓扑下区域划分未必理想,一个小算子挂了可能带崩一大片。我们针对几个关键作业做了验证,发现改成region之后单点失败的影响面确实小了很多。
更重要的是要防止重启风暴。我们配了restart-strategy的退避策略,让失败的作业不要立刻重试:
restart-strategy: exponential-delay restart-strategy.exponential-delay.initial-backoff: 10s restart-strategy.exponential-delay.max-backoff: 5min restart-strategy.exponential-delay.backoff-multiplier: 2.0这个配置的意义在于:如果一个作业是因为集群资源不足起不来,立刻重试只会让它再失败一次,白白消耗 JobManager 的调度资源。用指数退避,第一次等 10 秒,第二次 20 秒,慢慢往后推,给集群留出恢复的时间窗口。
7. 常见问题速查与避坑实录
7.1 问题速查表
把这次遇到的和之前踩过的坑整理成一张表,出问题的时候可以对着排查:
| 现象 | 最可能的原因 | 优先排查手段 |
|---|---|---|
| UI 打不开、REST 超时 | REST 线程池被轮询打满 | 线程栈看 WebMonitor 相关线程 |
| 作业提交超时 | RPC 线程池队列积压 | 抓栈看 RPC dispatcher 状态 |
| 心跳超时、TM 频繁重注册 | RPC 拥堵导致心跳延迟 | 统计日志中心跳超时的时间分布 |
| checkpoint 持续超时 | 触发频率过高或状态过大 | 看 checkpoint 的 duration 和 size 指标 |
| JobManager CPU 持续 90% | 序列化开销大或 GC 频繁 | 火焰图 + jstat 双向确认 |
| 容器被 OOMKilled | 堆外内存没算够 | 对比 RSS 和堆上限 |
| 作业反复重启起不来 | 重启风暴或资源不足 | 看 restart 策略和 slot 可用数 |
| 提交时卡在 Blob 传输 | jar 包过大或并发提交 | 看 BlobServer 日志和 jar 体积 |
7.2 几个容易走错的方向
第一个误区是"CPU 高就加核"。我们最早的反应就是把 JobManager 从 2 核加到 4 核,结果 CPU 使用率确实从 95% 降到 70%,但过了两天又回到 95%,因为作业数量也在涨。加资源只是在延后问题,不解决结构性的负载集中。
第二个误区是"内存不够就加堆"。堆从 2G 加到 4G 之后 GC 频率确实降了,但如果容器内存没同步加,就会变成 OOMKilled,而 OOMKilled 比 GC 慢要糟糕得多,因为它是硬重启,所有作业全部重来。加堆之前先算清楚堆外需要多少。
第三个误区是"监控越密越好"。我们平台侧那次改动就是典型的例子,出发点是想让用户看到更实时的状态,结果是把自己打挂了。监控的本质是抽样,采样的频率要和业务的实际需求匹配,不是越高越好。
7.3 几条我认为最有价值的经验
第一,排查顺序永远是"先排除 GC,再看线程,最后看业务"。顺序错了会浪费大量时间在错误的方向上,我见过有人对着火焰图分析了半天,最后发现是 Full GC 导致的 STW,根本没到业务逻辑那一层。
第二,Session 模式的集群必须设置作业数量上限。这个上限不是拍脑袋定的,要根据 JobManager 的规格、平均 checkpoint 频率、平均算子数量算出来。我们的经验值是 2 核 4G 的 JobManager 承载不超过 15 个中等作业,超过就要拆。
第三,任何关于 JobManager 的参数调整,都要在预发环境压测验证。尤其是线程池相关的参数,调大之后的表现跟作业的负载特征强相关,别人的经验值在你这里未必成立。我们的做法是准备一个压测作业集,模拟真实的作业数量和 checkpoint 频率,每次改参数先跑一遍。
第四,把 JobManager 的关键指标纳入告警。我们后来加了这么几条:RPC 线程池队列长度、REST 接口 P99 延迟、JobManager 老年代使用率、单位时间内的作业状态变更次数。这几条指标能在问题爆发前十几分钟就发出预警,给了足够的处置时间。
8. 我个人在这件事上的几点体会
这件事之后我对"资源过载"这个词的理解变了不少。以前觉得过载就是资源不够,加就完事了;现在更倾向于把它看成"负载结构和资源配置不匹配"。我们那个集群的 CPU 和内存按绝对值算都不算紧张,问题出在负载高度集中在几个共享的、容量有限的组件上——RPC 线程池、REST 线程池、Dispatcher 的处理能力。这些组件的容量是固定的,跟作业数量的增长不成比例,所以只要作业数往上走,早晚会撞上。
另一个体会是关于"小作业"的。很多人觉得小作业不占资源,实际上从 JobManager 的角度看,小作业的固定开销一点也不小:一个 JobMaster 对象、一套 checkpoint 协调逻辑、一套 metrics 上报、一份 ExecutionGraph。作业越小,这些固定开销占比越高,性价比越差。所以后来我们推了一波作业合并,把几十个定时小任务合成了几个大作业,JobManager 的负载直接降了一半。
最后想说的是,这类问题的定位过程其实没什么高科技,就是老老实实地看指标、抓栈、翻日志,把时间线对上,把因果链理清楚。工具都是现成的,难的是判断力——知道先看什么、看到某个现象该往哪个方向想、哪些证据可以互相印证。这个能力只能靠一次次真实的排查积累,看再多的文档也替代不了。