リレーショナルデータベース(PostgreSQL / MySQL / SQL Server / Oracle など)の設計・実装・運用で押さえておきたい原則をまとめたものです。製品ごとの差異がある箇所は注記しています。
目次
- スキーマ設計
- 命名規約
- データ型の選び方
- キーと制約
- インデックス
- クエリの書き方
- トランザクションと同時実行制御
- マイグレーション(スキーマ変更)
- セキュリティ
- パフォーマンスとスケーリング
- 運用・バックアップ・監視
- アンチパターン集
- チェックリスト
1. スキーマ設計
正規化を基本にする
- まず第3正規形(3NF)まで正規化する。重複データは更新時の不整合(更新異常)を生む。
- 1NF:1つのカラムに1つの値(カンマ区切りで複数値を入れない)。
- 2NF:複合主キーの一部にだけ依存するカラムを分離する。
- 3NF:主キー以外のカラムに依存するカラム(推移的従属)を分離する。
非正規化は「計測してから」
- 非正規化はパフォーマンス上の根拠(実測値)があるときだけ行う。
- 行う場合は、整合性を保つ仕組み(トリガー、アプリ側の更新処理、バッチでの再計算など)をセットで用意し、ドキュメントに残す。
- 集計用途ならマテリアライズドビューや集計テーブルを検討する。
エンティティを正しく切り出す
- 「1テーブル = 1つの概念」。
usersに請求先住所・配送先住所・設定値などを全部詰め込まない。 - 多対多の関係は必ず中間テーブル(関連テーブル)で表現する。
- 状態遷移を持つエンティティは、状態カラム+必要なら履歴テーブルを用意する。
共通カラム
多くのテーブルに次のようなカラムを持たせると運用が楽になる。
| カラム | 用途 |
|---|---|
id | 主キー |
created_at | 作成日時(タイムゾーン付き) |
updated_at | 更新日時 |
created_by / updated_by | 監査が必要な場合 |
version | 楽観的ロック用(必要な場合) |
論理削除は慎重に
deleted_at/is_deletedによる論理削除は、すべてのクエリに条件が必要になり、ユニーク制約とも衝突しやすい。- 代替案:
- 削除済みデータをアーカイブテーブルへ移動する
- 部分インデックス(PostgreSQL:
WHERE deleted_at IS NULL)でユニーク制約を張る - 監査目的なら履歴テーブル/イベントログで管理する
2. 命名規約
- 一貫性が最優先。チームで規約を決めて文書化する。
- 推奨される一般的な規約:
snake_caseを使う(大文字小文字の扱いがDBごとに違うため)- テーブル名は複数形(
users,orders)か単数形のどちらかに統一 - 外部キーは
<参照先単数形>_id(例:user_id) - 真偽値は
is_/has_プレフィックス(is_active) - 日時は
_at、日付は_onまたは_date(created_at,birth_date)
- 予約語を避ける(
user,order,group,descなど)。 - 略語は最小限に。
usr_nmよりuser_name。 - 制約・インデックスにも命名規約を:
pk_users,fk_orders_user_id,idx_orders_created_at,uq_users_email。
3. データ型の選び方
| 用途 | 推奨 | 避けたいもの |
|---|---|---|
| 金額 | DECIMAL(p, s) / NUMERIC | FLOAT / DOUBLE(丸め誤差) |
| 日時 | タイムゾーン付き型(PG: timestamptz)+UTCで保存 | 文字列で日時を保存 |
| 日付のみ | DATE | DATETIME に 00:00:00 を入れる |
| 真偽値 | BOOLEAN | CHAR(1) の ‘Y’/‘N’ |
| 列挙値 | 参照テーブル or CHECK 制約 | 制約なしの自由文字列 |
| ID | BIGINT または UUID | INT(枯渇リスク) |
| 長文 | TEXT | 過剰な VARCHAR(4000) 乱用 |
| JSON | JSONB(PG)/ JSON | 検索・結合するデータをJSONに詰める |
ポイント:
- 最小で十分な型を選ぶ。ただし将来の拡張余地(ID枯渇など)は考慮する。
- 文字コードは UTF-8(MySQLでは
utf8mb4、utf8は3バイトで絵文字が入らない)。 NULLの意味を明確にする。「未設定」「該当なし」「不明」を区別したいなら設計で表現する。- JSON型は便利だが、頻繁に検索・集計・結合する属性は通常のカラムにする。
4. キーと制約
主キー
- すべてのテーブルに主キーを設定する。
- サロゲートキー(
id) を基本にし、業務上の一意な値(メールアドレス、社員番号など)には別途ユニーク制約を張る。業務キーは変わることがある。 - UUIDを使う場合、ランダムなUUIDv4はB-treeの挿入性能を悪化させやすい。UUIDv7 や ULID など時系列順の値を検討する。
外部キー
- 原則として外部キー制約を張る。アプリのバグによる孤児レコードを防げる。
ON DELETEの挙動(RESTRICT/CASCADE/SET NULL)は意図的に選ぶ。CASCADEは便利だが大量削除を誘発しうる。- 外部キーカラムにはインデックスを付ける(PostgreSQLは自動では作らない)。親の削除・更新やJOINが遅くなるのを防ぐ。
その他の制約
NOT NULL:必須カラムには必ず付ける。デフォルトでNOT NULLにし、NULL許容を例外にする発想がよい。UNIQUE:重複してはいけないものはDBで保証する。アプリ側チェックだけでは競合状態で破られる。CHECK:値域や状態の整合性(price >= 0、end_at > start_at)。DEFAULT:妥当な初期値を設定する。
制約はドキュメントであり、最後の防衛線。 アプリケーションのバリデーションと二重になっても張っておく価値がある。
5. インデックス
基本
WHERE、JOIN、ORDER BY、GROUP BYで使うカラムが候補。- インデックスは読み取りを速くするが、書き込み・ストレージのコストを増やす。闇雲に追加しない。
- 選択性(カーディナリティ)の低いカラム単独(例:性別、真偽値)へのインデックスは効果が薄いことが多い。
複合インデックス
- カラムの順序が重要。左端から順に使われる(左端一致の原則)。
- 一般的な指針:等価条件(
=)のカラムを先、範囲条件(>,BETWEEN)やソートのカラムを後。 (a, b)があれば(a)単独のインデックスは通常不要。
高度なインデックス
- カバリングインデックス:クエリに必要なカラムをすべて含め、テーブル本体へのアクセスを省く(PG/SQL Server の
INCLUDE)。 - 部分インデックス:条件に合う行だけを対象にする(
WHERE status = 'active')。 - 式インデックス:
LOWER(email)などの関数結果に対して張る。 - 全文検索、地理空間、JSONなどには専用インデックス(GIN、GiST など)を使う。
インデックスが効かない典型例
- インデックス列に関数や演算を適用:
WHERE YEAR(created_at) = 2026→WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'に書き換える。 - 前方一致でない
LIKE '%foo'。 - 暗黙の型変換(文字列カラムに数値を比較するなど)。
OR条件の多用(UNIONやDBの最適化次第)。
運用
- 使われていないインデックスは定期的に棚卸しして削除する(PG:
pg_stat_user_indexes)。 - 重複インデックスを作らない。
6. クエリの書き方
SELECT *を避ける。必要なカラムだけを取得する(転送量削減、カバリングインデックスの活用、スキーマ変更への耐性)。- N+1問題に注意。ORMでループ内クエリが発生していないか確認し、JOINや一括取得(
IN、eager loading)を使う。 - 実行計画を確認する:
EXPLAIN(PG ではEXPLAIN (ANALYZE, BUFFERS))で、フルスキャン、想定外のネステッドループ、見積もり行数と実際の乖離をチェック。 - ページネーション:大きな
OFFSETは遅い。キーセット(シーク)方式WHERE id > :last_id ORDER BY id LIMIT 50を検討。 - 一括処理:大量の
INSERT/UPDATE/DELETEはバッチに分割し、ロックやレプリケーション遅延を抑える。 NULLの比較:= NULLではなくIS NULL。NOT INのサブクエリにNULLが含まれると結果が空になる罠に注意(NOT EXISTSを使う)。COUNT(*)で存在確認しない。EXISTSを使う。- 複雑なクエリは CTE(
WITH)で読みやすく分割する。ただしDBによっては最適化の壁になる点に留意。 - 集計・ソート・フィルタは可能な限りDB側で行い、アプリに全件持ってきて処理しない。
7. トランザクションと同時実行制御
トランザクション
- 一貫性が必要な一連の操作は1つのトランザクションにまとめる。
- トランザクションは短く保つ。トランザクション内で外部API呼び出しやユーザー入力待ちをしない。
- 長時間トランザクションはロック競合、VACUUM阻害(PG)、undoログ肥大化(MySQL)の原因になる。
分離レベル
| 分離レベル | ダーティリード | ノンリピータブルリード | ファントムリード |
|---|---|---|---|
| READ UNCOMMITTED | 起こりうる | 起こりうる | 起こりうる |
| READ COMMITTED | 防ぐ | 起こりうる | 起こりうる |
| REPEATABLE READ | 防ぐ | 防ぐ | 起こりうる※ |
| SERIALIZABLE | 防ぐ | 防ぐ | 防ぐ |
※ 実装により異なる(PG・MySQL InnoDB の REPEATABLE READ は多くのケースでファントムも防ぐ)。
- 既定値:PostgreSQL / SQL Server / Oracle は READ COMMITTED、MySQL InnoDB は REPEATABLE READ。
- 自分の使うDBの既定値と挙動を把握したうえで、必要な箇所だけ分離レベルを上げる。
- SERIALIZABLE を使う場合は、シリアライゼーション失敗時のリトライ処理を実装する。
ロック戦略
- 楽観的ロック:
versionカラムを使い、更新時にWHERE version = :vで競合を検出。競合が少ない場合に有効。 - 悲観的ロック:
SELECT ... FOR UPDATEで行をロック。競合が多い場合や在庫引当など。 - キューのような処理には
FOR UPDATE SKIP LOCKEDが便利。 - デッドロック対策:複数行・複数テーブルをロックする際は常に同じ順序でアクセスする。デッドロックは起こりうる前提でリトライを用意する。
冪等性
- 再送・リトライに備え、冪等キーやユニーク制約+
INSERT ... ON CONFLICT(UPSERT)で重複処理を防ぐ。
8. マイグレーション(スキーマ変更)
- スキーマ変更は必ずマイグレーションツールでコード管理する(Flyway、Liquibase、Alembic、Rails/Django/Prisma のマイグレーションなど)。手作業で本番DBを変更しない。
- マイグレーションはバージョン管理し、レビュー対象にする。
- ロールバック手順(down マイグレーション、または前進修正の方針)を用意する。
- 本番相当のデータ量でステージング検証してから適用する。
ゼロダウンタイムでの変更(Expand / Contract パターン)
- Expand:新カラム・新テーブルを追加(NULL許容 or デフォルト付き)
- アプリを新旧両対応にデプロイ(両方に書き込む)
- 既存データをバックフィル(小さなバッチで)
- アプリを新スキーマのみ参照に切り替え
- Contract:旧カラムを削除
注意が必要な操作
- 大きなテーブルへの
ALTER TABLEはテーブルロックや全件書き換えを起こすことがある。DB・バージョンごとの挙動を確認する。 - PostgreSQL:インデックス作成は
CREATE INDEX CONCURRENTLY。外部キー・CHECK制約はNOT VALIDで追加後にVALIDATE。 - MySQL:
ALGORITHM=INPLACE/INSTANTの可否を確認。大規模変更は gh-ost や pt-online-schema-change を検討。 - カラムのリネームや型変更は破壊的。新カラム追加+移行で行う。
- ロック取得待ちで詰まらないよう
lock_timeoutを設定する(PG)。
9. セキュリティ
- SQLインジェクション対策:必ずプレースホルダ/プリペアドステートメントを使う。文字列連結でSQLを組み立てない。動的なテーブル名・カラム名はホワイトリストで検証する。
- 最小権限の原則:
- アプリ用ユーザーには必要なテーブルへの必要な操作(DML)だけ許可する。
- マイグレーション用(DDL権限あり)とアプリ実行用のユーザーを分ける。
- 読み取り専用ユーザー(分析・レポート用)を用意する。
- スーパーユーザー/root をアプリから使わない。
- 機密情報の保護:
- パスワードは平文保存せず、bcrypt / scrypt / Argon2 などでハッシュ化(アプリ側で)。
- 個人情報やクレジットカード情報などは列単位の暗号化やトークナイズを検討。
- 保存時暗号化(TDE、ストレージ暗号化)と通信の暗号化(TLS)を有効にする。
- ネットワーク:DBをインターネットに直接公開しない。プライベートネットワーク+アクセス制御。
- 認証情報の管理:接続情報をソースコードにハードコードしない。シークレットマネージャを使い、定期的にローテーションする。
- 監査ログ:誰がいつ何を変更したかを追えるようにする(特に権限変更、機密データアクセス)。
- 行レベルセキュリティ(PG の RLS など)でマルチテナントの分離を強化できる。
10. パフォーマンスとスケーリング
まずは基本から
- 遅いクエリを特定する(スロークエリログ、PG:
pg_stat_statements、MySQL: Performance Schema)。 - 実行計画を読み、インデックスやクエリを改善する。
- 統計情報を最新に保つ(
ANALYZE)。 - それでも足りなければアーキテクチャ(キャッシュ、レプリカ、分割)を検討する。
早すぎる最適化は避けつつ、計測に基づいて改善する。
コネクション管理
- コネクションプールを使う(アプリ側プール、PgBouncer、ProxySQL、RDS Proxy など)。
- 最大接続数を無制限に増やさない。DBの接続はリソースを食う。
- アイドル接続・接続リークを監視する。
スケーリング手段
| 手段 | 適する状況 | 注意点 |
|---|---|---|
| スケールアップ | 手早く余裕を確保したい | 上限とコスト |
| リードレプリカ | 読み取り負荷が高い | レプリケーション遅延(書いた直後の読み取りは主系へ) |
| キャッシュ(Redis等) | 同じデータの繰り返し読み取り | 無効化戦略、整合性 |
| パーティショニング | 巨大テーブル、時系列データ、古いデータの一括削除 | パーティションキーを含まないクエリは遅くなりうる |
| シャーディング | 単一ノードで書き込みが捌けない | 複雑性が大きい。最終手段 |
その他
- 巨大テーブルの古いデータはアーカイブ/パーティション単位で削除する。
- PostgreSQL では autovacuum の設定を負荷に応じて調整し、テーブル肥大化(bloat)を監視する。
- バッファプール/共有バッファなどのメモリ設定をワークロードに合わせる。
11. 運用・バックアップ・監視
バックアップ
- 定期的なフルバックアップ+ポイントインタイムリカバリ(PITR)(WAL / binlog のアーカイブ)を組み合わせる。
- RPO(どこまでのデータ損失を許容するか)と RTO(どれだけで復旧するか)を決め、それに合う方式を選ぶ。
- バックアップは別リージョン/別アカウントにも保管する。
- リストアを定期的にテストする。復元できないバックアップは無意味。
高可用性
- 主系・待機系の構成(レプリケーション+自動フェイルオーバー)。
- フェイルオーバー手順を定期的に訓練する。
監視すべき指標
- CPU、メモリ、ディスク使用率とI/O
- 接続数、アクティブセッション数
- クエリのレイテンシ、スループット、スロークエリ
- ロック待ち、デッドロック発生数
- レプリケーション遅延
- キャッシュヒット率
- テーブル・インデックスのサイズ推移
- 長時間実行中のトランザクション
- (PG)トランザクションIDの周回(wraparound)までの残り
運用ルール
- 本番DBへの直接操作は原則禁止。必要な場合は手順書・レビュー・トランザクション内での確認(
BEGIN→ 確認 →COMMIT)を徹底する。 UPDATE/DELETEを書くときは、先に同じWHEREでSELECTして件数を確認する。- DBのバージョンを計画的にアップデートし、サポート切れを避ける。
- 開発・テスト環境に本番の個人情報をそのままコピーしない(マスキングする)。
12. アンチパターン集
| アンチパターン | 問題点 | 対策 |
|---|---|---|
| カンマ区切りで複数値を1カラムに格納 | 検索・結合・整合性が困難 | 子テーブル/中間テーブル |
| EAV(Entity-Attribute-Value) | 型・制約が効かず、クエリが複雑化 | 通常のカラム設計、必要ならJSON型 |
ポリモーフィック関連(target_type + target_id) | 外部キー制約が張れない | 参照先ごとのカラム or 中間テーブル |
| 外部キー制約なし | 孤児レコード、不整合 | 制約を張る |
FLOAT で金額管理 | 丸め誤差 | DECIMAL |
| タイムゾーンなしの日時 | 解釈が曖昧になる | タイムゾーン付き型+UTC保存 |
| 何でもJSONカラム | 制約・インデックス・型安全性の喪失 | 構造化できる属性はカラム化 |
| 全カラムにインデックス | 書き込み性能・容量の悪化 | クエリパターンに基づいて設計 |
| 文字列連結でSQL組み立て | SQLインジェクション | プレースホルダ |
| 長時間トランザクション | ロック競合、肥大化 | 短く保つ、外部I/Oを外へ |
| 手作業でのスキーマ変更 | 環境差分、再現不能 | マイグレーションツール |
テーブル名・カラム名に連番(col1, col2…) | 意味不明、正規化違反の兆候 | 意味のある名前、正規化 |
| DBを「ただの箱」として扱う | 整合性をアプリだけに依存 | 制約・トランザクションを活用 |
13. チェックリスト
設計時
- 第3正規形まで正規化されている(非正規化箇所には理由がある)
- すべてのテーブルに主キーがある
- 外部キー制約が張られ、外部キーカラムにインデックスがある
- 必須カラムに
NOT NULL、一意であるべき値にUNIQUEがある - 金額は
DECIMAL、日時はタイムゾーン付き - 命名規約に従っている、予約語を使っていない
- 文字コードが UTF-8(MySQL は
utf8mb4)
実装時
- すべてのクエリでプレースホルダを使っている
-
SELECT *を使っていない - N+1クエリがない
- 主要クエリの実行計画を確認した
- トランザクションの範囲が適切で短い
- 競合・デッドロック時のリトライがある
リリース時
- マイグレーションがコード管理・レビューされている
- 大きなテーブルへの変更のロック影響を確認した
- ロールバック方針がある
運用
- バックアップとPITRが設定され、リストアテスト済み
- 監視・アラートが設定されている
- アプリ用ユーザーは最小権限
- スロークエリを定期的に見直している
- 不要なインデックスを棚卸ししている