DEV Community

Godwill Oussou
Godwill Oussou

Posted on

title: "Le problème N+1 en GraphQL — pourquoi votre API Laravel ralentit (et comment le régler avec Lighthouse)"

Vous avez branché GraphQL sur votre API Laravel. Tout fonctionne, les requêtes sont propres, vos développeurs front-end sont contents de ne demander que les champs dont ils ont besoin. Puis un jour, une liste qui contenait 10 éléments en contient 500 — et votre API qui répondait en 80ms se met à répondre en 4 secondes.

Neuf fois sur dix, la cause est la même : le problème N+1. Et la bonne nouvelle, c'est qu'il se corrige en quelques minutes une fois qu'on sait où regarder.

Reproduire le problème

Prenons un cas simple : une liste d'articles, chacun avec son auteur.

query {
  articles(first: 20) {
    data {
      titre
      auteur { nom }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Rien d'alarmant à première vue. Pourtant, si la relation auteur est résolue naïvement — un accessor PHP qui recharge la relation à chaque fois, ou un resolver custom mal écrit — voici ce qui se passe réellement côté base de données :

SELECT * FROM articles LIMIT 20;               -- 1 requête

SELECT * FROM users WHERE id = 1;              -- pour l'article 1
SELECT * FROM users WHERE id = 2;              -- pour l'article 2
SELECT * FROM users WHERE id = 3;              -- pour l'article 3
-- ... répété pour chacun des 20 articles
Enter fullscreen mode Exit fullscreen mode

21 requêtes SQL pour une seule query GraphQL. Multipliez ça par une liste de 500 éléments, ou par deux niveaux de relations imbriquées (articles → commentaires → auteurs des commentaires), et vous comprenez pourquoi votre temps de réponse explose sans qu'aucune ligne de votre code applicatif n'ait visiblement changé.

C'est le problème N+1 : une requête initiale (le "1") suivie d'une requête supplémentaire pour chaque élément de la liste (le "N").

Pourquoi ça arrive si facilement en GraphQL

En REST, on écrit généralement un with('auteur') explicite dans le contrôleur, et le problème saute aux yeux dans le code. En GraphQL, la relation est déclarée une fois dans le schéma et résolue automatiquement pour chaque requête reçue — ce qui est justement tout l'intérêt du langage. Mais ça veut dire que le risque de N+1 est structurel, pas accidentel : dès qu'un champ expose une relation sur une liste, le danger existe par défaut.

La solution : regrouper avant de résoudre

Le principe qui règle 100% des cas de N+1, quel que soit le langage ou le framework, tient en une phrase :

Ne jamais exécuter une requête à l'intérieur d'une boucle. Collecter d'abord toutes les clés nécessaires, puis résoudre en un seul appel groupé.

Avec Lighthouse (le package GraphQL le plus utilisé dans l'écosystème Laravel), les directives de relation standard implémentent déjà ce principe pour vous :

type Article {
  titre: String!
  auteur: Utilisateur! @belongsTo
}
Enter fullscreen mode Exit fullscreen mode

En interne, @belongsTo ne va pas charger l'auteur article par article. Elle collecte les 20 user_id de la page en cours d'exécution, puis exécute une seule requête groupée juste avant de construire la réponse :

SELECT * FROM articles LIMIT 20;
SELECT * FROM users WHERE id IN (1, 2, 3, ..., 20);
Enter fullscreen mode Exit fullscreen mode

2 requêtes SQL au total, quelle que soit la taille de la liste. C'est le comportement par défaut de Lighthouse dès que vous utilisez les directives de relation standard (@hasMany, @belongsTo, @belongsToMany) — aucune configuration supplémentaire n'est nécessaire.

Et pour un champ calculé qui n'est pas une relation Eloquent ?

Le vrai piège arrive quand vous exposez un champ calculé — par exemple une note moyenne, un nombre de vues, ou tout ce qui nécessite une agrégation sur une autre table. Un resolver naïf ressemblera à ça :

final class ResolveNoteMoyenne
{
    public function __invoke(Article $article, array $args)
    {
        // ❌ Une requête AVG() par article affiché dans la liste
        return Avis::where('article_id', $article->id)->avg('note');
    }
}
Enter fullscreen mode Exit fullscreen mode

Le même problème N+1 revient, simplement déplacé hors des relations Eloquent standard. La solution : implémenter vous-même le pattern de batching avec un BatchLoader :

final class NoteMoyenneLoader extends BatchLoader
{
    public function resolve(): array
    {
        $ids = array_keys($this->keys);

        // Un seul appel groupé pour tous les articles en attente
        $moyennes = Avis::whereIn('article_id', $ids)
            ->selectRaw('article_id, AVG(note) as moyenne')
            ->groupBy('article_id')
            ->pluck('moyenne', 'article_id');

        $resultats = [];
        foreach ($this->keys as $id => $_) {
            $resultats[$id] = $moyennes[$id] ?? null;
        }
        return $resultats;
    }
}
Enter fullscreen mode Exit fullscreen mode
final class ResolveNoteMoyenne
{
    public function __invoke(Article $article, array $args)
    {
        return NoteMoyenneLoader::instance()->load($article->id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Le résultat est identique à celui des directives natives : une seule requête GROUP BY pour toute la liste, peu importe qu'elle contienne 5 ou 5000 articles.

Comment détecter un N+1 avant qu'il n'atteigne la production

Trois réflexes suffisent :

  • Laravel Debugbar en développement : il affiche le nombre exact de requêtes SQL exécutées par requête GraphQL. Si ce nombre grandit avec la taille de la liste retournée, vous avez un N+1.
  • DB::listen() dans vos tests d'intégration, combiné à une assertion sur le nombre de requêtes exécutées.
  • Laravel Telescope pour une vue détaillée, avec l'origine (stack trace) de chaque requête.

La règle pratique : le nombre de requêtes SQL d'une query GraphQL ne devrait jamais dépendre de la taille de la liste qu'elle retourne. Si c'est le cas, cherchez la relation ou le champ calculé qui n'utilise pas encore le batching.

Ce n'est qu'un point de départ

Le N+1 est souvent le premier problème de performance qu'on rencontre avec GraphQL — mais ce n'est qu'un point de départ. Vient ensuite la question du cache (que mettre en cache, et surtout quand l'invalider), la limitation de la complexité des requêtes pour se protéger des abus, et le rate limiting pour ne pas se faire déborder par un client mal codé ou malveillant.

J'ai rassemblé tout ça — N+1, cache Redis, rate limiting, scaling, plus la construction complète d'une API GraphQL avec Laravel et sa consommation depuis un client Angular — dans une formation complète : [GraphQL avec Laravel].

Si cet article vous a fait gagner du temps, la formation devrait vous en faire gagner beaucoup plus.


Des questions sur le N+1 ou sur GraphQL avec Laravel ? Les commentaires sont ouverts.

Top comments (0)