
做实时计算最绕不开的两个词一个是时间另一个就是状态。最近连续处理了几个实时指标需求每次都是在键控流KeyedDataStream上挂状态来完成的搞得不少刚上手 Flink 的同事跑来问我为什么非得先 keyBy 才能用状态ValueState、MapState 到底什么时候用同一个 key 的数据真的会出现在同一个并行子任务里吗这些问题我当年也纠结过很久特别是第一次写出 “Cannot use keyed state on a non-keyed stream” 这个异常时整个人都是懵的。这篇文章就把我踩过的坑、验证过的结论和代码里沉淀下来的经验一次性梳理清楚。不管你是刚接触状态编程还是已经用了几个月想补一补底层逻辑这篇都适合你慢慢看。1. 为什么状态总能跟键控流绑定在一起1.1 状态不是玄学它只是按 key 隔离的“托管局部变量”Flink 里的状态本质上就是算子内部保存下来的一段数据它可以跨事件、跨窗口、跨 checkpoint 存活。你说它像 Java 的成员变量吧确实像但区别在于 Flink 帮我们做了三件脏活累活自动备份、自动恢复、自动扩容迁移。正因为有这层托管我们才不用自己引入 Redis 或者外部数据库去维护中间结果否则每天光想着“挂了怎么恢复”“扩容怎么迁移”就够折腾了。但这里有一个关键限制只有作用在 KeyedDataStream 上的算子才真正支持按键隔离的 Keyed State。换句话说我们平时用的 ValueState、ListState、MapState全部都要先拿到一个 KeyedDataStream。为什么因为状态后端在保存这些状态时需要根据当前正在处理的 key 来决定把数据放进哪个状态分区。如果没有 key 维度的上下文状态就变成了一个“无主孤魂”既没法保证同一个业务实体的数据访问到同一份状态也没法在并行度改变时做精准迁移。我自己的理解是状态要挂在 key 上本质是给“海量并行数据处理”装上了一套按业务主键定位的寻址系统。比如交易流水来了我要统计“每个用户”的累计金额那用户 ID 就是 key。同一个用户的所有交易不管数据量多大、跑在多少个并行任务上都必须落到同一个子任务里并且共享同一份状态。这个在单机程序里非常简单但在分布式流计算里是 Flink 作为状态流处理器最核心的设计。1.2 keyBy 到底对数据做了什么很多人误以为 keyBy 是一个物理操作会改变数据本身。实际上它不修改数据内容只是按 key 的哈希值对数据流做逻辑分组并且约定相同 key 的数据会严格进入同一个并行子任务。调用 keyBy 之后原本普通的 DataStream 会变成 KeyedDataStreamIN, KEY这个对象在概念上更像一个“带 key 背景信息的数据流视图”而不是一个额外的执行阶段。KeyedDataStream 并不是一个能被单独执行的算子它只是把后续算子需要的 key 上下文传递了过去。你接着调用 process、flatMap、window 等操作时Flink 内部就会在心里记一句这个算子要按 key 组织状态和计算。真正发生数据重排的动作是在 keyBy 之后的第一个算子启动时完成的这个过程中 key 会被序列化并参与 hash 分配。这里有个经常被忽视的点keyBy 是根据 key 的哈希值把数据负载均衡到不同并行实例上但这种“均衡”是针对 key 空间而言的。如果一个热点 key 数据量巨大即使其他 key 都空闲这个热点 key 所在的子任务也会被压满。所以后来大家在处理倾斜场景时会对 key 加盐、拆分本质上是绕开 keyBy 的天然分配规则这个我们后面会细说。1.3 按 key 隔离状态换来了什么按 key 隔离状态最直接的好处是并发安全。同一个 key 的数据被路由到同一个任务实例在大多数情况下会被串行处理因此对同一个 key 的 ValueState 做更新不需要像普通多线程编程那样加锁。你不需要担心线程 A 正在改 user_001 的金额时线程 B 也把 user_001 的金额读出来导致脏数据。这比在外部存储里做“读改写”要轻松太多。再一个是状态范围可控。每一个 key 独立拥有自己的状态空间用户 A 的状态变化不会影响用户 B。状态后端在存储上也能以 key 为粒度做局部管理比如某个 key 不活跃了可以按 TTL 过期而不会误伤其他 key。还把“同一个业务实体”的多次事件统一在一起这意味着窗口计算、累计统计、去重、会话识别这些高频率需求都可以在一个带状态的算子内完成不需要反复和外部系统交互。我见过不少人在前期把状态都塞到外部存储里理由是“Flink 状态不好排查”。但实际做下来只要 key 设计合理KeyedDataStream 上的状态会比外部存储更稳因为它参与 checkpoint恢复时间几乎为零也不会因为网络抖动引入额外延迟。那些真正重要的“为什么”会在你写出一个带状态的算子并观察一段时间后自然就形成了肌肉记忆。2. 键控流上能用的状态类型与 API 选型2.1 五种状态类型分别对应什么场景第一次接触状态 API 的人经常被 ValueState、ListState、MapState、ReducingState、AggregatingState 搞得眼花。其实它们就是数据结构不同使用逻辑跟我们写普通 Java 集合几乎一样。我建议你先把下面这张表记在心里比一个个啃文档效率高很多。状态类型底层结构适合场景注意事项ValueState单值保存最新值、最后时间、累计量每次只能看到一个值需要自己保证更新时机ListState有序列表攒批、存最近 N 条记录追加写入遍历需要Iterable不能按索引随机访问MapStateK, V字典去重、动态维度表、key 到 value 的映射天然适合大量小字段的更新和查询ReducingState单值可累加归约实时求和、最大最小类型必须一致数据进来就 reduce不能保留中间列表AggregatingStateIN, OUT单值自定义聚合复杂的累加和转换比 ReducingState 灵活可以改变输出类型这里分享一个我个人的选型体会如果只是“更新某个值”无脑用 ValueState如果“一个 key 下面还要挂很多子维度”优先 MapState如果要做“最近 N 条”或“攒批发送”ListState 最合适。ReducingState 和 AggregatingState 更偏计算但它们本质上也是单值形态适合聚合结果比较小、不需要看中间明细的场景。把数据结构和存储统计口径匹配上代码自然简洁。2.2 状态描述符是创建状态的唯一入口在 Flink 里创建状态不能直接new ValueState()必须通过 StateDescriptor 来申请。这个设计不是故意折腾人而是因为状态后端的序列化、快照、恢复都需要知道状态的类型和 schema。看看最基本的 ValueState 声明ValueStateDescriptorLong totalAmountDesc new ValueStateDescriptor( total-amount, TypeInformation.of(new TypeHintLong() {}) ); ValueStateLong totalAmountState getRuntimeContext() .getState(totalAmountDesc);total-amount是状态名在同一个算子中是唯一的标识。后续做 checkpoint 恢复时Flink 会通过“算子 ID 状态名”找到对应的历史状态。如果你改了这个名字等于让 Flink 去找一份不存在的状态旧数据就全丢了。我在生产环境里见过不少因为改了状态名导致累计数据归零的事故所以要记住一旦状态上线名字尽量不要改哪怕只是大小写变化都要慎重。第二个要注意的是 TypeInformation。在 Java API 里泛型擦除会让我们在运行时拿不到ValueStateLong里的 Long 类型所以必须用 TypeHint 把类型信息显式传给 Flink。很多人曾踩过“状态能创建但恢复时序列化异常”的坑多半是用了Types.POJO这种宽泛类型或者干脆用 Java 原生序列化。我的建议是所有状态字段都给出明确的 TypeHint 或直接使用 Flink 提供的Types.LONG、Types.STRING等类型写起来多几行但换来稳定。2.3 状态的 TTL 与清理是必须考虑的事流计算任务通常常驻运行如果没有过期机制状态会随着时间推移无限膨胀。Flink 提供的 TTLTime To Live就是用来给状态设定“存活时间”的。最常见的配置是StateTtlConfig ttlConfig StateTtlConfig .newBuilder(org.apache.flink.api.common.time.Time.days(7)) .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite) .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired) .build();这个配置挂在状态描述符上desc.enableTimeLine(ttlConfig);实际 API 是enableTimeToLive。有两个参数特别值得注意。一个是更新策略OnCreateAndWrite表示状态被创建或更新时刷新过期时间适合“只要用户有动作就延长有效期”的场景比如在线用户会话另一个是过期状态的可见性NeverReturnExpired会让读操作直接不看过期数据而ReturnExpiredIfNotCleanedUp则可能让你读到“已经过期但还没被后台清理”的脏数据。不过 TTL 不是银弹。过期状态的清理是惰性的也就是说只有这个 key 再次被访问或者后台的清理线程碰到它才会真正删除。如果一个 key 再也不会出现它的状态会一直停在状态后端里占内存。真要控制状态规模更稳的办法是在业务逻辑上主动判断“这个 key 已经没必要保留了”然后调用state.clear()。数据量大之后再把 TTL 清理和业务主动清理结合起来用。3. 手把手实现一个带状态的键控流应用3.1 一个真实的业务需求为了把前面这些概念落到代码里我拿一个常见场景来演示用户交易事件流需要实时计算每个用户的历史累计交易金额同时记录并输出该用户最近一次交易的时间。这个需求在电商、支付、游戏充值的实时看板里非常典型。输入是一个交易事件至少包含三个字段用户 ID、交易金额、交易时间。要求输出里同时有累计金额和最近交易时间。这里最直观的解法就是keyBy userId然后在状态里保存累计金额和最近时间。每来一条事件就更新状态并把最新结果往下游发送。3.2 核心代码与运行逻辑下面这段是我在生产里用过简化后的完整结构。先定义输入事件public class TransactionEvent { public String userId; public double amount; public long timestamp; public TransactionEvent() {} public TransactionEvent(String userId, double amount, long timestamp) { this.userId userId; this.amount amount; this.timestamp timestamp; } }然后是主流程StreamExecutionEnvironment env StreamExecutionEnvironment.getExecutionEnvironment(); DataStreamTransactionEvent input env .addSource(new TransactionSource()) // 这里用你自己的数据源 .returns(TypeInformation.of(new TypeHintTransactionEvent() {})); KeyedDataStreamTransactionEvent, String keyedStream input .keyBy(event - event.userId); DataStreamString resultStream keyedStream .process(new UserAmountAccumulator()); resultStream.print(); env.execute(user-amount-accumulator);真正的计算逻辑在UserAmountAccumulator里。继承KeyedProcessFunction这是键控流上最常用的处理函数类型它既可以访问 Keyed State又可以注册定时器public class UserAmountAccumulator extends KeyedProcessFunctionString, TransactionEvent, String { private ValueStateDouble totalAmountState; private ValueStateLong lastTxTimeState; Override public void open(org.apache.flink.configuration.Configuration parameters) { ValueStateDescriptorDouble totalDesc new ValueStateDescriptor( total-amount, TypeInformation.of(new TypeHintDouble() {}) ); totalAmountState getRuntimeContext().getState(totalDesc); ValueStateDescriptorLong lastTimeDesc new ValueStateDescriptor( last-tx-time, TypeInformation.of(new TypeHintLong() {}) ); lastTxTimeState getRuntimeContext().getState(lastTimeDesc); } Override public void processElement( TransactionEvent event, Context ctx, CollectorString out) throws Exception { Double currentTotal totalAmountState.value(); if (currentTotal null) { currentTotal 0.0; } currentTotal event.amount; totalAmountState.update(currentTotal); Long lastTime lastTxTimeState.value(); if (lastTime null || event.timestamp lastTime) { lastTxTimeState.update(event.timestamp); } out.collect(userId ctx.getCurrentKey() , total currentTotal , lastTime lastTxTimeState.value()); } }这段代码逻辑不复杂但有三个细节值得品味。第一ctx.getCurrentKey()能直接拿到当前处理的 key不用自己再把 userId 塞进结果对象里。第二状态更新是“先读后写”这是状态编程里最常见的模式但要注意读出来为 null 的情况因为 FLink 状态首次访问时默认值就是 null而不像 Java 基础类型有默认 0。第三这里的每一条新事件都会触发输出如果下游是实时大屏频率可能很高如果只是定时报表更适合注册一个定时器每小时或每天输出一次累计值。3.3 为什么用 KeyedProcessFunction而不是普通算子有人会问用RichMapFunction加一个 ValueState 不也能实现吗确实能但有个重要差异ProcessFunction 额外提供了时间服务和更灵活的控制能力。比如想在“连续一段时间没有交易”时自动输出离线用户或者在每天零点输出一次累计快照你需要在 Context 里注册定时器这是普通 transform 很难做到的。KeyedProcessFunction 的另一层优势是它天然理解“key”这个上下文。你可以根据 key 的不同给不同业务实体设置不同的定时器定时器也会跟着 key 走。这个能力在实现超时未支付关单、订单超时提醒这类场景时简直像为它量身定做的。在 KeyedDataStream 上做状态编程我自己的第一选择基本都是它如果只是纯粹做转换且不用状态那再考虑普通 map 或 flatMap。4. 键控流状态实践里的坑我替你们踩过了4.1 key 为 null直接报错别心存侥幸Flink 对 key 有一个硬性要求不能为 null。如果你从上游数据里解析出的 userId 是 nullkeyBy 在计算 hash 时就会直接抛异常任务开始失败或者反压异常。很多同学第一次遇到这个错误时都会觉得是环境问题结果排查半天发现是因为脏数据。解决方法有两个一个是在 keyBy 之前先做过滤丢弃没有业务主键的数据另一个是给所有数据一个默认 key比如 “default_unknown”把脏数据集中到一个特殊分组里方便后续追踪。我比较推荐后者因为直接在过滤里把数据丢了有时候会掩盖数据质量问题后续很难定位。4.2 修改 key 的选择逻辑等于给状态判了死刑状态恢复时是怎么找到旧状态的靠的是“算子 ID key 状态名”的组合。正常情况同一个 key 在同一个算子的状态是可以无缝恢复的。但如果你某天改了 keyBy 里的选取逻辑比如从event.userId改成event.orderId那历史数据的状态全部停留在旧 key 下新 key 读到的状态全是 null。这种改动通常发生在代码重构时看起来很不起眼效果却非常隐蔽任务能正常启动数据也一直有新输出只是累计值从零重新开始了。所以我的经验是key 的选择一旦定了就是数据模型的一部分不要轻易改。哪怕只是从event.userId改成event.userId.trim()都比你想的危险因为空格前后是不同的字符串hash 不同分配到的子任务也不同。4.3 状态一致性局部更新容易多状态协同难很多业务同时用到多个状态比如同时维护累计金额、最近时间、历史订单列表。单个状态各自的 update 都是原子操作但多个状态的更新做不到真正的事务。处理过程中如果先更新了金额再更新时间时抛异常那就会造成两个状态不一致。Flink 的 checkpoint 机制能保证最终恢复时回到最近一次快照但快照之间这段时间里下游可能已经看到中间状态。应对办法是把“必须一起变化”的字段合并进同一个 ValueState让 Java 对象充当载体或者至少在一次 processElement 调用里集中完成所有状态更新减少中间异常的窗口。我个人的偏好是把这类强关联字段打成一个小对象存一个状态而不是拆成三四个状态描述符这样既降低了描述符数量也减少了状态管理的心智负担。4.4 状态后端、并行度和状态恢复的关系状态恢复这个话题有一个新手必踩的坑并行度变了状态怎么恢复Flink 的 Keyed State 是按 key 分组的状态本质上属于 key而不是某个并行实例。所以当并行度从 4 调到 8 时Flink 会把每个 key 的状态重新分配到新的实例上用户无感知。前提是你启动作业时配置了正确的 checkpoint 路径并且没有改算子 uid。我给每个带状态的算子手动设置 uid 已经成了肌肉记忆keyedStream.process(new UserAmountAccumulator()).uid(user-amount-acc);有了 uidFlink 才能准确地把 checkpoint 里的状态对到当前算子。没有 uid 时就只能靠算子名自动推导一旦算子顺序或名字变了恢复就可能失败。4.5 常见问题速查表现象可能原因优先排查项状态累计值突然归零改过状态名或 key 选择逻辑检查状态名、uid、key 表达式是否变更作业启动报 “Keyed state can only be used on a ‘keyed stream’”在 DataStream 上直接用了 Keyed State确认是否先调用了 keyBy状态越来越大内存溢出没有 TTL或清理策略不生效检查 TTL 配置必要时主动 clear一个用户的所有事件没聚合到同一个任务key 里掺了 nonce/时间戳等随机字段检查 keyBy 表达式是否稳定唯一恢复后数据重复或少了并行度变化 状态未正确补发配置 rescale确认 uid 一致性反压发生在带状态算子上单 key 扇入过多单个 key 状态读写慢考虑 key 加盐热点拆分再合并5. 状态规模变大之后你必须关心的性能和运维5.1 状态后端内存还是 RocksDBFlink 状态后端主要有两种选择堆内存based on heap和 RocksDB。第一次配置时很多人下意识觉得“放内存肯定快”于是在状态量只有几百兆时用堆内存完全没有问题。但状态一旦涨到几十个 GJVM 直接成为瓶颈GC 频繁作业频繁 Full GC 导致延迟飙高那一刻你会无比后悔当初没直接用 RocksDB。RocksDB 是把状态放到本地磁盘通过内存映射和块缓存能撑的状态量级大得多而且还支持增量 checkpoint对大状态作业的恢复速度更友好。代价是访问速度比内存慢单次状态读写有一些序列化开销。我的习惯是状态规模预计超过几个 G或者状态逻辑复杂就优先 RocksDB如果只是几百万 key 的小状态、对延迟极度敏感可以先考虑堆内存但一定要加监控。5.2 TTL 别只靠配置业务主动清理更可靠前面说过 TTL 是惰性清理。以我遇到的一个案例为例有一个记录“用户最近浏览商品”的 MapStateTTL 设了 30 天但活跃用户数巨大而且大量用户只出现过一次就不再来了。这些“一次性用户”的 key 永远等不到下一次访问TTL 后台清理也很难及时扫到它们最终状态量一直居高不下。后来我们改成了双保险在主流程里判断如果用户超过一段时间没有新事件就用定时器清理该用户的全部状态同时 TTL 作为兜底保证意外没删掉的也能过期。可别因为配置了 TTL 就觉得万事大吉业务不活跃 key 的清理必须自己上心。5.3 状态监控怎么配才能提前发现问题状态编程的作业最怕的不是逻辑错误而是状态悄悄膨胀等发现时已经晚了。我个人至少会盯三个指标state.backend.rocksdb.bytes.written写入字节量、state.backend.rocksdb.bytes.compacted压缩后字节量、以及当前状态中的 key 数量或记录数。如果这些指标出现持续上扬但业务大盘没有同步增长基本可以断定有状态异常。另外给状态算子配上“处理速率”和“current key 数量”的维度也很有用。读写状态变慢时算子会表现出背压数据处理延迟上涨这时如果能看到最近状态大小的增长曲线排查方向会清晰很多。其实这跟我前面说的经验一样越是系统帮你托管的东西越要自己盯指标。5.4 最后再分享一个我自己常用的调试技巧在实际开发中特别是状态逻辑稍微复杂时很推荐先开一个本地 MiniCluster用一段小数据流把事情跑通再上集群。本地启动时把 checkpoint 间隔调短一些模拟任务重启和恢复验证状态能否正确还原。尤其是你想验证“改并行度之后状态能不能恢复”时本地开两个并行度做一个带状态的计算再停掉作业把并行度改大重新启动看看累积值是不是连续完好的。这个验证十分钟就能做完却能避免上线后才发现恢复逻辑有问题。我个人的体会是KeyedDataStream 上的状态编程写起来并不难难的是理解它背后“按 key 隔离、按 key 恢复、按 key 更新”这套分布式模型。只要你把 key 当成状态的第一公民想清楚每个状态到底归属哪个业务主键绝大多数问题都能提前避开。这套东西我用到现在每次看到新手从“状态怎么老丢”变成“状态用得稳稳当当”都会觉得这些坑踩得值得。