株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。
Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
▶コーポレートサイト
このシリーズについて
「知ってはいる。けど、人に説明しろと言われると詰まる」——そんな歯がゆいDB用語を、1記事1用語・図解中心で解消していくシリーズです。
- 第1回:ACIDの「C(一貫性)」、説明できますか?
- 第2回:トランザクション分離レベル ―「READ COMMITTED」で結局なにが読めるの?
- 第3回:ロックとデッドロック ―「誰が」「何を」「どこまで」ロックしている?
- 第4回:「スキーマ」って結局なに? ― 同じ単語が文脈で別物を指している
まず自己診断
「テーブル設計は第3正規形までやっておけばいい」——現場でよく聞くフレーズです。では:
その「まで」は何ですか。第4正規形があるのに、なぜ3で止めるんですか。
ここで詰まる人が多いはずです。第1〜第3正規形の手順を暗記していても、「なぜ3が実務の着地点なのか」は教わらないからです。
答えを先に言うと、3NFまでで「よくある事故」がほぼ全部消えるからです。それを実際に見ていきます。
正規化の目的はひとつだけ
正規化の目的は、テーブルを増やすことでも、暗記した手順を踏むことでもありません。
同じ事実を、1箇所にしか書かない。
これだけです。同じ事実が複数箇所にあると、片方だけ更新されて矛盾します。
実測:正規化していないと何が起きるか
口で言うより速いので、動かします。sqlite3(macOS標準) で、受講記録を1枚の表に詰め込んだ「悪い設計」を作ります。
CREATE TABLE enrollments_bad (
student_id INTEGER NOT NULL,
student_name TEXT NOT NULL,
course_id INTEGER NOT NULL,
course_name TEXT NOT NULL,
teacher_name TEXT NOT NULL,
teacher_tel TEXT NOT NULL,
PRIMARY KEY (student_id, course_id)
);
INSERT INTO enrollments_bad VALUES
(1,'佐藤',101,'SQL入門','山田','090-1111'),
(2,'鈴木',101,'SQL入門','山田','090-1111'),
(3,'高橋',101,'SQL入門','山田','090-1111'),
(1,'佐藤',102,'Java入門','田中','090-2222'),
(2,'鈴木',104,'SQL応用','山田','090-1111');
山田先生(SQL入門とSQL応用の2講座を担当)の電話番号が、4行に重複しています。ここから3つの事故が起きます。
① 更新不整合:片方だけ変わる
山田先生の番号が変わったので更新します。ただし、うっかり条件を絞ってしまいました。
UPDATE enrollments_bad SET teacher_tel = '090-9999'
WHERE teacher_name = '山田' AND student_id = 1;
SELECT student_name, course_name, teacher_name, teacher_tel FROM enrollments_bad;
student_name course_name teacher_name teacher_tel
------------ ----------- ------------ -----------
佐藤 SQL入門 山田 090-9999
鈴木 SQL入門 山田 090-1111
高橋 SQL入門 山田 090-1111
佐藤 Java入門 田中 090-2222
鈴木 SQL応用 山田 090-1111
同じ山田先生に2つの電話番号が生まれました。確認します。
SELECT teacher_name, COUNT(DISTINCT teacher_tel) AS tel_kinds
FROM enrollments_bad GROUP BY teacher_name;
teacher_name tel_kinds
------------ ---------
山田 2
田中 1
どちらが正しいのか、DBには判断できません。
② 挿入不整合:受講者がいない講座を登録できない
新しい講座を作りたいのですが、まだ受講者がいません。
INSERT INTO enrollments_bad (course_id, course_name, teacher_name, teacher_tel)
VALUES (103,'Python入門','伊藤','090-3333');
Error: stepping, NOT NULL constraint failed: enrollments_bad.student_id (19)
講座を登録するのに、受講者をでっち上げないといけないという異常な状態です。
③ 削除不整合:消したくないものまで消える
Java入門の受講登録を取り消します。
DELETE FROM enrollments_bad WHERE course_id = 102;
SELECT COUNT(*) FROM enrollments_bad WHERE teacher_name = '田中';
0
田中先生の連絡先が、この世から消えました。 消したかったのは受講記録だけなのに。
1NF → 2NF → 3NF:1枚の表が割れていく
この3つの事故を消す作業が正規化です。段階ごとに「何が消えるか」を対にして見ます。
第1正規形(1NF):繰り返しをなくす
1行に「SQL入門, Java入門」のように複数の値を詰め込まない、という段階です。
| 悪い例(非1NF) | student_id | courses |
|---|---|---|
| 1 | SQL入門, Java入門 |
これを1講座1行に展開します。表がSQLで扱える形になるのがゴールで、ここはまだ事故は消えません。
第2正規形(2NF):キーの一部にしか依存しない列を追い出す
主キーは (student_id, course_id) の複合キーです。ここで各列を見ると、キー全体を見なくても決まる列があります。
student_name は student_id だけで決まります。キーの一部にしか依存していないこの状態を部分関数従属と呼び、これを分離するのが2NFです。
ここで②挿入不整合と③削除不整合が消えます。 講座の情報が courses 表に独立するので、受講者ゼロでも講座を登録でき、受講記録を消しても講座は残ります。
第3正規形(3NF):非キー列に依存する列を追い出す
2NF後の courses 表にはまだ問題があります。
teacher_tel は course_id で直接決まるのではなく、teacher_name を経由して決まっています。これが推移的関数従属。teachers 表として切り出すのが3NFです。
これは「理屈のための一手間」ではありません。2NFの courses 表を実際に見ると、山田先生が2講座を担当しているせいでまだ重複が残っています。
course_id name teacher_name teacher_tel
--------- -------- ------------ -----------
101 SQL入門 山田 090-1111
102 Java入門 田中 090-2222
104 SQL応用 山田 090-1111
この状態で片方の講座だけ更新すると、やはり矛盾します。
UPDATE courses SET teacher_tel = '090-9999' WHERE course_id = 101;
SELECT teacher_name, COUNT(DISTINCT teacher_tel) AS tel_kinds FROM courses GROUP BY teacher_name;
teacher_name tel_kinds
------------ ---------
山田 2
田中 1
2NFでは①更新不整合が消えきらない。 だから3NFが要るわけです。
| 段階 | やること | 消える事故 |
|---|---|---|
| 1NF | 繰り返しをなくす | (まだ消えない) |
| 2NF | 部分関数従属を除去 | ②挿入不整合・③削除不整合 |
| 3NF | 推移的関数従属を除去 | ①更新不整合 |
3NFを終えた時点で、冒頭の3つが全部消えている——これが「まで」の正体です。
実測:正規化後は事故が起きない
同じ操作を3NFの構成でやり直します。
-- 2講座を持つ山田先生も、更新は1行だけ
UPDATE teachers SET tel = '090-9999' WHERE name = '山田';
SELECT c.name AS course, t.name AS teacher, t.tel
FROM courses c JOIN teachers t ON c.teacher_id = t.teacher_id ORDER BY c.course_id;
course teacher tel
-------- ------- --------
SQL入門 山田 090-9999
Java入門 田中 090-2222
SQL応用 山田 090-9999
2つの講座の両方に、正しい番号が反映されました。矛盾の起こしようがありません。電話番号を書いた行が、そもそも1行しかないからです。
-- 受講者ゼロの講座も登録できる
INSERT INTO courses VALUES (103,'Python入門',3);
SELECT course_id, name FROM courses;
course_id name
--------- ----------
101 SQL入門
102 Java入門
103 Python入門
-- 受講記録を消しても講師は残る
DELETE FROM enrollments WHERE course_id = 102;
SELECT COUNT(*) FROM teachers WHERE name = '田中';
1
3つとも解消しました。
「まで」の答えと、その先
BCNF以降が存在しないわけではありません。並べるとこうです。
| 正規形 | 除去する対象 | 実務での登場頻度 |
|---|---|---|
| BCNF | 候補キーが複数あり重なる場合の従属 | まれ |
| 4NF | 多値従属 | かなりまれ |
| 5NF | 結合従属 | ほぼ理論の世界 |
3NFで止めるのは、手抜きではなく費用対効果です。BCNF以降が対象とするのは「候補キーが複数あって重なり合う」ような特殊な構造で、普通の業務テーブルではめったに現れません。一方でテーブルを割るほどJOINは増えます。得られる安全性と払うコストが釣り合う地点が3NF、というのが「まで」の意味です。
なお3NFとBCNFは、候補キーが1つだけのテーブルでは一致します。多くの実務テーブルは3NFにした時点で自動的にBCNFも満たしています。
非正規化は「手抜き」ではなく「取引」
最後に実務の注意です。あえて正規化を崩す非正規化という選択があります。
重要なのは、非正規化はチューニングの一種ではなく、トレードオフの選択だという点です。「速いから非正規化」ではなく、「更新不整合のリスクを引き受けてでも読み取り速度が要る」と判断したときだけ選びます。しかも計測して初めて判断できることなので、最初から非正規化で設計するのは、ただの設計ミスです。
第1回で「DBが守るのは宣言した制約だけ」と書きました。非正規化は、その守りを自分から手放す行為でもあります。
まとめ:1行で説明するなら
「正規化の目的は同じ事実を1箇所にしか書かないことです。1NFで繰り返しをなくし、2NFで部分関数従属を除いて挿入・削除の不整合を、3NFで推移的関数従属を除いて更新不整合を消します。よくある事故が3NFで出尽くすので、そこが実務の着地点。BCNF以降が扱うのは候補キーが重なる特殊なケースで、めったに出番がありません」
「第3正規形まで」の「まで」は、**「事故が消えきる地点まで」**という意味です。
次回
第6回:インデックス ——「貼ったのに効かないのはなぜ?」を図解します。
(シリーズ全11回の予定は第1回に掲載しています)
参考
- PostgreSQL 16 Documentation - CREATE TABLE(制約と主キー)
- PostgreSQL 16 Documentation - Constraints
- MySQL 8.0 Reference Manual - CREATE TABLE Statement
- SQLite - Query Language: CREATE TABLE
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!