redis缓存
使用redis缓存的原因是因为在可能的高并发环境下,mysql数据库无法承受大量的请求,可能会导致数据库崩溃。而这些请求很大一部分都是查询请求,因此采用redis这样的以内存作为存储数据空间的数据库来存储查询请求的数据,这样既提高了查询的效率,又分担了一大部分请求并发的压力。
具体实现使用了AOP面向切面编程的思想,即使用@PointCut注解指定切面,在本项目中就是指定各类控制器中的各种方法,并且以**@Around注解代表的环绕方式**进行切入。利用简单类名加方法名加参数名作为缓存的键cacheKey,方法得到的值作为键的值。对于一个查询请求类型的方法执行前先在redis中查询cacheKey,如果没有则在redis中设置这个cacheKey和值以及过期时间,设置过期时间的原因是因为redis是基于内存存储数据,因此需要定期清理数据。如果有cacheKey则直接从redis中获取值。对于除了查询类型以外的方法,则先删除当前控制器下的所有缓存键,避免缓存无法更新的问题,然后再去数据库进行查询,并返回结果。
在这个过程中,可能会遇到缓存击穿,缓存穿透,缓存雪崩的问题。
因为所谓的缓存击穿,就是一个承受着高并发请求的键,因为过期时间到了,cacheKey就失效了,这时候高并发的请求会涌入mysql数据库,就像大量的请求击穿了缓存一样,因此叫做缓存击穿嘛。在本项目中解决的方案为当cacheKey不存在时,进入数据库中查询的行为使用syncronize锁住,并且使用双检锁确保只有一个线程能进入数据库查询,这样即使高并发请求的key失效也不会造成大量的请求涌入mysql数据库了,就解决了缓存击穿的问题。但是因为此项目没有高并发的用户访问,属于内部操作管理系统,所以使用syncronize已足够。
而面向高并发用户的系统使用syncronize来锁就不能解决分布式服务器的问题,因为syncronize和lock只能锁住本地JVM的线程,无法锁住其他服务器中的线程,因此在那种情况下应该采用分布式锁,而分布式锁的实现比较常见的有两种方案,一种为zookeeper实现,一种是redis实现,实现原理为请求访问时使用SETNX命令向redis中存储一个具有过期时间的key,如果成功设置则上锁成功,如果设置失败则进入阻塞。由于分布式服务器使用的是同一个redis数据库,所以就能达到互斥的效果。
而对于缓存雪崩的问题,由于缓存雪崩就是多个key同时过期导致大量查询请求进入数据库,所以在设置键的过期时间的时候精确到微妙或纳秒级别,这样就可以避免大量键同时过期的问题。
对于缓存穿透的问题,也就是解决用户利用查询数据库不存在的数据进行恶意攻击的行为,本项目中是即使数据库中查出数据可能为空,但是也添加到缓存中,这样再次查询就会查出缓存中的空而不是进入mysql数据库中查。
java
@Aspect
@Component
public class RedisCacheAspect {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private final Random random = new Random();
// @Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping)")
@Pointcut("execution(* com.stt.cms.*.controller..*(..))")
private void pointcut(){}
@Around("pointcut()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
Signature signature = pjp.getSignature(); //获取连接点的签名
MethodSignature ms = (MethodSignature) signature;//因为在spring aop中,连接点的类型都是方法,因此,这个签名就是一个方法的签名
Method method = ms.getMethod();//从方法签名中获取方法
String className = method.getDeclaringClass().getSimpleName();
if (method.isAnnotationPresent(GetMapping.class)) {//如果方法上存在GetMapping注解,说明这个方法就是一个查询方法
// 检查是否是上传或下载方法
if (method.getName().startsWith("upload") || method.getName().startsWith("download")) {
return pjp.proceed(); // 直接执行方法,不进行缓存
}
//需要将返回结果放入redis缓存中,方便下一次查询的时候使用
Object[] args = pjp.getArgs();
String methodName = method.getName();
String cacheKey = className + "::" + methodName + JSON.toJSONString(args);
//首先去校验缓存中是否存在我们需要获取的数据
if (Boolean.TRUE.equals(redisTemplate.hasKey(cacheKey))) {
//如果缓存中存在,那么就说明获取的数据就是返回值
return redisTemplate.opsForValue().get(cacheKey);
} else {
//考虑到并发问题,这里需要上锁进行处理
synchronized (this){ //这里的双检锁就是为了解决缓存击穿问题的
//如果redis中不存在缓存
if(Boolean.FALSE.equals(redisTemplate.hasKey(cacheKey))){
//缓存中没有数据
Object result = pjp.proceed(); //执行方法得到的返回结果
//这里的过期时间要设置为随机过期时间,防止缓存雪崩,但是需要注意的是,随机时间是小单位的
//随机,不能是大单位的随机
long expire = Duration.ofMinutes(5).toNanos() + random.nextInt(1000);
redisTemplate.opsForValue().set(cacheKey, result, expire, TimeUnit.NANOSECONDS);
return result;
} else {
return redisTemplate.opsForValue().get(cacheKey);
}
}
}
//这里还需要考虑缓击穿和缓存存穿透问题
} else {
//其余情况我们只需要考虑增删改带来的缓存失效问题的处理
if(method.isAnnotationPresent(PostMapping.class)
|| method.isAnnotationPresent(PutMapping.class)
||method.isAnnotationPresent(DeleteMapping.class)){
//这里需要考虑去删除缓存,从而更新缓存
List<String> cacheKeys = redisTemplate.execute((RedisCallback<List<String>>) connection -> {
ScanOptions scanOptions = ScanOptions.scanOptions().match(className + "*").count(50).build();
List<String> keys = new ArrayList<>();
Cursor<byte[]> cursor = connection.scan(scanOptions);
while (cursor.hasNext()) {
byte[] next = cursor.next();
String scanKey = new String(next);
System.err.println("扫描到失效的键:" + scanKey);
keys.add(scanKey);
}
return keys;
});
if(cacheKeys != null)
redisTemplate.delete(cacheKeys);
}
return pjp.proceed();
}
}
}