YOUR DIGITAL HUB
← Retour au blog

Stratégies de cache PHP : OPcache, Redis, Varnish, CDN et le problème de l'invalidation

· 11 min de lecture
Visuel de couverture — Stratégies de cache PHP

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: public sur une page contenant le nom de l'utilisateur. Fuite garantie. Toujours vérifier que la clé de cache inclut user_id ou tenant_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-Language oublié, 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é.