Cloud Spannerの分散集約処理:大規模データに対するGROUP BYを極限まで速くする設計と実装
こんにちは。テクニカルリードの私だ。
今日のコードレビューで、あるジュニアエンジニアが書いたクエリを見て少し気になった。数千万行のトランザクションテーブルに対して、平然と次のような集約クエリを投げていたのだ。
— ⚠️ 典型的なアンチパターン:全件を単一ノードに集約しようとする愚行
SELECT
merchant_id,
SUM(amount)
FROM transactions
GROUP BY ` merchant_id;
彼らは「Cloud Spannerはリレーショナルデータベースだから、普通に書けば勝手に最適化してくれる」と思っている。だが、それは半分正しくて半分危険だ。Spannerは分散データベースであり、物理的なデータは数千、数万のノード(スプリット)に水平分散されている。このアーキテクチャの裏側を理解せずにお気楽にクエリを書くと、「ホットスポット」「ネットワーク帯域の飽和」「CoordinatorノードのCPU枯渇」という、分散DBの三大悪夢を踏み抜くことになる。
今回は、Cloud Spannerが内部でどのように「分散集約(Distributed Aggregation)」を行っているのか、そのアルゴリズムの核心と、我々エンジニアが実務でどう設計に落とし込むべきかを叩き込む。
—
1. Cloud Spannerの分散集約アルゴリズム:2段階プレアグリゲーションの真実
まず、Spannerのクエリエンジンが裏側で何をやっているのかを解剖しよう。
Spannerの分散集約は、単にデータを一箇所にかき集めて計算するようなナイーブなものではない。基本思想は「MapReduce的な思想の、極限までレイテンシを削ぎ落としたインメモリ実装」だ。
Spannerのクエリプランナーは、集約クエリ(`GROUP BY` や `SUM` など)を検知すると、多くの場合「2段階集約(Two-phase Aggregation)」の実行計画を組み立てる。
[クライアント]
▲ (最終集約結果のみを受信:通信量最小)
│
[Root / Coordinator ノード] (最終的な統合: SUMの合算、AVGの再計算など)
▲
│ (部分集約された軽量な中間データ)
┌─────┴─────────────────────────┐
│ スプリットA スプリットB スプリットC
│ (部分集約) (部分集約) (部分集約)
└───────────────────────────────┘
フェーズ1:部分集約(Partial Aggregation / Map側)
データが物理的に保存されている各スプリット(またはそれを担当するリーダーノード)上で、ローカルに保持するデータに対してまず `GROUP BY` と集約関数の部分計算を行う。
- `SUM(amount)` なら、スプリット内での部分和を計算。
- `COUNT()` なら、スプリット内での部分カウントを計算。
- 効果: ここでデータ量が劇的に圧縮される(例えば、数百万行のレコードが、ユニークな `merchant_id` の数だけの軽量な中間行に縮小される)。
フェーズ2:最終集約(Final Aggregation / Reduce側)
各スプリットから送られてきた部分集約結果を、クエリのコーディネーター役となるノード(Root)が受け取り、最終的な統合を行う。
- `SUM` は部分和同士を足し合わせる。
- `COUNT` も同様に足し合わせる。
- `AVG` のような非線形な集約関数であっても、各ノードから `SUM` と `COUNT` のペアを送らせることで、正確な全体平均をコーディネーター側で算出する。
この仕組みのおかげで、「数テラバイトの生データそのものをネットワーク経由でシャッフルする」という最悪の事態が回避される。ネットワークを流れるのは、常に「集約済み・圧縮済みの中間データ」なのだ。
—
2. 実務で直面する罠:なぜあなたの集約クエリは遅いのか?
この美しい分散集約メカニズムも、設計を誤ると完全に機能不全に陥る。実務でよくある「やってはいけない設計」を挙げておこう。
罠1:インターリーブ(Interleave)構造を無視した主キー設計
もし `transactions` テーブルの主キーが単に `transaction_id` だけだったらどうなるか。`merchant_id` ごとの集約を行う際、同一マーチャントのデータが全スプリットにバラバラに散らばることになる。結果として、フェーズ1での部分集約の効率が落ち、中間データのネットワーク転送量が増大する。
【解法】
テナントやマーチャント単位で集約を頻繁に行うなら、インターリーブ構造またはプレフィックス主キーの設計が必須だ。
— 模範的なテーブル定義(パーティション化の最適化)
CREATE TABLE merchants (
merchant_id INT64,
— その他のカラム
) PRIMARY KEY(merchant_id);
CREATE TABLE transactions (
merchant_id INT64,
transaction_id INT64,
amount INT64,
created_at TIMESTAMP,
) PRIMARY KEY(merchant_id, transaction_id),
INTERLEAVE IN PARENT merchants ON DELETE CASCADE;
この設計であれば、同一 `merchant_id` のデータは物理的に同一のスプリット(または近傍)にコロケーションされやすくなり、部分集約の局所性が飛躍的に向上する。
罠2:高カーディナリティすぎる GROUP BY
`GROUP BY` の対象カラムのカーディナリティ(ユニーク値の数)が、テーブルの総行数とほぼ同等である場合(例:ミリ秒単位のタイムスタンプや、UUIDでグループ化するなど)、フェーズ1でのデータ圧縮が全く効かない。
結果として、全スプリットから膨大な中間データがコーディネーターノードに送られ、Memory Limit Exceeded や CPUスパイイク を引き起こす。
—
3. 実践:パフォーマンスを極限まで引き出すクエリ設計パターン
では、数億行規模のデータから安全かつ高速に集約を行うためのプラクティスを示そう。
パターンA:コストベースオプティマイザ(CBO)を味方につける書き方
SpannerのCBOに正しい統計情報を与え、確実に2段階集約を選択させるためには、無駄な関数をラップさせないことだ。
— 良い例:オプティマイザが容易に部分集約を適用できるシンプルさ
SELECT
merchant_id,
SUM(amount) AS total_amount,
COUNT(1) AS txn_count
FROM transactions
WHERE created_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY merchant_id;
ここで、もし `WHERE` 句の条件がインデックスのプレフィックスと一致していれば、スキャン自体が高速化され、その後の分散集約フェーズへ極めてクリーンに移行する。
パターンB:マテリアライズドビュー(MV)による事前集約
リアルタイム性がそこまでシビアではなく(数秒〜数分の遅延が許容される)、ダッシュボード等で常に重い集約結果を返す必要がある場合、アドホックなクエリに頼るべきではない。マテリアライズドビューを使うのが、Spannerにおける最高峰の解だ。
— マテリアライズドビューの定義
CREATE MATERIALIZED VIEW MerchantDailySales
AS
SELECT
merchant_id,
DATE(created_at) AS sale_date,
SUM(amount) AS daily_sum,
COUNT() AS daily_count
FROM transactions
GROUP BY merchant_id, sale_date;
なぜこれが最強なのか?
分散集約処理そのものを「バックグラウンドのデータ更新時(あるいは非同期のビューメンテナンス時)」に事前完了させておくアプローチだ。クライアントからクエリが飛んできた時、Spannerは分散集約の計算をリアルタイムで行う必要すらなく、単に事前計算済みのビューからデータを一撃で引いてくるだけで済む。レイテンシはミリ秒単位に落ちる。
—
4. チーフアーキテクトからの最終インスペクション
Cloud Spannerにおける分散集約は、データベースエンジンが裏側でどれだけ頑張ってくれても、「元データの物理配置(主キー設計)」と「クエリの絞り込み(WHERE句の効率)」が間違っていれば破綻する。
設計レビューにおいて、以下のチェックリストをクリアしているか確認してほしい。
1. GROUP BY の対象カラムは、物理データ配置(主キー・インターリーブ)と調和しているか?
2. 集約前のフィルタリング(WHERE)によって、スキャン対象データが十分に削ぎ落とされているか?
3. アドホックな集約クエリの乱用になっていないか?高頻度で実行される重い集約はマテリアライズドビューにオフロードされているか?
分散データベースの本質は「隠蔽」ではなく「協調」だ。Spannerの分散集約アルゴリズムの挙動を脳内にインストールし、エンジンが最も効率よく並列計算できる舞台を整えてやるのが、我々シニアエンジニアの仕事である。
次のコードレビューでは、無駄な全件シャッフルを引き起こすクエリがないか、厳しく見させてもらう。健闘を祈る。
コメント