Arreglar el problema N+1 de GraphQL con entidades relacionadas

GraphQL viene a proponer una alternativa más moderna y avanzada para la creación de interfaces de programación de aplicaciones (API por sus siglas en Inglés). Provee de una descripción completa y entendible de los datos de una API, otorga al cliente la capacidad de solicitar los datos requeridos y simplifica su evolución.

Creada por Facebook en 2012 y liberada públicamente en 2015 viene a suplir ciertas carencias de las ya clásicas API REST, adaptada a múltiples lenguajes de programación y entornos se está posicionando como una alternativa seria que sólo el tiempo dirá hasta qué punto puede llegar a sustituirlas.

Tratándose de una propuesta bastante reciente no deja de tener ciertos aspectos que mejorar, uno de ellos es el que venimos a comentar en este artículo y es que tal y cómo ha sido diseñada presenta el problema de N+1 a la hora de acceder a entidades relacionadas que poseen algunos ORM, lo que en bases de datos bastante grandes produce una sobrecarga innecesaria a la hora de obtener los datos provocando una lentitud mayor a la deseada ralentizando así la carga de datos y por tanto la aplicación que requiera el uso de ellos.

El  problema N+1 hace que a la hora de intentar un objeto relacionado sea necesario realizar una consulta independiente a mayores de la original por cada una de las entidades cuando en realidad podría hacerse con una única consulta. Por ejemplo podemos tener una base de datos con dos tablas, una de perfiles de usuario y otra de usuarios que llevaría el id del perfil de usuario referenciada de la otra tabla.

Si tuviéramos 50 usuarios y quisiéramos obtenerlos todos junto  a su perfil, el problema N+1 haría que en vez de realizarse una única consulta se realizaran 51 consultas, una para obtener todos los usuarios y otras 50 para obtener los perfiles relacionados de esos usuarios, provocando la sobrecarga innecesaria del acceso a la base de datos, lo que a la larga puede suponer un grave problema.

En mi Github he creado una aplicación de ejemplo para exponer una API que presenta este problema y su solución, la rama master posee el problema y la rama n_plus_one_problem_solved tiene las modificaciones necesarias para corregirlo, para ello se ha hecho uso de la librería DataLoader, creada por @schrockn en 2010 para Facebook y simplificada posteriormente.

Para ejecutar la aplicación podemos seguir los pasos indicados a continuación (hace uso de un contenedor Docker con todo preparado para ejecutarse):

  • git clone https://github.com/amendezcabrera/graphql-n-1-tutorial.git
  • cd graphql-n-1-tutorial
  • docker-compose build
  • docker-compose up <- Esto se encargará de instalar todas las dependencias y levantar el servicio de la base de datos, web y una herramienta para administrar la base de datos
  • Accedemos a la url http://localhost:5050 dónde se nos abrirá un PGAdmin en versión web e introducimos las credenciales pgadmin4@pgadmin.org y admin como contraseña
  • Añadimos el servidor de la base de datos, en este caso, un contenedor Docker que lleva por nombre de equipo pgdb
    • Botón derecho encima de Servers y luego Create… Server
    • Le ponemos el nombre que queramos y abrimos la pestaña de Connection
    • Introducimos los siguientes datos:
      • Host name/address: pgdb
      • Username: postgres
  • Abrimos el servidor creado y con el botón derecho encima de Databases le damos a Create Database…
  • Le ponemos de nombre db (es muy importante que se llame así si no la aplicación no funcionará)
  • Ahora tenemos que crear las tablas necesarias y rellenarlas con datos, para ello conectamos al contenedor Docker que contiene la API mediante los siguientes comandos
    • docker ps <- Nos mostrará los contenedores que están en ejecución, tenemos que recoger el que lleva por nombre api
    • docker exec -it CONTAINER_ID /bin/bash <- CONTAINER_ID es el obtenido en el punto anterior
  • Una vez conectados creamos y rellenamos de datos las tablas con:
    • sequelize db:migrate
    • sequelize db:seed:all

Tras seguir estos pasos ya tenemos nuestra API funcionando para poder hacer uso de ella, si conectamos a la url http://localhost:81/graphiql tenemos un cliente para consultar datos a GraphQL. Al hacer checkout por primera vez de la rama n_plus_one_problem_solved hay que instalar las dependencias y recargar la aplicación o simplemente echar abajo los contenedores de Docker y volver a levantarlos.

Al ejecutar la consulta anterior, si nos fijamos en la consola donde hemos lanzado el Docker con nuestra API veremos las consultas que se realizan finalmente a nivel base de datos y comprobaremos que el problema N+1 está presente, una consulta tan simple como esta podría realizarse en una o dos consultas mientras que actualmente se ejecutan 51.

Cuanto mayor sea el número de datos a recibir más consultas se ejecutarán y más tardará en completarse la solicitud creando tiempos de espera innecesariamente largos para aquellos clientes que deseen hacer uso de los datos provistos por nuestra API.

Integrando la librería DataLoader y realizando pequeños cambios podemos reducir drásticamente el número de consultas realizadas optimizando así de forma significativa los tiempos de carga.

Para ello creamos el fichero loaders.js en la carpeta server que se encargará de gestionar el acceso a los datos por parte de DataLoader

const DataLoader = require('dataloader');
const ctrlUserProfile = require('./controllers/ctrl_user_profile');
 
const userProfileLoader = new DataLoader(userProfilesIds =&gt; {
    return ctrlUserProfile.get(userProfilesIds);
});
 
module.exports = {
    userProfileLoader
};

Haremos uso del contexto de GraphQL para pasar estos loaders a nuestros resolver. Para ello cambiamos la ruta index

// ...
router.use(
    "/api",
    graphqlExpress(req =&gt; {
        return {
            schema: schema,
            context: {req, loaders}
        }
    })
);
// ...

Adaptamos la relación entre ambas entidades instanciada en nuestro fichero resolvers para hacer uso del loader creado y pasarle los IDs de las relaciones que se van a obtener

        // ...
        User: {
            userProfile(userData, _args, {loaders}) {
                const userProfileId = userData.userProfileId &gt; 0 ? userData.userProfileId : 0;
                return loaders.userProfileLoader.load(userProfileId);
            }
       // ...
}

Y por último cambiamos el método para obtener los datos de nuestro controlador de perfiles de usuario

// ...
exports.get = (ids) =&gt; {
    return db.user_profile.findAll({
        where: {
            id: ids
        }
    }).then((userProfiles) =&gt; {
        const userProfileById = _.keyBy(userProfiles, "id");
        return ids.map(userProfileId =&gt; userProfileById[userProfileId]);
    });
};
// ...

Tras esto veremos que a la hora de solicitar los usuarios con sus perfiles se realizan dos únicas consultas lo que con una gran cantidad de datos supone una diferencia sustancial de carga tanto en recursos del servidor como en tiempo de ejecución.

La aplicación que acompaña este documento ha sido creada como ejemplo y no es válida para publicar en un entorno de producción.

Abajo puedes dejar tus comentarios

Relacionado:

By | 2018-04-15T22:33:23+00:00 abril 15th, 2018|Administrador de sistemas, Desarrollo, Informática, Web|0 Comments

About the Author:

Desarrollador de software de profesión, apasionado de la informática y todo lo relacionado con la tecnología

Leave A Comment