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


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

版權聲明:本文為博主原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接和本聲明。
本文鏈接: https://blog.csdn.net/hanchao5272/article/details/99706189

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; } } 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

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

2.2.隨機過期時間

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

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

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

    /** * 獲取隨機失效時間 * * @param originExpire 原定失效時間 * @param randomScope 最大隨機范圍 * @return 隨機失效時間 */ public static Long getRandomExpire(Long originExpire, Long randomScope) { return originExpire + RandomUtils.nextLong(0, randomScope); } 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

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

例如:大量緩存的過期時間設置在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; } } } 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62

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

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; } } 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32

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; } } } 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35

缺點:

  • 有可能設置空值緩存之后數據又有值了,這時如果無正確的刷新策略,會導致數據不一致,所以空值失效時間不要設置太長,例如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)); 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

運行結果:

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

**缺點:**是一種本地布隆過濾器,基於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; } } 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45

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

  • 數據庫訪問加鎖:針對的是所有的緩存。
  • 緩存重建加鎖:針對的是熱點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的壓力。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM