SpringBoot整合Redis實現常用功能超詳細過程
SpringBoot整合Redis實現常用功能建議大小夥們,在寫業務的時候,提前畫好流程圖,思路會清晰很多。文末有解決緩存穿透和擊穿的通用工具類。
1 登陸功能
我想,登陸功能
是每個項目必備的功能吧,但是想設計好,卻是很難!下面介紹兩種登陸功能的解決方式:
- 基於Session實現登錄流程
- 基於Redis實現登錄流程
1.1 基於Session實現登錄流程
功能流程:
發送驗證碼:
-
用戶在提交手機號後,會校驗手機號是否合法,如果不合法,則要求用戶重新輸入手機號
-
如果手機號合法,後臺此時生成對應的驗證碼,同時將驗證碼進行保存,然後再通過短信的方式將驗證碼發送給用戶
短信驗證碼登錄、註冊:
- 用戶將驗證碼和手機號進行輸入,後臺從session中拿到當前驗證碼,然後和用戶輸入的驗證碼進行校驗,如果不一致,則無法通過校驗,如果一致,則後臺根據手機號查詢用戶,
- 如果用戶不存在,則為用戶創建賬號信息,保存到數據庫,無論是否存在,都會將用戶信息保存到session中,方便後續獲得當前登錄信息
校驗登錄狀態:
- 用戶在請求時候,會從cookie中攜帶者JsessionId到後臺,後臺通過JsessionId從session中拿到用戶信息,如果沒有session信息,則進行攔截,如果有session信息,則
- 將用戶信息保存到threadLocal中,並且放行
1.1.1 session共享問題
基於session方式實現登陸功能
,最大的缺點就是在多臺
tomcat下session無法共享,就會下出現下面問題。
核心思路分析:
每個tomcat中都有一份屬於自己的session,假設用戶第一次訪問第一臺tomcat,並且把自己的信息存放到第一臺服務器的session中,但是第二次這個用戶訪問到瞭第二臺tomcat,那麼在第二臺服務器上,肯定沒有第一臺服務器存放的session,所以此時 整個登錄攔截功能就會出現問題,我們能如何解決這個問題呢?早期的方案是session拷貝
,就是說雖然每個tomcat上都有不同的session,但是每當任意一臺服務器的session修改時,都會同步給其他的Tomcat服務器的session,這樣的話,就可以實現session的共享瞭
但是這種方案具有兩個大問題
1、每臺服務器中都有完整的一份session數據
,服務器壓力過大。
2、session拷貝數據時,可能會出現延遲
所以咱們後來采用的方案都是基於redis來完成,我們把session換成redis,redis數據本身就是共享的,就可以避免session共享的問題瞭
1.2 Redis替代Session
1.2.1、設計key的結構
首先我們要思考一下利用redis來存儲數據,那麼到底使用哪種結構呢?由於存入的數據比較簡單,我們可以考慮使用String,或者是使用哈希,如下圖,如果使用String,同學們註意他的value,用多占用一點空間,如果使用哈希,則他的value中隻會存儲他數據本身,如果不是特別在意內存,其實使用String就可以啦。
1.2.2、設計key的具體細節
所以我們可以使用String結構,就是一個簡單的key,value鍵值對的方式,但是關於key的處理,session他是每個用戶都有自己的session,但是redis的key是共享的,咱們就不能使用code瞭
在設計這個key的時候,我們之前講過需要滿足兩點:
1、key要具有唯一性2、key要方便攜帶
如果我們采用phone
:手機號這個的數據來存儲當然是可以的,但是如果把這樣的敏感數據存儲到redis中並且從頁面中帶過來畢竟不太合適,所以我們在後臺生成一個隨機串token
,然後讓前端帶來這個token就能完成我們的整體邏輯瞭.
1.2.3、整體訪問流程
當註冊完成後,用戶去登錄會去校驗用戶提交的手機號和驗證碼
,是否一致,如果一致,則根據手機號查詢用戶信息,不存在則新建
,最後將用戶數據保存到redis,並且生成token作為redis的key,當我們校驗用戶是否登錄時,會去攜帶著token進行訪問,從redis中取出token對應的value,判斷是否存在這個數據,如果沒有則攔截,如果存在則將其保存到threadLocal中,並且放行。
2 緩存功能
2.1 什麼是緩存?
緩存(Cache),就是數據交換的緩沖區,俗稱的緩存就是緩沖區內的數據,一般從數據庫中獲取,存儲於本地代碼(例如:
例1:Static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>(); 本地用於高並發 例2:static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build(); 用於redis等緩存 例3:Static final Map<K,V> map = new HashMap(); 本地緩存
由於其被Static修飾,所以隨著類的加載而被加載到內存之中,作為本地緩存,由於其又被final修飾,所以其引用(例3:map)和對象(例3:new HashMap())之間的關系是固定的,不能改變,因此不用擔心賦值(=)導致緩存失效;
2.1.1 為什麼要使用緩存
一句話:因為速度快,好用
緩存數據存儲於代碼中,而代碼運行在內存中,內存的讀寫性能遠高於磁盤,緩存可以大大降低用戶訪問並發量帶來的
服務器讀寫壓力
實際開發過程中,企業的數據量,少則幾十萬,多則幾千萬,這麼大數據量,如果沒有緩存來作為"避震器",系統是幾乎撐不住的,所以企業會大量運用到緩存技術;
但是緩存也會增加代碼復雜度和運營的成本:
2.1.2 如何使用緩存
實際開發中,會構築多級緩存來使系統運行速度進一步提升,例如:本地緩存與redis中的緩存並發使用
瀏覽器緩存:主要是存在於瀏覽器端的緩存
應用層緩存:可以分為tomcat本地緩存,比如之前提到的map,或者是使用redis作為緩存
數據庫緩存:在數據庫中有一片空間是 buffer pool,增改查數據都會先加載到mysql的緩存中
CPU緩存:當代計算機最大的問題是 cpu性能提升瞭,但內存讀寫速度沒有跟上,所以為瞭適應當下的情況,增加瞭cpu的L1,L2,L3級的緩存
2.2.使用緩存
2.2.1 、緩存模型和思路
標準的操作方式就是查詢數據庫之前先查詢緩存
,如果緩存數據存在,則直接從緩存中返回,如果緩存數據不存在,再查詢數據庫,然後將數據存入redis
2.3 緩存更新策略
緩存更新是redis為瞭節約內存而設計出來的一個東西,主要是因為內存數據寶貴,當我們向redis插入太多數據,此時就可能會導致緩存中的數據過多,所以redis會對部分數據進行更新
,或者把他叫為淘汰更合適。
內存淘汰:redis自動進行,當redis內存達到咱們設定的max-memery的時候,會自動觸發淘汰機制
,淘汰掉一些不重要的數據(可以自己設置策略方式)
超時剔除:當我們給redis設置瞭過期時間ttl之後,redis會將超時的數據進行刪除,方便咱們繼續使用緩存
主動更新:我們可以手動調用方法把緩存刪掉,通常用於解決緩存和數據庫不一致問題
2.3.1 、數據庫緩存不一致解決方案:
由於我們的緩存的數據源來自於數據庫
,而數據庫的數據是會發生變化的
,因此,如果當數據庫中數據發生變化,而緩存卻沒有同步
,此時就會有一致性問題存在
,其後果是:
用戶使用緩存中的過時數據,就會產生類似多線程數據安全問題,從而影響業務,產品口碑等;怎麼解決呢?有如下幾種方案
Cache Aside Pattern 人工編碼方式:緩存調用者在更新完數據庫後再去更新緩存,也稱之為雙寫方案(一般采用
)
Read/Write Through Pattern : 由系統本身完成,數據庫與緩存的問題交由系統本身去處理
Write Behind Caching Pattern :調用者隻操作緩存,其他線程去異步處理數據庫,實現最終一致
2.3.2 、數據庫和緩存不一致采用什麼方案
綜合考慮使用方案一,但是方案一調用者如何處理呢?這裡有幾個問題
操作緩存和數據庫時有三個問題需要考慮:
如果采用第一個方案,那麼假設我們每次操作數據庫後,都操作緩存,但是中間如果沒有人查詢,那麼這個更新動作實際上隻有最後一次生效,中間的更新動作意義並不大,我們可以把緩存刪除,等待再次查詢時,將緩存中的數據加載出來
- 刪除緩存還是更新緩存?
- 更新緩存:每次更新數據庫都更新緩存,無效寫操作較多
- 刪除緩存:更新數據庫時讓緩存失效,查詢時再更新緩存
- 如何保證緩存與數據庫的操作的同時成功或失敗?
- 單體系統,將緩存與數據庫操作放在一個事務
- 分佈式系統,利用TCC等分佈式事務方案
應該具體操作緩存還是操作數據庫,我們應當是先操作數據庫,再刪除緩存
,原因在於,如果你選擇第一種方案,在兩個線程並發來訪問時,假設線程1先來,他先把緩存刪瞭,此時線程2過來,他查詢緩存數據並不存在,此時他寫入緩存,當他寫入緩存後,線程1再執行更新動作時,實際上寫入的就是舊的數據,新的數據被舊數據覆蓋瞭。
- 先操作緩存還是先操作數據庫?
- 先刪除緩存,再操作數據庫(
存在線程安全問題
) - 先操作數據庫,再刪除緩存
2.4 緩存穿透問題的解決思路
緩存穿透 :緩存穿透是指客戶端請求的數據在緩存中和數據庫中都不存在,這樣緩存永遠不會生效,這些請求都會打到數據庫。
常見的解決方案有兩種:
- 緩存空對象
- 優點:實現簡單,維護方便
- 缺點:
- 額外的內存消耗
- 可能造成短期的不一致
- 佈隆過濾
- 優點:內存占用較少,沒有多餘key
- 缺點:
- 實現復雜
- 存在誤判可能
緩存空對象思路分析:當我們客戶端訪問不存在的數據時,先請求redis,但是此時redis中沒有數據,此時會訪問到數據庫,但是數據庫中也沒有數據,這個數據穿透瞭緩存,直擊數據庫,我們都知道數據庫能夠承載的並發不如redis這麼高,如果大量的請求同時過來訪問這種不存在的數據,這些請求就都會訪問到數據庫,簡單的解決方案就是哪怕這個數據在數據庫中也不存在,我們也把這個數據存入到redis中去,這樣,下次用戶過來訪問這個不存在的數據,那麼在redis中也能找到這個數據就不會進入到緩存瞭.
佈隆過濾:佈隆過濾器其實采用的是哈希思想來解決這個問題,通過一個龐大的二進制數組,走哈希思想去判斷當前這個要查詢的這個數據是否存在,如果佈隆過濾器判斷存在,則放行,這個請求會去訪問redis,哪怕此時redis中的數據過期瞭,但是數據庫中一定存在這個數據,在數據庫中查詢出來這個數據後,再將其放入到redis中
假設佈隆過濾器判斷這個數據不存在,則直接返回
這種方式優點在於節約內存空間,存在誤判,誤判原因在於:佈隆過濾器走的是哈希思想,隻要哈希思想,就可能存在哈希沖突
小總結:
緩存穿透產生的原因是什麼?
- 用戶請求的數據在緩存中和數據庫中都不存在,不斷發起這樣的請求,給數據庫帶來巨大壓力
緩存穿透的解決方案有哪些?
- 緩存null值
- 佈隆過濾
- 增強id的復雜度,避免被猜測id規律
- 做好數據的基礎格式校驗
- 加強用戶權限校驗
- 做好熱點參數的限流
3.工具類
此工具類已經對緩存穿透,和緩存擊穿實現瞭通用功能。
可以對比上敘的流程圖查閱
import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.time.LocalDateTime; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.function.Function; import static com.hmdp.utils.RedisConstants.CACHE_NULL_TTL; /** * @author : look-word * 2022-08-19 17:02 **/ @Component public class CacheClient { @Resource private StringRedisTemplate stringRedisTemplate; public void set(String key, Object value, Long time, TimeUnit unit) { stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit); } /** * 設置邏輯過期時間 */ public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) { // .封裝邏輯時間 RedisData redisData = new RedisData(); redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time))); redisData.setData(value); String redisDataJson = JSONUtil.toJsonStr(redisData); // 寫入Redis stringRedisTemplate.opsForValue().set(key, redisDataJson); } /** * 解決緩存穿透 對未存在的數據 設置為null */ public <R, ID> R queryWithPassThrough (String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long cacheTime, TimeUnit cacheUnit) { // 緩存key String key = keyPrefix + id; // 1 查詢緩存中是否命中 String json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { R r = JSONUtil.toBean(json, type); return r; } // 解決緩存穿透 數據庫不存在的數據 緩存 也不存在 惡意請求 if (json != null) { return null; } // 2 查詢數據庫 存在 存入緩存 返回給前端 R r = dbFallback.apply(id); if (r == null) { // 解決緩存穿透 stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } // 2.1 轉換成json 存入緩存中 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(r), cacheTime, cacheUnit); return r; } // 線程池 public static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); /** * 解決緩存擊穿 邏輯過期時間方式 */ public <R, ID> R queryWithLogicalExpire (String keyPrefix, ID id, Class<R> type, String lockKeyPrefix, Function<ID, R> dbFallback, Long expiredTime, TimeUnit expiredUnit) { // 緩存key String key = keyPrefix + id; // 1 查詢緩存中是否命中 String redisDataJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(redisDataJson)) { return null; } // 2.命中 查看是否過期, // 2.1 未過期 直接返回舊數據 // 2.2 過期 獲取鎖 查詢數據寫入Redis設置新的過期時間 // 2.3 過期 未獲取鎖 返回 舊數據 RedisData redisData = JSONUtil.toBean(redisDataJson, RedisData.class); LocalDateTime expireTime = redisData.getExpireTime(); R r = JSONUtil.toBean((JSONObject) redisData.getData(), type); if (LocalDateTime.now().isBefore(expireTime)) { return r; } String lockKey = lockKeyPrefix + id; // 獲取鎖 boolean isLock = tryLock(lockKey); if (isLock) { CACHE_REBUILD_EXECUTOR.submit(() -> { try { // 查詢數據庫 R r1 = dbFallback.apply(id); // 存儲Redis 設置邏輯過期 過期時間 setWithLogicalExpire(key, r1, expiredTime, expiredUnit); } catch (Exception e) { throw new RuntimeException(e); } finally { // 釋放鎖 unlock(lockKey); } }); } // 未獲取到鎖 return r; } /** * 獲取鎖 */ public boolean tryLock(String key) { Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 100, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } /** * 釋放鎖 */ public void unlock(String key) { stringRedisTemplate.delete(key); } }
到此這篇關於SpringBoot整合Redis實現常用功能超詳細過程的文章就介紹到這瞭,更多相關SpringBoot整合Redis內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- Redis唯一ID生成器的實現
- java 用redisTemplate 的 Operations存取list集合操作
- springboot使用redis的詳細步驟
- SpringBoot整合Redis將對象寫入redis的實現
- SpringBoot集成Redis並實現主從架構的實踐