Простой случай сохранения до события изменения (Event)

Алгоритм сохранения до события очень простой и понятный. Нет смысла приводить код в силу его простоты, ограничимся блок-схемой.

У нас два метода get для чтения значения и set для записи значения. Они будут универсальны для любого элемента КЭШа. Однако функция чтения и записи значения будет для каждого типа элемента КЭШа своя. И нам необходима удобная система для определения каждого такого типа элемента КЭШа. И раз уж я это реализовал на PHP в который давно завезли систему классов, то именно классы я и использую для такого определения. Выглядеть такой класс будет следующим образом:

// Определение элемента КЭШа - Пользователь
class UserCacheItem extends CacheInvalidationEvent
{
    protected function __construct(protected int $id)
    {
    }
    // Читать значение
    protected function read(): int
    {
        return readFromStorage($this->id);
    }
    // Записать значение
    protected function write(int $value): void
    {
        writeToStorage($this->id, $value);
    }
}

В конструкторе указываем ключевые поля (по ним и классу элемента КЭШ-а будет генерироваться ключ для сохранения в КЭШ-е). Метод read должен возвращать запрашиваемое значение. А метод write вызывается при изменении данных по ключу.
Код использования выглядит так:

////////////////////////////////
// Читать данные 
$value = UserCacheItem::get(777);
////////////////////////////////
// Изменить данные
UserCacheItem::set(777, $value + 666);

Метод get наследуется от родительского класса. В качестве параметров указываются ключевые поля (которые затем передаются в конструктор класса определения элемента КЭШа). При его вызове происходит

  1. Генерация ключа для КЭШа

  2. Проверка значения по ключу в КЭШе

  3. Вызов метода read если значения в КЭШе нет, а затем сохранение значения в КЭШ

Метод set наследуется от родительского класса. В качестве параметров указываются ключевые поля ( те что передаются в конструктор класса определения элемента КЭШа) + поле/поля значения. При его вызове происходит

  1. Вызов метода write

  2. Генерация ключа для КЭШа

  3. Удаление ключа из КЭШа (если он там есть)

Проблема простого случая сохранения до события изменения (Event)

Получился простой и понятный алгоритм. Но как только мы захотим применить его на практике, то столкнемся с серьезной проблемой - дочерние вызовы. К примеру у нас есть элемент КЭШа статья который при чтении использует вызов элемента КЭШа пользователь. В этом случае при изменении элемента пользователь элемент статья останется со старым значением. И чтобы этого не случилось, нам нужно учитывать дочерние вызовы. Т.е. при изменении дочернего элемента КЭШа необходимо пересчитывать значение всех родительских элементов КЭШа.

Стандартное решение описанной выше проблемы заключается в сохранении для каждого ключа уникальной метки + сохранение вместе со значением всех ключей-меток, от которых зависит данное значение. При изменении значения изменяется метка и удаляется значение из КЭШа. При чтении значения проверяются все метки и если есть несоответствие, то значение в КЭШе считается неактуальным и генерируется заново.

Возьмем простой пример. Элемент с ключом K1 зависит от элементов K2 и K3. Элемент K3 в свою очередь зависит от K4 и K5.

На шаге 1 происходит запрос значения элемента K1. В результате в КЭШе будут сохранены следующие данные

K1-K5 - элементы со значением и метками элементов, от которых зависит значение.
#K1-#K5 - элементы со значением меток
В качестве значения метки используется версия. Для начального значения версия = 1.

На шаге 2 изменяем значение элемента K4. В результате этого изменения в КЭШе будут следующие данные:

Для метки #K4 изменилась версия, а сам элемент K4 был удален из КЭШа.

На шаге 3 опять происходит запрос значения элемента K1. При запросе будут получены ключи меток и при сравнении будет определено, что не все метки в элементе соответствуют своим фактическим значениям. А именно метка #K4 в элементе K1 = 1, а фактическое значение метки = 2. Поэтому будет вызвана функция расчета значения K1. В результате этого изменения в КЭШе будут следующие данные:

В КЕШ вернулось значение K4 так как его значение требовалось для K3=>K1, а в элементе K1 значение ключа #K4 изменилось на 2. При повторном запросе значения элемента K1 также происходит проверка значений меток, но так как они совпадают, то будет возвращаться значение из КЭШа без его пересчета.

Сложный случай сохранения до события изменения (Event)

Перепишем класс CacheInvalidationEvent, добавив в него алгоритм обновления КЭШа, описанный в предыдущем разделе.
При этом ни определение элемента КЭШа через класс, ни его использование не изменилось. Т.е. работа с чтением элемента КЭШа выгладят также как при простом случае (см. работу с UserCacheItem).

Проблема N+1 запроса при работе с КЭШем

Проблема N + 1 возникает, когда фреймворк доступа к данным выполняет N дополнительных SQL‑запросов для получения тех же данных, которые можно получить при выполнении одного SQL‑запроса. К примеру у нас есть элемент КЭШа CacheItemUser, который по идентификатору получает из БД информацию о пользователе. Класс для определения пользователя выглядит следующим образом:

class CacheItemUser extends CacheInvalidationEvent
{
    // В качестве ключа выступает идентификатор пользователя
    public function __construct(protected int $id)
    {
    }
    // Читать значение
    protected function read(): array
    {
        // Выбираем данные из БД
        return sql(
            'SELECT `data` FROM `users` WHERE id = &',
            $this->id
        )->row();
    }
    // Записать значение
    protected function write(array $data): void
    {
        // Обновить данные в БД
        return sql(
            'MODIFY `users` SET = `data` = & WHERE id = &',
            $data,
            $this->id
        )->row();
    }
}

Предположим нам нужно выбрать информацию по нескольким пользователям

$users = [];
foreach([1,2,3] as $id) {
    $users[$id] = CacheItemUser::get($id);
}

В результате будет выполнено 3 SQL запроса. Однако более рационально выбрать эту информацию из БД одним запросом сразу по всем пользователям. Доработаем систему работы с КЭШем таким образом, чтобы она работала более оптимально. Для этого нам необходимо:

  1. Каким то образом собирать информацию о том, что нам понадобится

  2. Функция чтения данных должна в качестве параметров получать список таких параметров

Для этого добавим в класс CacheInvalidationEvent от которого наследуются все элементы КЭШа метод prepare для указания данных, которые вскоре понадобятся. А также добавим в класс определения элемента КЭШа возможность задавать статический метод prepareRead для массовой обработки собранных для обработки параметров.

В этом случае класс для определения элемента КЭШа будет следующим:

class CacheItemUser extends CacheInvalidationEvent
{
    private array $data;
    // В качестве ключа выступает идентификатор пользователя
    public function __construct(protected int $id)
    {
    }
    // Подготовка для чтения значений
    static protected function prepareRead(array $objects): void
    {
        // Выбрать данные по пользователям
        $rows = sql('SELECT `id`, `data` FROM `users` WHERE id in (&)',$objects[]-id);
        // Сгруппировать по id
        $users = groupById($rows);
        // Разобрать данные по объектам
        foreach($objects as $object) {
            $object-data = $users[$object->id]['data'];
        }
    }    
    // Читать значение
    protected function read(): array
    {
        // Выбираем данные из БД
        return $this->data;
    }
    // Записать значение
    protected function write(array $data): void
    {
        // Обновить данные в БД
        return sql(
            'MODIFY `users` SET = `data` = & WHERE id = &',
            $data,
            $this->id
        )->row();
    }
}

И выборку данных перепишем с учетом метода prepare

// Подготовка данных
foreach([1,2,3] as $id) {
    CacheItemUser::prepare($id);
}
// Чтение
$users = [];
foreach([1,2,3] as $id) {
    $users[$id] = CacheItemUser::get($id);
}

При этом не обязательным является как задание метода prepareRead, так и вызов метода prepare. При отсутствии одного или другого чтение будет выполнено вы обычном режиме.

Случай сохранения на время (Lifetime)

Все стандартные пакеты КЭШирования поддерживают сохранения значения "на время", поэтому в своём пакете я тоже добавил этот режим. Подход точно такой же как для элемента "по событию":

  1. Определяем класс, который наследуем от CacheInvalidationLifetime.

class CacheItemFiles extends CacheInvalidationLifetime
{
    public function __construct()
    {
    }
    // Подготовка для чтения значений [не обязательно]
    static protected function prepareRead(array $objects): void
    {
        // ПредЧтение данных для списка объектов
    }    
    // Читать значение
    protected function read(): mixed
    {
        return ...; // Возвращает значение 
    }
    // Время жизни элемента КЭШа (по умолчанию возвращает null)
    protected function ttl(): ?int
    {
        return 1; // Возвращает время жизни
    }
}
  1. Используем

    $value = CacheItemFiles::get();

Взаимодействие элементов "по событию" и "по времени" между собой

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

Однако если внутри элемента, который запоминает до наступления события вызывать метод, который запоминает на время, то по истечении времени жизни значения вышестоящий элемент КЭШа будет пересчитан. Т.е. при наступлении события "время жизни истекло" все родительские элементы КЭШа будут пересчитаны (схема 2).

Плюсы:

  • Единый стиль определение элементов КЭШа

  • Возможность обработки нескольких элементов за один раз

Минусы:

  • Отсутствие подсказки в IDE по параметрам

Ссылка на github shasoft/cache-invalidation

Комментарии (2)


  1. spirit1984
    10.06.2024 13:00
    +3

    Не совсем понял, в чем смысл? Чем это лучше стандартных библиотек кеширования под РНР? Или это образовательный пример?


  1. MyraJKee
    10.06.2024 13:00
    +2

    Кажется было бы правильнее реализовать интерфейс psr