iGamingSep 11, 2026Lecture 10 min

Postgres de sportsbook aux pics de match : lignes chaudes, retard de règlement et prise de pari qui n'attend pas

Ingénierie de sportsbookPostgreSQLRèglement des parisIdempotence
Erreur de chargement de l'image

Un sportsbook dont le Postgres ralentit pendant les grands matchs et règle les paris longtemps après la fin de l'événement fait passer deux charges de travail par un même ensemble de lignes : la prise de pari, une écriture courte par requête, et le règlement, une rafale déclenchée par un seul résultat. Voici comment les deux chemins se séparent, d'où vient la contention, et comment les soldes restent corrects pendant que le règlement prend du retard.

Un sportsbook dont le Postgres devient le goulot d'étranglement pendant un grand match a en général un symptôme et deux causes. La prise de pari et le règlement se disputent les mêmes lignes, les mêmes verrous et les mêmes connexions précisément au pic du trafic, et le règlement tourne comme un travail qui retient ces lignes plutôt que comme une file qui peut attendre son tour.

Plus de matériel relève le niveau de trafic auquel cela se produit, sans supprimer la cause. Ce qui suit sépare les deux chemins, localise la contention et garde les soldes corrects pendant que le règlement prend du retard. Le comportement de PostgreSQL cité ci-dessous provient de la documentation de la version 18.

La réponse courte est structurelle. La prise de pari et le règlement cessent de partager des transactions : la prise de pari écrit le pari, une réservation de solde et une ligne d'outbox dans une seule transaction courte sous une clé d'idempotence, et le règlement consomme les événements de résultat par petits batches dont les effets portent eux aussi une clé, si bien qu'un message livré à nouveau ne déplace pas d'argent. Ce qu'amBrain peut étayer publiquement, c'est l'ingénierie de plateformes de casino, et un chiffre que nous y publions comme mesuré est celui de 12 opérateurs en production. La conception ci-dessous vient de la mécanique du problème, pas d'un cas à nous, et aucun chiffre qu'elle contient n'est mesuré sur un système à nous.

La prise de pari et le règlement sont deux charges de travail qui partagent des lignes

La prise de pari est une requête avec une personne qui attend : lire l'état du marché, vérifier un solde, écrire un pari, répondre. Le règlement part d'un seul résultat et s'étend d'un coup à chaque pari en cours sur les marchés concernés. Un grand match se termine alors que d'autres événements sont encore en cours : cette rafale tombe donc sur les lignes de solde de comptes qui parient à nouveau.

Si les deux chemins écrivent ces lignes dans leurs propres transactions, la latence de prise de pari devient une fonction de la plus longue transaction de règlement sur le même compte. Le découplage est un ensemble de promesses sur les verrous et le temps :

  • Le règlement ne retient pas une ligne de solde plus longtemps que ne le fait une transaction de prise de pari
  • Le règlement peut prendre du retard, et son backlog est une file qui a un âge plutôt qu'une pile de transactions ouvertes
  • Chaque effet sur l'argent se produit une seule fois, quel que soit le nombre de livraisons du message qui le porte
  • Les lectures qui n'ont pas besoin du primaire n'y touchent pas

Une ligne de solde est un verrou, que vous l'ayez conçue ainsi ou non

Le chapitre de PostgreSQL sur le verrouillage indique que les verrous au niveau ligne ne bloquent que ceux qui écrivent ou verrouillent la même ligne, pas ceux qui la lisent, et qu'une transaction qui cherche à obtenir un verrou attend indéfiniment, sauf si un interblocage est détecté. Une ligne par compte, mise à jour à chaque prise de pari, est donc une file, et à juste titre : le verrou empêche deux prises de pari de dépenser le même argent. Ce qui compte, c'est combien de temps chaque détenteur le garde.

Read Committed, le niveau d'isolation par défaut, garde la réservation simple. Un UPDATE qui trouve une ligne déjà mise à jour par une transaction concurrente attend que celle-ci fasse un commit ou un rollback et, si elle a fait un commit, réévalue sa clause WHERE sur la version mise à jour. Une mise à jour conditionnelle qui ne soustrait le montant que là où le solde disponible le couvre ne peut pas survendre et n'a besoin d'aucun SELECT FOR UPDATE.

  • Prenez le verrou du solde en dernier et faites le commit juste après ; la validation qui n'a besoin d'aucun verrou s'exécute d'abord
  • Verrouillez plusieurs comptes dans un seul ordre cohérent, ce que le chapitre sur le verrouillage donne comme moyen d'éviter les interblocages
  • Ne gardez pas l'exposition d'un marché sur une seule ligne que chaque prise de pari met à jour, sinon un marché populaire sérialise ses prises de pari derrière un seul verrou ; répartissez le compteur sur un ensemble fixe de lignes
  • Réglez lock_timeout sur le chemin de prise de pari, pour qu'une attente indéfinie devienne une erreur comptée, retentée sous la même clé d'idempotence
  • Gardez les colonnes de solde hors des index : le chapitre sur le stockage n'autorise une mise à jour HOT que lorsqu'aucune colonne indexée ne change et que la page qui contient l'ancienne ligne a de la place, ce qu'un fillfactor plus bas rend plus probable

Les transactions longues et l'autovacuum gardent la rafale sur disque

Un règlement qui marque chaque pari en cours d'un marché en une seule instruction conserve ces verrous de ligne jusqu'au commit et laisse une version morte de chaque ligne. Le chapitre sur le VACUUM indique qu'une ancienne version ne doit pas être supprimée tant que d'autres transactions peuvent encore la voir : un règlement long, ou un rapport resté inactif dans une transaction, garde donc toute la rafale sur disque.

L'autovacuum arrive en retard par conception. PostgreSQL 18 passe un VACUUM sur une table dès que les lignes mises à jour ou supprimées depuis le dernier VACUUM dépassent la plus petite de ces deux valeurs : autovacuum_vacuum_max_threshold d'une part, autovacuum_vacuum_threshold plus autovacuum_vacuum_scale_factor multiplié par le nombre de lignes d'autre part. Avec les valeurs par défaut, 100 000 000, 50 et 0.2, une table de paris de 50 millions de lignes attend environ dix millions de lignes mises à jour ou supprimées.

  • Surchargez ces seuils table par table sur les soldes et les paris en cours, ce que le chapitre sur le VACUUM permet via les paramètres de stockage
  • Réglez idle_in_transaction_session_timeout, dont la documentation avertit qu'une transaction ouverte empêche le VACUUM des tuples morts récents et peut contribuer à la fragmentation des tables (bloat)
  • Ajoutez des lignes de règlement plutôt que de basculer une colonne de statut indexée, car une mise à jour qui modifie une colonne indexée ne peut pas être HOT
  • Retirez l'historique en détachant ou en supprimant des partitions, ce que le chapitre sur le partitionnement décrit comme bien plus rapide qu'une opération de masse et exempt du surcoût de VACUUM d'un DELETE de masse

Les tables de file d'attente rendent l'effet facile à voir. Dans un billet de 2015 sur brandur.org, « Postgres Job Queues & Failure By MVCC », une transaction laissée inactive à côté d'une file de jobs a fait passer le temps de verrouillage d'un job de moins de 0,01 seconde à des pics de 15 fois ce niveau, parce que les lignes de jobs mortes ne pouvaient pas encore être supprimées.

Les connexions et les réplicas relèvent du même pic

Chaque connexion est un processus backend, et la documentation indique qu'augmenter max_connections, typiquement 100 par défaut, augmente les ressources dimensionnées à partir de ce paramètre, y compris la mémoire partagée. Donnez plutôt des pools séparés à la prise de pari et au règlement, pour qu'un backlog de règlement attende ses propres connexions.

  • Le pooling en mode transaction de PgBouncer n'attribue une connexion serveur que pour la durée d'une transaction : de nombreux clients se partagent ainsi moins de backends
  • Les fonctionnalités de session cassent dans ce mode : PgBouncer liste SET et RESET, LISTEN, les curseurs WITH HOLD et les advisory locks au niveau session comme non pris en charge
  • Les requêtes préparées nommées au niveau du protocole y fonctionnent depuis PgBouncer 1.21.0, sorti en octobre 2023, quand max_prepared_statements est non nul

Les réplicas soulagent les lectures, moyennant deux coûts. La réplication en flux est asynchrone par défaut : un commit devient donc visible sur le standby après un petit délai. Et le chapitre Hot Standby indique que les requêtes sur le standby qui entrent en conflit avec le nettoyage VACUUM venu du primaire sont annulées après un délai configuré, tandis que hot_standby_feedback l'empêche en retardant le nettoyage sur le primaire, ce qui peut y provoquer de la fragmentation de table (bloat).

La prise de pari est une seule transaction courte, dotée d'une clé avant le premier retry

Concevez la prise de pari à rebours depuis sa défaillance : un client tombe en timeout et réessaie, et le retry doit recevoir le premier résultat, pas créer un second pari.

  • Le client, ou l'edge qui reçoit la requête en premier, crée une clé d'idempotence par soumission, et chaque retry la porte inchangée
  • Une seule transaction écrit l'enregistrement du pari, la réservation sous forme de mise à jour conditionnelle du solde, et une ligne d'outbox pour le pari accepté
  • Les enregistrements de paris sont en append-only : règlement, annulations et corrections sont de nouvelles lignes qui font référence au pari, jamais des modifications
  • Une contrainte d'unicité transforme un retry en conflit : INSERT avec ON CONFLICT DO NOTHING n'insère rien, RETURNING ne renvoie que les lignes insérées, et le chemin relit le résultat stocké
  • Stripe documente le même contrat pour son API : le premier résultat pour une clé est enregistré et renvoyé aux requêtes suivantes, qu'il s'agisse d'un succès ou d'un échec, et une clé réutilisée avec des paramètres différents est rejetée

Partitionnez le stockage par le temps et le travail par marché. Le chapitre sur le partitionnement exige qu'une contrainte d'unicité sur une table partitionnée inclue toutes les colonnes de la clé de partitionnement : la clé d'idempotence soit porte donc la colonne de partitionnement, soit vit dans sa propre table. Il indique aussi que le planificateur gère assez bien jusqu'à quelques milliers de partitions quand les requêtes les élaguent toutes sauf quelques-unes, et le nombre de marchés n'est pas borné : des partitions par marché mettent donc du temps de planification sur le chemin de prise de pari.

La ligne d'outbox rend l'événement fiable. Dans le pattern transactional outbox tel que le décrit Chris Richardson, le message est stocké dans la base de données au sein de la transaction qui met à jour les entités métier, et un processus séparé le transmet ensuite. La même description en nomme le coût : le relais peut publier un message plus d'une fois, donc les consommateurs doivent être idempotents.

Le règlement est une file qui a le droit d'être en retard

Dès qu'un résultat arrive, le règlement est un backlog qui a un âge, et rien dans ce backlog ne retient une ligne attendue par la prise de pari plus longtemps qu'un batch :

  • L'ordre par marché, pas global : Kafka écrit les événements de même clé dans la même partition et documente que les consommateurs lisent une partition dans l'ordre d'écriture, si bien que les événements de résultat dont la clé est le marché restent dans l'ordre
  • Une table de file d'attente fonctionne dans certaines limites : la documentation juge SKIP LOCKED inadapté à un usage général, mais utilisable pour éviter la contention sur les verrous entre les consommateurs d'une table qui sert de file d'attente
  • Chaque batch règle un nombre borné de paris, passe leurs écritures au grand livre, met à jour les lignes de solde dans l'ordre des comptes et fait son commit
  • La progression est commitée avec les effets : un worker qui meurt en plein batch reprend depuis son dernier batch commité
  • Un résultat corrigé est un nouvel événement : des écritures d'extourne, puis de nouvelles écritures de règlement, jamais des modifications des anciennes

Le règlement a le droit d'être en retard. Il n'a pas le droit de se produire deux fois. La prise de pari n'a droit ni à l'un ni à l'autre, et c'est pourquoi les deux ne peuvent pas partager une transaction.

Les soldes ont besoin de deux montants et d'écritures qui arrivent une seule fois

Une seule colonne de solde ne peut pas décrire un pari accepté et pas encore réglé. Gardez deux montants par compte, disponible et réservé, et ne déplacez l'argent entre eux que par des écritures au grand livre qui portent chacune une clé :

  • La prise de pari fait passer le montant du disponible au réservé dans sa mise à jour conditionnelle
  • Le règlement libère la réservation et passe le débit final et l'éventuel crédit dans une seule transaction, avec une clé formée du pari, du type d'écriture et de la version de règlement
  • Une annulation libère la réservation, et une réservation dont le règlement n'arrive jamais a un propriétaire nommé et une échéance
  • La ligne de solde est une projection du grand livre, et une réconciliation planifiée qui additionne les écritures par compte signale la dérive comme un incident au lieu de la corriger discrètement

La livraison peut se répéter : le relais de l'outbox peut republier, et quand l'outbox est lue par décodage logique, la documentation indique qu'un slot peut renvoyer des changements récents après un crash. L'exigence est donc un effet qui se produit une seule fois. Chaque écriture au grand livre a une clé unique, la mise à jour du solde est commitée avec l'insertion, et un message livré à nouveau bute sur la contrainte et ne déplace pas d'argent.

L'historique des paris relève d'un modèle de lecture, pas du chemin d'écriture

Au pic, beaucoup de lectures se situent à côté de la prise de pari plutôt que sur elle : paris en cours, historique, écrans de solde rafraîchis après chaque événement. La description du CQRS par Chris Richardson sert ces requêtes depuis une base de vues tenue à jour par abonnement aux événements du service qui possède les données, et désigne comme coût le retard de réplication et des vues à cohérence à terme. L'outbox de la prise de pari publie déjà ces événements.

  • La réponse de prise de pari renvoie le pari accepté : le client l'affiche sans le relire depuis une vue qui peut être en retard
  • Les écrans qui ont besoin de l'état le plus récent lisent explicitement depuis le primaire, et cette liste reste courte
  • synchronous_commit réglé sur remote_apply fait attendre chaque commit jusqu'à ce que les standbys synchrones l'aient rejoué : du read-your-writes sur le réplica, payé en latence de prise de pari

Ce qu'il faut mesurer pendant que le match se joue encore

Faites les relevés pendant le pic, sur un même axe de temps que la latence de prise de pari :

  • Attentes de verrou : échantillonnez pg_stat_activity sur le type d'événement d'attente Lock et trouvez les bloqueurs avec pg_blocking_pids, dont la documentation avertit qu'elle peut affecter les performances si elle est appelée souvent
  • log_lock_waits est désactivé par défaut et ne signale que les attentes plus longues que deadlock_timeout, une seconde par défaut : le log ne montre donc aucune des attentes plus courtes
  • La plus ancienne transaction, d'après xact_start dans pg_stat_activity, et chaque session idle in transaction
  • Le nettoyage des tables chaudes : n_dead_tup, last_autovacuum, et n_tup_hot_upd face à n_tup_upd
  • La pression sur les pools : cl_waiting et maxwait issus de SHOW POOLS, où PgBouncer interprète un maxwait qui monte comme un pool qui ne suit pas
  • Le backlog de règlement exprimé en âge, car un décompte ne distingue pas une grosse file d'une file bloquée
  • replay_lag par standby, et wal_status et safe_wal_size pour les slots de réplication logique

Lus ensemble, ils localisent la panne. Une file de pool qui grossit avec des attentes de verrou stables désigne les connexions ; des attentes de verrou qui montent avec les batches de règlement désignent les lignes partagées ; si ni l'une ni l'autre ne bouge pendant que les lignes mortes grimpent, c'est la plus ancienne transaction qui est désignée.

Reconnaître les prestataires d'ingénierie qui font vraiment ce travail

La seconde moitié de la question, quelles sociétés sont spécialisées là-dedans, a un test qui ne demande aucune liste de fournisseurs. Un prestataire qui a déjà séparé ces chemins fait ce qui suit lors d'une première conversation :

  • Demande la latence de prise de pari et le backlog de règlement d'un vrai pic, sur un même axe de temps, avant de demander le schéma
  • Nomme le mécanisme qu'il s'attend à voir détenir la latence, et le relevé qui lui donnerait tort
  • Traite l'argent comme une suite de tests : livraison en double, worker tué en plein batch, résultat corrigé
  • Teste en charge une rafale de résultats pendant que le trafic de prise de paris continue, plutôt que l'un ou l'autre chemin seul
  • Énonce les critères de sortie à l'avance : un percentile de prise de pari pendant la rafale et un âge de backlog acceptable après
  • Peut mettre quelqu'un d'astreinte sur les workers de règlement, les slots de réplication et les pools de connexions le soir de la finale

Une réponse qui reste générale sur l'un de ces points signifie que le travail commencerait sans diagnostic.

La première décision n'est donc pas une base de données plus grosse. C'est de savoir quel mécanisme détient la latence le soir où la prise de pari ralentit, et si la prise de pari et le règlement partagent encore une transaction quelque part sur le chemin.

Ce qu'amBrain peut étayer publiquement : amBrain est une société de développement logiciel spécialisée dans les plateformes de trading, les matching engines, les systèmes de real-time bidding et l'ingénierie de plateformes de casino. amBrain construit des logiciels depuis 2019. Un chiffre que nous publions comme mesuré en iGaming est celui de 12 opérateurs en production. Nous travaillons en trois formats : livraison complète, équipe dédiée ou ingénieurs intégrés à votre équipe.

Vous avez une architecture de ce type sur la table ?

Apportez votre architecture actuelle et le mode de défaillance qui vous inquiète : nous les passerons en revue ensemble en une demi-heure.

Articles liés

Erreur de chargement de l'image
iGaming
Feb 28, 20266 min de lecture

Scaler les plateformes iGaming : les leçons de 10M d'utilisateurs simultanés

Lire l'article
Erreur de chargement de l'image
iGaming
Feb 7, 20265 min de lecture

Construire des fonctionnalités de jeu responsable : plongée technique

Lire l'article
Erreur de chargement de l'image
iGaming
Jan 15, 20267 min de lecture

Architecture du pari en direct : traiter les mises à jour de cotes en moins de 50ms

Lire l'article