← Retour au blog
Architecture

Comprendre le problème N+1 avec GraphQL

Le problème N+1 expliqué en détail dans le contexte de GraphQL : comment une requête initiale déclenche une cascade de requêtes additionnelles via les resolvers.

📅 ✍️ Antoine Coulon
graphqln-plus-1resolverssqlperformance

Le problème N+1, ça vous dit quelque chose ? Vous en avez sans doute déjà entendu parler, en lien avec GraphQL ou avec les ORMs. C’est l’un de ces pièges qui ne se voient pas dans le code, qui passent inaperçus en développement avec un jeu de données minuscule, et qui font soudain s’écrouler les temps de réponse en production. Avant de pouvoir le résoudre, encore faut-il bien comprendre comment il se manifeste.

D’où vient le nom « N+1 »

Le problème prend racine dans un contexte de récupération de données relationnelles, où une requête initiale (le « +1 ») est suivie par une série de requêtes supplémentaires (les « N »), d’où son nom : N + 1.

Attention toutefois à ne pas réduire ce « relationnel » au seul sens « base de données ». De façon plus générale, la requête initiale permet d’accéder à un ensemble d’éléments, et chacun de ces éléments déclenche ensuite sa propre requête. Le problème peut donc tout aussi bien se manifester avec des APIs HTTP qu’avec une base de données, d’où les différentes appellations que l’on croise : N+1 Queries ou N+1 API Calls.

Autrement dit, ce n’est pas un défaut propre à une technologie : c’est un motif d’accès aux données qui réapparaît dès qu’on parcourt une collection en allant chercher, pour chaque élément, ses données associées.

GraphQL, un terrain particulièrement propice

Prenons GraphQL, un outil avec lequel le problème peut se manifester très rapidement.

GraphQL est un environnement d’exécution qui combine trois choses : un langage de requêtes spécifique (un DSL), des schémas qui définissent la structure des données accessibles, et un ensemble de resolvers, des fonctions chargées de récupérer les données pour chaque élément du schéma. C’est précisément cette dernière brique, le resolver, qui rend le N+1 si facile à provoquer sans s’en rendre compte.

Un schéma d’exemple : des livres et leurs critiques

Imaginons un système qui permet d’accéder à des livres et à leurs critiques. Le schéma GraphQL pourrait ressembler à ceci :

type Query {
  books: [Book]
}

type Book {
  id: ID!
  title: String!
  reviews: [Review]
}

type Review {
  id: ID!
  rating: Int!
}

Admettons maintenant une requête qui demande « la liste de tous les livres, et pour chaque livre, les critiques associées » :

{
  books {
    reviews {
      rating
    }
  }
}

Rien de suspect à première vue : la requête est concise, lisible, et exprime exactement le besoin. Tout le piège se cache dans la manière dont GraphQL va l’exécuter.

Les resolvers, là où tout se joue

Pour que GraphQL puisse construire la réponse, nous devons définir les resolvers qui vont récupérer les données :

const resolvers = {
  Query: {
    books: () => booksRepository.findAll(),
  },
  Book: {
    reviews: (book: Book) => reviewsRepository.findByBookId(book.id),
  },
};

Le resolver books est invoqué une seule fois, ce qui déclenche une unique requête SQL pour récupérer l’ensemble des livres :

SELECT * FROM books;

Jusqu’ici, tout va bien. C’est le « +1 ».

Le problème surgit à l’étape suivante. Pour résoudre le champ reviews de chaque livre, GraphQL invoque le resolver reviews une fois par livre présent dans le résultat. Si la première requête a remonté N livres, alors le resolver reviews est appelé N fois, ce qui génère une cascade de requêtes SQL :

SELECT * FROM reviews WHERE book_id = 1;
SELECT * FROM reviews WHERE book_id = 2;
-- ...
SELECT * FROM reviews WHERE book_id = N;

Voilà les « N » qui s’ajoutent au « +1 ». Une requête additionnelle par livre.

L’addition est salée

Concrètement, avec 50 livres en base, on exécute 50 + 1 = 51 requêtes SQL rien que pour récupérer les livres et leurs critiques. Et ce, dans un cas aussi simple que celui-ci, avec un volume de données dérisoire.

En faisant tourner un serveur, le phénomène saute aux yeux dans les logs : la requête initiale unique, immédiatement suivie d’une rafale de requêtes pratiquement identiques, ne différant que par l’identifiant du livre.

Server running at http://127.0.0.1:4000
Query > "SELECT * FROM books"
Query > "SELECT * FROM reviews WHERE book_id = 1"
Query > "SELECT * FROM reviews WHERE book_id = 2"
Query > "SELECT * FROM reviews WHERE book_id = 3"
Query > "SELECT * FROM reviews WHERE book_id = 4"
Query > "SELECT * FROM reviews WHERE book_id = 5"
Query > "SELECT * FROM reviews WHERE book_id = 6"
Query > "SELECT * FROM reviews WHERE book_id = 7"
Query > "SELECT * FROM reviews WHERE book_id = 8"
Query > "SELECT * FROM reviews WHERE book_id = 9"
Query > "SELECT * FROM reviews WHERE book_id = 10"

C’est une catastrophe à plusieurs niveaux. Chaque requête supplémentaire, c’est un aller-retour réseau vers la base, une connexion mobilisée, une latence qui s’accumule. Là où une seule requête bien pensée suffirait, on en multiplie des dizaines, et ce nombre croît linéairement avec la quantité de données. Passez à 500 livres, à 5 000, et c’est tout votre temps de réponse qui explose, sous une charge que rien ne justifie. Le pire, c’est que tout cela reste invisible tant que le jeu de données de test est minuscule : le problème ne se révèle qu’en production, au plus mauvais moment.

Conclusion

Voilà comment se manifeste le problème N+1 avec GraphQL : une requête initiale qui en déclenche N autres, mécaniquement, à cause de la façon dont les resolvers sont invoqués champ par champ, élément par élément. Ce n’est pas un bug à proprement parler (le code est correct et fait exactement ce qu’on lui demande), mais un motif d’accès aux données qu’il faut apprendre à repérer.

Il est essentiel d’être d’abord conscient de ce piège pour pouvoir adopter les bonnes solutions. La plus connue dans l’écosystème GraphQL consiste à regrouper et à mettre en cache ces appels pour réduire la cascade à une poignée de requêtes : c’est le rôle du pattern DataLoader, que j’aborde dans la suite de cette série, Résoudre le problème N+1 avec DataLoader.