弱点補強 — BigQuery クエリコスト最適化 × dbt Materialization

2026-05-10 (Day 27) 日曜弱点補強 ★★★☆☆ C: データエンジニアリング × E: コスト試算 BigQuery / dbt Incremental

概要

🔪

パーティションプルーニング

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日のみスキャン
案Bdbt の 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ヶ月目以降はほぼゼロに近い。

コスト削減 ビジュアライゼーション

月間スキャン量の比較(TB) 0 7.5 12 15 TB 15 TB 現状 $93.75 5.4 TB 案A(パーティション) $33.75(▲64%) 0.06 TB 案B(Incremental) $0.375(▲99.6%) ▲64% ▲99.6% 現状(フルスキャン) 案A 案B(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 は WHERE DATE(ordered_at) >= ... でパーティションプルーニングが自動的に動作する。ただし TIMESTAMP 型には DATE() ラップが必要(型を合わせないとフルスキャンになる)。
2 Incremental の 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日)に再利用できる(関心の分離)。
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データ基盤の実務解。on_schema_change='fail' で本番パイプラインを守ることも忘れずに。

自己評価

自分の回答

気づき・メモ