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 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 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.
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.
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.
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.
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).
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.
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.
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 :
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.
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 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.
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.
Faites les relevés pendant le pic, sur un même axe de temps que la latence de prise de pari :
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.
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 :
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.
Apportez votre architecture actuelle et le mode de défaillance qui vous inquiète : nous les passerons en revue ensemble en une demi-heure.