← レッスン一覧に戻る

02.4 データモデリングと正規化 — 更新異常を消す設計¶

02.1 では、1 つの巨大なテーブルに顧客情報と商品情報を混ぜ込むと、 「1 件変えたいだけなのに何行も直さないといけない」という更新異常が起きることを見ました。

このレッスンでは、その原因を 正規化(normalization) という設計手法で取り除きます。 1NF → 2NF → 3NF の考え方を確認したあと、実際にテーブルを分割し、 「同じ操作をした時に何行変わるか」を正規化の前後で実測して比較します。

このレッスンのゴール:

  • 部分従属・推移従属という「更新異常の原因」を説明できる
  • テーブルを 1NF/2NF/3NF に沿って分割できる
  • 正規化の効果を「実測値」で示せる

1. なぜ更新異常が起きるのか¶

非正規化されたテーブル orders_flat を考えます。1 行が「注文の明細1件」を表し、 顧客の名前・メールと、商品の名前・価格が、注文のたびに繰り返し書き込まれています。

order_id customer_id customer_name customer_email product_id product_name price qty
1 1 佐藤 sato@example.com 10 マグカップ 800 2
2 1 佐藤 sato@example.com 20 ノート 300 1
3 2 鈴木 suzuki@example.com 10 マグカップ 800 3

佐藤さんの注文が増えるたびに customer_name/customer_email の同じ値がコピーされます。 これが起きる理由を、関数従属(functional dependency)で説明すると:

  • customer_name・customer_email は customer_id にしか従属していない(order_id は関係ない)
  • product_name・price は product_id にしか従属していない(order_id は関係ない)

つまり orders_flat の主キーは実質的に order_id なのに、主キーの一部(顧客・商品)にしか 従属しない列が同じテーブルに同居しています。これを 部分従属(partial dependency) と呼び、 2NF 違反の典型パターンです。

2. 非自明な点: 2NF と 3NF の違い¶

  • 1NF(第1正規形): 各列が「原子値」であること(1 マスに複数の値を詰め込まない)。 今回の orders_flat は各列が単一値なので 1NF 自体は満たしています。
  • 2NF(第2正規形): 1NF を満たし、かつ非キー列が主キーの一部にしか従属しない状態(部分従属) が無いこと。orders_flat は customer_name が order_id 全体ではなく customer_id だけに 従属しているため 2NF 違反です。
  • 3NF(第3正規形): 2NF を満たし、かつ非キー列が他の非キー列を経由して従属する状態 (推移従属 transitive dependency)が無いこと。たとえば「郵便番号→市区町村」のような 従属が同じテーブルにあると 3NF 違反になります。

部分従属は「主キーの一部だけで決まる列がある」、推移従属は「非キー列が別の非キー列で決まる」 —— 従属の相手が主キーの一部か、非キー列かの違いです。

In [1]:
import (
	"database/sql"
	"errors"
	"fmt"
	"strings"

	"github.com/janpfeifer/gonb/gonbui"

	_ "modernc.org/sqlite"
)

// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")

// openCourseDB は、このコース共通の SQLite 接続を開く。
// GoNB はセル単位で実行されるため、DB は「1セル完結」(open→defer Close→操作)で使う。
// セルをまたいだ状態共有は Go 変数ではなく、同じパスのファイルを介して行う。
func openCourseDB(path string) *sql.DB {
	db, err := sql.Open("sqlite", path)
	if err != nil {
		panic(err)
	}
	db.SetMaxOpenConns(1)
	return db
}

const flatDBPath = "file:_02_4_normalization.db"
In [2]:
%%
// 非正規化テーブルをシードする(DROP→CREATE→INSERT を1セルにまとめ、再実行しても同じ初期状態に戻す)。
db := openCourseDB(flatDBPath)
defer db.Close()

db.Exec(`DROP TABLE IF EXISTS orders_flat`)
db.Exec(`CREATE TABLE orders_flat (
	order_id INTEGER PRIMARY KEY,
	customer_id INTEGER,
	customer_name TEXT,
	customer_email TEXT,
	product_id INTEGER,
	product_name TEXT,
	price INTEGER,
	qty INTEGER
)`)

seed := []struct {
	customerID          int
	customerName, email string
	productID           int
	productName          string
	price, qty          int
}{
	{1, "佐藤", "sato@example.com", 10, "マグカップ", 800, 2},
	{1, "佐藤", "sato@example.com", 20, "ノート", 300, 1},
	{1, "佐藤", "sato@example.com", 30, "ペン", 150, 5},
	{2, "鈴木", "suzuki@example.com", 10, "マグカップ", 800, 3},
	{2, "鈴木", "suzuki@example.com", 20, "ノート", 300, 2},
	{3, "田中", "tanaka@example.com", 30, "ペン", 150, 1},
}
for _, s := range seed {
	_, err := db.Exec(
		`INSERT INTO orders_flat (customer_id, customer_name, customer_email, product_id, product_name, price, qty)
		 VALUES (?, ?, ?, ?, ?, ?, ?)`,
		s.customerID, s.customerName, s.email, s.productID, s.productName, s.price, s.qty)
	if err != nil {
		panic(err)
	}
}
fmt.Println("✅ orders_flat をシードしました(6行)")
✅ orders_flat をシードしました(6行)

3. 更新異常を実測する(正規化前)¶

佐藤さん(customer_id=1)のメールアドレスが変わったとします。orders_flat では 佐藤さんの行すべてを UPDATE しないと矛盾(同じ人なのに行によってメールが違う)が起きます。 何行変わるかを RowsAffected() で実測します。

In [3]:
%%
db := openCourseDB(flatDBPath)
defer db.Close()

res, err := db.Exec(`UPDATE orders_flat SET customer_email = ? WHERE customer_id = ?`, "sato-new@example.com", 1)
if err != nil {
	panic(err)
}
n, _ := res.RowsAffected()
fmt.Printf("非正規化テーブルでの更新: %d 行が変更された(佐藤さんの注文は3件あるので3行)\n", n)

var dup int
db.QueryRow(`SELECT COUNT(*) - COUNT(DISTINCT customer_id) FROM orders_flat`).Scan(&dup)
fmt.Printf("customer_id の重複行数(全%d行のうち): %d\n", 6, dup)
非正規化テーブルでの更新: 3 行が変更された(佐藤さんの注文は3件あるので3行)
customer_id の重複行数(全6行のうち): 3

4. 正規化する: customers / products / order_items に分割¶

orders_flat を、部分従属の相手ごとにテーブルへ分割します。

  • customers(customer_id, name, email) — 顧客情報は顧客IDにしか従属しない
  • products(product_id, name, price) — 商品情報は商品IDにしか従属しない
  • order_items(order_id, customer_id, product_id, qty) — 注文明細は注文IDに従属する事実だけを持つ

これで customer_name/customer_email は customers テーブルに 1 顧客 1 行しか存在しなくなります。

In [4]:
%%
db := openCourseDB(flatDBPath)
defer db.Close()

for _, ddl := range []string{
	`DROP TABLE IF EXISTS customers`,
	`DROP TABLE IF EXISTS products`,
	`DROP TABLE IF EXISTS order_items`,
	`CREATE TABLE customers (customer_id INTEGER PRIMARY KEY, name TEXT, email TEXT)`,
	`CREATE TABLE products (product_id INTEGER PRIMARY KEY, name TEXT, price INTEGER)`,
	`CREATE TABLE order_items (order_id INTEGER PRIMARY KEY, customer_id INTEGER, product_id INTEGER, qty INTEGER)`,
} {
	if _, err := db.Exec(ddl); err != nil {
		panic(err)
	}
}

// orders_flat から重複を除いて分解して詰め直す(正規化のマイグレーション操作そのもの)。
db.Exec(`INSERT INTO customers (customer_id, name, email)
          SELECT DISTINCT customer_id, customer_name, customer_email FROM orders_flat`)
db.Exec(`INSERT INTO products (product_id, name, price)
          SELECT DISTINCT product_id, product_name, price FROM orders_flat`)
db.Exec(`INSERT INTO order_items (order_id, customer_id, product_id, qty)
          SELECT order_id, customer_id, product_id, qty FROM orders_flat`)

var custCount, prodCount, itemCount int
db.QueryRow(`SELECT COUNT(*) FROM customers`).Scan(&custCount)
db.QueryRow(`SELECT COUNT(*) FROM products`).Scan(&prodCount)
db.QueryRow(`SELECT COUNT(*) FROM order_items`).Scan(&itemCount)
fmt.Printf("customers=%d行 / products=%d行 / order_items=%d行\n", custCount, prodCount, itemCount)
customers=3行 / products=3行 / order_items=6行

5. 更新異常を実測する(正規化後)¶

同じ「佐藤さんのメールアドレス変更」を、正規化後のテーブルで行います。

In [5]:
%%
db := openCourseDB(flatDBPath)
defer db.Close()

res, err := db.Exec(`UPDATE customers SET email = ? WHERE customer_id = ?`, "sato-new2@example.com", 1)
if err != nil {
	panic(err)
}
n, _ := res.RowsAffected()
fmt.Printf("正規化後のテーブルでの更新: %d 行が変更された\n", n)

var dup int
db.QueryRow(`SELECT COUNT(*) - COUNT(DISTINCT customer_id) FROM customers`).Scan(&dup)
fmt.Printf("customers テーブルでの customer_id 重複行数: %d\n", dup)
正規化後のテーブルでの更新: 1 行が変更された
customers テーブルでの customer_id 重複行数: 0

6. 正規化前後を並べて比較する¶

数値だけだと実感しづらいので、HTML 表にまとめます。

In [6]:
func renderComparison(rows [][4]string) string {
	var b strings.Builder
	b.WriteString(`<table border="1" cellpadding="4" style="border-collapse:collapse">`)
	b.WriteString(`<tr><th>項目</th><th>正規化前(orders_flat)</th><th>正規化後(customers等)</th><th>観察点</th></tr>`)
	for _, r := range rows {
		b.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`, r[0], r[1], r[2], r[3]))
	}
	b.WriteString(`</table>`)
	return b.String()
}
In [7]:
%%
gonbui.DisplayHTML(renderComparison([][4]string{
	{"メール変更で影響を受ける行数", "3行(佐藤さんの注文数ぶん重複更新)", "1行(customersに1行しかない)", "更新異常が消えた"},
	{"customer_id の重複行数", "3行(6行中、顧客は3人なので3重複)", "0行(customersはPKで1顧客1行)", "重複が構造的に無くなった"},
}))
gonbui.Sync()
項目正規化前(orders_flat)正規化後(customers等)観察点
メール変更で影響を受ける行数3行(佐藤さんの注文数ぶん重複更新)1行(customersに1行しかない)更新異常が消えた
customer_id の重複行数3行(6行中、顧客は3人なので3重複)0行(customersはPKで1顧客1行)重複が構造的に無くなった

表を見てください。「正規化前は1回の変更が複数行に波及する」「正規化後は1回の変更が1行で完結する」 という違いが、行数という実測値ではっきり分かります。これが「正規化で更新異常が消える」の中身です。

7. 直感・類推: 名簿を1冊にまとめるか、教室ごとに配るか¶

非正規化テーブルは、クラス名簿を教室の黒板に書き写して回るようなものです。 同じ生徒の名前が複数の黒板に書かれていると、転校のたびに全部の黒板を書き直す必要があります。

正規化は、「生徒名簿」を職員室に 1 冊だけ置き、各教室は生徒IDだけを黒板に書く方式です。 転校の連絡は職員室の名簿 1 冊を直すだけで済みます。教室(order_items)は 「どの生徒か」という事実だけを持ち、生徒の詳細(customers)は名簿という 1 箇所に集約されます。

練習問題 2.4: 影響行数を数える関数を実装しよう¶

customerOrderRowCount を実装してください。この関数は、 「その顧客の情報を orders_flat で変更したら何行更新されるか」を数えるだけ(非破壊)の関数です。 02.1/02.4 で見た更新異常の大きさを、コードから直接確認できるようにします。

仕様:

func customerOrderRowCount(db *sql.DB, customerID int) (int, error)
  • orders_flat の中で customer_id = customerID の行数を返す
  • 該当行が無ければ 0, nil を返す(エラーではない)
  • SQL エラーが起きたらそのエラーを返す

ヒント: SELECT COUNT(*) FROM orders_flat WHERE customer_id = ? を QueryRow して Scan するだけです。

In [8]:
// YOUR CODE HERE
// func customerOrderRowCount(db *sql.DB, customerID int) (int, error) を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func customerOrderRowCount(db *sql.DB, customerID int) (int, error) {
	return -1, ErrUnanswered
}

チェックのためのヘルパー¶

答え合わせに使う小さなヘルパー mustEqual を定義します。 (GoNB はローカルパッケージを import できないため、各ノートブックにこの定義を置いています)

In [9]:
import "reflect"

func mustEqual(got, want any, name string) {
	if reflect.DeepEqual(got, want) {
		fmt.Printf("✅ Passed: %s\n", name)
		return
	}
	panic(fmt.Sprintf("❌ %s\n  got  = %v (%T)\n  want = %v (%T)", name, got, got, want, want))
}
In [10]:
%%
// このチェックは SELECT のみで DB を変更しないため、何度実行しても同じ結果になる。
db := openCourseDB(flatDBPath)
defer db.Close()

n1, err1 := customerOrderRowCount(db, 1)
if errors.Is(err1, ErrUnanswered) {
	fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
	mustEqual(err1, nil, "customer_id=1 はエラーなし")
	mustEqual(n1, 3, "佐藤さん(customer_id=1)は3行")

	n2, err2 := customerOrderRowCount(db, 2)
	mustEqual(err2, nil, "customer_id=2 はエラーなし")
	mustEqual(n2, 2, "鈴木さん(customer_id=2)は2行")

	n3, err3 := customerOrderRowCount(db, 999)
	mustEqual(err3, nil, "存在しないIDはエラーではない")
	mustEqual(n3, 0, "存在しないIDは0行")

	fmt.Println("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください

まとめ¶

  • 部分従属(主キーの一部だけに従属)・推移従属(非キー列に従属)が更新異常の原因
  • 正規化はテーブルを従属関係ごとに分割し、事実を1箇所にまとめる設計
  • 効果は「同じ操作で何行変わるか」を RowsAffected() で実測して確認できる

答え合わせは 02.4-normalization-modeling-solutions.ipynb で行ってください。 次は 02.5 で「トランザクションとインデックス」を見ます。