Un stade compte 60 000 places. Quand une grande tournée est mise en vente, des millions de personnes arrivent dans la même minute, et toutes veulent les mêmes quelques milliers de bonnes places. En novembre 2022, Ticketmaster a fait état de 3,5 milliards de requêtes système au total pendant la prévente de l'Eras Tour, soit quatre fois son précédent pic. Stocker 60 000 lignes est trivial. Arbitrer équitablement des millions de requêtes concurrentes pour ces lignes, sans jamais vendre deux fois la même place, voilà la partie difficile.
Cet article conçoit un système de billetterie pour ce type d'ouverture des ventes : exigences, forme du trafic, salle d'attente virtuelle, blocage de places avec expiration, transaction de réservation, paiements, places convoitées et bots, avec un diagramme d'architecture.
Exigences
Fonctionnelles :
- Parcourir les événements et un plan de salle avec les disponibilités.
- Choisir des places et les bloquer pendant le paiement.
- Payer, recevoir les billets et libérer les places si le paiement n'aboutit pas.
Non fonctionnelles :
- Ne jamais vendre une place deux fois. C'est la seule règle qui ne peut pas plier.
- Traiter les gens équitablement : ordre d'arrivée ou tirage au sort publié, pas celui qui rafraîchit le plus vite.
- Tenir un trafic d'un ou deux ordres de grandeur au-dessus de la normale pendant les premières minutes d'une ouverture des ventes.
- Échouer vite et clairement : un « complet » rapide vaut mieux qu'une page qui tourne sans fin.
La forme d'une ouverture des ventes
Supposons 60 000 places et 2 millions de personnes qui arrivent dans les dix minutes suivant l'ouverture. Cela fait environ 3 300 nouvelles arrivées par seconde, et plus de 30 personnes par place. Aucune capacité ne permettra de donner un billet à tout le monde. Le vrai travail du système consiste à laisser acheter un nombre soutenable de personnes à la fois, à dire non rapidement aux autres et à garder l'inventaire juste sous une concurrence extrême.
La conception se divise donc en deux parties : une porte qui absorbe la foule, et un cœur de réservation qui ne voit jamais plus d'acheteurs qu'il ne peut en gérer.
La salle d'attente virtuelle
Placez une file d'attente devant le parcours de réservation :
- Les fans arrivés avant l'ouverture reçoivent une position aléatoire parmi tous les arrivants précoces, si bien qu'ouvrir cent onglets à 9:59 ne sert à rien. Les arrivants suivants sont placés dans l'ordre.
- La salle d'attente est une page statique servie par un CDN, si bien que des millions de navigateurs en attente ne coûtent presque rien.
- Les fans sont admis par lots, à un rythme calé sur la capacité de réservation mesurée, par exemple 2 000 par minute.
- Chaque fan admis reçoit un jeton signé de courte durée, lié à sa session. L'API de réservation rejette les requêtes sans jeton valide.
Des produits managés comme Cloudflare Waiting Room et Queue-it le font. Une version maison est un sorted set Redis avec les heures d'arrivée comme scores, où la position est le rang du membre :
ZADD queue:evt42 1793606400123 session:9f2c # score = arrival time in ms
ZRANK queue:evt42 session:9f2c # position, 0-based
ZPOPMIN queue:evt42 2000 # admit the next batch
Pour le groupe des arrivants précoces, attribuez des scores aléatoires au lieu des heures d'arrivée. Dans les deux cas, la file transforme un pic illimité en un flux régulier pour lequel le reste du système est dimensionné.
Le modèle d'inventaire
Le placement numéroté nécessite une ligne par place :
| Colonne | Exemple | Rôle |
|---|---|---|
event_id, seat_id |
evt42, S112-R7-14 |
Identité ; uniques ensemble |
status |
available, held, sold |
État actuel |
hold_id, held_until |
h_81f, horodatage |
Qui la bloque et jusqu'à quand |
order_id |
ord_5521 |
Renseigné à la vente |
Le placement libre est plus simple : un compteur par zone tarifaire, décrémenté par une mise à jour conditionnelle, au lieu de places individuelles.
Bloquer des places avec expiration
Un blocage réserve des places à un acheteur pendant quelques minutes, le temps qu'il paie. L'endroit le plus sûr pour les blocages est la base de données qui enregistre les ventes, avec une mise à jour conditionnelle :
UPDATE seats
SET status = 'held', hold_id = $3, held_until = now() + interval '10 minutes'
WHERE event_id = $1
AND seat_id = ANY($2)
AND (status = 'available' OR (status = 'held' AND held_until < now()));
-- If fewer rows changed than seats requested, roll back: someone else got one.
Deux acheteurs peuvent envoyer cette requête pour la même place à la même milliseconde. La base de données sérialise les mises à jour sur cette ligne : l'une la modifie, l'autre voit zéro ligne modifiée et reçoit une réponse claire, « place tout juste prise ». Les blocages expirés sont récupérés dans la même requête, si bien que l'expiration d'un blocage ne dépend jamais d'une tâche de fond qui tournerait à l'heure. Deux requêtes portant sur des ensembles de places qui se chevauchent peuvent parfois provoquer un deadlock ; la base le détecte et en annule une, donc réessayez une fois en cas d'erreur de deadlock.
À très grande échelle, certaines équipes placent un filtre rapide devant : SET seat:evt42:S112-R7-14 h_81f NX PX 600000 dans Redis, qui ne crée la clé que si elle est absente, avec une expiration de dix minutes. Cela décharge la base de données, mais la réplication Redis est asynchrone, si bien qu'un failover peut perdre des blocages. Traitez Redis comme une optimisation ; c'est toujours la base de données qui décide.
La dernière ligne de défense est une contrainte d'unicité sur (event_id, seat_id) dans la table des billets. Même si chaque couche au-dessus a un bug, la base de données refuse d'émettre le second billet.
Places convoitées et meilleure place disponible
Tout le monde veut les premiers rangs : des milliers de requêtes visent les mêmes quelques centaines de lignes, et la plupart échouent. Trois techniques réduisent cette concurrence d'accès :
- Meilleure place disponible. Au lieu de laisser chacun choisir sur le plan, laissez les acheteurs choisir une section et une quantité, et attribuez les places côté serveur avec
SELECT … FOR UPDATE SKIP LOCKED, pour que les requêtes concurrentes prennent des lignes différentes au lieu de faire la queue sur les mêmes. - Partitionnez l'inventaire par section, pour que des sections différentes verrouillent des lignes différentes et puissent vivre sur des shards de base de données différents.
- Mettez le plan de salle en cache avec une expiration courte, et acceptez qu'il ait quelques secondes de retard. C'est le blocage, pas le plan, qui fait foi pour la disponibilité.
Payer sans double débit
- Créez une commande à l'état
pendingliée au blocage. - Appelez le prestataire de paiement avec une clé d'idempotence dérivée de l'identifiant de commande. Chez Stripe, par exemple, les clés d'idempotence font jusqu'à 255 caractères, et une requête relancée avec la même clé renvoie le résultat initial au lieu de débiter à nouveau.
- En cas de succès, dans une seule transaction de base de données, marquez les places comme vendues, rattachez-les à la commande et marquez la commande comme payée.
- Écoutez les webhooks du prestataire pour détecter le cas où le paiement a réussi sans que votre service ait jamais vu la réponse.
L'expiration du blocage ne doit pas entrer en concurrence avec le paiement. Prolongez le blocage quand le paiement commence, ou excluez de l'expiration les places liées à une commande en attente. Sinon, une place peut expirer, être vendue à quelqu'un d'autre, puis voir son premier paiement réussir. Le diagramme d'intégration d'une passerelle de paiement détaille les webhooks et le rapprochement.
Libérer les blocages expirés
Les blocages expirent de façon paresseuse, via la condition held_until < now() ci-dessus, et de façon active, via un balayeur qui tourne toutes les quelques secondes et remet les blocages expirés à disponible pour que le plan de salle se mette à jour. Ne bâtissez pas l'expiration sur les notifications de keyspace de Redis : Redis documente son Pub/Sub comme « fire and forget », donc un abonné qui se déconnecte manque des événements.
Bots et abus
La salle d'attente est la principale défense, car elle rend la vitesse inutile. Ajoutez des limites d'achat par compte, des limites de débit par compte, IP et appareil, une inscription avant l'ouverture des ventes pour les événements très demandés, et des défis dans la salle d'attente. La conception d'un rate limiter traite de la limitation elle-même.
Le diagramme de la billetterie
Contenu du diagramme
- Fans
- Waiting room (static, on CDN)
- Queue positions + admissions
- API gateway: token check
- Booking service
- Seats, holds, orders
- Hold sweeper, every 5 s
- Payment provider
- order.confirmed events
- Ticket delivery
- arrive at 10:00
- ZADD / ZRANK
- batch + signed token
- admitted?
- hold seats, pay
- hold: conditional UPDATE, 10 min
- release expired holds
- charge + Idempotency-Key
- webhook
- order paid
- send tickets
Comment le lire :
- Les fans arrivent dans la salle d'attente sur le CDN, qui les retient et les admet par lots avec des jetons signés.
- La passerelle API et le service de réservation ne voient que les fans admis. Redis stocke les positions dans la file et l'état des admissions.
- Postgres détient les places. La mise à jour conditionnelle crée les blocages, et la contrainte d'unicité protège les ventes.
- Le prestataire de paiement est appelé avec une clé d'idempotence, et ses webhooks reviennent au service de réservation.
- La fonction de balayage libère les blocages expirés ; la file des commandes alimente l'envoi des billets par e-mail et dans l'application.
- Le bloc Markdown liste les chiffres de l'ouverture des ventes et les règles de blocage.
Dessiner une billetterie pour que les relecteurs trouvent les conditions de concurrence
- Dessinez la porte séparément du cœur. La salle d'attente et le service de réservation ont des modèles de montée en charge et des responsables différents.
- Écrivez la durée de blocage et la règle d'unicité sur le diagramme. Ces deux faits expliquent la conception.
- Placez la clé d'idempotence sur l'arête du paiement. C'est elle qui rend les nouvelles tentatives sûres.
- Montrez le chemin d'expiration. C'est là où les places reviennent que se cachent beaucoup de bugs de double vente.
Pour la méthode générale, voyez l'exemple de diagramme de conception de système. Les événements de commande peuvent être publiés avec le pattern transactional outbox, et les e-mails de billets via un pipeline comme celui de la conception d'un système de notifications.
Le construire dans Archboard
- Connectez-vous avec Google ou GitHub et créez un diagramme.
- Ajoutez une Personne du pack Modèle C4 pour les fans et un nœud CDN du pack Cloud pour la salle d'attente.
- Ajoutez une Passerelle API et un nœud Instance de calcul du pack Cloud pour le service de réservation.
- Ajoutez un nœud Redis du pack Données pour l'état de la file et un nœud Postgres pour les places, les blocages et les commandes.
- Ajoutez un Système externe du pack Modèle C4 pour le prestataire de paiement, une Fonction du pack Cloud pour le balayeur des blocages, une File d'attente pour les événements de commande et un autre nœud Instance de calcul pour l'envoi des billets.
- Étiquetez les arêtes avec les jetons, les règles de blocage et la clé d'idempotence, et ajoutez un bloc Markdown du pack Base avec les chiffres.
L'exemple utilise 11 nœuds de diagramme et reste dans les limites de l'offre Free.
FAQ
Comment les billetteries évitent-elles la double réservation ? Avec une mise à jour conditionnelle sur la ligne de la place, pour qu'une seule requête puisse la faire passer de disponible à bloquée, et une contrainte d'unicité sur la table des billets comme ultime garde-fou. Les caches et les files réduisent la charge, mais c'est la base de données qui décide.
Combien de temps doit durer le blocage d'une place ? Assez longtemps pour payer confortablement, et assez peu pour que les places abandonnées reviennent vite. Cinq à dix minutes, c'est une fourchette courante. Prolongez le blocage quand le paiement commence, pour que l'expiration ne puisse pas entrer en concurrence avec un débit réussi.
Qu'est-ce qu'une salle d'attente virtuelle ? Une file placée devant le parcours de réservation, qui admet les utilisateurs à un rythme que le système peut absorber. C'est généralement une page statique sur un CDN, avec des positions attribuées au hasard pour les arrivants précoces, puis dans l'ordre.
Les blocages de places doivent-ils vivre dans Redis ou dans la base de données ? La base de données doit être la source de vérité, car la réplication Redis est asynchrone et un failover peut perdre des clés. Redis fonctionne bien comme filtre rapide placé devant, tant que la base de données continue d'imposer l'unicité.
Comment gérer un paiement qui réussit après l'expiration du blocage ? Évitez-le en prolongeant les blocages pendant le paiement. Si cela arrive quand même, remboursez automatiquement ou proposez une place équivalente, et utilisez les webhooks du prestataire pour le détecter.