概要
COUNTIF による1スキャン集計
3つのCTEが同じテーブルを参照 = 3回スキャン。COUNTIF(event_type='impression')で1スキャンに統合。
dbt incremental + insert_overwrite
毎日90日分フルリビルド → 直近3日分のみスキャン。スキャン量を1/30以下に削減。
COALESCE による NULL 明示
SUM(revenue)はrevenuがNULLだとSUM全体がNULLになる。COALESCE(revenue, 0.0)で明示的にゼロ扱い。
コスト試算の実務判断
3TB × $6.25 × 30日 = $562.5/月 → 改善後: 99GB × $6.25 × 30日 = $18.6/月(▲$544/月削減)
問題
以下のBigQueryテーブルがあり、日次でキャンペーン効果分析レポートをdbtモデルとして作成しています。
テーブル仕様: raw.campaign_events
| カラム | 型 | 備考 |
|---|---|---|
event_id | STRING | イベントID |
event_date | DATE | パーティション列 |
campaign_id | STRING | クラスタリング列① |
user_id | STRING | クラスタリング列② |
event_type | STRING | 'impression' | 'click' | 'conversion' |
revenue | FLOAT64 | conversion のみ入る、それ以外 NULL |
制約
- テーブルサイズ: 90日で約 3TB(1日 33GB)
- dbt Core 1.8+ を使用
- 本番環境では毎日 06:00 JST に実行される
baseCTEは全ての event_type を含む(現在は3回テーブルスキャンが発生)
このモデルには3つの重大な問題点があります:
1. BigQuery コストの問題
2. dbt のベストプラクティス違反
3. データ品質・正確性の問題
1. BigQuery コストの問題
2. dbt のベストプラクティス違反
3. データ品質・正確性の問題
現在の dbt モデル (Bad)
90日で3TBのテーブルを 毎日3回フルスキャン しています(9TB/日)。
-- mart/campaign_daily_metrics.sql(問題あり)
{{ config(
materialized='table'
) }}
WITH base AS (
SELECT *
FROM {{ source('raw', 'campaign_events') }}
WHERE event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)
),
impressions AS (
SELECT
event_date,
campaign_id,
COUNT(*) AS impression_count
FROM base
WHERE event_type = 'impression'
GROUP BY 1, 2
),
clicks AS (
SELECT
event_date,
campaign_id,
COUNT(*) AS click_count
FROM base
WHERE event_type = 'click'
GROUP BY 1, 2
),
conversions AS (
SELECT
event_date,
campaign_id,
COUNT(*) AS conversion_count,
SUM(revenue) AS total_revenue
FROM base
WHERE event_type = 'conversion'
GROUP BY 1, 2
)
SELECT
i.event_date,
i.campaign_id,
i.impression_count,
c.click_count,
cv.conversion_count,
cv.total_revenue,
SAFE_DIVIDE(c.click_count, i.impression_count) AS ctr,
SAFE_DIVIDE(cv.conversion_count, c.click_count) AS cvr
FROM impressions i
LEFT JOIN clicks c USING (event_date, campaign_id)
LEFT JOIN conversions cv USING (event_date, campaign_id)
ヒント(段階的開示)
ヒント1 — 問題の方向性
- BigQuery のコスト = スキャン量。同じテーブルを何度読むか?
- dbt の
materialized='table'は毎回フルリビルドする。増分処理のための機能は? LEFT JOINとCOALESCEの関係を考える
ヒント2 — 3つの問題点アプローチ
- コスト問題:
baseCTEを3つのCTEが参照 = 物理的に3回フルスキャン。COUNTIFで1スキャンに統合できる - dbt ベストプラクティス: 毎日90日分フルスキャンより
incrementalマテリアライゼーションを使って当日分だけ追加 - データ品質: impression が 0 の campaign_id は
impressionsCTEに含まれないため、clicks や conversions だけある行がLEFT JOINで落ちる可能性がある
ヒント3 — incremental + COUNTIF 骨格
{{ config(
materialized='incremental',
partition_by={
"field": "event_date",
"data_type": "date",
"granularity": "day"
},
cluster_by=["campaign_id"],
incremental_strategy='insert_overwrite',
) }}
{% if is_incremental() %}
AND event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 3 DAY)
{% else %}
AND event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)
{% endif %}
-- 1スキャンで3種類の集計
SELECT
event_date,
campaign_id,
COUNTIF(event_type = 'impression') AS impression_count,
COUNTIF(event_type = 'click') AS click_count,
...
問題点分析
| # | 問題点 | 分類 | 影響 | 改善方法 |
|---|---|---|---|---|
| 1 | base CTEを impressions/clicks/conversions の3CTEが参照 |
3回スキャン | 3TB × 3 = 9TB/日スキャン($56.25/日) | COUNTIF(event_type = 'xxx') で1スキャンに統合 |
| 2 | materialized='table' で毎日90日分フルリビルド |
dbt違反 | 毎日3TBスキャン必須($18.75/日) | materialized='incremental' + is_incremental() で直近3日分のみ |
| 3 | SUM(revenue) で revenue が NULL の場合 SUM が NULL になる |
データ品質 | conversionイベントがあってもrevenue未設定でSUM=NULL | SUM(IF(event_type='conversion', COALESCE(revenue, 0.0), 0.0)) |
| 4 | impressions CTEに含まれないcampaign_idがLEFT JOINで落ちる |
データ欠損 | クリック・コンバージョンのみのキャンペーンが集計から消える | 単一SELECT + GROUP BY で統合(JOINを廃止) |
コスト削減の可視化
模範解答
{{ config(
materialized='incremental',
partition_by={
"field": "event_date",
"data_type": "date",
"granularity": "day"
},
cluster_by=["campaign_id"],
incremental_strategy='insert_overwrite',
on_schema_change='sync_all_columns'
) }}
/*
改善点:
1. [BQコスト] COUNTIF による1スキャン集計(3スキャン → 1スキャン)
2. [dbt] incremental + insert_overwrite で日次増分処理(90日フルリビルド廃止)
3. [品質] COALESCE で click/conversion が 0 の場合の NULL を 0 に明示
*/
SELECT
event_date,
campaign_id,
-- 1回のスキャンで3種類のイベントを集計
COUNTIF(event_type = 'impression') AS impression_count,
COUNTIF(event_type = 'click') AS click_count,
COUNTIF(event_type = 'conversion') AS conversion_count,
SUM(IF(event_type = 'conversion', COALESCE(revenue, 0.0), 0.0)) AS total_revenue,
-- 分母ゼロは NULL(SAFE_DIVIDE)で返す — ダッシュボード側でゼロ除算を避ける
SAFE_DIVIDE(
COUNTIF(event_type = 'click'),
COUNTIF(event_type = 'impression')
) AS ctr,
SAFE_DIVIDE(
COUNTIF(event_type = 'conversion'),
COUNTIF(event_type = 'click')
) AS cvr
FROM {{ source('raw', 'campaign_events') }}
WHERE 1=1
{% if is_incremental() %}
-- 増分実行時: 直近3日間を上書き(遅延データ・修正データを考慮してバッファ3日)
AND event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 3 DAY)
{% else %}
-- 初回フルビルド時: 90日分をロード
AND event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)
{% endif %}
GROUP BY
event_date,
campaign_id
ポイント解説
1
旧:
新:
BigQuery の料金 $6.25/TB → 約 $37.5/日 → $25/日節約
COUNTIF による1スキャン集計旧:
base CTE(3TB)を impressions, clicks, conversions の3CTEが参照 = 9TB スキャン新:
COUNTIF(event_type = 'impression') で条件付き集計 = 3TB スキャン(1/3に削減)BigQuery の料金 $6.25/TB → 約 $37.5/日 → $25/日節約
2
incremental + insert_overwritematerialized='table' は毎回90日フルリビルド = 毎日 3TB スキャン必須incremental + is_incremental() フィルタで 3日分(99GB)のみスキャン に削減。
insert_overwrite はパーティションごと上書きするので、遅延データが来ても冪等性を保てる。
バッファ3日の理由: キャンペーンデータは最大2日遅延することがある。
3
旧コードの
COALESCE(revenue, 0.0) の明示旧コードの
SUM(revenue) は conversion があっても revenue が NULL なら SUM が NULL になる。
COALESCE(revenue, 0.0) で「revenue 未設定の conversion」を 0 として集計。
4
LEFT JOIN 廃止によるデータ落ち防止
旧:
新: 単一 SELECT + GROUP BY なのでそのような問題が発生しない。
旧:
impressions CTE に含まれない campaign_id(クリックのみ・コンバージョンのみ)が消える。新: 単一 SELECT + GROUP BY なのでそのような問題が発生しない。
コスト削減の実務判断フロー
- BigQuery の「実行予定 TB」を確認(BQ コンソールで右上に表示)
- 3TB × $6.25 × 30日 = $562.5/月 → 改善後: 99GB × $6.25 × 30日 = $18.6/月
- コスト削減 = $543.9/月 → エンジニアの作業時間 vs ROI を比較してタスク優先度を決める
実務への応用
# Argo Workflows からの dbt 実行コマンド例
dbt run --select mart.campaign_daily_metrics --target prod
# フルリフレッシュが必要な場合(スキーマ変更時)
dbt run --select mart.campaign_daily_metrics --full-refresh --target prod
- MOps の日次キャンペーンレポート: 同様のパターン(複数CTE参照)が発生しやすい。COUNTIF最適化はすぐに適用できる。
-
スキーマ変更時:
on_schema_change='sync_all_columns'を設定することで、 新カラム追加時も自動的にテーブルスキーマを同期できる。 - コスト削減をPMに伝える: 年間$19,800の節約は具体的な数値でPMへの説明材料になる。
次のステップ
発展問題:
campaign_daily_metrics に dbt Unit Tests(dbt Core 1.8+)を追加して
COUNTIF の正確性をテストする
- 参考: dbt Incremental Models ドキュメント
- 参考: BigQuery
COUNTIFvsCOUNT(IF(...))のパフォーマンス比較 - 参考: BigQuery パーティション設計ガイドライン
今日のまとめ
「BigQuery の複数 CTE スキャン問題」は
コスト削減の数値試算($562.5/月 → $18.6/月)をセットで記録しておくと、PM や CFO への説明材料になる。
COUNTIF / SUM(IF(...)) による1スキャン集計と
dbt incremental モデルの組み合わせで、スキャン量を 1/30以下に削減できる。コスト削減の数値試算($562.5/月 → $18.6/月)をセットで記録しておくと、PM や CFO への説明材料になる。
COALESCE による NULL 明示と LEFT JOIN 廃止で、データ品質も同時に改善する。