Каждое приложение уникально. В большинстве приложений, однако, есть данные, которые меняются редко. Одним из наиболее распространенных узких мест в плане производительности является доступ к базе данных. Это связано со сложными процессами подключения/коммуникации, которые PHP должен выполнять при каждом запросе к базе данных для получения требуемых данных. Поэтому, если мы хотим добиться хорошей производительности, нам нужно добавить несколько уровней кэширования, где это требуется приложению.

В этой главе описываются потенциальные области, в которых можно реализовать кэширование для повышения производительности. Phalcon gives developers the tools they need to implement caching where their application needs it.

Кэширование наборов данных

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

Когда для Phalcon\Mvc\Model потребуется сервис для кэширования результирующих наборов, будет запрошена соответствующая служба из контейнера внедрения зависимостей. Название запрашиваемого сервиса — modelsCache. Фреймворк предоставлет компонент cache, который можно использовать для хранения данных любого типа. Теперь мы посмотрим, как мы можем интегрировать его с нашими моделями.

Во-первых, нам нужно будет зарегистрировать компонент кэша как сервис в контейнере DI.

<?php

use Phalcon\Cache\Frontend\Data as FrontendData;
use Phalcon\Cache\Backend\Memcache as BackendMemcache;

// Регистрация сервиса кэша моделей
$di->set(
    'modelsCache',
    function () {
        // По умолчанию данные кэша хранятся один день
        $frontCache = new FrontendData(
            [
                'lifetime' => 86400,
            ]
        );

        // Настройки соединения с memcached
        $cache = new BackendMemcache(
            $frontCache,
            [
                'host' => 'localhost',
                'port' => '11211',
            ]
        );

        return $cache;
    }
);

Вы имеете полный контроль в создании и настройке компонента кэша перед его регистрацией в качестве службы в контейнере DI. После того, как компонент кэш настроен правильно, результирующие наборы могут быть кэшированы следующим образом:

<?php

// Получение продукта без использования кэша
$products = Products::find();

// Используем кэширование наборов данных. Кэш остается в памяти в течении 1 часа (3600 секунд).
$products = Products::find(
    [
        'cache' => [
            'key' => 'my-cache',
        ],
    ]
);

// Кэш набора данных хранится всего 5 минут
$products = Products::find(
    [
        'cache' => [
            'key'      => 'my-cache',
            'lifetime' => 300,
        ],
    ]
);

// Мы используем сервис 'cache' из DI вместо 'modelsCache'
$products = Products::find(
    [
        'cache' => [
            'key'     => 'my-cache',
            'service' => 'cache',
        ],
    ]
);

Кэширование также может быть применено к результирующим наборам, созданным с использованием связей:

<?php

// Запрос некоторого сообщения
$post = Post::findFirst();

// Получаем комментарии, относящиеся к сообщению, и кэшируем их
$comments = $post->getComments(
    [
        'cache' => [
            'key' => 'my-key',
        ],
    ]
);

// Получаем комментарии, относящиеся к сообщению и устанавливаем срок их хранения
$comments = $post->getComments(
    [
        'cache' => [
            'key'      => 'my-key',
            'lifetime' => 3600,
        ],
    ]
);

Когда кэшированный результирующий набор должен быть признан недействительным, его можно просто удалить из кэша с помощью ключа, указанного выше.

Какие наборы данных кэшировать и на какое время, решает разработчик, после оценки потребностей приложения. Данные, которые меняют свои значения очень часто, не следует кэшировать, так как они становятся не действительными очень быстро, и кэширование в этом случаи отрицательно влияет на производительность приложения. Кроме того, большие наборы данных, которые не часто меняют свои значения, могут располагаться в кэше, но для реализации этой идеи необходимо оценить имеющиеся механизмы кэширования и влияния на производительность, так как это не всегда будет способствовать увеличению производительности приложения. Для минимизации взаимодействия с базой данных необходимо кэшировать нечасто изменяющиеся результирующие наборы. Решение о том, где использовать кэширование и как долго продиктовано потребностями приложения.

Форсирование кэша

Ранее мы видели, как Phalcon\Mvc\Model имеет встроенную интеграцию с компонентом кэширования, предоставленного фреймворком. Чтобы сделать запись/результирующий набор кэшируемым, мы передаем ключ cache в массиве параметров:

<?php

// Кэшируем результирующий набор всего на 5 минут
$products = Products::find(
    [
        'cache' => [
            'key'      => 'my-cache',
            'lifetime' => 300,
        ],
    ]
);

Это дает нам свободу для кэширования конкретных запросов. Однако если мы хотим кэшировать глобально все запросы, выполняемые моделью, мы можем переопределить метод find()/findFirst(), чтобы заставить кэшировать каждый запрос:

<?php

use Phalcon\Mvc\Model;

class Robots extends Model
{
    /**
     * Реализация метода, который возвращает
     * строковый ключ на основе параметров запроса
     */
    protected static function _createKey($parameters)
    {
        $uniqueKey = [];

        foreach ($parameters as $key => $value) {
            if (is_scalar($value)) {
                $uniqueKey[] = $key . ':' . $value;
            } elseif (is_array($value)) {
                $uniqueKey[] = $key . ':[' . self::_createKey($value) . ']';
            }
        }

        return join(',', $uniqueKey);
    }

    public static function find($parameters = null)
    {
        // Преобразование параметров в массив
        if (!is_array($parameters)) {
            $parameters = [$parameters];
        }

        // Проверяем, что ключ кэша не был передан
        // и создаем параметры кэша
        if (!isset($parameters['cache'])) {
            $parameters['cache'] = [
                'key'      => self::_createKey($parameters),
                'lifetime' => 300,
            ];
        }

        return parent::find($parameters);
    }

    public static function findFirst($parameters = null)
    {
        // ...
    }
}

Доступ к базе данных в несколько раз медленнее, чем вычисление ключа кэша. Вы вольны в реализации стратегии генерации ключа, которая лучше подходит для ваших задач. Следует отметить, что хороший ключ позволяет избежать конфликтов, насколько это возможно, это означает, что разные ключи возвращают при поиске независимые наборы записей.

Это дает вам полный контроль над тем, как кеш должен быть реализован для каждой модели. Если эта стратегия является общей для нескольких моделей, вы можете создать базовый класс для всех из них:

<?php

use Phalcon\Mvc\Model;

class CacheableModel extends Model
{
    protected static function _createKey($parameters)
    {
        // ... Создание ключа кэша на основе параметров
    }

    public static function find($parameters = null)
    {
        // ... Некоторая произвольная стратегия кэширования
    }

    public static function findFirst($parameters = null)
    {
        // ... Некоторая произвольная стратегия кэширования
    }
}

Затем используйте этот класс в качестве базового класса для каждой модели Cacheable:

<?php

class Robots extends CacheableModel
{

}

Кэширование PHQL запросов

Независимо от синтаксиса, который мы использовали для их создания, все запросы в ORM обрабатываются внутренне с помощью PHQL. Этот язык дает гораздо больше свободы для создания всех видов запросов. Конечно, эти запросы могут кэшироваться:

<?php

$phql = 'SELECT * FROM Cars WHERE name = :name:';

$query = $this->modelsManager->createQuery($phql);

$query->cache(
    [
        'key'      => 'cars-by-name',
        'lifetime' => 300,
    ]
);

$cars = $query->execute(
    [
        'name' => 'Audi',
    ]
);

Многократное использование связанных записей

Некоторые модели могут иметь отношения с другими моделями. Это позволяет нам легко проверять записи, относящиеся к экземплярам в памяти:

<?php

// Получить счет
$invoice = Invoices::findFirst();

// Получить клиента, связанного с факсом
$customer = $invoice->customer;

// Распечатать его / ее имя
echo $customer->name, "\n";

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

<?php

// Получить набор счетов-фактур
// SELECT * FROM invoices;
$invoices = Invoices::find();

foreach ($invoices as $invoice) {
    // Получить клиента, связанного с факсом
    // SELECT * FROM customers WHERE id = ?;
    $customer = $invoice->customer;

    // Распечатать его / ее имя
    echo $customer->name, "\n";
}

У клиента может быть один или несколько счетов, поэтому в этом примере одна и та же запись клиента может быть запрошена несколько раз. Чтобы избежать этого, мы могли бы пометить связь как многоразовую; делая это, мы говорим ORM автоматически повторно использовать записи из памяти вместо повторного запроса их снова и снова:

<?php

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public function initialize()
    {
        $this->belongsTo(
            'customers_id',
            'Customer',
            'id',
            [
                'reusable' => true,
            ]
        );
    }
}

Обратите внимание, что этот тип кэша работает только в памяти, это означает, что кэшированные данные освобождаются при завершении запроса.

Кэширование связанных записей

При запросе связанной записи ORM внутренне создает соответствующее условие и получает необходимые записи с помощьюfind()/findFirst() в целевой модели в соответствии со следующей таблицей:

ТипОписаниеImplicit Method
Belongs-To Возвращает экземпляр модели связанной записи напрямую. findFirst()
Has-One Возвращает экземпляр модели связанной записи напрямую. findFirst()
Has-Many Возвращает коллекцию экземпляров модели, на которую ссылается модель. find()

Это означает, что при получении связанной записи можно перехватить способ получения данных путем реализации соответствующего метода:

<?php

// Получить счет
$invoice = Invoices::findFirst();

// Получение клиента, связанного с накладной
$customer = $invoice->customer; // Invoices::findFirst('...');

// Такие же как выше
$customer = $invoice->getCustomer(); // Invoices::findFirst('...');

Соответственно, мы можем заменить метод findFirst() в модели Invoices и реализовать кэш, который считаем наиболее подходящим:

<?php

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public static function findFirst($parameters = null)
    {
        // ... Некоторая произвольная стратегия кэширования
    }
}

Рекурсивное кэшировоние связанных записей

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

<?php

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    protected static function _createKey($parameters)
    {
        // ... Создание ключа кэша на основе параметров
    }

    protected static function _getCache($key)
    {
        // Возвращает данные из кэша
    }

    protected static function _setCache($key, $results)
    {
        // Сохраняет данные в кэше
    }

    public static function find($parameters = null)
    {
        // Создание уникального ключа
        $key = self::_createKey($parameters);

        // Проверить, есть ли данные в кэше
        $results = self::_getCache($key);

        // Достоверных данных объекта
        if (is_object($results)) {
            return $results;
        }

        $results = [];

        $invoices = parent::find($parameters);

        foreach ($invoices as $invoice) {
            // Запрос связанного клиента
            $customer = $invoice->customer;

            // Назначить его записи
            $invoice->customer = $customer;

            $results[] = $invoice;
        }

        // Храните счета в кеше + их клиентов
        self::_setCache($key, $results);

        return $results;
    }

    public function initialize()
    {
        // Добавить отношения и инициализировать другие вещи
    }
}

Получение счетов-фактур из кэша уже получает данные клиента всего за один удар, уменьшая общие накладные расходы операции. Обратите внимание, что этот процесс также можно выполнить с помощью PHQL, следуя альтернативному решению:

<?php

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public function initialize()
    {
        // Добавить отношения и инициализировать другие вещи
    }

    protected static function _createKey($conditions, $params)
    {
        // ... Создание ключа кеша на основе параметров
    }

    public function getInvoicesCustomers($conditions, $params = null)
    {
        $phql = 'SELECT Invoices.*, Customers.* FROM Invoices JOIN Customers WHERE ' . $conditions;

        $query = $this->getModelsManager()->executeQuery($phql);

        $query->cache(
            [
                'key'      => self::_createKey($conditions, $params),
                'lifetime' => 300,
            ]
        );

        return $query->execute($params);
    }

} 

Кэширование на основе условий

В этом случае кэш реализуется по-разному в зависимости от полученных условий. Мы можем решить, что серверная часть кэша должна определяться первичным ключом:

ТипКэширующий сервер
1 - 10000 mongo1
10000 - 20000 mongo2
> 20000 mongo3

Самый простой способ добиться этого — добавить статический метод в модель, которая выбирает правильный кэш для использования:

<?php

use Phalcon\Mvc\Model;

class Robots extends Model
{
    public static function queryCache($initial, $final)
    {
        if ($initial >= 1 && $final < 10000) {
            $service = 'mongo1';
        } elseif ($initial >= 10000 && $final <= 20000) {
            $service = 'mongo2';
        } elseif ($initial > 20000) {
            $service = 'mongo3';
        }

        return self::find(
            [
                'id >= ' . $initial . ' AND id <= ' . $final,
                'cache' => [
                    'service' => $service,
                ],
            ]
        );
    }
}

Этот подход решает проблему, однако, если мы хотим добавить другие параметры выборки, например тип сортировки или различные условия для выборки, нам придется создать более сложный метод. Кроме того, этот подход не работает, если данные получены с помощью связанных записей или find()/findFirst():

<?php

$robots = Robots::find('id < 1000');
$robots = Robots::find("id > 100 AND type = 'A'");
$robots = Robots::find("(id > 100 AND type = 'A') AND id < 2000");

$robots = Robots::find(
    [
        "(id > ?0 AND type = 'A') AND id < ?1",
        'bind'  => [100, 2000],
        'order' => 'type',
    ]
);

Для этого нам необходимо перехватить промежуточное представление (IR), генерируемое парсером PHQL и реализовать логику кэширования для всех возможных условий выборки:

Для начала нам понадобится реализовать пользовательский конструктор запросов, чтобы иметь возможность перехватывать и генерировать полностью сформированный запрос:

<?php

use Phalcon\Mvc\Model\Query\Builder as QueryBuilder;

class CustomQueryBuilder extends QueryBuilder
{
    public function getQuery()
    {
        $query = new CustomQuery($this->getPhql());

        $query->setDI($this->getDI());

        if ( is_array($this->_bindParams) ) {
            $query->setBindParams($this->_bindParams);
        }

        if ( is_array($this->_bindTypes) ) {
            $query->setBindTypes($this->_bindTypes);
        }

        if ( is_array($this->_sharedLock) ) {
            $query->setSharedLock($this->_sharedLock);
        }

        return $query;
    }
}

Вместо прямого возврата Phalcon\Mvc\Model\Query, наш пользовательский конструктор возвращает экземпляр CustomQuery, этот класс выглядит так:

<?php

use Phalcon\Mvc\Model\Query as ModelQuery;

class CustomQuery extends ModelQuery
{
    /**
     * Метод execute переопределен
     */
    public function execute($params = null, $types = null)
    {
        // Parse the intermediate representation for the SELECT
        $ir = $this->parse();

        if ( is_array($this->_bindParams) ) {
            $params = array_merge($this->_bindParams, (array)$params);
        }

        if ( is_array($this->_bindTypes) ) {
            $types = array_merge($this->_bindTypes, (array)$types);
        }

        // Проверить, если в запросе есть условия
        if (isset($ir['where'])) {
            // Поля в условиях могут иметь любой порядок,
            // необходимый для рекурсивной проверки дерева условий,
            // чтобы найти искомую информацию
            $visitor = new CustomNodeVisitor();

            // Рекурсивно посещает узлы
            $visitor->visit($ir['where']);

            $initial = $visitor->getInitial();
            $final   = $visitor->getFinal();

            // Выбрать кэш в соответствии с диапазоном
            // ...

            // Проверка наличия данных в кэше
            // ...
        }

        // Выполнение запроса
        $result = $this->_executeSelect($ir, $params, $types);
        $result = $this->_uniqueRow ? $result->getFirst() : $result;

        // Кэширование результата
        // ...

        return $result;
    }
}

Реализация хелпера (CustomNodeVisitor), который рекурсивно проверяет условия выборки и формирует возможный диапазон для использования его в кэше:

<?php

class CustomNodeVisitor
{
    protected $_initial = 0;

    protected $_final = 25000;

    public function visit($node)
    {
        switch ($node['type']) {
            case 'binary-op':
                $left  = $this->visit($node['left']);
                $right = $this->visit($node['right']);

                if (!$left || !$right) {
                    return false;
                }

                if ($left === 'id') {
                    if ($node['op'] === '>') {
                        $this->_initial = $right;
                    }

                    if ($node['op'] === '=') {
                        $this->_initial = $right;
                    }

                    if ($node['op'] === '>=') {
                        $this->_initial = $right;
                    }

                    if ($node['op'] === '<') {
                        $this->_final = $right;
                    }

                    if ($node['op'] === '<=') {
                        $this->_final = $right;
                    }
                }

                break;

            case 'qualified':
                if ($node['name'] === 'id') {
                    return 'id';
                }

                break;

            case 'literal':
                return $node['value'];

            default:
                return false;
        }
    }

    public function getInitial()
    {
        return $this->_initial;
    }

    public function getFinal()
    {
        return $this->_final;
    }
}

Наконец, мы можем заменить метод find в модели Robots, чтобы использовать наши классы, которые мы создали:

<?php

use Phalcon\Mvc\Model;

class Robots extends Model
{
    public static function find($parameters = null)
    {
        if (!is_array($parameters)) {
            $parameters = [$parameters];
        }

        $builder = new CustomQueryBuilder($parameters);

        $builder->from(get_called_class());

        $query = $builder->getQuery();

        if (isset($parameters['bind'])) {
            return $query->execute($parameters['bind']);
        } else {
            return $query->execute();
        }
    }
} 

Кэширования плана выполнения PHQL

Как и большинство современных систем баз данных, PHQL внутренне кэширует план выполнения. Если один и тот же оператор выполняется несколько раз, PHQL повторно использует ранее созданный план, улучшая производительность. В целях достежения лучшей производительности настоятельно рекомендуется создавать все ваши SQL-запросы таким образом, чтобы передавать переменные параметры как связанные параметры:

<?php

for ($i = 1; $i <= 10; $i++) {
    $phql = 'SELECT * FROM Store\Robots WHERE id = ' . $i;

    $robots = $this->modelsManager->executeQuery($phql);

    // ...
}

В приведенном выше примере было создано десять планов, увеличивающих время выполнения приложения и потребление памяти. Перепишем этот код, воспользовавшись преимуществом связанных параметров для соращения обработки запроса ORM и базой данных:

<?php

$phql = 'SELECT * FROM Store\Robots WHERE id = ?0';

for ($i = 1; $i <= 10; $i++) {
    $robots = $this->modelsManager->executeQuery(
        $phql,
        [
            $i,
        ]
    );

    // ...
}

Также можно улучшить производительность повторного использования запроса PHQL:

<?php

$phql = 'SELECT * FROM Store\Robots WHERE id = ?0';

$query = $this->modelsManager->createQuery($phql);

for ($i = 1; $i <= 10; $i++) {
    $robots = $query->execute(
        $phql,
        [
            $i,
        ]
    );

    // ...
}

Кроме всего прочего, планы выполнения, включающие подготавливаемые запросы, кешируются большинством СУБД, сокращая общее время выполнения, а также защищают приложение от SQL-инъекций

Никаких мыслей по поводу “06 Кэширование в ORM”