非正規化とパフォーマンスのトレードオフ
第3回で正規化を学びましたが、現実のシステムでは「正規化を完璧に守ると遅すぎて使えない」という場面に直面することがあります。そのときに用いるのが非正規化です。
非正規化とは
非正規化? パフォーマンス向上のために意図的にデータの重複を許す設計。JOINが多くなって遅い場合に、あえてデータをまとめて検索を速くする。正規化とのトレードオフで判断する。 とは、意図的にデータの冗長性を許容して、クエリのパフォーマンスを向上させる設計手法です。
「正規化が良くて、非正規化が悪い」というわけではありません。正規化は整合性を優先し、非正規化はパフォーマンスを優先するトレードオフです。どちらが適切かはシステムの要件次第です。
読み取り重視 vs 書き込み重視
データベースへのアクセスパターンは大きく2種類あります。
読み取り重視(Read Heavy)
- 商品一覧ページ、検索結果ページ
- ダッシュボード・レポート画面
- ユーザーが頻繁にアクセスするページ
→ JOINを減らし、1クエリで必要なデータが取れる構造にする価値がある
書き込み重視(Write Heavy)
- センサーデータの記録
- ログ収集
- リアルタイム更新が必要なデータ
→ 正規化を守り、書き込みをシンプルに保つ
ECサイトで言えば、「商品一覧ページ」は読み取り重視(何千ユーザーが同時にアクセス)、「在庫の減算」は書き込み重視(在庫の整合性が重要)です。
スナップショットパターン
前回の実践編でも登場しましたが、スナップショットパターンは非正規化の代表的なテクニックです。
注文時の商品名・価格スナップショット
-- 正規化を厳密に守った場合(問題あり)
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_name と unit_price は products テーブルの情報と重複しますが、これは意図的な冗長性です。注文時点のデータを永続化することで、後から商品情報が変わっても注文履歴は正確に表示されます。
スナップショットすべき情報の判断
| 情報 | スナップショット | 理由 |
|---|---|---|
| 商品価格 | 必要 | 価格は変わる |
| 商品名 | 推奨 | リネームされることがある |
| 配送先住所 | 必要 | 変更・削除される可能性がある |
| ユーザー名 | 通常不要 | 表示名は現在のものでよい |
| 消費税率 | 必要 | 税制改正で変わる |
サマリテーブル
集計が重いクエリを高速化するために、集計結果をあらかじめ計算して保存する手法です。
問題のあるクエリ例
商品ページで「平均評価と件数」を表示する場合、毎回集計するとレビューが多い商品で遅くなります。
-- 毎回集計(レビューが多い商品では重い)
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. それでも解決できない場合に非正規化を検討する
「将来遅くなりそうだから」という理由で最初から非正規化するのは避けましょう。設計が複雑になる割に、実際には問題が起きないことが多いです。
まとめ
- 非正規化はパフォーマンスと整合性のトレードオフ
- スナップショットパターンは「後から変わる可能性のあるデータ」を保存する
- サマリテーブルは集計コストを事前に払っておく仕組み
- 非正規化は計測に基づいて判断する
次回はシリーズの最終回。設計レビューのチェックポイントとよくあるアンチパターンを学びます。