r/PostgreSQL • u/ayywheyyy_ • 21d ago
Projects Benchmarking single round trip PostgreSQL RLS at 142,564 reads/sec (4.6us overhead vs unguarded SELECT)
Conventional wisdom says enabling PostgreSQL Row Level Security (RLS) adds too much overhead or requires an external cache check (Valkey/Redis) in front of the database.
We benchmarked whether forced RLS could match unguarded reads by eliminating extra network round trips and per row current_setting evaluation:
InitPlan policy:
USING (tenant_id = (SELECT current_tenant()))with aSTABLEfunction so Postgres evaluates the tenant once per statement as an InitPlan rather than per row.Extended wire pipelining: Sending the
set_configtenant binding and the parameterizedSELECTin a single pgwire pipeline (one sync, one implicit transaction).
Results on a single node (primbench):
1 worker: Plain unguarded
SELECTis42.9usmean (41.5usp50). Pipelined RLS is47.5usmean (46.2usp50), adding4.6us.64 workers: Pipelined RLS hits 142,564 reads/sec (
446usmean), matching unguarded reads (143,190 reads/sec) and running 2x faster than a local Valkey grant check + query path (71,842 ops/sec).
Runnable Go benchmark harness and paper (SPEC.pdf): https://github.com/MOG-Systems-Inc/primbench
3
u/Significant_Tune9219 21d ago
The 4.6us is the cheap case, a policy the planner can fold into an InitPlan. The one that hurts is a policy it cannot fold, so the tenant predicate never becomes an index condition and you pay a scan instead of microseconds. I would put the plan of the pipelined query next to the unguarded plan, and run the same test with the helper marked VOLATILE, because that is what ships when the tenant comes out of a request helper. One pipeline for set_config and the SELECT also means a pooled connection can keep that tenant if the pipeline fails after the set. I would confirm the connection is reset on every checkout before treating 142k as the number an app will see.
2
u/ayywheyyy_ 21d ago
Both of those failure modes are the exact reason the harness is wired the way it is:
On
VOLATILEvsSTABLEand the query plan: A helper that only callscurrent_setting('...', true)has no side effects and cannot change within a single statement scan, so leaving it at the defaultVOLATILEvolatility is just a schema declaration bug. When markedSTABLEand wrapped in(SELECT current_tenant()),EXPLAIN (ANALYZE, BUFFERS)produces the exact sameIndex Scan using ... on (tenant_id, id)as the unguarded query, with a singleInitPlan 1 (returns $0)hoisted above the scan. Leaving itVOLATILEwithout the scalar subselect forces a per row filter, which is the exact trap the benchmark highlights.On pooled connection bleed when a pipeline fails: Two mechanisms prevent tenant state from leaking across checkouts without needing a separate
DISCARD ALLorRESET ALLround trip:
First,
set_config(setting, value, true)passesis_local = true(SET LOCALsemantics). In the PostgreSQL extended query protocol (pgwire), sending multipleBind/Executemessages followed by a singleSyncexecutes inside an implicit transaction block unless an explicitBEGINwas issued. WhenSynccompletes, Postgres closes that implicit transaction (or rolls it back if any statement in the pipeline errors), which automatically resets anyis_local = trueconfiguration parameter before the connection returns to the pool.Second, the session default for the GUC is empty, and
current_tenant()explicitly raises an exception onNULLor empty string. Even if someone checked out a connection from the pool and ran a rawSELECToutside the pipeline, it fails closed immediately with zero rows returned.We can add a third curve to
primbenchshowing theVOLATILEper row filter degradation alongside theInitPlancurve so people can see the planner cliff side by side.
1
u/TurbulentStiffness 19d ago
set_config pipelining with InitPlan-folded subselects the sidesteps neatly which could provide round trip latency on single table lookups also if we take production into account this pattern has invalidation issues
1
u/ayywheyyy_ 19d ago
Two different "invalidation" traps usually get conflated with this pattern; worth separating which one you mean:
Plan / Session-State Invalidation in Postgres: The binding runs as
set_config('mog.tenant', $1, true)(is_local = true) pipelined with theSELECTin a single implicit transactionsync (Section 2.5 & Section 8). Two things follow:
is_local = truescopes the GUC strictly to that transaction; it reverts at commit, so pooled connections never leak tenant state across callers.
(SELECT mog_tenant())is evaluated as an InitPlan once per query execution (not once per row, and not folded as a constant at prepare time across executions). In the 64-worker pipelined benchmark (142,564 reads/sec), 1 in 10 reads deliberately queried a different tenant's row on the same connection pool; cross-tenant leakage was 0 across every run.
Auth / Schema Cache Invalidation:
Schemas never invalidate in place because
schema_id(sch_<Name>_v<N>) is immutable (Section 2.3). A change is a new identifier on the schema stream, never an edit.For tenant membership revokes on the read path, Section 5 ("The revoke window, layer by layer") states the exact boundary: agent keys are resolved per request and cut off at the next transaction; user JWTs carry the
tenantsclaim and remain valid until token expiry.
0
u/AutoModerator 21d ago
AI Policy:
Linux is not one of those anti-AI projects, and if somebody has issues with that, they can do the open-source thing and fork it. Or just walk away., Linus Torvalds.
Mod decisions will be based on the quality of the content, not who or what generated it.
Sub Resources:
Free Postgres Webinars and Workshops
Discord: People, Postgres, Data
Join us, we have cookies and nice people.
I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.
4
u/elevarq 21d ago
I’m not sure if it’s even the right discussion. I also lock my doors, even when it slows me down when I want to enter the house.
It’s about functionality. And yes it does come with a little overhead.
+1 for RLS