概要
パーティションプルーニング
パーティション列に関数をかけずに生の比較を使うことで、必要な日付パーティションだけスキャンする。
クラスタリング活用
WHERE句でクラスタ列を先に絞ることで、スキャンするブロックを大幅に削減できる。
APPROX_COUNT_DISTINCT
KPIレポートでは誤差±1〜2%のHyperLogLog++アルゴリズムで十分。COUNT(DISTINCT)より大幅に高速。
SAFE_DIVIDE
ユーザー0件での ZeroDivisionError を防ぐ。BigQuery 組み込み関数でbが0のときNULLを返す。
問題
あなたはECサイトのMOpsチームのデータエンジニアとして、以下のBigQueryクエリを改善することになった。 このクエリは毎朝6時にArgo Workflowsで実行され、前日の販促キャンペーンごとの購入転換率(CVR)と売上を集計するものである。
テーブル情報
| テーブル | 規模 | パーティション | クラスタリング |
|---|---|---|---|
events | 日次 1億行 | occurred_at(DATEパーティション) | event_type, campaign_id |
campaigns | 1万行程度(マスター) | なし | なし |
orders | 日次 500万行 | created_at(DATEパーティション) | なし |
制約・前提条件
- BigQuery Standard SQL (GoogleSQL) を使用
- 対象は「前日1日分」のデータのみ
- CTEを使ってステップを分離すること
- コスト削減(スキャン量の削減)が最優先
期待する回答形式: 問題点リスト(3つ以上)+ 改善後SQL
現状クエリ (Bad)
このクエリには 4つの重大な問題点 があります。見つけてみてください。
現状: 実行時間 約45秒 / スキャン量 230GB
-- 現状のクエリ(Bad)
-- 実行時間: 約45秒 / スキャン量: 230GB
SELECT
c.campaign_id,
c.campaign_name,
c.start_date,
c.end_date,
COUNT(DISTINCT e.user_id) AS reached_users,
COUNT(DISTINCT o.user_id) AS converted_users,
ROUND(COUNT(DISTINCT o.user_id) / COUNT(DISTINCT e.user_id), 4) AS cvr,
SUM(o.amount) AS revenue
FROM `project.raw.events` e
JOIN `project.raw.campaigns` c
ON e.campaign_id = c.campaign_id
LEFT JOIN `project.raw.orders` o
ON e.user_id = o.user_id
AND o.created_at BETWEEN c.start_date AND c.end_date
WHERE e.event_type = 'impression'
AND DATE(e.occurred_at) = DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY)
GROUP BY 1, 2, 3, 4
ORDER BY revenue DESC
ヒント(段階的開示)
ヒント1 — 方向性
BigQuery のコスト削減で最重要なのは「スキャン量の削減」。
パーティション・クラスタリングが活かされているかどうかを確認しよう。
ヒント2 — アプローチ(3カテゴリ整理)
- パーティション
DATE(e.occurred_at) = ...はパーティションを使えない(フィールドに関数をかけると pruning が効かない) - JOIN範囲
ordersテーブルへの JOIN 範囲が広い(BETWEEN c.start_date AND c.end_date)→ 前日分のみに絞れるか? - COUNT(DISTINCT) 高コスト →
APPROX_COUNT_DISTINCTの使用を検討
ヒント3 — CTE骨格
-- Step 1: 前日のイベントだけフィルタ(パーティションを活かす)
WITH yesterday_impressions AS (
SELECT campaign_id, user_id
FROM `project.raw.events`
WHERE occurred_at >= TIMESTAMP(DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
AND occurred_at < TIMESTAMP(CURRENT_DATE()) -- パーティションプルーニング
AND event_type = 'impression' -- クラスタリング活用
),
-- Step 2: 前日の注文を絞る
yesterday_orders AS (
SELECT user_id, SUM(amount) AS amount
FROM `project.raw.orders`
WHERE created_at >= TIMESTAMP(DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
AND created_at < TIMESTAMP(CURRENT_DATE())
GROUP BY user_id
),
...
問題点分析
| # | 問題点 | 分類 | 影響 | 改善方法 |
|---|---|---|---|---|
| 1 | DATE(e.occurred_at) = ... でパーティション列に関数をかけている |
パーティション無効 | events テーブルを全スキャン(230GB) | occurred_at >= TIMESTAMP(...) AND occurred_at < TIMESTAMP(...) に変更 |
| 2 | orders の JOIN 範囲が BETWEEN c.start_date AND c.end_date(キャンペーン全期間) |
JOIN範囲過大 | orders テーブルを大量スキャン | 前日分のみに絞った CTE (yesterday_orders) を先に作る |
| 3 | COUNT(DISTINCT user_id) を2回使用 |
高コスト集計 | メモリ集約が重く実行時間・コストが高い | APPROX_COUNT_DISTINCT(誤差±1〜2%)に変更 |
| 4 | COUNT(...) / COUNT(...) で0除算リスク |
ゼロ除算 | ユーザー0件のキャンペーンでエラー | SAFE_DIVIDE(a, b) に変更 |
最適化フロー図 — Before vs After
模範解答
-- 改善後クエリ
-- 期待スキャン量: 約4GB(▲98%削減)
-- 期待実行時間: 約3秒
-- Step 1: 前日の impression イベントを取得(パーティション + クラスタリング活用)
WITH yesterday_impressions AS (
SELECT
campaign_id,
user_id
FROM `project.raw.events`
WHERE occurred_at >= TIMESTAMP(DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
AND occurred_at < TIMESTAMP(CURRENT_DATE()) -- パーティションプルーニング
AND event_type = 'impression' -- クラスタリング活用
),
-- Step 2: 前日の注文をユーザー単位で集計(パーティションプルーニング)
yesterday_orders AS (
SELECT
user_id,
SUM(amount) AS total_amount
FROM `project.raw.orders`
WHERE created_at >= TIMESTAMP(DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
AND created_at < TIMESTAMP(CURRENT_DATE())
GROUP BY user_id
),
-- Step 3: キャンペーンマスタと結合(小テーブルを先にフィルタ)
active_campaigns AS (
SELECT campaign_id, campaign_name, start_date, end_date
FROM `project.raw.campaigns`
WHERE end_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY)
),
-- Step 4: impression × campaign の集計
impression_agg AS (
SELECT
i.campaign_id,
APPROX_COUNT_DISTINCT(i.user_id) AS reached_users
FROM yesterday_impressions i
GROUP BY i.campaign_id
),
-- Step 5: 転換(impression → order)の集計
conversion_agg AS (
SELECT
i.campaign_id,
APPROX_COUNT_DISTINCT(i.user_id) AS converted_users,
SUM(o.total_amount) AS revenue
FROM yesterday_impressions i
INNER JOIN yesterday_orders o USING (user_id)
GROUP BY i.campaign_id
)
-- 最終集計
SELECT
c.campaign_id,
c.campaign_name,
c.start_date,
c.end_date,
imp.reached_users,
COALESCE(conv.converted_users, 0) AS converted_users,
ROUND(
SAFE_DIVIDE(COALESCE(conv.converted_users, 0), imp.reached_users),
4
) AS cvr,
COALESCE(conv.revenue, 0) AS revenue
FROM impression_agg imp
JOIN active_campaigns c USING (campaign_id)
LEFT JOIN conversion_agg conv USING (campaign_id)
ORDER BY revenue DESC
ポイント解説
1
パーティションプルーニングの正しい書き方
✗ Before(プルーニング無効)
WHERE DATE(e.occurred_at)
= DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY)
-- 関数をかけるとパーティション列が
-- 評価できず全スキャンになる
✓ After(プルーニング有効)
WHERE occurred_at >= TIMESTAMP(DATE_SUB(...))
AND occurred_at < TIMESTAMP(CURRENT_DATE())
-- 生のTIMESTAMP比較でプルーニングが機能
2
クラスタリングの活用
events テーブルは event_type, campaign_id でクラスタリング済み。
WHERE句で event_type = 'impression' を先に書くとクラスタリングが効き、スキャンブロックを削減できる。
3
COUNT(DISTINCT) → APPROX_COUNT_DISTINCT
COUNT(DISTINCT) はメモリ集約が重く、大テーブルで実行時間・コストが高い。
APPROX_COUNT_DISTINCT はHyperLogLog++アルゴリズムで誤差±1〜2%以内。KPIレポートなら十分。
厳密性が必要な経理・課金系には使わないこと。
4
SAFE_DIVIDE で0除算を防ぐ
COUNT(...) / COUNT(...) はユーザー0件でZeroDivisionErrorが起きる。
SAFE_DIVIDE(a, b) はbが0のときNULLを返す(BigQuery組み込み関数)。
5
CTEで処理ステップを分離
各CTEが何をしているか名前で自己説明している → レビュアビリティが高い。 BigQueryはCTEをマテリアライズしないため実行コストは同じだが、読みやすさが大幅向上する。
各CTEが何をしているか名前で自己説明している → レビュアビリティが高い。 BigQueryはCTEをマテリアライズしないため実行コストは同じだが、読みやすさが大幅向上する。
改善効果試算
| 指標 | Before | After | 改善率 |
|---|---|---|---|
| スキャン量 | 230 GB | 4 GB | ▲98% |
| 実行時間 | 約45秒 | 約3秒 | ▲93% |
| 日次コスト | $0.26/日 | $0.004/日 | ▲98% |
| 月次節約 | — | — | 約$7.8/月 |
実務への応用
MOpsチームでよく起きるシナリオ
-
キャンペーン施策の翌朝レポートが遅い:
本問題と同じパターン。
DATE(timestamp_col) = CURRENT_DATE()を書いてしまうと 全スキャンで月次コストが数万円膨らむ。 - コスト試算込みで提案: スキャン: 230GB → 4GB($1.15/TB × 0.226TB = $0.26/日 → $0.004/日)。 チームに展開するときは「コスト試算込みで」提案すると刺さりやすい。
-
APPROX使用の判断基準:
CVR・リーチ数などのKPIレポートはAPPROX可。
請求・返金・課金系は必ず正確な
COUNT(DISTINCT)を使うこと。
次のステップ
発展問題:
PARTITION BY date + CLUSTER BY user_id のテーブル設計をゼロから考える(パーティション粒度の選択・クラスタ列の優先順位)
- 参考: BigQuery documentation「Partition pruning」「Clustered table best practices」
- 参考: BigQuery Pricing($6.25/TB オンデマンドスキャン)
今日のまとめ
BigQueryコスト削減の最重要2原則は「パーティション列に関数をかけない(生の比較を使う)」と
「COUNT(DISTINCT)をAPPROX_COUNT_DISTINCTに置き換える」。
CTEで処理を分離するとレビュアビリティも上がる。
改善効果の数値(スキャン量・コスト削減額)をセットで記録しておくと、PM・経営層への説明材料になる。
改善効果の数値(スキャン量・コスト削減額)をセットで記録しておくと、PM・経営層への説明材料になる。