1. Regular Join:最灵活、也最"重"的 Join
Regular Join 就是大家最熟悉的那种:
sql
SELECT *
FROM Orders
INNER JOIN Product
ON Orders.product_id = Product.id;
和离线 SQL 看起来几乎一样,但在 Flink 流式语义下,有几个关键差异:
1.1 流式 Regular Join 的"代价"
在流式查询里,输入是无界数据流,Regular Join 的语义是:
任意一侧来一条新数据或变更,都要和另一侧所有历史和未来记录做 Join(满足 Join 条件的)。
为了支撑这种语义,Flink 必须:
- 把 Join 两侧的所有记录都放在状态里;
- 保证后面来的数据都能拿到历史数据做 Join。
这意味着什么?------状态可能无限增长 。
状态大小取决于:
- 两侧表的去重行数;
- 多表链式 Join 的中间结果数量。
所以 Flink 文档才反复强调:
一定要结合业务设置合理的 State TTL,否则 Regular Join 可以轻松把你打到 OOM。
1.2 内等值 Join vs 外等值 Join
目前 Flink Regular Join:
-
支持 Inner Join / Left Join / Right Join / Full Outer Join;
-
Join 条件必须是 等值 Join(Equi-Join),也就是:
- Join 条件中至少有一个
a.col = b.col之类的等值谓词;
- Join 条件中至少有一个
-
不支持:
- 纯笛卡尔积;
- Theta Join(
a.x > b.y这类单纯不等式)作为唯一 Join 条件。
示例:
sql
-- 内等值 Join
SELECT *
FROM Orders
INNER JOIN Product
ON Orders.product_id = Product.id;
-- 左外等值 Join
SELECT *
FROM Orders
LEFT JOIN Product
ON Orders.product_id = Product.id;
-- 右外等值 Join
SELECT *
FROM Orders
RIGHT JOIN Product
ON Orders.product_id = Product.id;
-- 全外等值 Join
SELECT *
FROM Orders
FULL OUTER JOIN Product
ON Orders.product_id = Product.id;
1.3 多表 Regular Join:优先考虑 MultiJoin
多表链式 Join 的一个典型坑是:
A JOIN B JOIN C JOIN D,如果直接按写的顺序算,很有可能中间状态巨大。
Flink 提供了一个 MultiJoin 算子,用来对多表 Join 做合并和优化(具体使用可以看官方 tuning 文档),实战建议是:
- 多表 Join 尽量一次写清楚,让优化器有机会使用 MultiJoin;
- 明确 Join 条件,不要写出"隐式笛卡尔积"。
2. Interval Join:按时间区间做事件关联
Interval Join 的典型场景是:
"订单和支付/发货根据时间做关联,只允许在一个时间区间内 Join。"
例如:
sql
SELECT *
FROM Orders o, Shipments s
WHERE o.id = s.order_id
AND o.order_time BETWEEN s.ship_time - INTERVAL '4' HOUR AND s.ship_time;
语义:
- 订单 ID 相等;
- 且订单时间在
[ship_time - 4 小时, ship_time]这个时间范围内。
2.1 Interval Join 的约束
相较于 Regular Join,Interval Join 更"克制":
-
只支持 append-only + 时间属性 的表;
-
Join 条件要求:
- 至少一个等值 Join;
- 再加上时间区间谓词:
合法的时间条件示例:
sql
ltime = rtime
ltime >= rtime AND ltime < rtime + INTERVAL '10' MINUTE
ltime BETWEEN rtime - INTERVAL '10' SECOND AND rtime + INTERVAL '5' SECOND
因为时间属性是单调递增(或准单调,考虑乱序 + watermark),Flink 能:
- 利用 watermark 判断哪些数据的 Join 再也不会被触发;
- 从状态中安全地清理这些旧记录。
一句话:Interval Join = 带时间窗口约束的 Join,在保证语义的前提下,大幅降低状态压力。
3. Temporal Join:给事实流做"时点维度补全"
Temporal Join(时态 Join)是 Flink 流式数仓里的关键能力之一,本质是:
对一个不断变化的维度表,在某个给定时间点回放出"当时的版本",并和事实流 Join。
这类场景在业务里非常常见:
- 币种汇率表、商品价格表、用户等级表;
- 订单/行为在当天看和第二天看,看到的维度信息可能不同。
Flink 提供两类时态 Join:
- Event Time Temporal Join:按事件时间上的"过去状态" Join;
- Processing Time Temporal Join:按处理时间上的"当前最新状态" Join。
3.1 事件时间时态 Join:还原"当时"的维度状态
典型例子就是"订单 + 汇率":
订单按下单时刻的汇率换算成 USD,而不是按当前最新汇率。
DDL 示例前面已经翻译过,这里只看核心 SQL:
sql
SELECT
order_id,
price,
orders.currency,
conversion_rate,
order_time
FROM orders
LEFT JOIN currency_rates FOR SYSTEM_TIME AS OF orders.order_time
ON orders.currency = currency_rates.currency;
语义:
- 左侧是订单表,
order_time有 watermark; - 右侧是版本化汇率表,主键
currency,update_time有 watermark; - 对每一条订单,用"订单的事件时间
order_time"去汇率表里查找当时版本; - 之后无论汇率如何变化,之前的 Join 结果都不会被更新。
注意两点:
-
Join 条件必须包含右表主键的等值条件:
orders.currency = currency_rates.currency。 -
watermark 的设置会决定:
- 等多长时间才认为"某个时间点之前的版本可以清理";
- 系统在等待乱序事件时的容忍度。
3.2 处理时间时态 Join:以"当前最新值"做维度补全
处理时间时态 Join 更接近"Lookup" 的语义:
每条事实流数据来到时,用当前最新的维度数据来补全字段。
典型用法:
sql
SELECT
o_amount, r_rate
FROM
Orders,
LATERAL TABLE (Rates(o_proctime))
WHERE
r_currency = o_currency;
特点:
- 不会保留维度表的历史版本;
- 同一个 key 不同时间 Join,得到的结果可能不同(最新值);
- 结果是非确定的,更适合"实时看当前状态"的场景,而不是"严格还原历史状态"。
4. Lookup Join:最常用的"流 + 外部维表"补全方式
Lookup Join 基本可以视为"处理时间时态 Join + 外部维表 Connector"。
最常见的场景:
实时行为/订单流 + MySQL/HBase/Redis 中的维度表,做字段补全。
DDL 示例:
sql
CREATE TEMPORARY TABLE Customers (
id INT,
name STRING,
country STRING,
zip STRING
) WITH (
'connector' = 'jdbc',
'url' = 'jdbc:mysql://mysqlhost:3306/customerdb',
'table-name' = 'customers'
);
SELECT o.order_id, o.total, c.country, c.zip
FROM Orders AS o
JOIN Customers FOR SYSTEM_TIME AS OF o.proc_time AS c
ON o.customer_id = c.id;
这里面有几个关键信息:
-
Orders 是主流,带
proc_time; -
Customers 是 Lookup 表,由
jdbcconnector 提供; -
FOR SYSTEM_TIME AS OF o.proc_time表示:- 在处理该订单的那一刻,从 MySQL 读一行数据;
- 后面 MySQL 里的数据变了,也不会回刷历史 Join 结果。
Lookup Join 的典型特性:
- 延迟取决于外部系统的响应时间;
- 不保留历史版本,仅使用当前最新记录;
- Join 条件必须是等值条件,类似主键或索引字段。
5. UNNEST / 数组 & Map & Multiset 展开
在业务里经常会看到 "一行里面嵌一个数组/Map",比如:
- 一条订单带多个商品;
- 一条记录里面有一个 Map 或 multiset 统计结构。
UNNEST 就是用来把这些嵌套结构拆平的利器。
5.1 数组展开
sql
-- 常量数组展开
SELECT * FROM (VALUES('order_1')), UNNEST(ARRAY['shirt', 'pants', 'hat']);
-- 表字段数组展开
SELECT order_id, product_name
FROM Orders
CROSS JOIN UNNEST(product_names) AS t(product_name);
5.2 WITH ORDINALITY:顺带把下标也拿出来
sql
SELECT *
FROM (VALUES ('order_1'), ('order_2'))
CROSS JOIN UNNEST(ARRAY['shirt', 'pants', 'hat'])
WITH ORDINALITY AS t(product_name, index);
index从 1 开始;- 对数组:顺序有保证;
- 对 Map / multiset:顺序不保证。
Map 展开时:
sql
SELECT *
FROM
(VALUES('order_1'))
CROSS JOIN UNNEST(MAP['shirt', 2, 'pants', 1, 'hat', 1]) WITH ORDINALITY;
multiset 展开时,如果某个元素 multiplicity 为 2,就会返回两行。
6. 表函数 Join:一拖多的"动态展开"
表函数 Join + LATERAL TABLE 的语义是:
左表每一行作为参数,调用一次表函数,将返回的多行和这行 Join。
6.1 Inner Join 表函数
如果表函数返回空结果,就直接丢弃这一行:
sql
SELECT order_id, res
FROM Orders,
LATERAL TABLE(table_func(order_id)) t(res);
6.2 Left Outer Join 表函数
如果表函数返回空结果,左侧行保留,右侧填 NULL:
sql
SELECT order_id, res
FROM Orders
LEFT OUTER JOIN LATERAL TABLE(table_func(order_id)) t(res)
ON TRUE;
常见用法:
- 对某一字段做拆分、解析(比如 JSON → 多行、多列);
- 对一行文本切词,输出多行关键词;
- 对一行调用外部服务,返回多行候选结果。
7. 如何给 Join"选型"?一张表看清楚
| 场景诉求 | 推荐 Join 类型 |
|---|---|
| 任意两张动态表全量 Join,语义最接近离线 SQL | Regular Join |
| 两个事件流按 ID + 时间区间 Join | Interval Join |
| 事实流回放某一时刻的维表版本 | Event Time Temporal Join |
| 事实流实时按当前最新维表信息做补全 | Processing Time Temporal Join / Lookup Join |
| 用数组/Map/multiset 拆平成多行 | UNNEST +(可选)WITH ORDINALITY |
| 每行调用一个 UDTF 返回多行并 Join | LATERAL TABLE / 表函数 Join |
再加一句非常关键的实战经验:
Regular Join 和无脑多表 Join 是生产事故高发区。
在流式场景下,一定要考虑:
- 状态大小;
- watermark 与 TTL;
- 是否可以改写为 Interval Join / Temporal Join / Lookup Join;
- 或者增加窗口约束,将 Join 范围限制在"业务合理的时间段"。
8. 小结
这篇我们从"语义 + 代价 + 典型场景"的角度,把 Flink SQL 的 Join 家族梳理了一遍:
- Regular Join:最像离线 SQL 的 Join,但状态压力最大;
- Interval Join:用时间区间限制 Join,控制状态大小;
- Temporal Join:按时间版本从维度表里取"当时的值",是流式数仓里非常核心的能力;
- Lookup Join:事实流实时补充外部维表信息的主力方案;
- UNNEST & 表函数 Join:负责把半结构化/嵌套数据"拆碎摊平"。