前两篇讲了流怎么进来、怎么聚合、怎么按窗口切分。但订单流里只有 ID------
city_id、product_id,大盘看板上总不能给运营看一堆数字吧?得把 ID 换成名称:北京、上海、iPhone 15、MacBook Pro。这就是 Join 干的事:把订单流和维表关联起来,补全业务可读的字段。
本文要点
- 为什么需要维表:ID 不是给人看的,业务需要名称和属性
- Lookup Join:来一条查一条,实时补全维表信息
- Temporal Join:维表会变,要按事件时间取当时的版本
- 窗口 Join:两条流在同一个窗口内配对
- 完整任务:订单流关联城市维表 + 商品维表,输出宽表到 Paimon
零 维表是什么
1 事实表 vs 维表
数仓里有两个基本概念:事实表和维表。
事实表存的是"发生了什么事"------订单、支付、点击、浏览。每条记录是一个事件,数量很大,还在源源不断地产生。订单流就是事实表:每来一条就是一个下单事件,有订单号、金额、时间。
维表存的是"这个东西是什么"------城市、商品、用户。每条记录是一个实体的描述信息,数据量不大,变化也慢。城市维表可能就几百条,商品维表几万条,跟每天几十万订单比,差了好几个数量级。
用订单场景打个比方:
- 订单表说:"有人买了个东西,花了 5999 元,在 city_001"
- 维表说:"city_001 是北京,那个东西是 iPhone 15"

事实表记动作,维表记属性。Join 就是把属性补到动作上,让事实变得可读。
2 为什么需要维表
前两篇的 Source 表长这样:
sql
CREATE TABLE orders_source (
order_id BIGINT,
city_id STRING,
product_id STRING,
amount DECIMAL(10, 2),
order_time TIMESTAMP(3),
WATERMARK FOR order_time AS order_time - INTERVAL '5' SECOND
) WITH ( ... );
city_id 是 city_001、city_002,product_id 是 prod_1001、prod_1002。机器能算,但运营看不懂------看板上写着 city_001 订单 1200 单,没人知道 city_001 是北京还是上海。
维表补的就是这层信息:
| city_id | city_name | province |
|---|---|---|
| city_001 | 北京 | 北京 |
| city_002 | 上海 | 上海 |
| city_003 | 深圳 | 广东 |
订单流来一条 city_001,去维表里查一下,补上 北京,输出给下游看板。这就是 Join 最朴素的需求------把 ID 翻译成名称。
Flink SQL 里 Join 有三种主流用法,适用场景不同,一个一个说。
一 Lookup Join:来一条查一条
1 是什么
Lookup Join 是最常用的维表关联方式。订单流来一条数据,Flink 就去维表里查一下对应 ID 的信息,查出来拼在一起输出。

语法很简单,跟普通 SQL 的 JOIN 差不多,区别只在维表的定义上:
sql
-- 城市维表(Lookup 源,Paimon)
CREATE TABLE city_dim (
city_id STRING,
city_name STRING,
province STRING,
PRIMARY KEY (city_id) NOT ENFORCED
) WITH (
'connector' = 'paimon',
'warehouse' = 'hdfs://namenode:8020/paimon/warehouse',
'table-name' = 'dim.city_dim',
'lookup.cache.max-rows' = '10000',
'lookup.cache.ttl' = '10min'
);
参数解释:
| 参数 | 值 | 含义 |
|---|---|---|
connector |
paimon |
维表的连接器类型。paimon 是现在生产常用的,维表直接存在 Paimon 里,和事实表共用存储,流批一体更方便。也支持 jdbc 连 MySQL/PostgreSQL |
warehouse |
hdfs://... |
Paimon 仓库地址(jdbc 连接器不用这个,换成 url 即可) |
table-name |
dim.city_dim |
维表的表名,格式是 库名.表名 |
lookup.cache.max-rows |
10000 |
本地缓存最多存多少行,满了就淘汰最久没用的。城市维表才几百条,设 1 万完全够 |
lookup.cache.ttl |
10min |
缓存存活时间------一条数据缓存 10 分钟后过期,下次再查就重新去维表取 |
sql
-- 关联查询
SELECT
o.order_id,
o.city_id,
c.city_name,
o.amount,
o.order_time
FROM orders_source AS o
JOIN city_dim FOR SYSTEM_TIME AS OF o.proc_time AS c
ON o.city_id = c.city_id;
FOR SYSTEM_TIME AS OF o.proc_time 是 Lookup Join 的标志------表示"用订单表的处理时间,去城市维表里查当前最新的版本"。因为维表数据可能会变(比如城市改名),但 Lookup Join 不关心历史版本,只查"现在"是什么。
模板:
sql
SELECT ...
FROM 主流表 AS 主表别名
JOIN 维表 FOR SYSTEM_TIME AS OF 主表别名.时间字段 AS 维表别名
ON 主表别名.关联字段 = 维表别名.关联字段;
FOR SYSTEM_TIME AS OF 是 SQL 标准里的固定关键字,意思是"按某个时间点去看维表的版本"。后面跟的时间字段决定了语义:
- Lookup Join 用处理时间:
o.proc_time→ 查当前最新值 - Temporal Join 用事件时间:
o.order_time→ 查事件发生时的版本
proc_time从哪来的?DDL 里没有定义
proc_time字段对吧?它是 Flink 的处理时间属性,不是真实数据字段,需要在 DDL 里显式声明:
sqlCREATE TABLE orders_source ( order_id BIGINT, city_id STRING, amount DECIMAL(10, 2), order_time TIMESTAMP(3), -- 处理时间:计算列,Flink 自动填入当前机器时间 proc_time AS PROCTIME(), WATERMARK FOR order_time AS order_time - INTERVAL '5' SECOND ) WITH ( ... );
AS PROCTIME()是计算列语法------这个字段不存数据,每次访问时 Flink 自动返回当前机器时间。
2 缓存很重要
每条订单都去查一次维表,QPS 高了维表扛不住。所以 Lookup Join 一般都开缓存:
lookup.cache.max-rows:最多缓存多少行,满了淘汰旧的lookup.cache.ttl:缓存多久过期,过期后重新查
开了缓存后,city_001 第一次来的时候查 Paimon,结果缓存 10 分钟。10 分钟内再来 city_001 的订单,直接从缓存里拿,不用查维表。城市维表才几百条数据,缓存命中率接近 100%,几乎没什么维表查询压力。
3 一致性和适用场景
Lookup Join 是最终一致性,不是强一致。维表更新后,Flink 不会立刻感知到------要等缓存过期了重新查,才能拿到新值。两种典型场景:
- 缓存没过期:城市名改了,但缓存还有 8 分钟才过期,这 8 分钟内的订单关联的还是旧名字
- 维表本身批量更新:比如城市维表每天凌晨跑批刷一次,批任务跑完之前,Flink 查到的都是旧数据。跑完后缓存慢慢过期,新数据才逐渐变正确
所以 Lookup Join 适合维表数据量不大、变化不频繁、能容忍短暂不一致的场景。城市、商品分类、用户等级这些维表,数据量几千到几万,10 分钟缓存完全够用。如果价格、汇率这种错了业务就对不上的,用下一节的 Temporal Join。
已经写错的数据怎么办?
缓存过期后新数据是对的,但已经写入 Paimon 的旧数据不会自动修正。两种处理方式:
- 不兜底:城市名、分类标签这种,错几条、错几小时业务上无所谓,接受不精确
- 批任务兜底:每天凌晨跑离线批任务,用正确的维表全量回刷前一天的数据,Paimon 主键表支持 upsert,直接覆盖旧值。流做实时、批做兜底,是数仓里常见的"流批一体"兜底方案
二 Temporal Join:维表会变,要取当时的版本
前面用城市维表讲了 Lookup Join------城市名基本不变,缓存 10 分钟完全够用。但有些维表是会频繁变的,而且变了之后历史数据也得用当时的值算,不能用最新值。最典型的就是商品价格,这一节换商品维表(SKU 维表)来讲。
1 Lookup Join 的问题
商品价格是会变的。iPhone 15 平时卖 5999,大促降到 5499,过完节又涨回去。如果用 Lookup Join 关联商品维表,问题就来了:
- 用户 08:00 下单,当时价格 5999
- 09:00 价格改成 5499
- 10:00 这条订单数据因为延迟才到 Flink,去查维表查到的是 5499
- 结果:订单实际支付 5999,统计的时候按 5499 算了,GMV 对不上
Lookup Join 查的是"现在"的维表,不是"下单时"的维表。 价格、税率、用户等级这些会随时间变化的属性,用 Lookup Join 会算错。
2 Temporal Join 怎么解决
Temporal Join(时态 Join)的思路是:维表保存每个时间点的版本,关联的时候按下单时的事件时间去取当时的版本,而不是取最新版本。

这需要维表本身是带版本的------Paimon 主键表默认就是 changelog 模式,每一次价格变动都会产生 +U/-U 的变更记录,Flink 以流模式读取时拿到完整的 changelog 流,所有历史版本都存在 State 里,关联时按事件时间取当时的版本:
sql
-- 商品维表(Paimon 主键表,changelog 流)
CREATE TABLE product_dim (
product_id STRING,
product_name STRING,
price DECIMAL(10, 2),
update_time TIMESTAMP(3),
WATERMARK FOR update_time AS update_time,
PRIMARY KEY (product_id) NOT ENFORCED
) WITH (
'connector' = 'paimon',
'warehouse' = 'hdfs://namenode:8020/paimon/warehouse',
'table-name' = 'dim.product_dim'
);
sql
-- 按下单时的事件时间关联商品维表
SELECT
o.order_id,
o.product_id,
p.product_name,
p.price,
o.amount,
o.order_time
FROM orders_source AS o
JOIN product_dim FOR SYSTEM_TIME AS OF o.order_time AS p
ON o.product_id = p.product_id;
注意这里用的是 o.order_time(事件时间),不是 o.proc_time(处理时间)。意思是:这条订单的事件时间是 08:00,就去商品维表里找 08:00 那一刻的价格版本------不管现在价格是多少,只认下单时的价格。
3 适用场景
Temporal Join 适合维表有版本变化、必须按事件时间取当时值的场景。最典型的就是商品价格、汇率、税率------这些属性直接影响金额计算,错了 GMV 就对不上。
三 窗口 Join:两条流在窗口内配对
1 为什么需要窗口 Join
前面两种都是"流 + 维表"的关联。有时候需要的是"流 + 流"------比如订单流和支付流,要找出"下单后 5 分钟内完成支付的订单"。两条都是流,都在不停来数据,怎么配对?
窗口 Join 的思路是:给两条流都开窗口,在同一个窗口内的数据互相关联,窗口关闭时输出匹配结果。

2 怎么写
sql
SELECT
o.order_id,
o.amount AS order_amount,
p.pay_amount,
p.pay_time
FROM TABLE(
TUMBLE(TABLE orders_source, DESCRIPTOR(order_time), INTERVAL '5' MINUTE)
) AS o
JOIN TABLE(
TUMBLE(TABLE pay_source, DESCRIPTOR(pay_time), INTERVAL '5' MINUTE)
) AS p
ON o.order_id = p.order_id
AND o.window_start = p.window_start
AND o.window_end = p.window_end;
两条流各自开 5 分钟的滚动窗口,然后按 order_id + 窗口边界关联。意思是:同一个 5 分钟窗口内,订单流和支付流里 order_id 对上的,就算匹配成功。
3 关键细节
两条流都必须有 Watermark ------窗口 Join 是事件时间语义,两条流各自按自己的 Watermark 推进窗口。订单流允许 5 秒乱序、支付流允许 10 秒乱序,各设各的,互不影响。窗口关闭以两条流中较慢的那条为准------订单流的 Watermark 到了 01:00 但支付流还没到,窗口不会关,得等支付流的 Watermark 也到了才算。
两条流的窗口必须对齐------窗口类型、窗口大小、时间字段都得一致,否则窗口对不上,关联不上。
窗口内的笛卡尔积------窗口 Join 是两条流窗口内所有数据的两两匹配。如果一个窗口内订单流有 100 条、支付流有 100 条,最坏情况要比 10000 次。所以窗口不能开太大,否则 State 和计算量都会爆炸。
只输出匹配上的------默认是 INNER JOIN,只有两边都有的才输出。下单了没支付的、支付了没找到订单的,默认都丢掉。要保留的话用 LEFT JOIN 或 FULL JOIN。
4 适用场景
窗口 Join 适合两条流、有共同 key、时间上有关联、在一定时间范围内能配对上的场景。订单和支付、点击和转化、消息发送和消息回执,都是典型的窗口 Join 场景。
四 三种 Join 怎么选
| Lookup Join | Temporal Join | 窗口 Join | |
|---|---|---|---|
| 关联对象 | 流 + 外部维表(Paimon/JDBC) | 流 + 版本维表(Paimon changelog 流) | 流 + 流 |
| 时间语义 | 处理时间(查当前值) | 事件时间(查当时值) | 事件时间(窗口对齐) |
| 维表变化 | 不关心历史,只取最新 | 保留所有版本,按时间取 | 两条流都是动态的 |
| 典型场景 | 城市名、商品名、用户等级 | 商品价格、汇率、税率 | 订单&支付、点击&转化 |
| 性能 | 靠缓存扛,缓存命中高很快 | State 存版本,越久 State 越大 | 窗口内笛卡尔积,窗口不能太大 |
一句话总结:补静态信息用 Lookup,补会变的属性用 Temporal,两条流配对用窗口 Join。
五 完整任务:订单宽表
1 需求
把订单流和城市维表、商品维表关联起来,输出一张订单宽表到 Paimon。宽表包含:订单号、城市名、省份、商品名、商品价格、订单金额、下单时间。
城市维表用 Lookup Join(城市名不会变,缓存够用),商品维表用 Temporal Join(价格会变,要按下单时的价格算)。
2 Source 表
sql
-- 订单流
CREATE TABLE orders_source (
order_id BIGINT,
city_id STRING,
product_id STRING,
amount DECIMAL(10, 2),
order_time TIMESTAMP(3),
proc_time AS PROCTIME(),
WATERMARK FOR order_time AS order_time - INTERVAL '5' SECOND
) WITH (
'connector' = 'datagen',
'rows-per-second' = '100',
'fields.city_id.kind' = 'random',
'fields.city_id.length' = '6',
'fields.product_id.kind' = 'random',
'fields.product_id.length' = '8',
'fields.amount.min' = '10',
'fields.amount.max' = '1000'
);
-- 城市维表(Lookup,Paimon)
CREATE TABLE city_dim (
city_id STRING,
city_name STRING,
province STRING,
PRIMARY KEY (city_id) NOT ENFORCED
) WITH (
'connector' = 'paimon',
'warehouse' = 'hdfs://namenode:8020/paimon/warehouse',
'table-name' = 'dim.city_dim',
'lookup.cache.max-rows' = '10000',
'lookup.cache.ttl' = '10min'
);
-- 商品维表(Paimon 主键表,changelog 流)
CREATE TABLE product_dim (
product_id STRING,
product_name STRING,
price DECIMAL(10, 2),
update_time TIMESTAMP(3),
WATERMARK FOR update_time AS update_time,
PRIMARY KEY (product_id) NOT ENFORCED
) WITH (
'connector' = 'paimon',
'warehouse' = 'hdfs://namenode:8020/paimon/warehouse',
'table-name' = 'dim.product_dim'
);
3 Sink 表
sql
CREATE TABLE dwd_order_wide (
order_id BIGINT,
city_id STRING,
city_name STRING,
province STRING,
product_id STRING,
product_name STRING,
price DECIMAL(10, 2),
amount DECIMAL(10, 2),
order_time TIMESTAMP(3),
PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
'connector' = 'paimon',
'path' = 's3://warehouse/dwd/dwd_order_wide',
'bucket' = '2'
);
4 写入 SQL
sql
INSERT INTO dwd_order_wide
SELECT
o.order_id,
o.city_id,
c.city_name,
c.province,
o.product_id,
p.product_name,
p.price,
o.amount,
o.order_time
FROM orders_source AS o
-- Lookup Join 关联城市维表(静态信息,用处理时间查最新)
LEFT JOIN city_dim FOR SYSTEM_TIME AS OF o.proc_time AS c
ON o.city_id = c.city_id
-- Temporal Join 关联商品维表(价格会变,用事件时间查当时版本)
LEFT JOIN product_dim FOR SYSTEM_TIME AS OF o.order_time AS p
ON o.product_id = p.product_id;
这里用了 LEFT JOIN------即使维表里查不到对应数据,订单数据也保留,只是名称字段为 NULL。如果用 INNER JOIN,查不到维表数据的订单会被直接丢掉,生产上一般不这么干。
5 验证
任务跑起来后,用 Paimon 读一下宽表:
sql
SELECT * FROM dwd_order_wide LIMIT 10;
能看到 city_name、province、product_name、price 都补上了,订单 ID 还是原来的,说明关联成功。再抽几条价格变化前后的订单,验证价格是不是按下单时的版本取的,而不是取最新价。
六 小结
- 维表的作用是补全信息------把订单流里的 ID 翻译成业务可读的名称和属性
- Lookup Join 是最常用的------来一条查一条,靠缓存扛压,适合静态或变化慢的维表(城市、分类)
- Temporal Join 解决维表变化问题------按事件时间取当时的版本,价格、汇率这类会影响金额的必须用它
- 窗口 Join 是流和流的关联------两条流在同一个窗口内配对,适合订单&支付、点击&转化这类场景
- 选型一句话:补静态信息用 Lookup,补会变的属性用 Temporal,两条流配对用窗口 Join
- 生产上一般用 LEFT JOIN------查不到维表数据时保留主表数据,避免丢订单