Warenkorb- und Bestellungsdesign für E-Commerce-Plattformen

1. Übersicht

Warenkorb und Bestellungsaufgabe sind die beiden meistgenutzten Funktionen einer E-Commerce-Plattform. Sie verursachen aber auch die meisten Probleme: Overselling, doppelte Belastungen, hängende Bestellungen bei Flash-Sales.

Dieser Artikel führt Sie durch ein vollständiges Design in Form einer Case-Study. Das Design umfasst Anforderungen, Datenmodell, API, Ablaufprozesse und Knackpunkte. Jeden Knackpunkt lösen wir mit Spring Boot 3, PostgreSQL, Redis und Kafka. Nach dem Lesen haben Sie ein Design-Framework, das Sie anwenden oder zu einem System-Design-Interview mitnehmen können.

2. Anforderungen

2.1. Funktionale Anforderungen

  • Kunden können Produkte (SKU) im Warenkorb hinzufügen, bearbeiten und löschen. Der Warenkorb wird über mehrere Login-Sessions hinweg beibehalten.
  • Beim Checkout berechnet das System Preise neu und prüft den Bestand, erstellt dann eine Bestellung im Status PENDING_PAYMENT.
  • Unbezahlte Bestellungen werden nach 15 Minuten storniert und Waren ins Lager zurückgegeben.
  • Nach erfolgreicher Zahlung versendet das System Ereignisse für Lagerverwaltung, E-Mail und Treuepunkte.

2.2. Größenordnung

  • 10 Millionen monatlich aktive Nutzer.
  • Warenkorboperationen: durchschnittlich 2.000 req/s, Peak 20.000 req/s.
  • Bestellungen: durchschnittlich 200 Bestellungen/s. Bei Flash-Sale bis zu 5.000 Bestellungen/s, konzentriert auf ein paar dutzend SKUs.

2.3. Einschränkungen

  • Kein Overselling. Das ist ein harter Constraint.
  • Keine Duplikatbestellungen wenn der Client die Anfrage erneut sendet (Timeout oder Doppelklick).
  • Sobald eine Bestellung erstellt wurde, müssen die Ereignisse versendet werden, auch wenn Kafka gerade unterbrochen ist.
  • Der Warenkorb darf eventuelle Konsistenz haben. Bestellungen und Bestand müssen aber starke Konsistenz haben.

Die letzte Einschränkung entscheidet über die Architektur. Der Warenkorb hat viele Lese-/Schreibzugriffe und ist weniger kritisch, deshalb auf Redis. Bestellungen und Bestand brauchen Transaktionen, deshalb auf PostgreSQL.

3. Datenmodell

Der Warenkorb auf Redis nutzt einen Hash pro Kunde:

key:   cart:{customerId}
field: sku
value: quantity
TTL:   30 Tage

Der Warenkorb speichert nur SKU und Menge, nicht den Preis. Preise können sich jederzeit ändern, deshalb berechnet das System den Preis immer beim Checkout neu.

Die Daten, die Transaktionen erfordern, liegen auf PostgreSQL:

CREATE TABLE inventory (
    sku        VARCHAR(64) PRIMARY KEY,
    available  INT NOT NULL CHECK (available >= 0),
    reserved   INT NOT NULL DEFAULT 0
);

CREATE TABLE orders (
    id               BIGSERIAL PRIMARY KEY,
    customer_id      BIGINT       NOT NULL,
    idempotency_key  VARCHAR(64)  NOT NULL,
    status           VARCHAR(32)  NOT NULL,
    total_amount     BIGINT       NOT NULL,
    expires_at       TIMESTAMPTZ  NOT NULL,
    created_at       TIMESTAMPTZ  NOT NULL DEFAULT now(),
    UNIQUE (customer_id, idempotency_key)
);

CREATE TABLE order_items (
    order_id    BIGINT REFERENCES orders(id),
    sku         VARCHAR(64) NOT NULL,
    quantity    INT         NOT NULL,
    unit_price  BIGINT      NOT NULL,
    PRIMARY KEY (order_id, sku)
);

CREATE TABLE outbox_events (
    id           BIGSERIAL PRIMARY KEY,
    aggregate_id BIGINT      NOT NULL,
    event_type   VARCHAR(64) NOT NULL,
    payload      JSONB       NOT NULL,
    published    BOOLEAN     NOT NULL DEFAULT false
);

Ein paar bemerkenswerte Punkte:

  • CHECK (available >= 0) ist die letzte Schutzschicht. Wenn der Code einen Fehler hat, blockiert die Datenbank immer noch den negativen Bestand.
  • UNIQUE (customer_id, idempotency_key) verhindert Duplikatbestellungen direkt auf Datenbankebene.
  • order_items.unit_price ist ein Preis-Snapshot zum Zeitpunkt der Bestellung. Alte Bestellungen werden nicht beeinflusst, wenn sich Produktpreise ändern.
  • Beträge werden als BIGINT in der kleinsten Währungseinheit (Dong) gespeichert. Nicht DOUBLE.

4. API-Design

GET    /api/cart                       -> Warenkorb anzeigen (mit aktuellem Preis)
PUT    /api/cart/items/{sku}           -> body {"quantity": 2}, Menge festlegen
DELETE /api/cart/items/{sku}           -> SKU aus Warenkorb entfernen
POST   /api/orders                     -> header Idempotency-Key, Bestellung erstellen
GET    /api/orders/{id}                -> Bestellungsstatus anzeigen

PUT mit absoluter Menge ist idempotent. Mehrfaches Aufrufen ergibt immer das gleiche Ergebnis. Wenn Sie es wie POST /add mit +1 designen, wird jeder Client-Retry eine weitere Menge addieren.

POST /api/orders ist nicht von Natur aus idempotent, deshalb muss der Client einen Idempotency-Key (UUID) für jeden Klick auf “Bestellung aufgeben” generieren. Beim erneuten Versuch sendet der Client den gleichen Key.

5. Warenkorb auf Redis

Abhängigkeit hinzufügen:

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

Warenkorb-Service:

@Service
public class CartService {

    private static final Duration CART_TTL = Duration.ofDays(30);
    private static final int MAX_ITEMS = 100;

    private final StringRedisTemplate redis;

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

    public void setQuantity(long customerId, String sku, int quantity) {
        String key = "cart:" + customerId;
        HashOperations<String, String, String> ops = redis.opsForHash();
        if (quantity <= 0) {
            ops.delete(key, sku);
            return;
        }
        if (!ops.hasKey(key, sku) && ops.size(key) >= MAX_ITEMS) {
            throw new IllegalStateException("Warenkorb hat bereits " + MAX_ITEMS + " Produkte");
        }
        ops.put(key, sku, String.valueOf(quantity));
        redis.expire(key, CART_TTL);
    }

    public Map<String, Integer> getItems(long customerId) {
        Map<String, String> raw = redis.<String, String>opsForHash().entries("cart:" + customerId);
        return raw.entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, e -> Integer.parseInt(e.getValue())));
    }
}

Erklärung:

  • ops.put(key, sku, ...) schreibt ein Feld im Hash mit Komplexität O(1). Zwei Geräte, die zwei verschiedene SKUs bearbeiten, überschreiben sich nicht gegenseitig. Wenn Sie den ganzen Warenkorb als JSON-String speichern, wird er überschrieben.
  • expire wird bei jedem Schreiben erneuert. Der Warenkorb läuft nur ab, wenn keine Operationen 30 Tage lang durchgeführt werden.
  • MAX_ITEMS begrenzt die Größe, um zu vermeiden, dass Schlüssel durch Bots zu groß werden.

hasKey gefolgt von size sind nicht atomar. Zwei parallele Anfragen können das Limit um 1–2 Items überschreiten. Mit dem Warenkorb ist eine kleine Abweichung akzeptabel. Sie sollten diesen “ungefähren” Grad nicht auf Bestand anwenden.

Hinweis: Redis ist keine absolut persistente Datenquelle. Aktivieren Sie AOF (appendfsync everysec), um Datenverluste auf etwa eine Sekunde zu begrenzen. Eine Sekunde Warenkorbdaten zu verlieren ist akzeptabel, aber Bestellungen zu verlieren nicht.

6. Bestellungsablauf

Der Ablauf von POST /api/orders durchläuft folgende Schritte:

1. Warenkorb aus Redis lesen
2. Aktuelle Preise aus Katalog abrufen
3. BEGIN transaction (PostgreSQL)
   3a. Orders mit idempotency_key einfügen  -> wenn Duplikat, alte Bestellung zurück
   3b. Bestand für jedes SKU reduzieren (conditional update) -> nicht genug, ROLLBACK
   3c. order_items einfügen (Preis-Snapshot)
   3d. outbox_events "OrderCreated" einfügen
4. COMMIT
5. Warenkorb löschen (best-effort)
6. Relay liest Outbox -> veröffentliche auf Kafka

6.1. Overselling verhindern durch conditional update

Der häufige Fehler ist, Bestand zu lesen, in Java zu vergleichen und dann zu schreiben. Zwischen Lesen und Schreiben könnte ein anderer Thread die Ware bereits gekauft haben. Der richtige Weg ist, Prüfung und Subtraktion in einer SQL-Anweisung zu kombinieren:

@Repository
public class InventoryRepository {

    private final JdbcClient jdbc;

    public InventoryRepository(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    public boolean reserve(String sku, int quantity) {
        int updated = jdbc.sql("""
                UPDATE inventory
                   SET available = available - :qty,
                       reserved  = reserved  + :qty
                 WHERE sku = :sku AND available >= :qty
                """)
                .param("qty", quantity)
                .param("sku", sku)
                .update();
        return updated == 1;
    }
}

Die Datenbank sperrt die Zeile während UPDATE läuft. Die zweite Anfrage muss auf die erste warten, wertet dann die Bedingung available >= :qty mit dem neuen Wert erneut aus. Wenn nicht genug Bestand vorhanden ist, gibt updated == 0 zurück und die Methode gibt false zurück. Sie brauchen keine SELECT ... FOR UPDATE oder version column.

6.2. Idempotenz und Transaktion

@Service
public class OrderService {

    private final JdbcClient jdbc;
    private final InventoryRepository inventory;

    public OrderService(JdbcClient jdbc, InventoryRepository inventory) {
        this.jdbc = jdbc;
        this.inventory = inventory;
    }

    @Transactional
    public long placeOrder(long customerId, String idempotencyKey, List<OrderLine> lines) {
        Optional<Long> existing = jdbc.sql("""
                INSERT INTO orders (customer_id, idempotency_key, status, total_amount, expires_at)
                VALUES (:c, :k, 'PENDING_PAYMENT', :total, now() + interval '15 minutes')
                ON CONFLICT (customer_id, idempotency_key) DO NOTHING
                RETURNING id
                """)
                .param("c", customerId).param("k", idempotencyKey)
                .param("total", lines.stream().mapToLong(OrderLine::subtotal).sum())
                .query(Long.class).optional();

        if (existing.isEmpty()) {
            return jdbc.sql("SELECT id FROM orders WHERE customer_id = :c AND idempotency_key = :k")
                    .param("c", customerId).param("k", idempotencyKey)
                    .query(Long.class).single();
        }
        long orderId = existing.get();

        for (OrderLine line : lines.stream().sorted(Comparator.comparing(OrderLine::sku)).toList()) {
            if (!inventory.reserve(line.sku(), line.quantity())) {
                throw new OutOfStockException(line.sku());
            }
            jdbc.sql("INSERT INTO order_items VALUES (:o, :s, :q, :p)")
                    .param("o", orderId).param("s", line.sku())
                    .param("q", line.quantity()).param("p", line.unitPrice())
                    .update();
        }

        jdbc.sql("""
                INSERT INTO outbox_events (aggregate_id, event_type, payload)
                VALUES (:id, 'OrderCreated', CAST(:payload AS jsonb))
                """)
                .param("id", orderId)
                .param("payload", "{\"orderId\":" + orderId + ",\"customerId\":" + customerId + "}")
                .update();
        return orderId;
    }
}

public record OrderLine(String sku, int quantity, long unitPrice) {
    long subtotal() { return quantity * unitPrice; }
}

public class OutOfStockException extends RuntimeException {
    public OutOfStockException(String sku) { super("Ausverkauft: " + sku); }
}

Wichtigste Punkte:

  • ON CONFLICT ... DO NOTHING RETURNING id gibt die ID nur zurück, wenn das Einfügen erfolgreich war. Wenn der Key bereits existiert, ist das Ergebnis leer. Dann liest der Service die alte Bestellung erneut und gibt sie zurück, deshalb erhält der Client bei Wiederholung immer genau eine Bestellung.
  • OutOfStockException ist eine RuntimeException, daher rollback @Transactional alles zurück: Bestellung, bereits reduzierte SKUs und Outbox. Es gibt keinen Halbzustand.
  • Die SKU-Liste wird sortiert, bevor der Bestand reduziert wird. Zwei Bestellungen mit SKU A und B sperren immer zuerst A, dann B, dadurch werden Deadlocks vermieden.
  • Wenn die erste Anfrage Rollback verursacht, wird der Key ebenfalls zurückgesetzt. Client-Wiederholung mit dem gleichen Key führt alles von vorne aus – genau das gewünschte Verhalten.

6.3. Transactional Outbox

Wenn Sie kafkaTemplate.send() innerhalb einer Transaktion aufrufen, erhalten Sie einen von zwei Fehlern. Kafka versendet erfolgreich, aber DB rollback erzeugt Spukereignisse. DB commit, aber Kafka hat einen Fehler – dann gehen Ereignisse verloren. Outbox behebt dies, indem das Ereignis in der gleichen Transaktion wie die Bestellung gespeichert wird, danach pushes ein Relay Ereignisse zu Kafka.

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
@Component
public class OutboxRelay {

    private final JdbcClient jdbc;
    private final KafkaTemplate<String, String> kafka;

    public OutboxRelay(JdbcClient jdbc, KafkaTemplate<String, String> kafka) {
        this.jdbc = jdbc;
        this.kafka = kafka;
    }

    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publish() throws Exception {
        List<OutboxEvent> events = jdbc.sql("""
                SELECT id, aggregate_id, event_type, payload::text AS payload
                  FROM outbox_events WHERE published = false
                 ORDER BY id LIMIT 100
                 FOR UPDATE SKIP LOCKED
                """).query(OutboxEvent.class).list();

        for (OutboxEvent e : events) {
            kafka.send("orders", String.valueOf(e.aggregateId()), e.payload()).get();
            jdbc.sql("UPDATE outbox_events SET published = true WHERE id = :id")
                    .param("id", e.id()).update();
        }
    }
}

record OutboxEvent(long id, long aggregateId, String eventType, String payload) {}

@Scheduled läuft nur, wenn die Anwendung @EnableScheduling aktiviert hat.

  • FOR UPDATE SKIP LOCKED erlaubt mehreren Relay-Instanzen, parallel zu laufen, ohne die gleiche Zeile zu verarbeiten.
  • Der Schlüssel der Nachricht ist aggregateId. Alle Ereignisse für die gleiche Bestellung fallen in die gleiche Partition und behalten die richtige Reihenfolge.
  • .get() wartet auf Kafka-Bestätigung, bevor die Zeile als published markiert wird. Wenn Relay in der Mitte stirbt, kann das Ereignis erneut versendet werden. Outbox garantiert at-least-once, deshalb muss der Consumer idempotent sein, z.B. bereits verarbeitete orderId speichern.

6.4. Zahlungsfrist abgelaufen

Ein regelmäßiger Job bricht überfällige Bestellungen ab und gibt Waren ins Lager zurück:

WITH expired AS (
    UPDATE orders SET status = 'CANCELLED'
     WHERE status = 'PENDING_PAYMENT' AND expires_at < now()
    RETURNING id
)
UPDATE inventory i
   SET available = i.available + oi.quantity,
       reserved  = i.reserved  - oi.quantity
  FROM order_items oi
 WHERE oi.order_id IN (SELECT id FROM expired) AND oi.sku = i.sku;

Die Bedingung status = 'PENDING_PAYMENT' behebt Konflikte mit dem Zahlungs-Callback. Callback muss auch den Status durch UPDATE ... WHERE status = 'PENDING_PAYMENT' ändern. Wer zuerst aktualisiert, gewinnt; der andere aktualisiert 0 Zeilen. Wenn Zahlung ankommt, nachdem die Bestellung storniert wurde, gibt das System Geld zurück, statt die Bestellung wiederherzustellen.

Die obige Anweisung geht davon aus, dass jedes SKU nur einmal in jeder Bestellung vorkommt – der Primärschlüssel (order_id, sku) garantiert dies. Wenn viele Bestellungen das gleiche SKU enthalten, wende PostgreSQL nur eine Join-Zeile pro inventory-Zeile an. Konsolidieren Sie vorher mit GROUP BY sku und SUM(quantity).

7. Plötzliche Lasten: Flash-Sale

Wenn 5.000 req/s um das gleiche SKU konkurrieren, warten alle in der Warteschlange um eine Zeile in inventory gesperrt zu werden. Die Datenbank wird verstopft, Latenz schnellt in die Höhe und zieht andere SKUs mit. Die Lösung ist, Bandbreite vor Berührung der DB zu reduzieren, mit einem Redis-Zähler, der Lua-Skripte ausführt:

@Component
public class FlashSaleGate {

    private static final RedisScript<Long> TRY_ACQUIRE = RedisScript.of("""
            local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
            local qty = tonumber(ARGV[1])
            if stock >= qty then
                redis.call('DECRBY', KEYS[1], qty)
                return 1
            end
            return 0
            """, Long.class);

    private final StringRedisTemplate redis;

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

    public boolean tryAcquire(String sku, int quantity) {
        Long ok = redis.execute(TRY_ACQUIRE, List.of("flash:stock:" + sku), String.valueOf(quantity));
        return ok != null && ok == 1L;
    }

    public void release(String sku, int quantity) {
        redis.opsForValue().increment("flash:stock:" + sku, quantity);
    }
}

Redis führt Lua-Skripte atomar aus. Keine Befehle sind dazwischen GET und DECRBY. Mit 1.000 Plätzen und 50.000 Anfragen passieren nur etwa 1.000 Anfragen das Gate, um in OrderService zu gehen. 49.000 Anfragen erhalten sofort HTTP 409 “Ausverkauft”, ohne eine einzige Datenbankabfrage zu verbrauchen.

Der Ablauf wird wieder zusammengefügt:

tryAcquire (Redis)  --false-->  409 Ausverkauft
       |true
placeOrder (PostgreSQL, conditional update bleibt gleich)
       |exception
release (Redis)

Redis ist nur ein Filter. Die Wahrheitsquelle ist immer noch conditional update in PostgreSQL. Wenn Redis die Zahlen verfehlt, z.B. durch Neustart oder wenn release nicht läuft, ist das schlimmstenfalls, ein paar Plätze unter Verkauf zu verkaufen, nicht Overselling.

Ein paar weitere Hilfsmaßnahmen:

  • Rate limit pro Kunde, um Bot-Spam zu blockieren.
  • Warteschlange: Push gewinnende Anfragen durch Gate in Kafka, Consumer erstellt Bestellungen mit einer Rate, die DB verarbeiten kann, Client-Poll GET /api/orders/{id}. Dies tauscht Latenz gegen Stabilität.
  • Hot-SKU trennen in eine separate Tabelle oder Instanz, um normale Bestellungen nicht zu beeinflussen.

8. Concurrency-Test für Bestand

Der wichtigste Test ist der Beweis, dass das System nicht über Bestand hinaus verkauft, wenn mehrere Threads gleichzeitig kaufen.

@JdbcTest
@Import(InventoryRepository.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class InventoryRepositoryTest {

    @Autowired JdbcClient jdbc;
    @Autowired InventoryRepository inventory;

    @BeforeEach
    void setUp() {
        jdbc.sql("""
                CREATE TABLE IF NOT EXISTS inventory (
                  sku VARCHAR(64) PRIMARY KEY,
                  available INT NOT NULL CHECK (available >= 0),
                  reserved INT NOT NULL DEFAULT 0)
                """).update();
        jdbc.sql("DELETE FROM inventory").update();
        jdbc.sql("INSERT INTO inventory (sku, available) VALUES ('IPHONE-16', 5)").update();
    }

    @Test
    void givenStockOf5_when20ConcurrentBuyers_thenExactly5Succeed() throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Boolean>> results = IntStream.range(0, 20)
                    .mapToObj(i -> executor.submit(() -> inventory.reserve("IPHONE-16", 1)))
                    .toList();

            long success = 0;
            for (Future<Boolean> f : results) {
                if (f.get()) success++;
            }
            assertThat(success).isEqualTo(5);
        }

        Integer left = jdbc.sql("SELECT available FROM inventory WHERE sku = 'IPHONE-16'")
                .query(Integer.class).single();
        assertThat(left).isZero();
    }
}
  • @Transactional(propagation = NOT_SUPPORTED) deaktiviert die standardmäßige Transaktion von @JdbcTest. Ohne dies sind in setUp eingefügte Daten noch nicht commitet und andere Threads können sie nicht sehen.
  • Der Test verwendet Virtual Threads von Java 21, um 20 gleichzeitige Anfragen zu erstellen.
  • Die standardmäßig eingebettete Datenbank von @JdbcTest (H2) reicht aus. Um ON CONFLICT und SKIP LOCKED zu testen, verwenden Sie echtes PostgreSQL über Testcontainers.

Erwartetes Ergebnis:

success = 5, available = 0

Wenn Sie die UPDATE-Anweisung durch “SELECT und dann Vergleich in Java” ersetzen, wird dieser Test zufällig fehlschlagen, manchmal success bis zu 7 oder 8.

9. Häufige Fehler und wann man nicht verwenden sollte

  • Preis im Warenkorb speichern und beim Checkout verwenden. Ein bösartiger Akteur könnte den alten Preis behalten oder die Payload ändern. Holen Sie sich immer den Preis vom Server.
  • Bestand sofort nach dem Hinzufügen reduzieren. Benutzer verlassen den Warenkorb sehr oft, deshalb wird Bestand sinnlos blockiert. Halten Sie Waren nur beim Checkout und immer begrenzt.
  • Kafka oder Zahlungsservice innerhalb von @Transactional aufrufen. Lange Transaktionen halten lange Sperren und garantieren noch immer keine Konsistenz. Verwenden Sie Outbox.
  • Redis als Wahrheitsquelle für Bestand betrachten. Redis sollte nur ein Filterschicht sein.
  • Verteilte Transaktionen (2PC/XA) zwischen Services verwenden. Diese Methode ist komplex und langsam. Verwenden Sie Saga basierend auf Ereignissen mit Kompensationsschritten (Bestellung stornieren, Bestand zurück).

Hinweis: Wenn deine Plattform nur ein paar dutzend Bestellungen pro Minute hat, reichen eine einzelne Datenbank mit conditional update und idempotency key. Redis gate, Kafka-Warteschlange und Hot-SKU trennen sollten nur hinzugefügt werden, wenn Messungen zeigen, dass die DB wirklich verstopft ist.

10. Fazit

Der Artikel hat Warenkorb und Bestellungsablauf Schicht für Schicht entworfen. Der Warenkorb befindet sich auf Redis Hash, weil Geschwindigkeit erforderlich ist und eventuelle Konsistenz akzeptabel ist. Bestellungen und Bestand befinden sich auf PostgreSQL, weil starke Konsistenz erforderlich ist.

Drei Kerntechniken lösen schwierige Punkte:

  • Conditional update verhindert Overselling.
  • Idempotency key zusammen mit UNIQUE-Constraint verhindert Duplikatbestellungen.
  • Transactional Outbox stellt sicher, dass keine Ereignisse verloren gehen.

Bei einem Flash-Sale filtert ein Redis-Gate mit Lua-Skript Bandbreite, bevor die Datenbank berührt wird, während die Datenbank immer noch die Wahrheitsquelle bleibt. Sie können mit der einfachsten Version anfangen und Schicht für Schicht hinzufügen, wenn tatsächliche Last es verlangt.

system-designspring-bootrediskafkacase-study