Best practice (?) – caching databázových dotazů
- Ondřej Mirtes
- Člen | 1536
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
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… :-)
- Ondřej Mirtes
- Člen | 1536
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
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
?>