映画チケット販売システムの設計:一時的なロックと期限管理

1. 概要

映画の大作が公開されると、数千人が同時に数百席の座席図をクリックします。システムは各座席が1人にしか属さないことを保証する必要があります。座席は支払い中に一時的に確保され、顧客が去った場合は自動的に解放される必要があります。

この記事では、設計全体を説明します:要件、データモデル、API、処理フロー、難しい点です。実装コードはJava 21、Spring Boot 3.x、SQL、Redisを使用します。読み終わったら、複数の同時リクエストがある場合に安全に座席を確保するメカニズムを自分で実装できます。また、クリーンアップジョブに依存せずに期限切れ座席を処理する方法も理解できます。

2. 要件

2.1 機能要件

  • 顧客は上映の座席図を見て、空き、確保中、または売却済みの状態を確認します。
  • 顧客は最大8席を選択し、10分間確保します。
  • 座席確保はall-or-nothingです:1席確保できない場合は、どの座席も確保しません。
  • 確保時間内に支払うと、座席は売却済みに変わります。
  • 10分後に支払わない場合、座席は自動的に空きに戻ります。

2.2 規模と制約

  • 約500の映画館、各映画館10ホール、各ホール100~300席。
  • 通常、数百リクエスト/秒。人気映画公開時には最大50,000リクエスト/秒に跳ね上がり、少数の上映に集中します。
  • 座席の重複販売は許されません。これはハード制約です。
  • 読み書き比率は非常に不均衡です:1回の座席確保に、座席図の表示が数十回伴います。
  • 支払いは外部ゲートウェイを通じます。コールバックは遅れる、2回来る、または来ないことがあります。

3. データモデル

中心的なテーブルはshow_seatです。各行は特定の上映の座席に対応します。別々の「確保」テーブルを作成しません。座席の状態と確保情報は同じ原子的なステートメントで更新する必要があるためです。

CREATE TABLE show_seat (
    showtime_id     BIGINT       NOT NULL,
    seat_code       VARCHAR(8)   NOT NULL,
    status          VARCHAR(16)  NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | BOOKED
    hold_id         UUID,
    customer_id     BIGINT,
    hold_expires_at TIMESTAMP,
    booking_id      BIGINT,
    PRIMARY KEY (showtime_id, seat_code)
);

CREATE INDEX idx_show_seat_hold ON show_seat (hold_id);
CREATE INDEX idx_show_seat_expired ON show_seat (status, hold_expires_at);

注意すべき点:

  • 主キー(showtime_id, seat_code)により、同じ上映の座席グループの更新が非常に高速になります。
  • hold_idは同じ確保内の座席をまとめます。支払いステップはこのhold_idに基づいてグループ全体を確認します。
  • インデックス(status, hold_expires_at)は、セクション7の期限切れ座席削除ジョブに使用されます。

テーブルshowtime、booking、paymentは標準的なCRUDテーブルなので、この記事では詳しく説明しません。

4. API

GET    /showtimes/{showtimeId}/seats          -> 座席図 + 状態
POST   /showtimes/{showtimeId}/holds          -> 座席確保、本文: {"seats":["F5","F6"]}
                                                 201 {"holdId":"...","expiresAt":"..."} | 409 座席が取られている場合
POST   /holds/{holdId}/checkout               -> 支払いセッション作成(ヘッダー Idempotency-Key)
DELETE /holds/{holdId}                        -> 顧客キャンセル、座席を即座に解放
POST   /payments/callback                     -> 支払いゲートウェイのコールバック

座席が他人に取られた場合は409 Conflictを返します。クライアントはこのコードを基に座席図を更新し、顧客に別の座席を選択させます。

5. 処理フロー

顧客          API                 DB / Redis              支払いゲートウェイ
  | 座席選択   |                       |                          |
  |------------>| 条件付きUPDATE       |                          |
  |             |---------------------->| HELD、10分後に期限切れ   |
  |<------------| 201 holdId           |                          |
  | チェックアウト |                       |                          |
  |------------>| 支払い作成(8分)     |------------------------->|
  |             |                       |        コールバック SUCCESS |
  |             |<-------------------------------------------------|
  |             | HELD -> BOOKED       |                          |
  |             | 期限内なら           |                          |

支払いセッションは8分後に期限切れに設定され、座席確保時間より短いです。2分間の余裕により、ほとんどのコールバックが座席解放前に到着します。

6. 座席の一時的なロック

必要な依存関係:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

6.1 条件付きUPDATEが信頼できるソース

よくある間違いは、先に読んでから書くことです:SELECTで座席が空いているか確認してからUPDATEします。2つのステートメント間に別のリクエストが座席を取ることがあります。結果として2人が座席を確保したと思い込みます。

正しい方法は、チェック部分をWHERE条件にまとめ、影響を受けた行数をカウントすることです:

@Service
public class SeatHoldService {

    private static final Duration HOLD_TTL = Duration.ofMinutes(10);

    private final NamedParameterJdbcTemplate jdbc;
    private final Clock clock;

    public SeatHoldService(NamedParameterJdbcTemplate jdbc, Clock clock) {
        this.jdbc = jdbc;
        this.clock = clock;
    }

    @Transactional
    public SeatHold hold(long showtimeId, List<String> seatCodes, long customerId) {
        UUID holdId = UUID.randomUUID();
        Instant now = clock.instant();
        Instant expiresAt = now.plus(HOLD_TTL);

        var params = new MapSqlParameterSource()
                .addValue("holdId", holdId)
                .addValue("customerId", customerId)
                .addValue("showtimeId", showtimeId)
                .addValue("seatCodes", seatCodes.stream().sorted().toList())
                .addValue("now", Timestamp.from(now))
                .addValue("expiresAt", Timestamp.from(expiresAt));

        int updated = jdbc.update("""
                UPDATE show_seat
                   SET status = 'HELD', hold_id = :holdId,
                       customer_id = :customerId, hold_expires_at = :expiresAt
                 WHERE showtime_id = :showtimeId
                   AND seat_code IN (:seatCodes)
                   AND (status = 'AVAILABLE'
                        OR (status = 'HELD' AND hold_expires_at < :now))
                """, params);

        if (updated != seatCodes.size()) {
            throw new SeatUnavailableException(showtimeId, seatCodes);
        }
        return new SeatHold(holdId, expiresAt);
    }
}

public record SeatHold(UUID holdId, Instant expiresAt) {}

public class SeatUnavailableException extends RuntimeException {
    public SeatUnavailableException(long showtimeId, List<String> seats) {
        super("Seats " + seats + " of showtime " + showtimeId + " are not available");
    }
}

コードの動作方法:

  • UPDATEステートメントは各座席行のロックを取得します。同じ座席F5を目指す2つのトランザクションはキューに入ります。後から来たトランザクションはWHERE条件を再評価し、status = 'HELD'を見つけるので、その行を更新できません。
  • 条件status = 'HELD' AND hold_expires_at < :nowは遅延期限切れです。確保時間が切れた座席は、クリーンアップジョブがまだ実行されていなくても空きと見なされます。
  • 更新された行数が要求座席数より少ない場合、例外をスローします。@Transactionalがトランザクション全体をロールバックするため、all-or-nothingが実現します。
  • Clockはサービスにインジェクトされるため、テストが時刻を制御できます。

顧客AがF5, F6を確保し、その後顧客BがF6, F7を確保しようとするとします。Bは次を受け取ります:

HTTP 409 Conflict
{"error":"Seats [F6, F7] of showtime 42 are not available"}

Bの座席F7はまだ空いています。Bのトランザクションはロールバックしたため、座席が変わりません。

注意: seatCodesをソートしてからupdateします。AがF5 → F6の順でロックし、BがF6 → F5の順でロックすると、2つのトランザクションがデッドロックする可能性があります。PostgreSQLでは、ロック順は行スキャン順に依存します。確実にするには、updateする前にSELECT ... ORDER BY seat_code FOR UPDATEを実行できます。

6.2 Redisを高速フィルタレイヤーとして使用

バースト負荷時、50,000の座席確保リクエストが1つの上映のわずか数百行に殺到します。データベースは非常に長いロックキューに入ります。Redisをフロントに配置して、確実に失敗するリクエストを早期にフィルタリングします。以下のLuaスクリプトにより、複数キーの操作が原子的になります:

@Component
public class RedisSeatGate {

    private static final RedisScript<Long> TRY_HOLD = new DefaultRedisScript<>("""
            for _, key in ipairs(KEYS) do
              if redis.call('EXISTS', key) == 1 then return 0 end
            end
            for _, key in ipairs(KEYS) do
              redis.call('SET', key, ARGV[1], 'PX', ARGV[2])
            end
            return 1
            """, Long.class);

    private final StringRedisTemplate redis;

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

    public boolean tryHold(long showtimeId, List<String> seats, UUID holdId, Duration ttl) {
        List<String> keys = seats.stream()
                .map(s -> "seat:{" + showtimeId + "}:" + s)
                .toList();
        Long ok = redis.execute(TRY_HOLD, keys, holdId.toString(),
                String.valueOf(ttl.toMillis()));
        return ok != null && ok == 1L;
    }
}

使用方法:

  • Redisはスクリプトを1つのスレッドで実行します。EXISTSチェックとSETの間にどのリクエストも割り込めません。
  • PXはキーにTTLを設定するため、Redisはタイムアウト時に自動的にキーを削除します。これはキャッシュレイヤーの自然な期限切れメカニズムです。
  • {showtimeId}部分はハッシュタグです。Redis Clusterでは、上映のすべてのキーが同じスロットに配置されます。ハッシュタグがない場合、マルチキースクリプトはCROSSSLOTエラーを報告します。

完全なフロー:最初にRedisでtryHoldを呼び出します。失敗したら即座に409を返します。成功したら、セクション6.1のUPDATEを実行します。UPDATEが失敗したら、Redisに設定したばかりのキーを削除します。

注意: Redisは信頼できるソースではありません。Redisはフェイルオーバー時にデータを失う可能性があり、キーはDBより数ミリ秒異なる時刻に期限切れになる可能性があります。最終的な決定は常に条件付きUPDATEです。システムにバースト負荷がない場合、Redis層を完全に削除して、管理する必要があるコンポーネントを減らしてください。

7. 確保期限の管理

期限切れの正確性はセクション6.1の条件hold_expires_at < :nowで保証されます。ただし、座席図は何かが上書きするまでHELDと表示されたままです。そのため、クリーンアップジョブを追加して、インターフェースが正確に表示されるようにする必要があります:

@Component
public class ExpiredHoldSweeper {

    private final JdbcTemplate jdbc;
    private final Clock clock;

    public ExpiredHoldSweeper(JdbcTemplate jdbc, Clock clock) {
        this.jdbc = jdbc;
        this.clock = clock;
    }

    @Scheduled(fixedDelay = 15_000)
    public void releaseExpired() {
        int released = jdbc.update("""
                UPDATE show_seat
                   SET status = 'AVAILABLE', hold_id = NULL,
                       customer_id = NULL, hold_expires_at = NULL
                 WHERE status = 'HELD' AND hold_expires_at < ?
                """, Timestamp.from(clock.instant()));
        if (released > 0) {
            // SeatsReleased をパブリッシュして座席図キャッシュを更新
        }
    }
}

このジョブは15秒ごとに実行されます。遅れて実行されても、複数のインスタンスで同時に実行されても問題ありません。ステートメントはべき等だからです。遅延期限切れのおかげで、ジョブが数分間停止してもシートの重複販売は発生しません。唯一の影響はインターフェースが一定期間古い情報を表示することです。構成で@EnableSchedulingを有効にすることを忘れずに。

支払い確認側は、期限をチェックする必要があります同じステートメント内で:

@Transactional
public boolean confirm(UUID holdId, long bookingId) {
    int updated = jdbc.update("""
            UPDATE show_seat
               SET status = 'BOOKED', booking_id = :bookingId, hold_expires_at = NULL
             WHERE hold_id = :holdId AND status = 'HELD'
               AND hold_expires_at >= :now
            """, new MapSqlParameterSource()
                    .addValue("holdId", holdId)
                    .addValue("bookingId", bookingId)
                    .addValue("now", Timestamp.from(clock.instant())));
    return updated > 0;
}

この関数は2つの場合にfalseを返します:holdが期限切れ、または座席が他の誰かに再度確保されました。どちらの場合も支払いステップで処理する必要があります。

8. 支払いとの一貫性

支払いゲートウェイからのコールバックが最もエラーしやすい場所です。処理すべき3つの状況があります:

  1. コールバックが2回来る。 payment_idをUNIQUE制約で保存します。2回目はトランザクションがすでに処理されているのを見て、200を返して何もしません。
  2. 座席期限切れ後にコールバックが来る。 confirmはfalseを返します。この時点でシステムはお金を取ったが座席を提供できません。予約をFAILEDとマークし、自動払い戻しを発行します。座席を「取り戻す」しようとしないでください。他の誰かがすでに購入しているかもしれません。
  3. コールバックが来ない。 定期的な照合ジョブが15分以上PENDINGの支払いの状態をゲートウェイから問い合わせ、上記2つの場合と同じロジックを適用します。

confirmが成功した後、メール送信、QR券の発行、またはポイント加算などのステップはトランザクション内で実行すべきではありません。代わりに、トランザクション内のBookingConfirmed行をoutboxテーブルに書き込みます。別のプロセスがイベントをKafkaにプッシュします。トピックはbooking-events、キーはbookingIdです。この方法により、イベントはDBがコミットした後にのみ発行され、コミットされるとイベントは確実に発行されます。

9. バースト負荷への対策

セクション6.2のRedisレイヤーは書き込みの一部を解決しています。人気映画を公開する際には、次の追加の対策が必要です:

  • 仮想待機室。 各人気上映は、座席選択ページに同時にN人しか入れません。残りの人は待機トークンを受け取り、Redisソート済みセットに保存されます(スコアは到着時刻)。数百席は50,000人が同時に選択する必要はありません。
  • 座席図のキャッシング。 座席図はRedisまたはCDNから読み取られ、SeatsHeld/SeatsReleasedイベントで更新されます。座席図が1~2秒遅れていても許容できます。座席確保ステップが正確性を再度チェックするためです。
  • 顧客ごとのレート制限。 例えば1分あたり最大5回の座席確保に制限して、ボットによる座席スキャンをブロックします。
  • showtime_idによる分割。 すべての競争は1つの上映内です。分散トランザクションなしでshowtime_idに基づいてDBをシャード化できます。

10. 競合状態のテスト

テストすべき最も重要な動作は:複数の顧客が同じ座席を確保しようとしたら、1人だけが成功することです。

@SpringBootTest
class SeatHoldServiceTest {

    @Autowired SeatHoldService service;
    @Autowired JdbcTemplate jdbc;

    @BeforeEach
    void setUp() {
        jdbc.update("DELETE FROM show_seat");
        jdbc.update("INSERT INTO show_seat (showtime_id, seat_code) VALUES (42, 'F5'), (42, 'F6')");
    }

    @Test
    void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
        int customers = 20;
        var start = new CountDownLatch(1);
        var successes = new AtomicInteger();

        try (var pool = Executors.newFixedThreadPool(customers)) {
            for (int i = 0; i < customers; i++) {
                long customerId = i;
                pool.submit(() -> {
                    start.await();
                    try {
                        service.hold(42, List.of("F5", "F6"), customerId);
                        successes.incrementAndGet();
                    } catch (SeatUnavailableException ignored) { }
                    return null;
                });
            }
            start.countDown();
        }

        assertThat(successes.get()).isEqualTo(1);
        assertThat(jdbc.queryForObject(
                "SELECT COUNT(DISTINCT hold_id) FROM show_seat WHERE status = 'HELD'",
                Integer.class)).isEqualTo(1);
    }
}

テストの説明:

  • CountDownLatchは20スレッドをスタートラインで保持し、同時にリリースして、競合の可能性を最大化します。
  • 「try-with-resources」をExecutorServiceで使用(Java 21)して、アサーション部分を実行する前にすべてのタスクの完了を待ちます。
  • 2番目のアサーションは、両方の座席が同じhold_idに属することを確認します。つまり、「各人1席」のような状況がないということです。

テストはH2で実行され、セクション3のschema.sqlファイルを使用します(H2のUUIDに変更してください。H2はUUIDをサポートしています)。Clock.systemUTC()というビーンを宣言する必要があります。期限切れをテストするには、そのビーンをモックClockで置き換え、時刻を早送りできるようにしてください。

注意: H2はPostgreSQLまたはMySQLのロック動作を正確にシミュレートしていません。同時実行関連のコードについては、Testcontainersなどでプロダクションデータベースでテストを追加実行してください。

11. よくあるエラー

  • チェック後に書く、つまりSELECT後にUPDATEし、ロックを取得しない。これが座席の重複販売の最大の原因です。
  • ジョブ削除に完全に依存して座席を解放します。ジョブが停止すると座席がスタックします。WHERE内の遅延期限切れはこのリスクを排除します。
  • Redisロックを信頼できるソースとして使用します。ロックはフェイルオーバー時に失われ、2人が同じ座席を確保する可能性があります。
  • SELECT ... FOR UPDATEを10分間保持して顧客の支払いを待ちます。コネクションプールはすぐに枯渇します。DBロックは数ミリ秒以内に存在すべきです。長い確保時間はデータで表現する必要があります。具体的にはhold_expires_at列です。
  • 複数のマシンからの時刻取得。 時間ソースを統一します:DBのnow()またはNTPが厳密に同期されたアプリケーションのClock。

12. まとめ

映画チケット販売システムの中心は条件付きUPDATEステートメントです。このステートメントは座席を原子的にロックし、期限切れの確保を空きと見なすため、重複販売が発生せず、クリーンアップジョブは不要です。その他のコンポーネントはこのコアの周りに構築されます:

  • Redis Luaスクリプトが早期に負荷をフィルタリングします。
  • スイーパージョブにより、インターフェースが正確な状態を表示します。
  • べき等性と照合により、異常な支払いコールバックを処理します。
  • Outboxと kafka が信頼できるイベント配信を実現します。
  • 仮想待機室がバースト負荷を吸収します。

実装する際は、SQLコアと競合テストから始めてください。実際の負荷メトリクスがRedisと仮想待機室が必要だと示すまで、追加しないでください。

system-designspring-bootredisconcurrencycase-study