Egyetlen NOTIFY a tranzakcióban, és 25 ezer TPS-ből lesz 1700

A LISTEN/NOTIFY a Postgres beépített pub/subja, és tényleg jó. Csakhogy a NOTIFY commitkor egy globális zárat fog, ami libasorba állítja a notifikáló tranzakciókat: 32 kliensnél 24 759 helyett 1708 TPS-t mértem. Megnéztem, kinek van igaza a Recall.ai és a DBOS vitájában, és mit javít a PostgreSQL 19 – és mit nem.

Van az a visszatérő tanács, ami minden „egyszerűsítsd a stacked” témájú előadásban elhangzik: nem kell neked Redis meg Kafka, a Postgresben van beépített pub/sub. Ez a LISTEN/NOTIFY, ősrégi, stabil, és tényleg működik. Aztán 2025 nyarán a Recall.ai kitett egy posztot „Postgres LISTEN/NOTIFY does not scale” címmel, arról, hogyan fektette meg a produkciós adatbázisukat egyetlen NOTIFY. Mire a DBOS válaszolt egy „Postgres LISTEN/NOTIFY Actually Scales” ellenposzttal. Amikor két gyártó nyilvánosan vitatkozik egy feature-ről, és mindkettőnél van benchmark, akkor egy dolgot érdemes csinálni: lemérni magadnak. Egy postgres:18-alpine konténer és a pgbench fél óra alatt eldöntötte a kérdést – és mindkettőjüknek igaza van.

Diagram: felül négy párhuzamos zöld nyíl fut egyszerre az adatbázis-hengerbe, alul a négy narancssárga tranzakció közül csak egy halad át a lakattal zárt kapun, a többi szaggatott vonallal várakozik előtte

Harminc másodperc működéstan

A mechanizmus kellemesen egyszerű. Egy kapcsolat feliratkozik egy tetszőleges nevű csatornára a LISTEN paranccsal, egy másik pedig üzen rá a NOTIFY-jal, opcionális szöveges payloaddal. Node-ból a node-postgres ezt egy sima eseményként adja oda:

import pg from 'pg';

const client = new pg.Client({ /* kapcsolati adatok */ });
await client.connect();

client.on('notification', (msg) => {
  console.log(msg.channel, msg.payload, msg.processId);
});

await client.query('LISTEN events_channel');

Egy másik sessionből kiadott NOTIFY events_channel, 'uj esemeny erkezett'; után a callback azonnal megszólal. Két fontos tulajdonság már itt látszik. Az egyik, hogy a NOTIFY tranzakcionális: az értesítés csak commitkor megy ki, rollbacknél soha – ez nagyszerű, mert nem tudsz olyan eseményről értesíteni, ami végül nem történt meg. A másik, hogy a Postgres garantálja a kézbesítést commit-sorrendben. Ez is nagyszerű. Ez a garancia lesz egyben a probléma is.

A zár, ami a dobozon nincs feltüntetve

Az összes értesítés egyetlen központi, lemezalapú sorba kerül (a pg_notify/ könyvtárba), és a commit-sorrend garancia csak úgy tartható, ha a bejegyzések pontosan commit-sorrendben kerülnek ebbe a sorba. A Postgres ezt a lehető legegyszerűbb eszközzel oldja meg: az async.c forrásból, szó szerint:

/*
 * Serialize writers by acquiring a special lock that we hold till
 * after commit.  This ensures that queue entries appear in commit
 * order, and in particular that there are never uncommitted queue
 * entries ahead of committed ones [...]
 */
LockSharedObject(DatabaseRelationId, InvalidOid, 0,
                 AccessExclusiveLock);

Minden tranzakció, amiben volt legalább egy NOTIFY, commit előtt fog egy globális, kizárólagos zárat – nem táblára, nem sorra, hanem egy fiktív „0-ás adatbázis” objektumra (a komment maga minősíti: „which is pretty ugly”) –, és a commit legvégéig tartja. Beleértve a WAL lemezre írását is. Vagyis a notifikáló tranzakciók commitja egyesével, libasorban történik, és a Postgres szokásos trükkje, a csoportos commit, számukra kikapcsol. A zár ráadásul klaszterszintű: nem is adatbázisonként, hanem Postgres-példányonként egy darab van belőle.

Mérjük meg

Két pgbench-szkriptet futtattam Postgres 18.6-on: mindkettő egy sort szúr be egy events táblába, a második emellé betesz egy NOTIFY-t is a tranzakcióba.

BEGIN;
INSERT INTO events (payload) VALUES (:client_id || '-' || random()::text);
NOTIFY events_channel, 'uj esemeny';
COMMIT;

-- futtatás: pgbench -n -c 32 -j 4 -T 10 -f write_notify.sql
KliensekSima INSERT (TPS)INSERT + NOTIFY (TPS)
11 4421 381
43 6451 749
86 8791 768
1613 5831 733
3224 7591 708

A sima INSERT szépen skálázódik a kliensszámmal. A NOTIFY-os változat 1 700 TPS környékén beáll egy vízszintes egyenesbe: 32 párhuzamos kliens pontosan annyit tud, mint egy. Ez a teljes sorosítás aláírása. És hogy ne csak következtessünk: a 32 klienses futás közben megkérdeztem a pg_stat_activity-t, mire vár a többi 31 backend.

 wait_event_type | wait_event | count
-----------------+------------+-------
 Lock            | object     |    31
 IO              | WalSync    |     1

-- és a pg_locks ugyanebben a pillanatban:
 locktype | classid | objid |        mode         | granted | count
----------+---------+-------+---------------------+---------+-------
 object   |    1262 |     0 | AccessExclusiveLock | f       |    31
 object   |    1262 |     0 | AccessExclusiveLock | t       |     1

Harmincegy backend várakozik ugyanarra a zárra, egy tartja – és épp a WAL-t fsyncolja, a többiek pedig ezt is végignézik. Az 1262-es classid a pg_database katalógusé: ez pontosan az az „AccessExclusiveLock on object 0 of class 1262 of database 0” sor, amire a Recall.ai csapata rámeredt a logban. A számomra legmeglepőbb részlet: mindezt úgy mértem, hogy egyetlen listener sem volt. A zárat a Postgres akkor is fogja, ha a csatornát senki sem hallgatja – fizeted az árat egy üres nézőtér előtt is.

Akkor kinek van igaza?

Mindkettőnek, mert nem ugyanarról beszélnek. A kulcs az, hogy a zárat csak a notifikáló tranzakciók fogják: a sima commitok vígan mennek mellettük párhuzamosan. A Recall.ai-nál több tízezer egyidejű író dolgozott, és az írási útvonalukon ott volt a NOTIFY – így a klaszter beállt a libasorba, miközben a CPU és az I/O unatkozott. A DBOS ellenmérése ugyanezt a fizikát mutatja: soronkénti NOTIFY-jal 2 900 írás/másodpercnél ütöttek plafont, de memóriában pufferelve és kötegenként, időzítve notifikálva 60 000-ig jutottak. A zár ugyanaz, csak ritkábban kell elkérni. Ha a tranzakcióidnak csak egy kis töredéke notifikál, ebből az egészből valószínűleg soha nem látsz semmit – a legtöbb alkalmazás jóval a plafon alatt él. A gyakori csapda nem a forgalom, hanem a minta: egy jó szándékú trigger, ami egy forró tábla minden sorváltozásánál NOTIFY-t hív, pontosan a Recall.ai-forgatókönyvet építi fel, csak kicsiben.

A további apró betűs részek

Ha már mértem, végigpróbáltam a dokumentáció többi állítását is, éles üzemben ugyanis ezek is számítanak:

  • A payloadnak 8000 bájtnál rövidebbnek kell lennie. Egy pg_notify('ch', repeat('x', 8000)) azonnal ERROR: payload string too long. Adatot ne küldj benne, csak kulcsot – az adat úgyis a táblában van.
  • Tranzakción belül deduplikál. Ugyanarra a csatornára ugyanazzal a payloaddal küldött NOTIFY-okból egyetlen értesítés megy ki. A trigger-per-sor mintánál ez néha jól jön, néha viszont csendben elnyeli az eseményeid felét.
  • Az értesítés nem éli túl sem a kapcsolatot, sem a restartot. A pg_notify/ sor nem WAL-ozott, újraindításkor törlődik; ha a listener épp nem kapcsolódik, az üzenet nyomtalanul elveszik. Nincs visszajátszás.
  • A sor 8 GB-os, és egy lassú listener eldugíthatja. Ha egy feliratkozott session hosszú tranzakcióban ül, a sor nem takarítható; félig telve a log figyelmeztet, tele sornál pedig a notifikáló commitok szállnak el hibával. A pg_notification_queue_usage() megmondja, hol tartasz.
  • PgBouncer tranzakciós poolinggal a LISTEN nem működik. A feature-táblázat szerint a NOTIFY átmegy, a LISTEN viszont „Never” – session-szintű állapot, amiről a pooling-cikkben már láttuk, hogyan végzi. A listenerhez dedikált, direkt kapcsolat kell.

A PostgreSQL 19 javít – csak nem ezen

A Recall.ai-poszt kirobbantotta vita nem maradt következmények nélkül: 2026 januárjában bekerült a Postgres 19-be (jelenleg béta) Joel Jacobson optimalizációja, ami egy megosztott csatornatérképet vezet be, és csak azokat a backendeket ébreszti fel, amelyeket az adott csatorna tényleg érdekel – a commit üzenete szerint többszörös áteresztőképesség-javulás, ha sok a listener, de üzenetenként csak kevesüket érinti. Csakhogy ez a felébresztési oldal. A commitokat sorosító globális zár a mai fejlesztői ágban is pontosan ott van, ahol volt; a forráskód kommentje mindössze annyit enged meg, hogy „ha a zárat egyszer skálázhatósági okból eltávolítanánk…”. Feltételes mód, jövő idő.

Csengő, nem futár

Használnám-e productionben? Igen – csengőnek, nem futárnak. A NOTIFY arra való, hogy jelezze: érdemes ránézni az adatbázisra, nem arra, hogy ő maga vigye házhoz az adatot. A Graphile Worker pontosan így használja: a job a táblában van, a NOTIFY csak felébreszti a workert, hogy ne kelljen sűrűn pollozni – és ha az üzenet elveszik, a ritka polling akkor is megtalálja a munkát. Ez a helyes felállás: az igazság forrása a tábla (mondjuk egy SKIP LOCKED-os queue), a NOTIFY csak latency-optimalizáció. Konfigfrissítés, cache-invalidáció, „új deploy van” jelzés: tökéletes. Eseménybusz, ahol minden írás üzenet, és az elvesztett üzenet adatvesztés: arra ott a logikai replikáció vagy egy tényleges üzenetsor. A legtöbb csapat sosem fogja elérni ezt a plafont – de aki triggerből notifikál egy forró táblán, az nem tudni fogja, hanem megméri. Csak nem szándékosan.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük