AOP和注解实现自定义缓存
原因
在应用中,缓存应当去存一些修改频率较低的一些数据或者被访问次数很多的热点数据,因此我们需要选择性的将业务中的一些数据加入到缓存中。这个时候我们就可以想到Java的自定义注解和Spring的AOP功能向结合,通过在方法的头上打上注解,来实现将方法返回的数据加入到缓存中,优化业务的性能。同时由于AOP和注解的特性,我们不需要对需要加入缓存的方法进行修改而对其进行增强。
实现
步骤一:定义自定义注解
package com.duan.blog.aop.annotation;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CacheAnnotation {
long expire() default 1 * 60 * 1000;
String KeyPrefix() default "";
String cacheName() default "";
}
我们在注解中定义了三个信息:
- expire:缓存的有效期
- KeyPrefix:缓存键的前缀
- cacheName:缓存名称
步骤二:定义注解切面
@Component
@Slf4j
@Aspect
public class CacheAspect {
@Resource
StringRedisTemplate stringRedisTemplate;
@Pointcut("@annotation(com.duan.blog.aop.annotation.CacheAnnotation)")
public void pointCut(){}
@Around("pointCut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature)joinPoint.getSignature();
CacheAnnotation annotation = signature.getMethod().getAnnotation(CacheAnnotation.class);
String cacheKey = annotation.KeyPrefix() + getKeyKeySuffix(joinPoint.getArgs(),signature.getName());
//log.info(cacheKey);
String redisData = stringRedisTemplate.opsForValue().get(cacheKey);
//缓存命中
if(StrUtil.isNotBlank(redisData)){
log.info("走了缓存~~~,{},{}",cacheKey,annotation.cacheName());
return JSONUtil.toBean(redisData,signature.getReturnType());
}
Object Result = joinPoint.proceed();
//解决缓存穿透
if(BeanUtil.isEmpty(Result)) stringRedisTemplate.opsForValue().set(cacheKey, "");
SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, annotation.cacheName());
try {
if(!lock.tryLock(10l)){
log.info("我没拿到锁,我需要等。。。" + Thread.currentThread().getId());
Thread.sleep(500);
return around(joinPoint);
}
log.info("缓存未命中,缓存重建,{},{},{}",cacheKey,annotation.cacheName(),Thread.currentThread().getId());
rebuildCache(Result,annotation.expire(),cacheKey);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
lock.unlock();
}
return BeanUtil.copyProperties(Result, signature.getReturnType());
}
/**
* 通过参数列表和缓存的名称和注解方法的参数列表来生成key的后缀
* @param args
* @param methodName
* @return
*/
private String getKeyKeySuffix(Object[] args,String methodName) {
StringBuilder params = new StringBuilder();
for (Object parameter : args) {
params.append(StrUtil.toString(parameter));
}
if (StrUtil.isNotEmpty(params)) {
//加密 以防出现key过长以及字符转义获取不到的情况,保证KeyPrefix后的字段唯一
params = new StringBuilder(DigestUtils.md5Hex(params.append(methodName).toString()));
}
return params.toString();
}
/**
* 重建缓存
* @param mysqlData
* @param expire
* @param cacheKey
* @throws InterruptedException
*/
private void rebuildCache(Object mysqlData,Long expire,String cacheKey) throws InterruptedException {
//Thread.sleep(10000);
stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(mysqlData),expire, TimeUnit.SECONDS);
log.info("重建缓存成功。。。。"+Thread.currentThread().getId());
}
}
这里我们通过AOP的参数ProceedingJoinPoint
获取到了目标函数和注解,我们就可以对其中的信息进行解析,我们将目标函数的参数列表和缓存的名称进行MD5加密来生成缓存的key值标识。并且在缓存未命中时实现了简单的redis分布式锁来保证重建缓存的过程中仅有一个线程在进行缓存的重构来解决缓存穿透时大量线程访问并操作数据库。
简单的redis分布式锁:
package com.duan.blog.utils;
import cn.hutool.core.lang.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
/**
* redis实现的分布式锁
*/
public class SimpleRedisLock implements ILock {
//锁的前缀
private static final String KEY_PREFIX="lock:";
//标识业务名称
private String name ;
private StringRedisTemplate stringRedisTemplate;
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate,String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name ;
}
/**
* 获取锁
* @param timeoutSec 锁持有的的超时,过期自动释放
* @return
*/
@Override
public boolean tryLock(Long timeoutSec) {
//获取线程ID
String ThreadId = ID_PREFIX + Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, ThreadId,
timeoutSec, TimeUnit.SECONDS);
if(success == null) return false;
return success;
}
/**
* 释放锁
*/
@Override
public void unlock() {
//锁的key
String key = KEY_PREFIX + name;
//线程标识
String ThreadId = stringRedisTemplate.opsForValue().get(key);
//比较当前线程标识是否和存入redis中的线程标识相同,相同则释放锁
if((ID_PREFIX + Thread.currentThread().getId()).equals(ThreadId)){
stringRedisTemplate.delete(key);
}
}
}
步骤三:在需要缓存的函数中添加自定义注解
@Override
@CacheAnnotation(KeyPrefix = "cache:archive" ,cacheName = "archive")
public Result getArchives() {
return Result.success(articleMapper.getArticleArchivesByDate());
}
结论
在通过本地的测试以及PostMan两个请求同时请求的测试后,可以知道该自定义缓存可以正常进行缓存工作,且功能符合预期。
但由于其缓存时redis的key是通过参数列表和缓存名称进行生成,因此我们在删除缓存的时候很难对其key值进行重现,也就很难实现对所建缓存进行精确的删除操作。我们当然可以通过缓存的前缀匹配来进行对该业务的大规模删除,但这样会造成大量的缓存错删。因此该AOP和自定义注解缓存虽然使用便利,但因为难以实现删除操作,因此缓存一致性非常差,只有在缓存的有效期过了之后,才会对缓存进行更新,不推荐使用。除非能够找到一个更好的方案生成缓存的key值能够使删除切点能够对key值进行重现来实现精确的缓存删除。