Spring緩存是如何實現的?如何擴展使其支持過期刪除功能?

来源:https://www.cnblogs.com/Jcloud/archive/2023/08/21/17646263.html
-Advertisement-
Play Games

我們希望將這些rpc結果數據緩存起來,併在一定時間後自動刪除,以實現在一定時間後獲取到最新數據。類似Redis的過期時間。本文是我的調研步驟和開發過程。 ...


前言:在我們的應用中,有一些數據是通過rpc獲取的遠端數據,該數據不會經常變化,允許客戶端在本地緩存一定時間。

該場景邏輯簡單,緩存數據較小,不需要持久化,所以不希望引入其他第三方緩存工具加重應用負擔,非常適合使用Spring Cache來實現。

但有個問題是,我們希望將這些rpc結果數據緩存起來,併在一定時間後自動刪除,以實現在一定時間後獲取到最新數據。類似Redis的過期時間。

接下來是我的調研步驟和開發過程。

Spring Cache 是什麼?

Spring Cache 是 Spring 的一個緩存抽象層,作用是在方法調用時自動緩存返回結果,以提高系統性能和響應速度。

目標是簡化緩存的使用,提供一致的緩存訪問方式,使開發人員能夠輕鬆快速地將緩存添加到應用程式中。

應用於方法級別,在下次調用相同參數的方法時,直接從緩存中獲取結果,而不必執行實際的方法體。

適用場景?

包括但不限於:

  • 頻繁訪問的方法調用,可以通過緩存結果來提高性能
  • 資料庫查詢結果,可以緩存查詢結果以減少資料庫訪問
  • 外部服務調用結果,可以緩存外部服務的響應結果以減少網路開銷
  • 計算結果,可以緩存計算結果以加快後續計算速度

優缺點

優點:

  • 提高應用的性能,避免重覆計算或查詢。
  • 減少對底層資源的訪問,如資料庫或遠程服務,從而減輕負載。
  • 簡化代碼,通過註解的方式實現緩存邏輯,而不需要手動編寫緩存代碼。

缺點:

  • 需要占用一定的記憶體空間來存儲緩存數據。
  • 可能導致數據不一致問題,如果緩存的數據發生變化,但緩存沒有及時更新,可能會導致臟數據的問題。(所以需要及時更新緩存)
  • 可能引發緩存穿透問題,當大量請求同時訪問一個不存在於緩存中的鍵時,會導致請求直接落到底層資源,增加負載。

重要組件

  1. CacheManager:緩存管理器,用於創建、配置和管理緩存對象。可以配置具體的緩存實現,如 Ehcache、Redis。

  2. Cache:緩存對象,用於存儲緩存數據,提供了讀取、寫入和刪除緩存數據的方法。

  3. 常用註解:

    • @Cacheable:被調用時,會檢查緩存中是否已存在,若有,則直接返回緩存結果,否則執行方法並將結果存入緩存,適用於只讀操作。
    • @CachePut:則每次都會執行方法體,並將結果存入緩存,即每次都會更新緩存中的數據,適用於寫操作。
    • @CacheEvict:被調用時,Spring Cache 會清除對應的緩存數據。

使用方式

  1. 配置緩存管理器(CacheManager):使用 @EnableCaching 註解啟用緩存功能,並配置具體的緩存實現。
  2. 在方法上添加緩存註解:使用 @Cacheable@CacheEvict@CachePut 等註解標記需要被緩存的方法。
  3. 調用被緩存的方法:當調用被標記為緩存的方法時,Spring Cache 會檢查緩存中是否已有該方法的緩存結果。
  4. 根據緩存結果返回數據:如果緩存中已有結果,則直接從緩存中返回;否則,執行方法並將結果存入緩存。
  5. 根據需要清除或更新緩存:使用 @CacheEvict@CachePut 註解可以在方法調用後清除或更新緩存。
    通過以上步驟,Spring Cache 可以自動管理緩存的讀寫操作,從而簡化緩存的使用和管理。

Spring Boot預設使用哪種實現,及其優缺點:

Spring Boot預設使用ConcurrentMapCacheManager作為緩存管理器的實現,適用於簡單的、單機的、對緩存容量要求較小的應用場景。

  • 優點:

    1. 簡單輕量:沒有外部依賴,適用於簡單的應用場景。
    2. 記憶體存儲:緩存數據存儲在記憶體中的ConcurrentMap中,讀寫速度快,適用於快速訪問和頻繁更新的數據。
    3. 多緩存實例支持:支持配置多個命名緩存實例,每個實例使用獨立的ConcurrentMap存儲數據,可以根據不同的需求配置多個緩存實例。
  • 缺點:

    1. 單機應用限制:ConcurrentMapCacheManager適用於單機應用,緩存數據存儲在應用的記憶體中,無法實現分散式緩存。
    2. 有限的容量:由於緩存數據存儲在記憶體中,ConcurrentMapCacheManager的容量受限於應用的記憶體大小,對於大規模數據或高併發訪問的場景可能存在容量不足的問題。
    3. 缺乏持久化支持:ConcurrentMapCacheManager不支持將緩存數據持久化到磁碟或其他外部存儲介質,應用重啟後緩存數據會丟失。

如何讓ConcurrentMapCacheManager支持過期自動刪除

前言也提到了,我們的場景邏輯簡單,緩存數據較小,不需要持久化,不希望引入其他第三方緩存工具加重應用負擔,適合使用ConcurrentMapCacheManager。所以擴展下ConcurrentMapCacheManager也許是最簡單的實現。

方案設計

為此,我設計了三種方案:

  1. 開啟定時任務,掃描緩存,定時刪除所有緩存;該方式簡單粗暴,統一定時刪除,但不能針對單條數據進行過期操作。
  2. 開啟定時任務,掃描緩存,並將單條過期的緩存數據刪除。
  3. 訪問緩存數據之前,判斷是否過期,若過期則重新執行方法體,並將結果覆蓋原緩存數據。

上述2、3方案都更貼近目標,且都有一個共同的難點,即如何判斷該緩存是否過期?或如何存放緩存的過期時間?
既然沒有好辦法,那就走一波源碼找找思路吧!

源碼解析

ConcurrentMapCacheManager 中定義了一個cacheMap(如下代碼),用於存儲所有緩存名及對應緩存對象。

private final ConcurrentMap<String, Cache> cacheMap = new ConcurrentHashMap<>(16);

cacheMap 中的存放的Cache的具體類型為ConcurrentMapCache
ConcurrentMapCache的內部定義了一個store(如下代碼),用於存儲該緩存下所有key、value,即真正的緩存數據。

private final ConcurrentMap<Object, Object> store;

其關係圖為:

img_2.png

以下為測試代碼,為一個查詢增加緩存操作:cacheName=getUsersByName,key為參數name的值,value為查詢用戶集合。

@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private UserMapper userMapper;

    @Override
    @Cacheable(value = "getUsersByName", key = "#name")
    public List<GyhUser> getUsersByName(String name) {
        return userMapper.getUsersByName(name);
    }
}

當程式調用到此方法前,會自動進入緩存攔截器CacheInterceptor,進而進入ConcurrentMapCacheManagergetCache方法,獲取對應的緩存實例,若不存在,則生成一個。

img_1.png
然後從緩存實例中查找緩存數據,找到則返回,找不到則執行目標方法。
img_3.png

執行完目標方法後,將返回結果放到緩存中。
img_4.png

實現自動過期刪除

根據上面的代碼跟蹤可以發現,緩存數據key/value存放在具體的緩存實例ConcurrentMapCachestore中,且get和put前後,有我可以操作的空間。

  1. 那麼,如果我將value重新包裝一下,將緩存時間封裝進去,併在get和put前後,將真正的緩存數據解析出來,供開發者使用,是否可以實現呢?說乾就乾!
/**
 * 緩存數據包裝類,保證緩存數據及插入時間
 */
public class ExpireCacheWrap {
    /**
     * 緩存數據
     */
    private final Object value;
    /**
     * 插入時間
     */
    private final Long insertTime;

    public ExpireCacheWrap(Object value, Long insertTime) {
        this.value = value;
        this.insertTime = insertTime;
    }

    public Object getValue() {
        return value;
    }

    public Long getInsertTime() {
        return this.insertTime;
    }
}
  1. 自定義一個Cache類,繼承ConcurrentMapCache,擴展get、put方法,實現對緩存時間的記錄和解析
/**
 * 緩存過期刪除
 */
public class ExpireCache extends ConcurrentMapCache {
    public ExpireCache(String name) {
        super(name);
    }

    @Override
    public ValueWrapper get(Object key) {
        // 解析緩存對象時,拿到value,去掉插入時間。對於業務中緩存的使用邏輯無感知無侵入,無需調整相關代碼
        ValueWrapper valueWrapper = super.get(key);
        if (valueWrapper == null) {
            return null;
        }
        Object storeValue = valueWrapper.get();
        storeValue = storeValue != null ? ((ExpireCacheWrap) storeValue).getValue() : null;
        return super.toValueWrapper(storeValue);
    }

    @Override
    public void put(Object key, @Nullable Object value) {
        // 插入緩存對象時,封裝對象信息:緩存內容+插入時間
        value = new ExpireCacheWrap(value, System.currentTimeMillis());
        super.put(key, value);
    }
}
  1. 自定義緩存管理器,將自定義的ExpireCache,替換預設的ConcurrentMapCache
/**
 * 緩存管理器
 */
public class ExpireCacheManager extends ConcurrentMapCacheManager {
    @Override
    protected Cache createConcurrentMapCache(String name) {
        return new ExpireCache(name);
    }
}
  1. 將自定義的緩存管理器ExpireCacheManager註入到容器中
@Configuration
class ExpireCacheConfiguration {
    @Bean
    public ExpireCacheManager cacheManager() {
        ExpireCacheManager cacheManager = new ExpireCacheManager();
        return cacheManager;
    }
}
  1. 開啟定時任務,自動刪除過期緩存
/**
 * 定時執行刪除過期緩存
 */
@Component
@Slf4j
public class ExpireCacheEvictJob {

    @Autowired
    private ExpireCacheManager cacheManager;
    /**
     * 緩存名與緩存時間
     */
    private static Map<String, Long> cacheNameExpireMap;
    // 可以優化到配置文件或字典中
    static {
        cacheNameExpireMap = new HashMap<>(5);
        cacheNameExpireMap.put("getUserById", 180000L);
        cacheNameExpireMap.put("getUsersByName", 300000L);
    }

    /**
     * 5分鐘執行一次
     */
    @Scheduled(fixedRate = 300000)
    public void cacheEvict() {
        Long now = System.currentTimeMillis();
        // 獲取所有緩存
        Collection<String> cacheNames = cacheManager.getCacheNames();
        for (String cacheName : cacheNames) {
            // 該類緩存設置的過期時間
            Long expire = cacheNameExpireMap.get(cacheName);
            // 獲取該緩存的緩存內容集合
            Cache cache = cacheManager.getCache(cacheName);
            ConcurrentMap<Object, Object> store = (ConcurrentMap) cache.getNativeCache();
            Set<Object> keySet = store.keySet();
            // 迴圈獲取緩存鍵值對,根據value中存儲的插入時間,判斷key是否已過期,過期則刪除
            keySet.stream().forEach(key -> {
                // 緩存內容包裝對象
                ExpireCacheWrap value = (ExpireCacheWrap) store.get(key);
                // 緩存內容插入時間
                Long insertTime = value.getInsertTime();
                if ((insertTime + expire) < now) {
                    cache.evict(key);
                    log.info("key={},insertTime={},expire={},過期刪除", key, insertTime, expire);
                }
            });
        }

    }
}

通過以上操作,實現了讓ConcurrentMapCacheManager支持過期自動刪除,並且對開發者
基本無感知無侵入,只需要在配置文件中配置緩存時間即可。

但是如果我的項目已經支持了第三方緩存如Redis,秉著不用白不用的原則,又該如何將該功能嫁接到Redis上呢?

正正好我們的項目最近在引入R2m,就試著搞一下吧-

未完待續~ Thanks~

作者:京東科技 郭艷紅

來源:京東雲開發者社區


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • ## 一、前言 - 還在為排查Java程式線上問題頭痛嗎,看我們用阿裡開源的診斷神器 Arthas 來幫您 - 本文開篇主要介紹 阿裡開源的診斷神器Arthas 3.7.0版本,watch、jad、classloader 命令,以 Debian 11、openjdk 11 為例 ## 二、Artha ...
  • ## 題目描述 給你兩個 非空 的鏈表,表示兩個非負的整數。它們每位數字都是按照 逆序 的方式存儲的,並且每個節點只能存儲 一位 數字。請你將兩個數相加,並以相同形式返回一個表示和的鏈表。你可以假設除了數字 0 之外,這兩個數都不會以 0 開頭。 ## 例子 > 輸入:l1 = [2,4,3], l ...
  • 虛擬機類載入的時機和過程 一、類載入的時機 類從被載入到虛擬機記憶體中開始,到卸載出記憶體為止,它的整個生命周期包括:載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載( ...
  • - Future :獲取非同步返回的結果需要使用輪詢的方式,消耗cup ```java ExecutorService executorService = Executors.newFixedThreadPool(10); Future future = executorService.submit( ...
  • 本文閱讀nacos-2.0.2的config源碼,編寫示例,分析推送配置、監聽配置的原理。 # 客戶端 ## 創建NacosConfigService對象 ```java Properties properties = new Properties(); properties.setProperty ...
  • 本教程將演示用Python開發一個簡單的數字猜測游戲的過程。 ### 競猜游戲的機制 我們正試圖開發一個游戲,從用戶那裡獲取上限和下限,在這個範圍內生成一個隨機數,要求用戶猜測這個數字,並計算用戶用了多少條線索才猜對了。這個游戲將只基於CLI。 ### 使用Python中的random 模塊的數字猜 ...
  • 如果物理實體有很多,那每個實體都要判斷和其他實體是否發生碰撞。有沒有比較簡便的方法呢,可以使用二進位與位掩碼,設置實體的類別,然後用位掩碼計算來得到兩者是否發生碰撞的結果。另外LOVE還提供了一個組別的功能,可以直接跳過計算結果,強制兩者發生碰撞和強制不發生碰撞 ...
  • ### 1、% - 運算符 %表示取模運算,也就是取餘數。 例如 6 % 4 = 2 ### 2、% - 引導符/占位符 引導符用於控制輸入輸出的格式。常見於printf("%d",a);scanf("%d",&a);語句。 1) %s - 字元串 (String) 2) %c - 字元 (Char ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...