「当時の情報」を後から正確に再現できるか
通販の分析をしていると、こんな問いに直面することがある。
「昨年のゴールド会員の購買金額を集計してください」
一見シンプルだが、落とし穴がある。「昨年ゴールドだったが、今はプラチナに昇格した顧客」はどう扱うか。現在の会員ランクでフィルタすると、昨年時点ではゴールドだった顧客が抜けてしまう。
もっと深刻な例もある。
「あの注文が発生した当時の商品価格はいくらでしたか?」
価格が改定されたあとで「当時の価格」を参照しようとしても、最新価格しか残っていなければ正確な売上分析ができない。
こういった「ある時点での状態」を正確に記録・参照するためのデータ設計手法が SCD(Slowly Changing Dimension:緩やかに変化するディメンション) だ。
今回は通販現場で最もよく使われる Type 2 SCD の設計と、それをSQLで実装する方法を解説する。
SCDの3つのタイプ
SCDには複数のタイプがある。まず全体像を把握しよう。
| タイプ | 概要 | 履歴 | 向いているケース |
|---|---|---|---|
| Type 1 | 上書き更新。古い値は消える | 残らない | 誤入力の修正など、履歴不要な変更 |
| Type 2 | 新しいレコードを追加。有効期間で管理 | 残る | 会員ランク・住所・価格など、時点参照が必要な変更 |
| Type 3 | 現在値と1つ前の値だけカラムで保持 | 1世代のみ | 変更が1回しか起きない前提のケース |
通販の分析基盤で最もよく使われるのは Type 2 だ。履歴を完全に保持しながら「特定時点での状態」を正確に再現できる。
Type 2 SCD のテーブル設計
Type 2 の肝は「有効期間(valid_from / valid_to)」カラムだ。
-- member_rank_history(会員ランク履歴テーブル:Type 2 SCD)
-- surrogate_key : サロゲートキー(このテーブル固有のID)
-- customer_id : 顧客ID(自然キー)
-- rank : 会員ランク
-- valid_from : このランクが有効になった日
-- valid_to : このランクが無効になった日(現在有効なレコードはNULLまたは9999-12-31)
-- is_current : 現在有効なレコードかどうかのフラグ(1=現在、0=過去)| surrogate_key | customer_id | rank | valid_from | valid_to | is_current |
|---|---|---|---|---|---|
| 1 | C001 | 一般 | 2022-01-15 | 2022-09-30 | 0 |
| 2 | C001 | シルバー | 2022-10-01 | 2023-06-30 | 0 |
| 3 | C001 | ゴールド | 2023-07-01 | NULL | 1 |
| 4 | C002 | 一般 | 2023-03-20 | NULL | 1 |
C001 は「一般 → シルバー → ゴールド」と昇格してきた。valid_to = NULL のレコードが現在有効なランクだ。
この設計で「2023年1月1日時点のC001のランクは?」という問いに正確に答えられる。valid_from ≤ 2023-01-01 < valid_to または valid_to が NULL の行を探せばよく、シルバーだったことが分かる。
STEP 1 ― 現在有効なレコードを取得する
is_current = 1 を使う方法と、valid_to IS NULL を使う方法がある。
-- STEP1: 現在有効なレコードを取得する
-- 方法1: is_current フラグを使う(シンプルで高速)
SELECT
customer_id,
rank AS current_rank,
valid_from
FROM member_rank_history
WHERE is_current = 1
ORDER BY customer_id;
-- 方法2: valid_to IS NULL を使う(is_currentカラムがない場合)
SELECT
customer_id,
rank AS current_rank,
valid_from
FROM member_rank_history
WHERE valid_to IS NULL
ORDER BY customer_id;どちらも同じ結果になる。is_current フラグは冗長だが、インデックスをかけてクエリを高速化しやすいメリットがある。大規模テーブルでは is_current = 1 のほうが WHERE 句の絞り込みが速い場合がある。
STEP 2 ― 特定時点での状態を取得する(AS-OFクエリ)
「ある時点でのランク」を取得するクエリだ。これが Type 2 SCD の最大の価値だ。
-- STEP2: 特定時点(2023-01-01)でのランクを取得する
SELECT
customer_id,
rank AS rank_at_that_date,
valid_from,
valid_to
FROM member_rank_history
WHERE
valid_from <= DATE '2023-01-01' -- その日より前から有効だった
AND (
valid_to > DATE '2023-01-01' -- その日より後まで有効だった
OR valid_to IS NULL -- または現在も有効(有効期限なし)
)
ORDER BY customer_id;出力イメージ
| customer_id | rank_at_that_date | valid_from | valid_to |
|---|---|---|---|
| C001 | シルバー | 2022-10-01 | 2023-06-30 |
| C002 | 一般 | 2023-03-20 | NULL |
※ C002 は 2023-03-20 に登録なので 2023-01-01 時点では存在しない。実際には行が返らない。
STEP 3 ― 注文テーブルとJOINして「注文時点のランク」を取得する(完成版)
注文日と会員ランク履歴をJOINして「この注文が発生したとき、その顧客は何ランクだったか」を求める。
-- STEP3: 注文時点の会員ランクを取得する
SELECT
o.order_id,
o.customer_id,
o.order_date,
o.total_amount,
r.rank AS rank_at_order_time, -- 注文時点のランク
r.valid_from,
r.valid_to
FROM orders o
JOIN member_rank_history r
ON o.customer_id = r.customer_id
-- 注文日がランクの有効期間内に収まっていること
AND r.valid_from <= o.order_date
AND (
r.valid_to > o.order_date
OR r.valid_to IS NULL
)
WHERE o.status = 'completed'
ORDER BY o.customer_id, o.order_date;出力イメージ
| order_id | customer_id | order_date | total_amount | rank_at_order_time |
|---|---|---|---|---|
| O101 | C001 | 2022-05-10 | 8,000 | 一般 |
| O203 | C001 | 2023-01-15 | 12,000 | シルバー |
| O341 | C001 | 2023-09-20 | 18,000 | ゴールド |
C001 の購買履歴に、注文時点でのランクが正確に付与された。2022年5月はまだ一般会員、2023年1月はシルバー、9月はゴールドに昇格していた後の購買だ。
STEP 4 ― 新しいランクを追加するときの更新処理
Type 2 SCD では「新しいレコードを追加する前に、既存の現在レコードを終了させる」という2ステップの更新が必要だ。
-- STEP4: ランク変更時の更新処理(C001 が 2024-04-01 にプラチナに昇格)
-- Step1: 現在有効なレコードの valid_to を更新する(終了させる)
UPDATE member_rank_history
SET
valid_to = DATE '2024-03-31', -- 昇格日の前日
is_current = 0
WHERE
customer_id = 'C001'
AND is_current = 1;
-- Step2: 新しいランクのレコードを追加する
INSERT INTO member_rank_history
(customer_id, rank, valid_from, valid_to, is_current)
VALUES
('C001', 'プラチナ', DATE '2024-04-01', NULL, 1);
UPDATE 非対応の DWH では、このステップをアプリケーション側やETLで対応し、全件を再作成するアプローチになる。
-- 代替:全件を再構築するアプローチ
CREATE TABLE member_rank_history_new AS
-- 既存レコードで、C001の現在レコードを終了させて新しい履歴を追加
SELECT
customer_id,
rank,
valid_from,
-- C001の現在レコードのvalid_toを昇格日前日に更新
CASE
WHEN customer_id = 'C001' AND is_current = 1
THEN DATE '2024-03-31'
ELSE valid_to
END AS valid_to,
CASE
WHEN customer_id = 'C001' AND is_current = 1
THEN 0
ELSE is_current
END AS is_current
FROM member_rank_history
UNION ALL
-- 新しいプラチナレコードを追加
SELECT
'C001' AS customer_id,
'プラチナ' AS rank,
DATE '2024-04-01' AS valid_from,
NULL AS valid_to,
1 AS is_current;
STEP 5 ― LEAD で valid_to を自動計算する
履歴テーブルを設計するとき、valid_to を毎回手動で設定するのは手間がかかる。valid_from だけを記録しておき、LEAD で「次のレコードの valid_from の前日」を valid_to として自動計算するアプローチも実務でよく使われる。
-- STEP5: valid_fromだけの履歴からLEADでvalid_toを計算する
-- 元テーブル(valid_from のみ)
-- member_rank_changes
-- customer_id, rank, changed_at(ランクが変わった日)
WITH rank_with_valid_to AS (
SELECT
customer_id,
rank,
changed_at AS valid_from,
-- 次のレコードのchanged_atの前日がvalid_to
DATE_ADD('day', -1,
LEAD(changed_at) OVER (
PARTITION BY customer_id
ORDER BY changed_at ASC
)
) AS valid_to,
-- 次のレコードがない(最新)かどうか
CASE
WHEN LEAD(changed_at) OVER (
PARTITION BY customer_id
ORDER BY changed_at ASC
) IS NULL
THEN 1 ELSE 0
END AS is_current
FROM member_rank_changes
)
SELECT *
FROM rank_with_valid_to
ORDER BY customer_id, valid_from;出力イメージ
| customer_id | rank | valid_from | valid_to | is_current |
|---|---|---|---|---|
| C001 | 一般 | 2022-01-15 | 2022-09-30 | 0 |
| C001 | シルバー | 2022-10-01 | 2023-06-30 | 0 |
| C001 | ゴールド | 2023-07-01 | NULL | 1 |
valid_from だけを記録する設計にしておき、LEAD で valid_to を導出することで、更新のたびに手動で valid_to を埋める手間がなくなる。Treasure Data のような追記型 DWH との相性も良い。
応用:ランク別の購買金額を「注文時点のランク」で集計する
Type 2 SCD の真価が発揮されるクエリだ。「昨年ゴールド会員だったときの購買金額」を現在のランクではなく注文時点のランクで集計する。
-- 注文時点のランク別の年次購買金額集計
WITH rank_history AS (
-- LEAD で valid_to を自動計算
SELECT
customer_id,
rank,
changed_at AS valid_from,
DATE_ADD('day', -1,
LEAD(changed_at) OVER (
PARTITION BY customer_id ORDER BY changed_at ASC
)
) AS valid_to
FROM member_rank_changes
)
SELECT
EXTRACT(YEAR FROM o.order_date) AS order_year,
r.rank AS rank_at_order_time,
COUNT(DISTINCT o.customer_id) AS customer_count,
COUNT(o.order_id) AS order_count,
SUM(o.total_amount) AS total_sales,
ROUND(AVG(o.total_amount), 0) AS avg_order_value
FROM orders o
JOIN rank_history r
ON o.customer_id = r.customer_id
AND r.valid_from <= o.order_date
AND (r.valid_to > o.order_date OR r.valid_to IS NULL)
WHERE o.status = 'completed'
GROUP BY EXTRACT(YEAR FROM o.order_date), r.rank
ORDER BY order_year, r.rank;
出力イメージ
| order_year | rank_at_order_time | customer_count | order_count | total_sales | avg_order_value |
|---|---|---|---|---|---|
| 2023 | 一般 | 8,412 | 12,841 | 128,410,000 | 10,000 |
| 2023 | シルバー | 2,184 | 4,912 | 73,680,000 | 15,000 |
| 2023 | ゴールド | 891 | 3,124 | 62,480,000 | 20,000 |
| 2023 | プラチナ | 124 | 621 | 18,630,000 | 30,000 |
ゴールド・プラチナ会員の平均注文金額が一般会員の2〜3倍であることが「注文時点のランク」で正確に集計できた。現在のランクで集計すると、昇格・降格した顧客の影響で数字が歪む。
実務での運用ヒント
① valid_to の末端値は NULL か遠い未来か統一する
valid_to = NULL(現在有効)と valid_to = DATE '9999-12-31'(遠い未来)の2つの流派がある。NULL は IS NULL チェックが必要で、9999-12-31 は比較演算子だけで処理できる。どちらかに統一してチームで合意しておかないと、クエリのたびにどちらか確認が必要になる。
② valid_from = valid_to になるレコードに注意
同日に2回ランク変更が起きると valid_from = valid_to のゼロ期間レコードが生まれることがある。AS-OF クエリで valid_from <= target_date AND valid_to > target_date の条件を使っていれば問題ないが、valid_from <= target_date AND valid_to >= target_date と書くと重複して2件ヒットするケースがある。> か >= かを意識して統一する。
③ surrogate_key(サロゲートキー)を必ず設ける
Type 2 SCD では同一 customer_id に複数行が存在する。customer_id だけでは一意に行を特定できないため、surrogate_key(このテーブル固有の連番ID)が必要だ。後続テーブルが surrogate_key で参照できるようにしておくと、データモデルが整合的になる。
まとめ
Type 2 SCD のポイントを振り返る。
valid_from/valid_to/is_currentの3カラムで有効期間を管理する- 現在状態は
is_current = 1またはvalid_to IS NULLで取得する - 特定時点の状態は
valid_from <= 対象日 AND (valid_to > 対象日 OR valid_to IS NULL)で取得する(AS-OF クエリ) - 注文テーブルとのJOINで「注文時点の会員ランク・価格」を正確に再現できる
valid_fromだけ記録しLEADでvalid_toを導出する設計が Treasure Data との相性が良い
「当時の情報を正確に再現できるか」はデータ基盤の品質を根本的に左右する。Type 2 SCD は実装コストが少し高いが、一度正しく設計すれば「あのときの数字をもう一度出してほしい」という要求にいつでも答えられる、信頼性の高い分析基盤の礎になる。