上一篇讲了有界流与无界流,其中一个例子是「按类别统计每分钟订单金额」,用到了 sum 聚合。如果你真的把那段代码跑起来,会看到每来一条记录,输出金额就往上累加一点。问题来了:累加的中间值存在哪?作业重启之后,它是从零重算,还是接着上次的继续?
答案是状态(State) 。sum 这类聚合算子内部维护了一个状态,跨记录、跨窗口保存中间结果。这一篇就把这件事拆开讲清楚:状态到底是什么、分哪几类、存在内存还是磁盘、Checkpoint 怎么把它存下来又怎么恢复。理解了这些,才谈得上调优、排障,以及回答面试里那个经典问题------「Flink 的 exactly-once 是怎么做到的」。
一、无状态与有状态算子的本质差异
判断一个算子是否有状态,标准只有一条:输出是否依赖历史记录。
- 无状态算子 :
map、filter、flatMap。输出只由当前这一条输入决定,处理完即丢弃,不保存任何中间结果。这类算子可以随意重启、迁移,甚至换一台机器重跑,结果都不变。 - 有状态算子 :
sum、window、distinct、双流join、CEP 模式匹配。输出 = 历史累积结果 + 当前输入的共同作用,它必须在内部保存中间结果。

有状态计算的必要性来自业务本身:累计求和、窗口聚合、精确去重、模式匹配,没有一个能靠「只看当前一条记录」完成。Flink 把这份中间结果抽象成统一的 State 接口,交给 StateBackend 管理,这是它与 Storm、Spark Streaming 相比在状态语义上做得最彻底的地方------状态是一等公民,而不是用户自己想办法存在外部存储里的附属品。
二、状态分类体系:Keyed State 与 Operator State
Flink 的状态按「绑定对象」分为两大类:绑定 Key 的 Keyed State ,和绑定算子实例(并行子任务)的 Operator State。这个区分直接决定了状态如何分区、并行度调整时如何迁移、故障时如何恢复。

2.1 Keyed State:与 Key 绑定
只有在 keyBy 之后才可用。同一条 key 的数据必然进入同一个并行子任务,其状态也随该 key 保存在该子任务本地。五种原语:
| 原语 | 用途 | 典型场景 |
|---|---|---|
ValueState<T> |
单值,get()/update() |
累计求和、最新水位 |
ListState<T> |
列表,add()/get() |
待处理队列 |
MapState<K,V> |
映射,put()/get()/remove() |
按 key 增量更新,大对象 |
ReducingState<T> |
归约,add() 触发合并 |
sum/max 等可结合运算 |
AggregatingState<I,O> |
聚合,输入输出类型可不同 | avg = sum / count |
2.2 Operator State:与算子实例绑定
与 key 无关,每个并行实例持有一份。典型场景是 Source 的 offset:Kafka Source 把各分区的消费位点存成 Operator State,故障恢复时据此从上次位置继续消费。两种原语:
ListState<T>:恢复时按并行度切片分发。UnionListState<T>:恢复时所有实例的状态先合并再均匀分发,用于需要把全局信息播给每个实例的场景。
实现 Operator State 需要实现 CheckpointedFunction 接口,在 snapshotState()/initializeState() 里手动读写状态------这正是它比 Keyed State 麻烦的地方,所以能用 Keyed State 就别手写 Operator State。
2.3 Broadcast State:广播状态
它是 Operator State 的一种特殊形式。通过 broadcast() 把一条规则流广播到所有并行实例,每个实例持有全量一份。典型场景是动态规则引擎 ------风控规则、过滤条件实时更新,无需重启作业;以及维表广播 Join(维度表小且需要常变时)。
三、StateBackend:状态存在内存还是磁盘
上层 API 屏蔽了状态的物理位置,get()/put() 的落点由 StateBackend 决定。Flink 1.13 之后统一为两种:

3.1 HashMapStateBackend(堆内存,默认)
状态以 Java 对象形式直接放在 TaskManager 堆内存。优点是访问零序列化、延迟最低 ;缺点是状态规模受 JVM 堆上限约束,状态一大,GC 压力陡增。Checkpoint 时做全量快照,把整份状态序列化写远端。
3.2 EmbeddedRocksDBStateBackend(本地磁盘)
状态序列化后写入 RocksDB(LSM-tree 结构,落本地磁盘 + 内存缓存)。状态可以远超堆内存、到 TB 级,代价是每次访问都要序列化/反序列化 。最大的收益在 Checkpoint:支持增量快照,只上传自上次快照以来变化的数据块,大状态作业的快照体积和耗时大幅下降。
旧版的 MemoryStateBackend(状态存 JobManager 内存)已经被移除,只存在于历史文档里,生产环境一律不用。
我的判断:状态量几十 GB 以内、追求极致低延迟的作业用 HashMap;状态量大、窗口长、或者 Checkpoint 已经成了瓶颈的作业,直接上 RocksDB,别等状态把堆撑爆了再迁移。
四、Key Group 与 Checkpoint 恢复全链路
状态不会凭空落盘,它是被 Checkpoint 周期性地快照到远端存储的。理解这条链路,就理解了 exactly-once 的一半。

4.1 Key Group:并行度调整的基石
Flink 把 key 空间固定切分为若干 key group (数量 = maxParallelism,默认 128,上限 32768)。映射关系是:
keyGroup = MurmurHash(key.hashCode()) % maxParallelism
key 到 key group 的归属永远不变,key group 到并行子任务是轮询分配。这样调整并行度(rescaling)时,只需要重新分配 key group,状态随 key group 一起迁移,无需重新计算每个 key 的去向。这是 Flink 状态能平滑扩缩容的关键设计。
4.2 Checkpoint:从触发到恢复
- JobManager 按
execution.checkpointing.interval周期触发,从 Source 注入 Checkpoint Barrier,随数据流向下游传播。 - 各算子收到 barrier 后对齐(align),然后对自身状态做快照,交 StateBackend 落盘(HashMap 全量 / RocksDB 增量),Source 同时记录 offset。
- 所有子任务快照完成后,JobManager 提交 checkpoint 元数据(状态文件指针 + offset 坐标)。
- 故障时从最近一次成功 checkpoint 恢复:拉取状态文件 → 按 key group 分发到子任务 → Source 从 offset 回放。
Barrier 对齐保证了快照的一致性------状态和 offset 是同一时刻的快照,所以恢复后每条数据恰好被处理一次。
五、代码:有状态计算的完整实现
下面用三个例子覆盖最常用的状态类型,Flink 1.18 可直接编译运行。
5.1 ValueState:按订单 ID 累计金额
java
import org.apache.flink.api.common.functions.RichFlatMapFunction;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.util.Collector;
public class ValueStateDemo {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 输入:orderId,amount
DataStream<String> input = env.socketTextStream("localhost", 9999);
input.map(line -> {
String[] p = line.split(",");
return Tuple2.of(p[0], Double.parseDouble(p[1]));
})
// keyBy 之后才能用 Keyed State
.keyBy(t -> t.f0)
.flatMap(new RichFlatMapFunction<Tuple2<String, Double>, Tuple2<String, Double>>() {
// ValueState 存每个 orderId 的累计金额
private transient ValueState<Double> total;
@Override
public void open(Configuration parameters) throws Exception {
ValueStateDescriptor<Double> desc =
new ValueStateDescriptor<>("order-total", Double.class);
total = getRuntimeContext().getState(desc);
}
@Override
public void flatMap(Tuple2<String, Double> in,
Collector<Tuple2<String, Double>> out) throws Exception {
Double cur = total.value();
if (cur == null) {
// 首次访问 value() 返回 null,必须判空,否则 NPE
cur = 0.0;
}
cur += in.f1;
total.update(cur); // 写回状态,Checkpoint 会把它持久化
out.collect(Tuple2.of(in.f0, cur));
}
})
.print();
env.execute("valuestate-demo");
}
}
要点:状态在 open() 里通过 RuntimeContext 注册,value() 首次返回 null,必须判空------这是 Keyed State 使用里最常见的 NPE 来源。
5.2 MapState:精确去重
MapState 适合按 key 增量增删的场景。下面统计每个分类下出现过多少个不同用户:
java
import org.apache.flink.api.common.state.MapState;
import org.apache.flink.api.common.state.MapStateDescriptor;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;
public class MapStateDemo {
public static class DedupProcess
extends KeyedProcessFunction<String, String, String> {
// key = category,MapState 存 user -> true 的去重标记
private transient MapState<String, Boolean> seenUsers;
@Override
public void open(Configuration parameters) throws Exception {
MapStateDescriptor<String, Boolean> desc =
new MapStateDescriptor<>("seen-users", String.class, Boolean.class);
seenUsers = getRuntimeContext().getMapState(desc);
}
@Override
public void processElement(String user, Context ctx, Collector<String> out)
throws Exception {
if (!seenUsers.contains(user)) {
seenUsers.put(user, true);
out.collect("category=" + ctx.getCurrentKey()
+ " new user=" + user);
}
}
}
}
对比用 ValueState<Set<String>> 存去重集合:每次更新都要把整个 Set 反序列化出来、加一个元素、再序列化写回,状态大了之后每次更新都是全量读写。MapState 只动单个 key,增量更新,这才是它的价值所在------存大对象时优先考虑 MapState 而不是 ValueState 包一个集合。
5.3 Broadcast State:动态规则
java
import org.apache.flink.api.common.state.MapStateDescriptor;
import org.apache.flink.streaming.api.datastream.BroadcastStream;
import org.apache.flink.streaming.api.functions.co.BroadcastProcessFunction;
import org.apache.flink.util.Collector;
public class BroadcastStateDemo {
public static void main(String[] args) throws Exception {
// 业务流 + 规则流,规则流广播给所有实例
// BroadcastProcessFunction 里通过 ctx.getBroadcastState(desc) 读写规则
MapStateDescriptor<String, String> ruleDesc =
new MapStateDescriptor<>("rules", String.class, String.class);
// BroadcastStream<Rule> rules = ruleStream.broadcast(ruleDesc);
// businessStream.connect(rules).process(new BroadcastProcessFunction<>() {...});
}
}
广播状态的核心是 connect(broadcastStream) 之后的 BroadcastProcessFunction:processBroadcastElement() 负责更新规则,processElement() 用当前规则处理业务数据。规则变更随 Checkpoint 一起持久化,恢复后规则不丢失。
六、State TTL:给状态设置生命周期
无界流里 key 会无限增长,状态也会无限膨胀。State TTL 就是给 Keyed State 设过期时间:
java
import org.apache.flink.api.common.state.StateTtlConfig;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.time.Time;
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.hours(24)) // 24 小时过期
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite) // 创建/写入刷新 TTL
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired) // 过期即不可见
.cleanupInRocksdbCompactFilter(1000) // RocksDB 压实清理,每 1000 条检查一次
.build();
ValueStateDescriptor<Double> desc = new ValueStateDescriptor<>("order-total", Double.class);
desc.enableTimeToLive(ttlConfig);
几个必须知道的坑:
- TTL 只对 Keyed State 生效,Operator State 没有 TTL。Source 的 offset 是 Operator State,不存在过期问题,但你自己手写的 Operator State 也享受不到 TTL。
- 默认清理是惰性的:只在读某个 key 时才发现它过期了。想主动回收,必须配置 cleanup 策略(RocksDB 的 compact filter,或堆内存的定时 full scan)。不配清理策略,过期 key 仍占内存/磁盘,等于 TTL 只读不删。
OnCreateAndWritevsOnReadAndWrite:前者只在创建和写入时刷新 TTL(适合「最后一次操作后过期」),后者读取也刷新(适合「最后一次访问后过期」)。选错会导致状态过早或过晚清理,留存量直接差一个数量级。
七、选型建议与五个真实踩坑
选型一句话:状态小、要低延迟用 HashMap + 内存;状态大、长窗口、Checkpoint 成瓶颈用 RocksDB;能 keyBy 就用 Keyed State,别手写 Operator State。
五个我在生产里踩过、也见别人踩过的坑:
- 用时间戳拼 key 导致状态无限膨胀 。
keyBy(userId + "_" + eventTime)这种写法,每个时间片都是新 key,状态随数据量线性增长,TTL 都救不回来。去重/聚合应该只按业务主键 keyBy,时间维度交给窗口。 - 固定 key 造成热点 。
keyBy(v -> "total")把所有数据压到单并行度,集群其余实例空转,状态全落在一个子任务上,单点成为瓶颈。 - RocksDB 的「读放大」。热 key 频繁读写时,每次访问都要反序列化,吞吐不如 HashMap。低延迟强需求 + 状态不大时,别迷信 RocksDB。
- ValueState 包大集合 。用
ValueState<Set/HashMap>存大量元素,每次更新全量反序列化再写回,随着集合增大性能指数下降。拆成 MapState,或改用增量更新的结构。 - 改了状态结构没走 Savepoint。Checkpoint 是 Flink 自动管理的、可以随时被清理;升级作业、改状态 schema 时必须用 Savepoint 做可迁移的重启点,否则旧状态对不上新算子,作业起不来。
状态是 Flink 精确一次语义的根基:无状态算子输出只看当前输入,有状态算子则把中间结果交给 StateBackend 持久化,再由 Checkpoint 周期快照到远端、故障时按 Key Group 分发恢复。弄明白「状态存在哪、何时存、如何恢复」这三件事,比背一堆配置参数更有用------因为所有关于 Flink 状态的问题,最终都会落到这三个问题上。
point**。Checkpoint 是 Flink 自动管理的、可以随时被清理;升级作业、改状态 schema 时必须用 Savepoint 做可迁移的重启点,否则旧状态对不上新算子,作业起不来。