- Le verrou exclusif global de Postgres LISTEN/NOTIFY limite le débit des implémentations simples, mais en mettant les notifications en mémoire tampon pour les envoyer par lots, un seul serveur peut traiter jusqu’à 60 000 écritures de flux par seconde
- Une transaction qui appelle NOTIFY conserve le verrou global jusqu’à la fin du commit et de
fsync()afin de garantir l’ordre de commit des notifications ; cela sérialise les commits et empêche aussi de tirer parti du commit groupé - L’implémentation initiale, qui appelait NOTIFY via un déclencheur à chaque écriture dans la table de flux, offrait une faible latence, mais se heurtait à un goulot d’étranglement à 2 900 opérations par seconde sans utiliser pleinement le CPU, la mémoire ni les IOPS
- En prenant la table de base de données, et non les notifications, comme source de vérité, puis en envoyant périodiquement les notifications accumulées en mémoire dans une seule transaction, on peut réduire fortement le nombre d’acquisitions de verrou
- Le risque de perte des notifications en mémoire tampon en cas de défaillance du processus est compensé par un polling peu fréquent ; même avec des lectures concurrentes, l’approche atteint une latence de 15 à 100 ms et un débit 20 fois supérieur à l’existant
Des flux à faible latence avec Postgres
- Un flux basé sur Postgres stocke chaque fragment de flux comme une nouvelle ligne dans la table
streams; un token de réponse de LLM peut aussi constituer un fragment - Côté lecture, il est difficile d’attendre efficacement avec une simple requête, car on ne sait pas quand le prochain fragment arrivera
- Le polling périodique, avec un intervalle long, augmente la latence pour des usages interactifs comme le chat en ligne ; avec un intervalle court, les pollers concurrents submergent la base de données
- Avec LISTEN/NOTIFY, le processus de lecture peut attendre en étant bloqué, puis être réveillé immédiatement par une notification signalant qu’un nouveau fragment a été écrit, évitant ainsi le polling inutile
Le goulot d’étranglement d’un NOTIFY à chaque écriture
- Dans l’implémentation initiale, à chaque nouveau fragment écrit dans la table
streams, un déclencheur exécutait une fonction pour envoyer un NOTIFY, et le processus de lecture attendait la notification avant de lire le nouveau fragment - L’exactitude et la faible latence étaient assurées, mais même avec une grande base Postgres, il n’était pas possible de soutenir plus de 2 900 écritures de flux par seconde
- Pendant que le goulot d’étranglement se manifestait, l’utilisation du CPU, de la mémoire et des IOPS n’augmentait pas de façon notable ; la cause était le verrou global dans le chemin de commit de NOTIFY
Un verrou global pour garantir l’ordre des commits
- Une transaction qui appelle NOTIFY acquiert un verrou exclusif global au début du commit et ne le libère qu’une fois entièrement commitée et son contenu écrit sur disque via
fsync() - Postgres garantit que les notifications sont livrées dans l’ordre de commit des transactions, et stocke toutes les notifications sortantes dans une file interne globale qui doit correspondre exactement à cet ordre de commit
- L’ajout des notifications à la file doit aussi être traité transactionnellement dans le cadre du commit, mais comme chaque transaction a un temps de commit différent, l’ordre ne peut pas être déterminé avant la fin du commit
- Le verrou global sérialise le commit des transactions contenant des notifications afin de fixer l’ordre à l’avance et de les ajouter dans le même ordre à la file interne des notifications
Comment la sérialisation des commits limite le débit
- Comme toutes les écritures de flux appellent NOTIFY via un déclencheur, chaque transaction d’écriture conserve le verrou global pendant tout le commit et le flush sur disque
- Les transactions étant commitée les unes après les autres, il n’est pas possible d’exploiter le commit groupé de Postgres, qui traite plusieurs transactions avec un seul
fsync() - Le débit ne peut pas dépasser la vitesse à laquelle Postgres commit des transactions individuelles ; les opérations attendent sur le verrou, si bien que le CPU et le disque ne sont pas pleinement utilisés
- Le patch qui sera inclus dans Postgres 19 ne supprime pas le verrou global et ne résout donc pas ce goulot d’étranglement
- Il optimise plutôt un cas limité où il existe de nombreux canaux de notification et où chaque listener n’attend qu’un canal précis
Mise en mémoire tampon et envoi par lots des notifications
- Dans de nombreux usages de LISTEN/NOTIFY, y compris les flux, les notifications ne sont pas la source de vérité : elles ne sont qu’un signal indiquant qu’il faut consulter la table où les données réelles sont stockées
- Dans cette architecture, les notifications elles-mêmes n’ont pas besoin d’un ordre global parfait ni d’une durabilité complète ; elles peuvent être mises en mémoire tampon puis envoyées périodiquement dans une seule transaction de lot
- Le verrou global n’est acquis qu’au moment de vider le tampon, et non à chaque écriture de flux individuelle
- Les écritures individuelles progressent rapidement, séparées de l’envoi des notifications en arrière-plan, et le débit augmente grâce aux optimisations de Postgres comme le commit groupé
Polling peu fréquent pour compenser la perte de notifications
- Si le processus s’arrête alors que des notifications restent en mémoire, celles-ci peuvent ne pas être livrées
- Le processus de lecture attend les notifications tout en interrogeant périodiquement la base de données pour vérifier s’il existe des données de flux écrites sans notification
- Ce polling sert uniquement de mécanisme auxiliaire pour récupérer les notifications perdues ; il peut donc être exécuté à faible fréquence, avec un impact limité sur les performances
Débit et latence
- L’implémentation optimisée traite jusqu’à 60 000 écritures de flux par seconde dans un environnement avec des processus de lecture concurrents, soit un débit 20 fois supérieur à celui de l’implémentation initiale
- Même avec ce débit plus élevé, la latence reste dans une plage de 15 à 100 ms
- Au débit maximal, le CPU de Postgres est pleinement utilisé, ce qui montre que l’on atteint une véritable saturation de la base de données elle-même, et non une contention sur les verrous
- Le code complet du benchmark est disponible dans dbos-postgres-benchmark
1 commentaires
Avis sur Hacker News
La scalabilité est une échelle continue, pas une dichotomie. 60 000 événements par seconde peuvent représenter 100 000 fois plus que nécessaire pour certains systèmes, et 100 000 fois trop peu pour d’autres. Parmi les erreurs courantes des développeurs, je citerais davantage le choix d’une technologie dont les caractéristiques de scalabilité ne conviennent pas que « l’optimisation prématurée »
Une technologie trop petite échoue clairement une fois ses limites dépassées, mais une technologie excessivement scalable apporte aussi une charge opérationnelle et des contraintes. Introduire ce type de technologie dans un petit système où un modèle plus riche pourrait fortement réduire l’effort de développement est aussi un mauvais choix
Les limites de LISTEN/NOTIFY sont suffisamment basses pour mériter l’attention ; après avoir calculé une charge maximale pessimiste, il vaut donc mieux garder au moins une marge de 10x. Mais pour beaucoup de projets, c’est suffisant. Grâce à son intégration à la base de données, à sa disponibilité et au fait qu’il ne nécessite pas d’exploiter un service séparé, ce n’est pas une option à exclure d’emblée ; même les 2 000 événements par seconde évoqués auparavant représentent déjà beaucoup pour un système qui traite un message à l’échelle de la seconde
Il vaut mieux concevoir pour l’échelle réellement attendue avec une marge raisonnable, et ne viser au-delà que lorsque la scalabilité supplémentaire est pratiquement gratuite. Si quelques milliers de dollars suffisent pour acheter du matériel plus gros, ou si les options sont équivalentes hors scalabilité, autant choisir la plus grande
Nous avons eu beaucoup de succès en combinant LISTEN/NOTIFY avec un broker d’abonnements GraphQL en Rust. Il y avait des dizaines de milliers d’abonnements, mais seulement une connexion LISTEN par hôte, soit 3 à 4 au total
Toutes les modifications étaient envoyées à chaque hôte, puis l’hôte gérait les abonnements réels des utilisateurs et décidait quoi publier. Remplacer des centaines d’hôtes Ruby ou Node par quelques hôtes Rust peut fortement simplifier l’architecture, et des approches réputées ne pas scaler peuvent en réalité très bien fonctionner
Quand j’étais CTO, nous sommes passés d’environ 100 000 traitements par jour sur l’ensemble des services à plusieurs millions, puis finalement à des dizaines de millions. Pendant cette période, un ingénieur a construit une file d’attente au-dessus de la sémantique LISTEN/NOTIFY afin de tirer parti de la forte cohérence avec le modèle de données. Ce n’était pas difficile à comprendre, et cela supprimait aussi une couche séparée de stockage et de transport ; à l’époque, cela semblait raisonnable
Mais en faisant évoluer cette fonctionnalité maison, nous avons dû contourner le fonctionnement interne de PostgreSQL, ce qui est devenu très pénible, et nous aurions dû migrer plus tôt vers un autre système. La scalabilité était aussi mauvaise : sur RDS, nous avons rencontré une forte contention disque dont la cause était difficile à diagnostiquer, et le VACUUM de cette table était un cauchemar. Comme nous avions réimplémenté de façon inhabituelle une file d’attente familière à l’aide de fonctionnalités internes de PostgreSQL, les autres ingénieurs en avaient peur et évitaient le débogage comme la prise de responsabilité
La leçon principale, au-delà des détails comme le schéma et les index, est de toujours commencer par des technologies simples et prévisibles. Si une cohérence très forte des données n’est pas indispensable, il vaut mieux utiliser une file simple au contrat d’API clair, comme SQS ou une file Redis, même si cela ajoute un composant d’infrastructure, puis adapter le reste autour. Moins le stockage de données principal assume de responsabilités mécaniques, mieux c’est
À mesure que l’écosystème des systèmes distribués progresse, on comprend ce que chaque composant peut faire. Commencer avec peu de composants et n’en ajouter que lorsque c’est réellement nécessaire produit des systèmes plus sains
J’apprécie toujours autant DBOS, qui exploite correctement Postgres et désormais SQLite. On peut l’introduire dans une stack CRUD existante avec presque aucun effort
Une fois qu’on commence à utiliser des workflows durables, on voit sans cesse de nouveaux endroits où les appliquer. Dernièrement, j’expérimente l’idée de considérer chaque e-mail comme un workflow durable, où l’utilisateur, son interlocuteur, des agents et des outils comme GitHub ou Attio participent tour à tour au flux
https://housecat.com/blog/gmail-durable-workflows-sandbox-vm
Ce type d’article résulte généralement de l’évaluation indépendante du problème, de la compréhension et de la solution de chacun. Il est discutable de parler de manque d’expertise simplement parce qu’on attendait une certaine performance avec la configuration par défaut d’un outil ; tout le monde continue d’apprendre par l’échec
Le fait que l’expérience ait utilisé un serveur de base de données avec 96 cœurs et 384 Go de RAM (https://github.com/dbos-inc/dbos-postgres-benchmark/blob/mai...) est très important et aurait dû être clairement indiqué. Les bases de données peuvent scaler verticalement, mais cela a aussi ses limites. Qui se connecte depuis où influence également les performances et la latence globale
60 000 événements par seconde peuvent sembler beaucoup, mais ce qui fait tomber les systèmes réels, ce sont les pics soudains de trafic, pas le trafic normal. À moins d’être une grande entreprise, on ne commence pas avec ce genre de gros serveur. Avec des réplicas de lecture et une redondance interrégion, un seul cluster de base de données de production coûte plus de 100 000 dollars
Cela ressemble à un article lié : Postgres LISTEN/NOTIFY does not scale - https://news.ycombinator.com/item?id=44490510 - juillet 2025, 321 commentaires
Il semble manquer la partie la plus importante de l’article : la méthode d’attribution d’un offset ou d’un numéro de séquence qui permet de suivre jusqu’où un consommateur a lu et de consulter les nouveaux messages via un chemin de repli. Il existe plusieurs approches, mais ce n’est pas facile à résoudre sans complexité ni contention de verrous, et une mauvaise implémentation peut aussi créer des conditions de concurrence avec les consommateurs. En général, l’auteur verrouillerait une ligne unique d’une table d’état, par exemple, pour attribuer le prochain numéro d’un sujet d’événements
Je me demande quelle est la meilleure méthode. Un outil qui lit la capture de données modifiées (CDC) et écrit les numéros d’événements attribués dans une autre table pourrait convenir, mais la latence risque d’être plus élevée, et ce processeur CDC devrait aussi effectuer le NOTIFY
Côté consommateur, il y a aussi des usages où le traitement par lots peut fortement augmenter le débit. Dans ce cas, on peut exécuter les consommateurs en boucle sans LISTEN/NOTIFY, traiter à chaque itération tous les nouveaux messages non traités, puis stocker le dernier numéro de séquence entre deux itérations
Je me souviens que la première version prenant en charge LISTEN/NOTIFY avait une implémentation de verrouillage médiocre, ce qui provoquait des problèmes de performances. L’ancien article critiqué ici le corrigeait d’ailleurs dans une note juste après le premier paragraphe
Si la correction date du 8 mai, l’article du 24 juillet doit reconnaître que l’article connu affirmant que cette fonctionnalité ne passait pas à l’échelle n’était pas malveillant et n’était peut-être pas faux à l’époque
Il optimise plutôt le cas plus restreint où il y a beaucoup de canaux de notification et où chaque récepteur n’attend qu’un canal précis
Lors de ma dernière vérification, LISTEN/NOTIFY avait une limite de 8 000 octets sur les données de notification, ce qui constituait clairement un aspect qui ne passait pas à l’échelle. Si les données ne peuvent pas être stockées sous forme de ligne avec seulement un ID transmis, c’est difficile à utiliser
Les événements d’un jeu web étaient des données temporaires décrivant des changements d’état ; il n’y avait donc aucune raison de les stocker en base, et ils pouvaient dépasser 8 000 octets, ce qui ne convenait pas à cet usage
L’article traite de la contention de verrous sur la file globale, mais ne semble pas mentionner un autre problème de la file globale de taille fixe. Un seul récepteur lent sur un canal pouvait bloquer les écritures sur tous les canaux. Du moins, ce type de panne était possible il y a quelques années ; cela a peut-être changé depuis