Query Go
非正規化パターン集 — 正規化しすぎを意図的に壊す型
データモデル設計ガイド

非正規化パターン集 — 正規化しすぎを意図的に壊す型

集計値キャッシュ、スナップショット、サマリーテーブルなど、読み取り性能のために計画的に非正規化する典型パターン 6 つと、同期戦略・陥りやすい罠を整理する。

非正規化パターン集 — 正規化しすぎを意図的に壊す型 eyecatch

なぜ非正規化するのか — 正規化は手段

3NF まで正規化すると、同じ事実は 1 か所にしか書かれず、更新異常は構造的に発生しません。代償として、画面表示や集計で多数の JOINが必要になり、レイテンシ・CPU・読み取り負荷が跳ね上がることがあります。

非正規化とは、この「書き込みの一貫性」と「読み取りの速度」のトレードオフの上で、あえて同じ事実を複数か所に持つ判断です。重要なのは次の 2 点です。

  • 無計画な非正規化はアンチパターン — 正規化を飛ばしていきなり「全部入り」のテーブルを作ると、どこに同じ事実が重複しているか後から追えなくなる
  • 計画された非正規化はパターン化されている — 本ページで扱うのはこちら。典型パターンが分類されていれば、レビュー時に「これは集計キャッシュ型」「これはスナップショット型」と共通言語で議論できる

手順としては 「まず 3NF まで正規化 → 計測 → ホットなクエリだけ非正規化」 が定石。計測前に非正規化しない、というのが最も重要なルールです。

判断軸 — いつ非正規化を考えるか

次の軸のどれかが明確に片側に振れているときだけ非正規化を検討します。1 つでも逆側に振れていたら、まずは正規化のまま残す方が安全です。

非正規化を検討する側正規化のまま残す側
読み書き比率読み取りが圧倒的に多い(1 : 100 以上)書き込みが頻繁
整合性の要件「ほぼリアルタイム」でよい(数秒〜数分のズレが許容)即時整合が必須(金額・残高・在庫)
JOIN コスト計測で明確に JOIN が支配的JOIN 数が少ない、インデックスで十分速い
データ規模数百万〜数億行で JOIN コストが無視できない数千〜数十万行、メモリに載る
集計の再計算コストCOUNT / SUM が毎秒発生する集計は日次バッチで十分

全軸で「検討する側」に寄っているなら、非正規化しないほうが逆にリスクです。ただし計測なしで「多分遅いだろう」は禁物。EXPLAIN / pg_stat_statements / Slow query log で実測して初めて判断します。

典型パターン 6 選

現場で繰り返し現れる非正規化のを 6 つ挙げます。どのパターンも「何を重複させるか」と「どう同期するか」がセットで決まります。

#パターン何を重複させる使い所
1集計値キャッシュ子テーブルの COUNT / SUM / MAX を親の列に「いいね数」「コメント数」「最新更新日」の表示
2スナップショット参照先マスタの当時の値を子行に固定コピー注文明細の unit_price / product_nameDDD カートDDD 注文参照)
3サマリーテーブル集計結果を専用テーブルに保存日次/月次 KPI、ダッシュボード、レポート
4マテリアライズドビューJOIN 済みの結果を物理化複雑な JOIN + 集計を定期リフレッシュ
5ツリー平坦化Closure table / path enumeration で祖先列を複製カテゴリツリー、組織階層、コメントスレッド
6結合済みワイドテーブル頻出 JOIN を 1 テーブルに畳む検索インデックス用、BI ツール連携、CQRS 読み取りモデル

1〜3 が最も頻度が高く、投資対効果も大きい。4〜6 はスケール要件が明確になってから導入する方が無難です。

同期戦略 — 重複をどう一致させるか

非正規化の真の難しさは「重複したデータをどう同期させるか」に集中します。選択肢は大きく 4 つ。どれを選ぶかは、整合性の即時性要件と運用コストで決まります。

戦略整合性実装コスト向いているパターン
DB トリガー即時(同一トランザクション)DB 依存、デバッグしづらい集計値キャッシュ、ツリー平坦化
アプリ層での同時更新即時(アプリのトランザクション内)実装容易、漏れやすい集計値キャッシュ、スナップショット
定期バッチ遅延(日次・時次)最も素朴、運用しやすいサマリーテーブル、マテリアライズドビュー
CDC / イベント駆動準リアルタイム(秒)基盤構築が重い結合済みワイドテーブル、CQRS 読み取りモデル

初期はアプリ層 + 夜間バッチの組み合わせで始め、スケール要件が明確になってからトリガー・CDC を検討するのが現実的です。最初からトリガーや Kafka を前提にすると運用コストが設計の足を引っ張ります

どの戦略でも必ず用意すべきものが 1 つあります。「重複列を正本から再構築できる再計算バッチ」です。ズレが見つかったときに一撃で直せる手段がなければ、非正規化はいつか事故ります。

陥りやすい罠

非正規化は導入より運用が難しい設計判断です。よく観察される罠を並べます。

  • 整合性ドリフト — 重複列が本体と徐々にズレる。原因は更新経路の漏れ(管理画面、データ修正 SQL、旧バッチ、リストア作業)。再計算バッチがないとリカバリ不能になる
  • 「どこを更新すれば良いか」が追えなくなる — 同じ値を複数テーブルに書く箇所が増え、新規機能追加時にレビューで抜ける。コードサーチで「この列を書く箇所」が 1 画面に収まらないなら赤信号
  • キャッシュの無効化タイミング — 子テーブルの UPDATE / DELETE / SOFT DELETE すべてを親の再計算トリガーに繋がないと、減算漏れが起きる
  • 非正規化が正規化の代替になってしまう — 3NF を飛ばして最初からワイドテーブル、は本ページが扱う「計画的な非正規化」ではない。どこに同じ事実が散っているか追えなくなる
  • スナップショットの意味が伝わらないunit_price が「今の価格」か「注文時点の価格」か、列名から読み取れないと分析 SQL が壊れる。命名で明示する(unit_price_at_order など)か、コメントで固定する
  • 過剰な非正規化 — 「念のため」で追加した列が実は 1 度も読まれていない。計測前の先回りは負債にしかならない

実務のチェックリスト

非正規化を入れる前・入れるとき・入れた後で、以下のチェックリストを通すと事故が減ります。

  • : 本当にボトルネックか? EXPLAIN / pg_stat_statements で測ったか? インデックス追加では解決しないか?
  • : 正規化のまま、マテリアライズドビュー or キャッシュ層(Redis など)で済まないか?
  • 導入時: どのパターン(上記 6 つ)に該当するかを明示した設計メモを残す
  • 導入時: 同期戦略を決める(同一トランザクション? バッチ? イベント?)
  • 導入時: 再計算バッチを同時に実装する。後回しにしない
  • 運用: 定期的に本体と重複列を照合するジョブ(できれば日次)を用意し、差分をアラート化
  • 運用: 列名 or コメントで「これは非正規化列」「元は X テーブル」と明示する
  • 廃止時: ボトルネックが解消された(インフラ強化、クエリ改善)なら、非正規化列を撤去する勇気を持つ

最後の「廃止する勇気」は意外と重要です。非正規化は一度入ると外しにくいので、入れた時の理由(「集計が毎秒 1000 回走っていて遅かった」など)を設計メモに残しておくと、後任が「今もその前提は成立しているか」を判断できます。