使用缓存来提升 PHP 项目
项目的增长及其负载对开发人员来说是一个真正的挑战。网站响应开始出现延迟,扩展问题也越来越频繁地被提出。有许多有效的解决方案可以提高项目的稳定性和负载能力,其中最基本的方法之一就是缓存。
缓存是指将数据临时保存在易于访问的位置,以便比从源头检索更快。使用缓存的最常见示例是从数据库获取数据。例如,当首次从数据库接收到某个产品时,它会在缓存中存储一段时间,因此后续对该产品的每个请求都不会干扰数据库,因为数据将从另一个存储中接收。
有哪些方法?
缓存方法有很多种。您可以在php-cache页面上查看兼容 PHP 的工具列表。不过,最常用的是以下几种:
- 亚太铜
- 大批
- Memcached
- Redis
让我们看看它们的区别和特点。
亚太铜
最常用且易于配置的缓存工具之一。它将数据保存到 RAM 中(并且知道如何缓存中间代码,但那是完全不同的故事……)。要开始使用 APCu,您需要确保已安装它。为此,请在命令行中运行以下命令:
php -i | grep 'apc.enabled'
# We're expecting to see:
# apc.enabled => On => On
另一种检查方法是:创建一个index.php文件,并将phpinfo()
函数调用放入其中。确保已为正在使用的目录配置了 Web 服务器,并通过服务器地址在浏览器中打开脚本。我们关注的是 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 minutes.
// Checking APCu availability
$isEnabled = apcu_enabled();
// Checks if there is data in the cache by key
$isExisted = apcu_exists($cacheKey);
// Saves data to the cache. Returns true if successful
// The $ttl argument determines how long the cache will be stored (seconds)
$isStored = apcu_store($cacheKey, ['name' => 'Demo product'], $ttl);
// Retrieves data from the cache by key. If not, returns false
$data = apcu_fetch($cacheKey);
// Deletes data from the cache by key
$isDeleted = apcu_delete($cacheKey);
var_dump([
'is_enabled' => $isEnabled,
'is_existed' => $isExisted,
'is_stored' => $isStored,
'is_deleted' => $isDeleted,
'fetched_data' => $data,
]);
任何缓存都基于键值存储的原理,这意味着存储的数据都存储在一个特殊的键中,以便访问。在这种情况下,键存储在$cacheKey
变量中。
重要提示!此方法仅适用于网站模式,即从命令行运行时,您将无法从缓存中获取数据,并且脚本执行完成后,您保存到缓存中的所有内容都将被清除。但是,这不会导致任何错误。
数组缓存
一种更简单但并非总是适用的缓存方法。如果 APCu 保存数据并使其可供所有进程后续执行,则数组缓存仅针对正在处理的请求存储数据。
这是什么意思呢?假设你有一个包含用户评论的页面。一个用户可以留下多条评论,当我们收集这些数据的数组时,我们不希望从数据库中多次获取同一个用户的数据。我们可以做的是将接收到的数据设置为一个数组,这样,如果有这样的数组,我们就不需要再次发出请求。这个原则非常简单,也很容易实现。让我们编写一个类来执行这样的保存操作。
class CustomArrayCache
{
/**
* Array is static and private
* – private – so that it can be accessed only from the
* methods of the class
* – static – so that the property is available in all instances
*/
private static array $memory = [];
// Method for storing data in memory
public function store(string $key, $value): bool
{
self::$memory[$key] = $value;
return true;
}
// Method for getting data from memory
public function fetch(string $key)
{
return self::$memory[$key] ?? null;
}
// Method for deleting data from memory
public function delete(string $key): bool
{
unset(self::$memory[$key]);
return true;
}
// Method for checking the availability of key data
public function exists(string $key): bool
{
return array_key_exists($key, self::$memory);
}
}
这种方法非常简单,但由于其局限性,很少使用。不过,了解一下还是很有用的。
Memcached 和 Redis
最先进的缓存方法。它们需要单独运行一个服务器(守护进程)。我们从 PHP 连接到该服务器的地址和端口。这些解决方案的配置比设置 APCu 更复杂,但数据存储方式非常相似(RAM)。它们最重要的优势在于:
- 与 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,因此以相同的方式应用,但每个都有自己的“底层”逻辑。
作为使用示例,让我们使用Composer将APCu和数组适配器加载到我们的项目中。
composer require cache/array-adapter
composer require cache/apcu-adapter
# Or
composer req cache/apcu-adapter cache/array-adapter
假设我们有一个专门用于从数据库中获取产品的类,我们称之为 ProductRepository,它有一个find($id)
方法,根据 ID 返回产品,如果不存在则返回 null。
class ProductRepository
{
/**
* In order not to complicate the example, we will stipulate that
* an array is returned as a product, and if it is not, null
*/
public function find(int $id): ?array
{
// ...
// Getting data from the database
return $someProduct;
}
}
如果我们想要启用缓存,不应该在存储库内部进行,因为它的职责是从数据库返回数据。那么,我们应该在哪里添加缓存呢?有几种流行的解决方案,最简单的方法是添加一个额外的提供程序类。它所做的就是尝试从缓存中获取数据,如果获取不到,它就会联系存储库。为此,我们将在此类的构造函数中定义两个依赖项——我们的存储库和CacheInterface
。为什么要使用接口?因为我们将能够使用任何提到的适配器或其他符合 PSR-16 的类。
use Psr\SimpleCache\CacheInterface;
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);
// Trying to get the product from the cache
$product = $this->cache->get($cacheKey);
if ($product !== null) {
// If there is a product, we return it
// Temporarily output echo to understand that the data is from the cache
echo 'Data from cache' . PHP_EOL; // PHP_EOL is a line break
return $product;
}
// If there is no product, we get it from the repository
$product = $this->productRepository->find($productId);
if ($product !== null) {
// Now we will save the received product to the cache for future requests
// Also temporarily output echo
echo 'Data from DB' . PHP_EOL;
$this->cache->set($cacheKey, $product);
}
return $product;
}
}
我们的类已经准备好了。现在让我们看看它与 APCu 适配器结合使用的情况。
use Cache\Adapter\Apcu\ApcuCachePool;
// Connect the Composer autoloader
require_once 'vendor/autoload.php';
// Our repository
$productRepository = new ProductRepository();
// APCu-cache adapter. Does not require any additional settings
$cache = new ApcuCachePool();
// Creating a provider, passing dependencies
$productDataProvider = new ProductDataProvider(
$productRepository,
$cache
);
// If there is such a product in the database, it will come back to us
$product = $productDataProvider->get(1);
var_dump($product);
如果我们想用数组适配器或任何其他方法替换 APCu 缓存,我们只需将其传递给提供程序,因为它们都实现了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
,或者向数据提供者的方法添加参数。例如($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 是一个很好的缓存解决方案,因为它易于配置并且使用 RAM,访问率非常高;
- 对于所有与 PHP 兼容的缓存工具,可以在网站php-cache.com上查看一些适配器;
- 缓存是单独的职责。尽量在单独的类中实现缓存相关功能;
- 如果要更新实体,则应从数据库中获取。如果仅需要查看实体,则可以从缓存中请求;
- 在大型项目中,使用Memcached或Redis来获得扩展能力。