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 違反になります。
部分従属は「主キーの一部だけで決まる列がある」、推移従属は「非キー列が別の非キー列で決まる」 —— 従属の相手が主キーの一部か、非キー列かの違いです。
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"
%%
// 非正規化テーブルをシードする(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() で実測します。
%%
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 行しか存在しなくなります。
%%
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. 更新異常を実測する(正規化後)¶
同じ「佐藤さんのメールアドレス変更」を、正規化後のテーブルで行います。
%%
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 表にまとめます。
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()
}
%%
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 するだけです。
// 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 できないため、各ノートブックにこの定義を置いています)
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))
}
%%
// このチェックは 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 で「トランザクションとインデックス」を見ます。