Aller au contenu
Sylvain Joffraud l4w
Retour

Survente : verrouiller la jauge avec SELECT … FOR UPDATE

Pour éviter la survente de billets avec PostgreSQL, compter les places vendues ne suffit pas : il faut les compter sous verrou. Sur une billetterie événementielle (API NestJS 10.4, Prisma 5.22, Stripe), la capacité d’un événement était saisie par l’organisateur, affichée dans l’app (« 3 restants »)… et vérifiée nulle part : un événement à 50 places pouvait en vendre 500. Le correctif tient en une ligne SQL, SELECT … FOR UPDATE sur l’événement, plus une décision produit sur ce qu’on fait d’un paiement qui arrive trop tard.

Symptôme : une jauge décorative

La jauge est le nombre maximal de billets vendables pour un événement (events.capacity). Elle servait uniquement à l’affichage. Même chose pour le quota par tarif (ticket_tiers.quantity) : l’API l’acceptait, le stockait, et ne le lisait jamais. Un « Early Bird limité à 50 » se vendait sans limite.

Ajouter un count() avant la création des billets ne règle rien. Deux acheteurs qui visent la dernière place exécutent le même comptage au même moment, voient tous les deux « une place libre », et créent chacun leur billet. C’est une course classique lecture puis écriture : la vérification n’a de valeur que si personne ne peut vendre entre le comptage et l’insertion.

Éviter la survente de billets : SELECT … FOR UPDATE sur l’événement

SELECT … FOR UPDATE pose un verrou de ligne : toute autre transaction qui demande le même verrou attend le COMMIT ou le ROLLBACK de la première. Prisma n’expose pas ce verrou dans son API de requêtes, d’où le $queryRaw dans une transaction interactive :

export async function assertSeatsAvailable(tx: Tx, eventId: string, quantity: number) {
  // Verrouille l'événement jusqu'à la fin de la transaction
  const rows = await tx.$queryRaw<{ capacity: number | null }[]>`
    SELECT capacity FROM events WHERE id = ${eventId}::uuid FOR UPDATE`;
  const capacity = rows[0]?.capacity ?? null;

  // Un billet occupe une place s'il n'est ni rejeté, ni annulé, ni remboursé
  const taken = await tx.ticket.count({ where: seatsTakenWhere(eventId) });

  if (capacity && taken + quantity > capacity) {
    throw new SoldOutException(capacity - taken); // 409 Conflict
  }
}

Le second acheteur attend, puis compte une place de moins et reçoit une erreur exploitable par l’app :

{ "statusCode": 409, "code": "SOLD_OUT", "message": "Not enough seats left for this event", "remaining": 0 }

Le quota par tarif réutilise le même verrou : comme tous les achats d’un événement passent déjà par la ligne events, il suffit d’ajouter un second comptage filtré sur tier_id juste après, avec une erreur TIER_SOLD_OUT. Un seul verrou sérialise la jauge globale et tous les tarifs.

Le filtre « places occupées » cache lui-même un piège sur les colonnes NULL en Prisma ; il fera l’objet d’un article à part.

Une vérification de stock n’est une garantie que si le comptage et l’insertion se font dans la même transaction, derrière le même verrou.

Pourquoi un verrou de ligne plutôt qu’un advisory lock ?

Pour les doublons de numéros de facture, j’avais utilisé un advisory lock parce qu’aucune ligne ne représentait la séquence à protéger. Ici, la ligne naturelle existe : l’événement. Verrouiller events suffit, sans clé de hachage à inventer.

Trois chemins créent des billets : la réservation, l’émission de billets gratuits et la délivrance d’un paiement Stripe. Les trois appellent la même garde, dans leur transaction, avant le premier INSERT.

Et si la dernière place part pendant le paiement ?

Avec Stripe, l’achat se fait en deux temps : création du PaymentIntent, puis fulfillment (délivrance des billets) après le succès du paiement. La décision produit : ne pas réserver la place à la création du paiement.

OptionAvantageCoût
Réserver à la création du paiementJamais de remboursementPlaces bloquées par les paniers abandonnés, expiration à gérer
Vérifier sans verrou, trancher au fulfillmentAucune place geléeRare paiement à rembourser

Concrètement, la création du paiement compte les places sans verrou et refuse avant tout débit dans le cas courant. Le fulfillment reprend le verrou FOR UPDATE : si la place est partie entre-temps, il ne crée aucun billet et renvoie soldOut: true. Ce fulfillment est déjà unique et idempotent, comme décrit dans le webhook Stripe et la confirmation client sous double advisory lock.

Reste à rendre l’argent, intégralement, virement vers l’organisateur et frais de plateforme compris :

await stripe.refunds.create(
  {
    payment_intent: intent.id,
    amount: intent.amount,
    // Paiement Connect : reprendre le virement et rendre les frais
    ...(intent.transfer_data ? { reverse_transfer: true, refund_application_fee: true } : {}),
  },
  { idempotencyKey: `soldout:${intent.id}` },
);

La clé d’idempotence dérivée de l’intent compte : la confirmation côté client et le webhook peuvent constater la survente tous les deux. Avec la même clé, Stripe ne crée qu’un seul remboursement (doc idempotence Stripe).

Capacité 0 ou NULL : illimitée, mais acceptée

Une capacité absente ou à zéro signifie vente sans limite : la garde laisse tout passer. Ce choix a un revers, puisqu’un organisateur qui oublie de saisir la jauge vend sans plafond. Une migration a donc ajouté unlimited_capacity_accepted_at et unlimited_capacity_accepted_by sur events : ouvrir une vente sans limite exige une acceptation expresse, horodatée par le serveur.

Lessons learned

Questions fréquentes

Comment éviter la survente de billets avec PostgreSQL quand deux acheteurs prennent la dernière place ?
Il faut compter les places vendues sous verrou. Dans la transaction qui crée les billets, SELECT capacity FROM events WHERE id = $1 FOR UPDATE verrouille la ligne de l’événement ; le second acheteur attend le commit du premier, puis compte une place de moins et reçoit un refus. Sans ce verrou, les deux transactions lisent le même nombre de places libres et vendent toutes les deux.
Prisma permet-il de faire un SELECT FOR UPDATE ?
L’API de requêtes de Prisma 5 n’expose pas de verrou de ligne. On passe par tx.$queryRaw à l’intérieur d’un prisma.$transaction(async (tx) => …) interactif, puis on continue avec le client tx pour compter et créer les billets. Le verrou est libéré au commit ou au rollback de cette transaction, donc tout doit se faire avec tx, jamais avec le client global.
Faut-il réserver la place au moment de créer le PaymentIntent Stripe ?
Pas forcément. Réserver bloque la place pendant que l’acheteur hésite ou abandonne, et impose un mécanisme d’expiration. L’alternative consiste à vérifier la jauge sans verrou à la création du paiement, puis à trancher sous verrou au moment de délivrer les billets : si la place est partie entre-temps, aucun billet n’est créé et le paiement est remboursé intégralement.
Comment éviter de rembourser deux fois un paiement Stripe ?
En passant une clé d’idempotence dérivée de l’identifiant du PaymentIntent, par exemple soldout:pi_…, dans les options de stripe.refunds.create. Si la confirmation côté client et le webhook payment_intent.succeeded constatent tous les deux la survente, Stripe renvoie le même remboursement au second appel au lieu d’en créer un nouveau.

Partager cet article :

Article précédent
mwan3 : le health-check d’usine qui coupe Internet
Article suivant
Migrer Plausible vers Coolify v4 : 5 pièges en 30 min