Day20 後半のゴール
「“ここまでは正規化、ここからは非正規化”を自分で線引きできるようになる」
前半で、正規化と非正規化のメリット・デメリットを整理しました。
後半では、いよいよ「実務でどうバランスを取るか」を具体的に決めていきます。
ゴールはこうです。
このテーブルはここまで正規化する
この部分はあえて非正規化する
その理由を、パフォーマンスと整合性の両面から説明できる
ここまで行くと、テーブル設計が「なんとなく」から一気にプロっぽくなります。
どこまで正規化するかの基準
「“マスタ系は固く、履歴系は柔らかく”が基本ライン」
まず、「どこまで正規化するか」のざっくりした基準を持ちましょう。
マスタ系(顧客マスタ、商品マスタ、都道府県マスタなど)は、基本的に正規化寄りで考えます。
理由はシンプルで、「長く生きる」「いろんな画面・機能から参照される」「矛盾が致命傷になりやすい」からです。
例えば、顧客マスタに「都道府県名」「会社名」「部署名」などを全部文字列で持つよりも、
都道府県マスタ・会社マスタ・部署マスタに分けてID参照にした方が、
後からの変更や集計が圧倒的に楽になります。
一方、履歴系(注文履歴、ログ、イベント記録など)は、非正規化を許容することが多いです。
理由は、「過去の状態をそのまま残したい」「読み取りが圧倒的に多い」「JOINだらけにすると死ぬ」からです。
注文履歴に「注文時のユーザー名」「注文時の商品名」「注文時の単価」を持たせるのは、
正規化の教科書的にはNG寄りですが、実務ではむしろ“やるべき非正規化”です。
ここでの大事な感覚は、
長く参照され、いろんなところから使われる“軸”になるテーブルは正規化寄り
大量に溜まり、主に読み取り・分析・履歴表示に使うテーブルは非正規化寄り
というざっくりした線引きです。
「非正規化していい場所」と「絶対にやめた方がいい場所」
「履歴のコピーはOK、マスタのコピー乱立は危険」
非正規化はどこでもやっていいわけではありません。
やっていい場所と、やると危険な場所があります。
やっていい代表例が「履歴のコピー」です。
注文時のユーザー名・商品名・単価・合計金額などを、注文テーブルや注文明細テーブルにコピーして持つのは、
「当時の状態を残す」「JOINを減らす」という意味で合理的です。
逆に危険なのは、「マスタ情報のコピー乱立」です。
例えば、顧客名を顧客マスタ以外のあちこちのテーブルに持ち始めると、
顧客名変更のたびに全テーブルを更新しないといけなくなり、
どこか1つでも更新漏れがあると矛盾が発生します。
「履歴として“その時点のスナップショット”を残すためのコピー」なのか、
「単にJOINが面倒だからマスタ情報をばらまいているだけ」なのか、
ここを自分で見分けられるようになると、一気に設計の質が上がります。
具体例でバランスを見る:注文システムの設計
「正規化版と非正規化版を並べて考える」
注文システムを例に、正規化寄りと非正規化寄りの違いをイメージしてみます。
正規化寄りの設計では、だいたいこうなります。
users(id, name, email, …)
products(id, name, price, …)
orders(id, user_id, created_at, …)
order_items(id, order_id, product_id, quantity, …)
注文履歴画面で「ユーザー名」「商品名」「注文時の単価」「合計金額」を出したいとき、
SQL はかなりJOINだらけになります。
一方、非正規化を取り入れた設計では、こう変えます。
orders に user_name, total_amount を持たせる
order_items に product_name, unit_price_at_order を持たせる
このとき、設計の意図ははっきりさせておきます。
user_name は「表示用の履歴」であり、顧客マスタの正式名称とズレる可能性がある
product_name や unit_price_at_order は「注文時点のスナップショット」であり、商品マスタの現在値とは違っていてよい
つまり、
マスタは「今の正しい状態」
履歴は「当時の状態」
と割り切るわけです。
この割り切りができると、
履歴側の非正規化は“仕様”であり、“矛盾”ではない
と説明できます。
非正規化するときの“お約束”
「仕様として明文化する・更新の責任場所を決める」
非正規化をやるときに一番危ないのは、「なんとなくやる」ことです。
やるなら、ちゃんと“お約束”を決めます。
このカラムは、マスタのコピーなのか、履歴のスナップショットなのか
マスタが変わったときに、このカラムも追従させるのか、させないのか
追従させるなら、どのレイヤー(アプリ/トリガー/バッチ)で責任を持つのか
例えば、orders.user_name を「常に最新の顧客名に追従させたい」なら、
顧客名変更時に orders を更新する処理が必須です。
逆に、「注文時点の名前を残したい」なら、
顧客名変更時に orders.user_name は更新しない、という仕様になります。
ここを曖昧にすると、「どっちが正しいの?」という地獄が始まります。
非正規化は、技術的な話であると同時に「仕様の話」でもあります。
パフォーマンス視点での判断軸
「JOINの回数・行数・頻度で“非正規化する価値”を測る」
パフォーマンスの観点から、「非正規化する価値があるか」を判断する軸も持っておきましょう。
その画面(クエリ)は、どれくらいの頻度で呼ばれるか
そのクエリは、何テーブルJOINしているか
JOIN するテーブルの行数はどれくらいか
EXPLAIN したときに、どこがボトルネックになっているか
例えば、
1日に数回しか使われない管理画面
JOIN が2〜3個で、EXPLAIN しても特に重そうではない
なら、わざわざ非正規化する必要は薄いです。
逆に、
1秒間に何十回も叩かれるAPI
JOIN が多く、EXPLAIN すると複数テーブルでフルスキャンや filesort が出ている
なら、「JOINを減らすための非正規化」は十分検討に値します。
ここで大事なのは、
「正規化の理想」ではなく「実際の負荷と頻度」で判断する
ということです。
実務での設計手順のイメージ
「まず正規化で組み立ててから、“痛いところだけ”非正規化する」
最後に、「じゃあ実際どう設計していくか」の流れをまとめます。
最初から非正規化全開で行くのではなく、
基本はこういう順番をおすすめします。
まず、正規化を意識して素直にテーブルを分ける
よく使う画面・APIのクエリを洗い出す
それらに EXPLAIN をかけて、JOIN のボトルネックを確認する
本当に重いところだけ、非正規化(履歴コピー・集計テーブル・キャッシュテーブルなど)を検討する
つまり、
「まずはきれいに作る」
「実際の負荷を見てから、必要なところだけ崩す」
というスタンスです。
最初から「どうせ重くなるから全部非正規化でいいや」とやると、
後から整合性地獄になります。
逆に、「正規化こそ正義」と信じてJOINだらけにすると、
今度はパフォーマンス地獄になります。
その真ん中に、自分なりのラインを引けるようになること。
それが Day20 のテーマです。
Day20 後半のまとめ
実務での「正規化と非正規化のバランス」は、テーブルの種類と用途で考えるのが現実的であり、顧客マスタ・商品マスタなど長く参照される“軸”となるマスタ系は正規化寄りにして更新のしやすさと整合性を優先し、注文履歴やログなど「過去の状態をそのまま残したい」「読み取りが圧倒的に多い」履歴系は、ユーザー名・商品名・単価などをコピーして持つ非正規化を積極的に許容する、という線引きが基本になる。
非正規化をする場合は、「これはマスタのコピーなのか、当時のスナップショットなのか」「マスタ変更時に追従させるのか、あえてさせないのか」といった仕様を明確にし、更新の責任場所(アプリ・トリガー・バッチ)を決めておかないと矛盾地獄になるため、単なる“楽するためのショートカット”ではなく“意図のある設計”として扱うことが重要になる。
最終的には、まず正規化を意識して素直に設計し、よく使うクエリに EXPLAIN をかけてJOINのボトルネックや負荷の高い部分を特定し、「頻度が高くて重いところだけ非正規化(履歴コピー・集計テーブル・キャッシュテーブルなど)で崩す」という順番で考えることで、「正規化信者」でも「非正規化信者」でもない、“目的に応じて使い分けられるエンジニア”に近づいていく。
