Saltar al contenido

SaaS multi-tenant: aislamiento, escalado y datos

Análisis a fondo de la arquitectura multi-tenant: aislamiento, partición de bases de datos, enrutamiento, cuotas y compartido frente a base por tenant.

4 min de lectura
Un diagrama de arquitectura multi-tenant que muestra infraestructura compartida con límites lógicos de tenant y capas de aislamiento de recursos

El espectro de multi-tenencia

La multi-tenencia no es una elección binaria entre «todo compartido» y «todo separado». Es un espectro, y la posición correcta en él depende de tus requisitos de cumplimiento, expectativas de los clientes, estructura de costos y capacidad del equipo.

En un extremo, todos los tenants comparten una base de datos, una instancia de aplicación y una capa de caché. En el otro, cada tenant obtiene una base de datos dedicada, computación dedicada y red dedicada. La mayoría de los sistemas en producción se sitúan en algún punto intermedio.

Modelos de aislamiento de tenant

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);
  }
}

Las tablas compartidas con una columna tenant_id son las más baratas de operar pero las más difíciles de asegurar. Una cláusula WHERE ausente en una sola consulta filtra datos entre tenants. El esquema por tenant proporciona un aislamiento más fuerte con una sobrecarga moderada. La base de datos por tenant ofrece el aislamiento más fuerte y el cumplimiento más sencillo, pero el costo operativo más alto.

Enrutamiento de peticiones y resolución de tenant

Cada petición debe resolverse a un tenant antes de que se ejecute cualquier lógica de negocio. La resolución debe ser rápida, cacheada e imposible de evitar.

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();
  };
}

Estrategias de base de datos para multi-tenencia

La capa de base de datos es donde la multi-tenencia se complica. La seguridad a nivel de fila, el pool de conexiones, las migraciones y las copias de seguridad se comportan de manera diferente según el modelo de aislamiento.

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
  }
}

Caché y trabajos en segundo plano conscientes del tenant

Las claves de caché deben incluir el ID del tenant. Los trabajos en segundo plano deben transportar el contexto del tenant. Sin estas salvaguardas, las colisiones de caché filtran datos y los trabajos se ejecutan en el contexto equivocado.

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;
  }
}

Conclusiones clave

La arquitectura multi-tenant es un espectro de compensaciones entre aislamiento, costo y complejidad operativa. Comienza con infraestructura compartida y filtrado de tenant a nivel de fila para productos en etapa inicial. Pasa a esquema por tenant cuando el cumplimiento requiera un aislamiento más fuerte. Reserva la base de datos por tenant para clientes enterprise que la necesiten explícitamente y estén dispuestos a pagar por ella.

Cada capa debe ser consciente del tenant: enrutamiento de peticiones, consultas de base de datos, claves de caché, trabajos en segundo plano y registros. Una sola capa que olvide el contexto del tenant crea una fuga de datos. Construye la resolución de tenant como middleware que se ejecute antes de cualquier lógica de negocio, y haz imposible ejecutar consultas sin un contexto de tenant.

Diseña el camino de migración entre modelos de aislamiento desde el principio. El peor resultado es quedar atascado en tablas compartidas cuando tu cliente más grande exige infraestructura dedicada. Abstrae la capa de base de datos detrás de un repositorio consciente del tenant para que el modelo de aislamiento pueda cambiar sin reescribir el código de la aplicación.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX