— dim_date(日付ディメンション)
CREATE TABLE dim_date (
date_id INT PRIMARY KEY, — 20250115 形式
full_date DATE NOT NULL, — 2025-01-15
year INT NOT NULL, — 2025
quarter INT NOT NULL, — 1 (Q1)
month INT NOT NULL, — 1
month_name VARCHAR(20) NOT NULL, — ‘1月’ または ‘January’
day_of_month INT NOT NULL, — 15
day_of_week INT NOT NULL, — 1=月曜日, 7=日曜日
day_name VARCHAR(20) NOT NULL, — ‘水曜日’ または ‘Wednesday’
week_of_year INT NOT NULL, — 3
is_weekend BOOLEAN NOT NULL, — 土日かどうか
is_holiday BOOLEAN NOT NULL, — 祝日かどうか
holiday_name VARCHAR(100), — 祝日名
fiscal_year INT NOT NULL, — 会計年度
fiscal_quarter INT NOT NULL, — 会計年度の四半期
season VARCHAR(20) NOT NULL — ‘春’, ‘夏’, ‘秋’, ‘冬’
);
💡 日付ディメンションのポイント
✅ 事前に全日付を登録:過去5年分+未来2年分など
✅ 豊富な属性:年、月、四半期、曜日、祝日など
✅ 集計が簡単:「月別」「四半期別」「曜日別」などがWHEREだけで可能
✅ 会計年度にも対応:企業の会計年度が4月開始などでもOK
ディメンション2:商品ディメンション
— dim_product(商品ディメンション)
CREATE TABLE dim_product (
product_id INT PRIMARY KEY,
product_code VARCHAR(50) NOT NULL UNIQUE, — 元システムの商品コード
product_name VARCHAR(200) NOT NULL,
category_level1 VARCHAR(100) NOT NULL, — 大カテゴリ(電子機器、衣料品など)
category_level2 VARCHAR(100), — 中カテゴリ(ノートPC、Tシャツなど)
category_level3 VARCHAR(100), — 小カテゴリ
brand VARCHAR(100),
supplier VARCHAR(100),
unit_price DECIMAL(10, 2) NOT NULL, — 標準価格
cost_price DECIMAL(10, 2), — 原価
is_active BOOLEAN NOT NULL DEFAULT true, — 販売中かどうか
created_date DATE NOT NULL,
discontinued_date DATE, — 販売終了日
— SCD Type 2 用(ゆっくり変化するディメンション対応)
effective_date DATE NOT NULL, — 有効開始日
expiration_date DATE, — 有効終了日
is_current BOOLEAN NOT NULL DEFAULT true — 現在有効なレコードか
);
— dim_customer(顧客ディメンション)
CREATE TABLE dim_customer (
customer_id INT PRIMARY KEY,
customer_code VARCHAR(50) NOT NULL UNIQUE,
customer_name VARCHAR(100) NOT NULL,
customer_segment VARCHAR(50), — ‘VIP’, ‘Regular’, ‘New’
gender VARCHAR(20),
age_range VARCHAR(20), — ’20-29′, ’30-39′ など
prefecture VARCHAR(50), — 都道府県
city VARCHAR(100), — 市区町村
region VARCHAR(50), — 地域(関東、関西など)
registration_date DATE NOT NULL, — 登録日
first_purchase_date DATE, — 初回購入日
total_purchases INT DEFAULT 0, — 累計購入回数
total_amount DECIMAL(15, 2) DEFAULT 0, — 累計購入金額
rfm_score INT, — RFMスコア(Recency, Frequency, Monetary)
is_active BOOLEAN NOT NULL DEFAULT true,
— SCD Type 2 用
effective_date DATE NOT NULL,
expiration_date DATE,
is_current BOOLEAN NOT NULL DEFAULT true
);
ディメンション4:プロモーションディメンション
— dim_promotion(プロモーションディメンション)
CREATE TABLE dim_promotion (
promotion_id INT PRIMARY KEY,
promotion_name VARCHAR(200) NOT NULL,
promotion_type VARCHAR(50), — ‘Discount’, ‘Free Shipping’, ‘Bundle’
discount_rate DECIMAL(5, 2), — 割引率(10.00 = 10%)
start_date DATE NOT NULL,
end_date DATE NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true
);
— プロモーションなしのデフォルトレコード
INSERT INTO dim_promotion (promotion_id, promotion_name, promotion_type)
VALUES (0, ‘プロモーションなし’, ‘None’);
📈 4. 設計ステップ3:ファクトテーブルの設計
売上ファクトテーブル
— fact_sales(売上ファクトテーブル)
CREATE TABLE fact_sales (
sales_fact_id BIGINT AUTO_INCREMENT PRIMARY KEY,
— ディメンションへの外部キー
date_id INT NOT NULL,
product_id INT NOT NULL,
customer_id INT NOT NULL,
promotion_id INT NOT NULL DEFAULT 0,
— メジャー(集計対象の数値)
quantity INT NOT NULL, — 販売数量
unit_price DECIMAL(10, 2) NOT NULL, — 販売時単価
discount_amount DECIMAL(10, 2) DEFAULT 0, — 割引額
sales_amount DECIMAL(12, 2) NOT NULL, — 売上金額(単価×数量 – 割引)
cost_amount DECIMAL(12, 2), — 原価(数量×原価単価)
profit_amount DECIMAL(12, 2), — 利益(売上金額 – 原価)
— 追加情報
order_number VARCHAR(50), — 元システムの注文番号
order_line_number INT, — 注文内の行番号
created_timestamp TIMESTAMP NOT NULL, — ETL処理日時
— 外部キー制約
FOREIGN KEY (date_id) REFERENCES dim_date(date_id),
FOREIGN KEY (product_id) REFERENCES dim_product(product_id),
FOREIGN KEY (customer_id) REFERENCES dim_customer(customer_id),
FOREIGN KEY (promotion_id) REFERENCES dim_promotion(promotion_id),
— インデックス(パフォーマンス最適化)
INDEX idx_date (date_id),
INDEX idx_product (product_id),
INDEX idx_customer (customer_id),
INDEX idx_date_product (date_id, product_id),
INDEX idx_date_customer (date_id, customer_id)
);
💡 ファクトテーブル設計のポイント
✅ 外部キーのみ:ディメンションへの参照だけ(商品名などは含めない)
✅ 加法性のある数値:売上金額、数量など、合計できる値を保持
✅ 計算済みの値も保持:利益額などを事前計算して保存(集計高速化)
✅ 適切なインデックス:頻繁に使うディメンションの組み合わせにインデックス
✅ BIGINT の主キー:数億行にも対応できるよう大きめのデータ型
🔍 5. 実践的な分析クエリ
クエリ1:月別売上推移
— 2024年の月別売上推移
SELECT
d.year AS 年,
d.month AS 月,
d.month_name AS 月名,
COUNT(DISTINCT f.sales_fact_id) AS 販売件数,
SUM(f.quantity) AS 販売数量,
SUM(f.sales_amount) AS 売上金額,
SUM(f.profit_amount) AS 利益,
ROUND(SUM(f.profit_amount) / SUM(f.sales_amount) * 100, 2) AS 利益率
FROM fact_sales f
JOIN dim_date d ON f.date_id = d.date_id
WHERE d.year = 2024
GROUP BY d.year, d.month, d.month_name
ORDER BY d.year, d.month;
— 2025年1月の商品カテゴリ別売上トップ10
SELECT
p.category_level1 AS カテゴリ,
COUNT(DISTINCT f.sales_fact_id) AS 販売件数,
SUM(f.quantity) AS 販売数量,
SUM(f.sales_amount) AS 売上金額,
ROUND(AVG(f.unit_price), 0) AS 平均単価
FROM fact_sales f
JOIN dim_product p ON f.product_id = p.product_id
JOIN dim_date d ON f.date_id = d.date_id
WHERE d.year = 2025 AND d.month = 1
AND p.is_current = true
GROUP BY p.category_level1
ORDER BY 売上金額 DESC
LIMIT 10;
クエリ3:都道府県別売上分析
— 2025年の都道府県別売上
SELECT
c.region AS 地域,
c.prefecture AS 都道府県,
COUNT(DISTINCT f.customer_id) AS 購入顧客数,
COUNT(DISTINCT f.sales_fact_id) AS 販売件数,
SUM(f.sales_amount) AS 売上金額,
ROUND(SUM(f.sales_amount) / COUNT(DISTINCT f.customer_id), 0) AS 顧客単価
FROM fact_sales f
JOIN dim_customer c ON f.customer_id = c.customer_id
JOIN dim_date d ON f.date_id = d.date_id
WHERE d.year = 2025
AND c.is_current = true
GROUP BY c.region, c.prefecture
ORDER BY 売上金額 DESC;
クエリ4:プロモーション効果測定
— プロモーション別の売上効果
SELECT
pr.promotion_name AS プロモーション名,
pr.promotion_type AS タイプ,
COUNT(DISTINCT f.sales_fact_id) AS 販売件数,
SUM(f.sales_amount) AS 売上金額,
SUM(f.discount_amount) AS 割引総額,
SUM(f.profit_amount) AS 利益,
ROUND(SUM(f.profit_amount) / SUM(f.sales_amount) * 100, 2) AS 利益率
FROM fact_sales f
JOIN dim_promotion pr ON f.promotion_id = pr.promotion_id
JOIN dim_date d ON f.date_id = d.date_id
WHERE d.year = 2025 AND d.month = 1
GROUP BY pr.promotion_id, pr.promotion_name, pr.promotion_type
ORDER BY 売上金額 DESC;
クエリ5:顧客セグメント別分析
— 顧客セグメント別の購買傾向
SELECT
c.customer_segment AS 顧客セグメント,
c.age_range AS 年齢層,
COUNT(DISTINCT f.customer_id) AS 顧客数,
SUM(f.sales_amount) AS 売上金額,
ROUND(AVG(f.sales_amount), 0) AS 平均購入金額,
COUNT(f.sales_fact_id) AS 購入回数,
ROUND(COUNT(f.sales_fact_id) / COUNT(DISTINCT f.customer_id), 2) AS 一人当たり購入回数
FROM fact_sales f
JOIN dim_customer c ON f.customer_id = c.customer_id
JOIN dim_date d ON f.date_id = d.date_id
WHERE d.year = 2025 AND d.quarter = 1
AND c.is_current = true
GROUP BY c.customer_segment, c.age_range
ORDER BY 売上金額 DESC;
クエリ6:曜日別売上分析
— 曜日別の売上傾向(直近3ヶ月)
SELECT
d.day_of_week AS 曜日コード,
d.day_name AS 曜日,
d.is_weekend AS 週末,
COUNT(DISTINCT f.sales_fact_id) AS 販売件数,
SUM(f.sales_amount) AS 売上金額,
ROUND(AVG(f.sales_amount), 0) AS 平均購入金額
FROM fact_sales f
JOIN dim_date d ON f.date_id = d.date_id
WHERE d.full_date >= DATE_SUB(CURDATE(), INTERVAL 3 MONTH)
GROUP BY d.day_of_week, d.day_name, d.is_weekend
ORDER BY d.day_of_week;
📊 6. ETL処理の実装例
業務DBからDWHへのデータロード
— ステップ1: 日付ディメンションの事前作成(初回のみ)
INSERT INTO dim_date (date_id, full_date, year, quarter, month, …)
SELECT
DATE_FORMAT(date_value, ‘%Y%m%d’) AS date_id,
date_value AS full_date,
YEAR(date_value) AS year,
QUARTER(date_value) AS quarter,
MONTH(date_value) AS month,
…
FROM
(SELECT DATE_ADD(‘2020-01-01’, INTERVAL seq DAY) AS date_value
FROM sequence_table
WHERE seq < DATEDIFF('2030-12-31', '2020-01-01')) dates;
-- ステップ2: 商品ディメンションの更新(日次)
-- 新規商品の追加
INSERT INTO dim_product (
product_code, product_name, category_level1, ..., effective_date, is_current
)
SELECT
p.product_code,
p.product_name,
p.category_name AS category_level1,
...,
CURDATE() AS effective_date,
true AS is_current
FROM oltp_products p
LEFT JOIN dim_product dp ON p.product_code = dp.product_code AND dp.is_current = true
WHERE dp.product_id IS NULL;
-- ステップ3: ファクトテーブルへのデータロード(日次)
INSERT INTO fact_sales (
date_id, product_id, customer_id, promotion_id,
quantity, unit_price, discount_amount, sales_amount, cost_amount, profit_amount,
order_number, order_line_number
)
SELECT
DATE_FORMAT(o.order_date, '%Y%m%d') AS date_id,
dp.product_id,
dc.customer_id,
COALESCE(dpr.promotion_id, 0) AS promotion_id,
od.quantity,
od.unit_price,
od.discount_amount,
od.quantity * od.unit_price - od.discount_amount AS sales_amount,
od.quantity * p.cost_price AS cost_amount,
(od.quantity * od.unit_price - od.discount_amount) - (od.quantity * p.cost_price) AS profit_amount,
o.order_number,
od.line_number
FROM oltp_orders o
JOIN oltp_order_details od ON o.order_id = od.order_id
JOIN dim_product dp ON od.product_code = dp.product_code AND dp.is_current = true
JOIN dim_customer dc ON o.customer_code = dc.customer_code AND dc.is_current = true
LEFT JOIN dim_promotion dpr ON o.promotion_code = dpr.promotion_code
JOIN oltp_products p ON od.product_code = p.product_code
WHERE o.order_date = DATE_SUB(CURDATE(), INTERVAL 1 DAY)
AND o.order_status = 'completed';
💡 ETL処理のポイント
✅ 増分ロード:前日分のデータだけをロード(全件ロードは避ける)
✅ SCD対応:変更があった場合は新しいレコードを追加
✅ トランザクション:エラー時はロールバック
✅ ログ記録:ETL処理の実行ログを保存
✅ データ検証:ロード前後で件数チェック
🎯 7. パフォーマンス最適化
集計テーブル(サマリーテーブル)の作成
📊 頻繁に使う集計は事前計算
毎回ファクトテーブルから集計するのではなく、よく使う集計は事前計算して保存します。
— 日次売上サマリーテーブル
CREATE TABLE summary_daily_sales (
date_id INT NOT NULL,
total_sales_amount DECIMAL(15, 2) NOT NULL,
total_orders INT NOT NULL,
total_customers INT NOT NULL,
average_order_amount DECIMAL(10, 2) NOT NULL,
PRIMARY KEY (date_id),
FOREIGN KEY (date_id) REFERENCES dim_date(date_id)
);
— 商品カテゴリ別日次サマリー
CREATE TABLE summary_daily_category_sales (
date_id INT NOT NULL,
category_level1 VARCHAR(100) NOT NULL,
total_sales_amount DECIMAL(15, 2) NOT NULL,
total_quantity INT NOT NULL,
total_orders INT NOT NULL,
PRIMARY KEY (date_id, category_level1),
FOREIGN KEY (date_id) REFERENCES dim_date(date_id)
);
パーティショニング
— ファクトテーブルを年月でパーティション分割
ALTER TABLE fact_sales
PARTITION BY RANGE (date_id) (
PARTITION p202401 VALUES LESS THAN (20240201),
PARTITION p202402 VALUES LESS THAN (20240301),
PARTITION p202403 VALUES LESS THAN (20240401),
…
PARTITION p_future VALUES LESS THAN MAXVALUE
);
— メリット:
— – 古いデータのクエリが高速化
— – パーティション単位でデータ削除が可能
— – メンテナンスが容易
SELECT
CASE
WHEN d.is_weekend = true THEN ‘週末’
ELSE ‘平日’
END AS 日タイプ,
COUNT(DISTINCT d.date_id) AS 日数,
COUNT(f.sales_fact_id) AS 販売件数,
SUM(f.sales_amount) AS 売上合計,
ROUND(SUM(f.sales_amount) / COUNT(DISTINCT d.date_id), 0) AS 日平均売上
FROM fact_sales f
JOIN dim_date d ON f.date_id = d.date_id
WHERE d.year = 2025 AND d.quarter = 1
GROUP BY d.is_weekend
ORDER BY d.is_weekend;
— 2025年1月と2024年1月を比較
WITH current_year AS (
SELECT
p.category_level1 AS category,
SUM(f.sales_amount) AS sales_2025
FROM fact_sales f
JOIN dim_date d ON f.date_id = d.date_id
JOIN dim_product p ON f.product_id = p.product_id
WHERE d.year = 2025 AND d.month = 1
AND p.is_current = true
GROUP BY p.category_level1
),
previous_year AS (
SELECT
p.category_level1 AS category,
SUM(f.sales_amount) AS sales_2024
FROM fact_sales f
JOIN dim_date d ON f.date_id = d.date_id
JOIN dim_product p ON f.product_id = p.product_id
WHERE d.year = 2024 AND d.month = 1
AND p.is_current = true
GROUP BY p.category_level1
)
SELECT
c.category AS カテゴリ,
c.sales_2025 AS 今年売上,
p.sales_2024 AS 前年売上,
c.sales_2025 – p.sales_2024 AS 増減額,
ROUND((c.sales_2025 – p.sales_2024) / p.sales_2024 * 100, 2) AS 成長率
FROM current_year c
LEFT JOIN previous_year p ON c.category = p.category
ORDER BY 成長率 DESC;
— ファクトテーブル
CREATE TABLE fact_restaurant_sales (
sales_id BIGINT AUTO_INCREMENT PRIMARY KEY,
date_id INT NOT NULL,
time_id INT NOT NULL, — 時間帯ディメンション
store_id INT NOT NULL,
menu_id INT NOT NULL,
weather_id INT NOT NULL, — 天気ディメンション
— メジャー
quantity INT NOT NULL,
unit_price DECIMAL(10, 2) NOT NULL,
sales_amount DECIMAL(12, 2) NOT NULL,
cost_amount DECIMAL(12, 2),
table_number INT,
party_size INT, — 人数
FOREIGN KEY (date_id) REFERENCES dim_date(date_id),
FOREIGN KEY (time_id) REFERENCES dim_time(time_id),
FOREIGN KEY (store_id) REFERENCES dim_store(store_id),
FOREIGN KEY (menu_id) REFERENCES dim_menu(menu_id),
FOREIGN KEY (weather_id) REFERENCES dim_weather(weather_id)
);
— 日付ディメンション
CREATE TABLE dim_date (
date_id INT PRIMARY KEY,
full_date DATE NOT NULL,
year INT NOT NULL,
month INT NOT NULL,
day_of_week INT NOT NULL,
day_name VARCHAR(20) NOT NULL,
is_weekend BOOLEAN NOT NULL,
is_holiday BOOLEAN NOT NULL,
event_name VARCHAR(100) — イベント名(クリスマス、バレンタインなど)
);
— 時間帯ディメンション
CREATE TABLE dim_time (
time_id INT PRIMARY KEY,
hour INT NOT NULL, — 0-23
time_band VARCHAR(20) NOT NULL,– ‘モーニング’, ‘ランチ’, ‘ディナー’, ‘レイトナイト’
is_peak_hour BOOLEAN NOT NULL — ピークタイムかどうか
);
— 店舗ディメンション
CREATE TABLE dim_store (
store_id INT PRIMARY KEY,
store_name VARCHAR(100) NOT NULL,
prefecture VARCHAR(50) NOT NULL,
city VARCHAR(100) NOT NULL,
store_type VARCHAR(50), — ‘路面店’, ‘モール内’, ‘駅構内’
seats_count INT,
opening_date DATE,
manager_name VARCHAR(100)
);
— メニューディメンション
CREATE TABLE dim_menu (
menu_id INT PRIMARY KEY,
menu_name VARCHAR(200) NOT NULL,
category VARCHAR(50) NOT NULL, — ‘メイン’, ‘サイド’, ‘ドリンク’, ‘デザート’
sub_category VARCHAR(50),
unit_price DECIMAL(10, 2) NOT NULL,
cost_price DECIMAL(10, 2),
is_seasonal BOOLEAN DEFAULT false,
is_active BOOLEAN DEFAULT true
);
— 天気ディメンション
CREATE TABLE dim_weather (
weather_id INT PRIMARY KEY,
weather_condition VARCHAR(50) NOT NULL, — ‘晴れ’, ‘曇り’, ‘雨’, ‘雪’
temperature_range VARCHAR(20), — ‘氷点下’, ‘寒い’, ‘涼しい’, ‘暖かい’, ‘暑い’
precipitation BOOLEAN NOT NULL — 降水があったか
);
分析クエリ例:
— 天気別・時間帯別の売上分析
SELECT
w.weather_condition AS 天気,
t.time_band AS 時間帯,
COUNT(*) AS 販売件数,
SUM(f.sales_amount) AS 売上合計,
ROUND(AVG(f.party_size), 1) AS 平均人数
FROM fact_restaurant_sales f
JOIN dim_weather w ON f.weather_id = w.weather_id
JOIN dim_time t ON f.time_id = t.time_id
JOIN dim_date d ON f.date_id = d.date_id
WHERE d.year = 2025 AND d.month = 1
GROUP BY w.weather_condition, t.time_band
ORDER BY w.weather_condition, t.time_id;
ビジネス要件によります。商品価格の変更履歴を保持したい、顧客セグメントの変化を追跡したい、過去の分析結果を正確に再現したい場合はSCD Type 2が必要です。一方、履歴が不要で常に最新の情報で分析したい場合、システムをシンプルに保ちたい場合はSCD Type 1で十分です。多くの場合、商品ディメンションと顧客ディメンションにはSCD Type 2を適用し、他のディメンションはSCD Type 1で十分です。