En bref — un marché entre joueurs ressemble à une fonctionnalité simple : on met une carte en vente, quelqu'un l'achète, l'argent change de main. En réalité, c'est l'un des problèmes de concurrence les plus exigeants qu'on puisse rencontrer dans une application web : plusieurs joueurs qui cliquent sur la même annonce à la même milliseconde, des ordres qui se croisent, des enchères qui se terminent pendant qu'une mise arrive. Sur Wikomplete, j'ai fait le choix de confier l'essentiel de la cohérence à PostgreSQL — transactions, verrous, contraintes — et de le prouver par des tests qui lancent de vraies requêtes en parallèle. Voici comment.
Le contexte : un carnet d'ordres pour des cartes
Wikomplete est un jeu de collection dont les cartes sont des articles Wikipédia. Les joueurs les échangent sur un marché qui fonctionne comme une petite bourse : un vendeur pose une annonce à un prix donné, un acheteur peut soit l'acheter directement, soit poser un ordre d'achat au prix qu'il est prêt à payer. Quand une annonce et un ordre se rencontrent — le prix demandé est inférieur ou égal au prix offert —, l'échange s'exécute automatiquement. À côté, un système d'enchères à durée fixe permet de vendre au plus offrant.
La monnaie est virtuelle, mais l'exigence ne l'est pas. Une carte vendue deux fois, un solde qui passe en négatif, une enchère réglée deux fois : il suffit d'un seul de ces bugs pour que les joueurs perdent confiance dans l'économie du jeu, et un joueur qui a passé des semaines à constituer sa collection ne pardonne pas. J'ai donc abordé ce marché comme on aborderait un système financier.
Un invariant avant tout
Tout part d'une règle que le système doit respecter en toutes circonstances : la masse monétaire est conservée. Chaque échange prélève une taxe — c'est le mécanisme qui retire de l'argent du jeu et limite l'inflation —, et c'est la seule façon dont de l'argent peut disparaître. Autrement dit, à tout moment :
argent total avant − argent total après = somme des taxes prélevéesCette équation est devenue l'assertion centrale de mes tests. Elle ne vérifie pas un scénario particulier : elle vérifie que, quelle que soit la séquence d'opérations, aucune pièce n'a été créée ni perdue en route.
Deux détails d'implémentation en découlent. D'abord, tous les montants sont des entiers (bigint en base, BigInt en TypeScript), jamais des nombres à virgule : la taxe est exprimée en points de base — 500 pour 5 % — et calculée par division entière. Ensuite, la taxe a un plancher d'une pièce : sans lui, la division entière arrondit à zéro sur les petits prix, et la majorité des échanges — ceux des cartes communes — ne prélèverait rien du tout.
Le séquestre : on ne promet que ce qu'on possède
Un ordre d'achat est une promesse : « j'achèterai cette carte à ce prix dès qu'un vendeur se présentera ». Pour qu'elle soit tenue, l'argent doit être disponible au moment où l'échange s'exécute, peut-être des jours plus tard. Chaque portefeuille a donc deux compteurs : le solde disponible et le montant gelé. Poser un ordre d'achat déplace immédiatement le prix du premier vers le second. Un joueur ne peut ainsi jamais s'engager au-delà de ce qu'il possède, et l'annulation d'un ordre ne fait que rendre ce qui avait été gelé.
Le débit lui-même ne suit pas le schéma « lire le solde, vérifier, écrire », qui laisse une fenêtre entre la lecture et l'écriture. La vérification fait partie de l'écriture :
UPDATE wallets
SET balance = balance - $price,
held = held + $price
WHERE user_id = $buyer
AND balance >= $price
RETURNING *;Si la requête ne modifie aucune ligne, c'est que les fonds sont insuffisants, et la transaction s'arrête là. Et en dernier recours, des contraintes CHECK (balance >= 0) et CHECK (held >= 0) sur la table garantissent qu'aucun bug applicatif, présent ou futur, ne pourra jamais faire passer un portefeuille en négatif : PostgreSQL refusera l'écriture.
Sérialiser ce qui doit l'être
Le cœur du problème est le rapprochement des ordres. Imaginons deux vendeurs qui mettent en vente la même carte au même instant, face à un unique ordre d'achat. Chacune des deux transactions cherche le meilleur ordre d'achat, le trouve, et tente de s'exécuter contre lui. Sans précaution, les deux pourraient réussir, et l'acheteur recevrait deux cartes pour un seul paiement.
Verrouiller la ligne de l'ordre d'achat ne suffit pas toujours, parce que le conflit porte sur l'ensemble des ordres d'une carte, y compris ceux qui n'existent pas encore au moment où la transaction commence. J'utilise donc un verrou consultatif de PostgreSQL, posé sur l'identifiant du modèle de carte, en tout début de transaction :
SELECT pg_advisory_xact_lock(hashtext($cardTemplateId));Toutes les opérations qui touchent au carnet d'une même carte passent ainsi l'une après l'autre, tandis que les échanges sur des cartes différentes continuent de s'exécuter en parallèle. Le verrou est libéré automatiquement à la fin de la transaction, qu'elle réussisse ou échoue : impossible de l'oublier.
Une dernière protection vit dans le schéma : un index unique partiel interdit qu'une même carte ait deux annonces actives en même temps.
CREATE UNIQUE INDEX listings_card_instance_unique
ON listings (card_instance_id)
WHERE status = 'active';Le filtre WHERE est essentiel : sans lui, une carte dont l'annonce a été annulée ou vendue ne pourrait plus jamais être remise en vente.
L'ordre des verrous évite les interblocages
Qui dit verrous dit risque d'interblocage — deadlock. Le cas typique ici : le joueur A achète une carte au joueur B pendant que B achète une autre carte à A. Si chaque transaction verrouille « le portefeuille de l'acheteur, puis celui du vendeur », la première tient A et attend B, la seconde tient B et attend A. PostgreSQL détecte la situation et annule l'une des deux, avec une erreur que le joueur ne devrait jamais voir.
La règle classique s'applique : toujours prendre les verrous dans le même ordre global. Tous les portefeuilles impliqués dans un mouvement d'argent sont verrouillés d'un coup, triés par identifiant :
const ids = [...new Set(userIds.filter(Boolean))].sort()
await tx.select().from(wallets)
.where(inArray(wallets.userId, ids))
.orderBy(wallets.userId)
.for('update')Même principe à l'échelle des familles de verrous : le verrou du carnet passe toujours avant ceux des portefeuilles, dans toutes les opérations. Ce sont mes tests de concurrence qui ont mis ces deux cas en évidence, avant la mise en ligne : sur quarante achats croisés lancés en parallèle, près de la moitié finissaient en interblocage avec l'ordre naïf. Avec l'ordre global, plus aucun.
Les enchères : un seul enjeu gelé à la fois
Les enchères reprennent le même séquestre. Une mise gèle les fonds de l'enchérisseur, et dans la même transaction, le précédent meilleur enchérisseur est remboursé. À aucun moment deux joueurs n'ont d'argent immobilisé sur la même enchère, et le gagnant est payé avec des fonds qui lui ont été réservés dès sa mise.
Quelques règles métier complètent le dispositif. Le vendeur ne peut plus annuler une enchère dès qu'une mise a été posée : l'enchérisseur doit pouvoir compter sur l'issue. Les durées sont choisies dans une liste fixe — une heure, six heures, un jour, trois jours — pour éviter les enchères éclair que personne n'a le temps de voir et celles qui immobilisent une carte pendant des semaines.
Le règlement des enchères arrivées à échéance est confié à une tâche planifiée, plutôt qu'au premier visiteur qui recharge la page. Chaque enchère est réglée dans sa propre transaction, qui revérifie son statut sous verrou avant d'agir : si deux passages de la tâche se chevauchent, le second trouve l'enchère déjà réglée et passe son chemin. Et une enchère qui échoue pour une raison quelconque ne bloque jamais le règlement des autres.
Les effets de bord attendent le commit
Un échange déclenche des effets visibles : notifications au vendeur, alerte aux joueurs qui avaient la carte dans leur liste de souhaits, mise à jour en temps réel de la page de la carte chez tous ceux qui la consultent. Aucun de ces effets n'est envoyé depuis l'intérieur de la transaction. Les notifications sont écrites en base dans la transaction, mais leur diffusion n'a lieu qu'après le commit.
La raison est simple : une transaction peut encore échouer jusqu'à son dernier instant. Diffuser « votre carte a été vendue » avant le commit, c'est prendre le risque d'annoncer un échange qui n'aura finalement jamais eu lieu. Après le commit, l'information est vraie, définitivement.
Prouver sous concurrence, pas seulement en séquence
Des tests unitaires classiques, qui exécutent les opérations l'une après l'autre, ne peuvent pas détecter une condition de course : par construction, il n'y a jamais de course. J'ai donc écrit une suite de tests fonctionnels qui lancent de vraies requêtes HTTP en parallèle contre une vraie base PostgreSQL. Quelques exemples de ce qu'elle vérifie :
- une annonce achetée par huit joueurs au même instant n'est vendue qu'une fois ; les sept autres reçoivent une erreur propre et retrouvent leur portefeuille intact ;
- six ordres d'achat simultanés face à une seule annonce : un seul est servi, les cinq autres restent en attente avec leurs fonds gelés ;
- des ordres d'achat posés en rafale ne font jamais passer un solde en négatif ;
- un carnet « agité » — annonces, achats, ordres et annulations mêlés au hasard — conserve la masse monétaire, et aucune erreur PostgreSQL brute ne remonte jamais jusqu'au joueur.
C'est cette suite qui a débusqué les interblocages décrits plus haut, bien avant qu'un joueur ne puisse les rencontrer. Elle tourne dans la CI à chaque modification, avant tout déploiement : c'est elle qui me permet de faire évoluer le marché sans crainte.
Ce que j'en retiens
La tentation, face à ce genre de problème, est de réinventer la cohérence dans le code applicatif : des files d'attente, des verrous en mémoire, des vérifications en cascade. PostgreSQL sait déjà faire tout cela, et bien mieux : transactions atomiques, verrous de ligne et verrous consultatifs, contraintes qui refusent l'impossible, index partiels qui encodent des règles métier. Mon travail consiste surtout à choisir les bons outils, à les appliquer dans un ordre cohérent, et à prouver le résultat sous une charge réellement concurrente.
Et si je ne devais garder qu'une idée : formuler l'invariant avant d'écrire la moindre ligne. « Aucune pièce ne se crée ni ne se perd, sauf par la taxe. » Tant que cette phrase est vraie après chaque test, l'économie tient.