Concevoir une app de taxi: trouver le chauffeur et jumeler
1. Vue d’ensemble
Une application de taxi se résume à deux questions fondamentales. Quel chauffeur est le plus proche du client ? Comment garantir qu’une course n’appartient qu’à un seul chauffeur, même si des dizaines de chauffeurs cliquent sur « Accepter » au même moment ?
Cet article couvre le problème entièrement selon l’approche du system design : les besoins, les estimations d’échelle, le modèle de données, les API et les flux de traitement. Nous nous concentrerons ensuite sur les défis critiques : la contention, la cohérence et les pics de charge. À la fin, vous serez capable de construire le cœur du système « trouver un chauffeur et jumeler une course » avec Spring Boot 3, Redis, Kafka et PostgreSQL.
2. Besoins
2.1. Besoins fonctionnels
- Les chauffeurs envoient leur position GPS régulièrement quand ils sont en ligne.
- Un client demande une course avec un point de départ et un point d’arrivée.
- Le système trouve les chauffeurs disponibles les plus proches et leur envoie une offre, séquentiellement.
- Le chauffeur accepte ou refuse. S’il ne répond pas dans les 15 secondes, le système propose à la personne suivante.
- Le client peut suivre l’état de sa course :
REQUESTED → ACCEPTED → IN_PROGRESS → COMPLETED(ouCANCELLED).
2.2. Échelle et contraintes
Imaginons que le système fonctionne dans un pays de taille moyenne :
- Aux heures de pointe, 300 000 chauffeurs sont en ligne, chacun envoyant sa position toutes les 4 secondes. Cela représente environ 75 000 écritures de position par seconde.
- Il y a environ 1 500 demandes de course par seconde. Quand il pleut ou en fin d’après-midi, ce chiffre peut être multiplié par 5.
- La recherche de chauffeur doit se faire en moins de 200 ms (p99).
- Une course n’a qu’un seul chauffeur. Un chauffeur n’a qu’une seule course active à la fois. C’est une contrainte qui ne peut pas être violée.
De ces chiffres, on tire une observation importante. Les positions de chauffeur sont des données très écrites, courte durée de vie et acceptent les pertes : perdre un point GPS n’est pas grave. À l’inverse, les données de course sont écrites beaucoup moins souvent mais doivent être absolument cohérentes. Donc ces deux types de données doivent être stockés dans deux systèmes différents.
3. Modèle de données
Les positions de chauffeur se conservent dans Redis. Les courses se conservent dans PostgreSQL :
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
);
-- Chaque chauffeur n'a qu'une seule course active au maximum
CREATE UNIQUE INDEX ux_rides_driver_active
ON rides (driver_id)
WHERE status IN ('ACCEPTED', 'IN_PROGRESS');
L’index unique partiel à la fin est un « filet de sécurité » final. Supposons qu’il y ait un bug dans la couche application qui essaie d’assigner deux courses au même chauffeur. Dans ce cas, la base de données rejettera la deuxième insertion.
Pour Redis, on utilise trois types de clés :
drivers:available GEO set: driverId -> (lng, lat), contient seulement les chauffeurs dispo
driver:heartbeat:{driverId} String, TTL 30s, indique que le chauffeur est toujours en ligne
driver:lock:{driverId} String, TTL 15s, valeur = rideId en train de faire une offre
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}
L’endpoint de mise à jour de position a une très grande charge. En pratique, il passe généralement par WebSocket ou une streams gRPC pour ne pas ouvrir une connexion HTTP tous les 4 secondes. Pour garder cet exemple simple, on reste sur REST.
5. Enregistrer et chercher la position d’un chauffeur avec Redis GEO
Ajoutez la dépendance :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
5.1. Mettre à jour la position
@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 stocke les coordonnées sous forme de geohash dans un sorted set. Chaque commande a une complexité O(log N), donc un nœud Redis traite sans difficulté des dizaines de milliers de commandes par seconde.
La partie heartbeat existe parce que les membres d’un GEO set n’ont pas de TTL individuel. Si un chauffeur perd la connexion ou ferme l’app, son ancienne position reste dans le set. La clé heartbeat qui expire après 30 secondes nous aide à filtrer ces chauffeurs « fantômes ».
Attention : Redis GEO prend les paramètres dans l’ordre (longitude, latitude). Inverser cet ordre est une erreur très courante. Quand on l’inverse, les chauffeurs de Ho Chi Minh-Ville se retrouvent au Pôle Sud.
5.2. Chercher le chauffeur le plus proche
public List<String> findNearby(double lng, double lat, double radiusKm, int limit) {
var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance()
.sortAscending()
.limit(limit * 2L); // récupérer plus pour compenser les chauffeurs filtrés
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 retourne les chauffeurs dans le rayon spécifié, triés du plus proche au plus loin. Ensuite, une seule commande MGET vérifie le heartbeat de tous les candidats, donc ça ne coûte qu’un aller-retour supplémentaire. Pour un client dans le district 1 et un rayon de 3 km, le résultat pourrait ressembler à :
[d-7 (0.4 km), d-19 (0.9 km), d-3 (1.6 km)]
Les distances ici sont à vol d’oiseau. En production, vous devriez réclasser le top-N des candidats en utilisant l’ETA réelle d’un service de carte. La raison : un chauffeur à 500 m mais de l’autre côté d’une rivière peut arriver plus lentement qu’un chauffeur à 1 km.
6. Jumeler une course et gérer la contention
Il y a deux types de contention à bloquer :
- Deux courses font une offre au même chauffeur. Deux clients côte à côte demandent une course en même temps, et les deux threads de matching choisissent
d-7. - Deux chauffeurs acceptent la même course. Cela arrive quand vous envoyez des offres en parallèle pour réduire le temps d’attente.
6.1. Verrouiller un chauffeur lors de l’offre avec Redis
@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 est une opération atomique. Seulement un thread de matching peut acquérir le verrou pour chaque chauffeur, et le verrou expire automatiquement après 15 secondes, exactement le temps d’attente pour la réponse du chauffeur. Grâce à ce TTL, le système « change de chauffeur » automatiquement quand il ne répond pas, sans besoin de tâche de nettoyage.
Le script Lua pour libérer le verrou ne supprime la clé que si la valeur correspond au rideId. Si vous utilisiez DEL directement, une course A dont le verrou a expiré pourrait accidentellement effacer le verrou qu’une course B vient d’acquérir.
6.2. Confirmer la course avec une mise à jour conditionnelle
Le verrou Redis est juste un mécanisme d’optimisation pour éviter d’envoyer des offres en doublon. La source de vérité reste la base de données :
@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;
}
}
La condition status = 'REQUESTED' transforme l’UPDATE en une opération compare-and-set. Quand deux chauffeurs cliquent sur accepter, la base de données sérialise les deux écritures sur la même ligne. La première retourne 1. La deuxième voit que le statut est déjà ACCEPTED et retourne 0. Cette approche ne nécessite pas de SELECT ... FOR UPDATE, ni de colonne version séparée.
Le service qui gère l’acceptation combine tous les éléments :
@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;
}
L’ordre ici est intentionnel : écrire d’abord la base de données, puis mettre à jour Redis. Si markBusy échoue, le chauffeur reste dans la liste des disponibles quelques secondes. Au pire, il reçoit une offre supplémentaire, et l’index unique partiel bloquera la deuxième assignment. Faire l’inverse est plus risqué : supprimer le chauffeur de Redis puis échouer à écrire la base de données le ferait « disparaître » du système.
7. Traitement asynchrone avec Kafka
Chercher un chauffeur, envoyer une offre et attendre 15 secondes est un long processus. On ne devrait pas garder la requête HTTP du client pendant tout ce temps. L’API POST /api/rides sauvegarde juste la course et retourne 202 Accepted. Le matching se fait dans un consumer séparé.
<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;
// constructeur omis pour la clarté
@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()); // notification push / WebSocket
return;
}
}
if (event.attempt() < 5) {
// personne n'est disponible : remettre en queue pour réessayer plus tard
kafka.send("ride-requested-retry", event.cityId(),
new RideRequested(event.rideId(), event.cityId(), event.lng(), event.lat(), event.attempt() + 1));
}
}
}
Le consumer parcourt les candidats du plus proche au plus loin. Il verrouille le premier chauffeur disponible et envoie une offre à cette personne. Si personne n’est libre, l’événement est repoussé vers la topic retry, avec une limite de nombre de tentatives.
La clé du message est cityId. Ainsi tous les trajets d’une même ville tombent dans le même groupe de partitions, et le cache local du consumer (s’il existe) a un meilleur taux de succès.
Un dernier problème : sauvegarder la course dans PostgreSQL puis envoyer à Kafka est en deux actions séparées. Si l’application meurt au milieu, la course reste coincée dans l’état REQUESTED pour toujours. La vraie solution est le transactional outbox. Vous écrivez l’événement dans une table outbox dans la même transaction que la table rides, puis laissez un processus séparé (ou Debezium) lire la table outbox et pousser vers Kafka.
Attention : Kafka garantit au-moins-une-fois, donc un consumer peut recevoir un événement deux fois. La logique de matching doit être idempotente. Avant d’envoyer une offre, vérifiez que la course est toujours en état
REQUESTED. La mise à jour conditionnelle de la section 6.2 gère déjà les doublons de manière sûre.
8. Supporter les pics de charge
Quand il pleut à 17 h, à la fois le volume de demandes et le nombre de chauffeurs qui activent l’app explosent. Il y a quatre approches principales.
- Sharding des données de position par région. Au lieu d’une seule clé
drivers:availablepour tout le pays, divisez par ville ou par carré de geohash, par exempledrivers:available:hcm. Chaque clé vit sur un shard du Redis Cluster, donc la charge d’écriture est distribuée. Quand un client se trouve près d’une frontière, vous interrogez aussi les carrés adjacents. - Réduire la fréquence d’envoi de position. Un chauffeur immobile ou transportant un client n’a pas besoin d’envoyer sa position toutes les 4 secondes. Seulement s’il se déplace de plus de 50 m ou après 15 secondes. Cette seule optimisation peut réduire 40–60 % du volume d’écriture.
- Backpressure au lieu de crash. Kafka joue le rôle de buffer. Quand le volume de demandes monte 5 fois, la latence du matching augmente de quelques secondes, mais aucune course n’est perdue. Vous montez en charge le nombre de consumers en fonction du décalage, avec une limite supérieure du nombre de partitions.
- Prix dynamique (surge pricing). C’est une solution au niveau du produit, pas technique. Un prix plus élevé réduit la demande et attire plus de chauffeurs en ligne, aidant à rééquilibrer le système.
De plus, vous devriez limiter la rate de l’API de demande de course par customerId. Quand l’app affiche « en train de chercher une voiture » trop longtemps, les clients tendent à cliquer à nouveau, créant d’eux-mêmes une deuxième vague de charge.
9. Tester la contention lors de l’acceptation
Le comportement critique à tester est : plusieurs chauffeurs acceptent la même course, et un seul gagne. Le test ci-dessous utilise H2 en mémoire via @JdbcTest. Le fichier src/test/resources/schema.sql contient la table rides de la section 3, sans l’index partiel car H2 ne le supporte pas.
@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");
}
}
Cinq virtual threads appellent tous assignDriver sur une même course. Quel que soit l’ordre d’exécution, une seule commande UPDATE correspond à la condition status = 'REQUESTED'.
Vous remarquerez que le test supprime l’@Transactional par défaut de @JdbcTest. Raison : les nouveaux threads ne s’exécutent pas dans la même transaction que la méthode de test, et l’INSERT reste visible pour eux car les données sont déjà committées selon la configuration d’auto-commit. Si vous obtenez winners = 0, ajoutez @Transactional(propagation = Propagation.NOT_SUPPORTED) sur la classe de test.
Pour la logique de GEO, préférez utiliser Testcontainers avec un vrai Redis au lieu de mocker. Le calcul de distance et l’ordre de tri ne peuvent pas être vérifiés par un mock.
10. Conclusion
Cet article a esquissé le cœur d’une application de taxi. L’idée clé est de séparer les données selon leur nature :
- Les positions de chauffeur s’écrivent beaucoup et acceptent les pertes, donc dans Redis GEO avec un heartbeat pour filtrer les chauffeurs déconnectés.
- Les courses ont besoin de cohérence, donc dans PostgreSQL. La mise à jour conditionnelle joue le rôle de compare-and-set, et l’index unique partiel est un filet de sécurité final.
- Le verrou Redis avec TTL empêche les offres en doublon et gère automatiquement les timeouts. Pourtant, c’est juste une optimisation, pas la source de vérité.
- Kafka découple le matching de la requête du client, absorbe les pics de charge et permet les retry. Le pattern outbox garantit qu’on ne perd pas d’événement.
Quand vous montez en charge, vous pouvez explorer : classer les candidats par ETA réelle au lieu de la distance directe, faire le matching par lot (batch matching) chaque peu de secondes pour optimiser globalement, et le partage de trajets (pooling). Tous peuvent être construits sur la base présentée ici.