Conception d'un système de réservation de billets : file d'attente virtuelle
1. Aperçu
Un grand concert ouvre la vente à 10h00. Quelques secondes après, des millions de personnes cliquent sur « Acheter un billet » pour se disputer environ 50.000 sièges. Si on envoie toutes ces requêtes à la base de données, le système s’effondrera. Si on bloque mal, deux personnes peuvent acheter le même siège.
Cet article explore étape par étape la conception : les exigences, le modèle de données, les API et les flux de traitement. L’accent porte sur trois défis : la gestion des pics de charge, la contention sur les sièges et la cohérence lors du paiement. Après la lecture, vous pourrez construire une file d’attente virtuelle avec Redis, un mécanisme pour réserver des sièges sans vendre en double, et un flux de confirmation de commande sûr même en cas d’appels répétés.
2. Énoncé du problème
2.1. Exigences fonctionnelles
- L’utilisateur accède à la page de l’événement et est placé dans une file d’attente virtuelle avant l’ouverture de la vente.
- Le système admet les utilisateurs dans la zone de sélection des sièges par lots, dans l’ordre d’arrivée.
- L’utilisateur choisit jusqu’à 4 sièges. Les sièges sont réservés (hold) pendant 5 minutes pour permettre le paiement.
- Si le paiement réussit, le siège passe à l’état vendu et le billet est émis.
- Si l’expiration arrive avant paiement, le siège est rendu au stock.
2.2. Échelle et contraintes
| Métrique | Valeur estimée |
|---|---|
| Utilisateurs simultanés au moment de l’ouverture | 2.000.000 |
| Nombre de sièges | 50.000 |
| Pic de requêtes pour la page d’événement | ~500.000 RPS |
| Nombre de personnes dans la zone de sélection simultanément | 5.000 |
La contrainte la plus importante est de ne jamais vendre deux fois le même siège. La cohérence ici doit être une strong consistency. En revanche, la position dans la file d’attente n’a besoin que d’être approximativement exacte : afficher « environ 12.000 personnes devant vous » suffit.
À partir de ces deux chiffres, on peut en déduire une observation clé : seuls 2,5 % des utilisateurs obtiendront un billet. Par conséquent, le système doit rejeter ou reporter la plupart des requêtes au niveau le moins coûteux possible, avant qu’elles ne touchent la base de données.
3. Architecture et flux de traitement
Client ──> CDN (page statique, page d'attente)
│
├─> Queue Service ──> Redis (ZSET file d'attente, token d'accès)
│ ▲
│ └── Admitter (scheduled, libère N personnes/sec)
│
└─> Booking Service (nécessite token d'accès)
├─> PostgreSQL (seat, order, outbox)
└─> Kafka (ticket-issued) ──> Ticket/Email Service
Le flux principal se déroule comme suit :
- Le client appelle
POST /events/{id}/queueet reçoit unqueueId. - Le client interroge
GET /events/{id}/queue/{queueId}toutes les quelques secondes pour voir sa position. Quand c’est son tour, il reçoit unaccessToken. - Le client appelle
POST /events/{id}/holdsavec le token et la liste des sièges. - Le client paie. Le prestataire de paiement appelle un webhook, et le système appelle
POST /orders/{id}/confirmavec une clé d’idempotence. - Le Booking Service enregistre la commande et les événements outbox dans la même transaction. Ensuite, l’événement est envoyé à Kafka pour émettre le billet.
La page d’attente est servie statiquement via CDN. Seules les API d’entrée et de sondage vont au backend, et les deux ne touchent que Redis.
4. Modèle de données
CREATE TABLE seat (
event_id BIGINT NOT NULL,
seat_id VARCHAR(16) NOT NULL,
section VARCHAR(16) NOT NULL,
price NUMERIC(12,2) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | SOLD
held_by BIGINT,
hold_expires_at TIMESTAMP,
PRIMARY KEY (event_id, seat_id)
);
CREATE TABLE ticket_order (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
event_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING | PAID | EXPIRED
idempotency_key VARCHAR(64) NOT NULL UNIQUE
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
L’état du siège réside directement sur la table seat. Grâce à cela, la réservation d’un siège ne nécessite qu’une seule instruction UPDATE avec condition. La contrainte UNIQUE sur idempotency_key est la dernière barrière contre la création de commandes dupliquées.
5. File d’attente virtuelle avec Redis
Ajouter la dépendance :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
5.1. Rejoindre la file d’attente
La file d’attente est un Redis Sorted Set. Le score est un numéro de séquence croissant, obtenu via INCR :
@Service
public class QueueService {
private final StringRedisTemplate redis;
public QueueService(StringRedisTemplate redis) {
this.redis = redis;
}
public long join(long eventId, long customerId) {
String queueKey = "queue:" + eventId;
String member = String.valueOf(customerId);
Long existing = redis.opsForZSet().rank(queueKey, member);
if (existing != null) {
return existing;
}
Long seq = redis.opsForValue().increment("queue:seq:" + eventId);
redis.opsForZSet().addIfAbsent(queueKey, member, seq);
return redis.opsForZSet().rank(queueKey, member);
}
public Long position(long eventId, long customerId) {
return redis.opsForZSet().rank("queue:" + eventId, String.valueOf(customerId));
}
}
Explication :
incrementretourne un numéro de séquence global. Vous n’utilisez pas le timestamp comme score car plusieurs requêtes pourraient avoir le même milliseconde.addIfAbsentcorrespond àZADD NX. Même si l’utilisateur appuie continuellement sur F5, il ne sera pas repoussé à la fin de la file.rank(ZRANK) a une complexité O(log N). Avec 2 millions d’éléments, cette commande prend seulement quelques microsecondes.
Trois personnes rejoignant la file d’attente successivement obtiendront les résultats suivants :
join(1, 101) -> 0
join(1, 202) -> 1
join(1, 101) -> 0 (rejoint, conserve sa position)
Remarque : Si vous avez besoin de bloquer les bots, authentifiez l’utilisateur (connexion, captcha) avant de le laisser rejoindre. Une fois dans le ZSET, un bot occupe une place tout comme une vraie personne.
5.2. Admitter : admettre les utilisateurs par lots
Un job s’exécute chaque seconde. Ce job vérifie combien de places restent dans la zone de sélection, puis prend exactement ce nombre de personnes en tête de file :
@Component
public class Admitter {
private static final int MAX_ACTIVE = 5_000;
private static final Duration TOKEN_TTL = Duration.ofMinutes(10);
private final StringRedisTemplate redis;
public Admitter(StringRedisTemplate redis) {
this.redis = redis;
}
@Scheduled(fixedRate = 1000)
public void admit() {
long eventId = 1L;
String activeKey = "active:" + eventId;
long now = System.currentTimeMillis();
redis.opsForZSet().removeRangeByScore(activeKey, 0, now);
long active = Optional.ofNullable(redis.opsForZSet().zCard(activeKey)).orElse(0L);
long slots = MAX_ACTIVE - active;
if (slots <= 0) return;
var batch = redis.opsForZSet().popMin("queue:" + eventId, slots);
if (batch == null) return;
for (var tuple : batch) {
String customerId = tuple.getValue();
String token = UUID.randomUUID().toString();
redis.opsForValue().set("access:" + token, customerId, TOKEN_TTL);
redis.opsForValue().set("token-of:" + eventId + ":" + customerId, token, TOKEN_TTL);
redis.opsForZSet().add(activeKey, customerId, now + TOKEN_TTL.toMillis());
}
}
}
Explication :
active:{eventId}est un ZSET avec un score égal au moment d’expiration.removeRangeByScorenettoie les personnes dont la session a expiré, puiszCardindique le nombre de personnes actuellement dans la zone de sélection.popMin(ZPOPMIN) récupère et supprime les N premières personnes de la file.- Lors du sondage, le client lit
token-of-.... Si la clé existe, cela signifie que c’est son tour et le client reçoit le token.
Vous devez activer @EnableScheduling et exécuter ce job sur exactement une instance, par exemple en utilisant ShedLock ou une élection de leader. Si deux instances l’exécutent simultanément, la zone de sélection peut dépasser MAX_ACTIVE.
Vous n’avez pas besoin de plusieurs Admitter pour traiter 2 millions de personnes. La vitesse à laquelle les utilisateurs sont admis est limitée par la capacité du Booking Service, pas par Redis.
5.3. Bloquer les requêtes sans token
@Component
public class AccessTokenFilter extends OncePerRequestFilter {
private final StringRedisTemplate redis;
public AccessTokenFilter(StringRedisTemplate redis) {
this.redis = redis;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().contains("/holds");
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String token = req.getHeader("X-Queue-Token");
String customerId = token == null ? null : redis.opsForValue().get("access:" + token);
if (customerId == null) {
res.sendError(HttpServletResponse.SC_FORBIDDEN, "Accès refusé");
return;
}
req.setAttribute("customerId", Long.valueOf(customerId));
chain.doFilter(req, res);
}
}
Ce filtre utilise le token comme clé de recherche et ne fait pas confiance au customerId envoyé par le client. L’utilisateur ne peut pas sauter la file en appelant directement l’API /holds.
6. Réserver les sièges : gérer la contention
Plusieurs personnes peuvent choisir le même siège A-12. La façon la plus simple et correcte est de laisser la base de données décider avec une instruction UPDATE conditionnelle :
@Service
public class SeatHoldService {
private static final Duration HOLD_TTL = Duration.ofMinutes(5);
private final JdbcTemplate jdbc;
public SeatHoldService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public void hold(long eventId, long customerId, List<String> seatIds) {
if (seatIds.size() > 4) throw new IllegalArgumentException("Maximum 4 sièges");
Timestamp now = Timestamp.from(Instant.now());
Timestamp expires = Timestamp.from(Instant.now().plus(HOLD_TTL));
for (String seatId : seatIds.stream().sorted().toList()) {
int updated = jdbc.update("""
UPDATE seat SET status = 'HELD', held_by = ?, hold_expires_at = ?
WHERE event_id = ? AND seat_id = ?
AND (status = 'AVAILABLE' OR (status = 'HELD' AND hold_expires_at < ?))
""", customerId, expires, eventId, seatId, now);
if (updated == 0) {
throw new SeatUnavailableException(seatId);
}
}
}
}
class SeatUnavailableException extends RuntimeException {
SeatUnavailableException(String seatId) { super("Siège indisponible : " + seatId); }
}
Explication :
- La clause
WHEREvérifie et met à jour dans une seule instruction atomique. La base de données verrouille la ligne. Une requête ultérieure attend, puis réévalue la condition et voitstatus = 'HELD', donc reçoitupdated = 0. - La condition
hold_expires_at < ?permet de reprendre un siège dont la réservation a expiré sans attendre un job de nettoyage. - Si une seule place du groupe échoue, l’exception annule toute la transaction. L’utilisateur ne sera jamais laissé avec « demi-groupe » de sièges.
- Le tri de
seatIdsavant la mise à jour garantit que chaque transaction verrouille dans le même ordre, évitant les deadlocks quand deux personnes choisissent les mêmes sièges dans des ordres différents, par exemple {A-1, A-2} et {A-2, A-1}.
Remarque : Ne faites pas du « SELECT pour vérifier l’état puis UPDATE » sans verrouillage. C’est la race condition classique qui entraîne la vente en double de sièges.
6.1. Test du comportement anti-double-vente
On crée 50 threads qui rivalisent pour le même siège. Le résultat attendu est qu’un seul thread réussisse.
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
@JdbcTest
@Import(SeatHoldService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class SeatHoldServiceTest {
@Autowired JdbcTemplate jdbc;
@Autowired SeatHoldService service;
@Test
void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
jdbc.update("DELETE FROM seat");
jdbc.update("INSERT INTO seat(event_id, seat_id, section, price) VALUES (1, 'A-12', 'VIP', 3000000)");
AtomicInteger success = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (long customer = 1; customer <= 50; customer++) {
long id = customer;
executor.submit(() -> {
try {
service.hold(1L, id, List.of("A-12"));
success.incrementAndGet();
} catch (SeatUnavailableException ignored) { }
});
}
}
assertThat(success.get()).isEqualTo(1);
assertThat(jdbc.queryForObject(
"SELECT status FROM seat WHERE seat_id = 'A-12'", String.class)).isEqualTo("HELD");
}
}
La table seat est créée à partir de src/test/resources/schema.sql. Vous utilisez la même instruction CREATE TABLE seat de la section 4, en remplaçant BIGSERIAL par BIGINT AUTO_INCREMENT si vous créez les autres tables. @Transactional(NOT_SUPPORTED) désactive la transaction englobant le test, pour que les données soient commitées et visibles par les autres threads.
7. Paiement et cohérence
Le webhook du prestataire de paiement peut être appelé plusieurs fois. Par conséquent, l’étape de confirmation de la commande doit être idempotente :
@Transactional
public void confirm(long orderId, long customerId, long eventId, String idempotencyKey) {
int inserted = jdbc.update("""
INSERT INTO ticket_order(id, customer_id, event_id, status, idempotency_key)
VALUES (?, ?, ?, 'PAID', ?) ON CONFLICT (idempotency_key) DO NOTHING
""", orderId, customerId, eventId, idempotencyKey);
if (inserted == 0) return; // déjà traité précédemment
int sold = jdbc.update("""
UPDATE seat SET status = 'SOLD'
WHERE event_id = ? AND held_by = ? AND status = 'HELD'
""", eventId, customerId);
if (sold == 0) throw new IllegalStateException("Réservation expirée, remboursement nécessaire");
jdbc.update("INSERT INTO outbox(topic, payload) VALUES ('ticket-issued', ?)",
"{\"orderId\":" + orderId + "}");
}
Ce code gère trois choses :
ON CONFLICT DO NOTHING(syntaxe PostgreSQL) garantit que les appels de webhook répétés ne créent pas deux commandes.- Le siège ne bascule à
SOLDque s’il est toujours réservé par ce client. Si la réservation a expiré et le siège appartient à quelqu’un d’autre, la transaction s’annule. La commande doit alors être dirigée vers le flux de remboursement. - Transactional outbox : l’événement est écrit dans la même transaction que la commande. Un processus séparé (ou Debezium) lit la table
outboxpuis envoie vers Kafka. Cela garantit qu’il n’y a jamais de cas où une commande est enregistrée mais le billet n’est pas émis, ou vice versa.
Remarque : Définissez le TTL de la réservation un peu plus long que le timeout de la page de paiement, par exemple réservation 5 minutes et page de paiement 4 minutes. Cette marge réduit les cas où le client a payé mais la réservation vient juste d’expirer.
8. Défis et erreurs courantes
-
Pics de charge au premier niveau : la page d’attente doit être une page statique sur CDN. L’API de sondage doit retourner un en-tête suggérant l’intervalle de sondage, par exemple 5–15 secondes selon la position. Si le client sonde chaque seconde, 2 millions de personnes généreront 2 millions RPS vers Redis.
-
Redis est un point de défaillance unique : utilisez Redis Cluster ou Sentinel. Si vous n’avez qu’un grand événement, tout le ZSET réside sur une clé, donc sur un shard. Un seul nœud Redis peut gérer des centaines de milliers d’ops/s, mais vous devez mesurer avant.
-
Affichage inexact du nombre de sièges restants : le nombre de sièges disponibles sur l’interface peut être lu à partir d’un cache et accepter un léger décalage. Seule l’instruction UPDATE de la section 6 est la source de vérité.
-
Réservations orphelines : l’utilisateur réserve des sièges puis ferme l’onglet. La condition d’expiration dans l’UPDATE gère la reprise des sièges. Vous avez juste besoin d’un job périodique pour retourner les sièges expirés à
AVAILABLEafin que l’interface affiche correctement. -
Quand vous n’avez PAS besoin d’une file d’attente virtuelle : pour un événement de quelques centaines de sièges et un trafic modéré, la limitation de débit associée à un UPDATE conditionnel suffit. Une file d’attente virtuelle ajoute de la complexité opérationnelle et ralentit l’expérience utilisateur. Ne l’utilisez que si le nombre de personnes voulant acheter dépasse largement la capacité du service de réservation.
9. Conclusion
Le système de vente de billets supporte bien la charge grâce à la séparation de deux préoccupations. La file d’attente virtuelle sur Redis absorbe les pics de charge et maintient l’équité. La base de données traite seulement le petit nombre de personnes admises et garantit aucune double-vente via UPDATE conditionnel.
Vous avez vu comment utiliser ZADD NX et ZPOPMIN pour mettre en file d’attente et admettre par lots, comment réserver des sièges de manière atomique et éviter les deadlocks, et comment confirmer une commande de manière idempotente avec transactional outbox pour envoyer des événements à Kafka. À partir de ce cadre, vous pouvez étendre avec la prévention de bots, les files d’attente prioritaires pour les membres, ou plusieurs événements en parallèle.