テーブルフォーマットはファイルの集合をデータベースのように管理する技術です。
SnowflakeやDatabricksのような大規模ベンダーはもとより、AWSやGoogle Cloud、Trinoなど多くの製品でサポートされています。ただ、テーブルフォーマットといってもファイル構造は様々です。
この記事では、各テーブルフォーマットにおけるファイル構造と変化を比較します。
はじめに
テーブルフォーマットはデータレイクの欠点を補うために生まれた手法です。
フォーマットによって違いはあるものの、次の2種類のファイルが中心になっていることは共通しています。
オブジェクトストレージにメタデータ用のファイルを作成することで、トランザクションやタイムトラベルなど、便利な機能を実現しています。また、メタデータファイルの作成が各エンジンやクライアントに委ねられていることも共通しています。ただし、各フォーマットの実装や機能には大きな差があります。ファイル追加ひとつをとっても、挙動は全く違います。
これらの違いを理解するため、本記事ではテーブルフォーマットのファイル構造と変化を比較します。
※ この記事は、筆者が高速にメタデータを変更するファイルベースのDBを自作する一環で、4つのテーブルフォーマットのファイル構造を調査したものをまとめたものです。
比較対象
この記事では、よく知られている以下のフォーマットを比較します。
そして、それぞれのフォーマットに対して、次の観点で比較していきます。
- ファイル構造
- テーブルの状態の変更方法
- 競合の検知とトランザクションの実現方法
比較
Apache Iceberg
Apache Iceberg(以下、Iceberg)は、代表的なテーブルフォーマットの一つです。Netflixから生まれたフォーマットで、読み込みに強いのが特徴です。
※ この記事では最新であるVersion 3 の方法を紹介します。
ファイル構造
鳥瞰すると、Icebergは下図のように複数のレイヤーで構成されています。

図の中央の metadata layerは、名前の通りメタデータを持つレイヤーで、3種類のファイルで構成されています。また、Catalogがメタデータレイヤーの一番上にあるmetadata fileを参照していることもわかります。
各ファイルについて見ていきます。
metadata file
metadata fileはテーブル自身のメタデータが書かれているファイルです。
データ追加などでテーブルの状態が変更されるときに新しく作成され、カタログが指す先も変更されます。つまり、「metadata fileを作成し、カタログを更新する」という操作が、RDBでいうコミットに相当します。このファイルには、列やパーティションの情報をはじめ、後続のmanifest listへの参照などがjsonで書かれています。
例えば、以下のような中身です。
{ "last-sequence-number": 3, "schemas": [ { "type": "struct", "schema-id": 0, "fields": [ { "id": 1, "name": "vendor_id", "required": false, "type": "long" }, { "id": 2, "name": "trip_id", "required": false, "type": "long" }, ... ] } ], "current-snapshot-id": 8502631251987096951, "snapshots": [ { "sequence-number": 3, "snapshot-id": 8502631251987096951, "manifest-list": "s3://warehouse/nyc/taxis/metadata/snap-8502631251987096951-1-f77e86d0-0a2c-45ad-b30e-0d90ce8e68bd.avro", ... } ], ... }
manifest list
manifest listはmanifest fileへの参照を列挙しているファイルで、前項のmetadata fileから参照されているファイルです。
中はAvroで書かれていて、各ファイルの情報が入っています。またパーティションの値も持っているので、クエリ実行時にどのmanifest fileを利用すべきかを判断することができるようになっています。
例えば、下のような値が入っています。(可視化のため、avroの中身をjsonに直しています)
[ { "manifest_path": "s3://warehouse/nyc/taxis/metadata/f77e86d0-0a2c-45ad-b30e-0d90ce8e68bd-m1.avro", "manifest_length": 7486, "added_snapshot_id": 8502631251987096951, "added_files_count": 1, "added_rows_count": 1, "partitions": "[{\"lower_bound\": \"1", \"upper_bound\": \"2"}]", ... }, { "manifest_path": "s3://warehouse/nyc/taxis/metadata/9e6d91da-fbfc-4c71-aa91-68ad62e27139-m0.avro", "manifest_length": 7563, "added_snapshot_id": 9069729192205494592, "added_files_count": 2, "deleted_files_count": 0, "added_rows_count": 4, "partitions": "[{\"lower_bound\": \"1", \"upper_bound\": \"1\"}]", ... }, ... ]
manifest file
manifest fileにはファイルのパスや統計がAvroで入っています。
このファイルまで辿ってくるとデータファイルへのパスが手に入ります。
具体的には、以下のようになっています。(可視化のため、avroの中身をjsonに直しています)
[ { "status": 1, "snapshot_id": 9069729192205494592, "data_file": "{\"content\": 0, \"file_path\": \"s3://warehouse/nyc/taxis/data/vendor_id=1/00000-4-c7fe0274-81c2-4022-9a1c-2859405b3967-0-00001.parquet\", ...", ... }, { "status": 1, "snapshot_id": 9069729192205494592, "data_file": "{\"content\": 0, \"file_path\": \"s3://warehouse/nyc/taxis/data/vendor_id=2/00000-4-c7fe0274-81c2-4022-9a1c-2859405b3967-0-00002.parquet\"...", ... } ]
data_fileフィールドの中にjsonが入っているのが見えますが、ここにはファイル情報が入っています。data_fileフィールドのjsonには以下のような情報が入っています。
{ "data_file": { "content": 0, "file_path": "s3://warehouse/nyc/taxis/data/vendor_id=1/00000-4-c7fe0274-81c2-4022-9a1c-2859405b3967-0-00001.parquet", "file_format": "PARQUET", "partition": { "vendor_id": 1 }, "record_count": 2, "file_size_in_bytes": 1605, "column_sizes": [ { "key": 1, "value": 76 }, ], "lower_bounds": [ { "key": 1, "value": "1" }, ], "upper_bounds": [ { "key": 1, "value": "2" }, ], ... } }
ファイル名や、フォーマット(parquet)、列の上限(lower_bounds)下限(upper_bounds)などが入っていることがわかります。
data file
data fileは実際のデータを持ったファイルです。
ファイル形式さえ気をつければ、既存のファイルをそのまま利用することができます。通常のデータレイクに置くファイルと変わりません。
ファイル構造まとめ
以上のように、Icebergは3種類のメタデータからできています。それ以外に統計情報用のファイルが作られることもありますが、
metadata file -> manifest list -> manifest file -> data file
という関係がIcebergの根幹です。
テーブルの状態の変更方法
ファイルの関係がわかればテーブルの状態を変更する方法は直感的で、ファイルを順番に作成していくだけです。つまり、
data fileを作る- 作成した
data fileの情報を書いたmanifest fileを作る - 作成した
manifest fileの情報を書いたmanifest listを作る - 作成した
manifest listの情報を書いたmetadata fileを作る - 作成した
metadata fileに向けてカタログの参照先を変更する
という順番でファイルを作っていけばテーブルの状態を変えることができます。
競合の検知とトランザクションの実現方法
ここまでで、ファイルを順番に作成していけばいいことがわかりました。
では、複数のプロセスが同時に変更しようとしている場合はどのように競合を防ぐのでしょうか?
答えは簡単で、「1つしか成功しない」です。
Icebergは、最初に現行のバージョンを取得し、そのバージョンに対応するメタデータを使ってテーブルの状態を変更します。そのため、変更中に現行バージョンが変わった場合、メタデータの変更をやり直します。つまり、「楽観的(optimistic)」です。
リトライが頻発した場合負荷が大きくなる設計ではありますが、大量のファイルを扱うための設計上の工夫です。公式には以下のように書かれています。
(the Iceberg table format) is designed to manage a large, slow-changing collection of files in a distributed file system or key-value store as a table.
訳: (Icebergは)分散ファイルシステムやKVS上にある、大規模な、更新頻度が低いファイルの集合をテーブルとして管理するために設計されている。
以上、Icebergのファイルの利用方法を紹介しました。
Delta Lake
Delta Lakeは、Databricksが作成したフォーマットで、1種類のメタデータファイルでも動作するシンプルさを持っています。
機能追加が進んでいるため全ての機能をサポートするには多くの実装が必要ですが、ファイルの追加・削除だけを対象にすると非常にシンプルにできています。
※ この記事は最新であるReader Version 3, Writer version 7 までの機能が記載されたDelta Transaction Log Protocolを参照しています。
ファイル構造
Delta Lakeでは、最もシンプルな場合、ファイル構造は下のようになります。
.
└── simple-table
├── _delta_log
│ ├── 00000000000000000000.json
│ ├── 0000000000000000001.checkpoint.parquet
│ ├── _last_checkpoint
│ ...
├── part-00000-29aaca97-e41b-4dd5-b94c-6d80e6eec67a-c000.snappy.parquet
├── part-00000-918a423a-82bb-4430-88b3-3c93c4dd8479-c000.snappy.parquet
...
ルートの階層にあるファイル(part-00000-918a423a-82bb-4430-88b3-3c93c4dd8479-c000.snappy.parquetなど)が実際のデータが入っているファイル(Data File)で、_delta_logディレクトリの中に入っているファイルがメタデータです。
さらに、パーティションやCDC対応などの機能を使用すると、下のように複数のディレクトリが作成されます。
.
└── complex-table
├── _change_data
│ ├── group=Bar
│ │ ├── cdc-00000-c483b69f-c1c0-4581-b254-35cb27c59d01.c000.snappy.parquet
│ ├── group=Foo
│ │ ├── cdc-00000-67bca4a4-160f-4ee8-beb4-32db77bbed06.c000.snappy.parquet
│ ...
├── _delta_log
│ ├── _sidecars
│ │ └── 00000000000000000010.checkpoint.0000000001.0000000001.5f45d5fc-9e91-4033-bffb-05430eb673cf.parquet
│ ├── 00000000000000000000.crc
│ ├── 00000000000000000000.json
│ ├── 00000000000000000010.checkpoint.38064ff7-86d5-4b66-8bdf-159f13fd9e95.json
│ ├── _last_checkpoint
│ ...
├── group=Bar
│ ├── part-00000-5ec3b332-a58e-466c-b2a2-3efb8cbb91e4.c000.snappy.parquet
├── group=Foo
│ ├── part-00000-0d369fcb-47d7-4704-9c51-f1959825ba25.c000.snappy.parquet
...
パーティション(group)ごとにディレクトリが分けられているのがわかります。
Delta File
Delta Fileはテーブル変更の履歴が入っているメタデータファイルで、テーブルを変更すると作成されます。
テーブルを読み込むときはDelta Fileを順番に読んでいけば現在のテーブルの状態がわかるようになっています。
ファイルはJSON(正確にはJSON Lines)で、下のような内容が書かれています。
{ "metaData": { "id": "d853860f-c0f1-4592-848a-f7145eb9082f", "format": { "provider": "parquet", "options": {} }, "schemaString": "{\"type\":\"struct\",\"fields\":[{\"name\":\"id\",\"type\":\"integer\",\"nullable\":true,\"metadata\":{}},...", "partitionColumns": [ "group" ], "configuration": { "delta.enableRowTracking": "true", ... }, ... } } { "protocol": { "minReaderVersion": 3, "minWriterVersion": 7, "readerFeatures": [...], "writerFeatures": [...] } } { "add": { "path": "group=Bar/part-00000-a7d76845-a25a-417f-a7db-f52be86f027f.c000.snappy.parquet", "size": 1024, ... } } { "remove": { "path": "group=Foo/part-00000-40f9fb17-d0c7-484b-84a6-a30c37e187b5.c000.snappy.parquet", ... } } { "cdc":{ "path":"_change_data/group=Bar/cdc-00000-ef0f9fbe-36e1-4850-8a36-c66a37b365ae.c000.snappy.parquet", ... } } ...
各jsonのキー(addやremove)が操作を表しています。この例では、「テーブル定義(metadata)」、「プロトコル設定(protocol)」、「ファイル追加(add)」、「ファイル削除(remove)」、「データ変更(cdc)」の操作をしたことが書かれています。
また、不正な変更を検知するためにチェックサム用のファイルを作ることもできます。
Checkpoint
Delta Fileはテーブルに変更があるたびに作成されるので、ファイル数はどんどん増えていきます。このDelta Fileの内容をまとめたものがCheckpointです。
Checkpointには対象のDelta Fileまでの有効な変更が全て入っているため、「テーブルの状態を把握するためには最新のCheckpointとその先のDelta Fileを読めばよい」ようになっています。
Checkpointは下の3種類のファイルの組み合わせで出来ています。
Delta Fileの内容をまとめたSidecar FileSidecar Fileのパスを持つCheckpoint File- 最新の
Checkpoint Fileのファイルのパスを持つLast Checkpoint File
この3種類のファイルを順にたどれば最新のCheckpointの情報を取得できるわけです。
まず、Sidecar Fileについて。parquetで書かれていて、中身は下のようにデータファイルに関する情報が入っています (可視化のため、parquetの中身をjsonに直しています)
[ { "path": "group=Bar/part-00000-a7d76845-a25a-417f-a7db-f52be86f027f.c000.snappy.parquet", "deletionTimestamp": 1768734735552, ... }, { "path": "group=Foo/part-00000-3436b65d-4fd4-4646-b4d5-e8a522b23c4a.c000.snappy.parquet", "deletionTimestamp": null, ... }, ... ]
そして、Checkpoint Fileには下のようにSidecar Fileのパスやテーブルの情報がjsonで入っています。
{ "checkpointMetadata": { "version": 10 } } { "sidecar": { "path": "00000000000000000010.checkpoint.0000000001.0000000001.5f45d5fc-9e91-4033-bffb-05430eb673cf.parquet", "sizeInBytes": 12667, } } { "protocol": { "minReaderVersion": 3, "minWriterVersion": 7, "readerFeatures": [...], "writerFeatures": [...], } } { "metaData": { "id": "d853860f-c0f1-4592-848a-f7145eb9082f", "format": { "provider": "parquet", "options": {} }, "schemaString": "{\"type\":\"struct\",\"fields\":[{\"name\":\"id\",\"type\":\"integer\",\"nullable\":true,\"metadata\":{}},...", "partitionColumns": [ "group" ], ... } } ...
そして、Last Checkpoint Fileはファイル名が固定(_last_checkpoint)されていて、下のようにCheckpoint Fileへのパスなどがjsonで入っています。
{ "version": 10, "sizeInBytes": 14079, "v2Checkpoint": { "path": "00000000000000000010.checkpoint.38064ff7-86d5-4b66-8bdf-159f13fd9e95.json", "sizeInBytes": 1412, "nonFileActions": [...], "sidecarFiles": [ { "path": "00000000000000000010.checkpoint.0000000001.0000000001.5f45d5fc-9e91-4033-bffb-05430eb673cf.parquet", "sizeInBytes": 12667, ... } ] }, ... }
Change Data File
ファイルの変更内容を書いているものが、Change Data Fileです。
UPDATEやDELETEなどデータを変えたときに作られます。Delta Fileに書かれているcdc操作に書かれたパスのファイルを読むことで、データの変更を追っているプログラムが既存の値を覚えずに変更内容を把握することができます。
ファイルはparquetで書かれていて、下のような内容です。(可視化のため、parquetの中身をjsonに直しています)
[ { "id": 2, "value": "bravo", "_row-id-col-xxx": 0, "_change_type": "delete", ... } ]
ファイル構造まとめ
以上、代表的なファイルを紹介しました。Delta Fileを中心にメタデータが構成されていることがわかります。そして、Delta Fileを読み取る時のファイルの順番は大まかに下のようになります。
Last Checkpoint File -> Checkpoint -> Delta Files -> 実際のファイル
他にもコンパクションや削除ベクトル(Deletion Vector)用のファイルがありますが、Delta Fileがメタデータの中心であることは変わりません。
テーブルの状態の変更方法
前節でDelta Lakeには複数のメタデータ用ファイルがあることを紹介しました。
複数の種類があるものの、各ファイルはDelta Fileを中心としているため、テーブルを変えるときは、下のように実ファイルを作成した後にDelta Fileを作成するだけで十分です。
- 実ファイルを作成
Delta Fileを作成- (必要であれば)
Checkpointを作成
競合の検知とトランザクションの実現方法
Delta Lakeは、メタデータの変更が非常に単純です。このシンプルさを支えるのが「Delta Fileを書く = コミット」という関係です。この関係を支えるため、ファイル名をインクリメントし続ける必要があります。つまり、最初のDelta Fileは00000000000000000000.jsonで、次のコミットは00000000000000000001.jsonと名前が決まっています。これに加えて上書きを禁止しているので、同時に書き込もうとしている場合は1プロセスしか成功せず、残りのプロセスはリトライすることになります。
以上、Delta Lakeのファイルの利用方法を紹介しました。
Apache Hudi
Apache Hudi(以下、Hudi)はUber発のフォーマットで、多くの機能を持っています。
コンパクションやインデックスなど、「インクリメンタルな書き込みを高速にしつつ、読み込みも速くする」ための機能を多く内包しているのが特徴です。機能の多さを考えると、「データレイクを便利にする仕組み」と捉えるよりも「(ファイルベースの)データベース管理システム」と捉えた方が役割を想像しやすいでしょう。IcebergやDelta Lakeよりもカバーしている機能範囲が大きく、テーブルフォーマットに相当する仕様を含んだ、より広範な仕様です。
データを追加するとき、Hudiはインデックスや統計情報を作成しつつデータ用のファイルも作ります。また、テーブルサービスと呼ばれる、データを管理するための機能も持っています。例えば、コンパクションやクラスタリング、不要データの削除を行う機能があり、非同期で呼び出すことができます。
この記事では、Hudiの豊富な機能を支える、メタデータを中心に取り上げます。

※ この記事では最新のバージョンである1.1.1 の方法を紹介します。
ファイル構造
Hudiのファイルは、.hoodieディレクトリにまとめられた複数種類のメタデータファイルと、実際のデータを持つFile Groupで構成されています。またメタデータファイルには、他のテーブルフォーマットに相当する機能を実現するものと、より高度な機能のためのものがあります。
例えば、テーブルフォーマット部分は下図のようなファイルが用意されています。

この図に書かれている「Timeline」と「Metadata Table」部分がメタデータで、「File Group / File Slice」に実際のデータが入っています。ただし、実際のデータ用のファイルであっても、これまでのフォーマットとは違いユーザーが作ったファイルが入っているわけではありません。
この節ではHudiがどのようなファイルを作っているか見ていきます。
Timeline
テーブルの変更を記録しているのがTimelineで、「操作内容」と「時間」をAvroで記録しています。
Timelineの操作には複数の状態(requested、inflight、completed)があり、状態遷移のタイミングで状態に応じたファイル(xxx.requested、xxx.inflight)を作成することで作業の進行状態と完了順序がわかるようになっています。また、ファイルが溜まると、./hoodie/timeline/historyディレクトリにまとめられます。さらにLSMツリーの要領で、もっと溜まれば次のレベルにまとめられます。
例えば、completed状態のファイルは以下のようになっています。(可視化のため、parquetの中身をjsonに直しています)
{ "partitionToWriteStats": { "partitionA": [ { "fileId": "2786c86a-9adf-48e5-9298-43ad235655f8-0", "path": "partitionA/2786c86a-9adf-48e5-9298-43ad235655f8-0_0-418-6254_20260125031732773.parquet", "prevCommit": "20260125031728591", "numWrites": 6, "partitionPath": "partitionA", "fileSizeInBytes": 436502, ... } ] }, "operationType": "UPSERT", ... }
この例では、"partitionA"というパーティションにUPSERTされたことが書かれています。
更に、Timelineをまとめた場合、manifestというファイルから参照できるようになっています。manifestファイルの中には以下のようにTimelineをまとめたファイルのパスが書かれています。(JSON)
{ "files" : [ { "fileName" : "20260125031656516_20260125031708044_0.parquet", "fileLen" : 3298 }, { "fileName" : "20260125031710285_20260125031710726_0.parquet", "fileLen" : 11560 }, ... ] }
まとめると、Timelineのファイルは下のようになっています。
path/.hoodie/timeline/
├── 20260125031732773.commit.requested <- 操作開始
├── 20260125031732773.inflight <- 操作中
├── 20260125031732773_20260125031736753.commit <- 操作完了
├── ...
└── history
├── 20260125031656516_20260125031708044_0.parquet <- まとめられたファイル
├── manifest_4 <- manifestファイル
├── ...
└── _version_ <- 最新のmanifestの番号を持つファイル
なお、「時間を記録する」と軽く書いていますが、複数の書き込みに対応するために「過去のTimelineよりも後の時間でファイルを作成すること」が仕様で明記されているので実装には慎重さが必要です。
File Group / File Slice
File GroupとFile Sliceは実際のデータが入っているファイルです。
IcebergやDelta Lakeの場合はユーザーが作ったファイルをそのまま置くことができますが、Hudiの場合はデータ用のファイルのフォーマットも決まっていて、File GroupとFile Sliceに沿ってファイルを作る必要があります。
まず、File Sliceは特定バージョン時点でのデータを取得できる単位で、Base FileとLog Fileで出来ています。ほとんどのデータはBase Fileに入っていて、追加データがLog Fileとして追加されます。つまり、Base FileとLog Fileをマージすると、そのバージョンの完全な内容になります。そして、複数バージョンをまとめた単位がFile Groupです。
たとえば、uuid列とfare列を持つテーブルに対するBase Fileは、以下のようにHudi用のデータとユーザーが指定した値(uuidやfare列)が混ざって入っています。(可視化のため、parquetの中身をjsonに直しています)
[ { "_hoodie_commit_time": 20260125153512834, "_hoodie_partition_path": "partitionA", "uuid": "334e26e9-8355-45cc-97c6-c31daf0df330", "fare": 19.1, ... }, ... ]
そして、追加データをもつLog Fileは、下のようにBase Fileと同じような内容が入っています(Hudiの独自フォーマットなので、可視化のためjsonに直しています)
{ "log_block_length": 2278, "version": 1, "content_length": 1388, "content": [ { "_hoodie_commit_time": "20260125153542168", "_hoodie_partition_path": "partitionA", "uuid": "e96c4396-3fad-413a-a942-4cb36106d721", "fare": 30.7, ... }, ... ] }
Metadata Table
Metadata Tableは高速化のために作られたメタデータの集まりです。
具体的には、ファイル一覧の情報と、統計情報を持つファイル群です。
まず、ファイルの一覧を保持しているファイルは.hoodie/metadata/filesディレクトリの中にあり、以下のような内容です。(Hudiの独自フォーマットなので、可視化のためjsonに直しています)
{ "__all_partitions__": { "_hoodie_file_name": { "string": "files-0000-0_10-190-2875_20260126133912332.hfile" }, "filesystemMetadata": { "map": { "partitionC": {...}, "partitionB": {...}, "partitionA": {...} } }, ... }, "partitionA": { "filesystemMetadata": { "map": { "2fa1b4ab-c3cf-46d1-af1e-bf796d3619fa-0_0-143-1990_20260126133906789.parquet": { "size": 436462, "isDeleted": false }, "2fa1b4ab-c3cf-46d1-af1e-bf796d3619fa-0_0-111-1375_20260126133903922.parquet": { "size": 436435, "isDeleted": false }, ... } }, ... }, "partitionB": { "filesystemMetadata": { "map": { "e81b01ad-ecdd-4b54-b6d7-2436af4c0f61-0_0-175-2605_20260126133909478.parquet": { "size": 436192, "isDeleted": false }, "e81b01ad-ecdd-4b54-b6d7-2436af4c0f61-0_1-39-301_20260126133851419.parquet": { "size": 436140, "isDeleted": false }, ... } }, }, }
__all_partitions__ が各パーティションの情報を持ち、それぞれのパーティションの中に具体的なファイル情報が書かれているのがわかります。
また、統計情報は列単位とパーティション単位でディレクトリが分けられています(.hoodie/metadata/column_statsと.hoodie/metadata/partition_stats)。例えば、uuid列の統計情報は下のよう書かれています。(Hudiの独自フォーマットなので、可視化のためjsonに直しています)
{ "columnName": "uuid", "fileName": "2fa1b4ab-c3cf-46d1-af1e-bf796d3619fa-0_0-143-1990_20260126133906789.parquet", "minValue": "134e26e9-8355-45cc-97c6-c31daf0df330", "maxValue": "e96c4396-3fad-413a-a942-4cb36106d721", "valueCount": 6, "nullCount": 0, "totalSize": 150, ... } { "fileName": "211656c3-27f9-4fe5-820d-bb26f951ec2e-0_0-39-300_20260126133851419.parquet", "columnName": "uuid", "minValue": "c8abbe79-8d89-47ea-b4ce-4d224bae5bfa", "maxValue": "c8abbe79-8d89-47ea-b4ce-4d224bae5bfa", "valueCount": 1, "nullCount": 0, "totalSize": 88, ... }
インデックス
高速に行を特定するためのインデックス用ファイルも用意されています。行の変更を反映し続けるCDCを使用している場合、特に有効です。
Hudiのインデックスには以下の種類が用意されています。
- Bloomfilter
- Record Index(プライマリキーのようなもの)
- 式インデックス
- セカンダリインデックス
どれも名前のとおりで、例えばRecord Indexの中には下のようにキー(例ではuuid)ごとにパーティションの場所が書かれています(Hudiの独自フォーマットなので、可視化のためjsonに直しています)
{ "13cf430c-889d-4015-bc98-59bdce1e530c": { "partitionName": "partitionB", "instantTime": 1769434749478, ... }, "c8abbe79-8d89-47ea-b4ce-4d224bae5bfa": { "partitionName": "partitionC", "instantTime": 1769434731419, ... }, ... }
ファイル構造まとめ
以上、Hudiの持つ主要なファイルを紹介しました。これ以外にもHudiは様々なファイルを持っていますが、長くなるためここまでにします。
テーブルの状態の変更方法
前節で紹介したように、Hudiは様々なファイルがあります。
更新は次の手順で行われます。
Timelineにファイルを作成- データファイル作成 (インデックスを参照しつつ適切なFile Groupを選択し、作成する)
- メタデータとインデックスを更新
Timelineにcompleted状態のファイルを作成- (必要であれば)テーブルサービスを実行
テーブルサービスは毎回実行する必要はありません。また、非同期で実行することもできます。
競合の検知とトランザクションの実現方法
Hudiのデータ変更は同時書き込み対策のためにTimelineの時間を使用します。また、DynamoDBなどで分散ロックを取る方法も提供されています。
ただし、OCCであるためリトライには負担があります。そのため、早期に衝突を検知する方法として、Early Conflict DetectionやNon-blocking Concurrency Controlの実装が試みられているのも特徴的です
以上、Hudiのファイルの利用方法を紹介しました。
Apache Paimon
Apache Paimon(以下、Paimon)は、Flinkコミュニティ発のフォーマットで、ストリーミングを重視したフォーマットです。
大まかな構造はIcebergと似ているものの、データファイルの作成方法まで決まっている点が大きく違います。例えば、プライマリーキーを考慮したファイルを作成することや、コンパクションの対象範囲が規定されています。データファイルの仕様まで定義することで機能を増やしている点はHudiに似ていて、仕様の広さはIcebergとHudiの中間的な位置にあります。
※ この記事は最新バージョンである1.3 の方法を紹介します。
ファイル構造
Paimonのファイルは、下図のようにSnapshot,Manifest,Data,Schemaディレクトリにわかれています。
ManifestはIcebergのMetadataレイヤーに相当し、SnapshotとSchemaがカタログ相当の役割を持ちます。また、Dataディレクトリにはデータファイルと共にCDCに便利なChangelogと呼ばれるファイルも入ります。

それぞれについて詳しく見ていきます。
Manifest
Manifestは、データファイルの一覧を持っているData Manifestと、そのファイルのポインタとなっているManifest Listで構成されています。
Data Manifestは以下のようにDataファイルの情報を持っています。(可視化のため、avroの中身をjsonに直しています)
[ { "_PARTITION": "partitionA", "_FILE": { "_FILE_NAME": "changelog-d10eaede-a8eb-4fc9-b338-b91e7b86c083-0.parquet", "_FILE_SIZE": 2588, "_ROW_COUNT": 1, "_MIN_KEY": "196c4396-3fad-413a-a942-4cb36106d721", "_MAX_KEY": "196c4396-3fad-413a-a942-4cb36106d721", ... }, ... }, { "_PARTITION": "partitionC", "_FILE": { "_FILE_NAME": "changelog-acd08255-0cfe-4980-91c3-6bd3db6472e9-0.parquet", "_FILE_SIZE": 2580, "_ROW_COUNT": 2, "_MIN_KEY": "134e26e9-8355-45cc-97c6-c31daf0df330", "_MAX_KEY": "296c4396-3fad-413a-a942-4cb36106d721", ... }, ... }, ... ]
そして、Manifest Listは以下のようにData Manifestを指す情報を持っています。
[ { "_FILE_NAME": "manifest-afabf49f-d001-44f0-8162-bf0c5005408a-0", "_FILE_SIZE": 2329, "_PARTITION_STATS": { "_MIN_VALUES": "partitionA", "_MAX_VALUES": "partitionC", ... }, ... } { "_FILE_NAME": "manifest-2cdf0d3f-4f2a-4c7f-9aff-13142bdcc35a-0", "_FILE_SIZE": 2438, "_PARTITION_STATS": { "_MIN_VALUES": "partitionA", "_MAX_VALUES": "partitionC", ... }, ... }, ... ]
Snapshot
現在のテーブルの状態を持っているのがSnapshotです。
Snapshotのファイルを作成することで、Paimonのテーブルにコミットしたことになります。ファイル名はsnapshot-1, snapshot-2のように連番になっていて、最大の番号を持っているものが最新のファイルです。
具体的には、以下のような情報をjsonで持っています。
{ "id" : 5, "schemaId" : 0, "baseManifestList" : "manifest-list-531c89a4-c44e-4dc3-b211-a43add15324e-0", "baseManifestListSize" : 1108, "deltaManifestList" : "manifest-list-531c89a4-c44e-4dc3-b211-a43add15324e-1", "deltaManifestListSize" : 1010, "commitIdentifier" : 9223372036854775807, "commitKind" : "APPEND", ... }
特徴的なのは、前回コミット時点の情報を持つbaseManifestListと対象コミットでの変更内容を持つdeltaManifestListの2つのManifest Listのファイル情報があることです。Paimonはこの2つのファイルを組み合わせることでテーブルの状態を把握できるようになっています。
Schema
Schemaはテーブルのスキーマを持っているファイルで、直感的な内容です。
具体的には、以下のような情報をjsonで持っています。
{ "version": 3, "id": 0, "fields": [ { "id": 0, "name": "ts", "type": "BIGINT" }, { "id": 1, "name": "uuid", "type": "STRING NOT NULL" }, ... ], "partitionKeys": ["partition"], "primaryKeys": ["uuid", "partition"], "options": { "bucket": "3", ... }, ... }
DataFile
Hudiと同じように、Paimonは実データをどのような内容を書くかも決まっています。
テーブルの内容はパーティションごとのディレクトリと、更に細分化するためのBucketディレクトリの下に保存されます(例: partition=partitionA/bucket-0/xxx.parquet)。さらに、PKを決めている場合は、Sorted Runという単位で順次保存されます。Sorted RunはLSMツリーの考え方をしていて、「まずレベル0としてファイルを作り、時期が来たら集約してレベルを上げる」という動きをします。また、下図のようにファイル内はキーで整列されているのも特徴です。

ファイルの内容は以下のようになっています。(parquetで作られますが、可視化のためjsonに直しています)
[ { "_KEY_uuid": "134e26e9-8355-45cc-97c6-c31daf0df330", "_SEQUENCE_NUMBER": 1, "ts": 978480120000, "uuid": "134e26e9-8355-45cc-97c6-c31daf0df330", ... }, { "_KEY_uuid": "296c4396-3fad-413a-a942-4cb36106d721", "_SEQUENCE_NUMBER": 2, "ts": 978483600000, "uuid": "296c4396-3fad-413a-a942-4cb36106d721", ... }, ... ]
また、Changelogという、コミットの差分部分だけを持つファイルもあります。(フィールドは同じです。)
ファイルまとめ
以上、Paimonの主要なファイルを紹介しました。
テーブルの状態の変更方法
Paimonの状態変更のためには、前節で紹介したファイルを追加していき、最後にSnapshotを書き込むことでコミットが確定しテーブルの内容が変わります。
競合の検知とトランザクションの実現方法
書き込みが並行で走っている場合、Snapshotの存在チェックを衝突検知に利用できます。Snapshotが連番で作成されているためです。ただし、衝突を検知するため、ファイル作成時に同名ファイルがあったら上書きしないようにする仕組みは必要です。
以上、Paimonのファイルの利用方法を紹介しました。
まとめ
以上、Iceberg、 Delta Lake、 Hudi、 Paimonのファイル構造と仕組みを紹介しました。
フォーマット比較の参考になれば幸いです。
付録
この記事を作成するため、以下のページをよく参照しました。
- Iceberg
- Delta Lake
- Hudi
- Paimon
また、衝突の話については「The ultimate guide to table format internals - all my writing so far」という記事が詳しく、参考になります。