一篇文章讲透 Flink State
1. 这份文档想解决什么问题
很多人第一次学 Flink state,会把它理解成"给算子加一个内存变量"。这个理解太浅了。
更接近本质的理解应该是:
- State 是流处理里"把过去带到未来"的机制
- Checkpoint 是把这份过去固化下来,保证失败后还能接上
- State Backend 是这份过去在运行时的物理承载方式
如果把这三件事想清楚,后面很多问题都会顺:
- 为什么
keyBy()之后才能拿ValueState - 为什么有
operator state、broadcast state - 为什么同样是 state,堆上、RocksDB、ForSt 的语义基本一致,性能和能力却差很多
- 为什么 continuation 这类"长事务拆步执行"的模式,本质上不是保存协程栈,而是把"后面还要做什么"显式写进 state
这份文档按下面顺序展开:
- 先讲 Flink state 要解决什么问题
- 再讲 state 的三种主要作用域:
keyed、operator、broadcast - 再讲 window / namespace / state 之间到底是什么关系
- 再讲 state backend 的演进:内存、磁盘、云端
- 最后讲 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 之前,最好先回答三个问题:
- 这份 state 属于谁?
- 某个 key
- 某个 operator subtask
- 所有 subtask 都应看到的同一份规则
- 这份 state 怎么重分配?
- 跟着 key 走
- 按列表元素 round-robin 分
- 广播给所有并发实例
- 失败恢复时,下一步靠什么继续?
- 恢复值本身
- 恢复待处理任务队列
- 恢复规则或元数据
把这三个问题想清楚,state 设计通常就不会偏。
4. Flink State 抽象分层
Flink 的 state 抽象,大致可以看成三层:
StateBackend- 决定运行时 backend 如何创建 keyed/operator backend
KeyedStateBackend/OperatorStateBackend- 运行时真正承载 state 的 backend 容器
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?
关键在于要区分两件事:
- Java 字段
- 这里的
countState只是函数对象里的一个字段
- 这里的
- 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);
}
这里的流程是:
- 用户函数对象被创建
countState字段先保持为空- task 真正启动后,在
open()里重新通过 runtime context 取 state 句柄 - 之后
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 多神奇,而是:
- 上游已经
keyBy(userId) - 运行时先切到当前 key
- 这次
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 看:
WindowAssigner先提供窗口自己的序列化器getWindowSerializer(...)WindowOperator.open()用这个windowSerializer去创建windowState- 处理每条元素时,对命中的每个 window 调一次
windowState.setCurrentNamespace(window) - 后续
add()/get()/clear()就都落在这个(key, window)坐标上
关键源码位置:
WindowAssigner提供 window serializer: WindowAssigner.java#L67-L72WindowOperator.open()用windowSerializer创建 window state: WindowOperator.java#L245-L280- 处理元素时切到当前 window namespace: WindowOperator.java#L405-L432
把这件事翻成更直白的话,就是:
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 为什么还要区分 actualWindow 和 stateWindow
对 merging window,情况会再多一层。
运行时不是永远直接拿"当前逻辑窗口"做 namespace,而是可能先通过 MergingWindowSet 找到真正承载状态的 stateWindow:
- 逻辑上这条元素命中某个
actualWindow - 如果窗口支持 merge,运行时会查这个窗口当前映射到哪个
stateWindow - 真正写 state 时,用的是
stateWindow作为 namespace - 需要合并时,再通过
mergeNamespaces(target, sources)把多个 namespace 的内容并到一起
关键代码:
- merging 场景下先找
stateWindow再写: WindowOperator.java#L375-L403 - trigger 侧按
(window, serializer, desc)取 partitioned state: WindowOperator.java#L853-L861
这说明 merging window 的核心不是"把几个 Java 对象名义上并一下",而是:
- 把几个 namespace 上的状态真实搬到一个目标 namespace
6.5 为什么 windowState() 会随 window 变,而 globalState() 不会
ProcessWindowFunction.Context 暴露了两套 store:
windowState()globalState()
它们的差别不是"一个能 checkpoint,一个不能 checkpoint",而是 namespace 不同:
windowState()走的是keyedStateBackend.getPartitionedState(window, windowSerializer, desc),也就是当前 window 作为 namespace: WindowOperator.java#L763-L768globalState()直接返回 operator 自己的KeyedStateStore: WindowOperator.java#L809-L817
而 AbstractStreamOperator.getPartitionedState(stateDescriptor) 的默认实现会自动走 VoidNamespace.INSTANCE: AbstractStreamOperator.java#L541-L565
VoidNamespace 自己的定义也很直白:
- 它就是"没有 namespace"的占位符: VoidNamespace.java#L23-L39
- 对应 serializer 也是一个固定的占位序列化器: VoidNamespaceSerializer.java#L29-L75
所以这里可以直接得出一个常见但容易混淆的结论:
windowState()是按(key, window)分区globalState()是按(key, VoidNamespace)分区
也就是说,globalState() 依然是 keyed state,只是它没有 window 这一维。
6.6 一个把关系说透的小例子
假设:
- 当前 key 是
userId=42 - 当前窗口是
[0,10) - 另一个窗口是
[10,20) - 你定义了一个名字叫
cnt的ValueState
那三种访问实际对应的是:
- 普通 keyed state:
(42, VoidNamespace) -> cnt - 第一个 window 的 state:
(42, [0,10)) -> cnt - 第二个 window 的 state:
(42, [10,20)) -> cnt
所以 "window / namespace / state" 的关系,不是三套彼此独立的机制,而是一条链:
- key 先决定"哪一份分区状态"
- namespace 再决定"这份分区状态里的哪个槽位"
- 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 里会落到三种不同语义:
- operator list state
- 每个 subtask 各有一份
- rescale 时按元素 round-robin 重分配
- union list state
- 恢复时把所有 subtask 的列表并到每个实例
- 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 帮你同步。
标准写法是:
- 先定义一个
MapStateDescriptor - 对规则流调用
.broadcast(descriptor) - 再把这个
BroadcastStream和主数据流connect - 最后在
BroadcastProcessFunction或KeyedBroadcastProcessFunction里,通过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,语义就是把一条元素发往所有下游并发实例:
所以一条规则消息到来时,流程是:
- 规则流元素被广播给所有下游 task
- 每个 task 在
processBroadcastElement(...)中都收到这条规则 - 每个 task 分别执行一次
ctx.getBroadcastState(...).put(...) - 于是每个 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 模式保存
- 恢复时每个并发实例都拿到同样的内容
相关实现见:
- DefaultOperatorStateBackend.java#L171-L174
- DefaultOperatorStateBackendSnapshotStrategy.java#L201-L211
- RoundRobinOperatorStateRepartitioner.java#L427-L447
所以一句话总结:
- 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 和翻译路径可以对应到:
broadcast()返回BroadcastStream: DataStream.java#L338-L344BroadcastStream参与connect(...): BroadcastConnectedStream.java#L230-L279- 最终翻译成普通 two-input operator: BroadcastStateTransformationTranslator.java#L70-L90
8.9 为什么不经过 JM 做数据分发
因为 JM 的职责是:
- 生成/部署拓扑
- 调度
- 协调 checkpoint
- 管理任务与分区信息
它不是高吞吐数据转发节点。
在 Flink 里,JM 负责"告诉 TM 们这条 broadcast 边怎么连",但不负责替它们转发规则数据本身。
任务部署与输入分区描述下发,见:
真正的数据广播发生在 TaskManager 网络层:
broadcast()在边上挂的是BroadcastPartitioner: DataStream.java#L855-L859- 分区器语义是"发往所有下游实例": BroadcastPartitioner.java#L35-L57
- 发送端会用
BroadcastRecordWriter: RecordWriterBuilder.java#L47-L53 BroadcastRecordWriter.java#L45-L49 - 底层再把同一条记录写到所有 subpartition: BufferWritingResultPartition.java#L173-L190
所以更准确的说法是:
- JM 负责控制面
- TM 负责数据面
- BroadcastState 依赖的是"TM 之间的广播分发 + 每个 task 本地副本",不是"JM 中转数据"
8.10 为什么 Flink 选这种实现
主要有三个原因:
- 统一模型
- 规则本身也是流,主数据也是流
- 用普通 DAG + 普通边 + 特殊 partitioner,模型最统一
- 复用现有机制
- watermark
- checkpoint
- backpressure
- failover
- rescale
都可以直接复用,不需要额外发明一条"控制消息总线"
- 避免 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 的语义走不同的重分配路径:
- keyed state:按 key-group
- operator list state:按 元素切分
- union list state:按 并集复制
- 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 时怎么做
过程是:
- 固定 key-group 总数不变
- 按新的并行度重新计算每个 subtask 负责的 key-group range
- 从旧的
KeyedStateHandle中取与这个 range 相交的部分 - 交给新的目标 subtask
源码路径:
- 新 range 计算与交集分配: StateAssignmentOperation.java#L679-L721
9.1.3 一个最小例子
假设:
maxParallelism = 128- 原并行度
p=2 - 新并行度
p=4
那么:
- 原来两个 subtask 可能是:
- subtask-0 负责
[0..63] - subtask-1 负责
[64..127]
- subtask-0 负责
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
定义见:
- OperatorStateHandle.java#L42-L52
getListState -> SPLIT_DISTRIBUTE: DefaultOperatorStateBackend.java#L210-L219
它的重分配思路不是"每个 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
也就是:
- 先把所有 subtask 里的这份状态做并集
- 再把完整并集给每个新 subtask
API 到模式映射见:
getUnionListState -> UNION: DefaultOperatorStateBackend.java#L210-L219
重分配算法见:
9.3.1 什么时候适合
适合:
- 小规模元数据
- 恢复时每个 subtask 都要先知道全集
- 再由自己做本地过滤
这就是前面 flink-agents 里 currentProcessingKeysOpState 的典型用法。
9.3.2 风险
它不适合高基数大列表。
因为它的恢复语义是:
- 每个 subtask 都先拿一份全量并集
如果列表特别大:
- checkpoint 元数据会膨胀
- restore 成本会很高
9.4 Broadcast State:每个 task 都恢复一份副本
BroadcastState 的模式是:
BROADCAST
定义和快照策略位置:
- OperatorStateHandle.java#L42-L52
- DefaultOperatorStateBackend.java#L171-L174
- DefaultOperatorStateBackendSnapshotStrategy.java#L201-L211
它和 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 步:
- runtime 先把当前 record 的 key 切到
KeyedStateBackend - RocksDB backend 把
currentKey和它所属的keyGroup写进共享的 key builder - state handle 再把当前 namespace,必要时再追加
userKey - 最终拼成 composite key,去对应的 Column Family 做
get/put/merge
这里要特别区分两层概念:
key- 是业务 key,比如
userId=42
- 是业务 key,比如
keyGroup- 是 Flink 把所有 key 按哈希切到固定数量桶之后得到的桶编号
- 编号范围是
0 .. maxParallelism-1
一个最小例子:
- 假设
maxParallelism = 128 - 那么 key-group 总共有
128个,编号就是0..127 userId=42可能落到keyGroup=17userId=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
这样做的直接好处有三个:
- 可以把同一个
key-group的数据放到同一段有序 key range 里 - checkpoint / restore 时,逻辑上的
KeyGroupRange和物理 key 前缀能够对齐 - rescale 时,Flink 可以按
key-group这层稳定中间分桶来重分配状态,而不需要按每条 key 单独搬
关键代码都比较直接:
setCurrentKey()时刷新共享 builder: RocksDBKeyedStateBackend.java#L520-L530keyGroup到 operator 的映射规则: KeyGroupRangeAssignment.java#L93-L127- state handle 保存并切换
currentNamespace: AbstractRocksDBState.java#L123-L126 - 构造
(keyGroup, key, namespace): SerializedCompositeKeyBuilder.java#L110-L128 - 构造
(keyGroup, key, namespace, userKey): SerializedCompositeKeyBuilder.java#L130-L154
这条链路说明了一件很重要的事:
- 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 type 到实现类工厂表: RocksDBKeyedStateBackend.java#L135-L171
createOrUpdateInternalState(...)->createState(...): RocksDBKeyedStateBackend.java#L1009-L1068
所以物理布局上,先分的是:
- 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)
读写路径就是标准的点查点写:
value()->db.get(cf, compositeKey): RocksDBValueState.java#L78-L89update()->db.put(cf, compositeKey, valueBytes): RocksDBValueState.java#L94-L110
所以 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
对应过程是:
add(x)用db.merge(...)追加单个元素: RocksDBListState.java#L124-L133update(list)才会整条覆盖写: RocksDBListState.java#L170-L183- window merge 时,会把多个 source namespace 的 bytes 读出、删掉,再 merge 到 target namespace: RocksDBListState.java#L136-L163
这套实现有两个值得记住的点:
- 逻辑上是 list,物理上仍然是一条 KV
- "追加"能力不是 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 格式:
#KeyGroup#Key#Namespace#UserKey: RocksDBMapState.java#L438-L442
这意味着:
- 同一个 Flink
MapState里的每个userKey,在 RocksDB 里都是独立 KV
对应实现也非常一致:
get/put/remove/contains都是构造完整 key 后做点查或点写: RocksDBMapState.java#L123-L179keys()/values()/iterator()则先拿KeyGroup + Key + Namespace做 prefix,再向后扫描连续区间: RocksDBMapState.java#L186-L274clear()也是先 seek 到 prefix,再批量删除整段: RocksDBMapState.java#L290-L316
另外它还有个很实用的小细节:
- 为了支持
userValue = null,序列化 value 时会先写一个 boolean 标志位: AbstractRocksDBState.java#L175-L182
所以 MapState 的本质不是"一个 value 里嵌套一张 Java Map",而是:
- 把一张逻辑 map 拆成同前缀的一串 RocksDB KV
10.5.4 ReducingState / AggregatingState:逻辑复杂,物理上通常仍是一条 KV
这两类 state 容易让人误以为底层也会变得很复杂,但源码说明正相反。
它们物理上大多仍然是:
- 一个
(key, namespace)对应一条 KV
区别主要在 add(...) 的语义:
ReducingState.add(v)会先读旧值,再调reduceFunction.reduce(old, v),最后回写结果: RocksDBReducingState.java#L87-L98AggregatingState.add(v)会先读 accumulator,没有就创建,再调aggFunction.add(...),最后回写 accumulator: RocksDBAggregatingState.java#L90-L105
merge namespace 时,它们也都是:
- 逐个 source namespace 读出旧值
- 在 Java 层做 reduce/merge
- 再写回 target namespace
对应代码:
ReducingState.mergeNamespaces(...): RocksDBReducingState.java#L101-L149AggregatingState.mergeNamespaces(...): RocksDBAggregatingState.java#L107-L159
这件事很值得记住,因为它解释了一个常见误区:
- 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 来构造的。构造参数里直接带着:
operatorIdentifierinstanceBasePathkeyGroupRangestateHandles
见 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 - 这个算子里声明了
ValueState、MapState、ListState
那么更接近真实实现的情况是:
- 这个算子会有
4个并发 subtask - 每个 subtask 各有一份自己的
RocksDBKeyedStateBackend - 每份 backend 各自打开一份本地 RocksDB 实例
- 这份 subtask 里的
ValueState、MapState、ListState共用这个 DB - 但它们分别落到不同 Column Family
所以既不是:
- "整个 TM 上所有 task 共用一个 RocksDB"
也不是:
- "每个 state descriptor 都单独开一个 RocksDB 进程/实例"
10.6.1 为什么不做 TM 级别共享
如果把 RocksDB 做成 TM 级别共享,表面上看似乎能少开几个 DB,但会立刻碰到几个问题:
- 生命周期不好对齐
- Flink 的 checkpoint、restore、dispose、failover、rescale,都是按 operator subtask 的 backend 来管理的
- 如果变成 TM 共享 DB,一个 subtask failover 或 rescale,就会和别的 subtask 共用生命周期,边界很难切干净
- key-group 归属不好隔离
- 每个 keyed subtask 只负责自己的
KeyGroupRange - 共享 DB 会让多个 subtask 的 key-group 混在一个更大的本地实例里,restore 和迁移边界更复杂
- 每个 keyed subtask 只负责自己的
- 资源隔离更差
- compaction、write buffer、iterator、snapshot 都会互相干扰
- 一个热点算子很容易把同机其他算子的 RocksDB 资源也拖慢
- 实现复杂度会明显升高
- 需要额外处理多 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/ListState:KeyGroupPrefix + serialize(42) + serialize([0,10))MapState:KeyGroupPrefix + serialize(42) + serialize([0,10)) + serialize("itemA")
对应的构造入口就是:
(keyGroup, key, namespace): AbstractRocksDBState.java#L166-L169(keyGroup, key, namespace, userKey): AbstractRocksDBState.java#L154-L158
所以把前面的几节串起来,你会发现 window / namespace / RocksDB 其实是一条完整链路:
WindowOperator把 window 放进 namespace- keyed backend 用
(key, namespace)定位逻辑 state - RocksDB backend 再把它编码进 composite key
10.8 为什么 Flink 选这套实现
这套设计不是偶然拼起来的,它同时解决了几个问题:
namespace复用同一份 state handle- window 很多时,不必创建海量 handle
MapState用userKey后缀拆成多条 KV- 可以做 prefix scan、按 entry 增量遍历、局部删除
ListState借 merge operator 做 append- 追加元素时不必每次整条 list 全量改写
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 里把这条演进说得很明确:
- 只把状态搬到远端还不够
- 还需要异步执行模型配套
对应的解耦状态文档也把三件事绑在一起讲:
- ForSt backend
- State V2 async API
- SQL operator 的异步状态访问
见 disaggregated_state.md#L53-L66。
这里还要补一层经常被误解的边界:
disaggregated- 指状态主存放在外部存储,例如
S3、HDFS - 本地磁盘更多承担 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 用成远端状态后端
这里还可以继续追问:那能不能一部分用异步、一部分用同步?
可以,但要分层次理解:
- 同一个 job 里混用
- 可以
- 官方文档明确提到 job 里可能有多个
ForSt instances,并且 API 使用方式可以混合:disaggregated_state.md#L155-L158
- 同一个 user function 里混用
- 不推荐
State V2文档明确写了:强烈不建议在同一个 user function 中混合同步和异步 state access:state_v2.md#L84-L91
也就是说,更稳妥的边界是:
- 不同 operator 可以分别选择 sync / async
- 但同一个 user function 最好只选一种状态访问模型
所以 ForSt 的本质是:
- 把"状态存哪里"从计算节点本地盘进一步解耦出去
10.10 一条主线总结
这条演进可以简化成:
- Heap
- 问题:我想要简单、快、小状态
- RocksDB
- 问题:我需要大状态,内存放不下
- ForSt
- 问题:我不仅要大状态,还要云原生下更轻的本地依赖和更强的解耦能力
所以 backend 演进的本质不是"换一种存储介质",而是:
- 随着状态规模和部署形态变化,重新平衡延迟、容量、恢复、扩缩容成本
11. State V2:官方的 continuation 风格状态访问
如果你想真正理解 continuation 和 state 的关系,State V2 很值得看。
11.1 它解决什么问题
当 state 在远端或访问延迟变高时,原来的同步接口会把 task 线程卡住。
所以 State V2 引入的是:
StateFuture.thenApplythenComposethenAccept
这其实就是:
- 把"读 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 步:
processElement()里发起一次 async state 请求- 当前 record 的"后续逻辑"挂到
StateFuture.thenApply / thenCompose / thenAccept上 - task 线程不阻塞等待,而是让出执行权,继续处理别的 mail / callback
- 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 步看:
AbstractValueState.asyncValue()调handleRequest(StateRequestType.VALUE_GET, null):AbstractValueState.java#L43-L46AbstractKeyedState.handleRequest(...)再把请求交给StateRequestHandler:AbstractKeyedState.java#L55-L65- 默认实现
StateExecutionController.handleRequest(...)会:- 先通过
asyncFutureFactory.create(currentContext)创建一个InternalAsyncFuture - 再创建一个
StateRequest(state, VALUE_GET, payload, future, context) - 然后把 request 交给异步执行控制器调度:StateExecutionController.java#L74-L103
- 先通过
- 这里的
asyncFutureFactory不是普通 future 工厂,它在创建 future 时就把"如何回到当前 record 的上下文"一起绑进去了:AsyncExecutionController构造时先创建CallbackRunnerWrapper- 再用它构造
AsyncFutureFactory:AsyncExecutionController.java#L163-L168 AsyncFutureFactory.create(context)会创建ContextAsyncFutureImpl,并把 callback 包成:- 先
callbackRunner.submit(...) - 再
asyncExecutionController.setCurrentContext(context) - 再真正执行 continuation:AsyncFutureFactory.java#L43-L53
- 先
CallbackRunnerWrapper.submit(...)本身又不会直接执行 callback,而是调用mailboxExecutor.execute(...),把它投成一封 mailbox mail:CallbackRunnerWrapper.java#L51-L63- 所以后续
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恢复CallbackRunnerWrappermailboxExecutor
所以如果只看用户代码里的:
java
cntState.asyncValue().thenAccept(...)
容易误以为:
- future 一完成,
thenAccept(...)就在某个后台线程里直接继续
但把 wrapper 这一层补上后,更准确的真实流程是:
- request 在后台完成
- wrapper 恢复当前 record 的 context
- callback 被投回 mailbox
- task 线程再继续执行
thenAccept(...)
11.5 future 完成后,谁来继续执行
这个问题要分两层。
第一层:
- 异步请求本身会由 async executor 之类的后台执行单元推进
第二层,也是更关键的一层:
- 用户 continuation 不会在某个独立业务线程里直接继续跑
- 它会被投回 mailbox,再由 task 线程继续执行
运行时代码里这件事很直接:
- callback runner 会把回调通过
mailboxExecutor.execute(...)投回 mailbox:CallbackRunnerWrapper.java#L51-L63 AsyncFutureImpl.thenAccept(...)/thenApply(...)/thenCompose(...)在 future 未完成分支里,都会走callbackRunner.submit(...),而不是直接在完成 future 的线程里执行用户 continuation:AsyncFutureImpl.java#L61-L97 AsyncFutureImpl.java#L100-L137 AsyncFutureImpl.java#L140-L177
所以如果你问"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 看出来:
- 当 in-flight 太多或需要等待异步完成时,它会反复
mailboxExecutor.tryYield(),把执行权让回 mailbox,而不是长时间阻塞当前线程:AsyncExecutionController.java#L320-L339 AsyncExecutionController.java#L441-L490 - 异步完成后的 callback 也是通过 mailbox executor 投递回 task 线程执行:CallbackRunnerWrapper.java#L51-L63
所以从执行模型看,它确实是一种:
- 不阻塞线程
- 让出执行权
- 等结果回来后再继续
的 continuation 风格。
11.7 那当前这条 record 做了什么存储
这里最容易误解。
如果把问题说得非常严格,答案是:
- 运行中的"异步调用现场"本身,不会被单独持久化成一份可续跑日志
- 运行时主要维护的是一组内存态控制结构
- checkpoint 前会先把 in-flight 收敛掉,再只对"已经收敛的一致状态"做快照
也就是说,它不是:
- "每发起一次异步 state 访问,就把这条 record 的 continuation 单独存盘"
而更像:
- "运行中先用内存里的 record context / epoch / in-flight 计数管理这条 record"
- "做 checkpoint 前先把这些未完成异步访问 drain 完"
- "只把最终一致的 state 内容纳入 checkpoint"
运行时关键对象有:
RecordContext- 保存当前 record 的 key、命名空间、epoch、引用计数等上下文
- 它更像"这条 record 当前活跃着的运行时上下文"
- 见 RecordContext.java#L46-L90
AsyncExecutionController- 维护 in-flight record 数量、key 占用、请求 drain 等控制逻辑
- 见 AsyncExecutionController.java#L117-L131
EpochManager- 把 record 和非 record 事件(如 watermark)按 epoch 串起来,保证先后关系
- 见 EpochManager.java#L62-L76
所以"当前 record 的进度"主要是放在这些运行时内存结构里,不是放在单独的 durable record log 里。
11.8 恢复时怎么接上
真正关键的是这一点:
- Flink 不会在故障恢复后继续跑原来那个未完成的
StateFuture - 它的做法是:checkpoint 前先把 in-flight 清空,只让一致状态进入快照
- 故障后从 checkpoint 状态恢复,未完成部分通过上游重放重新执行
异步状态算子在 checkpoint 前会显式做 drainInflightRecords(0):
这里的意思不是"把半条 continuation/future 原样存盘",而是:
- 如果当前还有 in-flight 的异步 state 请求
- checkpoint 会在
prepareSnapshotPreBarrier阶段等待它们收敛 AsyncExecutionController在等待过程中会反复tryYield(),让 mailbox 去执行异步回调,把 in-flight 数降下来- 直到
drainInflightRecords(0)返回,才继续真正的 barrier 与 snapshot
见:
所以更准确的说法是:
- checkpoint 会等这些异步 state 访问先"跑到可快照边界"
- 而不是把一个尚未完成的
StateFuture直接塞进 checkpoint
这也带来一个直接后果:
- 如果异步 state 请求迟迟不完成,checkpoint 就会被拖慢
- 严重时同样可能 timeout
这和前面讨论 continuation 时那个问题正好相反:
- 对 State V2 async state,Flink 的选择是:checkpoint 前先等它收敛
- 对用户自己维护的 continuation/外部异步工作流,Flink 不会替你保存半条运行时 future;你必须自己在 state 里把"未完成进度"写清楚
而 task checkpoint 主流程本来就是:
prepareSnapshotPreBarrier- 再发 barrier
- 再 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 正确建模
假设一条输入会经历三步:
- 校验输入
- 调 LLM
- 调工具并提交结果
不要把它想成"一个长函数中间 yield 两次",而要把它改写成:
java
enum Phase {
START,
WAIT_LLM,
WAIT_TOOL,
DONE
}
class WorkflowState {
Phase phase;
String llmResult;
String toolResult;
}
然后每次处理输入时:
- 先读
WorkflowState - 看当前
phase - 执行本阶段该做的事
- 把新
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 模式时必须掌握的五件事
- 身份
- 这份进度是按 key 维度,还是按 operator/subtask 维度?
- 阶段
- 当前处于哪一步?
- 输入边界
- source 已经越过这条输入后,谁来保证它不丢?
- 副作用边界
- 哪一步已经真正发出外部调用,重跑时怎么防重?
- 恢复入口
- 恢复后是谁再次驱动"继续执行"?
如果这五个问题都答清楚了,continuation 的设计通常就稳了。
13. 一句最重要的区分
以后看到一个复杂 state 设计,可以先问:
- 这是在解决 防丢
- 还是在解决 防重
通常:
keyed/operator state- 更偏防丢与恢复继续执行
ActionStateStore/ WAL / external durable store- 更偏防重与副作用去重
这两者都可能"保存一些中间进度",但本质职责不同。
14. 最后给一个心智模型
可以把 Flink state 理解成三层记忆:
- Keyed state
- 记住"某个 key 自己的过去"
- Operator state
- 记住"这个 subtask 手里还拿着哪些工作"
- Broadcast state
- 记住"所有实例都应共享的规则"
而 backend 演进就是在问:
- 这份记忆该放在堆上
- 放在本地磁盘
- 还是放到远端主存储
而 continuation/async state 模式在问的是:
- 怎么把'以后继续做什么'从线程栈里拿出来,显式放进 state
如果你能稳定地用这个心智模型分析问题,后面自己设计 continuation、异步工作流、去重与恢复逻辑时,就不会只停在"API 会不会用",而是真正掌握 state 的本质。
15. State 排障与运行时机制
前面的章节更偏"设计理解",这一章专门补:
- 恢复为什么会失败
- state 为什么会越跑越大
- checkpoint / restore 为什么会越来越慢
- RocksDB 为什么会抖动、刷盘、compaction 堵住
这一章统一按同一个模板写:
- 先讲过程
- 再讲为什么这样设计
- 最后给排障信号和典型现象
15.1 serializer 兼容:为什么代码能启动,恢复却失败
15.1.1 先讲过程
checkpoint / savepoint 里保存的,不只是 state bytes,还保存了 serializer 的快照信息。
更准确地说,恢复时会发生这几步:
- Flink 先从 checkpoint 元信息里读出旧 serializer 的
TypeSerializerSnapshot - 你的新作业注册当前 serializer
- runtime 用新旧 snapshot 做兼容性判断
- 判断结果通常分成四类:
COMPATIBLE_AS_ISCOMPATIBLE_AFTER_MIGRATIONCOMPATIBLE_WITH_RECONFIGURED_SERIALIZERINCOMPATIBLE
- 如果是不可兼容,或者当前 backend 不支持所需迁移,恢复阶段就会失败
关键源码:
TypeSerializerSnapshot的职责: TypeSerializerSnapshot.java#L28-L49- schema compatibility 四种结果: TypeSerializerSchemaCompatibility.java#L27-L69
StateSerializerProvider如何区分旧 schema / 新 schema serializer: StateSerializerProvider.java#L133-L189StateMigrationException定义: StateMigrationException.java#L21-L42
RocksDB restore 里也能直接看到这种检查:
- key serializer 如果需要迁移或不兼容,会直接抛错: RocksDBIncrementalRestoreOperation.java#L916-L945
一个最小例子:
- 上一版把某个 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 类型或编码方式变了
排查时先问这几个问题:
- state name 有没有复用旧名字
- key serializer / namespace serializer / value serializer 有没有变
- 这次改动是
compatible as is,还是其实已经进入 migration / incompatible - TTL 是否一起改了,因为 TTL 也会改变 state 的实际编码形态
15.2 TTL:为什么"设置了过期"不等于"立刻删掉"
15.2.1 先讲过程
TTL 的运行过程比直觉里更保守。
它大致是:
- state value 在 backend 中通常不是"裸值",而是带上时间信息的 TTL 包装值
- 读 state 时,runtime 会检查是否过期
- 清理过期值有多种路径:
- 访问时检查并清理
- heap backend 的增量 cleanup
- RocksDB compaction filter 清理
- 所以"过期"与"物理删除"并不是同一时刻发生
关键源码和文档:
- TTL value 真实长相:
userValue + lastAccessTimestamp,见 TtlValue.java#L27-L52 - heap 增量 cleanup 的迭代式说明: state.md#L430-L472
- RocksDB compaction filter cleanup: state.md#L474-L540
- RocksDB TTL compact filter manager: RocksDbTtlCompactFiltersManager.java#L90-L181
一个最小例子:
- 你把
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 过期很久后还占着磁盘
排查时优先看:
- backend 是 heap 还是 RocksDB
- 配置的是访问时 cleanup、增量 cleanup,还是 compaction filter
- 业务访问模式是不是"写一次后长期不读"
- list / map 这类集合 state 是否导致 TTL 清理成本显著变高
另外有个版本细节值得单独记住:
- Flink 2.2.0 起,TTL / 非 TTL state 间的迁移兼容性显著改善,见 state_migration.md#L28-L41
15.3 timer:state 的"未来唤醒点",不是普通定时器
15.3.1 先讲过程
这一节只保留 state 视角下最需要的内容,详细展开见 timer演进分析.md#L1-L29。
从 state 角度看,timer 的过程是:
- keyed operator 为某个
(key, namespace)注册 timer - timer service 把这个未来唤醒点保存下来
- checkpoint 时,timer 也会被按 key-group 写入快照
- 恢复后再按 key-group 读回
- 触发时,runtime 先切回对应 key / namespace,再执行回调
关键位置:
- timer 服务本身就是按 key 和 namespace 作用域工作的: InternalTimerService.java#L25-L60
- timer snapshot / restore 按 key-group 写入原始 keyed state: InternalTimeServiceManagerImpl.java#L231-L277
15.3.2 为什么这样设计
timer 如果不进入 checkpoint,就只有"未来会叫醒你",却没有"故障后还能接着叫醒你"的能力。
所以 timer 的本质不是普通线程定时器,而是:
- 和 keyed state 一起参与 checkpoint / restore / rescale 的未来触发点
15.3.3 排障信号和典型现象
排查 timer 相关问题时,不要只问"为什么没回调",更要问:
- 对应进度是不是已经写进 state
- timer 是 processing time 还是 event time
- event time 的话,watermark 有没有真正推进
- 恢复后是不是出现了 timer 集中补触发
如果问题已经明显落在 timer 机制本身,直接去看 timer演进分析.md#L1-L29 会更高效。
15.4 restore / local recovery:为什么恢复慢,不一定是状态太大
15.4.1 先讲过程
先分清两种恢复路径:
- 主路径
- JobManager 把 checkpoint handle 发回 task
- task 从分布式存储恢复状态
- 本地恢复路径
- task 同时保留一份本地 secondary copy
- 如果恢复时还能回到原来的位置,优先尝试从本地副本恢复
- 本地失败时,再透明回退到远端 primary copy
官方文档把这层关系说得很清楚:
- primary copy 才是 ground truth,本地恢复只是 secondary copy: large_state_tuning.md#L270-L289
- Flink 会先尝试本地恢复,再回退远端恢复: large_state_tuning.md#L282-L288
- 当前 task-local recovery 主要覆盖 keyed state backend,且 unaligned checkpoint 目前不支持: large_state_tuning.md#L297-L317
运行时结构里也能看到 local recovery 配置挂在 keyed backend 上:
HeapKeyedStateBackend持有LocalRecoveryConfig: HeapKeyedStateBackend.java#L133-L179LocalRecoveryConfig本身就是控制是否启用 task-local state 的配置对象: LocalRecoveryConfig.java#L25-L78
一个最小例子:
- 某个任务只有一个 TM 短暂进程重启
- 大多数 subtask 最终还被调度回原机器
- 这时真正决定恢复时长的,不一定是"总 state 有多大"
- 而更可能是"有多少 subtask 命中了本地恢复,有多少只能走远端拉取"
15.4.2 为什么这样设计
Flink 不能只依赖本地状态,因为:
- 本地磁盘不具备分布式容错能力
- 机器丢了,本地副本也一起丢
- rescale 还需要跨节点重新分配状态
所以它必须保留远端 primary copy 作为真相来源。
但只靠远端恢复,代价又太高,所以才增加 task-local recovery 这条"尽量命中本地、失败再回退"的加速路径。
15.4.3 排障信号和典型现象
遇到下面这些现象,优先怀疑 restore 路径,而不是先怀疑业务逻辑:
- checkpoint 很快,但恢复很慢
- 同一作业不同轮故障恢复耗时差异很大
- 只有一小部分 TM 失败,恢复却还是整片很慢
- 开了 local recovery,但恢复时间看起来没有明显改善
排查时先看:
- 是否真的开启了
state.backend.local-recovery - 当前用的是 full checkpoint 还是 incremental checkpoint
- 失败后 subtask 是否回到了原 slot / 原机器
- 是否用了 unaligned checkpoint,因为它当前不支持 task-local recovery
15.5 RocksDB 内存与 compaction:为什么 state 问题经常表现成性能问题
15.5.1 先讲过程
RocksDB 问题经常不是"读不出来",而是:
- memtable 刷得太勤
- block cache 不够
- compaction 积压
- native memory 顶高
- checkpoint / 写入 / 查询互相争资源
先抓住两个事实:
- RocksDB 实例虽然是 subtask 级 keyed backend,但内存配置不一定只在这一个实例内部生效
- 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 级内存预算"
这一点可以直接从配置对象和资源容器看出来:
fixedMemoryPerSlot是"一个 slot 内所有 RocksDB 实例共享"的固定内存: RocksDBMemoryConfiguration.java#L40-L41- managed memory / fixed-per-slot 都是在做"slot 级总预算"的约束: RocksDBMemoryConfiguration.java#L61-L90
sharedResources会把一个 slot 里的多个 RocksDB 实例挂到同一份 write buffer manager 上: RocksDBResourceContainer.java#L154-L160- 同样也会共享 block cache: RocksDBResourceContainer.java#L211-L237
- 文档里也明确说了:每个 state 对应一个 Column Family,所以 state 越多,write buffer 压力越大: large_state_tuning.md#L149-L156
再往前走一步,compaction 的默认行为也不要简单理解成"就是 RocksDB 原生默认值"。
Flink 在构造 ColumnFamilyOptions 时,会主动设置一批 compaction 相关参数:
compactionStylecompressionPerLevellevelCompactionDynamicLevelBytestargetFileSizeBasemaxBytesForLevelBasewriteBufferSizemaxWriteBufferNumberminWriteBufferNumberToMergeperiodicCompactionSeconds
见 RocksDBResourceContainer.java#L377-L409
这些参数的来源顺序是:
- Flink 配置里显式设置的值
PredefinedOptions里的 profile 值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 相关默认值包括:
compaction.style = LEVEL: RocksDBConfigurableOptions.java#L136-L149use-dynamic-size = false: RocksDBConfigurableOptions.java#L150-L168compression.per.level = SNAPPY: RocksDBConfigurableOptions.java#L170-L206target-file-size-base = 64mb: RocksDBConfigurableOptions.java#L208-L215max-size-level-base = 256mb: RocksDBConfigurableOptions.java#L216-L223writebuffer.size = 64mb: RocksDBConfigurableOptions.java#L224-L230writebuffer.count = 2: RocksDBConfigurableOptions.java#L232-L239writebuffer.number-to-merge = 1: RocksDBConfigurableOptions.java#L240-L246
如果你关心的是 TTL 清理相关的 periodic compaction,它也不是完全交给 RocksDB 自己摸索:
compaction.filter.query-time-after-num-entries默认是1000compaction.filter.periodic-compaction-time默认是30 days
见 RocksDBConfigurableOptions.java#L337-L355
这说明 Flink 对 compaction 的态度不是"全都交给 RocksDB 原生默认",而是:
- 默认提供一套偏保守、通用的基线
- 再允许 profile 和用户 factory 往具体硬件与负载上调
能观测什么,也不是黑盒:
MemTableFlushPendingCompactionPendingCurSizeAllMemTablesEstimateNumKeysBackgroundErrors
这些 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 数量一多,性能突然明显退化
排查时建议按这个顺序问:
- state 数量是不是很多,因为每个 state 都会对应一个 Column Family
- slot 级 RocksDB 预算是不是太小
- write buffer ratio / high priority pool ratio 是否合适
- compaction 是不是被 TTL filter、list/map state 或大批量更新放大了
- native metrics 有没有打开,否则你只能凭感觉猜
15.6 一组排障速记
如果你以后排 state 问题,先别急着看业务代码,可以先用下面这组问题定位层次:
- 恢复失败
- 先怀疑 serializer 兼容、state name 复用、TTL 变更
- state 越跑越大
- 先怀疑 TTL 清理路径不匹配访问模式,或热点 key / 大集合 state
- checkpoint 慢
- 先怀疑 RocksDB flush / compaction / native 内存争用
- 恢复慢
- 先怀疑 restore 路径、本地恢复命中率、远端读取
- "定时触发不对"
- 先怀疑 watermark / timer 类型 / 进度是否真正写入 state
所以从排障视角看,state 不只是:
- "我存了什么"
而更是:
- "这些 bytes 用什么 schema 解释"
- "什么时候算过期"
- "故障后从哪里恢复"
- "底层存储什么时候开始抖"
- "未来触发点靠什么接上"