チケットの二重販売をどう防ぐか: 指定席はユニーク制約、自由席は行ロック ― 架空サカスタのチケットサイトで学ぶ(3)
はじめに
架空のサッカースタジアム「ペヌソニックスタジアム(通称ペヌスタ)」のチケット販売サイトを、遊び場として作っています。
なぜ作っているのか、全体がどうなっているのかは紹介回に書きました。
ゴール裏年パス勢が、架空スタジアムのチケットサイトを作る理由 ― 架空サカスタのチケットサイトで学ぶ(0)
https://qiita.com/macchi23/items/5b9f5670c841f5c41be8
この記事では、席を選んでから買うまでの流れを扱います。
- 座席図をどう描くか
- カートに入れた席を 10 分押さえる「仮押さえ」の期限をどう守るか
- 同じ席が 2 人に売れる「二重販売」をどう防ぐか
- 決済(今はモック)が成功・失敗・期限切れのときに何が起きるか
スタジアムの構造(スタンド → ブロック → 席)と、試合ごとの割当(ホーム / アウェー / 緩衝帯)は 1 回目で書きました。
購入フローも、細部は Claude Code に任せた部分です。チケットサイトの一番肝心なところなので、仕組みを追いながら書きます。
座席図: ブロックの位置を並べて塗るだけ
1 回目で、ブロックごとに座席図の上の位置とサイズ(geometry)を持たせました。
フロントはそれを SVG の <rect> に並べるだけです。
{seatmap.blocks.map((b) => (
<g key={b.id} onClick={() => isSellable(b.allocation) && onSelectBlock(b)}>
<rect
x={b.geometry.x}
y={b.geometry.y}
width={b.geometry.w}
height={b.geometry.h}
fill={ALLOCATION_COLORS[b.allocation]}
fillOpacity={blockOpacity(b)}
/>
<text /* ブロック名(M4 など) */>{b.code}</text>
</g>
))}
- 色は割当(ホーム / アウェー / 緩衝帯 / 閉鎖)
- 濃さは残席の割合。販売するブロックでは、完売に近いほど薄くなる(
0.35 + 0.65 × 残席 / 定員)

試合ごとの割当で塗り分けた座席図(56 ブロック)。※開発中の画面です
指定席のブロックを選ぶと、1 席ずつの状態が出ます。
| 表示 | 中身 |
|---|---|
| 空席 | 誰も押さえていない |
| 自分の席 | 自分のカートに入っている |
| 他の方が確保中 | 他の人が仮押さえ中 |
| 販売済み | 決済まで終わっている |
API は「誰が押さえているか」を返しません。返すのは空席 / 確保中 / 販売済みの 3 つだけで、「自分の席」はフロントが自分のカートと突き合わせて出しています。
試合のページは、開いている間 15 秒ごとにデータを取り直して、他の人の動きを反映します。
カートは「pending の注文」
カート専用のテーブルは作らず、注文(orders)の 1 行をカートとして使っています。
| テーブル | 1 行が表すもの | 主な状態 |
|---|---|---|
orders |
カート兼注文 |
pending(カート)→ paid / expired
|
tickets |
席 1 つ分(自由席なら 1 人分) |
held(仮押さえ)→ paid / released / expired
|
order_items |
注文とチケットを結ぶ。券種と価格を持つ | — |
カートは「ユーザー × 試合」で 1 つだけです。部分ユニーク索引で、pending の行が 2 つできないようにしています。
CREATE UNIQUE INDEX orders_one_pending_per_user_match
ON orders (user_id, match_id) WHERE status = 'pending';
状態はこう移ります。
orders(カート兼注文):
tickets(席 1 つ分):
図は一次販売の流れだけです。リセールと年パスでは注文とチケットを最初から paid で作り、リセールで売れた元のチケットは resold になります。
期限が切れた orders は終わりで、次にカートに入れると新しい行ができます。
held のまま期限を過ぎた行も、書き換えられる前から「期限切れ」として扱います。これが次の節の話です。
カートを注文の行にしたのは、細部を任せた Claude Code の判断です。読み解くと、確定は状態を paid に書き換えるだけで済み、買われなかったカートも expired の行として残るので後から追える、という利点があります。代わりに、注文を数えるときは毎回 paid で絞る必要があります。
仮押さえは 10 分、席を足しても延びない
カートに最初の 1 枚を入れたときに、期限(orders.expires_at)を「今から 10 分」に決め、各チケットにもコピーします。
if ($heldCount === 0) {
$cart->expires_at = $now->addMinutes((int) config('ticketing.hold_minutes'));
$cart->save();
}
期限を付け直すのは、カートの中に有効な仮押さえが 1 つも無いときだけです。席を足すたびに延びると、少しずつ足し続けて席を持ち続けられてしまいます。
ほかの上限:
- 1 カート 6 枚まで
- カートの操作と決済は、リセールの操作と合わせて 1 人 1 分に 60 回まで
期限は時刻の条件で守り、バッチは片付けるだけ
ここがこの回の中心です。
仮押さえの期限が切れたら、チケットの状態を held から expired に書き換えます。
この書き換えは毎分のバッチ(tickets:expire-holds)がやっていますが、正しさはバッチに頼っていません。
もし「席を使っているか」を状態(held かどうか)だけで判定し、書き換えをバッチに任せると、期限が切れてから次のバッチが走るまで(毎分なら最大 1 分、バッチが遅れればそれ以上)、席は held のままです。
その間、他の人はその席を買えず、期限が切れた人は決済できてしまいます。
そこで、「席を使っているか」の判定は、毎回時刻で行います。
public function scopeConsumingInventory(Builder $q, CarbonImmutable $now): void
{
$q->where(function (Builder $w) use ($now) {
$w->where('status', TicketStatus::Paid->value)
->orWhere(function (Builder $h) use ($now) {
$h->where('status', TicketStatus::Held->value)
->where('expires_at', '>', $now);
});
});
}
「販売済み」か「期限内の仮押さえ」だけが席を使っている、という条件です。
残席の計算、席の状態の表示、カートの合計はこの条件で数えます。決済のときは、同じ条件を裏返して「held で期限が今以前なら期限切れ」と直接比べています。境界(期限ちょうどは期限切れ)はどこも同じです。
バッチが遅れても止まっても、期限を過ぎた仮押さえは誰の邪魔もしません。バッチは、状態を見た目どおりに片付ける係です。
バッチは、期限切れのカートを 1 件ずつ FOR UPDATE SKIP LOCKED で取って書き換えます。
ちょうど決済中でロックされているカートは飛ばすので、決済の処理とぶつかりません。
「今」はロックを取った後に取る
もう 1 つ、細かいけれど効く決まりがあります。判定に使う「今」の時刻は、ロックを取った後に取ります。
$allocations = $this->locks->forPurchase($order->match_id, $blocks);
// allocation のロック待ちの間に期限が過ぎうるので、時刻はロック取得後に取る
$now = CarbonImmutable::now();
if ($order->expires_at !== null && $order->expires_at <= $now) {
$this->holdExpiry->expireCart($order->id, $now);
// ...
}
処理の最初に「今」を取ると、ロックを待っている間に期限が過ぎても、その仮押さえを期限内として扱ってしまいます。
混んでいるときほどロック待ちは長くなるので、ちょうど危ない場面で起きる穴です。
カートの操作では、カートのロック直後に取った「今」を最後まで使います。途中で進めると、期限切れの仮押さえを残したまま期限だけ延ばしてしまうためです。
バッチは、遅れることもあれば、何かの理由で止まることもあります。それを正しさの前提にすると、遅れた分だけ席が売れなくなり、止まれば全部が止まります。
だから毎回、時刻で判定する。バッチは止まっても困らない片付けに回す、という分け方です。
二重販売: 指定席はユニーク索引、自由席は行ロック
同じ席を 2 人に売らない仕組みは、指定席と自由席で違います。
| 指定席 | 自由席 | |
|---|---|---|
| 守るもの | 同じ試合の同じ席は 1 枚 | ブロックの定員(250 人)を超えない |
| 仕組み | 部分ユニーク索引 | 割当の行を FOR UPDATE でロックして数える |
| 負けた側 | 409 seat_unavailable
|
409 sold_out
|
指定席: 索引に守らせる
CREATE UNIQUE INDEX tickets_active_seat_unique ON tickets (match_id, seat_id)
WHERE seat_id IS NOT NULL AND status NOT IN ('released','expired','resold');
外した席(released)と期限切れ(expired)の行は、索引の対象から外れます。同じ席をまた押さえられるようにするためです(resold はリセールの分)。
仮押さえ(held)と販売済み(paid)は同じ索引に入るので、1 本の索引で両方を守れます。
席を押さえるときは、ぶつかったら何もしない INSERT を投げます。Laravel の insertOrIgnoreReturning は、PostgreSQL では ON CONFLICT DO NOTHING RETURNING id になります。
$this->holdExpiry->releaseStaleSeat($match->id, $seat->id, $now);
$rows = DB::table('tickets')->insertOrIgnoreReturning([
'match_id' => $match->id,
'seat_id' => $seat->id,
'status' => TicketStatus::Held->value,
'expires_at' => $expiresAt,
// ...
], ['id']);
if ($rows->isEmpty()) {
throw PurchaseException::seatUnavailable();
}
返りが空なら、もう誰かが持っている席です。
2 人がまったく同時に INSERT しても、後の側は先の側のトランザクションが終わるのを待ってから判定されます(先の側がロールバックすれば後の側が取れます)。両方が成功することはありません(PostgreSQL のドキュメント)。
1 行目の releaseStaleSeat には理由があります。
索引の条件(WHERE)には now() のような時刻を書けません。そのため、期限切れでもまだ held のままの行は、索引を占めたままです。
押さえる直前に、同じ席の期限切れの行を expired に書き換えてから INSERT しています。
自由席: 行をロックして数える
自由席には席の行が無いので、ユニーク索引が使えません。
代わりに、試合 × ブロックの割当の行(1 回目の match_block_allocations)を FOR UPDATE でロックして、順番に 1 人ずつ数えます。
$allocation = $this->locks->forPurchase($match->id, [$block])[$block->id];
// ...
$consumed = Ticket::where('match_id', $match->id)
->where('block_id', $block->id)
->consumingInventory($now)
->count();
if ($consumed + $count > $block->capacity) {
throw PurchaseException::soldOut();
}
ここでも、数えるのは先ほどの「席を使っている」条件です。期限切れの仮押さえは数に入りません。
足りなければ 1 枚も作らずに 409 を返します。
カートの操作、確定、管理画面の割当の変更では、ロックを取る順番(カート → 割当 → チケット)をそろえています。順番がばらばらだと、お互いのロックを待ち合って止まる(デッドロック)からです。
管理画面の割当の変更とぶつかったときの扱いは、次の回で書きます。
決済(モック)と確定
決済はまだモックです。環境変数 TICKETING_MOCK_PAYMENT_OUTCOME が success(既定)なら成功、それ以外なら失敗を返します。
確定(checkout)は、1 つのトランザクションの中でこう進みます。
結果ごとの扱い:
-
成功: チケットと注文を
paidにする -
決済が失敗: トランザクション全体を巻き戻す。カートは
pending、チケットはheldのまま残るので、期限内なら同じ画面でやり直せる -
期限切れ: カートを期限切れにして、それをコミットしてから 409
cart_expiredを返す。例外をトランザクションの中で投げると、期限切れへの書き換えまで巻き戻ってしまうので、外で投げている -
二度押し: すでに
paidの注文は、同じ結果をそのまま返す。購入のイベントも増えない
一部の席だけ確定することはしません。6 枚のうち 1 枚でも期限が切れていたら、全体を期限切れにします。
残している課題
レビューで指摘されて、まだ直していないものがあります。
- 取り直しで占有し続けられる: 期限が切れた直後に同じ席を取り直す、あるいは全部外して取り直すと、新しい 10 分が始まる。1 人が席を持ち続けられる。リセールには、同じ出品を 30 分は押さえ直せない制限を入れたが、一次販売にはまだ無い
- 決済の冪等キーが無い: 確定のトランザクションは、デッドロックなどでぶつかると自動でやり直す(最大 3 回まで実行する)。決済の呼び出しもその中にあるので、モックなら問題ないが、本物の決済だと 1 回の確定で二重、三重に請求しうる。注文の id を冪等キーとして決済側に渡す形にする必要がある
- 仮押さえの後に価格が変わる: 確定のときに価格を計算し直すので、画面の金額と請求額がずれうる
本物の決済を入れる予定は無いので、どれもすぐには直しません。遊び場なので、「こういう穴がある」と分かった状態で残しておくこと自体に意味があると思っています。
数字
開発用の DB には、合成データで注文 91.9 万件、チケット 155.5 万件が入っています。
ただし合成データは確定した注文しか作らないので、仮押さえ中や期限切れの行は 0 件です。カゴ落ちは、行動ログ(カートに入れた / 買った)の差でしか見えません。これは合成データの回で扱います。
購入フローのテストは、カートが 41 件、確定が 12 件あります(座席の見せ方などを含めると 70 件。すべて通っています)。そのうち期限の境界(期限の 1 秒前は使用中、ちょうどで空席)や、「今」をロックの後に取っているかを確かめるテストの作りは、次の回で書きます。
まとめ
- 座席図は、ブロックの位置を SVG に並べて、割当で塗り、残席で濃さを変えるだけ
- カートは
pendingの注文。仮押さえは 10 分で、席を足しても延びない - 期限の正しさは「販売済みか、期限内の仮押さえか」という時刻の条件で毎回判定する。毎分のバッチは片付けるだけ。「今」はロックを取った後に取る
- 二重販売は、指定席は部分ユニーク索引、自由席は割当の行を
FOR UPDATEでロックして数えて防ぐ - 決済の失敗は全体を巻き戻し、期限切れは書き換えをコミットしてから返す
次回は、管理画面の割当エディタと、購入とぶつかったときの扱い、それを確かめるテストの仕組みです。
シリーズの他の回:
- (0)ゴール裏年パス勢が、架空スタジアムのチケットサイトを作る理由
- (1)スタジアムの座席をどうテーブルにするか
- (2)コンテナの中の localhost は届かない
- (4)管理画面で座席の割当を変えるとき、購入とどう直列化するか
番外編:
- (番外 1)Claude Code をサブエージェントで分業させ、仕組みは台帳で育てる
- (番外 2)
.gitignoreでは履歴から消えない ― リポジトリを履歴ごと public にする前と後にやった 7 つのこと
この記事の骨格は Claude Code に作らせ、自分の言葉で書き直しています。記事中のコードと数値は、手元のリポジトリで確かめています。