资讯动态

Flink双流Join源码剖析:三种机制的执行原理与选型

发布时间:2026/10/19 7:13:22 来源:尧图企业网站定制
拿到一个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 CoProcessFunctionCoProcessOperator / KeyedCoProcessOperator自定义状态完全自主控制数据逐条到达即触发回调任意复杂双流逻辑window joinWindowOperator JoinedStreams窗口内 ListState / MapState 按 key 分组缓冲窗口触发时统一计算同窗口内的批式匹配interval joinIntervalJoinOperator左右两条流的 MapState 缓冲数据到达时尝试匹配时间区间内的实时对齐coGroupWindowOperator CoGroupedStreams同 window join窗口触发时统一计算需要自定义分组对比逻辑看完这张表你应该能感觉到选择哪种方式不只是 API 偏好问题而是在“状态存储模型”和“触发模型”上做出的架构取舍。下面我从最基础的 connect 源码开始一层层往里看。2. connect 算子源码拆解双流处理的总入口2.1 从 DataStream.connect 到 ConnectedStreams 的执行链connect 的入口在 DataStream 类里方法签名很简单public R ConnectedStreamsT, R connect(DataStreamR 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 处理双流时会对左右流分别使用窗口状态。窗口状态的核心是MapStateWindow, ListStateT或者类似的嵌套状态结构。外层 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和rightBufferkey 是流中的 keyvalue 是一个包装对象存放记录本身和时间戳。当左流数据到达时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 KeyedCoProcessFunctionwindow join / coGroupinterval 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 面板。双流作业里左右两条流各自维护 watermarkUI 上可以看到两条 watermark 的差距。我见过很多 join 不出的问题本质都是两条流的 watermark 差得离谱左边已经到 12 点右边还在 11 点 20 分区间再大也匹配不上。此时排查方向应该聚焦在上游慢的那条链路而不是算子本身。5.4 一个容易忽略的细节KeyBy 分区一致性双流操作要求左右流经过的 keyBy 逻辑一致保证相同 key 的数据进入同一个 subtask。这个看起来是常识但在源码层面如果左右流调用的 keyBy 不是同一个 selector或者字段类型不一致比如一个是字符串一个是字节数组会导致 hash 值不同相同业务 key 落到不同 subtaskjoin 自然永远失败。这种问题极难排查因为作业不报错指标正常但输出就是为空。我排查过一个类似的线上事故订单流 keyBy 用的是订单 ID 的 Long 类型支付流 keyBy 用的是订单 ID 的 String 类型由于 Flink 内部哈希是基于序列化后的字节做的两个不同序列化器产生的哈希值完全不同数据被分到不同分区join 结果永远是零。检查时一定要确认两条流的 key 类型完全一致最好共用同一个 POJO 字段。写到这里我把 Flink 双流操作从 connect 到 interval join 的源码路径大体梳理了一遍。对我而言看这些源码最有价值的一点不是背下哪个类做了什么而是养成了用“状态存储 定时器 触发时机”三个维度去分析每一个双流作业的习惯。下次你遇到 join 结果不对、数据重复、状态膨胀这些现象不妨先问自己三个问题数据在什么状态下等待由什么机制触发配对过期的数据由谁来清理把这三个问题答清楚双流操作基本就掌握了大半。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑