【Flink SQL 学习笔记 三】维表关联:Lookup Join、Temporal Join、窗口 Join

前两篇讲了流怎么进来、怎么聚合、怎么按窗口切分。但订单流里只有 ID------city_idproduct_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_idcity_001city_002product_idprod_1001prod_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 里显式声明:

sql 复制代码
CREATE 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_nameprovinceproduct_nameprice 都补上了,订单 ID 还是原来的,说明关联成功。再抽几条价格变化前后的订单,验证价格是不是按下单时的版本取的,而不是取最新价。


六 小结

  1. 维表的作用是补全信息------把订单流里的 ID 翻译成业务可读的名称和属性
  2. Lookup Join 是最常用的------来一条查一条,靠缓存扛压,适合静态或变化慢的维表(城市、分类)
  3. Temporal Join 解决维表变化问题------按事件时间取当时的版本,价格、汇率这类会影响金额的必须用它
  4. 窗口 Join 是流和流的关联------两条流在同一个窗口内配对,适合订单&支付、点击&转化这类场景
  5. 选型一句话:补静态信息用 Lookup,补会变的属性用 Temporal,两条流配对用窗口 Join
  6. 生产上一般用 LEFT JOIN------查不到维表数据时保留主表数据,避免丢订单
相关推荐
你想知道什么?20 分钟前
提示词工程-学习笔记
笔记·学习
minglie121 分钟前
vivado中视频ip相关接口
学习
redfred30 分钟前
DBeaver 使用教程:开源免费数据库管理工具 100+ 数据库连接 SQL编辑器 ER图 AI生成SQL 全平台
数据库·sql·开源
山峰哥36 分钟前
数据库工程:Explain执行计划对比调优实战‌
大数据·数据库·sql·编辑器·深度优先
m4Rk_1 小时前
【论文阅读】Agent 记忆机制(53):Experience-Following——为什么错误经验会在记忆中不断传播
论文阅读·人工智能·学习·开源·github
额,不知道写啥。2 小时前
从区间加一次函数到区间加多次函数最值-----区间数据结构与函数的一些东西(《浅谈函数最值的动态维护》的学习笔记)
数据结构·笔记·学习
艾莉丝努力练剑3 小时前
【AI大模型接入SDK】Provider分析与实现
c++·人工智能·学习·面试·大模型·llm
浔溺10 小时前
al+大数据每日学习笔记26
笔记·学习
xx~t10 小时前
嵌入式——进程与线程1
linux·c语言·学习·嵌入式·进程与线程