データベース設計は、アプリケーションの性能と保守性を左右する重要な要素です。しかし、開発の現場では様々な理由から「アンチパターン」と呼ばれる問題のある設計が生まれてしまうことがあります。この記事では、よく見かけるデータベースのアンチパターンと、その解決策について解説します。
1. ジェイウォーク(Jaywalking):カンマ区切りリストの格納
最も頻繁に見かけるアンチパターンの一つが、複数の値をカンマ区切りで1つのカラムに格納するパターンです。
アンチパターン
articlesテーブル
| id | title | tag_ids |
|---|---|---|
| 1 | Rails入門 | 1,3,5 |
| 2 | Vue.js実践 | 2,3 |
| 3 | DB設計 | 1,4,5,12 |
tag_idsにカンマ区切りで複数のタグIDを格納しています。
なぜダメか
- 特定のタグを持つ記事を検索する際に
LIKE '%,3,%'のような非効率なクエリが必要になり、インデックスが効かない - 参照整合性を保証できないため、存在しないタグIDが紛れ込む可能性がある
- 集計処理が複雑になり、タグごとの記事数を数えるような単純な操作でさえ困難になる
- VARCHAR(255)などの文字数制限により、格納できるIDの数に暗黙の上限が生じる
解決策:交差テーブルの作成
articlesテーブル
| id | title |
|---|---|
| 1 | Rails入門 |
| 2 | Vue.js実践 |
| 3 | DB設計 |
article_tagsテーブル(交差テーブル)
| article_id | tag_id |
|---|---|
| 1 | 1 |
| 1 | 3 |
| 1 | 5 |
| 2 | 2 |
| 2 | 3 |
| 3 | 1 |
| 3 | 4 |
| 3 | 5 |
| 3 | 12 |
-- tag_id = 3 を持つ記事の検索が簡単に
SELECT * FROM articles a
INNER JOIN article_tags at ON a.id = at.article_id
WHERE at.tag_id = 3;2. EAV(Entity-Attribute-Value):柔軟性という名の罠
「将来どんな属性が追加されるかわからないから」という理由で採用されがちなのがEAVパターンです。
アンチパターン
product_attributesテーブル
| id | product_id | attr_name | attr_value |
|---|---|---|---|
| 1 | 1 | color | red |
| 2 | 1 | size | M |
| 3 | 1 | price | 1980 |
| 4 | 2 | color | blue |
| 5 | 2 | weight | 500g |
なぜダメか
- データ型の制約が効かないため、priceに文字列が入っても検出できない
- 必須属性の強制が不可能(NOT NULL制約が使えない)
- 単純なSELECTでも複雑なJOINやPIVOTが必要になる
- 外部キー制約による参照整合性を強制できない
解決策
まず本当にスキーマレスが必要か再検討することが重要です。多くの場合、属性はある程度予測可能です。
productsテーブル
| id | name | price | color | size | attributes |
|---|---|---|---|---|---|
| 1 | Tシャツ | 1980 | red | M | {“material”: “cotton”} |
| 2 | バッグ | 3500 | blue | NULL | {“weight”: “500g”} |
固定的な属性は通常のカラムで、動的な属性はJSON型カラムで管理します。
3. マジックナンバーとしてのNULL
NULLを「未設定」以外の意味で使用するパターンも危険です。
アンチパターン
subscriptionsテーブル
| id | user_id | start_date | end_date |
|---|---|---|---|
| 1 | 101 | 2024-01-01 | 2024-12-31 |
| 2 | 102 | 2024-03-01 | NULL |
| 3 | 103 | 2024-06-01 | NULL |
end_dateがNULL = 「現在も有効」という暗黙のルールになっています。
なぜダメか
- NULLは「値が存在しない」ことを表すべきで、特定のビジネスロジックを表現するために使うべきではない
- 本当に値が未設定なのか、それとも「有効」を意味するのか区別できない
- NULLを含む演算は結果がNULLになるため、集計時に予期せぬ結果を招く
解決策:明示的なステータスカラム
subscriptionsテーブル
| id | user_id | start_date | end_date | is_active |
|---|---|---|---|---|
| 1 | 101 | 2024-01-01 | 2024-12-31 | false |
| 2 | 102 | 2024-03-01 | NULL | true |
| 3 | 103 | 2024-06-01 | NULL | true |
ビジネスロジックは明示的なカラムで表現します。
4. インデックスショットガン:とりあえずインデックス
パフォーマンス問題が発生すると、「とりあえずインデックスを追加しよう」という対応がよく見られます。
アンチパターン
usersテーブルのインデックス一覧
| Key_name | Column_name |
|---|---|
| PRIMARY | id |
| idx_email | |
| idx_name | name |
| idx_email_name | email, name |
| idx_name_email | name, email |
| idx_created | created_at |
| idx_email_created | email, created_at |
なぜダメか
- 不要なインデックスはINSERT/UPDATE/DELETEの性能を低下させる
- ストレージを消費する
idx_emailとidx_email_nameは重複している(後者で前者の用途をカバーできる)- 複合インデックスの順序を誤ると、意図したクエリで使用されない
WHERE name = ? AND email = ?ではidx_email_nameは効率的に使われない
解決策
- EXPLAINを使って実際のクエリプランを確認する
- 本当に必要なインデックスだけを作成する
- 定期的に未使用インデックスを棚卸しする
-- クエリの実行計画を確認
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';5. ポリモーフィック関連:型の曖昧な外部キー
複数のテーブルへの参照を1つのカラムで表現しようとするパターンです。
アンチパターン
commentsテーブル
| id | body | commentable_type | commentable_id |
|---|---|---|---|
| 1 | 良い記事ですね | Article | 1 |
| 2 | 素敵な写真! | Photo | 5 |
| 3 | 面白い動画 | Video | 3 |
| 4 | 参考になりました | Article | 2 |
commentable_typeで参照先テーブルを、commentable_idでそのIDを指定しています。
なぜダメか
- 外部キー制約が設定できず、参照整合性を保証できない
- JOINが複雑になり、クエリの可読性が著しく低下する
- 存在しないレコードへの参照が混入しても検出できない
解決策1:テーブル分割
article_commentsテーブル
| id | article_id | body |
|---|---|---|
| 1 | 1 | 良い記事ですね |
| 2 | 2 | 参考になりました |
photo_commentsテーブル
| id | photo_id | body |
|---|---|---|
| 1 | 5 | 素敵な写真! |
解決策2:共通親テーブル
commentablesテーブル
| id | type |
|---|---|
| 1 | Article |
| 2 | Photo |
commentsテーブル
| id | commentable_id | body |
|---|---|---|
| 1 | 1 | 良い記事ですね |
| 2 | 2 | 素敵な写真! |
commentable_idに外部キー制約を設定できます。
6. 神テーブル(God Table)
「関連しそうなデータは全部1つのテーブルに」という発想で生まれる巨大テーブルです。
アンチパターン
usersテーブル
| カラム名 | 用途 |
|---|---|
| id | 主キー |
| name | 基本情報 |
| 基本情報 | |
| encrypted_password | 認証情報 |
| phone | プロフィール |
| address | プロフィール |
| notification_email | 設定 |
| notification_push | 設定 |
| theme | 設定 |
| plan_type | 課金情報 |
| billing_address | 課金情報 |
| credit_card_last4 | 課金情報 |
| login_count | 統計 |
| last_login_at | 統計 |
| … | さらに30カラム以上 |
なぜダメか
- NULLだらけのスパースなデータになりがち
- テーブルの責務が不明確になる
- 変更の影響範囲が広くなり、ALTER TABLEが恐怖になる
- 特定の機能だけ使いたい場合でも全カラムをロードしてしまう
解決策:責務ごとにテーブル分割
usersテーブル(認証に必要な最小限)
| id | name | encrypted_password |
|---|
user_profilesテーブル
| user_id | phone | address |
|---|
user_settingsテーブル
| user_id | notification_email | notification_push | theme |
|---|
user_billingsテーブル
| user_id | plan_type | billing_address | credit_card_last4 |
|---|
7. ソフトデリート依存症
「データは消さない、フラグを立てるだけ」という方針の過度な適用です。
アンチパターン
ordersテーブル
| id | user_id | total | status | deleted_at |
|---|---|---|---|---|
| 1 | 101 | 1500 | completed | NULL |
| 2 | 102 | 2300 | pending | NULL |
| 3 | 101 | 800 | completed | 2024-01-15 10:30:00 |
全テーブルにdeleted_atがあり、全クエリにWHERE deleted_at IS NULLが必要になります。
なぜダメか
- クエリに
deleted_at条件を書き忘れるリスクが常にある - インデックスの効率が低下する
- データ量が際限なく増加する
- UNIQUE制約との相性が悪い(削除済みレコードとの重複問題)
解決策
まず本当にソフトデリートが必要か再検討します。
監査目的ならアーカイブテーブルへの移動を検討:
ordersテーブル(現役データのみ)
| id | user_id | total | status |
|---|---|---|---|
| 1 | 101 | 1500 | completed |
| 2 | 102 | 2300 | pending |
archived_ordersテーブル
| id | user_id | total | status | archived_at |
|---|---|---|---|---|
| 3 | 101 | 800 | completed | 2024-01-15 10:30:00 |
どうしてもソフトデリートが必要な場合は、ビューやデフォルトスコープで条件漏れを防ぎます。
まとめ
データベースのアンチパターンは、短期的には問題なく動作することが多いため、発見が遅れがちです。しかし、データ量の増加やビジネス要件の変化に伴い、大きな技術的負債となって返ってきます。
設計時に意識すべきポイント:
- 正規化の原則を理解し適切に適用する
- 外部キー制約を活用して参照整合性を保証する
- 将来の拡張性とのバランスを考える
- EXPLAINを習慣化しクエリの実行計画を確認する
「動くからいい」ではなく、「なぜこの設計なのか」を説明できる状態を目指しましょう。
参考文献
Bill Karwin 著『SQLアンチパターン』(オライリージャパン、2013年)
原著は『SQL Antipatterns: Avoiding the Pitfalls of Database Programming』として2010年に出版されました。本記事で取り上げたジェイウォーク、EAV、ポリモーフィック関連などのアンチパターンは、この書籍で体系的に解説されています。
奥野幹也 著『理論から学ぶデータベース実践入門』(技術評論社、2015年)
リレーショナルモデルの理論的背景から実践的な設計手法まで、日本語で体系的に学べる書籍です。
ミック 著『達人に学ぶDB設計 徹底指南書』(翔泳社、2012年)
論理設計から物理設計まで、データベース設計の全体像を学べる入門書です。
コメント