database
A
信頼度ランク
| S | 公式ソース確認済み |
| A | 成功実績多数・失敗例少数 |
| B | 賛否両論 |
| C | 動作未確認・セキュリティリスク高 |
| Z | 個人所感 |
データベース設計の失敗パターン10選
よくあるDB設計のアンチパターンとその改善策を解説。EAV・Naive Tree・ポリモーフィック関連・JSONの濫用など、後から直すと大変な設計ミスを事前に防ぐための実践ガイドです。
一言結論
EAV・ポリモーフィック関連・JSONの濫用などのアンチパターンは「柔軟性」という名目で採用されがちだが、結局クエリの複雑化とインデックス効果の喪失を招くため、スキーマの正規化を最初に徹底することが長期的に安全だ。
1. EAV(Entity-Attribute-Value)パターン
柔軟性のために「なんでも格納できる」テーブルを作る反パターン。
-- ❌ EAV テーブル
CREATE TABLE attributes (
entity_id INT,
key VARCHAR(100),
value VARCHAR(1000)
);
-- データ例
INSERT INTO attributes VALUES (1, 'name', 'Alice'), (1, 'age', '30'), (1, 'email', 'alice@...');
-- 問題: 型チェックなし、ジョインが複雑、インデックスが効かない
SELECT * FROM attributes WHERE entity_id = 1 AND key = 'age' AND CAST(value AS INT) > 25;
-- ✅ 正しい設計: 型付きカラム
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
age INT,
email VARCHAR(255)
);
-- 拡張性が必要なら JSONB カラムを使う
ALTER TABLE users ADD COLUMN metadata JSONB;
2. NULLだらけのスパーステーブル
一つのテーブルに「商品」「ユーザー」「注文」を詰め込む。
-- ❌ 多くのカラムが NULL になる
CREATE TABLE entities (
id INT PRIMARY KEY,
user_name VARCHAR, -- ユーザー専用
user_email VARCHAR, -- ユーザー専用
product_price DECIMAL, -- 商品専用
product_sku VARCHAR, -- 商品専用
order_total DECIMAL, -- 注文専用
entity_type VARCHAR -- 'user' | 'product' | 'order'
);
-- ✅ テーブルを分ける(正規化)
CREATE TABLE users (id INT, name VARCHAR, email VARCHAR);
CREATE TABLE products (id INT, price DECIMAL, sku VARCHAR);
CREATE TABLE orders (id INT, total DECIMAL, user_id INT REFERENCES users(id));
3. ポリモーフィック関連(多態関連)
「コメント」が「投稿」にも「動画」にも「商品」にも付くケース。
-- ❌ 外部キー制約が使えない
CREATE TABLE comments (
id INT PRIMARY KEY,
body TEXT,
commentable_id INT, -- どのレコードか
commentable_type VARCHAR -- 'Post' | 'Video' | 'Product'
);
-- ✅ 専用の関連テーブルを用意する
CREATE TABLE post_comments (comment_id INT, post_id INT REFERENCES posts(id));
CREATE TABLE video_comments (comment_id INT, video_id INT REFERENCES videos(id));
CREATE TABLE product_comments (comment_id INT, product_id INT REFERENCES products(id));
または PostgreSQL の継承テーブル・パーティションを活用。
4. カンマ区切りのリスト
-- ❌ カラムにカンマ区切りで複数値を格納
CREATE TABLE articles (
id INT,
tags VARCHAR -- "javascript,typescript,react" ← 最悪の設計
);
-- 問題: 特定タグの検索が非効率、カンマの数が上限になる
SELECT * FROM articles WHERE tags LIKE '%typescript%';
-- ✅ 中間テーブル(多対多)
CREATE TABLE tags (id INT PRIMARY KEY, name VARCHAR);
CREATE TABLE article_tags (article_id INT REFERENCES articles(id), tag_id INT REFERENCES tags(id));
SELECT a.* FROM articles a JOIN article_tags at ON a.id = at.article_id JOIN tags t ON at.tag_id = t.id WHERE t.name = 'typescript';
5. Naive Tree(素朴な隣接リスト)
階層構造を単純な parent_id で表現し、再帰クエリを書きまくる。
-- 通常の隣接リストは再帰が必要
WITH RECURSIVE tree AS (
SELECT id, name, parent_id, 0 as depth FROM categories WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id, t.depth + 1 FROM categories c JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree;
-- ✅ 深い階層ならクロージャーテーブル(全祖先関係を保持)
CREATE TABLE category_closure (
ancestor_id INT REFERENCES categories(id),
descendant_id INT REFERENCES categories(id),
depth INT
);
6. 過剰なインデックス
-- ❌ すべてのカラムにインデックスを追加する
-- 書き込みのたびにインデックスの更新コストが発生する
-- また、読み取りでも使われないインデックスは無駄
-- ✅ インデックスは慎重に追加する
-- 追加前: EXPLAIN ANALYZE で効果を確認
-- 追加後: pg_stat_user_indexes で使用状況を定期確認
SELECT indexname, idx_scan FROM pg_stat_user_indexes WHERE idx_scan = 0;
7. UUID の乱用
-- UUID v4 はランダムなので B-tree インデックスが断片化する
-- 大量データでの挿入パフォーマンスが悪化
-- ❌ UUID v4 を主キーに使う
CREATE TABLE orders (id UUID DEFAULT gen_random_uuid() PRIMARY KEY);
-- ✅ 順序付き UUID(UUID v7)を使う
-- または BIGSERIAL(自動連番)を使い、外部公開用に UUID を別途持つ
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY, -- 内部キー
public_id UUID DEFAULT gen_random_uuid() UNIQUE -- 外部公開用
);
8. トランザクションの忘れ
// ❌ 複数の DB 操作をトランザクションなしで実行
await db.orders.create({ userId, total });
await db.inventory.decrement({ productId, quantity });
// 途中で失敗すると、注文だけ作られて在庫が減らない(データ不整合)
// ✅ トランザクションで括る
await db.transaction(async (trx) => {
await trx.orders.create({ userId, total });
await trx.inventory.decrement({ productId, quantity });
// エラーが起きれば自動ロールバック
});
9. N+1 クエリ
// ❌ ループ内でクエリを発行する
const users = await db.users.findMany();
for (const user of users) {
user.posts = await db.posts.findMany({ where: { userId: user.id } });
}
// → 1 + N 本のクエリ(N = ユーザー数)
// ✅ JOIN or includes で一括取得
const users = await db.users.findMany({
include: { posts: true }
});
10. 日時のタイムゾーン無視
-- ❌ TIMESTAMP(タイムゾーンなし)を使う
created_at TIMESTAMP DEFAULT NOW()
-- ✅ TIMESTAMPTZ(タイムゾーン付き)を使う
created_at TIMESTAMPTZ DEFAULT NOW()
-- PostgreSQL は内部的に UTC で保存し、出力時にセッションのタイムゾーンに変換
まとめ
最初の設計ミスは後から直すのが最もコストがかかります。特に EAV・カンマ区切りリスト・タイムゾーンの無視 は一度データが溜まると移行が困難になるため、設計段階での注意が重要です。