Command Palette

Search for a command to run...

Blog
PreviousNext

Le piège des requêtes N+1 : pourquoi ton backend rame (et comment le corriger)

Ton API met 3 secondes à répondre ? Tu fais peut-être des centaines de requêtes sans le savoir. Explication simple du problème N+1 en C# avec Entity Framework, et comment le résoudre.

Déjà, pourquoi j'écris cet article

Je suis étudiant ingénieur à EPITA et je bosse en alternance chez Silogis en tant que Full Stack Engineer. Au quotidien, je touche autant au frontend qu'au backend. Et le backend, c'est un monde où un détail qui a l'air anodin peut transformer une app rapide en escargot.

Le problème N+1, je suis tombé dedans. Plusieurs fois. Sur de vrais projets, avec de vrais utilisateurs qui attendaient. Donc plutôt que de garder ça pour moi, j'ai voulu écrire l'article que j'aurais aimé lire quand j'ai découvert le sujet.

L'analogie du restaurant

Avant de parler de code, imagine une situation.

T'es serveur dans un restaurant. Un client commande une pizza. Tu vas en cuisine, tu reviens avec la pizza. Easy.

Maintenant, une table de 10 personnes commande. Tu vas en cuisine, tu prends une seule pizza, tu reviens. Puis tu retournes en cuisine pour la deuxième. Puis la troisième. Dix allers-retours pour dix pizzas.

C'est absurde, non ? Tu ferais un seul voyage et tu ramènerais les dix pizzas d'un coup.

Et pourtant, c'est exactement ce que fait ton code quand il tombe dans le piège du N+1.

Concrètement, ça donne quoi ?

T'as une API qui renvoie la liste des restaurants avec leurs plats. Deux tables en base de données : Restaurants et Plats.

Voici le code qui a l'air correct mais qui pose problème :

var restaurants = await db.Restaurants.ToListAsync();
 
foreach (var restaurant in restaurants)
{
    var plats = await db.Plats
        .Where(p => p.RestaurantId == restaurant.Id)
        .ToListAsync();
 
    restaurant.Plats = plats;
}

Pour chaque restaurant, on retourne demander ses plats à la base. 100 restaurants = 101 requêtes SQL. D'où le nom : N+1.

L'impact réel

Sur ta machine en local, avec 10 restaurants, tu vois rien. Ça répond en 50ms. Tout roule.

En production, avec 500 restaurants :

ApprocheRequêtesTemps réseau seul
N+1501~1 seconde
Optimisé1~2 ms

Et ça, c'est uniquement le temps réseau. On compte même pas le temps d'exécution des requêtes, le pool de connexions qui sature, et les autres utilisateurs qui patientent derrière.

Le N+1 est vicieux parce qu'il marche parfaitement en dev et explose en production. C'est le genre de truc où tu te dis "ça tourne nickel" et trois mois plus tard, le client t'appelle parce que l'app est devenue inutilisable.

La solution : tout charger d'un coup

Entity Framework (l'ORM qu'on utilise en C# pour parler à la base de données) propose Include pour charger les relations en une seule requête :

var restaurants = await db.Restaurants
    .Include(r => r.Plats)
    .ToListAsync();

C'est l'équivalent de ramener les 10 pizzas en un seul voyage. Entity Framework génère un JOIN SQL et récupère tout d'un coup.

Et quand t'as pas besoin de tout, tu peux projeter uniquement ce qui t'interesse :

var result = await db.Restaurants
    .Select(r => new
    {
        r.Nom,
        r.Adresse,
        NombreDePlats = r.Plats.Count(),
        PlatPopulaire = r.Plats
            .OrderByDescending(p => p.NombreDeCommandes)
            .Select(p => p.Nom)
            .FirstOrDefault()
    })
    .ToListAsync();

Une seule requête, uniquement les données utiles. C'est propre.

Les variantes du piège

Le N+1 ne se cache pas toujours dans un foreach évident. Voici les formes les plus courantes :

SituationCe qui se passeLa solution
Boucle avec appel DBChaque itération fait une requêteCharger tous les IDs d'un coup avec Where + Contains
Lazy loading actifAccéder à .Plats déclenche une requête invisibleDésactiver le lazy loading, utiliser Include
Appel caché dans un serviceGetClientById() dans une boucleCréer une méthode GetClientsByIds() qui charge en lot
Sérialisation JSONLe sérialiseur accède aux relations et déclenche des requêtesProjeter avec Select avant de sérialiser

Le point commun : à chaque fois, le code a l'air clean. Il compile, il renvoie les bonnes données. Mais derrière, c'est le chaos.

Comment le détecter ?

La méthode la plus simple : activer les logs SQL d'Entity Framework en dev. Si tu vois la même requête se répéter 50 fois avec des paramètres différents dans ta console, t'as un N+1. Garanti.

L'autre réflexe, plus systématique : après chaque endpoint, se poser la question :

Est-ce que je fais une requête dans une boucle ?

Si oui, il y a probablement un moyen de tout charger en une seule requête.

Ce que je retiens

Le N+1 c'est pas un bug. C'est un réflexe à changer. Au lieu de raisonner objet par objet, raisonne par lot. C'est contre intuitif au début, mais une fois que t'as le déclic, tu le vois partout.

Et c'est ça qui fait la différence entre un backend qui tient 10 utilisateurs et un backend qui en tient 10 000.

CV