學習資料
學習的黑馬程序員嗶站項目黑馬點評,用作記錄和探究原理。
Redis緩存
緩存 :就是數據交換的緩沖區,是存儲數據的臨時地方,讀寫性能較高
緩存常見的場景:
- 數據庫查詢加速:通過將頻繁查詢的數據緩存起來,減少對數據庫的直接訪問,提高查詢效率。
- 網頁內容緩存:將動態生成的網頁內容緩存起來,減少服務器生成頁面的次數,提高網頁響應速度。
- 會話數據緩存:將用戶會話數據緩存起來,支持分布式系統中的會話共享。
- 隊列系統:將任務隊列緩存起來,支持高效的任務調度和執行。
使用Redis緩存的流程
實際上,讀取數據庫是很消耗資源的。假設現在有一大批用戶短時間內請求數據庫,數據庫此時將面臨巨大的壓力。為了緩解這種壓力,可以使用Redis來緩存頻繁訪問的數據,減少對數據庫的直接訪問。
具體流程如下:
檢查緩存:首先檢查Redis緩存中是否存在所需數據。
如果緩存中存在數據,則直接返回數據給用戶,結束流程。
如果緩存中不存在數據,則繼續下一步。
查詢數據庫:從數據庫中查詢所需數據。
查詢到數據后,將數據返回給用戶,同時將數據寫入Redis緩存,以便下次快速訪問。
如果未查詢到數據,則返回空或相應的提示信息。
更新緩存:當數據庫中的數據發生變化時,需要同步更新Redis緩存中的數據,確保數據的一致性。通常可以使用以下兩種策略:
主動更新:在數據庫更新的同時,主動更新緩存中的數據。
被動更新:當檢測到緩存中的數據已失效時,再次從數據庫中查詢數據并更新緩存。
更新策略問題
為了保證緩存一致問題,我們在選擇更新策略的時候一般選擇主動更新的策略,
被動更新不一致問題
因為被動更新會出現不一致情況,比如說我們現在將一個店鋪信息存入數據庫,策略選擇的被動策略,但是查詢店鋪信息我們選擇的是使用Redis緩存,此時永遠不回去更新這個店鋪,因為我頁面呈遞不出來這個店鋪信息,怎么能去請求他然后查詢數據庫更新呢?這就出現不一致的情況了。
對于這種情況再進行一個說明,我這邊說的場景就是說我們把這個商品的列表信息存入Redis,當添加一個新的商品之后,緩存中沒有,那么前端中就指定不會有,此時就很尷尬的一個場景。但是說一般不會這樣玩,一般只是把商品詳情信息存入Redis,商品列表還是查詢數據庫,畢竟不是很多,并且這樣的話避免出現不同步問題。
主動更新 緩存更新時機選擇
主動更新的時候時機選擇也是比較重要的,當我更新數據庫一條消息的時候,此時我是選擇先將數據存入數據庫還是說先將Redis中的數據刪除? 或者我向數據庫添加一個消息,我是先存入數據庫,還是先存入Redis中呢?
我們來看一下這張圖
先刪除緩存再更新數據庫
場景先刪除緩存,然后操作更新數據庫,這個時候我們面臨一個問題,由于就是說第一次請求肯定是先請求緩存中的,當緩存沒有的時候此時請求數據庫,我們假設第一個用戶A此時更新了數據庫中一件商品的信息,選擇先把緩存中舊的信息刪除,然后將數據庫更新,那么此時用戶A更新數據庫的時候,用戶B來訪問這個信息,先看緩存,緩存中沒有,那么此時肯定得查詢數據庫,數據庫中還沒更新成功,那么這個數據查詢依舊是舊的值,這樣不就出現問題了嗎,這樣的話就出現一個場景,由于就是更新數據庫耗時久,那么這個時候出現大量請求這個內容,那么持續請求數據庫,會造成壓力,二就是此時數據也不一致。
先更新數據庫,再刪除緩存。
在處理緩存和數據庫更新時,我們選擇先更新數據庫,然后刪除緩存。這樣做有幾個優勢:
降低數據庫查詢壓力:
當用戶訪問該信息時,緩存中仍然是舊的信息。雖然這意味著短時間內用戶可能獲取到的是舊數據,但由于緩存命中,減少了對數據庫的直接查詢,降低了數據庫的查詢壓力。
確保數據一致性:
一旦數據庫更新完成,再刪除緩存。此時,緩存被清除,下一次用戶訪問該信息時,會從數據庫讀取最新的數據并重新加載到緩存中。這種方式保證了數據的一致性,確保緩存中不會出現過時的數據。
針對緩存不一致問題我了解的解決方案有以下幾種:
- 延遲雙刪策略(Double Deletion with Delay)
在更新數據庫后,通過延遲再次刪除緩存以確保數據的一致性。
// 更新數據庫
updateDatabase();
// 刪除緩存
deleteCache();
// 延遲一段時間后再刪除緩存
Thread.sleep(500); // 延遲時間根據具體場景調整
deleteCache();
- 互斥鎖機制(Mutex Locking Mechanism)
使用分布式鎖來避免多個請求同時更新數據庫或緩存。確保只有一個請求可以訪問數據庫并更新緩存。
String value = getCache(key);
if (value == null) {if (lock(key)) {try {value = queryDatabase();setCache(key, value);} finally {unlock(key);}} else {// 等待鎖釋放后重試Thread.sleep(50); // 重試時間根據具體場景調整value = getCache(key);}
}
return value;
- 緩存預熱(Cache Warming)
在應用啟動時或緩存失效時,預先加載常用的數據到緩存中,減少緩存穿透的概率。
// 在應用啟動時加載常用數據到緩存
loadCommonDataToCache();
- 讀寫分離(Read-Write Separation)
將讀操作與寫操作分離,寫操作使用主數據庫,讀操作使用從數據庫,減輕數據庫壓力。
// 寫操作使用主數據庫
updateMainDatabase();// 讀操作使用從數據庫
value = queryReadReplicaDatabase();
緩存場景會出現的問題
緩存穿透
是指客戶端請求的數據在緩存中和數據庫中都不存在,這樣緩存永遠不會生效,這些請求都會打到數據庫。常見的解決方案有兩種。
- 緩存空對象
也就是說我們此時查詢數據庫發現這個內容是空的,我們避免下一次有人把繼續訪問這個空的內容,然后造成服務器壓力,那么此時我們把這個空的內容緩存到Redis中,此時當再次請求這個內容的時候,請求的是Redis中的內容,對服務器壓力會降低很多。
步驟:
客戶端請求數據。
檢查緩存,發現沒有命中。
查詢數據庫,發現數據不存在。
將空對象(如null或者特殊標識)緩存到Redis中,并設置一個合理的過期時間。
當再次請求這個內容時,直接從Redis中獲取空對象,減少數據庫壓力。
- 布隆過濾
布隆過濾器是一種概率型數據結構,可以高效地判斷某個元素是否在一個集合中。通過將所有可能存在的緩存鍵值存儲在布隆過濾器中,可以快速判斷某個請求是否是無效的(即數據庫中也不存在),從而減少對數據庫的查詢。
步驟:
初始化布隆過濾器,將所有可能存在的鍵值添加到過濾器中。
客戶端請求數據時,先檢查布隆過濾器。
如果布隆過濾器判斷數據不存在,直接返回空結果,避免查詢數據庫。
如果布隆過濾器判斷數據可能存在,再查詢緩存和數據庫。
示例代碼:
// 初始化布隆過濾器,并添加所有可能存在的鍵值
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), 1000000);
bloomFilter.putAll(getAllPossibleKeys());String key = getRequestKey();
if (!bloomFilter.mightContain(key)) {// 布隆過濾器判斷數據不存在,直接返回空結果return null;
}String value = getCache(key);
if (value == null) {value = queryDatabase(key);if (value == null) {// 數據不存在,緩存空對象setCache(key, "NULL", 300); // 300秒過期時間,可根據具體情況調整} else {// 數據存在,緩存實際數據setCache(key, value);}
}
if ("NULL".equals(value)) {// 返回數據不存在的響應return null;
}
return value;
緩存穿透的解決方案
- 緩存null值
- 布隆過濾
- 增強id的復雜度,避免被猜測id規律
- 做好數據的基礎格式校驗
- 加強用戶權限校驗
緩存雪崩
緩存雪崩主要是由于一段時間內,緩存的key值全部失效,也就是緩存期結束,導致大量請求到達數據庫,帶來巨大的壓力。
那么這個場景的解決方案也很簡單。
緩存擊穿
緩存擊穿被稱為熱點key 問題,就是一種被高并發訪問并且緩存重建業務較復雜的key,失效了,大量請求訪問的瞬間給數據庫帶來巨大壓力
假設一家店突然推出一家新的菜品,但是說此時緩存過程中數據庫更新了,將舊的Redis緩存刪除之后,此時大量用戶訪問這個內容的話會出現一個場景,形成一個閉環。
這樣不就出現大量請求數據庫的場景了嗎?我們要怎么避免這個內容呢
這個解決方案有兩種:
方案一:使用互斥鎖
相信大家在學習javase階段的多線程肯定面臨一個問題,也就是說多線程搶票問題,當不加鎖的情況下會出現超賣問題,加一個鎖一次只能搶一個,這樣會更好。這個位置也就是說更新緩存的時候加一個鎖,讓其他業務線程不進行更新操作。
但是說這個鎖應該選什么呢?現在既然在使用Redis,那么這個時候我們就直接選擇使用Redis中字符串類型的SETNX 作為互斥鎖。
方案二:使用邏輯過期
將Redis緩存中的內容設置一個邏輯過期字段,保證在讀取緩存時,可以判斷數據是否過期。這樣可以減少緩存穿透和擊穿問題。
具體步驟:
客戶端請求數據。
檢查緩存,獲取緩存數據和邏輯過期時間。
如果數據未過期,直接返回緩存數據。
如果數據已過期或緩存未命中:
啟動一個異步線程更新緩存。
返回舊數據或提示正在更新中。
緩存擊穿 - Java代碼解決
互斥鎖解決
互斥鎖這邊使用的鎖對象是Redis 中String類型的SETNX,由于其一個鍵只能賦值一次,這樣的話符合預期場景。
這樣寫的話,會保證不會出現連續查詢數據庫,保證當一個值改變之后只更新一次數據庫操作。
其實也就是說,當我線程一更新的時候,我先判斷指定的鎖的key是否存在,存在將他設置,此時相當于線程一獲取了鎖,那么縣城二進入的時候便不可以獲取道這個鎖對象,那么線程二就需要等待,等待之后重試。直到緩存命中才結束。
場景:書寫接口查詢店鋪的詳細信息。其中id是店鋪的標識
public Shop queryWithPassThrough(Long id) {// 店鋪在Redis中的key規范 同一前綴 + 店鋪idString key = RedisConstants.CACHE_SHOP_KEY + id;// 查詢redis緩存中 是否會有這個內容String s = stringRedisTemplate.opsForValue().get(key);// 存在 直接返回if (StrUtil.isNotBlank(s)) {return JSONUtil.toBean(s, Shop.class);}/** 既然不是null 那一定是 ""* 這樣的話我們需要進行 返回錯誤了 防止繼續去查詢數據庫* 這個是緩存穿透的一個防護手段 上面講解過了* */if (s != null) {return null;}// 實現緩存重建// 獲取互斥鎖String lockKey = RedisConstants.LOCK_SHOP_KEY + id;Shop byId = null;try {
// 獲取鎖 直接使用String中的setnx即可 判斷是否獲取成功boolean b = tryLocal(lockKey);
// 失敗if (!b) {// 休眠Thread.sleep(50);// 重試 重新調用遞歸queryWithPassThrough(id);}// 不存在 查詢數據庫byId = getById(id);// 不存在 返回錯誤if (byId == null) {/** 寫入空值內容 防止 持續 緩存穿透 造成服務器資源浪費* 時間設置成 2 min* */stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);return null;}// 數據庫存在// 將數據放入redis中/** 這個位置書寫一個超時設置 避免資源浪費* 主要是為了解決* 緩存不一致問題* */stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(byId), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);// 返回數據} catch (Exception e) {throw new RuntimeException(e);} finally {
// 釋放鎖delLocal(lockKey);}
// 返回店鋪詳情return byId;}private boolean tryLocal(String key) {Boolean judge = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);return BooleanUtil.isTrue(judge);}private void delLocal(String key) {stringRedisTemplate.delete(key);}
邏輯刪除
邏輯刪除 ,也就是向儲存的數據中加一個字段,這個字段就是過期時間,這邊設置的實體是按照這種格式設計的內容。
package com.hmdp.utils;import lombok.Data;import java.time.LocalDateTime;/*
* 邏輯過期時間實體
* */
@Data
public class RedisData {private LocalDateTime expireTime;// 存入的數據private Object data;
}
我們緩存重建的時候,需要將邏輯過期時間重置,所以這個時候我們需要封裝一個方法來重置邏輯過期時間。
public void saveShopToRedis(Long id, Long seconds) {
// 1.查詢店鋪數據Shop byId = getById(id);RedisData redisData = new RedisData();redisData.setData(byId);
// 2.封裝邏輯過期時間redisData.setExpireTime(LocalDateTime.now().plusSeconds(seconds));
// 3.寫入redisstringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));}
邏輯過期代碼 - 其中緩存重建新開一個線程使用。
public Shop queryWithLogicalExpire(Long id) {String key = RedisConstants.CACHE_SHOP_KEY + id;
// 查詢redis緩存中 是否會有這個內容String s = stringRedisTemplate.opsForValue().get(key);
// 存在 直接返回if (StrUtil.isNotBlank(s)) {return JSONUtil.toBean(s, Shop.class);}/** 既然不是null 那一定是 ""* 這樣的話我們需要進行 返回錯誤了 防止繼續去查詢數據庫* 這個是緩存穿透的一個防護手段* */if (s != null) {return null;}RedisData bean = JSONUtil.toBean(s, RedisData.class);Shop shop = JSONUtil.toBean((JSONObject) bean.getData(), Shop.class);LocalDateTime localDateTime = bean.getExpireTime();if (localDateTime.isAfter(LocalDateTime.now())) {return shop;}String lockKey = RedisConstants.LOCK_SHOP_KEY + id;boolean b = tryLocal(lockKey);if (b) {try {CACHE_REBUILD_EXCUTOR.submit(() -> {this.saveShopToRedis(id, RedisConstants.CACHE_SHOP_TTL);});} catch (Exception e) {throw new RuntimeException(e);} finally {
// 釋放鎖delLocal(lockKey);}}return shop;}