Кеширование в PHP - теперь немного лучше, чем просто кеш
В этой короткой статье я расскажу, как я убирал тривиальность из политики кеширования на файлах в PHP сценариях. В принципе это всё можно применить и не к "файловому" кешированию. Надеюсь, многим эта статейка принесет пользу.
Итак, что же мне не нравилось в моей тривиальности, и что я хотел бы улучшить?
Во-первых, меня очень напрягала обстановка, когда несколько килобайт кеша вдруг одновременно устаревало и могло довольно прилично нагрузить сервер. А во-вторых, мой мозг до некоторого момента все время мучила мысль о непосредственной работе с файлами, а точнее - создание и удаление.
Все эти беды происходили от выбранного мною тривиального подхода к управлению кешированием. Суть его заключалась в следующем.
Сначала я проверял актуальность кешированных данных и если они таковыми не являлись, то проводил операцию получения данных и записывал "свежий" кеш на диск. Иначе - если кеш был актуален - просто брал данные от туда. Вот пример из моего старенького шаблонизатора:
Listing №1 (PHP)
<?php
if ($this->_Cache->IsActual()) {
return $this->_Cache->Read();
}
else {
$Content = $this->Parse($File);
$this->_Cache->Write($Content);
return $Content;
}
?>
При этом проверка актуальности выглядела приблизительно так:
Listing №2 (PHP)
<?php
public function IsActual()
{
clearstatcache();
if (!file_exists($this->_Id)) {
return FALSE;
}
if (!($CreateTime = filemtime($this->_Id))) {
return FALSE;
}
if (($CreateTime + $this->_Expired) < time()) {
@unlink($this->_Id);
return FALSE;
}
return TRUE;
}
?>
И вот, в один прекрасный день я решил попытаться избавиться от таких проблем:
Одновременное обновление большого количества кешированных данных. То есть при практически одновременном обращении пользователей в момент устаревания кеша, происходит не только большая нагрузка на сервер но и конфликты кеширования. Большая нагрузка происходит от того, что кеш не может быстро обновиться, так как данных довольно много. Поэтому каждый пользователь фактически заново запускает процедуру обновления кеша, что влечет за собой конфликты, которые в свою очередь оказывают еще большую нагрузку на сервер
Медленное обновление кеша, вследствие его немалого объема. То есть необходимо, большой кеш обновить как можно быстрее
Большое спасибо Джорджу Шлосснейглу, за прекрасный простой приём, используя который можно организовать решение этих двух проблем. По сути Джордж предлагает заменить операции удаления старого кеша и создания нового кеша одной операцией - заменой старого кеша. Стоило немного подумать над приемом Джорджа и из него вытекло решение всех вышеописанных проблем:
Разбиваем большой кешь на отдельные куски
Заранее готовим каждый из кусков в разные моменты времени, маскируя наличие нового кеша
Заменяем старый кеш на новый быстрым методом
Вот и всё - очень просто и со вкусом.
Теперь, по традиции, непосредственно сама реализация. Она далека от идеала и предназначена всего лишь для объяснения сути. Но все же давайте раздуем примерчик интерфейсом для backend-а. А также, в связи с тем, что я все данные получаю из функций и методов, я "приноровлю" механизм кеширования к методам получения возвращаемых результатов.
Для начала сконструируем интерфейс с нужными функциями:
Listing №3 (PHP)
<?php
interface Cachebackend {
/**
* @param string $Key
*/
public function GetCache($Key);
/**
* @param string $Key
* @param int $ExpiredPeriod
*/
public function IsActual($Key, $ExpiredPeriod);
/**
* @param string $Key
* @param int $ExpiredPeriod
* @param int $SoonPeriod
* @param string $SoonFuseKeyPostfix
*/
public function IsNeedCreateNewCache($Key, $ExpiredPeriod, $SoonPeriod, $SoonFuseKeyPostfix);
/**
* @param string $Key
* @param string $Data
*/
public function PutCache($Key, $Data);
/**
* @param stirng $PreparedKey
* @param string $RenameKey
* @return bool
*/
public function Rename($PreparedKey, $RenameKey);
}
?>
Теперь сконструируем непосредственный каркас с нашим алгоритмом:
Listing №4 (PHP)
<?php
class Cacher {
/**
* @var Cachebackend
*/
private static $_Backend = NULL;
/**
* @var mixed
*/
private static $_CallbackSignature = array();
/**
* @var array
*/
private static $_CallbackArguments = array();
/**
* @static
* @param string $Tag
* @param string $Key
* @param int $ExpiredPeriod
* @param int $SoonPeriod
* @param sting $SoonFuseKeyPostfix '.next'
* @return mixed
*/
public static function GetData($Tag, $Key, $ExpiredPeriod, $SoonPeriod, $SoonFuseKeyPostfix = '.next')
{
$Key = CACHE_PATH . strtolower($Tag) . '_' . strtolower($Key) . CACHE_EXT;
if (NULL === self::$_Backend) {
self::$_Backend = new Cachefilebackend;
}
if (self::$_Backend->IsActual($Key, $ExpiredPeriod)) {
$Data = self::$_Backend->GetCache($Key);
if(self::$_Backend->IsNeedCreateNewCache($Key, $ExpiredPeriod, $SoonPeriod, $SoonFuseKeyPostfix)) {
$CallbackData = self::_GetCallbackResult();
self::$_Backend->PutCache($Key . $SoonFuseKeyPostfix, $CallbackData);
}
return $Data;
}
else {
if(self::$_Backend->IsActual($Key . $SoonFuseKeyPostfix, $ExpiredPeriod)) {
$Data = self::$_Backend->GetCache($Key . $SoonFuseKeyPostfix);
self::$_Backend->Rename($Key . $SoonFuseKeyPostfix, $Key);
return $Data;
}
else {
$CallbackData = self::_GetCallbackResult();
self::$_Backend->PutCache($Key, $CallbackData);
return $CallbackData;
}
}
}
/**
* @static
* @param string $CallbackFunction
* @param array $CallbackArguments
* @param mixed $CallbackObject NULL
*/
public static function SetCallback($CallbackFunction, $CallbackArguments, $CallbackObject = NULL)
{
if ($CallbackObject) {
self::$_CallbackSignature = array($CallbackObject, $CallbackFunction);
}
else {
self::$_CallbackSignature = $CallbackFunction;
}
self::$_CallbackArguments = $CallbackArguments;
}
/**
* @static
* @return mixed
*/
private static function _GetCallbackResult()
{
self::_CheckCallback();
return call_user_func_array(self::$_CallbackSignature, self::$_CallbackArguments);
}
/**
* @static
*/
private static function _CheckCallback()
{
if(!is_callable(self::$_CallbackSignature, FALSE, $CallableName)) {
throw new Exception($CallableName . ' is not correct callback');
}
if(!is_array(self::$_CallbackArguments)) {
throw new Exception('Callback arguments must be an array');
}
}
}
?>
Давайте немного разберемся, что к чему.
Итак, основной метод - это метод GetData. Что мы передаем ему:
$Tag - идентификатор группы кеш-данных. Предназначен для управления целой группой кеш-данных.
$Key - идентификатор конкретных кеш-данных. Однозначно идентифицирует кешируемые данные.
$ExpiredPeriod - время жизни кеша. Это время можно даже назвать как время "псевдо" жизни, ведь новый кеш появится до истечения этого времени, хотя старый и удалиться именно по этому значению.
$SoonPeriod - период времени, который определяет временной интервал между появлением нового кеша и удалением старого: T(удаления старого кеша) - T(создания нового кеша) = $SoonPeriod
$SoonFuseKeyPostfix - параметр по умолчанию, обозначающий расширение файлов нового кеша, чтобы не путать их с еще существующими файлами старого кеша.
Константы CACHE_PATH и CACHE_EXT определяют кеш-директорию и расширение кеш-файлов соответственно. Метод GetData создает объект backend-а и выполняет соответствующие действия по управлению кешем:
Проверяет актуальности кеша и если он таков, то отдает его данные
Создает новый кешь, если время для необходимости его создания наступило
Если кешированные данные не актуальны и новый кешь создан, то происходит замена старого кеша на новый и отдача соответствующих данных из нового кеша
Если ни один из кешей не актуален то выполняется естественное получение данных, а также запись их в кеш
Таким образом с помощью параметра $SoonPeriod мы можем каждый кеш-кусок обновить в разные моменты времени, и практически безболезненно выдать новую информацию в один момент. Конечно же, это не избавит нас полностью от вышеописанных проблем, но все же при некоторых экспериментах со значениями $SoonPeriod для конкретного проекта поможет снизить нагрузку на сервер.
Функция SetCallback может принимать как метод объекта, так и функцию для получения кешируемых данных. Остальные две функции выполняют внутреннюю работу.
Теперь опишем файловый backend, который следует вышеобъявленному интерфейсу:
Listing №5 (PHP)
<?php
class Cachefilebackend implements Cachebackend {
/**
* @see Cachebackend
*/
public function GetCache($Key)
{
clearstatcache();
if (file_exists($Key)) {
$Content = '';
$Content = @file_get_contents($Key);
return unserialize((string)$Content);
}
else {
throw new Exception('Cached keyfile "' . $Key. '" does not exists');
}
}
/**
* @see Cachebackend
*/
public function IsActual($Key, $ExpiredPeriod)
{
clearstatcache();
if (file_exists($Key)) {
if (time() - filemtime($Key) > $ExpiredPeriod) {
@unlink($Key);
return FALSE;
}
else {
return TRUE;
}
}
return FALSE;
}
/**
* @see Cachebackend
*/
public function IsNeedCreateNewCache($Key, $ExpiredPeriod, $SoonPeriod, $SoonFuseKeyPostfix)
{
clearstatcache();
if (file_exists($Key)) {
if ((time() - filemtime($Key) > $ExpiredPeriod - $SoonPeriod) && (
(!file_exists($Key . $SoonFuseKeyPostfix)))) {
return TRUE;
}
else {
return FALSE;
}
}
else {
return FALSE;
}
}
/**
* @see Cachebackend
*/
public function PutCache($Key, $Data)
{
if($Key) {
@file_put_contents($Key, serialize($Data));
}
else {
throw new Exception('Cache key is empty');
}
}
/**
* @see Cachebackend
*/
public function Rename($PreparedKey, $RenameKey)
{
return @rename($PreparedKey, $RenameKey);
}
}
?>
Думаю теперь надо привести пример использования. Допустим, у вас есть объект $UserData, который возвращает одни данные c помощью метода GetData(), и функция GetUserText(), возвращающая другие данные. В обе функции передается параметр - идентификатор пользователя. Результат их работы складывается как строки и отдается на съедение браузеру. Мы закешируем эти данные на день и подготовим кеш объекта за час до конца дня, а кеш функции за два часа.
Listing №6 (PHP)
<?php
//Инициализируем пользователя
$User = new User();
//Инициализируем данные пользователя
$UserData= new UserData($User->Id);
//Настраиваем кеширование данных пользователя
Cacher::SetCallback('GetData', array($User->Id), $UserData);
//Получаем данные пользователя
$Response = Cacher::GetData('userdata', md5($User->Id), 24 * 60 * 60, 60 * 60);
//Настраиваем кеширование текста пользователя
Cacher::SetCallback('GetUserText', array($User->Id));
//Получаем текст пользователя
$Response .= Cacher::GetData('usertext', md5($User->Id), 24 * 60 * 60, 120 * 60);
//Отдаем результат
echo $Response;
?>
Естественно, все сработает как мы хотели только если хотя бы один пользователь обратиться к серверу не ранее чем за час до начала нового дня, поэтому выбор времени создания нового кеша зависит от множества факторов конкретного проекта. Так что всё в ваших руках. Спасибо за внимание.