概要
缓存是现在系统中必不可少的模块,并且已经成为了高并发高性能架构的一个关键组件。从硬件缓存、到软件缓存;从底层的操作系统到上层的应用系统,缓存无处不在,在我理解,要深入掌握这门技术,需要先掌握缓存的思想。
缓存解决的问题
说白了,缓存就是计算机系统中最常见的空间换时间的思想的体现,为的就是尽最大可能提升计算机软件系统的性能。举几个例子如:
1、内存中的数据需要放到CPU中去计算,不是当需要计算的时候再从内存中一个数据一个数据的去取,而是有高速cpu缓存一次性保存很多数据,用于提升内存和cpu之间的数据交换。
2、普通Web应用,通常我们从数据库获取数据,然后返回给浏览器进行展示,数据库的数据到浏览器,之间经历我们的数据库,后端web应用(服务器内存),网络,再到浏览器,用户想要更快的获取到数据,那么就可以利用缓存,提前把数据放到web应用、甚至放到浏览器。
3、复杂的系统 ,用户获取数据的路线可能是下面的样子:
浏览器 》 CDN(内容分发网络) 》 代理层 》 缓存中间件
》 应用层 》
》应用层缓存|缓存中间件 》 数据库缓存 》 数据库
缓存存在的问题
数据一致性问题
从上面描述的两个场景不难看出,缓存使用时,最明显存在的问题就是数据实时性问题,可能用户获取到的数据不是我们最新的数据,即缓存与数据库数据一致性问题。
解决方案
1、当然我们可以采用完全串行化的方式(即保证缓存操作与数据库操作的原子性)保证缓存与数据库的数据一致性问题。但是这与我们缓存通常要解决的高并发下问题相违背。
2、下面简单说下几种方式,其实都不能保证强一致性,其中前面3中方式不推荐,推荐第4种并且详细说明(需要了解详细为什么的可以查看文章https://blog.csdn.net/chang384915878/article/details/86756463
https://blog.csdn.net/qq_27384769/article/details/79499373
https://blog.kido.site/2018/11/24/db-and-cache-preface/)
a、先更新缓存,再更新数据库,考虑写与写之间的并发,会有问题
b、先更新数据库,再更新缓存,考虑写与写之间的并发,会有问题
c、先删除缓存,再更新数据库,考虑读写之间的并发,有问题
d、先更新数据库,再删除缓存,推荐,但也存在较小几率有问题,比如,读先来读数据,发现缓存没有,从数据库获取了数据,准备更新缓存,此时写更新了数据库,然后删除了缓存完成了写操作;此刻,读线程最后再用旧数据更新了缓存,则导致缓存里的数据是旧数据,与数据库里的新数据不一致。这种情况只会出现缓存里没有数据的情况下。通过设置过期时间或者下次再有数据更新时消除不一致。
3、阿里开源canal,mysql与redis之间的增量同步中间服务,详细使用方式可以查看
https://blog.csdn.net/lyl0724/article/details/80528428
https://blog.csdn.net/weixin_40606441/article/details/79840205
缓存雪崩
问题出现:
redis持久化淘汰
redis缓存过期失效
redis重启、升级
导致缓存查不到,短时间内如果来大量请求,可能对数据库造成压力。
1、采用数据库连接池可以避免对数据库造成连接压力。但是压力总量不变,只是数据库层面限流了。
2、将压力提前,所以需要在应用层、业务层限流,在查询数据库前添加限流器,进入方法,先拿缓存,拿不到就获取semphere,拿到锁的先查缓存,查不到再查数据库,查到数据库再更新缓存。容错、限流、降级
缓存击穿
问题出现:
当频繁访问数据库本身就不存在的数据时,不论访问多少次,都不会在缓存中找到,这就绕过了缓存层,造成了缓存击穿
问题如何解决:
1、查询到数据库中不存在就给redis插入空值,但是这个解决不了大量不存在ID的查询,因为会造成redis存储大量没用的控制信息。
2、filter,先判断是否存在,把所有存在的数据的key加载到内存或者redis。就可以先判断是否存在了。
3、方案2会造成空间大量浪费,所以继续优化,只用一个bit来表示某个key是否存在,引出布隆过滤器。
BloomFilter
布隆过滤器采用bit和hash的方式实现,空间占用小,但是会有少量因为hash取模算法导致相同的slot位置而冲突导致的存在误判(不存在的不会误判),意思是判断存在,其实可能不存在,和更新数据困难的问题。布隆过滤器需要不断维护。
这个误判很少,1、可以通过设置null值解决。2、通过多次hash减少误判
redis三方模块redis-bloom,可以通过在配置文件中配置loadModules引入该模块的功能。
RedisBloomFilter
结合缓存雪崩里的逻辑:
进入方法,先用bloomfilter判断是否存在,先拿缓存,拿不到就获取semphere,拿到锁的先查缓存,查不到再查数据库,查到数据库再更新缓存。
解决方案
如果要解决上面提到的缓存雪崩与缓存穿透问题,往往需要在用到缓存的业务代码中增加大量的逻辑,导致原先简单的业务代码变得复杂,甚至难以维护,但是我们可以使用spring AOP实现自定义缓存注解优雅的处理上诉过程
注意:
1、spring面向切面编程的方式
2、我们可以使用spring提供的spel表达式解析器
SpelExpressionParser
借用网易云老师的代码:
a、核心切面类
package com.study.cache.stampeding.annotations;
import java.lang.reflect.Method;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import javax.annotation.Resource;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.EvaluationException;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.ParseException;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import com.study.cache.stampeding.bloom.RedisBloomFilter;
@Component
@Aspect
public class CoustomCacheAspect {
private Logger logger = LoggerFactory.getLogger(this.getClass());
@Resource(name = "mainRedisTemplate")
StringRedisTemplate mainRedisTemplate;
@Autowired
RedisBloomFilter filter;
// 数据库限流,根据数据库连接数来定义大小
Semaphore semaphore = new Semaphore(30);
@Pointcut("@annotation(com.study.cache.stampeding.annotations.CoustomCache)")
public void cachePointcut() {
}
// 定义相应的事件
@Around("cachePointcut()")
public Object doCache(ProceedingJoinPoint joinPoint) {
Object value = null;
CoustomCache cacheAnnotation = findCoustomCache(joinPoint);
// 解析缓存Key
String cacheKey = parseCacheKey(joinPoint);
// 在缓存之前去进行过滤
String bloomFilterName = cacheAnnotation.bloomFilterName();
boolean exists = filter.exists(bloomFilterName, cacheKey);
if(! exists) {
logger.warn(Thread.currentThread().getName()+" 您需要的商品是不存在的+++++++++++++++++++++++++++");
return "您需要的商品是不存在的";
}
// 1、 判定缓存中是否存在
value = mainRedisTemplate.opsForValue().get(cacheKey);
if (value != null) {
logger.debug("从缓存中读取到值:" + value);
return value;
}
// 访问数据库进行限流
try {
if(semaphore.tryAcquire(5, TimeUnit.SECONDS)) {
value = mainRedisTemplate.opsForValue().get(cacheKey);
if (value != null) {
logger.debug("从缓存中读取到值:" + value);
return value;
}
// 交给服务层方法实现,从数据库获取
value = joinPoint.proceed();
// 塞到缓存,过期时间10S
final String v = value.toString();
mainRedisTemplate.execute((RedisCallback<Boolean>) conn -> {
return conn.setEx(cacheKey.getBytes(), 120, v.getBytes());
});
}else { // semaphore.tryAcquire(5, TimeUnit.SECONDS) 超时怎么办?
// 再去获取一遍缓存,说不定已经有请求构建好了缓存。
value = mainRedisTemplate.opsForValue().get(cacheKey);
if(value != null) {
logger.debug("等待后,再次从缓存获得");
return value;
}
// 缓存尚未构建好,进行服务降级,容错
// 友好的提示,对不起,票已售空、11.11 提示稍后付款;客官您慢些;
// 不断降低我们的预期目标, 外星人、小黑、华为、小米
logger.debug("服务降级——容错处理");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Throwable e) {
logger.error(e.getMessage(), e);
}finally {
try {
semaphore.acquire();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return value;
}
private CoustomCache findCoustomCache(ProceedingJoinPoint joinPoint) {
CoustomCache cacheAnnotation;
try {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = joinPoint.getTarget().getClass().getMethod(signature.getName(), signature.getMethod().getParameterTypes());
cacheAnnotation = method.getAnnotation(CoustomCache.class);
return cacheAnnotation;
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (SecurityException e) {
e.printStackTrace();
}
return null;
}
/**
* 获取缓存Key
* @param joinPoint
* @return
*/
private String parseCacheKey(ProceedingJoinPoint joinPoint) {
CoustomCache cacheAnnotation;
// 解析
String cacheKey = null;
try {
// 0-1、 当前方法上注解的内容
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = joinPoint.getTarget().getClass().getMethod(signature.getName(), signature.getMethod().getParameterTypes());
cacheAnnotation = findCoustomCache(joinPoint);
String keyEl = cacheAnnotation.key();
// 0-2、 前提条件:拿到作为key的依据 - 解析springEL表达式
// 创建解析器
ExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression(keyEl);
EvaluationContext context = new StandardEvaluationContext(); // 参数
// 添加参数
Object[] args = joinPoint.getArgs();
DefaultParameterNameDiscoverer discover = new DefaultParameterNameDiscoverer();
String[] parameterNames = discover.getParameterNames(method);
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i].toString());
}
String key = expression.getValue(context).toString();
cacheKey = cacheAnnotation.prefix() == null ? "" : cacheAnnotation.prefix() + key;
} catch (ParseException e) {
e.printStackTrace();
} catch (EvaluationException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (SecurityException e) {
e.printStackTrace();
}
return cacheKey;
}
}
b、注解类
package com.study.cache.stampeding.annotations;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 自定义的缓存注解
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CoustomCache {
/**
* key的规则,可以使用springEL表达式,可以使用方法执行的一些参数
*/
String key();
/**
* 缓存key的前缀
* @return
*/
String prefix();
/**
* 采用布隆过滤器的名称
* @return
*/
String bloomFilterName();
}
c、使用
@CoustomCache(key = "#goodsId", prefix = "goodsStock-", bloomFilterName = "goodsBloomFilter")
public Object queryStockByAnn(final String goodsId) {
// CRUD,只需要关系业务代码,交给码农去做
return databaseService.queryFromDatabase(goodsId);
}
总结
最近工作比较忙,把以前的笔记整理了下形成了此篇文章,很多地方没有详细深入与画图举例,现在这打个标记,后续希望自己能够沉下来做一个完成的中间件的总结。