11 - Le problème N+1 : le détecter et le corriger
Ce que tu vas apprendre
- Ce qu'est le N+1 et pourquoi les ORM le provoquent
- Comment le détecter (logs, pg_stat_statements)
- 3 stratégies de correction : JOIN, eager loading, DataLoader
- Les cas ou le N+1 est acceptable
Prerequisites
Avoir lu les articles sur les JOINs et l'introduction ou j'ai mentionne le problème.
Le problème explique
Tu as une page qui affiche 50 commandes avec les produits de chaque commande. Avec un ORM en mode lazy loading, voila ce qui se passe :
sql-- Requete 1 : recuperer les 50 commandes
SELECT * FROM orders LIMIT 50;
-- Puis, pour CHAQUE commande, une requete supplementaire :
SELECT * FROM order_items WHERE order_id = 1;
SELECT * FROM order_items WHERE order_id = 2;
SELECT * FROM order_items WHERE order_id = 3;
-- ... 47 de plus ...
SELECT * FROM order_items WHERE order_id = 50;
Total : 51 requêtes. Pour une seule page. D'ou le nom N+1 : 1 requête pour la liste, puis N requêtes pour les détails.
Chaque requête individuelle est rapide (quelques millisecondes). Mais 51 requêtes, ca fait 51 allers-retours réseau entre ton application et PostgreSQL. Meme avec un serveur local, le coût s'accumule. Sur un VPS avec la base sur un autre serveur, chaque aller-retour ajoute 1-5ms de latence réseau. 51 requêtes x 3ms = 150ms rien que pour le réseau. Avec des relations imbriquees, ca explose vite.
Comment les ORM provoquent le N+1
Prisma en mode lazy :
typescript// 1 requete
const orders = await prisma.order.findMany({ take: 50 });
// Puis dans le template ou la serialisation...
for (const order of orders) {
// Chaque acces a .items declenche une requete separee
const items = await prisma.orderItem.findMany({
where: { orderId: order.id }
});
}
Drizzle avec des relations separees :
typescriptconst orders = await db.select().from(ordersTable).limit(50);
for (const order of orders) {
const items = await db.select().from(orderItemsTable)
.where(eq(orderItemsTable.orderId, order.id));
}
Le code a l'air propre. C'est ca le piège. Tu ne vois pas les 51 requêtes. Il faut activer le logging pour les compter.
Détecter le N+1
En développement : logs SQL
Avec Prisma, active le logging des requêtes :
typescriptconst prisma = new PrismaClient({
log: ['query'],
});
Avec Drizzle :
typescriptconst db = drizzle(pool, { logger: true });
Charge ta page, regarde la console. Si tu vois 50 fois la meme requête avec un paramètre différent, c'est un N+1.
En production : pg_stat_statements
sql-- Active l'extension (une fois)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Trouve les requetes les plus frequentes
SELECT
query,
calls,
mean_exec_time::NUMERIC(10,2) AS avg_ms,
total_exec_time::NUMERIC(10,2) AS total_ms
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 20;
Si tu vois une requête SELECT * FROM order_items WHERE order_id = $1 appelee 50000 fois par jour alors que tu as 1000 visiteurs, c'est un N+1. Le ratio entre les appels de la requête de liste et de la requête de détail te donne le multiplicateur.
Correction 1 : JOIN
La solution la plus directe. Une seule requête :
sqlSELECT o.id AS order_id, o.status, o.total,
oi.product_name, oi.quantity, oi.unit_price
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
ORDER BY o.created_at DESC
LIMIT 50;
order_id | status | total | product_name | quantity | unit_price
----------+-----------+--------+--------------+----------+------------
1 | completed | 150.00 | Clavier | 1 | 89.99
1 | completed | 150.00 | Souris | 1 | 59.99
3 | completed | 230.00 | Ecran | 1 | 349.00
Le problème avec le JOIN : les colonnes de orders sont dupliquees pour chaque item. Si une commande a 10 items, les donnees de la commande apparaissent 10 fois. C'est du trafic réseau gaspille. Et cote application, tu dois "regrouper" les lignes pour reconstruire la structure commande -> items.
C'est quand meme la meilleure solution pour la majorite des cas. Le coût du regroupement cote appli est negligeable compare a 51 allers-retours SQL.
Correction 2 : eager loading (ORM)
C'est la facon idiomatique de corriger le N+1 avec un ORM. En Prisma :
typescriptconst orders = await prisma.order.findMany({
take: 50,
include: {
items: true, // charge les items en meme temps
},
orderBy: { createdAt: 'desc' },
});
Prisma généré deux requêtes (pas 51) :
sqlSELECT * FROM orders ORDER BY created_at DESC LIMIT 50;
SELECT * FROM order_items WHERE order_id IN (1, 2, 3, ..., 50);
En Drizzle :
typescriptconst orders = await db.query.orders.findMany({
limit: 50,
with: {
items: true,
},
orderBy: [desc(ordersTable.createdAt)],
});
Deux requêtes au lieu de 51. L'ORM fait le regroupement pour toi. C'est la correction la plus simple et souvent suffisante.
Correction 3 : DataLoader (batch)
Le pattern DataLoader (popularise par Facebook/Meta pour GraphQL) fonctionne comme le eager loading mais de facon transparente. Au lieu de charger les relations a l'avance, il collecte les IDs pendant l'exécution et fait une seule requête batch.
Le principe en pseudo-code :
typescript// Au lieu de faire N requetes individuelles...
// Le DataLoader collecte tous les IDs demandes dans le meme tick
// puis fait UNE requete : SELECT * FROM order_items WHERE order_id IN (...)
const itemLoader = new DataLoader(async (orderIds: number[]) => {
const items = await db.query(
'SELECT * FROM order_items WHERE order_id = ANY($1)',
[orderIds]
);
// Regrouper par order_id et retourner dans le meme ordre que les IDs
return orderIds.map(id => items.filter(item => item.order_id === id));
});
// Utilisation (le batching est automatique)
const items = await itemLoader.load(orderId);
La version SQL de ce que fait le DataLoader :
sql-- Au lieu de :
SELECT * FROM order_items WHERE order_id = 1;
SELECT * FROM order_items WHERE order_id = 2;
-- ...
SELECT * FROM order_items WHERE order_id = 50;
-- Le DataLoader fait :
SELECT * FROM order_items WHERE order_id = ANY(ARRAY[1, 2, 3, ..., 50]);
Le DataLoader est surtout utile dans les API GraphQL ou les relations sont resolues de facon dynamique selon la requête du client. Pour les API REST classiques, le eager loading suffit.
Comparaison des approches
| Approche | Nb requêtes | Complexite | Cas d'usage |
|---|---|---|---|
| N+1 (le problème) | N + 1 | Nulle | Jamais acceptable en prod |
| JOIN | 1 | Faible | API REST, rapports |
| Eager loading | 2 | Faible | ORM, cas standard |
| DataLoader | 2 | Moyenne | GraphQL, resolvers dynamiques |
Le N+1 est-il parfois acceptable ?
Oui, dans de rares cas :
- N est petit et fixe (afficher les 3 derniers articles avec leurs tags)
- Les requêtes individuelles sont cachees (Redis, cache applicatif)
- Le code est un script ponctuel, pas une API en production
Pour une API en production avec des utilisateurs réels, non. Le N+1 est le problème de performance numero un des applications backend. J'ai vu des pages passer de 4 secondes a 200ms juste en corrigeant un N+1.
Sur paltemps.fr, chaque endpoint API est vérifié avec pg_stat_statements avant la mise en production. Si le nombre de requêtes par endpoint dépassé ce qu'on attend, on investigue.
Résumé
- Le N+1 : 1 requête de liste + N requêtes de détail = lenteur
- Détection : activer le logging SQL, compter les requêtes par page
- Correction : JOIN (1 requête), eager loading (2 requêtes), DataLoader (2 requêtes, pour GraphQL)
- pg_stat_statements en production pour trouver les requêtes trop frequentes
- Le N+1 est le problème de performance numero un des backends avec ORM
Article précédent : 10 - JSON et JSONB Article suivant : 12 - Migrations