Design eines Food-Delivery-Systems: Bestellungen, Restaurants, Lieferer und Echtzeit-Status

1. Übersicht

Eine Food-Delivery-App hat drei Parteien, die gleichzeitig auf eine Bestellung einwirken: der Kunde bestellt, das Restaurant bestätigt und der Lieferer nimmt die Lieferung an. Alle drei Aktivitäten laufen gleichzeitig ab, über mobiles Netzwerk mit Unterbrechungen, und konzentrieren sich zur Mittagszeit.

Dieser Artikel führt Sie durch den gesamten Designprozess: Anforderungen, Datenmodell, APIs, Zustandsmaschine, wie Sie Bestellungen an Lieferer verteilen, ohne dass es zu Duplikaten kommt, wie Sie die Konsistenz zwischen Datenbank und Kafka gewährleisten, und wie Sie Echtzeit-Status an Kunden pushen. Am Ende haben Sie ein konkretes Design-Framework, das Sie anwenden oder in System-Design-Interviews verwenden können.

2. Anforderungen

2.1. Funktionale Anforderungen

  • Der Kunde sieht das Menü, bestellt, bezahlt, verfolgt den Status und den Standort des Lieferers.
  • Das Restaurant erhält die Bestellung, bestätigt oder lehnt ab, und teilt dann mit, dass das Essen bereit ist.
  • Der Lieferer aktiviert seinen Online-Status, sendet regelmäßig seinen Standort, nimmt angebotene Bestellungen an oder lehnt ab, und aktualisiert den Abhol- und Lieferstatus.
  • Der Kunde kann die Bestellung vor Beginn der Zubereitung stornieren.

2.2. Skalierung und Einschränkungen

Wir gehen von folgenden Zahlen aus, um eine Grundlage für die Berechnung zu haben:

Bestellungen:   1.000.000 Bestellungen/Tag, Spitzenlast (11–13 Uhr) ~30%
                → ~42 Bestellungen/Sekunde durchschnittliche Spitzenlast, Spitzen bis ~200 Bestellungen/Sekunde
Lieferer:       50.000 Lieferer online, senden Standort alle 4 Sekunden
                → ~12.500 Standortschreibvorgänge/Sekunde
Verfolgung:     Kunde öffnet Tracking-Bildschirm → Hundertausende Echtzeit-Verbindungen

Aus diesen Zahlen ergeben sich drei Haupteinschränkungen:

  • Bestellungen müssen stark konsistent sein. Keine verlorenen Bestellungen und keine Bestellung darf zwei Lieferern zugewiesen werden.
  • Lieferer-Standorte akzeptieren schwache Konsistenz. Ein verlorener Standortpunkt ist in Ordnung, aber Schreibvorgänge müssen sehr schnell sein. Wir speichern Standorte nicht in einer relationalen Datenbank.
  • Bestellungen müssen idempotent sein. Mobiles Netzwerk führt oft dazu, dass der Client Anfragen erneut sendet.

3. Gesamtarchitektur

 App Kunde / Restaurant / Lieferer
          │  REST + SSE/WebSocket
          ▼
     API Gateway (Rate Limit, Auth)
          │
 ┌────────┼───────────────┬──────────────────┐
 ▼        ▼               ▼                  ▼
Order   Restaurant     Dispatch           Tracking
Service Service        Service            Service
 │ PostgreSQL           │ Redis GEO         │ Redis (Standort, pub/sub)
 │ + Outbox             │                   │
 └──────────► Kafka (order-events) ◄────────┘

Der Order Service ist die einzige Quelle der Wahrheit für Bestellungen. Andere Services reagieren auf Events im Kafka. Lieferer-Standorte befinden sich nur in Redis.

Die folgenden Beispiele verwenden diese Dependencies:

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

4. Datenmodell

CREATE TABLE orders (
    id              BIGSERIAL PRIMARY KEY,
    customer_id     BIGINT      NOT NULL,
    restaurant_id   BIGINT      NOT NULL,
    shipper_id      BIGINT,
    status          VARCHAR(20) NOT NULL,
    total_amount    NUMERIC(12,0) NOT NULL,
    idempotency_key VARCHAR(64) NOT NULL,
    version         INT         NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (customer_id, idempotency_key)
);

CREATE TABLE order_items (
    order_id  BIGINT REFERENCES orders(id),
    dish_id   BIGINT NOT NULL,
    dish_name VARCHAR(200) NOT NULL,
    price     NUMERIC(12,0) NOT NULL,
    quantity  INT NOT NULL
);

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

CREATE INDEX idx_orders_restaurant_active ON orders (restaurant_id, status);
CREATE INDEX idx_outbox_unpublished ON outbox (id) WHERE published = false;

Es gibt einige wichtige Punkte zu beachten:

  • UNIQUE (customer_id, idempotency_key) verhindert doppelte Bestellungen, wenn der Client eine Anfrage erneut sendet.
  • version dient dem Optimistic Locking, wenn mehrere Parteien gleichzeitig eine Bestellung aktualisieren.
  • order_items speichert dish_name und price zum Zeitpunkt der Bestellung. Wenn das Restaurant später die Preise ändert, beeinträchtigt das nicht die alte Rechnung.
  • Die outbox-Tabelle wird für den Konsistenzabschnitt in Abschnitt 8 verwendet.

5. Zustandsmaschine für Bestellungen

Eine Bestellung darf nur gültige Übergänge durchführen. Sie sollten diese Regel an einer einzigen Stelle codieren, anstatt if-Statements überall zu verteilen:

public enum OrderStatus {
    PLACED, CONFIRMED, PREPARING, READY_FOR_PICKUP, PICKED_UP, DELIVERED, CANCELLED;

    private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = Map.of(
        PLACED,           Set.of(CONFIRMED, CANCELLED),
        CONFIRMED,        Set.of(PREPARING, CANCELLED),
        PREPARING,        Set.of(READY_FOR_PICKUP),
        READY_FOR_PICKUP, Set.of(PICKED_UP),
        PICKED_UP,        Set.of(DELIVERED),
        DELIVERED,        Set.of(),
        CANCELLED,        Set.of()
    );

    public boolean canTransitionTo(OrderStatus next) {
        return TRANSITIONS.get(this).contains(next);
    }
}

TRANSITIONS listet die gültigen Nachfolgezustände für jeden Status auf. Der Kunde kann nur bei PLACED oder CONFIRMED stornieren. Wenn das Restaurant bereits kocht (PREPARING), kann die Bestellung nicht mehr zu CANCELLED zurückkehren. Die beiden Endzustände haben keine nachfolgenden Schritte.

Ein Test, um diese Regeln zu überprüfen:

class OrderStatusTest {

    @Test
    void customerCanCancelBeforePreparing() {
        assertThat(OrderStatus.PLACED.canTransitionTo(OrderStatus.CANCELLED)).isTrue();
        assertThat(OrderStatus.CONFIRMED.canTransitionTo(OrderStatus.CANCELLED)).isTrue();
    }

    @Test
    void cannotCancelOnceKitchenStarted() {
        assertThat(OrderStatus.PREPARING.canTransitionTo(OrderStatus.CANCELLED)).isFalse();
    }

    @Test
    void deliveredIsTerminal() {
        assertThat(OrderStatus.values())
            .noneMatch(OrderStatus.DELIVERED::canTransitionTo);
    }
}

6. API

POST  /api/orders                         Kunde bestellt (Idempotency-Key Header)
GET   /api/orders/{id}                    Bestelldetails anzeigen
POST  /api/orders/{id}/cancel             Kunde storniert
POST  /api/restaurant/orders/{id}/confirm Restaurant bestätigt
POST  /api/restaurant/orders/{id}/ready   Restaurant teilt mit, dass Essen bereit ist
PUT   /api/shippers/me/location           Lieferer sendet Standort {lat, lng}
POST  /api/shippers/me/offers/{orderId}/accept   Lieferer nimmt Bestellung an
POST  /api/orders/{id}/pickup | /deliver  Lieferer aktualisiert Status
GET   /api/orders/{id}/stream             SSE: Status + Lieferer-Standort

Die API ist nach Aktionen (/confirm, /accept) konzipiert, nicht nach dem generischen Muster PATCH status=.... Dies macht die Berechtigungsprüfung einfacher: nur das Restaurant der Bestellung kann /confirm aufrufen, nur der zugewiesene Lieferer kann /pickup aufrufen.

7. Umgang mit Konflikten

7.1. Status-Update mit Optimistic Locking

Stellen Sie sich vor, der Kunde drückt Stornierung genau dann, wenn das Restaurant Bestätigung drückt. Wenn beide lesen, dass der Status PLACED ist, denken beide, dass sie gültig sind. Wir lösen dies mit einer bedingten UPDATE-Anweisung:

@Repository
public class OrderRepository {

    private final JdbcClient jdbc;

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

    public boolean transition(long orderId, OrderStatus from, OrderStatus to, int version) {
        if (!from.canTransitionTo(to)) {
            throw new IllegalStateException(from + " -> " + to + " ungültig");
        }
        int updated = jdbc.sql("""
                UPDATE orders SET status = :to, version = version + 1
                WHERE id = :id AND status = :from AND version = :version
                """)
            .param("to", to.name())
            .param("id", orderId)
            .param("from", from.name())
            .param("version", version)
            .update();
        return updated == 1;
    }
}

Die Datenbank stellt sicher, dass nur eines der beiden UPDATE-Statements die version-Bedingung erfüllt. Der Gewinner erhält true. Der Verlierer erhält false und gibt HTTP 409 mit dem neuesten Status zurück, damit die App „Das Restaurant hat gerade die Bestellung bestätigt” anzeigen kann.

7.2. Bestellungsverteilung an Lieferer

Dies ist der schwierigste Teil. Wir müssen einen Lieferer in der Nähe des Restaurants finden und sicherstellen, dass jede Bestellung nur einen Lieferer hat.

Schritt 1: Speichern Sie den Standort mit Redis GEO. Jedes Mal, wenn der Lieferer seinen Standort sendet, schreiben wir in ein GEO Sorted Set und erneuern einen Heartbeat-Key:

@Service
public class ShipperLocationService {

    private final StringRedisTemplate redis;

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

    public void update(long shipperId, double lat, double lng) {
        String id = String.valueOf(shipperId);
        redis.opsForGeo().add("shipper:geo", new Point(lng, lat), id);
        redis.opsForValue().set("shipper:alive:" + id, "1", Duration.ofSeconds(30));
    }

    public List<String> findNearby(double lat, double lng) {
        var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
            .includeDistance().sortAscending().limit(20);
        var results = redis.opsForGeo().search("shipper:geo",
            GeoReference.fromCoordinate(lng, lat),
            new Distance(3, Metrics.KILOMETERS), args);
        return results.getContent().stream()
            .map(r -> r.getContent().getName())
            .filter(id -> Boolean.TRUE.equals(redis.hasKey("shipper:alive:" + id)))
            .toList();
    }
}

Wichtig: Point erwartet zuerst die Länge, dann die Breite – ein häufiger Fehler. Member in einem GEO Set verfallen nicht automatisch. Daher wird der Key shipper:alive:* mit einem TTL von 30 Sekunden verwendet, um Lieferer zu entfernen, die die App geschlossen haben oder deren Verbindung unterbrochen wurde. Ein regelmäßiger Job kann tote Member mit ZREM aus shipper:geo entfernen.

Bei 12.500 Schreibvorgängen pro Sekunde kann eine Redis-Instanz immer noch gut mithalten. Wenn Sie auf mehrere Städte erweitern, sharden Sie nach Region: shipper:geo:hcm, shipper:geo:hn.

Schritt 2: Laden Sie jeden Lieferer ein, einen nach dem anderen. Der Dispatch Service sendet Angebote nacheinander an die nächsten Lieferer. Jedes Angebot verfällt nach 20 Sekunden. Um zu verhindern, dass ein Lieferer zwei Bestellungen gleichzeitig angeboten bekommt, sperren wir mit SET NX:

public boolean reserveShipperForOffer(String shipperId, long orderId) {
    Boolean ok = redis.opsForValue().setIfAbsent(
        "offer:shipper:" + shipperId, String.valueOf(orderId), Duration.ofSeconds(20));
    return Boolean.TRUE.equals(ok);
}

Wenn false, schaut der Lieferer gerade ein anderes Angebot an, also gehen wir zum nächsten über.

Schritt 3: Finalisieren Sie die Bestellungszuweisung in der Datenbank. Die Redis-Sperre hilft nur bei der Koordination. Die endgültige Entscheidung muss in der Datenbank liegen:

public boolean assignShipper(long orderId, long shipperId) {
    return jdbc.sql("""
            UPDATE orders SET shipper_id = :shipperId, version = version + 1
            WHERE id = :orderId AND shipper_id IS NULL
              AND status IN ('CONFIRMED', 'PREPARING', 'READY_FOR_PICKUP')
            """)
        .param("shipperId", shipperId)
        .param("orderId", orderId)
        .update() == 1;
}

Die Bedingung shipper_id IS NULL stellt sicher, dass selbst wenn zwei Lieferer gleichzeitig „Annehmen” drücken (z. B. weil ein altes Angebot abgelaufen ist und erneut gesendet wurde), nur einer gewinnt.

Anmerkung: Verwenden Sie die Redis-Sperre nicht als Quelle der Wahrheit für die Bestellungszuweisung. Redis kann failover und die Sperre verlieren. In diesem Fall ist die Constraint in der Datenbank der letzte Schutz.

8. Konsistenz zwischen Datenbank und Kafka

Nachdem eine Bestellung erstellt wurde, muss der Order Service ein OrderPlaced-Event ausgeben, damit das Restaurant eine Benachrichtigung erhält. Wenn Sie die Datenbank schreiben und dann kafkaTemplate.send() aufrufen, könnte der Service zwischen den beiden Schritten abstürzen. Das Ergebnis: Die Bestellung existiert, aber das Restaurant erfährt es nie. Dies ist das Dual-Write-Problem.

Die Lösung ist das transactional outbox Pattern: Schreiben Sie Events in die outbox-Tabelle in derselben Transaktion wie die Bestellung.

@Transactional
public long placeOrder(PlaceOrderCommand cmd) {
    long orderId = orderRepository.insert(cmd); // wirft DuplicateKeyException bei Duplikat
    jdbc.sql("""
            INSERT INTO outbox (aggregate_id, event_type, payload)
            VALUES (:id, 'OrderPlaced', CAST(:payload AS JSONB))
            """)
        .param("id", orderId)
        .param("payload", toJson(cmd, orderId))
        .update();
    return orderId;
}

Beide Datensätze werden gemeinsam committed oder gemeinsam zurückgerollt. Wenn der Client mit demselben Idempotency-Key erneut sendet, wirft die UNIQUE-Constraint eine DuplicateKeyException. Der Controller fängt diesen Fehler ab, findet die alte Bestellung und gibt diese zurück, anstatt eine neue zu erstellen.

Ein Relay liest die Outbox und pushed zu Kafka:

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

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

FOR UPDATE SKIP LOCKED erlaubt mehreren Relay-Instanzen zu laufen ohne doppelte Verarbeitung. Der Message-Schlüssel ist orderId, daher fallen alle Events derselben Bestellung in die gleiche Partition und behalten die richtige Reihenfolge.

Das Relay garantiert at-least-once Delivery. Wenn der Service nach send aber vor commit abstürzt, wird das Event erneut gesendet. Daher muss der Consumer idempotent sein, z. B. durch Speichern der bereits verarbeiteten event_id oder durch UPDATE ... WHERE status = ... wie in Abschnitt 7.1.

9. Echtzeit-Status für Kunden

Kunden müssen zwei Arten von Updates sehen: Bestellstatus (selten, wichtig) und Lieferer-Standort (häufig, können übersprungen werden). Server-Sent Events ist eine gute Wahl, weil der Datenstrom nur unidirektional vom Server zum Client fließt:

@RestController
public class OrderStreamController {

    private final Map<Long, List<SseEmitter>> emitters = new ConcurrentHashMap<>();

    @GetMapping(value = "/api/orders/{id}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter stream(@PathVariable long id) {
        SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
        emitters.computeIfAbsent(id, k -> new CopyOnWriteArrayList<>()).add(emitter);
        emitter.onCompletion(() -> emitters.get(id).remove(emitter));
        emitter.onTimeout(emitter::complete);
        return emitter;
    }

    public void push(long orderId, String eventName, Object data) {
        emitters.getOrDefault(orderId, List.of()).forEach(e -> {
            try {
                e.send(SseEmitter.event().name(eventName).data(data));
            } catch (IOException ex) {
                e.completeWithError(ex);
            }
        });
    }
}

Jede Bestellung hat eine Liste von Emittern (Kunden können auf mehreren Geräten öffnen). push sendet Events mit Namen, z. B. status oder location. Der Client empfängt:

event:status
data:{"orderId":1024,"status":"PICKED_UP"}

event:location
data:{"lat":10.7769,"lng":106.7009}

Wenn Sie mehrere Instanzen betreiben, liegt die SSE-Verbindung eines Kunden nur auf einer Instanz. Es gibt zwei Möglichkeiten, dass Events die richtige Instanz erreichen:

  • Bestellstatus: Jede Tracking-Instanz verbraucht order-events mit ihrer eigenen group.id, daher erhält jede Instanz alle Events und pusht nur an die Emitter, die sie hält.
  • Lieferer-Standort: Wenn die Bestellung im Status PICKED_UP ist, veröffentlicht Tracking den Standort im Redis-Channel track:order:{id}. Die Instanz, die die Verbindung hält, subscribt diesen Channel. Wir senden nicht 12.500 Messages/Sekunde durch Kafka, wenn nur ein kleiner Teil angesehen wird.

Um die Last zu reduzieren, können Sie Standorte zusammenfassen: Der Client benötigt nur einen Punkt alle 4–5 Sekunden und interpoliert die Bewegung auf der Karte selbst.

10. Traffic-Spitzen zur Stoßzeit

Zur Mittagszeit oder bei Aktionen kann der Traffic um das 5–10-fache ansteigen. Einige Techniken helfen dem System standzuhalten:

  • Cache-Menü. Menüs werden oft gelesen, selten geschrieben. Cachen Sie sie in Redis nach restaurant_id und invalidieren Sie den Cache, wenn das Restaurant ein Gericht ändert. Die meisten „Restaurant-anzeigen”-Requests werden die Datenbank nicht treffen.
  • Rate Limiting nach Benutzer und Restaurant im Gateway. Sie können auch vorübergehend Bestellungen bei überlastetem Restaurant pausieren (mehr als N Bestellungen im Status PLACED), anstatt Kunden 40 Minuten warten zu lassen.
  • Schreib- und Verarbeitungspfade trennen. Die API zum Bestellen schreibt nur orders und outbox und gibt sofort zurück. Benachrichtigungen, Versandberechnungen und Promotionsempfehlungen laufen asynchron über Kafka.
  • Batch-Dispatch. Wenn die Nachfrage das Angebot übersteigt, führt Batch-Matching von Bestellungen und Lieferern alle 5–10 Sekunden zu besseren Ergebnissen als jeden sofort einzuladen. Das System kann auch zwei Bestellungen aus dem gleichen Restaurant einem Lieferer zusammenfassen.
  • Kontrollierte Degradation. Wenn Redis Tracking langsam wird, priorisieren Sie den Bestellstrom und pausieren Sie vorübergehend die Standort-Pushes. Kunden akzeptieren eine erstarrte Karte für ein paar Sekunden eher, als gar nicht bestellen zu können.

11. Häufige Fehler

  • Lieferer-Standorte in PostgreSQL speichern. 12.500 Schreibvorgänge pro Sekunde, jeder aktualisiert eine Reihe, führen zu Bloat und Lock-Konflikten. Speichern Sie nur Fahrtverläufe in Batches (z. B. über Kafka in ein Data Warehouse), wenn Sie Abrechnungen benötigen.
  • Status mit SELECT prüfen und dann UPDATE. Zwischen den beiden Anweisungen gibt es immer einen Race-Condition-Slot. Sie sollten die Bedingung in die WHERE-Klausel einbinden.
  • Kafka im @Transactional ohne Outbox senden. Die Transaktion kann zurückgerollt werden, aber die Message ist bereits weg, oder umgekehrt.
  • SseEmitter ohne Timeout verwenden. Aufgehängte Verbindungen sammeln sich an und erschöpfen Ressourcen. Sie sollten ein Timeout setzen und den Client sich selbst neu verbinden lassen mit GET /api/orders/{id}, um den neuesten Status zu erhalten.

Anmerkung: Eine Mehrservice-Architektur mit Kafka, Redis GEO und Outbox lohnt sich nur bei ausreichend großer Skala. Für ein paar hundert Bestellungen pro Tag reicht eine monolithische Spring-Boot-App, ein PostgreSQL und 5-sekündiges Polling aus und ist viel leichter zu betreiben.

12. Fazit

In diesem Artikel haben Sie ein Food-Delivery-System von den Anforderungen bis zur Implementierung designed. Es gibt vier Schlüsselideen:

  • Modellieren Sie Bestellungen mit einer expliziten Zustandsmaschine.
  • Lösen Sie Konflikte mit bedingten UPDATEs und Optimistic Locking.
  • Verteilen Sie Bestellungen mit Redis GEO kombiniert mit Database-Constraints.
  • Wahren Sie Konsistenz mit Kafka durch Transactional Outbox.

Das durchgängige Prinzip ist: Kategorisieren Sie Daten nach ihren Konsistenzanforderungen. Bestellungen benötigen starke Konsistenz, also gehören sie in eine relationale Datenbank. Lieferer-Standorte akzeptieren Datenverlust, also gehen sie über Redis mit Geschwindigkeitspriorität. Sobald Sie diese beiden getrennt haben, werden die meisten schwierigen Aspekte des Problems viel handhabbarer.

system-designspring-bootrediskafkacase-study