Stratégies de cache PHP : OPcache, Redis, Varnish, CDN et le problème de l'invalidation
Le cache est une promesse, pas une solution
Il existe deux problèmes en informatique : nommer les choses, invalider le cache, et les erreurs de +1. La blague de Phil Karlton est vraie pour une raison précise : un cache mal conçu amplifie les bugs au lieu de les masquer. Une donnée stale servie à 99,9% des utilisateurs est une vraie régression, pas une optimisation.
Malgré ce risque, le cache reste indispensable. Une application PHP moderne empile cinq niveaux de cache entre le code source et le navigateur. Chaque niveau a son rôle, ses invariants, ses pièges. Cet article détaille les cinq niveaux, les patterns d'invalidation éprouvés et les pièges que nous voyons le plus souvent en audit.
Les cinq niveaux
| Niveau | Emplacement | Durée | Usage | Outil typique |
|---|---|---|---|---|
| 1 | Navigateur | minutes à jours | Assets statiques, API publiques | Cache-Control, Service Worker |
| 2 | CDN | secondes à heures | Pages, JSON public, images | Cloudflare, CloudFront, Fastly |
| 3 | Reverse proxy | secondes à minutes | Pages dynamiques publiques | Varnish, Nginx proxy_cache |
| 4 | Applicatif | minutes à heures | Résultats métier, queries | Redis, Memcached |
| 5 | Bytecode | jusqu'au restart | Code PHP compilé | OPcache |
Un cache hit au niveau 1 coûte 0 ms serveur. Un hit au niveau 5 reste dans le processus PHP. Plus on capte tôt, plus on gagne. Mais chaque niveau apporte sa complexité d'invalidation.
Niveau 5 — OPcache, la base non négociable
OPcache compile le bytecode PHP une fois et le garde en mémoire partagée. Sans OPcache, chaque requête re-parse et re-compile le code. C'est gratuit, activé par défaut depuis PHP 5.5, et pourtant encore mal configuré chez un tiers des clients que nous auditons.
Configuration production recommandée (extrait de php.ini ou d'un fichier dédié dans /etc/php/8.3/fpm/conf.d/10-opcache.ini) :
; OPcache activé
opcache.enable = 1
opcache.enable_cli = 0
; Mémoire allouée (à ajuster selon le volume de code)
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 32
; Nombre de fichiers caché-ables (ajuster au-dessus du nombre réel)
opcache.max_accelerated_files = 20000
; Ne pas revalider les timestamps en prod (critique)
opcache.validate_timestamps = 0
; Garder les commentaires : Doctrine, Symfony annotations en dépendent
opcache.save_comments = 1
; Optimisations avancées
opcache.enable_file_override = 0
opcache.fast_shutdown = 1
opcache.jit_buffer_size = 64M
opcache.jit = tracing
Le point crucial est opcache.validate_timestamps = 0. Avec 1, PHP stat chaque fichier à chaque requête pour détecter un changement : overhead de 10 à 30% gratuit. En prod, le code ne change que sur un deploy, donc on désactive la revalidation et on force la purge au deploy.
opcache.save_comments = 1 est obligatoire pour les applications qui utilisent les annotations ou attributs PHP (Symfony, Doctrine). Laisser 0 fait planter la DI.
Impact mesuré : 3× à 5× plus rapide sur une application Symfony typique par rapport à OPcache désactivé. Sur un endpoint avec 200 classes chargées, le gain est de 40 à 60 ms par requête.
Niveau 4 — Cache applicatif
Le cache applicatif mémorise des résultats métier : requêtes coûteuses, projections, appels API externes.
Doctrine second-level cache
Doctrine offre un cache de second niveau pour les entités lues fréquemment. À réserver aux entités rarement modifiées (catégories, configurations, taxonomies).
# config/packages/doctrine.yaml
doctrine:
orm:
second_level_cache:
enabled: true
region_cache_driver:
type: pool
pool: doctrine.redis_cache_pool
regions:
category_region:
cache_driver:
type: pool
pool: doctrine.redis_cache_pool
lifetime: 3600
#[ORM\Entity]
#[ORM\Cache(usage: 'NONSTRICT_READ_WRITE', region: 'category_region')]
class Category
{
// ...
}
NONSTRICT_READ_WRITE est le mode de choix pour du read-heavy. READ_WRITE ajoute un locking distribué et est plus lent.
Cache Redis manuel
Pour le cache métier classique, nous utilisons symfony/cache avec un adapter Redis.
# config/packages/cache.yaml
framework:
cache:
default_redis_provider: 'redis://redis:6379'
pools:
app.hot_cache:
adapter: cache.adapter.redis
default_lifetime: 300
app.tag_cache:
adapter: cache.adapter.redis_tag_aware
default_lifetime: 3600
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class DashboardService
{
public function __construct(
private CacheInterface $hotCache,
private DashboardQueryBus $queryBus,
) {}
public function compute(int $tenantId): DashboardDto
{
return $this->hotCache->get(
"dashboard.{$tenantId}",
function (ItemInterface $item) use ($tenantId) {
$item->expiresAfter(300);
$item->tag(["tenant.{$tenantId}"]);
return $this->queryBus->handle(new ComputeDashboardQuery($tenantId));
}
);
}
}
Structures Redis adaptées
Redis n'est pas qu'un store clé/valeur. Utiliser les bonnes structures change l'efficacité.
| Structure | Usage | Exemple |
|---|---|---|
| Strings | Cache clé/valeur classique | SET cache:dashboard:42 "{...}" EX 300 |
| Hashes | Objets structurés, maj partielle | HSET user:42 lastLogin "2026-06-15" |
| Sets | Dédoublonnage, intersections | SADD online:users 42 43 44 |
| Sorted sets | Leaderboards, files triées | ZADD trending:articles 1280 42 |
| Streams | Event log, pub/sub persistant | XADD events:invoices * type updated id 42 |
Patterns de mise en cache
- Cache-aside : l'application lit Redis, puis la DB si miss, et peuple Redis après. Pattern le plus courant, celui du code Symfony ci-dessus.
- Write-through : chaque écriture en DB écrit aussi dans Redis. Donne une cohérence forte mais couple fortement.
- Write-behind : l'écriture va d'abord dans Redis, puis async en DB. Performance maximum, risque de perte en cas de crash Redis. À réserver aux cas spécifiques (compteurs).
Niveau 3 — Varnish et le reverse proxy
Varnish intercepte les requêtes HTTP avant qu'elles n'atteignent PHP. Sur les endpoints publics (pages marketing, JSON non authentifié), un hit Varnish coûte 1 à 5 ms.
vcl 4.1;
backend default {
.host = "app";
.port = "80";
}
sub vcl_recv {
# Jamais de cache pour les authentifiés
if (req.http.Authorization || req.http.Cookie ~ "PHPSESSID|token") {
return (pass);
}
# Jamais de cache pour les non-GET
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# Normaliser les headers pour maximiser les hits
unset req.http.Accept-Encoding;
return (hash);
}
sub vcl_backend_response {
# TTL par défaut 2 min, grace 1h (servir le cache stale si le backend plante)
set beresp.ttl = 120s;
set beresp.grace = 1h;
# Support de l'invalidation par tag via header applicatif
if (beresp.http.X-Cache-Tags) {
set beresp.http.xkey = beresp.http.X-Cache-Tags;
}
}
L'invalidation Varnish par tags passe par le module xkey (vmod). L'application émet le header X-Cache-Tags: invoice:42 tenant:7 lors de la réponse. Plus tard, un BAN ciblé invalide tous les objets taggés avec invoice:42.
ESI : Edge Side Includes
ESI permet de cacher des fragments de page indépendamment. Exemple typique : une page produit cache-friendly avec un bloc "panier" personnalisé assemblé par Varnish.
<esi:include src="/_fragment/cart?sid=abc123" />
Symfony supporte ESI nativement via le composant HttpKernel. Sous-requête rendue avec son propre cache TTL.
Niveau 2 — CDN
Cloudflare, AWS CloudFront et Fastly sont les CDN que nous déployons le plus souvent. Leur rôle est de cacher géographiquement au plus près de l'utilisateur. Un utilisateur à Sydney reçoit la réponse depuis un point de présence à 10 ms au lieu de 300 ms depuis Paris.
Règles que nous établissons systématiquement sur Cloudflare :
- Assets statiques (
.css,.js,.jpg,.woff2) :Cache-Control: public, max-age=31536000, immutable. Versionnés par hash dans le nom de fichier. - JSON public (
/api/public/*) : TTL 60 s au edge avec stale-while-revalidate. - Pages HTML publiques : edge cache 5 min, purge API sur publication.
- Tout le reste (
/admin,/api/private) :bypass.
L'API de purge de Cloudflare accepte des tags via Cache-Tag. Notre CI l'utilise après chaque déploiement pour invalider les tags affectés.
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"tags":["pages","invoices"]}'
Niveau 1 — Navigateur et Service Worker
Le cache navigateur est gouverné par les headers Cache-Control et ETag. Sur une SPA, le Service Worker permet un contrôle fin (workbox, Next.js cache).
Pour une API REST consommée par un client mobile ou SPA, la règle d'or : chaque endpoint GET doit porter un Cache-Control explicite. Par défaut dans Symfony, les réponses ont Cache-Control: private, must-revalidate, ce qui empêche toute mise en cache. Il faut le corriger endpoint par endpoint.
L'invalidation : le hard problem
Invalider un cache correctement est plus difficile que le concevoir. Trois stratégies.
Time-based (TTL)
Le plus simple : chaque entrée expire après une durée. Aucune invalidation active nécessaire. Convient aux données qui supportent une obsolescence courte (taux de change mis à jour toutes les minutes).
Piège : bien choisir le TTL. Trop court, le cache ne sert pas. Trop long, les utilisateurs voient des données stale pendant trop longtemps.
Tag-based
Chaque entrée porte un ou plusieurs tags. On invalide tous les tags d'un coup.
// À l'écriture du cache
$cache->get("invoice.42", function (ItemInterface $item) {
$item->tag(['tenant.7', 'invoice.42', 'invoices']);
$item->expiresAfter(3600);
return $this->buildDto(42);
});
// À la modification de la facture 42
$cache->invalidateTags(['invoice.42']);
// À la modification globale (rare)
$cache->invalidateTags(['invoices']);
Nécessite un adapter tag-aware (RedisTagAwareAdapter de Symfony, Varnish xkey, Cloudflare Cache-Tag). C'est la stratégie que nous utilisons le plus souvent en production.
Event-based (pub/sub)
Une modification émet un événement sur un canal Redis pub/sub. Les consommateurs (autres workers, caches locaux) invalident les entrées concernées. Utile quand plusieurs instances applicatives ont leur propre cache L1 local.
// Publisher
$redis->publish('cache:invalidate', json_encode([
'tags' => ['invoice.42', 'tenant.7'],
'timestamp' => time(),
]));
// Consumer dans chaque worker
$redis->subscribe(['cache:invalidate'], function ($redis, $chan, $msg) use ($localCache) {
$payload = json_decode($msg, true);
foreach ($payload['tags'] as $tag) {
$localCache->forgetByTag($tag);
}
});
Cache stampede : le piège silencieux
Un cache stampede arrive quand une entrée expire et que 1000 requêtes concurrentes tombent toutes sur le miss en même temps. Elles recalculent toutes en parallèle, saturent la DB, et l'application s'écroule.
Deux protections.
Lock distribué
On pose un lock Redis pendant qu'une seule requête recalcule ; les autres attendent ou renvoient la valeur stale.
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\RedisStore;
final class StampedeSafeCache
{
public function __construct(
private CacheInterface $cache,
private LockFactory $lockFactory,
) {}
public function get(string $key, int $ttl, callable $compute): mixed
{
$item = $this->cache->getItem($key);
if ($item->isHit()) {
return $item->get();
}
$lock = $this->lockFactory->createLock("lock.{$key}", 30);
if (!$lock->acquire(blocking: false)) {
// Attente courte puis relecture
usleep(100_000);
return $this->cache->getItem($key)->get();
}
try {
$value = $compute();
$item->set($value);
$item->expiresAfter($ttl);
$this->cache->save($item);
return $value;
} finally {
$lock->release();
}
}
}
Probabilistic early expiration
Plus élégant : on recalcule l'entrée avant son expiration, avec une probabilité qui augmente à mesure qu'on s'en rapproche. Aucune requête ne voit jamais le miss.
final class ProbabilisticCache
{
public function get(string $key, int $ttl, callable $compute): mixed
{
$item = $this->cache->getItem($key);
if ($item->isHit()) {
$meta = $item->getMetadata();
$expiresAt = $meta['expiry'] ?? 0;
$computeTime = $meta['compute_ms'] ?? 100;
$now = microtime(true);
$delta = ($computeTime / 1000) * log(mt_rand() / mt_getrandmax());
if ($now - $delta < $expiresAt) {
return $item->get();
}
}
$start = microtime(true);
$value = $compute();
$computeMs = (int) ((microtime(true) - $start) * 1000);
$item->set($value);
$item->expiresAfter($ttl);
$item->getMetadata()['compute_ms'] = $computeMs;
$this->cache->save($item);
return $value;
}
}
Basé sur le papier "Optimal Probabilistic Cache Stampede Prevention" (Vattani, Chierichetti, Lowenstein, 2015). Nous l'appliquons sur les caches critiques en production.
Stack par taille d'application
| Taille | Cache minimal | Cache optimal |
|---|---|---|
| Solo / MVP (1 instance) | OPcache + Redis | OPcache + Redis + Cloudflare free |
| PME (3 à 10 instances) | OPcache + Redis + Varnish | Ajouter CDN Cloudflare payant |
| Scale-up (10+ instances) | OPcache + Redis cluster + Varnish | Fastly ou CloudFront avec tags, Redis sentinel |
La tentation courante est de sauter des niveaux. "On a Cloudflare, on n'a pas besoin de Redis." C'est faux : le CDN cache du public, Redis cache du privé. Les deux sont complémentaires.
Métriques à suivre
Sans mesure, on ne sait pas si le cache fonctionne.
- Hit ratio par niveau. Varnish hit ratio < 80% : l'invalidation est trop agressive. > 99% : on cache peut-être trop longtemps.
- Memory usage Redis. Si on frôle
maxmemory, l'eviction commence et les hits s'effondrent. - Eviction rate Redis. Mesure via
INFO stats. Au-delà de 100/sec, il faut plus de RAM ou des TTL plus courts. - Cache miss latency. Une entrée miss doit se recalculer en moins de 500 ms pour ne pas propager la lenteur.
- Staleness. Temps moyen entre la modification d'une donnée et la disponibilité du nouveau contenu pour les utilisateurs.
Pièges observés en audit
- Cacher une erreur. Si le code backend lève une exception, ne pas cacher la réponse. Sinon on sert des 500 pendant le TTL.
- Cacher des données personnalisées par erreur.
Cache-Control: publicsur une page contenant le nom de l'utilisateur. Fuite garantie. Toujours vérifier que la clé de cache inclutuser_idoutenant_id. - TTL infini sans stratégie de bust. "Ça ne change jamais" devient faux un jour. Toujours prévoir un mécanisme d'invalidation, même si on ne compte pas l'utiliser.
- Cache L1 local sans cohérence. Plusieurs workers PHP avec leur propre cache en mémoire. Invalidation impossible sauf pub/sub. Nous évitons ce pattern sauf cas spécifique.
- Vary incorrect. Un header
Vary: Accept-Languageoublié, et tout le monde reçoit la langue du premier visiteur.
Conclusion
Le cache est une discipline à part entière, pas un simple bolt-on. Bien appliqué, il divise par 10 ou 100 la charge serveur et améliore l'expérience utilisateur de façon spectaculaire. Mal appliqué, il masque des bugs, sert des données obsolètes et transforme un incident local en incident global.
Pour un audit de stratégie de cache sur votre application PHP ou l'architecture d'un cache multi-niveau adapté à votre trafic, contactez-nous à contact@your-digital-hub.com. Voir aussi notre expertise PHP et notre service performance et scalabilité.