Skip to content

S1 第 2 期:打开一张 Paimon 表的目录,里面都是什么 ​

Apache Paimon 源码学习

作者 X老师(DaemonforY),Paimon 2.0 / master 源码,按 CC BY-NC-SA 4.0 发布。配套实验和代码在 GitHub。

Paimon表目录中数据与多层元数据的全貌
Paimon表目录中数据与多层元数据的全貌AI 生成配图

TL;DR:一张 Paimon 表就是一个目录。数据在 分区/bucket-N/ 下的 parquet 文件里;**“哪些数据文件属于当前版本”**由 snapshot → manifest list → manifest 三层元数据描述。读数据时,Paimon 先找到最新的 snapshot,再顺着清单找到数据文件。

实验:建一张表,写 5 行 ​

sql
CREATE TABLE orders (
  order_id BIGINT, user_id BIGINT, amount DECIMAL(10,2), status STRING, dt STRING,
  PRIMARY KEY (dt, order_id) NOT ENFORCED
) PARTITIONED BY (dt) WITH ('bucket' = '2');

INSERT INTO orders VALUES
  (1, 101, 99.90, 'CREATED', '2026-09-24'), (2, 102, 15.00, 'CREATED', '2026-09-24'),
  (3, 103, 250.00, 'CREATED', '2026-09-24'), (4, 101, 8.80, 'CREATED', '2026-09-25'),
  (5, 104, 66.60, 'CREATED', '2026-09-25');

写完后,orders 表目录里一共 10 个文件(在表目录下用 find . -type f -exec ls -l {} \; | awk '{print $5, $9}' | sort -k2 列出,左列为字节数):

1979 ./dt=2026-09-24/bucket-0/data-76887e73-7107-4449-b106-dc461ac9be77-0.parquet
1827 ./dt=2026-09-24/bucket-1/data-b789be4f-f68d-455b-8be9-b2556462800d-0.parquet
1979 ./dt=2026-09-25/bucket-0/data-6ab8c9c6-2160-4639-ac31-a928934097d7-0.parquet
2270 ./manifest/manifest-832affb5-9651-4dbd-8d15-931e3d1ae4a0-0
1006 ./manifest/manifest-list-9565fe2b-61a5-47b9-afc4-df6cc1dcbab2-0
1132 ./manifest/manifest-list-9565fe2b-61a5-47b9-afc4-df6cc1dcbab2-1
587 ./schema/schema-0
1 ./snapshot/EARLIEST
1 ./snapshot/LATEST
595 ./snapshot/snapshot-1

(文件名中的 UUID 每次运行都不同,字节数以 Paimon 2.0.0 为准。)

从下往上看。

1. 数据文件:分区/bucket-N/data-*.parquet ​

分区桶中存放并行读写的数据文件
分区桶中存放并行读写的数据文件AI 生成配图
  • dt=2026-09-24 是分区目录(PARTITIONED BY (dt))。
  • bucket-0、bucket-1 是桶:每个分区内按主键哈希分成 2 个桶('bucket' = '2')。桶是 Paimon 读写并行的最小单位,每个桶内部是一棵独立的 LSM 树。
  • 一个有意思的细节:dt=2026-09-25 只有 bucket-0,没有 bucket-1。因为这个分区的两个订单(4 和 5)哈希后都落在了 0 号桶,1 号桶没有数据,就不会创建目录。分桶公式是 abs(主键哈希 % 桶数)。
  • 这次写入一共产生了 3 个数据文件,日志里也印证了这一点:“number of commit messages: 3”——每个有数据的桶一条提交信息。

2. snapshot:一个版本的“入口” ​

snapshot/snapshot-1 是一个 JSON 文件,完整内容如下(下面只解释其中几个关键字段):

json
{
  "version" : 3,
  "uuid" : "fb1a82a0-0a23-4246-a139-d8535b3abffe",
  "id" : 1,
  "schemaId" : 0,
  "baseManifestList" : "manifest-list-9565fe2b-61a5-47b9-afc4-df6cc1dcbab2-0",
  "baseManifestListSize" : 1006,
  "deltaManifestList" : "manifest-list-9565fe2b-61a5-47b9-afc4-df6cc1dcbab2-1",
  "deltaManifestListSize" : 1132,
  "commitUser" : "eda2c146-bb92-4071-8aa1-b47950558eec",
  "commitIdentifier" : 9223372036854775807,
  "commitKind" : "APPEND",
  "timeMillis" : 1791118454246,
  "totalRecordCount" : 5,
  "deltaRecordCount" : 5,
  "watermark" : -9223372036854775808,
  "nextRowId" : 0
}
  • id:版本号,每次提交 +1。
  • schemaId:这个版本用的表结构,对应 schema/schema-0。
  • baseManifestList:上一个版本的全量文件清单。这是第一次提交,没有历史,所以它是个空清单(只有 1006 字节)。
  • deltaManifestList:本次提交新增/删除了哪些文件。
  • commitKind:APPEND 表示普通写入;以后还会看到 COMPACT(合并)、OVERWRITE(覆盖)。
  • totalRecordCount / deltaRecordCount:总记录数、本次新增记录数。

一个快照 = 某一时刻“哪些数据文件有效”的完整清单。 读最新数据就是读最新快照,读历史数据(时间旅行)就是读旧快照。

3. manifest list 与 manifest:两级文件清单 ​

快照通过清单逐层指向真实数据文件
快照通过清单逐层指向真实数据文件AI 生成配图
  • manifest list 是“清单的清单”:记录了若干个 manifest 文件的名字和统计信息。
  • manifest 记录具体的数据文件条目:ADD 或 DELETE + 文件元数据(所在分区、桶、level、主键范围、行数、统计信息……)。
  • 用系统表 orders$manifests 可以看到:这个 manifest 里有 3 条 ADD(num_added_files = 3),正好对应 3 个数据文件。

为什么要两级?文件数量多了以后,每次提交只需要写新增部分的 manifest,旧的 manifest 可以复用,提交不会随表变大而变慢。

4. LATEST / EARLIEST:两个“提示”文件 ​

  • LATEST 内容是 1,EARLIEST 内容也是 1:最新和最早的快照号。
  • 注意它们只是提示(hint)。源码里(HintFileUtils.findLatest)读到 LATEST = N 后,还会检查 snapshot-(N+1) 是否存在;如果存在,说明提示过期了,就退回到列目录查找。所以就算这两个文件更新不及时,也不会读错版本。

5. schema:表结构的版本 ​

schema/schema-0 记录了字段、分区键、主键和表参数(完整内容):

json
{
  "version" : 3,
  "id" : 0,
  "fields" : [ {
    "id" : 0,
    "name" : "order_id",
    "type" : "BIGINT NOT NULL"
  }, {
    "id" : 1,
    "name" : "user_id",
    "type" : "BIGINT"
  }, {
    "id" : 2,
    "name" : "amount",
    "type" : "DECIMAL(10, 2)"
  }, {
    "id" : 3,
    "name" : "status",
    "type" : "STRING"
  }, {
    "id" : 4,
    "name" : "dt",
    "type" : "STRING NOT NULL"
  } ],
  "highestFieldId" : 4,
  "partitionKeys" : [ "dt" ],
  "primaryKeys" : [ "dt", "order_id" ],
  "options" : {
    "bucket" : "2"
  },
  "comment" : "",
  "timeMillis" : 1791118447925
}

每个字段都有一个 id。以后修改表结构(加列、改名)会生成 schema-1,Paimon 靠字段 id 而不是名字来对应新旧文件里的列。

一个能直接证明这一点的实验(仓库实验 6,用的是另一张表 schema_demo (order_id, status, amount),字段 id 依次为 0、1、2):删掉 amount 列再加回一个同名的 amount 列,旧数据的 amount 全部变成 NULL——因为新列拿到的是新 id(4),旧文件里的数据属于 id 2,对不上了。改列名则相反:status 改名为 order_status 后 id 仍是 1,旧数据照常读出。

一张图串起来:读数据时发生了什么 ​

LATEST(提示) ──► snapshot-1 ──► manifest-list ──► manifest ──► 3 个 parquet 文件
                    │
                    └──► schema-0(用哪个表结构解析)

小结 ​

文件作用
分区/bucket-N/data-*.parquet真正的数据
manifest-*数据文件条目(ADD/DELETE + 元数据)
manifest-list-*manifest 的清单
snapshot-N一个版本的入口:指向 base/delta 清单
LATEST / EARLIEST最新/最早快照号的提示
schema-N表结构版本

下期预告:同样往表里插两条一模一样的数据,主键表和 Append 表的结果天差地别。

自测题:如果手动删掉 snapshot/LATEST,这张表还能正常读吗?为什么?

代码示例在页面里运行时使用 HiveGPT 的模型接口。延伸阅读来自 JavaGuide(Apache-2.0),版权归原作者。