Chapitre 6.6 — Performances : le problème N+1 et compagnie
Partie 6 : Doctrine ORM — Chapitre 6/7 Version de référence : Doctrine ORM 3 / Symfony 7.4.
⏱️ TL;DR
- Le problème N+1 : charger une liste (1 requête), puis accéder à une relation paresseuse de chaque élément (N requêtes) → 1 + N requêtes au lieu d’une. Le tueur de perf n°1 avec un ORM.
- Diagnostic : le profiler affiche le nombre de requêtes SQL. 200 requêtes pour afficher 100 lignes = N+1.
- Correction : un fetch join (
JOIN+addSelect) charge la relation en une seule requête.- Index : ajoutez des index sur les clés étrangères et les colonnes filtrées/triées souvent (
#[ORM\Index]).- Gros volumes : traiter par lots (
toIterable()+clear()), lecture en DTO (chapitre 6.3), mode read-only.- Pour un dev Next.js : le N+1 est le même piège qu’avec Prisma quand on boucle des
includemanquants ; le fetch join ≈ uninclude/selectbien placé.
🎯 Objectifs
- Reconnaître et diagnostiquer un N+1.
- Le corriger avec un fetch join.
- Ajouter des index pertinents.
- Traiter de gros volumes sans exploser la mémoire.
1. Le problème N+1, en action
Rappel (chapitre 6.2) : les relations sont paresseuses par défaut. Regardez ce code apparemment innocent :
$bookings = $bookingRepository->findAll(); // 1 requête : SELECT * FROM bookings
foreach ($bookings as $booking) {
echo $booking->getSlot()->getService()->getName(); // ← 1 requête PAR booking !
}Pour 100 réservations : 1 requête pour la liste + 100 requêtes pour charger chaque Slot (puis potentiellement 100 de plus pour chaque Service). C’est le N+1 : la performance s’effondre à mesure que les données grandissent.
2. Diagnostiquer avec le profiler
En dev, la barre de debug affiche le nombre de requêtes SQL de la page (icône base de données). Un nombre qui croît avec le volume de données affichées est le signe d’un N+1. L’onglet Doctrine du profiler liste chaque requête, avec les doublons — vous voyez les 100 SELECT ... FROM slots WHERE id = ? identiques.
⚠️ Piège : le N+1 est invisible en dev avec peu de données (2 réservations = 3 requêtes, « ça va »). Il explose en prod avec des milliers de lignes. Prenez l’habitude de regarder le compteur de requêtes du profiler sur vos listes, même quand « ça marche ».
3. La correction : le fetch join
La solution est de charger la relation en même temps que la liste, via un JOIN avec addSelect (pour hydrater aussi l’entité jointe) :
public function findAllWithSlotAndService(): array
{
return $this->createQueryBuilder('b')
->addSelect('s', 'svc') // hydrate AUSSI slot et service
->join('b.slot', 's') // JOIN vers Slot
->join('s.service', 'svc') // JOIN vers Service
->getQuery()
->getResult();
}Désormais, une seule requête ramène les réservations avec leurs slots et services. La boucle qui accède à $booking->getSlot()->getService() ne déclenche plus aucune requête supplémentaire. De 101 requêtes à 1.
⚠️ Piège :
join()sansaddSelect()filtre mais n’hydrate pas la relation — le N+1 persiste ! C’estaddSelect('s')qui charge réellement les slots dans les objets. Retenez : JOIN pour filtrer, JOIN + addSelect pour charger et tuer le N+1.
💡 Pour un dev Next.js : le fetch join ≈ un
includePrisma (prisma.booking.findMany({ include: { slot: { include: { service: true } } } })). Le N+1, c’est exactement l’oubli de cetincludesuivi d’un accès en boucle. Même problème, même solution : charger ce dont on a besoin en une requête.
4. Fetch EAGER vs LAZY
On peut forcer une relation à se charger toujours avec le parent (fetch: 'EAGER'), mais c’est rarement une bonne idée globale (on charge trop). La bonne pratique est de rester LAZY par défaut et de faire des fetch joins ciblés là où on en a besoin (dans la requête concernée). Vous contrôlez ainsi le chargement par cas d’usage, pas globalement.
5. Les index
Une requête qui filtre/trie sur une colonne non indexée fait un scan complet de la table. Ajoutez des index :
#[ORM\Entity]
#[ORM\Index(name: 'idx_booking_status', columns: ['status'])]
#[ORM\Index(name: 'idx_booking_start', columns: ['start'])]
class Booking { /* ... */ }Où indexer :
- les clés étrangères (souvent indexées automatiquement, mais vérifiez) ;
- les colonnes fréquemment en
WHERE,ORDER BY,JOIN; - les colonnes
unique(index unique, ex.slug,email).
N’indexez pas tout : chaque index ralentit les écritures et occupe de l’espace. Indexez selon vos requêtes réelles (que le profiler révèle). Un index s’ajoute via une migration (chapitre 6.4).
6. Gros volumes : traiter par lots
Charger 100 000 entités d’un coup sature la mémoire. Deux techniques :
- Itération en flux avec
toIterable()(générateur, mémoire constante) :
$q = $repository->createQueryBuilder('b')->getQuery();
foreach ($q->toIterable() as $i => $booking) {
// traiter $booking
if ($i % 100 === 0) {
$this->em->flush();
$this->em->clear(); // détache les entités déjà traitées → libère la mémoire
}
}
$this->em->flush();- Lecture seule / projection DTO (chapitre 6.3) quand on n’écrit pas :
SELECT NEW App\Dto\...évite le suivi Unit of Work et l’hydratation d’entités complètes.
🐘 Rappel PHP 8+ :
toIterable()renvoie un générateur (yield, chapitre 2.7). C’est le même bénéfice mémoire que pour la lecture de fichiers : on ne garde en mémoire qu’un objet à la fois, pas les 100 000.
7. Cache en production
En prod, on met en cache les métadonnées de mapping et le plan de requêtes (via APCu/OPcache) pour éviter de re-parser les attributs à chaque requête. Doctrine propose aussi un cache de second niveau (mise en cache des entités elles-mêmes) pour des données peu changeantes — un sujet avancé (Partie 15). Pour l’essentiel, N+1 + index règlent 90 % des problèmes de perf.
✏️ Exercices
Exercice 1. Cette page affiche 50 prestataires et, pour chacun, le nombre de services (via $provider->getServices()). Le profiler montre 51 requêtes. Diagnostic et correction ?
✅ Solution
Diagnostic : N+1 — 1 requête pour les prestataires + 1 par prestataire pour charger sa collection services. Correction : fetch join dans la requête —
$this->createQueryBuilder('p')
->addSelect('s')
->leftJoin('p.services', 's')
->getQuery()->getResult();leftJoin + addSelect('s') charge les services avec les prestataires en une requête. (Si on ne veut que le nombre, une requête COUNT groupée est encore plus efficace.)
Exercice 2. Pourquoi ->join('b.slot', 's') sans addSelect('s') ne corrige-t-il pas toujours le N+1 ?
✅ Solution
join seul sert à filtrer (utiliser la relation dans un WHERE) mais n’hydrate pas l’entité jointe : les Slot ne sont pas chargés dans les objets Booking. Accéder ensuite à $booking->getSlot() re-déclenche une requête → le N+1 subsiste. Il faut addSelect('s') pour que les slots soient réellement chargés en même temps.
Exercice 3. Vous devez traiter 500 000 réservations pour recalculer un champ. Comment éviter de saturer la mémoire ?
✅ Solution
Itérer en flux avec toIterable() (générateur → un objet en mémoire à la fois), et flusher + clear() par lots (ex. tous les 100/500) pour détacher les entités déjà traitées et libérer la mémoire. Éventuellement, déléguer à Messenger pour paralléliser (Partie 12).
🧠 Quiz de révision
1. Qu’est-ce que le problème N+1 ?
Charger une liste (1 requête) puis accéder à une relation paresseuse de chaque élément (N requêtes) → 1 + N requêtes. Le principal problème de perf avec un ORM.
2. Comment le diagnostiquer ?
Via le profiler (compteur de requêtes SQL / onglet Doctrine) : un nombre de requêtes qui croît avec le volume affiché, avec des SELECT identiques répétés.
3. Comment le corriger ?
Par un fetch join : ->join('b.slot', 's')->addSelect('s') charge la relation en une seule requête. Le addSelect est indispensable.
4. Sur quelles colonnes ajouter des index ?
Les clés étrangères et les colonnes souvent en WHERE/ORDER BY/JOIN, ainsi que les colonnes unique. Pas tout (les index ralentissent les écritures).
5. Comment traiter 500 000 entités sans saturer la mémoire ?
Itérer avec toIterable() (générateur) et flusher + clear() par lots pour détacher les entités traitées.
Prochain chapitre : 6.7 — Recherche avec Meilisearch — indexer les entités pour une recherche instantanée et tolérante aux fautes.