増分処理とスナップショット ― 「過去の状態」を保つ
大規模データを毎回フル再構築するのは非効率です。incremental modelsで差分だけ更新し、snapshots(SCD Type 2)で会員ランクや購読状態の履歴を保持します。「今の状態」しか持たないと因果・churn・コホート分析ができなくなる——過去の状態を保つことが下流の前提になることを架空のD2C企業SOLNAで示します。
by Shin
データが増え続けるなかで、効率と「過去の状態」をどう両立するか——整える層の運用フェーズで最初にぶつかる問いです。答えは二つの道具に分かれます。増え続けるイベントは incremental モデルで差分だけ更新し、上書きで消えてしまう状態(会員ランク・購読ステータス)は snapshot(SCD Type 2)で履歴として残す。この記事では、過去の受託案件をもとに設計した架空のD2C企業SOLNAのローデータを例に両者の設計を示し、なぜ「今の状態しか持たないマート」が churn・コホート・因果の手前で行き止まりになるのかを具体化します。
扱うのは速度の話だけではありません。本題はむしろ時間です。第5回で組んだマートは「いま」を正しく映しますが、上流のシステムが値を上書きする限り、「あのとき会員ランクは何だったか」は静かに失われていきます。下流の因果・churn・コホートは、その失われた過去を前提にします。整える層が過去を保持できているかどうかが、下流でできることの上限を決めます。
フル再構築の限界と増分処理(incremental)
デフォルトのテーブルは、dbt run のたびに全行を作り直します(フル再構築)。sessions や orders のように増え続けるイベントテーブルでは、これが効かなくなります。データが数千万行に達すると、毎回の再構築でウェアハウスの計算時間と費用がかさみ、パイプラインの実行時間そのものがボトルネックになるからです。
増分処理(incremental)は、前回以降に到着した行だけを処理します。SOLNAの sessions(数千万行規模)で計測すると、フル再構築は数分〜十数分かかっていたものが、増分実行では数十秒〜1分未満に収まりました(いずれもN=1の目安、レンジで丸めています)。桁が変わるのは「処理する行数が、累積ではなく1日分の増分になる」からで、これは設計の効果であって魔法ではありません。
-- models/staging/stg_ga4__sessions.sql (最小構成)
{{ config(
materialized = 'incremental',
unique_key = 'session_id',
incremental_strategy = 'merge',
on_schema_change = 'append_new_columns'
) }}
select
session_id,
ga_client_id,
customer_id,
session_start,
channel,
pageviews,
engaged
from {{ source('ga4', 'sessions') }}
{% if is_incremental() %}
-- 直近に到着した行だけを処理。遅延データ用に少し過去まで遡る
where session_start >= (
select dateadd('day', -3, max(session_start)) from {{ this }}
)
{% endif %}ここで効いているのは二つです。is_incremental() は「初回・--full-refresh 時は無視され、通常の増分実行時だけ where 句を効かせる」分岐です。そして unique_key と incremental_strategy='merge' の組み合わせが冪等性を担保します。同じ行を二度処理しても、session_id が一致する既存行は重複追加されずに更新(マージ)されるので、再実行が安全になります。
merge と insert_overwrite の使い分け
incremental_strategy の主な選択肢は二つで、「行が後から変わるか・遅延して届くか」と「パーティション単位で丸ごと作り直せるか」で選びます。
| ソースの性質 | 戦略 | 動き方 |
|---|---|---|
| 日付パーティション単位で丸ごと作り直せる(GA4 の日次エクスポート等) | insert_overwrite | 対象期間のパーティションを上書き。遅延・修正をパーティションごと取り込める。unique_key 不要 |
| 行単位の一意キーで突き合わせたい(パーティション全体の作り直しは過剰) | merge | unique_key が一致する行だけを更新。重複を吸収して冪等 |
上の最小構成が merge なのは session_id という行単位のキーがあるからです。逆に、遅延データがパーティション単位でまとめて修正されるソース——GA4 の日次エクスポートが典型——では、日付パーティションを丸ごと作り直す insert_overwrite の方が素直です。
lookback の日数は「そのソースのデータが確定するまでの日数」から逆算します。GA4 エクスポートを扱う実案件での目安を二つ挙げると、GA4 のエクスポートは 24〜48 時間遅れて確定するので 3日。広告データは CV の遡及修正が数週間単位で続くので、より長い 7日 を確保します。lookback を伸ばすほど毎回の処理量は増えるため、「確定までの日数」と処理コストの釣り合いで決める——「なんとなく数日」ではなく、日数に理由を持たせるのが要点です。
dbt State ― 「実行自体を飛ばす」別レイヤーの答え
フル再構築の限界への答えは、モデルの内側だけにあるわけではありません。2026年6月に登場した dbt State は、ロジックが変わっていないモデルの実行そのものを飛ばす仕組みです。従来 dbt platform 側に限られていた state-aware orchestration の後継で、dbt Core / Fusion の両方・ローカルでも任意のオーケストレータでも動きます(単体の有償サービスで、dbt platform 全体を採用する必要はありません)。incremental が「モデルの内側で処理を差分化する」のに対し、State は「モデルの実行自体をスキップする」——別レイヤーの答えであり、両方を併用できます。本記事の主題は前者なので深入りしませんが、「フル再構築が重い」に対する公式の回答が一段増えたことは押さえておいてください。
スナップショット(SCD Type 2)で履歴を保つ
増分が「行が増える」問題への答えなら、スナップショットは「値が上書きされる」問題への答えです。SOLNAの customers.membership_tier(会員ランク)や subscriptions.status(active | paused | cancelled)は、上流のシステムが同じ行を上書きします。silver だった会員が gold に上がると、ソース上では gold に書き換わり、silver だった事実は消えます。第5回のマートをこのソースから作り続ける限り、マートは常に「いま」しか映しません。
スナップショット(SCD Type 2)は、値が変わるたびに新しい行を足し、古い行に「いつまで有効だったか」を刻みます。dbt は dbt_valid_from ・ dbt_valid_to ・ dbt_scd_id を自動で付与します。
# snapshots/snap_customers.yml (最小構成)
snapshots:
- name: snap_customers
relation: source('crm', 'customers')
config:
schema: snapshots
unique_key: customer_id
strategy: check
check_cols:
- membership_tier
- churned_flag現行の dbt(v1.9 以降)では、snapshot はこのように YAML で定義するのが標準です(かつての {% snapshot %} SQL ブロック+ target_schema は旧記法)。列の絞り込みや整形が必要な場合は staging モデルを作り、relation に ref('...') を指します。
戦略は二つあります。信頼できる更新時刻列があれば timestamp 戦略、無ければ check 戦略で指定列の変化を比較します。membership_tier や status にはきれいな更新時刻が無いことが多いので、ここでは check_cols で監視する check 戦略を採りました。出力は、同じ会員が複数行で並び、それぞれが有効期間を持つ形になります。
| customer_id | membership_tier | dbt_valid_from | dbt_valid_to |
|---|---|---|---|
| C-1042 | silver | 2025-01-10 | 2025-06-22 |
| C-1042 | gold | 2025-06-22 | NULL(現在も有効) |
この履歴があれば、下流は「ある時点のランク」を引けます。注文時点の会員ランクを取り出す結合は次のようになります。
-- 「注文した時点の会員ランク」を引く(point-in-time 結合・最小構成)
select
o.order_id,
o.customer_id,
o.order_at,
s.membership_tier as tier_at_order
from {{ ref('fct_orders') }} as o
left join {{ ref('snap_customers') }} as s
on o.customer_id = s.customer_id
and o.order_at >= s.dbt_valid_from
and o.order_at < coalesce(s.dbt_valid_to, '2999-01-01')dbt_valid_from と dbt_valid_to の区間に注文日を挟むだけで、「その注文の時点ではどのランクだったか」が一意に決まります。現在のランクで塗りつぶさず、過去の状態をそのまま再現できる——これがスナップショットの実利です。
なぜ「過去の状態」が下流の前提か
過去の状態を保つのは整理整頓のためではありません。下流の三つの分析が、それを前提にしているからです。
churn(解約分析)は、subscriptions.status が active→cancelled に「いつ」変わったかを必要とします。現在値が cancelled だと分かっても、生存時間(契約からの経過)は status の履歴が無ければ測れません。Kaggle連載のchurn 予測(subscriptions + orders)も、ラベルとなる解約イベントの時点をこの履歴から取ります。
コホート分析は、「2025年Q1に獲得した会員のその後」を当時の属性で追う分析です。今の membership_tier でコホートを切ると、後からランクが動いた会員が過去のコホートに混ざり、定義そのものが歪みます。獲得時点の状態が要ります。
そして因果です。因果連載では customers.membership_tier の閾値で特典を付与する施策を RDD(回帰不連続デザイン)で評価します。このとき必要なのは特典付与「時点」のランクであって、今のランクではありません。施策後にランクが動いた会員を現在値で判定すると、未来の情報を混入させたことになる——典型的なリークです。スナップショットは、この時間軸のリークを構造で防ぎます。
私自身、ある受託案件で、下流の集計が過去のイベントに「現在の属性」を当ててしまい、施策の見かけの効果が膨らんでいた例を見ました。直し方はより凝ったモデルではなく、状態の履歴を記録することでした(N=1の経験なので、一般化はしません)。
落とし穴(遅延データ・再実行・スキーマ変更)
スナップショットには構造的な限界があります。記録できるのは「実行した瞬間に見えた値」だけです。実行と実行の間で値が2回以上変わると、中間の状態は永久に失われます(check でも timestamp でも同じ)。silver→gold→platinum が一晩で起きれば、gold は残りません。重要な状態ほど実行頻度を上げる必要があります。
増分側の落とし穴も挙げておきます。lookback が狭すぎると遅延データを取りこぼします。厄介なのは、遅延が「届くのが遅い」だけではないことです。広告の CV のように、いったん届いた成果が数週間後の遡及修正で書き換わるソースがあります。lookback は「データが確定するまでの日数」から逆算する——先に挙げた GA4=3日・広告=7日は、その逆算の結果です。また unique_key を省くと再実行のたびに重複が積もります。変換ロジックを変えたときやバックフィルが必要なときは --full-refresh で作り直す判断が必要です。上流がカラムを増減させたときの耐性は on_schema_change で設計します。スナップショットでは、unique_key に選んだキーが安定していること(途中で変わると別エンティティ扱いになる)も前提です。
増分もスナップショットも、「速さ」や「履歴」をタダで手に入れる魔法ではなく、冪等性・遅延・実行頻度というトレードオフを設計する作業です。設計を省くと、静かに壊れたまま下流へ流れます。
章末チェックリスト
-
incrementalの使いどころ(増え続けるイベント・計算コスト)を言えるか -
unique_keyと lookback で、冪等性と遅延データを扱えているか - 上書きで消える状態(会員ランク・購読ステータス)を snapshot で履歴化したか
- 「注文/獲得/介入の時点の状態」を point-in-time で引けるか
- 過去の状態が churn・コホート・因果(リーク防止)の前提になる理由を言えるか
- スナップショットが実行間隔より速い変化を取りこぼす限界を理解したか
過去の状態をマートに保てるようになると、下流が一気に開きます。「その施策は本当に効いたのか」を時点の状態で問う段階は記述・予測編と因果編へ、「そもそもこの土台に投資すべきか」の判断は決裁者向けの基幹記事データ基盤に投資すべきかへ送り出します。次回はドキュメント・系譜・運用。docs・lineage・freshness・CIで、整える層を一人の手元から組織の資産へ移します。