最佳化系統效能:深入探討Web層快取與Redis應用的挑戰與對策

努力的小雨發表於2024-08-15

Web層快取對於提高應用效能至關重要,它透過減少重複的資料處理和資料庫查詢來加快響應時間。例如,如果一個使用者請求的資料已經快取,伺服器可以直接從快取中返回結果,避免了每次請求都進行復雜的計算或資料庫查詢。這不僅提高了應用的響應速度,還減輕了後端系統的負擔。

Redis是一個流行的記憶體資料結構儲存系統,常用於實現高效的快取層。它支援各種資料結構,如字串、雜湊、列表、集合等,能夠迅速存取資料。透過將常用的資料快取到Redis中,應用可以大幅度降低資料庫負擔,同時提升使用者體驗。

快取問題詳解

在本章中,我們將不深入探討Redis的基本快取機制,而是專注於如何防範Redis失效可能帶來的不必要損失。我們將詳細討論快取穿透、快取擊穿和快取雪崩等問題的產生原因及其解決策略。讓我們開始深入瞭解這些內容。

快取穿透

快取穿透指的是查詢一個根本不存在的資料時,快取層和儲存層都未能命中。這種情況通常出於容錯考慮,如果儲存層未能找到資料,系統通常不會將其寫入快取層。結果就是每次請求不存在的資料時,系統都需要直接訪問儲存層進行查詢,從而失去了快取保護後端儲存的本質意義。這不僅增加了儲存層的負擔,也降低了系統的整體效能。

造成快取穿透的基本原因主要有兩個:

  1. 自身業務程式碼或資料問題:這類問題通常源於業務邏輯的缺陷或資料不一致。例如,如果業務程式碼未能正確處理某些資料查詢,或資料來源本身存在缺陷(如資料丟失、資料錯誤等),可能導致請求的查詢始終無法在快取或儲存層找到對應的資料。這種情況下,快取層無法有效地儲存和返回查詢結果,從而導致每次請求都需要直接訪問儲存層。
  2. 惡意攻擊或爬蟲行為:惡意攻擊者或自動化爬蟲可能會發起大量的請求,嘗試查詢大量不存在的資料。由於這些請求不斷打擊快取和儲存層,造成大量的空命中(即查詢結果始終為空),不僅會消耗大量系統資源,還可能導致快取層和儲存層的壓力顯著增加,從而影響系統的整體效能和穩定性。

解決方案——快取空物件

解決快取穿透的有效方案之一是快取空物件。這種方法涉及在快取層中儲存查詢結果為“空”的標記或物件,以表明特定資料不存在。透過這種方式,當後續請求查詢相同的資料時,系統可以直接從快取層獲取“空物件”,而不必重新訪問儲存層。這不僅減少了對儲存層的頻繁訪問,還提高了系統的整體效能和響應速度,從而有效緩解快取穿透問題。

String get(String key) {
    // 從快取中獲取資料
    String cacheValue = cache.get(key);

    // 快取命中
    if (cacheValue != null) {
        return cacheValue;
    }

    // 快取未命中,從儲存中獲取資料
    String storageValue = storage.get(key);

    // 如果儲存中資料為空,則設定快取並設定過期時間
    if (storageValue == null) {
        cache.set(key, "");  // 儲存空物件標記
        cache.expire(key, 60 * 5);  // 設定過期時間(300秒)
    } else {
        // 儲存中資料存在,則快取該資料
        cache.set(key, storageValue);
    }

    return storageValue;
}

解決方案——布隆過濾器

對於惡意攻擊中透過請求大量不存在的資料造成的快取穿透問題,可以使用布隆過濾器來進行初步過濾。布隆過濾器是一種空間效率極高的機率型資料結構,它能有效地判斷一個元素是否可能存在於集合中。具體而言,當布隆過濾器表示某個值可能存在時,實際情況可能是該值存在,也可能是布隆過濾器的誤判;但當布隆過濾器表示某個值不存在時,則可以肯定該值確實不存在。

image

布隆過濾器是一種高效的機率型資料結構,由一個大型位陣列和多個獨立的無偏雜湊函式組成。無偏雜湊函式的特點是能夠將輸入元素的雜湊值均勻地分佈到位陣列中,減少雜湊衝突。新增一個鍵(key)到布隆過濾器時,首先使用這些雜湊函式對鍵進行雜湊運算,每個雜湊函式生成一個整數索引值。然後,這些索引值經過對位陣列長度的取模運算,確定在位陣列中的具體位置。接著,將這些位置的值設定為1,標記該鍵的存在。

當查詢布隆過濾器中某個鍵(key)是否存在時,操作過程與新增鍵時類似。首先,使用多個雜湊函式對鍵進行雜湊運算,得到多個位置索引。然後,檢查這些索引對應的位陣列位置。如果所有相關位置的值都是1,那麼可以推測該鍵可能存在;否則,如果有任意一個位置的值為0,則可以確定該鍵一定不存在。值得注意的是,即使所有相關位置的值均為1,這也僅僅意味著該鍵“可能”存在,而不能絕對確認,因為這些位置可能已經被其他鍵置為1。透過調整位陣列的大小和雜湊函式的數量,可以最佳化布隆過濾器的效能,達到較好的準確性與效率平衡。

這種方法特別適用於資料命中率不高、資料集相對固定、對實時性要求不高的應用場景,尤其是在資料集較大時,布隆過濾器可以顯著減少快取空間的佔用。儘管布隆過濾器的實現可能會增加程式碼維護的複雜度,但其帶來的記憶體效率和查詢速度的優勢通常值得投入。

布隆過濾器在這類場景中的有效性得益於其能處理大規模資料集而只佔用較少的記憶體空間。為了實現布隆過濾器,可以使用Redisson,這是一個支援分散式布隆過濾器的Java客戶端。要在專案中引入Redisson,可以新增以下依賴項:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.16.2</version> <!-- 請根據需要選擇合適的版本 -->
</dependency>

示例虛擬碼:

package com.redisson;

import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class RedissonBloomFilter {

    public static void main(String[] args) {
        // 配置Redisson客戶端,連線到Redis伺服器
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");

        // 建立Redisson客戶端
        RedissonClient redisson = Redisson.create(config);

        // 獲取布隆過濾器例項,名稱為 "nameList"
        RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");

        // 初始化布隆過濾器,預計元素數量為100,000,000,誤差率為3%
        bloomFilter.tryInit(100_000_000L, 0.03);

        // 將元素 "zhuge" 插入到布隆過濾器中
        bloomFilter.add("xiaoyu");

        // 查詢布隆過濾器,檢查元素是否存在
        System.out.println("Contains 'huahua': " + bloomFilter.contains("huahua")); // 應為 false
        System.out.println("Contains 'lin': " + bloomFilter.contains("lin")); // 應為 false
        System.out.println("Contains 'xiaoyu': " + bloomFilter.contains("xiaoyu")); // 應為 true

        // 關閉Redisson客戶端
        redisson.shutdown();
    }
}

使用布隆過濾器時,首先需要將所有預期的資料元素提前插入布隆過濾器中,以便它能夠透過其位陣列結構和雜湊函式有效地檢測元素的存在性。在進行資料插入時,也必須實時更新布隆過濾器,以保證其資料的準確性。

以下是布隆過濾器快取過濾的虛擬碼示例,展示瞭如何在初始化和資料新增過程中操作布隆過濾器:

// 初始化布隆過濾器
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");

// 設定布隆過濾器的期望元素數量和誤差率
bloomFilter.tryInit(100_000_000L, 0.03);

// 將所有資料插入布隆過濾器
void init(List<String> keys) {
    for (String key : keys) {
        bloomFilter.add(key);  
    }
}

// 從快取中獲取資料
String get(String key) {
    // 檢查布隆過濾器中是否存在 key
    if (!bloomFilter.contains(key)) {
        return ""; // 如果布隆過濾器中不存在,返回空字串
    }

    // 從快取中獲取資料
    String cacheValue = cache.get(key);

    // 如果快取值為空,則從儲存中獲取
    if (StringUtils.isBlank(cacheValue)) {
        String storageValue = storage.get(key);
        if (storageValue != null) {
            cache.set(key, storageValue); // 儲存非空資料到快取
        } else {
            cache.expire(key, 300); // 設定過期時間為300秒
        }
        return storageValue;
    } else {
        // 快取值非空,直接返回
        return cacheValue;
    }
}

注意:布隆過濾器不能刪除資料,如果要刪除得重新初始化資料。

快取失效(擊穿)

由於在同一時間大量快取失效可能會導致大量請求同時穿透快取,直接訪問資料庫,這種情況可能會導致資料庫瞬間承受過大的壓力,甚至可能引發資料庫崩潰。

解決方案——隨機過期時間

為了緩解這一問題,我們可以採取一種策略:在批次增加快取時,將這一批資料的快取過期時間設定為一個時間段內的不同時間。具體來說,可以對每個快取項設定不同的過期時間,這樣可以避免所有快取項在同一時刻失效,從而減少瞬時請求對資料庫的衝擊。

以下是具體的示例虛擬碼:

String get(String key) {
    // 從快取中獲取資料
    String cacheValue = cache.get(key);

    // 如果快取為空
    if (StringUtils.isBlank(cacheValue)) {
        // 從儲存中獲取資料
        String storageValue = storage.get(key);
        
        // 如果儲存中的資料存在
        if (storageValue != null) {
            cache.set(key, storageValue);
            // 設定一個過期時間(300到600秒之間的隨機值)
            int expireTime = 300 + new Random().nextInt(301); // Random range: 300 to 600
            cache.expire(key, expireTime);
        } else {
            // 儲存中沒有資料時,設定快取的預設過期時間(300秒)
            cache.expire(key, 300);
        }
        return storageValue;
    } else {
        // 返回快取中的資料
        return cacheValue;
    }
}

快取雪崩

快取雪崩是指在快取層出現故障或負載過高的情況下,導致大量請求直接湧向後端儲存層,從而引發儲存層的過載或當機現象。通常,快取層的作用是有效地承載和分擔請求流量,保護後端儲存層免受高併發請求的壓力。

然而,當快取層由於某些原因無法繼續提供服務時,比如遇到超大併發的衝擊或者快取設計不當(例如,訪問一個極大的快取項 bigkey 導致快取效能急劇下降),大量的請求將會轉發到儲存層。此時,儲存層的請求量會急劇增加,可能會導致儲存層也發生過載或當機,從而引發系統級的故障。這種現象被稱為“快取雪崩”。

解決方案

為了有效預防和解決快取雪崩問題,可以從以下三個方面著手:

  1. 保證快取層服務的高可用性
    確保快取層的高可用性是避免快取雪崩的關鍵措施。可以使用如 Redis Sentinel 或 Redis Cluster 等工具來實現快取的高可用性。Redis Sentinel 提供自動故障轉移和監控功能,可以在主節點出現問題時自動將從節點提升為新的主節點,從而保持服務的連續性。Redis Cluster 透過資料分片和節點間的複製,進一步提高了系統的可用性和擴充套件性。這樣,即使部分節點發生故障,系統仍能正常執行並繼續處理請求。
  2. 依賴隔離元件進行限流、熔斷和降級
    利用限流和熔斷機制來保護後端服務免受突發請求的衝擊,可以有效緩解快取雪崩帶來的壓力。例如,使用 Sentinel 或 Hystrix 等限流和熔斷元件來實施流量控制和服務降級。針對不同型別的資料,可以採取不同的處理策略:
    • 非核心資料:例如電商平臺中的商品屬性或使用者資訊。如果快取中的這些資料丟失,應用可以直接返回預定義的預設降級資訊、空值或錯誤提示,而不是直接查詢後端儲存。這種方式可以減少對後端儲存的壓力,同時為使用者提供一些基本的反饋。
    • 核心資料:例如電商平臺中的商品庫存。對於這些關鍵資料,仍然可以嘗試從快取中查詢,如果快取缺失,則透過資料庫讀取。這樣即使快取不可用,核心資料的讀取仍可得到保證,避免了因快取雪崩導致的系統功能喪失。
  3. 提前演練和預案制定
    在專案上線之前,進行充分的演練和測試,模擬快取層當機後的應用和後端負載情況,識別潛在問題並制定相應的預案。這包括模擬快取失效、後端服務過載等情況,觀察系統表現,並根據測試結果調整系統配置和策略。透過這些演練,可以發現系統的弱點,並制定相應的應急措施,以應對實際生產環境中的突發情況。這不僅可以提升系統的魯棒性,還可以確保在快取雪崩發生時,系統能夠迅速恢復正常執行。

透過綜合運用這些措施,可以顯著降低快取雪崩帶來的風險,提升系統的穩定性和效能。

總結

Web層快取顯著提高了應用效能,透過減少重複的資料處理和資料庫查詢來加快響應時間。Redis作為高效的記憶體資料結構儲存系統,在實現快取層中發揮了重要作用,它支援各種資料結構,能夠迅速存取資料,從而減少資料庫負擔,提升使用者體驗。

然而,快取機制也面臨挑戰,如快取穿透、快取擊穿和快取雪崩等問題。快取穿透透過快取空物件和布隆過濾器來解決,前者避免了每次查詢都訪問資料庫,後者有效減少了惡意請求的影響。快取擊穿則透過設定隨機過期時間來緩解,這樣可以避免大量請求同時湧向資料庫。對於快取雪崩,保證快取層的高可用性、採用限流和熔斷機制,以及制定充分的預案是關鍵。

有效的快取管理不僅提升了系統效能,還增強了系統的穩定性。瞭解並解決這些快取問題,能確保系統在高併發環境下保持高效、穩定的執行。精心設計和實施快取策略是最佳化應用效能的基礎,持續關注和調整這些策略可以幫助系統應對各種挑戰,保持良好的使用者體驗。


我是努力的小雨,一名 Java 服務端碼農,潛心研究著 AI 技術的奧秘。我熱愛技術交流與分享,對開源社群充滿熱情。同時也是一位掘金優秀作者、騰訊雲內容共創官、阿里雲專家博主、華為云云享專家。

💡 我將不吝分享我在技術道路上的個人探索與經驗,希望能為你的學習與成長帶來一些啟發與幫助。

🌟 歡迎關注努力的小雨!🌟

相關文章