非正規化のユースケースとトレードオフ ― 入門

はじめに

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. 更新不整合のリスク:コピー元だけ直してコピー先を忘れると値がズレ、バグが静かにデータへ蓄積する。
  2. 書き込みコストの増加:1回の書き込みで複数箇所を触り、ロック競合や負荷が上がる場合がある。
  3. 複雑性の移動:問題は消えず、「読み取りの複雑さ」から「同期の複雑さ」へ移るだけ。

判断のチェックリスト

  • 本当に読み取りがボトルネックか?(計測したか)
  • 正規化のまま、インデックス / クエリ最適化 / キャッシュで解けないか?
  • 非正規化した値の唯一の更新経路を保証できるか?
  • ズレた場合に検知・再計算する手段があるか?

この4つに自信を持って答えられないなら、非正規化はまだ早い可能性が高いです。

まとめ

  • 非正規化は「読み取りの速さ」を「整合性を管理するコスト」で買う設計判断。
  • 有効なユースケースは、読み取り主体・高頻度集計・履歴の凍結(注文時点の価格など)。
  • 代表パターンは冗長カラム / 事前集計カラム / サマリテーブル。
  • 同期はアプリ・トリガー・非同期のいずれかで担保し、必ず唯一の更新経路と再計算手段を用意する。
  • 入れる前に計測し、まず正規化のまま解けないかを検討する。それでも足りないときの選択肢として持っておくのが健全。

「正規化は原則、非正規化は例外。例外を選ぶときは理由と後始末をセットで」――これが入門としての着地点です。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。