Now-Next

SaaS-architectuur

Multi-tenant SaaS op PostgreSQL: zes beslissingen

De zes keuzes die een multi-tenant SaaS in PostgreSQL vastlegt: isolatiemodel, RLS, indexen, bedragen, één klant terugzetten en migreren op schaal. Nagemeten.

Chris van Eijk · · 12 min lezen

Een SaaS-product waarin meer dan één klant zit, legt zes dingen vast in zijn database. Vier daarvan hebben we eerder afzonderlijk uitgewerkt; twee zijn we voor dit artikel gaan meten, omdat we er nergens een gemeten antwoord op vonden — wat het kost om één klant terug te zetten, en waar schema-per-klant werkelijk vastloopt.

Dit is de overzichtspagina van die zes. Per beslissing staat er wat PostgreSQL ervoor heeft, of je hem later nog kunt wijzigen, en waar het uitgewerkt staat.

Welke beslissingen legt een multi-tenant SaaS vast in PostgreSQL?

Zes. Eén: het isolatiemodel — een database per klant, een schema per klant, of gedeelde tabellen met een tenant_id. Twee: hoe je die scheiding afdwingt, en voor gedeelde tabellen is dat row-level security met FORCE ROW LEVEL SECURITY erbij. Drie: de indexen, waarbij tenant_id vooraan in elke samengestelde index hoort. Vier: het kolomtype voor bedragen, numeric met een expliciete schaal of een geheel aantal centen. Vijf: hoe je één klant apart weggooit of terugzet, want pg_dump kan geen rijen filteren. Zes: hoe je migreert als het aantal klanten groeit, en bij schema-per-klant is dat de beslissing die als eerste een muur raakt.

Alleen beslissing drie en vier zijn later nog redelijk te herzien. De andere vier zijn schemabeslissingen die je met een migratie op productiedata moet omzetten, en dat is precies waarom ze in de eerste week horen en niet in het tweede jaar.

De zes in één tabel

#BeslissingWat PostgreSQL ervoor heeftLater nog te wijzigen?Uitgewerkt in
1Isolatiemodelaparte databases, CREATE SCHEMA, of een tenant_id-kolomnee — een migratie over alle klantdatawelk isolatiemodel kies je
2De scheiding afdwingenENABLE + FORCE ROW LEVEL SECURITY, CREATE POLICY, current_setting()nee — elke tabel en elk beleid apartwaar tenants lekken
3Indexensamengestelde indexen met tenant_id vooraan, eventueel partitioneringja — CREATE INDEX CONCURRENTLYwaar tenants lekken
4Bedragennumeric(14,2) of bigint in centen; niet money, nooit double precisiondeels — een type omzetten is te doen, een schaal terugdraaien nietnumeric, integer of money
5Één klant weggooien of terugzettenCOPY (SELECT … WHERE tenant_id = …), of DROP SCHEMAnee — het hangt aan beslissing 1hieronder, nagemeten
6Migreren op schaaléén ALTER TABLE, of één per klantnee — het hangt aan beslissing 1hieronder, nagemeten

De eerste vier zijn beslissingen over hoe de data eruitziet. De laatste twee zijn beslissingen over hoe je hem beheert, en die worden vrijwel altijd overgeslagen — ze doen geen pijn zolang er tien klanten zijn.

1. Het isolatiemodel

Drie modellen, en de keuze valt bijna altijd op gedeelde tabellen met een tenant_id: het is het goedkoopste om te beheren en het schaalt verder dan de meeste teams verwachten. Een database per klant is het juiste antwoord als een klant contractueel zijn eigen database eist of een eigen herstelmoment moet kunnen kiezen. Schema per klant klinkt als de middenweg en is het zelden — zie beslissing 6.

De volledige afweging, met de drie vragen die hem beslissen, staat in multi-tenant architectuur: welk isolatiemodel kies je.

2. De scheiding afdwingen

Bij gedeelde tabellen is de tenant_id-kolom niet de scheiding — het filter erop is dat, en een vergeten filter is een datalek. Row-level security verplaatst dat filter naar de database, zodat een rauwe query voor een rapport, een achtergrondtaak zonder ingelogde gebruiker of een import die de ORM omzeilt geen rijen van een andere klant kan zien.

Dat werkt, en het staat makkelijker uit dan aan: de eigenaar van een tabel omzeilt zijn eigen beleid zonder FORCE ROW LEVEL SECURITY, een lege klantcontext is na een RESET niet NULL maar '', en een vreemde sleutel kijkt om het beleid heen. De vijf manieren waarop RLS stilletjes niet werkt staan, elk nagemeten, in row-level security in PostgreSQL: waar tenants lekken.

3. De indexen

tenant_id hoort vooraan in elke samengestelde index waarlangs je klantdata opvraagt. Dat is geen prestatiedetail maar de reden dat RLS niets extra kost: het beleid komt in het queryplan terecht als Index Cond en niet als een extra filterstap. Op 200.000 facturen waarvan 2.003 van één klant zijn, wordt de beleidsvoorwaarde een Index Cond in een Bitmap Index Scan en komt de eigen WHERE er als Filter achteraan — nagemeten, en het komt overeen met wat de documentatie belooft over de evaluatievolgorde.

Dit is de enige van de zes die je op een draaiend product zonder pijn kunt bijstellen: CREATE INDEX CONCURRENTLY en klaar.

4. Het kolomtype voor bedragen

numeric met een expliciete precisie en schaal voor de meeste producten, een geheel aantal centen in bigint als bedragen vooral worden opgeteld en heen en weer gaan naar een betaalsysteem. Niet real of double precision, want die zijn niet exact. Niet money, want dat kan geen fracties van een cent bewaren en koppelt zijn decimalen aan de database-instelling lc_monetary.

De drie kandidaten naast elkaar, met de valkuil dat een kolom met een schaal bij het opslaan afrondt zonder iets te zeggen, staan in bedragen in PostgreSQL: numeric, integer of money. Waarom een komma-getal geen geld kan bewaren, staat in bedragen in een SaaS-product: centen, geen floats.

5. Wat kost het om één klant terug te zetten?

Dit is de vraag die het isolatiemodel echt beslist, en hij wordt bijna nooit gesteld. Een klant belt dat er iets is weggegooid, of een klant vertrekt en wil zijn data mee. Wat doe je dan?

pg_dump kan geen rijen filteren. Dat is geen instelling die we hebben gemist: in PostgreSQL 17.11 heeft pg_dump --table, --exclude-table en --filter, en die selecteren alle drie objecten, geen rijen. Er is geen --where. Bij gedeelde tabellen bestaat de back-up van één klant dus niet als kant-en-klaar gereedschap; je schrijft hem zelf.

Dat zelf schrijven is snel. In een database met 200.000 facturen en 200.000 factuurregels over 100 klanten kostte het uitschrijven van alle rijen van één klant (2.000 facturen, 2.000 regels) 0,10 seconde met drie COPY-opdrachten. Terugzetten kostte 0,15 seconde. Dat is niet het probleem.

Het probleem is dat je die drie opdrachten zelf moet opsommen, en dat een tabel die je vergeet stilletjes niet meegaat. Laat je database daarom zelf het exportscript schrijven:

select format('\copy (select * from %I where tenant_id = :tenant) to ''/tmp/%s.csv'' csv',
              table_name, table_name)
from information_schema.columns
where column_name = 'tenant_id' and table_schema = 'public'
order by table_name;

En stel daarnaast de vraag die er werkelijk toe doet — welke tabellen vallen hier búiten:

select t.table_name
from information_schema.tables t
where t.table_schema = 'public' and t.table_type = 'BASE TABLE'
  and not exists (select 1 from information_schema.columns c
                  where c.table_schema = t.table_schema
                    and c.table_name = t.table_name
                    and c.column_name = 'tenant_id')
order by 1;

In onze testdatabase gaf die tweede query precies één tabel: klanten — de tabel die de klant zélf beschrijft en dus geen tenant_id heeft. Dat is de goede uitkomst. Elke andere naam die eruit komt, is een tabel waarvan je moet kunnen uitleggen waarom hij niet bij een klant hoort.

De verrassing: weggooien is 182× duurder dan terugzetten

Bij het meten liep één stap volledig uit de pas. Het verwíjderen van die ene klant — 2.000 facturen en 2.000 factuurregels uit tabellen van 200.000 rijen — kostte 21,8 seconden, twee keer gemeten. Terugzetten kostte 0,15 seconde.

De oorzaak zit niet in de rijen maar in een vreemde sleutel zonder index. factuurregels.factuur_id verwijst naar facturen.id, en de tabel had een index op tenant_id en op zijn eigen primaire sleutel — maar niet op factuur_id. Bij elke verwijderde factuur moet PostgreSQL dan de hele tabel factuurregels doorlopen om te controleren of er nog een regel naar verwijst. 2.000 keer 200.000 rijen.

De documentatie zegt dit letterlijk, en het is een van de weinige plekken waar PostgreSQL je expliciet waarschuwt dat hij iets niet voor je doet: “Since a DELETE of a row from the referenced table or an UPDATE of a referenced column will require a scan of the referencing table for rows matching the old value, it is often a good idea to index the referencing columns too. Because this is not always needed, and there are many choices available on how to index, the declaration of a foreign key constraint does not automatically create an index on the referencing columns.”

Eén index erbij:

create index on factuurregels (factuur_id);

Aanleggen: 0,14 seconde. Dezelfde verwijdering daarna: 0,12 seconde. Van 21,8 naar 0,12 — een factor 182, door een index die geen enkele leesquery in dit product nodig had.

Dat is precies het soort kosten dat je pas ontdekt op de avond dat een klant belt. Loop daarom één keer na welke verwijzende kolommen in je schema geen index hebben:

select c.conrelid::regclass as tabel,
       (select string_agg(a.attname, ', ')
        from unnest(c.conkey) k join pg_attribute a
          on a.attrelid = c.conrelid and a.attnum = k) as kolommen
from pg_constraint c
where c.contype = 'f'
  and not exists (
    select 1 from pg_index i
    where i.indrelid = c.conrelid
      and (i.indkey::smallint[])[0:array_length(c.conkey,1)-1] = c.conkey
  )
order by 1;

Bij een database per klant of een schema per klant bestaat deze hele vraag niet: één klant weggooien is DROP DATABASE of DROP SCHEMA … CASCADE, en dat kostte in onze test 0,10 seconde voor een schema met tien tabellen. Dat is de eerlijke winst van die modellen, en de enige die we hebben kunnen meten.

6. Waar loopt schema-per-klant vast?

Schema per klant wordt aangeraden met het argument dat PostgreSQL “een paar duizend schema’s” wel aankan. Dat cijfer staat overal en nergens met een meting eronder, dus we hebben het zelf nagelopen: schema’s met tien tabellen elk, stap voor stap tot 2.000 schema’s, op PostgreSQL 17.11 met de standaardinstellingen.

De uitkomst is niet dat het langzaam wordt. De uitkomst is dat je back-up stopt met werken.

Schema’sTabellenMigratiesweep: één ALTER TABLE per klantinformation_schema.tablespg_dump --schema-only
1001.0000,13 s3,1 ms0,30 s (51.031 regels)
5005.0000,31 s14,6 ms1,14 s (255.031 regels)
1.00010.0000,52 s27,1 ms2,28 s (510.031 regels)
2.00020.0001,01 s51,7 msmislukt

Alles in de eerste drie kolommen groeit netjes recht toe recht aan. Een migratie over 2.000 klanten kost een seconde; dat is prima. Maar pg_dump --schema-only breekt af:

pg_dump: error: query failed: ERROR:  out of shared memory
HINT:  You might need to increase "max_locks_per_transaction".

De grens zat in onze opzet tussen 12.068 en 13.068 tabellen — bij 1.200 schema’s lukte de dump nog, bij 1.300 niet meer. Dat is een stuk lager dan “een paar duizend schema’s”, en het is een harde stop en geen vertraging: je hebt geen schemadump, dus ook geen volledige back-up.

Waarom dit gebeurt, staat in de documentatie: “The shared lock table has space for max_locks_per_transaction objects (e.g., tables) per server process or prepared transaction; hence, no more than this many distinct objects can be locked at any one time.” pg_dump neemt een lock op elke tabel die hij dumpt, in één transactie. Met de standaardwaarden (max_locks_per_transaction 64, max_connections 100) is er ruimte voor ongeveer 6.400 objecten — en omdat de documentatie er ook bij zegt dat individuele transacties meer mogen locken zolang alles samen in de tabel past, ligt de werkelijke grens hoger én is hij niet exact voorspelbaar. Dat laatste is het vervelende deel: het exacte getal hangt af van je instellingen en van wat er verder op de server gebeurt, dus je kunt er niet op plannen.

En de reparatie is duurder dan hij lijkt. max_locks_per_transaction “can only be set at server start”. We hebben dat nagemeten: na ALTER SYSTEM SET max_locks_per_transaction = 256 stond pending_restart op true, gaf een pg_reload_conf() géén verandering en mislukte dezelfde pg_dump opnieuw. Ná een herstart lukte hij wél. Je back-up repareren vraagt dus een herstart van je database — op een beheerd platform is dat een onderhoudsvenster dat je met je klanten moet afspreken, op de avond dat je ontdekte dat je geen back-up had.

Dat is beslissing 6 in één zin: bij gedeelde tabellen is een migratie één ALTER TABLE en groeit er niets mee; bij schema per klant groeit het aantal objecten met je klantenlijst, en het eerste wat daaraan bezwijkt is niet je snelheid maar je herstelplan.

Twee antwoorden, en dat is met opzet

Wij bouwen zelf twee SaaS-producten — booxx en Pilot-Next — en ze staan niet op hetzelfde antwoord op beslissing 2. Dat is geen inconsistentie maar het gevolg van de vraag waar wil je dat de fout opvalt.

Het ene antwoord legt de scheiding in de database. Dan weigert de database zelf, ook bij een rauwe query voor een rapport of een achtergrondtaak die niemand had voorzien — en dan koop je er de vijf gaten bij die in waar tenants lekken staan.

Het andere antwoord legt de scheiding in de applicatielaag en zet de vangst in architectuurtests op de broncode: een route mag zijn klant niet uit het verzoek halen, en een bulkmutatie op een tabel zonder eigen klantkolom moet op zijn relatie filteren. Dat faalt tijdens de build in plaats van tijdens de query, en het dekt alleen wat je hebt bedacht te toetsen.

Welke van de twee bij een product past, hangt af van de drie vragen uit het isolatiemodel-artikel — en van of er geld of een administratie van iemand anders in de database staat. Zo ja, dan willen wij dat de database zelf weigert.

Controleer je eigen database in een kwartier

Vier queries, en ze zijn alle vier hierboven of in de gelinkte artikelen uitgewerkt:

  1. Welke tabellen met een klantkolom hebben geen beleid? De controlequery uit gat 5 van het RLS-artikel.
  2. Staat FORCE aan, en is de applicatierol niet de eigenaar? Gat 1 van hetzelfde artikel.
  3. Welke tabellen vallen buiten een klant-export? De tweede query uit beslissing 5 hierboven.
  4. Welke vreemde sleutels hebben geen index op de verwijzende kolom? De laatste query uit beslissing 5.

Query 3 en 4 kosten samen vijf minuten en zijn de twee die vrijwel nooit gesteld worden. Komt er iets uit dat je niet had verwacht, dan is dat geen incident maar de normale uitkomst — wij vonden bij het schrijven van dit artikel een index die geen enkele leesquery nodig heeft en die het verschil is tussen 0,12 en 21,8 seconden.


Loopt je product tegen een van deze zes aan, of staat de klantscheiding nog volledig in de applicatiecode van iets dat groter is geworden dan gepland — stuur een mail. Wij bouwen SaaS-producten waarin deze zes vanaf de eerste migratie vastliggen, en we ontwikkelen bestaande producten door waarin dat nog niet zo is.

Nagemeten op 21-09-2026 in PostgreSQL 17.11 (Debian, in een weggegooide container) met de standaardinstellingen: max_locks_per_transaction 64, max_connections 100, shared_buffers 128 MB. De schaalmeting gebruikte schema’s met tien tabellen elk; de exportmeting een database met 100 klanten, 200.000 facturen en 200.000 factuurregels. De aangehaalde documentatie: Constraints en Lock Management, beide gelezen op 21-09-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