3. Operator State 详解:ListState、UnionListState、BroadcastState 的使用场景与实现

聊完 Keyed State,咱们得把目光转向 Operator State。说实话,很多新手容易把这两者搞混。我刚开始接触 Flink 时也踩过这个坑——明明数据是按分区处理的,为什么还要有 Operator State?

说白了,Operator State 是绑定在 算子并行子任务 上的状态。每个并行实例维护自己的一份状态,跟 key 没关系。你想想看,如果我要记录每个算子的处理进度、要广播一份配置到所有并行实例,Keyed State 就使不上劲了。

核心区别一句话: Keyed State 按 key 分区,Operator State 按算子实例分区。

3.1 Operator State 的三种“姿势”

Flink 提供了三种 Operator State 的原生实现,我按使用频率排个序:

  • ListState — 最常用,每个并行实例维护一个列表
  • UnionListState — ListState 的变体,重分配策略不同
  • BroadcastState — 所有实例共享同一份状态

这三种状态都实现了 ListCheckpointed 或 BroadcastState 接口。嗯,这里要注意:ListCheckpointed 是 Flink 1.x 的老接口,但很多生产代码还在用,你得认识它。

3.2 ListState:最朴素的 Operator State

ListState 的逻辑很简单——每个并行子任务维护一个 List。快照时把这个 List 存下来,恢复时再还给你。

我在项目中遇到过这样一个场景:需要记录每个 Kafka 分区已经消费到的 offset。用 ListState 就特别合适,每个并行实例只管自己消费的那几个分区。

public class MySourceFunction extends RichParallelSourceFunction<String>
        implements ListCheckpointed<Long> {

    private transient ListState<Long> checkpointedState;
    private List<Long> offsets = new ArrayList<>();

    @Override
    public void initializeState(FunctionInitializationContext context) throws Exception {
        ListStateDescriptor<Long> descriptor =
                new ListStateDescriptor<>("offsets", Long.class);

        checkpointedState = context.getOperatorStateStore()
                .getListState(descriptor);

        // 恢复状态
        if (context.isRestored()) {
            offsets.clear();
            for (Long offset : checkpointedState.get()) {
                offsets.add(offset);
            }
        }
    }

    @Override
    public List<Long> snapshotState(long checkpointId, long timestamp) throws Exception {
        // 返回当前 offsets 的快照
        return new ArrayList<>(offsets);
    }
}
我的习惯: 用 getListState() 而不是直接实现 ListCheckpointed。前者能利用 Flink 的托管状态机制,后者需要你自己管理序列化。除非你用的是 Flink 1.2 之前的版本,否则我建议走托管状态。

3.3 UnionListState:重分配时的“全量拷贝”

UnionListState 跟 ListState 长得几乎一样,唯一的区别在于 状态重分配策略。

为什么会这样?你想想看,当算子并行度从 4 改成 3 时,ListState 会把每个实例的列表均匀分给新实例。而 UnionListState 呢?它会把所有实例的列表 全量广播 给每个新实例。

特性 ListState UnionListState
重分配策略 均匀分配(每个实例拿一部分) 全量广播(每个实例拿全部)
适用场景 分区 offset、局部计数器 全局黑名单、需要全量数据的场景
状态量 较小(每个实例只存自己的) 较大(每个实例存全部)
获取方式 getListState() getUnionListState()

我曾经在做一个全局去重过滤器时用过 UnionListState。每个实例需要知道所有已经处理过的 ID,重分配时不能丢失任何数据。用 UnionListState 就对了——每个新实例拿到全量数据,自己再做去重。

注意: UnionListState 在并行度增大时,每个新实例都会收到全量状态。如果状态很大(比如上亿条记录),内存会瞬间爆炸。我曾经见过一个线上事故,就是因为 UnionListState 存了 500 万条记录,并行度从 2 扩到 10,直接 OOM。所以,状态量大的场景慎用 UnionListState。

3.4 BroadcastState:所有实例共享的“公告板”

BroadcastState 是三种 Operator State 里最特殊的一个。它不实现 ListCheckpointed,而是走 BroadcastProcessFunction 或 KeyedBroadcastProcessFunction。

说白了,BroadcastState 就是一块“公告板”。你把配置、规则、模型参数写上去,所有并行实例都能读到一模一样的内容。

我做过一个实时风控系统,规则引擎的规则会动态更新。用 BroadcastState 就特别优雅:

  1. 一个低吞吐的广播流,源源不断地推送新规则
  2. 所有并行实例订阅这个广播流,更新本地的 BroadcastState
  3. 数据流过来时,直接查本地 BroadcastState 做判断
// 定义广播状态描述符
MapStateDescriptor<String, Rule> ruleStateDescriptor =
        new MapStateDescriptor<>(
                "rules",
                String.class,
                Rule.class
        );

// 广播配置流
DataStream<Rule> ruleStream = env
        .addSource(new RuleSourceFunction())
        .broadcast(ruleStateDescriptor);

// 连接数据流和广播流
DataStream<Output> output = dataStream
        .connect(ruleStream)
        .process(new BroadcastProcessFunction<Event, Rule, Output>() {

            @Override
            public void processElement(Event event,
                    ReadOnlyContext ctx,
                    Collector<Output> out) throws Exception {
                // 从 BroadcastState 读取规则
                ReadOnlyBroadcastState<String, Rule> state =
                        ctx.getBroadcastState(ruleStateDescriptor);
                Rule rule = state.get("current_rule");
                if (rule != null && rule.matches(event)) {
                    out.collect(new Output(event, rule));
                }
            }

            @Override
            public void processBroadcastElement(Rule rule,
                    Context ctx,
                    Collector<Output> out) throws Exception {
                // 更新 BroadcastState
                ctx.getBroadcastState(ruleStateDescriptor)
                        .put("current_rule", rule);
            }
        });
关键点: BroadcastState 是 只读 的。在 processElement 里你只能通过 ReadOnlyBroadcastState 读取,不能修改。修改只能在 processBroadcastElement 里做。这个设计是为了保证所有实例的状态一致性。

3.5 三种状态的选择策略

我画了一张图,帮你快速决策该用哪种 Operator State:

Operator State 选择决策树 需要 Operator State? 所有实例需要相同数据? 是 BroadcastState 配置、规则、模型 否 重分配需全量数据? 是 UnionListState 全局黑名单、全量缓存 否 ListState 分区 offset、局部计数 选择建议 • 状态量小、需要全量 → UnionListState(注意内存) • 状态量大、只需局部 → ListState(最常用) • 所有实例共享只读数据 → BroadcastState(动态配置首选)

3.6 避坑指南

最后,我把自己踩过的坑总结一下:

  • ListState 的序列化问题: 如果你用 ListCheckpointed 接口,返回的 List 元素必须可序列化。我遇到过有人把 KafkaConsumer 对象塞进去,结果快照时直接报错。
  • UnionListState 的扩容陷阱: 前面说过了,状态量大时别用 UnionListState。如果非要用,记得在扩容前先做状态清理。
  • BroadcastState 的更新延迟: BroadcastState 的更新不是事务性的。如果你在 processBroadcastElement 里更新了状态,但下游还没读到,数据可能会用旧规则处理。我一般会在规则里加版本号,下游做版本校验。
  • 状态清理: Operator State 不会自动过期。如果你用 ListState 存 offset,记得定期清理已经提交的 offset,否则状态会无限增长。
我曾经踩过的坑: 有一次我用 BroadcastState 存机器学习模型参数,模型文件有 200MB。结果每个并行实例都存了一份,16 个实例就是 3.2GB。更坑的是,Checkpoint 时要把这 3.2GB 全部序列化到远端存储,每次 Checkpoint 都要十几秒。后来我把模型参数拆成增量更新,每次只广播变化的部分,才把 Checkpoint 时间降下来。

Operator State 虽然不如 Keyed State 那么常用,但在特定场景下它是不可替代的。尤其是 BroadcastState,在实时数仓、风控、规则引擎这些需要动态配置的场景里,几乎是标配。你只要记住:按实例分区的用 ListState,需要全量的用 UnionListState,需要共享的用 BroadcastState,基本就不会选错。