Conception d'un système d'enchères : concurrence et fermeture

1. Vue d’ensemble

Un système de ventes aux enchères semble simple : les utilisateurs placent des enchères, le prix le plus élevé gagne. La difficulté réside à deux moments. D’abord, des centaines d’enchères peuvent affluer vers une même session en quelques secondes. Deuxièmement, la limite de « session terminée » doit être absolument claire. Perdre une enchère ou mal identifier le gagnant est une erreur financière.

Cet article couvre toute la conception : les exigences, le modèle de données, l’API, comment gérer les enchères simultanées avec PostgreSQL et Redis, le mécanisme anti-sniping (anti-dernière minute) et comment fermer une session de manière sécurisée avec plusieurs instances en cours d’exécution. Après la lecture, vous serez en mesure de construire le cœur d’un système de ventes aux enchères fiable avec Spring Boot 3.

2. Exigences du problème

2.1. Exigences fonctionnelles

  • Le vendeur crée une session d’enchères avec un prix de départ, un incrément minimum et une heure de fin.
  • L’acheteur place une enchère. Une enchère est valide quand la session est ouverte et le prix n’est pas inférieur à prix actuel + incrément.
  • S’il y a une enchère valide dans les 2 dernières minutes, la session est prolongée de 2 minutes.
  • Quand le temps s’écoule, le système ferme la session, identifie le gagnant et émet des événements pour les étapes de paiement et de notification.
  • Les spectateurs reçoivent les nouveaux prix en temps réel.

2.2. Échelle et contraintes

  • Environ 100 000 sessions ouvertes simultanément. La plupart des sessions sont très « froides », avec seulement quelques enchères par heure.
  • Certaines sessions « chaudes » (articles de luxe, flash sales) reçoivent jusqu’à 2 000 enchères/seconde dans la dernière minute.
  • Forte cohérence par session : aucune enchère valide n’est perdue, aucune enchère après l’heure de fermeture n’est acceptée, un seul gagnant.
  • Les enchères doivent être idempotentes : une nouvelle tentative du client en cas de timeout ne doit pas créer deux enchères.
  • La latence de placement d’enchère en p99 doit être inférieure à 200 ms.

En résumé, nous avons besoin de cohérence par session. Entre les différentes sessions, ce n’est pas nécessaire. C’est le point clé pour scaler : chaque session est une unité de contention indépendante.

3. Modèle de données

Nous utilisons PostgreSQL comme source de vérité. La table auction maintient l’état actuel. La table bid est un journal append-only.

CREATE TABLE auction (
    id             BIGSERIAL PRIMARY KEY,
    seller_id      BIGINT        NOT NULL,
    title          VARCHAR(200)  NOT NULL,
    current_price  NUMERIC(14,2) NOT NULL,
    min_increment  NUMERIC(14,2) NOT NULL,
    leader_id      BIGINT,
    status         VARCHAR(10)   NOT NULL DEFAULT 'OPEN',  -- OPEN | CLOSED
    end_time       TIMESTAMPTZ   NOT NULL
);
CREATE INDEX idx_auction_open_end ON auction (end_time) WHERE status = 'OPEN';

CREATE TABLE bid (
    id          BIGSERIAL PRIMARY KEY,
    auction_id  BIGINT        NOT NULL REFERENCES auction(id),
    bidder_id   BIGINT        NOT NULL,
    amount      NUMERIC(14,2) NOT NULL,
    request_id  UUID          NOT NULL,
    created_at  TIMESTAMPTZ   NOT NULL DEFAULT clock_timestamp(),
    UNIQUE (auction_id, request_id)
);

Quelques points importants :

  • current_price et leader_id sont dénormalisés dans auction. Ainsi, vérifier une enchère ne demande que la lecture d’une seule ligne au lieu d’un SELECT MAX(amount) sur la table bid.
  • L’index partiel idx_auction_open_end ne contient que les sessions ouvertes. Le job de fermeture de session parcourra cet index très rapidement.
  • UNIQUE (auction_id, request_id) est le mécanisme d’idempotence au niveau de la base de données.

4. API

POST /auctions                     créer une session
GET  /auctions/{id}                voir l'état de la session
POST /auctions/{id}/bids           placer une enchère (en-tête Idempotency-Key: <uuid>)
WS   /ws/auctions/{id}             recevoir les nouveaux prix en temps réel

Requête de placement d’enchère :

{ "bidderId": 42, "amount": 1250000 }

La réponse peut prendre trois formes. 201 Created quand l’enchère est acceptée. 409 Conflict quand le prix est trop bas ou la session est fermée. 200 OK avec le résultat antérieur quand la requête a le même Idempotency-Key.

5. Placement d’enchères simultanées

5.1. L’erreur classique : lire puis écrire

La façon la plus naturelle d’écrire s’avère être la mauvaise façon :

@Transactional
public void placeBidNaive(long auctionId, long bidderId, BigDecimal amount) {
    Auction a = auctionRepository.findById(auctionId).orElseThrow();
    if (amount.compareTo(a.getCurrentPrice().add(a.getMinIncrement())) < 0) {
        throw new BidRejectedException("Prix trop bas");
    }
    a.setCurrentPrice(amount);
    a.setLeaderId(bidderId);
}

Supposons que le prix actuel est 1 000. Deux threads lisent le prix 1 000. Le thread A place 1 100, le thread B place 1 050. Les deux passent le contrôle. Si B commit après, le prix final est 1 050 et le leader est B. L’enchère plus haute de A est écrasée. C’est une erreur de perte de mise à jour.

5.2. Mise à jour conditionnelle atomique

La solution la plus élégante est de fusionner la vérification et l’écriture en une seule instruction UPDATE. La condition se trouve dans le WHERE :

public interface AuctionRepository extends JpaRepository<Auction, Long> {

    @Modifying
    @Query(value = """
        UPDATE auction
           SET current_price = :amount,
               leader_id     = :bidderId,
               end_time      = CASE
                   WHEN end_time - clock_timestamp() < INTERVAL '2 minutes'
                   THEN clock_timestamp() + INTERVAL '2 minutes'
                   ELSE end_time END
         WHERE id = :id
           AND status = 'OPEN'
           AND end_time > clock_timestamp()
           AND :amount >= current_price + min_increment
        """, nativeQuery = true)
    int tryPlaceBid(@Param("id") long id,
                    @Param("bidderId") long bidderId,
                    @Param("amount") BigDecimal amount);
}

Le mécanisme fonctionne comme suit :

  • PostgreSQL verrouille la ligne auction lors de l’UPDATE. Le deuxième thread doit attendre que le premier soit validé.
  • Au niveau READ COMMITTED, après déverrouillage, PostgreSQL réévalue la clause WHERE sur la version la plus récente de la ligne. L’enchère 1.050 rencontre le prix 1.100, la condition échoue, l’instruction retourne 0.
  • La logique anti-sniping (bloc CASE) est intégrée dans cette instruction. Ainsi, l’extension ne s’écarte jamais de l’enchère qui la déclenche.
  • Le temps est tiré de clock_timestamp() de la base de données et non de l’horloge de chaque instance d’application. La base de données est la source unique de temps.

Remarque : N’utilisez pas now() ici. Dans PostgreSQL, now() retourne l’heure du début de la transaction. Une transaction démarrée avant la fermeture mais s’exécutant lentement pourrait laisser « passer » une enchère après l’heure de fermeture.

Le service enregistre l’enchère dans la même transaction :

@Service
@RequiredArgsConstructor
public class BidService {

    private final AuctionRepository auctionRepository;
    private final BidRepository bidRepository;

    @Transactional
    public Bid placeBid(long auctionId, long bidderId, BigDecimal amount, UUID requestId) {
        var existing = bidRepository.findByAuctionIdAndRequestId(auctionId, requestId);
        if (existing.isPresent()) {
            return existing.get(); // nouvelle tentative du client : retourne le résultat antérieur
        }
        int updated = auctionRepository.tryPlaceBid(auctionId, bidderId, amount);
        if (updated == 0) {
            throw new BidRejectedException("Session fermée ou prix insuffisant");
        }
        return bidRepository.save(new Bid(auctionId, bidderId, amount, requestId));
    }
}

Si deux nouvelles tentatives avec le même requestId s’exécutent en parallèle, les deux peuvent passer la vérification findBy.... Ensuite, la contrainte UNIQUE lèvera une DataIntegrityViolationException pour la deuxième transaction. Cette transaction est entièrement annulée, y compris la partie UPDATE de prix. Le contrôleur capture cette exception et retourne l’enchère existante.

5.3. Test avec enchères simultanées

Le test ci-dessous envoie 200 enchères en parallèle via des virtual threads vers une même session, puis vérifie les invariants. Dépendances à ajouter :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class BidServiceConcurrencyTest {

    @Container @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @Autowired BidService bidService;
    @Autowired AuctionRepository auctionRepository;
    @Autowired BidRepository bidRepository;

    @Test
    void concurrentBids_keepHighestAcceptedBidAsLeader() throws Exception {
        long id = auctionRepository.save(Auction.open("Montre ancienne",
                new BigDecimal("1000"), new BigDecimal("10"),
                Instant.now().plus(Duration.ofHours(1)))).getId();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 1; i <= 200; i++) {
                long bidder = i;
                var amount = new BigDecimal(1000 + i * 10);
                executor.submit(() -> {
                    try {
                        bidService.placeBid(id, bidder, amount, UUID.randomUUID());
                    } catch (BidRejectedException ignored) { }
                });
            }
        }

        Auction result = auctionRepository.findById(id).orElseThrow();
        BigDecimal maxAccepted = bidRepository.findMaxAmount(id);

        assertThat(result.getCurrentPrice()).isEqualByComparingTo("3000");
        assertThat(result.getLeaderId()).isEqualTo(200L);
        assertThat(maxAccepted).isEqualByComparingTo(result.getCurrentPrice());
    }
}

L’enchère 3 000 (bidder 200) est toujours valide car aucune enchère n’est plus haute. Par conséquent, quel que soit l’ordre d’exécution, elle doit être le leader. Si vous remplacez tryPlaceBid par la version naive de la section 5.1, ce test échouera régulièrement. C’est le signe d’une condition de course.

6. Sessions « chaudes » : transférer la contention vers Redis

La mise à jour conditionnelle sérialise toutes les enchères sur une seule ligne. Pour une session, PostgreSQL peut généralement traiter de quelques centaines à environ un millier d’UPDATE par seconde. Une session recevant 2 000 enchères/seconde créera une longue queue de verrous et poussera p99 au-delà du seuil.

Pour les sessions marquées comme « chaudes », nous transférons le processus décisionnel vers Redis. Redis exécute le script Lua de manière single-threaded et atomique. Dépendances à ajouter :

<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>
@Service
@RequiredArgsConstructor
public class HotBidService {

    private static final RedisScript<Long> PLACE_BID = RedisScript.of("""
        local a = redis.call('HMGET', KEYS[1], 'price', 'inc', 'end', 'status')
        local now = tonumber(ARGV[3])
        if a[4] ~= 'OPEN' or now >= tonumber(a[3]) then return -1 end
        if tonumber(ARGV[1]) < tonumber(a[1]) + tonumber(a[2]) then return 0 end
        local newEnd = tonumber(a[3])
        if newEnd - now < 120000 then newEnd = now + 120000 end
        redis.call('HSET', KEYS[1], 'price', ARGV[1], 'leader', ARGV[2], 'end', newEnd)
        return 1
        """, Long.class);

    private final StringRedisTemplate redis;
    private final KafkaTemplate<String, BidAccepted> kafka;

    public boolean placeBid(long auctionId, long bidderId, long amount, UUID requestId) {
        long now = Long.parseLong(redis.execute(
                (RedisCallback<Long>) c -> c.serverCommands().time()).toString());
        Long r = redis.execute(PLACE_BID, List.of("auction:" + auctionId),
                String.valueOf(amount), String.valueOf(bidderId), String.valueOf(now));
        if (r == null || r != 1L) return false;
        kafka.send("bid-accepted", String.valueOf(auctionId),
                new BidAccepted(auctionId, bidderId, amount, requestId, now));
        return true;
    }
}

Explication rapide :

  • Le script lit le prix, l’incrément, l’heure de fin et le statut, vérifie puis écrit, tout en une seule étape atomique. Valeur retournée : 1 pour accepter, 0 pour prix trop bas, -1 pour session fermée.
  • Le temps est tiré de la commande TIME de Redis pour que toutes les instances partagent une horloge unique. En pratique, vous pouvez appeler redis.call('TIME') directement dans le script. Pour ce faire, vous devez activer le script effects replication, qui est activé par défaut à partir de Redis 5.
  • Les enchères acceptées seront envoyées à Kafka avec la clé auctionId. Toutes les enchères d’une session tombent dans la même partition et conservent l’ordre correct. Le consumer les écrit dans PostgreSQL par lot.

Remarque : À ce stade, Redis devient la source de vérité pour la durée de la session. Vous devez activer AOF avec appendfsync everysec ou always, exécuter une réplica, et gérer les cas où kafka.send échoue après que Redis ait écrit (nouvelle tentative ou rapprochement périodique entre Redis et PostgreSQL). N’appliquez pas ce modèle à toutes les sessions. La plupart des sessions froides fonctionnent bien avec la mise à jour conditionnelle et sont beaucoup plus simples.

7. Fermeture de session

7.1. Fermer une session avec une instruction atomique

Nous n’avons pas besoin d’un scheduler précis à la milliseconde pour chaque session. La limite réelle se trouve dans la condition end_time > clock_timestamp() de la mise à jour des enchères. Après l’heure de fermeture, aucune enchère ne peut passer, même si le statut est toujours OPEN. Le job de fermeture de session ne doit que « clôturer le registre » en quelques secondes :

@Component
@RequiredArgsConstructor
public class AuctionCloser {

    private final JdbcTemplate jdbc;

    @Scheduled(fixedDelay = 1000)
    @Transactional
    public void closeExpired() {
        var closed = jdbc.query("""
            UPDATE auction SET status = 'CLOSED'
             WHERE id IN (SELECT id FROM auction
                           WHERE status = 'OPEN' AND end_time <= clock_timestamp()
                           ORDER BY end_time LIMIT 500
                           FOR UPDATE SKIP LOCKED)
            RETURNING id, leader_id, current_price
            """, (rs, i) -> new AuctionClosed(rs.getLong("id"),
                    (Long) rs.getObject("leader_id"), rs.getBigDecimal("current_price")));

        for (var e : closed) {
            jdbc.update("INSERT INTO outbox(topic, aggregate_id, payload) VALUES (?, ?, ?::jsonb)",
                    "auction-closed", e.auctionId(), e.toJson());
        }
    }
}

Cette instruction est sûre lors de l’exécution de plusieurs instances car :

  • FOR UPDATE SKIP LOCKED permet aux instances de partager les sessions. Deux instances ne fermeront jamais la même session.
  • Si une enchère détient un verrou sur la ligne, SKIP LOCKED ignore cette ligne. La prochaine exécution vérifiera à nouveau, au cas où end_time aurait été prolongé.
  • Le changement de statut OPEN → CLOSED ne se produit qu’une seule fois. Relancer le job ne produit aucun effet secondaire, ce qui rend le job idempotent.

7.2. Émettre des événements avec l’Outbox

L’événement auction-closed déclenche le paiement et envoie un email au gagnant. Si vous appelez kafka.send directement dans la transaction, vous risquez deux mauvaises situations. D’abord, l’événement est envoyé mais la transaction est annulée. Ensuite, la transaction est validée mais l’envoi échoue. La table outbox est écrite dans la même transaction que la fermeture de la session. Ensuite, un relay (polling ou Debezium CDC) pousse les lignes outbox vers Kafka. Le consumer doit être idempotent selon auctionId, car le relay garantit at-least-once.

8. Pics de charge et fan-out en temps réel

La dernière minute d’une session chaude ne comporte pas seulement de nombreuses enchères mais aussi des dizaines de milliers de spectateurs. Quelques techniques complémentaires :

  • Limitation de débit par utilisateur, par exemple maximum 5 enchères/seconde avec token bucket sur Redis. Cette technique bloque les bots spam avant qu’ils n’atteignent la couche de contention.
  • Rejet anticipé : mettre en cache le prix actuel en mémoire (quelques centaines de millisecondes) et rejeter immédiatement les enchères clairement trop basses avant d’appeler la DB ou Redis. Le cache est utilisé uniquement pour rejeter, jamais pour accepter.
  • Fan-out via pub/sub : quand une enchère est acceptée, publiez-la sur le channel Redis auction:{id}. Chaque nœud WebSocket s’abonne et la pousse vers ses clients. Si les enchères arrivent trop densément, regroupez les mises à jour tous les 100–200 ms. Les spectateurs n’ont besoin de voir que le prix le plus récent, pas chaque enchère.
  • Séparation lecture/écriture : GET /auctions/{id} peut lire depuis une réplica ou un cache. Seules les nouvelles enchères doivent aller à la source.

9. Pièges courants et quand ne pas utiliser cette approche

  • Utiliser l’horloge de l’application pour comparer avec end_time. Les instances qui dévient de quelques secondes l’une par rapport à l’autre est courant. Laissez toujours la base de données ou Redis décider de l’heure.
  • Utiliser @Version (verrouillage optimiste) pour les sessions chaudes. C’est toujours correct, mais 2 000 enchères/seconde généreront d’innombrables OptimisticLockException et boucles de retry. La mise à jour conditionnelle n’a besoin de retry car le WHERE est réévalué automatiquement.
  • Utiliser SELECT ... FOR UPDATE puis vérifier en Java. C’est correct mais cela maintient le verrou plus longtemps, car il faut attendre un aller-retour via l’application. Utilisez-le uniquement quand la logique de vérification est trop complexe pour l’écrire en SQL.
  • Verrou distribué (Redisson) autour de toute la fonction de placement d’enchère. Cette approche ajoute un point de défaillance et le risque que le verrou expire à mi-chemin, alors que la base de données a déjà un verrouillage de ligne.
  • Ignorer l’idempotence. Les nouvelles tentatives du client mobile en cas de perte réseau sont courantes. Sans request_id, la même personne pourrait se faire enchérir « contre elle-même ».

10. Conclusion

La clé d’un système de ventes aux enchères est de traiter chaque session comme une unité de cohérence indépendante. Cet article a abordé chaque point difficile comme suit :

  • Enchères simultanées : fusionnez la vérification et l’écriture en une seule instruction UPDATE ... WHERE atomique, avec la logique anti-sniping dans la même instruction.
  • Point de fin : tirez l’heure de la base de données (clock_timestamp()), pas de l’application.
  • Sessions chaudes : transférez la contention vers Redis Lua et persistez via Kafka, au prix d’une exploitation plus complexe.
  • Fermeture de session : utilisez FOR UPDATE SKIP LOCKED, changement d’état idempotent et événements via Outbox.

Vous devriez commencer par la version PostgreSQL pure. Elle suffit pour la plupart des cas et est facile à vérifier avec des tests de concurrence comme dans la section 5.3. Ce n’est que lorsque les mesures réelles montrent qu’une ligne auction devient un goulot d’étranglement que vous devez transférer ces sessions vers Redis.

system-designconcurrencyspring-bootpostgresqlredis