はじめに:「正常」なのに遅い?
Cloudflareは毎日数百万件のClickHouseクエリを実行しています。課金システムから不正検出まで、中核パイプラインを担うこのOLAPデータベースで、ある日突然、日次集計ジョブが遅くなり始めました。問題は、すべての指標が正常だったことです。I/O、メモリ、スキャン行数、読み取りパート数 — 通常なら疑うような要素が一つも問題を明らかにしませんでした。
この記事は、CloudflareのエンジニアリングチームがClickHouse内部の深くに隠れたボトルネックを発見し、3つのパッチで解決するまでの全プロセスをまとめたものです。単なる「クエリチューニングのコツ」ではなく、大規模システムにおける予期せぬ相互作用がどのようにパフォーマンスを蝕むのかを示す生きた事例です。
本記事はCloudflareブログのClickHouseクエリプラン競合デバッグストーリーを基に再構成しました。

背景:Ready-Analyticsシステムとパーティショニングの落とし穴
Cloudflareは100PBを超えるデータを数十のClickHouseクラスターに保存しています。内部チームのオンボーディングを簡素化するため、2022年初頭に「Ready-Analytics」というシステムを構築しました。核となるアイデアは、単一の大規模テーブルに全チームがデータをストリーミングするというものでした。
問題の発端:「画一的な」保持ポリシー
Ready-Analyticsテーブルはday単位でパーティショニングされており、保持ジョブは31日経過したパーティションを単純に削除していました。しかし、チームごとにデータ保持要件は異なります。あるチームは法的・契約上の理由でデータを数年保存する必要があり、別のチームは数日で十分でした。結局、この「one-size-fits-all」ポリシーのために多くのチームがシステムを利用できませんでした。
解決策:パーティショニングキーの変更
チームはパーティショニングキーを(day)から(namespace, day)に変更することを決定しました。これにより各名前空間ごとにパーティションを管理でき、既存の保持システムもそのまま活用できます。
-- 変更前:日単位パーティショニング
PARTITION BY toYYYYMMDD(timestamp)
-- 変更後:名前空間+日単位パーティショニング
PARTITION BY (namespace, toYYYYMMDD(timestamp))
重要な前提: すべてのクエリは特定の名前空間でフィルタリングされるため、単一クエリが読み取るパート数は変わらない。したがってパフォーマンスに影響はない。
この前提は誤りでした。

本論:ボトルネック追跡の3幕
第1幕:「CPU」フレームグラフの落とし穴
2025年3月、課金チームが日次集計ジョブが徐々に遅くなっていると報告しました。チームはClickHouseのtrace_logを利用してフレームグラフを生成しました。
-- trace_logを活用したフレームグラフデータ抽出(例)
SELECT
thread_name,
symbol,
count() AS samples
FROM system.trace_log
WHERE
query_id IN (SELECT query_id FROM system.query_log WHERE ...)
AND trace_type = 'CPU'
GROUP BY thread_name, symbol
ORDER BY samples DESC
最初のCPUベースのフレームグラフは、filterPartsByPartition関数で45%のCPU時間が消費されていることを示しました。チームはこの関数のヒューリスティック評価順序を変更する小さなパッチを適用しましたが、わずか5%の改善にとどまりました。
第2幕:「Real」フレームグラフの衝撃
チームは「Real」トレース(待機中のスレッドもサンプリング)に切り替えました。結果は衝撃的でした。
クエリ時間の半分以上が単一のミューテックス(MergeTreeData)を待つために費やされていました。
クエリプランナーの実行プロセス:
- このミューテックスに**排他ロック(Exclusive Lock)**を取得
- テーブルの全パートリストを完全にコピー
- ロックを解放
- コピーしたリストから関連パートのみをフィルタリング
数万のパートと数百の同時クエリがある場合、すべてのスレッドが一列に並んでこのミューテックスを待っていたのです。
第3幕:3つのパッチ
パッチ1:共有ロック(Shared Lock)の使用
クエリプランナーはパートリストを読み取り専用で参照します。排他ロックは不要でした。
// 変更前:排他ロック
std::lock_guard<std::mutex> lock(mutex);
// 変更後:共有ロック (std::shared_lock)
std::shared_lock<std::shared_mutex> lock(mutex);
結果: ロック競合が即座に解消され、クエリ時間が大幅に削減されました。
パッチ2:ベクターコピーの遅延
新しいフレームグラフは、今度はパートベクターをコピーする時間がボトルネックであることを示しました。数万要素のベクターを毎秒数百回コピーするのは決して軽い処理ではありません。
// 変更前:毎回全体をコピー
auto parts = table->getParts(); // ロック取得後に全体をコピー
// 変更後:共有コピーを活用 (Copy-on-Writeパターン)
// 読み取り専用処理はキャッシュされた共有コピーを参照
// パート変更時にのみキャッシュを更新
結果: さらなるパフォーマンス向上。このパッチはClickHouseコミュニティにPR #85535としてコントリビュートされ、25.11バージョンから利用可能になりました。
パッチ3:二分探索(Binary Search)の導入
時間の経過とともにパート数が再び増加し、パフォーマンス低下が再現しました。しかし今回は、filterPartsByPartition関数における**線形スキャン(Linear Scan)**がボトルネックでした。
パートリストはパーティショニングキー(最初のカラムがnamespace)でソートされています。ほとんどのクエリは特定の名前空間でフィルタリングするため、二分探索を活用できました。
# 概念例:二分探索によるパートフィルタリング
# 実際のClickHouseコードはC++で実装
def filter_parts_by_namespace(parts, target_namespace):
# 1. 二分探索でtarget_namespaceの範囲を特定
left = binary_search_left(parts, target_namespace)
right = binary_search_right(parts, target_namespace)
# 2. 該当範囲内でのみ追加条件を検査
candidate_parts = parts[left:right]
return [p for p in candidate_parts if meets_other_conditions(p)]
結果: クエリ時間が50%削減され、パート数とクエリ時間の相関が完全に消失しました。
注意点: この最適化は
namespace IN (5,10)のような複数条件に対しては効果が限定的です。より汎用的なアプローチ(クエリ条件キャッシュの拡張など)を引き続き研究中です。

結論:「正しい」決定も前提を疑え
この事例から得られる教訓は明らかです。「当然パフォーマンスに影響しないだろう」という前提が最も危険であるということ。パーティショニングキーの変更は論理的には完璧に見えましたが、ClickHouse内部のロック機構と組み合わさり、予期せぬボトルネックを生み出しました。
日本開発環境での適用コンテキスト
国内のSI/金融業界でもOLAPデータベース(ClickHouse、Druid、Pinotなど)の導入が増えています。特にマルチテナント(Multi-tenant) アーキテクチャでは、パーティショニング戦略がパフォーマンスに直結します。
- テーブル設計段階から同時実行性(Concurrency)とパート増加率を考慮すべきです。
- 「パート数が増えてもクエリあたりの読み取りパート数は同じ」 という前提は、ロック競合とコピーコストを見落とします。
- パフォーマンス監視時はCPU使用率だけでなく、ロック待ち時間(Lock Waits)やスレッドブロッキング状態も併せて追跡すべきです。
本技術の限界と注意点
- Cloudflareのパッチは特定のClickHouseバージョンとワークロードに最適化されたものです。すべての環境で同じ効果を保証するものではありません。
- パーティション数が極端に増加すると、ZooKeeperのメタデータにも負荷がかかります。(Cloudflareはこの問題を「100GB ZooKeeperクラスター」の話として別途取り上げています。)
- 名前空間ベースのパーティショニングはフィルタリング条件が明確な場合に効果的です。多様な条件でクエリする環境では別のアプローチが必要になる可能性があります。
次のステップ学習方向
- ClickHouse公式ドキュメントのパーティショニングとパート管理を熟読してみてください。
- フレームグラフ分析ツール(例:
pyroscope、async-profiler)を実際の環境に適用する練習をしてみると良いでしょう。 - 大規模OLAPシステムにおけるロックフリー(lock-free)データ構造とCopy-on-Writeパターンについて学習してみてください。