Conception d'un système de livraison de nourriture : commandes et statuts

1. Aperçu

Une application de livraison de nourriture implique trois parties agissant sur une commande : le client qui commande, le restaurant qui confirme et le livreur qui accepte la course. Ces trois actions se déroulent en parallèle, sur un réseau mobile instable, et s’accumulent aux heures de pointe.

Cet article parcourt l’intégralité du processus de conception : les besoins, le modèle de données, les API, la machine à états, la manière d’affecter les commandes aux livreurs sans chevauchement, comment maintenir la cohérence entre la base de données et Kafka, et comment pousser les mises à jour de statut en temps réel aux clients. Après lecture, vous disposerez d’un cadre de conception concrète à appliquer ou à apporter à un entretien de design système.

2. Exigences

2.1. Exigences fonctionnelles

  • Le client consulte le menu, passe une commande, paie, suit le statut et la position du livreur.
  • Le restaurant reçoit la commande, la confirme ou la refuse, puis signale que le plat est prêt.
  • Le livreur se met en ligne, envoie sa position périodiquement, accepte ou refuse une course proposée, puis met à jour son statut pour « article pris » et « livré ».
  • Le client peut annuler la commande avant que le restaurant ne commence à préparer.

2.2. Échelle et contraintes

Nous supposons les chiffres suivants pour avoir une base de calcul :

Commandes:      1 000 000 commandes/jour, les heures de pointe (11h-13h) représentent ~30%
                → ~42 commandes/s en moyenne aux heures de pointe, pics jusqu'à ~200 commandes/s
Livreurs:       50 000 livreurs en ligne, envoient leur position toutes les 4 s
                → ~12 500 écritures de position/s
Suivi:          le client ouvre l'écran de tracking → des centaines de milliers de connexions temps réel

À partir de ces chiffres, il y a trois contraintes principales :

  • Les commandes doivent avoir une forte cohérence. Pas de perte de commande et jamais une seule commande assignée à deux livreurs.
  • La position du livreur accepte une cohérence faible. Perdre un point de localisation ne pose pas de problème, mais l’écriture doit être très rapide. Nous n’écrivons pas la position dans une base de données relationnelle.
  • La création d’une commande doit être idempotente. Le réseau mobile peut amener le client à renvoyer la requête.

3. Architecture globale

 Client / Restaurant / Shipper apps
          │  REST + SSE/WebSocket
          ▼
     API Gateway (limitation de débit, authentification)
          │
 ┌────────┼───────────────┬──────────────────┐
 ▼        ▼               ▼                  ▼
Order   Restaurant     Dispatch           Tracking
Service Service        Service            Service
 │ PostgreSQL           │ Redis GEO         │ Redis (positions, pub/sub)
 │ + outbox             │                   │
 └──────────► Kafka (order-events) ◄────────┘

Order Service est la source de vérité pour les commandes. Les autres services réagissent aux événements sur Kafka. La position du livreur réside uniquement dans Redis.

Les exemples ci-dessous utilisent les dépendances suivantes :

<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. Modèle de données

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;

Quelques points à noter :

  • UNIQUE (customer_id, idempotency_key) empêche les doublons quand le client renvoie une requête.
  • version sert à l’optimistic locking quand plusieurs parties mettent à jour une commande.
  • order_items conserve dish_name et price au moment de la commande. Même si le restaurant change les prix plus tard, cela n’affecte pas les anciennes factures.
  • La table outbox est utilisée pour la partie cohérence à la section 8.

5. Machine à états de la commande

Une commande ne doit suivre que des étapes valides. Vous devriez coder cette règle en un seul endroit plutôt que de disperser des if dans tous les services :

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 énumère les états suivants valides pour chaque état. Le client ne peut annuler qu’à PLACED ou CONFIRMED. Une fois que le restaurant a commencé à préparer (PREPARING), la commande ne peut plus revenir à CANCELLED. Les deux états finaux n’ont pas d’étape suivante.

Test pour vérifier cette règle :

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                         Client passe une commande (header Idempotency-Key)
GET   /api/orders/{id}                    Consulte les détails de la commande
POST  /api/orders/{id}/cancel             Client annule la commande
POST  /api/restaurant/orders/{id}/confirm Restaurant confirme
POST  /api/restaurant/orders/{id}/ready   Restaurant signale que c'est prêt
PUT   /api/shippers/me/location           Livreur envoie sa position {lat, lng}
POST  /api/shippers/me/offers/{orderId}/accept   Livreur accepte une course
POST  /api/orders/{id}/pickup | /deliver  Livreur met à jour son statut
GET   /api/orders/{id}/stream             SSE: statut + position du livreur

L’API est conçue autour d’actions (/confirm, /accept) plutôt qu’un style générique PATCH status=.... Cette approche facilite la vérification des permissions : seul le restaurant de la commande peut appeler /confirm, et seul le livreur assigné peut appeler /pickup.

7. Gestion des conditions de concurrence

7.1. Mise à jour du statut avec optimistic locking

Supposons que le client clique sur annuler juste au moment où le restaurant clique sur confirmer. Si tous deux lisent PLACED, tous deux pensent que c’est valide. Nous résolvons cela avec une instruction UPDATE conditionnelle :

@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 + " n'est pas valide");
        }
        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;
    }
}

La base de données garantit que seul l’une des deux instructions UPDATE satisfait la condition version. Le gagnant reçoit true. Le perdant reçoit false et retourne HTTP 409 avec le dernier statut, afin que l’application affiche « Le restaurant vient de confirmer la commande ».

7.2. Attribution des commandes aux livreurs

C’est le point le plus difficile. Nous avons besoin de trouver un livreur près du restaurant et de garantir que chaque commande n’est affectée qu’à un seul livreur.

Étape 1 : stocker la position à l’aide de Redis GEO. Chaque fois qu’un livreur envoie sa position, nous l’écrivons dans un sorted set GEO et rafraîchissons une clé heartbeat :

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

Point prend la longitude en premier, la latitude en second, une erreur très courante. Les membres dans un set GEO ne s’expirent pas automatiquement. Donc la clé shipper:alive:* avec un TTL de 30 secondes est utilisée pour filtrer les livreurs qui ont fermé l’app ou perdu la connexion. Un job périodique peut exécuter ZREM sur les membres morts de shipper:geo.

Avec 12 500 écritures par seconde, une instance Redis fonctionne toujours bien. Quand vous évoluez vers plusieurs villes, vous shardez par région : shipper:geo:hcm, shipper:geo:hn.

Étape 2 : inviter chaque livreur, une invitation à la fois. Le Dispatch Service envoie les invitations séquentiellement au livreur le plus proche. Chaque invitation a une durée de 20 secondes. Pour qu’un livreur ne reçoive pas deux invitations à la fois, nous utilisons un verrou avec 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);
}

Si false, le livreur regarde une autre invitation, nous passons à la suivante.

Étape 3 : verrouiller l’attribution dans la base de données. Le verrou Redis ne fait que coordonner. La décision finale doit être dans la base de données :

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

La condition shipper_id IS NULL garantit que même si deux livreurs cliquent sur « Accepter » (par exemple parce que l’ancienne invitation a expiré et a été renvoyée), un seul gagne.

Remarque : N’utilisez pas un verrou Redis comme source de vérité pour l’attribution des commandes. Redis peut basculer et perdre le verrou. Le constraint dans la base de données est alors le dernier rempart.

8. Cohérence entre la base de données et Kafka

Après la création d’une commande, Order Service doit publier un événement OrderPlaced pour que le restaurant reçoive une notification. Si vous écrivez dans la base de données puis appelez kafkaTemplate.send(), le service peut planter entre les deux. Le résultat est que la commande existe mais le restaurant n’en sait rien. C’est le problème de la double écriture.

La solution est la boîte de sortie transactionnelle : écrire l’événement dans la table outbox dans la même transaction que la commande.

@Transactional
public long placeOrder(PlaceOrderCommand cmd) {
    long orderId = orderRepository.insert(cmd); // Lancer DuplicateKeyException si la clé est en double
    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;
}

Les deux enregistrements se valident ensemble ou se restaurent ensemble. Si le client renvoie le même Idempotency-Key, le constraint UNIQUE lève DuplicateKeyException. Le contrôleur capture cette erreur, trouve l’ancienne commande et la retourne au lieu d’en créer une nouvelle.

Un relais lit la boîte de sortie et pousse vers 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 permet d’exécuter plusieurs instances de relais sans traiter les doublons. La clé du message est orderId, donc tous les événements d’une même commande vont dans la même partition et conservent l’ordre.

Le relais garantit at-least-once. Si le service plante après send mais avant commit, l’événement sera renvoyé. Par conséquent, le consommateur doit être idempotent, par exemple en stockant event_id déjà traité ou en utilisant UPDATE ... WHERE status = ... comme dans la section 7.1.

9. Statut temps réel pour le client

Le client doit voir deux types de mises à jour : le statut de la commande (peu fréquent, important) et la position du livreur (fréquent, peut manquer). Server-Sent Events est un bon choix car le flux de données ne va que dans un sens, du serveur au client :

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

Chaque commande a une liste d’émetteurs (le client peut ouvrir sur plusieurs appareils). push envoie un événement nommé, par exemple status ou location. Du côté client, il reçoit :

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

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

Lors de l’exécution de plusieurs instances, la connexion SSE du client n’est que sur une seule instance. Il y a deux façons pour les événements d’arriver au bon endroit :

  • Statut de la commande : chaque instance Tracking consomme order-events avec un group.id distinct, donc chaque instance reçoit tous les événements et ne pousse que vers l’émetteur qu’elle détient.
  • Position du livreur : quand la commande est en PICKED_UP, Tracking publie la position sur le canal Redis track:order:{id}. L’instance qui détient la connexion s’abonne à ce canal. Nous ne poussons pas 12 500 messages/s via Kafka quand seule une petite partie est regardée.

Pour réduire la charge, vous pouvez grouper les positions : le client n’a besoin que d’un point toutes les 4-5 s et peut interpoler le mouvement sur la carte.

10. Pics de charge aux heures de pointe

À midi ou lors de promotions, le trafic peut augmenter 5 à 10 fois. Quelques techniques aident le système à rester robuste :

  • Cacher le menu. Le menu est beaucoup lu, peu écrit. Vous mettez en cache dans Redis par restaurant_id et invalidez le cache quand le restaurant modifie un plat. La plupart des requêtes « consulter le restaurant » ne toucheront pas la base de données.
  • Limite de débit par utilisateur et par restaurant au niveau de la passerelle. Vous pouvez également arrêter temporairement l’acceptation des commandes chez les restaurants surchargés (plus de N commandes en PLACED) au lieu de faire attendre les clients 40 minutes.
  • Séparer le chemin d’écriture du chemin de traitement. L’API de commande écrit juste orders et outbox puis revient immédiatement. Les notifications, le calcul du frais de livraison, les suggestions de promotion s’exécutent de manière asynchrone via Kafka.
  • Ordonnancement par lot. Quand la demande dépasse l’offre, l’appairage des commandes et des livreurs toutes les 5-10 s (batch matching) donne de meilleurs résultats que d’inviter chacun immédiatement. Le système peut aussi regrouper deux commandes du même restaurant pour un livreur.
  • Dégradation gracieuse. Quand Redis Tracking est lent, privilégiez le flux de commande et arrêtez temporairement la transmission de position. Les clients acceptent que la carte se fige quelques secondes plutôt que de ne pas pouvoir passer de commande.

11. Erreurs courantes

  • Stocker la position du livreur dans PostgreSQL. 12 500 écritures par seconde, chacune mettant à jour une ligne, causera du bloat et une contention de verrous. Vous ne devriez enregistrer l’historique des trajets par lot (par exemple, pousser via Kafka vers un data lake) que si nécessaire pour la réconciliation.
  • Vérifier le statut avec SELECT puis UPDATE. Il y a toujours une brèche entre les deux ordres pour une condition de course. Vous devriez mettre la condition dans la clause WHERE.
  • Envoyer à Kafka dans @Transactional sans outbox. La transaction est restaurée mais le message a volé, ou l’inverse.
  • Utiliser un SseEmitter sans timeout. Les connexions en suspens s’accumulent et épuisent les ressources. Vous devriez définir un timeout et laisser le client se reconnecter via GET /api/orders/{id} pour obtenir le dernier statut.

Remarque : L’architecture multi-services avec Kafka, Redis GEO et outbox n’en vaut la peine qu’à grande échelle. Avec quelques centaines de commandes par jour, une application Spring Boot monolithique, une PostgreSQL et un polling toutes les 5 s suffisent et sont beaucoup plus faciles à exploiter.

12. Conclusion

Dans cet article, vous avez conçu un système de livraison de nourriture de bout en bout. Il y a quatre points clés :

  • Modélisez les commandes avec une machine à états claire.
  • Résolvez les conditions de concurrence avec UPDATE conditionnel et optimistic locking.
  • Affectez les commandes avec Redis GEO combiné à des constraints de base de données.
  • Maintenez la cohérence avec Kafka grâce à la boîte de sortie transactionnelle.

Le principe qui traverse tout est de classifier les données selon les besoins de cohérence. Les commandes ont besoin d’une forte cohérence, elles doivent rester dans une base de données relationnelle. La position du livreur accepte les pertes et doit passer par Redis, en donnant la priorité à la vitesse. Une fois que vous avez séparé ces deux types, la plupart des points difficiles du problème deviennent plus faciles à gérer.

system-designspring-bootrediskafkacase-study