概要
パーティションプルーニング
WHERE DATE(ordered_at) >= ... でパーティション境界を絞り、スキャン量を激減させる。
dbt Incremental (insert_overwrite)
毎日の増分(2 GB)のみを再スキャン。2ヶ月目以降のコストをほぼゼロにする。
直近90日スコープ
ダッシュボードで必要な期間だけ保持。パーティション + is_incremental() フィルタで2日バッファを持たせ遅延到着データに対応。
on_schema_change='fail'
意図しないカラム追加・削除を防ぐ。本番パイプラインは安全側に倒す。
問題
毎日 09:00 に実行されるダッシュボード用集計クエリが BigQuery の請求コストを圧迫しています。
現状コスト状況
| 項目 | 値 |
|---|---|
raw.orders テーブルサイズ | 500 GB(毎日 2 GB 増加) |
| 月間実行回数 | 30回(毎日1回) |
| BigQuery オンデマンド料金 | $6.25 / TB |
| 現状の月間スキャン量 | 500 GB × 30 = 15 TB/月 |
| 現状の月間コスト | $93.75/月 |
問題1: コスト試算(E)
以下の2つの改善案それぞれについて、月間スキャン量(TB)と月間コスト($)を試算せよ。
| 改善案 | 内容 |
|---|---|
| 案A | クエリにパーティションフィルタを追加し、直近90日のみスキャン |
| 案B | dbt の incremental を使い、増分のみ更新(毎日の増分: 2 GB) |
問題2: dbt 実装(C)
案Bの Incremental Model を dbt で実装せよ。以下を含めること:
- dbt model config(materialization, partition_by, cluster_by)
- 増分ロジック(
is_incremental()フィルタ) - スキーマ定義(
schema.yml)の主要カラムに description とnot_nullテストを追加
期待する回答形式: 数値計算(表形式)+ dbt SQL コード + schema.yml
現状のコード(dbt model: mart_campaign_roi.sql)
このクエリは毎回 500 GB をフルスキャン しています。パーティションフィルタが一切ない状態。
-- mart_campaign_roi.sql(現状)
-- ※ dbt config なし、毎回フルスキャン
SELECT
c.campaign_id,
c.campaign_name,
c.start_date,
c.end_date,
c.budget_jpy,
COUNT(DISTINCT o.user_id) AS reached_users,
COUNT(o.order_id) AS orders,
SUM(o.amount_jpy) AS revenue_jpy,
SUM(o.amount_jpy) - c.budget_jpy AS profit_jpy,
SAFE_DIVIDE(SUM(o.amount_jpy) - c.budget_jpy, c.budget_jpy) * 100 AS roi_pct
FROM
`project.raw.campaigns` c
LEFT JOIN `project.raw.orders` o
ON o.campaign_id = c.campaign_id
AND o.ordered_at BETWEEN c.start_date AND c.end_date
GROUP BY
c.campaign_id, c.campaign_name, c.start_date, c.end_date, c.budget_jpy
ヒント(段階的開示)
ヒント1 — 方向性
BigQuery のコスト削減には「スキャン量を減らす」ことが最も効果的。パーティションフィルタと Incremental(増分更新)の2軸で考える。
ヒント2 — アプローチ(コスト計算)
- 案A: 500 GB のうち直近90日分だけスキャン。日次2 GB増加、90日分なら最大 90 × 2 GB = 180 GB
- 案B: Incremental Model は初回フルスキャン後、毎日の増分(2 GB)のみを追加スキャン。2ヶ月目以降は増分のみ = 2 GB × 30 = 60 GB/月
ヒント3 — dbt Incremental の骨格
{{ config(
materialized='incremental',
partition_by={'field': 'ordered_at', 'data_type': 'date'},
cluster_by=['campaign_id'],
incremental_strategy='insert_overwrite'
) }}
SELECT ...
{% if is_incremental() %}
WHERE ordered_at >= DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY)
{% endif %}
模範解答 — 問題1: コスト試算
前提計算
テーブル全体: 500 GB、毎日2 GB増加。直近90日分のデータ量: 2 GB/日 × 90日 = 180 GB
| 項目 | 現状 | 案A(パーティションフィルタ) | 案B(Incremental、2ヶ月目〜) |
|---|---|---|---|
| 1回あたりスキャン量 | 500 GB | 180 GB | 2 GB(増分のみ) |
| 月間スキャン量 | 15 TB | 5.4 TB | 0.06 TB |
| 月間コスト | $93.75 | $33.75 | $0.375 |
| 削減額(vs現状) | — | -$60.00(▲64%) | -$93.375(▲99.6%) |
※ 案Bの初月はフルスキャン(500 GB)+増分29日分が発生するが、2ヶ月目以降はほぼゼロに近い。
コスト削減 ビジュアライゼーション
模範解答 — 問題2: dbt 実装
{{
config(
materialized='incremental',
partition_by={
'field': 'ordered_date',
'data_type': 'date',
'granularity': 'day'
},
cluster_by=['campaign_id'],
incremental_strategy='insert_overwrite',
on_schema_change='fail'
)
}}
-- 増分実行時は直近2日分のパーティションを上書き(遅延到着データ対応)
WITH orders_filtered AS (
SELECT
o.order_id,
o.campaign_id,
o.user_id,
o.amount_jpy,
DATE(o.ordered_at) AS ordered_date
FROM `{{ source('raw', 'orders') }}` AS o
WHERE
DATE(o.ordered_at) >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)
{% if is_incremental() %}
-- 増分実行: 直近2日のパーティションのみ再計算(遅延到着データを考慮)
AND DATE(o.ordered_at) >= DATE_SUB(CURRENT_DATE(), INTERVAL 2 DAY)
{% endif %}
),
campaign_daily AS (
SELECT
c.campaign_id,
c.campaign_name,
c.start_date,
c.end_date,
c.budget_jpy,
of.ordered_date,
COUNT(DISTINCT of.user_id) AS reached_users,
COUNT(of.order_id) AS orders,
SUM(of.amount_jpy) AS revenue_jpy
FROM `{{ source('raw', 'campaigns') }}` AS c
INNER JOIN orders_filtered AS of
ON of.campaign_id = c.campaign_id
AND of.ordered_date BETWEEN c.start_date AND c.end_date
GROUP BY
c.campaign_id, c.campaign_name, c.start_date,
c.end_date, c.budget_jpy, of.ordered_date
)
SELECT
campaign_id,
campaign_name,
start_date,
end_date,
budget_jpy,
ordered_date,
reached_users,
orders,
revenue_jpy,
SAFE_DIVIDE(revenue_jpy - budget_jpy, budget_jpy) * 100 AS roi_pct_daily
FROM campaign_daily
version: 2
models:
- name: mart_campaign_roi
description: >
キャンペーン別・日次 ROI 集計テーブル。
直近90日のみ保持し、毎日増分更新(insert_overwrite)する。
ダッシュボードはこのテーブルを campaign_id でグルーピングして期間累計を表示すること。
columns:
- name: campaign_id
description: "キャンペーンID(raw.campaigns.campaign_id)"
tests:
- not_null
- name: campaign_name
description: "キャンペーン名"
tests:
- not_null
- name: ordered_date
description: "注文日(パーティションキー)"
tests:
- not_null
- name: budget_jpy
description: "キャンペーン予算(円)"
tests:
- not_null
- name: reached_users
description: "当日にキャンペーン経由で購入したユニークユーザー数"
- name: orders
description: "当日の注文件数"
- name: revenue_jpy
description: "当日の売上(円)"
- name: roi_pct_daily
description: "当日 ROI(%)= (revenue - budget) / budget × 100。期間累計はBI側で計算"
ポイント解説
1
パーティションフィルタの効果
BigQuery は
BigQuery は
WHERE DATE(ordered_at) >= ... でパーティションプルーニングが自動的に動作する。ただし TIMESTAMP 型には DATE() ラップが必要(型を合わせないとフルスキャンになる)。
2
Incremental の
BigQuery では
insert_overwrite 戦略BigQuery では
merge より insert_overwrite の方がコストと速度で優れる。パーティション単位で上書きするため、遅延到着データ(昨日のデータが今日届く)に対応するには INTERVAL 2 DAY でバッファを持たせる。
3
Cluster by の選択
campaign_id でクラスタリングすることで、WHERE campaign_id = X のフィルタが入るダッシュボードクエリのスキャン量をさらに削減できる(最大75%削減が目安)。
4
ROI 計算の粒度設計
モデル内では「日次」粒度で保持し、集計(期間累計ROI)はダッシュボード層に委ねる。1つのモデルを複数の集計期間(7日/30日/90日)に再利用できる(関心の分離)。
モデル内では「日次」粒度で保持し、集計(期間累計ROI)はダッシュボード層に委ねる。1つのモデルを複数の集計期間(7日/30日/90日)に再利用できる(関心の分離)。
5
スキーマ変更時に自動マイグレーションせず失敗させることで、意図しないカラム追加・削除を防ぐ。本番環境のデータパイプラインでは安全側に倒す。
on_schema_change='fail'スキーマ変更時に自動マイグレーションせず失敗させることで、意図しないカラム追加・削除を防ぐ。本番環境のデータパイプラインでは安全側に倒す。
実務への応用
ECサイト/MOps文脈:
- 施策(クーポン/メルマガ/リターゲティング)ごとのROIをこのテーブルから即座に集計可能
- Argo Workflows の DAG で
dbt run --select mart_campaign_roiを毎朝09:00に実行し、DataDog でジョブ成功/失敗とスキャン量をモニタリング - BigQuery の
INFORMATION_SCHEMA.JOBSでスキャン量の推移を週次レビューし、コスト異常(前週比150%超)をアラート
今日のまとめ
BigQuery のコスト削減はパーティションフィルタだけで64%削減できるが、dbt Incremental(insert_overwrite)を組み合わせると99%超の削減が実現できる。
コストと鮮度のトレードオフを「増分2日バッファ」で解消するのがECデータ基盤の実務解。
コストと鮮度のトレードオフを「増分2日バッファ」で解消するのがECデータ基盤の実務解。
on_schema_change='fail' で本番パイプラインを守ることも忘れずに。