調査の動機
「同時アクセスが増えたとき、このDBは持つのか」を聞かれて困ったので、手元で測れる最小構成を作りました。
使うのは PostgreSQL 標準付属の pgbench と Docker Compose だけです。ホストに psql も pgbench も入れません。コピペで動く状態にしてあるので、上から順に流せば15分程度で一通り終わります。
測るのは3つです。
- 同時クライアント数を1から200まで上げてどこで頭打ちになるか
-
max_connectionsを超えると何が起きるか - 書き込みの形(INSERT / バラバラなUPDATE / 同じ1行へのUPDATE)で性能がどれだけ変わるか
差が一番大きかったのは3つめで、同じ50クライアントでもTPSが27.9倍違いました。
この記事は手順だけに絞っています。pg_stat_activity や pg_locks を使って「なぜ遅いのか」を掘る話は省いています。
環境
| 項目 | 内容 |
|---|---|
| 必要なもの | Docker と Docker Compose だけ(動作確認: Docker 29.7.2 / Compose v5.5.0) |
| DB | PostgreSQL 17.11(公式イメージ) |
| 測定に使ったマシン | Windows 11 + Docker Desktop、16コア |
| 所要時間 | 約15分(うち pgbench の実行待ちが5分ほど) |
シェルは Bash でも PowerShell でも、この記事のコマンドはそのまま動きます。環境変数を渡す2か所だけ書き方が変わるので、そこは PowerShell 版も併記しました。
構成
負荷をかける側もコンテナにします。ホストに PostgreSQL クライアントを入れずに済むうえ、サーバーとクライアントのバージョンが必ず揃います。
打つコマンドの形はほぼ1種類だけです。
docker compose exec client pgbench <オプション>
接続先・ユーザー・パスワードは compose.yaml の環境変数に入れてあるので、-h -U -d を毎回書かなくて済みます。
ファイルを用意する
作業用ディレクトリに次の4ファイルを置きます。
.
├── compose.yaml
└── sql
├── insert.sql
├── update_diff_row.sql
└── update_same_row.sql
ファイル一式は GitHub に置いてあります。写すのが面倒なら clone してください。
compose.yaml
触るのは max_connections と公開ポートだけです。他は postgres 公式イメージのデフォルトのまま使います。
name: pg-concurrency
services:
# 検証対象。永続ボリュームは持たせない(down でデータごと消える)
postgres-test:
image: postgres:17
container_name: postgres-test
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: benchmark
command:
- postgres
- -c
- max_connections=${POSTGRES_MAX_CONNECTIONS:-100}
ports:
- "${POSTGRES_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d benchmark"]
interval: 1s
timeout: 5s
retries: 30
# 負荷生成に使うクライアント。pgbench と psql はこの中のものを使う
client:
image: postgres:17
container_name: bench-client
depends_on:
postgres-test:
condition: service_healthy
environment:
PGHOST: postgres-test
PGPORT: 5432
PGUSER: postgres
PGPASSWORD: password
PGDATABASE: benchmark
volumes:
- ./sql:/work/sql:ro
# 相対パスで sql/xxx.sql と書けるようにする
# (絶対パスにすると Windows の Git Bash がパスを変換してしまう)
working_dir: /work
command: ["sleep", "infinity"]
パスワードを password にしているのは使い捨ての検証環境だからです。ポート 5432 をホストに公開している点も含めて、この設定のまま共有ネットワークや本番に置かないでください。
sql/insert.sql
-- 同一テーブルへの INSERT
\set user_id random(1, 10000)
\set amount random(100, 100000)
INSERT INTO orders (user_id, amount) VALUES (:user_id, :amount);
sql/update_diff_row.sql
-- 別レコードへの UPDATE(行ロックが分散する)
\set pid random(2, 1000)
UPDATE inventory SET stock = stock - 1 WHERE product_id = :pid;
sql/update_same_row.sql
-- 同一レコードへの UPDATE(行ロックが1行に集中する)
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1;
\set 変数名 random(下限, 上限) は pgbench 自身のメタコマンドで、pgbench 側が値を決めてから SQL に埋め込みます。SQL の中に直接 random(1, 10000) と書く手もありますが、整数範囲を取る random(min, max) は PostgreSQL 17 で追加されたものなので、16以前のサーバーではエラーになります。\set ならサーバーのバージョンを問いません。
起動と初期データ投入
docker compose up -d
[+] Running 3/3
✔ Network pg-concurrency_default Created
✔ Container postgres-test Healthy
✔ Container bench-client Started
client は postgres-test のヘルスチェックが通ってから起動するので、起動直後に接続して失敗することがありません。
続けて pgbench 標準のテーブル一式(TPC-B風の4テーブル)を作ります。
docker compose exec client pgbench -i -s 10
dropping old tables...
NOTICE: table "pgbench_accounts" does not exist, skipping
creating tables...
generating data (client-side)...
1000000 of 1000000 tuples (100%) of pgbench_accounts done (elapsed 0.11 s, remaining 0.00 s)
vacuuming...
creating primary keys...
done in 1.07 s (drop tables 0.00 s, create tables 0.01 s, client-side generate 0.76 s, vacuum 0.11 s, primary keys 0.19 s).
初回は does not exist, skipping の NOTICE が4件出ますが、正常です。
-s はスケール係数で、pgbench_accounts の行数を10万行単位で指定します。-s 10 なら100万行、約160MBです。デフォルトの shared_buffers が128MBなので、このくらいならほぼメモリに載ります。
| 指定 | 行数 | おおよそのサイズ |
|---|---|---|
-s 1 |
100,000 | 約16MB |
-s 10 |
1,000,000 | 約160MB |
-s 100 |
10,000,000 | 約1.6GB |
ポート 5432 がすでに使われているとき
別の PostgreSQL が動いていると docker compose up -d が失敗します。公開ポートだけ変えれば回避できます。
POSTGRES_PORT=55432 docker compose up -d
PowerShell では起動前に環境変数を設定します。
$env:POSTGRES_PORT = "55432"
docker compose up -d
client は Docker ネットワーク内で postgres-test:5432 に直接つなぐので、公開ポートを変えても以降のコマンドは1つも変わりません。
1クライアントで基準値を取る
いきなり200で回しても「遅い」ことしか分かりません。まず同時実行なしの値を取ります。
docker compose exec client pgbench -c 1 -j 1 -T 20
pgbench (17.11 (Debian 17.11-1.pgdg13+2))
starting vacuum...end.
transaction type: <builtin: TPC-B (sort of)>
scaling factor: 10
query mode: simple
number of clients: 1
number of threads: 1
maximum number of tries: 1
duration: 20 s
number of transactions actually processed: 11060
number of failed transactions: 0 (0.000%)
latency average = 1.808 ms
initial connection time = 5.471 ms
tps = 553.111756 (without initial connection time)
この環境の基準値は 553.1 TPS / 1.808 ms でした。
出力はここだけ見る
| 項目 | 意味 |
|---|---|
number of failed transactions |
失敗した数。0以外ならその測定は捨てる |
tps |
1秒あたりに完了したトランザクション数。高いほどよい |
latency average |
1トランザクションの平均応答時間。低いほどよい |
initial connection time |
全クライアントが接続し終えるまでの時間 |
見る順番は、まず failed transactions が0か確認して、それから tps と latency average を記録します。
-c と -j の違い
混同しやすいので分けておきます。
| オプション | 対象 | 意味 |
|---|---|---|
-c |
DB側 | 同時クライアント数。そのまま PostgreSQL への同時接続数になる |
-j |
負荷生成側 | pgbench 自身が使う OS スレッド数。DB の負荷とは無関係 |
-T |
実行時間 | 秒数を指定 |
-j が少なすぎると DB ではなく pgbench 自身がボトルネックになり、DB の性能を過小評価してしまいます。この記事では次の対応で進めます。
-c |
-j |
|---|---|
| 1 | 1 |
| 10 〜 25 | 4 |
| 50以上 | 8 |
デフォルトで何が流れているか
-f を指定しないとき、pgbench は TPC-B 風(銀行口座の入出金を模した定番のベンチマーク)の組み込みシナリオを実行します。中身は UPDATE 3回 + SELECT 1回 + INSERT 1回を1トランザクションにまとめたもので、-f を付けなくてもこれ自体が書き込みテストになっています。
同時接続数を1から200まで上げる
-c を変えながら順に流すだけです。
docker compose exec client pgbench -c 1 -j 1 -T 20
docker compose exec client pgbench -c 10 -j 4 -T 20
docker compose exec client pgbench -c 25 -j 4 -T 20
docker compose exec client pgbench -c 50 -j 8 -T 20
docker compose exec client pgbench -c 100 -j 8 -T 20
5条件で合計100秒ほどかかります。
150クライアントで壁にぶつかる
docker compose exec client pgbench -c 150 -j 8 -T 10
pgbench: error: connection to server at "postgres-test" (192.168.0.2), port 5432 failed: FATAL: sorry, too many clients already
pgbench: error: could not create connection for client 107
測定が始まらずエラーで終わります。postgres 公式イメージのデフォルトは max_connections = 100 です。
100クライアントが通ったのは、pgbench が postgres(スーパーユーザー)で接続していて superuser_reserved_connections = 3 の予約枠を使えているからです。一般ユーザーなら97接続あたりで失敗します。
could not create connection for client 107 の107は接続を作りかけた順番であって、上限値ではありません。マルチスレッドで並行に接続を張るので、何番目で落ちるかは実行ごとに変わります。
max_connections を上げる
起動時にしか反映されない設定なので、コンテナを作り直します。
docker compose down
POSTGRES_MAX_CONNECTIONS=300 docker compose up -d
PowerShell ではこうなります。
docker compose down
$env:POSTGRES_MAX_CONNECTIONS = "300"
docker compose up -d
反映を確認します。
docker compose exec client psql -P footer=off -c "SHOW max_connections;"
max_connections
-----------------
300
-P footer=off は行数フッターを消すオプションです。Windows のコンソールだと (1 行) が文字化けするので付けています。
永続ボリュームを持たせていないのでデータも消えています。初期データを入れ直してから200を流します。
docker compose exec client pgbench -i -s 10
docker compose exec client pgbench -c 200 -j 8 -T 20
max_connections を上げれば解決、という話ではありません。PostgreSQL は接続ごとにプロセスを1つ作るので、接続数に比例してメモリとコンテキストスイッチのコストが増えます。実システムでは上限を上げるより、アプリ側に接続プールを置いて DB への接続数を抑えるほうが有効なことが多いです。ここでは「200まで測る」のが目的なので引き上げています。
接続数スイープの結果
-s 10、組み込みシナリオ、-T 20、max_connections = 300 での実測値です。
| 同時数 | -j |
TPS | 平均 Latency (ms) | 失敗 | 初期接続 (ms) |
|---|---|---|---|---|---|
| 1 | 1 | 566.6 | 1.765 | 0 | 5.9 |
| 10 | 4 | 2749.4 | 3.637 | 0 | 20.8 |
| 25 | 4 | 3567.4 | 7.008 | 0 | 50.1 |
| 50 | 8 | 3767.6 | 13.271 | 0 | 69.9 |
| 100 | 8 | 3942.7 | 25.363 | 0 | 123.3 |
| 200 | 8 | 3888.3 | 51.437 | 0 | 235.6 |
TPS が伸びるのは25クライアントあたりまでです。1から10へ増やしたときは4.9倍になりますが、25から200へ8倍に増やしても9%しか動きません。
一方でレイテンシは最後まで素直に伸び続け、200クライアントでは51.4msと1クライアント時の29倍です。接続数を増やしても処理量は増えず、待ち時間だけが延びる領域に入っています。
200クライアントのTPSが100を下回っていますが、差は1.4%なので測定ノイズと見るのが妥当です。先ほど単独で測った1クライアントの 553.1 TPS も、この表の 566.6 TPS とは2%ほどずれています。-T 20 は短いので、1点だけを見て「200まで増やすと性能が落ちる」と判断しないほうが安全です。気になるなら同じ条件で3回測って中央値を取ります。
initial connection time が接続数にほぼ比例して伸びている点も見ておきたいところです。10接続より上では1接続あたり1.2msほどで、1リクエストごとに接続を張り直す設計だと処理内容と関係なく毎回これが乗ります。
数値はこの1台で測ったものなので、絶対値はマシンによって変わります。頭打ちになるクライアント数と、そこから先でレイテンシだけが伸びる形のほうを見てください。負荷生成側も同じホストのコンテナで動いている点も、絶対値を割り引いて見る理由になります。
書き込みパターンを変えて測る
ここからが本題です。同じ50クライアントでも、更新対象が散らばっているか1行に集中しているかで結果が変わります。
| パターン | 実システムでの例 |
|---|---|
| A 別レコードUPDATE | ユーザーごとのプロフィール更新、注文ステータス更新 |
| B 同一テーブルINSERT | ログ記録、イベント登録、注文登録 |
| C 同一レコードUPDATE | 在庫引き当て、カウンタ更新、席の確保 |
テーブルを作る
docker compose exec client psql -c "
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
amount INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
DROP TABLE IF EXISTS inventory;
CREATE TABLE inventory (
product_id INTEGER PRIMARY KEY,
stock INTEGER NOT NULL
);
INSERT INTO inventory SELECT g, 100000000 FROM generate_series(1, 1000) AS g;
"
inventory の product_id = 1 が全クライアントで奪い合う1行、2..1000 がパターンA用です。在庫を1億にしてあるのは、測定中に stock が尽きて負にならないようにするためです。
3パターンを流す
パターンごとの絶対値だけ見ても「Cが遅い」で終わってしまうので、クライアント数も10 / 50 / 100と変えて、増やしたときの伸び方を見ます。-j は前述の対応表どおりです。
# 10クライアント
docker compose exec client pgbench -c 10 -j 4 -T 20 -f sql/update_diff_row.sql
docker compose exec client pgbench -c 10 -j 4 -T 20 -f sql/insert.sql
docker compose exec client pgbench -c 10 -j 4 -T 20 -f sql/update_same_row.sql
# 50クライアント
docker compose exec client pgbench -c 50 -j 8 -T 20 -f sql/update_diff_row.sql
docker compose exec client pgbench -c 50 -j 8 -T 20 -f sql/insert.sql
docker compose exec client pgbench -c 50 -j 8 -T 20 -f sql/update_same_row.sql
# 100クライアント
docker compose exec client pgbench -c 100 -j 8 -T 20 -f sql/update_diff_row.sql
docker compose exec client pgbench -c 100 -j 8 -T 20 -f sql/insert.sql
docker compose exec client pgbench -c 100 -j 8 -T 20 -f sql/update_same_row.sql
9本で合計3分ほどです。-f で独自スクリプトを流すときは pgbench が自動 VACUUM をしてくれないので、条件を揃えたいなら水準を変えるたびに前述の CREATE TABLE を流し直してください。理由は後述の「繰り返し測るときの注意」に書いています。
パターン別の結果
TPS(高いほどよい)
| パターン | 10 | 50 | 100 | 10→100の伸び |
|---|---|---|---|---|
| A 別レコードUPDATE | 4,461.0 | 21,924.4 | 34,334.3 | 7.7倍 |
| B 同一テーブルINSERT | 3,910.7 | 23,450.8 | 36,154.8 | 9.2倍 |
| C 同一レコードUPDATE | 884.9 | 785.4 | 921.0 | 1.0倍 |
平均レイテンシ ms(低いほどよい)
| パターン | 10 | 50 | 100 | 10→100の悪化 |
|---|---|---|---|---|
| A 別レコードUPDATE | 2.242 | 2.281 | 2.913 | 1.3倍 |
| B 同一テーブルINSERT | 2.557 | 2.132 | 2.766 | 1.1倍 |
| C 同一レコードUPDATE | 11.301 | 63.665 | 108.579 | 9.6倍 |
総評
50クライアントで比べると、AとCの差はTPSで27.9倍です。同じDB、同じテーブル、同じUPDATE文で、違うのは WHERE 句が変数か固定値かだけです。
AとBはクライアントを10倍にするとTPSが7.7〜9.2倍に伸びます。このときレイテンシは1.1〜1.3倍にしかならず、ほぼ横ばいのまま処理量だけが増えています。100クライアントでもまだ頭打ちが見えません。
Cは10でも100でもTPSが800〜900のまま動かず、レイテンシだけが9.6倍になります。同じ1行を更新する処理は同時に1つしか進めないので、クライアントを増やしても待ち行列が長くなるだけです。
失敗トランザクションは全パターンで0です。ロック競合が起きても PostgreSQL はエラーを返さず、待たせて順番に処理します。アプリのログには何も残らず、利用者からは「なんとなく遅い」としか見えません。だからこそ測って数字で押さえる必要があります。
Cのパターンで処理量を増やす方法は、並列度を上げることではありません。1トランザクションを短くする、更新対象を複数行に分割する、集計を非同期にする、といった設計側の変更になります。サーバーを増強しても効きません。
繰り返し測るときの注意
pgbench が実行前に自動で VACUUM するのは組み込みシナリオのときだけです。-f で独自スクリプトを使う場合は対象外なので、繰り返すとデッドタプル(UPDATE や DELETE で不要になった古い行)が溜まってテーブルが膨らみ、あとの測定ほど不利になります。
docker compose exec client psql -P footer=off -c \
"SELECT pg_size_pretty(pg_total_relation_size('orders')) AS orders_size,
pg_size_pretty(pg_total_relation_size('inventory')) AS inventory_size;"
orders_size | inventory_size
-------------+----------------
111 MB | 464 kB
inventory は1,000行しかないのに464kB もあります。条件を揃えたいなら、測定のたびに前述の CREATE TABLE を流し直してテーブルを作り直すのが手っ取り早いです。
片付け
docker compose down
永続ボリュームを定義していないので、DBのデータもコンテナと一緒に消えます。測定値をターミナルにしか出していない場合は、消す前に控えておいてください。
この構成で分からないこと
DB単体しか測っていないので、次のあたりは別途確認が必要になります。
| 項目 | この記事の条件 | 本番と差が出る理由 |
|---|---|---|
| データ量 |
-s 10 で約160MB |
shared_buffers にほぼ収まる。本番データが載らなければディスクI/Oが支配的になる |
| SQL | pgbench の単純な1文 | JOIN や集計が入るとプラン次第で桁が変わる |
| 測定時間 | 20秒 | チェックポイントや autovacuum は時間が経たないと現れない |
| 負荷生成 | DBと同じホスト上のコンテナ | 負荷生成側とDBが同じCPUを奪い合っている |
本番相当に近づけるなら、優先度の高い順に「データ量を本番相当にする」「-f に実際のトランザクションを渡す」「負荷生成を別ホストに置く」「-T 300 以上に延ばす」の4つになります。
利用者から見た応答時間まで評価したいなら、アプリケーションサーバーを含めた負荷試験(k6、Locust、JMeter など)が別に必要です。接続プールが枯渇したときの待ちは、DBのどのビューにも現れません。