跳转到内容

模型缓存


概览

在大多数应用程序中,有些数据很少发生变化。性能方面的常见瓶颈之一是访问数据库的数据。我们首先有一个允许 PHP 与数据库通信的复杂层,然后在数据库自身内部存在另一个复杂层和潜在瓶颈,当尝试分析发送的查询并返回数据时(特别是当查询包含多个连接和分组语句时)。

实现某些缓存层可以减少对数据库的连接和查询次数。这将确保仅在绝对必要时才从数据库中查询数据。本文展示了可以通过缓存提高性能的一些方面。

结果集

一种避免在每次请求中都查询数据库的成熟技术是使用具有更快访问速度的系统(通常是内存)来缓存那些不经常变化的结果集。

Phalcon\Mvc\Model需要一个服务来缓存结果集,它会从依赖注入容器中请求该服务。该服务的名称叫做modelsCache。Phalcon 提供了一个缓存组件,可以存储任何类型的数据。将这个服务集成到你的代码中需要一个缓存对象中的相关方法手动

<?php

use Phalcon\Cache\Cache;
use Phalcon\Cache\AdapterFactory;
use Phalcon\Di\FactoryDefault;
use Phalcon\Storage\SerializerFactory;

$container = new FactoryDefault();

$container->set(
    'modelsCache',
    function () {
        $serializerFactory = new SerializerFactory();
        $adapterFactory    = new AdapterFactory($serializerFactory);

        $options = [
            'defaultSerializer' => 'Php',
            'lifetime'          => 7200
        ];

        $adapter = $adapterFactory->newInstance('apcu', $options);

        return new Cache($adapter);
    }
);

注意

必须使用一个能够正确序列化和反序列化对象而不改变其状态的序列化器。PhpIgbinary是这样的序列化器。Json会将对象转换为stdClassSimple/Complex结果集将变成数组。选择一个不能正确存储对象的序列化器,在模型恢复缓存时会导致错误。

在注册缓存组件之前,你完全控制如何创建和自定义缓存组件。你可以查看缓存文档以了解创建缓存组件时可用的各种选项和自定义设置。

一旦缓存组件正确设置好了,就可以通过在模型的查询命令中使用cache元素来缓存结果集,例如find, findFirst等等。

$invoices = Invoices::find();
不使用缓存

$invoices = Invoices::find(
    [
        'cache' => [
            'key' => 'my-cache',
        ],
    ]
);
使用my-cache作为键缓存此结果集。结果将在 7200 秒后过期,这是在设置缓存服务时指定的。

$invoices = Invoices::find(
    [
        'cache' => [
            'key'      => 'my-cache',
            'lifetime' => 300,
        ],
    ]
);
使用my-cache作为键缓存此结果集 5 分钟。

$invoices = Invoices::find(
    [
        'cache' => [
            'key'     => 'my-cache',
            'service' => 'cache',
        ],
    ]
);
使用my-cache作为键缓存结果集,但现在使用 DI 容器中的服务cache而不是默认的modelsCache

关系

你还可以缓存由关系返回的结果集。

<?php

use MyApp\Models\Customers;
use MyApp\Models\Invoices;

$customer = Customers::findFirst(
    [
        'conditions' => 'cst_id = :cst_id:',
        'bind'       => [
            'cst_id' => 1,
        ],
    ]
);

$invoices = $customer->getRelated(
    'invoices',
    [
        'cache' => [
            'key'      => 'my-key',
            'lifetime' => 300,
        ]
    ]
);

$invoices = $customer->getInvoices(
    [
        'cache' => [
            'key'      => 'my-key',
            'lifetime' => 300,
        ]
    ]
);
在上面的例子中,我们在getRelated方法上调用Customer模型以从invoices关系中检索发票。我们还传入了一个数组,其中包含必要的选项,用来将结果集缓存 5 分钟,并使用my-key作为键。

我们也可以使用魔术方法getInvoices,这个方法名get与关系名相同,在这种情况下是invoices.

当需要使缓存的结果集失效时,你可以简单地使用上面提到的键从缓存中删除它。

缓存哪些结果以及缓存多长时间取决于你的应用程序需求。频繁变更的结果集不应该被缓存,因为后续底层记录的变化会使缓存结果很快失效。

注意

缓存数据的成本在于编译和存储这些数据到缓存中。在制定缓存策略时,你应该始终权衡这种处理成本。缓存什么数据以及缓存多久取决于你的应用程序的需求。

强制缓存

前面我们看到了Phalcon\Mvc\Model如何整合框架提供的缓存组件。为了让一条记录/结果集可缓存,我们在参数数组中传递了键cache:

<?php

$invoices = Invoices::find(
    [
        'cache' => [
            'key'      => 'my-cache',
            'lifetime' => 300,
        ],
    ]
);

这给了我们自由缓存特定查询的能力,但是,如果我们想全局缓存模型上的每个查询,我们可以重写find()/findFirst()方法以强制每个查询都被缓存:

<?php

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public static function find($parameters = null)
    {
        $parameters = self::checkCacheParameters($parameters);

        return parent::find($parameters);
    }

    public static function findFirst($parameters = null)
    {
        $parameters = self::checkCacheParameters($parameters);

        return parent::findFirst($parameters);
    }

    protected static function checkCacheParameters($parameters = null)
    {
        if (null !== $parameters) {
            if (true !== is_array($parameters)) {
                $parameters = [$parameters];
            }

            if (true !== isset($parameters['cache'])) {
                $parameters['cache'] = [
                    'key'      => self::generateCacheKey($parameters),
                    'lifetime' => 300,
                ];
            }
        }

        return $parameters;
    }

    protected static function generateCacheKey(array $parameters)
    {
        $uniqueKey = [];

        foreach ($parameters as $key => $value) {
            if (true === is_scalar($value)) {
                $uniqueKey[] = $key . ':' . $value;
            } elseif (true === is_array($value)) {
                $uniqueKey[] = sprintf(
                    '%s:[%s]',
                    $key,
                    self::generateCacheKey($value)
                );
            }
        }

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

访问数据库的速度比计算一个缓存键慢很多倍。你可以自由地实现任何适合你需求的键生成策略。请注意,一个好的键应尽可能避免冲突——这意味着不同的键应该返回互不相关的记录。

这使你能够完全控制每个模型的缓存应该如何实现。如果这种策略适用于多个模型,则可以创建一个基类供你的模型继承,或者不继承:

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

abstract class AbstractCacheable extends Model
{
    public static function find($parameters = null)
    {
        $parameters = self::checkCacheParameters($parameters);

        return parent::find($parameters);
    }

    public static function findFirst($parameters = null)
    {
        $parameters = self::checkCacheParameters($parameters);

        return parent::findFirst($parameters);
    }

    protected static function checkCacheParameters($parameters = null)
    {
        if (null !== $parameters) {
            if (true !== is_array($parameters)) {
                $parameters = [$parameters];
            }

            if (true !== isset($parameters['cache'])) {
                $parameters['cache'] = [
                    'key'      => self::generateCacheKey($parameters),
                    'lifetime' => 300,
                ];
            }
        }

        return $parameters;
    }

    protected static function generateCacheKey(array $parameters)
    {
        $uniqueKey = [];

        foreach ($parameters as $key => $value) {
            if (true === is_scalar($value)) {
                $uniqueKey[] = $key . ':' . $value;
            } elseif (true === is_array($value)) {
                $uniqueKey[] = sprintf(
                    '%s:[%s]',
                    $key,
                    self::generateCacheKey($value)
                );
            }
        }

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

然后你可以让需要可缓存功能的模型继承这个抽象类,而不需要的模型则直接使用 Phalcon 的模型类。

<?php

namespace MyApp\Models;

use MyApp\Models\AbstractCachable;

class Invoices extends AbstractCachable
{

}

PHQL 查询

不管我们使用什么语法创建它们,ORM 中的所有查询都会通过PHQL内部处理。这种语言赋予了你更多的自由来创建各种类型的查询。当然,这些查询也可以被缓存:

<?php

$phql  = 'SELECT * FROM Customers WHERE cst_id = :cst_id:';
$query = $this
    ->modelsManager
    ->createQuery($phql)
;

$query->cache(
    [
        'key'      => 'customers-1',
        'lifetime' => 300,
    ]
);

$invoice = $query->execute(
    [
        'cst_id' => 1,
    ]
);

可复用的关系

一些模型可能与其他模型有关系。这使我们能够轻松检查与内存中实例相关的记录:

<?php

use MyApp\Models\Invoices;

$invoice = Invoices::findFirst(
    [
        'conditions' => 'inv_id = :inv_id:',
        'bind'       => [
            'inv_id' => 1,
        ],
    ]
);

$customer = $invoice->customer;

echo $customer->cst_name, PHP_EOL;

上面的例子非常简单。它查找 ID 为inv_id = 1的发票,然后使用关系customerCustomers模型中检索相关记录。之后,我们打印出客户的名字。

如果我们检索一个客户并想要显示他们的发票时,这也适用:

<?php

use MyApp\Models\Invoices;

$invoices = Invoices::find();

foreach ($invoices as $invoice) {
    // SELECT * FROM co_customers WHERE cst_id = ?;
    $customer = $invoice->customer;

    echo $customer->cst_name, PHP_EOL;
}
一个客户可以拥有多个发票。因此,在此示例中,相同的客户记录可能会被不必要地查询多次。为了避免这种情况,我们可以将关系设置为reusable。这会指示 Phalcon 在第一次访问时将相关的记录缓存在内存中,后续对该记录的调用将从内存缓存的实体中返回数据。

<?php

use MyApp\Models\Customers;
use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public function initialize()
    {
        $this->belongsTo(
            'inv_cst_id',
            Customers::class,
            'cst_id',
            [
                'reusable' => true,
            ]
        );
    }
}

请注意,这种类型的缓存仅在内存中工作,这意味着当请求结束时,缓存的数据将会被释放。

注意

上述示例是仅供演示并不应该在你的代码中使用,因为它引入了N+1问题

当查询相关记录时,ORM 会在内部构建适当的条件,并根据下表中目标模型中的find()/findFirst()来获取所需的记录:

类型 方法 描述
属于 findFirst() 直接返回关联记录的模型实例
拥有一个 findFirst() 直接返回关联记录的模型实例
拥有多个 find() 返回引用模型的模型实例集合

这意味着当你获取相关记录时,可以通过实现相应的方法来拦截数据获取的方式:

<?php

use MyApp\Models\Invoices;

$invoice = Invoices::findFirst(
    [
        'conditions' => 'inv_id = :inv_id:',
        'bind'       => [
            'inv_id' => 1,
        ],
    ]
);

// Invoices::findFirst('...');
$customer = $invoice->customer;               

// Invoices::findFirst('...');
$customer = $invoice->getCustomer();

// Invoices::findFirst('...');
$customer = $invoice->getRelated('customer');

上述调用在后台执行了相同的findFirst方法。此外,我们还可以替换findFirst()方法,在Invoices模型并实现最适合我们应用需求的缓存:

<?php

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public static function findFirst($parameters = null)
    {
        // ...
    }
}

在这种情况下,我们假设每次查询结果集时,还会检索它们的关联记录。可以将其想象为一种预加载形式。如果我们存储找到的记录及其关联实体,在某些情况下,我们可以减少获取所有实体所需的开销:

<?php

use Phalcon\Di\Di;
use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public function initialize()
    {
        $this->belongsTo(
            'inv_cst_id',
            Customers::class,
            'cst_id',
            [
                'reusable' => true,
            ]
        );
    }

    public static function find($parameters = null)
    {
        $cacheKey = self::generateCacheKey($parameters);
        $results  = self::cacheGet($cacheKey);

        if (true === is_object($results)) {
            return $results;
        }

        $results = [];

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

        foreach ($invoices as $invoice) {
            $customer = $invoice->getRelated('customer');

            $invoice->customer = $customer;

            $results[] = $invoice;
        }

        self::cacheSet($cacheKey, $results);

        return $results;
    }

    protected static function cacheGet($cacheKey)
    {
        $cache = Di::getDefault()->get('cache');

        return $cache->get($cacheKey);
    }

    protected static function cacheSet($cacheKey, $results)
    {
        $cache = Di::getDefault()->get('cache');

        return $cache->save($cacheKey, $results);
    }

    protected static function generateCacheKey(array $parameters)
    {
        $uniqueKey = [];

        foreach ($parameters as $key => $value) {
            if (true === is_scalar($value)) {
                $uniqueKey[] = $key . ':' . $value;
            } elseif (true === is_array($value)) {
                $uniqueKey[] = sprintf(
                    '%s:[%s]',
                    $key,
                    self::generateCacheKey($value)
                );
            }
        }

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

获取所有发票还将遍历结果集并获取所有相关的Customer记录,并使用customer属性将它们存储在结果集中。操作完成后,整个结果集会被存储在缓存中。任何后续对find的调用Invoices将使用缓存的结果集,而不会访问数据库。

注意

你需要确保在数据库中的底层记录发生变化时,你有策略使缓存失效,这样你的查询始终能够获取正确的数据。

上述操作也可以通过 PHQL 实现:

<?php

use Phalcon\Di\Di;
use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public function initialize()
    {
        $this->belongsTo(
            'inv_cst_id',
            Customers::class,
            'cst_id',
            [
                'reusable' => true,
            ]
        );
    }

    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::generateCacheKey(
                    $conditions, 
                    $params
                ),
                'lifetime' => 300,
            ]
        );

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

    protected static function generateCacheKey(array $parameters)
    {
        $uniqueKey = [];

        foreach ($parameters as $key => $value) {
            if (true === is_scalar($value)) {
                $uniqueKey[] = $key . ':' . $value;
            } elseif (true === is_array($value)) {
                $uniqueKey[] = sprintf(
                    '%s:[%s]',
                    $key,
                    self::generateCacheKey($value)
                );
            }
        }

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

条件

我们可以采用的策略之一是条件缓存。由于每个缓存后端都有其优缺点,我们可以决定缓存后端由我们访问的模型的主键值来确定:

类型 缓存后端
1 - 10000 redis1
10000 - 20000 redis2
> 20000 redis3

实现此目的最简单的方法是在模型中添加一个静态方法,用于选择要使用的正确缓存:

<?php

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public static function queryCache(int $initial, int $final)
    {
        if ($initial >= 1 && $final < 10000) {
            $service = 'redis1';
        } elseif ($initial >= 10000 && $final <= 20000) {
            $service = 'redis2';
        } else {
            $service = 'redis3';
        }

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

此方法解决了问题,但是,如果我们想添加其他参数如排序或条件,我们必须创建一个更复杂的方法。此外,如果数据是通过相关记录或find()/findFirst():

<?php

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

查询获得的,则此方法不起作用。

要实现这一点,我们需要拦截由 PHQL 解析器生成的中间表示(IR)并自定义缓存:

<?php

namespace MyApp\Components;

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 (true === is_array($this->bindParams)) {
            $query->setBindParams(
                $this->bindParams
            );
        }

        if (true === is_array($this->bindTypes)) {
            $query->setBindTypes(
                $this->bindTypes
            );
        }

        if (true === is_array($this->sharedLock)) {
            $query->setSharedLock(
                $this->sharedLock
            );
        }

        return $query;
    }
}

我们自定义的构建器返回一个Phalcon\Mvc\Model\Query,我们的自定义构建器返回一个CustomQuery实例:

<?php

namespace MyApp\Components;

use MyApp\Components\CustomNodeVisitor;
use Phalcon\Mvc\Model\Query as ModelQuery;

class CustomQuery extends ModelQuery
{
    public function execute($params = null, $types = null)
    {
        $ir = $this->parse();

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

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

        if (true === isset($ir['where'])) {
            $visitor = new CustomNodeVisitor();
            $visitor->visit(
                $ir['where']
            );

            $initial = $visitor->getInitial();
            $final   = $visitor->getFinal();
            $key     = $this->queryCache($initial, $final);
            $result  = $this->getDI()->get('cache')->get($key);

            if (true === is_object($result)) {
                return $result;
            }   
        }

        $result   = $this->executeSelect($ir, $params, $types);
        $result   = $this->uniqueRow ? $result->getFirst(): $result;
        $cacheKey = $this->calculateKey();

        $this->getDI()->get('cache')->save($cacheKey, $result);

        return $result;
    }
}
在上面的代码片段中,我们调用了parse()方法来自Phalcon\Mvc\Model\Query,以获取 PHQL 查询本身的中间表示。然后我们确保处理所有传递的参数和类型(如果有的话)。接着我们检查中间表示的where元素中是否有任何条件。条件中的字段可能还包含order别名。我们需要递归检查条件树以找到我们需要的信息。

我们使用了CustomNodeVisitor辅助方法,它会递归检查条件,寻找将用于缓存范围的字段。

最后,我们将检查缓存是否有数据并返回它。或者,我们将执行查询并将结果存储在缓存中后再返回。

<?php

class CustomNodeVisitor
{
    protected $initial = 0;

    protected $final = 25000;

    public function getInitial(): int
    {
        return $this->initial;
    }

    public function getFinal(): int
    {
        return $this->final;
    }

    public function visit(array $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;
        }
    }
}

最后一个任务是替换find方法,在Invoices模型以使用我们刚刚创建的类:

<?php

use MyApp\Components\CustomQueryBuilder;
use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public static function find($parameters = null)
    {
        if (true !== 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 Invoices WHERE inv_id = ' . $i;

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

    // ...
}

在上面的示例中,生成了十个执行计划,增加了应用程序的内存使用和处理负担。重写上述代码以利用绑定参数,可以减少 ORM 和数据库系统所需的处理:

<?php

$phql = 'SELECT * FROM Invoices WHERE id = ?0';

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

    // ...
}

通过重用 PHQL 查询还可以提高性能:

<?php

$phql  = 'SELECT * FROM Invoices WHERE id = ?0';
$query = $this
    ->modelsManager
    ->createQuery($phql)
;

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

    // ...
}

涉及预编译语句的查询的执行计划也会被大多数数据库系统缓存,从而减少整体执行时间,并保护您的应用程序免受SQL 注入攻击.

无噪 Logo
无噪文档
25 年 6 月翻译
版本号 5.9
文档源↗