解決快取雪崩

這組 Java 程式碼提供了一個基於 Spring 和 Redis 的通用快取刷新框架。它允許應用程式註冊需要定期刷新的快取資訊,並通過排程任務和分佈式鎖機制,確保快取數據在過期前被預先載入或刷新,從而有效緩解快取雪崩問題。該實現著重於快取數據的預熱 (Warm-up) 和異步刷新 (Asynchronous Refresh),以避免大量快取同時失效導致資料庫壓力驟增。

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

核心目的

  • 解決快取雪崩: 通過定時檢查快取狀態並在快取過期前進行刷新,避免大量請求同時穿透到資料庫。

  • 數據預熱: 應用程式啟動時,預先載入必要的快取數據。

  • 分佈式環境下的安全刷新: 利用 Redis 分佈式鎖,確保在多個應用實例部署時,同一個快取鍵只會由一個實例刷新,避免重複操作和資源浪費。

未涵蓋範圍

  • 未解決快取穿透: 此實現沒有針對查詢一個「不存在」的數據時,資料庫仍然會被查詢的問題(即快取穿透)提供解決方案,例如未實作布隆篩檢器或快取空值的功能。


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

1. RefreshCacheProcess.java (介面)

這個介面定義了快取刷新服務應具備的基本功能。

  • 套件: com.caster.normal.refresh

  • 用途: 規範任何快取刷新處理器的行為,使其能夠註冊要刷新的快取資訊。

  • 方法:

    • void addRefreshCache(S info): 用於向快取刷新服務中添加一個 RefreshCacheInfo 物件,表明這個快取需要被管理和刷新。其中 SRefreshCacheInfo 的泛型子類。


2. RefreshCacheInfo.java (數據模型)

這個類別是快取刷新任務的數據載體,定義了每個快取需要刷新的詳細資訊和行為。

  • 套件: com.caster.normal.refresh

  • Lombok註解: @Data (自動生成 Getter/Setter/equals/hashCode/toString)、@Accessors(chain = true) (使 Setter 方法可鏈式呼叫)。

  • 核心欄位:

    • cacheName (String): 快取的唯一名稱,用於在 Redis 中作為 Key 的一部分。

    • queryData (Q): 用於獲取數據的查詢參數,泛型 Q。

    • resultData (T): 獲取數據的結果,泛型 T (此欄位在刷新邏輯中可能不直接使用,但作為 Function 的回傳型別)。

    • cacheExpireTime (Long): 快取的過期時間(秒)。這是一個關鍵參數,在註釋中特別強調:這個時間必須比排程的刷新間隔長一點,以避免在排程觸發刷新時,快取已經過期,造成短暫的數據空窗期。例如,如果排程每小時刷新一次,則快取過期時間應至少為 (60 * 60) + 60 秒(1 小時 1 分鐘)。

    • getDataFunction (Function<Q, T>): 一個 java.util.function.Function 介面,定義了如何從原始數據源(例如資料庫或外部 API)獲取最新數據的邏輯。它接收 queryData 作為輸入,返回類型為 T

    • isCacheOutdatedFunction (Function<T, Boolean>): 另一個 Function 介面,用於定義判斷快取數據是否「邏輯上」過期或需要刷新的自定義邏輯。它接收當前快取中的數據 T 作為輸入,返回一個 Boolean 值(true 表示過期/需要刷新,false 表示最新)。

  • equalshashCode 方法: 覆寫了這兩個方法,使得 RefreshCacheInfo 物件能基於 cacheName 進行相等判斷和 Hash 計算,這對於在 HashSet 中管理這些資訊至關重要。


3. DefaultRefreshCacheProcessImpl.java (核心實現)

這是 RefreshCacheProcess 介面的主要實作類別,負責快取刷新任務的具體執行。

  • 套件: com.caster.normal.refresh.impl

  • Spring註解: @Service (標記為 Spring 服務組件)、@RequiredArgsConstructor (自動生成帶有 final 欄位的構造函數)、@Slf4j (Lombok,用於日誌)。

  • 關鍵組件:

    • RedisTemplate<String, Object> redisTemplate: 用於與 Redis 互動的 Spring Data Redis 模板,負責快取數據的存取和鎖的實現。

    • ExecutorService executorService: 一個固定大小的執行緒池 (10 個執行緒),用於異步執行快取刷新任務,避免阻塞主排程執行緒。

    • Set<RefreshCacheInfo> refreshCacheInfoSet: 儲存所有需要管理的 RefreshCacheInfo 物件的集合。

    • CACHE_LOCK_PREFIX, CACHE_KEY_PREFIX, LAST_UPDATE_TIME_KEY_PREFIX: 定義了 Redis 中不同類型鍵的前綴,用於區分快取數據、鎖和上次更新時間。

  • 核心方法及邏輯:

    • @PostConstruct init():

      • 在 Spring 容器初始化 DefaultRefreshCacheProcessImpl 後執行。

      • 用於「初始化已完成的快取結構」,並執行一次快取預熱 (Warm-up),即對 refreshCacheInfoSet 中所有已註冊的快取立即執行一次 refresh() 操作,確保應用啟動時快取中已有數據。

    • addRefreshCache(RefreshCacheInfo info):

      • 實現自 RefreshCacheProcess 介面。

      • 允許外部程式碼向這個刷新管理器註冊新的快取資訊,這些快取將會被排程器管理和刷新。

    • @Scheduled(fixedRate = 3000) public void refreshCacheMain():

      • 這是觸發快取刷新邏輯的排程任務。

      • fixedRate = 3000 表示每 3 秒執行一次此方法。

      • 異步執行: 整個刷新邏輯被提交到 executorService 中異步執行,防止排程任務本身因刷新時間過長而阻塞。

      • 刷新判斷邏輯: 遍歷 refreshCacheInfoSet 中的每個快取:

        • if (!existed(info) || isCacheOutdated(info)): 判斷快取是否「不存在」或「已過期」。

          • !existed(info): 快取在 Redis 中完全不存在。

          • isCacheOutdated(info): 根據 cacheExpireTimeisCacheOutdatedFunction 判斷快取是否需要刷新。

        • 分佈式鎖: if (acquireLock(info.getCacheName())):

          • 核心雪崩解決方案之一。 在多個應用實例部署時,確保只有一個實例能獲取到針對該快取鍵的鎖,從而防止多個實例同時去資料庫刷新同一個快取,避免對資料庫造成重複壓力。

          • 如果成功獲取鎖,則執行 refresh(info)

          • 刷新完成後,releaseLock(info.getCacheName()) 釋放鎖。

          • 如果未能獲取鎖,則跳過本次刷新,避免競爭。

    • getCacheData(String cacheName):

      • 從 Redis 中獲取指定快取的數據。

    • existed(RefreshCacheInfo<Q, T> info):

      • 判斷指定快取是否在 Redis 中存在數據。

    • private void refresh(RefreshCacheInfo<Q, T> info):

      • 執行實際的快取數據獲取和寫入 Redis 的操作。

      • info.getGetDataFunction().apply(info.getQueryData()): 呼叫 RefreshCacheInfo 中定義的數據獲取函數,從數據源獲取最新數據。

      • redisTemplate.opsForValue().set(...): 將獲取的數據存入 Redis,並設定 cacheExpireTime

      • redisTemplate.opsForValue().set(LAST_UPDATE_TIME_KEY_PREFIX + ..., System.currentTimeMillis(), ...): 同步儲存快取的最後更新時間戳,用於 isCacheOutdated 方法判斷。

    • private long getLastUpdateTime(String className):

      • 從 Redis 獲取指定快取的上次更新時間。如果不存在,則返回 0。

    • private boolean isCacheOutdated(RefreshCacheInfo<Q, T> info):

      • 快取過期判斷邏輯。

      • 基於時間戳判斷: currentTime - cacheLastUpdateTime > cacheAgeThreshold:判斷距離上次更新的時間是否超過了 cacheExpireTime。這是在快取過期前進行預刷新的核心邏輯。

      • 自定義邏輯判斷: info.getIsCacheOutdatedFunction().apply(getCacheData(info.getCacheName())): 呼叫 RefreshCacheInfo 中定義的自定義判斷函數。這允許使用者根據業務需求(例如,數據版本號、數據內容變化)來判斷快取是否需要刷新。

    • private boolean acquireLock(String className):

      • 分佈式鎖的獲取。

      • 使用 redisTemplate.opsForValue().setIfAbsent(key, value) 方法,這是一個原子操作,只有當 Key 不存在時才能設置成功。成功設置表示獲取到鎖。

      • 此處為簡化版,真實生產環境的分佈式鎖通常還需要設置過期時間 (TTL) 和防死鎖機制 (例如看門狗)。

    • private void releaseLock(String lockName):

      • 分佈式鎖的釋放。

      • 直接刪除鎖的 Key。同樣,真實環境中釋放鎖還需考慮鎖的持有者是否為當前線程,防止誤刪。

解決快取雪崩的機制

此實現透過以下機制來解決快取雪崩:

  1. 排程預刷新: refreshCacheMain 方法會定期檢查快取,如果快取數據即將達到其 cacheExpireTime,或者根據 isCacheOutdatedFunction 判斷需要更新,就會觸發刷新。這使得快取在過期前就能被更新,避免了大量快取同時失效導致的「雪崩」。

  2. 分佈式鎖: acquireLockreleaseLock 確保了在多個服務實例部署時,針對同一個快取鍵,只有一個實例能夠執行刷新操作。這避免了所有實例同時衝擊資料庫的狀況,即使快取真的失效,也只有一個請求會到達資料庫,其他請求會等待。

  3. 異步執行: ExecutorService 的使用,將實際的刷新邏輯從排程主線程中分離出來,即使單次刷新耗時較長,也不會阻塞排程器本身,保證了排程任務的穩定性。

  4. 初始化預熱: init() 方法確保應用程式啟動時,部分或全部快取數據能夠被載入,提高初期的快取命中率,減少啟動時的資料庫壓力。

Last updated