◀ Back to blog
DevOps

Caching strategies with Redis

Published on 30 Aug 2024· 7 min read
#Redis#Cache#Performance

Redis: advanced caching strategies

Redis is much more than a simple key-value cache. Here are the strategies to get the most out of it.

Being stored in memory, Redis usually responds in under a millisecond, whereas a complex SQL query can take tens of milliseconds. Caching the result of expensive operations (heavy queries, calls to an external API, aggregate computations) relieves the database and greatly reduces response time. But a poorly designed cache also creates its own bugs: stale data shown to users, load spikes when a key expires, saturated memory. The choice of strategy matters as much as the tool.

What should you cache?

A good candidate is data that is read far more often than it is modified, expensive to produce, and that tolerates a slight delay: product catalog, content pages, configuration, third-party API results. Conversely, an account balance, real-time stock or user-specific data that is rarely read again are poor candidates: the complexity of invalidation often outweighs the benefit.

Caching patterns

Cache-Aside (Lazy Loading)

This is the most common pattern: the application checks the cache first; on a cache miss, it reads the database and then stores the result with a time to live. Only data that is actually requested ends up in the cache.

class ProductService
{
    public function getProduct(int $id): Product
    {
        $cacheKey = "product:{$id}";
        $cached = $this->redis->get($cacheKey);

        if ($cached !== false) {
            return unserialize($cached);
        }

        $product = $this->repository->find($id);
        $this->redis->setex($cacheKey, 3600, serialize($product));

        return $product;
    }
}

Two remarks about this code. First, it uses the phpredis extension, which returns false for a missing key; with Predis, which returns null, test against null instead. Second, serializing a full Doctrine entity is fragile (proxies, lazily loaded collections, class structure changing between two deployments). I recommend caching an array or a simple DTO rather than the entity itself.

Write-Through

Here, every write updates the database and the cache in the same operation. The cache stays warm, and the next read does not need to touch the database.

public function updateProduct(Product $product): void
{
    $this->repository->save($product);
    $this->redis->setex(
        "product:{$product->getId()}",
        3600,
        serialize($product)
    );
}

Order matters: the database is the source of truth, so it is written first. If the Redis write then fails, the cache holds a stale version until the TTL expires. For this reason, many teams prefer a simpler variant: delete the key after the write (DEL) and let cache-aside rebuild it on the next read. This also avoids caching data nobody will read again.

Caching with Symfony

In a Symfony application, there is rarely a reason to work with Redis directly: the Cache component provides configurable pools, tag management and protection against load spikes.

# config/packages/cache.yaml
framework:
    cache:
        pools:
            app.cache.products:
                adapter: cache.adapter.redis
                default_lifetime: 3600
                provider: 'redis://redis:6379'
                tags: true

The tags: true option makes the pool tag-aware, which is required to call $item->tag(). The pool is then injected as a TagAwareCacheInterface, and the #[Target] attribute selects it explicitly:

use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;

final class ProductCatalog
{
    public function __construct(
        #[Target('app.cache.products')]
        private readonly TagAwareCacheInterface $cache,
        private readonly ProductRepository $repository,
    ) {
    }

    /** @return list<array{id: int, name: string}> */
    public function all(): array
    {
        return $this->cache->get('products_list', function (ItemInterface $item): array {
            $item->tag(['products']);

            return array_map(
                static fn (Product $p): array => ['id' => $p->getId(), 'name' => $p->getName()],
                $this->repository->findAll(),
            );
        });
    }
}

The get() method implements cache-aside for you: the callback only runs when the key is missing. It also protects against cache stampede (dozens of requests recomputing the same value at the same moment, right after it expires) thanks to a lock and probabilistic early expiration, tunable with the third argument $beta.

Notice that the cached result is a plain array, not a list of entities: it serializes without surprises and remains valid even if the entity evolves.

Cache invalidation

  • TTL: automatic expiration after a delay
  • Tags: group invalidation with Symfony tags
  • Events: invalidation on Doctrine events
  • Versioning: versioned cache keys

The TTL is the safety net: even if an invalidation is forgotten, the data will eventually refresh. Choose it based on the delay acceptable to the business, not at random. Tags and Doctrine events combine very well: an entity listener invalidates the relevant tags as soon as a product is created, updated or deleted.

use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Events;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Contracts\Cache\TagAwareCacheInterface;

#[AsEntityListener(event: Events::postPersist, method: 'invalidate', entity: Product::class)]
#[AsEntityListener(event: Events::postUpdate, method: 'invalidate', entity: Product::class)]
#[AsEntityListener(event: Events::preRemove, method: 'invalidate', entity: Product::class)]
final class ProductCacheInvalidator
{
    public function __construct(
        #[Target('app.cache.products')]
        private readonly TagAwareCacheInterface $cache,
    ) {
    }

    public function invalidate(Product $product): void
    {
        $this->cache->invalidateTags(['products', 'product_'.$product->getId()]);
    }
}

We listen to preRemove rather than postRemove, because after the deletion Doctrine may reset the entity's identifier to null. Finally, versioning means including a version in the key (product:v2:42). Bumping the version makes all old keys unreachable at once, which is handy when the format of cached data changes during a deployment; the old keys then disappear on their own thanks to their TTL.

Configuring Redis for caching

Without a memory limit, Redis grows until it exhausts the server's RAM. For an instance dedicated to caching, set a limit and an eviction policy in redis.conf:

maxmemory 512mb
maxmemory-policy allkeys-lru

With allkeys-lru, Redis removes the least recently used keys when the limit is reached. If the same instance also holds sessions or message queues, this policy could wipe them out: use volatile-lru instead (which only evicts keys with a TTL) or, better, separate instances.

Measuring cache effectiveness

A cache is judged by its hit ratio. The INFO stats command provides the keyspace_hits and keyspace_misses counters:

redis-cli INFO stats | grep -E 'keyspace_(hits|misses)'
redis-cli INFO memory | grep used_memory_human
redis-cli --bigkeys

A low hit ratio points to TTLs that are too short, keys that are too specific or data that is rarely read again. --bigkeys walks the database with SCAN and spots the largest keys.

Common pitfalls

  • KEYS * in production: this command blocks Redis while it walks through every key. Use SCAN, or better, tags.
  • Keys without a TTL: they pile up indefinitely. Everything in the cache should have a time to live.
  • Cache as the source of truth: the application must keep working, more slowly, if Redis is flushed or unavailable.
  • Keys without a namespace: prefix your keys (app:product:42) to avoid collisions between applications sharing the same instance.

Summary

Start with cache-aside and a reasonable TTL, rely on Symfony's Cache component rather than raw Redis calls, invalidate by tags from Doctrine events, and monitor the hit ratio. A simple, well-invalidated cache beats a sophisticated architecture that serves stale data.