Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Redis Common Use Cases - Cache Management Techniques

Tech 3
  1. Introduction

Redis is essentially a caching framework, so we need to study how to use Redis to cache data and how to address common issues in caching such as cache penetration, cache breakdown, and cache avalanche, as well as how to handle cache consistency problems.

  1. Advantages and Disadvantages of Caching

2.1 Advantages of Caching

Caching can reduce the load on the database and improve read/write efficiency and response time.

2.2 Disadvantages of Caching

  1. Requires additional resource consumption
  2. Ensuring consistency between cache and database is a challenge, so businesses requiring strong consistency should avoid using caching.

3. Cache Consistency

To ensure consistency between cache and database, there are generally three approaches: cache-aside pattern, read-through/write-through pattern, and write-behind caching.

3.1 Cache-Aside Pattern (Side-Channel Caching)

This is an approach where developers manually handle the cache, which is also the common way to interact with Redis. It involves updating the database first before updating the cache.

3.1.1 Steps

1. Read from Cache

If the cache hits, return the data directly. If not, retrieve it from the database and update the cache.

2. Write to Cache

If the cache does not hit, update the database directly. If the cache hits, update the database and then update/delete the cache.

3.1.2 Updating Cache and Database

Reading from the cache doesn't pose consistency issues, but when writing to the cache, the order of updating the database or cache can lead to inconsistency. There are four main methods for writing operasions: write database then cache, write cache then database, write database then delete cache, and write cache then delete data.

1. Write Cache Then Database

If the cache is written successfully and the database fails, the database rolls back, leading to inconsistency between the cache and the database.

2. Write Database Then Cache

As shown, thread 1 updates the database to 1 successfully, thread 2 gets the time slice, updates the database and cache to x, and thread 1 gets the time slice again, updating the cache to 1. This results in inconsistency between the cache and the database.

3. Delete Cache Then Update Database

Thread 1 deletes the cache, thread 2 gets the time slice, reads the cache as empty, queries the database, and writes the old value into the cache. Thread 1 then updates the database to a new value, causing inconsistency.

4. Update Database Then Delete Cache

Assume that due to the cache eviction policy, the cache has expired. Initially, the cache is null.

As shown, if the cache is initially null due to the eviction policy, thread 1 queries the database and gets A=1, thread 2 gets the time slice, updates the database to A=x and deletes the cache, thread 1 gets the time slice again, updates the cache to A=1, leading to inconsistency.

However, these two conditions usually occur rarely during development: 1. The cache just expires and needs to be reloaded from the database; 2. After thread 1 queries the database, thread 2 immediately updates the database and deletes the cache.

Thus, this method can be used as a way to update the database and cache.

5. Delayed Double Deletion

Delayed double deletion adds another deletion after the third method of deleting the cache and updating the database. As explained earlier, between deleting the cache and updating the database, other threads may read the old value and update the cache. Therefore, a delay is introduced to delete the cached values updated by these threads.

a) Why the First Deletion Is Needed

Because deleting and writing to the database are not atomic operations. If you first update the database and then delay the deletion of the cache, if the second deletion fails, there will be inconsistency. Introducing a deletion before updating the database ensures the deletion is successful as much as possible.

b) Why the Second Deletion Is Needed

As previously explained, the second deletion is to remove the cached values that were updated by other threads between the first deletion and the update.

3.1.3 Ensuring Successful Deletion

Based on the above analysis, in actual development, we can use the following two methods to update the cache:

  1. Update the database and delete the cache
  2. Delayed double deletion

Both of these methods require ensuring the deletion is successful to maintain consistency between the cache and the database. How can we ensure the deletion is successful?

1. Set a Timeout

This method uses the built-in expiration strategy of the cache to ensure the cache will eventually expire, reducing dirty reads as much as possible.

2. Retry

When deletion fails, retries can be performed, but this may affect interface performance.

3. Listen to Binlog

For example, delayed double deletion can be transformed into the following steps:

  1. Delete the cache
  2. Update the database
  3. Listen to binlog to delete the cache

The middleware that listens to binlog usually has a retry mechanism to ensure the deletion is successful as much as possible.

3.1.4 Ensuring Strong Consistency Between Redis and Database

The methods mentioned earlier for updating the cache, such as updating the database and deleting the cache, and delayed double deletion, cannot fully guarantee consistency between the database and the cache. For high consistency requirements, a synchronous approach can be used: first update the data base and then update Redis, and add a distributed lock to both operations to ensure atomicity.

3.2 Read/Write Through Pattern (Read-Through/Write-Through)

The cache and database are treated as a single entity. Users only need to operate the cache, while the consistency between the cache and the database is handled by the cache itself. I think this pattern is better described as read-through and write-through because clients mainly interact with the cache. If the cache doesn't have data, it synchronizes the database content through the cache. After updating the cache data, it synchronizes to the database through the cache.

3.2.1 Reading the Cache

When reading the cache, if the data exists, it is returned directly. If not, the cache retrieves the data from the database and returns it to the user.

3.2.2 Writing the Cache

When writing to the cache, if the cache is not hit, the database is updated. If the cache is hit, the cache is updated, and the cache synchronizes the data to the database. Note that the cache must ensure the atomicity of thece two actions.

Why does the client update the database directly instead of the cache? Because this distributes the update operations across read and write cache processes. When reading the cache, it synchronizes the part that missed the cache, and when writing the cache, it synchronizes the part that hit the cache.

3.2.3 Advantages

This reduces the development workload for users compared to side-channel caching, as the cache itself handles the synchronization with the database.

3.3 Write-Behind Caching Pattern (Asynchronous Write-Back)

Asynchronous write-back means the user only updates the data in the cache, and then starts a thread to asynchronously write the data from the cache to the database. This approach is used in many frameworks, such as the persistence of MappedFile in RocketMQ and the page cache in Linux.

Its advantages include fast operation since only the cache is involved, and the main difference from read-through/write-through patterns is that the latter synchronously flushes data to the database after updating the cache, whereas asynchronous write-back does it asynchronously, potentially leading to data loss risks.

  1. Cache Penetration

4.1 What Is Cache Penetration

Cache penetration occurs when a client accesses the cache and finds no data, then accesses the database, which also contains no data. Thus, the request goes all the way to the database, causing excessive pressure on the database.

4.2 How to Solve Cache Penetration

4.2.1 Cache Empty Object

1. Operation

When the database has no data, an empty object can be cached in Redis.

2. Pros and Cons

Simple to implement, but if the database has objects and uses an expiration strategy, there may be a period of inconsistency with the database.

3. Code
public <R,ID> R queryWithPassThrough(
        String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
    String key = keyPrefix + id;
    // 1. From redis query shop cache
    String json = stringRedisTemplate.opsForValue().get(key);
    // 2. Determine if it exists
    if (StrUtil.isNotBlank(json)) {
        // 3. Exist, return directly
        return JSONUtil.toBean(json, type);
    }
    // Determine if it's an empty value
    if (json != null) {
        // Return an error message
        return null;
    }

    // 4. Not exist, query the database by id
    R r = dbFallback.apply(id);
    // 5. Not exist, return error
    if (r == null) {
        // Write an empty value to redis
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        // Return error message
        return null;
    }
    // 6. Exist, write to redis
    this.set(key, r, time, unit);
    return r;
}

4.2.3 Bloom Filter

A bloom filter can be used to check whether the data exists before the request. If it does not exist, it returns directly. A bloom filter is a tool based on probabilistic statistics to determine whether an element exists in a bit array.

Let's look at its implementation principle:

A bloom filter consists of a set of hash functions and an array. Suppose there are k hash functions. When an object is input, these k hash functions perform hash operations on the string and map it to k bit positions in the array. When determining if an object exists, it checks whether all the bits corresponding to the object are 1. If they are all 1, the object may exist. But why 'may' and not 'must'? Because there could be hash collisions, and there is a very low probability that two elements may map to the same position after being processed by k hash functions.

  1. Cache Avalanche

5.1 What Is Cache Avalanche

Cache avalanche occurs at a certain moment when a large number of keys expire simultaneously or Redis crashes, causing a large number of requests to flood the database, increasing the database pressure significantly.

5.2 How to Solve Cache Avalanche

To prevent a large number of keys from expiring at the same time: set a random value for the expiration time of the keys;

To prevent Redis from crashing: use a cluster to ensure high availability of the Redis service.

  1. Cache Breakthrough

6.1 What Is Cache Breakthrough

Cache breakthrough occurs in a high-concurrency scenario where a hot key (a key with high access frequency or long cache reconstruction time) expires, causing a large number of threads to rebuild the cache simultaneously.

6.2 How to Solve Cache Breakthrough

6.2.1 Mutex Lock

1. Idea

This means ensuring only one thread rebuilds the cache. When a thread finds the cache missing, it first acquires a mutex lock, then queries the database, builds the cache, and updates it. Other threads that come to get the cache find it missing and need to block and acquire the lock to rebuild the cache.

2. Code
    public <R, ID> R queryWithMutex(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1. Query shop cache from redis
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2. Determine if it exists
        if (StrUtil.isNotBlank(shopJson)) {
            // 3. Exist, return directly
            return JSONUtil.toBean(shopJson, type);
        }
        // Determine if it's an empty value
        if (shopJson != null) {
            // Return an error message
            return null;
        }

        // 4. Rebuild cache
        // 4.1 Acquire a mutex lock
        String lockKey = LOCK_SHOP_KEY + id;
        R r = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2 Determine if it was acquired successfully
            if (!isLock) {
                // 4.3 Failed to acquire lock, sleep and retry
                Thread.sleep(50);
                return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
            }
            // 4.4 Successfully acquired lock, query database by id
            r = dbFallback.apply(id);
            // 5. Not exist, return error
            if (r == null) {
                // Write an empty value to redis
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                // Return error message
                return null;
            }
            // 6. Exist, write to redis
            this.set(key, r, time, unit);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 7. Release lock
            unlock(lockKey);
        }
        // 8. Return
        return r;
    }

6.2.2 Logical Expiration

1. Idea

The main steps are as follows:

  1. Set a logical expiration time for the data and write it into the cache, for example, {"name":"three","expireTime":1720712827}.

  2. Thread 1 queries the cache, finds it has expired, and starts a separate thread to rebuild the cache. This cache rebuilding also requires a mutex lock to prevent multiple threads from rebuilding.

  3. Other threads accessing the cache, finding it has expired, first acquire the lock. If the data has already expired, they attempt to acquire the lock to rebuild the cache, but if they fail to acquire the lock, they return the old data from the cache.

2. Code
public <R, ID> R queryWithLogicalExpire(
        String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
    String key = keyPrefix + id;
    // 1. Query shop cache from redis
    String json = stringRedisTemplate.opsForValue().get(key);
    // 2. Determine if it exists, here it preheats the cache, loading hot data into redis in advance
    if (StrUtil.isBlank(json)) {
        // 3. Exist, return directly
        return null;
    }
    // 4. Hit, first deserialize json into an object
    RedisData redisData = JSONUtil.toBean(json, RedisData.class);
    R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 5. Determine if it has expired
    if(expireTime.isAfter(LocalDateTime.now())) {
        // 5.1 Not expired, return shop information directly
        return r;
    }
    // 5.2 Expired, need to rebuild the cache
    // 6. Rebuild cache
    // 6.1 Acquire a mutex lock
    String lockKey = LOCK_SHOP_KEY + id;
    boolean isLock = tryLock(lockKey);
    // 6.2 Determine if the lock was acquired successfully
    if (isLock){
        // 6.3 Successfully acquired, start an independent thread to rebuild the cache
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                // Query database
                R newR = dbFallback.apply(id);
                // Rebuild cache
                this.setWithLogicalExpire(key, newR, time, unit);
            } catch (Exception e) {
                throw new RuntimeException(e);
            } finally {
                // Release lock
                unlock(lockKey);
            }
        });
    }
    // 6.4 Return the expired shop information
    return r;
}
3. Pros and Cons

Logical expiration avoids lock waiting, but consumes extra space (storing cache expiration time) and cannot guarantee consistency (as other threads returning old data after detecting a thread rebuilding the cache asynchronously).

  1. References

1.Redis Lecture 12 - Three Design Patterns of Cache - CSDN Blog

2.Solution to Cache Consistency Problems - CSDN Blog

3.https://www.yuque.com/hollis666/un6qyk/tmcgo0

  1. Heima Programmer Redis Introduction to Practical Course, In-depth Analysis of Redis Underlying Principles + Redis Distributed Lock + Enterprise Solutions + Heima Evaluation Practical Project - Bilibili

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.