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

2024.08.30

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;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.

解決方案—布隆過濾器

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

圖片圖片

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

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

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

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

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.16.2</version> <!-- 请根据需要选择合适的版本 -->
</dependency>
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

範例偽代碼:

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();
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.

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

以下是布隆過濾器快取過濾的偽代碼範例,展示如何在初始化和資料添加過程中操作布隆過濾器:

// 初始化布隆过滤器
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;
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.

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

緩存失效(擊穿)

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

解決方案-隨機過期時間

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

以下是具體的範例偽代碼:

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;
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.

緩存雪崩

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

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

解決方案

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

  1. 保證快取層服務的高可用性:確保快取層的高可用性是避免快取雪崩的關鍵措施。可以使用如Redis Sentinel 或Redis Cluster 等工具來實現快取的高可用性。 Redis Sentinel 提供自動故障轉移和監控功能,可在主節點出現問題時自動將從節點提升為新的主節點,從而保持服務的連續性。 Redis Cluster 透過資料分片和節點間的複製,進一步提高了系統的可用性和擴展性。這樣,即使部分節點發生故障,系統仍能正常運作並繼續處理請求。
  2. 依賴隔離組件進行限流、熔斷和降級:利用限流和熔斷機制來保護後端服務免受突發請求的衝擊,可以有效緩解緩存雪崩帶來的壓力。例如,使用Sentinel 或Hystrix 等限流和熔斷組件來實施流量控制和服務降級。針對不同類型的數據,可以採取不同的處理策略:

非核心資料:例如電商平台中的商品屬性或使用者資訊。如果快取中的這些資料遺失,應用程式可以直接傳回預先定義的預設降級資訊、空值或錯誤提示,而不是直接查詢後端儲存。這種方式可以減少對後端儲存的壓力,同時為使用者提供一些基本的回饋。

核心資料:例如電商平台中的商品庫存。對於這些關鍵數據,仍然可以嘗試從快取中查詢,如果快取缺失,則透過資料庫讀取。這樣即使快取不可用,核心資料的讀取仍可得到保證,避免了因快取雪崩而導致的系統功能喪失。

  1. 提前演練和預案製定:在專案上線之前,進行充分的演練和測試,模擬快取層宕機後的應用和後端負載情況,識別潛在問題並製定相應的預案。這包括模擬快取失效、後端服務過載等情況,觀察系統表現,並根據測試結果調整系統配置和策略。透過這些演練,可以發現系統的弱點,並制定相應的應急措施,以應對實際生產環境中的突發情況。這不僅可以提升系統的穩健性,還可以確保在快取雪崩發生時,系統能夠快速恢復正常運作。

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

總結

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

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

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