Multi-Tenant Symfony: Drei Patterns und ihre tatsächlichen Kosten

Wie Sie in Symfony zwischen Database-per-Tenant, Schema-per-Tenant und Shared Schema wählen, indem Sie Isolation, Betriebskosten und Migrationsrisiko vergleichen.

Eine Holzschreibtisch-Szene mit einem Schild Multi-Tenant Symfony, drei Tenant-Figuren, einer Messingwaage, die Flexibilität und Kosten abwägt, einer skizzierten Tabelle, die Database-per-Tenant, Schema-per-Tenant und Shared Schema mit ihren tatsächlichen Kosten vergleicht, sowie Klebezetteln mit den Fragen, die zu stellen sind, und dem Hinweis, dass es kein kostenloses Mittagessen gibt, nur Abwägungen

Jede Multi-Tenant-Architekturentscheidung, bei der ich im Raum war, beginnt mit derselben Frage: “Welches Pattern ist am sichersten?” Das ist die falsche Frage, und sie zu beantworten führt zur falschen Architektur. Alle drei Patterns, die ich gleich beschreibe, lassen sich sicher umsetzen. Keines davon ist standardmäßig sicher. Die nützlichere Frage, die, um die ich das Gespräch zu lenken versuche, lautet: “Welches Pattern hat die günstigsten Betriebskosten für die Form des Geschäfts, das wir tatsächlich betreiben?”

Denn die Unterschiede zwischen Database-per-Tenant, Schema-per-Tenant und Shared Schema drehen sich nicht wirklich um Isolation. Isolation ist ein Nebenprodukt. Die Unterschiede liegen in den Kosten der Operationen, die Sie wöchentlich, monatlich und quartalsweise durchführen werden, für immer, solange das System existiert. Einen neuen Tenant einrichten. Eine Migration ausführen. Ein Support-Ticket untersuchen. Einen heißen Tenant skalieren, ohne einen ruhigen zu stören. Einen Tenant für DSGVO-Konformität löschen. Einen einzelnen Tenant aus einem Backup wiederherstellen.

Dieser Essay geht die drei Patterns so durch, wie ich sie in Engagements tatsächlich evaluiere, was jedes im Betrieb kostet und wie man wählt. Die Mechanik ist Symfony-spezifisch, die Analyse gilt für jedes Framework.

Was ein Tenant tatsächlich ist

Vor jedem Pattern: das Wort. “Tenant” ist überladen. Ich verwende es in folgendem Sinne: eine isolierte Menge an Daten und Identitäten, die die Anwendung als Einheit der Zugriffskontrolle behandelt. Ein Tenant hat Benutzer. Benutzer melden sich bei einem Tenant an. Daten, die von den Benutzern eines Tenants geschrieben werden, sind für die Benutzer eines anderen Tenants niemals sichtbar, außer durch explizites, auditierbares Teilen.

Der Tenant ist meistens ein Kunde (ein Unternehmen, das Ihr SaaS kauft). Manchmal ist es ein Workspace innerhalb eines Kunden (Slacks “Workspace”, Notions “Team”). Manchmal ein physischer Standort oder eine Region. Die Pattern-Entscheidungen sind unabhängig davon dieselben, das Vokabular ändert sich.

Drei Dinge sollten Sie festnageln, bevor Sie Code schreiben:

  1. Wie viele Tenants erwarten Sie? Zehn große Enterprise-Accounts und zehntausend kleine Self-Service-Accounts sind unterschiedliche Probleme. Das erste ist plausibel Database-per-Tenant. Das zweite mit ziemlicher Sicherheit nicht.
  2. Wie variabel sind sie in der Größe? Wenn der größte Tenant das 1.000-fache des Medians ist, haben Sie unabhängig vom Pattern ein Noisy-Neighbor-Problem, und die Pattern-Wahl muss das berücksichtigen.
  3. Wie sensibel sind die Daten? Healthcare und Finance drängen Sie in Richtung stärkerer Isolation, ein Marketing-Tool braucht nicht dieselbe Haltung.

Diese drei Antworten formen den Rest. Zehn Enterprise-Tenants mit 200-facher Größenvarianz im Gesundheitswesen ist ein völlig anderes Geschäft als zweitausend Self-Service-Accounts mit gleichmäßiger Nutzung in der Marketing-Tech. Das für das eine richtige Pattern ist für das andere falsch.

Pattern 1: Database per Tenant

Jeder Tenant bekommt seine eigene Datenbank-Instanz (oder zumindest seine eigene Datenbank auf einer geteilten Instanz). Die Anwendung verbindet sich zu einer anderen Datenbank, abhängig davon, zu welchem Tenant der Request gehört.

Wie das in Symfony aussieht:

YAML
doctrine:
    dbal:
        default_connection: default
        connections:
            default:
                url: '%env(resolve:DATABASE_URL)%'

Zur Laufzeit wird die Connection pro Request über eine RequestStack-fähige Connection-Factory oder einen Decorator auf dem EntityManager getauscht. Die meisten Teams landen bei etwas wie:

PHP
final readonly class TenantConnectionFactory
{
    public function __construct(
        private TenantContextInterface $tenantContext,
        private TenantDatabaseRegistryInterface $registry,
    ) {}

    public function forCurrentTenant(): Connection
    {
        $tenant = $this->tenantContext->current();
        $params = $this->registry->connectionParamsFor($tenant);

        return DriverManager::getConnection($params);
    }
}

Das TenantContextInterface liest den aktuellen Tenant aus dem Request (Subdomain, Pfad-Präfix, Header oder authentifiziertem Benutzer), und die Registry mappt Tenant-IDs auf Connection-Parameter.

Was es im Betrieb kostet:

Operation Kosten
Onboarding eines Tenants Datenbank bereitstellen, Migrations ausführen, seeden. Minuten bis Stunden pro Tenant, skriptbar.
Migration ausführen Gegen jede Tenant-Datenbank ausführen. 10 Tenants: 10 Migrations. 10.000 Tenants: ein echter Job.
Support-Ticket untersuchen Zu einer Datenbank verbinden, direkt abfragen. Einfach, lokal, kein Tenant-Filter zu merken.
Einen heißen Tenant skalieren Diese Datenbank auf eine größere Instanz verschieben. Chirurgisch, keine Auswirkung auf andere.
DSGVO-Löschung Datenbank droppen. Fertig.
Per-Tenant Backup-Restore Eine Datenbank wiederherstellen. Einfach.
Kostenuntergrenze Hoch. Auch kleine Tenants kosten Sie eine Datenbank.
Tenant-übergreifendes Reporting Schwierig. Erfordert Föderation über Datenbanken hinweg.

Wo es gewinnt: Zehn bis einige hundert Enterprise-Tenants. Strikte Data-Residency- oder regulatorische Anforderungen (jeder Tenant in einer bestimmten Jurisdiktion). Kunden mit stark unterschiedlichen Größen oder Lastprofilen (der Enterprise-Kunde, der 500-mal so groß ist wie der nächstgrößere).

Wo es verliert: Self-Service-Produkte mit tausenden kleinen Tenants. Die Kosten pro Tenant (Infrastruktur, Migrationszeit, operativer Overhead) machen die Unit Economics unmöglich. 10.000 Migrations auszuführen ist nicht schwierig, bis es schwierig wird, und dieses “bis es schwierig wird” kommt tendenziell im schlechtmöglichsten Moment.

Symfony-spezifischer Fallstrick: Der Metadaten-Cache von Doctrine wird standardmäßig über Connections hinweg geteilt, was in Ordnung ist. Aber der EntityManager selbst trägt connection-bezogenen Zustand, wenn Sie also mitten im Request die Connection tauschen, müssen Sie den Manager leeren oder einen tenant-spezifischen instanziieren. Machen Sie das falsch, schreiben Sie still und leise die Daten eines Tenants in die Datenbank eines anderen. Ich habe das zweimal gesehen. Es ist das schlimmste Ergebnis aller Patterns.

Pattern 2: Schema per Tenant

Eine Datenbank-Instanz, eine Datenbank, viele Schemas. Jeder Tenant lebt in seinem eigenen PostgreSQL-Schema (oder MySQL-Datenbank, da MySQL sie gleich nennt). Die Anwendung setzt den search_path pro Request auf das Schema des Tenants.

Wie das in Symfony aussieht:

PHP
final readonly class TenantSchemaListener
{
    public function __construct(
        private TenantContextInterface $tenantContext,
        private Connection $connection,
    ) {}

    #[AsEventListener(event: KernelEvents::REQUEST, priority: 256)]
    public function onRequest(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $tenant = $this->tenantContext->current();
        $this->connection->executeStatement(
            \sprintf('SET search_path TO %s', $this->quoteIdentifier($tenant->schema())),
        );
    }
}

Das ist die Ein-Zeilen-Variante. Die produktive Variante muss Connection-Pooling handhaben (wird die Connection über Requests hinweg wiederverwendet, leakt der search_path aus dem letzten Request), Doctrine-Migrations, die ein bestimmtes Schema adressieren müssen, und den Edge Case, dass ein Background-Job auf derselben Connection Messages für mehrere Tenants verarbeitet.

Was es im Betrieb kostet:

Operation Kosten
Onboarding eines Tenants Schema erstellen, Migrations dagegen ausführen. Schnell, skriptbar, immer noch pro Tenant.
Migration ausführen Eine Datenbank, N Schemas. N ALTER TABLEs ausführen. Langsamer als eins, deutlich schneller als N Datenbanken.
Support-Ticket untersuchen Zur Datenbank verbinden, SET search_path, abfragen. Eine Connection, viele Schemas.
Einen heißen Tenant skalieren Schwierig. Die Datenbank ist geteilt, die Last eines Tenants trifft die Connections aller. Nur vertikale Skalierung, es sei denn, Sie migrieren das Schema in eine eigene DB.
DSGVO-Löschung DROP SCHEMA ist schnell und sauber.
Per-Tenant Backup-Restore Schwieriger als DB-per-Tenant. pg_dump pro Schema ist umständlich, ein einzelnes Schema in eine laufende DB zu restaurieren, ist nicht trivial.
Kostenuntergrenze Niedriger als DB-per-Tenant. Eine Datenbank-Lizenz, N Schemas.
Tenant-übergreifendes Reporting Machbar via UNION ALL über Schemas, aber Queries werden schnell lang.

Wo es gewinnt: Die Mitte des Größenspektrums. Einige Dutzend bis einige hundert Tenants, in ähnlicher Größenordnung, wenn Sie günstige logische Isolation ohne die Kosten von N Datenbanken wollen. Häufig das Pattern, das Teams wählen, wenn sie aus Shared Schema herauswachsen, aber DB-per-Tenant nicht rechtfertigen können.

Wo es verliert: Alles, was starke operative Isolation zwischen Tenants erfordert. Die Datenbank ist immer noch eine Datenbank. Eine schlechte Query eines Tenants sperrt Tabellen, die alle anderen nutzen. Eine langsame tenant-spezifische Migration hält Locks über den geteilten Pool. Connection-Pool-Erschöpfung ist ein geteiltes Schicksal.

Symfony-spezifischer Fallstrick: Doctrine hat keinen First-Class-Support für Schema-per-Tenant. Sie landen bei einer eigenen Naming Strategy, einem Per-Request-Listener und (fast immer) einem Bug, bei dem Background-Jobs mit dem falschen search_path laufen, weil der Messenger-Handler ihn nicht zurückgesetzt hat. Ich empfehle dieses Pattern zögerlich und nur mit offenen Augen bezüglich der Messenger-Integrationskosten.

Pattern 3: Shared Schema

Eine Datenbank, ein Schema, ein Satz Tabellen. Jede Tabelle mit tenant-spezifischen Daten hat eine tenant_id-Spalte. Jede Query wird mit WHERE tenant_id = ? gefiltert. Sicherheit wird im Anwendungscode durchgesetzt (und, falls Sie paranoid sind, mit PostgreSQL Row-Level Security als Gürtel-und-Hosenträger-Sicherung).

Wie das in Symfony aussieht:

Das Datenmodell hat tenant_id auf jeder tenant-spezifischen Entity. Doctrine Filters liefern den Hook dafür:

PHP
#[AsDoctrineListener(event: 'postLoad')]
final readonly class TenantFilterConfigurator
{
    public function __construct(
        private EntityManagerInterface $em,
        private TenantContextInterface $tenantContext,
    ) {}

    public function configure(): void
    {
        $filter = $this->em->getFilters()->enable('tenant_filter');
        $filter->setParameter('tenant_id', $this->tenantContext->current()->id());
    }
}

Wobei tenant_filter ein Doctrine\ORM\Query\Filter\SQLFilter ist, der WHERE tenant_id = :tenant_id zu jeder Query auf tenant-spezifischen Entities hinzufügt. Das ist die Vordergrund-Geschichte.

Die Hintergrund-Geschichte: Jedes Mal, wenn Sie vergessen, den Filter anzuwenden, haben Sie ein Datenleck. Filter gelten nicht für native SQL-Queries. Sie gelten für den QueryBuilder in Weisen, die Junior-Entwickler immer wieder überraschen. Sie gelten nicht für EntityManager::find() auf tenant-spezifischen Entities, es sei denn, Sie konfigurieren das explizit. Die Anwendung trägt die Last, es jedes einzelne Mal richtig zu machen.

Was es im Betrieb kostet:

Operation Kosten
Onboarding eines Tenants Zeile in tenants einfügen. Fertig. Millisekunden.
Migration ausführen Einmal ausführen. Keine Per-Tenant-Schleife.
Support-Ticket untersuchen SELECT ... WHERE tenant_id = ? in jeder Query. Leicht in Ad-hoc-Tooling falsch zu machen.
Einen heißen Tenant skalieren Können Sie eigentlich nicht. Der Tenant teilt sich Ressourcen mit jedem anderen Tenant.
DSGVO-Löschung DELETE FROM ... WHERE tenant_id = ? über jede Tabelle. Langsam, fehleranfällig, leicht eine kaskadierende Relation zu vergessen.
Per-Tenant Backup-Restore Ohne Application-Level-Tooling nahezu unmöglich. pg_dump filtert nicht nach Spalte.
Kostenuntergrenze Sehr niedrig. Eine Datenbank, ein Schema, Sie zahlen, was Sie nutzen.
Tenant-übergreifendes Reporting Trivial. Es ist alles eine Tabelle.

Wo es gewinnt: Self-Service-Produkte mit tausenden Tenants. Ähnlich große Tenants. Wenig sensible Daten. Workloads, bei denen tenant-übergreifende Analytik ein First-Class-Feature ist, kein Compliance-Problem.

Wo es verliert: Alles mit Compliance-Anforderungen, die harte Fragen zur Tenant-Isolation stellen. Alles mit stark unterschiedlichen Tenant-Größen (der eine Riesen-Tenant verschmutzt die Performance aller anderen). Alles, wo das Ops-Team isoliert an den Daten eines Tenants arbeiten muss.

Symfony-spezifischer Fallstrick: Doctrine-Filter sind leicht versehentlich zu deaktivieren. $em->getFilters()->disable('tenant_filter') existiert aus legitimen Gründen (Admin-Hintergrundaufgaben, tenant-übergreifende Reports) und ist eine Selbstschussfalle. Ich zwinge jedes Team auf diesem Pattern, eine statische Analyse-Regel zu schreiben, die jeden Aufruf von disable() markiert und explizite Begründung verlangt. Ohne diese Regel deaktiviert jemand im Jahr 2026 den Filter, und Sie erfahren es 2028.

Die Fragen, die die Wahl tatsächlich antreiben

Die obige Pattern-Matrix ist nützlich, aber Entscheidungen laufen selten auf eine einzelne Zeile hinaus. Die Fragen, die in den Gesprächen mit Gründern und CTOs tatsächlich das Pattern entscheiden:

Wie viele Tenants projizieren Sie in 18 Monaten? Lautet die Antwort “zehn bis dreißig”, ist Database-per-Tenant noch auf dem Tisch. Lautet sie “fünftausend”, nicht mehr. Niemand führt pro Woche Migrations gegen fünftausend Datenbanken aus und bleibt dabei bei Verstand.

Hat einer Ihrer Tenants eigene regulatorische Anforderungen? HIPAA-Tenants und Nicht-HIPAA-Tenants in derselben Datenbank sind ein Audit-Albtraum. Healthcare-Tenants in ihrer eigenen Datenbank, einfacher. Wenn auch nur ein einzelner Tenant Sie zu DB-per-Tenant drängt, gehen entweder alle dorthin, oder Sie akzeptieren die Tooling-Komplexität, zwei Patterns zu betreiben.

Haben Sie ein Problem mit dem größten Tenant? Macht ein Kunde 40% der Last aus, braucht er wahrscheinlich eigene Infrastruktur. Das könnte DB-per-Tenant speziell für diesen Kunden sein (während alle anderen auf einem anderen Pattern laufen). Das ist legitim, lassen Sie architektonische Reinheit nicht von einem pragmatischen Split abhalten.

Wie sieht Ihr Ops-Team aus? Ein Zwei-Personen-Plattform-Team kann zehntausend Datenbanken nicht zuverlässig betreiben. Sie können eine Datenbank mit zehntausend Zeilen in der Tenant-Tabelle betreiben. Passen Sie das Pattern an die tatsächliche Betriebskapazität des Teams an, nicht an die, die Sie sich wünschen.

Wie oft werden Sie Migrations ausführen? Ein Team, das monatlich Schema-Änderungen ausliefert, verträgt DB-per-Tenant besser als eines, das sie wöchentlich ausliefert. Jede Migration ist operative Oberfläche, je mehr Sie haben, desto mehr kostet diese Oberfläche.

Wie viel teilen Ihre Tenants untereinander? Ist das Kernfeature “Benutzer in unterschiedlichen Tenants kollaborieren an geteilten Dokumenten”, machen die dafür nötigen tenant-übergreifenden Queries Shared Schema nahezu zwingend. Sind Tenants echt isolierte Inseln, funktioniert jedes Pattern.

Das Pattern fällt aus den Antworten. Es fällt nahezu nie aus der Frage “Was ist am sichersten”, weil alle absicherbar sind und der Aufwand, die Isolation richtig hinzubekommen, über die Patterns hinweg ungefähr konstant ist.

Die Hybrid-Patterns, die im Feld tatsächlich gewinnen

Die meisten Multi-Tenant-Architekturen, die ich unter Skalierung halten sehen habe, sind keine reinen Ausprägungen eines Patterns. Sie sind Hybride, abgestimmt auf die Form des Geschäfts.

Shared Schema mit tenant-spezifischen Erweiterungen. Zentrale tenant-spezifische Tabellen leben im Shared Schema. Tenants, die Custom Fields brauchen, bekommen eine Key-Value-Tabelle (tenant_id, field_name, field_value) oder eine JSON-Spalte. Skaliert günstig, Customizing ist möglich, ohne das Schema anzufassen.

Shared Schema mit Premium-Tenants, die in dedizierte Datenbanken gehoben werden. 95% der Tenants auf Shared Schema, die oberen 5% (nach Größe oder Vertragsstufe) in eine eigene Datenbank gehoben. Der Anwendungscode muss wissen, wie er sich zu beiden verbinden kann. Hässlich, aber pragmatisch.

Schema-per-Tenant mit einem einzigen großen “public”-Schema. Tenants bekommen ihr eigenes Schema für tenant-spezifische Daten, ein geteiltes Public-Schema hält tenant-übergreifende Referenzdaten (Länder, Währungen, Feature Flags). Sauberer, als zu versuchen, Referenzdaten in jedes Tenant-Schema zu replizieren.

Database-per-Tenant für Enterprise-Kunden, Shared Schema für Self-Service. Das häufigste Pattern, das ich bei reifen SaaS-Unternehmen sehe. Zwei Code-Pfade, mehr Ops-Overhead, aber die Unit Economics funktionieren für beide Enden des Kundenspektrums.

Die Falle bei Hybriden ist die operative Last. Jeder Hybrid sind zwei (oder mehr) Patterns, was zwei Runbooks, zwei Sets an Tooling, zwei Fehlerzustände bedeutet. Es lohnt sich, wenn die Kundensegmente genuin unterschiedlich sind, es lohnt sich nicht, wenn Sie sich schlicht nicht entscheiden konnten.

Wie Sie nach der Wahl evaluieren

Sobald Sie sich entschieden haben, evaluieren Sie das Pattern alle sechs Monate anhand der tatsächlichen Zahlen:

  • Onboarding-Zeit pro Tenant. Wächst sie, arbeitet das Pattern gegen Sie.
  • Migrations-Dauer. Liegt sie über einer Stunde, führen Sie sie wahrscheinlich zu selten aus, weil sie schmerzen, was bedeutet, Ihre Deprecation-Schuld wächst.
  • Untersuchungszeit für Support-Tickets. Können Support-Engineers Fragen zu einem Tenant nicht in unter fünf Minuten beantworten, stimmt die operative Ergonomie des Patterns nicht.
  • Noisy-Neighbor-Beschwerden. Tenants, die melden “das System war heute langsam”, obwohl sich nur die Workload eines Tenants änderte, sind ein Signal, dass die Isolation schwächer ist als gedacht.
  • DSGVO-Löschlatenz. Regulatoren stellen dazu härtere Fragen. Dauert eine Löschung wegen des Patterns Wochen, ist das ein Risiko, das es vor fünf Jahren nicht gab.

Driftet eine dieser Metriken, ist das Pattern nicht zwingend falsch, aber die Diskussion muss stattfinden. Manchmal ergänzen Sie Tooling. Manchmal heben Sie einen Tenant in eine eigene Datenbank. Manchmal, selten, migrieren Sie von einem Pattern zum anderen. Das ist ein großes Projekt, gehen Sie es mit offenen Augen an.

Zwischen Patterns zu migrieren, ist ein Projekt, kein Refactoring

Eine letzte Bemerkung, weil ich diese Frage mindestens zweimal pro Jahr bekomme. Teams, die Shared Schema gewählt haben und jetzt den Noisy-Neighbor-Schmerz spüren, fragen, ob sie “zu DB-per-Tenant wechseln” können. Technisch ja. Praktisch ist es ein mehrmonatiges Projekt.

Die Arbeit: Aufbau einer Provisioning-Pipeline für neue Datenbanken, Erweiterung der Anwendung, damit sie mehrere Connections kennt, Migrations-Tooling schreiben, das bestehende Tenants aus dem Shared Schema herausschneidet, sie ohne Downtime herauszuschneiden (Expand-Contract, dieselben Patterns wie in meinem Essay zu Doctrine-Migrations), jedes Query-Pattern zu aktualisieren, das Shared-Schema-Semantik voraussetzte, und jeden Integrationstest neu durchlaufen zu lassen.

Ich habe diese Art von Migration zweimal geleitet. Beide dauerten vier bis sechs Monate an Senior-Engineering-Zeit. Beide waren es wert, weil sich das Geschäft so stark verändert hatte, dass das ursprüngliche Pattern nicht mehr passte. Unterschätzen Sie das Projekt nicht, und nehmen Sie es nicht in Angriff, wenn der Schmerz nicht klar strukturell ist, statt mit Tooling lösbar.

Die Nicht-Antwort

Haben Sie gehofft, dieser Essay sagt Ihnen, welches Pattern das beste ist, lesen Sie den falschen Essay. Keines ist das beste. Das beste Pattern ist jenes, dessen Betriebskosten zum Betriebsbudget des betreibenden Teams passen, dessen Performance-Profil zur Form der Kunden passt, die es bedient, und dessen Isolationsgarantien zur Compliance-Realität des Geschäfts passen.

Was Sie tun können: sich weigern, aus Gewohnheit oder Ideologie zu wählen. Zeichnen Sie die Frage-Matrix. Beantworten Sie sie ehrlich. Wählen Sie das Pattern, auf das die Antworten zeigen. Bauen Sie es. Prüfen Sie die Entscheidung alle sechs Monate gegen die Metriken. Das ist die Praxis, das konkrete Pattern, das dabei herausfällt, ist sekundär.


Wählen Sie gerade ein Multi-Tenant-Pattern für ein neues Produkt, oder leben Sie mit einem, das nicht mehr passt, dann geht mein Scaling-Engagement genau diese Entscheidungsmatrix mit Ihrem Team durch. Einwöchige Diagnostik, eine dokumentierte Pattern-Wahl mit festgehaltenen Tradeoffs, und (falls Sie migrieren) ein stufenweiser Plan, der das Geschäft während der Transition am Laufen hält.

Referenzen

Bereit, Ihre Architektur in den Griff zu bekommen?

Buchen Sie ein kostenloses 30-minütiges Gespräch mit Silas. Kein Verkaufsgespräch, nur ein direkter Austausch über Ihre Herausforderungen.

Antwort in der Regel innerhalb von 24 Stunden.

Kostenloses Gespräch buchen