Now-Next

SaaS-architectuur

Row-level security in PostgreSQL: wat kost het, nagemeten

Wat een RLS-beleid kost in PostgreSQL 17: volatiliteit van functies, de (select …)-omhulling, lidmaatschapsqueries en de leakproof-regel die je index uitzet.

Chris van Eijk · · 9 min lezen

Row-level security kost niets — als het beleid een eenvoudige vergelijking is en de query alleen vraagt wat het beleid al afbakent. Dat maten we eerder: met de juiste index ís het beleid de indexopzoeking. Maar een beleid kun je op veel manieren schrijven, en een query kan om meer vragen dan het beleid weet. Eén van de varianten die we maten maakte elke query 80 milliseconden of meer; een andere kostte bijna twee seconden; en één regel die makkelijk over het hoofd wordt gezien maakte van een opzoeking van 0,2 ms er een van 34 tot 45 ms — vooral voor de grootste klant.

Wat kost een row-level-security-beleid?

Niets meetbaars, in de eenvoudigste vorm. Op een tabel met 2 miljoen facturen waren dezelfde queries met het beleid tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid even snel als zonder row-level security met een expliciete WHERE tenant_id = …: 11,0 tegen 11,0 ms om de 267.023 facturen van de grootste klant te tellen, 0,30 tegen 0,34 ms voor de laatste 50 van een middelste klant. De kosten komen van drie andere plekken: hoe het beleid aan de klant komt, of het lidmaatschappen opzoekt, en welke functies de query zelf gebruikt.

De opzet

PostgreSQL 17.10 in een wegwerpcontainer, standaardinstellingen, warme cache, mediaan van vijf metingen. De tabel uit PostgreSQL-indexen voor multi-tenant SaaS: 1.998.794 facturen van 1.000 klanten van zeer ongelijke omvang — de grootste heeft er 267.023, de middelste 534 — met een index op (tenant_id, issued_on). We voegden een kolom customer_email toe met twee indexen, (tenant_id, lower(customer_email)) en (tenant_id, customer_email text_pattern_ops), en later een derde op een gegenereerde kolom. De applicatie verbindt als een rol zonder BYPASSRLS; de vergelijking zonder row-level security draait als eigenaar van de tabel, met de klant in de WHERE. We maakten het beleid voor elke reeks opnieuw aan, waardoor opgeslagen plannen vervallen; elk getal hieronder hoort dus bij een plan dat voor die klant is gemaakt — waarom dat uitmaakt, staat aan het eind van de volgende paragraaf.

Maakt het uit hoe het beleid aan de klant komt?

Alleen als de functie niet kan worden ingevoegd (inlining). Veel teams stoppen de opzoeking in een hulpfunctie, tenant_id = app.current_tenant(). We maten dat in vijf vormen, voor twee queries: alle facturen tellen, en de laatste 50.

BeleidGrootste klant, tellenGrootste klant, laatste 50Middelste klant, tellenMiddelste klant, laatste 50
geen RLS, WHERE tenant_id = …11,0 ms0,39 ms0,20 ms0,34 ms
nullif(current_setting(…), '')::uuid11,0 ms0,37 ms0,15 ms0,30 ms
SQL-functie, VOLATILE (de standaard)15,0 ms0,29 ms0,15 ms0,29 ms
SQL-functie, STABLE15,1 ms0,29 ms0,15 ms0,29 ms
PL/pgSQL-functie, VOLATILE (de standaard)1.877 ms1.920 ms1.860 ms1.884 ms
PL/pgSQL-functie, STABLE14,5 ms0,29 ms0,15 ms0,30 ms
(select …) om de PL/pgSQL-functie VOLATILE14,6 ms0,30 ms0,16 ms0,30 ms

De SQL-functie was snel, of hij nu VOLATILE of STABLE was gedeclareerd: PostgreSQL voegde hem in, en EXPLAIN toonde de expressie nullif(current_setting(…)) zelf als Index Cond, zonder de functie. Een PL/pgSQL-functie kan niet worden ingevoegd. Gedeclareerd als VOLATILE, wat je krijgt als je niets declareert, moet hij voor elke rij opnieuw worden aangeroepen; de index is dan onbruikbaar en de query wordt een sequentiële scan over 2 miljoen rijen — ook voor een klant met 534 facturen. STABLE lost het op, en de aanroep in (select …) verpakken ook, omdat die er een waarde van maakt die één keer per query wordt berekend. Dat advies lees je vaak; het klopt, en voor een SQL-functie die kan worden ingevoegd is het overbodig.

Inlining heeft voorwaarden, en twee gangbare beveiligingsgewoonten breken die. Een SQL-functie met SECURITY DEFINER, of met een SET search_path-clausule, wordt niet meer ingevoegd. Gedeclareerd als STABLE maakte dat niet uit — 14,6, 0,31, 0,18 en 0,31 ms — maar een VOLATILE SQL-functie kostte 3.804 ms met SECURITY DEFINER en 4.600 ms met SET search_path — een sequentiële scan, net als de PL/pgSQL-functie. Declareer de hulpfunctie dus als STABLE, in welke taal ook; dan hangt het niet van inlining af.

Het tellen voor de grootste klant bleef met elke functie op zo’n 15 ms in plaats van 11. Dat is parallellisme: een functie is PARALLEL UNSAFE tenzij je anders declareert, en de documentatie zegt letterlijk dat zo’n functie “forces a serial execution plan”. Als STABLE PARALLEL SAFE kreeg de hulpfunctie zijn parallelle plan terug: 10,2 tot 10,9 ms voor de SQL-functie, 11,2 tot 11,8 ms voor de PL/pgSQL-functie.

Dat parallelle plan heeft een prijs op een gedeelde verbinding. psycopg bereidt een query automatisch voor zodra hij vaker dan vijf keer op een verbinding is uitgevoerd, en een statement zonder parameters houdt het plan van zijn eerste klant. Met die standaard, op één directe verbinding, kostte het tellen van de facturen van de middelste klant direct na die van de grootste 7,3 ms in plaats van 0,2 — met de parallel-veilige hulpfunctie en met het gewone current_setting()-beleid allebei, omdat beide nu een parallel plan hadden om door te geven. Met het seriële plan van een niet-parallel-veilige hulpfunctie bleef het 0,15 ms. Achter een connection pooler wordt dit erger; dat maten we apart in PgBouncer en RLS.

Wat kost een lidmaatschapsquery in het beleid?

Zo’n 80 milliseconden bij elke query, op deze tabel. Als gebruikers bij meerdere klanten kunnen horen, vraagt een gangbaar beleid de lidmaatschapstabel rechtstreeks:

using (tenant_id in (
  select m.tenant_id from memberships m
  where m.user_id = nullif(current_setting('app.user_id', true), '')::int))
BeleidGrootste klant, tellenGrootste klant, laatste 50Middelste klant, tellenMiddelste klant, laatste 50
klant in een instelling11,0 ms0,37 ms0,15 ms0,30 ms
tenant_id in (select … from memberships …)83–85 ms95–99 ms79–81 ms79–82 ms
tenant_id = any (array(select …))14,4 ms158 ms0,16 ms0,74 ms

Met in (select …) had de planner geen enkele waarde meer om in de index op te zoeken. Hij toetste het beleid rij voor rij aan de lijst met lidmaatschappen, en de 534 facturen van de middelste klant kostten evenveel als de 267.023 van de grootste: 80 ms voor een lijst die 0,30 ms kost. Herschreven als = any (array(select …)) krijgt de planner een waardenlijst die één keer wordt berekend, en dat loste het tellen op — maar de laatste 50 van de grootste klant werden trager, 158 ms, omdat het plan nu al zijn rijen via een bitmap ophaalde en sorteerde in plaats van de index achterwaarts af te lopen.

De versie die overal snel was, is de eerste regel: los het lidmaatschap één keer op, bij het begin van het verzoek, en zet de gekozen klant in de instelling. Het beleid vergelijkt dan met één waarde. De controle dat de gebruiker bij die klant hoort, verhuist naar de code die de klant instelt — één query per verzoek in plaats van één per rij.

Waarom gebruikt PostgreSQL mijn index niet meer onder row-level security?

Door een regel voor functies die niet leakproof zijn. De PostgreSQL-documentatie: “The system will enforce conditions from security policies and security barrier views before any user-supplied conditions from the query itself that contain non-leakproof functions, in order to prevent the inadvertent exposure of data.” Een functie die iets over een rij kan verraden — via een foutmelding, bijvoorbeeld — mag geen rijen zien die het beleid nog niet heeft goedgekeurd.

lower() is niet leakproof. LIKE ook niet. Een opzoeking die zonder row-level security direct antwoord geeft:

select count(*) from invoices
where lower(customer_email) = lower($1);

— gebruikt onder het beleid de index alleen voor de klant, en rekent daarna lower() uit op elke rij van die klant. EXPLAIN voor de grootste klant liet een Index Cond op alleen tenant_id zien, met het e-mailadres als Filter en Rows Removed by Filter: 89,005 in elk van drie parallelle processen: alle 267.015 andere rijen.

QueryGrootste klant (267.023 rijen)Middelste klant (534 rijen)
geen RLS, lower(customer_email) = lower($1)0,21 ms0,19 ms
RLS, dezelfde query34–45 ms0,38–0,40 ms
RLS, met tenant_id = $1 erbij in de query44,8 ms0,37 ms
RLS, opgeslagen gegenereerde kolom customer_email_lower = lower($1)0,25 ms0,19 ms
geen RLS, customer_email like 'klant12%'0,24 ms0,20 ms
RLS, dezelfde LIKE22–24 ms0,25–0,28 ms
RLS, customer_email ~>=~ $1 and customer_email ~<~ $20,30 ms—

Dit is de val in multi-tenant-vorm: de middelste klant merkt er nauwelijks iets van, omdat 534 rijen doorlopen snel gaat. De kosten groeien met de omvang van de klant, dus ze landen volledig bij je grootste klant, en een test met kleine klanten laat ze niet zien.

Zelf de klant in de query zetten helpt niet: de gelijkheid op uuid werd al gebruikt; het probleem is de andere voorwaarde. Wat helpt, is de voorwaarde zelf leakproof maken. De gelijkheidsoperator op text is dat, dus een opgeslagen gegenereerde kolom met lower(customer_email) en een index op (tenant_id, customer_email_lower) bracht de opzoeking terug naar 0,25 ms. lower($1) aan de kant van de parameter mag: de documentatie voegt toe dat functies “which are not passed any arguments from the security barrier view or table do not have to be marked as leakproof”. Voor zoeken op een begin zijn de text_pattern_ops-operatoren ~>=~ en ~<~ leakproof, en het bereik zelf uitschrijven bracht 22 ms terug naar 0,30 ms.

lower() zelf als leakproof markeren kan, maar alleen als superuser, en het is een belofte over de functie van een ander. Wij zouden het niet doen.

Hoe vind je deze gevallen in je eigen database?

Twee controles. Beleidsregels waarvan de expressie een volatiele functie aanroept:

select p.tablename, p.policyname, pr.oid::regprocedure as function
from pg_policies p
join pg_proc pr
  on position(pr.proname || '(' in coalesce(p.qual, '') || ' ' || coalesce(p.with_check, '')) > 0
where pr.provolatile = 'v'
  and pr.pronamespace not in ('pg_catalog'::regnamespace, 'information_schema'::regnamespace)
order by 1, 2;

Deze query zoekt op de functienaam in de tekst van het beleid; twee functies met dezelfde naam in verschillende schema’s kunnen dus een vals alarm geven. Op een testtabel met zeven beleidsregels meldde hij precies de vier die een VOLATILE functie gebruiken: de PL/pgSQL-functie, die met SECURITY DEFINER, de ingevoegde SQL-functie — snel, maar alleen zolang niemand SECURITY DEFINER toevoegt — en die in (select …), die snel is. Elke treffer is het waard om als STABLE te declareren. En voor de leakproof-regel: draai EXPLAIN op je traagste schermen als de applicatierol, niet als eigenaar. Een voorwaarde die als eigenaar een Index Cond is en als applicatie een Filter, is dit probleem.

Waarom het tellen van de grootste klant vijf keer trager kan worden zonder dat er aan het beleid iets verandert — de visibility map, en een autovacuumdrempel die geen enkele klant alleen haalt — staat in tellen per klant in PostgreSQL.

Wat we niet hebben gemeten

PostgreSQL 18, een koude cache, tabellen groter dan het geheugen, beleid met WITH CHECK bij schrijven, en ingewikkelder lidmaatschapsmodellen met rollen per klant. De tijden komen van één machine met standaardinstellingen; lees ze als verhoudingen. Die verhoudingen zijn consequent: elk traag geval hierboven verloor een indexvoorwaarde of een indexvolgorde die de query nodig had, en elke oplossing gaf die terug.

Waarom het eenvoudigste beleid ook het veiligste is — en de vijf manieren waarop row-level security stilletjes niet werkt — staat in row-level security in PostgreSQL: waar tenants lekken.

Bronnen: de PostgreSQL 17-documentatie over CREATE FUNCTION (LEAKPROOF, en PARALLEL UNSAFE en VOLATILE als standaard), gelezen op 30 september 2026. Alle metingen zijn van onszelf, in PostgreSQL 17.10, op 30 september en 1 oktober 2026.

Laten we praten

Wat gaan we bouwen?

Eén gesprek is genoeg om te weten of we bij elkaar passen. Vertel wat je voor je ziet — wij zeggen hoe snel dat kan, en of wij daar de juiste partij voor zijn.

Start het gesprek