# 読まないパーツが犯人？ Cloudflare が突き止めた ClickHouse の隠れボトルネック！

やっほー、しぃちゃんだよ！今日はね、データベースの奥の奥にひそんでた「見えないボトルネック」を追いかける、ワクワクのデバッグ探偵ものだよ。あやうく請求書が出せなくなりかけたっていうから、ちょっとドキドキだね。

## なにが発表されたの？

Cloudflare の Blog が、自社の請求(billing)パイプラインが突然遅くなった原因を突き止めた話を公開したの。犯人は、みんなも使ってる OLAP データベース ClickHouse の内部に隠れてたんだって。

Cloudflare は毎日、何百万回も ClickHouse に問い合わせて「どのユーザーにいくら請求するか」を計算してるの。このパイプラインは数億ドル規模の売上や不正検知を支えてるから、遅れると大問題。その日次集計ジョブが、あるマイグレーションのあとからどんどん遅くなっちゃったんだ。しかも I/O もメモリも、読んだ行数も読んだパーツ数も、いつもチェックする指標は全部きれい。原因がぜんぜん見えない、っていう怖いやつだよ。

## 今までどうだったの？

Cloudflare は 100 ペタバイト超のデータを数十クラスターの ClickHouse に持ってるんだって。社内チームがすぐ使えるように、2022 年初めに「Ready-Analytics」っていう仕組みを作ったの。新しいテーブルを設計しなくても、1 個の巨大なテーブルにデータを流し込むだけ。データセットは namespace で区別して、みんな共通スキーマ(float 20 個・string 20 個・timestamp・indexID)を使うんだ。主キーは (namespace, indexID, timestamp) という形。2024 年 12 月には 2PiB を超えて、毎秒数百万行が入ってくる人気ぶりだったよ。

でもひとつ弱点があってね。保持期間(retention)が「全部まとめて 31 日」だったの。ClickHouse に TTL 機能が無かった昔からの名残で、日(day)でパーティション分割して、31 日より古いパーティションをまるごと落とす方式。だから「何年も残したい」チームも「数日でいい」チームも同じルール。これを namespace ごとに変えられるようにしたかったんだ。

そこで選んだのが、パーティションキーを (day) から (namespace, day) に変える案。これなら既存の retention をそのまま使いつつ、namespace 単位でデータを落とせる。総パーツ数は増えるけど、「どのクエリも特定の namespace で絞り込むから、1 クエリが読むパーツ数は変わらないはず」と考えたの。だから性能は影響を受けないと信じてた。ここが、あとで効いてくる落とし穴だったんだ。移行は 2025 年 1 月に開始したよ。

## これで何が変わるの？

いちばん大事なのは、時間切れ寸前だった請求の危機が解消したこと。報告時に 3 万個だったパーツは 1 年後に 16 万個まで増えたのに、クエリ時間は安定したままになったんだ。

しかもうれしいのは、Cloudflare がこれらの修正を自分たちのフォークに閉じ込めず、upstream の ClickHouse に還元したこと。あとで出てくる最適化 1 と 2 は PR #85535 として取り込まれて、ClickHouse 25.11 から誰でも使えるようになったの。つまり ClickHouse を使ってるチームなら、パーツが多いテーブルの計画フェーズが速くなる恩恵をそのまま受け取れるってこと。

反対に、ちょっと背筋が伸びる教訓もあるよ。「1 クエリが読むパーツ数は変わらないから性能も変わらない」という一見もっともらしい仮定が、総パーツ数の増加を通じて足をすくったの。よく練った変更でも、思い込みひとつで足元をすくわれる、っていう良い例だね。

## 深く潜ってみよう

移行から 2 か月後、2025 年 3 月末に請求チームから「日次集計が遅い」と報告が。しかも進むほど遅くなる。個別クエリの指標は「前より多くのデータやパーツを読んでる」わけじゃない。数日かけて、ようやくクエリ時間とクラスター全体の総パーツ数をプロットしたら…相関がくっきり。読んでもいない余分なパーツが、存在するだけで足を引っぱってたの。

原因を掘るために使ったのが ClickHouse 内蔵の `trace_log`。実行中のサーバーからトレースを記録してくれるテーブルで、ユーザーやクエリ ID で絞り込めるんだ。まず CPU ベースのフレームグラフを取ると、なんとクエリ時間の 45% が `filterPartsByPartition` っていう 1 個の関数、つまり「どのパーツを読むか決める query planning フェーズ」に消えてた。最初はここのヒューリスティックの評価順を並べ替える小さなパッチを当てて、5% だけ改善。惜しいけど本丸じゃなかったの。

そこで「CPU」トレース(動いてるスレッドだけ)から「Real」トレース(待機中も含む全スレッド)に切り替えたら…真実が見えたよ。本当の犯人は CPU じゃなくてロック競合だったんだ。

> More than half of our query duration was spent waiting to acquire a single mutex (MergeTreeData) that protects the table's list of parts.

クエリを計画するために、どのスレッドも(1)テーブルのパーツ一覧を守る mutex `MergeTreeData` の排他ロックを取り、(2)全パーツのリストをまるごとコピーし、(3)ロックを解放し、(4)必要なパーツだけに絞り込む、っていう手順を踏んでたの。パーツが数万個、同時クエリが数百本もあると、みんな 1 列に並んで順番待ち。これが真のボトルネックだったんだ。

Cloudflare はここに 3 連発のパッチを当てたよ。

- 最適化 1・共有ロック: プランナはパーツ一覧を読むだけで書き換えないのに、排他ロックを使ってた。そこを `std::shared_lock` に変えて、全プランナが同時に入れるように。ロック競合は一気に消滅したの。
- 最適化 2・コピーをやめる: 共有ロックにしても、数万要素のベクターを毎秒何百回もコピーするコストが残ってた。そこでパーツ一覧の「共有コピー」を 1 つ持って、読み取り専用の処理(計画など)はそこを読むだけに。パーツ集合を変える処理(挿入など)のときだけキャッシュを作り直す。プランナは本当に必要な「絞り込み後」のリストしかコピーしなくなったの。この 1 と 2 が PR #85535 として ClickHouse 25.11 に入ったよ。
- 最適化 3・二分探索: パーツ数が増えるとまたじわじわ遅くなる。フィルタは全パーツを線形にスキャンしてたんだけど、パーツ一覧はパーティションキー順にソート済み。しかもその先頭列は、ほとんどのクエリが絞り込む namespace(テナントを表す列)。そこで namespace で二分探索して、見なくていいパーツを一気に除外。残った狭い範囲だけ従来どおり 1 個ずつ条件チェック。2026 年 3 月にこれを入れたら、クエリ時間は 50% 減。しかもパーツ数との相関がついに切れたんだって。

ただし二分探索は `namespace in (5,10)` みたいな条件だと効きにくくて、そこは query condition cache をパーツフィルタにも広げる、みたいなもっと汎用な方法を検討中なんだって。あとパーツ増加は、ClickHouse のメタデータを管理する ZooKeeper にも負担で、「100 ギガバイトの ZooKeeper クラスター」の話はまた別の機会に、って予告してたよ。

## まとめ

- Cloudflare の請求パイプラインが遅くなった真犯人は、ClickHouse の query planning フェーズでの mutex 競合。読まないパーツも「存在するだけ」で待ち行列を作ってたの。
- (day) から (namespace, day) への分割変更で総パーツ数が激増。「1 クエリが読むパーツ数は変わらないから性能も変わらない」という仮定が落とし穴だった。
- 決め手は「Real」トレースのフレームグラフ。CPU だけ見てたら、待ち時間はずっと隠れたままだったの。
- 共有ロック・コピー回避・二分探索の 3 連発で解決。前の 2 つは PR #85535 として ClickHouse 25.11 に還元済みで、みんなも恩恵を受けられるよ。報告時 3 万個だったパーツは 1 年で 16 万個まで増えたのに、クエリ時間は安定したまま。
- ClickHouse を大規模に運用してる人、パーティション設計で悩んでる人、フレームグラフでボトルネックを狩るのが好きな人に刺さる 1 本だよ！
