Ticketverkaufssystem für Konzerte: virtuelle Warteschlangen für Millionen
1. Überblick
Ein großes Konzert öffnet den Ticket-Verkauf um 10:00 Uhr. Nur wenige Sekunden später klicken Millionen von Menschen gleichzeitig auf „Ticket kaufen”, um sich etwa 50.000 Plätze zu sichern. Würde man alle diese Anfragen direkt zur Datenbank leiten, würde das System zusammenbrechen. Blockiert man falsch, können zwei Personen denselben Platz kaufen.
Dieser Beitrag führt Sie durch jeden Schritt des Designs: Anforderungen, Datenmodell, APIs und Verarbeitungsablauf. Der Fokus liegt auf drei schwierigen Punkten: Lastspitzen, Platzkonkurrenz und Konsistenz beim Zahlungsvorgang. Danach werden Sie eine virtuelle Warteschlange mit Redis aufbauen, einen Mechanismus zur Reservierung von Plätzen ohne Überbuchung und einen sicheren Bestätigungsfluss, der mehrfache Aufrufe verträgt.
2. Anforderungen an die Funktionalität
2.1. Funktionale Anforderungen
- Der Benutzer betritt die Veranstaltungsseite und wird vor der Öffnungszeit in eine virtuelle Warteschlange eingereiht.
- Das System führt Benutzer satzweise in die Platzauswahlzone ein – erste Personen zuerst.
- Der Benutzer wählt maximal 4 Plätze. Plätze werden reserviert (hold) für 5 Minuten zur Zahlung.
- Zahlung erfolgreich → Platz wechselt zu Verkauft-Status und Ticket wird ausgegeben.
- Wenn die Frist abläuft, ohne dass gezahlt wird, wird der Platz zum Bestand zurückgegeben.
2.2. Größenordnung und Einschränkungen
| Metriken | Geschätzter Wert |
|---|---|
| Gleichzeitige Benutzer beim Verkaufsstart | 2.000.000 |
| Anzahl der Plätze | 50.000 |
| Spitzenlast auf der Veranstaltungsseite | ~500.000 RPS |
| Anzahl der Benutzer gleichzeitig in der Platzauswahlzone | 5.000 |
Die wichtigste Einschränkung ist niemals einen Platz doppelt zu verkaufen. Konsistenz muss hier starke Konsistenz (Strong Consistency) sein. Dagegen kann die Position in der Warteschlange ungefähr sein: es reicht aus, „etwa 12.000 Personen vor Ihnen” anzuzeigen.
Aus diesen beiden Zahlen ergibt sich eine wichtige Erkenntnis: Nur 2,5 % der Benutzer bekommen Tickets. Das System muss daher die meisten Anfragen auf der billigsten Ebene ablehnen oder verzögern – bevor sie die Datenbank erreichen.
3. Architektur und Verarbeitungsfluss
Client ──> CDN (statische Seite, Warteseite)
│
├─> Queue Service ──> Redis (ZSET Warteschlange, Access Token)
│ ▲
│ └── Admitter (geplant, N Benutzer/Sekunde zulassen)
│
└─> Booking Service (erfordert Access Token)
├─> PostgreSQL (seat, order, outbox)
└─> Kafka (ticket-issued) ──> Ticket/Email Service
Der hauptsächliche Ablauf verläuft wie folgt:
- Der Client ruft
POST /events/{id}/queueauf und erhältqueueId. - Der Client fragt regelmäßig per
GET /events/{id}/queue/{queueId}ab – alle paar Sekunden – um seine Position zu sehen. Wenn die Reihe kommt, erhält der Client denaccessToken. - Der Client sendet den Token mit
POST /events/{id}/holdsund einer Liste von Plätzen. - Der Client zahlt. Der Zahlungsanbieter ruft einen Webhook auf, das System ruft
POST /orders/{id}/confirmmit einem Idempotency-Schlüssel auf. - Der Booking Service schreibt Bestellung und Outbox-Event in einer einzigen Transaktion. Danach wird das Event an Kafka gepusht, um Tickets auszugeben.
Die Warteseite wird über CDN statisch bereitgestellt. Nur die APIs zum Beitreten und Abfragen gehen ins Backend – und beide greifen nur auf Redis zu.
4. Datenmodell
CREATE TABLE seat (
event_id BIGINT NOT NULL,
seat_id VARCHAR(16) NOT NULL,
section VARCHAR(16) NOT NULL,
price NUMERIC(12,2) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | SOLD
held_by BIGINT,
hold_expires_at TIMESTAMP,
PRIMARY KEY (event_id, seat_id)
);
CREATE TABLE ticket_order (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
event_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING | PAID | EXPIRED
idempotency_key VARCHAR(64) NOT NULL UNIQUE
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
Der Platzstatus befindet sich direkt in der seat-Tabelle. Dadurch benötigt das Halten eines Platzes nur eine einzelne bedingte UPDATE-Anweisung. Die UNIQUE-Einschränkung auf idempotency_key ist die letzte Schranke gegen doppelte Bestellungen.
5. Virtuelle Warteschlange mit Redis
Füge Abhängigkeit hinzu:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
5.1. Benutzer zur Warteschlange hinzufügen
Die Warteschlange ist ein Redis Sorted Set. Der Score ist eine aufsteigende Ordnungsnummer, erhalten durch INCR:
@Service
public class QueueService {
private final StringRedisTemplate redis;
public QueueService(StringRedisTemplate redis) {
this.redis = redis;
}
public long join(long eventId, long customerId) {
String queueKey = "queue:" + eventId;
String member = String.valueOf(customerId);
Long existing = redis.opsForZSet().rank(queueKey, member);
if (existing != null) {
return existing;
}
Long seq = redis.opsForValue().increment("queue:seq:" + eventId);
redis.opsForZSet().addIfAbsent(queueKey, member, seq);
return redis.opsForZSet().rank(queueKey, member);
}
public Long position(long eventId, long customerId) {
return redis.opsForZSet().rank("queue:" + eventId, String.valueOf(customerId));
}
}
Erklärung:
incrementgibt eine globale Ordnungsnummer zurück. Sie verwenden nicht den Zeitstempel als Score, weil viele Anfragen zur gleichen Millisekunde ankommen können.addIfAbsententsprichtZADD NX. Selbst wenn ein Benutzer F5 mehrfach drückt, wird er nicht ans Ende der Warteschlange verschoben.rank(ZRANK) hat eine Zeitkomplexität von O(log N). Mit 2 Millionen Elementen kostet dieser Befehl immer noch nur wenige Mikrosekunden.
Drei Personen, die hintereinander eintreten, erhalten folgende Ergebnisse:
join(1, 101) -> 0
join(1, 202) -> 1
join(1, 101) -> 0 (erneut eingetreten, Position bleibt gleich)
Hinweis: Um Bots zu blockieren, authentifizieren Sie Benutzer (Login, Captcha) bevor sie die Warteschlange betreten. Sobald sie im ZSET sind, belegt ein Bot einen Platz wie ein echter Benutzer.
5.2. Admitter: Benutzer satzweise zulassen
Ein Job läuft jede Sekunde. Dieser Job prüft, wie viele Plätze noch in der Platzauswahlzone frei sind, und holt genau diese Anzahl von Personen von der Front der Warteschlange:
@Component
public class Admitter {
private static final int MAX_ACTIVE = 5_000;
private static final Duration TOKEN_TTL = Duration.ofMinutes(10);
private final StringRedisTemplate redis;
public Admitter(StringRedisTemplate redis) {
this.redis = redis;
}
@Scheduled(fixedRate = 1000)
public void admit() {
long eventId = 1L;
String activeKey = "active:" + eventId;
long now = System.currentTimeMillis();
redis.opsForZSet().removeRangeByScore(activeKey, 0, now);
long active = Optional.ofNullable(redis.opsForZSet().zCard(activeKey)).orElse(0L);
long slots = MAX_ACTIVE - active;
if (slots <= 0) return;
var batch = redis.opsForZSet().popMin("queue:" + eventId, slots);
if (batch == null) return;
for (var tuple : batch) {
String customerId = tuple.getValue();
String token = UUID.randomUUID().toString();
redis.opsForValue().set("access:" + token, customerId, TOKEN_TTL);
redis.opsForValue().set("token-of:" + eventId + ":" + customerId, token, TOKEN_TTL);
redis.opsForZSet().add(activeKey, customerId, now + TOKEN_TTL.toMillis());
}
}
}
Erklärung:
active:{eventId}ist ein ZSET mit dem Score als Ablaufzeitpunkt.removeRangeByScoreentfernt Personen, deren Sitzung abgelaufen ist, dann gibtzCarddie Anzahl der Personen in der Platzauswahlzone an.popMin(ZPOPMIN) holt und entfernt die N ersten Personen aus der Warteschlange.- Beim Abfragen liest der Client
token-of:.... Wenn der Schlüssel existiert, bedeutet das, dass die Reihe kommt, und der Client erhält den Token.
Sie müssen @EnableScheduling aktivieren und diesen Job auf genau einer Instanz ausführen – z. B. mit ShedLock oder Leader Election. Wenn zwei Instanzen gleichzeitig laufen, kann die Platzauswahlzone MAX_ACTIVE überschreiten.
Sie brauchen auch nicht mehrere Admitter, um 2 Millionen Menschen zu verarbeiten. Die Geschwindigkeit des Zulassens wird durch die Kapazität des Booking Service begrenzt, nicht durch Redis.
5.3. Anfragen ohne Token blockieren
@Component
public class AccessTokenFilter extends OncePerRequestFilter {
private final StringRedisTemplate redis;
public AccessTokenFilter(StringRedisTemplate redis) {
this.redis = redis;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().contains("/holds");
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String token = req.getHeader("X-Queue-Token");
String customerId = token == null ? null : redis.opsForValue().get("access:" + token);
if (customerId == null) {
res.sendError(HttpServletResponse.SC_FORBIDDEN, "Nicht zugelassen");
return;
}
req.setAttribute("customerId", Long.valueOf(customerId));
chain.doFilter(req, res);
}
}
Dieser Filter verwendet den Token als Lookup-Schlüssel und vertraut nicht der vom Client gesendeten customerId. Benutzer können nicht vordrängeln, indem sie direkt die API /holds aufrufen.
6. Plätze reservieren: Umgang mit Konkurrenz
Mehrere Personen können denselben Platz A-12 wählen. Der einfachste und richtige Weg ist, die Datenbank entscheiden zu lassen – mit einer einzigen bedingten UPDATE-Anweisung:
@Service
public class SeatHoldService {
private static final Duration HOLD_TTL = Duration.ofMinutes(5);
private final JdbcTemplate jdbc;
public SeatHoldService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public void hold(long eventId, long customerId, List<String> seatIds) {
if (seatIds.size() > 4) throw new IllegalArgumentException("Max 4 Plätze");
Timestamp now = Timestamp.from(Instant.now());
Timestamp expires = Timestamp.from(Instant.now().plus(HOLD_TTL));
for (String seatId : seatIds.stream().sorted().toList()) {
int updated = jdbc.update("""
UPDATE seat SET status = 'HELD', held_by = ?, hold_expires_at = ?
WHERE event_id = ? AND seat_id = ?
AND (status = 'AVAILABLE' OR (status = 'HELD' AND hold_expires_at < ?))
""", customerId, expires, eventId, seatId, now);
if (updated == 0) {
throw new SeatUnavailableException(seatId);
}
}
}
}
class SeatUnavailableException extends RuntimeException {
SeatUnavailableException(String seatId) { super("Platz vergeben: " + seatId); }
}
Erklärung:
- Die
WHERE-Klausel prüft und aktualisiert in einer einzigen atomaren Anweisung. Die Datenbank sperrt diese Zeile. Später eingehende Anfragen warten, bewerten die Bedingung dann neu, sehenstatus = 'HELD'und erhaltenupdated = 0. - Die Bedingung
hold_expires_at < ?ermöglicht die Rückgewinnung abgelaufener Reservierungen, ohne auf einen Cleanup-Job zu warten. - Wenn ein Platz fehlschlägt, rollback die Exception die gesamte Transaktion zurück. Ein Benutzer wird niemals „halb reserviert”.
- Das Sortieren von
seatIdsvor dem Update hilft allen Transaktionen, in der gleichen Reihenfolge zu sperren und vermeidet Deadlock, wenn zwei Personen sich überkreuzende Plätze wählen – z. B. {A-1, A-2} und {A-2, A-1}.
Hinweis: Machen Sie nicht „SELECT zum Prüfen, dann UPDATE” ohne Sperre. Das ist die klassische Race Condition, die zu Doppelverkäufen führt.
6.1. Test für Schutz vor Doppelverkauf
Wir lassen 50 Threads gleichzeitig um einen Platz kämpfen. Das erwartete Ergebnis ist, dass genau ein Thread erfolgreich ist.
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
@JdbcTest
@Import(SeatHoldService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class SeatHoldServiceTest {
@Autowired JdbcTemplate jdbc;
@Autowired SeatHoldService service;
@Test
void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
jdbc.update("DELETE FROM seat");
jdbc.update("INSERT INTO seat(event_id, seat_id, section, price) VALUES (1, 'A-12', 'VIP', 3000000)");
AtomicInteger success = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (long customer = 1; customer <= 50; customer++) {
long id = customer;
executor.submit(() -> {
try {
service.hold(1L, id, List.of("A-12"));
success.incrementAndGet();
} catch (SeatUnavailableException ignored) { }
});
}
}
assertThat(success.get()).isEqualTo(1);
assertThat(jdbc.queryForObject(
"SELECT status FROM seat WHERE seat_id = 'A-12'", String.class)).isEqualTo("HELD");
}
}
Die seat-Tabelle wird aus src/test/resources/schema.sql erstellt. Verwende das CREATE TABLE seat aus Abschnitt 4, ersetze BIGSERIAL durch BIGINT AUTO_INCREMENT, wenn du die anderen Tabellen hinzufügst. @Transactional(NOT_SUPPORTED) schaltet die Transaktion um den Test aus, damit die Daten committed werden und andere Threads sie sehen.
7. Zahlung und Konsistenz
Der Webhook des Zahlungsanbieters kann mehrfach aufgerufen werden. Daher muss der Bestätigungsschritt idempotent sein:
@Transactional
public void confirm(long orderId, long customerId, long eventId, String idempotencyKey) {
int inserted = jdbc.update("""
INSERT INTO ticket_order(id, customer_id, event_id, status, idempotency_key)
VALUES (?, ?, ?, 'PAID', ?) ON CONFLICT (idempotency_key) DO NOTHING
""", orderId, customerId, eventId, idempotencyKey);
if (inserted == 0) return; // bereits verarbeitet
int sold = jdbc.update("""
UPDATE seat SET status = 'SOLD'
WHERE event_id = ? AND held_by = ? AND status = 'HELD'
""", eventId, customerId);
if (sold == 0) throw new IllegalStateException("Hold abgelaufen, Rückerstattung erforderlich");
jdbc.update("INSERT INTO outbox(topic, payload) VALUES ('ticket-issued', ?)",
"{\"orderId\":" + orderId + "}");
}
Dieser Code verarbeitet drei Dinge:
ON CONFLICT DO NOTHING(PostgreSQL-Syntax) stellt sicher, dass wiederholte Webhook-Aufrufe keine doppelten Bestellungen erzeugen.- Plätze wechseln nur zu
SOLD, wenn sie von diesem Kunden reserviert wurden. Wenn die Reservierung abgelaufen ist und der Platz einem anderen gehört, rollback die Transaktion zurück. Dann müssen die Bestellung in den Rückerstattungsfluss gehen. - Transactional Outbox: Events werden in derselben Transaktion wie die Bestellung geschrieben. Ein separater Prozess (oder Debezium) liest die
outbox-Tabelle und pusht zu Kafka. Dadurch gibt es keine Situation, in der eine Bestellung gespeichert ist, aber das Ticket nicht ausgegeben wird, oder umgekehrt.
Hinweis: Setzen Sie die TTL der Reservierung etwas länger als das Timeout der Zahlungsseite – z. B. Reservierung 5 Minuten, Zahlungsseite 4 Minuten. Dieser Puffer reduziert die Fälle, in denen der Kunde bezahlt hat, aber die Reservierung gerade abgelaufen ist.
8. Schwierigkeiten und häufige Fehler
- Lastspitze in der obersten Schicht: Die Warteseite muss eine statische Seite auf CDN sein. Die Poll-API sollte einen Header mit empfohlenem Poll-Intervall zurückgeben – z. B. 5–15 Sekunden je nach Position. Wenn Clients jede Sekunde abfragen, erzeugen 2 Millionen Menschen 2 Millionen RPS zu Redis.
- Redis ist ein Single Point of Failure: Verwenden Sie Redis Cluster oder Sentinel. Für ein großes Event liegt das gesamte ZSET in einem Key, also auf einem Shard. Ein einzelner Redis-Node kann immer noch Hunderttausende Ops/s verarbeiten, aber Sie sollten im Voraus messen.
- Anzeige „verfügbare Plätze” ist nicht genau: Die Anzahl der verfügbaren Plätze auf der Benutzeroberfläche kann aus dem Cache gelesen werden und kann um einige Sekunden abweichen. Nur die UPDATE-Anweisung in Abschnitt 6 ist die Quelle der Wahrheit.
- Verwaiste Reservierungen: Ein Benutzer reserviert einen Platz, schließt aber den Tab. Die Ablaufdauer im UPDATE verarbeitet die Rückgewinnung bereits. Sie müssen nur einen regelmäßigen Job hinzufügen, um abgelaufene Plätze auf
AVAILABLEzurückzusetzen, damit die Benutzeroberfläche korrekt angezeigt wird. - Wann Sie KEINE virtuelle Warteschlange brauchen: Bei Events mit Hunderten von Plätzen und moderatem Datenverkehr ist Rate Limiting plus bedingte UPDATE ausreichend. Die virtuelle Warteschlange fügt Betriebskomplexität hinzu und verlangsamt die Benutzererfahrung. Nutzen Sie sie nur, wenn die Anzahl der kaufwilligen Personen die Kapazität der Booking-Schicht weit übersteigt.
9. Fazit
Das Ticketverkaufssystem verkraftet hohe Lasten durch Trennung von zwei Belangen. Die virtuelle Warteschlange auf Redis absorbiert Lastspitzen und wahrt Fairness. Die Datenbank verarbeitet nur die zugelassenen Personen und stellt über bedingte UPDATE sicher, dass Plätze nicht doppelt verkauft werden.
Sie haben gesehen, wie ZADD NX und ZPOPMIN die Warteschlange organisieren und Benutzer satzweise zulassen, wie Plätze atomar reserviert werden und Deadlock vermieden wird, und wie idempotente Bestätigungen kombiniert mit Transactional Outbox Events zu Kafka pushen. Von dieser Grundlage aus können Sie erweitern: Bot-Schutz, priorisierte Warteschlangen für Mitglieder oder mehrere parallele Events.