Skip to content

Performance & Cache

TL;DR — La perf en prod, ça se joue à plusieurs couches : (1) OPcache + preload côté PHP, (2) reverse proxy HTTP (Varnish / Symfony HttpCache / CDN), (3) cache applicatif (Redis/APCu via Cache component), (4) cache Doctrine (metadata + query + result), (5) cache navigateur via headers. Mesure avant d'optimiser : profile (Blackfire, Tideways), lis le profiler, identifie le bottleneck. Une bonne invalidation > un gros cache.

🧠 Mental model — ASCII diagram + analogy

   Browser                                           Origin (PHP-FPM)
    │                                                       │
    ▼                                                       ▼
   ┌─────────────────────────────────────────────────────────────────┐
   │  L0: Browser cache (Cache-Control, ETag, Last-Modified)         │
   ├─────────────────────────────────────────────────────────────────┤
   │  L1: CDN (Cloudflare, Fastly)                                   │
   ├─────────────────────────────────────────────────────────────────┤
   │  L2: Reverse proxy (Varnish, Symfony HttpCache, Nginx)          │
   │      ESI fragments, surrogate keys                              │
   ├─────────────────────────────────────────────────────────────────┤
   │  L3: PHP OPcache + preload (in-memory bytecode)                 │
   ├─────────────────────────────────────────────────────────────────┤
   │  L4: Application cache (Cache component → Redis/APCu)           │
   │      - tag invalidation                                         │
   │      - per-route, per-user                                      │
   ├─────────────────────────────────────────────────────────────────┤
   │  L5: Doctrine caches (metadata, query, result)                  │
   ├─────────────────────────────────────────────────────────────────┤
   │  L6: Database (PostgreSQL/MySQL) + its own caches               │
   └─────────────────────────────────────────────────────────────────┘

Analogie : un bouclier multicouche. Chaque couche absorbe le maximum avant de laisser passer. Tu veux que 95% des requêtes meurent avant d'arriver à PHP. Une réponse cachée par Varnish coûte 0.1ms, une réponse PHP standard 50-200ms.

L'invalidation est le vrai problème. "There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton.

🛠️ Code minimal — realistic snippet (PHP 8.2+)

yaml
# config/packages/cache.yaml
framework:
    cache:
        app: cache.adapter.redis
        default_redis_provider: '%env(REDIS_URL)%'
        pools:
            cache.api_responses:
                adapter: cache.adapter.redis_tag_aware # supports tags
                default_lifetime: 3600
            cache.heavy_computation:
                adapter: cache.adapter.apcu # local, no network
                default_lifetime: 86400
            cache.short_lived:
                adapter: cache.adapter.array # in-memory per-request
php
<?php
// src/Service/BookCatalog.php — using cache with tags
namespace App\Service;

use App\Repository\BookRepository;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final class BookCatalog
{
    public function __construct(
        private TagAwareCacheInterface $cacheApiResponses, // bound via service config
        private BookRepository $repo,
    ) {}

    public function getBestsellers(): array
    {
        return $this->cacheApiResponses->get('bestsellers', function (ItemInterface $item) {
            $item->expiresAfter(3600);
            $item->tag(['books', 'bestsellers']);
            return $this->repo->findBestsellers();
        });
    }

    public function invalidateOnBookChange(int $bookId): void
    {
        $this->cacheApiResponses->invalidateTags(['books']); // wipes bestsellers + any tagged 'books'
    }
}
php
<?php
// src/Controller/BookController.php — HTTP cache headers
namespace App\Controller;

use App\Repository\BookRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\Cache;
use Symfony\Component\Routing\Attribute\Route;

final class BookController extends AbstractController
{
    public function __construct(private readonly BookRepository $repo) {}

    #[Route('/books/{id}', methods: ['GET'])]
    #[Cache(public: true, maxage: 3600, smaxage: 86400, mustRevalidate: true)]
    public function show(int $id, Request $request): Response
    {
        $book = $this->repo->find($id) ?? throw $this->createNotFoundException();

        // Build the validator FIRST, check 304 BEFORE rendering Twig.
        $resp = new Response();
        $resp->setEtag(md5($book->getUpdatedAt()->format('U')));
        $resp->setLastModified($book->getUpdatedAt());
        $resp->setPublic(); // explicit: shareable across users

        if ($resp->isNotModified($request)) {
            return $resp; // 304 — no body, no Twig render, no DB hydration beyond the find()
        }

        return $this->render('book/show.html.twig', ['book' => $book], $resp);
    }
}

Subtilité 304 : le gain réel d'isNotModified() est d'éviter le rendu Twig et la sérialisation — pas la requête SQL (tu as déjà chargé $book pour calculer l'ETag). Pour économiser aussi le SQL, calcule l'ETag à partir d'une colonne version/updated_at lue via une requête SELECT updated_at minimale, ou délègue entièrement la validation au reverse proxy (Varnish stocke l'ETag).

yaml
# config/packages/doctrine.yaml — Doctrine caches
doctrine:
    orm:
        metadata_cache_driver:
            type: pool
            pool: doctrine.system_cache_pool
        query_cache_driver:
            type: pool
            pool: doctrine.system_cache_pool
        result_cache_driver:
            type: pool
            pool: doctrine.result_cache_pool

framework:
    cache:
        pools:
            doctrine.system_cache_pool:
                adapter: cache.adapter.apcu # local, fast, immutable schema
            doctrine.result_cache_pool:
                adapter: cache.adapter.redis # shared, invalidatable
php
<?php
// Using Doctrine result cache
$qb = $this->em->createQueryBuilder()
    ->select('b')->from(Book::class, 'b')
    ->where('b.published = true');

$qb->getQuery()
   ->enableResultCache(3600, 'published_books_v1') // key includes a version → easy bust
   ->getResult();
ini
; php.ini for production — OPcache + preload
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0          ; CRITICAL in prod, never re-check FS
opcache.revalidate_freq=0
opcache.save_comments=1                 ; needed for annotations/attributes
opcache.fast_shutdown=1
opcache.jit_buffer_size=128M
opcache.jit=tracing
opcache.preload=/var/www/app/config/preload.php
opcache.preload_user=www-data

realpath_cache_size=4096K
realpath_cache_ttl=600
php
<?php
// config/preload.php — generated by Symfony
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';

🎯 Patterns courants

  1. Cache-aside avec tags — pattern par défaut : $cache->get($key, fn() => compute()) + tag(). À l'invalidation, invalidateTags(['user_42']) purge tout ce qui dépend de cet user.

  2. HTTP cache + ESI — page complète mise en cache par Varnish, fragments dynamiques (panier, user-info) en ESI. Symfony envoie <esi:include src="/_fragments/cart"/>, Varnish remplit le fragment.

  3. Cache versioning par clépublished_books_v1. Pour invalider tout en un changement de code, bump à _v2. Plus simple que tags si rare. Combinable avec Symfony\Component\Cache\Adapter\TraceableAdapter pour mesurer hit ratio.

  4. APCu pour lectures locales fréquentes — config app, feature flags, traduction. Pas de réseau, mais par worker (donc invalidation = redéployer ou apcu_clear_cache() via endpoint admin).

  5. stampede protectioncache->get() accepte un beta param (probabilistic early expiration). Sous forte concurrence, évite que 100 workers recalculent la même clé en parallèle.

    php
    $cache->get($key, $callback, /* beta */ 1.0); // default 1.0
  6. Profile-driven optimizationBlackfire, tideways, ou xhprof. Mesure d'abord (call graph, hot methods, SQL count), puis optimise. Sans profile, tu optimises ce qui te plaît, pas ce qui ralentit.

🧭 Comment un staff engineer raisonne

Le coût de chaque couche (ordre de grandeur)

CoucheLatence d'un hitPortéeInvalidationCoût opérationnel
Browser cache0 (rien sur le réseau)1 userimpossible (TTL only)nul, mais dangereux si trop long
CDN (Cloudflare/Fastly)~5-30ms (edge)globalpurge API / surrogate keys$$ trafic, mais décharge l'origine
Varnish / HttpCache~0.1-1msun datacenterBAN/PURGE par tagRAM, config VCL
Redis (réseau)~0.3-1ms RTTcluster partagétags / clésun service à opérer + monitorer
APCu (local)~10-50µsun worker FPMaucune cross-workergratuit, mais incohérent en multi-worker
Doctrine result cache= backend (Redis/APCu)selon poolhash de query / clé expliciteinvalidation fragile
OPcache + preloadbytecode déjà en RAMun processreload php-fpmgratuit, x2-3 perf

Règle de décision : descends d'une couche seulement si la couche au-dessus ne peut pas absorber la requête. Une balance bancaire (auth, privée, fraîcheur 5 min) ne peut pas vivre en CDN → Redis. Une page catalogue anonyme doit mourir en Varnish/CDN bien avant PHP.

Le vrai axe de design : cohérence vs fraîcheur vs charge

Chaque décision de cache est un point dans le triangle fraîcheur ↔ charge origine ↔ complexité d'invalidation. Tu ne peux pas maximiser les trois.

  • TTL court, pas de tags : simple, toujours frais à ±TTL, mais charge l'origine. Bon pour des données volatiles peu coûteuses.
  • TTL long + invalidation par tags : charge minimale, fraîcheur quasi-immédiate, mais tu paies la complexité — chaque write doit connaître les tags à purger. C'est là que les bugs vivent (un write qui oublie un tag = donnée fantôme servie pendant 30 jours).
  • Versioning par clé (...v3) : invalidation = changement de code/déploiement. Zéro logique runtime, mais ne purge pas à chaud — réservé aux données qui changent au rythme des déploiements (schémas, config compilée).

Heuristique staff : préfère TTL + stale-while-revalidate comme défaut, et n'ajoute des tags que là où une donnée périmée est inacceptable (prix, stock, droits). Le cache le plus fiable est celui qui expire tout seul ; l'invalidation manuelle est une dette que tu rembourses à chaque feature.

Stampede, dogpile et la beta

Sous forte concurrence, l'expiration d'une clé chaude déclenche un thundering herd : N workers ratent le cache simultanément et recalculent tous la même valeur. Le Cache component de Symfony implémente la Probabilistic Early Expiration (paramètre beta) : chaque worker tire un nombre et décide avant l'expiration réelle de recalculer en avance, tout seul, pendant que les autres servent encore l'ancienne valeur.

php
// beta = 1.0 (défaut) : recalcul anticipé activé.
// beta = INF : recalcul immédiat (utile pour forcer un refresh).
// beta = 0  : désactive l'early expiration (comportement TTL strict).
$value = $cache->get($key, $callback, beta: 1.0);

Pour les cas où une seule entité doit recalculer (verrou dur), combine avec un LockInterface : le premier worker prend le lock et recompute, les autres servent le stale ou attendent. C'est le pattern stale-while-revalidate côté applicatif.

Observabilité — tu ne peux pas optimiser ce que tu ne mesures pas

Un cache sans métriques est un pari. Mesure au minimum : hit ratio par pool, latence backend (p50/p99 Redis), taux d'invalidation, taille des sets de tags.

php
<?php
// Décore un pool pour exposer le hit ratio à Prometheus.
use Symfony\Component\Cache\Adapter\TraceableAdapter;

$traceable = new TraceableAdapter($realPool);
// ... après le cycle requête :
foreach ($traceable->getCalls() as $call) {
    // $call->name = 'get'|'getItem'|..., $call->hits, $call->misses
    $metrics->incr("cache.{$call->name}.hits", $call->hits);
    $metrics->incr("cache.{$call->name}.misses", $call->misses);
}

En prod, framework.cache peut être branché sur un pool tracé en dev/test (le profiler Symfony affiche déjà hits/misses par pool). Un hit ratio < 80% sur un pool censé être chaud = la clé varie trop (paramètres dans la clé, TTL trop court, ou invalidation trop agressive). Un hit ratio de 100% sur un pool de données mutables = tu sers probablement du périmé.

🔄 Versions

SymfonyCache notes
5.4Cache\Adapter\TagAwareAdapter stable. TagAwareCacheInterface.
6.0Symfony\Contracts\Cache séparé de Symfony\Component\Cache. Préfère les contracts pour le typehint.
6.3MarshallerInterface amélioré (igbinary, lz4). Doctrine metadata cache doit être PSR-6.
6.4LTS. RedisTagAwareAdapter mature.
7.0Suppression d'anciens drivers (Memcached < 3.0). Doctrine cache legacy supprimé.
Doctrine ORMCache
2.xDoctrine\Common\Cache (deprecated).
2.13+Bridge vers PSR-6 (doctrine/cache 2.x).
3.0Doctrine\Common\Cache supprimé. Uniquement PSR-6/Symfony Cache.
API PlatformCache
2.xCaches metadata custom.
3.xRefactor : utilise Symfony Cache PSR-6. api_platform.metadata_cache pool.

OPcache JIT : tracing est OK en prod sur PHP 8.1+. function est plus stable mais moins agressif. Bench avant.

⚠️ Pitfalls

  1. opcache.validate_timestamps=1 en prod — chaque inclusion require re-stat le FS. -30% perf. Toujours 0 en prod, et cache:clear au deploy.

  2. OPcache pas warmé après reload — premier hit après systemctl reload php-fpm est lent. Solution : cachetool opcache:status, ou pré-hit /healthz post-deploy.

  3. Preload + autowiring conflictopcache.preload charge les classes mais leur __construct ne tourne pas. Si une classe a un side-effect au load (rare mais ça existe), boum. Aussi : preload ne supporte pas les classes utilisant des traits dont les fichiers n'ont pas été préchargés → fatal error.

  4. Cache stampede sans beta — 1000 requêtes simultanées, le cache expire → 1000 recomputes en parallèle → DB explose. Symptôme : "death by cache miss".

  5. Tag invalidation lenteRedisTagAwareAdapter invalide en parcourant un set. Si ton tag est utilisé par 100k clés, invalidation = O(n). Préfère des tags fins (par entity ID, pas par type global).

  6. Doctrine result_cache sans hash — la clé par défaut est un hash de la query. Si tu changes setParameter('id', 1)(':id', 1), c'est une clé différente. Sois conscient ou force enableResultCache($ttl, 'explicit_key').

  7. HTTP cache + cookies — par défaut, Symfony HttpCache ne cache pas une réponse avec cookies ou Authorization header. Logique (privé). Force Cache-Control: public explicitement si tu sais que la réponse est partageable.

  8. APCu invalidation cross-worker — APCu est par-process (PHP-FPM child). apcu_delete('key') dans un worker laisse les autres workers servir l'ancienne valeur. Pour invalidation globale : utilise Redis, pas APCu.

🧪 Testing

php
<?php
namespace App\Tests\Performance;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class HomePagePerfTest extends WebTestCase
{
    public function testHomeIsFast(): void
    {
        $client = static::createClient();
        $client->enableProfiler();
        $start = microtime(true);
        $client->request('GET', '/');
        $elapsedMs = (microtime(true) - $start) * 1000;

        self::assertLessThan(200, $elapsedMs, 'home page too slow');

        /** @var \Doctrine\Bundle\DoctrineBundle\DataCollector\DoctrineDataCollector $db */
        $db = $client->getProfile()->getCollector('db');
        self::assertLessThan(10, $db->getQueryCount(), 'too many SQL queries — N+1?');
    }
}

Pour benchmark réaliste : ab, wrk, k6 contre un environnement prod-like (OPcache on, preload, Redis warm).

🎬 Cas d'usage concrets

Scénario 1 — Cache catalogue e-commerce Cdiscount (50M visiteurs/mois)

Cdiscount opère une marketplace avec 50M de SKU dont 5M actifs. La page produit /produit/{slug} reçoit 150 req/s en moyenne, 800 en pic. Sans cache, chaque hit coûte ~280ms (4 jointures Doctrine + 2 appels services pricing/stock). Le stack adopte trois couches : (1) Cache HTTP via Varnish devant la stack PHP, TTL 5 min pour les anonymes (Cache-Control: public, s-maxage=300), invalidation par tags product-{id} quand le stock change ; (2) Cache applicatif Redis Cluster (12 nœuds) pour les fragments productFeatures, relatedProducts, reviews via cache.app Symfony (TTL 1h) ; (3) Doctrine result_cache pour les requêtes de listing catégorie (clé hashée avec filtres) avec TTL 15 min. Le WriteListener post-flush invalide les tags. OPcache + preload (préchargement des entités Doctrine + serializer metadata) divisent le temps de bootstrap par 3. Résultat mesuré : P95 sur /produit/* passe de 280ms à 38ms, et le hit ratio Varnish atteint 78% sur les anonymes.

Scénario 2 — Cache jurisprudence cabinet (Doctrine LexisNexis-like, Dalloz)

Une base de jurisprudence française sert 2 millions de décisions (Cour de cassation, CE, CA) consultées par 30 000 avocats abonnés. Chaque recherche full-text (Elasticsearch derrière) renvoie 50 décisions résumées. Le résumé d'une décision (Decision::summary + keywords + related) est calculé par un service NLP coûteux (180ms). Le cache cache.app.summaries (Redis, TTL 30 jours) clé par decision_id + version du modèle NLP. L'invalidation est par tag model-v{version} quand l'éditeur rafraîchit le modèle. Les pages publiques de décisions (référencées Google) sont en cache HTTP Varnish (TTL 24h) avec ESI (<esi:include>) pour le bloc "Voir aussi" plus dynamique. La recherche fréquente (top 1 000 keywords) est précachée en background via Messenger nocturne. Le warmup cache:warmup n'est utilisé qu'en déploiement. Effets mesurés : 90% des consultations de décisions sont servies sans toucher au moteur NLP, latence P95 = 45ms.

Scénario 3 — Cache API banque (BPCE Open Banking)

L'API DSP2 expose /api/accounts/{iban}/balance consultée massivement par les agrégateurs (Bridge, Linxo) toutes les 30 min par client. La régulation impose : pas de cache > 5 min sur les balances en lecture. Le stack : pas de Varnish (auth par token), cache applicatif Redis avec namespace psd2:{client_id}: et TTL 240s, invalidation explicite quand un virement est passé (event TransferCompletedcache.invalidateTags(["balance-{iban}"]). L'enjeu n'est pas la perf brute mais la résilience : si le mainframe core banking est indisponible, le cache sert de fallback 30 minutes (TTL stale) avec un header X-PSD2-Cache: stale-while-revalidate. Le TagAwareAdapter permet d'invalider tous les caches d'un client lors d'un retrait de consentement. APCu reste activé en local L1 devant Redis pour les métadonnées rarement changeantes (référentiel devises, codes BIC).

🛠️ Exemple end-to-end

Cas : page produit e-commerce avec cache HTTP Varnish + cache applicatif tagué + invalidation événementielle.

php
<?php
// src/Controller/ProductController.php
declare(strict_types=1);

namespace App\Controller;

use App\Entity\Product;
use App\Service\ProductView;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\Cache;
use Symfony\Component\Routing\Attribute\Route;

final class ProductController extends AbstractController
{
    public function __construct(private readonly ProductView $view) {}

    #[Route('/produit/{slug}', name: 'product_show', methods: ['GET'])]
    #[Cache(public: true, maxage: 60, smaxage: 300, mustRevalidate: true)]
    public function show(string $slug): Response
    {
        $product = $this->view->buildView($slug);
        $response = $this->render('product/show.html.twig', ['product' => $product]);
        $response->setEtag(md5($product->fingerprint));
        $response->headers->set('X-Cache-Tags', sprintf('product-%d,category-%d', $product->id, $product->categoryId));
        return $response;
    }
}
php
<?php
// src/Service/ProductView.php
declare(strict_types=1);

namespace App\Service;

use App\Repository\ProductRepository;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;

final readonly class ProductView
{
    public function __construct(
        private TagAwareCacheInterface $cache,
        private ProductRepository $products,
        private PricingClient $pricing,
        private RelatedProductsService $related,
    ) {}

    public function buildView(string $slug): ProductViewModel
    {
        return $this->cache->get(
            sprintf('product.view.%s.v3', $slug),
            function (ItemInterface $item) use ($slug): ProductViewModel {
                $item->expiresAfter(900);
                $product = $this->products->findOneBySlugWithJoins($slug)
                    ?? throw new NotFoundHttpException(sprintf('Product "%s" not found.', $slug));

                $item->tag([
                    sprintf('product-%d', $product->id),
                    sprintf('category-%d', $product->categoryId),
                ]);

                return new ProductViewModel(
                    id: $product->id,
                    slug: $product->slug,
                    name: $product->name,
                    price: $this->pricing->resolve($product),
                    related: $this->related->topFive($product->categoryId),
                    fingerprint: hash('xxh128', $product->updatedAt->format('U') . $product->id),
                );
            }
        );
    }
}
php
<?php
// src/EventListener/ProductChangedListener.php
declare(strict_types=1);

namespace App\EventListener;

use App\Event\StockChangedEvent;
use App\Event\PriceChangedEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Contracts\Cache\TagAwareCacheInterface;

final readonly class ProductChangedListener
{
    public function __construct(
        private TagAwareCacheInterface $cache,
        private VarnishPurger $varnish,
    ) {}

    #[AsEventListener]
    public function onStockChanged(StockChangedEvent $event): void
    {
        $tag = sprintf('product-%d', $event->productId);
        $this->cache->invalidateTags([$tag]);
        $this->varnish->banByTag($tag);
    }

    #[AsEventListener]
    public function onPriceChanged(PriceChangedEvent $event): void
    {
        $this->cache->invalidateTags([
            sprintf('product-%d', $event->productId),
            sprintf('category-%d', $event->categoryId),
        ]);
    }
}
yaml
# config/packages/cache.yaml
framework:
    cache:
        app: cache.adapter.redis_tag_aware
        default_redis_provider: '%env(REDIS_DSN)%'
        pools:
            cache.app.summaries:
                adapter: cache.adapter.redis_tag_aware
                default_lifetime: 2592000 # 30 jours
php
<?php
// tests/Performance/ProductPageTest.php
declare(strict_types=1);

namespace App\Tests\Performance;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class ProductPageTest extends WebTestCase
{
    public function testProductPageHasCacheControlAndTag(): void
    {
        $client = static::createClient();
        $client->request('GET', '/produit/iphone-17-pro');

        self::assertResponseIsSuccessful();
        self::assertSame(
            'public, max-age=60, must-revalidate, s-maxage=300',
            $client->getResponse()->headers->get('Cache-Control'),
        );
        self::assertStringContainsString(
            'product-',
            $client->getResponse()->headers->get('X-Cache-Tags'),
        );
    }
}

Couverture : header HTTP cache + cache applicatif Redis tag-aware + invalidation événementielle Varnish + test de non-régression. Lignée droite vers un P95 < 50ms en prod.


🔁 Quand utiliser / éviter

Utiliser :

  • Toujours : OPcache + preload en prod. Gratuit, x2-3 perf.
  • HTTP cache pour pages publiques (catalogue, blog) → Varnish/CDN.
  • Cache applicatif pour calculs >50ms ou requêtes répétitives.
  • Doctrine result cache pour des queries longues + données peu mutables.

Éviter :

  • Cache un endpoint user-specific public sans private → fuite de données entre users.
  • Cache "au cas où" : ajoute de la complexité d'invalidation, mesure d'abord.
  • TTL trop longs sans tags : tu sers des données périmées et tu ne peux plus invalider proprement.
  • Cache une opération idempotente cheap (< 5ms) : le hit cache coûte parfois plus que recompute.

🏋️ Exercices

Environnement prod-like recommandé : Redis (ou cache.adapter.redis_tag_aware), OPcache activé, et un outil de charge (k6, wrk ou ab). Chaque exercice escalade en difficulté.

1. Cache-aside tagué + mesure du hit ratio

Objectif : envelopper une méthode coûteuse dans un pool redis_tag_aware, taguer par entité, et prouver le gain par une métrique.

Implémente un CategoryListing::topProducts(int $categoryId) qui lit en DB (simule 80ms avec usleep), met en cache via get() + tag(["category-$id"]), expire à 600s. Ajoute un TraceableAdapter (ou lis le profiler) et logge le hit ratio après 100 appels mélangés.

Indice / Solution

Injecte TagAwareCacheInterface. La clé doit inclure categoryId. Après 100 appels sur 5 catégories, attends ~95 hits / 5 miss. Vérifie qu'invalidateTags(["category-3"]) ramène un seul miss au prochain appel sur la catégorie 3, sans toucher les autres.

2. Validation HTTP 304 sans rendu Twig

Objectif : faire qu'un GET /books/{id} répété renvoie 304 Not Modified et prouver qu'aucun rendu Twig n'a lieu.

Reprends le BookController de ce chapitre. Écris un test fonctionnel : premier GET200 + header ETag ; second GET avec If-None-Match: <etag>304 + body vide. Bonus : compte les appels Twig via un TwigDataCollector du profiler et assert qu'il vaut 0 sur le 304.

Indice / Solution

$client->request('GET', $url, server: ['HTTP_IF_NONE_MATCH' => $etag]). isNotModified() compare l'ETag entrant. Assert assertResponseStatusCodeSame(304) et assertEmpty($client->getResponse()->getContent()). Le collector Twig est sur $client->getProfile()->getCollector('twig')getTemplateCount() doit être 0 sur le 304.

3. Reproduire puis tuer un cache stampede

Objectif : casser une clé chaude sous concurrence (death by cache miss), puis corriger avec beta + lock.

Mets un calcul de 300ms derrière cache->get($key, $cb, beta: 0) (early expiration désactivée), TTL 5s. Lance k6 à 200 VUs. Observe à chaque expiration un pic de latence et un pic de charge DB (N recomputes simultanés). Corrige : passe beta: 1.0, puis ajoute un LockInterface autour du callback pour garantir un seul recompute.

Indice / Solution

beta: 0 désactive l'early expiration → tous les workers ratent en même temps à T+5s. Avec beta: 1.0, le recompute se déclenche de façon probabiliste avant l'expiration, étalé dans le temps → plus de pic. Le lock ($factory->createLock($key.'.recompute')) sérialise les rares cas où plusieurs entrent quand même : le détenteur recompute, les autres acquire() en non-bloquant et servent le stale. Vérifie via un compteur atomique Redis que le callback ne tourne qu'une fois par fenêtre.

4. Invalidation par tags + purge Varnish cohérente

Objectif : garantir qu'un changement de stock invalide à la fois le cache applicatif et le reverse proxy, sans race.

Émets un StockChangedEvent. Un listener doit invalidateTags(["product-$id"]) (Redis) et envoyer un BAN/PURGE au reverse proxy par le même tag. Écris un test qui : (a) chauffe le cache, (b) modifie le stock, (c) re-GET et assert que la nouvelle valeur est servie. Piège : l'ordre flush DB → invalidation. Que se passe-t-il si tu invalides avant le commit ?

Indice / Solution

Invalider avant commit = fenêtre où un autre worker recharge l'ancienne valeur depuis la DB (transaction pas encore visible) et re-remplit le cache → fantôme. Invalide après postFlush/postCommit (Doctrine onFlush est trop tôt). Pour Varnish, utilise un Surrogate-Key/xkey header et BAN obj.http.x-cache-tags ~ product-42. Le test doit re-warm puis muter et re-GET ; le P0 c'est l'ordre causal write→commit→invalidate.

5. Profiler-driven : éliminer un N+1 caché derrière le cache (break-then-fix)

Objectif : prouver qu'un cache peut masquer un problème de perf qui revient en force au premier miss.

Construis une page listing avec un N+1 Doctrine (50 requêtes). Mets-la en cache HTTP/applicatif. En charge, tout va bien (hit). Maintenant : déploie (cache froid) ou invalide en masse → le « cold start » écroule la DB. Profile avec le DoctrineDataCollector, corrige le N+1 (JOIN/fetch=EAGER/DTO), puis mesure le coût d'un miss avant/après.

Indice / Solution

Le cache transforme un problème permanent en problème intermittent (au déploiement / à l'invalidation de masse) — donc plus dur à diagnostiquer. Le test de non-régression (assertLessThan(10, $db->getQueryCount()) du chapitre) attrape le N+1 même quand le cache le cacherait en prod. Corrige par addSelect + JOIN ou un repository DTO. Bonus : ajoute un cache warmer Messenger pour ne jamais servir un public sur cache froid.

6. Architecture : balance bancaire avec stale-while-revalidate et fallback

Objectif (production-grade) : concevoir un cache de balance PSD2 — fraîcheur ≤ 5 min, mais résilient à une panne du core banking.

Cache redis avec TTL 240s par psd2:{client}:{iban}. Quand le core banking répond, rafraîchis. Quand il est down, sers la valeur stale jusqu'à 30 min avec un header X-PSD2-Cache: stale. Invalide par tag balance-{iban} sur TransferCompleted, et par consent-{client} sur retrait de consentement.

Indice / Solution

Stocke deux horizons : fresh_until (240s) et hard_expire (30 min). Le get() recompute si now > fresh_until ; si le core banking jette une exception, catch et renvoie la valeur stale (tant que now < hard_expire) avec le header stale. Au-delà, propage l'erreur (503). Les tags balance-* et consent-* permettent la double granularité d'invalidation. C'est le scénario 3 du chapitre, implémenté.

🎤 En entretien

Q : « s-maxage vs max-age vs mustRevalidate — qui obéit à quoi ? »max-age cible le cache privé (navigateur) ; s-maxage cible les caches partagés (CDN, Varnish, proxies) et l'emporte sur max-age pour eux. must-revalidate interdit de servir du stale une fois expiré sans revalider auprès de l'origine (ETag/Last-Modified). Réponse senior : sépare TTL public (long, s-maxage) et privé (court ou no-store), et n'oublie pas qu'une réponse avec cookie/Authorization n'est pas mise en cache partagé par défaut.

Q : « Tags vs versioning de clé pour l'invalidation — quand chacun ? » Tags = invalidation à chaud, ciblée, mais coûteuse à grande échelle (set par tag, O(n) à la purge, et chaque write doit connaître ses tags). Versioning de clé (...v3) = invalidation au déploiement, gratuite en runtime, mais pas de purge à chaud. Réponse senior : versioning pour ce qui change au rythme du code (config, schéma sérialisé), tags fins (par ID, jamais par type global) pour ce qui change à chaud, et TTL+SWR comme défaut pour éviter d'invalider tout court.

Q : « Pourquoi APCu ne peut pas être ta source de vérité de cache en multi-worker ? » APCu est en mémoire par process PHP-FPM : apcu_delete() dans un worker laisse les autres servir l'ancienne valeur, et il n'y a pas d'invalidation cross-worker ni cross-serveur. Réponse senior : APCu est un L1 local devant un L2 partagé (Redis), réservé aux données quasi-immuables (config compilée, référentiels), avec une stratégie d'invalidation = redéploiement ou TTL court ; toute donnée invalidable à chaud doit vivre dans Redis.

Q : « OPcache validate_timestamps=0 : qu'est-ce que ça casse, et comment tu déploies ? » Avec validate_timestamps=0, PHP ne re-stat plus les fichiers : le code en mémoire est figé jusqu'au reload. Un déploiement « in-place » sert donc l'ancien bytecode. Réponse senior : déploie en atomic symlink (nouveau release dir + bascule du symlink) puis systemctl reload php-fpm (graceful) ou cachetool opcache:reset, suivi d'un pré-warm (cache:warmup + un hit /healthz) pour ne pas servir le premier user sur un OPcache/preload froid.

🔗 Liens

Bibliothèque tech perso — Achref