データベース設計入門 — 正規化からインデックスまで実践ガイド
なぜデータベース設計が重要なのか
データベース設計の良し悪しは、アプリケーションのパフォーマンス、保守性、スケーラビリティに直結します。後から設計を変更するのは極めて困難なため、最初の設計が勝負です。
この記事では、実務で即使えるデータベース設計の基礎から、パフォーマンスチューニングまでを実例付きで解説します。
対象読者
- Webアプリ開発初心者〜中級者
- SQL基礎は理解しているが設計は未経験の方
- 既存DBの改善方法を知りたいエンジニア
使用するDBMS
例はPostgreSQLベースですが、MySQL、SQLiteでも応用可能です。
データベース設計の5ステップ
ステップ1: 要件定義
ステップ2: 概念設計(ER図作成)
ステップ3: 論理設計(正規化)
ステップ4: 物理設計(テーブル定義、インデックス)
ステップ5: パフォーマンスチューニング
順番に見ていきましょう。
ステップ1: 要件定義 — 何を保存するか明確にする
実例: ECサイトのデータベース
保存すべきデータ:
- ユーザー情報(ID、名前、メール、パスワード)
- 商品情報(ID、名前、価格、在庫数)
- 注文情報(注文ID、ユーザーID、注文日時、合計金額)
- 注文明細(注文ID、商品ID、数量、小計)
ビジネスルール:
- 1ユーザーは複数の注文が可能
- 1注文には複数の商品を含められる
- 在庫管理は商品マスタで一元管理
これをER図に落とし込みます。
ステップ2: 概念設計 — ER図でエンティティと関連を可視化
ER図とは
Entity-Relationship Diagram(実体関連図)。データの構造を視覚化する設計図。
ER図の基本要素
- エンティティ(実体): 四角形。Users、Productsなど
- リレーション(関連): 線。1対多、多対多など
- 属性(カラム): 楕円。user_id、nameなど
実例: ECサイトのER図
[Users] 1 ----< * [Orders] * >---- * [Products]
| |
user_id order_id
name user_id (FK)
email order_date
password total_amount
created_at
|
v
[OrderItems]
order_id (FK)
product_id (FK)
quantity
subtotal
カーディナリティ(多重度):
- Users 1 : N Orders(1ユーザーは複数注文)
- Orders M : N Products(多対多、中間テーブル OrderItems で解決)
ER図作成ツール
無料ツール:
- dbdiagram.io: ブラウザでサクッと作成、DDL自動生成
- draw.io: 汎用図作成ツール
- DBeaver: DB管理ツール、既存DBからER図自動生成
有料ツール:
- MySQL Workbench: MySQL公式(無料版もあり)
- ERMaster: Eclipse プラグイン
ステップ3: 論理設計 — 正規化で冗長性を排除
正規化とは
データの重複を排除し、更新時の不整合を防ぐ技術。1NF → 2NF → 3NF → BCNF → 4NF と段階的に進める。
実務では 第3正規形(3NF) までが基本。
非正規形(0NF)の悪い例
-- 注文テーブル(悪い設計)
CREATE TABLE orders_bad (
order_id INT PRIMARY KEY,
user_name VARCHAR(100),
user_email VARCHAR(100),
product_names TEXT, -- "商品A, 商品B, 商品C"
quantities TEXT, -- "2, 1, 3"
prices TEXT, -- "1000, 500, 300"
total_amount INT
);
問題点:
- 商品情報が文字列で連結されている(検索困難)
- ユーザー情報が重複(同じユーザーの注文ごとに名前・メール記録)
- 商品価格変更時に全注文データを更新必要
第1正規形(1NF)— 繰り返し項目の排除
ルール: 全ての属性が単一値(アトミック)であること。
-- 1NFに修正
CREATE TABLE orders_1nf (
order_id INT,
user_name VARCHAR(100),
user_email VARCHAR(100),
product_name VARCHAR(100),
quantity INT,
price INT,
PRIMARY KEY (order_id, product_name)
);
改善点: 商品情報が行単位に分割され、検索可能に。
残る問題: user_name、user_email が注文ごとに重複。
第2正規形(2NF)— 部分関数従属の排除
ルール: 主キーの一部だけに従属する属性を別テーブルに分離。
-- Usersテーブルを分離
CREATE TABLE users (
user_id SERIAL PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(100) UNIQUE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE orders_2nf (
order_id INT,
user_id INT REFERENCES users(user_id),
product_name VARCHAR(100),
quantity INT,
price INT,
PRIMARY KEY (order_id, product_name)
);
改善点: ユーザー情報の重複解消。メール変更が1箇所で済む。
第3正規形(3NF)— 推移的関数従属の排除
ルール: 主キー以外の属性に従属する属性を分離。
-- Productsテーブルを分離
CREATE TABLE products (
product_id SERIAL PRIMARY KEY,
name VARCHAR(100),
price INT,
stock INT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE orders (
order_id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(user_id),
order_date TIMESTAMP DEFAULT NOW(),
total_amount INT
);
CREATE TABLE order_items (
order_item_id SERIAL PRIMARY KEY,
order_id INT REFERENCES orders(order_id),
product_id INT REFERENCES products(product_id),
quantity INT,
subtotal INT -- price * quantity のスナップショット
);
重要なポイント:
order_items.subtotalは冗長だが、過去の注文時点の価格を保持するため必要- 商品の現在価格が変わっても、過去の注文金額は変わらない
これが 正規化の例外 — ビジネスロジック上の必要性。
正規化のメリット・デメリット
メリット:
- データ重複排除 → ストレージ節約
- 更新時の不整合防止
- データの一貫性保証
デメリット:
- JOIN が増えてクエリが複雑化
- 読み取りパフォーマンスが低下する場合がある
→ 読み取り頻度が高い場合は 意図的な非正規化 も選択肢。
ステップ4: 物理設計 — テーブル定義とインデックス
データ型の選択
整数型:
SMALLINT -- -32768 〜 32767(年齢、数量など)
INT -- -2億 〜 2億(一般的なID)
BIGINT -- 大規模データ(TwitterのツイートIDなど)
SERIAL -- 自動採番(PostgreSQL)
文字列型:
VARCHAR(n) -- 可変長(最大n文字)
TEXT -- 無制限(長文コンテンツ)
CHAR(n) -- 固定長(国コードなど)
日時型:
DATE -- 日付のみ(誕生日など)
TIMESTAMP -- 日時(created_at、updated_at)
TIMESTAMPTZ -- タイムゾーン付き(グローバルサービスで必須)
制約(Constraint)の設定
CREATE TABLE users (
user_id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
age INT CHECK (age >= 0 AND age <= 150),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE orders (
order_id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'shipped', 'completed')),
total_amount INT CHECK (total_amount >= 0),
order_date TIMESTAMP DEFAULT NOW()
);
重要な制約:
PRIMARY KEY: 一意性 + NOT NULLUNIQUE: 重複禁止(emailなど)NOT NULL: NULL禁止CHECK: 値の範囲制限FOREIGN KEY: 外部キー制約(参照整合性)ON DELETE CASCADE: 親レコード削除時に子も削除
インデックス設計 — 検索速度を100倍にする
インデックスは本の索引と同じ。適切に設定すれば検索が劇的に高速化。
インデックスが必要な場所
- WHERE句で頻繁に使うカラム
- JOIN条件のカラム
- ORDER BY、GROUP BYで使うカラム
- 外部キー
基本的なインデックス作成
-- 単一カラムインデックス
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
-- 複合インデックス(複数カラム)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
CREATE INDEX idx_order_items_order_product ON order_items(order_id, product_id);
複合インデックスの順序
重要: カラムの順序が性能に直結。
-- ケース1: WHERE user_id = 1 AND status = 'paid'
-- → idx_orders_user_status (user_id, status) が最適
-- ケース2: WHERE status = 'paid' AND user_id = 1
-- → 同じインデックスで対応可能
-- ケース3: WHERE status = 'paid'
-- → user_id が先頭のインデックスは使われない!
-- → 別途 idx_orders_status が必要
原則: カーディナリティ(種類の多さ)が高いカラムを先頭に。
-- 悪い例(statusは種類が少ない)
CREATE INDEX idx_bad ON orders(status, user_id);
-- 良い例(user_idは種類が多い)
CREATE INDEX idx_good ON orders(user_id, status);
インデックスの確認(PostgreSQL)
-- テーブルのインデックス一覧
\d orders
-- 実行計画でインデックス使用を確認
EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 1 AND status = 'paid';
出力例:
Index Scan using idx_orders_user_status on orders (cost=0.29..8.31 rows=1)
Index Cond: ((user_id = 1) AND (status = 'paid'))
Index Scan なら成功。Seq Scan(全件スキャン)なら要改善。
インデックスの注意点
デメリット:
- 書き込み(INSERT/UPDATE/DELETE)が遅くなる
- ストレージ容量を消費
原則: 必要最低限のインデックスに留める。
不要なインデックス例:
-- 悪い例: 全カラムにインデックス
CREATE INDEX idx_users_name ON users(name); -- 検索しないなら不要
CREATE INDEX idx_users_created_at ON users(created_at); -- 範囲検索が稀なら不要
ステップ5: SQL最適化 — パフォーマンスチューニング
遅いクエリの典型例と改善策
問題1: N+1クエリ
-- 悪い例: 注文一覧取得後、ループでユーザー情報取得
SELECT * FROM orders; -- 100件
-- アプリ側でループ
for order in orders:
SELECT * FROM users WHERE user_id = order.user_id; -- 100回実行
合計101回のクエリ実行 → パフォーマンス最悪。
改善: JOIN で1回に集約:
SELECT
o.order_id,
o.total_amount,
o.order_date,
u.name AS user_name,
u.email
FROM orders o
INNER JOIN users u ON o.user_id = u.user_id
LIMIT 100;
問題2: SELECT *
-- 悪い例: 不要なカラムも取得
SELECT * FROM products WHERE category = 'Electronics';
-- 良い例: 必要なカラムのみ指定
SELECT product_id, name, price FROM products WHERE category = 'Electronics';
理由:
- ネットワーク転送量削減
- インデックスのみで処理完結する場合あり(Covering Index)
問題3: WHERE句での関数使用
-- 悪い例: インデックスが使われない
SELECT * FROM users WHERE LOWER(email) = 'test@example.com';
-- 良い例: 関数を使わずに検索
SELECT * FROM users WHERE email = 'test@example.com';
代替案: 関数ベースインデックス(PostgreSQL)
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
問題4: OR条件の乱用
-- 悪い例: インデックスが効きにくい
SELECT * FROM orders WHERE user_id = 1 OR status = 'paid';
-- 良い例: UNION で分割
SELECT * FROM orders WHERE user_id = 1
UNION
SELECT * FROM orders WHERE status = 'paid';
問題5: LIKE ‘%keyword%‘
-- 最悪: 前方一致でないLIKE
SELECT * FROM products WHERE name LIKE '%laptop%'; -- 全件スキャン
-- 改善: 前方一致なら高速
SELECT * FROM products WHERE name LIKE 'laptop%'; -- インデックス使用
-- 最善: 全文検索インデックス(PostgreSQL)
CREATE INDEX idx_products_name_gin ON products USING gin(to_tsvector('english', name));
SELECT * FROM products WHERE to_tsvector('english', name) @@ to_tsquery('laptop');
EXPLAINで実行計画を分析
EXPLAIN ANALYZE
SELECT
o.order_id,
u.name,
SUM(oi.subtotal) AS total
FROM orders o
JOIN users u ON o.user_id = u.user_id
JOIN order_items oi ON o.order_id = oi.order_id
WHERE o.order_date >= '2026-01-01'
GROUP BY o.order_id, u.name;
見るべきポイント:
Seq Scan→ インデックス不足costの値(低いほど良い)rowsの見積もり精度
実践例: ECサイトの完全なスキーマ
-- Usersテーブル
CREATE TABLE users (
user_id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
-- Productsテーブル
CREATE TABLE products (
product_id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
price INT NOT NULL CHECK (price >= 0),
stock INT NOT NULL DEFAULT 0 CHECK (stock >= 0),
category VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_products_category ON products(category);
CREATE INDEX idx_products_name ON products(name);
-- Ordersテーブル
CREATE TABLE orders (
order_id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
total_amount INT NOT NULL CHECK (total_amount >= 0),
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'shipped', 'completed', 'cancelled')),
order_date TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_date ON orders(order_date);
-- OrderItemsテーブル
CREATE TABLE order_items (
order_item_id SERIAL PRIMARY KEY,
order_id INT NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
product_id INT NOT NULL REFERENCES products(product_id),
quantity INT NOT NULL CHECK (quantity > 0),
unit_price INT NOT NULL, -- 注文時の価格(スナップショット)
subtotal INT NOT NULL,
UNIQUE(order_id, product_id)
);
CREATE INDEX idx_order_items_order ON order_items(order_id);
CREATE INDEX idx_order_items_product ON order_items(product_id);
-- トリガー: updated_at自動更新
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
非正規化の実践 — パフォーマンスとのトレードオフ
ケース: 注文回数の表示
要件: ユーザー一覧画面で各ユーザーの注文回数を表示。
正規化版(遅い):
SELECT
u.user_id,
u.name,
COUNT(o.order_id) AS order_count
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
GROUP BY u.user_id, u.name;
ユーザー数10万件、注文数100万件なら激遅。
非正規化版(高速):
-- usersテーブルにorder_countカラム追加
ALTER TABLE users ADD COLUMN order_count INT DEFAULT 0;
-- トリガーで自動更新
CREATE OR REPLACE FUNCTION update_user_order_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE users SET order_count = order_count + 1 WHERE user_id = NEW.user_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE users SET order_count = order_count - 1 WHERE user_id = OLD.user_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_order_count
AFTER INSERT OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION update_user_order_count();
-- 高速な検索
SELECT user_id, name, order_count FROM users;
トレードオフ:
- 読み取り: 爆速(JOIN不要)
- 書き込み: やや遅い(トリガー実行)
- データ整合性: トリガーで保証
データベース設計のアンチパターン
1. EAV(Entity-Attribute-Value)パターン
-- 悪い例
CREATE TABLE entity_attributes (
entity_id INT,
attribute_name VARCHAR(50),
attribute_value TEXT
);
-- 同じ商品の属性がバラバラに
-- product_id=1, name=price, value=1000
-- product_id=1, name=stock, value=50
問題: 型安全性なし、クエリが複雑、パフォーマンス最悪。
代替案: JSONBカラム(PostgreSQL)または適切な正規化。
2. ENUM の VARCHAR 保存
-- 悪い例
status VARCHAR(20) -- 'pending', 'paid', ... タイポの危険
-- 良い例
CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'completed');
status order_status;
3. 複数値をカンマ区切りで保存
-- 悪い例
tag_ids VARCHAR(100) -- '1,3,5,7'
-- 良い例: 中間テーブル
CREATE TABLE product_tags (
product_id INT REFERENCES products(product_id),
tag_id INT REFERENCES tags(tag_id),
PRIMARY KEY (product_id, tag_id)
);
まとめ: 良いDB設計の7原則
- 正規化は3NFまで — 冗長性を排除
- ビジネスロジック優先 — 必要なら非正規化も
- インデックスは戦略的に — WHERE/JOIN/ORDER BY を重点的に
- 制約で整合性保証 — CHECK, FOREIGN KEY, UNIQUE
- EXPLAIN で検証 — 推測でなく計測
- N+1クエリ撲滅 — JOIN で一括取得
- スケーラビリティを考慮 — 将来のデータ増加を想定
データベース設計は一度決めると変更が困難です。この記事の原則を守り、保守性とパフォーマンスを両立した設計を目指しましょう。
関連記事:
- AWS無料枠 完全ガイド2026 — RDS無料枠でDB運用
- Docker Compose 実践ガイド — PostgreSQL環境を5分で構築
- AIコーディングツール比較2026 — SQLクエリ生成にAI活用
便利ツール: DevToolBox でSQL整形・ER図作成が可能です。