Now-Next

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.

Chris van Eijk · · 8 min lezen

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.

De modellen in één tabel

ModelWaar zit de scheidingWat kost een extra klantMigratiesEén klant apart terugzettenRapporten over alle klantenGrootste risico
Eigen installatie per klant (single-tenant)aparte servers en een aparte databaseeen complete omgeving erbijper installatie, en installaties lopen uit de paseenvoudigalleen via een aparte koppelingversies die per klant uiteenlopen
Database per klantaparte database op dezelfde of een andere servereen database, plus verbindingen en back-upséén keer per databaseeenvoudigalleen via een aparte koppelingbeheer groeit lineair met het aantal klanten
Schema per klanteen eigen set tabellen per klant in één databaseeen set tabellen erbijéén keer per schemaredelijklastigde scheiding hangt aan welk schema de verbinding kiest
Gedeelde tabellen, filter in de applicatieeen tenant_id en een filter in de codeeen rij in de tabel met klantenéén keerwerk: rijen per klant uit een back-up haleneenvoudigéén vergeten filter is een datalek
Gedeelde tabellen met row-level securityeen tenant_id, het filter én een beleid in de databaseeen rij in de tabel met klantenéén keerwerk: rijen per klant uit een back-up haleneenvoudig, via een expliciete uitzonderingeen 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.

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.

Bij booxx staat het zo ingericht, en drie details maken 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 SECURITY gebruikt. Gebruiken je migraties en je applicatie dezelfde databasegebruiker, dan is de applicatie de eigenaar en staat het beleid er zonder die regel voor niets. Bij booxx staat op elke tabel met een beleid zowel ENABLE als FORCE.
  • Laat een lege context niets opleveren. Ons beleid eist 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. Bij booxx gaat dat via één functie die letterlijk bypassRls heet, zodat elk gebruik ervan in een review opvalt. Verbind de applicatie ook nooit als superuser: volgens dezelfde documentatie omzeilen superusers en rollen met BYPASSRLS het beleid áltijd, ook met FORCE.

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.

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.

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.

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.

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