「重複した履歴から最新を取る」は日常業務だ
通販のデータベースには、同じエンティティに対して複数の履歴レコードが積み上がるテーブルが必ず存在する。
- 注文ステータスの変更履歴(
pending → processing → shipped → delivered) - 会員ランクの変更履歴(
一般 → シルバー → ゴールド) - 住所の変更履歴(引越しのたびに更新される)
- 商品価格の改定履歴
こうした「最新の状態だけを使いたい」という場面で、2つのアプローチが対立する。
アプローチA:ROW_NUMBER で最新を選ぶ(読み取り時に解決)
テーブルには履歴を全件保持したまま、SELECT するときに ROW_NUMBER で最新1件だけを取り出す。
アプローチB:DELETE + INSERT で最新を維持する(書き込み時に解決)
新しいレコードを INSERT する前に古いレコードを DELETE し、常に「最新の1件だけ」がテーブルに存在する状態を維持する。
どちらが正解かは一概には言えない。設計の目的・データ量・更新頻度・履歴の保持要否によって最適解が変わる。この記事では両者を徹底比較し、通販現場での選択基準を明確にする。
サンプルシナリオ:注文ステータスの履歴テーブル
注文のステータスが変更されるたびに履歴レコードが追加されるテーブルを例にとる。
-- order_status_history(注文ステータス履歴テーブル)
-- history_id : 履歴ID(主キー、AUTO INCREMENT)
-- order_id : 注文ID
-- status : ステータス('pending','processing','shipped','delivered','cancelled')
-- changed_at : ステータス変更日時
-- changed_by : 変更者('system' / 'operator')
-- note : メモ| history_id | order_id | status | changed_at | changed_by |
|---|---|---|---|---|
| 1 | O001 | pending | 2024-12-01 09:00:00 | system |
| 2 | O001 | processing | 2024-12-01 10:30:00 | system |
| 3 | O001 | shipped | 2024-12-02 14:00:00 | operator |
| 4 | O002 | pending | 2024-12-01 11:00:00 | system |
| 5 | O002 | cancelled | 2024-12-01 11:45:00 | operator |
| 6 | O001 | delivered | 2024-12-04 15:00:00 | system |
O001 の現在の最新ステータスは delivered(history_id = 6)だ。
アプローチA:ROW_NUMBER で最新を取る
基本クエリ
-- ROW_NUMBERで各注文の最新ステータスを取得する
WITH ranked AS (
SELECT
history_id,
order_id,
status,
changed_at,
changed_by,
note,
-- 注文ごとに変更日時の降順で番号を振る(1 = 最新)
ROW_NUMBER() OVER (
PARTITION BY order_id
ORDER BY changed_at DESC
) AS rn
FROM order_status_history
)
SELECT
history_id,
order_id,
status AS current_status,
changed_at AS last_changed_at,
changed_by
FROM ranked
WHERE rn = 1 -- 最新の1件だけを残す
ORDER BY order_id;
出力イメージ
| order_id | current_status | last_changed_at | changed_by |
|---|---|---|---|
| O001 | delivered | 2024-12-04 15:00:00 | system |
| O002 | cancelled | 2024-12-01 11:45:00 | operator |
シンプルで分かりやすい。元テーブルに全履歴が残っているため、「O001 はいつ shipped になったか」を後から追うことも可能だ。
MIN / MAX で代替する方法
最新1件だけが欲しい場合、ROW_NUMBER より単純な MAX を使うアプローチもある。
-- MAX(changed_at)で最新日時を取り、再度JOINして全カラムを取得する
WITH latest_time AS (
SELECT
order_id,
MAX(changed_at) AS latest_changed_at
FROM order_status_history
GROUP BY order_id
)
SELECT
h.order_id,
h.status AS current_status,
h.changed_at AS last_changed_at,
h.changed_by
FROM order_status_history h
JOIN latest_time l
ON h.order_id = l.order_id
AND h.changed_at = l.latest_changed_at
ORDER BY h.order_id;
こちらはウィンドウ関数を使わないため、古いSQLエンジンでも動く。ただし「同じ changed_at のレコードが2件ある」場合に複数行が返るリスクがある。安全なのは ROW_NUMBER だ。
ROW_NUMBER vs MAX の使い分け
| 観点 | ROW_NUMBER | MAX + JOIN |
|---|---|---|
| 同時刻レコードへの対処 | ORDER BY のタイブレーカーで制御可能 | 複数行が返る可能性あり |
| 追加カラムの取得 | CTEの中で全カラム保持できる | JOINが必要 |
| 可読性 | やや冗長だが意図が明確 | シンプル |
| エンジン互換性 | ウィンドウ関数対応が必要 | どこでも動く |
アプローチB:DELETE + INSERT で最新を維持する
こちらはテーブル設計の話になる。新しいステータスを記録するとき、古いレコードを削除してから新しいレコードを挿入する。結果としてテーブルには常に「最新の1件」しか存在しない。
-- ステータスを更新するときの処理(例:O001 を delivered に更新)
-- Step 1: 既存レコードを削除
DELETE FROM order_current_status
WHERE order_id = 'O001';
-- Step 2: 新しいレコードを挿入
INSERT INTO order_current_status
(order_id, status, changed_at, changed_by)
VALUES
('O001', 'delivered', '2024-12-04 15:00:00', 'system');
もしくはデータベースによっては UPSERT(INSERT ... ON CONFLICT DO UPDATE / REPLACE INTO)で1文で実現できる。
-- PostgreSQL / BigQuery の UPSERT(MERGE文)相当
MERGE INTO order_current_status AS target
USING (
SELECT 'O001' AS order_id, 'delivered' AS status,
TIMESTAMP '2024-12-04 15:00:00' AS changed_at, 'system' AS changed_by
) AS source
ON target.order_id = source.order_id
WHEN MATCHED THEN
UPDATE SET status = source.status,
changed_at = source.changed_at,
changed_by = source.changed_by
WHEN NOT MATCHED THEN
INSERT (order_id, status, changed_at, changed_by)
VALUES (source.order_id, source.status, source.changed_at, source.changed_by);
SELECT するときは何も考えずに全件取るだけでよい。
-- SELECT は単純に全件取るだけ
SELECT *
FROM order_current_status
ORDER BY order_id;
2つのアプローチを徹底比較する
| 観点 | ROW_NUMBER(読み取り時解決) | DELETE + INSERT(書き込み時解決) |
|---|---|---|
| 履歴の保持 | ✅ 全履歴が残る | ❌ 最新しか残らない |
| SELECT の複雑さ | ❌ 毎回 ROW_NUMBER が必要 | ✅ 単純な SELECT でよい |
| テーブルのサイズ | ❌ 履歴が増え続ける | ✅ 常に最小限 |
| 書き込みコスト | ✅ INSERT のみ(低コスト) | ❌ DELETE + INSERT(2操作必要) |
| 障害時の対応 | ✅ 履歴があるので原因追跡できる | ❌ 削除前の状態に戻れない |
| 分析クエリの利便性 | ✅ 時系列分析が可能 | ❌ 現在状態しか分からない |
| 整合性リスク | ✅ INSERT のみなので整合性が壊れにくい | ❌ DELETE と INSERT の間に障害が起きると不整合になる |
| データウェアハウス適性 | ✅ Treasure Data のような DWH に向く | ⚠ トランザクション対応の RDBMS 向き |
通販現場での選択基準
履歴テーブル(アプローチA推奨)
「いつ、誰が、何を変えたか」を追跡する必要があるテーブルには全件保持 + ROW_NUMBER が向く。
- 注文ステータス変更履歴
- 価格改定履歴
- 会員ランク変更履歴
- 住所変更履歴(返品対応・配送トラブル調査に使う)
-- 実務でよく使う「現在と1つ前」を同時に取得するパターン
WITH ranked AS (
SELECT
order_id,
status,
changed_at,
ROW_NUMBER() OVER (
PARTITION BY order_id ORDER BY changed_at DESC
) AS rn,
LAG(status) OVER (
PARTITION BY order_id ORDER BY changed_at DESC
) AS prev_status -- 1つ前のステータス
FROM order_status_history
)
SELECT
order_id,
status AS current_status,
prev_status, -- 直前のステータス(例:shipped → delivered の遷移確認)
changed_at
FROM ranked
WHERE rn = 1;
現在状態テーブル(アプローチB推奨)
リアルタイムの最新状態だけが必要で、履歴不要のテーブルにはシンプルな DELETE + INSERT が向く。
- カート内商品テーブル(追加・削除を繰り返す一時的な状態)
- セッション情報テーブル(ログイン中かどうか)
- 在庫数テーブル(現在の在庫数のみ管理)
両方持つ設計(最も堅牢)
本番の OLTP(オンライントランザクション処理)では「現在状態テーブル(アプローチB)」を持ち、分析基盤(Treasure Data 等)では「履歴テーブル(アプローチA)」に同期するという二段構えが理想的だ。
[OLTP DB] order_current_status(最新のみ・高速参照)
↓ ETL/CDC(変更データキャプチャ)
[DWH] order_status_history(全履歴・分析用)Treasure Data での実務パターン
Treasure Data はデータを追記(Append)するアーキテクチャが基本だ。DELETE がサポートされていないため、アプローチA(ROW_NUMBER)一択になる。
ただし毎回全件 ROW_NUMBER を計算するとコストが高い。そこで実務でよく使うのが「最新スナップショットテーブル」を定期的に作り直すパターンだ。
-- Treasure Dataでの実務パターン:
-- 毎朝バッチで「最新ステータスのみ」のスナップショットを作り直す
CREATE TABLE order_latest_status AS
WITH ranked AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY order_id
ORDER BY changed_at DESC
) AS rn
FROM order_status_history
WHERE changed_at >= DATE_ADD('day', -90, CURRENT_DATE) -- 直近90日分で十分な場合
)
SELECT
order_id,
status AS current_status,
changed_at AS last_changed_at,
changed_by
FROM ranked
WHERE rn = 1;
このスナップショットテーブルを後続のクエリから参照することで、毎回 ROW_NUMBER を計算するコストを節約できる。
よくある間違い:GROUP BY + MAX だけで済ませてしまう
MAX(changed_at) だけでなく他のカラム(status など)も一緒に取りたいとき、よくある誤りがある。
-- ❌ 間違い:MAX(changed_at) と status が対応していない
SELECT
order_id,
MAX(changed_at) AS last_changed_at,
status -- これは「最新日時のstatusとは限らない」
FROM order_status_history
GROUP BY order_id, status; -- status を GROUP BY に入れると全ステータスが出てしまう
GROUP BY order_id だけにすると status が SELECT できず、GROUP BY order_id, status にすると全ステータスが出る。この罠から逃れるには ROW_NUMBER か MAX + JOIN の二択だ。SQL を書き始めたばかりの担当者が必ずハマる箇所なので、チームでのコードレビュー時に特に注意したい。
まとめ
| ROW_NUMBER | DELETE + INSERT | |
|---|---|---|
| いつ解決するか | 読み取り時(SELECT) | 書き込み時(INSERT/DELETE) |
| 向いているケース | 履歴追跡が必要・DWH環境 | リアルタイム最新状態・OLTP環境 |
| Treasure Data | ✅ こちらのみ使える | ❌ DELETE 非対応 |
「最新レコードを取る」という一見シンプルな要件の裏に、データ設計の哲学が詰まっている。履歴を捨てるか保持するかの判断は、後から変えることが難しいため、テーブル設計の段階で意識的に選ぶ必要がある。