この記事は、PDO + PostgreSQL で最小 CRUD は作ったものの、同時実行でも在庫の整合を保てる更新処理まではまだ整理できていない人を対象にしています。
題材は在庫 1 件の商品です。PHP(PDO)から PostgreSQL を更新する処理を例に、ロックなしだと二重引当が起きる様子を先に再現し、そのあと SELECT ... FOR UPDATE で同じ処理を守るところまで進めます。
先に読んでおくと入りやすい記事:
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop(WSL 連携有効)
以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。
サービス名は app と db に固定しています。
1. ゴールと非対象
到達する状態:
- ロックなしの在庫引当が二重実行で壊れることを再現できる
SELECT ... FOR UPDATEをBEGIN/COMMITと組み合わせて、安全な在庫引当に直せるROLLBACKしたときに何が戻り、後続リクエストに何が見えるかを確認できる
扱わない内容:
NOWAIT/SKIP LOCKEDSERIALIZABLEやREPEATABLE READの深掘り- Laravel / Symfony / Doctrine の書き方
- デッドロックの再現
今回の主題は、FOR UPDATE の構文そのものではありません。
在庫判定をどの順番で並べるか、そのために BEGIN / SELECT ... FOR UPDATE / UPDATE / COMMIT をどう組み立てるかにあります。
2. 最小デモ環境を作成する
記事用の最小デモを作ります。
mkdir -p ~/projects/postgresql-select-for-update-inventory-demo
cd ~/projects/postgresql-select-for-update-inventory-demo
mkdir -p bin docker/php docker/db
code .
compose.yml を作成します。
services:
app:
build:
context: .
dockerfile: docker/php/Dockerfile
working_dir: /workspace
volumes:
- ./:/workspace
env_file:
- .env
command: ["sleep", "infinity"]
depends_on:
db:
condition: service_healthy
db:
image: postgres:17
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
- ./docker/db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 20
volumes:
db-data:
docker/php/Dockerfile を作成します。
FROM php:8.5-cli
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq-dev \
&& docker-php-ext-install pdo_pgsql \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace
.env.example を作成します。
POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=app
APP_DB_HOST=db
APP_DB_PORT=5432
APP_DB_NAME=app
APP_DB_USER=app
APP_DB_PASS=app
APP_PRODUCT_SKU=BOOK-001
docker/db/init.sql を作成します。
CREATE TABLE IF NOT EXISTS products (
id BIGSERIAL PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
stock INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS inventory_reservations (
id BIGSERIAL PRIMARY KEY,
order_ref TEXT NOT NULL,
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (order_ref, product_id)
);
INSERT INTO products (sku, name, stock)
VALUES ('BOOK-001', 'PostgreSQL入門本', 1)
ON CONFLICT (sku) DO NOTHING;
ここでは products.stock に CHECK (stock >= 0) を入れていません。
二重引当が起きたときに、在庫が -1 になる様子を目で確認したいからです。実務では制約も併用してください。
コードのポイント
CREATE TABLE IF NOT EXISTS inventory_reservations (
id BIGSERIAL PRIMARY KEY,
order_ref TEXT NOT NULL,
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (order_ref, product_id)
);
- 5行目
CHECK (quantity > 0)— 予約数量ゼロ・マイナスを DB レベルで排除する。アプリケーション側のバリデーション漏れを補う安全網になる。 - 7行目
UNIQUE (order_ref, product_id)— 同じ注文番号と商品の組み合わせでの二重登録を DB レベルで防ぐ。FOR UPDATEによるロック制御とは独立した、二重引当への別軸の守りになる。
起動します。
cp .env.example .env
docker compose up -d --build
docker compose exec db pg_isready -U app -d app
docker compose exec db psql -U app -d app -c "\dt"
products と inventory_reservations の 2 テーブルが見えれば準備完了です。
詰まったとき:
docker compose logs db
docker compose down -v
docker compose up -d --build
3. 初期状態を戻す補助スクリプトを用意する
ここからは、何度でも同じ検証を繰り返せるように、接続共通化と状態リセットを先に整えます。
最初に置くのは bin/bootstrap.php です。
<?php
declare(strict_types=1);
function createPdo(): PDO
{
$dsn = sprintf(
'pgsql:host=%s;port=%s;dbname=%s',
getenv('APP_DB_HOST') ?: 'db',
getenv('APP_DB_PORT') ?: '5432',
getenv('APP_DB_NAME') ?: 'app'
);
return new PDO(
$dsn,
getenv('APP_DB_USER') ?: 'app',
getenv('APP_DB_PASS') ?: 'app',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
}
function productSku(): string
{
$sku = getenv('APP_PRODUCT_SKU') ?: 'BOOK-001';
if ($sku === '') {
throw new RuntimeException('APP_PRODUCT_SKU is empty.');
}
return $sku;
}
bin/reset-demo.php を作成します。
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$pdo = createPdo();
$sku = productSku();
$pdo->beginTransaction();
try {
$truncate = $pdo->exec('TRUNCATE TABLE inventory_reservations RESTART IDENTITY');
if ($truncate === false) {
throw new RuntimeException('Failed to truncate inventory_reservations.');
}
$update = $pdo->prepare('UPDATE products SET stock = 1 WHERE sku = :sku');
$update->execute([':sku' => $sku]);
if ($update->rowCount() !== 1) {
throw new RuntimeException(sprintf('Product not found: %s', $sku));
}
$pdo->commit();
echo "demo reset completed" . PHP_EOL;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
fwrite(STDERR, $e->getMessage() . PHP_EOL);
exit(1);
}
bin/show-state.php を作成します。
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$pdo = createPdo();
$sku = productSku();
$productStmt = $pdo->prepare(
'SELECT id, sku, name, stock FROM products WHERE sku = :sku'
);
$productStmt->execute([':sku' => $sku]);
$product = $productStmt->fetch();
if (!is_array($product)) {
fwrite(STDERR, sprintf('Product not found: %s', $sku) . PHP_EOL);
exit(1);
}
$reservationStmt = $pdo->prepare(
'SELECT order_ref, quantity, created_at
FROM inventory_reservations
WHERE product_id = :product_id
ORDER BY id ASC'
);
$reservationStmt->execute([':product_id' => $product['id']]);
$reservations = $reservationStmt->fetchAll();
echo json_encode(
[
'product' => [
'sku' => $product['sku'],
'name' => $product['name'],
'stock' => (int) $product['stock'],
],
'reservations' => array_map(
static fn (array $row): array => [
'order_ref' => (string) $row['order_ref'],
'quantity' => (int) $row['quantity'],
'created_at' => (string) $row['created_at'],
],
$reservations
),
],
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE
) . PHP_EOL;
実行します。
docker compose exec app php bin/reset-demo.php
docker compose exec app php bin/show-state.php
初期状態は次のようになります。
{
"product": {
"sku": "BOOK-001",
"name": "PostgreSQL入門本",
"stock": 1
},
"reservations": []
}
途中で状態が分からなくなったら、まず reset-demo.php で初期状態に戻してからやり直します。
4. ロックなしだと二重引当が起きることを再現する
ここで、あえて SELECT ... FOR UPDATE を入れない版を作ります。
先に競合の順番だけ図で見ると、2 本とも同じ在庫 1 を読んだまま進んでいることが分かります。問題は UPDATE の瞬間だけではなく、更新前の在庫判定が古くなることです。
sequenceDiagram
participant A as Terminal A
participant DB as PostgreSQL
participant B as Terminal B
A->>DB: BEGIN
A->>DB: SELECT stock without lock
DB-->>A: stock = 1
Note over A: sleep 5
B->>DB: BEGIN
B->>DB: SELECT stock without lock
DB-->>B: stock = 1
B->>DB: UPDATE products stock - 1
B->>DB: INSERT reservation ORDER-B
B->>DB: COMMIT
A->>DB: UPDATE products stock - 1
A->>DB: INSERT reservation ORDER-A
A->>DB: COMMIT
このあと reserve-without-lock.php で、同じ流れをそのまま手元で再現します。
bin/reserve-without-lock.php を作成します。
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$orderRef = $argv[1] ?? '';
$quantity = isset($argv[2]) ? (int) $argv[2] : 1;
$sleepSeconds = isset($argv[3]) ? (int) $argv[3] : 0;
if ($orderRef === '' || $quantity < 1 || $sleepSeconds < 0) {
fwrite(
STDERR,
"Usage: php bin/reserve-without-lock.php ORDER_REF QUANTITY [SLEEP_SECONDS]" . PHP_EOL
);
exit(1);
}
$pdo = createPdo();
$sku = productSku();
try {
$pdo->beginTransaction();
$select = $pdo->prepare(
'SELECT id, stock FROM products WHERE sku = :sku'
);
$select->execute([':sku' => $sku]);
$product = $select->fetch();
if (!is_array($product)) {
throw new RuntimeException(sprintf('Product not found: %s', $sku));
}
$productId = (int) $product['id'];
$stock = (int) $product['stock'];
printf("[%s] stock before update: %d" . PHP_EOL, $orderRef, $stock);
if ($stock < $quantity) {
printf("[%s] insufficient stock, rollback" . PHP_EOL, $orderRef);
$pdo->rollBack();
exit(1);
}
if ($sleepSeconds > 0) {
printf("[%s] sleeping %d second(s) before update" . PHP_EOL, $orderRef, $sleepSeconds);
sleep($sleepSeconds);
}
$update = $pdo->prepare(
'UPDATE products SET stock = stock - :quantity WHERE id = :id'
);
$update->bindValue(':quantity', $quantity, PDO::PARAM_INT);
$update->bindValue(':id', $productId, PDO::PARAM_INT);
$update->execute();
$insert = $pdo->prepare(
'INSERT INTO inventory_reservations (order_ref, product_id, quantity)
VALUES (:order_ref, :product_id, :quantity)'
);
$insert->bindValue(':order_ref', $orderRef, PDO::PARAM_STR);
$insert->bindValue(':product_id', $productId, PDO::PARAM_INT);
$insert->bindValue(':quantity', $quantity, PDO::PARAM_INT);
$insert->execute();
$pdo->commit();
printf("[%s] committed" . PHP_EOL, $orderRef);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
fwrite(STDERR, $e->getMessage() . PHP_EOL);
exit(1);
}
一度リセットしてから、2 つのターミナルを開いて実行します。
docker compose exec app php bin/reset-demo.php
ターミナルA:
docker compose exec app php bin/reserve-without-lock.php ORDER-A 1 5
ターミナルB:
docker compose exec app php bin/reserve-without-lock.php ORDER-B 1 0
1 本目は sleep 5 で 5 秒待つので、その間にターミナルBで 2 本目を実行します。
ORDER-A が stock before update: 1 を表示して止まっている間に ORDER-B を実行すると、割り込みを再現しやすくなります。
両方が stock before update: 1 を表示したら、すでに危ない状態です。2 本とも「在庫 1 を見た」まま進んでいるからです。
実行後の状態を確認します。
docker compose exec app php bin/show-state.php
期待する結果:
{
"product": {
"sku": "BOOK-001",
"name": "PostgreSQL入門本",
"stock": -1
},
"reservations": [
{
"order_ref": "ORDER-B",
"quantity": 1,
"created_at": "..."
},
{
"order_ref": "ORDER-A",
"quantity": 1,
"created_at": "..."
}
]
}
在庫は 1 件しかなかったのに、予約が 2 行入っています。
UPDATE 自体は行ロックを取りますが、判定に使った在庫値が古いままなので、二重引当を防げません。
うまく再現できないとき:
- 2 本目の実行が遅すぎると、1 本目が先に終わって在庫不足にならない
duplicate key value violates unique constraintが出たら、ORDER-A/ORDER-Bのようにorder_refを変える- 状態が崩れたら
docker compose exec app php bin/reset-demo.phpに戻る
コードのポイント
$select = $pdo->prepare(
'SELECT id, stock FROM products WHERE sku = :sku'
);
$select->execute([':sku' => $sku]);
$product = $select->fetch();
$productId = (int) $product['id'];
$stock = (int) $product['stock'];
if ($stock < $quantity) {
printf("[%s] insufficient stock, rollback" . PHP_EOL, $orderRef);
$pdo->rollBack();
exit(1);
}
- 2行目
SELECT id, stock ... WHERE sku = :sku—FOR UPDATEがないため、他トランザクションが同じ行を同時に読み進めることをブロックしない。ターミナルA・B が両方とも在庫1を見たまま判定に進めてしまう。 - 10行目
if ($stock < $quantity)—sleepを挟んだ後に評価されるため、チェック時点での在庫値はすでに別トランザクションによって書き換えられている可能性がある。ロックなし在庫引当が二重になる根本原因はここにある。
5. SELECT ... FOR UPDATE で在庫引当を守る
次は修正版です。違いは SELECT に FOR UPDATE を付けることと、その SELECT を明示トランザクションの中に置くことです。
bin/reserve-with-lock.php を作成します。
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$orderRef = $argv[1] ?? '';
$quantity = isset($argv[2]) ? (int) $argv[2] : 1;
$sleepSeconds = isset($argv[3]) ? (int) $argv[3] : 0;
$mode = $argv[4] ?? 'commit';
if ($orderRef === '' || $quantity < 1 || $sleepSeconds < 0) {
fwrite(
STDERR,
"Usage: php bin/reserve-with-lock.php ORDER_REF QUANTITY [SLEEP_SECONDS] [commit|rollback]" . PHP_EOL
);
exit(1);
}
if (!in_array($mode, ['commit', 'rollback'], true)) {
fwrite(STDERR, "Mode must be commit or rollback" . PHP_EOL);
exit(1);
}
$pdo = createPdo();
$sku = productSku();
try {
$pdo->beginTransaction();
$select = $pdo->prepare(
'SELECT id, stock FROM products WHERE sku = :sku FOR UPDATE'
);
$select->execute([':sku' => $sku]);
$product = $select->fetch();
if (!is_array($product)) {
throw new RuntimeException(sprintf('Product not found: %s', $sku));
}
$productId = (int) $product['id'];
$stock = (int) $product['stock'];
printf("[%s] stock after lock: %d" . PHP_EOL, $orderRef, $stock);
if ($stock < $quantity) {
printf("[%s] insufficient stock, rollback" . PHP_EOL, $orderRef);
$pdo->rollBack();
exit(1);
}
if ($sleepSeconds > 0) {
printf("[%s] sleeping %d second(s) while holding lock" . PHP_EOL, $orderRef, $sleepSeconds);
sleep($sleepSeconds);
}
$update = $pdo->prepare(
'UPDATE products SET stock = stock - :quantity WHERE id = :id'
);
$update->bindValue(':quantity', $quantity, PDO::PARAM_INT);
$update->bindValue(':id', $productId, PDO::PARAM_INT);
$update->execute();
$insert = $pdo->prepare(
'INSERT INTO inventory_reservations (order_ref, product_id, quantity)
VALUES (:order_ref, :product_id, :quantity)'
);
$insert->bindValue(':order_ref', $orderRef, PDO::PARAM_STR);
$insert->bindValue(':product_id', $productId, PDO::PARAM_INT);
$insert->bindValue(':quantity', $quantity, PDO::PARAM_INT);
$insert->execute();
if ($mode === 'rollback') {
printf("[%s] rollback requested" . PHP_EOL, $orderRef);
$pdo->rollBack();
exit(0);
}
$pdo->commit();
printf("[%s] committed" . PHP_EOL, $orderRef);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
fwrite(STDERR, $e->getMessage() . PHP_EOL);
exit(1);
}
もう一度リセットします。
docker compose exec app php bin/reset-demo.php
ターミナルA:
docker compose exec app php bin/reserve-with-lock.php ORDER-A 1 5
ターミナルB:
docker compose exec app php bin/reserve-with-lock.php ORDER-B 1 0
1 本目は sleep 5 で 5 秒待つので、その間にターミナルBで 2 本目を実行します。
ここでは 2 本目がすぐに進まず、1 本目の COMMIT か ROLLBACK まで待ちます。
待っている間、ターミナルBが静かに見えても異常ではありません。SELECT ... FOR UPDATE が同じ行のロック解放を待っている状態です。
実行後の状態を確認します。
docker compose exec app php bin/show-state.php
期待する結果:
{
"product": {
"sku": "BOOK-001",
"name": "PostgreSQL入門本",
"stock": 0
},
"reservations": [
{
"order_ref": "ORDER-A",
"quantity": 1,
"created_at": "..."
}
]
}
このとき 2 本目は、待機後に stock after lock: 0 を見て insufficient stock, rollback へ進みます。
止めたいのは 2 本目の UPDATE そのものではなく、「古い在庫で判定したまま進むこと」です。FOR UPDATE はその順番を作ります。
コードのポイント
$pdo->beginTransaction();
$select = $pdo->prepare(
'SELECT id, stock FROM products WHERE sku = :sku FOR UPDATE'
);
$select->execute([':sku' => $sku]);
$product = $select->fetch();
$productId = (int) $product['id'];
$stock = (int) $product['stock'];
printf("[%s] stock after lock: %d" . PHP_EOL, $orderRef, $stock);
if ($stock < $quantity) {
printf("[%s] insufficient stock, rollback" . PHP_EOL, $orderRef);
$pdo->rollBack();
exit(1);
}
- 1行目
$pdo->beginTransaction()—FOR UPDATEはトランザクション内でのみロックを保持する。beginTransaction()がなければSELECT終了と同時にロックが解放され、意味をなさない。 - 4行目
FOR UPDATE— 該当行に排他ロックを取る。このロックはCOMMITかROLLBACKまで保持されるため、後続のSELECT ... FOR UPDATEは先行トランザクションの終了を待つ。 - 14行目
if ($stock < $quantity)— ロック取得後に読んだ在庫を使って判定するため、他トランザクションがすでに在庫を減らしていた場合、ここで在庫不足と判断してrollBack()へ分岐できる。
6. COMMIT と ROLLBACK の見え方を確認する
FOR UPDATE は、後続を必ず失敗させる仕組みではありません。
先行トランザクションの終わり方に応じて、後続側が見る在庫が変わります。
それを rollback モードで確認します。
docker compose exec app php bin/reset-demo.php
ターミナルA:
docker compose exec app php bin/reserve-with-lock.php ORDER-A 1 5 rollback
ターミナルB:
docker compose exec app php bin/reserve-with-lock.php ORDER-B 1 0
1 本目は sleep 5 で 5 秒待つので、その間にターミナルBで 2 本目を実行します。
ORDER-A がロックを取って待っている間に ORDER-B を実行すると、ROLLBACK 後に後続側がどう進むかを確認しやすくなります。
そのあと 1 本目は在庫を減らして予約行を入れた時点で、自分で ROLLBACK します。
すると 1 本目の変更は消え、行ロックも解放されます。待っていた 2 本目は、そのあと在庫 1 を見て成功します。
実行後の状態:
docker compose exec app php bin/show-state.php
期待する結果:
{
"product": {
"sku": "BOOK-001",
"name": "PostgreSQL入門本",
"stock": 0
},
"reservations": [
{
"order_ref": "ORDER-B",
"quantity": 1,
"created_at": "..."
}
]
}
残るのは ORDER-B だけです。ORDER-A の在庫減算と予約 INSERT は、ROLLBACK で両方とも戻っています。
この挙動を押さえておくと、「在庫不足なら戻す」「途中で例外が出たら戻す」を落ち着いて書けます。
7. PHP実装へ戻すときの勘所と詰まりどころ
このデモを実務コードへ戻すときに、最低限外したくない点は 4 つです。
FOR UPDATEはbeginTransaction()とセットで使う
SELECT ... FOR UPDATE だけ実行しても、明示トランザクションがなければステートメント終了時にロックが解放されます。
在庫判定と更新を同じトランザクションへ閉じるところまでが 1 セットです。
- ロックする行は必要最小限へ絞る
今回は sku = :sku の 1 行だけを対象にしました。
在庫引当のたびに広い範囲をロックすると、待機が増えて別の詰まり方を呼びます。
- 重複予約の抑止は別軸でも持っておく
デモでは UNIQUE (order_ref, product_id) を入れています。
同じ注文番号で二重送信されたときに、アプリケーション側だけへ責任を寄せないためです。
- 制約や監査ログは後から足す
実務では stock >= 0 の制約、監査ログ、タイムアウト、再試行も検討対象です。
それでも最初に固めるべきは、beginTransaction() の中で SELECT ... FOR UPDATE -> 判定 -> UPDATE の順を崩さないことです。
詰まりやすい点と対処は次の表のとおりです。
| 症状 | 主原因 | まず見る場所 | 戻る章 |
|---|---|---|---|
| 2 本目のターミナルが止まったままに見える | FOR UPDATE がロック解放待ちをしている | 1 本目が COMMIT / ROLLBACK まで到達しているか | 5章 |
FOR UPDATE を入れたのに効果が見えない | 明示トランザクションに入っていない | beginTransaction() と commit() / rollBack() の有無 | 5章 |
duplicate key value violates unique constraint | 同じ order_ref を再利用した | コマンドの引数 | 4章 |
could not find driver | pdo_pgsql が入っていない | `docker compose exec app php -m | grep pgsql` |
init.sql を変えたのに反映されない | 既存ボリュームが残っている | docker compose down -v を実行したか | 2章 |
切り分けに使う最小コマンド:
docker compose exec app php -m | grep pgsql
docker compose exec app php bin/reset-demo.php
docker compose exec app php bin/show-state.php
docker compose logs db
docker compose down -v
8. まとめと次の一歩
本記事では、次の 3 点を確認しました。
- ロックなしの在庫引当は、在庫 1 件でも二重引当を起こしうる
SELECT ... FOR UPDATEは、在庫判定の前に順番を作るために使うROLLBACKした変更は残らず、待っていた後続側はそのあとに正しい在庫を見て進める
在庫数を読んでから更新する処理で、同じ行へ複数リクエストが入るなら、BEGIN の中で SELECT ... FOR UPDATE を先に取るところから始めるのが安全です。
次の一歩として、「後続を待たせる FOR UPDATE」の次に「待たせずに別の仕事へ進める」パターンへ広げるなら、PHP + PostgreSQLでジョブキューを作る(FOR UPDATE SKIP LOCKED 最小構成) がそのまま続きです。
テスト側から固めたいなら、このデモを PHPUnit の DB テストへ落とし込む流れも候補です。
今回の最小構成を自分の手で 1 回再現して、2 本目がどこで待ち、どのタイミングで進むかを体感しておくと、その後の設計判断がぶれにくくなります。