航空券予約システムの設計:座席確保、決済、オーバーブッキング防止

1. 概要

飛行機のチケット販売は単純に見えます:顧客が座席を選んで、代金を払い、チケットを受け取ります。難しいのは、何千人もが限られた数の座席を争うことです。それに、支払いは第三者経由で行われ、遅延したり複数回結果が返ってくる可能性があります。

このブログでは、要件からコードまで、航空券予約システムを設計します。システムには期限付き座席確保、冪等な決済、重複販売防止、急激な負荷対応が含まれます。読み終わったら、コンサート券販売、ホテル予約、フラッシュセールなど同様の問題に適用できる設計フレームワークが得られます。

2. 要件仕様

2.1. 機能要件

  • 飛行機を検索し、空いている座席図を表示する。
  • 座席確保(hold):1つ以上の座席を10分間確保する。
  • 決済。成功するとチケットを発行、失敗または期限切れの場合は座席を解放する。
  • キャンセルとポリシーに基づいた払戻。

2.2. 規模と制約

  • 1日あたり約5,000便、各便180座席。
  • 読み取り(検索、座席表示)が書き込みの約100倍。
  • 格安チケット販売開始時に50,000リクエスト/秒が数十の便に集中する可能性。
  • 強い制約: 1つの座席が2人に販売されることはありません。座席クラス別の販売枚数は航空会社が許可した上限を超えません。

注: 航空会社は意図的にオーバーブッキング(例:180座席のエコノミークラスに対して183枚販売)を行います。理由は常に乗り遅れる顧客がいるからです。我々が防ぐのは競合状態による意図しないオーバーブッキングです。意図的なオーバーブッキング率はパラメータとして管理する必要があります。

これら2つの制約はアーキテクチャの選択を決定します。座席の在庫データには強い一貫性が必要です。検索部分は最終的一貫性(eventual consistency)を受け入れ、強力にキャッシュします。

3. データモデル

PostgreSQLを在庫のソース・オブ・トゥルースとして使用します:

CREATE TABLE flight (
    id              BIGINT PRIMARY KEY,
    code            VARCHAR(10) NOT NULL,
    departure_at    TIMESTAMPTZ NOT NULL
);

CREATE TABLE fare_inventory (
    flight_id       BIGINT NOT NULL REFERENCES flight(id),
    fare_class      VARCHAR(10) NOT NULL,        -- ECONOMY, BUSINESS
    capacity        INT NOT NULL,
    overbook_limit  INT NOT NULL DEFAULT 0,
    sold            INT NOT NULL DEFAULT 0,
    held            INT NOT NULL DEFAULT 0,
    PRIMARY KEY (flight_id, fare_class),
    CHECK (sold + held <= capacity + overbook_limit)
);

CREATE TABLE seat (
    flight_id   BIGINT NOT NULL REFERENCES flight(id),
    seat_no     VARCHAR(4) NOT NULL,
    status      VARCHAR(10) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE, HELD, SOLD
    booking_id  BIGINT,
    held_until  TIMESTAMPTZ,
    PRIMARY KEY (flight_id, seat_no)
);

CREATE TABLE booking (
    id           BIGINT PRIMARY KEY,
    flight_id    BIGINT NOT NULL,
    customer_id  BIGINT NOT NULL,
    status       VARCHAR(12) NOT NULL,   -- HELD, CONFIRMED, EXPIRED, CANCELLED
    amount       NUMERIC(12,2) NOT NULL,
    expires_at   TIMESTAMPTZ NOT NULL
);

CREATE TABLE payment (
    id               BIGINT PRIMARY KEY,
    booking_id       BIGINT NOT NULL,
    idempotency_key  VARCHAR(64) NOT NULL UNIQUE,
    status           VARCHAR(10) NOT NULL
);

在庫の管理には2つのレベルがあります:

  • fare_inventory は運賃クラス別に集計します。CHECK 制約が最終的なセーフティネット:コードにバグがあっても、データベースはオーバーブッキング上限を超えることを拒否します。
  • seat は個別の座席を管理します。主キー (flight_id, seat_no) により、各座席の状態は1行だけで管理されます。

4. API

GET  /flights?from=SGN&to=HAN&date=2026-12-20     -> 便一覧(キャッシュ)
GET  /flights/{id}/seats                           -> 座席図(2-5秒キャッシュ)
POST /bookings                                     -> 座席確保、bookingId + expiresAtを返す
POST /bookings/{id}/payments                       -> Idempotency-Keyヘッダー必須
POST /payments/webhook                             -> 決済ゲートウェイからのコールバック
DELETE /bookings/{id}                              -> キャンセル

座席確保のリクエスト:

{ "flightId": 1001, "customerId": 42, "fareClass": "ECONOMY", "seats": ["12A", "12B"] }

座席図は数秒古い可能性があります。ユーザーは「空席」に見えた座席が確保時に拒否される場合があります。これはトレードオフとして受け入れられる範囲です。座席確保ステップは常にデータベースで再度チェックするためです。

5. 座席確保:重複販売防止

よくある間違いは「読んで書く」パターンです。SELECT status で空いているのを見て、UPDATE します。2つのトランザクションが同時に「空いている」と読んで、同時に書き込みます。結果として1つの座席が2人に販売されます。

解決策は conditional update です。チェック条件を UPDATE 文に組み込み、影響を受けた行数を数えます。

public interface SeatRepository extends JpaRepository<Seat, SeatId> {

    @Modifying
    @Query(value = """
        UPDATE seat
           SET status = 'HELD', booking_id = :bookingId, held_until = :until
         WHERE flight_id = :flightId
           AND seat_no IN (:seatNos)
           AND (status = 'AVAILABLE' OR (status = 'HELD' AND held_until < now()))
        """, nativeQuery = true)
    int holdSeats(Long flightId, List<String> seatNos, Long bookingId, Instant until);

    @Modifying
    @Query(value = """
        UPDATE seat SET status = 'SOLD', held_until = NULL
         WHERE booking_id = :bookingId AND status = 'HELD'
        """, nativeQuery = true)
    int markSold(Long bookingId);
}

public interface FareInventoryRepository extends JpaRepository<FareInventory, FareInventoryId> {

    @Modifying
    @Query(value = """
        UPDATE fare_inventory SET held = held + :n
         WHERE flight_id = :flightId AND fare_class = :fareClass
           AND sold + held + :n <= capacity + overbook_limit
        """, nativeQuery = true)
    int reserve(Long flightId, String fareClass, int n);
}

PostgreSQL は UPDATE 実行時に行をロックします。2番目のトランザクションは待機し、その後新しいデータで WHERE 条件を再評価します。この時点で条件が偽になるため、ステートメントは0行を返します。条件 held_until < now() により、期限切れの確保座席を「奪う」ことができ、クリーンアップジョブを待つ必要がありません。

Service はこれらのステップを単一トランザクションに統合します:

@Service
@RequiredArgsConstructor
public class BookingService {

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

    private final SeatRepository seatRepository;
    private final FareInventoryRepository inventoryRepository;
    private final BookingRepository bookingRepository;
    private final IdGenerator idGenerator;

    @Transactional
    public Booking hold(HoldRequest req) {
        int n = req.seats().size();
        if (inventoryRepository.reserve(req.flightId(), req.fareClass(), n) != 1) {
            throw new SoldOutException(req.flightId(), req.fareClass());
        }

        Instant until = Instant.now().plus(HOLD_TTL);
        long bookingId = idGenerator.next();
        int held = seatRepository.holdSeats(req.flightId(), req.seats(), bookingId, until);
        if (held != n) {
            // reserve と既に確保した座席すべてをロールバック
            throw new SeatUnavailableException(req.seats());
        }

        return bookingRepository.save(new Booking(bookingId, req.flightId(),
                req.customerId(), BookingStatus.HELD, req.amount(), until));
    }
}

SeatUnavailableException は RuntimeException なので、Spring はトランザクション全体をロールバックします。顧客はすべて選択した座席を確保するか、何も確保しないかのどちらかです。12Aは確保しても12Bは失敗というようなことはありません。

注: 2人の顧客が同時に {12A, 12B} と {12B, 12A} を選択する場合のデッドロックを防ぐため、ダウンストリームに送る前に seatNos をソートしてください。Postgres は IN リストのロック順序を保証しません。座席グループが大きい場合は、更新前に SELECT … ORDER BY seat_no FOR UPDATE を実行してください。

座席がちょうど期限切れになって他人に奪われた場合、古い予約の held カウンタはまだ残ります。セクション7のクリーンアップジョブがこれを減算します。

6. 冪等な決済

決済ゲートウェイは webhook を複数回呼ぶ可能性があります。ユーザーは「支払う」を2回クリックするかもしれません。すべての金銭処理は冪等である必要があります。

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final PaymentRepository paymentRepository;
    private final BookingRepository bookingRepository;
    private final SeatRepository seatRepository;
    private final FareInventoryRepository inventoryRepository;
    private final OutboxRepository outboxRepository;

    @Transactional
    public PaymentResult confirm(long bookingId, String idempotencyKey) {
        Optional<Payment> existing = paymentRepository.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            return PaymentResult.of(existing.get());   // 重複呼び出し:古い結果を返す
        }

        // HELD -> CONFIRMED、予約がまだ期限切れでない場合のみ成功
        int updated = bookingRepository.transition(bookingId, "HELD", "CONFIRMED");
        if (updated == 0) {
            paymentRepository.save(Payment.refundRequired(bookingId, idempotencyKey));
            return PaymentResult.REFUND;
        }

        Booking booking = bookingRepository.findById(bookingId).orElseThrow();
        int seats = seatRepository.markSold(bookingId);
        inventoryRepository.moveHeldToSold(booking.getFlightId(), booking.getFareClass(), seats);

        paymentRepository.save(Payment.success(bookingId, idempotencyKey));
        outboxRepository.save(OutboxEvent.bookingConfirmed(bookingId));
        return PaymentResult.SUCCESS;
    }
}

transition コマンドは別の conditional update です:

UPDATE booking SET status = :to WHERE id = :id AND status = :from

重要なポイントは「支払い到着」と「確保期限切れ」の間の競合です。支払い処理ジョブと期限切れジョブの両方が HELD ステータスから状態遷移を行います。どちらか一方だけが勝ちます。支払いが遅く到着した場合、顧客は自動で払戻を受け取り、別の人に販売された座席を受け取りません。

列 idempotency_key UNIQUE は、同じキーで2つのリクエストが並列実行される場合を防ぎます。どちらも古い記録を見つけませんが、2番目のリクエストはinsert時に DataIntegrityViolationException でエラーになり、トランザクションはロールバックします。

6.1. Outbox と Kafka

チケット発行、メール送信、チェックイン システムとの同期はすべて非同期で実行されます。トランザクション内で Kafka に直接送信しないでください。DB が commit しても Kafka 送信が失敗した場合(またはその逆)、データが不整合になります。代わりに、同じトランザクション内の outbox テーブルに書き込み、relay が Kafka に手動で push します:

@Component
@RequiredArgsConstructor
public class OutboxRelay {

    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafka;

    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publish() {
        for (OutboxEvent e : outboxRepository.findTop100ByPublishedFalseOrderById()) {
            kafka.send("booking-events", String.valueOf(e.getBookingId()), e.getPayload()).join();
            e.markPublished();
        }
    }
}

Relay は at-least-once を保証するため、チケット発行側の consumer も冪等である必要があります。例えば処理済みの bookingId を記録することで対応できます。bookingId をキーとして使うと、同じ予約のイベントは同じ partition に入り、順序が保証されます。

7. 期限切れ確保の清掃

@Scheduled(fixedDelay = 30_000)
@Transactional
public void expireHolds() {
    for (Booking b : bookingRepository.findTop500ByStatusAndExpiresAtBefore(
            BookingStatus.HELD, Instant.now())) {
        if (bookingRepository.transition(b.getId(), "HELD", "EXPIRED") == 1) {
            int released = seatRepository.releaseByBooking(b.getId()); // HELD -> AVAILABLE
            inventoryRepository.release(b.getFlightId(), b.getFareClass(), b.getSeatCount());
        }
    }
}

このジョブは予約の seatCount に基づいて held を減算します。実際に解放された座席の数ではありません。理由は、他の人に以前奪われた座席が存在するかもしれないが、カウンタは古い予約に対してはまだカウント中だからです。複数のインスタンスで実行する場合、SELECT … FOR UPDATE SKIP LOCKED を使うか、ShedLock を使ってノード間で重複処理を避けてください。

8. 急激な負荷への対応

格安チケット販売開始時に、50,000リクエスト/秒が数行の fare_inventory に押し寄せます。これはホットローです。すべてのトランザクションは同じ行ロックを待機するキューに並びます。データベースは CPU で死ぬのではなく、接続プールが枯渇して死にます。

戦略は DB に到達する前にフィルタリングすることです:

  1. バーチャル待機室(waiting room): N人/秒だけを予約画面に入らせ、残りの人にはキュー待機トークンを与えます。
  2. Redis 上の高速フィルタ: SET NX で座席をロックし、確実に負ける request を早期に拒否します。
  3. DB は依然として最終的な審判官です。
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Component
@RequiredArgsConstructor
public class SeatGate {

    private final StringRedisTemplate redis;

    public boolean tryEnter(long flightId, String seatNo, long customerId) {
        String key = "seat:%d:%s".formatted(flightId, seatNo);
        Boolean ok = redis.opsForValue()
                .setIfAbsent(key, String.valueOf(customerId), Duration.ofSeconds(15));
        return Boolean.TRUE.equals(ok);
    }
}

tryEnter が false を返す場合、API はトランザクションを開かずに即座に 409 Conflict を返します。TTL 15秒は DB 上の座席確保ステップに十分で、インスタンスがクラッシュした場合はキーは自動的に消えます。

注: Redis を座席在庫のソース・オブ・トゥルースにしないでください。Redis フェイルオーバー時、キーが失われ、2人の顧客が同時にゲートを通る可能性があります。これでもかまいません。DB 上の conditional update がまだ防止するからです。一方、Redis だけを信頼する場合、座席の重複販売が発生します。

運賃クラス別のホットロー対応として、fare_inventory を複数の「バケット」(例:10行、各行18座席)に分割し、ランダムにバケットを選んでロック競合を減らすことができます。

9. 重複販売防止テスト

最も重要なテスト:50個のスレッドが同時に1つの座席を確保しようとして、正確に1つだけ成功します。PostgreSQL を Testcontainers 経由で実際に実行してください。H2 のロック動作は Postgres と異なるためです。

@SpringBootTest
@Testcontainers
class BookingServiceConcurrencyTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @Autowired BookingService bookingService;

    @Test
    void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
        int threads = 50;
        AtomicInteger success = new AtomicInteger();
        CountDownLatch start = new CountDownLatch(1);

        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < threads; i++) {
                long customerId = i;
                pool.submit(() -> {
                    start.await();
                    try {
                        bookingService.hold(new HoldRequest(1001L, customerId, "ECONOMY",
                                List.of("12A"), new BigDecimal("1500000")));
                        success.incrementAndGet();
                    } catch (SeatUnavailableException | SoldOutException ignored) { }
                    return null;
                });
            }
            start.countDown();
        }

        assertThat(success.get()).isEqualTo(1);
    }
}

このテストは、サンプルデータ(便1001、座席12A空席)が Flyway または data.sql で事前ロードされていることを想定しています。CountDownLatch はすべてのスレッドを同時に解放し、競合を最大化します。ExecutorService の try-with-resources(Java 21)はすべてのタスクが完了するまで待機します。期待される結果:正確に1回成功、49回は SeatUnavailableException を受け取ります。

10. よくあるバグ

  • JVM 内で synchronized または lock を使う: 1インスタンスの場合のみ正しく動作します。2つの pod にスケールアップするとバグります。
  • ホットロー向けに @Version で Optimistic locking を使う: ロジック的には正しいですが、急激な負荷時にはリトライ率が非常に高くなります。Conditional update の方が効率的です。
  • 決済ゲートウェイ呼び出し中にトランザクションを開いたままにする: 行ロックが数秒間保持されます。座席を確保して commit、決済を呼び出し、その後新しいトランザクションで確認してください。
  • 期限切れ確保後の払戻処理を忘れる: これは最大の苦情源です。常に自動払戻の分岐を用意してください。
  • 座席図を長すぎるキャッシュにする: ユーザーは常に失われた座席を選択します。2-5秒が妥当です。

11. 結論

航空券予約システムは1つの原則を中心に展開します:強い一貫性が必要な部分(座席在庫、予約ステータス)を、遅延を許容する部分(検索、通知、チケット発行)から切り離します。データベースの conditional update は分散ロックなしに競合を解決します。HELD → CONFIRMED/EXPIRED の状態機械と条件付き遷移は、支払いと期限切れの間の競合を処理します。Idempotency key と outbox は、資金とイベントの重複を防ぎます。Redis とバーチャル待機室は、負荷が急増したときにデータベースを保護します。

このフレームワークを「リソースが限定的で多くの人が競合する」あらゆる問題に応用できます:コンサートチケット、ホテル予約、医療予約スロット、フラッシュセールなど。

system-designspring-bootconcurrencyrediskafka