Ускоряем PHP-проект с помощью кэширования

Кэширование — инструмент, позволяющий совладать с первыми скачками нагрузки на приложение, а также абсолютный must-have в крупных проектах
8 минут1487

В статье будут рассмотрены рекомендации стандарта 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 у вас не установлен,  сделать это можно следующим способом:

  1. Запустите окно терминала (Linux/MacOS) или командную строку (Windows. Введите в поиске "cmd").
  2. Выполните команду:
pecl install apcu apcu_bc

       3. Откройте в любом текстовом редакторе файл конфигурации 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
  1. Если указанных строк нет, добавьте их и сохраните файл конфигурации.
  2. Повторите проверку наличия установленного 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.
программированиеphpкэшированиеразработка бэкэнд
Нашли ошибку в тексте? Напишите нам.
Спасибо,
что читаете наш блог!