PostgreSQL を本番運用するうえで押さえておきたい設計・クエリ・運用の指針をまとめたものです。 数値はあくまで出発点です。実際のワークロードで計測しながら調整してください。
目次
- スキーマ設計
- データ型の選び方
- インデックス
- クエリの書き方
- トランザクションとロック
- VACUUM と統計情報
- 設定チューニング
- 接続管理
- セキュリティ
- バックアップとリカバリ
- マイグレーション(安全なスキーマ変更)
- 監視
- パーティショニング
- アンチパターン集
- チェックリスト
1. スキーマ設計
命名規則
- テーブル名・カラム名は 小文字の snake_case にする(例:
user_accounts,created_at)。- 大文字や記号を使うと常にダブルクォートが必要になり、ミスの元になります。
- 予約語(
user,order,groupなど)は避ける。 - テーブル名は単数形か複数形のどちらかにプロジェクト内で統一する。
主キー
- すべてのテーブルに主キーを定義する(論理レプリケーションでも必要になります)。
- 連番なら
serialではなくbigint GENERATED ALWAYS AS IDENTITYを使う。intは約21億件で枯渇します。最初からbigintにしておくのが安全です。
- 外部に公開する ID や分散環境で生成する ID には UUID を検討する。
- ランダムな UUIDv4 はインデックスの局所性が悪く、書き込み性能が落ちやすい。
時系列順に並ぶ UUIDv7 を推奨します(PostgreSQL 18 以降は
uuidv7()が組み込み)。
- ランダムな UUIDv4 はインデックスの局所性が悪く、書き込み性能が落ちやすい。
時系列順に並ぶ UUIDv7 を推奨します(PostgreSQL 18 以降は
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT users_email_key UNIQUE (email)
);制約でデータを守る
アプリ側だけでなく、DB 側でも整合性を担保します。
NOT NULLを基本にし、NULL を許すのは意味があるときだけにする。FOREIGN KEY、UNIQUE、CHECKを積極的に使う。- 制約にはわかりやすい名前を付けると、エラーメッセージから原因を追いやすくなります。
ALTER TABLE orders
ADD CONSTRAINT orders_amount_positive CHECK (amount > 0);外部キーにはインデックスを
PostgreSQL は 外部キーの参照側カラムに自動でインデックスを作りません。 親テーブルの DELETE/UPDATE や JOIN が遅くなるため、参照側にインデックスを作成します。
CREATE INDEX orders_user_id_idx ON orders (user_id);正規化と JSONB
- 基本は正規化(第3正規形程度)で設計する。
jsonbは「スキーマが可変な付帯情報」に限定する。検索・結合・集計に頻繁に使う項目は通常のカラムにする。
2. データ型の選び方
| 用途 | 推奨 | 避けたいもの | 理由 |
|---|---|---|---|
| 日時 | timestamptz | timestamp | タイムゾーンの扱いを誤りにくい |
| 文字列 | text(必要なら CHECK (length(x) <= n)) | char(n) | varchar(n) と性能差はなく、長さ変更も楽 |
| 金額 | numeric(p, s) または最小単位の bigint | float, money | 浮動小数点は丸め誤差が出る |
| 真偽値 | boolean | int, char(1) | 意図が明確になる |
| JSON | jsonb | json | インデックスが使え、処理も速い |
| IP アドレス | inet / cidr | text | 範囲検索や検証ができる |
| 列挙値 | text + CHECK か参照テーブル | 頻繁に変わる値の ENUM | ENUM は値の削除・並び替えが難しい |
| 期間 | tstzrange など範囲型 | 開始・終了の2カラムのみ | 排他制約で重複を防げる |
-- 予約期間の重複を DB レベルで禁止する例
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE reservations (
room_id bigint NOT NULL,
during tstzrange NOT NULL,
EXCLUDE USING gist (room_id WITH =, during WITH &&)
);3. インデックス
基本方針
- インデックスは 実際のクエリから逆算して作る。「念のため」に作らない。
- インデックスが増えるほど書き込みが遅くなり、ストレージと VACUUM のコストも増えます。
- 使われていないインデックスは定期的に見直して削除する(監視を参照)。
インデックスの種類
| 種類 | 主な用途 |
|---|---|
| B-tree(デフォルト) | 等価・範囲・ソート。ほとんどの場合これ |
| GIN | jsonb、配列、全文検索、pg_trgm による部分一致 |
| GiST | 範囲型、地理情報(PostGIS)、排他制約 |
| BRIN | 物理的な並び順と相関する巨大テーブル(時系列ログなど) |
| Hash | 等価比較のみ。B-tree で足りることが多い |
複合インデックスの列順
- 等価条件の列を先に、範囲条件の列を後に置く。
(a, b)のインデックスはWHERE a = ?にも使えるが、WHERE b = ?単体には基本的に使えない。
-- WHERE user_id = ? AND created_at >= ? ORDER BY created_at DESC
CREATE INDEX orders_user_created_idx ON orders (user_id, created_at DESC);便利なテクニック
-- 部分インデックス: 条件に合う行だけを対象にして小さく保つ
CREATE INDEX orders_pending_idx ON orders (created_at) WHERE status = 'pending';
-- 式インデックス: 関数を通した検索に対応
CREATE INDEX users_email_lower_idx ON users (lower(email));
-- カバリングインデックス: Index Only Scan を狙う
CREATE INDEX orders_user_cover_idx ON orders (user_id) INCLUDE (amount, status);
-- 部分一致検索(LIKE '%foo%')
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX products_name_trgm_idx ON products USING gin (name gin_trgm_ops);本番環境では CONCURRENTLY
通常の CREATE INDEX はテーブルへの書き込みをブロックします。本番では必ず次のようにします。
CREATE INDEX CONCURRENTLY orders_status_idx ON orders (status);
-- 失敗すると INVALID なインデックスが残るので、確認して削除・再作成する4. クエリの書き方
実行計画を見る
遅いクエリは推測せず、まず実行計画を確認します。
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;確認ポイント:
- 推定行数(
rows=)と実際の行数(actual rows=)が大きくずれていないか → ずれていれば統計情報の問題 - 大きなテーブルへの想定外の
Seq Scan SortやHashでディスクを使っていないか(external merge、Batches> 1)Buffers: shared readが多い → キャッシュに乗っていない
注意:
EXPLAIN ANALYZEは実際にクエリを実行します。更新系はBEGIN; ... ROLLBACK;で囲んでください。
よくある改善ポイント
-
SELECT *を避け、必要な列だけを取る。 -
インデックス列を関数や演算で包まない。
-- NG: インデックスが使われない WHERE date(created_at) = '2026-10-29' -- OK WHERE created_at >= '2026-10-29' AND created_at < '2026-10-30' -
N+1 クエリを避ける。JOIN や
WHERE id = ANY($1)でまとめて取得する。 -
ページングは OFFSET よりキーセット方式にする。OFFSET は読み飛ばす行もすべて処理します。
-- NG: ページが深くなるほど遅い SELECT * FROM posts ORDER BY id DESC LIMIT 20 OFFSET 100000; -- OK: キーセットページネーション SELECT * FROM posts WHERE id < $last_id ORDER BY id DESC LIMIT 20; -
存在確認は
EXISTSを使う(COUNT(*) > 0は全件数える)。 -
NOT INとサブクエリの組み合わせに注意。サブクエリに NULL が含まれると結果が空になります。NOT EXISTSを使ってください。 -
大量 INSERT は一括で。複数行の
INSERT ... VALUES (...), (...)かCOPYを使う。 -
UPSERT は
INSERT ... ON CONFLICTを使う。複雑な同期処理にはMERGEも使えます。
プリペアドステートメント / パラメータバインド
- SQL インジェクション対策として、文字列連結でクエリを組み立てない。必ずプレースホルダを使う。
5. トランザクションとロック
- トランザクションは短く保つ。トランザクション内で外部 API 呼び出しやユーザー入力待ちをしない。
- 長時間のトランザクションは VACUUM を妨げ、テーブルの肥大化(bloat)を招きます。
- 分離レベルはデフォルトの
READ COMMITTEDが基本。整合性要件が厳しい処理ではSERIALIZABLEを使い、直列化エラー(SQLSTATE40001)時のリトライ処理を必ず実装する。 - デッドロックを避けるため、複数行・複数テーブルを更新するときは 常に同じ順序でロックを取る。
- ジョブキューのような処理には
SELECT ... FOR UPDATE SKIP LOCKEDが便利です。
SELECT id FROM jobs
WHERE status = 'queued'
ORDER BY id
LIMIT 10
FOR UPDATE SKIP LOCKED;タイムアウトを設定する
暴走したクエリや放置されたトランザクションから DB を守ります。
ALTER ROLE app_user SET statement_timeout = '30s';
ALTER ROLE app_user SET idle_in_transaction_session_timeout = '60s';
ALTER ROLE app_user SET lock_timeout = '5s';6. VACUUM と統計情報
PostgreSQL は MVCC の仕組み上、UPDATE/DELETE で古い行(dead tuple)が残ります。これを回収するのが VACUUM です。
- autovacuum は絶対に無効にしない。
- 更新の多い大きなテーブルは、テーブル単位で autovacuum を積極的に動くよう調整する。
ALTER TABLE events SET (
autovacuum_vacuum_scale_factor = 0.01, -- デフォルト 0.2(20%)は大きなテーブルには遅すぎる
autovacuum_analyze_scale_factor = 0.005
);- 大量のデータ投入や削除の後は、手動で
ANALYZEを実行して統計情報を更新する。 - トランザクション ID の周回(wraparound)を監視する。放置すると DB が書き込みを停止します。
SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC;VACUUM FULLはテーブルを排他ロックするため、本番では基本的に使わない。肥大化の解消が必要ならpg_repackなどを検討する。- 列同士に相関がある場合は拡張統計(
CREATE STATISTICS)で推定精度を上げられます。
7. 設定チューニング
主要なパラメータと目安です(専用 DB サーバーを想定)。
| パラメータ | 目安 | 説明 |
|---|---|---|
shared_buffers | メモリの 25% 程度 | PostgreSQL 自身のキャッシュ |
effective_cache_size | メモリの 50〜75% | OS キャッシュを含めた見積もり(プランナーへのヒント) |
work_mem | 4〜64MB 程度から | ソート・ハッシュ1操作あたりのメモリ。接続数 × 操作数の分だけ使われるので上げすぎ注意 |
maintenance_work_mem | 512MB〜2GB | VACUUM やインデックス作成用 |
random_page_cost | SSD なら 1.1 前後 | デフォルト 4.0 は HDD 前提 |
effective_io_concurrency | SSD なら 200 程度 | 先読みの並列度 |
max_wal_size | 数 GB〜 | 小さいとチェックポイントが頻発する |
checkpoint_completion_target | 0.9 | チェックポイントの I/O を平準化 |
max_connections | 必要最小限 | 増やすより接続プーラーを使う |
ログ関連もあわせて設定しておくと調査が楽になります。
log_min_duration_statement = 500ms # 遅いクエリを記録
log_lock_waits = on
log_temp_files = 0
log_checkpoints = on
log_autovacuum_min_duration = 1s- 設定値の当たりを付けるには PGTune のようなツールも参考になります。
- 変更は1つずつ行い、効果を計測してから次に進みます。
8. 接続管理
- PostgreSQL は 1接続につき1プロセスを使うため、接続数が多いとメモリと CPU を圧迫します。
- アプリ側のコネクションプールに加え、必要に応じて PgBouncer などの接続プーラーを導入する。
- transaction モードでは、セッション単位の機能(セッション変数、
LISTEN、一部のプリペアドステートメントなど)に制約がある点に注意。
- transaction モードでは、セッション単位の機能(セッション変数、
- プールサイズは「多ければ速い」わけではありません。CPU コア数の数倍程度から始めて計測します。
idle in transactionのまま放置されている接続を監視する。
9. セキュリティ
- 最小権限の原則: アプリ用ロールにスーパーユーザーやテーブル所有権を与えない。
- マイグレーション用(DDL 権限あり)、アプリ用(DML のみ)、読み取り専用(SELECT のみ)とロールを分ける。
publicスキーマへのデフォルトの権限を見直す。
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;- 認証方式は
scram-sha-256を使う(md5やtrustは避ける)。 pg_hba.confで接続元を必要な範囲に限定する。DB をインターネットに直接公開しない。- 通信は TLS(SSL)で暗号化する。
- マルチテナントでは行レベルセキュリティ(RLS)の導入を検討する。
SECURITY DEFINER関数を作るときはsearch_pathを明示的に固定する。- パスワードや接続文字列をコードに直書きせず、シークレット管理の仕組みを使う。
10. バックアップとリカバリ
- バックアップはリストアできて初めて意味がある。定期的にリストア訓練を行う。
- 論理バックアップ(
pg_dump)だけでなく、物理バックアップ + WAL アーカイブによる PITR(ポイントインタイムリカバリ) を用意する。- 運用ツールとして pgBackRest や Barman がよく使われます。
- バックアップは別のリージョンやストレージにも保管する。
- RPO(どこまで遡れればよいか)と RTO(何分で復旧したいか)を決めてから方式を選ぶ。
- 高可用性が必要ならストリーミングレプリケーションを構成し、フェイルオーバー手順を整備する(Patroni など)。
- レプリカはバックアップの代わりになりません(誤操作の DELETE もそのまま複製されます)。
# 論理バックアップ(カスタム形式・並列リストア可能)
pg_dump -Fc -d mydb -f mydb.dump
pg_restore -j 4 -d mydb_restore mydb.dump11. マイグレーション(安全なスキーマ変更)
本番の大きなテーブルへの DDL は、ロックによってサービス停止を招くことがあります。
基本ルール
-
マイグレーションの前に
lock_timeoutを短く設定し、ロック待ちで後続クエリを詰まらせないようにする。SET lock_timeout = '3s'; -
マイグレーションはバージョン管理し、ツール(Flyway, Sqitch, 各フレームワークの仕組みなど)で適用する。
危険な操作と安全な代替手段
| やりたいこと | 危険な方法 | 安全な方法 |
|---|---|---|
| インデックス追加 | CREATE INDEX | CREATE INDEX CONCURRENTLY |
| 外部キー追加 | そのまま ADD CONSTRAINT | NOT VALID で追加 → 後から VALIDATE CONSTRAINT |
| CHECK 制約追加 | そのまま追加 | NOT VALID → VALIDATE |
| NOT NULL 追加 | 直接 SET NOT NULL | CHECK (col IS NOT NULL) NOT VALID → VALIDATE → SET NOT NULL |
| カラムの型変更 | ALTER COLUMN TYPE(テーブル書き換え) | 新カラム追加 → バックフィル → 切り替え → 旧カラム削除 |
| カラム名変更 | いきなり RENAME | 新旧両対応のアプリをデプロイしてから段階的に移行 |
-- 外部キーを安全に追加する例
ALTER TABLE orders
ADD CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES users (id) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_user_fk;- 大量データの UPDATE(バックフィル)は 小さなバッチに分けて実行し、長いトランザクションを作らない。
- 定数のデフォルト値付きカラム追加(
ADD COLUMN ... DEFAULT 0)は PostgreSQL 11 以降テーブル書き換えが発生しないため高速です。
12. 監視
必ず入れておきたい拡張: pg_stat_statements
# postgresql.conf
shared_preload_libraries = 'pg_stat_statements'CREATE EXTENSION pg_stat_statements;
-- 合計実行時間が長いクエリ Top 10
SELECT calls, round(total_exec_time) AS total_ms,
round(mean_exec_time, 2) AS mean_ms, query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;見ておくべき指標
| 指標 | 確認方法の例 |
|---|---|
| 遅いクエリ | pg_stat_statements、スロークエリログ |
| 接続数・状態 | pg_stat_activity |
| 長時間トランザクション | pg_stat_activity の xact_start |
| ロック待ち | pg_locks と pg_blocking_pids() |
| キャッシュヒット率 | pg_stat_database(目安 99% 以上) |
| dead tuple・VACUUM 状況 | pg_stat_user_tables |
| 未使用インデックス | pg_stat_user_indexes の idx_scan = 0 |
| レプリケーション遅延 | pg_stat_replication |
| XID 周回までの余裕 | age(datfrozenxid) |
| ディスク使用量 | pg_database_size()、pg_total_relation_size() |
-- 長時間実行中のクエリ
SELECT pid, now() - query_start AS duration, state, query
FROM pg_stat_activity
WHERE state <> 'idle' AND now() - query_start > interval '1 minute'
ORDER BY duration DESC;
-- ブロックしている/されているセッション
SELECT pid, pg_blocking_pids(pid) AS blocked_by, query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
-- 使われていないインデックス
SELECT schemaname, relname, indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;未使用インデックスを削除する前に、統計がリセットされた時期とレプリカ側での利用有無も確認してください。
13. パーティショニング
- 巨大なテーブル(目安として数千万〜数億行以上) や、古いデータを定期的に削除するテーブルに有効です。
- 古いパーティションを
DROP/DETACHするだけで削除でき、大量 DELETE と VACUUM のコストを避けられます。
- 古いパーティションを
- 時系列データには 範囲(RANGE)パーティショニング が一般的です。
- クエリの WHERE 句にパーティションキーが含まれないと、全パーティションを走査してしまいます。
- パーティションを細かくしすぎるとプランニングが遅くなるので、数百程度までに抑えます。
- パーティションの作成・削除は
pg_partmanなどで自動化すると楽になります。
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
occurred_at timestamptz NOT NULL,
payload jsonb,
PRIMARY KEY (id, occurred_at) -- 主キーにパーティションキーを含める必要がある
) PARTITION BY RANGE (occurred_at);
CREATE TABLE events_2026_10 PARTITION OF events
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');14. アンチパターン集
| アンチパターン | 問題 | 対策 |
|---|---|---|
| 全カラムにインデックス | 書き込み低下・容量増加 | クエリに合わせて作る |
int の主キー | 21億件で枯渇 | 最初から bigint |
timestamp(TZ なし) | タイムゾーンの不整合 | timestamptz |
float で金額管理 | 丸め誤差 | numeric か整数 |
何でも jsonb | 制約・型・統計が効かない | 主要項目は通常カラムに |
| EAV(Entity-Attribute-Value)設計 | クエリが複雑で遅い | 正規化 or jsonb |
大きな OFFSET | 深いページで遅い | キーセットページネーション |
| 長時間トランザクション | bloat・ロック競合 | 処理を分割、タイムアウト設定 |
| アプリがスーパーユーザーで接続 | 被害範囲が最大化 | 権限を分けたロール |
| autovacuum の無効化 | bloat・XID 周回 | 有効のまま調整 |
| 接続数を増やして解決 | メモリ枯渇・性能低下 | 接続プーラー |
| バックアップのリストア未検証 | いざという時に戻せない | 定期的なリストア訓練 |
本番で CREATE INDEX(非 CONCURRENTLY) | 書き込みブロック | CONCURRENTLY |
15. チェックリスト
設計
- すべてのテーブルに主キーがある(
bigint IDENTITYまたは UUIDv7) - 日時は
timestamptz、金額はnumeric/ 整数 -
NOT NULL・外部キー・CHECK制約を定義している - 外部キーの参照側カラムにインデックスがある
クエリ
- 主要なクエリの実行計画を
EXPLAIN (ANALYZE, BUFFERS)で確認した - N+1 や大きな
OFFSETがない - すべてのクエリでパラメータバインドを使っている
運用
-
pg_stat_statementsを有効にしている - スロークエリログを出力している
-
statement_timeout/idle_in_transaction_session_timeoutを設定している - 接続プーラーを導入している(または必要性を検討した)
- autovacuum が正常に動いており、XID の age を監視している
セキュリティ
- アプリ用ロールは最小権限
- 認証は
scram-sha-256、通信は TLS -
pg_hba.confで接続元を制限している
可用性
- PITR 可能なバックアップがある
- リストア手順を実際に試した
- マイグレーションは安全な手順(CONCURRENTLY / NOT VALID /
lock_timeout)で行っている