リレーショナルデータベース(PostgreSQL / MySQL / SQL Server / Oracle など)の設計・実装・運用で押さえておきたい原則をまとめたものです。製品ごとの差異がある箇所は注記しています。


目次

  1. スキーマ設計
  2. 命名規約
  3. データ型の選び方
  4. キーと制約
  5. インデックス
  6. クエリの書き方
  7. トランザクションと同時実行制御
  8. マイグレーション(スキーマ変更)
  9. セキュリティ
  10. パフォーマンスとスケーリング
  11. 運用・バックアップ・監視
  12. アンチパターン集
  13. チェックリスト

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) / NUMERICFLOAT / DOUBLE(丸め誤差)
日時タイムゾーン付き型(PG: timestamptz)+UTCで保存文字列で日時を保存
日付のみDATEDATETIME に 00:00:00 を入れる
真偽値BOOLEANCHAR(1) の ‘Y’/‘N’
列挙値参照テーブル or CHECK 制約制約なしの自由文字列
IDBIGINT または UUIDINT(枯渇リスク)
長文TEXT過剰な VARCHAR(4000) 乱用
JSONJSONB(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 パターン)

  1. Expand:新カラム・新テーブルを追加(NULL許容 or デフォルト付き)
  2. アプリを新旧両対応にデプロイ(両方に書き込む)
  3. 既存データをバックフィル(小さなバッチで)
  4. アプリを新スキーマのみ参照に切り替え
  5. 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. パフォーマンスとスケーリング

まずは基本から

  1. 遅いクエリを特定する(スロークエリログ、PG: pg_stat_statements、MySQL: Performance Schema)。
  2. 実行計画を読み、インデックスやクエリを改善する。
  3. 統計情報を最新に保つ(ANALYZE)。
  4. それでも足りなければアーキテクチャ(キャッシュ、レプリカ、分割)を検討する。

早すぎる最適化は避けつつ、計測に基づいて改善する。

コネクション管理

  • コネクションプールを使う(アプリ側プール、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が設定され、リストアテスト済み
  • 監視・アラートが設定されている
  • アプリ用ユーザーは最小権限
  • スロークエリを定期的に見直している
  • 不要なインデックスを棚卸ししている

0件のページ