Systementwurf: Bestellverfolgung und Echtzeit-Lieferboten-Positionen
1. Überblick
Wenn man Essen bestellt oder etwas versendet, möchte der Kunde wissen, in welchem Status die Bestellung ist und wo sich der Lieferbote auf der Karte befindet. Dieser Artikel entwirft ein solches System von den Anforderungen bis zum Code. Der Artikel durchläuft das Datenmodell, APIs, Verarbeitungsflüsse und dann drei schwierige Punkte: Konflikte beim Annehmen von Bestellungen, Datenkonsistenz und Lastspitzen.
Nach dem Lesen haben Sie eine Referenzarchitektur und Spring Boot 3.x-Code-Schnipsel zum Starten der Implementierung.
2. Anforderungen
2.1. Funktionale Anforderungen
- Lieferboten senden GPS-Positionen regelmäßig von der App aus.
- Kunden sehen Status der Bestellung und Position des Lieferboten nahezu in Echtzeit mit einer Latenz unter 3 Sekunden.
- Lieferboten nehmen Bestellungen an. Jede Bestellung wird genau einem Lieferboten zugewiesen.
- Der Bestellstatus folgt einem Lebenszyklus:
PENDING → ASSIGNED → PICKED_UP → DELIVERING → DELIVERED. Von einigen Status kann zuCANCELLEDübergegangen werden. - Speichern Sie den Status-Verlauf für Abstimmung und Reklamationsbearbeitung.
2.2. Größenschätzung
| Metrik | Wert |
|---|---|
| Online-Lieferboten in der Spitzenzeit | 50.000 |
| Positions-Sendezyklus | 4 Sekunden |
| Positions-Schreib-Durchsatz | ~12.500 msg/s |
| Kunden mit offenen Verfolgungsbildschirmen | ~200.000 Verbindungen |
| Neue Bestellungen in der Spitzenzeit | ~300 Bestellungen/s |
2.3. Einschränkungen
- Position ist temporäre Daten. Der Verlust einiger Punkte ist akzeptabel, aber keine alten Positionen sollten neue Positionen überschreiben.
- Bestellstatus sind wichtige Daten. Sie dürfen nicht verloren gehen und die Reihenfolge darf nicht durcheinander kommen.
- Das Mobilfunknetz ist instabil, daher wird die App Daten erneut senden und im Batch-Modus versenden.
Dies führt zu einer kritischen Entscheidung: Trennung von zwei Streams. Der Positions-Stream benötigt hohen Durchsatz und akzeptiert Datenverlust. Der Status-Stream benötigt Transaktionen und Zuverlässigkeit.
3. Gesamtarchitektur
App Lieferbote ──HTTP batch──> Location API ──> Kafka [shipper-locations]
│
▼
Location Consumer ──> Redis (neueste Position, GEO)
│
└──> Redis Pub/Sub [order:{id}:loc]
│
App Kunde <──────── SSE ──────── Tracking Gateway <────────────┘
App Lieferbote ──HTTP──> Order Service ──> PostgreSQL (orders, order_events, outbox)
│
└──> Outbox Relay ──> Kafka [order-events]
Jede Komponente hat eine Aufgabe:
- Kafka absorbiert die Schreiblast von Positionen und behält die Reihenfolge pro Lieferboten bei, indem
shipperIdals Key verwendet wird. - Redis speichert nur die neueste Position jedes Lieferboten. Das System speichert nicht jeden GPS-Punkt in der relationalen Datenbank.
- PostgreSQL ist die Single Source of Truth für den Bestellstatus.
- SSE schiebt Daten in eine Richtung vom Server zum Client. SSE ist einfacher als WebSocket und ausreichend für diesen Use-Case.
4. Datenmodell
4.1. PostgreSQL für Bestellungen
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
shipper_id BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
version INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_events (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
from_status VARCHAR(20),
to_status VARCHAR(20) NOT NULL,
actor_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_id BIGINT NOT NULL,
payload JSONB NOT NULL,
published BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_outbox_unpublished ON outbox(id) WHERE published = false;
Die Spalte version dient der optimistischen Sperrung. Die Tabelle order_events ist ein Nur-Anhängen-Verlauf, nicht bearbeitbar. Die Tabelle outbox stellt sicher, dass Ereignisse an Kafka emittiert werden, ohne verloren zu gehen. Abschnitt 7 erklärt dies im Detail.
4.2. Redis für Positionen
shipper:loc:{shipperId} HASH {lat, lng, ts} TTL 120s
shippers:geo:{city} GEO member = shipperId
shipper:active-order:{shipperId} STRING orderId
Das TTL von 120 Sekunden lässt den Lieferboten automatisch aus dem System verschwinden, wenn die Verbindung unterbrochen wird. Sie benötigen keinen separaten Cleanup-Job. Der GEO-Schlüssel ist nach Stadt aufgeteilt, daher muss der Befehl GEOSEARCH bei der Suche nach dem nächsten Lieferboten nicht das ganze Land durchsuchen.
5. API
POST /api/v1/shippers/{shipperId}/locations body: [ {lat,lng,ts}, ... ] -> 202
POST /api/v1/orders/{orderId}/claim -> 200 | 409
POST /api/v1/orders/{orderId}/transitions body: {to: "PICKED_UP"} -> 200 | 409
GET /api/v1/orders/{orderId} -> status + aktuelle Position
GET /api/v1/orders/{orderId}/track -> text/event-stream
Die Locations-API gibt 202 Accepted zurück, da sie Daten nur in Kafka schiebt und sofort antwortet. Diese API akzeptiert Daten in Batches, damit die App Punkte, die bei Netzwerkausfällen stecken bleiben, sammeln und auf einmal senden kann.
6. Locations-Verarbeitungsfluss
Sie benötigen die folgenden Abhängigkeiten:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</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>
Konfigurieren Sie JSON-Serializer für Kafka:
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
acks: 1
consumer:
group-id: location-consumer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: "com.nonla.tracking"
Der Wert acks: 1 ist eine bewusste Wahl. Der Verlust einiger GPS-Punkte, wenn der Broker-Leader stirbt, ist akzeptabel, im Gegenzug für niedrigere Schreiblatenz als acks: all.
6.1. Positionen empfangen
public record LocationUpdate(long shipperId, double lat, double lng, long ts) {}
@RestController
@RequestMapping("/api/v1/shippers")
public class LocationController {
private final KafkaTemplate<String, LocationUpdate> kafka;
public LocationController(KafkaTemplate<String, LocationUpdate> kafka) {
this.kafka = kafka;
}
@PostMapping("/{shipperId}/locations")
public ResponseEntity<Void> push(@PathVariable long shipperId,
@RequestBody List<LocationUpdate> batch) {
batch.stream()
.filter(u -> u.shipperId() == shipperId)
.forEach(u -> kafka.send("shipper-locations", String.valueOf(shipperId), u));
return ResponseEntity.accepted().build();
}
}
Zwei Punkte zu beachten:
- Key ist
shipperId. Alle Punkte desselben Lieferboten fallen in die gleiche Partition, daher verarbeitet der Consumer sie in der richtigen Reihenfolge. - Der
filterverwirft Punkte mit einemshipperId, der sich vom Pfad unterscheidet. In der Praxis sollten SieshipperIdaus JWT extrahieren, anstatt dem Body zu vertrauen.
6.2. Schreiben Sie die neueste Position, werfen Sie alte Punkte weg
Die Reihenfolge in der Partition reicht nicht aus. Die App kann nach der Wiederherstellung der Konnektivität einen älteren Batch erneut senden, und dieser Batch könnte nach einem neueren Punkt ankommen. Daher muss der Consumer ts vergleichen und nur schreiben, wenn der Punkt neuer ist. Der Vergleich und die Schreiboperation müssen atomar sein, daher verwenden wir ein Lua-Skript:
@Component
public class LocationConsumer {
private static final RedisScript<Long> UPSERT_IF_NEWER = RedisScript.of("""
local cur = redis.call('HGET', KEYS[1], 'ts')
if cur and tonumber(cur) >= tonumber(ARGV[3]) then return 0 end
redis.call('HSET', KEYS[1], 'lat', ARGV[1], 'lng', ARGV[2], 'ts', ARGV[3])
redis.call('EXPIRE', KEYS[1], 120)
return 1
""", Long.class);
private final StringRedisTemplate redis;
public LocationConsumer(StringRedisTemplate redis) {
this.redis = redis;
}
@KafkaListener(topics = "shipper-locations", concurrency = "6")
public void onLocation(LocationUpdate u) {
Long updated = redis.execute(UPSERT_IF_NEWER,
List.of("shipper:loc:" + u.shipperId()),
String.valueOf(u.lat()), String.valueOf(u.lng()), String.valueOf(u.ts()));
if (updated == null || updated == 0) return; // alter Punkt, überspringen
redis.opsForGeo().add("shippers:geo:hcm", new Point(u.lng(), u.lat()),
String.valueOf(u.shipperId()));
String orderId = redis.opsForValue().get("shipper:active-order:" + u.shipperId());
if (orderId != null) {
redis.convertAndSend("order:" + orderId + ":loc",
"{\"lat\":%s,\"lng\":%s,\"ts\":%d}".formatted(u.lat(), u.lng(), u.ts()));
}
}
}
Die Verarbeitungsabfolge ist wie folgt:
- Das Skript gibt
0zurück, wenn der Punkt älter oder gleich dem gespeicherten Punkt ist. Der Consumer stoppt dann und aktualisiert weder GEO noch schiebt zum Client. - Der Befehl
GEOADDakzeptiert Parameter in der Reihenfolge Längenngrad zuerst, Breitengrad danach. Dies ist ein häufiger Fehler. - Das System published nur, wenn der Lieferbote eine Bestellung hat. Ein Lieferbote ohne Auftrag erzeugt keinen Fan-out-Traffic.
Hinweis: Das Skript oben touchiert nur einen Schlüssel, daher funktioniert es auf Redis Cluster. Wenn Sie
GEOADDin das Skript einbeziehen möchten, müssen die Schlüssel im gleichen Slot sein. Verwenden Sie dann Hash-Tags wie{hcm}:loc:42und{hcm}:geo.
6.3. Push zu Clients über SSE
@RestController
public class TrackingController implements MessageListener {
private final Map<String, Set<SseEmitter>> subscribers = new ConcurrentHashMap<>();
@GetMapping(value = "/api/v1/orders/{orderId}/track",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter track(@PathVariable String orderId) {
SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
Set<SseEmitter> set = subscribers.computeIfAbsent(orderId, k -> ConcurrentHashMap.newKeySet());
set.add(emitter);
Runnable cleanup = () -> set.remove(emitter);
emitter.onCompletion(cleanup);
emitter.onTimeout(cleanup);
emitter.onError(e -> cleanup.run());
return emitter;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(message.getChannel()); // order:123:loc
String orderId = channel.split(":")[1];
String body = new String(message.getBody());
subscribers.getOrDefault(orderId, Set.of()).forEach(e -> {
try {
e.send(SseEmitter.event().name("location").data(body));
} catch (IOException ex) {
e.completeWithError(ex);
}
});
}
}
Sie registrieren einen Listener mit dem Pattern order:*:loc:
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory cf, TrackingController listener) {
var c = new RedisMessageListenerContainer();
c.setConnectionFactory(cf);
c.addMessageListener(listener, new PatternTopic("order:*:loc"));
return c;
}
Der Client erhält Events wie folgt:
event:location
data:{"lat":10.7769,"lng":106.7009,"ts":1760342400000}
Das Gateway ist zustandslos, daher können Sie beliebig viele Instanzen horizontal skalieren. Die Instanz, die die SSE-Verbindung des Clients hält, empfängt Messages über Pub/Sub und schiebt sie herunter. Mit 200.000 Verbindungen sollten Sie Virtual Threads aktivieren (spring.threads.virtual.enabled=true) oder zu WebFlux wechseln, damit jede Verbindung keinen Platform Thread belegt.
7. Schwierige Punkte und Lösungen
7.1. Konflikte: Zwei Lieferboten nehmen die gleiche Bestellung an
Eine neue Bestellung wird vielen nahegelegenen Lieferboten angezeigt. Zwei können gleichzeitig auf „Annehmen” klicken. Der falsche Weg ist, den Status zu lesen, PENDING zu prüfen und dann zu schreiben, da es eine Lücke zwischen Lesen und Schreiben gibt. Der richtige Weg ist die Verwendung eines bedingten UPDATE:
@Service
public class OrderClaimService {
private final JdbcTemplate jdbc;
public OrderClaimService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public boolean claim(long orderId, long shipperId) {
int rows = jdbc.update("""
UPDATE orders
SET shipper_id = ?, status = 'ASSIGNED', version = version + 1, updated_at = now()
WHERE id = ? AND status = 'PENDING'
""", shipperId, orderId);
if (rows == 0) return false; // jemand anderes hat das angenommen
jdbc.update("INSERT INTO order_events(order_id, from_status, to_status, actor_id) VALUES (?, 'PENDING', 'ASSIGNED', ?)",
orderId, shipperId);
jdbc.update("INSERT INTO outbox(aggregate_id, payload) VALUES (?, ?::jsonb)",
orderId, "{\"orderId\":%d,\"status\":\"ASSIGNED\",\"shipperId\":%d}".formatted(orderId, shipperId));
return true;
}
}
PostgreSQL sperrt die Zeile während der UPDATE-Ausführung. Der zweite Lieferbote muss warten, dann wertet die Bedingung status = 'PENDING' neu aus, sieht, dass sie nicht mehr erfüllt ist, und erhält rows == 0. Der Controller gibt 409 Conflict zurück, die App zeigt eine Nachricht „Bestellung wurde bereits von jemandem angenommen”. Sie benötigen keine verteilte Sperre.
7.2. Konsistenz: State Machine und Outbox
Jede Statusübergang muss gültig sein. Sie kodieren die Übergangsregeln direkt in dem Enum:
public enum OrderStatus {
PENDING, ASSIGNED, PICKED_UP, DELIVERING, DELIVERED, CANCELLED;
public boolean canTransitionTo(OrderStatus next) {
return switch (this) {
case PENDING -> next == ASSIGNED || next == CANCELLED;
case ASSIGNED -> next == PICKED_UP || next == CANCELLED;
case PICKED_UP -> next == DELIVERING;
case DELIVERING -> next == DELIVERED;
case DELIVERED, CANCELLED -> false;
};
}
}
Teste für das Hauptverhalten:
class OrderStatusTest {
@Test
void shouldAllowHappyPath() {
assertThat(OrderStatus.PENDING.canTransitionTo(OrderStatus.ASSIGNED)).isTrue();
assertThat(OrderStatus.DELIVERING.canTransitionTo(OrderStatus.DELIVERED)).isTrue();
}
@Test
void shouldRejectCancelAfterPickup() {
assertThat(OrderStatus.PICKED_UP.canTransitionTo(OrderStatus.CANCELLED)).isFalse();
}
@Test
void terminalStatesAreFinal() {
assertThat(OrderStatus.values())
.noneMatch(OrderStatus.DELIVERED::canTransitionTo)
.noneMatch(OrderStatus.CANCELLED::canTransitionTo);
}
}
Bei Statusübergang verwenden Sie die Bedingung WHERE id = ? AND version = ?. Wenn keine Zeile aktualisiert wird, bedeutet das, dass jemand die Bestellung vorher geändert hat. In diesem Fall geben Sie 409 zurück und lassen den Client neu laden.
Das Outbox-Pattern löst das Problem „Erfolgreich in DB schreiben, aber Kafka-Versand fehlgeschlagen”. Ereignisse werden in der outbox-Tabelle in derselben Transaktion wie orders geschrieben. Ein periodisches Relay liest unveröffentlichte Zeilen und schiebt sie zu Kafka:
@Scheduled(fixedDelay = 500)
@Transactional
public void relay() {
var rows = jdbc.queryForList(
"SELECT id, aggregate_id, payload::text AS payload FROM outbox WHERE published = false ORDER BY id LIMIT 200 FOR UPDATE SKIP LOCKED");
for (var r : rows) {
kafka.send("order-events", r.get("aggregate_id").toString(), (String) r.get("payload")).join();
jdbc.update("UPDATE outbox SET published = true WHERE id = ?", r.get("id"));
}
}
Die Klausel FOR UPDATE SKIP LOCKED ermöglicht es, mehrere Relays parallel auszuführen, ohne eine Zeile doppelt zu verarbeiten. Das Relay garantiert At-least-once: Wenn es nach dem Versand aber vor dem Markieren als published stirbt, wird das Ereignis erneut gesendet. Daher muss der Consumer von order-events idempotent sein. Zum Beispiel ignoriert der Consumer Ereignisse mit einer version, die kleiner als oder gleich der verarbeiteten Version ist.
Der Consumer von order-events ist auch der Ort, an dem der Schlüssel shipper:active-order:{shipperId} gesetzt oder gelöscht wird. Dadurch weiß der Positions-Stream, welche Bestellung gepusht werden soll.
7.3. Lastspitzen
Mittags, bei starkem Regen oder am Verkaufstag kann die Last um das 3–5-Fache innerhalb weniger Minuten ansteigen. Sie haben folgende Behandlungsmöglichkeiten:
- Kafka als Puffer. Wenn Consumer langsam sind, steigt das Lag, aber die API antwortet immer noch schnell. Sie erhöhen die Partition-Anzahl (z.B. 48) im Voraus, da eine Erhöhung während der Laufzeit die Reihenfolge nach Key durcheinander bringt.
- Werfen Sie zu alte Punkte weg. Wenn das Lag groß ist, hat ein 30 Sekunden alter GPS-Punkt keinen Anzeigewert mehr. Der Consumer kann ihn sofort überspringen:
if (System.currentTimeMillis() - u.ts() > 30_000) return;. - Adaptive Sendezyklen. Der Server gibt einen Vorschlag-Header zurück, z.B.
X-Next-Interval: 8, damit die App den Sendezyklus verlangsamt, wenn das System überlastet ist. Ruhende Lieferboten werden seltener gesendet. - Batching auf dem Client. Die App fasst 2–3 Punkte in eine Request zusammen. Die Anzahl der HTTP-Anfragen sinkt drastisch, während die Anzahl der Kafka-Messages gleich bleibt.
- Rate Limiting pro Lieferboten. Sie begrenzen jeden Lieferboten auf maximal 1 Request/Sekunde, um fehlerhafte Apps oder gefälschte Clients zu blockieren.
- Ressourcen-Trennung. Der Positions-Stream läuft auf einem Redis-Cluster und einer separaten Instanzgruppe. Wenn dieser Stream ausfällt, läuft die Bestellungsannahme und Statusaktualisierung immer noch normal.
8. Häufige Fehler und wann man das nicht tun sollte
- Jeden GPS-Punkt in PostgreSQL schreiben. 12.500 Inserts/s plus Indizierung werden die Datenbank schnell überlasten. Wenn Sie die Route zur Abstimmung speichern müssen, lassen Sie einen anderen Consumer in einem geeigneten Repository wie TimescaleDB oder Object Storage batchweis mit niedrigerer Frequenz schreiben.
- Verwenden von verteilter Sperre (Redisson) zum Annehmen von Bestellungen. Dieser Ansatz ist komplexer und fehleranfälliger als bedingtes UPDATE auf der Single Source of Truth.
- Auf die Ankunftsreihenfolge von Anfragen vertrauen. Mobilfunknetze garantieren keine Reihenfolge. Sie müssen immer
tsvergleichen und für Statusversionvergleichen. - Redis Pub/Sub für wichtige Daten. Pub/Sub ist Fire-and-Forget. Wenn der Subscriber die Verbindung verliert, gehen Messages verloren. Dies ist in Ordnung für Positionen, aber nicht für Status-Ereignisse. Status-Ereignisse sollten über Kafka gehen. Wenn der Client die SSE-Verbindung wiederherstellt, ruft er
GET /orders/{id}auf, um den neuesten Snapshot zu erhalten.
Hinweis: Wenn Ihr System nur wenige hundert Lieferboten hat, ist diese Architektur Overkill. Ein einziger Service, der Positionen in Redis schreibt, kombiniert mit Client-Polling alle 5 Sekunden, reicht aus. Fügen Sie Kafka und SSE Gateway hinzu, wenn die realen Metriken Sie dazu zwingen.
9. Fazit
Dieser Artikel hat ein System zur Verfolgung von Bestellungen und Lieferboten-Positionen nach dem Kernprinzip entworfen: Trennung von temporären Daten von wichtigen Daten.
- Positionen gehen über Kafka in Redis. Das System akzeptiert Datenverlust, blockiert aber Überschreiben alter Punkte mit einem Lua-Skript, das Zeitstempel vergleicht.
- Der Bestellstatus befindet sich in PostgreSQL. Konflikte werden durch bedingtes UPDATE gelöst, Konsistenz wird durch State Machine und Outbox-Pattern gewährleistet.
- Lastspitzen werden von Kafka absorbiert, kombiniert mit dem Verwerfen alter Punkte, adaptiven Sendezyklen und Ressourcen-Trennung.
Von diesem Framework aus können Sie zusätzliche Funktionen wie ETA-Berechnung, Bestellgrouping nach Standort mit GEOSEARCH oder Speicherung der Route für Abstimmungszwecke hinzufügen.