C データエンジニアリング — 価格変更履歴SCD Type2 × point-in-time JOIN(dbt snapshotがtimestamp strategyだがソースDBの同一バッチ内updated_at不変で価格変更を検知できない × dbt_valid_to IS NULL(現在有効行)とBETWEENのNULL比較が常にUNKNOWNとなりJOIN漏れ × BETWEEN閉区間により境界時刻の注文が2つの価格履歴行に二重マッチ × require_partition_filter未設定でフルスキャンが可能 × product_idクラスタリング未設定 × カテゴリ別日別値引き効果サマリを3箇所が個別集計 × dbt testsがunique/not_nullのみでSCD区間の重複を検知できない)(MOps 販促システムチーム「セール価格が反映されない」「同じ注文が2重カウント」不整合 Bad→Good)

2026-09-09 水曜 水曜 C: データエンジニアリング ★★★★☆ BigQuery / dbt Core 1.9+ dbt snapshot / SCD Type2

概要

🕵️

変更検知はソースのタイムスタンプ設計に依存する

dbt snapshotのtimestamp strategyは万能ではない。ソースDBが同一バッチ内でupdated_atを変えない仕様なら、値そのものの変化を見るcheck strategyに切り替える必要がある。

🕳️

NULLはBETWEENでも常にUNKNOWN

dbt_valid_to IS NULL(現在有効な行)をBETWEENの範囲条件に使うとNULL比較になりJOINが不成立になる。番兵値(sentinel value)への置き換えが定石。

📏

区間結合は半開区間がデフォルト

BETWEENは両端を含む閉区間。SCDの隣接する2区間の境界時刻がどちらの区間に属するか曖昧になり二重マッチする。[開始, 終了)の半開区間で設計する。

💰

正しさとコストは独立した軸

パーティションフィルタ強制・クラスタリング・集計の事前計算はいずれも「同じ処理を高コストで繰り返す」ことへの対処であり、JOINロジックの正しさとは別に守る必要がある。

問題

ECサイト MOps 販促システムチームでは、商品の価格変更履歴(通常価格 ⇄ セール価格)を追跡し、「キャンペーン期間中の値引きが実際の注文にどれだけ寄与したか」を測定する基盤 mart_campaign_discount_effect を運用している。

商品マスタDB(product_master、updated_atカラムを持つ)から dbt snapshot(snapshot_strategy: timestamp)で snap_product_price_history を作成し、SCD Type2(dbt_valid_from / dbt_valid_to)として価格変更履歴を保持している。これとorders(注文明細、ordered_atを持つ)をpoint-in-time JOIN(「注文した瞬間に有効だった価格」を突き合わせるJOIN)し、注文時点の価格が通常価格かセール価格かを判定した上で、カテゴリ別・日別に値引き効果をLooker Studioダッシュボードで可視化している。dbt Core 1.9+ / BigQuery Standard SQL / Argo Workflows 3.5+で毎晩1回バッチ実行している。

先月起きた出来事: キャンペーン振り返りで、「一部商品でセール価格が反映されないまま通常価格の注文として集計されていた」「同じ注文が値引き効果の集計に2回カウントされていた」という2つの不整合が発覚し、調査したところ、以下の7つの設計・データ品質・コスト上の問題が見つかった。

問題点を全て洗い出し、dbt snapshotの変更検知方式の見直し(check strategy) × point-in-time JOINのNULL境界・重複区間バグの解消 × require_partition_filterによるフルスキャン防止 × product_idクラスタリング × BI Engine / Materialized Viewによるダッシュボードクエリの重複計算解消 × dbt singular testによるSCD区間整合性検証を活用したBad→Goodリファクタリングを行ってください。

制約・前提条件

  • BigQuery Standard SQL、dbt Core 1.9+(snapshot、singular test、Unit Testsがいずれも利用可能)、Argo Workflows 3.5+
  • product_master: product_id, price, is_on_sale(BOOLEAN), updated_at。同一バッチジョブ内で複数カラムを更新してもupdated_atが同一タイムスタンプになるソースDB仕様(アプリ側でトランザクション開始時刻を一括セットしている)
  • snap_product_price_history(dbt snapshot出力): product_id, price, is_on_sale, dbt_valid_from, dbt_valid_to(現在有効な行はdbt_valid_to IS NULL)
  • orders: order_id, product_id, ordered_at, quantity, order_amount
  • 現状のmart_campaign_discount_effectはo.product_id = p.product_id AND o.ordered_at BETWEEN p.dbt_valid_from AND p.dbt_valid_toでJOINしている
  • snap_product_price_history・ordersいずれもテーブルレベルでパーティションフィルタを必須化する設定が入っておらず、Looker Studioの自由記述フィルタ画面から日付条件なしでクエリを発行できてしまう
  • JOINキーであるproduct_idにクラスタリングが設定されていない
  • 「カテゴリ別・日別の値引き効果サマリ」は経営ダッシュボード・現場責任者ダッシュボード・週次レポート用バッチの3箇所が個別に同じ集計クエリを発行しており、キャッシュや事前計算の仕組みが無い
  • dbt testsはsnap_product_price_historyに対してunique(product_id, dbt_valid_from)とnot_null(product_id)のみで、同一product_idのSCD区間が互いに重ならないことを検証するテストが無い
期待する回答形式: 問題点の列挙(番号付き)+ 改善後dbt snapshot設定 + 改善後dbtモデル(point-in-time JOIN)+ 改善後BigQuery DDL(require_partition_filter・クラスタリング)+ 改善後ダッシュボード集計設計(BI Engine / Materialized View)+ dbt singular test(SCD区間整合性)+ 実行例(input→output)+ 設計意図の説明

悪いコード (Before)

この実装には 7つの設計・データ品質・コスト上の問題 が隠れています。見つけてみてください。
snapshots/snap_product_price_history.yml — timestamp strategy
snapshots:
  - name: snap_product_price_history
    relation: source('ecommerce', 'product_master')
    config:
      unique_key: product_id
      # 問題①: ソースのupdated_atが同一バッチ内で変化しない仕様なのに
      #        timestamp strategyのままで、価格変更が検知されない
      strategy: timestamp
      updated_at: updated_at
mart_campaign_discount_effect.sql — BETWEEN閉区間・NULL境界未処理
-- 問題②: dbt_valid_to IS NULL(現在有効行)に対しBETWEENで比較すると
--        NULLとの比較が常にUNKNOWNとなりJOIN不成立
-- 問題③: BETWEENは閉区間なので境界時刻ちょうどの注文が
--        隣接する2つの価格履歴行の両方にマッチし二重カウントされる
SELECT o.order_id, o.product_id, o.ordered_at,
       p.price, p.is_on_sale
FROM {{ ref('stg_orders') }} o
JOIN {{ ref('snap_product_price_history') }} p
  ON o.product_id = p.product_id
 AND o.ordered_at BETWEEN p.dbt_valid_from AND p.dbt_valid_to
問題点サマリー(7点)
4-7コスト・整合性層 — require_partition_filter未設定 / product_idクラスタリング無し / 3箇所が集計を個別実装 / SCD区間重複を検知するテストが無い

ヒント(段階的開示)

ヒント1 — 方向性
問題は3層に分かれる。(1) 変更検知の層——dbt snapshotのtimestamp strategyは「タイムスタンプが変わったこと」を根拠に変更を検知するため、ソース側のタイムスタンプ設計が壊れているとそもそも変更を捉えられない。(2) JOINロジックの層——BETWEENは両端を含む閉区間として評価される上、NULLとの比較は常にUNKNOWNになるという2つのSQLの基本性質を踏まえずにSCD区間とJOINすると、「現在有効な行が漏れる」「区間の境界で重複する」という正反対の2種類のバグが同時に起こり得る。(3) スキャン・コストの層——パーティションフィルタの強制・クラスタリング・集計の事前計算はいずれも「正しさ」とは独立した、繰り返し発生するコストを構造的に抑える対策である。
ヒント2 — アプローチ
  • 問題①: updated_atが同一バッチ内で変わらず変更検知できない → dbt snapshotのstrategy: checkに変更し、check_cols: [price, is_on_sale]で値そのものの変化を検知する
  • 問題②: dbt_valid_to IS NULLとBETWEENのNULL比較でJOIN漏れ → COALESCE(dbt_valid_to, TIMESTAMP('9999-12-31'))のような番兵値で置き換える
  • 問題③: BETWEEN閉区間で境界時刻の注文が二重マッチ → 半開区間[dbt_valid_from, dbt_valid_to)に変更する(>= AND <)
  • 問題④: パーティションフィルタ未強制でフルスキャン可能 → require_partition_filter=trueを設定する
  • 問題⑤: product_idクラスタリング未設定 → CLUSTER BY product_idを設定する
  • 問題⑥: 3箇所が同じ集計を個別発行 → 日次カテゴリ別サマリを事前計算するdbtモデルを1つ作り全員がそこを参照する
  • 問題⑦: SCD区間重複を検知するテストが無い → 同一product_id内で区間が重ならないことを検証するdbt singular testを追加する
ヒント3 — コードの骨格
-- point-in-time JOINの骨格(半開区間 + 番兵値)
WITH price_history AS (
  SELECT product_id, price, is_on_sale, dbt_valid_from,
    COALESCE(dbt_valid_to, TIMESTAMP('9999-12-31')) AS dbt_valid_to_sentinel
  FROM {{ ref('snap_product_price_history') }}
)
SELECT o.order_id, o.product_id, o.ordered_at, p.price, p.is_on_sale
FROM {{ ref('stg_orders') }} o
JOIN price_history p
  ON o.product_id = p.product_id
 AND o.ordered_at >= p.dbt_valid_from          -- 開始は含む
 AND o.ordered_at <  p.dbt_valid_to_sentinel   -- 終了は含まない
-- SCD区間整合性の singular test の骨格
SELECT product_id, dbt_valid_from
FROM {{ ref('snap_product_price_history') }} a
JOIN {{ ref('snap_product_price_history') }} b USING (product_id)
WHERE a.dbt_valid_from < b.dbt_valid_from
  AND a.dbt_valid_to > b.dbt_valid_from   -- 区間が重複

問題点分析(7点)

#問題点分類改善方法
1timestamp strategyでソースの変更を検知できない検知層strategy: check + check_cols
2NULLとBETWEENの比較でJOIN漏れ正しさ層COALESCEで番兵値に置換
3BETWEEN閉区間で境界時刻が二重マッチ正しさ層半開区間 [開始, 終了) へ変更
4require_partition_filter未設定コスト層require_partition_filter=true
5product_idクラスタリング未設定コスト層CLUSTER BY product_id
63箇所が同じ集計を個別実装コスト層事前計算テーブルへの一本化(BI Engine)
7SCD区間重複を検知するテストが無い回帰防止層dbt singular test

SCD区間比較図 — Bad vs Good の比較

Bad(変更前)— BETWEEN閉区間 + NULL境界未処理 時間軸 → 通常価格1000円(09/01〜09/05) セール価格800円(09/05〜NULL=現在) 09/05 00:00 問題③: 両区間にBETWEENでマッチ→二重カウント 問題②: NULLとのBETWEEN比較→JOIN不成立 09/10の注文 dbt snapshot(timestamp strategy) 問題①: 同一バッチ内でupdated_at不変→価格変更が未検知 商品Aの1000円→800円が反映されないまま集計 require_partition_filter未設定・クラスタリング無し 問題④⑤: 日付条件無しの自由記述クエリでフルスキャン product_id JOINのシャッフルコストも増大 経営/現場/週次バッチが個別に集計クエリ発行 問題⑥: 同じ計算を3箇所が重複実行しコスト&ズレリスク 問題⑦: SCD区間重複を検知するテストが無い × セール価格が反映されず通常価格として集計 × 境界時刻の注文が二重カウントされる Good(変更後)— 半開区間 + 番兵値で境界を一意化 時間軸 → 通常価格1000円 [09/01, 09/05) セール価格800円 [09/05, 9999-12-31) 09/05 00:00 修正③: 半開区間で新価格行のみにマッチ 修正②: 番兵値9999-12-31で現在有効行とJOIN成立 09/10の注文 dbt snapshot(check strategy + check_cols) 修正①: 値そのものの変化を検知し新しいsnapshot行を生成 1000円→800円が正しく履歴化される require_partition_filter=true + CLUSTER BY product_id 修正④⑤: 日付条件無しクエリをBigQuery側でエラー化 JOINのシャッフルコストも削減 事前計算テーブルへ一本化(BI Engine)+ singular test 修正⑥⑦: 3箇所が1つのテーブルを参照、区間重複はCIで検知 コストと数値の一貫性が両立する ✓ 半開区間+番兵値で境界が一意に決まる ✓ 正しさとコストと回帰防止が独立して守られる 修正

模範解答

Before — timestamp strategy
snapshots:
  - name: snap_product_price_history
    relation: source('ecommerce', 'product_master')
    config:
      unique_key: product_id
      strategy: timestamp
      updated_at: updated_at
      # 問題①: 同一バッチ内でupdated_atが変化しない仕様のため
      #        価格・is_on_sale変更を検知できない
After — check strategy(修正①)
snapshots:
  - name: snap_product_price_history
    relation: source('ecommerce', 'product_master')
    config:
      schema: snapshots
      unique_key: product_id
      # 修正①: updated_atが同一バッチ内で変化しない仕様のため、
      #        タイムスタンプではなく値そのものの変化で検知する
      strategy: check
      check_cols: [price, is_on_sale]
models/marts/mart_campaign_discount_effect.sql(修正②③: 番兵値 + 半開区間)
-- 修正②: dbt_valid_toがNULL(現在有効な行)を番兵値に置き換え、NULL比較によるJOIN漏れを防ぐ
-- 修正③: BETWEEN(閉区間)から半開区間([開始,終了))へ変更し、境界時刻の重複マッチを防ぐ

WITH price_history AS (
  SELECT
    product_id,
    price,
    is_on_sale,
    dbt_valid_from,
    -- 修正②: NULL(現在有効)を遠い未来の番兵値に置き換える
    COALESCE(dbt_valid_to, TIMESTAMP('9999-12-31')) AS dbt_valid_to_sentinel
  FROM {{ ref('snap_product_price_history') }}
),

orders_priced AS (
  SELECT
    o.order_id,
    o.product_id,
    o.ordered_at,
    o.quantity,
    o.order_amount,
    p.price AS price_at_order_time,
    p.is_on_sale AS was_on_sale
  FROM {{ ref('stg_orders') }} o
  JOIN price_history p
    ON o.product_id = p.product_id
   -- 修正③: 開始時刻は含み(>=)、終了時刻は含まない(<)半開区間にすることで
   --        隣接する2つの履歴行の境界時刻ちょうどの注文が二重にマッチしないようにする
   AND o.ordered_at >= p.dbt_valid_from
   AND o.ordered_at <  p.dbt_valid_to_sentinel
)

SELECT * FROM orders_priced
Before — フィルタ強制無し・クラスタリング無し
CREATE TABLE `mops-project.snapshots.snap_product_price_history`
(
  product_id STRING NOT NULL,
  price NUMERIC NOT NULL,
  is_on_sale BOOL NOT NULL,
  dbt_valid_from TIMESTAMP NOT NULL,
  dbt_valid_to TIMESTAMP
)
PARTITION BY DATE(dbt_valid_from);
-- 問題④⑤: require_partition_filter無し、CLUSTER BY無し
After — フィルタ強制 + クラスタリング(修正④⑤)
-- 修正④: パーティションフィルタなしのクエリをBigQuery側でエラーにし、
--        意図しないフルスキャンを構造的に防ぐ
-- 修正⑤: product_idでのJOINが主要アクセスパターンのためクラスタリングを設定
CREATE TABLE IF NOT EXISTS `mops-project.snapshots.snap_product_price_history`
(
  product_id STRING NOT NULL,
  price NUMERIC NOT NULL,
  is_on_sale BOOL NOT NULL,
  dbt_valid_from TIMESTAMP NOT NULL,
  dbt_valid_to TIMESTAMP
)
PARTITION BY DATE(dbt_valid_from)
CLUSTER BY product_id
OPTIONS (require_partition_filter = true);   -- 修正④

CREATE TABLE IF NOT EXISTS `mops-project.raw_events.orders`
(
  order_id STRING NOT NULL,
  product_id STRING NOT NULL,
  ordered_at TIMESTAMP NOT NULL,
  quantity INT64,
  order_amount NUMERIC
)
PARTITION BY DATE(ordered_at)
CLUSTER BY product_id
OPTIONS (require_partition_filter = true);   -- 修正④
models/marts/mart_campaign_discount_effect_daily_summary.sql(修正⑥: 集計一本化)
-- 修正⑥: 経営ダッシュボード・現場ダッシュボード・週次バッチの3箇所が
--        個別に発行していた集計を1つのdbtモデルに集約し、全員がここを参照する。
-- BI Engineの予約容量を有効化しておくことで、このテーブルへの
-- 繰り返しクエリはインメモリで高速応答され、追加のスキャン課金も発生しにくくなる。
{{ config(materialized='table', partition_by={'field': 'order_date', 'data_type': 'date'}, cluster_by=['category']) }}

SELECT
  DATE(o.ordered_at) AS order_date,
  c.category,
  o.was_on_sale,
  COUNT(*) AS order_count,
  SUM(o.order_amount) AS total_amount,
  AVG(o.order_amount) AS avg_order_amount   -- 客単価
FROM {{ ref('mart_campaign_discount_effect') }} o
JOIN {{ ref('dim_product') }} c USING (product_id)
GROUP BY order_date, category, was_on_sale
3箇所のダッシュボード/バッチが参照するクエリ
-- 以後この事前計算済みテーブルのみを参照する(require_partition_filter適用)
SELECT category, was_on_sale, SUM(total_amount) AS total_amount
FROM `mops-project.marts.mart_campaign_discount_effect_daily_summary`
WHERE order_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
GROUP BY category, was_on_sale
tests/assert_price_history_no_overlapping_intervals.sql(修正⑦)
-- 修正⑦: 同一product_id内でSCD区間が重複していないことを検証する。
--        1行でも返れば test failure(=区間の重なりがある)。
SELECT
  a.product_id,
  a.dbt_valid_from AS interval_a_start,
  b.dbt_valid_from AS interval_b_start
FROM {{ ref('snap_product_price_history') }} a
JOIN {{ ref('snap_product_price_history') }} b
  ON a.product_id = b.product_id
 AND a.dbt_valid_from < b.dbt_valid_from
WHERE a.dbt_valid_to IS NOT NULL          -- 現在有効な行(NULL)は比較対象から除外
  AND a.dbt_valid_to > b.dbt_valid_from   -- 前の区間の終了が次の区間の開始より後ろ=重複
問題修正内容効果
① timestamp strategyで変更検知漏れstrategy: check + check_cols値そのものの変化で価格変更を検知
② NULLとBETWEENの比較でJOIN漏れCOALESCEで9999-12-31の番兵値に置換現在有効な行への注文が正しくJOINされる
③ BETWEEN閉区間で境界二重マッチ半開区間 [開始, 終了) へ変更境界時刻の注文が一意の区間にのみマッチ
④ require_partition_filter未設定OPTIONS(require_partition_filter=true)フィルタ漏れクエリをエラー化
⑤ クラスタリング未設定CLUSTER BY product_idJOINのシャッフルコスト削減
⑥ 3箇所が個別集計事前計算テーブルへの一本化(BI Engine)コスト削減&数値の一貫性確保
⑦ SCD区間重複を検知するテストが無いdbt singular test区間重複の混入をCIで検知

実行例(input → output)

input(改善前のシナリオ):

商品Aは同一バッチジョブ内でpriceが1,000円→800円に変わったがupdated_atは変化しなかった。また商品Bは価格変更履歴がdbt_valid_from=2026-09-01 00:00:00→dbt_valid_to=2026-09-05 00:00:00(旧価格)、dbt_valid_from=2026-09-05 00:00:00→dbt_valid_to=NULL(新価格)という2行を持つ。ordered_at=2026-09-05 00:00:00(境界ちょうど)の注文と、ordered_at=2026-09-10(現在有効な行が対象)の注文が存在する。

output(Before・問題のある実装):

商品Aはsnap_product_price_historyに新しい行が作られず、800円の注文が1,000円時点の価格として集計され続ける。商品Bの2026-09-05 00:00:00ちょうどの注文はBETWEENの閉区間により旧価格行・新価格行の両方にマッチし2重カウントされる。2026-09-10の注文はdbt_valid_to IS NULLとのBETWEEN評価がUNKNOWNになりJOIN結果から消える。

output(After・改善後):

check strategyにより商品Aの価格変更(1,000円→800円)が正しく新しいsnapshot行として記録される。商品Bの2026-09-05 00:00:00の注文は半開区間[dbt_valid_from, dbt_valid_to_sentinel)により新価格行のみにマッチし2重カウントされない。2026-09-10の注文は番兵値9999-12-31により現在有効な行と正しくJOINされる。singular testをdbt buildに含めることで、今後同様の区間重複がSCDデータに混入した場合はCIで検知される。

ポイント解説

1SCDの変更検知は「ソースのタイムスタンプ設計」に依存する — timestamp strategyは万能ではなく、アプリ側のタイムスタンプ更新仕様が壊れている場合はcheck strategyで値そのものの変化を見る方が確実。
2NULLはBETWEENを含むあらゆる比較演算子でUNKNOWNになる — 「現在有効な行」を表すNULLを持つカラムを範囲JOINの条件に使う場合は、番兵値への置き換えが定石。
3区間結合は半開区間で設計するのがデフォルト — 閉区間(BETWEEN)は境界時刻がどちらの区間に属するかが曖昧になり重複の温床になる。SCDやイベント期間の結合は[開始, 終了)の半開区間を基本形にする。
4パーティションフィルタの強制はコスト対策であると同時に事故防止策でもある — require_partition_filter=trueは「うっかり全期間スキャンする」というヒューマンエラーをSQL文法レベルで防げる。
5同じ集計を複数箇所が個別実装すると、コストだけでなく「実装がズレて数値が食い違う」リスクも生む — 事前計算テーブルへの一本化は、コスト最適化と「Single Source of Truth」の両方を同時に満たす。
6uniqueテストは「区間の重複」までは検証できない — SCDテーブルの整合性は、開始時刻の一意性だけでなく区間同士が重ならないことを別途singular testで検証する必要がある。

実務への応用

MOpsチームが扱う「価格変更履歴 × 注文」のようなSCD Type2×point-in-time JOINの構造は、価格に限らず会員ランク・配送料率・クーポン適用条件など「ある時点で有効だった設定値」を後から突き合わせるあらゆる分析基盤で繰り返し登場する。今回のNULL番兵値化・半開区間化は、dbt snapshotを使うプロジェクトで再利用できる汎用パターンであり、既存の他のSCDモデル(会員ランク履歴など)に同じ観点でレビューをかけることで、同種のバグを未然に洗い出せる。

SCDの区間結合は「境界の扱い」を最初に決める設計判断: BETWEENをそのまま使うのではなく、半開区間・番兵値という2つの決め事を最初に固定しておけば、以後同種のpoint-in-time JOINを書くたびに同じ落とし穴を踏まずに済む。

今日のまとめ

SCD Type2を使ったpoint-in-time JOINは、ソース側のタイムスタンプ設計・NULL比較の性質・閉区間と半開区間の違いという3つの落とし穴が重なりやすく、それぞれ独立した原因で「値が反映されない」「二重カウントされる」という正反対の不整合を同時に引き起こし得る。 コスト面のパーティションフィルタ強制・クラスタリング・集計の一本化とあわせて、正しさとコストの両輪でSCD設計をレビューする必要がある。

次のステップ

  • 発展問題: 今回は価格が1系統だが、「地域別価格」「会員ランク別価格」のように同時に複数のSCDディメンションを持つ場合、point-in-time JOINはどう設計を変えるべきか(複合キーでのSCD管理・区間整合性テストの拡張)を設計せよ。
  • 参考: dbt snapshot strategy(timestamp / check) / SCD Type2 point-in-time join / BigQuery require_partition_filter / BI Engine / dbt singular test

自己評価(あとで記入)