スタジアムの座席は 60,000 席です。大規模なツアーが発売されると、何百万人もが同じ 1 分間に押し寄せ、全員が同じ数千枚の良席を求めます。2022 年 11 月、Ticketmaster は Eras Tour の先行販売中に合計 35 億件のシステムリクエストがあったと報告しました。これは従来のピークの 4 倍です。60,000 行を保存するのは簡単です。難しいのは、その行をめぐって競合する何百万ものリクエストを、公平に、しかも同じ座席を決して 2 回売ることなくさばくことです。
この記事では、そうした発売開始に向けたチケット予約システムを設計します。要件、トラフィックの形、仮想待合室、期限付きの座席仮押さえ、予約トランザクション、決済、人気席、ボットを、アーキテクチャ図とともに扱います。
要件
機能要件:
- イベントと、空席状況付きの座席表を閲覧できること。
- 座席を選び、支払いの間それを仮押さえできること。
- 支払いを行ってチケットを受け取り、支払いが行われなければ座席を解放すること。
非機能要件:
- 同じ座席を決して 2 回売らないこと。これだけは曲げられない唯一のルールです。
- 人々を公平に扱うこと。最も速く再読み込みした人ではなく、到着順か、公表された抽選で決めます。
- 発売開始直後の数分間、通常より 1〜2 桁多いトラフィックに耐えること。
- 素早く、はっきりと失敗すること。すぐに返る「完売」は、回り続ける読み込み画面に勝ります。
発売開始のトラフィックの形
座席が 60,000 席、開始から 10 分以内に 200 万人が到着すると仮定します。毎秒約 3,300 人の新規到着で、1 席あたり 30 人以上です。どれだけ容量を増やしても、全員にチケットは行き渡りません。システムの本当の仕事は、持続可能な人数だけに同時に購入させ、残りの人には素早く断り、極端な競合のもとでも在庫を正しく保つことです。
そこで設計は 2 つに分かれます。群衆を受け止めるゲートと、処理できる数の購入者しか見ない予約コアです。
仮想待合室
予約フローの前にキューを置きます。
- 開始前に到着したファンには、早く来た全員の中でランダムな順番を割り当てます。こうすれば 9:59 にタブを 100 個開いても意味がありません。その後に到着した人は順番に並びます。
- 待合室は CDN から配信する静的ページなので、何百万ものブラウザーが待っていてもコストはほとんどかかりません。
- ファンは、計測した予約処理能力に合わせたペース、たとえば毎分 2,000 人で、まとめて入場させます。
- 入場したファンには、セッションに結び付いた、署名付きで短命なトークンを発行します。予約 API は、有効なトークンのないリクエストを拒否します。
Cloudflare Waiting Room や Queue-it といったマネージド製品がこれを提供しています。自前で作るなら、到着時刻をスコアにした Redis のソート済みセットを使い、順番はメンバーのランクになります。
ZADD queue:evt42 1793606400123 session:9f2c # score = arrival time in ms
ZRANK queue:evt42 session:9f2c # position, 0-based
ZPOPMIN queue:evt42 2000 # admit the next batch
早期到着者のグループには、到着時刻の代わりにランダムなスコアを割り当てます。いずれにしても、キューは上限のないスパイクを、システムの残りの部分が想定する一定の流れに変えます。
在庫モデル
指定席では、座席ごとに 1 行が必要です。
| 列 | 例 | 目的 |
|---|---|---|
event_id, seat_id |
evt42, S112-R7-14 |
識別子。組み合わせで一意 |
status |
available, held, sold |
現在の状態 |
hold_id, held_until |
h_81f, タイムスタンプ |
誰がいつまで仮押さえしているか |
order_id |
ord_5521 |
販売時に設定 |
自由席はもっと単純です。個々の座席の代わりに価格帯ごとのカウンターを持ち、条件付き更新で減らします。
期限付きの座席仮押さえ
仮押さえは、購入者が支払う間の数分間、その人のために座席を確保します。仮押さえを置く最も安全な場所は、販売を記録しているのと同じデータベースで、条件付き更新を使います。
UPDATE seats
SET status = 'held', hold_id = $3, held_until = now() + interval '10 minutes'
WHERE event_id = $1
AND seat_id = ANY($2)
AND (status = 'available' OR (status = 'held' AND held_until < now()));
-- If fewer rows changed than seats requested, roll back: someone else got one.
2 人の購入者が、同じ座席に対してこの文を同じミリ秒に送ることがあります。データベースはその行への更新を直列化し、一方が行を変更し、もう一方は変更行数 0 を受け取って「その座席はたった今取られました」という明確な応答を得ます。期限切れの仮押さえは同じ文の中で回収されるので、仮押さえの期限切れがバックグラウンドジョブの定時実行に依存することはありません。重なり合う座席の集合を求める 2 つのリクエストが、まれにデッドロックを起こすことがあります。データベースがそれを検出して一方を中止するので、デッドロックのエラーでは 1 回だけ再試行します。
非常に大規模な場合、前段に高速なフィルターを置くチームもあります。Redis の SET seat:evt42:S112-R7-14 h_81f NX PX 600000 は、キーが存在しない場合にだけ、10 分の有効期限付きでキーを設定します。データベースの負荷を減らせますが、Redis のレプリケーションは非同期なので、フェイルオーバーで仮押さえが失われることがあります。Redis は最適化として扱い、判断は引き続きデータベースが下します。
最後の防衛線は、チケットテーブルの (event_id, seat_id) に対する一意制約です。上のどの層にバグがあっても、データベースは 2 枚目のチケットの発行を拒否します。
人気席とベストアベイラブル
誰もが前列を欲しがるので、何千ものリクエストが同じ数百行に集中し、その大半は失敗します。競合を減らす手法は 3 つあります。
- ベストアベイラブル。 全員に座席表から選ばせるのではなく、購入者にはセクションと枚数を選んでもらい、
SELECT … FOR UPDATE SKIP LOCKEDを使ってサーバー側で座席を割り当てます。こうすれば、同時のリクエストは同じ行に並ぶ代わりに別々の行を取得します。 - 在庫をセクションごとに分割します。 そうすれば、セクションごとに別の行をロックし、別のデータベースシャードに置くこともできます。
- 座席表を短い有効期限でキャッシュし、数秒古い可能性を受け入れます。空席状況を決めるのは座席表ではなく、仮押さえです。
二重請求のない決済
- 仮押さえに紐付いた
pending状態の注文を作成します。 - 注文 ID から導いた冪等性キーを付けて決済プロバイダーを呼び出します。たとえば Stripe の冪等性キーは最大 255 文字で、同じキーで再試行したリクエストは、再度請求する代わりに最初の結果を返します。
- 成功したら、1 つのデータベーストランザクションの中で、座席を販売済みにし、注文に紐付け、注文を支払い済みにします。
- 支払いは成功したのにサービスが応答を受け取れなかったケースを捕捉するため、プロバイダーの Webhook を受け取ります。
仮押さえの期限切れが決済と競合してはいけません。決済が始まったら仮押さえを延長するか、保留中の注文がある座席を期限切れの対象から外します。そうしないと、座席が期限切れになって別の人に売られ、その後で最初の決済が成功してしまうことがあります。Webhook と照合については決済ゲートウェイ連携の図で詳しく扱っています。
期限切れの仮押さえを解放する
仮押さえは、上の held_until < now() 条件による遅延的な方法と、数秒ごとに実行されるスイーパーによる能動的な方法の両方で期限切れになります。スイーパーは期限切れの仮押さえを空席に戻し、座席表が追いつくようにします。期限切れの処理を Redis のキースペース通知の上に構築してはいけません。Redis は Pub/Sub を「ファイア・アンド・フォーゲット」と説明しており、接続が切れたサブスクライバーはイベントを取りこぼします。
ボットと不正利用
待合室が主な防御策です。速さを無意味にするからです。さらに、アカウントごとの購入上限、アカウント・IP・デバイスごとのレート制限、需要の高いイベントでの発売前登録、待合室でのチャレンジを追加します。制限そのものについてはレートリミッターのシステム設計で扱っています。
チケット予約の図
ダイアグラムの内容
- Fans
- Waiting room (static, on CDN)
- Queue positions + admissions
- API gateway: token check
- Booking service
- Seats, holds, orders
- Hold sweeper, every 5 s
- Payment provider
- order.confirmed events
- Ticket delivery
- arrive at 10:00
- ZADD / ZRANK
- batch + signed token
- admitted?
- hold seats, pay
- hold: conditional UPDATE, 10 min
- release expired holds
- charge + Idempotency-Key
- webhook
- order paid
- send tickets
図の読み方は次のとおりです。
- ファンは CDN 上の待合室に到着します。 待合室はファンを待たせ、署名付きトークンを付けてまとめて入場させます。
- API ゲートウェイと予約サービスには、入場したファンしか届きません。Redis は待ち行列の順番と入場状態を保存します。
- 座席は Postgres が保持します。 条件付き更新が仮押さえを作り、一意制約が販売を守ります。
- 決済プロバイダーは冪等性キー付きで呼び出され、その Webhook は予約サービスに戻ってきます。
- スイーパー関数が期限切れの仮押さえを解放し、注文キューがメールとアプリでのチケット配信に注文を送ります。
- Markdown ブロックは、発売開始の数値と仮押さえのルールを並べています。
レビュー担当者が競合状態を見つけられる予約システムの描き方
- ゲートはコアと分けて描きます。 待合室と予約サービスは、スケーリングのモデルも担当者も異なります。
- 仮押さえの時間と一意性のルールを図に書きます。 この 2 つの事実が設計を説明します。
- 冪等性キーは決済のエッジに書きます。 再試行が安全な理由はそれです。
- 期限切れの経路を示します。 座席が戻ってくる場所こそ、二重販売のバグが多く潜む場所です。
一般的な進め方はシステム設計図の例を参照してください。注文イベントはトランザクショナルアウトボックスパターンで発行でき、チケットのメールは通知システムの設計のようなパイプラインで送れます。
Archboard で作る
- Google または GitHub でサインインし、図を作成します。
- ファン用に C4 モデルパックの 人物 を、待合室用にクラウドパックの CDN ノードを追加します。
- 予約サービス用に、クラウドパックの APIゲートウェイ と コンピューティング ノードを追加します。
- 待ち行列の状態用にデータパックの Redis ノードを、座席・仮押さえ・注文用に Postgres ノードを追加します。
- 決済プロバイダー用に C4 モデルパックの 外部システム、仮押さえのスイーパー用にクラウドパックの 関数、注文イベント用に キュー、チケット配信用にもう 1 つの コンピューティング ノードを追加します。
- エッジにトークン、仮押さえのルール、冪等性キーのラベルを付け、数値を書いた基本パックの Markdown ブロックを追加します。
この例で使うダイアグラムノードは 11 個で、Free プランの範囲に収まります。
よくある質問
チケット販売システムはどうやって二重予約を防ぐのですか? 座席の行に対する条件付き更新で、空席から仮押さえに移せるリクエストを 1 つだけにし、最後の安全策としてチケットテーブルに一意制約を置きます。キャッシュやキューは負荷を減らしますが、判断するのはデータベースです。
座席の仮押さえはどのくらいの長さにすべきですか? 余裕を持って支払える程度に長く、放棄された座席がすぐ戻る程度に短くします。5〜10 分がよくある範囲です。決済が始まったら仮押さえを延長し、期限切れが成功した請求と競合しないようにします。
仮想待合室とは何ですか? 予約フローの前に置くキューで、システムが処理できるペースでユーザーを入場させます。通常は CDN 上の静的ページで、早期到着者にはランダムに、それ以降は到着順に順番を割り当てます。
座席の仮押さえは Redis とデータベースのどちらに置くべきですか? 信頼できる情報源はデータベースにすべきです。Redis のレプリケーションは非同期で、フェイルオーバーでキーが失われることがあるからです。データベースが引き続き一意性を保証している限り、Redis は前段の高速なフィルターとしてうまく機能します。
仮押さえの期限切れ後に成功した決済はどう扱えばよいですか? 決済中は仮押さえを延長して、そもそも起こらないようにします。それでも起きた場合は、自動で返金するか同等の座席を提案し、プロバイダーの Webhook でそのケースを検出します。