Best practice (?) – caching databázových dotazů

Upozornění: Tohle vlákno je hodně staré a informace nemusí být platné pro současné Nette.
Ondřej Mirtes
Člen | 1536
+
0
-

Již delší dobu jsem uvažoval nad tím, jak implementovat caching výsledků databázových dotazů pro menší zatížení serveru.

Samozřejmě první, co člověka napadne, je obalit vnitřek každého getteru v jednotlivých modelech nějakým isset($cache["name"]). Tomu jsem se chtěl vyhnout. A dnes mě cestou domů napadlo takové řešení, že jsem se nemohl dočkat, až doběhnu domů a nadatlím ho :o)

Podařilo se mi docílit toho, že člověk pouze namísto klasického dibi::query("SELECT... zavolá mírně obměněné self::query("SELECT... (nebo chcete-li $this->query("SELECT...) a model si automaticky zajistí, jestli dostane čerstvá data z DB anebo nějaká starší z cache.

Vytvořil jsem abstrakní třídu CacheModel, která implementuje rozhraní ICacheModel:

/**
 * Cache Model interface
 *
 * @author Ondřej Mirtes
 * @link http://ondrej.mirtes.cz/
 */
interface ICacheModel {

    /**
     * Executes the SQL query.
     * @param  string|mixed      SQL statement.
     * @return CacheModel|ResultSet Object itself for further manipulating or results
     */
    function query($sql);

    /**
     * Fetches one cached or fresh row of a DB result
     * @return DibiRow
     */
    function fetch();

    /**
     * Fetches array of cached or fresh rows of a DB result
     * @return array Array of DibiRow
     */
    function fetchAll();

    /**
     * Fetches first cached or fresh field of a DB result
     * @return mixed
     */
    function fetchSingle();

    /**
     * Fetches cached or fresh associative array of a DB result
     * @param string Key (field) of an associative array
     * @return array Array of DibiRow
     */
    function fetchAssoc($assocKey);

    /**
     * Fetches cached or fresh associative array of a DB result
     * @param string Key (field) of an associative array
     * @param string Value of an associative array
     * @return array
     */
    function fetchPairs($key, $value);

    /**
      * Sets requested cache expiration
      * @param  int      Time in seconds.
      */
    function setExpiration($time);

    /**
      * Gets requested (or default) cache expiration
      * @return int Time in seconds.
      */
    function getExpiration();
}

Takže model se podobá API dibi (toho základního, na DibiDataSource jsem si netroufnul, ještě ho zatím ani nepoužívám), aby se jeho užívání co nejvíce podobalo klasickému dibi.

Zda se jedná o ten samý dotaz a mohu tedy použít jeho cachnutou verzi zjišťuji dle parametrů, které dostane query(). Problém nastává v momentě, kdy se sice nabízí cachování např. nových článků v blogu, jenže tam mám v podmínce něco ve smyslu 'time<=%i',time(), tudíž by se dotaz při každém spuštění vyhodnotil jako nový a ke cachování by nedošlo. Z toho důvodu jsem zavedl zápis 'time<=%i',self::TIMESTAMP (nebo také 'time<=%i',CacheModel::TIMESTAMP) – tato konstanta se těsně před odesláním na databázi překládá na aktuální čas. Pokud vás napadají další části dotazu, které se takto mění a přesto je vhodné jejich výsledky cachovat, sem s nimi :)

Tak jo, konec povídání, tady je zdrojový kód CacheModel (již bez PHPDoc):

abstract class CacheModel extends BaseModel implements ICacheModel {

    const TIMESTAMP = "CacheModelTimeStamp_gdjrlw5bdowl";

    private $expiration = 60; //1 minute by default
    private $cache;
    private $hash;
    private $args;

    private $wasQueried = false;

    public function __construct() {
        $this->cache = Environment::getCache();
    }

    public function query($args) {
        $this->args = func_get_args();
        $this->hash = $this->hashArray($this->args);

        $this->wasQueried = true;

        return $this;
    }

    public function fetch() {
        return $this->saveToCacheAndGetResult("fetch");
    }

    public function fetchAll() {
        return $this->saveToCacheAndGetResult("fetchAll");
    }

    public function fetchSingle() {
        return $this->saveToCacheAndGetResult("fetchSingle");
    }

    public function fetchAssoc($assocKey) {
        return $this->saveToCacheAndGetResult("fetchAssoc",$assocKey);
    }

    public function fetchPairs($key, $value) {
        return $this->saveToCacheAndGetResult("fetchPairs",$key, $value);
    }

    public function setExpiration($time) {
        if ($time >= 0) $this->expiration = $time;
    }

    public function getExpiration() {
        return $this->expiration;
    }

    private function saveToCacheAndGetResult($mode,$param1=null,$param2=null) {
        if (!$this->wasQueried) throw new InvalidStateException("Query must be called first.");

        if (isset($this->cache[$this->hash."-".$mode]) && $this->expiration != 0)
            return $this->cache[$this->hash."-".$mode]; //cached
        else { //need to get new fresh results
            if ($mode == "fetch" || $mode == "fetchAll" || $mode == "fetchSingle") {
                $res = dibi::$mode($this->translateTimestamp($this->args));
            }

            if ($mode == "fetchAssoc") {
                if ($param1 == null || $param2 != null)
                    throw new InvalidStateException("Wrong number of parameters.");

                $res = dibi::query($this->translateTimestamp($this->args))
                        ->fetchAssoc($param1);
            }

            if ($mode == "fetchPairs") {
                if ($param1 == null || $param2 == null)
                    throw new InvalidStateException("Wrong number of parameters.");

                $res = dibi::query($this->translateTimestamp($this->args))
                        ->fetchPairs($param1, $param2);
            }

            $this->cache->save($this->hash."-".$mode, $res, array(
                'expire' => time() + $this->expiration,
            ));

            return $res;
        }
    }

    private function hashArray($array) {
       $hash = "";

       foreach($array as $value) $hash = $hash.$value;
       return sha1($hash);
    }

    private function translateTimestamp() {
        if (!in_array(self::TIMESTAMP,$this->args)) return $this->args;
        else {
            $args = $this->args;
            foreach($args as $key => $value)
                if ($value == self::TIMESTAMP) $args[$key] = time();

            return $args;
        }
    }

}

A použití vypadá takto:

class TestModel extends CacheModel {
	public function getTest() {
		$res = self::query("SELECT * from test
				    where time<=%i", self::TIMESTAMP,
				    "order by time desc");
		return $res->fetchAll();
	}
}

Mám dotaz – je to dobré řešení? :)

P.S.: Původní nápad zahrnoval, že všechny metody budou statické a cachoval bych už DibiResult, ale narazil jsem na limit PHP – že resource objekty nejdou serializovat.

Editoval LastHunter (13. 5. 2009 11:04)

Patrik Votoček
Člen | 2221
+
0
-

Nemělo by tohle být spíš v Code Snippets Repository ?

PS: Zatim jsem to necetl (neni cas musim dokonfigurovat server) – pak na to kouknu… :-)

kaja47
Člen | 16
+
0
-

Připadá mi, že jenom duplikuješ funkci query cache databázového stroje, která dělá přesně to samé: po prvním dotazu si kešuje data, které by vrátil dotaz. Stejný dotaz pak trvá nula-nula-nic.

Jakub Šulák
Člen | 222
+
0
-

Nemáte náhodou někdo výsledky testů na rychlost?

Ondřej Mirtes
Člen | 1536
+
0
-

kaja47 napsal(a):

Připadá mi, že jenom duplikuješ funkci query cache databázového stroje, která dělá přesně to samé: po prvním dotazu si kešuje data, které by vrátil dotaz. Stejný dotaz pak trvá nula-nula-nic.

No jo, jenže query cache právě nepozná ty měnící se hodnoty. A i bez nich je čtení z disku nejspíš rychlejší (viz níže).

Jakub Šulák napsal(a):

Nemáte náhodou někdo výsledky testů na rychlost?

Zkoušel jsem to jen tak na localhostu primitivně, poslat 500 dotazů co každý vrací 1000 položek. Při vypnuté mé cache byl běh skriptu 25s, při zapnuté (tj. při čtení z disku) byl 18s.

LuKo
Člen | 116
+
0
-

LastHunter napsal(a):

No jo, jenže query cache právě nepozná ty měnící se hodnoty. A i bez nich je čtení z disku nejspíš rychlejší (viz níže).

Stejně, jako měnící se hodnoty zafixuješ pro tvojí cache, je zafixuješ i pro query cache. Takže místo 'time<=%i',time() použiješ něco jako:

<?php
$_t = time(); // aktuální čas
$t = $_t - $_t%60; // ořízneš vteřiny
dibi::query("SELECT... time<=%i", $t); // dotaz s parametrem měnícím se každou minut, místo každou vteřinu
?>