Redis: 緩存過期、緩存雪崩、緩存穿透、緩存擊穿(熱點)、緩存並發(熱點)、多級緩存、布隆過濾器
1.緩存過期
緩存過期:在使用緩存時,可以通過TTL(Time To Live)設置失效時間,當TTL為0時,緩存失效。
為什么要設置緩存的過期時間呢?
一、為了節省內存
例如,在緩存中存放了近3年的10億條博文數據,但是經常被訪問的可能只有10萬條,其他的可能幾個月才訪問一次。
那么,就沒有必要讓所有的博文數據長期存在於緩存中。
設置一個過期時間比方說7天,超過7天未被訪問的博文數據將會自動失效,如此節省大量內存。
二、時效性信息
有些信息具有時效性,設置過期時間非常合適。例如:游戲中的發言間隔為10秒鍾,可以通過緩存實現。
三、用於分布式鎖
參考博客:Redis: 分布式鎖的官方算法RedLock以及Java版本實現庫Redisson
四、其他需求
2.緩存雪崩
緩存雪崩:某一時間段內,緩存服務器掛掉,或者大量緩存失效,導致大量請求直接訪問數據庫,給數據庫造成極大壓力,甚至宕機,嚴重時引起整個系統的崩潰。

解決辦法:
- 數據庫訪問加鎖
- 隨機過期時間
- 定時刷新緩存
- 緩存刷新標記
- 多級緩存
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.緩存穿透
緩存穿透:大量請求查詢本就不存在的數據,由於這些數據在緩存中肯定不存在,所以會直接繞過緩存,直接訪問數據庫,給數據庫造成極大壓力,甚至宕機,嚴重時引起整個系統的崩潰。
舉例:有些黑客惡意攻擊網站,制造大量請求訪問不存在的緩存,直接搞垮網站。
解決辦法:
- 空值緩存
- 布隆過濾器
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數組和一系列隨機映射函數。布隆過濾器可以用於檢索一個元素是否在一個集合中。它的優點是空間效率和查詢時間都比一般的算法要好的多,缺點是有一定的誤識別率和刪除困難。
簡單理解布隆過濾器
- 首先,我們定義一個bit數組,每個元素只占1byte。

-
然后,在存放每個元素時,分表對其進行若干次(例如3次)哈希函數計算,將每個哈希結果對應的bit數組元素置為1。
-
最后,判斷一個元素是否在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過期的瞬間來不及更新,導致大量請求直接訪問數據庫,給數據庫造成極大壓力,甚至宕機,嚴重時引起整個系統的崩潰。

解決辦法:
- 緩存重建加鎖
- 熱點key不過期:重建緩存期間,數據不一致。
- 多級緩存。
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在分布式緩存中的節點是固定的,所以這個節點短時間內承受極大壓力,可能會掛掉,引起整個緩存集群的掛掉,導致大量請求直接訪問數據庫,給數據庫造成極大壓力,甚至宕機,嚴重時引起整個系統的崩潰。
**舉例:**現實生活中發生的一些重大新聞,會導致大量用戶訪問微博,導致微博直接掛掉。這些新聞可能就是緩存中的幾條數據。
解決辦法:
- 多讀多寫
- 多級緩存
5.1.多讀多寫
多讀多寫:關鍵在於把全部流向一個緩存節點的壓力進行分擔。
實施簡述:
- 確定存在一個key為熱點key。
- 分布式緩存的節點數為N。
- 通過某種算法將這個key轉換成一組key:key1,key2…keyN,並且確保這些keyi分表落到不同的緩存node上。
- 當請求訪問這個key時,通過輪訓或者隨機的方式,訪問keyi即可獲取value值。

缺點
- 需要提供合適的算法保證拆分后的key落在不同的緩存節點上。
- 如果緩存節點數量發生了變化,原有算法是否繼續可用?
- 如果緩存內容發送變化,如何保證所有keyi的強一致性?
- 整體來說,這個方案
過重
。
5.2.多級緩存
參考:章節2.5.多級緩存
。
關注點:由於服務節點存在多個,本地緩存能夠做到分布式緩存不易做到的事情:通過負載均衡,分散熱點key的壓力。
