Dans le premier volet de cette série, nous avons vu comment le problème N+1 se manifeste, et pourquoi GraphQL y est particulièrement exposé : une requête qui semble innocente côté client se traduit, côté serveur, par une avalanche de requêtes vers la base de données. Reste maintenant la vraie question : comment endiguer ce déluge ? La réponse tient en un pattern aussi simple qu’efficace, le DataLoader.
Une seule requête plutôt que N
L’intuition est élémentaire. Au lieu d’exécuter N requêtes supplémentaires, pourquoi ne pas en exécuter une seule ?
Côté SQL, le problème est d’ailleurs trivial à formuler. Plutôt que de marteler la base avec une requête par identifiant :
SELECT * FROM reviews WHERE book_id = 1;
SELECT * FROM reviews WHERE book_id = 2;
-- ...
SELECT * FROM reviews WHERE book_id = N;
On peut tout récupérer en une passe avec une clause IN :
SELECT * FROM reviews WHERE book_id IN (1, 2, ..., N);
Note : il existe d’autres manières de gérer ce type de requêtes en SQL (jointures, sous-requêtes, fonctions de fenêtrage), mais gardons ce cas simple pour illustrer le pattern.
D’un point de vue SQL, c’est donc presque sans effort : on obtient exactement le même résultat en une seule requête, sans pénalité de performance liée à la multiplication des allers-retours.
Le hic, c’est que dans un contexte GraphQL, on n’écrit jamais directement cette requête IN. Les resolvers sont exécutés de manière isolée et indépendante : par défaut, le resolver chargé de récupérer les avis d’un livre va être invoqué N fois, une fois pour chaque livre de la liste. Et chaque invocation, prise séparément, n’a aucune connaissance des N-1 autres. C’est précisément ce qui reproduit le problème N+1.
Tout l’enjeu devient alors le suivant : comment regrouper ces N invocations indépendantes en une seule requête, munie de tous les paramètres nécessaires ?
Le pattern DataLoader
C’est exactement ce que résout le pattern DataLoader : une solution puissante à la récupération concurrente de données. Popularisé par GraphQL, il reste tout à fait applicable en dehors de cet écosystème, partout où l’on souhaite agréger des accès aux données dispersés.
Le DataLoader repose sur deux mécanismes complémentaires : le batching et le caching.
Le batching : différer pour mieux regrouper
Le Loader a un comportement lazy. Plutôt que d’exécuter chaque requête immédiatement (eagerly), au moment précis où un resolver la demande, il met les appels en file d’attente. Au sein d’une même requête GraphQL, il collecte ainsi toutes les demandes émises par les différents resolvers, puis les regroupe et les exécute en une seule fois.
Concrètement, ce mécanisme fait passer :
- d’un modèle N invocations, 1 paramètre (une requête par livre) ;
- à un modèle 1 invocation, N paramètres (une seule requête pour tous les livres).
C’est ce renversement qui dissout le problème N+1 : les N requêtes individuelles fusionnent en une requête unique portant l’ensemble des identifiants.
Le caching : ne jamais demander deux fois la même chose
Le DataLoader embarque également un mécanisme de cache. Un Loader mémorise les résultats des appels déjà effectués, ce qui permet d’éviter les requêtes redondantes et de dédupliquer les paramètres au sein d’une même requête. Si deux resolvers réclament les avis du même livre, la donnée n’est chargée qu’une seule fois.
En pratique avec Mercurius
Voyons comment ce pattern se matérialise dans un serveur GraphQL Node.js. L’exemple ci-dessous s’appuie sur Mercurius, un adaptateur Fastify pour construire des serveurs GraphQL. Le concept de loaders y est intégré nativement et reprend directement les concepts du pattern DataLoader (à l’origine, la bibliothèque dataloader).
/**
* Mercurius est un adaptateur Fastify qui permet de créer des serveurs GraphQL.
* Le concept de `loaders` est intégré de manière native et reprend les concepts
* du pattern DataLoader (initialement librairie dataloader).
*/
const loaders: MercuriusLoaders<Context> = {
Book: {
/**
* Le loader `reviews` est invoqué une seule fois par requête. Tous les livres
* sont batchés et regroupés dans un tableau.
*/
reviews: async (queries: Array<{ obj: Review }>, context) => {
/**
* On récupère les identifiants uniques des livres batchés, ce qui nous permet
* de récupérer tous les avis en une seule requête.
* ex: 'SELECT * FROM reviews WHERE book_id IN (1, 2, 3);'
*/
const batchedBookIds = queries.map(({ obj }) => obj.id);
const reviews =
await context.dependencies.reviewsRepository.findByBookIds(
batchedBookIds
);
/**
* Pour que le loader puisse faire le lien entre les livres batchés
* et les avis récupérés par livre, il faut préserver l'ordre initial du
* batch, pour que chacun des avis soit associé au bon livre.
* ex: [ [
* book1, -> [review1, review2],
* book2, -> [review3],
* book3 -> [review4, review5, review6]
* ] ]
*/
const reviewsByBookId = groupBy(reviews, "book_id");
return batchedBookIds.map((id) => reviewsByBookId[id] ?? []);
},
},
};
Deux détails de ce code méritent d’être soulignés, car ils sont au cœur du contrat du DataLoader.
D’abord, la signature du loader. Là où un resolver classique reçoit un seul livre, le loader reçoit un tableau de requêtes (queries) : c’est la matérialisation du batching. Mercurius ne l’invoque qu’une seule fois par requête GraphQL, en lui passant l’ensemble des livres collectés. On extrait alors tous leurs identifiants (batchedBookIds) pour les transmettre d’un coup au repository, qui exécute la requête IN unique.
Ensuite, et c’est le point le plus subtil : la préservation de l’ordre. Le loader doit retourner un tableau dont chaque élément correspond, dans le même ordre, à l’entrée reçue en paramètre. Comme la base nous renvoie une liste plate d’avis, on la regroupe par book_id (ici via un groupBy), puis on reconstruit le résultat final en parcourant batchedBookIds dans l’ordre. Un livre sans aucun avis doit renvoyer un tableau vide (?? []) plutôt qu’une valeur absente : sans cette garantie d’alignement, les avis seraient associés au mauvais livre.
Conclusion
Le pattern DataLoader est une solution simple, mais redoutablement efficace, pour gérer les requêtes concurrentes et optimiser les performances dans GraphQL. En regroupant les accès aux données par batching et en éliminant les appels redondants par caching, il transforme le déferlement de N+1 requêtes en une seule requête maîtrisée.
L’essentiel à retenir : le problème N+1 n’est pas une fatalité de GraphQL, mais une conséquence de l’isolation de ses resolvers. Le DataLoader réconcilie cette isolation avec l’efficacité d’un accès groupé aux données, sans rien sacrifier de la lisibilité du schéma. Si vous ne deviez retenir qu’un seul réflexe face à un resolver de relation, ce serait celui-ci : le passer derrière un loader.