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.

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
| Kliensek | Sima INSERT (TPS) | INSERT + NOTIFY (TPS) |
|---|---|---|
| 1 | 1 442 | 1 381 |
| 4 | 3 645 | 1 749 |
| 8 | 6 879 | 1 768 |
| 16 | 13 583 | 1 733 |
| 32 | 24 759 | 1 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))azonnalERROR: 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
- PostgreSQL dokumentáció: NOTIFY
- postgres/postgres – src/backend/commands/async.c
- Optimize LISTEN/NOTIFY via shared channel map and direct advancement (PostgreSQL 19 commit)
- Recall.ai: Postgres LISTEN/NOTIFY does not scale
- DBOS: Postgres LISTEN/NOTIFY Actually Scales
- PgBouncer: Features – SQL feature map for pooling modes
- Graphile Worker dokumentáció