SaaS-architectuur
PgBouncer en RLS: klantcontext, SET LOCAL, gedeelde plannen
RLS achter PgBouncer in transactiemodus, nagemeten: welke klantcontext lekt, wat SET LOCAL buiten een transactie doet, en één plan voor alle klanten.
Elke handleiding over row-level security achter een connection pooler geeft hetzelfde advies: gebruik SET LOCAL, niet SET. Dat advies klopt, en in row-level security in PostgreSQL: waar tenants lekken gaven wij het ook, zonder het na te meten. Dat hebben we nu gedaan: PgBouncer in transactiemodus voor een tabel met 2 miljoen facturen van 1.000 klanten, met de klant in current_setting('app.tenant_id') en een beleid op elke rij.
Het lek via SET is echt, en het is de minst interessante uitkomst. Twee andere zaken zijn minder bekend: SET LOCAL buiten een transactie doet stilletjes niets, zodat je op een verbinding die een ander heeft vervuild de klant van die ander leest; en PgBouncer deelt één prepared statement, en daarmee één queryplan, tussen alle clients die dezelfde SQL sturen — ook als die voor verschillende klanten werken.
Lekt klantcontext door PgBouncer in transactiemodus?
Ja, met SET of set_config(…, false). Eén client voerde SET app.tenant_id = '<grootste klant>' uit en verbrak de verbinding; een tweede client die helemaal niets instelde, telde 267.023 rijen — alle facturen van de grootste klant. Hetzelfde met set_config(…, false) voor de middelste klant: de volgende client, zonder context, zag de 534 facturen van die klant.
Met SET LOCAL binnen een expliciete transactie zag de volgende client 0 rijen. De instelling eindigt met de transactie, en in transactiemodus is dat precies het moment waarop PgBouncer de serververbinding aan een ander geeft.
PgBouncer ruimt niet voor je op. De documentatie zegt letterlijk dat bij transaction pooling “the server_reset_query is not used, because in that mode, clients must not use any session-based features”. De DISCARD ALL die je misschien kent van session pooling draait nooit.
De opzet
PostgreSQL 17.10 en PgBouncer 1.25.2 in één container, pool_mode = transaction, default_pool_size = 1, zodat elke client op dezelfde serververbinding uitkomt — in productie met meer verbindingen is het toeval welke je krijgt, en wordt elke uitkomst hieronder een kans in plaats van een zekerheid. max_prepared_statements = 200, de standaard sinds PgBouncer 1.24. De client is psycopg 3.3.4, in autocommit.
De tabel is die uit PostgreSQL-indexen voor multi-tenant SaaS: 1.998.794 facturen (146 MB), de grootste klant 267.023, de middelste 534, de kleinste 267, een index op (tenant_id, issued_on). De applicatie verbindt als een rol zonder BYPASSRLS, met FORCE ROW LEVEL SECURITY op de tabel en dit beleid:
create policy tenant_isolation on invoices
using (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
Wat doet SET LOCAL buiten een transactie?
Niets, op een waarschuwing na. De PostgreSQL-documentatie zegt het in één regel: “Issuing this outside of a transaction block emits a warning and otherwise has no effect.” In autocommit is elke opdracht zijn eigen transactie, dus een SET LOCAL op een eigen regel staat erbuiten.
set_config(…, true) is erger, want die waarschuwt niet eens. Hij geeft de klant terug die je meegaf — een controle op de returnwaarde slaagt dus — en de eerstvolgende opdracht ziet een lege instelling, omdat de transactie die hem zette de SELECT set_config(…) zelf was.
| Wat de client deed, in autocommit | Waarschuwing | Wat de volgende query zag |
|---|---|---|
SET LOCAL app.tenant_id = '<middelste>' | SET LOCAL can only be used in transaction blocks | 0 rijen |
SELECT set_config('app.tenant_id', '<middelste>', true) | geen — geeft de klant-id terug | 0 rijen |
SET LOCAL … '<middelste>' op een verbinding waar een eerdere client SET … '<grootste>' deed | dezelfde waarschuwing | 267.023 rijen van de grootste klant |
De laatste regel is die om te onthouden. Geen van beide fouten lekt op zichzelf: SET lekt alleen naar een client die niets instelt, en SET LOCAL buiten een transactie geeft alleen nul rijen. Samen las een verzoek voor de middelste klant de facturen van de grootste. Een waarschuwing in een log is het enige spoor.
De oplossing is geen andere instelling maar een expliciete transactie om de hele werkeenheid — BEGIN, klant instellen, de queries, COMMIT — en een controle: lees na het instellen current_setting('app.tenant_id', true) terug in een aparte opdracht binnen dezelfde transactie. Vergelijk met de klant die je zette, niet alleen met leeg: in autocommit op een schone verbinding gaf het teruglezen een lege string, maar op een vervuilde verbinding geeft het de klant van de vorige client.
Wat geeft het beleid terug als de klant ontbreekt?
Dat hangt af van hoe je het beleid schreef, en bij één gangbare vorm van welke serververbinding je toevallig krijgt. We lieten een client zonder context twee keer lezen: één keer op een verse serververbinding, één keer op een verbinding waarop een eerdere client SET LOCAL netjes had gebruikt.
| Beleidsexpressie | Verse serververbinding | Eerder gebruikte serververbinding |
|---|---|---|
current_setting('app.tenant_id')::uuid | fout: unrecognized configuration parameter | fout: invalid input syntax for type uuid: "" |
current_setting('app.tenant_id', true)::uuid | 0 rijen | fout: invalid input syntax for type uuid: "" |
nullif(current_setting('app.tenant_id', true), '')::uuid | 0 rijen | 0 rijen |
De eerste vorm faalt luid, altijd. De derde geeft niets, altijd. De tweede — die de meeste handleidingen laten zien — geeft niets op een verse verbinding en een fout op een verbinding die al iemand heeft bediend, omdat een instelling die één keer heeft bestaan daarna een lege string is en geen NULL. Achter een pooler betekent dat: hetzelfde verzoek slaagt of faalt, afhankelijk van de verbinding die het trekt. Kies bewust de eerste of de derde; de tweede is kop of munt.
Deelt PgBouncer een queryplan tussen klanten?
Ja. Sinds versie 1.21 kan PgBouncer prepared statements op protocolniveau volgen in transactiemodus, sinds 1.24 staat dat standaard aan, en de documentatie beschrijft hoe: “if the same query string is prepared multiple times (possibly by different clients), then these queries share the same internal name.” Eén statement per serververbinding, één plan, voor elke client die erop terechtkomt.
Onder RLS doet dat ertoe, want een query die zijn klant uit current_setting() haalt heeft geen parameters, en een statement zonder parameters gebruikt altijd een generiek plan — het plan van de eerste uitvoering, met de statistieken van die klant. We maten een totaal per status, select status, count(*), sum(amount) from invoices group by status, voorbereid door de ene client en daarna uitgevoerd door een tweede client op een eigen verbinding, mediaan van vijf metingen:
| Eerste client | Tweede client | Via PgBouncer | Direct, elke client een eigen verbinding | Niet voorbereid |
|---|---|---|---|---|
| grootste klant | middelste klant | 7,6 ms | 0,62 ms | 0,75 ms |
| middelste klant | grootste klant | 137 ms | 77 ms | 77 ms |
Direct op PostgreSQL bereidt elke client zijn eigen statement voor en krijgt hij zijn eigen plan. Via PgBouncer kreeg de tweede client het plan van de eerste: tien keer te traag voor de kleine klant, 1,8 keer voor de grote. pg_prepared_statements op de server liet één statement zien, PGBOUNCER_1, met zes generieke uitvoeringen voor twee clients. We herhaalden de hele reeks; geen getal verschoof meer dan 1,1 ms.
De gegevens lekten niet. In elke meting kreeg elke client precies de rijen van zijn eigen klant; het beleid wordt bij elke uitvoering opnieuw geëvalueerd. Wat gedeeld wordt is het plan, en daarmee bepaalt de klant die die SQL als eerste op die serververbinding stuurde hoe snel het voor alle anderen is — in een drukke pool per verbinding een andere.
Je hoeft zelf niets voor te bereiden om dit te krijgen. psycopg bereidt een query automatisch voor zodra hij vaker dan prepare_threshold keer op een verbinding is uitgevoerd; standaard is dat 5.
Helpt het om tenant_id als parameter mee te geven?
Niet op zichzelf. In het indexenartikel was een parameter de oplossing: where tenant_id = $1 naast het beleid, zodat de planner weet voor welke klant hij plant. Direct op PostgreSQL, met één klant per sessie, klopt dat. De regel van PostgreSQL — vijf specifieke plannen, daarna het generieke plan als de geschatte kosten daarvan niet veel hoger zijn dan het gemiddelde van die vijf, zoals beschreven bij PREPARE — telt per prepared statement, en achter PgBouncer verzamelt dat statement de uitvoeringen van alle clients. Een pool in je applicatie die één sessie aan veel klanten geeft, doet hetzelfde; wij maten het via PgBouncer. Na tien uitvoeringen voor de middelste klant kreeg de grootste klant een generiek plan:
| Query met parameter, eerst 10× voor | Daarna voor | plan_cache_mode = auto | force_custom_plan | Niet voorbereid |
|---|---|---|---|---|
| middelste klant | grootste klant | 140 ms | 78 ms | 79 ms |
| grootste klant | middelste klant | 0,66 ms | 0,74 ms | 0,75 ms |
De oplossing is één regel op de rol waarmee de applicatie verbindt:
alter role app set plan_cache_mode = force_custom_plan;
Daarmee werd elke uitvoering van de query met parameter gepland voor zijn eigen klant, en kwamen de tijden overeen met die van een niet-voorbereide query. De instelling geldt voor nieuwe serververbindingen, dus laat PgBouncer daarna opnieuw verbinden. Voor de variant zonder parameter doet hij niets — we maten 138 ms met de instelling aan — omdat plan_cache_mode alleen kiest tussen plannen voor statements mét parameters. Je hebt ze allebei nodig: de klant als parameter, en specifieke plannen afgedwongen.
De prijs is plantijd bij elke uitvoering, voor een query als deze een fractie van een milliseconde. Het alternatief is dat het scherm van je grootste klant zo snel is als het plan dat een kleine klant toevallig achterliet.
Een controlelijst voor RLS achter een pooler
- Nooit
SETofset_config(…, false)voor de klant. Zoek in je code op allebei. Eén keer is genoeg om een serververbinding te vervuilen voor elke client daarna. - Eén expliciete transactie per werkeenheid, met de klant daarbinnen ingesteld via
SET LOCALofset_config(…, true). Niet in autocommit. - Lees de klant terug in een aparte opdracht na het instellen, en stop als het niet de klant is die je zette.
- Schrijf het beleid zo dat een ontbrekende klant altijd hetzelfde oplevert:
nullif(…, '')voor nul rijen, ofcurrent_setting()zondermissing_okvoor een fout. Niet de vorm ertussenin. - Geef de klant ook als parameter mee, en zet
plan_cache_mode = force_custom_planop de applicatierol als je verbindingen poolt en prepared statements gebruikt.
Achtergrondworkers zijn de plek waar punt 1 en 2 het vaakst misgaan, omdat één worker alle klanten om de beurt bedient. Hoe je voorkomt dat één van die klanten de rest laat wachten, staat in takenwachtrij per klant in PostgreSQL.
SET ROLE in plaats van een variabele ontloopt hier niets van: het lekt op precies dezelfde manier door de pooler, en kost per wissel meer naarmate je meer klanten hebt — gemeten in rol per klant of sessievariabele voor RLS in PostgreSQL.
Wat we niet hebben gemeten
Session pooling, andere poolers (Supavisor, RDS Proxy, PgCat), PgBouncer vóór 1.21, andere drivers dan psycopg, en een pool met meer dan één serververbinding. De lekken en de plancache zijn van PostgreSQL en PgBouncer; of het gedeelde plan jou bereikt, hangt af van of en wanneer je driver prepared statements op protocolniveau gebruikt. We verwachten elders hetzelfde gedrag — maar dat is een verwachting, geen meting. De tijden komen van één machine met standaardinstellingen en een warme cache; lees ze als verhoudingen.
Bronnen: de PostgreSQL 17-documentatie over SET, PREPARE en plan_cache_mode, de configuratiereferentie van PgBouncer (max_prepared_statements, server_reset_query) en de changelog (1.21, 1.24), en de psycopg-documentatie over prepared statements, alle gelezen op 30 september 2026. Alle metingen zijn van onszelf, in PostgreSQL 17.10, PgBouncer 1.25.2 en psycopg 3.3.4, op 30 september 2026.