なんとなく使っている PostgreSQL の中身を、徹底的に解説する
この記事は初心者から中級のエンジニアを、本番運用に耐えるレベルまで一気に引き上げる徹底解説です。全 47 セクション・13 グループにわたって、PostgreSQL の基礎・アーキテクチャ・MVCC・WAL・インデックス・実行計画・レプリケーション・バックアップ・セキュリティまでを、図解とインタラクティブツール、そして実際のコマンド例とともに扱います。
PostgreSQL の世界では、「初歩の入門教材」と「すでにベテラン向けの濃すぎる解説」は豊富にあるものの、その間を埋める中級レベルの教材がほとんど存在しません。入門書を読み終えた後、本番運用に出ていく前に何を学べばいいのか分からない — そういう空白を埋めるための記事です。
したがって対象は中級者だけではありません。SQL を最低限触ったことがある初心者でも、最初の章で基礎をおさらいしてから読み進められるよう構成してあります。「動いている DB の中身がどうなっているのか、ちゃんと理解した状態で本番に出たい」というすべての人に向けて書きました。
全 13 グループの構成です。基礎から順に積み上げていきますが、興味のあるグループから読み始めても OK です。無料と有料の境目も合わせて記載しています。
この章ではまず、PostgreSQL を本格的に深掘りする前に押さえておきたい基礎をおさらいします。「PostgreSQL とは何か」「どんな立ち位置の DB か」「どんな階層構造で、どんなデータ型があり、どうやって操作するか」「トランザクションとは」といった共通言語をここで揃え、次章以降の内部解剖に備えます。すでに知っている内容は読み飛ばしてもらって構いません。
PostgreSQL(ポストグレス、または PG)は、1986 年にカリフォルニア大学バークレー校で生まれたオープンソースのオブジェクト関係データベース管理システム (ORDBMS) です。単なる RDBMS ではなく、ユーザー定義型・継承・複雑なクエリ・JSON ネイティブサポートなど、オブジェクト指向の概念を取り込んでいるのが名前の由来 (Post-Ingres) です。
まずは PostgreSQL が他の RDBMS とどう違うのか、業界での立ち位置から見ていきます。
| DB | 分類 | 強み | 弱み |
|---|---|---|---|
PostgreSQL | OSS / Object-Relational | 機能の豊富さ・標準 SQL 準拠・拡張性 | スキーマレス用途では NoSQL に劣る |
MySQL | OSS (Oracle 所有) | シンプルさ・読み取り速度・運用ナレッジ | 機能セットが限定的・ストレージエンジン依存 |
Oracle DB | 商用 (高額) | エンタープライズ機能・サポート・成熟度 | コスト・複雑さ・ベンダーロックイン |
SQLite | OSS / 組込み | 組込み・軽量・ファイル 1 つ | 同時書込が苦手・大規模不向き |
PostgreSQL が選ばれる場面は明確です。「複雑なクエリ・データ整合性・拡張性」が 重要なシステム——金融・地理情報・大規模 EC・分析基盤など。逆に、シンプルな KVS で済むキャッシュ層や、 書込が極端に少ない組込み用途では、別の選択肢のほうが適しています。
ここから先を読み進めるにあたって、PostgreSQL の「データの入れ物の階層」を最初に押さえます。本記事で「データベース」「スキーマ」といった用語が頻繁に出てきますが、これらは MySQL や Oracle と意味が少し違います。
階層は3 層です。
initdb で初期化される単位で、データは PGDATA/ ディレクトリに置かれる。-d で 1 つを選ぶ。別 DB の中身は同じ SQL では参照不可。CREATE DATABASE で増やせる。public。スキーマ修飾は schema_name.table_name と書きます (例: audit.event_log)。明示しなければ search_path 設定 (デフォルト "$user", public) に従って解決されます。
PostgreSQL のアクセス制御は「ロール」という単位で行われます。他の DB と違って、PostgreSQL ではユーザーもグループも区別せずすべて「ロール」で扱います。区別は属性の違いだけです。
CREATE USER は CREATE ROLE ... LOGIN のショートカット。CREATE GROUP は CREATE ROLE ... NOLOGIN のショートカット。-- ① ユーザー (ログイン可能なロール) CREATE ROLE alice WITH LOGIN PASSWORD 'xxx'; -- ② グループ (ログイン不可、権限の束) CREATE ROLE app_team NOLOGIN; GRANT SELECT, INSERT, UPDATE ON orders TO app_team; -- ③ alice を app_team のメンバーに GRANT app_team TO alice; -- → alice は app_team の権限を継承して使える
PostgreSQL は SQL 標準型に加えて、独自の強力な型を多数持っています。日常的に使う主要なものを分類別にまとめました。
smallint2 byte (-32768 〜 32767)integer / int4 byte (約 ±21 億)。最頻出bigint / int88 byte。サロゲートキーは普通こちらnumeric(p, s) / decimal可変精度。金額計算は必ずこれ (float は誤差が出る)real / double precision4 / 8 byte の浮動小数点。誤差ありtext可変長。本番の標準。長さ制限なしvarchar(n)n 文字までの可変長。本質的に text と同じchar(n)固定長 (足りない分を空白埋め)。ほぼ使わないtimestamptz本番標準。タイムゾーン付き。UTC で保存・取り出し時に変換timestampTZ なし。曖昧さの元なので非推奨date日付のみ (時刻なし)time / timetz時刻のみ。あまり使わないinterval期間。"3 days 2 hours" のような値が入るbooleantrue / false / nullbyteaバイナリデータ (画像など)uuid128bit のユニーク ID。gen_random_uuid() で生成jsonbバイナリ JSON。インデックス可、検索高速。JSON は基本これjsonテキスト JSON。jsonb の方が良いケースが多い配列 (例: int[])任意の型を配列に。tags text[] のようにenumCREATE TYPE status AS ENUM (...) で独自列挙型範囲型 (int4range / tstzrange)値の範囲 (期間予約システムなど)inet / cidrIP アドレス / ネットワークpoint / polygon / geometry (PostGIS)地理空間データpsql は PostgreSQL の標準対話クライアントです。SQL を実行するだけでなく、独自のメタコマンド (バックスラッシュコマンド) で DB の状態を素早く確認できる、本番調査の最強の道具です。
# === 接続方法 === # 個別オプションで psql -h db.example.com -p 5432 -U alice -d myapp # 接続文字列 (URI) で psql "postgresql://alice:xxx@db.example.com:5432/myapp" psql "postgresql://alice@db.example.com/myapp?sslmode=require" # 環境変数でも可 export PGHOST=db.example.com PGUSER=alice PGDATABASE=myapp psql # === よく使うメタコマンド === \? -- メタコマンド一覧 (ヘルプ) \h CREATE TABLE -- SQL コマンドの構文ヘルプ \q -- psql 終了 # 一覧系 \l -- 全 DB 一覧 \du -- 全ロール一覧 \dn -- 全スキーマ一覧 \dt -- 現在スキーマのテーブル一覧 \dt public.* -- public スキーマの全テーブル \di -- インデックス一覧 \dv -- ビュー一覧 \df -- 関数一覧 # 詳細系 \d users -- テーブル定義 (列・index・FK 等) \d+ users -- さらに詳しい情報 (サイズ・コメント等) \dt+ -- テーブル一覧 + サイズ情報 \du+ alice -- ロールの詳細属性 # 設定 / 接続 \c warehouse -- 別 DB に接続切替 \conninfo -- 現在の接続情報 \set -- psql 変数一覧 \timing on -- クエリ所要時間を表示 \x -- 拡張表示 (1 カラム 1 行) 切替 # 出力先 / ファイル実行 \o /tmp/out.txt -- 出力をファイルへ \i schema.sql -- SQL ファイルを実行 \copy users TO '/tmp/users.csv' CSV HEADER \copy users FROM '/tmp/users.csv' CSV HEADER
覚えるべき必須メタコマンド: \d (オブジェクト詳細)、\dt (テーブル一覧)、\du (ロール一覧)、\l (DB 一覧)、\timing on (時間計測)、\x (拡張表示)。これだけで本番調査の 80% が回ります。
SQL コマンドは大きく 3 種類に分類されます。これからの章でも頻出する用語なので、ここで整理しておきます。
CREATE TABLEテーブルを作るALTER TABLEテーブル定義を変更する (列追加・型変更・制約追加など)DROP TABLEテーブルを削除するCREATE INDEXインデックスを作るCREATE SCHEMA / DATABASEスキーマ / DB を作るTRUNCATE TABLE全行高速削除 (DDL 扱い、ロールバック可能)SELECTデータを取得する。最頻出INSERT行を追加するUPDATE行を更新するDELETE行を削除するCOPY大量データを高速入出力する (バッチに必須)MERGE / UPSERT (ON CONFLICT)存在すれば更新、なければ挿入BEGIN / START TRANSACTIONトランザクション開始COMMIT変更を確定ROLLBACK変更を取り消しSAVEPOINTトランザクション内に戻り点を作るSET TRANSACTION分離レベル等を設定GRANT権限を付与REVOKE権限を剥奪トランザクションは、複数の SQL 文を「全部成功するか、全部なかったことにする」 1 つの単位として扱う仕組みです。本番運用で最も基本的かつ重要な概念のひとつ。
PostgreSQL を含むリレーショナル DB のトランザクションは、ACID の 4 性質を満たします。
基本的な書き方は次の通りです。
-- ① 普通のトランザクション BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 'alice'; UPDATE accounts SET balance = balance + 100 WHERE id = 'bob'; COMMIT; -- 両方成功してから確定。途中エラーなら何も変わらない -- ② 失敗時の取消 BEGIN; UPDATE orders SET status = 'shipped' WHERE id = 1; -- ↑ ここでエラー or 「やっぱり取消したい」と思ったら ROLLBACK; -- → 何も変わらなかったことになる -- ③ 自動コミット (BEGIN なしの場合) UPDATE accounts SET balance = balance - 100 WHERE id = 'alice'; -- ↑ これ単体で 1 トランザクションとして即コミットされる -- (psql のデフォルト挙動。明示的に BEGIN しなくても整合性は保たれる) -- ④ 安全な実験 (本番で危険な UPDATE を試したい時) BEGIN; UPDATE users SET deleted_at = now() WHERE created_at < '2020-01-01'; SELECT count(*) FROM users WHERE deleted_at IS NOT NULL; -- ↑ 影響件数を確認 ROLLBACK; -- → 結果だけ見て、本当は何もしなかったことにできる
本章では、SQL を投げてから結果が返ってくるまでに PostgreSQL の内部で起きていることを、5 つのステージとして俯瞰します。
私たちは普段「SELECT を投げる → データが返る」というように、SQL の入出力だけを意識して仕事をしています。しかし PostgreSQL の内部では、その 1 行の SQL に対して複数のサブシステムが順番にバトンを渡しながら処理を進めるパイプラインが動いています。具体的には Parser → Rewriter → Planner → Executor → Output という 5 段階で、それぞれが「SQL を構文木に変える」「ビューやルールを展開する」「最適な実行計画を選ぶ」「実際にデータを取りに行く」「結果をクライアントに返す」という別々の役割を担っています。
この 5 ステージの全体像を頭に入れておくと、後続の章で扱う EXPLAIN の読み方、クエリチューニング、パフォーマンス問題の切り分けが圧倒的にスムーズになります。「クエリが遅い時、どのステージが時間を食っているのか」「EXPLAIN が表示している計画は、どの段階の結果なのか」が見えるようになるためです。本章ではまず各ステージの役割を最小限で押さえ、所要時間の目安を整理します。詳細は G4 (15-17 章) で深掘りします。
押さえるべきポイントは Planner (3 番目) です。 ここで「Seq Scan する? Index Scan する? どの結合順がいい?」を判断しています。 EXPLAIN で見えるのは Planner の出力で、本記事の Section 09〜11 で深掘りします。
ここまでが無料公開部分です。次のセクションからは、これらの仕組みを実際に観察し、操作し、調整する方法を プレミアム会員向けに徹底解説していきます。
本章では、PostgreSQL のプロセスモデル全体像について学んでいきます。「PostgreSQL を起動した時に、内部で何が動いているのか」を俯瞰し、後続の章で各プロセスを詳しく見ていくための土台を作ります。
PostgreSQL のアーキテクチャ最大の特徴は マルチプロセスモデル を採用している点にあります。これは、クライアントが PostgreSQL に接続するたびに、OS 上に「新しいプロセスを 1 つ」フォークして割り当てる方式です。たとえば 100 人が同時に接続すれば、サーバ上には 100 個の postgres プロセスが並んで存在することになります。ps コマンドで観察すると、何個も postgres という名前のプロセスが並ぶのはこのためです。
対照的に、MySQL や Oracle はマルチスレッドモデルを採用しており、接続が増えてもプロセスは 1 つのまま、その中でスレッドだけが増えていきます。どちらにも一長一短があり、PostgreSQL がプロセス方式を選んだ理由 (堅牢性とクラッシュ耐性) と、その代償 (接続オーバーヘッドの大きさ) は、後続の接続プーリング章まで一貫して影響してきます。本章ではまず両者の違いを比較し、続いて PostgreSQL の典型的なプロセスツリー、プロセス間の通信手段までを順に押さえます。
図の4 層を順に押さえてください: ① 一番上に親の Postmaster、② 起動時に立ち上がる常駐バックグラウンド、③ 必要に応じて fork される動的プロセス、 ④ 全プロセスが共有する共有メモリと、その下にディスク。この階層構造を頭に入れておくと、後の章で出てくる細かい話が全部繋がります。
ここまでで「どんなプロセスが存在するか」は分かりました。次は「それらが実際の処理ごとにどう連携して動くか」を観察します。下のタブで代表的なユースケースを選ぶと、そのシナリオで動くプロセスが点灯し、データの流れと処理ステップが見えます。
ps auxf | grep postgres で全プロセスツリーが見えます。SELECT * FROM pg_stat_activity で Backend と Background プロセスの状態が分かります。 まずは自分の環境で何が動いているかを観察するのが、アーキテクチャ理解の最良の方法です。本章では、PostgreSQL のすべてのプロセスを束ねる司令塔である Postmaster と、その起動シーケンスについて学んでいきます。
Postmaster は、PostgreSQL クラスタ全体の親プロセスです。サーバを起動した際に、systemd や init から最初に起こされる実体がこの Postmaster で、以降の Checkpointer や WAL Writer、Autovacuum Launcher、そしてクライアント接続を担当する Backend プロセスといった子プロセスは、すべてこの Postmaster から派生 (fork) して生まれます。プロセスツリーで言えば最上位、つまり PostgreSQL の大元に位置するプロセスです。
ここで重要なのは、Postmaster 自身はクエリを実行しないという点です。Postmaster の仕事は次の 4 つに集約されます。① 子プロセスの起動、② 死亡監視と再起動、③ シグナルの中継、④ クライアント接続の受付と Backend プロセスの fork。クエリの実行は子プロセス側の責任で、Postmaster はあくまで「人事と総務」に専念しています。本章ではこの 4 つの責務と、サーバ起動時に何がどの順番で初期化されるか、そしてクラッシュからの自動復旧やシャットダウンの仕組みを順に見ていきます。
異常終了 (kill -9 / OS パニック等) で再起動した時、Postmaster はStartup プロセスを起動して最後の Checkpoint 以降の WAL を全部リプレイします。
# 異常終了後の起動ログ例 LOG: database system was interrupted LOG: database system was not properly shut down; automatic recovery in progress LOG: redo starts at 0/A1B2C000 LOG: redo done at 0/A1F50000 system usage: CPU 1.23s/0.45u sec LOG: database system is ready to accept connections # WAL を 4MB ぶんリプレイした、という意味。 # → checkpoint_timeout を短くしておけば復旧が速い
pg_ctl stop -m smart (デフォルト)pg_ctl stop -m fastpg_ctl stop -m immediateSIGHUP外部から / pg_ctl reloadpostgresql.conf を再読込。子プロセスにも伝播SIGINTpg_ctl stop -m fastFast Shutdown を開始SIGTERMpg_ctl stop -m smart / systemdSmart Shutdown を開始SIGQUITpg_ctl stop -m immediateImmediate Shutdown (即停止)SIGCHLDOS から自動子プロセスが終了したことを Postmaster に通知SIGUSR1プロセス間汎用通知 (バックグラウンドプロセス間連携)kill -9 postmaster は絶対に避けてください。pg_ctl status -D /var/lib/postgresql/16/main で稼働状況、postmaster.pid ファイルで PID やソケット情報が読み取れます。本章では、PostgreSQL でクエリを実際に実行する役割を担う Backend プロセスの内部構造について学んでいきます。
Backend プロセスは、クライアント 1 接続につき 1 つフォークされる、クエリ実行担当のプロセスです。たとえば psql でログインしたり、Web アプリが connection pool 経由で DB に接続した瞬間、Postmaster は裏で 1 つ Backend プロセスを fork し、以降そのプロセスがクライアントとの 1 対 1 のやり取りを担当します。接続が切れるとプロセスも終了します。ps で見える「postgres: user db host idle」のような行が、まさにこれです。
Backend プロセス自体は単一のプロセスですが、その内部にはSQL を受け取って結果を返すまでの一連の処理を分担するサブシステムが組み込まれています。具体的には、SQL 文を解析する Parser、意味解析と書き換えを行う Rewriter、最適な実行計画を選ぶ Planner、そして実際にデータを取りに行く Executor の 4 段構成です。本章ではこの内部構造を順に追い、加えて Backend が確保するメモリ領域、ステートマシン (idle / active / idle in transaction など) の挙動までを扱います。
Backend プロセスは、クエリを受けると4 つのサブシステムを順番に通します。 このパイプラインを正しく理解しないと、なぜ EXPLAIN がそういう計画を出すか、なぜプリペアドステートメントが速いか、といった話が腑に落ちません。 各サブシステムを 1 つずつ深掘りします (Section 15 でさらに詳しく)。
クライアントから届いた SQL 文字列を抽象構文木 (AST) に変換するサブシステム。 単に「文字列 → ツリー構造」ですが、ここで発生するエラーは構文エラー (syntax error)として返ります。
RawStmt という構造体 (まだ意味は知らない)-- 入力 SQL
SELECT name FROM users WHERE id = 5;
-- Parser の出力 (概念的)
SelectStmt
├─ targetList: [ColumnRef("name")]
├─ fromClause: [RangeVar("users")]
└─ whereClause:
A_Expr "="
├─ ColumnRef("id")
└─ A_Const(5)
-- この時点では:
-- ・users テーブルが存在するか知らない
-- ・id カラムが integer かも知らない
-- ・5 が int4 か int8 かも未確定Parser が出した「生のツリー」に意味づけを行い、VIEW やルールを展開するフェーズ。実体は parse_analyze + QueryRewriter の 2 段階に分かれます。
5 → integer)Query 構造体 — 意味の確定したツリーSELECT * FROM active_users を元 SELECT に置換CREATE RULE で定義されたルールの適用SELECT FOR UPDATE 系のロック指定の正規化relation "foo" does not exist、column "bar" does not exist、function f(integer) does not exist など。 これらは「構文は正しいが、DB の現状とマッチしない」エラーです。PostgreSQL の頭脳。同じ Query を実行する方法は無数にあるため、統計情報とコストモデルから最も速い計画を見つけ出します。 ここでの判断が悪いと、本来 0.1 秒で済むクエリが 10 秒かかったりします。
random_page_cost 等のパラメータで評価Planner の判断はすべて pg_statistic に蓄積された統計情報に依存しています。 ANALYZE が古いと「100 万行返ると思って Hash Join 選んだら実は 5 行だった」のように桁違いに外すことになります。
-- 統計が利用可能か確認 SELECT relname, n_live_tup, last_analyze, last_autoanalyze FROM pg_stat_user_tables WHERE relname = 'orders'; -- 重要カラムは統計の解像度を上げる ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000; ANALYZE orders; -- ヒストグラム を見る SELECT * FROM pg_stats WHERE tablename = 'orders' AND attname = 'status';
CREATE STATISTICS で多変量統計、または明示的なヒント拡張 (pg_hint_plan) を検討。Planner が決めたツリー (PlanNode のツリー) をVolcano モデルで実行する部分。 各ノードが「次の 1 行をくれ (next())」と親に呼ばれ、再帰的に子へ伝播 → 子が 1 行返す、を繰り返します。
Seq Scanテーブル全件スキャン (インデックス使わず)Index Scanインデックスから 1 件ずつ取得 → ヒープ参照Index Only Scanインデックス内の値だけで完結 (heap 参照スキップ)Bitmap Heap/Index Scan大量の行を効率取得 (OR 条件などで有利)Nested Loop外側ループ × 内側ループ。小さい × 小さい や Index 利用時に有利Hash Join内側をハッシュ化してから外側を流す。大規模の =JOIN に強いMerge Join両者ソート済みなら 1 パスでマージ。ORDER BY のキー一致時に強いSortwork_mem に収まれば quicksort、超えたら外部ソートAggregateGROUP BY や集計関数 (sum / count) の処理Gather / Gather MergeParallel Worker から結果を集めるAppendパーティション結合・UNION ALLLimit指定件数で打ち切り-- SELECT name FROM users WHERE id > 5 ORDER BY name LIMIT 10;
Limit (10)
└→ next() を 10 回呼ぶ
└→ Sort (ORDER BY name)
└→ 子から全行取って一気にソート
└→ Index Scan (id > 5)
└→ Btree を順に辿って 1 件ずつ next() で返す
-- 各ノードは「次の 1 行を返す」だけのシンプルなインターフェース
-- これを組み合わせるだけで複雑な SQL が表現できるjit_above_cost = 100000)。クライアント → 文字列 "SELECT ..." → Backend
│
┌────▼────┐
│ Parser │ → 構文木 (RawStmt)
└────┬────┘
▼
┌─────────┐
│Analyzer │ → 意味確定 (Query)
│+Rewriter│ → VIEW/RLS 展開
└────┬────┘
▼
┌─────────┐
│ Planner │ → 最適計画 (PlannedStmt)
└────┬────┘
▼
┌─────────┐
│Executor │ → 実データ取得・処理
└────┬────┘
▼
結果をクライアントへ返却work_memデフォルト 4MBSort・Hash・Materialize 1 操作あたりの最大メモリ。複数操作なら倍数を消費temp_buffersデフォルト 8MB一時テーブル用のローカルバッファ。セッション毎に独立maintenance_work_memデフォルト 64MBVACUUM・CREATE INDEX 等の保守作業用カタログキャッシュ不定 (リレーション数依存)pg_class・pg_attribute 等のメタデータをプロセス内にキャッシュプラン/クエリキャッシュ不定Prepared Statement の実行計画を保持idle in transaction は「BEGIN したまま COMMIT/ROLLBACK しないで何もしていない状態」。 これがあると、VACUUM が古いタプルを回収できない・ロックが保持されたままになり、本番障害の主要原因の 1 つです。
-- idle in transaction を発見 SELECT pid, usename, state, now() - state_change AS duration, query FROM pg_stat_activity WHERE state = 'idle in transaction' ORDER BY duration DESC; -- タイムアウトを設定 (本番では必須) ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min'; ALTER SYSTEM SET statement_timeout = '30s'; SELECT pg_reload_conf();
activeクエリ実行中。CPU/IO を消費しているidleクエリ待ち。コネクションは生きているが何もしていないidle in transactionBEGIN 後・COMMIT 前で何もしていない (危険)idle in transaction (aborted)トランザクションが失敗状態で ROLLBACK 待ちfastpath function call高速関数呼び出し中 (まれ)disabledtrack_activities = off の場合のみLISTEN channel でクライアント間メッセージ通知を受け取れます。 簡易的な pub/sub として使えますが、メッセージは永続化されないので Kafka 等の代替にはなりません。本章では、PostgreSQL の裏側で常時動き続けているバックグラウンドプロセス群について学んでいきます。
バックグラウンドプロセスとは、クライアントの SQL とは無関係に PostgreSQL が勝手に動かしている裏方の常駐プロセスのことです。クエリを 1 つも投げていない状態でも、ps で見ると checkpointer や autovacuum launcher など、複数の postgres: プロセスが並んでいるのが見えます。これらが各々「ディスクへの書き出し」「WAL ログの記録」「不要になった行の掃除」といった運用維持の仕事を分担して担当しており、DB が止まらず動き続けるための心臓部となっています。
本章ではこれらすべてのバックグラウンドプロセスを 1 つずつ取り上げ、「なぜそのプロセスが存在するか」「どのような動作をしているか」「本番でどんな問題を起こし得るか」を網羅的に解説します。各プロセスの役割を把握しておくと、本番運用中に「このプロセスが多すぎる」「このプロセスの動きが遅い」といった異常に気付けるようになり、トラブル時の原因特定が格段に早くなります。
bgwriter_delay = 200msbgwriter_lru_maxpages = 100bgwriter_lru_multiplier = 2.0bgwriter_flush_after = 512kBpg_stat_bgwriter (buffers_clean / buffers_backend / buffers_alloc)pg_wal/ ディレクトリに無限に溜まってディスクを使い切る、② クラッシュ後の起動で「最初の WAL から最新まで全部リプレイ」するのに何時間もかかる、という致命的な問題が起きます。ゲームに例えるなら「オートセーブが一度も走らないまま遊び続けている」ような危険な状態です。checkpoint_completion_target を 0.9 にして書き出しを時間で分散する設定が本番では定石です。checkpoint_timeout = 5minmax_wal_size = 1GBmin_wal_size = 80MBcheckpoint_completion_target = 0.9checkpoint_flush_after = 256kBpg_stat_bgwriter.checkpoints_timed (時間トリガ) / checkpoints_req (サイズトリガ)synchronous_commit = off を選べば、WAL Writer の書き出しすら待たずにコミット成功を返せるようになり、クラッシュ時のデータロスを許容する代わりに極限まで応答速度を上げられます (非同期コミットの仕組み)。wal_writer_delay = 200mswal_writer_flush_after = 1MBsynchronous_commit = on/off/local/remote_write/remote_applypg_stat_wal (wal_records / wal_bytes / wal_write_time / wal_sync_time)pg_wal/archive_status/ に対応する .ready という小さなマーカーファイルを作ります。Archiver はこのディレクトリを 1 秒間隔で監視し、新しい .ready を見つけると、postgresql.conf で指定した archive_command (例: aws s3 cp %p s3://bucket/%f) を実行して該当 WAL ファイルを退避します。成功すると .ready を .done にリネームし、その WAL は PostgreSQL に「もう削除して良い」と通知されます。pg_wal/ ディレクトリが無限に肥大化し、最終的にディスク満杯で本番停止という事故になります。本番では pg_stat_archiver.last_failed_time の監視が必須です。.ready ファイルを見つけたら archive_command 実行.done にリネーム、失敗なら次回再試行pg_stat_archiver.last_failed_time を必ず監視せよ。archive_mode = onarchive_command = 'aws s3 cp %p s3://bucket/wal/%f'archive_timeout = 0 (= 0 だと WAL 満杯まで待つ)archive_library = ... (PG 15+ の代替)pg_stat_archiver (archived_count / failed_count / last_failed_wal)autovacuum_naptime (デフォルト 1 分) ごとに目を覚まし、クラスタ内の各データベースを順に巡回します。テーブルごとに pg_stat_user_tables を確認し、① デッドタプル数が閾値を超えているか、② INSERT が増えすぎていないか、③ XID 年齢が古くなりすぎていないか、のいずれかが該当すれば Autovacuum Worker を fork して実際の掃除を任せます。autovacuum_max_workers (デフォルト 3) で決まります。本番ではテーブル数に応じて 5〜10 まで増やすことが多いです。autovacuum = on (絶対 OFF にしない)autovacuum_naptime = 1minautovacuum_max_workers = 3autovacuum_vacuum_threshold = 50autovacuum_vacuum_scale_factor = 0.2pg_stat_user_tables (last_autovacuum / n_dead_tup)maintenance_work_mem 上に記録します。次にIndex 掃除として、そのテーブルの全インデックスを 1 つずつスキャンして該当エントリを削除します。続いてHeap クリーンでテーブル本体のページを掃除し、可視性マップ (VM) を更新します。最後に必要に応じて統計情報も更新します。VACUUM PARALLEL N で複数インデックスを並列処理できるようになりました。idle in transaction セッションがあるだけで全テーブルの掃除が止まることもあり、statement_timeout や idle_in_transaction_session_timeout の設定が Autovacuum がちゃんと進むかどうかにも直結します。autovacuum_vacuum_cost_limit = 200autovacuum_vacuum_cost_delay = 2msautovacuum_vacuum_insert_scale_factor = 0.2 (PG 13+)pg_stat_progress_vacuum (進捗) / pg_stat_user_tables.last_autovacuumpg_subscription ビューを定期的に確認します。有効化されているのにまだ Apply Worker が起動していない Subscription を見つけると、Worker を fork して起動します。Apply Worker が異常終了した場合の自動再起動も担当します。max_logical_replication_workers (デフォルト 4) の上限に注意が必要です。これを超える Subscription を作ろうとすると、起動できない Subscription が出てきます。Subscription を増やす時は max_worker_processes 全体も合わせて増やしておく必要があります。max_logical_replication_workers = 4max_sync_workers_per_subscription = 2pg_stat_subscription (subscribed Worker の数と状態)pg_stat_subscription の last_msg_send_time や pg_subscription_rel.srsubstate を継続監視するのが必要です。pg_subscription_rel.srsubstate が r (ready) でなくなったら要調査。streaming = on (PG 14+ で in-progress txn を即時適用)binary = true (バイナリ高速)two_phase = true (PG 15+)pg_stat_subscription.received_lsn / latest_end_lsn / last_msg_send_timeps コマンドで postgres: walsender ... という表示で見えます。pg_wal/ 配下の新しい WAL レコードを順次読み取り、TCP ストリームで Subscriber に逐次送信し続けます。物理レプリならバイト列をそのまま、論理レプリなら pgoutput で論理メッセージに変換してから送ります。restart_lsn を更新します。これが Primary 側にとって「この位置以前の WAL はもう削除して良い」というマーカーになります。max_slot_wal_keep_size (PG 13+) で上限を設定するのが安全策です。同時稼働数は max_wal_senders (デフォルト 10) で制限されます。max_slot_wal_keep_size (PG 13+) を設定しないとディスク満杯リスク。pg_stat_replication で write_lag/flush_lag/replay_lag を必ず監視。max_wal_senders = 10wal_keep_size = 0 (スロット使うなら 0 OK)max_slot_wal_keep_size = 50GB (PG 13+)pg_stat_replication (write_lag / flush_lag / replay_lag / sent_lsn / flush_lsn)pg_wal/ ディレクトリに書き込みます。Standby 1 台につき 1 プロセスです。wal_receiver_timeout (デフォルト 60 秒) 経過後に Receiver が異常を検知して再接続を試みます。長時間切断していると pg_stat_wal_receiver.status で streaming → disconnected の遷移が見えます。設定ミス (primary_conninfo の typo、認証エラーなど) も Receiver のログから判別できます。primary_conninfo = ...primary_slot_name = ...wal_receiver_timeout = 60swal_receiver_status_interval = 10spg_stat_wal_receiver (status / written_lsn / flushed_lsn / latest_end_lsn)shutdown checkpoint が記録されていれば正常終了と判断して即座に通常モードへ。されていなければクラッシュからの起動と判断し、データの整合性を取り戻す必要があります。pg_control ファイル (起動制御情報を持つ重要なファイル) から「最後の Checkpoint LSN」を読み取り、そこから順に WAL レコードを再生 (Redo) して、最新のコミット状態までデータベースを巻き戻します。これが完了してConsistency Point に達すると、通常モードに移行して Postmaster がクライアント接続を受け付け始めます。recovery_target_time や recovery_target_lsn で指定された地点まで WAL を再生する役目を担います。max_wal_size を大きくしている本番だと、クラッシュ前に溜まっていた WAL の量に比例して復旧時間が伸びます。pg_stat_activity で backend_type = startup を確認すると進行中かどうかが分かります。recovery_target_time = ...recovery_target_lsn = ...restore_command = 'aws s3 cp ...'recovery_target_action = 'promote' / 'pause' / 'shutdown'pg_stat_activity (backend_type = startup) / ログの "redo done"logging_collector = on にしておくと、Postmaster が起動時に Logger を fork します。Logger は全子プロセスの stderr を OS のパイプ経由で受け取り、それを 1 つのログファイルに時刻付きで書き込みます。ファイル名は log_filename (例: postgresql-%Y-%m-%d.log) のパターンで自動命名され、log_rotation_age (1 日) や log_rotation_size (10MB) に達すると自動で新しいファイルに切り替わります (ローテート)。logging_collector = on が定石で、これがないとログ管理がぐっと面倒になります。注意点として、ディスクが満杯になると Logger 自身が止まり、それに依存していたプロセスがフリーズする可能性があるので、ログディスク容量の監視も忘れずに。logging_collector = onlog_directory = 'pg_log'log_filename = 'postgresql-%Y-%m-%d.log'log_rotation_age = 1dlog_rotation_size = 10MBファイル直接 (tail -f /var/log/postgresql/postgresql-*.log)pg_stat_user_tables や pg_stat_database、pg_stat_replication といった pg_stat_* ビューを頼りにします。これらには「テーブルにアクセスされた回数」「インデックスが使われた回数」「I/O 量」「現在実行中のクエリ」など、本番運用に欠かせない情報がリアルタイムに格納されています。pg_stat_tmp/ に書き出します。pg_stat_* ビューを SELECT すると、Collector が pg_stat_tmp/ から値を読み出して返してくれていました。ps コマンドで stats collector という名前のプロセスは見えなくなります。プロセスとしては存在しないが、pg_stat_* ビュー自体は引き続き使えるという形になりました。PG 14 以前から 15 への移行時に stats_temp_directory を tmpfs に置く設定を入れていた場合、設定削除が必要なので注意が必要です。stats_temp_directory = ... (PG 14 以前)track_counts / track_io_timing / track_functions (PG 15+ も同じ)pg_stat_* ビュー全般 / PG 15+ では pg_stat_kcache 等の拡張も追加可pg_cron (DB 内 cron でジョブ実行)、TimescaleDB (時系列データの自動圧縮)、Citus (分散ノードの管理)、pg_partman (パーティション自動メンテ) などの拡張機能は、SQL クエリの裏で常時何かしらのバックグラウンド処理を動かす必要があります。shared_preload_libraries に拡張機能名を指定して PostgreSQL を起動すると、② 拡張機能の _PG_init() 関数が呼ばれて BackgroundWorker を登録し、③ Postmaster が指定タイミング (起動時 or オンデマンド) で fork し、④ 拡張機能のコードが動き始めます。max_worker_processes (デフォルト 8) で決まります。Parallel Query や Logical Replication の Apply Worker もこの枠を共有して消費するので、複数拡張を使う本番環境では max_worker_processes = 16 などに増やしておくのが定石です。pg_stat_activity で backend_type = background worker として表示されます。max_worker_processes = 8 (拡張・Parallel・Logical Rep すべてで共有)shared_preload_libraries に拡張名を追加pg_stat_activity (backend_type = background worker / 拡張名)max_parallel_workers (デフォルト 8)、1 つのクエリで使える数は max_parallel_workers_per_gather (デフォルト 2) で決まります。min_parallel_table_scan_size (テーブルサイズの下限) や parallel_setup_cost (フォーク開始コスト) で発動条件を調整できます。EXPLAIN ANALYZE の Workers Launched 表示で実際に何個 Worker が動いたかを確認できます。min_parallel_table_scan_size や parallel_setup_cost で発動条件を調整。max_parallel_workers = 8max_parallel_workers_per_gather = 2min_parallel_table_scan_size = 8MBparallel_setup_cost = 1000EXPLAIN ANALYZE の "Workers Launched" / pg_stat_activity (backend_type = parallel worker)ここまで各プロセスを個別に詳しく見てきました。最後に「同じ DB でも、どんな状況に置かれるかで各プロセスの忙しさが変わる」ことを観察します。下のタブで運用状況を切り替えると、各プロセスの「状態 (待機 / 通常 / 稼働中 / 高負荷 / 障害)」「活動量」「今やっていること」が連動して変化します。
SELECT pid, backend_type, state, query FROM pg_stat_activity ORDER BY backend_type で、 どの種類のプロセスが何個動いているかが一覧できます。本番監視ではこのクエリを定期実行して、想定外のプロセスがないか見ておくと安心です。本章では、PostgreSQL が物理メモリと物理ディスクをどう使い分けているか、そのメモリ構造とファイルレイアウトについて学んでいきます。
PostgreSQL のメモリは大きく「共有メモリ」と「プロセスローカルメモリ」の 2 系統に分かれています。共有メモリはすべてのプロセスから読み書きできる共通領域で、テーブルやインデックスのキャッシュ (Shared Buffers)、WAL の書き込みバッファ、進行中のトランザクション情報、ロック状態などを保持しています。プロセスローカルメモリは各 Backend プロセスが個別に持つ専用領域で、ソートやハッシュテーブルといったクエリごとの作業領域に使われます。
この 2 系統の使い分けが、PostgreSQL の性能と運用挙動を決めます。たとえば共有メモリ (shared_buffers) を大きくすればキャッシュヒット率は上がりますが、OS キャッシュとの二重化で逆効果になる範囲があります。プロセスローカル (work_mem) は接続数だけ掛け算で消費されるため、安易に増やすと OOM の原因になります。本章ではこの 2 系統の構造を図で俯瞰し、各領域の役割、メモリ使用量の見積もり方、そしてディスク上のデータディレクトリの構成までを一気に整理します。
実 RAM 上での配置と、各プロセスがどこにアクセスするかを 1 枚にまとめた図です。 上部のプロセスローカル (各 Backend が独立) と、中央の共有メモリ (全プロセス共通) の 2 層がポイント。 下層のOS バッファキャッシュ + ディスクも含めて「3 段キャッシュ」として理解すると、I/O チューニング時の判断が早くなります。
共有メモリは PostgreSQL の心臓部。全プロセスが同じメモリ空間を参照することで、複数プロセスが協調してデータベースを動かせるようになっています。 ここでは 12 の主要領域それぞれについて「何のためにあるか」「サイズ感」「運用で気にすべきこと」を深掘りします。
shared_buffers (デフォルト 128MB → 本番は RAM の 25%)wal_buffers (デフォルト 16MB、自動的に shared_buffers/32)数 MB 程度 (XID 数に比例、自動拡張)数 MB (使用頻度に比例)小さい (SAVEPOINT 数に比例)max_connections × 数百 bytemax_locks_per_transaction × max_connections × 約 270 bytemax_pred_locks_per_transaction に比例小さい (テーブル数に比例)pgstat_* 関数で更新、参照側は pg_stat_* ビュー経由でアクセス。max_replication_slots × 約 1KB統計用 (小)max_worker_processes × 数百 byte各 Backend プロセスが独立で確保する領域。「同時 100 接続 × work_mem 4MB = 400MB」のように掛け算で増えるのがポイントです。
work_memSort・Hash・Materialize 用。1 クエリ内の操作毎・並列ワーカー毎に確保maintenance_work_memVACUUM・CREATE INDEX 用。同時実行が少ないので大きく設定可temp_buffers一時テーブル用ローカルバッファ。セッション単位で独立カタログキャッシュpg_class・pg_attribute 等。テーブル/カラム数で増大プラン/クエリキャッシュPrepared Statement のプランを保持CurrentMemoryContextクエリ内で動的確保される作業領域。Executor が頻繁に使うクライアントとのソケットバッファTCP の送受信用。通常は OS 管理# 共有メモリの最低必要量
共有メモリ = shared_buffers
+ wal_buffers
+ max_connections × (proc 管理領域 ~10KB)
+ max_locks_per_transaction × max_connections × (ロック領域 ~270 byte)
+ max_replication_slots × (~数 KB)
+ その他 (CLOG, MultiXact 等)
# プロセスローカルメモリの最大量
ローカル = max_connections
× work_mem
× 想定同時 sort/hash 操作数
+ max_connections × (バックエンド基本 ~10MB)
+ autovacuum_max_workers × maintenance_work_mem
# 全体
合計 = 共有メモリ + ローカル合計 + OS バッファキャッシュ + その他オーバーヘッドPG の物理ファイルは data_directory (通常 /var/lib/postgresql/16/main) に集約されています。ここの構造を理解すると、トラブル時の調査が早くなります。
base/DB 毎のサブディレクトリ。実テーブル・インデックスのデータファイルbase/<oid>/<relfilenode>実際のデータファイル。1 ファイル 1GB 制限、超えると .1, .2 と分割global/クラスタ全体で共有するシステムテーブル (pg_database 等)pg_wal/WAL ファイル群 (16MB 固定)。アーカイブされたら削除されるpg_xact/CLOG (Commit Log) の永続化先pg_multixact/MultiXact 情報pg_subtrans/サブトランザクション情報pg_tblspc/テーブルスペースへのシンボリックリンクpg_replslot/レプリケーションスロットの状態pg_logical/論理レプリの状態管理pg_stat_tmp/統計の一時ファイル (PG 14 以前)pg_log/ または log/PG ログファイル (logging_collector が ON の場合)postgresql.conf主要設定ファイルpg_hba.conf認証設定pg_ident.confユーザー名マッピングpostmaster.pid稼働中の Postmaster の PID とソケット情報PG_VERSIONクラスタのバージョン番号 (1 行)-- テーブル名 → 物理ファイルパスを引く
SELECT pg_relation_filepath('orders');
-- → base/16384/16720
-- relfilenode 取得
SELECT oid, relname, relfilenode, pg_relation_size(oid) AS size
FROM pg_class
WHERE relname = 'orders';
-- 注意:
-- ・relfilenode は VACUUM FULL や TRUNCATE で変わる
-- ・oid は変わらない (PG 内部の識別子)
-- ・1GB を超えると .1, .2 ... と分割される
-- ファイルサイズ確認
ls -lh /var/lib/postgresql/16/main/base/16384/16720*rm したりすると即破損します。 postgresql.conf 以外はすべて「PG が自分で管理するもの」と覚えてください。SELECT pg_size_pretty(pg_database_size(current_database())) で DB 全体、pg_table_size('orders') でテーブル単独、pg_indexes_size('orders') でインデックス合計サイズが取れます。本章では、PostgreSQL の挙動を制御する設定ファイル群と、その反映の仕組みについて学んでいきます。
PostgreSQL の動作は 4 つの設定ファイルと、それらを補うパラメータ階層によってコントロールされます。postgresql.conf がメイン設定、pg_hba.conf が接続認証ルール、pg_ident.conf が OS ユーザー名と DB ロール名のマッピング、postgresql.auto.conf が ALTER SYSTEM による動的書き換え用 — それぞれが役割を分担し、組み合わさって 1 つの挙動を決めます。
「どのファイルに何を書くべきか」「どうやって反映させるか」「どこから上書きされる可能性があるか」を把握していないと、本番で「設定したはずなのに効かない」「再起動を忘れていて反映されない」「auto.conf が勝手に上書きしている」といったトラブルに直面します。本章では、4 つのファイルそれぞれの役割と書き方、パラメータが反映されるタイミング (context)、reload と restart の使い分け、そして設定のベストプラクティスまでを順に整理します。
postgresql.confpg_hba.confpg_ident.confpostgresql.auto.conf| context | 反映タイミング | 例 |
|---|---|---|
| internal | 変更不可 (コンパイル時) | block_size, wal_block_size |
| postmaster | 完全再起動が必要 | shared_buffers, max_connections, port |
| sighup | pg_ctl reload (再起動なし) | work_mem, log_*, autovacuum, archive_* |
| superuser-backend | 次の接続から (スーパーユーザーのみ) | log_connections |
| backend | 次の接続から | client_encoding |
| superuser | 即時 (スーパーユーザーのみ) | log_min_messages |
| user | 即時 (誰でも SET 可) | search_path, work_mem (上書き) |
postmaster なのに reload しかしていない、あるいはauto.confで上書きされていてファイルだけ書き換えても効かない。 必ず SHOW パラメータ名 で実値を確認しましょう。メイン設定ファイル。1,000 以上のパラメータがあり、13 のカテゴリに分かれています。 ファイルの場所は SHOW config_file; で確認できます (典型的には /etc/postgresql/16/main/postgresql.conf)。
全体は # から始まるコメント + key = value 形式。 値の単位は MB / GB (メモリ)、ms / s / min / h (時間)、on / off (真偽値) など型に応じて。 引用符が必要なのは文字列 (listen_addresses = '*') のみで、数値・enum は不要。
data_directory / config_file / hba_filelisten_addresses / port / max_connections / sslshared_buffers / work_mem / maintenance_work_mem / effective_cache_sizewal_level / max_wal_size / checkpoint_timeout / archive_*max_wal_senders / max_replication_slots / hot_standby / synchronous_standby_namesrandom_page_cost / effective_io_concurrency / default_statistics_targetlogging_collector / log_directory / log_min_duration_statement / log_line_prefixautovacuum / autovacuum_max_workers / autovacuum_*_scale_factorsearch_path / timezone / statement_timeout / idle_in_transaction_session_timeoutdeadlock_timeout / max_locks_per_transactionstandard_conforming_strings / backslash_quoterestart_after_crashshared_preload_libraries / pg_stat_statements.* / auto_explain.*以下、本番でよく触る 34 個のパラメータについて、 「何をする値か」「デフォルト」「推奨値とその理由」「よくあるミス」を 1 つずつ解説します。
listen_addressesdefault: 'localhost'portdefault: 5432max_connectionsdefault: 100superuser_reserved_connectionsdefault: 3shared_buffersdefault: 128MBeffective_cache_sizedefault: 4GBwork_memdefault: 4MBmaintenance_work_memdefault: 64MBwal_buffersdefault: -1 (自動)temp_buffersdefault: 8MBwal_leveldefault: replicamax_wal_sizedefault: 1GBmin_wal_sizedefault: 80MBcheckpoint_timeoutdefault: 5mincheckpoint_completion_targetdefault: 0.9archive_modedefault: offarchive_commanddefault: ''wal_compressiondefault: off (PG 15+ は on)max_wal_sendersdefault: 10max_replication_slotsdefault: 10max_slot_wal_keep_sizedefault: -1 (無制限)hot_standbydefault: onsynchronous_standby_namesdefault: ''synchronous_commitdefault: onautovacuumdefault: onautovacuum_max_workersdefault: 3autovacuum_naptimedefault: 1minautovacuum_vacuum_scale_factordefault: 0.2autovacuum_vacuum_thresholddefault: 50autovacuum_vacuum_cost_limitdefault: 200autovacuum_freeze_max_agedefault: 200000000 (2 億)logging_collectordefault: offlog_directorydefault: 'log'log_filenamedefault: 'postgresql-%Y-%m-%d_%H%M%S.log'log_min_duration_statementdefault: -1 (無効)log_line_prefixdefault: '%m [%p] 'log_lock_waitsdefault: offlog_checkpointsdefault: off (PG 15+ は on)log_connections / log_disconnectionsdefault: offlog_temp_filesdefault: -1random_page_costdefault: 4.0seq_page_costdefault: 1.0effective_io_concurrencydefault: 1default_statistics_targetdefault: 100jitdefault: onstatement_timeoutdefault: 0 (無制限)idle_in_transaction_session_timeoutdefault: 0 (無制限)lock_timeoutdefault: 0 (無制限)idle_session_timeoutdefault: 0 (無制限、PG 14+)deadlock_timeoutdefault: 1sshared_preload_librariesdefault: ''pg_stat_statements.maxdefault: 5000pg_stat_statements.trackdefault: top# ─── 接続 ─── listen_addresses = '*' port = 5432 max_connections = 200 # PgBouncer 併用前提 superuser_reserved_connections = 5 # ─── メモリ (RAM 16GB を想定) ─── shared_buffers = 4GB effective_cache_size = 12GB work_mem = 64MB maintenance_work_mem = 1GB wal_buffers = -1 # 自動 (16MB) # ─── WAL / Checkpoint ─── wal_level = replica max_wal_size = 4GB min_wal_size = 1GB checkpoint_timeout = 5min checkpoint_completion_target = 0.9 archive_mode = on archive_command = 'aws s3 cp %p s3://bucket/wal/%f' wal_compression = zstd # PG 15+ # ─── Replication ─── max_wal_senders = 10 max_replication_slots = 10 max_slot_wal_keep_size = 50GB hot_standby = on synchronous_commit = on # ─── Autovacuum ─── autovacuum = on autovacuum_max_workers = 4 autovacuum_naptime = 1min autovacuum_vacuum_scale_factor = 0.1 autovacuum_vacuum_cost_limit = 2000 # ─── Logging ─── logging_collector = on log_directory = 'pg_log' log_filename = 'postgresql-%Y-%m-%d.log' log_min_duration_statement = 1000 log_line_prefix = '%t [%p]: db=%d,user=%u,app=%a,client=%h ' log_lock_waits = on log_checkpoints = on log_connections = on log_disconnections = on log_temp_files = 0 # ─── プランナー (SSD 想定) ─── random_page_cost = 1.1 effective_io_concurrency = 200 default_statistics_target = 100 # ─── タイムアウト (絶対設定) ─── statement_timeout = 30s idle_in_transaction_session_timeout = 5min lock_timeout = 1s idle_session_timeout = 30min # PG 14+ # ─── 拡張機能 ─── shared_preload_libraries = 'pg_stat_statements,auto_explain' pg_stat_statements.max = 10000 pg_stat_statements.track = all
ホストベース認証 (Host-Based Authentication) の設定ファイル。 「誰がどこから何の方式でログインできるか」を 5 列 (+ 1 オプション列) で上から順に評価し、最初にマッチしたルールで認証します。 順序を間違えると、強い認証ルールが弱いルールに上書きされる事故が起きるので並び順が極めて重要です。
ファイル場所は SHOW hba_file; で確認可能。編集後は SELECT pg_reload_conf(); または pg_ctl reload で反映 (再起動不要)。
# TYPE DATABASE USER ADDRESS METHOD [OPTIONS] local all all peer hostssl mydb app_user 10.0.0.0/8 scram-sha-256 hostssl replication replicator 10.0.1.0/24 scram-sha-256 hostssl all +readonly 10.0.0.0/8 scram-sha-256 hostnossl all all 0.0.0.0/0 reject
localUnix domain socket 経由 (同一ホスト)hostTCP/IP 経由 (SSL あり/なし両方)hostsslTCP/IP かつ SSL/TLS 必須hostnosslTCP/IP かつ SSL/TLS 不可 (通常は reject に使う)hostgssencTCP/IP かつ GSSAPI 暗号化必須hostnogssencTCP/IP かつ GSSAPI 暗号化なしall全 DBreplicationStreaming Replication 用 (特別、通常 DB 接続には適用されない)sameuserユーザー名と同名の DB のみsamerole / +groupメンバーであるロール名と同名の DBmydb,otherdbカンマ区切りで複数指定可@filename外部ファイルからロードall全ユーザー+groupnameロール (グループ) のメンバー全員user1,user2カンマ区切り複数@filenameファイルからロード10.0.0.0/8CIDR 表記。サブネット指定192.168.1.5/32単一 IPsamehost / samenetサーバと同一ホスト / 同一サブネット.example.comドメイン (DNS 逆引き必須、推奨しない)(空欄)local 行では指定不要trustパスワード不要。本番では絶対禁止 ❌reject即拒否。明示的なブラックリスト用scram-sha-256パスワード認証 (推奨 ✓)md5旧パスワード認証。脆弱なので scram に移行すべきpeerOS ユーザー名と一致 (local 専用)certクライアント証明書のみgss / sspiKerberos / Windows SSPIldap / radius / pam外部認証サーバidentidentd プロトコル (古い)METHOD によって追加オプションが指定できます。例: map=usermap1 (pg_ident でマッピング)、clientcert=verify-full (証明書検証)、ldapserver=ldap.example.com (LDAP サーバ)。
① 上から順に評価、② TYPE + DATABASE + USER + ADDRESS がすべて一致した行が採用、 ③ METHOD が reject なら拒否、④ パスワード認証なら入力を検証して可否決定。 ⑤ 一度マッチしたら後続の行は評価されない — 弱いルールが上にあると強いルールが死ぬので注意。
# ── 1. ローカル管理用 (peer = OS user 一致) local all postgres peer # ── 2. アプリケーション接続 (SSL 必須 + scram) hostssl mydb app_user 10.0.0.0/8 scram-sha-256 # ── 3. レプリケーション (専用ユーザー) hostssl replication replicator 10.0.1.0/24 scram-sha-256 # ── 4. 読み取り専用ロール hostssl all +readonly 10.0.0.0/8 scram-sha-256 # ── 5. 非 SSL を明示的に拒否 (フォールバック) hostnossl all all 0.0.0.0/0 reject # ── 6. それ以外も拒否 (デフォルト) host all all 0.0.0.0/0 reject
OS ユーザー名と PostgreSQL ロール名のマッピング表。peer / cert / gss / ldap 認証で、 外部から渡される「ユーザー名」を PG 内の「ロール名」へ変換する時に使います。
単独では機能せず、pg_hba.conf の OPTIONS で map=マップ名 を指定して初めて有効になります。 ファイル場所は SHOW ident_file; で確認可能。
# pg_ident.conf # MAPNAME SYSTEM-USERNAME PG-USERNAME # OS ユーザー deploy → PG ロール app_user として認証可 deploy_map deploy app_user # 複数の OS ユーザーを 1 つの PG ロールに集約 analyst_map alice analyst analyst_map bob analyst analyst_map carol analyst # 正規表現も可 (PG-USERNAME の \1 で参照) regex_map /^(.*)@example\.com$ \1
# pg_hba.conf 側でマップ名を参照 local all all peer map=deploy_map hostssl all +analysts 10/8 cert map=analyst_map
ALTER SYSTEM コマンドが自動で読み書きする設定ファイル。 手で編集してはいけません。SQL 経由でしか書き換えない代わりに、postgresql.conf より後に読まれて優先されるので、本番で動的に設定を変える時の正規ルートになります。
ファイル場所は data_directory 直下 (/var/lib/postgresql/16/main/postgresql.auto.conf など)。 PG 起動時に postgresql.conf → postgresql.auto.conf の順で読まれるため、両方で同じパラメータが設定されている場合は auto.conf が勝ちます。
-- 設定変更 (auto.conf に書き込み) ALTER SYSTEM SET work_mem = '128MB'; -- reload で適用 (context が sighup なら即反映) SELECT pg_reload_conf(); -- 確認 SHOW work_mem; SELECT name, setting, source, context FROM pg_settings WHERE name = 'work_mem'; -- リセット (auto.conf から削除 → postgresql.conf の値に戻る) ALTER SYSTEM RESET work_mem; -- 全リセット ALTER SYSTEM RESET ALL; -- ファイル中身を見る \! cat /var/lib/postgresql/16/main/postgresql.auto.conf
-- セッション内 SET work_mem = '256MB'; -- トランザクション内のみ (推奨) BEGIN; SET LOCAL work_mem = '256MB'; -- 重い集計クエリ COMMIT; -- ↑ COMMIT した瞬間に元の値に戻る (PgBouncer の transaction モードでも安全) -- 特定ロールに永続 ALTER ROLE analyst SET work_mem = '512MB'; -- 特定 DB に永続 ALTER DATABASE warehouse SET work_mem = '256MB'; -- 特定ロール × 特定 DB ALTER ROLE analyst IN DATABASE warehouse SET work_mem = '1GB';
-- シンプル確認
SHOW shared_buffers;
SHOW all;
-- 詳細 (どこから値が来たか分かる)
SELECT name, setting, unit, source, sourcefile, sourceline, context, pending_restart
FROM pg_settings
WHERE name IN ('shared_buffers', 'work_mem', 'max_connections');
-- 再起動が必要な保留中の変更
SELECT name, setting, pending_restart FROM pg_settings WHERE pending_restart;
-- カテゴリ別に見る
SELECT category, name, setting FROM pg_settings WHERE category LIKE '%Replication%';
-- ファイル一覧
SELECT * FROM pg_file_settings;設定ファイルを書き換えても、それだけでは動いている PostgreSQL には伝わりません。 反映させるには reload (再読込) か restart (再起動) のどちらかが必要で、 パラメータごとに「どちらが必要か」が決まっています。これを決めているのが context という属性です。
context とは:各パラメータが「いつ反映されるか」を表す PostgreSQL のメタデータです。SELECT name, context FROM pg_settings; で確認できます。 値は以下の 6 種類です。
internalpostmastersighupsuperuser-backendbackendsuperuser / usersighup / backend / superuser / userpg_ctl reloadsystemctl reload postgresqlSELECT pg_reload_conf(); (SQL からも可)postmasterpg_settings.pending_restart = true で「再起動待ち」のパラメータを確認 → ② Streaming Replica にフェイルオーバー → ③ Primary を restart → ④ Replica に戻す、の手順で実質ダウンタイム数秒に抑えるのが定石です。pg_ctl restart -m fastsystemctl restart postgresql判断フロー: ① パラメータを ALTER SYSTEM か postgresql.conf で変更 → ② SELECT context FROM pg_settings WHERE name = '...'; で確認 → ③ postmaster なら restart、それ以外は reload。 わからない時はとりあえず reload して、SELECT name, pending_restart FROM pg_settings WHERE pending_restart; で「再起動が必要なまま残っているもの」を見つけるのが安全です。
shared_buffers 等を変更した気でいる → pending_restart=true のまま放置される。 ② postgresql.auto.conf と postgresql.conf の両方に同じ設定があり、auto.conf が勝って意図と違う値で動いている。 ③ pg_hba.conf を編集したのに reload を忘れて「認証が変わらない」と悩む。# postgresql.conf 内で他ファイルを include 可能 include = '/etc/postgresql/16/main/memory.conf' include = '/etc/postgresql/16/main/wal.conf' # ディレクトリ全体を読み込み (推奨パターン) include_dir = '/etc/postgresql/16/main/conf.d/' # 環境毎に分けると管理しやすい # conf.d/00-base.conf # conf.d/10-memory.conf # conf.d/20-replication.conf # conf.d/90-overrides.conf ← この順で後勝ち
設定ファイルを直接編集しなくても、SQL コマンドで動的にパラメータを変更できます。スコープ (適用範囲) によって使うコマンドが違うので、それぞれの構文と影響範囲を正確に押さえます。
ALTER SYSTEM SET param = value;postgresql.auto.conf に書き込み、PG 再起動後も保持。実際には pg_reload_conf() で反映 (context = sighup の場合) または再起動が必要 (context = postmaster)。手でファイル編集する代わりにこちらを使うのが現代的。ALTER SYSTEM RESET param;ALTER SYSTEM RESET ALL;ALTER DATABASE name SET param = value;ALTER ROLE name SET param = value;ALTER ROLE name IN DATABASE db SET param = value;SET param = value;SET LOCAL param = value;SET SESSION CHARACTERISTICS AS ...;RESET param;RESET ALL;SHOW param;SHOW ALL で全パラメータ。SHOW shared_buffers;SELECT current_setting('shared_buffers') でも同じ。SELECT pg_reload_conf();pg_ctl reload と同じ効果。context = sighup のパラメータが反映される。SELECT pg_settings.*;SELECT * FROM pg_settings WHERE name = 'work_mem' でパラメータの詳細情報 (現在値・デフォルト・context・上書き元) が見える。SELECT pg_file_settings.*;実運用での組み合わせ例:
-- ① 永続的な設定変更 (SQL で完結、ファイル編集不要)
ALTER SYSTEM SET work_mem = '128MB';
SELECT pg_reload_conf(); -- 反映 (context = sighup なので reload で OK)
SHOW work_mem; -- 確認
-- ② 再起動が必要なパラメータ (context = postmaster)
ALTER SYSTEM SET shared_buffers = '8GB';
SELECT pg_reload_conf(); -- これだけでは反映しない
-- pending_restart で未反映を確認
SELECT name, setting, pending_restart FROM pg_settings WHERE pending_restart;
-- → サーバ再起動が必要
-- ③ 重いバッチクエリで一時的に work_mem を上げる (安全パターン)
BEGIN;
SET LOCAL work_mem = '512MB';
-- 重い集計クエリ
COMMIT; -- 自動で元に戻る (PgBouncer transaction mode でも安全)
-- ④ analyst ロールだけ statement_timeout を緩める
ALTER ROLE analyst SET statement_timeout = '30min';
ALTER ROLE analyst SET work_mem = '512MB';
-- ⑤ 分析用 DB だけメモリ設定を変える
ALTER DATABASE warehouse SET work_mem = '256MB';
ALTER DATABASE warehouse SET random_page_cost = 1.1;
-- ⑥ 設定の追跡 (どこから来ているか)
SELECT name, setting, unit, source, sourcefile, sourceline, context
FROM pg_settings
WHERE name IN ('work_mem', 'shared_buffers', 'statement_timeout');
-- ⑦ ファイルから読み込まれた行を確認 (構文エラー検出にも)
SELECT * FROM pg_file_settings WHERE NOT applied;
-- applied = false の行は構文エラーなどで読み込み失敗
-- ⑧ ALTER SYSTEM の取り消し (auto.conf からの削除)
ALTER SYSTEM RESET work_mem;
SELECT pg_reload_conf();
-- ⑨ 全 ALTER SYSTEM 設定をクリア (auto.conf 完全削除)
ALTER SYSTEM RESET ALL;
SELECT pg_reload_conf();
-- ⑩ シェルから (psql 経由でないと打ちにくいので)
-- $ pg_ctl reload -- 設定再読込
-- $ pg_ctl restart -m fast -- 再起動
-- $ systemctl reload postgresql -- systemd 環境ALTER SYSTEM の落とし穴: ① ALTER SYSTEM はpostgresql.auto.conf に書くだけで反映はしない。必ず pg_reload_conf() か再起動が必要、② postgresql.auto.conf は手で編集してはいけない (ALTER SYSTEM が管理する領域なので整合性が崩れる)、③ postgresql.conf より auto.conf の値が常に優先される (リロードの順序)。
pgtune.leopard.in.ua や Postgres Cluster Cookbook を参考に。テーブル設計は最初にミスると後で取り返しがつかない領域です。 後からインデックスは追加できますが、データ型の変更やキー設計の見直しは、本番運用後では数日のダウンタイムや大量データ移行を伴います。 「10 年運用してもメンテナンスしやすいテーブル設計」を習慣化することが、本章のゴールです。
基本は 3NF (第 3 正規形) まで正規化し、必要に応じて意図的に崩します。
1NF2NF3NFBCNF非正規化| 用途 | 推奨 | 避けたい | 理由 |
|---|---|---|---|
| 可変長文字列 | text | varchar(n) | PG では性能差なし。length 上限は CHECK 制約で |
| 小〜中サイズ整数 | integer (4 byte) | smallint で節約しすぎ | アライメントで結局 4 byte 占有が多い |
| 大きい ID | bigint (8 byte) | integer で 21 億超え | 将来の溢れ防止 |
| 金額 | numeric(N,2) | float, money | 丸め誤差を防ぐ。会計用は numeric 一択 |
| 日時 | timestamptz | timestamp | TZ なしは「いつのこと?」が曖昧。常にtimestamptz |
| 半構造化 | jsonb | json, text+JSON.parse | バイナリ + index 可 |
| 主キー (新規) | bigint generated by default as identity | serial | PG 10+ の標準。serial は古い書き方 |
| 主キー (分散) | uuid (gen_random_uuid) | app 側で生成 + text 保存 | pgcrypto / PG 14+ で標準サポート |
| 真偽値 | boolean | char(1) で T/F | 型安全 + 1 byte |
| 配列 | 基本は別テーブル | text[] / int[] を多用 | JOIN・index 設計が複雑化 |
| IP アドレス | inet / cidr | text | 専用演算子・index 利用可 |
| 範囲データ | tstzrange など範囲型 | 開始/終了の 2 カラム | EXCLUSION 制約で重複防止可 |
PRIMARY KEY行を一意に識別。NULL 不可・暗黙の B-tree indexUNIQUE重複を禁止。NULL は複数許容 (NULLS NOT DISTINCT で PG 15+ から禁止可)FOREIGN KEY参照整合性。ON DELETE CASCADE / RESTRICT / SET NULL のポリシー要設計CHECKカラム単体の値域・複数カラム間の整合性 (例: end_at > start_at)NOT NULLNULL を許可するかは明示的に決める。曖昧にしないEXCLUDE範囲型と組み合わせて「予約時間が重ならない」等の制約。範囲業界の魔法DEFERRABLEトランザクション末まで検査を遅延。FK の循環や入れ替えで使う-- 予約時間の重複禁止 (EXCLUDE)
CREATE EXTENSION btree_gist;
CREATE TABLE reservations (
room_id int,
during tstzrange,
EXCLUDE USING gist (
room_id WITH =,
during WITH &&
)
);
INSERT INTO reservations VALUES (1, '[2026-03-15 10:00, 12:00)');
INSERT INTO reservations VALUES (1, '[2026-03-15 11:00, 13:00)');
-- ERROR: conflicting key value violates exclusion constraint主キー (Primary Key) の選び方には、大きく分けて 2 つの方針があります。サロゲートキー (代理キー) は、業務的な意味を持たない人工的な ID をシステムが自動採番して PK に使う方針です。 一方のナチュラルキー (自然キー) は、メールアドレスや ISBN、社員番号など、業務上もともと一意性を持つ値をそのまま PK に使う方針を指します。
実装面では、サロゲートキーをさらに 連番 (bigint identity) と UUID の 2 種類のどちらで実現するかという選択があり、合わせると現実的な選択肢は次の 3 つになります。PK は後から変えるのが最も難しいテーブル設計要素なので、10 年後の運用を見据えて選びます。
監査用カラム (audit columns) とは、ビジネスデータ本体 (商品名・金額・住所など) とは別に、「このレコードはいつ・誰によって作られ、いつ・誰が最後に変更したのか」というメタ情報を記録するためのカラム群を指します。 ビジネス的には何の価値も生まない、純粋な記録のためのカラムです。
ではなぜわざわざ毎回入れるのか。ここをきちんと押さえていないと、以下のような場面で後から大きな代償を払うことになるからです。
つまり監査用カラムは「普段は使わないが、いざという時に救命具になる」存在です。 そして救命具は事故が起きてから付けても手遅れ。後付けでは過去データに値が入らず、最も知りたい「事故前の状態」が永遠に分からないままになります。だから新規テーブル作成時に必ず最初から入れておく、というのが業界の鉄則です。
標準は次の 4 点セットです。created_at (作成日時) /updated_at (最終更新日時) /created_by (作成ユーザー ID) /updated_by (最終更新ユーザー ID)。 このうち updated_at はアプリ側で更新し忘れるのが最も多いミスなので、DB トリガーで自動的に now() を書き込む構成が定石です。created_by / updated_by はアプリのセッション情報からアプリ側で入れます (誰がログインしているかは DB は知らないので)。
-- どのテーブルにも入れたい 4 つ CREATE TABLE orders ( id bigint generated by default as identity primary key, ... -- 監査カラム (4 点セット) created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), created_by bigint REFERENCES users(id), updated_by bigint REFERENCES users(id) ); -- updated_at の自動更新トリガー CREATE OR REPLACE FUNCTION trg_update_timestamp() RETURNS trigger AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END $$ LANGUAGE plpgsql; CREATE TRIGGER orders_update_ts BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION trg_update_timestamp();
Soft Delete (論理削除) とは、ユーザーが「削除」操作をした時に DELETE 文で実際に行を消すのではなく、deleted_at という日時カラムに削除した時刻を入れて「削除されたことにする」設計パターンです。物理的にはレコードはテーブルに残り続け、アプリは「deleted_at IS NULL の行だけを生きている行として扱う」という暗黙のルールで動きます。
ではなぜわざわざ消さないのか。「DELETE すれば良いじゃん」と思うかもしれませんが、本番システムでは消したくない事情が次々に出てきます:
ここまでは Soft Delete を採用する理由の話です。ただし「deleted_at を足せばそれで終わり」では本番で必ず事故が起きます。 以下の3 つの罠を知らずに導入すると、サービス停止級のトラブルにつながります:
以下は 3 つの罠それぞれに対する具体的な対策コードです。
ALTER TABLE users ADD COLUMN deleted_at timestamptz; -- ❌ 全クエリに WHERE deleted_at IS NULL が必要 → アプリで漏れがち SELECT * FROM users WHERE deleted_at IS NULL; -- ✓ partial index で「生きてる行」だけ高速に CREATE INDEX idx_users_active ON users (email) WHERE deleted_at IS NULL; -- ✓ VIEW で「生存」のみ見せて WHERE 書き忘れを防ぐ CREATE VIEW active_users AS SELECT * FROM users WHERE deleted_at IS NULL; -- ✓ UNIQUE 制約は partial で CREATE UNIQUE INDEX users_email_active ON users (email) WHERE deleted_at IS NULL; -- → 削除済みユーザーと同じ email で再登録可能になる
PostgreSQL の JSONB 型は、1 つのカラムの中にスキーマレスな JSON を丸ごと格納できる特殊な型です。 通常のカラム (text / int / timestamptz) は「1 カラム 1 値」が原則ですが、JSONB は「1 カラムに任意の構造化データ (オブジェクト・配列・ネスト) を入れられる」点が決定的に違います。MongoDB のようなドキュメント DB の世界観を、RDB の中に部分的に取り込める機能と言えます。
ではなぜこの設計判断をテーマにするのか。設計時にもっとも悩ましい「カラムにすべきか、JSONB にまとめるべきか」は、判断を誤ると後からほぼ取り返しがつかない領域だからです。
つまりこの判断は「便利だから JSONB」「型安全だからカラム」のような二択ではなく、そのデータが「将来どう使われるか」で決まります。 判断の決定打になるのは次の 3 つの問いです:
1 つでも YES があればカラムに切り出すべきです。全部 NO のもの (ログ詳細・ユーザー固有設定・解析用メタデータ) だけが JSONB の出番。整理すると次の使い分けになります:
DB 設計をしていると、「固定された数種類の値しか取らないカラム」が頻繁に出てきます。たとえば注文ステータス (pending / shipped / delivered / cancelled)、支払方法、ユーザー権限、優先度などです。これら「有限の選択肢から 1 つを選ぶ型」を、テーブル設計上どう表現するか — それがこの章のテーマです。
ではなぜわざわざ章を立てて議論するのか。「普通に text で持って、アプリで弾けばいいじゃないか」と思うかもしれません。しかしそれだと本番で必ず想定外の値が入ります。SQL バッチ・データ移行・別アプリからの書き込み・手動 UPDATE など、アプリのバリデーションを通らない経路が必ず存在し、気付くと status カラムに 'Pending' と 'pending ' と 'PENDING' が混在し、集計クエリが崩壊します。だから DB レベルで「決められた値しか入らない」と物理的に保証する必要があるのです。
ところがその「保証する手段」が PostgreSQL には3 通りあり、どれもメリットとデメリットがあります。それぞれの特性を理解せず安易に選ぶと、「本番で値の追加ができない」「削除した値が永遠に残る」「並び順が表示できない」といった運用ハマりに直面します。以下が 3 つの選択肢です:
結論を一言で言うと、「値が今後変わるか」「業務メタデータが必要か」で決まります。 ① 値がコードレベルの定数 (例: 性別・血液型・国コード) → ENUM、 ② 業務側で値の追加削除や並び順の変更が起きる (例: プラン名・タグ・カテゴリ) → Lookup Table、 ③ その中間で、ラベル等のメタデータは要らないが将来追加はあり得る → CHECK 制約、というのが現場の判断軸です。
-- 方式 A: ENUM 型
CREATE TYPE order_status AS ENUM ('pending', 'shipped', 'delivered', 'cancelled');
ALTER TABLE orders ADD COLUMN status order_status;
-- ◯ 型安全・サイズ小
-- ✗ 値追加が ALTER TYPE ADD VALUE (DDL)、削除は更に面倒
-- 方式 B: Lookup テーブル
CREATE TABLE order_statuses (
code text PRIMARY KEY,
label text NOT NULL,
sort_order int NOT NULL
);
ALTER TABLE orders ADD COLUMN status text REFERENCES order_statuses(code);
-- ◯ 追加・削除・並び順管理が楽
-- ✗ JOIN が要る
-- 方式 C: CHECK 制約 + text
ALTER TABLE orders ADD COLUMN status text NOT NULL
CHECK (status IN ('pending', 'shipped', 'delivered', 'cancelled'));
-- ◯ 中間的。値追加は ALTER TABLE で柔軟
-- ✗ メタデータが取れない
-- 使い分け: 値が安定なら ENUM、変動が頻繁なら Lookup Table通常のテーブルは「現在の状態」だけを保持します。たとえば users テーブルの 1 行は、そのユーザーの「いまのメールアドレス・いまの名前・いまの料金プラン」だけが入っており、過去にどんな値だったかは分かりません。これに対して履歴管理とは、テーブルに「いつから、いつまで、どんな値だったか」を保持させる設計のことを言います。
ではなぜわざわざ章を立てるのか。「普通は現在の値だけで十分でしょ」と思うかもしれません。しかし業務システムでは、想像以上の頻度で「過去の値」が必要になります:
ここで困るのが、PostgreSQL には 「ON / OFF にすれば自動で履歴を保持してくれる」標準機能が存在しないことです (SQL Server の Temporal Table のような機能は無い)。代わりに、設計者が用途に応じて以下の 4 つのパターンから選んで自前で組む必要があります。 どれが正解という話ではなく、「何の履歴を、どれくらい遡りたいか」「書き込み性能をどこまで犠牲にできるか」「監査の厳しさ」で使い分けます。複数を組み合わせるケースも普通にあります (例: 重要テーブルは Shadow Table + 全テーブル共通の Audit Log)。
まず命名規則とは、テーブル名・カラム名・インデックス名・制約名などをチーム全体で統一されたルールに沿って付ける取り決めのことです。 技術的には「User」でも「users」でも「user_table」でも動くので、最初は些細な問題に見えます。
ではなぜわざわざ規則を決める必要があるのか。理由は 2 つあります:
以下は業務システムで最低限合わせておきたい慣例です。プロジェクトごとに細かい差はあれど、概ね業界共通の最大公約数となっています。これと異なるルールを採用する場合でも、「なぜ違うのか」を説明できる状態にしておくことが重要です。
users / order_itemscreated_at / user_idcreated_at, deleted_atis_active, has_subscriptionuser_id, order_ididx_orders_user_statususers_pkeyorder ✗ / orders ◯まずスキーマ進化 (schema evolution) とは、本番稼働中のサービスを止めずにテーブル定義 (カラム追加・型変更・制約追加・インデックス追加など) を変えていく手順や考え方のことです。 開発初期のように「夜中にメンテ時間を取って ALTER TABLE 流して終わり」という運用ができる時期はとうに過ぎ、24/7 で動き続けるサービスでも安全に DB スキーマを変えていく必要があります。これを実現するための知識と手順がスキーマ進化です。
ではなぜわざわざ章を立てるのか。「ALTER TABLE ... ADD COLUMN ... を打てば終わりじゃないの?」と思うかもしれませんが、本番では無邪気に打つと数時間単位でサイトが落ちるからです:
逆に言うと、PostgreSQL の現代バージョン (11+) では、メタデータ更新だけで瞬時に終わる ALTERと全行スキャンする ALTER の境界がしっかり整理されており、書き方を知っていればほとんどの変更を瞬時かつ無停止で適用できます。 以下は本番で頻出するパターンを、「ロックを最小限に抑える書き方」と、対比のための「絶対やってはいけない書き方」のセットで示したものです。これらを知っているかどうかで、深夜メンテが必要なケースと不要なケースが分かれます。
-- ❌ 危険: 全行に書き込みが走る ALTER TABLE orders ADD COLUMN status text NOT NULL DEFAULT 'pending'; -- ✓ PG 11+: NULL 可で DEFAULT なしなら即座 (メタデータのみ更新) ALTER TABLE orders ADD COLUMN status text; -- ✓ NOT NULL を後から付ける場合 -- Step 1: NULL 可で追加 ALTER TABLE orders ADD COLUMN status text; -- Step 2: バックフィル (バッチで) UPDATE orders SET status = 'pending' WHERE status IS NULL; -- Step 3: NOT NULL 化 ALTER TABLE orders ALTER COLUMN status SET NOT NULL; -- ✓ FK の追加は NOT VALID で先に飛ばす ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID; -- 既存行の検査を後で ALTER TABLE orders VALIDATE CONSTRAINT fk_user;
PostgreSQL のスキーマは MySQL でいう DB のような名前空間で、1 つの DB 内に複数の論理グループを作れます。マルチテナント・モジュール分離・拡張機能の隔離に使います。
-- スキーマ作成 CREATE SCHEMA tenant_a; CREATE SCHEMA tenant_b; CREATE SCHEMA audit; -- スキーマ内にテーブル作成 CREATE TABLE tenant_a.orders (...); CREATE TABLE tenant_b.orders (...); -- search_path で参照解決の順序を指定 SET search_path TO tenant_a, public; SELECT * FROM orders; -- → tenant_a.orders を参照 -- 用途: -- ① マルチテナント (テナント毎にスキーマ分離) -- ② アプリケーション層分離 (auth / billing / analytics スキーマ) -- ③ 拡張機能を別スキーマに隔離 (pg_stat_statements 等) -- ④ pg_dump --schema=xxx で部分バックアップ
PostgreSQL のテーブルスペースは、テーブルやインデックスを別のディスクに配置する機能。ホットデータを SSD、コールドデータを HDD など、I/O 特性で使い分けられます。
-- 作成 CREATE TABLESPACE fast_ssd LOCATION '/mnt/nvme/pg'; CREATE TABLESPACE cold_hdd LOCATION '/mnt/hdd/pg'; -- テーブルやインデックス毎に指定 CREATE TABLE hot_orders (...) TABLESPACE fast_ssd; CREATE INDEX idx_archive ON archive_logs (id) TABLESPACE cold_hdd; -- DB のデフォルト変更 ALTER DATABASE warehouse SET TABLESPACE fast_ssd; -- 既存テーブルを移動 (重いので注意) ALTER TABLE old_logs SET TABLESPACE cold_hdd; -- 用途: -- ① 頻繁アクセス用 SSD と長期保管用 HDD の使い分け -- ② WAL を別ディスクに分離 (initdb --waldir で初期化時に指定) -- ③ 大きなインデックスだけ別ボリュームへ
PostgreSQL はテーブル継承をサポートしており、子テーブルが親のカラムを引き継げます。 パーティショニングの旧実装の基盤でしたが、現在は宣言的パーティショニング (PG 10+) を使うのが主流。継承自体は共通列を持つ複数テーブルのまとめ管理として使えます。
-- 親テーブル CREATE TABLE entities ( id bigserial PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now() ); -- 子テーブル (entities の列を引き継ぐ) CREATE TABLE users ( name text NOT NULL, email text UNIQUE ) INHERITS (entities); -- entities への SELECT は users も含めて返る SELECT * FROM entities; -- users の行も含まれる SELECT * FROM ONLY entities; -- 親のみ -- 注意点: -- ・FK や UNIQUE は親子間で共有されない -- ・パーティショニングには現代の宣言的構文を使うべき -- ・現在は監査・ロギングテーブルの共通カラム集約くらい
PostgreSQL は独自データ型を作れるのが強力。よく使う 4 種を紹介します。
-- ① Composite Type (構造体)
CREATE TYPE address AS (
street text,
city text,
postal_code text,
country text
);
CREATE TABLE customers (
id bigserial PRIMARY KEY,
shipping address,
billing address
);
-- ② Domain (制約付きの型エイリアス)
CREATE DOMAIN positive_int AS int CHECK (VALUE > 0);
CREATE DOMAIN email_address AS text
CHECK (VALUE ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$');
CREATE TABLE products (
quantity positive_int,
contact_email email_address
);
-- ③ Enum (Section ⑧ 参照)
CREATE TYPE order_status AS ENUM ('pending', 'shipped', 'delivered');
-- ④ Range Type (範囲)
CREATE TABLE reservations (
room_id int,
during tstzrange, -- timestamptz の範囲
price_range int4range -- int の範囲
);
SELECT * FROM reservations
WHERE during @> '2026-03-15 14:00:00'::timestamptz;他のカラムから自動計算される列。アプリ側の計算ロジックを DB に寄せられます。
CREATE TABLE products (
id bigserial PRIMARY KEY,
price numeric NOT NULL,
tax_rate numeric NOT NULL DEFAULT 0.10,
-- STORED: 物理的に保存される (index 可)
price_with_tax numeric GENERATED ALWAYS AS (price * (1 + tax_rate)) STORED
);
-- 全文検索用 tsvector も生成列の典型
CREATE TABLE articles (
title text,
body text,
search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''))
) STORED
);
CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);
-- 注意: VIRTUAL (計算のみで保存しない) は将来の PG で対応予定serial は古い書き方。PG 10 以降は SQL 標準のidentity columnを使うのが現代的です。
-- ❌ 古い書き方 CREATE TABLE orders ( id serial PRIMARY KEY -- 内部で pg_get_serial_sequence による sequence 生成 ); -- ✓ 現代的な書き方 (推奨) CREATE TABLE orders ( id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY ); -- ALWAYS: 明示的 INSERT を禁止 CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY ); -- INSERT INTO orders (id, ...) VALUES (5, ...); -- エラー -- INSERT INTO orders DEFAULT VALUES; -- OK -- BY DEFAULT: 明示的 INSERT も許可 (移行時に便利) CREATE TABLE orders ( id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY ); -- メリット: ① SQL 標準、② DEFAULT 値の検査が厳密、 -- ③ pg_dump で serial の sequence 復元の落とし穴回避
通常テーブルとは違う 2 種類の特殊テーブル。正しく使うと性能が桁違いに上がります。
-- ① UNLOGGED TABLE: WAL を書かない (高速、ただしクラッシュで消える) CREATE UNLOGGED TABLE realtime_metrics ( metric_name text, value numeric, ts timestamptz ); -- 用途: 一時集計バッファ / セッションキャッシュ / 失っても良いログ -- 性能: INSERT が通常テーブルの 2〜5 倍速い -- 注意: クラッシュやレプリカ移行で TRUNCATE される -- ② TEMPORARY TABLE: セッション終了で自動削除 CREATE TEMP TABLE session_cart ( product_id bigint, quantity int ); -- 用途: バッチ処理の中間テーブル / 大規模 JOIN の前処理 -- スコープ: 同一セッション内のみ可視 -- 切断時に自動 DROP -- ON COMMIT 動作 CREATE TEMP TABLE work (...) ON COMMIT DROP; -- COMMIT で消える CREATE TEMP TABLE work (...) ON COMMIT DELETE ROWS; -- COMMIT で行だけ消える CREATE TEMP TABLE work (...) ON COMMIT PRESERVE ROWS; -- 残る (デフォルト)
通常の VIEW は「クエリのエイリアス」ですが、マテビューは結果を物理的に保存するビュー。重い集計を事前計算しておけます。
-- 作成 (この時点で計算される)
CREATE MATERIALIZED VIEW daily_sales AS
SELECT date_trunc('day', created_at) AS day,
count(*) AS orders,
sum(amount) AS revenue
FROM orders
GROUP BY 1;
-- index も張れる
CREATE INDEX idx_daily_sales_day ON daily_sales (day);
-- 再計算 (テーブル全体ロック)
REFRESH MATERIALIZED VIEW daily_sales;
-- 無停止再計算 (UNIQUE index が必要)
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales;
-- 用途:
-- ① ダッシュボード用集計
-- ② 重い JOIN の事前計算
-- ③ 全文検索インデックス
-- ④ 統計レポート
-- 注意: 自動更新されない → cron / pg_cron で REFRESH スケジュール他の PostgreSQL・MySQL・S3 ファイル・REST API などをあたかも PG のテーブルのように JOIN・SELECT できる機能。
-- postgres_fdw (PG → PG) CREATE EXTENSION postgres_fdw; CREATE SERVER remote_pg FOREIGN DATA WRAPPER postgres_fdw OPTIONS (host 'analytics.example.com', dbname 'warehouse'); CREATE USER MAPPING FOR app_user SERVER remote_pg OPTIONS (user 'reader', password 'xxx'); -- リモートテーブルを仮想化 CREATE FOREIGN TABLE remote_orders ( id bigint, amount numeric, created_at timestamptz ) SERVER remote_pg OPTIONS (schema_name 'public', table_name 'orders'); -- 普通の SQL で使える SELECT u.name, count(o.id) FROM users u JOIN remote_orders o ON o.user_id = u.id GROUP BY u.name; -- 他の FDW: -- file_fdw : CSV/TSV ファイル -- mysql_fdw : MySQL -- mongo_fdw : MongoDB -- redis_fdw : Redis -- multicorn : Python で自作 FDW
HOT (Heap-Only Tuple) update は MVCC のコストを抑える PG 独自の最適化 (Section 13 で詳述)。「更新対象カラムにインデックスがない」「ページ内に空きがある」時に発動し、index 更新を省略。 これを設計でどう活かすか:
-- ① 頻繁に UPDATE するカラムには index を張らない
-- (status 等)
-- ② fillfactor を 80〜90 に下げて HOT 余地を確保
CREATE TABLE orders (
id bigint generated by default as identity PRIMARY KEY,
user_id bigint NOT NULL,
status text,
updated_at timestamptz
) WITH (fillfactor = 85);
-- 既存テーブルにも適用可
ALTER TABLE orders SET (fillfactor = 85);
-- ③ HOT update が効いているか確認
SELECT n_tup_upd, n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / nullif(n_tup_upd,0), 1) AS hot_ratio
FROM pg_stat_user_tables WHERE relname = 'orders';
-- → hot_ratio が 70% 以上なら効いているPostgreSQL のタプル (1 行) はカラム順そのままでバイナリ化されます。データ型ごとにアライメント (4/8 byte 境界揃え) があるため、カラム順序を変えるだけで行サイズが小さくなることがあります。
-- ❌ 非効率: アライメントで隙間ができる CREATE TABLE bad ( a bool, -- 1 byte + 7 byte padding b bigint, -- 8 byte c bool, -- 1 byte + 7 byte padding d bigint, -- 8 byte e text -- 可変長 ); -- → 32 byte + text -- ✓ 効率的: 大きい型を先に、小さい型をまとめる CREATE TABLE good ( b bigint, -- 8 byte d bigint, -- 8 byte a bool, -- 1 byte c bool, -- 1 byte (+ 6 byte padding) e text -- 可変長 ); -- → 24 byte + text。1 行 8 byte 節約 -- ルール: -- ① bigint/timestamptz/double (8 byte) を先に -- ② int/float (4 byte) を次に -- ③ smallint (2 byte) を次に -- ④ bool/char (1 byte) をまとめる -- ⑤ 可変長 (text/bytea/jsonb) は最後 -- 数百万〜数億行なら効果絶大
埋め込みベクトル (LLM の出力) を保存・近傍検索する拡張。RAG・推薦・類似検索のインフラとして急速に普及中。
CREATE EXTENSION vector; CREATE TABLE documents ( id bigserial PRIMARY KEY, body text, embedding vector(1536) -- OpenAI text-embedding-3-small の次元 ); -- HNSW index (近似最近傍、高速) CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops); -- 類似検索 (コサイン距離) SELECT id, body, embedding <=> $1 AS distance FROM documents ORDER BY embedding <=> $1 LIMIT 10; -- 距離演算子: -- <-> L2 距離 (ユークリッド) -- <=> コサイン距離 -- <#> 内積 (符号反転)
本章では、PostgreSQL がテーブルの 1 行 1 行をディスク上にどう物理的に配置しているかについて学んでいきます。
PostgreSQL では、テーブルの 1 行のことを タプル (tuple) と呼びます。アプリ目線では「INSERT した 1 行」「SELECT で返ってくる 1 行」のことですが、内部で見ると、タプルは単なる論理的な行ではなく、「23 byte のヘッダ + データ本体 + アライメント用のパディング」から構成される物理的なバイト列です。さらに複数のタプルは ページ (page) と呼ばれる固定サイズ 8KB のブロックにまとめて格納され、これがディスク上のテーブルファイルを構成する最小単位となっています。
この物理レイアウトを理解しておくことが、後続の章で扱う MVCC (バージョン管理)・VACUUM (ゴミ回収)・WAL (耐障害ログ) のすべての話の前提になります。たとえば「UPDATE すると古い行と新しい行の両方がページ内に残る」「VACUUM がそのゴミを掃除する」「HOT update でインデックス更新が省略される」といった挙動は、すべてこのタプルとページの仕組みの上に成り立っています。本章ではまず 8KB ページの内部構造、タプルヘッダの中身、そして大きなデータをはみ出させずに保存する仕組み (TOAST) を順に押さえます。
ページサイズは8KB 固定。これはコンパイル時に決まる定数で、後から変えられません。 ItemId 配列はページの上から、タプル本体は下から積まれ、間の Free Space が縮まっていきます。 ItemId は「タプルへのポインタ」で、4 バイトずつ。タプル更新時に物理位置は変わっても ItemId は維持され、 インデックスから参照される論理 IDとして機能します。これが後の HOT update で効いてきます。
HeapTupleHeaderData {
t_xmin : XID // 作成したトランザクション ID
t_xmax : XID // 削除/更新したトランザクション ID (0 なら有効)
t_cid : CID // コマンド ID (同一トランザクション内の順序)
t_ctid : ItemId // 自分自身か、UPDATE 後の新タプルへのポインタ
t_infomask : flags // 各種フラグ (NULL を持つか、HOT か、等)
t_infomask2 : flags
t_hoff : uint8 // ヘッダ長
// ↓ ここから実データ (カラムの値が並ぶ)
}この t_xmin / t_xmax / t_ctid こそが MVCC の核心です。 次の Section 05 で動きを追います。ここでは「タプル 1 つにつき、誰が作って誰が消したかが記録されている」 ことだけ押さえてください。
1 つのタプルは原則として8KB 以下でなければなりません (ページに収まらないため)。 しかし TEXT や BYTEA など、可変長のカラムが大きくなると問題になります。 これを解決するのが TOAST (The Oversized-Attribute Storage Technique) です。
ALTER TABLE ... ALTER COLUMN ... SET STORAGE で戦略を変えられます。PLAIN (圧縮なし) / EXTERNAL (圧縮なしで外部) /EXTENDED (圧縮 + 外部、デフォルト) / MAIN (圧縮優先) の 4 種。MVCC (Multi-Version Concurrency Control、多版並行制御) は PostgreSQL の最大の特徴のひとつです。一言で言うと、UPDATE や DELETE をしてもデータベース上から古いバージョンのタプルを即座に消さず、新しいバージョンと並存させる仕組みです。
なぜこんなことをするのか。それは「読み手と書き手が干渉しない並行制御」を実現するためです。たとえば、ある集計クエリが 1 億行のテーブルを 30 秒かけてスキャンしている最中に、別のセッションが同じテーブルで UPDATE / DELETE を実行する。普通の発想ではどちらかが待たされそうですが、MVCC のおかげで両方が同時に進行できます。集計側は「自分のトランザクションが始まった瞬間の世界」を見続け、UPDATE 側は新しいバージョンを並列で書き込んでいくのです。これが PostgreSQL の高い同時実行性能の源泉です。
鍵となるのが Section 10 (タプルと物理レイアウト) で見たタプルヘッダの 2 つのフィールド、xmin と xmax です。これらが各タプルに「このタプルは XID xmin で作られ、XID xmax で消された (または更新された)」と刻まれていて、トランザクションは自分の XID と比較して「自分から見えるバージョン」を選びます。
MVCC の話に入る前に、「そもそもなぜ並行制御という仕組みが必要なのか」を確認します。複数のユーザーやアプリが同じデータを同時に読み書きする時、何も対策しないと様々な不整合が起き、データベースの信頼性が崩れます。代表的なものを 4 つ挙げます。
こうした問題を防ぐ仕組みが並行制御 (Concurrency Control) です。世のデータベースには大きく分けて 2 つのアプローチがあります。
並行制御の方式は、大きく次の 2 つに分類できます。PostgreSQL は後者 (MVCC) を採用しています。
MVCC の最大の利点は「SELECT がブロックされない」こと。長時間の集計クエリやレポート生成が走っている間も、他のトランザクションは普通に UPDATE / DELETE できます。これが PostgreSQL の高い同時実行性能の源泉です。代償としてゴミが溜まる問題は、Section 13 (VACUUM) でカバーします。
UPDATE は古いタプルを上書きしません。代わりに新しいタプルを書き、古いタプルの xmax に 自分の XID を埋めて「死亡」と印を付けます。古いタプルの t_ctid は 新しいタプルを指すように更新されます。これが PostgreSQL の「コピーオンライト」です。
MVCC を支えているのは、すべてのタプル (= 行) の先頭 23 バイトに記録されるヘッダ情報 (tuple header) です。アプリケーションから見える列 (id, name, ...) のすぐ前に、PostgreSQL が内部用に管理しているこのヘッダが格納されています。MVCC に関係する主要なフィールドは次の通りです。
xminxmaxcmincmaxctidtableoidt_infomask通常の SELECT では普通の列だけが返りますが、明示的に指定すれば SQL でこれらを覗くことができます。本番障害の調査でしばしば使う、知っておくと便利なテクニックです。
-- ① タプルのメタデータを覗く
SELECT xmin, xmax, ctid, * FROM users LIMIT 5;
-- xmin | xmax | ctid | id | name
-- 12345 | 0 | (0,1) | 1 | Alice ← 生きている (xmax=0)
-- 12350 | 12380 | (0,2) | 2 | Bob ← Tx 12380 が殺した
-- 12360 | 0 | (0,3) | 3 | Carol ← 生きている
-- ② 現在のトランザクション ID を確認
SELECT pg_current_xact_id(); -- PG 13+
SELECT txid_current(); -- 旧 API (PG 12 以前)
-- ③ 「ある XID は既にコミット済みか?」を判定
-- ヘッダ情報の解釈に使われている内部関数
SELECT pg_xact_status('12345'); -- 'committed' / 'aborted' / 'in progress'
-- ④ ページ単位での物理状態確認 (pageinspect 拡張)
CREATE EXTENSION pageinspect;
SELECT * FROM heap_page_items(get_raw_page('users', 0));
-- lp | t_xmin | t_xmax | t_ctid | t_data
-- 1 | 12345 | 0 | (0,1) | ... ← まだ有効
-- 2 | 12350 | 12380 | (0,4) | ... ← UPDATE されて (0,4) を指しているUPDATE が実行されると、内部では次の 3 つが連続で起きます。
xmax に自分の XID を書き込む (「このトランザクションがこのタプルを死亡させた」と印を付ける)。xmin に自分の XID を入れる (「このトランザクションが新しい行を作った」と記録)。ctid を新タプルの位置に書き換える (= ポインタとして使う)。これで「古いタプル → 新しいタプル」のチェーンが繋がる。これにより、同時に走っている他のトランザクションは古いタプル (xmax = 自分の XID 以外と判定) も参照できる一方、自分は新しいタプルを参照することができます。「異なるバージョンを同時並列で見せる」状態がここで実現されます。
トランザクションは開始時にスナップショットを取ります。これは「いま実行中の XID 一覧」と 「コミット済みの最後の XID」を含む情報。各タプルを読むとき、その xmin/xmax を自分のスナップショットと比較して、 「自分から見えるバージョン」を選びます。
-- 擬似コード: 各タプルが「見える」かの判定 function visible(tuple, my_snapshot): // 1. 作成者がコミット済みで、自分より前なら基本見える if not committed(tuple.xmin): return false if tuple.xmin > my_snapshot.xmax: return false // 未来のトランザクション // 2. 削除者が未確定 or 自分より後なら、まだ有効 if tuple.xmax == 0: return true if not committed(tuple.xmax): return true if tuple.xmax > my_snapshot.xmax: return true // まだ削除されていない時点で見ている // 3. それ以外は「削除済み」 return false
抽象的な議論だけでは MVCC の挙動はピンと来ないので、具体的な並行実行例を 4 つ見てみます。それぞれ「Tx A と Tx B が同時に何をしようとした時、どちらがどう動くか」を追います。
MVCC は「読み手と書き手が干渉しない」という素晴らしい性質を持ちますが、それと引き換えに大きな代償も払っています。本番運用でハマる場面は、ほぼすべてこれらの代償への対処です。
idle_in_transaction_session_timeout で自動切断。pg_stat_activity で長時間 idle のセッションを監視。autovacuum_freeze_max_age (デフォルト 2 億) で発動条件が決まる。ANALYZE 相当の統計更新を行う。Autovacuum はデフォルトで両方やってくれる。つまり、PostgreSQL を本番運用する上で「MVCC の代償への対処 = VACUUM の運用」が極めて重要です。Section 13 / 14 で VACUUM と Autovacuum を深く扱うのは、まさにこの代償をきちんと管理するためです。
本章では、PostgreSQL が「クラッシュしても絶対にデータを失わない」ために用いる仕組み、WAL (Write-Ahead Log) について学んでいきます。
WAL は日本語にすると「先行書き込みログ」です。PostgreSQL におけるすべての変更操作 (INSERT / UPDATE / DELETE) は、実際にテーブルのデータファイルを書き換える前に、必ず WAL という追記専用ログにその変更内容を先に書き込みます。「データを変える前に、何を変えるかを必ず先にログに残しておく」というのが名前の由来です。
この仕組みがあるおかげで、たとえ書き込みの途中で電源が落ちてもデータが守られます。再起動時に WAL を順に読み直して未完了の変更をやり直せば、クラッシュ直前の状態まで復旧できるためです。さらに WAL は耐障害性のためだけでなく、レプリケーション・PITR (任意時点への復元)・論理レプリケーションといった PostgreSQL の主要機能の土台にもなっており、後続の章すべてに繋がる中核技術です。本章では WAL の流れ、書き込みのタイミング、そして溜まった WAL をデータファイルへ反映する Checkpoint の仕組みまでを順に解説します。
重要なのは順序です。「① WAL を書く → ② Shared Buffers の実データを書き換える → ③ コミット時に WAL を fsync」。 Data File への書き込みは Checkpointer が後で非同期に行います。これにより、コミットの応答速度を「ランダム I/O であるデータファイル書き込み」ではなく「シーケンシャル I/O である WAL 追記」のスピードに できます。HDD なら 100 倍以上の差が出ます。
WAL を扱う上で必ず登場するのが LSN (Log Sequence Number) という概念です。LSN はWAL 全体を 1 本の長い連続したバイト列とみなし、その中の任意の位置をバイト単位で指し示す番号です。「WAL の何バイト目」というオフセットだと考えると分かりやすいでしょう。
LSN は 64 ビット整数で、PostgreSQL の表示上は 0/3000110 のように「上位 32 bit / 下位 32 bit (どちらも 16 進)」の形式で出力されます。「ここまで WAL を書いた」「ここまでディスクに flush した」「ここまで Standby に送信した」「ここまで Standby が適用した」といったあらゆる進捗の物差しとして PostgreSQL 内部で使われます。
pg_current_wal_lsn()現在の WAL 書き込み位置 (Primary で)pg_current_wal_insert_lsn()WAL Buffer に書いたが、まだディスクへ flush していないかもしれない最先端の位置pg_current_wal_flush_lsn()ディスクに flush 済みの位置 (ここまでが永続化済み)pg_last_wal_receive_lsn()(Standby) Primary から受信した最新位置pg_last_wal_replay_lsn()(Standby) Standby が実際に再生した位置pg_wal_lsn_diff(a, b)2 つの LSN の差分をバイト単位で取得 (レプリ遅延の計算等)WAL はディスク上では pg_wal/ ディレクトリに置かれた 16MB のセグメントファイルとして保存されます。各ファイルは満杯になると次のセグメントへロールオーバーする、追記専用のリングのような仕組みになっています。
ファイル名は 00000001000000A20000007E のような 24 桁の 16 進文字列で、3 つの意味があります。最初の 8 桁がタイムライン ID (フェイルオーバー時に増える)、次の 8 桁がLSN の上位、最後の 8 桁が同一上位 LSN 内のセグメント番号を表します。
# pg_wal/ ディレクトリの中身 $ ls -la /var/lib/postgresql/16/main/pg_wal/ -rw------- 1 postgres postgres 16777216 Jun 10 12:00 00000001000000A20000007D # 16MB の WAL -rw------- 1 postgres postgres 16777216 Jun 10 12:05 00000001000000A20000007E # 次のセグメント drwx------ 2 postgres postgres 4096 Jun 10 12:05 archive_status/ # アーカイブ管理 # ファイル名の意味 # ┌──────── タイムライン ID (フェイルオーバー毎に +1) # │ ┌──────── LSN の上位 32bit # │ │ ┌──────── セグメント番号 (LSN の下位) # 00000001 000000A2 0000007E # 現在使用中のセグメントを SQL で確認 SELECT pg_walfile_name(pg_current_wal_lsn()); # → '00000001000000A20000007E' # WAL ディレクトリの合計サイズ SELECT pg_size_pretty(sum(size)) FROM pg_ls_waldir();
archive_status/ サブディレクトリには .ready / .done という小さなマーカーファイルが置かれます。WAL ファイルが完成すると .ready が作られ、Archiver がそれを発見して archive_command を実行、成功すると .done にリネームされる、というアーカイブ進捗管理の仕組みです。
WAL 1 ファイル (16MB) の中には、何千〜何万件ものWAL レコードが連続して詰まっています。1 レコードは「ある 1 つの変更操作 (1 行の INSERT、1 ページの更新、Checkpoint の発生など)」を記述した可変長のバイナリです。
各 WAL レコードは大きく次の 3 部構成です。ヘッダ (レコード長・所属トランザクション ID (XID)・レコード種別・前レコードへのリンク・CRC)、変更内容ブロック (実際の差分データ、または影響を受けたページ全体)、そして後続レコードへの境界。再生時は先頭から順に CRC を確認しつつレコードを読み、レコード種別に応じた処理を適用していきます。
XLOG_HEAP_INSERT1 行のテーブル INSERT。新しいタプルの内容が記録されるXLOG_HEAP_UPDATEUPDATE。古いタプルへの xmax 設定 + 新タプルの追加XLOG_HEAP_DELETEDELETE。古いタプルへの xmax 設定 (実体はまだ残る)XLOG_BTREE_INSERT_*B-tree インデックスへのエントリ追加XLOG_XACT_COMMITトランザクションのコミット。これが flush されると COMMIT 確定XLOG_XACT_ABORTトランザクションのロールバックXLOG_CHECKPOINT_ONLINECheckpoint 完了マーカー。クラッシュ復旧の起点になるXLOG_FPI (Full Page Image)ページ全体のスナップショット (Torn Page Protection 用)pg_waldump コマンドを使うと、実際の WAL ファイルを人間が読める形式で覗けます。「先週リリース後に WAL 量が急増した、何のクエリが原因か」を追跡したい時など、本番トラブル調査で重宝します。
# WAL ファイルの中身をデコード $ pg_waldump 00000001000000A20000007E | head -5 rmgr: Heap len (rec/tot): 62/ 62, tx: 842, lsn: 0/7E000028, prev 0/7D02FFD0, desc: INSERT off 3 flags 0x00, blkref #0: rel 1663/16384/16389 blk 0 rmgr: Btree len (rec/tot): 72/ 72, tx: 842, lsn: 0/7E000068, prev 0/7E000028, desc: INSERT_LEAF off 3, blkref #0: rel 1663/16384/16395 blk 1 rmgr: Transaction len (rec/tot): 34/ 34, tx: 842, lsn: 0/7E0000B0, prev 0/7E000068, desc: COMMIT 2026-06-10 12:34:56.789 JST # レコード種別ごとに集計 (どの操作が WAL を膨らませているか) $ pg_waldump --stats 00000001000000A20000007E
COMMIT を実行した時、Backend は「WAL がどこまで安全な状態になるまで待ってからクライアントに COMMIT 成功を返すか」を synchronous_commit パラメータで制御します。この設定が 耐障害性とコミット速度のトレードオフを決定します。
off速度: 最速耐障害性: 低wal_writer_delay × 3 ≈ 600ms 分のコミット済みデータが消える可能性。整合性 (DB が壊れない) は保たれる。ログ収集や解析系で性能を取りたい時に有効。local速度: 速耐障害性: 中on (デフォルト)速度: 速耐障害性: 中synchronous_standby_names で設定されていれば remote_flush 相当に格上げされる。デフォルトかつ多くのケースの正解。remote_write速度: 中耐障害性: 中〜高remote_apply速度: 遅耐障害性: 最高実践的な使い分け: 多くのシステムでは全体は on のままにし、必要な部分だけトランザクション単位で SET LOCAL synchronous_commit = 'off' で緩める運用が現実解です。ログテーブルへの大量 INSERT バッチだけ off、決済処理だけ remote_apply、のように業務要件ごとに分けられます。
PostgreSQL がもう一つ抱えている根本的な問題が Torn Page Problem (引き裂かれたページ問題) です。PG のデータページは 8KB ですが、Linux などの OS はディスクへの書き込みを通常 4KB (= ファイルシステムのブロック) 単位で行います。つまり 8KB ページの書き込みは内部的に 2 回の I/O に分かれており、その間に電源が落ちると、ページの前半 4KB は新しい内容、後半 4KB は古い内容、という壊れた状態 (torn page) が残ります。
この壊れたページは「変更の差分」だけ持つ WAL レコードでは復元できません (元のページが壊れているので、差分を適用しても意味不明な結果になる)。これを防ぐために、PostgreSQL は Full Page Writes (FPW) という仕組みを使います。full_page_writes = on (デフォルト) の場合、Checkpoint 後に最初にそのページを変更する時だけ、ページ全体 (8KB) を WAL に書き込みます。
こうしておけば、クラッシュ復旧時にまず WAL から「Checkpoint 直後のページ全体」を復元し、それ以降の差分 WAL を順に適用することで、torn page があっても正しい状態に戻せます。デメリットはWAL サイズが膨らむこと。Checkpoint 直後はページ全体が WAL に乗るため、書き込み量が一時的に増加します。
-- WAL サイズを抑える対策 (PG 14+ 推奨)
wal_compression = on -- pglz / lz4 / zstd で FPW を圧縮
-- (PG 15+ では lz4 / zstd を選択可)
-- Checkpoint 頻度を下げて FPW の発生回数を減らす
max_wal_size = 8GB -- 大きくして checkpoint 間隔を伸ばす
checkpoint_timeout = 30min
-- 注意: full_page_writes = off は ZFS など
-- 「8KB アトミック書き込み保証のあるストレージ」でのみ可。
-- 普通の ext4/xfs では絶対に off にしてはいけない。ここまで見てきた WAL の仕組みが「実際に何の役に立つか」をまとめると、クラッシュからの自動復旧です。PostgreSQL の最大の価値は「電源が落ちても・OS が落ちても・kill -9 されても、起動し直せば必ず整合性のある状態に戻る」という性質で、これを実現しているのが WAL です。
startup という専用のバックグラウンドプロセスを fork。これが復旧担当。pg_control) から「最後の Checkpoint の LSN」を読む。これが復旧の起点。つまり PG の「クラッシュ後の起動が異常に長い」というのはほぼ全てStep 4 (Redo) の所要時間です。Checkpoint からどれくらい WAL が溜まっていたかで時間が決まります。max_wal_size を巨大にすると平常時の Checkpoint 頻度が減って性能は上がりますが、その分クラッシュ復旧時間も伸びる、というトレードオフがあります。
Checkpoint は、それまでに変更された Shared Buffers の dirty page を全てディスク (Data Files) へ書き出して、「ここまで WAL は要らない」と宣言する処理です。これが完了すると、それ以前の WAL はクラッシュ復旧に不要になり、削除またはリサイクルできます。
Checkpoint には 2 つのトリガーがあります。時間ベース (checkpoint_timeout 経過) とサイズベース (max_wal_size 到達)。前者は通常運用時の主な発動契機ですが、書き込みが激しいと max_wal_size を先に超えてしまい、サイズベースで強制発動します。後者が頻発しているとシステムが書き込みに耐えきれていないサインで、pg_stat_bgwriter.checkpoints_req の急増で検知できます。
重要なのは checkpoint_completion_target パラメータ。Checkpoint で大量のページを一気に書くと I/O スパイクが起きてクエリが遅くなりますが、completion_target = 0.9 にすると「checkpoint_timeout の 90% の時間をかけて分散して書く」挙動になり、I/O 波が平準化されます。本番ではほぼ常に 0.9 にしておくのが定石です。
checkpoint_timeout5min本番は 15min〜30min を推奨時間トリガ。長くすると Checkpoint 頻度が下がり性能↑、復旧時間↑max_wal_size1GB本番は 4〜16GBサイズトリガ。大きいほど Checkpoint 頻度が下がるmin_wal_size80MB本番は 1〜2GBWAL を最低限保持するサイズ。リサイクル用checkpoint_completion_target0.9デフォルトのまま 0.9I/O 分散の度合い。0.9 で穏やかな書き出しcheckpoint_flush_after256kBデフォルトのままOS にバッファされた書き込みを定期的に flush する閾値WAL は性能とトレードオフの大きい領域なので、本番投入前に最低限のパラメータは調整が必要です。下表が現代の本番環境で見直すべきパラメータです。
wal_leveldefault: replica推奨: replica / logicalwal_buffersdefault: -1 (auto)推奨: 16MB が安定wal_writer_delaydefault: 200ms推奨: デフォルト OKwal_compressiondefault: off推奨: on または lz4synchronous_commitdefault: on推奨: on (通常) / off (ログ系)archive_modedefault: off推奨: on (本番では必須)archive_commanddefault: 空推奨: cp / aws s3 cp / wal-gここまで WAL は「クラッシュ復旧の道具」として説明してきましたが、PostgreSQL の WAL は「変更の完全な履歴」でもあるため、他の主要機能の土台にもなっています。本記事の後続章で扱う多くのテーマが WAL を中心に組み立てられていることを押さえておくと、全体像が掴みやすくなります。
本番で最頻出の WAL 関連トラブルが 「pg_wal/ ディレクトリが肥大化してディスクが満杯になる」事象です。WAL は本来、Checkpoint 後に削除またはリサイクルされて一定サイズで安定するはずですが、いくつかの原因で削除されずに溜まり続ける状態が発生します。
原因はほぼ 3 パターンに分類できます。下表のいずれに該当するかを切り分けるところが第一歩です。
SELECT * FROM pg_stat_archiver; -- last_failed_wal が値を持っていて、last_failed_time が新しければ archive 失敗中
SELECT slot_name, active, pg_size_pretty( pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) ) AS retained FROM pg_replication_slots ORDER BY 3 DESC;
SELECT * FROM pg_stat_bgwriter; -- checkpoints_req (サイズ起因) が checkpoints_timed より多ければ慢性的
普段の運用と障害調査でよく使う、WAL 状態を見るための SQL をまとめました。本番監視に組み込んでおく価値があるクエリ群です。
-- ① 現在の WAL 位置と書き込みレート
SELECT pg_current_wal_lsn() AS current_lsn,
pg_walfile_name(pg_current_wal_lsn()) AS current_file;
-- ② pg_wal/ ディレクトリの合計サイズ
SELECT pg_size_pretty(sum(size)) AS total_wal
FROM pg_ls_waldir();
-- ③ ファイルごとの一覧 (古い順)
SELECT name, pg_size_pretty(size) AS size, modification
FROM pg_ls_waldir()
ORDER BY name;
-- ④ Archiver の状態 (本番監視の必須)
SELECT archived_count, last_archived_wal, last_archived_time,
failed_count, last_failed_wal, last_failed_time
FROM pg_stat_archiver;
-- ⑤ レプリケーションスロットと WAL 保持量
SELECT slot_name, active,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
) AS retained_wal,
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS retained_bytes
FROM pg_replication_slots
ORDER BY retained_bytes DESC NULLS LAST;
-- ⑥ Checkpoint の発動傾向
SELECT checkpoints_timed, checkpoints_req,
round(100.0 * checkpoints_req /
nullif(checkpoints_timed + checkpoints_req, 0), 1) AS pct_req,
buffers_checkpoint, buffers_clean, buffers_backend
FROM pg_stat_bgwriter;
-- pct_req が 30% 超ならサイズトリガが頻発 → max_wal_size 増やす
-- ⑦ WAL 生成量とコスト (PG 14+)
SELECT wal_records, wal_fpi, wal_bytes,
pg_size_pretty(wal_bytes) AS wal_size,
wal_write_time, wal_sync_time
FROM pg_stat_wal;
-- ⑧ レプリ遅延 (Primary 側で)
SELECT application_name, state,
pg_size_pretty(pg_wal_lsn_diff(sent_lsn, write_lsn)) AS write_lag_bytes,
pg_size_pretty(pg_wal_lsn_diff(sent_lsn, flush_lsn)) AS flush_lag_bytes,
pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS replay_lag_bytes,
write_lag, flush_lag, replay_lag
FROM pg_stat_replication;pg_wal/ ディレクトリに溜まり続けます。 レプリケーションスロットを残したまま物理 standby を消すと、WAL が無限に溜まってディスクが満杯になり、 本番が止まる事故が起きます。レプリケーション関連の整理は慎重に。本章では、PostgreSQL が運用中に必ず発生する「ゴミ」を掃除するための仕組み、VACUUM について学んでいきます。
前章までで見た通り、PostgreSQL は MVCC の仕組みにより、UPDATE や DELETE をしても古い行を即座に消さずに残します。これにより並行する他のトランザクションからは古い行がまだ見えており、ロックなしで読み書きを並列実行できる、というメリットがあるのですが、副作用として「もう誰からも見えなくなった古い行 = デッドタプル」がテーブル内に増えていきます。これを物理的に掃除して、空いた領域を再利用可能にするのが VACUUM です。
VACUUM が正しく動かないと何が起きるか。デッドタプルが溜まり続け、テーブルが本来のサイズの何倍にも膨れ上がるブロート (bloat) という状態に陥ります。ディスク容量を圧迫するだけでなく、テーブルスキャンが遅くなり、キャッシュ効率も悪化します。さらに進むと、トランザクション ID (xid) の枯渇という致命的な障害に至り、DB が緊急停止します。中規模以上の PostgreSQL 運用において、VACUUM の理解は必須スキルです。本章では VACUUM が具体的に何を行うか、HOT update との関係、VACUUM FULL との違い、そして実運用での注意点までを整理します。
VACUUM の話に入る前に、「そもそも PostgreSQL はなぜ放っておくとゴミが溜まる構造になっているのか」をはっきりさせます。これを押さえると、VACUUM が何のために存在するかが本当の意味で腑に落ちます。
PostgreSQL は MVCC (Multi-Version Concurrency Control) という方式を採用しています。MVCC では、UPDATE や DELETE をしても物理的にはデータをすぐには消さないのが原則です。具体的にどう動いているかを 1 ステップずつ追ってみます。
INSERTxmin = 自分のトランザクション ID (XID) が記録される。xmax = 0 (まだ削除されていない)。UPDATExmax = 自分の XID がマークされる。同時に新しいタプルが別の場所に書かれて xmin = 自分の XID が入る。UPDATE 1 回で「古い + 新しい」の 2 つのタプルがテーブル内に並存する。DELETExmax = 自分の XID をマークするだけ。新タプルは作られない。物理的にはまだ残ったまま。COMMITではなぜ即座に消さないのか。「同じタイミングで動いている別のトランザクションがその古いタプルを参照している可能性がある」からです。たとえば長時間の SELECT 集計が走っている時、その集計開始時点でのスナップショット (一貫した過去の状態) を保証するために、削除済みに見える行も消さずに残しておかないといけないのです。これが MVCC の「並行性とロックフリーの代償」です。
つまり PostgreSQL では、書き換えや削除を行うたびに必ず「使われなくなる予定の古いタプル」が物理的に残り続けます。これがいわゆる「ゴミ」の正体です。これを「もう誰からも参照されないと確認できたタイミングで、まとめて掃除する」のが VACUUM の役割になります。
一口に「ゴミ」と言っても、PostgreSQL の内部には実は複数の種類のゴミが異なる場所に溜まっています。VACUUM がそれぞれを処理する方法もちょっとずつ違うので、種類別に把握しておくと挙動が読み解きやすくなります。
PROCESS_TOAST オプションで制御可能)。{`_fsm`} )発生時:タプルの追加・削除・サイズ変更のたびに更新される正体:各ページに「あと何バイトの空きがあるか」を記録した補助マップ。VACUUM や HOT update で空きが増えた情報がここに反映されないと、INSERT が空きを見つけられず毎回テーブル末尾に追加してしまい、bloat が悪化する。放置の害:FSM が古いまま放置されると、空きはあるのに使われず、テーブルが不必要に肥大化する。掃除する人:VACUUM が掃除のついでに FSM を更新する。これも VACUUM の重要な仕事の一つ。このうち ① と ② が「テーブル / インデックスが太る」原因の主犯で、本番でもっとも頻繁に話題に上がります。③ は隠れた肥大化、④ は性能劣化の遠因、⑤ は緊急停止リスク。VACUUM はこれら全種類をまとめて面倒見てくれる「総合掃除人」というわけです。次の Figure では、VACUUM がこれらを具体的にどう処理しているかを見ていきます。
VACUUM が直接掃除しないゴミもある: 古い WAL ファイル (pg_wal/) は Checkpointer の役目 (Section 12 参照)、pg_xact/ 内のコミットログは Autovacuum の Freeze 後に自動削除、ソート溢れの一時ファイル (base/pgsql_tmp/) はクエリ完了時に削除、と別の機構が担当します。「VACUUM = ゴミ全般を掃除」ではなく、「VACUUM = 上記 5 種類の MVCC 由来ゴミを掃除」と理解するのが正確です。
VACUUM は単に「ゴミを消す」だけのコマンドではなく、内部では複数の重要なタスクを同時にこなしています。1 回の VACUUM 実行で具体的に何が行われているかを正確に把握すると、後のチューニングや障害対応で迷わなくなります。
1 回の VACUUM 実行は、ざっくり「テーブル本体スキャン → インデックススキャン → ページ更新 → クリーンアップ」の 4 フェーズで進みます。テーブルサイズが大きいほど各フェーズが長くなり、特に index が複数あると 2 番目のフェーズが本体スキャンの何倍にもなることがあります。
チューニング観点での重要ポイントは maintenance_work_mem。これが小さいと Phase 1 と 2 の組が複数パスに分かれ、テーブルが大きいほど何倍も遅くなります。本番では 1〜2GB に設定するのが推奨です。
重要なのは、通常の VACUUM はディスクサイズを縮めないこと。デッドタプルを「空き領域」としてマークするだけで、テーブルファイル自体は同じサイズで残ります。物理的に縮めるには別の手段が必要で、それぞれに性能特性と本番での扱いやすさが大きく異なります。
ShareUpdateExclusiveLock (SELECT/INSERT/UPDATE/DELETE 並列 OK)AccessExclusiveLock (全アクセス停止)一瞬の AccessExclusiveLock (切り替え時のみ)短時間のロックのみ通常の UPDATE は「新しいタプルをページに追加 + 全インデックスエントリを追加」というコストの大きい操作です。しかし、UPDATE で変更するカラムにどのインデックスも張られていない場合、PostgreSQL は HOT (Heap-Only Tuple) update という最適化を使い、コストを大幅に下げてくれます。
HOT update が発動すると、新しいタプルは同じページ内に追加され、古いタプルから新タプルへの「ポインタチェーン」だけが作られます。インデックスは古いタプルを指したまま (ポインタチェーンを辿れば新タプルに到達できる)、追加更新は不要。さらに、後続の SELECT 時にチェーンを辿る過程で、不要になった古いタプルをその場で削除する Mini-VACUUM も自動的に起こります。
実運用では、頻繁に UPDATE されるテーブルではfillfactor を 80〜90 に下げてページ内に意図的に空きを作っておくのが定石です。空きがあるほど HOT update が成立しやすくなります。確認は pg_stat_user_tables の n_tup_hot_upd / n_tup_upd 比率で。
-- UPDATE が頻繁なテーブルは fillfactor を下げて HOT 余地を確保
CREATE TABLE orders (
id bigint generated by default as identity PRIMARY KEY,
user_id bigint NOT NULL,
status text,
updated_at timestamptz
) WITH (fillfactor = 85); -- ページの 85% まで使用、15% を HOT 用に予約
-- 既存テーブルにも適用可
ALTER TABLE orders SET (fillfactor = 85);
-- 次回 VACUUM FULL / pg_repack 後から有効
-- HOT update が効いているか確認
SELECT relname,
n_tup_upd,
n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) AS hot_ratio
FROM pg_stat_user_tables
WHERE n_tup_upd > 0
ORDER BY hot_ratio NULLS LAST;
-- hot_ratio が 70% 以上なら効いているPostgreSQL のもっとも怖い障害の一つが XID Wraparound (XID 周回) です。MVCC で各タプルに記録されるトランザクション ID (XID) は32 ビット整数で、約 21 億 (2³² の半分) で 1 周します。1 周すると、過去のタプルの xmin が「未来の値」と判定され、全データが見えなくなる致命的な状態に陥ります。
これを防ぐために PostgreSQL はFreeze (凍結) という仕組みを持ちます。古いタプルの xmin を「FrozenXID」という特殊な値に書き換えると、そのタプルは「絶対に過去のもの = 周回問題と無関係」になります。VACUUM が通常の掃除ついでに Freeze も行います (vacuum_freeze_min_age を超える年齢のタプルから順に)。
ただし通常の VACUUM は「変更があったページ」しか見ないので、更新されないテーブル (例: ログテーブル) は Freeze されないまま XID が溜まります。これを救うのが Aggressive Vacuum (積極的バキューム)。XID の最古値が autovacuum_freeze_max_age (デフォルト 2 億) を超えたテーブルは、全ページを強制スキャンする Aggressive モードに切り替わって Freeze します。
-- ① XID 残量を監視 (本番では絶対モニタリングすべき)
SELECT datname,
age(datfrozenxid) AS xid_age,
2^31 - age(datfrozenxid) AS xid_remaining
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
-- xid_age が autovacuum_freeze_max_age (2億) に近づいたら警戒
-- ② テーブル単位の XID 年齢
SELECT relname,
age(relfrozenxid) AS xid_age,
pg_size_pretty(pg_total_relation_size(relid)) AS size
FROM pg_stat_user_tables
JOIN pg_class ON pg_class.oid = relid
ORDER BY age(relfrozenxid) DESC LIMIT 20;
-- ③ 緊急時の手動 FREEZE
VACUUM (FREEZE, VERBOSE) huge_log_table;
-- 巨大テーブルなら処理時間が長いので注意VACUUM がデッドタプルを掃除する副作用として、可視性マップ (Visibility Map, VM) というファイルが更新されます。VM は各ページにつき 2 bit の小さなマップで、「このページの全タプルが全トランザクションから見える状態か」「全タプルが Frozen 済みか」の 2 フラグを持っています。
これが大事なのは、可視性が確定したページに対してはIndex-Only Scan が使えるためです。通常の Index Scan はインデックスからタプル位置を引いた後、必ずテーブル本体を読みに行って可視性を再確認します (MVCC では index だけで判断できないため)。VM で「全員から見える」と確定しているページは、テーブル本体を読まずに index だけで結果を返せるのです。これは大量の集計クエリで劇的な高速化を生みます。
つまり VACUUM は単にゴミを掃除しているだけでなく、「SELECT を Index-Only Scan に高速化する」効果も同時に提供しています。これが Autovacuum を止めると性能が落ちるもう一つの理由でもあります。
-- ① 可視性マップの占有率 (高いほど Index-Only Scan が効きやすい)
SELECT relname,
pg_size_pretty(pg_relation_size(relid, 'main')) AS heap_size,
pg_size_pretty(pg_relation_size(relid, 'vm')) AS vm_size,
n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE n_live_tup > 1000
ORDER BY n_live_tup DESC LIMIT 10;
-- ② Index-Only Scan が使われているか EXPLAIN で確認
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM orders WHERE user_id = 123;
-- → "Index Only Scan" が出ていれば成功
-- → "Heap Fetches" の値が 0 に近いほど良い (再フェッチが少ない)本番運用で「VACUUM が進んでいない」「bloat が増えている」を診断するためのクエリ群です。これらをダッシュボードに組み込んでおくと、VACUUM 起因の問題に早期に気付けます。
-- ① 実行中の VACUUM の進捗 (PG 9.6+)
SELECT pid, datname, relid::regclass AS table,
phase, heap_blks_total, heap_blks_scanned, heap_blks_vacuumed,
round(100.0 * heap_blks_scanned / nullif(heap_blks_total, 0), 1) AS pct
FROM pg_stat_progress_vacuum;
-- ② デッドタプル比率の高いテーブル Top 20
SELECT relname,
n_live_tup, n_dead_tup,
round(100.0 * n_dead_tup / nullif(n_live_tup, 0), 1) AS dead_pct,
last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 100
ORDER BY n_dead_tup DESC LIMIT 20;
-- dead_pct が 20% 超えるテーブルは Autovacuum 設定見直しの候補
-- ③ Autovacuum が走った回数と頻度
SELECT relname,
n_vacuum_count AS manual_vacuum,
autovacuum_count,
n_analyze_count AS manual_analyze,
autoanalyze_count,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY autovacuum_count DESC LIMIT 20;
-- ④ bloat 推定 (pgstattuple 拡張で正確に)
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple('orders');
-- dead_tuple_percent / free_percent が bloat の指標
-- ⑤ 手動 VACUUM 実行 (緊急時)
VACUUM (VERBOSE, ANALYZE) orders; -- 通常
VACUUM (VERBOSE, ANALYZE, PARALLEL 4) orders; -- 並列 (PG 13+)
VACUUM (FREEZE, VERBOSE) orders; -- Freeze 含むここまで「VACUUM の仕組み」を学んできましたが、SQL コマンドとしての VACUUM の構文そのものは案外知られていません。本番運用では手動 VACUUM を打つ場面 (緊急の bloat 対処、Autovacuum 設定確認後の即時実行、メンテナンスウィンドウでのまとめ掃除など) が必ずあるので、構文とオプションを正確に押さえておきましょう。
基本構文は次の 2 形式です。括弧付き形式が現代的で、新しいオプションはこちらでしか書けません。
-- ① 旧式 (PG 8.x までの構文、今でも動く) VACUUM [FULL] [FREEZE] [VERBOSE] [ANALYZE] [table_name [(column, ...)]]; -- ② 推奨: 括弧付き形式 (PG 9.0+、新オプションはこちらのみ) VACUUM (option [, option ...]) [table_name [(column, ...)]]; -- 引数なしで全テーブルを対象 (Autovacuum と同等の挙動) VACUUM; -- 特定テーブルのみ VACUUM orders; -- 特定テーブルの特定カラムだけ ANALYZE VACUUM (ANALYZE) orders (user_id, status);
代表的なオプションを 1 つずつ見ていきます。FULL と FREEZE は特に挙動が大きく変わるので注意。
FULLdefault: OFFFREEZEdefault: OFFvacuum_freeze_min_age を 0 にしたのと同等。緊急時や巨大ログテーブルの計画 Freeze に。VERBOSEdefault: OFFlog_autovacuum_min_duration で代替可。ANALYZEdefault: OFFDISABLE_PAGE_SKIPPINGdefault: OFFSKIP_LOCKED (PG 12+)default: OFFINDEX_CLEANUP (PG 12+)default: AUTOOFF でインデックス処理を完全スキップして緊急高速化、ON で必ず実行。AUTO (デフォルト) で PG が判断。PROCESS_TOAST (PG 14+)default: ONOFF で本体だけ高速化したい時に。通常は ON のまま。TRUNCATE (PG 12+)default: ONOFF でこの段階をスキップ。負荷下げに使う。PARALLEL N (PG 13+)default: -1PARALLEL 4 が定番。BUFFER_USAGE_LIMIT (PG 16+)default: -1BUFFER_USAGE_LIMIT '256MB'実運用での代表的なコマンド組み合わせを以下にまとめます。
-- ① 日常的な手動 VACUUM (バッチ後に統計も更新) VACUUM (ANALYZE, VERBOSE) orders; -- ② 巨大テーブルを並列で速く (PG 13+) VACUUM (ANALYZE, PARALLEL 4, VERBOSE) transactions; -- ③ ログテーブルの計画的 FREEZE (Aggressive Vacuum 回避) VACUUM (FREEZE, VERBOSE, ANALYZE) event_log; -- ④ 複数テーブルをロック競合スキップで一気に掃除 (PG 12+) VACUUM (ANALYZE, SKIP_LOCKED) orders, products, users; -- ⑤ 本番キャッシュを汚さず VACUUM (PG 16+) VACUUM (ANALYZE, BUFFER_USAGE_LIMIT '256MB') huge_table; -- ⑥ 緊急高速化: インデックス処理を切って速く VACUUM (ANALYZE, INDEX_CLEANUP OFF) emergency_table; -- 注: index は別途 REINDEX が必要になる -- ⑦ クラスタ全体 (全 DB / 全テーブル) を VACUUM -- シェルから (psql ではない) $ vacuumdb --all --analyze-in-stages $ vacuumdb --all --analyze-in-stages --jobs 4 -- 並列で速く -- ⑧ 単一 DB を VACUUM (vacuumdb は内部で VACUUM 文を発行) $ vacuumdb -d myapp --analyze --verbose --parallel 4
シェルコマンドの vacuumdb は SQL の VACUUM 文をラップしたユーティリティで、「クラスタ全体」「複数 DB」「複数テーブル並列」のような psql 単発では書きにくい操作を簡潔に表現できます。本番のメンテナンスバッチではこちらを使うのが定石です。pg_upgrade 後の統計復旧で必須の --analyze-in-stages もここに含まれます。
ANALYZE は VACUUM と並んで重要なメンテナンスコマンドです。テーブルからランダムサンプルを取って pg_statistic ビューに統計情報を書き込み、Planner が実行計画を立てる時の材料を最新化する役割を担います。
ANALYZE が走らないと何が起きるか。Planner は古い統計情報のまま判断するので、「テーブルに 100 万行あるのに 10 行だと思ってネステッドループ JOIN を選ぶ」「Index Scan が最適なのに Seq Scan を選ぶ」など、桁違いに遅い実行計画を選ぶ事故が頻発します。pg_upgrade 直後にクエリが急に遅くなる現象も、本質的にはこれです。
-- ① 基本構文 ANALYZE [VERBOSE] [table_name [(column, ...)]]; -- ② 引数なしで全テーブル (= cluster-wide analyze) ANALYZE; -- ③ 特定テーブルのみ ANALYZE orders; -- ④ 特定カラムのみ (頻繁に WHERE で使うカラムを優先) ANALYZE orders (user_id, status, created_at); -- ⑤ 進捗確認付き ANALYZE VERBOSE orders; -- ⑥ より詳しい統計を集める (default_statistics_target を一時的に上げる) SET default_statistics_target = 1000; -- デフォルト 100 ANALYZE orders; RESET default_statistics_target; -- ⑦ カラム単位で統計詳細度を変える ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000; -- このカラムだけ詳しく ANALYZE orders (status); -- ⑧ シェルからクラスタ全体 (pg_upgrade 後の必須コマンド) $ vacuumdb --all --analyze-in-stages -- これは「速い精度→普通の精度→高い精度」の 3 段階で -- ANALYZE を順に実行する。本番停止時間を最小化する。
ANALYZE と VACUUM ANALYZE の違い: ANALYZE は統計のみ更新、VACUUM ANALYZE はデッドタプル掃除と統計更新の両方を行います。バッチ INSERT の直後など「データを大量に変更したが bloat はない」場面では ANALYZE 単体で十分です。
default_statistics_target の話: ANALYZE はテーブルから 300 × default_statistics_target 行のランダムサンプルを抽出して統計を作ります。デフォルトの 100 は中規模 DB で標準的ですが、データ分布が偏っているカラム (例: VIP ユーザーの user_id がアクセスの 80% を占める) では Planner が誤判定するので、該当カラムだけ 500〜1000 に上げる、というのが定番の対処です。
autovacuum_freeze_max_age に近づいています」というアラートが出たら、即対応してください。SELECT relname, n_dead_tup, last_vacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 10 で、 デッドタプルが溜まっているテーブルを発見できます。これがチューニング診断の出発点です。本章では、VACUUM を人間が打たなくても自動で実行してくれる Autovacuum の仕組みと、本番環境でのチューニング方法について学んでいきます。
前章で見た VACUUM は重要な仕組みですが、何百ものテーブルを抱える本番 DB で運用者が毎晩 cron で VACUUM を打って回るのは現実的ではありません。そこで PostgreSQL は、Autovacuum Launcher というバックグラウンドプロセスが裏で常駐し、各テーブルのデッドタプル数を監視しながら、必要に応じて自動的に VACUUM を実行する仕組みを備えています。これによりほとんどのケースで、運用者は VACUUM の存在を意識しなくても DB が健全に動き続けます。
ところがデフォルト設定はあくまで小規模 DB を想定した控えめな値になっており、テーブルが数千万行を超えるような中〜大規模システムでは Autovacuum が追いつかず、結果として VACUUM 起因の性能劣化や bloat 蓄積が頻発します。中〜大規模環境では必ずチューニングが必要です。本章では Autovacuum がいつ発動するかの判定式、主要パラメータの意味、テーブル単位での個別チューニング方法を、本番運用で実際に使う形で整理します。
Autovacuum は実は1 つのプロセスではなく、Launcher と複数の Worker の協調動作です。Launcher は「いつ・誰が掃除すべきか」を判定するスケジューラ、Worker が実際の掃除を担当する実行係、という分業構造になっています。
autovacuum_naptime (デフォルト 1 分) ごとに目を覚ます。pg_stat_user_tables を確認。各テーブルについて「デッドタプル数 > 閾値」「INSERT 累積 > 閾値」「XID 年齢 > freeze_max_age」のいずれかに該当すれば Worker 起動対象に。autovacuum_max_workers (デフォルト 3) の上限まで、必要なテーブルに対して Worker を fork。各 Worker は 1 テーブル担当。naptime を待ってまた巡回開始。ここで重要なのは autovacuum_max_workers の意味。これは「同時に動かせる Worker の最大数」で、テーブル数が多いと「Worker 上限まで詰まっていて新しいテーブルは次回まで待たされる」状況が起きます。テーブル数が多い大規模 DB では本番で 5〜10 に増やすことが多いです。
Autovacuum が「あるテーブルに対して VACUUM すべき」と判断するトリガーは、実は1 つではなく 3 つあります。それぞれ意味と閾値の計算式が違うので、まとめて理解しておく必要があります。
threshold = autovacuum_vacuum_threshold (50)
+ autovacuum_vacuum_scale_factor (0.2) × reltuplesthreshold = autovacuum_vacuum_insert_threshold (1000)
+ autovacuum_vacuum_insert_scale_factor (0.2) × reltuplesage(relfrozenxid) > autovacuum_freeze_max_age (2億)
つまり「VACUUM が走っていない」ように見えても、実は巨大なログテーブルが密かに XID 年齢を貯めていて、いずれ Aggressive Vacuum で突然 I/O が詰まるといったことが起き得ます。本番運用では trigger ① だけでなく、③ も意識した監視が必要です。
以下は本番運用で必ず見直すべきパラメータです。「デフォルトがなぜそうなっているか」と「なぜ本番で変える必要があるか」を併記しました。
autovacuum_vacuum_scale_factordefault: 0.2推奨: 0.05 (大規模なら 0.01)autovacuum_vacuum_thresholddefault: 50推奨: 50〜200autovacuum_vacuum_insert_scale_factor (PG 13+)default: 0.2推奨: 0.1〜0.2autovacuum_analyze_scale_factordefault: 0.1推奨: 0.02〜0.05autovacuum_max_workersdefault: 3推奨: 4〜10autovacuum_naptimedefault: 1min推奨: 15s〜30s (ホットなら)autovacuum_vacuum_cost_delaydefault: 2ms (PG 12+)推奨: 2ms (デフォルト推奨)autovacuum_vacuum_cost_limitdefault: -1 (= 200)推奨: 1000〜3000maintenance_work_memdefault: 64MB推奨: 1GB〜2GBautovacuum_freeze_max_agedefault: 2億推奨: デフォルト推奨上で示したパラメータを実際に postgresql.conf に書き起こした、中〜大規模本番 DB 向けの推奨設定例です。
# ======================================== # Autovacuum 本番推奨設定 (中〜大規模 DB 向け) # ======================================== autovacuum = on # 絶対に off にしない # 発動を頻繁に autovacuum_vacuum_scale_factor = 0.05 # 5% で発動 (デフォルト 20%) autovacuum_vacuum_threshold = 50 # 最低閾値はそのまま autovacuum_vacuum_insert_scale_factor = 0.1 # INSERT 起因も早めに (PG 13+) autovacuum_analyze_scale_factor = 0.02 # ANALYZE はもっと頻繁に # 並列度 autovacuum_max_workers = 6 # 同時 6 テーブルまで autovacuum_naptime = 30s # 30 秒毎に巡回 # 1 回あたりの速度 (SSD 想定) autovacuum_vacuum_cost_delay = 2ms # デフォルト維持 autovacuum_vacuum_cost_limit = 2000 # 速く回す (デフォルト 200) # メモリ maintenance_work_mem = 2GB # Autovacuum も使う # ログ (1 秒以上の Autovacuum はログに残す) log_autovacuum_min_duration = 1s log_lock_waits = on # ロック待ちも記録 # Freeze (基本デフォルト) autovacuum_freeze_max_age = 200000000 # 2 億 vacuum_freeze_table_age = 150000000 # 1.5 億で Aggressive 移行
テーブルの性格 (使われ方) に応じて、Autovacuum の設定を変えるのが上級の運用です。ALTER TABLE でテーブル単位の上書きを組み合わせると、グローバル設定 1 本で全部賄うより遥かに健全な状態を保てます。
orders / sessions / user_statusmaster_data / settings / countriesevent_log / access_log / audit_logautovacuum_vacuum_insert_scale_factor = 0.05 で先回り。巨大なら計画的に手動 FREEZE。transactions / messages日次集計テーブル / 月次レポート上で挙げた分類戦略を実際の ALTER TABLE 文に落とし込んだ例です。コピペで使える形にしてあります。
-- ========================================
-- ① ホットテーブル (頻繁 UPDATE) — 注文・セッションなど
-- ========================================
ALTER TABLE orders SET (
fillfactor = 85, -- HOT update 用の空き
autovacuum_vacuum_scale_factor = 0.02, -- 2% で発動
autovacuum_vacuum_threshold = 100,
autovacuum_analyze_scale_factor = 0.01,
autovacuum_vacuum_cost_limit = 3000, -- 速く回す
autovacuum_vacuum_cost_delay = 2
);
-- ========================================
-- ② Append-only ログテーブル — イベントログなど
-- ========================================
ALTER TABLE event_log SET (
fillfactor = 100, -- 空き不要
autovacuum_vacuum_insert_scale_factor = 0.05, -- INSERT 起因を早めに
autovacuum_analyze_scale_factor = 0.05,
-- 巨大なら parallel VACUUM 設定
autovacuum_vacuum_cost_limit = 2000
);
-- ========================================
-- ③ 巨大トランザクションテーブル (1 億行超)
-- ========================================
ALTER TABLE transactions SET (
autovacuum_vacuum_scale_factor = 0.01, -- 1% で発動
autovacuum_vacuum_threshold = 1000,
autovacuum_vacuum_cost_limit = 5000, -- I/O を多く使って速く
autovacuum_freeze_min_age = 50000000 -- Freeze を早めに
);
-- ========================================
-- ④ 設定の確認
-- ========================================
SELECT relname, reloptions
FROM pg_class
WHERE relname IN ('orders', 'event_log', 'transactions');
-- ========================================
-- ⑤ 設定の解除 (デフォルトに戻す)
-- ========================================
ALTER TABLE orders RESET (autovacuum_vacuum_scale_factor);
ALTER TABLE orders RESET (fillfactor);パラメータをいじったら、それが効いているかを観測する必要があります。下記は本番ダッシュボードに組み込みたい監視クエリ群です。
-- ① Autovacuum が遅れているテーブルの発見
SELECT relname,
n_live_tup, n_dead_tup,
round(100.0 * n_dead_tup / nullif(n_live_tup, 0), 1) AS dead_pct,
last_autovacuum,
now() - last_autovacuum AS time_since_last_av
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY dead_pct DESC NULLS LAST
LIMIT 20;
-- ② 同時稼働中の Autovacuum Worker (max_workers の足りなさ判定)
SELECT pid, datname, query_start, query
FROM pg_stat_activity
WHERE query LIKE 'autovacuum:%'
ORDER BY query_start;
-- これが常に autovacuum_max_workers と同数なら、上限張り付き = 増やすべき
-- ③ Autovacuum がブロックされていないか確認
SELECT activity.pid, activity.usename, activity.query,
pg_blocking_pids(activity.pid) AS blocking_pids,
activity.state, activity.wait_event_type, activity.wait_event
FROM pg_stat_activity AS activity
WHERE activity.query LIKE 'autovacuum:%';
-- ④ XID 年齢の追跡 (Aggressive Vacuum の予兆)
SELECT datname,
age(datfrozenxid) AS xid_age,
round(100.0 * age(datfrozenxid) / 200000000, 1) AS pct_to_aggressive
FROM pg_database
WHERE datallowconn
ORDER BY age(datfrozenxid) DESC;
-- pct_to_aggressive > 50% で計画的に対処を始める
-- ⑤ log_autovacuum_min_duration を有効にしてある場合、
-- PostgreSQL ログから Autovacuum 実行履歴を確認
-- grep "automatic vacuum" /var/log/postgresql/*.log「Autovacuum が走らない」「テーブルがどんどん bloat していく」という事象は、ほとんどの場合「設定が悪い」のではなく「何かに邪魔されている」のが原因です。代表的なブロック要因を整理しました。
autovacuum_work_mem でこれを上書き可。pg_stat_progress_vacuum ビューで実行中の VACUUM 進捗を確認できます。pg_stat_user_tables.last_autovacuum で最終実行時刻を見て、 遅れているテーブルがないか定期チェックしましょう。log_autovacuum_min_duration = 1s でログに実行履歴を残すのも非常に役立ちます。本章では、PostgreSQL が SQL 文を受け取ってから結果を返すまでのクエリ実行 5 ステージを、各段の内部処理レベルまで掘り下げて学んでいきます。
Section 02 では、PostgreSQL が SQL を Parser → Rewriter → Planner → Executor → Output の 5 段階を順に通過させて処理する、という流れを俯瞰しました。本章ではその各段について、「具体的に何を入力に取り、何を出力するか」「内部でどんなデータ構造に変換されるか」を 1 段ずつ詳細に追います。
この内容は単なる内部構造の知識ではなく、後の EXPLAIN 章 (16 章) の前提になります。EXPLAIN の出力 (実行計画) は、ここで紹介する Planner が選んだ「結果」をそのまま表示したものです。Planner 以前で何が起きているか、そして Planner が何を入力に何を出力するかが分かっていないと、EXPLAIN の読み方が表面的なものに留まってしまいます。本章をしっかり押さえることが、後続のクエリチューニングすべての土台になります。
受け取った文字列を字句解析・構文解析し、Parse Tree に変換します。 ここでは意味解析はしません (テーブルがあるかは見ない)。例:
SELECT name FROM users WHERE id = 5;
↓ Parser の出力 (簡略化)
SelectStmt {
targetList: [ResTarget { name: 'name' }]
fromClause: [RangeVar { relname: 'users' }]
whereClause: A_Expr {
op: '=',
lexpr: ColumnRef { name: 'id' },
rexpr: A_Const { val: 5 }
}
}続いてAnalyzer が「テーブル/カラムは実在するか」「型は合うか」を検証し、Parse Tree を Query Tree に変換します。 その後Rewriter が VIEW やルールを実テーブル参照に展開します。
-- VIEW の展開例 CREATE VIEW active_users AS SELECT * FROM users WHERE deleted_at IS NULL; -- ユーザーが書いたクエリ SELECT name FROM active_users WHERE id = 5; -- Rewriter 後 (実テーブル参照に展開される) SELECT name FROM users WHERE deleted_at IS NULL AND id = 5;
最も重要なステージ。Query Tree から複数の実行計画 (Plan Tree) 候補を生成し、コストが最小のものを選びます。 具体的にやっていること:
Plan Tree のノードをルートから辿りつつ、各ノードがタプルを返す Iterator Pattern (Volcano モデル) で動きます。 上位ノードが「次のタプルくれ」と聞き、下位ノードが 1 つ返す。これをループします。
-- 実行プラン例
Sort (cost=10.45..10.55 rows=100)
Sort Key: users.name
-> Hash Join (cost=1.23..8.67 rows=100)
Hash Cond: (orders.user_id = users.id)
-> Seq Scan on orders
-> Hash
-> Seq Scan on users
Filter: (active = true)
-- Executor の動き:
-- Sort が "next" → Hash Join "next" → Seq Scan orders "next" → 1 行返す
-- Sort はこれを 100 行ためてからソート → 上位に返すExecutor が出したタプルを、PostgreSQL のワイヤープロトコルでクライアントへ送ります。 大きい結果セットの場合、ここがボトルネックになることもあります (ネットワーク帯域)。
本章では、クエリチューニングにおいてもっとも重要なツール、EXPLAIN と EXPLAIN ANALYZE の読み方について学んでいきます。
EXPLAIN は、SELECT や UPDATE などの SQL の前に付けると、その SQL が「実際にどんな手順で実行されるつもりか」という実行計画 (Plan) をテキスト形式で表示してくれるコマンドです。さらに EXPLAIN ANALYZE を付けるとクエリを実際に実行し、計画と並べて各ステップの所要時間と処理件数まで表示してくれます。「Planner が選んだ実行計画は何か」「想定と実測がどれくらいズレているか」を SQL 単位で見られる、唯一の標準ツールです。
EXPLAIN を読めるかどうかは、パフォーマンス問題を「自力で原因まで辿れる」か、「とりあえず勘でいじって祈る」かの分水嶺になります。クエリが遅いとき、EXPLAIN を見ずに index を足したり statement_timeout を伸ばしたりしても、根本原因にはほぼ届きません。本章では基本的な出力の読み方、cost と rows の意味、主要なスキャン方式 (Seq Scan / Index Scan / Bitmap Scan)、Join 方式 (Nested Loop / Hash / Merge)、そして EXPLAIN ANALYZE で本当に見るべき項目までを、実例付きで解説します。
EXPLAIN SELECT * FROM users WHERE email = 'taro@example.com';
QUERY PLAN
─────────────────────────────────────────────
Index Scan using users_email_idx on users
(cost=0.28..8.30 rows=1 width=128)
Index Cond: (email = 'taro@example.com')括弧の中の cost=0.28..8.30 が読みづらい部分です。これは:
0.28 (startup cost)最初の 1 行を返すまでのコスト。LIMIT クエリでこちらが重要8.30 (total cost)全行返し終わるまでの累積コストrows=1推定行数。これが大きく外れると Planner が誤った判断をするwidth=1281 行あたりの平均バイト数EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders
WHERE user_id = 5 AND status = 'shipped';
QUERY PLAN
─────────────────────────────────────────────────────
Index Scan using orders_user_status_idx on orders
(cost=0.42..23.45 rows=18 width=64)
(actual time=0.018..0.052 rows=15 loops=1)
Index Cond: ((user_id = 5) AND (status = 'shipped'))
Buffers: shared hit=4
Planning Time: 0.123 ms
Execution Time: 0.082 msEXPLAIN は本番デバッグで最も頻繁に叩くコマンドの一つです。基本構文と全オプションを正確に押さえると、調査の精度と速度が大きく上がります。
-- ① 旧式構文 (PG 8.x 以前から) EXPLAIN [ANALYZE] [VERBOSE] statement; -- ② 推奨: 括弧付き形式 (PG 9.0+、新オプションはこちらのみ) EXPLAIN (option [, option ...]) statement; -- 動かさず計画だけ表示 EXPLAIN SELECT * FROM orders WHERE user_id = 123; -- 実際に動かして実測値も EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 123; -- 推奨フル装備 EXPLAIN (ANALYZE, BUFFERS, VERBOSE, WAL, SETTINGS) SELECT * FROM orders WHERE user_id = 123;
オプション一覧:
ANALYZEdefault: OFFBUFFERSdefault: OFFVERBOSEdefault: OFFCOSTSdefault: ONTIMINGdefault: ONSUMMARYdefault: AUTOWAL (PG 13+)default: OFFSETTINGS (PG 12+)default: OFFGENERIC_PLAN (PG 16+)default: OFFFORMATdefault: TEXTTEXT (人間向け) / JSON / YAML / XML。JSON はツール解析や Grafana 等での加工に最適。実運用での典型的な組み合わせをまとめます。
-- ① 本番調査の標準セット (これを最初に試す)
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS, WAL)
SELECT * FROM orders WHERE user_id = $1 AND status = $2;
-- ② 危険なクエリは ROLLBACK で囲む
BEGIN;
EXPLAIN (ANALYZE, BUFFERS)
UPDATE orders SET status = 'shipped' WHERE id IN (1,2,3);
ROLLBACK;
-- ③ ツール解析用に JSON で出力
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT ... ;
-- → explain.dalibo.com / pev2 などに貼り付けて可視化
-- ④ PREPARE 経由のプラン確認 (PG 16+)
EXPLAIN (GENERIC_PLAN) SELECT * FROM orders WHERE user_id = $1;
-- ⑤ 大量行クエリで ANALYZE オーバーヘッドを軽減
EXPLAIN (ANALYZE, TIMING OFF, BUFFERS)
SELECT * FROM large_table WHERE indexed_col = 'X';
-- ⑥ auto_explain で本番の遅いクエリを自動取得
-- postgresql.conf:
shared_preload_libraries = 'auto_explain'
auto_explain.log_min_duration = '1s'
auto_explain.log_analyze = on
auto_explain.log_buffers = on
auto_explain.log_format = 'json'rows=18 (推定) と rows=15 (実測) が近いか。 大きくズレるなら統計情報が古い (ANALYZE で更新)。 ② actual time が大きいノードがボトルネック。 ③ Buffers: shared hit=4 はキャッシュヒット、read=4 ならディスク I/O 発生。BEGIN; EXPLAIN ANALYZE ...; ROLLBACK; で囲んでください。explain.dalibo.com や explain.depesz.com に EXPLAIN 結果を貼ると、ボトルネックノードを色付きで可視化してくれます。複雑な JOIN プランの解析に重宝します。本章では、PostgreSQL の Planner が複数の実行計画候補の中から 1 つを選ぶ際に内部で行っているコスト計算の仕組みについて学んでいきます。
SQL を投げた時、Planner は「このクエリを実行する方法」を一通りではなく、可能な選択肢を複数生成します。たとえば「Seq Scan + Hash Join」「Index Scan + Nested Loop」「Bitmap Scan + Merge Join」といった具合に、テーブルの読み方や Join の方式の組み合わせだけでも多数のパターンが存在します。その中から実際に実行する 1 つを選ぶために、Planner は各候補に「コスト」という数値を割り当てて比較するという方法を取っています。EXPLAIN の出力で見える cost=0.00..123.45 のような数字がこれです。
ここで重要なのは、コストは秒やミリ秒ではなく無次元の相対値であるという点です。基準となるのは「ディスクからページを 1 枚順次読み込むコスト = 1.0」(seq_page_cost) で、それを基にランダム I/O・CPU 処理・タプル評価などの値が決まります。これらの基準値を理解しておくと、「なぜ Planner が Seq Scan を選んだのか」「なぜ index があるのに使ってくれないのか」といった一見不可解な挙動が、すべて数値の上で腑に落ちます。本章ではコスト計算式の全体像、主要パラメータ、そして統計情報が果たす役割を順に解説します。
-- Seq Scan のコスト(おおざっぱ)
total_cost = (relpages × seq_page_cost)
+ (reltuples × cpu_tuple_cost)
+ (reltuples × cpu_operator_cost × フィルタ数)
例: 10,000 ページ × 100 行/ページ のテーブル
seq_page_cost = 1.0 (デフォルト)
cpu_tuple_cost = 0.01 (デフォルト)
cpu_operator_cost = 0.0025 (デフォルト)
total_cost = 10,000 × 1.0 + 1,000,000 × 0.01 + ...
= 10,000 + 10,000 + ...
= 約 20,000
-- Index Scan のコスト
total_cost = startup_cost (= ツリー降下)
+ matched_pages × random_page_cost
+ matched_tuples × cpu_index_tuple_cost
random_page_cost のデフォルト = 4.0 (HDD 想定)seq_page_cost1.0通常変えない(基準)random_page_cost4.0SSD なら 1.1〜1.5 へ下げるcpu_tuple_cost0.01通常変えないcpu_index_tuple_cost0.005通常変えないeffective_cache_size4GBRAM の 50〜75% に上げるeffective_io_concurrency1SSD なら 200、NVMe なら 300最も効果が大きいのが random_page_cost です。デフォルト 4.0 は HDD 前提の値。 SSD/NVMe ならシーケンシャルとランダムの差はほぼゼロなので、4.0 のままだとプランナーが「Index Scan よりも Seq Scan の方が安い」と過剰に判断し、本来 Index を使うべきところを Seq Scan してしまいます。
コスト計算はテーブルの行数・カラムの値分布などの統計に依存します。これらは ANALYZE(または VACUUM ANALYZE) で更新され、pg_statistic ビューに保存されます。
-- カラムごとの統計を確認 SELECT attname, n_distinct, most_common_vals, most_common_freqs FROM pg_stats WHERE tablename = 'users' AND attname = 'status'; -- 統計サンプルを増やす (デフォルト 100 → 1000 へ) ALTER TABLE users ALTER COLUMN status SET STATISTICS 1000; ANALYZE users;
random_page_cost が HDD 用のまま、 ③ サンプルが粗い (STATISTICS が小さい)、④ パラメータ化クエリで「いつもの値」と違う値が来た。 EXPLAIN を見て、推定行数と実測行数が大きく違ったらこのどれかが原因です。SET enable_seqscan = OFF; でプラン強制も可能。 ただし、長期運用ではコードに紛れ込ませず、根本原因を解決してください。本章では、PostgreSQL が標準で持つ6 種類のインデックスと、それぞれの得意分野について学んでいきます。
インデックスとは、テーブルの中から特定の行を高速に見つけるための「索引」です。本のページ末尾にある索引と同じ発想で、目的の値からその値が入っている行の場所 (ページ番号) を即座に引けるようにしておき、テーブル全体をスキャンする「全件読み」を避けるために使います。インデックスがなければ 1 億行のテーブルから 1 行探すのに 1 億行を読む必要がありますが、適切なインデックスがあれば数回のページアクセスで済みます。
PostgreSQL の特徴は、用途に応じた異なる構造の6 種類のインデックス (B-tree / Hash / GIN / GiST / BRIN / SP-GiST) を持っている点です。汎用的な B-tree、JSONB や配列に強い GIN、地理データや全文検索に強い GiST、巨大なシーケンシャルデータに強い BRIN など、それぞれ得意なデータの形やクエリパターンが異なります。実際の現場では B-tree だけで済ませてしまうケースも多いですが、適材適所で選べると桁違いの性能改善が可能です。本章では 6 種すべてを俯瞰し、どんなデータ・どんなクエリでどれを選ぶべきかを整理します。
| ユースケース | 第 1 候補 | 備考 |
|---|---|---|
| 通常の等価/範囲検索 | B-tree | 90% のケースはこれ |
| JSONB の中のキー検索 | GIN | jsonb_path_ops で更に高速化 |
| 配列の包含チェック (@>) | GIN | tag_ids @> ARRAY[5] など |
| 全文検索 (tsvector) | GIN | GiST は更新優先・GIN は検索優先 |
| 地理空間 (PostGIS) | GiST / SP-GiST | PostGIS 推奨は GiST |
| 時系列の created_at | BRIN | 巨大ログテーブルで激小に |
| IP アドレスの cidr | SP-GiST | inet 型の階層構造を活用 |
| LIKE 'foo%' (前方一致) | B-tree | 末尾ワイルドカードは効く |
| LIKE '%foo%' (中間一致) | GIN + pg_trgm | trigram 拡張で実現 |
インデックスを作る CREATE INDEX は実は非常に多機能で、知らないオプションだけで本番性能が桁違いに変わります。基本構文とオプションを順に押さえます。
-- 完全構文 (PG 16 時点)
CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] [index_name]
ON [ONLY] table_name [USING method]
( {column | (expression)} [COLLATE collation]
[opclass [(opclass_parameter = value [, ...])]]
[ASC | DESC] [NULLS {FIRST | LAST}] [, ...] )
[INCLUDE ( column_name [, ...] )]
[NULLS [NOT] DISTINCT]
[WITH (storage_parameter [= value] [, ...])]
[TABLESPACE tablespace_name]
[WHERE predicate];UNIQUECONCURRENTLYIF NOT EXISTSON ONLYUSING methodbtree (デフォルト) / hash / gin / gist / brin / spgist。(expression)CREATE INDEX ON users (lower(email)) のようにカラムに関数を適用した値で索引化。COLLATEopclassvarchar_pattern_ops で LIKE が効くようにする、jsonb_path_ops で JSONB の特定演算子を最適化する等。ASC | DESC, NULLS FIRST | LASTORDER BY created_at DESC を高速化したい時は DESC で作る。INCLUDE (column, ...)NULLS [NOT] DISTINCT (PG 15+)NULLS NOT DISTINCT で NULL 同士を重複扱いにできる。WITH (...)fillfactor (デフォルト 90)、GIN の fastupdate、BRIN の pages_per_range など。TABLESPACEWHERE predicate本番でよく使う組み合わせの実例集です。CONCURRENTLY 付きが本番の基本。
-- ① 本番の標準形 (CONCURRENTLY + IF NOT EXISTS) CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_user_id ON orders (user_id); -- ② UNIQUE 制約付き (重複防止) CREATE UNIQUE INDEX CONCURRENTLY idx_users_email ON users (email); -- ③ 関数インデックス (大文字小文字無視の検索) CREATE INDEX CONCURRENTLY idx_users_lower_email ON users (lower(email)); -- ④ 部分インデックス (生存ユーザーだけ) CREATE INDEX CONCURRENTLY idx_users_active_email ON users (email) WHERE deleted_at IS NULL; -- ⑤ マルチカラム (順序が重要、WHERE と ORDER BY を網羅) CREATE INDEX CONCURRENTLY idx_orders_user_status_created ON orders (user_id, status, created_at DESC); -- ⑥ カバリングインデックス (Index-Only Scan 用) CREATE INDEX CONCURRENTLY idx_orders_user_covering ON orders (user_id) INCLUDE (status, amount, created_at); -- ⑦ LIKE 'foo%' 高速化用 opclass 指定 CREATE INDEX CONCURRENTLY idx_users_name_prefix ON users (name varchar_pattern_ops); -- ⑧ JSONB の特定キーへの GIN インデックス CREATE INDEX CONCURRENTLY idx_events_data_gin ON events USING GIN (data jsonb_path_ops); -- ⑨ BRIN (巨大な時系列テーブルで) CREATE INDEX CONCURRENTLY idx_logs_created_brin ON logs USING BRIN (created_at) WITH (pages_per_range = 32); -- ⑩ CONCURRENTLY 失敗時の INVALID 検出と削除 SELECT indexrelid::regclass FROM pg_index WHERE indisvalid = false; DROP INDEX CONCURRENTLY idx_failed; -- 再実行 CREATE INDEX CONCURRENTLY ...;
CONCURRENTLY の注意点: ① トランザクション内で実行不可、② 失敗するとインデックスが INVALID 状態で残り indisvalid = false になる、③ DROP INDEX も CONCURRENTLY 付きで実行可能 (PG 9.2+)。マイグレーションフレームワークのデフォルトは非 CONCURRENTLY が多いので、明示指定が必要なことが多い。
本章では、PostgreSQL のデフォルトインデックスである B-tree の内部構造について学んでいきます。
CREATE INDEX と書くだけで作られる、もっとも普通のインデックス — それが B-tree です。PostgreSQL を含むほぼすべての RDBMS が標準採用しており、検索・範囲・並び替え・LIKE 'foo%' の前方一致まで、汎用的に効くもっとも頻出のインデックスです。実装は正確には B+tree (B プラスツリー) で、リーフ (末端ノード) のみが実データへの参照を持ち、内部ノードは「次にどちらに進むか」の方向案内だけを担う、というツリー構造になっています。
B-tree の内部構造を理解しておくと、「UNIQUE 制約の挙動」「INSERT で稀に発生するロック」「マルチカラムインデックスでカラム順序がなぜ重要か」「テーブルが膨れる時にインデックスも膨れる理由」といった日常の疑問が一気に説明できます。本章では B-tree の階層構造、ページ分割 (Page Split) の動き、そして実運用での性能特性まで、図解中心で解説していきます。
ポイントは リーフ間が双方向リンクで繋がっていることです。 これにより WHERE id BETWEEN 20 AND 60 のような範囲検索が、リーフを順に辿るだけで完了します。 ツリーの高さは 3〜5 程度 (1 ページに数百個の子を持てるため)。10 億件のテーブルでも数回のページアクセスで検索完了します。
INSERT で新しいキーがリーフに入りきらなくなると、Page Split が発生します。 リーフを 2 つに分割し、親ノードへ新しい区切り値を昇格させる処理です。これは O(1) でなく書き込みコストが大きいため、 INSERT が頻発するインデックスはこの分割が継続的に起きます。
CREATE INDEX ... WITH (fillfactor = 70) で、各ページを 70% までしか埋めない設定。 残り 30% を分割回避のバッファに使えます。デフォルトは 90。更新が多いインデックスは 70〜80へ下げると分割頻度が下がります。ORDER BY でソートなしで取得、 ③ Index-Only Scan で先頭から N 件のような取得が高速。これらが効く理由は全部このリンクです。本章では、PostgreSQL が誇る 2 つの強力なインデックス、GIN と GiST について学んでいきます。
B-tree は「1 つのカラムに 1 つの値」が入っている場合に高速に検索できるインデックスです。しかし PostgreSQL では、1 つのカラムに配列を入れたり、JSONB 形式のオブジェクトを入れたり、長文テキストを全文検索したい、といった「1 つのセルに複数の要素が入っているデータ」を扱うことが頻繁にあります。こうしたデータに対しては B-tree は無力で、別の検索構造が必要になります。
ここで登場するのが GIN (Generalized Inverted Index) と GiST (Generalized Search Tree) です。GIN は転置インデックスと呼ばれる構造で、「どの単語がどの行に出現するか」を逆引きできるため、全文検索・配列の包含検索・JSONB の特定キー検索などに圧倒的に強い特性を持ちます。GiST は汎用ツリーとして地理空間データや範囲型に向く構造で、地図検索や時間範囲の重複判定で使われます。本章ではこの 2 つの仕組みと、それぞれの典型ユースケース、JSONB や全文検索で実際にどう書くかまでを解説します。
GIN は転置インデックス (inverted index)。Google などの検索エンジンと同じ仕組みで、 「各要素から、それを含むタプルへのポインタ」を持ちます。
-- 元データ id | tags 1 | ['rust', 'go'] 2 | ['python', 'go'] 3 | ['rust', 'python'] -- GIN インデックスの中身 (転置) 'rust' → [tuple_1, tuple_3] 'go' → [tuple_1, tuple_2] 'python' → [tuple_2, tuple_3] -- クエリ例 SELECT * FROM articles WHERE tags @> ARRAY['rust']; -- → GIN で 'rust' を引いて tuple_1, tuple_3 を取得
@>)、JSONB のキー検索 (?)、 全文検索 (@@)。-- 戦略 A: jsonb_ops (デフォルト) - 全演算子サポート CREATE INDEX idx_data ON events USING GIN (data); -- ? ?| ?& @> @@ @? 全て使える -- インデックスサイズは大きい -- 戦略 B: jsonb_path_ops - @> のみだが小さく速い CREATE INDEX idx_data ON events USING GIN (data jsonb_path_ops); -- @> のみサポート (キー存在 ? は使えない) -- インデックスサイズは A の 1/2〜1/3、検索も速い -- 大抵の場合 @> しか使わないので B が推奨
GiST は木構造の汎用フレームワークで、空間データ・範囲データ・類似検索に最適化できます。 PostGIS の地理データ検索の土台もこれです。
pg_trgm での「Tokyo に似ている文字列」ORDER BY col <-> point LIMIT 10)<->)fastupdate=on (デフォルト) で、更新を一時的に保留バッファに溜めて遅延適用します。 VACUUM 時にまとめて反映され、書込性能が大幅向上。ただし、保留中はクエリ時に追加スキャンが必要 (若干遅くなる)。本章では、インデックスの種類を選ぶだけでは引き出せない性能を引き出す3 つの応用テクニックについて学んでいきます。
B-tree や GIN というインデックスの種類を理解しても、それだけでは現場の課題には対処しきれません。本番ではしばしば「全テーブルに索引を張ると重いが、よく使う一部の行だけは速く引きたい」「WHERE で関数を使うとインデックスが効かない」「複数カラムを組み合わせて検索したい」といった、使い方の工夫が必要な場面に直面します。
本章ではこれらを解決する 3 つのテクニックを扱います。 ① 部分インデックス (Partial Index) — 行の一部だけにインデックスを張ってサイズを抑える、② 関数インデックス (Expression Index) — カラムの値を加工した結果でインデックスを張る、③ マルチカラムインデックス (Composite Index) — 複数のカラムをまとめて 1 つのインデックスに乗せる。 どれも CREATE INDEX の構文を少し変えるだけで使え、適切に使えば数十倍の高速化や、インデックスサイズの劇的な削減につながります。
条件を絞ったインデックス。「テーブルの一部だけ」のインデックスを作ります。
-- 全注文のうち「未発送」だけインデックス CREATE INDEX idx_orders_pending ON orders (created_at) WHERE status = 'pending'; -- クエリでマッチすればインデックスが効く SELECT * FROM orders WHERE status = 'pending' AND created_at > NOW() - INTERVAL '1 day'; -- メリット: -- ① インデックスサイズが激小 (全体の数 %) -- ② 更新コストも下がる -- ③ 統計の精度向上 (絞ったデータでの分布が明確)
式をインデックスに張れます。「大文字小文字を無視した検索」「JSON のフィールド抽出」などで威力を発揮。
-- 大文字小文字を区別せず email 検索
CREATE INDEX idx_users_email_lower
ON users (LOWER(email));
-- クエリは LOWER で囲む必要がある
SELECT * FROM users WHERE LOWER(email) = 'taro@example.com';
-- JSONB の特定フィールドにインデックス
CREATE INDEX idx_data_user_id
ON events ((data->>'user_id'));
SELECT * FROM events WHERE data->>'user_id' = '12345';
-- 日付の年月単位
CREATE INDEX idx_orders_yearmonth
ON orders (DATE_TRUNC('month', created_at));複数カラムを1 つのインデックスに。列順が極めて重要です。
CREATE INDEX idx_orders_user_status ON orders (user_id, status); -- ◯ 効く WHERE user_id = 5 -- 前方一致 WHERE user_id = 5 AND status = 'shipped' -- 両方 WHERE user_id = 5 ORDER BY status -- ORDER BY も -- × 効きにくい WHERE status = 'shipped' -- user_id を飛ばすと弱い WHERE user_id IN (1,2,3) AND status = 'x' -- 部分的に効くが完璧でない -- ルール: 「最も選択性が高い列」を先に -- = ID系 → 状態系 → 日付系 の順が定石
CREATE INDEX ... INCLUDE (col1, col2) で 「インデックスに含めるだけで検索キーとしては使わない」カラムを追加できます。これで Index-Only Scan が成立しやすくなり、 heap (テーブル本体) を読まずに済みます。pg_stat_user_indexes で 使われていないインデックスを定期的に削除しましょう。本章では、現場で頻発する「インデックスが効かないクエリのアンチパターン」について学んでいきます。
「インデックスを張ったのにクエリが速くならない」という相談は、本番運用で繰り返し発生する典型的なトラブルです。原因の 9 割は、クエリの書き方がインデックスを使える形になっていないことにあります。たとえば WHERE 句で関数で包んでいる、暗黙の型変換が起きている、否定条件で書いている — こうした見た目だけでは気付きにくい書き方が、Planner に「このインデックスは使えない」と判定させ、結果として全件スキャンを引き起こしてしまうのです。
本章では、こうした事故を引き起こす頻出パターンを 7 つ集めて解説します。それぞれについて「なぜインデックスが効かないのか」「正しく書くとどうなるか」「EXPLAIN で何を確認すれば気付けるか」を、Before / After の対比で見ていきます。一度パターンを覚えてしまえば、コードレビューで瞬時に検知できるようになり、本番事故を予防できます。
WHERE LOWER(email) = 'a@example.com'
-- 関数インデックスを作るか、保存時に正規化 CREATE INDEX idx_email_lower ON users (LOWER(email)); -- または、保存時に小文字化して通常 B-tree
-- id は integer のカラム WHERE id = '5' -- 文字列と比較
WHERE id = 5 -- 数値で比較
WHERE status NOT IN ('active', 'paused', ...)WHERE status = 'cancelled' -- 正のリストで書く
WHERE name LIKE '%東京%'
-- pg_trgm + GIN CREATE EXTENSION pg_trgm; CREATE INDEX idx_name_trgm ON places USING GIN (name gin_trgm_ops); -- これで LIKE '%foo%' も使えるようになる
WHERE user_id = 5 OR email = 'x@y.com'
-- UNION で書き換えるか、複数の Bitmap Index Scan を期待 SELECT * FROM users WHERE user_id = 5 UNION SELECT * FROM users WHERE email = 'x@y.com';
-- ANALYZE してから半年経過 -- → 行数推定が桁違いに外れる → Seq Scan を選んでしまう
ANALYZE orders; -- または autovacuum_analyze の調整 -- 重要カラムは STATISTICS 値を上げる ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
-- index: (created_at, user_id) WHERE created_at > '2024-01-01' AND user_id = 5
-- index 列順を逆に CREATE INDEX ON orders (user_id, created_at); -- 等価条件のカラムを前に置く
EXPLAIN ANALYZE を実行、② Seq Scan が出ていないか確認、 ③ Index Scan でも推定行数と実測行数が大きく違わないか、④ 上のアンチパターンに当てはまっていないか。 この順で診断すると、9 割の問題は自力で解決できるようになります。本章では、本番運用で必ず必要になるインデックスのメンテナンスについて学んでいきます。
インデックスは「一度 CREATE INDEX したら終わり」ではなく、運用し続けるうちに必ず劣化していきます。原因は、UPDATE や DELETE で発生したインデックスエントリのゴミ (デッドタプルに対応する古いエントリ) が、表本体と同じようにインデックス側にも蓄積するためです。これにより、本来コンパクトに収まっていたインデックスがどんどん膨らみ、bloat (膨れ) と呼ばれる状態になります。膨らんだインデックスは検索が遅くなり、ディスクとメモリを無駄に消費します。
厄介なのは、VACUUM はテーブル側のデッドタプルを掃除してくれますがインデックスは完全には縮めてくれないという点です。一度膨らんだインデックスを物理的に縮めるには、REINDEX や pg_repack などを使った明示的なメンテナンスが必要になります。本章では bloat の検出方法、REINDEX の基本と本番運用での注意点 (CONCURRENTLY オプションのハマりどころ)、そして pg_repack を使った無停止メンテナンスまでを順に解説します。
-- ① 各インデックスのサイズと使用状況
SELECT
schemaname,
relname AS table,
indexrelname AS index,
pg_size_pretty(pg_relation_size(indexrelid)) AS size,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20;
-- ② 未使用インデックス (idx_scan = 0) を削除候補に
SELECT schemaname, relname, indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelname NOT LIKE '%pkey' -- PK は除外
ORDER BY pg_relation_size(indexrelid) DESC;
-- ③ bloat 推定 (pgstattuple 拡張、より正確)
CREATE EXTENSION pgstattuple;
SELECT * FROM pgstatindex('idx_orders_user_id');
-- avg_leaf_density が 50% 以下なら bloat 大REINDEX はインデックスを完全に作り直して bloat を解消します。 ただし、デフォルトの REINDEX はACCESS EXCLUSIVE ロックを取るので、本番では使えません。
-- ❌ 本番で使うな (ACCESS EXCLUSIVE で全停止) REINDEX INDEX idx_orders_user_id; REINDEX TABLE orders; -- ◯ 本番で使える (PG 12+) REINDEX INDEX CONCURRENTLY idx_orders_user_id; REINDEX TABLE CONCURRENTLY orders; -- PG 11 以前は CREATE INDEX CONCURRENTLY で代替 CREATE INDEX CONCURRENTLY idx_orders_user_id_new ON orders (user_id); DROP INDEX CONCURRENTLY idx_orders_user_id; ALTER INDEX idx_orders_user_id_new RENAME TO idx_orders_user_id;
pg_repack 拡張は、テーブル本体・インデックスを完全無停止で再構築できます。 REINDEX CONCURRENTLY より柔軟で、テーブル bloat も同時に解消できます。
# インストール (RHEL/CentOS) sudo yum install pg_repack15 # DB に拡張作成 psql -c "CREATE EXTENSION pg_repack;" # テーブルを再構築 (bloat 解消) pg_repack -h host -U user -d mydb -t orders # インデックスのみ再構築 pg_repack -h host -U user -d mydb --index=idx_orders_user_id # 並列度を上げる pg_repack -j 4 -h host -U user -d mydb -t orders
REINDEX はインデックスを再構築するコマンドです。bloat したインデックスを「捨てて作り直す」ことでサイズと検索速度を回復させます。本番運用では CONCURRENTLY 付きでの使用が必須です。
-- 基本構文
REINDEX [(option [, ...])] {INDEX | TABLE | SCHEMA | DATABASE | SYSTEM}
[CONCURRENTLY] name;
-- オプション
-- ( VERBOSE )
-- ( CONCURRENTLY ) -- PG 12+ で別名指定可能
-- ( TABLESPACE name ) -- PG 14+
-- ( CONCURRENTLY [boolean] ) -- PG 12+INDEX nameTABLE nameSCHEMA nameDATABASE nameSYSTEM nameCONCURRENTLY (PG 12+)VERBOSETABLESPACE name (PG 14+)実運用での組み合わせ例:
-- ① 本番標準: 個別 index を無停止再構築 REINDEX (CONCURRENTLY, VERBOSE) INDEX idx_orders_user_id; -- ② テーブル全 index を無停止再構築 REINDEX (CONCURRENTLY) TABLE orders; -- ③ スキーマ全体 (深夜計画停止) REINDEX (CONCURRENTLY) SCHEMA public; -- ④ DB 全体 (大規模再構築、計画停止前提) REINDEX (CONCURRENTLY) DATABASE myapp; -- ⑤ 別テーブルスペースへ移動しつつ再構築 (PG 14+) REINDEX (TABLESPACE fast_ssd, CONCURRENTLY) INDEX idx_huge; -- ⑥ シェルから (reindexdb は内部で REINDEX 文を発行) $ reindexdb --concurrently -d myapp -t orders $ reindexdb --concurrently -d myapp --jobs 4 -- 並列再構築 -- ⑦ CONCURRENTLY 失敗時の検出と削除 SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid; DROP INDEX CONCURRENTLY idx_failed_reindex; -- ⑧ システムカタログの REINDEX (CONCURRENTLY 不可) -- 単一ユーザーモードで実行する必要あり (緊急修復用) $ postgres --single -D /var/lib/postgresql/16/main myapp backend> REINDEX SYSTEM myapp;
似た目的のコマンドが複数あるので、使い分けの整理が重要です。
本章では、PostgreSQL のトランザクション分離レベルと、レベルごとに発生し得る並行実行時の異常 (アノマリ) について学んでいきます。
分離レベル (Isolation Level) とは、「同時に走っている他のトランザクションの影響を、どこまで自分から遮断するか」を決める設定です。たとえば、自分のトランザクション処理中に別のユーザーがデータを書き換えた場合、その変更が自分にも途中で見えてしまうのか、それとも完全に遮断されて自分の処理が終わるまでは見えないのか — この境界を決めるのが分離レベルです。レベルを強くすれば整合性は上がりますが、ロックや競合による性能低下が増えるトレードオフがあります。
SQL 標準では分離レベルは 4 段階 (Read Uncommitted / Read Committed / Repeatable Read / Serializable) と定められていますが、PostgreSQL の実装はSQL 標準とは少し違う挙動を持っています。たとえば PostgreSQL では Read Uncommitted は実質的に存在せず Read Committed と同じ動きをします。また Repeatable Read は SQL 標準よりも強い保証を提供します。本章では各レベルの定義、4 つのアノマリ (Dirty Read / Non-Repeatable Read / Phantom Read / Serialization Anomaly)、そして PostgreSQL 独自の挙動の違いを 1 つの対応表にまとめて整理します。
| 分離レベル | Dirty Read | Non-Repeat | Phantom | Serial 異常 |
|---|---|---|---|---|
| Read Uncommitted | ○ | ○ | ○ | ○ |
| Read Committed | × | ○ | ○ | ○ |
| Repeatable Read | × | × | × (PG では発生しない) | ○ |
| Serializable | × | × | × | × |
PostgreSQL の特殊なところを 2 つ。① Read Uncommitted は実装されておらず、指定しても Read Committed として扱われます。 ② Repeatable Read は、SQL 標準では Phantom が起こりますが、PostgreSQL では起こりません (Snapshot Isolation のため)。
-- トランザクション単位で BEGIN; SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- ... COMMIT; -- セッション単位 SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- DB 全体 (postgresql.conf) default_transaction_isolation = 'read committed'
トランザクションを開始・確定・取り消すコマンド群です。アプリ側のフレームワーク (Rails / Django / Prisma 等) が普段自動でこれを発行していますが、psql で手動操作する場面 (調査・修復・バッチ) では正確な構文知識が必要です。
-- ① 基本構文
BEGIN [WORK | TRANSACTION]
[ ISOLATION LEVEL { SERIALIZABLE | REPEATABLE READ | READ COMMITTED | READ UNCOMMITTED } ]
[ READ WRITE | READ ONLY ]
[ NOT DEFERRABLE | DEFERRABLE ]
[ , ... ];
COMMIT [WORK | TRANSACTION] [AND [NO] CHAIN];
ROLLBACK [WORK | TRANSACTION] [AND [NO] CHAIN];
-- ② START TRANSACTION は BEGIN と同義 (SQL 標準形)
START TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY;ISOLATION LEVELdefault_transaction_isolation。READ WRITE | READ ONLYREAD ONLY だと内部で書込防止 + 一部最適化が効く。レポート系トランザクションでつけると安全と性能の両得。Standby では強制 READ ONLY。DEFERRABLEAND CHAIN (PG 12+)-- ① 普通のトランザクション
BEGIN;
UPDATE orders SET status = 'shipped' WHERE id = 1;
INSERT INTO audit_log (action) VALUES ('shipped order 1');
COMMIT;
-- ② Serializable + READ ONLY DEFERRABLE で長時間レポート
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
SELECT count(*), sum(amount) FROM orders WHERE created_at >= '2026-01-01';
COMMIT;
-- ③ 念のためトランザクションで実行して動作確認
BEGIN;
EXPLAIN (ANALYZE, BUFFERS)
UPDATE orders SET ... WHERE ...;
ROLLBACK; -- 必ず ROLLBACK で巻き戻す
-- ④ 自動コミットなしで複数 Tx を連続実行 (PG 12+)
BEGIN;
-- 処理 1
COMMIT AND CHAIN;
-- 処理 2 (同じ設定で自動的に新トランザクション)
COMMIT;
-- ⑤ psql のオートコミットを切る
\set AUTOCOMMIT off -- psql クライアント側の設定
-- 以降、明示的に COMMIT/ROLLBACK が必要SAVEPOINT はトランザクション内に「戻り点」を作るコマンドで、エラーが起きてもトランザクション全体を巻き戻さずに、SAVEPOINT 以降だけを取り消せます。複雑なバッチ処理や、エラー耐性のある一括投入で重宝します。
-- 基本構文 SAVEPOINT name; ROLLBACK TO [SAVEPOINT] name; -- name までロールバック (Tx は継続) RELEASE [SAVEPOINT] name; -- savepoint を解放 (もう戻れなくなる)
-- ① バッチで「失敗した 1 件だけスキップ」したい時
BEGIN;
INSERT INTO orders (...) VALUES (...); -- ① 成功
SAVEPOINT sp_item_2;
INSERT INTO orders (...) VALUES (...); -- ② エラー!
ROLLBACK TO sp_item_2; -- ② だけ取り消し
INSERT INTO orders (...) VALUES (...); -- ③ 続行 (成功)
COMMIT;
-- → ① と ③ だけ保存される
-- ② エラーハンドリングと組み合わせ
BEGIN;
SAVEPOINT before_risky;
-- 失敗する可能性のある処理
INSERT INTO ... VALUES (...);
-- 成功したらここで RELEASE
RELEASE SAVEPOINT before_risky;
COMMIT;
-- 失敗したら ROLLBACK TO before_risky を実行する設計
-- ③ psql の自動 SAVEPOINT (エラー時に自動巻き戻し)
\set ON_ERROR_ROLLBACK interactive
-- 以降、エラーが出てもトランザクションが abort 状態にならず続行可
-- ④ ネストした SAVEPOINT
BEGIN;
SAVEPOINT outer_sp;
-- 何か処理
SAVEPOINT inner_sp;
-- 内側の処理
RELEASE SAVEPOINT inner_sp;
ROLLBACK TO outer_sp; -- inner も outer も巻き戻る
COMMIT;SAVEPOINT のコスト: SAVEPOINT は内部的にサブトランザクションを作るため、1 トランザクション内に大量 (数千〜) の SAVEPOINT を作ると subtransaction overflow が起きて性能が劇的に劣化します。一部 ORM のデフォルト挙動 (例: 古い ActiveRecord) が毎クエリで SAVEPOINT を打つアンチパターンがあるので注意。
本章では、PostgreSQL のロックの種類と、それぞれがいつ・どのように取得されるかについて学んでいきます。
PostgreSQL のロックは、複数のトランザクションが同じデータを同時に触ろうとした際に、データの整合性を保つために PostgreSQL が内部で取得する「待ち合わせのための印」です。たとえば、トランザクション A がある行を UPDATE している最中に、トランザクション B が同じ行を UPDATE しようとした場合、B は A の処理が終わるまで自動的に待たされます。この「待たせる」「待つ」を実現しているのがロックです。
PostgreSQL のロックは粒度の違いにより 3 階層に分かれます。テーブル全体を対象とするテーブルロック (8 段階の強さがある)、個々の行を対象とする行ロック (4 種類)、そしてアプリケーションが任意の意味を持たせて使える Advisory Lock (アプリ用) の 3 つです。Advisory Lock はセマフォやミューテックスのように、業務的な排他制御 (バッチの二重起動防止など) に流用できます。
通常、ロックは UPDATE や SELECT FOR UPDATE によって暗黙的に取得され、トランザクションのコミットまたはロールバック時に自動的に解放されます。アプリケーション側で明示的に意識する場面は多くありません。ところが、ALTER TABLE や VACUUM FULL といった強い粒度のロックを長時間保持する操作を本番環境で実行した場合、後続のクエリがすべてロック待ちの行列に並び、結果としてサービス全体が応答不能に陥る事故が発生します。本番運用でもっとも踏みやすい運用ミスの一つです。
本章ではまずロックの取得から解放までのライフサイクルを押さえ、続いてテーブルロック 8 レベルの一覧と完全な競合マトリクス、SQL コマンド別の取得ロック早見表、行ロック 4 種類の詳細、Advisory Lock の使い分け、そして実運用でもっとも重要なロック状態の確認方法と待ち時間制御までを扱います。ロックの挙動を体系的に把握しておくことが、本番事故の予防と原因特定の両面で大きな助けとなります。
PostgreSQL の重量ロック (heavyweight lock)はすべて「取得した瞬間」から「トランザクション終了 (COMMIT / ROLLBACK)」まで保持され続けるのが大原則です。文単位で解放されないため、長いトランザクションは取得した全ロックをその間ずっと抱え続けます。これが本番事故の根本原因になります。
特に重要なのは④ の待ち行列です。たとえば本番で素の ALTER TABLE (ACCESS EXCLUSIVE 要求) を発行すると、たとえ要求自体は数ミリ秒の処理でも、先行する長い SELECT が終わるまで待たされ、その間に後続の SELECT が全員「ALTER の後ろ」に並ばされるため、結果として全停止します。これが「ALTER TABLE は速いはずなのに本番が止まる」現象の正体です。
| # | ロック名 | 略 | 取られる時 (代表的な SQL) | 競合するレベル | 解説 |
|---|---|---|---|---|---|
| 1 | ACCESS SHARE | AS | SELECT (読み取りのみ) | AE のみ | もっとも弱い。SELECT 文が暗黙取得し、テーブル定義の変更だけをブロック。 |
| 2 | ROW SHARE | RS | SELECT FOR UPDATE / SHARE / KEY SHARE | E, AE | 行ロックを取る SELECT が取得。読み取りは止めず、テーブルを排他で開く操作だけブロック。 |
| 3 | ROW EXCLUSIVE | RX | INSERT / UPDATE / DELETE / MERGE | S, SRX, E, AE | 行を書き換える系の DML が取得。他の DML とは共存可、CREATE INDEX や ALTER とは競合。 |
| 4 | SHARE UPDATE EXCLUSIVE | SUX | VACUUM (FULL でない), ANALYZE, CREATE INDEX CONCURRENTLY, REINDEX CONCURRENTLY, ALTER TABLE 一部 (VALIDATE 等) | SUX, S, SRX, E, AE | メンテ系の「DML は止めない、ただし同種のメンテは 1 つだけ」を実現するレベル。同一テーブルで VACUUM が二重起動しないのもこれのおかげ。 |
| 5 | SHARE | S | CREATE INDEX (CONCURRENTLY なし) | RX, SUX, SRX, E, AE | DML はブロック (RX とぶつかる) するが、SELECT は通す。本番で素の CREATE INDEX を打つと書き込みが全停止する原因。 |
| 6 | SHARE ROW EXCLUSIVE | SRX | CREATE TRIGGER, ALTER TABLE 一部 | RX, SUX, S, SRX, E, AE | SHARE と ROW EXCLUSIVE の両方の役割。実用上 ALTER 系の中程度の DDL で出てくる。 |
| 7 | EXCLUSIVE | E | REFRESH MATERIALIZED VIEW CONCURRENTLY | RS, RX, SUX, S, SRX, E, AE | ほぼ全てをブロックするが、ACCESS SHARE (=素の SELECT) だけは通す稀有な存在。 |
| 8 | ACCESS EXCLUSIVE | AE | ALTER TABLE (多くの形), DROP TABLE, TRUNCATE, REINDEX (非 CONCURRENTLY), VACUUM FULL, CLUSTER, LOCK TABLE のデフォルト | 全て (AS も含む) | もっとも強い。SELECT すら止まる。本番事故の主因の 9 割はこのレベルが絡む。 |
ACCESS EXCLUSIVE が最強。これを取るとSELECT すら止まります。 ALTER TABLE や VACUUM FULL でこれが取られるため、本番で安易に打つと全停止します。覚え方は「ACCESS が付くと SELECT すら止める / EXCLUSIVE が付くと DML を止める」。
行が「自分が持っているロック」、列が「他が取りに来たロック」。✓ なら共存可、✗ なら後から来た方が待たされます。表の右下に向かうほど競合が増えていきます。
| 保持 \ 要求 | AS | RS | RX | SUX | S | SRX | E | AE |
|---|---|---|---|---|---|---|---|---|
| AS | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ |
| RS | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ |
| RX | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ |
| SUX | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| S | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ |
| SRX | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
| E | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
| AE | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
重要な観察: ① 行 AS (素の SELECT) と列 AE の 1 マスだけが ✗ — つまりSELECT を止められるのは AE だけ。② 行 RX (DML) と列 RX は ✓ — つまり異なる行への DML 同士は競合しない (これがあるから OLTP がスケールする)。③ 列 AE は全て ✗ — 何かを保持している間は AE を取れない。
実務でよく使う SQL がそれぞれどのロックを取るかの早見表。本番に投入する DDL の安全性を判断する時の第一参照として使います。
| SQL | テーブルロック | 行ロック | 備考 |
|---|---|---|---|
| SELECT | ACCESS SHARE | — | もっとも軽い読み取り。 |
| SELECT ... FOR KEY SHARE | ROW SHARE | FOR KEY SHARE | FK 整合性チェックで内部利用される最弱の行ロック。 |
| SELECT ... FOR SHARE | ROW SHARE | FOR SHARE | 読みたいが他の UPDATE を防ぎたい時。 |
| SELECT ... FOR NO KEY UPDATE | ROW SHARE | FOR NO KEY UPDATE | PK 以外の列を更新する予定の弱版 FOR UPDATE。 |
| SELECT ... FOR UPDATE | ROW SHARE | FOR UPDATE | 行を後で更新する確定意思を示す。 |
| INSERT / UPDATE / DELETE / MERGE | ROW EXCLUSIVE | 対象行に行ロック | UPDATE/DELETE は FOR UPDATE 相当の行ロックも自動取得。 |
| CREATE INDEX (CONCURRENTLY なし) | SHARE | — | 書き込みを止める。本番では使わない。 |
| CREATE INDEX CONCURRENTLY | SHARE UPDATE EXCLUSIVE | — | 読み書き継続可。ただし時間はかかる。 |
| VACUUM (FULL なし) | SHARE UPDATE EXCLUSIVE | — | 通常運用。並走しない、それ以外と競合せず。 |
| ANALYZE | SHARE UPDATE EXCLUSIVE | — | 統計情報更新。VACUUM と排他。 |
| ALTER TABLE ... ADD COLUMN (DEFAULT なし) | ACCESS EXCLUSIVE | — | PG 11+ ではメタデータ書換のみで即時。短時間だが瞬間的に AE。 |
| ALTER TABLE ... ADD COLUMN ... DEFAULT 〜 | ACCESS EXCLUSIVE | — | PG 11+ で IMMUTABLE デフォルト値なら即時 / volatile (now() 等) は全行 rewrite で長時間 AE。 |
| ALTER TABLE ... ADD CONSTRAINT ... CHECK | ACCESS EXCLUSIVE | — | NOT VALID を付ければ短時間。後で VALIDATE CONSTRAINT は SHARE UPDATE EXCLUSIVE。 |
| ALTER TABLE ... ADD FOREIGN KEY | SHARE ROW EXCLUSIVE | — | 参照側と被参照側の両テーブルで SRX。やはり NOT VALID 推奨。 |
| ALTER TABLE ... ALTER COLUMN TYPE | ACCESS EXCLUSIVE | — | 型キャストが必要なら全行 rewrite で長時間。 |
| DROP TABLE / TRUNCATE | ACCESS EXCLUSIVE | — | TRUNCATE は速いが AE を取るので待たされる側からは「遅い」と見える。 |
| REINDEX (CONCURRENTLY なし) | ACCESS EXCLUSIVE | — | 本番では必ず REINDEX CONCURRENTLY を使う。 |
| REINDEX CONCURRENTLY | SHARE UPDATE EXCLUSIVE | — | 本番でインデックス膨張を直す時の標準手段。 |
| VACUUM FULL / CLUSTER | ACCESS EXCLUSIVE | — | 全テーブル rewrite。本番では原則禁止、pg_repack を使う。 |
| REFRESH MATERIALIZED VIEW (CONCURRENTLY なし) | ACCESS EXCLUSIVE | — | リフレッシュ中は MV を SELECT すらできない。 |
| REFRESH MATERIALIZED VIEW CONCURRENTLY | EXCLUSIVE | — | 読み取り (素の SELECT) は通すが、ROW SHARE 以上を全部止める。 |
| LOCK TABLE foo; | ACCESS EXCLUSIVE | — | モード未指定の LOCK TABLE はデフォルトで AE。明示しないと事故る。 |
本番投入の判断軸: テーブルロックが SHARE UPDATE EXCLUSIVE 以下なら DML を止めないので原則安全。SHARE 以上は書き込みが止まる、EXCLUSIVE 以上は業務時間中は避ける。ACCESS EXCLUSIVE は「短時間で済むことが保証できる時」だけ。
行ロックはテーブルロックとは別系統で、行単位の競合を制御します。SELECT ... FOR ... 構文で明示取得し、UPDATE / DELETE も対象行に内部で取得します。強さは 4 段階あり、用途で使い分けます。
| 名前 | 強さ | ブロックするもの | 用途・典型例 |
|---|---|---|---|
| FOR UPDATE | 最強 | FOR UPDATE / FOR NO KEY UPDATE / FOR SHARE / FOR KEY SHARE すべて | 行を後で更新するのが確定。最も標準的な「悲観ロック」用途。 |
| FOR NO KEY UPDATE | 強 | FOR UPDATE / FOR NO KEY UPDATE / FOR SHARE | PK / 一意制約列を変更しないと約束する弱い FOR UPDATE。UPDATE 文が自動でこれを取る (PK 列を触らない時)。 |
| FOR SHARE | 弱 | FOR UPDATE / FOR NO KEY UPDATE | 読みたいが他の UPDATE を防ぎたい (与信枠の参照、在庫の読み合わせ等)。複数 Tx が同時に取れる。 |
| FOR KEY SHARE | 最弱 | FOR UPDATE のみ | FK の参照元行に対し PostgreSQL が内部で取る。子側で INSERT する時に親が消えないことだけ保証。 |
-- ① 在庫を引き当ててから減算する (典型的な悲観ロック) BEGIN; SELECT stock FROM items WHERE id = 42 FOR UPDATE; -- 行ロック取得 -- アプリ側で stock >= 1 を確認 UPDATE items SET stock = stock - 1 WHERE id = 42; COMMIT; -- ここで解放 -- ② FK 用に内部で取られる FOR KEY SHARE (PostgreSQL が勝手に取る) INSERT INTO orders (user_id, ...) VALUES (10, ...); -- → users(id=10) に対し裏で FOR KEY SHARE が取られ、 -- トランザクション中に users(id=10) が消されないことが保証される -- ③ FOR NO KEY UPDATE は UPDATE が自動取得する (PK 列を触らない時) UPDATE items SET name = 'new' WHERE id = 42; -- → name 列のみ更新。FK 参照側からの FOR KEY SHARE と共存できる
行ロックは「取れるまで待つ」のがデフォルトですが、待ちたくない / 取れない行をスキップしたい場合のオプションが 2 つあります。特に SKIP LOCKED はDB ベースのジョブキュー実装の事実上の標準パターンです。
-- ① NOWAIT — 取れなければ 55P03 エラーで即座に返る SELECT * FROM items WHERE id = 42 FOR UPDATE NOWAIT; -- 用途: UI から「ロック中なら諦めて他のことをする」ユースケース -- 待ち時間 0 で UX を保つ -- ② SKIP LOCKED — 取れない行を結果から除外する -- ★ ジョブキューの取り出しパターン (worker が並列に走っても重複しない) SELECT id, payload FROM jobs WHERE status = 'queued' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 10; -- 取れた 10 件をその場で 'running' に UPDATE → COMMIT。 -- 他の worker は SKIP LOCKED で別の 10 件を取れる。 -- AWS SQS や Sidekiq を入れずに済むのが利点。 -- ③ 比較 -- (デフォルト) 待つ 正確だが詰まりやすい -- NOWAIT 即エラー UI のリトライ前提 -- SKIP LOCKED 即スキップ ジョブキュー / バッチ並列処理
SKIP LOCKED の落とし穴: 結果が「その瞬間に他で持たれていない行」になるため、FIFO 順序が保証されないことに注意。古い順に確実に処理したい場合は、worker 数を 1 に絞るか、別のキューイング機構を検討します。
Advisory Lock はテーブル / 行とは独立した「整数値の名前空間」に対するロックで、PostgreSQL は意味を解釈せず、アプリ側が決めた ID をミューテックスのように使います。バッチ二重起動防止、分散リーダー選出、レート制限などに使われます。
| 関数 | スコープ | 挙動 | 説明 |
|---|---|---|---|
| pg_advisory_lock(id) | セッション | ブロック | 取れるまで待ち続ける。セッション終了 or pg_advisory_unlock で解放。 |
| pg_try_advisory_lock(id) | セッション | 即時 false | 取れなければ即座に false を返す。バッチ二重起動防止の定番。 |
| pg_advisory_xact_lock(id) | トランザクション | ブロック | COMMIT/ROLLBACK で自動解放。unlock 忘れの心配がない。 |
| pg_try_advisory_xact_lock(id) | トランザクション | 即時 false | 最も使いやすい組合せ。試行 + 自動解放。 |
| pg_advisory_unlock(id) | セッション | — | セッションスコープのロックを明示的に解放。 |
| pg_advisory_unlock_all() | セッション | — | そのセッションが取った Advisory Lock を全部解放。 |
-- ① バッチ二重起動防止 (最も典型的なパターン)
BEGIN;
-- 'nightly-aggregation' を 64bit ハッシュにして ID にする
-- false が返ったら他で既に動いているので即終了
SELECT pg_try_advisory_xact_lock(hashtext('nightly-aggregation'));
-- → true なら処理本体、false なら何もせず COMMIT
COMMIT; -- xact 版は自動解放
-- ② 2 引数版 — (classId, objectId) の組で名前空間を分ける
SELECT pg_try_advisory_xact_lock(100, user_id);
-- 用途: 「ユーザー単位のクリティカルセクション」を作りたい時
-- ③ セッション版でリーダー選出
-- アプリ起動時に取りに行き、取れた 1 台だけがリーダーになる
SELECT pg_try_advisory_lock(42);
-- アプリ終了 / セッション切断で自動解放される使い分けの指針: 迷ったら pg_try_advisory_xact_lock を使うのが安全。「トランザクション終了で自動解放 + 取れなければ即 false」の組合せがほとんどのユースケースを満たし、解放忘れの事故を防げます。pg_advisory_lock (ブロック版) は必ず取れる保証が必要な時に限定。
本番でクエリが止まっている時、「今、誰が、何を、どのレベルでロックしていて、誰を待たせているか」を把握できなければ復旧できません。これを 1 本の SQL で見るのが以下のパターンです。
-- ① まず「待たされているクエリ」を列挙
SELECT pid, now() - query_start AS waiting_for,
wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
ORDER BY waiting_for DESC;
-- ② 待たせている張本人 (blocking pid) を炙り出す
SELECT pid, pg_blocking_pids(pid) AS blocked_by,
state, query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
-- ③ pg_locks で具体的なロックモードを確認
SELECT l.locktype, l.relation::regclass, l.mode, l.granted,
a.pid, a.usename, a.query
FROM pg_locks l
JOIN pg_stat_activity a USING (pid)
WHERE l.relation = 'orders'::regclass
ORDER BY l.granted DESC;
-- granted=true が現在のロック保持者、false が待ち。
-- ④ 困った時の最終手段 — 待たせている側を切断
SELECT pg_cancel_backend(<pid>); -- まずキャンセル
SELECT pg_terminate_backend(<pid>); -- ダメなら強制切断覚えておくべき 1 行: SELECT pid, pg_blocking_pids(pid), query FROM pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) > 0; — これだけで「待ち状況」がほぼ全部分かります。本番障害対応の最初の 1 発はこれ。
ロック待ちは無制限だと致命的に長くなります。本番では必ずタイムアウトを設定して、最悪でも「待ち過ぎ」で詰まらないようにします。
-- ① lock_timeout — ロック取得の待ち時間に上限 SET lock_timeout = '3s'; ALTER TABLE orders ADD COLUMN flag bool; -- 3 秒以内にロックを取れなければ 55P03 でエラー終了 -- → 本番に DDL を打つ時は必ずこれを付ける -- ② statement_timeout — クエリの実行時間そのものに上限 SET statement_timeout = '30s'; -- 30 秒を超えると 57014 (query_canceled) で打ち切られる -- ③ idle_in_transaction_session_timeout — 「BEGIN したまま放置」を殺す SET idle_in_transaction_session_timeout = '60s'; -- BEGIN 後にアプリ側が放置すると、行ロックを抱えたまま全停止する -- → 60 秒で自動 ROLLBACK 切断 -- ④ 本番運用の推奨セット (postgresql.conf) -- lock_timeout = '5s' -- statement_timeout = '60s' -- バッチでは個別に伸ばす -- idle_in_transaction_session_timeout = '60s'
運用 Tips: 本番に DDL を投入する時は毎回 SET lock_timeout を先頭に付ける運用にします。これがあれば「ALTER TABLE が長時間 ACCESS EXCLUSIVE を待ち続けてサービス全停止」という最悪パターンを構造的に防げます。
ALTER TABLE ... ADD COLUMN ... DEFAULT now() を実行。 ACCESS EXCLUSIVE で全アクセスが止まり、しかも now() は volatile なので全行 rewrite が走り、何時間も終わらない。 対策: ① PG 11+ なら DEFAULT 定数 (IMMUTABLE) は即時、② volatile デフォルトは「カラムを NULL で追加 → DEFAULT を別 ALTER → バッチで埋める」の 3 ステップ、③ 必ず SET lock_timeout 付き。CREATE INDEX CONCURRENTLY。 ② FK 追加は ADD CONSTRAINT ... NOT VALID → 後で VALIDATE CONSTRAINT。 ③ 制約変更は NOT VALID パターンで AE 保持時間を最小化。 ④ 物理再編成 (VACUUM FULL / CLUSTER) は使わず pg_repack。 ⑤ どんな DDL も先頭に SET lock_timeout = '3s'; SET statement_timeout = '60s';。SELECT pid, pg_blocking_pids(pid), query FROM pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) > 0; で詰まりの全体像。 ② SELECT * FROM pg_locks WHERE NOT granted で待ちロックの実体。 ③ SELECT pg_cancel_backend(pid) → ダメなら pg_terminate_backend(pid)。デッドロックは、2 つ以上のトランザクションが互いの保持するロックを待ち合う状態。 放置すると永久に解決しないため、PostgreSQL は定期的に検出して片方を強制終了します。
デッドロックは、2 つのトランザクションがロックを取る順序が逆の時に起こります。 最も典型的なのが「Tx A は row_1 → row_2 の順、Tx B は row_2 → row_1 の順」というすれ違いです。 以下に時系列で何が起きるかを示します。
UPDATE row_1 ...UPDATE row_2 ...UPDATE row_2 ...UPDATE row_1 ...T4 の時点で、両トランザクションが「相手が握っているロック」を待つ状態になります。 どちらも自分のトランザクションを完了するまでロックを解放できないので、放っておくと永遠にこの状態が続きます。これがデッドロックです。 関係を図で表すと以下の循環待機 (circular wait) グラフになります:
矢印が Tx A → row_2 → Tx B → row_1 → Tx A と一周しているのが見えます。これが「循環待機」で、外部から介入しない限り永久に解けません。 そのため PostgreSQL は deadlock_timeout (デフォルト 1 秒) ごとに待ち関係のグラフをスキャンし、循環を発見した時点でどちらか片方を強制的に ROLLBACK させます。 ROLLBACK されたトランザクション側は 40P01 エラーを受け取り、アプリ側でリトライするのが定石です。
静止画では伝わりにくい「時系列でどう状態が変わるか」をシミュレータで体験できます。シナリオを選んで 次へ ボタンを進めると、各 Tx が何をしているか・どのロックを保持しているか・どこで待機しているか・どの瞬間に循環ができるか・PostgreSQL がどう介入するかが、1 ステップずつ見えます。
(まだ何もしていません)
(まだ何もしていません)
PostgreSQL はdeadlock_timeout (デフォルト 1 秒) 経過後にデッドロックを検出。 関係するトランザクションのうち片方を強制中断します (40P01 エラー)。
-- デッドロック検出時のエラー例
ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 678;
blocked by process 12346.
Process 12346 waits for ShareLock on transaction 679;
blocked by process 12345.
HINT: See server log for query details.ORDER BY id を癖にすると安全。log_lock_waits = on を設定すると、 deadlock_timeout を超えるロック待ちが発生したらログに残ります。本番で必ず ON にしておきましょう。本章では、PostgreSQL の動作を観測するための統計ビュー pg_stat_* と、それを使ったボトルネック診断の手法について学んでいきます。
pg_stat_* は、PostgreSQL が自分自身の動作をリアルタイムに記録して公開している統計ビュー群です。実体は通常の SQL で SELECT 可能なビューで、名前が pg_stat_ で始まるものが数十種類用意されています。
各ビューには、たとえば「どのテーブルが何回スキャンされたか」「現在どのクエリが実行中か」「インデックスは何回参照されたか」「ディスク I/O はどれくらい発生したか」といった内部状態が記録されます。位置づけとしては、OS における top や iostat に相当する、DB のための観測ツールと考えると分かりやすいでしょう。
パフォーマンスチューニングをこの pg_stat_* から始める理由は、DB が遅くなった際に推測でパラメータを調整しても効果が安定しないことにあります。「遅いように感じたのでメモリを増やした」という対処では、結果は当たることもあれば外れることもあり、再現性のある改善になりません。本番 DB の性能問題に対する正しい初手は、pg_stat_* を参照してどこが詰まっているかを診断することです。これにより、たとえば次のような疑問に直接的な答えが得られます。
pg_stat_activitypg_stat_user_tablespg_stat_user_indexespg_statio_user_tablespg_stat_replicationpg_stat_* を読めるかどうかは、推測でパラメータを変更する対症療法と、根拠に基づいて原因を特定する診断的アプローチを分ける境界と言ってよいものです。 本セクションでは、本番で頻繁に参照する主要ビューの役割を解説したうえで、現場ですぐ使える診断クエリを目的別に紹介していきます。
pg_stat_activityまず最初に見るビューpg_stat_user_tablesインデックス効果やブロート確認pg_stat_user_indexes使われていないインデックスの発見pg_stat_statementsスロークエリ Top 10 特定pg_stat_databaseキャッシュ効率の全体観pg_stat_bgwriterI/O 不足の診断pg_stat_replicationスタンバイ監視pg_locksロック競合の診断-- ① 長時間実行中のクエリを発見
SELECT pid, now() - query_start AS duration, query, state
FROM pg_stat_activity
WHERE state != 'idle' AND now() - query_start > INTERVAL '10 seconds'
ORDER BY duration DESC;
-- ② キャッシュヒット率 (95% 以上が理想)
SELECT
sum(heap_blks_hit) / nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0) AS hit_ratio
FROM pg_statio_user_tables;
-- ③ 使われていないインデックス (削除候補)
SELECT schemaname, relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
-- ④ デッドタプル多いテーブル
SELECT relname, n_dead_tup, n_live_tup,
round(n_dead_tup::numeric / nullif(n_live_tup, 0) * 100, 1) AS dead_pct
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY dead_pct DESC NULLS LAST;
-- ⑤ ロックブロックの発生源
SELECT blocked.pid AS blocked_pid, blocking.pid AS blocking_pid,
blocked.query AS blocked_query, blocking.query AS blocking_query
FROM pg_stat_activity AS blocked
JOIN pg_stat_activity AS blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid));本章では、PostgreSQL の本番運用で必ず直面するスロークエリの特定と対処方法について学んでいきます。
スロークエリ (slow query) とは、文字通り「実行に時間のかかる SQL 文」を指す現場用語です。絶対的な閾値が定められているわけではなく、各システムが「これより遅ければ問題である」と決めた基準値 (一般には 200ms、500ms、1 秒などが用いられます) を超えた SQL を、その環境におけるスロークエリと呼びます。
たとえば、Web API のバックエンドのような OLTP 系システムであれば、1 リクエスト内の SQL は 100ms を超えた時点で要警戒の対象となります。一方、夜間バッチのような処理では 10 秒を超えるあたりが目安になります。このように、システムの特性によって「遅い」と判断される境界線は大きく異なります。
スロークエリの特定と対処を独立した章として扱う理由は、本番 DB のパフォーマンス問題が、ほぼ例外なく「ごく一部のクエリが全体の負荷の大部分を占めている」状態に起因するためです。具体的には、次のような事実が現場で繰り返し観察されます。
スロークエリへの対処手順は、次の 3 段階として確立されています: ① 拾う (どのクエリが遅いかを特定する) → ② 分析する (なぜ遅いのかを調べる) → ③ 直す (改善する)。 いずれか一つでも欠けると効果は得られません。拾わずに直そうとすれば当てずっぽうとなり、分析を飛ばせば逆効果の修正を加えてしまうこともあります。 本セクションでは、この 3 段階それぞれで用いる道具を順に解説していきます。
pg_stat_statements はクエリ毎に累積実行時間と呼出回数を記録する拡張。 PostgreSQL の標準モジュールで、ほぼ必ず有効化すべきです。
-- 有効化
CREATE EXTENSION pg_stat_statements;
-- postgresql.conf:
-- shared_preload_libraries = 'pg_stat_statements'
-- pg_stat_statements.track = all
-- 累積時間トップ 10
SELECT query, calls, total_exec_time / 1000 AS total_sec,
mean_exec_time AS mean_ms, rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
-- 統計リセット
SELECT pg_stat_statements_reset();特定したクエリに対し、Section 10 で扱った EXPLAIN (ANALYZE, BUFFERS) を実行。 重要なのは:
auto_explain モジュールを有効化すると、 指定時間を超えたクエリの EXPLAIN を自動でログに記録してくれます (例: 1 秒以上)。 本番でスロークエリが発生した瞬間を捉えられるので、調査がはかどります。本章では、PostgreSQL のパフォーマンスに直結するメモリ関連設定について学んでいきます。
PostgreSQL のメモリ設定とは、サーバが搭載している物理メモリを、PostgreSQL のどの用途に、どれだけ割り当てるかを制御する設定群を指します。PostgreSQL は内部でメモリを次の 3 つの用途に分けて利用しています。
① テーブルやインデックスのページキャッシュ (読み取りを高速化するための共有領域)、② クエリ実行中のソートやハッシュテーブルなど一時的な作業領域、③ VACUUM や CREATE INDEX といったメンテナンス処理のための作業領域。本章では、これらを制御する 4 つのパラメータの意味と、本番環境での推奨値の考え方を解説します。
メモリ設定を独立した章として扱う理由は、PostgreSQL のデフォルト値が1990 年代の非常に貧弱なハードウェアでも起動できるよう極めて控えめに設定されており、現代の本番サーバ (RAM 16GB〜数百 GB の規模) に対しては実態に対して桁違いに小さいためです。具体的には、各パラメータのデフォルト値は次のような問題を引き起こします。
shared_buffers128 MBwork_mem4 MBeffective_cache_size4 GBmaintenance_work_mem64 MBしたがってメモリ設定の見直しは、いわゆる「細かいチューニング」ではなく、デフォルトのまま運用することで本来の DB 性能を 1 桁分捨てているという事実に対する必須の対応と位置づけられます。 以下ではまず、手元の RAM 量を入力すると各パラメータの推奨値が自動計算される対話ツールを提示し、続いて各パラメータの意味と、その推奨値が導かれる根拠を解説します。前提知識として、G3 の MVCC・VACUUM、G4 の Planner、G5 のインデックスを押さえておくと、各パラメータが性能に効く理屈をより深く理解できます。
shared_bufferseffective_cache_sizework_memmaintenance_work_memshared_bufferseffective_cache_sizework_memmaintenance_work_memSET LOCAL work_mem = '256MB';) が安全。max_connections (デフォルト 100 → PgBouncer 使うなら 200〜400)、wal_buffers (16MB が定石)、random_page_cost (SSD なら 1.1)。pgtune.leopard.in.ua などの自動計算ツールも便利です。本章では、巨大テーブルを物理的に分割する パーティショニングについて学んでいきます。
パーティショニングとは、1 つの大きなテーブルを「論理的には 1 つのテーブル」として見せたまま、「物理的には複数の小さなサブテーブル」に分割する仕組みです。アプリケーションからは普通のテーブルとして SELECT / INSERT できますが、内部では実際のデータは複数のファイルに分かれて保存されます。これにより、テーブルが巨大化しても各サブテーブルは小さいままなので、VACUUM・INDEX 構築・古いデータ削除などが現実的な時間で完了します。
テーブルが数億行を超えると、単一テーブルのままでは VACUUM が終わらない、INDEX 構築に数時間かかる、古いログ行の削除で I/O が詰まる、といった運用破綻が確実に発生します。パーティショニングはこれを根本から解決する手段です。PostgreSQL 10 以降では宣言的パーティショニングがサポートされ、CREATE TABLE 時に PARTITION BY を書くだけで設定できるようになりました。本章ではまず導入判断の基準と親子テーブルの構造を押さえ、続いて RANGE / LIST / HASH の 3 方式の使い分け、DEFAULT と複合パーティション、Partition Pruning (不要パーティションを読み飛ばす最適化)、ATTACH/DETACH での運用、INDEX と制約のルール、既存テーブルからの移行、pg_partman での自動化、本番事故パターンまでを順に解説します。
パーティショニングは「テーブルが大きい」だけでは正解にならず、運用が破綻し始めたタイミングで導入するのが鉄則です。早すぎると保守コスト (子テーブル作成・古いパーティション切り離し) だけが残り、遅すぎると DROP/INDEX 再構築の窓口が取れなくなります。次のいずれかが当てはまるなら、本気で検討すべき時期です。
逆に向かないケース: ① テーブルが 10GB 未満 — パーティション分の管理コストが見合わない。② パーティションキーが頻繁に変わる列 (UPDATE される値) — 行の物理移動が発生し性能劣化。③ クエリの WHERE 句にパーティションキーが入らない — Pruning が効かず全パーティションスキャンになって逆に遅い。
宣言的パーティショニングでは、親テーブル (Partitioned Table) がスキーマ (列定義) を持ち、実データはすべて子テーブル (Partition) に格納されます。親テーブル自体には 1 行も保存されません。SELECT / INSERT は親に対して発行され、PostgreSQL が自動で適切な子に振り分けます。
RANGE: 値の範囲で分割。時系列データの定番。
-- 月ごとに分割
CREATE TABLE logs (
id bigint,
created_at timestamptz,
message text
) PARTITION BY RANGE (created_at);
CREATE TABLE logs_2026_01 PARTITION OF logs
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE logs_2026_02 PARTITION OF logs
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- メリット:
-- ① 古いデータ削除が DROP TABLE で一瞬
-- ② プランナーが該当パーティションだけスキャン (Partition Pruning)
-- ③ パーティション単位で VACUUM 並列化| 観点 | RANGE | LIST | HASH |
|---|---|---|---|
| 典型用途 | 時系列ログ・履歴・売上 | 地域・カテゴリ・テナント | ユーザ ID・セッション ID 分散 |
| キーの型 | 順序のある型 (date / int / numeric) | 離散値 (text / enum) | 何でも (hashtext が定義されていれば) |
| 古いデータ削除 | DROP TABLE で一瞬 | カテゴリ単位で DROP 可 | 不可 (HASH は範囲がない) |
| データ分布 | 時間に沿って偏る (最新が hot) | カテゴリ次第で偏る | 均等に分散 |
| 書込競合 | 最新パーティションに集中 | カテゴリに依存 | 分散される |
| パーティション追加 | 時期が来たら作る (自動化推奨) | 新しい値が出たら作る | 不可 (再構築必要) |
| Partition Pruning | 範囲条件で強く効く | 等価条件で効く | 等価条件でのみ効く |
| 代表例 | logs (月別) / metrics (日別) | customers (国別) / events (region 別) | sessions / orders (user_id) |
選択の決め手: 「古いデータを定期削除するなら RANGE」「カテゴリで業務的に分けたいなら LIST」「ホットスポットを分散させたいなら HASH」。迷ったらRANGE が最頻出です。
実運用ではパーティションを多段にしたり、どれにも該当しない値の受け皿を用意したりします。両者とも本番設計で頻出します。
-- ① DEFAULT パーティション — どこにも入らない値の保険
CREATE TABLE customers_other PARTITION OF customers DEFAULT;
-- 新しい国コード 'KR' が入ってきても customers_other に落ちる
-- ★ DEFAULT がないと「該当パーティションがない」エラーで INSERT 失敗
-- ② DEFAULT の罠
-- DEFAULT に大量に溜まると、新パーティション追加時に
-- 「新領域のデータが DEFAULT にあるかチェック」のため ACCESS EXCLUSIVE が長時間。
-- 対策: 監視して空にする / DEFAULT を作らず明示的に全網羅する設計
-- ③ 複合パーティション — 月別 RANGE × ユーザ HASH の 2 段
CREATE TABLE events (
user_id bigint,
created_at timestamptz,
payload jsonb
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_03 PARTITION OF events
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01')
PARTITION BY HASH (user_id); -- ★ 2 段目
CREATE TABLE events_2026_03_p0 PARTITION OF events_2026_03
FOR VALUES WITH (modulus 4, remainder 0);
CREATE TABLE events_2026_03_p1 PARTITION OF events_2026_03
FOR VALUES WITH (modulus 4, remainder 1);
-- ... p2, p3
-- 効果: 「時系列で古いデータを DROP できる」+「最新月の書込は 4 つに分散」
-- 用途: SaaS のテナント別イベント・大規模ログ収集パーティショニングの性能上の最大の意義がこの Partition Pruning (不要パーティションの読み飛ばし) です。PostgreSQL は 2 段階で枝刈りを行い、それぞれ効くタイミングと条件が違います。
SELECT * FROM logs WHERE created_at >= '2026-02-15';
PREPARE q AS SELECT * FROM logs WHERE created_at >= $1;
EXECUTE q('2026-02-15');-- EXPLAIN 出力の見方
EXPLAIN SELECT * FROM logs
WHERE created_at >= '2026-02-15' AND created_at < '2026-02-20';
Append (cost=0.00..XXX rows=Y width=Z)
-> Seq Scan on logs_2026_02 logs_1
Filter: ((created_at >= '2026-02-15') AND ...)
-- ★ logs_2026_01, logs_2026_03 等が出てこない = Planner-time Pruning 成功
-- Execution-time pruning が起きる時の表示
EXPLAIN ANALYZE EXECUTE q('2026-02-15');
Append ...
Subplans Removed: 11 -- ★ 実行時に 11 個のパーティションが除外された
-> Seq Scan on logs_2026_02 ...
-- 重要なパラメータ
SET enable_partition_pruning = on; -- デフォルト on
SET constraint_exclusion = partition; -- 旧仕組み (継承用), 今は不要plan_cache_mode = force_custom_plan で都度プランニングさせる対策があります。既にデータが入っているテーブルを後からパーティションとして取り込むのが ATTACH PARTITION、パーティションを切り離すのが DETACH PARTITION。本番運用ではこの 2 つのコマンドで「古い月をアーカイブ」「先月分を取り込む」を回します。
-- ① ATTACH — 既存テーブルをパーティション化された親に取り込む
-- 事前にチェック制約を付けておくと取り込み時のスキャンを省略できる
ALTER TABLE logs_2026_04 ADD CONSTRAINT chk_2026_04
CHECK (created_at >= '2026-04-01' AND created_at < '2026-05-01') NOT VALID;
ALTER TABLE logs_2026_04 VALIDATE CONSTRAINT chk_2026_04;
ALTER TABLE logs ATTACH PARTITION logs_2026_04
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
-- ★ チェック制約が範囲と一致していれば全行スキャン不要 (PG が自動判定)
-- ② DETACH CONCURRENTLY (PG 14+) — 親へのロックを最小化して切り離す
ALTER TABLE logs DETACH PARTITION logs_2025_01 CONCURRENTLY;
-- 内部的に 2 段階のトランザクションで実施
-- → 親へのロックは SHARE UPDATE EXCLUSIVE で済み、SELECT/INSERT は止まらない
-- ③ 切り離した後は普通のテーブル → アーカイブして DROP
pg_dump -t logs_2025_01 mydb | gzip > /archive/logs_2025_01.sql.gz
DROP TABLE logs_2025_01;
-- ④ 「先月の DROP + 来月の作成」を月初に回す典型バッチ
BEGIN;
CREATE TABLE logs_2026_05 PARTITION OF logs
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
ALTER TABLE logs DETACH PARTITION logs_2025_05 CONCURRENTLY; -- 13 ヶ月前を切離
COMMIT;ATTACH 時のスキャンに注意: ATTACH は対象テーブルに不正な値が無いかを保証する必要があり、適切な CHECK 制約がないと全行スキャンが走ります。本番テーブルだと数十分〜数時間止まることもあるため、必ず NOT VALID + VALIDATE の順で事前準備します。
パーティションテーブルの INDEX・制約には独特のルールがあります。知らずに本番投入すると「FK が貼れない」「UNIQUE が効かない」「DROP INDEX で全停止」といった事故を踏みます。
| 対象 | ルール | 備考 |
|---|---|---|
| CREATE INDEX | 親に作ると全子に自動で作られる (PG 11+) | 巨大親では時間がかかる → 子に CONCURRENTLY で作ってから ATTACH INDEX |
| CREATE UNIQUE INDEX | パーティションキーを含む必要 | グローバル UNIQUE はない。本物の UNIQUE が必要なら別途設計 (一意 ID 生成側で担保) |
| PRIMARY KEY | 同上 — パーティションキーが PK に含まれる必要 | 時系列なら (id, created_at) のような複合 PK にする |
| FOREIGN KEY | FK 元 (参照する側) として宣言可能 | 逆 (FK 先) になるのは PG 12+。それまでは自分への FK を貼れず不便だった |
| DROP INDEX | 親で実行すると子にも伝播 | 本番では DROP INDEX CONCURRENTLY を使う。親から打つと巨大ロック |
| CHECK 制約 | 親に追加 → 全子に伝播 | NOT VALID 付きにして VALIDATE は子ごとに分けると本番に優しい |
| TRIGGER | 親に作っても子に効く (PG 13+ の statement-level / row-level) | BEFORE / AFTER の制約あり。複雑になる場合は子ごとに作る方が安全 |
| partition-wise JOIN | 同じパーティションキーで結合すると子同士で結合 | enable_partitionwise_join = on 必須 (デフォルト off) |
いざパーティション化しようと思った時、既に巨大化したテーブルをどう移行するかが最大の難所です。本番に応じて 4 つのパターンがあります。
時系列 RANGE パーティションは「毎月新しい子テーブルを作る」「13 ヶ月前を切り離す」など、定型作業が永遠に続きます。これを自動化するのが pg_partman 拡張で、PG 業界の事実上の標準です。
-- ① インストール (apt / yum で入る)
sudo apt install postgresql-17-partman
-- ② shared_preload_libraries に追加 (postgresql.conf)
shared_preload_libraries = 'pg_partman_bgw'
pg_partman_bgw.interval = 3600 -- 1 時間に 1 回メンテ
pg_partman_bgw.role = 'postgres'
pg_partman_bgw.dbname = 'mydb'
-- ③ 拡張作成 + 親テーブル準備
CREATE EXTENSION pg_partman SCHEMA partman;
CREATE TABLE logs (
id bigint,
created_at timestamptz NOT NULL,
message text
) PARTITION BY RANGE (created_at);
-- ④ pg_partman に親を登録 (1 日 1 子・3 日先まで先取り)
SELECT partman.create_parent(
p_parent_table => 'public.logs',
p_control => 'created_at',
p_type => 'native',
p_interval => '1 day',
p_premake => 3 -- 3 日先まで先回り作成
);
-- ⑤ 保持ポリシー (90 日経過したパーティションは自動 DETACH)
UPDATE partman.part_config
SET retention = '90 days',
retention_keep_table = false -- 切り離すだけでなく DROP まで
WHERE parent_table = 'public.logs';
-- ⑥ 確認
SELECT * FROM partman.part_config WHERE parent_table = 'public.logs';
SELECT partman.show_partitions('public.logs');
-- ⑦ 手動メンテ (bgworker を入れない場合は cron で回す)
SELECT partman.run_maintenance('public.logs');ポイントは p_premake (先回り作成数) と retention (保持期間)。先回り作成が足りないと「明日のパーティションが無い」エラーで本番が止まります。p_premake は最低でも 7、毎日のメンテが回ることを pg_partman.run_maintenance の最終実行時刻で監視するのが定石です。
plan_cache_mode = force_custom_plan で対処。enable_partitionwise_join = on と enable_partitionwise_aggregate = on を本番で有効化。本章では、PostgreSQL のStreaming Replication (物理レプリケーション) について学んでいきます。
レプリケーションとは、「1 台のサーバ (Primary) で起きた変更を、別のサーバ (Standby) にもリアルタイムに反映して、両者を同じ状態に保つ」仕組みです。これによって、Primary が壊れても Standby に切り替えればサービスを続けられる耐障害性、Standby を読み取り専用として活用してアクセスを分散する負荷分散、そして Standby でバックアップを取って Primary に負荷をかけない運用効率化 — の 3 つのメリットが得られます。本番運用に入る規模になれば、ほぼ必須の構成です。
PostgreSQL は 2 種類のレプリケーション方式を提供しています。本章で扱うStreaming Replication (物理) は、Section 12 で見たWAL のバイト列をそのまま Standby に流して再生する方式で、Primary と Standby が完全に同じ物理コピーになります。シンプルで高速、HA 構成の基盤として最も普及しています。もう一つのLogical Replication は次章で扱います。本章ではまず Streaming Replication の仕組み、同期/非同期モード、HA 構成の典型例までを順に押さえます。
Section 06 で扱った WAL を Primary から Standby へストリーム送信します。 バイナリレベルで同じデータベースのコピーを作ります。
PostgreSQL にはもうひとつ Logical Replication (論理レプリケーション) があります。 物理が「WAL を流す」のに対し、論理は「行レベルの変更を SQL 風の論理ログで流す」方式。 詳しくは次の Section 26 で扱います。ここではざっくり違いだけ:
Streaming Replication の概念は分かっても、実際に Standby を 1 台構築する手順はステップが多くてハマりがちです。Primary 側の準備からスタートして、Standby 起動までのコマンドを順に並べました。
# ========================================
# Primary 側の準備 (postgresql.conf)
# ========================================
wal_level = replica # logical でも可
max_wal_senders = 10 # 同時 walsender 数の上限
max_replication_slots = 10 # スロット数の上限
hot_standby = on # Standby で読み取り可能に
synchronous_commit = on # ローカル WAL flush 待ち
# 同期レプリにするなら:
# synchronous_standby_names = 'standby1'
# ========================================
# Primary 側の pg_hba.conf (Standby からの接続を許可)
# ========================================
hostssl replication replicator 10.0.0.0/8 scram-sha-256
# ========================================
# Primary 側で実行する SQL
# ========================================
-- ① レプリケーション専用ロールを作成
CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'strong_password';
-- ② レプリケーションスロットを作成 (WAL を予約確保)
SELECT pg_create_physical_replication_slot('standby1_slot');
-- ③ 既存スロットの確認
SELECT slot_name, active, restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
# ========================================
# Standby 側で実行 (シェル)
# ========================================
# ④ Primary からベースバックアップを取得し Standby を初期化
sudo systemctl stop postgresql
sudo -u postgres pg_basebackup \
-h primary.example.com -U replicator \
-D /var/lib/postgresql/16/main \
-Fp -X stream \
-S standby1_slot \
-R -P # -R で recovery 設定を自動生成
# -S でレプリスロット使用
# ⑤ postgresql.auto.conf を確認 (pg_basebackup -R が生成)
sudo -u postgres cat /var/lib/postgresql/16/main/postgresql.auto.conf
# primary_conninfo = 'host=primary user=replicator password=...'
# primary_slot_name = 'standby1_slot'
# ⑥ standby.signal の存在確認 (pg_basebackup -R で自動作成)
ls /var/lib/postgresql/16/main/standby.signal
# ⑦ Standby 起動
sudo systemctl start postgresql
# ⑧ Standby 側で起動確認
sudo -u postgres psql -c "SELECT pg_is_in_recovery();" # true なら成功
# ========================================
# Primary 側でレプリ状態を確認
# ========================================
SELECT application_name, client_addr, state, sync_state,
write_lag, flush_lag, replay_lag
FROM pg_stat_replication;Streaming Replication の運用で使う主要な SQL コマンド・関数を分類別にまとめました。
SELECT pg_create_physical_replication_slot('name');SELECT pg_create_physical_replication_slot('name', true);SELECT pg_drop_replication_slot('name');SELECT * FROM pg_replication_slots;
SELECT * FROM pg_stat_replication;
SELECT pg_current_wal_lsn();
SELECT pg_walfile_name(pg_current_wal_lsn());
SELECT pg_is_in_recovery();
SELECT pg_last_wal_receive_lsn();
SELECT pg_last_wal_replay_lsn();
SELECT pg_last_xact_replay_timestamp();
SELECT * FROM pg_stat_wal_receiver;
SELECT pg_promote();
SELECT pg_promote(wait_seconds => 60);
$ pg_ctl promote -D /var/lib/postgresql/16/main
$ touch /var/lib/postgresql/16/main/promote.signal
SELECT pg_wal_replay_pause();
SELECT pg_wal_replay_resume();
SELECT pg_is_wal_replay_paused();
SELECT pg_wal_lsn_diff(lsn1, lsn2);
SELECT pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) FROM pg_stat_replication;
Section 12 (WAL) で見た synchronous_commit の設定が、Streaming Replication の同期度合いを決定します。「どこまで Standby が受け取った時点で COMMIT を返すか」をパラメータで制御します。
-- ① 非同期 (デフォルト) — Primary でコミット → あとで Standby へ synchronous_standby_names = '' synchronous_commit = on -- local と同等 -- ② 同期: 1 台が WAL を flush したら COMMIT synchronous_standby_names = 'standby1' synchronous_commit = on -- = remote_flush 相当 -- ③ 同期: 1 台が WAL を再生したら COMMIT (Read your own writes 保証) synchronous_standby_names = 'standby1' synchronous_commit = remote_apply -- ④ 複数 Standby のうち N 台が同期 synchronous_standby_names = 'FIRST 2 (standby1, standby2, standby3)' -- 上位 2 台が同期、残りは非同期 -- ⑤ どれか N 台が同期 (定足数) synchronous_standby_names = 'ANY 2 (standby1, standby2, standby3)' -- 3 台中いずれか 2 台が WAL flush したら COMMIT -- 設定後は reload で反映 (再起動不要) SELECT pg_reload_conf(); -- 現在の同期 Standby 確認 SELECT application_name, sync_state, sync_priority FROM pg_stat_replication; -- sync_state: sync (同期) / potential (待機) / async (非同期) -- トランザクション単位で同期度を一時変更 (重要トランザクションだけ強く) BEGIN; SET LOCAL synchronous_commit = remote_apply; -- 金融取引などの重要な処理 COMMIT;
pg_stat_replication ビューで遅延 (write_lag/flush_lag/replay_lag) を確認できます。 遅延が大きい場合: ① ネットワーク帯域不足、② Standby の I/O 性能不足、 ③ Primary の更新負荷急増、④ 長時間トランザクションが Standby で実行中、のいずれか。synchronous_standby_names 設定で同期化可能 (Standby 到達まで Primary がコミット待ち)。 データロス回避と引き換えに、Primary レイテンシが上がります。本章では、もう一つのレプリケーション方式である Logical Replication (論理レプリケーション) について学んでいきます。
前章の Streaming Replication が「WAL のバイナリをそのまま Standby に流して再生する」物理レベルのコピーだったのに対し、Logical Replication は「変更の意味を保ったまま」同期する方式です。Publisher 側で WAL を解読し、「テーブル X に INSERT された」「テーブル Y の id=42 が UPDATE された」といった論理的な変更に復元してから Subscriber 側に送り、Subscriber は受け取った内容を SQL として再実行します。
この「途中で WAL を解読してから送る」というひと手間によって、Streaming Replication にはない次のような柔軟性が得られます。① PostgreSQL のバージョンが違う Subscriber に送れる (アップグレード時の移行に使える)、② 特定のテーブルだけ同期できる (マルチテナント分離)、③ Subscriber 側でも書き込みができる (双方向同期や ETL)、④ 異なるスキーマへの変換も可能。一方で構造が複雑になるため、運用上の罠も多くあります。本章では Logical Replication の内部処理 (Logical Decoding)、Replication Slot、PUBLICATION / SUBSCRIPTION の組み方、典型ユースケース 5 つ、そして PG バージョン別の新機能までを順に解説します。
ポイントは 論理デコーダ (pgoutput) です。 WAL は本来「ページ内のバイト範囲を書き換えた」という物理情報ですが、これを解読して「users テーブルに id=5, name=Taro を INSERT」のような論理メッセージに再構築します。 この変換があるため、Subscriber 側はテーブル構造さえ合っていれば異バージョンでも適用可能になります。
論理レプリの状態は レプリケーションスロット で管理されます。 「どの LSN まで Subscriber が受信完了したか」を Publisher 側で記憶し、送信前の WAL を削除しない仕組みです。
-- スロット一覧
SELECT slot_name, slot_type, database, active,
restart_lsn, confirmed_flush_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn))
AS lag_bytes
FROM pg_replication_slots;
-- 例:
-- slot_name | type | database | active | lag_bytes
-- my_sub | logical | mydb | true | 12 MB
-- 危険: Subscriber が長時間切断されると WAL が無限に蓄積
-- → ディスク満杯で本番停止リスク
-- 一時停止する場合は max_slot_wal_keep_size を設定max_slot_wal_keep_size = '50GB' (PG 13+) を設定し、 上限を超えたら自動でスロット無効化されるよう保護してください。-- 1. テーブル指定
CREATE PUBLICATION my_pub FOR TABLE users, orders;
-- 2. スキーマ単位 (PG 15+)
CREATE PUBLICATION my_pub FOR TABLES IN SCHEMA app;
-- 3. 全テーブル
CREATE PUBLICATION my_pub FOR ALL TABLES;
-- 4. 行フィルタリング (PG 15+)
CREATE PUBLICATION my_pub FOR TABLE orders
WHERE (status IN ('shipped', 'delivered'));
-- → status が 'pending' の行は送らない
-- 5. 列フィルタリング (PG 15+)
CREATE PUBLICATION my_pub FOR TABLE users (id, name, email);
-- → 機密カラム (password_hash 等) を除外
-- 6. アクション制限
CREATE PUBLICATION my_pub FOR TABLE logs
WITH (publish = 'insert'); -- INSERT のみ送る (UPDATE/DELETE は無視)-- 基本
CREATE SUBSCRIPTION my_sub
CONNECTION 'host=publisher dbname=mydb user=repl password=xxx'
PUBLICATION my_pub;
-- オプション付き
CREATE SUBSCRIPTION my_sub
CONNECTION '...'
PUBLICATION my_pub
WITH (
copy_data = true, -- 初期同期するか (デフォルト true)
create_slot = true, -- 自動でスロット作成
enabled = true, -- 即開始するか
streaming = on, -- in-progress トランザクションをストリーミング (PG 14+)
binary = true, -- バイナリ形式 (PG 14+、高速)
two_phase = true -- 2PC 対応 (PG 15+)
);
-- 一時停止
ALTER SUBSCRIPTION my_sub DISABLE;
ALTER SUBSCRIPTION my_sub ENABLE;
-- 削除 (スロットも DROP)
DROP SUBSCRIPTION my_sub;max_logical_replication_workers を超えるテーブルは順次同期、 ④ 同期中に失敗するとそのテーブルだけ最初からやり直し。 対策: ① copy_data=false + 手動 COPY、 ② テーブルを分けて Publication 化し並列度を上げる、③ 巨大テーブルは別途 pg_dump で先回り。論理レプリケーションはDDL (ALTER TABLE / CREATE INDEX 等) を同期しません。 Publisher で ALTER TABLE users ADD COLUMN age int しても、Subscriber には反映されません。 手動で順番に流す必要があります。
-- 正しい順序: 追加系は Subscriber が先、削除系は Publisher が先 -- ◯ カラム追加 -- Step 1: Subscriber に追加 (NULL OK のカラム) ALTER TABLE users ADD COLUMN age int; -- Step 2: Publisher に追加 (このタイミングで論理レプリのデータが流れる) ALTER TABLE users ADD COLUMN age int; -- ◯ カラム削除 -- Step 1: Publisher から削除 ALTER TABLE users DROP COLUMN age; -- Step 2: Subscriber から削除 ALTER TABLE users DROP COLUMN age; -- ◎ 自動化: pg_easy_replicate 等のサードパーティツールを検討
Subscriber 側で独立に書込可能なため、競合が起きることがあります。
-- Publisher 側: 送信状況
SELECT * FROM pg_stat_replication;
-- application_name で Subscriber を識別
-- write_lag, flush_lag, replay_lag で遅延を確認
-- Publisher 側: スロット状況 (最重要)
SELECT slot_name, active, confirmed_flush_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots WHERE slot_type = 'logical';
-- retained_wal が異常に大きいなら Subscriber が遅れている
-- Subscriber 側: 受信状況
SELECT subname, received_lsn, latest_end_lsn, latest_end_time
FROM pg_stat_subscription;
-- Subscriber 側: テーブル毎の同期状態
SELECT srsubid::regrole AS sub, srrelid::regclass AS table, srsubstate
FROM pg_subscription_rel;
-- srsubstate: i=initialize, d=copying, s=sync done, r=ready| PG バージョン | 機能 |
|---|---|
| PG 10 | 論理レプリケーション初登場 (基本機能) |
| PG 12 | PUBLICATION の WHERE 句サポート (一部) |
| PG 14 | バイナリ形式・streaming オプション (in-progress txn) |
| PG 15 | 行/列フィルタリング、TABLES IN SCHEMA、2PC 対応 |
| PG 16 | standby からの論理レプリ可、双方向に近づく |
| PG 17 | スロット同期 (failover に強い)、subscriber 側で TRUNCATE スキップ可 |
Logical Replication のPublisher 側で「何を publish するか」を定義するのが PUBLICATION です。テーブル単位での選択、行・列フィルタ、操作種別の選択など細かい制御ができます。
-- 基本構文
CREATE PUBLICATION name
[ FOR ALL TABLES
| FOR publication_object [, ...] ]
[ WITH (publication_parameter [= value] [, ... ]) ];
-- publication_object:
-- TABLE [ ONLY ] table_name [ * ] [ (column_name [, ...]) ] [ WHERE (expression) ]
-- TABLES IN SCHEMA { schema_name | CURRENT_SCHEMA } (PG 15+)
ALTER PUBLICATION name { ADD | SET | DROP } publication_object [, ...];
ALTER PUBLICATION name OWNER TO new_owner;
ALTER PUBLICATION name RENAME TO new_name;
ALTER PUBLICATION name SET (publication_parameter [= value]);
DROP PUBLICATION [ IF EXISTS ] name [, ...];FOR ALL TABLESFOR TABLE nameTABLE a, b, c。TABLE ... (column, ...) (PG 15+)TABLE ... WHERE (expr) (PG 15+)WHERE tenant_id = 1 でマルチテナント分離。TABLES IN SCHEMA name (PG 15+)publish = 'insert,update,delete,truncate'publish = 'insert' のように絞れる。publish_via_partition_root = true-- ① 全テーブルを publish (要 SUPERUSER) CREATE PUBLICATION pub_all FOR ALL TABLES; -- ② 特定テーブルだけ CREATE PUBLICATION pub_app FOR TABLE users, orders, products; -- ③ 列フィルタ (PG 15+、PII を含めない publish) CREATE PUBLICATION pub_public_users FOR TABLE users (id, name, created_at); -- email は除外 -- ④ 行フィルタ (PG 15+、マルチテナント分離) CREATE PUBLICATION pub_tenant_1 FOR TABLE orders WHERE (tenant_id = 1); -- ⑤ スキーマ全体 (PG 15+) CREATE PUBLICATION pub_audit FOR TABLES IN SCHEMA audit; -- ⑥ 操作種別を絞る (削除は同期しない) CREATE PUBLICATION pub_append_only FOR TABLE event_log WITH (publish = 'insert'); -- ⑦ ALTER で対象追加・削除 ALTER PUBLICATION pub_app ADD TABLE invoices; ALTER PUBLICATION pub_app DROP TABLE products; ALTER PUBLICATION pub_app SET TABLE users, orders, invoices; -- 全置換 -- ⑧ 確認 SELECT * FROM pg_publication; SELECT * FROM pg_publication_tables WHERE pubname = 'pub_app'; -- ⑨ 削除 DROP PUBLICATION pub_app;
Subscriber 側で「どの Publisher のどの PUBLICATION を受け取るか」を定義するのが SUBSCRIPTION です。作成すると即時に初期データコピー + 継続ストリーミングが開始されます。
-- 基本構文
CREATE SUBSCRIPTION name
CONNECTION 'conninfo'
PUBLICATION publication_name [, ...]
[ WITH (subscription_parameter [= value] [, ...]) ];
ALTER SUBSCRIPTION name CONNECTION 'conninfo';
ALTER SUBSCRIPTION name SET PUBLICATION publication_name [, ...]
[ WITH (refresh = false) ];
ALTER SUBSCRIPTION name REFRESH PUBLICATION
[ WITH (copy_data = true) ];
ALTER SUBSCRIPTION name ENABLE;
ALTER SUBSCRIPTION name DISABLE;
ALTER SUBSCRIPTION name OWNER TO new_owner;
ALTER SUBSCRIPTION name RENAME TO new_name;
ALTER SUBSCRIPTION name SET (subscription_parameter [= value]);
ALTER SUBSCRIPTION name SKIP (lsn = '0/12345'); -- PG 15+
DROP SUBSCRIPTION [ IF EXISTS ] name;connect = true/falseenabled = true/falsecreate_slot = true/falseslot_name = 'name'copy_data = true/falsesynchronous_commit = on/off/...binary = true/false (PG 14+)streaming = on/off/parallel (PG 14+)two_phase = true (PG 15+)disable_on_error = true (PG 15+)origin = none/any (PG 16+)-- 前提: Subscriber 側に Publisher と同じテーブル構造を作っておく
CREATE TABLE users (id bigint PRIMARY KEY, name text, created_at timestamptz);
-- ① 基本的なサブスクリプション (即時初期コピー + 継続ストリーミング)
CREATE SUBSCRIPTION sub_app
CONNECTION 'host=publisher dbname=myapp user=replicator password=xxx'
PUBLICATION pub_app;
-- ② 段階構築: 接続なしで作って後で有効化
CREATE SUBSCRIPTION sub_app
CONNECTION '...'
PUBLICATION pub_app
WITH (enabled = false, create_slot = false, slot_name = 'pre_made_slot');
ALTER SUBSCRIPTION sub_app ENABLE;
-- ③ 高速化フル装備 (PG 14+)
CREATE SUBSCRIPTION sub_fast
CONNECTION '...'
PUBLICATION pub_app
WITH (
binary = true,
streaming = parallel, -- PG 16+
disable_on_error = true, -- PG 15+
synchronous_commit = off -- apply 高速化
);
-- ④ 既存データをコピーしない (既に同期されている時)
CREATE SUBSCRIPTION sub_continue
CONNECTION '...'
PUBLICATION pub_app
WITH (copy_data = false);
-- ⑤ PUBLICATION の追加・変更
ALTER SUBSCRIPTION sub_app SET PUBLICATION pub_app, pub_audit;
ALTER SUBSCRIPTION sub_app REFRESH PUBLICATION; -- 新テーブルを反映
-- ⑥ 一時停止と再開
ALTER SUBSCRIPTION sub_app DISABLE;
ALTER SUBSCRIPTION sub_app ENABLE;
-- ⑦ エラーで止まったサブスクリプションを「特定 LSN だけスキップ」(PG 15+)
ALTER SUBSCRIPTION sub_app SKIP (lsn = '0/3000110');
-- ⑧ 状態確認
SELECT * FROM pg_subscription;
SELECT subname, received_lsn, latest_end_lsn, last_msg_send_time,
last_msg_receipt_time
FROM pg_stat_subscription;
SELECT * FROM pg_subscription_rel;
-- srsubstate: i=initial / d=data copy 中 / s=sync / r=ready
-- ⑨ 削除 (スロットも一緒に削除しようとする)
DROP SUBSCRIPTION sub_app;
-- 接続できない時は事前に
ALTER SUBSCRIPTION sub_app DISABLE;
ALTER SUBSCRIPTION sub_app SET (slot_name = NONE);
DROP SUBSCRIPTION sub_app;
-- → Publisher 側のスロットは残るので別途削除するPUBLICATION / SUBSCRIPTION を使わずに、SQL 関数で直接論理メッセージを取り出すこともできます。Debezium のような CDC ツールがこの仕組みを使って WAL から変更ストリームを抽出しています。
-- ① 論理レプリ用スロットを作成 (出力プラグインを指定)
SELECT pg_create_logical_replication_slot(
'my_logical_slot',
'pgoutput' -- 標準の論理レプリ用 (PUBLICATION 経由)
-- 'test_decoding' -- デバッグ用 (人間が読める形式)
-- 'wal2json' -- JSON 形式 (Debezium 系)
);
-- ② スロットから変更を取得 (peek = 読むだけ、進めない)
SELECT * FROM pg_logical_slot_peek_changes(
'my_logical_slot',
NULL, -- upto_lsn (NULL なら最新まで)
NULL, -- upto_nchanges (NULL なら無制限)
'pretty-print', '1' -- プラグイン固有のオプション
);
-- ③ スロットから変更を取得して進める (get = 読んで position 更新)
SELECT * FROM pg_logical_slot_get_changes(
'my_logical_slot',
NULL, NULL
);
-- ④ スロットを削除
SELECT pg_drop_replication_slot('my_logical_slot');
-- ⑤ test_decoding プラグインの出力例 (人間に読める)
-- BEGIN 502
-- table public.users: INSERT: id[bigint]:1 name[text]:'Alice'
-- COMMIT 502
-- ⑥ 全スロットの状態
SELECT slot_name, plugin, slot_type, active, restart_lsn, confirmed_flush_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;pglogical: 古くからのサードパーティ拡張、機能豊富、 ② Debezium: 論理デコーダから読み取り Kafka へ、 ③ BDR (Bi-Directional Replication): マルチマスタ (商用)、 ④ pg_easy_replicate: 論理レプリの初期同期・スキーマ同期を自動化。 標準機能では足りない場合に検討。REPLICA IDENTITY FULL 設定だが遅い)、 ② シーケンスは同期されない (Subscriber 側で別途設定)、 ③ TRUNCATE は同期されるが、CASCADE には注意、 ④ ラージオブジェクト (Large Object) は対象外、 ⑤ DDL は同期されない (前述)。本章では、レプリケーションを活用してアクセスを複数台に振り分ける負荷分散構成について学んでいきます。
前章までで Streaming / Logical の 2 種類のレプリケーションを見ました。Standby を作っただけでは耐障害性が上がるだけで、まだ性能は活用しきれていません。Web アプリの多くは読み取りクエリが書き込みより 10〜100 倍多いので、読み取りを Standby (リードレプリカ) に振り分け、Primary は書き込みに専念させる構成にすれば、全体のスループットが大きく向上します。
ただし「どこで振り分けるか」の選択肢が複数あり、それぞれメリット・デメリットが異なります。本章では代表的な 3 つの実装パターンを扱います。① HAProxy — DB 手前の L4/L7 プロキシで振り分ける構成、② pgpool-II — PostgreSQL 専用のミドルウェアでクエリの中身まで見て振り分ける構成、③ アプリケーション層振り分け — ORM やアプリのコードで読み書きの接続先を分ける構成。具体的な設定例とともに、どの状況でどれを選ぶべきかの判断軸を整理します。
実装パターンに入る前に、「負荷分散で何が解決できて、何は解決できないのか」をはっきりさせます。期待値を間違えると、構築した後で「思ったほど性能が上がらない」と落胆する結果になります。
負荷分散の核心は 「読み取りを Replica に逃がす」ですが、すべての SELECT を Replica に投げてよいわけではありません。レプリケーション遅延 (Primary の書き込みが Replica に届くまでの遅れ、通常数 ms〜数百 ms) があるため、「書き込み直後の自分の更新を読みに行く」ような読み取りを Replica に流すと、ユーザーには「更新したのに反映されていない」という不可解な挙動として現れます。
ここから「どこで Read / Write を振り分けるか」の実装パターンに入ります。振り分けの場所には大きく 3 つの選択肢があり、それぞれ運用負荷・柔軟性・トラブル時の挙動が異なります。下表は概要で、続く章でそれぞれの具体例と考察を扱います。
connects_to、Django の routers、Prisma の複数 datasource など、近代的な ORM はだいたい対応している。HAProxy は元々 Web トラフィックの分散に広く使われているロードバランサですが、PostgreSQL の前段に置く構成も枯れていて、本番投入実績が豊富です。ポイントは「書き込み用ポート (5432) と読み取り用ポート (5433) を別に立てる」こと。アプリは用途に応じてポートを使い分け、HAProxy 側で実際の DB ノードへの振り分けを行います。
書き込み用ポートでは、tcp-check で Patroni (HA 管理ツール) の REST API を叩き、現在 Primary になっているノードを判定します。Primary が落ちて別ノードに昇格しても、HAProxy が自動的に新 Primary へ接続を切り替えるため、アプリは接続文字列を変えずに済みます。読み取り用ポートでは複数 Replica に Round-Robin で振り分けます。
運用上の注意: ① HAProxy 自身が単一障害点になるので、Keepalived + VIP や DNS 切替などで HAProxy 自体を冗長化する、② Patroni の REST エンドポイントを直接見るので、Patroni のバージョン互換に注意、③ TCP 切断時の挙動 (アプリのコネクションプールが古い接続を保持し続けないか) も検証が必要。
# haproxy.cfg
# Primary 用ポート (書き込み)
listen postgres-primary
bind *:5432
mode tcp
option tcp-check
# Patroni の /primary エンドポイントで Primary 判定
tcp-check send GET\ /primary\ HTTP/1.0\r\n\r\n
tcp-check expect string HTTP/1.0\ 200
server pg1 10.0.0.1:5432 check port 8008
server pg2 10.0.0.2:5432 check port 8008 backup
server pg3 10.0.0.3:5432 check port 8008 backup
# Replica 用ポート (読み取りのみ、Round-Robin)
listen postgres-replica
bind *:5433
mode tcp
balance roundrobin
option tcp-check
tcp-check send GET\ /replica\ HTTP/1.0\r\n\r\n
tcp-check expect string HTTP/1.0\ 200
server pg1 10.0.0.1:5432 check port 8008
server pg2 10.0.0.2:5432 check port 8008
server pg3 10.0.0.3:5432 check port 8008
# アプリ側: 用途で接続先ポートを切替
# 書き込み: postgres://app@haproxy:5432/mydb
# 読み取り: postgres://app@haproxy:5433/mydbpgpool-II は PostgreSQL 専用のミドルウェアで、HAProxy と決定的に違うのは「SQL の中身まで読み取る」点です。クライアントから受け取った SQL を内部のパーサで解析し、「SELECT なら Replica へ」「INSERT / UPDATE / DELETE なら Primary へ」「トランザクション中はすべて Primary へ」とクエリ単位で自動振り分けします。
メリットは、アプリ側が一切修正不要なこと。アプリは「pgpool-II 1 台に繋いでいる」つもりで普通に SQL を投げるだけで、裏で Read/Write が自動的に最適なノードへ流れます。既存の本番システムに、ソースコードを触らずに後から負荷分散を被せられるのが強み。接続プーリング機能も統合されており、別途 PgBouncer を立てなくて済むのも利点です。
注意点と運用の難所: ① 設定パラメータが多く、運用ノウハウの習得コストが高い、② SQL パーサが内蔵関数や CTE 経由の書き込みを誤判定して Replica に流してしまう既知ケースがある (例: SELECT nextval('seq') や WITH ins AS (INSERT ...))、③ pgpool-II 自身も冗長化が必要、④ CPU オーバーヘッドが無視できないので、トラフィック規模が大きい場合はベンチマーク必須。「自動でやってくれる魔法」ではなく、注意深い設定とテストが要る道具です。
# pgpool.conf load_balance_mode = on master_slave_mode = on master_slave_sub_mode = 'stream' backend_hostname0 = 'primary' backend_port0 = 5432 backend_weight0 = 1 backend_flag0 = 'ALLOW_TO_FAILOVER' backend_hostname1 = 'replica1' backend_port1 = 5432 backend_weight1 = 1 backend_flag1 = 'ALLOW_TO_FAILOVER' # 自動振り分け例 # SELECT * FROM users → replica1 へ # INSERT INTO orders ... → primary へ # BEGIN; ... COMMIT; → 全て primary へ (トランザクション保証)
アプリケーション層振り分けは、アプリのコード側で「これは読み取り用接続、これは書き込み用接続」と明示的に使い分ける方式です。プロキシを挟まない素直な構成で、近年はもっとも採用が増えている方式と言ってよいでしょう。Rails の connects_to { reading:, writing: }、Django の DATABASE_ROUTERS、Sequelize / Prisma の複数 datasource など、近代的な ORM はだいたい標準対応しています。
利点は柔軟性と透明性です。「このリクエストは Read だから Replica で」「ただし管理画面の Read は最新が欲しいから Primary で」「分析クエリは専用 Replica で」のような細かい振り分けロジックをコードで完全に制御でき、デバッグ時にもクエリの行き先がコードを読めば一目でわかります。プロキシ層のブラックボックスがない分、原因調査もしやすい。
一方の落とし穴は「Read のつもりが Write を伴ってしまうクエリ」をうっかり Replica に投げる事故です。たとえば SELECT nextval('seq') や INSERT ... RETURNING、副作用のあるストアド関数の呼び出しなどは Replica では実行できません (Replica は読み取り専用)。レビュー時にこの観点を必ず確認する文化と、ORM での生 SQL の取り扱いガイドラインが必要です。
以下のコードは、最も典型的なパターン (Read/Write で別 PrismaClient を持つ) と、ハマりやすい「Read after Write の罠」(書き込み直後の Read を Replica に流すとレプリ遅延で古い値が見える) への対処例です。
// 2 つの接続を保持
const primary = new PrismaClient({
datasources: { db: { url: process.env.PRIMARY_URL } }
})
const replica = new PrismaClient({
datasources: { db: { url: process.env.REPLICA_URL } }
})
// 用途で使い分け
async function getUserProfile(id: number) {
return await replica.user.findUnique({ where: { id } }) // 読み取り
}
async function updateUser(id: number, data: any) {
return await primary.user.update({ where: { id }, data }) // 書き込み
}
// "Read after Write" の罠
async function updateAndShow(id: number, data: any) {
await primary.user.update({ where: { id }, data })
// ⚠ replica にレプリ遅延あれば古い値が見える
// → トランザクション後の読み取りは Primary で
return await primary.user.findUnique({ where: { id } })
}負荷分散構成でもっとも頻発する事故が、いわゆる Read after Write 問題です。ユーザーが何かを更新した直後、その確認画面でデータを再読み込みする — というありふれた操作で、レプリケーション遅延 (Primary の変更が Replica に届くまでの遅れ) のせいで「更新したはずなのに、まだ古い値が見えている」状態が一瞬発生します。
典型シナリオ: フォーム送信 → ❶ Primary に INSERT 成功 → ❷ サンクスページに遷移 → ❸ そのページで「登録した内容」を表示するために SELECT → このとき Replica にレプリ遅延 (例: 200ms) があると、まだ届いていない新規行を読みに行ってしまい、UI には何も表示されない or 古い情報が表示される、という現象になります。
対策には次の選択肢があり、組み合わせて使うのが一般的です。
負荷分散を組んだら、その「効き具合」と「副作用」を継続的に観測する必要があります。下記は本番運用で必ずダッシュボードに乗せたい主要メトリクスです。
SELECT application_name, replay_lag FROM pg_stat_replication;
(LB / pgpool / アプリ側のメトリクス)
SELECT xact_commit FROM pg_stat_database WHERE datname = current_database();
SHOW hot_standby_feedback;
SELECT slot_name, pg_size_pretty( pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) ) AS retained FROM pg_replication_slots;
ここまでの 3 パターンはすべて Self-Hosted (自前で組む) ことを前提にしていますが、クラウドのマネージド PostgreSQL を使う場合、負荷分散は付属機能として最初から組み込まれているのが普通です。HAProxy や pgpool-II を自前で組む工数を惜しまずに使えるなら、検討する価値が大きい選択肢です。
マネージドを使う場合のアプリ側の実装は、本章の ③ Application 層振り分け とまったく同じです (接続先 URL が 2 種類になるだけ)。「振り分けの仕組み」を Self-Hosted で組むコストが省ける一方、コストと機能制約・ベンダーロックインのトレードオフは別途検討してください。
本章では、PostgreSQL を 24/7 で動かし続けるための高可用性 (HA) 構成について学んでいきます。
高可用性 (High Availability) とは、Primary サーバが故障してもサービスを止めずに動かし続けられる仕組みのことです。前章の Streaming Replication で Standby は用意できましたが、それだけでは「Primary が壊れた時にどの Standby を昇格させるか」「アプリケーションの接続先をどう切り替えるか」「壊れた Primary を後で復旧した時にどう扱うか」を人間が手で対応する必要があります。深夜の障害でこれを毎回手作業でやるのは現実的ではありません。
そこで本番ではこれらの判定と切り替えを自動化するクラスタ管理ツールを組み合わせて使います。代表的なものが Patroni と repmgr です。Patroni は etcd / Consul / ZooKeeper といった分散合意ストアを使って Primary を 1 台だけに選出し続ける仕組みを持っており、Kubernetes 環境を含めて事実上の業界標準となっています。本章では HA 構成の典型例、Patroni の動き (リーダー選出 / 自動フェイルオーバー)、そして本番運用上の注意点までを解説します。
Patroni を使うと patronictl という CLI でクラスタ全体の状態確認・フェイルオーバー・スイッチオーバー・再起動などを操作します。本番障害対応で必ず叩くコマンドなので、構文を頭に入れておく必要があります。
# 基本構文 patronictl -c /etc/patroni/patroni.yml [command] [options]
patronictl listpatronictl topologypatronictl historypatronictl switchoverpatronictl failoverpatronictl restart [member]patronictl reload [member]patronictl reinit [member]patronictl pause / resumepatronictl edit-configpatronictl show-configpatronictl querypatronictl version# ① クラスタ状態確認 (毎日の運用で最頻出) $ patronictl -c /etc/patroni/patroni.yml list # 出力例: # + Cluster: my_cluster -------+----+-----------+ # | Member | Host | Role | State | TL | Lag in MB | # +----------+------------+---------+---------+----+-----------+ # | pg-node1 | 10.0.0.10 | Leader | running | 3 | | # | pg-node2 | 10.0.0.11 | Replica | running | 3 | 0 | # | pg-node3 | 10.0.0.12 | Replica | running | 3 | 0 | # +----------+------------+---------+---------+----+-----------+ # ② 計画的スイッチオーバー (Leader を別ノードに移す、無停止) $ patronictl switchover my_cluster # 対話的に「現 Leader」「新 Leader」「実行時刻」を聞かれる # 非対話 (自動化用): $ patronictl switchover --leader pg-node1 --candidate pg-node2 \ --scheduled now --force # ③ 緊急フェイルオーバー (Leader が応答不能時) $ patronictl failover --candidate pg-node2 --force # 現在の Leader が死んでいる前提。データロスの可能性あり # ④ 設定の編集 (全メンバーにローリング反映) $ patronictl edit-config # YAML エディタが開く。max_connections や shared_buffers 等を変更 # Patroni が pg_reload_conf() / 再起動を必要に応じて自動実行 # ⑤ クラスタ全体の pause / resume (メンテナンス用) $ patronictl pause --wait # → 全メンバーが「Patroni の自動制御なし」状態に。 # → メンテナンス完了後: $ patronictl resume # ⑥ Replica の再初期化 (壊れた Standby の救出) $ patronictl reinit my_cluster pg-node3 # → pg-node3 のデータディレクトリを削除 → Leader から取り直し # ⑦ メンバー個別の再起動 $ patronictl restart my_cluster pg-node2 # ⑧ クエリ実行 (どのメンバーに対しても) $ patronictl query my_cluster pg-node1 \ "SELECT pg_current_wal_lsn();" # ⑨ Patroni REST API も叩ける (HAProxy のヘルスチェック用) $ curl http://pg-node1:8008/primary # 200 if Primary $ curl http://pg-node1:8008/replica # 200 if Replica $ curl http://pg-node1:8008/health # 一般ヘルスチェック $ curl http://pg-node1:8008/cluster # クラスタ状態 JSON
Patroni 以外でも、HA 構成で叩く可能性のあるコマンドをまとめておきます。
# ① pg_isready — PostgreSQL が接続を受け付けているかチェック # ロードバランサや監視ツールで使う典型コマンド $ pg_isready -h 10.0.0.10 -p 5432 # 終了コード: 0 = 接続可、1 = 拒否、2 = 応答なし、3 = エラー # パスワード不要、ネットワーク疎通+起動状態のみ確認するので # ヘルスチェックに最適 # ② repmgr (Patroni の代替) の主要コマンド # クラスタ状態 $ repmgr -f /etc/repmgr.conf cluster show # Primary の確認 $ repmgr -f /etc/repmgr.conf primary register # Standby の登録 $ repmgr -f /etc/repmgr.conf standby register # Standby のフォローを変更 $ repmgr -f /etc/repmgr.conf standby follow # Primary の昇格 $ repmgr -f /etc/repmgr.conf standby promote # 自動フェイルオーバー daemon の起動 $ repmgrd -f /etc/repmgr.conf # ③ pg_ctl — 単体 PostgreSQL の制御 (Patroni 配下では直接使わない) $ pg_ctl status -D /var/lib/postgresql/16/main $ pg_ctl start -D /var/lib/postgresql/16/main $ pg_ctl stop -D /var/lib/postgresql/16/main -m fast $ pg_ctl restart -D /var/lib/postgresql/16/main -m fast $ pg_ctl reload -D /var/lib/postgresql/16/main $ pg_ctl promote -D /var/lib/postgresql/16/main # Standby を Primary に $ pg_ctl status -D /var/lib/postgresql/16/main # ④ systemd 経由 (本番でよくある形) $ sudo systemctl status postgresql $ sudo systemctl start postgresql $ sudo systemctl reload postgresql $ sudo systemctl restart postgresql # ⑤ クラウドマネージドサービスの操作 # AWS RDS (CLI) $ aws rds reboot-db-instance --db-instance-identifier prod-pg $ aws rds failover-db-cluster --db-cluster-identifier prod-cluster # GCP Cloud SQL $ gcloud sql instances restart prod-pg $ gcloud sql instances failover prod-pg # Azure Database for PostgreSQL $ az postgres flexible-server restart -g rg -n prod-pg
本章では、PostgreSQL のデータを安全に守るためのバックアップ戦略について学んでいきます。
バックアップとは、DB に入っているデータを別の場所に複製して保存しておくことです。事故が起きた時に、その複製からデータを戻すこと (リストア) でサービスを復旧します。コンピュータの「定期的にファイルを USB メモリにコピーしておく」のと同じ発想ですが、本番 DB の場合は「ただコピーしておくだけ」ではすまない難しさがあります。
まずバックアップがなぜ重要かを、起こり得る事故と一緒に押さえます。 本番 DB は次のような事故に常に晒されています。
これらのどれが起きても、バックアップがなければデータは完全に失われ、サービスは終わります。ユーザーの登録情報、購入履歴、課金記録、メッセージ — すべて消えた状態で復旧することは不可能で、最悪の場合は企業の存続そのものに直結します。世界には実際、データを失ったことが原因で廃業に追い込まれた企業が無数に存在しています。
重要なのは、「バックアップを取っている」と「バックアップから戻せる」は別物であるという点です。バックアップファイルは存在していたが壊れていた、リストア手順を誰も知らなかった、テストしていなかったので所要時間が想定の 10 倍かかった、というのは現場で頻繁に起こる事故パターンです。本章では、単にバックアップの取り方を覚えるのではなく、「実際に戻せるバックアップ戦略を組むには何が必要か」を体系的に扱います。
PostgreSQL のバックアップ手法は多岐にわたるため、まず3 つの分類軸で全体像を整理します。
これらを組み合わせて初めて「本番に耐えるバックアップ戦略」が成立します。まずは全体像を以下のマップで俯瞰します。
PostgreSQL のバックアップ方式は、大きく分けて以下の 7 種類があります。①〜⑦ の番号はこの後の各節と 1 対 1 に対応し、各方式を順番に深掘りしていきます。表の「対象」と「範囲」を見れば、ある方式がどの位置付け (物理 / 論理 × フル / 増分) かが一目で分かります。
| # | 方式 | タイミング | 対象 | 範囲 | 主な用途 |
|---|---|---|---|---|---|
| ① | オフライン物理バックアップ (Cold Backup) | オフライン | 物理 | フル | 保守時の初期データ準備・小規模システム |
| ② | オンライン物理バックアップ (pg_basebackup) | オンライン | 物理 | フル | 本番フルバックアップ・スタンバイ初期化【本番標準】 |
| ③ | 低レベル API (pg_backup_start/stop) | オンライン | 物理 | フル | OS スナップショットと組合せる時の整合点作成 |
| ④ | スナップショット系 (EBS / LVM / ZFS) | オンライン | 物理 | フル | クラウド標準・取得が瞬時・巨大 DB 向き |
| ⑤ | 論理バックアップ (pg_dump / pg_dumpall) | オンライン | 論理 | フル | マイグレーション・開発環境・部分復元・異版間移行 |
| ⑥ | WAL アーカイブ | オンライン | 物理 | 増分 (継続) | PITR の継続的増分・本番必須 (②と組み合わせる) |
| ⑦ | 増分 / 差分バックアップ (pgBackRest / PG 17 ネイティブ) | オンライン | 物理 | 増分 / 差分 | 大規模 DB の効率的バックアップ・保管容量削減 |
本番運用の王道: ② オンライン物理 (pg_basebackup) でフルを定期取得 + ⑥ WAL アーカイブ で継続的に増分を貯め続ける構成。これで「日次のフル + 任意の時点へ巻き戻せる PITR」が両立します。データ量が大きくなれば⑦ 増分・差分を組み合わせ、クラウドなら④ スナップショットに切り替える、というのが基本の進化パスです。⑤ 論理は別軸 (移行 / 部分復元用) として常備します。
一番原始的な方法。DB を停止してデータディレクトリ全体をコピーします。稼働中はやってはいけません (タプル不整合になる)。 現代では稀ですが、メンテナンス時間が取れる小規模システムや、論理レプリケーションの初期データ準備で使われることがあります。
# Step 1: DB を停止 sudo systemctl stop postgresql # Step 2: データディレクトリを丸ごとコピー sudo tar czf /backups/cold_$(date +%Y%m%d).tar.gz \ -C /var/lib/postgresql/16 main/ # Step 3: 起動 sudo systemctl start postgresql # 復元時は逆手順 (停止 → 展開 → 起動)
本番運用でもっとも一般的なバックアップ方式です。「物理」とは、データベースの内部ファイル (テーブル本体・インデックス・WAL など PGDATA/ ディレクトリ配下の実ファイル) をそのままバイト列としてコピーすることを意味します。「オンライン」は DB を止めずに、稼働中のまま取得できるという意味です。
ただし、ただファイルを cp -r で丸ごとコピーするだけでは整合性が崩れます。コピーしている最中にも DB は WAL を書き、ページを更新し続けているからです。そこで PostgreSQL は「バックアップ中のファイル変動を WAL で記録しておき、後でリプレイすれば整合性が取れる」という巧妙な仕組みを採用しています。
内部的にはバックアップは次の3 フェーズで進みます。
この 3 フェーズを自動でやってくれるのが pg_basebackup です。Phase 1/3 の API 呼び出しから、Phase 2 のファイル転送まで全部こなしてくれます。本番では基本これを使います。一方、ストレージ層のスナップショット (EBS / LVM / ZFS) と組み合わせたい時は、Phase 1 と Phase 3 だけ手動で呼んで、Phase 2 (ファイルコピー) をスナップショット機能に任せる、という使い方をします。これが次節の「③ 低レベル API」です。
ここで重要なポイントが 2 つあります。
pg_basebackup -Xs はバックアップと並行して WAL ストリームを受信し、復元に必要な分を漏れなく確保します。これがないと「バックアップ取れたのに復元できない」事故が起きます。# 高レベル: pg_basebackup の本番標準形 (推奨)
pg_basebackup -h primary -U replicator \
-D /backups/base \
-Fp -Xs -P -R -c fast \
-S backup_slot # PG 13+ レプリスロット併用で WAL 欠落防止
# -Fp: プレーン形式 (ディレクトリ展開)
# -Ft -z: tar + 圧縮 (PG 15+ では --compress=zstd:5 も可)
# -Xs: WAL を stream 取得 (整合性確保)
# -P: 進捗表示
# -R: standby.signal と recovery 設定を自動生成 (Standby 構築時)
# -c fast: 即座に Checkpoint 実行 (バックアップ開始までを高速化)
# -S slot: 既存のレプリスロットを使用
# 低レベル API + 外部ツール (スナップショット系と組み合わせる場合)
# Phase 1
psql -c "SELECT pg_backup_start('my-snapshot-2026-06-12');"
# Phase 2 (例: EBS スナップショット)
aws ec2 create-snapshot --volume-id vol-xxxxxxxxx
# Phase 3
psql -c "SELECT * FROM pg_backup_stop();"
# labelfile | spcmapfile | ... ← この出力を保存しておく
# "STOP WAL LOCATION: 0/A2000060" | ... → backup_label と一緒に保管スナップショット系 (LVM/EBS/ZFS) と組み合わせる時に使う低レベル API です。 PG 15 以前は pg_start_backup() / pg_stop_backup()、PG 15 以降は名前が変わって pg_backup_start() / pg_backup_stop() に。
-- バックアップ開始を PG に通知 (Checkpoint 実行)
SELECT pg_backup_start('my-snapshot-2026-03-15');
-- OS 側でスナップショットを取得
-- (LVM, EBS, ZFS など)
$ aws ec2 create-snapshot --volume-id vol-xxx
-- バックアップ終了を PG に通知 (ラベル/WAL 範囲を確定)
SELECT * FROM pg_backup_stop();
-- backup_label, tablespace_map が返る → これを backup と一緒に保管ストレージ層のスナップショット機能を活用する方式。取得時間が瞬時なのが最大の魅力です。
物理バックアップが「データファイルそのものをバイト列としてコピー」するのに対し、論理バックアップは「DB の中身を、再現するための SQL 文として書き出す」方式です。たとえばテーブル users に 3 行入っていれば、出力は概ね CREATE TABLE users (...); COPY users FROM stdin; (3 行データ) \\. のような「コピーすれば同じ状態に戻せる命令の列」になります。
この方式の最大の魅力は柔軟性です。出力は SQL なので、PostgreSQL のバージョンが違っても (例: 13 から 16 へ) 復元できますし、必要なテーブルだけ抜き出せるし、別の DB システムに移行する時の素材にもなります。代わりに、テーブル全行を SQL に書き起こすので、取得時間と復元時間はテーブルのサイズに比例します。本番 DB が数百 GB を超えると現実的でなくなる、という弱点もあります。
内部の動きを見てみましょう。pg_dump は次の流れで動作します。
ポイントは ① REPEATABLE READ で開始すること。MVCC の章で見た通り、PostgreSQL は「トランザクション開始時のスナップショット」を一貫して見続ける機能を持っており、これを使うことで「ダンプ中に走った UPDATE / INSERT / DELETE は無視される」状態を作り出します。これにより、巨大テーブルを書き出している途中で他のユーザーが書き込んでいても、出力は整合性のある単一の時点を表します。
pg_dump には4 つの出力形式があり、それぞれ用途が異なります。
-Fp (plain)プレーン SQL 形式-Fc (custom)カスタム圧縮形式-Fd (directory)ディレクトリ形式-Ft (tar)tar 形式また、論理バックアップには対象スコープによって 2 つのコマンドが存在します。
pg_dumppg_dumpall実運用では「定期的に pg_dump で個別 DB をカスタム形式 (Fc) で取得 + 週次で pg_dumpall --globals-only でロール定義を取得」を組み合わせるのが定石です。
物理バックアップと論理バックアップはどちらが優れているかの話ではなく、目的が違うものです。それぞれの特性をはっきりさせて、状況に応じて使い分けるのが正解です。
| 観点 | 物理バックアップ | 論理バックアップ |
|---|---|---|
| 取得対象 | PGDATA/ 配下の物理ファイル (テーブル本体・インデックス・WAL を含む) | SQL 文 (CREATE TABLE / COPY などのテキストや独自バイナリ) |
| 取得時間 | I/O 性能に依存。SSD + 並列なら数百 GB/h | 行数に比例。数百 GB だと半日以上かかる場合も |
| 復元時間 | 高速 (ファイル展開 → WAL リプレイ) | 遅い (SQL を 1 文ずつ実行 + Index 再構築) |
| ファイルサイズ | テーブル + インデックス + WAL 全部 | テーブル本体のみ (Index は復元時に再構築)。COPY 形式は元データに近い |
| バージョン互換 | 同一メジャー版必須。PG 15 → 16 への移行不可 | 異バージョン間 OK。PG 13 で取って PG 16 に復元可能 |
| 部分復元 | 不可。クラスタ全体まるごと復元 | OK。テーブル単位 / スキーマ単位 / データのみ など柔軟 |
| PITR (任意時点復元) | 可能。WAL アーカイブと組み合わせる | 不可 (取得時点のスナップショットのみ) |
| Standby 構築 | OK。pg_basebackup -R で即構築 | 不可 (物理ファイルがない) |
| 整合性の取り方 | pg_backup_start/stop + WAL | REPEATABLE READ スナップショット |
| DB 停止 | 不要 (オンライン取得可能) | 不要 (オンライン取得可能) |
| 他 DB 製品への移行 | 不可 | OK (SQL ベースなので加工可能) |
| 主なツール | pg_basebackup / pgBackRest / Barman / WAL-G | pg_dump / pg_dumpall / pg_restore |
| 代表的な用途 | 本番運用の定期バックアップ / PITR / Standby 構築 / 高速復元 | DB 移行 / 別環境のセットアップ / テーブル単位の救出 / 開発環境への投入 |
実運用での組み合わせの定石は次の通りです。
PG が生成し続ける WAL をS3 や別ストレージへ送り続ける仕組み。これ単体で増分バックアップになり、PITR の基盤になります。
ここまでの ②③④ はいずれもフルバックアップ (毎回 DB 全体をコピー)でした。データベースが 10GB のうちは問題ありませんが、本番が成長して1TB、10TBになると毎日フルを取るのは現実的でなくなります。容量・取得時間・I/O 負荷の 3 つすべてが指数関数的に膨らみ、夜間バッチに収まらなくなるのです。
そこで使うのが増分 (incremental) と差分 (differential)です。考え方は単純で、「前回から変わったブロックだけコピーする」。1TB の DB でも 1 日に変わるブロックが 10GB なら、増分は 10GB で済みます。これによりバックアップ時間とストレージを 1〜2 桁削減できます。⑥ WAL アーカイブも本質的には「変更分だけを記録する」増分ですが、こちらは「変更後のページそのものをまるごとコピーする」点で異なります (WAL は差分命令の連なり、増分はページのスナップショット)。
「増分」と「差分」は紛らわしいので、「土曜日の状態に戻したい時、何のファイルが要るか」というシナリオで違いを示します。下の図では、赤い破線で囲んだのが復元に必要なファイルです。
要点は 3 つ。① フルは毎日 DB 全体を取るので容量が膨大だが、復元は最新フル 1 個でいい。② 差分は常に日曜のフルから測るので、月→火→水…と日々大きくなる。代わりに復元は「フル + その日の差分」の 2 個で済む。③ 増分は前日との差だけを取るので毎回小さいが、復元にはフル + 全部の増分が必要 (鎖のように繋がっている)。
| 観点 | フル | 差分 (Differential) | 増分 (Incremental) |
|---|---|---|---|
| 基準点 | なし (毎回 DB 全体) | 前回フルからの変更 | 前回フル or 直前の増分からの変更 |
| 1 回あたり容量 | 大 (DB 全体) | 日々増える (累積差分) | 小 (1 日分の変更のみ) |
| 取得時間 | 長い | 日々増える | 毎回最小 |
| 1 週間の総容量 | 7× (毎日フル) | ~3× (フル 1 回 + 累積差分 6 個) | ~1.5× (フル 1 回 + 小さい増分 6 個) |
| 復元に必要なファイル | フル 1 個のみ | フル + 最新の差分 1 個 | フル + すべての増分 (チェーン) |
| 復元時間 | 速い (1 段) | 速い (2 段) | 遅い (N 段、増分を順次適用) |
| チェーン破損リスク | なし | 低い (差分 1 個が壊れても他に影響なし) | 高い (途中の増分が壊れると以降が無効) |
| 本番運用での位置付け | 週次の基礎 | 中規模 DB の標準 | 大規模 DB の標準 |
| 代表ツール | pg_basebackup / pgBackRest --type=full | pgBackRest --type=diff | pg_basebackup --incremental (PG 17+) / pgBackRest --type=incr |
選び方の要点: 容量を最小化したいなら増分、復元の信頼性と速度を取るなら差分。ストレージが安価でクラウド S3 のような環境なら増分、自前ストレージで保管容量を強く気にするなら差分か、両者のハイブリッド (例: 日曜フル + 水曜差分 + 平日増分) を組みます。
PG 17 から、PostgreSQL 本体にブロックレベル増分バックアップが標準搭載されました。それまでは pgBackRest など外部ツール頼みでしたが、いまや pg_basebackup だけで増分が取れます。仕組みの核は WAL Summarizer という新しいバックグラウンドプロセスです。
重要なポイントは、WAL Summarizer が「ブロック単位の変更追跡」を裏で常時走らせていること。これを summarize_wal = on (PG 17 デフォルト) で有効化していないと、--incremental を指定してもエラーになります。本番投入時の最初のチェック項目です。
まずは postgresql.conf で WAL Summarizer を有効化します (PG 17 ではデフォルト on のことが多いですが、明示しておくのが安全)。
# postgresql.conf summarize_wal = on # WAL Summarizer プロセスを起動 wal_summary_keep_time = '10d' # サマリ保持期間 (この期間内なら増分可) # 反映 SELECT pg_reload_conf(); # 起動確認 SELECT * FROM pg_stat_wal_summarizer;
以下は「日曜にフル → 月〜土に増分」を 1 週間運用するスクリプト例です。
# === 日曜: フル取得 === pg_basebackup -h primary -D /backups/2026w12/full \ -Fp -Xs -P -c fast # === 月曜: 1 回目の増分 (フルを基準) === pg_basebackup -h primary -D /backups/2026w12/incr-mon \ -Fp -Xs \ --incremental=/backups/2026w12/full/backup_manifest # === 火曜: 2 回目の増分 (前日の増分を基準) === pg_basebackup -h primary -D /backups/2026w12/incr-tue \ -Fp -Xs \ --incremental=/backups/2026w12/incr-mon/backup_manifest # ... 水〜土まで同様にチェーンを伸ばす # === 復元: pg_combinebackup で「フル + 全増分」を合成 === # 例えば「火曜の状態」に戻したい時: pg_combinebackup \ /backups/2026w12/full \ /backups/2026w12/incr-mon \ /backups/2026w12/incr-tue \ -o /var/lib/postgresql/17/restored # あとは restored ディレクトリで postgres を起動するだけ # WAL アーカイブも併用すれば、増分以降の任意の時刻まで PITR 可能
--incremental に渡します。チェーンが途切れるとそれ以降の増分は無効になり、復元時に pg_combinebackup でエラーになります。月曜の増分ディレクトリを削除したら、火〜土の増分はすべて捨てて、再度フルから取り直す必要があります。pgBackRest はPostgreSQL 専用の本番運用バックアップツールで、増分・差分・PITR・S3 アップロード・並列処理・圧縮・保持ポリシーまで全部入りの定番です。PG 17 ネイティブ増分が登場する前から数年の運用実績があり、今でも「とりあえずこれを入れておけば間違いない」と言える存在です。
特徴: (a) S3 / Azure / GCS への直接アップロード対応、(b) 並列バックアップ (CPU フル活用)、(c) 圧縮 (gz / lz4 / zstd)、(d) 暗号化、(e) 自動の保持ポリシー (古いバックアップを自動削除)、(f) 復元時の自動チェーン解決。
# === /etc/pgbackrest/pgbackrest.conf の設定例 === [global] repo1-path=/var/lib/pgbackrest # ローカル保管先 repo1-retention-full=2 # フルを 2 世代保持 repo1-retention-diff=4 # 差分を 4 世代保持 process-max=4 # 並列度 compress-type=zstd # 圧縮アルゴリズム compress-level=3 [main] pg1-path=/var/lib/postgresql/17/main # PG データディレクトリ # === 1 週間のスケジュール === # Sunday: フル pgbackrest --stanza=main --type=full backup # Wed: 差分 (前回フルからの差分 → 復元は「フル + 水差分」の 2 段) pgbackrest --stanza=main --type=diff backup # Mon, Tue, Thu, Fri, Sat: 増分 (直前のバックアップからの差分) pgbackrest --stanza=main --type=incr backup # === バックアップ一覧 === pgbackrest --stanza=main info # Backup list が表示される。チェーン構造も視覚化される。 # === 復元 (3 パターン) === # (1) 最新のバックアップに戻す pgbackrest --stanza=main restore # (2) 任意の時刻に PITR pgbackrest --stanza=main \ --type=time --target='2026-03-15 14:25:00+09' restore # (3) 特定の世代を指定 pgbackrest --stanza=main \ --type=name --set=20260308-022500F restore # === S3 アップロード版 (本番推奨) === [global] repo1-type=s3 repo1-s3-bucket=mycompany-pg-backup repo1-s3-region=ap-northeast-1 repo1-s3-endpoint=s3.amazonaws.com repo1-cipher-type=aes-256-cbc # 静止時暗号化 repo1-cipher-pass=... # 鍵 (秘匿管理)
実運用では、容量と復元時間のバランスを取って「フル + 差分 + 増分」の組み合わせを使います。代表的な 3 パターンを紹介します。
summarize_wal = on が前提。プロセスが何らかの理由で停止すると、その間の WAL からは増分が取れません。pg_stat_wal_summarizer を監視に組み込み、停止アラートを必ず設定する。repo1-retention-archive で自動連携。compress-type=zstd + process-max で並列圧縮し、転送量を最小化する。pg_combinebackup はフルと全増分を合成して展開後の DB と同じサイズの新ディレクトリを作る。1TB DB なら 1TB 以上の空きが必要。復元先ホストのディスク容量を事前確認しないと、復元途中で no space left エラーで止まる。summarize_wal = on と --incremental だけで運用可能。 ② 大規模 / 本番クリティカル (~10TB+) ならpgBackRest。S3 アップロード・並列・圧縮・自動保持で運用コストを最小化。 ③ どちらの場合も復元演習を四半期に 1 回実施し、WAL アーカイブの保持期間とチェーン健全性の監視を必ず仕込む。| ツール | 増分 | 圧縮 | 並列 | クラウド | 評価 |
|---|---|---|---|---|---|
| pg_basebackup | PG 17+ | △ | × | △ | ◯ 標準 |
| pgBackRest | ◎ | ◎ | ◎ | ◎ | ◎ 推奨 |
| Barman | ◯ | ◯ | ◯ | △ | ◯ オンプレ向き |
| WAL-G | ◯ | ◎ | ◎ | ◎ | ◯ クラウド特化 |
| Citus Forge / Vendor | マネージド | - | - | - | △ 依存 |
pg_dump は論理バックアップを取るコマンドラインユーティリティで、テーブル定義と SQL データをファイルに書き出します。psql ではなくシェルから実行します。
# 基本構文 pg_dump [connection-option ...] [option ...] [dbname]
-h host / -p port / -U user-d dbname-F formatp = plain SQL (デフォルト) / c = custom (圧縮 + 並列復元可) / d = directory / t = tar。本番は -Fc または -Fd 推奨。-f file-Fd ではディレクトリ名。指定しないと標準出力へ。-j N-Fd でのみ有効。大規模 DB で時間短縮に。CPU 数の半分程度が目安。-Z N-Fc / -Fd で有効。-s --schema-only-a --data-only-t pattern-t public.orders のようにスキーマ付きで指定。-T pattern-n schema-N で除外も可。--no-owner--no-privileges--no-comments--column-inserts--inserts--snapshot=NAME--lock-wait-timeout=N# ① 本番標準: 圧縮 custom 形式 (pg_restore で並列復元可) pg_dump -h prod-db -U readonly -Fc -Z 9 -f backup.dump myapp # ② 並列ダンプ (大規模 DB で 4 並列) pg_dump -h prod-db -U readonly -Fd -j 4 -f backup_dir myapp # ③ スキーマのみ取得 (DDL バックアップ) pg_dump -h prod-db -U readonly -s -f schema.sql myapp # ④ 特定テーブルのみ (ログテーブルを除外) pg_dump -h prod-db -U readonly -Fc \ -t 'public.users' -t 'public.orders' \ -f users_orders.dump myapp # ⑤ ログテーブルを除外して取得 pg_dump -h prod-db -U readonly -Fc \ -T 'public.*_log' \ -f no_logs.dump myapp # ⑥ 別環境への移行向け (所有者・権限を抜く) pg_dump -h prod-db -U readonly -Fc \ --no-owner --no-privileges \ -f migration.dump myapp # ⑦ S3 へ直接ストリーミング pg_dump -h prod-db -U readonly -Fc myapp | \ aws s3 cp - s3://backups/myapp-$(date +%Y%m%d).dump # ⑧ pg_dumpall — クラスタ全体 (全 DB + ロール + 設定) pg_dumpall -h prod-db -U postgres > full_cluster.sql # ロールとテーブルスペースだけ pg_dumpall -h prod-db -U postgres --globals-only > globals.sql
pg_dump と pg_dumpall の違い: pg_dump は 1 つの DB のみ、pg_dumpall はクラスタ全体 (全 DB + ロール + テーブルスペース + 設定)。本番では「定期的に pg_dump で個別 DB + 週次で pg_dumpall --globals-only でロール定義」を組み合わせるのが定石です。
pg_basebackup は物理バックアップを取るコマンドで、データファイルと WAL を整合性を保った状態でコピーします。PITR の起点となるベースバックアップや、新しい Standby サーバの初期化に使います。
# 基本構文 pg_basebackup [option ...]
-h host / -p port / -U user-D directory-F formatp = plain (展開済み) / t = tar (複数 tablespace は -Ft 必須)。-z / --compress=N-X stream / fetch / nonestream (デフォルト、並行受信で整合性保証) / fetch (バックアップ完了後一括) / none (取らない、archive 前提)。-R --write-recovery-confstandby.signal と接続情報を自動生成。Standby 構築時に必須。-S slotname-c fast / spreadfast は即時実行 (短時間で開始)、spread は I/O 分散 (本番影響少)。-P --progress--max-rate=N--checkpoint=fast-T olddir=newdir# ① 本番標準: 圧縮 tar + WAL stream
pg_basebackup -h primary -U replicator \
-D /backup/$(date +%Y%m%d) \
-Ft -z -X stream -P -c fast
# ② Standby 初期化用 (-R で recovery 設定自動生成)
pg_basebackup -h primary -U replicator \
-D /var/lib/postgresql/16/main \
-R -X stream -S standby_slot -P
# ③ レート制限ありで本番影響を抑える
pg_basebackup -h primary -U replicator \
-D /backup/today \
-Ft -z -X stream \
--max-rate=50000 # 50 MB/s に制限
# ④ S3 直接転送 (PG 15+ の server-side compression)
pg_basebackup -h primary -U replicator \
-D - -Ft -X fetch \
--compress=server-zstd:5 | \
aws s3 cp - s3://backups/base-$(date +%Y%m%d).tar.zst
# ⑤ テーブルスペースを別ディスクへ
pg_basebackup -h primary -U replicator \
-D /var/lib/postgresql/16/main \
-T /old/tablespace=/new/tablespace \
-R -X stream
# ⑥ 進捗とサイズを事前確認 (pg_database_size でだいたい分かる)
psql -h primary -c "SELECT pg_size_pretty(sum(pg_database_size(datname)))
FROM pg_database WHERE datallowconn;"pg_basebackup の注意点: ① レプリ用ロールに REPLICATION 権限が必要、② postgresql.conf の max_wal_senders が足りないと失敗、③ pg_hba.conf に replication エントリが必要、④ 大規模 DB では時間がかかるので -S でレプリスロットを使うのが安全。
cp -r や Windows のシャドウコピー) を稼働中の PostgreSQL に対して直接取ると、タプル不整合で起動不能になります。 必ず ② pg_basebackup、③ 低レベル API + スナップショット、④ DB 停止のいずれか。本章では、前章で取ったバックアップを実際にデータベースへ戻すリストア (復元) の手順について学んでいきます。
前章で繰り返し述べた通り、「バックアップを取っている」と「バックアップから戻せる」は別物です。バックアップファイルが存在していても、いざ事故が起きて復元しようとした時に「コマンドを叩いたことがない」「手順書はあるが古い」「テスト復元をやっていない」と、現場の手は止まります。本番障害は分刻みで損害が増える性質を持つため、いざという時に確実に動ける手順を、平時に身につけておく必要があります。
本章では、本番でよく遭遇する 3 つの復元シナリオを取り上げます。① pg_restore — pg_dump で取った論理バックアップから戻す基本パターン、② PITR (Point-In-Time Recovery) — WAL アーカイブを使って「特定の時刻直前」の状態へ復元する高度なパターン、③ 部分復元 — 特定のテーブルや一部のデータだけを別 DB に復元するパターン。それぞれについて、実際に叩くコマンドと、本番でハマりやすい落とし穴を整理します。
pg_restore は pg_dump で取ったcustom 形式 (-Fc) または directory 形式 (-Fd) または tar 形式 (-Ft) のバックアップから復元するコマンドです。plain SQL 形式 (-Fp) は psql で直接流すのでこのコマンドは使いません。
# 基本構文 pg_restore [connection-option ...] [option ...] [file-name]
-h host / -p port / -U user-d dbnamecreatedb で空 DB を作っておくのが基本。-C --createCREATE DATABASE で作る。dump 元の DB 設定を引き継ぐ。-d で別名指定可。-c --clean--if-exists-j N-s --schema-only-a --data-only-t pattern-t public.orders のように。-T pattern-n schema / -N schema-l --list-L file --use-list=file-1 --single-transaction--exit-on-error--no-owner--no-privileges--disable-triggers--no-comments--verbose# ① 基本: 新規 DB を作って復元 createdb -h host -U user newdb pg_restore -h host -U user -d newdb backup.dump # ② 並列復元 (大きいバックアップで威力、CPU 数の半分目安) pg_restore -h host -U user -d newdb -j 8 backup.dump # ③ 別 DB として作りつつ復元 pg_restore -h host -U postgres -C -d postgres \ -j 4 backup.dump # -C と -d postgres で「dump 元の DB を新規作成して復元」 # ④ 単一トランザクション復元 (失敗したら全部ロールバック) pg_restore -h host -U user -d newdb -1 backup.dump # ⑤ 既存 DB に上書き (DROP してから再作成) pg_restore -h host -U user -d existing --clean --if-exists backup.dump # ⑥ 特定テーブルだけ復元 (誤って TRUNCATE した時の救出) pg_restore -h host -U user -d production \ -a -t public.orders backup.dump # -a で data-only、-t で対象テーブル指定 # ⑦ スキーマだけ復元 (テスト環境を本番構造で立てる) pg_restore -h host -U user -d test_env -s backup.dump # ⑧ TOC 編集して選択的復元 (高度) pg_restore -l backup.dump > toc.list # toc.list を編集 (先頭に ; を付けると行スキップ) pg_restore -h host -U user -d newdb -L toc.list backup.dump # ⑨ S3 から直接ストリーム復元 aws s3 cp s3://backups/myapp.dump - | \ pg_restore -h host -U user -d newdb # ⑩ plain SQL 形式は psql で復元 psql -h host -U user -d newdb -f backup.sql # または psql -h host -U user newdb < backup.sql # ⑪ 進捗確認しながら復元 pg_restore -h host -U user -d newdb \ --verbose -j 4 backup.dump 2>&1 | tee restore.log
pg_restore の注意点: ① 復元先 DB は事前に作っておく (-C 使うなら postgres DB に接続)、② 並列復元 (-j) は custom / directory 形式のみ、③ トリガー・FK・index 等は復元中も有効なので、大量データなら --disable-triggers や事前 DROP も検討、④ 復元中は対象 DB の負荷が極めて高いので、本番への直接復元はメンテナンスウィンドウで。
「3 時間前の状態に戻したい」「誤った UPDATE の直前に戻したい」という時に使います。 事前に archive_mode = on で WAL を別保管庫へ送り続けている前提です。
# Step 1: 直近のフルバックアップを展開 sudo systemctl stop postgresql sudo rm -rf /var/lib/postgresql/16/main/* tar xzf /backups/base.tar.gz -C /var/lib/postgresql/16/main/ # Step 2: recovery 設定 (PG 12+ は postgresql.auto.conf + standby.signal) cat >> /var/lib/postgresql/16/main/postgresql.auto.conf <<EOF restore_command = 'aws s3 cp s3://my-bucket/wal/%f %p' recovery_target_time = '2026-03-15 14:25:00 JST' recovery_target_action = 'promote' EOF # Step 3: recovery 開始の signal ファイル touch /var/lib/postgresql/16/main/recovery.signal # Step 4: 起動 → WAL を時点まで適用 → 自動的に通常モードへ sudo systemctl start postgresql # Step 5: ログで確認 tail -f /var/log/postgresql/postgresql-16-main.log # "archive recovery complete" → "database system is ready" を確認 # 時点指定の選択肢: # recovery_target_time = '2026-03-15 14:25:00' (時刻) # recovery_target_lsn = '0/A1B2C3D4' (WAL の正確な位置) # recovery_target_xid = 12345 (トランザクション ID) # recovery_target_name = 'before_disaster' (事前に pg_create_restore_point で名前付け)
全体を戻すのではなく、「誤って TRUNCATE したテーブル 1 つだけ復元」という需要が多いです。 物理バックアップでは難しく、論理バックアップ + 手作業が現実的。
# 戦略 A: 別 DB に全体復元 → COPY で対象テーブルだけ移行
createdb -h host -U user temp_restore
pg_restore -h host -U user -d temp_restore backup.dump
psql -h host -U user -d production -c \
"INSERT INTO orders
SELECT * FROM dblink('host=host dbname=temp_restore',
'SELECT * FROM orders') AS t (id INT, ...);"
dropdb -h host -U user temp_restore
# 戦略 B: 別ホストに復元 → pg_dump で対象テーブルだけ → 本番に流す
pg_restore -h temp_host -d newdb backup.dump
pg_dump -h temp_host -t orders newdb | psql -h primary production
# 戦略 C: PITR で全体を新ホストに → 必要テーブルだけ抽出
# (誤操作 5 分前の状態を取り戻したい場合に有効)本章では、本番 PostgreSQL の監視 (モニタリング) について学んでいきます。
監視とは、DB の動作状態を継続的に観測し、異常が起きていないか・起きそうな兆候はないかを把握する活動です。本番運用では、いきなり止まる前に「このペースだとあと 1 週間でディスクが満杯になる」「最近スロークエリが増えてきている」「レプリカ遅延が拡大している」といった兆候を捉え、深刻化する前に対処することが極めて重要です。何も見ていない状態では、ユーザーから「動きません」と連絡が来た時点で初めて気付くことになります。
PostgreSQL の監視は「リアルタイム」と「履歴」の 2 軸で考えます。リアルタイム監視は「なぜ今遅いのか」を即座に答えるためのもの (障害発生時の現場対応)、履歴監視は「このペースだと 3 ヶ月後にどうなるか」を予測するためのもの (傾向分析と容量計画) です。本章では、監視対象の 3 レイヤ (OS / PG プロセス / クエリ)、押さえるべきメトリクス Top 10、pg_stat_statements / auto_explain / pgbadger といった必須ツール、そしてアラート設計の考え方までを順に整理します。
いきなりメトリクスの話に入る前に、まず「そもそも何を観測すべきなのか」の全体地図を押さえます。 DB の監視は対象によって 3 つのレイヤに整理すると見通しが良くなります。サーバそのものの状態を見るOS / インフラ層、PostgreSQL プロセスの内部状態を見るPG 内部層、実際にユーザーが投げているクエリを見るクエリ / アプリ層です。
重要なのは、この 3 つは「どちらが正しい」ではなく、層によって見えるものが違うということです。たとえばユーザーから「サイトが遅い」と言われた時、L3 を見れば「スロークエリが急増している」と分かり、L2 を見れば「キャッシュヒット率が下がっている」と分かり、L1 を見れば「ディスク I/O が飽和している」と分かる、というように「どの層で異常が起きているか」を特定することで初めて原因に近づけます。本番監視では 3 層すべてを並行して見ることが必須です。
次に、この 3 レイヤの中で本番運用上絶対に外せないメトリクスを 10 個に絞って解説します。これらを最低限抑えておけば、本番で起きる問題の 8 割以上は早期に検知できます。
ここでは、本番運用で最低限見ておくべき 10 個のメトリクスを 1 つずつ取り上げ、「何を見るためのものか (役割)」「どの SQL で取れるか」「どの値が正常か」「異常値だった場合、何を意味するか」をセットで解説します。各メトリクスは前述の 3 レイヤのどこかに対応しており、組み合わせて見ることで全体像を把握できます。
SELECT count(*) FROM pg_stat_activity;
SELECT count(*) FROM pg_stat_activity WHERE state='idle in transaction';
SELECT sum(heap_blks_hit) / nullif(sum(heap_blks_hit)+sum(heap_blks_read),0) FROM pg_statio_user_tables;
SELECT xact_commit + xact_rollback FROM pg_stat_database WHERE datname=current_database();
SELECT application_name, replay_lag FROM pg_stat_replication;
SELECT relname, n_dead_tup, n_live_tup FROM pg_stat_user_tables;
SELECT count(*) FROM pg_locks WHERE NOT granted;
SELECT pg_database_size(current_database()); SELECT * FROM pg_ls_waldir();
(OS レベル: top / iostat / vmstat / sar)
SELECT * FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;
ここからは、メトリクスを取るだけでは見えてこない「個別のクエリの遅さ」を観測するためのツール 3 つを順に紹介します。 これらは本番 PostgreSQL の三種の神器と呼べる定番セットで、どんなチームでも導入が推奨されています。
pg_stat_statements は PostgreSQL に標準同梱されている拡張機能で、すべての SQL の実行回数と所要時間を裏で記録し続ける役割を担います。1 度有効化しておけば、それ以降「クエリ A は 1 日に何回呼ばれ、合計何秒かかったか」「クエリ B の平均応答時間はどう推移しているか」が SQL で取り出せるようになります。
なぜ必要か。本番では同じクエリが秒間何千回と繰り返し実行されており、その中で「全体の負荷の大部分を占めているのはどのクエリか」を特定する手段がないと、チューニングの優先順位が決められません。pg_stat_statements は「ORDER BY total_exec_time DESC で並べるだけで重い順 Top 10 が出てくる」という、最もシンプルで強力なボトルネック特定方法を提供してくれます。本番 PG では必ず有効化すべきツールです。
-- ① 有効化 (postgresql.conf) shared_preload_libraries = 'pg_stat_statements' pg_stat_statements.track = all -- 再起動 (postmaster context) -- ② 拡張を作成 (各 DB) CREATE EXTENSION pg_stat_statements; -- ③ 累積時間トップ 10 のクエリ SELECT substr(query, 1, 80) AS query, calls, round(total_exec_time::numeric, 2) AS total_ms, round(mean_exec_time::numeric, 2) AS mean_ms, rows, round(100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0), 1) AS hit_ratio FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10; -- ④ 平均実行時間が最近悪化したクエリを探す SELECT query, calls, mean_exec_time, stddev_exec_time FROM pg_stat_statements WHERE mean_exec_time > 100 -- 100ms 以上 ORDER BY stddev_exec_time DESC LIMIT 20; -- ⑤ リセット (リリース直後の比較用) SELECT pg_stat_statements_reset();
auto_explain は、「遅いクエリが発生した瞬間に、その EXPLAIN ANALYZE を自動でログに書き出してくれる」拡張です。たとえば「1 秒以上かかったクエリは全部その時の実行計画を保存しておく」という設定をしておけば、本番でスパイクが起きた後でも、その瞬間にどんな計画で動いていたかをログから振り返ることができます。
なぜ pg_stat_statements だけでは足りないのか。pg_stat_statements は「累積時間」や「平均時間」は記録しますが、その時の実行計画 (EXPLAIN の結果) までは保存しません。本番で問題が起きてから「どのクエリが遅かったか」は分かっても、「なぜそのクエリが遅かったのか」(Seq Scan に切り替わっていた・統計情報が古くてプランがおかしかった・他のクエリと競合していた、など) を後追いで調査するには、当時の EXPLAIN が必要です。auto_explain がそれを自動でログに残してくれます。
# postgresql.conf shared_preload_libraries = 'pg_stat_statements,auto_explain' auto_explain.log_min_duration = 1000 # 1 秒以上を記録 auto_explain.log_analyze = true # EXPLAIN ANALYZE 相当 auto_explain.log_buffers = true # Buffers 情報も auto_explain.log_format = 'text' # または 'json' auto_explain.log_nested_statements = true # 副作用: log_analyze=true は実クエリ実行コストの ~5% 増。 # 慎重に使うか、log_min_duration を大きめに。
pgbadger は、PostgreSQL が出力するログファイル (postgresql-*.log) を解析して、1 つの HTML レポートにまとめてくれる OSS ツールです。スロークエリ Top N、時間帯別の QPS グラフ、エラーパターン、接続トレンド、checkpoint の頻度などが、グラフ・表・ランキング形式で 1 ページにまとまります。
なぜ役立つか。pg_stat_statements や auto_explain は「現在の状態を SQL で取り出す」仕組みなので、本番で振り返りや関係者共有には向きません。pgbadger なら、毎週ログを食わせて HTML を生成し、PR / Slack に貼って関係者全員でレビューする、という運用が可能になります。「先週と比べてスロークエリ Top 10 がどう変わったか」「リリース前後でクエリの分布がどう変わったか」を視覚的に追えるため、週次・月次のチューニングサイクルを回す上で必須のツールです。
# 前提: postgresql.conf でログを取っておく log_min_duration_statement = 1000 # 1 秒以上のクエリを記録 log_line_prefix = '%t [%p]: db=%d,user=%u ' log_checkpoints = on log_connections = on log_disconnections = on log_lock_waits = on log_temp_files = 0 # pgbadger 実行 pgbadger -o report.html /var/log/postgresql/postgresql-*.log # 出力: スロークエリ Top N / 時間帯別 QPS / エラー集計 / 接続トレンド # 開発環境にコピーしてブラウザで開ける
三種の神器 (pg_stat_statements / auto_explain / pgbadger) はクエリレベルの観測手段ですが、これらを常時グラフ化したり、複数台にまたがって集約したり、過去のトレンドを保存したりするには、別途メトリクス収集ツールやダッシュボードと組み合わせる必要があります。次にその選択肢を整理します。
PostgreSQL の監視まわりは関連ツールが多いので、まず「自分が欲しい機能はどのカテゴリか」を切り分けることが大切です。下記の 5 カテゴリは役割が違うため、いくつかを組み合わせて使うのが普通です。たとえば「Prometheus + Grafana + pg_stat_statements + pgbadger + RDS Performance Insights」のように、4〜5 個のツールを併用するのも珍しくありません。
Prometheus + Grafana / Datadog / New Relic / CloudWatchpgbadger / pgAnalyzepg_stat_statements / pg_stat_monitor (Percona)pgAdmin / DBeaver / TablePlus / DataGripRDS Performance Insights / Cloud SQL Insights / Azure Query Performanceツールが揃ったら、次は「収集したデータをどう見せるか」のダッシュボード設計です。ここで間違えると、せっかく揃えた監視基盤が使われなくなります。
ありがちな失敗は、「全部のメトリクスを 1 つのダッシュボードに詰め込む」というものです。グラフが 30 個も並んでいると、どこを見ればいいか分からなくなり、結局誰も見なくなります。良いダッシュボードは「誰が、いつ、何の目的で見るか」を明確にして、目的別に複数枚作るのが鉄則です。下記の 4 種類が現場でよく使われるパターンです。
ダッシュボードは「自発的に見にいく」仕組みですが、緊急の異常は誰かに自動で通知する必要があります。そのためのアラート設計を最後に押さえます。
アラート設計でもっとも大事なのは、「すべてのアラートを同じ通知経路で送らない」ことです。深夜 3 時に電話で起こされてもいい P0 アラートと、翌朝確認すればいい P2 アラートを混ぜると、すぐに狼少年化して本物のアラートが埋もれます。重要度を 3 段階に分けて、通知経路と対応時間を変えるのが定石です。
重要なのは「ユーザー影響に直結するものだけを最上位アラートにする」という原則です。たとえば「キャッシュヒット率 89%」のような内部指標は深夜に起こす理由にはならず、P2 (週次レビュー) で十分です。一方「コミット成功率 90% を下回った」のようにユーザー操作が失敗している状態は、すぐに対応しないと損害が拡大するため P0 です。下記がその振り分けの例です。
本章では、本番 PostgreSQL の運用で必須となる接続プーリングと、その代表的な実装である PgBouncer について学んでいきます。
Section 03 で見たように、PostgreSQL はクライアント 1 接続ごとに 1 つの OS プロセスを fork する設計です。これは堅牢ですが、1 接続あたり 5〜20MB のメモリを消費し、加えて接続開始時の fork や認証で 5〜30ms かかります。Web アプリのように瞬間的に 1000 接続が来る環境では、これだけで 10〜20GB のメモリが「接続を持つだけ」で消費され、本来 Shared Buffers やキャッシュに回せるはずのメモリを浪費してしまいます。
この問題を解決するのが接続プーリングです。少数の DB 接続を事前に作って常駐させておき、クライアントが来たらその中から空きを 1 つ貸す、終わったら返してもらって次のクライアントに再利用する、という仕組みです。これにより 1000 クライアントを 20〜50 個の DB 接続でさばけるようになり、メモリ消費・接続セットアップ時間ともに劇的に下がります。PostgreSQL の世界で最も普及しているプーラーが PgBouncer です。本章では PgBouncer の役割、3 つのプールモード (session / transaction / statement) の違い、本番での設定値、運用上の注意点までを解説します。
PostgreSQL のマルチプロセスモデル (Section 03) ゆえに、接続を増やすと3 つの問題が一斉に起こります。 「なぜ pooler が必要か」をきちんと言語化できることが、設計判断の前提となります。
PgBouncer の解決策: 軽量な「中継プロセス」を立てて、アプリ ↔ DB 間に挟む。 アプリは 使い捨ての接続 を PgBouncer に張り、PgBouncer は少数の DB 接続を再利用してクエリを実行。 結果として「1000 接続のように見えるが、実 DB 接続は 50」が実現できます。
PgBouncer は3 つのプール戦略を選べます。本番ではほぼ transaction モード一択ですが、各モードの仕組みと制約を知っておくと、ORM 設定や障害切り分けで役立ちます。
sessiontransactionstatement# /etc/pgbouncer/pgbouncer.ini [databases] mydb = host=pg-primary port=5432 dbname=mydb mydb_replica = host=pg-replica port=5432 dbname=mydb [pgbouncer] listen_addr = 0.0.0.0 listen_port = 6432 # アプリはここに繋ぐ # プールモード (本番ほぼ常に transaction) pool_mode = transaction # 接続数の設定 (これが最重要) max_client_conn = 1000 # アプリから受け付ける接続上限 default_pool_size = 25 # DB 1 つに対する実 DB 接続数 min_pool_size = 5 # 最小プール (起動時) reserve_pool_size = 5 # 緊急用予備プール reserve_pool_timeout = 5 # 何秒待ってから reserve を使うか max_db_connections = 50 # DB 全体での実接続上限 # 認証 auth_type = scram-sha-256 auth_file = /etc/pgbouncer/userlist.txt # タイムアウト server_idle_timeout = 600 # idle な DB 接続の切断時間 server_lifetime = 3600 # 1 つの DB 接続を再利用する最大時間 query_timeout = 0 # 0 = 制限なし (PG 側で設定推奨) query_wait_timeout = 120 # アプリがプールから接続を取るまでの待ち時間 # 管理 admin_users = postgres # SHOW POOLS など管理コマンドが使えるユーザー stats_users = monitoring # 統計閲覧専用ユーザー # ログ log_connections = 1 log_disconnections = 1 log_pooler_errors = 1 # /etc/pgbouncer/userlist.txt の例 # (SCRAM ハッシュは psql の \password で生成) "app_user" "SCRAM-SHA-256$4096:..."
transaction モードではプリペアドステートメントを無効化するか、PG 16+ で対応するキャッシュ層を使う必要があります。代表的なライブラリの設定例:
// Node.js (pg)
// connection string に ?pgbouncer=true を付ける、または:
const pool = new Pool({
connectionString: 'postgres://app@pgbouncer:6432/mydb',
max: 10,
statement_timeout: 30000,
})
// Prisma — PgBouncer モード
// schema.prisma の datasource で
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL") // マイグレーション用 (直 DB)
}
// DATABASE_URL に ?pgbouncer=true を付ける
// Python (psycopg2 / psycopg3)
import psycopg
conn = psycopg.connect("host=pgbouncer port=6432 dbname=mydb",
prepare_threshold=None) # PG ≥ 16 + psycopg3 なら不要
// Ruby on Rails (ActiveRecord)
production:
prepared_statements: false
advisory_locks: false # 必要に応じて
// Java (HikariCP)
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(10);
config.addDataSourceProperty("prepareThreshold", "0"); // PG JDBC で prepared 無効「default_pool_size をいくつにすべきか」は本番投入前に必ず詰める必要のある判断です。考え方は以下のフレームワークになります。
# 推奨式 (簡易版) default_pool_size = CPU コア数 × 2 + ディスクスピンドル数 # 例: 8 CPU + SSD なら default_pool_size = 8 × 2 + 1 = 17 → 20 に丸める # その他の考慮点: # ① ピーク時に処理待ちが頻発 → pool_size を増やすか、クエリを最適化 # ② DB CPU が常に高負荷 → pool_size 増やすと逆効果 # ③ pool_mode = session の場合は、ほぼ同時セッション数を確保 # max_client_conn は「アプリの最大スレッド × アプリサーバ数」 # 例: 10 サーバ × 50 worker = 500 → max_client_conn = 1000 (倍の余裕) # max_db_connections (DB 側の上限) # PG の max_connections - (Standby 用 + 管理用余裕) - default_pool_size × DB 数 # 普通は PG max_connections = 100 → max_db_connections = 80 程度
PgBouncer は専用の管理コンソールを持っており、psql で pgbouncer という仮想 DB に接続すると SHOW コマンドが使えます。
# 管理コンソールに接続 psql -h pgbouncer-host -p 6432 -U postgres pgbouncer # 主要コマンド SHOW POOLS; -- プール毎の状態 (cl_active / cl_waiting / sv_active / sv_idle) SHOW DATABASES; -- 接続先 DB 一覧 SHOW SERVERS; -- DB へ繋がっている実接続 SHOW CLIENTS; -- アプリから来ている接続 SHOW STATS; -- 累積統計 (qps / レイテンシなど) SHOW STATS_AVERAGES; -- 直近平均 SHOW CONFIG; -- 現在の設定値 SHOW MEM; -- メモリ使用量 SHOW LISTS; -- 各種カウンタの合計 # 動的操作 RELOAD; -- 設定再読込 (再起動不要) PAUSE mydb; -- 特定 DB を一時停止 RESUME mydb; -- 再開 KILL mydb; -- 強制切断 SUSPEND; -- 全 PgBouncer 一時停止 (メンテナンス用) SHUTDOWN;
cl_waiting (クライアント待ち)プール枯渇のサイン。> 0 が頻発するなら pool_size を増やすsv_active (アクティブ DB 接続)プールがどれだけ使われているか。常に max に張り付くなら設計見直しsv_idle (idle DB 接続)十分な余裕があるか確認。0 なら逼迫avg_query_time個別 SQL の平均実行時間。突然伸びたらクエリ最適化が必要avg_xact_timeトランザクション全体の平均時間。長い = アプリ側で長時間 Tx を持っているtotal_received / total_sentネットワーク帯域消費SET LOCAL でなく SET を使うと別接続に漏れる、 ③ アプリ側で「セッションに状態を持つ」設計は壊れる、 ④ LISTEN/NOTIFY 不可、⑤ 一時テーブルが消える、 ⑥ advisory lock はトランザクション内で完結させる必要あり。directUrl など、DDL は PgBouncer を経由せず直接 PG へつなぐのが鉄則。 PgBouncer 経由だと CREATE INDEX CONCURRENTLY 等が動かないため。本章では、本番運用中の PostgreSQL を新しいバージョンに上げるためのアップグレード戦略について学んでいきます。
PostgreSQL は毎年 1 回メジャーバージョンアップされる DB です (PG 16 → 17 → 18 ...)。各メジャーバージョンは原則 5 年間サポートされ、それを過ぎるとセキュリティパッチも提供されなくなるため、本番運用において「永遠に古いバージョンを使い続ける」という選択肢は存在しません。新機能の取り込み、パフォーマンス改善、セキュリティの維持のいずれの観点でも、定期的なアップグレードが必要になります。
アップグレードには 2 種類あります。マイナーアップグレード (16.1 → 16.2) はバグ修正のみで内部データ形式の変更はないため、新しいバイナリで再起動するだけで済みます。一方メジャーアップグレード (16.x → 17.x) は内部フォーマットが変わる可能性があり、データの移行が必要です。本番では数時間のダウンタイムが許されない場合も多く、選ぶ方法によって運用負担が大きく変わります。本章では 3 つの主要なアップグレード方法 (pg_dumpall / pg_upgrade / Logical Replication 経由) の比較、pg_upgrade の典型フロー、ダウンタイム最小化のテクニックまでを順に解説します。
# 1. 新バージョンを別ディレクトリにインストール # 2. 旧 PG を停止 sudo systemctl stop postgresql-15 # 3. pg_upgrade を実行 (ハードリンクモード) pg_upgrade \ --old-bindir=/usr/lib/postgresql/15/bin \ --new-bindir=/usr/lib/postgresql/16/bin \ --old-datadir=/var/lib/postgresql/15/main \ --new-datadir=/var/lib/postgresql/16/main \ --link # 4. 新バージョンを起動 sudo systemctl start postgresql-16 # 5. ANALYZE で統計再構築 psql -c "ANALYZE;" # 6. 動作確認 → 旧データを削除
pg_basebackup または pg_dumpall)、 ② リリースノートで互換性破壊変更を確認、③ pg_upgrade --check で事前検査、 ④ ステージングで全アプリの動作確認。postgresql.org/support/versioning/ で確認。本章では、PostgreSQL が半構造化データを扱うための強力な機能、JSON / JSONB について学んでいきます。
通常のテーブルカラムは「数値」「文字列」「日付」のように固定された型の値しか入れられません。これに対し JSONB 型のカラムには、JSON 形式の自由なオブジェクトを 1 つ丸ごと入れられます。たとえばユーザーごとに保持するフィールドが違う設定値、API レスポンスのログ、外部システムからの可変構造データなど、「あらかじめスキーマを決めきれないデータ」を、テーブル定義を変えずに保存できます。MongoDB のようなドキュメント DB の柔軟性を、RDB の中に部分的に取り込める仕組みです。
PostgreSQL には JSON と JSONB の 2 種類があり、本番ではほぼ常に JSONB を選びます (理由は本文で解説)。JSONB は GIN インデックスと組み合わせることで、特定キーの存在や値の検索を高速にできるのも大きな特徴です。一方で、何でも JSONB に入れてしまうと型安全性が失われ、集計や JOIN が苦しくなるという罠もあります。本章では JSON / JSONB の違い、基本演算子、GIN インデックスでの加速方法、そして使い所と避け所を整理します。
-- データ例
{
"user": {"name": "Taro", "age": 30},
"tags": ["postgres", "go"]
}
-- 基本演算子
data -> 'user' -- jsonb 取得 ({"name":"Taro",...})
data ->> 'user' -- text として取得
data -> 'user' -> 'name' -- ネスト
data #> '{user,name}' -- パスでアクセス
data #>> '{user,name}' -- パス + text
-- 存在チェック (GIN で高速化可)
data ? 'tags' -- キー 'tags' が存在
data ?| array['x','y'] -- いずれか存在
data ?& array['x','y'] -- 両方存在
-- 包含チェック (GIN で高速化可)
data @> '{"tags": ["postgres"]}' -- 部分一致
data <@ '{"a":1,"b":2,"c":3}' -- 自分が含まれる-- パターン A: 全演算子サポート CREATE INDEX idx_data ON events USING GIN (data); -- ? ?| ?& @> @@ @? 全て使える、ただしサイズ大 -- パターン B: jsonb_path_ops (推奨) CREATE INDEX idx_data ON events USING GIN (data jsonb_path_ops); -- @> のみサポート、サイズ半分以下 -- 特定フィールドのみ CREATE INDEX idx_user_id ON events ((data->>'user_id')); -- B-tree。値の等価検索が高速 -- 部分インデックス + JSONB CREATE INDEX idx_active ON events ((data->>'status')) WHERE data->>'status' = 'active';
data @@ '$.user.age > 20' のようなJSON Path 式がサポートされました。 複雑なクエリが書きやすく、index も効きます。本章では、SQL の表現力を一段引き上げる 2 つの機能、Window 関数 と CTE (Common Table Expression) について学んでいきます。
この 2 つは、いずれも標準 SQL の機能ですが、GROUP BY や単純な JOIN だけでは書きづらかった処理を、SQL 一発で表現できるようにしてくれる強力な道具です。Window 関数は「各行を残したまま、そのグループ内の集計値も同時に取得する」機能で、累計売上・ランキング・前年比・移動平均などを 1 クエリで計算できます。CTE は「WITH 句で名前付きの中間結果を定義しておき、後続のクエリで使い回す」機能で、長大なネストを平らに分解して可読性を上げ、さらに再帰 CTE では階層構造の探索すら SQL でできてしまいます。
どちらも、知らないとアプリ側のループや複雑な自己 JOIN で書く羽目になるところを、SQL 単体で完結させてくれる機能です。本章では Window 関数の基本構文と主要な関数群、CTE の非再帰・再帰の使い分け、そして PostgreSQL バージョン別の性能上の注意点 (PG 11 以前の最適化フェンス問題など) を順に解説します。
Window 関数とは、「結果行を集約せずに各行のまま保ったまま、その行に対して『同じグループ内の集計値』を計算してくれる関数」のことです。 この説明だけでは分かりにくいので、馴染みのある GROUP BY と対比すると違いが見えます。
ではなぜこの章を立てるのか。Window 関数を知らないと、本来 SQL 一発で書ける処理をアプリ側のループや、複雑なサブクエリ自己 JOINで書く羽目になるからです。SQL の書き手の力量がもっとも如実に表れる領域でもあります。
構文の核は 関数() OVER (PARTITION BY ... ORDER BY ...) の OVER 句です。PARTITION BY で窓 (集計の対象範囲) を区切るキーを指定し (例: 顧客 ID 毎にグルーピング)、ORDER BY でその窓内の並び順を決めます (例: 注文日時順)。 関数部分は SUM / COUNT / AVG など普通の集計関数のほか、RANK / LAG / NTILE など Window 関数専用のものが使えます。
-- 各注文に対し、その顧客の累積金額を計算
SELECT order_id, customer_id, amount,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at
) AS running_total
FROM orders;
-- 結果:
-- order_id | customer_id | amount | running_total
-- 101 | 1 | 1000 | 1000
-- 105 | 1 | 500 | 1500
-- 110 | 1 | 2000 | 3500
-- 102 | 2 | 3000 | 3000
-- ランキング
SELECT product, sales,
RANK() OVER (ORDER BY sales DESC) AS rank,
LAG(sales) OVER (ORDER BY sales DESC) AS prev_sales
FROM sales_summary;ROW_NUMBER()1, 2, 3, 4, ... と連番RANK()同値は同順位、その分次は飛ばす (1,1,3...)DENSE_RANK()同値は同順位、飛ばさない (1,1,2...)LAG(col)前の行の値を取得 (時系列の差分計算)LEAD(col)次の行の値を取得SUM/AVG/COUNT OVER累積集計NTILE(n)行を n 個のグループに分割 (四分位数等)FIRST_VALUE / LAST_VALUEパーティション内の最初/最後の値CTE (Common Table Expression、共通テーブル式) とは、WITH 句で定義する「名前付きの一時的なサブクエリ」のことです。クエリの先頭で「WITH foo AS (SELECT ...)」と書いておくと、後続のクエリで foo をテーブルのように参照できます。 イメージとしては「SQL 内のローカル変数」「使い捨ての一時 VIEW」のような存在です。
ではなぜこの章を立てるのか。CTE を使うかどうかで、SQL の読みやすさが文字通り桁違いに変わるからです。 経験を積んだ書き手の SQL を見比べた時、最も差が出るのは「ネストを CTE で平らに分解しているか」と言われます。
CTE には2 つの形があります。 ① 非再帰 CTE: 単なる「名前付きサブクエリ」で、最も普通の使い方。可読性向上のためにほぼ全ての中規模以上の SQL で使われます。 ② 再帰 CTE (WITH RECURSIVE): 自分自身を参照する CTE で、木構造の階層展開・グラフ探索・連番生成・組織図のたどり方といった、ループ無しでは書けないはずの処理を SQL 一発で実現します。普段書く機会の少ない構文ですが、覚えると「アプリ側のループで書いていた処理が DB 1 クエリで終わる」ようになります。
性能上の注意: CTE には PG バージョンごとに挙動の罠があります。 PG 11 以前は CTE が「最適化フェンス」となり、プランナーが CTE の内側を外側と組み合わせて最適化できないという制約がありました (CTE が必ず一時的に Materialize されていた)。このため「CTE を使うと遅くなる」と言われた時代があります。 PG 12 以降は1 回しか参照されない & 副作用がない CTE は自動でインライン化されるよう改善され、可読性のための CTE は安心して使えるようになりました。 挙動を明示的に制御したい場合は WITH foo AS MATERIALIZED (...) (必ず一時実体化) または NOT MATERIALIZED (必ずインライン化) を指定します。
-- 基本: 可読性向上 WITH active_users AS ( SELECT id FROM users WHERE last_login > NOW() - INTERVAL '30 days' ), recent_orders AS ( SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '7 days' ) SELECT u.id, COUNT(o.id) AS recent_count FROM active_users u LEFT JOIN recent_orders o ON o.user_id = u.id GROUP BY u.id; -- 再帰 CTE: ツリー/グラフ走査 WITH RECURSIVE descendants AS ( SELECT id, parent_id, 1 AS depth FROM categories WHERE id = 5 UNION ALL SELECT c.id, c.parent_id, d.depth + 1 FROM categories c JOIN descendants d ON c.parent_id = d.id ) SELECT * FROM descendants;
WITH ... AS MATERIALIZED /NOT MATERIALIZED。本章では、PostgreSQL の内部でロジックを実行するための手続き型言語 PL/pgSQL について学んでいきます。
通常の SQL は宣言的な言語で、IF 文や FOR ループといった手続き的な制御構造を持ちません。一方の PL/pgSQL (Procedural Language / PostgreSQL) は、SQL の中に変数宣言・条件分岐・ループ・例外処理を組み込めるよう拡張した言語で、PostgreSQL のデフォルト手続き言語として標準搭載されています。これを使うと、DB の内部に関数 (Function)・ストアドプロシージャ・トリガーを書き、複雑なロジックを SQL から呼び出して実行できます。
PL/pgSQL の最大の判断ポイントは「どこまでロジックを DB 側に寄せるか」です。updated_at の自動更新トリガーのように DB に閉じる処理は寄せるべきですが、ビジネスロジックを片っ端から PL/pgSQL に詰め込むとテストとデプロイの難易度が跳ね上がります。本章では関数の書き方、トリガーで自動処理を実装する典型パターン、そして「アプリ側か DB 側か」を判断する基準を整理します。
CREATE OR REPLACE FUNCTION calculate_discount(
amount NUMERIC,
customer_id INTEGER
) RETURNS NUMERIC AS $$
DECLARE
v_total NUMERIC;
v_discount NUMERIC;
BEGIN
SELECT SUM(amount) INTO v_total
FROM orders WHERE customer_id = $2;
IF v_total > 100000 THEN
v_discount := amount * 0.15; -- VIP は 15% off
ELSIF v_total > 10000 THEN
v_discount := amount * 0.05;
ELSE
v_discount := 0;
END IF;
RETURN v_discount;
END;
$$ LANGUAGE plpgsql;
-- 使う
SELECT calculate_discount(5000, 123);-- updated_at を自動更新するトリガー CREATE OR REPLACE FUNCTION update_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_users_timestamp BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_timestamp(); -- 監査ログ自動記録 CREATE OR REPLACE FUNCTION audit_orders() RETURNS TRIGGER AS $$ BEGIN INSERT INTO orders_audit (order_id, action, changed_at, changed_by) VALUES (NEW.id, TG_OP, NOW(), current_user); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_audit_orders AFTER INSERT OR UPDATE OR DELETE ON orders FOR EACH ROW EXECUTE FUNCTION audit_orders();
PL/Python、PL/Perl、PL/v8 (JavaScript) なども使えます。 ただしセキュリティ (untrusted な言語) や運用負荷を考えると、PL/pgSQL の範囲に留めるのが安全。本章では、PostgreSQL のアクセス制御の基盤であるロールと権限について学んでいきます。
PostgreSQL は、誰がどのテーブルにアクセスできるかをロール (role) という単位で管理します。他の RDBMS では「ユーザー」と「グループ」が別物として扱われることが多いですが、PostgreSQL はすべてを「ロール」として統一しており、ログイン可能なロールがユーザー、ログイン不可能でメンバーを持つロールがグループ という関係になっています。これにより権限管理がシンプルかつ柔軟になります。
本番運用において、ロールと権限の設計はセキュリティの土台です。アプリ用ロールに SUPERUSER を与えていたら、SQL インジェクション 1 回で DB 全体が破壊されます。逆に「必要最小限の権限」の設計ができていれば、被害を局所化できます。本章では、ロールの基本構文、本番の典型的なロール設計テンプレート、GRANT / DEFAULT PRIVILEGES の使い方、SET ROLE と SECURITY DEFINER による権限の動的切り替え、そして権限監査用の棚卸し SQL までを順に解説します。
-- ログインできるユーザーロール CREATE ROLE app_user LOGIN PASSWORD 'xxx'; -- ログイン不可のグループロール CREATE ROLE app_readers; CREATE ROLE app_writers; -- ユーザーをグループに所属 GRANT app_readers TO app_user; -- 権限付与 GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readers; GRANT INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_writers; -- 将来のテーブルにも自動適用 ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO app_readers;
app_ownerapp_readapp_writeapp_adminanalyst_xxxCREATE ROLE / ALTER ROLE で指定する属性が、ロールの権限・能力を決めます。 一覧で押さえておきましょう。
LOGIN / NOLOGINパスワード認証でログインできるか。グループロールは NOLOGIN にするSUPERUSER / NOSUPERUSER全権限。本番では絶対に最小限に。RLS も無視するCREATEDB / NOCREATEDB新しい DB を作る権限。アプリ用ロールには不要CREATEROLE / NOCREATEROLE他のロールを作る権限。チーム管理ロールに付与REPLICATION / NOREPLICATIONStreaming/Logical Replication で接続する権限。専用ロールに分離BYPASSRLS / NOBYPASSRLSRLS を無視できる権限。スーパーユーザー以外で必要なら明示CONNECTION LIMIT nこのロールから同時接続できる上限。スクリプト用に -1 (無制限) を避けるVALID UNTILパスワードの有効期限。臨時アクセス用に必須INHERIT / NOINHERITメンバーに対する権限の自動継承。NOINHERIT なら SET ROLE で明示切替ロールに与えられる権限はオブジェクト種別ごとに違います。よく使うものを整理:
| オブジェクト | 主要権限 |
|---|---|
| TABLE | SELECT / INSERT / UPDATE / DELETE / TRUNCATE / REFERENCES / TRIGGER |
| COLUMN | SELECT (col) / INSERT (col) / UPDATE (col) / REFERENCES (col) |
| SEQUENCE | USAGE / SELECT / UPDATE |
| DATABASE | CREATE / CONNECT / TEMPORARY |
| SCHEMA | CREATE / USAGE |
| FUNCTION | EXECUTE |
| LANGUAGE | USAGE |
| TABLESPACE | CREATE |
| TYPE / DOMAIN | USAGE |
| FOREIGN SERVER | USAGE |
| LARGE OBJECT | SELECT / UPDATE |
| ROLE (グループ) | GRANT 〜 TO で メンバー追加 |
中規模 Web サービスでの典型的なロール構成を SQL レベルで提示します。これをコピペベースで持っておけば本番初期構築が楽になります。
-- ============================================ -- ① グループロール (ログイン不可、権限の入れ物) -- ============================================ CREATE ROLE app_readonly NOLOGIN; CREATE ROLE app_readwrite NOLOGIN; CREATE ROLE app_owner NOLOGIN; -- ============================================ -- ② スキーマ単位の USAGE 付与 -- ============================================ GRANT USAGE ON SCHEMA public TO app_readonly, app_readwrite; -- ============================================ -- ③ 既存テーブルへの権限付与 -- ============================================ -- 読み取り専用 GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readonly; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_readonly; -- 書き込み GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_readwrite; GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO app_readwrite; -- DDL 含む全権限 (マイグレーション用) ALTER SCHEMA public OWNER TO app_owner; -- ============================================ -- ④ 将来作られるテーブルにも自動適用 -- (これを忘れると新テーブル追加のたびに権限が漏れる) -- ============================================ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO app_readonly; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_readwrite; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE ON SEQUENCES TO app_readonly, app_readwrite; -- ============================================ -- ⑤ ログインユーザー (実際の接続主体) -- ============================================ CREATE ROLE app_prod LOGIN PASSWORD 'xxx' -- 本番アプリ CONNECTION LIMIT 100; GRANT app_readwrite TO app_prod; CREATE ROLE bi_user LOGIN PASSWORD 'yyy' -- BI ツール CONNECTION LIMIT 5; GRANT app_readonly TO bi_user; CREATE ROLE migration LOGIN PASSWORD 'zzz' -- マイグレーション専用 CONNECTION LIMIT 2 VALID UNTIL '2026-12-31'; GRANT app_owner TO migration; -- ============================================ -- ⑥ 一時アクセス (個別分析者など) -- ============================================ CREATE ROLE analyst_taro LOGIN PASSWORD 'aaa' VALID UNTIL '2026-09-30' -- 3ヶ月で失効 CONNECTION LIMIT 3; GRANT app_readonly TO analyst_taro;
現場で一番引っかかるのが「新しいテーブルを作ったらアプリから見えない」問題です。GRANT SELECT ON ALL TABLES はすでに存在するテーブルのみに効きます。 将来作るテーブルにも適用するには ALTER DEFAULT PRIVILEGES を使う必要があります。
-- ❌ 失敗パターン GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readonly; -- → この時点のテーブルにだけ適用される CREATE TABLE new_table (...); -- → app_readonly からは「permission denied for table new_table」になる -- ✓ 正しいパターン ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO app_readonly; CREATE TABLE new_table (...); -- → 自動的に SELECT 権限が付く -- 注意: ALTER DEFAULT PRIVILEGES の効果は -- 「これを実行したロールがオブジェクトを作った場合」のみ -- → マイグレーションを app_owner で行うなら、その状態で実行する SET ROLE app_owner; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO app_readonly;
SET ROLE は「セッション中に別のロールに切り替える」機能。SECURITY DEFINER 関数は「関数の所有者の権限で実行される」関数。両方とも権限境界を超える時に使います。
-- ① SET ROLE: セッション内で一時的に別ロールに SET ROLE app_readonly; SELECT * FROM users; -- app_readonly の権限で動く RESET ROLE; -- 元に戻る -- ② SECURITY DEFINER: 所有者権限で動く関数 -- (Postgres の sudo 相当。慎重に使う) CREATE FUNCTION admin_op(...) RETURNS void AS $$ BEGIN -- ここの SQL は関数の所有者の権限で動く -- 呼び出し元が権限を持っていなくても実行できる DELETE FROM audit_log WHERE created_at < now() - INTERVAL '1 year'; END $$ LANGUAGE plpgsql SECURITY DEFINER; -- 慎重に: search_path 攻撃を防ぐため必ず明示 CREATE FUNCTION admin_op(...) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public -- ← 必ず指定 AS $$ ... $$; -- 実行権限のみ付与 GRANT EXECUTE ON FUNCTION admin_op TO app_readwrite;
-- ① 全ロールと属性
SELECT rolname, rolsuper, rolinherit, rolcreaterole, rolcreatedb,
rolcanlogin, rolreplication, rolbypassrls, rolconnlimit, rolvaliduntil
FROM pg_roles
ORDER BY rolname;
-- ② ロールの所属関係 (誰が誰のメンバーか)
SELECT r.rolname AS member, g.rolname AS group_role
FROM pg_auth_members am
JOIN pg_roles r ON r.oid = am.member
JOIN pg_roles g ON g.oid = am.roleid
ORDER BY g.rolname, r.rolname;
-- ③ テーブル権限の一覧
SELECT grantee, table_schema, table_name, privilege_type
FROM information_schema.role_table_grants
WHERE grantee NOT IN ('postgres', 'PUBLIC')
ORDER BY grantee, table_schema, table_name;
-- ④ SUPERUSER の検出 (本番では限定すべき)
SELECT rolname FROM pg_roles WHERE rolsuper = true;
-- ⑤ ログイン可能なロールと最終ログイン
SELECT rolname, rolvaliduntil
FROM pg_roles
WHERE rolcanlogin = true
ORDER BY rolname;
-- ⑥ パスワード期限切れ間近を発見
SELECT rolname, rolvaliduntil
FROM pg_roles
WHERE rolvaliduntil IS NOT NULL
AND rolvaliduntil < now() + INTERVAL '30 days';
-- ⑦ ロールが持つ全権限を 1 ロールずつ確認
SELECT * FROM information_schema.table_privileges
WHERE grantee = 'app_readonly';ロールを作成・変更・削除するためのコマンド群です。ユーザー作成は実体的に CREATE ROLE で行います (CREATE USER は CREATE ROLE LOGIN のエイリアス)。
-- 基本構文 CREATE ROLE name [ [WITH] option [...] ]; ALTER ROLE name [ [WITH] option [...] ]; ALTER ROLE name RENAME TO new_name; ALTER ROLE name SET param TO value; ALTER ROLE name IN DATABASE db SET param TO value; DROP ROLE [IF EXISTS] name [, ...]; REASSIGN OWNED BY old_role TO new_role; DROP OWNED BY role;
LOGIN | NOLOGINSUPERUSER | NOSUPERUSERCREATEDB | NOCREATEDBCREATEROLE | NOCREATEROLEINHERIT | NOINHERITREPLICATION | NOREPLICATIONBYPASSRLS | NOBYPASSRLSPASSWORD 'pwd' | PASSWORD NULLVALID UNTIL 'timestamp'CONNECTION LIMIT NIN ROLE role_nameROLE member_nameADMIN member_name-- ① アプリ用ロール (本番標準)
CREATE ROLE app_user
WITH LOGIN PASSWORD 'strong_password'
CONNECTION LIMIT 100
VALID UNTIL '2027-01-01';
-- ② 読み取り専用ロール (BI ツール用)
CREATE ROLE bi_reader LOGIN PASSWORD 'xxx';
-- ③ グループロール (権限を束ねる)
CREATE ROLE app_team NOLOGIN; -- ログイン不可 = グループ
GRANT app_team TO app_user, bi_reader; -- メンバー追加
-- ④ レプリ用ロール
CREATE ROLE replicator
WITH LOGIN REPLICATION PASSWORD 'xxx';
-- ⑤ ロール属性の変更
ALTER ROLE app_user PASSWORD 'new_password';
ALTER ROLE app_user VALID UNTIL '2027-12-31';
ALTER ROLE app_user CONNECTION LIMIT 200;
ALTER ROLE app_user NOSUPERUSER; -- スーパーユーザー剥奪
-- ⑥ ロール単位のパラメータ設定
ALTER ROLE analyst SET statement_timeout = '30min';
ALTER ROLE analyst SET work_mem = '512MB';
-- ⑦ ロール名変更
ALTER ROLE old_name RENAME TO new_name;
-- ⑧ ロール削除 (持っているオブジェクトの引き継ぎが必要)
REASSIGN OWNED BY retiring_user TO app_team; -- オブジェクト引き継ぎ
DROP OWNED BY retiring_user; -- 個人所有のオブジェクト削除
DROP ROLE retiring_user; -- 最後にロール削除
-- ⑨ ロール一覧
\du -- psql コマンド
SELECT rolname, rolsuper, rolcanlogin, rolvaliduntil
FROM pg_roles ORDER BY rolname;
-- ⑩ メンバーシップ確認
SELECT r.rolname AS member, g.rolname AS in_group
FROM pg_auth_members a
JOIN pg_roles r ON a.member = r.oid
JOIN pg_roles g ON a.roleid = g.oid;オブジェクトへのアクセス権を付与・剥奪するコマンドです。対象 (テーブル / スキーマ / DB / 関数 / 列など) ごとに付与できる権限の種類が違うので、対応表を頭に入れておく必要があります。
-- テーブルへの権限
GRANT { { SELECT | INSERT | UPDATE | DELETE | TRUNCATE | REFERENCES | TRIGGER }
[, ...] | ALL [PRIVILEGES] }
ON { [TABLE] table_name [, ...] | ALL TABLES IN SCHEMA schema [, ...] }
TO role_specification [, ...] [WITH GRANT OPTION];
REVOKE [GRANT OPTION FOR]
{ ... 同じ ... }
FROM role_specification [, ...] [CASCADE | RESTRICT];
-- スキーマへの権限
GRANT { USAGE | CREATE | ALL [PRIVILEGES] } ON SCHEMA ... TO ...;
-- DB への権限
GRANT { CONNECT | CREATE | TEMP | ALL [PRIVILEGES] } ON DATABASE ... TO ...;
-- カラム単位の権限 (機密情報の保護に有用)
GRANT { SELECT | INSERT | UPDATE | REFERENCES } (column [, ...])
ON [TABLE] table TO role;
-- シーケンス・関数・型・FDW・LANGUAGE などにも個別の権限種別ありTABLESELECT / INSERT / UPDATE / DELETE / TRUNCATE / REFERENCES / TRIGGERCOLUMNSELECT / INSERT / UPDATE / REFERENCES (列単位)SEQUENCEUSAGE / SELECT / UPDATEDATABASECONNECT / CREATE (スキーマ作成) / TEMP (一時テーブル作成)SCHEMAUSAGE (アクセス) / CREATE (オブジェクト作成)FUNCTION / PROCEDUREEXECUTETYPE / DOMAINUSAGETABLESPACECREATELANGUAGEUSAGEFOREIGN DATA WRAPPER / SERVERUSAGE-- ① アプリ用ロールへの典型的な付与セット GRANT CONNECT ON DATABASE myapp TO app_user; GRANT USAGE ON SCHEMA public TO app_user; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user; GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO app_user; -- ② 読み取り専用ロール GRANT CONNECT ON DATABASE myapp TO bi_reader; GRANT USAGE ON SCHEMA public TO bi_reader; GRANT SELECT ON ALL TABLES IN SCHEMA public TO bi_reader; -- ③ 特定カラムだけ参照可能に (PII 保護) GRANT SELECT (id, name, email_domain) -- email 本体は隠す ON users TO marketing_team; -- ④ WITH GRANT OPTION (再委譲可能に) GRANT SELECT ON orders TO team_lead WITH GRANT OPTION; -- team_lead が他者に GRANT 可能になる -- ⑤ 剥奪 REVOKE INSERT ON orders FROM old_user; REVOKE ALL ON TABLE orders FROM old_user; REVOKE app_team FROM retiring_user; -- グループから除外 -- ⑥ パブリック (誰でも) からの剥奪 (セキュリティ強化の基本) REVOKE ALL ON DATABASE myapp FROM PUBLIC; REVOKE ALL ON SCHEMA public FROM PUBLIC; -- ⑦ 権限の確認 \dp orders -- psql の権限一覧 SELECT grantee, privilege_type FROM information_schema.role_table_grants WHERE table_name = 'orders'; -- ⑧ 関数の権限 (関数は GRANT EXECUTE) GRANT EXECUTE ON FUNCTION my_func(int) TO app_user; REVOKE EXECUTE ON FUNCTION my_func(int) FROM PUBLIC;
ALTER DEFAULT PRIVILEGES は「これから作成されるオブジェクト」の権限を事前に設定するコマンドです。GRANT ON ALL TABLES IN SCHEMA は今存在するテーブルにしか効かないので、新規テーブル追加時に毎回 GRANT し忘れる事故を防ぐために必須です。
-- 基本構文
ALTER DEFAULT PRIVILEGES
[ FOR { ROLE | USER } target_role [, ...] ]
[ IN SCHEMA schema_name [, ...] ]
abbreviated_grant_or_revoke;
-- abbreviated は GRANT/REVOKE と同じ構文の短縮版-- ① 一番大事な設定: 「これから作る全テーブル」を app_user が読み書きできるように
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
-- ② シーケンスも同様に
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO app_user;
-- ③ 関数も
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT EXECUTE ON FUNCTIONS TO app_user;
-- ④ 特定ユーザーが作るオブジェクトに限定
ALTER DEFAULT PRIVILEGES FOR ROLE migration_user IN SCHEMA public
GRANT SELECT ON TABLES TO bi_reader;
-- migration_user が CREATE TABLE したものは bi_reader が読める
-- ⑤ 設定の確認
\ddp -- psql の defaults 表示
SELECT pg_get_userbyid(defaclrole) AS owner,
defaclnamespace::regnamespace,
defaclobjtype,
defaclacl
FROM pg_default_acl;
-- ⑥ 設定の解除
ALTER DEFAULT PRIVILEGES IN SCHEMA public
REVOKE SELECT ON TABLES FROM old_user;注意点: ① ALTER DEFAULT PRIVILEGES は「現在のユーザー」が作るオブジェクトに対する設定 (FOR ROLE で指定しない限り)、② 設定が複数のユーザーや複数のスキーマで重なって設定されるとデバッグが大変になるので、本番では「全権限管理は app_team グループに集約」のような一元化が推奨。
普段使うことは少ないですが、本番の権限管理で重要な 2 つの仕組みです。
-- ① SET ROLE — セッション内で一時的に別ロールに「なる」
-- 自分が member であるロールにのみ切替可能
SET ROLE app_team; -- app_team として動く
SELECT current_user; -- → app_team
RESET ROLE; -- 元に戻る
-- 用途: 管理者が監査ログを残しつつアプリ権限で動作確認
-- (current_user は変わるが session_user は元のまま)
SELECT session_user, current_user;
-- ② SECURITY DEFINER 関数 — 関数定義時のロール権限で実行
-- 通常の関数 (SECURITY INVOKER = デフォルト) は呼び出し元の権限で動く
-- SECURITY DEFINER 関数は定義者の権限で動く
CREATE FUNCTION add_credit(amount numeric) RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp -- 必ず固定!
AS $$
BEGIN
UPDATE accounts SET balance = balance + amount
WHERE user_id = current_setting('app.user_id')::int;
END;
$$;
-- 関数の所有者 (例: admin) の権限で実行されるので、
-- accounts テーブルへの UPDATE 権限を呼び出し元に与えなくても動く
GRANT EXECUTE ON FUNCTION add_credit(numeric) TO app_user;
-- セキュリティ要件:
-- ① search_path を必ず固定 (関数内で参照するテーブル名が乗っ取られないように)
-- ② 引数を厳密にバリデート (SQL injection)
-- ③ 本当に必要な処理だけに使う
-- ④ 関数オーナーはアプリ用最小権限ではなく、必要最小の管理者ロールpostgres (スーパーユーザー) で運用する。 SQL インジェクションが起きたら DB ごと壊れる。 ② GRANT ALL ON ALL TABLES を脳死で打つ。最小権限の原則を守る。 ③ DEFAULT PRIVILEGES なしで新テーブル追加し、「権限ない」エラーで本番停止。pg_roles / pg_auth_members /information_schema.role_table_grants を上の棚卸し SQL でチェック。 cron で月次レポートを生成すると、退職者の削除漏れ等を機械的に発見できます。本章では、PostgreSQL のセキュリティ機能のなかでも特に強力な Row Level Security (RLS、行レベルセキュリティ) について学んでいきます。
RLS は「同じテーブルを SELECT しても、見る人 (ロール) によって返ってくる行が変わる」という機能です。たとえばマルチテナント SaaS の orders テーブルに対し、テナント A のロールでアクセスすればテナント A の注文だけ、テナント B のロールでアクセスすればテナント B の注文だけが見える、というふうに、テーブルへのアクセスにポリシーを定義しておけるのです。アプリ側で WHERE tenant_id = ? を書き忘れても、データの漏洩を DB レイヤで物理的に防げます。
マルチテナント SaaS、ユーザー個人のプライバシー保護、組織階層に応じた権限分離、医療や金融のように行単位の機密性が要求される業務 — こうした要件すべてに対する強力なセーフティネットになります。アプリのバグや SQL の書き間違いが命取りになる領域で、最後の砦として機能してくれます。本章では RLS の基本構文、マルチテナント SaaS での実装例、ポリシーの種類 (SELECT / INSERT / UPDATE / DELETE 別)、そして運用上の注意点を順に整理します。
-- 例: ユーザーは自分の注文のみ見える
CREATE TABLE orders (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL,
amount numeric,
...
);
-- RLS を有効化
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- ポリシー: 自分の user_id の行のみ
CREATE POLICY user_orders_policy ON orders
USING (user_id = current_setting('app.current_user_id')::bigint);
-- アプリ側でセッション変数を設定
SET app.current_user_id = 123;
-- このユーザーは自分の注文しか見えない
SELECT * FROM orders; -- WHERE user_id = 123 が暗黙適用される-- テナント分離
CREATE TABLE invoices (
id bigserial,
tenant_id bigint NOT NULL,
amount numeric,
...
);
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::bigint);
-- INSERT 時のポリシー (誤った tenant_id 書き込み防止)
CREATE POLICY tenant_insert ON invoices
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);
-- アプリの接続時にテナント ID を設定
-- (例: PgBouncer + ミドルウェアで自動セット)
SET app.tenant_id = 5;USING (...) SELECT / UPDATE / DELETE で「どの行が見える/触れるか」WITH CHECK (...)INSERT / UPDATE で「どの値で挿入できるか」FOR SELECT読み取り専用ポリシーFOR INSERT挿入専用ポリシーFOR UPDATE更新専用ポリシーFOR DELETE削除専用ポリシーFOR ALL全アクション (デフォルト)BYPASSRLS 属性を持つロールは RLS を無視する、 ② インデックスがあっても、RLS の条件式が複雑だと使われないことがある、 ③ パフォーマンスインパクトがあるので、本番では EXPLAIN で確認必須。本章では、PostgreSQL の通信を保護する SSL / TLS と、クライアントの正当性を検証する認証方式について学んでいきます。
DB との通信は、何もしなければパスワードを含めて全部が平文でネットワークを流れる状態になります。これは社内ネットワークだから安全、という時代ではすでになく、内部不正・侵入を許した踏み台からの傍受・VPN 越しのスニッフィング・クラウド環境内のサイドチャネル攻撃など、現代の脅威モデルでは社内も外部もリスクは同程度と考えるべきです。本番 PostgreSQL では SSL/TLS による通信暗号化が必須、というのが現代の事実上の前提です。
加えて、認証 (パスワード) の保存方式も重要です。古い md5 認証は既に脆弱とされ、現代の本番では scram-sha-256 が業界標準です。本章では SSL/TLS の有効化手順、PostgreSQL の主要な認証方式 (scram-sha-256 / cert / peer / ident など) の使い分け、そして pg_hba.conf での認証ルールの優先順位までを整理します。
# postgresql.conf ssl = on ssl_cert_file = '/etc/ssl/certs/server.crt' ssl_key_file = '/etc/ssl/private/server.key' # pg_hba.conf — SSL 必須化 # TYPE DATABASE USER ADDRESS METHOD hostssl all all 0.0.0.0/0 scram-sha-256 hostnossl all all 0.0.0.0/0 reject # 非 SSL 拒否 # 接続側で確認 psql "host=db.example.com sslmode=require dbname=mydb user=xxx"
trustパスワード不要 (危険、開発のみ)× 本番禁止md5MD5 パスワードハッシュ× 弱い、非推奨scram-sha-256SCRAM-SHA-256 (PG 10+)◎ 標準certクライアント証明書認証◯ 機械間通信に強いpeerOS ユーザー名と一致◯ ローカル接続のみgss / sspiKerberos / SSPI◯ AD 統合環境ldapLDAP サーバ問合せ◯ エンタープライズpg_hba.conf は上から順に評価され、最初にマッチしたルールが適用されます。 順序を間違えると、強い認証ルールが弱いルールに上書きされる事故が起きます。
# ◯ 正しい例: 限定的→広範な順 hostssl mydb app_user 10.0.0.0/8 scram-sha-256 hostssl mydb readonly 10.0.1.0/24 scram-sha-256 hostssl all all 0.0.0.0/0 reject # × 悪い例: 順序逆 → 上の trust が全部マッチする host all postgres 127.0.0.1/32 trust hostssl all all 0.0.0.0/0 scram-sha-256
sslmode=require を付けるか、 Strict なら sslmode=verify-full + ルート CA 指定で MITM 対策も完璧。本章では、ここまで学んできた知識の総ざらいとして、本番運用で実際に起きる典型的なミス 15 件をカテゴリ別にまとめます。
本記事の各章では「こうするのが正しい」を解説してきましたが、現場で本当に起きている事故は、教科書的な知識を知っていれば防げたはずのものがほとんどです。VACUUM の放置・max_connections の過大設定・ALTER TABLE による全停止・接続切断時のリーク・効かないインデックスの放置 — どれも珍しい事故ではなく、PostgreSQL を運用している現場であれば多くのチームが一度は踏んでいるパターンです。
本章はそれらをチェックリストとして並べたものです。各項目について「何が起きるか」「どう直すか・予防するか」をセットで示しているので、自分の運用環境に当てはまっていないかを順に確認してください。1 件でも該当していれば、それを潰すだけで深刻な障害を未然に防げる可能性があります。
autovacuum_naptime や autovacuum_vacuum_cost_limit でペースを調整し、特定テーブルで頻度を上げたければ ALTER TABLE ... SET (autovacuum_vacuum_scale_factor = 0.05) のようにテーブル単位で調整する。緊急時の手動 VACUUM はあくまで補助。-- ❌ 絶対 NG autovacuum = off -- ✓ 全体の頻度を上げる autovacuum_naptime = 30s autovacuum_vacuum_cost_limit = 2000 -- ✓ 個別テーブルで強化 ALTER TABLE orders SET ( autovacuum_vacuum_scale_factor = 0.05, autovacuum_analyze_scale_factor = 0.02 );
BEGIN したまま COMMIT も ROLLBACK もせず長時間 (数分〜数時間) 接続を保持したままになるパターン。よくある原因は、トランザクション開始後に外部 API 呼び出しをしている / バッチ処理の中で手動操作待ちが入っている / コネクションプールが壊れて接続が掴まれっぱなしになっている、など。pg_stat_activity で state = 'idle in transaction' のものが目印です。statement_timeout (1 クエリの上限) と idle_in_transaction_session_timeout (BEGIN 後の放置上限) を両方設定しておく。検出には pg_stat_activity でアラート、長時間 idle は強制 pg_terminate_backend() で切る運用も併用する。-- postgresql.conf statement_timeout = '5min' idle_in_transaction_session_timeout = '10min' -- 監視 SQL (放置トランザクション検出) SELECT pid, state, now() - xact_start AS duration, query FROM pg_stat_activity WHERE state = 'idle in transaction' AND xact_start < now() - interval '5 minutes';
VACUUM FULL を実行してしまうパターン。VACUUM FULL は通常の VACUUM と名前は似ていますが、内部動作はまったくの別物で、テーブル全体を新しいファイルに書き直すためテーブル全体に AccessExclusiveLock がかかります。pg_repack 拡張を使う。pg_repack は新しいテーブルを裏で並行構築し、最後の一瞬だけ短いロックを取って切り替えるため、ほぼ無停止で実行できる。どうしても VACUUM FULL が必要なら、計画停止メンテで実施し Standby にフェイルオーバーしてから打つ。-- ❌ 本番で気軽に打たない
VACUUM FULL orders; -- 数時間サービス停止のリスク
-- ✓ pg_repack を使う (無停止再構築)
CREATE EXTENSION pg_repack;
pg_repack -d mydb -t orders
-- bloat 確認 (pgstattuple 拡張)
SELECT * FROM pgstattuple('orders');pg_stat_user_indexes で idx_scan = 0 の未使用 index を洗い出し、本番で使われていないものは削除する。本番投入前に「このクエリのために必要」とレビューで合意を取れた index のみ追加する、というルール化が有効。-- 使われていない index を一覧 SELECT schemaname, tablename, indexname, idx_scan FROM pg_stat_user_indexes WHERE idx_scan = 0 AND indexrelname NOT LIKE '%_pkey' ORDER BY pg_relation_size(indexrelid) DESC; -- 1 ヶ月以上使われていない index は削除候補 -- (実行前に pg_stat_statements_reset() を打ち、十分な観測期間を確保) DROP INDEX CONCURRENTLY idx_old_unused;
EXPLAIN ANALYZE も取らずに「index を足しましょうか」「メモリ増やしますか」と対策を打つパターン。本人は親切で報告しているつもりでも、何が遅いか分からない状態でいじっても直る確率は低く、逆効果になることすらあります。EXPLAIN (ANALYZE, BUFFERS)。特に「Planner の推定行数 vs 実測行数」が桁違いにずれていれば統計情報が古いサインなので ANALYZE。Seq Scan が出ていれば index を疑い、Buffers の shared read が多ければキャッシュ不足を疑う、という順序で原因を絞り込む。-- 必ずこれを先に取る EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE user_id = 123 AND status = 'pending'; -- 読むポイント -- ① 推定 (rows=...) vs 実測 (actual rows=...) のズレ -- ② Seq Scan が出ていないか -- ③ Buffers: shared read が多い (キャッシュミス) -- ④ もっとも時間がかかっている子ノード
WHERE lower(email) = ?、WHERE DATE(created_at) = ?、WHERE user_id::text = ? など。型変換も内部的には関数呼び出しなので同じく無効化されます。email = lower(?) のように引数側を加工)、② 関数インデックス (式インデックス) を張る (例: CREATE INDEX ON users (lower(email)))、③ 保存時に正規化しておく (DB に入れる前に lower 済みの値で書く)、のいずれか。アプリ側の修正が難しければ ② を選ぶ。-- ❌ index が効かない
SELECT * FROM users WHERE lower(email) = 'foo@bar.com';
SELECT * FROM orders WHERE DATE(created_at) = '2026-01-01';
-- ✓ クエリ側を直す
SELECT * FROM users WHERE email = lower('foo@bar.com');
SELECT * FROM orders
WHERE created_at >= '2026-01-01'
AND created_at < '2026-01-02';
-- ✓ 関数インデックスを張る
CREATE INDEX idx_users_lower_email ON users (lower(email));postgresql.conf は 1990 年代の貧弱なマシンでも起動できるように極めて控えめな値になっています。shared_buffers = 128MB、work_mem = 4MB、effective_cache_size = 4GB など、現代の本番サーバ (RAM 16GB〜数百 GB) では実態と桁が違います。にもかかわらず「とりあえず動いている」とそのまま本番投入してしまうパターン。shared_buffers = RAM × 25%、effective_cache_size = RAM × 50〜75%、work_mem = 64MB〜256MB から始め、運用しながら pg_stat_* で実測してチューニングする。-- 16GB RAM のサーバなら最低限こう設定 shared_buffers = 4GB -- RAM の 25% effective_cache_size = 12GB -- RAM の 75% work_mem = 64MB -- 集計クエリ次第で 128〜256MB maintenance_work_mem = 1GB -- VACUUM/CREATE INDEX 用 wal_buffers = 16MB max_wal_size = 4GB checkpoint_completion_target = 0.9
-- ❌ こうしない max_connections = 2000 -- ✓ こうする max_connections = 200 -- 直接接続は 100〜200 で十分 shared_buffers = 4GB -- ✓ アプリは PgBouncer 経由で繋ぐ -- pgbouncer.ini: -- pool_mode = transaction -- default_pool_size = 50 -- max_client_conn = 2000
同時セッション数 × 1 クエリ内のソート数 × work_mem。たとえば 100 接続 × 3 ソート × 1GB = 300GB という、物理 RAM を遥かに超える要求になります。ピーク時に瞬間的にメモリが足りなくなり、Linux の OOM Killer が PostgreSQL プロセスを強制終了 → DB ごとクラッシュという事故が現実に起きます。SET LOCAL work_mem = '256MB' でトランザクション内だけ一時的に拡大する。あるいは特定ロール・特定 DB に対して ALTER ROLE analyst SET work_mem = '512MB' のように分析専用接続だけ拡大する。-- ❌ こうしない work_mem = 1GB -- 全セッションで掛け算 → OOM -- ✓ デフォルトは控えめに work_mem = 64MB -- ✓ 必要時だけ拡大 (トランザクション単位で確実に戻る) BEGIN; SET LOCAL work_mem = '256MB'; -- 重い集計クエリ COMMIT; -- ✓ 分析専用ロールだけ拡大 ALTER ROLE analyst SET work_mem = '512MB';
# 復元訓練の典型フロー # 1. 別環境に最新バックアップを復元 pg_restore -h staging -d restored_db backup.dump # 2. 主要テーブルの件数チェック psql -h staging -d restored_db -c \ "SELECT count(*) FROM users; SELECT count(*) FROM orders;" # 3. 所要時間を記録 # 4. 古い手順書を更新 # 5. Slack / Wiki でチームに共有
pg_replication_slots ビューを監視。active = false のまま日数が経っているスロットは削除候補。Subscriber を撤去する時は必ず Primary 側の pg_drop_replication_slot() も実行する手順を運用に組み込む。max_slot_wal_keep_size (PG 13+) でスロットあたりの WAL 保持上限を設定するのも保険として有効。-- スロット監視
SELECT slot_name, active, restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;
-- 不要スロットの削除
SELECT pg_drop_replication_slot('old_subscriber');
-- PG 13+: 保持上限で保険
max_slot_wal_keep_size = 64GBpg_basebackup を使う (WAL 整合性まで含めた一貫したバックアップ)、または PostgreSQL 標準の pg_backup_start() / pg_backup_stop() で「バックアップモード」に入れてからディスクスナップショットを取る。クラウドのマネージド DB (RDS など) であれば、付属のスナップショット機能は内部で整合性を担保しているので利用 OK。-- ✓ 物理バックアップの正攻法
pg_basebackup -h primary -D /backup/$(date +%Y%m%d) \
-Ft -z -P -X stream
-- ✓ ディスクスナップショットを取りたい場合
SELECT pg_backup_start('snapshot_label');
-- ↑ この間に EBS スナップショット
SELECT pg_backup_stop();
-- ❌ NG: 稼働中に何もせずスナップショット
# aws ec2 create-snapshot --volume-id ... ← 不整合の可能性postgres ロールで設定してしまうパターン。「動くから良いか」と本番でもそのままになっている、というのが典型的な経緯。検証環境で動くのと、本番でセキュリティ的に許されるのは別の話です。DROP TABLE による全データ破壊、COPY ... FROM PROGRAM による任意コマンド実行 (OS への侵入)、ロール作成・パスワード変更、すべて可能です。ニュースになるレベルの情報漏洩・データ破壊事故の原因として頻繁に登場します。-- ✓ 専用ロールを作る CREATE ROLE app_user LOGIN PASSWORD 'xxx'; GRANT CONNECT ON DATABASE myapp TO app_user; 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; -- ❌ 絶対にこうしない GRANT ALL ON ALL TABLES IN SCHEMA public TO app_user; ALTER USER app_user WITH SUPERUSER;
pg_hba.conf はクライアントの認証方式を定義する設定ファイルで、trust という認証方式を選ぶと「パスワード一切なしで誰でも接続できる」状態になります。開発環境のサンプル設定や、初期化スクリプトの名残でこれが本番に紛れ込んだまま運用されているケースが少なくありません。trust をすべて廃止する。本番では scram-sha-256 を基本とし、Unix ソケット越しのローカル管理接続だけ peer 認証にする。さらに SSL/TLS を強制 (hostssl) して通信を暗号化し、CIDR で接続元 IP も絞る。# ❌ 危険: 認証なしで誰でも接続可能 host all all 0.0.0.0/0 trust host all all 192.168.0.0/16 trust # ✓ 本番の最低限ルール # OS ローカルの postgres 管理 local all postgres peer # アプリからの接続: 暗号化必須+パスワード hostssl myapp app_user 10.0.0.0/8 scram-sha-256 # レプリケーション: 専用ロール hostssl replication replicator 10.0.1.5/32 scram-sha-256 # 編集後に必ず reload SELECT pg_reload_conf();
pg_upgrade でメジャーバージョンを上げた直後の DB は、統計情報 (pg_statistic) が引き継がれず空っぽになっている状態です。これに気付かず本番運用を再開すると、Planner は「テーブル件数も分布も分からない」状態で実行計画を立てるため、誤判断を連発します。vacuumdb --all --analyze-in-stages を実行する。これは「重要なテーブルから順に粗い精度→精度を上げて 3 段階で ANALYZE する」コマンドで、最も短時間で統計情報を最低限の精度まで戻せます。pg_upgrade 後のチェックリストに「ANALYZE 実行」を必ず明記しておく。# ✓ pg_upgrade 後の必須手順 pg_upgrade -d /old -D /new -b /old/bin -B /new/bin # 統計情報を段階的に再構築 (公式推奨) vacuumdb --all --analyze-in-stages # 並列で速く vacuumdb --all --analyze-in-stages -j 4 # 個別 DB / 特定テーブルだけ先に集中復旧したい場合 ANALYZE VERBOSE users; ANALYZE VERBOSE orders;
pg_lint や類似ツールでマイグレーションを検証、 ② 監視で「デッドタプル比率」「レプリ遅延」「未使用 index」を可視化、 ③ 復元訓練を四半期固定で実施。「人間の注意力に頼らない仕組み」が中長期で最も安いセキュリティ投資です。ここまで全 46 章にわたって扱ってきた内容を、グループ別に振り返ります。
本記事では中級者向けに本番運用で頻出する範囲を扱いました。さらに先に進みたい人向けに、扱わなかった領域を挙げておきます。
テーブル設計・MVCC・WAL・インデックス・実行計画・チューニング・パーティショニング・レプリケーション・バックアップ・セキュリティまで、PostgreSQL の中身を本気で理解するための全要素はプレミアムプランで読めます。
プレミアムを見る