挿入された行の自動 upsert
ALTER や DELETE ステートメントを使わなくても、同じ行の複数のコピーを挿入し、そのうち 1 つを最新バージョンとして示すことで、更新操作を適用できます。これに対し、バックグラウンドプロセスが同じ行の古いバージョンを非同期に削除し、不変な insert を使って更新操作を効率的に再現します。
これは、テーブルエンジンが重複行を識別できることに依存しています。これは、一意性の判定に ORDER BY 句を使うことで実現されます。つまり、2 つの行が ORDER BY で指定されたカラムに対して同じ値を持つ場合、それらは重複と見なされます。テーブル定義時に指定する version カラムにより、2 つの行が重複と識別された場合に最新バージョンの行を保持できます。つまり、version の値が最も大きい行が保持されます。
このプロセスを以下の例で示します。ここでは、行は A カラム (このテーブルの ORDER BY) によって一意に識別されます。これらの行は 2 つの batches として挿入され、その結果、ディスク上に 2 つのパーツが作成されたと仮定します。その後、非同期のバックグラウンドプロセスで、これらのパーツがマージされます。
ReplacingMergeTree では、さらに deleted カラムも指定できます。このカラムには 0 または 1 を設定でき、値が 1 の場合はその行 (およびその重複) が削除済みであることを示し、それ以外では 0 を使用します。注意: 削除済みの行はマージ時には削除されません。
このプロセスでは、パーツマージ中に次のことが起こります。
- A カラムの値 1 で識別される行には、version 2 の更新行と version 3 の削除行 (deleted カラムの値は 1) の両方があります。したがって、削除済みとしてマークされた最新の行が保持されます。
- A カラムの値 2 で識別される行には、2 つの更新行があります。新しい方の行が保持され、price カラムの値は 6 になります。
- A カラムの値 3 で識別される行には、version 1 の行と version 2 の削除行があります。この削除行が保持されます。
削除済みの行は決して削除されない点に注意してください。これらは
OPTIMIZE table FINAL CLEANUP によって強制的に削除できます。これには実験的な設定 allow_experimental_replacing_merge_with_cleanup=1 が必要です。これは、以下の条件下でのみ実行してください。
- クリーンアップで削除対象となる古いバージョンを持つ行が、操作の実行後に挿入されないことを確実にできること。これらが挿入されると、削除済みの行がすでに存在しないため、誤って保持されます。
- クリーンアップを実行する前に、すべてのレプリカが同期していることを確認してください。これは次のコマンドで実現できます。
ReplacingMergeTree で deletes を処理する方法は、上記の条件でクリーンアップ期間を確保できる場合を除き、delete の割合が少量から中程度 (10%未満) の table にのみ推奨されます。
ヒント: 今後変更されない一部のパーティションに対して OPTIMIZE FINAL CLEANUP を実行できる場合もあります。
プライマリ/重複排除キーの選択
ORDER BY のカラムの値は、変更をまたいでも行を一意に識別できなければなりません。そのため、Postgres のようなトランザクションデータベースから移行する場合は、元の Postgres の主キーを ClickHouse の ORDER BY 句に含める必要があります。
ClickHouse の利用者であれば、テーブルの ORDER BY 句に含めるカラムを選び、クエリパフォーマンスを最適化することには慣れているでしょう。一般に、これらのカラムは よく実行されるクエリに基づいて選択し、カーディナリティの低い順に並べるべきです。重要なのは、ReplacingMergeTree では追加の制約があることです。つまり、これらのカラムは不変でなければなりません。たとえば Postgres からレプリケーションする場合は、基になる Postgres のデータで変化しないカラムだけをこの句に追加してください。他のカラムは変更されてもかまいませんが、これらのカラムは行を一意に識別するため、常に同じ値である必要があります。
分析ワークロードでは、通常、Postgres の主キーはあまり役に立ちません。特定の 1 行を対象にしたルックアップを行うことはほとんどないためです。カラムはカーディナリティの低い順に並べることを推奨しており、さらに ORDER BY の前方にあるカラムに一致する条件のほうが通常は高速 であることから、Postgres の主キーは ORDER BY の末尾に追加するべきです (分析上の価値がある場合を除きます) 。Postgres で複数のカラムが主キーを構成している場合は、カーディナリティとクエリ上の有用性を考慮しつつ、それらを ORDER BY に追加する必要があります。また、MATERIALIZED カラムで値を連結して、一意の主キーを生成する方法も検討できます。
Stack Overflow データセットの posts テーブルを考えてみましょう。
ORDER BY キーとして (PostTypeId, toDate(CreationDate), CreationDate, Id) を使用します。各投稿で一意な Id カラムにより、行を重複排除できます。必要に応じて、Version カラムと Deleted カラムをスキーマに追加します。
ReplacingMergeTree のクエリ
ORDER BY カラムの値を一意の識別子として使って重複した行を特定し、最も大きいバージョンだけを残すか、最新バージョンが削除を示している場合は重複した行をすべて削除します。ただし、これで保証されるのは最終的な整合性だけであり、行が必ず重複排除されるわけではないため、これに依存すべきではありません。したがって、クエリでは更新行や削除行も対象に含まれるため、誤った結果が返される可能性があります。
正しい結果を得るには、background merges をクエリ時の重複排除と削除行の除去で補う必要があります。これは FINAL 演算子を使うことで実現できます。
上記の Posts テーブルについて考えてみましょう。このデータセットは通常の方法でロードできますが、値として 0 を指定した deleted カラムと version カラムを追加します。例を簡単にするため、ここでは 10000 行のみをロードします。
INSERT INTO SELECT でシミュレートできます。
INSERT INTO SELECT で再現できます。
FINAL を適用すると、正しい結果が得られます。
FINAL のパフォーマンス
FINAL 演算子は、クエリにわずかなパフォーマンス上のオーバーヘッドをもたらします。
これは、クエリが主キーカラムでフィルタリングされていない場合に特に顕著です。
その結果、より多くのデータが読み取られ、重複排除のオーバーヘッドも増加します。WHERE 条件でキーカラムを
フィルタリングに使用すると、読み込まれて重複排除に回されるデータ量を減らせます。
WHERE 条件でキーカラムを使用しない場合、ClickHouse は現在、FINAL 使用時に PREWHERE 最適化を利用しません。この最適化は、フィルタされていないカラムについて読み取る行数を減らすことを目的としています。この PREWHERE を再現し、パフォーマンスを改善できる可能性がある例は、こちらで確認できます。
ReplacingMergeTree でパーティションを活用する
FINAL クエリのパフォーマンスを向上させるために、do_not_merge_across_partitions_select_final=1 という設定を使用できます。この設定により、FINAL 使用時にはパーティションごとに個別にマージと処理が行われます。
次の posts テーブルでは、パーティション化を使用していません。
FINAL が実際に処理を行う必要があるように、重複した行を insert して AnswerCount を増やし、100万行を更新します。
FINAL を使用して、年ごとの回答の合計を計算します:
do_not_merge_across_partitions_select_final=1 を指定して上記のクエリをもう一度実行します。
マージ動作に関する考慮事項
マージ選択ロジック
大きなパーツにおけるマージ動作
max_bytes_to_merge_at_max_space_in_pool のしきい値に達すると、min_age_to_force_merge_seconds が設定されていても、以降のマージ対象には選ばれなくなります。その結果、継続的なデータ挿入に伴って蓄積する可能性がある重複の除去を、自動マージに頼ることはできなくなります。
これに対処するには、OPTIMIZE FINAL を実行して、パーツを手動でマージし、重複を削除できます。自動マージとは異なり、OPTIMIZE FINAL は max_bytes_to_merge_at_max_space_in_pool のしきい値を回避し、利用可能なリソース、特にディスク容量のみに基づいてパーツをマージし、各パーティションでパーツが 1 つだけ残るまで処理を進めます。ただし、この方法は大規模なテーブルではメモリ消費が大きくなる可能性があり、新しいデータが追加されるたびに繰り返し実行が必要になる場合があります。
パフォーマンスを維持しつつ、より持続可能な解決策とするには、テーブルのパーティション化を推奨します。これにより、データパーツがマージの最大サイズに達するのを防ぎやすくなり、継続的な手動最適化の必要性も減らせます。