Conception du panier et du flux de commande e-commerce

1. Vue d’ensemble

Le panier et le flux de commande sont les deux fonctionnalités les plus utilisées sur une plateforme e-commerce. Ce sont aussi les plus susceptibles de causer des problèmes : surstock, facturation double, commandes bloquées lors de flash sales.

Cet article couvre une conception complète sous forme d’étude de cas. La conception comprend les exigences, le modèle de données, l’API, le flux de traitement et les défis techniques. Chaque défi est assorti d’une solution utilisant Spring Boot 3, PostgreSQL, Redis et Kafka. À la fin, vous disposerez d’une architecture à appliquer ou à emporter lors d’un entretien de conception de systèmes.

2. Les exigences

2.1. Exigences fonctionnelles

  • Les clients peuvent ajouter, modifier, supprimer des produits (SKU) du panier. Le panier persiste entre plusieurs sessions de connexion.
  • Lors du paiement, le système recalcule les prix et vérifie le stock, puis crée une commande à l’état PENDING_PAYMENT.
  • Les commandes non payées après 15 minutes seront annulées et le stock sera remis à jour.
  • Après un paiement réussi, le système émet des événements pour l’entrepôt, la notification par email et les points de récompense.

2.2. Hypothèses d’échelle

  • 10 millions d’utilisateurs actifs par mois.
  • Opérations sur le panier : en moyenne 2 000 req/s, pic 20 000 req/s.
  • Passages en caisse : en moyenne 200 commandes/s. Lors d’une flash sale, jusqu’à 5 000 commandes/s, concentrées sur quelques dizaines de SKU.

2.3. Contraintes

  • Pas de surstock. C’est une contrainte dure.
  • Pas de doublons de commandes quand le client renvoie la requête suite à un timeout ou double-clique.
  • Une fois la commande créée, l’événement doit être émis, même si Kafka est temporairement indisponible.
  • Le panier est autorisé à être “à peu près” cohérent. Les commandes et le stock doivent être strictement cohérents.

Cette dernière contrainte détermine l’architecture. Le panier a beaucoup de lectures/écritures et est peu critique, donc il est stocké sur Redis. Les commandes et le stock ont besoin de transactions, donc ils sont stockés sur PostgreSQL.

3. Modèle de données

Le panier sur Redis utilise un Hash par client :

key:   panier:{customerId}
field: sku
value: quantité
TTL:   30 jours

Le panier stocke uniquement le SKU et la quantité, pas le prix. Le prix peut changer à tout moment, donc le système recalcule toujours le prix au moment du paiement.

Les données qui nécessitent une transaction sont sur PostgreSQL :

CREATE TABLE inventory (
    sku        VARCHAR(64) PRIMARY KEY,
    available  INT NOT NULL CHECK (available >= 0),
    reserved   INT NOT NULL DEFAULT 0
);

CREATE TABLE orders (
    id               BIGSERIAL PRIMARY KEY,
    customer_id      BIGINT       NOT NULL,
    idempotency_key  VARCHAR(64)  NOT NULL,
    status           VARCHAR(32)  NOT NULL,
    total_amount     BIGINT       NOT NULL,
    expires_at       TIMESTAMPTZ  NOT NULL,
    created_at       TIMESTAMPTZ  NOT NULL DEFAULT now(),
    UNIQUE (customer_id, idempotency_key)
);

CREATE TABLE order_items (
    order_id    BIGINT REFERENCES orders(id),
    sku         VARCHAR(64) NOT NULL,
    quantity    INT         NOT NULL,
    unit_price  BIGINT      NOT NULL,
    PRIMARY KEY (order_id, sku)
);

CREATE TABLE outbox_events (
    id           BIGSERIAL PRIMARY KEY,
    aggregate_id BIGINT      NOT NULL,
    event_type   VARCHAR(64) NOT NULL,
    payload      JSONB       NOT NULL,
    published    BOOLEAN     NOT NULL DEFAULT false
);

Quelques points à noter dans ce schéma :

  • CHECK (available >= 0) est la dernière ligne de défense. Si le code a un bug, la base de données empêche toujours le stock négatif.
  • UNIQUE (customer_id, idempotency_key) empêche les doublons de commandes au niveau de la base de données.
  • order_items.unit_price est une capture du prix au moment de la commande. Les anciennes commandes ne sont pas affectées par les changements de prix des produits.
  • Les montants sont stockés en BIGINT dans la plus petite unité (centimes). N’utilisez pas DOUBLE.

4. Conception de l’API

GET    /api/cart                       -> consulter le panier (avec les prix actuels)
PUT    /api/cart/items/{sku}           -> corps {"quantity": 2}, définir la quantité
DELETE /api/cart/items/{sku}           -> supprimer SKU du panier
POST   /api/orders                     -> en-tête Idempotency-Key, créer commande à partir du panier
GET    /api/orders/{id}                -> consulter l'état de la commande

PUT avec une quantité absolue est idempotent. L’appeler plusieurs fois produit le même résultat. Si c’était conçu comme POST /add avec +1, chaque tentative du client ajouterait un produit de plus.

POST /api/orders n’est pas idempotent par nature, donc le client doit générer une Idempotency-Key (UUID) pour chaque fois que l’utilisateur clique sur “Commander”. Lors d’une tentative, le client renvoie la même clé.

5. Panier sur Redis

Ajouter la dépendance :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Service du panier :

@Service
public class CartService {

    private static final Duration CART_TTL = Duration.ofDays(30);
    private static final int MAX_ITEMS = 100;

    private final StringRedisTemplate redis;

    public CartService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public void setQuantity(long customerId, String sku, int quantity) {
        String key = "cart:" + customerId;
        HashOperations<String, String, String> ops = redis.opsForHash();
        if (quantity <= 0) {
            ops.delete(key, sku);
            return;
        }
        if (!ops.hasKey(key, sku) && ops.size(key) >= MAX_ITEMS) {
            throw new IllegalStateException("Le panier contient déjà " + MAX_ITEMS + " produits");
        }
        ops.put(key, sku, String.valueOf(quantity));
        redis.expire(key, CART_TTL);
    }

    public Map<String, Integer> getItems(long customerId) {
        Map<String, String> raw = redis.<String, String>opsForHash().entries("cart:" + customerId);
        return raw.entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, e -> Integer.parseInt(e.getValue())));
    }
}

Explications :

  • ops.put(key, sku, ...) écrit un champ dans le Hash avec une complexité O(1). Deux clients modifiant deux SKU différents ne s’écrasent pas l’un l’autre. Si vous stockiez le panier entier en tant que chaîne JSON, ce serait écrasé.
  • expire est renouvelé à chaque écriture. Le panier expire après 30 jours d’inactivité.
  • MAX_ITEMS limite la taille du panier pour éviter les clés trop grandes dues aux bots.

Les commandes hasKey puis size ne sont pas atomiques. Deux requêtes parallèles pourraient dépasser la limite de 1–2 articles. Pour un panier, une petite divergence est acceptable. Vous ne devriez pas appliquer cette approximation au stock.

Remarque : Redis n’est pas une source de données absolument durable. Activez AOF (appendfsync everysec) pour limiter la perte à environ une seconde. Perdre une seconde de données de panier est acceptable, mais perdre une commande ne l’est pas.

6. Flux de commande

Le flux POST /api/orders traverse les étapes suivantes :

1. Lire le panier depuis Redis
2. Récupérer le prix actuel du Catalogue
3. BEGIN transaction (PostgreSQL)
   3a. Insérer orders avec idempotency_key  -> si doublon, retourner ancienne commande
   3b. Réduire le stock pour chaque SKU (mise à jour conditionnelle) -> si rupture, ROLLBACK
   3c. Insérer order_items (snapshot du prix)
   3d. Insérer outbox_events "OrderCreated"
4. COMMIT
5. Supprimer le panier (best-effort)
6. Relais lit outbox -> publie vers Kafka

6.1. Prévention du surstock par mise à jour conditionnelle

L’erreur courante est de lire le stock, comparer en Java, puis écrire. Entre la lecture et l’écriture, un autre thread peut avoir acheté l’article. La bonne approche est de combiner la vérification et la soustraction en une seule instruction SQL :

@Repository
public class InventoryRepository {

    private final JdbcClient jdbc;

    public InventoryRepository(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    public boolean reserve(String sku, int quantity) {
        int updated = jdbc.sql("""
                UPDATE inventory
                   SET available = available - :qty,
                       reserved  = reserved  + :qty
                 WHERE sku = :sku AND available >= :qty
                """)
                .param("qty", quantity)
                .param("sku", sku)
                .update();
        return updated == 1;
    }
}

La base de données verrouille la ligne pendant l’UPDATE. La deuxième requête doit attendre la première, puis réévalue la condition available >= :qty avec la nouvelle valeur. S’il n’y a pas assez de stock, updated == 0 et la méthode retourne false. Vous n’avez pas besoin de SELECT ... FOR UPDATE ou de colonne de version.

6.2. Idempotence et transaction

@Service
public class OrderService {

    private final JdbcClient jdbc;
    private final InventoryRepository inventory;

    public OrderService(JdbcClient jdbc, InventoryRepository inventory) {
        this.jdbc = jdbc;
        this.inventory = inventory;
    }

    @Transactional
    public long placeOrder(long customerId, String idempotencyKey, List<OrderLine> lines) {
        Optional<Long> existing = jdbc.sql("""
                INSERT INTO orders (customer_id, idempotency_key, status, total_amount, expires_at)
                VALUES (:c, :k, 'PENDING_PAYMENT', :total, now() + interval '15 minutes')
                ON CONFLICT (customer_id, idempotency_key) DO NOTHING
                RETURNING id
                """)
                .param("c", customerId).param("k", idempotencyKey)
                .param("total", lines.stream().mapToLong(OrderLine::subtotal).sum())
                .query(Long.class).optional();

        if (existing.isEmpty()) {
            return jdbc.sql("SELECT id FROM orders WHERE customer_id = :c AND idempotency_key = :k")
                    .param("c", customerId).param("k", idempotencyKey)
                    .query(Long.class).single();
        }
        long orderId = existing.get();

        for (OrderLine line : lines.stream().sorted(Comparator.comparing(OrderLine::sku)).toList()) {
            if (!inventory.reserve(line.sku(), line.quantity())) {
                throw new OutOfStockException(line.sku());
            }
            jdbc.sql("INSERT INTO order_items VALUES (:o, :s, :q, :p)")
                    .param("o", orderId).param("s", line.sku())
                    .param("q", line.quantity()).param("p", line.unitPrice())
                    .update();
        }

        jdbc.sql("""
                INSERT INTO outbox_events (aggregate_id, event_type, payload)
                VALUES (:id, 'OrderCreated', CAST(:payload AS jsonb))
                """)
                .param("id", orderId)
                .param("payload", "{\"orderId\":" + orderId + ",\"customerId\":" + customerId + "}")
                .update();
        return orderId;
    }
}

public record OrderLine(String sku, int quantity, long unitPrice) {
    long subtotal() { return quantity * unitPrice; }
}

public class OutOfStockException extends RuntimeException {
    public OutOfStockException(String sku) { super("Rupture de stock : " + sku); }
}

Les points clés :

  • ON CONFLICT ... DO NOTHING RETURNING id ne retourne l’id que si l’insertion réussit. Si la clé existe déjà, le résultat est vide. Ensuite le service relit l’ancienne commande et la retourne, donc le retry du client reçoit toujours exactement une commande.
  • OutOfStockException est une RuntimeException, donc @Transactional annule le tout : la commande, tous les SKU déjà déduits et l’outbox. Il n’y a pas d’état partiel.
  • La liste des SKU est triée avant de déduire du stock. Deux commandes contenant à la fois SKU A et B verrouillent toujours A d’abord, puis B, ainsi évitent les deadlock.
  • Si la première requête est annulée, la clé l’est aussi. Un client réessayant avec la même clé réexécute depuis le début, c’est exactement le comportement souhaité.

6.3. Outbox transactionnel

Si vous appelez kafkaTemplate.send() dans une transaction, vous rencontrez l’un des deux problèmes. Kafka envoie avec succès mais la BD annule, créant ainsi un événement fantôme. La BD valide mais Kafka échoue, perdant l’événement. L’Outbox résout ce problème en écrivant l’événement dans la même transaction que la commande, puis un relais pousse l’événement vers Kafka.

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
@Component
public class OutboxRelay {

    private final JdbcClient jdbc;
    private final KafkaTemplate<String, String> kafka;

    public OutboxRelay(JdbcClient jdbc, KafkaTemplate<String, String> kafka) {
        this.jdbc = jdbc;
        this.kafka = kafka;
    }

    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publish() throws Exception {
        List<OutboxEvent> events = jdbc.sql("""
                SELECT id, aggregate_id, event_type, payload::text AS payload
                  FROM outbox_events WHERE published = false
                 ORDER BY id LIMIT 100
                 FOR UPDATE SKIP LOCKED
                """).query(OutboxEvent.class).list();

        for (OutboxEvent e : events) {
            kafka.send("orders", String.valueOf(e.aggregateId()), e.payload()).get();
            jdbc.sql("UPDATE outbox_events SET published = true WHERE id = :id")
                    .param("id", e.id()).update();
        }
    }
}

record OutboxEvent(long id, long aggregateId, String eventType, String payload) {}

@Scheduled ne s’exécute que si l’application a @EnableScheduling activé.

  • FOR UPDATE SKIP LOCKED permet à plusieurs instances de relais de s’exécuter en parallèle sans traiter deux fois la même ligne.
  • La clé du message est aggregateId. Tous les événements d’une même commande tombent dans la même partition et conservent l’ordre correct.
  • .get() attend la confirmation de Kafka avant de marquer comme publié. Si le relais s’arrête à mi-parcours, l’événement peut être renvoyé à nouveau. L’Outbox garantit au-moins-une-fois, donc le consumer doit être idempotent, par exemple, en enregistrant les orderId déjà traités.

6.4. Expiration du paiement

Un job périodique annule les commandes expirées et remet le stock à jour :

WITH expired AS (
    UPDATE orders SET status = 'CANCELLED'
     WHERE status = 'PENDING_PAYMENT' AND expires_at < now()
    RETURNING id
)
UPDATE inventory i
   SET available = i.available + oi.quantity,
       reserved  = i.reserved  - oi.quantity
  FROM order_items oi
 WHERE oi.order_id IN (SELECT id FROM expired) AND oi.sku = i.sku;

La condition status = 'PENDING_PAYMENT' résout la contention avec le callback de paiement. Le callback doit aussi changer l’état avec UPDATE ... WHERE status = 'PENDING_PAYMENT'. Celui qui met à jour en premier gagne, l’autre met à jour 0 lignes. Si le paiement arrive après l’annulation de la commande, le système rembourse, au lieu de ressusciter la commande.

L’instruction ci-dessus suppose que chaque SKU n’apparaît qu’une fois par commande, ce que la clé primaire (order_id, sku) garantit. Si un lot annule plusieurs commandes contenant le même SKU, PostgreSQL n’applique qu’une seule ligne de jointure pour chaque ligne d’inventory. Ensuite vous devez regrouper d’abord avec GROUP BY sku et SUM(quantity).

7. Pics de charge : flash sale

Quand 5 000 requêtes/s se battent pour un SKU, toutes font la queue pour le verrou d’une seule ligne dans inventory. La base de données est engorgée, la latence monte en flèche et affecte les autres SKU. La solution est de bloquer quelques flux avant de toucher la DB, avec un compteur de stock sur Redis exécutant un script Lua :

@Component
public class FlashSaleGate {

    private static final RedisScript<Long> TRY_ACQUIRE = RedisScript.of("""
            local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
            local qty = tonumber(ARGV[1])
            if stock >= qty then
                redis.call('DECRBY', KEYS[1], qty)
                return 1
            end
            return 0
            """, Long.class);

    private final StringRedisTemplate redis;

    public FlashSaleGate(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public boolean tryAcquire(String sku, int quantity) {
        Long ok = redis.execute(TRY_ACQUIRE, List.of("flash:stock:" + sku), String.valueOf(quantity));
        return ok != null && ok == 1L;
    }

    public void release(String sku, int quantity) {
        redis.opsForValue().increment("flash:stock:" + sku, quantity);
    }
}

Redis exécute le script Lua de façon atomique. Aucune commande n’intervient entre GET et DECRBY. Avec 1 000 places et 50 000 requêtes, seules environ 1 000 requêtes passent la porte pour entrer dans OrderService. Les 49 000 requêtes restantes reçoivent immédiatement HTTP 409 “Épuisé” sans utiliser une seule requête DB.

Le flux se réassemble comme suit :

tryAcquire (Redis)  --false-->  409 Épuisé
       |true
placeOrder (PostgreSQL, mise à jour conditionnelle inchangée)
       |exception
release (Redis)

Redis n’est qu’un filtre. La source de vérité reste la mise à jour conditionnelle dans PostgreSQL. Si Redis diverge, par exemple, en raison d’un redémarrage ou parce que release ne s’exécute pas, le pire résultat est de vendre quelques places en moins, pas de surstock.

Quelques mesures supplémentaires :

  • Limiter la fréquence par client pour bloquer les bots.
  • File d’attente : pousser les requêtes gagnantes dans Kafka, le consommateur crée des commandes au rythme que la DB peut supporter, le client fait un poll sur GET /api/orders/{id}. Cela échange la latence contre la stabilité.
  • Séparer les SKU populaires dans une table ou instance distincte pour ne pas affecter les commandes normales.

8. Test de contention sur le stock

Le test le plus important est de prouver que le système ne vend pas plus que le stock quand plusieurs threads achètent simultanément.

@JdbcTest
@Import(InventoryRepository.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class InventoryRepositoryTest {

    @Autowired JdbcClient jdbc;
    @Autowired InventoryRepository inventory;

    @BeforeEach
    void setUp() {
        jdbc.sql("""
                CREATE TABLE IF NOT EXISTS inventory (
                  sku VARCHAR(64) PRIMARY KEY,
                  available INT NOT NULL CHECK (available >= 0),
                  reserved INT NOT NULL DEFAULT 0)
                """).update();
        jdbc.sql("DELETE FROM inventory").update();
        jdbc.sql("INSERT INTO inventory (sku, available) VALUES ('IPHONE-16', 5)").update();
    }

    @Test
    void givenStockOf5_when20ConcurrentBuyers_thenExactly5Succeed() throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Boolean>> results = IntStream.range(0, 20)
                    .mapToObj(i -> executor.submit(() -> inventory.reserve("IPHONE-16", 1)))
                    .toList();

            long success = 0;
            for (Future<Boolean> f : results) {
                if (f.get()) success++;
            }
            assertThat(success).isEqualTo(5);
        }

        Integer left = jdbc.sql("SELECT available FROM inventory WHERE sku = 'IPHONE-16'")
                .query(Integer.class).single();
        assertThat(left).isZero();
    }
}
  • @Transactional(propagation = NOT_SUPPORTED) désactive la transaction par défaut de @JdbcTest. Si vous ne la désactivez pas, les données insérées dans setUp ne sont pas validées et les autres threads ne les voient pas.
  • Le test utilise les virtual threads de Java 21 pour créer 20 requêtes simultanées.
  • La base de données intégrée par défaut de @JdbcTest (H2) suffit pour ce test. Pour tester ON CONFLICT et SKIP LOCKED, utilisez PostgreSQL réel via Testcontainers.

Résultat attendu :

success = 5, available = 0

Si vous remplacez l’instruction UPDATE par ‘SELECT puis comparer en Java’, ce test échouera aléatoirement, parfois success monte à 7 ou 8.

9. Erreurs courantes et quand ne pas l’utiliser

  • Stocker le prix dans le panier et l’utiliser directement au moment du paiement. Un attaquant pourrait conserver l’ancien prix ou modifier la charge utile. Toujours obtenir le prix du serveur.
  • Déduire le stock dès l’ajout au panier. Les utilisateurs abandonnent beaucoup, donc le stock sera bloqué inutilement. Ne réservez que lors du paiement et toujours avec une limite de temps.
  • Appeler Kafka ou le service de paiement à l’intérieur de @Transactional. Les transactions longues gardent les verrous longtemps et ne garantissent toujours pas la cohérence. Utilisez l’Outbox.
  • Considérer Redis comme la source de vérité pour le stock. Redis ne devrait être qu’une couche de filtrage.
  • Utiliser une transaction distribuée (2PC/XA) entre les services. C’est complexe et lent. Utilisez une saga basée sur les événements avec les étapes de compensation (annuler la commande, rembourser le stock).

Remarque : Si votre plateforme n’a que quelques dizaines de commandes par minute, une seule base de données avec mise à jour conditionnelle et clé d’idempotence suffit. La porte Redis, la file Kafka et la séparation des SKU populaires ne valent la peine d’être ajoutés que quand les métriques mesurées montrent que la DB est réellement engorgée.

10. Conclusion

Cet article a conçu le panier et le flux de commande par couches. Le panier se trouve sur Redis Hash car il doit être rapide et accepte une cohérence approximative. Les commandes et le stock sont sur PostgreSQL car ils ont besoin d’une cohérence stricte.

Trois techniques clés résolvent les défis :

  • Mise à jour conditionnelle empêche le surstock.
  • Clé d’idempotence avec la contrainte UNIQUE empêchent les doublons de commandes.
  • Outbox transactionnel garantit qu’aucun événement n’est perdu.

Pendant une flash sale, une porte Redis exécutant un script Lua filtre le trafic avant de toucher la base de données, tandis que la base de données reste la source de vérité. Vous pouvez commencer par la version la plus simple et ajouter chaque couche quand la charge réelle l’exige.

system-designspring-bootrediskafkaetude-de-cas