データエンジニアリング — BigQuery クエリ最適化

2026-04-22 C: データエンジニアリング ★★★☆☆ BigQuery パーティション・クラスタリング MOps キャンペーンCVR集計

概要

📦

パーティションプルーニング

パーティション列に関数をかけずに生の比較を使うことで、必要な日付パーティションだけスキャンする。

🔍

クラスタリング活用

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
campaigns1万行程度(マスター)なしなし
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

✗ Before(230GB スキャン) events DATE(occurred_at)=... campaigns JOIN 全キャンペーン結合 orders LEFT JOIN BETWEEN start〜end(全期間) COUNT(DISTINCT) 高コスト集計 × 2 230 GB $0.26/日 ✓ After(4GB スキャン) yesterday_impressions TIMESTAMP比較 + event_type絞り込み yesterday_orders 前日分のみ 事前集計済み active_campaigns 期間フィルタ済み 小テーブル先絞り APPROX_COUNT_DISTINCT HyperLogLog++ 誤差±1〜2% 4 GB $0.004/日 ▲98% 削減 コスト比較(スキャン量) Before 230GB After 4GB(1.7%)

模範解答

-- 改善後クエリ
-- 期待スキャン量: 約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をマテリアライズしないため実行コストは同じだが、読みやすさが大幅向上する。

改善効果試算

指標BeforeAfter改善率
スキャン量230 GB4 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・経営層への説明材料になる。

自己評価

自分の回答

気づき・メモ