第3部 設計とパフォーマンス
第6章 正規化とテーブル設計の実践
SQLが書けるようになった今こそ、「そもそもテーブルをどう分けるか」を学ぶときです。悪い設計は毎日の業務に矛盾をばらまきます。正規化という道具で、壊れないテーブルの作り方を身につけましょう。
🎯 この章で学ぶこと
- 「1枚の大きな表」がもたらす3つの異常 — 更新不整合・挿入異常・削除異常
- 第1正規形 — 繰り返し・カンマ区切りをなくして「1セル1値」にする
- 第2正規形 — 複合キーの一部にだけ依存する列を分離する
- 第3正規形 — 「キー以外の列から決まる列」を別テーブルへ追い出す
- 正規化の実益と、あえて崩す判断(非正規化)の使いどころ
- 要件文からCREATE TABLE一式まで、設計の流れをひととおり体験する
第2章でER図と「表を分ける」発想に触れ、第3章で company.db のテーブルを作りました。あのとき部署を departments テーブルに分けたのは、実はこの章で学ぶ正規化を先取りしたものです。この章では「なぜ分けるのか」「どこまで分けるのか」を理屈から積み上げます。
6.1 悪いテーブルの典型例 — 全部入り「注文一覧」
ある会社の注文管理が、こんな1枚のテーブルで作られていたとします。Excelの表をそのままCREATE TABLEした、現場で本当によく見る形です。
| 注文ID | 顧客名 | 顧客住所 | 商品名 | 単価 | 数量 |
|---|---|---|---|---|---|
| 1001 | 山田商店 | 東京都台東区1-2-3 | コピー用紙A4 | 500 | 10 |
| 1002 | 山田商店 | 東京都台東区1-2-3 | トナー黒 | 8000 | 2 |
| 1003 | 川口製作所 | 埼玉県川口市4-5-6 | コピー用紙A4 | 500 | 50 |
| 1004 | 山田商店 | 東京都台東区1-2-3 | コピー用紙A4 | 480 | 20 |
一見便利そうですが、同じ情報があちこちに重複しています。山田商店の住所が3回、コピー用紙の情報が3回。この重複が、次の3つの「異常(アノマリー)」を引き起こします。
| 異常 | 何が起きるか(上の表での具体例) |
|---|---|
| 更新不整合 | 山田商店が移転したら3行すべて直す必要がある。1行でも直し漏れると「住所が2種類ある顧客」が誕生し、どちらが正しいか誰にも分からなくなる |
| 挿入異常 | 「まだ注文のない新規顧客」を登録できない。顧客情報の置き場所が注文の行しかないので、注文が発生するまで顧客が存在できない |
| 削除異常 | 注文1003をキャンセルして行を消すと、川口製作所の住所という注文とは関係ない情報まで道連れに消える |
さらによく見ると、注文1004のコピー用紙の単価が480円になっています。値引きなのか入力ミスなのか、表からは判別できません。「同じ事実は1か所にだけ書く」——これが守られていないテーブルは、時間とともに必ず矛盾を蓄積します。正規化とは、この原則を段階的に実現する手続きです。
6.2 第1正規形 — 繰り返しをなくす
最初の関門は「1つのセルには1つの値だけ」というルールです。次のような表を見たことはないでしょうか。
| 注文ID | 顧客名 | 商品名 |
|---|---|---|
| 2001 | 山田商店 | コピー用紙A4,トナー黒,クリアファイル |
| 2002 | 川口製作所 | コピー用紙A4 |
1つのセルに商品名がカンマ区切りで3つ。Excelなら読めますが、データベースでは致命的です。「トナー黒を含む注文を数える」には文字列の部分一致で探すしかなく、遅いうえに「トナー黒EX」も誤ってヒットします。集計も結合もまともにできません。
解決は行に分けることです。繰り返しの単位(注文の明細1件)を1行にします。
| 注文ID | 商品名 |
|---|---|
| 2001 | コピー用紙A4 |
| 2001 | トナー黒 |
| 2001 | クリアファイル |
| 2002 | コピー用紙A4 |
すべてのセルが単一の値になった状態を第1正規形(1NF)と呼びます。SQLの WHERE 商品名 = 'トナー黒' や GROUP BY 商品名 が正しく機能するのは、この形になって初めてです。SQLは「1セル1値」を前提に設計された言語なのです。
設計時は列が減ってスッキリ見えますが、検索・集計・更新のすべてが文字列加工との格闘になります。「複数の値を持つ」と感じたら、それは列ではなく行(または別テーブル)のサインです。
6.3 第2正規形 — 部分関数従属をなくす
先ほどの明細テーブルに単価と顧客名を戻して考えます。この表の行を一意に特定するには「注文ID+商品名」の複合キー(2列セットの主キー)が必要です。
| 注文ID(キー) | 商品名(キー) | 数量 | 単価 | 顧客名 |
|---|---|---|---|---|
| 2001 | コピー用紙A4 | 10 | 500 | 山田商店 |
| 2001 | トナー黒 | 2 | 8000 | 山田商店 |
| 2002 | コピー用紙A4 | 50 | 500 | 川口製作所 |
ここで各列が「キーの何によって決まるか」を観察します。ある列Aの値が決まると列Bの値が1つに決まる関係を関数従属と呼びます。
- 数量 — 注文IDと商品名の両方が決まって初めて決まる(キー全体に従属)→ ここにいてよい
- 単価 — 商品名だけで決まる(コピー用紙A4なら常に500円)→ キーの一部にしか従属していない
- 顧客名 — 注文IDだけで決まる → 同じくキーの一部にしか従属していない
このように複合キーの一部にだけ依存することを部分関数従属と呼びます。単価が商品ごとの事実なのに明細の行数だけ重複コピーされる——6.1節の異常の原因そのものです。解決は、依存先ごとにテーブルを分けることです。
-- 商品の事実は商品テーブルへ、注文の事実は注文テーブルへ
products(商品ID, 商品名, 単価)
orders(注文ID, 顧客名)
order_items(注文ID, 商品ID, 数量) -- キー全体に従属する数量だけが残る
すべての非キー列がキー全体に従属する状態が第2正規形(2NF)です。「その事実は、何が決まれば決まるのか?」と自問するのがコツです。
6.4 第3正規形 — 推移的関数従属をなくす
ordersテーブルに顧客情報を足して見てみます。主キーは注文IDひとつなので、第2正規形は満たしています。
| 注文ID(キー) | 顧客ID | 顧客名 | 顧客住所 |
|---|---|---|---|
| 2001 | C01 | 山田商店 | 東京都台東区1-2-3 |
| 2002 | C02 | 川口製作所 | 埼玉県川口市4-5-6 |
| 2003 | C01 | 山田商店 | 東京都台東区1-2-3 |
顧客名と顧客住所は、注文IDから直接決まっているのではなく、「注文ID → 顧客ID → 顧客名・住所」と2段階(玉突き)で決まっています。これを推移的関数従属と呼びます。顧客が引っ越せば注文の行を全部直す羽目になる——またしても同じ病気です。
処方箋も同じで、「顧客IDが決まれば決まるもの」を顧客テーブルへ分離します。
customers(顧客ID, 顧客名, 顧客住所)
orders(注文ID, 顧客ID) -- 顧客のことは顧客IDという参照だけを持つ
非キー列同士の従属がなくなった状態が第3正規形(3NF)です。実務のテーブル設計は、ほとんどの場合この第3正規形を目標にすれば十分です。ここまでの3段階は、結局ひとつの言葉に要約できます——「すべての列は、キーに、キー全体に、そしてキーだけに従属せよ」。
第3章から使っているcompany.dbで、employeesが部署名ではなく dept_id を持ち、部署名はdepartmentsに1回だけ書かれているのは、まさに推移的関数従属(社員ID → 部署ID → 部署名)の分離です。「部署名の変更はdepartmentsの1行を直すだけ」——正規化の恩恵を、あなたはすでに体験していたわけです。
6.5 正規化の実益と、あえて崩す判断
正規化の対価も正直に見ておきましょう。テーブルが分かれるほど、元の「全部入りの表」を見るにはJOIN(第4章)が必要になります。テーブルが5つ6つと絡む集計クエリは書くのも実行するのも重くなりがちです。
それでも原則は「まず正規化」です。JOINが増えるのは「書く手間と計算コスト」の問題ですが、正規化しない場合に起きるのは「データそのものが信用できなくなる」問題で、重さが違います。遅いクエリは第7章の技術で速くできますが、矛盾したデータは技術では直せません。
そのうえで、実務にはあえて崩す(非正規化する)定番パターンがあります。
- 集計用テーブルを別に持つ — 「日次の売上サマリー」など、夜間バッチでJOINと集計を済ませた結果を専用テーブルに書いておき、朝の報告画面はそれを読むだけにする。正本(正規化されたテーブル)は崩さず、コピーの側だけ崩すのが要点です。
- 履歴には値を焼き付ける — 注文明細に「注文時点の単価」を持たせるのは重複ではなく正解です。商品マスタの単価は将来変わりますが、過去の注文金額は変わってはいけないからです。「現在の事実」と「その時点の記録」は別物、と覚えてください。
「正規化すると遅くなりそうだから最初から崩す」は典型的な早すぎる最適化です。順序は①第3正規形で設計する → ②実際に遅い箇所を計測で特定する(第7章)→ ③インデックスで解決できないときに初めて非正規化を検討する、です。崩した箇所は「正本とコピーの二重管理」という新しい仕事を生むことも忘れずに。
6.6 設計演習 — 社内問い合わせ管理システム
仕上げに、要件文からテーブル設計まで通しで歩いてみます。情報システム部にこんな依頼が来たとしましょう。
「社員からのIT問い合わせをチケットとして記録したい。チケットには件名・本文・起票者・担当者・状態(未対応/対応中/完了)がある。チケットには複数のコメントがぶら下がり、誰がいつ書いたかを残したい。」
設計の手順はいつも同じです。①名詞を拾って「モノ」の候補にする → ②モノごとの属性を列にする → ③モノ同士の関係(1対多)を外部キーで結ぶ。
- 名詞を拾う: 社員(users)、チケット(tickets)、コメント(comments)、状態(statuses)
- 関係を考える: 1人の社員は多くのチケットを起票する(1対多)。1つのチケットに多くのコメントが付く(1対多)。状態は「未対応/対応中/完了」の選択肢マスタにする——チケットに文字列で直接書くと表記ゆれ(「完了」「済」「クローズ」)が起きるからです。まさに正規化の発想です。
CREATE TABLE一式はこうなります。
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE statuses (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE -- 未対応 / 対応中 / 完了
);
CREATE TABLE tickets (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_by INTEGER NOT NULL REFERENCES users(id), -- 起票者
assigned_to INTEGER REFERENCES users(id), -- 担当者(未定ならNULL)
status_id INTEGER NOT NULL REFERENCES statuses(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE comments (
id INTEGER PRIMARY KEY,
ticket_id INTEGER NOT NULL REFERENCES tickets(id),
author_id INTEGER NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
チェックしてみましょう。1セル1値(1NF)、主キーは単独のidなので部分従属なし(2NF)、「起票者の名前」のような玉突きの列はなく参照はすべてIDだけ(3NF)。担当者が異動で改名しても直すのはusersの1行だけです。要件の名詞がほぼそのままテーブルになる——正規化された設計は、現実の構造の素直な写し絵になります。
正規化は整合性のためだけの技術ではありません。個人情報を1枚のテーブルに集約しないこと自体が、被害を限定する防御になります。たとえば社員の氏名・自宅住所・給与・評価を1テーブルに置くと、「氏名一覧が見たいだけ」の画面や担当者にも全列へのアクセス権を渡しがちで、SQLインジェクション(第9章)で1テーブル抜かれただけで全情報が漏れます。氏名などの基本情報と、住所・給与などの機微情報をテーブルとして分離しておけば、テーブル単位でアクセス権を分けられ、漏えい時の被害も「抜かれたテーブルの範囲」に限定できます。設計段階で「この列は誰が読めるべきか」を考える癖(第1章)が、ここで具体的な形になります。
まとめ
- 重複を放置したテーブルは、更新不整合・挿入異常・削除異常という3つの病気を必ず発症する
- 第1正規形: 1セル1値。カンマ区切りは行(または別テーブル)に分ける
- 第2正規形: 複合キーの一部にだけ従属する列(部分関数従属)を、その依存先のテーブルへ分離する
- 第3正規形: 「キー以外の列から玉突きで決まる列」(推移的関数従属)を別テーブルへ分離する。合言葉は「キーに、キー全体に、キーだけに」
- 原則は第3正規形。非正規化は計測で必要性を確認してから、正本ではなくコピー側(集計用テーブルなど)で行う
- 個人情報のテーブル分離はアクセス権の分離を可能にし、漏えい時の被害を限定する防御になる
練習問題
次の「研修受講記録」テーブルを第3正規形まで分解し、分解後の各テーブルの列を示してください。1行は「ある社員がある研修を受講した記録」で、主キーは(社員ID, 研修ID)の複合キーです。
受講記録(社員ID, 研修ID, 受講日, 社員名, 部署ID, 部署名, 研修名, 研修時間)
解答を見る
各列の従属先を調べます。受講日は複合キー全体に従属(その社員がその研修を受けた日)。社員名・部署IDは社員IDだけに従属(部分関数従属)。研修名・研修時間は研修IDだけに従属(部分関数従属)。さらに部署名は「社員ID → 部署ID → 部署名」の推移的関数従属です。よって4テーブルに分解します。
部署(部署ID, 部署名)
社員(社員ID, 社員名, 部署ID) -- 部署IDは部署への外部キー
研修(研修ID, 研修名, 研修時間)
受講記録(社員ID, 研修ID, 受講日) -- 主キーは(社員ID, 研修ID)
受講記録には「キー全体に従属する受講日」だけが残りました。社員の異動は社員テーブルの1行、研修名の変更は研修テーブルの1行を直すだけで済みます。
ある備品管理テーブルは1枚だけで、次の列を持ちます。
貸出記録(貸出ID, 備品名, 備品の保管場所, 借りた社員名, 社員のメールアドレス, 貸出日, 返却日)
このテーブルで、(1) 挿入異常、(2) 削除異常、(3) 更新不整合が、それぞれ具体的にどんな場面で起きるかを説明してください。
解答を見る
(1) 挿入異常: まだ一度も貸し出されていない新しい備品を登録できません。備品の情報を書ける場所が貸出記録の行しかないため、「貸出ID」が発生するまで備品が存在できないのです(誰も借りていない社員も同様)。
(2) 削除異常: 古い貸出記録を整理のため削除すると、その備品を記録していた行が最後の1件だった場合、備品名や保管場所という備品そのものの情報まで消えます。「記録の削除」のつもりが「備品台帳の削除」になってしまいます。
(3) 更新不整合: 社員のメールアドレスが変わったとき、その社員の貸出行すべてを書き換える必要があります。1行でも漏れると新旧アドレスが混在し、どちらが正しいか分からなくなります。備品の保管場所の変更も同様です。——対策は、備品テーブル・社員テーブルを分離し、貸出記録は備品IDと社員IDの参照だけを持つ第3正規形への分解です。
6.6節のチケット管理システムに「1つのチケットに複数のタグ(例: ネットワーク, プリンタ, 至急)を付けたい」という要件が追加されました。ticketsテーブルに tags TEXT 列を足してカンマ区切りで保存する案の問題点を指摘し、正しいテーブル設計をCREATE TABLEで示してください。
解答を見る
カンマ区切り案は第1正規形違反です。「プリンタのタグが付いたチケット数」を数えるにはLIKEの部分一致に頼るしかなく、遅いうえに「プリンタドライバ」のような別タグに誤ヒットします。タグ名の変更も全チケットの文字列置換になります。
チケットとタグは「多対多」(1チケットに複数タグ、1タグは複数チケットに付く)なので、タグのマスタと中間テーブルで表現します。
CREATE TABLE tags (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE ticket_tags (
ticket_id INTEGER NOT NULL REFERENCES tickets(id),
tag_id INTEGER NOT NULL REFERENCES tags(id),
PRIMARY KEY (ticket_id, tag_id) -- 同じタグの二重付与を防ぐ
);
集計も ticket_tags をJOINして GROUP BY するだけの素直なSQLになります(第4章)。「多対多は中間テーブル」は設計の定石として覚えておきましょう。