войти зарегистрироваться

PHP whois

индекс
169,28

Кеширование в PHP — теперь немного лучше, чем просто кеш

Так как в моем блоге комментируют эту статью только боты и то редко, то я решил выложить ее сюда, чтобы узнать мнение профессионалов. В этой короткой статье я расскажу, как я убирал тривиальность из политики кеширования на файлах в PHP сценариях. В принципе это всё можно применить и не к «файловому» кешированию. Надеюсь, многим эта статейка принесет пользу.

Итак, что же мне не нравилось в моей тривиальности, и что я хотел бы улучшить? Во-первых, меня очень напрягала обстановка, когда несколько килобайт кеша вдруг одновременно устаревало и могло довольно прилично нагрузить сервер. А во-вторых, мой мозг до некоторого момента все время мучила мысль о непосредственной работе с файлами, а точнее — создание и удаление.
Все эти беды происходили от выбранного мною тривиального подхода к управлению кешированием. Суть его заключалась в следующем.
Сначала я проверял актуальность кешированных данных и если они таковыми не являлись, то проводил операцию получения данных и записывал «свежий» кеш на диск. Иначе — если кеш был актуален — просто брал данные от туда. Вот пример из моего старенького шаблонизатора:
  1. <?php
  2. if ($this->_Cache->IsActual()) {
  3.   return $this->_Cache->Read();
  4. }
  5. else {
  6.   $Content = $this->Parse($File);
  7.   $this->_Cache->Write($Content);
  8.   return $Content;
  9. }
  10. ?>
* This source code was highlighted with Source Code Highlighter.

При этом проверка актуальности выглядела приблизительно так:
  1. <?php
  2. public function IsActual()
  3. {
  4.   clearstatcache();
  5.   if (!file_exists($this->_Id)) {
  6.     return FALSE;
  7.   }
  8.   if (!($CreateTime = filemtime($this->_Id))) {
  9.     return FALSE;
  10.   }
  11.   if (($CreateTime + $this->_Expired) < time()) {
  12.     @unlink($this->_Id);
  13.     return FALSE;
  14.   }
  15.   return TRUE;
  16. }
  17. ?>
* This source code was highlighted with Source Code Highlighter.

И вот, в один прекрасный день я решил попытаться избавиться от таких проблем:
  • Одновременное обновление большого количества кешированных данных. То есть при практически одновременном обращении пользователей в момент устаревания кеша, происходит не только большая нагрузка на сервер но и конфликты кеширования. Большая нагрузка происходит от того, что кеш не может быстро обновиться, так как данных довольно много. Поэтому каждый пользователь фактически заново запускает процедуру обновления кеша, что влечет за собой конфликты, которые в свою очередь оказывают еще большую нагрузку на сервер
  • Медленное обновление кеша, вследствие его немалого объема. То есть необходимо, большой кеш обновить как можно быстрее

Большое спасибо Джорджу Шлосснейглу, за прекрасный простой приём, используя который можно организовать решение этих двух проблем. По сути Джордж предлагает заменить операции удаления старого кеша и создания нового кеша одной операцией — заменой старого кеша. Стоило немного подумать над приемом Джорджа и из него вытекло решение всех вышеописанных проблем:
  • Разбиваем большой кеш на отдельные куски
  • Заранее готовим каждый из кусков в разные моменты времени, маскируя наличие нового кеша
  • Заменяем старый кеш на новый быстрым методом

Вот и всё — очень просто и со вкусом.
Теперь, по традиции, непосредственно сама реализация. Она далека от идеала и предназначена всего лишь для объяснения сути. Но все же давайте раздуем примерчик интерфейсом для backend-а. А также, в связи с тем, что я все данные получаю из функций и методов, я «приноровлю» механизм кеширования к методам получения возвращаемых результатов.
Для начала сконструируем интерфейс с нужными функциями:
  1. <?php
  2. interface Cachebackend {
  3.   /**
  4.    * @param string $Key
  5.    */
  6.   public function GetCache($Key);
  7.   /**
  8.    * @param string $Key
  9.    * @param int  $ExpiredPeriod
  10.    */
  11.   public function IsActual($Key, $ExpiredPeriod);
  12.   /**
  13.    * @param string $Key
  14.    * @param int  $ExpiredPeriod
  15.    * @param int  $SoonPeriod
  16.    * @param string $Postfix
  17.    */
  18.   public function IsNeedNewCache($Key, $ExpiredPeriod, $SoonPeriod, $Postfix);
  19.   /**
  20.    * @param string $Key
  21.    * @param string $Data
  22.    */
  23.   public function PutCache($Key, $Data);
  24.   /**
  25.    * @param stirng $PreparedKey
  26.    * @param string $RenameKey
  27.    * @return bool
  28.    */
  29.   public function Rename($PreparedKey, $RenameKey);
  30. }
  31. ?>
* This source code was highlighted with Source Code Highlighter.

Теперь сконструируем непосредственный каркас с нашим алгоритмом:
  1. <?php
  2. class Cacher {
  3.   /**
  4.    * @var Cachebackend
  5.    */
  6.   private static $_Backend = NULL;
  7.   /**
  8.    * @var mixed
  9.    */
  10.   private static $_CallbackSignature = array();
  11.   /**
  12.    * @var array
  13.    */
  14.   private static $_CallbackArguments = array();
  15.   /**
  16.    * @static
  17.    * @param string $Tag
  18.    * @param string $Key
  19.    * @param int  $ExpiredPeriod
  20.    * @param int  $SoonPeriod
  21.    * @param sting $Postfix '.next'
  22.    * @return mixed
  23.    */
  24.   public static function GetData($Tag, $Key, $ExpiredPeriod, $SoonPeriod, $Postfix = '.next')
  25.   {
  26.     $Key = CACHE_PATH . strtolower($Tag) . '_' . strtolower($Key) . CACHE_EXT;
  27.     if (NULL === self::$_Backend) {
  28.       self::$_Backend = new Cachefilebackend;
  29.     }
  30.     if (self::$_Backend->IsActual($Key, $ExpiredPeriod)) {
  31.       $Data = self::$_Backend->GetCache($Key);
  32.       if(self::$_Backend->IsNeedNewCache($Key, $ExpiredPeriod, $SoonPeriod, $Postfix)) {
  33.         $CallbackData = self::_GetCallbackResult();
  34.         self::$_Backend->PutCache($Key . $Postfix, $CallbackData);
  35.       }
  36.       return $Data;
  37.     }
  38.     else {
  39.       if(self::$_Backend->IsActual($Key . $Postfix, $ExpiredPeriod)) {
  40.         $Data = self::$_Backend->GetCache($Key . $Postfix);
  41.         self::$_Backend->Rename($Key . $Postfix, $Key);
  42.         return $Data;
  43.       }
  44.       else {
  45.         $CallbackData = self::_GetCallbackResult();
  46.         self::$_Backend->PutCache($Key, $CallbackData);
  47.         return $CallbackData;
  48.       }
  49.     }
  50.   }
  51.   /**
  52.    * @static
  53.    * @param string $CallbackFunction
  54.    * @param array $CallbackArguments
  55.    * @param mixed $CallbackObject NULL
  56.    */
  57.   public static function SetCallback($CallbackFunction, $CallbackArguments, $CallbackObject = NULL)
  58.   {
  59.     if ($CallbackObject) {
  60.       self::$_CallbackSignature = array($CallbackObject, $CallbackFunction);
  61.     }
  62.     else {
  63.       self::$_CallbackSignature = $CallbackFunction;
  64.     }
  65.     self::$_CallbackArguments = $CallbackArguments;
  66.   }
  67.   /**
  68.    * @static
  69.    * @return mixed
  70.    */
  71.   private static function _GetCallbackResult()
  72.   {
  73.     self::_CheckCallback();
  74.     return call_user_func_array(self::$_CallbackSignature, self::$_CallbackArguments);
  75.   }
  76.   /**
  77.    * @static
  78.    */
  79.   private static function _CheckCallback()
  80.   {
  81.    if(!is_callable(self::$_CallbackSignature, FALSE, $CallableName)) {
  82.       throw new Exception($CallableName . ' is not correct callback');
  83.     }
  84.     if(!is_array(self::$_CallbackArguments)) {
  85.       throw new Exception('Callback arguments must be an array');
  86.     }
  87.   }
  88. }
  89. ?>
* This source code was highlighted with Source Code Highlighter.

Давайте немного разберемся, что к чему.
Итак, основной метод — это метод GetData. Что мы передаем ему:
  • $Tag — идентификатор группы кеш-данных. Предназначен для управления целой группой кеш-данных.
  • $Key — идентификатор конкретных кеш-данных. Однозначно идентифицирует кешируемые данные.
  • $ExpiredPeriod — время жизни кеша. Это время можно даже назвать как время «псевдо» жизни, ведь новый кеш появится до истечения этого времени, хотя старый и удалиться именно по этому значению.
  • $SoonPeriod — период времени, который определяет временной интервал между появлением нового кеша и удалением старого: T(удаления старого кеша) — T(создания нового кеша) = $SoonPeriod
  • $Postfix — параметр по умолчанию, обозначающий расширение файлов нового кеша, чтобы не путать их с еще существующими файлами старого кеша.

Константы CACHE_PATH и CACHE_EXT определяют кеш-директорию и расширение кеш-файлов соответственно. Метод GetData создает объект backend-а и выполняет соответствующие действия по управлению кешем:
  • Проверяет актуальности кеша и если он таков, то отдает его данные
  • Создает новый кеш, если время для необходимости его создания наступило
  • Если кешированные данные не актуальны и новый кеш создан, то происходит замена старого кеша на новый и отдача соответствующих данных из нового кеша
  • Если ни один из кешей не актуален то выполняется естественное получение данных, а также запись их в кеш

Таким образом с помощью параметра $SoonPeriod мы можем каждый кеш-кусок обновить в разные моменты времени, и практически безболезненно выдать новую информацию в один момент. Конечно же, это не избавит нас полностью от вышеописанных проблем, но все же при некоторых экспериментах со значениями $SoonPeriod для конкретного проекта поможет снизить нагрузку на сервер.
Функция SetCallback может принимать как метод объекта, так и функцию для получения кешируемых данных. Остальные две функции выполняют внутреннюю работу.
Теперь опишем файловый backend, который следует вышеобъявленному интерфейсу:
  1. <?php
  2. class Cachefilebackend implements Cachebackend {
  3.   /**
  4.    * @see Cachebackend
  5.    */
  6.   public function GetCache($Key)
  7.   {
  8.     clearstatcache();
  9.     if (file_exists($Key)) {
  10.       $Content = '';
  11.       $Content = @file_get_contents($Key);
  12.       return unserialize((string)$Content);
  13.     }
  14.     else {
  15.       throw new Exception('Cached keyfile "' . $Key. '" does not exists');
  16.     }
  17.   }
  18.   /**
  19.    * @see Cachebackend
  20.    */
  21.   public function IsActual($Key, $ExpiredPeriod)
  22.   {
  23.     clearstatcache();
  24.     if (file_exists($Key)) {
  25.       if (time() - filemtime($Key) > $ExpiredPeriod) {
  26.         @unlink($Key);
  27.         return FALSE;
  28.       }
  29.       else {
  30.         return TRUE;
  31.       }
  32.     }
  33.     return FALSE;
  34.   }
  35.   /**
  36.    * @see Cachebackend
  37.    */
  38.   public function IsNeedNewCache($Key, $ExpiredPeriod, $SoonPeriod, $Postfix)
  39.   {
  40.     clearstatcache();
  41.     if (file_exists($Key)) {
  42.       if ((time() - filemtime($Key) > $ExpiredPeriod - $SoonPeriod) && (
  43.         (!file_exists($Key . $Postfix)))) {
  44.         return TRUE;
  45.       }
  46.       else {
  47.         return FALSE;
  48.       }
  49.     }
  50.     else {
  51.       return FALSE;
  52.     }
  53.   }
  54.   /**
  55.    * @see Cachebackend
  56.    */
  57.   public function PutCache($Key, $Data)
  58.   {
  59.     if($Key) {
  60.       @file_put_contents($Key, serialize($Data));
  61.     }
  62.     else {
  63.       throw new Exception('Cache key is empty');
  64.     }
  65.   }
  66.   /**
  67.    * @see Cachebackend
  68.    */
  69.   public function Rename($PreparedKey, $RenameKey)
  70.   {
  71.     return @rename($PreparedKey, $RenameKey);
  72.   }
  73. }
  74. ?>
* This source code was highlighted with Source Code Highlighter.

Думаю теперь надо привести пример использования. Допустим, у вас есть объект $UserData, который возвращает одни данные c помощью метода GetData(), и функция GetUserText(), возвращающая другие данные. В обе функции передается параметр — идентификатор пользователя. Результат их работы складывается как строки и отдается на съедение браузеру. Мы закешируем эти данные на день и подготовим кеш объекта за час до конца дня, а кеш функции за два часа.
  1. <?php
  2. //Инициализируем пользователя
  3. $User = new User();
  4. //Инициализируем данные пользователя
  5. $UserData= new UserData($User->Id);
  6. //Настраиваем кеширование данных пользователя
  7. Cacher::SetCallback('GetData', array($User->Id), $UserData);
  8. //Получаем данные пользователя
  9. $Response = Cacher::GetData('userdata', md5($User->Id), 24 * 60 * 60, 60 * 60);
  10. //Настраиваем кеширование текста пользователя
  11. Cacher::SetCallback('GetUserText', array($User->Id));
  12. //Получаем текст пользователя
  13. $Response .= Cacher::GetData('usertext', md5($User->Id), 24 * 60 * 60, 120 * 60);
  14. //Отдаем результат
  15. echo $Response;
  16. ?>
* This source code was highlighted with Source Code Highlighter.


Естественно, все сработает как мы хотели только если хотя бы один пользователь обратиться к серверу не ранее чем за час до начала нового дня, поэтому выбор времени создания нового кеша зависит от множества факторов конкретного проекта. Так что всё в ваших руках.

комментарии (40)

  • да, мысль про «отложенное» кэширование интересна. Побольше бы материалов, внедрим в Web Optimizer
  • Кеш — мужского рода и пишется без мягкого знака.
    • Проверочное — Кеша.
      • Ага — Инокентий :-)
  • качественный блоггер, только исправить 'Коментирии'

    лично я событием для синхронизации кэша выбираю запись измененного контента
    • Да, бывают разные приложения. Возможно, придет время и вам надо будет обновлять кеш не чаще раза в пять минут, причём постоянно. И если он формируется довольно «тяжелым» путем, логично было бы поступить вышеописанным образом.
    • «качественный блоггер» — интересно, почему такое мнение? Из-за отсутствия WP?
  • А я бы решал задачу обновления с учетом всего вышесказанного, но + одно весьма важное замечание:
    1) обновлять кэш ТОЛЬКО в том случае, если данные действительно обновились. А это значит нужно где-то держать флаг изменения данных
    2) обновлять кэш отдельным процессом, а не в момент, когда обращается пользователь. Это приводит к тому, что пользователь видит ВСЕГДА КЭШ. А вот обновление происходит на кроне в *nix (или расписании под винду). Какие выгоды: 1. нет никаких конфликтов, потому что файлы обновляет один единственный процесс вне зависимости от количества пользователей и частоты их обращения, 2. нагрузка распределенная, то есть потенциально сервер нагружен равномерно, а не большими пиками в момент массового обновления кэша.
    3) кэш формировать отдельным именем, а потом простыми операциями заменять старый файл на новый готовый (когда он уже не формируется)
    4) иногда думал об использовании версионности кэша. Это значит, что создаются файлики типа cache.block12345.67890, где 67890 — это версия кэша. Допустим где-то в базе данных лежит привязка на то, что актуальный кэш лежит в версии 67889. Потом фоновый процесс создает файлик 67890 и заменяет привязку к версии на более актуальную. Это приводит к тому, что нет сложных файловых операций. По сути происходит лишь создание нового файла. В нагруженных системах очень хорошо работает. 1000-чи пользователей сначала обращаются к 67889, а потом единомоментно начинают читать 67890… переход от одной версии к другой в этом случае происходит со скоростью обновления записи в БД.
    • +1
      Только вот одно замечание — часто кеш делается так чтобы к базе вобще не обращаться. Поэтому текущую версию кеша лучше все-таки хранить в виде обычной константы в файлике настроек, или в ENV или еще где-то, откуда её можно считать без дополнительных затрат.
    • Я тоже думал о версионности кеша, но, действительно, надо где-то хранит информацию об актуальной версии. Думаю такая методика особенно подойдет в тех случаях, когда отключаются временные метки для ускорения работы на дисковом уровне.
      Насчет первого пункта могу сказать, что обновляться могут большие объемы данных, поэтому их можно обновить по частям в разные моменты времени.
    • может, воспользоваться array_pop(glob('cache.block12345.*'));? если удалять старые версии кеша, то будет самое то, имхо.
    • Кстати, не обязательно запускать по крону обновление кэша. Ведь чтобы по крону, это надо пробегаться по всему кэшу с соответствующими проверками и последовательной генерацией. Хотя можно запускать генерацию недостающего кэша уже сразу после того, как запрашиваемая страница пользоватля передана сервером. Тут можно либо в том же потоке, либо в отдельном сгенерировать недостающие блоки.
    • Где-то слышал используется система кеша на эвент изменения данных, то есть в момент когда изменились данные вызывается функция обновления кеша. Единственный вопрос тут у меня возник с очень динамическими данными то есть к примеру количество просмотров, но и тут можно решить отложеным версионным кешем.
    • Есть предложение, привязку версий сделать симлинками. а смену актуальной версии кеша
      unlink cache.block12345 && link -s cache.block12345 cache.block12345.newversion
      • Да, это решение подходит для *nix систем.
        в винь — ярлыки?… в целом да, в случае, когда нет желания хранить актуальную версию в базе, можно хранить в линке. Согласен
  • А вот объясните мне профит от вытягивания кеша самим ПХП? Почему бы отдачу не отдать на откуп веб-серверу?
    • Смотря что вы подразумеваете? Чисто заголовками управлять?
      • 1. Ровно то, что я написал.
        2. Нет. Проще. Класть кешированные страницы как html-файлы в папку public. Так в рельсах при полном кешировании страницы делается, например.
        • А если надо закешировать какие-то промежуточные данные?
          Думаю веб-сервер это не сможет.
          • 1. А надо?
            1.1 Если промежуточные данные все равно встраивать, то в случае обычных страниц все равно собирать скриптом и не быстро и не гибко. Тот же сборочный скрипт может и дать полностью кешированную страницу в структуру веб-сервера.
            1.2 Если это аякс, то опять же что мешает аяксу дать страницу, которая будет частью конечной, статическую, кешированную, от веб-сервера.
            2. Сможет — SSI.
            • 1.1 — а в случае не обычных страниц?
              Нет, я не спорю, я же не предлагаю «способ решение всех проблем» ;)
        • а как же примитивная логика, как показать кнопку «Вход» для незалогиненых и кнопку «Выход» для залогиненых. ЖС не предлагать
        • Это вполне важное замечание. В реализации подобного кэша, можно отвечать на один вопрос: кэшируемая страница нуждается ли в сессии? Для тех же незарегистрированных пользователей сайта. можно отдавать страницы как статику без запуска php. Веб-сервер nginx как раз позволяет с соответствующим модулем отдавать не только статику из файлов, но и, например, из memcached.
  • Посылать инвалидирующие кеш сигналы из CMS (когда, например, записывается страница) вместо устаревание по времени не позволит значительно снизить нагрузку?
  • А можно хранить указатель на какой-то кэш и при обновлении кэша — сперва записывать на диск, а потом менять указатель.

    +дополнительный флаг о том что обновление кэша уже запущено
  • я б просто использовал класс memcached :) заодно и винт бы не мучал.
    • я бы тоже. А если его нет?
      Причем он тоже обновляется, приводить к конфликтам и тоже внезапно может потянуть тяжелый бэкенд. Поэтому скрыто обновить кеш частями — тоже вариант.
      • Тут проблема в том что то что вы делаете с диском это блокирующая операция и при большой конкурентности будет прилично расти la сервера.
        На каждый ключ у вас фал — это очень плохая практика, забудьте ее, для таких веще лучше использовать спец БД, например bdb.
        Так же у вас нет блокировок, и на GetData при двух конкурентных запросах может получится так, что в кеш единовременно положили данные два запроса, но запрос со старыми данными лег позднее чем с новыми — потеря данных.
        • блокировки тут вы могли бы сделать на основе оптимистической конкуренции
        • согласен, тут явно не идеальная реализация ибо я основной упор делал на «размытии» момента формирования кеш данных
      • в мемкеш, кстати, такие блокировки решаются cas-ом
  • flock()
  • не сочтите за рекламу, но подобное обсуждалось ещё здесь (помню, были ремарки из зала, не знаю, слышно ли их назаписи):
    highload.ru/papers2008/7158.html
    просто смахивает на создание очередного велосипеда, не обижайтесь.
  • Неправильно распознает при
    <?php
    if(...) {
    ?>
    html код
    <?php
    } else {
    //здесь код php почему-то не распознается
    }
    ?>
    • Блин, не в тот топик комментарий. Надо было в WebIDE
  • Пример с использование Zend Framework
  • Я сделал так (выдержка из класса):

    public function load ( $cache_name )
    {
    $cache_file = $this->cache_dir. DIRECTORY_SEPARATOR. $cache_name. '.php';

    if ( filemtime ( $cache_file ) < time () )
    {
    $this->clear ( $cache_name );
    return false;
    }

    if (! file_exists ( $cache_file ))
    {
    return $this->error = CACHE_FILE_NOT_EXISTS;
    }

    $content = @unserialize( @file_get_contents( $cache_file ));

    if (! $content or! is_array ( $content ))
    {
    return false;
    }

    $this->debug[ 'load' ][ $cache_name ] ++;

    return $content;
    }

    public function save ( $cache_name, $value, $expire = 0 )
    {
    $cache_file = $this->cache_dir. DIRECTORY_SEPARATOR. $cache_name. '.php';

    @file_put_contents ( $cache_file, serialize( $value ), LOCK_EX );

    if (! file_exists ( $cache_file ))
    {
    return false;
    }

    if ( $expire != 0 )
    {
    @touch( $cache_file, time () + $expire );
    }

    @chmod ( $cache_file, 0666 );

    $this->debug[ 'save' ][ $cache_name ] ++;
    }
Только авторизованные пользователи могут оставлять комментарии. Авторизуйтесь, пожалуйста.