TT Lab
はじめる
学ぶ 学習パス コース

ClickHouse — 列指向分析 DB を中身から

ReplacingMergeTree — 更新と削除を INSERT で表現する

TT Labで続きを見る

一言でいうと

ReplacingMergeTreeは、ソートキーが同じ行をマージのときに1つに減らすエンジンです。バージョン列が大きいほうが勝ち、勝った行に削除マーカーがあれば、そのキーは存在しないものとして扱います。マージはいつ起きるかわからないので、正確な答えが必要なクエリは、FINALかargMaxで、クエリ実行時に同じルールを適用する必要があります。

なぜ必要なのか

本番DBのユーザーテーブルを分析DBに移すと、すぐに「料金プランが変わった」「退会した」という変更がついてきます。行指向DBならUPDATEやDELETEの1行で済みますが、前のモジュールで見たとおり、MergeTreeのパートは一度書かれたら変更しません。1行を変えるには、その行が入っているパートの列ファイルを書き直す必要があり、それを変更のたびに行うと、書き込みが爆発します。

そこで発想を逆にします。変更せず、新しいバージョンを1行追加します。退会も「退会した」というバージョンを追加します。公式ガイド(Working with the ReplacingMergeTree engine)は、これを「不変のINSERTで更新を真似る」と表現しています。古いバージョンを片付ける作業は、どのみち起きるバックグラウンドのマージに乗せます。書き込みはいつも追記(append)なので速く、代償は「マージされるまでは古いバージョンも見える」ことです。

どう動くのか

3回のINSERTが作った3つのパートに、ユーザー7のバージョン1・2と削除マーカー付きのバージョン3、ユーザー8のバージョン1が散らばっています。通常のマージのあとには、ユーザー7の削除マーカーの行とユーザー8の行が残り、CLEANUPマージのあとにはユーザー8だけが残ります。そのあと、ユーザー7のバージョン1が遅れてもう一度入ってくると、削除マーカーが残ったテーブルではバージョン3が勝って削除された状態が保たれますが、CLEANUPしたテーブルではユーザー7が復活します

エンジンの宣言はReplacingMergeTree(ver, is_deleted)です。リファレンスドキュメントが定めるルールは、次のとおりです。

このラボのデータ(ユーザー100,000人・更新30,058行・削除5,108行)で測った数値が、ルールをそのまま示しています。マージを止めて入れるとcount()は135,166行、FINALを付けると94,892人です。OPTIMIZE ... FINALでまとめたあとも行は100,000個で、ユーザーごとに1つですが、そのうち5,108個が削除マーカーの行なので、FINALなしで数えると、やはり間違いです。CLEANUPマージのあとで、やっと94,892行になります。

FINALは、クエリ実行中にマージのルールを適用します。同じ答えをargMaxでも出せます。

SELECT plan, count() FROM (
  SELECT user_id, argMax(plan, ver) AS plan, argMax(is_deleted, ver) AS d
  FROM rmt.users GROUP BY user_id
) WHERE d = 0 GROUP BY plan;

argMax(x, ver)は、verが最も大きい行のxです。何を選ぶべきかをクエリが直接述べるので、FINALを使えない場所(ほかのエンジン、複数のテーブルを合わせた結果)でも通用します。

もう1つ、1回のINSERTの中の重複は、入れた瞬間に減ります。デフォルト設定のoptimize_on_insert = 1のため、3つのバージョンを1回のINSERTで入れたら、パートは最初から100,000行でした。「マージ前」を見たいなら、バージョンを別々に入れ、そのテーブルのマージをSYSTEM STOP MERGESで止める必要があります。止めたテーブルにOPTIMIZEを実行すると、「Cancelled merging parts」で拒否されます。

現場での姿

最もよくある事故は、ソートキーに変わる列を入れてしまうことです。クエリ性能のためにORDER BY (user_id, plan)のように決めると、料金プランが変わったユーザーは、ソートキーが変わって「別の行」になります。ラボでこのように作ったテーブルは、FINALで数えても115,062行(95,919人)になりました。古い料金プランの行が永遠にまとまらず、削除マーカーも古い料金プランの行を隠せないので、退会したユーザーが生きています。ガイドが「ORDER BYの列は変わってはならない」と明記している理由です。

2番目は、CLEANUPのあとの再送です。パイプラインが古いバッチをもう一度送ることはよくあります(再処理、オフセットの巻き戻し)。削除マーカーが残っているテーブルでは、バージョン3がバージョン1に勝つので平気ですが、CLEANUPで削除の行を消したテーブルでは、比べる相手がなく、古い行がそのまま復活します。ラボで、ユーザー1–1000の最初のロード行をもう一度入れたら、CLEANUPしたテーブルだけで56人が復活しました。ガイドは、CLEANUPを「古いバージョンが二度と入ってこないと確信できるときだけ」使うよう述べています。

3番目はFINALのコストです。FINALはクエリ実行中にマージを行うので、絞り込む条件がソートキーにかかるほど安くなります。パーティションキーが行ごとに変わらないなら、パーティションごとに別々に処理させるdo_not_merge_across_partitions_select_finalも、ガイドに出ています。

次のラボですること

rmt.usersをReplacingMergeTree(ver, is_deleted)で作り、マージを止めたまま変更履歴の3つのまとまりを入れます。FINALなしとありで数えた行数を比べ、FINALなしでargMaxを使って、料金プランごとの現在のユーザー数を求めます。新しいテーブル2つにバージョンごとに移し、1つは通常のマージ、もう1つはCLEANUPマージを行ってから、残った行を数え、ソートキーにplanを入れたテーブルが何を復活させるかを見ます。最後に、古い行をもう一度入れて、2つのテーブルの反応がどう違うかを記録します。