В статье будут рассмотрены рекомендации стандарта PSR и реализация кэш-сервисов в соответствии с этими стандартами, а также различные программные решения, кластеризация кэша и рекомендации по использованию.
Скачок роста проекта и нагрузки на него могут стать настоящим испытанием для разработчика. Веб-сайт начинает отвечать с большой задержкой, и всё важнее становится вопрос масштабирования. Существует множество эффективных решений для повышения устойчивости проекта к нагрузке и скорости его работы, и один из самых базовых — кэширование.
Кэширование — это сохранение данных в высоко доступных местах на временной основе для того, чтобы их можно было получать быстрее, чем из оригинального источника. Самый распространенный пример применения кэша — получение данных из базы. При первом получении, допустим, продукта из базы данных, он сохраняется в кэш на определённое время, поэтому каждый следующий запрос к этому продукту уже не будет тревожить БД: данные будут получены из другого хранилища.
Какие бывают подходы?
Существует множество подходов к кэшированию. Список совместимых с PHP инструментов можно посмотреть на странице PHP-cache. Самые распространенные из них:
- Apcu
- Array
- Memcached
- Redis
Давайте разберемся, какие особенности есть у каждого из них и чем они отличаются друг от друга.
APCu
Один из самых распространённых и простых в настройке инструментов кэширования, сохраняет нужные нам данные в оперативную память. (Ещё умеет кэшировать промежуточный код, но это уже совсем другая история) Чтобы начать работу с APCu, необходимо убедиться, что он установлен. Для этого в командной строке запустите следующую команду:
php -i | grep 'apc.enabled' # Ожидаем увидеть: # apc.enabled => On => On
Другой способ проверки: создайте файл index.php и поместите в него вызов функции phpinfo(). Убедитесь, что у вас настроен веб-сервер для используемой директории и откройте скрипт в браузере через адрес сервера. Нас интересует секция APCu: если внутри неё есть пункт APCu Support: Enabled, значит всё хорошо, мы можем идти дальше.
Если APCu у вас не установлен, сделать это можно следующим способом:
- Запустите окно терминала (Linux/MacOS) или командную строку (Windows. Введите в поиске "cmd").
- Выполните команду:
pecl install apcu apcu_bc
-
Откройте в любом текстовом редакторе файл конфигурации php.ini и убедитесь в наличии следующих строк:
# Windows extension=php_apcu.dll extension=php_apcu_bc.dll apc.enabled=1 apc.enable_cli=1 #Linux / MacOS extension="apcu.so" extension="apc.so" apc.enabled=1 apc.enable_cli=1
- Если указанных строк нет, добавьте их и сохраните файл конфигурации.
- Повторите проверку наличия установленного APCu.
Для использования этого подхода кэширования нам понадобятся основные функции. Вот пример их применения:
$cacheKey = 'product_1'; $ttl = 600; // 10 минут. // Проверка доступности APCu $isEnabled = apcu_enabled(); // Проверяет, есть ли данные в кэше по ключу $isExisted = apcu_exists($cacheKey); // Сохраняет данные в кэш. В случае успеха возвращает true // Аргумент $ttl определяет, как долго будет храниться кэш (секунды) $isStored = apcu_store($cacheKey, ['name' => 'Demo product'], $ttl); // Получает данные из кэша по ключу. В случае их отсутствия, вернет false $data = apcu_fetch($cacheKey); // Удаляет данные из кэша по ключу $isDeleted = apcu_delete($cacheKey); var_dump([ 'is_enabled' => $isEnabled, 'is_existed' => $isExisted, 'is_stored' => $isStored, 'is_deleted' => $isDeleted, 'fetched_data' => $data, ]);
Любой кэш работает по принципу key-value хранилища: это значит, что данные сохраняются со специальным ключом, по которому и происходит обращение. В данном случае ключ хранится в переменной $cacheKey.
Важно! Этот подход работает только при работе в режиме веб-сайта, то есть при запуске из командной строки вы не будете получать данные из кэша, а всё, что вы в него сохранили, будет очищено по завершению работы скрипта. Однако это не вызовет никаких ошибок.
Array-кэш
Более простой, но не всегда применимый метод кэширования. Если APCu сохраняет данные и делает их доступными для последующих выполнений всеми процессами, то Array-кэш хранит их только в рамках обрабатываемого запроса.
Что это значит? Представим, что у вас есть страница с комментариями пользователей. Один пользователь может оставить несколько сообщений, и когда мы будем собирать массив этих данных, нам не захочется несколько раз ходить в базу данных за одним и тем же пользователем. Что мы можем сделать, так это сохранить полученные данные в массив, чтобы при его наличии не делать повторный запрос. Этот принцип очень прост и так же просто реализуется. Давайте напишем класс, который будет выполнять подобное сохранение:
class CustomArrayCache { /** * Массив приватный и статический * - приватный — чтобы обращаться к нему можно было только * из методов класса. * - статический — чтобы свойство было доступно во всех экземплярах */ private static array $memory = []; // Метод сохранения данных в памяти public function store(string $key, $value): bool { self::$memory[$key] = $value; return true; } // Метод получения данных из памяти public function fetch(string $key) { return self::$memory[$key] ?? null; } // Метод удаления данных из памяти public function delete(string $key): bool { unset(self::$memory[$key]); return true; } // Метод проверки наличия данных по ключу public function exists(string $key): bool { return array_key_exists($key, self::$memory); } }
Из-за своей ограниченности этот подход применяется редко, однако знать о нём полезно.
Memcached и Redis
Наиболее продвинутые подходы кэширования. Подразумевают наличие запущенного отдельно сервера Memcached или Redis. Из PHP мы подключаемся к этому серверу по адресу и порту. Конфигурация этих решений сложнее, чем настройка APCu, но метод хранения данных очень похож: оперативная память. Самыми главными их преимуществами являются
- изолированность от PHP: за кэш отвечают отдельные сервисы;
- возможность кластеризации: если нагрузка на ваш проект очень велика, кластеризация сервисов кэширования поможет с ней справиться.
В этой статье мы не будем вдаваться в подробности настройки Memcached и Redis. На этом этапе нам важно помнить, что, если нагрузка очень высокая, нам следует смотреть в сторону именно этих решений, так как они имеют хороший потенциал к масштабированию.
Стандарт PSR-16
В PSR есть два стандарта, посвящённых кэшированию: PSR-6 (обычный интерфейс кэширования) и PSR-16 (простой интерфейс кэширования) — мы сосредоточимся на PSR-16.
Этот стандарт предлагает специальный интерфейс (CacheInterface), которому могут удовлетворять классы, выполняющие функцию кэширования. Согласно ему, такие классы должны реализовывать следующие методы:
- get($key, $default) — получение данных из кэша: вторым аргументом передаётся значение, которое будет возвращено в случае отсутствия этих данных;
- set($key, $value, $ttl = null) — сохранение данных в кэш: как мы уже видели ранее, третьим параметром передаётся время хранения в секундах. Если оставить его пустым (null), значение будет подставлено по умолчанию из конфигурации кэша;
- delete($key) — удаляет данные по ключу;
- clear() — очищает все хранилище;
- getMultiple($keys, $default) — позволяет получить данные сразу по нескольким ключам;
- setMultiple($values, $ttl = null) — позволяет записать сразу несколько значений. В качестве $value мы передаем ассоциативный массив, где ключ — $key для кэша, а значение — данные для сохранения;
- deleteMultiple($keys) — удаляет данные по нескольким ключам;
- has($key) — проверяет наличие данных по ключу.
Как вы можете заметить, интерфейс очень прост, и даже тех функций, что мы рассмотрели в примере с APCu, достаточно для того, чтобы написать свой сервис кэша в соответствии с PSR-16. Но зачем это нужно?
Главные преимущества соблюдения стандартов PSR заключаются в том, что
- они поддерживаются большинством популярных библиотек;
- многие PHP-программисты придерживаются PSR и с легкостью освоятся в вашем коде;
- благодаря интерфейсу, мы можем легко подменять используемый сервис на любой другой, поддерживающий PSR-16.
Давайте подробнее рассмотрим последний пункт и его преимущества.
Подключение PSR-16 библиотек
Библиотеки, создающие «обертку» над существующими инструментами кэширования для соответствия интерфейсу называются адаптерами. Для примера, рассмотрим адаптеры тех методов, что мы уже обсудили:
Все они удовлетворяют PSR-16 и поэтому применяются одинаково, однако логика «под капотом» у каждого своя.
Для примера давайте загрузим APCu- и Array-адаптеры в наш проект с помощью Composer.
composer require cache/array-adapter composer require cache/apcu-adapter # Или composer req cache/apcu-adapter cache/array-adapter
Давайте представим, что у нас есть специальный класс для получения продуктов из базы данных. Назовем его ProductRepository, у него есть метод find($id), который возвращает продукт по его идентификатору, а если такого продукта нет — null.
class ProductRepository { /** * Чтобы не усложнять пример, обусловимся, что в качестве продукта * возвращается массив, а если его нет — null */ public function find(int $id): ?array { // ... // Получаем данные из БД return $someProduct; } }
Если мы хотим подключить кэширование, мы не должны делать это внутри репозитория, потому что его ответственность — возвращать данные из базы данных. Куда же мы тогда добавим кэш? Есть несколько популярных решений, самое простое — дополнительный класс-провайдер. Всё, что он будет делать — пробовать получить данные из кэша, а если это не получится — обратится в репозиторий. Для этого в конструкторе такого класса определим две зависимости — наш репозиторий и CacheInterface. Почему именно интерфейс? Потому что так мы сможем использовать абсолютно любой из упомянутых адаптеров или других классов, удовлетворяющих PSR-16.
class ProductDataProvider { private ProductRepository $productRepository; private CacheInterface $cache; public function __construct(ProductRepository $productRepository, CacheInterface $cache) { $this->productRepository = $productRepository; $this->cache = $cache; } public function get(int $productId): ?array { $cacheKey = sprintf('product_%d', $productId); // Пробуем получить продукт из кэша $product = $this->cache->get($cacheKey); if ($product !== null) { // Если продукт есть, возвращаем // Временно выведем echo, чтобы понять, что данные из кэша echo 'Данные из кэша' . PHP_EOL; // PHP_EOL - перенос строки return $product; } // Если продукта нет, получаем его из репозитория $product = $this->productRepository->find($productId); if ($product !== null) { // Теперь сохраним полученный продукт в кэш для будущих запросов // Также временно выведем echo echo 'Данные из БД' . PHP_EOL; $this->cache->set($cacheKey, $product); } return $product; } }
Наш класс готов. Теперь давайте рассмотрим его применение в сочетании с APCu-адаптером.
use Cache\\Adapter\\Apcu\\ApcuCachePool; // Подключаем автозагрузчик Composer require_once 'vendor/autoload.php'; // Наш репозиторий $productRepository = new ProductRepository(); // APCu-кэш адаптер. Не требует никаких дополнительных настроек $cache = new ApcuCachePool(); // Создаем провайдер, передаем зависимости $productDataProvider = new ProductDataProvider( $productRepository, $cache ); // Если в БД есть такой продукт, он к нам вернется $product = $productDataProvider->get(1); var_dump($product);
Если же мы захотим, заменить APCu-кэширование на Array-адаптер или любой другой, мы просто передадим новый подход в провайдер вместо старого, потому что все они реализуют CacheInterface.
use Cache\\Adapter\\PHPArray\\ArrayCachePool; // ... $productRepository = new ProductRepository(); //$cache = new ApcuCachePool(); $cache = new ArrayCachePool(); $productDataProvider = new ProductDataProvider( $productRepository, $cache ); // ...
Состояние гонки и обновление данных
Кэш работает до тех пор, пока мы содержим его в актуальном состоянии. Это значит, что, если пользователь хочет обновить продукт, то продукт должен обновиться и в базе данных, и в нашем кэше. Однако здесь есть один важный нюанс.
Представим, что нашим проектом пользуется очень большое количество пользователей, и двое из них одновременно обновляют одну и ту же сущность. В этом случае, может возникнуть такая ситуация:
- пользователь 1 получил сущность из кэша;
- пользователь 1 обновил сущность в БД;
- пользователь 2 получил сущность из кэша;
- пользователь 1 обновил данные в кэше;
- пользователь 2 обновил сущность в БД, но перезаписал её старыми данными, потому что сущность была неактуальна на момент получения и т. д.
Такая ситуация называется состоянием гонки, когда несколько процессов обращаются одновременно к одному и тому же ресурсу, и может возникнуть конфликт версий. Чтобы избежать такой проблемы, следует придерживаться одного простого правила:
Когда вы получаете любую сущность в коде с целью её обновления, всегда используйте данные из БД.
В любой ситуации, когда нам нужно получить продукт и мы не собираемся его обновлять — используем кэш. Если же мы хотим его обновить — обращаемся к данным из БД.
Вы можете либо обращаться в нужных местах к ProductRepository вместо ProductDataProvider, либо добавить аргумент к методу DataProvider. Например, такой ($fromCache):
class ProductDataProvider { // ... public function get(int $productId, bool $fromCache = true): ?array { $cacheKey = sprintf('product_%d', $productId); $product = $fromCache ? $this->cache->get($cacheKey) : null; if ($product !== null) { return $product; } $product = $this->productRepository->find($productId); if ($product !== null) { $this->cache->set($cacheKey, $product); } return $product; } }
Заключение
Кэширование требует от разработчика дополнительных усилий при разработке проекта, и его применение не всегда может быть целесообразно. Решение применять его или нет должно быть основано на предполагаемой (или фактической) нагрузке и ваших ожиданиях от скорости отклика пользователю.
Однако вне зависимости от того, будете ли вы применять эти подходы в ваших текущих проектах или нет, стоит изучить их и применить на практике, потому что этот навык обязательно пригодится вам в работе в крупных командах.
Подводя итог, повторим ключевые идеи статьи:
- Соблюдение PSR-16 (или PSR-6) позволит вам с легкостью подключить для кэширования стороннюю библиотеку и сделает ваш код понятным другим разработчикам.
- Для небольших проектов хорошим решением для кэширования станет APCu, т. к. он прост в настройке и использует оперативную память, доступ к которой очень высокий.
- Для всех совместимых с PHP-инструментов кэширования есть адаптеры, которые можно посмотреть на сайте php-cache.com.
- Кэширование — отдельная ответственность. Старайтесь реализовывать работу с кэшем в отдельных классах.
- Если мы собираемся обновить сущность, её следует получать из БД. Если сущность нужна нам только для просмотра — мы можем запросить её из кэша.
- В крупных проектах для получения возможности масштабирования применяются Memcached или Redis.