IT技術ブログ

予約の二重受付を防ぐ実装の選び方。アプリ側の空き確認が同時の申込で破れることを並列リクエストで再現し、一意制約・ロック・条件つき更新・排他制約を比べる

予約の二重受付は、アプリ側で空きを確かめるだけでは同時の申込で起きます。並列リクエストで再現したうえで、一意制約、ロックを取るトランザクション、条件つきUPDATE、PostgreSQLの排他制約を、手元で動かした結果とともに比べます。

この記事の結論:予約の二重受付は、アプリ側で「空きがあるか確かめてから登録する」だけでは防げません。定員1の枠なら一意制約、定員が2以上の枠なら条件つきのUPDATEで残りを減らすか枠の行をロックしてから数える、予約ごとに時間の長さが違うならPostgreSQLの排他制約で、最後の判定をデータベースに任せます。どの方法でも、ロックを持ったまま外部のAPIを待たないことが前提です。

この記事では、Node.jsで小さな予約のサーバーを立て、同じ枠に同時に30件の申込を送るテストで、それぞれの方法の結果を確かめました。同時に動くプロセスを4つ立て、それぞれが別の接続でSQLiteを使う作りです。PostgreSQLのサーバーは手元にないため、PostgreSQLのSQLはWebAssemblyで動くPGliteで文法と1接続での動きだけを確かめ、同時の申込での動きは確かめていません。どこまで確かめたかは、方法ごとに書き分けています。

アプリ側の空き確認だけでは、同時の申込で二重受付が起きる

「空きを数える→空いていれば登録する」という2段階の処理は、同時に届いた申込どうしが同じ空きを見てしまうため、定員を超えて受け付けます。手元の再現では、定員1の枠に30件を同時に送ると、30件すべてが受け付けられました。

再現に使ったのは次の作りです。予約の枠の表と、予約の表があり、受付の処理は空きを数えてから登録します。数えてから登録するまでの間に20ミリ秒の待ちを入れているのは、実際のサーバーでは入力の検証や外部のAPIの呼び出しがここに挟まるからです。

js
// 方式1:アプリ側で空きを確かめてから登録する(同時の申込で破れる)
async function naive(slotId, customer) {
  const slot = db.prepare("SELECT capacity FROM slots WHERE id = ?").get(slotId);
  const { n } = db.prepare("SELECT COUNT(*) AS n FROM bookings WHERE slot_id = ?").get(slotId);
  if (n >= slot.capacity) return [409, { error: "満席" }];
  await sleep(DELAY); // 本番ではここに入力の検証や外部APIの呼び出しが挟まる
  db.prepare("INSERT INTO bookings (slot_id, customer) VALUES (?, ?)").run(slotId, customer);
  return [201, { ok: true }];
}

結果は次のとおりです(3回の実行の結果を並べています)。

作り方 定員1の枠に30件 定員3の枠に30件
確認と登録のあいだに20msの処理 3回とも30件受付 3回とも30件受付
確認と登録を続けて書く(あいだに await なし) 3件・1件・4件受付 5件・6件・6件受付

2行目は、確認と登録を続けて書き、間に待ちを入れなかった場合です。1つのプロセスの中では2つの処理が割り込まれずに続けて動きますが、別のプロセスの申込とは同時に動くため、定員3の枠に5〜6件が入りました。サーバーを複数台やサーバーレスで動かす構成では、この状態が普通です。1の作りで待ちを0ミリ秒にした場合も、定員1の枠に10件を同時に送るテストを5回行い、毎回4〜10件が受け付けられました。

画面で「残り1枠」と表示していても、保存の時点で数え直していても、この問題は残ります。判定とその結果の書き込みが、データベースの中で1つの操作になっていないからです。

一意制約は、1つの枠に1件しか入らない予約で使う

1つの枠に1件だけの予約なら、予約の表に「同じ枠のIDは1行まで」という一意制約をかけ、登録が成功したかどうかで判定します。手元の再現では、30件の同時の申込のうち、受け付けられたのは3回とも1件でした。

sql
CREATE TABLE bookings_unique (
  id       INTEGER PRIMARY KEY,
  slot_id  INTEGER NOT NULL REFERENCES slots(id),
  customer TEXT NOT NULL,
  UNIQUE (slot_id)
);
js
// 方式2:一意制約に任せる(定員1の枠向け)
async function unique(slotId, customer) {
  await sleep(DELAY);
  try {
    db.prepare("INSERT INTO bookings_unique (slot_id, customer) VALUES (?, ?)").run(slotId, customer);
    return [201, { ok: true }];
  } catch (err) {
    if (err.errcode === 2067) return [409, { error: "満席" }]; // SQLITE_CONSTRAINT_UNIQUE
    throw err;
  }
}

先に空きを数える必要はありません。登録を試み、一意制約の違反なら「満席」として返します。違反のエラーの判定は、データベースごとの番号で行います。SQLiteでは拡張のエラーコード2067、PostgreSQLではSQLSTATEの23505です(PGliteで2件目を登録したときに23505 duplicate key value violates unique constraintが返ることを確かめました)。

取り消しを扱うときは注意が要ります。取り消した予約の行を残したまま一意制約をかけると、空いたはずの枠に次の予約が入れません。取り消しの行を別の表に移すか、PostgreSQLならWHEREつきの一意インデックス(部分インデックス)で「有効な予約だけ」に制約をかけます。

席の番号や担当者の枠のように、定員2以上でも「どの席か」まで決まる予約なら、UNIQUE (slot_id, seat_no)のように席ごとの一意制約にすれば同じ考え方が使えます。

定員のある枠は、条件つきのUPDATEで残りを減らす

定員が2以上の枠は、枠の表に予約済みの数を持たせ、「予約済みが定員より少ないときだけ1増やす」というUPDATEを1文で実行し、更新できた行が1行なら受け付けます。手元の再現では、定員3の枠に30件を送り、3回とも3件だけが受け付けられました。

sql
CREATE TABLE slots (
  id        INTEGER PRIMARY KEY,
  starts_at TEXT NOT NULL,
  capacity  INTEGER NOT NULL,
  booked    INTEGER NOT NULL DEFAULT 0 CHECK (booked <= capacity)
);
js
// 方式4:条件つきの UPDATE で枠の残りを1つ減らし、成功したときだけ登録する(定員2以上の枠向け)
async function counter(slotId, customer) {
  await sleep(DELAY);
  db.exec("BEGIN IMMEDIATE");
  try {
    const r = db
      .prepare("UPDATE slots SET booked = booked + 1 WHERE id = ? AND booked < capacity")
      .run(slotId);
    if (r.changes === 0) {
      db.exec("ROLLBACK");
      return [409, { error: "満席" }];
    }
    db.prepare("INSERT INTO bookings (slot_id, customer) VALUES (?, ?)").run(slotId, customer);
    db.exec("COMMIT");
    return [201, { ok: true }];
  } catch (err) {
    if (db.isTransaction) db.exec("ROLLBACK");
    throw err;
  }
}

条件の判定と増やす処理が1つのUPDATEの中にあるので、2つの申込が同じ「残り」を見て両方とも通ることがありません。枠の更新と予約の登録は同じトランザクションに入れ、片方だけが残らないようにします。CHECK (booked <= capacity)は、ほかの経路から書き換えられたときの最後の歯止めです。

PostgreSQLでは、同じ処理をUPDATE ... WHERE id = $1 AND booked < capacity RETURNING bookedと書き、返ってきた行の数で判定します。PGliteで、定員3の枠に5回続けて申し込み「受付・受付・受付・満席・満席」となることは確かめましたが、PGliteは1つの接続で動くため、同時の申込での動きはここでは確かめていません。

この方法の弱点は、予約の取り消しや人数の変更のたびにbookedも正しく戻す必要があることです。数がずれたときに予約の表から数え直せるよう、予約の表を正本にしておきます。

行ロックを取るトランザクションは、ロックの中で外部の処理を待たない

「数えてから登録する」形を残したいときは、トランザクションの最初にロックを取り、確認と登録をまとめます。PostgreSQLならSELECT ... FOR UPDATEで枠の行をロックし、SQLiteならBEGIN IMMEDIATEで書き込みのロックを先に取ります。手元の再現では、ロックの中で待たない作りなら、定員1も定員3も定員どおりに受け付けました。

js
// 方式3:トランザクションの最初に書き込みのロックを取り、確認と登録をまとめる
// 時間のかかる処理はトランザクションの前に済ませ、ロックの間は await しない
async function lock(slotId, customer) {
  await sleep(DELAY); // 入力の検証や外部APIの呼び出しは、ここ(ロックの前)で行う
  db.exec("BEGIN IMMEDIATE"); // PostgreSQL なら SELECT ... FOR UPDATE で枠の行をロックする
  try {
    const slot = db.prepare("SELECT capacity FROM slots WHERE id = ?").get(slotId);
    const { n } = db.prepare("SELECT COUNT(*) AS n FROM bookings WHERE slot_id = ?").get(slotId);
    if (n >= slot.capacity) {
      db.exec("ROLLBACK");
      return [409, { error: "満席" }];
    }
    db.prepare("INSERT INTO bookings (slot_id, customer) VALUES (?, ?)").run(slotId, customer);
    db.exec("COMMIT");
    return [201, { ok: true }];
  } catch (err) {
    if (db.isTransaction) db.exec("ROLLBACK");
    throw err;
  }
}

比べるために、ロックの取り方と待つ場所を変えた2つの作りも試しました。

作り方 定員1に30件 定員3に30件 500になった申込のエラー
BEGIN IMMEDIATE、ロックの中で待たない 1件受付・29件満席(3回とも) 3件受付・27件満席(3回とも) なし
ふつうのBEGIN(DEFERRED) 1件受付、0〜3件が500 3件受付、4〜5件が500 database is locked
BEGIN IMMEDIATEのあと、ロックの中で20ms待つ 1件受付、7件が500 3件受付、16〜19件が500 cannot start a transaction within a transaction

ふつうのBEGINでは、定員は守られたものの、一部の申込がエラーになりました。SQLiteのドキュメントによると、DEFERREDのトランザクションは最初に読み取りで始まり、書き込みに移る時点でほかの接続が書き込みをしているとSQLITE_BUSYで失敗します。数える前に書き込みのロックを取るBEGIN IMMEDIATEにすると、順番待ちになり、エラーは出ませんでした。

3行目は、1つのプロセスで1本の接続を使い回したまま、トランザクションの途中で待った場合です。待っている間に同じプロセスの別の申込が同じ接続でトランザクションを始めようとして、エラーになりました。原因は、ロックを持ったまま待ったことです。

PostgreSQLで書くと次のようになります。接続のプールから1本借り、その接続の中でBEGINからCOMMITまでを行います。

js
// PostgreSQL(pg のプール)での書き方の例。ここでは実行していない
async function reserveWithLock(pool, slotId, customer) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const slot = await client.query("SELECT capacity FROM slots WHERE id = $1 FOR UPDATE", [slotId]);
    const { rows } = await client.query("SELECT count(*)::int AS n FROM bookings WHERE slot_id = $1", [slotId]);
    if (rows[0].n >= slot.rows[0].capacity) {
      await client.query("ROLLBACK");
      return "満席";
    }
    await client.query("INSERT INTO bookings (slot_id, customer) VALUES ($1, $2)", [slotId, customer]);
    await client.query("COMMIT");
    return "受付";
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

この関数そのものは実行していません。同じSQL(FOR UPDATEで枠の行をロックしてから数えて登録する)をPGliteのトランザクションで動かし、定員1の枠に3回続けて申し込んで「受付・満席・満席」となることは確かめました。PostgreSQLの既定の分離レベルはRead Committedで、ロックを取らずに数えるだけでは、方式1と同じく同時の申込を止められません。

時間の長さが違う予約は、PostgreSQLの排他制約で重なりを拒む

施術時間や利用時間が予約ごとに違い、「同じ部屋や担当者で、時間帯が重なる予約を入れない」という条件なら、PostgreSQLの排他制約が向いています。範囲の型(tstzrange)とbtree_gist拡張を組み合わせ、重なる行の登録をデータベースが拒みます。

sql
CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE reservations (
  id         bigserial PRIMARY KEY,
  room_id    integer   NOT NULL,
  customer   text      NOT NULL,
  during     tstzrange NOT NULL,
  status     text      NOT NULL DEFAULT 'confirmed',
  -- 同じ部屋で、時間帯が重なる「確定」の予約を許さない
  CONSTRAINT no_overlap EXCLUDE USING gist (
    room_id WITH =,
    during  WITH &&
  ) WHERE (status = 'confirmed')
);

PGliteで、次の5件を順に登録しました。

OK    A 10:00-11:00
NG    B 10:30-11:30(Aと重なる): 23P01 conflicting key value violates exclusion constraint "no_overlap"
OK    C 11:00-12:00(Aの終わりと接する)
OK    D 10:30-11:30(別の部屋)
OK    E 10:15-10:45(取消済みとして記録)

範囲は'[)'(始まりを含み、終わりを含まない)で作っているので、11:00に終わる予約と11:00に始まる予約は重なりと見なされません。WHERE (status = 'confirmed')を付けているため、取り消した予約は制約の対象から外れます。違反のエラーはSQLSTATEの23P01で返るので、アプリはこの番号を見て「その時間は埋まっています」と返します。

ここで確かめたのは、制約の定義が通ることと、1つの接続で重なる行が拒まれることまでです。同時の申込での動きは確かめていません。PostgreSQLのドキュメントでは、排他制約は「どの2行を指定した列と演算子で比べても、比較のどれか1つは成り立たない」ことを保証するものと説明されており、一意制約と同じく、判定をデータベースに任せられる方法です。担当者の勤務表から空き枠を計算する設計は、パーソナルトレーナーのシフトの記事で扱っています。

どれを選ぶかは、枠の形とデータベースで決める

選び方は、予約の枠がどんな形かでほぼ決まります。迷ったら、データベースが自分で判定できる方法(一意制約、条件つきUPDATE、排他制約)を先に検討し、ロックの方法はそのどれにも当てはまらないときに使います。

予約の枠の形 向いている方法 手元で確かめたこと
1枠1件(個室、1対1の面談) 一意制約 SQLiteで同時30件→1件受付。PGliteで違反が23505
1枠に定員(クラス、説明会) 条件つきUPDATE SQLiteで同時30件→定員どおり。PGliteで1接続の動き
席や担当まで決まる 席ごとの一意制約 考え方のみ(実行していない)
時間の長さが予約ごとに違う 排他制約(PostgreSQL) PGliteで重なりの拒否と23P01。同時は未確認
判定が複雑で、上のどれにも当てはまらない 行ロック+トランザクション SQLiteのBEGIN IMMEDIATEで同時30件→定員どおり。PGliteでFOR UPDATEの1接続の動き

どの方法でも、満席だった申込には「満席」と分かる応答(例では409)を返し、画面は選び直せる状態に戻します。エラーをすべて「通信に失敗しました」にまとめると、利用者は同じ申込を繰り返してしまいます。

そもそも予約の仕組みを既製のサービスに任せるか、自社向けに作るかを決める段階なら、会社のコラムSaaSかオーダーメイドか|業務システムの選び方が参考になります。株式会社bundlyzeでは、複数の業種で使う予約管理システムを、企画から開発、運用まで手がけています。同時の申込でも定員を守る受付の作り直しを考えている場合の窓口は、システム開発のページです。

再現に使ったコード

手元で同じテストを動かせるよう、使ったファイルをそのまま載せます。Node.js 22のnode:sqliteを使っているので、追加のパッケージなしで動きます(PGliteの確認だけ@electric-sql/pgliteを入れます)。

テスト用のデータベースを作るスクリプトです。

setup.mjsjs
// setup.mjs
// テスト用のデータベースを作り直す
import { DatabaseSync } from "node:sqlite";
import { rmSync } from "node:fs";

const FILE = "booking.db";
for (const f of [FILE, FILE + "-wal", FILE + "-shm"]) rmSync(f, { force: true });

const db = new DatabaseSync(FILE);
db.exec(`
  PRAGMA journal_mode = WAL;

  -- 予約枠(定員つき)
  CREATE TABLE slots (
    id       INTEGER PRIMARY KEY,
    starts_at TEXT NOT NULL,
    capacity INTEGER NOT NULL,
    booked   INTEGER NOT NULL DEFAULT 0 CHECK (booked <= capacity)
  );

  -- 方式1〜3で使う予約の表(制約なし)
  CREATE TABLE bookings (
    id       INTEGER PRIMARY KEY,
    slot_id  INTEGER NOT NULL REFERENCES slots(id),
    customer TEXT NOT NULL
  );

  -- 方式2で使う予約の表(1枠1件を一意制約で守る)
  CREATE TABLE bookings_unique (
    id       INTEGER PRIMARY KEY,
    slot_id  INTEGER NOT NULL REFERENCES slots(id),
    customer TEXT NOT NULL,
    UNIQUE (slot_id)
  );

  INSERT INTO slots (id, starts_at, capacity) VALUES
    (1, '2026-10-10T10:00', 1),  -- 定員1の枠
    (2, '2026-10-10T11:00', 3);  -- 定員3の枠
`);
db.close();
console.log("setup done");

予約を受け付けるサーバーです。clusterでプロセスを4つ立て、それぞれが別の接続でSQLiteを開きます。

server.mjsjs
// server.mjs
// 予約を受け付けるHTTPサーバー。プロセスを4つ立て、それぞれが別の接続でDBを使う
import cluster from "node:cluster";
import http from "node:http";
import { DatabaseSync } from "node:sqlite";
import { setTimeout as sleep } from "node:timers/promises";

const PORT = 8920;
const WORKERS = 4;
const DELAY = Number(process.env.DELAY ?? 20); // 確認と登録のあいだに挟まる処理の時間(ミリ秒)

if (cluster.isPrimary) {
  for (let i = 0; i < WORKERS; i++) cluster.fork();
} else {
  const db = new DatabaseSync("booking.db");
  db.exec("PRAGMA busy_timeout = 5000"); // 書き込みの順番待ちを最大5秒まで待つ

  const handlers = { naive, "check-only": checkOnly, unique, deferred, lock, "lock-await": lockAwait, counter };

  http.createServer(async (req, res) => {
    const url = new URL(req.url, "http://localhost");
    const handler = handlers[url.pathname.slice(1)];
    if (!handler) return send(res, 404, { error: "not found" });
    const slotId = Number(url.searchParams.get("slot"));
    const customer = url.searchParams.get("customer");
    try {
      send(res, ...(await handler(slotId, customer)));
    } catch (err) {
      send(res, 500, { error: String(err.message) });
    }
  }).listen(PORT);

  // 方式1:アプリ側で空きを確かめてから登録する(同時の申込で破れる)
  async function naive(slotId, customer) {
    const slot = db.prepare("SELECT capacity FROM slots WHERE id = ?").get(slotId);
    const { n } = db.prepare("SELECT COUNT(*) AS n FROM bookings WHERE slot_id = ?").get(slotId);
    if (n >= slot.capacity) return [409, { error: "満席" }];
    await sleep(DELAY); // 本番ではここに入力の検証や外部APIの呼び出しが挟まる
    db.prepare("INSERT INTO bookings (slot_id, customer) VALUES (?, ?)").run(slotId, customer);
    return [201, { ok: true }];
  }

  // 方式1の別形:確認と登録のあいだに await を挟まない。それでも別のプロセスとは同時に動く
  async function checkOnly(slotId, customer) {
    await sleep(DELAY);
    const slot = db.prepare("SELECT capacity FROM slots WHERE id = ?").get(slotId);
    const { n } = db.prepare("SELECT COUNT(*) AS n FROM bookings WHERE slot_id = ?").get(slotId);
    if (n >= slot.capacity) return [409, { error: "満席" }];
    db.prepare("INSERT INTO bookings (slot_id, customer) VALUES (?, ?)").run(slotId, customer);
    return [201, { ok: true }];
  }

  // 方式2:一意制約に任せる(定員1の枠向け)
  async function unique(slotId, customer) {
    await sleep(DELAY);
    try {
      db.prepare("INSERT INTO bookings_unique (slot_id, customer) VALUES (?, ?)").run(slotId, customer);
      return [201, { ok: true }];
    } catch (err) {
      if (err.errcode === 2067) return [409, { error: "満席" }]; // SQLITE_CONSTRAINT_UNIQUE
      throw err;
    }
  }

  // 方式3:トランザクションの最初に書き込みのロックを取り、確認と登録をまとめる
  // 時間のかかる処理はトランザクションの前に済ませ、ロックの間は await しない
  async function lock(slotId, customer) {
    await sleep(DELAY); // 入力の検証や外部APIの呼び出しは、ここ(ロックの前)で行う
    db.exec("BEGIN IMMEDIATE"); // PostgreSQL なら SELECT ... FOR UPDATE で枠の行をロックする
    try {
      const slot = db.prepare("SELECT capacity FROM slots WHERE id = ?").get(slotId);
      const { n } = db.prepare("SELECT COUNT(*) AS n FROM bookings WHERE slot_id = ?").get(slotId);
      if (n >= slot.capacity) {
        db.exec("ROLLBACK");
        return [409, { error: "満席" }];
      }
      db.prepare("INSERT INTO bookings (slot_id, customer) VALUES (?, ?)").run(slotId, customer);
      db.exec("COMMIT");
      return [201, { ok: true }];
    } catch (err) {
      if (db.isTransaction) db.exec("ROLLBACK");
      throw err;
    }
  }

  // 方式3の比較:ふつうの BEGIN(DEFERRED)。読み取りから書き込みへ移るときにぶつかる
  async function deferred(slotId, customer) {
    await sleep(DELAY);
    db.exec("BEGIN");
    try {
      const slot = db.prepare("SELECT capacity FROM slots WHERE id = ?").get(slotId);
      const { n } = db.prepare("SELECT COUNT(*) AS n FROM bookings WHERE slot_id = ?").get(slotId);
      if (n >= slot.capacity) {
        db.exec("ROLLBACK");
        return [409, { error: "満席" }];
      }
      db.prepare("INSERT INTO bookings (slot_id, customer) VALUES (?, ?)").run(slotId, customer);
      db.exec("COMMIT");
      return [201, { ok: true }];
    } catch (err) {
      if (db.isTransaction) db.exec("ROLLBACK");
      throw err;
    }
  }

  // 方式3の失敗例:ロックを取ったまま await する
  async function lockAwait(slotId, customer) {
    db.exec("BEGIN IMMEDIATE");
    try {
      const slot = db.prepare("SELECT capacity FROM slots WHERE id = ?").get(slotId);
      const { n } = db.prepare("SELECT COUNT(*) AS n FROM bookings WHERE slot_id = ?").get(slotId);
      if (n >= slot.capacity) {
        db.exec("ROLLBACK");
        return [409, { error: "満席" }];
      }
      await sleep(DELAY); // ロックを持ったまま待つ
      db.prepare("INSERT INTO bookings (slot_id, customer) VALUES (?, ?)").run(slotId, customer);
      db.exec("COMMIT");
      return [201, { ok: true }];
    } catch (err) {
      if (db.isTransaction) db.exec("ROLLBACK");
      throw err;
    }
  }

  // 方式4:条件つきの UPDATE で枠の残りを1つ減らし、成功したときだけ登録する(定員2以上の枠向け)
  async function counter(slotId, customer) {
    await sleep(DELAY);
    db.exec("BEGIN IMMEDIATE");
    try {
      const r = db
        .prepare("UPDATE slots SET booked = booked + 1 WHERE id = ? AND booked < capacity")
        .run(slotId);
      if (r.changes === 0) {
        db.exec("ROLLBACK");
        return [409, { error: "満席" }];
      }
      db.prepare("INSERT INTO bookings (slot_id, customer) VALUES (?, ?)").run(slotId, customer);
      db.exec("COMMIT");
      return [201, { ok: true }];
    } catch (err) {
      if (db.isTransaction) db.exec("ROLLBACK");
      throw err;
    }
  }
}

function send(res, status, body) {
  res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  res.end(JSON.stringify(body));
}

同じ枠に同時に申込を送り、応答と登録された件数を数えるスクリプトです。

race.mjsjs
// race.mjs
// 同じ枠に、同時に N 件の申込を送り、何件受け付けられたかを数える
// 使い方: node race.mjs <方式> <枠のID> <件数>
import { DatabaseSync } from "node:sqlite";

const [mode = "naive", slot = "1", count = "10"] = process.argv.slice(2);
const N = Number(count);

const results = await Promise.all(
  Array.from({ length: N }, (_, i) =>
    fetch(`http://127.0.0.1:8920/${mode}?slot=${slot}&customer=c${i + 1}`).then((r) => r.status),
  ),
);

const tally = results.reduce((acc, s) => ((acc[s] = (acc[s] ?? 0) + 1), acc), {});
const db = new DatabaseSync("booking.db", { readOnly: true });
const table = mode === "unique" ? "bookings_unique" : "bookings";
const { n } = db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE slot_id = ?`).get(Number(slot));
const { capacity } = db.prepare("SELECT capacity FROM slots WHERE id = ?").get(Number(slot));
console.log(`${mode} 枠${slot}(定員${capacity})に${N}件: 応答 ${JSON.stringify(tally)} / 登録された件数 ${n}`);

方式ごとに、データベースを作り直してサーバーを起動し、30件を送ります。

run-all.shbash
# run-all.sh
#!/bin/bash
# 方式ごとに、DBを作り直してサーバーを起動し、同じ枠へ同時に30件の申込を送る
run() {
  node setup.mjs >/dev/null 2>&1
  node server.mjs > server.log 2>&1 &
  SERVER=$!
  sleep 1.5
  for args in "$@"; do node race.mjs $args 2>/dev/null; done
  kill $SERVER; wait $SERVER 2>/dev/null
}
for mode in naive check-only unique deferred lock-await lock counter; do
  if [ "$mode" = "unique" ]; then run "unique 1 30"; else run "$mode 1 30" "$mode 2 30"; fi
done

PGliteで、一意制約・行ロック・条件つきUPDATEのSQLを確かめたスクリプトです。

pg-lock.mjsjs
// pg-lock.mjs
// PGlite で、行ロックと条件つき UPDATE の SQL が通るかを確かめる(1接続のみ。同時実行は確かめていない)
import { PGlite } from "@electric-sql/pglite";

const db = new PGlite();
await db.exec(`
  CREATE TABLE slots (
    id       integer PRIMARY KEY,
    capacity integer NOT NULL,
    booked   integer NOT NULL DEFAULT 0 CHECK (booked <= capacity)
  );
  CREATE TABLE bookings (
    id       bigserial PRIMARY KEY,
    slot_id  integer NOT NULL REFERENCES slots(id),
    customer text    NOT NULL
  );
  CREATE TABLE bookings_unique (
    id       bigserial PRIMARY KEY,
    slot_id  integer NOT NULL REFERENCES slots(id),
    customer text    NOT NULL,
    CONSTRAINT one_booking_per_slot UNIQUE (slot_id)
  );
  INSERT INTO slots (id, capacity) VALUES (1, 1), (2, 3);
`);

// 方式3:枠の行を FOR UPDATE でロックしてから数える
async function reserveWithLock(slotId, customer) {
  return db.transaction(async (tx) => {
    const slot = (await tx.query("SELECT capacity FROM slots WHERE id = $1 FOR UPDATE", [slotId])).rows[0];
    const { n } = (await tx.query("SELECT count(*)::int AS n FROM bookings WHERE slot_id = $1", [slotId])).rows[0];
    if (n >= slot.capacity) return "満席";
    await tx.query("INSERT INTO bookings (slot_id, customer) VALUES ($1, $2)", [slotId, customer]);
    return "受付";
  });
}

// 方式4:条件つき UPDATE で残りを減らす
async function reserveWithCounter(slotId, customer) {
  return db.transaction(async (tx) => {
    const r = await tx.query(
      "UPDATE slots SET booked = booked + 1 WHERE id = $1 AND booked < capacity RETURNING booked",
      [slotId],
    );
    if (r.rows.length === 0) return "満席";
    await tx.query("INSERT INTO bookings (slot_id, customer) VALUES ($1, $2)", [slotId, customer]);
    return "受付";
  });
}

const lockResults = [];
for (let i = 1; i <= 3; i++) lockResults.push(await reserveWithLock(1, `L${i}`));
console.log("FOR UPDATE(定員1に3件):", lockResults.join(" / "));

const counterResults = [];
for (let i = 1; i <= 5; i++) counterResults.push(await reserveWithCounter(2, `C${i}`));
console.log("条件つき UPDATE(定員3に5件):", counterResults.join(" / "));

await db.query("INSERT INTO bookings_unique (slot_id, customer) VALUES (1, 'U1')");
try {
  await db.query("INSERT INTO bookings_unique (slot_id, customer) VALUES (1, 'U2')");
} catch (err) {
  console.log("一意制約(同じ枠に2件目):", err.code, err.message);
}
FOR UPDATE(定員1に3件): 受付 / 満席 / 満席
条件つき UPDATE(定員3に5件): 受付 / 受付 / 受付 / 満席 / 満席
一意制約(同じ枠に2件目): 23505 duplicate key value violates unique constraint "one_booking_per_slot"

排他制約を確かめたスクリプトです。

pg-exclusion.mjsjs
// pg-exclusion.mjs
// PGlite(WebAssembly で動く PostgreSQL)で、排他制約が重なる予約を拒むかを確かめる
import { PGlite } from "@electric-sql/pglite";
import { btree_gist } from "@electric-sql/pglite/contrib/btree_gist";

const db = new PGlite({ extensions: { btree_gist } });
console.log((await db.query("SELECT version()")).rows[0].version);

await db.exec(`
  CREATE EXTENSION IF NOT EXISTS btree_gist;

  CREATE TABLE reservations (
    id         bigserial PRIMARY KEY,
    room_id    integer   NOT NULL,
    customer   text      NOT NULL,
    during     tstzrange NOT NULL,
    status     text      NOT NULL DEFAULT 'confirmed',
    -- 同じ部屋で、時間帯が重なる「確定」の予約を許さない
    CONSTRAINT no_overlap EXCLUDE USING gist (
      room_id WITH =,
      during  WITH &&
    ) WHERE (status = 'confirmed')
  );
`);

async function tryInsert(label, roomId, from, to, status = "confirmed") {
  try {
    await db.query(
      "INSERT INTO reservations (room_id, customer, during, status) VALUES ($1, $2, tstzrange($3, $4, '[)'), $5)",
      [roomId, label, from, to, status],
    );
    console.log(`OK    ${label}`);
  } catch (err) {
    console.log(`NG    ${label}: ${err.code} ${err.message}`);
  }
}

await tryInsert("A 10:00-11:00",               1, "2026-10-10 10:00+09", "2026-10-10 11:00+09");
await tryInsert("B 10:30-11:30(Aと重なる)",  1, "2026-10-10 10:30+09", "2026-10-10 11:30+09");
await tryInsert("C 11:00-12:00(Aの終わりと接する)", 1, "2026-10-10 11:00+09", "2026-10-10 12:00+09");
await tryInsert("D 10:30-11:30(別の部屋)",   2, "2026-10-10 10:30+09", "2026-10-10 11:30+09");
await tryInsert("E 10:15-10:45(取消済みとして記録)", 1, "2026-10-10 10:15+09", "2026-10-10 10:45+09", "cancelled");

動作確認した環境

2026年10月4日に、手元のMacで次の環境を使って確かめました。

項目 バージョン・内容
OS macOS 26(Darwin 25.6.0)
Node.js 22.23.2(node:sqlite、node:cluster)
SQLite Node.js 22.23.2 に組み込みのもの(WALモード、busy_timeout 5000ms)
PGlite 0.5.8(PostgreSQL 18.3 を WebAssembly で動かすもの)
同時の申込 1つの枠に30件を Promise.all で同時に送信。サーバーは4プロセス

SQLiteでは、すべての方法を同時の申込で3回ずつ試しました。PGliteでは、SQLの文法と1つの接続での結果だけを確かめ、同時の申込は試していません。PostgreSQLのサーバー(pgのプールを使う例)は手元になく、実行していません。本番のサーバーやサーバーレスの環境での速さ、待ち時間、エラーの起きやすさも測っていません。

根拠にした公式ドキュメント(2026年10月4日に確認)

よくある質問

予約の前に空きを確かめる処理を入れているのに、二重予約が起きるのはなぜですか?

空きを確かめてから登録するまでの間に、別の申込も同じ空きを見て登録に進めるからです。手元の再現では、定員1の枠に同時に30件を送ると、確認と登録のあいだに20ミリ秒の処理を挟んだ作りで30件すべてが受け付けられました。確認と登録を続けて書いても、別のプロセスの申込とは同時に動くため、定員を超えることがありました。

一意制約とロックは、どちらを使えばよいですか?

1つの枠に1件しか入らない予約なら、一意制約がいちばん単純で確実です。定員が2以上の枠なら、枠の残りを条件つきのUPDATEで減らす方法か、枠の行をロックしてから数える方法を使います。時間の長さが予約ごとに違い、重なりで判定したい場合は、PostgreSQLの排他制約が向いています。

トランザクションを使えば、それだけで二重予約は防げますか?

防げるとは限りません。ロックを取らずにトランザクションを始めると、読み取りの時点ではほかの申込を止めないため、データベースの種類や分離レベルによって、定員を超えるか、エラーで落ちるかが変わります。PostgreSQLならSELECT ... FOR UPDATEで枠の行をロックし、SQLiteならBEGIN IMMEDIATEで書き込みのロックを先に取ります。

ロックを取ったまま外部のAPIを呼んでもよいですか?

避けます。ロックを持っている間はほかの申込が待たされ、待ちきれずにエラーになることがあります。手元の再現でも、1本の接続を使い回したままトランザクションの途中で待つ作りでは、多くの申込がエラーになりました。決済や外部への問い合わせはロックの前か後に回し、ロックの中では確認と登録だけを行います。