15. メタデータ三層構造を覗く
Iceberg のテーブルは、カタログが持つ1本のポインタから始まり、metadata.json、manifest list、manifest、data file という順に辿れる構造になっています。この階層があるおかげで、スキャン計画はデータファイルを開かずに大半を切り落とせます。
ここでは samples/05_metadata.py を使い、実際のテーブルからこの各層を順に取り出して表示します。あわせて、プランニングが使う統計情報が実際にどう格納されているかを確認します。
確かめたいこと
- カタログが保持しているのが metadata.json への1本のポインタだけであること
- スナップショットごとに manifest list が1つ対応すること
- manifest が data file の一覧と要約統計を持つこと
- 列統計(lower_bound / upper_bound)が実際にどんな値で入っているか
- manifest entry の sequence_number が何を表すか
背景
構造は次のように連なります。
catalog → metadata.json → manifest list → manifest → data file
カタログが管理する状態は、テーブルごとの metadata.json の位置だけです。コミットとは、このポインタを新しい metadata.json へアトミックに差し替える操作にほかなりません。ポインタ差し替えの実際のプロトコルは 16. REST API を直接叩く で扱います。
スキャン計画は段階的に絞り込みます。第1段は manifest list に記録された各 manifest のパーティション要約統計(field_summary)だけを見て、manifest ファイル自体を開かずにスキップ判定します。第2段で残った manifest を開き、第3段で data file ごとの列統計を使ってファイル単位のスキップ(file skipping)を行います。
処理の流れ
lab.metadata_demoを作り直し、5行ずつ3回 append します。append を分けることで manifest が複数できます。第1層として
tbl.metadataの主要フィールドとtbl.metadata_locationを表示します。inspect.metadata_log_entries()で、過去の metadata.json の履歴を表示します。第2層として、各スナップショットの
manifest_listのパスを表示します。
for snap in md.snapshots:
print(f" sequence-number = {snap.sequence_number}")
print(f" manifest-list = .../{snap.manifest_list.rsplit('/', 1)[-1]}")第3層として
inspect.manifests()で manifest 一覧を、続いてinspect.files()で data file 一覧を表示します。readable_metricsを展開し、列ごとの統計を人が読める形で確認します。
rm = files.select(["file_path", "readable_metrics"]).to_pylist()
for col, stats in rm[0]["readable_metrics"].items():
...- 最後に
inspect.entries()で manifest entry の status と sequence_number を表示します。
実測結果
第1層の metadata.json は次の状態でした。書き込み3回に対してスナップショットが3個、refs は main のみです。
location = s3://warehouse/lab_catalog/lab/metadata_demo
table-uuid = 4bf21d74-231a-4c1a-acaa-70912f1b4d25
format-version = 2
last-sequence-number= 3
current-snapshot-id = 9056089237084054718
schemas = 1 個
partition-specs = 1 個
snapshots = 3 個
refs = ['main']
カタログが指しているファイルは1つだけです。
s3://warehouse/lab_catalog/lab/metadata_demo/metadata/00003-b5aa2cfc-6d34-40ba-ad39-2218c096c7fb.metadata.json
metadata の履歴は4エントリありました。1行目はテーブル作成時点で、まだスナップショットがないため latest_snapshot_id が NaN です。
timestamp latest_snapshot_id latest_sequence_number
2026-07-18 13:49:36.920 NaN NaN
2026-07-18 13:49:37.221 6.202441e+18 1.0
2026-07-18 13:49:37.320 7.230180e+18 2.0
2026-07-18 13:49:37.406 9.056089e+18 3.0
第2層では、3つのスナップショットにそれぞれ別の manifest list が対応していました。sequence-number は 1, 2, 3 と増加し、parent が連鎖しています。
snapshot-id = 6202440951381350097
parent = None
sequence-number = 1
operation = Operation.APPEND
manifest-list = .../snap-6202440951381350097-0-c0f71b99-dfa5-4c08-b891-60ef86c219dc.avro
第3層の manifest は3つ、いずれも added_data_files_count が 1 です。append ごとに manifest が1つ、data file が1つ作られたことが分かります。
path length partition_spec_id added_data_files_count existing_data_files_count
ec39da35-6253-4d8f-8629-b738f2fbd8ca-m0.avro 4436 0 1 0
e8c8624d-fad3-40c7-9e0f-d4e51e89ade5-m0.avro 4438 0 1 0
c0f71b99-dfa5-4c08-b891-60ef86c219dc-m0.avro 4434 0 1 0
data file は5行で 1746〜1753 バイトでした。manifest(4434〜4438 バイト)のほうが、それが記述する data file より大きいという逆転が起きています。小さい書き込みを繰り返すとメタデータの比率が跳ね上がることが、この規模でも見て取れます。
file_path file_format record_count file_size_in_bytes
00000-0-ec39da35-6253-4d8f-8629-b738f2fbd8ca.parquet PARQUET 5 1747
00000-0-e8c8624d-fad3-40c7-9e0f-d4e51e89ade5.parquet PARQUET 5 1753
00000-0-c0f71b99-dfa5-4c08-b891-60ef86c219dc.parquet PARQUET 5 1746
列統計は次のとおりです。
id:
column_size = 123
value_count = 5
null_value_count = 0
nan_value_count = None
lower_bound = 200
upper_bound = 204
category:
lower_bound = A
upper_bound = C
amount:
lower_bound = 2.0
upper_bound = 42.0
ts:
lower_bound = 2026-07-03 00:00:00
upper_bound = 2026-07-03 04:00:00
id が 200〜204、ts が 2026-07-03 の4時間分に収まっています。これは3回目の append(batch=2)で書かれたファイルです。述語 id < 100 が来れば、このファイルは lower_bound を見た時点で開かずに除外できます。
manifest entry は3件、いずれも status=1(ADDED)でした。
status snapshot_id sequence_number file_sequence_number
1 9056089237084054718 3 3
1 7230179648642667438 2 2
1 6202440951381350097 1 1
ここから分かること
コミットの実体は metadata.json のポインタ差し替えです。カタログ側が保持する状態はこの1つだけで、それ以外の情報はすべてオブジェクトストレージ上のファイルに載っています。カタログを差し替えても、データとメタデータの構造は変わりません。
file skipping は列統計に依存します。ここで効いてくる既定値が2つあります。
write.metadata.metrics.default = truncate(16)— 文字列やバイナリの境界値は16文字で切り詰められます。URL や UUID 文字列のように共通プレフィックスが長い列では、境界値が実質同一になりプルーニングが効きません。たとえばhttps://example.com/a/...とhttps://example.com/z/...は、先頭16文字がどちらもhttps://example.なので、統計上は同じ範囲に見えます。ファイルごとの lower/upper がすべて一致するため、どの述語を投げても1ファイルも枝刈りできません。write.metadata.metrics.max-inferred-column-defaults = 100— 101列目以降には既定でメトリクスが収集されません。ワイドテーブルで後方の列に述語をかけると、全ファイル読みになります。
metadata.json の蓄積も運用上の論点です。write.metadata.previous-versions-max の既定は 100 で、write.metadata.delete-after-commit.enabled の既定は false です。何もしなければ metadata JSON は溜まり続けます。さらに、追跡上限の 100 を溢れた分は untracked になるため、後から削除を有効化しても対象になりません。
sequence_number はコミット順を表し、delete ファイルの適用判定に使われます。position delete は「データ seq <= delete seq」、equality delete は「データ seq < delete seq」(厳密に小さい)で適用されます。この非対称性が、同一コミット内で追加した行を position delete で消せる理由です。
小ファイルとメタデータの膨張は compaction で解消しますが、PyIceberg には compaction がありません。詳細は 17. PyIceberg の制約を実証する を参照してください。運用面の整理は姉妹リポジトリの調査報告書(https://dobachi.github.io/iceberg-research/ )にまとめてあります。