📋 このステップで学ぶこと
ECサイトの要件分析と複雑な設計
商品、カート、注文、在庫管理の設計
トランザクション処理の考慮
決済履歴と注文ステータス管理
レポート用スタースキーマ設計
在庫管理ロジックとデータ整合性
学習時間の目安: 3.5時間 | 難易度: 中級〜上級
📋 1. ECサイトの要件定義
STEP 18からの続き:より複雑なシステムへ
STEP 18ではSNSのデータベース設計を学びました。このSTEP 19では、さらに複雑なECサイト(オンラインショップ) のデータベースを設計します。ECサイトはトランザクション処理 、在庫管理 、決済処理 など、データの整合性が非常に重要なシステムです。Amazon風のフルスケールなECサイトを想定して設計しましょう。
主要機能
🎯 ECサイトで必要な機能
商品管理 :商品情報、カテゴリ、画像、在庫
ユーザー管理 :会員登録、配送先情報
カート機能 :商品をカートに追加、数量変更
注文処理 :注文確定、注文ステータス管理
決済処理 :クレジットカード、銀行振込等
在庫管理 :在庫数の管理、在庫切れ防止
レビュー機能 :商品レビューと評価
レポート :売上分析、商品別売上
エンティティの洗い出し
エンティティ
説明
主な属性
users
顧客情報
ID、名前、メール、パスワード
addresses
配送先住所
ID、郵便番号、住所、電話番号
categories
商品カテゴリ
ID、カテゴリ名、親カテゴリID
products
商品情報
ID、商品名、価格、説明
product_images
商品画像
ID、商品ID、画像URL
inventory
在庫情報
商品ID、在庫数、倉庫ID
carts
カート
ID、ユーザーID
cart_items
カート内商品
カートID、商品ID、数量
orders
注文
ID、ユーザーID、合計金額、ステータス
order_items
注文明細
注文ID、商品ID、数量、単価
payments
決済履歴
ID、注文ID、決済方法、金額
reviews
商品レビュー
ID、商品ID、ユーザーID、評価
📐 2. ER図とテーブル設計
usersテーブル
CREATE TABLE users (
— 主キー:ユーザーの一意識別子
user_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
— ログイン用メールアドレス(重複不可)
email VARCHAR(255) NOT NULL UNIQUE,
— パスワードハッシュ(平文は絶対NG)
password_hash VARCHAR(255) NOT NULL,
— 名前:姓と名を分けて保存(日本語対応)
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
— 連絡先電話番号(任意)
phone VARCHAR(20),
— タイムスタンプ
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
— インデックス:メールでのログイン検索用
INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
addressesテーブル
CREATE TABLE addresses (
— 主キー:配送先の一意識別子
address_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
— どのユーザーの住所か
user_id BIGINT UNSIGNED NOT NULL,
— 住所情報:日本の住所形式に対応
postal_code VARCHAR(10) NOT NULL, — 郵便番号(123-4567)
prefecture VARCHAR(50) NOT NULL, — 都道府県
city VARCHAR(100) NOT NULL, — 市区町村
street VARCHAR(255) NOT NULL, — 町名・番地
building VARCHAR(100), — 建物名・部屋番号(任意)
— 配送先電話番号
phone VARCHAR(20) NOT NULL,
— デフォルト配送先フラグ
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
— 外部キー:ユーザー削除時に住所も削除
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
categoriesテーブル(階層構造)
CREATE TABLE categories (
— 主キー:カテゴリの一意識別子
category_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
— カテゴリ名(例:家電、パソコン、ノートPC)
category_name VARCHAR(100) NOT NULL,
— 親カテゴリID:自己参照で階層構造を表現
— NULLなら最上位カテゴリ
parent_category_id BIGINT UNSIGNED NULL,
— 表示順序
display_order INT UNSIGNED DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
— 外部キー:親カテゴリ削除時はNULLに
FOREIGN KEY (parent_category_id) REFERENCES categories(category_id) ON DELETE SET NULL,
INDEX idx_parent (parent_category_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
🔗 カテゴリの階層構造の例
家電 (parent_category_id = NULL)
├─ パソコン・周辺機器 (parent_category_id = 1)
│ ├─ ノートPC (parent_category_id = 2)
│ └─ デスクトップPC (parent_category_id = 2)
└─ スマートフォン (parent_category_id = 1)
productsテーブル
CREATE TABLE products (
— 主キー:商品の一意識別子
product_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
— どのカテゴリに属するか
category_id BIGINT UNSIGNED NOT NULL,
— 商品情報
product_name VARCHAR(255) NOT NULL, — 商品名
description TEXT, — 商品説明(長文対応)
— 価格情報:DECIMAL型で正確な金額を保存
price DECIMAL(10, 2) NOT NULL, — 販売価格
cost_price DECIMAL(10, 2), — 原価(レポート・利益計算用)
— 販売状態:FALSEなら非公開
is_active BOOLEAN DEFAULT TRUE,
— レビュー情報:非正規化(毎回計算より高速)
average_rating DECIMAL(3, 2) DEFAULT 0.00, — 平均評価(0.00〜5.00)
reviews_count INT UNSIGNED DEFAULT 0, — レビュー数
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
— 外部キー:カテゴリがある商品は削除禁止(RESTRICT)
FOREIGN KEY (category_id) REFERENCES categories(category_id) ON DELETE RESTRICT,
— インデックス:カテゴリ別・価格順・評価順の検索用
INDEX idx_category (category_id),
INDEX idx_price (price),
INDEX idx_rating (average_rating DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
💰 価格はDECIMAL型
DECIMAL(10, 2) = 最大8桁.小数点以下2桁
例:99999999.99(約1億円まで対応)
浮動小数点型(FLOAT, DOUBLE)は誤差が出るため金額には使わない!
product_imagesテーブル
CREATE TABLE product_images (
— 主キー:画像の一意識別子
image_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
— どの商品の画像か
product_id BIGINT UNSIGNED NOT NULL,
— 画像URL(外部ストレージの場合はCDN URL)
image_url VARCHAR(500) NOT NULL,
— 表示順序:小さい順に表示
display_order INT UNSIGNED DEFAULT 0,
— メイン画像フラグ:一覧表示用
is_primary BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
— 外部キー:商品削除時に画像も削除
FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE,
INDEX idx_product (product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
inventoryテーブル(在庫管理)
CREATE TABLE inventory (
— 主キー:在庫レコードの一意識別子
inventory_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
— どの商品の在庫か
product_id BIGINT UNSIGNED NOT NULL,
— 倉庫ロケーション(複数倉庫対応)
warehouse_location VARCHAR(100) DEFAULT ‘main’,
— 在庫数:実際の在庫数
quantity INT UNSIGNED NOT NULL DEFAULT 0,
— 予約数:カートに入れられて予約中の数
— 利用可能在庫 = quantity – reserved_quantity
reserved_quantity INT UNSIGNED DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
— 外部キー:商品削除時に在庫も削除
FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE,
— ユニーク制約:同じ商品・倉庫の組み合わせは1レコードのみ
UNIQUE KEY unique_product_warehouse (product_id, warehouse_location),
INDEX idx_product (product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
🔒 在庫管理の重要性
quantity :実在庫数
reserved_quantity :カート内で予約中の数
利用可能在庫 = quantity – reserved_quantity
注文確定時に reserved_quantity を quantity から引く
cartsテーブル
CREATE TABLE carts (
— 主キー:カートの一意識別子
cart_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
— どのユーザーのカートか
user_id BIGINT UNSIGNED NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
— 外部キー:ユーザー削除時にカートも削除
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
— ユニーク制約:1ユーザーにつき1カートのみ
UNIQUE KEY unique_user_cart (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
cart_itemsテーブル
CREATE TABLE cart_items (
— 主キー:カートアイテムの一意識別子
cart_item_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
— どのカートに入っているか
cart_id BIGINT UNSIGNED NOT NULL,
— どの商品か
product_id BIGINT UNSIGNED NOT NULL,
— 数量
quantity INT UNSIGNED NOT NULL DEFAULT 1,
— カートに追加した日時
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
— 外部キー:カート・商品削除時に自動削除
FOREIGN KEY (cart_id) REFERENCES carts(cart_id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE,
— ユニーク制約:同じカートに同じ商品は1レコードのみ(数量で管理)
UNIQUE KEY unique_cart_product (cart_id, product_id),
INDEX idx_cart (cart_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ordersテーブル
CREATE TABLE orders (
— 主キー:注文の一意識別子
order_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
— 注文者
user_id BIGINT UNSIGNED NOT NULL,
— 配送先住所ID
shipping_address_id BIGINT UNSIGNED NOT NULL,
— 金額情報:DECIMAL型で正確に保存
total_amount DECIMAL(10, 2) NOT NULL, — 注文合計
shipping_fee DECIMAL(10, 2) DEFAULT 0.00, — 送料
tax_amount DECIMAL(10, 2) DEFAULT 0.00, — 税額
— 注文・支払いステータス:ENUM型で固定値のみ許可
order_status ENUM(‘pending’, ‘confirmed’, ‘shipped’, ‘delivered’, ‘cancelled’) DEFAULT ‘pending’,
payment_status ENUM(‘pending’, ‘paid’, ‘failed’, ‘refunded’) DEFAULT ‘pending’,
— 各ステータスの日時
ordered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, — 注文日時
shipped_at TIMESTAMP NULL, — 発送日時
delivered_at TIMESTAMP NULL, — 配達完了日時
— 外部キー:RESTRICT=注文がある限りユーザー・住所は削除不可
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE RESTRICT,
FOREIGN KEY (shipping_address_id) REFERENCES addresses(address_id) ON DELETE RESTRICT,
INDEX idx_user (user_id),
INDEX idx_status (order_status),
INDEX idx_ordered_at (ordered_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
⚠️ ON DELETE RESTRICTの理由
注文情報は履歴として永続的に保存 する必要があります。
ユーザーが退会しても、過去の注文履歴は残すため、RESTRICTにしています。
order_itemsテーブル
CREATE TABLE order_items (
— 主キー:注文明細の一意識別子
order_item_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
— どの注文の明細か
order_id BIGINT UNSIGNED NOT NULL,
— どの商品か
product_id BIGINT UNSIGNED NOT NULL,
— スナップショット:注文時点の情報を保存
— 商品名・価格が後で変わっても注文履歴には影響しない
product_name VARCHAR(255) NOT NULL, — 注文時の商品名
unit_price DECIMAL(10, 2) NOT NULL, — 注文時の単価
— 数量と小計
quantity INT UNSIGNED NOT NULL,
subtotal DECIMAL(10, 2) NOT NULL, — 単価 × 数量
— 外部キー
FOREIGN KEY (order_id) REFERENCES orders(order_id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE RESTRICT,
INDEX idx_order (order_id),
INDEX idx_product (product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
📸 スナップショット設計
product_name とunit_price は、注文時点の情報を保存します。
商品名や価格が後で変わっても、注文履歴には注文時の情報 が残ります。
paymentsテーブル
CREATE TABLE payments (
— 主キー:支払いの一意識別子
payment_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
— どの注文の支払いか
order_id BIGINT UNSIGNED NOT NULL,
— 支払い方法:ENUM型で固定値のみ許可
payment_method ENUM(‘credit_card’, ‘bank_transfer’, ‘cash_on_delivery’, ‘paypal’) NOT NULL,
— 支払い金額
amount DECIMAL(10, 2) NOT NULL,
— 支払いステータス
payment_status ENUM(‘pending’, ‘completed’, ‘failed’, ‘refunded’) DEFAULT ‘pending’,
— 決済サービスから返されるトランザクションID(照合用)
transaction_id VARCHAR(255),
— 支払い完了日時
paid_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
— 外部キー:支払いがあるなら注文は削除不可
FOREIGN KEY (order_id) REFERENCES orders(order_id) ON DELETE RESTRICT,
INDEX idx_order (order_id),
INDEX idx_transaction (transaction_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
reviewsテーブル
CREATE TABLE reviews (
— 主キー:レビューの一意識別子
review_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
— どの商品へのレビューか
product_id BIGINT UNSIGNED NOT NULL,
— 誰が書いたレビューか
user_id BIGINT UNSIGNED NOT NULL,
— 購入済み確認用:この注文で購入した商品のレビュー
order_id BIGINT UNSIGNED NOT NULL,
— 評価:1〜5の整数(CHECK制約で範囲を強制)
rating INT UNSIGNED NOT NULL CHECK (rating BETWEEN 1 AND 5),
— レビューコメント(任意)
comment TEXT,
— 購入確認済みフラグ(「購入済みユーザー」バッジ表示用)
is_verified_purchase BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
— 外部キー
FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
FOREIGN KEY (order_id) REFERENCES orders(order_id) ON DELETE CASCADE,
— ユニーク制約:同じ注文で同じ商品に複数レビューは不可
UNIQUE KEY unique_user_product_order (user_id, product_id, order_id),
INDEX idx_product (product_id),
INDEX idx_rating (rating DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
🔄 3. トランザクション処理の設計
注文確定の流れ
ECサイトで最も重要な注文処理 は、複数のテーブルを更新するため、トランザクション で保護します。
START TRANSACTION;
— 1. カート内容を取得
SELECT ci.product_id, ci.quantity, p.price
FROM cart_items ci
INNER JOIN products p ON ci.product_id = p.product_id
WHERE ci.cart_id = 123;
— 2. 在庫チェック
SELECT product_id, (quantity – reserved_quantity) as available
FROM inventory
WHERE product_id IN (1, 2, 3)
FOR UPDATE; — 行ロック
— 3. 在庫が足りない場合はROLLBACK
— (アプリケーション側で判断)
— 4. 注文を作成
INSERT INTO orders (user_id, shipping_address_id, total_amount, shipping_fee, tax_amount)
VALUES (10, 5, 15000.00, 500.00, 1500.00);
SET @order_id = LAST_INSERT_ID();
— 5. 注文明細を作成
INSERT INTO order_items (order_id, product_id, product_name, unit_price, quantity, subtotal)
SELECT @order_id, p.product_id, p.product_name, p.price, ci.quantity, p.price * ci.quantity
FROM cart_items ci
INNER JOIN products p ON ci.product_id = p.product_id
WHERE ci.cart_id = 123;
— 6. 在庫を減らす
UPDATE inventory i
INNER JOIN cart_items ci ON i.product_id = ci.product_id AND ci.cart_id = 123
SET i.quantity = i.quantity – ci.quantity,
i.reserved_quantity = i.reserved_quantity – ci.quantity;
— 7. カートを空にする
DELETE FROM cart_items WHERE cart_id = 123;
COMMIT;
⚠️ トランザクション処理の注意点
FOR UPDATE :在庫確認時に行ロックをかける(同時注文対策)
在庫チェック :在庫不足ならROLLBACKする
スナップショット :注文時の商品名・価格を保存
デッドロック対策 :テーブル更新順序を統一
📊 4. レポート用スタースキーマ設計
スタースキーマとは?
売上分析のために、OLTP (トランザクション処理)とは別に、OLAP (分析処理)用のテーブルを設計します。
【スタースキーマの構造】
┌────────────┐
│ dim_date │
└────────────┘
│
│
┌─────┴──────┐
│ │
┌───────┴────┐ ┌────┴────────┐ ┌─────────────┐
│ dim_product│ │ fact_sales │ │ dim_customer│
└────────────┘ └─────────────┘ └─────────────┘
│
┌─────┴──────┐
│ │
┌───────┴────┐ ┌────┴────────┐
│ dim_region │ │ dim_payment │
└────────────┘ └─────────────┘
fact_salesテーブル(ファクトテーブル)
CREATE TABLE fact_sales (
sale_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT UNSIGNED NOT NULL,
date_id INT UNSIGNED NOT NULL, — 日付ディメンション
product_id BIGINT UNSIGNED NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL,
region_id INT UNSIGNED,
payment_method_id INT UNSIGNED,
quantity INT UNSIGNED NOT NULL,
unit_price DECIMAL(10, 2) NOT NULL,
sales_amount DECIMAL(10, 2) NOT NULL, — 売上金額
cost_amount DECIMAL(10, 2), — 原価
profit_amount DECIMAL(10, 2), — 利益
INDEX idx_date (date_id),
INDEX idx_product (product_id),
INDEX idx_customer (customer_id),
INDEX idx_composite (date_id, product_id, customer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
dim_dateテーブル(日付ディメンション)
CREATE TABLE dim_date (
date_id INT UNSIGNED PRIMARY KEY,
full_date DATE NOT NULL,
year INT UNSIGNED NOT NULL,
month INT UNSIGNED NOT NULL,
day INT UNSIGNED NOT NULL,
quarter INT UNSIGNED NOT NULL,
day_of_week INT UNSIGNED NOT NULL,
week_of_year INT UNSIGNED NOT NULL,
is_weekend BOOLEAN DEFAULT FALSE,
is_holiday BOOLEAN DEFAULT FALSE,
INDEX idx_full_date (full_date),
INDEX idx_year_month (year, month)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
📅 日付ディメンションの例
date_id: 20240115
full_date: 2024-01-15
year: 2024
month: 1
day: 15
quarter: 1
day_of_week: 1 (月曜日)
week_of_year: 3
is_weekend: FALSE
is_holiday: FALSE
売上分析クエリ例
— 月別売上
SELECT
d.year,
d.month,
SUM(f.sales_amount) as total_sales,
SUM(f.profit_amount) as total_profit
FROM fact_sales f
INNER JOIN dim_date d ON f.date_id = d.date_id
GROUP BY d.year, d.month
ORDER BY d.year DESC, d.month DESC;
— 商品別売上TOP10
SELECT
p.product_name,
SUM(f.quantity) as total_quantity,
SUM(f.sales_amount) as total_sales
FROM fact_sales f
INNER JOIN products p ON f.product_id = p.product_id
WHERE f.date_id >= 20240101 AND f.date_id <= 20241231
GROUP BY p.product_id, p.product_name
ORDER BY total_sales DESC
LIMIT 10;
🔒 5. 在庫管理とデータ整合性
在庫ロック戦略
🛡️ 楽観的ロック vs 悲観的ロック
楽観的ロック(Optimistic Locking)
更新時にバージョン番号をチェック
競合が少ない場合に有効
ALTER TABLE inventory ADD COLUMN version INT UNSIGNED DEFAULT 0;
UPDATE inventory
SET quantity = quantity – 1,
version = version + 1
WHERE product_id = 123 AND version = 5; — バージョンチェック
悲観的ロック(Pessimistic Locking)
読み取り時に行ロック(FOR UPDATE)
確実だが、パフォーマンス低下
SELECT * FROM inventory
WHERE product_id = 123
FOR UPDATE; — 行ロック
在庫不足の防止
— カート追加時に在庫チェック
INSERT INTO cart_items (cart_id, product_id, quantity)
SELECT 123, 456, 2
FROM inventory
WHERE product_id = 456
AND (quantity – reserved_quantity) >= 2;
— 在庫を予約
UPDATE inventory
SET reserved_quantity = reserved_quantity + 2
WHERE product_id = 456;
⚡ 6. パフォーマンス最適化
インデックス設計
— 商品検索用
CREATE INDEX idx_products_category_price ON products(category_id, price);
CREATE INDEX idx_products_active_rating ON products(is_active, average_rating DESC);
— 注文検索用
CREATE INDEX idx_orders_user_date ON orders(user_id, ordered_at DESC);
CREATE INDEX idx_orders_status ON orders(order_status, ordered_at DESC);
— 売上分析用(複合インデックス)
CREATE INDEX idx_fact_sales_analysis ON fact_sales(date_id, product_id, customer_id);
キャッシュ戦略
🚀 Redisを使ったキャッシュ例
商品詳細 :30分キャッシュ(頻繁に見られる)
カート内容 :リアルタイム(キャッシュしない)
人気商品ランキング :1時間キャッシュ
在庫数 :5分キャッシュ(整合性重視)
読み取りレプリカの活用
【データベース構成】
Master DB(書き込み専用)
↓ レプリケーション
Replica DB(読み取り専用)
【使い分け】
✅ 商品検索、一覧表示 → Replica DB
✅ 注文処理、在庫更新 → Master DB
✅ 売上レポート → 専用のAnalytics DB
📝 7. STEP 19 のまとめ
✅ このステップで学んだこと
ECサイトの複雑な要件分析 と設計
トランザクション処理 の実装(注文確定の流れ)
在庫管理 ロジックとデータ整合性
スナップショット設計 (注文時の価格保存)
スタースキーマ によるレポート設計
楽観的ロック/悲観的ロック の使い分け
💡 重要ポイント
ECサイトはデータ整合性 が最重要(特に在庫・注文)
トランザクション を使って複数テーブルの更新を保護
スナップショット で過去の注文情報を正確に保持
DECIMAL型 を使って金額の誤差をなくす
OLTP とOLAP を分離してパフォーマンス向上
🎯 次のステップへ
いよいよ最終ステップ!STEP 20では予約システムの設計 に挑戦します。
時間軸の考慮やダブルブッキング防止など、さらに高度な設計を学びましょう。
📝 総合演習
演習 1
応用
クーポン機能を追加してください。
要件:
クーポンコードで割引(固定額または割引率)
有効期限と利用回数制限
特定の商品やカテゴリに限定可能
解答を見る
【解答】
CREATE TABLE coupons (
coupon_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
coupon_code VARCHAR(50) NOT NULL UNIQUE,
discount_type ENUM(‘fixed’, ‘percentage’) NOT NULL,
discount_value DECIMAL(10, 2) NOT NULL,
min_purchase_amount DECIMAL(10, 2) DEFAULT 0.00,
max_discount_amount DECIMAL(10, 2), — 割引率の場合の上限
valid_from TIMESTAMP NOT NULL,
valid_until TIMESTAMP NOT NULL,
max_usage_count INT UNSIGNED,
current_usage_count INT UNSIGNED DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_code (coupon_code),
INDEX idx_valid (valid_from, valid_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE coupon_products (
coupon_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (coupon_id, product_id),
FOREIGN KEY (coupon_id) REFERENCES coupons(coupon_id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE order_coupons (
order_id BIGINT UNSIGNED NOT NULL,
coupon_id BIGINT UNSIGNED NOT NULL,
discount_amount DECIMAL(10, 2) NOT NULL,
PRIMARY KEY (order_id),
FOREIGN KEY (order_id) REFERENCES orders(order_id) ON DELETE CASCADE,
FOREIGN KEY (coupon_id) REFERENCES coupons(coupon_id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
演習 2
発展
ポイントシステムを追加してください。
要件:
購入金額の1%がポイントとして付与
ポイントは次回注文で使用可能
ポイントの有効期限は1年間
ポイント履歴を記録
解答を見る
【解答】
— ユーザーのポイント残高
CREATE TABLE user_points (
user_id BIGINT UNSIGNED PRIMARY KEY,
total_points INT UNSIGNED DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
— ポイント履歴
CREATE TABLE point_transactions (
transaction_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED,
transaction_type ENUM(‘earn’, ‘use’, ‘expire’) NOT NULL,
points INT NOT NULL, — 獲得:+、使用:-
balance_after INT UNSIGNED NOT NULL,
expires_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
FOREIGN KEY (order_id) REFERENCES orders(order_id) ON DELETE SET NULL,
INDEX idx_user_date (user_id, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
— ポイント獲得処理(注文完了時)
DELIMITER //
CREATE TRIGGER after_order_complete
AFTER UPDATE ON orders
FOR EACH ROW
BEGIN
DECLARE earned_points INT;
IF NEW.order_status = ‘delivered’ AND OLD.order_status <> ‘delivered’ THEN
— 1%をポイント付与(100円=1ポイント)
SET earned_points = FLOOR(NEW.total_amount / 100);
— ポイント残高を更新
INSERT INTO user_points (user_id, total_points)
VALUES (NEW.user_id, earned_points)
ON DUPLICATE KEY UPDATE total_points = total_points + earned_points;
— ポイント履歴を記録
INSERT INTO point_transactions (user_id, order_id, transaction_type, points, balance_after, expires_at)
SELECT NEW.user_id, NEW.order_id, ‘earn’, earned_points, total_points, DATE_ADD(NOW(), INTERVAL 1 YEAR)
FROM user_points
WHERE user_id = NEW.user_id;
END IF;
END //
DELIMITER ;
演習 3
発展
ウィッシュリスト(お気に入り)機能を追加してください。
要件:
ユーザーが商品をウィッシュリストに追加できる
複数のウィッシュリストを作成可能(例:「誕生日」「クリスマス」)
ウィッシュリストを公開/非公開に設定可能
解答を見る
【解答】
— ウィッシュリスト
CREATE TABLE wishlists (
wishlist_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
wishlist_name VARCHAR(100) NOT NULL DEFAULT ‘デフォルト’,
is_public BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
INDEX idx_user (user_id),
INDEX idx_public (is_public)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
— ウィッシュリスト商品
CREATE TABLE wishlist_items (
wishlist_item_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
wishlist_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
priority INT UNSIGNED DEFAULT 0, — 優先度
note TEXT, — メモ
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (wishlist_id) REFERENCES wishlists(wishlist_id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE,
UNIQUE KEY unique_wishlist_product (wishlist_id, product_id),
INDEX idx_wishlist (wishlist_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
— ウィッシュリスト取得クエリ
SELECT
wl.wishlist_name,
p.product_id,
p.product_name,
p.price,
pi.image_url,
wi.priority,
wi.note
FROM wishlists wl
INNER JOIN wishlist_items wi ON wl.wishlist_id = wi.wishlist_id
INNER JOIN products p ON wi.product_id = p.product_id
LEFT JOIN product_images pi ON p.product_id = pi.product_id AND pi.is_primary = TRUE
WHERE wl.user_id = 1
ORDER BY wl.wishlist_name, wi.priority DESC, wi.added_at DESC;
❓ よくある質問
Q1: なぜ注文テーブルに商品名と価格を保存するのですか?
注文時点のスナップショットを残すためです。 商品名や価格は後から変更される可能性があります。もし注文テーブルが商品テーブルを参照するだけだと、「あのとき3,000円で買ったはずなのに、今見たら5,000円になっている」という事態が起きます。過去の注文履歴を正確に保持するために、注文時の情報をスナップショットとして保存します。
Q2: 在庫管理でreserved_quantityを使う理由は?
カート内での仮押さえを管理するためです。 ユーザーがカートに商品を追加した時点で在庫を「予約」し、他のユーザーが同時に購入しようとした際の在庫切れを防ぎます。ただし、カートに入れたまま放置されるケースもあるため、一定時間経過後に予約を解除する仕組み(カート有効期限)も必要です。
Q3: カテゴリの階層構造で「閉包テーブル」を使わないのはなぜ?
シンプルさを優先したためです。 閉包テーブル(Closure Table)パターンを使うと、任意の深さの階層を効率的に取得できます。しかし、ECサイトのカテゴリは通常3〜4階層程度なので、自己参照(parent_category_id)で十分対応できます。深い階層が必要な場合や、頻繁に祖先・子孫を取得する場合は閉包テーブルを検討してください。
Q4: 楽観的ロックと悲観的ロックはどちらを使うべき?
競合の頻度によります。 ECサイトの在庫管理では、同じ商品を同時に購入するケースが多い(人気商品のセールなど)場合は悲観的ロック(FOR UPDATE)が安全です。一般的な商品で競合が少ない場合は、楽観的ロック(バージョン番号)の方がパフォーマンスが良いです。実務では、商品の人気度に応じて使い分けることもあります。
Q5: OLTP用とOLAP用のデータベースを分ける必要がありますか?
規模によります。 小規模なECサイトでは、同じデータベース上でレポート用のビューを作成するだけで十分です。しかし、注文数が増えてレポートクエリが重くなると、本番のトランザクション処理に影響が出ます。その場合は、レプリカDBを使う、または専用のデータウェアハウス(Amazon Redshift、BigQueryなど)を構築することを検討します。
Q6: 消費税の計算はどこで行うべきですか?
注文確定時にアプリケーション側で計算し、DBに保存します。 消費税率は法改正で変わる可能性があるため、注文時点の税率と税額を order テーブルに保存しておくことが重要です。また、軽減税率(食品は8%、それ以外は10%など)に対応する場合は、商品ごとに税率を持たせる設計も検討が必要です。
×
artnasekai
#artnasekai #学習メモ