Spring Boot快取實戰 Redis 設定有效時間和自動重新整理快取,時間支援在配置檔案中配置
問題描述
Spring Cache提供的@Cacheable註解不支援配置過期時間,還有快取的自動重新整理。
我們可以通過配置CacheManneg來配置預設的過期時間和針對每個快取容器(value)單獨配置過期時間,但是總是感覺不太靈活。下面是一個示例:
@Bean
public CacheManager cacheManager(RedisTemplate redisTemplate) {
RedisCacheManager cacheManager= new RedisCacheManager(redisTemplate);
cacheManager.setDefaultExpiration(60 );
Map<String,Long> expiresMap=new HashMap<>();
expiresMap.put("Product",5L);
cacheManager.setExpires(expiresMap);
return cacheManager;
}
我們想在註解上直接配置過期時間和自動重新整理時間,就像這樣:
@Cacheable(value = "people#120#90", key = "#person.id")
public Person findOne(Person person) {
Person p = personRepository.findOne(person.getId());
System.out.println("為id、key為:" + p.getId() + "資料做了快取");
return p;
}
value屬性上用#號隔開,第一個是原始的快取容器名稱,第二個是快取的有效時間,第三個是快取的自動重新整理時間,單位都是秒。
快取的有效時間和自動重新整理時間支援SpEl表示式,支援在配置檔案中配置,如:
@Cacheable(value = "people#${select.cache.timeout:1800}#${select.cache.refresh:600}", key = "#person.id", sync = true)//3
public Person findOne(Person person) {
Person p = personRepository.findOne(person.getId());
System.out.println("為id、key為:" + p.getId() + "資料做了快取");
return p;
}
解決思路
檢視原始碼你會發現快取最頂級的介面就是CacheManager和Cache介面。
CacheManager說明
CacheManager功能其實很簡單就是管理cache,介面只有兩個方法,根據容器名稱獲取一個Cache。還有就是返回所有的快取名稱。
public interface CacheManager {
/**
* 根據名稱獲取一個Cache(在實現類裡面是如果有這個Cache就返回,沒有就新建一個Cache放到Map容器中)
* @param name the cache identifier (must not be {@code null})
* @return the associated cache, or {@code null} if none found
*/
Cache getCache(String name);
/**
* 返回一個快取名稱的集合
* @return the names of all caches known by the cache manager
*/
Collection<String> getCacheNames();
}
Cache說明
Cache介面主要是操作快取的。get根據快取key從快取伺服器獲取快取中的值,put根據快取key將資料放到快取伺服器,evict根據key刪除快取中的資料。
public interface Cache {
ValueWrapper get(Object key);
void put(Object key, Object value);
void evict(Object key);
...
}
請求步驟
- 請求進來,在方法上面掃描@Cacheable註解,那麼會觸發org.springframework.cache.interceptor.CacheInterceptor快取的攔截器。
- 然後會呼叫CacheManager的getCache方法,獲取Cache,如果沒有(第一次訪問)就新建一Cache並返回。
- 根據獲取到的Cache去呼叫get方法獲取快取中的值。RedisCache這裡有個bug,原始碼是先判斷key是否存在,再去快取獲取值,在高併發下有bug。
程式碼分析
在最上面我們說了Spring Cache可以通過配置CacheManager來配置過期時間。那麼這個過期時間是在哪裡用的呢?設定預設的時間setDefaultExpiration,根據特定名稱設定有效時間setExpires,獲取一個快取名稱(value屬性)的有效時間computeExpiration,真正使用有效時間是在createCache方法裡面,而這個方法是在父類的getCache方法呼叫。通過RedisCacheManager原始碼我們看到:
// 設定預設的時間
public void setDefaultExpiration(long defaultExpireTime) {
this.defaultExpiration = defaultExpireTime;
}
// 根據特定名稱設定有效時間
public void setExpires(Map<String, Long> expires) {
this.expires = (expires != null ? new ConcurrentHashMap<String, Long>(expires) : null);
}
// 獲取一個key的有效時間
protected long computeExpiration(String name) {
Long expiration = null;
if (expires != null) {
expiration = expires.get(name);
}
return (expiration != null ? expiration.longValue() : defaultExpiration);
}
@SuppressWarnings("unchecked")
protected RedisCache createCache(String cacheName) {
// 呼叫了上面的方法獲取快取名稱的有效時間
long expiration = computeExpiration(cacheName);
// 建立了Cache物件,並使用了這個有效時間
return new RedisCache(cacheName, (usePrefix ? cachePrefix.prefix(cacheName) : null), redisOperations, expiration,
cacheNullValues);
}
// 重寫父類的getMissingCache。去建立Cache
@Override
protected Cache getMissingCache(String name) {
return this.dynamic ? createCache(name) : null;
}
AbstractCacheManager父類原始碼:
// 根據名稱獲取Cache如果沒有呼叫getMissingCache方法,生成新的Cache,並將其放到Map容器中去。
@Override
public Cache getCache(String name) {
Cache cache = this.cacheMap.get(name);
if (cache != null) {
return cache;
}
else {
// Fully synchronize now for missing cache creation...
synchronized (this.cacheMap) {
cache = this.cacheMap.get(name);
if (cache == null) {
// 如果沒找到Cache呼叫該方法,這個方法預設返回值NULL由子類自己實現。上面的就是子類自己實現的方法
cache = getMissingCache(name);
if (cache != null) {
cache = decorateCache(cache);
this.cacheMap.put(name, cache);
updateCacheNames(name);
}
}
return cache;
}
}
}
由此這個有效時間的設定關鍵就是在getCache方法上,這裡的name引數就是我們註解上的value屬性。所以在這裡解析這個特定格式的名稱我就可以拿到配置的過期時間和重新整理時間。getMissingCache方法裡面在新建快取的時候將這個過期時間設定進去,生成的Cache物件操作快取的時候就會帶上我們的配置的過期時間,然後過期就生效了。解析SpEL表示式獲取配置檔案中的時間也在也一步完成。
CustomizedRedisCacheManager原始碼:
package com.xiaolyuh.redis.cache;
import com.xiaolyuh.redis.cache.helper.SpringContextHolder;
import com.xiaolyuh.redis.utils.ReflectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.cache.Cache;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.core.RedisOperations;
import java.util.Collection;
import java.util.concurrent.ConcurrentHashMap;
/**
* 自定義的redis快取管理器
* 支援方法上配置過期時間
* 支援熱載入快取:快取即將過期時主動重新整理快取
*
* @author yuhao.wang
*/
public class CustomizedRedisCacheManager extends RedisCacheManager {
private static final Logger logger = LoggerFactory.getLogger(CustomizedRedisCacheManager.class);
/**
* 父類cacheMap欄位
*/
private static final String SUPER_FIELD_CACHEMAP = "cacheMap";
/**
* 父類dynamic欄位
*/
private static final String SUPER_FIELD_DYNAMIC = "dynamic";
/**
* 父類cacheNullValues欄位
*/
private static final String SUPER_FIELD_CACHENULLVALUES = "cacheNullValues";
/**
* 父類updateCacheNames方法
*/
private static final String SUPER_METHOD_UPDATECACHENAMES = "updateCacheNames";
/**
* 快取引數的分隔符
* 陣列元素0=快取的名稱
* 陣列元素1=快取過期時間TTL
* 陣列元素2=快取在多少秒開始主動失效來強制重新整理
*/
private static final String SEPARATOR = "#";
/**
* SpEL標示符
*/
private static final String MARK = "$";
RedisCacheManager redisCacheManager = null;
@Autowired
DefaultListableBeanFactory beanFactory;
public CustomizedRedisCacheManager(RedisOperations redisOperations) {
super(redisOperations);
}
public CustomizedRedisCacheManager(RedisOperations redisOperations, Collection<String> cacheNames) {
super(redisOperations, cacheNames);
}
public RedisCacheManager getInstance() {
if (redisCacheManager == null) {
redisCacheManager = SpringContextHolder.getBean(RedisCacheManager.class);
}
return redisCacheManager;
}
@Override
public Cache getCache(String name) {
String[] cacheParams = name.split(SEPARATOR);
String cacheName = cacheParams[0];
if (StringUtils.isBlank(cacheName)) {
return null;
}
// 有效時間,初始化獲取預設的有效時間
Long expirationSecondTime = getExpirationSecondTime(cacheName, cacheParams);
// 自動重新整理時間,預設是0
Long preloadSecondTime = getExpirationSecondTime(cacheParams);
// 通過反射獲取父類存放快取的容器物件
Object object = ReflectionUtils.getFieldValue(getInstance(), SUPER_FIELD_CACHEMAP);
if (object != null && object instanceof ConcurrentHashMap) {
ConcurrentHashMap<String, Cache> cacheMap = (ConcurrentHashMap<String, Cache>) object;
// 生成Cache物件,並將其儲存到父類的Cache容器中
return getCache(cacheName, expirationSecondTime, preloadSecondTime, cacheMap);
} else {
return super.getCache(cacheName);
}
}
/**
* 獲取過期時間
*
* @return
*/
private long getExpirationSecondTime(String cacheName, String[] cacheParams) {
// 有效時間,初始化獲取預設的有效時間
Long expirationSecondTime = this.computeExpiration(cacheName);
// 設定key有效時間
if (cacheParams.length > 1) {
String expirationStr = cacheParams[1];
if (!StringUtils.isEmpty(expirationStr)) {
// 支援配置過期時間使用EL表示式讀取配置檔案時間
if (expirationStr.contains(MARK)) {
expirationStr = beanFactory.resolveEmbeddedValue(expirationStr);
}
expirationSecondTime = Long.parseLong(expirationStr);
}
}
return expirationSecondTime;
}
/**
* 獲取自動重新整理時間
*
* @return
*/
private long getExpirationSecondTime(String[] cacheParams) {
// 自動重新整理時間,預設是0
Long preloadSecondTime = 0L;
// 設定自動重新整理時間
if (cacheParams.length > 2) {
String preloadStr = cacheParams[2];
if (!StringUtils.isEmpty(preloadStr)) {
// 支援配置重新整理時間使用EL表示式讀取配置檔案時間
if (preloadStr.contains(MARK)) {
preloadStr = beanFactory.resolveEmbeddedValue(preloadStr);
}
preloadSecondTime = Long.parseLong(preloadStr);
}
}
return preloadSecondTime;
}
/**
* 重寫父類的getCache方法,真假了三個引數
*
* @param cacheName 快取名稱
* @param expirationSecondTime 過期時間
* @param preloadSecondTime 自動重新整理時間
* @param cacheMap 通過反射獲取的父類的cacheMap物件
* @return Cache
*/
public Cache getCache(String cacheName, long expirationSecondTime, long preloadSecondTime, ConcurrentHashMap<String, Cache> cacheMap) {
Cache cache = cacheMap.get(cacheName);
if (cache != null) {
return cache;
} else {
// Fully synchronize now for missing cache creation...
synchronized (cacheMap) {
cache = cacheMap.get(cacheName);
if (cache == null) {
// 呼叫我們自己的getMissingCache方法建立自己的cache
cache = getMissingCache(cacheName, expirationSecondTime, preloadSecondTime);
if (cache != null) {
cache = decorateCache(cache);
cacheMap.put(cacheName, cache);
// 反射去執行父類的updateCacheNames(cacheName)方法
Class<?>[] parameterTypes = {String.class};
Object[] parameters = {cacheName};
ReflectionUtils.invokeMethod(getInstance(), SUPER_METHOD_UPDATECACHENAMES, parameterTypes, parameters);
}
}
return cache;
}
}
}
/**
* 建立快取
*
* @param cacheName 快取名稱
* @param expirationSecondTime 過期時間
* @param preloadSecondTime 制動重新整理時間
* @return
*/
public CustomizedRedisCache getMissingCache(String cacheName, long expirationSecondTime, long preloadSecondTime) {
logger.info("快取 cacheName:{},過期時間:{}, 自動重新整理時間:{}", cacheName, expirationSecondTime, preloadSecondTime);
Boolean dynamic = (Boolean) ReflectionUtils.getFieldValue(getInstance(), SUPER_FIELD_DYNAMIC);
Boolean cacheNullValues = (Boolean) ReflectionUtils.getFieldValue(getInstance(), SUPER_FIELD_CACHENULLVALUES);
return dynamic ? new CustomizedRedisCache(cacheName, (this.isUsePrefix() ? this.getCachePrefix().prefix(cacheName) : null),
this.getRedisOperations(), expirationSecondTime, preloadSecondTime, cacheNullValues) : null;
}
}
那自動重新整理時間呢?
在RedisCache的屬性裡面沒有重新整理時間,所以我們繼承該類重寫我們自己的Cache的時候要多加一個屬性preloadSecondTime來儲存這個重新整理時間。並在getMissingCache方法建立Cache物件的時候指定該值。
CustomizedRedisCache部分原始碼:
/**
* 快取主動在失效前強制重新整理快取的時間
* 單位:秒
*/
private long preloadSecondTime = 0;
// 重寫後的構造方法
public CustomizedRedisCache(String name, byte[] prefix, RedisOperations<? extends Object, ? extends Object> redisOperations, long expiration, long preloadSecondTime) {
super(name, prefix, redisOperations, expiration);
this.redisOperations = redisOperations;
// 指定自動重新整理時間
this.preloadSecondTime = preloadSecondTime;
this.prefix = prefix;
}
// 重寫後的構造方法
public CustomizedRedisCache(String name, byte[] prefix, RedisOperations<? extends Object, ? extends Object> redisOperations, long expiration, long preloadSecondTime, boolean allowNullValues) {
super(name, prefix, redisOperations, expiration, allowNullValues);
this.redisOperations = redisOperations;
// 指定自動重新整理時間
this.preloadSecondTime = preloadSecondTime;
this.prefix = prefix;
}
那麼這個自動重新整理時間有了,怎麼來讓他自動重新整理呢?
在呼叫Cache的get方法的時候我們都會去快取服務查詢快取,這個時候我們在多查一個快取的有效時間,和我們配置的自動重新整理時間對比,如果快取的有效時間小於這個自動重新整理時間我們就去重新整理快取(這裡注意一點在高併發下我們最好只放一個請求去重新整理資料,儘量減少資料的壓力,所以在這個位置加一個分散式鎖)。所以我們重寫這個get方法。
CustomizedRedisCache部分原始碼:
/**
* 重寫get方法,獲取到快取後再次取快取剩餘的時間,如果時間小余我們配置的重新整理時間就手動重新整理快取。
* 為了不影響get的效能,啟用後臺執行緒去完成快取的刷。
* 並且只放一個執行緒去重新整理資料。
*
* @param key
* @return
*/
@Override
public ValueWrapper get(final Object key) {
RedisCacheKey cacheKey = getRedisCacheKey(key);
String cacheKeyStr = new String(cacheKey.getKeyBytes());
// 呼叫重寫後的get方法
ValueWrapper valueWrapper = this.get(cacheKey);
if (null != valueWrapper) {
// 重新整理快取資料
refreshCache(key, cacheKeyStr);
}
return valueWrapper;
}
/**
* 重寫父類的get函式。
* 父類的get方法,是先使用exists判斷key是否存在,不存在返回null,存在再到redis快取中去取值。這樣會導致併發問題,
* 假如有一個請求呼叫了exists函式判斷key存在,但是在下一時刻這個快取過期了,或者被刪掉了。
* 這時候再去快取中獲取值的時候返回的就是null了。
* 可以先獲取快取的值,再去判斷key是否存在。
*
* @param cacheKey
* @return
*/
@Override
public RedisCacheElement get(final RedisCacheKey cacheKey) {
Assert.notNull(cacheKey, "CacheKey must not be null!");
// 根據key獲取快取值
RedisCacheElement redisCacheElement = new RedisCacheElement(cacheKey, fromStoreValue(lookup(cacheKey)));
// 判斷key是否存在
Boolean exists = (Boolean) redisOperations.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
return connection.exists(cacheKey.getKeyBytes());
}
});
if (!exists.booleanValue()) {
return null;
}
return redisCacheElement;
}
/**
* 重新整理快取資料
*/
private void refreshCache(Object key, String cacheKeyStr) {
Long ttl = this.redisOperations.getExpire(cacheKeyStr);
if (null != ttl && ttl <= CustomizedRedisCache.this.preloadSecondTime) {
// 儘量少的去開啟執行緒,因為執行緒池是有限的
ThreadTaskHelper.run(new Runnable() {
@Override
public void run() {
// 加一個分散式鎖,只放一個請求去重新整理快取
RedisLock redisLock = new RedisLock((RedisTemplate) redisOperations, cacheKeyStr + "_lock");
try {
if (redisLock.lock()) {
// 獲取鎖之後再判斷一下過期時間,看是否需要載入資料
Long ttl = CustomizedRedisCache.this.redisOperations.getExpire(cacheKeyStr);
if (null != ttl && ttl <= CustomizedRedisCache.this.preloadSecondTime) {
// 通過獲取代理方法資訊重新載入快取資料
CustomizedRedisCache.this.getCacheSupport().refreshCacheByKey(CustomizedRedisCache.super.getName(), key.toString());
}
}
} catch (Exception e) {
logger.info(e.getMessage(), e);
} finally {
redisLock.unlock();
}
}
});
}
}
那麼自動重新整理肯定要掉用方法訪問資料庫,獲取值後去重新整理快取。這時我們又怎麼能去呼叫方法呢?
我們利用java的反射機制。所以我們要用一個容器來存放快取方法的方法資訊,包括物件,方法名稱,引數等等。我們建立了CachedInvocation類來存放這些資訊,再將這個類的物件維護到容器中。
CachedInvocation原始碼:
public final class CachedInvocation {
private Object key;
private final Object targetBean;
private final Method targetMethod;
private Object[] arguments;
public CachedInvocation(Object key, Object targetBean, Method targetMethod, Object[] arguments) {
this.key = key;
this.targetBean = targetBean;
this.targetMethod = targetMethod;
if (arguments != null && arguments.length != 0) {
this.arguments = Arrays.copyOf(arguments, arguments.length);
}
}
public Object[] getArguments() {
return arguments;
}
public Object getTargetBean() {
return targetBean;
}
public Method getTargetMethod() {
return targetMethod;
}
public Object getKey() {
return key;
}
/**
* 必須重寫equals和hashCode方法,否則放到set集合裡沒法去重
* @param o
* @return
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
CachedInvocation that = (CachedInvocation) o;
return key.equals(that.key);
}
@Override
public int hashCode() {
return key.hashCode();
}
}
維護快取方法資訊的容器和重新整理快取的類CacheSupportImpl 關鍵程式碼:
private final String SEPARATOR = "#";
/**
* 記錄快取執行方法資訊的容器。
* 如果有很多無用的快取資料的話,有可能會照成記憶體溢位。
*/
private Map<String, Set<CachedInvocation>> cacheToInvocationsMap = new ConcurrentHashMap<>();
@Autowired
private CacheManager cacheManager;
// 重新整理快取
private void refreshCache(CachedInvocation invocation, String cacheName) {
boolean invocationSuccess;
Object computed = null;
try {
// 通過代理呼叫方法,並記錄返回值
computed = invoke(invocation);
invocationSuccess = true;
} catch (Exception ex) {
invocationSuccess = false;
}
if (invocationSuccess) {
if (!CollectionUtils.isEmpty(cacheToInvocationsMap.get(cacheName))) {
// 通過cacheManager獲取操作快取的cache物件
Cache cache = cacheManager.getCache(cacheName);
// 通過Cache物件更新快取
cache.put(invocation.getKey(), computed);
}
}
}
private Object invoke(CachedInvocation invocation)
throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
final MethodInvoker invoker = new MethodInvoker();
invoker.setTargetObject(invocation.getTargetBean());
invoker.setArguments(invocation.getArguments());
invoker.setTargetMethod(invocation.getTargetMethod().getName());
invoker.prepare();
return invoker.invoke();
}
// 註冊快取方法的執行類資訊
@Override
public void registerInvocation(Object targetBean, Method targetMethod, Object[] arguments,
Set<String> annotatedCacheNames, String cacheKey) {
// 獲取註解上真實的value值
Collection<String> cacheNames = generateValue(annotatedCacheNames);
// 獲取註解上的key屬性值
Class<?> targetClass = getTargetClass(targetBean);
Collection<? extends Cache> caches = getCache(cacheNames);
Object key = generateKey(caches, cacheKey, targetMethod, arguments, targetBean, targetClass,
CacheOperationExpressionEvaluator.NO_RESULT);
// 新建一個代理物件(記錄了快取註解的方法類資訊)
final CachedInvocation invocation = new CachedInvocation(key, targetBean, targetMethod, arguments);
for (final String cacheName : cacheNames) {
if (!cacheToInvocationsMap.containsKey(cacheName)) {
cacheToInvocationsMap.put(cacheName, new CopyOnWriteArraySet<>());
}
cacheToInvocationsMap.get(cacheName).add(invocation);
}
}
@Override
public void refreshCache(String cacheName) {
this.refreshCacheByKey(cacheName, null);
}
// 重新整理特定key快取
@Override
public void refreshCacheByKey(String cacheName, String cacheKey) {
// 如果根據快取名稱沒有找到代理資訊類的set集合就不執行重新整理操作。
// 只有等快取有效時間過了,再走到切面哪裡然後把代理方法資訊註冊到這裡來。
if (!CollectionUtils.isEmpty(cacheToInvocationsMap.get(cacheName))) {
for (final CachedInvocation invocation : cacheToInvocationsMap.get(cacheName)) {
if (!StringUtils.isBlank(cacheKey) && invocation.getKey().toString().equals(cacheKey)) {
logger.info("快取:{}-{},重新載入資料", cacheName, cacheKey.getBytes());
refreshCache(invocation, cacheName);
}
}
}
}
現在重新整理快取和註冊快取執行方法的資訊都有了,我們怎麼來把這個執行方法資訊註冊到容器裡面呢?這裡還少了觸發點。
所以我們還需要一個切面,當執行@Cacheable註解獲取快取資訊的時候我們還需要註冊執行方法的資訊,所以我們寫了一個切面:
/**
* 快取攔截,用於註冊方法資訊
* @author yuhao.wang
*/
@Aspect
@Component
public class CachingAnnotationsAspect {
private static final Logger logger = LoggerFactory.getLogger(CachingAnnotationsAspect.class);
@Autowired
private InvocationRegistry cacheRefreshSupport;
private <T extends Annotation> List<T> getMethodAnnotations(AnnotatedElement ae, Class<T> annotationType) {
List<T> anns = new ArrayList<T>(2);
// look for raw annotation
T ann = ae.getAnnotation(annotationType);
if (ann != null) {
anns.add(ann);
}
// look for meta-annotations
for (Annotation metaAnn : ae.getAnnotations()) {
ann = metaAnn.annotationType().getAnnotation(annotationType);
if (ann != null) {
anns.add(ann);
}
}
return (anns.isEmpty() ? null : anns);
}
private Method getSpecificmethod(ProceedingJoinPoint pjp) {
MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
Method method = methodSignature.getMethod();
// The method may be on an interface, but we need attributes from the
// target class. If the target class is null, the method will be
// unchanged.
Class<?> targetClass = AopProxyUtils.ultimateTargetClass(pjp.getTarget());
if (targetClass == null && pjp.getTarget() != null) {
targetClass = pjp.getTarget().getClass();
}
Method specificMethod = ClassUtils.getMostSpecificMethod(method, targetClass);
// If we are dealing with method with generic parameters, find the
// original method.
specificMet