Cover for Utiliser Redis pour stocker le cache de Doctrine

Utiliser Redis pour stocker le cache de Doctrine

La meilleur requête est celle que l’on a pas à faire — un inconnu

Dans mon actuelle mission, nous développons une application sous Symfony2 avec Doctrine comme ORM. Voici grossièrement à quoi ressemble l’architecture globale :

architecture globale

Comme vous le voyez, l’application est dédoublée sur deux serveurs distincts qui ont interdiction de se parler, en dehors de la base de données. Nous avons été confrontés à des problématiques de performances qui nous a contraint d’utiliser le cache de Doctrine.

Il faut savoir qu’il y a 3 types de cache pour Doctrine :

  • Query Cache : transformation DQL -> SQL;
  • Result Cache : résultat de la requête;
  • Metadata Cache : annotation des entities.

Si on regarde la liste des drivers, on s’aperçoit qu’il n’est pas évident de mutualiser du cache entre plusieurs serveur (qui ne peuvent pas communiquer directement ensemble).

C’est là que Redis arrive ♥

En quelques mots, Redis (pour REmote DIctionary Server) est un SGBD clé-valeur qui s’inscrit dans la mouvance NoSQL. En plus d’être simple d’utilisation, sa performance qui ferait pâlir Usain Bolt. Cela est principalement dû au fait que tout est persisté dans le cache du serveur. Si vous doutez encore de cette dernière phrase, sachez que YouPorn, Stack Overflow, Github… l’utilisent ;-)

Voici un exemple de fonctionnement :

Terminal window
> SET name "maxence"
OK
> GET name
"maxence"
> KEYS *
1) "name"
# Assigner un TTL sur une variable
> SETEX mission 10 "votre mission si vous l'acceptez... ce message s'autodétruira dans 10s"
OK
# 10' après, le couple clef/valeur a disparu
> GET mission
(nil)

Installation

L’installation de Redis est plutôt simple et se fait en quelques lignes de commande :

Terminal window
# download
wget http://download.redis.io/redis-stable.tar.gz
tar xvzf redis-stable.tar.gz
cd redis-stable
make
# install
sudo make install
# start
redis-server

Il va ensuite falloir installer les bundles qui vont bien :

Terminal window
composer require snc/redis-bundle 2.x-dev
composer require predis/predis ^1.0

Et on modifie le app/AppKernel.php:

<?php
public function registerBundles()
{
$bundles = array(
// ...
new Snc\RedisBundle\SncRedisBundle(),
// ...
);
//...
}

Dans le config.yml :

imports:
- { resource: redis.yml }
# Doctrine Configuration
doctrine:
dbal:
#...
orm:
auto_generate_proxy_classes: '%kernel.debug%'
naming_strategy: doctrine.orm.naming_strategy.underscore
# IMPORTANT!
auto_mapping: true
metadata_cache_driver: redis
query_cache_driver: redis
# redis.yml
snc_redis:
clients:
default:
type: predis
alias: default
dsn: redis://1.2.3.4
doctrine:
type: predis
alias: doctrine
dsn: redis://1.2.3.4
doctrine:
metadata_cache:
client: doctrine
entity_manager: default
document_manager: default
result_cache:
client: doctrine
entity_manager: default
query_cache:
client: doctrine
entity_manager: default

Et voilà pour l’installation.
A ce stade, seuls les caches de metadata et de query sont opérationnels. Pour la mise en cache du résultat, il faudra le faire manuellement sur chaque requête.

Mettre en cache le résultat

Terminé les requêtes inlines dans les controllers ! Vous allez désormais devoir utiliser le DQL ou le QueryBuilder.

<?php
public function findBeers()
{
$query = $this->getEntityManager()
->createQuery(
'select beers from MaxpouBeerBundle:Beers b'
)
;
$query->useResultCache(true);
$query->setResultCacheLifetime(3600); //3600sec = 1 hour
return $query->getResult();
}

Maintenant, si l’on recharge la page, cette requête ne se fera plus via MySQL mais bien via Redis. On peut vérifier tout cela en allant sur Redis et en rentrant la commande suivante : KEYS *. Voici ce que l’on va avoir :

Terminal window
> KEYS *
1) "[Maxpou\\BeerBundle\\Entity\\Beer$CLASSMETADATA][1]"
2) "[809cd863587594a754a7ffda5c2c06ee4640ebe3][1]"

La première ligne va contenir les métadonnées de la classe Beer. La seconde, contiendra la requête et son résultat. Si vous voulez avoir une clé un peu plus digeste, vous pouvez utiliser cette méthode $query->setResultCacheId('my_wonderful_key'); ou même faire 1 pierre 3 coups : $query->useResultCache(true, 3600, 'my_wonderful_key');.

Voici ce que nous aurons : (je n’ai pas réussi à supprimer le [1])

Terminal window
> KEYS *
1) "[Maxpou\\BeerBundle\\Entity\\Beer$CLASSMETADATA][1]"
2) "[my_wonderful_key][1]"

Pour nettoyer le cache, voici quelques commandes :

Terminal window
# Nettoyer cache des queries
php app/console doctrine:cache:clear-query
# Nettoyer cache des metadatas
php app/console doctrine:cache:clear-metadata
# Nettoyer cache des résultats
php app/console doctrine:cache:clear-result
# Vider la base redis
php app/console redis:flushdb

Invalider le cache des requêtes

C’est bien de mettre en place un système de cache, mais vous n’allez pas demander à vos utilisateurs de lancer la commande après chaque opération. Il va donc falloir utiliser les événements de Doctrine, et plus particulièrement l’entity listeners Si vous faites des tests, pensez à commenter le cache des métadonnées ;-)

Définissez le service :

<?php
namespace Maxpou\BeerBundle\Service;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Maxpou\BeerBundle\Entity\Beer;
class BeerListener
{
private $cacheDriver;
public function __construct($cacheDriver)
{
$this->cacheDriver = $cacheDriver;
}
public function postPersist(Beer $beer, LifecycleEventArgs $args)
{
$this->cacheDriver->expire('[beers_all][1]', 0);
}
public function postUpdate(Beer $beer, LifecycleEventArgs $args)
{
$this->cacheDriver->expire('[beers_all][1]', 0);
}
public function postRemove(Beer $beer, LifecycleEventArgs $args)
{
$this->cacheDriver->expire('[beers_all][1]', 0);
}
}

Ici, beer_all est le nom de l’id assigné au cache.

Le service.yml :

beer_listener:
class: Maxpou\BeerBundle\Service\BeerListener
arguments:
- '@snc_redis.doctrine'
tags:
- { name: doctrine.orm.entity_listener }

Et enfin l’annotation sur l’entity :

<?php
/**
* Beer
*
* @ORM\Table(name="beers")
* @ORM\Entity(repositoryClass="Maxpou\BeerBundle\Repository\BeerRepository")
* @ORM\EntityListeners({"Maxpou\BeerBundle\Service\BeerListener"})
*/
class Beer implements BeerInterface
{
//...

Les plus pointilleux d’entre vous auront remarqués que je redéfini le TTL de ma clef au lieu de la supprimer. En effet, si je supprime ma clef, la nouvelle clef créée sera [beers_all][2]. Et le compteur augmentera ainsi de suite…
Avec cette technique de fainéant, on garde la main sur le nom de la clef qui sera toujours [beers_all][1].

Pour aller plus loin

Tips

Utiliser les pipes unix avec le redis-cli : echo "KEYS *" | ./path/to/redis-cli | grep beer
Vérifier le TTL d’une clef: TTL [beer-id-42][1]


About the author

Maxence Poutord

Hey, I'm Maxence Poutord, a passionate software engineer. In my day-to-day job, I'm working as a senior front-end engineer at Orderfox. When I'm not working, you can find me travelling the world or cooking.

Follow @_maxpou

Recommended posts