概要
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 Tests と incremental insert_overwrite を活用した形にリファクタリングせよ。
対象モデルは「商品カテゴリ別・日次セッション → 購入転換率(CVR)集計テーブル」。販促チームが Looker Studio でリアルタイムに参照しており、遅延は1時間以内が要件。
制約・前提条件
- BigQuery(asia-northeast1)、dbt Core 1.8+(Unit Tests 使用可)
raw.sessionsはsession_date STRING('YYYY-MM-DD')で保存されており変更不可stg_ordersは 2026-05-20 設計済みで存在すること前提- 販促チームが参照する mart は直近 7日の CVR サマリー(Looker Studio 直参照)
- 目標: スキャン量 1TB 以下/月、dbt Unit Tests で CVR ロジックの正しさを保証
悪いコード (Before)
-- ❌ 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 バグがサイレントに本番流入
ヒント(段階的開示)
ヒント1 — 方向性
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)
模範解答 — 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)
| 項目 | Before | After | 削減率 |
|---|---|---|---|
| バッチ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 | 約 $36 | 99.2% |
| ダッシュボード表示速度 | 20〜40 秒 | 1 秒以下 | — |
| CVR バグ検知 | なし(サイレント流入) | Unit Tests が CI で自動検知 | — |
※ BigQuery オンデマンド料金 $5/TB として試算。リージョン: asia-northeast1。
ポイント解説
BigQuery は WHERE 句のパーティション列に関数(CAST 含む)が適用されると枝刈りを諦める。元データが STRING の場合は staging で
PARSE_DATE('%Y-%m-%d', session_date) し DATE 型に変換、下流は変換済み列を WHERE session_date >= ... と書くことが必須。クエリプランの Slot ms + Bytes processed で確認できる。
セッションは深夜に遅れて届くことが多い(例: 23:59のセッションが翌日 01:00 にストリームで到達)。過去2日分を
insert_overwrite で上書きすることで重複なく遅延データを自動修正できる。merge と比較してフルスキャンが不要なためスキャン量が 1/100 以下になる。
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 が推奨。
unit_tests: ブロックはモック行を given: で定義し expect: でアサートする。dbt test --select unit_tests で CI に組み込め、PR マージ時に CVR ロジックの退行を自動検知できる。実テーブルを使わないため BigQuery の課金は発生しない。特に「境界値(sessions=0)」「通常ケース」「NULLケース」の3パターンは必ず書くべき。
Looker Studio が累積テーブルを直参照すると、フィルタ変更のたびに数GBのスキャンが発生する。
mart_cvr_last7d(数十MB)を専用の参照面として作り、Looker Studio はここだけを参照させることで1回のスキャン量を MB 単位に抑えられる。Looker Studio のキャッシュ設定(最大12時間)と組み合わせるとさらに低コスト化できる。
実務への応用
- MOps 販促チームの CVR モニタリング: クーポン施策ごとの CVR 変化を追うユースケースはまさにこのパターン。
categoryをcampaign_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は別の手動ワークフローに分離する。
今日のまとめ
dbt 1.8+ Unit Tests で CVR の分母ゼロ・NULL ケースを CI 保証することで、サイレントなロジックバグを本番流入前に検知できる。
SAFE_DIVIDE + COALESCE の組み合わせは BigQuery の数値計算における必須のイディオム。