Redis: 緩存過期、緩存雪崩、緩存穿透、緩存擊穿(熱點)、緩存併發(熱點)、多級緩存、布隆過濾器

1.緩存過期

緩存過期:在使用緩存時,可以通過TTL(Time To Live)設置失效時間,當TTL爲0時,緩存失效。

爲什麼要設置緩存的過期時間呢?

一、爲了節省內存

例如,在緩存中存放了近3年的10億條博文數據,但是經常被訪問的可能只有10萬條,其他的可能幾個月才訪問一次。

那麼,就沒有必要讓所有的博文數據長期存在於緩存中。

設置一個過期時間比方說7天,超過7天未被訪問的博文數據將會自動失效,如此節省大量內存。

二、時效性信息

有些信息具有時效性,設置過期時間非常合適。例如:遊戲中的發言間隔爲10秒鐘,可以通過緩存實現。

三、用於分佈式鎖

參考博客:Redis: 分佈式鎖的官方算法RedLock以及Java版本實現庫Redisson

四、其他需求

2.緩存雪崩

緩存雪崩:某一時間段內,緩存服務器掛掉,或者大量緩存失效,導致大量請求直接訪問數據庫,給數據庫造成極大壓力,甚至宕機,嚴重時引起整個系統的崩潰。

在這裏插入圖片描述

解決辦法:

  1. 數據庫訪問加鎖
  2. 隨機過期時間
  3. 定時刷新緩存
  4. 緩存刷新標記
  5. 多級緩存

2.1.數據庫訪問加鎖

因爲短時間內大量請求訪問數據庫,導致後續影響,那麼限制數據庫的訪問量不就行了嗎?

限制數據庫訪問量的方法有很多,對數據庫的訪問進行加鎖就是一種最直接的方式。

下面分別給出的僞代碼:

    /**
     * 用於加鎖的對象
     */
    private static final byte[] LOCK_OBJ = new byte[0];

    /**
     * 獲取商品信息
     */
    public String getGoodsByLock(String key) {
        //獲取緩存值
        String value = RedisService.get(key);

        // 如果緩存有值,就直接取出來即可
        if (value != null) {
            return value;
        } else {
            //對數據庫的訪問進行加鎖限制
            synchronized (LOCK_OBJ) {
                value = RedisService.get(key);
                if (value != null) {
                    return value;
                } else {
                    //訪問數據庫
                    value = MySqlService.select(key);
                    //緩存刷新
                    RedisService.set(key, value, 10);
                }
            }
            return value;
        }
    }

分析:加鎖會產生線程阻塞,導致用戶長時間進行等待,體驗不好,只適合併發量小的場景。

2.2.隨機過期時間

緩存雪崩的主要原因是,短時間內大量緩存失效造成的,那麼避免大量緩存同時失效不就行了嗎?

避免大量緩存失效的最直接方法就是給緩存設置不同的過期時間。例如,原定失效時間30分鐘,修改爲失效時間在30~35分鐘之內隨機。

下面給出一種獲取隨機失效時間的簡單實現作爲參考:

    /**
     * 獲取隨機失效時間
     *
     * @param originExpire 原定失效時間
     * @param randomScope  最大隨機範圍
     * @return 隨機失效時間
     */
    public static Long getRandomExpire(Long originExpire, Long randomScope) {
        return originExpire + RandomUtils.nextLong(0, randomScope);
    }

**分析:**隨機過期時間,雖然實現簡單,但是並不能完全避免大量緩存的同時過期。

例如:大量緩存的過期時間設置在30~35分鐘,但是無論如何隨機,這些緩存經過40分鐘後,都會過期。

造成如此結果的原因可能有很多,例如:過期時間設計不合理等。

2.3.定時刷新緩存

避免大量緩存失效的另一種策略就是:開發額外的服務,定時刷新緩存。

這樣做,雖然能夠保證緩存的失效,但是有個弊端:緩存可能多種多樣,每種緩存都需要開發對應的定時刷新服務,相當麻煩。

2.4.緩存刷新標記

緩存失效標記,其實也是一種緩存刷新策略,只不過它更加通用化,無需針對每種緩存進行定製開發。

**思路:**不僅存儲緩存數據,而且存儲是否需要刷新的標記。

緩存刷新標記

  • 標記數據是否應該被刷新,如果存在則表示數據無需刷新,反之則表示需要刷新。
  • 緩存刷新標記的過期時間要比緩存本身的過期時間要短,這樣才能起到提前刷新的效果。可以設置爲1:2,或者1:1.5

下面給出僞代碼:

    /**
     * 線程池:用於異步刷新緩存
     */
    private static ExecutorService executorService = Executors.newCachedThreadPool();
    /**
     * 緩存刷新標記後綴
     */
    public static final String REFRESH_SUFFIX = "_r";

    /**
     * 獲取緩存刷新標記的key
     */
    public String getRefreshKey(String key) {
        return key + REFRESH_SUFFIX;
    }

    /**
     * 判斷無需刷新: 刷新標記存在,則表示不需要刷新
     */
    public boolean notNeedRefresh(String key) {
        return RedisService.containsKey(key + REFRESH_SUFFIX);
    }

    /**
     * 獲取商品信息
     */
    public String getGoods(String key) {
        //獲取緩存值
        String value = RedisService.get(key);

        //過期時間
        Long expire = 10L;

        //如果無需刷新,則直接返回緩存值
        if (notNeedRefresh(key)) {
            //理論上:如果緩存刷新標記存在,則緩存必存在,所以可以直接返回
            return value;
        } else {
            //如果需要刷新,則重置緩存刷新標記的過期時間
            RedisService.set(getRefreshKey(key), "1", expire / 2);

            //如果緩存有值,就直接返回即可
            if (value != null) {
                //因爲有值,所以可以異步刷新緩存
                executorService.submit(() -> {
                    //訪問數據庫
                    String newValue = MySqlService.select(key);
                    //緩存刷新
                    RedisService.set(key, newValue, expire);
                });

                return value;
            } else {
                //因爲無值,所以還是要同步刷新緩存
                value = MySqlService.select(key);
                //緩存刷新
                RedisService.set(key, value, expire);

                return value;
            }
        }
    }

分析:刷新標記本身也存在大量失效的可能。

2.5.多級緩存

所謂多級緩存,就是設置多個層級的緩存。

在這裏插入圖片描述

例如:

  • 本地緩存 + 分佈式緩存構成二級緩存,本地緩存作爲第一級緩存,分佈式緩存作爲第二級緩存。
  • 本地緩存可以通過多種技術實現,如:Ehcache、Caffeine等。
  • 分佈式緩存一般採用Redis實現。
  • 由於本地緩存會佔用JVM的heap空間,所以本地緩存中存放少量關鍵信息,其他的緩存信息存放在分佈式緩存中。

下面是一個二級緩存示例的僞代碼:

    /**
     * 是否使用一級緩存
     */
    @Setter
    private boolean useFirstCache;

    /**
     * 查詢商品信息
     */
    public String getGoods(String key) {
        String value;

        //如果使用一級緩存,則首先從一級緩存中獲取數據
        if (useFirstCache) {
            value = LocalCacheService.get(key);
            if (value != null) {
                return value;
            }
        }

        //如果一級緩存中無值,則查詢二級緩存
        value = RedisCacheService.get(key);
        if (value != null) {
            return value;
        } else {
            //如果二級緩存中也無值,則查詢數據庫
            value = MySqlService.select(key);
            //緩存刷新
            RedisCacheService.set(key, value, 10);
            return value;
        }
    }

3.緩存穿透

緩存穿透:大量請求查詢本就不存在的數據,由於這些數據在緩存中肯定不存在,所以會直接繞過緩存,直接訪問數據庫,給數據庫造成極大壓力,甚至宕機,嚴重時引起整個系統的崩潰。

舉例:有些黑客惡意攻擊網站,製造大量請求訪問不存在的緩存,直接搞垮網站。

解決辦法:

  1. 空值緩存
  2. 布隆過濾器

3.1.空值緩存

空值緩存:查詢數據庫爲空時,仍然把設置成一種默認值進行緩存,這樣後續請求繼續請求這個key時,知道值不存在就不會去數據庫查詢了。

下面給出示例僞代碼:

    /**
     * 緩存空值
     */
    public static final String NULL_CACHE = "_";

    /**
     * 獲取商品信息
     */
    public String getGoodsByLock(String key) {
        //獲取緩存值
        String value = RedisCacheService.get(key);

        //如果緩存有值
        if (value != null) {
            //如果緩存的是空值,則直接返回空,無需查詢數據庫
            if (NULL_CACHE.equals(value)) {
                return null;
            } else {
                return value;
            }
        } else {
            //訪問數據庫
            value = MySqlService.select(key);
            //如果數據庫有值,則直接返回
            if (value != null) {
                //緩存刷新
                RedisCacheService.set(key, value, 10);
                return value;
            } else {
                //如果數據庫無值,則設置空值緩存
                RedisCacheService.set(key, NULL_CACHE, 5);
                return null;
            }
        }
    }

缺點

  • 有可能設置空值緩存之後數據又有值了,這時如果無正確的刷新策略,會導致數據不一致,所以空值失效時間不要設置太長,例如5分鐘即可。
  • 空值緩存雖然能夠避免緩存穿透,但是如果存在大量請求不存在,則會儲存大量空值緩存,消耗較多內存。

3.2.布隆過濾器

什麼是布隆過濾器?

布隆過濾器(Bloom Filter)是1970年由布隆提出的。它實際上是一個很長的bit數組和一系列隨機映射函數。布隆過濾器可以用於檢索一個元素是否在一個集合中。它的優點是空間效率和查詢時間都比一般的算法要好的多,缺點是有一定的誤識別率和刪除困難。

簡單理解布隆過濾器

  1. 首先,我們定義一個bit數組,每個元素只佔1byte。

在這裏插入圖片描述

  1. 然後,在存放每個元素時,分表對其進行若干次(例如3次)哈希函數計算,將每個哈希結果對應的bit數組元素置爲1。

  2. 最後,判斷一個元素是否在bit數組中,只需對其同樣進行若干次(例如3次)哈希函數計算,如果計算結果對應的bit數組元素都爲1,則可以判斷:這個元素可能存在與bit數組中;如果有任一個哈希結果對應的元素不爲1,則可以判斷:這個元素必定不存在於bit數組中。

    在這裏插入圖片描述

關於布隆過濾器的實現有多種,常用的有guava包和redis。

guava版本的布隆過濾器

這裏給出guava版本布隆過濾器的簡單使用:

        //定義布隆過濾器的期望填充數量
        Integer expectedInsertions = 100;
        //定義布隆過濾器:默認情況下,使用5個哈希函數已保證3%的誤差率。
        BloomFilter<Long> userIdFilter = BloomFilter.create(Funnels.longFunnel(),expectedInsertions);

        //填充布隆過濾器
        //獲取全部用戶ID List<Long> idList = MySqlService.getAllId();
        List<Long> idList = Lists.newArrayList(521L,1314L,9527L,3721L);
        if (CollectionUtils.isNotEmpty(idList)){
            idList.forEach(userIdFilter::put);
        }

        //通過布隆過濾器判斷數據是否存在
        log.info("521是否存在:{}",userIdFilter.mightContain(521L));
        log.info("125是否存在:{}",userIdFilter.mightContain(125L));

運行結果:

 INFO traceId: pers.hanchao.basiccodeguideline.redis.bloom.BloomFilterDemo:33 - 521是否存在:true 
 INFO traceId: pers.hanchao.basiccodeguideline.redis.bloom.BloomFilterDemo:34 - 125是否存在:false 

**缺點:**是一種本地布隆過濾器,基於JVM內存,會佔用heap空間,重啓失效,不適用與分佈式場景,不適用與大批量數據。

Redis版本的布隆過濾器

基於Redis的布隆過濾器實現,目前本人也並未深入瞭解,這裏暫時就不班門弄斧了,各位可自行了解。

4.緩存熱點併發

緩存熱點併發: 大量請求查詢一個熱點Key,此key過期的瞬間來不及更新,導致大量請求直接訪問數據庫,給數據庫造成極大壓力,甚至宕機,嚴重時引起整個系統的崩潰。

在這裏插入圖片描述

解決辦法:

  1. 緩存重建加鎖
  2. 熱點key不過期:重建緩存期間,數據不一致。
  3. 多級緩存。

4.1.緩存重建加鎖

章節2.1.數據庫訪問加鎖的思路類似,僞代碼如下:

    /**
     * 用於加鎖的對象
     */
    private static final byte[] LOCK_OBJ = new byte[0];

    /**
     * 通過某種手段(如配置中心等)判斷一個值是熱點key。這裏爲了示例直接硬編碼
     */
    private Set<String> hotKeySet = Sets.newHashSet("521", "1314");

    /**
     * 獲取商品信息
     */
    public String getGoodsByLock(String key) {
        //獲取緩存值
        String value = RedisCacheService.get(key);

        // 如果緩存有值,就直接取出來即可
        if (value != null) {
            return value;
        } else {
            //如果是熱點key,則對緩存重建過程進行加鎖
            if (hotKeySet.contains(key)) {
                //對緩存重建過程進行加鎖限制
                synchronized (LOCK_OBJ) {
                    value = RedisCacheService.get(key);
                    if (value != null) {
                        return value;
                    } else {
                        //訪問數據庫
                        value = MySqlService.select(key);
                        //緩存刷新
                        RedisCacheService.set(key, value, 10);
                    }
                }
            } else {
                //如果是普通Key,無需對緩存重建加鎖
                value = MySqlService.select(key);
                //緩存刷新
                RedisCacheService.set(key, value, 10);
            }

            return value;
        }
    }

雖然兩者的代碼類似,但是出發點不一樣兩者的不同:

  • 數據庫訪問加鎖:針對的是所有的緩存。
  • 緩存重建加鎖:針對的是熱點Key。

同樣的,加鎖會產生線程阻塞,導致用戶長時間進行等待,體驗不好,只適合併發量小的場景。

4.2.熱點key不過期

熱點Key不過期很好理解,就是通過某種手段(查庫、配置中心等等)確定某個key是熱點key,則在建立緩存時,不設置過期時間。

這種方式雖然從根本上杜絕了失效的可能,但是也有其不足之處:

  • 就算緩存不過期,也會因數據變化而進行緩存重建,緩存重構期間,可能會產生數據不一致的問題。

4.3.多級緩存

參考:章節2.5.多級緩存

關注點:將熱點Key存放在一級緩存。

5.緩存擊穿

緩存擊穿:大量請求查詢一個熱點Key,由於一個Key在分佈式緩存中的節點是固定的,所以這個節點短時間內承受極大壓力,可能會掛掉,引起整個緩存集羣的掛掉,導致大量請求直接訪問數據庫,給數據庫造成極大壓力,甚至宕機,嚴重時引起整個系統的崩潰。

**舉例:**現實生活中發生的一些重大新聞,會導致大量用戶訪問微博,導致微博直接掛掉。這些新聞可能就是緩存中的幾條數據。

解決辦法:

  1. 多讀多寫
  2. 多級緩存

5.1.多讀多寫

多讀多寫:關鍵在於把全部流向一個緩存節點的壓力進行分擔。

實施簡述:

  • 確定存在一個key爲熱點key。
  • 分佈式緩存的節點數爲N。
  • 通過某種算法將這個key轉換成一組key:key1,key2…keyN,並且確保這些keyi分表落到不同的緩存node上。
  • 當請求訪問這個key時,通過輪訓或者隨機的方式,訪問keyi即可獲取value值。

在這裏插入圖片描述

缺點

  • 需要提供合適的算法保證拆分後的key落在不同的緩存節點上。
  • 如果緩存節點數量發生了變化,原有算法是否繼續可用?
  • 如果緩存內容發送變化,如何保證所有keyi的強一致性?
  • 整體來說,這個方案過重

5.2.多級緩存

參考:章節2.5.多級緩存

關注點:由於服務節點存在多個,本地緩存能夠做到分佈式緩存不易做到的事情:通過負載均衡,分散熱點key的壓力。

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章