ミューテーション・軽量 DELETE・TTL — 変更できないパーツを変える方法
一言でいうと
MergeTreeのパートは、一度書くと変わりません。そのため、UPDATE・DELETEはパートをまるごと新しく書き直す非同期の作業(ミューテーション)になり、軽量DELETEは削除する行を隠す印だけを書いて、実際の削除はマージに任せ、TTLはマージが起きるときに、期限が過ぎた行や値を取り除いたり、要約したりするルールです。3つとも「今すぐ、この行だけ」ではないことを知っておく必要があります。
なぜ必要なのか
前のモジュールで、パートが不変という設計が、書き込みと圧縮を速くすることを見ました。代償は、修正です。個人情報の削除依頼、誤って入ったステータス値の訂正、古いログの整理は、どの分析DBにも来ます。行指向DBのように、その場で数バイトを直すことはできないので、ClickHouseはこの要求を3つのツールに分けました。まれで重い訂正はミューテーション、頻繁な削除は軽量DELETE、ルールで表せる寿命管理はTTLです。公式ドキュメントの「Avoid mutations」が、最初のツールをできるだけ使わないよう述べている理由を、数値で見るのが、このモジュールの目標です。
どう動くのか
ミューテーション。ALTER TABLE t UPDATE ... WHERE ...とALTER TABLE t DELETE WHERE ...は、ドキュメントがわざとALTERで始まるようにした、重いコマンドです。投入すると、system.mutationsに1行ができ、バックグラウンドでその行が入っているパートを新しく書き直して、アトミックに差し替えます。デフォルトは非同期で、mutations_sync = 2を指定すると、終わるまで待ちます。このPodで、40万行のパートの2%(8千行あまり)のstatusを変更したら、パート名がall_1_1_1からall_1_1_1_2に変わり、system.part_logのMutatePartの記録は、40万行を読んで、40万行を書きました。名前の末尾の2が、そのミューテーションの番号です。ドキュメントが述べるいくつかの性質として、ミューテーションは投入順に適用され、投入前に入ったデータにだけかかり、元に戻せず、サーバーを再起動しても続きから動きます。ソートキー・パーティションキーの列は、UPDATEできません。
失敗したミューテーションは、もっと恐ろしいです。文字列のメールアドレスをtoUInt32に変えるミューテーションを入れたら、system.mutationsにis_done = 0、latest_fail_error_code_name = CANNOT_PARSE_TEXTが残り、自分では止まりませんでした。そのあとにmutations_sync = 2で入れたまともなUPDATEも、前のものに阻まれて、UNFINISHEDエラーで返ってきました。終わらせる方法はKILL MUTATIONだけです。
軽量DELETE。DELETE FROM t WHERE ...は、内部的にUPDATE _row_exists = 0 WHERE ...ミューテーションになります(system.mutationsのcommandにそのまま見えます)。Wideパートなら、隠れた列_row_existsだけを書き、ほかの列ファイルはハードリンクで再利用します。以後のSELECTは、このマスクをPREWHEREで適用します。このPodで、ユーザー888の22行を削除した直後、通常のSELECTは0行、SETTINGS apply_deleted_mask = 0は22行、system.partsのrowsはそのままでした。行が実際に取り除かれたのは、OPTIMIZE ... FINALでマージしたあとでした。ドキュメントも「次のマージのときに物理的に削除される」と述べています。プロジェクションのあるテーブルでは、デフォルトで拒否されることは、前のモジュールで見ました。
TTL。TTLはテーブルや列に付けるルールで、マージのときに適用されます。マージがなければ、ドキュメントのmerge_with_ttl_timeout(デフォルトは4時間)ごとにTTLマージを試みます。使い方は3つあります。
| 形式 | 例 | 期限が過ぎると |
|---|---|---|
| 列TTL | email String TTL event_date + INTERVAL 90 DAY |
その列の値を、型のデフォルト値('')にします |
| 行TTL+WHERE | TTL event_date + INTERVAL 180 DAY DELETE WHERE status = 'test' |
条件に合う行を削除します |
| GROUP BYによる要約 | TTL ts + INTERVAL 30 DAY GROUP BY user_id, toStartOfDay(ts) SET ... |
複数の行を1行にまとめます |
MODIFY TTLや、TTLが付いたMODIFY COLUMNは、デフォルト(materialize_ttl_after_modify = 1)で、既存のパートに適用するMATERIALIZE TTLミューテーションを一緒に作ります。つまり、TTLを変えるのも、パートを書き直す作業です。GROUP BYによる要約のグループ化の列は、プライマリキーの先頭部分である必要があります。このPodで、2024年3月の閲覧記録20万行は、INSERT直後は20万行のままで、OPTIMIZE FINALのあと(ユーザー, 日付)の15,500行に減りました。
TTLの式がnow()基準なので、結果が時間によって変わる点も、知っておいてください。このラボはデータをすべて2024年にし、法的保存の行はif(legal_hold = 1, toDate('2100-01-01'), ...)で遠い未来を与えて、採点の時刻に関係なく、同じ行が同じ判定を受けるようにしました。
現場での姿
「GDPRの削除依頼が1日に数百件あって、1件ごとにALTER DELETEを実行する」という設計は、すぐにミューテーションの待ち行列をためます。テーブルのすべてのミューテーションは順番に動き、1つが失敗で止まれば、後ろがすべて阻まれます。削除が頻繁なら、軽量DELETEやReplacingMergeTreeの削除マーカー、パーティション単位のDROPを先に検討します。
2番目は、「軽量DELETEをしたから、ディスクが空いたはずだ」という誤解です。マスクを書いただけなので、容量はマージのあとで戻ります。法的に「いつまでに物理削除」を約束する必要があるなら、ドキュメントが勧めるように、min_age_to_force_merge_secondsやALTER DELETEを使います。
3番目は、TTLが「効かない」という報告です。TTLはマージのときにしか適用されないので、静かなテーブルでは数時間遅れることがあります。確認するときは、MATERIALIZE TTLですぐに適用し、普段は、TTLと同じ時間単位でパーティションを分けて、まるごと落ちるようにするのが、ドキュメントの推奨です。
次のラボですること
mut.eventsの40万行をパート1つにしてから、ALTER UPDATEがパートをまるごと書き直す様子をpart_logで確認します。ユーザー777はALTER DELETEで、888は軽量DELETEで削除し、マスクと物理削除の違いを数えます。email列のTTLとtest行のTTLを適用し、閲覧記録をGROUP BY TTLで要約したあと、最後に、失敗して止まったミューテーションを記録してKILLします。