Redis 緩存擊穿問題及解決方案
1. 緩存擊穿概念
緩存擊穿:緩存擊穿也叫做熱點Key問題,就是少量被高并發(fā)訪問并且緩存重建業(yè)務(wù)比較復(fù)雜的key突然失效了,無數(shù)的請求訪問會在瞬間給數(shù)據(jù)庫帶來巨大的壓力。
如圖所示:

線程1緩存未命中,去重建緩存;在線程1重建緩存的時候,線程2緩存又沒命中,線程2也去重建緩存;和線程2同時來的線程3,線程4…緩存都沒命中,都去重建緩存,給數(shù)據(jù)庫帶來了巨大的壓力。
2. 解決方案
緩存擊穿的常見解決方案有兩種:
- 互斥鎖
- 邏輯過期
2.1 互斥鎖
互斥鎖的實現(xiàn)思路就是在第一個線程到來的時候獲取互斥鎖,后面的線程來到之后嘗試去獲取互斥鎖,獲取失敗,于是進行休眠重試。直到第一個線程緩存重建成功之后,釋放互斥鎖。之后其余線程在重試過程中就成功查詢緩存命中了重建數(shù)據(jù)。
互斥鎖的流程圖如下:

2.1.1 互斥鎖的優(yōu)缺點
優(yōu)點:
- 沒有額外的內(nèi)存消耗
- 保證一致性(數(shù)據(jù)庫和redis數(shù)據(jù)一致)
- 實現(xiàn)簡單
缺點:
- 線程需要等待,性能受影響
- 可能有死鎖風(fēng)險(一個方法里有多個查詢操作,另一個方法也有多個重合的查詢操作)
2.1.2 互斥鎖的代碼實現(xiàn)
我們先設(shè)定一個場景:假設(shè)這是一個電商平臺,我們通過id去查詢店鋪信息。
代碼實現(xiàn)流程圖如下:

首先我們編寫獲取鎖和釋放鎖的方法,如下所示:
//獲取鎖
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//釋放鎖
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
然后編寫一個解決緩存擊穿問題的方法,最后寫一個調(diào)用解決方法的業(yè)務(wù)方法:
@Override
public Result queryById(Long id) {
//緩存空對象解決 緩存穿透
//Shop shop = queryWithPassThrough(id);
//互斥鎖解決 緩存擊穿
Shop shop = queryWithMutex(id);
if (shop == null) {
return Result.fail("店鋪不存在!");
}
return Result.ok(shop);
}
public Shop queryWithMutex(Long id) {
//1.從redis查詢商鋪緩存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判斷是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//此時 shopJson 不是為null就是為""
if (shopJson != null) {
//為""直接返回錯誤信息,為null查詢數(shù)據(jù)庫
return null;
}
//4.實現(xiàn)緩存重建
//4.1.獲取互斥鎖
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
//4.2.判斷是否獲取成功
while (!isLock) {
//4.3.失敗,則休眠重試
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4.獲取鎖成功,再次檢測緩存釋放存在(double check)
String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(cacheShopJson)) {
//4.5.存在,直接返回
return JSONUtil.toBean(cacheShopJson, Shop.class);
}
//5.緩存數(shù)據(jù)不存在,根據(jù)id查詢數(shù)據(jù)庫
shop = getById(id);
//模擬重建的延時
Thread.sleep(200);
//6.不存在,返回錯誤
if (shop == null) {
//緩存空值
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//7.存在,寫入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//8.釋放鎖
unLock(lockKey);
}
return shop;
}
2.2 邏輯過期
邏輯過期就是給緩存的數(shù)據(jù)添加一個邏輯過期字段,而不是真正的給它設(shè)置一個TTL。每次查詢緩存的時候去判斷是否已經(jīng)超過了我們設(shè)置的邏輯過期時間,如果未過期,直接返回緩存數(shù)據(jù);如果已經(jīng)過期則進行緩存重建。
邏輯過期的流程圖如下:

解釋:第一個線程到來之后發(fā)現(xiàn)邏輯過期,于是獲取互斥鎖,再開啟一個新線程去進行緩存重建。當后續(xù)線程到來時,發(fā)現(xiàn)緩存已過期,嘗試獲取互斥鎖也失敗,但是此時不進行等待重試,而是直接返回過期數(shù)據(jù)。之后第一個線程成功緩存數(shù)據(jù)釋放互斥鎖之后,后面線程繼續(xù)來訪,發(fā)現(xiàn)命中緩存并且沒有過期,返回重建數(shù)據(jù)。
2.2.1 邏輯過期的優(yōu)缺點
優(yōu)點:
- 線程無需等待,性能較好
缺點:
- 不保證一致性(因為會返回過期數(shù)據(jù))
- 有額外的內(nèi)存消耗(同時緩存了邏輯過期時間的字段)
- 實現(xiàn)復(fù)雜
2.2.2 邏輯過期的代碼實現(xiàn)
我們先設(shè)定一個場景:假設(shè)這是一個電商平臺,我們通過id去查詢店鋪信息。
代碼實現(xiàn)流程圖如下:

1)構(gòu)建存儲類
我們想要實現(xiàn)邏輯過期,首先得清楚redis中到底要存儲什么樣的數(shù)據(jù)?我們是不是要在每個類中都添加一個邏輯過期的字段?這是不對的,如果我們再每個類中都添加了一個邏輯過期時間字段,這樣對原代碼就有了 侵入性 ,我們應(yīng)該使整個系統(tǒng)具有可拓展性,所以我們應(yīng)該新建一個類來填充要存入redis的數(shù)據(jù),代碼如下:
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
2)創(chuàng)建線程池
由于我們需要開啟獨立線程去重建緩存,所以我們可以選擇創(chuàng)建一個線程池。
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
3)編寫緩存重建的代碼
緩存重建就是直接查詢數(shù)據(jù)庫,將查詢到的數(shù)據(jù)緩存到redis中。
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
//1.查詢店鋪數(shù)據(jù)
Shop shop = getById(id);
//2.封裝邏輯過期時間
RedisData redisData = new RedisData();
redisData.setData(shop);
//設(shè)置邏輯過期時間
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
4)編寫業(yè)務(wù)方法并調(diào)用緩存擊穿方法
@Override
public Result queryById(Long id) {
//緩存空對象解決 緩存穿透
//Shop shop = queryWithPassThrough(id);
//互斥鎖解決 緩存擊穿
//Shop shop = queryWithMutex(id);
//邏輯過期解決 緩存擊穿
Shop shop = queryWithLogicalExpire(id);
if (shop == null) {
return Result.fail("店鋪不存在!");
}
return Result.ok(shop);
}
public Shop queryWithLogicalExpire(Long id) {
//1.從redis查詢商鋪緩存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判斷是否存在
if (StrUtil.isBlank(shopJson)) {
//未命中,直接返回空
return null;
}
//3.命中,判斷是否過期
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop cacheShop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
//3.1未過期,直接返回店鋪信息
return cacheShop;
}
//3.2.已過期,緩存重建
//3.3.獲取鎖
String lockKey = LOCK_SHOP_KEY + id;
boolean flag = tryLock(lockKey);
if (flag) {
//3.4.獲取成功
//4再次檢查redis緩存是否過期,做double check
shopJson = stringRedisTemplate.opsForValue().get(key);
//4.1.判斷是否存在
if (StrUtil.isBlank(shopJson)) {
//未命中,直接返回空
return null;
}
//4.2.命中,判斷是否過期
redisData = JSONUtil.toBean(shopJson, RedisData.class);
cacheShop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
//4.3.未過期,直接返回店鋪信息
return cacheShop;
}
CACHE_REBUILD_EXECUTOR.submit(() -> {
//5.重建緩存
try {
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//釋放鎖
unLock(lockKey);
}
});
}
//7.獲取失敗,返回舊數(shù)據(jù)
return cacheShop;
}到此這篇關(guān)于Redis 緩存擊穿問題及解決方案的文章就介紹到這了,更多相關(guān)Redis 緩存擊穿內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Redis緩存和數(shù)據(jù)庫的數(shù)據(jù)一致性的問題解決
隨業(yè)務(wù)增長,直接操作數(shù)據(jù)庫性能下降,引入緩存提高讀性能常見,但緩存和數(shù)據(jù)庫的雙寫操作會引發(fā)數(shù)據(jù)不一致問題,本文討論幾種常用同步策略,感興趣的可以了解一下2024-09-09

