Gestion d’un cache SQL sous Zend Framework


  • Introduction

Nous allons voir comment éviter l’envoi inutile de requêtes SQL identiques lors de l’exécution d’un script PHP utilisant Zend Framework. L’idée est de mettre en cache le résultat de chaque nouvelle requête SQL. Pour ensuite être capable de renvoyer directement le résultat mis en cache dans le cas où les données de la base utilisées par cette requête n’ont pas été modifiées. En effet, si nous sommes capable de vérifier qu’aucun enregistrement des tables concernées par la requête n’a été modifié depuis la mise en cache, nous sommes certain de pouvoir renvoyer directement le résultat en cache puisque la sélection de données sera forcément identique. Attention toutefois, nous partons du principe que les requêtes ne comportent pas de conditions de sélection extérieures aux données elles-mêmes. Une requête qui comporterait par exemple une condition sur l’heure courante pourrait voir son résultat changer même si les données sur lesquelles portent la requête ne sont jamais modifiées. Il vous faudra donc veiller à utiliser à bon escient ce mécanisme de cache.

  • Comment connaître la dernière date de modification des tables grâce aux triggers :

Considérons la table MySQL suivante :

CREATE TABLE utilisateur (
  id BIGINT NOT NULL AUTO_INCREMENT,
  nom VARCHAR(255) DEFAULT NULL,
  PRIMARY KEY (id)
);

Nous avons besoin de conserver la dernière date de modification de cette table. Pour cela, nous allons créer une autre table appelée « modif_table » qui aura la structure suivante :

CREATE TABLE modif_table (
  nom_table CHAR(255) DEFAULT NULL,
  maj TIMESTAMP,
  PRIMARY KEY (nom_table),
  KEY (maj)
);

Grâce aux triggers, nous allons sauvegarder dans cette table « modif_table » la date de dernière modification de notre table « utilisateur ». Le champ « nom_table » aura donc ici la valeur « utilisateur » et le champ « maj » la date et heure de la dernière modification.

Voyons maintenant les instructions MySQL pour mettre en place les triggers :

delimiter //
CREATE TRIGGER trig_apres_insert_utilisateur AFTER INSERT ON utilisateur FOR EACH ROW
BEGIN
  INSERT INTO modif_table (nom_table, maj) VALUES ('utilisateur', NOW()) ON DUPLICATE KEY UPDATE maj=NOW();
END;
//
CREATE TRIGGER trig_apres_update_utilisateur AFTER UPDATE ON utilisateur FOR EACH ROW
BEGIN
  INSERT INTO modif_table (nom_table, maj) VALUES ('utilisateur', NOW()) ON DUPLICATE KEY UPDATE maj=NOW();
END;
//
CREATE TRIGGER trig_apres_delete_utilisateur AFTER DELETE ON utilisateur FOR EACH ROW
BEGIN
  INSERT INTO modif_table (nom_table, maj) VALUES ('utilisateur', NOW()) ON DUPLICATE KEY UPDATE maj=NOW();
END;
//
delimiter ;

Pour chaque événement (insertion, modification et suppression d’un enregistrement) qui peut avoir pour effet de modifier les données de la table « utilisateur », la date de dernière modification est mise à jour dans la table « modif_table ». Notez l’utilisation d’une requête « INSERT…ON DUPLICATE » qui permet soit d’insérer un enregistrement s’il n’existe pas déjà soit de le mettre à jour s’il est déjà présent dans la table. En effet, l’enregistrement qui stocke la dernière modification pour la table « utilisateur » n’est pas obligatoirement déjà présent dans la table « modif_table ».

Nous pouvons maintenant tester si nos triggers fonctionnent comme nous le souhaitons. Après avoir créé les deux tables et les triggers vus précedemment dans une base de données, nous allons tout d’abord créer un premier enregistrement dans la table « utilisateur » :

mysql> INSERT INTO utilisateur (nom) VALUES ('Linus Torvald');
Query OK, 1 row affected (0.03 sec)

mysql> SELECT * FROM utilisateur;
+----+---------------+
| id | nom           |
+----+---------------+
|  1 | Linus Torvald |
+----+---------------+
1 row in set (0.00 sec)

mysql> SELECT * FROM modif_table;
+-------------+---------------------+
| nom_table   | maj                 |
+-------------+---------------------+
| utilisateur | 2008-08-19 22:14:53 |
+-------------+---------------------+
1 row in set (0.00 sec)

On peut constater que l’insertion de l’enregistrement « utilisateur » a provoqué la création d’un enregistrement dans la table « modif_table » comme nous l’avons défini grâce aux triggers.

Modifions ensuite l’enregistrement « utilisateur » :

mysql> UPDATE utilisateur SET nom='Donald Knuth' WHERE id=1;
Query OK, 1 row affected (0.02 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> SELECT * FROM modif_table;
+-------------+---------------------+
| nom_table   | maj                 |
+-------------+---------------------+
| utilisateur | 2008-08-19 22:18:19 |
+-------------+---------------------+
1 row in set (0.00 sec)

Là encore, nos triggers ont joué leurs rôles. Nous pouvons constater que la valeur du champ « maj » de la table « modif_table » a été modifiée pour refléter la mise à jour de l’enregistrement « utilisateur ».

Enfin, testons la suppression d’un enregistrement :

mysql> DELETE FROM utilisateur WHERE id=1;
Query OK, 1 row affected (0.00 sec)

mysql> SELECT * FROM modif_table;
+-------------+---------------------+
| nom_table   | maj                 |
+-------------+---------------------+
| utilisateur | 2008-08-19 22:22:21 |
+-------------+---------------------+
1 row in set (0.00 sec)

La date et l’heure de modification ont bien été modifiées.

  • Définition de la classe Zend Framework

Nous avons donc maintenant tout ce qui nous est nécessaire en ce qui concerne la base de données. Il nous reste maintenant à l’utiliser sous Zend Framework.

Première étape, nous avons besoin d’une classe dédiée pour prendre en charge la gestion de la table « modif_table ». Nous avons uniquement besoin d’une méthode qui nous indiquera s’il est possible d’utiliser le cache. Plus précisément, une méthode qui nous indiquera si aucune des tables utilisées dans la requête de sélection n’a été modifiée depuis la mise en cache du résultat.

Voici donc notre classe qui sera enregistrée dans un fichier appelé « sql_modif_table.php » :

require_once('Zend/Db/Table/Abstract.php');
require_once('Zend/Db/Select.php');

class Sql_Modif_Table {
  // nom de la table SQL
  protected $_name = 'modif_table';
  // nom du champ clef primaire
  protected $_primary = 'nom_table';

  // indique si aucune table n'a été modifiée depuis un temps donné
  // renvoie faux si au moins une table a été modifiée, vrai sinon
  // le premier paramètre est un tableau qui donne les noms des tables
  // le deuxième paramètre est le temps unix utilisé pour la comparaison avec le temps de modification des tables
  public function no_update(array $table, $time) {
    // par défaut, renvoie FALSE (au moins une table a été modifiée)
    $r = FALSE;
    // il faut au moins une table et un temps
    if (is_array($table) && (count($table) > 0) && $time) {
      $db = Zend_Db_Table_Abstract::getDefaultAdapter();
      // on met entre quote le nom des tables
      $tab_quote_table = array();
      foreach ($table as $t) {
        array_push($tab_quote_table, $db->quote($t));
      }
      // on construit la partie IN de la requête
      $sql_in = implode(',', $tab_quote_table);
      $sql_in = '('.$sql_in.')';
      // on construit la requête
      $select = $db->select();
      $select->from($this->_name, array('nb' => 'COUNT(*)'))
                 ->where('nom_table IN '.$sql_in)
                 ->where('UNIX_TIMESTAMP(maj) <= ?', $time);
      $stmt = $select->query();
      $row = $stmt->fetch();
      if ($row) {
        $nb = $row['nb'];
        // si le nombre renvoyé par la requête est égal au nombre de tables,
        // cela signifie qu'aucune table n'a été modifiée depuis le temps passé en paramètre
        if ($nb == count($tab_quote_table)) {
          $r = TRUE;
        }
      }
    }
    return($r);
  }
}

Cette méthode « no_update() » construit une requête SQL à partir de ses paramètres afin de vérifier si une des tables contenues dans la variable « $table » a été modifiée après le temps (format Unix) indiqué dans la variable « $time ». Par exemple, si la méthode est appelée avec les paramètres suivants :

...
$table = array('table1', 'table2');
$time = '1219422874';
$r = $o->no_update($table, $time);
...

La requête SQL sera :

SELECT COUNT(*) AS nb FROM modif_table WHERE nom_table IN ( 'table1', 'table2' ) AND UNIX_TIMESTAMP ( maj ) <= '1219422874';
&#91;/sourcecode&#93;

Deuxième étape, nous allons maintenant surcharger la méthode Zend_Db_Select::query() afin de faire en sorte que toutes les requêtes "SELECT" envoyées bénéficient du cache si cela est possible. Voici donc notre classe qui sera enregistrée dans un fichier appelé "db_select.php" :

&#91;sourcecode language='php'&#93;
require_once('Zend/Db/Select.php');
require_once('sql_modif_table.php');

class Db_Select extends Zend_Db_Select {

  // la méthode envoie la requête vers le serveur de base de données
  // seulement s'il n'est pas possible d'utiliser le cache
  private function query_cache($fetchMode) {
    // tableau qui stocke le resultat des requêtes
    // la clef du tableau est la signature MD5 de la requête SELECT
    static $cache_value = array();
    // tableau qui stocke la date et l'heure de l'envoi des requêtes
    // la clef du tableau est la signature MD5 de la requête SELECT
    static $cache_time = array();

    // signature MD5 de la requête
    $sql = $this->__toString();
    $signature = md5($sql);
    $r = null;
    // par défaut, n'utilise pas le cache
    $use_cache = false;
    if (isset($cache_value[$signature]) && isset($cache_time[$signature])) {
      // la requête a déjà été envoyée, on vérifie si l'on peut utiliser le résultat mis en cache
      $table_update = new Sql_Modif_Table;
      // on récupère la liste des tables sur lesquelles porte la requête
      $part_from_sql = $this->getPart(Zend_Db_Select::FROM);
      $tables_in_sql = array();
      foreach ($part_from_sql as $from) {
        $t = $from['tableName'];
        array_push($tables_in_sql, $t);
      }
      // peut-on utiliser le cache ?
      if ($table_update->no_update($tables_in_sql, $cache_time[$signature])) {
        // oui, donc le résultat renvoyé sera celui présent en cache
        $r = $cache_value[$signature];
        $use_cache = true;
      }
    }
    if (! $use_cache) {
      // le cache ne peut être utilisé
      $time_cache = time();
      // donc on envoie la requête SELECT à la base de données
      $r = parent::query($fetchMode);
      // et on stocke ce nouveau résultat dans le cache
      $cache_value[$signature] = $r;
      $cache_time[$signature] = $time_cache;
    }
    return($r);
  }

  // méthode surchargée afin d'utiliser la méthode avec cache
  public function query($fetchMode = null) {
    $r = $this->query_cache($fetchMode);
    return($r);
  }

}

Nous allons également surcharger la classe Zend_Db_Table_Abstract afin de disposer d’une classe qui interdit l’utilisation directe de la méthode Zend_Db_Table_Abstract::select(). Cette nouvelle classe sera stockée dans un fichier appelé « db_table.php » :

require_once('Zend/Db/Table/Abstract.php');

class Db_Table extends Zend_Db_Table_Abstract {

  public function insert(array $data) {
    return parent::insert($data);
  }

  public function update(array $data, $where) {
    return parent::update($data, $where);
  }

  public function delete(array $where) {
    return parent::delete($where);
  }

  // on interdit l'utilisation directe de cette méthode
  public function select() {
    die('Db_Table::select():Don\'t use this method !');
  }

}
  • Intégration dans une application Zend Framework :

Si vous utilisez le modèle MVC que propose Zend pour votre application, les trois fichiers que nous avons vu précédemment seront typiquement à placer dans le répertoire « application/models/ ».

L’appel aux méthodes que nous avons définies se fera par exemple de la manière suivante :

...
$db = Db_Table::getDefaultAdapter();
$select = new Db_Select($db);
$select->from($this->tableName)
           ->where('champ = ?', $valeur);
$stmt = $select->query();
$row = $stmt->fetch();
...

Vous remarquerez l’utilisation des classes « Db_Table » et « Db_Select » que nous avons créées. De cette manière, nous sommes certain que les requêtes utiliseront le mécanisme de cache que nous avons mis en place.

  • Pour aller plus loin…

Comme les données du cache sont stockées dans un tableau, le cache n’est actif que pendant l’exécution du script PHP. Il ne sera donc utile que si vous exécutez plusieurs fois les mêmes requêtes « SELECT » dans un même script. Ce qui est évidemment assez limité… Néanmoins, il serait tout à fait possible en modifiant uniquement la méthode Db_Select::query_cache() de rendre le cache persistant et ainsi de le partager pour tous les scrips PHP de votre application. Une possibilité intéressante serait par exemple de gérer ce cache avec le logiciel « memcached » qui permet de stocker en mémoire vive des données via un mécanisme client/serveur. Une librairie pour développer des clients « memcached » en PHP est disponible.

Par ailleurs, une bien meilleure implémentation serait d’utiliser la classe Zend_Cache proposée dans Zend Framework pour la gestion proprement dite du cache. Il serait ainsi possible d’utiliser les différents backends déjà disponibles et en particulier le backend « memcached ».

  • Références :

Triggers sous MySQL : http://dev.mysql.com/doc/refman/5.0/fr/triggers.html

Documentation Zend Framework : http://framework.zend.com/

Memcached : http://www.danga.com/memcached/

Publicités

2 réflexions sur “Gestion d’un cache SQL sous Zend Framework

  1. Il serait intéressant de mesurer les perfs de cette méthode.

    Car le moteur de base de données met lui aussi les données en cache et que dans tous les cas, chaque requête « cachée » impose de vérifier la date de dernière modification de la table à laquelle elle fait référence.

    A voir si le fait de bien régler le cache du moteur de base de données ne donnent pas des résultats équivalent voir supérieur, sans nécessité de mettre en place cette solution qui me semble tout de même assez lourde à gérer.

    Mais je peux me tromper !

    • Ce serait effectivement intéressant. La méthode proposée permet en tout cas d’éviter l’envoi des données depuis la base vers le client PHP si le cache est utilisé. Par ailleurs, le coût en terme de performance pour effectuer la requête de test sur les dates de modification est faible. D’autant que la table dédiée (modif_table) aura justement toutes les chances d’être dans le cache du serveur MySQL.

      Cela dit, la méthode devient évidemment encore plus intéressante si vous mettez en cache non plus le résultat de la requête mais le résultat du traitement effectué sur les données retournées par cette requête. Par exemple, dans le cas d’une application web, le code HTML ou le code JSON qui serait envoyé au navigateur web.

Laisser un commentaire

Entrez vos coordonnées ci-dessous ou cliquez sur une icône pour vous connecter:

Logo WordPress.com

Vous commentez à l'aide de votre compte WordPress.com. Déconnexion / Changer )

Image Twitter

Vous commentez à l'aide de votre compte Twitter. Déconnexion / Changer )

Photo Facebook

Vous commentez à l'aide de votre compte Facebook. Déconnexion / Changer )

Photo Google+

Vous commentez à l'aide de votre compte Google+. Déconnexion / Changer )

Connexion à %s