The growth of the project and its load can be a real challenge for the developer. The website begins responding with a delay and the question of scaling is being raised more often. There are many effective solutions to increase the stability of the project and the load capability, and one of the most basic is caching.
Caching is the saving of data in highly accessible places temporarily so that their retrieval is performed faster than from the source. The most common example of using a cache is getting data from a database. When a product is first received from the database, for example, it is stored in the cache for a certain time, so each subsequent request to this product will not disturb the database, since the data will be received from another storage.
What are the approaches?
There are many approaches to caching. You can check the list of PHP-compatible tools on the php-cache page. However, the most common ones are:
- APCu
- Array
- Memcached
- Redis
Let's look at their differences and features.
APCu
One of the most common and easy-to-configure caching tools. Saves the data into RAM (and also knows how to cache intermediate code, but that's a completely different story ...). To get started with APCu, you need to make sure that it is installed. To do that, run the following command on the command line:
php -i | grep 'apc.enabled'
# We're expecting to see:
# apc.enabled => On => On
Another way to check it: create a file index.php and put the phpinfo()
function call in it. Make sure that you have a web server configured for the directory you are using and open the script in the browser via the server address. We are interested in the APCu section, if there is an APCu Support: Enabled item inside it, then everything is fine, we can move on.
If you don't have APCu installed, you can do it in the following way:
- Launch a terminal window (Linux/macOS) or a command prompt (Windows. Enter "cmd" in the search)
- Run the command:
pecl install apcu apcu_bc
-
Open the php.ini configuration file via any text editor and make sure that the following lines are present:
# 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
If there are no specified lines, add them and save the configuration file
Repeat the check for the installed APCu
To use this caching approach, we will need the basic functions. Here is an example of their application.
$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,
]);
Any cache works on the principle of key-value storage, which means that the stored data is stored with a special key, which is accessed. In this case, the key is stored in the $cacheKey
variable.
Important! This approach only works in the website mode, i.e. when running from the command line, you will not receive data from the cache, and everything that you have saved to it will be cleared when the script completes. However, this will not cause any errors.
Array-cache
A simpler, but not always applicable, caching method. If the APCu saves data and makes it available for subsequent execution by all processes, then the Array cache stores it only for the request being processed.
What does it mean? Imagine that you have a page with user comments. One user can leave several messages, and when we collect an array of this data, we do not want to fetch the same user from the database several times. What we can do is set the received data to an array so that if there is one, we don't make a request again. This principle is very simple and just as easy to implement. Let's write a class that will perform such a save.
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);
}
}
This approach is very simple, but due to its limitations, it is rarely used. However, it is useful to know about it.
Memcached & Redis
The most advanced caching approaches. They require a server (daemon) running separately. From PHP, we connect to this server at the address and port. The configuration of these solutions is more complicated than setting up an APCu, but the data storage method is very similar (RAM). Their most important advantages are:
- Isolation from PHP. Separate services are responsible for the cache;
- Clustering capability. If the load on your project is very high, clustering of caching services will help to cope with it;
In this article, we will not go into the details of configuring Memcached and Redis. At this stage, we need to remember that if the load is very high– we should look towards these solutions since they have a good potential for scaling.
PSR-16 Standard
PSR has two standards dedicated to caching: PSR-6 (normal caching interface) and PSR-16 (Simple caching interface) – we will focus on PSR-16.
This standard offers a special interface (CacheInterface
) that classes that perform the caching function can satisfy. According to it, such classes should implement the following methods:
-
get($key, $default)
- Getting data from the cache. The second argument passes the value that will be returned if this data is missing.; -
set($key, $value, $ttl = null)
- Saving data to the cache. As we saw earlier, the third parameter is passed the storage time in seconds. If left empty (null), the default value from the cache configuration will be substituted; -
delete($key)
- Deletes key data; -
clear()
- Clears the entire storage; -
getMultiple($keys, $default)
- Allows you to get data for several keys at once; -
setMultiple($values, $ttl = null)
- Allows you to record multiple values at once. As$value
we pass an associative array, where the key is$key
for the cache, and the value is the data to save; -
deleteMultiple($keys)
- Deletes data for multiple keys; -
has($key)
- Checks the availability of key data.
As you can see, the interface is very simple, and even the functions that we considered in the example for APCu are enough to write your cache service following PSR-16. But why is it necessary?
The main advantages of compliance with PSR standards:
- They are supported by the most popular libraries;
- Many PHP programmers adhere to PSR and will easily get used to your code;
- Thanks to the interface, we can easily change the service used to any other that supports PSR-16.
Let's take a closer look at the last point and its benefits.
Usage of PSR-16 libraries
Libraries that create a "wrapper" over existing caching tools to match the interface are called adapters. For example, consider the adapters of the methods that we have already discussed:
All of them satisfy PSR-16 and therefore are applied the same way, but each has its own logic "under the hood".
For an example of usage, let's load APCu and array adapters using composer into our project.
composer require cache/array-adapter
composer require cache/apcu-adapter
# Or
composer req cache/apcu-adapter cache/array-adapter
Let's imagine that we have a special class for getting products from a database. Let's call it ProductRepository, it has a find($id)
method that returns a product by its ID, and if there is no such product, 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;
}
}
If we want to enable caching, we should not do it inside the repository, because its responsibility is to return data from the database. Where then will we add the cache? There are several popular solutions, the simplest is an additional provider class. All he will do is try to get data from the cache, and if it didn't work out, he will contact the repository. To do this, we will define two dependencies in the constructor of such a class – our repository and CacheInterface
. Why the interface? Because we will be able to use absolutely any of the mentioned adapters or other classes that satisfy 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;
}
}
Our class is ready. Now let's look at its application in combination with an APCu adapter.
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);
If we want to replace APCu caching with an Array adapter or any other approach, we'll just pass it to the provider instead, because they all implement CacheInterface
.
use Cache\Adapter\PHPArray\ArrayCachePool;
// ...
$productRepository = new ProductRepository();
//$cache = new ApcuCachePool();
$cache = new ArrayCachePool();
$productDataProvider = new ProductDataProvider(
$productRepository,
$cache
);
// ...
Race condition and data updating
The cache works as long as we keep it up to date. This means that if a user wants to update a product, it must be updated both in the database and in our cache. However, there is one important nuance here.
Imagine that our project is used by a very large number of users. And two of them update the same entity at the same time. In this case, the following situation may occur:
- User 1 received an entity from the cache
- User 1 updated the entity in the DB
- User 2 got the entity from the cache
- User 1 has updated the data in the cache
- User 2 updated the entity in the database but overwritten it with old data because the entity was irrelevant at the time of receipt
- and so on...
This situation is called a race condition when several processes access the same resource at the same time and a version conflict may occur. To avoid such a problem, one simple rule should be followed:
When you receive any entity in the code to update it, always use data from the database.
In any situation when we need to get a product and we are not going to update it, we use a cache. If we want to update it - use data from the database.
You can either refer to ProductRepository
in the right places, instead of ProductDataProvider
or add an argument to the data provider's method. For example ($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;
}
}
Conclusion
Caching requires additional efforts from the developer when making a project and its use may not always be appropriate. The decision to apply it or not should be based on the expected (or actual) load and your expectations of the response speed to the user.
However, regardless of whether you will use these approaches in your current projects or not, they should be studied and put into practice, because this skill will be useful to you when working in large teams.
Summing up the article, let's look at the key ideas:
- Compliance with PSR-16 (or PSR-6) will allow you to easily connect a third-party library for caching and make your code understandable to other developers;
- For small projects, APCu is a good caching solution, because it is easy to configure and uses RAM, access to which is very high;
- For all PHP-compatible caching tools, some adapters can be viewed on the website php-cache.com;
- Caching is a separate responsibility. Try to implement working with the cache in separate classes;
- If we are going to update an entity, it should be obtained from the database. If we need an entity only for viewing– we can request it from the cache;
- In large projects, Memcached or Redis are used to get the ability to scale.
Top comments (7)
What do you recommend for a simple php website using no database?
It really depends on what you are going to store. If there's no need to use database, I guess you have no plans (at least now) to store anything. For instance, it might be some kind of backend which fetches data from external APIs and you can just optimize it with cache.
What type of cache to use? Well, again it depends. If data might be fetched several times per one request to your backend - array cache may solve it. If data is rarely updated - better to use something more powerful, like APCu, file cache or even Redis
Array?
I recommend you use file cache for start. Especially if you need not tags functionality. Later you can better undertenant what you actually need.
That’s a great one Elijah 👌 Thanks for sharing!
Nice article! Suggested topics for a follow up article:
Great suggestion! Thanks a lot