Redis Common Use Cases - Cache Management Techniques
- 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.
- 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
- Requires additional resource consumption
- 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:
- Update the database and delete the cache
- 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:
- Delete the cache
- Update the database
- 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.
- 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.
- 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.
- 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:
-
Set a logical expiration time for the data and write it into the cache, for example, {"name":"three","expireTime":1720712827}.
-
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.
-
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).
- 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
- Heima Programmer Redis Introduction to Practical Course, In-depth Analysis of Redis Underlying Principles + Redis Distributed Lock + Enterprise Solutions + Heima Evaluation Practical Project - Bilibili