Introduction

Redis caching looks simple at a high level, but in reality it is one of the most complex and remarkable parts of modern engineering. This article does not dive into implementation code. Instead, it covers the problems you might encounter when adding Redis as a cache database to your product.

Caching is generally used to reduce the load on the primary database and improve the response time of read operations. Databases such as MongoDB and Postgres do not perform as well under high loads because they are designed more for consistency than raw speed. Redis, on the other hand, is built for speed because it serves data from memory (RAM) instead of reading it from disk.

Caching Patterns

There is no perfect or universally correct pattern for every system. Each pattern has a tradeoff, and understanding those tradeoffs helps us choose a strategy suited to the situation.

1. Cache Aside Pattern (Lazy-Loading)

This is the most common pattern used with Redis. When requesting data, the application checks the cache first. On a miss, it loads the data from the database and populates the cache. During a write operation, the application deletes the associated cache key so that the next read fetches the newly updated data.

This pattern is useful when optimising for read-heavy operations. The first request for any piece of data will be relatively slow because it queries two databases, meaning two network hops.

2. Write-Through Pattern

This pattern is used when we want strong consistency between the cache and the primary database. During a write operation, the new value is written to both the primary database and the cache simultaneously.

Write latency takes a hit because we are writing to two systems. Read operations remain fast because the cache is always up to date. This pattern is useful in systems where we can tolerate slightly higher write latency in exchange for consistent reads.

3. Write-Back Pattern (Write-Behind)

This pattern is rarely used, and the reason becomes clear from its tradeoffs. During a write operation, the new value is written only to the cache. A background worker then synchronises the primary database and the cache database.

The obvious downsides are:

  • If Redis goes down before the worker synchronises the primary database, data loss can occur.
  • Additional worker overhead is required.
  • There is less consistency between the cache and the primary database.

This is mainly used when write operations are heavy and directly writing to the main database is not feasible.

These are the most famous caching patterns. Next, we will look at the scenarios where these patterns can break and, in the worst cases, bring down the primary database.

1. Cache Penetration

Imagine that we are building a user service and storing user data in Redis using a user key. If the user for a requested key is not present in the primary database, the server first looks in the cache, then in the primary database, and finally returns null.

This looks harmless, but imagine that 10,000 non-existent keys are requested. Those 10,000 requests bypass the cache and reach the primary database. The main reason for introducing Redis - absorbing a spike - has failed. There are several ways to solve this, depending on the scale at which the system operates.

A. Cache Non-Existent Keys

The simple and effective approach is to cache a non-existent result asnull in Redis with a relatively small TTL (time to live). The next time the request arrives, Redis returns that result and the primary database is protected.

Every non-existent key reaches the primary database once, after which Redis caches the non-existence and returns null on later requests. In the example above, 10,000 different non-existent keys would still reach the primary database once each before being cached.

This works well at a scale of about one million records because non-existent keys can be cached with a very short TTL, keeping read and write speed fast while limiting storage. When the scale reaches billions of keys and processing those requests in Redis becomes expensive, a second approach is needed.

B. Bloom Filters (BF)

Bloom filters are used at very large scales, such as those operated by Google or X, where the volume is so high that even Redis cannot absorb every request. A complete explanation of Bloom filters would require an article of its own, but the important context is that a Bloom filter is a data structure that gives a probabilistic answer about whether a key is present in a database.

When Bloom filters are used with Redis, they can produce false positives: the filter may say that a key is present even when it is not. In production, caching null values works at millions of records, but at a higher scale a hybrid approach can be used. The first layer uses a Bloom filter, followed by null caching when needed.

2. Cache Breakdown

Every platform has data that is requested very frequently, such as a celebrity's Instagram page receiving millions of hits per second. In technical terms, this is a hot key: a key that is requested again and again.

A hot key is not stored permanently in Redis. It is stored with a TTL. If the hot key expires and 10,000 requests arrive in the next 100 milliseconds, every request can penetrate the cache and reach the primary database for the same key. This creates a thundering herd against the database.

A. Request Coalescing

The problem is that all 10,000 requests see the same cache miss and try to fetch the value from the primary database to rebuild the cache. That work is redundant. The ideal behaviour is for one request to rebuild the cache while the others either wait or serve stale data. The latter approach is often called stale-while-revalidate.

With a single node instance, requests for the same key can be stored in an in-memory map and handled together. With multiple node instances, a shared lock is needed, such as a distributed lock implemented with Redis using the Redisson algorithm.

2. Logical Expiration

Logical expiration refreshes a key before its physical TTL expires. For example, if the key TTL is 300 ms, refreshing can begin during the window from 270 ms to 300 ms, leaving time to rebuild the value before the key expires. Stripe is described as using this method heavily; for a five-minute TTL, refreshing can begin around the second or third minute.

3. Mutex or Semaphore

This is another form of locking. A mutex or semaphore limits the number of requests that are allowed to rebuild a cache entry at the same time.

Cache Avalanche

An avalanche means a storm: if many keys expire at the same time, both Redis and the primary database can be overwhelmed.

1. Jittered TTL

The more synchronised the cache is, the more devastating a thundering herd can be. One solution is to spread expiration times so that keys do not expire together. Adding random numbers to TTLs with suitable logic can reduce the problem to a certain extent.

2. Multi-Level Caching

A cache avalanche is a situation in which many keys expire at once. The right way to address it is to prepare for the situation. Production systems generally do not use only one caching layer; they use several layers:

text
Browser
HTTP Cache (Browser)
CDN / Edge Cache
Load Balancer
API Gateway Cache (optional)
Application Cache (in-memory)
Distributed Cache (Redis/Memcached)
Database Cache
Database

Conclusion

That is all from this guide. Redis caching is more than placing a fast key-value store in front of a database: the choice of caching pattern and the handling of penetration, breakdown, and avalanche scenarios determine whether the cache protects the primary database under load.

To explore these topics further, here are the references linked in the original article:

  1. Redis Cache Problems: Penetration, Breakdown and Avalanche
  2. Python Distributed Locks with Redlock
  3. The Thundering Herd Problem and Addressing It
  4. How to Use Bloom Filters for Cache Penetration Prevention

Happy learning!