航空券予約システムの設計:座席確保、決済、オーバーブッキング防止
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 に到達する前にフィルタリングすることです:
- バーチャル待機室(waiting room): N人/秒だけを予約画面に入らせ、残りの人にはキュー待機トークンを与えます。
- Redis 上の高速フィルタ:
SET NXで座席をロックし、確実に負ける request を早期に拒否します。 - 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 とバーチャル待機室は、負荷が急増したときにデータベースを保護します。
このフレームワークを「リソースが限定的で多くの人が競合する」あらゆる問題に応用できます:コンサートチケット、ホテル予約、医療予約スロット、フラッシュセールなど。