レイクハウスのテーブル形式 — Apache Iceberg をメタデータで理解する
パーティションは列ではなく規則だ — 隠れたパーティショニングとパーティション進化
一言でいうと
Icebergは、パーティション値を人が作った列ではなく、ソース列にかける変換ルール(例: days(order_ts))で計算してマニフェストに記録し、読み取る側はソース列の条件だけでファイルをスキップします。ルールはパーティション仕様IDを付けて積み重なるので、途中で変えても、古いファイルは古いルールのまま読めます。
なぜHive方式のパーティションが問題だったのか
Partitioningドキュメントが挙げる例は、典型的です。ログテーブルを日付で分けるには、Hiveではevent_dateという列を別に作り、書き込む側がevent_timeからその値を計算して入れる必要があります。ここから問題が次々に生まれます。
- 書き込む側が間違えると、静かに間違います。
2018-12-01の代わりに20181201と書いたり、別のソース列(処理時刻)を使ったり、タイムゾーンの計算を誤ったりしても、エラーは出ません。結果だけが間違います。 - 読み取る側が知っていないと、遅くなります。
event_timeの条件だけをかけても、Hiveは2つの列の関係を知らないので、すべてのファイルを読みます。人々は、event_dateの条件を追加する方法を覚えておかなければなりません。 - 変更できません。クエリがパーティション列に縛られているので、日単位を時間単位に変えるには、新しいテーブルを作ってクエリをすべて書き直す必要があります。
どう動くのか: 変換とパーティション仕様
Icebergのパーティション仕様は、フィールドごとにソース列ID、パーティションフィールドID、変換、名前を持ちます。エンジンは行を書き込むときに変換を計算し、そのファイルのパーティション値としてマニフェストに記録します。1つのファイルの行は、すべて同じパーティション値を持ちます。変換の一覧は次のとおりです。
| 変換 | 結果 | 用途 |
|---|---|---|
| identity | 値そのまま | 種類の少ないカテゴリ列 |
| year・month・day・hour | 1970年から数えた年・月・日・時間 | 時刻の列 |
| bucket[N] | ハッシュをNで割った余り | 種類の多いキー(顧客番号) |
| truncate[W] | 幅Wで切り詰めた値 | 数値の区間、文字列の先頭部分 |
| void | 常にnull | バージョン1でフィールドをなくすとき |
bucketは、32ビットのMurmur3(x86、シード0)ハッシュの符号ビットを捨てて、Nで割ります。仕様がハッシュを定めているので、Sparkが書いてPythonが読んでも、同じ値は同じバケットに入ります。
読み取る側は、order_ts >= Xのようなソース列の条件だけを使います。スキャン計画が、その条件をパーティション条件(order_ts_day >= day(X))に変換して、マニフェストのパーティション値と比較します。この変換は「包含」(inclusive)する側に計算されるので、条件に合う行がありうるファイルは、決して漏れません。ユーザーは、パーティションがどう分かれているかを知らなくても構いません。だから「隠し」パーティショニングなのです。
パーティション進化: 古いファイルはそのまま
データが増えて、月単位では粗すぎるようになったとします。Evolutionドキュメントによると、仕様を変えても古いデータは古い仕様のまま残り、新しいデータだけが新しいレイアウトで書かれます。仕様はリストに積み重なり、マニフェストごとに自分が書いた仕様IDを記憶します。計画するときは、仕様ごとに別々に条件を変換してファイルを選びますが、ドキュメントはこれをsplit planningと呼びます。そして、パーティション進化はmetadataの操作なので、ファイルを急いで書き直すことはないと、はっきり記しています。
Sparkでは、ALTER TABLEでフィールドを追加し(ADD PARTITION FIELD)、削除し(DROP PARTITION FIELD)、置き換えます(REPLACE PARTITION FIELD … WITH …)。
ALTER TABLE lake.demo.events REPLACE PARTITION FIELD months(event_ts) WITH days(event_ts);
現場での姿
パーティションを変えたから古いデータも書き直さなければならないというのは、最もよくある誤解です。古い月単位のファイルは、そのままにしても正確に読めます。書き直すのは選択であり、理由が必要です。たとえば、古い期間を日単位で頻繁に照会して、読み取る量を減らしたいときのようにです。そのときも、コンパクション(rewrite_data_files)のような別の処理で行います。
1日の条件なのに1か月分を読む場合です。月単位のファイルに1日の条件をかけると、パーティションでは、その月のファイルを丸ごと選ぶことになります。ファイルの中の列統計(下限・上限)が役に立ちますが、ファイルが1か月分を均等に含んでいる場合は役に立ちません。計画が開くファイルの行数と、実際に該当する行数の差が、そのコストです。
顧客番号で分けたらパーティションが数万個になった場合です。identityで種類の多い列を分けると、小さなファイルが爆発します。bucketでバケット数を決めておくのが正解です。
実務で本当に大切なこと
- パーティションはルールです。ソース列と変換だけを記述し、パーティション値はエンジンが計算します。
- クエリは、ソース列だけでかけます。パーティション列を別に知っている必要はありません。
- 仕様は積み重なり、ファイルは自分の仕様を記憶します。進化のあとで古いファイルを書き直す必要はありません。
- 種類の多い列はbucket、時刻はyear・month・day・hourです。
次のラボですること
3月の1か月分をmonths(order_ts)で分けたテーブルに入れ、pyicebergで1日の条件の計画を立てて、何ファイル・何行を開くかを測ります。パーティションをdays(order_ts)に変えてから4月を入れ、3月のファイルは仕様0のまま、4月のファイルだけが仕様1であることを確認します。3月の1日と4月の1日の条件が読む行数を比べ、顧客テーブルをbucket(4, customer_id)で分けて、バケットが仕様どおりに計算されたかを確認します。