概要
Partition Pruning
WHERE 句のパーティション列に CAST/関数 を適用すると pruning が無効化される。STRING の order_date は staging で PARSE_DATE し、下流は変換済み DATE 列を WHERE する。
Incremental insert_overwrite
パーティション単位で置換する戦略。遅延データ対応(3日分再計算)と低スキャンを両立。merge よりスキャン量が少なく、大規模ファクトテーブルの標準。
Cluster by 設計
campaign_name / coupon_code でクラスタリングするとパーティション内ブロック単位の pruning が効く。Looker Studio のフィルタが多いほど効果が大きい。
ダッシュボード参照面の分離
30日固定窓の小テーブル(数MB)を mart 層に作り、Looker Studio はここを参照。raw を直接参照させないことで 1回 1.4TB → 50MB に圧縮。
問題
Looker Studio に表示される「クーポン施策別 直近30日売上ダッシュボード」が、1回更新あたり 1.4TB スキャン・月額約 $21,000 という非常に高コストな状態。レポート表示まで平均45秒かかり苦情多発。dbt モデルを再設計してコスト99%削減+応答1秒以下を実現せよ。
制約・前提条件
- BigQuery(リージョン:
asia-northeast1)、dbt Core 1.8+ raw.orders.order_dateはSTRING('YYYY-MM-DD' 形式)で保存されている- 上流の
raw.ordersテーブル定義は変更できない(外部システムが書き込み) - 営業時間中(JST 9:00〜21:00)のダッシュボード遅延ゼロが必須
- 目標: 1回スキャン 100MB 以下・月額コスト $1,000 以下
期待する回答形式: 改善後の dbt モデル全文(staging + intermediate + marts)+ 各変更点の説明(番号付き)+ 月額コスト試算(Before / After)
悪いコード (Before)
5つの問題点が潜んでいます。見つけてください。
❌ mart_coupon_daily_sales.sql(現状)
-- ❌ materialized='view' で毎回再計算
{{ config(materialized='view') }}
SELECT
o.order_date,
c.coupon_code,
c.campaign_name,
SUM(oi.quantity * oi.unit_price) AS gross_sales,
SUM(oi.quantity * oi.unit_price
- oi.discount_amount) AS net_sales,
COUNT(DISTINCT o.order_id) AS order_count,
COUNT(DISTINCT o.user_id) AS unique_users
FROM `prj-prod.raw.orders` o -- 350GB
LEFT JOIN `prj-prod.raw.order_items` oi
ON o.order_id = oi.order_id -- 1.2TB
LEFT JOIN `prj-prod.raw.coupons` c
ON o.coupon_id = c.coupon_id
-- ❌ CAST(order_date AS DATE) で
-- partition pruning が無効化
WHERE CAST(o.order_date AS DATE)
>= DATE_SUB(CURRENT_DATE('Asia/Tokyo'),
INTERVAL 30 DAY)
GROUP BY 1, 2, 3
-- ❌ ビュー内 ORDER BY は呼び出し側で
-- 再ソートされ無意味かつ高コスト
ORDER BY o.order_date DESC,
gross_sales DESC
❌ Looker Studio カスタムクエリ
-- ❌ ビュー越しに raw を毎回フルスキャン
-- ❌ WHERE order_date 指定がないため
-- 上流の 30日フィルタしか効かず
-- 実質 350GB + 1.2TB を毎回読む
SELECT *
FROM `prj-prod.marts.mart_coupon_daily_sales`
WHERE campaign_name = @campaign
-- 結果:
-- 1回スキャン: 1.4 TB
-- 料金: $7 / 回
-- 1日 100 回 × 30 日 = $21,000 / 月
-- 表示時間: 平均 45 秒
ヒント(段階的開示)
ヒント1 — 方向性
問題は3カテゴリ: (a) パーティション枝刈りが効かない型の不一致(STRING の order_date を CAST で DATE 化)、(b) 集計の再計算ロス(view で毎回ゼロから再集計)、(c) ダッシュボードが raw を直接参照。dbt の3層設計(staging → intermediate → marts)で責務を分離するのが基本。
ヒント2 — アプローチ(構成要素)
- staging 層:
PARSE_DATE('%Y-%m-%d', order_date)で DATE 化し、partition_byで再パーティション化 - incremental 戦略:
insert_overwrite+is_incremental()で「過去3日分のみ再計算」 - cluster_by:
campaign_name, coupon_codeでブロック単位の pruning を効かせる - ORDER BY 削除: ビュー定義の ORDER BY は再実行されるため削除。ソートはダッシュボード側
- 30日窓テーブル:
materialized='table'で数MB の小テーブルを作り、Looker Studio はこれを参照
ヒント3 — incremental + insert_overwrite の骨格
{{ config(
materialized='incremental',
incremental_strategy='insert_overwrite',
partition_by={
'field': 'order_date',
'data_type': 'date',
'granularity': 'day'
},
cluster_by=['campaign_name', 'coupon_code']
) }}
SELECT
order_date, coupon_code, campaign_name,
SUM(gross_sales) AS gross_sales,
...
FROM {{ ref('int_coupon_orders_joined') }}
{% if is_incremental() %}
WHERE order_date
>= DATE_SUB(CURRENT_DATE('Asia/Tokyo'),
INTERVAL 3 DAY)
{% endif %}
GROUP BY order_date, coupon_code, campaign_name
ポイント: WHERE order_date は CAST なしで書く(partition pruning を効かせる)。insert_overwrite は3日分のパーティションを丸ごと置換するため、遅延データの修正も自動反映。
問題点分析
| # | 問題点 | 影響 | 改善方法 |
|---|---|---|---|
| 1 | order_date が STRING 型のまま CAST(... AS DATE) で比較 |
partition pruning が無効化され、フルスキャン(350GB + 1.2TB)が走る | staging 層で PARSE_DATE し、DATE 型で partition_by 再パーティション化 |
| 2 | materialized='view' で毎リクエスト再集計 |
1日100回のダッシュボード更新が毎回 1.4TB スキャン → $21,000/月 | incremental + insert_overwrite で差分のみ更新 |
| 3 | ビュー定義に ORDER BY |
呼び出し側で SELECT する際に再ソートされ無意味、かつソートコストが上乗せ | ビューから ORDER BY を削除。ソートはダッシュボード側で実行 |
| 4 | クラスタリング未設定 | campaign_name フィルタ時もパーティション全体を読む |
cluster_by=['campaign_name', 'coupon_code'] でブロック pruning |
| 5 | Looker Studio が大きい mart を直接参照(30日窓を毎回スキャン) | フィルタ変更ごとに mart 全期間(数百GB)に当たる | 30日固定窓の小テーブル mart_coupon_sales_last30d(数MB)を分離 |
アーキテクチャ図 — Before(view 1段)vs After(3層 + incremental)
模範解答 — dbt モデル群
1. staging層: stg_orders.sql
{{ config(
materialized='incremental',
incremental_strategy='insert_overwrite',
partition_by={
'field': 'order_date',
'data_type': 'date',
'granularity': 'day'
},
cluster_by=['user_id', 'coupon_id'],
on_schema_change='append_new_columns'
) }}
-- 目的: raw.orders(STRING型 order_date)を DATE 型に正規化し、
-- partition pruning が効く形に再パーティション化する
SELECT
PARSE_DATE('%Y-%m-%d', order_date) AS order_date, -- ✅ STRING → DATE
order_id,
user_id,
coupon_id,
order_status,
created_at
FROM {{ source('raw', 'orders') }}
{% if is_incremental() %}
-- 遅延データ対応で3日分を再計算(insert_overwrite で重複排除)
WHERE PARSE_DATE('%Y-%m-%d', order_date)
>= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 3 DAY)
{% endif %}
2. staging層: stg_order_items.sql
{{ config(
materialized='incremental',
incremental_strategy='insert_overwrite',
partition_by={'field': 'order_date', 'data_type': 'date', 'granularity': 'day'},
cluster_by=['order_id']
) }}
-- order_items を order_date でパーティション化(結合時の pruning 用)
SELECT
oi.order_id,
PARSE_DATE('%Y-%m-%d', o.order_date) AS order_date,
oi.product_id,
oi.quantity,
oi.unit_price,
oi.discount_amount
FROM {{ source('raw', 'order_items') }} oi
INNER JOIN {{ source('raw', 'orders') }} o USING (order_id)
{% if is_incremental() %}
WHERE PARSE_DATE('%Y-%m-%d', o.order_date)
>= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 3 DAY)
{% endif %}
3. intermediate層: int_coupon_orders_joined.sql
{{ config(
materialized='incremental',
incremental_strategy='insert_overwrite',
partition_by={'field': 'order_date', 'data_type': 'date', 'granularity': 'day'},
cluster_by=['coupon_code', 'campaign_name']
) }}
-- 目的: orders × order_items × coupons を結合して mart 用の事前集計を作る
WITH orders AS (
SELECT * FROM {{ ref('stg_orders') }}
{% if is_incremental() %}
WHERE order_date >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 3 DAY)
{% endif %}
),
items AS (
SELECT * FROM {{ ref('stg_order_items') }}
{% if is_incremental() %}
WHERE order_date >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 3 DAY)
{% endif %}
),
coupons AS (
-- 50万行のディメンション。マスタなので JOIN 全件 OK
SELECT coupon_id, coupon_code, campaign_name
FROM {{ source('raw', 'coupons') }}
)
SELECT
o.order_date,
o.order_id,
o.user_id,
c.coupon_code,
c.campaign_name,
i.quantity * i.unit_price AS gross_sales,
i.quantity * i.unit_price - i.discount_amount AS net_sales
FROM orders o
-- ✅ パーティション列を結合キーに含めて pruning 強化
INNER JOIN items i USING (order_id, order_date)
LEFT JOIN coupons c USING (coupon_id)
4. mart層(日次累積): mart_coupon_daily_sales.sql
{{ config(
materialized='incremental',
incremental_strategy='insert_overwrite',
partition_by={'field': 'order_date', 'data_type': 'date', 'granularity': 'day'},
cluster_by=['campaign_name', 'coupon_code']
-- ✅ ORDER BY は削除。ソートはダッシュボード側で行う
) }}
-- 目的: クーポン×日別の売上を事前集計。過去全期間を保持。
SELECT
order_date,
coupon_code,
campaign_name,
SUM(gross_sales) AS gross_sales,
SUM(net_sales) AS net_sales,
COUNT(DISTINCT order_id) AS order_count,
COUNT(DISTINCT user_id) AS unique_users
FROM {{ ref('int_coupon_orders_joined') }}
{% if is_incremental() %}
WHERE order_date >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 3 DAY)
{% endif %}
GROUP BY order_date, coupon_code, campaign_name
5. ダッシュボード参照面: mart_coupon_sales_last30d.sql
{{ config(
materialized='table', -- ✅ 30日窓の小テーブル(数MB)。毎日 dbt run で再生成
cluster_by=['campaign_name', 'coupon_code']
) }}
-- 目的: Looker Studio が直接参照する固定窓テーブル。
-- mart_coupon_daily_sales から直近30日のみを切り出す。
SELECT
order_date,
coupon_code,
campaign_name,
gross_sales,
net_sales,
order_count,
unique_users
FROM {{ ref('mart_coupon_daily_sales') }}
WHERE order_date >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 30 DAY)
AND order_date < CURRENT_DATE('Asia/Tokyo')
6. Looker Studio 側のクエリ(改善後)
-- ✅ 数MBの小テーブルを参照。
-- ✅ cluster_by が効いて campaign_name フィルタ時のスキャン量がさらに削減
SELECT *
FROM `prj-prod.marts.mart_coupon_sales_last30d`
WHERE campaign_name = @campaign
月額コスト試算(Before / After)
| 項目 | Before | After | 削減率 |
|---|---|---|---|
| 1回あたりスキャン量 | 1.4 TB | 50 MB | 99.996% |
| 1回あたり料金 | $7.00 | $0.00025 | — |
| 1日 100回 × 30日 | $21,000 | $0.75 | — |
| dbt run 日次バッチ追加 | — | 約 $30 / 月 | — |
| 合計月額 | 約 $21,000 | 約 $31 | 99.85% |
| ダッシュボード表示時間 | 平均 45秒 | 1秒以下 | — |
※ BigQuery オンデマンド料金 $5/TB として試算。リージョン: asia-northeast1。
ポイント解説
1. パーティション枝刈りが効く条件
WHERE 句のパーティション列に
WHERE 句のパーティション列に
CAST/関数 を適用しないこと。WHERE CAST(order_date AS DATE) >= ... は pruning が無効化される。元データが STRING の場合は staging で型変換し、下流は変換済み列を WHERE する。BigQuery のクエリプランで「Slot ms + Bytes processed」を確認するのが鉄則。
2. incremental 戦略の選び方
insert_overwrite: パーティション単位で置換。partition pruning が効くためスキャン量が少ない。遅延データ対応に最適merge: 主キー単位でマージ。柔軟だがフルスキャンが必要なため高コストappend: 単純追記。重複排除なし
insert_overwrite 一択。
3. clustering の効果
パーティション内でブロック(数MB単位)にデータを並べ替える。WHERE 句にクラスタ列を含めると、不要なブロックを読み飛ばす(partition 内 pruning)。
パーティション内でブロック(数MB単位)にデータを並べ替える。WHERE 句にクラスタ列を含めると、不要なブロックを読み飛ばす(partition 内 pruning)。
campaign_name のような中カーディナリティ列に有効。最大4列まで、左から順に効く。
4. ビュー / materialized view / table の使い分け
view: 毎回再計算。raw に近い層で使うmaterialized view: BQ が自動増分更新(制約: JOIN 数限定、集計のみ等)table: dbt run のたびに再生成。事前集計の小テーブルに最適incremental table: 差分更新。大規模ファクトテーブルの標準
5. ダッシュボードのコストパターン
ダッシュボードは「1日数百〜数千回クエリ」なので、1回あたりのスキャン量を MB 単位に抑えることが最重要。raw を直接参照させない・materialized 層を必ず挟むのが鉄則。Looker Studio のキャッシュ設定(12時間)も併用するとさらに低コスト化できる。
ダッシュボードは「1日数百〜数千回クエリ」なので、1回あたりのスキャン量を MB 単位に抑えることが最重要。raw を直接参照させない・materialized 層を必ず挟むのが鉄則。Looker Studio のキャッシュ設定(12時間)も併用するとさらに低コスト化できる。
実務への応用
- 既存の Looker Studio ダッシュボードを棚卸しし、raw を直接参照しているものを mart に差し替える(コスト削減効果が大きい順)
- BigQuery の
INFORMATION_SCHEMA.JOBS_BY_PROJECTを SUM して、過去30日のクエリコスト Top10 を抽出 → 改善対象を優先順位付け - DataDog の BigQuery インテグレーションで
bigquery.query.total_bytes_billedをユーザー別にダッシュボード化し、コスト所有者を明確化 - dbt の
--full-refreshフラグはコストが膨大になるので 本番では原則禁止。スキーマ変更時のみ手動実行 partition_byを後から付けるのは難しい(CREATE TABLE 時のみ設定可能)ので、新規モデルは必ず初回から付ける
今日のまとめ
BigQuery のコスト削減は (1) パーティション列を CAST/関数なしで WHERE する、(2) incremental で差分更新する、(3) ダッシュボード参照面を小テーブル化する、(4) cluster_by で頻出フィルタを最適化する の4点が基本。
raw を直接ダッシュボードから参照させない 3層設計(staging → intermediate → marts)が、99% コスト削減と 45秒→1秒 の応答時間を同時に実現する。
raw を直接ダッシュボードから参照させない 3層設計(staging → intermediate → marts)が、99% コスト削減と 45秒→1秒 の応答時間を同時に実現する。