Arquitectura
ArquitecturaEliminando el "problema n+1"

Eliminando el "problema n+1"

Aprendamos cómo Gato GraphQL evita por completo el "problema n+1" gracias a su propio diseño arquitectónico.

Qué es el "problema n+1"

El "problema n+1" significa, básicamente, que el número de consultas ejecutadas contra la base de datos puede llegar a ser tan grande como el número de nodos del grafo.

¿Qué significa esto? Veámoslo con un ejemplo: supongamos que queremos recuperar una lista de directores y, para cada uno de ellos, sus películas, mediante la siguiente consulta:

{
  query {
    directors(first: 10) {
      name
      films(first: 10) {
        title
      }
    }
  }
}

Para ser eficientes, esperaríamos ejecutar solo 2 consultas para recuperar los datos de la base de datos: 1 para obtener los datos de los directores, y 1 para recuperar los datos de todas las películas de todos los directores.

Sin embargo, para satisfacer esta consulta, GraphQL necesitará ejecutar "n+1" consultas contra la base de datos: 1 primero para recuperar la lista de los N directores (10 en este caso) y, después, para cada uno de los N directores, 1 consulta para recuperar su lista de películas. En nuestro caso, debemos ejecutar 1+10=11 consultas.

Este problema surge porque los resolvers de GraphQL solo manejan 1 objeto a la vez, y no todos los objetos del mismo tipo al mismo tiempo. En nuestro caso, el resolver que gestiona los objetos del tipo Query (que es el tipo raíz) será llamado una vez la primera vez para obtener la lista de todos los objetos Director y, después, el resolver que gestiona el tipo Director será llamado una vez por cada objeto Director, para recuperar su lista de películas.

Dicho de otro modo: los resolvers de GraphQL ven el árbol, no el bosque.

Este problema es en realidad peor de lo que parece a primera vista, porque el número de nodos de un grafo crece exponencialmente con el número de niveles del grafo. Entonces, el nombre "n+1" solo es válido para un grafo de 2 niveles de profundidad. Para un grafo de 3 niveles de profundidad, ¡debería llamarse el problema "N2+n+1"! Y así sucesivamente...

Por ejemplo, siguiendo nuestro ejemplo anterior, añadamos también a la consulta la lista de actores/actrices de cada película, así:

{
  query {
    directors(first: 10) {
      name
      films(first: 10) {
        title
        actors(first: 10) {
          name
        }
      }
    }
  }
}

Entonces, las consultas ejecutadas contra la base de datos son: 1 primero para recuperar la lista de los 10 directores, después 1 consulta para recuperar la lista de películas de cada director para cada uno de los 10 directores, y por último 1 consulta para recuperar cada lista de actores/actrices para cada una de las 10 películas de cada uno de los 10 directores. Esto da un total de 1+10+100=111 consultas.

Tras observar este comportamiento, el "problema n+1" puede considerarse fácilmente el mayor obstáculo de rendimiento de GraphQL: si se deja sin control, consultar grafos de unos cuantos niveles de profundidad puede volverse tan lento como para hacer que GraphQL sea prácticamente inútil.

Solución general al "problema n+1"

La solución estándar al "problema n+1" la proporcionó por primera vez la utilidad DataLoader. Su estrategia es muy sencilla: diferir la resolución de segmentos de la consulta hasta una etapa posterior, en la que todos los objetos del mismo tipo puedan resolverse juntos, en una única consulta. Esta estrategia, llamada "batching", resuelve eficazmente el problema "n+1".

Además, DataLoader cachea los objetos después de recuperarlos, de modo que si una consulta posterior necesita cargar un objeto ya cargado, puede omitir la ejecución y recuperar el objeto de la caché. Esta estrategia, denominada "caching", es sobre todo una optimización añadida sobre el "batching".

Problemas con la solución "batching/diferida"

Técnicamente hablando, no hay ningún problema con la estrategia "batching" o "diferida": simplemente funciona.

(A partir de ahora, nos referiremos a la estrategia únicamente como "diferida".)

El problema, sin embargo, es que esta estrategia es una idea tardía: el desarrollador puede implementar primero el servidor y, después, al notar lo lento que es resolver las consultas, decidirá introducir el mecanismo de diferimiento. Por lo tanto, implementar los resolvers puede implicar algunos pasos en falso, añadiendo fricción al proceso de desarrollo. Además, dado que el desarrollador debe entender cómo funciona el mecanismo "diferido", su implementación resulta más compleja de lo que podría ser de otra forma.

Este problema no reside en la propia estrategia, sino en que el servidor GraphQL ofrezca esta funcionalidad como un complemento, a pesar de que sin ella consultar puede ser tan lento como para volver GraphQL prácticamente inútil.

La solución a este problema es, por tanto, sencilla: la estrategia "diferida" no debería ser un complemento, sino estar integrada en el propio servidor GraphQL. En lugar de tener 2 estrategias de ejecución de consultas, "normal" y "diferida", solo debería haber 1, "diferida". Y el servidor GraphQL debe ejecutar el mecanismo "diferido" aunque el desarrollador implemente el resolver de la manera "normal" (en otras palabras, el servidor GraphQL se ocupa de la complejidad adicional, no el desarrollador).

Y eso es exactamente lo que hace Gato GraphQL.

Hacer que "diferido" sea la única estrategia ejecutada por el servidor GraphQL

El problema con la mayoría de servidores GraphQL es que la responsabilidad de resolver los tipos de objeto (object, union e interface) como objetos recae en los propios resolvers al procesar el nodo padre (por ejemplo: films => directors), en lugar de delegar esta tarea al motor de carga de datos.

Gato GraphQL traslada esta responsabilidad fuera del resolver y la lleva al motor de carga de datos del servidor, así:

  1. Los resolvers devuelven IDs, no objetos, al resolver una relación entre los nodos padre e hijo
  2. Dada una lista de IDs de un tipo determinado, una entidad DataLoader obtiene los objetos correspondientes de ese tipo
  3. El motor de carga de datos del servidor es el pegamento entre estas 2 partes: primero obtiene los IDs de los objetos a partir de los resolvers y, justo antes de ejecutar la consulta anidada para la relación (momento en el que habrá acumulado todos los IDs a resolver para el tipo concreto), recupera los objetos para esos IDs a través del DataLoader (que puede incluir eficientemente todos los IDs en una única consulta).

Este enfoque puede resumirse así: "Trabaja con IDs, no con objetos".

Usemos el mismo ejemplo de antes para visualizar este nuevo enfoque. La consulta de abajo recupera una lista de directores y sus películas:

{
  query {
    directors(first: 10) {
      name
      films(first: 10) {
        title
      }
    }
  }
}

Fíjate en los 2 campos a recuperar de cada director, name y films, y en lo distintos que son actualmente:

El campo name es de tipo escalar. Es inmediatamente resoluble, ya que podemos esperar que el objeto de tipo Director contenga una propiedad de tipo string llamada name, que contenga el nombre del director. Por lo tanto, una vez que tenemos el objeto Director, no es necesario ejecutar una consulta adicional para resolver esta propiedad.

El campo films, en cambio, es una lista de un tipo de objeto. Normalmente no es inmediatamente resoluble, ya que referencia una lista de objetos, de tipo Film, que todavía deben recuperarse de la base de datos mediante 1 o más consultas adicionales. Por lo tanto, el desarrollador necesitaría implementar el mecanismo "diferido" para él.

Ahora, consideremos el comportamiento distinto, y hagamos que el campo films se resuelva como una lista de IDs (en lugar de una lista de objetos). Como podemos esperar que el objeto Director contenga una propiedad llamada filmIDs que contenga los IDs de todas sus películas, de tipo array of string (asumiendo que el ID se representa como una cadena), entonces este campo también puede resolverse inmediatamente, sin tener que implementar el mecanismo "diferido".

Por último, además del ID, el resolver debe aportar una pieza de información adicional: el tipo del objeto esperado (en nuestro ejemplo, podría ser [(Film, 2), (Film, 5), (Film, 9)]). Esta información, sin embargo, es interna, se pasa al motor, y no es necesario que se emita en la respuesta a la consulta.

Implementación del enfoque adaptado en código

Veamos cómo implementa Gato GraphQL este enfoque en código PHP. El código de abajo muestra los distintos resolvers (a efectos de claridad, todo el código de abajo ha sido editado).

FieldResolvers

Los FieldResolvers reciben un objeto de un tipo concreto y resuelven sus campos. Para las relaciones, también deben indicar el tipo del objeto al que se resuelven. Este es su contrato:

interface FieldResolverInterface
{
  public function resolveValue($object, string $field, array $args = []);
  public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string;
}

Su implementación tiene este aspecto:

class PostFieldResolver implements FieldResolverInterface
{
  public function resolveValue($object, string $field, array $args = [])
  {
    $post = $object;
    switch ($field) {
      case 'title':
        return $post->title;
      case 'author':
        return $post->authorID; // This is an ID, not an object!
    }
 
    return null;
  }
 
  public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string
  {
    switch ($field) {
      case 'author':
        return UserTypeResolver::class;
    }
 
    return null;
  }
}

Fíjate en cómo, al eliminar la lógica que trata con promesas/objetos diferidos, el código que resuelve el campo author se ha vuelto muy sencillo y conciso.

TypeResolvers

Los TypeResolvers son objetos que tratan con un tipo específico: conocen el nombre del tipo y qué TypeDataLoader carga los objetos de su tipo, entre otras cosas.

El motor de carga de datos, al resolver campos, recibirá IDs de una determinada clase TypeResolver. Después, al recuperar los objetos para esos IDs, el motor de carga de datos preguntará al TypeResolver qué objeto TypeDataLoader debe utilizar para cargar esos objetos.

Su contrato se define así:

interface TypeResolverInterface
{
  public function getTypeName(): string;
  public function getTypeDataLoaderClass(): string;
}

En nuestro ejemplo, la clase UserTypeResolver define que el tipo User debe cargar sus datos mediante la clase UserTypeDataLoader:

class UserTypeResolver implements TypeResolverInterface
{
  public function getTypeName(): string
  {
    return 'User';
  }
 
  public function getTypeDataLoaderClass(): string
  {
    return UserTypeDataLoader::class;
  }
}

TypeDataLoaders

Los TypeDataLoaders reciben una lista de IDs de un tipo concreto y devuelven los objetos correspondientes de ese tipo. Este es su contrato:

interface TypeDataLoaderInterface
{
  public function getObjects(array $ids): array;
}

Recuperar usuarios se hace así:

class UserTypeDataLoader implements TypeDataLoaderInterface
{
  public function getObjects(array $ids): array
  {
    $userAPI = UserAPIFacade::getInstance();
    return $userAPI->getUsers($ids);
  }
}

Ejecutando una consulta (realmente) grande

Probemos que esta estrategia funciona. Ve al cliente GraphiQL de Gato GraphQL y ejecuta la consulta de abajo, que involucra un grafo de 10 niveles de profundidad (posts => author => posts => tags => posts => comments => author => posts => comments => author) y que no podría resolverse en un tiempo decente si se estuviera produciendo el "problema n+1".

query {
  posts(pagination:{ limit:10 }) {
    excerpt
    title
    url
    author {
      name
      url
      posts(pagination:{ limit:10 }) {
        title
        tags(pagination:{ limit:10 }) {
          slug
          url
          posts(pagination:{ limit:10 }) {
            title
            comments(pagination:{ limit:10 }) {
              content
              date
              author {
                name
                posts(pagination:{ limit:10 }) {
                  title
                  url
                  comments(pagination:{ limit:10 }) {
                    content
                    date
                    author {
                      name
                      username
                      url
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Al desplazarte por los resultados verás lo grande que es la respuesta, cuántas entidades involucra y cuántos niveles ha recuperado, y aun así se ha ejecutado con rapidez, sin ninguna dificultad.