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_priceist ein Preis-Snapshot zum Zeitpunkt der Bestellung. Alte Bestellungen werden nicht beeinflusst, wenn sich Produktpreise ändern.- Beträge werden als
BIGINTin der kleinsten Währungseinheit (Dong) gespeichert. NichtDOUBLE.
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.expirewird bei jedem Schreiben erneuert. Der Warenkorb läuft nur ab, wenn keine Operationen 30 Tage lang durchgeführt werden.MAX_ITEMSbegrenzt 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 idgibt 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.OutOfStockExceptionist eineRuntimeException, daher rollback@Transactionalalles 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 LOCKEDerlaubt 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 alspublishedmarkiert 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 insetUpeingefü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. UmON CONFLICTundSKIP LOCKEDzu 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
@Transactionalaufrufen. 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.