ProcArrayLock と LWLock の内部メカニズム
PostgreSQL を運用していると、ある接続数を境に「CPU は余っているのに全クエリが一律に遅延する」「軽量なクエリすら数百ミリ秒かかる」といった、原因の掴みにくい現象に遭遇することがあります。
特定のクエリや行へのロック競合ではない。それなのに DB が確実に詰まっている。その背景に潜むことがあるのが、PostgreSQL の 内部共有メモリ構造への並行アクセス競合 です。
本ドキュメントでは、その代表格である ProcArrayLock を主軸に、関連する LWLock(Lightweight Lock)群と接続数スケーラビリティの仕組みを解説します。
同様の症状は CPU 飽和・ディスク I/O 律速・行ロック競合などからも発生しうるため、原因の切り分けには観測が不可欠です。本ドキュメントは「この種のボトルネックがある場合に何を観測してどう判断するか」に焦点を当てます。診断フローは 5. 症状の見抜き方 と 9. まとめ を参照してください。
目次
- PostgreSQL のプロセスモデル
- ProcArray と MVCC スナップショット
- ProcArrayLock の役割
- 接続数が増えると遅くなる理由
- 症状の見抜き方
- PostgreSQL 14 以降の改善
- 関連する LWLock 群
- 対策
- まとめ
- 参考リソース
1. PostgreSQL のプロセスモデル
1.1 接続 = 1 プロセス
PostgreSQL は プロセスベースの並行モデル を採用しています。postmaster と呼ばれる親プロセスがクライアント接続を受け付け、接続ごとに新しい backend process を fork() します。
┌─────────────────────────────────── ───────────────────────┐
│ PostgreSQL Server Process Tree │
│ │
│ postmaster (親) │
│ ├── checkpointer │
│ ├── background writer │
│ ├── walwriter │
│ ├── autovacuum launcher │
│ ├── stats collector │
│ │ │
│ ├── backend process 1 ← クライアント接続 #1 │
│ ├── backend process 2 ← クライアント接続 #2 │
│ ├── backend process 3 ← クライアント接続 #3 │
│ │ ... │
│ └── backend process N ← クライアント接続 #N │
└──────────────────────────────────────────────────────────┘
各 backend は 独立した OS プロセス で、独自のメモリ空間を持ちます。
1.2 マルチスレッドモデルとの違い
| 観点 | PostgreSQL(プロセス) | MySQL/MariaDB(スレッド) |
|---|---|---|
| 1 接続あたりのオーバーヘッド | 大(fork コスト、独立メモリ) | 小(スレッド作成のみ) |
| プロセス間通信 | 共有メモリ経由 | スレッド間で直接参照 |
| クラッシュ時の影響 | 1 プロセスのみ | 全体に影響しうる |
| 高接続数のスケーラビリティ | 接続数増加でオーバーヘッド大 | 比較的良い |
PostgreSQL のプロセスモデルは 隔離性と堅牢性 に優れる一方、接続数が増えるとプロセス間の調整コストが増大 します。
1.3 接続あたりのコスト
1 つの backend process は最低でも数 MB のメモリを使用し、扱うデータ量に応じて拡大します(ソート用 work_mem、テンポラリテーブル、キャッシュなど)。
加えて、後述の 共有メモリ構造への調整コスト が「目に見えにくいオーバーヘッド」として効いてきます。
2. ProcArray と MVCC スナップショット
2.1 ProcArray とは
ProcArray は、現在存在する全 backend process の状態を保持する 共有メモリ上の配列 です。各 エントリには以下のような情報が含まれます:
- 実行中のトランザクション ID(XID)
- サブトランザクション情報
- vacuum 関連フラグ
- 接続が見ている最古の XID(xmin)
すべての backend がこの配列を 読み書きする ため、並行アクセスの調整が必要になります。
2.2 MVCC スナップショットとは
PostgreSQL の MVCC(Multi-Version Concurrency Control)は、トランザクション分離レベルを実現するために スナップショット を使います。
┌─────────────────────────────────────────────────────────┐
│ MVCC スナップショットの仕組み │
│ │
│ トランザクション開始時、または各 SQL 文の実行時に │
│ 「現時点で見えるべき他トランザクションの状態」を │
│ スナップショットとして固定 │
│ │
│ スナップショット = { │
│ xmin: 最小の active XID, │
│ xmax: 次に割り当てられる XID, │
│ active_xids: [現在実行中の XID リスト] │
│ } │
└─────────────────────────────────────────────────────────┘
このスナップショットを構築する関数が GetSnapshotData() で、トランザクションの開始時や各文の実行時など、極めて頻繁に呼ばれます。
2.3 GetSnapshotData の計算量
GetSnapshotData() の本質は 「現在 active な backend を全部スキャンする」 処理です。
全 backend を順に走査:
for backend in ProcArray:
if backend has active XID:
active_xids.append(xid)
track min(xmin)
→ 計算量は O(N)(N = 接続数)
つまり、接続数が増えるほどスナップショット生成が遅くなる。これが PostgreSQL の接続スケーラビリティの根本的な制約となっています。
3. ProcArrayLock の役割
3.1 ProcArrayLock とは
ProcArrayLock は、ProcArray という共有データ構造への並行アクセスを保護する LWLock(Lightweight Lock)です。
LWLock は、ヒープテーブル行などに対するアプリケーションレベルのロックとは別の、PostgreSQL 内部の共有メモリ構造を保護する低レベル同期プリミティブ です。
3.2 取得モード
LWLock には 2 つのモードがあります:
| モード | 用途 | 同時保持 |
|---|---|---|
| SHARED | 読み取り(スナップショット取得など) | 複数 backend が同時保持可能 |
| EXCLUSIVE | 書き込み(トランザクション開始/終了) | 1 backend のみ |
3.3 ProcArrayLock を取得するタイミング
主なケース:
| 操作 | モード | 説明 |
|---|---|---|
GetSnapshotData() | SHARED | スナップショット取得時、ProcArray を読む |
ProcArrayAdd() | EXCLUSIVE | 新規 backend の追加時 |
ProcArrayRemove() | EXCLUSIVE | backend 終了時 |
ProcArrayEndTransaction() | EXCLUSIVE | トランザクション終了時の状態更新 |
→ トランザクション境界ごとに頻繁に EXCLUSIVE が取られる ため、SHARED 取得が頻繁にブロックされる構造になります。
4. 接続数が増えると遅くなる理由
4.1 二つの作用が複合する
ProcArrayLock が接続数増加でボトルネックになる理由は、2 つの作用の複合 です:
作用 1: スナップショット計算自体が O(N)
接続数が増えると GetSnapshotData() の 1 回あたりの処理時間 が伸びる。
作用 2: ProcArrayLock 自体の競合
多くの backend が同時に snapshot を取得しようとして ロック取得待ち が発生する。EXCLUSIVE(トランザクション終了など)が割り込めば SHARED 待ち全体がブロックされる。
4.2 ピンポン現象(cache line ping-pong)
複数の CPU コアが頻繁に同じメモリ領域を更新すると、CPU キャッシュラインの 「所有権の取り合い」 が発生します。
時刻 t1: CPU#1 が backend A の xmin を更新(キャッシュラインを排他取得)
時刻 t2: CPU#2 が backend B の xmin を更新(CPU#1 からキャッシュライン強奪)
時刻 t3: CPU#3 が backend C の xmin を更新(CPU#2 から強奪)
...
時刻 tx: スナップショット計算のため全 backend の状態を読みたい backend が
キャッシュミスを連発しメモリから読み直し → 遅い
接続数 × CPU コア数の積で キャッシュ効率が急速に悪化 することが知られています。
4.3 アイドル接続でも遅延を引き起こす
注意すべきは、アイドル接続(何も処理していない接続)でも、ProcArray にエントリは存在する 点です。
GetSnapshotData() は全 backend を走査するため、アイドル接続が大量にあるだけで アクティブな接続の処理速度が低下 します。
コミュニティのベンチマークによると、active 接続 1 つに対してアイドル接続が増えるだけで、その active 接続の CPU 時間の大半が
GetSnapshotData()に費やされる現象が観測されています。
これは 「使ってない接続なら無害だろう」という直感を裏切る挙動 です。
5. 症状の見抜き方
5.1 観察される表面的な症状
| 症状 | 通常時 | ProcArrayLock 競合時 |
|---|---|---|
SELECT set_config(...) 等の軽量クエリ | 1ms 未満 | 数十〜数百 ms |
| 全クエリの平均レイテンシ | ベースライン | 一律に数倍〜十数倍 |
| DB CPU 使用率 | クエリ量に比例 | クエリ量に対して不釣り合いに高い |
| トランザクション数 | 線形に増加 | 頭打ち |