元の着想:技術面接で「要件からDBの不変条件まで説明する」必要性については、バックエンドエンジニアの面接対策ガイドで触れています。本稿はその予約競合を題材に、SQL・実装・検証を新たに書き起こした技術解説です。
結論
面接官の空き枠や会議室の予約では、「空いているか」をアプリケーションで先に検索してからINSERTするだけでは不十分です。並行した2リクエストが同じ空きを読めば、どちらも予約できてしまうからです。
PostgreSQLなら、時間範囲の重複禁止をExclusion ConstraintとしてDBに置くと、この不変条件を競合下でも守れます。
- 時刻区間は半開区間
[開始, 終了)にする - 同じ面接官IDについて、区間が重なる行を禁止する
- APIはSQLSTATE
23P01を409 Conflictへ変換する - アプリの事前検索はUX用、最終判定はDB用と役割を分ける
これは「面接の予定をどう設計しますか?」というシステム設計の問いにも、そのまま使える回答です。
なぜ「SELECTしてからINSERT」が壊れるのか
例えば面接官42の9:00〜10:00を、候補者AとBがほぼ同時に予約するとします。
SELECT ... WHERE NOT EXISTS をトランザクションで包んでも、分離レベルやロック戦略を正しく選ばなければ同じ問題を抱えます。予約の成立条件そのものをDB制約にすれば、書き込み経路が増えても守る場所は1か所です。
スキーマ:IDの一致と時間範囲の重なりを同時に禁止する
まず btree_gist を有効にします。これにより、整数の等値比較をGiSTインデックスで使えます。
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE interview_slots (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
interviewer_id bigint NOT NULL,
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
CHECK (starts_at < ends_at),
EXCLUDE USING gist (
interviewer_id WITH =,
tstzrange(starts_at, ends_at, '[)') WITH &&
)
);
ここで重要なのは2つです。
-
interviewer_id WITH =は「同じ面接官だけ」を比較対象にします。別の面接官なら同じ時間帯でも予約できます。 -
tstzrange(...) WITH &&は区間の重なりを意味します。&&はrange型のoverlap演算子です。
'[)' は開始を含み、終了を含まない半開区間です。したがって [09:00, 10:00) と [10:00, 11:00) は重なりません。枠の境界で余計な空き時間を作らないため、予約系では扱いやすい表現です。
成功するケースと失敗するケースをSQLで固定する
次のSQLはPostgreSQL 16で実行できます。
INSERT INTO interview_slots (interviewer_id, starts_at, ends_at)
VALUES (42, '2026-08-01 09:00+09', '2026-08-01 10:00+09');
-- 境界で接するだけなので成功します。
INSERT INTO interview_slots (interviewer_id, starts_at, ends_at)
VALUES (42, '2026-08-01 10:00+09', '2026-08-01 11:00+09');
-- 9:30〜10:30は最初の枠と重なるため失敗します。
INSERT INTO interview_slots (interviewer_id, starts_at, ends_at)
VALUES (42, '2026-08-01 09:30+09', '2026-08-01 10:30+09');
最後のINSERTは ERROR: conflicting key value violates exclusion constraint となり、SQLSTATEは 23P01(exclusion_violation)です。これは期待どおりの業務エラーであり、サーバーエラーとして500にするべきではありません。
TypeScriptから409 Conflictとして返す
node-postgresを使う例です。UIで事前に空き状況を表示していても、このINSERTを必ず通すのがポイントです。
import type { PoolClient } from "pg";
type BookingInput = {
interviewerId: number;
startsAt: Date;
endsAt: Date;
};
class SlotAlreadyTakenError extends Error {
constructor() {
super("この時間帯はすでに予約されています");
}
}
function isPgError(error: unknown): error is Error & { code?: string } {
return error instanceof Error && "code" in error;
}
export async function reserveInterviewSlot(
client: PoolClient,
input: BookingInput,
): Promise<void> {
try {
await client.query(
`INSERT INTO interview_slots (interviewer_id, starts_at, ends_at)
VALUES ($1, $2, $3)`,
[input.interviewerId, input.startsAt, input.endsAt],
);
} catch (error: unknown) {
if (isPgError(error) && error.code === "23P01") {
throw new SlotAlreadyTakenError();
}
throw error;
}
}
HTTP層では SlotAlreadyTakenError を409に対応させます。再試行で直る障害ではないため、汎用的な指数バックオフで隠さず、クライアントに別の時刻を選ばせます。
テストで確認する最小セット
制約は「エラーが出ること」も仕様です。少なくとも次の3ケースをCIで確認します。
# 1. 09:00-10:00 を作成できる
# 2. 10:00-11:00 は作成できる(半開区間の境界)
# 3. 09:30-10:30 は SQLSTATE 23P01 で失敗する
本稿のSQLをPostgreSQL 16コンテナで実行し、1と2のINSERT成功、3の制約違反、最終行数が2件であることを確認しました。アプリ層の単体テストだけでは並行書き込みの最後の砦にならないため、実DBに対するこの種のテストを残す価値があります。
運用で先に決めること
Exclusion Constraintは強力ですが、要件を曖昧にしたまま入れるものではありません。
- バッファ時間:面接の前後15分を確保するなら、保存する範囲を広げるか、制約用の生成列を別に持ちます。
-
キャンセル:論理削除した行を制約対象から外すなら、
WHERE (cancelled_at IS NULL)を持つpartial exclusion constraintを検討します。 -
タイムゾーン:DBには
timestamptzを保存し、表示時だけ利用者のタイムゾーンに変換します。 - 複数リソース:面接官と会議室の両方を排他にしたい場合、それぞれの不変条件を別テーブルまたは別制約で明示します。
まとめ
予約の重複は「先に空きを見たか」ではなく「書き込み時に不変条件を守れたか」で決まります。PostgreSQLのExclusion Constraintを使うと、面接枠・会議室・機材などの時間予約を、アプリケーションの競合タイミングに依存させずに実装できます。
面接で説明するなら、半開区間、btree_gist、23P01 → 409、そして事前検索と最終制約の責務分離まで話せると、設計の根拠が伝わりやすくなります。