正規化と性能の実務折衷
原則は正規化ですが、性能要件で折衷が必要になる場面があります。 重要なのは「根拠ある非正規化」であり、感覚で崩すことではありません。 このページでは、非正規化の判断基準・代表パターン・安全策を具体的なSQLとともに解説します。
1. 基本方針 ── まず正規化、計測してから非正規化
非正規化は「設計の敗北」ではなく、計測に基づく意図的なトレードオフです。ただし順番を間違えると、保守不能な設計になります。
- まず第3正規形(3NF)で設計する ── 更新不整合のないクリーンな構造を出発点にする
- インデックスとクエリを最適化する ── 非正規化の前にできることは多い。適切なインデックス追加やクエリ書き換えで大半の遅延は解決する
- 計測でボトルネックを特定する ── EXPLAIN ANALYZE やスロークエリログで「何が遅いのか」を数値で把握する
- 限定範囲で非正規化する ── 問題箇所だけをピンポイントで崩す。テーブル全体を非正規化しない
- 整合性維持手段を必ず用意する ── トリガー、バッチ、アプリ層のいずれかで同期戦略を設計する
- 効果を計測して記録する ── 「非正規化前 → 後」の応答時間を記録し、判断根拠をドキュメントに残す
よくある失敗: 「JOINが多いから遅いはず」という推測で非正規化を始めること。 実際にはインデックスが欠如しているだけ、というケースが非常に多い。 必ず計測してから判断すること。
2. 非正規化を検討する条件
以下の指標が「すべて」揃った場合に、非正規化を検討対象に入れます。1つでも欠けていれば、まず別の最適化を試みるべきです。
計測指標チェックリスト
| 指標 | 確認方法 | 検討基準の目安 |
|---|---|---|
| クエリ応答時間 | EXPLAIN ANALYZE の Execution Time | 要件上限を超えている(例: 画面表示 200ms以内の要件に対し 500ms) |
| JOIN回数とコスト | EXPLAIN の Nested Loop / Hash Join のコスト | JOIN除去でコストが大幅に下がることが実測で確認できる |
| スロークエリログ | PostgreSQL: log_min_duration_statementMySQL: slow_query_log |
同一パターンのクエリが高頻度で記録されている |
| インデックス最適化済み | EXPLAIN で Seq Scan がないことを確認 | 適切なインデックスを貼っても改善しない |
| クエリ書き換え検討済み | サブクエリ → JOIN変換、EXISTS活用など | 書き換えでも要件を満たせない |
スロークエリログの有効化例(PostgreSQL):
-- postgresql.conf で設定
log_min_duration_statement = 200 -- 200ms以上のクエリを記録
-- 実行中に一時的に有効化する場合
SET log_min_duration_statement = 200;
-- MySQL の場合
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.2; -- 200ms以上を記録
3. 代表的な非正規化パターン
パターン1: 集計テーブル(日次/月次売上サマリ)
高頻度で参照される集計結果を、事前計算してテーブルに保存するパターンです。 ダッシュボードやレポート画面で毎回リアルタイム集計するのが重い場合に有効です。
正規化版: 毎回JOINして集計
-- 月次売上をリアルタイムに集計(正規化版)
-- orders + order_items + products を毎回JOINする
SELECT
DATE_TRUNC('month', o.order_date) AS sales_month,
p.category_id,
c.category_name,
COUNT(DISTINCT o.order_id) AS order_count,
SUM(oi.quantity) AS total_quantity,
SUM(oi.quantity * oi.unit_price) AS total_amount
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
JOIN categories c ON p.category_id = c.category_id
WHERE o.order_date >= '2025-01-01'
AND o.order_date < '2026-01-01'
GROUP BY sales_month, p.category_id, c.category_name
ORDER BY sales_month, total_amount DESC;
-- 問題: 受注件数が数百万件になると、毎回の集計に数秒〜数十秒かかる
非正規化版: 集計テーブルを事前作成
-- 集計テーブルの定義
CREATE TABLE monthly_sales_summary (
summary_id SERIAL PRIMARY KEY,
sales_month DATE NOT NULL, -- 月初日(2025-01-01 等)
category_id INT NOT NULL REFERENCES categories(category_id),
category_name VARCHAR(100) NOT NULL, -- 冗長だがJOIN不要にする
order_count INT NOT NULL DEFAULT 0,
total_quantity INT NOT NULL DEFAULT 0,
total_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (sales_month, category_id)
);
-- 日次バッチで集計テーブルを更新(UPSERT)
INSERT INTO monthly_sales_summary
(sales_month, category_id, category_name,
order_count, total_quantity, total_amount, updated_at)
SELECT
DATE_TRUNC('month', o.order_date),
p.category_id,
c.category_name,
COUNT(DISTINCT o.order_id),
SUM(oi.quantity),
SUM(oi.quantity * oi.unit_price),
CURRENT_TIMESTAMP
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
JOIN categories c ON p.category_id = c.category_id
WHERE o.order_date >= DATE_TRUNC('month', CURRENT_DATE)
GROUP BY DATE_TRUNC('month', o.order_date), p.category_id, c.category_name
ON CONFLICT (sales_month, category_id)
DO UPDATE SET
category_name = EXCLUDED.category_name,
order_count = EXCLUDED.order_count,
total_quantity = EXCLUDED.total_quantity,
total_amount = EXCLUDED.total_amount,
updated_at = CURRENT_TIMESTAMP;
-- 参照時は集計テーブルを直接SELECT(JOIN不要)
SELECT sales_month, category_name, order_count, total_amount
FROM monthly_sales_summary
WHERE sales_month >= '2025-01-01'
ORDER BY sales_month, total_amount DESC;
-- 効果: 3テーブルJOIN + GROUP BY → 単一テーブルSELECTに短縮
更新戦略の選択肢: (A) 日次バッチ(夜間に全再集計)── 実装が単純。鮮度は1日遅れ。 (B) イベント駆動(注文確定時に差分更新)── 鮮度が高いが実装が複雑。 ダッシュボード用なら (A) で十分なケースが多い。
パターン2: 冗長カラム追加(注文テーブルに顧客名を持たせる)
JOINを減らすために、参照先テーブルの値を複製して保持するパターンです。 注文一覧画面で毎回顧客テーブルをJOINするのが重い場合などに使います。
正規化版(JOINが必要)
-- 注文一覧を取得するたびにJOINが必要
SELECT o.order_id, o.order_date, o.total_amount,
c.customer_name, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.order_date >= '2025-12-01'
ORDER BY o.order_date DESC;
非正規化版(冗長カラムを追加)
-- orders テーブルに顧客名を冗長に持たせる
ALTER TABLE orders ADD COLUMN customer_name VARCHAR(100);
-- 既存データを一括更新
UPDATE orders o
SET customer_name = c.customer_name
FROM customers c
WHERE o.customer_id = c.customer_id;
-- JOINなしで注文一覧を取得可能に
SELECT order_id, order_date, total_amount, customer_name
FROM orders
WHERE order_date >= '2025-12-01'
ORDER BY order_date DESC;
更新同期: トリガーによる自動同期
-- 顧客名が変更されたら orders の冗長カラムも自動更新するトリガー
CREATE OR REPLACE FUNCTION sync_customer_name()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.customer_name <> OLD.customer_name THEN
UPDATE orders
SET customer_name = NEW.customer_name
WHERE customer_id = NEW.customer_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_customer_name
AFTER UPDATE OF customer_name ON customers
FOR EACH ROW
EXECUTE FUNCTION sync_customer_name();
注意: 顧客名の変更頻度が高い場合、トリガーによる orders 大量更新がボトルネックになりうる。 対象顧客の注文件数が多い場合は、バッチ更新やアプリ層同期を検討すること。
パターン3: マテリアライズドビュー
複雑なクエリの結果をDBが自動的にテーブルとして保存する仕組みです。 集計テーブルを手動管理する負担を減らせます(PostgreSQLで利用可能)。
作成
-- カテゴリ別・月別の売上サマリをマテリアライズドビューとして定義
CREATE MATERIALIZED VIEW mv_monthly_category_sales AS
SELECT
DATE_TRUNC('month', o.order_date) AS sales_month,
p.category_id,
c.category_name,
COUNT(DISTINCT o.order_id) AS order_count,
SUM(oi.quantity * oi.unit_price) AS total_amount
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
JOIN categories c ON p.category_id = c.category_id
GROUP BY sales_month, p.category_id, c.category_name
WITH DATA; -- 作成時にデータを即座に格納
-- 検索高速化のためインデックスを付与
CREATE INDEX idx_mv_sales_month
ON mv_monthly_category_sales (sales_month);
リフレッシュ戦略
-- 方法1: 全件再構築(シンプルだが重い)
REFRESH MATERIALIZED VIEW mv_monthly_category_sales;
-- 方法2: 同時並行リフレッシュ(参照をブロックしない、UNIQUE INDEX が必要)
CREATE UNIQUE INDEX idx_mv_sales_unique
ON mv_monthly_category_sales (sales_month, category_id);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_category_sales;
-- cron等で定期リフレッシュを設定する例(毎日午前3時)
-- crontab:
-- 0 3 * * * psql -d mydb -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_category_sales;"
参照時
-- 通常のテーブルと同じようにSELECTできる
SELECT sales_month, category_name, order_count, total_amount
FROM mv_monthly_category_sales
WHERE sales_month >= '2025-01-01'
ORDER BY sales_month, total_amount DESC;
マテリアライズドビューの利点と制約:
利点 ── 定義SQLがそのまま残るため、集計テーブルより保守しやすい。
制約 ── リフレッシュまでデータが古い。リアルタイム性が必要な場面には不向き。
MySQLにはマテリアライズドビューがないため、集計テーブル + バッチで代替する。
パターン4: キャッシュテーブル(検索用の読み取り専用テーブル)
複数テーブルの情報をフラットに結合した、検索専用の読み取り専用テーブルです。 全文検索や複雑なフィルタリングが必要な商品検索画面などで使います。
-- 検索用キャッシュテーブルの定義
CREATE TABLE product_search_cache (
product_id INT PRIMARY KEY,
product_name VARCHAR(200) NOT NULL,
category_name VARCHAR(100) NOT NULL,
brand_name VARCHAR(100),
price NUMERIC(10,2) NOT NULL,
avg_rating NUMERIC(3,2),
review_count INT DEFAULT 0,
stock_quantity INT DEFAULT 0,
is_available BOOLEAN DEFAULT TRUE,
search_text TEXT, -- 検索用の結合テキスト
last_synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 全文検索用インデックス(PostgreSQL)
CREATE INDEX idx_product_search_text
ON product_search_cache USING GIN (to_tsvector('japanese', search_text));
-- キャッシュテーブルの更新バッチ(全件洗い替え)
TRUNCATE product_search_cache;
INSERT INTO product_search_cache
(product_id, product_name, category_name, brand_name,
price, avg_rating, review_count, stock_quantity,
is_available, search_text, last_synced_at)
SELECT
p.product_id,
p.product_name,
c.category_name,
b.brand_name,
p.price,
COALESCE(r.avg_rating, 0),
COALESCE(r.review_count, 0),
COALESCE(s.quantity, 0),
COALESCE(s.quantity, 0) > 0,
-- 検索用テキストを結合
p.product_name || ' ' || c.category_name
|| ' ' || COALESCE(b.brand_name, ''),
CURRENT_TIMESTAMP
FROM products p
JOIN categories c ON p.category_id = c.category_id
LEFT JOIN brands b ON p.brand_id = b.brand_id
LEFT JOIN (
SELECT product_id,
AVG(rating) AS avg_rating,
COUNT(*) AS review_count
FROM reviews GROUP BY product_id
) r ON p.product_id = r.product_id
LEFT JOIN inventory s ON p.product_id = s.product_id;
-- 検索時はキャッシュテーブル1つだけで完結(6テーブルJOINが不要)
SELECT product_id, product_name, category_name,
price, avg_rating, review_count
FROM product_search_cache
WHERE is_available = TRUE
AND price BETWEEN 1000 AND 5000
AND to_tsvector('japanese', search_text) @@ to_tsquery('japanese', 'ワイヤレス')
ORDER BY avg_rating DESC
LIMIT 20;
キャッシュテーブルの運用ポイント:
(1) last_synced_at を入れておけば、画面に「最終更新: 10分前」と表示でき、データ鮮度を利用者に伝えられる。
(2) TRUNCATE + INSERT よりも、差分更新(UPSERTや変更フラグ)のほうが更新時間を短縮できるが、実装は複雑になる。
4. 計測と判断の実務フロー
EXPLAIN ANALYZE の基本的な読み方
-- 問題のクエリに EXPLAIN ANALYZE を付けて実行
EXPLAIN ANALYZE
SELECT o.order_id, o.order_date, c.customer_name, SUM(oi.quantity * oi.unit_price)
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
JOIN order_items oi ON o.order_id = oi.order_id
WHERE o.order_date >= '2025-01-01'
GROUP BY o.order_id, o.order_date, c.customer_name;
-- 出力例(抜粋)
HashAggregate (cost=4520.00..4620.00 rows=10000 width=52)
(actual time=245.3..262.1 rows=9873 loops=1)
-> Hash Join (cost=1200.00..4020.00 rows=100000 width=44)
(actual time=12.5..198.7 rows=98542 loops=1)
Hash Cond: (oi.order_id = o.order_id)
-> Seq Scan on order_items oi ★ ここが問題
(cost=0.00..1800.00 rows=100000 width=16)
(actual time=0.02..45.3 rows=100000 loops=1)
-> Hash (cost=950.00..950.00 rows=20000 width=36)
(actual time=11.2..11.2 rows=19756 loops=1)
-> Hash Join ...
Planning Time: 0.85 ms
Execution Time: 278.4 ms ★ 全体の実行時間
読むべきポイント
| 項目 | 見る場所 | 判断 |
|---|---|---|
| 全体の実行時間 | 最下部の Execution Time |
要件の応答時間と比較する |
| Seq Scan(全件走査) | 各ノードのスキャン方式 | 大きなテーブルで Seq Scan なら、インデックス不足の可能性が高い |
| actual time | 各ノードの実測時間 | 最も時間がかかっているノードがボトルネック |
| rows(推定 vs 実測) | rows=推定 vs rows=実測 |
大きく乖離していれば統計情報が古い → ANALYZE テーブル名; を実行 |
ボトルネック特定の手順
- スロークエリログから対象クエリを特定する ── 頻度と実行時間の両方を確認
- EXPLAIN ANALYZE で実行計画を取得する ── 推定値ではなく実測値を見る
- Seq Scan を探す ── 大きなテーブルに対する全件走査がないか確認
- インデックス追加で解決できるか試す ── WHERE句・JOIN条件のカラムにインデックスを追加
- クエリを書き換えて改善できるか試す ── サブクエリの展開、EXISTSへの変換など
- それでも不十分な場合に非正規化を検討する
-- 手順4の例: Seq Scan を解消するインデックス追加
CREATE INDEX idx_orders_order_date ON orders (order_date);
CREATE INDEX idx_order_items_order_id ON order_items (order_id);
-- インデックス追加後に再度 EXPLAIN ANALYZE で効果を確認
EXPLAIN ANALYZE
SELECT ... (同じクエリ);
実務のコツ: EXPLAIN ANALYZEは本番相当のデータ量で実行すること。 開発環境の100件で速くても、本番の100万件で遅いケースは頻出する。
5. 整合性を守る安全策
真実源(Source of Truth)の明示
非正規化を行ったら、「どちらが正(マスター)で、どちらが複製か」を必ず明示します。
| データ | 真実源テーブル | 複製先 | 同期方式 |
|---|---|---|---|
| 顧客名 | customers.customer_name | orders.customer_name | トリガー(即時同期) |
| 月次売上集計 | orders + order_items(元データ) | monthly_sales_summary | 日次バッチ(3:00 AM) |
| 商品検索情報 | products, categories, reviews 等 | product_search_cache | 10分間隔バッチ |
原則: 複製先テーブルを直接 UPDATE/INSERT してはならない。 必ず真実源を更新し、同期処理を経由して複製先に反映する。 これを破ると、どちらが正しいか分からなくなる。
同期方式の比較: トリガー vs バッチ vs アプリ層
| 方式 | 即時性 | 実装負荷 | デバッグ | 適用場面 |
|---|---|---|---|---|
| トリガー | 即時(同一トランザクション内) | 中 | 難(暗黙的に動く) | 冗長カラムの同期。変更頻度が低いマスタ系 |
| バッチ | 遅延あり(分〜時間単位) | 低 | 易(ログで追跡可能) | 集計テーブル、キャッシュテーブルの全件洗い替え |
| アプリ層 | 任意(実装次第) | 高 | 中(アプリログで追跡) | 複雑な条件分岐を伴う更新。複数DB間の同期 |
整合性検証クエリの例
非正規化したデータが真実源と一致しているかを定期的に検証します。不一致が見つかったら同期処理の不具合を調査します。
-- 検証1: orders.customer_name と customers.customer_name の不一致を検出
SELECT o.order_id, o.customer_id,
o.customer_name AS orders_name,
c.customer_name AS master_name
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.customer_name <> c.customer_name
OR (o.customer_name IS NULL AND c.customer_name IS NOT NULL);
-- 結果が0件なら整合性OK。1件でもあれば同期処理に問題あり。
-- 検証2: 集計テーブルの金額が元データと一致するか確認
SELECT s.sales_month, s.category_id,
s.total_amount AS summary_amount,
r.real_amount AS recalc_amount,
s.total_amount - r.real_amount AS diff
FROM monthly_sales_summary s
JOIN (
SELECT DATE_TRUNC('month', o.order_date) AS sales_month,
p.category_id,
SUM(oi.quantity * oi.unit_price) AS real_amount
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
GROUP BY sales_month, p.category_id
) r ON s.sales_month = r.sales_month
AND s.category_id = r.category_id
WHERE ABS(s.total_amount - r.real_amount) > 0.01;
-- diff が 0 でない行があれば、バッチ処理の不具合を調査する
整合性検証クエリは cron やジョブスケジューラで日次実行し、不一致が見つかったらアラートを飛ばす仕組みにしておくと安全です。
6. 非正規化の管理ルール
非正規化は「導入したら終わり」ではなく、継続的な管理が必要です。以下の3点をルール化します。
6-1. ドキュメント化
非正規化を適用した箇所はすべて記録します。将来のメンバーが「なぜこの冗長カラムがあるのか」を理解できるようにするためです。
| 記録項目 | 例 |
|---|---|
| 対象テーブル/カラム | orders.customer_name |
| 真実源 | customers.customer_name |
| 導入理由 | 注文一覧画面の応答時間が500ms → 要件200msを超過 |
| 導入日 | 2025-06-15 |
| 導入時の計測結果 | JOIN版: 480ms → 冗長カラム版: 95ms |
| 同期方式 | customers UPDATE時トリガー |
| 検証クエリ | 上記の検証1クエリへのリンク |
| 撤退条件 | 顧客名更新頻度が月1000件を超えた場合、再検討 |
6-2. 定期検証
- 整合性チェック: 検証クエリを日次で実行し、不一致をアラート通知する
- 性能効果の再確認: 四半期ごとにデータ量増加に伴う性能変化を計測する
- 同期処理の健全性: バッチジョブの実行時間・成功率を監視する
6-3. 撤退基準の設定
非正規化は永続的なものではありません。以下のいずれかに該当したら、正規化への回帰や別のアプローチへの切り替えを検討します。
- 同期処理の失敗率が一定以上(例: 月1回以上の整合性不一致)になった
- マスターデータの更新頻度が増え、トリガーが性能ボトルネックになった
- アプリ改修で非正規化テーブルの参照がなくなった(不要になった)
- DBの移行やリプレースで、別の最適化手段(例: 読み取りレプリカ)が利用可能になった
非正規化は技術的負債になりやすい施策です。「いつ撤退するか」を導入時に決めておくことで、負債の蓄積を防げます。
7. 判断フローチャート
「JOINが遅い」と感じたときに、安易に非正規化へ飛びつかないための段階的な判断フローです。
クエリが遅い?
│
├─ NO → 問題なし。正規化を維持。
│
└─ YES
│
▼
EXPLAIN ANALYZE で実行計画を確認
│
├─ Seq Scan が見つかる
│ │
│ ▼
│ インデックスを追加して再計測
│ │
│ ├─ 改善した → 解決。非正規化は不要。
│ └─ 改善しない → 次へ
│
├─ 統計情報が古い(推定rows と actual rows が大きく乖離)
│ │
│ ▼
│ ANALYZE テーブル名; を実行して再計測
│ │
│ ├─ 改善した → 解決。
│ └─ 改善しない → 次へ
│
└─ JOINコストが支配的
│
▼
クエリの書き換えを試す
(サブクエリ展開、EXISTS、LATERAL JOIN等)
│
├─ 改善した → 解決。
└─ 改善しない
│
▼
★ ここで初めて非正規化を検討 ★
│
├─ 集計が重い → 集計テーブル or マテリアライズドビュー
├─ 参照JOINが重い → 冗長カラム追加
└─ 検索が複雑 → キャッシュテーブル
│
▼
導入後:
(1) 効果を計測して記録
(2) 整合性維持手段を実装
(3) 管理ルールを文書化
(4) 撤退基準を設定
まとめ: 非正規化は最後の手段です。インデックス追加 → 統計情報更新 → クエリ書き換え → 非正規化、の順で段階的に進めてください。 そして導入する際は、真実源の明示・同期処理・検証クエリ・撤退基準の4点をセットで準備すること。 これが「根拠ある非正規化」の基本原則です。