對於緩存穿透,雪崩相信不少小夥伴都有聽過,無論是工做中仍是面試都熱點問題,本文重點帶你們分析這些問題,給位看官請往下看!java
同時用XMind畫了一張導圖記錄Redis的學習筆記和一些面試解析(源文件對部分節點有詳細備註和參考資料,歡迎關注個人公衆號:阿風的架構筆記 後臺發送【導圖】拿下載連接, 已經完善更新): 面試
爲了緩解持久層數據庫的壓力,在服務器和存儲層之間添加了一層緩存;redis
一個簡單的正常請求: 當客戶端發起請求時,服務器響應處理,會先從redis緩存層查詢客戶端須要的請求數據,若是緩存層有緩存的數據,會將數據返回給服務器,服務器再返回給客戶端;若是緩存層中沒有客戶端須要的數據,則會去底層存儲層查找,再返回給服務器;算法
緩存穿透就是: 當客戶端想要查詢一個數據,發現redis緩存層中沒有(即緩存沒有命中),因而向持久層數據庫查詢,發現也沒有,因而本次查詢失敗;當用戶不少的時候,緩存都沒有命中,因而都去請求了持久層數據庫,此時會給持久層數據庫形成很大的壓力,這時候就至關於出現了緩存穿透。數據庫
在緩存層加布隆過濾器,通俗簡述一下其做用:將數據庫中的 id ,經過某方式映射到布隆過濾器,當處理不存在的 id 時,布隆過濾器會將該請求過直接過濾出去,不會到數據庫作操做。數組
1)概述: 布隆過濾器是一種數據結構,比較巧妙的機率型數據結構,其實是一個很長的二進制向量和一系列隨機映射函數,特色是高效地插入和查詢,能夠用來告訴你 「某樣東西必定不存在或者可能存在」,相比於傳統的 List、Set、Map 等數據結構,它更高效、佔用空間更少,可是缺點是其返回的結果是機率性的,而不是確切的。緩存
2)返回結果的不確切性: 布隆過濾器是一個 bit 向量或者說 bit 數組:假設有8位服務器
映射數據1: 使用多個不一樣的哈希函數生成多個哈希值,並對每一個生成的哈希值指向的 bit 位置爲 1,好比三次hash完後,data1 將一、三、6位,置爲1;markdown
映射數據2: data2 將二、三、6位,置爲1,此時因爲hash爲隨機性,因此6位和 data1 有重複的,便會覆蓋 data1 的第6位的1;網絡
問題來了!!
6 這個 bit 位因爲兩個值的哈希函數都返回了這個 bit 位,所以它被覆蓋了,當咱們若是想查詢 data3這個值是否存在,假設哈希函數返回了 一、五、6三個值,結果咱們發現 5 這個 bit 位上的值爲 0,說明沒有任何一個值映射到這個 bit 位上,所以咱們能夠很肯定地說 data3 這個值不存在。而當咱們須要查詢 data1 這個值是否存在的話,那麼哈希函數必然會返回 一、三、6,而後咱們檢查發現這三個 bit 位上的值均爲 1,那麼咱們是否能夠說 data1 存在了麼?答案是不能夠,只能是 data1 這個值可能存在!由於隨着增長的值愈來愈多,被置爲 1 的 bit 位也會愈來愈多,這樣某個值 data4 即便沒有被存儲過,可是萬一哈希函數返回的三個 bit 位都被其餘位置位了 1 ,那麼程序仍是會判斷 data4 這個值存在。
因此: 布隆過濾器的長度會直接影響誤報率,布隆過濾器越長且誤報率越小。
3)簡單剖析布隆過濾器源碼
導入guava的包:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>23.0</version>
</dependency>
複製代碼
源碼: BloomFilter一共四個create方法,最終都是走向第四個方法;
public static <T> BloomFilter<T> create(Funnel<? super T> funnel, int expectedInsertions) {
return create(funnel, (long) expectedInsertions);
}
public static <T> BloomFilter<T> create(Funnel<? super T> funnel, long expectedInsertions) {
return create(funnel, expectedInsertions, 0.03); // FYI, for 3%, we always get 5 hash functions
}
public static <T> BloomFilter<T> create( Funnel<? super T> funnel, long expectedInsertions, double fpp) {
return create(funnel, expectedInsertions, fpp, BloomFilterStrategies.MURMUR128_MITZ_64);
}
static <T> BloomFilter<T> create( Funnel<? super T> funnel, long expectedInsertions, double fpp, Strategy strategy) {
......
}
複製代碼
參數類型: funnel:數據類型;expectedInsertions:指望插入的值的個數;fpp:錯誤率(默認值爲0.03);strategy:哈希算法。
總結: 錯誤率越大,所需空間和時間越小;反之錯誤率越小,所需空間和時間越大!
在日常高併發的系統中,大量的請求同時查詢一個key時,此時這個key正好失效了,就會致使大量的請求都打到數據庫上面去。這種現象咱們稱爲緩存擊穿
這種解決方案思路比較簡單,就是隻讓一個線程構建緩存,其餘線程等待構建緩存的線程執行完,從新從緩存獲取數據就能夠了。若是是單機,能夠用synchronized或者lock來處理,若是是分佈式環境能夠用分佈式鎖就能夠了(分佈式鎖,能夠用memcache的add, redis的setnx, zookeeper的添加節點操做)。
在value內部設置1個超時值(timeout1), timeout1比實際的redis timeout(timeout2)小。當從cache讀取到timeout1發現它已通過期時候,立刻延長timeout1並從新設置到cache。而後再從數據庫加載數據並設置到cache中
從redis上看,確實沒有設置過時時間,這就保證了,不會出現熱點key過時問題,也就是「物理」不過時。
從功能上看,若是不過時,那不就成靜態的了嗎?因此咱們把過時時間存在key對應的value裏,若是發現要過時了,經過一個後臺的異步線程進行緩存的構建,也就是「邏輯」過時
class MyCache{
private ConcurrentHashMap<String, String> map;
private CountDownLatch countDownLatch;
private AtomicInteger atomicInteger;
public MyCache(ConcurrentHashMap<String, String> map, CountDownLatch countDownLatch, AtomicInteger atomicInteger) {
this.map = map;
this.countDownLatch = countDownLatch;
this.atomicInteger = atomicInteger;
}
public String get(String key){
String value = map.get(key);
if (value != null){
System.out.println(Thread.currentThread().getName()+"\t 線程獲取value值 value="+value);
return value;
}
// 若是沒獲取到值
// 首先嚐試獲取token,而後去查詢db,初始化化緩存;
// 若是沒有獲取到token,超時等待
if (atomicInteger.compareAndSet(0,1)){
System.out.println(Thread.currentThread().getName()+"\t 線程獲取token");
return null;
}
// 其餘線程超時等待
try {
System.out.println(Thread.currentThread().getName()+"\t 線程沒有獲取token,等待中。。。");
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 初始化緩存成功,等待線程被喚醒
// 等待線程等待超時,自動喚醒
System.out.println(Thread.currentThread().getName()+"\t 線程被喚醒,獲取value ="+map.get("key"));
return map.get(key);
}
public void put(String key, String value){
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
map.put(key, value);
// 更新狀態
atomicInteger.compareAndSet(1, 2);
// 通知其餘線程
countDownLatch.countDown();
System.out.println();
System.out.println(Thread.currentThread().getName()+"\t 線程初始化緩存成功!value ="+map.get("key"));
}
}
class MyThread implements Runnable{
private MyCache myCache;
public MyThread(MyCache myCache) {
this.myCache = myCache;
}
@Override
public void run() {
String value = myCache.get("key");
if (value == null){
myCache.put("key","value");
}
}
}
public class CountDownLatchDemo {
public static void main(String[] args) {
MyCache myCache = new MyCache(new ConcurrentHashMap<>(), new CountDownLatch(1), new AtomicInteger(0));
MyThread myThread = new MyThread(myCache);
ExecutorService executorService = Executors.newFixedThreadPool(5);
for (int i = 0; i < 5; i++) {
executorService.execute(myThread);
}
}
}
複製代碼
緩存擊穿就是單個高熱數據過時的瞬間,數據訪問量較大,未命中redis後,發起了大量對同一數據的數據庫訪問,致使對數據庫服務器形成壓力。應對策略應該在業務數據分析與預防方面進行,配合運行監控測試與即時調整策略,畢竟單個key的過時監控難度較高,配合雪崩處理策略便可。
緩存雪崩是指: 某一時間段,緩存集中過時失效,即緩存層出現了錯誤,不能正常工做了;因而全部的請求都會達到存儲層,存儲層的調用量會暴增,形成 「雪崩」;
**好比:**雙十二臨近12點,搶購商品,此時會設置商品在緩存區,設置過時時間爲1小時,當到了1點時,緩存過時,全部的請求會落到存儲層,此時數據庫可能扛不住壓力,天然 「掛掉」。
這個思想的含義是,既然redis有可能掛掉,那我多增設幾臺redis,這樣一臺掛掉以後其餘的還能夠繼續工做,其實就是搭建的集羣。
這個解決方案的思想是,在緩存失效後,經過加鎖或者隊列來控制讀數據庫寫緩存的線程數量。好比對某個key只容許一個線程查詢數據和寫緩存,其餘線程等待。
數據預熱的含義就是在正式部署以前,我先把可能的數據先預先訪問一遍,這樣部分可能大量訪問的數據就會加載到緩存中,在即將發生大併發訪問前手動觸發加載緩存不一樣的key,設置不一樣的過時時間,讓緩存失效的時間點儘可能均勻。
緩存預熱就是系統上線後,將相關的緩存數據直接加載到緩存系統。這樣就能夠避免在用戶請求的時候,先查詢數據庫,而後再將數據緩存的問題。用戶直接查詢事先被預熱的緩存數據。如圖所示:
若是不進行預熱, 那麼 Redis 初識狀態數據爲空,系統上線初期,對於高併發的流量,都會訪問到數據庫中, 對數據庫形成流量的壓力。
前置準備工做:
平常例行統計數據訪問記錄,統計訪問頻度較高的熱點數據
利用LRU數據刪除策略,構建數據留存隊列
例如:storm與kafka配合
複製代碼
準備工做:
實施:
緩存預熱就是系統啓動前,提早將相關的緩存數據直接加載到緩存系統。避免在用戶請求的時候,先查詢數據庫,而後再將數據緩存的問題!用戶直接查詢事先被預熱的緩存數據
降級的狀況,就是緩存失效或者緩存服務掛掉的狀況下,咱們也不去訪問數據庫。咱們直接訪問內存部分數據緩存或者直接返回默認數據。
舉例來講:
對於應用的首頁,通常是訪問量很是大的地方,首頁裏面每每包含了部分推薦商品的展現信息。這些推薦商品都會放到緩存中進行存儲,同時咱們爲了不緩存的異常狀況,對熱點商品數據也存儲到了內存中。同時內存中還保留了一些默認的商品信息。以下圖所示:
降級通常是有損的操做,因此儘可能減小降級對於業務的影響程度。
若是你以爲這篇內容對你還蠻有幫助,我想邀請你幫我三個小忙: