「データが正しい」を前提にしていないか
ここまでのシリーズで、RFM分析・LTV計算・コホート分析・セグメント抽出と、さまざまな分析クエリを作ってきた。しかし実は、これらすべてに共通する大前提がある。
「入力データが正しい」という前提だ。
現実のデータは汚い。同じ顧客が複数IDで登録されている。注文金額にマイナス値が入っている。昨日まで毎日1,000件入ってきていた注文データが今日突然ゼロになっている。未来の日付で注文が登録されている。
こういったデータの異常に気づかないまま分析を続けると、間違った数字を正しいものとして経営会議に持ち込むことになる。それはSQLの問題ではなく、ビジネス上の意思決定の誤りに直結する。
データ品質の確認は「完璧なデータが来てから分析を始める」ものではない。日次・週次で自動チェックを走らせ、異常を早期に検知する習慣が必要だ。
この記事では、通販データベースで頻発する異常・重複のパターンごとに、すぐに使えるチェッククエリを紹介する。
検知すべき異常の6パターン
通販現場で実際によく遭遇する異常パターンを6つに整理した。
| # | パターン | 症状の例 |
|---|---|---|
| 1 | 件数の急変 | 昨日1,200件だった注文が今日10件しかない |
| 2 | 金額の異常値 | 注文金額がマイナス、または1件で100万円超 |
| 3 | 日付の異常 | 未来日付の注文、登録日より前の購買日 |
| 4 | 主キーの重複 | 同じ order_id が複数行存在する |
| 5 | 参照整合性の破綻 | orders に存在する customer_id が customers に存在しない |
| 6 | NULL混入 | 必須カラムに NULL が入っている |
チェック1 ― 日次件数の急変を検知する
前日比で件数が激減・激増していないかを確認する。データパイプラインの障害、ETLの設定ミス、上流システムの仕様変更などで件数が突然変わることがある。
-- チェック1: 注文件数の前日比チェック
WITH daily_count AS (
SELECT
order_date,
COUNT(order_id) AS order_count
FROM orders
WHERE order_date >= DATE_ADD('day', -7, CURRENT_DATE) -- 直近7日
GROUP BY order_date
),
with_prev AS (
SELECT
order_date,
order_count,
LAG(order_count, 1) OVER (ORDER BY order_date) AS prev_day_count
FROM daily_count
)
SELECT
order_date,
order_count,
prev_day_count,
-- 前日比(%)
ROUND(
(order_count - prev_day_count) * 100.0
/ NULLIF(prev_day_count, 0)
, 1) AS day_over_day_pct,
-- 異常フラグ:前日比 -50% 以下、または +200% 以上
CASE
WHEN (order_count - prev_day_count) * 100.0
/ NULLIF(prev_day_count, 0) <= -50 THEN '⚠ 急減'
WHEN (order_count - prev_day_count) * 100.0
/ NULLIF(prev_day_count, 0) >= 200 THEN '⚠ 急増'
ELSE '正常'
END AS anomaly_flag
FROM with_prev
WHERE prev_day_count IS NOT NULL -- 初日は比較対象なしで除外
ORDER BY order_date DESC;
出力イメージ
| order_date | order_count | prev_day_count | day_over_day_pct | anomaly_flag |
|---|---|---|---|---|
| 2024-12-07 | 1,284 | 1,198 | +7.2 | 正常 |
| 2024-12-06 | 1,198 | 1,241 | -3.5 | 正常 |
| 2024-12-05 | 1,241 | 48 | +2485.4 | ⚠ 急増 |
| 2024-12-04 | 48 | 1,189 | -96.0 | ⚠ 急減 |
12月4日に件数が激減し、翌5日に急増している。ETLバッチが4日分を5日にまとめて流し込んでしまうようなパイプライン障害が疑われる。
閾値(-50%・+200%)は自社データの特性に合わせて調整する。週末に件数が落ちる業種なら、曜日別の平均値と比較する方法もある。
チェック2 ― 金額の異常値を検知する
注文金額に関して、「ありえない値」が混入していないかを確認する。
-- チェック2: 金額異常チェック
SELECT
'金額がゼロ以下' AS check_type,
COUNT(*) AS anomaly_count,
MIN(total_amount) AS min_value,
MAX(total_amount) AS max_value
FROM orders
WHERE total_amount <= 0
AND order_date >= DATE_ADD('day', -7, CURRENT_DATE)
UNION ALL
SELECT
'金額が異常に高額(50万円超)' AS check_type,
COUNT(*) AS anomaly_count,
MIN(total_amount) AS min_value,
MAX(total_amount) AS max_value
FROM orders
WHERE total_amount > 500000
AND order_date >= DATE_ADD('day', -7, CURRENT_DATE)
UNION ALL
SELECT
'total_amountがNULL' AS check_type,
COUNT(*) AS anomaly_count,
NULL AS min_value,
NULL AS max_value
FROM orders
WHERE total_amount IS NULL
AND order_date >= DATE_ADD('day', -7, CURRENT_DATE);
出力イメージ
| check_type | anomaly_count | min_value | max_value |
|---|---|---|---|
| 金額がゼロ以下 | 3 | -1,800 | 0 |
| 金額が異常に高額(50万円超) | 1 | 980,000 | 980,000 |
| total_amountがNULL | 0 | NULL | NULL |
マイナス金額は返品・キャンセルの処理ミスが疑われる。50万円超は実際にあり得る注文かもしれないが、担当者に確認する価値がある。
チェック3 ― 日付の異常を検知する
未来日付・ありえない過去日付・論理的に矛盾する日付を検知する。
-- チェック3: 日付異常チェック
SELECT
'未来日付の注文' AS check_type,
COUNT(*) AS anomaly_count,
MIN(order_date) AS min_date,
MAX(order_date) AS max_date
FROM orders
WHERE order_date > CURRENT_DATE
UNION ALL
SELECT
'登録日より前の注文日' AS check_type,
COUNT(*) AS anomaly_count,
MIN(o.order_date) AS min_date,
MAX(o.order_date) AS max_date
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.order_date < c.registered_at -- 登録前に注文している(論理矛盾)
UNION ALL
SELECT
'5年以上前の注文(データ移行ミスの疑い)' AS check_type,
COUNT(*) AS anomaly_count,
MIN(order_date) AS min_date,
MAX(order_date) AS max_date
FROM orders
WHERE order_date < DATE_ADD('year', -5, CURRENT_DATE);
「登録日より前の注文日」が存在する場合、システム移行時のデータ投入ミスや、顧客マスタの登録日が後から修正されたケースが多い。放置すると在籍期間の計算(LTVのSTEP2で使った tenure_days)が負の値になる。
チェック4 ― 主キーの重複を検知する
order_id は注文テーブルの主キーのはずだが、ETLやデータ統合の過程で重複が生まれることがある。重複した主キーは集計時に件数を二重カウントする原因になる。
-- チェック4: 主キー重複チェック
WITH duplicate_orders AS (
SELECT
order_id,
COUNT(*) AS row_count
FROM orders
GROUP BY order_id
HAVING COUNT(*) > 1 -- 2件以上存在するorder_idだけ残す
)
SELECT
COUNT(*) AS duplicate_key_count, -- 重複しているorder_idの種類数
SUM(row_count) AS total_duplicate_rows, -- 重複行の総数
MIN(row_count) AS min_duplication,
MAX(row_count) AS max_duplication
FROM duplicate_orders;
さらに「どの order_id が重複しているか」の詳細も出しておく。
-- 重複しているorder_idの具体的な内容を確認する
WITH duplicate_ids AS (
SELECT order_id
FROM orders
GROUP BY order_id
HAVING COUNT(*) > 1
)
SELECT
o.*
FROM orders o
JOIN duplicate_ids d ON o.order_id = d.order_id
ORDER BY o.order_id, o.customer_id;
重複行を見比べることで「完全に同一の行か」「一部カラムだけ異なるか」を確認できる。完全同一ならETLの二重投入、一部異なるならデータ更新が正しく適用されていない可能性がある。
チェック5 ― 参照整合性の破綻を検知する
orders.customer_id が customers テーブルに存在しない「孤立した注文」がないかを確認する。
-- チェック5: 参照整合性チェック(孤立レコード)
-- 孤立した注文(顧客マスタに存在しないcustomer_id)
SELECT
'顧客マスタに存在しないcustomer_id' AS check_type,
COUNT(DISTINCT o.customer_id) AS anomaly_count
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.customer_id
WHERE c.customer_id IS NULL
UNION ALL
-- 孤立した注文明細(注文テーブルに存在しないorder_id)
SELECT
'注文テーブルに存在しないorder_id' AS check_type,
COUNT(DISTINCT oi.order_id) AS anomaly_count
FROM order_items oi
LEFT JOIN orders o ON oi.order_id = o.order_id
WHERE o.order_id IS NULL
UNION ALL
-- 孤立したクーポン使用(クーポンマスタに存在しないcoupon_id)
SELECT
'クーポンマスタに存在しないcoupon_id' AS check_type,
COUNT(*) AS anomaly_count
FROM orders
WHERE coupon_id IS NOT NULL
AND coupon_id NOT IN (SELECT coupon_id FROM coupons);
出力イメージ
| check_type | anomaly_count |
|---|---|
| 顧客マスタに存在しないcustomer_id | 12 |
| 注文テーブルに存在しないorder_id | 0 |
| クーポンマスタに存在しないcoupon_id | 3 |
顧客マスタに存在しないcustomer_idが12件ある。退会処理でマスタを物理削除したが注文履歴が残っている、あるいはデータ統合時にIDが変換されなかったケースが考えられる。これらの12件はRFM分析やLTV計算から除外されるか、誤った値を返す。
チェック6 ― 必須カラムのNULL混入を検知する
ビジネス上「NULL はありえない」カラムを一括でチェックする。
-- チェック6: 必須カラムのNULLチェック(直近7日分)
SELECT
'orders.customer_id' AS column_name,
COUNT(*) FILTER (WHERE customer_id IS NULL) AS null_count,
COUNT(*) AS total_count
FROM orders
WHERE order_date >= DATE_ADD('day', -7, CURRENT_DATE)
UNION ALL
SELECT
'orders.order_date',
COUNT(*) FILTER (WHERE order_date IS NULL),
COUNT(*)
FROM orders
WHERE created_at >= DATE_ADD('day', -7, CURRENT_DATE)
UNION ALL
SELECT
'orders.total_amount',
COUNT(*) FILTER (WHERE total_amount IS NULL),
COUNT(*)
FROM orders
WHERE order_date >= DATE_ADD('day', -7, CURRENT_DATE)
UNION ALL
SELECT
'orders.status',
COUNT(*) FILTER (WHERE status IS NULL),
COUNT(*)
FROM orders
WHERE order_date >= DATE_ADD('day', -7, CURRENT_DATE);
FILTER (WHERE ...)句についてCOUNT(*) FILTER (WHERE 条件)は「条件を満たす行だけをカウントする」構文だ。Treasure DataのPrestoでサポートされている。MySQLなど一部の環境ではSUM(CASE WHEN column IS NULL THEN 1 ELSE 0 END)と書き換えればよい。
すべてを1本の日次ヘルスチェッククエリにまとめる
上記の6チェックをまとめて実行し、異常がある項目だけを返すサマリクエリを作る。これを毎朝自動実行してSlackに通知する仕組みにすると、データ品質の監視が自動化できる。
-- 日次データ品質ヘルスチェック(サマリ版)
WITH checks AS (
-- チェック1: 昨日の注文件数が一昨日から50%以上減少
SELECT
'order_count_drop' AS check_name,
'注文件数の急減(前日比-50%以下)' AS description,
CASE
WHEN (
SELECT COUNT(*) FROM orders
WHERE order_date = DATE_ADD('day', -1, CURRENT_DATE)
) * 1.0
/ NULLIF(
SELECT COUNT(*) FROM orders
WHERE order_date = DATE_ADD('day', -2, CURRENT_DATE)
, 0) < 0.5
THEN 1 ELSE 0
END AS is_anomaly
UNION ALL
-- チェック2: 金額がゼロ以下の注文
SELECT
'negative_amount',
'金額ゼロ以下の注文あり',
CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
FROM orders
WHERE total_amount <= 0
AND order_date >= DATE_ADD('day', -1, CURRENT_DATE)
UNION ALL
-- チェック3: 未来日付の注文
SELECT
'future_date',
'未来日付の注文あり',
CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
FROM orders
WHERE order_date > CURRENT_DATE
UNION ALL
-- チェック4: 主キー重複
SELECT
'duplicate_order_id',
'注文IDの重複あり',
CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
FROM (
SELECT order_id FROM orders
GROUP BY order_id HAVING COUNT(*) > 1
)
UNION ALL
-- チェック5: 孤立した注文
SELECT
'orphan_orders',
'顧客マスタに存在しない注文あり',
CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.customer_id
WHERE c.customer_id IS NULL
AND o.order_date >= DATE_ADD('day', -1, CURRENT_DATE)
UNION ALL
-- チェック6: 必須カラムのNULL
SELECT
'null_in_required_columns',
'customer_idまたはtotal_amountにNULLあり',
CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
FROM orders
WHERE (customer_id IS NULL OR total_amount IS NULL)
AND order_date >= DATE_ADD('day', -1, CURRENT_DATE)
)
SELECT
check_name,
description,
CASE WHEN is_anomaly = 1 THEN '🚨 異常検知' ELSE '✅ 正常' END AS status,
CURRENT_DATE AS checked_at
FROM checks
ORDER BY is_anomaly DESC, check_name;
出力イメージ
| check_name | description | status | checked_at |
|---|---|---|---|
| negative_amount | 金額ゼロ以下の注文あり | 🚨 異常検知 | 2024-12-07 |
| order_count_drop | 注文件数の急減(前日比-50%以下) | ✅ 正常 | 2024-12-07 |
| orphan_orders | 顧客マスタに存在しない注文あり | ✅ 正常 | 2024-12-07 |
| duplicate_order_id | 注文IDの重複あり | ✅ 正常 | 2024-12-07 |
| future_date | 未来日付の注文あり | ✅ 正常 | 2024-12-07 |
| null_in_required_columns | customer_idまたはtotal_amountにNULLあり | ✅ 正常 | 2024-12-07 |
「🚨 異常検知」が1行でも出たらSlackに通知する設定にしておけば、朝イチで問題に気づける。
実務での運用ヒント
① Treasure DataのdigdagでSlack通知を自動化する
このヘルスチェッククエリをdigdagで毎朝6時に実行し、is_anomaly = 1 の行が1件でもあればSlackのWebhookに通知するワークフローが組める。分析担当者が毎朝手動でチェックする手間が完全になくなる。
② 閾値はデータを見ながら育てる
最初から「前日比-50%で異常」という閾値が正しいとは限らない。自社のデータで過去の正常・異常を振り返り、誤検知(正常なのに異常と判定)と見逃し(異常なのに正常と判定)のバランスを調整していく。
③ テーブルごとにチェック範囲を分ける
注文テーブルは「直近1日」、マスタテーブルは「全件」、ログテーブルは「直近7日」など、テーブルの更新頻度と規模に合わせてチェック範囲を変えると、クエリの実行コストを抑えられる。
まとめ
今回紹介した6つのチェックパターンを振り返る。
- 件数の急変 — LAGで前日比を計算し、閾値を超えたら異常フラグを立てる
- 金額の異常値 — ゼロ以下・上限超え・NULLをUNION ALLでまとめてチェックする
- 日付の異常 — 未来日付・論理矛盾・極端な過去日付を検知する
- 主キーの重複 — GROUP BY + HAVING COUNT(*) > 1 で特定する
- 参照整合性の破綻 — LEFT JOIN + IS NULL で孤立レコードを抽出する
- NULL混入 — COUNT(*) FILTER (WHERE IS NULL) で必須カラムを一括確認する
どれだけ精緻な分析クエリを作っても、入力データが壊れていれば出てくる数字は信用できない。データ品質のチェックは「ついでにやること」ではなく、分析の精度を守るための基盤作業だ。