管理画面で座席の割当を変えるとき、購入とどう直列化するか ― 架空サカスタのチケットサイトで学ぶ(4)
はじめに
架空のサッカースタジアム「ペヌソニックスタジアム(通称ペヌスタ)」のチケット販売サイトを、遊び場として作っています。
なぜ作っているのか、全体がどうなっているのかは紹介回に書きました。
ゴール裏年パス勢が、架空スタジアムのチケットサイトを作る理由 ― 架空サカスタのチケットサイトで学ぶ(0)
https://qiita.com/macchi23/items/5b9f5670c841f5c41be8
1 回目で、試合ごとにブロックの割当(ホーム / アウェー / 緩衝帯 / 閉鎖)を持たせました。3 回目では、その割当を見て席を売る流れを書きました。
この記事では、その割当を販売が始まった後に管理画面から変えるときの話をします。
最初の割当は、過去の動員から決めた見込みにすぎません。売れ行きが見込みと違えば、アウェー席を広げる・狭める、緩衝帯を足す、といった調整が要ります。
割当を変えると、2 つの相手とぶつかります。
- 購入: 管理者がブロックを緩衝帯にしたのと同じ瞬間に、誰かがそのブロックの席をカートに入れていたらどうなるか
- ほかの管理者: 2 人が同じ試合の画面を開いて、同じブロックを別の色に塗ったらどうなるか
後半では、こうした「ぶつかったとき」をテストでどう再現しているかと、Playwright の E2E、検証を 1 コマンドにまとめた scripts/check.sh を書きます。
仮押さえの期限、指定席の部分ユニーク索引、自由席の行ロックは 3 回目に書いたので、ここでは前提として使います。
割当エディタ: 塗って、差分だけを送る
管理画面では、座席図の上でブラシ(ホーム / アウェー / 緩衝帯 / 閉鎖)を選び、ブロックをクリックして塗ります。保存すると、塗り替えたブロックだけを送ります。

割当エディタで S4 を緩衝帯に塗った直後。保存前の変更が右に並び、保存するとこの差分だけを送る。斜線はチケットのあるブロック(年パスの固定席)で、塗れない
送る中身は、ブロックごとに「画面に見えていた値」と「変えたい値」の組です。
{
"changes": [
{ "block_id": 44, "expected": "away", "allocation": "buffer" }
]
}
expected が、ほかの管理者とぶつかったことを見つける鍵です。サーバーは、ロックを取った後の現在値が expected と違えば、そのブロックを stale(古い画面を見ていた)として拒否します。
いわゆる楽観的な同時編集です。画面を開いている間ずっとロックを持つわけにはいかないので、保存の瞬間に「見ていた値のままか」だけを確かめます。
差分だけを送るので、触っていないブロックはほかの管理者の保存とぶつかりません。
競合したブロックは画面の下書きから外し、最新の値を表示して塗り直してもらいます。ほかの人の変更を見ないまま上書きさせないためです。
変えてよいかは、チケットの有無で決める
どのブロックなら変えてよいか。最初は「販売前と販売中で何を許すか」と、試合の状態で分けるつもりでした。
ところが、当時の試合の状態には「販売中」しか無く、販売前を区別できませんでした。
そこで、そのブロックに使用中のチケットが 1 枚でもあれば変更を拒否する、というルールにしています。販売前の試合にはチケットが無いので、そのまま自由に変えられます。
チケットがあるブロックの扱いは、「拒否」「仮押さえだけなら強制」「警告して強制」の 3 つから、拒否を選びました。
- 使用中のチケット = 購入済み(paid)か、期限内の仮押さえ(held)。3 回目と同じ判定を使い回している
- 期限切れの仮押さえは数えない
- キックオフ後の試合は読み取り専用
- 1 回の保存は、全件適用か全件拒否。1 ブロックでも引っかかれば何も変えずに 409 を返し、ブロックごとの理由(
stale/has_tickets)を並べる
売った席の属性を、後から変えないためです。ホームのつもりで買った席が、買った後にアウェーや緩衝帯になってはいけません。
割当はブロック単位の属性なので、「空いている席だけ緩衝帯にする」はできません。やるなら席単位で割当を持つ必要があり、そこまではしていません。
パナソニックスタジアム吹田(パナスタ)では、ゴール裏の上層がアウェーのサポーター用で、緩衝帯を挟んでカテゴリ 6 の自由席が続きます。カテゴリ 6 の自由席券はエリアのどこでも入れる券です。運営の実際は分かりませんが、アウェー側を広げるときは、そのブロックが売れているかを考えずに、ブロック単位で自由席を減らしてアウェーを増やしているのだろうと思います。
ペヌスタでは自由席の券をブロックに紐づけたので、1 枚でも売れたブロックは動かせません。実物に寄せるなら、自由席をエリア全体で売って、ブロックを動かすときは「残るエリアの定員が売れた枚数を下回らないか」で判定する形になりそうです。
なお、今の開発用データでは、未販売の試合でも、年パスの固定席があるブロックはチケットがあるので変えられません。
固定席が無いのは南スタンドの S1〜S9 の 9 ブロック(すべて自由席)だけで、1 試合 56 ブロックのうち変えられるのはこの 9 つです。通常の配置では、アウェー 4・緩衝帯 1・ホーム 4 にあたります。
購入とぶつかったら: 割当の行で直列化する
ここがこの回の本題です。
何をロックするか
購入と管理者が共通して触るのは、試合 × ブロックの割当の行(match_block_allocations)です。そこで、この行を両者の待ち合わせ場所にしています。
- チケットを新たに「使用中」にする経路(仮押さえ、確定、年パスの発行)は、その前に必ず割当の行をロックし、ロックして読んだ値で売ってよいかを判定する
- 管理者は、変えるブロックの割当の行を
FOR UPDATEでロックしてから、チケットを数える
| 経路 | 割当の行のロック | その後 |
|---|---|---|
| 管理者の保存 | 対象を block_id 昇順に FOR UPDATE(待つのは最大 3 秒) |
現在時刻を取る → キックオフ判定 → expected と比べる・チケットを数える → 更新 + 変更履歴 |
| 指定席の仮押さえ | FOR SHARE |
チケットを INSERT(部分ユニーク索引が最後の砦) |
| 自由席の仮押さえ | FOR UPDATE |
定員を数えて INSERT(3 回目) |
| 購入の確定 | 仮押さえのあるブロックだけ、block_id 昇順に 1 行ずつ、席の種類で決めたモードで | 現在時刻を取る → 期限判定 → チケットを FOR UPDATE → 割当の値で判定 → 決済 → paid に |
| 年パスの発行 | 対象のブロックを FOR UPDATE
|
固定席のチケットを作る |
PostgreSQL の行ロックのぶつかり方は、この 2 つについては次のとおりです(PostgreSQL 17 のドキュメント 13.3.2 の表から抜粋)。
| 握っている側 \ 取りに来る側 | FOR SHARE |
FOR UPDATE |
|---|---|---|
FOR SHARE |
通る | 待つ |
FOR UPDATE |
待つ | 待つ |
購入側のロックの入口は 1 か所にまとめています。
public function forPurchase(int $matchId, iterable $blocks): array
{
$sorted = [];
foreach ($blocks as $block) {
$sorted[$block->id] = $block;
}
ksort($sorted);
$allocations = [];
foreach ($sorted as $blockId => $block) {
$query = MatchBlockAllocation::where('match_id', $matchId)->where('block_id', $blockId);
$query = $block->seating_type === 'reserved' ? $query->sharedLock() : $query->lockForUpdate();
$allocations[$blockId] = $query->value('allocation') ?? 'closed';
}
return $allocations;
}
管理者はチケットの表をロックしない
管理者は、チケットの行をロックしていません。それでも見落としが起きないのは、次の 2 つが揃っているからです。
- ブロックの使用中のチケットを 0 枚から増やしうる経路(仮押さえ、確定、年パスの発行)は、割当の行のロックを持っている間にしかチケットを使用中にしない。だから管理者の
FOR UPDATEが取れた時点で、そのブロックのロックを持っていた購入は全部終わっている(コミットかロールバック済み)。まだロックを取っていない購入は、管理者のコミットを待つ - PostgreSQL の既定の分離レベル READ COMMITTED では、文ごとにスナップショットを取り直す。ロックの後に流す COUNT には、直前にコミットされたチケットも入る
リセールの確定は割当の行をロックしません。ただ、元の券を「転売済み」にして同じ席の新しい券を作るだけなので、そのブロックの使用中の枚数は 1 枚以上のまま変わらず、管理者の判定は崩れません。
図にすると、こうなります。
逆に、管理者が先にロックを取った場合は、購入側が待ちます。管理者が緩衝帯に変えてコミットすると、待っていた購入側の FOR SHARE は更新後の行を返します。
READ COMMITTED で、ロック待ちの間に相手がコミットした行は、新しい版で読み直されるからです(PostgreSQL 17 のドキュメント 13.2.1)。購入側は「緩衝帯」を読んで、販売対象外としてはじきます。
指定席は SHARE、自由席は UPDATE
指定席を FOR SHARE にしているのは、購入同士を待たせないためです。
指定席の二重販売は部分ユニーク索引が防ぐので(3 回目)、同じブロックの別の席を買う人同士は並行してよい。一方、確定の処理は決済の呼び出しの間もロックを持っています。ここを FOR UPDATE にすると、同じブロックの指定席の購入が、前の人の決済待ちで一列に並んでしまいます。
自由席は、定員を数えてから INSERT するので、購入同士も直列にする必要があります。だから FOR UPDATE です。
デッドロックを避ける 2 つの決まり
- ロックは block_id の昇順で取る。管理者も購入側も同じ順にすれば、互いに相手の持っている行を待つ形にならない
-
同じトランザクションで
FOR SHAREからFOR UPDATEに上げない。2 人が同じ行を SHARE で握ったまま、それぞれ UPDATE に上げようとすると、互いを待ち合う
どちらも、PostgreSQL のドキュメント(13.3.4)にある「一貫した順序でロックを取る」「最初に、必要な一番強いモードを取る」をそのまま守っている形です。
モードは席の種類(指定席 / 自由席)で決め打ちにしています。席の種類は変わらないので、途中でモードが変わることがありません。
待つのは管理者
一次販売の購入側にはロック待ちの上限を掛けていません。管理者側だけ、SET LOCAL lock_timeout で 3 秒を上限にしています。
3 秒待っても取れなければ、409 allocation_busy(「購入処理と競合しました。しばらくしてからやり直してください。」)を返します。
管理者の保存は数ブロック分の更新ですぐに終わるので、3 秒待って取れないのはよほど混んでいるときだけです。そのときは管理者が引くほうが、全体として丸く収まると考えました。
管理者側の処理の前半です(キックオフの判定と、ブロックの存在確認は省略)。
DB::statement("SET LOCAL lock_timeout = '{$timeout}ms'");
$blockIds = array_column($changes, 'block_id');
$rows = MatchBlockAllocation::where('match_id', $match->id)
->whereIn('block_id', $blockIds)
->orderBy('block_id')
->lockForUpdate()
->get()
->keyBy('block_id');
$now = CarbonImmutable::now();
// (キックオフ後なら 409 match_locked)
$counts = $this->ticketCounts($match, $now, $blockIds);
SET LOCAL はトランザクションが終わると元に戻ります。そのため、この処理はトランザクションの一番外側でしか呼べないようにしています。外側に別のトランザクションがあると、3 秒の上限がその後の処理にまで残ってしまうからです。
また入れ子の中では、Laravel はデッドロックでもやり直さず、外側のトランザクションごと失敗させます。
Laravel の DB::transaction(..., 3) は、デッドロックなど並行性のエラーのときだけ自動でやり直します。ロック待ちのタイムアウト(SQLSTATE 55P03)はやり直しの対象外なので、サービスの側で捕まえて allocation_busy に変えています。
購入側で万一ロック待ちのエラーやデッドロックが表に出たときは、503 busy を返し、SQL の中身はレスポンスに出しません。
変更履歴は、同じトランザクションで書く
1 回目で「同じ試合の中での変更の経緯は、別に履歴を持たないと消える。管理画面の回で扱う」と書きました。その履歴が match_block_allocation_changes です。
1 ブロックの変更につき 1 行を、割当の更新と同じトランザクションで書きます。
| changed_at(UTC) | 試合 | ブロック | from | to | change_set_id |
|---|---|---|---|---|---|
| 2026-10-17 11:08:35 | 114 | S4 | away | buffer | f39555d1-… |
- 同じトランザクションで書く: 割当だけ変わって履歴が残らない、あるいは履歴だけ残って割当が変わっていない、という状態を作らない
- 外部キーは削除を拒否する(restrict): 試合や管理者のユーザーを消しても、履歴は消えない。先に履歴をどうするか決めないと消せない
- 行動ログ(events)には載せない: events は利用者の行動(閲覧、カートに入れる、購入)のログで、合成データと粒度を揃えている。管理者の操作を混ぜると、分析のときにいちいち除外が要る
1 回の保存で変えた複数ブロックは、同じ change_set_id でまとめます。
同時実行をテストで再現する
ここまでの「ぶつかったとき」の話は、正しく動いているかを手で確かめるのが難しいところです。
3 回目で書いた購入の流れを作ったときは、開発環境で同じ席に 8 本の仮押さえ(カートに入れる)リクエストを並列に投げ、成功が 1 本だけになることを 1 回確かめました。ただ、毎回これをやるわけにはいきません。
そこで PHPUnit では、並列のプロセスを走らせず、1 つのテストの中で「ぶつかった瞬間」を決まった順番で作ることにしています。手は 4 つあります(1 と 2 は DB の接続を 2 本使う)。
1. 別の接続でロックを握っておく
テスト用の別接続(probe)でトランザクションを開き、割当の行を握ったままにします。
本来の接続には lock_timeout = 200ms を掛けておき、処理がロック待ちのタイムアウト(55P03)で落ちれば「待たされた」、通れば「待たされなかった」と判定します。
/** 別接続でトランザクションを開き、allocation 行を指定モードで握ったままにする。 */
private function probeLock(Block $block, string $mode): void
{
$probe = $this->probeConnection();
$probe->beginTransaction();
$probe->select(
"select id from match_block_allocations where match_id = ? and block_id = ? {$mode}",
[$this->match->id, $block->id],
);
}
public function test_admin_style_update_lock_blocks_reserved_add_item(): void
{
$this->probeLock($this->reserved, 'for update');
$this->shortLockTimeout();
$this->assertLockNotAvailable(fn () => $this->addSeat(1));
$this->assertSame(0, Ticket::count());
}
public function test_share_lock_by_other_buyer_does_not_block_reserved_add_item(): void
{
$this->probeLock($this->reserved, 'for share');
$this->shortLockTimeout();
$change = $this->addSeat(2);
$this->assertTrue($change->changed);
$this->assertSame(1, Ticket::count());
}
管理者が FOR UPDATE を握っていれば指定席の仮押さえは待たされ、ほかの購入者が FOR SHARE を握っているだけなら通る。上の表のとおりに動くことを、組み合わせごとに確かめています。
2. ロックを取る直前に、別の接続でコミットする
Laravel の接続には、SQL を送る直前に呼ばれるフック(beforeExecuting)があります。これを使って、「本来の接続がロックのクエリを送る直前に、別の接続がチケットを INSERT してコミットした」状況を作ります。
public function test_ticket_committed_just_before_lock_is_detected(): void
{
$fired = false;
DB::connection()->beforeExecuting(function ($query) use (&$fired) {
$sql = strtolower($query);
if ($fired || ! str_contains($sql, 'match_block_allocations') || ! str_contains($sql, 'for update')) {
return;
}
$fired = true;
$this->probeConnection()->table('tickets')->insert([/* paid のチケット 1 枚 */]);
});
$e = $this->conflict([$this->change($this->reserved, 'home', 'buffer')]);
$this->assertTrue($fired);
$this->assertSame('has_tickets', $e->conflicts[0]['reason']);
}
「チケットの表はロックしない」で書いた、ロックの後の COUNT に直前のコミットが入ることを、このテストで確かめています。
購入側にも同じ形のテストがあり、ロックの直前に別の接続が割当を緩衝帯に変えてコミットすると、購入が販売対象外ではじかれることを見ています。
3. 流れた SQL の順番を見る
DB::listen で、テスト中に流れた SQL を記録します。「割当の行のロック → チケットの INSERT」の順になっているか、ロックが block_id の昇順か、モードが席の種類どおりかを確かめます。
このうち自由席の一括変更の順番のテストは、レビューで見つかった不具合を受けて足したものです。減らす券種と増やす券種を同時に指定すると、チケットの UPDATE が割当のロックより先に流れていました。ほかのテストは全部通っていて、セキュリティレビューのサブエージェントが見つけました。
4. ロックの直後に時刻を進める
3 回目で「期限の境界や、『今』をロックの後に取っているかを確かめるテストの作りは次の回で」と書いた分です。
時刻は Laravel の travelTo で固定します。仮押さえの期限が 00:10:00 なら、00:09:59 に固定すれば「まだ期限内」、00:10:00 に進めれば「ちょうど期限切れ」です。
そのうえで DB::listen を使い、最初のロックのクエリが流れた直後に時刻を期限の後へ進めます。ロックを待っている間に期限が過ぎた状況です。
/** 最初の `for update` クエリの直後に、時刻を期限後へ 1 回だけ進める(ロック待ちの間に期限が過ぎた状況)。 */
private function expireAfterFirstLock(): void
{
$fired = false;
DB::listen(function ($query) use (&$fired) {
if (! $fired && str_contains(strtolower($query->sql), 'for update')) {
$fired = true;
$this->travelTo(Carbon::parse('2026-06-01 00:10:00', 'UTC'));
}
});
}
「今」をロックの前に取っていると、期限内として扱ってしまい、テストが落ちます。
「今」の取り方では、レビューの修正が別の決まりを壊したこともあります。
レビューで「ロック待ちの後の古い『今』で判定すると、作った時点で期限切れの仮押さえができうる」と指摘され、案の 1 つだった「割当のロックの後に『今』を取り直す」で直しました。テストは全部通りましたが、再レビューで、カートの中の仮押さえの期限がばらばらになると指摘されて撤回しました。
カートの期限の判定は、その前のカートのロックの直後の時刻で済んでいて、そこで時刻を進めると「期限切れの仮押さえを残したまま、新しい分の期限だけ延ばす」ことになるからです。
撤回したときに、ロックをまたいでも期限が 1 種類のままであることを確かめるテストを足しています。
並列で投げるテストは、通っても「たまたまぶつからなかった」のか分かりません。順番を決めて作れば、毎回同じところでぶつかる、というのが一番の利点だと思いました。
後片付け
Laravel のテストでよく使う RefreshDatabase は、各テストを 1 つのトランザクションの中で走らせ、終わりに巻き戻します。この方式では、別の接続からテストのデータが見えません。
そこで、接続を 2 本使うテストだけ DatabaseTruncation(テーブルを空にする方式)を使っています。
ここで 1 つ落とし穴がありました。DatabaseTruncation はテストの最初にテーブルを空にするので、最後のテストのデータが残り、後に走る RefreshDatabase のテストの件数が狂います。テストの終わりにも自分で空にしています。
この別接続の仕組みは、後から作ったリセールと年パスの発行の同時実行テストでも使い回しています。
画面ごと確かめる: Playwright の E2E
PHPUnit は API とサービスの単位です。画面で塗って保存できるか、一般利用者の画面で本当に買えなくなるかは、Playwright の E2E で確かめています。
管理画面の E2E 9 本のうち、割当エディタの 5 本を順番に流します(残り 4 本は、画面上部のユーザー切替からの 403 / 401 と、打ち間違いの URL の 404)。
- 管理者でない人には 403、未選択なら 401 のページ
- 自由席のブロックを緩衝帯に塗って保存できる
- 緩衝帯にしたブロックは、一般利用者の画面で選べず、API でも 422 になる
- ホームに戻すと仮押さえでき、仮押さえが入ったブロックは塗れなくなる(ロック表示)
- ほかの管理者が先に変えていると、競合として表示される
5 本目では、画面で塗っている間に、API から(別の管理者の操作に見立てて)同じブロックを変えておき、保存したときに競合の表示が出ることを見ています。
// その間に別の管理者が other を変更する(元の割当と違う値にして、確実に競合させる)
const conflicting = other.allocation === 'buffer' ? 'closed' : 'buffer';
const res = await patchAllocations(adminId, matchId, [{ block_id: other.id, expected: other.allocation, allocation: conflicting }]);
expect(res.status).toBe(200);
await page.getByRole('button', { name: '保存', exact: true }).click();
await expect(page.getByText('競合したブロックがあり保存できませんでした', { exact: false })).toBeVisible();
await expect(page.getByText(`${other.code}: 他の管理者が変更済み(現在: ${ALLOCATION_LABELS[conflicting]})`)).toBeVisible();
開発用の DB に対して走らせる
E2E 専用の DB は用意していません。起動中の開発環境(開発用の DB)に対して走らせています。
E2E 専用の DB に向けるには、app / web / frontend のコンテナをもう 1 組立て、DB の作成・シード・合成データの生成も別に回す必要がありました。最初の 2 本を書いた時点では割に合わないと判断し、開発用の DB をそのまま使っています。
その代わり、いくつか決まりを置いています。
- 対象はその時のデータから選ぶ: 試合やブロックの ID を決め打ちせず、「今、販売中で、変えられる自由席が 2 つ以上ある試合」を API で探す
-
終わったら戻す:
afterAllで、カートと割当を開始時の値に戻す -
順番に流す: 塗る → 買えない → 戻す、と前のテストの結果に依存するので、
test.describe.serialで 1 本ずつ流す。1 本落ちたら残りは流さない。Playwright のドキュメントでは、テストは独立させるのがよく、serial は勧められていません。ここでは手順そのものを確かめたいので使っています -
色で判定しない: ブロックの色ではなく、
data-allocationやdata-lockedの属性を見る
それでも、データの変化で何度か落ちました。
- 年パスの固定席を発行したら、固定席のあるブロックが「チケットあり」でロックされ、「ロックされたブロックが 1 つも無い試合」を探す選び方では対象が無くなった(ロックされていない自由席から選ぶように変えた)
- 日付が進んでファンクラブ先行の期間に入り、非会員で買う E2E が落ちた(今のフェーズで買える利用者を選ぶようにした)
- 開発用の DB にマイグレーションを当て忘れたまま流し、利用者一覧の API が 500 になって既存の 3 本が落ちた
もう 1 つ、この記事を書きながら気づいたことがあります。開発用のデータでは、2026-12-06 14:00 から 2027-01-16 14:00(JST)までは、一般販売中の試合が 1 つもありません。この間は E2E 全体が対象を見つけられずに落ちます。
期間そのものは直さず、落ちるときに「なぜ回せないか、次に回せるのはいつか」を出すようにしました。
一般販売中の試合が無い期間なので E2E は回せない(開発 DB の日付に依存する)。次の一般販売開始は 2027-01-16 14:00 JST(試合 115)
データが動くたびに落ちるのは面倒ですが、落ちるたびに「今のデータで何が起きるか」が分かるので、遊び場としては悪くないと思っています。本番に近いものを作るなら、E2E 専用の DB と固定のデータを用意すると思います。
1 回流すと、開発用の DB に購入済みのチケットが 4 枚、割当の変更履歴が 5 行増えます。行動ログの側は、ほとんどを E2E が使うセッション ID で除外できます(未ログインで座席図を見る 1 本だけは、ふつうのセッション ID で閲覧が 2 件残る)。
1 コマンドで回す: scripts/check.sh
ここまでの検証は、scripts/check.sh 1 本から呼べるようにしています。
scripts/check.sh # all: backend → frontend → shell → secrets
scripts/check.sh backend # pint → テスト用 DB の用意 → phpunit
scripts/check.sh e2e # Playwright(ホストで実行)
| 対象 | 中身 |
|---|---|
| backend | コード整形の検査(pint)、テスト用 DB の用意、PHPUnit |
| frontend | ESLint、ルートの型の生成、型検査 |
| shell | スクリプトの構文、ShellCheck、bash 3.2 の落とし穴の検査 |
| secrets | 秘密情報の検査(gitleaks) |
| e2e | Playwright。all には入れない |
作りで決めたことは 3 つです。
-
途中で止めない:
set -eを使わず、各段の成否を集めて最後に一覧を出す。1 回流せば、落ちている段が全部分かる -
テストは開発用の DB と分ける: PHPUnit は別の DB(
penusta_test)で走らせる。無ければ作る -
E2E は
allに入れない: 開発用の DB に書き込むので、気軽に流すallからは外している
frontend の型の生成、shell と secrets の段を入れた経緯は、番外編で Claude Code の運用と合わせて書きました。
Claude Code をサブエージェントで分業させ、仕組みは台帳で育てる ― 架空サカスタのチケットサイトで学ぶ(番外 1)
https://qiita.com/macchi23/items/f321a699d61b38cd7636
数字
2026-11-01 に手元で流した結果です。
| 項目 | 値 |
|---|---|
| PHPUnit のテスト(全体) | 439 件(アサーション 35,350) |
| そのうち割当エディタとロックまわり | 56 件(購入側のロック 15、管理者側 17、管理 API 11、エラーの変換 6、権限 7) |
scripts/check.sh(all、11 段) |
約 1 分 53 秒 |
| E2E | 6 ファイル 15 本(管理画面 9 本)、約 20 秒 |
| 管理者のロック待ちの上限 / テストでの上限 | 3 秒 / 0.2 秒 |
| 割当の行 | 6,888 行(123 試合 × 56 ブロック) |
まとめ
- 割当エディタは差分だけを送り、ブロックごとの
expectedでほかの管理者との競合を見つける - 変えてよいかは、チケットの有無で決める。売った席の属性は後から変えない
- 購入と管理者は、割当の行のロックで直列化する。指定席は
FOR SHARE、自由席と管理者はFOR UPDATE。昇順に取り、モードを上げない - 管理者はチケットの表をロックしない。チケットを新たに使用中にする経路はロックを持つ間にしか作らず、READ COMMITTED の COUNT がコミット済みの分を拾う
- 待つのは管理者。3 秒で諦めて 409 を返す
- 同時実行のテストは、並列プロセスではなく、接続 2 本と SQL の前後のフックで、ぶつかる順番を決めて作る
次回は、行動ログを最初から記録する設計と、答えを仕込んだ合成データの話です。
シリーズの他の回:
- (0)ゴール裏年パス勢が、架空スタジアムのチケットサイトを作る理由
- (1)スタジアムの座席をどうテーブルにするか
- (2)コンテナの中の localhost は届かない
- (3)チケットの二重販売をどう防ぐか: 指定席はユニーク制約、自由席は行ロック
番外編:
- (番外 1)Claude Code をサブエージェントで分業させ、仕組みは台帳で育てる
- (番外 2)
.gitignoreでは履歴から消えない ― リポジトリを履歴ごと public にする前と後にやった 7 つのこと
この記事の骨格は Claude Code に作らせ、自分の言葉で書き直しています。記事中のコードと数値は、手元のリポジトリで確かめています。