レイクハウスのテーブル形式 — Apache Iceberg をメタデータで理解する
変更できないファイルで行を直す二つの方法 — copy-on-write と merge-on-read
一言でいうと
Parquetファイルは書き換えられないので、1行を変えるには、そのファイルを新しく書き直すか(copy-on-write)、「どのファイルの何番目の行は削除された」という削除ファイルを別に書いておいて、読むときに取り除く(merge-on-read)必要があります。前者は書き込みが高くて読み取りが安く、後者はその反対です。テーブルプロパティ3つが、これを決めます。
なぜ問題になるのか: 100万行ファイルの1行
注文1件が返金に変わりました。その注文が入ったParquetファイルには、ほかの注文が100万件入っています。ファイルは1度書かれたら変わりません(仕様の要求条件が「インプレース書き込みなし」です)。そうすると、1件を直すために100万件を書き直すべきでしょうか。
フォーマットバージョン1では、そうでした。バージョン2が、この問題を解くために削除ファイルを加えました。不変のデータファイルを書き直さなくても、その中の個別の行を削除したり変更したりできるようになったのです。
どう動くのか: 削除の3つの形
行レベルの削除には、2つの系統があります。
| 種類 | 何で削除するか | 主に誰が使うか |
|---|---|---|
| 位置削除ファイル(バージョン2) | (データファイルのパス、行の位置) | Sparkのmerge-on-read |
| deletion vector(バージョン3以上) | データファイルごとに1つのビットマップ | バージョン3のテーブルの位置削除 |
| 等価削除ファイル | 列の値(例: order_id = 'O123') | ストリーミングupsert(Flinkなど) |
位置削除ファイルは、file_pathとpos(0から数えた行番号)の2列のファイルで、file_path、続いてposの順に並べて書きます。読み取る側は、データファイルを読みながら、そのファイルの削除された位置を飛ばします。deletion vectorは、同じ情報をPuffinファイルの中のRoaringビットマップでより小さく収め、データファイルごとに最大1つだけ置きます。バージョン3では、位置削除ファイルが非推奨予定になりました。
等価削除ファイルは、位置を知らず、値だけを知っています。ストリーミングupsertは、古い行がどのファイルの何番目にあるかを探す余裕がないので、「このキーの古い行は削除せよ」とだけ書きます。その代わり、読み取る側が関連するすべてのデータファイルの行と値を照らし合わせなければならず、最もコストが高くなります。
どの削除がどのデータにかかるかは、シーケンス番号が決めます。スキャン計画のルールによると、位置削除はシーケンス番号が同じか小さいデータファイルに、等価削除は厳密に小さいデータファイルにだけかかります。そのため、等価削除を書いたあとで同じキーで入れ直した新しい行は、削除されません。
書き込みモード: テーブルが記憶する
SparkがDELETE・UPDATE・MERGEをどちらの方式で行うかは、テーブルプロパティのwrite.delete.mode・write.update.mode・write.merge.modeが決め、3つともデフォルトはcopy-on-writeです。merge-on-readはバージョン2以上でのみ使えます。
- copy-on-write: 変更された行が入ったデータファイルを丸ごと新しく書き、古いファイルを一覧から外します。削除ファイルがないので、読み取りは最も速くなります。
- merge-on-read: データファイルはそのままにして、削除ファイル(変更された行なら、新しい行が入ったデータファイルも)だけを書きます。書き込みは小さく速い代わりに、読むたびに削除を適用しなければなりません。
MERGE INTOは、WHEN MATCHED(削除・更新)とWHEN NOT MATCHED(挿入)を1回のコミットで行います。ソースの1行に変更が2つ対応すると、どちらを適用すべきかわからず失敗するので、変更のバッチはキーごとに1つに整理しておきます。
現場での姿
CDCのロード先テーブルがだんだん遅くなる場合です。数分おきにMERGEするmerge-on-readのテーブルは、削除ファイルがたまり続け、読むたびにそれを突き合わせます。定期的なコンパクション(rewrite_data_filesの削除の割合・個数の基準、rewrite_position_delete_files)で、削除をデータに畳み込む必要があります。
ほかのエンジンが削除した行を返す場合です。削除ファイルを理解できないエンジンは、merge-on-readのテーブルで削除した行まで読みます。複数のエンジンが同じテーブルを読むなら、各エンジンがどの削除形式をサポートしているかを、先に確認します。ラボ環境のDuckDB 1.5.5とpyiceberg 0.12は、バージョン2の位置削除を反映して読みます。
1日1回大きく変更するテーブルの場合です。夜間バッチが昨日のパーティションを丸ごと直し、昼間はダッシュボードが何百回も読むなら、copy-on-writeのほうが適しています。書き込みコストは1回で、読み取りの利得は何百回です。
実務で本当に大切なこと
- copy-on-writeは書き込み増幅、merge-on-readは読み取り増幅です。書く頻度と読む頻度で選びます。
- モードはテーブルプロパティです。デフォルトは3つともcopy-on-writeで、merge-on-readはバージョン2以上です。
- merge-on-readのテーブルでは、コンパクションが運用の一部です。削除ファイルがたまると、読み取りが遅くなります。
- 読み取るエンジンが削除形式をサポートしているか確認します。知らないエンジンは、削除した行を返します。
次のラボですること
同じ3月の注文でcopy-on-writeのテーブルとmerge-on-readのテーブルを作り、同じDELETEを実行して、一方はデータファイルを書き直し、もう一方は位置削除ファイルだけを加えることを、コミットの要約で確認します。変更のバッチで2つのテーブルに同じようにMERGEしたあと、位置削除ファイルを1つpyarrowで直接開いて、どのファイルの何番目の行を指しているかを見て、2つのMERGEが書いたバイト数を比べ、DuckDBとpyicebergが削除を反映して読むかを確認します。