Cachování v PHP

Nevýhoda PHP je v tom, že každý jednotlivý request vytváří nový proces, kompiluje zdrojové kódy, vytváří objekty a načítá konfigurace, soubory a data z databáze.

PHP ale nabízí řadu možností, jak tyto procesy urychlit pomocí různých cache. Tady je jejich přehled s popisem výhod a nevýhod.

Teorie

Pro začátek trocha teorie. Pokud si myslíte, že o cache už více vše, můžete tuto kapitolu přeskočit.

Chování cache je definováno v návrhových vzorech (design patterns) Cache aside (ukládání bokem) a Read-through Cache (přímé ukládání; dále rozšiřitelná na Write-through cache).

Cache aside

Tato metoda spočívá v tom, že přímo v programu načtete data ze zdroje a následně je uložíte do cache. Při dalším čtení dat pak nejprve ověříte, zda nejsou data již v cache a pokud ano, přečtete je z cache místo z původního zdroje.

Read-through Cache

Tohle je nadstavba Cache aside, která zapouzdřuje to, že musíte ověřit, zda jsou data v cache a pokud ne, tak je načíst ze zdroje.

Přímá cache totiž funguje tak, že se jí přímo zeptáte na data, která hledáte a cache, pokud data nemá, sama zjistí, odkud a jak data získat a provede to před tím, než vám data vrátí (a uloží je do cache). Může jít třeba o nadstavbu vaší modelové vrstvy, která automaticky cachuje data, která načte z databáze.

V tomto případě přes cache probíhá pouze načítání dat a jejich zápis se provádí přímo do původního zdroje.

Write-through Cache

Pro načítání funguje stejně jako Read-through cache, ale navíc přes cache probíhá i změna dat.

Při změně dat zapíšete změnu také rovnou do cache, odkud je možno ji okamžitě znovu přečíst. To pak znamená, že při změně dat je není potřeba znovu načítat ze zdroje. Do původního zdroje se data mohou zapisovat okamžitě (aby oba zdroje obsahovali stejná data) nebo se zpožděním (takže data v cache se mohou ztratit).

Zpožděný zápis je výhodný v případě, že data, která ukládáte, se mění příliš často a není potřeba mít v původním úložišti podchycené všechny změny. Příkladem mohou být statistiky, do kterých můžete ukládat data několikrát během generování stránky, ale pokud se vám třeba v případě pádu ztratí statistiky za posledních 5 minut, není to žádný problém (obvykle se sledují denní nebo měsíční průměry a součty, na které 5-minutový výpadek nemá vliv). Pak stačí data ukládat do cache (RAM nebo pevný disk) a do úložiště (databáze) je přepsat po určité době (samozřejmě 5 minut je příklad; u důležitějších dat to může být třeba každou sekundu).

Strategie

Kromě způsobů, jak data do cache ukládat a číst je, definují návrhové vzory také několik způsobů, jak data z cache vymazávat.

TTL (time-to-live, doba přežití)

Tato strategie určuje, jak dlouho mohou být data v cache uložena od doby, co byla načtena z původního zdroje. Tato strategie se používá v případě, že data v původním zdroji se mohou měnit externě bez zásahu vlastního programu (např. změna databáze přes PhpMyAdmin, nahrání nových souborů na disk, atd.).

Čas přežití je potřeba zvolit s ohledem na potřebu aktuálnosti dat (např. jízdní řády stačí aktualizovat jednou denně zatímco informace o zpoždění spojů by se měla aktualizovat každou minutu). Stejně tak je potřeba brát v úvahu častost čtení informací. Data, která potřebujete jen na pár minut (třeba pro přidání článku do blogu) není potřeba cachovat na dobu hodin nebo dní.

Data, která překročila svoji dobu přežití, jsou označována jako stale (zkažená).

Invalidation (zneplatnění)

Toto je opak strategie TTL, protože se zabývá vymazáním dat, která byla změněna přímo vaším programem. V takovém případě totiž program ví, že data změnil a může je současně vymazat i z cache (nebo je tam zapsat v případě Write-through cache). Tato data se nazývají invalid (neplatná) a nejčastěji se mažou metodou invalidate() dostupnou pod konkrétní cache.

Pro invalidaci je zpravidla potřeba znát konkrétní klíče nebo smazat celou (část) cache. Pro získání jmen klíčů, které je potřeba vymazat, se obvykle používají tagy, což jsou identifikátory hodnot z původního úložiště. Např. pokud ukládáje informace o uživatelých, může být tagem user_123456 (kde 123456 je číslo řádku z databáze). Po invalidaci tímto tagem pak cache může vymazat popis profilu i fotky uživatele, i když jsou uloženy pod různými klíči.

Eviction (vystěhování)

Tato strategie se používá u cache, která je omezena (typicky RAM) a může dojít k jejímu zaplnění. V takovém případě by nebylo možno přidávat nová a aktuální data do cache. Proto tato strategie zajistí, že se z cache odstraní data, která již nebyla delší dobu používána (přečtena).

Strategie může odstranit data buď podle konkrétního času (např. vše nepoužité více než hodinu) nebo podle velikosti (maže data od nejstarších dokud nezíská potřebné volné místo). Mazaná data se označují jako least-recently used (nejdéle nepoužívaná).

Nevýhoda této strategie je, že pro přečtení dat je potřeba si zapsat aktuální čas, což u některých úložišť může snížit jejich životnost (SSD) nebo rychlost přístupu (pevný disk). U RAM cache, kde se nejčastěji používá, to ale nebývá problém.

Priority (důležitost)

Alternativní způsob promazání paměti cache, pokud dochází místo. Místo posledního času přístupu přiřadíte každému klíči prioritu podle složitosti, s jakou jste data získali z původního úložiště, a potřebou aktuálnosti.

Data, která se získávají dlouho, ale následně je není potřeba často měnit (např. statistika přístupu za minulý měsíc) by měla mít vysokou důležitost, zatímco data, která lze získat rychle nebo se často mění by měla mít nízkou důležitost.

Při nedostatku místa v cache pak stačí buď mazat data s nejnižší důležitostí (lowest priority data), dokud nebude dostatek místa, nebo smazat data s důležitostí nižší (tedy matematicky větší) než určitá hodnota.

Dobré je mít jako nejvyšší důležitost číslo 0 a nižší buď zapisovat jako vyšší čísla (nebo záporná čísla, pokud chcete, aby byla i matematicky nižší).

Soubory

Nejjednodušší způsob ukládání dat je zapsat je do souboru na disku:

function cache($name, $callback) {
    $file = '/tmp/'.$name.'.tmp';

    if (file_exists($file) {
        return file_get_contents($file);
    }

    $data = $callback();
    file_put_contents($file, $data);
    return $data;
}

Výhoda je jednoduchost ukládání a rychlost v případě, že zdroj dat je pomalý (Databáze, HTTP download, API, apod.). Rychlost lze dále zvýšit tím, že soubory budete ukládat na ramdisk, což by nemělo vadit z povahy cache (data jsou duplicitní a dočasná, takže nevadí, když se RAM při restartu smaže).

Nevýhoda je to, že výše uvedené to jediné, co taková cache nabízí. Automatické mazání (expirace) nebo hromadná invalidace (např. tagy) nepřichází v úvahu a bylo by potřeba si na ní napsat vlastní funkce (např. CRON pro expiraci).

Naštěstí většina souborových úložišť si pamatuje čas vytvoření, úpravy a případně posledního otevření souboru, takže se to dá použít pro ošetření expirace.

Do souboru je možno ukládat pouze binární nebo řetězcová data, takže všechny ostatní hodnoty budete muset převést do jiného formátu (např. JSON nebo serializace) a pak je zpětně po načtení z cache převést do původního formátu.

Další nevýhoda souborů u velkých projektů je v tom, že čím více souborů ke ve složce, tím pomaleji operační systém reaguje na jejich výpis a načtení. Pokud tedy plánujete ukládat tisíce různých souborů, zauvažujte nad tím, jak je lépe rozdělit do podsložek tak, aby v každé složce bylo maximálně několik stovek souborů (nebo dalších podsložek; z hlediska souborového systému je složka jen další soubor obsahující jména souborů v dané složce).

Soubory jsou ale výborným způsobem jak s cachováním začít, pokud jste zatím nic podobného nedělali a nemáte s tím příliš zkušeností. Pokud totiž napíšete kód, který data špatně cachuje, není nic jednoduššího než soubory z disku smazat a začít znovu. Pokud rovnou začnete se složitějšími cache, kam nemáte přímý přístup, nebude tak jednoduché zjistit, co se do cache ukládá a jak se toho zbavit.

OpCache

Vychází ze Zend Optimizer Cache a slouží k ukládání zkompilovaných zdrojových souborů do paměti, aby nebyla potřeba jejich opakovaná kompilace při každém requestu.

OpCache, pokud je zapnuta, se aktivuje automaticky pokaždé, když v kódu použijete include() nebo require() (včetně *_once() variant).

Pomocí malého triku lze ale využít i pro ukládání objektů (instancí tříd) a polí dat získaných ze souboru nebo databáze. Trik spočívá v tom, že objekt nebo pole převedete na zdrojový kód (pomocí var_export()) a uložíte do souboru na disku. Následně pak soubor načtete pomocí include(), čímž aktivujete OpCache, která objekt nebo pole zkompiluje a uloží do paměti. Při opakovaném vložení se pak již objekt nebo pole načítá přímo z paměti (v již zkompilované podobě).

Uložení dat do paměti provedete následující funkcí (převzato od @dylanwenzlau):

function opcache_set($key, $val) {
   $val = var_export($val, true);
   // HHVM fails at __set_state, so just use object cast for now
   $val = str_replace('stdClass::__set_state', '(object)', $val);
   // Write to temp file first to ensure atomicity
   $tmp = "/tmp/$key." . uniqid('', true) . '.tmp';
   file_put_contents($tmp, '<?php $' . $key . '=' . $val . ';', LOCK_EX);
   rename($tmp, '/tmp/'.$key.'.php');
}

Vložení do kódu pak provedete jednoduše pomocí include() a přečtením stejnojmenné proměnné. Pozor ale na to, že je potřeba ověřit, jestli daný soubor existuje a pokud ne, načíst data z původního zdroje.

Funkce pro získání dat z cache nebo generátoru:

function opcache_get($key, $callback) {
    if (include('/tmp/'.$key.'.php')) {
        return $$key;
    }
    else {
        $data = $callback($key);
        opcache_set($key, $data);
        return $data;
    }
}   

Do callback pak předáte anonymní funkci, která získává data ze souboru nebo z databáze:

$profile_id = 123456; //identifikátor toho, co chcete získat
$data = opcache_get('profile_'.$profile_id, function() use ($profile_id) {
    return \Model\Profile::get($profile_id);
});

Výhoda OpCache je v tom, že skvěle funguje na objekty a velká pole, protože ukládá data v již zkompilované podobě, zatímco ostatní cache je musí deserializovat ze stringu.

Druhá výhoda je ta, že automaticky ukládá a sleduje čas změny souboru na disku, takže když do souboru uložíte novou hodnotu, cache ho automaticky překompiluje.

A samozřejmě nezapomeňme na to, že aktivace OpCache celkově zrychlí všechny stávající skripty, protože je nebude potřeba opakovaně kompilovat z *.php.

Nevýhoda je v tom, že soubory musíte fyzicky ukládat na disk, což je pomalé (ale můžete použít ramdisk), a nechat je zkompilovat, což zabere nějakou dobu (např. kvůli ověřování syntaxe PHP). Cache je tak nevhodná pro ukládání primitivních hodnot (čísla) a krátkých řetězců, které rychleji načtete ze souboru přímo. Samozřejmě můžete upravit opcache_set() tak, aby malé hodnoty ukládala jinak (rovnou textově a s jinou příponou) a opcache_get() je pak načítala přímo přes file_get_contents() (pak ale přijdete o ukládání do RAM, pokud nepoužijete RAM disk).

Další nevýhoda je ta, že nemáte žádnou možnost data chytře invalidovat (podle tagu, času, apod.). Jediná možnost je použít funkci opcache_invalidate(). Pak je ale na vás, jak najdete konkrétní klíč (pokud ho přímo neznáte), ale pravděpodobně to bude pomocí procházení dočasné složky na disku, kde mohou být tisíce souborů, takže to nebude nijak rychlé.

Poznámka: Pro urychlení můžete ručně zavolat funkci opcache_compile_file(), která soubor zkompiluje hned a ne až při prvním načtení. Nelze ale zkompilovat soubor, který byl uložen aktuálně spuštěným skriptem (podle času vytvoření vs. čas spuštění skriptu). Obejít se to dá funkcí touch(), pomocí které můžete změnit čas vytvoření souboru (na nějaký starší). V tom případě ale pozor na to, aby zase cache poznala, že se soubor změnil a překompilovala ho. To lze zase obejít zavoláním funkce opcache_invalidate() s parametrem $force, která soubor z cache vymaže.

APCu

Původně externí balík APC (Alternative PHP Cache) je od PHP7 přímo nativně vložen pod jménem APCu  (APC User Cache).

APCu umožňuje přímo ukládat a načítat hodnoty a objekty a také podporuje automatické vymazávání po zadaném počtu sekund ($ttl).

Hodnoty můžete ukládat pomocí apcu_add() nebo apcu_store() (kde store vždy uloží novou hodnotu, zatímco add zachová starou, pokud existuje) a přečíst ji přes apcu_fetch(). Pokud je hodnota číselná, můžete ji přímo v cache měnit pomocí apcu_inc(), apcu_dec() a apcu_cas() (zapíše nové číslo, pokud je v cache uloženo očekávané číslo).

Cachování pomocí generátoru můžete provést funkcí apcu_entry($key, $callback, $ttl), která buď vrátí hodnotu z cache, nebo zavolá callback a získanou hodnotu uloží. APCu automaticky (pomocí zámku) zajistí, že callback se zavolá jen jednou, i když o neexistující data požádá více skriptů najednou.

Hodnotu v cache můžete najít pomocí apcu_exists() a vymazat ji můžete přes apcu_delete() Celá cache se pak vymaže funkcí apcu_clear_cache().

Většina výše uvedených funkcí navíc podporuje hromadné operace, kdy vložit data můžete pomocí pole [klíč1 => hodnota, klíč2 => hodnota] a do ostatních funkcí můžete předat pole klíčů a funkce pak provede danou operaci se všemi klíči (a případně vrátí pole hodnot pro jednotlivé klíče).

Pro získání více hodnot bez znalosti konkrétních klíčů můžete použít APCUIterator, který najde klíče odpovídající regulárnímu výrazu:

//získání všech klíčů určitého typu (např. profily)
$profiles = new APCUIterator('/^profile_[0-9]+/');
foreach ($profiles as $key => $profile) {
    //... zpracování profilů
}

//vymazání všech klíčů týkajících se id "123456"
$values = new APCUIterator('/_123456/');
apcu_delete(array_keys(iterator_to_array($values)));

Výhoda APCu je, že můžete přímo ukládat hodnoty nebo objekty do paměti a nedojde k jejich vymazání po skončení skriptu. Další výhoda je možnost nastavení doby validity každé hodnoty, takže pak dojde k jejímu automatickému vymazání.

Nevýhoda je, že kromě hledání regulárním výrazem (relativně pomalé) neumožňuje invalidaci dat podle tagů.

SHM

Shared Memory Cache je další nativní součást PHP (pokud je zkompilováno s --enable-shmop), která využívá sdílené paměti operačního systému. Funguje stejně jako práce se soubory s tím rozdílem, že soubor identifikujete pomocí čísla (místo jména) a musíte dopředu uvést, jak velké místo chcete rezervovat. Následně můžete do souboru zapisovat a číst z něj data, přičemž se vše zapisuje do RAM, kde data vydrží do restartu PC/serveru.

function shmop_set(int $key, $var): int
    $data = serialize($var);
    $size = strlen($data);

    $cache = shmop_open($key, 'c', 0600, $size);
    shmop_write($cache, $data, 0);
    shmop_close($cache); //zápis do paměti
    
    return $size; //nebo shmop_size($cache)
}

function shmop_get(int $key, int $size) {
    $cache = shmop_open($key, 'r', 0, 0);
    $data = shmop_read($cache, 0, $size);
    shmop_close($cache);
    return unserialize($data);
}

function shmop_invalidate(int $key) {
    $cache = shmop_open($key, 'w', 0, 0);
    shmop_delete($cache);
    shmop_close($cache);
}

Fakt, že paměťové bloky určujete podle čísla ve sdílené paměti, může mít za následek to, že si data přepíšete něčím jiným, nebo dokonce že přepíšete data jiného programu. Proto se do shmop_open() udávají přístupová práva (v osmičkové formě jako 0xxx) stejně jako u souborů na disku, aby každý program mohl určit, kdo smí nebo nesmí jeho data číst nebo měnit. V případě kolize pak funkce vrátí FALSE. Na druhou stranu je SHM skvělým prostředkem pro sdílení dat mezi vlákny, která tak mohou načítat data z disku nebo databáze nezávisle na hlavním skriptu a ukládat je do cache.

Novou paměť můžete rezervovat zadáním písmene 'c' nebo 'n' do druhého parametru shmop_open(). Režim 'c' vždy vytvoří nový blok a případně přepíše existující zatímco 'n' vytvoří nový jen pokud neexistuje a v opačném případě vrátí FALSE. Otevřít existující blok můžete naopak pomocí 'r' (pouze čtení) nebo 'w' (čtení, zápis a mazání); pokud blok neexistuje, funkce vrátí FALSE. Při otevření existujícího bloku již nemusíte zadávat práva a velikost a stačí uvést nuly. Nicméně stále musíte vědět, kolik dat z paměti přečíst, takže zapsanou velikost si musíte pamatovat.

Pro získání čísla můžete použít funkci ftok(), které ale musíte předat jméno souboru na disku a ona podle něj vytvoří náhodné číslo. Alternativně můžete uvést číslo v hexadecimálním tvaru jako třeba 0xFFAA0001.

//načtení konfigurace a uložení do paměti
$file = __DIR__ . '/config.ini';
$id = ftok($file, 't');
$data = file_get_contents($file);
$size = shmop_set($id, $data);

//příklad uložení pro pozdější načtení
$_SESSION['config'] = ['key' => $id, 'size' => $size];

To, že SHM funguje stejně jako soubory na disku je také její největší nevýhoda. Nestačí totiž říct číslo bloku, ale musíte také říct, jak velká ta hodnota je. Pokud tedy ukládáte data o neurčité velikosti, znamená to, že si někam jinam budete muset zapisovat, co jste do cache zapsali, abyste to mohli později znovu přečíst (a nemůže to být v proměnné, protože ta se na konci skriptu vymaže).

Nutnost znát velikost dat můžete obejít tím, že při zápisu rezervujete místo o 2 až 4 bajty větší (podle předpokládané velikosti dat), a na začátek si zapíšete velikost dat.

//zápis velikosti
$cache = shmop_open($id, 'c', 0600, $size + 4);
shmop_write($cache, $size, 0);
shmop_write($cache, $data, 4);

//načtení velikosti
$size = shmop_read($id, 0, 4);
$data = shmop_read($id, 4, $size);

Je také jasné, že složitější hodnoty (pole, objekty, apod.) musíte před uložením převést na ideálně binární data, která budete moci zapsat a po přečtení z těchto dat zase rekonstruovat původní hodnotu.

Pokud budete chtít SHM používat více, můžete její nevýhoduobrátit ve výhodu tím, že do jednoho paměťového bloku (který vytvoříte hodně velký) budete zapisovat více různých dat (díky tomu, že do shmop_write() a shmop_read() zadáváte pozici, od které se má zapisovat nebo číst). Můžete pak data segmentově procházet nebo všechny najednou vymazat a vyhnete se potřebě pro každé přečtení dat z cache ji otevírat a zase zavírat, což stojí drahocenný čas. Naopak ale můžete ztratit v případě, že více skriptů bude potřebovat zapisovat do stejného bloku paměti a budou muset na sebe navzájem čekat.

Další nevýhoda SHM na Linuxu je v tom, že ke smazání bloku dojde až po skončení skriptu, takže jeho opětovné vytvoření způsobí chybu.

Nette Cache

Framework Nette nabízí vlastní cache, do které můžete ukládat hodnoty a následně je invalidovat podle času, tagů nebo priority. Navíc se data v cache rozdělují do sekcí (např. jméno třídy), takže se vám nepomotají, když omylem použijete stejné jméno.

Nette cache (ve verzi 2.4) ukládá data do souborů na disku a tagy a priority ukládá do SQLite databáze (také soubor na disku).

Cache nabízí několik funkcí, ale většinou si vystačíte jen s $cache->load(), která buď data načte z cache nebo zavolá generátor, který data načte z jiného zdroje (stejně jako apcu_entry() výše). Vymazání jednoho klíče provedete tak, že místo generátoru předáte NULL. Výmaz celé cache (jedné sekce) pak tím, že předáte NULL místo jména klíče. Vymazat více klíčů pomocí tagu nebo priority pak můžete funkcí $cache->clean().

Výhoda Nette Cache (tedy tagy a priority) je zároveň její nevýhoda. Pro ukládání totiž používá SQLite, které může na velkém projektu (miliony záznamů v cache) vyrůst na několik gigabajtů a pak je pomalé v něm najít všechny klíče odpovídající danému tagu nebo prioritě. Navíc SQLite databáze nedokáže efektivně promazávat odstraněné řádky, takže velikost souboru neustále roste.

Na druhou stranu Nette pro většinu svých součástí nabízí interface, takže pokud byste ji chtěli použít, můžete si místo SQLite napsat vlastní rozšíření \Nette\Caching\Storages\IJournal a ukládat tagy a priority jinam (jiná cache, plnohodnotná databáze, apod.). IStorage má jen dvě metody: write() zapíše podmínky (tagy a priority) patřící danému klíči a clean() naopak vrátí klíč podle tagu nebo priority (zda je i vymaže je na úložišti).

SQL Databáze

Může se zdát nesmyslné ukládat do databáze data, která jste s největší pravděpodobností získali právě z té samé databáze. Rozdíl ale může být v tom, že zatímco primární data získáváte z databáze na základě často složitých a složených dotazů (JOIN, UNION, WHERE, GROUP BY, SORT BY, atd.), data v cache jsou již připravena v podobě, v jaké je potřebujete (JSON pole, serializovaný objekt, apod.) a v databázi mohou být uložena jako TEXT nebo BLOB pod konkrétním ID nebo jménem.

Výhoda SQL tabulky je v tom, že si dle svých potřeb můžete přidat sloupce (tagy, priority, čas expirace, atd.) a pomocí indexů pak číst nebo mazat potřebné záznamy. K tomu pak plně využijete databázové optimalizátory, které v případě diskových nebo paměťových cache nemáte k dispozici. Na druhou stranu si sami budete muset zajistit např. expiraci dat, kterou třeba APC umí automaticky.

Samozřejmě v případě ukládání polí nebo PHP objektů je budete muset převést do formátu, který je možno uložit do BLOBu (JSON nebo serializace).

Výhoda oproti ostatním cache může být v tom, že může běžet na samostatném (databázovém) serveru a nerozhází ji tedy ani pád webového serveru nebo v případě cloudu lze sdílet mezi více servery.

Nevýhoda je pak samozřejmě menší rychlost při komunikaci s DB v porovnání s přímím přístupem na pevný disk nebo do RAM. Nedává tedy smysl do cache ukládat primitivní hodnoty nebo data, která můžete stejně rychle získat jednoduchým dotazem. Leda že cache tabulka je uložena v RAM (MEMORY storage engine) zatímco výchozí tabulka je na disku (InnoDB, MyISAM).

Volbou úložiště (MyISAM, InnoDB, Memory, apod.) pak můžete zajistit různé možnosti sdílení dat mezi skripty nebo servery. Těžko se ale asi bude řešit situace, kdy více skriptů chce najednou získat stejná data, která v cache ještě nejsou (jedině transakcemi, které ale ne všechny úložiště podporují).

Napsat komentář

Vaše emailová adresa nebude zveřejněna. Vyžadované informace jsou označeny *

Tato stránka používá Akismet k omezení spamu. Podívejte se, jak vaše data z komentářů zpracováváme..