【Paimon 学习笔记 二】存储架构:快照、manifest、LSM 树、bucket

第一篇把 Paimon 的定位说清了------"流存储上补批的能力",起点和三剑客相反。但"LSM 树"和"快照"到底怎么落地?订单写进去之后,磁盘上是什么样子?这篇打开黑盒。

零 Paimon目录:一张 Paimon 表的目录结构

先给全景。一张 Paimon 表在存储上就是一个目录,里面五样东西:

复制代码
s3://warehouse/orders/
├── partition=2026-08-29/
│   ├── bucket-0/
│   │   ├── data-0-1.parquet      ← 数据文件
│   │   ├── data-0-2.parquet
│   │   └── ...
│   ├── bucket-1/
│   │   └── data-1-1.parquet
│   └── ...
├── snapshot/
│   ├── snapshot-1                 ← 快照(版本)
│   ├── snapshot-2
│   └── ...
├── manifest/
│   ├── manifest-0                 ← 清单(数据文件索引)
│   └── ...
├── schema/
│   └── schema-0                   ← schema 版本
└── metadata/
    └── ...                        ← 表配置

上面是有分区的表。如果建表时不指定分区字段,目录里就没有 partition=.../ 这一层,bucket 直接挂在表根目录下:

复制代码
s3://warehouse/orders/
├── bucket-0/              ← 无 partition 层,bucket 直接在表根目录
│   └── data-0-1.parquet
├── bucket-1/
├── snapshot/
├── manifest/
├── schema/
└── metadata/

五样各司其职:

目录 装什么 管什么
partition=.../bucket-.../ 数据文件(Parquet) 数据怎么存
snapshot/ 快照文件 版本和可见性
manifest/ 清单文件 数据文件索引
schema/ schema 版本 表结构版本
metadata/ 表配置 表级配置

第一篇说"从 Iceberg 继承快照 + manifest"------继承的就是 snapshot/manifest/ 这两个目录。下面先用一个具体例子打底,再逐个拆开。

引子:一条订单在磁盘上经历了什么

后面几个概念都抽象,先用 order_001 走一遍全流程,看它在磁盘上到底经历了什么。

场景:订单流从 Kafka 进来,Flink 写进 Paimon orders 表(主键表,2 个 bucket,按天分区)。order_001 在 10:00 到达、状态"待支付",10:01 改成"已支付"。两次 Checkpoint(10:00 和 10:01)各提交一次。

第一次 Checkpoint(10:00)

order_001 进 Flink,按 hash(order_001) % 2 = 0 路由到 bucket-0。数据先进内存,Checkpoint 触发时 flush 成文件:

复制代码
partition=2026-08-29/bucket-0/data-0-1.parquet  ← order_001=待支付

flush 完,Paimon 创建 manifest-1(记录 data-0-1),再创建 snapshot-1 用 rename 写入 snapshot/ 目录。snapshot-1 出现的这一刻,order_001=待支付 对读者可见。

此刻目录:

复制代码
s3://warehouse/orders/
├── partition=2026-08-29/
│   └── bucket-0/
│       └── data-0-1.parquet          ← order_001=待支付
├── snapshot/
│   └── snapshot-1                    ← 10:00 的版本
├── manifest/
│   └── manifest-1                    ← delta:新增 data-0-1(第一个快照,base 为空)
├── schema/
│   └── schema-0
└── metadata/

第二次 Checkpoint(10:01)

order_001 状态改成"已支付"再进来。注意:data-0-1 不动。新数据写进内存,Checkpoint 触发,追加新文件:

复制代码
partition=2026-08-29/bucket-0/
  ├── data-0-1.parquet  ← order_001=待支付(旧版本,不动)
  └── data-0-2.parquet  ← order_001=已支付(新版本,追加)

创建 manifest-2(记录"新增 data-0-2"),创建 snapshot-2。snapshot-2 出现,查询 order_001 时 merge 两个文件取最新------已支付。

此刻目录:

复制代码
s3://warehouse/orders/
├── partition=2026-08-29/
│   └── bucket-0/
│       ├── data-0-1.parquet          ← 待支付(旧)
│       └── data-0-2.parquet          ← 已支付(新)
├── snapshot/
│   ├── snapshot-1                    ← 10:00
│   └── snapshot-2                    ← 10:01
├── manifest/
│   ├── manifest-1                    ← 记 data-0-1,snapshot-2 的 base 指向它
│   └── manifest-2                    ← 本次 delta:新增 data-0-2
└── ...

两个关键观察,正好对应 Paimon 怎么解决第一篇 Hive 的几个坎:

观察 解决了 Hive 的什么问题 后面哪节展开
snapshot-1/2 出现 = 可见,没出现 = 不可见 可见性(Hive 靠分区注册约定,小时级) 第一节
data-0-1 没被改,新版本写进 data-0-2 更新代价(Hive 要重写整个文件) 第二节
order_001 按 hash 落到 bucket-0,不是 bucket-1 小文件 + 并行度(Hive 没有分桶,文件随意散落) 第四节
manifest 记录"哪个文件在哪个 bucket" 没有版本管理(Hive 只有目录,没有清单索引) 第一节

后面每个概念都会回到这个例子里找到对应。

一 Paimon版本管理:从 Iceberg 继承的版本管理

1 snapshot 文件:一个版本长什么样

第一篇说过:每次提交产生一个快照,快照指向一份文件清单。现在看清单到底长什么样。一个 snapshot 文件(snapshot-N)的内容简化后:

json 复制代码
{
  "version": 2,
  "baseManifest": "manifest-1",
  "deltaManifest": "manifest-2",
  "changelogManifest": "manifest-c1",
  "commitUser": "user-flink",
  "commitTime": "2026-08-29T10:01:00Z",
  "recordCount": 15200
}

关键在 baseManifest + deltaManifest 的分工:base 是上一版 的全量文件清单,delta 只记本次提交 新增/删除的文件。读取时把 base + delta 合并,就是这一版完整的文件列表------不用像 Delta Lake 那样从头重放日志推导。changelogManifest 单独记 changelog 文件的清单,流读时用它(第三篇展开)。

对应引子:snapshot-2 的 base 指向 manifest-1(里面记着 data-0-1),delta 是 manifest-2(本次新增 data-0-2)------两份合并,{data-0-1, data-0-2} 就是 snapshot-2 的完整文件列表。注意每次提交后,上一个 delta 就被收进了下一个快照的 base,所以读任何一个快照永远只需要 base + 本次 delta 两份清单,不用追溯历史 delta。

这里"收进"是指针层面的动作,不要和后面的 manifest compaction 混:每次提交会写一份新的 base 清单列表(manifest list),把上一版 base 和 delta 引用的 manifest 文件名都列进去------只是登记引用,不动 manifest 文件本身,代价很小。真正把一堆小 manifest 文件物理合并成大文件的,才是 manifest compaction。打个比方:base 是目录页,每次提交重印一页目录(多列一章);compaction 是把拆散的章节装订成合订本------目录每次都重印,合订本攒够了才做。

但 base 背后引用的 manifest 文件会越攒越多------每次提交多一个小文件。manifest 有自己的合并机制(和数据的 compaction 是两回事):

  • 每次提交生成一个 delta manifest,记本次变化,随后成为下一版 base 的一部分
  • manifest 文件攒到一定数量(由 manifest.full-compaction-threshold 控制,默认 30),触发 manifest compaction:把所有 manifest 合并成一份全量清单,旧的清掉
  • 之后快照的 base 只引用这份新清单,重新从零开始攒

用引子的例子展开看:

复制代码
snapshot-1:  base(空)                   + delta(manifest-1,新增 data-0-1)  → 全量 = {data-0-1}
snapshot-2:  base(manifest-1)           + delta(manifest-2,新增 data-0-2)  → 全量 = {data-0-1, data-0-2}
snapshot-3:  base(manifest-1~2)         + delta(manifest-3,...)
...
snapshot-31: base(manifest-1~30)        + delta(manifest-31)  ← 清单文件攒到 30 个,触发 manifest compaction
             → manifest-1~31 合并成 manifest-32(一份全量清单),旧的清掉
snapshot-32: base(manifest-32)          + delta(manifest-33)

(上面为了简化只写了单个数据文件,实际一个 delta manifest 记的是本次提交的所有文件变化------多个 bucket 各自 flush 出的文件都记在同一个 delta 里。)

所以 manifest compaction 要解决的读放大,不是"要合并的 delta 太多",而是 base 引用的小清单文件太多------读取时要打开几十个巴掌大的 manifest 文件;compaction 把它们定期合成一份大的。

注意:manifest compaction 和 LSM compaction 是两件独立的事。即使 LSM compaction 把 data-0-1 和 data-0-2 合成了 data-0-3,manifest 层只是记一条"删除 data-0-1、删除 data-0-2、新增 data-0-3"的 delta------manifest 不关心数据文件内容怎么变,只记录文件增删。两者各管各的合并,触发条件不同,互不影响。

2 manifest 文件:数据文件的索引

manifest 文件记录每个数据文件的元信息:

复制代码
manifest-2:
  file: data-0-1.parquet
    partition: 2026-08-29
    bucket: 0
    minKey: order_001
    maxKey: order_500
    rowCount: 500
  file: data-0-2.parquet
    partition: 2026-08-29
    bucket: 0
    minKey: order_501
    maxKey: order_800
    rowCount: 300

minKey/maxKey 的作用是裁剪 :查询 WHERE order_id = 'order_650' 时,引擎看 manifest 发现 order_650 落在 data-0-2 的范围里,直接跳过 data-0-1------不用扫全表。这正是第一篇 Iceberg"清单代替列目录"的思路在 Paimon 里的落地。

注意:minKey/maxKey 不需要单独指定------它就是主键 的 min/max。建表时定义了 PRIMARY KEY (order_id),Paimon 自动对每个数据文件记录 order_id 的范围,裁剪就按主键来。append 表(两种表类型,第三节展开)没有主键,manifest 里没有主键级的 minKey/maxKey------普通列的 min/max 统计还有,但主键级裁剪做不了,只能靠分区字段 + 列统计裁剪。主键表有主键级裁剪、append 表没有,这是两种表的读取效率差异之一。

3 schema 版本:数据文件自己带着结构

除了 minKey/maxKey,manifest 里还记录每个数据文件是用哪个 schema 版本 写的------这就用到了 schema/ 目录。但有个更基础的事实先说:Parquet 文件本身是自描述的------文件尾部(footer)嵌着写入那一刻的 schema(列名、类型),读取时引擎先读 footer 拿到这个文件的 schema 直接解析,不需要问外部。

两层配合:

  • 文件级:每个数据文件带着自己写入时的 schema,老文件永远认得自己长什么样
  • 表级schema/ 目录存表结构的版本序列(schema-0、schema-1......),manifest 记录每个文件属于哪个版本

schema 演进时就看出价值了:表加了列,新文件带新 schema,老文件不动------读取按列名对齐,老文件缺新列就补 null,不用重写历史文件。时间旅行也跟着受益:读 snapshot-3 时读到的是当时写的文件,文件里嵌着当时的 schema,即使后来表结构变了,旧快照照样按当时的结构读出来。

对比 Hive:metastore 只存"当前"一份 schema,没有版本------表结构改了之后老文件和新定义对不上,只能靠查询引擎宽容处理,也没法回答"10:00 时表结构是什么"。

4 提交的原子性:靠文件系统

第一篇说了"提交要么全登记、要么不登记"。Paimon 的实现靠的是文件系统的 rename 原子性------利用 rename(重命名)操作本身是原子的这个特性,保证"提交"这个动作要么完整成功、要么完全没发生:

  1. 数据文件先写好------但此刻没有任何 manifest 引用它,读者看不到
  2. 创建 manifest 文件,记录新增的数据文件
  3. 创建 snapshot-N 文件,用 rename 操作写入 snapshot/ 目录
  4. HDFS/S3 的 rename/create 是原子的------snapshot-N 要么完整存在,要么不存在

读者只看 snapshot/ 目录里有哪些快照号。snapshot-N 出现 = 这次提交成功;没出现 = 失败,数据文件成了孤儿,后台清理掉。中间态永远不会被读到------第一篇说的 ACID,落地就是这个机制。

反过来想就更清楚:如果不用 rename,而是"先写半个 snapshot 再补另一半",那写了一半时读者看到的就是个残缺的文件清单。这正是 Hive 的问题------写一半的文件直接暴露在目录里,谁都能读到。rename 把"切换"变成一步到位的原子操作,中间态不存在。

5 时间旅行和回滚

有了快照,时间旅行和回滚就是"指认快照号":

sql 复制代码
-- 读 snapshot-5 那一刻的表
SELECT * FROM orders VERSION AS OF '5';

-- 回滚到 snapshot-5
CALL sys.rollback_to('orders', 5);

回滚不是删数据------只是把"最新快照"的指针从 snapshot-N 拨回 snapshot-5。snapshot-6 到 snapshot-N 的数据文件还在,可以再滚回去(除非快照过期被清理,第四篇讲过期策略)。

快照什么时候产生?------按 Checkpoint 提交。Flink 作业每次 Checkpoint 成功,Paimon sink 就提交一个快照。具体机制第三篇展开,这里先记住结论:一次 Checkpoint = 一个快照

二 Paimon主键更新机制:LSM 树怎么做

第一篇说 Paimon 用 LSM 树把"改"变成"追加",不用像 Hudi 那样 COW/MOR 二选一。现在拆开看 LSM 怎么做到的。

LSM 树(Log-Structured Merge Tree)最早是数据库的存储引擎数据结构(RocksDB、LevelDB、Cassandra 都在用),核心思路一句话:把随机写变成顺序写 ------修改不就地更新旧文件,而是追加新文件,读取时再合并取最新。传统 B+ 树改一条记录要先找到位置再原地改写(随机 I/O),LSM 把"改"变成"追加"(顺序 I/O),用读时 merge 的代价换写端极轻------这个 trade-off 在写多读少的场景非常划算。Paimon 把 LSM 树搬到了湖格式里:每个 bucket 内部就是一棵 LSM 树,上面那张目录结构里的 data-0-1.parquetdata-0-2.parquet 就是同一棵 LSM 树的不同 sorted run。

这里先消除一个看目录结构图容易产生的疑惑:LSM 树在磁盘上没有专门的目录,它的物理实体就是 bucket-N/ 目录下那堆数据文件。具体说:

  • LSM 树是逻辑结构,不是目录结构------它的"树干"就是 bucket-0/ 里那些 Parquet 文件,每个文件是一个 sorted run
  • 层级(L0、L1、L2...)不体现在目录里:所有 level 的文件平铺在同一个 bucket 目录下,从文件名看不出谁在第几层。层级关系记录在 manifest 里------每条文件元信息带 level 字段,读取时靠它重建"哪几个文件组成这棵树、各在第几层"
  • 所以一棵完整的 LSM 树 = bucket 目录里的数据文件 + manifest 里的 level 元信息,合起来才是

其中 sorted run 直译是"一趟有序数据":sorted 指文件内的行按主键排好序,run 指一次 flush 跑出来的这一趟。一次 flush 产生一个 sorted run(一个 Parquet 文件),多次 flush 产生多个------磁盘上平铺,逻辑上分层。画成图:

回头看第零节的目录结构:snapshot/、manifest/、schema/ 装的都是元数据(版本、索引、表结构),真正装数据的只有 bucket 目录。一句话:目录结构回答"文件放哪",LSM 回答"这些文件之间的版本关系怎么组织"------后者是叠加在数据文件上的读写规则,不占额外磁盘空间。

1 问题:订单改状态

订单 order_001 的状态从"待支付"改成"已支付"。Hive 的做法是找到 order_001 所在的文件,整个重写------代价跟着文件大小走。Paimon 的做法完全不同:不改旧文件,写一条新的

2 写路径:内存 → flush → sorted run

一条 upsert 进来,Paimon 的写入过程:

  1. 写内存:数据进入内存中的有序表(类似 RocksDB 的 memtable),按主键排序
  2. flush :内存攒到一定量,或者 Checkpoint 触发时,把内存数据刷成一个有序的数据文件------一个 sorted run
  3. 多次写 = 多个 sorted run 并存:order_001 第一次写产生 data-0-1(一个 sorted run),改状态再写产生 data-0-2(另一个 sorted run)------两个文件都在磁盘上,data-0-1 里有"待支付",data-0-2 里有"已支付"

关键点:写入只追加,不修改旧文件。data-0-1 写好之后就不动了------这就是"写端轻"的来源。和引子里两次 Checkpoint 产生 data-0-1、data-0-2 是同一件事,这里换 LSM 视角再看一遍。

这里要澄清一个容易混的点:写入瞬间主键表和 append 表是一样的------都是只追加新文件,不改旧文件。但两者追加的东西本质不同:

  • 主键表 追加的是有序的版本:数据进内存 sorted table 按主键排好,flush 出来的 sorted run 内部按主键有序,manifest 还记着 minKey/maxKey。正因为有序,"同一行的新旧两个版本"才能被识别出来------order_001 在 data-0-1 里是"待支付"、在 data-0-2 里是"已支付",靠主键就知道是同一行的演变。
  • append 表 追加的是无序的事件:没有主键、没有排序、没有 minKey/maxKey。同一条数据写两次就是两条独立事实,没有"新旧"关系可言,读取全部返回、不 merge、也不清理。

所以"所有变化都记录"这话只在有主键时才有意义------没有主键,谈不上"变化",只有"又发生了一件事"。CDC 语义(changelog 的 +I/-U/+U/-D:+I 新增、-U/+U 更新前/后值、-D 删除)正是从主键表的版本对比里产生的,append 表没有这个能力。

3 读路径:merge 取最新

查询 order_001 时,Paimon 怎么知道取哪个版本?------merge(合并同一主键的多个版本):

  1. 先确定读哪个 snapshot(默认最新,时间旅行则指定版本号)
  2. 拿那个 snapshot 的 base + delta manifest 合并出完整文件列表,用 minKey/maxKey 裁剪出包含 order_001 的 sorted run
  3. 同时读这些 sorted run,按主键合并------同一个主键有多条记录时,取写入时间最晚的那条,"已支付"覆盖"待支付"

注意:这里的 merge 不是"合并数据文件",而是合并同一主键的多个版本。如果 order_001 只有一个版本,merge 就是直接取它;如果有多个版本(比如"待支付"和"已支付"同时存在),merge 做的就是"取写入时间最晚的那个版本"------也就是你直觉里"直接取最新"的意思,只是换了个词。

物理上同一主键的所有版本都存着(data-0-1 的"待支付"和 data-0-2 的"已支付"并存),"最新"是读取时 merge 出来的逻辑结果,compaction 之前旧版本一直在磁盘上。代价是读时要合并多个版本,但 compaction 会定期把多个 sorted run 合成一个,控制读放大。

4 compaction:后台清理旧版本

sorted run 越攒越多,读时要 merge 的文件也越来越多。compaction 的工作就是把多个 sorted run 合并成一个,同时删掉被覆盖的旧版本

  • 合并前:data-0-1(order_001=待支付)、data-0-2(order_001=已支付)------两个文件
  • 合并后:data-0-3(order_001=已支付)------一个文件,旧版本删掉

上面的例子简化成每个文件只有 order_001 一条。实际一个 sorted run 里是几十上百万行,compaction 做的是全文件 merge:同时读 data-0-1 和 data-0-2 的所有行,按主键逐行对比------同一个主键在两个文件都有,取新版本;只有一个文件有,原样保留。data-0-1 里除了 order_001 的其他订单(order_002、order_003......)如果在 data-0-2 没有更新,就原样搬进 data-0-3,不会丢。

触发时机靠参数 num-sorted-run.compaction-trigger(默认 5):sorted run 数量到 5 就触发合并。这个参数权衡的是写放大和读放大------值小→合并频繁,读快但写放大;值大→合并少,写快但读要 merge 更多文件。具体调参第四篇给框架。

注意:这里的 compaction 是数据层 的------合并的是数据文件(sorted run),清理的是旧版本记录。和前面快照部分讲的 manifest compaction 是两件独立的事:manifest compaction 合并的是清单文件(delta manifest),控制的是元数据读放大;数据 compaction 合并的是数据文件,控制的是数据读放大。两者触发条件不同,互不影响------即使数据 compaction 把 data-0-1 和 data-0-2 合成了 data-0-3,manifest 层也只是记一条"删除 data-0-1、删除 data-0-2、新增 data-0-3"的 delta,manifest 不关心数据文件内容怎么变,只记录文件增删。

5 和 Hudi COW/MOR 对比:不用二选一

第一篇留的对比在这里回收:

Hudi COW Hudi MOR Paimon LSM
写入 重写整个文件 追加日志文件 追加 sorted run
读取 直接读(写时已合并) 合并 base + log merge 多个 sorted run
合并 写时做(写放大大) 后台 compaction 后台 compaction
架构选择 二选一 二选一 不用二选一,调 compaction 参数即可

Paimon 的 LSM 本质上是 MOR 的思路------"写时追加,读时合并,后台 compaction"------但不用像 Hudi 那样在 COW 和 MOR 之间做架构级选择。Hudi 的 COW 和 MOR 是两套不同的写法和读法,选错了改架构成本大;Paimon 只有一种机制,不用在"写时合并"和"读时合并"之间做架构级选择------调 compaction 参数就行,不需要重构表结构。

三 Paimon的两种模式:主键表 vs append 表

Paimon 有两种表类型,区别就在有没有主键------也就是有没有上面那套 LSM。

1 主键表(Primary Key Table)

建表时定义了 PRIMARY KEY:

sql 复制代码
CREATE TABLE orders (
    order_id STRING,
    order_status STRING,
    city STRING,
    amount DECIMAL(10,2),
    order_time TIMESTAMP(3),
    PRIMARY KEY (order_id) NOT ENFORCED
) WITH (...);
  • 有 LSM:同一条记录多次写,读取只呈现最新版本
  • 支持 upsert:相同 order_id 再写就是更新
  • 支持 changelog 流读:+I/-U/+U/-D
  • 适合:订单表、维表(状态会变的数据)

2 append 表(Append Table)

没有主键:

sql 复制代码
CREATE TABLE raw_events (
    event_time TIMESTAMP(3),
    event_type STRING,
    payload STRING
) WITH (...);
  • 没有 LSM:写一条就是一条,不去重
  • 只追加:相同数据写两次就是两条
  • 没有 changelog(只有 +I append 事件)
  • 适合:原始日志、埋点数据(不会改的数据)

append 表看起来像"没有 LSM 的 Hive 目录",但差别不小------它有 snapshot → manifest → schema 的完整版本体系:

  • 有快照:写入先落文件、快照提交了才可见,ACID 保证。Hive 目录没有版本,写一半的文件直接暴露
  • 有 manifest:查询不用列目录,manifest 里有每个文件的统计信息(min/max、null count),可以裁剪跳过不需要的文件。Hive 查询只能列目录扫文件
  • schema 演进:加列不用重写历史文件,读取按列名对齐补 null。Hive 的 metastore 只有"当前"一份 schema
  • 和主键表共用查询引擎:append 表和主键表用同一套 snapshot + manifest + schema 机制,可以互转、共享查询接口

用一句对比:Hive 是"纯文件目录",append 表是"带版本的文件目录"------差的是 upsert 和 changelog 流读,版本管理和查询效率都在。

那 append 表没有 LSM,是不是也没有 bucket?不是------bucket 和 LSM 是两件事

  • bucket 回答"数据写进哪个目录"------管的是并行度,两种表都需要
  • LSM 回答"同一个主键的多个版本在桶里怎么存"------只有主键表需要

append 表建表时同样可以指定 bucketbucket-key(比如按 event_type 哈希分桶),数据按哈希路由进 bucket-N 目录,桶里就是一堆顺序追加的 Parquet 文件------没有 sorted run、没有 merge、没有 compaction。桶的作用只是分目录攒文件,让 Flink 可以多路并行写。不指定 bucket 也行(unbucketed,不分桶),数据直接堆在(分区)目录下,适合纯批场景------没有流式并行写的需求,分桶就没必要。

3 对比

主键表 append 表
主键
存储 LSM 树(sorted run + merge) 纯文件追加
bucket ✓ 每桶一棵 LSM 树 ✓ 可选,桶内无 LSM
更新 ✓ 原生 upsert ✗ 只追加
changelog 流读 ✓ +I/-U/+U/-D △ 只有 +I
读取 需 merge 直接读
compaction 需要 不需要
写放大 有(compaction 带来)
适合 订单、维表 原始日志、事件流

订单链路里:订单表、城市维表、商品维表、订单宽表都是主键表------因为状态会变、要 upsert。如果只是存一份原始 binlog 日志(不改、不查,只归档),append 表更轻。什么样的数据该用哪种------第四篇给完整的选型框架,这里先记住:要改的用主键表,只追加的用 append 表

四 Paimon并行度管理:bucket

1 bucket 是什么

LSM 树是 bucket 内部的事。bucket 是上面一层------一张表横向切成多少份。层级关系:

复制代码
表 → 分区 → bucket → sorted run

orders/
├── partition=2026-08-29/
│   ├── bucket-0/    ← 一个独立的 LSM 树
│   ├── bucket-1/    ← 一个独立的 LSM 树
│   └── bucket-2/
└── partition=2026-08-30/
    ├── bucket-0/
    ├── bucket-1/
    └── bucket-2/

每个 bucket 是一个独立的 LSM 树------这句是对主键表说的。bucket 本身是并行度单位,不是 LSM 的附属品:append 表同样有 bucket,只是桶里装的不是 LSM 树,就是一堆顺序追加的文件。有没有 LSM 决定"桶里怎么存",有没有 bucket 决定"写进哪个目录",两件事正交。

bucket 之间数据互不重叠------主键表按 hash(primary key) % bucket_count 分配,同一个 order_id 永远落在同一个 bucket 里。

2 bucket = 并行度

bucket 是读写并行度的最小单位:

  • :Flink sink 的并行度受 bucket 数约束------一个 task 写一个 bucket
  • :Spark / Flink 一个 task 读一个 bucket

bucket 数 = 最大并行度。3 个 bucket,写并行度最多 3;想 10 路并行写,至少 10 个 bucket。

3 bucket 数量的权衡

bucket 太少 bucket 太多
并行度不够,写入吞吐上不去 每个 bucket 数据量小,小文件多
单个 bucket LSM 层级深,读放大大 metadata 开销大(每 bucket 独立 manifest)

bucket 数量怎么定------涉及数据量、写入吞吐、查询并行度------第四篇给判断框架,这里先记住它是并行度的天花板。

五 一条订单从写入到被读:把架构串起来

前面的概念是零件,现在组装。一条订单从 Kafka 写进 Paimon、再被 Spark / 下游 Flink 读出来,完整路径:

写入:Kafka 订单流 → Flink writer → 写进 Paimon 表(提交 snapshot)

  1. 订单从 Kafka 进 Flink
  2. Flink 按 hash(order_id) % bucket_count 路由到对应 bucket 的 writer
  3. writer 把数据写进内存 sorted table
  4. Checkpoint 触发:内存数据 flush 成 sorted run(Parquet 文件)
  5. 生成 manifest,创建 snapshot-N
  6. snapshot-N 写入 snapshot/ 目录------这一刻数据对读者可见

批读:Spark 从 Paimon 表的最新 snapshot 读全量

  1. Spark 读最新的 snapshot-N
  2. 从 snapshot 拿到 manifest 列表
  3. 主键表:用 manifest 的 minKey/maxKey 裁剪出需要的数据文件,merge 多个 sorted run,取每个主键的最新版本
  4. append 表:没有主键级裁剪,靠分区字段 + 列统计裁剪;读到文件直接返回,不 merge

流读:下游 Flink 作业从 Paimon 表新 snapshot 的 changelog 读增量

  1. 下游 Flink 作业(和写入不是同一个作业)持续监听这张表的新快照
  2. 新快照出现 → 读它的 changelog manifest,拿到本次新增/删除的文件
  3. 对比前后版本输出 +I/-U/+U/-D 变更流------这就是 Flink 系列里 Temporal Join 维表"按事件时间翻版本"的来源

三个角色各管一摊:快照管版本(什么时候可见)、LSM 管数据(怎么存怎么读)、bucket 管并行(切成多少份写)。

六 验证:动手操作一遍

引子里用 order_001 走了全流程,这里给出实际 SQL,可以自己建表验证。

sql 复制代码
-- 建表(Spark SQL,第一篇的约束:Paimon 表用 Spark 建)
CREATE TABLE orders (
    order_id STRING,
    order_status STRING,
    city STRING,
    amount DECIMAL(10,2),
    order_time TIMESTAMP(3),
    PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
    'connector' = 'paimon',
    'bucket' = '2',
    'warehouse' = 's3://warehouse'
);

写两条订单(对应引子第一次 Checkpoint):

sql 复制代码
INSERT INTO orders VALUES
    ('order_001', '待支付', '北京', 100.00, TIMESTAMP '2026-08-29 10:00:00'),
    ('order_002', '待支付', '上海', 200.00, TIMESTAMP '2026-08-29 10:00:00');

改一条状态(对应引子第二次 Checkpoint):

sql 复制代码
INSERT INTO orders VALUES
    ('order_001', '已支付', '北京', 100.00, TIMESTAMP '2026-08-29 10:00:00');

两次写入后磁盘上的文件变化------就是引子里展示的那样:data-0-1 不动,data-0-2 追加,snapshot-1 和 snapshot-2 各对应一次 Checkpoint。等 compaction 跑完,data-0-1 和 data-0-2 合并成新文件 data-0-3,两个旧文件被清理。

查快照清单验证:

sql 复制代码
SELECT * FROM orders$snapshots;
-- snapshot_id | commit_user  | commit_time           | record_count
-- 1           | user-flink   | 2026-08-29T10:00:00Z  | 2
-- 2           | user-flink   | 2026-08-29T10:01:00Z  | 2

order_001 从"待支付"改成"已支付",record_count 还是 2------因为主键表是 upsert,不是新增一行,而是同一行更新。这也验证了 LSM 的核心:写入是追加,但逻辑上是更新

七 小结

  • 快照:snapshot 指向 manifest,manifest 指向数据文件------继承 Iceberg 的设计;提交靠文件系统 rename 原子性保证;一次 Checkpoint = 一个快照
  • LSM 树:写入只追加 sorted run,读取 merge 取最新,compaction 后台清理------把"改"变成"追加",不用 COW/MOR 二选一
  • 两种表:主键表有 LSM(能 upsert、有 changelog);append 表纯追加(轻、快、但不能改)
  • bucket:主键表每个 bucket 一棵独立 LSM 树,bucket 数 = 最大并行度

下一篇把这些零件装进工作流程:Flink 怎么按 Checkpoint 提交、changelog 怎么流出、Spark 怎么批读、外部存储怎么选------架构图里的每条线,对应实际操作。

相关推荐
双眼鈹35 分钟前
周报8.31
笔记·学习
pnoker43 分钟前
拆解 IoT DC3:六层微服务架构
java·物联网·spring cloud·微服务·架构
sukioe1 小时前
城智连响:基于 LangGraph 与四库分层架构的城市公共设施智能报修与派单系统
人工智能·python·ai·架构·langchain
harmony&1 小时前
Docker 容器技术从入门到实战:生态系统、安装部署与架构详解
docker·容器·架构
打团从来人不齐1 小时前
Linux服务器搭建笔记-008:台式机共享WIFI
linux·服务器·笔记
2601_962073971 小时前
大数据-258 离线数仓 - Griffin架构 配置安装 Livy 架构设计 解压配置 Hadoop Hive
大数据·hadoop·架构
疯狂打码的少年1 小时前
【数据库技术】SQL概述与数据定义(DDL:CREATE/DROP/ALTER)
数据库·笔记·sql·oracle
硬件yun1 小时前
学功能安全
学习·安全·汽车
火眼金睛炼单词1 小时前
图像记单词:视觉化学习能否破解英语记忆难题
学习