Flugticket-Buchungssystem entwerfen: Platzreservierung, Zahlung, Overbooking-Schutz

1. Überblick

Flugtickets zu verkaufen klingt einfach: Kunde wählt Platz, zahlt, erhält Ticket. Das Problem: Tausende Menschen konkurrieren um eine begrenzte Anzahl von Plätzen. Zahlungen laufen über Drittanbieter, können verzögert sein oder mehrere Ergebnisse zurückliefern.

In diesem Artikel entwirfst du ein Ticketbuchungssystem von den Anforderungen bis zum Code. Das System umfasst zeitgebundene Platzreservierungen, idempotente Zahlungen, Schutz vor Doppelverkauf und Skalierbarkeit bei plötzlichen Lastspitzen. Nachdem du diesen Artikel gelesen hast, hast du ein Konzept, das du auf ähnliche Probleme anwenden kannst wie Konzerttickets, Hotelreservierungen oder Flash Sales.

2. Anforderungen

2.1. Funktionale Anforderungen

  • Flüge suchen und verfügbare Plätze anzeigen.
  • Platz reservieren (hold): einen oder mehrere Plätze für 10 Minuten blocken.
  • Zahlung durchführen. Bei Erfolg: Ticket ausstellen. Bei Fehler oder Ablauf: Platz freigeben.
  • Ticket stornieren und Rückerstattung gemäß Richtlinien vornehmen.

2.2. Skalierung und Einschränkungen

  • Etwa 5.000 Flüge/Tag, je 180 Plätze.
  • Lesezugriffe (Suchen, Platzanzeige) etwa 100× höher als Schreibzugriffe.
  • Beim Start von günstigen Tickets können 50.000 Anfragen/Sekunde auf wenige Dutzend Flüge treffen.
  • Kritische Constraints: Ein Platz darf niemals an zwei Personen verkauft werden. Die Anzahl verkaufter Tickets pro Klasse darf das Limit nicht überschreiten.

Hinweis: Fluggesellschaften überbuchen absichtlich um einen bestimmten Prozentsatz, z.B. 183 Tickets für 180 Economy-Plätze. Der Grund: Es gibt immer Passagiere, die nicht fliegen. Was wir verhindern, ist unbeabsichtigtes Overbooking durch Race Conditions. Das beabsichtigte Overbooking sollte ein kontrollierter Parameter sein.

Diese beiden Constraints bestimmen die Architektur. Platzverfügbarkeit braucht starke Konsistenz. Die Suchfunktion kann Eventually Consistent sein und aggressiv gecacht werden.

3. Datenmodell

PostgreSQL ist die Quelle der Wahrheit für die Platzverfügbarkeit:

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
);

Das Design hat zwei Ebenen der Platzverwaltung:

  • fare_inventory zählt pro Ticketklasse. Die CHECK-Bedingung ist ein letztes Sicherheitsnetz: Selbst wenn der Code buggy ist, wird die Datenbank Overbooking ablehnen.
  • seat verwaltet einzelne Plätze. Der Primärschlüssel (flight_id, seat_no) garantiert, dass jeder Platz nur eine Statuszeile hat.

4. API

GET  /flights?from=SGN&to=HAN&date=2026-12-20     -> Flüge (gecacht)
GET  /flights/{id}/seats                           -> Sitzplan (2–5 Sekunden Cache)
POST /bookings                                     -> Platz reservieren, bookingId + expiresAt zurückgeben
POST /bookings/{id}/payments                       -> Header Idempotency-Key erforderlich
POST /payments/webhook                             -> Payment Gateway ruft zurück
DELETE /bookings/{id}                              -> Stornieren

Reservierungsanfrage:

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

Der Sitzplan darf wenige Sekunden veraltet sein. Ein Benutzer könnte einen “freien” Platz sehen und dann beim Reservieren abgelehnt werden. Das ist ein akzeptabler Kompromiss, solange die Reservierungsphase immer die Datenbank überprüft.

5. Platzreservierung: Doppelverkauf verhindern

Ein häufiger Fehler ist “Lesen dann Schreiben”: SELECT status, sieht AVAILABLE, dann UPDATE. Zwei Transaktionen könnten beide lesen (Platz frei sehen) und beide schreiben. Ein Platz wird an zwei Personen verkauft.

Die Lösung ist conditional update: die Überprüfungsbedingung in die UPDATE-Anweisung integrieren und die Anzahl betroffener Zeilen zählen.

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 sperrt die Zeile während der UPDATE-Ausführung. Die zweite Transaktion muss warten, dann wird die WHERE-Bedingung mit den neuen Daten neu bewertet. Diesmal ist die Bedingung falsch, also gibt die Anweisung 0 Zeilen zurück. Die Bedingung held_until < now() ermöglicht es, abgelaufene reservierte Plätze zu “überschreiben”, ohne auf einen Cleanup-Job zu warten.

Der Service verbindet beide Schritte in einer Transaktion:

@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) {
            // Rollback von reserve und allen bereits gehaltenen Plätzen
            throw new SeatUnavailableException(req.seats());
        }

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

SeatUnavailableException ist eine RuntimeException, daher führt Spring ein Rollback der gesamten Transaktion durch. Der Kunde erhält entweder alle gewählten Plätze reserviert oder keine. Es gibt kein Szenario, in dem Platz 12A reserviert ist, aber 12B verloren gehen kann.

Hinweis: Um Deadlocks zu vermeiden, wenn zwei Kunden {12A, 12B} und {12B, 12A} wählen, sortiere seatNos vor dem Absenden. Postgres garantiert keine Sperrreihenfolge bei IN-Listen. Bei größeren Sitzgruppen verwende SELECT … ORDER BY seat_no FOR UPDATE vor dem Update.

Falls ein Platz gerade abgelaufen ist und jemand anderes ihn “stiehlt”, zählt der held-Zähler der alten Buchung immer noch. Der Cleanup-Job in Abschnitt 7 wird ihn später reduzieren.

6. Idempotente Zahlungen

Das Payment Gateway kann Webhooks mehrfach aufrufen. Benutzer können “Zahlen” zweimal klicken. Jede Geldtransaktion muss idempotent sein.

@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());   // wiederholter Aufruf: altes Ergebnis zurückgeben
        }

        // HELD -> CONFIRMED, nur erfolgreich wenn Buchung nicht abgelaufen ist
        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;
    }
}

Die transition-Methode ist wieder ein conditional update:

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

Der kritische Punkt ist das Rennen zwischen “Zahlung eingegangen” und “Reservierungszeit abgelaufen”. Beide Prozesse ändern den Status von HELD. Nur einer gewinnt. Wenn die Zahlung zu spät kommt, wird der Kunde automatisch erstattet, anstatt einen Platz zu erhalten, der bereits an jemand anderen verkauft wurde.

Die Spalte idempotency_key UNIQUE schützt vor dem Fall, dass zwei Anfragen denselben Schlüssel parallel ausführen. Beide sehen den alten Datensatz nicht, aber die zweite Anfrage schlägt mit DataIntegrityViolationException beim Insert fehl und wird zurückgerollt.

6.1. Outbox und Kafka

Ticketausstellung, E-Mails und Synchronisation mit dem Check-in-System laufen asynchron. Schicke Kafka nicht direkt in der Transaktion. Wenn DB committed aber der Kafka-Versand fehlschlägt (oder umgekehrt), werden Daten inkonsistent. Schreibe in die outbox-Tabelle in derselben Transaktion, dann pushes ein Relay zu Kafka:

@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();
        }
    }
}

Das Relay garantiert at-least-once, daher muss der Consumer auf der Ticketseite auch idempotent sein, z.B. indem er bereits verarbeitete bookingIds speichert. Mit bookingId als Schlüssel stellen wir sicher, dass alle Events einer Buchung in dieselbe Partition gehen und die Reihenfolge bleibt.

7. Cleanup abgelaufener Reservierungen

@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());
        }
    }
}

Der Job reduziert held um seatCount der Buchung, nicht um die Anzahl gerade freigegebener Plätze. Das ist wichtig, weil manche Plätze von anderen Kunden bereits “gestohlen” wurden, aber der held-Zähler wird immer noch für die alte Buchung gezählt. Bei mehreren Instanzen verwende SELECT … FOR UPDATE SKIP LOCKED oder ShedLock, um Duplikate zu vermeiden.

8. Skalierung bei Laststöße

Beim Start günstiger Tickets treffen 50.000 Anfragen/Sekunde auf einige wenige fare_inventory-Zeilen. Das ist eine Hot Row. Jede Transaktion wartet auf dasselbe Row-Lock. Die Datenbank fällt nicht durch CPU-Last aus, sondern durch erschöpften Connection Pool.

Die Strategie ist vorgefiltert vor der Datenbank:

  1. Virtueller Warteraum: Nur N Anfragen/Sekunde kommen auf die Buchungsseite, der Rest erhält ein Warteschlangen-Token.
  2. Schneller Filter auf Redis: Plätze mit SET NX sperren, um früh abgelehnte Anfragen sofort zu weigern.
  3. DB ist der endgültige Schiedsrichter.
<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);
    }
}

Falls tryEnter false zurückgibt, antwortet die API sofort mit 409 Conflict, ohne eine Transaktion zu öffnen. Das TTL von 15 Sekunden reicht für die Reservierungsphase in der DB, und der Key verschwindet automatisch bei einem Instanzabsturz.

Hinweis: Mache Redis nicht zur Quelle der Wahrheit für Platzverfügbarkeit. Bei einem Redis-Failover könnten Keys verloren gehen und zwei Personen könnten durchkommen. Das ist in Ordnung, da das conditional update in der DB sie immer noch blockiert. Umgekehrt: Wenn du nur Redis vertraust, werden Plätze doppelt verkauft.

Bei Hot Rows im fare_inventory-Zähler kannst du fare_inventory in mehrere “Buckets” aufteilen (z.B. 10 Zeilen, je 18 Plätze) und zufällig einen auswählen, um Lock-Konkurrenz zu reduzieren.

9. Tests gegen Doppelverkauf

Der wichtigste Test: 50 Threads versuchen, denselben Platz zu reservieren – nur einer gewinnt. Führe ihn mit echtem PostgreSQL via Testcontainers durch, da das Locking-Verhalten von H2 anders ist.

@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);
    }
}

Der Test geht davon aus, dass Testdaten (Flug 1001, freier Platz 12A) über Flyway oder data.sql geladen wurden. Das CountDownLatch startet alle Threads gleichzeitig für maximale Konkurrenz. Das try-with-resources von ExecutorService (Java 21) wartet, bis alle Tasks erledigt sind, bevor es schließt. Erwartetes Ergebnis: exakt ein Erfolg, 49× SeatUnavailableException.

10. Häufige Fehler

  • synchronized oder Lock in der JVM verwenden: Funktioniert nur bei einer Instanz. Zwei Pods = kaputt.
  • Optimistic Locking mit @Version auf Hot Rows: Logisch richtig, aber bei Laststöße sind Retry-Raten hoch. Conditional update ist effizienter, da kein Vorlesen nötig ist.
  • Transaktion offenlassen während Payment Gateway aufgerufen wird: Row-Locks werden Sekunden festgehalten. Reserviere, commit, zahle, bestätige in einer neuen Transaktion.
  • Zahlung nach Ablauf der Reservierungszeit vergessen zu verarbeiten: Das ist die größte Beschwerdequelle. Immer einen Branch für automatische Rückerstattung haben.
  • Sitzplan zu lange cachen: Benutzer wählen ständig bereits verkaufte Plätze. 2–5 Sekunden reichen.

11. Fazit

Ein Flugticket-Buchungssystem dreht sich um ein Prinzip: Trenne, was stark konsistent sein muss (Platzverfügbarkeit, Buchungsstatus), von dem, was verzögert sein darf (Suche, Benachrichtigungen, Ticketausstellung). Conditional Updates in der Datenbank lösen Konkurrenz ohne verteilte Locks. Eine Zustandsmaschine HELD → CONFIRMED/EXPIRED mit konditionalen Übergängen behandelt das Rennen zwischen Zahlung und Ablauf. Idempotency-Keys mit Outbox verhindern doppelte Transaktionen und Events. Redis und virtuelle Warteschlangen schützen die Datenbank vor Laststöße.

Du kannst dieses Framework auf jedes Problem mit “begrenzte Ressource, viele Konkurrenten” anwenden: Konzerttickets, Hotelzimmer, Arzttermine oder Flash Sales.

system-designspring-bootconcurrencyrediskafka