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_priceetleader_idsont dénormalisés dansauction. Ainsi, vérifier une enchère ne demande que la lecture d’une seule ligne au lieu d’unSELECT MAX(amount)sur la tablebid.- L’index partiel
idx_auction_open_endne 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
auctionlors 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 clauseWHEREsur la version la plus récente de la ligne. L’enchère 1.050 rencontre le prix 1.100, la condition échoue, l’instruction retourne0. - 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 :
1pour accepter,0pour prix trop bas,-1pour session fermée. - Le temps est tiré de la commande
TIMEde Redis pour que toutes les instances partagent une horloge unique. En pratique, vous pouvez appelerredis.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 everysecoualways, 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 LOCKEDpermet 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 LOCKEDignore cette ligne. La prochaine exécution vérifiera à nouveau, au cas oùend_timeaurait été prolongé. - Le changement de statut
OPEN → CLOSEDne 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’innombrablesOptimisticLockExceptionet boucles de retry. La mise à jour conditionnelle n’a besoin de retry car leWHEREest réévalué automatiquement. - Utiliser
SELECT ... FOR UPDATEpuis 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 ... WHEREatomique, 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.