如果你已经开始在项目里用Flink写流式作业,大概率会碰到这类场景:本地能跑通的WordCount,一提交到集群上就卡住不动;Web UI里明明有4个TaskManager,任务却一直停在SCHEDULED;设置了并行度10,却有一半task迟迟没有进度。这些问题,本质上都和“集群架构”以及“运行模式”这两件事没对上号有关。
这篇文章就把这两个主题拆开讲透:Flink集群里每个进程到底在忙什么,一份作业从提交到出数的完整流转链路是怎样的,Standalone、YARN、Kubernetes三种运行模式有什么本质差异、该怎么选,最后我会整理几个实际部署和排障中踩过的坑。适合正在做部署、排障,或者打算把Flink从开发机迁到生产集群的朋友。读完你至少能建立一张清晰的图:谁在调度、谁在执行、作业走到哪一步会卡住。
1. 先认清三个角色:JobManager、TaskManager和Client谁在干活
Flink集群大体是master-worker形态,但Flink把“master”这个角色拆得比Spark细。很多任务起不来,是因为只看到了两个进程名字,没搞懂内部组件的分工。所以我建议先不要急着看slot和并行度,把角色对齐了,后面所有问题都能对上号。
1.1 JobManager:一个名字好记,其实内置三个部门
Flink 1.9以后的JobManager,已经不是一个“大而全的独裁者”,组件上拆成了Dispatcher、ResourceManager、JobMaster三块。
- Dispatcher:对外提供REST接口和Web UI,接收客户端提交的作业,也是作业进入集群的第一站。它负责启动对应的JobMaster,并维护作业提交的元数据。
- ResourceManager:负责slot的注册与分配。哪个TaskManager登记了多少slot、某个作业申请多少个slot,都由它来协调。在Standalone模式下,它管理的是集群静态的slot池;在YARN或Kubernetes模式下,它背后还会对接外部资源管理系统,动态申请容器。
- JobMaster:跟具体作业绑定,一个作业对应一个JobMaster。它负责把ExecutionGraph上的任务真正调度到TaskManager的slot上,同时协调checkpoint,处理作业执行过程中的故障恢复。
打个比方,JobManager像一家施工总包公司:Dispatcher是前台接待,ResourceManager是物料调度员,JobMaster才是每个工地的具体项目经理。TaskManager则是下面真正干活的施工班组。
生产环境里,JobManager至少要有两个进程来做高可用。默认只起一个当然也能跑,但一旦进程崩溃,所有作业都会跟着断掉。多JobManager之间通过ZooKeeper或etcd做leader选举,active节点挂了以后,standby节点会从HA存储中把作业元数据拉回来,继续对外提供调度。有一点需要理解清楚:standby接管的是作业的“图纸和管理权”,不是把计算进度也恢复了,真正要等待TaskManager重新连接、再通过checkpoint恢复状态。
1.2 TaskManager:slot不是线程数,它只是内存份额
TaskManager是真正执行用户代码的worker节点,本质是一个JVM进程。它启动后会把自己拥有的slot数量注册给ResourceManager,然后等待调度。
很多人对slot有误解,以为slot数是线程数,或者认为是CPU核数。实际上,slot是TaskManager进程内部的内存资源划分单元。每个slot承载一组任务,slot与slot之间的堆内存、托管内存是隔离的,但它们共享同一个JVM的CPU资源。Flink没有为每个slot做CPU配额,一个任务里出现计算密集的循环,是可能拖累同机其他slot的。
默认情况下,同一个作业里的不同算子可以共享slot,这就是插槽共享机制。正因为有它,一个普通作业需要的slot总数约等于所有算子中的最大并行度,而不是所有算子并行度之和。这是后面排查“任务卡在SCHEDULED”的关键前提。
实践经验上,一台物理机放几个TaskManager、每个开几个slot,需要结合内存和GC来定。比如8核32G的机器,我更倾向于起2个TaskManager,每个给4个slot、8G左右内存,而不是开一个大进程塞8个slot。原因很简单:单个JVM堆过大,GC停顿的时间会变长,故障隔离半径也被拉大了。如果业务状态特别大,slot数量更要谨慎,尽量让一个TaskManager少承载几个并行实例。
1.3 Client:任务提交完就可以走的“中介”
Client是我们在本地跑flink run时候的进程。它负责读取用户代码,把写好的DataStream程序转换成StreamGraph,然后进一步优化成JobGraph,提交给Dispatcher。
这里有个容易被忽略的细节:--detach参数。加-d提交后,客户端可以立刻退出,作业继续在集群里跑,日志不会打印到本地;不加-d,客户端会保持attach模式,把TaskManager转发过来的日志流式打印在前台。开发时attach方便,生产环境我建议一律detach,日志交给采集系统统一管理,否则长期任务一旦终端断开,会影响作业运行。
在YARN和Kubernetes的application模式下,main函数会被搬到集群内部运行,传统意义上的Client就不需要长期存在了。这个模式我们后面讲运行模式时会再展开。
2. 一条作业从提交到跑起来,中间发生了什么
搞清楚三大角色,接下来看流转链路。你写的代码,会经历三张图的层层变换,最后才变成TaskManager上一个个并行的Task。这个过程里隐藏了几乎所有调度问题的根因。
2.1 三张图:StreamGraph、JobGraph、ExecutionGraph
第一张图是StreamGraph。env.addSource、各种map/filter/window、addSink这些API调用,在代码执行阶段会构建成一个逻辑DAG。这个阶段只是“把流程画出来”,还没有考虑并行度怎么铺开。
提交前,客户端会做算子链合并,把可以串联执行的多个算子合并成一个执行单位,生成JobGraph。比如source.map.filter.sink如果满足条件,就可以合并成一条执行链,最终变成一个JobVertex。合并条件比较严格:
- 上下游算子并行度必须相同;
- 数据分区模式必须是forward,也就是上游算子的结果原样传给下游,不做重分区;
- 上下游算子没有被显式隔离,且都允许chaining;
- 中间没有窗口、keyBy这类需要重分布的操作。
合并的价值在于减少调度单元数,也减少TaskManager之间的网络传输和序列化开销。我见过有人为了排查方便,给每个算子都加.disableChaining(),结果一个简单作业从几个task膨胀到几十个task,网络开销陡增。排查问题时用一用没问题,生产千万别默认全局禁用。
第三张图是ExecutionGraph。JobGraph被提交到JobManager后,JobMaster会按并行度把每个JobVertex展开成多个ExecutionVertex,比如并行度10,一个JobVertex就变成10个ExecutionVertex,它们之间再连上ExecutionEdge。这张图才是真正可以调度的并行执行图。
2.2 调度器是如何分配slot的
ExecutionGraph生成以后,调度器开始干活:把每个ExecutionVertex放置到一个具体slot上执行。
放置策略上,流处理作业更看重的是负载均衡,所以要尽量把一个作业的并行任务分散到不同TaskManager,避免所有任务挤在同一台机器上,把内存和网络打满。如果可用slot不够,调度器并不会让作业立刻失败,而是让对应ExecutionVertex停在SCHEDULED状态等待。这就是为什么你会在Web UI上看到作业一直“卡住”但没有任何报错的原因。
作业等待资源的过程中,JobManager会继续接受新的TaskManager注册。一旦有slot空出来,调度器会立刻补上。如果等了很久都没有资源,可以通过配置缩短超时,让作业快速失败并触发告警,而不是一直干等。这一点在线上尤为重要,我习惯把等待时长压到几分钟以内,避免故障作业悄悄挂一晚上。
2.3 数据在任务之间怎么流动:pipelined、blocking和反压
任务间传输数据时,分区策略决定了每条记录去哪个下游任务。常见几种:
- forward:数据原样交给下一个并行实例,要求并行度一致,通常出现在算子链内部;
- keyBy:按key做hash分区,保证相同key进入同一个下游实例;
- rebalance:轮询分发;
- broadcast:广播给所有下游实例。
传输模型上,流任务默认是pipelined的:上游有数据就往下发。TaskManager之间通过Netty通道和内存中的网络缓冲传递数据。当下游处理不过来,网络缓冲被占满,发送端就会感受到背压,从而放慢生产速度,这就是反压机制。它本质上是好事情,让整个链路不会因为下游过慢而丢数据或耗尽内存。
在批式执行中,还会出现blocking数据边界:上游任务把全部结果写完、落盘或落内存缓冲后,下游任务才被调度执行。这种stage式执行和流式pipelined完全不同,对内存和磁盘的要求也不同。如果一个作业里同时混合流表和批表计算,网络缓冲很容易成为瓶颈,后面我会专门讲内存参数怎么调。
3. Standalone、YARN、Kubernetes,三种运行模式怎么选
从资源来源的角度看,Flink本身不是一个资源管理系统,它需要从外部拿到机器或容器。三种运行模式的区别,本质上是“谁来提供计算资源、谁负责进程生命周期、作业之间如何隔离”。
3.1 Standalone:轻量,但资源全靠手动管理
Standalone是Flink自带的最简单运行模式。通过bin/start-cluster.sh启动一组常驻进程:一个或多个JobManager、若干TaskManager。所有提交到这个集群的作业,共享这一组节点的固定资源池。
它的典型问题就是资源静态化。总slot数等于所有TaskManager slot数之和,作业提交多了就会互相争抢,不会动态扩容。想在Standalone上增强能力,需要靠外部运维工具来管理进程、做监控和告警,Flink本身给不了太多支持。
我的建议是:本地调试、给教学演示、小团队实验可以用Standalone,但生产环境除非公司已有非常成熟的进程托管平台,否则别硬上。它的价值在于,作为理解Flink调度模型的起点,完全没有YARN和K8s的额外概念干扰。
3.2 YARN模式:生产最成熟,重点看三种子模式
YARN是Flink on大数据集群最经典的形态。只要公司已有Hadoop生态,YARN模式几乎是性价比最高的选择。大体流程是:客户端向YARN的ResourceManager申请容器,然后启动Flink的JobManager和TaskManager。
YARN下有三种子模式,非常容易混淆:
- Session模式:先启动一个常驻的Flink集群,之后提交多个作业进去共享资源。好处是启动快、复用JobManager;坏处是多个作业混在一起,资源配置容易互相干扰,JobManager故障会影响所有作业。适合短任务、临时分析。
- Per-job模式:每次提交作业都单独起一个Flink集群,作业结束集群就释放。隔离性比Session好很多,但main函数还是跑在客户端,客户端断网或长时间占着终端,会影响作业生命周期管理。
- Application模式:连main函数也搬到集群内运行。客户端只负责把作业jar包提交上去,提交完就可以退出。这是目前官方最推荐的生产模式,资源隔离干净、运维链路清晰,也避免了客户端断连带来的问题。
提交命令图上算子,简单演示一下:
# 启动一个YARN session集群 bin/yarn-session.sh -n 4 -s 4 -jm 1024m -tm 4096m # 往这个session集群提交作业 bin/flink run -m yarn-cluster -p 8 -d examples/streaming/WindowJoin.jar# Application模式提交(推荐) bin/flink run-application -t yarn-application \ -p 8 \ -Djobmanager.memory.process.size=2048m \ -Dtaskmanager.memory.process.size=4096m \ /path/to/your-job.jar在YARN模式里要注意,Flink申请到的容器内存和taskmanager进程内存不是一回事。-tm参数指定的是TaskManager容器内存,至少要比Flink进程内存多出一部分余量,否则容器可能因为超出YARN限制被杀死。
3.3 Kubernetes模式:云原生,但别为了“新”而上
Kubernetes模式下,Flink原生跟K8s API交互,以容器为最小单位拉起JobManager和TaskManager。最常用的是application模式:提交作业时,Flink向K8s创建JobManager Deployment,JobManager进程内直接运行main函数,再根据并行度申请TaskManager Pod。
K8s的优势是隔离好、弹性好、跟监控、日志、CI/CD体系容易打通。缺点是排障链路变长,网络层面要从Pod IP、DNS、Service转发开始排查;状态后端放在RocksDB时,还要考虑本地磁盘和PVC容量;Pod频繁重启导致IP变化,也会影响TaskManager之间的连接恢复。
所以我的态度一直很明确:如果公司没有成熟的容器平台和配套监控,业务量也没大到必须弹性伸缩,YARN往往比K8s更省心。K8s适合已经在云原生体系里落地的团队,部署形态可以逐步过度,不必一步到位。
3.4 三种模式的横向对比
| 对比维度 | Standalone | YARN | Kubernetes |
|---|---|---|---|
| 资源来源 | 自有常驻进程 | YARN容器 | Pod容器 |
| 是否常驻 | 是,常驻集群 | Session常驻,Per-job/Application作业即用即释放 | Session/Application模式可常驻 |
| 作业隔离性 | 差,共享全部资源 | 中等到好,取决于子模式 | 好,容器级隔离 |
| Client依赖 | 提交后可退出 | Application模式可退出 | Application模式可退出 |
| 动态伸缩 | 无 | 有,基于容器申请 | 有,可结合HPA |
| 运维复杂度 | 低 | 中 | 高 |
| 典型适用 | 开发、测试、学习 | 生产Hadoop生态成熟团队 | 云原生基础设施成熟团队 |
选型其实没有“绝对正确”,核心是看团队已有的基础设施。我自己经历过的情况是:从Standalone直接跳到K8s,中间绕了很多弯路,回过头来觉得YARN的成熟度被严重低估了。
4. 集群搭好了,作业还是跑不动:现场排障四件事
很多问题不是Flink本身的bug,而是对架构和运行模式理解不足导致的。这里整理四个我在实际现场经常遇到的场景,每一个都有明确的现象、原因和解决办法。
4.1 Slot供需错配:为什么作业一直“卡住”
现象是作业提交后一直处于RUNNING但task停在SCHEDULED,Web UI上看不到任务进度,也没有异常日志。
原因通常是作业需要的slot数大于集群当前可用slot。由于slot共享机制的存在,一个作业需要的slot数不是所有算子并行度之和,而是最大并行度。比如某作业source并行度10,window并行度10,sink并行度6,那它实际需要大约10个slot。如果集群当前只有8个可用slot,就会有2个ExecutionVertex一直等待。
排查时先看集群总slot:
bin/flink list -m <jobmanager:port>Web UI的Task Managers页面直接显示每个TaskManager的slot数量和当前使用量。解决方法无非三个方向:增加TaskManager个数、给作业降低最大并行度、关掉某些作业释放资源。
我见过最典型的误操作是:YARN模式下,给一个作业设置了很高的并行度,但申请TaskManager的slot数不足,作业卡在等待资源。这种问题只要在提交前算一笔账:所需slot ≈ 最大并行度 + 预留缓冲,就不会踩。
4.2 反压从哪来、怎么消
反压本身不是错误,它是一个信号。Web UI的Back Pressure页面会给出每个task的反压状态,HIGH表示已通过反压传导到当前task,OK表示处理正常。
定位反压源头,通常是从下游往上游看。比如Kafka消费lag持续上涨,但Sink写数据库的速度很慢,那瓶颈往往就在Sink或者它调用的外部系统上。不要一上来就加并行度,先去查外部存储的写入延迟、连接池大小、SQL慢查询。
如果确认是单算子计算密集导致的反压,可以考虑增加并行度并保证slot够用;如果是因为外部RPC太慢,优先用异步IO改造,而不是扩大并行度——加大并行度只会让更多线程同时去等外部响应。
网络缓冲太小也会加剧反压。高吞吐的shuffle场景,我习惯把taskmanager.memory.network.min和max调大,操作要在作业提交前的conf里配好,或者用-D参数覆盖。
4.3 内存模型和OOM,别一股脑设大堆
Flink 1.10之后的内存模型比老版本清晰很多,TaskManager进程内存大致分为:
| 内存区域 | 主要用途 | 默认说明 |
|---|---|---|
| JVM堆内 | 算子状态、用户代码对象 | 由task heap控制 |
| JVM直接内存/堆外 | 网络缓冲、框架、RocksDB | 常被忽略 |
| 托管内存 | 状态后端、排序缓冲区 | 默认占较大比例 |
| JVM开销 | MetaSpace、线程栈等 | 需要预留 |
常见的内存OOM有以下几种特征:
- Java heap space:堆内不足,优先考虑业务状态是否超过预期,再决定调大task heap;
- Direct buffer memory:网络缓冲或堆外框架内存不足,通常出现在shuffle量大或者使用了堆外状态后端的场景;
- RocksDB占用暴涨:托管内存不足时,RocksDB会占用超出预期,导致进程被YARN/K8s杀掉。
这里分享一个实际调参经验:如果状态用的是RocksDB,不要盲目调大JVM堆。RocksDB是磁盘型状态后端,大量数据在本地磁盘,JVM堆设太大反而浪费,托管内存 block cache 才是更关键的参数。反之,如果状态全在堆上,再设置过大的托管内存只会挤压业务堆空间。配置示例:
jobmanager.memory.process.size: 2048m taskmanager.memory.process.size: 4096m taskmanager.memory.managed.fraction: 0.4 taskmanager.memory.network.min: 64mb taskmanager.memory.network.max: 256mb一组好的参数,永远来自对作业内存画像的观察,而不是照抄网上模板。
4.4 状态后端和checkpoint:容错能力藏在细节里
集群模式下,状态后端的选择直接决定checkpoint和恢复能力。先放一个对比:
| 对比维度 | HashMapStateBackend | RocksDBStateBackend |
|---|---|---|
| 状态存储位置 | JVM堆内 | 本地磁盘 + 托管内存缓存 |
| 容量上限 | 受限于堆大小 | 可超过内存容量 |
| 读写性能 | 快 | 相对慢,受磁盘影响 |
| 生产适用 | 小状态、原型 | 大规模状态、生产主流 |
checkpoint由JobManager的JobMaster协调触发,TaskManager里的每个算子定期把状态快照发到持久化存储(HDFS、S3、本地文件)。集群故障后,作业会从最近一次的checkpoint恢复。
生产里我建议把状态后端设为RocksDB,checkpoint间隔设置在30秒到60秒之间,并设置最小暂停间隔,避免周期性checkpoint任务之间频繁重叠:
state.backend: rocksdb state.checkpoints.dir: hdfs:///flink-checkpoints execution.checkpointing.interval: 30s execution.checkpointing.min-pause-between-checkpoints: 10s另有一个小技巧:大型作业做版本升级前,主动打一次savepoint,而不是只依赖自动checkpoint。Savepoint更像一份“手工快照”,它可以在作业停止、代码变更、集群迁移后作为恢复起点。恢复命令也很简单:
bin/flink run -s hdfs:///flink-savepoints/savepoint-XXXX \ /path/to/your-job.jar这样你在上线新版本前,永远有一条可靠的回滚通道。
5. 我个人的推进路线建议
如果你第一次把Flink从开发机迁到生产,我建议的路线是:先在Standalone上把slot、内存、checkpoint这些概念跑明白,再切换到YARN Application模式试生产,等团队和基础设施都准备好了,再去碰Kubernetes。不要一上来就上最复杂的部署形态,排障的时候你会发现,问题往往出在最基础的理解上。
还有一条运维细节:如果集群里TaskManager日志没有统一采集,那出问题时会非常被动。所有作业尽量用detach模式运行,日志交给采集系统集中检索,Web UI和日志平台配合着看,才能快速定位。
架构和运行模式属于那种“懂了不觉得难,不懂天天踩坑”的知识点。希望这篇文章能把你的那张图补完整,后面遇到作业卡住、OOM、反压的时候,你起码知道应该先去看哪个组件、查哪段链路。