基于本文回答

播面 播面

刷题像听歌,多听自然懂
0
评论

广告点击流中,同一个用户在 24 小时内可能有数万条点击,但业务仅需要保留该用户的“首次点击”(First-Row)行为。为什么说 Paimon 的 First-row 合并引擎在 Flink 状态控制和磁盘读写性能上,远优于 Flink SQL 自带的有状态去重(Deduplicate)?它的底层拦截机制是怎样的?

在广告点击流分析等高并发实时场景中,针对同一个用户在短时间内(如 24 小时内)产生数万次重复点击,但业务仅需要保留该用户的“首次点击”(First-Row)行为,Flink 传统的有状态去重(Deduplicate)确实会面临巨大的性能和存储痛点。

下面结合 Apache Paimon 官方文档与业界实践方案,为您详细剖析为什么 Paimon 的 First-row 合并引擎在 Flink 状态控制和磁盘 R/W 性能上表现更佳,以及其底层的拦截机制是如何运作的。


一、 传统 Flink SQL 有状态去重(Deduplicate)的痛点

在 Flink SQL 中,获取首次点击通常使用类似于以下的 ROW_NUMBER() 窗口去重语法:

sql
SELECT * FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY click_time ASC) as row_num
  FROM click_stream
) WHERE row_num = 1

对于拥有数千万乃至上亿活跃用户、且单个用户频繁点击的广告流场景,这种实现方式存在以下主要瓶颈:

  1. Flink 状态(State)极度膨胀
    为了判断某个用户在 24 小时内是否已经点击过,Flink 必须在本地状态后端(通常是 RocksDB State Backend)中,以 user_id(或 user_id + ad_id)作为 Key 缓存所有已见过的用户标记。这意味着 Flink 需要在本地 SSD 磁盘中维护一个庞大的状态库,随着活跃用户量的增加,状态大小呈线性暴增。
  2. Checkpoint 延迟与吞吐受限
    由于 RocksDB 状态体积过大,Flink 在执行 Checkpoint 时的快照文件会变得非常臃肿,导致备份时间拉长、网络 I/O 开销飙升。一旦遇到状态对齐延迟或长尾效应,极易产生反压,使实时任务出现性能波动。
  3. 极高的本地磁盘 I/O 读写压力
    一个用户一天点击数万次,意味着 Flink 必须对该用户的状态执行数万次 RocksDB 的 Lookup(读)操作。即使第一次点击已经在状态中打上了标记,后续几万次点击依然需要不断去检索 RocksDB 的 SST 文件,从而引发严重的磁盘读取和缓存失效问题。
  4. 历史数据回放与冷启动困难
    如果任务由于业务变更需要“回刷”过去一段时间的数据,Flink 难以在短时间内重建如此庞大的本地 State,甚至无法直接通过外部静态表进行初始化,导致重算效率非常低下。

二、 为什么 Paimon 的 First-row 合并引擎远优于 Flink SQL

通过在 Paimon 表中指定 'merge-engine' = 'first-row' 并配合 'changelog-producer' = 'lookup',上述痛点可以得到针对性的解决:

  • Flink 任务彻底“去状态化”(State-free)
    Flink 作业本身不再需要通过本地的 RocksDB 状态去记录“哪些用户已点击过”,这些“去重状态”全部被托管到了数据湖存储层(Paimon 的 LSM-tree 结构)中。Flink 实时作业变成了一个几乎无状态(或仅有极轻量级状态)的纯写入任务,大幅释放了 Flink TaskManager 的内存和 CPU 资源。
  • 大幅优化 Checkpoint 耗时与任务鲁棒性
    在实际生产案例(如蚂蚁集团双十一大促等场景)中,引入 Paimon First-row 方案后,Flink 任务的 CPU 使用率可下降约 60%,内存消耗降低 35%,Checkpoint 大小和耗时降幅可达 90%,任务回放与重置效率提升了数倍。
  • 轻量级、无回撤的 Insert-Only 变更流
    普通的去重引擎(如 Deduplicate)在处理数据更新时,由于保留最新值,往往会向后发送 -U(Update Before)和 +U(Update After)的撤回消息。而 Paimon First-row 引擎由于仅保留最早的那一条,后续的点击直接被静默丢弃,只会向下游生成 +I(Insert-only)的增量数据。这使得下游的 OLAP 引擎(如 StarRocks/Doris)或下游聚合任务可以以极低的成本直接处理追加流,无需承担回撤计算的巨大开销。

三、 Paimon First-row 的底层拦截机制

Paimon 是一个基于 LSM-tree(日志结构合并树)架构的数据湖存储系统。First-row 引擎能够做到如此高效的去重,核心在于其在写入/合并阶段的主键 Lookup 拦截机制

plaintext
                    ┌──────────────────────────────┐
                    │      Flink Stream Input      │ (包含同一个 Key 的数万次点击)
                    └──────────────┬───────────────┘
                                   │
                                   ▼
                    ┌──────────────────────────────┐
                    │ Paimon MemTable (Memory Buffer)
                    └──────────────┬───────────────┘
                                   │ (Flush / Compaction)
                                   ▼
            ┌──────────────────────────────────────────────┐
            │        Trigger Lookup & Compaction          │
            └──────────────────────┬───────────────────────┘
                                   │
                                   ├─────────────────────────────┐
                        [Key K exists in high-level SST?]        │ (No - First Click)
                                   │                               ▼
                                   │ (Yes - Duplicate Click)  ┌────────────────────────┐
                                   ▼                          │  Write K to LSM Tree   │
                        ┌─────────────────────┐               │  Produce +I Changelog  │
                        │ Intercept & Discard │               └────────────────────────┘
                        │ (不落盘,不写文件)   │
                        └─────────────────────┘

1. 内存缓冲(MemTable)阶段

Flink 算子将上游的点击数据写入 Paimon 时,首先会缓冲在内存的 MemTable(通常是基于堆的 LSM 内存树结构)中。

2. 触发后台 Lookup 查询

当 MemTable 满触发 Flush 写入 Level 0 数据文件,或者发生小文件 Compaction 时,为了实现 First-row 机制并向下游产生正确的 Insert-only 变化日志,Paimon 必须开启 changelog-producer = 'lookup'
此时,Paimon 的 Writer 会在后台触发 Lookup 检索进程

3. 极速的索引过滤与本地 SSD 缓存

为防止每次 Lookup 都去分布式文件系统(DFS)中遍历读取数据而引发严重的性能崩溃,Paimon 设计了多级过滤与缓存机制:

  • Min-Max 与 Bloom Filter 粗筛:每个高层级的数据文件都自带 min/max 统计及 Bloom Filter 索引,这部分元数据长驻内存。Paimon 能够以接近 0 开销的方式直接判定:目标 Key 是否可能存在于该文件中。
  • 本地磁盘 Lookup Cache 索引:Paimon 会在 TaskManager 的本地磁盘(通常是高性能本地 SSD)以及内存中建立 Key 与对应文件的映射缓存(利用 RocksDB 格式缓存)。Lookup 流程几乎所有的检索都会在极高速的本地缓存中完成,而不必请求 DFS。

4. 底层硬拦截与静默丢弃(Core Interception)

在进行 Lookup 检索时,底层的判断逻辑非常直接:

  • 若 Key 已存在(非首次点击):Paimon 判定该点击行为为重复数据,直接在内存或 Compaction 节点中将其丢弃(Discard)。该数据既不会写入高层级的 SST 文件中导致存储膨胀,也不会向下游下发任何 Changelog。这也是为什么“数万次无用点击”不会导致 Paimon 磁盘 I/O 负载崩溃的根本原因。
  • 若 Key 不存在(首次点击):判定为该用户的 First-row 行为,允许将该记录正常向下游发出一条 +I 的 Changelog,并写入 LSM 树的文件中。

5. 可见性保证与 Radical Compaction

在 Paimon 的 First-row 引擎中,有一个底层限制:Level 0 层的临时文件在未被 Compaction 合并到高层级之前,对 Lookup 查找是不可见的
为了保障实时去重的正确性(防止同一个 Key 在极短时间内多次流入、尚未完成 Compaction 导致 Lookup 查不到),Paimon 针对 First-row 和 Lookup 启用了更激进的 Radical Compaction 策略,强制将 Level 0 文件立即与高层级进行合并,以此确保首次写入的数据能够立即可见,被后续到来的重复 Key 成功检索并拦截。


四、 方案对比总结

对比维度 Flink SQL 状态去重 (Deduplicate) Paimon First-row 合并引擎
状态存储位置 Flink 算子本地 RocksDB 状态 Paimon 存储层内部(LSM-tree + 本地 Lookup Cache)
Flink Checkpoint 大小 随活跃用户规模(Key)呈线性暴增 极小,Flink 作业几乎无状态
大规模点击 I/O 开销 极高(每次点击都要频繁读写 RocksDB State) 极低(后续的重复点击在 LSM 写入端直接被检索拦截和静默丢弃)
下流 Changelog 类型 通常产生回撤流(-U, +U),下游计算成本高 仅产生追加流(+I),对下游计算和存储极其友好
历史数据回放与冷启动 状态重建极难,效率很低 极为简单,数据湖存储天然支持回溯与批读写
00:00
00:00