Join / CEP 算子化设计
状态:设计定稿(Phase 1 随附实现)。来源:架构评审遗留待办 C——「
join/cep:包装为 DataStream 算子(现为纯内存工具类,无法参与 pipeline);join 与 table 的 join 语义二选一」。 本文先行落稿,Phase 1 实现以其为准;Phase 2/3 条目仅设计不实现。
1. 背景与问题
join 与 cep 两个模块在 Phase 1 之前是调用方驱动的纯内存工具库(Phase 1 已包装为 DataStream 算子,用法见 Join.md / CEP.md,本节保留当时的问题陈述):
join.StreamJoiner:processLeft/processRight逐条喂入、同步返回配对结果,缓冲在实例内ConcurrentHashMap;cep.PatternSequenceMatcher/PatternMatcher:调用方在自己的消息循环里调process(event, timestamp)。
Phase 1 之前两者都没有接入 DataStream 算子链:用户没法写出 env.fromMqTopic(...).keyBy(...).<join|match> 这样的 pipeline,得自己搭消费循环、自己处理分区与生命周期。而两套执行引擎(runtime 的 in-memory 引擎与 runtime 的 Redis 引擎)都已提供统一的有状态算子底座:
KeyedStream.process(KeyedProcessFunction<K, I, O>) // 双引擎均已实现其中 Redis 引擎的 process 完整支持 processing-time / event-time 定时器(RedisStreamBuilder 接线 ctx.registerProcessingTimeTimer/registerEventTimeTimer → 算子 onProcessingTime/onEventTime)。算子化因此不需要动任何引擎代码——把工具逻辑装进 KeyedProcessFunction 即可在两引擎跑。
2. 语义二选一:DataStream 侧 join 的定位
仓库里有两套「join」:
join 模块 | table 模块(KTable join) | |
|---|---|---|
| 输入模型 | 两条事件流 + 时间窗口 | changelog 表(有界、可回放) |
| 输出 | 每次配对即发射(append) | 表视图 / changelog |
| 状态 | 窗口期双缓冲 | 全量键值 |
决策:DataStream 算子层的 join = stream-stream windowed join(join 模块语义)。 KTable join 保持 table 模块内的表语义,不重复提供流算子形态。理由:
- 输入模型不同——双流无界事件 vs 有界 changelog,强行统一会迫使一方削足适履;
- KTable join 已有独立文档与测试背书(
docs/Table.md),语义成立; - 流处理运行时的空白面恰是 stream-stream join 算子(Flink 同样两者并存且语义分立)。
边界写进两份文档(docs/Join.md、docs/Table.md):涉及「流与流按时间窗口配对」用 join 算子;涉及「表与表按键对齐」用 KTable。
3. Join 算子化
3.1 输入模型:三方案
stream-stream join 是双输入算子,而当前两引擎的 pipeline 都是单源(RedisPipelineDefinition 一 topic 一 group)。三个方案:
| 方案 | 形态 | 引擎改动 | 评估 |
|---|---|---|---|
| A. 引擎级双源 | fromMqTopics(A, B),runner 单循环消费两 topic、两 group、双 offset checkpoint | 大(runner/checkpoint/恢复全要双源化) | 正确姿势但工程量大,与动态伸缩(P4 待办)耦合,远期单列 |
| B. 信封多路复用 | 左右流各自 map 成 Envelope(tag=left/right、joinKey、时间戳、payload)写入同一 join 输入 topic;keyBy(joinKey).process(JoinOperator) | 零(纯算子 + 用户级 map) | 立即可用、双引擎通吃;同 key 经 MQ 分区哈希天然路由到同一 runner;checkpoint 复用单 topic offset 语义 |
C. DataStream.join(other) 流式糖 | a.join(b).where(...).equalTo(...).window(...).apply(...) | 编译期展开为 B(信封 + 桥接 map) | 语法糖,Phase 2 |
决策:Phase 1 落方案 B;Phase 2 落 C 作为 B 的语法糖;A 归入 P4 动态伸缩一并设计。
方案 B 的代价与说明:
- 多一跳 MQ(左右流先写入 join 输入 topic)。对已基于 Redis Streams MQ 的作业是同构操作;吞吐敏感场景可在 Phase 2 后评估 A;
- 信封是公开 API(
join模块新增类型),tag 左右、带原始事件时间戳——时间戳由信封构造端从JoinConfig的 extractor 提取后随信封携带,JoinOperator 不再对 payload 做反射式取时。
3.2 StreamJoinOperator<K, L, R, O>(Phase 1 核心类,落 join 模块)
// io.github.cuihairu.redis.streaming.join.operator
Envelope<K, L, R> // 单流信封:tag(LEFT/RIGHT)、joinKey、timestamp、payload(L|R 二选一)
StreamJoinOperator.asKeyedProcessFunction(JoinConfig<L,R,K> cfg, JoinFunction<L,R,O> fn)
// 产 KeyedProcessFunction<K, Envelope<K,L,R>, O>语义(尽量逐点对齐既有 StreamJoiner,可复用其窗口谓词逻辑):
- 实例内按 key 持
leftBuffer / rightBuffer(与StreamJoiner同型的Map<K, List<TimestampedElement>>); - 配对谓词锚定左元素时间戳(沿用
StreamJoiner修正后的语义:无论哪侧先到,asymmetric 窗口afterOnly/beforeOnly判定一致); - 外连接(LEFT/RIGHT/FULL_OUTER)沿用 B-36 语义:无匹配立即发射
join(elem, null);后到的对侧在窗口内命中时再发射一次join(L, R)——下游须容忍同 key 双记录(文档明示,非 waiting/retract 实现); - 状态治理沿用
JoinConfig.maxStateSize(超限按最旧时间戳逐条淘汰)与stateRetentionTime(清理为事件驱动:每次配对入口按两侧缓冲的最大时间戳统一清过期,与StreamJoiner.cleanup逐点一致,算子不另注册引擎定时器;窗口跨度 > 保留时长在JoinConfig.validate()已拦); - 与
StreamJoiner的关系:StreamJoiner保留原样(独立场景与既有测试),StreamJoinOperator在窗口谓词/淘汰策略上委托同一份逻辑(抽公共 helper 或直接内聚一份实现 + 对照测试钉住行为一致),避免第三套窗口判定。
3.3 一致性边界(Phase 2 起升级为 at-least-once 重算)
- Phase 1(历史):算子缓冲在算子实例内存中,不参与 checkpoint——failover 后未配对的半边状态丢失,join 输出对 failover 是 at-most-once(半边丢失)+ envelope 层 at-least-once 的组合。
- Phase 2(2026-10-12 落地):两侧缓冲迁入 keyed
ValueState(每 key 一份 JSON 文档,经ctx.getState读写,见 §3.4),随既有 state/checkpoint 机制快照与恢复。failover 语义升级为 at-least-once 重算:MQ 重放 + 状态恢复后配对继续,重放的 envelope 可能再次配对——去重交给下游幂等或 sink 去重。 - 两处相对 raw joiner 实例级记账的范围收窄(均为卫生性调整,窗口谓词仍守护正确性,retention/eviction 只是内存管理):
maxStateSize按 per-key 计(两侧合计),不再是实例内跨 key 总量;- retention 清理的参考时间是该 key 自己的最新时间戳,空闲 key 的过期元素活到该 key 下一条事件(或被 cap 逐出),不再被其他 key 的活动清扫。
3.4 状态布局(join / cep 算子,Phase 2)
- 通道:
ctx.getState(new StateDescriptor<>(stateName, String.class))——Context.getState为 Phase 2 新增的默认方法(默认抛UnsupportedOperationException,两引擎各自覆写接线:内存引擎接InMemoryKeyedStateStore、Redis 引擎接 keyed store)。泛型擦除下 Redis JSON 反序列化会退化为LinkedHashMap,故算子自己持ObjectMapper、把缓冲编码为 JSON 字符串存ValueState<String>。 - 确定性命名:Redis 引擎的 state 键形如
prefix:jobName:cg:group:topic:p:pid:state:operatorId:stateName,operatorId 确定性生成(streamId-keyBy-N),因此 stateName 必须固定——__operator:join:buffers/__operator:cep:partials——重启的 job 才能找回上一实例的文档(随机名等于自废持久化)。同一 keyed stream 上不可能挂两个同类算子(process()返回DataStream非KeyedStream),单一固定名无冲突。 - join 文档:
JoinKeyBuffers{left[], right[]},元素为自描述 JSON(BufferedElement{type, json, timestamp},type 记运行时类名)——两侧各自独立可解码,不依赖先到的是哪一侧的信封。 - cep 文档:
PersistedMatcherState<T>{partials[]}(PartialMatchState{startTimestamp, currentStep, eventsByStep})。 - mapper 统一
findAndRegisterModules()+ 关WRITE_DATES_AS_TIMESTAMPS(事件可能带Instant等 java.time 字段)+ 关FAIL_ON_UNKNOWN_PROPERTIES;jackson-datatype-jsr310以implementation依赖加入 join/cep 两模块。
4. CEP 算子化
4.1 PatternSequenceProcessFunction<K, T>(落 cep 模块)
// io.github.cuihairu.redis.streaming.cep.operator
new PatternSequenceProcessFunction<>(pattern, timestampExtractor)
// 产 KeyedProcessFunction<K, T, EventSequence<T>>
// 另有 3 参重载:追加 maxTrackedKeys(0 = 不设上限)语义:
- 实例内按 key 持
PatternSequenceMatcher实例(每 key 独立匹配状态,互不串扰); within(duration)截止由 matcher 自身的事件驱动清理承担——process(event, timestamp)入口先按事件时间戳丢弃过期 partial(超时未完成 = 不匹配),算子不另注册引擎定时器;闲置 key 的残留 partial 在该 key 下一条事件到达时清理,或被 key 数上限逐出;- key 数上限(
maxTrackedKeys,默认 10000,0 = 不限):按最近活跃时间逐出最久未触达的 key 的 matcher,防止高基数 key 场景下 matcher 映射无界增长; - 输出复用
cep.EventSequence<T>; - 单条件匹配(
Pattern/PatternMatcher)不做算子:无序列状态的需求filter即可,有界扩展序列的场景归入后续需求,不预造 API。
4.2 状态与恢复(Phase 2 起进 keyed state)
Phase 1(历史):partial match 状态在算子实例内存、不进 checkpoint,failover 后未完成序列丢失(后果比 join 轻——丢的是未完成序列,不产生错误输出,只是漏报)。
Phase 2(2026-10-12 落地):采用直写缓存结构——
- 实例内的 matcher 映射仍是热缓存(每 key 一个
PatternSequenceMatcher),每条事件处理后直写该 key 的 keyedValueState<String>(无 partial 则clear()); - 缓存 miss 时先水合:从 keyed state 反序列化该 key 的 partial 列表回 matcher;
- 失效墓碑(
evictedKeys):被maxTrackedKeys逐出的 key 在本实例生命周期内不得从 state 复活陈旧 partial(防止逐出后的旧状态与新事件错配)。
快照可精确恢复的证明:matcher 的 partial 的 step 序号只增不减,因此 flat 事件序 == 按 step 升序逐级拼接的序——PatternSequenceMatcher.exportPartialMatches() 导出(step 升序深拷贝)、restorePartialMatches() 按此序重放 addEvent,事件顺序与时间戳逐一还原(钉在 PatternSequenceMatcherStateExportTest)。
5. 分阶段落地
| 阶段 | 内容 | 状态 |
|---|---|---|
| Phase 1 | Envelope + StreamJoinOperator + PatternSequenceProcessFunction;InMemory / Redis 双引擎集成测试;docs/Join.md、docs/CEP.md 增「算子化」章节;本文档落稿 | 本批实现 |
| Phase 2 | DataStream.join(other) 语法糖(编译期展开为信封 + 桥接);两算子缓冲迁 keyed ValueState 进 checkpoint;TimestampAssigner 重载接入后评估 event-time join 窗口 | 缓冲迁移本批落地(2026-10-12,卡点 A 解除,见 §5.1);语法糖仍卡汇合原语设计,见 §5.2 |
| Phase 3 | 引擎级双源 pipeline(方案 A)、event-time 水位对齐窗口、retract/waiting 语义(外连接二次发射消除) | 远期,与 P4 动态伸缩合并设计 |
5.1 卡点 A:Context 状态访问面——已解除(2026-10-12)
原卡点:KeyedProcessFunction.Context 只有时钟+定时器方法,没有 getState(...) 通道,两引擎都无法把 keyed state 句柄交给算子。
解除方式(本批):
core:Context.getState(StateDescriptor)新增默认方法,默认抛UnsupportedOperationException(不破坏既有自实现 Context 的二进制兼容),并钉契约测试;runtime:内存引擎ContextImpl接InMemoryKeyedStateStore,Redis 引擎匿名 Context 接 keyed store 的getValueState;join/cep:两算子缓冲迁 keyedValueState<String>(自管 JSON 编解码,见 §3.4),failover 语义升级(§3.3/§4.2);- 证明:模块级测试以「同一 store + 全新函数实例」模拟重启——join 配对与 cep partial 均从上一实例状态续跑。
5.2 卡点 B:DataStream.join(other) 语法糖——仍开,待设计(2026-10-12 复核)
设计走「编译期展开为信封 + 桥接」,需要把左右流汇入同一 join 输入(MQ topic 或引擎内 union)——DataStream API 现在没有 union/双源汇合原语,内存引擎无 topic 基底;设计本身把「引擎级双源 pipeline(方案 A)」放在 Phase 3。
本机不可解的原因:这不是补代码,而是先要定 API——汇合原语长什么样(DataStream.union(other)?自动编排两条 pipeline 写同一 topic?)、内存引擎的汇合基底用什么、桥接算子落在哪一侧。这些选择影响 runtime 公开面,须先出设计稿评审(详见 docs/Union-DualSource-Design.md),本批已把设计稿落档,实施仍待评审通过。
未实施清单(留档):
DataStream.union(...)(或等价汇合原语)双引擎实现;DataStream.join(other)语法糖展开为 envelope map + 汇合 + keyBy +StreamJoinOperator;- event-time join 窗口评估(依赖
TimestampAssigner重载接入)。
6. 影响面与兼容性
join/cep模块既有公开 API 零改动(StreamJoiner/PatternSequenceMatcher等原样保留;PatternSequenceMatcher新增 export/restore 一对方法,纯增量);新增类型均在各自模块新operator子包;- Phase 1 算子实现无新增模块依赖;Phase 2 两模块新增
implementation libs.jackson.jsr310(状态 JSON 需序列化 java.time 字段),core增Context.getState默认方法,runtime两处接线; - 既有自实现
KeyedProcessFunction.Context不受影响(默认方法抛不支持,契约不变); - 窗口谓词对照
StreamJoiner的差分测试保持钉住一致;新增状态面单测(缓冲/partial 跨实例续跑、retention/cap 行为、JSON round-trip)+ 双引擎集成测试(Redis 腿 reachability 门控)。