C データエンジニアリング — BigQuery CVR 集計 99%コスト削減 × dbt 1.8+ Unit Tests × incremental insert_overwrite

2026-05-27 (Day 51) 水曜 C: データエンジニアリング ★★★☆☆ BigQuery / dbt Core 1.8+ PARSE_DATE / SAFE_DIVIDE / Unit Tests

概要

📅

PARSE_DATE → partition pruning

WHERE 句で CAST(session_date AS DATE) を使うと partition pruning が無効化される。staging で PARSE_DATE('%Y-%m-%d', session_date) し DATE 型に変換、下流は変換済み列を WHERE することが鉄則。

♻️

incremental insert_overwrite

パーティション単位で置換する戦略。遅延セッション(深夜到達)に対応するため2日分を再計算。materialized='table' のフルリフレッシュからスキャン量を 99% 削減。

🧪

dbt 1.8+ Unit Tests

モック行を given: で定義し expect: でアサートする。CVR 計算の分母ゼロ・NULL 混入を CI で自動検証。コードと同じリポジトリで品質保証。

🪟

7日窓テーブルで参照面を分離

Looker Studio 専用の固定窓テーブル(数十MB)を mart_cvr_last7d として分離。累積テーブルを直参照させないことで 1回 600GB → 50MB に圧縮。

問題

ECサイトの Argo Workflows で夜間に実行されるデータパイプラインが、dbt のモデル更新ステップで毎回フルリフレッシュしており、BigQuery の処理コストが月額 $4,500 を超えている。以下の「悪いコード」を見て問題点を全て洗い出し、dbt Core 1.8+ の Unit Testsincremental insert_overwrite を活用した形にリファクタリングせよ。

対象モデルは「商品カテゴリ別・日次セッション → 購入転換率(CVR)集計テーブル」。販促チームが Looker Studio でリアルタイムに参照しており、遅延は1時間以内が要件。

制約・前提条件

  • BigQuery(asia-northeast1)、dbt Core 1.8+(Unit Tests 使用可)
  • raw.sessionssession_date STRING('YYYY-MM-DD') で保存されており変更不可
  • stg_orders は 2026-05-20 設計済みで存在すること前提
  • 販促チームが参照する mart は直近 7日の CVR サマリー(Looker Studio 直参照)
  • 目標: スキャン量 1TB 以下/月、dbt Unit Tests で CVR ロジックの正しさを保証
期待する回答形式: 問題点の列挙(番号付き)+ 改善後 dbt モデル全文(staging + mart)+ dbt Unit Tests(YAML)+ Before/After スキャン量とコスト試算

悪いコード (Before)

このコードには 5つの設計上の問題 が隠れています。見つけてください。
❌ mart_category_cvr_daily.sql(現状)
-- ❌ materialized='table' で毎回全件フルリフレッシュ
{{ config(materialized='table') }}

SELECT
    -- ❌ CAST(STRING → DATE) で partition pruning が無効化
    CAST(s.session_date AS DATE)    AS order_date,
    s.product_category              AS category,
    COUNT(DISTINCT s.session_id)    AS sessions,
    COUNT(DISTINCT o.order_id)      AS orders,
    -- ❌ sessions=0 の場合に NULL が返る(0.0 にならない)
    COUNT(DISTINCT o.order_id) /
        NULLIF(COUNT(DISTINCT s.session_id), 0) AS cvr
FROM `prj-prod.raw.sessions` s     -- 600GB・pruning 不可
LEFT JOIN `prj-prod.raw.orders` o
    -- ❌ CAST での JOIN → pruning 無効
    ON CAST(s.session_date AS DATE) = CAST(o.order_date AS DATE)
    AND s.product_category = o.product_category
-- ❌ 直近30日を毎回フルスキャン
WHERE CAST(s.session_date AS DATE)
    >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 30 DAY)
GROUP BY 1, 2
-- ❌ Unit Tests 一切なし → CVR バグがサイレントに本番流入
問題点サマリー(5点)
1CAST(session_date AS DATE) で partition pruning 無効 — staging で PARSE_DATE し DATE 型で再パーティション化が必須
2materialized='table' でフルリフレッシュ — incremental + insert_overwrite に変更し差分2日分のみ再計算
3CVR 計算で sessions=0 → NULL — SAFE_DIVIDE + COALESCE で 0.0 を返す
4dbt Unit Tests 未設定 — dbt 1.8+ の unit_tests: ブロックでロジックを CI 保証
5Looker Studio が大テーブルを直参照 — 7日窓の mart_cvr_last7d を分離

ヒント(段階的開示)

ヒント1 — 方向性
問題は3カテゴリ: (a) STRING 型 session_date の partition pruning 無効化(CAST 変換で WHERE を書いている)、(b) table で毎回フルリフレッシュ(incremental 未使用)、(c) ロジック検証ゼロ(dbt Unit Tests 未使用でデータ品質が無保証)。dbt 1.8+ の unit_tests: ブロックで分母ゼロ・NULL 混入のロジックテストが書ける。
ヒント2 — アプローチ(構成要素)
  • staging 層: PARSE_DATE('%Y-%m-%d', session_date) で DATE 化、partition_by で再パーティション
  • incremental 戦略: insert_overwrite + is_incremental() で過去2日分を再計算(遅延セッションに対応)
  • CVR 計算のガード: SAFE_DIVIDE(orders, sessions) + COALESCE(..., 0.0) で分母ゼロを 0.0 に
  • dbt Unit Tests: unit_tests: ブロックで「sessions=0 は cvr=0.0」「通常ケース 10/200=0.05」を検証
  • mart_cvr_last7d: materialized='table' の7日窓テーブルを Looker Studio 参照面に
ヒント3 — incremental + Unit Test の骨格
{{ config(
    materialized='incremental',
    incremental_strategy='insert_overwrite',
    partition_by={
        'field': 'session_date',
        'data_type': 'date',
        'granularity': 'day'
    },
    cluster_by=['category']
) }}

SELECT
    session_date,
    category,
    sessions,
    orders,
    COALESCE(SAFE_DIVIDE(orders, sessions), 0.0) AS cvr
FROM {{ ref('int_session_order_joined') }}
{% if is_incremental() %}
WHERE session_date >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 2 DAY)
{% endif %}
# dbt 1.8+ Unit Test の骨格
unit_tests:
  - name: test_cvr_zero_when_no_sessions
    model: mart_category_cvr_daily
    given:
      - input: ref('int_session_order_joined')
        rows:
          - {session_date: '2026-05-27', category: 'apparel', sessions: 0, orders: 0}
    expect:
      rows:
        - {session_date: '2026-05-27', category: 'apparel', cvr: 0.0}

問題点分析(5点)

#問題点影響改善方法
1 CAST(session_date AS DATE) で partition pruning 無効化 WHERE/JOIN に CAST が入ると BigQuery が枝刈り不可 → 600GB フルスキャン staging で PARSE_DATE('%Y-%m-%d', session_date) し DATE 型で partition_by 再パーティション
2 materialized='table' で毎回フルリフレッシュ 1日10バッチ実行 × 30日で月額 $4,500 超 incremental + insert_overwrite で差分2日分のみ再計算
3 CVR 計算で sessions=0 → NULL(NULLIF のみ) Looker Studio グラフが欠損表示になる SAFE_DIVIDE + COALESCE(..., 0.0) で 0.0 を返す
4 dbt Unit Tests 未設定 CVR ロジックのバグがサイレントに本番流入するリスク dbt 1.8+ unit_tests: ブロックでモック行テストを追加
5 Looker Studio が累積テーブルを直参照 フィルタ変更のたびに数GB のスキャンが発生 mart_cvr_last7d(7日窓・数十MB)を分離し Looker Studio はここを参照

アーキテクチャ図 — Before(1段 table)vs After(3層 incremental + Unit Tests)

Before: 1段 table(毎回 600GB フルスキャン) raw.sessions STRING型 / 600GB / pruning不可 raw.orders STRING型 / 350GB / pruning不可 mart_category_cvr_daily (TABLE) CAST(session_date AS DATE) → pruning無効 sessions=0 → CVR が NULL / Unit Tests なし 600GB / リクエスト Looker Studio 1日10回バッチ × $1.50 × 30日 = $450 直参照で表示 20〜40 秒 月額 約 $4,500 CVR バグ検知不可 / 表示 30秒 After: 3層 incremental + Unit Tests(4GB/バッチ) raw.sessions / raw.orders(変更不可) stg_sessions / stg_orders PARSE_DATE → DATE型 + partition_by(day) int_session_order_joined incremental insert_overwrite / 2日分 / DATE JOIN mart_category_cvr_daily SAFE_DIVIDE + incremental / cluster_by(category) ← dbt Unit Tests(3パターン)で CVR ロジック保証 🧪 Unit Tests mart_cvr_last7d (TABLE) 7日窓 / 数十MB / Looker Studio 参照面 50MB / リクエスト 月額 約 $36(99.2% 削減) CVR バグを CI で検知 / 表示 1秒以下

模範解答 — dbt モデル群 + Unit Tests

1. staging層: stg_sessions.sql

{{ config(
    materialized='incremental',
    incremental_strategy='insert_overwrite',
    partition_by={
        'field': 'session_date',
        'data_type': 'date',
        'granularity': 'day'
    },
    cluster_by=['product_category', 'user_id'],
    on_schema_change='append_new_columns'
) }}

-- 目的: raw.sessions(STRING型 session_date)を DATE 型に正規化し
--       partition pruning が効く形に再パーティション化する
SELECT
    PARSE_DATE('%Y-%m-%d', session_date) AS session_date,  -- ✅ STRING → DATE
    session_id,
    user_id,
    product_category,
    page_views,
    duration_seconds,
    created_at
FROM {{ source('raw', 'sessions') }}
{% if is_incremental() %}
-- 遅延セッション対応: 2日分を insert_overwrite で再計算(重複は自動排除)
WHERE PARSE_DATE('%Y-%m-%d', session_date)
    >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 2 DAY)
{% endif %}

2. intermediate層: int_session_order_joined.sql

{{ config(
    materialized='incremental',
    incremental_strategy='insert_overwrite',
    partition_by={'field': 'session_date', 'data_type': 'date', 'granularity': 'day'},
    cluster_by=['category']
) }}

-- 目的: セッション × 注文を product_category × 日次で事前 JOIN
WITH sessions AS (
    SELECT
        session_date,
        product_category AS category,
        COUNT(DISTINCT session_id) AS sessions
    FROM {{ ref('stg_sessions') }}
    {% if is_incremental() %}
    WHERE session_date >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 2 DAY)
    {% endif %}
    GROUP BY session_date, product_category
),
orders AS (
    SELECT
        order_date,
        product_category AS category,
        COUNT(DISTINCT order_id) AS orders
    FROM {{ ref('stg_orders') }}  -- 2026-05-20 設計済み
    {% if is_incremental() %}
    WHERE order_date >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 2 DAY)
    {% endif %}
    GROUP BY order_date, product_category
)
SELECT
    s.session_date,
    s.category,
    s.sessions,
    -- ✅ LEFT JOIN で orders が NULL の場合は 0 に正規化
    COALESCE(o.orders, 0) AS orders
FROM sessions s
LEFT JOIN orders o
    -- ✅ DATE 型同士の比較 → partition pruning 有効
    ON s.session_date = o.order_date
    AND s.category    = o.category

3. mart層(累積): mart_category_cvr_daily.sql

{{ config(
    materialized='incremental',
    incremental_strategy='insert_overwrite',
    partition_by={
        'field': 'session_date',
        'data_type': 'date',
        'granularity': 'day'
    },
    cluster_by=['category']
    -- ✅ ORDER BY なし(ビュー内ソートは再実行されるため削除)
) }}

-- 目的: カテゴリ × 日次の CVR を事前集計して保持する累積テーブル
SELECT
    session_date,
    category,
    sessions,
    orders,
    -- ✅ SAFE_DIVIDE: sessions=0 の場合 NULL ではなく 0.0 を返す
    -- ✅ COALESCE: SAFE_DIVIDE が NULL を返すエッジケース(sessions=NULL)も 0.0 に
    COALESCE(SAFE_DIVIDE(orders, sessions), 0.0) AS cvr
FROM {{ ref('int_session_order_joined') }}
{% if is_incremental() %}
WHERE session_date >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 2 DAY)
{% endif %}

4. Looker Studio 参照面: mart_cvr_last7d.sql

{{ config(
    materialized='table',  -- ✅ 7日窓の小テーブル(数十MB)。dbt run 毎に再生成
    cluster_by=['category']
) }}

-- 目的: Looker Studio が直接参照する固定窓テーブル。
--       mart_category_cvr_daily から直近7日のみを切り出す。
SELECT
    session_date,
    category,
    sessions,
    orders,
    cvr,
    ROUND(cvr * 100, 2) AS cvr_pct  -- 表示用: パーセント表記(例: 5.00%)
FROM {{ ref('mart_category_cvr_daily') }}
WHERE session_date >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 7 DAY)
  AND session_date <  CURRENT_DATE('Asia/Tokyo')

5. dbt Unit Tests(models/marts/schema.yml)

version: 2

models:
  - name: mart_category_cvr_daily
    description: "カテゴリ別日次 CVR 累積テーブル(セッション → 購入転換率)"

unit_tests:
  # ✅ テスト1: sessions=0 のカテゴリは cvr=0.0 であること
  - name: test_cvr_zero_when_no_sessions
    model: mart_category_cvr_daily
    given:
      - input: ref('int_session_order_joined')
        rows:
          - {session_date: '2026-05-27', category: 'new_arrivals', sessions: 0, orders: 0}
    expect:
      rows:
        - {session_date: '2026-05-27', category: 'new_arrivals', cvr: 0.0}

  # ✅ テスト2: 通常ケースの CVR 計算精度(10/200=0.05)
  - name: test_cvr_normal_case
    model: mart_category_cvr_daily
    given:
      - input: ref('int_session_order_joined')
        rows:
          - {session_date: '2026-05-27', category: 'apparel', sessions: 200, orders: 10}
    expect:
      rows:
        - {session_date: '2026-05-27', category: 'apparel', cvr: 0.05}

  # ✅ テスト3: orders=0(LEFT JOIN でマッチなし)の場合 cvr=0.0
  - name: test_cvr_when_no_orders
    model: mart_category_cvr_daily
    given:
      - input: ref('int_session_order_joined')
        rows:
          - {session_date: '2026-05-27', category: 'electronics', sessions: 500, orders: 0}
    expect:
      rows:
        - {session_date: '2026-05-27', category: 'electronics', cvr: 0.0}

月額コスト試算(Before / After)

項目BeforeAfter削減率
バッチ1回スキャン量600 GB(30日分 sessions + orders)4 GB(2日分 incremental)99.3%
バッチ1回料金($5/TB)$3.00$0.02
バッチ 1日 10回 × 30日$900$6
Looker Studio 参照 1日 200回$3,600(6GB/回 × $5/TB × 200 × 30)$0(50MB → 切り捨て)
dbt run バッチ追加コスト約 $30 / 月
月額合計約 $4,500約 $3699.2%
ダッシュボード表示速度20〜40 秒1 秒以下
CVR バグ検知なし(サイレント流入)Unit Tests が CI で自動検知

※ BigQuery オンデマンド料金 $5/TB として試算。リージョン: asia-northeast1。

ポイント解説

1. PARSE_DATE vs CAST — partition pruning の可否
BigQuery は WHERE 句のパーティション列に関数(CAST 含む)が適用されると枝刈りを諦める。元データが STRING の場合は staging で PARSE_DATE('%Y-%m-%d', session_date) し DATE 型に変換、下流は変換済み列を WHERE session_date >= ... と書くことが必須。クエリプランの Slot ms + Bytes processed で確認できる。
2. incremental insert_overwrite の遅延データ対応
セッションは深夜に遅れて届くことが多い(例: 23:59のセッションが翌日 01:00 にストリームで到達)。過去2日分を insert_overwrite で上書きすることで重複なく遅延データを自動修正できる。merge と比較してフルスキャンが不要なためスキャン量が 1/100 以下になる。
3. SAFE_DIVIDE vs NULLIF — 分母ゼロのガード
NULLIF(sessions, 0) は分母ゼロを NULL にするが、Looker Studio でグラフが欠損表示になる。SAFE_DIVIDE(orders, sessions) は sessions=0 の場合 NULL を返し、COALESCE(..., 0.0) と組み合わせることで安全に 0.0 を返せる。チーム内で「CVR 未計測カテゴリ」と「CVR=0% カテゴリ」を区別したい場合は NULL を維持することもあるが、Looker Studio の可視化安定性を優先するなら 0.0 が推奨。
4. dbt 1.8+ Unit Tests の使い方
unit_tests: ブロックはモック行を given: で定義し expect: でアサートする。dbt test --select unit_tests で CI に組み込め、PR マージ時に CVR ロジックの退行を自動検知できる。実テーブルを使わないため BigQuery の課金は発生しない。特に「境界値(sessions=0)」「通常ケース」「NULLケース」の3パターンは必ず書くべき。
5. ダッシュボード参照面の分離(7日窓テーブル)
Looker Studio が累積テーブルを直参照すると、フィルタ変更のたびに数GBのスキャンが発生する。mart_cvr_last7d(数十MB)を専用の参照面として作り、Looker Studio はここだけを参照させることで1回のスキャン量を MB 単位に抑えられる。Looker Studio のキャッシュ設定(最大12時間)と組み合わせるとさらに低コスト化できる。

実務への応用

  • MOps 販促チームの CVR モニタリング: クーポン施策ごとの CVR 変化を追うユースケースはまさにこのパターン。categorycampaign_name に置き換えるだけで転用できる。
  • Argo Workflows との統合: dbt run --select mart_cvr_last7d+ を Argo の DAG に組み込み、毎時バッチの最後のステップとして実行。前段の incremental stg/int が失敗した場合は Argo のリトライで再計算される。
  • DataDog でバッチコストを可視化: BigQuery の INFORMATION_SCHEMA.JOBS_BY_PROJECT から total_bytes_billed を取得し、dbt ジョブ別のスキャン量を DataDog カスタムメトリクスとして送信。突然のコスト増(--full-refresh 誤実行など)をアラートで検知できる。
  • dbt --full-refresh は本番禁止: スキーマ変更時のみ手動実行。Argo の dbt run ステップにはフラグを付けず、dbt run --full-refresh は別の手動ワークフローに分離する。

今日のまとめ

BigQuery コスト削減の3点セット: (1) staging で PARSE_DATE → DATE 型変換(partition pruning を有効化)、(2) incremental insert_overwrite で差分のみ再計算、(3) Looker Studio 専用の固定窓テーブルを分離

dbt 1.8+ Unit Tests で CVR の分母ゼロ・NULL ケースを CI 保証することで、サイレントなロジックバグを本番流入前に検知できる。SAFE_DIVIDE + COALESCE の組み合わせは BigQuery の数値計算における必須のイディオム。

自己評価

自分の回答

気づき・メモ