SaaS-architectuur
Multi-tenant architectuur: welk isolatiemodel kies je?
Database per klant, schema per klant of één database met tenant_id en row-level security: de modellen naast elkaar, en de drie vragen die de keuze beslissen.
Now-Next bouwt SaaS-producten en neemt bestaande over. Wat we bouwen
Wie een SaaS-product bouwt, moet vroeg kiezen hoe de gegevens van klanten uit elkaar blijven. Er zijn in de praktijk vier manieren om dat te doen, en de discussie erover gaat vaak over techniek terwijl de keuze bijna altijd door iets anders wordt beslist: wat je klanten contractueel van je eisen, en hoeveel klanten je verwacht.
Dit artikel zet de modellen naast elkaar. Het is geen neutrale opsomming: wij hebben een voorkeur, we zeggen welke, en we zeggen ook wanneer die voorkeur de verkeerde is.
Welk multi-tenant model kies je voor een SaaS-product?
Voor de meeste B2B-SaaS-producten is één gedeelde database met een kolom tenant_id op elke tabel de juiste keuze, mits die scheiding ook in de database zelf wordt afgedwongen met row-level security (RLS) en niet alleen in de applicatie. Een database per klant is pas de betere keuze als een klant contractueel een eigen database of opslaglocatie eist, of als je een kleine groep zeer grote klanten hebt. Een schema per klant lijkt een middenweg, maar combineert in de praktijk het beheer van het ene model met het risico van het andere.
Row-level security of een schema per klant?
Kies voor een nieuw product row-level security op gedeelde tabellen. Een schema per klant komt pas in beeld als klanten echt verschillende tabellen nodig hebben, of als je een bestaand single-tenant product verhuist waarvan de tabellen niet kunnen veranderen. Op PostgreSQL 17 voegde een policy die tenant_id gewoon met de sessievariabele vergelijkt niets meetbaars toe aan een telling van de 267.023 facturen van de grootste klant (11,0 ms met en zonder, met tenant_id vooraan in de index), en het aantal tabellen groeit niet mee met de klantenlijst. Bij een schema per klant groeit het aantal objecten wél, en van wat we maten bezweek met standaardinstellingen als eerste niet de snelheid maar pg_dump: tussen 1.200 en 1.300 schema’s van elk tien tabellen, dus boven de 12.000 tabellen. Meer tabellen per klant, of ander werk op de server, brengt die grens dichterbij.
| Op PostgreSQL 17; de cijfers zijn gemeten | Row-level security op gedeelde tabellen | Schema per klant |
|---|---|---|
| Kolom toevoegen | één ALTER TABLE: op zichzelf 1,7 ms. Achter een rapport van drie seconden wacht hij, en de query’s van elke klant sluiten achter hem aan: 2,0 s voor een andere klant; lock_timeout = '200ms' begrenst dat, en de ALTER moet dan opnieuw | één ALTER TABLE per schema: 1,01 s voor 2.000 schema’s, 2,4 s in een aparte meting met een commit per schema. Dat is alleen de doorloop; wat een lopend rapport ermee doet, hebben we niet gemeten |
| Schemadump | groeit niet mee met klanten, tenzij elke klant een partitie krijgt | met standaardinstellingen faalde pg_dump --schema-only met out of shared memory tussen 1.200 en 1.300 schema’s; een hogere max_locks_per_transaction lost het op, vraagt een herstart en moet meegroeien met het aantal klanten |
| Eén klant verwijderen | 0,12 s voor 2.000 facturen en 2.000 regels uit 200.000 elk, met een index op de foreign key; 21,8 s zonder; 1,34 s voor een klant met 60.000 facturen en 240.000 regels | 0,10 s voor DROP SCHEMA … CASCADE op tien tabellen |
| Wat klanten scheidt | een policy, die standhoudt zolang de rol hem niet omzeilt (superuser, BYPASSRLS, eigenaar zonder FORCE) en de klant binnen de transactie wordt gezet | het search_path van de verbinding, gezet door de applicatie; een query met de schemanaam erin gaat daar dwars langs, tenzij elke klant ook een eigen rol heeft |
| Tabellen die per klant verschillen | niet als eigen structuur; hooguit een jsonb-kolom of een extra tabel met tenant_id | mogelijk, maar migraties verschillen dan per schema |
Een schema per klant wint op drie punten: andere tabellen; verwijderen, de enige winst die we hebben gemeten, omdat DROP SCHEMA geen extra index nodig heeft (die 0,10 s gaat over tien tabellen, niet over een vergelijkbare hoeveelheid gegevens); en één klant apart terugzetten, in de tabel hieronder redelijk bij schema’s en werk bij gedeelde tabellen. En geen van beide modellen vergeeft een slordige pool: achter PgBouncer in transactiemodus, op één serververbinding, liet een gewone SET één client 267.023 facturen van een andere klant tellen, tegen 0 met SET LOCAL in een transactie. Een search_path dat zo wordt gezet zou hetzelfde risico hebben; dat hebben we niet gemeten. De dump-, verwijder- en doorloopcijfers komen uit zes beslissingen, de 2,4 s uit out of shared memory (Engelstalig); de rest staat met links in de tabel verderop.
De modellen in één tabel
| Model | Waar zit de scheiding | Wat kost een extra klant | Migraties | Eén klant apart terugzetten | Rapporten over alle klanten | Grootste risico |
|---|---|---|---|---|---|---|
| Eigen installatie per klant (single-tenant) | aparte servers en een aparte database | een complete omgeving erbij | per installatie, en installaties lopen uit de pas | eenvoudig | alleen via een aparte koppeling | versies die per klant uiteenlopen |
| Database per klant | aparte database op dezelfde of een andere server | een database, plus verbindingen en back-ups | één keer per database | eenvoudig | alleen via een aparte koppeling | beheer groeit lineair met het aantal klanten |
| Schema per klant | een eigen set tabellen per klant in één database | een set tabellen erbij | één keer per schema | redelijk | lastig | de scheiding hangt aan welk schema de verbinding kiest |
| Gedeelde tabellen, filter in de applicatie | een tenant_id en een filter in de code | een rij in de tabel met klanten | één keer | werk: rijen per klant uit een back-up halen | eenvoudig | één vergeten filter is een datalek |
| Gedeelde tabellen met row-level security | een tenant_id, het filter én een beleid in de database | een rij in de tabel met klanten | één keer | werk: rijen per klant uit een back-up halen | eenvoudig, via een expliciete uitzondering | een verbinding die RLS stilletjes omzeilt |
Twee kolommen beslissen het meestal: wat kost een extra klant en grootste risico. De rest volgt daaruit.
Database per klant: sterke scheiding, lineair beheer
Elke klant krijgt een eigen database. De scheiding is dan fysiek: een query die bij de verkeerde klant uitkomt, kan alleen ontstaan doordat de applicatie met de verkeerde database verbindt. Eén klant terugzetten naar gisteren is eenvoudig, en een klant die zijn gegevens in een bepaalde regio wil hebben, kun je dat geven.
De prijs is dat alles wat je voor één database doet, je voor elke klant doet. Een migratie draait per database, en halverwege een uitrol heb je klanten op de oude en klanten op de nieuwe structuur. Verbindingen, back-ups en monitoring groeien mee met het aantal klanten. En een eenvoudige vraag als hoeveel facturen zijn er deze maand over alle klanten heen gemaakt vraagt ineens een aparte pijplijn.
Kies dit als een klant het contractueel eist, als je een handvol grote klanten hebt in plaats van honderden kleine, of als één klant zo zwaar is dat hij de rest zou vertragen.
Schema per klant: de middenweg die zelden het midden is
In PostgreSQL kan één database meerdere schema’s bevatten, en dan krijgt elke klant een eigen set tabellen. Dat klinkt als het beste van twee werelden: één server, toch gescheiden tabellen.
In de praktijk erf je van de database-per-klant-kant het beheer — elke migratie draait per schema, en elke nieuwe klant voegt een complete set tabellen toe aan dezelfde database — en van de gedeelde kant het risico. De scheiding hangt namelijk aan welk schema de verbinding op dat moment gebruikt, en dat zet de applicatie. Een verbinding uit een pool die nog op het schema van de vorige klant staat, is dezelfde soort fout als een vergeten filter: één regel, en de gevolgen zijn een datalek.
Kies dit als je al een bestaand single-tenant product hebt waarvan de tabellen niet te veranderen zijn, en je het toch op één server wilt draaien. Als nieuw ontwerp raden we het af.
En er is een grens die we zelf hebben opgezocht: bij tien tabellen per klant liep pg_dump --schema-only op PostgreSQL 17.11 met de standaardinstellingen ergens tussen 1.200 en 1.300 schema’s vast op out of shared memory. Niet langzaam — stuk, en de reparatie vraagt een herstart van de database. De meting staat in multi-tenant SaaS op PostgreSQL: zes beslissingen.
Gedeelde tabellen met een tenant_id: goedkoop, en één vergeten filter van een lek
Alle klanten staan in dezelfde tabellen, en elke rij draagt het nummer van de klant waar hij bij hoort. Een extra klant is een rij in een tabel. Een migratie draait één keer. Rapporten over alle klanten heen zijn een gewone query.
Het risico zit in het filter. Meestal staat dat als globale scope in de ORM, en de gaten zitten dan altijd op dezelfde plekken: een rauwe query voor een rapport dat te traag was, een achtergrondtaak zonder ingelogde gebruiker, een import die de ORM omzeilt, en een relatie die één stap te ver loopt. Die vier werken we uit in multi-tenant vanaf dag één.
Kies dit niet zonder de volgende laag.
Gedeelde tabellen met row-level security: het model dat wij kiezen
Hetzelfde datamodel, met één toevoeging: de database weigert zelf rijen die niet bij de huidige klant horen. De applicatie zet per verbinding één sessievariabele met het klantnummer, en een beleid op elke tabel vergelijkt tenant_id daarmee. Een rauwe query, een achtergrondtaak of een import zonder klantcontext krijgt nul rijen terug.
De vijf plekken waar row-level security daarna stilletjes uitstaat of eromheen lekt, hebben we apart nagemeten in vijf gaten in tenant-scheiding. Drie details maken hier het verschil tussen een beleid dat er staat en een beleid dat werkt:
- Zet RLS aan én dwing hem af. Volgens de PostgreSQL-documentatie omzeilt de eigenaar van een tabel het beleid, tenzij je
ALTER TABLE … FORCE ROW LEVEL SECURITYgebruikt. Gebruiken je migraties en je applicatie dezelfde databasegebruiker, dan is de applicatie de eigenaar en staat het beleid er zonder die regel voor niets. Zet dus op elke tabel met een beleid zowelENABLEalsFORCE. - Laat een lege context niets opleveren. Laat het beleid eisen dat de sessievariabele gevuld is voordat hij wordt vergeleken. Een verbinding die vergeet hem te zetten, ziet dus niets in plaats van alles: hinderlijk, maar de goede kant op fout.
- Maak de uitzondering zichtbaar. Beheertaken die over alle klanten heen werken, moeten om het beleid heen kunnen. Laat dat via één functie lopen met een naam die niemand kan missen —
bypassRlsbijvoorbeeld — zodat elk gebruik ervan in een review opvalt. Verbind de applicatie ook nooit als superuser: volgens dezelfde documentatie omzeilen superusers en rollen metBYPASSRLShet beleid áltijd, ook metFORCE.
Wat blijft is het nadeel van het gedeelde model: één klant apart terugzetten betekent zijn rijen uit een back-up halen, en dat is werk. Voor de meeste producten weegt dat niet op tegen beheer dat niet meegroeit met het aantal klanten — maar het gedeelde model heeft zijn eigen dagelijkse kosten, en die hebben we gemeten.
Wat kost het gedeelde model in het dagelijkse beheer?
In een gedeelde database zit een groot deel van de prijs niet in de gewone queries, maar in het werk eromheen: de gegevens van één klant verwijderen, exporteren of verhuizen uit een tabel die alle klanten delen, een migratie, een telling, een takenwachtrij, een trigger, het beleid zelf. Bij elk daarvan is er één ding dat bepaalt hoe je het veilig doet. We hebben ze gemeten op PostgreSQL 17.10 in wegwerpcontainers, met 2 tot 1.000 klanten met gegevens (en tot 10.000 rollen); de tabel geeft per klus de bevinding, en elke regel linkt naar de volledige meting en de opzet ervan.
| Klus in een gedeelde database | Wat we maten | Wat eruit volgt |
|---|---|---|
| Eén grote klant verwijderen (60.000 facturen, 240.000 regels) | 1,34 s, met cascade of tabel voor tabel gelijk; 1,16 s daarvan ging naar de foreign-key-trigger. De eigen partitie van die klant weggooien: 2–3 ms per statement, maar onder een ACCESS EXCLUSIVE-lock op de hele tabel | batch je, kies de grootte dan met EXPLAIN ANALYZE (bij 5.000 per batch las het plan beide tabellen helemaal); een partitie alleen met een lock_timeout |
Eén klant exporteren met pg_dump onder row-level security | de rijen klopten, maar de dump bevatte setval-regels met de gedeelde tellers (311.205 facturen over alle 100 klanten) | exporteer met --exclude-table-data='*_seq' |
| Eén klant verhuizen naar een eigen database met een publicatie met rijfilter | met de standaard replica identity faalde elke UPDATE en DELETE op de tabel — voor élke klant; met REPLICA IDENTITY FULL 19% meer WAL, eerste kopie 1,3 s | zet de replica identity vóór je de publicatie aanmaakt |
| Een kolom toevoegen terwijl een rapport van drie seconden loopt | de ALTER TABLE wachtte 2,5 s, en de query van een andere klant wachtte 2,0 s daarachter; met lock_timeout = '200ms' gaf de ALTER het na 200 ms op en duurde de andere query 5 ms | draai nooit een migratie op een gedeelde tabel zonder lock-timeout |
| De facturen van de grootste klant tellen na één update van 80.000 van zijn facturen | 13,2 ms werd 64,7 ms; autovacuum startte niet, want de drempel was 415.032 dode rijen voor de hele tabel | verlaag autovacuum_vacuum_scale_factor op grote gedeelde tabellen (0,01: vacuum na 31 s, terug naar 13,3 ms) |
| Eén klant zet 1.000 taken klaar | tien kleine klanten wachtten met FIFO een mediaan van 4,5 s, met round-robin per klant 0,10–0,15 s | neem taken om de beurt per klant, niet op volgorde van binnenkomst — en kijk naar het plan van die query: op de voor de hand liggende manier geschreven kostte hij 10 s per taak |
| De klantcontext zetten achter PgBouncer in transaction mode | na een gewone SET telde de volgende client, die zelf niets zette, 267.023 facturen van een andere klant; met SET LOCAL in een transactie 0 | alleen SET LOCAL, en alleen binnen een transactie |
| Een worker die alle klanten bedient vanuit een eigen psycopg-verbindingspool | met de standaardinstellingen van de pool en een set_config(…, false) voor de hele sessie liet een geslaagde taak zijn klant staan voor de volgende taak, die zelf niets zette en 20.000 facturen van de verkeerde klant telde; een mislukte taak liet alleen in autocommit zijn eigen klant achter | zet de klant met set_config(…, true) binnen de transactie van de taak |
| Van klant wisselen met een rol per klant in plaats van een sessievariabele | eerste SET ROLE in een verbinding 211 ms bij 10.000 rollen; na het toevoegen van één klant betaalde de volgende wissel weer de volle prijs (87 ms bij 5.000); de variabele 0,05–0,11 ms | een rol per klant kost je bij elke nieuwe klant, in elke open verbinding |
| Een unieke constraint over alle klanten | een klant die de rij van een andere klant niet kan zien, kreeg toch duplicate key value violates unique constraint | zet tenant_id in de constraint |
| Een audittrigger op een gedeelde tabel | 32 µs per update van één rij; één bulk-UPDATE van 100.000 rijen 4,4 keer zo traag | een statement-trigger voor bulkwijzigingen (3,6 in plaats van 4,4 keer) |
| Het beleid zelf | de grootste klant tellen: 11,0 ms tegen 11,0 ms zonder row-level security; een lidmaatschapssubquery in het beleid 79–99 ms bij elke query, een VOLATILE PL/pgSQL-functie 1,9 s | houd het beleid een kale vergelijking met de sessievariabele |
Geen van deze punten is een reden om een ander model te kiezen: voor elk bestaat een bekende oplossing. Een aantal ervan zie je pas als één klant veel groter is dan de rest — de telling, de wachtrij, het verwijderen — en dat is precies het moment waarop de oplossing het lastigst uit te rollen is.
Drie vragen die de keuze beslissen
1. Eist een klant een eigen database of een eigen opslaglocatie? Staat dat in een contract of een aanbesteding, dan is de discussie voor die klant voorbij. Dat hoeft niet voor iedereen te gelden: een gedeelde database voor de meeste klanten en een eigen database voor die ene grote kan prima samengaan, zolang de applicatie in beide gevallen dezelfde code draait.
2. Moet je één klant apart kunnen terugzetten? Als “zet alleen onze gegevens terug naar gisteren” een verzoek is dat je verwacht, is dat een argument voor een database per klant. Is het een zeldzaamheid, dan is een script dat de rijen van één klant uit een back-up haalt goedkoper dan dat beheer — zet de back-up ergens anders terug en exporteer die klant daaruit met pg_dump onder row-level security, zonder de tellerwaarden.
3. Verwacht je tientallen klanten of duizenden? Bij tientallen is het beheer van aparte databases te overzien. Bij duizenden is het het werk zelf geworden, en dan wint het gedeelde model op kosten én op migratiesnelheid.
Kun je later nog van model wisselen?
Eén kant op gaat redelijk, de andere nauwelijks. Van een gedeelde database naar een eigen database voor één klant is een kopie van alle rijen met zijn tenant_id, en omdat de tabellen gelijk zijn, verandert er aan de applicatiecode niets — alleen aan de verbinding die voor die klant wordt gekozen. Andersom — losse databases samenvoegen tot één — is een project: elke tabel krijgt alsnog een tenant_id, elke query moet worden nagelopen, en als elke database zijn eigen oplopende nummering had, botsen de sleutels en moeten alle verwijzingen worden omgenummerd.
Daarom valt de klantscheiding onder de drie beslissingen die je later niet meer terugdraait. Het veiligste vertrekpunt is het model waar je vanaf kunt: gedeelde tabellen, RLS aan en afgedwongen, en sleutels die niet per database worden geteld. Wat het kost om één grote klant uit gedeelde tabellen naar een eigen database te verhuizen — en welke replica-identity-stap de updates van elke klant blokkeert als je hem vergeet — maten we in één klant verhuizen met logische replicatie en rijfilters.
Sta je voor die keuze bij een nieuw SaaS-product, of wil je weten welk model je bestaande product eigenlijk draait en of het standhoudt? Stuur een mail. Een half uur met de databasestructuur erbij is meestal genoeg om het te zien.