做实时数仓的朋友应该都体会过这种痛:业务方说“把订单和支付关联起来展示”,你心想这不就是一个 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 JSONDeserializationSchema(), props))
.map(json -> parsePayment(json))
.assignTimestampsAndWatermarks(
WatermarkStrategy.<PaymentInfo>forBoundedOutOfOrderness(Duration.ofSeconds(10))
.withTimestampAssigner((payment, ts) -> payment.getPayTime())
);
orderStream
.join(paymentStream)
.where(OrderInfo::getOrderId)
.equalTo(PaymentInfo::getOrderId)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.apply((OrderInfo order, PaymentInfo payment) -> {
String orderId = order.getOrderId();
BigDecimal amount = order.getAmount();
BigDecimal payAmount = payment.getPayAmount();
return orderId + "," + amount + "," + payAmount;
})
.print();
这段代码的逻辑是:订单流和支付流都按订单号分区,然后在 5 分钟的滚动窗口内做等值连接。只有订单事件和支付事件落在同一个 5 分钟窗口里,才会输出结果。
实际跑起来你会发现,第一天的数据很正常,因为测试数据都是你手动造的,时间误差很小。但生产环境一上,各种问题就来了,下面这两个是最高频的。
2.3 用CoGroup实现窗口Left Join
生产环境里“左连接”才是常态。业务方会问你:订单有但没支付的,也要能看到啊。窗口 Join 本身只支持内连接,要用窗口实现 Left Join,你需要换成 coGroup 操作。
CoGroup 的处理方式粗糙一点,但思路清楚:同一个窗口内,两条流的数据分别分组,然后在 CoGroupFunction 里自己决定哪些要输出、哪些不输出。
java复制orderStream
.coGroup(paymentStream)
.where(OrderInfo::getOrderId)
.equalTo(PaymentInfo::getOrderId)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.apply(new CoGroupFunction<OrderInfo, PaymentInfo, String>() {
@Override
public void coGroup(Iterable<OrderInfo> orders, Iterable<PaymentInfo> payments, Collector<String> out) {
boolean isPaid = payments.iterator().hasNext();
for (OrderInfo order : orders) {
if (isPaid) {
for (PaymentInfo payment : payments) {
out.collect(order.getOrderId() + ",已支付," + payment.getPayAmount());
}
} else {
out.collect(order.getOrderId() + ",未支付,0");
}
}
}
})
注意 coGroup 的输入迭代器是有限集合,因为窗口已经把数据框住了。这个方法写起来麻烦,但能让你在控制输出结构时非常自由。比如你想在未支付时输出一个默认值 0,或者想同时输出多条支付记录对应的多行结果,都可以在 coGroup 里自己控制。
2.4 窗口Join的隐藏代价
窗口 Join 的问题是它把所有数据都留在窗口状态里,直到窗口触发才释放。假设你开的是一个 30 分钟的窗口,高峰期的订单流每分钟产生 10 万条数据,那这一个窗口就要缓存 300 万条订单数据,支付流还要再缓存一份对应数据。这个状态量级在小数据量下看不出来,数据量一大,状态后端的压力、checkpoint 的耗时都会成倍上升。
另外一个很隐蔽的问题:窗口在事件时间语义下,必须等 Watermark 越过窗口结束时间才会触发。如果你的上游某个分区一分钟都没有新数据,Watermark 就可能卡住,整个窗口的结果就会无限期延后。我之前排查过一个“结果半小时不出”的问题,最后发现是某个上游 Topic 的数据稀疏,Watermark 推进不及时导致的——这个在后面的实战复盘里会详细说。
3. Interval Join:用时间区间代替固定窗口
3.1 为什么固定窗口解决不了时间漂移
窗口 Join 的问题在于“窗口边界太死”。订单 10:00:59 产生,支付 10:01:01 到达,如果窗口是 10:00 到 10:05,两边都在窗口内,能配上;但如果订单是 10:00:59,支付是 10:05:01,一个在窗口末尾,一个在下一个窗口开头,就永远配不上了。数据量一大,这种边界擦肩而过的情况特别多。
这也是为什么我会在一些场景里换用 Interval Join。它的思路不是定义死板的窗口,而是给每条数据定义一个“可匹配的时间范围”:左边这条数据,可以和右边流中时间落在 [leftTime - 上下界, leftTime + 下上界] 范围内的数据匹配。这样你的业务逻辑就从“同时落在某个窗口内”变成了“在可接受的时间偏差内”。
3.2 Interval Join的语法与语义
Flink 的 Interval Join 需要两条流都做好事件时间分配和 Watermark 策略,然后通过 keyBy 分区,再用 intervalJoin 方法连接。
需要注意一点:Interval Join 的处理逻辑是通过 ProcessJoinFunction 实现的。它不会像窗口 Join 那样等窗口结束才输出,而是一旦匹配成功就立刻输出。这个特性非常适合场景是“一边数据到达后,能马上跟另一边数据关联上”的情况。
Interval Join 有四个边界控制方法,每个都有实际用途:
| 方法 | 作用 |
|---|---|
between(Time, Time) |
设置上下界,闭区间 |
lowerBoundExclusive() |
下界开区间,匹配时不包含下边界 |
upperBoundExclusive() |
上界开区间,匹配时不包含上边界 |
process(ProcessJoinFunction) |
定义匹配成功后的处理逻辑 |
用的时候要特别注意 between 的时间参数到底应该填多少。如果 left 流是订单流、right 流是支付流,订单产生时间一定早于支付时间,那 between(Time.minutes(-5), Time.minutes(30)) 的意思就是:在左边订单时间的 5 分钟之前到 30 分钟之后的范围内找右边的支付记录。这个范围如果你填反了,或者填得太窄,都会导致大量数据 join 不上。
3.3 代码示例:订单流与退款流的时间区间关联
之前做过一个售后场景,订单产生之后,用户可能在几小时甚至几天后发起退款。退款流里的记录带有原订单 ID 和退款时间,但退款时间和订单时间差得太多了,窗口 Join 完全不可行,用 Interval Join 就非常合适。
java复制orderStream
.keyBy(OrderInfo::getOrderId)
.intervalJoin(refundStream.keyBy(RefundInfo::getOrderId))
.between(Time.minutes(-5), Time.hours(72))
.process(new ProcessJoinFunction<OrderInfo, RefundInfo, String>() {
@Override
public void processElement(OrderInfo order, RefundInfo refund, Context ctx, Collector<String> out) {
out.collect(order.getOrderId() + ",订单金额:" + order.getAmount()
+ ",退款金额:" + refund.getRefundAmount());
}
})
在这个例子中,between(Time.minutes(-5), Time.hours(72)) 表示退款流里的记录,可以关联到退款时间前 5 分钟到后 72 小时内的订单记录。这里把上界设为 72 小时,是因为业务规定用户下单后 3 天内才能申请退款。边界根据业务规则设,不要随便填一个大值,否则状态会保持很久。
3.4 Interval Join的上界下界怎么定
Interval Join 的上界和下界不是拍脑袋定的,它直接决定了状态大小和匹配成功率。我个人有三个经验值可以参考:
- 如果左右两条流是“同时产生、先后到达”的关系,比如订单和支付,下界设
-5min,上界设30min或者60min就够了,主要给系统延迟留余量。 - 如果右流非常稀疏,比如退款、改签这类低频事件,上界可以设大一些,比如
48h或72h,但一定要评估状态量级,必要的时候给 key 加上 TTL。 - 如果你确定左边的业务时间一定小于右边,比如订单时间一定早于支付时间,那下界直接设
0,不用给负数。这样能减少左侧的状态保留时间,降低状态压力。
Interval Join 相比窗口 Join,状态是自动清理的。Flink 会定期清理那些已经超出边界范围、不可能再匹配上的数据,所以不会出现窗口结束后状态全部压在内存里等触发的情况。但它也不是免费的,每个 key 对应的状态依然会保留上下界区间那么长时间,设计业务规则时一定要把“状态保留时间 = 上界 - 下界”这个公式记在心里。
4. 维表Join:实际项目里用得最多的“多流”关联
4.1 维表Join的本质:流与静态数据的关联
说句实在话,我后来复盘发现,真正让业务跑起来的多流关联,大部分不是“流流 Join”,而是“流 + 维表 Join”。为什么?因为实时流之间的 Join 要处理乱序、延迟、状态管理,成本极高。但很多时候业务要关联出去的那部分数据根本不需要走实时流——比如用户信息、商品信息、门店信息,这些数据一天变不了几次,把整个 MySQL 表加载进来当维表就够了。
所谓维表 Join,就是拿实时流里的某个字段,去外面查一张“变得很慢的表”。订单流里带着 userId,你需要把它关联上用户维表得到用户的会员等级、所在城市;商品流里带着 skuId,你需要关联上商品维表得到品类和品牌。这种场景在离线数仓里都习以为常,在实时链路里就成了一个特别的优化点——因为如果你做得不好,每条数据同步查一次 MySQL,数据库直接被你打挂。
我第一次做维表 Join 的时候,写的就是最普通的同步查询:在 MapFunction 里用 JDBC 去查数据库。上线十分钟,MySQL 的 CPU 直接飙到 100%,连接数爆满,整个服务被拖死。后来才老老实实研究异步 IO。
4.2 千万别用同步查询:异步IO的正确姿势
Flink 的 AsyncFunction 就是为了解决这个问题的。它允许你在处理一条数据时,异步发起外部请求,同时不阻塞当前算子线程。简单说,同步查询是一条一条排队查数据库,异步 IO 是一个线程同时发出几十上百个查询请求,结果谁先回来谁先处理。
核心代码长这样:
java复制public class AsyncMySQLRequest extends RichAsyncFunction<String, UserInfo> {
private transient ExecutorService executorService;
@Override
public void open(Configuration parameters) {
executorService = Executors.newFixedThreadPool(20);
}
@Override
public void asyncInvoke(String userId, ResultFuture<UserInfo> resultFuture) {
CompletableFuture<UserInfo> future = CompletableFuture.supplyAsync(() -> {
// 这里执行实际的 JDBC 查询
return queryUserByCacheOrDb(userId);
}, executorService);
future.thenAccept(resultFuture::complete);
}
}
注意几个细节:
asyncInvoke 里不能直接做耗时操作,要丢到线程池里异步执行,否则阻塞的还是算子线程。ResultFuture 必须在回调里调用完成,否则这条数据会一直挂在算子状态里,引发超时。引入异步 IO 的时候,要额外定义超时时间:AsyncDataStream.unorderedWait(stream, new AsyncMySQLRequest(), 5, TimeUnit.SECONDS),超过 5 秒查不回来,这条数据会输出到丢弃分支,避免整个任务卡死。
还有一个小技巧:用异步 IO 访问 MySQL 时,连接池大小要大于算子并行度,否则即使异步,连接也会成为瓶颈。我一般把连接池设成 并行度 * 4。
4.3 本地缓存+定时刷新:保护下游数据库
异步 IO 只是降低了连接等待,但没有减少查询次数。真正要减少数据库压力,得在本地做缓存。
我的做法是:用 Flink 的 RichAsyncFunction 在 open 方法里初始化一个本地缓存,比如 Guava Cache 或者 Caffeine,设置了过期时间,比如 5 分钟。查询的时候先去缓存里拿,拿不到再查数据库,查完写回缓存。对于用户表这种变更不频繁的数据,5 分钟缓存完全够用。
如果维表数据量不大,比如几千条,更简单粗暴的办法是:定时全量加载。用 ProcessFunction 里注册一个定时器,每 10 分钟把整个维表加载一次,放到本地 Map 里,查询时直接内存查询,比缓存方案还要快。这种方式我在商品维表上用过,效果很好,因为商品也就几万条,全量加载水花都不大。
选缓存策略有一个判断标准,你对着套就行:
| 维表数据量 | 变更频率 | 推荐方案 |
|---|---|---|
| 几万条以内 | 天级变更 | 定时全量加载到本地Map |
| 十万到百万级 | 小时级变更 | Caffeine本地缓存,定时刷新 |
| 百万级以上 | 分钟级变更 | Redis维表 + 异步查询 + 本地缓存两层 |
| 来源是业务库 | 变更实时性要求高 | Flink CDC 监听 binlog,推送 Kafka 再构建维表 |
4.4 用Flink SQL实现维表关联
如果你用的是 Flink SQL,那更省事,直接用 Lookup Join。在 SQL 里把 MySQL 表注册成维表,然后 FOR SYSTEM_TIME AS OF 语法就能关联。
sql复制CREATE TABLE user_dim (
user_id BIGINT,
user_name STRING,
level STRING,
PRIMARY KEY (user_id) NOT ENFORCED
) WITH (
'connector' = 'jdbc',
'url' = 'jdbc:mysql://localhost:3306/app_db',
'table-name' = 't_user',
'username' = 'root',
'password' = '123456',
'lookup.cache.max-rows' = '10000',
'lookup.cache.ttl' = '5min'
);
SELECT
o.order_id,
o.user_id,
u.user_name,
u.level
FROM order_stream o
LEFT JOIN user_dim FOR SYSTEM_TIME AS OF o.proc_time AS u
ON o.user_id = u.user_id;
这里有两个参数要特别关注:lookup.cache.max-rows 和 lookup.cache.ttl。前者设置本地缓存的最大行数,超过之后按 LRU 淘汰;后者设置缓存有效期,到期后重新查询数据库。这两个参数没有标准值,要根据你的维表数据量和对实时性的要求来调。缓存设得太短,数据库压力大;设得太长,业务数据的更新不及时,用户看到的信息就滞后。
4.5 维表数据的实时更新:CDC方案
最后一个进阶场景:如果维表来自业务库,而且数据变更非常频繁,比如库存表,5 分钟缓存就不可接受了。这时候有两种选择:一是把库存状态也做成实时流,用前面讲的窗口或 Interval Join;二是用 Flink CDC 监听 MySQL 的 binlog,把变更记录推到 Kafka,再在 Flink 里消费并构建一个实时更新的本地维表。
CDC 方案的优点是真正实时,业务库里一改,Flink 这边几乎同步就能拿到最新的维表值。代价是要多维护一条 Kafka Topic,还要处理 MySQL 连接和 binlog 的权限配置。我之前用一个简单版本:把 CDC 变更流和主数据流 connect 起来,在 CoProcessFunction 里更新共享状态。每次来一条变更,就更新状态里的维表值;每次来一条订单,就到状态里查最新的维表值。这样比查外存更稳定,也不依赖数据库连接。
5. 实战复盘:线上支付金额对不上的完整排查过程
5.1 现象:大屏指标时对时错
有一次线上实时大屏的数据出了问题,业务反馈“今日支付金额不对,忽高忽低”。这个看板的计算逻辑不复杂:订单流和支付流做窗口 Join,然后按支付渠道维度汇总金额。最开始怀疑是上游数据源的问题,但查看 Kafka 消费 lag 都正常,JVM 内存也没有异常,checkpoint 也成功。现象是最诡异的那种:不是完全错误,而是有些渠道对、有些渠道不对,然后过一阵子又自己恢复了。
5.2 排查链路:从Watermark到事件时间字段
我当时的排查链路大概是这样的,给你做个参考。
第一步,看 Watermark。当时我在 Flink UI 上看到订单流的 Watermark 一直维持在 10 分钟前不动,而且有规律地波动。这基本说明事件时间没有被正常推进。于是我去看上游 Kafka 里的数据,发现订单数据的事件时间字段有些是 create_time,有些是 update_time,两个字段混着用。
第二步,拉出具体数据对比。我选取了一个统计错误的渠道,找出其中一条订单和对应支付记录,发现订单的 create_time 是 10:00,支付的 pay_time 是 10:20。订单流按 create_time 分配的时间戳是 10:00,支付流按 pay_time 分配的时间戳是 10:20,两个时间差了 20 分钟。
第三步,窗口设定是 5 分钟的滚动窗口,Watermark 允许乱序延迟是 10 秒。10:00 的订单和 10:20 的支付,永远不可能出现在同一个 5 分钟窗口里。之前“偶尔正确”的原因,是下游有一条补数链路,每天凌晨会重新算一次,把白天没 join 上的数据补进去,所以大屏白天错、第二天早上又对。
5.3 根因:时间语义被搞混
根因其实不是 Flink 的问题,而是我对上游数据的时间字段定义没排查干净。create_time 是业务发生时间,update_time 是数据变更时间。上游在数据变更时会更新 update_time,并且重新发一条消息到 Kafka,但这条消息的业务时间仍然是原来的 create_time。我最初的设计为了让数据看起来更新鲜,在消费端用了 update_time 作为事件时间,直接导致订单流和支付流的时间基准错位。
这其实是多流 Join 里最容易踩、也最难排查的一类问题:你必须保证所有参与 Join 的流,事件时间字段的业务含义完全一致。如果左边流用“业务发生时间”,右边流用“数据变更时间”,两个时间字段本来就存在时间差,无论你用什么窗口、什么 Join 类型,结果都不可信。
5.4 修复方案与效果对比
修复分三步走。
第一步,把两条流的事件时间统一改成业务发生时间。订单流用订单创建时间,支付流用支付完成时间。Kafka 消息里如果带有多个时间字段,明确约定只用 event_time。
第二步,把 Watermark 策略从固定延迟 10 秒改成了延迟 5 分钟。为什么这么改?因为支付回调在某些银行渠道下会有较明显的延迟,高峰时段个别支付通知能晚到几分钟。延迟设太短,合法数据会被丢;设太长,窗口触发会变慢。5 分钟是当时结合线上数据分布得出的折中值。
第三步,给窗口 Join 增加了 sideOutputLateData 兜底,晚到的数据不丢弃,放入侧输出流,每天定时用离线任务重算这部分。
改完之后,大屏数据基本稳定了,偶尔还有极个别晚到数据,也能通过侧输出流补数抵掉。我之前写过一个经验总结:如果你发现 join 对不上,先别急着换 Join 类型,先把两边的事件时间字段逐个检查一遍。
5.5 事后反思:延迟数据的兜底机制
这个 case 给了我两个很深的教训。第一,延迟数据的处理必须提前设计,不能等到出问题了再补。从一开始就分好侧输出流,把 join 不上和迟到的数据接出来单独观察,比事后翻日志高效得多。第二,多流 Join 的正确性不能只靠单条任务保证,要配合离线或周期性的对账任务,定期把实时结果和离线算出的结果比对,偏差超过阈值就告警。做实时的人很容易陷入“任务不挂就万事大吉”的误区,其实数据对不对,比任务跑不跑更重要。
6. 多流Join工程落地的几个“度量衡”
6.1 状态大小:RocksDB与TTL
多流 Join 最终都要落到状态管理上。窗口 Join 的状态大小约等于“窗口时长 × 流峰值速率”,Interval Join 的状态大小是“上下界之差 × 流速率”。在做方案设计的时候,我一般会先估算这个值,再决定用什么状态后端。
状态后端我统一用 RocksDB,因为它能支撑大状态,配合增量 checkpoint 也不会像 Heap 状态后端那样频繁 Full GC。但 RocksDB 不是万能的,状态太大会导致 checkpoint 变慢、恢复时间变长。所以我给每个 keyed state 都设置了 TTL,比如订单 Join 产生的状态只保留 3 天,超过自动清理。这里有个坑要注意:State TTL 的清理机制是惰性清理,只有在状态被访问或执行 checkpoint 时才会真正删掉,所以 TTL 不意味着状态盘上立刻变小,它只是保证逻辑上不会再命中这些过期数据。
6.2 事件时间与乱序容忍度
事件时间语义是我在每次设计中都会强调的东西。多流 Join 的正确性基石,是所有参与 Join 的流都基于同一个时间语义。乱序容忍度的设置不是拍脑袋,我一般这样定:先统计上游数据端到端延迟的 P95 值,Watermark 延迟设置为略大于这个值,比如 P95 是 3 分钟,我设 5 分钟;如果业务对实时性要求高,就设 2 分钟,然后把超出 2 分钟的迟到数据放到侧输出流处理。这样做的目的是在“实时性”和“正确性”之间取得一个动态平衡。
6.3 为什么大厂不建议把所有流都怼进一个多表Join
网上有一个讨论特别多的话题:为什么大厂不建议使用多表 Join。这个在离线数仓里说的是“别把十几张表 join 成一个超大 SQL”,因为性能和维护性都很差。在 Flink 场景下,多表 Join 的问题更严重:每多一路流参与 join,状态量就会线性甚至指数上涨;多流之间的时间对齐也会变得更复杂;任何一个上游源头出现问题,整个 join 结果都会受到影响。
所以我在设计实时链路时,会参考离线数仓的分层思想,不是把一堆流直接怼在一起,而是先对单流做清洗、去重和聚合,把数据量降下来,再进入多流关联环节。大厂里常见的做法是:先用 Flink 把多个源头清洗成统一的明细流,再落一次 Kafka,后续再按需 join。这样每一层只做一件事,出问题时也好排查。
6.4 实时数仓分层的思路
把多流 Join 放到一个更大的视角里看,你会发现它只是实时数仓某一层的工作。我现在的通用做法是:ODS 层负责接入 Kafka,DWD 层负责清洗和明细构建,DIM 层放维表,DWS 层做多流 Join 和轻度汇总,ADS 层直接服务报表和大屏。多流 Join 尽量放在 DWS 层,并且在进入这一层之前,每条流都已经完成过滤、补全、时间规范化这三步预处理。
这几步预处理特别重要。过滤是去掉垃圾数据和测试数据,补全是填充缺失字段和默认值,时间规范化是把所有流的时间字段统一成同一个语义和时区。做完这三步,后面的多流 Join 才能真正稳定。如果省略这些,你写的每一个 Join 都可能是定时炸弹,只是爆炸时间不确定而已。
我自己现在接到一个多流 Join 需求,会先问三个问题:这两条流的时间字段是否统一?数据超过多久算迟到?迟到的数据往哪里去?这三个问题有了明确答案,我才开始写代码。这个习惯帮我省掉了大量线上排查的精力,也希望你早点养成。
