一、什么是緩存
-
在實際開發中,系統需要"避震器",
防止過高的數據訪問猛沖系統
,導致其操作線程無法及時處理信息而癱瘓.- 這在實際開發中對企業講,對產品口碑,用戶評價都是致命的。所以企業非常重視緩存技術;
-
緩存(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())之間的關系是固定的,不能改變,因此不用擔心賦值(=)導致緩存失效。
二、為什么使用緩存
- 優點:訪問速度快,好用
- 緩存數據存儲于代碼中,而代碼運行在內存中,
內存的讀寫性能遠高于磁盤
,緩存可以大大降低
用戶訪問并發量帶來的服務器讀寫壓力。- 實際開發過程中,企業的數據量,少則幾十萬,多則幾千萬,這么大數據量,如果沒有緩存來作為"避震器",系統是幾乎撐不住的,所以企業會大量運用到緩存技術。
- 緩存也會增加代碼復雜度和運營的成本。
三、使用緩存的缺點與優點
四、如何使用緩存
- 實際開發中,會構建
多級緩存
來使系統運行速度進一步提升,例如:本地緩存與redis中的緩存并發使用等等。- 瀏覽器緩存:主要是存在于瀏覽器端的緩存
- 應用層緩存:可以分為tomcat本地緩存,比如之前提到的map,或者是使用redis作為緩存
- 數據庫緩存:在數據庫中有一片空間是 buffer pool,增改查數據都會先加載到mysql的緩存中
- CPU緩存:當代計算機最大的問題是 cpu性能提升了,但內存讀寫速度沒有跟上,所以為了適應當下的情況,增加了cpu的L1,L2,L3級的緩存
四、1、添加緩存的思路
-
標準的操作方式就是
查詢數據庫之前
先查詢緩存
:- 如果緩存數據存在,則直接從緩存中返回。
- 如果緩存數據不存在,再查詢數據庫,然后將查詢到的數據存入redis,再把數據庫的數據返回。
-
不使用緩存
-
使用緩存
-
舉例:根據id查詢商鋪的信息
四、2、緩存的更新策略
- 緩存更新是redis為了
節約內存
而設計出來的一個東西,主要是因為內存數據寶貴,當我們向redis插入太多數據,此時就可能會導致緩存中的數據過多
,所以redis會對部分數據進行更新,或者把它叫為淘汰更合適。主要有一下三種策略:- 內存淘汰:redis自動進行,當redis內存達到咱們設定的max-memery的時候,會自動觸發淘汰機制,淘汰掉一些不重要的數據(可以自己設置策略方式)
- 超時剔除:當我們給redis設置了過期時間ttl之后,redis會將超時的數據進行刪除,方便后面繼續使用緩存
- 主動更新:我們可以
手動調用方法把緩存刪掉
,通常用于解決緩存和數據庫不一致問題
四、2.1、數據庫和緩存不一致的問題解決:
- 由于我們的緩存的數據源來自于數據庫,而數據庫的數據是會發生變化的,因此,如果當數據庫中數據發生變化,而緩存卻沒有同步,此時就會有一致性問題存在,其后果是:
- 用戶使用緩存中的過時數據,就會產生類似多線程數據安全問題,從而影響業務,產品口碑等。怎么解決呢?有如下幾種方案:
- 1、
Cache Aside Pattern 人工編碼方式
:緩存調用者在更新完數據庫后再去更新緩存,也稱之為雙寫方案 - 2、
Read/Write Through Pattern
: 由系統本身完成,數據庫與緩存的問題交由系統本身去處理 - 3、
Write Behind Caching Pattern
:調用者只操作緩存,其他線程去異步處理數據庫,實現最終一致
- 1、
- 用戶使用緩存中的過時數據,就會產生類似多線程數據安全問題,從而影響業務,產品口碑等。怎么解決呢?有如下幾種方案:
數據庫和緩存不一致采用Cache Aside Pattern 人工編碼方式
- 數據庫和緩存不一致采用方法為:
Cache Aside Pattern 人工編碼方式
:緩存調用者在更新完數據庫后再去更新緩存,也稱之為雙寫方案
操作緩存和數據庫時有三個問題需要考慮:
- 假設我們每次操作數據庫后,都操作緩存,但是中間如果沒有人查詢,那么這個更新動作實際上只有最后一次生效,中間的更新動作意義并不大,我們可以
把緩存刪除,等待再次查詢時,將緩存中的數據加載出來
. - 問題一:選擇刪除緩存還是更新緩存? (
刪除緩存
)- 更新緩存:每次更新數據庫都更新緩存,無效寫操作較多
- 刪除緩存:更新數據庫時讓緩存失效,查詢時再更新緩存
- 為什么不更新緩存兒選擇直接刪除緩存呢?
- 因為
緩存的更新成本比刪除成本更高
。(因為你寫入數據庫的值在很多情況下并不是直接寫入緩存的,而是要經過一系列復雜的計算再寫入緩存。那么每次寫入數據庫后都再次計算寫入緩存的值,無疑是浪費性能的,所以刪除緩存更為適合。
- 因為
- 問題二:如何保證緩存與數據庫的操作的同時成功或失敗?
- 單體系統,將緩存與數據庫操作放在一個事務
- 分布式系統,利用TCC等分布式事務方案
- 問題三:選擇先操作緩存還是先操作數據庫?(
先操作數據庫,再刪除緩存
)- 先刪除緩存,再操作數據庫
- 先操作數據庫,再刪除緩存
- 具體操作緩存還是操作數據庫,我們應當是先操作數據庫,再刪除緩存
- 原因:如果你選擇第一種方案(Cache Aside Pattern 人工編碼方式),在兩個線程并發來訪問時,假設線程1先來,他先把緩存刪了,此時線程2過來查詢緩存數據并不存在,此時線程2查詢數據庫并把查詢出來的數據寫入緩存,當線程2把數據寫入緩存后,線程1再執行更新動作時,實際上寫入的就是舊的數據,新的數據被舊數據覆蓋了。
先刪除緩存還是先執行數據庫操作的選擇分析
- 寫緩存的時間是很快的(幾毫秒),而操作數據庫的時間是很長的。(操作緩存和操作數據庫效率差別大)
1、先刪除緩存,再操作數據
- 先刪除緩存,再操作數據的正常情況:
- 先刪除緩存,再操作數據的異常情況(出現數據庫和緩存數據不一致的問題):
- 解決辦法一:
延遲雙刪:最終一致性的方法,即使用雙刪的辦法。
(就是每次修改完數據之后再把緩存刪除了(使用延遲
刪除,避免刪除緩存操作在舊數據的寫入緩存操作之前)),這樣就保證了數據的一致性了,但還是會出現一次數據不一致的問題,如果想避免一次數據一致性都不出現就得使用強一致性的方法了)(推薦使用這個方法)- 這方法感覺就相當于先操作數據庫再刪除緩存一樣了,由此看來還是選擇先操作再刪除緩存
- 解決辦法二:使用
強一致性
的辦法(就是保證redis的操作和數據庫的操作的原子性
),可以使用加鎖的方法(使用這種方法會影響性能,我們使用redis的意義就是為了提高性能,所以加鎖就得不償失了,所以不太推薦了)
- 解決辦法一:
2、 先操作數據,再刪除緩存
- 先操作數據,再刪除緩存的正常情況:
- 先操作數據,再刪除緩存的異常情況1(出現數據庫和緩存數據不一致的問題):因為緩存操作是很快的,所以這種異常情況幾乎不可能發生。
- 先操作數據,再刪除緩存的異常情況2:刪除緩存失敗的情況,就是線程2執行刪除緩存操作時出現刪除失敗的問題,導致最終的緩存數據還是舊數據。
- 解決方案一:使用異步刪除重試的辦法(結合mq)
- 解決方案二:cannal解耦(使用cannal提供的java客戶端)
最終選擇:先操作數據庫再刪除緩存
緩存穿透
緩存雪崩
緩存擊穿(熱點key)
最后可以把緩存擊穿和緩存穿透的解決方法封裝成一個工具類
基于StringRedisTemplate封裝一個緩存工具類,滿足下列需求:
- 方法1:將任意Java對象序列化為json并存儲在string類型的key中,并且可以設置TTL過期時間
- 方法2:將任意Java對象序列化為json并存儲在string類型的key中,并且可以設置邏輯過期時間,用于處理緩
存擊穿問題
- 方法3:根據指定的key查詢緩存,并反序列化為指定類型,利用緩存空值的方式解決緩存穿透問題
- 方法4:根據指定的key查詢緩存,并反序列化為指定類型,需要利用邏輯過期解決緩存擊穿問題
@Component
public class CacheClient {private final StringRedisTemplate stringRedisTemplate;private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);//使用構造器注入第三方beanStringRedisTemplatepublic CacheClient(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}// 添加緩存,設置key的過期時間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.setData(value);redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));// 寫入RedisstringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));}// 獲取緩存,解決緩存穿透public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){String key = keyPrefix + id;// 1.從redis查詢商鋪緩存String json = stringRedisTemplate.opsForValue().get(key);// 2.判斷是否存在if (StrUtil.isNotBlank(json)) {// 3.存在,直接返回return JSONUtil.toBean(json, type);}// 判斷命中的是否是空值if (json != null) {// 返回一個錯誤信息return null;}// 4.不存在,根據id查詢數據庫R r = dbFallback.apply(id);// 5.不存在,返回錯誤if (r == null) {// 將空值寫入redisstringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);// 返回錯誤信息return null;}// 6.存在,寫入redisthis.set(key, r, time, unit);return r;}// 使用邏輯過期時間,解決緩存擊穿public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {String key = keyPrefix + id;// 1.從redis查詢商鋪緩存String json = stringRedisTemplate.opsForValue().get(key);// 2.判斷是否存在if (StrUtil.isBlank(json)) {// 3.存在,直接返回return null;}// 4.命中,需要先把json反序列化為對象RedisData redisData = JSONUtil.toBean(json, RedisData.class);R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);LocalDateTime expireTime = redisData.getExpireTime();// 5.判斷是否過期if(expireTime.isAfter(LocalDateTime.now())) {// 5.1.未過期,直接返回店鋪信息return r;}// 5.2.已過期,需要緩存重建// 6.緩存重建// 6.1.獲取互斥鎖String lockKey = LOCK_SHOP_KEY + id;boolean isLock = tryLock(lockKey);// 6.2.判斷是否獲取鎖成功if (isLock){// 6.3.成功,開啟獨立線程,實現緩存重建CACHE_REBUILD_EXECUTOR.submit(() -> {try {// 查詢數據庫R newR = dbFallback.apply(id);// 重建緩存this.setWithLogicalExpire(key, newR, time, unit);} catch (Exception e) {throw new RuntimeException(e);}finally {// 釋放鎖unlock(lockKey);}});}// 6.4.返回過期的商鋪信息return r;}// 使用互斥鎖解決緩存穿透public <R, ID> R queryWithMutex(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {String key = keyPrefix + id;// 1.從redis查詢商鋪緩存String shopJson = stringRedisTemplate.opsForValue().get(key);// 2.判斷是否存在if (StrUtil.isNotBlank(shopJson)) {// 3.存在,直接返回return JSONUtil.toBean(shopJson, type);}// 判斷命中的是否是空值if (shopJson != null) {// 返回一個錯誤信息return null;}// 4.實現緩存重建// 4.1.獲取互斥鎖String lockKey = LOCK_SHOP_KEY + id;R r = null;try {boolean isLock = tryLock(lockKey);// 4.2.判斷是否獲取成功if (!isLock) {// 4.3.獲取鎖失敗,休眠并重試Thread.sleep(50);return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);}// 4.4.獲取鎖成功,根據id查詢數據庫r = dbFallback.apply(id);// 5.不存在,返回錯誤if (r == null) {// 將空值寫入redisstringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);// 返回錯誤信息return null;}// 6.存在,寫入redisthis.set(key, r, time, unit);} catch (InterruptedException e) {throw new RuntimeException(e);}finally {// 7.釋放鎖unlock(lockKey);}// 8.返回return r;}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);}
}