非正規化の判断
非正規化の判断 とは
非正規化のメリット・デメリットを理解し、適切な判断ができるようにSQLで考えます。本レッスンでは、非正規化の判断 の基本から実際の使いどころまでを整理し、現場で迷わず使える形に落とし込みます。
非正規化とは
非正規化 (denormalization) は、3NF まで進めた設計を意図的に部分的に崩し、冗長な情報をテーブルに残す設計手法です。「正規化を理解した上で、性能や運用要件のためにあえて崩す」のがポイントで、最初から非正規化を選ぶのとは意味が違います。読み込み中心のシステムや、履歴を保持したい場面で活躍します。
なぜ崩すのか
3NF を保つと JOIN が増えます。注文 1 行を画面に表示するために、顧客マスタ、商品マスタ、配送先マスタなどを毎回 JOIN すると、トラフィックが多い API では性能ボトルネックになります。また、注文時の商品価格を後から変更できると会計上の整合性が崩れるため、わざと当時の価格を order_items に持つ (スナップショット) 必要があります。
非正規化のパターン
1. 履歴を固定する
注文時点の商品価格は将来の価格変更に影響されてはいけません。order_items 側に unit_price_at_order を持たせるのが定石です。
SQL クエリ
CREATE TABLE order_items (
order_id INT,
product_id INT,
quantity INT,
unit_price_at_order INT,
PRIMARY KEY (order_id, product_id)
);2. 読み込み高速化の集計列
注文ごとの合計金額は明細から計算できますが、注文一覧の表示で毎回明細を SUM するのは高コストです。orders.total_amount を事前計算列として持たせ、明細変更時にトリガーやアプリで書き換えるのが現実解です。
SQL クエリ
CREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT,
total_amount INT
);非正規化の代償
非正規化は性能の代わりに書き込み複雑性と整合性リスクを支払います。明細を更新したのに orders.total_amount を更新し忘れたら、合計と内訳の数値が食い違ってしまいます。
非正規化は「正規化された設計を理解した上で、意識的に一部だけを冗長化する」のがコツです。「全部 1 枚にすれば速い」と早合点して 1NF から崩すのは、ただの設計ミスにすぎません。
避けたい例 非正規化を始めた途端、明細の更新フローと合計列の更新フローが分散していき、メンテナンスが破綻するケースがあります。トランザクションでまとめる、リアルタイム集計に置き換える、などの仕組みをセットで考えるべきです。
いつ非正規化するか
次のいずれかが当てはまる時に検討します。
- 同じ JOIN が大量に発生し、明確な性能課題が出ている
- 過去の値を固定保存したい (注文時点の価格、契約時点の手数料率)
- 集計結果を頻繁に参照するが、明細の更新は稀である
逆にデータが小さく、JOIN コストが問題になっていないなら、3NF を保ったままの方が安全です。
非正規化は「先に性能課題を計測してから判断する」のがコツです。負荷試験で JOIN コストが本当に問題なのか、インデックス調整で十分ではないのか、を順に確認してから設計を崩します。
履歴固定が必要なドメイン
非正規化が正解になる典型ドメインは、金額・契約・税率など「過去の事実をそのまま保存しなければならないもの」です。
- 注文時の単価 (商品価格は時間とともに変わる)
- 契約時の手数料率 (顧客との取り決め)
- 申し込み時の住所 (引っ越し後も発送履歴は当時の住所で残す)
これらは履歴を 3NF のままで保とうとすると、商品マスタや料率マスタを「時間軸」で多重持ちする必要があり、設計とクエリが複雑化します。注文側にスナップショットを残すほうが運用が楽になることが多いです。
非正規化を安全に運用する仕組み
非正規化を採用するなら、整合性を保つための仕組みをセットで設計します。代表的な手段は次の通りです。
SQL クエリ
-- 1. トランザクションで明細更新と集計列更新をまとめる
BEGIN;
UPDATE order_items SET quantity = 5 WHERE order_id = 1001 AND product_id = 10;
UPDATE orders SET total_amount = (
SELECT SUM(unit_price_at_order * quantity) FROM order_items WHERE order_id = 1001
) WHERE id = 1001;
COMMIT;アプリ側で明細更新と合計更新を別々に書くと、片方が成功・片方が失敗するシナリオが発生します。トランザクションで括ることで整合性を守れます。さらに、夜間バッチで total_amount を再計算する補正ジョブを併用するのが定石です。
マテリアライズドビューという選択肢
リードレプリカ に集計結果を持たせる、マテリアライズドビュー を使う、という選択肢も非正規化の一種です。元テーブルの構造は 3NF のまま保ち、読み込みのために別の物理表を作るので、論理設計を汚さずに性能を得られます。
まとめ
- 非正規化は 3NF を理解した上で意図的に崩す設計
- 履歴の固定、集計の事前計算が代表的な動機
- 性能の対価として書き込みの複雑性を負担する
- まずは正規化、性能問題が出てから非正規化を検討する
次のレッスン
次は 正規化クイズ です。データベース正規化に関する理解度をクイズ形式でチェックし、知識を定着させます。
事前確認 — 進む前に次の 3 つができることを確認しましょう。
- 非正規化 の要点を自分の言葉で説明できる
- このレッスンの最小コード (または操作手順) を見ずに書ける
- 練習問題やクイズで間違えた箇所を読み直して理解した
理解度チェック (30 秒)
Q. 非正規化 とは何か、1 文で説明してください。
A. 本文の「このレッスンで分かること」または冒頭の説明文を見直し、自分の言葉で要約できれば OK。詰まったら本レッスンの最初の H2 セクションを読み返してみましょう。
関連レッスン
テーブル構造
schema.sql
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(100),
unit_price INT
);
CREATE TABLE order_items (
order_id INT,
product_id INT,
quantity INT,
unit_price_at_order INT,
PRIMARY KEY (order_id, product_id)
);
CREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT,
total_amount INT
);
INSERT INTO products VALUES
(10, 'ノート', 350),
(11, 'ペン', 180),
(12, '消しゴム', 120);
INSERT INTO orders VALUES
(1001, 1, 1200),
(1002, 2, 500),
(1003, 3, 1380);
INSERT INTO order_items VALUES
(1001, 10, 2, 300),
(1001, 11, 4, 150),
(1002, 10, 1, 300),
(1002, 11, 2, 200),
(1003, 10, 3, 360),
(1003, 12, 3, 100);期待される出力
| order_id | total_amount | calculated_total |
|---|---|---|
| 1002 | 500 | 700 |