第一篇把 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(重命名)操作本身是原子的这个特性,保证"提交"这个动作要么完整成功、要么完全没发生:
- 数据文件先写好------但此刻没有任何 manifest 引用它,读者看不到
- 创建 manifest 文件,记录新增的数据文件
- 创建 snapshot-N 文件,用 rename 操作写入
snapshot/目录 - 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.parquet、data-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 的写入过程:
- 写内存:数据进入内存中的有序表(类似 RocksDB 的 memtable),按主键排序
- flush :内存攒到一定量,或者 Checkpoint 触发时,把内存数据刷成一个有序的数据文件------一个 sorted run
- 多次写 = 多个 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(合并同一主键的多个版本):
- 先确定读哪个 snapshot(默认最新,时间旅行则指定版本号)
- 拿那个 snapshot 的 base + delta manifest 合并出完整文件列表,用 minKey/maxKey 裁剪出包含 order_001 的 sorted run
- 同时读这些 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 表建表时同样可以指定 bucket 和 bucket-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):
- 订单从 Kafka 进 Flink
- Flink 按
hash(order_id) % bucket_count路由到对应 bucket 的 writer - writer 把数据写进内存 sorted table
- Checkpoint 触发:内存数据 flush 成 sorted run(Parquet 文件)
- 生成 manifest,创建 snapshot-N
- snapshot-N 写入
snapshot/目录------这一刻数据对读者可见
批读:Spark 从 Paimon 表的最新 snapshot 读全量:
- Spark 读最新的 snapshot-N
- 从 snapshot 拿到 manifest 列表
- 主键表:用 manifest 的 minKey/maxKey 裁剪出需要的数据文件,merge 多个 sorted run,取每个主键的最新版本
- append 表:没有主键级裁剪,靠分区字段 + 列统计裁剪;读到文件直接返回,不 merge
流读:下游 Flink 作业从 Paimon 表新 snapshot 的 changelog 读增量:
- 下游 Flink 作业(和写入不是同一个作业)持续监听这张表的新快照
- 新快照出现 → 读它的 changelog manifest,拿到本次新增/删除的文件
- 对比前后版本输出 +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 怎么批读、外部存储怎么选------架构图里的每条线,对应实际操作。