状态后端统一设计(state 模块 ↔ runtime keyed store)与水位线逻辑收敛
本文供评审,不含代码改动。目标:仓库有两套互不兼容的 Redis 状态实现,水位线计算逻辑存在三处分叉;收敛为一套标准。
现状(事实盘点)
状态:两套实现
| 维度 | runtime RedisKeyedStateStore | state 模块 RedisStateBackend |
|---|---|---|
| key 布局 | {stateKeyPrefix}:{job}:cg:{group}:topic:{topic}:p:{pid}:state:{operatorId}:{stateName}[:shard:N](按 job/group/topic/partition/operator 全维度键控) | {keyPrefix}{descriptor.getName()} 扁平 key(默认前缀 state:,无 job/分区维度) |
| codec | StringCodec + JSON | 按 Class 类型的 codec(二进制) |
| 状态类型 | 仅 ValueState | Value/Map/List/Set 四种 |
| 附加能力 | TTL、schema 登记(:{job}:stateSchema)、stateKeys 索引(:{job}:stateKeys,供 checkpoint 枚举)、热 key 观测 | 无 |
| 依赖关系 | checkpoint 模块依赖其 stateKeys 索引做恢复枚举——它是有下游契约的 | 无生产侧依赖(除 examples/文档) |
水位线:三处逻辑
runtimeinternal/WatermarkState:volatile long + idle标志的单调水位线状态容器(只管"只增不减")。runtimeRedisPipelineRunner.candidateFor:maxEventTimeMs - outOfOrdernessMs(守卫!= Long.MIN_VALUE),逐条更新。watermark模块BoundedOutOfOrdernessWatermarkGenerator:maxTimestamp - maxOutOfOrderness - 1(Flink 语义:watermarkw表示"不会再有 ≤ w 的事件"),初始maxTimestamp = MIN_VALUE + ooo + 1。
2 与 3 语义不同:runtime 少减 1(watermark 恰等于 max - ooo,边界事件会被误判为"未迟到"与否取决于下游比较符),且初始值/守卫各自处理。同一配置在两处产出不同的边界行为。
问题
- 用户面 API(
state模块StateDescriptor)产出的状态与 runtime 引擎实际使用的 keyed 状态不能互访:key 布局与 codec 都不同,写进去的数据另一套读不到。 - 扁平 key 无 job 维度:同进程跑两个作业用同名 state 即串数据(与
StreamKeys静态全局是同类问题,见多租户设计)。 - 无 TTL:
state模块状态永不过期;runtime 侧有 TTL 但只支持 ValueState。 - 水位线
-1分叉是隐性语义 bug 源:改一处不改另一处,下游窗口触发的边界行为就漂移。
可选方案
方案 A:以 runtime keyed store 为标准实现,state 模块退为 API 面 + 适配层(推荐)
理由:runtime store 被 checkpoint 恢复契约依赖(stateKeys 索引),有 TTL/schema/热键治理,key 布局带全维度——它才是"活的"标准;state 模块的 StateBackend/StateDescriptor API 形状好(四种状态类型、类型描述符),适合保留为用户面。
- 保留:
state模块的 API(StateBackend接口、StateDescriptor、四种状态接口)不动。 - 替换实现:
RedisStateBackend改为薄适配层——key 生成委托 keyed store 的布局函数(job 维度必填),codec 统一 StringCodec+JSON;Map/List/Set 在 keyed store 原语上补齐(Map→Hash per key、List→List per key、Set→Set per key,键控进{...}:state:{operatorId}:{name}布局,stateKeys 索引同步登记)。 - TTL/schema 对齐:四种状态都走 TTL 与 schema 登记(schema 只对 Value/复杂类型有意义,Map/List/Set 登记元素类型)。
- 兼容性:key 布局与 codec 变更 = 不兼容变更,走 major + 迁移说明(老数据无生产消费者,风险低)。
方案 B:双轨保留,只写文档划清边界("state 模块=独立用例,runtime store=引擎内部")
零改动,但两套 codec/key 长期并存,checkpoint 与用户状态的互操作永远是坑。不推荐为终态;可作为 v0 的临时文档口径。
方案 C:反向统一(state 模块实现为标准,runtime 迁过去)
不可行:checkpoint 的 stateKeys 契约、按分区键控、TTL/热键都要重做,破坏面远大于方案 A。
水位线收敛(独立小项,先做;已落地 2026-10-11)
把"bounded-out-of-orderness 计算"收敛到 watermark 模块一处:
BoundedOutOfOrdernessWatermarkGenerator的max - ooo - 1是 Flink 标准语义,以它为准。RedisPipelineRunner.candidateFor改为复用同一计算(提取公共静态函数或直接内嵌 generator 实例),消除-1分叉与初始值差异。WatermarkState保留(它是"只增不减 + idle"的容器,与计算逻辑正交)。- 边界语义变更(
-1)需要在文档标注:watermarkw从此表示"≤ w 的事件不再来"。
落地记录:公式收敛为 BoundedOutOfOrdernessWatermarkGenerator.watermarkFor(maxEventTimeMs, outOfOrdernessMillis) 静态方法(下溢饱和到 MIN_VALUE),onPeriodicEmit 与 RedisPipelineRunner.candidateFor 都经它;runtime 主源集新增 implementation project(':watermark')。边界语义前移 1ms 已在 docs/watermark.md 标注,钉住新边界的测试:RedisPipelineRunnerWatermarkTest(994/1194)、RedisPipelineRunnerGapClosureTest(94_999/99_999 + MIN_VALUE 守卫)、RedisPipelineRunnerIdleFlushTest(t-1)、BoundedOutOfOrdernessWatermarkGeneratorTest(公式与饱和直测)。
迁移路径(方案 A 分期)
第 1 步(最小,已落地 2026-10-11):水位线收敛(上面的独立小项),纯 runtime 内部 + watermark 模块,无外部兼容性影响。
第 2 步(已落地 2026-10-11):keyed store 补 Map/List/Set 状态类型 + schema 登记;补单测(含 checkpoint 枚举新状态类型)。
落地记录:
RedisKeyedStateStore新增getMapState(name, keyType, valueType)、getListState(StateDescriptor)、getSetState(StateDescriptor)——RedisKeyedMapState/RedisKeyedListState/RedisKeyedSetState(核心MapState/ListState/SetState接口),布局为每当前 key 一个 Redis Hash/List/Set,键 = keyed 状态布局 +:{序列化当前key}(不分片——每 key 结构本身就是扇出单元),字段/元素 JSON 序列化;stateKeys索引登记 + TTL 继承(Map 走touch得到 TTL+热键+容量采样,List/Set 访问时滑动续期 EXPIRE,热键按 hash 字段数检测故不适用);Map/List/Set schema 按 value/元素类型登记(ensureSchemaForKey)。checkpoint 侧RedisStateType增LIST/SET:snapshotState枚举RType.LIST/SET、restore 走同一 stage-then-rename 流程并应用 TTL。测试:三个 GapClosure 单测(30 用例)+RedisKeyedCollectionStatesIntegrationTest(布局/索引/TTL/clear + 检查点 LIST/SET 快照恢复与漂移清除)。第 3 步(major,已落地 2026-10-12):
RedisStateBackend改为适配层委托 keyed store;删除RedisValueState/RedisMapState/RedisListState/RedisSetState旧实现;迁移文档给 key/codec 对照表。落地记录:
RedisStateBackend重写为 keyed store 薄适配层——构造参数从(redisson, keyPrefix)变为(redisson, jobName[, stateKeyPrefix])(默认前缀streaming:runtime),内部持有一个 solo 身份(topic/consumerGroup/operatorId = standalone、partition 0、无 TTL、无分片、无热键治理)的 store,每次操作前后把线程局部 current key 钉为solo,四类状态全部落在标准 keyed 布局({prefix}:{job}:cg:standalone:topic:standalone:p:0:state:standalone:{name}[:s:solo],StringCodec + 纯 JSON,无 Redisson 默认 codec 的@class类型信息),并照常登记stateKeys索引与stateSchema(FAIL 策略固定)——checkpoint 快照机制对适配层状态直接可用。key/codec 与老实现不兼容(设计预期,老数据无生产消费者),对照表与"读旧写新"搬运示例见docs/state.md「迁移对照表」。配套:state模块新增implementation project(':runtime')(无环,runtime 主源不依赖 state);同批补上 keyed list 的 B-38 原子性对齐(update/addAll改单次REDIS_WRITE_ATOMIC批,见 POTENTIAL_BUGS B-38 追记)。测试:RedisStateBackendTest重写为 mock 版(钉 solo 布局/codec/索引登记/delegation,9 用例)、StateIntegrationTest重写(真实 Redis,7 用例含 stateKeys/schema 登记)、StateGrandStormTest换锚RedisStateBackend、examplesStateExample(+Test)迁移;删除 4 个旧实现与 5 个旧测试。
非目标
- 不做 RocksDB/堆内存等新后端抽象(
StateBackend接口已足够承载)。 - 不改 checkpoint 协议与 stateKeys 索引格式。
- 不在本设计内做状态迁移工具(老 key 数据量小,文档给重建指引即可)。