土法炼钢兴趣小组的算法知识备份

【数据湖与开放表格式】Iceberg 元数据树

文章导航

分类入口
databasestorage
标签入口
#iceberg#metadata#manifest#snapshot#manifest-list#table-format#scan-planning

目录

表格式为什么存在 里,我们把问题归到一句话:对象存储不能原子 rename、LIST 很贵,所以「目录即表」的 Hive 模式既给不出原子提交,也给不出快照隔离,planning 还要随分区数线性 LIST。Iceberg 的回答是把「表当前由哪些文件组成」这件事,从「扫目录」变成「读一棵不可变元数据树」。

这棵树有四层:catalog 指针 → vN.metadata.json → manifest list(一个 manifest list 文件对应一个 snapshot)→ manifest file → data file。本文逐层打开它,重点回答三件事:

证据锚定 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-filestotal-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 的原子性来源分得很清:

本文实验用 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)

两个结构性要点:

  1. schema 与 partition spec 都是「历史全集 + 当前指针」,不是只存当前。列用全局唯一的 field id 标识(这里 id 1..4),分区字段也有独立的 partition field id(10001001)。schema/分区演进因此可以做到不重写历史数据——老文件按它当年的 schema/spec 解释,靠 id 而非位置对齐。这一点是第 9 篇(分区演进)和第 16 篇(schema 演进)的地基。
  2. snapshot 列表内嵌在 metadata.json,但每个 snapshot 真正引用的 manifest 列表在另一个文件(manifest list)。这样 metadata.json 本身保持「小而全」,不随数据文件数膨胀。

四、第二层:snapshot 与 manifest list

snapshot:某一时刻的表内容

表规范《Snapshots》定义一个 snapshot 至少包含:snapshot-idparent-snapshot-idsequence-numbertimestamp-msmanifest-list(指向该 snapshot 的 manifest list 文件)、summaryschema-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 文件。summarytotal-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_nullcontains_nanlower_boundupper_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_bucketNone(那时还没有这个分区字段),新文件(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 里的整数(2045520456)就是 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 已采纳 新增数据类型(纳秒时间戳、unknownvariantgeometrygeography)、列默认值、多参数 transform行血缘(row lineage)二进制 deletion vector、表加密密钥
V4 开发中,未采纳 元数据结构重组(如 metadata 字段支持相对位置)等

几条使用边界:


九、元数据治理: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,引用类型是 branchtag,各带保留策略。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 元数据树用「一个可变指针 + 三层不可变文件」把「表当前由哪些文件组成」变成一次自上而下的读:

一次查询的 planning 因此是 partition pruning → file pruning → row-group pruning 的三级漏斗,前两级全在元数据里完成,不 LIST 目录、少打开文件。这套结构也是后面所有篇章的地基:分区如何隐藏与演进(第 9 篇)、行级删除如何挂到 data file(第 10 篇)、提交如何做原子 swap 与乐观并发(第 11 篇)。


上一篇表格式为什么存在

下一篇隐藏分区与分区演进

返回 系列目录


附录、扩展阅读与工程注记

下面是读 Iceberg 元数据时常踩或常问的点,每条尽量锚定规范条款或实跑观察,便于排障时按图索骥。

current-snapshot-id = -1 表示什么

空表或尚未设置当前快照时,metadata.jsoncurrent-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 是不带时区的 timestampday(ts) 直接按值取天;若用 timestamptz,则按 UTC 归一后再取天。跨时区报表要留意 transform 是按 UTC 计算的(规范《Partition Transforms》),不会按会话时区漂移。


参考资料

  1. Apache Iceberg Table Spec, Overview / Goalsiceberg.apache.org/spec
  2. Apache Iceberg Table Spec, Snapshots / Manifest Lists / Manifests / Manifest Entry Fields / Data File Fields
  3. Apache Iceberg Table Spec, Scan Planning / Sequence Number Inheritance
  4. Apache Iceberg Table Spec, Format Versioning(V1/V2/V3 已采纳,V4 开发中)
  5. Apache Iceberg Table Spec, Table Metadata Fields / Commit Conflict Resolution and Retry
  6. apache/iceberg 1.x 源码:org.apache.iceberg.{TableMetadata, Snapshot, ManifestFile, ManifestEntry, DataFile, BaseMetastoreTableOperations}
  7. 实验环境: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

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-30 · database / storage

【数据湖与开放表格式】Lakehouse 全景:从 Hive 表到开放表格式

Hive 目录式分区表把『表』等同于『一组目录加 metastore 里的分区行』,于是没有原子提交、planning 要 LIST 目录、schema 与分区演进常要重写。本文用这三个硬伤切入,讲清 lakehouse 把表拆成『不可变数据文件 + 可变元数据指针 + catalog』三层后各自解决了什么,并给出全系列的分层地图。

2026-06-30 · database / storage

【数据湖与开放表格式】表格式为什么存在

目录式分区表(Hive 表)在对象存储上有三处硬伤:并发写部分提交、list planning 太贵、缺快照隔离与原子提交。本文拆开放表格式补上的四件事——原子提交、快照隔离、文件级统计裁剪、schema 与分区演进,并抽象出三家共有的『元数据指针 + 不可变数据文件』骨架。

2026-06-30 · database / storage

【数据湖与开放表格式】隐藏分区与分区演进

拆解 Iceberg 的 partition spec 与 transform(identity/bucket[N]/truncate[W]/year/month/day/hour/void):隐藏分区如何让查询不写分区列谓词也能裁剪,分区演进为何不重写历史数据(文件携带所属 spec),以及与 Hive 静/动态分区的本质差异。基于 pyiceberg 0.11.1 真实演进 spec 并观察新旧文件。

2026-06-30 · database / storage

【数据湖与开放表格式】行级删除与 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 回退。


By .