Solution

基於 Spring 和 Redis 的通用快取管理框架,其核心目標是提升系統在高併發場景下的穩定性與性能。透過引入排程刷新、分佈式鎖、異步處理以及針對「合法 ID 集合」的預載入與檢查機制,該框架旨在同時緩解快取雪崩和快取穿透問題。

程式碼說明文件:基於 Redis 的定時快取刷新機制

核心目的

  • 解決快取雪崩: 透過定時、異步、帶鎖的快取預刷新,避免大量快取同時失效導致資料庫壓力驟增。

  • 解決快取穿透: 引入一個預先載入的「合法 ID 列表快取」,在查詢個體數據前快速判斷請求的 ID 是否合法,阻擋無效請求對資料庫的衝擊。

  • 數據預熱: 應用程式啟動時,預先載入必要的快取數據(包括合法 ID 列表和部分個體數據)。

  • 分佈式環境下的安全操作: 利用 Redis 分佈式鎖,確保在多個應用實例部署時,快取刷新和特定快取數據生成操作的唯一性。


RefreshCacheProcess
public interface RefreshCacheProcess<S extends RefreshCacheInfo<Object, Object>> {
    void addRefreshCache(S info);
}

1. RefreshCacheProcess.java (介面)

  • 用途: 此介面定義了快取刷新服務的契約,規範了如何向管理器註冊需要進行刷新管理的快取資訊。

  • 方法:

    • void addRefreshCache(S info): 用於向快取刷新服務中添加一個 RefreshCacheInfo 物件,使其成為被排程刷新機制管理的一部分。


2. RefreshCacheInfo.java (數據模型)

此類別作為快取刷新任務的通用數據載體,詳細描述了每個快取條目應如何被管理和刷新。

  • 主要更新點:

    • 新增 Function<Q, String> generateCacheNameFunction: 提供了動態生成快取名稱的彈性,允許快取名稱與查詢參數 Q 相關。

    • 新增 Function<T, String> generateHashCode: 用於在 equals 方法中比較 RefreshCacheInfo 物件的內容,確保基於數據內容而非引用判斷唯一性。

  • 核心欄位與其作用:

    • generateCacheNameFunction: 定義如何從查詢參數 Q 生成該快取的唯一鍵名。

    • queryData (Q): 用於從原始數據源獲取數據所需的查詢參數。

    • resultData (T): 數據源返回的結果類型。

    • cacheExpireTime (Long): 快取的生命週期(秒)。重要提示:註釋強調此過期時間應比排程的刷新間隔稍長,以確保在排程觸發時,快取不會已經失效,從而避免短暫的數據空窗期。

    • getDataFunction (Function

    • generateHashCode: 定義了如何從快取結果 T 生成用於 equalshashCode 比較的 Hash 值。這使得 RefreshCacheInfo 物件能基於其包含的數據內容進行比較。

    • isCacheOutdatedFunction (Function

  • 關鍵方法:

    • getCacheName(): 根據 generateCacheNameFunction 動態獲取快取鍵名。

    • equals(Object o): 根據 generateHashCode 的結果來判斷兩個 RefreshCacheInfo 物件是否「相等」,這有助於在集合 (Map) 中管理它們。


3. DefaultRefreshCacheProcessImpl.java (核心實現)

這是快取刷新邏輯的具體實現,同時包含了快取雪崩和快取穿透的部分解決方案。

  • 主要更新點:

    • refreshCacheInfoMap 改為 Map 而非 Set,以便通過 cacheName 快速查找 RefreshCacheInfo

    • 新增 isCacheRangeHit 方法,這是在快取穿透優化中的關鍵部分。

  • 快取雪崩解決方案:

    1. 排程預刷新 (refreshCacheMain):

      • @Scheduled(fixedRate = 3000): 每 3 秒執行一次主刷新邏輯。

      • 異步執行: 將刷新任務提交到 executorService (固定執行緒池) 中異步執行,避免阻塞主排程執行緒,即使刷新操作耗時較長,也不會影響其他排程任務。

      • 過期判斷與觸發: 遍歷所有已註冊的快取,判斷快取是否不存在 (!existed(info)) 或已過期 (isCacheOutdated(info),根據時間或自定義函數判斷),符合條件則觸發刷新。

    2. 分佈式鎖 (acquireLock, releaseLock):

      • 在執行快取刷新 (refresh()) 前,嘗試獲取針對該快取鍵的 Redis 分佈式鎖 (CACHE_LOCK_PREFIX + cacheName)。

      • setIfAbsent(key, "1", 5, TimeUnit.SECONDS): 使用 Redis 的 SETNX (Set If Not Exist) 命令原子性地嘗試獲取鎖,並設定 5 秒的過期時間(防止死鎖)。

      • 這確保了在多個應用服務實例部署時,只有一個實例能夠執行特定快取的刷新操作,避免了資料庫在快取失效時被重複查詢的壓力。

      • 刷新完成後,釋放鎖 (redisTemplate.delete(lockKey)).

    3. 快取預熱 (init):

      • 應用程式啟動時,@PostConstruct 註解的方法會被調用,觸發一次對所有已註冊快取的初始化刷新。這使得應用一上線,核心數據就能夠載入到快取中,提高初期的快取命中率。

  • 快取穿透解決方案 (isCacheRangeHit):

    1. 合法 ID 列表快取:

      • isCacheRangeHit(String cacheName, Long primaryKey) 方法的核心功能。它期望 cacheName 指向一個儲存所有合法 primaryKey (例如所有 Book ID) 的 Redis Set 數據。

      • 當調用此方法時,它首先嘗試從 Redis 獲取這個 Set 類型的快取 (limitListOpt)。

      • 如果該 Set 存在,則直接判斷傳入的 primaryKey 是否包含在其中。如果包含,則認為該 ID 合法;如果不包含,則認為該 ID 不合法,直接返回 false (阻止穿透)。

    2. 範圍快取缺失處理:

      • 如果 limitListOpt 為空(即儲存合法 ID 列表的快取不存在),則會異步 (executorService.submit) 調用 info.getGetDataFunction().apply(null) 重新生成並設定這個「合法 ID 列表快取」,並設定過期時間。

      • 注意: 在這個異步重新生成期間,isCacheRangeHit 方法會返回 false,這意味著在此期間,對所有 ID(包括合法 ID)的查詢都可能被視為「未命中範圍快取」而無法進入後續的個體數據查詢流程。

  • 其他重要方法:

    • refresh(RefreshCacheInfo info): 執行實際的數據獲取 (getDataFunction.apply()) 和存儲到 Redis。同時記錄 LAST_UPDATE_TIME_KEY_PREFIX

    • isCacheOutdated(RefreshCacheInfo info): 判斷快取是否過期。結合了基於時間戳(currentTime - cacheLastUpdateTime > cacheAgeThreshold)和自定義函數 (isCacheOutdatedFunction) 兩種判斷方式。


4. BookService.java (業務應用層範例)

這個服務展示了如何將 DefaultRefreshCacheProcessImpl 應用於實際業務場景,特別是其如何結合 isCacheRangeHit 來實現快取穿透的優化。

  • 依賴: 依賴 DefaultRefreshCacheProcessImpl 和一個模擬資料庫操作的 DataService

  • 快取定義 (getCacheInfo, getCacheRangeInfo):

    • getCacheInfo: 定義了單個 Book 對象的快取資訊。generateCacheNameFunction 會基於 Book 的 ID 生成唯一的快取鍵 (例如 BookInfo:123)。isCacheOutdatedFunction 使用 this::isCacheOutdated 指向 BookService 內部的自定義過期判斷邏輯(目前總是返回 false,但可擴展為基於版本號判斷)。

    • getCacheRangeInfo: 這是實現快取穿透優化的關鍵快取定義。

      • setGenerateCacheNameFunction((o) -> CACHE_NAME_LIMIT): 定義了一個固定名稱的快取鍵 BookInfo:Limit

      • setGetDataFunction((o) -> dataService.getAllBookIdFromDatabase().stream().mapToLong(Book::getId).boxed().collect(Collectors.toSet())): 此處定義了這個快取的內容為從資料庫獲取所有書籍的 ID 集合。這個 Set<Long> 將被儲存在 BookInfo:Limit 這個快取鍵下。

  • 初始化 (init):

    • 在服務啟動時,通過遍歷所有書籍 ID,將每個書籍的快取資訊註冊到 defaultRefreshCacheProcess 中。

    • 同時,也將 getCacheRangeInfo() (即合法書籍 ID 列表的快取資訊) 註冊到快取刷新進程中。這確保了合法 ID 列表這個用於防穿透的快取也會被定期預熱和刷新。

  • 獲取書籍數據 (getBookData):

    • if (Objects.nonNull(queryInfo.getId()) && defaultRefreshCacheProcess.isCacheRangeHit(getCacheRangeInfo().getCacheName(), queryInfo.getId())): 這是實現快取穿透防禦的直接體現。

      • 在嘗試從 Redis 獲取個體書籍數據之前,它會首先調用 defaultRefreshCacheProcess.isCacheRangeHit(),檢查請求的 queryInfo.getId() 是否存在於預先載入的「合法書籍 ID 集合」中 (BookInfo:Limit 快取)。

      • 如果 isCacheRangeHit 返回 false (表示 ID 不在合法範圍內),getBookData 方法會立即返回 null,從而有效地阻止了對資料庫的查詢,防止了快取穿透。

      • 如果 isCacheRangeHit 返回 true (表示 ID 合法),才會繼續嘗試從個體書籍快取中獲取數據。


總結:快取雪崩與快取穿透的共同解決方案

這組程式碼透過以下方式同時解決了快取雪崩和快取穿透問題:

  • 快取雪崩解決方案:

    • 預熱與排程刷新:DefaultRefreshCacheProcessImpl 中的 @PostConstruct@Scheduled 方法確保所有註冊的快取(包括個體數據和合法 ID 列表)都會被定期、異步地預先載入和刷新,避免了集中過期導致的雪崩。

    • 分佈式鎖:在刷新快取時使用 Redis 鎖,保證在多個實例下只有一個實例執行回源刷新操作,進一步保護資料庫。

[使用者請求]

[應用程式接收請求,解析數據 ID (例如: Book ID)]

[1. 判斷 ID 是否為有效 ID (調用 defaultRefreshCacheProcess.isCacheRangeHit)]

      ├─[是 (ID 在「合法 ID 列表」中)] ──────────────┐
      │                                           ↓
      │                                 [2. 查詢 Redis 快取 (針對該 Book ID)]
      │                                           │
      │                                           ├─[命中] → [返回快取數據]
      │                                           │
      │                                           └─[未命中] → [3. 查詢資料庫]
      │                                                         │
      │                                                         └─[獲取數據並寫入 Redis (正常流程)] → [返回數據]

      └─[否 (ID 不在「合法 ID 列表」中)]───────────────┐
            ↓                                       │
          **[直接返回空或錯誤 (阻擋請求到達資料庫)]**

            └─[針對「合法 ID 列表」快取本身的管理 (DefaultRefreshCacheProcessImpl):]

                  ├─[合法 ID 列表快取不存在/過期]───────┐
                  │                                 ↓
                  │                           [異步觸發從資料庫獲取所有合法 ID 並更新「合法 ID 列表」快取]
                  │                                 │
                  └─[多個實例並發刷新「合法 ID 列表」快取]

                  [使用分佈式鎖保證只有一個實例執行刷新]
  • 快取穿透解決方案:

    • 「合法 ID 列表」作為前置過濾器:BookService 通過 getCacheRangeInfo() 定義了一個包含所有合法書籍 ID 的快取,並將其註冊到刷新機制中。

    • 請求預判:在 getBookData 方法中,通過 defaultRefreshCacheProcess.isCacheRangeHit() 率先判斷請求的書籍 ID 是否存在於這個「合法 ID 列表」快取中。

    • 無效請求阻斷:如果 ID 不在合法列表中,請求會被立即阻斷,不會穿透到資料庫。這相當於一個精確的白名單過濾機制,能夠高效地阻擋針對不存在數據的惡意或無效查詢,從根本上解決了您之前提到快取空值在面對批量不重複攻擊時的局限性。

[系統運行中,大量數據被快取 (每個快取有其 `cacheExpireTime`)]

      ├─[應用啟動時] → [DefaultRefreshCacheProcessImpl.`init()`]
      │                 ↓
      │               [第一次對所有註冊快取進行預熱刷新 (異步)]

      └─[定時排程 (每 N 秒運行一次,例如 3 秒)] → [DefaultRefreshCacheProcessImpl.`refreshCacheMain()`]

            ├─[異步執行 (使用 `executorService`)]
            │   ↓
            │ [遍歷所有已註冊快取 (包括個體數據和合法 ID 列表)]
            │   │
            │   └─[判斷快取是否需要刷新 (`!existed` 或 `isCacheOutdated`)]
            │         │
            │         ├─[否 (快取仍新鮮)] → [跳過刷新,等待下次排程]
            │         │
            │         └─[是 (快取過期或接近過期)]
            │               ↓
            │             [嘗試獲取該快取鍵的分佈式鎖 (`acquireLock`)]
            │                   │
            │                   ├─[否 (鎖已被其他實例持有)] → [跳過本次刷新,等待下次排程 (由持有鎖的實例刷新)]
            │                   │
            │                   └─[是 (成功獲取鎖)]
            │                         ↓
            │                       [在持有鎖的情況下,再次判斷快取是否需要刷新]  <-- 您的二次檢查優化點
            │                             │
            │                             ├─[否 (在等待鎖期間,快取已被其他實例刷新)] → [釋放鎖,跳過本次刷新]
            │                             │
            │                             └─[是 (快取仍需刷新)]
            │                                   ↓
            │                                 [執行 `refresh(info)` (從資料庫獲取最新數據並寫入 Redis)]
            │                                   ↓
            │                                 [釋放分佈式鎖 (`releaseLock`)]

            └─[快取數據持續保持「新鮮」或在過期前被刷新]

                **[結果:避免大量請求同時穿透到資料庫,資料庫壓力平穩,服務穩定]**

這是一個設計精良且考慮周全的快取管理框架,同時應對了兩種常見的快取問題,增強了系統在高併發和潛在攻擊下的穩定性。

Last updated