Jedes SaaS-Produkt bedient mehr als einen Kunden mit derselben Codebasis. Eine einzige, enge Frage entscheidet überraschend viel über deine Architektur: Wenn Kunde A und Kunde B beide Daten in deinem System speichern, wie verhinderst du, dass A jemals die Zeilen von B sieht?
Es gibt zwei gängige Antworten. Eine gemeinsame Datenbank mit einer Spalte, die angibt, wem jede Zeile gehört, oder eine eigene Datenbank für jeden Kunden. Die meisten Vergleiche enden bei „Kommt darauf an“. Dieser Beitrag beschäftigt sich damit, wovon es tatsächlich abhängt: mit den konkreten technischen Kosten, die jeder Ansatz dir aufbürdet, und damit, wie du erkennst, welche Kosten dein Produkt tragen kann.
Für die konkreten Beispiele verwende ich Laravel, weil ich damit arbeite, aber der Zielkonflikt selbst ist unabhängig von Framework und Sprache. Dieselbe Entscheidung begegnet dir in Rails, Django, Spring und reinem SQL.
Ein Spektrum, kein Entweder-oder
Bevor es um die beiden Optionen geht, hilft die Einsicht, dass „Isolation“ kein einzelner Schalter ist. Es ist ein Regler, der üblicherweise von Pool (alles geteilt) bis Silo (alles getrennt) reicht:
POOL ──────────────────────────────────────────────► SILO günstiger, dichter stärker isoliert, teurer
Gemeinsame DB ──► Gemeinsame DB ──► Datenbank ──► Eigene Infra gemeinsames Schema pro pro Mandant pro Mandant Schema Mandant (Spalte tenant_id)Je weiter rechts, desto stärker die Isolation und desto höher die Kosten pro Mandant. Die beiden Optionen, die dieser Beitrag vergleicht, gemeinsames Schema und Datenbank pro Mandant, sind die zwei Punkte auf diesem Regler, zwischen denen die meisten Teams tatsächlich wählen. Die äußeren Enden (Schema pro Mandant oder komplett getrennte Infrastruktur) sind Varianten desselben Zielkonflikts, keine anderen Zielkonflikte.
Entscheidend ist, was der Regler eigentlich misst: ob die Mandantenisolation durch deinen Anwendungscode oder durch deine Infrastruktur durchgesetzt wird. Alles Weitere folgt aus dieser einen Unterscheidung.
Ansatz 1: Gemeinsame Datenbank, Isolation im Code
Die Zeilen aller Mandanten liegen in denselben Tabellen. Jede Zeile trägt eine Spalte (tenant_id, organization_id, manchmal nur user_id), die ihren Eigentümer benennt. Isolation bedeutet: Jede Abfrage wird nach dieser Spalte gefiltert, ohne Ausnahme.
-- Eine Tabelle, die Daten aller Mandanten vermischtSELECT * FROM invoices WHERE tenant_id = ? AND status = 'overdue';Gefährlich sind die Worte „ohne Ausnahme“. Wer den Filter ein einziges Mal vergisst, in einer Raw-Query, einem Report oder einem Hintergrundjob, zeigt einem Kunden die Daten eines anderen. Deshalb verlassen sich ausgereifte Codebasen nicht darauf, dass Entwickler daran denken. Sie machen den Filter automatisch und schwer zu umgehen.
In Laravel ist das ein Global Scope: eine Regel auf Modellebene, die jeder Abfrage stillschweigend die Bedingung tenant_id anhängt, sodass ein schlichtes Invoice::all() bereits „die Rechnungen dieses Mandanten“ bedeutet. Ein Trait trägt die Spalte beim Einfügen ein, damit man sie auch beim Schreiben nicht vergessen kann:
trait BelongsToTenant{ protected static function booted(): void { static::creating(fn ($model) => $model->tenant_id ??= currentTenant()->id); static::addGlobalScope('tenant', fn ($query) => $query->where('tenant_id', currentTenant()->id) ); }}Dieses Muster (eine Spalte, ein automatischer Filter beim Lesen, ein automatischer Eintrag beim Schreiben) ist die ganze Idee, in jedem Framework. Eine zweite Schicht, Autorisierungs-Policies, lohnt sich, damit eine Anfrage, die den Datensatz eines anderen Mandanten per ID errät, ein explizites 403 erhält, statt stillschweigend aus einer gefilterten Liste zu fallen.
Was ist mit Row-Level Security?
Das ist der naheliegende Einwand gegen alles bisher Gesagte: Wenn das Risiko ein vergessenes WHERE ist, warum lässt man dann nicht die Datenbank den Mandantenfilter erzwingen, sodass eine vergessene Klausel nichts mehr preisgeben kann? Genau das leistet Row-Level Security (RLS), und es ist die stärkste Option, nach der die meisten Teams nie greifen.
In PostgreSQL aktivierst du sie auf der Tabelle und hängst eine Policy an, die den aktuellen Mandanten aus einer Sitzungsvariable liest:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices USING (tenant_id = current_setting('app.tenant_id')::int);Dann setzt die App diese Variable einmal pro Anfrage, und ab da wird jede Abfrage auf invoices von der Datenbank gefiltert, egal ob die Anwendung daran gedacht hat, die Bedingung anzufügen:
SET app.tenant_id = '42'; -- zu Beginn der Anfrage setzenSELECT * FROM invoices; -- die Datenbank liefert nur die Zeilen von Mandant 42Ein vergessener Filter, eine Raw-Query, ein Reporting-Job, der den Scope übersprungen hat: Nichts davon kann noch über Mandanten hinweg Daten preisgeben, weil die Durchsetzung nicht mehr im Anwendungscode liegt. Das verschiebt die Isolation im gemeinsamen Schema tatsächlich von „im Code durchgesetzt“ in Richtung „in der Infrastruktur durchgesetzt“ und verkleinert die Lücke beim Schadensradius gegenüber Datenbank pro Mandant mehr als alles andere auf dieser Liste.
Kostenlos ist das nicht, und die Einschränkungen sind der Grund, warum RLS wenig genutzt wird:
- Die Sitzungsvariable muss auf jeder Verbindung korrekt gesetzt sein. Das ist die neue Stolperfalle. Bei einem Connection-Pool, der wiederverwendete Verbindungen ausgibt, ist eine aus einer früheren Anfrage übrig gebliebene Variable selbst ein Datenleck zwischen Mandanten. Deshalb muss sie auf die Transaktion begrenzt (
SET LOCAL) oder jedes Mal zurückgesetzt werden. - Tabelleneigentümer und Superuser umgehen RLS standardmäßig. Die Rolle, mit der sich deine App verbindet, muss eine Rolle ohne Eigentümerschaft sein, oder du musst
FORCE ROW LEVEL SECURITYverwenden, sonst tut die Policy für diese Rolle stillschweigend nichts. - Jede Abfrage trägt das Policy-Prädikat, was reale Auswirkungen auf Planer und Performance hat (mit einem Index auf
tenant_idmeist unproblematisch, aber nicht null). - Es ist ein Feature von Postgres / SQL Server, kein universelles. MySQL hat kein natives RLS, und da der Laravel-Standardstack MySQL ist, entscheidest du dich dafür mit einer anderen Datenbank, das Framework liefert es nicht mit. Laravel setzt dir die Sitzungsvariable auch nicht automatisch; du würdest sie in einem Connection-Listener oder einer Request-Middleware setzen.
Wo es passt: RLS lässt dich die Dichte und die betriebliche Einfachheit einer einzigen gemeinsamen Datenbank behalten und gibt dir dabei eine Isolation, die die Datenbank durchsetzt, statt darauf zu vertrauen, dass jede Abfrage sich korrekt verhält. Wenn du auf Postgres bist und Isolation wichtig ist, ist das oft die bessere Antwort, als gleich zu einer Datenbank pro Mandant zu springen.
Ansatz 2: Datenbank pro Mandant, Isolation in der Infrastruktur
Jeder Mandant bekommt eine eigene Datenbank. Der Lebenszyklus einer Anfrage erhält vorn einen zusätzlichen Schritt:
- Eine Anfrage kommt an, etwa für
acme.app.com. - Die App ermittelt, welcher Mandant das ist, typischerweise aus der Subdomain, einer eigenen Domain oder einem Token.
- Sie richtet die Datenbankverbindung auf die Datenbank dieses Mandanten aus (
acme_db). - Alles danach ist eine gewöhnliche Anwendung für einen einzelnen Mandanten: normale Abfragen, kein Filtern.
Es gibt nirgends eine Spalte tenant_id, weil es nichts zu filtern gibt: Die Verbindung ist die Grenze.
Eine fehlende WHERE-Klausel kann nicht mehr über Mandanten hinweg Daten preisgeben, weil die Daten des anderen Mandanten über diese Verbindung gar nicht erreichbar sind. Das ist der Reiz in einem Satz: Die Isolation hält auch dann, wenn dein Code einen Fehler hat.
In Laravel ist das gängige Werkzeug stancl/tenancy. Es übernimmt die Mandantenerkennung, das Anlegen und Migrieren der Datenbank jedes Mandanten bei der Anmeldung und, entscheidend, macht auch Cache, Queues und Dateispeicher mandantenfähig, nicht nur die Datenbankverbindung. (Ein leichteres Paket, spatie/laravel-multitenancy, erkennt nur den aktuellen Mandanten und überlässt dir die Logik zur Datentrennung.) Aber auch hier ist der Mechanismus universell: Mandant ermitteln, Verbindung wechseln, normale Abfragen ausführen.
Die Kosten, die tatsächlich entscheiden
Hier winken die meisten Vergleiche ab. Die Wahl ist ein Tausch dessen, welche Probleme du lieber hättest. Direkt gegenübergestellt:
| Dimension | Gemeinsame DB | DB pro Mandant |
|---|---|---|
| Schadensradius eines Bugs | Betrifft alle Mandanten | Auf einen begrenzt |
| Isolation durch | App-Code | Infrastruktur |
| Migrationen | Einmal ausführen | × N Datenbanken ausführen |
| Kosten pro Mandant | Nahe null | Fester Overhead |
| Noisy Neighbors | Geteilte Konkurrenz | Natürlich isoliert |
| Mandantenübergreifende Abfragen | Eine Abfrage | Fan-out über N |
| Mandant löschen | Gefilterte Deletes | Datenbank droppen |
| Verbindungen | Ein Pool | Pool-pro-DB-Limits |
Vier davon sind mehr wert als eine Tabellenzelle.
Der Schadensradius ist die eigentliche Schlagzeile
Diese Dimension wird am häufigsten unterschätzt. In einer gemeinsamen Datenbank, deren Isolation nur im Code durchgesetzt wird, ist der schlimmste Fall katastrophal und total: Ein einziger vergessener Filter in einem Reporting-Endpunkt kann die Daten aller Kunden auf einmal offenlegen. (Genau diese Lücke schließt Row-Level Security, weshalb sich der Aufwand auf Postgres lohnt.) Bei Datenbank pro Mandant ist dieselbe Art von Bug begrenzt: Die Daten befinden sich physisch gar nicht auf der Verbindung, über die sie abfließen könnten. Du kaufst hier keine Bequemlichkeit, du kaufst einen kleineren schlimmsten Fall. Für manche Produkte (Gesundheitsdaten, Finanzdaten, alles, an dem ein Gesetz zur Meldung von Datenschutzverletzungen hängt) ist dieser kleinere schlimmste Fall die ganze Entscheidung.
Der Betriebsaufwand wächst mit der Mandantenzahl
Eine gemeinsame Datenbank hat ein Schema zu migrieren, eine Sache zu sichern, einen Satz Metriken zu beobachten. Datenbank pro Mandant multipliziert all das mit deiner Mandantenzahl. Eine Migration ist nicht mehr eine Anweisung. Es ist dieselbe Anweisung, nacheinander gegen jede Mandantendatenbank ausgeführt, und eine fehlerhafte kann über Hunderte hinweg halb gelingen, bevor du es bemerkst. Backups, Monitoring und die Frage „ist wirklich jede Datenbank auf dem neuesten Schema?“ werden zu Flottenproblemen statt zu Problemen eines einzelnen Objekts.
Ressourcendichte ist ein Kostenfaktor, keine Fußnote
Jede Datenbank hat einen festen Overhead (Speicher, Verbindungs-Footprint, Backup-Speicher, eine Mindestgröße), egal ob sie eine Zeile oder eine Million enthält. Eine gemeinsame Datenbank verteilt diesen Overhead auf alle Mandanten. Datenbanken pro Mandant zahlen ihn pro Mandant. Wenn deine Mandanten Tausende kleiner, günstig zu bedienender Nutzer sind, kann dieser Overhead deine Infrastrukturrechnung dominieren, für einen Isolationsvorteil, den diese Nutzer gar nicht brauchen. Deshalb dominiert das gemeinsame Modell B2C-Produkte mit hohem Volumen und niedrigem Preis pro Mandant: Oft ist es der einzige Ansatz, dessen Unit Economics überhaupt aufgehen.
Das Verbindungsmanagement ist die Schwachstelle dabei. Ein Connection-Pool zu einer Datenbank ist Routine. Einen offenen Pool zu zehntausend Mandantendatenbanken gleichzeitig kannst du nicht halten; die Auflösung muss verbinden, arbeiten und freigeben, und wer das falsch macht, erschöpft unter Last klassischerweise das Verbindungslimit eines Datenbankservers.
Noisy Neighbors und ungleichmäßiges Wachstum
In einer gemeinsamen Datenbank konkurrieren Mandanten um dieselbe CPU, dieselbe I/O, denselben Query-Planner-Cache. Ein Kunde, der einen riesigen Report ausführt oder einfach zehnmal schneller wächst als alle anderen, verschlechtert die Erfahrung für alle. Du bekämpfst das mit Indizes, Read Replicas und Abfragelimits, kannst es aber nicht vollständig beseitigen; die Konkurrenz ist strukturell.
Datenbank pro Mandant macht dasselbe ungleichmäßige Wachstum zu einer Option statt zu einem Problem: Weil jeder Mandant bereits eine eigene Datenbank ist, kann ein Mandant, der den Rest überholt, auf dedizierte Hardware verschoben werden, ohne die Einrichtung anderer anzufassen. Aber diese Option ist nicht kostenlos; sie wird in einer anderen Währung bezahlt. Sobald ein Mandant woanders lebt, ist deine Infrastruktur heterogen: Migrationen, Deployments, Backups und Monitoring müssen Mandanten an verschiedenen Orten berücksichtigen, und „wo lebt dieser Mandant eigentlich?“ wird zu einer Frage, die dein System bei jeder Anfrage korrekt beantworten muss. Du hast ein Performance-Konkurrenzproblem gegen ein Flottenverwaltungsproblem getauscht. Welches von beiden dein Team besser tragen kann, ist ein echter Teil der Entscheidung.
Ein Entscheidungsrahmen
Ohne die Einzelheiten läuft es auf vier Fragen hinaus:
- Ist physische Trennung eine Anforderung, keine Präferenz? Wenn ein Kundenvertrag, eine Aufsichtsbehörde oder ein Datenresidenzgesetz verlangt, dass die Daten eines Mandanten physisch von denen eines anderen getrennt sind, entscheidet das: Greif zu Datenbank pro Mandant (oder weiter rechts auf dem Spektrum), unabhängig von allem Folgenden. Das ist der häufigste Grund, warum reale Systeme diesen Weg gehen, und es ist bezeichnenderweise eine geschäftliche, keine technische Anforderung.
- Wie sieht deine Mandantenbasis aus? Eine Handvoll großer, hochwertiger Kunden macht den Overhead pro Mandant leicht zu rechtfertigen. Tausende kleiner Mandanten mit niedrigem Preis machen ihn ruinös; das Dichte-Argument gewinnt deutlich.
- Wie zentral sind mandantenübergreifende Daten für das Produkt? Plattformweite Analytik, nutzungsbasierte Abrechnung, Admin-Dashboards über alle Kunden hinweg: alles trivial in einer gemeinsamen Datenbank, alles ein echtes Ingenieurproblem über getrennte hinweg. Wenn das zum Kern deines Produkts gehört, gewichte es stark.
- Welchen Ausfall kannst du dir am wenigsten leisten? Das ist die Frage, bei der man verweilen sollte. Der schlimmste Tag einer gemeinsamen Datenbank ist ein mandantenübergreifendes Datenleck. Der schlimmste Tag eines Setups pro Mandant ist ein betrieblicher: eine Migration, die eine Teilmenge der Datenbanken beschädigt hat, ein Mandant, der auf einem alten Schema hängt. Wähle die Architektur, deren schlimmsten Fall dein Produkt und dein Team tatsächlich überstehen können.
Der Standard und wann man ihn verlässt
Der gesammelte Rat der Backend-Community landet an einer konsistenten Stelle, und die Kostenaufstellung erklärt, warum: Beginne mit einer gemeinsamen Datenbank und spaltenbasiertem Scoping und wechsle erst zu Datenbank pro Mandant, wenn dich etwas Konkretes dazu zwingt.
„Etwas Konkretes“ ist fast nie reine Größe; eine gut indizierte gemeinsame Datenbank trägt sehr weit. Es ist eine Anforderung: ein unterschriebener Vertrag, der Isolation verlangt, eine Vorschrift darüber, wo Daten physisch liegen, ein einzelner Mandant, der so groß ist, dass er eigene Hardware braucht. Ohne eines davon bietet das gemeinsame Modell weniger betriebliche Angriffsfläche, bessere Unit Economics und triviale mandantenübergreifende Abfragen, und du behältst die Option, einen anspruchsvollen Mandanten später herauszulösen, was tatsächlich leichter ist, als eine ganze Flotte von Silos wieder in eine Datenbank zurückzuführen.
Der Fehler ist nicht, das eine oder das andere zu wählen. Der Fehler ist, die stärker isolierte, teurere Architektur standardmäßig zu wählen, weil sie sich strenger anfühlt, und dann ihr betriebliches Gewicht für eine Isolationsgarantie zu tragen, nach der keiner deiner Kunden je gefragt hat.