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_inventoryzählt pro Ticketklasse. DieCHECK-Bedingung ist ein letztes Sicherheitsnetz: Selbst wenn der Code buggy ist, wird die Datenbank Overbooking ablehnen.seatverwaltet 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
seatNosvor dem Absenden. Postgres garantiert keine Sperrreihenfolge beiIN-Listen. Bei größeren Sitzgruppen verwendeSELECT … ORDER BY seat_no FOR UPDATEvor 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:
- Virtueller Warteraum: Nur N Anfragen/Sekunde kommen auf die Buchungsseite, der Rest erhält ein Warteschlangen-Token.
- Schneller Filter auf Redis: Plätze mit
SET NXsperren, um früh abgelehnte Anfragen sofort zu weigern. - 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
synchronizedoder Lock in der JVM verwenden: Funktioniert nur bei einer Instanz. Zwei Pods = kaputt.- Optimistic Locking mit
@Versionauf 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.