【Redis】快取穿透,快取擊穿,快取雪崩及解決方案

A minor發表於2020-11-08

1.快取穿透

快取穿透是指查詢一個根本不存在的資料, 快取層和儲存層都不會命中, 通常出於容錯的考慮, 如果從儲存層查不到資料則不寫入快取層。

快取穿透將導致不存在的資料每次請求都要到儲存層去查詢, 失去了快取保護後端儲存的意義。 造成快取穿透的基本原因有兩個:

  1. 自身業務程式碼或者資料出現問題。
  2. 一些惡意攻擊、 爬蟲等造成大量空命中。

快取穿透問題解決方案:

方案一:快取空物件

String get(String key) { 
    // 從快取中獲取資料 
    String cacheValue = cache.get(key); 
    // 快取為空 
    if (StringUtils.isBlank(cacheValue)) { 
        // 從儲存中獲取 
        String storageValue = storage.get(key); 
        cache.set(key, storageValue); 
        // 如果儲存資料為空, 需要設定一個過期時間(300秒)
        if (storageValue == null) { 
            cache.expire(key, 60 * 5); 1
        } 
        return storageValue; 
    } else { 
        // 快取非空 
        return cacheValue; 
    } 
}

方案二:布隆過濾器

對於惡意攻擊,向伺服器請求大量不存在的資料造成的快取穿透,還可以用布隆過濾器先做一次過濾,對於不存在的資料布隆過濾器一般都能夠過濾掉,不讓請求再往後端傳送。當布隆過濾器說某個值存在時,這個值可能不存在;當它說不存在時,那就肯定不存在。

在這裡插入圖片描述

布隆過濾器就是一個大型的位陣列和幾個不一樣的無偏 hash 函式。所謂無偏就是能夠把元素的 hash 值算得比較均勻。

向布隆過濾器中新增 key 時,會使用多個 hash 函式對 key 進行 hash 算得一個整數索引值然後對位陣列長度進行取模運算得到一個位置,每個 hash 函式都會算得一個不同的位置。再把位陣列的這幾個位置都置為 1 就 完成了 add 操作。

向布隆過濾器詢問 key 是否存在時,跟 add 一樣,也會把 hash 的幾個位置都算出來,看看位陣列中這幾個位 置是否都為 1,只要有一個位為 0,那麼說明布隆過濾器中這個key 不存在。

如果都是 1,這並不能說明這個 key 就一定存在,只是極有可能存在,因為這些位被置為 1 可能是因為其它的 key 存在所致。如果這個位陣列比較稀疏,這個概率就會很大,如果這個位陣列比較擁擠,這個概率就會降低。

這種方法適用於資料命中不高、 資料相對固定、 實時性低(通常是資料集較大) 的應用場景, 程式碼維護較為複雜, 但是快取空間佔用很少。

可以用guava包自帶的布隆過濾器,引入依賴:

<dependency> 
    <groupId>com.google.guava</groupId> 
    <artifactId>guava</artifactId> 
    <version>22.0</version> 
</dependency> 

示例虛擬碼:

import com.google.common.hash.BloomFilter;

// 初始化布隆過濾器
// 1000:期望存入的資料個數,0.001:期望的誤差率 
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.forName("utf‐8")), 1000, 0.001);

//把所有資料存入布隆過濾器 
void init(){ 
    for (String key: keys) { 
        bloomFilter.put(key); 
    } 
}

String get(String key) { 1
    // 從布隆過濾器這一級快取判斷下key是否存在 
    Boolean exist = bloomFilter.mightContain(key); 
    if(!exist){ 
        return ""; 
    } 
    // 從快取中獲取資料 
    String cacheValue = cache.get(key); 
    // 快取為空 
    if (StringUtils.isBlank(cacheValue)) { 
        // 從儲存中獲取 
        String storageValue = storage.get(key); 
        cache.set(key, storageValue); 
        // 如果儲存資料為空, 需要設定一個過期時間(300秒) 
        if (storageValue == null) { 
            cache.expire(key, 60 * 5); 
        } 
        return storageValue; 
    } else { 
        // 快取非空 
        return cacheValue;  
    } 
}

更多關於布隆過濾器的可以看這篇:【資料結構】布隆過濾器:BloomFilter原理及Java實現

2.快取擊穿

開發人員使用“快取+過期時間”的策略既可以加速資料讀寫, 又保證資料的定期更新, 這種模式基本能夠滿足絕大部分需求。 但是有兩個問題如果同時出現, 可能就會對應用造成致命的危害:

  1. 當前key是一個熱點key(例如一個熱門的娛樂新聞),併發量非常大。
  2. 重建快取不能在短時間完成, 可能是一個複雜計算, 例如複雜的SQL、 多次IO、 多個依賴等。

在快取失效的瞬間, 有大量執行緒來重建快取, 造成後端負載加大, 甚至可能會讓應用崩潰。 要解決這個問題主要就是要避免大量執行緒同時重建快取。 我們可以利用互斥鎖來解決,此方法只允許一個執行緒重建快取, 其他執行緒等待重建快取的執行緒執行完, 重新從快取獲取資料即可。

示例虛擬碼:

String get(String key) { 
    // 從Redis中獲取資料 
    String value = redis.get(key); 
    // 如果value為空, 則開始重構快取 
    if (value == null) { 
        // 只允許一個執行緒重建快取, 使用nx, 並設定過期時間ex 
        String mutexKey = "mutext:key:" + key; 
        if (redis.set(mutexKey, "1", "ex 180", "nx")) { 
            // 從資料來源獲取資料 
            value = db.get(key); 
            // 回寫Redis, 並設定過期時間 1
            redis.setex(key, timeout, value);
            // 刪除key_mutex 
            redis.delete(mutexKey); 
        }
        // 其他執行緒休息50毫秒後重試 
        else { 
            Thread.sleep(50); 
            get(key); 
        } 
    } 
    return value; 
}

3.快取雪崩

快取雪崩是指,我們設定快取時採用了相同的過期時間,所以大批量快取在同一時間失效,導致流量會像奔逃的野牛一樣, 打向後端儲存層。

由於快取層承載著大量請求, 有效地保護了儲存層, 但是如果快取層由於某些原因不能提供服務(比如超大併發過來,快取層支撐不住,或者由於快取設計不好,類似大量請求訪問bigkey,導致快取能支撐的併發急劇下降), 於是大量請求都會達到儲存層, 儲存層的呼叫量會暴增, 造成儲存層也會級聯當機的情況。

快取雪崩是因為大面積的快取失效,打崩了DB。而快取擊穿是值一個熱點Key在不停的扛著大併發,當這個Key失效的瞬間,持續的大併發就穿破快取,直接請求資料庫。(也可以理解成快取雪崩說的是很多key,而快取擊穿是某個熱點key)

對於這種情況我們在批量增加快取時好將這一批資料的快取過期時間設定為一個時間段內的不同時間。

示例虛擬碼:

String get(String key) { 
    // 從快取中獲取資料 
    String cacheValue = cache.get(key); 
    // 快取為空 
    if (StringUtils.isBlank(cacheValue)) { 
        // 從儲存中獲取 
        String storageValue = storage.get(key); 
        cache.set(key, storageValue);
        //設定一個過期時間(300到600之間的一個隨機數) 
        int expireTime = new Random().nextInt(300) + 300; 
        if (storageValue == null) { 
            cache.expire(key, expireTime); 13  } 
        return storageValue; 
    } else { 
        // 快取非空 
        return cacheValue; 
    }
}

另外,預防和解決快取雪崩問題, 還可以從以下三個方面進行著手:

  1. 保證快取層服務高可用性,比如使用Redis Sentinel或Redis Cluster。
  2. 依賴隔離元件為後端限流並降級。比如使用Hystrix限流降級元件。
  3. 提前演練。 在專案上線前, 演練快取層宕掉後, 應用以及後端的負載情況以及可能出現的問題, 在此基礎上做一些預案設定。

在這裡插入圖片描述

相關文章