1 Punkte von GN⁺ 1 시간 전 | 1 Kommentare | Auf WhatsApp teilen
  • Die globale exklusive Sperre von Postgres LISTEN/NOTIFY begrenzt den Durchsatz einfacher Implementierungen, aber durch das Puffern und gebündelte Senden von Benachrichtigungen sind auf einem einzelnen Server bis zu 60.000 Stream-Schreibvorgänge pro Sekunde möglich
  • Eine Transaktion, die NOTIFY aufruft, hält die globale Sperre bis Commit und fsync() abgeschlossen sind, um die Commit-Reihenfolge der Benachrichtigungen zu garantieren; dadurch werden Commits serialisiert und Group Commit kann nicht genutzt werden
  • Die ursprüngliche Implementierung, die bei jedem Schreibvorgang in die Stream-Tabelle per Trigger NOTIFY aufrief, bot zwar geringe Latenz, stieß aber schon bei 2.900 Vorgängen pro Sekunde an einen Engpass, ohne CPU, Speicher oder IOPS ausreichend auszulasten
  • Wenn statt der Benachrichtigungen die Datenbanktabelle als Source of Truth dient und im Speicher gesammelte Benachrichtigungen periodisch in einer einzigen Transaktion gesendet werden, lässt sich die Zahl der Sperrerwerbe stark reduzieren
  • Das geringe Risiko verlorener Benachrichtigungen bei Prozessausfällen wird durch Polling mit niedriger Frequenz abgefangen; so werden selbst bei gleichzeitigen Lesezugriffen 15–100 ms Latenz und der 20-fache Durchsatz erreicht

Ein Low-Latency-Stream auf Basis von Postgres

  • Ein Postgres-basierter Stream speichert jedes Stream-Fragment als neue Zeile in der Tabelle streams; auch ein Antwort-Token eines LLM kann ein solches Fragment sein
  • Auf der Leseseite ist unbekannt, wann das nächste Fragment eintrifft, sodass effizientes Warten allein per einfacher Abfrage schwierig ist
  • Periodisches Polling führt bei langen Intervallen zu hoher Latenz für interaktive Anwendungsfälle wie Online-Chat, bei kurzen Intervallen überrollen viele gleichzeitige Poller die Datenbank
  • Mit LISTEN/NOTIFY kann der Leseprozess blockierend warten und wird sofort mit einer Benachrichtigung geweckt, sobald ein neues Fragment geschrieben wurde, wodurch unnötiges Polling entfällt

Der Engpass durch NOTIFY bei jedem Schreibvorgang

  • In der ursprünglichen Implementierung führte ein Trigger jedes Mal, wenn ein neues Fragment in die Tabelle streams geschrieben wurde, eine Funktion aus, die jeweils ein NOTIFY sendete; der Leseprozess wartete auf die Benachrichtigung und las dann das neue Fragment
  • Korrektheit und geringe Latenz waren gegeben, aber selbst auf einer großen Postgres-Datenbank ließen sich dauerhaft nicht mehr als 2.900 Stream-Schreibvorgänge pro Sekunde halten
  • Während des Engpasses stiegen CPU-, Speicher- und IOPS-Nutzung nicht auffällig an; die Ursache war die globale Sperre im Commit-Pfad von NOTIFY

Die globale Sperre zur Sicherung der Commit-Reihenfolge

  • Eine Transaktion, die NOTIFY aufruft, erwirbt zu Beginn des Commits eine globale exklusive Sperre und gibt sie erst frei, wenn der Commit vollständig abgeschlossen und sein Inhalt per fsync() auf die Festplatte geschrieben ist
  • Postgres garantiert, dass Benachrichtigungen in der Commit-Reihenfolge der Transaktionen zugestellt werden, und speichert alle ausgehenden Benachrichtigungen in einer globalen internen Queue, die exakt dieser Commit-Reihenfolge entsprechen muss
  • Auch das Hinzufügen von Benachrichtigungen zur Queue muss als Teil des Commits transaktional erfolgen; da Transaktionen jedoch unterschiedlich lange für den Commit brauchen, kann die Reihenfolge vor Abschluss des Commits nicht festgelegt werden
  • Die globale Sperre serialisiert die Commits von Transaktionen mit Benachrichtigungen, legt die Reihenfolge vorab fest und sorgt dafür, dass sie auch in derselben Reihenfolge in die interne Benachrichtigungs-Queue eingetragen werden

Wie die Serialisierung von Commits den Durchsatz begrenzt

  • Da alle Stream-Schreibvorgänge per Trigger NOTIFY aufrufen, hält jede Schreibtransaktion die globale Sperre während des gesamten Commits und des Flushs auf die Festplatte
  • Weil Transaktionen nacheinander committen, kann Postgres seinen Group Commit nicht nutzen, bei dem mehrere Transaktionen mit einem einzigen fsync() verarbeitet werden
  • Der Durchsatz kann die Commit-Geschwindigkeit einzelner Transaktionen in Postgres nicht übersteigen, und da Vorgänge an der Sperre warten, werden CPU und Datenträger ebenfalls nicht ausreichend ausgelastet
  • Der in Postgres 19 enthaltene Patch beseitigt die globale Sperre nicht und kann diesen Engpass daher nicht auflösen
    • Stattdessen optimiert er den eingeschränkten Fall mit vielen Benachrichtigungskanälen, bei dem jeder Listener nur auf einen bestimmten Kanal wartet

Benachrichtigungen puffern und gebündelt senden

  • Bei Streams und vielen anderen LISTEN/NOTIFY-Anwendungsfällen sind Benachrichtigungen nicht die Source of Truth, sondern nur ein Signal, die Tabelle mit den eigentlichen Daten zu prüfen
  • In einer solchen Struktur brauchen die Benachrichtigungen selbst weder perfekte globale Reihenfolge noch vollständige Dauerhaftigkeit; sie können daher im Speicher gepuffert und periodisch in einer einzigen Batch-Transaktion gesendet werden
  • Die globale Sperre wird nicht mehr bei jedem einzelnen Stream-Schreibvorgang erworben, sondern nur noch beim Leeren des Puffers
  • Einzelne Schreibvorgänge laufen dadurch schnell und getrennt vom Versand der Benachrichtigungen im Hintergrund, sodass Postgres-Optimierungen wie Group Commit den Durchsatz erhöhen können

Polling mit niedriger Frequenz als Absicherung gegen verlorene Benachrichtigungen

  • Wenn ein Prozess ausfällt, während Benachrichtigungen noch im Speicher liegen, können diese Benachrichtigungen unter Umständen nicht zugestellt werden
  • Der Leseprozess wartet deshalb auf Benachrichtigungen und fragt die Datenbank zugleich periodisch ab, um zu prüfen, ob ohne Benachrichtigung geschriebene Stream-Daten vorhanden sind
  • Dieses Polling ist nur ein Hilfsmechanismus zur Wiederherstellung verlorener Benachrichtigungen, kann daher mit niedriger Frequenz laufen und wirkt sich nur gering auf die Performance aus

Durchsatz und Latenz

  • Die optimierte Implementierung verarbeitet bei gleichzeitig aktiven Leseprozessen bis zu 60.000 Stream-Schreibvorgänge pro Sekunde und erreicht damit den 20-fachen Durchsatz der ursprünglichen Implementierung
  • Auch bei erhöhtem Durchsatz bleibt die Latenz im Bereich von 15–100 ms
  • Beim maximalen Durchsatz wird die Postgres-CPU vollständig ausgelastet, was zeigt, dass nicht mehr Sperrenkonflikte, sondern die tatsächliche Sättigung der Datenbank selbst erreicht wurde
  • Den vollständigen Benchmark-Code gibt es unter dbos-postgres-benchmark

1 Kommentare

 
GN⁺ 1 시간 전
Hacker-News-Kommentare
  • Skalierbarkeit ist ein kontinuierliches Spektrum, kein binäres Entweder-oder. 60.000 Vorgänge pro Sekunde sind für manche Systeme 100.000-mal mehr als nötig und für andere 100.000-mal zu wenig. Ein häufiger Entwicklerfehler ist aus meiner Sicht weniger „verfrühte Optimierung“ als vielmehr die Wahl einer Technologie mit unpassenden Skalierungseigenschaften
    Eine zu kleine Technologie scheitert zwar klar, wenn sie an ihre Grenzen stößt, aber auch eine übermäßig skalierbare Technologie bringt operativen Aufwand und Einschränkungen mit sich. Sie in ein kleines System einzuführen, bei dem ein ausdrucksstärkeres Modell den Entwicklungsaufwand stark senken könnte, ist ebenfalls eine schlechte Wahl
    Die Grenzen von LISTEN/NOTIFY sind niedrig genug, dass man vorsichtig sein sollte und selbst nach Berechnung einer pessimistischen Maximal-Last mindestens den 10-fachen Puffer einplanen sollte, aber für viele Projekte reicht es aus. Wegen der Integration mit der Datenbank, der Verfügbarkeit und des Vorteils, keinen separaten Service betreiben zu müssen, ist es keine Option, die man pauschal ausschließen sollte; selbst die zuvor genannten 2.000 Vorgänge pro Sekunde sind für ein System, das eine Nachricht pro Sekunde verarbeitet, ein großer Wert

    • Eine kleine Gegenrede zu einem guten Artikel: Systeme, die 6 Milliarden Requests pro Sekunde verarbeiten, gibt es fast nicht, und selbst wenn doch, würden sie wahrscheinlich eigens für ihren Zweck gebaute Werkzeuge verwenden
    • Wenn man mit weniger als 60.000 Vorgängen pro Sekunde rechnet und dann von 20.000 auf 200.000 hochschießt, ist das ein besseres Problem, als auf 1 Million Vorgänge pro Sekunde hin zu bauen und am Ende tatsächlich nur 20.000 zu haben. Der unerwartete Erfolg im ersten Fall finanziert Notmaßnahmen und Skalierungskosten, während man im zweiten an einer teuren Kostenstruktur und Vorabinvestitionen festhängt
      Besser ist es, für die tatsächlich erwartete Größenordnung mit etwas Reserve zu entwerfen und nur dann darüber hinauszugehen, wenn zusätzliche Skalierbarkeit praktisch kostenlos ist. Wenn man für ein paar tausend Dollar größere Hardware kaufen kann oder die Optionen außer bei der Skalierbarkeit gleichwertig sind, kann man die größere wählen
    • In diesem Fall wird die Hardware-Grenze erreicht, daher kann man es als skalierend betrachten. Der Flaschenhals liegt nicht bei der Datenbank oder der Ein-/Ausgabe, sondern bei der Hardware; bei maximalem Durchsatz ist die Postgres-CPU vollständig ausgelastet, was nicht auf Contention, sondern auf die Sättigung der Datenbank selbst hinweist
  • Mit einer Kombination aus LISTEN/NOTIFY und einem Rust-GraphQL-Subscription-Broker hatten wir großen Erfolg. Es gab zehntausende Subscriptions, aber nur 3–4 LISTEN-Verbindungen insgesamt, je eine pro Host
    Alle Änderungen wurden an jeden Host gesendet, und der Host verwaltete die tatsächlichen Nutzer-Subscriptions und entschied, was veröffentlicht werden sollte. Wenn man Hunderte Ruby- oder Node-Hosts durch ein paar Rust-Hosts ersetzt, kann man die Struktur stark vereinfachen, und selbst ein Ansatz, der als nicht skalierend gilt, funktioniert ziemlich gut

    • Mich würde interessieren, welche GraphQL-Bibliothek du für Rust empfehlen würdest
  • Als ich als CTO gearbeitet habe, verarbeiteten alle Services zusammen anfangs etwa 100.000 Vorgänge pro Tag, dann mehrere Millionen und schließlich zig Millionen. In diesem Prozess baute ein Engineer eine Queue auf Basis der LISTEN/NOTIFY-Semantik, um die starke Konsistenz mit dem Datenmodell zu nutzen. Schwer zu verstehen war es nicht, und weil es auch keine separate Storage- oder Transport-Schicht brauchte, wirkte es damals vernünftig
    Beim Skalieren der selbstgebauten Funktionalität wurde es jedoch sehr unerquicklich, weil wir PostgreSQL-Interna umgehen mussten, und wir hätten früher auf ein anderes System wechseln sollen. Auch die Skalierbarkeit war nicht gut: Auf RDS kam es zu starker, schwer zu diagnostizierender Disk-Contention, und das VACUUM dieser Tabelle war ein Albtraum. Weil eine vertraute Queue als ungewohnte PostgreSQL-Interne Funktion umgesetzt wurde, scheuten andere Engineers sie, ebenso wie Debugging und Ownership
    Die zentrale Lehre, abgesehen von Details wie Schema und Indizes, ist, immer mit einfacher und vorhersehbarer Technologie zu beginnen. Wenn man keine extrem starke Datenkonsistenz braucht, ist es besser, auch um den Preis einer zusätzlichen Infrastruktur-Komponente eine Queue mit einfachem API-Vertrag wie SQS oder Redis Queue zu verwenden und den Rest darauf auszurichten. Je weniger mechanische Verantwortung ein einzelner Kern-Datenspeicher trägt, desto besser war es

    • Am Ende lässt sich das auf „Es skaliert nicht, wenn man es benutzt, ohne zu verstehen, wie es funktioniert“ zusammenfassen. Normales LISTEN/NOTIFY skaliert nicht, aber der Originalartikel hat tatsächlich einen Weg gefunden, es zu skalieren, daher muss nicht jedes Team das erneut lösen
      Mit der Weiterentwicklung des Ökosystems verteilter Systeme versteht man besser, was die einzelnen Komponenten leisten können. Es ist gesünder, mit wenigen Komponenten zu beginnen und erst dann weitere hinzuzufügen, wenn sie wirklich nötig sind
    • Es ist selten, dass eine neue Komponente alle anderen Faktoren übertrumpft, aber es hat große Vorteile, eine Queue unabhängig von anderen Architekturkomponenten zu betreiben. Queues sind von Natur aus für Store-and-Forward gebaut; wenn man ihren Lebenszyklus trennt, kann man Systeme auch während Updates, Incident-Analysen und Patch-Zeiten voneinander entkoppeln
    • Das ist eher ein Verwaltungsproblem, und ich stimme der Schlussfolgerung nicht zu, dass man der Infrastruktur deshalb eine neue Komponente hinzufügen sollte. Einen neuen Netzwerk-Knoten hinzuzufügen, nur weil Entwickler einen Teil des Stacks nicht anfassen wollen, ist nicht sinnvoll; man sollte ihnen diese Verantwortung einfach geben
  • Ich mag weiterhin DBOS, das Postgres und inzwischen sogar SQLite richtig nutzt. Es lässt sich auch in bestehende CRUD-Stacks fast ohne Aufwand einführen
    Sobald man anfängt, dauerhafte Workflows zu verwenden, sieht man ständig neue Einsatzmöglichkeiten. Zuletzt experimentiere ich damit, jede einzelne E-Mail als dauerhaften Workflow zu betrachten, in den nacheinander der Nutzer, die Gegenpartei, Agenten und Tools wie GitHub oder Attio eingebunden werden
    https://housecat.com/blog/gmail-durable-workflows-sandbox-vm

  • Solche Artikel sind meist das Ergebnis davon, dass jeder sein eigenes Problem, Verständnis und seine eigene Lösung unabhängig bewertet. Nur weil jemand mit den Standardeinstellungen eines Tools bestimmte Performance erwartet hat, ist es schwer, ihm mangelnde Fachkenntnis vorzuwerfen; jeder lernt weiter durch Fehlschläge
    Dass für den Test ein Datenbankserver mit 96 Kernen und 384 GB RAM verwendet wurde (https://github.com/dbos-inc/dbos-postgres-benchmark/blob/mai...), ist sehr wichtig und hätte klar genannt werden sollen. Datenbanken lassen sich vertikal skalieren, aber auch das hat Grenzen. Wer sich von wo verbindet, beeinflusst ebenfalls die Performance und die Gesamtlatenz
    60.000 Vorgänge pro Sekunde mögen groß wirken, aber in realen Systemen bringen nicht die normalen Traffic-Werte alles zum Einsturz, sondern kurzzeitige Traffic-Spitzen. Wenn man kein Großkonzern ist, wird man nicht mit so einem großen Server anfangen. Rechnet man Read Replicas und regionsübergreifende Redundanz ein, kostet schon ein einziges produktives Datenbank-Cluster über 100.000 Dollar

  • Scheint der verwandte Beitrag zu sein: Postgres LISTEN/NOTIFY does not scale - https://news.ycombinator.com/item?id=44490510 - Juli 2025, 321 Kommentare

  • Im Beitrag scheint der wichtigste Punkt zu fehlen: die Vergabe von Offsets oder Sequenznummern, damit nachvollzogen werden kann, bis wohin ein Consumer gelesen hat, und neue Nachrichten über einen alternativen Pfad abgefragt werden können. Es gibt verschiedene Ansätze, aber ohne Komplexität oder Lock-Contention ist das nicht leicht zu lösen, und bei einer falschen Implementierung entstehen auch Race Conditions mit den Consumern. Meist würde der Autor wohl ein Lock auf eine einzelne Zeile in einer Statustabelle oder Ähnlichem setzen, um die nächste Nummer für ein Event-Topic zu vergeben
    Ich frage mich, was hier der beste Ansatz ist. Ein Tool, das Change Data Capture (CDC) liest und die zugewiesene Event-Nummer in eine andere Tabelle schreibt, könnte brauchbar sein, aber die Latenz könnte höher werden, und dieser CDC-Prozessor müsste dann auch NOTIFY ausführen
    Auf Consumer-Seite gibt es auch Anwendungsfälle, bei denen sich der Durchsatz durch Batch-Verarbeitung stark erhöhen lässt. Dann kann man auf LISTEN/NOTIFY verzichten, die Consumer stattdessen in einer Schleife laufen lassen und jedes Mal alle neuen, noch nicht verarbeiteten Nachrichten abarbeiten und zwischen den Schleifendurchläufen die letzte Sequenznummer speichern

  • Soweit ich mich erinnere, hatte die erste Release mit Unterstützung für LISTEN/NOTIFY wegen einer schlechten Lock-Implementierung Performance-Probleme. Auch der ältere Beitrag, der hier kritisiert wird, hat das in einer Korrektur direkt nach dem ersten Absatz richtiggestellt
    Wenn diese Korrektur vom 8. Mai stammt, dann sollte man beim Beitrag vom 24. Juli anerkennen, dass der bekannte Artikel, der behauptete, diese Funktion skaliere nicht, nicht böswillig geschrieben war und nach damaligem Stand möglicherweise auch nicht falsch war

    • Falls damit die Optimierung gemeint ist, die in Postgres 19 kommen soll, wird sie auch im Original behandelt. Dieser Patch (https://github.com/postgres/postgres/commit/282b1cde9dedf456...) entfernt weder das globale Lock noch behebt er den beobachteten Bottleneck
      Stattdessen optimiert er den stärker eingeschränkten Fall, in dem es viele Benachrichtigungskanäle gibt und jeder Empfänger nur auf genau einen bestimmten Kanal wartet
  • Soweit ich zuletzt nachgesehen habe, hatte LISTEN/NOTIFY bei den Benachrichtigungsdaten eine Obergrenze von 8.000 Byte, was in dieser Hinsicht klar nicht skalierbar war. Wenn sich Daten nicht als Zeilen speichern lassen und nur ihre ID übermittelt werden kann, ist das schwer nutzbar
    Die Events eines Webspiels waren flüchtige Daten zur Beschreibung von Zustandsänderungen; es gab keinen Grund, sie in der Datenbank zu speichern, und sie konnten auch größer als 8.000 Byte sein, daher war das für diesen Einsatzzweck ungeeignet

    • Wenn ich ein skalierbares Benachrichtigungssystem bauen würde, würde ich eine Obergrenze für die Benachrichtigungsgröße setzen. So bleibt die Nachrichtengröße O(1), sodass man sich auf die Skalierung der Anzahl der Benachrichtigungen konzentrieren kann, und beliebig große Nachrichten können die Performance zum Erliegen bringen und deuten womöglich auch darauf hin, dass das Benachrichtigungssystem falsch verwendet wird
    • Ich frage mich, ob man nicht auch Nachrichten senden könnte, die auf einen geänderten Zustand verweisen, selbst wenn sie nicht auf eine bestimmte Zeile zeigen
  • Im Beitrag wird Lock-Contention bei einer globalen Queue behandelt, aber ein anderes Problem einer globalen Queue fester Größe scheint nicht erwähnt zu werden. Ein einzelner langsamer Empfänger auf einem Kanal konnte Schreibvorgänge für alle Kanäle blockieren. Zumindest vor einigen Jahren war diese Art von Ausfall möglich; vielleicht hat sich das inzwischen geändert