ブログ

データベース正規化:第1〜第3正規形と非正規化

データベースの正規化を試験科目として覚えている人は多いのですが、実務での正規化は試験ではなく事故の予防です。この記事では第1・第2・第3正規形がそれぞれどんな事故を防ぐのかを、掲示板とECサイトの例でわかりやすくひとつずつ確かめて、最後に多くの解説が飛ばす部分、あえて崩すのはいつか(非正規化)まで扱います。

まず事故の場面からです。会員の住所が、会員テーブル・注文テーブル・配送テーブルの3か所に保存されているDBがあります。会員が引っ越して、1か所だけ更新されました。いま同じ会員の住所が、3か所で2種類の値として存在しています。どれが本当でしょうか。この問いに答えられない状態が更新時の不整合(更新異常)で、正規化はこの事故を構造で防ぐ方法です。

ひと目でわかる:第1〜第3正規形が防ぐ事故

3つの正規形の違いを先に表で押さえておくと、下の例を早く身につけられます。

正規形 捕まえる構造 代表的な症状
第1正規形(1NF) 1マスに複数の値 カンマでつないだタグを検索・結合できない
第2正規形(2NF) 複合キーの一部にだけ従属するカラム 注文明細ごとに商品名がコピーされ修正が大仕事になる
第3正規形(3NF) キー以外のカラムに従属するカラム 郵便番号を直すと市区町村も直すことになる

ここから、違反のDDLと直したDDLを並べてひとつずつ見ていきます。

第1正規形(1NF):カンマ区切りカラムの例

いちばんよく見る違反からです。投稿にタグを付けたいけれどテーブルを増やしたくなくて、こう作ってしまった場合です。

-- 1NF違反:1カラムに複数の値
CREATE TABLE posts (
  id    BIGINT AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(200) NOT NULL,
  tags  VARCHAR(255) NULL COMMENT 'タグ:mysql,er図,正規化 のようにカンマで'
);

最初は問題なく動きます。困るのは「er図タグが付いた投稿一覧」が要るようになった瞬間からです。LIKE '%er図%' の検索は別のタグまで拾い、インデックスは効かず、タグ名をひとつ変えるには文字列を全部パースして直すことになります。1マスに値が複数入った瞬間、DBはその値をデータとして扱えなくなるのです。

1NFは「すべてのマスに値はひとつ」という規則で、直し方は値ごとに行を持たせることです。

-- 1NF充足:タグを行に
CREATE TABLE tags (
  id   INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(50) NOT NULL UNIQUE COMMENT 'タグ名'
);

CREATE TABLE post_tags (
  post_id BIGINT NOT NULL,
  tag_id  INT    NOT NULL,
  PRIMARY KEY (post_id, tag_id),
  CONSTRAINT fk_pt_post FOREIGN KEY (post_id) REFERENCES posts (id),
  CONSTRAINT fk_pt_tag  FOREIGN KEY (tag_id)  REFERENCES tags (id)
) COMMENT='投稿タグ';

確認ポイントはひとつです。カンマ・スラッシュ・空白で値をつないだカラムはないか。あるなら、検索が必要になってから直すより、いま分けておくほうが修正の手間がずっと少なくて済みます。

第2正規形(2NF):複合キーの半分にだけ従属するカラム

2NFが問題になるのは複合主キーを使うテーブルだけです。ECサイトの注文明細テーブルで見ていきます。

-- 2NF違反:product_nameが複合キーの一部(product_id)にだけ従属
CREATE TABLE order_items (
  order_id     BIGINT NOT NULL,
  product_id   BIGINT NOT NULL,
  product_name VARCHAR(200) NOT NULL COMMENT '商品名(問題のカラム)',
  quantity     INT NOT NULL,
  PRIMARY KEY (order_id, product_id)
);

このテーブルの主キーは(order_id, product_id)の複合キーです。ところがproduct_nameは、注文と関係なくproduct_idだけで決まる値です。結果は、同じ商品名が注文明細の数だけコピーされること。商品名を変える日には数万行をUPDATEするか、一部だけ直って同じ商品が2つの名前で残ることになります。

部分関数従属とは

いまの状況を呼ぶ名前が部分関数従属です。難しく聞こえますが意味はそのままで、複合キーの一部だけで決まるカラムがそのテーブルに入っている状態です。product_nameはキーの半分(product_id)にだけ従属しているので部分従属で、2NFはこういうカラムを自分のキーがあるテーブルへ移しなさいという規則です。

-- 2NF充足:商品情報はproductsへ
CREATE TABLE products (
  id   BIGINT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(200) NOT NULL COMMENT '商品名'
);

CREATE TABLE order_items (
  order_id   BIGINT NOT NULL,
  product_id BIGINT NOT NULL,
  quantity   INT NOT NULL,
  PRIMARY KEY (order_id, product_id),
  CONSTRAINT fk_oi_product FOREIGN KEY (product_id) REFERENCES products (id)
);

確認ポイント:複合キーのテーブルで「このカラム、キー全体ではなく一部だけでも決まらないか?」をカラムごとに問えば見つかります。ひとつでも当てはまれば、そのカラムの置き場所は別のテーブルです。

第3正規形(3NF):キーでないカラムに従属するカラム

3NFは複合キーがなくても引っかかります。会員テーブルに住所を入れるとよく生まれる形です。

-- 3NF違反:cityがキー(id)ではなくzip_codeに従属
CREATE TABLE members (
  id       BIGINT AUTO_INCREMENT PRIMARY KEY,
  email    VARCHAR(255) NOT NULL UNIQUE,
  zip_code CHAR(7)     NULL COMMENT '郵便番号',
  city     VARCHAR(50) NULL COMMENT '市区町村(問題のカラム)'
);

cityは会員(id)が決める値ではなく、zip_codeが決める値です。郵便番号が分かれば市区町村は自動的に決まるからです。この状態では同じ郵便番号の市区町村名が会員の数だけコピーされ、市町村合併で名前が変われば会員テーブルを何万行も直すことになります。会員情報を直していたら住所の体系が崩れる、冒頭のあの事故です。

推移的関数従属の例

この構造の名前は推移的関数従属です。idがzip_codeを決め、zip_codeがcityを決めるので、id → zip_code → cityと間接的に従属がつながった状態です。3NFはこのつながりを断って、キーでないカラム(zip_code)に従属するカラム(city)を別のテーブルへ移しなさいという規則です。

-- 3NF充足:郵便番号の情報はzip_codesへ
CREATE TABLE zip_codes (
  zip_code CHAR(7) PRIMARY KEY COMMENT '郵便番号',
  city     VARCHAR(50) NOT NULL COMMENT '市区町村'
);

CREATE TABLE members (
  id       BIGINT AUTO_INCREMENT PRIMARY KEY,
  email    VARCHAR(255) NOT NULL UNIQUE,
  zip_code CHAR(7) NULL,
  CONSTRAINT fk_members_zip FOREIGN KEY (zip_code) REFERENCES zip_codes (zip_code)
);

確認ポイント:「このカラム、主キー以外のカラムだけでも決まらないか?」。決まるなら、その別のカラムをキーに持つテーブルへ移すべきです。

BCNFから先をあまり話さない理由

教科書にはBCNF、第4正規形、第5正規形と続きますが、実務の大半は3NFで止まります。それより上が3NFと違う結果になるのは、候補キーが重なる場合や多値従属がある特殊な構造だけで、普通の業務スキーマは3NFを守れば自動的に満たしていることが多いのです。3NFまでを基礎として確実にして、その上は該当する構造に出会ったときに調べても遅くありません。

非正規化はいつやるのか:判断基準3つ

ここまで読んで「じゃあとにかく分ければいい」で終わると、半分しか学んでいないことになります。実務には、正規形を知ったうえであえて崩す選択、非正規化があります。問題は崩すこと自体ではなく根拠なく崩すことなので、設計レビューで非正規化を認めるときに見る基準は3つです。

  1. 計測されたボトルネックか。 「結合が多いと遅くなりそう」は根拠になりません。実際のクエリの実行計画と応答時間が問題として計測されてからの話です。推測で作った重複は、性能はそのままで事故のリスクだけ積むことになりがちです。
  2. 更新経路が1か所で管理されるか。 重複を作ると、2つの値を揃える責任がアプリケーションに移ります。その更新がトリガーでもバッチでもコード1か所でも、単一の経路で行われるという答えが必要です。
  3. 崩した事実が文書に残るか。 次の担当者が「これ重複してるけどミスかな」と整理してしまった瞬間が事故です。どのカラムがなぜ重複なのかをテーブル定義書に残すところまでが非正規化です。

よくある2つの型も区別しておく必要があります。集計カラム(投稿のコメント数のような)は、数えれば出る値を性能のために保存する典型的な非正規化なので、上の3基準を全部通す必要があります。一方、スナップショットのカラム(注文明細の注文時点単価)はそもそも非正規化ではありません。商品の現在価格と注文当時の価格は別の意味のデータで、コピーではなくその時点の事実の記録だからです。この区別が付くと、重複に見えるカラムが出てきたときの判断が速くなります。

正規化した結果を目で確かめる

正規化はテーブルを分ける作業なので、終わるとテーブル数が増えてリレーション線が生まれます。この時点で構造を目で確かめておくとよいです。上で直したDDLをそのままWorksCove ERDに貼り付けると、こう描かれます。

正規化の例をER図で確認:1NFで分けたタグ(post_tags)と3NFで分けた郵便番号(zip_codes)がリレーション線でつながったダイアグラム

カンマ区切りだったタグがpost_tagsの中間テーブルに、会員の中にあった市区町村がzip_codesへの参照に変わった構造が、リレーション線として見えます。線の記号が読みにくければER図の記号まとめを横に置いてください。FKがリレーションのN側に正しくあるか、分けたテーブルが意図どおりつながったかの確認はER図の書き方のチェックリストがそのまま使えますし、DDLを取り出して貼り付ける流れ自体はSQLからER図を自動生成するにまとまっています。

よくある質問

正規化はどの正規形まですればよいですか?

実務の基準は第3正規形(3NF)までです。第1〜第3正規形が実務の事故の大半を防いでくれて、その上のBCNF・第4正規形は、候補キーが重なる場合や多値従属のような特殊な構造でしか3NFと結果が変わりません。3NFを基本に置き、性能のためにあえて崩す非正規化を例外として管理するのが現実的な運用です。

注文テーブルに注文時点の価格をコピーするのは正規化違反ですか?

違反ではなく、正しい設計です。注文時点の価格と商品の現在価格は別の意味のデータで、商品の価格が変わっても過去の注文金額はそのまま残らなければなりません。同じ値の重複保存ではなくその時点の事実の記録なので、スナップショットのカラムは正規化と衝突しません。

JSONカラムに複数の値を入れるのは第1正規形違反ですか?

その値で検索や結合をするなら、違反が生む問題をそのまま抱えることになります。タグをJSON配列で入れると、カンマ区切りカラムと同じインデックスと整合性の問題が起きます。逆に、丸ごと保存して丸ごと読むだけの設定値やログ系データなら、JSONカラムは実用的な選択になり得ます。その値がリレーションとして使われるかどうかが判断基準です。

NoSQLの時代にも正規化は必要ですか?

リレーショナルDBを使う限り必要です。NoSQLが重複を許すのは正規化が間違いだからではなく、更新時の不整合の管理をアプリケーションが引き受ける代わりに読み取り性能を得る、別のやり方を選んでいるだけです。その選択の代償を理解するには、正規化が防いでいた事故が何かをむしろ知っておく必要があるので、どちらを使うにせよ出発点は同じです。

最後に要約します。1マスに値はひとつ(1NF)、キー全体に従属(2NF)、キーだけに従属(3NF)。この3つを基本として守り、崩すときは計測・更新経路・文書化の3つを確かめてから崩す。ここまで押さえておけば、正規化は試験勉強ではなく、明日の設計レビューでそのまま使う基準になります。