摘要:

本文从一个在 2021 年前后困扰着大量实时数仓工程师的真实问题出发:为什么用 Apache Flink 向数据湖(Apache HudiApache Iceberg)写入数据,数据新鲜度始终无法突破 5-10 分钟的瓶颈? 分析根本原因后,我们将理解这不是 Flink 的问题,也不是 Hudi/Iceberg 的问题,而是”不可变文件 + 微批提交”这个根本架构模式在高频写入场景下的天花板。Apache Paimon 用一个来自数据库领域已有 40 年历史的设计——LSM-Tree(Log-Structured Merge-Tree)——打破了这个天花板。理解 Paimon 为什么选择 LSM-Tree,是理解它与 Hudi/Iceberg 所有架构差异的起点。


第 1 章 实时数仓的延迟困境

1.1 2021 年的真实工程场景

2021 年,阿里巴巴的数据团队面临一个典型问题。他们的实时数仓架构是:

MySQL/OceanBase(在线业务库)
    ↓(Flink CDC 实时捕获变更)
Kafka(变更事件流)
    ↓(Flink 消费 + 处理)
Hudi/Iceberg 数据湖(离线 + 准实时查询)
    ↓
Hive / Presto / Trino(查询)

这个架构在理论上可以做到分钟级的数据新鲜度——MySQL 的变更通过 Flink CDC 几乎实时捕获,Kafka 的消费延迟在秒级。问题出在最后一步:Flink 向 Hudi/Iceberg 写入数据的延迟

实际测量下来,端到端延迟(从 MySQL 变更到 Hudi 表可查询)始终在 5-15 分钟,无论怎么调优都无法突破这个壁垒。

1.2 延迟的根本来源:Checkpoint 驱动的微批提交

要理解这个 5-15 分钟延迟的根因,需要深入 Flink 写入 Hudi/Iceberg 的执行模型。

Flink 写 Hudi/Iceberg 的流程(简化)

Flink 消费 Kafka 事件流(连续,每秒数千条记录)

每隔 N 分钟触发 Checkpoint:
  Step 1:Flink Checkpoint 开始
         → 向所有 Operator 发送 Barrier(屏障)
         → 等待所有 Operator 的 State 快照完成
         ← 耗时:30 秒 ~ 2 分钟(取决于状态大小)

  Step 2:Checkpoint 完成后,触发 Commit
         → Flink Sink(Hudi/Iceberg Writer)将本次 Checkpoint 内
           积累的数据文件提交到 Hudi/Iceberg 的元数据
         → 提交后,数据对读者可见
         ← 耗时:10 秒 ~ 1 分钟

  Step 3:下次 Checkpoint 前,Flink 继续消费并缓冲数据
         → 这段时间的数据在内存或 Buffer 中,对任何读者不可见

关键约束:Flink 的 exactly-once 语义要求 Hudi/Iceberg 的 Commit 必须在 Checkpoint 完成后才能执行——这保证了即使 Job 崩溃重启,已 Commit 的数据是完整的(没有重复也没有丢失)。

这意味着:数据从 Kafka 消费到 Hudi/Iceberg 可查询,至少需要等待一个完整的 Checkpoint 周期。Checkpoint 间隔越短,数据新鲜度越好,但:

Checkpoint 间隔 → 数据新鲜度    反向影响

Checkpoint 间隔 = 1 分钟:
  → 数据新鲜度:约 1-3 分钟
  → 每分钟一次 Commit,每次产生大量小文件(Hudi/Iceberg 没有 MemTable 缓冲写放大)
  → S3 上每小时产生数千个小文件(1440 次 Commit × 10+ 文件/次)
  → 小文件问题严重:查询时打开文件数过多,性能下降

Checkpoint 间隔 = 5 分钟:
  → 数据新鲜度:5-10 分钟
  → 每次 Commit 合并 5 分钟的数据,文件大小合理(128MB 级别)
  → 小文件问题缓解,但数据新鲜度牺牲

Checkpoint 间隔 = 10 分钟:
  → 数据新鲜度:10-20 分钟(加上 Checkpoint 本身耗时)
  → 小文件问题好,但实时性完全丧失

这就是”实时写湖延迟困境”的本质:Hudi/Iceberg 的文件级提交模型,无法在保持合理文件大小的同时实现低数据延迟

1.3 为什么 Hudi MoR 也解决不了这个问题

Apache Hudi 的 MoR(Merge-on-Read)表在每次 deltacommit 时只追加日志文件,写入很快——但这只解决了”写放大”问题,没有解决”提交延迟”问题。

Hudi MoR 的 deltacommit 流程(Flink 场景):
  仍然需要在 Flink Checkpoint 完成后才能 deltacommit
  → 数据可见延迟 = Checkpoint 间隔 + Checkpoint 执行时间
  → 仍然是分钟级

唯一的改进:
  每次 deltacommit 写入的是小日志文件(.log),不是大 Parquet 文件
  → 小文件问题相对缓解
  → 但日志文件积累后 Compaction 的代价同样不小

即便是 Hudi 最轻量的写入模式,数据新鲜度的天花板仍然是 Checkpoint 间隔,通常无法做到分钟内。

1.4 Paimon 的根本性解答:解耦写入与可见性

Paimon 团队(最初是阿里巴巴 Flink 团队)对这个问题的分析是:

延迟不是 Flink 的问题,而是”文件系统上的不可变文件”这个存储模型的问题。不可变文件意味着每次”发布”新数据都需要一次文件提交(Commit),Commit 必须等 Checkpoint 完成,所以延迟被 Checkpoint 周期下限约束。

解法方向是:引入一种支持高频写入而不需要高频 Commit 的存储结构

这个解法在数据库领域已经存在了 40 年:LSM-Tree(Log-Structured Merge-Tree)


第 2 章 LSM-Tree 的核心思想

2.1 LSM-Tree 解决的问题

LSM-Tree 最初由 Patrick O’Neil 等人于 1996 年提出,被 LevelDBRocksDBApache HBaseApache Cassandra 等系统广泛使用。它的设计目标是:在随机写入(如按主键更新)场景下,实现接近顺序写的吞吐量

为什么传统存储对随机写慢?以 B-Tree(关系数据库的标准索引)为例:

B-Tree 随机写入问题:
  写入 key=12345, value=新值
  → 在 B-Tree 中查找 key=12345 的位置(可能是任意磁盘位置)
  → 读取该磁盘页(随机读)
  → 在页内修改值
  → 写回该磁盘页(随机写)

随机 IO 的代价(HDD 时代):
  HDD 随机写:100-200 IOPS(每秒 100-200 次 IO)
  HDD 顺序写:100MB/s(相当于 100000+ IOPS)
  性能差距:1000 倍!

LSM-Tree 的核心思想是:不做随机写,只做顺序写。具体策略是:

LSM-Tree 写入策略:
  1. 新写入先进内存(MemTable)
     → 内存中保持有序结构(如跳表 SkipList)
     → 内存写入速度 = 内存速度(极快)

  2. 内存满了,顺序刷写到磁盘(生成 SST 文件,Level 0)
     → 顺序写,速度快
     → 每个 SST 文件内部有序(按 key 排序)

  3. 后台异步合并(Compaction)
     → 将多个 SST 文件合并,去除重复 key 的旧版本
     → 合并后文件层级上升(Level 0 → Level 1 → Level 2 ...)
     → 读取时合并多层(用堆排序)

写入代价:顺序写(快)+ 内存写(极快)
读取代价:需合并多层 SST 文件(比 B-Tree 稍慢)

这就是 LSM-Tree 的核心权衡:用读放大换写放大——写入极快(只做顺序写),读取时合并多层数据(有额外开销)。

2.2 LSM-Tree 如何解决数据湖的流式写入问题

Paimon 把 LSM-Tree 的思想应用到数据湖场景,解决了以下关键问题:

解决”高频写入 = 海量小文件”问题

传统数据湖写入(Hudi/Iceberg):
  Flink 每秒写 1000 条记录
  每次 Checkpoint(5 分钟)生成一批文件(如 10 个 Parquet,每个 5MB)
  → 每小时产生 120 个文件(每分钟 2 批)
  → 每天产生 2880 个文件(仅一个分区)
  → 随时间积累,小文件问题严重

Paimon 写入:
  Flink 每秒写 1000 条记录 → 先进入 MemTable(内存)
  MemTable 满后(如 256MB)顺序刷写到 Level 0 SST 文件
  Level 0 文件积累后,后台 Compaction 合并到 Level 1/2(大文件)
  → 数据在内存中的积累与 Checkpoint 解耦
  → 小文件生命周期短(很快被 Compaction 合并),不在 S3 上永久积累

解决”数据可见性依赖 Checkpoint”的问题

Paimon 通过在写入流程中引入独立的”可见性提交”(Snapshot Commit)机制,允许在 Flink Checkpoint 之间发布数据快照,使数据对读者更频繁地可见:

Paimon 的写入可见性模型(简化):
  Flink Checkpoint 间隔:5 分钟(保证 exactly-once)
  数据快照发布间隔:10 秒(数据对读者可见)
  
  两者解耦:
  - 快照发布不需要等 Checkpoint(只是"最新的内存 + L0 文件"对外可见)
  - Checkpoint 保证的是"崩溃恢复时不丢数据",与可见性是正交的
  
  数据新鲜度:约 10-30 秒(取决于配置)
  vs Hudi/Iceberg:5-15 分钟

秒级 vs 分钟级的工程价值

数据新鲜度从分钟级提升到秒级,对以下场景有决定性意义:

  • 实时大屏:电商大促的实时 GMV 统计,需要秒级刷新
  • 风控系统:用户行为异常检测,分钟级延迟可能导致损失已发生
  • 实时维表:流计算中的 Lookup Join,维表数据越新,业务逻辑越准确
  • 准实时报表:业务运营在上午 9 点查看昨晚 23:59 的最终数据

第 3 章 Paimon 的整体架构

Paimon 最初叫做 Flink Table Store,这个名字直接揭示了它的设计定位:不是一个独立的通用数据湖框架,而是 Flink 的”原生存储层”

这种原生绑定关系体现在:

1. Flink 流写是 Paimon 的第一公民
   → Flink 的 Checkpoint 机制与 Paimon 的提交协议深度集成
   → Flink 的 Watermark 机制与 Paimon 的 Compaction 触发器集成

2. Flink SQL 作为主要操作界面
   → DDL(建表/改表)→ Flink SQL
   → DML(写入/查询)→ Flink SQL / Spark SQL
   → 无需专门的 "Paimon Client" SDK

3. Flink 的 State 机制用于 Paimon 的 MemTable 管理
   → MemTable 数据存在 Flink Operator State 中
   → Checkpoint 时持久化 MemTable 状态(防止崩溃丢数据)

Paimon 也支持 Spark(批处理读写)、Trino/Presto(查询)等其他引擎,但 Flink 场景是设计优先级最高的。

3.2 Paimon 的物理存储结构

表根目录(S3/HDFS)/
├── manifest/                    ← 元数据层(类似 Iceberg 的 ManifestList)
│   ├── manifest-list-snap-1.avro
│   └── manifest-abc.avro
├── snapshot/                    ← 快照层(每次 Commit 生成一个快照文件)
│   ├── LATEST                   ← 指向最新快照 ID 的文件
│   ├── EARLIEST                 ← 指向最早保留快照 ID 的文件
│   ├── snapshot-00000001        ← 快照 1 的元数据(JSON)
│   └── snapshot-00000002        ← 快照 2 的元数据
├── schema/                      ← Schema 版本管理
│   └── schema-0                 ← Schema 定义(JSON)
└── partition=2024-01-01/        ← 分区目录(支持 Hive 风格分区)
    └── bucket-0/                ← Bucket 目录(按 bucket 分桶)
        ├── data-xxx.orc         ← Level 0 数据文件(刚刷写的 SST)
        ├── data-yyy.orc         ← Level 1 数据文件(经过 Compaction)
        └── data-zzz.orc         ← Level 2 数据文件(高层,最稳定)

注意 Paimon 的数据文件格式默认是 ORC(也支持 Parquet、Avro),而不是其他数据湖普遍使用的 Parquet。这是因为 ORC 在小文件随机写场景下的压缩效率略优于 Parquet,更适合 LSM-Tree 的 Level 0 小文件场景。

3.3 Paimon 与其他三者的整体架构对比


graph TD
    subgraph "Hudi 架构"
        HW["写入:CoW/MoR</br>Index → 文件路由"]
        HM["元数据:Timeline</br>.hoodie/ 目录"]
        HS["存储:不可变 Parquet + Log"]
        HW --> HM --> HS
    end

    subgraph "Iceberg 架构"
        IW["写入:Append/Overwrite</br>无 Record Index"]
        IM["元数据:三层结构</br>Snapshot→ManifestList→Manifest"]
        IS["存储:不可变 Parquet"]
        IW --> IM --> IS
    end

    subgraph "Paimon 架构"
        PW["写入:MemTable 内存写</br>顺序刷写 SST"]
        PC["后台 Compaction</br>多层 SST 合并"]
        PM["元数据:Snapshot</br>轻量快照文件"]
        PS["存储:LSM-Tree 分层 SST</br>Level 0/1/2/..."]
        PW --> PC --> PS
        PW --> PM
    end

    classDef hudi fill:#6272a4,stroke:#bd93f9,color:#f8f8f2
    classDef iceberg fill:#44475a,stroke:#50fa7b,color:#f8f8f2
    classDef paimon fill:#282a36,stroke:#ff79c6,color:#ff79c6
    class HW,HM,HS hudi
    class IW,IM,IS iceberg
    class PW,PC,PM,PS paimon

第 4 章 Paimon 与 Hudi/Iceberg 的本质差异

4.1 存储模型的代际差异

Hudi 和 Iceberg 都基于不可变文件(Immutable File) 模型——数据文件一旦写出就不再修改,更新通过写新文件(CoW)或追加日志(MoR)来实现。这个模型天生适合批处理(大批量写入,每次写完整文件),但对高频流式写入不友好。

Paimon 基于 LSM-Tree(可变 MemTable + 分层不可变 SST) 模型——数据先写入可变的内存结构(MemTable),内存满后顺序刷写到磁盘,多个小文件通过后台 Compaction 合并为大文件。这个模型天生适合高频写入(内存写速度接近顺序写)。

不可变文件模型(Hudi/Iceberg)的流式写入天花板:
  数据可见性 = 文件提交(Commit)的频率
  Commit 频率受限于 Checkpoint 间隔
  → 数据新鲜度天花板:~ 5-10 分钟

LSM-Tree 模型(Paimon)的流式写入上限:
  数据可见性 = 快照发布频率(独立于 Checkpoint)
  快照发布极轻量(只写一个小的 JSON 快照文件)
  → 数据新鲜度:~ 10-30 秒

4.2 Paimon 不适合的场景

Paimon 的 LSM-Tree 模型带来低写延迟,但也有代价:

LSM-Tree 的固有代价(Paimon 继承):

1. 读放大(Read Amplification):
   查询时可能需要合并多层 SST 文件(Level 0, 1, 2...)
   直到后台 Compaction 把 Level 0 的小文件合并到高层,读放大才降低
   → 在 Compaction 不及时的情况下,查询性能不如 Hudi CoW 或 Iceberg

2. Compaction 资源消耗:
   后台 Compaction 持续消耗 CPU 和 IO
   在 Flink Job 资源紧张时,Compaction 可能与主写入流程竞争
   → 需要为 Compaction 预留额外资源

3. 空间放大(Space Amplification):
   同一条记录可能在多个 SST 层中有旧版本
   直到 Compaction 清理旧版本,存储空间占用高于实际数据量
   → 空间使用率不如 Hudi/Iceberg(CoW 每次只保留最新版本文件)

适合 Paimon 的场景:
  ✅ 高频流式 Upsert(Flink CDC、实时维表写入)
  ✅ 秒级数据新鲜度要求(实时大屏、实时风控)
  ✅ Flink 流计算 + 查询混合场景(Lookup Join)

不适合 Paimon 的场景:
  ❌ 低频大批量 ETL(Hudi CoW 或 Iceberg 更适合)
  ❌ 多引擎混合访问(Iceberg 的生态更广)
  ❌ 纯 Spark 批处理栈(Paimon 的 Spark 支持不如 Iceberg 完善)

小结

Paimon 的诞生解决了 Flink 实时写湖场景中”不可变文件模型的分钟级延迟天花板”问题,其核心武器是 LSM-Tree——这个在 RocksDBHBaseCassandra 中已经被广泛验证的存储设计,第一次被系统性地应用到数据湖场景。

Paimon 的核心价值主张:在 Flink 流计算生态中,提供秒级延迟的流式 Upsert + ACID 语义 + 可查询的存储层——这是 Hudi 和 Iceberg 在 2022 年之前无法达到的。

下一篇 02 LSM-Tree 存储引擎——为什么用 LSM 而非 CoW 文件覆盖 将深入 Paimon 的 LSM-Tree 实现细节:MemTable 的数据结构、SST 文件的分层合并策略、Compaction 的触发机制,以及 Paimon 如何在 HDFS/S3 等对象存储(不支持随机写)上实现 LSM-Tree 的核心语义。

思考题

  1. Paimon 的核心洞察是”解耦写入路径和可见性路径”——数据写入后立即可见,LSM Compaction 在后台异步完成。这与 Hudi MoR 的”写 Log 文件,查询时合并”类似。Paimon 的 LSM 分层设计如何比 Hudi MoR 的”Base 文件 + Log 文件”更有效地控制读放大?在”尚未 Compaction”的状态下,两者的查询性能退化程度有什么本质差异?
  2. Paimon 起源于 Flink 社区(最初名为 Flink Table Store),与 Flink 集成最为紧密。对于 Paimon 的主键表(Primary Key Table),Spark 读取时需要理解 LSM 的多层文件结构并在读取时合并。Spark 的 Paimon Connector 实现了这个合并逻辑吗?如果 Spark 只读取了 Level-0 的文件而没有合并,会读到不一致的数据吗?
  3. Paimon 将”秒级可见”作为核心卖点,但这依赖 Checkpoint 触发的提交频率(如 Flink Checkpoint 间隔为 30 秒,则数据可见延迟约 30 秒)。在需要真正毫秒级可见性的场景(如实时风控),30 秒的延迟是否可以接受?Paimon 有没有比 Checkpoint 更频繁的提交机制?