Concevoir un système de vouchers : limiter l'utilisation sous charge

1. Aperçu

Un voucher « réduction de 100k, seulement 1 000 utilisations » semble simple. Quand arrive le flash sale, des dizaines de milliers de requêtes arrivent simultanément. À ce moment, le système peut émettre 1 050 utilisations au lieu de 1 000, et cette différence, c’est de l’argent réel perdu. Cet article traverse une étude de cas complète : les exigences, le modèle de données, l’API, puis résout progressivement les points difficiles : la concurrence, la cohérence et les pics de charge. Après la lecture, vous saurez quand une simple requête UPDATE atomique suffit, et quand vous devez intégrer Redis et Kafka.

2. Exigences

2.1. Fonctionnalités

  • Créer un voucher avec code, montant de réduction, montant minimum de commande, périodes de validité.
  • Limiter le nombre total d’utilisations et le nombre d’utilisations par client.
  • Lors du checkout, le voucher est réservé (reserve). Paiement réussi, alors confirmé (confirm). Annulation de la commande ou expiration, alors libéré (release).

2.2. Échelle et contraintes

  • Environ 10 millions de clients. Un voucher flash sale offre 1 000 utilisations mais peut recevoir 50 000 requêtes/seconde pendant quelques secondes au démarrage.
  • p99 de l’API reserve sous 100 ms.
  • Jamais de surallocation. Une sous-allocation temporaire est acceptable, par exemple une utilisation réservée puis libérée 15 minutes plus tard.
  • Une requête renvoyée (retry) ne doit pas consommer d’utilisation supplémentaire. En d’autres termes, l’API doit être idempotente selon orderId.

3. Modèle de données

La base de données (PostgreSQL) est la source de vérité ultime :

CREATE TABLE voucher (
    code             VARCHAR(32) PRIMARY KEY,
    discount_amount  BIGINT      NOT NULL,
    min_order_amount BIGINT      NOT NULL DEFAULT 0,
    total_limit      INT         NOT NULL,
    used_count       INT         NOT NULL DEFAULT 0,
    per_user_limit   INT         NOT NULL DEFAULT 1,
    starts_at        TIMESTAMP   NOT NULL,
    ends_at          TIMESTAMP   NOT NULL,
    CONSTRAINT ck_used CHECK (used_count <= total_limit)
);

CREATE TABLE voucher_redemption (
    id           BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    voucher_code VARCHAR(32) NOT NULL REFERENCES voucher(code),
    customer_id  BIGINT      NOT NULL,
    order_id     VARCHAR(64) NOT NULL UNIQUE,
    status       VARCHAR(16) NOT NULL, -- RESERVED, CONFIRMED, RELEASED
    created_at   TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT uq_voucher_customer UNIQUE (voucher_code, customer_id)
);

Quelques points à noter :

  • CHECK (used_count <= total_limit) est le filet de sécurité ultime. Même si le code a un bug, la base de données ne permet pas la surallocation.
  • order_id UNIQUE garantit l’idempotence : une commande ne peut utiliser qu’une seule utilisation.
  • UNIQUE (voucher_code, customer_id) n’est exact que lorsque per_user_limit = 1, ce qui est le cas le plus courant. Si vous autorisez plusieurs utilisations par client, supprimez cette contrainte et comptez dans une table séparée.

4. API

POST   /vouchers/{code}/reservations        body: {customerId, orderId, orderAmount}
       201 {orderId, discountAmount}
       409 {error: "OUT_OF_STOCK" | "ALREADY_USED"}
       422 {error: "NOT_ELIGIBLE"}           (expiration, montant de commande insuffisant)
POST   /vouchers/{code}/reservations/{orderId}/confirm
DELETE /vouchers/{code}/reservations/{orderId}

orderId est généré par le client (service de commande). De cette façon, lors d’une nouvelle tentative, le client renvoie le même ancien orderId et le serveur reconnaît que c’est une requête dupliquée.

5. L’approche naïve et pourquoi c’est faux

@Transactional
public boolean redeemNaive(String code) {
    Voucher v = voucherRepository.findById(code).orElseThrow();
    if (v.getUsedCount() >= v.getTotalLimit()) {
        return false;
    }
    v.setUsedCount(v.getUsedCount() + 1);
    return true;
}

C’est l’erreur classique de « check-then-act ». Deux threads lisent usedCount = 999, tous deux voient qu’il y a encore de la place, tous deux écrivent 1000. Le résultat est que deux clients reçoivent une réduction mais le compteur n’augmente que d’un. Au niveau d’isolation READ COMMITTED par défaut, la transaction ne vous sauve pas dans ce cas.

6. Solution 1 : UPDATE conditionnel dans la base de données

Le moyen le plus simple de corriger cela est de fusionner la vérification et l’écriture en une seule instruction atomique :

@Service
public class VoucherService {

    private final JdbcTemplate jdbc;

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

    @Transactional
    public boolean redeem(String code, long customerId, String orderId) {
        int updated = jdbc.update("""
                UPDATE voucher SET used_count = used_count + 1
                WHERE code = ?
                  AND used_count < total_limit
                  AND CURRENT_TIMESTAMP BETWEEN starts_at AND ends_at
                """, code);
        if (updated == 0) {
            return false; // utilisation épuisée ou expirée
        }
        jdbc.update("""
                INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
                VALUES (?, ?, ?, 'CONFIRMED')
                """, code, customerId, orderId);
        return true;
    }
}

Le code ci-dessus fonctionne comme suit :

  • La requête UPDATE verrouille la ligne du voucher. La base de données réévalue used_count < total_limit après avoir obtenu le verrou, donc deux requêtes ne peuvent pas dépasser le dernier lors de la dernière utilisation.
  • updated == 0 signifie que la condition est fausse : utilisation épuisée ou voucher expiré.
  • Si le client a déjà utilisé le voucher, ou si orderId est renvoyé, la requête INSERT lève une DuplicateKeyException. La transaction effectue un rollback, used_count revient à sa valeur précédente. Au niveau du contrôleur, mappez cette exception à 409 ALREADY_USED.

6.1. Test du comportement sous charge parallèle

@JdbcTest
@Import(VoucherService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class VoucherServiceTest {

    @Autowired JdbcTemplate jdbc;
    @Autowired VoucherService voucherService;

    @BeforeEach
    void setUp() {
        jdbc.update("DELETE FROM voucher_redemption");
        jdbc.update("DELETE FROM voucher");
        jdbc.update("""
                INSERT INTO voucher(code, discount_amount, total_limit, starts_at, ends_at)
                VALUES ('FLASH100', 100000, 10, DATEADD('DAY', -1, CURRENT_TIMESTAMP),
                        DATEADD('DAY', 1, CURRENT_TIMESTAMP))
                """);
    }

    @Test
    void shouldNeverExceedTotalLimit() throws Exception {
        AtomicInteger success = new AtomicInteger();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 200; i++) {
                long customerId = i;
                executor.submit(() -> {
                    if (voucherService.redeem("FLASH100", customerId, "order-" + customerId)) {
                        success.incrementAndGet();
                    }
                });
            }
        }

        Integer used = jdbc.queryForObject(
                "SELECT used_count FROM voucher WHERE code = 'FLASH100'", Integer.class);
        assertThat(success.get()).isEqualTo(10);
        assertThat(used).isEqualTo(10);
    }
}

@Transactional(propagation = NOT_SUPPORTED) désactive la transaction par défaut de @JdbcTest. Sinon, les données créées dans setUp ne sont pas validées et les autres threads ne les voient pas. Il y a 200 threads virtuels en concurrence pour 10 utilisations, et le résultat est toujours exactement 10 :

success = 10, used_count = 10

6.2. Limitations de la solution 1

Cette approche est correcte, et pour la plupart des vouchers ordinaires (quelques centaines de requêtes/seconde), c’est suffisant. Le problème n’apparaît que lorsque la charge se concentre sur une seule ligne (hot row). 50 000 requêtes/seconde s’alignent en attente d’un verrou sur la ligne FLASH100. Chaque transaction détient le verrou pendant quelques millisecondes, donc le débit réel est limité à quelques milliers par seconde. Le reste expire, le pool de connexions s’épuise, et toute la base de données ralentit, y compris les tables non liées aux vouchers.

7. Solution 2 : Redis comme « portail de contrôle » avec script Lua

L’idée : placer le compteur d’utilisations restantes dans Redis. Redis fonctionne en single-thread, chaque script Lua s’exécute de manière atomique et prend quelques microsecondes. Seules les requêtes qui ont réservé une place continuent vers la base de données. Pour un voucher avec 1 000 utilisations, la base de données reçoit seulement jusqu’à 1 000 écritures au lieu de 50 000.

Dépendance :

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

Script de réservation, sauvegardé à resources/scripts/reserve.lua :

-- KEYS[1] = voucher:{code}:remaining
-- KEYS[2] = voucher:{code}:users      (hash customerId -> nombre d'utilisations)
-- KEYS[3] = voucher:{code}:orders     (hash orderId -> customerId)
-- ARGV[1] = customerId, ARGV[2] = perUserLimit, ARGV[3] = orderId
if redis.call('HEXISTS', KEYS[3], ARGV[3]) == 1 then return 0 end   -- retry, déjà réservé
if redis.call('EXISTS', KEYS[1]) == 0 then return -3 end            -- voucher pas chargé
local used = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0')
if used >= tonumber(ARGV[2]) then return -2 end                      -- client déjà utilisé
if tonumber(redis.call('GET', KEYS[1])) <= 0 then return -1 end      -- utilisations épuisées
redis.call('DECR', KEYS[1])
redis.call('HINCRBY', KEYS[2], ARGV[1], 1)
redis.call('HSET', KEYS[3], ARGV[3], ARGV[1])
return 1

Les clés utilisent un hash tag {code}. De cette façon, sur un Redis Cluster, les trois clés se trouvent dans le même slot et le script s’exécute.

Côté Java :

@Component
public class VoucherGate {

    public enum Result { RESERVED, DUPLICATE, OUT_OF_STOCK, ALREADY_USED, NOT_LOADED }

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

    private final StringRedisTemplate redis;

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

    public Result reserve(String code, long customerId, int perUserLimit, String orderId) {
        String p = "voucher:{" + code + "}:";
        Long r = redis.execute(RESERVE,
                List.of(p + "remaining", p + "users", p + "orders"),
                String.valueOf(customerId), String.valueOf(perUserLimit), orderId);
        return switch (r.intValue()) {
            case 1 -> Result.RESERVED;
            case 0 -> Result.DUPLICATE;
            case -1 -> Result.OUT_OF_STOCK;
            case -2 -> Result.ALREADY_USED;
            default -> Result.NOT_LOADED;
        };
    }
}

DUPLICATE est considéré comme un succès : si le client réessaie, il reçoit le même résultat qu’avant. NOT_LOADED signifie que le voucher n’a pas encore été « réchauffé ». Vous pouvez charger paresseusement depuis la base de données en utilisant SET ... NX, ou mieux encore, charger à l’avance avant l’heure d’ouverture de la vente en utilisant un job :

redis-cli SET "voucher:{FLASH100}:remaining" 1000

Une autre optimisation : quand remaining atteint 0, une passerelle API ou un drapeau cache local (cache local, TTL quelques secondes) peut répondre OUT_OF_STOCK directement sans appeler Redis. Des dizaines de milliers de requêtes « arrivées trop tard » s’arrêteront à la couche la plus externe.

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

Redis est maintenant le décideur, mais la base de données est la vraie source de vérité pour l’argent. Il y a deux façons de synchroniser.

Approche A : écrire la BD de façon synchrone juste après Redis. Vous exécutez toujours la requête UPDATE de la section 6. Si la BD échoue, appelez le script release pour redonner l’utilisation à Redis. Cette approche est plus simple et plus cohérente, convient quand le nombre d’utilisations est petit.

Approche B : envoyer des événements via Kafka quand vous voulez séparer complètement la BD du chemin critique :

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
public record VoucherReserved(String code, long customerId, String orderId) {}

@Component
public class RedemptionConsumer {

    private final JdbcTemplate jdbc;

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

    @KafkaListener(topics = "voucher-reserved", groupId = "voucher-writer")
    @Transactional
    public void on(VoucherReserved e) {
        int inserted = jdbc.update("""
                INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
                VALUES (?, ?, ?, 'RESERVED')
                ON CONFLICT (order_id) DO NOTHING
                """, e.code(), e.customerId(), e.orderId());
        if (inserted == 1) {
            jdbc.update("UPDATE voucher SET used_count = used_count + 1 WHERE code = ?", e.code());
        }
    }
}

Côté service, après que Lua retourne RESERVED, vous appelez kafkaTemplate.send("voucher-reserved", code, event) avec la clé code. Le consommateur a les caractéristiques suivantes :

  • Kafka garantit at-least-once, donc le même événement peut arriver deux fois. ON CONFLICT (order_id) DO NOTHING rend la deuxième occurrence sans effet, et used_count n’augmente que si nous insérons réellement une nouvelle ligne.
  • La contrainte CHECK (used_count <= total_limit) reste en place. Si Redis est nourri avec un mauvais nombre, la BD refuse et l’événement tombe dans un dead-letter topic pour que l’opérateur gère.

Note : Si Redis réussit la réservation mais que l’envoi à Kafka échoue, cette utilisation reste « suspendue » dans Redis. C’est une sous-allocation, sûr pour l’argent mais nécessitant un nettoyage. C’est pourquoi vous avez besoin d’un job de rapprochement (section 8.1).

8.1. Expiration de la réservation et rapprochement

Si un client réserve un voucher puis abandonne le panier, cette utilisation doit être restituée. Un job périodique gère cela :

@Scheduled(fixedDelay = 60_000)
public void releaseExpired() {
    List<Map<String, Object>> expired = jdbc.queryForList("""
            UPDATE voucher_redemption SET status = 'RELEASED'
            WHERE status = 'RESERVED' AND created_at < CURRENT_TIMESTAMP - INTERVAL '15 minutes'
            RETURNING voucher_code, customer_id, order_id
            """);
    expired.forEach(row -> voucherGate.release(
            (String) row.get("voucher_code"),
            ((Number) row.get("customer_id")).longValue(),
            (String) row.get("order_id")));
}

La requête UPDATE ... RETURNING (PostgreSQL) change l’état et retourne la liste des lignes modifiées en même temps. Donc même si deux instances exécutent le job en même temps, pas de libération dupliquée : une ligne une fois passée à RELEASED ne correspond plus au critère de sélection. Vous devez aussi réduire used_count correspondant dans la BD. La fonction release appelle un script Lua inverse : seulement si HDEL orders orderId retourne 1, alors INCR remaining et HINCRBY users customerId -1. Ainsi, appeler release deux fois reste sûr.

Ajoutez aussi un job de rapprochement s’exécutant après chaque vente : comparer total_limit - used_count dans la BD avec remaining dans Redis, puis alerter si l’écart est important.

9. Erreurs courantes et quand ne pas utiliser cette approche

  • Utiliser GET puis DECR comme deux commandes séparées depuis Java. C’est à nouveau du check-then-act, simplement déplacé vers Redis. La vérification et la décrémentation doivent être dans le même script Lua.
  • Utiliser seulement DECR puis vérifier si négatif et INCR à nouveau. Cette approche fonctionne, mais pendant le moment où la valeur est négative, une autre requête peut lire incorrectement. Elle rend aussi la libération plus compliquée.
  • Optimistic locking (@Version) pour un voucher populaire. Quand 50 000 requêtes se disputent une ligne, presque toutes échouent et réessaient. La charge est pire qu’avec un pessimistic lock.
  • Redis sans persistence/replication. Si Redis redémarre et perd les données, et qu’un job de warmup recharge depuis total_limit, vous survalorisiez. Chargez toujours depuis total_limit - used_count dans la BD, moins les utilisations actuellement en RESERVED.
  • Appliquer Redis + Kafka à chaque voucher. Les vouchers ordinaires et peu chargés n’ont besoin que de la solution 6. Ajouter deux systèmes, c’est ajouter deux places où les données peuvent diverger. Activez le « mode flash sale » seulement pour les vouchers marqués.

Note : Chaque couche au-dessus n’est qu’une optimisation. Les contraintes dans la base de données (CHECK, UNIQUE) sont la garantie ultime qu’aucune utilisation n’est émise en excès.

10. Conclusion

Le problème de limiter les utilisations d’un voucher est essentiellement un problème de comptage atomique sous contention. Avec une charge modérée, une seule requête UPDATE ... WHERE used_count < total_limit plus les contraintes unique est correcte et assez rapide. Lors d’un flash sale avec une hot row, vous utilisez un script Redis Lua comme portail de contrôle atomique. Kafka aide à extraire la partie écriture BD du chemin critique, et un mécanisme de réservation avec expiration plus un job de rapprochement gèrent les écarts. Le principe transversal est : accepter une sous-allocation temporaire, ne jamais faire de surallocation, et faire en sorte que la base de données maintienne la couche de protection ultime.

system-designspring-bootrediskafkaconcurrency