一 背景
对于日常接口加缓存需求,spring提供的@Cacheable注解在大部分场景下都很好实现功能。(若对此注解不熟悉,请先谷歌)
但是随着业务代码越写越多,慢慢会发现有些场景下@Cacheable注解并不能完美实现我们想要的功能。
使用@Cacheable都会指定一个key,nameSpace或者其他的一些条件,随你愿意。
但是@Cacheable最大的一个遗憾是它不支持批量key,如果我调用的方法会产生多个key的缓存,这时候@Cacheable就会显得有点苍白。
比如有如下方法
public List<Student> getStudentByIds(List<Integer> ids, Boolean includeTeacher, Boolean includeFather) {
/**
* 实现忽略
*/
return new LinkedList<>();
}
Student类如下:
public class Student {
private Integer id;
private Integer age;
private String name;
private Integer teacherId;
private String teacherName;
private Integer fatherId;
private String fatherName;
}
针对这种情况,如果用@Cacheable注解的话,会很鸡肋。因为@Cacheable只能有一个key,而这个方法最好是针对每一个学生id都产生一个key,这样才能保证缓存的命中率。比如你查询了学生id为1,2,4,5的数据,下次再查询2,4,6学生数据时,应该2,4走缓存,6走代码查询,这样提高缓存命中率。
二 目前的方案
对于上述的情景,可以有以下解决方案:
1 仍然使用@Cacheable注解,调用方法之前对ids参数进行排序,然后将ids序列化(或者其他方式,反正能得到一个string就行)与其他参数拼接在一起,组成缓存的key。
缺点:不太可行,这样会导致缓存命中率低,因为很少会出现两次一模一样的调用(入参完全相同)
2 手动写缓存代码
不使用@Cacheable注解。大致逻辑是先手动组装缓存的key,然后批量获取缓存数据,再找出没有缓存的key,调用方法,得到要缓存的数据,写入未缓存的数据。方法可行,完美完成功能。
缺点一 :麻烦,每当有类似方法需要加缓存时,每次都要写代码。
缺点二:写缓存数据的时候不方便。有两种写缓存方式,第一种,一条一条put(带超时时间),需要与redis交互多次。第二种,一次multiSet,然后依次设置key的超时时间。这样有一个问题,这不是一个原子操作,这两步操作不在一个事务中,如果第一步成功了,第二步失败了,就会导致已缓存的数据没有失效时间。
三 写个注解
import java.lang.annotation.*;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface ListCache {
/**
* 缓存排除list参数后面一截的key,支持SpEL表达式
*/
String key() default "";
/**
* 返回字段中与入参list对应的字段名
*/
String idKey();
/**
* 缓存nameSpace
*/
String cacheNamespace();
/**
* 缓存时间 秒 默认120秒
*/
int cacheTime() default 120;
}
对注解的处理,大致逻辑如下:
强制要求加注解的方法的第一个入参是List类型,然后将list中的数据拼接上注解的key字段,组成key的list,调用multiget获取缓存数据,找出未缓存的数据,调用方法获取数据,拼接缓存key和对应value,调用lua脚本缓存数据。使用lua脚本的好处是,第一,只需要如redis交互一次,减少调用,第二,执行的lua脚本在一个事务中,要么成功,要么失败,不会存在成功一半的场景。使用lua主要是因为redisTemplate无法满足要求,虽然redisTemplate有multiSet操作,但是无法指定超时时间,必须先multiSet,再依次指定key的超时时间,这不是一个原子操作,如果在multiSet成功后,程序崩溃,导致后面设置超时的操作没有执行,就会导致key没有超时时间,无限存在。
具体实现代码如下:
package com.aihuishou.opt.inspection.service.aspect;
@Aspect
@Component
@Slf4j
public class ListCacheAspect {
private static final String COLON = ":";
private final ExpressionParser parser = new SpelExpressionParser();
private final LocalVariableTableParameterNameDiscoverer discoverer = new LocalVariableTableParameterNameDiscoverer();
@Autowired
private RedisTemplate redisTemplate;
@Pointcut("@annotation(com.aihuishou.opt.inspection.service.aspect.ListCache)")
public void doPerform1() {
}
@Around("doPerform1()")
public Object determine(ProceedingJoinPoint jp) throws Throwable {
MethodSignature signature = (MethodSignature) jp.getSignature();
Method targetMethod = signature.getMethod();
if (targetMethod.isAnnotationPresent(ListCache.class)) {
try {
return getList(jp, targetMethod);
} catch (Throwable throwable) {
log.error("ListCache 缓存失败,异常:", throwable);
return jp.proceed();
}
} else {
return jp.proceed();
}
}
private List getList(ProceedingJoinPoint jp, Method targetMethod) throws Throwable {
ListCache listCache = targetMethod.getAnnotation(ListCache.class);
Object[] args = jp.getArgs();
if (!(args[0] instanceof List)) {
throw new IllegalArgumentException("第一个参数是必须List");
}
String spel = listCache.key();
String idKey = listCache.idKey();
int cacheTime = listCache.cacheTime();
String cacheNamespace = listCache.cacheNamespace();
if (!cacheNamespace.endsWith(COLON)) {
cacheNamespace = cacheNamespace.concat(COLON);
}
String spelKey = parseSpel(targetMethod, args, spel, String.class);
List listKey = (List) args[0];
if (CollectionUtils.isEmpty(listKey)) {
return Lists.newLinkedList();
}
List<String> redisKeys = Lists.newLinkedList();
for (Object o : listKey) {
redisKeys.add(cacheNamespace + o.toString() + spelKey);
}
List responseList = Lists.newLinkedList();
List notCachedListKey = Lists.newLinkedList();
if (CollectionUtils.isNotEmpty(redisKeys)) {
responseList = redisTemplate.opsForValue().multiGet(redisKeys);
}
int i = 0;
for (Object o : responseList) {
/**
* 这里利用了multiGet的一个特性,multiGet返回的数据会按照请求时key的顺序返回对应的value数据,
* 即保证数据顺序一致,如果key对应的value不存在,返回null
*/
if (Objects.isNull(o)) {
notCachedListKey.add(listKey.get(i));
}
i++;
}
if (CollectionUtils.isEmpty(notCachedListKey)) {
return responseList;
}
args[0] = notCachedListKey;
List proceed = (List) jp.proceed(args);
if (CollectionUtils.isEmpty(proceed)) {
return responseList;
}
Map<String, Object> map = Maps.newHashMapWithExpectedSize(notCachedListKey.size());
/**
* 这里这样写的原因是你无法保证方法返回的数据能保证入参的顺序。方法的实现可能是查询数据库,
* 也可能是调用其他服务,无法做到像multiGet一样保证顺序,
* 所以注解中的idKey完全是为了拼接不在redis中的数据的缓存的key
*/
for (Object o : proceed) {
Field declaredField = o.getClass().getDeclaredField(idKey);
declaredField.setAccessible(true);
String remainRedisKey = cacheNamespace + declaredField.get(o).toString() + spelKey;
map.put(remainRedisKey, o);
}
DefaultRedisScript<String> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scrip.lua")));
redisScript.setResultType(String.class);
List<String> keys = map.keySet().stream().map(t -> String.valueOf(t)).collect(Collectors.toList());
//缓存时间特殊处理,之所以不放到value中,是因为会报错,类型转换错误。
keys.add(String.valueOf(cacheTime));
redisTemplate.execute(redisScript, redisTemplate.getValueSerializer(), redisTemplate.getStringSerializer()
, keys, map.values().toArray());
responseList.addAll(proceed);
return responseList;
}
/**
* 解析 SpEL 表达式
*
* @param method 方法
* @param arguments 参数
* @param spel 表达式
* @param clazz 返回结果的类型
* @return 执行spel表达式后的结果
*/
private <T> T parseSpel(Method method, Object[] arguments, String spel, Class<T> clazz) {
String[] params = discoverer.getParameterNames(method);
EvaluationContext context = new StandardEvaluationContext();
for (int len = 0; len < params.length; len++) {
context.setVariable(params[len], arguments[len]);
}
Expression expression = parser.parseExpression(spel);
return expression.getValue(context, clazz);
}
}
对应的lua脚本如下:
local keys = KEYS
local values = ARGV
--缓存时间特殊处理
local timeout = tonumber(KEYS[table.getn(keys)])
for i = 1, table.getn(keys) - 1 do
redis.call('set' , keys[i] , values[i] , 'EX' , timeout)
end
return "ok"
使用示例:
@ListCache(key = "'-'+#includeTeacher+'-'+#includeFather", idKey = "id", cacheNamespace = CacheNamespace.STUDENT)
public List<Student> getStudentByIds(List<Integer> ids, Boolean includeTeacher, Boolean includeFather) {
/**
* 实现忽略
*/
return new LinkedList<>();
}
四 其他
- 由于在代码中使用到了反射,对于其性能,没有做专门的测试,后续有时间再做下完整的性能测试。
- cacheTime的默认值可以从配置文件中获取,保证项目缓存时间一致。
- 对于缓存数据的写入,现在的操作是同步写入,然后再返回。如果为了追求效率,可将缓存数据写入改为异步,但是要注意,异步写入的缓存数据不能在写入前被修改,否则可能会导致缓存数据错误。可以将要缓存的数据做一次深拷贝,这样会慢一些,但是保证数据没问题。如果你确认数据不会被修改,也可直接缓存数据。
- 本来是想在网上找找解决方案的,没找到,如果有更好的方案,请不吝赐教。