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

非正規化とパフォーマンスのトレードオフ

第3回で正規化を学びましたが、現実のシステムでは「正規化を完璧に守ると遅すぎて使えない」という場面に直面することがあります。そのときに用いるのが非正規化です。

非正規化とは

非正規化? パフォーマンス向上のために意図的にデータの重複を許す設計。JOINが多くなって遅い場合に、あえてデータをまとめて検索を速くする。正規化とのトレードオフで判断する。 とは、意図的にデータの冗長性を許容して、クエリのパフォーマンスを向上させる設計手法です。

「正規化が良くて、非正規化が悪い」というわけではありません。正規化は整合性を優先し、非正規化はパフォーマンスを優先するトレードオフです。どちらが適切かはシステムの要件次第です。

正規化 vs 非正規化 — トレードオフ 正規化 整合性 重視 非正規化 パフォーマンス 重視 正規化のメリット + データに重複がない + 更新が1箇所で済む + 整合性が保ちやすい + ストレージ効率が良い + 書き込みが高速 + 拡張・変更がしやすい 正規化のデメリット - JOINが多くなる - 読み取りクエリが複雑 (多くの場合インデックスで解決可) 非正規化のメリット + JOINが減る + 読み取りが高速 + クエリがシンプル + キャッシュ効率が良い + スケールしやすい (Read Heavy システムに有効) 非正規化のデメリット - データが重複する - 更新箇所が複数必要 - 整合性管理がアプリ側に 使い分けの原則 まず正規化で設計する パフォーマンスを計測する ボトルネックを特定する インデックスで解決できるか試す それでも遅ければ非正規化を検討 「将来遅くなるから」は早計! 計測に基づいて判断すること YAGNIの原則を守る
図1: 正規化vs非正規化のトレードオフ

読み取り重視 vs 書き込み重視

データベースへのアクセスパターンは大きく2種類あります。

読み取り重視(Read Heavy)

  • 商品一覧ページ、検索結果ページ
  • ダッシュボード・レポート画面
  • ユーザーが頻繁にアクセスするページ

→ JOINを減らし、1クエリで必要なデータが取れる構造にする価値がある

書き込み重視(Write Heavy)

  • センサーデータの記録
  • ログ収集
  • リアルタイム更新が必要なデータ

→ 正規化を守り、書き込みをシンプルに保つ

ECサイトで言えば、「商品一覧ページ」は読み取り重視(何千ユーザーが同時にアクセス)、「在庫の減算」は書き込み重視(在庫の整合性が重要)です。

スナップショットパターン

前回の実践編でも登場しましたが、スナップショットパターンは非正規化の代表的なテクニックです。

スナップショットパターン 注文後に価格が変わっても履歴を正確に保持する 問題のある設計(スナップショットなし) products PK id=101 name: Tシャツ price: 3000 ← 値上げ後 order_items PK id=501 order_id: 201 product_id: 101 ← FK quantity: 2 (価格は products から取得) 問題 注文時の価格: 2000円(当時) 現在の表示: 3000円(値上げ後)→ 注文履歴が間違う! 正しい設計(スナップショットあり) products PK id=101 name: Tシャツ price: 3000 ← 値上げ後 order_items PK id=501 order_id: 201 product_id: 101 unit_price: 2000 quantity: 2 ← 注文時の価格を保存 正しい動作 注文時の価格: 2000円(unit_price に保存) 現在の表示: 2000円 → 注文履歴が正確! スナップショットすべき情報の判断基準 データ スナップ必要 理由 商品価格 必要 キャンペーン・値上げで変わる 配送先住所 必要 引越し・削除後も注文先が特定できる必要がある 消費税率 必要 税制改正で変わる(過去の注文の税率を保持)
図2: スナップショットパターンの仕組み

注文時の商品名・価格スナップショット

-- 正規化を厳密に守った場合(問題あり)
order_items
  order_id   FK -> orders.id
  product_id FK -> products.id  -- 商品情報は products から取得
  quantity   INT

-- 実際の商品名と価格はJOINで取得
SELECT
  oi.quantity,
  p.name,    -- 現在の商品名
  p.price    -- 現在の価格(注文時と違う可能性!)
FROM order_items oi
JOIN products p ON oi.product_id = p.id;

この設計の問題は、商品名や価格が変わると過去の注文の表示も変わってしまうことです。

-- スナップショットパターン(実践的)
order_items
  order_id      FK -> orders.id
  product_id    FK -> products.id
  product_name  VARCHAR(255) NOT NULL  -- 注文時の商品名を記録
  unit_price    INT UNSIGNED NOT NULL  -- 注文時の価格を記録
  quantity      INT UNSIGNED NOT NULL

product_nameunit_priceproducts テーブルの情報と重複しますが、これは意図的な冗長性です。注文時点のデータを永続化することで、後から商品情報が変わっても注文履歴は正確に表示されます。

スナップショットすべき情報の判断

情報スナップショット理由
商品価格必要価格は変わる
商品名推奨リネームされることがある
配送先住所必要変更・削除される可能性がある
ユーザー名通常不要表示名は現在のものでよい
消費税率必要税制改正で変わる

サマリテーブル

集計が重いクエリを高速化するために、集計結果をあらかじめ計算して保存する手法です。

問題のあるクエリ例

商品ページで「平均評価と件数」を表示する場合、毎回集計するとレビューが多い商品で遅くなります。

-- 毎回集計(レビューが多い商品では重い)
SELECT
  p.id,
  p.name,
  AVG(r.rating) AS avg_rating,
  COUNT(r.id) AS review_count
FROM products p
LEFT JOIN reviews r ON p.id = r.product_id
GROUP BY p.id, p.name;

サマリテーブルを使った解決

-- サマリテーブル
CREATE TABLE product_stats (
  product_id    BIGINT UNSIGNED PRIMARY KEY,
  avg_rating    DECIMAL(3,2) NOT NULL DEFAULT 0.00,
  review_count  INT UNSIGNED NOT NULL DEFAULT 0,
  updated_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE
);

-- 商品ページのクエリ(JOIN1回、集計なし)
SELECT
  p.id,
  p.name,
  ps.avg_rating,
  ps.review_count
FROM products p
LEFT JOIN product_stats ps ON p.id = ps.product_id;

サマリテーブルは、レビューが追加・削除されるたびにアプリケーション側で更新するか、定期バッチで再集計します。

-- レビュー追加時にサマリを更新するSQL
INSERT INTO product_stats (product_id, avg_rating, review_count)
SELECT
  product_id,
  AVG(rating),
  COUNT(*)
FROM reviews
WHERE product_id = ?
GROUP BY product_id
ON DUPLICATE KEY UPDATE
  avg_rating = VALUES(avg_rating),
  review_count = VALUES(review_count),
  updated_at = CURRENT_TIMESTAMP;

その他の非正規化テクニック

カラムの追加(集計値のキャッシュ)

-- ordersテーブルにitem_countを追加
ALTER TABLE orders ADD COLUMN item_count INT UNSIGNED NOT NULL DEFAULT 0;

order_items の行数を毎回COUNTするのではなく、注文確定時に item_count を設定します。

JSONカラムによる非構造データの格納

-- 商品の動的な属性をJSONで管理
CREATE TABLE products (
  id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name       VARCHAR(255) NOT NULL,
  price      INT UNSIGNED NOT NULL,
  attributes JSON  -- {"color": "red", "size": "M", "weight": "200g"}
);

カテゴリによって異なる属性(服ならサイズ・色、電子機器ならスペック)を柔軟に管理できます。ただし、JSON カラムへの検索にはインデックスの工夫が必要です。

いつ非正規化を検討するか

非正規化はパフォーマンス問題が実際に発生してから対処するのが原則です。

1. まず正規化された設計でシステムを作る
2. 実際にパフォーマンスを計測する
3. ボトルネックが DB クエリにあると特定する
4. インデックスで解決できないか試みる
5. それでも解決できない場合に非正規化を検討する

「将来遅くなりそうだから」という理由で最初から非正規化するのは避けましょう。設計が複雑になる割に、実際には問題が起きないことが多いです。

まとめ

  • 非正規化はパフォーマンスと整合性のトレードオフ
  • スナップショットパターンは「後から変わる可能性のあるデータ」を保存する
  • サマリテーブルは集計コストを事前に払っておく仕組み
  • 非正規化は計測に基づいて判断する

次回はシリーズの最終回。設計レビューのチェックポイントとよくあるアンチパターンを学びます。