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:

  1. Der Client ruft POST /events/{id}/queue auf und erhält queueId.
  2. 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 den accessToken.
  3. Der Client sendet den Token mit POST /events/{id}/holds und einer Liste von Plätzen.
  4. Der Client zahlt. Der Zahlungsanbieter ruft einen Webhook auf, das System ruft POST /orders/{id}/confirm mit einem Idempotency-Schlüssel auf.
  5. 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:

  • increment gibt eine globale Ordnungsnummer zurück. Sie verwenden nicht den Zeitstempel als Score, weil viele Anfragen zur gleichen Millisekunde ankommen können.
  • addIfAbsent entspricht ZADD 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. removeRangeByScore entfernt Personen, deren Sitzung abgelaufen ist, dann gibt zCard die 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, sehen status = 'HELD' und erhalten updated = 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 seatIds vor 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 Hundert­tausende 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 AVAILABLE zurü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 Betriebs­komplexitä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.

system-designredisspring-bootconcurrencykafka