1. はじめに
前回は、実践的なテーブル設計パターンを学びました。
多対多、履歴管理、論理削除など、現場で使える技術が身につきましたね!✨
いよいよ最終回です!🎉
今回のテーマはアンチパターン、つまり「やってはいけない設計」です!
「このテーブル設計、なんかおかしい...🤔」
「動くけど、なんか使いにくい...」
「データが増えたら、めちゃくちゃ遅くなった!😱」
こんな経験、ありませんか?
実は、データベース設計にはよくある失敗パターンがあります。
これを知っておけば、同じ失敗を避けられます!
この記事では、現場でよく見る「やりがちな失敗」と、
その正しい対策を学びます!💪
前回のおさらい📝
- 多対多リレーション - 中間テーブルで実装
- 履歴データ - 履歴テーブルで管理
- 論理削除 - deleted_atフラグ
- 階層構造 - 隣接リスト、パスエンコード
- ポリモーフィック関連 - type + idで複数参照
- 監査ログ - 変更履歴を記録
今回は、これらを踏まえて、やってはいけない設計を学びます!
2. アンチパターン1:EAV(Entity-Attribute-Value)🔀
2.1 問題:「何でも入るテーブル」を作ってしまう
シナリオ
商品によって、持つ属性が違う:
- スマホ:画面サイズ、RAM、ストレージ、カメラ画素数
- 本:著者、出版社、ISBN、ページ数
- 服:サイズ、色、素材
「全部1つのテーブルで管理したい!」💡
2.2 ❌ 悪い設計:EAVパターン
テーブル構造
-- 商品テーブル
CREATE TABLE products (
product_id INT PRIMARY KEY,
product_name VARCHAR(100)
);
-- 属性テーブル(EAV)
CREATE TABLE product_attributes (
attribute_id INT AUTO_INCREMENT PRIMARY KEY,
product_id INT,
attribute_name VARCHAR(50), -- '画面サイズ', '著者', 'サイズ' など
attribute_value TEXT, -- '6.1インチ', '村上春樹', 'M' など
FOREIGN KEY (product_id) REFERENCES products(product_id)
);
データの例
productsテーブル
| product_id | product_name |
|---|---|
| 1 | iPhone 15 |
| 2 | 1Q84 |
| 3 | Tシャツ |
product_attributesテーブル
| attribute_id | product_id | attribute_name | attribute_value |
|---|---|---|---|
| 1 | 1 | 画面サイズ | 6.1インチ |
| 2 | 1 | RAM | 6GB |
| 3 | 1 | ストレージ | 128GB |
| 4 | 2 | 著者 | 村上春樹 |
| 5 | 2 | 出版社 | 新潮社 |
| 6 | 3 | サイズ | M |
| 7 | 3 | 色 | 白 |
何が問題?🤔
問題1:クエリが複雑すぎる!😵
-- iPhone 15のRAMを取得したい
SELECT attribute_value
FROM product_attributes
WHERE product_id = 1 AND attribute_name = 'RAM';
-- 簡単そう?でも...
-- RAMが6GB以上の商品を検索したい
SELECT DISTINCT p.product_name
FROM products p
INNER JOIN product_attributes pa ON p.product_id = pa.product_id
WHERE pa.attribute_name = 'RAM'
AND CAST(SUBSTRING_INDEX(pa.attribute_value, 'GB', 1) AS UNSIGNED) >= 6;
-- 複雑すぎる!😱
問題2:データ型が使えない!💥
-- attribute_value は TEXT型
-- 数値として扱えない!
-- 日付として扱えない!
-- すべて文字列!
-- 価格で並び替え?
'10000' < '9000' -- 文字列比較だと、こうなる!💥
問題3:制約が設定できない!🚫
-- RAMは1GB、2GB、4GB、6GB、8GB だけ許可したい
-- → CHECK制約が使えない!
-- 画面サイズは数値 + 単位のはず
-- → データ型で強制できない!
-- 不正なデータが入り放題!😱
問題4:インデックスが効かない!🐢
attribute_nameとattribute_valueの組み合わせで検索
→ 複合インデックスが作りにくい
→ 遅い!
問題5:NULLの扱いが難しい!🤔
-- 「画面サイズ」という属性がない = 「行」がない
-- NULL ではなく、行自体が存在しない!
-- 「画面サイズ属性を持たない商品」を検索するには?
SELECT p.product_id
FROM products p
LEFT JOIN product_attributes pa
ON p.product_id = pa.product_id
AND pa.attribute_name = '画面サイズ'
WHERE pa.attribute_id IS NULL;
-- 複雑!😵
2.3 ✅ 正しい設計:テーブルを分ける
方法1:商品種別ごとにテーブルを分ける
-- 基本情報(共通)
CREATE TABLE products (
product_id INT AUTO_INCREMENT PRIMARY KEY,
product_name VARCHAR(100) NOT NULL,
price DECIMAL(10,2) NOT NULL,
product_type VARCHAR(20) NOT NULL, -- 'smartphone', 'book', 'clothing'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- スマホ専用テーブル
CREATE TABLE smartphones (
smartphone_id INT PRIMARY KEY,
product_id INT UNIQUE NOT NULL,
screen_size DECIMAL(3,1) NOT NULL, -- 数値型!✅
ram_gb INT NOT NULL,
storage_gb INT NOT NULL,
camera_mp INT,
FOREIGN KEY (product_id) REFERENCES products(product_id),
CHECK (ram_gb IN (1, 2, 4, 6, 8, 16)) -- 制約が使える!✅
);
-- 本専用テーブル
CREATE TABLE books (
book_id INT PRIMARY KEY,
product_id INT UNIQUE NOT NULL,
author VARCHAR(100) NOT NULL,
publisher VARCHAR(100),
isbn VARCHAR(13) UNIQUE,
pages INT,
published_date DATE,
FOREIGN KEY (product_id) REFERENCES products(product_id)
);
-- 服専用テーブル
CREATE TABLE clothing (
clothing_id INT PRIMARY KEY,
product_id INT UNIQUE NOT NULL,
size ENUM('XS', 'S', 'M', 'L', 'XL', 'XXL') NOT NULL, -- ENUM!✅
color VARCHAR(50) NOT NULL,
material VARCHAR(100),
FOREIGN KEY (product_id) REFERENCES products(product_id)
);
クエリが簡単に!✨
-- RAMが6GB以上のスマホを検索
SELECT p.product_name, s.ram_gb
FROM products p
INNER JOIN smartphones s ON p.product_id = s.product_id
WHERE s.ram_gb >= 6;
-- シンプル!速い!🚀
-- 価格で並び替え
SELECT product_name, price
FROM products
ORDER BY price DESC;
-- 数値として正しくソートされる!✅
方法2:共通属性だけテーブルに、個別属性はJSONで
-- MySQL 5.7+、PostgreSQL 9.2+
CREATE TABLE products (
product_id INT AUTO_INCREMENT PRIMARY KEY,
product_name VARCHAR(100) NOT NULL,
price DECIMAL(10,2) NOT NULL,
product_type VARCHAR(20) NOT NULL,
specifications JSON, -- 個別属性をJSONで!
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- スマホの登録
INSERT INTO products (product_name, price, product_type, specifications)
VALUES (
'iPhone 15',
120000,
'smartphone',
JSON_OBJECT(
'screen_size', '6.1',
'ram_gb', 6,
'storage_gb', 128,
'camera_mp', 48
)
);
-- 検索
SELECT product_name
FROM products
WHERE product_type = 'smartphone'
AND JSON_EXTRACT(specifications, '$.ram_gb') >= 6;
メリット:
- 柔軟性が高い
- テーブルが増えない
デメリット:
- JSON内は型チェックが甘い
- インデックスが効きにくい
2.4 💡 根本的な解決:適切なデータベースを選ぶ
リレーショナルDBでJSONを使うのは「妥協策」
もし商品ごとに属性が大きく異なるなら...
そもそもリレーショナルDBが適していない可能性! 🤔
NoSQL(ドキュメント指向DB)の検討を!
// MongoDB
{
product_name: "iPhone 15",
product_type: "smartphone",
specs: {
screen_size: "6.1",
ram_gb: 6,
storage_gb: 128
}
}
これが自然な設計! ✨
ハイブリッドアプローチ(実務的)
リレーショナルDB:
- 注文、顧客、在庫(トランザクション重要)
NoSQL:
- 商品カタログ(柔軟性重要)
適材適所で使い分ける! ⚖️
2.5 まとめ:EAVアンチパターン
| 項目 | EAV(悪い) | テーブル分割(良い) |
|---|---|---|
| クエリ | 複雑😵 | シンプル✅ |
| データ型 | TEXT固定💥 | 適切な型✅ |
| 制約 | 使えない🚫 | 使える✅ |
| パフォーマンス | 遅い🐢 | 速い🚀 |
| 保守性 | 低い😰 | 高い✅ |
EAVは最後の手段!基本はテーブルを分けよう! 💡
2.6 おまけ:データベース選択のフローチャート📊
商品の属性は統一されている?
YES → リレーショナルDB
NO ↓
トランザクションが必要?
YES → リレーショナルDB(JSONを限定的に使用)
NO ↓
柔軟性が最重要?
YES → NoSQL(MongoDB等)
または
→ ハイブリッド(両方使う)
3. アンチパターン2:ジェイウォーク(Jaywalking)🚶
3.1 問題:カンマ区切りのIDを保存してしまう
シナリオ
記事に複数のタグを付けたい:
記事1:「データベース, SQL, 初心者」
記事2:「プログラミング, Python」
3.2 ❌ 悪い設計:カンマ区切りで保存
CREATE TABLE posts (
post_id INT PRIMARY KEY,
title VARCHAR(200),
content TEXT,
tag_ids VARCHAR(100) -- '1,2,3' のように保存💥
);
データの例
| post_id | title | tag_ids |
|---|---|---|
| 1 | データベース入門 | 1,2,3 |
| 2 | Python入門 | 4,5 |
何が問題?🤔
問題1:検索できない!🔍❌
-- タグID=2を含む記事を検索したい
SELECT * FROM posts WHERE tag_ids = '2';
-- 見つからない!😱
-- LIKE を使う?
SELECT * FROM posts WHERE tag_ids LIKE '%2%';
-- '2' だけでなく '12', '20' も引っかかる!💥
-- 正しく検索するには...
SELECT * FROM posts
WHERE CONCAT(',', tag_ids, ',') LIKE '%,2,%';
-- 複雑!インデックスも効かない!🐢
問題2:結合できない!🔗❌
-- タグ名を取得したい
CREATE TABLE tags (
tag_id INT PRIMARY KEY,
tag_name VARCHAR(50)
);
-- JOINできない!💥
SELECT p.title, t.tag_name
FROM posts p
INNER JOIN tags t ON ... -- ここが書けない!😱
問題3:集計できない!📊❌
-- 各タグが使われている記事数を集計したい
-- → できない!😱
問題4:データの整合性が保てない!💥
-- tag_ids = '1,2,999'
-- tag_id=999 は存在しない!
-- でも、外部キー制約が使えないので、
-- 不正なデータが入ってしまう!😱
問題5:第1正規形に違反!📋❌
「1つのセルに1つの値」という原則に違反!
3.3 ✅ 正しい設計:中間テーブルを作る
-- 記事テーブル
CREATE TABLE posts (
post_id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- タグテーブル
CREATE TABLE tags (
tag_id INT AUTO_INCREMENT PRIMARY KEY,
tag_name VARCHAR(50) NOT NULL UNIQUE
);
-- 中間テーブル(多対多)
CREATE TABLE post_tags (
post_id INT,
tag_id INT,
PRIMARY KEY (post_id, tag_id),
FOREIGN KEY (post_id) REFERENCES posts(post_id),
FOREIGN KEY (tag_id) REFERENCES tags(tag_id)
);
データの例
postsテーブル
| post_id | title |
|---|---|
| 1 | データベース入門 |
| 2 | Python入門 |
tagsテーブル
| tag_id | tag_name |
|---|---|
| 1 | データベース |
| 2 | SQL |
| 3 | 初心者 |
| 4 | プログラミング |
| 5 | Python |
post_tagsテーブル
| post_id | tag_id |
|---|---|
| 1 | 1 |
| 1 | 2 |
| 1 | 3 |
| 2 | 4 |
| 2 | 5 |
クエリが簡単に!✨
-- 記事1のタグ一覧を取得
SELECT t.tag_name
FROM post_tags pt
INNER JOIN tags t ON pt.tag_id = t.tag_id
WHERE pt.post_id = 1;
| tag_name |
|---|
| データベース |
| SQL |
| 初心者 |
-- タグ「データベース」が付いた記事を検索
SELECT p.title
FROM posts p
INNER JOIN post_tags pt ON p.post_id = pt.post_id
INNER JOIN tags t ON pt.tag_id = t.tag_id
WHERE t.tag_name = 'データベース';
| title |
|---|
| データベース入門 |
-- 各タグの使用回数を集計
SELECT t.tag_name, COUNT(pt.post_id) AS post_count
FROM tags t
LEFT JOIN post_tags pt ON t.tag_id = pt.tag_id
GROUP BY t.tag_id, t.tag_name
ORDER BY post_count DESC;
| tag_name | post_count |
|---|---|
| データベース | 1 |
| SQL | 1 |
| 初心者 | 1 |
| プログラミング | 1 |
| Python | 1 |
全部できる! ✨
3.4 まとめ:ジェイウォークアンチパターン
| 項目 | カンマ区切り(悪い) | 中間テーブル(良い) |
|---|---|---|
| 検索 | 困難😵 | 簡単✅ |
| 結合 | できない💥 | できる✅ |
| 集計 | できない💥 | できる✅ |
| 整合性 | 保てない😱 | 保てる✅ |
| 正規化 | 違反📋❌ | 準拠✅ |
カンマ区切りは絶対ダメ!中間テーブルを使おう! 💡
4. アンチパターン3:ナイーブツリー(Naive Tree)🌳
4.1 問題:再帰クエリを何度も実行してしまう
シナリオ
コメントの返信機能:
コメント1
├── 返信1-1
│ └── 返信1-1-1
└── 返信1-2
└── 返信1-2-1
└── 返信1-2-1-1
深い階層のコメントを表示したい!
4.2 ❌ 悪いパターン:アプリケーション側で再帰
テーブル構造
CREATE TABLE comments (
comment_id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
parent_id INT NULL, -- 親コメントのID
user_id INT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES comments(comment_id)
);
データの例
| comment_id | post_id | parent_id | content |
|---|---|---|---|
| 1 | 1 | NULL | コメント1 |
| 2 | 1 | 1 | 返信1-1 |
| 3 | 1 | 2 | 返信1-1-1 |
| 4 | 1 | 1 | 返信1-2 |
| 5 | 1 | 4 | 返信1-2-1 |
| 6 | 1 | 5 | 返信1-2-1-1 |
アプリケーション側で再帰(悪い例)
# 擬似コード
def get_comment_tree(comment_id):
# 1回目のクエリ
comment = db.query("SELECT * FROM comments WHERE comment_id = ?", comment_id)
# 2回目のクエリ(子コメントを取得)
children = db.query("SELECT * FROM comments WHERE parent_id = ?", comment_id)
# 子コメントごとに再帰(さらにクエリが発生!)
for child in children:
child['children'] = get_comment_tree(child['comment_id']) # N+1問題!💥
comment['children'] = children
return comment
# コメント1のツリーを取得
tree = get_comment_tree(1)
# クエリが何回実行された?
# 1回目:コメント1を取得
# 2回目:コメント1の子を取得(2件)
# 3回目:返信1-1の子を取得(1件)
# 4回目:返信1-1-1の子を取得(0件)
# 5回目:返信1-2の子を取得(1件)
# 6回目:返信1-2-1の子を取得(1件)
# 7回目:返信1-2-1-1の子を取得(0件)
# 合計7回!😱
# コメントが100個あったら、100回以上のクエリ!💥
何が問題?🤔
N+1問題💥
1回目:親を取得(1回)
2回目以降:子を1つずつ取得(N回)
合計:1 + N 回のクエリ!
データが増えると、指数関数的に遅くなる!🐢💨
4.3 ✅ 正しい設計:再帰CTEで一発取得
MySQL 8.0+、PostgreSQL
-- 記事1のコメントツリーを一発で取得!✨
WITH RECURSIVE comment_tree AS (
-- ルートコメント(parent_id = NULL)
SELECT
comment_id,
parent_id,
content,
0 AS level,
CAST(comment_id AS CHAR(200)) AS path
FROM comments
WHERE post_id = 1 AND parent_id IS NULL
UNION ALL
-- 再帰:子コメントを取得
SELECT
c.comment_id,
c.parent_id,
c.content,
ct.level + 1,
CONCAT(ct.path, '/', c.comment_id)
FROM comments c
INNER JOIN comment_tree ct ON c.parent_id = ct.comment_id
)
SELECT * FROM comment_tree ORDER BY path;
1回のクエリで全部取れる! 🚀
結果
| comment_id | parent_id | content | level | path |
|---|---|---|---|---|
| 1 | NULL | コメント1 | 0 | 1 |
| 2 | 1 | 返信1-1 | 1 | 1/2 |
| 3 | 2 | 返信1-1-1 | 2 | 1/2/3 |
| 4 | 1 | 返信1-2 | 1 | 1/4 |
| 5 | 4 | 返信1-2-1 | 2 | 1/4/5 |
| 6 | 5 | 返信1-2-1-1 | 3 | 1/4/5/6 |
または:パスエンコード方式
CREATE TABLE comments (
comment_id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
path VARCHAR(255) NOT NULL, -- '/1/2/3/' のように保存
level INT NOT NULL,
content TEXT NOT NULL,
INDEX idx_path (path)
);
-- 記事1のコメントツリーを取得
SELECT *
FROM comments
WHERE post_id = 1
ORDER BY path;
-- 1回のクエリで完了!✨
4.4 まとめ:ナイーブツリーアンチパターン
| 項目 | アプリ側再帰(悪い) | 再帰CTE/パス(良い) |
|---|---|---|
| クエリ回数 | N+1回💥 | 1回✅ |
| パフォーマンス | 遅い🐢 | 速い🚀 |
| コード | 複雑😵 | シンプル✅ |
階層データは、再帰CTEかパスエンコードで! 💡
5. アンチパターン4:ID Required(IDが必要)🆔
5.1 問題:意味のないIDを全テーブルに付けてしまう
シナリオ
中間テーブルにも、自動採番のIDを付けてしまう!
5.2 ❌ 悪い設計:不要なIDを付ける
-- 生徒と授業の中間テーブル
CREATE TABLE enrollments (
enrollment_id INT AUTO_INCREMENT PRIMARY KEY, -- これ必要?🤔
student_id INT NOT NULL,
course_id VARCHAR(20) NOT NULL,
enrolled_date DATE,
FOREIGN KEY (student_id) REFERENCES students(student_id),
FOREIGN KEY (course_id) REFERENCES courses(course_id)
);
何が問題?🤔
問題1:重複登録を防げない!💥
-- 田中くんが数学に2回登録できてしまう!
INSERT INTO enrollments (student_id, course_id) VALUES (1, 'math');
INSERT INTO enrollments (student_id, course_id) VALUES (1, 'math');
-- enrollment_id が違うから、エラーにならない!😱
| enrollment_id | student_id | course_id |
|---|---|---|
| 1 | 1 | math |
| 2 | 1 | math |
重複!💥
問題2:無駄なストレージ💾
-- IDがなければ:
(student_id, course_id) = 主キー
→ 8バイト
-- IDがあると:
enrollment_id + (student_id, course_id)
→ 12バイト
100万件あったら、4MB無駄!
5.3 ✅ 正しい設計:複合主キーを使う
CREATE TABLE enrollments (
student_id INT NOT NULL,
course_id VARCHAR(20) NOT NULL,
enrolled_date DATE,
grade VARCHAR(2),
PRIMARY KEY (student_id, course_id), -- 複合主キー!✅
FOREIGN KEY (student_id) REFERENCES students(student_id),
FOREIGN KEY (course_id) REFERENCES courses(course_id)
);
メリット
-- 重複登録を防げる!✅
INSERT INTO enrollments (student_id, course_id) VALUES (1, 'math');
-- OK
INSERT INTO enrollments (student_id, course_id) VALUES (1, 'math');
-- エラー!PRIMARY KEY violation!✅
-- ストレージも節約!💾
クエリも簡単
-- 田中くんの数学の成績を更新
UPDATE enrollments
SET grade = 'A'
WHERE student_id = 1 AND course_id = 'math';
-- IDがあっても、結局student_idとcourse_idで検索する!
-- IDは不要!
5.4 ❓ いつIDが必要?
IDが必要な場合✅
-- 注文明細:同じ商品を複数個注文できる
CREATE TABLE order_items (
order_item_id INT AUTO_INCREMENT PRIMARY KEY, -- 必要!✅
order_id INT NOT NULL,
product_id INT NOT NULL,
quantity INT NOT NULL,
-- (order_id, product_id) では一意にならない!
-- 同じ注文で同じ商品を2行に分けることがある
);
IDが不要な場合❌
-- 生徒と授業:一度登録したら、同じ組み合わせはない
CREATE TABLE enrollments (
student_id INT NOT NULL,
course_id VARCHAR(20) NOT NULL,
PRIMARY KEY (student_id, course_id) -- これで十分!✅
);
-- ユーザーといいね:同じユーザーが同じ投稿にいいねは1回だけ
CREATE TABLE likes (
user_id INT NOT NULL,
post_id INT NOT NULL,
PRIMARY KEY (user_id, post_id) -- これで十分!✅
);
5.5 まとめ:ID Requiredアンチパターン
| 項目 | 不要なID(悪い) | 複合主キー(良い) |
|---|---|---|
| 重複防止 | できない💥 | できる✅ |
| ストレージ | 無駄💾 | 節約✅ |
| パフォーマンス | 遅い🐢 | 速い🚀 |
意味のないIDは付けない!複合主キーで十分! 💡
6. アンチパターン5:メタデータトリブル(Metadata Tribbles)📊
6.1 問題:テーブルやカラムを動的に増やしてしまう
シナリオ
年度ごとにテーブルを分けてしまう:
sales_2024
sales_2025
sales_2026
...
月ごとにカラムを分けてしまう:
| product_id | jan | feb | mar | apr | ... |
6.2 ❌ 悪い設計1:年度ごとにテーブル
CREATE TABLE sales_2024 (
sale_id INT PRIMARY KEY,
product_id INT,
amount DECIMAL(10,2),
sale_date DATE
);
CREATE TABLE sales_2025 (
sale_id INT PRIMARY KEY,
product_id INT,
amount DECIMAL(10,2),
sale_date DATE
);
CREATE TABLE sales_2026 (
sale_id INT PRIMARY KEY,
product_id INT,
amount DECIMAL(10,2),
sale_date DATE
);
-- 毎年テーブルが増える!😱
何が問題?🤔
問題1:クエリが複雑!😵
-- 2024年と2025年の合計売上を取得したい
SELECT SUM(amount)
FROM sales_2024
UNION ALL
SELECT SUM(amount)
FROM sales_2025;
-- または
SELECT
(SELECT SUM(amount) FROM sales_2024) +
(SELECT SUM(amount) FROM sales_2025) AS total;
-- テーブルが増えるたびに、クエリを修正!💥
問題2:保守が大変!🔧
-- 新年になったら、テーブルを作成
CREATE TABLE sales_2027 (...);
-- アプリケーションのコードも修正!
-- 毎年作業が発生!😱
問題3:複数年にまたがる分析ができない!📊
-- 過去3年間の売上推移を見たい
-- → 3つのテーブルをUNIONして...
-- → 複雑すぎる!😵
6.3 ✅ 正しい設計:1つのテーブルにまとめる
CREATE TABLE sales (
sale_id INT AUTO_INCREMENT PRIMARY KEY,
product_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
sale_date DATE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_sale_date (sale_date),
INDEX idx_product_date (product_id, sale_date)
);
クエリが簡単に!✨
-- 2024年と2025年の合計売上
SELECT SUM(amount)
FROM sales
WHERE sale_date BETWEEN '2024-01-01' AND '2025-12-31';
-- シンプル!✅
-- 過去3年間の年別売上
SELECT
YEAR(sale_date) AS year,
SUM(amount) AS total_amount
FROM sales
WHERE sale_date >= DATE_SUB(CURDATE(), INTERVAL 3 YEAR)
GROUP BY YEAR(sale_date);
-- 簡単!✨
パフォーマンスは?🤔
「1つのテーブルにすると、データが多すぎて遅くなるのでは?」
→ インデックスがあれば大丈夫!✅
→ パーティショニングも使える!
パーティショニング(高度な技術)
-- 年度ごとにパーティションを分ける
CREATE TABLE sales (
sale_id INT AUTO_INCREMENT,
product_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
sale_date DATE NOT NULL,
PRIMARY KEY (sale_id, sale_date)
)
PARTITION BY RANGE (YEAR(sale_date)) (
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026),
PARTITION p2026 VALUES LESS THAN (2027),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
-- 内部的にはテーブルが分かれているが、
-- 使う側は1つのテーブルとして扱える!✨
6.4 ❌ 悪い設計2:月ごとにカラム
CREATE TABLE monthly_sales (
product_id INT PRIMARY KEY,
product_name VARCHAR(100),
jan DECIMAL(10,2),
feb DECIMAL(10,2),
mar DECIMAL(10,2),
apr DECIMAL(10,2),
may DECIMAL(10,2),
jun DECIMAL(10,2),
jul DECIMAL(10,2),
aug DECIMAL(10,2),
sep DECIMAL(10,2),
oct DECIMAL(10,2),
nov DECIMAL(10,2),
dec DECIMAL(10,2)
);
何が問題?🤔
-- 合計売上を計算したい
SELECT
product_id,
(jan + feb + mar + apr + may + jun +
jul + aug + sep + oct + nov + dec) AS total
FROM monthly_sales;
-- 長い!😱
-- 月別の売上ランキングを作りたい
-- → できない!💥
-- カラムが横に並んでいるため、集計できない!
6.5 ✅ 正しい設計:行で表現する
CREATE TABLE monthly_sales (
product_id INT NOT NULL,
year INT NOT NULL,
month INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
PRIMARY KEY (product_id, year, month),
CHECK (month BETWEEN 1 AND 12)
);
データの例
| product_id | year | month | amount |
|---|---|---|---|
| 1 | 2026 | 1 | 10000 |
| 1 | 2026 | 2 | 12000 |
| 1 | 2026 | 3 | 15000 |
| 2 | 2026 | 1 | 8000 |
| 2 | 2026 | 2 | 9000 |
クエリが簡単に!✨
-- 商品1の合計売上
SELECT SUM(amount) AS total
FROM monthly_sales
WHERE product_id = 1 AND year = 2026;
-- 月別の売上ランキング
SELECT month, SUM(amount) AS total
FROM monthly_sales
WHERE year = 2026
GROUP BY month
ORDER BY total DESC;
-- 簡単!✨
6.6 まとめ:メタデータトリブルアンチパターン
| 項目 | テーブル/カラム増殖(悪い) | 1つのテーブル(良い) |
|---|---|---|
| クエリ | 複雑😵 | シンプル✅ |
| 保守 | 大変🔧 | 楽✅ |
| 集計 | 困難📊 | 簡単✅ |
| 拡張性 | 低い😰 | 高い✅ |
テーブルやカラムを動的に増やさない! 💡
7. アンチパターン6:ラウンディングエラー(Rounding Errors)💰
7.1 問題:金額をFLOATやDOUBLEで保存してしまう
シナリオ
商品の価格を管理したい!
7.2 ❌ 悪い設計:FLOAT/DOUBLEを使う
CREATE TABLE products (
product_id INT PRIMARY KEY,
product_name VARCHAR(100),
price FLOAT -- これがダメ!💥
);
何が問題?🤔
-- 商品を登録
INSERT INTO products (product_id, product_name, price)
VALUES (1, '商品A', 1234.56);
-- 取得
SELECT price FROM products WHERE product_id = 1;
-- 結果:1234.5599975585938 😱
-- 微妙に違う!💥
計算するとさらに...
-- 10個買ったら?
SELECT price * 10 FROM products WHERE product_id = 1;
-- 期待:12345.60
-- 実際:12345.599975585938 😱
-- 100個買ったら?
SELECT price * 100 FROM products WHERE product_id = 1;
-- 期待:123456.00
-- 実際:123455.99975585938 😱
-- 1円足りない!💸
なぜこうなる?🤔
FLOAT/DOUBLEは「浮動小数点数」
→ 2進数で表現される
→ 0.1 のような10進数の小数を正確に表現できない!
0.1 + 0.2 = 0.30000000000000004
これが「丸め誤差」!
7.3 ✅ 正しい設計:DECIMALを使う
CREATE TABLE products (
product_id INT PRIMARY KEY,
product_name VARCHAR(100),
price DECIMAL(10, 2) -- 10桁、小数点以下2桁✅
);
正確に計算できる!✨
-- 商品を登録
INSERT INTO products (product_id, product_name, price)
VALUES (1, '商品A', 1234.56);
-- 取得
SELECT price FROM products WHERE product_id = 1;
-- 結果:1234.56 ✅
-- 計算
SELECT price * 10 FROM products WHERE product_id = 1;
-- 結果:12345.60 ✅
SELECT price * 100 FROM products WHERE product_id = 1;
-- 結果:123456.00 ✅
-- 完璧!✨
DECIMAL(M, D)の指定方法
DECIMAL(10, 2)
↑ ↑
M D
M:全体の桁数(整数部 + 小数部)
D:小数点以下の桁数
例:
DECIMAL(10, 2) → 12345678.90 まで
DECIMAL(15, 4) → 12345678901.2345 まで
用途別の推奨
-- 商品価格(円)
price DECIMAL(10, 2) -- 99,999,999.99円まで
-- 為替レート
exchange_rate DECIMAL(10, 4) -- 1.2345 のように小数点以下4桁
-- 重量(kg)
weight DECIMAL(8, 3) -- 12345.678kg まで
-- 割引率(%)
discount_rate DECIMAL(5, 2) -- 100.00% まで
7.4 まとめ:ラウンディングエラーアンチパターン
| 項目 | FLOAT/DOUBLE(悪い) | DECIMAL(良い) |
|---|---|---|
| 精度 | 不正確💥 | 正確✅ |
| 金額計算 | 誤差あり😱 | 誤差なし✅ |
| 使いどころ | 科学計算🔬 | 金額、数量💰 |
金額は必ずDECIMAL!FLOAT/DOUBLEは使わない! 💡
8. アンチパターン7:プアマンズ検索エンジン(Poor Man's Search Engine)🔍
8.1 問題:LIKEで全文検索をしてしまう
シナリオ
記事のタイトルと本文から、キーワードを検索したい!
8.2 ❌ 悪い設計:LIKEで検索
-- 記事を検索
SELECT * FROM posts
WHERE title LIKE '%データベース%'
OR content LIKE '%データベース%';
何が問題?🤔
問題1:遅い!🐢
-- LIKEは部分一致検索
-- インデックスが効かない!(前方一致以外)
-- 100万件のデータで検索すると...
-- 数秒〜数十秒かかる!😱
問題2:複数キーワードの検索が大変!😵
-- 「データベース」と「設計」の両方を含む記事を検索
SELECT * FROM posts
WHERE (title LIKE '%データベース%' OR content LIKE '%データベース%')
AND (title LIKE '%設計%' OR content LIKE '%設計%');
-- 複雑!😱
-- 「データベース」「設計」「初心者」の3つを含む記事は?
-- → さらに複雑!💥
問題3:関連度順に並べられない!📊
「データベース」というキーワードが
10回出てくる記事と、1回だけの記事
→ 同じ扱い!
関連度が高い順に並べたい!
→ LIKEではできない!💥
問題4:類義語・表記ゆれに対応できない!🔤
-- 「データベース」で検索
-- → 「DB」「database」は引っかからない!
-- 「コンピュータ」で検索
-- → 「コンピューター」は引っかからない!
-- 全部ORで書く?
WHERE content LIKE '%データベース%'
OR content LIKE '%DB%'
OR content LIKE '%database%'
-- 大変!😱
8.3 ✅ 正しい設計:全文検索エンジンを使う
方法1:MySQLの全文検索(FULLTEXT)
CREATE TABLE posts (
post_id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FULLTEXT INDEX ft_title_content (title, content) -- 全文検索インデックス!✨
) ENGINE=InnoDB;
-- 全文検索
SELECT * FROM posts
WHERE MATCH(title, content) AGAINST('データベース 設計' IN NATURAL LANGUAGE MODE);
-- 速い!🚀
-- 関連度順に自動で並ぶ!✨
関連度スコアも取得できる!
SELECT
post_id,
title,
MATCH(title, content) AGAINST('データベース 設計') AS relevance_score
FROM posts
WHERE MATCH(title, content) AGAINST('データベース 設計')
ORDER BY relevance_score DESC;
| post_id | title | relevance_score |
|---|---|---|
| 1 | データベース設計入門 | 2.5 |
| 2 | データベースの基礎 | 1.8 |
| 3 | SQL設計のコツ | 0.9 |
方法2:PostgreSQLの全文検索
-- 全文検索用のカラムを追加
ALTER TABLE posts ADD COLUMN search_vector tsvector;
-- インデックスを作成
CREATE INDEX idx_search_vector ON posts USING GIN(search_vector);
-- 検索ベクトルを更新(トリガーで自動化推奨)
UPDATE posts
SET search_vector = to_tsvector('japanese', title || ' ' || content);
-- 全文検索
SELECT * FROM posts
WHERE search_vector @@ to_tsquery('japanese', 'データベース & 設計');
-- 速い!🚀
方法3:Elasticsearch(最強)
大規模サービス向け:
- Elasticsearch
- Apache Solr
- Amazon CloudSearch
専用の検索エンジン!
- 超高速!⚡⚡
- 類義語対応!
- 表記ゆれ対応!
- あいまい検索!
- ハイライト表示!
別のサーバーで動かす必要がある
8.4 まとめ:プアマンズ検索エンジンアンチパターン
| 項目 | LIKE検索(悪い) | 全文検索(良い) |
|---|---|---|
| 速度 | 遅い🐢 | 速い🚀 |
| 複数キーワード | 大変😵 | 簡単✅ |
| 関連度 | わからない💥 | わかる✅ |
| 類義語 | 対応できない😱 | 対応できる✅ |
LIKEは簡単な検索だけ!本格的な検索は全文検索エンジンを! 💡
9. ボーナス:その他のアンチパターン⚠️
9.1 マジックナンバー(Magic Numbers)🎩
❌ 悪い例
-- ステータスを数値で管理
CREATE TABLE orders (
order_id INT PRIMARY KEY,
status INT -- 1:pending, 2:confirmed, 3:shipped, 4:delivered...
);
-- クエリ
SELECT * FROM orders WHERE status = 2;
-- 2って何?🤔
✅ 良い例
-- ENUMまたはVARCHARで管理
CREATE TABLE orders (
order_id INT PRIMARY KEY,
status ENUM('pending', 'confirmed', 'shipped', 'delivered', 'cancelled')
);
-- クエリ
SELECT * FROM orders WHERE status = 'confirmed';
-- わかりやすい!✅
9.2 ファイルのBLOB保存💾
❌ 悪い例
-- 画像をBLOBで保存
CREATE TABLE photos (
photo_id INT PRIMARY KEY,
photo_data BLOB -- 画像データそのものを保存!💥
);
-- 1枚10MBの画像が1万枚
-- → データベースが100GB!😱
✅ 良い例
-- ファイルパスだけ保存
CREATE TABLE photos (
photo_id INT PRIMARY KEY,
file_path VARCHAR(255) -- '/images/photo1.jpg'
);
-- 実際のファイルは、ストレージサービスに保存
-- Amazon S3, Google Cloud Storage など
9.3 NULLの乱用🚫
❌ 悪い例
-- すべてのカラムがNULL許可
CREATE TABLE users (
user_id INT PRIMARY KEY,
username VARCHAR(50), -- NULL許可!
email VARCHAR(255), -- NULL許可!
age INT -- NULL許可!
);
-- データの整合性が保てない!💥
✅ 良い例
-- 必須項目にはNOT NULL
CREATE TABLE users (
user_id INT PRIMARY KEY,
username VARCHAR(50) NOT NULL, -- 必須!✅
email VARCHAR(255) NOT NULL, -- 必須!✅
age INT, -- 任意(NULL OK)
bio TEXT -- 任意(NULL OK)
);
10. まとめ🎓
お疲れ様でした!🎉
データベース設計基礎シリーズ、全7回が完結しました!
10.1 今回学んだアンチパターン📚
1. EAV(Entity-Attribute-Value)🔀
- 「何でも入るテーブル」は作らない!
- テーブルを分けるか、JSONを使う
2. ジェイウォーク(Jaywalking)🚶
- カンマ区切りのIDは絶対ダメ!
- 中間テーブルを使う
3. ナイーブツリー(Naive Tree)🌳
- アプリ側で再帰しない!
- 再帰CTEかパスエンコード
4. ID Required🆔
- 意味のないIDは不要!
- 複合主キーで十分な場合も
5. メタデータトリブル📊
- テーブル/カラムを動的に増やさない!
- 1つのテーブルにまとめる
6. ラウンディングエラー💰
- 金額はFLOAT/DOUBLEを使わない!
- 必ずDECIMALを使う
7. プアマンズ検索エンジン🔍
- LIKEで全文検索しない!
- 全文検索エンジンを使う
8. ボーナス
- マジックナンバー - 意味のある値を使う
- BLOB保存 - ファイルはストレージに
- NULL乱用 - 必須項目にはNOT NULL
10.2 アンチパターン回避のコツ💡
1. 設計前に要件を整理する
2. 正規化を意識する
3. 将来の拡張を考える(でも過度にしない)
4. パフォーマンスを測定する
5. チームでレビューする
10.3 設計のチェックリスト✅
設計時に確認すること:
- 第3正規形を満たしているか?
- 適切なデータ型を使っているか?
- 主キーが設定されているか?
- 外部キー制約が設定されているか?
- インデックスは適切か?
- NOT NULL制約は適切か?
- カンマ区切りの値はないか?
- 金額にFLOATを使っていないか?
- 繰り返し項目はないか?
- 無駄なIDを付けていないか?
10.4 シリーズ全体の振り返り📖
第1回:データベース設計の基本概念
- エンティティ、属性、主キー
- ER図の書き方
第2回:SQLの基本をマスターしよう
- CREATE、INSERT、SELECT、UPDATE、DELETE
- WHERE、JOIN、GROUP BY
第3回:正規化入門
- 第1正規形、第2正規形、第3正規形
- データの重複を減らす
第4回:インデックスの仕組みと使い方
- B-Tree構造
- 単一カラム、複合インデックス
- EXPLAINで確認
第5回:トランザクションとACID特性
- BEGIN、COMMIT、ROLLBACK
- 原子性、一貫性、独立性、永続性
- 分離レベル、ロック
第6回:実践的なテーブル設計パターン集
- 多対多、履歴管理、論理削除
- 階層構造、ポリモーフィック関連
- 監査ログ
第7回:よくあるアンチパターンと対策
- やってはいけない設計
- 失敗から学ぶ
10.5 次のステップ🚀
このシリーズで基礎は身につきました!
次は、実際にプロジェクトで使ってみましょう!💪
おすすめの学習
-
実際にデータベースを設計してみる
- 個人プロジェクトで実践
- ポートフォリオを作る
-
パフォーマンスチューニングを学ぶ
- クエリの最適化
- インデックスの調整
-
高度なトピックに挑戦
- レプリケーション
- シャーディング
- バックアップとリカバリ
-
他のデータベースも学ぶ
- NoSQL(MongoDB、Redis)
- NewSQL(CockroachDB、TiDB)
10.6 最後に📝
データベース設計は、経験が何より重要です!
- 失敗を恐れずに、色々試してみる
- 他の人の設計を見て学ぶ
- レビューをもらう
- 本番運用で問題が起きたら、改善する
完璧な設計はありません!
常に改善し続けることが大切です!✨
このシリーズが、あなたのデータベース設計の
第一歩になれば嬉しいです!🎉
データベース設計基礎シリーズ(完結)
- データベース設計の基本概念(ER図、エンティティ)
- SQLの基本をマスターしよう
- 正規化入門(第1〜第3正規形)
- インデックスの仕組みと使い方
- トランザクションとACID特性
- 実践的なテーブル設計パターン集
- よくあるアンチパターンと対策 ← 今回✅ 完結!🎉
💬 質問や感想があれば、コメント欄でお気軽にどうぞ!
👍 役に立ったら、いいね&ストックをお願いします!
🎓 ここまで読んでくださって、本当にありがとうございました!
データベース設計、楽しんでくださいね!🙌✨