Now-Next

SaaS-architectuur

PostgreSQL-indexen voor multi-tenant SaaS: tenant_id voorop

Vijf indexen, 1.000 klanten, één query, nagemeten in PostgreSQL 17: waarom tenant_id vooraan hoort, wat RLS met het plan doet en wanneer partitioneren loont.

Chris van Eijk · · 9 min lezen

“Zet tenant_id vooraan in elke index” is het advies dat elke multi-tenant-handleiding geeft, en bijna geen enkele laat zien wat er gebeurt als je dat niet doet. In multi-tenant SaaS op PostgreSQL: zes beslissingen zijn de indexen beslissing drie, en de enige van de zes die je later nog zonder pijn kunt wijzigen. Juist daarom is het de moeite waard om te weten wat de verkeerde keuze kost, voordat je daar in productie achter komt.

Dus hebben we het gemeten: vijf indexen op dezelfde tabel, dezelfde query, één kleine en één grote klant, met en zonder row-level security, en met en zonder partitionering. PostgreSQL 17.10, standaardinstellingen, warme cache, mediaan van vijf metingen.

Waar hoort tenant_id in een samengestelde index?

Vooraan. Op een tabel met 2 miljoen facturen verdeeld over 1.000 klanten beantwoordde de index (tenant_id, issued_on) de vraag “de 50 nieuwste facturen van deze klant” in 0,28 ms voor een kleine klant en in 0,09 ms voor de grootste, met 51 en 7 gelezen pagina’s. Elke andere index die we probeerden was snel voor een van de twee klanten en traag voor de ander: (issued_on) had 26 ms nodig voor de kleine klant, (tenant_id) alleen had 82 ms nodig voor de grote.

De reden is dat een multi-tenant-tabel nooit gelijkmatig gevuld is. De index die werkt voor je grootste klant kan precies de verkeerde zijn voor de andere 999, en een test met één klant laat dat niet zien.

De opzet

Eén tabel invoices met een tenant_id van het type uuid, een datum, een status en een bedrag. De klanten zijn bewust ongelijk, omdat echte klanten dat ook zijn: de klant met rang r krijgt ongeveer 2.000.000 / (7,49 × r) facturen. Dat geeft 2.000.003 rijen (146 MB), waarvan de grootste klant er 267.183 heeft, de middelste 534 en de kleinste 267. De rijen staan in willekeurige volgorde, zoals wanneer duizend klanten tegelijk factureren.

create table invoices (
  id bigint generated always as identity primary key,
  tenant_id uuid not null references tenants(id),
  number int not null,
  status text not null,
  issued_on date not null,
  amount numeric(14,2) not null
);

De query is die waarmee elk SaaS-scherm begint:

select id, number, issued_on, amount
from invoices
where tenant_id = $1
order by issued_on desc
limit 50;

We draaiden hem voor de middelste klant (534 facturen) en de grootste (267.183), elke keer met precies één secundaire index.

Vijf indexen, één query

IndexGrootteKleine klant (534 rijen)Grote klant (267.183 rijen)
geen—18.806 pagina’s · 39,6 ms18.806 pagina’s · 58,8 ms
(issued_on)14 MB6.355 pagina’s · 26,2 ms8 pagina’s · 0,11 ms
(tenant_id)14 MB534 pagina’s · 1,97 ms18.939 pagina’s · 82,2 ms
(issued_on, tenant_id)32 MB445 pagina’s · 1,42 ms9 pagina’s · 0,10 ms
(tenant_id, issued_on)32 MB51 pagina’s · 0,28 ms7 pagina’s · 0,09 ms

Een pagina is 8 kB; het aantal is de regel Buffers uit EXPLAIN (ANALYZE, BUFFERS).

Wat elke regel zegt:

  • (issued_on) is perfect voor de grote klant: loop de datums achterwaarts af en bijna elke rij is van hem. Voor de kleine klant loopt PostgreSQL dezelfde datums af en gooit 339.000 rijen van anderen weg voordat hij er 50 heeft. Hoe groter je grootste klant, hoe erger dit wordt voor alle anderen.
  • (tenant_id) is het spiegelbeeld. Voor de kleine klant vindt hij 534 rijen en sorteert die. Voor de grote klant vindt hij 267.183 rijen verspreid over de hele tabel, haalt ze allemaal op en sorteert dan om er 50 over te houden — trager dan helemaal geen index.
  • (issued_on, tenant_id) bevat beide kolommen en leest voor de kleine klant toch bijna negen keer zoveel pagina’s als de goede volgorde, omdat tenant_id dan alleen binnen de index gecontroleerd kan worden en niet gebruikt om naar de juiste plek te springen.
  • (tenant_id, issued_on) springt naar de klant en leest daarna de datums op volgorde. Vijftig rijen, en klaar.

Dat is de regel achter “tenant_id voorop”: eerst gelijkheid op de klant, dan waarop de query sorteert of een bereik vraagt. Het betekent ook dat de index onder dit scherm 32 MB is en geen 14 MB — dat betaal je in opslag en bij elke schrijfactie.

Verandert row-level security het plan?

Nee. Met een beleid using (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid), FORCE ROW LEVEL SECURITY en de query zonder WHERE was het plan dezelfde Index Scan Backward op (tenant_id, issued_on), met het beleid als Index Cond: 51 pagina’s en 0,28 ms voor de kleine klant, 7 pagina’s en 0,11 ms voor de grote. Dat klopt met wat we eerder maten in row-level security in PostgreSQL: waar tenants lekken: met de goede index is het beleid geen extra filter, het ís de indexzoekactie.

De planner schat onder RLS ook goed. Voor de grote klant verwachtte hij 268.134 rijen en vond er 267.183; voor de kleine verwachtte hij 674 en vond er 534. current_setting() wordt uitgerekend als de query gepland wordt, dus gebruikt de planner de statistiek van precies die klant. Dat is goed nieuws, en het is precies wat de valkuil hieronder klaarzet.

Waarom wordt een query onder RLS traag voor één klant?

Omdat een prepared statement één keer plant, en de klant onder RLS geen parameter is. Komt de klant uit current_setting(), dan heeft het statement geen parameters, en voor dat geval is de PostgreSQL-documentatie expliciet: “if the prepared statement has no parameters, then this is moot and a generic plan is always used”. Het plan dat voor de eerste klant is gemaakt, wordt op die verbinding hergebruikt voor elke klant daarna.

We maten het met een totaal per status, select status, count(*), sum(amount) from invoices group by status, één keer voorbereid en daarna uitgevoerd voor een andere klant:

Voorbereid voorUitgevoerd voorBevroren planVers gepland
kleine klantgrote klant138 ms82 ms
grote klantkleine klant7,5 ms0,69 ms

In het eerste geval draait een plan dat voor 674 rijen is gemaakt op 267.183 rijen, zonder parallelle workers. Het tweede is relatief erger: een parallel plan voor de grote klant, hergebruikt voor een klant met 534 rijen, is elf keer trager dan nodig. Op een connection pool is het toeval welke klant als eerste op een verbinding kwam.

plan_cache_mode = force_custom_plan helpt niet: die instelling geldt alleen voor statements met parameters, en we maten 151 ms voor het bevroren geval met hem aan.

Wat wel helpt, is de planner een parameter geven. Houd het beleid als bewaker en geef de klant ook in de query mee:

prepare tenant_totals(uuid) as
  select status, count(*), sum(amount)
  from invoices
  where tenant_id = $1
  group by status;

Voorbereid voor de kleine klant en uitgevoerd voor de grote kostte dit 90 ms, met het parallelle plan dat de grote klant nodig heeft. Het beleid houdt nog steeds elke rij van een andere klant tegen als de parameter ooit verkeerd is; de parameter vertelt de planner alleen voor welke klant hij plant. Of je driver achter je rug statements voorbereidt, verschilt per driver en per instelling — kijk het na in plaats van aan te nemen dat hij het niet doet.

Maakt uuid of integer uit voor tenant_id?

Voor de indexgrootte wel. Dezelfde index (tenant_id, issued_on) op 2 miljoen rijen is 32 MB met een uuid en 23 MB met een integer, een kwart kleiner. Een bestaand product zouden we daarvoor niet omzetten — het sleuteltype hoort bij beslissing één en wijzigen is een migratie over elke tabel — maar voor een nieuw product is het een echte kostenpost naast de redenen om voor uuid te kiezen.

Wanneer partitioneer je op klant?

Niet voor querysnelheid op deze omvang. We kopieerden dezelfde 2 miljoen rijen naar twee gepartitioneerde tabellen, 16 hashpartities op tenant_id en een lijstpartitie die de grootste klant een eigen tabel geeft, met dezelfde index en hetzelfde RLS-beleid.

TabelKleine klant, nieuwste 50Grote klant, totaal per status
niet gepartitioneerd51 pagina’s · 0,18 ms18.952 pagina’s · 86,4 ms
16 hashpartities49 pagina’s · 0,19 ms3.025 pagina’s · 91,2 ms
grote klant in eigen partitie52 pagina’s · 0,19 ms2.514 pagina’s · 79,6 ms

Partition pruning werkt onder RLS: de current_setting() van het beleid wordt bij de start van de query opgelost, en EXPLAIN liet Subplans Removed: 15 zien voor de hashtabel. Het totaal van de grote klant las 6,3 en 7,5 keer minder pagina’s. Sneller was het niet, want 267.183 rijen optellen kost evenveel waar ze ook staan, en de tabel van 146 MB paste in het geheugen. De documentatie geeft zelf als vuistregel dat partitioneren loont als “the size of the table should exceed the physical memory of the database server”.

Waar partitioneren wél verschil maakte, is het beheer. De grote klant weghalen kostte 141 ms als DELETE en 4,4 ms als DETACH PARTITION plus DROP TABLE, en een losgekoppelde partitie kun je met pg_dump --table apart wegschrijven. Dat is de reden om één heel grote klant een eigen partitie te geven: vertrekken, terugzetten en vacuümen van die klant — niet zijn facturen sneller lezen.

Hoe controleer je je eigen schema?

Deze query geeft elke index op een tabel met een tenant_id-kolom waarin tenant_id niet de eerste kolom is. We toetsten hem op de tabel hierboven met twee bewust verkeerde indexen, (issued_on) en (status, tenant_id); hij gaf precies die twee terug en de goede niet.

select i.indrelid::regclass  as table_name,
       i.indexrelid::regclass as index_name,
       pg_get_indexdef(i.indexrelid) as definition
from pg_index i
join pg_attribute t
  on t.attrelid = i.indrelid
 and t.attname = 'tenant_id'
 and not t.attisdropped
where i.indkey[0] <> t.attnum
  and not i.indisprimary
order by 1, 2;

Niet elke treffer is fout. Een index die alleen een interne taak over alle klanten bedient, mag terecht met iets anders beginnen. Maar elke treffer onder een scherm dat je klanten gebruiken is een kandidaat, en een unieke beperking in de lijst — unique (number) in plaats van unique (tenant_id, number) — is meestal een fout en geen prestatiekwestie.

Een index wijzigen is het goedkope deel: CREATE INDEX CONCURRENTLY bouwt de nieuwe zonder schrijfacties te blokkeren, daarna gooi je de oude weg.

Wat we niet hebben gemeten

Een koude cache, een tabel groter dan het geheugen, PostgreSQL 18, en de schrijfkosten van de grotere index. Alle getallen hierboven komen van één machine met standaardinstellingen (shared_buffers 128 MB); lees ze dus als verhoudingen en niet als tijden die je in productie zult zien. Om die verhoudingen gaat het: in elke test was de index met tenant_id vooraan de enige die voor beide klanten snel was.

Bronnen: de PostgreSQL 17-documentatie over PREPARE (generieke en specifieke plannen) en over tabelpartitionering (pruning en wanneer partitioneren loont), beide gelezen op 27 september 2026. Alle metingen zijn van onszelf, in PostgreSQL 17.10, op 27 september 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