SJ blog
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・カンマ区切りリスト・タイムゾーンの無視 は一度データが溜まると移行が困難になるため、設計段階での注意が重要です。


参考: SQL Antipatterns(Bill Karwin) / Use The Index, Luke