我們希望將這些rpc結果數據緩存起來,併在一定時間後自動刪除,以實現在一定時間後獲取到最新數據。類似Redis的過期時間。本文是我的調研步驟和開發過程。 ...
前言:在我們的應用中,有一些數據是通過rpc獲取的遠端數據,該數據不會經常變化,允許客戶端在本地緩存一定時間。
該場景邏輯簡單,緩存數據較小,不需要持久化,所以不希望引入其他第三方緩存工具加重應用負擔,非常適合使用Spring Cache來實現。
但有個問題是,我們希望將這些rpc結果數據緩存起來,併在一定時間後自動刪除,以實現在一定時間後獲取到最新數據。類似Redis的過期時間。
接下來是我的調研步驟和開發過程。
Spring Cache 是什麼?
Spring Cache 是 Spring 的一個緩存抽象層,作用是在方法調用時自動緩存返回結果,以提高系統性能和響應速度。
目標是簡化緩存的使用,提供一致的緩存訪問方式,使開發人員能夠輕鬆快速地將緩存添加到應用程式中。
應用於方法級別,在下次調用相同參數的方法時,直接從緩存中獲取結果,而不必執行實際的方法體。
適用場景?
包括但不限於:
- 頻繁訪問的方法調用,可以通過緩存結果來提高性能
- 資料庫查詢結果,可以緩存查詢結果以減少資料庫訪問
- 外部服務調用結果,可以緩存外部服務的響應結果以減少網路開銷
- 計算結果,可以緩存計算結果以加快後續計算速度
優缺點
優點:
- 提高應用的性能,避免重覆計算或查詢。
- 減少對底層資源的訪問,如資料庫或遠程服務,從而減輕負載。
- 簡化代碼,通過註解的方式實現緩存邏輯,而不需要手動編寫緩存代碼。
缺點:
- 需要占用一定的記憶體空間來存儲緩存數據。
- 可能導致數據不一致問題,如果緩存的數據發生變化,但緩存沒有及時更新,可能會導致臟數據的問題。(所以需要及時更新緩存)
- 可能引發緩存穿透問題,當大量請求同時訪問一個不存在於緩存中的鍵時,會導致請求直接落到底層資源,增加負載。
重要組件
-
CacheManager:緩存管理器,用於創建、配置和管理緩存對象。可以配置具體的緩存實現,如 Ehcache、Redis。
-
Cache:緩存對象,用於存儲緩存數據,提供了讀取、寫入和刪除緩存數據的方法。
-
常用註解:
- @Cacheable:被調用時,會檢查緩存中是否已存在,若有,則直接返回緩存結果,否則執行方法並將結果存入緩存,適用於只讀操作。
- @CachePut:則每次都會執行方法體,並將結果存入緩存,即每次都會更新緩存中的數據,適用於寫操作。
- @CacheEvict:被調用時,Spring Cache 會清除對應的緩存數據。
使用方式
- 配置緩存管理器(CacheManager):使用
@EnableCaching
註解啟用緩存功能,並配置具體的緩存實現。 - 在方法上添加緩存註解:使用
@Cacheable
、@CacheEvict
、@CachePut
等註解標記需要被緩存的方法。 - 調用被緩存的方法:當調用被標記為緩存的方法時,Spring Cache 會檢查緩存中是否已有該方法的緩存結果。
- 根據緩存結果返回數據:如果緩存中已有結果,則直接從緩存中返回;否則,執行方法並將結果存入緩存。
- 根據需要清除或更新緩存:使用
@CacheEvict
、@CachePut
註解可以在方法調用後清除或更新緩存。
通過以上步驟,Spring Cache 可以自動管理緩存的讀寫操作,從而簡化緩存的使用和管理。
Spring Boot預設使用哪種實現,及其優缺點:
Spring Boot預設使用ConcurrentMapCacheManager
作為緩存管理器的實現,適用於簡單的、單機的、對緩存容量要求較小的應用場景。
-
優點:
- 簡單輕量:沒有外部依賴,適用於簡單的應用場景。
- 記憶體存儲:緩存數據存儲在記憶體中的
ConcurrentMap
中,讀寫速度快,適用於快速訪問和頻繁更新的數據。 - 多緩存實例支持:支持配置多個命名緩存實例,每個實例使用獨立的
ConcurrentMap
存儲數據,可以根據不同的需求配置多個緩存實例。
-
缺點:
- 單機應用限制:
ConcurrentMapCacheManager
適用於單機應用,緩存數據存儲在應用的記憶體中,無法實現分散式緩存。 - 有限的容量:由於緩存數據存儲在記憶體中,
ConcurrentMapCacheManager
的容量受限於應用的記憶體大小,對於大規模數據或高併發訪問的場景可能存在容量不足的問題。 - 缺乏持久化支持:
ConcurrentMapCacheManager
不支持將緩存數據持久化到磁碟或其他外部存儲介質,應用重啟後緩存數據會丟失。
- 單機應用限制:
如何讓ConcurrentMapCacheManager
支持過期自動刪除
前言也提到了,我們的場景邏輯簡單,緩存數據較小,不需要持久化,不希望引入其他第三方緩存工具加重應用負擔,適合使用ConcurrentMapCacheManager
。所以擴展下ConcurrentMapCacheManager
也許是最簡單的實現。
方案設計
為此,我設計了三種方案:
- 開啟定時任務,掃描緩存,定時刪除所有緩存;該方式簡單粗暴,統一定時刪除,但不能針對單條數據進行過期操作。
- 開啟定時任務,掃描緩存,並將單條過期的緩存數據刪除。
- 訪問緩存數據之前,判斷是否過期,若過期則重新執行方法體,並將結果覆蓋原緩存數據。
上述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;
其關係圖為:
以下為測試代碼,為一個查詢增加緩存操作: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
,進而進入ConcurrentMapCacheManager
的getCache
方法,獲取對應的緩存實例,若不存在,則生成一個。
然後從緩存實例中查找緩存數據,找到則返回,找不到則執行目標方法。
執行完目標方法後,將返回結果放到緩存中。
實現自動過期刪除
根據上面的代碼跟蹤可以發現,緩存數據key/value存放在具體的緩存實例ConcurrentMapCache
的store
中,且get和put前後,有我可以操作的空間。
- 那麼,如果我將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;
}
}
- 自定義一個
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);
}
}
- 自定義緩存管理器,將自定義的
ExpireCache
,替換預設的ConcurrentMapCache
/**
* 緩存管理器
*/
public class ExpireCacheManager extends ConcurrentMapCacheManager {
@Override
protected Cache createConcurrentMapCache(String name) {
return new ExpireCache(name);
}
}
- 將自定義的緩存管理器
ExpireCacheManager
註入到容器中
@Configuration
class ExpireCacheConfiguration {
@Bean
public ExpireCacheManager cacheManager() {
ExpireCacheManager cacheManager = new ExpireCacheManager();
return cacheManager;
}
}
- 開啟定時任務,自動刪除過期緩存
/**
* 定時執行刪除過期緩存
*/
@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~
作者:京東科技 郭艷紅
來源:京東雲開發者社區