レイクハウスのテーブル形式 — Apache Iceberg をメタデータで理解する
列名を変えても古いファイルが読めるのは列 ID のおかげだ
一言でいうと
Icebergは、列ごとに変わらないフィールドIDを与え、データファイルにもそのIDを書き込んでおきます。読み取るときは名前ではなくIDで対応づけるので、列の追加・削除・名前の変更・順序の変更・型の拡張がすべてmetadataの1行で済み、古いファイルは書き直されません。
なぜスキーマ変更が事故になったのか
データレイクでスキーマを変えることは、長いあいだ恐ろしい作業でした。原因は、ファイルの中の列を何で見つけるかにあります。
名前で見つけるテーブル(Hive方式のParquetテーブルの大半)では、amountをamount_krwに変えると、古いファイルにはamountという列しかないので、新しい名前で読んだときにその列が丸ごとnullになります。もっと悪いことも起きます。couponを削除して数か月後に別の意味のcouponを新しく作ると、古いファイルに残っていた古いcouponの値がよみがえり、新しい列に混ざって出てきます。
位置で見つける形式(CSV、順序ベースのHiveテーブル)は、反対の方向に壊れます。3番目の列を削除すると、4番目の列が3番目の位置に繰り上がって、名前と値がずれます。
Evolutionドキュメントが、まさにこの2つの失敗を挙げています。名前で追跡する形式は、名前を再利用すると削除した列をよみがえらせることができ、位置で追跡する形式は、列を削除すると別の列に使われる名前が変わります。そのためチームは、スキーマを変えるたびにテーブル全体を書き直すか、そもそも変えられずに間違った名前を何年も抱えたまま過ごしてきました。
どう動くのか: IDで対応づける
テーブルを作ると、列ごとにIDが付きます(order_id 1、customer_id 2、…)。書き込むエンジンは、Parquetファイルの列定義に、そのIDをfield_idとして一緒に書き込みます。スキーマはmetadataにリストとして積み重なり、それぞれがschema-idを持ち、スナップショットは自分を作ったときのスキーマIDを記憶します。
列プロジェクションのルールは単純です。データファイルの列は、フィールドIDで選びます。テーブルのスキーマにあってファイルにないIDは(名前マッピングやデフォルト値がなければ)nullで埋めます。このルール1つから、5種類の変更がすべて安全になります。
| 変更 | metadataで起きること | 古いファイルを読むと |
|---|---|---|
| 追加 | 新しいIDを持つフィールドができる | そのIDがないのでnull |
| 名前の変更 | 同じIDの名前だけが変わる | ファイルの中の古い名前とIDでつながり、値はそのまま |
| 削除 | 現在のスキーマからそのIDが消える | ファイルに値が残っていても読まない |
| 順序の変更 | フィールドの順序だけが変わる | IDで選ぶので、値が混ざらない |
| 型の拡張 | 型が広い方へ変わる | 狭い型で書かれた値を、広げて読む |
削除した列と同じ名前で再び追加した列は、新しいIDを受け取ります。古いファイルには古いIDの値しかないので、新しい列には何も現れません。ドキュメントが保証する「追加された列は、他の列の既存の値を決して読まない」とは、このことです。
型は拡張しかできない
仕様のスキーマ進化の節は、フォーマットバージョン1・2で許される型の変更を、3つに限定しています。int → long、float → double、decimal(P, S) → decimal(P', S)(P' > P、精度だけの拡張)です。バージョン3では、date → timestampのようないくつかが加わります。どれも値が切り捨てられない方向です。longをintに戻すことは、すでにファイルにある大きな値が切り捨てられるおそれがあるため許されず、エンジンはその変更を拒否します。
現場での姿
名前の間違った列を直す場合です。amoutのような誤字の列を何年も抱えているテーブルは、よくあります。Icebergでは、名前の変更がmetadataの1行なので、その日のうちに直せます。ただし、ファイルを直接開く利用側(フィールドIDを知らないスクリプト)は今も古い名前を見る、という点は知らせる必要があります。
金額の列がintに収まらなくなった場合です。ウォン単位の金額が21億を超える日が来ます。int → longの拡張は、ファイルを書き直さずにすぐ実行できます。逆に、「容量を減らすため」にlongをintに変えたいという要求は、拒否されるのが正しい動作です。
列を削除して作り直したら古い値が混ざった場合です。名前で読んでいた古いテーブルで、よく起きた事故です。Icebergに移行したあとは、同じ名前で作り直しても、新しい列は空の状態で始まります。
実務で本当に大切なこと
- IDが列の正体であり、名前は表示にすぎません。名前の変更と順序の変更は、いつでも安全です。
- スキーマ変更は、データファイルを書き直しません。スナップショットも増えず、metadataにスキーマが1つ積み重なるだけです。
- 型は拡張しかできません。int → long、float → double、decimalの精度の拡張です。
- 削除した列のIDは、戻ってきません。同じ名前で作り直した列は、空の新しい列です。
次のラボですること
テーブルを作って3月1日分を入れ、coupon列を追加してから3月2日分を入れます。amountをamount_krwに変えたあと、最初のコミットのParquetファイルのフッターをpyarrowで直接開いて、古い名前のamountとfield_id 4がそのまま残っていることを確認します。amount_krwをBIGINTに拡張し、INTに戻そうとすると拒否されるときのエラー条件を書き留め、couponを削除して再追加して、新しいIDと空の値を確認したあと、スキーマが複数積み重なってもスナップショットとデータファイルはそのままであることを、数えて確かめます。