Introduction :
La performance, toujours la performance ! C’est avec cette idée que je me suis lancé dans l’exploration du cache Symfony, même en environnement de développement, afin de bien comprendre son fonctionnement. Le cache permet d’optimiser considérablement les performances, en particulier lorsqu’il s’agit de requêtes complexes et volumineuses.
Dans un premier temps, nous allons voir le cache simple proposé par défaut par Symfony, qui offre déjà des résultats impressionnants. Puis, nous plongerons dans un niveau d’optimisation encore plus avancé : le cache de second niveau (L2) avec Doctrine.
C’est quoi le cache L2 ?
Derrière ce nom un peu barbare se cache en réalité une mécanique puissante : un second niveau de cache, conçu pour répondre à un besoin spécifique. Contrairement au cache classique (qui se limite généralement aux résultats de requêtes et aux métadonnées), le cache L2 stocke directement les entités en mémoire, réduisant ainsi le nombre d’appels à la base de données.
Là où cela devient particulièrement efficace, c’est que Doctrine permet la mise en cache complète d’une entité grâce à un simple attribut en en-tête de l’entity (merci PHP 8 !).
Imaginez un site e-commerce avec des milliers de produits : au lieu d’interroger la base de données à chaque chargement, les entités des produits fréquemment consultés sont directement servies depuis le cache. Résultat ? Des temps de réponse ultra-rapides et une charge réduite sur le serveur SQL.
Alors, prêt à booster la vitesse de chargement de votre application Symfony 7 avec Doctrine et le cache L2 ? 🚀
Les sections ( à venir ) :
- Doctrine Cache L2 avec APCu en local
- Doctrine Cache L2 avec Redis
- Doctrine Cache L2 avec Scheduler et Redis
Mise en place avec le FileSystem
Commençons par la base : le FileSystem de Symfony. Bonne nouvelle, il est inclus par défaut et pour de nombreux cas d’usage, il est largement suffisant.
L’idée est simple : stocker les entités mises en cache directement sur le disque au lieu d’aller systématiquement interroger la base de données. Cela permet d’éviter les requêtes répétitives et de gagner en performances sans avoir à configurer une solution externe comme Redis ou Memcached.
Exemple avec une entité Product
Prenons un exemple concret avec une entité Product
:
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Cache;
#[ORM\Entity]
#[Cache(usage: "READ_ONLY")]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: "integer")]
private int $id;
#[ORM\Column(type: "string", length: 255)]
private string $name;
#[ORM\Column(type: "float")]
private float $price;
}
💡 Explication :
L’attribut @Cache(usage="READ_ONLY")
signifie que cette entité est en lecture seule. Cela veut dire qu’au premier chargement, elle est stockée dans le cache FileSystem et aux chargements suivants, aucune requête SQL ne sera effectuée. Symfony ira directement récupérer les données depuis le cache.
Résultat ?
🔥 Performance boostée : plus besoin de requêtes SQL redondantes.
💾 Moins de charge serveur : le cache stocke tout en local.
🔄 Mise à jour contrôlée : en mode READ_ONLY
, les données ne changent que si on vide manuellement le cache ou si l’entité est rechargée depuis la base.
C’est tout simplement magique ✨ !
Les différents modes de cache L2
Oui, plusieurs modes de gestion du cache sont possibles en fonction des besoins de l’application. Doctrine offre trois stratégies principales :
1️⃣ READ_ONLY – Lecture seule
2️⃣ NONSTRICT_READ_WRITE – Lecture et écriture sans verrouillage strict
3️⃣ READ_WRITE – Lecture et écriture avec gestion stricte de la cohérence
Pourquoi ces différences ?
Chaque mode de cache est adapté à un besoin métier précis. Voyons cela avec des cas concrets :
🔹 Cas 1 : Mettre en cache les commentaires d’une page produit
👉 Utilisation : READ_ONLY
✅ Les commentaires ne changent pas souvent (sauf modération). On peut donc les stocker en cache sans risque d’incohérence.
#[ORM\Entity]
#[Cache(usage: "READ_ONLY")]
class Comment
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: "integer")]
private int $id;
#[ORM\Column(type: "text")]
private string $content;
}
🔹 Cas 2 : Mettre en cache une jointure de stock produit pour un « warm-up »
👉 Utilisation : NONSTRICT_READ_WRITE
✅ On veut que les données soient mises à jour régulièrement, mais on accepte un léger décalage. Idéal pour une jointure avec le stock.
#[ORM\Entity]
#[Cache(usage: "NONSTRICT_READ_WRITE")]
class Stock
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: "integer")]
private int $id;
#[ORM\Column(type: "integer")]
private int $quantity;
}
🔹 Cas 3 : Mettre en cache une entité sur le back-office avec beaucoup de jointures
👉 Utilisation : READ_WRITE
✅ On veut éviter tout risque d’incohérence, donc on verrouille strictement les données en cache.
#[ORM\Entity]
#[Cache(usage: "READ_WRITE")]
class Product
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: "integer")]
private int $id;
#[ORM\Column(type: "string", length: 255)]
private string $name;
#[ORM\Column(type: "float")]
private float $price;
}
Choisir le bon mode de cache
🔹 READ_ONLY → Parfait pour les données qui ne changent jamais. Ultra-performant.
🔹 NONSTRICT_READ_WRITE → Bon compromis pour éviter des requêtes inutiles tout en mettant à jour les données.
🔹 READ_WRITE → Idéal quand on veut garantir une cohérence totale des données, avec des verrous et invalidations de cache strictes.
Symfony et Doctrine offrent une grande flexibilité pour gérer le cache selon la logique métier. On peut optimiser au cas par cas :
✅ Un mode ultra-performant pour des entités statiques
✅ Un cache optimisé pour les mises à jour fréquentes
✅ Une gestion rigoureuse pour éviter toute incohérence
La clé ? Analyser son besoin et choisir la bonne stratégie de cache ! 🚀
Cache concret complexe :
Imaginons un scénario classique d’un e-commerce : on charge une page produit avec plusieurs informations essentielles :
✔ Le produit
✔ Les produits associés
✔ Les avis et la notation
✔ Le stock en temps réel
✔ Les options de catégorie (variations, tailles, couleurs, etc.)
Sans cache, cette simple page générerait plusieurs requêtes SQL coûteuses, ralentissant le site. C’est là que le cache de second niveau (L2) prend tout son sens en permettant une gestion fine des données selon leur nature et leur fréquence de mise à jour.
Stratégie de mise en cache des jointures
L’idée ? Optimiser chaque relation en choisissant le bon mode de cache pour éviter des requêtes inutiles tout en garantissant la cohérence des données.
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Cache;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
#[ORM\Entity]
#[Cache(usage: "READ_ONLY")]
class Product
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: "integer")]
private int $id;
#[ORM\Column(type: "string", length: 255)]
private string $name;
#[ORM\Column(type: "float")]
private float $price;
// 🔹 Avis et notation - cache en READ_ONLY car pas besoin d'update fréquent
#[ORM\OneToMany(targetEntity: "Review", mappedBy: "product")]
#[Cache(usage: "READ_ONLY")]
private Collection $reviews;
// 🔹 Produits associés - NONSTRICT_READ_WRITE pour éviter un cache obsolète
#[ORM\ManyToMany(targetEntity: "Product")]
#[Cache(usage: "NONSTRICT_READ_WRITE")]
private Collection $relatedProducts;
// 🔹 Stock - READ_WRITE pour toujours être à jour
#[ORM\OneToOne(targetEntity: "Stock", mappedBy: "product")]
#[Cache(usage: "READ_WRITE")]
private ?Stock $stock = null;
// 🔹 Catégories & variations - READ_ONLY car elles changent rarement
#[ORM\ManyToMany(targetEntity: "CategoryOption")]
#[Cache(usage: "READ_ONLY")]
private Collection $categoryOptions;
public function __construct()
{
$this->reviews = new ArrayCollection();
$this->relatedProducts = new ArrayCollection();
$this->categoryOptions = new ArrayCollection();
}
}
Pourquoi ce choix de cache ?
Attribut | Mode de cache L2 | Pourquoi ? |
---|---|---|
Stock | READ_WRITE | Doit être toujours exact |
Produit principal | READ_ONLY | Ne change pas souvent |
Avis / notation | READ_ONLY | Pas critique s’ils ne sont pas mis à jour en temps réel |
Produits associés | NONSTRICT_READ_WRITE | Peut évoluer, mais pas critique en instantané |
Catégories / Variations | READ_ONLY | Changements très rares |
Avantages d’avoir tout dans Product
✅ Moins de requêtes SQL → Doctrine ne va plus interroger la base à chaque appel
✅ Chargement plus rapide → Les données sont déjà en mémoire
✅ Gestion fine des mises à jour → Seules les infos critiques sont rafraîchies immédiatement
Conclusion
Comme nous venons de le voir, Doctrine et son cache L2, même en utilisant le FileSystem, permet de diviser le temps de chargement par 2.5 selon mes tests. 🔥
C’est déjà un énorme gain de performance pour une simple activation du cache !
Mais… rien n’est parfait !
Car oui, si le cache L2 accélère les chargements suivants, il faut toujours une première requête pour le remplir. Et c’est là qu’intervient le warm-up du cache, qui permet de précharger certaines requêtes avant même qu’un utilisateur n’accède à la page.
🚀 La suite ? Warm-up et Redis !
Dans un prochain test, je vais :
✔ Combiner le composant Scheduler pour exécuter des commandes en tâche de fond
✔ Utiliser Redis comme stockage de cache (plus rapide que le FileSystem)
✔ Comparer les performances entre FileSystem et Redis
💡 Pourquoi Redis ? Parce qu’il stocke tout en mémoire vive, ce qui offre une latence quasi nulle et permet un cache encore plus performant !
Cet article t’a plu ?
Si tu souhaites voir sur mon Github le code source de cette V.0 ça se passe via ce lien pour voir la mise en place du FileSystem
Laisser un commentaire