A PgBouncer tranzakciós pooling módja csendben átadja a session-állapotot a következő kliensnek

A PgBouncer transaction pooling módja gyorsabb, mert a szerveroldali kapcsolat tranzakciónként cserélődik – de emiatt a SET, az advisory lock és a LISTEN/NOTIFY is átszivárogtathat egy teljesen másik kliensre. Hands-on demóval bizonyítva.

A legtöbb Node.js/Postgres projektben egyszer eljön a pillanat, amikor valaki bedob egy PgBouncert az alkalmazás és az adatbázis közé. A Postgres max_connections paramétere alapesetben 100, mert minden kapcsolat egy önálló szerveroldali folyamat – miközben egy forgalmasabb backend percenként ezres nagyságrendben nyithatna kapcsolatot, ha hagynák. A beállítás triviálisnak tűnik: pool_mode = transaction, mert ez adja a legjobb átbocsátást. Csak kevesen gondolják végig, mit adnak fel érte.

Diagram: két kliens, egy közös PgBouncer kapcsolatpool és egy Postgres-adatbázis – az egyik kapcsolat-foglalat állapota (sárga pont) a szaggatott piros nyíllal a másik, független klienshez kerül át.

A PgBouncer három pooling módot ismer. session módban egy kliens a kapcsolódásától a lecsatlakozásáig ugyanazt a szerveroldali Postgres-kapcsolatot használja – ez a legbiztonságosabb, de nem skálázódik jobban, mint a sima Postgres. statement módban minden lekérdezés után visszakerül a kapcsolat a poolba, tranzakció ilyenkor csak egyetlen statementből állhat. A webes backendek túlnyomó része a köztes transaction módot használja: a szerveroldali kapcsolat a tranzakció végén – nem a kliens lecsatlakozásakor – kerül vissza a közös poolba, onnantól pedig egy másik kliens tranzakciója kaphatja meg (pool_mode dokumentáció).

Ez önmagában ártalmatlanul hangzik, amíg nem gondolod végig, hogy a „session” fogalma innentől kettéesik. A klienskönyvtárad (node-postgres, psycopg2, JDBC-driver) szempontjából egyetlen hosszú életű TCP-kapcsolat van a PgBounceren keresztül. A tényleges Postgres-oldali session viszont tranzakciónként cserélődhet – és minden, amit Postgres a session állapotának tekint, ezen a ponton elszakad attól a klienstől, aki beállította.

A demó: amit egy másik kliens session-jéből kapsz

Ahelyett hogy csak elmondanám, inkább összeraktam egy minimális Postgres 16 + PgBouncer 1.25.2 konténerpárt (pool_mode = transaction, default_pool_size = 1, hogy biztosan egyetlen szerveroldali kapcsolatot osszanak meg). Az „A kliens” beállít egy search_path-t, majd lezárja a kapcsolatát. Utána egy vele semmilyen kapcsolatban nem álló, teljesen friss „B kliens” csatlakozik, és csak lekérdezi a saját search_path-ját:

# A kliens: beállítja a search_path-ot, majd lecsatlakozik
$ psql -h pgbouncer -p 6432 -U app -d testdb
testdb=# SET search_path TO tenant_a;
SET
testdb=# \q

# B kliens: teljesen új, független kapcsolat
$ psql -h pgbouncer -p 6432 -U app -d testdb -c "SHOW search_path;"
 search_path
-------------
 tenant_a
(1 row)

B kliens soha nem futtatott semmilyen SET parancsot, mégis A kliens beállítását látja. Ha ez multi-tenant alkalmazásban a bérlő azonosítására szolgáló schema-váltás lett volna (ami a search_path-os megoldásoknál gyakori minta), akkor B kliens most A bérlő adatait látná a sajátjaként – csendben, hibaüzenet nélkül.

Ennek pontos oka a server_reset_query beállítás: ez a parancs (alapértéken DISCARD ALL) törli a kapcsolat állapotát, mielőtt PgBouncer visszaadná a poolnak – de csak session pooling módban fut le. A dokumentáció szó szerint kimondja: tranzakciós módban a kliensek nem használhatnak session-szintű funkciókat, mert minden tranzakció más kapcsolaton végezheti, más állapottal (server_reset_query dokumentáció). A reset nem azért marad ki, hogy elrontsa a napodat – azért, hogy ne kelljen minden tranzakció után egy plusz round-tripet fizetni a Postgres felé, ami épp a pooling teljesítményelőnyét nyelné el.

Mi számít még „session-szintűnek”

  • SET (session-szintű konfiguráció, pl. search_path, statement_timeout) – ahogy fent láttuk, tetszőlegesen átszivárog vagy elveszik.
  • pg_advisory_lock() – a dokumentáció szerint „session-level advisory lock”-ot ad; ha a lezáró tranzakció közben más kapja meg a háttérkapcsolatot, a te alkalmazásod elveszíti a fogantját a saját zárja fölött (Postgres advisory lock dokumentáció).
  • LISTEN/NOTIFY – a LISTEN „a jelenlegi sessiont regisztrálja figyelőként”, és a regisztráció a session végével automatikusan törlődik (LISTEN dokumentáció). Transaction pooling alatt nincs garantált, folyamatosan élő session a klienshez rendelve, így a feliratkozás bármikor eltűnhet – vagy egy másikéhoz kerülhetsz hozzá.
  • Prepared statement-ek SQL szinten (PREPARE/EXECUTE/DEALLOCATE) – ezeket PgBouncer nyers formában továbbküldi a szerverre, nem követi őket, tehát pont úgy köthetnek egy konkrét háttérkapcsolathoz, mint a SET.

A gyakorlati megoldás a legtöbb esetre: SET helyett SET LOCAL, ami a dokumentáció szerint csak „az aktuális tranzakció végéig” él (SET dokumentáció) – pont a határ, amit PgBouncer is figyel. Zárolásnál pg_advisory_lock() helyett pg_advisory_xact_lock(), ami automatikusan feloldódik a tranzakció végén, nem marad ott árválkodva egy idegen kapcsolaton. LISTEN/NOTIFY-hoz pedig egyszerűen nem PgBouncert használunk: ezekhez egy közvetlen, session pooling módú (vagy pooler nélküli) kapcsolatot kell nyitni.

A prepared statement-ek nem (csak) régi rossz hír

Ha korábban dolgoztál PgBouncerrel, valószínűleg találkoztál a tanáccsal, hogy „kapcsold ki a prepared statement-eket, mert PgBouncer alatt törnek”. 2023 óta ez már csak félig igaz. A PgBouncer 1.21.0-s kiadása („The one with prepared statements”) bevezette a protokollszintű named prepared statement-ek követését: a max_prepared_statements beállítás (alapértéke 200) bekapcsolva PgBouncer maga gondoskodik arról, hogy egy kliens által elkészített statement bármelyik háttérkapcsolaton elérhető legyen, akkor is, ha az adott tranzakciót egy másik szerveroldali kapcsolat szolgálja ki, mint amelyiken eredetileg preparálták (max_prepared_statements dokumentáció, 1.21.0 release notes).

Ezt is leteszteltem: a node-postgres klienssel egy névvel ellátott (name: 'get-one') paraméteres lekérdezést futtattam négyszer, ugyanazon a PgBouncer transaction pooling kapcsolaton keresztül, és minden hívás hibamentesen visszaadta az eredményt – pedig a mögöttes háttérkapcsolat elméletileg tranzakciónként cserélődhetett volna.

const client = new pg.Client({ host: 'pgbouncer', port: 6432, /* ... */ });
await client.connect();

for (let i = 0; i < 4; i++) {
  const res = await client.query({
    name: 'get-one',
    text: 'SELECT $1::int AS n',
    values: [i],
  });
  console.log(`kör ${i}: OK, n=${res.rows[0].n}`);
}
// kör 0: OK, n=0
// kör 1: OK, n=1
// kör 2: OK, n=2
// kör 3: OK, n=3

Ettől persze nem lesz teljesen kockázatmentes a téma. A fenti követés csak a protokollszintű, paraméteres prepared statement-ekre vonatkozik – az SQL-szintű PREPARE-re nem. És a való életben nem csak PgBouncerrel találkozol: sok managed pooler (más gyártótól, vagy PgBouncer régebbi verziójából) még nem támogatja ezt, ezért a Prisma ORM dokumentációja a mai napig pgbouncer=true kapcsolóval ajánlja letiltani a saját named prepared statement-jeit poolerek mögött, ha nem vagy biztos a pooler képességeiben (Prisma – PgBouncer beállítási útmutató).

Mikor éri meg egyáltalán transaction pooling

A legtöbb stateless, kérés-válasz jellegű webes lekérdezésnek – egy tranzakción belüli SELECT/INSERT/UPDATE, tranzakció commit, kapcsolat vissza a poolba – semmi baja nem lesz a transaction poolingtől, és pontosan ez a mód hozza a throughput-előnyt, amiért egyáltalán pooler kell. A gond csak azoknál a funkcióknál jön elő, amelyek a session élettartamára, nem a tranzakció élettartamára számítanak. PgBouncer ezt adatbázisonként vagy felhasználónként külön is konfigurálhatóvá teszi (pool_mode felülírható a [databases] vagy [users] szekcióban) – ha van egy migrációs vagy háttérfeldolgozó folyamatod, ami LISTEN-re vagy session-szintű zárakra épít, azt érdemes külön, session pooling módú útvonalon vagy közvetlenül a Postgreshez kötni, a többit pedig hagyni transaction pooling alatt futni.

A tanulság nem az, hogy kerüld a PgBouncert – a tanulság az, hogy a "kapcsolat" szó mást jelent a klienskódodnak és a Postgres szerverfolyamatnak, mihelyt pooler van közéjük ékelve. Amíg ezt nem futtatod le magad, könnyű azt hinni, hogy csak egy performance-optimalizálásról van szó.

Források

Leave a Reply

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