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

相关推荐
zhojiew12 小时前
在AWS中国区使用NYC Taxi数据集在Apache Flink(KDA)中实现流数据处理管道的实践
flink·apache
行者-全栈开发13 小时前
【AI交通安全】IoT智能机车实战:ESP32+MQTT+Flink全栈方案,事故率降65%
人工智能·物联网·mqtt·flink·时序数据库·influxdb·智能机车
大大大大晴天️1 天前
Flink技术实践:RocksDB 状态后端技术解密
大数据·flink
清平乐的技术专栏2 天前
【Flink学习】(二)Flink 本地环境搭建,运行第一个入门程序
大数据·flink
大大大大晴天2 天前
Flink技术实践:RocksDB 状态后端技术解密
大数据·flink
清平乐的技术专栏2 天前
【FlinkSQL笔记】(二)Flink SQL 基础语法详解
笔记·sql·flink
码上滚雪球2 天前
Flink Agents 深度解读:当实时数据流遇上 AI 智能体
大数据·人工智能·flink·滚雪球
若兰幽竹2 天前
【Flink 电商用户行为分析】从数据采集到实时决策:构建全链路用户行为分析系统设计
大数据·flink·实时数据分析·电商用户行为数据分析
清平乐的技术专栏2 天前
【FlinkSQL笔记】(一)什么是Flink SQL
笔记·sql·flink