Conception d'une flash sale : vendre 10 000 produits en 1 seconde

1. Aperçu

Une flash sale est un problème classique d’architecture système. Des centaines de milliers de personnes cliquent sur « Acheter » exactement au même instant. Le stock ne contient que quelques milliers de produits. Le système doit supporter une charge extrême et surtout ne jamais vendre au-delà du stock disponible.

Cet article parcourt une étude de cas précise : vendre 10 000 produits en environ 1 seconde. À la fin, vous saurez comment formuler les exigences, concevoir le modèle de données et l’API, effectuer des réservations atomiques avec Redis Lua, persister asynchrone les commandes via Kafka et restituer le stock si le client n’a pas payé.

2. Exigences

2.1. Exigences fonctionnelles

  • Chaque flash sale contient un ou plusieurs produits, chaque produit ayant une quantité fixe et un prix promotion.
  • L’utilisateur connecté clique sur « Acheter » et reçoit une réponse : réservation réussie, stock épuisé ou déjà acheté.
  • Chaque utilisateur ne peut acheter qu’1 produit par flash sale.
  • Une réservation doit être payée dans les 10 minutes. Au-delà, le produit est retourné au stock pour que d’autres puissent l’acheter.

2.2. Échelle

  • Stock : 10 000 produits.
  • Participants : environ 500 000 utilisateurs, la plupart cliquant dans les 1–2 premières secondes.
  • Pic de charge estimé : 200 000–500 000 requêtes/seconde vers l’endpoint d’achat.

Seuls 10 000 d’entre eux réussissent. Plus de 98 % des requêtes reçoivent « stock épuisé ». C’est le détail clé : le système doit rejeter rapidement et à bas coût.

2.3. Contraintes

  • Jamais de vente en négatif : le nombre de commandes valides ne dépasse jamais 10 000.
  • Pas de surstock inutile : les produits réservés mais non payés doivent être restitués.
  • Le système de vente régulier ne doit pas être affecté par la flash sale.

3. Architecture générale

Une base de données relationnelle seule peut gérer quelques milliers d’UPDATE par seconde sur la même ligne. C’est bien moins que les centaines de milliers dont on a besoin. Nous séparons donc le flux en plusieurs couches, chacune filtrant la charge pour les suivantes :

Client ──► CDN (page de vente statique, compte à rebours)
  │
  ▼
API Gateway (rate limit par utilisateur/IP, bloque les bots)
  │
  ▼
Flash Sale Service ──► Redis (Lua : vérification + décrémentation atomique du stock)
  │                         │ réussi (~10 000 fois)
  │                         ▼
  │                    Topic Kafka « flash-sale-orders »
  │                         │
  ▼                         ▼
202 Accepted          Order Consumer ──► PostgreSQL (source de vérité finale)

Idées principales :

  1. Redis est le goulot d’étranglement. L’opération de décrément du stock s’exécute en mémoire, single-threaded, donc atomique et rapide.
  2. Kafka est un tampon. Seuls environ 10 000 messages réussis entrent dans la queue. Le consumer persiste en DB au rythme de la DB.
  3. PostgreSQL est le filet de sécurité. Les contraintes dans la DB garantissent pas de vente en négatif, même si Redis a un problème.

4. Modèle de données

Dans PostgreSQL, nous avons besoin de deux tables :

CREATE TABLE flash_sale_item (
    id         BIGINT PRIMARY KEY,
    sale_id    BIGINT  NOT NULL,
    product_id BIGINT  NOT NULL,
    quantity   INT     NOT NULL,
    sold       INT     NOT NULL DEFAULT 0,
    CONSTRAINT chk_not_oversold CHECK (sold <= quantity)
);

CREATE TABLE flash_sale_order (
    id         UUID PRIMARY KEY,
    item_id    BIGINT      NOT NULL REFERENCES flash_sale_item(id),
    user_id    BIGINT      NOT NULL,
    status     VARCHAR(16) NOT NULL,   -- PENDING, PAID, EXPIRED
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT uq_item_user UNIQUE (item_id, user_id)
);

Deux contraintes importantes :

  • chk_not_oversold empêche sold de dépasser quantity à la couche DB.
  • uq_item_user applique à la fois la règle « un produit par personne » et rend le consumer idempotent quand Kafka remet le message.

Dans Redis, chaque produit de la flash sale utilise deux clés :

{item:1001}:stock    -> "10000"          (String, quantité restante)
{item:1001}:buyers   -> {userId, ...}    (Set, utilisateurs ayant réservé)

La partie {item:1001} est une hash tag. Grâce à elle, les deux clés restent toujours sur le même slot dans Redis Cluster. Le script Lua ne peut s’exécuter que quand toutes les clés sont sur le même slot.

Avant l’ouverture de la vente, un job pré-charge le stock dans Redis :

redis-cli SET "{item:1001}:stock" 10000

5. API

POST /api/flash-sales/items/{itemId}/purchase
Authorization: Bearer <token>

202 Accepted   {"orderId": "c0a8...", "status": "PENDING"}
409 Conflict   {"error": "ALREADY_PURCHASED"}
410 Gone       {"error": "SOLD_OUT"}

GET /api/flash-sales/orders/{orderId}
200 OK         {"orderId": "c0a8...", "status": "PENDING"}

L’endpoint d’achat retourne 202 au lieu de 201, car la commande n’est pas encore persistée en DB. Le client garde orderId et sonde l’endpoint GET jusqu’à ce que la commande soit prête pour le paiement. userId provient du token, pas du body, pour empêcher les achats pour autrui.

6. Réservation atomique avec Redis Lua

Ajouter la dépendance :

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

L’erreur la plus courante est d’écrire GET puis DECR comme deux commandes séparées depuis Java. Entre les deux, des centaines d’autres threads lisent la même valeur 1 et la décrémentent. Le résultat est un stock négatif. Le script Lua résout ce problème car Redis exécute tout le script comme un bloc, sans entrelacer d’autres commandes.

Fichier src/main/resources/lua/reserve.lua :

-- KEYS[1] = stock, KEYS[2] = buyers, ARGV[1] = userId
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
  return -2
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
  return -1
end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return stock - 1

Le script retourne -2 si l’utilisateur a déjà acheté, -1 si c’est épuisé, sinon la quantité restante après décrément. La vérification et la décrémention du stock se font en une seule étape, pas de race condition.

Script de restitution src/main/resources/lua/release.lua :

-- Incrémenter le stock seulement si l'utilisateur a vraiment réservé
if redis.call('SREM', KEYS[2], ARGV[1]) == 1 then
  return redis.call('INCR', KEYS[1])
end
return -1

La condition SREM == 1 empêche une restitution double pour la même personne. C’est une erreur courante quand le job d’expiration et le thread d’annulation s’exécutent en parallèle.

Le service appelant :

public enum ReserveResult { SUCCESS, SOLD_OUT, ALREADY_PURCHASED }

@Service
public class StockReservationService {

    private static final RedisScript<Long> RESERVE =
            RedisScript.of(new ClassPathResource("lua/reserve.lua"), Long.class);
    private static final RedisScript<Long> RELEASE =
            RedisScript.of(new ClassPathResource("lua/release.lua"), Long.class);

    private final StringRedisTemplate redis;

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

    public ReserveResult reserve(long itemId, long userId) {
        Long result = redis.execute(RESERVE, keys(itemId), String.valueOf(userId));
        if (result == null) throw new IllegalStateException("Redis returned null");
        if (result == -2) return ReserveResult.ALREADY_PURCHASED;
        if (result == -1) return ReserveResult.SOLD_OUT;
        return ReserveResult.SUCCESS;
    }

    public void release(long itemId, long userId) {
        redis.execute(RELEASE, keys(itemId), String.valueOf(userId));
    }

    private static List<String> keys(long itemId) {
        String tag = "{item:" + itemId + "}";
        return List.of(tag + ":stock", tag + ":buyers");
    }
}

Spring Data Redis envoie le script avec EVALSHA et bascule automatiquement à EVAL si Redis n’a pas le script en cache. Pas besoin de gérer manuellement les SHA.

7. Persistance asynchrone des commandes via Kafka

Ajouter la dépendance :

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

7.1. Controller : réserver puis publier l’événement

public record OrderPlaced(UUID orderId, long itemId, long userId) {}

@RestController
@RequestMapping("/api/flash-sales")
public class PurchaseController {

    private final StockReservationService reservation;
    private final KafkaTemplate<String, OrderPlaced> kafka;

    public PurchaseController(StockReservationService reservation,
                              KafkaTemplate<String, OrderPlaced> kafka) {
        this.reservation = reservation;
        this.kafka = kafka;
    }

    @PostMapping("/items/{itemId}/purchase")
    public ResponseEntity<Map<String, Object>> purchase(@PathVariable long itemId,
                                                        @AuthenticationPrincipal Jwt jwt) {
        long userId = Long.parseLong(jwt.getSubject());
        return switch (reservation.reserve(itemId, userId)) {
            case SOLD_OUT -> ResponseEntity.status(410).body(Map.of("error", "SOLD_OUT"));
            case ALREADY_PURCHASED -> ResponseEntity.status(409).body(Map.of("error", "ALREADY_PURCHASED"));
            case SUCCESS -> {
                var event = new OrderPlaced(UUID.randomUUID(), itemId, userId);
                kafka.send("flash-sale-orders", String.valueOf(itemId), event)
                     .whenComplete((ok, ex) -> {
                         if (ex != null) reservation.release(itemId, userId);
                     });
                yield ResponseEntity.accepted()
                        .body(Map.of("orderId", event.orderId(), "status", "PENDING"));
            }
        };
    }
}

Quelques points à noter :

  • Une requête « stock épuisé » coûte juste un appel Redis et retourne. C’est le chemin pour 98 % des requêtes.
  • Si l’envoi à Kafka échoue, nous appelons release pour restituer au stock. Sans cette compensation, le produit reste « gelé » à jamais.
  • La clé du message est itemId. Toutes les commandes du même produit entrent dans la même partition, donc traitées dans l’ordre.

Le producer doit activer acks=all et l’idempotence pour ne pas perdre de messages :

spring.kafka.producer.acks=all
spring.kafka.producer.properties.enable.idempotence=true
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer

7.2. Consumer : DB est le filet de sécurité

@Component
public class OrderConsumer {

    private final JdbcTemplate jdbc;

    public OrderConsumer(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    @KafkaListener(topics = "flash-sale-orders", groupId = "order-writer")
    public void handle(OrderPlaced event) {
        int inserted = jdbc.update("""
                INSERT INTO flash_sale_order (id, item_id, user_id, status)
                VALUES (?, ?, ?, 'PENDING')
                ON CONFLICT DO NOTHING
                """, event.orderId(), event.itemId(), event.userId());
        if (inserted == 0) return; // message dupliqué, déjà traité

        int updated = jdbc.update("""
                UPDATE flash_sale_item SET sold = sold + 1
                WHERE id = ? AND sold < quantity
                """, event.itemId());
        if (updated == 0) {
            throw new IllegalStateException("DB stock exhausted for item " + event.itemId());
        }
    }
}

ON CONFLICT DO NOTHING rend le consumer idempotent. Kafka garantit une livraison au moins une fois, donc le même message peut arriver deux fois. La deuxième fois, aucune ligne n’est insérée et la fonction retourne tôt.

L’UPDATE ... WHERE sold < quantity est une condition atomique au niveau DB. Si Redis est décalé (par exemple quelqu’un charge mal les données), la requête retourne 0. Transaction rollback, la commande n’est pas créée. Vous devriez configurer un error handler pour envoyer le message en erreur vers une dead-letter topic et restituer le stock Redis, plutôt que de retrier infiniment.

Le consumer ne reçoit qu’environ 10 000 messages et les persiste séquentiellement par partition, donc la DB subit quelques milliers de transactions par seconde. C’est tout à fait normal.

8. Expiration du paiement et restitution du stock

Un job périodique balaie les commandes expirées :

@Component
public class ExpiredOrderJob {

    private final JdbcTemplate jdbc;
    private final StockReservationService reservation;

    public ExpiredOrderJob(JdbcTemplate jdbc, StockReservationService reservation) {
        this.jdbc = jdbc;
        this.reservation = reservation;
    }

    @Scheduled(fixedDelay = 30_000)
    @Transactional
    public void expire() {
        List<long[]> expired = jdbc.query("""
                UPDATE flash_sale_order SET status = 'EXPIRED'
                WHERE status = 'PENDING' AND created_at < now() - interval '10 minutes'
                RETURNING item_id, user_id
                """, (rs, i) -> new long[]{rs.getLong(1), rs.getLong(2)});

        for (long[] row : expired) {
            jdbc.update("UPDATE flash_sale_item SET sold = sold - 1 WHERE id = ?", row[0]);
            reservation.release(row[0], row[1]);
        }
    }
}

UPDATE ... RETURNING change le statut et récupère la liste en une seule requête. Si deux instances exécutent le job simultanément, chaque ligne n’est mise à jour que par une instance, car la condition status = 'PENDING' ne reste vraie qu’une fois.

Attention à l’ordre : release sur Redis est dans le code de la transaction DB mais en dehors de la transaction elle-même. Si la transaction fait rollback après le release, Redis aura un surplus d’une unité. En pratique, vous devriez exécuter le release après le commit, par exemple avec TransactionSynchronization.afterCommit. Vous pouvez aussi émettre un événement « OrderExpired » via Kafka. La version abrégée ici est juste pour l’illustration.

9. Test : ne jamais vendre en négatif

Le comportement critique à tester : 1 000 utilisateurs en compétition pour 100 produits doivent voir exactement 100 réussies. Nous utilisons Testcontainers pour exécuter Redis réellement.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@Testcontainers
class StockReservationServiceTest {

    @Container
    static GenericContainer<?> redisContainer =
            new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @Test
    void whenThousandUsersCompeteForHundredItems_thenExactlyHundredSucceed() {
        var factory = new LettuceConnectionFactory(
                redisContainer.getHost(), redisContainer.getMappedPort(6379));
        factory.afterPropertiesSet();
        var template = new StringRedisTemplate(factory);
        var service = new StockReservationService(template);
        template.opsForValue().set("{item:1}:stock", "100");

        var successes = new AtomicInteger();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (long userId = 0; userId < 1_000; userId++) {
                long id = userId;
                executor.submit(() -> {
                    if (service.reserve(1, id) == ReserveResult.SUCCESS) {
                        successes.incrementAndGet();
                    }
                });
            }
        }

        assertThat(successes.get()).isEqualTo(100);
        assertThat(template.opsForValue().get("{item:1}:stock")).isEqualTo("0");
        assertThat(service.reserve(1, 0)).isEqualTo(ReserveResult.ALREADY_PURCHASED);
    }
}

Le bloc try-with-resources avec le virtual thread executor attend que toutes les tâches se terminent. Résultats attendus :

successes = 100
stock     = "0"

Si vous remplacez le script Lua par GET puis DECR séparément, ce test échouera de manière instable et le stock descendra souvent en négatif.

10. Points difficiles et erreurs courantes

Hot key sur Redis. Toutes les requêtes frappent la même clé {item:1001}:stock. Un nœud Redis traite environ 100 000 commandes/seconde. À une charge plus élevée, deux approches :

  • Sharding du stock : découper les 10 000 en 10 clés {item:1001:0} … {item:1001:9}, chacune avec 1 000, sur différents nœuds. Choisir un shard aléatoire, essayer un autre si épuisé. La règle « un produit par personne » doit alors router les utilisateurs de manière stable vers un shard selon userId % 10, ou vérifier à la couche DB.
  • Drapeau hors stock local : quand Redis signale SOLD_OUT, chaque instance active un AtomicBoolean en mémoire et refuse immédiatement sans appeler Redis à nouveau.

Pic de charge avant même Redis. Rate limiting au gateway, CAPTCHA ou « queue virtuelle » aide à bloquer la plupart des bots. La page de vente doit être statique sur CDN. Le bouton « Acheter » ne s’active qu’à l’heure H, et l’URL d’achat ne s’affiche que là pour réduire les clics précoces par script.

Redis perd les données. Si Redis bascule et la replica n’a pas eu le temps de recevoir le dernier DECR, le stock sur Redis peut être plus élevé que réel. C’est là que la condition sold < quantity en DB joue son rôle. Ne jamais abandonner ce filet de sécurité.

Cohérence éventuelle. L’utilisateur reçoit 202 mais la commande n’est pas encore en DB. L’UI doit afficher le statut « en cours de traitement » et faire du polling, pas dire « achat réussi » tout de suite.

Note: N’utilisez pas SELECT ... FOR UPDATE sur la ligne de stock comme mécanisme principal pour la flash sale. C’est juste en termes de cohérence, mais chaque transaction fait la queue sur le même row lock. Le débit chute à quelques centaines à quelques milliers de requêtes/seconde, le pool de connexions s’épuise et abat tout le système de vente régulier.

Note: L’architecture Redis + Kafka ne vaut la peine que si la charge est vraiment grande. Si votre flash sale n’a que quelques centaines de requêtes/seconde, une simple UPDATE ... WHERE sold < quantity dans PostgreSQL suffit. C’est beaucoup plus simple et pas de problème de synchronisation entre deux magasins de données.

11. Conclusion

La conception d’une flash sale tourne autour d’un principe : filtrer la charge à chaque couche et garder une source unique de vérité avec des contraintes strictes. Dans cet article, vous avez vu :

  • Un script Redis Lua qui vérifie et décrémente le stock de manière atomique, rejette « épuisé » en un seul round-trip.
  • Kafka qui tampon pour que la DB ne reçoive que le nombre exact de commandes réussies.
  • PostgreSQL avec UNIQUE, CHECK et UPDATE ... WHERE sold < quantity qui sert de filet de sécurité contre la vente en négatif et gère les messages dupliqués.
  • Un mécanisme de compensation quand l’envoi Kafka échoue ou la commande expire, avec protection contre la double restitution.

En pratique, commencez par les tests de contention comme à la section 9. Ensuite, lancez des tests de charge avec des chiffres proches du pic réel, avant que la première flash sale ne s’ouvre.

conception-systèmerediskafkaspring-bootconcurrence