状态计算基础:有状态 vs 无状态,状态的定义与分类

聊流批一体,绕不开一个核心概念——状态。

我刚开始接触流计算时,对“状态”的理解很模糊。觉得不就是存点数据嘛,有啥好讲的?后来在线上环境踩了几次坑,才明白状态设计的好坏,直接决定了作业能不能跑稳、跑快。

这一节,咱们把状态计算的基础打扎实。

有状态计算 vs 无状态计算

先看个最简单的例子。你写一个 map 函数,输入一条数据,输出一条数据。它不依赖任何历史记录,这就是无状态计算。

// 无状态:输入什么,输出什么
DataStream<String> stream = env.fromElements("a", "b", "c");
stream.map(value -> value.toUpperCase());

但如果你要算“截止到当前,一共来了多少条数据”,那就需要记住之前的结果。这个“记住”的能力,就是有状态计算。

核心区别一句话:无状态计算不依赖过去,有状态计算需要“记东西”。

我个人习惯这样区分:

  • 无状态:幂等、易扩展、失败后重启无负担。适合做过滤、格式转换、简单映射。
  • 有状态:需要维护累加器、窗口聚合、双流 join、去重逻辑。这些场景,没状态根本玩不转。

你想想看,如果做实时去重,不记住之前来过哪些数据,怎么判断新来的数据是不是重复?

避坑指南:我曾经接手过一个项目,同事把所有状态都塞在内存 HashMap 里。作业跑了一周,一次重启后数据全乱了。为什么?因为 HashMap 的状态不会自动恢复。记住:生产环境一定要用框架管理的状态,别自己搞。

状态的定义与分类

Flink 把状态分成两大类:Keyed State 和 Operator State。

Keyed State(键控状态)

Keyed State 是“按 key 隔离”的状态。什么意思呢?就是每条数据根据它的 key,只访问自己那份状态。

举个例子:统计每个用户的点击次数。用户 A 的计数不会影响用户 B 的计数。这就是 Keyed State 的典型场景。

// Keyed State 示例:统计每个用户的点击次数
DataStream<ClickEvent> clicks = ...;
clicks
    .keyBy(event -> event.getUserId())
    .process(new KeyedProcessFunction<String, ClickEvent, Long>() {
        
        private ValueState<Long> countState;
        
        @Override
        public void open(Configuration parameters) {
            ValueStateDescriptor<Long> descriptor = 
                new ValueStateDescriptor<>("clickCount", Types.LONG);
            countState = getRuntimeContext().getState(descriptor);
        }
        
        @Override
        public void processElement(ClickEvent value, Context ctx, Collector<Long> out) {
            Long current = countState.value();
            if (current == null) current = 0L;
            countState.update(current + 1);
            out.collect(countState.value());
        }
    });

Keyed State 有几种常见类型:

状态类型 说明 适用场景
ValueState<T> 单个值,类型由 T 指定 计数器、开关标志
ListState<T> 列表,可追加元素 缓存最近 N 条记录
MapState<UK, UV> 键值映射 维度表缓存、配置映射
ReducingState<T> 自动聚合,保留聚合结果 累加求和、求最大值
AggregatingState<IN, OUT> 自定义聚合逻辑 复杂指标计算

注意:Keyed State 必须在 keyBy 之后才能使用。如果你没做 keyBy,直接调用 getState(),运行时会报错。这个坑我见过不止一次。

Operator State(算子状态)

Operator State 是“算子级别”的状态。它不按 key 划分,而是按并行子任务划分。每个并行实例维护自己的一份状态。

典型的应用场景:Kafka 连接器的 offset 记录。每个并行子任务消费自己的分区,记录自己消费到的位置。

// Operator State 示例:记录每个子任务处理过的数据条数
public class MyStatefulSource extends RichParallelSourceFunction<Long> {
    
    private ListState<Long> offsetState;
    private long offset = 0;
    
    @Override
    public void open(Configuration config) {
        ListStateDescriptor<Long> descriptor = 
            new ListStateDescriptor<>("offset", Types.LONG);
        offsetState = getRuntimeContext().getListState(descriptor);
    }
    
    @Override
    public void run(SourceContext<Long> ctx) {
        // 从状态恢复 offset
        for (Long value : offsetState.get()) {
            offset = value;
        }
        // 生产数据...
    }
}

Operator State 目前主要支持两种:

  • ListState:每个并行子任务维护一个列表。重启时,所有子任务的列表合并起来,再重新分配。
  • UnionListState:类似 ListState,但重启时把完整列表广播给每个子任务。适合需要全局视图的场景。

状态后端(State Backend)选型

状态存哪儿?怎么存?这就是状态后端要解决的问题。

Flink 提供了三种状态后端,我按自己的使用经验给你排个序:

状态后端 存储位置 适用规模 我推荐的使用场景
MemoryStateBackend TaskManager 内存 + JobManager 内存(快照) 极小(百 MB 以内) 本地开发调试,千万别上生产
FsStateBackend TaskManager 内存 + 外部文件系统(快照) 中等(GB 级别) 小规模生产,状态不大时可用
RocksDBStateBackend RocksDB(本地磁盘) + 外部文件系统(快照) 超大(TB 级别) 生产首选,能扛大状态

我的建议:生产环境无脑选 RocksDB。除非你的状态真的只有几百 MB,而且对延迟极其敏感,才考虑 FsStateBackend。MemoryStateBackend 只适合在 IDE 里跑着玩。

为什么我力推 RocksDB?

  • 它把状态存在本地磁盘,不受 JVM 堆内存限制。你想想看,如果状态有 100GB,堆内存怎么可能装得下?
  • RocksDB 支持增量 checkpoint,只上传变化的部分,大大减少快照时间。
  • 它天然支持异步快照,不会阻塞正常的数据处理。

但 RocksDB 也有代价:读写需要序列化/反序列化,比纯内存访问慢一些。不过对于绝大多数场景,这点延迟完全可以接受。

选型口诀:小状态用内存,大状态用 RocksDB。不确定大小时,直接上 RocksDB 最稳妥。

下面这张图总结了状态计算的核心知识体系:

状态计算核心知识体系 状态计算 有状态计算 无状态计算 Keyed State(键控状态) Operator State(算子状态) ValueState ListState MapState ReducingState AggregatingState ListState UnionListState 状态后端选型 MemoryStateBackend(开发用) FsStateBackend(小规模) RocksDBStateBackend(生产推荐) 核心原则:小状态用内存,大状态用 RocksDB。不确定时直接上 RocksDB 最稳妥

嗯,这一节的内容就到这儿。状态计算的基础打牢了,后面讲状态复用、增量 checkpoint、容灾恢复时,你才能理解为什么有些设计是必要的。