データエンジニアリング — BigQuery コスト99%削減 × dbt incremental × partition pruning × clustering

2026-05-20 (Day 46) 水曜 C: データエンジニアリング ★★★☆☆ BigQuery / dbt Core 1.8+ incremental insert_overwrite

概要

📅

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_dateSTRING('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)

Before: view 1段(毎回1.4TBスキャン) raw.orders STRING型 / 350GB raw.order_items 1.2TB / pruning不可 mart_coupon_daily_sales (VIEW) CAST(order_date AS DATE) → pruning無効 ORDER BY 入り(毎回ソート) 1.4TB / リクエスト Looker Studio 1日100回 × $7 = $21,000/月 表示時間: 平均 45秒 月額 $21,000 表示45秒 / 苦情多発 After: 3層 + incremental(50MBスキャン) raw.orders / order_items 変更不可(外部書き込み) stg_orders / stg_order_items PARSE_DATE → DATE型 + partition_by int_coupon_orders_joined incremental insert_overwrite / 3日分 mart_coupon_daily_sales incremental + cluster_by(campaign_name) mart_coupon_sales_last30d (TABLE) 30日固定窓 / 数MB / Looker参照面 50MB / リクエスト 月額 $31(99.85%削減) 表示時間 1秒以下

模範解答 — 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)

項目BeforeAfter削減率
1回あたりスキャン量1.4 TB50 MB99.996%
1回あたり料金$7.00$0.00025
1日 100回 × 30日$21,000$0.75
dbt run 日次バッチ追加約 $30 / 月
合計月額約 $21,000約 $3199.85%
ダッシュボード表示時間平均 45秒1秒以下

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

ポイント解説

1. パーティション枝刈りが効く条件
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: 単純追記。重複排除なし
ECサイトの注文データのように「過去数日のデータが修正される」ケースでは insert_overwrite 一択。
3. clustering の効果
パーティション内でブロック(数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時間)も併用するとさらに低コスト化できる。

実務への応用

  • 既存の 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秒 の応答時間を同時に実現する。

自己評価

自分の回答

気づき・メモ