在 表格式为什么存在
里,我们把问题归到一句话:对象存储不能原子
rename、LIST 很贵,所以「目录即表」的 Hive
模式既给不出原子提交,也给不出快照隔离,planning
还要随分区数线性 LIST。Iceberg
的回答是把「表当前由哪些文件组成」这件事,从「扫目录」变成「读一棵不可变元数据树」。
这棵树有四层:catalog 指针 →
vN.metadata.json → manifest list(一个 manifest
list 文件对应一个 snapshot)→ manifest file → data
file。本文逐层打开它,重点回答三件事:
- 每一层各存什么、为什么这么分层;
- snapshot 与 manifest 里的分区数据与列级统计(lower/upper bound、null count、value count)长什么样;
- 一次查询如何只靠读元数据(不
LIST目录)就把候选收敛到一小撮 data file,即 Iceberg 的 metadata planning。
证据锚定 Apache Iceberg 表规范(iceberg.apache.org/spec,以
V2 为主线,V3 特性单独标注)与 apache/iceberg
1.x 源码的类名。实验在本机用 pyiceberg 0.11.1 +
pyarrow 24.0.0 真实建表、逐层 dump,正文中带
== 标记的输出均来自实跑。
实验环境:Intel Core i9-12900K,32 GB 内存,Linux 6.6(WSL2,Arch),Python 3.14.5,pyiceberg 0.11.1,pyarrow 24.0.0。catalog 用 SQLite(
pyiceberg.catalog.sql.SqlCatalog),warehouse 为本地文件系统file:///tmp/ice_wh。建表默认format-version = 2。
一、四层元数据树全景
先给全景,后面每层再钻进去。Iceberg 表规范《Overview》节把这套结构概括为:表状态保存在 metadata 文件里,任何改动都生成一个新的 metadata 文件并通过原子 swap 替换旧指针;一个 snapshot 通过一个 manifest list 引用一组 manifest,manifest 再逐行记录 data file、其分区元组与指标。
flowchart TD
CAT["catalog 指针<br/>表名 → 当前 metadata 位置"]
META["vN.metadata.json<br/>schema / partition-specs / snapshots 列表"]
ML["manifest list(= 一个 snapshot)<br/>snap-*.avro,每行一个 manifest"]
MAN["manifest file<br/>*-m0.avro,每行一个 data/delete file"]
DATA["data file<br/>*.parquet(含列级 stats)"]
CAT -->|原子 swap| META
META -->|current-snapshot-id| ML
ML -->|manifest_path + 分区摘要| MAN
MAN -->|file_path + 列 stats| DATA
每层的职责与载体:
| 层 | 载体 | 关键内容 | 谁写谁读 |
|---|---|---|---|
| catalog 指针 | catalog 后端(DB 行 / REST / 文件) | 表名 → 当前 metadata.json 位置 |
提交时 CAS swap |
| 表元数据 | vN.metadata.json |
schema、partition-specs、sort-orders、snapshots 列表、当前 snapshot id、各类 log | 每次提交生成新版本 |
| manifest list | snap-<sid>-….avro(Avro) |
一个 snapshot 引用的所有 manifest,每行带分区摘要与计数 | planning 第一跳 |
| manifest file | <uuid>-m0.avro(Avro) |
一组 data/delete file,每行带分区元组与列级 stats | planning 第二跳 |
| data file | *.parquet/*.orc/*.avro |
实际数据;列级 min/max 等也回写进上层 manifest | 实际扫描 |
四层里只有 catalog
指针是可变的(每次提交把它指向新的
metadata.json);其余三层文件全部不可变,新提交写新文件、复用旧文件,旧
snapshot 因此仍可读——这是时间旅行与并发的物理基础(见 提交协议与并发控制)。
二、实验:建表、写入、落盘
下面这段是真实跑过的脚本骨架(已删去 import
细节),建一张按 day(ts)
分区的表,追加三次:
from pyiceberg.catalog.sql import SqlCatalog
from pyiceberg.schema import Schema
from pyiceberg.types import NestedField, LongType, TimestampType, StringType, DoubleType
from pyiceberg.partitioning import PartitionSpec, PartitionField
from pyiceberg.transforms import DayTransform
catalog = SqlCatalog("demo", uri="sqlite:////tmp/ice_wh/catalog.db",
warehouse="file:///tmp/ice_wh")
catalog.create_namespace("sales")
schema = Schema(
NestedField(1, "id", LongType(), required=True),
NestedField(2, "ts", TimestampType(), required=False),
NestedField(3, "category", StringType(), required=False),
NestedField(4, "amount", DoubleType(), required=False),
)
spec = PartitionSpec(
PartitionField(source_id=2, field_id=1000, transform=DayTransform(), name="ts_day")
)
tbl = catalog.create_table("sales.orders", schema=schema, partition_spec=spec)
tbl.append(batch_day1) # id 1,2 ── 2026-01-01
tbl.append(batch_day2) # id 3,4 ── 2026-01-02
tbl.append(batch_day3) # id 5 ── 2026-01-02三次 append 后的 snapshot 链(实跑输出,字段顺序:snapshot_id / parent / sequence_number / operation / summary):
== snapshots after 3 appends ==
3880697074975577261 None 1 APPEND {added-data-files:1, added-records:2, total-data-files:1, total-records:2, ...}
155716870697618587 3880697074975577261 2 APPEND {added-data-files:1, added-records:2, total-data-files:2, total-records:4, ...}
3919319797156717390 155716870697618587 3 APPEND {added-data-files:1, added-records:1, total-data-files:3, total-records:5, ...}
每次 append 产生一个新
snapshot,parent-snapshot-id
串成链,sequence-number
单调递增(1、2、3)。summary
里既有增量(added-data-files)也有累计(total-data-files、total-records)——这些计数让运维不用展开文件就能看表的规模。
落盘文件(实跑,本系列后面的隐藏分区演进会再追加一次,这里先看三次
append
之后的形态;完整文件清单见第七节的演进结果)。先看一次完整跑完(含后续演进)后的
metadata/ 目录:
== metadata files on disk ==
00000-….metadata.json 866 bytes # create_table
00001-….metadata.json 1686 bytes # append #1
00002-….metadata.json 2445 bytes # append #2
00003-….metadata.json 3210 bytes # append #3
00004-….metadata.json 3529 bytes # update partition spec(演进,见第 9 篇)
00005-….metadata.json 4288 bytes # append #4(演进后)
11892241-…-m0.avro 4634 bytes # manifest(append #1)
b88156f1-…-m0.avro 4639 bytes # manifest(append #2)
6d7cc781-…-m0.avro 4617 bytes # manifest(append #3)
1dd7074d-…-m0.avro 5058 bytes # manifest(append #4)
snap-5050526576113921271-….avro 1734 bytes # manifest list(snapshot #1)
snap-3131141492607277098-….avro … # manifest list(snapshot #2)
snap-5375278359232776294-….avro … # manifest list(snapshot #3)
snap-9121353935807547174-….avro … # manifest list(snapshot #4)
三类文件一目了然:*.metadata.json
是表元数据的版本序列,snap-*.avro 是 manifest
list(一个对应一个 snapshot),*-m0.avro 是
manifest file。下面逐层打开。
三、第一层:catalog
指针与 metadata.json
catalog 指针:表的唯一可变量
catalog 的核心职责只有一件:把表名映射到当前 metadata 文件位置,并提供一次原子替换。Iceberg 表规范《Table Metadata》与《Commit Conflict Resolution and Retry》把两类 catalog 的原子性来源分得很清:
- Metastore
tables:当前指针存在元数据库的一行里,提交时用「当前指针
==
期望旧值」的条件更新(compare-and-swap)保证原子;对应源码入口
org.apache.iceberg.BaseMetastoreTableOperations(apache/iceberg 1.x)。 - File system tables:靠文件系统的原子
rename 选出
vN+1.metadata.json作为新版本,因此在不能原子 rename 的纯对象存储上不安全——这也是生产几乎都用 REST/JDBC/Glue 等 catalog,而非 Hadoop file-based catalog 的原因。
本文实验用 SQLite catalog,指针就是 SQLite 里的一行;每次
append 都把它从 0000N 切到
0000N+1。提交协议细节留到第 11
篇,这里只需记住:整棵树里唯一被原地改写的,是
catalog 的这一个指针。
metadata.json
字段
metadata.json 是表的「根」。实跑 dump
最新版本(00005-….metadata.json)的关键字段:
== latest metadata.json ==
format-version: 2
table-uuid: be29d8e3-bca2-4f07-9698-d5f363684142
last-sequence-number: 4
last-column-id: 4
current-schema-id: 0
default-spec-id: 1
last-partition-id: 1001
current-snapshot-id: 214609383314602773
default-sort-order-id: 0
schemas: [{"type":"struct","fields":[
{"id":1,"name":"id","type":"long","required":true},
{"id":2,"name":"ts","type":"timestamp","required":false},
{"id":3,"name":"category","type":"string","required":false},
{"id":4,"name":"amount","type":"double","required":false}],
"schema-id":0,"identifier-field-ids":[]}]
partition-specs: [
{"spec-id":0,"fields":[{"source-id":2,"field-id":1000,"transform":"day","name":"ts_day"}]},
{"spec-id":1,"fields":[{"source-id":2,"field-id":1000,"transform":"day","name":"ts_day"},
{"source-id":3,"field-id":1001,"transform":"bucket[4]","name":"category_bucket"}]}]
#snapshots: 4
snapshot-log: [{"snapshot-id":3880…,"timestamp-ms":…}, … 共 4 条]
对照表规范《Table Metadata Fields》,几个字段值得记住:
| 字段 | 含义 | 实跑值 |
|---|---|---|
format-version |
表规范版本(1/2/3) | 2 |
table-uuid |
表唯一标识,重建同名表也会变 | 一个 UUID |
last-sequence-number |
已分配的最大 sequence number | 4(四次提交) |
last-column-id |
已分配的最大列 id,schema 演进据此分配新 id | 4 |
schemas /
current-schema-id |
所有历史 schema 与当前 schema | 一份 schema,id=0 |
partition-specs /
default-spec-id |
所有历史 partition spec 与当前默认 spec | 两个 spec(演进,见第 9 篇) |
last-partition-id |
已分配的最大 partition field id | 1001 |
snapshots /
current-snapshot-id |
所有 snapshot 与当前 snapshot | 4 个 |
snapshot-log |
当前分支的 snapshot 时间线 | 4 条 |
sort-orders /
default-sort-order-id |
排序规则 | 0(unsorted) |
两个结构性要点:
- schema 与 partition spec 都是「历史全集 +
当前指针」,不是只存当前。列用全局唯一的 field id
标识(这里
id 1..4),分区字段也有独立的 partition field id(1000、1001)。schema/分区演进因此可以做到不重写历史数据——老文件按它当年的 schema/spec 解释,靠 id 而非位置对齐。这一点是第 9 篇(分区演进)和第 16 篇(schema 演进)的地基。 - snapshot 列表内嵌在
metadata.json里,但每个 snapshot 真正引用的 manifest 列表在另一个文件(manifest list)。这样metadata.json本身保持「小而全」,不随数据文件数膨胀。
四、第二层:snapshot 与 manifest list
snapshot:某一时刻的表内容
表规范《Snapshots》定义一个 snapshot
至少包含:snapshot-id、parent-snapshot-id、sequence-number、timestamp-ms、manifest-list(指向该
snapshot 的 manifest list
文件)、summary、schema-id。实跑
dump 当前 snapshot:
one snapshot: {
"snapshot-id": 214609383314602773,
"parent-snapshot-id": 3919319797156717390,
"sequence-number": 4,
"timestamp-ms": 1782830431517,
"manifest-list": "file:///tmp/ice_wh/sales/orders/metadata/snap-214609383314602773-0-….avro",
"summary": {"operation":"append","added-data-files":"2","added-records":"2",
"changed-partition-count":"2","total-data-files":"5","total-records":"7",
"total-delete-files":"0","total-position-deletes":"0","total-equality-deletes":"0"},
"schema-id": 0}
snapshot 不直接列文件,而是通过
manifest-list 指向一个 manifest list
文件。summary 里
total-data-files: 5 / total-records: 7
是全表累计——读这一个字段就知道表有多大,不必展开任何
manifest。
manifest list:一个 snapshot 引用的 manifest 清单
manifest list 是一个 Avro 文件,每行描述一个
manifest。表规范《Manifest Lists》给出
manifest_file 结构的字段(带 field id)。实跑
table.inspect.manifests()(当前
snapshot):
== inspect.manifests (current snapshot) ==
content path(...-m0.avro) length spec_id added_snapshot_id added existing deleted partition_summaries
0 1dd7074d-… 5058 1 9121353935807547174 2 0 0 [{null:F,nan:F,low:2026-01-03,up:2026-01-03}, {null:F,nan:F,low:0,up:2}]
0 6d7cc781-… 4617 0 5375278359232776294 1 0 0 [{null:F,nan:F,low:2026-01-02,up:2026-01-02}]
0 b88156f1-… 4639 0 3131141492607277098 1 0 0 [{null:F,nan:F,low:2026-01-02,up:2026-01-02}]
0 11892241-… 4634 0 5050526576113921271 1 0 0 [{null:F,nan:F,low:2026-01-01,up:2026-01-01}]
逐列对照规范:
| 字段(field id) | 含义 | 实跑解读 |
|---|---|---|
content(517) |
0=data manifest,1=delete manifest | 全 0(本例无 delete) |
manifest_path(500) |
manifest 文件位置 | 四个 *-m0.avro |
manifest_length(501) |
manifest 字节数 | 4617–5058 |
partition_spec_id(502) |
该 manifest 用的 spec id | 三个老 manifest = 0,新的 =
1 |
added_snapshot_id(503) |
加入该 manifest 的 snapshot | 各自的提交 |
added/existing/deleted_files_count(504/505/506) |
三种状态文件数 | 这里都是 added |
partitions(507,field_summary
列表) |
每个分区字段一个摘要 | 见下 |
field_summary(规范字段
509–518)对该 manifest
内所有文件的每个分区字段给出
contains_null、contains_nan、lower_bound、upper_bound。第一行那个
spec=1 的 manifest
有两个摘要:ts_day ∈ [2026-01-03, 2026-01-03]、category_bucket ∈ [0, 2]。这正是
planning 第一跳的剪枝依据:只看 manifest
list,就能判定一个 manifest
是否可能含有匹配分区的文件,从而整段跳过。
这对应表规范《Goals》里的硬目标:planning 用 O(1) 次远程调用,而不是随分区数 O(n)。manifest list 把「分区摘要 + 文件计数」前置到一个文件里,正是为了让 planner 不必打开每个 manifest。
五、第三层:manifest file
manifest file 同样是 Avro,每行是一个
manifest_entry,描述一个 data file 或
delete file。表规范《Manifest Entry Fields》定义 entry
外层字段:
| 字段(field id) | 类型 | 含义 |
|---|---|---|
status(0) |
int | 0=EXISTING,1=ADDED,2=DELETED |
snapshot_id(1) |
long | 加入/删除该文件的 snapshot;null 时继承自 manifest 元数据 |
sequence_number(3) |
long | data sequence number;status=1 时 null 表示继承 |
file_sequence_number(4) |
long | 文件加入时的 sequence number |
data_file(2) |
struct | 文件路径、分区元组、指标(见下节) |
实跑
table.inspect.entries()(含演进后的全部文件):
== inspect.entries (status, sequence_number, content, record_count, partition, path) ==
status 1 seq 4 content 0 rows 1 part {ts_day:2026-01-03, category_bucket:2} 00000-0-c9fb….parquet
status 1 seq 4 content 0 rows 1 part {ts_day:2026-01-03, category_bucket:0} 00000-1-c9fb….parquet
status 1 seq 3 content 0 rows 1 part {ts_day:2026-01-02, category_bucket:None} 00000-0-0c18….parquet
status 1 seq 2 content 0 rows 2 part {ts_day:2026-01-02, category_bucket:None} 00000-0-cc2d….parquet
status 1 seq 1 content 0 rows 2 part {ts_day:2026-01-01, category_bucket:None} 00000-0-dffc….parquet
每一行的 partition 就是该 data file
的分区元组——注意它直接存在元数据里,而不是靠解析目录名得到。老文件(seq
1–3,spec 0)的 category_bucket 是
None(那时还没有这个分区字段),新文件(seq
4,spec 1)两个字段都有值。sequence_number
标记文件内容的相对新旧,是后续判断「哪些 delete file
作用于哪个 data file」的依据(第 10 篇)。
sequence number 继承
表规范《Sequence Number
Inheritance》解释了一个省写的设计:新加入文件的 data/file
sequence number 在写 manifest 时先写成
null,因为提交成功前还不知道 snapshot 的最终
sequence number;读取时用 manifest list 里记录的该 manifest
的 sequence number 替换。好处是一个 manifest
写一次就能在提交重试里复用,重试只需重写 manifest
list。这让乐观并发重试的代价从「重写整棵树」降到「重写最上面两层」。
六、第四层:data file 与列级统计
manifest_entry.data_file
才是逐文件指标的所在。表规范《Data File
Fields》定义的关键字段:
| 字段(field id) | 含义 |
|---|---|
content(134) |
0=DATA,1=POSITION DELETES,2=EQUALITY DELETES |
file_path(100) |
文件 URI |
file_format(101) |
parquet/orc/avro/puffin |
partition(102) |
分区元组,字段 id 来自 partition spec |
record_count(103) |
行数(或 deletion vector 基数) |
file_size_in_bytes(104) |
文件字节数 |
column_sizes(108) |
列 id → 该列磁盘字节 |
value_counts(109) |
列 id → 值个数(含 null/NaN) |
null_value_counts(110) |
列 id → null 个数 |
nan_value_counts(137) |
列 id → NaN 个数 |
lower_bounds(125) |
列 id → 下界(二进制序列化) |
upper_bounds(128) |
列 id → 上界(二进制序列化) |
split_offsets(132) |
可切分点(如 Parquet row group 偏移) |
lower_bounds/upper_bounds
在文件里是「列 id → 二进制」的 map(按表规范 Appendix D
的单值序列化编码)。pyiceberg 的
inspect.files().readable_metrics
把它解码成可读值。实跑(每文件每列:value_count / null /
lower / upper / column_size):
== inspect.files: readable_metrics per file ==
file: 00000-0-dffc….parquet rows: 2 size: 1734 # 2026-01-01 那批
id: count=2 nulls=0 lower=1 upper=2 col_size=114
ts: count=2 nulls=0 lower=2026-01-01 08:00:00 upper=2026-01-01 09:30:00 col_size=120
category: count=2 nulls=0 lower=book upper=food col_size=92
amount: count=2 nulls=0 lower=7.0 upper=12.5 col_size=120
file: 00000-0-cc2d….parquet rows: 2 size: 1731 # 2026-01-02 那批
id: count=2 nulls=0 lower=3 upper=4 col_size=114
ts: count=2 nulls=0 lower=2026-01-02 10:00:00 upper=2026-01-02 11:00:00 col_size=120
category: count=2 nulls=0 lower=book upper=toy col_size=90
amount: count=2 nulls=0 lower=20.0 upper=33.3 col_size=120
这就是 Iceberg「文件级裁剪」的弹药:要查
amount > 35,planner 看到第一个文件
amount ∈ [7.0, 12.5]、第二个
∈ [20.0, 33.3],两个都不可能命中,直接整文件跳过——不打开任何
Parquet。要查
category = 'book',则两文件的
[book, food]、[book, toy] 都覆盖
book,需要进一步进 Parquet 内部按 row group /
page 的 column index 再裁(见 Parquet
文件格式深拆)。
这里有一条清晰的「裁剪漏斗」:
\[ \text{partition pruning(manifest list / manifest)} \;\to\; \text{file pruning(manifest 列 stats)} \;\to\; \text{row group / page pruning(Parquet column index)} \]
前两级在 Iceberg 元数据里完成,第三级才进文件。统计写在 manifest 里而非要去读每个 Parquet footer,正是「不 list、少打开文件」的关键。
stats 是「写入时回填」的,不保证永远精确。
lower_bounds/upper_bounds是边界(≤ 所有非空非 NaN 值 / ≥ 同理),不是精确分布;compaction、stats 维护策略会影响其新鲜度(第 17 篇)。
七、metadata planning:一次读如何收敛到文件集合
把四层串起来,一次 scan 的 planning 路径是:
sequenceDiagram
participant Q as Query
participant C as Catalog
participant M as metadata.json
participant L as manifest list
participant F as manifest file
Q->>C: 解析表名
C-->>Q: 当前 metadata.json 位置
Q->>M: 读根,取 current-snapshot-id → manifest-list
Q->>L: 读 manifest list(一个 Avro 文件)
Note over L: 用 partition_summaries 跳过<br/>不可能匹配的整个 manifest
Q->>F: 只读未被跳过的 manifest
Note over F: 用每文件分区元组 + 列 stats<br/>跳过不可能匹配的 data file
F-->>Q: 候选 data file 列表(含 split offsets)
关键在表规范《Scan Planning》的一句:scan 谓词会被转换成分区谓词来筛 manifest 与文件,转换使用「写该 manifest 时的 partition spec」,而不是当前 spec。 这让查询只写数据列谓词、由 Iceberg 自动推导分区谓词(隐藏分区,第 9 篇展开),也让分区演进后新旧文件能各按自己的 spec 被正确裁剪。
实跑一个只在源列 ts
上加谓词的查询(不写任何分区列),看 planner
选了几个文件:
== scan with hidden-partition predicate (ts only) ==
planned data files for ts>=2026-01-02: 4
00000-0-c9fb….parquet spec 1 Record[20456, 2]
00000-1-c9fb….parquet spec 1 Record[20456, 0]
00000-0-0c18….parquet spec 0 Record[20455]
00000-0-cc2d….parquet spec 0 Record[20455]
全表 5 个 data file,谓词
ts >= 2026-01-02 让 planner 选中 4
个,精确剔除了 2026-01-01
那个文件——而我们从没在查询里写
ts_day。partition record
里的整数(20455、20456)就是
day 变换后的分区值(从 1970-01-01
起的天数:2026-01-02 = 20455,2026-01-03 = 20456),由
Iceberg 把 ts >= 2026-01-02 自动投影成
ts_day >= 20455。注意结果里 spec 0 与 spec 1
的文件混在一起被裁——跨 spec planning 各按自己的 spec
转换谓词。
和 Hive「目录式 planning」对照:
| 维度 | Hive 目录式 | Iceberg 元数据树 |
|---|---|---|
| 找文件 | 递归 LIST 分区目录 |
读 manifest list + manifest(O(1) 量级远程调用) |
| 分区裁剪 | 靠目录名匹配 | 靠 manifest 分区摘要 + 文件分区元组 |
| 文件裁剪 | 无(要打开文件) | manifest 列 stats 直接剔除文件 |
| 一致性 | 依赖目录列举的最终一致性 | 读一个不可变 snapshot,强一致 |
| 随规模 | 随分区数线性退化 | 随 manifest 数,且可 compaction |
八、表规范版本边界 V1 / V2 / V3
第 8–11 篇都以 V2 为主线,但要在源头讲清版本边界。依据表规范《Format Versioning》(V1/V2/V3 均已被社区正式采纳并完成,V4 仍在开发、尚未正式采纳):
| 版本 | 状态 | 主要能力边界 |
|---|---|---|
| V1 | 已采纳 | 不可变文件的分析表:Parquet/Avro/ORC;无行级删除;分区值随文件记录 |
| V2 | 已采纳 | 新增行级删除(position/equality delete file),引入 sequence number;写入端约束更严(细节见规范 Appendix E) |
| V3 | 已采纳 | 新增数据类型(纳秒时间戳、unknown、variant、geometry、geography)、列默认值、多参数
transform、行血缘(row
lineage)、二进制 deletion
vector、表加密密钥 |
| V4 | 开发中,未采纳 | 元数据结构重组(如 metadata 字段支持相对位置)等 |
几条使用边界:
- V1 的所有数据/元数据文件升级到 V2 后仍有效,读 V1 元数据时按规范 Appendix E 补默认值——这是「升级不重写元数据树」的保证(规范《Format Versioning》)。
- 本文 pyiceberg 0.11.1 建表默认
format-version = 2(实跑format-version: 2),行级删除与 deletion vector 留到第 10 篇专门拆。 - V3 虽已采纳,但各查询/写入引擎的实现进度不一。deletion vector、row lineage、variant 等是否可用取决于所用引擎与库版本;本系列遇到 V3 特性会单独标注「规范已定义、引擎支持视版本而定」,不当作随处可用的稳定结论。
九、元数据治理:log、refs、统计与 metrics 配置
四层树之外,metadata.json
还挂着几组对运维很关键的结构。它们不参与「找数据文件」的主路径,却决定元数据会不会膨胀、能不能多分支、planning
拿不拿得到统计。
metadata-log 与版本保留
每次提交都写一个新的
vN.metadata.json,旧版本由
metadata-log 字段跟踪(一串
{timestamp-ms, metadata-file})。保留多少历史版本由表属性
write.metadata.previous-versions-max(默认
100)控制。这串历史既是「元数据级时间旅行」的入口,也是元数据目录膨胀的来源之一——长期高频提交的表,metadata/
下会堆很多 *.metadata.json,需要靠保留策略和
expire_snapshots 收敛。
分支与标签:refs
表规范《Snapshot References》定义
refs:一个「名字 → 引用」的 map,引用类型是
branch 或
tag,各带保留策略。main
是默认分支,current-snapshot-id 实际就是
main 当前指向的
snapshot。分支/标签让同一张表上能并行维护多条 snapshot
线(如 WAP:write-audit-publish 先写到审计分支,校验后再
fast-forward 到 main),也让「给某个 snapshot
打个长期标签」成为元数据操作。详见第 16 篇时间旅行。
表统计与 Puffin
表规范《Table Statistics》允许 metadata.json
挂一组 statistics,每项指向一个
Puffin 文件(约定后缀
*.stats),里面以 blob 形式存放如 NDV(distinct
值估计,Theta sketch)等统计,供 cost-based 优化与 planning
使用。这层是可选的、与四层树正交的「附加统计」,第 17
篇专门讲 Puffin 与统计维护。
metrics 写入配置决定裁剪能力
第六节的列级 stats 不是「无脑全写」。写端用表属性控制每列写哪些指标:
| 属性 | 取值 | 影响 |
|---|---|---|
write.metadata.metrics.default |
none / counts /
truncate(N) / full |
默认所有列的指标级别 |
write.metadata.metrics.column.<col> |
同上 | 单列覆盖默认 |
truncate(16) 之类会把
lower_bounds/upper_bounds
截断到前若干字节(仍保持有效上下界),在「裁剪能力」和「manifest
体积」之间取舍。对宽字符串列全写 full 会让
manifest 迅速膨胀;对从不参与过滤的列设 none
可省元数据。裁剪效果差时,先查这张表是不是把关键列的
metrics 关了。
为什么 manifest/manifest list 用 Avro
manifest 与 manifest list 都是 Avro(行存),而 data file 多用 Parquet(列存)。原因是访问模式不同:planning 要逐行扫一个 manifest 的所有条目(读全部字段做判断),行存更顺;而且 Avro 对「写一次、整文件读」的 append 模式友好。data file 是分析查询的对象,需要列裁剪与压缩,才用列存。两类文件都满足「合法 Iceberg 数据文件」的要求(规范《Manifests》《Manifest Lists》)。
十、小结
Iceberg 元数据树用「一个可变指针 + 三层不可变文件」把「表当前由哪些文件组成」变成一次自上而下的读:
- catalog 指针 是唯一可变量,提交即原子 swap,是 ACID 的着力点;
metadata.json存 schema、所有 partition spec、所有 snapshot 的「历史全集 + 当前指针」,靠 field id 而非位置对齐,为演进留出空间;- manifest list 把分区摘要与计数前置,让 planner 跳过整个 manifest;
- manifest file 逐文件记录分区元组与列级 stats,让 planner 不打开 Parquet 就剔除文件;
- data file 是不可变 Parquet,其列 min/max 等被回填进 manifest 供裁剪。
一次查询的 planning 因此是 partition pruning → file
pruning → row-group pruning
的三级漏斗,前两级全在元数据里完成,不 LIST
目录、少打开文件。这套结构也是后面所有篇章的地基:分区如何隐藏与演进(第
9 篇)、行级删除如何挂到 data file(第 10
篇)、提交如何做原子 swap 与乐观并发(第 11 篇)。
上一篇:表格式为什么存在
下一篇:隐藏分区与分区演进
返回 系列目录
附录、扩展阅读与工程注记
下面是读 Iceberg 元数据时常踩或常问的点,每条尽量锚定规范条款或实跑观察,便于排障时按图索骥。
current-snapshot-id = -1
表示什么
空表或尚未设置当前快照时,metadata.json 的
current-snapshot-id 为
-1(规范《Table Metadata
Fields》)。create_table 后还没 append
的表就是这个状态——此时 snapshots 为空,scan
返回零行。
last-sequence-number 的单调性
last-sequence-number
随每次成功提交递增(实跑四次提交后为
4)。它给整棵树提供一个全局时间轴:data sequence number
决定「哪个 delete file 作用于哪个 data file」(第 10
篇),所以乱序写历史数据时必须显式给出 data sequence
number,不能简单继承。
manifest 跨 snapshot 复用
慢变的 manifest 会被多个 snapshot 的 manifest list
共同引用,避免每次提交都重写全部元数据。实跑里每次 append
只新增一个 *-m0.avro
和一个 snap-*.avro:老
manifest 原样复用,新 manifest list 把老 manifest
的路径再列一遍。这就是「append
的元数据写入量与表已有文件数无关」的来源。
snapshot summary 的 operation 取值
summary.operation 常见
append(只增文件)、overwrite(增删并存,如
CoW
更新)、delete(只删)、replace(compaction
等等价替换)。运维可据此区分「这次提交干了什么」,无需 diff
文件清单。
split_offsets 与并行读
data_file.split_offsets(field
132)记录文件内的可切分点,对 Parquet 即各 row group
起始偏移。引擎据此把一个大文件切成多个 split
并行读,不必先打开文件读 footer 才知道怎么切。
column_sizes 的用途
column_sizes(field 108)是「列 id →
该列磁盘字节」,用于 IO 成本估算与
CBO;行存格式(Avro)该字段留空。它解释了为什么宽表只读两列时,Iceberg/引擎能较准地预估扫描量。
lower/upper bound 是边界不是精确值
lower_bounds/upper_bounds
只保证「≤ 所有非空非 NaN 值」「≥
同理」,可能因写端截断而比真实极值更宽。它们能保证不漏匹配文件(不产生假阴性),但可能让一些实际不含目标值的文件被保留(假阳性),后续靠
Parquet column index 再裁。
identifier-field-ids 预告
schema 里的 identifier-field-ids
标记「用于判断行身份」的字段集合,equality delete(第 10
篇)按这些字段匹配要删的行。本例为空([]),表示没有声明主键语义。
读 V1 元数据的默认值
V1 表升级到 V2 后,老文件缺的字段(如
data_file.content)按规范 Appendix E
补默认值(content 默认
0=DATA)。这是「升级不重写元数据树」的兜底规则,读端必须按版本宽容处理。
delete manifest 单独成文件
content=1 的 manifest 只装 delete
file,planning 时先扫 delete manifest 再扫 data
manifest(规范《Manifests》)。本例无删除,所有 manifest
content=0;行级删除的元数据形态见第 10 篇。
分区统计文件(partition statistics)
除 manifest 里的逐文件分区元组外,规范《Partition Statistics》还允许挂可选的分区级统计文件(Parquet 编码),加速「按分区聚合」类查询的元数据计算。它是优化项,不是 planning 必需。
metadata.json 是否可读懂全表
可以。metadata.json 含 schema、所有
spec、所有 snapshot
与各类指针,但不含逐文件清单——文件清单在
manifest 层。所以读 metadata.json
能回答「表结构、有哪些快照、当前指向谁」,回答「当前有哪些数据文件」要继续往下读
manifest list 与 manifest。
file-based catalog 的并发风险
Hadoop(file-based)catalog 靠文件系统原子 rename 选出新版本号,在不能原子 rename 的纯对象存储上可能两个写者都「成功」,造成丢提交。生产应使用 REST/JDBC/Glue/Nessie 等有真正 CAS 能力的 catalog(第 11、15 篇)。
rewrite manifests 的时机
频繁小提交会让 manifest 数量增长、单个 manifest
变碎,拖慢 planning。rewrite_manifests(第 17
篇)把碎 manifest 合并、按分区重排,减少 planning 要打开的
manifest 数。它只改元数据层,不动 data file。
pyiceberg 与 Java 实现的一致性边界
本文实验用 pyiceberg 0.11.1。pyiceberg 与
apache/iceberg(Java)实现同一份表规范,元数据文件互通;但引擎特性覆盖不同(如某些
V3 特性、某些 compaction 操作 Java
端更全)。跨实现读写时以表规范与 format-version
为准,不以单一实现的能力为准。
timestamp 与 timestamptz 的分区
本例 ts 是不带时区的
timestamp。day(ts)
直接按值取天;若用 timestamptz,则按 UTC
归一后再取天。跨时区报表要留意 transform 是按 UTC
计算的(规范《Partition
Transforms》),不会按会话时区漂移。
参考资料
- Apache Iceberg Table Spec, Overview / Goals(iceberg.apache.org/spec)
- Apache Iceberg Table Spec, Snapshots / Manifest Lists / Manifests / Manifest Entry Fields / Data File Fields
- Apache Iceberg Table Spec, Scan Planning / Sequence Number Inheritance
- Apache Iceberg Table Spec, Format Versioning(V1/V2/V3 已采纳,V4 开发中)
- Apache Iceberg Table Spec, Table Metadata Fields / Commit Conflict Resolution and Retry
apache/iceberg1.x 源码:org.apache.iceberg.{TableMetadata, Snapshot, ManifestFile, ManifestEntry, DataFile, BaseMetastoreTableOperations}- 实验环境:pyiceberg 0.11.1、pyarrow 24.0.0、Python
3.14.5、Linux 6.6(WSL2/Arch)、Intel i9-12900K、32
GB;catalog=SQLite,warehouse=本地
FS,
format-version=2
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【数据湖与开放表格式】Lakehouse 全景:从 Hive 表到开放表格式
Hive 目录式分区表把『表』等同于『一组目录加 metastore 里的分区行』,于是没有原子提交、planning 要 LIST 目录、schema 与分区演进常要重写。本文用这三个硬伤切入,讲清 lakehouse 把表拆成『不可变数据文件 + 可变元数据指针 + catalog』三层后各自解决了什么,并给出全系列的分层地图。
【数据湖与开放表格式】表格式为什么存在
目录式分区表(Hive 表)在对象存储上有三处硬伤:并发写部分提交、list planning 太贵、缺快照隔离与原子提交。本文拆开放表格式补上的四件事——原子提交、快照隔离、文件级统计裁剪、schema 与分区演进,并抽象出三家共有的『元数据指针 + 不可变数据文件』骨架。
【数据湖与开放表格式】隐藏分区与分区演进
拆解 Iceberg 的 partition spec 与 transform(identity/bucket[N]/truncate[W]/year/month/day/hour/void):隐藏分区如何让查询不写分区列谓词也能裁剪,分区演进为何不重写历史数据(文件携带所属 spec),以及与 Hive 静/动态分区的本质差异。基于 pyiceberg 0.11.1 真实演进 spec 并观察新旧文件。
【数据湖与开放表格式】行级删除与 Merge-on-Read
Iceberg 在不可变文件上做行级删除的两条路线:copy-on-write(重写整文件)与 merge-on-read(写 delete 文件,读时合并)。讲清 position delete 与 equality delete 的语义、字段与作用域规则,写放大/读放大的取舍,V2 delete file 到 V3 deletion vector(Puffin 承载)的差异与迁移,以及读路径如何把 data file 与 delete 合并出可见行。基于 pyiceberg 0.11.1 实测 CoW 写放大并观察 MoR 回退。