仓库:https://github.com/apache/flink
官方文档:https://nightlies.apache.org/flink/flink-docs-lts/
技术栈:Java 11 / StateBackend / KeyGroup / Watermark
解读版本:release-1.20.5(commit
0980485)解读视角:总架构师评审(架构 / 源码 / 生产 / 进阶)
第 02 篇:「有状态流处理范式与时间语义」------ KeyedState 的 Key Group 分配与 Watermark 对齐内核
阅读本文你将了解:
- Flink 把状态切成 KeyedState / OperatorState / BroadcastState 三种作用域,其中 KeyedState 是 Exactly-Once 的基石(对应
concepts/stateful-stream-processing.md)。- KeyedState 底层靠 Key Group 做原子重分布单元,
key → keyGroup的数学本质是一行MathUtils.murmurHash(keyHash) % maxParallelism(flink-runtime/src/main/java/org/apache/flink/runtime/state/KeyGroupRangeAssignment.java:75)。- 六种状态 API(Value/List/Map/Reducing/Aggregating/Broadcast)都从
StateDescriptor派生,统一以「名字 + 序列化器 + 默认值」登记(flink-core/src/main/java/org/apache/flink/api/common/state/StateDescriptor.java:60)。setCurrentKey如何在每次处理记录时切换 key 上下文、并顺带算出 keyGroupIndex(flink-runtime/src/main/java/org/apache/flink/runtime/state/AbstractKeyedStateBackend.java:246-251)。- Watermark 对齐不是取全局最小值那么简单,而是通过
StatusWatermarkValve对每个输入子分区单独记账、再求"对齐子分区的最小 watermark"(flink-streaming-java/src/main/java/org/apache/flink/streaming/runtime/watermarkstatus/StatusWatermarkValve.java:153)。
02.0 一句话定性
Flink 的状态机制可以定性为:用"Key Group"这个介于 key 与并行度之间的抽象层,把"key 如何路由到状态"和"状态如何随并行度伸缩"两个问题解耦 。key 永远只通过 murmurHash(key) % maxParallelism 映射到一个 key group,key group 再被连续区间地分配给各并行子任务;无论并行度怎么变,key group 的归属只是"重新划分区间",key 与状态的对应关系不变。而时间语义上,Watermark 是"事件时间进度"的分布式共识机制------每个上游的 watermark 先各自对齐,再取最小值下传。这两条线共同构成了 Flink 状态一致性的地基。
02.1 架构视角(Architect)
02.1.1 三种状态作用域与可插拔状态后端
Flink 把算子状态按"作用域"分成三类,这是理解一切状态 API 的入口:
| 状态类型 | 作用域 | 典型用途 | 分配单元 |
|---|---|---|---|
| KeyedState | 单个 key | 聚合、窗口、去重 | Key Group |
| OperatorState | 单个算子实例(subtask) | Source 的 offset、广播前的配置 | subtask |
| BroadcastState | 广播到所有 subtask | 动态规则广播(join 维度表) | 广播 |
而"状态到底存在哪",由可插拔的 StateBackend 决定。官方文档明确指出:StateBackend 不仅决定存状态的数据结构(内存 HashMap 还是 RocksDB),还实现"对 key/value 状态做 point-in-time 快照"的逻辑(concepts/stateful-stream-processing.md 的 State Backends 小节)。这正是后面第 06/07 篇(Checkpoint 协调、RocksDB 深潜)要打穿的地方。
StateBackend 接口在源码里确实只有两个工厂方法,把"创建哪种 key/value 存储"这个决定下放给实现类(flink-runtime/src/main/java/org/apache/flink/runtime/state/StateBackend.java:104、:151):
java
// flink-runtime/src/main/java/org/apache/flink/runtime/state/StateBackend.java:104
<K> CheckpointableKeyedStateBackend<K> createKeyedStateBackend(
KeyedStateBackendParameters<K> parameters) throws Exception;
// flink-runtime/src/main/java/org/apache/flink/runtime/state/StateBackend.java:151
OperatorStateBackend createOperatorStateBackend(OperatorStateBackendParameters parameters)
throws Exception;
下面的类图展示了状态后端的抽象分层:

图示讲解 :这张类图回答"状态后端如何做到可插拔"。顶层
StateBackend接口只定义两个工厂方法------createKeyedStateBackend和createOperatorStateBackend,把"创建哪种 key/value 存储"这个决定下放给实现类。中间层KeyedStateBackend接口定义状态访问契约(setCurrentKey、getOrCreateKeyedState),由抽象基类AbstractKeyedStateBackend承载公共逻辑(numberOfKeyGroups、keyGroupRange这两个字段就在这个基类,见flink-runtime/src/main/java/org/apache/flink/runtime/state/AbstractKeyedStateBackend.java:74-77)。底层HeapKeyedStateBackend和RocksDBKeyedStateBackend是两种具体实现,二者差异只在"存内存哈希还是 RocksDB",对外契约完全一致------这就是为什么改状态后端不需要改应用逻辑。右侧KeyGroupRangeAssignment是被AbstractKeyedStateBackend.setCurrentKey调用的工具类,负责 key→keyGroup 的数学映射。
02.1.2 Key Group:状态伸缩的原子单元
官方文档强调:KeyedState 进一步被组织成 Key Group ,它是 Flink 重分布 KeyedState 的原子单元,Key Group 的数量恰好等于最大并行度(maxParallelism) (concepts/stateful-stream-processing.md 的 Keyed State 小节)。这一句话是整个状态伸缩机制的核心设计决策------把"并行度"这个运行时可变量,和"状态分区"这个需要稳定的量,用 maxParallelism 隔离开。并发度只能在 [1, maxParallelism] 内变化,key group 的归属随并行度重新划分区间,但 key→keyGroup 的映射永远不变。
02.1.3 三种时间语义:Event / Processing / Ingestion Time
时间语义是 Watermark 机制的前提,官方 concepts/time.md 给出三种时钟,缺一不可:
| 时间语义 | 定义 | 是否可重放 | 典型用途 |
|---|---|---|---|
| Event Time | 事件真实发生时间(数据自带字段) | 是(确定性) | 乱序窗口、正确性关键作业 |
| Processing Time | 算子处理该记录时的机器时钟 | 否 | 低延迟、对正确性不敏感 |
| Ingestion Time | 数据进入 Flink 的时间 | 否 | 折中方案(1.20 已基本弃用) |
Event Time 能"重放出一致结果"的关键在于它不依赖处理时刻 ------同样的数据在任何机器、任何时刻重跑,窗口归属一致。但代价是"事件时间进度"无法从机器时钟推知,必须靠 Watermark 显式携带,这就是下一节要打穿的 StatusWatermarkValve 对齐内核存在的根本原因。
02.2 源码侦探(Source Sleuth)
02.2.1 key → keyGroup 的数学本质:一行 murmurHash 取模
Key Group 分配的全部秘密,收敛在 KeyGroupRangeAssignment 的一行代码里:
java
// flink-runtime/src/main/java/org/apache/flink/runtime/state/KeyGroupRangeAssignment.java:75
public static int computeKeyGroupForKeyHash(int keyHash, int maxParallelism) {
return MathUtils.murmurHash(keyHash) % maxParallelism;
}
而 assignToKeyGroup 只是先取 key.hashCode() 再调它:
java
// flink-runtime/src/main/java/org/apache/flink/runtime/state/KeyGroupRangeAssignment.java:63
public static int assignToKeyGroup(Object key, int maxParallelism) {
Preconditions.checkNotNull(key, "Assigned key must not be null!");
return computeKeyGroupForKeyHash(key.hashCode(), maxParallelism);
}
关键源码事实 :key 到 keyGroup 的映射是
murmurHash(key.hashCode()) % maxParallelism(flink-runtime/src/main/java/org/apache/flink/runtime/state/KeyGroupRangeAssignment.java:76),注意这里做了两次哈希 ------先key.hashCode(),再murmurHash。第二次 murmurHash 的目的是把 Java 自带hashCode(很多实现只对低位敏感、分布不均)打散成均匀分布,避免某些自定义 key 的hashCode碰撞导致数据倾斜。这是"数据倾斜防御"在源码层的第一个落点:倾斜不是靠运气,而是靠这个 double-hash 保证的。
02.2.2 setCurrentKey:处理每条记录前的 key 上下文切换
理解了 key→keyGroup 的映射后,AbstractKeyedStateBackend.setCurrentKey 就一目了然------它在每次处理一条 keyed 记录前,切换"当前 key"并顺带算出 keyGroup:
java
// flink-runtime/src/main/java/org/apache/flink/runtime/state/AbstractKeyedStateBackend.java:246
public void setCurrentKey(K newKey) {
notifyKeySelected(newKey);
this.keyContext.setCurrentKey(newKey);
this.keyContext.setCurrentKeyGroupIndex(
KeyGroupRangeAssignment.assignToKeyGroup(newKey, numberOfKeyGroups));
}
关键源码事实 :
setCurrentKey做了三件事(flink-runtime/src/main/java/org/apache/flink/runtime/state/AbstractKeyedStateBackend.java:246-251):①notifyKeySelected通知所有 key 选择监听器(queryable state 与 latency tracking 依赖它);② 把 key 塞进keyContext(一个线程级上下文,getCurrentKey就是从这里读的,:278);③ 调assignToKeyGroup算出keyGroupIndex一起塞进上下文。注意它并没有"切换状态存储" ------真正的状态定位是惰性的,等到getOrCreateKeyedState(:345)才用keyGroupIndex去后端里找对应的状态实例。这种"先记 key、惰性取状态"的设计,让连续处理同一 key 的记录时不必反复做状态查找。
02.2.3 StateDescriptor 与六种状态 API 的访问契约
状态 API 看似有六种,其实都从同一个抽象基类 StateDescriptor 派生,统一以"名字 + 序列化器 + 默认值"登记。StateDescriptor 是 abstract class(flink-core/src/main/java/org/apache/flink/api/common/state/StateDescriptor.java:60),三个构造函数都把 name 交给 checkNotNull 强制非空(:126、:140、:157)------名字是状态的唯一标识,改名等价于"换了一块新状态",这正是 savepoint 恢复时按名字匹配状态的依据。
六种状态接口的读/写契约各不相同,但都遵循"先 getDescriptor().getName() 登记、再按 key 惰性取值"的同一套链路:
| 状态接口 | 读方法 | 写方法 | 源码位置 |
|---|---|---|---|
ValueState<T> |
value() |
update(T) |
flink-core-api/src/main/java/org/apache/flink/api/common/state/ValueState.java:54/:65 |
ListState<T> |
get() |
add(T) / update(List) |
flink-core-api/src/main/java/org/apache/flink/api/common/state/ListState.java:59/:74 |
MapState<UK,UV> |
get(UK) |
put(UK,UV) |
flink-core-api/src/main/java/org/apache/flink/api/common/state/MapState.java:54/:63 |
ReducingState<T> |
get() |
add(T)(自动归约) |
flink-core-api/src/main/java/org/apache/flink/api/common/state/ReducingState.java |
AggregatingState<IN,OUT> |
get() |
add(IN)(聚合 + 换型) |
flink-core-api/src/main/java/org/apache/flink/api/common/state/AggregatingState.java |
ValueState 的接口定义只有两行核心方法(flink-core-api/src/main/java/org/apache/flink/api/common/state/ValueState.java:54 的 value() 与 :65 的 update()),它之所以能扛住高吞吐,是因为实现类(如 HeapValueState)内部只维护"当前 key 对应的一小块状态",读写在 setCurrentKey 切好的上下文里就地完成。
02.2.4 OperatorState 的三种分布方式:List / UnionList / Broadcast
OperatorState 是"挂在算子实例上、而非某个 key 上"的状态,OperatorStateStore 接口暴露三种分布方式(flink-core/src/main/java/org/apache/flink/api/common/state/OperatorStateStore.java:27):
| 方式 | 方法 | 重分布行为 | 典型用途 |
|---|---|---|---|
| ListState | getListState(:77) |
恢复时按并行度均匀切分 | Kafka Source 的 offset |
| UnionListState | getUnionListState(:100) |
恢复时广播全量给每个 subtask | Source 的分区列表 |
| BroadcastState | getBroadcastState(:53) |
状态广播到所有 subtask | 动态规则、维度表 join |
三者差异只在并行度变化时状态如何重分布 :ListState 是"均分",适合"每个 subtask 各管一段 offset";UnionListState 是"全量广播",适合"恢复时需要看到全局";BroadcastState 则是在运行期就持续广播。这个差异决定了 Source 算子(如 FlinkKafkaConsumer)几乎无一例外选择 ListState 存 offset------因为 Kafka 分区数不变时,每个 subtask 只认领自己那一段。
02.2.5 Watermark 对齐内核:StatusWatermarkValve 的逐子分区记账
Watermark 在多输入算子(join、union 后)处需要对齐------只有所有输入流的 watermark 都推进了,算子才能输出新的全局 watermark。这个逻辑收敛在 StatusWatermarkValve:
java
// flink-streaming-java/src/main/java/org/apache/flink/streaming/runtime/watermarkstatus/StatusWatermarkValve.java:153
public void inputWatermark(Watermark watermark, int channelIndex, DataOutput<?> output) {
final SubpartitionStatus subpartitionStatus;
if (watermark instanceof InternalWatermark) {
int subpartitionStatusIndex = ((InternalWatermark) watermark).getSubpartitionIndex();
subpartitionStatus = subpartitionStatuses.get(channelIndex).get(subpartitionStatusIndex);
} else {
subpartitionStatus = subpartitionStatuses.get(channelIndex).get(subpartitionIndexes[channelIndex]);
}
if (lastOutputWatermarkStatus.isActive() && subpartitionStatus.watermarkStatus.isActive()) {
long watermarkMillis = watermark.getTimestamp();
if (watermarkMillis > subpartitionStatus.watermark) { // 只推进、不回退
subpartitionStatus.watermark = watermarkMillis;
if (subpartitionStatus.isWatermarkAligned) {
adjustAlignedSubpartitionStatuses(subpartitionStatus);
} else if (watermarkMillis >= lastOutputWatermark) {
markWatermarkAligned(subpartitionStatus); // 追平后重新对齐
}
findAndOutputNewMinWatermarkAcrossAlignedSubpartitions(output); // 求最小并下传
}
}
}
关键源码事实 :
inputWatermark的核心是逐子分区记账 + 只推进不回退 + 求对齐子分区的最小值 (flink-streaming-java/src/main/java/org/apache/flink/streaming/runtime/watermarkstatus/StatusWatermarkValve.java:167-185)。三个关键细节:①if (watermarkMillis > subpartitionStatus.watermark)保证 watermark 单调递增,乱序到达的旧 watermark 被直接忽略;② 每个输入子分区(甚至一个 channel 内的多个 subpartition,见InternalWatermark分支)都有独立的 watermark 账本,这是Watermark类只需一个timestamp字段(flink-streaming-java/src/main/java/org/apache/flink/streaming/api/watermark/Watermark.java:41)就能工作的原因------记账结构在 valve 里,不在 watermark 本身;③ 真正输出的是"所有已对齐 子分区的 watermark 最小值"(findAndOutputNewMinWatermarkAcrossAlignedSubpartitions),对齐/未对齐(idle)状态由inputWatermarkStatus(:199)单独维护。
下面的时序图把"数据流 → watermark 生成 → 对齐 → 窗口触发"的完整链路串起来:

图示讲解 :这张时序图回答"一个事件时间戳如何变成驱动窗口计算的 watermark"。第一步,
SourceFunction读到的每条记录先经TimestampAssigner.extractTimestamp(flink-core/src/main/java/org/apache/flink/api/common/eventtime/TimestampAssigner.java:57)抽出事件时间;第二步,WatermarkGenerator.onEvent(flink-core/src/main/java/org/apache/flink/api/common/eventtime/WatermarkGenerator.java:38)拿到事件时间并内部累积"当前最大事件时间";第三步,onPeriodicEmit(:46)按watermarkInterval周期性地把"最大事件时间 - 允许延迟"作为 watermark 发射出去;第四步,watermark 在多输入算子处进入StatusWatermarkValve做逐子分区对齐、求最小后下传;第五步,WindowOperator拿到全局 watermark,凡watermark >= windowEnd的窗口被触发计算。注意图中onEvent与onPeriodicEmit的分工------前者负责"事件驱动"(punctuated),后者负责"周期驱动"(periodic),这正是WatermarkGenerator两个方法的语义边界。
02.3 生产实践(Production)
02.3.1 状态大小的估算与 Key Group 数的选择
| 配置项 | 作用 | 生产建议 |
|---|---|---|
state.backend |
选择状态后端(hashmap / rocksdb) | 大状态必选 rocksdb |
state.checkpoints.dir |
快照落盘位置 | 分布式可靠存储(HDFS/S3) |
pipeline.max-parallelism |
maxParallelism(= key group 数) | 默认 128,需预先规划;一旦写入 savepoint 不可更改 |
execution.checkpointing.interval |
检查点间隔 | 权衡容错开销与恢复时间 |
Key Group 数(maxParallelism)是唯一"不可事后改"的参数 ------因为 key→keyGroup 的映射依赖它,改了就破坏已保存状态的分区一致性。这直接解释了为什么官方文档强调"Key Group 数量 = 最大并行度",且生产上要在作业上线前就规划好未来的并行度上限。这是很多团队踩坑的点:默认 128 的 maxParallelism 看似够用,一旦未来想把并行度扩到 256,就必须放弃旧 savepoint 重新灌数。源码里 AbstractKeyedStateBackend 构造时就做了 numberOfKeyGroups >= keyGroupRange.getNumberOfKeyGroups() 的防御性断言(flink-runtime/src/main/java/org/apache/flink/runtime/state/AbstractKeyedStateBackend.java:194),杜绝了"keyGroup 数小于本 subtask 分配的区间"这类自相矛盾。
02.3.2 Watermark 的两个高频故障
- watermark 停滞导致窗口永不触发 :如果某个输入源长时间不发 watermark(例如数据断流),对齐机制会让全局 watermark 停住。Flink 用 idleness 机制兜底------
inputWatermarkStatus收到IDLE状态后,会把该子分区标记为未对齐,从最小 watermark 计算中剔除(flink-streaming-java/src/main/java/org/apache/flink/streaming/runtime/watermarkstatus/StatusWatermarkValve.java:214-219),从而不让一个静默的分区卡死整个 job。 - 乱序数据 + 窗口过早关闭 :这是
Watermark的timestamp(flink-streaming-java/src/main/java/org/apache/flink/streaming/api/watermark/Watermark.java:58getTimestamp)与数据真实事件时间之间的 gap 问题,靠allowedLateness和 side output 补救,本质是"watermark 是事件时间的保守估计"这一语义的代价。
02.3.3 allowedLateness 与 side output:乱序数据的兜底
乱序数据到达时窗口可能已经触发并关闭,Flink 提供了三层兜底:
allowedLateness:窗口触发后不立即清除,继续接受迟到数据并重算窗口结果(代价是窗口状态驻留更久、内存更高)。- side output :超过
allowedLateness的数据不再进入窗口,而是被侧输出流接住,做单独的补偿逻辑(如补写下游)。 - Watermark 的"保守估计"语义 :
WatermarkGenerator的 watermark =当前最大事件时间 - 允许延迟,这个"延迟余量"本身就是为乱序预留的缓冲(flink-core/src/main/java/org/apache/flink/api/common/eventtime/WatermarkGenerator.java:38的onEvent累积最大事件时间的语义)。
02.4 进阶向导(Advanced)
02.4.1 双哈希为何能抗倾斜
computeKeyGroupForKeyHash 的 MathUtils.murmurHash(keyHash) % maxParallelism(flink-runtime/src/main/java/org/apache/flink/runtime/state/KeyGroupRangeAssignment.java:76)值得深挖:Java 的 String.hashCode 已知对某些前缀相同的字符串分布很差,直接 hashCode % n 会造成 key group 间严重倾斜。Flink 用 murmurHash 二次打散,把任意 hashCode 映射到近似均匀分布,再取模。这是"数据倾斜"问题在分区函数层面的系统性防御------前提是 maxParallelism 足够大(key group 数够多),模运算才能体现均匀性。
02.4.2 与 Spark Structured Streaming 的状态模型对比
| 维度 | Flink | Spark Structured Streaming |
|---|---|---|
| 状态抽象 | KeyedState/OperatorState/BroadcastState | StateStore(HDFSBackedStateStore)+ 聚合算子 |
| 伸缩单元 | Key Group(maxParallelism 固定) | 按 operator + 分区重分布 |
| 时间语义 | EventTime + Watermark(对齐/非对齐) | EventTime + Watermark(基于状态的行水印) |
| 状态后端 | 可插拔(HashMap/RocksDB) | 默认内存 + HDFS checkpoint |
| 重分布保证 | key→keyGroup 映射永不变 | 依赖 state store 的版本化 |
02.4.3 一个容易被误读的点
很多人以为 setCurrentKey 会直接"写入状态"。实际上它只是切换线程级上下文 (flink-runtime/src/main/java/org/apache/flink/runtime/state/AbstractKeyedStateBackend.java:248 keyContext.setCurrentKey),真正的状态读写在 getOrCreateKeyedState(:345)里才发生。这个区分在排查"状态读不到"问题时至关重要:如果某条记录的 key 上下文没被正确设置(例如在非 keyed 流上误用了 keyed 状态),getOrCreateKeyedState 会拿到错误的 keyGroupIndex,表现为"读到了别的 key 的状态"或 NPE,而非"状态丢失"。
02.5 小结与下一篇
本篇打穿了 Flink 状态机制的两条地基线:Key Group 的数学本质 (flink-runtime/src/main/java/org/apache/flink/runtime/state/KeyGroupRangeAssignment.java:75 的 murmurHash 取模)与 Watermark 对齐内核 (flink-streaming-java/src/main/java/org/apache/flink/streaming/runtime/watermarkstatus/StatusWatermarkValve.java:153 的逐子分区求最小)。理解了 key→keyGroup 的映射永不因并行度变化而变,就理解了为什么 Flink 能在运行时安全地 rescale 状态。
下一篇进入《作业图与调度器》,切入点从"状态怎么分"转到"作业怎么跑":我们将追踪 StreamGraph → JobGraph → ExecutionGraph 三级图转换,看一条程序如何被翻译成可调度的执行图,以及 DefaultScheduler 如何决定"哪个 Task 去哪个 Slot 跑"(对应本系列 03 篇)。