Flink运行时架构:JobManager、TaskManager、Slot、并行度与状态的关系

说实话,很多初学者学Flink状态管理,一上来就盯着API看,结果越学越迷糊。为什么?因为没搞懂运行时架构。状态不是凭空存在的,它得有个“家”——这个家就是TaskManager里的Slot。今天我就带你把这几个核心角色捋清楚。

一、Flink集群的“三驾马车”

一个Flink集群跑起来,主要靠三个角色配合:JobManager、TaskManager、Client。咱们重点说前两个,因为状态管理跟它们关系最密切。

1. JobManager:大脑

JobManager是整个集群的“指挥官”。它负责接收作业、生成执行计划、协调检查点、故障恢复。说白了,它不干活,但它知道谁该干什么。

我在项目中遇到过一个问题:JobManager挂了,整个作业就废了。所以生产环境一定要配HA(高可用),用ZooKeeper做leader选举。别省这个配置,血的教训。

2. TaskManager:工人

TaskManager是真正干活的。它启动多个线程,每个线程处理一个子任务。状态就存在TaskManager的内存或磁盘里。

每个TaskManager会向JobManager注册,报告自己有多少个Slot。JobManager根据这些信息分配任务。

3. Slot:工位

Slot是TaskManager内部的资源隔离单元。一个TaskManager可以配置多个Slot,每个Slot占用固定的内存。

举个例子:你给TaskManager分配了8GB内存,设置4个Slot,那每个Slot就是2GB。这个2GB包括算子计算用的内存,也包括状态存储用的内存。

核心理解:Slot是资源的最小分配单位,也是状态的最小承载单位。一个Slot里跑一个子任务,这个子任务的状态就存在这个Slot对应的内存区域里。

二、并行度:到底开几个线程干活?

并行度决定了你的算子有几个并行实例。比如并行度设为3,那Source、Map、Sink这些算子就会各开3个线程,分布在不同的Slot上。

并行度可以在四个层面设置,优先级从低到高:

配置层级 设置方式 生效范围
算子级别 .map(...).setParallelism(4) 单个算子
执行环境级别 env.setParallelism(4) 整个作业
提交作业时 -p 4 覆盖环境设置
配置文件 parallelism.default: 4 全局默认

我个人的习惯是:在算子级别设置关键算子的并行度,其他用环境默认值。这样灵活,也好调优。

三、并行度与Slot的关系:一张图说清楚

很多人搞不清并行度和Slot到底怎么对应。我画了一张图,你看完就明白了。

并行度与Slot分配关系图 JobManager TaskManager 1 (4 Slot) TaskManager 2 (4 Slot) Slot 0 Slot 1 Slot 2 Slot 3 Slot 0 Slot 1 Slot 2 Slot 3 Source[0] Map[0] Sink[0] Source[1] Map[1] Sink[1] Source[2] Map[2] Sink[2] Source[3] Map[3] Sink[3] 关键说明 • 并行度=4,每个算子有4个子任务(Source[0-3]、Map[0-3]、Sink[0-3]) • 每个Slot运行一个完整的算子链(Source→Map→Sink) • 状态存储在各自Slot的内存中,互不干扰 • Slot数量 ≥ 最大并行度,否则任务会排队等待

你看,并行度4的作业,需要至少4个Slot来跑。如果集群只有3个Slot,那第4个子任务就得等着。这就是为什么我建议你配置Slot数量时,要大于等于作业的最大并行度。

四、状态到底存在哪里?

这个问题我问过很多面试者,答案五花八门。其实很简单:状态存在TaskManager的Slot里。

具体来说:

  • 堆内存状态(HeapKeyedStateBackend):存在JVM堆里,速度快,但受GC影响大。适合小状态。
  • RocksDB状态(RocksDBStateBackend):存在本地磁盘,RocksDB管理。适合大状态,但读写慢一点。

我个人的经验是:状态小于1GB用堆内存,大于1GB用RocksDB。别纠结,选错了后面调优很痛苦。

小技巧:如果你用RocksDB,记得给TaskManager多分配一些堆外内存。因为RocksDB本身不占JVM堆,它用堆外内存和磁盘。我曾经因为没配堆外内存,导致RocksDB频繁刷盘,性能惨不忍睹。

五、并行度变化时,状态怎么办?

这是生产环境经常遇到的问题。你想想看,业务量涨了,想扩大并行度,但状态已经存在原来的Slot里了。怎么迁移?

Flink支持两种模式:

模式 说明 适用场景
重新缩放(Rescale) 状态按照Key Group重新分配 KeyedStream,状态按Key分布
均匀分布(Rebalance) 状态轮询分配给新算子 非KeyedStream,Operator State

举个例子:原来并行度2,状态分布在2个Slot里。改成并行度4后,Flink会把每个Slot里的状态按Key Group拆成两份,分给4个Slot。这个过程叫状态重分布。

注意:状态重分布是有代价的。它会触发一次全量快照,然后恢复。如果你的状态很大(比如几百GB),这个过程可能持续几分钟。我建议你在业务低峰期做扩缩容操作。

六、一个完整的例子:从架构到状态

说了这么多,咱们看一段代码。这是一个简单的计数作业,统计每个用户的点击次数。

public class ClickCountJob {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        
        // 设置并行度为4
        env.setParallelism(4);
        
        // 开启检查点,每5秒一次
        env.enableCheckpointing(5000);
        
        DataStream<ClickEvent> clicks = env
            .addSource(new ClickSource())
            .name("click-source");
        
        DataStream<UserCount> counts = clicks
            .keyBy(event -> event.userId)
            .map(new RichMapFunction<ClickEvent, UserCount>() {
                
                // 声明ValueState,存储每个用户的点击次数
                private ValueState<Long> countState;
                
                @Override
                public void open(Configuration parameters) {
                    ValueStateDescriptor<Long> descriptor = 
                        new ValueStateDescriptor<>("count", Types.LONG);
                    countState = getRuntimeContext().getState(descriptor);
                }
                
                @Override
                public UserCount map(ClickEvent event) throws Exception {
                    Long currentCount = countState.value();
                    if (currentCount == null) {
                        currentCount = 0L;
                    }
                    currentCount++;
                    countState.update(currentCount);
                    return new UserCount(event.userId, currentCount);
                }
            })
            .name("count-map");
        
        counts.addSink(new PrintSink<>()).name("print-sink");
        
        env.execute("Click Count Job");
    }
}

这个作业跑起来后,架构是这样的:

  • JobManager接收作业,生成执行计划,把任务分配给TaskManager。
  • 4个并行度,需要至少4个Slot。每个Slot跑一个完整的算子链:Source → Map → Sink。
  • 每个Map算子的Slot里,都有一个countState。它只存储分配到该Slot的那些userId的计数。
  • 如果某个userId的点击事件被路由到Slot 0,那它的状态就存在Slot 0里。下次这个userId再来事件,还是去Slot 0。

为什么会这样?因为keyBy保证了同一个Key的数据永远去同一个分区。这是状态一致性的基础。

七、避坑指南:我踩过的几个坑

做Flink这几年,我在状态和架构上栽过不少跟头。分享几个典型的:

  1. Slot数量配少了:我曾经把Slot数量配成和并行度一样,结果一个TaskManager挂了,整个作业就瘫了。后来我改成Slot数量 = 并行度 × 1.5,留出冗余。
  2. 状态太大导致OOM:堆内存状态默认无界,状态大了直接撑爆JVM。后来我改用RocksDB,并设置了state.backend.rocksdb.memory.managed: true,让Flink自动管理内存。
  3. 并行度变化后状态丢失:有一次我改了并行度,忘了从保存点恢复,结果状态全丢了。记住:改并行度一定要指定--fromSavepoint。

一句话总结:JobManager管调度,TaskManager管执行,Slot管资源,并行度管并发,状态管数据。它们的关系就像公司架构:CEO(JobManager)分配任务,员工(TaskManager)在工位(Slot)上干活,每个员工负责一部分客户(状态),客户越多(并行度大)就需要更多工位。

好了,这一章的内容就到这里。你理解了运行时架构,后面学状态API、检查点、保存点就会轻松很多。记住我这句话:状态管理的问题,十有八九是架构没搞懂。