第 02 篇:「有状态流处理范式与时间语义」—— KeyedState 的 Key Group 分配与 Watermark 对齐内核

仓库: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 对齐内核

阅读本文你将了解:

  1. Flink 把状态切成 KeyedState / OperatorState / BroadcastState 三种作用域,其中 KeyedState 是 Exactly-Once 的基石(对应 concepts/stateful-stream-processing.md)。
  2. KeyedState 底层靠 Key Group 做原子重分布单元,key → keyGroup 的数学本质是一行 MathUtils.murmurHash(keyHash) % maxParallelismflink-runtime/src/main/java/org/apache/flink/runtime/state/KeyGroupRangeAssignment.java:75)。
  3. 六种状态 API(Value/List/Map/Reducing/Aggregating/Broadcast)都从 StateDescriptor 派生,统一以「名字 + 序列化器 + 默认值」登记(flink-core/src/main/java/org/apache/flink/api/common/state/StateDescriptor.java:60)。
  4. setCurrentKey 如何在每次处理记录时切换 key 上下文、并顺带算出 keyGroupIndex(flink-runtime/src/main/java/org/apache/flink/runtime/state/AbstractKeyedStateBackend.java:246-251)。
  5. 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 接口只定义两个工厂方法------createKeyedStateBackendcreateOperatorStateBackend,把"创建哪种 key/value 存储"这个决定下放给实现类。中间层 KeyedStateBackend 接口定义状态访问契约(setCurrentKeygetOrCreateKeyedState),由抽象基类 AbstractKeyedStateBackend 承载公共逻辑(numberOfKeyGroupskeyGroupRange 这两个字段就在这个基类,见 flink-runtime/src/main/java/org/apache/flink/runtime/state/AbstractKeyedStateBackend.java:74-77)。底层 HeapKeyedStateBackendRocksDBKeyedStateBackend 是两种具体实现,二者差异只在"存内存哈希还是 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()) % maxParallelismflink-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 派生,统一以"名字 + 序列化器 + 默认值"登记。StateDescriptorabstract classflink-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:54value():65update()),它之所以能扛住高吞吐,是因为实现类(如 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.extractTimestampflink-core/src/main/java/org/apache/flink/api/common/eventtime/TimestampAssigner.java:57)抽出事件时间;第二步,WatermarkGenerator.onEventflink-core/src/main/java/org/apache/flink/api/common/eventtime/WatermarkGenerator.java:38)拿到事件时间并内部累积"当前最大事件时间";第三步,onPeriodicEmit:46)按 watermarkInterval 周期性地把"最大事件时间 - 允许延迟"作为 watermark 发射出去;第四步,watermark 在多输入算子处进入 StatusWatermarkValve 做逐子分区对齐、求最小后下传;第五步,WindowOperator 拿到全局 watermark,凡 watermark >= windowEnd 的窗口被触发计算。注意图中 onEventonPeriodicEmit 的分工------前者负责"事件驱动"(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 的两个高频故障

  1. 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。
  2. 乱序数据 + 窗口过早关闭 :这是 Watermarktimestampflink-streaming-java/src/main/java/org/apache/flink/streaming/api/watermark/Watermark.java:58 getTimestamp)与数据真实事件时间之间的 gap 问题,靠 allowedLateness 和 side output 补救,本质是"watermark 是事件时间的保守估计"这一语义的代价。

02.3.3 allowedLateness 与 side output:乱序数据的兜底

乱序数据到达时窗口可能已经触发并关闭,Flink 提供了三层兜底:

  1. allowedLateness:窗口触发后不立即清除,继续接受迟到数据并重算窗口结果(代价是窗口状态驻留更久、内存更高)。
  2. side output :超过 allowedLateness 的数据不再进入窗口,而是被侧输出流接住,做单独的补偿逻辑(如补写下游)。
  3. Watermark 的"保守估计"语义WatermarkGenerator 的 watermark = 当前最大事件时间 - 允许延迟,这个"延迟余量"本身就是为乱序预留的缓冲(flink-core/src/main/java/org/apache/flink/api/common/eventtime/WatermarkGenerator.java:38onEvent 累积最大事件时间的语义)。

02.4 进阶向导(Advanced)

02.4.1 双哈希为何能抗倾斜

computeKeyGroupForKeyHashMathUtils.murmurHash(keyHash) % maxParallelismflink-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 篇)。

相关推荐
数智启示录2 天前
Apache Doris 4.0.8 一致性实战(第 3 篇):Checkpoint 一直成功,Exactly-Once 为什么仍可能是假的
大数据·经验分享·面试·flink
数智启示录2 天前
Apache Doris 4.0.8 CDC 正确性(第 9 篇):Flink Checkpoint 一直成功,表里为什么仍是旧数据
大数据·数据库·经验分享·面试·flink
Thomas21432 天前
flink处理迟到数据
大数据·flink
头茬韭菜4 天前
图解 Fluss(五):Flink 接入与数据生命周期 —— Connector、表生命周期与冷热分层
大数据·flink·fluss
StarRocks_labs4 天前
StarRocks × Fluss × Paimon:流湖仓分析与 ETL 闭环
starrocks·flink·jni·native·paimon·湖仓·fluss
数智启示录4 天前
Doris精讲篇(六) 从第一批小文件到 -235:Doris 是怎样被正常写入拖死的
大数据·数据库·经验分享·面试·flink
StarRocks_labs4 天前
StarRocks 存算分离架构下的大规模实时导入优化实践
starrocks·flink·sstable·compaction·tablet·主键索引·存算分离架构
ZCBUS实时计算6 天前
信创混合存储架构落地实战|基于 ZCBUS 实时计算构建证券高可用实时风控数仓
大数据·架构·flink·kafka·dba
效率工作实验室6 天前
实时数据分析平台有哪些?流式分析引擎对比
flink·实时数据分析平台·流式分析引擎·流处理引擎对比·实时olap数据库