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)思路就是在第一個線程到來的時候獲取互斥鎖,后面的線程來到之后嘗試去獲取互斥鎖,獲取失敗,于是進(jì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)過期則進(jìn)行緩存重建。
邏輯過期的流程圖如下:
解釋:第一個線程到來之后發(fā)現(xiàn)邏輯過期,于是獲取互斥鎖,再開啟一個新線程去進(jìn)行緩存重建。當(dāng)后續(xù)線程到來時,發(fā)現(xiàn)緩存已過期,嘗試獲取互斥鎖也失敗,但是此時不進(jì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