非正規化のユースケースとトレードオフ ― 入門
はじめに
RDB設計を学ぶと、まず「正規化しなさい」と教わります。第1〜第3正規形まで整えれば、データの重複が消え、更新時の矛盾(更新不整合)が起きにくくなる。これは正しい出発点です。
一方、実運用のシステムでは「あえて正規化を崩す」=非正規化(denormalization) を選ぶ場面があります。本記事では、非正規化とは何か、どんなときに検討する価値があるのか、そして何を失うのか(トレードオフ)を入門者向けに整理します。
結論から言うと、非正規化は「速さ」を「整合性の管理コスト」で買う行為です。無条件の最適化テクニックではありません。
正規化のおさらい
正規化の目的は「1つの事実を1か所にだけ持つ」ことです。注文と商品を分けて持つ、正規化された形はこうなります。
-- 商品マスタ(事実は1か所)
CREATE TABLE products (
id BIGINT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
unit_price INT NOT NULL
);
-- 注文明細(商品はIDで参照)
CREATE TABLE order_items (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL
);
商品名や単価を変えたいとき、products の1行を直すだけで済みます。重複がないので矛盾も生まれません。これが正規化の強みです。
何が問題になるのか
正規化されたスキーマは「書き込み」に強い代わりに、「読み取り」で JOIN が増えます。「注文一覧(商品名・単価・小計つき)」を出すには、毎回 products を結合し、小計を計算します。
SELECT
oi.order_id,
p.name,
p.unit_price,
oi.quantity,
p.unit_price * oi.quantity AS subtotal
FROM order_items oi
JOIN products p ON p.id = oi.product_id
WHERE oi.order_id = ?;
明細数件・低頻度なら問題ありません。しかし次が重なると効いてきます。
- 読み取りが書き込みに対して圧倒的に多い
- 結合するテーブルが多段・大規模
- 集計(合計・件数・ランキング)を高頻度に表示する
- 一覧・ダッシュボードのように大量の行を一度に返す
このとき候補に挙がるのが非正規化です。
非正規化とは
非正規化とは、本来は他テーブルにある(あるいは計算で導ける)データを、あえて重複して保持することです。読み取り時の結合・計算を、書き込み時(または事前計算時)に前倒しします。代表的なパターンを挙げます。
1. 冗長カラム(値のコピー)
CREATE TABLE order_items (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
-- ↓ 非正規化。products からコピー
product_name VARCHAR(255) NOT NULL,
unit_price INT NOT NULL,
quantity INT NOT NULL
);
JOIN なしで明細を表示できます。さらに「注文時点の商品名・単価を凍結できる」という副次的な利点も。マスタ単価が後から変わっても過去の注文金額は不変であるべき ― この要件では、むしろ非正規化が正しい設計です(履歴の保存)。
2. 事前集計カラム(サマリ)
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
-- ↓ 非正規化。order_items の合計をキャッシュ
total_amount INT NOT NULL DEFAULT 0,
item_count INT NOT NULL DEFAULT 0
);
「いいね数」「フォロワー数」「投稿件数」などのカウンタも同じ仲間です。
3. サマリテーブル / 集計テーブル
日次・月次レポートなど重い集計を定期バッチで別テーブルに作っておきます。元データは正規化のまま残し、参照用の非正規化した面を追加で持つ考え方です。
整合性をどう保つか
非正規化の本質的な難しさは「重複した値をどう同期し続けるか」です。方法は主に3つ。
A. アプリケーションコードで更新する
同じトランザクション内で両方を更新します。素直ですが、更新経路が複数あると漏れます。
BEGIN;
INSERT INTO order_items (order_id, product_id, quantity, subtotal)
VALUES (?, ?, ?, ?);
UPDATE orders
SET total_amount = total_amount + ?,
item_count = item_count + 1
WHERE id = ?;
COMMIT;
B. トリガーで更新する
DB側で自動同期します。更新経路が複数あっても取りこぼしにくい反面、ロジックがDBに埋もれます。
CREATE TRIGGER trg_update_order_total
AFTER INSERT ON order_items
FOR EACH ROW
UPDATE orders
SET total_amount = total_amount + (NEW.unit_price * NEW.quantity),
item_count = item_count + 1
WHERE id = NEW.order_id;
C. 非同期で再計算する(結果整合性)
集計ジョブやイベント駆動で後追い更新します。ズレる瞬間を許容できる表示系カウンタ向き。厳密な金額計算には不向きです。
効果は必ず計測する
非正規化が効いたかは、必ず計測して判断します。感覚で入れないことが最重要です。
EXPLAIN ANALYZE SELECT order_id, product_name, unit_price * quantity AS subtotal FROM order_items WHERE order_id = ?;
- 非正規化「前」の実行計画・レイテンシを記録する
- 「後」で再計測し、改善幅が管理コストに見合うか判断する
- その前に「正規化のままインデックス追加・クエリ改善・キャッシュ層で解けないか」を検討する
注意点(トレードオフ)
| 観点 | 正規化 | 非正規化 |
|---|---|---|
| 読み取り速度 | JOIN・集計が必要 | 速い(前倒し済み) |
| 書き込み | 1か所を更新 | 複数箇所を同期 |
| 整合性 | DBが守りやすい | アプリ/トリガーで守る責任 |
| ストレージ | 小さい | 重複ぶん増える |
| 変更容易性 | 高い | 同期ロジックに引きずられる |
失うものを具体的に言うと、
- 更新不整合のリスク:コピー元だけ直してコピー先を忘れると値がズレ、バグが静かにデータへ蓄積する。
- 書き込みコストの増加:1回の書き込みで複数箇所を触り、ロック競合や負荷が上がる場合がある。
- 複雑性の移動:問題は消えず、「読み取りの複雑さ」から「同期の複雑さ」へ移るだけ。
判断のチェックリスト
- 本当に読み取りがボトルネックか?(計測したか)
- 正規化のまま、インデックス / クエリ最適化 / キャッシュで解けないか?
- 非正規化した値の唯一の更新経路を保証できるか?
- ズレた場合に検知・再計算する手段があるか?
この4つに自信を持って答えられないなら、非正規化はまだ早い可能性が高いです。
まとめ
- 非正規化は「読み取りの速さ」を「整合性を管理するコスト」で買う設計判断。
- 有効なユースケースは、読み取り主体・高頻度集計・履歴の凍結(注文時点の価格など)。
- 代表パターンは冗長カラム / 事前集計カラム / サマリテーブル。
- 同期はアプリ・トリガー・非同期のいずれかで担保し、必ず唯一の更新経路と再計算手段を用意する。
- 入れる前に計測し、まず正規化のまま解けないかを検討する。それでも足りないときの選択肢として持っておくのが健全。
「正規化は原則、非正規化は例外。例外を選ぶときは理由と後始末をセットで」――これが入門としての着地点です。
