Ride-Sharing-App: Nächsten Fahrer finden und Fahrt zuweisen
1. Übersicht
Eine Ride-Sharing-App hat zwei zentrale Fragen. Welcher Fahrer ist dem Fahrgast am nächsten? Wie stellt man sicher, dass eine Fahrt genau einem Fahrer gehört, selbst wenn Dutzende Fahrer gleichzeitig „Akzeptieren” drücken?
Dieser Artikel behandelt das komplette Problem im Stil eines Systemdesigns: Anforderungen, Skalierungsschätzung, Datenmodell, API und Verarbeitungsabläufe. Danach konzentriert sich der Artikel auf die Schwachstellen: Konkurrenzbedingungen, Konsistenz und Lastspitzen.
Nach dieser Lektüre kannst du den Kernbereich „Fahrer finden und Fahrt zuweisen” mit Spring Boot 3, Redis, Kafka und PostgreSQL implementieren.
2. Anforderungen
2.1. Funktionale Anforderungen
- Fahrer übermitteln ihren GPS-Standort regelmäßig, während sie online sind.
- Fahrgäste buchen Fahrten mit Abhol- und Zielort.
- Das System findet die verfügbaren Fahrer in der Nähe und schickt Anfragen nacheinander an sie.
- Fahrer akzeptieren oder lehnen ab. Antwortet ein Fahrer nicht innerhalb von 15 Sekunden, leitet das System die Anfrage an den nächsten Fahrer weiter.
- Fahrgäste können den Fahrzeustand verfolgen:
REQUESTED → ACCEPTED → IN_PROGRESS → COMPLETED(oderCANCELLED).
2.2. Skalierung und Einschränkungen
Angenommen, das System läuft in einem Land mittlerer Größe:
- In Stoßzeiten sind 300.000 Fahrer online, jeder sendet seinen Standort alle 4 Sekunden. Das ergibt etwa 75.000 Standort-Schreibvorgänge pro Sekunde.
- Es gibt etwa 1.500 Fahrtanfragen pro Sekunde. Bei Regen oder Feierabendverkehr kann sich diese Zahl verfünffachen.
- Die Fahrersuche muss innerhalb von 200 ms abgeschlossen sein (p99).
- Jede Fahrt gehört zu genau einem Fahrer. Ein Fahrer kann maximal eine aktive Fahrt haben. Das ist eine nicht verhandelbare Einschränkung.
Aus diesen Zahlen ergibt sich eine wichtige Beobachtung. Der Standort eines Fahrers ist ein Datum mit hohem Schreibaufkommen, kurzer Lebensdauer und Datenverlust ist akzeptabel: Eine fehlende GPS-Position ist nicht schlimm. Im Gegensatz dazu werden Fahrtdaten viel weniger häufig geschrieben, erfordern aber absolute Konsistenz. Daher sollten diese beiden Datentypen in zwei verschiedenen Speichersystemen liegen.
3. Datenmodell
Fahrstandorte werden in Redis gespeichert. Fahrten werden in PostgreSQL gespeichert:
CREATE TABLE rides (
id BIGSERIAL PRIMARY KEY,
customer_id VARCHAR(64) NOT NULL,
driver_id VARCHAR(64),
pickup_lat DOUBLE PRECISION NOT NULL,
pickup_lng DOUBLE PRECISION NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED',
created_at TIMESTAMP NOT NULL DEFAULT now(),
accepted_at TIMESTAMP
);
-- Jeder Fahrer kann maximal eine aktive Fahrt haben
CREATE UNIQUE INDEX ux_rides_driver_active
ON rides (driver_id)
WHERE status IN ('ACCEPTED', 'IN_PROGRESS');
Der partielle eindeutige Index am Ende ist das letzte Sicherheitsnetz. Wenn die Anwendungsschicht einen Bug hat und versucht, zwei Fahrten demselben Fahrer zuzuweisen, wird die Datenbank den zweiten Schreibzugriff ablehnen.
Redis verwendet drei Arten von Schlüsseln:
drivers:available GEO-Set: driverId -> (lng, lat), enthält nur verfügbare Fahrer
driver:heartbeat:{driverId} String, TTL 30s, markiert Fahrer als noch online
driver:lock:{driverId} String, TTL 15s, Wert = rideId, für die gerade eine Anfrage versendet wird
4. API
POST /api/drivers/{driverId}/location { "lat": 10.7769, "lng": 106.7009 }
POST /api/rides { "customerId": "c-42", "pickupLat": ..., "pickupLng": ... }
POST /api/rides/{rideId}/accept { "driverId": "d-7" }
GET /api/rides/{rideId}
Der Standort-Update-Endpunkt hat eine sehr hohe Last. In der Praxis läuft dieser Teil normalerweise über WebSocket oder gRPC-Stream, um nicht alle 4 Sekunden eine neue HTTP-Verbindung zu öffnen. Um das Beispiel einfach zu halten, verwenden wir hier immer noch REST.
5. Fahrerstaudorte mit Redis GEO speichern und suchen
Dependency hinzufügen:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
5.1. Standort aktualisieren
@Service
public class DriverLocationService {
static final String AVAILABLE_KEY = "drivers:available";
private final StringRedisTemplate redis;
public DriverLocationService(StringRedisTemplate redis) {
this.redis = redis;
}
public void updateLocation(String driverId, double lng, double lat) {
redis.opsForGeo().add(AVAILABLE_KEY, new Point(lng, lat), driverId);
redis.opsForValue().set("driver:heartbeat:" + driverId, "1", Duration.ofSeconds(30));
}
public void markBusy(String driverId) {
redis.opsForGeo().remove(AVAILABLE_KEY, driverId);
}
}
GEOADD speichert Koordinaten als Geohash in einem sortierten Set. Jeder Befehl hat eine Zeitkomplexität von O(log N), so kann ein Redis-Knoten problemlos zehntausende Befehle pro Sekunde verarbeiten.
Der Heartbeat existiert, weil Mitglieder im GEO-Set keine individuelle TTL haben. Wenn ein Fahrer die Verbindung verliert oder die App schließt, bleibt die alte Koordinate im Set. Der Heartbeat-Schlüssel läuft nach 30 Sekunden ab und hilft uns, diese „Phantom”-Fahrer herauszufiltern.
Hinweis: Redis GEO erwartet Parameter in der Reihenfolge (longitude, latitude). Die Reihenfolge zu verwechseln ist ein sehr häufiger Fehler. Im Fall einer Umkehrung landen Fahrer aus Ho-Chi-Minh-Stadt plötzlich am Südpol.
5.2. Nächste Fahrer finden
public List<String> findNearby(double lng, double lat, double radiusKm, int limit) {
var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance()
.sortAscending()
.limit(limit * 2L); // lấy dư để bù cho tài xế bị lọc
GeoResults<RedisGeoCommands.GeoLocation<String>> results = redis.opsForGeo().search(
AVAILABLE_KEY,
GeoReference.fromCoordinate(lng, lat),
new Distance(radiusKm, Metrics.KILOMETERS),
args);
List<String> candidates = results.getContent().stream()
.map(r -> r.getContent().getName())
.toList();
List<String> alive = redis.opsForValue().multiGet(
candidates.stream().map(id -> "driver:heartbeat:" + id).toList());
List<String> online = new ArrayList<>();
for (int i = 0; i < candidates.size() && online.size() < limit; i++) {
if (alive.get(i) != null) {
online.add(candidates.get(i));
}
}
return online;
}
GEOSEARCH gibt Fahrer in einem bestimmten Radius zurück, sortiert von nah bis fern. Dann überprüft ein einzelner MGET-Befehl den Heartbeat aller Kandidaten, was nur eine zusätzliche Round-Trip kostet. Für einen Fahrgast in Distrikt 1 mit einem Radius von 3 km kann das Ergebnis so aussehen:
[d-7 (0.4 km), d-19 (0.9 km), d-3 (1.6 km)]
Die Entfernung hier ist die Luftlinie. In der Praxis solltest du die Top-N-Kandidaten anhand der tatsächlichen ETA vom Kartendienst neu bewerten. Der Grund ist, dass ein Fahrer 500 m entfernt, aber auf der anderen Seite eines Flusses, länger brauchen kann als ein Fahrer 1 km entfernt.
6. Fahrtverteilung und Konkurrenzbedingungen handhaben
Es gibt zwei Arten von Konkurrenzbedingungen, die verhindert werden müssen:
-
Zwei Fahrten laden gleichzeitig einen Fahrer ein. Zwei Fahrgäste in der Nähe buchen gleichzeitig, und beide Matching-Prozesse wählen
d-7. -
Zwei Fahrer akzeptieren gleichzeitig eine Fahrt. Das passiert, wenn du parallel Anfragen an mehrere Fahrer verschickst, um die Wartezeit zu reduzieren.
6.1. Fahrer mit Redis sperren, während eine Anfrage läuft
@Service
public class DriverLockService {
private static final RedisScript<Long> RELEASE = RedisScript.of("""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
""", Long.class);
private final StringRedisTemplate redis;
public DriverLockService(StringRedisTemplate redis) {
this.redis = redis;
}
public boolean tryLock(String driverId, long rideId) {
return Boolean.TRUE.equals(redis.opsForValue()
.setIfAbsent("driver:lock:" + driverId, String.valueOf(rideId), Duration.ofSeconds(15)));
}
public void release(String driverId, long rideId) {
redis.execute(RELEASE, List.of("driver:lock:" + driverId), String.valueOf(rideId));
}
}
SET NX EX ist ein atomarer Befehl. Nur ein Matching-Prozess kann den Lock für einen Fahrer halten, und der Lock verfällt nach 15 Sekunden automatisch, genau der Zeit, auf die auf die Antwort des Fahrers gewartet wird. Dank dieser TTL „wechselt” das System automatisch zu einem anderen Fahrer, wenn dieser nicht antwortet, ohne dass Bereinigungsaufgaben nötig sind.
Das Lua-Skript zum Freigeben des Locks löscht den Schlüssel nur, wenn der Wert rideId entspricht. Wenn du einfach DEL nutzt, könnte eine abgelaufene Fahrt A versehentlich einen Lock löschen, den Fahrt B gerade erworben hat.
6.2. Fahrt mit bedingtem Update zuweisen
Der Redis-Lock ist nur ein Optimierungsmechanismus, um doppelte Anfragen zu vermeiden. Die Wahrheit liegt immer noch in der Datenbank:
@Repository
public class RideRepository {
private final JdbcTemplate jdbc;
public RideRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public boolean assignDriver(long rideId, String driverId) {
int updated = jdbc.update("""
UPDATE rides
SET status = 'ACCEPTED', driver_id = ?, accepted_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'REQUESTED'
""", driverId, rideId);
return updated == 1;
}
}
Die Bedingung status = 'REQUESTED' wandelt den UPDATE-Befehl in eine vergleich-und-setzen-Operation um. Wenn zwei Fahrer gleichzeitig akzeptieren, serialisiert die Datenbank die beiden Schreibvorgänge auf derselben Zeile. Der erste Befehl gibt 1 zurück. Der zweite sieht, dass der Status bereits ACCEPTED ist, und gibt 0 zurück. Diese Methode benötigt kein SELECT ... FOR UPDATE und keine separate Versionsspalte.
Der Service, der die Fahrtannahme verarbeitet, kombiniert diese Schritte:
@Transactional
public AcceptResult accept(long rideId, String driverId) {
if (!rideRepository.assignDriver(rideId, driverId)) {
return AcceptResult.ALREADY_TAKEN;
}
locationService.markBusy(driverId);
lockService.release(driverId, rideId);
return AcceptResult.ACCEPTED;
}
Die Reihenfolge hier ist absichtlich: Datenbank zuerst, dann Redis. Wenn der markBusy-Schritt fehlschlägt, bleibt der Fahrer noch ein paar Sekunden in der verfügbaren Liste. Im schlimmsten Fall erhält der Fahrer eine weitere Anfrage, und der partielle eindeutige Index blockiert die zweite Fahrt. Umgekehrt wäre es riskanter: den Fahrer aus Redis zu löschen und dann ein Datenbankschreiben zu fehlschlagen würde den Fahrer „verschwinden” lassen.
7. Asynchrone Verarbeitung mit Kafka
Das Suchen nach einem Fahrer, das Senden einer Anfrage und das Warten von 15 Sekunden ist ein langer Prozess. Die HTTP-Anfrage des Fahrgasts sollte nicht die ganze Zeit über hängen bleiben. Der Endpunkt POST /api/rides speichert die Fahrt nur und gibt sofort 202 Accepted zurück. Die Fahrtverteilung läuft in einem separaten Consumer.
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
public record RideRequested(long rideId, String cityId, double lng, double lat, int attempt) {}
@Component
public class MatchingConsumer {
private final DriverLocationService locations;
private final DriverLockService locks;
private final OfferSender offerSender;
private final KafkaTemplate<String, RideRequested> kafka;
// Konstruktor ist hier weggelassen
@KafkaListener(topics = "ride-requested", groupId = "matching")
public void onRideRequested(RideRequested event) {
for (String driverId : locations.findNearby(event.lng(), event.lat(), 3, 10)) {
if (locks.tryLock(driverId, event.rideId())) {
offerSender.send(driverId, event.rideId()); // push notification / WebSocket
return;
}
}
if (event.attempt() < 5) {
// Noch kein verfügbarer Fahrer: erneut versuchen
kafka.send("ride-requested-retry", event.cityId(),
new RideRequested(event.rideId(), event.cityId(), event.lng(), event.lat(), event.attempt() + 1));
}
}
}
Der Consumer durchsucht Kandidaten von nah bis fern. Er sperrt den ersten verfügbaren Fahrer und sendet eine Anfrage an ihn. Wenn kein Fahrer verfügbar ist, wird das Event zum Retry-Topic verschoben, mit einer begrenzten Anzahl von Versuchen.
Der Schlüssel der Nachricht ist cityId. Dadurch landen alle Fahrten einer Stadt in derselben Partition, und der lokale Driver-Cache des Consumers (falls vorhanden) hat eine höhere Hit-Rate.
Es gibt noch ein kleines Problem: Die Fahrt in PostgreSQL zu speichern und dann Kafka zu senden sind zwei getrennte Operationen. Wenn die Anwendung zwischendurch abstürzt, bleibt die Fahrt für immer im Status REQUESTED stecken. Die richtige Lösung ist das Transactional Outbox Pattern. Du schreibst das Event in die outbox-Tabelle innerhalb derselben Transaktion wie die rides-Tabelle, und lässt dann einen separaten Prozess (oder Debezium) die Outbox-Tabelle lesen und zu Kafka pushen.
Hinweis: Kafka garantiert „at-least-once”-Lieferung, sodass ein Consumer ein Event zweimal erhalten kann. Die Matching-Logik muss idempotent sein. Bevor du eine Anfrage sendest, überprüfe, dass die Fahrt immer noch im Status
REQUESTEDist. Die bedingte Aktualisierung in Abschnitt 6.2 ist bereits vor Duplikaten sicher.
8. Mit Lastspitzen umgehen
Wenn es um 17 Uhr zu regnen beginnt, steigen sowohl die Fahrtanfragen als auch die Zahl der Online-Fahrer steil an. Es gibt vier Hauptwege, damit umzugehen.
-
Standortdaten nach Gebiet sharden. Statt eines einzigen
drivers:available-Schlüssels für das ganze Land, teile nach Stadt oder Geohash-Zellen, z.B.drivers:available:hcm. Jeder Schlüssel liegt auf einem Shard des Redis-Clusters, wodurch die Schreibleast verteilt wird. Wenn ein Fahrgast nahe der Grenze zwischen zwei Zellen ist, fragst du auch die benachbarten Zellen ab. -
Standort-Update-Frequenz reduzieren. Fahrer, die stillstehen oder Fahrgäste befördern, müssen nicht alle 4 Sekunden ihren Standort senden. Nur bei Bewegung über 50 m oder nach 15 Sekunden senden. Diese Optimierung allein kann 40–60% der Schreibvorgänge sparen.
-
Backpressure statt Zusammenbruch. Kafka fungiert als Puffer. Wenn die Fahrtanfragen um das Fünffache steigen, erhöht sich die Matching-Latenz um ein paar Sekunden, aber keine Fahrt geht verloren. Du skalierst die Anzahl der Consumer basierend auf dem Consumer-Lag, mit einer Obergrenze gleich der Anzahl der Partitionen.
-
Dynamische Preisgestaltung (Surge Pricing). Das ist eine Produktebenen-Lösung, keine technische. Höhere Preise reduzieren die Nachfrage und locken mehr Fahrer an, was das System wieder ins Gleichgewicht bringt.
Außerdem solltest du die Fahrtanfrage-API pro customerId Rate-Limit haben. Wenn die App zu lange „Fahrt wird gesucht” anzeigt, klicken Fahrgäste dazu, die Anfrage mehrmals zu senden, und erzeugen dadurch selbst eine verdoppelte Last.
9. Konkurrenzbedingungen beim Fahrtannehmen testen
Das wichtigste Verhalten zum Testen ist: Wenn mehrere Fahrer eine Fahrt gleichzeitig annehmen, gewinnt nur einer. Der folgende Test nutzt H2 im Speicher über @JdbcTest. Die Datei src/test/resources/schema.sql enthält die rides-Tabelle wie in Abschnitt 3, aber ohne den partiellen Index, da H2 diesen nicht unterstützt.
@JdbcTest
@Import(RideRepository.class)
class RideRepositoryTest {
@Autowired
RideRepository rideRepository;
@Autowired
JdbcTemplate jdbc;
@Test
void givenConcurrentAccepts_whenAssignDriver_thenOnlyOneDriverWins() throws Exception {
jdbc.update("INSERT INTO rides (id, customer_id, pickup_lat, pickup_lng, status) "
+ "VALUES (1, 'c-42', 10.77, 106.70, 'REQUESTED')");
List<String> drivers = List.of("d-1", "d-2", "d-3", "d-4", "d-5");
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Boolean>> results = drivers.stream()
.map(d -> executor.submit(() -> rideRepository.assignDriver(1L, d)))
.toList();
long winners = 0;
for (Future<Boolean> f : results) {
if (f.get()) winners++;
}
assertThat(winners).isEqualTo(1);
}
String status = jdbc.queryForObject("SELECT status FROM rides WHERE id = 1", String.class);
assertThat(status).isEqualTo("ACCEPTED");
}
}
Fünf Virtual Threads rufen gleichzeitig assignDriver für eine Fahrt auf. Unabhängig von der Ausführungsreihenfolge passt nur genau ein UPDATE-Befehl die Bedingung status = 'REQUESTED'.
Du könntest bemerken, dass der Test die standardmäßige @Transactional-Anmerkung von @JdbcTest deaktiviert hat. Der Grund: Neue Threads laufen nicht in derselben Transaktion wie die Test-Methode, und der INSERT-Befehl ist sichtbar, weil die Daten mit Auto-Commit committed wurden. Wenn du winners = 0 erhältst, füge @Transactional(propagation = Propagation.NOT_SUPPORTED) zur Test-Klasse hinzu.
Für GEO-Logik solltest du Testcontainers verwenden und echte Redis-Instanzen statt Mock-Objekte einsetzen. Mock-Objekte können die Distanzberechnung und Sortierreihenfolge nicht verifizieren.
10. Fazit
Dieser Artikel hat die Kernkomponenten einer Ride-Sharing-App skizziert. Der Schlüsselpunkt ist die Trennung von Daten nach ihren Eigenschaften:
-
Fahrstandorte haben hohes Schreibaufkommen und Datenverlust ist akzeptabel, daher sollten sie in Redis GEO mit Heartbeat gespeichert werden, um offline gegangene Fahrer zu filtern.
-
Fahrten benötigen Konsistenz, daher sollten sie in PostgreSQL gespeichert werden. Bedingte Updates fungieren als Compare-and-Set, und der partielle Index ist das letzte Sicherheitsnetz.
-
Redis-Locks mit TTL verhindern doppelte Fahrereinladungen und handhaben Timeouts automatisch. Dies sind jedoch nur Optimierungen, nicht die Quelle der Wahrheit.
-
Kafka trennt das Matching vom Kundenrequest, puffert Last ab und erlaubt Retries. Das Outbox-Pattern stellt sicher, dass keine Events verloren gehen.
Bei weiterer Skalierung kannst du folgende Richtungen erwägen: Kandidaten nach ETA statt Luftlinie bewerten, Batch-Matching alle paar Sekunden für globale Optimierung, und Ride-Pooling. Alles kann auf der hier besprochenen Grundlage aufgebaut werden.