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+)
# 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
// 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
// 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é$bookpour calculer l'ETag). Pour économiser aussi le SQL, calcule l'ETag à partir d'une colonneversion/updated_atlue via une requêteSELECT updated_atminimale, ou délègue entièrement la validation au reverse proxy (Varnish stocke l'ETag).
# 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
// 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();; 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
// config/preload.php — generated by Symfony
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';🎯 Patterns courants
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.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.Cache versioning par clé —
published_books_v1. Pour invalider tout en un changement de code, bump à_v2. Plus simple que tags si rare. Combinable avecSymfony\Component\Cache\Adapter\TraceableAdapterpour mesurer hit ratio.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).stampede protection—cache->get()accepte unbetaparam (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.0Profile-driven optimization —
Blackfire,tideways, ouxhprof. 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)
| Couche | Latence d'un hit | Portée | Invalidation | Coût opérationnel |
|---|---|---|---|---|
| Browser cache | 0 (rien sur le réseau) | 1 user | impossible (TTL only) | nul, mais dangereux si trop long |
| CDN (Cloudflare/Fastly) | ~5-30ms (edge) | global | purge API / surrogate keys | $$ trafic, mais décharge l'origine |
| Varnish / HttpCache | ~0.1-1ms | un datacenter | BAN/PURGE par tag | RAM, config VCL |
| Redis (réseau) | ~0.3-1ms RTT | cluster partagé | tags / clés | un service à opérer + monitorer |
| APCu (local) | ~10-50µs | un worker FPM | aucune cross-worker | gratuit, mais incohérent en multi-worker |
| Doctrine result cache | = backend (Redis/APCu) | selon pool | hash de query / clé explicite | invalidation fragile |
| OPcache + preload | bytecode déjà en RAM | un process | reload php-fpm | gratuit, 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.
// 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
// 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
| Symfony | Cache notes |
|---|---|
| 5.4 | Cache\Adapter\TagAwareAdapter stable. TagAwareCacheInterface. |
| 6.0 | Symfony\Contracts\Cache séparé de Symfony\Component\Cache. Préfère les contracts pour le typehint. |
| 6.3 | MarshallerInterface amélioré (igbinary, lz4). Doctrine metadata cache doit être PSR-6. |
| 6.4 | LTS. RedisTagAwareAdapter mature. |
| 7.0 | Suppression d'anciens drivers (Memcached < 3.0). Doctrine cache legacy supprimé. |
| Doctrine ORM | Cache |
|---|---|
| 2.x | Doctrine\Common\Cache (deprecated). |
| 2.13+ | Bridge vers PSR-6 (doctrine/cache 2.x). |
| 3.0 | Doctrine\Common\Cache supprimé. Uniquement PSR-6/Symfony Cache. |
| API Platform | Cache |
|---|---|
| 2.x | Caches metadata custom. |
| 3.x | Refactor : 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
opcache.validate_timestamps=1en prod — chaque inclusionrequirere-stat le FS. -30% perf. Toujours0en prod, etcache:clearau deploy.OPcache pas warmé après reload — premier hit après
systemctl reload php-fpmest lent. Solution :cachetool opcache:status, ou pré-hit/healthzpost-deploy.Preload + autowiring conflict —
opcache.preloadcharge les classes mais leur__constructne 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.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".Tag invalidation lente —
RedisTagAwareAdapterinvalide 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).Doctrine
result_cachesans hash — la clé par défaut est un hash de la query. Si tu changessetParameter('id', 1)↔(':id', 1), c'est une clé différente. Sois conscient ou forceenableResultCache($ttl, 'explicit_key').HTTP cache + cookies — par défaut, Symfony HttpCache ne cache pas une réponse avec cookies ou Authorization header. Logique (privé). Force
Cache-Control: publicexplicitement si tu sais que la réponse est partageable.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
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
// 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
// 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
// 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),
]);
}
}# 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
// 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
- Cache component : https://symfony.com/doc/current/components/cache.html
- HTTP cache : https://symfony.com/doc/current/http_cache.html
- ESI : https://symfony.com/doc/current/http_cache/esi.html
- OPcache preload : https://www.php.net/manual/en/opcache.preloading.php
- Doctrine caching : https://www.doctrine-project.org/projects/doctrine-orm/en/current/reference/second-level-cache.html
- Blackfire : https://blackfire.io