データエンジニアリング — dbt incremental × COUNTIF 最適化

2026-04-29 C: データエンジニアリング ★★★☆☆ dbt Core 1.8+ × BigQuery COUNTIF 3スキャン → 1スキャン(▲67% コスト削減)

概要

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_idSTRINGイベントID
event_dateDATEパーティション列
campaign_idSTRINGクラスタリング列①
user_idSTRINGクラスタリング列②
event_typeSTRING'impression' | 'click' | 'conversion'
revenueFLOAT64conversion のみ入る、それ以外 NULL

制約

  • テーブルサイズ: 90日で約 3TB(1日 33GB)
  • dbt Core 1.8+ を使用
  • 本番環境では毎日 06:00 JST に実行される
  • base CTEは全ての event_type を含む(現在は3回テーブルスキャンが発生)
このモデルには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 JOINCOALESCE の関係を考える
ヒント2 — 3つの問題点アプローチ
  1. コスト問題: base CTEを3つのCTEが参照 = 物理的に3回フルスキャン。COUNTIFで1スキャンに統合できる
  2. dbt ベストプラクティス: 毎日90日分フルスキャンより incremental マテリアライゼーションを使って当日分だけ追加
  3. データ品質: impression が 0 の campaign_id は impressions CTEに含まれないため、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を廃止)

コスト削減の可視化

月次スキャンコスト比較(BigQuery $6.25/TB) ✗ Before materialized='table' + 3CTEスキャン 3TB × 3スキャン × 30日 = 270TB/月 = $1,687/月 ✓ After incremental + COUNTIF(1スキャン) 初回 3TB 日次: 99GB × 29日 約3TB + 2.9TB = 5.9TB/月 = $36.8/月 削減効果 $1,687/月 $36.8/月 ▲97.8% $1,650/月 節約 年間 $19,800 節約

模範解答

{{ 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 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_overwrite
materialized='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 廃止によるデータ落ち防止
旧: impressions CTE に含まれない campaign_id(クリックのみ・コンバージョンのみ)が消える。
新: 単一 SELECT + GROUP BY なのでそのような問題が発生しない。

コスト削減の実務判断フロー

  1. BigQuery の「実行予定 TB」を確認(BQ コンソールで右上に表示)
  2. 3TB × $6.25 × 30日 = $562.5/月 → 改善後: 99GB × $6.25 × 30日 = $18.6/月
  3. コスト削減 = $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_metricsdbt Unit Tests(dbt Core 1.8+)を追加して COUNTIF の正確性をテストする
  • 参考: dbt Incremental Models ドキュメント
  • 参考: BigQuery COUNTIF vs COUNT(IF(...)) のパフォーマンス比較
  • 参考: BigQuery パーティション設計ガイドライン

今日のまとめ

「BigQuery の複数 CTE スキャン問題」は COUNTIF / SUM(IF(...)) による1スキャン集計と dbt incremental モデルの組み合わせで、スキャン量を 1/30以下に削減できる。

コスト削減の数値試算($562.5/月 → $18.6/月)をセットで記録しておくと、PM や CFO への説明材料になる。 COALESCE による NULL 明示と LEFT JOIN 廃止で、データ品質も同時に改善する。

自己評価

自分の回答

気づき・メモ