一篇文章讲透 Flink State

1. 这份文档想解决什么问题

很多人第一次学 Flink state,会把它理解成"给算子加一个内存变量"。这个理解太浅了。

更接近本质的理解应该是:

  • State 是流处理里"把过去带到未来"的机制
  • Checkpoint 是把这份过去固化下来,保证失败后还能接上
  • State Backend 是这份过去在运行时的物理承载方式

如果把这三件事想清楚,后面很多问题都会顺:

  • 为什么 keyBy() 之后才能拿 ValueState
  • 为什么有 operator statebroadcast state
  • 为什么同样是 state,堆上、RocksDB、ForSt 的语义基本一致,性能和能力却差很多
  • 为什么 continuation 这类"长事务拆步执行"的模式,本质上不是保存协程栈,而是把"后面还要做什么"显式写进 state

这份文档按下面顺序展开:

  1. 先讲 Flink state 要解决什么问题
  2. 再讲 state 的三种主要作用域:keyedoperatorbroadcast
  3. 再讲 window / namespace / state 之间到底是什么关系
  4. 再讲 state backend 的演进:内存、磁盘、云端
  5. 最后讲 continuation / 异步状态机为什么本质上是 state 设计问题

2. 先抓住本质:State 到底解决什么问题

2.1 没有 state,流处理只能"看当前这一条"

如果一个算子没有 state,它看到一条记录时,只知道:

  • 当前输入是什么
  • 当前时间是什么

它不知道:

  • 这个 key 之前来过几次
  • 这个窗口已经累积了多少值
  • 这条输入之前是否已经触发过某个副作用
  • 这条长任务已经执行到了第几步

所以从设计上说,state 的第一个作用是:

  • 给算子提供跨记录的记忆

2.2 只有内存变量还不够,因为失败后会丢

假设你用一个普通的 Java 字段记录计数:

java 复制代码
private long count;

这在进程活着时能工作,但一旦 task 崩溃、迁移、扩缩容,这个字段就没了。

所以 state 的第二个作用是:

  • 把"记忆"接入 Flink 的快照与恢复协议

这就是 ValueState 接口注释里强调的点:state 会被系统一致性地 checkpoint 并恢复。ValueState.java#L25-L35

2.3 只有能恢复也不够,因为还要能扩缩容

流处理作业不是静态单机程序,它会:

  • 扩容
  • 缩容
  • failover
  • rescale

所以 state 设计还要解决第三个问题:

  • 状态如何随着数据分区规则一起移动和重分配

这就是为什么 Flink state 不是"你随便 new 一个对象,框架帮你保存",而是:

  • 你必须告诉框架这份 state 的作用域
  • 框架才能知道恢复或扩缩容时该怎么搬

3. 理解 State 的最小框架

写任何一份 state 之前,最好先回答三个问题:

  1. 这份 state 属于谁?
    • 某个 key
    • 某个 operator subtask
    • 所有 subtask 都应看到的同一份规则
  2. 这份 state 怎么重分配?
    • 跟着 key 走
    • 按列表元素 round-robin 分
    • 广播给所有并发实例
  3. 失败恢复时,下一步靠什么继续?
    • 恢复值本身
    • 恢复待处理任务队列
    • 恢复规则或元数据

把这三个问题想清楚,state 设计通常就不会偏。

Flink 的 state 抽象,大致可以看成三层:

  1. StateBackend
    • 决定运行时 backend 如何创建 keyed/operator backend
  2. KeyedStateBackend / OperatorStateBackend
    • 运行时真正承载 state 的 backend 容器
  3. ValueState / ListState / MapState / BroadcastState 等 API
    • 用户代码直接使用的 state 接口

StateBackend 本身就是一个工厂接口,它的职责不是直接提供 value() / update(),而是创建 keyed 和 operator backend。StateBackend.java#L40-L57 StateBackend.java#L104-L152

这层设计很重要,因为它把:

  • 用户语义
  • 运行时容器
  • 物理存储实现

分开了。

4.1 为什么很多 state 字段要写成 transient

这也是理解 Flink state 时一个很关键、但经常被忽略的点。

先看一个常见写法:

java 复制代码
private transient ValueState<Long> countState;

很多人第一次看到会疑惑:

  • 不是说 state 要持久化吗?
  • 为什么这里反而写成 transient

关键在于要区分两件事:

  1. Java 字段
    • 这里的 countState 只是函数对象里的一个字段
  2. Flink managed state 的真实内容
    • 比如某个 key 对应的 count=17

真正被 checkpoint / restore 的,是第 2 个,不是第 1 个。

也就是说:

  • ValueState / ListState / MapState 这些字段,通常只是访问运行时 backend 的句柄
  • 它们不是业务状态本体
  • 业务状态本体在 Flink 的 managed state backend 里

所以 transient 的含义不是"这个 state 不需要持久化",而是:

  • 这个 Java 字段本身不应该参与普通 Java 序列化
  • 真正的状态恢复,靠的是 Flink checkpoint,而不是把这个句柄对象序列化出去

通常的标准写法是:

java 复制代码
private transient ValueState<Long> countState;

@Override
public void open(OpenContext ctx) {
    ValueStateDescriptor<Long> desc =
            new ValueStateDescriptor<>("count", Long.class);
    countState = getRuntimeContext().getState(desc);
}

这里的流程是:

  1. 用户函数对象被创建
  2. countState 字段先保持为空
  3. task 真正启动后,在 open() 里重新通过 runtime context 取 state 句柄
  4. 之后 countState.value() / update() 才真正连到当前 subtask 的 backend 上

4.1.1 什么通常应该写 transient

一般这几类字段都建议写成 transient

  • ValueState / ListState / MapState / ReducingState / AggregatingState 这类 state 句柄
  • RuntimeContext
  • 外部客户端连接
  • cache、logger、线程池、future、continuation context
  • 其他可以在 open() 后重新构造的运行时对象

它们的共同点是:

  • 不是你真正想 checkpoint 的业务进度
  • 丢了以后可以重新绑定或重新创建

4.1.2 什么不能只靠 transient

如果一个字段表达的是业务进度,比如:

  • 当前 workflow 的 phase
  • 某个 key 的计数
  • 待续跑任务列表
  • 某次外部调用已经完成到哪一步

这些都不能只放普通字段里,无论你写不写 transient 都不够。

它们应该进入:

  • keyed state
  • operator state
  • broadcast state

因为只有这些 state 才会参与 checkpoint、恢复和 rescale。

4.1.3 continuation 场景怎么理解

这在 continuation / 异步状态机场景里尤其重要。

比如:

  • continuation 对象本身
  • future 引用
  • mailbox 里的闭包

通常都只是运行时对象,应该被当成 transient 看待。

真正该保存进 Flink state 的,不是这些对象本身,而是:

  • 当前执行到第几步
  • 后面还要继续做什么
  • 哪些任务还没完成
  • 哪些外部调用结果已经 durable 持久化

一句话总结就是:

  • continuation 栈对象通常是 transient
  • continuation 的业务进度绝不能只是 transient 字段,而必须显式写进 state

5. Keyed State:和 key 绑定的 state

5.1 它的本质

Keyed state 不是"全局一个值",而是:

  • 每个 key 一份独立状态

KeyedStateStore 的注释写得很直白:state 的值作用域是"当前正在处理元素的 key",系统会自动把 key 和 state 一起分区与迁移。KeyedStateStore.java#L30-L39

ValueState 也明确说了:

  • 它只在 KeyedStream 上可访问
  • 当前函数看到的值,总是"当前元素所属 key 的那份值"ValueState.java#L29-L35

5.2 运行时怎么做到"当前 key 的那份 state"

底层对应的是 KeyedStateBackend

  • 它有 setCurrentKey(...)
  • getCurrentKey()
  • 通过当前 key 去路由状态访问

KeyedStateBackend.java#L39-L58

也就是说,你在用户代码里写:

java 复制代码
countState.value()
countState.update(v)

表面上没传 key,但运行时早就通过当前输入元素把 key 设进 backend 了。

5.3 一个最小例子

java 复制代码
public class Counter extends RichFlatMapFunction<Event, Tuple2<String, Long>> {
    private transient ValueState<Long> countState;

    @Override
    public void open(OpenContext ctx) {
        ValueStateDescriptor<Long> desc =
                new ValueStateDescriptor<>("count", Long.class);
        countState = getRuntimeContext().getState(desc);
    }

    @Override
    public void flatMap(Event value, Collector<Tuple2<String, Long>> out) throws Exception {
        Long count = countState.value();
        if (count == null) {
            count = 0L;
        }
        count++;
        countState.update(count);
        out.collect(Tuple2.of(value.userId, count));
    }
}

前提不是 ValueState 多神奇,而是:

  1. 上游已经 keyBy(userId)
  2. 运行时先切到当前 key
  3. 这次 value()/update() 访问的是该 key 的槽位

5.4 Keyed state 解决了什么

它解决的是:

  • 每个 key 独立记忆
  • key 级 checkpoint / 恢复
  • key 级扩缩容迁移

如果你要写:

  • 会话状态
  • 用户画像
  • 去重标记
  • continuation 的"这个 key 当前执行到第几步"

通常第一反应都应该是 keyed state。

6. Window / Namespace / State:window 其实就是 keyed state 的命名空间

6.1 先看过程:WindowOperator 怎么把 window 变成 namespace

如果只记一句话,这一节最重要的结论是:

  • 在运行时,window 本质上就是 keyed state 的 namespace

流程可以直接顺着 WindowOperator 看:

  1. WindowAssigner 先提供窗口自己的序列化器 getWindowSerializer(...)
  2. WindowOperator.open() 用这个 windowSerializer 去创建 windowState
  3. 处理每条元素时,对命中的每个 window 调一次 windowState.setCurrentNamespace(window)
  4. 后续 add() / get() / clear() 就都落在这个 (key, window) 坐标上

关键源码位置:

把这件事翻成更直白的话,就是:

  • keyBy(userId) 先选中"哪一行 key"
  • setCurrentNamespace(window) 再选中"这行 key 下的哪一个窗口格子"

6.2 namespace 到底在隔离什么

只要是 keyed state,运行时定位的都不是单独一个 key,而是:

  • (key, namespace)

InternalKvState 直接把 setCurrentNamespace(...) 定义成内部能力,说明 namespace 不是 window API 的附属品,而是 keyed state 的基础维度之一: InternalKvState.java#L75-L80

一个最小例子:

  • 当前 key 是 userId=42
  • 这个 key 同时命中两个窗口:[0,10)[10,20)
  • 两个窗口都访问同一个 ListStateDescriptor("cnt")

那运行时并不是"一个 cnt state 被覆盖两次",而是:

  • (42, [0,10)) -> 一份 state
  • (42, [10,20)) -> 另一份 state

所以 window 的本质不是"额外一套专门的 window state 系统",而是:

  • 把 keyed state 的 namespace 从默认值切成了 Window 对象

6.3 setCurrentNamespace(window) 为什么只是"指针切换"

这一步很容易被误解成"创建一个新的 state 对象",但实际不是。

AbstractKeyedStateBackend.getPartitionedState(...) 会缓存最近一次返回的 state handle;如果命中同一个 state 名字,它只是把 lastState.setCurrentNamespace(namespace) 改掉,然后复用原来的 handle: AbstractKeyedStateBackend.java#L426-L455

所以更准确的理解是:

  • state handle 通常会被复用
  • currentNamespace 是这个 handle 当前指向哪个 namespace 的"可变指针"

这套设计的直接好处是:

  • 不需要为每个 window 反复 new state 对象
  • window 很多时,切 namespace 的成本远低于反复建 handle

6.4 merging window 为什么还要区分 actualWindowstateWindow

对 merging window,情况会再多一层。

运行时不是永远直接拿"当前逻辑窗口"做 namespace,而是可能先通过 MergingWindowSet 找到真正承载状态的 stateWindow

  1. 逻辑上这条元素命中某个 actualWindow
  2. 如果窗口支持 merge,运行时会查这个窗口当前映射到哪个 stateWindow
  3. 真正写 state 时,用的是 stateWindow 作为 namespace
  4. 需要合并时,再通过 mergeNamespaces(target, sources) 把多个 namespace 的内容并到一起

关键代码:

这说明 merging window 的核心不是"把几个 Java 对象名义上并一下",而是:

  • 把几个 namespace 上的状态真实搬到一个目标 namespace

6.5 为什么 windowState() 会随 window 变,而 globalState() 不会

ProcessWindowFunction.Context 暴露了两套 store:

  • windowState()
  • globalState()

它们的差别不是"一个能 checkpoint,一个不能 checkpoint",而是 namespace 不同:

AbstractStreamOperator.getPartitionedState(stateDescriptor) 的默认实现会自动走 VoidNamespace.INSTANCEAbstractStreamOperator.java#L541-L565

VoidNamespace 自己的定义也很直白:

所以这里可以直接得出一个常见但容易混淆的结论:

  • windowState() 是按 (key, window) 分区
  • globalState() 是按 (key, VoidNamespace) 分区

也就是说,globalState() 依然是 keyed state,只是它没有 window 这一维。

6.6 一个把关系说透的小例子

假设:

  • 当前 key 是 userId=42
  • 当前窗口是 [0,10)
  • 另一个窗口是 [10,20)
  • 你定义了一个名字叫 cntValueState

那三种访问实际对应的是:

  • 普通 keyed state:(42, VoidNamespace) -> cnt
  • 第一个 window 的 state:(42, [0,10)) -> cnt
  • 第二个 window 的 state:(42, [10,20)) -> cnt

所以 "window / namespace / state" 的关系,不是三套彼此独立的机制,而是一条链:

  1. key 先决定"哪一份分区状态"
  2. namespace 再决定"这份分区状态里的哪个槽位"
  3. state descriptor 再决定"这个槽位里存的是哪种逻辑结构"

7. Operator State:和 key 无关,但和 operator 实例有关

7.1 它的本质

Operator state 属于:

  • 某个 operator subtask

不是某个 key。

运行时接口上,OperatorStateBackend 就是 OperatorStateStore + Snapshotable 的组合。OperatorStateBackend.java#L27-L34

这类 state 适合保存:

  • source split 列表
  • sink writer 本地缓存
  • 某个 subtask 当前负责的工作分片
  • 和 key 无关的待处理列表

7.2 这里的"全局 state"要小心

很多人会问:

  • "怎么声明和 key 无关的全局 state?"

先说结论:

  • Flink 没有一个任意函数都能随便写的'全局单例 mutable state'

通常你说的"和 key 无关",在 Flink 里会落到三种不同语义:

  1. operator list state
    • 每个 subtask 各有一份
    • rescale 时按元素 round-robin 重分配
  2. union list state
    • 恢复时把所有 subtask 的列表并到每个实例
  3. broadcast state
    • 所有并发实例都维护同样一份规则映射

这三种都"和 key 无关",但恢复和扩缩容语义完全不同。

7.3 ListState 和 UnionListState 的区别

OperatorStateStore 的注释把这件事说得很清楚:

  • getListState(...)
    • 状态项之间互相独立
    • rescale 时 round-robin 重新分给并行实例
  • getUnionListState(...)
    • 恢复时每个实例都拿到 restore 前所有元素的并集

OperatorStateStore.java#L57-L101

所以如果你想声明"和 key 无关的全局信息",先别急着用 UnionListState,先问自己:

  • 我是要分片重分配
  • 还是要每个实例都看到全量

8. Broadcast State:所有实例都应看到同一份规则

8.1 它的本质

Broadcast state 适合保存:

  • 规则流
  • 配置流
  • 动态路由表
  • 模型版本信息

BroadcastState 的定义里最关键的一句是:

  • 所有 operator 实例都应该存储同样的元素

BroadcastState.java#L27-L39

这意味着它不是"中心化单份存储",而是:

  • 每个 subtask 各自维护一份副本

只是因为输入流是 broadcast,所以大家最终看到的内容应该相同。

8.2 为什么它容易被误叫成"全局 state"

因为从业务角度看,它像"所有实例共享同一份规则"。

但从实现角度看,它不是一个 cluster-wide 的共享 map,而是:

  • 每个 task 本地都维护一份相同内容

KeyedBroadcastProcessFunction 也体现了这个边界:

  • 普通 keyed side 只能只读 broadcast state
  • broadcast side 才能读写 broadcast state,并能影响本地 keyed state

KeyedBroadcastProcessFunction.java#L50-L53 KeyedBroadcastProcessFunction.java#L71-L80 KeyedBroadcastProcessFunction.java#L92-L103

8.3 它到底怎么声明

Broadcast state 不是你在算子里随便声明一个"全局变量",然后 Flink 帮你同步。

标准写法是:

  1. 先定义一个 MapStateDescriptor
  2. 对规则流调用 .broadcast(descriptor)
  3. 再把这个 BroadcastStream 和主数据流 connect
  4. 最后在 BroadcastProcessFunctionKeyedBroadcastProcessFunction 里,通过 ctx.getBroadcastState(...) 读写

文档示例见:

API 入口见:

一个最小例子:

java 复制代码
MapStateDescriptor<String, Rule> ruleStateDesc =
        new MapStateDescriptor<>("rules", String.class, Rule.class);

BroadcastStream<Rule> broadcastRules = ruleStream.broadcast(ruleStateDesc);

mainStream
    .connect(broadcastRules)
    .process(new KeyedBroadcastProcessFunction<String, Event, Rule, Output>() {

        @Override
        public void processBroadcastElement(
                Rule rule,
                Context ctx,
                Collector<Output> out) throws Exception {
            ctx.getBroadcastState(ruleStateDesc).put(rule.id(), rule);
        }

        @Override
        public void processElement(
                Event event,
                ReadOnlyContext ctx,
                Collector<Output> out) throws Exception {
            Rule rule = ctx.getBroadcastState(ruleStateDesc).get(event.ruleId());
            if (rule != null) {
                out.collect(applyRule(event, rule));
            }
        }
    });

这个例子里最关键的不是 API,而是语义:

  • 规则流负责"把规则发给所有 task"
  • broadcast state 负责"每个 task 把规则写进自己本地的规则表"
  • 主数据流负责"读取自己本地那份规则表"

8.4 它为什么能同步到所有 task

关键不在 state 本身会"远程同步",而在于:

  • 输入先被广播
  • 每个 task 再本地写入

DataStream.broadcast() 底层走的是 BroadcastPartitioner,语义就是把一条元素发往所有下游并发实例:

所以一条规则消息到来时,流程是:

  1. 规则流元素被广播给所有下游 task
  2. 每个 task 在 processBroadcastElement(...) 中都收到这条规则
  3. 每个 task 分别执行一次 ctx.getBroadcastState(...).put(...)
  4. 于是每个 task 本地都得到相同的一份规则副本

这也是为什么它"看起来像全局",但实现上并不是"共享一个对象"。

8.5 为什么不能用"全局变量"代替

因为 Flink 的并发 task:

  • 可能不在同一个 JVM
  • 可能不在同一台机器
  • 没有共享内存

所以你如果自己写:

java 复制代码
static Map<String, Rule> globalRules = new HashMap<>();

这个 globalRules 只在当前这个 task 所在进程里有效,不会自动:

  • 同步给别的 task
  • 参与 checkpoint / restore
  • 在 rescale 时正确重分配

而 BroadcastState 的保证是:

  • 规则通过 broadcast 输入复制到所有 task
  • 每个 task 本地有一份托管副本
  • checkpoint / restore / rescale 也按 broadcast 语义处理

官方文档甚至直接提醒:

  • Flink 没有跨 task 通讯,所以广播状态依赖"广播输入 + 每个 task 存同样元素"这条约束

见:

8.6 恢复和扩缩容时怎么办

Broadcast state 也不是"恢复时去某个中心节点拉一份全局 map"。

它本质上还是 operator state,只是快照与重分配模式是 BROADCAST

  • backend 里把它按 broadcast 模式保存
  • 恢复时每个并发实例都拿到同样的内容

相关实现见:

所以一句话总结:

  • BroadcastState 不是"大家共用一个对象"
  • 而是"每个 task 各有一份副本,靠广播输入和框架恢复机制保持一致"

8.7 规则流可以来自 MQ 吗?数据经过 JM 吗?

可以。

规则流在 Flink 里没有什么"神秘特权",它本质上就是一条普通的 DataStream。所以它完全可以来自:

  • Kafka source
  • Pulsar source
  • 文件 source
  • 自定义 source

只要它先变成普通 DataStream,后面就可以继续 .broadcast(...)

例如:

java 复制代码
DataStream<Rule> ruleStream =
        env.fromSource(ruleSource, WatermarkStrategy.noWatermarks(), "rule-source");

BroadcastStream<Rule> broadcastRules = ruleStream.broadcast(ruleStateDesc);

fromSource(...) 只是创建普通 DataStreamSource,没有限制"只能主数据流才能这么干": StreamExecutionEnvironment.java#L1752-L1811

8.8 它在图上到底怎么接

Flink 选择的不是"JM 收到规则后再统一转发",也不是"给 source 开一条特殊隐式通道",而是:

  • 规则流先作为普通 source 进入 DAG
  • 在需要的地方调用 .broadcast(...)
  • 再和主数据流 connect(...)
  • 最终形成一个普通的 two-input operator

也就是说,一个典型拓扑长这样:

text 复制代码
KafkaRuleSource --> broadcast() ----\
                                      -> KeyedBroadcastProcessFunction -> downstream
KafkaMainSource --> keyBy(...) ------/

它的本质是"一条广播输入边 + 一条普通 keyed 输入边",而不是某种特殊控制面旁路。

API 和翻译路径可以对应到:

8.9 为什么不经过 JM 做数据分发

因为 JM 的职责是:

  • 生成/部署拓扑
  • 调度
  • 协调 checkpoint
  • 管理任务与分区信息

它不是高吞吐数据转发节点。

在 Flink 里,JM 负责"告诉 TM 们这条 broadcast 边怎么连",但不负责替它们转发规则数据本身

任务部署与输入分区描述下发,见:

真正的数据广播发生在 TaskManager 网络层

所以更准确的说法是:

  • JM 负责控制面
  • TM 负责数据面
  • BroadcastState 依赖的是"TM 之间的广播分发 + 每个 task 本地副本",不是"JM 中转数据"

主要有三个原因:

  1. 统一模型
    • 规则本身也是流,主数据也是流
    • 用普通 DAG + 普通边 + 特殊 partitioner,模型最统一
  2. 复用现有机制
    • watermark
    • checkpoint
    • backpressure
    • failover
    • rescale
      都可以直接复用,不需要额外发明一条"控制消息总线"
  3. 避免 JM 变热点
    • 如果所有规则消息都经过 JM 转发,JM 很容易变成吞吐瓶颈和单点热点

8.11 一个直观判断法

如果你的 state 是:

  • "每个用户一份" -> keyed state
  • "每个 subtask 一份工作清单" -> operator state
  • "所有 subtask 都要看到同一份规则" -> broadcast state

9. Rescale 时 state 到底怎么搬

这一节非常值得单独讲,因为:

  • 如果不理解 rescale,前面 keyed / operator / broadcast 的区别只停留在"API 名字不同"
  • 一旦理解 rescale,你就会明白:state 类型本质上是在声明"扩缩容时这份状态该怎么重新分配"

Flink 在 rescale 时,不是简单"把旧文件复制到新机器"。

它会根据 state 的语义走不同的重分配路径:

  1. keyed state:按 key-group
  2. operator list state:按 元素切分
  3. union list state:按 并集复制
  4. broadcast state:按 每 task 一份副本恢复

运行时总入口在 StateAssignmentOperation.assignStates(),它会把不同类型 state 分流到不同重分配路径:StateAssignmentOperation.java#L105-L246

9.1 Keyed State:按 key-group 重分配

Keyed state 不是按"单条 key"搬,而是按 key-group 搬。

这是 Flink 最关键的设计之一。

9.1.1 为什么不是按 key 直接搬

如果按 key 逐条重分配:

  • 元数据会太大
  • 恢复/扩缩容成本太高
  • 任务之间迁移粒度过细,调度开销大

所以 Flink 先把整个 key 空间切成固定数量的桶,也就是 key-group

maxParallelism 本质上决定的就是:

  • key-group 的总数

文档和代码位置:

9.1.2 rescale 时怎么做

过程是:

  1. 固定 key-group 总数不变
  2. 按新的并行度重新计算每个 subtask 负责的 key-group range
  3. 从旧的 KeyedStateHandle 中取与这个 range 相交的部分
  4. 交给新的目标 subtask

源码路径:

9.1.3 一个最小例子

假设:

  • maxParallelism = 128
  • 原并行度 p=2
  • 新并行度 p=4

那么:

  • 原来两个 subtask 可能是:
    • subtask-0 负责 [0..63]
    • subtask-1 负责 [64..127]

rescale 后变成:

  • subtask-0 负责 [0..31]
  • subtask-1 负责 [32..63]
  • subtask-2 负责 [64..95]
  • subtask-3 负责 [96..127]

所以 keyed state 的迁移单位不是:

  • "userA 去哪台机器、userB 去哪台机器"

而是:

  • "userA 所属的 key-group 去哪台机器"

这也是为什么 keyed state 最适合:

  • 每 key 独立的业务状态

因为它天然跟 key-group 一起搬。

9.2 Operator List State:按元素切分

普通 ListState 的 operator state,在运行时语义是:

  • SPLIT_DISTRIBUTE

定义见:

它的重分配思路不是"每个 subtask 原封不动拿回旧列表",而是:

  • 把所有 subtask 的 list 逻辑上拼起来
  • 再按元素粒度近似均匀切分给新的并发实例

重分配算法在:

9.2.1 一个例子

假设旧并行度是 2:

  • subtask-0 有 [s1, s2, s3, s4]
  • subtask-1 有 [s5, s6, s7, s8, s9, s10]

现在扩到并行度 3。

rescale 后不是:

  • 0 继续拿 [s1..s4]
  • 1 继续拿 [s5..s10]
  • 2 拿空

而是逻辑上按元素重分配,结果大致会是:

  • subtask-0 拿 4 个
  • subtask-1 拿 3 个
  • subtask-2 拿 3 个

所以这类 state 适合表达:

  • "一批可切分的工作项"

比如:

  • source split 列表
  • 待消费 partition 列表
  • 可重新分片的任务清单

9.3 Union List State:全量复制到每个 subtask

UnionListState 的模式是:

  • UNION

也就是:

  1. 先把所有 subtask 里的这份状态做并集
  2. 再把完整并集给每个新 subtask

API 到模式映射见:

重分配算法见:

9.3.1 什么时候适合

适合:

  • 小规模元数据
  • 恢复时每个 subtask 都要先知道全集
  • 再由自己做本地过滤

这就是前面 flink-agentscurrentProcessingKeysOpState 的典型用法。

9.3.2 风险

它不适合高基数大列表。

因为它的恢复语义是:

  • 每个 subtask 都先拿一份全量并集

如果列表特别大:

  • checkpoint 元数据会膨胀
  • restore 成本会很高

9.4 Broadcast State:每个 task 都恢复一份副本

BroadcastState 的模式是:

  • BROADCAST

定义和快照策略位置:

它和 union list 有点像,都有"每个实例都可能拿到全量"的味道,但本质不同:

  • union list 是"恢复时先拿到并集,再本地过滤"
  • broadcast state 是"每个 task 本来就应该维护一份规则副本"

官方文档对 rescale 的描述也很直白:

  • 同并行度时,各读各自
  • 扩容时,新 task 会按 round-robin 方式复用旧快照分区

见:

这里的关键前提不是"系统帮你做复杂合并",而是:

  • 所有 task 都是通过广播规则流做同构更新

所以恢复后每个 task 再拿回一份副本,是语义上成立的。

9.5 一组对照

  • keyed state
    • 重分配单位:key-group
    • 适合:每 key 独立状态
    • 主导问题:新并行度下谁负责哪些 key-group
  • operator list state
    • 重分配单位:list element
    • 适合:可切分工作列表
    • 主导问题:如何把元素重新均分
  • union list state
    • 重分配方式:并集复制到所有 task
    • 适合:小规模全量索引/元数据
    • 主导问题:恢复时每个实例都要先知道全集
  • broadcast state
    • 重分配方式:每 task 恢复一份副本
    • 适合:规则/配置流
    • 主导问题:所有实例必须保持同构更新

9.6 rescale 为什么是 state 设计的核心

因为 state 类型的选择,本质上就是在声明:

  • 这份状态在扩缩容时应该怎么搬

这也是为什么前面那些分类不是"只是 API 长得不一样":

  • keyed state 在声明"跟着 key-group 搬"
  • list state 在声明"按元素切开重分配"
  • union list state 在声明"每个实例先拿全集"
  • broadcast state 在声明"每个实例都维护一份规则副本"

如果你以后设计 continuation / workflow / source split / 规则系统时,能先想清楚:

  • "这份进度在 rescale 时到底该怎么搬?"

那通常 state 类型就会选得很稳。

10. State Backend 的演进:内存、磁盘、云端

这部分不要只看"底层存哪里",而要看它们各自在解决什么问题。

10.1 Heap / HashMapStateBackend:先追求简单与低延迟

HashMapStateBackend 的注释很直接:

  • 工作状态放在 TaskManager JVM heap
  • checkpoint 走配置好的 CheckpointStorage

HashMapStateBackend.java#L40-L60

它解决的问题是:

  • 本地访问延迟低
  • 实现简单
  • 状态不太大时够快

代价也很明确:

  • 多 slot 并发时,所有 task 的工作状态都要吃 JVM heap
  • 大状态容易顶爆内存或导致 GC 压力

所以 heap backend 适合:

  • 小中型状态
  • 追求低延迟
  • 不想承受 RocksDB 本地磁盘与序列化额外成本

10.2 RocksDB:把工作状态扩展到本地磁盘

EmbeddedRocksDBStateBackend 的注释是:

  • 状态保存在嵌入式 RocksDB
  • 可以存下超过内存的大状态
  • 会 spill 到本地磁盘

EmbeddedRocksDBStateBackend.java#L86-L96

它解决的问题是:

  • 单机内存装不下大状态
  • 需要更强的 checkpoint 扩展性
  • 希望利用 LSM/SST 组织支持增量 checkpoint

代价是:

  • 本地状态访问慢于 heap
  • 依赖本地磁盘
  • compaction、上传、恢复路径更复杂

所以 RocksDB 的本质不是"只是把 Map 放磁盘",而是:

  • 用本地嵌入式 KV 引擎换状态规模

10.3 先看过程:一次 keyed state 访问怎么落到 RocksDB

这一段最好按真实调用路径来理解,而不是一上来就背结论。

一条 keyed state 访问落到 RocksDB,大致是这 4 步:

  1. runtime 先把当前 record 的 key 切到 KeyedStateBackend
  2. RocksDB backend 把 currentKey 和它所属的 keyGroup 写进共享的 key builder
  3. state handle 再把当前 namespace,必要时再追加 userKey
  4. 最终拼成 composite key,去对应的 Column Family 做 get / put / merge

这里要特别区分两层概念:

  • key
    • 是业务 key,比如 userId=42
  • keyGroup
    • 是 Flink 把所有 key 按哈希切到固定数量桶之后得到的桶编号
    • 编号范围是 0 .. maxParallelism-1

一个最小例子:

  • 假设 maxParallelism = 128
  • 那么 key-group 总共有 128 个,编号就是 0..127
  • userId=42 可能落到 keyGroup=17
  • userId=1001 也完全可能落到 keyGroup=17

Flink 在 RocksDB row key 里放 keyGroup,不是为了补"业务 key 不够唯一",而是为了把底层物理 key 排布和 Flink 的运行时分区模型对齐。因为 Flink 在 checkpoint / restore / rescale 时,真正搬运和分配的单位是 key-group,不是单条 key。

所以更准确地说,RocksDB 里的 row key 不是单纯:

  • key + namespace

而是:

  • keyGroup + key + namespace

如果是 MapState,再继续追加:

  • keyGroup + key + namespace + userKey

这样做的直接好处有三个:

  1. 可以把同一个 key-group 的数据放到同一段有序 key range 里
  2. checkpoint / restore 时,逻辑上的 KeyGroupRange 和物理 key 前缀能够对齐
  3. rescale 时,Flink 可以按 key-group 这层稳定中间分桶来重分配状态,而不需要按每条 key 单独搬

关键代码都比较直接:

这条链路说明了一件很重要的事:

  • RocksDB backend 并没有为每种 state 单独发明一套存储引擎
  • 它的核心统一模型就是"Column Family + composite key + value bytes"

10.4 通用存储模型:一个 state 名字一组 Column Family

RocksDB backend 不会把所有 keyed state 胡乱塞进一个大 map 里。

更接近真实实现的说法是:

  • 每个 stateDesc.getName() 会注册一份 keyed state 元信息
  • 每份 named state 绑定一组自己的 Column Family
  • 不同 state type 再映射到不同的 state 实现类

创建和更新入口在:

所以物理布局上,先分的是:

  • state name

进入某个具体 state 之后,再分的是:

  • key-group
  • key
  • namespace
  • userKey (仅 MapState 这类需要额外 map key 的类型)

10.5 ValueState / ListState / MapState 的真实落盘结构

10.5.1 ValueState:一个 (key, namespace) 对应一条 KV

这是最直观的一种。

  • RocksDB key:KeyGroup + Key + Namespace
  • RocksDB value:valueSerializer.serialize(value)

读写路径就是标准的点查点写:

所以 ValueState 本质上就是:

  • 在某个 (key, namespace) 槽位里放一个值
10.5.2 ListState:还是一个 (key, namespace),但支持 append/merge

ListState 有意思的地方在于,它物理上仍然只有一条 KV:

  • RocksDB key:KeyGroup + Key + Namespace
  • RocksDB value:整条 list 的序列化结果

但它为了高效追加,不是每次 add(x) 都把整条 list 读出来再全量回写。

源码里直接注明:

  • 这个 state 所在的 Column Family 需要配置 RocksDB StringAppendOperator,因为实现会调用 merge()RocksDBListState.java#L52-L58

对应过程是:

这套实现有两个值得记住的点:

  1. 逻辑上是 list,物理上仍然是一条 KV
  2. "追加"能力不是 list 数据结构天然自带的,而是借了 RocksDB merge operator
10.5.3 MapState:一张 map 被拆成多条 RocksDB KV

MapState 和前两者最大的不一样,是它不会把整张 map 序列化成一大坨 value 再整体回写。

它的做法是:

  • RocksDB key:KeyGroup + Key + Namespace + UserKey
  • RocksDB value:userValue 的序列化 bytes

源码注释直接写了 key 格式:

这意味着:

  • 同一个 Flink MapState 里的每个 userKey,在 RocksDB 里都是独立 KV

对应实现也非常一致:

另外它还有个很实用的小细节:

所以 MapState 的本质不是"一个 value 里嵌套一张 Java Map",而是:

  • 把一张逻辑 map 拆成同前缀的一串 RocksDB KV
10.5.4 ReducingState / AggregatingState:逻辑复杂,物理上通常仍是一条 KV

这两类 state 容易让人误以为底层也会变得很复杂,但源码说明正相反。

它们物理上大多仍然是:

  • 一个 (key, namespace) 对应一条 KV

区别主要在 add(...) 的语义:

merge namespace 时,它们也都是:

  • 逐个 source namespace 读出旧值
  • 在 Java 层做 reduce/merge
  • 再写回 target namespace

对应代码:

这件事很值得记住,因为它解释了一个常见误区:

  • state API 的逻辑结构越复杂,不代表 RocksDB 里的物理结构也越复杂
  • 很多复杂性其实停留在 Java 层的读改写协议和 merge 逻辑里

10.6 RocksDB 到底是 TM 级别、task 级别,还是更细粒度

先说结论:

  • 不是 TM 级别共享
  • 也不是一个 task 里每个 state 各开一个 RocksDB
  • 更准确地说,它是 keyed operator subtask 级别的一份 RocksDB 实例

也就是:

  • 一个 keyed operator 的一个 subtask,通常对应一份 RocksDBKeyedStateBackend
  • 这份 backend 里有一份自己的 RocksDB DB
  • 这个 subtask 里的多个 keyed state 共享这一个 DB,但各自使用不同的 Column Family

源码里有两个位置能把这件事说死。

第一,builder 本身就是按一份 keyed backend 来构造的。构造参数里直接带着:

  • operatorIdentifier
  • instanceBasePath
  • keyGroupRange
  • stateHandles

RocksDBKeyedStateBackendBuilder.java#L153-L203

这说明它不是在"整个 TM 上建一个全局 RocksDB 服务",而是在为:

  • 某个 operator subtask 的那份 keyed state backend

做初始化。

第二,RocksDBKeyedStateBackend 自己的注释写得很直白:

  • 不同 k/v states 不会各有一个 RocksDB 实例
  • 它们共用同一个 DB,只是各写各的 Column Family

RocksDBKeyedStateBackend.java#L263-L268

build() 的过程中,也确实是在 restore 完之后创建:

  • 一份 db
  • 一份 sharedRocksKeyBuilder
  • 一份 checkpointStrategy

最后把它们组装成这一份 backend,见 RocksDBKeyedStateBackendBuilder.java#L336-L431

可以用一个最小例子理解:

  • 作业里有一个 keyBy(userId) 后的聚合算子
  • 并行度是 4
  • 这个算子里声明了 ValueStateMapStateListState

那么更接近真实实现的情况是:

  1. 这个算子会有 4 个并发 subtask
  2. 每个 subtask 各有一份自己的 RocksDBKeyedStateBackend
  3. 每份 backend 各自打开一份本地 RocksDB 实例
  4. 这份 subtask 里的 ValueStateMapStateListState 共用这个 DB
  5. 但它们分别落到不同 Column Family

所以既不是:

  • "整个 TM 上所有 task 共用一个 RocksDB"

也不是:

  • "每个 state descriptor 都单独开一个 RocksDB 进程/实例"

10.6.1 为什么不做 TM 级别共享

如果把 RocksDB 做成 TM 级别共享,表面上看似乎能少开几个 DB,但会立刻碰到几个问题:

  1. 生命周期不好对齐
    • Flink 的 checkpoint、restore、dispose、failover、rescale,都是按 operator subtask 的 backend 来管理的
    • 如果变成 TM 共享 DB,一个 subtask failover 或 rescale,就会和别的 subtask 共用生命周期,边界很难切干净
  2. key-group 归属不好隔离
    • 每个 keyed subtask 只负责自己的 KeyGroupRange
    • 共享 DB 会让多个 subtask 的 key-group 混在一个更大的本地实例里,restore 和迁移边界更复杂
  3. 资源隔离更差
    • compaction、write buffer、iterator、snapshot 都会互相干扰
    • 一个热点算子很容易把同机其他算子的 RocksDB 资源也拖慢
  4. 实现复杂度会明显升高
    • 需要额外处理多 operator / 多 subtask 的命名、并发、故障恢复、清理顺序
    • 但收益未必大,因为 Flink 现在已经通过 Column Family 复用了"同一个 subtask 内多个 state"这层共享

所以 Flink 选的是一个中间粒度:

  • subtask 内共享一个 DB
  • subtask 之间彼此隔离

这个粒度基本刚好:

  • 对内,多个 state 共用一个 RocksDB,避免每个 state 单独开库
  • 对外,每个 operator subtask 保留自己的生命周期、快照边界和 key-group 边界

10.6.2 为什么也不做得更细,比如每个 state 一个 DB

如果每个 ValueState / MapState / ListState 都各开一个 RocksDB 实例,也会有明显问题:

  • RocksDB 实例数会膨胀
  • 每个实例都有自己的 memtable、block cache、compaction 线程、WAL/manifest 元数据
  • checkpoint / restore 时也会多出更多本地目录和管理成本

所以 Flink 没这么做,而是:

  • 一个 subtask 一份 DB
  • 多个 state 用 Column Family 隔离

这也是前面说 "统一 KV 编码承载多种逻辑 state" 的另一个落点。

10.7 一个把 RocksDB 结构具象化的小例子

假设:

  • 当前 key 是 userId=42
  • 当前 window namespace 是 [0,10)
  • state name 是 cnt
  • MapState 的 userKey 是 "itemA"

那 backend 内部真正构造的 RocksDB key,大致是:

  • ValueState / ListStateKeyGroupPrefix + serialize(42) + serialize([0,10))
  • MapStateKeyGroupPrefix + serialize(42) + serialize([0,10)) + serialize("itemA")

对应的构造入口就是:

所以把前面的几节串起来,你会发现 window / namespace / RocksDB 其实是一条完整链路:

  1. WindowOperator 把 window 放进 namespace
  2. keyed backend 用 (key, namespace) 定位逻辑 state
  3. RocksDB backend 再把它编码进 composite key

这套设计不是偶然拼起来的,它同时解决了几个问题:

  1. namespace 复用同一份 state handle
    • window 很多时,不必创建海量 handle
  2. MapStateuserKey 后缀拆成多条 KV
    • 可以做 prefix scan、按 entry 增量遍历、局部删除
  3. ListState 借 merge operator 做 append
    • 追加元素时不必每次整条 list 全量改写
  4. Reducing/Aggregating 把复杂逻辑留在 Java 层
    • 物理模型仍维持统一的 KV 结构

所以 RocksDB backend 最有意思的地方,不是"它支持很多 state 类型",而是:

  • 它用一套统一的 KV 编码,承载了多种逻辑 state 语义

10.9 ForSt:把主状态进一步解耦到远端存储

ForStStateBackend 的注释更激进:

  • 状态可以超过内存,甚至超过本地磁盘
  • spill 到 remote storage

ForStStateBackend.java#L89-L96

这不是简单"远程版 RocksDB",它试图解决的是云原生时代的新问题:

  • 容器本地磁盘受限
  • 本地 compaction 带来尖峰资源抖动
  • 超大状态扩缩容慢
  • checkpoint 太重

Flink 2.0 release note 里把这条演进说得很明确:

  • 只把状态搬到远端还不够
  • 还需要异步执行模型配套

flink-2.0.md#L37-L46

对应的解耦状态文档也把三件事绑在一起讲:

  1. ForSt backend
  2. State V2 async API
  3. SQL operator 的异步状态访问

disaggregated_state.md#L53-L66

这里还要补一层经常被误解的边界:

  • disaggregated
    • 指状态主存放在外部存储,例如 S3HDFS
    • 本地磁盘更多承担 cache / buffer 角色
  • local state store
    • 指状态主访问路径仍以 TaskManager 本地磁盘为主
    • 不是每次 state 访问都去远端拿

ForSt 文档对这个边界说得很直接:

  • 默认情况下,只有使用 async state API(State V2)时,ForSt 才真正做 disaggregated state
  • 如果还是同步 state API,ForSt 默认只作为 local state store

disaggregated_state.md#L153-L166

所以 ForSt 不是"只要用了就自动全远端化",而是:

  • 要配合 async state access,才真正把 ForSt 用成远端状态后端

这里还可以继续追问:那能不能一部分用异步、一部分用同步?

可以,但要分层次理解:

  1. 同一个 job 里混用
  2. 同一个 user function 里混用
    • 不推荐
    • State V2 文档明确写了:强烈不建议在同一个 user function 中混合同步和异步 state access:state_v2.md#L84-L91

也就是说,更稳妥的边界是:

  • 不同 operator 可以分别选择 sync / async
  • 但同一个 user function 最好只选一种状态访问模型

所以 ForSt 的本质是:

  • 把"状态存哪里"从计算节点本地盘进一步解耦出去

10.10 一条主线总结

这条演进可以简化成:

  1. Heap
    • 问题:我想要简单、快、小状态
  2. RocksDB
    • 问题:我需要大状态,内存放不下
  3. ForSt
    • 问题:我不仅要大状态,还要云原生下更轻的本地依赖和更强的解耦能力

所以 backend 演进的本质不是"换一种存储介质",而是:

  • 随着状态规模和部署形态变化,重新平衡延迟、容量、恢复、扩缩容成本

11. State V2:官方的 continuation 风格状态访问

如果你想真正理解 continuation 和 state 的关系,State V2 很值得看。

11.1 它解决什么问题

当 state 在远端或访问延迟变高时,原来的同步接口会把 task 线程卡住。

所以 State V2 引入的是:

  • StateFuture.thenApply
  • thenCompose
  • thenAccept

StateFuture.java#L27-L63

这其实就是:

  • 把"读 state -> 再决定下一步"的流程,写成 continuation 风格的状态机

官方中文文档也直接要求:

  • 不要阻塞拿结果
  • 用 future chain 描述后续逻辑

state_v2.md#L84-L91 state_v2.md#L107-L130

11.2 怎么开启

在 DataStream 上,必须先对 KeyedStream 调:

java 复制代码
keyedStream.enableAsyncState();

KeyedStream.java#L1092-L1101

11.3 它为什么和 continuation 很像

因为它的思路就是:

  • 不保留阻塞线程
  • 把"下一步做什么"编码到 future continuation 里

这和你自己手写 continuation/state machine 的本质是同一个方向:

  • 显式描述下一步
  • 不要依赖线程栈隐式悬挂

11.4 async state 的运行时本质是什么

如果把它说得最简单,可以先抓住 4 步:

  1. processElement() 里发起一次 async state 请求
  2. 当前 record 的"后续逻辑"挂到 StateFuture.thenApply / thenCompose / thenAccept
  3. task 线程不阻塞等待,而是让出执行权,继续处理别的 mail / callback
  4. state 结果回来后,再把 continuation 投回 mailbox,由 task 线程继续执行

这说明它不是:

  • "开一个独立线程把用户逻辑接着跑完"

而更像:

  • "先把后续逻辑登记成 continuation,等结果回来后再回到 mailbox 线程续跑"

一个最小 demo:

java 复制代码
public class AsyncCountFn extends KeyedProcessFunction<String, Event, Output> {

    private transient org.apache.flink.api.common.state.v2.ValueState<Integer> cntState;

    @Override
    public void open(OpenContext ctx) {
        cntState =
                getRuntimeContext()
                        .getState(
                                new org.apache.flink.api.common.state.v2.ValueStateDescriptor<>(
                                        "cnt", Integer.class));
    }

    @Override
    public void processElement(Event event, Context ctx, Collector<Output> out) throws Exception {
        cntState.asyncValue()
                .thenApply(cnt -> cnt == null ? 1 : cnt + 1)
                .thenCompose(nextCnt -> cntState.asyncUpdate(nextCnt).thenApply(ignored -> nextCnt))
                .thenAccept(nextCnt -> {
                    // 注意:不是某个后台线程直接执行这段代码。
                    // future 完成后,这个 callback 会先被 runtime 投回 mailbox,
                    // 然后才由 task 主线程执行到这里;所以 out.collect(...)
                    // 发生时,已经是"回到 task 线程之后"的事了。
                    if (nextCnt >= 100) {
                        out.collect(new Output(event.userId(), nextCnt));
                    }
                });
    }
}

这个 demo 的关键不是语法,而是控制流:

  • processElement() 发起异步读取后就结束当前直线执行
  • "加一、写回、判断是否输出"这些动作没有靠阻塞等待串起来
  • 它们被拆进 future chain,等结果回来后再继续

如果把 asyncValue() 真的沿源码拆开,它的完整过程可以按下面 6 步看:

  1. AbstractValueState.asyncValue()handleRequest(StateRequestType.VALUE_GET, null)AbstractValueState.java#L43-L46
  2. AbstractKeyedState.handleRequest(...) 再把请求交给 StateRequestHandlerAbstractKeyedState.java#L55-L65
  3. 默认实现 StateExecutionController.handleRequest(...) 会:
    • 先通过 asyncFutureFactory.create(currentContext) 创建一个 InternalAsyncFuture
    • 再创建一个 StateRequest(state, VALUE_GET, payload, future, context)
    • 然后把 request 交给异步执行控制器调度:StateExecutionController.java#L74-L103
  4. 这里的 asyncFutureFactory 不是普通 future 工厂,它在创建 future 时就把"如何回到当前 record 的上下文"一起绑进去了:
    • AsyncExecutionController 构造时先创建 CallbackRunnerWrapper
    • 再用它构造 AsyncFutureFactoryAsyncExecutionController.java#L163-L168
    • AsyncFutureFactory.create(context) 会创建 ContextAsyncFutureImpl,并把 callback 包成:
  5. CallbackRunnerWrapper.submit(...) 本身又不会直接执行 callback,而是调用 mailboxExecutor.execute(...),把它投成一封 mailbox mail:CallbackRunnerWrapper.java#L51-L63
  6. 所以后续 thenApply / thenCompose / thenAccept 虽然是挂在 future 上,但真正执行它们时的完整顺序其实是:
    • state request 完成
    • future complete
    • continuation 通过 callbackRunner.submit(...) 包装
    • wrapper 先恢复这条 record 的 RecordContext
    • 再把 callback 投回 mailbox
    • 最后由 task 线程执行用户 continuation

这条链路说明:

  • asyncValue() 并不是"直接开个线程去查,然后把结果同步塞回来"
  • 它本质上是"封装一条 state request,再返回一个 future 作为后续 continuation 的挂载点"
  • 而这个 future 在创建时,就已经被 runtime 接上了:
    • RecordContext 恢复
    • CallbackRunnerWrapper
    • mailboxExecutor

所以如果只看用户代码里的:

java 复制代码
cntState.asyncValue().thenAccept(...)

容易误以为:

  • future 一完成,thenAccept(...) 就在某个后台线程里直接继续

但把 wrapper 这一层补上后,更准确的真实流程是:

  • request 在后台完成
  • wrapper 恢复当前 record 的 context
  • callback 被投回 mailbox
  • task 线程再继续执行 thenAccept(...)

11.5 future 完成后,谁来继续执行

这个问题要分两层。

第一层:

  • 异步请求本身会由 async executor 之类的后台执行单元推进

第二层,也是更关键的一层:

  • 用户 continuation 不会在某个独立业务线程里直接继续跑
  • 它会被投回 mailbox,再由 task 线程继续执行

运行时代码里这件事很直接:

所以如果你问"future 完成后怎么继续",更准确的答案是:

  • 不是靠独立线程直接续跑用户逻辑
  • 而是靠 mailbox 把 continuation 重新排回 task 主线程

这也解释了为什么它和 continuation 很像:

  • 当前这条 record 先"停在 future 上"
  • task 线程去做别的事
  • 等结果回来,再从 continuation 继续

11.6 异步调用 state 也是一种"yield"吗

可以说是,但要说得更准确一点:

  • 它不是"把当前 Java 线程真的挂起在某个系统调用上"
  • 而是 通过 mailbox 协作式让出执行权
  • 当前 record 对应的后续逻辑,会挂在 StateFuture.thenApply / thenCompose / thenAccept 这些 continuation 链上,等异步 state 结果回来后再继续推进

所以它和你手写 continuation 的相似点在于:

  • 当前这条记录没有靠"阻塞等待结果"继续执行
  • 而是把"下一步该做什么"注册成回调
  • task 线程在等待期间可以去处理别的 mail / callback

运行时这一点可以直接从 AsyncExecutionController 看出来:

所以从执行模型看,它确实是一种:

  • 不阻塞线程
  • 让出执行权
  • 等结果回来后再继续

的 continuation 风格。

11.7 那当前这条 record 做了什么存储

这里最容易误解。

如果把问题说得非常严格,答案是:

  • 运行中的"异步调用现场"本身,不会被单独持久化成一份可续跑日志
  • 运行时主要维护的是一组内存态控制结构
  • checkpoint 前会先把 in-flight 收敛掉,再只对"已经收敛的一致状态"做快照

也就是说,它不是:

  • "每发起一次异步 state 访问,就把这条 record 的 continuation 单独存盘"

而更像:

  • "运行中先用内存里的 record context / epoch / in-flight 计数管理这条 record"
  • "做 checkpoint 前先把这些未完成异步访问 drain 完"
  • "只把最终一致的 state 内容纳入 checkpoint"

运行时关键对象有:

  1. RecordContext
    • 保存当前 record 的 key、命名空间、epoch、引用计数等上下文
    • 它更像"这条 record 当前活跃着的运行时上下文"
    • RecordContext.java#L46-L90
  2. AsyncExecutionController
  3. EpochManager

所以"当前 record 的进度"主要是放在这些运行时内存结构里,不是放在单独的 durable record log 里。

11.8 恢复时怎么接上

真正关键的是这一点:

  • Flink 不会在故障恢复后继续跑原来那个未完成的 StateFuture
  • 它的做法是:checkpoint 前先把 in-flight 清空,只让一致状态进入快照
  • 故障后从 checkpoint 状态恢复,未完成部分通过上游重放重新执行

异步状态算子在 checkpoint 前会显式做 drainInflightRecords(0)

这里的意思不是"把半条 continuation/future 原样存盘",而是:

  1. 如果当前还有 in-flight 的异步 state 请求
  2. checkpoint 会在 prepareSnapshotPreBarrier 阶段等待它们收敛
  3. AsyncExecutionController 在等待过程中会反复 tryYield(),让 mailbox 去执行异步回调,把 in-flight 数降下来
  4. 直到 drainInflightRecords(0) 返回,才继续真正的 barrier 与 snapshot

见:

所以更准确的说法是:

  • checkpoint 会等这些异步 state 访问先"跑到可快照边界"
  • 而不是把一个尚未完成的 StateFuture 直接塞进 checkpoint

这也带来一个直接后果:

  • 如果异步 state 请求迟迟不完成,checkpoint 就会被拖慢
  • 严重时同样可能 timeout

这和前面讨论 continuation 时那个问题正好相反:

  • 对 State V2 async state,Flink 的选择是:checkpoint 前先等它收敛
  • 对用户自己维护的 continuation/外部异步工作流,Flink 不会替你保存半条运行时 future;你必须自己在 state 里把"未完成进度"写清楚

而 task checkpoint 主流程本来就是:

  1. prepareSnapshotPreBarrier
  2. 再发 barrier
  3. 再 snapshot

见:

这意味着:

  • checkpoint 成功时,异步 state 访问不会留下"半条没收敛的 record continuation"混进快照
  • 恢复时接上的,是 checkpoint state
  • 不是旧的 async future

一句话总结:

  • 运行时靠 RecordContext / mailbox callback / in-flight 管理异步推进
  • checkpoint 前靠 drain 保证边界收敛
  • 恢复时靠 checkpoint state + 上游重放接上
  • 因此这类异步 state 模型不会出现"source 已越过,但 record 既没进 checkpoint、也没被 operator state 接住"的窗口

12. continuation 的本质:不是保存栈,而是保存阶段

这是最重要的一节。

如果你以后想自己写出 continuation 这类模式,真正要掌握的不是"某个 API 名字",而是这个转换:

  • 把长过程写成显式状态机

12.1 错误直觉

很多人会直觉认为 continuation 的恢复应该靠:

  • 保存 coroutine 栈
  • 保存 Java continuation 对象
  • 保存 mailbox 里的闭包

这在 Flink 里通常都不是可靠主线。

真正可靠的主线是:

  • 保存阶段
  • 保存待执行任务描述
  • 保存已完成副作用的 durable 结果

12.2 正确建模

假设一条输入会经历三步:

  1. 校验输入
  2. 调 LLM
  3. 调工具并提交结果

不要把它想成"一个长函数中间 yield 两次",而要把它改写成:

java 复制代码
enum Phase {
    START,
    WAIT_LLM,
    WAIT_TOOL,
    DONE
}

class WorkflowState {
    Phase phase;
    String llmResult;
    String toolResult;
}

然后每次处理输入时:

  1. 先读 WorkflowState
  2. 看当前 phase
  3. 执行本阶段该做的事
  4. 把新 phase 和中间结果写回 state

这样 checkpoint 保存的就是:

  • 任务现在在哪个阶段
  • 后面该从哪里继续

而不是某个语言运行时的栈帧。

12.3 一个最小 demo

java 复制代码
public class WorkflowFn extends KeyedProcessFunction<String, Input, Output> {

    private transient ValueState<WorkflowState> workflowState;

    @Override
    public void open(OpenContext ctx) {
        ValueStateDescriptor<WorkflowState> desc =
                new ValueStateDescriptor<>("workflow", WorkflowState.class);
        workflowState = getRuntimeContext().getState(desc);
    }

    @Override
    public void processElement(Input in, Context ctx, Collector<Output> out) throws Exception {
        WorkflowState s = workflowState.value();
        if (s == null) {
            s = new WorkflowState();
            s.phase = Phase.START;
        }

        switch (s.phase) {
            case START:
                validate(in);
                s.phase = Phase.WAIT_LLM;
                workflowState.update(s);
                callLlmAsync(in);  // 外部异步触发
                return;

            case WAIT_LLM:
                // 这里通常由回调事件再次驱动
                return;

            case WAIT_TOOL:
                return;

            case DONE:
                out.collect(buildOutput(s));
                workflowState.clear();
        }
    }
}

这个 demo 的重点不是语法,而是:

  • 把继续执行的依据放进 state

12.4 写 continuation 模式时必须掌握的五件事

  1. 身份
    • 这份进度是按 key 维度,还是按 operator/subtask 维度?
  2. 阶段
    • 当前处于哪一步?
  3. 输入边界
    • source 已经越过这条输入后,谁来保证它不丢?
  4. 副作用边界
    • 哪一步已经真正发出外部调用,重跑时怎么防重?
  5. 恢复入口
    • 恢复后是谁再次驱动"继续执行"?

如果这五个问题都答清楚了,continuation 的设计通常就稳了。

13. 一句最重要的区分

以后看到一个复杂 state 设计,可以先问:

  • 这是在解决 防丢
  • 还是在解决 防重

通常:

  • keyed/operator state
    • 更偏防丢与恢复继续执行
  • ActionStateStore / WAL / external durable store
    • 更偏防重与副作用去重

这两者都可能"保存一些中间进度",但本质职责不同。

14. 最后给一个心智模型

可以把 Flink state 理解成三层记忆:

  1. Keyed state
    • 记住"某个 key 自己的过去"
  2. Operator state
    • 记住"这个 subtask 手里还拿着哪些工作"
  3. Broadcast state
    • 记住"所有实例都应共享的规则"

而 backend 演进就是在问:

  • 这份记忆该放在堆上
  • 放在本地磁盘
  • 还是放到远端主存储

而 continuation/async state 模式在问的是:

  • 怎么把'以后继续做什么'从线程栈里拿出来,显式放进 state

如果你能稳定地用这个心智模型分析问题,后面自己设计 continuation、异步工作流、去重与恢复逻辑时,就不会只停在"API 会不会用",而是真正掌握 state 的本质。

15. State 排障与运行时机制

前面的章节更偏"设计理解",这一章专门补:

  • 恢复为什么会失败
  • state 为什么会越跑越大
  • checkpoint / restore 为什么会越来越慢
  • RocksDB 为什么会抖动、刷盘、compaction 堵住

这一章统一按同一个模板写:

  1. 先讲过程
  2. 再讲为什么这样设计
  3. 最后给排障信号和典型现象

15.1 serializer 兼容:为什么代码能启动,恢复却失败

15.1.1 先讲过程

checkpoint / savepoint 里保存的,不只是 state bytes,还保存了 serializer 的快照信息。

更准确地说,恢复时会发生这几步:

  1. Flink 先从 checkpoint 元信息里读出旧 serializer 的 TypeSerializerSnapshot
  2. 你的新作业注册当前 serializer
  3. runtime 用新旧 snapshot 做兼容性判断
  4. 判断结果通常分成四类:
    • COMPATIBLE_AS_IS
    • COMPATIBLE_AFTER_MIGRATION
    • COMPATIBLE_WITH_RECONFIGURED_SERIALIZER
    • INCOMPATIBLE
  5. 如果是不可兼容,或者当前 backend 不支持所需迁移,恢复阶段就会失败

关键源码:

RocksDB restore 里也能直接看到这种检查:

一个最小例子:

  • 上一版把某个 state 存成 Tuple2<String, Integer>
  • 新版本把它改成另一种二进制 layout,但沿用原 state name
  • 代码能编译,作业也能提交
  • 但恢复时,旧 bytes 不再能被新 serializer 正确解释
  • 于是 checkpoint restore 才是第一次真正暴露问题的地方
15.1.2 为什么这样设计

原因很直接:

  • Flink 要保证"旧 checkpoint 里的 bytes 还能被正确解释"
  • 它不能只相信"Java 类名看起来差不多"
  • 真正决定能不能恢复的,是二进制 schema 是否兼容

所以这里的核心不是:

  • "你的代码对象长得像不像"

而是:

  • "新 serializer 能不能正确读旧 bytes"
15.1.3 排障信号和典型现象

如果遇到下面这些现象,第一时间就该怀疑 serializer 兼容:

  • 作业从空跑能启动,但从 savepoint / checkpoint 恢复失败
  • 报错里出现 StateMigrationException
  • 只改了字段、泛型、TTL 配置或 serializer 实现,结果恢复突然失败
  • 某个 state name 沿用旧名字,但 value 类型或编码方式变了

排查时先问这几个问题:

  1. state name 有没有复用旧名字
  2. key serializer / namespace serializer / value serializer 有没有变
  3. 这次改动是 compatible as is,还是其实已经进入 migration / incompatible
  4. TTL 是否一起改了,因为 TTL 也会改变 state 的实际编码形态

15.2 TTL:为什么"设置了过期"不等于"立刻删掉"

15.2.1 先讲过程

TTL 的运行过程比直觉里更保守。

它大致是:

  1. state value 在 backend 中通常不是"裸值",而是带上时间信息的 TTL 包装值
  2. 读 state 时,runtime 会检查是否过期
  3. 清理过期值有多种路径:
    • 访问时检查并清理
    • heap backend 的增量 cleanup
    • RocksDB compaction filter 清理
  4. 所以"过期"与"物理删除"并不是同一时刻发生

关键源码和文档:

一个最小例子:

  • 你把 ValueState<Order> 配成 TTL = 1h
  • 某个 key 在 10:00 写入,之后不再访问
  • 到了 11:30,它逻辑上已经过期
  • 但如果没有新的访问、没有 heap 增量清理、也还没轮到 RocksDB compaction
  • 这条记录可能仍然暂时留在底层存储里
15.2.2 为什么这样设计

因为如果要求"每个过期点都立刻精确物理删除",代价会非常高:

  • 需要额外调度大量定时清理动作
  • 对 heap backend 会增加扫描成本
  • 对 RocksDB 会增加 compaction / JNI / 底层遍历开销

所以 Flink 选的是:

  • 逻辑过期优先
  • 物理删除延后、惰性、按机会发生

这能在大部分场景下,把语义和成本平衡住。

15.2.3 排障信号和典型现象

下面这些现象,通常不是 TTL "失效",而是你把"逻辑过期"和"物理清理"混成了一件事:

  • state size 不断增长,但业务侧几乎读不到这些旧值
  • 开了 TTL 之后,checkpoint bytes 下降不明显
  • RocksDB compaction 很忙,CPU 和 IO 升高
  • 某些很少访问的 key 过期很久后还占着磁盘

排查时优先看:

  1. backend 是 heap 还是 RocksDB
  2. 配置的是访问时 cleanup、增量 cleanup,还是 compaction filter
  3. 业务访问模式是不是"写一次后长期不读"
  4. list / map 这类集合 state 是否导致 TTL 清理成本显著变高

另外有个版本细节值得单独记住:

15.3 timer:state 的"未来唤醒点",不是普通定时器

15.3.1 先讲过程

这一节只保留 state 视角下最需要的内容,详细展开见 timer演进分析.md#L1-L29

从 state 角度看,timer 的过程是:

  1. keyed operator 为某个 (key, namespace) 注册 timer
  2. timer service 把这个未来唤醒点保存下来
  3. checkpoint 时,timer 也会被按 key-group 写入快照
  4. 恢复后再按 key-group 读回
  5. 触发时,runtime 先切回对应 key / namespace,再执行回调

关键位置:

15.3.2 为什么这样设计

timer 如果不进入 checkpoint,就只有"未来会叫醒你",却没有"故障后还能接着叫醒你"的能力。

所以 timer 的本质不是普通线程定时器,而是:

  • 和 keyed state 一起参与 checkpoint / restore / rescale 的未来触发点
15.3.3 排障信号和典型现象

排查 timer 相关问题时,不要只问"为什么没回调",更要问:

  1. 对应进度是不是已经写进 state
  2. timer 是 processing time 还是 event time
  3. event time 的话,watermark 有没有真正推进
  4. 恢复后是不是出现了 timer 集中补触发

如果问题已经明显落在 timer 机制本身,直接去看 timer演进分析.md#L1-L29 会更高效。

15.4 restore / local recovery:为什么恢复慢,不一定是状态太大

15.4.1 先讲过程

先分清两种恢复路径:

  1. 主路径
    • JobManager 把 checkpoint handle 发回 task
    • task 从分布式存储恢复状态
  2. 本地恢复路径
    • task 同时保留一份本地 secondary copy
    • 如果恢复时还能回到原来的位置,优先尝试从本地副本恢复
    • 本地失败时,再透明回退到远端 primary copy

官方文档把这层关系说得很清楚:

运行时结构里也能看到 local recovery 配置挂在 keyed backend 上:

一个最小例子:

  • 某个任务只有一个 TM 短暂进程重启
  • 大多数 subtask 最终还被调度回原机器
  • 这时真正决定恢复时长的,不一定是"总 state 有多大"
  • 而更可能是"有多少 subtask 命中了本地恢复,有多少只能走远端拉取"
15.4.2 为什么这样设计

Flink 不能只依赖本地状态,因为:

  • 本地磁盘不具备分布式容错能力
  • 机器丢了,本地副本也一起丢
  • rescale 还需要跨节点重新分配状态

所以它必须保留远端 primary copy 作为真相来源。

但只靠远端恢复,代价又太高,所以才增加 task-local recovery 这条"尽量命中本地、失败再回退"的加速路径。

15.4.3 排障信号和典型现象

遇到下面这些现象,优先怀疑 restore 路径,而不是先怀疑业务逻辑:

  • checkpoint 很快,但恢复很慢
  • 同一作业不同轮故障恢复耗时差异很大
  • 只有一小部分 TM 失败,恢复却还是整片很慢
  • 开了 local recovery,但恢复时间看起来没有明显改善

排查时先看:

  1. 是否真的开启了 state.backend.local-recovery
  2. 当前用的是 full checkpoint 还是 incremental checkpoint
  3. 失败后 subtask 是否回到了原 slot / 原机器
  4. 是否用了 unaligned checkpoint,因为它当前不支持 task-local recovery

15.5 RocksDB 内存与 compaction:为什么 state 问题经常表现成性能问题

15.5.1 先讲过程

RocksDB 问题经常不是"读不出来",而是:

  • memtable 刷得太勤
  • block cache 不够
  • compaction 积压
  • native memory 顶高
  • checkpoint / 写入 / 查询互相争资源

先抓住两个事实:

  1. RocksDB 实例虽然是 subtask 级 keyed backend,但内存配置不一定只在这一个实例内部生效
  2. Flink 支持把 RocksDB 内存做成 slot 级共享预算

这里最容易混淆的是:

  • 实例粒度预算粒度 不是一回事

更准确地说:

  • RocksDB 的 DB instance 仍然是 operator subtask 级别
  • 但 block cache / write buffer manager 这类内存资源,可以在同一个 slot 里的多个 RocksDB 实例之间共享

所以:

  • 如果一个 slot 里只跑一个 stateful subtask,看起来 slot 级共享和 task 级实例是重合的
  • 但只要一个 slot 里承载多个 stateful subtask,它们就会是"多个独立 DB 实例,共享一份 slot 级内存预算"

这一点可以直接从配置对象和资源容器看出来:

再往前走一步,compaction 的默认行为也不要简单理解成"就是 RocksDB 原生默认值"。

Flink 在构造 ColumnFamilyOptions 时,会主动设置一批 compaction 相关参数:

  • compactionStyle
  • compressionPerLevel
  • levelCompactionDynamicLevelBytes
  • targetFileSizeBase
  • maxBytesForLevelBase
  • writeBufferSize
  • maxWriteBufferNumber
  • minWriteBufferNumberToMerge
  • periodicCompactionSeconds

RocksDBResourceContainer.java#L377-L409

这些参数的来源顺序是:

  1. Flink 配置里显式设置的值
  2. PredefinedOptions 里的 profile 值
  3. RocksDBConfigurableOptions 里的默认值

也就是:

  • configured value > pre-defined value > default value

RocksDBResourceContainer.java#L323-L338

这里还有一个容易误解的点:

  • PredefinedOptions.DEFAULT 并不是"Flink 额外给你一套经验型 profile"
  • 它其实是空 profile,本身不额外指定选项

PredefinedOptions.java#L54-L60

所以默认情况下,更准确的说法是:

  • Flink 先创建 RocksDB 的基础对象
  • 再套上 RocksDBConfigurableOptions 的默认值
  • 如果你选了 SPINNING_DISK_OPTIMIZED / FLASH_SSD_OPTIMIZED 之类 profile,再用 profile 覆盖其中一部分
  • 最后如果你提供 RocksDBOptionsFactory,再由用户代码继续覆盖

常见 compaction / flush 相关默认值包括:

如果你关心的是 TTL 清理相关的 periodic compaction,它也不是完全交给 RocksDB 自己摸索:

  • compaction.filter.query-time-after-num-entries 默认是 1000
  • compaction.filter.periodic-compaction-time 默认是 30 days

RocksDBConfigurableOptions.java#L337-L355

这说明 Flink 对 compaction 的态度不是"全都交给 RocksDB 原生默认",而是:

  • 默认提供一套偏保守、通用的基线
  • 再允许 profile 和用户 factory 往具体硬件与负载上调

能观测什么,也不是黑盒:

  • MemTableFlushPending
  • CompactionPending
  • CurSizeAllMemTables
  • EstimateNumKeys
  • BackgroundErrors

这些 RocksDB native metrics 都能打开,见 RocksDBNativeMetricOptions.java#L37-L140

15.5.2 为什么这样设计

Flink 不把 RocksDB 当成"单纯的本地字典",而是当成一个带 memtable、cache、flush、compaction 的嵌入式存储引擎。

所以 state 性能问题最后常常会转成:

  • 内存分配问题
  • IO 放大问题
  • compaction 调度问题
  • native / managed memory 边界问题

也正因为如此,很多"checkpoint 变慢""吞吐下降""延迟抖动"的根因,其实在 RocksDB 资源模型里,不在 state API 表面。

Flink 默认值之所以看起来偏保守,也是因为它首先要保证:

  • 在大多数通用场景下先稳定
  • 不要先把 native memory、flush 和 compaction 压力推得过高

所以这些默认值更像:

  • 面向通用作业的基线

而不是:

  • 已经针对你这条业务链路调到最优
15.5.3 排障信号和典型现象

如果看到下面这些现象,可以优先从 RocksDB 资源模型切入:

  • checkpoint 时长持续上升
  • CPU 和磁盘 IO 周期性尖刺
  • rocksdb.compaction.pending 长时间为真
  • memtable 持续堆积或频繁 flush
  • state 数量一多,性能突然明显退化

排查时建议按这个顺序问:

  1. state 数量是不是很多,因为每个 state 都会对应一个 Column Family
  2. slot 级 RocksDB 预算是不是太小
  3. write buffer ratio / high priority pool ratio 是否合适
  4. compaction 是不是被 TTL filter、list/map state 或大批量更新放大了
  5. native metrics 有没有打开,否则你只能凭感觉猜

15.6 一组排障速记

如果你以后排 state 问题,先别急着看业务代码,可以先用下面这组问题定位层次:

  1. 恢复失败
    • 先怀疑 serializer 兼容、state name 复用、TTL 变更
  2. state 越跑越大
    • 先怀疑 TTL 清理路径不匹配访问模式,或热点 key / 大集合 state
  3. checkpoint 慢
    • 先怀疑 RocksDB flush / compaction / native 内存争用
  4. 恢复慢
    • 先怀疑 restore 路径、本地恢复命中率、远端读取
  5. "定时触发不对"
    • 先怀疑 watermark / timer 类型 / 进度是否真正写入 state

所以从排障视角看,state 不只是:

  • "我存了什么"

而更是:

  • "这些 bytes 用什么 schema 解释"
  • "什么时候算过期"
  • "故障后从哪里恢复"
  • "底层存储什么时候开始抖"
  • "未来触发点靠什么接上"
相关推荐
赵渝强老师2 小时前
【赵渝强老师】MySQL数据库的分库与分表
数据库·mysql
XDHCOM2 小时前
利用MSSQL解析优化数据库性能,提升效率,驱动业务创新与稳定发展
数据库·sqlserver
AI先驱体验官2 小时前
臻灵:数字人形象驱动新突破,NVIDIA开源PersonaPlex带来的技术变局
大数据·人工智能·深度学习·重构·开源·aigc
郝学胜-神的一滴2 小时前
激活函数:神经网络的「非线性灵魂」,让模型从“直线”走向“万能”
人工智能·pytorch·python·深度学习·神经网络·程序人生·机器学习
鸿蒙程序媛2 小时前
【工具汇总】git 常用命令行汇总
大数据·git·elasticsearch
·云扬·2 小时前
MySQL分区实战指南:从原理到落地的完整攻略
数据库·mysql
csgo打的菜又爱玩2 小时前
3.HAService启动流程解析
flink
雨墨✘2 小时前
PHP怎么执行Shell命令_exec与shell_exec区别说明【说明】
jvm·数据库·python
人工智能培训2 小时前
如何将高层任务分解为可执行的动作序列?
大数据·人工智能·算法·机器学习·知识图谱