0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【初心者向け】データベース設計の基礎 #7 - よくあるアンチパターンと対策🚫

0
Last updated at Posted at 2026-03-03

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 次のステップ🚀

このシリーズで基礎は身につきました!
次は、実際にプロジェクトで使ってみましょう!💪

おすすめの学習

  1. 実際にデータベースを設計してみる

    • 個人プロジェクトで実践
    • ポートフォリオを作る
  2. パフォーマンスチューニングを学ぶ

    • クエリの最適化
    • インデックスの調整
  3. 高度なトピックに挑戦

    • レプリケーション
    • シャーディング
    • バックアップとリカバリ
  4. 他のデータベースも学ぶ

    • NoSQL(MongoDB、Redis)
    • NewSQL(CockroachDB、TiDB)

10.6 最後に📝

データベース設計は、経験が何より重要です!

  • 失敗を恐れずに、色々試してみる
  • 他の人の設計を見て学ぶ
  • レビューをもらう
  • 本番運用で問題が起きたら、改善する

完璧な設計はありません!
常に改善し続けることが大切です!✨

このシリーズが、あなたのデータベース設計の
第一歩になれば嬉しいです!🎉


データベース設計基礎シリーズ(完結)

  1. データベース設計の基本概念(ER図、エンティティ)
  2. SQLの基本をマスターしよう
  3. 正規化入門(第1〜第3正規形)
  4. インデックスの仕組みと使い方
  5. トランザクションとACID特性
  6. 実践的なテーブル設計パターン集
  7. よくあるアンチパターンと対策 ← 今回✅ 完結!🎉

💬 質問や感想があれば、コメント欄でお気軽にどうぞ!
👍 役に立ったら、いいね&ストックをお願いします!
🎓 ここまで読んでくださって、本当にありがとうございました!

データベース設計、楽しんでくださいね!🙌✨

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?