公開日 2026-05-16

PHP + PostgreSQLで在庫引当を安全に実装する(SELECT ... FOR UPDATE 最小構成)

PHP(PDO)から在庫引当の二重処理を再現し、`SELECT ... FOR UPDATE` で防ぐ流れと PostgreSQL の待ち方を最小構成で確認する。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. 最小デモ環境を作成する
  4. コードのポイント
  5. 3. 初期状態を戻す補助スクリプトを用意する
  6. 4. ロックなしだと二重引当が起きることを再現する
  7. コードのポイント
  8. 5. SELECT ... FOR UPDATE で在庫引当を守る
  9. コードのポイント
  10. 6. COMMIT と ROLLBACK の見え方を確認する
  11. 7. PHP実装へ戻すときの勘所と詰まりどころ
  12. 8. まとめと次の一歩

この記事は、PDO + PostgreSQL で最小 CRUD は作ったものの、同時実行でも在庫の整合を保てる更新処理まではまだ整理できていない人を対象にしています。 題材は在庫 1 件の商品です。PHP(PDO)から PostgreSQL を更新する処理を例に、ロックなしだと二重引当が起きる様子を先に再現し、そのあと SELECT ... FOR UPDATE で同じ処理を守るところまで進めます。

先に読んでおくと入りやすい記事:

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL 連携有効)

以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。
サービス名は appdb に固定しています。

1. ゴールと非対象

到達する状態:

  • ロックなしの在庫引当が二重実行で壊れることを再現できる
  • SELECT ... FOR UPDATEBEGIN / COMMIT と組み合わせて、安全な在庫引当に直せる
  • ROLLBACK したときに何が戻り、後続リクエストに何が見えるかを確認できる

扱わない内容:

  • NOWAIT / SKIP LOCKED
  • SERIALIZABLEREPEATABLE 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.stockCHECK (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"

productsinventory_reservations の 2 テーブルが見えれば準備完了です。

products と inventory_reservations のテーブル一覧

詰まったとき:

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-Astock 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 自体は行ロックを取りますが、判定に使った在庫値が古いままなので、二重引当を防げません。

ロックなしで二重引当になった show-state の結果

うまく再現できないとき:

  • 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 = :skuFOR UPDATE がないため、他トランザクションが同じ行を同時に読み進めることをブロックしない。ターミナルA・B が両方とも在庫 1 を見たまま判定に進めてしまう。
  • 10行目 if ($stock < $quantity)sleep を挟んだ後に評価されるため、チェック時点での在庫値はすでに別トランザクションによって書き換えられている可能性がある。ロックなし在庫引当が二重になる根本原因はここにある。

5. SELECT ... FOR UPDATE で在庫引当を守る

次は修正版です。違いは SELECTFOR 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 本目の COMMITROLLBACK まで待ちます。
待っている間、ターミナルBが静かに見えても異常ではありません。SELECT ... FOR UPDATE が同じ行のロック解放を待っている状態です。

FOR UPDATE で ORDER-A がロック保持中のターミナルA FOR UPDATE で ORDER-B が待機中のターミナルB

実行後の状態を確認します。

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 はその順番を作ります。

FOR UPDATE 後の show-state の結果

コードのポイント

    $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 — 該当行に排他ロックを取る。このロックは COMMITROLLBACK まで保持されるため、後続の SELECT ... FOR UPDATE は先行トランザクションの終了を待つ。
  • 14行目 if ($stock < $quantity) — ロック取得後に読んだ在庫を使って判定するため、他トランザクションがすでに在庫を減らしていた場合、ここで在庫不足と判断して rollBack() へ分岐できる。

6. COMMITROLLBACK の見え方を確認する

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 を見て成功します。

ROLLBACK モードで ORDER-A がロック保持中のターミナルA ROLLBACK モードで ORDER-B が待機中のターミナルB

実行後の状態:

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 で両方とも戻っています。
この挙動を押さえておくと、「在庫不足なら戻す」「途中で例外が出たら戻す」を落ち着いて書けます。

ROLLBACK 後の show-state の結果

7. PHP実装へ戻すときの勘所と詰まりどころ

このデモを実務コードへ戻すときに、最低限外したくない点は 4 つです。

  1. FOR UPDATEbeginTransaction() とセットで使う

SELECT ... FOR UPDATE だけ実行しても、明示トランザクションがなければステートメント終了時にロックが解放されます。
在庫判定と更新を同じトランザクションへ閉じるところまでが 1 セットです。

  1. ロックする行は必要最小限へ絞る

今回は sku = :sku の 1 行だけを対象にしました。
在庫引当のたびに広い範囲をロックすると、待機が増えて別の詰まり方を呼びます。

  1. 重複予約の抑止は別軸でも持っておく

デモでは UNIQUE (order_ref, product_id) を入れています。
同じ注文番号で二重送信されたときに、アプリケーション側だけへ責任を寄せないためです。

  1. 制約や監査ログは後から足す

実務では 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 driverpdo_pgsql が入っていない`docker compose exec app php -mgrep 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 本目がどこで待ち、どのタイミングで進むかを体感しておくと、その後の設計判断がぶれにくくなります。