業務DBをClickHouseに同期して分析基盤を作ると、ほぼ全員が同じところで転びます。
構成は単純で、MySQL側の orders テーブル1万件をClickHouseにコピーし、以降は更新分だけを差分同期する、というよくあるものです。初回同期のあと SELECT count() を叩いたら1万件。ここまでは想定通りでした。
差分同期を1回流したあと、もう一度数えるとこうなります。
初回同期直後 : 10000
差分同期直後 (FINALなし) : 12000
更新しただけなのに2,000件増えました。ここからが妙で、同じ操作を続けたのに、条件によって3通りに分かれます。
- そのまま同期を続けると1万8千件まで増え、あるとき突然1万件に戻り、また増えはじめる
- 同期を止めて放置すると、90分待っても1万2千件のまま動かない
- 本番規模を想定して2,000万件で試すと、戻らずに増え続ける
エラーは一切出ません。例外も警告もなく、件数だけが黙って動きます(マージ自体は system.part_log を有効にしていれば追えますが、これが異常だと教えてくれるものは何もありません)。
この記事では、なぜこうなるのか、どれだけ数字がズレるのか、そして実務でどう始末をつけるのかを、全部手元で動かして確かめた結果とともに書きます。使ったデータは numbers() で生成したダミーです。
掲載した現象はすべて、ネイティブビルド(ClickHouse 26.8.1 / macOS arm64)と公式Dockerイメージ(ClickHouse 26.5.1 / Linux aarch64)の二つの環境で再現を確認しています。ただし一致するのは現象であって、数値ではありません。バックグラウンドマージのタイミングや、後半のベンチマークで使う乱数データに依存する部分は、実行のたびに多少変わります。数値を並べる箇所には、どちらの環境で測ったかを明記しています。
なぜこの構成になるのか
業務DBの更新をClickHouse側に反映する方法は、大きく3つあります。
1つ目は ALTER TABLE ... UPDATE。 非同期のミューテーションで、影響するデータパートの対象列を書き直します。結果は確実ですが処理が重く、件数の多い更新を継続的に流す用途には向きません。
2つ目は軽量UPDATE。 26.8時点ではBeta扱いですが、普通の UPDATE 文がそのまま書けます。
CREATE TABLE lw (id UInt64, status String) ENGINE = MergeTree ORDER BY id
SETTINGS enable_block_number_column = 1, enable_block_offset_column = 1;
UPDATE lw SET status = 'paid' WHERE id <= 100;
100万行のテーブルに対して0.155秒で完了し、直後のSELECTに即座に反映されました。パートを見ると、更新分だけの小さなパッチパートが別に作られています。
┌─name───────────────────────────────────────────────┬────rows─┬─part_type─┐
│ all_1_1_0 │ 1000000 │ Wide │
│ patch-b494fc75f2ea5039ad93df97bb52ef2c-all_3_3_0_2 │ 100 │ Compact │
└────────────────────────────────────────────────────┴─────────┴───────────┘
軽快ですが、公式が想定しているのはテーブルの10%程度までの少量更新です。CDCのように更新が絶え間なく流れ込む構成には向きません。
3つ目が ReplacingMergeTree。 更新を「行の挿入」として受け取り、同じキーの行が複数あったら新しいほうを残す、というエンジンです。更新のあるデータを継続的に同期する構成では、公式ドキュメントでもこれが第一候補として出てきます。この記事が扱うのはこの3つ目です。
CREATE TABLE orders (
id UInt64,
status String,
amount Decimal(12,2),
updated_at DateTime
) ENGINE = ReplacingMergeTree(updated_at)
ORDER BY id;
ReplacingMergeTree(updated_at) の updated_at はバージョン列で、同じ id の行が複数あったら updated_at が大きいほうを残す、という意味になります。ここまで読むと「これで更新が表現できる」と思います。私もそう思いました。
ReplacingMergeTree は重複を「消す」テーブルではない
罠はエンジン名にあります。Replacing と言っていますが、重複が消えるのはバックグラウンドのマージが走ったときだけです。
公式ドキュメントの表現はかなり率直で、マージは "in the background at an unknown time" に実行される、と書かれています。そのうえでこう続きます。
you can't plan for it. Some of the data may remain unprocessed.
つまり「いつ消えるかは計画できないし、消えないまま残るデータもある」と明言されています。挿入した直後にSELECTすれば、古い行と新しい行が両方返ってきます。これは不具合ではなく仕様通りの動作です。
先ほどの1万件と2,000件で確かめると、こうなります。
差分同期直後 (FINALなし) : 12000
差分同期直後 (FINALあり) : 10000
売上合計 (FINALなし) : 12000000
売上合計 (FINALあり) : 10000000
アクティブpart数 : 2
FINAL を付ければ正しい答えが返ります。付けない場合は、正しいこともあれば正しくないこともあります。マージが先に終わっていれば正しい値が返りますし、この例のように終わっていなければ返りません。保証がないというのが正確な言い方で、正しさの責任がテーブル側ではなくクエリ側に置かれている、というのがこのエンジンの本質です。
なぜ、あるとき突然1万件に戻るのか
いちばん気持ち悪いのが、放っておくと数字が正常に戻ることです。時間が経てばマージが走って直る、という理解でいったん納得しかけました。
これは半分しか合っていません。更新日時を1日ずつ進めた差分バッチを10回、4秒間隔で連続投入し、毎回そのつど件数とパート数を記録してみます(実時間で40秒ほどの出来事で、「毎日」動かしたわけではありません。日付はデータの中身にすぎません)。
同期回 count() parts FINAL
8/3 14000 3 10000
8/4 16000 4 10000
8/5 18000 5 10000
8/6 10000 1 10000
8/7 12000 2 10000
8/8 14000 3 10000
8/9 16000 4 10000
8/10 18000 5 10000
8/11 10000 1 10000
8/12 12000 2 10000
件数が10,000と18,000のあいだをのこぎり波のように往復し、リセットは決まってパートが5つになった回に起きています。FINAL を付けた列だけが最初から最後まで10,000で安定しています。
なぜ5つなのか。merge_selector_base という設定が効いています。
SELECT name, value FROM system.merge_tree_settings WHERE name = 'merge_selector_base';
-- merge_selector_base 5
名前から「5パート溜まったらマージ」と読みたくなりますが、これは違います。ソースを見ると、base はパート数ではなくサイズの比に対する閾値です。
Minimum ratio of size of one part to all parts in set of parts to merge
判定式はこうなっています(SimpleMergeSelector.cpp)。
(sum_size + count * size_fixed_cost_to_add) / (max_size + size_fixed_cost_to_add) >= lowered_base
size_fixed_cost_to_add は既定5MBの固定コストです。ここで、同じくらいのサイズのパートが n 個ある場合を考えると、この比はおよそ n になります。だから base = 5 に対しては「同サイズのパートが5個」で条件を満たす。のこぎり波の周期の正体はこれで、パート数そのものが閾値なのではなく、たまたま比がパート数に一致していたというだけです。
時間も効く。ただし下限がある
lowered_base という名前が示す通り、この閾値は固定ではありません。
lowered_base = interpolateLinear(base, 2.0, combined_ratio)
パートの経過時間とパーティション内のパート数に応じて、base は 5 から下げられます。ヘッダのコメントには「1 まで下がる(実質どの2パートでもマージする)」とありますが、実装が下げるのは 2.0 までです。ソースにはこうも書かれています。
If no new parts arrive, we should continue to merge existing data parts to eventually optimize the table.
つまり新しいパートが来なくても、時間が経てばマージは進みます。実際に確かめました。ほぼ同サイズのパート3つ(各8,682バイト)を作り、以降INSERTを一切せずに放置します。
経過 0秒 parts=3
経過 600秒 parts=3
経過1200秒 parts=3
経過1800秒 parts=3
経過2541秒 → マージ発生 parts=1 count=6000 → 2000
42分後に、何もしていないのにマージされました。比が3.0なので、lowered_base が 3.0 を下回るまで待たされた計算になります。事前にパラメータから予測した発火時刻は2,404秒で、実測2,541秒との誤差は6%でした。
system.part_log にも記録が残ります。
event_time table event_type merge_reason merged_from part_name
2026-08-23 03:41:04 idle3 MergeParts RegularMerge all_1_1_1, all_2_2_1, all_3_3_1 all_1_3_2
ところが、既定設定では直らない組み合わせがある
ここまでなら「待てばいつか直る」で終わりでした。ところが最初の例、つまり10,000件のパートと2,000件のパートというサイズの揃っていない2パートで同じことをやると、結果が変わります。
経過 0秒 count=12000 parts=2
経過 1800秒 count=12000 parts=2
経過 3600秒 count=12000 parts=2
経過 5400秒 count=12000 parts=2 ← 90分経過、マージされず
90分放置しても一度もマージされませんでした。判定式に実測サイズを入れると理由が分かります。
(50,039 + 2 × 5MB) / (41,339 + 5MB) = 1.9938
lowered_base の下限 = 2.0
1.9938 < 2.0。閾値の下限にわずかに届いていません。lowered_base が 2.0 より下がらないので、この2パートは通常のバックグラウンドマージの対象になりません。
一般化すると、パートがちょうど2つのとき、この比が 2.0 に達するのは両者のサイズが完全に等しい場合だけです。片方が少しでも大きければ 2.0 を下回ります。初回フルロードのあとに小さな差分を1回だけ入れる、という同期パターンはまさにこの形です。
条件を明示しておきます。この「マージされない」は、ClickHouse 26.8.1 系の SimpleMergeSelector、既定設定、追加INSERTなし、強制マージ設定なしという条件下での話です。merge_selector_blurry_base_scale_factor を有効にすると閾値が確率的に揺れますし、次に触れる強制マージを設定すれば話は変わります。
逃げ道:min_age_to_force_merge_seconds
止まったパートを動かす設定があります。既定は 0(無効)ですが、値を入れると通常の判定を飛ばして古いパートを強制的にマージします。
CREATE TABLE force_test (...) ENGINE = ReplacingMergeTree(updated_at) ORDER BY id
SETTINGS min_age_to_force_merge_seconds = 30;
先ほどと同じ 10,000件 + 2,000件の不均等な2パートで試すと、こうなりました。
投入直後 count=12000 parts=2
15秒後 count=12000 parts=2
30秒後 count=10000 parts=1 ← 強制マージされた
比が 2.0 に届かないままでも、設定した秒数でマージされます。すでに動いているテーブルに ALTER TABLE ... MODIFY SETTING で後から入れることもできますが、その場合はマージ選択タスクのバックオフ(max_merge_selecting_sleep_ms 既定60秒)の影響で反映まで数分かかりました。
もちろんこれは強制的に書き込み増幅を上げる設定なので、パートが大きいテーブルに軽い気持ちで入れるものではありません。
実データ規模だと、のこぎり波にならない
ここまでの実験は1万件の土台に2千件、つまり20%もの差分を当てるという極端な比率でした。実務のCDCはもっと小さい差分を流すので、同じ形になるとは限りません。確かめました。
2,000万件の土台に対して、2万件(全体の0.1%)の差分を20回投入します。
回 count() parts FINAL
1 20,019,991 4 20,000,000
5 20,099,952 8 20,000,000
6 20,119,661 4 20,000,000 ← partは減ったのにcountは減らない
10 20,199,614 8 20,000,000
11 20,218,819 4 20,000,000
15 20,298,775 8 20,000,000
20 20,397,072 7 20,000,000
のこぎり波になりません。 パート数は4と8のあいだを往復しているのに、count() は一度も土台の20,000,000へ戻らず、単調に増え続けています。20回で約40万件、2%の水増しです。
パート構成を見ると理由が分かります。
all_1_6_2 6,671,718行 26.45 MiB ← 土台
all_7_12_2 6,671,718行 26.45 MiB ← 土台
all_13_18_2 6,656,564行 26.39 MiB ← 土台
all_19_35_4 337,110行 2.46 MiB ← 差分が集まったもの
all_36_36_1 19,986行 98.22 KiB ← 新しい差分
all_37_37_1 19,989行 98.25 KiB
all_38_38_1 19,987行 98.20 KiB
マージされているのは小さい差分パートどうしだけです。26MiBの土台と98KiBの差分では、判定式の比が 1.004 程度にしかならず、いつまでも同じグループに入りません。差分どうしのマージで消えるのは差分内の重複だけなので、土台に対する重複はそのまま残り続けます。
全パートをまとめてマージする条件を計算すると、比は 3.72。base がここまで下がるのに必要な時間を既定パラメータから逆算すると、約4.9日でした。
sum_size 82MiB → size_normalized 0.382
max_age_to_lower_base = 11.5日
比 3.72 に必要な combined_ratio = 0.426
→ 推定 4.89日
この計算式は、先ほどの数KBのパート3つに当てはめると0.7時間と出ます。実測が42分だったので、モデルとしては妥当そうです。
つまり規模によって話がまるで変わります。
- 小さいテーブルでは、パート数が数個のうちに比が閾値を超えるので、数十分から数時間で勝手に直る。のこぎり波になる
- 大きいテーブルでは、土台と差分のサイズ差が大きすぎて同じマージに入らない。重複は単調に積み上がり、全体マージが起きるまで日単位で戻らない
冒頭に挙げた「あるとき突然1万件に戻り、また増えはじめる」は、1万件という小さなテーブルだったから起きた現象です。同じ感覚で本番の数千万件テーブルを運用すると、待っても直りません。
まとめると、「そのうち直る」は条件付きどころか、実データ規模ではあてになりません。
- パートのサイズが揃っていれば、いずれ勝手に直る(数KBのパート3つで実測42分)
- サイズが大きく偏った2パートは、既定設定のままでは待っても直らない
- 土台が大きく差分が小さい実運用の形では、重複が単調に増え続ける
ダッシュボードを開くたびに数字が違う現象の正体はこれで、しかも「しばらく待てば正しくなる」と期待してよい保証はどこにもありません。
数字はどれくらいズレるのか
小さいテーブルだと実感が湧かないので、2,000万件の初回同期に対して10万件のCDC差分を60バッチ流した状態で測りました。以下はDocker環境(26.5.1)での値です。差分の対象IDを rand64() で選んでいるため、絶対値は実行のたびに数百件ずれます。
count() FINALなし : 21,481,332
count() FINALあり : 20,000,000
sum(amount) status='paid' FINALなし : 16,347,183,000
sum(amount) status='paid' FINALあり : 15,308,190,000
売上が6.8%多く出ています。
この壊れ方の何が厄介かというと、桁が変わるような派手なズレ方をしないことです。クエリは成功し、それらしい数字が返り、グラフの形も自然です。誰も気づきません。気づくのは、経理の数字と突き合わせたときか、翌週に同じレポートを出して数字が違ったときです。
FINAL を付ければ直る。ただし代償がある
FINAL はクエリ実行時にマージ相当の処理を行って正しい結果を返す修飾子です。公式ドキュメントには、指定した列に加えて主キー列も読む場合があること、追加の計算資源とメモリを要することが書かれています。
同じテーブル(アクティブパート6つ、Docker環境)で、各クエリ5回実行した最速値です。
count() FINALなし 最速 0.089s
count() FINALあり 最速 0.211s (2.4倍)
sum(amount) status絞込 FINALなし 最速 0.152s
sum(amount) status絞込 FINALあり 最速 0.296s (1.9倍)
正直なところ、覚悟していたほど遅くはありませんでした。2,000万行に対して0.3秒なら実用範囲です。
ただしこの倍率をそのまま持ち帰らないでください。FINALのコストは、重複するキー範囲の広さ、未統合のパート、実際に読む行数などが増えるほど高くなる傾向があり、パート数に対して単純に比例するわけでもありません。あとで触れる公式ベンチマークのように、条件次第では桁違いに重くなります。速いからClickHouseを選んだのに、正しさのために恒久的な税金を払い続ける構図であることは変わりません。
回避策としてよく紹介される argMax + GROUP BY も測りました。
SELECT sum(t.1) FROM (
SELECT id, argMax(tuple(amount, status), updated_at) AS t
FROM orders_cdc GROUP BY id
) WHERE t.2 = 'paid';
列ごとに argMax(amount, updated_at) と argMax(status, updated_at) を並べたくなりますが、これは避けてください。公式リファレンスは、最大値が複数ある場合にどの行が返るかについて "which of the associated arg is returned is not deterministic" と明記しています。バージョンが衝突したとき、amount と status が別々の行から拾われて、存在しない組み合わせが出来上がる可能性があります。タプルにまとめれば1行から取ることが保証されます。
まずネイティブ環境(26.8.1 / macOS)での値です。
argMax方式 最速 0.578s
FINAL 最速 0.269s
FINALより2倍以上遅い。ところが同じクエリをDocker(26.5.1 / メモリ7GB割り当て)で実行すると、差が桁違いに開きます。
argMax方式 最速 12.645s
FINAL 最速 0.325s
同じクエリで40倍近い差です。何が起きているのか system.query_log を見ると分かります。
実行時間 ピークメモリ ディスク退避
argMax 既定 12.43s 3.59 GiB 18パート
FINAL 0.20s 72.57 MiB なし
argMaxが3.59GiBまでメモリを食い、7GBのコンテナに収まりきらずに外部集計へ落ちていました。ネイティブ環境ではメモリに載りきったので0.578秒で済んだ、という違いです。
ただしここで止めると結論を誤ります。このテーブルは ORDER BY id で、集計も GROUP BY id。つまりソートキーの順に集計できる形なので、順序付き集計を有効にすると状況が変わります。
実行時間 ピークメモリ
argMax 既定 12.43s 3.59 GiB
argMax + optimize_aggregation_in_order=1 1.70s 450.59 MiB
FINAL 0.20s 72.57 MiB
設定ひとつでargMaxは7倍速くなり、メモリは8分の1になり、ディスク退避も消えました。
ここから言えるのは、「argMaxよりFINALが速い」を一般則にしてはいけないということです。今回のデータ・クエリ・メモリ制限・既定の集計設定という条件下ではFINALが速く軽かった、というだけで、GROUP BYとソートキーの関係、max_threads、外部集計のしきい値、事前集計したマテリアライズドビューの有無などで結果は動きます。
逆方向の証拠もあります。ClickHouse公式のベンチマークでは、ReplacingMergeTree + FINAL がベースラインの20〜200倍のメモリを使い、クエリが21〜550%遅くなった例が報告されています。同じ比較で、先ほど触れた軽量UPDATEは7〜18%の劣化に収まっています。FINALが常に軽いわけではまったくありません。
「FINALは遅いからargMaxで書き換えよう」という定番の助言は、少なくとも無条件には成り立ちません。
で、どうするのか
実測を踏まえた結論です。
設計時に決めておくこと
差分の入れ方でパートの形が決まり、パートの形でマージされるかどうかが決まります。土台と差分のサイズが極端に離れるほど、重複は残り続けます。
バッチを細かく刻みすぎると小さなパートが増え、まとめすぎると土台との差が開きます。どちらが効くかはテーブル次第なので、投入後に system.parts でサイズ分布を見て、判定式の比が 2.0 を割っていないかを確認しておくと安心です。
WITH 5*1024*1024 AS C
SELECT count() AS parts,
formatReadableSize(sum(bytes_on_disk)) AS sum_size,
formatReadableSize(max(bytes_on_disk)) AS max_size,
round((sum(bytes_on_disk) + count()*C) / (max(bytes_on_disk) + C), 3) AS ratio
FROM system.parts WHERE table = 'orders' AND active;
比が 2.0 を下回っているなら、そのテーブルは放っておいても直りません。min_age_to_force_merge_seconds を検討するタイミングです。
なお ORDER BY の選び方、パーティションキー、削除イベントの扱いにも、これとは別種の落とし穴があります。そちらは FINAL を付けても直らない類の問題なので、稿を改めて書きます。
運用でやること
FINAL の付け忘れを人間の注意力で防ぐのは無理です。BIツールから接続する場合はSQLに手を入れる余地すらありません。この問題には二つの解があります。
一つはビューで包む方法です。
CREATE VIEW orders_v AS SELECT * FROM orders_cdc FINAL;
生テーブル : 21,481,332
ビュー経由 : 20,000,000
もう一つが final 設定です。これはクエリを書き換えずに、対象テーブルすべてに自動でFINALを適用します。
final=0 : 21,481,332
final=1 : 20,000,000
ユーザーやプロファイルの単位で final=1 を既定にしておけば、分析用の接続からFINALが抜け落ちる事故は防げます。
ただしこれが直してくれるのは「FINALの付け忘れ」だけです。final=1 はテーブルエンジンのマージ規則をクエリ時に適用するだけなので、規則そのものが間違っていれば素通りします。ORDER BY の設計ミス、バージョンの衝突、CDCの欠落や順序の乱れ、削除イベントの取りこぼしは、いずれもこれでは直りません。
やってはいけないこと
OPTIMIZE TABLE ... FINAL を定期実行して先に潰しておく、という発想は自然ですが、公式ドキュメントは "we generally recommend against using OPTIMIZE TABLE ... FINAL" と明確に非推奨としており、管理作業向けで日常運用には適さないとしています。全パートを書き直すので、テーブルが育つほど破滅的になります。
しかも、これを実行しても消えない種類の重複があります(パーティション設計を誤った場合で、別記事で扱います)。効かない上に重い、という組み合わせになり得ます。
とはいえ全面禁止ではありません。公式の表現は "you should avoid the OPTIMIZE FINAL operation in most cases" で、テーブルをフリーズする前やエクスポート前の最終化のような、一回限りの管理操作は正当な用途として認められています。禁じ手なのは定期実行のほうです。
まとめ
ReplacingMergeTree は「重複を消してくれるテーブル」ではなく、「いつか消すかもしれないテーブル」です。正しさの責任はクエリ側の FINAL に外出しされています。
この構造を知らないまま業務DBを同期すると、次のどちらかを作ることになります。静かに数字がズレたダッシュボードか、毎回FINALを払う遅いクエリか。前者のほうが圧倒的に多いはずです。エラーが出ないので。
冒頭の3通りの分かれ方に、不思議なところは一つもありませんでした。ClickHouseは設計通りに動いていて、こちらが設計を知らなかっただけです。増えたのはマージ前だから、減ったのはパートが溜まってマージ条件を満たしたから、減らなかったのは判定式の比が閾値に届かないからです。
ただし調べていて一番こわかったのは、「そのうち直る」が保証ではないことのほうでした。しかも直りにくくなる方向が最悪で、開発環境の小さいデータでは勝手に直り、本番の大きいデータでは直らない。テスト環境で問題が出ないので、気づくのは本番に載せたあとです。
「しばらく待てば正しくなる」は、テーブルが小さかっただけかもしれません。
検証環境: ClickHouse 26.8.1 (macOS arm64 ネイティブビルド) および ClickHouse 26.5.1 (公式Dockerイメージ / Linux aarch64 / 10コア・メモリ7GB) の2環境で確認。データは numbers() による生成値。再現用のDDL・投入手順・計測クエリは同梱の repro.sql にまとめてあります。
参考: