Retour au cours

backend / php-laravel

Cache et performance

Leçon 211 exercice

Explication

Ce que vous allez apprendre

  • Mettre en cache le résultat d'un calcul coûteux avec Cache::remember et prévoir son invalidation
  • Invalider un groupe entier d'entrées de cache d'un coup grâce aux tags
  • Éviter le "cache stampede" lors du recalcul concurrent d'une donnée très demandée
  • Sélectionner uniquement les colonnes nécessaires et traiter de gros volumes avec cursor()
  • Précompiler la configuration, les routes et les vues avant un déploiement en production

Dans quel contexte ?

La page d'accueil d'une boutique affiche les "10 produits les plus vendus", un calcul qui nécessite une agrégation coûteuse sur toute la table des ventes. Recalculer cette liste à chaque visite représente une charge inutile sur la base de données, alors que le classement ne change en réalité que quelques fois par jour. Le cache permet de calculer cette liste une fois, puis de servir instantanément toutes les requêtes suivantes pendant une durée définie.

D'abord, la mécanique de base du cache applicatif

Cache::remember('cle', $duree, function () { ... }) lit d'abord le cache : si la clé existe, la valeur est retournée immédiatement sans exécuter la closure. Sinon, la closure s'exécute, son résultat est stocké en cache pour la durée spécifiée, puis retourné. C'est le pattern à connaître par cœur, tant il revient dans presque toute application Laravel non triviale.

rememberForever retire la notion de durée — utile pour des données quasi statiques, mais qui doivent alors être invalidées explicitement dès qu'elles changent, jamais par expiration automatique.

Prérequis

Il faut avoir compris les relations Eloquent et le problème N+1 (leçon 11) : le cache et l'eager loading sont deux outils complémentaires de performance, pas interchangeables.

Ensuite, un cache qui n'est jamais invalidé devient un bug

Mettre en cache un résultat sans jamais prévoir de l'invalider crée un "cache stale" : les données affichées deviennent fausses dès que la source change. Un Observer sur le modèle (ProduitObserver::updated()) peut appeler Cache::forget() automatiquement à chaque modification pertinente, garantissant la fraîcheur des données mises en cache.

Méthode de cacheUsage
rememberCache avec expiration, recalcul après un délai
rememberForeverCache permanent, à invalider manuellement
forgetInvalide une clé précise
tags(...)->flush()Invalide tout un groupe logique de clés d'un coup

Piège fréquent

Mettre en cache une donnée sans jamais prévoir son invalidation est une des sources de bugs les plus difficiles à diagnostiquer en production : tout semble fonctionner en développement (où le cache expire vite ou est désactivé), mais les utilisateurs voient des données obsolètes en prod pendant des heures.

Il reste un problème de concurrence à connaître

Quand une donnée coûteuse à calculer expire, plusieurs requêtes simultanées peuvent toutes constater le cache vide en même temps et déclencher le même calcul lourd en parallèle — le "cache stampede". Cache::lock('cle', $duree)->block(...) garantit qu'une seule requête effectue réellement le recalcul, les autres attendant son résultat plutôt que de dupliquer le travail.

Maintenant, des optimisations qui ne dépendent pas du cache

Au-delà du cache, sélectionner uniquement les colonnes nécessaires (select([...])) réduit la quantité de données transférées depuis la base. cursor() traite les résultats un par un sans jamais les charger tous en mémoire simultanément — complémentaire à chunkById vu en leçon 10, pour des cas où l'ordre de traitement séquentiel importe.

Astuce

Model::preventLazyLoading(!app()->isProduction()) fait volontairement PLANTER l'application en développement dès qu'un problème N+1 non intentionnel se produit, plutôt que de le laisser dégrader silencieusement les performances en production. Un excellent réflexe à activer dès le début d'un projet.

Maintenant que l'application est rapide, il reste à s'assurer qu'elle est aussi sûre : la prochaine leçon couvre les failles de sécurité les plus courantes en PHP et Laravel, et comment s'en protéger systématiquement.

Commandes & code

Cache et performance

php
<?php
use Illuminate\Support\Facades\Cache;

// remember : lit le cache, sinon exécute la closure et met en cache le résultat
$produits = Cache::remember('produits.populaires', now()->addMinutes(15), function () {
    return \App\Models\Produit::withCount('ventes')
        ->orderByDesc('ventes_count')
        ->limit(10)
        ->get();
});

// rememberForever pour des données quasi statiques (à invalider explicitement)
$config = Cache::rememberForever('config.site', fn () => \App\Models\Configuration::pluck('valeur', 'cle'));

// Invalidation ciblée après une écriture (éviter le cache périmé / stale)
class ProduitObserver {
    public function updated(\App\Models\Produit $produit): void {
        Cache::forget("produit.{$produit->id}");
        Cache::forget('produits.populaires');
    }
}

// Tags de cache (Redis/Memcached uniquement) : invalider un groupe entier d'un coup
Cache::tags(['produits', "categorie.{$categorieId}"])->remember(
    "produits.categorie.{$categorieId}",
    3600,
    fn () => \App\Models\Produit::where('categorie_id', $categorieId)->get()
);
Cache::tags(['produits'])->flush(); // invalide tout ce qui porte le tag 'produits'

// Cache de requêtes lourdes avec verrou pour éviter le "cache stampede"
// (plusieurs requêtes concurrentes qui recalculent la même donnée expirée en même temps)
$resultat = Cache::lock('calcul.rapport.mensuel', 10)->block(5, function () {
    return Cache::remember('rapport.mensuel', 3600, fn () => genererRapportLourd());
});
php
<?php
// Optimisations Eloquent : éviter le N+1, sélectionner uniquement le nécessaire
$commandes = \App\Models\Commande::query()
    ->select(['id', 'client_id', 'total', 'cree_le']) // pas de SELECT * inutile
    ->with(['client:id,nom'])                          // eager load avec colonnes limitées
    ->latest('cree_le')
    ->cursor();                                          // curseur mémoire-efficace pour gros volumes

foreach ($commandes as $commande) {
    // traite une ligne à la fois sans tout charger en mémoire
}

// Index de base de données : vérifier via EXPLAIN que les requêtes fréquentes sont indexées
// Schema::table('commandes', fn ($t) => $t->index(['client_id', 'statut']));

// Lazy loading désactivé en dev pour détecter les N+1 non intentionnels
\Illuminate\Database\Eloquent\Model::preventLazyLoading(!app()->isProduction());
bash
# Commandes de préchauffage pour la production
php artisan config:cache      # config compilée en un seul fichier PHP
php artisan route:cache       # routes compilées (pas de closures dans les routes si activé !)
php artisan view:cache        # templates Blade précompilés
php artisan event:cache       # cache la découverte des events/listeners
php artisan optimize          # exécute les caches ci-dessus en une commande

Résumé

  • Cache::remember est le pattern de base ; toujours prévoir l'invalidation (observer, TTL cohérent).
  • Les tags de cache permettent d'invalider un groupe logique sans connaître chaque clé individuellement.
  • Cache::lock() évite le cache stampede lors du recalcul de données coûteuses très demandées.
  • preventLazyLoading() en dev fait planter les N+1 au lieu de les laisser dégrader la prod silencieusement.

Exercices pratiques

1 disponible
1

Mission : des prix perimes affiches pendant des heures

Objectif : Corriger un cache jamais invalide et proteger un recalcul lourd contre les acces concurrents.

Contexte

La liste produits.populaires est mise en cache avec Cache::remember pendant 15 minutes, mais un administrateur a modifie le prix d'un produit vedette il y a deux heures : le site continue d'afficher l'ancien prix. En parallele, un rapport mensuel tres couteux a calculer (genererRapportLourd()) est demande simultanement par cinq utilisateurs au moment precis ou son cache expire, ce qui a fait grimper la charge CPU du serveur en fleche pendant plusieurs secondes.

Résoudre l’exercice →