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;
}
}
分析:加鎖會產生線程阻塞,導致用戶長時間進行等待,體驗不好,只適合併發量小的場景。
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.緩存穿透
緩存穿透:大量請求查詢本就不存在的數據,由於這些數據在緩存中肯定不存在,所以會直接繞過緩存,直接訪問數據庫,給數據庫造成極大壓力,甚至宕機,嚴重時引起整個系統的崩潰。
舉例:有些黑客惡意攻擊網站,製造大量請求訪問不存在的緩存,直接搞垮網站。
解決辦法:
- 空值緩存
- 布隆過濾器
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數組和一系列隨機映射函數。布隆過濾器可以用於檢索一個元素是否在一個集合中。它的優點是空間效率和查詢時間都比一般的算法要好的多,缺點是有一定的誤識別率和刪除困難。
簡單理解布隆過濾器
- 首先,我們定義一個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));
運行結果:
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過期的瞬間來不及更新,導致大量請求直接訪問數據庫,給數據庫造成極大壓力,甚至宕機,嚴重時引起整個系統的崩潰。
解決辦法:
- 緩存重建加鎖
- 熱點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;
}
}
雖然兩者的代碼類似,但是出發點不一樣兩者的不同:
數據庫訪問加鎖
:針對的是所有的緩存。緩存重建加鎖
:針對的是熱點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的壓力。