Aller au contenu principal
Retour au blog

Second Level Cache Doctrine ORM : configuration Symfony 7.4

Flavien Métivier22 décembre 20258 min

Ton app Symfony tient la charge, mais la base de données subit les mêmes lectures répétitives en boucle : catalogues produit, référentiels métier, agrégats consultés des dizaines de fois par seconde sans jamais changer. Le First Level Cache (identity map par requête HTTP) ne suffit pas. Le query cache allège les plans d'exécution, mais provoque toujours un aller-retour réseau vers la base. Le Second Level Cache (SLC) de Doctrine ORM persiste les entités hydratées entre plusieurs requêtes HTTP — et peut réduire de 80 % le nombre de SELECT sur des lectures stables. Pourtant, il est absent de la quasi-totalité des projets Symfony en production. Ce guide couvre la configuration complète sur Symfony 7.4 LTS + Doctrine ORM 3.x : les trois stratégies de cohérence, le découpage en régions, l'invalidation correcte après écriture, et les pièges qui font tourner le cache sans qu'il cache grand-chose.

Ce que le Second Level Cache fait concrètement

Le SLC est un cache partagé entre tous les EntityManager — et donc entre toutes les requêtes HTTP. Quand Doctrine doit hydrater Product#42, il consulte d'abord le SLC. Si l'entrée existe, aucun SQL n'est émis : le graphe d'objets est reconstruit depuis le cache. Il opère à trois niveaux : entités individuelles (lookup par identifiant), collections (résultats d'associations OneToMany/ManyToMany), et résultats de requêtes (via ->setCacheable(true) sur un QueryBuilder). Ces trois niveaux sont configurables indépendamment par région, ce qui permet un contrôle fin de la cohérence et du TTL par agrégat métier.

Configuration avec Symfony Cache et Redis

Symfony 7.4 expose un système de pools PSR-6 que Doctrine ORM 3.x consomme nativement. Créer un pool Redis dédié évite que les évictions LRU du cache applicatif ne vident les données Doctrine. Il suffit ensuite de le brancher dans la configuration ORM.

# config/packages/cache.yaml
framework:
    cache:
        pools:
            cache.doctrine.slc:
                adapter: cache.adapter.redis
                provider: '%env(REDIS_URL)%'
                default_lifetime: 3600
                tags: true  # nécessaire pour l'invalidation par tag
# config/packages/doctrine.yaml
doctrine:
    orm:
        second_level_cache:
            enabled: true
            log_enabled: '%kernel.debug%'
            region_cache_driver:
                type: pool
                pool: cache.doctrine.slc
            regions:
                product_region:
                    lifetime: 7200
                    type: READ_ONLY
                catalog_region:
                    lifetime: 900
                    type: NONSTRICT_READ_WRITE
                order_region:
                    lifetime: 300
                    type: READ_WRITE

La variable REDIS_URL suit le format DSN Symfony : redis://user:pass@redis:6379/0. En production avec Redis Sentinel ou Cluster, préfère redis+sentinel://... — le pool Symfony gère la reconnexion de manière transparente.

Les trois stratégies et quand choisir laquelle

Le choix de la stratégie n'est pas cosmétique : il détermine le niveau de verrouillage et la cohérence maximale atteignable. Doctrine en propose trois, chacune adaptée à un profil de lecture/écriture différent.

  • READ_ONLY : zéro verrou, zéro synchronisation. Réservé aux entités jamais modifiées après insertion (référentiels, pays, devises, taxonomies). C'est la stratégie la plus rapide. Si tu tentes un flush() sur une entité READ_ONLY, Doctrine lève une exception.
  • NONSTRICT_READ_WRITE : les mises à jour invalident la clé après le flush, mais sans verrou entre la lecture et l'écriture. Une courte fenêtre de données légèrement périmées reste possible. Convient aux catalogues produit, aux prix et aux contenus éditoriaux où une lecture « presque fraîche » est acceptable.
  • READ_WRITE : verrou optimiste via un timestamp stocké avec l'entrée. Garantit qu'aucun lecteur ne voit une entité en cours de modification. Coût supplémentaire en I/O Redis. À réserver aux agrégats modifiés concurremment : panier, stock en temps réel.

Règle pratique : commence par READ_ONLY sur tout ce qui ne change qu'en back-office, NONSTRICT_READ_WRITE sur le reste du catalogue, et ne dégaine READ_WRITE que si des tests de charge révèlent des incohérences réelles.

Annoter les entités et leurs associations

L'activation se fait entité par entité via l'attribut #[ORM\Cache]. Si une association n'est pas annotée, Doctrine l'interrogera toujours en SQL même si l'entité racine est servie depuis le cache — c'est le piège numéro un.

<?php
// src/Entity/Product.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: ProductRepository::class)]
#[ORM\Cache(usage: 'READ_ONLY', region: 'product_region')]
class Product
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private string $name;

    // L'association DOIT aussi être annotée
    #[ORM\ManyToOne(targetEntity: Category::class)]
    #[ORM\JoinColumn(nullable: false)]
    #[ORM\Cache(usage: 'READ_ONLY', region: 'catalog_region')]
    private Category $category;

    // Collection OneToMany
    #[ORM\OneToMany(mappedBy: 'product', targetEntity: ProductImage::class)]
    #[ORM\Cache(usage: 'READ_ONLY', region: 'product_region')]
    private Collection $images;
}
<?php
// Activer le cache sur une requête DQL/QueryBuilder
$qb = $this->getEntityManager()->createQueryBuilder();
$products = $qb
    ->select('p', 'c')
    ->from(Product::class, 'p')
    ->join('p.category', 'c')
    ->where('p.active = :active')
    ->setParameter('active', true)
    ->setCacheable(true)         // active le query result cache
    ->setCacheRegion('product_region')
    ->getQuery()
    ->getResult();

Besoin d'un expert Symfony ?

Réserver un appel

Découper en régions par agrégat métier

Regrouper toutes les entités dans une région unique est tentant, mais contre-productif : un flush sur un Order viderait le cache des Product. La règle DDD s'applique directement : une région par agrégat ou par bounded context. Voici une organisation typique pour un e-commerce Symfony :

  • catalog_region — Product, Category, Brand — TTL 7200 s, READ_ONLY ou NONSTRICT_READ_WRITE
  • pricing_region — Price, Discount, TaxRate — TTL 900 s, NONSTRICT_READ_WRITE
  • geo_region — Country, Currency, ShippingZone — TTL 86400 s, READ_ONLY
  • order_region — Order, OrderLine — TTL 300 s, READ_WRITE
  • user_region — Address, PaymentMethod — TTL 600 s, READ_WRITE

Chaque région dispose de son propre objet CacheItemPoolInterface via la configuration Symfony. Pour aller plus loin, configure un pool Redis avec un index de base différent par région : tu isoleras les namespaces et pourras surveiller la consommation mémoire région par région avec redis-cli info keyspace.

Invalider proprement après une écriture

En NONSTRICT_READ_WRITE et READ_WRITE, Doctrine invalide automatiquement l'entrée de l'entité modifiée après le flush(). En revanche, les bulk updates DQL (UPDATE product SET ... WHERE ...) contournent ce mécanisme : Doctrine ne connaît pas les identifiants concernés et n'invalide rien. Même constat avec des commandes CQRS passant par DBAL directement. Dans ces cas, l'invalidation est manuelle :

<?php
// src/Service/ProductUpdater.php
namespace App\Service;

use Doctrine\ORM\EntityManagerInterface;

class ProductUpdater
{
    public function __construct(
        private readonly EntityManagerInterface $em,
    ) {}

    public function bulkMarkAsUnavailable(array $productIds): void
    {
        // Mise à jour DQL — Doctrine ne sait pas quelles entités sont touchées
        $this->em->createQuery(
            'UPDATE App\\Entity\\Product p SET p.available = false WHERE p.id IN (:ids)'
        )
        ->setParameter('ids', $productIds)
        ->execute();

        // Invalidation explicite par identifiant
        $cache = $this->em->getCache();
        foreach ($productIds as $id) {
            $cache->evictEntity(Product::class, $id);
        }

        // Ou vider toute la région si la liste est grande
        // $cache->evictEntityRegion(Product::class);
    }
}

Pour les listeners Doctrine, positionne l'invalidation sur l'événement postFlush — jamais sur preFlush. À l'étape pre, la transaction n'est pas encore commitée : une invalidation prématurée ouvre une fenêtre où le cache est vide mais la donnée pas encore écrite. Les listeners onFlush sont également risqués si tu appelles flush() à l'intérieur : tu génères une boucle infinie.

Les pièges qui neutralisent le cache

  • TTL trop court + trafic élevé = thundering herd. Si 200 requêtes arrivent au même instant à l'expiration du TTL, toutes trouvent le cache vide et frappent la BDD simultanément. Solution : utilise le probabilistic early expiration via le composant Lock de Symfony pour qu'une seule requête recharge, les autres attendant.
  • Entité partiellement chargée. Si tu utilises select('p.id, p.name') au lieu de select('p'), Doctrine retourne un tableau scalaire et non une entité managée — le SLC ne s'applique pas. Idem pour les proxies non initialisés sérialisés en cache.
  • Associations manquantes dans le schéma de cache. Annoter l'entité racine sans annoter ses associations force Doctrine à exécuter les JOIN de toute façon. La partie association du graphe revient de la BDD à chaque hit.
  • UnitOfWork pollué après un bulk insert. Si tu crées 10 000 entités en boucle sans clear() périodique, l'UnitOfWork gonfle et les performances s'effondrent avant même que le SLC entre en jeu.
  • log_enabled: true en production. Cette option enregistre chaque hit/miss dans un tableau en mémoire. Sur des endpoints à fort trafic, cela consomme de la RAM inutilement. Garde-le activé uniquement dans l'environnement dev.

Métriques avant/après sur une app Symfony 7.4

Sur une application e-commerce Symfony 7.4 avec environ 400 produits par page de catégorie et des associations Category, Brand et TaxRate chargées pour chaque produit, voici les mesures avant/après activation du SLC (Redis local, cache chaud, 50 requêtes concurrentes via wrk) :

# Avant SLC — profil Blackfire sur GET /catalog/electronics
SQL queries        : 47  (dont 42 lectures d'entités)
DB time            : 186 ms
Total response     : 390 ms
Memory peak        : 24 MB

# Après SLC — même endpoint, cache chaud
SQL queries        : 5   (1 requête liste + 4 requêtes auxiliaires non cachées)
DB time            : 12 ms
Total response     : 88 ms
Memory peak        : 18 MB

# Cache hit rate après 5 minutes de trafic
catalog_region     : 97.3 % hits
pricing_region     : 91.8 % hits
geo_region         : 99.6 % hits

La réduction de 47 à 5 requêtes SQL s'explique par les 42 lookups d'entités liées (Category et Brand identiques sur plusieurs produits) que le SLC sert directement. Le temps de réponse chute de 390 ms à 88 ms : la latence réseau et l'hydratation répétée sont éliminées. Le gain mémoire provient de l'absence de re-hydratation des mêmes objets. Ces résultats sont reproductibles dès lors que les entités associées sont correctement annotées et que le TTL dépasse la durée médiane de la session utilisateur.

Le Second Level Cache de Doctrine ORM est l'une des rares optimisations qui intervient avant la couche SQL sans modifier la logique applicative. Bien configuré avec des régions métier, des stratégies adaptées et une invalidation explicite sur les chemins d'écriture, il décharge vers Redis une fraction significative du trafic base de données — libérant des connexions pour les writes et les requêtes complexes qui ne peuvent être mises en cache. Si ton application Symfony 7.4 souffre de lectures répétitives et que tu veux un diagnostic outillé (Blackfire + profil SLC) avant de te lancer, Bear Scan est l'audit qui part du profil de production et ressort un plan d'action priorisé par impact mesurable.

Cet article vous a plu ? Partagez-le !

Besoin d'un expert Symfony ?

20 ans d'expérience sur l'écosystème PHP/Symfony.