目的 之前在github上找了一個開源的項目,改了改緩存的擴展,讓其支持在緩存註解上控制緩存失效時間以及多長時間主動在後臺刷新緩存以防止緩存失效( Spring Cache擴展:註解失效時間+主動刷新緩存 )。示意圖如下: 那篇文章存在兩個問題: 所有的配置是建立在修改緩存容器的名稱基礎上,與傳統緩 ...
目的
之前在github上找了一個開源的項目,改了改緩存的擴展,讓其支持在緩存註解上控制緩存失效時間以及多長時間主動在後臺刷新緩存以防止緩存失效( Spring Cache擴展:註解失效時間+主動刷新緩存 )。示意圖如下:
那篇文章存在兩個問題:
- 所有的配置是建立在修改緩存容器的名稱基礎上,與傳統緩存註解的寫法有所區別,後續維護成本會增加;
- 後臺刷新緩存時會存在併發更新的問題
另外,當時項目是基於springboot 1.x,現在springboot2.0對緩存這塊有所調整,需要重新適配。
SpringBoot 2.0對緩存的變動
RedisCacheManager
看看下麵的構造函數,與1.x有比較大的改動,這裡就不貼代碼了。
public RedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) {
this(cacheWriter, defaultCacheConfiguration, true);
}
public RedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration, String... initialCacheNames) {
this(cacheWriter, defaultCacheConfiguration, true, initialCacheNames);
}
RedisCache
既然上層的RedisCacheManager變動了,這裡也就跟著變了。
protected RedisCache(String name, RedisCacheWriter cacheWriter, RedisCacheConfiguration cacheConfig) {
super(cacheConfig.getAllowCacheNullValues());
Assert.notNull(name, "Name must not be null!");
Assert.notNull(cacheWriter, "CacheWriter must not be null!");
Assert.notNull(cacheConfig, "CacheConfig must not be null!");
this.name = name;
this.cacheWriter = cacheWriter;
this.cacheConfig = cacheConfig;
this.conversionService = cacheConfig.getConversionService();
}
方案
針對上述的三個問題,分別應對。
將緩存配置從註解上轉移到初始化緩存的地方
創建一個類用來描述緩存配置,避免在緩存註解上通過非常規手段完成特定的功能。
public class CacheItemConfig implements Serializable {
/**
* 緩存容器名稱
*/
private String name;
/**
* 緩存失效時間
*/
private long expiryTimeSecond;
/**
* 當緩存存活時間達到此值時,主動刷新緩存
*/
private long preLoadTimeSecond;
}
具體的應用參見下麵兩步。
適配springboot 2.0
修改CustomizedRedisCacheManager
構造函數:
public CustomizedRedisCacheManager(
RedisConnectionFactory connectionFactory,
RedisOperations redisOperations,
List<CacheItemConfig> cacheItemConfigList)
參數說明:
- connectionFactory,這是一個redis連接工廠,用於後續操作redis
- redisOperations,這個一個redis的操作實例,具體負責執行redis命令
- cacheItemConfigList,這是緩存的配置,比如名稱,失效時間,主動刷新時間,用於取代在註解上個性化的配置。
具體實現如下:核心思路就是調用RedisCacheManager的構造函數。
private RedisCacheWriter redisCacheWriter;
private RedisCacheConfiguration defaultRedisCacheConfiguration;
private RedisOperations redisOperations;
public CustomizedRedisCacheManager(
RedisConnectionFactory connectionFactory,
RedisOperations redisOperations,
List<CacheItemConfig> cacheItemConfigList) {
this(
RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory),
RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofSeconds(30)),
cacheItemConfigList
.stream()
.collect(Collectors.toMap(CacheItemConfig::getName,cacheItemConfig -> {
RedisCacheConfiguration cacheConfiguration =
RedisCacheConfiguration
.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(cacheItemConfig.getExpiryTimeSecond()))
.prefixKeysWith(cacheItemConfig.getName());
return cacheConfiguration;
}))
);
this.redisOperations=redisOperations;
CacheContainer.init(cacheItemConfigList);
}
public CustomizedRedisCacheManager(
RedisCacheWriter redisCacheWriter
,RedisCacheConfiguration redisCacheConfiguration,
Map<String, RedisCacheConfiguration> redisCacheConfigurationMap) {
super(redisCacheWriter,redisCacheConfiguration,redisCacheConfigurationMap);
this.redisCacheWriter=redisCacheWriter;
this.defaultRedisCacheConfiguration=redisCacheConfiguration;
}
由於我們需要主動刷新緩存,所以需要重寫getCache方法:主要就是將RedisCache構造函數所需要的參數傳遞過去。
@Override
public Cache getCache(String name) {
Cache cache = super.getCache(name);
if(null==cache){
return cache;
}
CustomizedRedisCache redisCache= new CustomizedRedisCache(
name,
this.redisCacheWriter,
this.defaultRedisCacheConfiguration,
this.redisOperations
);
return redisCache;
}
修改CustomizedRedisCache
核心方法就一個,getCache:當獲取到緩存時,實時獲取緩存的存活時間,如果存活時間進入緩存刷新時間範圍即調起非同步任務完成緩存動態載入。ThreadTaskHelper是一個異常任務提交的工具類。下麵方法中的參數key,並不是最終存入redis的key,是@Cacheable註解中的key,要想獲取緩存的存活時間就需要找到真正的key,然後讓redisOptions去調用ttl命令。在springboot 1.5下麵好像有個RedisCacheKey的對象,但在springboot2.0中並未發現,取而代之獲取真正key是通過函數this.createCacheKey來完成。
public ValueWrapper get(final Object key) {
ValueWrapper valueWrapper= super.get(key);
if(null!=valueWrapper){
CacheItemConfig cacheItemConfig=CacheContainer.getCacheItemConfigByCacheName(key.toString());
long preLoadTimeSecond=cacheItemConfig.getPreLoadTimeSecond();
String cacheKey=this.createCacheKey(key);
Long ttl= this.redisOperations.getExpire(cacheKey);
if(null!=ttl&& ttl<=preLoadTimeSecond){
logger.info("key:{} ttl:{} preloadSecondTime:{}",cacheKey,ttl,preLoadTimeSecond);
ThreadTaskHelper.run(new Runnable() {
@Override
public void run() {
logger.info("refresh key:{}", cacheKey);
CustomizedRedisCache.this.getCacheSupport()
.refreshCacheByKey(CustomizedRedisCache.super.getName(), key.toString());
}
});
}
}
return valueWrapper;
}
CacheContainer,這是一個輔助數據存儲,將前面設置的緩存配置放入容器以便後面的邏輯獲取。其中包含一個預設的緩存配置,防止 在未設置的情況導致緩存獲取異常。
public class CacheContainer {
private static final String DEFAULT_CACHE_NAME="default";
private static final Map<String,CacheItemConfig> CACHE_CONFIG_HOLDER=new ConcurrentHashMap(){
{
put(DEFAULT_CACHE_NAME,new CacheItemConfig(){
@Override
public String getName() {
return DEFAULT_CACHE_NAME;
}
@Override
public long getExpiryTimeSecond() {
return 30;
}
@Override
public long getPreLoadTimeSecond() {
return 25;
}
});
}
};
public static void init(List<CacheItemConfig> cacheItemConfigs){
if(CollectionUtils.isEmpty(cacheItemConfigs)){
return;
}
cacheItemConfigs.forEach(cacheItemConfig -> {
CACHE_CONFIG_HOLDER.put(cacheItemConfig.getName(),cacheItemConfig);
});
}
public static CacheItemConfig getCacheItemConfigByCacheName(String cacheName){
if(CACHE_CONFIG_HOLDER.containsKey(cacheName)) {
return CACHE_CONFIG_HOLDER.get(cacheName);
}
return CACHE_CONFIG_HOLDER.get(DEFAULT_CACHE_NAME);
}
public static List<CacheItemConfig> getCacheItemConfigs(){
return CACHE_CONFIG_HOLDER
.values()
.stream()
.filter(new Predicate<CacheItemConfig>() {
@Override
public boolean test(CacheItemConfig cacheItemConfig) {
return !cacheItemConfig.getName().equals(DEFAULT_CACHE_NAME);
}
})
.collect(Collectors.toList());
}
}
修改CacheManager載入方式
由於主動刷新緩存時需要用緩存操作,這裡需要載入RedisTemplate,其實就是後面的RedisOptions介面。序列化機制可心隨意調整。
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(mapper);
template.setValueSerializer(serializer);
template.setKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
載入CacheManager,主要是配置緩存容器,其餘的兩個都是redis所需要的對象。
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory,RedisTemplate<Object, Object> redisTemplate) {
CacheItemConfig productCacheItemConfig=new CacheItemConfig();
productCacheItemConfig.setName("Product");
productCacheItemConfig.setExpiryTimeSecond(10);
productCacheItemConfig.setPreLoadTimeSecond(5);
List<CacheItemConfig> cacheItemConfigs= Lists.newArrayList(productCacheItemConfig);
CustomizedRedisCacheManager cacheManager = new CustomizedRedisCacheManager(connectionFactory,redisTemplate,cacheItemConfigs);
return cacheManager;
}
解決併發刷新緩存的問題
CustomizedRedisCache的get方法,當判斷需要刷新緩存時,後臺起了一個非同步任務去更新緩存,此時如果有N個請求同時訪問同一個緩存,就是發生類似緩存擊穿的情況。為了避免這種情況的發生最好的方法就是加鎖,讓其只有一個任務去做更新的事情。Spring Cache提供了一個同步的參數來支持併發更新控制,這裡我們可以模仿這個思路來處理。
- 將正在進行緩存刷新的KEY放入一個容器,其它線程訪問時如果發現KEY已經存在就直接跳過;
- 緩存刷新完成後從容器中刪除對應的KEY
- 在容器中未發現正在進行緩存刷新的KEY時,利用鎖機制確保只有一個任務執行刷新,類似雙重檢查
public ValueWrapper get(final Object key) {
ValueWrapper valueWrapper= super.get(key);
if(null!=valueWrapper){
CacheItemConfig cacheItemConfig=CacheContainer.getCacheItemConfigByCacheName(key.toString());
long preLoadTimeSecond=cacheItemConfig.getPreLoadTimeSecond();
;
String cacheKey=this.createCacheKey(key);
Long ttl= this.redisOperations.getExpire(cacheKey);
if(null!=ttl&& ttl<=preLoadTimeSecond){
logger.info("key:{} ttl:{} preloadSecondTime:{}",cacheKey,ttl,preLoadTimeSecond);
if(ThreadTaskHelper.hasRunningRefreshCacheTask(cacheKey)){
logger.info("do not need to refresh");
}
else {
ThreadTaskHelper.run(new Runnable() {
@Override
public void run() {
try {
REFRESH_CACKE_LOCK.lock();
if(ThreadTaskHelper.hasRunningRefreshCacheTask(cacheKey)){
logger.info("do not need to refresh");
}
else {
logger.info("refresh key:{}", cacheKey);
CustomizedRedisCache.this.getCacheSupport().refreshCacheByKey(CustomizedRedisCache.super.getName(), key.toString());
ThreadTaskHelper.removeRefreshCacheTask(cacheKey);
}
}
finally {
REFRESH_CACKE_LOCK.unlock();
}
}
});
}
}
}
return valueWrapper;
}
以上方案是在單機情況下,如果是多機也會出現執行多次刷新,但這種代碼是可接受的,如果做到嚴格意義的一次刷新就需要引入分散式鎖,但同時會帶來系統複雜度以及性能消耗,有點得不嘗失的感覺,所以建議單機方式即可。
客戶端配置
這裡不需要在緩存容器名稱上動刀子了,像正規使用Cacheable註解即可。
@Cacheable(value = "Product",key ="#id")
@Override
public Product getById(Long id) {
this.logger.info("get product from db,id:{}",id);
Product product=new Product();
product.setId(id);
return product;
}
本文源碼
文中代碼是依賴上述項目的,如果有不明白的可下載源碼