コンサートチケット予約システムの設計: 数百万ユーザーのための仮想キュー

1. 概要

大規模なコンサートが10:00にチケット販売を開始します。その数秒後、数百万人が同時に「チケット購入」ボタンをクリックして、限定5万席を奪い合います。このリクエスト全体をデータベースに流すとシステムが落ちます。間違った方法でリクエストをブロックすると、2人が同じ座席を購入する可能性があります。

このページでは、要件、データモデル、APIとプロセスフロー、実装の各ステップを説明します。焦点は3つの難しい点です:突発的な負荷、座席の競合、支払い確認時の一貫性。最後には、Redisで仮想キューを構築し、座席の重複販売を防ぐ仕組みを理解し、冪等性を備えた安全な注文確認フローを実装できるようになります。

2. 要件

2.1. 機能要件

  • ユーザーはイベントページに入場し、販売開始前に仮想キューに入ります。
  • システムはキューの順番に従い、ユーザーを座席選択エリアへ段階的に入場させます。
  • ユーザーは最大4席を選択できます。選択した座席は支払い完了まで**保持(hold)**され、有効期限は5分です。
  • 支払いが成功すると、座席は「売却済み」になりチケットが発行されます。
  • 有効期限内に支払いがないと、座席は在庫に戻ります。

2.2. スケールと制約

メトリクス 推定値
販売開始時の同時ユーザー数 200万
座席数 5万
イベントページへのピークRPS 約50万
座席選択エリアの同時ユーザー数 5,000

最も重要な制約は座席の二重販売が起こらないことです。ここでの一貫性はstrong consistencyである必要があります。一方、キューでの位置表示は「前に約1万2000人がいます」程度の近似値で十分です。

これら2つの数値から、最大の気付きが得られます:購入できるのはたった2.5%のユーザーです。したがってシステムは、大多数のリクエストをデータベースに到達する前に、できるだけ低コストなレイヤーで拒否または遅延させなければなりません。

3. アーキテクチャとフロー

Client ──> CDN (静的ページ、待機ページ)
   │
   ├─> Queue Service ──> Redis (ZSET キュー、アクセストークン)
   │        ▲
   │        └── Admitter (スケジュール実行、毎秒N人を入場させる)
   │
   └─> Booking Service (アクセストークンが必須)
            ├─> PostgreSQL (seat, order, outbox)
            └─> Kafka (ticket-issued) ──> Ticket/Email Service

主なフローは以下のとおりです:

  1. クライアントが POST /events/{id}/queue を呼び出し、queueId を受け取ります。
  2. クライアントは数秒ごとに GET /events/{id}/queue/{queueId} をポーリングして位置を確認します。自分の番になると、accessToken を受け取ります。
  3. クライアントはトークンを持ち、座席リストで POST /events/{id}/holds を呼び出します。
  4. クライアントが支払いを完了します。支払いプロバイダーがWebhookを呼び出し、システムはべき等キー付きで POST /orders/{id}/confirm を呼び出します。
  5. Booking Serviceが注文とoutboxイベントを同一トランザクション内に記録します。その後イベントはKafkaにプッシュされてチケットが発行されます。

待機ページはCDNから静的に提供されます。バックエンドに到達するのはjoinとpollのAPIのみであり、どちらもRedisだけを使用します。

4. データモデル

CREATE TABLE seat (
    event_id        BIGINT       NOT NULL,
    seat_id         VARCHAR(16)  NOT NULL,
    section         VARCHAR(16)  NOT NULL,
    price           NUMERIC(12,2) NOT NULL,
    status          VARCHAR(16)  NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | SOLD
    held_by         BIGINT,
    hold_expires_at TIMESTAMP,
    PRIMARY KEY (event_id, seat_id)
);

CREATE TABLE ticket_order (
    id              BIGSERIAL PRIMARY KEY,
    customer_id     BIGINT      NOT NULL,
    event_id        BIGINT      NOT NULL,
    status          VARCHAR(16) NOT NULL, -- PENDING | PAID | EXPIRED
    idempotency_key VARCHAR(64) NOT NULL UNIQUE
);

CREATE TABLE outbox (
    id         BIGSERIAL PRIMARY KEY,
    topic      VARCHAR(64) NOT NULL,
    payload    TEXT        NOT NULL,
    created_at TIMESTAMP   NOT NULL DEFAULT now()
);

座席のステータスは seat テーブルに直接格納されます。これにより、座席の保持は1つの条件付きUPDATEステートメントで実現できます。idempotency_key の UNIQUE 制約は、重複する注文記録を防ぐ最後の砦です。

5. Redisを使った仮想キュー

依存関係を追加します:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

5.1. キューに入る

キューはRedis Sorted Setです。スコアは INCR から取得した昇順の序番です:

@Service
public class QueueService {

    private final StringRedisTemplate redis;

    public QueueService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public long join(long eventId, long customerId) {
        String queueKey = "queue:" + eventId;
        String member = String.valueOf(customerId);

        Long existing = redis.opsForZSet().rank(queueKey, member);
        if (existing != null) {
            return existing;
        }
        Long seq = redis.opsForValue().increment("queue:seq:" + eventId);
        redis.opsForZSet().addIfAbsent(queueKey, member, seq);
        return redis.opsForZSet().rank(queueKey, member);
    }

    public Long position(long eventId, long customerId) {
        return redis.opsForZSet().rank("queue:" + eventId, String.valueOf(customerId));
    }
}

説明:

  • increment はグローバルシーケンス番号を返します。タイムスタンプをスコアに使わない理由は、複数のリクエストが同じミリ秒を共有する可能性があるからです。
  • addIfAbsent は ZADD NX に対応します。ユーザーがF5を連続で押しても、キューの末尾に下げられません。
  • rank(ZRANK)の時間計算量はO(log N)です。200万要素でも数マイクロ秒しかかかりません。

3人が順番にキューに入った結果は以下のようになります:

join(1, 101) -> 0
join(1, 202) -> 1
join(1, 101) -> 0   (再度入場、位置は変わらない)

注意: ボット対策が必要な場合は、キューに入る前にユーザーを認証(ログイン、captcha)してください。ZSETに一度入ると、ボットは本物のユーザーと同じように1席を占有します。

5.2. Admitter: 段階的にユーザーを入場させる

1秒ごとに実行されるジョブです。このジョブは座席選択エリアに何席開いているかを確認してから、キューの先頭から正確にその数のユーザーを取得します:

@Component
public class Admitter {

    private static final int MAX_ACTIVE = 5_000;
    private static final Duration TOKEN_TTL = Duration.ofMinutes(10);

    private final StringRedisTemplate redis;

    public Admitter(StringRedisTemplate redis) {
        this.redis = redis;
    }

    @Scheduled(fixedRate = 1000)
    public void admit() {
        long eventId = 1L;
        String activeKey = "active:" + eventId;
        long now = System.currentTimeMillis();

        redis.opsForZSet().removeRangeByScore(activeKey, 0, now);
        long active = Optional.ofNullable(redis.opsForZSet().zCard(activeKey)).orElse(0L);
        long slots = MAX_ACTIVE - active;
        if (slots <= 0) return;

        var batch = redis.opsForZSet().popMin("queue:" + eventId, slots);
        if (batch == null) return;

        for (var tuple : batch) {
            String customerId = tuple.getValue();
            String token = UUID.randomUUID().toString();
            redis.opsForValue().set("access:" + token, customerId, TOKEN_TTL);
            redis.opsForValue().set("token-of:" + eventId + ":" + customerId, token, TOKEN_TTL);
            redis.opsForZSet().add(activeKey, customerId, now + TOKEN_TTL.toMillis());
        }
    }
}

説明:

  • active:{eventId} はスコアがタイムアウト時刻であるZSETです。removeRangeByScore はセッションが終了したユーザーをクリーンアップし、その後 zCard が座席選択エリアにいるユーザー数を示します。
  • popMin(ZPOPMIN)はキューの先頭のN人を取得して削除します。
  • ポーリング時、クライアントは token-of:... を読みます。このキーが存在すれば自分の番が来たことを意味し、クライアントはトークンを受け取ります。

このジョブは正確に1つのインスタンスで実行する必要があります。例えばShedLockまたはleader electionを使います。2つのインスタンスが同時に実行されると、座席選択エリアが MAX_ACTIVE を超える可能性があります。

また、200万人を処理するために複数のAdmitterを実行する必要もありません。ユーザーを入場させる速度はAdmitterではなく、Booking Serviceの処理能力によって制限されます。

5.3. トークンなしのリクエストをブロック

@Component
public class AccessTokenFilter extends OncePerRequestFilter {

    private final StringRedisTemplate redis;

    public AccessTokenFilter(StringRedisTemplate redis) {
        this.redis = redis;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return !request.getRequestURI().contains("/holds");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        String token = req.getHeader("X-Queue-Token");
        String customerId = token == null ? null : redis.opsForValue().get("access:" + token);
        if (customerId == null) {
            res.sendError(HttpServletResponse.SC_FORBIDDEN, "Not admitted");
            return;
        }
        req.setAttribute("customerId", Long.valueOf(customerId));
        chain.doFilter(req, res);
    }
}

このフィルターはトークンをキーとして使用して顧客IDを検索し、クライアントから送信された customerId を信頼しません。ユーザーは直接API /holds を呼び出してキューに割り込むことはできません。

6. 座席の保持: 競合の処理

複数のユーザーが同じ座席A-12を選択できます。最もシンプルで正しい方法は、データベースに条件付きUPDATEで判定させることです:

@Service
public class SeatHoldService {

    private static final Duration HOLD_TTL = Duration.ofMinutes(5);
    private final JdbcTemplate jdbc;

    public SeatHoldService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    public void hold(long eventId, long customerId, List<String> seatIds) {
        if (seatIds.size() > 4) throw new IllegalArgumentException("Max 4 seats");
        Timestamp now = Timestamp.from(Instant.now());
        Timestamp expires = Timestamp.from(Instant.now().plus(HOLD_TTL));

        for (String seatId : seatIds.stream().sorted().toList()) {
            int updated = jdbc.update("""
                UPDATE seat SET status = 'HELD', held_by = ?, hold_expires_at = ?
                WHERE event_id = ? AND seat_id = ?
                  AND (status = 'AVAILABLE' OR (status = 'HELD' AND hold_expires_at < ?))
                """, customerId, expires, eventId, seatId, now);
            if (updated == 0) {
                throw new SeatUnavailableException(seatId);
            }
        }
    }
}

class SeatUnavailableException extends RuntimeException {
    SeatUnavailableException(String seatId) { super("Seat taken: " + seatId); }
}

説明:

  • WHERE 句は条件チェックと更新を1つのアトミックなステートメントで行います。データベースはその行をロックします。後に来たリクエストは待機し、その後条件を再評価して status = 'HELD' を確認するため、updated = 0 を受け取ります。
  • hold_expires_at < ? 条件により、クリーンアップジョブを待たずに有効期限切れの座席を再取得できます。
  • グループ内の1席でも失敗すると、例外がトランザクション全体をロールバックします。ユーザーが「部分的な」座席のセットで保持されることはありません。
  • seatIds を更新前にソートすることにより、すべてのトランザクションが同じ順序でロックされ、2人が互いにクロスで選択した場合(例えば {A-1, A-2} と {A-2, A-1})のデッドロックを防ぎます。

注意: 「最初にSELECTでステータスをチェックしてからUPDATEする」という方法は、ロック機構がないため、座席の二重販売につながる古典的なrace conditionです。

6.1. 二重販売を防ぐ動作テスト

50個のスレッドが同じ座席を奪い合います。期待される結果は、正確に1つのスレッドだけが成功することです。

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>
@JdbcTest
@Import(SeatHoldService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class SeatHoldServiceTest {

    @Autowired JdbcTemplate jdbc;
    @Autowired SeatHoldService service;

    @Test
    void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
        jdbc.update("DELETE FROM seat");
        jdbc.update("INSERT INTO seat(event_id, seat_id, section, price) VALUES (1, 'A-12', 'VIP', 3000000)");

        AtomicInteger success = new AtomicInteger();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (long customer = 1; customer <= 50; customer++) {
                long id = customer;
                executor.submit(() -> {
                    try {
                        service.hold(1L, id, List.of("A-12"));
                        success.incrementAndGet();
                    } catch (SeatUnavailableException ignored) { }
                });
            }
        }

        assertThat(success.get()).isEqualTo(1);
        assertThat(jdbc.queryForObject(
            "SELECT status FROM seat WHERE seat_id = 'A-12'", String.class)).isEqualTo("HELD");
    }
}

seat テーブルは src/test/resources/schema.sql から作成されます。先ほどのセクション4の CREATE TABLE seat ステートメントをそのまま使い、他の表を追加する場合は BIGSERIAL を BIGINT AUTO_INCREMENT に変更してください。@Transactional(NOT_SUPPORTED) はテストを囲むトランザクションを無効にするため、データがコミットされ、他のスレッドから見えるようになります。

7. 支払いと一貫性

支払いプロバイダーのWebhookは複数回呼び出されることがあります。したがって、注文確認のステップはべき等である必要があります:

@Transactional
public void confirm(long orderId, long customerId, long eventId, String idempotencyKey) {
    int inserted = jdbc.update("""
        INSERT INTO ticket_order(id, customer_id, event_id, status, idempotency_key)
        VALUES (?, ?, ?, 'PAID', ?) ON CONFLICT (idempotency_key) DO NOTHING
        """, orderId, customerId, eventId, idempotencyKey);
    if (inserted == 0) return; // 既に処理済み

    int sold = jdbc.update("""
        UPDATE seat SET status = 'SOLD'
        WHERE event_id = ? AND held_by = ? AND status = 'HELD'
        """, eventId, customerId);
    if (sold == 0) throw new IllegalStateException("Hold expired, refund required");

    jdbc.update("INSERT INTO outbox(topic, payload) VALUES ('ticket-issued', ?)",
        "{\"orderId\":" + orderId + "}");
}

このコードは3つのことを処理します:

  • ON CONFLICT DO NOTHING(PostgreSQLの構文)は、Webhook呼び出しの重複が2つの注文を作成しないことを保証します。
  • 座席は、その顧客によってまだ保持されている場合にのみ SOLD に変わります。holdの有効期限が切れ、座席が他の誰かのものになった場合、トランザクションはロールバックされます。この場合、注文は払い戻しフローに移される必要があります。
  • Transactional outbox: イベントは注文と同じトランザクション内に記録されます。別のプロセス(またはDebezium)が outbox テーブルを読んでKafkaにプッシュします。これにより、注文は保存されたがチケットが発行されない場合や、その逆の場合が発生しません。

注意: holdのTTLを支払いページのタイムアウトより長めに設定してください。例えば、hold 5分、支払いページ 4分。このバッファにより、支払い済みだがholdが期限切れになったケースを減らします。

8. 実装時のポイント

  • 冒頭の突発的な負荷: 待機ページは静的ページでCDNから提供する必要があります。poll APIは推奨されるpoll間隔をヘッダーで返すべきです。例えば位置に応じて5〜15秒。クライアントが毎秒pollすると、200万人は200万 RPSをRedisに生成します。
  • Redisは単一障害点: Redis ClusterまたはSentinelを使用します。1つの大きなイベントの場合、ZSET全体は1つのキーに存在し、1つのシャードに存在します。1つのRedisノードは依然として数十万 ops/sに対応できますが、事前に計測すべきです。
  • 「席が残っています」の表示が不正確: UIの残席数はキャッシュから読み取り、数秒のズレを許容できます。セクション6のUPDATEステートメントだけが唯一の真実です。
  • 不要なhold: ユーザーがタブを閉じた後も座席を保有します。UPDATEの有効期限条件はこれを処理してくれます。定期的なジョブを追加して、有効期限切れの座席を AVAILABLE に戻すだけで、UIは正確に表示されます。
  • 仮想キューが不要な場合: 座席が数百席でトラフィックが中程度の場合、rate limitingと条件付きUPDATEで十分です。仮想キューは運用の複雑さを増し、ユーザーエクスペリエンスも遅くなります。購入したいユーザーの数が予約システムの処理能力を大幅に超える場合にだけ使うべきです。

9. 結論

高負荷に対応するチケット販売システムは、2つの懸念を分離することで実現できます。仮想キュー(Redis上)は突発的な負荷を吸収し、公平性を保ちます。データベースは入場を許可された少数のユーザーのみを処理し、条件付きUPDATEで座席の二重販売がないことを保証します。

ここまでで、ZADD NX と ZPOPMIN を使ったキューイング、段階的な入場方法、アトミックな座席保持とデッドロック回避、べき等な注文確認とtransactional outboxを使ったKafkaへのイベント送信が分かりました。このフレームワークを基に、ボット対策、会員向けの優先キュー、複数イベントの並行処理など、さらに拡張できます。

system-designredisspring-bootconcurrencykafka