Una de las últimas cosas que he incorporado al gestor de bitácoras Gesbit es la posibilidad de "cachear", de guardar para su posterior uso, ciertas consultas SQL se llevan a cabo. Me gustaría comentar algo sobre este asunto, por si pudiera serle de utilidad a alguien. Se trata de una forma de caché muy sencillo, pero también útil, que se limita a guardar el resultado de las consultas de tipo SELECT, SHOW, DESCRIBE y EXPLAIN, pero, ojo, no todas ellas, como luego se verá.

La idea es la siguiente. Para conformar una determinada página de una bitácora gestionada con Gesbit, por ejemplo, esta que lees ahora, es necesario realizar una serie de consultas SQL, las menos posibles, pero, por ejemplo, tenemos que obtener el nombre de la categoría de la bitácora por la que el lector está navegando en un momento dado. Para esto necesitamos recurrir a la base de datos, y así se hace, pero, ¿qué pasa si queremos mostrar el nombre de la categoría varias veces en una misma página?

Ahí es donde entra en juego el caché de las consultas SQL, precisamente, de este tipo de consultas, de manera que pueda mostrarse en una misma página el resultado qeu nos interesa, sin tener que hacer tantas consultas SQL como tantos lugares en que queremos mostrar el dato que necesitamos. El caché implementado en Gesbit es tiene un mecanismo de expiración muy sencillo: dura, lo que dura la ejecución del "script". Con esto sirve perfectamente con su cometido.

Por otro lado la forma en que se lleva a cabo el caché del resultado de las consultas SQL es muy sencilla. En Gesbit se utiliza una adaptación de una clase que, si no usáis, al menos os sonará de algo: la clase ezSQL de Justin Vincent. Esta clase para PHP es utilizada en multitud de proyectos. Yo, personalmente, desde que la conocí, me olvidé de las funciones nativas para trabajar con MySQL desde PHP. Pues bien, existe en esta clase un método muy especial: el encargado de realizar las consultas SQL, propiamente dichas.

Este es el punto, por lo tanto, en que hay que comprobar si una consulta SQL de las que interesa guardar sus resultados, ha sido ya realizada, o aún no se ha hecho y es menester hacerla y guardar su resultado en el caché, para sucesivas ocasiones. De hecho el método "Execute()" (así se llama al método "principal" en la adaptación de la clase para Gesbit) comienza con la siguiente instrucción condicional:

if(!$this->IsQueryInCache($sql)){
 
}

Como puede verse, utilizamos otro método para averiguar si una consulta SQL está ya guardada en el caché o no lo está. El método en cuestión es "IsQueryInCache()", y es un método público, puesto que considero de utilidad al mismo más allá de para la propia clase MySql de que vengo hablando. Esta es la implementación del método:

public function IsQueryInCache($sql){
  $sqlHash = $this->GetSqlQueryHash($sql);
  if(!empty($this->cachedQueries) && $this->QueryIsCachable($sql)){
    return in_array($sqlHash, array_keys($this->cachedQueries));
  }else{
    return false;  
  }
}

La implementación del método nos dice varias cosas, pero, básicamente, se trata de comprobar si la consulta se SQL ya se ejecutó antes y sus resultados fueron guardados. Como puedes ver, el caché de las consultas se guarda en una variable de la clase, la variable privada "cachedQueries". Esta variable es un Array asociativo, de claves únicas, puesto que estas no son más que una "firma" (hash) de las consultas SQL, propiamente dichas.

Primero comprobamos que el Array en que guardamos la caché de las consultas SQL no está vacío, porque, de ser así, será evidente que la consulta en cuestión no está guardada aún. La siguiente condición de la instrucción utiliza otro método de la clase MySql, "QueryIsCachable()", que verdaderamente es el que ha inspirado este aburrido texto, puesto que es el que considero más "reseñable", si alguno lo es.

Pero antes de ver el método "QueryIsCachable()", veamos el método "GetSqlQueryHash()". Es un método privado, que nos sirve para obtener la "firma" de una determinada consulta SQL, de modo que podamos usarla unívocamente como clave del Array en que guardaremos los resultados de las consultas. Cada clave del Array corresponde a una consulta SQL, que conoceremos por su "firma", creada a partir de la propia consulta, mediante el método "GetSqlQueryHash()". Aquí la implementación de este método:

private function GetSqlQueryHash($sql){
  return strtolower(substr(md5($sql), 1, 10));
}

Como puedes ver se obtiene el MD5 de la cadena que contiene la consulta SQL, y de este retornamos sólo los diez primeros caracteres, puesto que los consideramos suficientes como para conformar la clave unívoca de la consulta SQL en cuestión. Se usa este método "GetSqlQueryHash()" y no directamente las funciones correspondientes, porque necesitaremos la firma de las consultas SQL en varios lugares, para comodidad nuestra y un mejor mantenimiento del código.

Pero volvamos a "IsQueryInCache()", mostrado más arriba. Decía que "QueryIsCachable()", usado en el anterior, me parece el método más reseñable, porque, es importante asegurarnos de no guardar en caché consultas que no deseemos guardar en caché. En principio, se trata de guardar consultas del tipo SELECT, SHOW, DESCRIBE y EXPLAIN. La clase MySQL, no obstante, cuenta con sendos métodos que activan y desactivan el caché de las consultas, porque a veces será necesario asegurarse de que accedemos a la base de datos y no al caché de resultados previamente guardados.

La implementación de "QueryIsCachable()" está basada en parte en información que encontré en el artículo MySQL's Query Cache, de Ian Gilfillan. Sobre todo en lo que toca a evitar el caché de consultas SQL que, aunque del tipo SELECT, por ejemplo, utilice funciones como "NOW" o "RAND", que retornan la fecha y hora actuales, y un número aleatorio, datos que no tiene sentido guardar en caché, pues no serían válidos para sucesivas ocasiones. La implementación del método "QueryIsCachable()" es esta:

  private function QueryIsCachable($sql){
    $sql = strtolower(trim($sql));
    return $this->useQueriesCache && 
      (
        (substr($sql, 0, 6) == 'select') ||         
        (substr($sql, 0, 4) == 'show') ||
        (substr($sql, 0, 8) == 'describe') ||                           
        (substr($sql, 0, 7) == 'explain')                  
      )
      && (strpos($sql,'found_rows') == 0)
      && (strpos($sql,'rand(') == 0)
      && (strpos($sql,'now(') == 0)    
      && (strpos($sql,'curdate(') == 0)
      && (strpos($sql,'current_date(') == 0)
      && (strpos($sql,'current_time(') == 0)
      && (strpos($sql,'current_timestamp(') == 0)
      && (strpos($sql,'curtime(') == 0)
      && (strpos($sql,'sysdate(') == 0) 
      && (strpos($sql,'unix_timestamp(') == 0) 
      && (strpos($sql,'benchmark(') == 0)
      && (strpos($sql,'connection_id(') == 0)
      && (strpos($sql,'database(') == 0)      
      && (strpos($sql,'encrypt(') == 0)    
      && (strpos($sql,'get_lock(') == 0)    
      && (strpos($sql,'last_insert_id(') == 0)    
      && (strpos($sql,'load_file(') == 0)    
      && (strpos($sql,'master_pos_wait(') == 0)          
      && (strpos($sql,'release_lock(') == 0) 
      && (strpos($sql,'user(') == 0) 
      && (strpos($sql,'in share mode') == 0) 
      && (strpos($sql,'into outfile') == 0) 
      && (strpos($sql,'into dumpfile') == 0) 
      && (strpos($sql,'in share mode') == 0);    
  }

Quizá busquemos algunas funciones que no usaremos habitualmente, o no usaremos, sin más, pero, la clase MySql de Gesbit no está pensada sólo para Gesbit, y, por otro lado, no pienso que esté demás tener en cuenta dichas funciones de MySql cuyos resultados no debemos guardar en el caché. No obstanta, el orden de las condiciones no es azaroso: están primero las que presumiblemente se utilizarán más, de modo que no haga falta llegar hasta el final de las condiciones para comprobar que una consulta es o no "cacheable".

Ya estoy terminando. Pero regresemos al método "Execute()". Hemos visto que lo primero que se hace es comprobar si una consulta SQL ha sido guardada ya en el caché. Veamos qué hacemos tanto si no lo ha sido como si efectivamente sus resultados ya han sido guardados antes. En realidad, en lo que toca a este punto, el método "Execute()" se resume en esto:

if(!$this->IsQueryInCache($sql)){
  // Luego veremos esto
}else{
  return $this->QueryResultsFromCache($sql);       
}

Pero ahí vemos algo importante. Y es que, en caso de que la consulta SQL se hubiera ejecutado y estuviera guardada, el método "Execute()" ha de retornar lo que se espera del mismo. Dicho de otro modo, quien llama al método "Execute()" no distinguirá en un principio si los resultados le llegan de la base de datos o del caché de las consultas SQL. Hemos dicho que puede forzarse a no usar el caché, pero, en este caso se trata de que el método "Execute()" retorne lo que se espera, provenga o no del caché. Ahora bien, veamos el método "QueryResultsFromCache()":

private function QueryResultsFromCache($sql){
  if($this->IsQueryInCache($sql)){
    $this->cacheServedQueries++;
    $this->resultsFromCache = true;
    $sqlHash = $this->GetSqlQueryHash($sql);
    $this->lastResults = $this->cachedQueries[$sqlHash]['result']; 
    return $this->cachedQueries[$sqlHash]['rows'];
  }
}

Como ves, sólo por precaución, comprobamos que, efectivamente, los resultados que queremos obtener del caché están ahí. Puedes ver algunas otras cosas ya interesantes. Incrementamos una variable "contador", a la que podrá recurrirse después, si es necesario. Y también damos el valor correspondiente a "resultsFromCache", para que, de este modo, alguien interesado pueda saber que los resultados que ha obtenido provienen del caché y no de la base de datos.

Volvemos a hacer uso de la firma de la consulta SQL, para acceder al Array donde están los resultados que nos interesan y, por último, hacemos lo que se supone tiene que hacer el método "Execute()", es decir, quienes uséis la clase ezSQL ya os sonará, lo que hacemos es situar los resultados en la variable "lastResults", y retornar, precisamente (recuerda que esto servirá de resultado del método "Execute()") el número de "filas" que ha producido la consulta, si es que se esperaba que produjera alguno.

Quizá lo que queda un tanto difuso es el Array "cachedQueries", puesto que se ve que hacemos uso de uno de sus elementos (identificado mediante su clave, que es la firma de la consulta SQL), pero este es a su vez otro Array asociativo, que contiene las claves "result" y "rows", como puede verse. Pero esto se terminará de comprender si mostramos el método que usamos en el método "Execute()" en el caso contrario al que estamos viendo, es decir, en el caso de que la consulta no se hubiera aún guardado y tuviéramos que hacerlo.

private function SaveQueryInCache($sql, $result, $numRows){
  if($this->QueryIsCachable($sql)){
    $sqlHash = $this->GetSqlQueryHash($sql);
    $this->cachedQueries[$sqlHash]['sql'] = $sql;
    $this->cachedQueries[$sqlHash]['rows'] = $numRows;
    $this->cachedQueries[$sqlHash]['result'] = $result;
    return true;
  }else{
    return false;  
  }
}

Es decir, como puedes ver, existe un método "SaveQueryInCache()", que en realidad recibe todo lo necesario mediante sus parámetros, de modo que pueda guardar la consulta SQL, sus resultados, y el número de filas retornadas en estos (si existen) en el Array "cachedQueries". Como ves, otra vez hacemos uso de "GetSqlQueryHash()", definitivamente, fue buena idea separar este asunto en un método aparte, para no tener que recordar cómo componemos la firma de una consulta SQL: este método nos la ofrece, desde cualquier otro punto del script.

Y creo que lo voy a dejar aquí, puesto que me temo que habré aburrido a más de uno y no habré descubierto nada a nadie. Es un caché muy sencillo, sólo válido para el tiempo de ejecución del "script", pero, que a la vez resulta sumamente útil. Para Gesbit, por ejemplo, ha significado mejorar los títulos de todas las páginas de una bitácora, sin necesidad de hacer más consultas SQL, que, en definitiva, ya habríamos necesitado realizar antes.

Si descargas Gesbit podrás echar un vistazo más en detalle a la clase MySql. Esta contiene otros métodos aún relacionados con el caché, por ejemplo, uno que retorna el número total de consultas "servidas" desde el caché, otro que retorna el Array en que guardamos el caché mismo, etc., además de otros métodos propios de esta la clase MySql, como he dicho basada en la fantástica ezSQL. Si no he sido muy pesado y lo que he dicho puede servir de algo a alguien, me doy por satisfecho en este punto.