PHP cache, semafory

Praktyka cache’owania danych jest powszechna wśród programistów aplikacji webowych ze względu na optymalizację dostępu do danych bezpośrednio ze źródła ich pochodzenia, a w szczególności:

  • trudność dostępu (np. wykonanie skomplikowanych połączeń),
  • ograniczenia dostępu (np. limit odpytywania),
  • długi czas oczekiwania na dane; powodów jest wiele.

O ile tematyką stworzenia samego mechanizmu cache zajęli się m.in. Nospor, możecie podejrzeć jak to wygląda w Zend_Cache, Symfony, czy Kohana; tak ja chciałbym zwrócić uwagę na jeszcze jedną rzecz.

Zazwyczaj schemat kodu wygląda mniej więcej tak:

< ?php
$oCache = new Cache(); // tworzony jest jakis obiekt cache

if($oCache->expired(3600) || !is_array($aData = $oCache->load())) // sprawdzamy, czy jest cache i nie wygasł
{
  $aData = $oModel->GetSomething(); // zbieramy dane z bazy danych
  $oCache->save($aData);
}

// $aData przechowuje nasze dane do użytku
?>

Symulacja, parę linijek kodu, a ile nieszczęść.

Wszystko działa pięknie, dopóki nie spotkamy się z sytuacją, gdy setki osób (procesów) jednocześnie zechcą zbierać takie dane z bazy danych. Przeprowadźmy zatem krótką dywagację. Załóżmy, że użytkownik #1 wchodzi na stronę, stwierdza, że nie ma cache, lub jest nieświeży, wówczas przechodzi do połączenia się z bazą danych i zaczyna zbierać dane. W tym samym czasie, zanim użytkownikowi #1 zostaną zwrócone dane wchodzi użytkownik #2, który stwierdza, że nie ma cache, bo użytkownik #1 jeszcze nie zebrał danych, postanawia połączyć się z bazą i zrobić to samo, co użytkownik #1, powtarzając niepotrzebnie czynność i dodatkowo obciążając bazę. Można by iść dalej i wprowadzić n użytkowników, którzy powtarzają czynność, dopóki dane nie pojawią się w cache i kolejni użytkownicy będą z niego korzystać. Co się stanie natomiast, gdy kolejka tak narośnie, że użytkownikowi #1 zabraknie zasobów systemowych, aby ukończyć proces zbierania danych, co spowoduje, że pozostałym też? Kolejka będzie wydłużała się w nieskończoność, póki system operacyjny nie podejmie żadnych działań (np. odłączy bazę danych, lub po prostu wyłączy serwer, np. w IIS7 wyłączy cały application pool). Aby doszło do tej kolizji nie jest potrzebne wcale natężenie użytkowników, serwer może akurat np. zajmować się wysyłką maili lub nieoptymalnie zrobionym procesem, który zajmuje zasoby, a w tym czasie wejdzie tylko pięciu użytkowników.

Parę linijek kodu, a ile nieszczęść.

Pojęcie semafora.

Semafor w informatyce – jest chronioną zmienną lub abstrakcyjnym typem danych, który stanowi klasyczną metodę kontroli dostępu przez wiele procesów do wspólnego zasobu w środowisku programowania równoległego.

Więcej na temat semaforów na Wikipedii, bądź w Podstawy informatyki / Stefan Węgrzyn. – Warszawa : Państwowe Wydawnictwo Naukowe, 1982.

Podejście do problemu.

< ?php
$oCache = new Cache();

if($oCache->expired(3600) || !is_array($aData = $oCache->load()))
{
  $oCache->savePrepare(); // stawiamy semafor

  $aData = $oModel->GetSomething();
  $oCache->save($aData); // metoda save() może (nie musi) od razu zwolnić semafor, gdy próba zapisu się zakończy
  // jeżeli metoda save() nie zwalnia zasobu, możemy np. użyć:
  // $oCache->saveFinalize();
}

// $aData przechowuje nasze dane do użytku
?>

Rozwiązaniem jest zastosowanie semafora blokującego dostęp do zasobu (w tym przypadku abstrakcyjnie „cache”, mniej abstrakcyjnie może być to plik na dysku, przestrzeń w pamięci operacyjnej, rekord w bazie danych, cokolwiek, co cache przerzymuje). Dla wartości semafora = 1 zasób jest wolny (nieużywany, jest 1 cache), gdy jest mniejszy/równy 0 zasób jest zajęty, ktoś z niego „korzysta”. Zajętość zasobu powinna być sprawdzana przy próbie odczytu. Dopóki zasób nie zostanie zwolniony, nie będzie można określić, czy są dane w cache. Jeżeli nie można określić, czy dane są w cache, należy zaczekać na zwolnienie zasobu.

Teraz nasze rozwiązanie nie dopuści do przytoczonej w powyższym przykładzie sytuacji. Zanim cache nie zostanie odblokowany po próbie zapisu, nie uzyskamy odczytu, czekając na niego i nie przechodząc w skrypcie nigdzie dalej.

Gdy save() się nie powiedzie? Można zastosować timeouty odczytu na load(). Wówczas złapalibyśmy wyjątek i przeszli dalej do realizacji zapisu, tak, jakby semafora nie było.

Implementacja.

Do swoich kodów podchodzę jak najbardziej abstrakcyjnie (tutaj idealnie nada się wzorzec fabryki), zatem stworzyłem klasę Cache, która obsługuje 'silniki’ implementujące interfejs Cache_Engine. Jednym z nich jest silnik Cache_Engine_File, który wykorzystuje pliki na dysku do składowania cache.

Najprostszym semaforem dla plików jest funkcja flock() (gotowe, sprawdzone rozwiązanie, w dodatku na poziomie systemu plików, nic tylko implementować). Sprawa wygląda bardzo prosto, dopóki nie zwolnimy flagi LOCK_EX po jej założeniu, ludzie nie będą czytali z pliku, czekając na zwolnienie dostępu. Ktoś powie: truizm, blokować pliki powinno się przed wykonywaniem na nich operacji. Tak. Ale grunt, w którym miejscu to zablokowanie nastąpi. Wykorzystujemy blokowanie do wyższego celu.

Wg. dokumentacji nie można polegać na flock() w przypadku Windows98 oraz systemów FAT32. Zbyt dużym poziomem abstrakcji jest dla mnie stawianie serwisu na pamięci flash lub Win98, ale faktycznie, najprostsza pamięć flash z systemem FAT32 może się czasem zdarzyć w serwerowniach i nie jest to wcale taki głupi pomysł. Co wtedy? Jako semafor możemy stworzyć plik z suffiksem .lock obok tworzonego pliku cache. Gdy plik istnieje oznacza to, że cache jest zablokowany, jeżeli nie – jest wolny. Czekamy tak długo, aż zostanie usunięty plik .lock.

Przykładowy kod źródłowy.

Przykładowy kod źródłowy obsługuje Cache_Engine_File oraz Cache_Engine_Filelock, gdzie w drugim przypadku można klasy użyć spokojnie na partycjach FAT32. Kod jest przykładowy, dlatego nie obsługuje m.in. zagnieżdżania plików w katalogach, usuwanie cache’u itd, zaimplementowałem tylko zapis i odczyt.

Klasy zostały napisane tak, aby zgłaszane przez nie błędy były zgodnie z ideologią hierarchiczną Exceptions w PHP, przy okazji zapraszam do lektury wpisu „Wyjątki w PHP” autorstwa Tomasza Jędrzejewskiego (Zyxits).

Przykładowe czekanie na zwolnienie pliku .lock:

< ?php

protected function _waitUnlock($iWaitTimeout)
{
  if($iWaitTimeout)
  {
    try
    {
      // quick first check
      if(is_file($this->_path(false, 'lock')))
      {
        // wait for unlock file
        $iWaitTimeout /= 1000000;
        $iLockTime = microtime(true);
        $bLockWait = true;

        // wait for the file
        try
        {
          while(is_file($this->_path(false, 'lock')))
          {
            $iLockWaitDelta = microtime(true) - $iLockTime;

            if($iLockWaitDelta > $iWaitTimeout && $iWaitTimeout !== true)
              { $bLockWait = false; break; }

            usleep(rand(1, 999));
          }
        }
        // cache lock path does not exists
        catch(Cache_Exception_Runtime $oE) {}

        if(!$bLockWait)
          throw new Cache_Exception_Runtime('Unable to access cache, it is totally locked, after "' . $iWaitTimeout . '" s.');
      }
    }
    // cache lock path does not exists
    catch(Cache_Exception_Runtime $oE) {}
  }
  else
  {
    try
    {
      if(is_file($this->_path(false, 'lock')))
        throw new Cache_Exception_Runtime('Unable to access cache, it is currently locked, after "' . $iWaitTimeout . '" s.');
    }
    // cache path does not exists
    catch(Cache_Exception_Runtime $oE) {}
  }
  
  return true;
}

?>
 

13 thoughts on “PHP cache, semafory

  1. Dla rozwiązania #1, używającego flock(), działasz na systemie plików, czyli schodzisz dość nisko, jeżeli chodzi o mechanizmy. Przypomina to semafor o wartości acquire zawsze równej 1 (max. jeden dostęp).

    Dla rozwiązania #2 z .lock lepiej stworzyć plik – to zadziała wszędzie. Rozwiązanie ma być out-of-the-box. Sam rozważałem nad zastosowaniem wspomnianych przez Ciebie semaforów, ale nie znalazłem ich na jednym z moich serwerów. Co więcej, nie zawsze działa:
    http://www.php.net/manual/en/function.sem-get.php#76793

    Jednym z plusów jest to, że podczas crashu pehapa, semafor jest zwalniany:
    http://www.php.net/manual/en/function.sem-acquire.php#41527
    Dla naszych implementacji tworzenie pliku pomocniczego, trzeba by było obsłużyć odpowiednio wyrzucony wyjątek, np. dla sprawdzenia, czy filemtime() pliku blokującego nie jest zbyt duży i przejść do ponownego wygenerowania cache – bo po tym poznamy, czy przypadkowo proces, który go stworzył się nie zawisił.

  2. Być może się mylę, ale rozwiązanie odczytywania cache zaproponowane przez Ciebie ma jedną wadę: jeśli faktycznie w czasie odświeżania danych na stronę wejdzie kilka osób i zacznie czekać na semaforze, to po jego zwolnieniu każda z nich będzie znowu wczytywać dane od początku, pomimo tego że dane już zostały zapisane w cache’u.

    A w kodzie _waitUnlock pierwszy w kolejności catch nie ma co łapać (chyba, że $this->_path() rzuca takimi wyjątkami).

  3. O, widzę że nasza dyskusja zaowocowała fajnym wpisem :). Ja jeszcze tak tylko uzupełnię, że jeśli nie mamy jakichś specyficznych wymagań odnośnie procesu cache’owania, najprostszym rozwiązaniem, które zadziała zawsze, jest po prostu wywalenie operacji odświeżania do oddzielnego skryptu, który odpalamy poprzez Crona.

  4. @miron, wszystko jest w porządku ;). Pozostali poczekają na plik i odczytają z niego gotowe już dane, czyli stwierdzą, że istnieją i pominą proces ich zbierania. Zmienna $aData będzie zawierała po odwieszonym przez semafor load() już gotowe, wpisane przez kogoś innego (i zwolnione przez semafor) dane. Jeżeli chodzi o wyjątek, szkic _path() z parametrem, żeby nie odbudowywać ścieżek nie pokazuje, ale powinien rzucać wyjątkiem, że ścieżka jest wadliwa (nie istnieje na którymś zagłębieniu folderu), czyli coś na wzór is_file(). Edit: wyjątek dopisany.

    Przykładem dla rozwiązania @Zyx jest pobranie n losowych rekordów(durny przykład : P) jest pobranie ostatnio dodanych produktów z zewnętrznego sklepu, gdzie dostęp mamy „ograniczony” przez długie połączenie do bazy. Warunek jest „do przewidzenia”, zatem można wydzielić ze skryptu tę część kodu i uruchomić go jako zadanie zaplanowane, gdzie dostęp do wpisywania będzie miał tylko jeden proces.

  5. Wszystko zależy od strony, ale z tego co się orientuję, to większe serwisy zamiast pobierać dane na żądanie w momencie, kiedy cache „się skończy” dbają po prostu o to, żeby cache się nie skończył – nie pamiętam dokładnej nazwy tej techniki.

    A realizowane jest to m.in w sposób, jaki podał Zyx – skrypt w Cronie, a w przypadku np. memcache’a może to być nawet inna maszyna.

  6. @singles, oczywiście, o ile znasz dziedzinę funkcji. Niestety nie jesteś w stanie przewidzieć, jakie kryteria będą używane przez użytkowników do np. (nagle częstego) wyszukiwania i porównywania produktów, zatem nie jesteś w stanie zaplanować(!) procesu pobierania takich danych.

    Też nie w tym istota tego artykułu. Grunt, że problem jest rozwiązywalny poprzez skorzystanie z bardzo prostych, sprawdzonych i szybkich w implementacji rozwiązań.

    O ile dostęp do danych o znanej dziedzinie(!) jest długi/zasobożerny, warto go zaplanować na boku dla innego procesu, żeby nie stawiać przed zadaniem kolejki, a dane będą świeże, kiedy skończy się nieodczuwalny dla użytkowników, odpalony na boku proces ich pobierania i przetwarzania.

  7. Odnośnie znajomości dziedziny, wszystko zależy od konkretnego przykładu. Ale taki np. Reddit cachuje „wszystko”. Pozwolę sobie zacytować spory fragment:

    „By far the most surprising feature of their architecture is in Lesson Six, whose essential idea is: The key to speed is to precompute everything and cache It. They turn the precompute knob up to 11. It sounds like nearly everything you see on Reddit has been precomputed and cached, regardless of the number of versions they need to create. For example, they precompute all 15 different sort orders (hot, new, top, old, this week. etc) for listings when someone submits a link. Normally developers would be afraid of going this extreme, being this wasteful. But they thought it’s better to wasteful upfront than slow. Wasting disk and memory is better than keeping users waiting. So if you’ve been holding back, go to 11, you have a good precedent.”

    http://highscalability.com/blog/2010/5/17/7-lessons-learned-while-building-reddit-to-270-million-page.html

  8. Bo znają dziedzinę. Jakby nie znali, nie są w stanie tego zrobić(!). W przykładzie, który zacytowałeś, dziedziną są kolumny (all 15 different sort orders), po których sortują, zatem jest z góry znana: np. po kolumnie A, AB, C, … n. BTW: fajny, wartościowy link. Taktyka poświęcenia też jest często stosowana, ale na to poświęcę osobny artykuł.

  9. Losowe rekordy w cache’u? Przecież wtedy przestaną być losowe :). Możemy jednak założyć, że gdy np. 5000 użytkowników wejdzie jednocześnie, to nie zauważą, że wszyscy mają to samo wyświetlone, ale wtedy nie robi różnicy czy skorzystamy z Semaforów czy Crona.
    // wiem, durny przykład : P, podałem inny, rozsądniejszy

    Przeszkoda dla Crona zachodzi głównie wtedy, gdy dane do cache’a muszą być określone na podstawie jakichś parametrów wejściowych żądania trudnych do przewidzenia.
    // czyli dziedzina, Athlan

  10. Jest jeszcze jedna kwestia. Gdy tych żądań będzie naprawdę dużo, to w twoim przypadku przez te timeouty wszystkie będą wisieć, aż w końcu mogą ubić Apachea (czy inny serwer www).

    Ciekawym rozwiązaniem może być serwowanie tym żądaniom… starego cachu, aż nie zostanie odświeżony przez ten jeden jedyny proces, który wczytuje nowe dane. Wtedy bardzo szybko „pozbywamy się” z głowy wszystkich nowych żądań.

  11. Zaproponowałeś bardzo ciekawe rozwiązanie z tym serwowaniem starego cache, które się sprawdzi tylko i wyłącznie dla cache’u nieświeżego, a nie nieistniejącego(!). Czyszczenie cache bowiem nie powinno polegać na pozostawieniu jakichkolwiek kopii zapasowych. Klasa będzie bardziej skomplikowana w implementacji, ale jak coś naskrobię w wolnej chwili to na pewno wypostuję.

    Przypadku zwieszania procesu niestety tak prosto nie wyeliminujesz, bo flock() będzie czekał dopóty, dopóki nie zwolni się plik, kod wykonywany jest z góry na dół i będzie czekał. Można oczywiście ominąć ten mechanizm i zaprzęgnąć do tego przykładową implementację z usleep() i rzucać odpowiednimi wyjątkami, jest to mniej optymalne, ale może dać ciekawsze efekty. Czyli używamy strategii poświęcenia.

  12. Mam do was pytanie, ponieważ widzę, że zajmujecie się na bardziej zaawansowanym poziomie programowaniem w php.

    Czy jest uzasadnionym utworzenie i trzymanie obiektów obsługujących stronę w cache?

    Dla zobrazowania tego co mam w głowie posłuże się małym przykładem

    Tworzymy instancje obiektu system który w swoich atrybutach przechowuje inne obiekty, które mają za zadanie np. parsowanie adresu url i wyzwalanie odpowiedniego controllera i metody (np. klasa router) następnie klasa session i wreszcie szereg obiektów z typu mvc

    class System{

    private $router
    private $session
    private controllers = array();

    }

    atrybut controllers przechowuje wszystkie controllery a każdy controller przechowuje w swoim środku modele i widoki z ustawionymi juz atrybutami np. dla modelu jakiegos controllera są ustawione dane dot. bazy danych itp.

    class System{

    private $router = new Router()
    private $session = new Session
    private controllers = array(

    'controller1′ => array(

    array(
    views => array(0 => new View
    1 => new View
    2 => new View
    3 => new View)

    models => array(0 => new Model
    1 => new Model
    2 => new Model
    3 => new Model)
    )
    )

    );

    }

    oczywiscie te obiekty maja swoje wnętrze którym są już ustawione prametry których wymagają. I teraz tak przygotowana klasa trafia do cache i i już proces rozruchu takiej aplikacji jest pomijany z uwagi, że ustawianie wszystklich atrybutów następuje w momencie utworzenia obiektu system. Chciałbym zapytać o waszą opinię bo widzę że znacie się na tematcie. Czy mój pomysł jest uzasadniony a może ma jakieś wady których ja nie widzę?

    pozdrawiam

Comments are closed.