TypeScriptで楽観ロックを実装する:在庫予約の売り越しを条件付きUPDATEで防ぐ
在庫が残り1件のとき、同時に届いた2件の予約をどう扱うでしょうか。アプリケーション側で「在庫がある」と確認してから更新するだけでは、両方が同じ在庫を読めてしまいます。
この記事では、在庫数と version を同じ UPDATE 文の条件に置くことで、古い読み取り結果による更新を1件だけに絞る方法を、TypeScript と PostgreSQL で整理します。
先に結論
実装の要点は次の3つです。
- 読み取り時に
versionも返す - 更新時は
WHERE stock > 0 AND version = $expectedVersionを付ける - 更新件数が0なら、成功として扱わず競合または在庫切れとして返す
SELECT → if → UPDATE を別々の判断にしないことが重要です。判定と更新を一つの SQL 文にまとめれば、同じ行に対する競合をデータベースが直列化します。
何が壊れるのか
初期状態を stock=1, version=42 とします。Request A と Request B がほぼ同時に在庫を読んだ場合、どちらも「残り1」と判断できます。
version を条件に含めないと、在庫補充や他の更新をまたいで古い画面の操作を受け入れてしまいます。反対に stock > 0 だけでは、在庫数以外の同時変更を検出できません。予約のように「見た状態に基づく更新」では、両方を条件に入れると意図が明確です。
PostgreSQL では条件付き UPDATE にする
テーブルには更新世代を表す整数を持たせます。
CREATE TABLE inventories (
id uuid PRIMARY KEY,
stock integer NOT NULL CHECK (stock >= 0),
version integer NOT NULL DEFAULT 1
);
予約を確定する SQL は次のようになります。
UPDATE inventories
SET
stock = stock - 1,
version = version + 1
WHERE
id = $1
AND stock > 0
AND version = $2
RETURNING id, stock, version;
RETURNING が1行なら予約成功です。0行なら、少なくとも次のどちらかです。
- すでに別のリクエストが更新し、version が変わった
- 在庫がなくなった、または対象 ID が存在しない
この時点で「競合か在庫切れか」を厳密に分ける必要がある UI なら、失敗後に最新状態を読み直します。ただし、その読み直しで更新を再試行して成功扱いにするのは別の仕様です。利用者の意図を確認せずに無条件リトライすると、二重クリックや古い操作を隠してしまいます。
TypeScript から更新件数を判定する
pg を使う場合、成功条件は返却された行の有無だけにできます。
import type { Pool } from "pg";
type ReservationResult =
| { ok: true; stock: number; version: number }
| { ok: false; reason: "conflict_or_sold_out" };
export async function reserveOne(
db: Pool,
inventoryId: string,
expectedVersion: number,
): Promise<ReservationResult> {
const result = await db.query<{
stock: number;
version: number;
}>(
`
UPDATE inventories
SET stock = stock - 1, version = version + 1
WHERE id = $1
AND stock > 0
AND version = $2
RETURNING stock, version
`,
[inventoryId, expectedVersion],
);
const updated = result.rows[0];
if (!updated) {
return { ok: false, reason: "conflict_or_sold_out" };
}
return { ok: true, stock: updated.stock, version: updated.version };
}
パラメータをプレースホルダで渡すことで、ID や version を文字列連結しません。ここで大切なのは、事前の SELECT 結果を信じて UPDATE しないことです。成功可否は、必ずこの更新結果から決めます。
DB なしで不変条件をテストする
SQL の条件を in-memory 実装に写しておくと、失敗ケースを短くテストできます。次のコードは Bun で実行できます。
import assert from "node:assert/strict";
type Inventory = Readonly<{ id: string; stock: number; version: number }>;
class InventoryRepository {
private readonly rows = new Map<string, Inventory>();
constructor(seed: Inventory[]) {
for (const row of seed) this.rows.set(row.id, row);
}
// SQL の WHERE stock > 0 AND version = $expectedVersion に対応する。
reserve(id: string, expectedVersion: number): boolean {
const current = this.rows.get(id);
if (!current || current.stock <= 0 || current.version !== expectedVersion) {
return false;
}
this.rows.set(id, {
...current,
stock: current.stock - 1,
version: current.version + 1,
});
return true;
}
get(id: string) {
return this.rows.get(id);
}
}
const repo = new InventoryRepository([{ id: "ticket-A", stock: 1, version: 42 }]);
assert.equal(repo.reserve("ticket-A", 42), true);
assert.deepEqual(repo.get("ticket-A"), {
id: "ticket-A",
stock: 0,
version: 43,
});
// 同じ読み取り結果(version=42)を持つ後続リクエストは成功しない。
assert.equal(repo.reserve("ticket-A", 42), false);
// 在庫がゼロなら、最新 version を渡しても成功しない。
assert.equal(repo.reserve("ticket-A", 43), false);
console.log("optimistic-lock tests passed");
今回確認したい不変条件は、次の2つです。
- 成功した予約は stock を1減らし、version を1増やす
- 同じ expectedVersion を持つ後続の予約は成功しない
実際の本番処理では、並行性の保証は in-memory の Map ではなく、先ほどの条件付き UPDATEが担います。このテストは、アプリケーションがその SQL と同じ成功条件を守っているかを確認するものです。
複数テーブルにまたがるとき
予約レコードも同時に作るなら、在庫の減算と予約 INSERT を別々に確定させない方が安全です。PostgreSQL なら CTE で一つの文にできます。
WITH updated_inventory AS (
UPDATE inventories
SET stock = stock - 1, version = version + 1
WHERE id = $1
AND stock > 0
AND version = $2
RETURNING id
)
INSERT INTO reservations (inventory_id, user_id)
SELECT id, $3
FROM updated_inventory
RETURNING id;
これなら在庫更新に成功した場合だけ予約が作られます。複数の商品をまとめて予約するケースでは、対象行を一定の順序で扱い、トランザクション境界と失敗時の返却仕様を先に決めます。単一行の楽観ロックを、そのまま複数行の万能解として広げないことも設計上のポイントです。
面接で説明するなら
「version 列を置きます」だけでは不十分です。次の順で説明すると、競合の境界が伝わります。
- 古い読み取り結果を持つ更新を検出したい
- そのため
versionとstock > 0を UPDATE の条件に入れる - 更新件数が0なら成功にせず、最新状態を返すか再操作を促す
- 予約レコードまで必要なら、同じ SQL 文または明示したトランザクションで原子化する
楽観ロックは「ロックを取らないから簡単」な仕組みではありません。どの更新を古いとみなすかを、条件式と失敗時の UX の両方で定義するための設計です。
