緩存介紹與設計

閃念基因 發佈 2023-02-06T14:04:16.739999+00:00

一、為什麼要使用緩存緩存分為本地緩存和分布式緩存。以 Java 為例,使用自帶的 map 或者 guava 實現的是本地緩存,主要的特點是輕量以及快速,生命周期隨著 jvm 的銷毀而結束,並且在多實例的情況下,每個實例都需要各自保存一份緩存,緩存不具有一致性。

一、為什麼要使用緩存


緩存分為本地緩存和分布式緩存。以 java 為例,使用自帶的 map 或者 guava 實現的是本地緩存,主要的特點是輕量以及快速,生命周期隨著 jvm 的銷毀而結束,並且在多實例的情況下,每個實例都需要各自保存一份緩存,緩存不具有一致性。

使用 redis 或 memcached 之類的稱為分布式緩存,在多實例的情況下,各實例共用一份緩存數據,緩存具有一致性。缺點是需要保持 redis 或 memcached服務的高可用,整個程序架構上較為複雜。

二、緩存分類


1:本地緩存

只有當前實例自身可以使用,當前大多數場景為集群部署,本地緩存不能做到集群內共享,同時本地緩存使用的是當前實例的內存。以java 為例,本地緩存有自帶的Map ,Guava,Caffeine等。

本地緩存對比:

1、使用Map 或者 ConcurrentHashMap 需要自己設計和編寫緩存淘汰策略。

2、Guava為了解決線程安全問題, 核心的數據結構就是按照 ConcurrentHashMap 來的,但是他幫我實現了緩存淘汰策略,監控緩存加載/命中情況。

3、Caffeine支持異步加載方式,直接返回CompletableFutures,相對於GuavaCache的同步方式,它不用阻塞等待數據的載入。GuavaCache是基於LRU的,而Caffeine是基於LRU和LFU的

總結

Guava要比使用java原生容器做本地緩存要好,但是Caffeine又再Guava的基礎上進行了升級,所以Caffeine目前是作為本地緩存的最佳框架。同時Caffeine也是Spring 5默認支持的cache。

2:分布式緩存

集群場景內相關服務都可以使用。不占用程序內存。如redis,memcached

Redis 與 Memcached 主要有以下不同:

redis作為緩存的一些建議

1:避免使用大key(value 超過10K)

2:避免同一時間大量緩存時效(1. 大量key 設置統一時效時間,2.同一時間主動觸發大量key時效操作)

3:所有的緩存類信息(數據源存儲在DB,都需要有防止緩存穿透的邏輯)

4:避免使用key更新操作。

5:有批量查詢操作,使用Mget/pipeline等操作,同時不要在pipeline操作中有一些其他的計算操作等(如rpc)

高可用(redis)

redis 的 高可用主要有 哨兵模式和集群模式。

哨兵模式

哨兵模式是一主多從的模式,做讀寫分離的話,可以提高redis讀的速度,但是他不能提高redis寫的能力,所以他不能支持更高的並發。當哨兵發現master 宕機之後,會重新在從服務中選舉出一個新的節點作為master。在這個選舉的過程中整個redis服務是不可用的,知道選舉結束。

redis cluster 集群模式

redis官方推薦的集群方式,多主多從。這種集群模式沒有中心節點,客戶端通過CRC16算法對key進行hash得到一個值,以此來判斷這個key應該存放在集群中的哪個主節點上。即便是某個節點宕機,在其選舉的過程中,redis服務的其他節點也是可用的。同時根據官方文檔介紹,他可以線性擴展到上萬個節點(但是官網不推薦超過1000個節點)。所以該方案總體是要優於哨兵模式的。

三、緩存的讀取


現在一個比較好的實踐方案,就是Cache Aside Pattern。
先來看一下數據的讀取過程,規則是:先讀cache,再讀db 。詳細步驟如下:
每次讀取數據,都從cache里讀
如果讀到了,則直接返回,稱作 cache hit
如果讀不到cache的數據,則從db裡面撈一份,稱作cache miss
將讀取到的數據,塞入到緩存中,下次讀取的時候,就可以直接命中

四、緩存使用的常見問題


1:資料庫數據和緩存數據不一致問題

資料庫的瓶頸是大家有目共睹的,高並發的環境下,很容易I/O鎖死。當務之急,就是把常用的數據,給撈到速度更快的存儲里去。這個更快的存儲,就有可能是分布式的,比如Redis,也有可能是單機的,比如Caffeine。
但一旦加入緩存,就可能會涉及到緩存與資料庫雙存儲雙寫,你只要是雙寫,就一定會有數據一致性的問題。使用過java多線程的,肯定會對JMM的模型記憶猶新。一個數值,只要同時在兩個地方存儲,那就會產生問題。

解決方案

1-1:db更新,則刪除緩存

1-1-1:為什麼是刪除,而不是更新?

原因很簡單,很多時候,在複雜點的緩存場景,緩存不單單是資料庫中直接取出來的值。比如可能更新了某個表的一個欄位,然後其對應的緩存,是需要查詢另外兩個表的數據並進行運算,才能計算出緩存最新的值的。如果此時及時更新緩存的話,為了保證一致性,必定會使用一個比較大的失誤包裹。另外更新緩存的代價有時候是很高的。是不是說,每次修改資料庫的時候,都一定要將其對應的緩存更新一份?也許有的場景是這樣,但是對於比較複雜的緩存數據計算的場景,就不是這樣了。
如果你頻繁修改一個緩存涉及的多個表,緩存也頻繁更新。但是問題在於,這個緩存到底會不會被頻繁訪問到?像 mybatis,hibernate,都是有懶加載思想的。

1-1-2:為什麼是先更新db再刪除緩存

先更新緩存,再更新db,db更新失敗的話,db會回滾,而redis不能回滾,會造成緩存和db數據不一致。

1-2:使用第三方插件監控資料庫bin log

當允許短暫的數據不一致情況,可以使用第三方插件監聽db log的方案(databus和canal),同時他也可以在依賴服務的db發生變更時,更新響應緩存(eg:當一個系統依賴權限系統的時候,權限變更)

2緩存穿透

第二個問題是緩存穿透。

產生這個問題的原因可能是外部的惡意攻擊,例如,對用戶信息進行了緩存,但惡意攻擊者使用不存在的用戶 ID 頻繁請求接口,導致查詢緩存不命中,然後穿透 DB 查詢依然不命中。這時會有大量請求穿透緩存訪問到 DB。

解決的辦法:

2-1:緩存空值

對不存在的用戶,在緩存中保存一個空對象進行標記,防止相同 ID 再次訪問 DB,不過有時這個方法並不能很好解決問題,可能導致緩存中存儲大量無用數據。

2-2:使用布隆過濾器

使用 BloomFilter 過濾器,BloomFilter 的特點是存在性檢測,如果 BloomFilter 中不存在,那麼數據一定不存在;如果 BloomFilter 中存在,實際數據也有可能會不存在。非常適合解決這類的問題。

3:緩存擊穿

第三個問題是緩存擊穿,就是某個熱點數據失效時,大量針對這個數據的請求會穿透到數據源。

解決方案

1:後台主動刷新

既然問題出現在某個key失效的問題,那我們只要不讓這個key失效就行了,可以後台起一個Cron任務,去主動更新key的過期時間。
比如,一個key是30分鐘過期,可以讓cron每29分鐘執行一次,更改它的過期時間。

2:檢查時更新

在獲取緩存值時,可以再請求獲取後,修改對應的過期時間。
將緩存key的過期時間一起保存在數值中,在get操作後,將過期時間與當前時間進行對比,塊過期時,修改對應的時間欄位。

3:多級緩存

可以使用多套緩存實例保存緩存值。
比如可以採用一級緩存、二級緩存機制,一級失效時間設置短些,二級長些,這樣訪問一級不存在時,則訪問二級。

4:加鎖限制訪問

此方案為緩存失效後,使用互斥鎖保護對資料庫的頻繁操作,是第一個失效請求到資料庫後,設置緩存值,後續的直接命中緩存,從而保護資料庫。

public User queryById(int id) throws InterruptedException {   
                User user= (User) redisTemplate.opsForValue().get(id+"");
    if (null==user)
    {
        //排隊拿到鎖,請求資料庫
        if (tryLock(id+""))
        {
            try {
                System.out.println(Thread.currentThread().getName()+"拿到鎖請求資料庫--》");
                user=deptDao.queryById(id);
                if (user==null)
                {
                    //防止緩存穿透 設置空對象
                    redisTemplate.opsForValue().set(id+"",new User(),30, TimeUnit.MINUTES);
                }else {
                    redisTemplate.opsForValue().set(id+"",user);
                }
 
            }
            finally {
                unlock(id+"");
            }
 
        }else{
             user =(User)redisTemplate.opsForValue().get(id+"");
             if (null==user)
             {
                 System.out.println(Thread.currentThread().getName()+"等待--》");
                 Thread.sleep(100);
                 return queryById(id);
             }
        }
    }

4緩存雪崩

第四個問題是緩存雪崩。產生的原因是緩存掛掉,這時所有的請求都會穿透到 DB。

解決方法

1:發生之前

緩存失效時間添加隨機值,避免同一時間大片緩存同時失效

2 :在發生前,做好緩存的高可用

比如是使用 Redis,可以使用 主從+哨兵 ,Redis Cluster 來避免 Redis 全盤崩潰的情況

3:事中

本地緩存 :Caffeine
限流&熔斷機制:常見的Hystrix等技術。

4:事後

開啟持久化配置,更快的恢復線上數據。

五、集群下本地緩存更新

服務集群部署多實例的情況下,可以使用redis隊列或者藉助第三方消息隊列來實現集群下所有實例的本地緩存統一修改。如下:

六、熱key

1:什麼是熱key

MySQL等資料庫會被頻繁訪問的熱數據

redis的被密集訪問的key

2:熱key解決方案

熱key問題歸根到底就是如何找到熱key,並將熱key放到jvm內存的問題。只要該key在內存里,我們就能極快地來對它做邏輯,內存訪問和redis訪問的速度不在一個量級,流程如下:

但是如何保證本地緩存存儲的都是熱key,如何提高他們的命中率,也是我們需要考慮的問題。

我們需要一個可以高效率的能夠準確探測出熱key的工具,這個工具可以我們自己開發,也可以使用一些開源框架。

熱key探測的關鍵性指標如下:

1:實時性

key往往是突發性瞬間就熱了,根本不給你再慢悠悠手工去配置中心添加熱key再推送到jvm的機會。它大部分時間不可預知,來得也非常迅速。如果短時間內沒能進到內存,就有redis集群被打爆的風險。所以熱key探測框架最重要的就是實時性,最好是某個key剛有熱的苗頭,在1秒內它就已經進到整個服務集群的內存里了,1秒後就不會再去密集訪問redis了。

2:準確性

這個很重要,也容易實現,累加數量,做到不誤探,精準探測,保證探測出的熱key是完全符合用戶自己設定的閾值。

3:集群 一致性

這個比較重要,尤其是某些帶刪除key的場景,要能做到刪key時整個集群內的該key都會刪掉,以避免數據的錯誤。

3:JdHotKey(熱key探測組件)

JdHotKey是京東開源的一款毫秒級熱key探測框架,用於快速探測項目中的熱key,已經實戰與618 雙十一等大促銷場景,此框架可以達到8核8G單機16W+的qps,16核機器每秒可達30萬以上探測量。它有很強的實時性,默認情況下,500ms(可自行配置)即可探測出待測key是否熱key,是熱key它就會進到jvm內存中。

七、 總結


使用緩存會增加系統複雜性,應根據實際業務考慮是否需要使用以及使用分布式緩存還是本地緩存,設置合理的緩存策略可以提高系統的性能,同時要合理的規避可能由於引入緩存而增加的風險。

作者:王騰蛟

來源:微信公眾號:新東方技術

出處:https://mp.weixin.qq.com/s/SJoVDcBuZD9ks58CnZfExA

關鍵字: