Retour au cours

backend / graphql

Le problème N+1 et DataLoader

Leçon 101 exercice

Explication

Ce que vous allez apprendre

  • Comprendre pourquoi un resolver de champ imbriqué naïf génère une requête DB par élément parent
  • Diagnostiquer le problème N+1 en comptant les requêtes SQL générées par une seule query GraphQL
  • Comprendre le principe du batching : regrouper N appels individuels en un seul
  • Écrire un DataLoader qui respecte le contrat strict d'ordre des résultats
  • Savoir pourquoi un DataLoader doit être recréé à chaque requête HTTP, jamais partagé globalement

Dans quel contexte ?

Une page catalogue affiche 50 produits, chacun avec le nom de sa catégorie. Un développeur backend implémente le resolver categorie sur Produit de la façon la plus naturelle : une requête SQL SELECT * FROM categories WHERE id = ... à chaque appel. Le jour où le trafic augmente, le monitoring de la base de données explose : chaque requête GraphQL sur cette page génère 51 requêtes SQL au lieu de 2.

D'abord, comprendre pourquoi ça arrive naturellement

GraphQL résout chaque champ indépendamment, niveau par niveau. Pour une liste de 50 produits, le resolver categorie est appelé 50 fois, une fois par produit — c'est exactement le comportement attendu et documenté à la leçon sur les resolvers. Le problème n'est pas GraphQL en lui-même, mais l'implémentation naïve : chaque appel individuel déclenche sa propre requête base de données, sans qu'aucun des 50 appels ne "sache" que les 49 autres existent au même moment.

Une fois ce diagnostic posé : 1 requête pour les produits, + N requêtes pour leurs catégories, d'où le nom "N+1"

Avec 50 produits, cela fait 51 requêtes SQL au lieu de 2 (une pour les produits, une seule pour toutes leurs catégories regroupées). Sur une page avec plusieurs relations imbriquées, ce facteur multiplicatif peut très vite dégrader gravement les performances, sans que le code source semble pourtant fautif à première vue.

Il reste à comprendre l'idée de la solution : le batching

Un DataLoader intercepte tous les appels .load(id) faits pendant le même "tick" d'exécution (concrètement, avant que la boucle d'événements ne redonne la main), les regroupe, et lance un seul appel base de données avec la liste complète des identifiants demandés. Les 50 appels individuels à categorie deviennent ainsi un seul SELECT ... WHERE id = ANY(ids).

ApprocheNombre de requêtes SQL pour 50 produitsComplexité d'implémentation
Resolver naïf (1 requête par élément)51 (1 + 50)Très simple, mais ne passe pas à l'échelle
DataLoader (batch automatique)2 (1 + 1 groupée)Un peu plus de code, indispensable en production

Ensuite, une contrainte stricte à respecter absolument

Le batch loader reçoit une liste d'identifiants et DOIT retourner ses résultats dans exactement le même ordre, avec null aux positions où l'identifiant demandé n'a pas de correspondance en base. DataLoader associe chaque position du tableau d'entrée à la même position en sortie ; inverser cet ordre corromprait silencieusement les données retournées à d'autres produits.

Piège fréquent

Créer un DataLoader en singleton global (par exemple au niveau du module, partagé par toute l'application) provoque une fuite de cache entre utilisateurs différents : les données mises en cache pour la requête d'un utilisateur A pourraient être servies à un utilisateur B qui n'a pas les mêmes droits d'accès. Un DataLoader doit toujours être instancié dans le context, donc recréé à chaque requête HTTP.

Bonne pratique

Applique le pattern DataLoader systématiquement dès qu'un resolver de champ imbriqué fait une requête individuelle sur une collection potentiellement grande — même si le problème ne se voit pas encore en développement avec 3 produits de test. Le N+1 est un problème qui n'apparaît souvent qu'en production, avec un volume de données réel.

Une fois les performances de lecture maîtrisées avec DataLoader, la prochaine leçon change complètement de registre : les subscriptions, qui permettent au serveur de pousser des mises à jour en temps réel vers le client, sans que celui-ci ait besoin de reposer une requête.

Commandes & code

Le problème N+1 et DataLoader

graphql
# Cette requête, naïvement implémentée, génère 1 + N requêtes SQL :
# 1 requête pour les produits, puis 1 requête PAR produit pour sa catégorie
query {
  produits(limite: 50) {
    nom
    categorie {
      nom
    }
  }
}
python
# MAUVAIS : resolver naïf -- une requête DB à chaque appel de "categorie"
@strawberry.type
class Produit:
    categorie_id: strawberry.Private[str]

    @strawberry.field
    def categorie(self) -> "Categorie":
        return db.query("SELECT * FROM categories WHERE id = %s", [self.categorie_id])
        # Appelé 50 fois pour 50 produits = 50 requêtes SQL en plus de la requête initiale
python
# BON : DataLoader regroupe (batch) les appels et déduplique automatiquement
from strawberry.dataloader import DataLoader

async def charger_categories(ids: list[str]) -> list["Categorie"]:
    # Un SEUL appel DB pour TOUS les ids demandés dans le même tick d'événement
    lignes = await db.fetch_all(
        "SELECT * FROM categories WHERE id = ANY(%s)", [ids]
    )
    par_id = {ligne["id"]: ligne for ligne in lignes}
    # L'ordre du retour DOIT correspondre exactement à l'ordre des ids demandés
    return [par_id.get(id_) for id_ in ids]

@strawberry.type
class Produit:
    categorie_id: strawberry.Private[str]

    @strawberry.field
    async def categorie(self, info: strawberry.Info) -> "Categorie":
        # info.context porte le dataloader créé une fois par requête HTTP
        return await info.context["categorie_loader"].load(self.categorie_id)

# Créer un dataloader NEUF à chaque requête HTTP (jamais partagé entre requêtes -- fuite de données)
def get_context():
    return {"categorie_loader": DataLoader(load_fn=charger_categories)}
javascript
// Équivalent Node.js avec la librairie dataloader (Apollo Server)
const DataLoader = require('dataloader');

function creerCategorieLoader(db) {
  return new DataLoader(async (ids) => {
    const lignes = await db.query(
      'SELECT * FROM categories WHERE id = ANY($1)', [ids]
    );
    const parId = new Map(lignes.map((l) => [l.id, l]));
    // Respecter l'ordre exact des ids en entrée, avec null pour les absents
    return ids.map((id) => parId.get(id) ?? null);
  });
}

const context = ({ req }) => ({
  categorieLoader: creerCategorieLoader(db),
  utilisateur: authentifier(req),
});

const resolvers = {
  Produit: {
    categorie: (parent, _args, { categorieLoader }) => categorieLoader.load(parent.categorieId),
  },
};

Résumé

  • Le N+1 apparaît dès qu'un resolver de champ imbriqué fait une requête DB par élément parent.
  • DataLoader regroupe (batch) tous les .load(id) appelés dans le même tick en UN seul appel avec tous les ids.
  • Le batch loader DOIT retourner les résultats dans le même ordre que les ids reçus (contrat strict).
  • Un DataLoader se crée par requête HTTP, jamais en singleton global (sinon fuite de cache entre utilisateurs).

Exercices pratiques

1 disponible
1

Mission : sauver le monitoring base de données d'une page catalogue à 50 produits

Objectif : Diagnostiquer précisément l'origine du problème N+1 puis le corriger avec un DataLoader respectant le contrat d'ordre strict.

Contexte

Le monitoring de la base de données déclenche une alerte : une seule requête GraphQL sur la page catalogue (50 produits, chacun avec sa catégorie) génère 51 requêtes SQL. Le resolver categorie sur Produit fait un SELECT * FROM categories WHERE id = ... à chaque appel individuel, exactement comme dans l'exemple "MAUVAIS" de la leçon.

Résoudre l’exercice →