#06 データベースを設計する

ECサイトを設計する(実践)

これまで学んだ設計理論を実践に活かす時間です。今回はECサイトを題材に、要件整理からER図、CREATE TABLE 文まで一気通貫で設計します。

要件整理

今回設計するECサイトの要件は次のとおりです。

ユーザー管理:

  • 会員登録・ログインができる
  • 複数の配送先住所を登録できる

商品管理:

  • 商品はカテゴリに属する(カテゴリは階層なし)
  • 商品には在庫数がある
  • 商品にユーザーがレビュー(評価+コメント)を書ける

購買フロー:

  • カートに商品を追加できる
  • カートから注文を確定できる
  • 注文には使用した配送先住所が記録される
  • 注文後に商品価格が変わっても注文時の価格は変わらない

これらの要件から、以下の8テーブルが必要と判断できます。

users, addresses, categories, products, carts, orders, order_items, reviews

完全なER図

ECサイト完全ER図(8テーブル) users PK id name email UNIQUE password created_at updated_at deleted_at NULL addresses PK id FK user_id postal_code prefecture city address1 is_default CASCADE on delete categories PK id name slug UNIQUE created_at products PK id FK category_id NULL name description TEXT price INT stock INT created_at SET NULL on cat del carts PK id FK user_id FK product_id quantity UNIQUE(user, product) CASCADE on del orders PK id FK user_id shipping_addr TEXT total_price INT status ENUM ordered_at RESTRICT on user del IDX: user_id, status order_items PK id FK order_id FK product_id quantity unit_price (snapshot) CASCADE on order del RESTRICT on prod del IDX: product_id reviews PK id FK user_id FK product_id rating TINYINT(1-5) comment TEXT created_at UNIQUE(user, product) IDX: product_id 1:N 1:N 1:N 1:N 1:N 1:N 1:N 1:N 1:N 凡例 ユーザー系テーブル 商品系テーブル 注文系テーブル PK = Primary Key FK = Foreign Key 破線 = リレーション(1:N = 1対多) snapshot = 注文時点の値を保存
図1: ECサイトの完全ER図(8テーブル)

CREATE TABLE 文

DDL(CREATE TABLE)概要 テーブル作成順序(外部キー制約のため依存順に作成) users 参照なし 最初に作成 categories 参照なし 2番目に作成 addresses FK: users.id CASCADE products FK: categories.id SET NULL carts FK: users, products CASCADE orders FK: users.id RESTRICT order_items FK: orders.id CASCADE FK: products.id reviews FK: users, products CASCADE インデックス設計 テーブル インデックス 用途・理由 users UNIQUE(email) ログイン検索・重複防止 products idx_products_category_id カテゴリ別商品一覧 orders idx_orders_user_id, status 購入履歴・ステータス絞り込み reviews idx_reviews_product_id 商品詳細ページのレビュー取得
図2: DDL全体像

users テーブル

CREATE TABLE users (
  id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name       VARCHAR(100) NOT NULL,
  email      VARCHAR(255) NOT NULL,
  password   VARCHAR(255) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uq_users_email (email)
);

addresses テーブル

CREATE TABLE addresses (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id     BIGINT UNSIGNED NOT NULL,
  postal_code VARCHAR(8) NOT NULL,
  prefecture  VARCHAR(10) NOT NULL,
  city        VARCHAR(100) NOT NULL,
  address1    VARCHAR(255) NOT NULL,
  address2    VARCHAR(255),
  is_default  TINYINT(1) NOT NULL DEFAULT 0,
  created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT fk_addresses_users FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
  KEY idx_addresses_user_id (user_id)
);

is_default フラグで「デフォルト住所」を管理します。アプリケーション側で「1ユーザーにデフォルト住所は1つ」を保証します。

categories テーブル

CREATE TABLE categories (
  id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name       VARCHAR(100) NOT NULL,
  slug       VARCHAR(100) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uq_categories_slug (slug)
);

slug はURL用の識別子(例: mens-shirts)です。URLに使うため UNIQUE 制約をかけます。

products テーブル

CREATE TABLE products (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  category_id BIGINT UNSIGNED,
  name        VARCHAR(255) NOT NULL,
  description TEXT,
  price       INT UNSIGNED NOT NULL,
  stock       INT UNSIGNED NOT NULL DEFAULT 0,
  created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  CONSTRAINT fk_products_categories
    FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE SET NULL,
  KEY idx_products_category_id (category_id)
);

カテゴリが削除されても商品は残したいため ON DELETE SET NULL を使います。

carts テーブル

CREATE TABLE carts (
  id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id    BIGINT UNSIGNED NOT NULL,
  product_id BIGINT UNSIGNED NOT NULL,
  quantity   INT UNSIGNED NOT NULL DEFAULT 1,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  CONSTRAINT fk_carts_users    FOREIGN KEY (user_id)    REFERENCES users (id)    ON DELETE CASCADE,
  CONSTRAINT fk_carts_products FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
  UNIQUE KEY uq_carts_user_product (user_id, product_id)
);

UNIQUE KEY uq_carts_user_product により、同じユーザーが同じ商品を2行追加するのを防ぎます。

orders テーブル

CREATE TABLE orders (
  id                BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id           BIGINT UNSIGNED NOT NULL,
  shipping_address  TEXT NOT NULL,  -- 注文時の住所スナップショット(JSON形式)
  total_price       INT UNSIGNED NOT NULL,
  status            ENUM('pending', 'paid', 'shipped', 'delivered', 'cancelled') NOT NULL DEFAULT 'pending',
  ordered_at        TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT fk_orders_users FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT,
  KEY idx_orders_user_id (user_id),
  KEY idx_orders_status (status)
);

shipping_address はJSON文字列で住所情報を保持します。addresses テーブルへの外部キーではなく、注文時の住所をスナップショットとして保存することで、後から住所が変更・削除されても注文の配送先が変わりません。

order_items テーブル

CREATE TABLE order_items (
  id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id   BIGINT UNSIGNED NOT NULL,
  product_id BIGINT UNSIGNED NOT NULL,
  quantity   INT UNSIGNED NOT NULL DEFAULT 1,
  unit_price INT UNSIGNED NOT NULL,  -- 注文時の価格スナップショット
  CONSTRAINT fk_order_items_orders   FOREIGN KEY (order_id)   REFERENCES orders (id)   ON DELETE CASCADE,
  CONSTRAINT fk_order_items_products FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE RESTRICT,
  UNIQUE KEY uq_order_items_order_product (order_id, product_id),
  KEY idx_order_items_product_id (product_id)
);

reviews テーブル

CREATE TABLE reviews (
  id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id    BIGINT UNSIGNED NOT NULL,
  product_id BIGINT UNSIGNED NOT NULL,
  rating     TINYINT UNSIGNED NOT NULL,  -- 1〜5
  comment    TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT fk_reviews_users    FOREIGN KEY (user_id)    REFERENCES users (id)    ON DELETE CASCADE,
  CONSTRAINT fk_reviews_products FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
  UNIQUE KEY uq_reviews_user_product (user_id, product_id),  -- 1ユーザー1商品につき1レビュー
  KEY idx_reviews_product_id (product_id)
);

インデックスの設計

適切な インデックス? テーブルの検索を高速化するためのデータ構造。本の索引のように、特定の列の値から行の位置をすばやく特定できる。検索は速くなるが、書き込みはやや遅くなる。 設計はクエリのパフォーマンスに直結します。今回設計したインデックスの根拠を説明します。

テーブルインデックス理由
usersemail (UNIQUE)ログイン時の検索、メール重複チェック
addressesuser_idユーザーの住所一覧取得
productscategory_idカテゴリ別商品一覧
ordersuser_id購入履歴表示
ordersstatus管理画面での注文ステータス絞り込み
order_itemsproduct_id商品の注文実績確認
reviewsproduct_id商品詳細ページのレビュー取得

インデックスの注意点: インデックスは読み取りを速くする一方、書き込みを遅くします。不要なインデックスは貼らないようにしましょう。

設計判断のまとめ

今回の設計で行ったトレードオフをまとめます。

  1. 住所のスナップショットorders.shipping_address を TEXT(JSON)にし、addresses への外部キーを持たない。注文後の住所変更に対応するため
  2. price のスナップショットorder_items.unit_price を持つ。商品価格変更後も注文時の価格を保持するため
  3. ON DELETE SET NULL(categories) — カテゴリ削除でも商品は消えないよう。商品の category_id は NULL を許可
  4. ENUM で status 管理 — 注文ステータスは有限のため ENUM で型安全に管理

まとめ

要件から設計に落とし込む際には、「このデータは後から変わりうるか」「このリレーションは本当に適切か」を常に問い続けることが大切です。設計には「唯一の正解」はなく、要件とトレードオフのバランスを取ることが求められます。

次回は、正規化された設計を意図的に崩す「非正規化」とパフォーマンスのトレードオフを学びます。