Ticketing-System entwerfen: Plätze sperren und Reservierungen ablaufen lassen
1. Überblick
Wenn ein Blockbuster in den Verkauf geht, klicken tausende Nutzer gleichzeitig auf einen Sitzplan, der aber nur wenige hundert Plätze hat. Das System muss sicherstellen, dass jeder Platz nur einer Person gehört. Plätze werden während der Zahlung temporär reserviert und müssen sich automatisch freigeben, wenn der Kunde den Prozess abbricht.
Dieser Artikel führt dich durch das gesamte Design: Anforderungen, Datenmodell, API, Verarbeitungsabläufe und Herausforderungen. Der Beispielcode nutzt Java 21, Spring Boot 3.x, SQL und Redis. Danach kannst du einen sicheren Reservierungsmechanismus unter hoher Concurrency implementieren. Du wirst auch lernen, wie man Platzfreigaben bei Ablauf handhabt, ohne auf einen Cleanup-Job angewiesen zu sein.
2. Anforderungen
2.1. Funktionale Anforderungen
- Der Nutzer sieht einen Sitzplan für eine Vorstellung mit den Zuständen verfügbar, reserviert oder verkauft.
- Der Nutzer wählt maximal 8 Plätze und reserviert diese für 10 Minuten.
- Reservierung ist Alles-oder-Nichts: Kann ein Platz nicht reserviert werden, wird keiner reserviert.
- Zahlt der Nutzer während der Reservierungszeit, wird der Platz als verkauft markiert.
- Nach 10 Minuten ohne Zahlung wird der Platz automatisch wieder verfügbar.
2.2. Umfang und Randbedingungen
- Etwa 500 Kinos, jedes mit 10 Sälen, jeder Saal mit 100–300 Plätzen.
- Im Normalbetrieb hunderte Request/Sekunde. Beim Start eines großen Films bis zu 50.000 Request/Sekunde, konzentriert auf einige wenige Vorstellungen.
- Keine doppelten Verkäufe erlaubt. Das ist eine harte Anforderung.
- Lesevorgänge überwiegen massiv: Auf eine Reservierung kommen Dutzende Sitzplan-Abfragen.
- Zahlungen laufen über ein externes Gateway. Callbacks können verspätet ankommen, doppelt ankommen oder nie ankommen.
3. Datenmodell
Die zentrale Tabelle ist show_seat. Jede Zeile repräsentiert einen Platz in einer bestimmten Vorstellung. Wir lagern “Reservierungen” nicht in eine separate Tabelle aus, da Platzstatus und Reservierungsdaten in einer einzigen atomaren Abfrage aktualisiert werden müssen.
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);
Wichtige Punkte:
- Der Primärschlüssel
(showtime_id, seat_code)ermöglicht sehr schnelle Updates über alle Plätze einer Vorstellung. hold_idgruppiert alle Plätze einer einzelnen Reservierung. Der Zahlungsbestätigungsschritt wird die gesamte Gruppe über diesehold_idbestätigen.- Der Index
(status, hold_expires_at)wird vom Cleanup-Job in Abschnitt 7 benötigt.
Die Tabellen showtime, booking und payment sind Standard-CRUD-Tabellen und werden hier nicht detailliert behandelt.
4. API
GET /showtimes/{showtimeId}/seats -> Sitzplan + Status
POST /showtimes/{showtimeId}/holds -> Platz reservieren, Body: {"seats":["F5","F6"]}
201 {"holdId":"...","expiresAt":"..."} | 409 wenn belegt
POST /holds/{holdId}/checkout -> Zahlungssession erstellen (Header Idempotency-Key)
DELETE /holds/{holdId} -> Nutzer bricht ab, Platz sofort freigeben
POST /payments/callback -> Payment-Gateway ruft zurück
Antworte mit 409 Conflict, wenn ein Platz bereits von jemand anderem reserviert ist. Der Client nutzt diesen Code, um den Sitzplan zu aktualisieren und den Nutzer einzuladen, einen anderen Platz zu wählen.
5. Verarbeitungsablauf
Nutzer API DB / Redis Payment-Gateway
| wählt Platz | | |
|------------>| UPDATE mit Bedingung | |
| |---------------------->| HELD, verfällt nach 10' |
|<------------| 201 holdId | |
| zahlen | | |
|------------>| Payment erstellen (8')|------------------------->|
| | | callback SUCCESS |
| |<-------------------------------------------------|
| | HELD -> BOOKED wenn | |
| | noch nicht verfallen | |
Die Zahlungssession verfällt nach 8 Minuten, kürzer als die Reservierungsdauer. Das 2-Minuten-Puffer stellt sicher, dass die meisten Callbacks vor Ablauf der Reservierung eintreffen.
6. Plätze temporär sperren
Benötigte Dependencies:
<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. Die bedingte UPDATE-Abfrage ist die Quelle der Wahrheit
Ein häufiger Fehler ist: erst lesen, dann schreiben. Mit SELECT prüfst du, ob der Platz frei ist, dann UPDATE. Zwischen den zwei Abfragen kann eine andere Request dazwischenkommen und den Platz reservieren. Resultat: zwei Leute denken, sie haben den Platz reserviert.
Der richtige Weg ist, die Prüfung in die WHERE-Bedingung zu integrieren und die Anzahl betroffener Zeilen zu zählen:
@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");
}
}
So funktioniert der Code:
- Das
UPDATEsperrt jede Platz-Zeile mit einem Row-Lock. Zwei Transaktionen, die beide auf PlatzF5abzielen, müssen sich anstellen. Die spätere Transaction wertet dieWHERE-Bedingung neu aus, siehtstatus = 'HELD'und aktualisiert diese Zeile nicht. - Die Bedingung
status = 'HELD' AND hold_expires_at < :nowist Lazy Expiration. Ein Platz mit abgelaufener Reservierung gilt als frei, auch wenn ihn noch kein Cleanup-Job gelöscht hat. - Ist die Zahl der Updates kleiner als die Anzahl gewünschter Plätze, werfen wir eine Exception.
@Transactionalrollback alles zurück, somit erhalten wir Alles-oder-Nichts. Clockwird in den Service injiziert, damit Tests die Zeit steuern können.
Angenommen, Nutzer A reserviert F5, F6, dann versucht Nutzer B F6, F7 zu reservieren. B erhält:
HTTP 409 Conflict
{"error":"Seats [F6, F7] of showtime 42 are not available"}
Platz F7 von B ist noch verfügbar, aber Bs Transaction wurde zurückgerollt.
Wichtig: Sortiere
seatCodesvor dem Update. Wenn A in der ReihenfolgeF5 → F6sperrt und B in der ReihenfolgeF6 → F5, können die zwei Transaktionen deadlocken. Mit PostgreSQL hängt die Lock-Reihenfolge von der Zeilenscan-Reihenfolge ab. Um sicherzugehen, kannst duSELECT ... ORDER BY seat_code FOR UPDATEvor dem Update ausführen.
6.2. Redis als schnelle Blockierungsschicht
Bei Lastspitzen dringen 50.000 Anfragen gleichzeitig auf die paar hundert Zeilen einer Vorstellung. Die Datenbank muss eine extrem lange Lock-Warteschlange verwalten. Wir stellen Redis davor, um Anfragen früh abzulehnen, die sicher verlieren. Das folgende Lua-Skript macht Multi-Key-Operationen atomar:
@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;
}
}
So wird es verwendet:
- Redis führt das Lua-Skript auf einem einzigen Thread aus. Keine Anfrage kann zwischen der
EXISTS-Prüfung undSETdazwischenkommen. PXsetzt eine TTL für den Key, Redis löscht den Key automatisch nach Ablauf. Das ist Redisbis natürlicher Ablaufmechanismus.- Der Teil
{showtimeId}ist ein Hash Tag. In Redis Cluster landen alle Keys einer Vorstellung im gleichen Slot. Ohne Hash Tag wirft das Multi-Key-Skript einenCROSSSLOT-Fehler.
Der komplette Ablauf ist: tryHold auf Redis zuerst aufrufen. Bei Fehler sofort 409 antworten. Bei Erfolg das UPDATE aus Abschnitt 6.1 ausführen. Wenn UPDATE fehlschlägt, den gerade gesetzten Redis-Key löschen.
Wichtig: Redis ist nicht die Quelle der Wahrheit. Redis kann bei Failover Daten verlieren und Key-Ablauf kann um Millisekunden von der DB abweichen. Die endgültige Entscheidung trifft immer das bedingte
UPDATE. Wenn dein System keine Lastspitzen hat, lass die Redis-Schicht weg, um eine Komponente weniger zu betreiben.
7. Reservierungen ablaufen lassen
Die Korrektheit des Ablaufs wird bereits durch die hold_expires_at < :now-Bedingung in Abschnitt 6.1 sichergestellt. Aber der Sitzplan zeigt noch HELD an, bis jemand ihn überschreibt. Daher brauchen wir einen Cleanup-Job, um die Anzeige zu aktualisieren:
@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 Event publishen, um Cache des Sitzplans zu aktualisieren
}
}
}
Dieser Job läuft alle 15 Sekunden. Es schadet nicht, wenn er verspätet läuft oder auf mehreren Instanzen gleichzeitig läuft, denn die Abfrage ist idempotent. Durch Lazy Expiration kann der Job auch einige Minuten ausfallen, ohne Doppelverkäufe zu verursachen. Die einzige Konsequenz ist eine veraltete Sitzplan-Anzeige in dieser Zeit. Vergiss nicht, @EnableScheduling in der Konfiguration zu aktivieren.
Die Zahlungsbestätigung muss die Ablaufdauer in der gleichen Abfrage prüfen:
@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;
}
Diese Methode gibt false zurück, wenn die Reservierung abgelaufen ist oder der Platz von jemand anderem erneut reserviert wurde. Beide Fälle müssen im Zahlungsschritt behandelt werden.
8. Konsistenz mit Zahlungen
Callbacks vom Payment-Gateway sind die größte Fehlerquelle. Es gibt drei Szenarien, die du handhaben musst:
- Callback kommt zweimal an. Speichere
payment_idmit einerUNIQUE-Einschränkung. Beim zweiten Mal siehst du, dass die Transaktion bereits verarbeitet wurde, antwortest mit200und machst nichts weitere. - Callback kommt nach Ablauf der Reservierung an.
confirmgibtfalsezurück. Das System hat bereits Geld eingezogen, kann aber den Platz nicht liefern. Markiere die Buchung alsFAILEDund gib automatisch Geld zurück. Versuche nicht, den Platz “zurückzuerobern”, denn jemand anders könnte ihn bereits gekauft haben. - Callback kommt nie an. Ein regelmäßiger Abgleich-Job fragt den Status von
PENDING-Zahlungen älter als 15 Minuten beim Gateway ab und wendet dann die Logik wie in den zwei Fällen oben an.
Nach erfolgreicher confirm sollten Schritte wie E-Mail-Versand, QR-Ticket-Generierung oder Treuepunkte-Gutschrift nicht in der gleichen Transaction laufen. Schreib eine BookingConfirmed-Zeile in die Outbox-Tabelle in dieser Transaction. Ein separater Prozess pushed das Event zu Kafka, mit Topic booking-events und Key als bookingId. Das stellt sicher, dass das Event nur versendet wird, wenn die DB committed hat, und wenn sie committed, wird das Event definitiv versendet.
9. Lastspitzen bewältigen
Die Redis-Schicht in Abschnitt 6.2 löst nur das Schreib-Problem. Wenn ein großer Film in den Verkauf geht, brauchst du diese zusätzlichen Maßnahmen:
- Virtuelle Warteschlange. Jede populäre Vorstellung lässt nur N Nutzer gleichzeitig auf die Sitzplan-Seite. Andere erhalten ein Queue-Token, gespeichert in einem Redis Sorted Set mit Score als Ankunftszeit. Wenige hundert Plätze brauchen nicht 50.000 Nutzer gleichzeitig.
- Sitzplan cachen. Der Plan wird aus Redis oder CDN gelesen und via
SeatsHeld/SeatsReleased-Events aktualisiert. Eine 1–2 Sekunden alte Anzeige ist akzeptabel, denn der Reservierungsschritt prüft trotzdem genau. - Rate-Limit pro Nutzer. Zum Beispiel maximal 5 Reservierungen pro Minute, um Bot-Scraping zu blockieren.
- Partitionierung nach
showtime_id. Alle Konflikte bleiben in einer Vorstellung. Du kannst die DB nachshowtime_idsharden, ohne verteilte Transaktionen zu brauchen.
10. Concurrency testen
Das wichtigste Verhalten zu prüfen ist: Wenn mehrere Nutzer den gleichen Platz reservieren, gewinnt nur einer.
@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);
}
}
Test-Erklärung:
CountDownLatchhält 20 Threads an der Startlinie und lässt sie dann alle gleichzeitig los, um Konkurrenz zu maximieren.try-with-resourcesaufExecutorService(Java 21) wartet, bis alle Tasks fertig sind, bevor die Assertions laufen.- Die zweite Assertion prüft, dass beide Plätze zur gleichen
hold_idgehören, das heißt, es gibt kein “jeder bekommt einen Platz”-Szenario.
Der Test läuft auf H2 mit schema.sql aus Abschnitt 3 (konvertiere UUID-Typ zu H2s Version, die bereits unterstützt wird). Du musst ein Clock.systemUTC()-Bean deklarieren. Um Ablauf zu testen, ersetze dieses Bean durch eine Fake-Clock, die die Zeit vorspulen kann.
Wichtig: H2 emuliert das Lock-Verhalten von PostgreSQL oder MySQL nicht exakt. Für Concurrency-Code solltest du auch auf der echten Production-Datenbank testen, zum Beispiel via Testcontainers.
11. Häufige Fehler
- Erst prüfen, dann schreiben, also
SELECTdannUPDATE, ohne Lock. Das ist die Nummer-Eins-Ursache für Doppelverkäufe. - Vollständig vom Cleanup-Job abhängen, um Plätze freizugeben. Job fällt aus und Plätze sitzen fest. Lazy Expiration in
WHEREbeseitigt dieses Risiko. - Redis-Locks als Quelle der Wahrheit nutzen. Lock geht bei Failover verloren, zwei Nutzer reservieren den gleichen Platz.
SELECT ... FOR UPDATE10 Minuten halten lassen, während man auf Zahlung wartet. Der Connection-Pool läuft schnell leer. DB-Locks sollten nur Millisekunden existieren. Lange Haltezeiten müssen als Daten modelliert werden, speziell diehold_expires_at-Spalte.- Zeit von den Uhren mehrerer Maschinen nehmen. Einige dich auf eine Zeitquelle: entweder DB-
now()oder AppsClockmit stramm synchronisiertem NTP.
12. Fazit
Das Herzstück eines Ticketing-Systems ist eine bedingte UPDATE-Abfrage. Diese Abfrage sperrt Plätze atomar und behandelt abgelaufene Reservierungen als frei, daher gibt es nie Doppelverkäufe und der Job läuft korrekt auch ohne Cleanup. Weitere Komponenten bauen um diesen Kern:
- Redis Lua-Skript blockiert Lädt früh.
- Sweeper-Job hilft der UI, den korrekten Status anzuzeigen.
- Idempotenz und Abgleich handhaben unerwartete Payment-Callbacks.
- Outbox mit Kafka versendet zuverlässige Events.
- Virtuelle Warteschlange puffert Lastspitzen.
Beim Implementieren fang mit dem SQL-Kern und Concurrency-Tests an. Nur Redis und Warteschlange hinzufügen, wenn reale Last-Metriken zeigen, dass du sie brauchst.