Flash-Sale-Design: 10.000 Artikel in 1 Sekunde ohne Überverkauf

1. Übersicht

Flash Sale ist ein klassisches Problem des Systemdesigns. Hunderttausende Menschen drücken gleichzeitig auf „Kaufen” zum selben Zeitpunkt. Das Lager hat aber nur wenige tausend Produkte. Das System muss plötzliche Lasten aushalten und darf auf keinen Fall mehr verkaufen als vorhanden ist.

Dieser Artikel führt dich durch eine konkrete Fallstudie: 10.000 Produkte in etwa 1 Sekunde verkaufen. Danach wirst du wissen, wie man Anforderungen definiert, ein Datenmodell und eine API entwirft, atomare Reservierungen mit Redis Lua umsetzt, Bestellungen asynchron über Kafka schreibt und Bestände freigibst, wenn Kunden nicht zahlen.

2. Anforderungen

2.1. Funktionale Anforderungen

  • Jeder Sale umfasst eines oder mehrere Produkte; jedes Produkt hat eine feste Menge und einen Sale-Preis.
  • Ein angemeldeter Benutzer drückt auf „Kaufen” und erhält ein Ergebnis: Reservierung erfolgreich, ausverkauft oder bereits gekauft.
  • Jeder Benutzer darf 1 Produkt pro Sale kaufen.
  • Die Reservierung muss innerhalb von 10 Minuten bezahlt werden. Danach wird die Ware ins Lager zurück und kann von anderen gekauft werden.

2.2. Skalierung

  • Bestand: 10.000 Produkte.
  • Teilnehmer: etwa 500.000 Menschen, die meisten klicken in den ersten 1–2 Sekunden.
  • Spitzenlast geschätzt: 200.000–500.000 Requests/Sekunde auf den Kaufendpoint.

Aber nur 10.000 davon sind erfolgreich. Mehr als 98 % der Requests werden „ausverkauft” sein. Das ist das Wichtigste: Das System muss schnell und effizient ablehnen.

2.3. Einschränkungen

  • Kein Überverkauf: Die Zahl der gültigen Bestellungen überschreitet niemals 10.000.
  • Nicht zu viel unbezahlte Ware: Reservierte, aber nicht bezahlte Artikel müssen ins Lager zurück.
  • Der normale Verkaufsbetrieb darf nicht beeinflusst werden, während der Flash Sale läuft.

3. Gesamtarchitektur

Eine einzelne relationale Datenbank kann ein paar tausend UPDATE Operationen pro Sekunde auf derselben Zeile verarbeiten. Das ist weit entfernt von ein paar hundert tausend. Daher teilen wir den Durchsatz in mehrere Schichten auf, jede filtert Last für die nächste:

Client ──► CDN (statische Sale-Seite, Countdown)
  │
  ▼
API Gateway (Rate Limiting pro User/IP, Bot-Schutz)
  │
  ▼
Flash Sale Service ──► Redis (Lua: Check + Lagerbestand reduzieren, atomar)
  │                         │ Erfolg (~10.000 mal)
  │                         ▼
  │                    Kafka Topic "flash-sale-orders"
  │                         │
  ▼                         ▼
202 Accepted          Order Consumer ──► PostgreSQL (letzte Quelle der Wahrheit)

Die Kernidee:

  1. Redis ist das Gatekeeper. Die Reduzierung des Bestands läuft im Speicher, Single-Threaded, daher atomar und schnell.
  2. Kafka ist der Buffer. Nur etwa 10.000 erfolgreiche Messages gehen in die Queue. Der Consumer schreibt in die DB mit der Geschwindigkeit der DB.
  3. PostgreSQL ist das Sicherheitsnetz. Constraints garantieren, dass wir nicht überverkaufen, selbst wenn Redis Probleme hat.

4. Datenmodell

In PostgreSQL brauchen wir zwei Tabellen:

CREATE TABLE flash_sale_item (
    id         BIGINT PRIMARY KEY,
    sale_id    BIGINT  NOT NULL,
    product_id BIGINT  NOT NULL,
    quantity   INT     NOT NULL,
    sold       INT     NOT NULL DEFAULT 0,
    CONSTRAINT chk_not_oversold CHECK (sold <= quantity)
);

CREATE TABLE flash_sale_order (
    id         UUID PRIMARY KEY,
    item_id    BIGINT      NOT NULL REFERENCES flash_sale_item(id),
    user_id    BIGINT      NOT NULL,
    status     VARCHAR(16) NOT NULL,   -- PENDING, PAID, EXPIRED
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT uq_item_user UNIQUE (item_id, user_id)
);

Zwei wichtige Constraints:

  • chk_not_oversold verhindert, dass sold größer wird als quantity auf Datenbankebene.
  • uq_item_user setzt sowohl die Regel „1 Produkt pro Person” durch, als auch macht den Consumer idempotent, wenn Kafka die Message erneut zustellt.

In Redis haben wir für jedes Sale-Produkt zwei Keys:

{item:1001}:stock    -> "10000"          (String, verbleibender Bestand)
{item:1001}:buyers   -> {userId, ...}    (Set, Benutzer, die reserviert haben)

Der {item:1001} Teil ist ein Hash Tag. Dadurch liegen beide Keys immer im gleichen Slot im Redis Cluster. Ein Lua-Script kann nur laufen, wenn alle Keys im gleichen Slot sind.

Vor der Eröffnung wird der Bestand durch einen Job in Redis geladen:

redis-cli SET "{item:1001}:stock" 10000

5. API

POST /api/flash-sales/items/{itemId}/purchase
Authorization: Bearer <token>

202 Accepted   {"orderId": "c0a8...", "status": "PENDING"}
409 Conflict   {"error": "ALREADY_PURCHASED"}
410 Gone       {"error": "SOLD_OUT"}

GET /api/flash-sales/orders/{orderId}
200 OK         {"orderId": "c0a8...", "status": "PENDING"}

Der Kaufendpoint gibt 202 zurück, nicht 201, weil die Bestellung nicht sofort in die DB geschrieben wird. Der Client behält die orderId und pollt den GET Endpoint, bis die Bestellung bereit ist. Die userId kommt aus dem Token, nicht aus dem Body, damit ein Benutzer nicht für andere kaufen kann.

6. Atomare Reservierung mit Redis Lua

Dependency hinzufügen:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Der häufigste Fehler ist, GET und DECR als zwei getrennte Befehle von Java aus zu schreiben. Dazwischen haben hunderte andere Threads Zeit, denselben Wert 1 zu lesen und zu reduzieren. Das Ergebnis ist negativer Bestand. Ein Lua-Script verhindert das, weil Redis das ganze Script als Block ausführt, ohne andere Befehle dazwischen zu lassen.

Datei src/main/resources/lua/reserve.lua:

-- KEYS[1] = stock, KEYS[2] = buyers, ARGV[1] = userId
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
  return -2
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
  return -1
end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return stock - 1

Das Script gibt -2 zurück, wenn der Benutzer bereits gekauft hat, -1 wenn ausverkauft, sonst die verbleibende Menge nach der Reduzierung. Check und Reduzierung geschehen in einem Schritt, daher keine Race Condition.

Script für die Freigabe src/main/resources/lua/release.lua:

-- Erhöhe den Bestand nur, wenn der Benutzer tatsächlich reserviert hat
if redis.call('SREM', KEYS[2], ARGV[1]) == 1 then
  return redis.call('INCR', KEYS[1])
end
return -1

Die Bedingung SREM == 1 verhindert, dass wir zweimal für denselben Benutzer freigeben. Das ist ein häufiger Fehler, wenn ein Job verfällt und die Cancellation parallel läuft.

Der Service ruft das Script auf:

public enum ReserveResult { SUCCESS, SOLD_OUT, ALREADY_PURCHASED }

@Service
public class StockReservationService {

    private static final RedisScript<Long> RESERVE =
            RedisScript.of(new ClassPathResource("lua/reserve.lua"), Long.class);
    private static final RedisScript<Long> RELEASE =
            RedisScript.of(new ClassPathResource("lua/release.lua"), Long.class);

    private final StringRedisTemplate redis;

    public StockReservationService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public ReserveResult reserve(long itemId, long userId) {
        Long result = redis.execute(RESERVE, keys(itemId), String.valueOf(userId));
        if (result == null) throw new IllegalStateException("Redis returned null");
        if (result == -2) return ReserveResult.ALREADY_PURCHASED;
        if (result == -1) return ReserveResult.SOLD_OUT;
        return ReserveResult.SUCCESS;
    }

    public void release(long itemId, long userId) {
        redis.execute(RELEASE, keys(itemId), String.valueOf(userId));
    }

    private static List<String> keys(long itemId) {
        String tag = "{item:" + itemId + "}";
        return List.of(tag + ":stock", tag + ":buyers");
    }
}

Spring Data Redis sendet das Script mit EVALSHA und fällt automatisch auf EVAL zurück, wenn Redis das Script noch nicht gecacht hat. Du musst die SHA nicht selbst verwalten.

7. Asynchrones Schreiben von Bestellungen über Kafka

Dependency hinzufügen:

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

7.1. Controller: Reservieren und Event publizieren

public record OrderPlaced(UUID orderId, long itemId, long userId) {}

@RestController
@RequestMapping("/api/flash-sales")
public class PurchaseController {

    private final StockReservationService reservation;
    private final KafkaTemplate<String, OrderPlaced> kafka;

    public PurchaseController(StockReservationService reservation,
                              KafkaTemplate<String, OrderPlaced> kafka) {
        this.reservation = reservation;
        this.kafka = kafka;
    }

    @PostMapping("/items/{itemId}/purchase")
    public ResponseEntity<Map<String, Object>> purchase(@PathVariable long itemId,
                                                        @AuthenticationPrincipal Jwt jwt) {
        long userId = Long.parseLong(jwt.getSubject());
        return switch (reservation.reserve(itemId, userId)) {
            case SOLD_OUT -> ResponseEntity.status(410).body(Map.of("error", "SOLD_OUT"));
            case ALREADY_PURCHASED -> ResponseEntity.status(409).body(Map.of("error", "ALREADY_PURCHASED"));
            case SUCCESS -> {
                var event = new OrderPlaced(UUID.randomUUID(), itemId, userId);
                kafka.send("flash-sale-orders", String.valueOf(itemId), event)
                     .whenComplete((ok, ex) -> {
                         if (ex != null) reservation.release(itemId, userId);
                     });
                yield ResponseEntity.accepted()
                        .body(Map.of("orderId", event.orderId(), "status", "PENDING"));
            }
        };
    }
}

Einige Punkte zu beachten:

  • Ein “ausverkauft”-Request kostet nur einen Redis-Aufruf und ist dann erledigt. Das ist der Pfad für 98% der Requests.
  • Wenn das Senden zu Kafka fehlschlägt, geben wir die Ware frei mit release. Ohne diesen Schritt würde die Ware „phantomhaft” für immer gebunden bleiben.
  • Der Key ist itemId. Alle Bestellungen desselben Produkts gehen in die gleiche Partition und werden der Reihe nach verarbeitet.

Der Producer sollte acks=all und Idempotenz aktiviert haben, um Nachrichtenverlust zu vermeiden:

spring.kafka.producer.acks=all
spring.kafka.producer.properties.enable.idempotence=true
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer

7.2. Consumer: DB ist das Sicherheitsnetz

@Component
public class OrderConsumer {

    private final JdbcTemplate jdbc;

    public OrderConsumer(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    @KafkaListener(topics = "flash-sale-orders", groupId = "order-writer")
    public void handle(OrderPlaced event) {
        int inserted = jdbc.update("""
                INSERT INTO flash_sale_order (id, item_id, user_id, status)
                VALUES (?, ?, ?, 'PENDING')
                ON CONFLICT DO NOTHING
                """, event.orderId(), event.itemId(), event.userId());
        if (inserted == 0) return; // Message wiederholt, bereits verarbeitet

        int updated = jdbc.update("""
                UPDATE flash_sale_item SET sold = sold + 1
                WHERE id = ? AND sold < quantity
                """, event.itemId());
        if (updated == 0) {
            throw new IllegalStateException("DB stock exhausted for item " + event.itemId());
        }
    }
}

ON CONFLICT DO NOTHING macht den Consumer idempotent. Kafka garantiert mindestens einmal, daher kann dieselbe Nachricht zwei Mal ankommen. Das zweite Mal werden keine Zeilen eingefügt und die Methode kehrt früh zurück.

Das UPDATE ... WHERE sold < quantity Statement ist der atomare Zustand auf Datenbankebene. Wenn Redis abweicht (zum Beispiel jemand tippt die Zahlen falsch ein), gibt die Anweisung 0 zurück. Die Transaktion wird zurückgerollt, die Bestellung wird nicht erstellt. Du solltest einen Error Handler konfigurieren, der solche Nachrichten in ein Dead-Letter-Topic schiebt und den Bestand in Redis freigibst, anstatt unbegrenzt zu wiederholen.

Weil der Consumer nur etwa 10.000 Nachrichten erhält und diese nacheinander pro Partition verarbeitet, trägt die DB eine Last von ein paar tausend Transaktionen pro Sekunde. Das ist völlig normal.

8. Ablauf der Zahlungsfrist und Bestandsverwaltung

Ein regelmäßiger Job scannt verfallene Bestellungen:

@Component
public class ExpiredOrderJob {

    private final JdbcTemplate jdbc;
    private final StockReservationService reservation;

    public ExpiredOrderJob(JdbcTemplate jdbc, StockReservationService reservation) {
        this.jdbc = jdbc;
        this.reservation = reservation;
    }

    @Scheduled(fixedDelay = 30_000)
    @Transactional
    public void expire() {
        List<long[]> expired = jdbc.query("""
                UPDATE flash_sale_order SET status = 'EXPIRED'
                WHERE status = 'PENDING' AND created_at < now() - interval '10 minutes'
                RETURNING item_id, user_id
                """, (rs, i) -> new long[]{rs.getLong(1), rs.getLong(2)});

        for (long[] row : expired) {
            jdbc.update("UPDATE flash_sale_item SET sold = sold - 1 WHERE id = ?", row[0]);
            reservation.release(row[0], row[1]);
        }
    }
}

UPDATE ... RETURNING setzt den Status und holt die Liste der Bestellungen in einem einzigen Statement ab. Wenn zwei Instanzen den Job gleichzeitig ausführen, wird jede Zeile nur von einer Instanz aktualisiert, weil die Bedingung status = 'PENDING' nach dem ersten Mal nicht mehr zutrifft.

Beachte die Reihenfolge: release auf Redis läuft innerhalb der Transaktion, gehört aber nicht zu ihr. Wenn die Transaktion nach dem release zurückgerollt wird, wird Redis um eine Einheit zu viel freigeben. In der Praxis solltest du das release nach dem Commit verschieben, zum Beispiel mit TransactionSynchronization.afterCommit. Du könntest auch ein “OrderExpired”-Event über Kafka veröffentlichen. Die gekürzte Version hier ist nur zum Veranschaulichen.

9. Testen: Niemals überverkaufen

Das wichtigste Verhalten zum Testen ist: 1.000 Menschen konkurrieren um 100 Produkte, dann sollten genau 100 erfolgreich sein. Wir verwenden Testcontainers, um echtes Redis zu laufen.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@Testcontainers
class StockReservationServiceTest {

    @Container
    static GenericContainer<?> redisContainer =
            new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @Test
    void whenThousandUsersCompeteForHundredItems_thenExactlyHundredSucceed() {
        var factory = new LettuceConnectionFactory(
                redisContainer.getHost(), redisContainer.getMappedPort(6379));
        factory.afterPropertiesSet();
        var template = new StringRedisTemplate(factory);
        var service = new StockReservationService(template);
        template.opsForValue().set("{item:1}:stock", "100");

        var successes = new AtomicInteger();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (long userId = 0; userId < 1_000; userId++) {
                long id = userId;
                executor.submit(() -> {
                    if (service.reserve(1, id) == ReserveResult.SUCCESS) {
                        successes.incrementAndGet();
                    }
                });
            }
        }

        assertThat(successes.get()).isEqualTo(100);
        assertThat(template.opsForValue().get("{item:1}:stock")).isEqualTo("0");
        assertThat(service.reserve(1, 0)).isEqualTo(ReserveResult.ALREADY_PURCHASED);
    }
}

Der try-with-resources-Block mit einem Virtual-Thread-Executor wartet darauf, dass alle Tasks fertig sind, bevor er weitergeht. Das erwartete Ergebnis:

successes = 100
stock     = "0"

Wenn du das Lua-Script durch GET und DECR einzeln ersetzt, wird dieser Test unzuverlässig fehlschlagen und der Bestand wird oft negativ sein.

10. Schwierigkeiten und häufige Fehler

Hot Key auf Redis. Jede Request trifft denselben Key {item:1001}:stock. Ein Redis-Node verarbeitet etwa 100.000 Befehle/Sekunde. Bei höherer Last gibt es zwei Wege:

  • Bestand aufteilen (Sharding): Teile 10.000 in 10 Keys {item:1001:0} … {item:1001:9} auf, jeder 1.000, auf verschiedenen Knoten. Ein Request wählt einen Shard zufällig aus, wenn voll, versucht es mit einem anderen. Dann muss die Regel „1 Produkt pro Person” entweder pro Shard mit userId % 10 routen oder in der DB geprüft werden.
  • Lokale Out-of-Stock-Flag: Wenn Redis SOLD_OUT meldet, setzt jede Instanz eine AtomicBoolean im Speicher und lehnt ab, ohne Redis anzurufen.

Lastpeak vor Redis. Rate Limiting am Gateway, Captcha oder „virtuelle Warteschlange” hilft, die meisten Bots abzuschneiden. Die Sale-Seite sollte statisch auf CDN sein. Der „Kaufen”-Button wird nur zur richtigen Zeit aktiv, und die URL wird nur dann zurückgegeben, um Vorbeschaffungen durch Skripte zu reduzieren.

Redis-Datenverlust. Wenn Redis ein Failover durchführt und das Replica den letzten DECR-Befehl nicht erhält, kann der Redis-Bestand höher sein als die Realität. Hier nutzt die Bedingung sold < quantity in der DB. Entferne dieses Sicherheitsnetz niemals.

Schwache Konsistenz am Ende. Der Benutzer erhält 202, aber die Bestellung ist noch nicht im DB. Die UI muss „wird verarbeitet” anzeigen und pollt, darf nicht sofort „erfolgreich gekauft” sagen.

Hinweis: Nutze nicht SELECT ... FOR UPDATE auf der Bestandszeile als Hauptmechanismus für Flash Sales. Es ist konsistent, aber alle Transaktionen stellen sich in die Warteschlange für das gleiche Row Lock. Der Durchsatz sinkt auf ein paar hundert bis ein paar tausend Requests/Sekunde, der Connection Pool erschöpft sich und zieht auch das normale Verkaufssystem in den Absturz.

Hinweis: Die Redis + Kafka-Architektur ist nur sinnvoll bei wirklich hoher Last. Wenn dein Sale nur ein paar hundert Requests/Sekunde hat, ist ein einfaches UPDATE ... WHERE sold < quantity in PostgreSQL ausreichend. Es ist viel einfacher und hat keine Synchronisationsprobleme zwischen zwei Datenspeichern.

11. Fazit

Das Design eines Flash Sales dreht sich um ein Prinzip: Lass die Last nach und nach durchsickern und halte eine Quelle der Wahrheit mit harten Constraints. In diesem Artikel hast du gesehen:

  • Redis Lua-Script prüft und reduziert Bestände atomar, lehnt „ausverkauft” in einem einzigen Round-Trip ab.
  • Kafka wirkt als Buffer, damit die DB nur die exakte Anzahl der erfolgreichen Bestellungen erhält.
  • PostgreSQL mit UNIQUE, CHECK und UPDATE ... WHERE sold < quantity als Sicherheitsnetz gegen Überverkauf und zum Behandeln von doppelten Messages.
  • Ausgleichmechanismen, wenn Kafka-Versand fehlschlägt oder eine Bestellung abläuft, mit Schutz gegen doppelte Freigabe.

Bei der praktischen Anwendung solltest du mit einem Konkurrenz-Test wie in Abschnitt 9 anfangen. Danach führe einen Last-Test mit Zahlen nahe an den echten Spitzenwerten durch, bevor der erste Sale die Tür öffnet.

system-designrediskafkaspring-bootconcurrency