Cache breakdown problem (hotspot key invalidation)
The cache breakdown problem is also called the hot key problem. It means that a key that is accessed by highly concurrently and has a complicated cache rebuilding business
suddenly fails. At this time, countless requests and accesses will hit the database in an instant. , brings huge impact
- The key of a product in a flash sale suddenly becomes invalid. Since everyone is rushing to buy it, there will be countless requests to access the database directly at this moment, causing cache breakdown.
Mutex lock
If the corresponding store information is not cached in the cache, all threads need to obtain the lock before querying the store information in the database. This ensures that only one thread accesses the database and avoids excessive database access pressure
- Advantages: Simple implementation and no additional memory destruction (adding a lock). When the thread that obtains the thread lock rebuilds the cache data, the data queried from the cache and the data in the database will be consistent when other threads access it again.
- Disadvantages: When the thread that has obtained the thread lock is operating the database, other threads can only wait, changing the performance of the query from parallel to serial (the tryLock method + double check can solve this), but there is still the risk of deadlock.
setnx implements mutex lock
Query store information based on store ID
, adding the step of obtaining a mutex lock, that is, when the cache misses, only the thread that successfully obtains the lock can query the database, ensuring that only one thread goes to the database Execute query statements
to prevent cache breakdown
Use the setnx key (lock Id) value
command provided by redis to determine whether a thread has successfully inserted the key (lock). del key
means releasing the lock
Return value | Description |
---|---|
0 | Indicates that the thread fails to insert the key, that is, the thread fails to acquire the lock |
1 | Indicates that the thread inserts the key successfully, that is, the thread acquires the lock successfully |
The method corresponding to the setnx instruction in StringRedisTemplate
is setIfAbsent()
. Returning true means the insertion is successful, and fasle means the insertion failed
//Each store has its own lock. Try to obtain the lock based on the lock's ID (lock prefix + store ID) (essentially inserting the key) private boolean tryLock(String key) {<!-- --> Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); // We use the BooleanUtil tool class here to convert Boolean type variables into boolean to avoid returning null during the unboxing process return BooleanUtil.isTrue(flag); } // Release the lock (essentially delete the key) private void unlock(String key) {<!-- --> stringRedisTemplate.delete(key); }
Separately implement the method queryWithMutex
that is responsible for solving the problem of cache breakdown
. In this method, if the store information is found and the store cannot be found, null will be returned. Finally, < Make unified judgment and return result class in code>queryById
- If the lock is acquired successfully, you should check again whether the redis cache exists, because at this time, other threads may have rebuilt the cache and just released the lock. Do a double check. If it exists, there is no need to rebuild the cache.
@Override public Result queryById(Long id) {<!-- --> //Use mutex lock to solve cache breakdown Shop shop = queryWithMutex(id); // If shop equals null, it means that the corresponding store does not exist in the database or the cached store information is an empty string. if (shop == null) {<!-- --> return Result.fail("The store does not exist!!"); } // shop is not equal to null, return the queried merchant information to the front end return Result.ok(shop); } @Override public Shop queryWithMutex(Long id) {<!-- --> //1. First query the corresponding store cache information from Redis. The constant value here is the fixed store prefix + query the store's Id. String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); //2. If the store information is queried in Redis, and the store information is not an empty string, it will be converted to Shop type and returned directly. "", null and "/t/n (line feed)" will be judged to be empty and false will be returned. if (StrUtil.isNotBlank(shopJson)) {<!-- --> Shop shop = JSONUtil.toBean(shopJson, Shop.class); return shop; } //3. If the hit is an empty string, which is the empty data we cache, return null if (shopJson != null) {<!-- --> return null; } // 4. If there is no hit, try to obtain the mutex lock (essentially inserting the key) based on the lock's Id (lock prefix + store Id) to implement cache reconstruction. // Calling Thread's sleep method will throw an exception. You can use try/catch/finally to wrap up the process of acquiring and releasing the lock. Shop shop = null; try {<!-- --> // 4.1 Get the mutex lock boolean isLock = tryLock(LOCK_SHOP_KEY + id); // 4.2 Determine whether the lock acquisition is successful (whether the key insertion is successful) if(!isLock){<!-- --> //4.3 If the lock acquisition fails (key insertion fails), then sleep for a period of time and re-query the store cache (recursively) Thread.sleep(50); return queryWithMutex(id); } //4.4 If the lock is acquired successfully (the key is inserted successfully), the database is queried according to the store's Id. shop = getById(id); // Since querying the database locally is faster, you can simulate reconstruction delays and trigger concurrency conflicts here. Thread.sleep(200); // 5. If the corresponding store cannot be found in the database, write the empty string to Redis and set the validity period. if(shop == null){<!-- --> stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL,TimeUnit.MINUTES); return null; } //6. The store information is found in the database, that is, shop is not null, convert the shop object into a json string, write it to redis and set the TTL stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL,TimeUnit.MINUTES); }catch (Exception e){<!-- --> throw new RuntimeException(e); } finally {<!-- --> //7. Regardless of whether there will be an exception before, the lock must be released eventually. unlock(lockKey); } //Finally return the queried merchant information to the front end return shop; }
Testing mutex locks to resolve cache breakdown
Use Jmeter
to simulate a cache breakdown scenario. At a certain moment, the cached TTL of a hot store expires. At this time, users cannot obtain the cache data of the hot store from Redis, and then they have to Go to the database to query store information
-
First
delete the cached data of hot stores in Redis to simulate TTL expiration
, and then use Jmete to open 100 threads to access this uncached store information -
If the background log only outputs one SQL statement, it means that our mutex is in effect and has not caused a large number of users to go to the database to execute SQL statements to query store information.
PLAINTEXT : ==> Preparing: SELECT id,name,type_id,images,area,address,x,y,avg_price,sold,comments,score,open_hours,create_time,update_time FROM tb_shop WHERE id=? : ==> Parameters: 2(Long) : <== Total: 1
Logical expiration (cache warm-up)
The main reason for the cache breakdown problem is that we set the expiration time for the key. Assuming that we do not set the expiration time, there will be no cache breakdown problem. However, if we do not set the expiration time, the cached data will continue to occupy memory< /strong>
- Advantages: Build cache through asynchronous threads to avoid waiting for other threads and improve performance
- Disadvantages: Building an asynchronous thread business is complex, and requires maintaining an expire field, which requires additional memory consumption. Before the asynchronous thread completes building the cache, other threads return expired data (dirty data), resulting in data inconsistency.
Logical expiration application
Implement query store
business based on store ID, and solve cache breakdown problem based on logical expiration method (hotspot key needs to be added in advance)
Step 1: Because the value of the data stored in redis now needs to have an expiration time attribute, you can create a new entity class containing the original data and expiration time fields
(without invading the original code)
@Data public class RedisData {<!-- --> // Expiration private LocalDateTime expireTime //Original data (use universal Object) private Object data; }
Step 2: Add a new method in ShopServiceImpl, use unit testing to cache preheating i.e. add hotspot key
, encapsulate hotspot store information and expiration time fields into RedisData objects and write them Redis cache
public void saveShop2Redis(Long id, Long expirSeconds) {<!-- --> // 1. Query store data in the database according to the store ID Shop shop = getById(id); // Since local query database is faster, simulate reconstruction delay Thread.sleep(200); // 2. Encapsulate the logical expiration time (convert the current time to seconds) RedisData redisData = new RedisData(); //Set hot store information redisData.setData(shop); redisData.setExpireTime(LocalDateTime.now().plusSeconds(expirSeconds)); // 3. Convert the RedisData object containing hot store information and logical expiration time fields into a JSON string and cache it to Redis stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData)); }
Step 3: Run the test method in the test class, and then go to the Redis graphical page to view the stored value
(including the data field, that is, the shop object and the expireTime logical expiration time field)
@SpringBootTest class HmDianPingApplicationTests {<!-- --> @Autowired private ShopServiceImpl shopService; @Test public void test(){<!-- --> shopService.saveShop2Redis(1L,1000L); } }
{<!-- --> "data": {<!-- --> "area": "大关", "openHours": "10:00-22:00", "sold": 4215, "images": "https://qcloud.dpfile.com/pc/jiclIsCKmOI2arxKN1Uf0Hx3PucIJH8q0QSz-Z8llzcN56-IdNpm8K8sG4.jpg", "address": "No. 29, Jinchang Wenhua Court, Jinhua Road", "comments": 3035, "avgPrice": 80, "updateTime": 1666502007000, "score": 37, "createTime": 1640167839000, "name": "476 Tea Restaurant", "x": 120.149192, "y": 30.316078, "typeId": 1, "id": 1 }, "expireTime": 1666519036559 }
Step 4: Write the queryWithLogicalExpire
method. In this method, if the store information is found, it returns null if the shop cannot be found. Finally, unify it in the queryById
method. Determine and return the result class
//Declare a thread pool, because using logical expiration to solve cache breakdown requires a new thread to complete the reconstruction of the cache. private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); @Override public Result queryById(Long id) { //Test using logical expiration to solve cache breakdown Shop shop = queryWithLogicalExpire(id); // If shop equals null, it means that the corresponding store does not exist in the database or the cached store information is an empty string. if (shop == null) { return Result.fail("The store does not exist!!"); } // shop is not equal to null, return the queried merchant information to the front end return Result.ok(shop); } public Shop queryWithLogicalExpire(Long id) { //1. First query the corresponding hot store cache information (including expiration time) from Redis. The constant value here is the fixed store prefix + query store Id. String json = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); //2. If it is not hit, that is, json is equal to null, or if it is hit, but json is equal to the empty string, null will be returned directly (indicating that we have not imported the corresponding key) //"", null and "/t/n (line feed)" will all be judged as empty and return false if (StrUtil.isBlank(json)) { return null; } //3. If the hot store information is queried in Redis and it is not an empty string, convert the JSON string into a RedisData object RedisData redisData = JSONUtil.toBean(json, RedisData.class); //4. The essential type of redisData.getData() is JSONObject type (or JSON string) and not Object type object, so it cannot be directly converted to Shop type, and a tool class is required. Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class); //5. Get the expiration time encapsulated in the RedisData object and determine whether it has expired. LocalDateTime expireTime = redisData.getExpireTime(); if(expireTime.isAfter(LocalDateTime.now())) { // 5.1. Not expired, return store information directly return shop; } // 6. It has expired and needs to be cached again. Query the store information corresponding to the database and then write it to Redis and set the logical expiration time. // 6.1. Obtain mutex lock boolean isLock = tryLock(LOCK_SHOP_KEY + id); // 6.2. Determine whether the lock acquisition is successful if (isLock){ // Check again whether the Redis cache has expired (double check). If it exists, there is no need to rebuild the cache. // If the store information cached in Redis is still expired, start an independent thread to implement cache reconstruction (you can sleep for 200ms during testing), and the actual logical expiration time of the cache is set to 30 minutes. CACHE_REBUILD_EXECUTOR.submit( ()->{// Start an independent thread try{ this.saveShop2Redis(id,20L); }catch (Exception e){ throw new RuntimeException(e); }finally { unlock(LOCK_SHOP_KEY + id); } }); } // 6.4. Return expired store information return shop; } public void saveShop2Redis(Long id, Long expirSeconds) {<!-- --> // 1. Query store data in the database according to the store ID Shop shop = getById(id); // Since local query database is faster, simulate reconstruction delay Thread.sleep(200); // 2. Encapsulate the logical expiration time (convert the current time to seconds) RedisData redisData = new RedisData(); //Set hot store information redisData.setData(shop); redisData.setExpireTime(LocalDateTime.now().plusSeconds(expirSeconds)); // 3. Convert the RedisData object containing hot store information and logical expiration time fields into a JSON string and cache it to Redis stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData)); }
Test logic expiration to solve cache breakdown
Use Jmeter to test whether all threads will perform cache reconstruction or return old data when they cannot find the data. If the data is inconsistent when rebuilding the data, will the cache data in Redis be updated
- Use the
saveShop2Redis
method in the test classHmDianPingApplicationTests
to add a cache of hot store information to Redis and set the logical expiration time to 2 seconds. - Manually modify the information of this hot store in the MySQL database. After 2 seconds, the hot store data cached in Redis will logically expire and be inconsistent with the corresponding store information in the MySQL database.
- When users access expired cached data, they need to open a new thread to reconstruct the cached data. Before reconstruction, only dirty data (data before modification) can be obtained, and new data (data after modification) can be obtained after reconstruction. data)
Open 100 to access logical expired data
The users in the front can only see the dirty data, and the users in the back can see the new data