パートとマージ — INSERT がパートを作り、マージが減らす
一言でいうと
MergeTreeでは、INSERT 1回で新しいパートが少なくとも1つできます。バックグラウンドのマージが、小さなパートをまとめて大きなパートにします。パートがマージより速くたまると、サーバーはINSERTを拒否します(TOO_MANY_PARTS)。そのため、ClickHouseにデータを入れるときの核心は「何行を入れるか」ではなく、パートをいくつ作るかです。
なぜ必要なのか
前のモジュールで、パートは一度書かれると変わらないと説明しました。そのおかげで、書き込みはロックなしで新しいディレクトリを1つ作るだけで終わり、読み取りはすでにソートされ圧縮されたファイルを走査するだけで済みます。ところがこの設計には代償がついてきます。行を1行ずつ入れると、パートも1行だけのものが1つずつできます。読み取りはパートごとにインデックスを別々に見てファイルを別々に開く必要があるため、パートが多いほど遅くなり、マージはその小さなパートをもう一度読み、もう一度書かなければなりません。
行指向DBに慣れた人は、イベントが来るたびにINSERTを1行ずつ送るコードを簡単に書いてしまいます。ClickHouseでは、そのコードが運用障害になります。このモジュールでは、パートができてまとまる過程をsystem.part_logのイベントで追い、しきい値を超えたときに何が止まるのか、サーバーが代わりにまとめてくれる非同期INSERTが何を変えるのかを、数値で見ます。
どう動くのか
パート名が履歴です。all_3_7_2は、パーティションall、ブロック番号3から7までを覆い、マージレベルが2という意味です。INSERTが作ったパートはブロック番号を1つ受け取ってall_N_N_0になり、公式ドキュメント(Part merges)のとおり、マージのたびにレベルが1つずつ上がります。ラボのPodで、マージを止めたテーブルにINSERT文10個を送ると、all_1_1_0からall_10_10_0までがそのまま残ります。
INSERT 1回 ≠ パート1つです。サーバーは届いた行をブロックにまとめ、ブロックごとにパートを書きます。INSERT ... SELECTは、min_insert_block_size_rows(デフォルトは1,048,449)の分だけ集まるまで、ブロックをつなげます。100万行をmin_insert_block_size_rows = 250000で入れたら、パートが4個(261,636 × 3 + 215,092)できました。パーティションがあるテーブルなら、ブロックがパーティションごとにさらに分かれます。これは次のモジュールの主題です。
イベントはpart_logに残ります。このPodはsystem.part_logを有効にしてあります。パートができるとNewPart(どのINSERTのquery_idが作ったかも一緒に)、まとめられるとMergeParts(何をまとめたかはmerged_fromに)が、1行ずつたまります。system.partsは「今」しか見せませんが、part_logは「どうやってここまで来たか」を見せます。テーブルを削除して同じ名前で作り直すと古い記録と混ざるので、table_uuidで絞り込みます。
マージは止められますが、OPTIMIZEも一緒に止まります。SYSTEM STOP MERGES 표は、そのテーブルのマージを止めます(サーバーを再起動すると解除されます。プレースホルダーはテーブル名です)。実測したところ、止めたテーブルにOPTIMIZE ... FINALを実行すると、Cancelled merging parts(ABORTED)で拒否されました。SYSTEM START MERGESで解除した直後は、バックグラウンドのマージが先に10個をall_1_10_1にまとめ、FINALがその1つのパートを書き直してall_1_10_2になりました。ドキュメントのとおり、FINALはすでに1つのパートでもマージを行います。どの経路をたどったかは毎回違うことがあるので、part_logで確認します。
しきい値について。パーティション1つのアクティブなパートがparts_to_delay_insertを超えると、INSERTをわざと遅らせ、parts_to_throw_insertに達するとToo many parts ... Merges are processing significantly slower than insertsで拒否します(エラー252)。この値はテーブル設定です。5に下げてマージを止めたまま1行ずつ入れたところ、5個目までは入り、6回目が拒否されました。解除する方法は、パートを減らすことです。マージを有効にしてまとめてから入れ直すと、入ります。
非同期INSERTはサーバーがまとめてくれます。26.8のデフォルトはasync_insert = 1、wait_for_async_insert = 1です。ラボのPodでINSERT ... VALUESを送ると、query_logにInsertとAsyncInsertFlushの2つのクエリが残ります。ところが待機するモードで1つのクライアントが1件ずつ送ると、毎回バッファが空になるので、パートも1つずつできます(5回で5個)。複数のINSERTが1つのパートになるには、同時にバッファにある必要があります。待機しないモード(wait_for_async_insert = 0)で5回送り、SYSTEM FLUSH ASYNC INSERT QUEUEで空にしたら、5回分が1つのパートになりました。ドキュメントは待機しないモードを勧めていません。エラーがクライアントに返らないからです。ラボでは、時間に関係なくまとまる様子を見るために使います。なお、INSERT ... SELECTには非同期INSERTは適用されません。
現場での姿
最もよくある障害が「Too many parts」です。原因はほとんどの場合、次の2つのどちらかです。1行ずつ挿入するコレクターか、細かく分けすぎたパーティションです。しきい値を上げても症状を遅らせるだけだと、ドキュメントも述べています。まずpart_logでNewPartをquery_idごとに数えると、どのINSERTがパートを大量に作っているかがすぐにわかります。解決策は、クライアントでまとめて送る(ドキュメント(Selecting an insert strategy)の推奨は、1回に最低1,000行、できれば10,000–100,000行)か、それができなければ非同期INSERTに任せることです。
2番目は、OPTIMIZE ... FINALをcronで回している運用です。ドキュメント(Avoid OPTIMIZE FINAL)は、これを日常の作業ではなく管理作業として扱うよう述べています。すでに1つのパートでも書き直すので、大きなテーブルではコストが高くなります。このラボでFINALを使うのは、パート数を固定して目で確かめるためです。
次のラボですること
マージを止めたparts.eventsにINSERT文10個を送ってパート10個を作り、100万行のINSERT 1回がブロックサイズによって複数のパートになる様子を、part_logで確認します。マージを有効にしてOPTIMIZEでまとめた過程を読み、しきい値を5に下げたテーブルでTOO_MANY_PARTSを起こしてから、解除します。最後に、非同期INSERT 5個を1つのパートにまとめ、テーブルごとの新しいパート数を1つの表にまとめます。