Redis : stratégies de cache avancées
Redis est bien plus qu'un simple cache clé-valeur. Voici les stratégies pour en tirer le maximum.
Stocké en mémoire, Redis répond en général en moins d'une milliseconde, là où une requête SQL complexe peut prendre des dizaines de millisecondes. Mettre en cache le résultat des opérations coûteuses (requêtes lourdes, appels à une API externe, calculs d'agrégats) soulage la base de données et réduit fortement le temps de réponse. Mais un cache mal pensé crée aussi ses propres bugs : données périmées affichées aux utilisateurs, pics de charge à l'expiration d'une clé, mémoire saturée. Le choix de la stratégie compte autant que l'outil.
Que mettre en cache ?
Un bon candidat est une donnée lue beaucoup plus souvent qu'elle n'est modifiée, coûteuse à produire, et qui tolère un léger retard : catalogue produits, pages de contenu, configuration, résultats d'API tierces. À l'inverse, un solde de compte, un stock en temps réel ou des données propres à un utilisateur et rarement relues sont de mauvais candidats : la complexité de l'invalidation dépasse souvent le gain.
Patterns de cache
Cache-Aside (Lazy Loading)
C'est le pattern le plus courant : l'application consulte d'abord le cache ; en cas d'absence (cache miss), elle lit la base de données puis stocke le résultat avec une durée de vie. Seules les données réellement demandées finissent en cache.
class ProductService
{
public function getProduct(int $id): Product
{
$cacheKey = "product:{$id}";
$cached = $this->redis->get($cacheKey);
if ($cached !== false) {
return unserialize($cached);
}
$product = $this->repository->find($id);
$this->redis->setex($cacheKey, 3600, serialize($product));
return $product;
}
}
Deux remarques sur ce code. D'abord, il utilise l'extension phpredis, qui renvoie false pour une clé absente ; avec Predis, qui renvoie null, testez plutôt null. Ensuite, sérialiser une entité Doctrine complète est fragile (proxies, collections chargées à la demande, changement de structure de la classe entre deux déploiements). Je recommande de mettre en cache un tableau ou un DTO simple plutôt que l'entité elle-même.
Write-Through
Ici, chaque écriture met à jour la base et le cache dans la même opération. Le cache reste chaud, et la lecture suivante n'a pas besoin de toucher la base.
public function updateProduct(Product $product): void
{
$this->repository->save($product);
$this->redis->setex(
"product:{$product->getId()}",
3600,
serialize($product)
);
}
L'ordre est important : la base de données est la source de vérité, elle est donc écrite en premier. Si l'écriture dans Redis échoue ensuite, le cache contient une version périmée jusqu'à l'expiration du TTL. Pour cette raison, beaucoup d'équipes préfèrent une variante plus simple : supprimer la clé après l'écriture (DEL) et laisser le cache-aside la reconstruire à la prochaine lecture. On évite ainsi d'écrire en cache des données que personne ne relira.
Cache avec Symfony
Dans une application Symfony, il est rarement utile de manipuler Redis directement : le composant Cache fournit des pools configurables, la gestion des tags et une protection contre les pics de charge.
# config/packages/cache.yaml
framework:
cache:
pools:
app.cache.products:
adapter: cache.adapter.redis
default_lifetime: 3600
provider: 'redis://redis:6379'
tags: true
L'option tags: true rend le pool compatible avec les tags, ce qui est indispensable pour appeler $item->tag(). Le pool s'injecte alors sous la forme d'un TagAwareCacheInterface, et l'attribut #[Target] permet de le choisir explicitement :
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
final class ProductCatalog
{
public function __construct(
#[Target('app.cache.products')]
private readonly TagAwareCacheInterface $cache,
private readonly ProductRepository $repository,
) {
}
/** @return list<array{id: int, name: string}> */
public function all(): array
{
return $this->cache->get('products_list', function (ItemInterface $item): array {
$item->tag(['products']);
return array_map(
static fn (Product $p): array => ['id' => $p->getId(), 'name' => $p->getName()],
$this->repository->findAll(),
);
});
}
}
La méthode get() implémente le cache-aside à votre place : la fonction de rappel n'est exécutée qu'en cas d'absence de la clé. Elle protège aussi contre le cache stampede (des dizaines de requêtes qui recalculent la même valeur au même instant, juste après son expiration) grâce à un verrou et à une expiration anticipée probabiliste, réglable avec le troisième argument $beta.
Remarquez que le résultat mis en cache est un simple tableau, et non une liste d'entités : il se sérialise sans surprise et reste valide même si l'entité évolue.
Invalidation du cache
- TTL : expiration automatique après un délai
- Tags : invalidation par groupe avec les tags Symfony
- Events : invalidation sur événement Doctrine
- Versioning : clés de cache versionnées
Le TTL est le filet de sécurité : même si une invalidation est oubliée, la donnée finira par se rafraîchir. Choisissez-le selon le retard acceptable pour le métier, pas au hasard. Les tags et les événements Doctrine se combinent très bien : un écouteur d'entité invalide les tags concernés dès qu'un produit est créé, modifié ou supprimé.
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Events;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
#[AsEntityListener(event: Events::postPersist, method: 'invalidate', entity: Product::class)]
#[AsEntityListener(event: Events::postUpdate, method: 'invalidate', entity: Product::class)]
#[AsEntityListener(event: Events::preRemove, method: 'invalidate', entity: Product::class)]
final class ProductCacheInvalidator
{
public function __construct(
#[Target('app.cache.products')]
private readonly TagAwareCacheInterface $cache,
) {
}
public function invalidate(Product $product): void
{
$this->cache->invalidateTags(['products', 'product_'.$product->getId()]);
}
}
On écoute preRemove plutôt que postRemove, car après la suppression Doctrine peut remettre l'identifiant de l'entité à null. Le versioning, enfin, consiste à inclure une version dans la clé (product:v2:42). Changer de version rend toutes les anciennes clés inaccessibles d'un coup, ce qui est pratique quand le format des données en cache évolue lors d'un déploiement ; les anciennes clés disparaissent ensuite d'elles-mêmes grâce à leur TTL.
Configurer Redis pour un usage cache
Sans limite de mémoire, Redis grossit jusqu'à épuiser la RAM du serveur. Pour une instance dédiée au cache, fixez une limite et une politique d'éviction dans redis.conf :
maxmemory 512mb
maxmemory-policy allkeys-lru
Avec allkeys-lru, Redis supprime les clés les moins récemment utilisées quand la limite est atteinte. Si la même instance contient aussi des sessions ou des files de messages, cette politique pourrait les effacer : utilisez alors volatile-lru (qui n'évince que les clés ayant un TTL) ou, mieux, des instances séparées.
Mesurer l'efficacité du cache
Un cache se juge à son taux de succès. La commande INFO stats fournit les compteurs keyspace_hits et keyspace_misses :
redis-cli INFO stats | grep -E 'keyspace_(hits|misses)'
redis-cli INFO memory | grep used_memory_human
redis-cli --bigkeys
Un taux de succès faible signale des TTL trop courts, des clés trop spécifiques ou des données peu relues. --bigkeys parcourt la base avec SCAN et repère les clés les plus volumineuses.
Pièges fréquents
KEYS *en production : cette commande bloque Redis le temps de parcourir toutes les clés. UtilisezSCAN, ou mieux, les tags.- Clés sans TTL : elles s'accumulent indéfiniment. Tout ce qui est en cache devrait avoir une durée de vie.
- Cache comme source de vérité : l'application doit continuer à fonctionner, plus lentement, si Redis est vidé ou indisponible.
- Clés sans espace de noms : préfixez vos clés (
app:product:42) pour éviter les collisions entre applications partageant la même instance.
En résumé
Commencez par le cache-aside avec un TTL raisonnable, appuyez-vous sur le composant Cache de Symfony plutôt que sur des appels Redis bruts, invalidez par tags à partir des événements Doctrine, et surveillez le taux de succès. Un cache simple et bien invalidé vaut mieux qu'une architecture sophistiquée qui sert des données périmées.