做实时数仓的朋友应该都体会过这种痛:业务方说“把订单和支付关联起来展示”,你心想这不就是一个 join 吗,可真动起手来,订单流、支付流、库存流三个 Kafka Topic 摆在那里,时间对不齐、数据有迟到、重复消费还时不时冒出来,一个双流 join 做一周都算快的。这篇 Flink 学习笔记,就把我在多流 Join 上踩过的坑、总结出的方案选择和能直接抄的代码,一次说清楚。内容偏实战,适合刚开始用 Flink 做实时数据接入或者实时数仓开发的读者,也适合那些已经写了几个 join 但总是对不上数的同学。
1. 先搞清楚:多流Join到底在解决什么问题
1.1 一个真实业务场景:订单、支付、库存三张“实时表”
先说一个我实际做过的场景。业务上有个需求,要把订单、支付、库存三个系统的数据实时合并成一张宽表,再推到前端的实时看板。听起来很常规吧,离线数仓里这就是三张表 join 一下的事,但到了实时这条链路里,问题就全变了。
订单数据在 Kafka 的 ods_order_info 里,支付数据在 ods_payment_info 里,库存变更在 ods_stock_change 里。三个 Topic 的生产速度不一样,消费速度也不一样;同一个订单,可能在订单流里是 10 点 00 分产生的,但支付流里要等用户真正付款、支付网关回调入库之后才出现,可能已经是 10 点 05 分了。你如果按照 Kafka 消息到达 Flink 的时间去关联,那百分之百对不上。
这个时候你才明白,所谓“多流 Join”,真正的难点不是 Join 语法本身,而是如何定义两条流中哪些数据是一对儿。在离线表里,有主键、有事务、有快照,你把两张表 JOIN ON 一个订单号就行;但在实时流里,每条数据是源源不断进来的,你永远不知道另一个流的那个“匹配值”什么时候来,甚至不知道它还会不会来。
1.2 为什么流式环境里不能像MySQL那样直接写Join
很多人刚接触 Flink SQL 的时候有个误解:既然 Flink SQL 支持 JOIN 语法,那我把两个 Kafka Topic 注册成两张表,然后写一句 SELECT * FROM a JOIN b ON a.id = b.id 不就行了?
语法上确实可以,但跑起来你会发现问题。流是无限的,a 流中一条数据进来,它要和 b 流里的所有数据做匹配,b 流之前来过的每一条数据都要记住,后面的每一条数据也要和它对比。换句话说,这个 Join 会把两个流的所有历史数据都攒在状态里,状态无限增长,最终把 RocksDB 撑爆,任务重启一次恢复状态要几个小时。
所以流式环境里的 Join 必须加“时间边界”。要么是窗口边界,要么是区间边界,要么干脆把其中一条流降级成查询外部存储的维表。理解了这一点,你再看 Flink 提供的几种 Join 方案,思路就非常清晰了。
1.3 多流Join的四种实现方式总览
我整理了一张表,是我后来做方案选型时反复对照的:
| 方案 | 适用场景 | 需要维护的状态 | 能处理乱序吗 | 输出方式 |
|---|---|---|---|---|
| 窗口 Join | 两个流在同一时间窗口内对齐 | 窗口内缓存所有数据 | 有限容忍,靠 Watermark | 窗口触发时一次性输出 |
| Interval Join | 两条流的时间有先后关系,允许偏差范围 | 区间内数据,TTL 自动清理 | 支持,基于事件时间 | 匹配时立即输出 |
| 维表 Join(异步IO) | 实时流关联外部静态数据 | 本地缓存 + 连接池 | 天然支持 | 每条记录处理完即输出 |
| Flink SQL Regular Join | 离线 SQL 迁移,状态无限累积 | 需要手动控制生命周期 | 不推荐用于无条件 Join | 持续输出 |
后面我按这个顺序一个一个说。前两个是真正的“流流 Join”,第三个是实际项目里最常用的,第四种更多是概念上要知道,因为你很容易写着写着就掉进状态无限增长的坑里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 窗口Join:最直观但最容易踩坑的双流对齐方案
2.1 窗口Join的核心机制:把无限流切成有限块
窗口 Join 的思路是:既然两个无限流无法直接做完整匹配,那把无限流切成有限的时间块,在同一个时间块内按键关联。这个过程你可以理解成把两根不断延长的绳子,每隔一段就剪一刀,然后让剪下来的每一段去和另一根绳子对应时间段里的段做配对。
Flink 的窗口 Join 在底层做的事情是:两条流分别 keyBy,然后进入同一个 Window,窗口内两侧的数据都会先缓存到状态里,等窗口触发计算的时候,把两边状态里能匹配上的数据对找出来,交给 JoinFunction 处理。所以它天然是一个内连接。
这里的核心前提是:你要关联的两条流,在业务时间上必须大致对齐。比如订单流里 10:00:30 产生的订单,支付流里这条订单的支付事件最好也在 10:00:30 附近到达,最多差几秒钟。差得太远,窗口 Join 就会丢数据。
2.2 完整代码:订单流与支付流实现5分钟窗口Join
我直接给一段可以跑的代码。环境是 Flink 1.17,使用 DataStream API,事件时间语义。
java复制StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
DataStream<OrderInfo> orderStream = env
.addSource(new FlinkKafkaConsumer<>("ods_order_info", new JSONDeserializationSchema(), props))
.map(json -> parseOrder(json))
.assignTimestampsAndWatermarks(
WatermarkStrategy.<OrderInfo>forBoundedOutOfOrderness(Duration.ofSeconds(10))
.withTimestampAssigner((order, ts) -> order.getOrderTime())
);
DataStream<PaymentInfo> paymentStream = env
.addSource(new FlinkKafkaConsumer<>("ods_payment_info", new JSOND
