レイクハウスのテーブル形式 — Apache Iceberg をメタデータで理解する
テーブルはディレクトリではなくファイル一覧 — metadata.json からデータファイルまで
一言でいうと
Icebergのテーブルは、ディレクトリを走査して調べるものではなく、metadata.json → マニフェストリスト → マニフェスト → データファイルとつながるリストのツリーとして定義されます。コミットとは、新しいツリーを書いておいたうえで、カタログのポインター1つをアトミックに切り替える操作です。
なぜディレクトリではだめだったのか
Hive方式のテーブルでは、「テーブル」はディレクトリです。orders/order_date=2026-03-01/の下にあるファイルがそのまま当日のデータで、読み取るエンジンはディレクトリを列挙してファイルを集めます。この方式は単純ですが、3つの問題を抱えています。
1つ目は、原子性がないことです。書き込みジョブがファイル10個のうち5個をアップロードした時点で読み取りジョブがディレクトリを列挙すると、中途半端な結果を見ることになります。2つ目は、列挙のコストが高いことです。Reliabilityドキュメントが指摘するとおり、Hiveテーブルはメタストア(パーティション)とファイルシステム(ファイル)の2か所で状態を追跡するため、ジョブを計画するたびにパーティション数に比例する一覧取得が必要です。オブジェクトストレージではこの取得が遅く、かつては結果が遅れて整合することさえありました。3つ目は、何がテーブルなのかについての合意がないことです。誰かが誤ってディレクトリにファイルを1つ落とすと、それもテーブルの一部になります。
どう動くのか: 4層のツリー
仕様の概要は、最初の文で方向を変えます。この形式は、ディレクトリではなくファイルを1つ1つ追跡します。書き込む側はファイルをどこにでも書いておき、明示的なコミットによってのみテーブルに加えます。
| 層 | 形式 | 収めるもの |
|---|---|---|
| metadata.json | JSON | スキーマのリスト、パーティション仕様のリスト、プロパティ、スナップショットのリスト、現在のスナップショットID、refs |
| マニフェストリスト | Avro | スナップショット1つを構成するマニフェストと、それぞれのパーティション要約・ファイル数 |
| マニフェスト | Avro | データ(または削除)ファイルごとに1行: パス、パーティション値、行数、列ごとの下限・上限 |
| データファイル | Parquetなど | 実際の行 |
スナップショットは、snapshot-id、親を指すparent-snapshot-id、コミット順を表すsequence-number、そして自身のmanifest-listのパスを持ちます。要約(summary)のoperationはappend・replace・overwrite・deleteの4つのうちのいずれかで、追加した行数・ファイル数などの数値も一緒に記録されます。スナップショットのデータは、そのマニフェストに記録された有効なファイルの和集合です。
マニフェストの1行(エントリ)にはstatusがあり、0がEXISTING、1がADDED、2がDELETEDです。スキャン計画は、DELETEDのエントリを使いません。そのうえで、2段階でスキップします。マニフェストリストのパーティション要約でマニフェストを丸ごとスキップし、マニフェストの列統計でファイルをスキップします。ディレクトリの列挙はどこにもありません。
コミットは何をするのか
2回目のコミットを考えてみましょう。新しいデータファイルを書き、そのファイル1つを収めた新しいマニフェストを書き、1回目のコミットのマニフェストと新しいマニフェストの両方を指す新しいマニフェストリストを書き、スナップショットを1つ加えた新しいmetadata.jsonを書きます。仕様は、マニフェストがスナップショット間で再利用されると記しています。古いマニフェストは書き直さず、指すだけです。そのため、コミットのコストはテーブルのサイズではなく、変更された量に比例します。
最後に、カタログにある「現在のmetadataはこのファイル」というポインターをアトミックに切り替えます。その瞬間より後にテーブルを開いた人は新しい状態を、その前に開いた人は古い状態を最後まで見ます。中途半端な状態はありません。シーケンス番号はコミットごとに1つ増え、競合に負けて再コミットするときは、マニフェストリストだけを書き直せばよいように設計されています。
現場での姿
昨日入れたデータが見えないという場合です。ファイルはバケットにあるのに行が見えないなら、ほぼ必ずそのファイルが現在のスナップショットのマニフェストにありません。書き込みジョブがファイルだけを書いて、コミットの直前に死んだのです。Icebergでは、これが正常な動作です。コミットされていないファイルは、テーブルの一部ではありません。
ディレクトリを消したのにテーブルが無事だった、あるいはその逆だった、という場合です。データファイルのパスは、マニフェストにフルパスで記録されています。ディレクトリ構造は慣例にすぎず、意味はありません。手作業でファイルを消すとテーブルが壊れ(マニフェストが存在しないファイルを指す)、手作業でファイルを入れても何も起きません。
計画が遅いという場合です。ストリーミングで1分ごとにコミットしたテーブルは、マニフェストが数千個になります。クエリ計画が遅くなる原因が、データではなくメタデータ層にあることもあると知っていてはじめて、コンパクションとマニフェストの整理という対処につながります。
実務で本当に大切なこと
- テーブルの状態は、1つのmetadata.jsonで決まります。そのファイルが指さないものは、テーブルではありません。
- コミットは、新しいツリーを書いてポインターを切り替える操作です。古いツリーはそのまま残り、タイムトラベルの材料になります。
- マニフェストは再利用されます。2つ目のスナップショットが最初のマニフェストをそのまま指すのを目で確認しておくと、あとのコンパクションや期限切れ処理が理解しやすくなります。
- 読み取りは、リストと統計でスキップします。ディレクトリの列挙がないので、パーティションが多くても計画コストが爆発しません。
次のラボですること
Sparkでテーブルを作り、1日分の注文を2回コミットします。そのあと、ツールを使わずにツリーをたどって下ります。SQLiteカタログの1行から現在と以前のmetadataのパスを読み、jqでmetadata.jsonのスナップショット一覧(親・シーケンス番号・マニフェストリスト)を取り出し、Avro形式のマニフェストリストをデコードして2つ目のスナップショットが最初のコミットのマニフェストをそのまま指すことを確認し、マニフェストをデコードして有効なデータファイルと行数を書き出します。採点ツールは、書き出した値を実際のメタデータと比較します。