概要
Cloudflareではオープンソースの分析データベース ClickHouse を多用しています。既存の大規模テーブルのパーティションキーに列を追加して、テナントごとの保持(per-tenant retention)を可能にする設計変更を行いました。数週間後、請求関連のジョブが日次の厳しい締め切りに間に合わなくなり、I/O やメモリ、スキャン行数、読み取られるパーツ数など、通常の原因を確認しても異常はありませんでした。最終的に原因はクエリプランニングにおけるロック競合であることが判明しました。本稿はその調査と対策についての技術的な記録です。\
セットアップ:ペタバイト規模の分析基盤
- ClickHouseで数十クラスタにまたがり100ペタバイト以上のデータを運用。
- 2022年初めに社内向けに「Ready-Analytics」という仕組みを構築。多数のチームが新規テーブルを定義する代わりに単一の大規模テーブルへデータをストリームする。
- データは
namespace で識別され、共通スキーマ(例:20個のfloat、20個のstring、timestamp、indexID)を持つ。
- クエリ性能に重要なのはソート順で、
indexID は主キーの一部になっているため各 namespace のデータは期待されるクエリに最適な並びになる。
- 最終的な主キーは
(namespace, indexID, timestamp)。
- この仕組みは人気で、2024年12月までに2PiBを超え、毎秒数百万行の取り込みがある。
- ただし保持(retention)方式に致命的な欠点があった。\
問題点:全員に同じ保持期間
- 既存のテーブルは日単位でパーティション(
day)区切りにしており、31日より古いパーティションをドロップするジョブで保持を実現していた。
- しかし一律の31日保持は不都合があり、法的・契約上で長期保持が必要なチームと、数日だけで良いチームが混在しているため、用途によっては Ready-Analytics を使えない状況だった。
- 要件は「
namespace ごとの保持を可能にする」こと。
検討したアプローチ
- Table-per-Namespace:自然に解決するが、数千テーブルをオンデマンドで管理する自動化が必要。
- 新しいパーティションキー:
(day) から (namespace, day) へ変更。
後者を選択。これにより既存のパーティションベースの保持運用を継続しつつ、namespace ごとの粒度でパーティションを捨てられるようになる。パーツ数は増えると予想したが、「各クエリは特定の namespace でフィルタされるため、単一クエリが読むパーツ数は変わらない」という前提の下、性能影響はないと判断した。
- また
max-min fairness を用いるストレージ管理を導入し、ターゲットディスク利用率(例:90%)を共有しつつ運用。移行は2025年1月に開始し、Merge table 機能で新旧テーブルを併用して段階的に移行を進めた。\
ミステリー:請求処理が遅くなる
2025年3月下旬、請求チームのデイリー集計ジョブが徐々に遅くなり、締め切りが危ぶまれる状況に。I/O・メモリとも異常なし、個別クエリは以前と同程度のデータ/パーツを読んでいる。にもかかわらず全体が遅い。調査の結果、クラスタ全体の総パーツ数とクエリ実行時間に強い相関があることが判明した。つまり「余分なパーツを読んでいなくても、存在するだけで遅くなっている」。\
調査:フレームグラフでボトルネックを探索
trace_log(ClickHouseの組み込みトレーステーブル)を用いて leaf SELECT クエリに絞ったフレームグラフを作成。
- まずCPUサンプリング(アクティブスレッドのみ)を取ると、
filterPartsByPartition 関数に大量の時間が費やされており、サンプルの45%がここに集中していることが見えた。
- この結果を受けて、パーツのプルーニングに使うヒューリスティクスの評価順を変える小さなパッチを当て、約5%の改善を得たが根本解決には至らず。
- 次に「Real」トレース(全スレッドをサンプリング)に切り替えると状況が一変。クエリ時間の半分以上が、テーブルのパーツ一覧を保護する単一のミューテックス(
MergeTreeData)の取得待ちで消費されていた。
プランニング時、各スレッドは次を行っていた:
- このミューテックスを独占で取得する。
- テーブル内の全パーツリストを丸ごとコピーする。
- ロックを解放する。
- コピーしたリストをフィルタして実際に読むパーツを決める。
数万のパーツと数百の同時クエリがあるため、全スレッドがシリアル化されて順番待ちになっていたのが原因だった。\
対策:3つの最適化パッチ
この発見を踏まえ、ホットスポットを緩和するための一連の最適化を計画・実装し、最終的には upstream へも貢献しました。以下が主要な3つの最適化です。
最適化 1 — 共有ロックを使う
- 問題点:クエリプランナーはパーツリストを変更しないのに、排他ロックを取っていた。
- 修正:排他ロックの代わりに
std::shared_lock(共有ロック)を取得するように変更。
- 効果:すべてのクエリプランナーが同時にその臨界区間へ入れるようになり、ロック待ちが消えてクエリ時間が大幅に短縮。
最適化 2 — ベクタの丸ごとコピーを止める
- 状況:共有ロック導入後もパフォーマンスは完全復帰せず、次のフレームグラフで「巨大なパーツベクタのコピー」に時間がかかっていることが判明(コピーとフィルタで時間を消費)。
- 修正:コピーを遅延させ、読み取り専用の「共有コピー」キャッシュを用意。読み取り系(クエリプランニング)はこのキャッシュを参照する。パーツ集合を変更する操作(挿入など)があればキャッシュを再生成する。プランナーは実際に必要な部分だけをコピーする。
- 効果:さらに大きな性能改善を実現。
- 貢献:この変更は ClickHouse 側と小さな設計調整を行い、PR #85535 としてマージされ、ClickHouse version 25.11 以降で利用可能になった。
最適化 3 — パーツ探索に二分探索を使う
- 残存問題:それでもパーツ数増加に伴う性能低下は残っていた。プルーニングコードは依然としてパーツ全件を線形スキャンしていた。
- 修正方針:パーツのベクタはパーティションキーでソートされているため、最初のキーである
namespace を使って二分探索(binary search)で対象範囲を絞る。範囲を狭めた後、残りのパーツに対して従来の逐次判定を行う。
- 効果:2026年3月にこのパッチをデプロイしたところ、クエリ実行時間が約50%低下し、パーツ総数との相関が事実上断たれた。
- 制限:
namespace in (5,10) のような複雑な条件には一般化しにくく、より汎用的な解としては「クエリ条件キャッシュの拡張」によるパーツフィルタリングの最適化を検討中。
結果と教訓
- 単純な前提(「クエリは常に同じ数のパーツを読む」)の裏で、内部実装がスケールしない箇所を持っていると、存在するだけのデータ構造がパフォーマンスに悪影響を与えることがある。
- サンプリング方式(CPUサンプル vs 全スレッドサンプル)によって見えるボトルネックがまったく異なることがあるため、診断では両方を使うべき。
- 修正は3段階で効果を積み上げ、最終的には upstream にも貢献してコミュニティ全体の恩恵とした(PR #85535、
25.11 以降)。
以上が、Ready-Analytics のパーティション変更が露呈させた ClickHouse 内部の隠れたボトルネックと、それを解消するために行った調査・最適化の概要です。