Paimon Lookup Join 详解

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 ****必须****是 deduplicatepartial-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',
    ...
);

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。"*

相关推荐
abcy07121318 小时前
spark核心组件
flink·spark
翔云1234563 天前
Kafka + Flink实时时流处理场景
flink·kafka
abcy0712134 天前
flink state实例
大数据·算法·flink
Flink_China4 天前
抖音集团基于Paimon的流式数据湖应用实践
大数据·flink
abcy0712134 天前
flink 对齐与非对齐,窗口,状态state原理详解
大数据·flink
大大大大晴天️4 天前
Flink DataStream API 进阶使用与避坑指南
大数据·flink
abcy0712135 天前
flink Task Slots and Resources详解
flink
大大大大晴天5 天前
Flink DataStream API多流操作与实践
flink
大大大大晴天7 天前
深入浅出 Flink DataStream API:原理与使用
flink