Paimon Lookup Join 详解
Lookup Join 是 Paimon 特有的一种维表关联方式。它不是标准 SQL 里的 join 类型,而是 Paimon 根据自身 LSM 存储特性设计出来的 *快照查询式 Join*。
一、先定位 Lookup Join 在 Paimon Join 体系中的位置
Paimon 作为 Flink 的表存储,提供了四种 Join 方式:
shell
┌────────────────────────────────────────────────────────────────────┐
│ Paimon Join 体系 │
├──────────────┬──────────────┬───────────────┬──────────────────────┤
│ Normal Join │ Temporal │ Interval Join │ Lookup Join │
│ (双流 Join) │ Join (版本表)│ (时间范围) │ (快照查询) │
├──────────────┼──────────────┼───────────────┼──────────────────────┤
│ 两条流 Join │ 事实流 JOIN │ 流 JOIN │ 流触发 → 查询快照 │
│ 都用 state │ 维度快照 │ 含时间窗口 │ 不维 state │
│ 数据量大时 │ 维度:版本表 │ 如物流轨迹 │ 维度端无 state │
│ state 很大 │ 事实:任意表 │ 时效性 Join │ 维度变更 = 重新查询 │
└──────────────┴──────────────┴───────────────┴──────────────────────┘
*Lookup Join 的本质:* 不是"两条流实时关联",而是"主表来一条数据 → 去维度表查一下当前快照 → 返回结果"。它是****请求-响应模式*,不是*事件时间关联模式****。
二、Lookup Join 的工作机制
2.1 原理
shell
┌───────────────────────────────────────────────────────────────────┐
│ Lookup Join 流程 │
│ │
│ 事实流(主表) Paimon 维度表 │
│ ┌─────────┐ ┌──────────────────┐ │
│ │ Event 1 │──── lookup ──────▶│ full-compaction │ │
│ │ user=A │◀── name="张三"───│ 的最新快照 │ │
│ └─────────┘ │ │ │
│ ┌─────────┐ │ user=A → 张三 │ │
│ │ Event 2 │──── lookup ──────▶│ user=B → 李四 │ │
│ │ user=B │◀── name="李四"───│ user=C → 王五 │ │
│ └─────────┘ └──────────────────┘ │
│ │
│ 核心: │
│ - 主表每条数据到达时,触发一次对维度表的「点查询」 │
│ - 查询的是维度表 full-compaction 后的最新快照 │
│ - Flink 不维护维度端的 state(或者只缓存查询结果) │
│ - 维度表更新后,新到的事实数据会查到新值 │
└───────────────────────────────────────────────────────────────────┘
2.2 full-compaction 在这里的角色
这是你之前问过的问题,也是 Lookup Join 最容易被误解的地方:
shell
changelog-producer 的两个选项:
┌─────────────────────────────────────────────────────────────────────┐
│ changelog-producer = 'input' │
│ │
│ 输出原始 CDC 消息 (+I / +U / -D) │
│ 每次 compaction 会压缩旧版本,但下游看到的是增量变更流 │
│ 适用于流式聚合、Temporal Join │
├─────────────────────────────────────────────────────────────────────┤
│ changelog-producer = 'lookup' ★ Lookup Join 专用 │
│ │
│ 只有 full-compaction 完成后才生成快照 │
│ 下游查询的是"全量快照",不是增量变更 │
│ 延迟 = compaction interval(compaction 跑得越快,数据越新鲜) │
└─────────────────────────────────────────────────────────────────────┘
*结论:Lookup Join 依赖的是 changelog-producer='lookup',不是 'input'。* 我之前在余额汇总设计里也刻意区分了这个场景。
三、表定义要求
3.1 维度表必须满足的条件
sql
-- ✅ 适用于 Lookup Join 的维度表
CREATE TABLE dim.dim_user (
user_id BIGINT,
name STRING,
city STRING,
tier STRING,
age INT,
PRIMARY KEY (user_id) NOT ENFORCED
) WITH (
'bucket' = '8',
'merge-engine' = 'deduplicate', -- ★ 必须是 deduplicate 或 partial-update
'changelog-producer' = 'lookup', -- ★ 必须是 lookup
'changelog-producer.lookup-wait' = 'true', -- ★ 是否等待 compaction 完成
'compaction.min.file-num' = '3', -- 控制 compaction 频率
'compaction.max.file-num' = '20'
);
| 参数 | 可选值 | Lookup Join 要求 |
|---|---|---|
merge-engine |
deduplicate, partial-update, aggregation, first-row |
****必须****是 deduplicate 或 partial-update |
changelog-producer |
none, input, lookup, full-compaction |
****必须****是 lookup |
changelog-producer.lookup-wait |
true (default), false |
true = 等待 compaction 完成才返回结果(数据一致但慢);false = 立即返回当前可用数据(快但不保证最新) |
3.2 主表没有特殊要求
shell
-- 主表可以是任何流
CREATE TEMPORARY TABLE kafka_source (
user_id BIGINT,
event_type STRING,
amount DECIMAL(18,2),
event_time TIMESTAMP(3),
-- watermark 可选
WATERMARK FOR event_time AS event_time - INTERVAL '5' SECOND
) WITH (
'connector' = 'kafka',
...
);
四、Flink SQL 语法
4.1 基本写法
sql
-- Lookup Join:FOR SYSTEM_TIME AS OF 语法
SELECT
o.user_id,
o.event_type,
o.amount,
o.event_time,
u.name AS user_name,
u.city AS user_city,
u.tier AS user_tier
FROM kafka_source o
LEFT JOIN dim_user
FOR SYSTEM_TIME AS OF o.proctime AS u -- ★ 用处理时间
ON o.user_id = u.user_id;
*FOR SYSTEM_TIME AS OF o.proctime* 的含义:
shell
对主表 o 的每一行,在 proctime(处理时间,即"此时此刻")去查询维度表 dim_user 的最新快照。
注意:不是事件时间(event_time),是处理时间。
这意味着:
- 如果维度表在 10:05 更新,主表事件的事件时间是 9:50...
- 它在 10:06 被处理时,关联到的是 10:06 时刻的维度快照(已更新)
- 这不是"事件发生时的维度值",而是"事件被处理时的维度值"
4.2 完整示例:用户事件关联维度
sql
-- =========================================
-- 设置 Lookup Join 相关参数
-- =========================================
SET 'table.exec.source.idle-timeout' = '60s';
-- =========================================
-- 定义维度表(Paimon,预先建好)
-- =========================================
CREATE TABLE dim_user (
user_id BIGINT,
name STRING,
city STRING,
tier STRING,
-- 如果维度表有 SCD Type 2 的字段,Lookup Join 只拿 is_current=true 的行
update_time TIMESTAMP(3),
PRIMARY KEY (user_id) NOT ENFORCED
) WITH (
'connector' = 'paimon',
'path' = 'hdfs:///warehouse/dim.db/dim_user',
-- 维度表的 Paimon 配置在建表时已设定:
-- changelog-producer = 'lookup'
-- merge-engine = 'deduplicate'
'lookup.async' = 'true', -- ★ 开启异步查询
'lookup.async-thread-number' = '16' -- 异步线程数
);
-- =========================================
-- 定义主表(Kafka)
-- =========================================
CREATE TABLE user_events (
user_id BIGINT,
event_type STRING,
amount DECIMAL(18,2),
event_time TIMESTAMP(3),
proctime AS PROCTIME(), -- ★ 处理时间字段
WATERMARK FOR event_time AS event_time - INTERVAL '5' SECOND
) WITH (
'connector' = 'kafka',
'topic' = 'user_events',
'properties.bootstrap.servers' = '...',
'format' = 'json'
);
-- =========================================
-- Lookup Join 查询
-- =========================================
INSERT INTO enriched_events
SELECT
e.user_id,
e.event_type,
e.amount,
e.event_time,
u.name,
u.city,
u.tier,
-- 可以加业务逻辑
CASE
WHEN u.tier = 'VIP' AND e.amount > 10000 THEN 'high_value'
WHEN u.tier = 'VIP' THEN 'vip_normal'
ELSE 'regular'
END AS event_label
FROM user_events e
LEFT JOIN dim_user FOR SYSTEM_TIME AS OF e.proctime AS u
ON e.user_id = u.user_id
WHERE u.user_id IS NOT NULL; -- 如果维度不存在,丢弃(或保留,看业务)
五、Lookup Join 的缓存机制
Lookup Join 不是每次都去读文件。它有缓存:
sql
CREATE TABLE dim_user (
...
) WITH (
'connector' = 'paimon',
'path' = '...',
-- ★ 缓存相关参数
'lookup.cache' = 'PARTIAL', -- 缓存策略:ALL | PARTIAL | NONE
'lookup.partial-cache.expire-after-access' = 'true',
'lookup.partial-cache.expire-after-write' = 'true',
'lookup.partial-cache.expire-time' = '30min',
'lookup.partial-cache.cache-missing-key' = 'true',
'lookup.partial-cache.max-rows' = '100000'
);
三种缓存策略
| 策略 | 行为 | 适用场景 |
|---|---|---|
NONE |
每次查询都读文件 | 维度表极小(< 千行),或对实时性要求极致 |
PARTIAL |
只缓存命中的 key(LRU) | ★ 最常用。维度表大但热点集中 |
| ALL | 启动时全量加载到内存 | 维度表小(< 10万行),且变化不频繁 |
shell
缓存刷新机制:
维度表在 Paimon 中更新 → full-compaction 生成新快照
↓
Lookup Join 感知到新快照 → 根据 strategy 刷新缓存
↓
后续查询命中新缓存 → 拿到更新后的维度值
延迟 = compaction_interval + 缓存刷新周期
PARTIAL 缓存的参数调优
sql
-- 场景:1000万用户维度表,热点用户约10万
'lookup.cache' = 'PARTIAL',
'lookup.partial-cache.expire-after-access' = 'true',
'lookup.partial-cache.expire-after-write' = 'true',
'lookup.partial-cache.expire-time' = '10min',
'lookup.partial-cache.cache-missing-key' = 'true', -- 也缓存未命中的 key(防穿透)
'lookup.partial-cache.max-rows' = '200000'
六、Lookup Join vs Temporal Join(对比)
这是最容易混淆的一对:
shell
┌────────────────────────────────────────────────────────────────────┐
│ Lookup Join vs Temporal Join │
├──────────────┬─────────────────────────┬───────────────────────────┤
│ │ Lookup Join │ Temporal Join │
├──────────────┼─────────────────────────┼───────────────────────────┤
│ 时间语义 │ 处理时间 (proctime) │ 事件时间 (event_time) │
│ │ "此时此刻"的维度快照 │ "事件发生时"的维度快照 │
├──────────────┼─────────────────────────┼───────────────────────────┤
│ 维度表要求 │ changelog-producer= │ changelog-producer= │
│ │ lookup │ input(版本表) │
│ │ + merge-engine= │ + merge-engine= │
│ │ deduplicate │ deduplicate │
├──────────────┼─────────────────────────┼───────────────────────────┤
│ 维度状态 │ Flink 不维护 state │ Flink 维护版本 state │
│ │ (或只缓存查询结果) │ (每个 key 的多版本) │
├──────────────┼─────────────────────────┼───────────────────────────┤
│ 历史回放 │ ❌ 不支持 │ ✅ 支持 │
│ │ 重放结果依赖当前快照 │ 重放结果与首次一致 │
├──────────────┼─────────────────────────┼───────────────────────────┤
│ 内存占用 │ 低(缓存可控) │ 可能高(所有 key 多版本) │
├──────────────┼─────────────────────────┼───────────────────────────┤
│ 适用场景 │ 实时大屏、在线服务 │ 数据分析、精确历史回看 │
│ │ 不关心事件时间的维度 │ 需要事件时间的精确维度 │
└──────────────┴─────────────────────────┴───────────────────────────┘
代码对比
sql
-- Lookup Join:用 proctime
SELECT ...
FROM fact_table f
LEFT JOIN dim_table FOR SYSTEM_TIME AS OF f.proctime AS d
ON f.key = d.key;
-- Temporal Join:用 event_time
SELECT ...
FROM fact_table f
LEFT JOIN dim_table FOR SYSTEM_TIME AS OF f.event_time AS d
ON f.key = d.key;
*一行之差,语义完全不同。*
实战选择决策树
shell
你需要的是:
┌─────────────────────────────────────┐
│ 关心"事件发生时"的维度值吗? │
├─────────────────────────────────────┤
│ │
│ YES ────▶ Temporal Join │
│ │ │
│ └─ 例:用户下单时的会员等级 │
│ (就算现在升级了,也应该用 │
│ 下单时的等级计算折扣) │
│ │
│ NO ─────▶ Lookup Join │
│ │ │
│ └─ 例:实时大屏展示最新用户名 │
│ (用户改名了,大屏就该显示 │
│ 最新的名字) │
└─────────────────────────────────────┘
七、Lookup Join 的典型场景
场景 1:实时大屏 --- 交易流水关联最新用户信息
sql
-- Kafka 交易流水 + Paimon 用户维度
-- 用户改名/升级后,后续交易马上关联到新信息
INSERT INTO screen_trade_realtime
SELECT
t.trade_id,
t.amount,
t.event_time,
u.name AS user_name, -- 用最新名称
u.tier AS current_tier, -- 用最新等级
u.city AS current_city -- 用最新城市
FROM kafka_trade t
LEFT JOIN dim_user FOR SYSTEM_TIME AS OF t.proctime AS u
ON t.user_id = u.user_id;
场景 2:风控 --- 交易时查询账户状态黑名单
sql
-- 黑名单是 Paimon 表,动态更新
INSERT INTO risk_alert
SELECT
t.trade_id,
t.user_id,
t.amount,
b.risk_level,
CASE
WHEN b.risk_level = 'BLACK' THEN 'BLOCK'
WHEN b.risk_level = 'HIGH' AND t.amount > 50000 THEN 'REVIEW'
ELSE 'PASS'
END AS action
FROM kafka_trade t
LEFT JOIN dim_blacklist FOR SYSTEM_TIME AS OF t.proctime AS b
ON t.user_id = b.user_id;
场景 3:数据补全 --- 上游丢字段,Lookup Join 兜底
sql
-- 假设 Kafka 消息里只有 user_id,但下游需要 user_name 和 city
-- Lookup Join 是理想方案,不需要修改上游
INSERT INTO downstream_enriched
SELECT
e.*,
u.name,
u.city,
u.age
FROM kafka_thin_event e
LEFT JOIN dim_user FOR SYSTEM_TIME AS OF e.proctime AS u
ON e.user_id = u.user_id;
八、性能与调优
8.1 核心参数速查
| 参数 | 默认值 | 建议 | 说明 |
|---|---|---|---|
lookup.async |
false |
true |
★ 必须开异步,否则阻塞主流程 |
lookup.async-thread-number |
4 |
CPU 核心数 × 2 | 异步查询线程池大小 |
lookup.cache |
NONE |
PARTIAL |
缓存策略 |
lookup.partial-cache.max-rows |
unbounded |
热点 key 数量 × 2 | 防止 OOM |
lookup.partial-cache.expire-time |
unbounded |
10min |
维度变更后缓存失效时间 |
lookup.partial-cache.cache-missing-key |
false |
true |
防止缓存穿透 |
lookup.boostrap-parallelism |
1 |
4 |
启动时预加载并行度(ALL 缓存) |
8.2 容量规划
shell
场景:维度表 1000 万行,每行 ~200B,热点 10 万 key
PARTIAL 缓存内存估算:
缓存行数:200,000 (max-rows)
每行:200B(序列化后可能更小)
内存:200,000 × 200B ≈ 40MB
实际可能 2-3 倍(JVM 开销),约 100-150MB
→ 给 Flink TaskManager 加 200MB heap 即可
vs. Temporal Join 的 state:
同样是 1000 万 key,Temporal Join 需要维护所有 key 的所有版本
RocksDB state 可能上 GB 级别
8.3 延迟分析
shell
Lookup Join 的端到端数据新鲜度 =
MySQL CDC 延迟(秒级)
+ Paimon commit 频率(可配置,如 30s)
+ full-compaction 间隔(如 1min)
+ 缓存过期时间(如 10min)
────────────────────────────
≈ 分钟级
如果不接受分钟级延迟,可以考虑:
1. 减小 compaction 间隔(增加写压力)
2. 用 NONE 缓存策略(增加读压力)
3. 改用 Temporal Join(增加 state 开销)
九、常见陷阱
陷阱 1:维度表没开 lookup changelog-producer
sql
-- ❌ 这表不能用于 Lookup Join
CREATE TABLE dim_user (
...
) WITH (
'merge-engine' = 'deduplicate',
'changelog-producer' = 'input' -- ← 不是 lookup!
);
-- 报错:
-- java.lang.IllegalStateException: Lookup table should enable
-- 'changelog-producer' = 'lookup'
*必须 changelog-producer = 'lookup'。* 如果建错了,需要重建表或等 Paimon 支持 ALTER TABLE 改属性(目前部分版本不支持)。
陷阱 2:缓存过期时间设置太长
sql
'lookup.partial-cache.expire-time' = '24h' -- ❌ 太长了
-- 后果:用户升级 VIP 后,24 小时内 Lookup Join 仍返回旧等级
-- 风控规则基于旧等级执行,可能误杀或漏过
陷阱 3:lookup-wait 导致背压
sql
'changelog-producer.lookup-wait' = 'true' -- 默认值
-- 含义:维度表 compaction 完成前,Lookup Join 会等
-- 如果 compaction 很慢(大量小文件),查询会被阻塞
-- 主表消费被拖慢 → Kafka consumer lag 上升
-- 解决方案:
1. 减小 compaction.min.file-num(更频繁 compaction)
2. 或设 lookup-wait=false(接受短暂不一致)
陷阱 4:异步查询线程数不够
sql
'lookup.async' = 'true',
'lookup.async-thread-number' = '2' -- ❌ 太少
-- 主表 QPS 10000/s,维度表查询平均 5ms
-- 单线程:1000ms / 5ms = 200 QPS
-- 2 线程:400 QPS << 10000 QPS
-- → 请求排队 → 超时 → 背压
-- 计算:所需线程数 ≈ 期望 QPS / (1000ms / 平均查询耗时)
-- = 10000 / (1000/5) = 50
'lookup.async-thread-number' = '64'
十、与之前余额汇总设计的关联
回顾我们上轮的设计:
shell
场景 推荐 Join 类型 理由
────────────────────────────────────────────────────────────────
实时大屏(当前余额) Lookup Join 用户改了名就显示新名字,正常
事件时间分析 Temporal Join 事件时间 + SCD Type 2 维度
("下单时 VIP 等级") 精确关联事件发生时的维度版本
账户余额聚合 Normal Join + GROUP BY 余额变更是同一条流
(同一 Paimon 表) 不需要维度关联
在 dwd.fact_account_balance_current 关联 dim_user 时:
sql
-- 实时大屏场景:Lookup Join ✓
INSERT INTO screen_user_balance
SELECT
a.user_key,
SUM(a.current_balance),
u.user_name, -- 拿到最新名字
u.user_tier -- 拿到最新等级
FROM paimon_account_balance a
LEFT JOIN dim_user FOR SYSTEM_TIME AS OF a.proctime AS u
ON a.user_key = u.user_key AND u.is_current = true
GROUP BY a.user_key, u.user_name, u.user_tier;
总结
| 维度 | Lookup Join 的核心要点 |
|---|---|
| *本质* | 请求-响应模式,不是双流 Join;主表来一条→查维度表→返回 |
| *时间* | 处理时间语义(proctime),查"此时此刻"的维度快照 |
| *前提* | 维度表必须 changelog-producer=lookup + merge-engine=deduplicate |
| *优势* | 内存极省(不维护维度 state),维度可无限大 |
| *劣势* | 延迟 = compaction + 缓存周期;不支持事件时间历史回放 |
| *兄弟* | Temporal Join(事件时间语义,维护 state,支持历史回放) |
选型口诀:*"不要历史的用 Lookup,要精确历史的用 Temporal。"*