Redis快取的主要異常及解決方案

京東雲開發者發表於2023-01-29

作者:京東物流 陳昌浩

1 導讀

Redis 是當前最流行的 NoSQL資料庫。Redis主要用來做快取使用,在提高資料查詢效率、保護資料庫等方面起到了關鍵性的作用,很大程度上提高系統的效能。當然在使用過程中,也會出現一些異常情景,導致Redis失去快取作用。

2 異常型別

異常主要有 快取雪崩 快取穿透 快取擊穿。

2.1 快取雪崩

2.1.1 現象

快取雪崩是指大量請求在快取中沒有查到資料,直接訪問資料庫,導致資料庫壓力增大,最終導致資料庫崩潰,從而波及整個系統不可用,好像雪崩一樣。

2.1.2 異常原因

  • 快取服務不可用。
  • 快取服務可用,但是大量KEY同時失效。

2.1.3 解決方案

1.快取服務不可用
redis的部署方式主要有單機、主從、哨兵和 cluster模式。

  • 單機
    只有一臺機器,所有資料都存在這臺機器上,當機器出現異常時,redis將失效,可能會導致redis快取雪崩。

  • 主從
    主從其實就是一臺機器做主,一個或多個機器做從,從節點從主節點複製資料,可以實現讀寫分離,主節點做寫,從節點做讀。
    優點:當某個從節點異常時,不影響使用。
    缺點:當主節點異常時,服務將不可用。

  • 哨兵
    哨兵模式也是一種主從,只不過增加了哨兵的功能,用於監控主節點的狀態,當主節點當機之後會進行投票在從節點中重新選出主節點。
    優點:高可用,當主節點異常時,自動在從節點當中選擇一個主節點。
    缺點:只有一個主節點,當資料比較多時,主節點壓力會很大。

  • cluster模式
    叢集採用了多主多從,按照一定的規則進行分片,將資料分別儲存,一定程度上解決了哨兵模式下單機儲存有限的問題。
    優點:高可用,配置了多主多從,可以使資料分割槽,去中心化,減小了單臺機子的負擔.
    缺點:機器資源使用比較多,配置複雜。

  • 小結
    從高可用得角度考慮,使用哨兵模式和cluster模式可以防止因為redis不可用導致的快取雪崩問題。

2.大量KEY同時失效
可以透過設定永不失效、設定不同失效時間、使用二級快取和定時更新快取失效時間

  • 設定永不失效
    如果所有的key都設定不失效,不就不會出現因為KEY失效導致的快取雪崩問題了。redis設定key永遠有效的命令如下:
    PERSIST key
    缺點:會導致redis的空間資源需求變大。

  • 設定隨機失效時間
    如果key的失效時間不相同,就不會在同一時刻失效,這樣就不會出現大量訪問資料庫的情況。
    redis設定key有效時間命令如下:
    Expire key
    示例程式碼如下,透過RedisClient實現

/**
* 隨機設定小於30分鐘的失效時間
* @param redisKey
* @param value
*/
private void setRandomTimeForReidsKey(String redisKey,String value){
//隨機函式
Random rand = new Random();
//隨機獲取30分鐘內(30*60)的隨機數
int times = rand.nextInt(1800);
//設定快取時間(快取的key,快取的值,失效時間:單位秒)
redisClient.setNxEx(redisKey,value,times);
}
  • 使用二級快取
    二級快取是使用兩組快取,1級快取和2級快取,同一個Key在兩組快取裡都儲存,但是他們的失效時間不同,這樣1級快取沒有查到資料時,可以在二級快取裡查詢,不會直接訪問資料庫。
    示例程式碼如下:
public static void main(String[] args) {
CacheTest test = new CacheTest();
//從1級快取中獲取資料
String value = test.queryByOneCacheKey("key");
//如果1級快取中沒有資料,再二級快取中查詢
if(StringUtils.isBlank(value)){
value = test.queryBySecondCacheKey("key");
//如果二級快取中沒有,從資料庫中查詢
if(StringUtils.isBlank(value)){
value =test.getFromDb();
//如果資料庫中也沒有,就返回空
if(StringUtils.isBlank(value)){
System.out.println("資料不存在!");
}else{
//二級快取中儲存資料
test.secondCacheSave("key",value);
//一級快取中儲存資料
test.oneCacheSave("key",value);
System.out.println("資料庫中返回資料!");
}
}else{
//一級快取中儲存資料
test.oneCacheSave("key",value);
System.out.println("二級快取中返回資料!");
}
}else {
System.out.println("一級快取中返回資料!");
}
}
  • 非同步更新快取時間
    每次訪問快取時,啟動一個執行緒或者建立一個非同步任務來,更新快取時間。
    示例程式碼如下:
public class CacheRunnable implements Runnable {

private ClusterRedisClientAdapter redisClient;
/**
* 要更新的key
*/
public String key;

public CacheRunnable(String key){
this.key =key;
}

@Override
public void run() {
//更細快取時間
redisClient.expire(this.getKey(),1800);
}

public String getKey() {
return key;
}

public void setKey(String key) {
this.key = key;
}
}
public static void main(String[] args) {
CacheTest test = new CacheTest();
//從快取中獲取資料
String value = test.getFromCache("key");
if(StringUtils.isBlank(value)){
//從資料庫中獲取資料
value = test.getFromDb("key");
//將資料放在快取中
test.oneCacheSave("key",value);
//返回資料
System.out.println("返回資料");
}else{
//非同步任務更新快取
CacheRunnable runnable = new CacheRunnable("key");
runnable.run();
//返回資料
System.out.println("返回資料");
}
}

3.小結
上面從服務不可用和key大面積失效兩個方面,列舉了幾種解決方案,上面的程式碼只是提供一些思路,具體實施還要考慮到現實情況。當然也有其他的解決方案,我這裡舉例是比較常用的。畢竟現實情況,千變萬化,沒有最好的方案,只有最適用的方案。

2.2 快取穿透

2.2.1 現象

快取穿透是指當使用者在查詢一條資料的時候,而此時資料庫和快取卻沒有關於這條資料的任何記錄,而這條資料在快取中沒找到就會向資料庫請求獲取資料。使用者拿不到資料時,就會一直髮請求,查詢資料庫,這樣會對資料庫的訪問造成很大的壓力。

2.2.2 異常原因

  • 非法呼叫

2.2.3 解決方案

1.非法呼叫
可以透過快取空值或過濾器來解決非法呼叫引起的快取穿透問題。

  • 快取空值
    當快取和資料庫中都沒有值時,可以在快取中存放一個空值,這樣就可以減少重複查詢空值引起的系統壓力增大,從而最佳化了快取穿透問題。
    示例程式碼如下:
private String queryMessager(String key){
//從快取中獲取資料
String message = getFromCache(key);
//如果快取中沒有 從資料庫中查詢
if(StringUtils.isBlank(message)){
message = getFromDb(key);
//如果資料庫中也沒有資料 就設定短時間的快取
if(StringUtils.isBlank(message)){
//設定快取時間(快取的key,快取的值,失效時間:單位秒)
redisClient.setNxEx(key,null,60);
}else{
redisClient.setNxEx(key,message,1800);
}
}
return message;
}

缺點:大量的空快取導致資源的浪費,也有可能導致快取和資料庫中的資料不一致。

  • 布隆過濾器
    布隆過濾器由布隆在 1970 年提出。它實際上是一個很長的二進位制向量和一系列隨機對映函式。布隆過濾器可以用於檢索一個元素是否在一個集合中。是以空間換時間的演算法。

布隆過濾器的實現原理是一個超大的位陣列和幾個雜湊函式。
假設雜湊函式的個數為 3。首先將位陣列進行初始化,初始化狀態的維陣列的每個位都設定位 0。如果一次資料請求的結果為空,就將key依次透過 3 個雜湊函式進行對映,每次對映都會產生一個雜湊值,這個值對應位陣列上面的一個點,然後將位陣列對應的位置標記為 1。當資料請求再次發過來時,用同樣的方法將 key 透過雜湊對映到位陣列上的 3 個點。如果 3 個點中任意一個點不為 1,則可以判斷key不為空。反之,如果 3 個點都為 1,則該KEY一定為空。

缺點:
可能出現誤判,例如 A 經過雜湊函式 存到 1、3和5位置。B經過雜湊函式存到 3、5和7位置。C經過雜湊函式得到位置 3、5和7位置。由於3、5和7都有值,導致判斷A也在陣列中。這種情況隨著資料的增多,機率也變大。
布隆過濾器沒法刪除資料。

  • 布隆過濾器增強版
    增強版是將布隆過濾器的bitmap更換成陣列,當陣列某位置被對映一次時就+1,當刪除時就-1,這樣就避免了普通布隆過濾器刪除資料後需要重新計算其餘資料包Hash的問題,但是依舊沒法避免誤判。

  • 布穀鳥過濾器
    但是如果這兩個位置都滿了,它就不得不「鳩佔鵲巢」,隨機踢走一個,然後自己霸佔了這個位置。不同於布穀鳥的是,布穀鳥雜湊演算法會幫這些受害者(被擠走的蛋)尋找其它的窩。因為每一個元素都可以放在兩個位置,只要任意一個有空位置,就可以塞進去。所以這個傷心的被擠走的蛋會看看自己的另一個位置有沒有空,如果空了,自己挪過去也就皆大歡喜了。但是如果這個位置也被別人佔了呢?好,那麼它會再來一次「鳩佔鵲巢」,將受害者的角色轉嫁給別人。然後這個新的受害者還會重複這個過程直到所有的蛋都找到了自己的巢為止。

    缺點:
    如果陣列太擁擠了,連續踢來踢去幾百次還沒有停下來,這時候會嚴重影響插入效率。這時候布穀鳥雜湊會設定一個閾值,當連續佔巢行為超出了某個閾值,就認為這個陣列已經幾乎滿了。這時候就需要對它進行擴容,重新放置所有元素。

2.小結
以上方法雖然都有缺點,但是可以有效的防止因為大量空資料查詢導致的快取穿透問題,除了系統上的最佳化,還要加強對系統的監控,發下異常呼叫時,及時加入黑名單。降低異常呼叫對系統的影響。

2.3 快取擊穿

2.3.1 現象

key中對應資料存在,當key中對應的資料在快取中過期,而此時又有大量請求訪問該資料,快取中過期了,請求會直接訪問資料庫並回設到快取中,高併發訪問資料庫會導致資料庫崩潰。redis的高QPS特性,可以很好的解決查資料庫很慢的問題。但是如果我們系統的併發很高,在某個時間節點,突然快取失效,這時候有大量的請求打過來,那麼由於redis沒有快取資料,這時候我們的請求會全部去查一遍資料庫,這時候我們的資料庫服務會面臨非常大的風險,要麼連線被佔滿,要麼其他業務不可用,這種情況就是redis的快取擊穿。

2.3.2 異常原因

熱點KEY失效的同時,大量相同KEY請求同時訪問。

2.3.3 解決方案

1.熱點key失效

  • 設定永不失效
    如果所有的key都設定不失效,不就不會出現因為KEY失效導致的快取雪崩問題了。redis設定key永遠有效的命令如下:
    PERSIST key
    缺點:會導致redis的空間資源需求變大。

  • 設定隨機失效時間
    如果key的失效時間不相同,就不會在同一時刻失效,這樣就不會出現大量訪問資料庫的情況。
    redis設定key有效時間命令如下:
    Expire key
    示例程式碼如下,透過RedisClient實現

/**
* 隨機設定小於30分鐘的失效時間
* @param redisKey
* @param value
*/
private void setRandomTimeForReidsKey(String redisKey,String value){
//隨機函式
Random rand = new Random();
//隨機獲取30分鐘內(30*60)的隨機數
int times = rand.nextInt(1800);
//設定快取時間(快取的key,快取的值,失效時間:單位秒)
redisClient.setNxEx(redisKey,value,times);
}
  • 使用二級快取
    二級快取是使用兩組快取,1級快取和2級快取,同一個Key在兩組快取裡都儲存,但是他們的失效時間不同,這樣1級快取沒有查到資料時,可以在二級快取裡查詢,不會直接訪問資料庫。
    示例程式碼如下:
public static void main(String[] args) {
CacheTest test = new CacheTest();
//從1級快取中獲取資料
String value = test.queryByOneCacheKey("key");
//如果1級快取中沒有資料,再二級快取中查詢
if(StringUtils.isBlank(value)){
value = test.queryBySecondCacheKey("key");
//如果二級快取中沒有,從資料庫中查詢
if(StringUtils.isBlank(value)){
value =test.getFromDb();
//如果資料庫中也沒有,就返回空
if(StringUtils.isBlank(value)){
System.out.println("資料不存在!");
}else{
//二級快取中儲存資料
test.secondCacheSave("key",value);
//一級快取中儲存資料
test.oneCacheSave("key",value);
System.out.println("資料庫中返回資料!");
}
}else{
//一級快取中儲存資料
test.oneCacheSave("key",value);
System.out.println("二級快取中返回資料!");
}
}else {
System.out.println("一級快取中返回資料!");
}
}
  • 非同步更新快取時間
    每次訪問快取時,啟動一個執行緒或者建立一個非同步任務來,更新快取時間。
    示例程式碼如下:
public class CacheRunnable implements Runnable {

private ClusterRedisClientAdapter redisClient;
/**
* 要更新的key
*/
public String key;

public CacheRunnable(String key){
this.key =key;
}

@Override
public void run() {
//更細快取時間
redisClient.expire(this.getKey(),1800);
}

public String getKey() {
return key;
}

public void setKey(String key) {
this.key = key;
}
}
public static void main(String[] args) {
CacheTest test = new CacheTest();
//從快取中獲取資料
String value = test.getFromCache("key");
if(StringUtils.isBlank(value)){
//從資料庫中獲取資料
value = test.getFromDb("key");
//將資料放在快取中
test.oneCacheSave("key",value);
//返回資料
System.out.println("返回資料");

}else{
//非同步任務更新快取
CacheRunnable runnable = new CacheRunnable("key");
runnable.run();
//返回資料
System.out.println("返回資料");
}
}
  • 分散式鎖
    使用分散式鎖,同一時間只有1個請求可以訪問到資料庫,其他請求等待一段時間後,重複呼叫。
    示例程式碼如下:
/**
* 根據key獲取資料
* @param key
* @return
* @throws InterruptedException
*/
public String queryForMessage(String key) throws InterruptedException {
//初始化返回結果
String result = StringUtils.EMPTY;
//從快取中獲取資料
result = queryByOneCacheKey(key);
//如果快取中有資料,直接返回
if(StringUtils.isNotBlank(result)){
return result;
}else{
//獲取分散式鎖
if(lockByBusiness(key)){
//從資料庫中獲取資料
result = getFromDb(key);
//如果資料庫中有資料,就加在快取中
if(StringUtils.isNotBlank(result)){
oneCacheSave(key,result);
}
}else {
//如果沒有獲取到分散式鎖,睡眠一下,再接著查詢資料
Thread.sleep(500);
return queryForMessage(key);
}
}
return result;
}

2.小結
除了以上解決方法,還可以預先設定熱門資料,透過一些監控方法,及時收集熱點資料,將資料預先儲存在快取中。

3 總結

Redis快取在網際網路中至關重要,可以很大的提升系統效率。 本文介紹的快取異常以及解決思路有可能不夠全面,但也提供相應的解決思路和程式碼大體實現,希望可以為大家提供一些遇到快取問題時的解決思路。如果有不足的地方,也請幫忙指出,大家共同進步。

相關文章