Zum Inhalt springen

Multi-Tenant-SaaS: Isolation, Skalierung, Datenstrategie

Deep Dive in Multi-Tenant-Muster für SaaS: Tenant-Isolation, Datenbankpartitionierung, Request-Routing, Quoten und Shared vs. Database-per-Tenant.

4 Min. Lesezeit
Ein Multi-Tenant-Architekturdiagramm, das gemeinsame Infrastruktur mit logischen Tenant-Grenzen und Ressourcen-Isolationsebenen zeigt

Das Multi-Tenancy-Spektrum

Multi-Tenancy ist keine binäre Entscheidung zwischen „Shared Everything" und „Separate Everything". Es ist ein Spektrum, und die richtige Position auf diesem Spektrum hängt von deinen Compliance-Anforderungen, Kundenerwartungen, Kostenstruktur und Teamkapazität ab.

An einem Ende teilen sich alle Tenants eine Datenbank, eine Anwendungsinstanz und einen Cache-Layer. Am anderen Ende bekommt jeder Tenant eine dedizierte Datenbank, dedizierte Compute-Ressourcen und dediziertes Networking. Die meisten Produktionssysteme landen irgendwo dazwischen.

Modelle zur Tenant-Isolation

tstypescript
type IsolationModel = "shared" | "schema-per-tenant" | "database-per-tenant";
 
interface TenantConfig {
  id: string;
  name: string;
  isolationModel: IsolationModel;
  tier: "free" | "pro" | "enterprise";
  region: string;
  resourceQuota: ResourceQuota;
}
 
interface ResourceQuota {
  maxStorageGB: number;
  maxRequestsPerMinute: number;
  maxConcurrentConnections: number;
  maxComputeUnits: number;
}
 
// ❌ One-size-fits-all isolation — forces enterprise customers
//    into shared infrastructure they don't want
function getConnection(): DatabaseConnection {
  return sharedPool.getConnection();
}
 
// ✅ Tier-based isolation — matching isolation to customer needs
function getTenantConnection(tenant: TenantConfig): DatabaseConnection {
  switch (tenant.isolationModel) {
    case "database-per-tenant":
      return getDedicatedConnection(tenant.id);
    case "schema-per-tenant":
      return getSchemaConnection(tenant.id);
    case "shared":
      return getSharedConnection(tenant.id);
  }
}

Geteilte Tabellen mit einer tenant_id-Spalte sind am günstigsten zu betreiben, aber am schwierigsten abzusichern. Eine fehlende WHERE-Klausel in einer einzelnen Abfrage lässt Daten über Tenant-Grenzen hinweg durchsickern. Schema-per-Tenant bietet stärkere Isolation mit moderatem Overhead. Database-per-Tenant bietet die stärkste Isolation und die einfachste Compliance, aber die höchsten Betriebskosten.

Request-Routing und Tenant-Auflösung

Jeder Request muss vor der Ausführung jeglicher Geschäftslogik einem Tenant zugeordnet werden. Die Auflösung muss schnell, gecacht und unmöglich zu umgehen sein.

tstypescript
interface TenantResolver {
  resolve(req: IncomingRequest): Promise<TenantConfig | null>;
}
 
class SubdomainTenantResolver implements TenantResolver {
  constructor(
    private readonly cache: Cache,
    private readonly tenantRepo: TenantRepository
  ) {}
 
  async resolve(req: IncomingRequest): Promise<TenantConfig | null> {
    const host = req.headers.host || "";
    const subdomain = host.split(".")[0];
 
    if (!subdomain || subdomain === "www" || subdomain === "app") {
      return null;
    }
 
    // Check cache first
    const cached = await this.cache.get<TenantConfig>(
      `tenant:${subdomain}`
    );
    if (cached) return cached;
 
    const tenant = await this.tenantRepo.findBySubdomain(subdomain);
    if (!tenant) return null;
 
    await this.cache.set(`tenant:${subdomain}`, tenant, { ttl: 300 });
    return tenant;
  }
}
 
// Middleware that enforces tenant resolution
function tenantMiddleware(
  resolver: TenantResolver
): RequestMiddleware {
  return async (req, res, next) => {
    const tenant = await resolver.resolve(req);
 
    if (!tenant) {
      res.status(404).json({ error: "Tenant not found" });
      return;
    }
 
    if (tenant.tier === "free" && tenant.resourceQuota) {
      const allowed = await checkRateLimit(
        tenant.id,
        tenant.resourceQuota.maxRequestsPerMinute
      );
      if (!allowed) {
        res.status(429).json({ error: "Rate limit exceeded" });
        return;
      }
    }
 
    // Attach tenant to request context
    req.tenant = tenant;
    next();
  };
}

Datenbankstrategien für Multi-Tenancy

Die Datenbank-Layer ist der Punkt, an dem Multi-Tenancy kompliziert wird. Row-Level-Security, Connection-Pooling, Migrationen und Backups verhalten sich je nach Isolationsmodell unterschiedlich.

tstypescript
// Shared database with row-level security
class SharedTenantRepository<T extends { tenantId: string }> {
  constructor(
    private readonly db: Database,
    private readonly tableName: string
  ) {}
 
  async findAll(tenantId: string): Promise<T[]> {
    // Always filter by tenant — never trust application code alone
    return this.db.query<T>(
      `SELECT * FROM ${this.tableName} WHERE tenant_id = $1`,
      [tenantId]
    );
  }
 
  async create(tenantId: string, data: Omit<T, "tenantId">): Promise<T> {
    const columns = Object.keys(data);
    const values = Object.values(data);
 
    return this.db.queryOne<T>(
      `INSERT INTO ${this.tableName} 
        (tenant_id, ${columns.join(", ")})
       VALUES ($1, ${columns.map((_, i) => `$${i + 2}`).join(", ")})
       RETURNING *`,
      [tenantId, ...values]
    );
  }
 
  async delete(tenantId: string, id: string): Promise<boolean> {
    const result = await this.db.execute(
      `DELETE FROM ${this.tableName} WHERE id = $1 AND tenant_id = $2`,
      [id, tenantId]
    );
    return result.rowCount > 0;
  }
}
 
// Schema-per-tenant strategy
class SchemaTenantManager {
  constructor(private readonly db: Database) {}
 
  async createTenantSchema(tenantId: string): Promise<void> {
    const schemaName = this.sanitizeSchemaName(tenantId);
 
    await this.db.execute(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
 
    // Run migrations within the tenant schema
    await this.db.execute(`SET search_path TO "${schemaName}"`);
    await this.runMigrations();
    await this.db.execute(`SET search_path TO public`);
  }
 
  async getTenantConnection(tenantId: string): Promise<Database> {
    const schemaName = this.sanitizeSchemaName(tenantId);
    const conn = await this.db.getConnection();
    await conn.execute(`SET search_path TO "${schemaName}"`);
    return conn;
  }
 
  private sanitizeSchemaName(tenantId: string): string {
    return `tenant_${tenantId.replace(/[^a-zA-Z0-9_]/g, "")}`;
  }
 
  private async runMigrations(): Promise<void> {
    // Apply schema migrations
  }
}

Tenant-bewusstes Caching und Background-Jobs

Cache-Keys müssen die Tenant-ID enthalten. Background-Jobs müssen den Tenant-Kontext mitführen. Ohne diese Absicherungen führen Cache-Kollisionen zu Datenlecks und Jobs laufen im falschen Tenant-Kontext.

tstypescript
class TenantCache {
  constructor(private readonly cache: Cache) {}
 
  async get<T>(tenantId: string, key: string): Promise<T | null> {
    return this.cache.get<T>(this.tenantKey(tenantId, key));
  }
 
  async set<T>(
    tenantId: string,
    key: string,
    value: T,
    ttl?: number
  ): Promise<void> {
    await this.cache.set(this.tenantKey(tenantId, key), value, { ttl });
  }
 
  async invalidateAll(tenantId: string): Promise<void> {
    const pattern = `tenant:${tenantId}:*`;
    await this.cache.deletePattern(pattern);
  }
 
  private tenantKey(tenantId: string, key: string): string {
    return `tenant:${tenantId}:${key}`;
  }
}
 
// Background job with tenant context
interface TenantJob<T = unknown> {
  tenantId: string;
  jobType: string;
  payload: T;
  priority: number;
  scheduledAt: Date;
}
 
class TenantJobProcessor {
  constructor(
    private readonly queue: JobQueue,
    private readonly tenantManager: SchemaTenantManager
  ) {}
 
  async enqueue<T>(
    tenantId: string,
    jobType: string,
    payload: T
  ): Promise<string> {
    const job: TenantJob<T> = {
      tenantId,
      jobType,
      payload,
      priority: this.getTenantPriority(tenantId),
      scheduledAt: new Date(),
    };
 
    return this.queue.add(job);
  }
 
  async process(job: TenantJob): Promise<void> {
    // Establish tenant context before processing
    const db = await this.tenantManager.getTenantConnection(
      job.tenantId
    );
 
    try {
      const handler = this.getHandler(job.jobType);
      await handler.execute(job.payload, db);
    } finally {
      await db.release();
    }
  }
 
  private getTenantPriority(tenantId: string): number {
    // Enterprise tenants get higher job priority
    return 1;
  }
 
  private getHandler(jobType: string): JobHandler {
    // Resolve handler by type
    return {} as JobHandler;
  }
}

Kernpunkte

Multi-Tenant-Architektur ist ein Spektrum von Kompromissen zwischen Isolation, Kosten und Betriebskomplexität. Beginne für Produkte in der Frühphase mit geteilter Infrastruktur und Row-Level-Tenant-Filterung. Wechsle zu Schema-per-Tenant, wenn Compliance eine stärkere Isolation erfordert. Reserviere Database-per-Tenant für Enterprise-Kunden, die es explizit benötigen und dafür bezahlen.

Jede Layer muss Tenant-bewusst sein: Request-Routing, Datenbankabfragen, Cache-Keys, Background-Jobs und Logging. Eine einzelne Ebene, die den Tenant-Kontext vergisst, erzeugt ein Datenleck. Baue die Tenant-Auflösung als Middleware, die vor jeder Geschäftslogik läuft, und stelle sicher, dass Abfragen ohne Tenant-Kontext nicht ausführbar sind.

Entwerfe den Migrationspfad zwischen Isolationsmodellen von Anfang an. Das schlechteste Ergebnis ist, in geteilten Tabellen festzustecken, wenn dein größter Kunde dedizierte Infrastruktur fordert. Abstrahiere die Datenbank-Layer hinter einem Tenant-bewussten Repository, damit sich das Isolationsmodell ändern lässt, ohne Anwendungscode neu schreiben zu müssen.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX