仕組みから学ぶ Web
基礎読了目安 14#正規化#テーブル設計#整合性#RDB

正規化 — 同じ事実を2か所に書かない

正規化は理論ではなく「更新時に矛盾が起きない形」を作る作業。どこまで正規化し、どこで意図的に崩すか。

この記事の進み方

What — 段階的に分解していく

正規化は「1つの事実を1か所にまとめる」という作業を、段階を踏んで進めるものです。

Figure非正規形から第3正規形まで
非正規形+ なし1つの行に繰り返しがある商品1, 商品2, 商品3 …
第1正規形+ 繰り返しの排除繰り返しを別の行に分けるorder_items テーブルに分離
第2正規形+ 部分関数従属の排除主キーの一部にしか依存しない列を分ける商品名を products へ
第3正規形+ 推移的関数従属の排除キー以外の列に依存する列を分ける都道府県名を prefectures へ
この段階までで排除された重複
なしデータ本体
1/4
非正規形1件の注文に商品を3つまで入れられるよう、product1 / product2 / product3 のようなカラムを並べた状態。4つ目を注文されると破綻し、「商品名で検索」も書けない。

各段階は「どういう種類の重複を排除するか」で分かれている。実務では第3正規形まで到達すれば、更新時の矛盾はほぼ防げる。

正規化されていないと何が起きるか

Compare注文テーブルに商品名を持つ場合と持たない場合

orders に product_name を直接持つ。結合が不要で速いが、事実が複数箇所に散る。

orders
+----+------------+---------------------+-------+
| id | product_id | product_name        | price |
+----+------------+---------------------+-------+
| 1  | 10         | ワイヤレスイヤホン Pro | 15000 |
| 2  | 10         | ワイヤレスイヤホンPro  | 15000 |  ← 表記ゆれ
| 3  | 10         | ワイヤレスイヤホン Pro | 12000 |  ← 値も違う
+----+------------+---------------------+-------+
読み取り
結合不要で速い
更新時の矛盾
起きる
商品名の変更
全行を更新する必要
未注文の商品
登録できない
  • 商品名を変えるには、その商品の全注文行を UPDATE する必要がある。1行でも漏れると分裂する。
  • まだ1件も注文がない商品は、このテーブルに存在できない(挿入時異常)。
  • 最後の注文を削除すると、商品の情報も消える(削除時異常)。

正規化の効果は「速さ」ではなく「矛盾が起きえない構造」。速さについては、非正規化のほうが有利なことも多い。だから判断が必要になる。

関数従属
ある列の値が決まれば、別の列の値も決まる関係。product_id が決まれば product_name も決まる。
部分関数従属
複合主キーの一部だけで決まってしまう関係。第2正規形で排除する。
推移的関数従属
キー → 中間の列 → その列、と2段階で決まる関係。第3正規形で排除する。
更新時異常
同じ事実が複数行にあるため、一部だけ更新されて矛盾すること。
挿入時異常
関連するデータがないと登録できないこと。注文がない商品を登録できない、など。
削除時異常
ある行を消すと、消すつもりのなかった情報まで失われること。
確認 — ここまで読めたか

正規化が主に守っているのはどれですか?

まず選ぶ(解答例は a〜d の記号で説明します)

Why — 正規化が守っているのは「更新」

正規化の目的は速さではありません。同じ事実を1か所にしか置かないことで、更新時の矛盾を構造的に不可能にすることです。

冒頭の事故を思い出してください。商品名を変更する処理を書いた人は、products テーブルを更新しました。それは正しい。orders にもコピーがあることを知らなかっただけです。

人間が「全部のコピーを更新する」ことに期待する設計は、必ず破れます。 コピーがそもそも存在しなければ、破れようがありません。

では、なぜ非正規化するのか

正規化が正しいなら、なぜ実務で非正規化するのでしょうか。理由は2つあります。

理由1:読み取りの性能

正規化を進めるほどテーブルが増え、結合が増えます。5テーブルの結合を毎秒1万回実行するような場面では、非正規化が現実的な選択になります。

ただしこれは最後の手段です。まずインデックス、次にキャッシュ、それでも足りなければ非正規化。順序を飛ばして非正規化から入ると、性能が出ないうえに矛盾も抱えることになります。

理由2:そもそも「同じ事実」ではない

こちらのほうが重要です。冒頭の事故には、実は別の見方があります。

注文時の商品名は、products の現在の商品名と同じ事実でしょうか

領収書に印字された商品名は、その時点の商品名です。商品名が後から変わっても、過去の領収書の記載が変わってはいけません。同様に、注文時の価格は「今の価格」ではなく「注文したときの価格」です。

つまり orders.product_name は、products.name のコピーではなく、独立した事実なのです。

冒頭の事故の本当の問題は、「商品名をコピーしたこと」ではなく、**「スナップショットのつもりなのか、キャッシュのつもりなのかが曖昧だったこと」**です。

  • スナップショットなら → 商品名が変わっても更新してはいけない。表記ゆれは正常
  • キャッシュなら → 商品名が変わったら全行更新しなければならない。更新漏れが事故

どちらのつもりかが決まっていないと、どちらの運用も正しくできません。

確認 — ここまで読めたか

注文テーブルに購入時の商品名を持たせるのは、非正規化の失敗ですか?

まず選ぶ(解答例は a〜d の記号で説明します)

演習 — まず自分で判断する

解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。

演習 — 設計判断を問う

この設計をどう直しますか?

与えられた条件
  • 飲食店のモバイルオーダーシステム。注文テーブルが以下の1枚だけになっている
  • orders(id, table_no, item1_name, item1_price, item2_name, item2_price, item3_name, item3_price, staff_name, staff_phone, total, ordered_at)
  • 1回の注文で4品以上頼まれることがあり、その場合は注文を分けて登録している
  • 商品の価格は月替わりで変わる。過去の注文の金額は、注文時の価格でなければならない
  • スタッフの電話番号が変わったとき、過去の注文データの電話番号も更新すべきか議論になっている
  • 「今月、唐揚げは何食出たか」という集計を毎日行っている
この軸で考える
  • · 繰り返しのカラムがあることで、どんなクエリが書けなくなっているか
  • · 商品価格とスタッフ電話番号は、同じ性質のデータか
  • · 正規化すべき列と、スナップショットとして残すべき列をどう見分けるか
まず選ぶ(解答例は a〜d の記号で説明します)

演習 — 説明できるか

「結合が多くて遅いので、テーブルに列を写したい」という提案に、何を確認すべきかを説明してください

与えられた条件
  • 相手は速度を理由にしている
  • 写す対象は他テーブルの現在値
  • 反対ではなく、条件を詰めたい

読み終わりましたか?

読了にすると、これを前提とする記事がロードマップで開放されます。

この記事を前提にしている記事