Conception d'un système de suivi de commandes en temps réel

1. Vue d’ensemble

Quand vous commandez à manger ou demandez une livraison, vous voulez savoir à quel stade se trouve votre commande et où se trouve le livreur sur la carte. Cet article conçoit un tel système, des exigences au code. On passe en revue le modèle de données, les API, les flux de traitement, puis les trois défis : les conditions de concurrence lors de l’acceptation d’une commande, la cohérence des données et les pics de charge.

Après la lecture, vous aurez une architecture de référence et des extraits de code Spring Boot 3.x pour commencer votre implémentation.

2. Exigences

2.1. Exigences fonctionnelles

  • Le livreur envoie sa position GPS à intervalle régulier depuis l’application.
  • Le client voit l’état de la commande et la position du livreur en quasi-temps réel, avec une latence inférieure à 3 secondes.
  • Le livreur accepte la commande. Chaque commande n’est assignée qu’à un seul livreur.
  • L’état de la commande suit un cycle de vie : PENDING → ASSIGNED → PICKED_UP → DELIVERING → DELIVERED. À partir de certains états, elle peut passer à CANCELLED.
  • Conserver l’historique des états pour le rapprochement et le traitement des réclamations.

2.2. Estimation de l’échelle

Métrique Valeur
Livreurs en ligne à l’heure de pointe 50 000
Intervalle d’envoi de position 4 secondes
Taux d’écriture de position ~12 500 msg/s
Clients ayant l’écran de suivi ouvert ~200 000 connexions
Nouvelles commandes à l’heure de pointe ~300 commandes/s

2.3. Contraintes

  • La position est une donnée temporaire. Perdre quelques points est acceptable, mais on ne doit pas afficher une ancienne position sur une plus récente.
  • L’état de la commande est une donnée critique. On ne doit pas la perdre ni avoir d’inversion d’ordre.
  • Le réseau mobile est instable, donc l’application renverra les données et les enverra par lots.

D’où une décision clé : séparer les deux flux. Le flux de position a besoin d’un débit élevé et peut accepter des pertes. Le flux d’état exige des transactions et de la durabilité.

3. Architecture globale

App livreur ──HTTP batch──> API Localisation ──> Kafka [shipper-locations]
                                                   │
                                                   ▼
                                        Consommateur Localisation ──> Redis (dernière position, GEO)
                                                   │
                                                   └──> Redis Pub/Sub [order:{id}:loc]
                                                               │
App client <──────── SSE ──────── Passerelle de suivi <────────────┘

App livreur ──HTTP──> Service Commandes ──> PostgreSQL (orders, order_events, outbox)
                                              │
                                              └──> Relai Outbox ──> Kafka [order-events]

Chaque composant remplit un rôle :

  • Kafka absorbe la charge d’écriture des positions et maintient l’ordre par livreur, en utilisant la clé shipperId.
  • Redis conserve uniquement la dernière position de chaque livreur. Le système n’écrit pas chaque point GPS dans une base de données relationnelle.
  • PostgreSQL est la source d’autorité pour l’état de la commande.
  • SSE pousse les données unidirectionnellement du serveur au client. SSE est plus simple que WebSocket et suffisant pour ce problème.

4. Modèle de données

4.1. PostgreSQL pour les commandes

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;

La colonne version sert au verrouillage optimiste. La table order_events est un historique d’ajouts seulement, jamais de modifications. La table outbox garantit que les événements sont publiés vers Kafka sans être perdus. La section 7 expliquera cela en détail.

4.2. Redis pour les positions

shipper:loc:{shipperId}         HASH  {lat, lng, ts}       TTL 120s
shippers:geo:{city}             GEO   member = shipperId
shipper:active-order:{shipperId} STRING orderId

Un TTL de 120 secondes aide un livreur déconnecté à disparaître automatiquement du système. Vous n’avez pas besoin d’une tâche de nettoyage séparée. La clé GEO est séparée par ville, donc la commande GEOSEARCH pour trouver le livreur le plus proche n’a pas besoin de scanner le pays entier.

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}                    -> état + position actuelle
GET  /api/v1/orders/{orderId}/track              -> text/event-stream

L’API de position retourne 202 Accepted car elle pousse simplement les données dans Kafka et répond immédiatement. Cette API accepte les données par lots, de sorte que l’application peut regrouper les points qui étaient coincés lors d’une perte de réseau et les envoyer en une seule fois.

6. Flux de traitement de la position

Vous avez besoin des dépendances suivantes :

<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>

Configuration du sérialiseur JSON pour 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"

La valeur acks: 1 est un choix délibéré. Perdre quelques points GPS quand le leader du broker meurt est acceptable, en échange d’une latence d’écriture plus faible qu’avec acks: all.

6.1. Recevoir la position

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();
    }
}

Deux points à noter :

  • La clé est shipperId. Tous les points d’un même livreur tombent dans la même partition, donc le consommateur les traite dans le bon ordre d’envoi.
  • Le filtre filter rejette les points avec un shipperId différent du chemin. En pratique, vous devriez extraire le shipperId du JWT plutôt que de faire confiance au body.

6.2. Écrire la plus récente position, ignorer les anciennes

L’ordre dans la partition ne suffit pas. L’application peut renvoyer un ancien lot après le rétablissement du réseau, et ce lot peut arriver après un point plus récent. Le consommateur doit donc comparer les ts et n’écrire que si le point est plus récent. La comparaison et l’écriture doivent être atomiques, donc nous utilisons un script Lua :

@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; // point ancien, ignorer

        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()));
        }
    }
}

Le flux de traitement est le suivant :

  • Le script retourne 0 si le point est plus ancien ou égal au point en stockage. Le consommateur s’arrête alors, sans mettre à jour le GEO et sans pousser au client.
  • La commande GEOADD prend les paramètres dans l’ordre longitude d’abord, latitude ensuite. C’est une erreur très courante.
  • Le système ne publie que si le livreur a une commande. Un livreur sans commande ne génère pas de trafic fan-out.

Remarque : Le script ci-dessus ne touche qu’une seule clé, il peut donc s’exécuter sur Redis Cluster. Si vous souhaitez inclure GEOADD dans le script, les clés doivent être dans le même slot. Utilisez alors un hash tag comme {hcm}:loc:42 et {hcm}:geo.

6.3. Pousser au client avec 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);
            }
        });
    }
}

Vous enregistrez un écouteur avec le 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;
}

Le client reçoit les événements comme suit :

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

La passerelle est sans état, vous pouvez donc faire évoluer horizontalement autant d’instances que vous le souhaitez. L’instance qui maintient la connexion SSE du client recevra les messages via Pub/Sub et les poussera. Avec 200 000 connexions, vous devriez activer les virtual threads (spring.threads.virtual.enabled=true) ou basculer vers WebFlux, de sorte que chaque connexion ne retient pas un platform thread.

7. Les défis et solutions

7.1. Conditions de concurrence : deux livreurs acceptent la même commande

Une nouvelle commande est affichée à plusieurs livreurs à proximité. Deux personnes peuvent cliquer sur “Accepter” en même temps. La mauvaise façon est de lire l’état, de vérifier PENDING, puis d’écrire, car il y a une brèche entre la lecture et l’écriture. La bonne façon est d’utiliser une UPDATE conditionnelle :

@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; // quelqu'un d'autre a déjà accepté

        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 verrouille la ligne lors de l’exécution du UPDATE. Le deuxième livreur doit attendre, puis réévalue la condition status = 'PENDING', constate qu’elle ne correspond plus et obtient rows == 0. Le contrôleur retourne 409 Conflict, et l’application affiche “La commande a déjà été acceptée”. Vous n’avez pas besoin de verrou distribué.

7.2. Cohérence : machine d’états et outbox

Chaque transition d’état doit être valide. Vous codez les règles de transition directement dans l’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;
        };
    }
}

Test pour le comportement principal :

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);
    }
}

Lors de la transition d’état, vous utilisez la condition WHERE id = ? AND version = ?. Si aucune ligne n’est mise à jour, cela signifie que quelqu’un d’autre a modifié la commande auparavant. Vous retournez alors 409 et laissez le client recharger.

Le pattern Outbox résout le problème “l’écriture en DB réussit mais l’envoi à Kafka échoue”. L’événement est écrit dans la table outbox dans la même transaction que celle de orders. Un relai périodique lit les lignes non publiées et les pousse vers 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"));
    }
}

La clause FOR UPDATE SKIP LOCKED permet d’exécuter plusieurs relais en parallèle sans traiter deux fois la même ligne. Le relai garantit at-least-once : s’il meurt après l’envoi mais avant de marquer published, l’événement sera renvoyé. Par conséquent, le consommateur de order-events doit être idempotent. Par exemple, il ignore les événements avec une version inférieure ou égale à celle déjà traitée.

Le consommateur de order-events est aussi l’endroit où on définit ou supprime la clé shipper:active-order:{shipperId}. Grâce à cela, le flux de position sait pour quelle commande pousser.

7.3. Pics de charge

L’heure du déjeuner, une grosse pluie ou un jour de promotion peuvent faire tripler ou quintupler la charge en quelques minutes. Voici les façons de gérer cela :

  • Kafka comme tampon. Si le consommateur est lent, le lag augmente, mais l’API répond toujours rapidement. Vous augmentez le nombre de partitions (par exemple 48) à l’avance, car augmenter les partitions en cours d’exécution mélangera l’ordre par clé.
  • Ignorer les anciens points. Avec un grand lag, un point GPS de 30 secondes n’a plus d’intérêt pour l’affichage. Le consommateur peut l’ignorer immédiatement : if (System.currentTimeMillis() - u.ts() > 30_000) return;.
  • Intervalle d’envoi adaptatif. Le serveur retourne un en-tête de suggestion, par exemple X-Next-Interval: 8, pour que l’application augmente l’intervalle d’envoi quand le système est surchargé. Un livreur à l’arrêt envoie moins souvent.
  • Regrouper côté client. L’application regroupe 2-3 points dans une seule requête. Le nombre de requêtes HTTP baisse fortement, tandis que le nombre de messages Kafka reste le même.
  • Limitation de débit par livreur. Vous limitez chaque livreur à maximum 1 requête/seconde pour bloquer les applications bugguées ou les clients contrefaits qui spamment.
  • Isolation des ressources. Le flux de position s’exécute sur un cluster Redis et un ensemble d’instances distincts. Si ce flux s’arrête, l’acceptation de commande et la mise à jour d’état continuent normalement.

8. Erreurs courantes et quand ne pas le faire

  • Écrire chaque point GPS dans PostgreSQL. 12 500 insertions/s plus les index vont rapidement étouffer la base de données. Si vous avez besoin de conserver l’itinéraire pour le rapprochement, faites en sorte qu’un autre consommateur écrive par lots dans un entrepôt approprié comme TimescaleDB ou un stockage d’objets, avec une fréquence inférieure.
  • Utiliser un verrou distribué (Redisson) pour accepter une commande. Cette approche est plus complexe et plus sujette aux erreurs qu’une UPDATE conditionnelle sur la source d’autorité elle-même.
  • Faire confiance à l’ordre d’arrivée des requêtes. Le réseau mobile ne garantit pas l’ordre. Vous devez toujours comparer les ts, et pour l’état, comparer les version.
  • Redis Pub/Sub pour les données critiques. Pub/Sub fonctionne en fire-and-forget. Un souscripteur qui perd la connexion perd le message. C’est acceptable pour la position, mais pas pour les événements d’état. Les événements d’état doivent passer par Kafka. Quand le client se reconnecte à SSE, il appelle GET /orders/{id} pour obtenir un snapshot à jour.

Remarque : Si votre système n’a que quelques centaines de livreurs, cette architecture est excessive. Un seul service écrivant les positions dans Redis, associé à un polling côté client toutes les 5 secondes, suffit. Ajoutez Kafka et la passerelle SSE quand les chiffres réels vous y forcent.

9. Conclusion

Cet article a conçu un système de suivi de commandes et de position de livreur selon un principe clé : séparer les données temporaires des données critiques.

  • La position passe par Kafka puis dans Redis. Le système accepte les pertes, mais empêche un ancien point de surcharger un plus récent via un script Lua qui compare les timestamps.
  • L’état de la commande se trouve dans PostgreSQL. Les conditions de concurrence sont résolues par une UPDATE conditionnelle, la cohérence est maintenue par une machine d’états et le pattern Outbox.
  • Les pics de charge sont absorbés par Kafka, combinés à la suppression des anciens points, les intervalles d’envoi adaptatifs et l’isolation des ressources.

À partir de ce cadre, vous pouvez ajouter des fonctionnalités comme le calcul d’ETA, l’appairage de commandes par position avec GEOSEARCH, ou la conservation de l’itinéraire pour le rapprochement.

system-designspring-bootkafkaredisrealtime