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 protection — cache->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 optimization — Blackfire, 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 conflict — opcache.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 lente — RedisTagAwareAdapter 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 TransferCompleted → cache.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 GET → 200 + 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