はじめに:「正常」なのに遅い?

Cloudflareは毎日数百万件のClickHouseクエリを実行しています。課金システムから不正検出まで、中核パイプラインを担うこのOLAPデータベースで、ある日突然、日次集計ジョブが遅くなり始めました。問題は、すべての指標が正常だったことです。I/O、メモリ、スキャン行数、読み取りパート数 — 通常なら疑うような要素が一つも問題を明らかにしませんでした。

この記事は、CloudflareのエンジニアリングチームがClickHouse内部の深くに隠れたボトルネックを発見し、3つのパッチで解決するまでの全プロセスをまとめたものです。単なる「クエリチューニングのコツ」ではなく、大規模システムにおける予期せぬ相互作用がどのようにパフォーマンスを蝕むのかを示す生きた事例です。

本記事はCloudflareブログのClickHouseクエリプラン競合デバッグストーリーを基に再構成しました。

ClickHouse server rack with data flow diagram overlay showing query planning bottleneck Dev Environment Setup

背景: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))

重要な前提: すべてのクエリは特定の名前空間でフィルタリングされるため、単一クエリが読み取るパート数は変わらない。したがってパフォーマンスに影響はない。

この前提は誤りでした。

Flame graph visualization of ClickHouse mutex contention during query planning Developer Related Image

本論:ボトルネック追跡の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)を待つために費やされていました。

クエリプランナーの実行プロセス:

  1. このミューテックスに**排他ロック(Exclusive Lock)**を取得
  2. テーブルの全パートリストを完全にコピー
  3. ロックを解放
  4. コピーしたリストから関連パートのみをフィルタリング

数万のパートと数百の同時クエリがある場合、すべてのスレッドが一列に並んでこのミューテックスを待っていたのです。

第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)のような複数条件に対しては効果が限定的です。より汎用的なアプローチ(クエリ条件キャッシュの拡張など)を引き続き研究中です。

Cloud engineers debugging ClickHouse performance issues on multiple monitors Algorithm Concept Visual

結論:「正しい」決定も前提を疑え

この事例から得られる教訓は明らかです。「当然パフォーマンスに影響しないだろう」という前提が最も危険であるということ。パーティショニングキーの変更は論理的には完璧に見えましたが、ClickHouse内部のロック機構と組み合わさり、予期せぬボトルネックを生み出しました。

日本開発環境での適用コンテキスト

国内のSI/金融業界でもOLAPデータベース(ClickHouse、Druid、Pinotなど)の導入が増えています。特にマルチテナント(Multi-tenant) アーキテクチャでは、パーティショニング戦略がパフォーマンスに直結します。

  • テーブル設計段階から同時実行性(Concurrency)とパート増加率を考慮すべきです。
  • 「パート数が増えてもクエリあたりの読み取りパート数は同じ」 という前提は、ロック競合とコピーコストを見落とします。
  • パフォーマンス監視時はCPU使用率だけでなく、ロック待ち時間(Lock Waits)やスレッドブロッキング状態も併せて追跡すべきです。

本技術の限界と注意点

  • Cloudflareのパッチは特定のClickHouseバージョンとワークロードに最適化されたものです。すべての環境で同じ効果を保証するものではありません。
  • パーティション数が極端に増加すると、ZooKeeperのメタデータにも負荷がかかります。(Cloudflareはこの問題を「100GB ZooKeeperクラスター」の話として別途取り上げています。)
  • 名前空間ベースのパーティショニングはフィルタリング条件が明確な場合に効果的です。多様な条件でクエリする環境では別のアプローチが必要になる可能性があります。

次のステップ学習方向

  1. ClickHouse公式ドキュメントパーティショニングとパート管理を熟読してみてください。
  2. フレームグラフ分析ツール(例:pyroscopeasync-profiler)を実際の環境に適用する練習をしてみると良いでしょう。
  3. 大規模OLAPシステムにおけるロックフリー(lock-free)データ構造Copy-on-Writeパターンについて学習してみてください。

合わせて読みたい記事

本コンテンツは、信頼性の高い情報源をもとにAIツールを活用して作成され、編集者によるレビューを経て公開されています。専門家によるアドバイスの代替となるものではありません。