目录
@CacheEvict是用来标注在需要清除缓存元素的方法或类上的。
一、Caffeine的基础使用
Caffeine Cache 以其高性能和可扩展性赢得 本地缓存之王 的称号,它是一个 Java 缓存库。
Spring Boot 1.x 版本中的默认本地缓存是 Guava Cache。但在 Spring5 (SpringBoot 2.x)后,Spring 官方放弃了 Guava Cache 作为缓存机制,而是使用性能更优秀的 Caffeine 作为默认缓存组件。
Caffeine 官方测试报告:https://github.com/ben-manes/caffeine/wiki/Benchmarks-zh-CN
Caffeine特点
1、自动将数据加载到缓存中,同时也可以采用异步的方式加载。
2、内存淘汰策略:基于频次、基于最近访问、最大容量。
3、根据上一次的缓存访问\上一次的数据写入决定缓存的过期的设置。
4、当一条缓存数据过期了,自动清理,清理的时候也是异步线程来做。
5、考虑JVM的内存管理机制,加入弱引用、软引用。
6、缓存数据被清理后,会收到相关的通知信息
7、缓存数据的写入可以传播到外部的存储。
8、统计功能:被访问次数,命中,清理的个数,加载个数
Caffeine Cache入门
<!-- Spring boot Cache-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!--for caffeine cache-->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.7.0</version>
</dependency>
Cache是一个核心的接口,里面定义了很多方法,我们要使用缓存一般是使用Cache的的子类,根据官方的方法,我们通过caffeine这个类来获得实现Cache的类。
Caffeine 配置说明:
-
initialCapacity=[integer]: 初始的缓存空间大小
-
maximumSize=[long]: 缓存的最大条数
-
maximumWeight=[long]: 缓存的最大权重
-
expireAfterAccess=[duration]: 最后一次写入或访问后经过固定时间过期
-
expireAfterWrite=[duration]: 最后一次写入后经过固定时间过期
-
refreshAfterWrite=[duration]: 创建缓存或者最近一次更新缓存后经过固定的时间间隔,刷新缓存
-
weakKeys: 打开 key 的弱引用
-
weakValues:打开 value 的弱引用
-
softValues:打开 value 的软引用
-
recordStats:开发统计功能
@Slf4j
public class CaffeineTest {
@Test
public void testCaffeine() throws InterruptedException {
// 创建Caffeine缓存实例
Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(100) // 最大缓存数量
.expireAfterAccess(3L, TimeUnit.SECONDS) // 缓存过期时间
.build();
cache.put("baidu","www.baidu.com");//设置缓存项
cache.put("spring","www.spring.io");//设置缓存项
log.info("cache size:{}",cache.estimatedSize());//获取缓存大小
log.info("cache baidu:{}",cache.getIfPresent("baidu"));//获取缓存项
TimeUnit.SECONDS.sleep(4);
log.info("cache size:{}",cache.estimatedSize());//获取缓存大小
log.info("cache baidu:{}",cache.getIfPresent("baidu"));//缓存项已过期,返回null
// 过期数据 的同步加载机制 方案1:使用get方法的回调函数
log.info("获取缓存[get]获取缓存:baidu={}",cache.get("baidu",(key)->{
log.info("进入[失效处理]函数");
try {
TimeUnit.SECONDS.sleep(3);//休眠3秒
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("[失效处理]:spring={}",cache.getIfPresent("spring"));//失效处理
return key.toUpperCase();
}));
log.info("cache baidu:{}",cache.getIfPresent("baidu"));
// 过期数据 的同步加载机制 方案2:使用CacheLoader
}
@Test
public void testCaffeine2() throws InterruptedException {
LoadingCache<String, String> cache = Caffeine.newBuilder()
.maximumSize(100)//设置缓存中保存的最大数量
.expireAfterAccess(3L, TimeUnit.SECONDS)//如无访问则3秒后失效
.build(new CacheLoader<String, String>() {
@Override
public String load( String key) throws Exception {
log.info("正在重新加载数据...");
TimeUnit.SECONDS.sleep(1);
return key.toUpperCase();
}
});
cache.put("baidu","www.baidu.com");//设置缓存项
cache.put("spring","www.spring.io");//设置缓存项
//创建key的列表,通过cache.getAll()拿到所有key对应的值
ArrayList<String> keys = new ArrayList<>();
keys.add("baidu");
keys.add("spring");
//拿到keys对应缓存的值
Map<String, String> map = cache.getAll(keys);
for (Map.Entry<String, String> entry : map.entrySet()) {
log.info("缓存的键:{}、缓存值:{}",entry.getKey(),entry.getValue());//获取数据
}
log.info("--------------------------------------------------------------");
TimeUnit.SECONDS.sleep(5L);
//拿到keys对应缓存的值
map = cache.getAll(keys);
for (Map.Entry<String, String> entry : map.entrySet()) {
log.info("缓存的键:{}、缓存值:{}",entry.getKey(),entry.getValue());//获取数据
}
log.info("LoadingCache 方法结束");
}
@Test
public void testCaffeine3() throws InterruptedException, ExecutionException {
AsyncLoadingCache<String, String> cache = Caffeine.newBuilder()
.maximumSize(100)//设置缓存中保存的最大数量
.expireAfterAccess(3L, TimeUnit.SECONDS)
.buildAsync(new CacheLoader<String, String>() {
@Override
public String load( String key) throws Exception {
log.info("正在重新加载数据...");
TimeUnit.SECONDS.sleep(1);
return key.toUpperCase();
}
});
//使用了异步的缓存之后,缓存的值都是被CompletableFuture给包裹起来的
//所以在追加缓存和得到缓存的时候要通过操作CompletableFuture来进行
cache.put("baidu",CompletableFuture.completedFuture("www.baidu.com"));//设置缓存项
cache.put("spring",CompletableFuture.completedFuture("www.spring.io"));//设置缓存项
TimeUnit.SECONDS.sleep(5);
//创建key的列表,通过cache.getAll()拿到所有key对应的值
ArrayList<String> keys = new ArrayList<>();
keys.add("baidu");
keys.add("spring");
//拿到keys对应缓存的值
Map<String, String> map = cache.getAll(keys).get();
for (Map.Entry<String, String> entry : map.entrySet()) {
log.info("缓存的键:{}、缓存值:{}",entry.getKey(),entry.getValue());//获取数据
}
log.info("AsyncLoadingCache 方法结束");
}
// 内存淘汰机制
@Test
public void testCaffeine4() throws InterruptedException {
/**
* Caffeine提供了三种内存淘汰机制:
* 1. 基于容量:当缓存的容量超过最大限制时,Caffeine会自动淘汰最近最少使用的缓存项。
* 2. 基于时间:当缓存项的过期时间到了,Caffeine会自动淘汰该缓存项。
* 3. 基于引用:当缓存项的引用数量为0时,Caffeine会自动淘汰该缓存项。
* 以上三种淘汰机制可以同时使用,也可以单独使用。
*/
//Caffeine 会有一个异步线程来专门负责清除缓存
Cache<String, String> cache = Caffeine.newBuilder()
//将最大数量设置为一
.maximumSize(1)
.expireAfterAccess(3L, TimeUnit.SECONDS)
.build();
cache.put("name","张三");
cache.put("age","18");
System.out.println(cache.getIfPresent("name"));
TimeUnit.MILLISECONDS.sleep(100);
System.out.println(cache.getIfPresent("name"));
System.out.println(cache.getIfPresent("age"));
log.info("-------------------------------------------");
// 权重淘汰机制
/**
* Caffeine提供了一种权重淘汰机制,可以给缓存项设置权重,当缓存项的容量超过最大限制时,Caffeine会优先淘汰权重较低的缓存项。
* 例如:
* 1. 缓存项A的权重为1,缓存项B的权重为2,缓存项C的权重为3。
* 2. 缓存项A被访问一次,缓存项B被访问两次,缓存项C被访问三次。
* 3. 当缓存项A被淘汰时,缓存项B和缓存项C都可以被淘汰。
*/
Cache<String, String> cache2 = Caffeine.newBuilder()
.maximumWeight(2)
.weigher((key, value) -> {
System.out.println("权重处理,key="+key+" value="+value);
if (key.equals("name")) {
return 1;
} else if (key.equals("age")) {
return 2;
} else {
return 3;
}
})
.build();
cache2.put("name","张三");
cache2.put("age","18");
cache2.put("sex","男");
TimeUnit.MILLISECONDS.sleep(100);
System.out.println(cache2.getIfPresent("name"));
System.out.println(cache2.getIfPresent("age"));
System.out.println(cache2.getIfPresent("sex"));
}
//基于引用驱逐策略--软引用:-Xms20m -Xmx20m
@Test
public void ExpireSoft() throws InterruptedException {
Cache<String, Object> cache = Caffeine.newBuilder()
.maximumSize(100)
.softValues()
.build();
cache.put("name",new SoftReference<>("张三"));
System.out.println("第1次读:"+cache.getIfPresent("name"));
List<byte[]> list = new LinkedList<>();
try {
for(int i=0;i<100;i++) {
list.add(new byte[1024*1024*1]); //1M的对象
}
} catch (Throwable e) {
//抛出了OOM异常时
TimeUnit.SECONDS.sleep(1);
System.out.println("OOM时读:"+cache.getIfPresent("name"));
System.out.println("Exception*************"+e.toString());
}
}
//基于引用驱逐策略--弱引用
@Test
public void ExpireWeak() throws InterruptedException {
Cache<String, Object> cache = Caffeine.newBuilder()
.maximumSize(100)
.weakValues()
.build();
cache.put("name",new WeakReference<>("张三"));
System.out.println("第1次读:"+cache.getIfPresent("name"));
System.gc();//进行一次GC垃圾回收
System.out.println("GC后读:"+cache.getIfPresent("name"));
}
//统计功能
@Test
public void CacheStats () throws Exception{
Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(2)
.recordStats() //开启统计功能
.removalListener(((key, value, cause) -> System.out.println("键:"+key+" 值:"+value+" 清除原因:"+cause)))
.expireAfterAccess(200L,TimeUnit.SECONDS)
.build();
cache.put("name","张三");
cache.put("sex","男");
cache.put("age","18");
//设置的key有些是不存在的,通过这些不存在的进行非命中操作
String[] keys = new String[]{"name","age","sex","phone","school"};
for (int i = 0; i < 1000; i++) {
cache.getIfPresent(keys[new Random().nextInt(keys.length)]);
}
CacheStats stats = cache.stats();
System.out.println("用户请求查询总次数:"+stats.requestCount());
System.out.println("命中个数:"+stats.hitCount());
System.out.println("命中率:"+stats.hitRate());
System.out.println("未命中次数:"+stats.missCount());
System.out.println("未命中率:"+stats.missRate());
System.out.println("加载次数:"+stats.loadCount());
System.out.println("总共加载时间:"+stats.totalLoadTime());
System.out.println("平均加载时间(单位-纳秒):"+stats.averageLoadPenalty ());
System.out.println("加载失败率:"+stats.loadFailureRate()); //加载失败率,= 总共加载失败次数 / 总共加载次数
System.out.println("加载失败次数:"+stats.loadFailureCount());
System.out.println("加载成功次数:"+stats.loadSuccessCount());
System.out.println("被淘汰出缓存的数据总个数:"+stats.evictionCount());
System.out.println("被淘汰出缓存的那些数据的总权重:"+stats.evictionWeight());
}
}
二、Caffeine + Redis 二级缓存架构设计
在高性能的服务项目中,我们一般会将一些热点数据存储到 Redis这类缓存中间件中,只有当缓存的访问没有命中时再查询数据库。在提升访问速度的同时,也能降低数据库的压力。
但是在一些场景下单纯使用 Redis 的分布式缓存不能满足高性能的要求,所以还需要加入使用本地缓存Caffeine,从而再次提升程序的响应速度与服务性能。于是,就产生了使用本地缓存(Caffeine)作为一级缓存,再加上分布式缓存(Redis)作为二级缓存的两级缓存架构。
二级缓存架构的优缺点
优点:
-
一级缓存基于应用的内存,访问速度非常快,对于一些变更频率低、实时性要求低的数据,可以放在本地缓存中,提升访问速度;
-
使用一级缓存能够减少和 Redis 的二级缓存的远程数据交互,减少网络 I/O 开销,降低这一过程中在网络通信上的耗时。
缺点:
-
数据一致性问题:两级缓存与数据库的数据要保持一致,一旦数据发生了修改,在修改数据库的同时,一级缓存、二级缓存应该同步更新。
-
分布式多应用情况下:一级缓存之间也会存在一致性问题,当一个节点下的本地缓存修改后,需要通知其他节点也刷新本地一级缓存中的数据,否则会出现读取到过期数据的情况。
-
缓存的过期时间、过期策略以及多线程的问题
实现二级缓存方案
手动二级缓存
@Configuration
public class CaffeineConfig {
@Bean
public Cache<String,Object> caffeineCache(){
return Caffeine.newBuilder()
.initialCapacity(128)//初始大小
.maximumSize(1024)//最大数量
.expireAfterWrite(15, TimeUnit.SECONDS)//过期时间 15S
.build();
}
}
//Caffeine+Redis两级缓存查询
public User query1_2(long userId){
String key = "user-"+userId;
User user = (User) cache.get(key,
k -> {
//先查询 Redis (2级缓存)
Object obj = redisTemplate.opsForValue().get(key);
if (Objects.nonNull(obj)) {
log.info("get data from redis:"+key);
return obj;
}
// Redis没有则查询 DB(MySQL)
User user2 = userMapper.selectById(userId);
log.info("get data from database:"+userId);
redisTemplate.opsForValue().set(key, user2, 30, TimeUnit.SECONDS);
return user2;
});
return user;
}
在 Cache 的 get 方法中,会先从Caffeine缓存中进行查找,如果找到缓存的值那么直接返回。没有的话查找 Redis,Redis 再不命中则查询数据库,最后都同步到Caffeine的缓存中。
注解方式实现二级缓存
在Spring中提供了CacheManager接口和对应的注解
-
@Cacheable:根据键从缓存中取值,如果缓存存在,那么获取缓存成功之后,直接返回这个缓存的结果。如果缓存不存在,那么执行方法,并将结果放入缓存中。
-
@CachePut:不管之前的键对应的缓存是否存在,都执行方法,并将结果强制放入缓存。
-
@CacheEvict:执行完方法后,会移除掉缓存中的数据。
// Spring中先配置CacheManager
@Bean
public CacheManager cacheManager(){
CaffeineCacheManager cacheManager=new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.initialCapacity(128)
.maximumSize(1024)
.expireAfterWrite(15, TimeUnit.SECONDS));
return cacheManager;
}
在启动类上再添加上 @EnableCaching 注解在UserService类对应的方法上添加 @Cacheable 注解
//Caffeine+Redis两级缓存查询-- 使用注解
@Cacheable(value = "user", key = "#userId")
public User query2_2(long userId){
String key = "user-"+userId;
//先查询 Redis (2级缓存)
Object obj = redisTemplate.opsForValue().get(key);
if (Objects.nonNull(obj)) {
log.info("get data from redis:"+key);
return (User)obj;
}
// Redis没有则查询 DB(MySQL)
User user = userMapper.selectById(userId);
log.info("get data from database:"+userId);
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.SECONDS);
return user;
}
然后就可以达到类似的效果。
@Cacheable 注解的属性:
参数 | 解释 | col3 |
key | 缓存的key,可以为空,如果指定要按照SpEL表达式编写,如不指定,则按照方法所有参数组合 | @Cacheable(value=”testcache”, key=”#userName”) |
value | 缓存的名称,在 spring 配置文件中定义,必须指定至少一个 | 例如:@Cacheable(value=”mycache”) |
condition | 缓存的条件,可以为空,使用 SpEL 编写,返回 true 或者 false,只有为 true 才进行缓存 | @Cacheable(value=”testcache”, condition=”#userName.length()>2”) |
methodName | 当前方法名 | #root.methodName |
method | 当前方法 | #root.method.name |
target | 当前被调用的对象 | #root.target |
targetClass | 当前被调用的对象的class | #root.targetClass |
args | 当前方法参数组成的数组 | #root.args[0] |
caches | 当前被调用的方法使用的Cache | #root.caches[0].name |
这里有一个condition属性指定发生的条件示例表示只有当userId为偶数时才会进行缓存
//只有当userId为偶数时才会进行缓存
@Cacheable(value = "user", key = "#userId", condition="#userId%2==0")
public User query2_3(long userId){
String key = "user-"+userId;
//先查询 Redis (2级缓存)
Object obj = redisTemplate.opsForValue().get(key);
if (Objects.nonNull(obj)) {
log.info("get data from redis:"+key);
return (User)obj;
}
// Redis没有则查询 DB(MySQL)
User user = userMapper.selectById(userId);
log.info("get data from database:"+userId);
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.SECONDS);
return user;
}
@CacheEvict是用来标注在需要清除缓存元素的方法或类上的。
当标记在一个类上时表示其中所有的方法的执行都会触发缓存的清除操作。@CacheEvict可以指定的属性有value、key、condition、allEntries和beforeInvocation。其中value、key和condition的语义与@Cacheable对应的属性类似。即value表示清除操作是发生在哪些Cache上的(对应Cache的名称);key表示需要清除的是哪个key,如未指定则会使用默认策略生成的key;condition表示清除操作发生的条件。下面我们来介绍一下新出现的两个属性allEntries和beforeInvocation。
//清除缓存(所有的元素)
@CacheEvict(value="user", key = "#userId",allEntries=true)
public void deleteAll(long userId) {
System.out.println(userId);
}
//beforeInvocation=true:在调用该方法之前清除缓存中的指定元素
@CacheEvict(value="user", key = "#userId",beforeInvocation=true)
public void delete(long userId) {
System.out.println(userId);
}
自定义注解实现二级缓存
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DoubleCache {
String cacheName();
String key(); //支持springEl表达式
long l2TimeOut() default 120;
CacheType type() default CacheType.FULL;
}
public enum CacheType {
FULL, //存取
PUT, //只存
DELETE //删除
}
public class ElParser {
public static String parse(String elString, TreeMap<String,Object> map){
elString=String.format("#{%s}",elString);
//创建表达式解析器
ExpressionParser parser = new SpelExpressionParser();
//通过evaluationContext.setVariable可以在上下文中设定变量。
EvaluationContext context = new StandardEvaluationContext();
map.entrySet().forEach(entry->
context.setVariable(entry.getKey(),entry.getValue())
);
//解析表达式
Expression expression = parser.parseExpression(elString, new TemplateParserContext());
//使用Expression.getValue()获取表达式的值,这里传入了Evaluation上下文
String value = expression.getValue(context, String.class);
return value;
}
}
@Slf4j
@Component
@Aspect
@AllArgsConstructor
public class CacheAspect {
private final Cache cache;
private final RedisTemplate redisTemplate;
@Pointcut("@annotation(com.msb.caffeine.cache.DoubleCache)")
public void cacheAspect() {
}
@Around("cacheAspect()")
public Object doAround(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
//拼接解析springEl表达式的map
String[] paramNames = signature.getParameterNames();
Object[] args = point.getArgs();
TreeMap<String, Object> treeMap = new TreeMap<>();
for (int i = 0; i < paramNames.length; i++) {
treeMap.put(paramNames[i],args[i]);
}
DoubleCache annotation = method.getAnnotation(DoubleCache.class);
String elResult = ElParser.parse(annotation.key(), treeMap);
String realKey = annotation.cacheName() + ":" + elResult;
//强制更新
if (annotation.type()== CacheType.PUT){
Object object = point.proceed();
redisTemplate.opsForValue().set(realKey, object,annotation.l2TimeOut(), TimeUnit.SECONDS);
cache.put(realKey, object);
return object;
}
//删除
else if (annotation.type()== CacheType.DELETE){
redisTemplate.delete(realKey);
cache.invalidate(realKey);
return point.proceed();
}
//读写,查询Caffeine
Object caffeineCache = cache.getIfPresent(realKey);
if (Objects.nonNull(caffeineCache)) {
log.info("get data from caffeine");
return caffeineCache;
}
//查询Redis
Object redisCache = redisTemplate.opsForValue().get(realKey);
if (Objects.nonNull(redisCache)) {
log.info("get data from redis");
cache.put(realKey, redisCache);
return redisCache;
}
log.info("get data from database");
Object object = point.proceed();
if (Objects.nonNull(object)){
//写入Redis
redisTemplate.opsForValue().set(realKey, object,annotation.l2TimeOut(), TimeUnit.SECONDS);
//写入Caffeine
cache.put(realKey, object);
}
return object;
}
}
切面中主要做了下面几件工作:
-
通过方法的参数,解析注解中 key 的 springEl 表达式,组装真正缓存的 key。
-
根据操作缓存的类型,分别处理存取、只存、删除缓存操作。
-
删除和强制更新缓存的操作,都需要执行原方法,并进行相应的缓存删除或更新操作。
-
存取操作前,先检查缓存中是否有数据,如果有则直接返回,没有则执行原方法,并将结果存入缓存。
然后使用的话就非常方便了,代码中只保留原有业务代码,再添加上我们自定义的注解就可以了:
三、二级缓存架构-缓存一致性问题
就是如果一个应用修改了缓存,另外一个应用的caffeine缓存是没有办法感知的,所以这里就会有缓存的一致性问题
解决方案也很简单,就是在Redis中做一个发布和订阅。
遇到修改缓存的处理,需要向对应的频道发布一条消息,然后应用同步监听这条消息,有消息则需要删除本地的Caffeine缓存。