拿到一个Flink双流作业,我最先警惕的不是业务逻辑本身,而是 join 背后的状态模型。单流处理是一条河,数据按顺序流过去就行;双流操作等于两条河在某处合流,合流那一刻怎么等、怎么配、怎么清,全由源码里的数据结构、状态存储和定时器决定。
很多人在 API 层面觉得双流 join 很简单:调用 connect、coGroup、join 接口,再写一个函数就完事。但真到线上就会发现,同一条业务逻辑,用 connect 和用 interval join 写出来的行为可能差一截;窗口 join 迟到数据到底怎么处理,非 Event Time 下为什么行为怪异,这些问题不读源码根本说不清。这篇文章围绕几个核心算子类,把 Flink 双流操作的执行机理拆开讲清楚,看完你不仅能理解三种 join 的语义差异,也拿得出一套在真实项目里排查双流问题的思路。
1. 双流操作全家桶:先分清四类执行模式
双流操作听起来是一个概念,Flink 里实际上藏着几套完全不同的执行路径。它们的共同点是都涉及两个输入流,但数据缓冲方式、触发时机、状态结构完全不同。我把常见 API 归成四类,先建立整体框架。
1.1 connect 与 CoProcessFunction:最接近底层的编程入口
connect 是双流操作的基础原语。两个 DataStream 调用 connect 之后得到一个 ConnectedStreams,再调用 process 方法,传入 CoProcessFunction 或 KeyedCoProcessFunction。这个方案最大的特点是灵活:两个流的数据各自独立触发回调,逻辑完全由开发者自己控制。你想在收到左流数据时查一下右流状态,或者反过来,都行;想开定时器延迟处理,也行。
从执行模型看,connect 最终生成的是 TwoInputStreamOperator,也就是双输入算子。每个并行 subtask 会同时接收左右两个流的数据,底层通过 InputSelection 机制控制读取顺序,默认是轮流消费两条流。你要知道,这种实现给了最大的自由,同时也意味着所有状态管理、过期清理、乱序处理都需要自己写。
1.2 window join:先装进抽屉再同时打开
window join 是另一类思路。两条流分别经过 keyBy,然后进入同一个窗口,窗口内的数据会被装进各自的状态存储,等到窗口触发那一刻,Flink 一次性把左右两个集合拿出来做配对。对应到源码,它是通过 JoinedStreams 包装 coGroup 实现,底层复用 WindowOperator。
用户感知到的语义是“同窗口内做 join”,底层表现则是“窗口缓冲 + 触发时集中计算”。这种模式适合两条流都有明确的时间窗口概念,比如每五分钟一个批次,两个批次做匹配。它不像 connect 那样一条一条处理,而是攒批处理,吞吐高,但实时性差一些。
1.3 interval join:时间区间内互相等待
interval join 是业务上最常见的场景:右流时间戳落在左流时间戳的某个范围内就算匹配。典型的例子是订单流和支付流对齐,要求支付时间在订单创建前后几分钟内。它对应 IntervalJoinOperator,本质上是 KeyedCoProcessFunction 的封装,内部用 MapState 做数据缓冲,依赖于定时器来清理过期数据。
这个方案最大的价值在于能处理“时间不同步”的两条流,不用固定在同一个窗口边界上。缺点是状态缓冲量较大,而且匹配逻辑是两两配对,时间复杂度跟区间内数据量有关。
1.4 coGroup:窗口机制下的自由操作
coGroup 和 window join 在实现上几乎同源,都是 window 机制加状态缓冲。区别在于窗口触发之后,coGroup 是把左右两个集合同时交给用户自定义函数处理,用户可以自己决定怎么配对、怎么输出,甚至不配对,只做分组对比。而 window join 的语义固定为 join 匹配,API 层帮用户做了 pair 的逻辑。
从源码层面看,coGroup 的算子类是 WindowCoGroupOperator 的核心逻辑,join 是 buildJoinFunction 做一层包装。理解 coGroup 有助于理解 join 的底层,我建议所有想深入源码的人都从 coGroup 入手。
1.5 四类方案的语义速查
| 操作类型 | 核心执行类 | 缓冲方式 | 触发时机 | 适用场景 |
|---|---|---|---|---|
| connect + CoProcessFunction | CoProcessOperator / KeyedCoProcessOperator | 自定义状态,完全自主控制 | 数据逐条到达即触发回调 | 任意复杂双流逻辑 |
| window join | WindowOperator + JoinedStreams | 窗口内 ListState / MapState 按 key 分组缓冲 | 窗口触发时统一计算 | 同窗口内的批式匹配 |
| interval join | IntervalJoinOperator | 左右两条流的 MapState 缓冲 | 数据到达时尝试匹配 | 时间区间内的实时对齐 |
| coGroup | WindowOperator + CoGroupedStreams | 同 window join | 窗口触发时统一计算 | 需要自定义分组对比逻辑 |
看完这张表,你应该能感觉到,选择哪种方式不只是 API 偏好问题,而是在“状态存储模型”和“触发模型”上做出的架构取舍。下面我从最基础的 connect 源码开始,一层层往里看。
2. connect 算子源码拆解:双流处理的总入口
2.1 从 DataStream.connect 到 ConnectedStreams 的执行链
connect 的入口在 DataStream 类里,方法签名很简单:public <R> ConnectedStreams<T, R> connect(DataStream<R> dataStream)。它不做什么重型操作,只是构造一个 ConnectedStreams 对象,把两个流的 transformation 关系记录下来。
关键在后续调用。ConnectedStreams 提供的 keyBy 方法会生成 KeyedConnectedStreams,此时两个流共用同一个 KeySelector。process 方法最终会创建一个 TwoInputTransformation,并指定算子类型。这里有个细节:如果你传入的是 KeyedCoProcessFunction,运行时创建的是 KeyedCoProcessOperator;传 CoProcessFunction,则创建 CoProcessOperator。两者的区别在于是否按 key 隔离状态。
从调度层面看,双流算子会存在某些 subtask 上,同时连接两个输入分区。实现类通过 InputSelectable 接口控制从哪个输入读取数据,源码里的input1和input2两个成员变量就代表着两条物理输入流。Flink 会尽量避免死锁,通过loadBalance或broadcast等方式分配数据。
2.2 KeyedCoProcessFunction 两个回调方法的时序保障
进入算子内部,核心逻辑在 KeyedCoProcessOperator 的processElement1和processElement2方法。这两个方法分别对应左流和右流的数据到达事件。算子内部拿到元素之后,先通过keySelector提取当前记录的 key,然后放到对应 keyed state 的命名空间里,最后调用用户自定义函数userFunction.processElement1。
这里最容易被忽略的是“当前 key”的概念。在 KeyedCoProcessOperator 里面,每处理一条数据,都要切换状态命名空间。也就是说同一时刻只有一个 key 处于活跃状态,你在这个回调里访问 ValueState 只能拿到当前 key 对应的值。这就是为什么你不能在 processElement1 里批量遍历其他 key 的状态——根本看不到。
时序保障方面,Flink 保证同一 key 的数据在同一个 subtask 按顺序处理,但左右两条流之间并不存在全局顺序约定。这也是双流处理中最常见的误解:很多人以为 connect 之后左流和右流的数据会像 merge 一样按时间有序混合,实际不是,它们是分别触发的,谁先到取决于上游网络传输。
2.3 状态隔离与共享:左右流的状态为什么可以共存
在 KeyedCoProcessOperator 内部,状态存储通过KeyedStateBackend管理。左右流的 key 是同一个,所以天然落在同一个 backend 里。你在 CoProcessFunction 里声明的每个状态描述符(如 ValueStateDescriptor),都会被注册到当前算子实例的 backend 中,底层实际存储是命名空间隔离的。
这就意味着左右流的数据在物理上共享同一个 subtask 的 state,但在逻辑上可以做到互不干扰。比如你声明一个leftSeenState和一个rightSeenState,分别记录左右流是否到过,即便 key 相同,它们各自的值也不会相互覆盖,因为 state name 不同。
不过要注意,所有状态都是挂在当前 key 下的。如果你一个 key 的数据量极大,或者 key 分布不均匀,就容易出现状态倾斜。这种情况在 connect 写自定义状态时尤其危险,因为没人帮你自动清理。实操中我在用 connect 做双流关联时,一定会手动注册定时器做状态清理,或者配合 State TTL 使用。
2.4 为什么 connect 不能做窗口聚合
很多人尝试用 connect 实现类似 window join 的效果,即把数据攒起来达到某个条件再输出。技术上是可行的,但代价非常大:你不仅要自己用 MapState 按窗口维度缓冲数据,还要注册窗口触发的定时器,自己去处理事件时间推进、定时器合并、状态清理等一系列本可以由 window 机制代劳的事情。
从源码可以看到,window join 的复杂逻辑都封装在 WindowOperator 里,包括窗口分配器、触发器、状态存储、窗口合并等。connect 什么都没有,它就是最朴素的双输入驱动模型。所以选型时我心里有一条判断标准:如果需求本质是“时间窗口内配对”,直接上 window join;如果需求是“逐条事件驱动,并且需要自定义复杂的时序状态”,才用 connect。
3. Window Join 源码本质:两个 MapState 按 key 的集中式配对
3.1 从 join 到 coGroup:一个被伪装过的算子
你调用DataStream.join(DataStream)时,Flink 实际构造的是JoinedStreams,它内部有一个where和equalTo方法用来指定两条流的 key。最终调用window方法后,底层会构建一个WindowCoGroupTransformation,再通过CoGroupedStreams的内部类完成 join 逻辑到 coGroup 逻辑的转换。
可以理解为:window join 是 coGroup 的一个特化形态。coGroup 在窗口触发时把左右集合丢给用户处理,join 则替用户做了内连接的配对输出。看源码的入口时,我会直接看CoGroupedStreams如何构造 WindowOperator,因为 window join 的所有核心机制都在 WindowOperator 里。
3.2 窗口内状态结构:ListState 和 MapState 怎么搭配使用
WindowOperator 处理双流时,会对左右流分别使用窗口状态。窗口状态的核心是MapState<Window, ListState<T>>或者类似的嵌套状态结构。外层 key 是窗口对象,内层是同一窗口内的数据列表。
这里要区分两个维度:第一,每条数据进来,先由WindowAssigner分配窗口;第二,同一个 key 下同一个窗口的数据累积到一个 ListState 里。之所以用 ListState 而不用堆外存储,是因为窗口数据需要频繁遍历和删除,ListState 在 RocksDB 下的序列化开销相对可控。
触发环节则依赖Trigger。Flink 默认的 EventTimeTrigger 会注册一个基于 watermark 的定时器,当 watermark 越过窗口结束时间,触发onElement的循环调用,最终调用emitWindowContents。这个方法会从状态里取出左流集合和右流集合,按 key 做内连接匹配。
3.3 源码里的配对过程:谁遍历谁,谁查谁
在emitWindowContents里,配对并不是代码里写两个 for 循环直接嵌套,而是有一个明确的遍历顺序。Flink 会对左流状态leftIterator做外层遍历,对于每一个左流元素,从右流状态中按相同的 key 查询对应的记录集合,然后遍历右集合输出所有匹配对。
这个设计的巧妙之处在于直接用 key 做等值连接查询,避免了 O(n*m) 的笛卡尔积。因为左右流都已经按 key 分组,每个左元素只需要查一次右状态就能拿到所有匹配项。你仔细想想,这其实和关系数据库里的 Hash Join 思想一致:小表驱动大表,按 key 探测。
不过要注意,如果同一个 key 在窗口内数据量非常大,比如某个用户 ID 在五分钟窗口内有几十万条点击数据,那查询右状态时返回的集合也会很大,此时单条数据的处理耗时会被放大。我碰到过因为这种热点 key 导致窗口触发时长时间卡住的情况,下游水位上涨、整个作业延迟飙升,最后只能通过拆分窗口或者过滤异常 key 来解决。
3.4 从源码看 window join 的局限
WindowJoinOperator 的源码注释里写得很清楚,它是基于窗口状态和触发器的批式匹配。这里有几个天然局限:
第一,窗口缓存的数据量不会被单独清理,直到窗口触发。意思是如果某个窗口永远等不到足够大的 watermark(比如上游某分区数据断流),窗口内的状态就会一直堆积,内存和磁盘压力都会持续增长。生产上的对策一般是设置空闲流处理策略withIdleness,或者靠 TTL 兜底。
第二,窗口 join 的内连接语义意味着找不到匹配的左或右数据都会被丢弃。如果你想做 left outer join,需要走CoGroupedStreams调用with类型的外部连接包装,其实底层是用一个标志位标记匹配情况,再做有无输出的补全。这个和标准 SQL 的 outer join 一样,源码里会有LeftOuterJoin、RightOuterJoin两个辅助类。
第三,窗口 join 只适用于时间窗口对齐的场景。如果两个流的 key 一样但时间偏差很大,比如左流数据昨天到,右流今天才到,window join 是不可能完成的,必须用 interval join。
4. Interval Join 源码剖析:一条时间线上的等待与清理
4.1 IntervalJoinOperator 的核心状态设计
IntervalJoinOperator 是我在生产环境用最多的双流算子。它的源码逻辑非常清晰:维护两个 MapState,分别叫leftBuffer和rightBuffer,key 是流中的 key,value 是一个包装对象,存放记录本身和时间戳。
当左流数据到达时,processElement1会把数据写入leftBuffer,然后马上调用registerTimer注册一个清理定时器;同时立即遍历rightBuffer,看看有没有右流数据的时间戳落在匹配区间内,如果有,就输出匹配对。右流到达时对称执行,从leftBuffer里查匹配项。
这里最核心的匹配区间计算逻辑是:一条左流记录的时间戳为 t,定义时间下界和上界,比如t + lowerBound到t + upperBound。右流记录时间戳在这个区间内就算匹配。注意这个区间是闭区间还是开区间取决于配置,默认是 [timestamp + lowerBound, timestamp + upperBound]。
4.2 数据到达时的配对与输出逻辑
从源码实现看,IntervalJoinOperator 的每个 process 方法都有一段对称的循环:刚到的数据写入自身缓冲状态,然后从对方缓冲状态中筛选时间戳在区间内的记录。筛选过程其实是在遍历对方 MapState 中当前 key 下的所有数据,逐一判断时间戳边界。
这种设计带来的性能特征和 window join 很不一样。window join 是窗口触发那一刻做批量配对;interval join 则是数据一到就尝试配对,因此实时性高得多。但也正因为实时配对,数据到达顺序会影响匹配结果:如果右流先到,左流后到,左流在 processElement1 时能查到右流,匹配成功;反过来也成立。可如果双方都到了,却因为时间戳差异不在区间内,那这两条记录就永远不匹配,只能等各自清理定时器触发后被删除。
4.3 定时器清理机制:数据不会永远堆积
IntervalJoinOperator 的清理策略是每个数据进来时都注册一个定时器,时间点在“数据自身时间戳 + 区间上界(如果是右流,则是时间戳 + 区间绝对上界)”。定时器触发时,会把该数据从对应 Buffer 中删除。
这里有一个容易踩坑的点:如果两条流的数据时间戳差距很大,比如左流数据时间戳是 5 分钟前,右流数据时间戳是当前时间,那么匹配区间设置必须足够大才能覆盖差异,否则大量数据会相互等不到。区域设置太小,数据堆积之后被定时器清理,看起来就是莫名其妙的“丢数据”。
我的实际建议是:interval join 的时间区间必须根据两条流的上游延时差来设置,不能只看业务语义。比如业务上要求支付在订单后 15 分钟内完成,但支付流的上游可能比订单流慢 10 分钟,那 upperBound 至少要设到 25 分钟以上,否则永远配不上。
4.4 为什么会输出重复数据
这是 interval join 最容易让人困惑的地方:同一对数据,可能被输出两次。原因是左流到达时,它会在 rightBuffer 中配对一次;右流到达时,它又会在 leftBuffer 中配对一次。如果左右两条记录先后到达,第一次有一条配对输出,第二次到达时再次配对输出,就产生了重复。
源码里面并没有自动去重逻辑,因为 Flink 认为匹配输出本身是增量流式的。你需要结合下游幂等或自定义去重逻辑来解决。我在项目里通常会给每条记录生成一个唯一业务 ID 组合,在 sink 端做去重,或者改用 KeyedProcessFunction 明确控制:只在左流到达时进行配对,右流到达时只做缓冲,避免重复。
4.5 状态过期和内存控制
IntervalJoinOperator 的缓冲状态是无界的,如果没有清理机制,状态会无限增长。很多初看源码的人以为 MapState 会自动清理,其实不会。上文提到的定时器清理只是清理过期数据的前提,真正能不能及时清理取决于 watermark 是否正常推进。
如果上游某个并行度数据断流,watermark 停在某个值,那所有定时器都不会触发,状态会一直挂着。我在生产上遇到过因为一个分区的 Kafka 消费卡住,导致整个 interval join 作业的 RocksDB 状态持续增长,最后磁盘被打满的情况。当时排查了很久,最后在 source 端加上了空闲检测和超时跳过逻辑才解决。
5. 源码级选型对照与生产踩坑速查
5.1 三种核心双流机制选型对照表
| 维度 | connect + KeyedCoProcessFunction | window join / coGroup | interval join |
|---|---|---|---|
| 触发模型 | 事件逐条触发 | 窗口触发,批量处理 | 事件到达时即触发匹配 |
| 状态结构 | 自定义,完全可控 | 窗口聚合状态 | 左右独立 MapState 缓冲 |
| 清理机制 | 自己写定时器或 TTL | 窗口结束即清理 | 定时器按时间区间清理 |
| 乱序容忍度 | 自己控制 | 依赖 watermark 和 allowedLateness | 依赖 watermark 和区间设置 |
| 重复输出 | 自己控制 | 一个窗口内不重复 | 天然可能重复,需下游去重 |
| 适合场景 | 任意复杂状态机、动态规则 | 固定窗口周期匹配 | 时间区间内的事件对齐 |
选型不能光看功能,还要结合状态规模、窗口数量、输出结果是否允许重复来综合判断。比如状态规模很大的场景,我基本不会用 interval join,因为左流大量不匹配的数据会长时间占用状态空间,脏数据一多就会拖垮 RocksDB。
5.2 生产中的典型问题和排查方向
状态膨胀是第一优先级。我排查双流作业性能问题时,第一步就是看每个算子当前的 state size 指标。如果是 window join,看窗口状态是否长期积压;如果是 interval join,看左右 buffer 是否只涨不降。一个直觉判断方法是:如果作业停止消费几分钟,然后看 state 大小是否回落,如果完全不回落,说明清理逻辑没有生效。
乱序数据引发的结果偏差也很常见。Flink 默认处理的是事件时间,但很多人测试时用的是 processing time,导致线上换了环境之后行为完全不一样。源码里 EventTimeTrigger 的注册逻辑依赖 watermark,如果你上游没有正确设置事件时间提取器和延迟阈值,窗口永远不会触发。
数据倾斜在双流 join 中表现更隐蔽。单流倾斜看 key 分布就行,双流倾斜还要看左右流的 key 分布是否一致。如果左流热点 key 在右流中数据量很小,那 join 依然会产生大量无匹配输出,浪费计算资源。我在处理这种问题时,会先做一次 key 分桶预聚合,或者用热点 key 单独走一条特殊逻辑通道。
5.3 调试双流作业的实用技巧
看源码不能只看,还要会调试。我的经验是先打出一对样本 key 的完整日志:左流到达时间、左流写入状态的版本、右流到达时间、匹配尝试时间、定时器清理时间。把这五个时间点打出来,基本就能定位是等待问题、清理问题,还是重复输出问题。
另一个懒人技巧是利用 Flink Web UI 的 Watermark 面板。双流作业里左右两条流各自维护 watermark,UI 上可以看到两条 watermark 的差距。我见过很多 join 不出的问题,本质都是两条流的 watermark 差得离谱,左边已经到 12 点,右边还在 11 点 20 分,区间再大也匹配不上。此时排查方向应该聚焦在上游慢的那条链路,而不是算子本身。
5.4 一个容易忽略的细节:KeyBy 分区一致性
双流操作要求左右流经过的 keyBy 逻辑一致,保证相同 key 的数据进入同一个 subtask。这个看起来是常识,但在源码层面,如果左右流调用的 keyBy 不是同一个 selector,或者字段类型不一致(比如一个是字符串,一个是字节数组),会导致 hash 值不同,相同业务 key 落到不同 subtask,join 自然永远失败。
这种问题极难排查,因为作业不报错,指标正常,但输出就是为空。我排查过一个类似的线上事故:订单流 keyBy 用的是订单 ID 的 Long 类型,支付流 keyBy 用的是订单 ID 的 String 类型,由于 Flink 内部哈希是基于序列化后的字节做的,两个不同序列化器产生的哈希值完全不同,数据被分到不同分区,join 结果永远是零。检查时一定要确认两条流的 key 类型完全一致,最好共用同一个 POJO 字段。
写到这里,我把 Flink 双流操作从 connect 到 interval join 的源码路径大体梳理了一遍。对我而言,看这些源码最有价值的一点,不是背下哪个类做了什么,而是养成了用“状态存储 + 定时器 + 触发时机”三个维度去分析每一个双流作业的习惯。下次你遇到 join 结果不对、数据重复、状态膨胀这些现象,不妨先问自己三个问题:数据在什么状态下等待?由什么机制触发配对?过期的数据由谁来清理?把这三个问题答清楚,双流操作基本就掌握了大半。