本文目录
- 前言
- 本文术语
- 本文项目地址
- 设计思路
- 开发思路
- @DoubleCacheAble 双缓存注解(如何设计?)
- 动态条件表达式?例如:#a.id=?(如何解析?)
- 缓存切面(如何设计?)
- 缓存 CRUD 如何设计?(使用委派模式)
- 整合自动装配
- Redis 过期 Key 如何处理?
- 查缓存测试
- 过期 Key 清除测试
- 推荐阅读
前言
现在手上有个系统写操作比较少,很多接口都是读操作,也就是写多读少,性能上遇到瓶颈了,正所谓前人栽树、后人乘凉,原先系统每次都是查数据库的,性能比较低,如果先查 redis,redis 没数据再查数据库的话,但是还可以更快,那就是使用内存查询,依次按照内存、redis、db的顺序从快到慢查询,可使系统整体的性能提升一个档次,但是仅限于读多写少的场景,写多读少的场景没必要搞这么多缓存,搞多了缓存一致性也是个问题,就好比 mysql 数据库的读多写少,我们可以用 MYISM 存储引擎。
本文术语
- CaffeineCache:一级缓存
- Redis:二级缓存
本文项目地址
此项目已收录于 Gitee,感兴趣的小伙伴可以克隆下来去查看一下,也欢迎提出宝贵意见大家一起来优化这个项目。
MRCache:https://gitcode.net/qq_42875345/mrcache
设计思路
给系统加二层缓存,怎么加?每个接口都加个判断,先从内存查,内存没数据再查 redis 再查 db ?那工作量太大了,且代码耦合性太高,代码看着也难看一大坨同质化的代码。先说个结论。如果你们的项目架构比较好,所有本地接口或者是 Rpc 接口调用,采用了责任链来实现只需在责任链头部新增一个,查缓存的节点即可。责任链设计模式精讲入口,
如果没用到责任链,那利用 Aop 切面+自定义注解+ Spel 框架+ CaffeineCache 内存框架 来实现即可,工作量也不大,加个切面即可。接下来进入实战。
开发思路
下面贴一段 Spring 缓存中的 @CacheAble 注解使用代码,我们配个 RedisCacheManager 后,使用此注解即可将返回结果存入Redis。Redis 中有缓存则不会执行方法中的逻辑。思考那么是否我们可以写一个 @DoubleCacheAble 注解,将原先查 Redis 的逻辑替换成,先查本地缓存、再查 Redis、最后查 db 的逻辑呢?答案是可以的且有俩种实现方式。
@Cacheable(value = "doubleCache: ", key = "#student.sId", unless = "0")
public Object testCacheable(Student student) throws InterruptedException {
Thread.sleep(1000 * 10);
return map;
}
- 方式一:重写 Cache 、CacheManager 、CacheResolver 、KeyGenerator 接口,然后定制化里面的方法,改成自己的逻辑即可,加多少层缓存都没问题。二开比较繁琐,且容易出错
举个例子就拿 @CacheAble 的使用来说,为什么每次我们使用这些注解前都要加如下的配置,那是因为 spring.data.redis 包帮我们二次封装了 Cache、CacheManager 的逻辑,且提供了默认的 KeyGenerator、CacheResolver 等实现类,感兴趣的小伙子可以自行 debug 源码
@Data
@ConfigurationProperties(prefix = "spring.redis")
@EnableCaching
@Configuration
public class CacheConfig extends CachingConfigurerSupport {
private int database;
private String host;
private int port;
private String password;
@Bean
public CacheManager cacheManager() {
RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ZERO)
.disableCachingNullValues()
.computePrefixWith(cacheName -> "caching_fm:" + cacheName);
RedisCacheManager redisCacheManager = RedisCacheManager.builder(redisConnectionFactory())
.cacheDefaults(configuration) // 默认配置(强烈建议配置上)。 比如动态创建出来的都会走此默认配置
.build();
return redisCacheManager;
}
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
configuration.setHostName(host);
configuration.setPassword(RedisPassword.of(password));
configuration.setPort(port);
configuration.setDatabase(database);
LettuceConnectionFactory factory = new LettuceConnectionFactory(configuration);
return factory;
}
}
还有一种方式也是最容易实现的一种方式就是前言提到的加一层切面,对所有查询操作切入,织入查二层缓存的逻辑。
@DoubleCacheAble 双缓存注解(如何设计?)
高效简洁的开发当然少不了我们的自定义注解辣,完全对标 @CacheAble ,支持动态 SPEL 解析,是否缓存空值等等。日后需要增加更复杂的功能完善该注解就行。一个注解代码不做过多解释。
//作用于方法
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DoubleCacheAble {
//缓存key:静态写死部分
String value();
//缓存key:动态spel部分
String key();
//操作类型
String type();
//是否缓存空值,默认不缓存空
String unLess() default "0";
}
动态条件表达式?例如:#a.id=?(如何解析?)
一开始我还天真的以为,要不要写个算法来实现,想想都头大。后来想着 @CacheAble 这个注解不是已经实现了这个功能吗,把他里面的源码 copy 出来不就行了。但是 copy 了一会发现不对劲,各种缺包,于是乎开始 debug 源码,直到 debug 到如下这行代码,#student.id 被解析了,然后发现了 Spring 里面的存在一种名叫 SPEL 解析的技术包,拿来即用。
然后摸索了一会便有了我如下的这个 demo ,就是对源码的封装解析了一下。本质都是利用 Spring 里面的工具类。可以看到动态表达式已经被我成功解析,不得感叹我可真是个小天才。
public static void main(String[] args) throws NoSuchMethodException {
Method method = new DoubleCacheServiceImpl().getClass().getMethod("testCacheable", Student.class);
Object[] cusArgs = new Object[1];
cusArgs[0] = Student.builder()
.sId(666)
.sName("测试name")
.build();
Object value = PARSER.parseExpression("#student.sId+'-'+#student.sName")
.getValue(new MethodBasedEvaluationContext(null, method, cusArgs, NAME_DISCOVERER));
System.err.println("SPEL表达式解析出来的内容为:"+value);
}
具体的参数列个说明吧:
- MethodBasedEvaluationContext:方法上下文,值是从中获取的
- method:被解析的方法
- cusArgs:被解析方法中的参数值
- SpelExpressionParser:Spring 提供的 SPEL 解析包
- DefaultParameterNameDiscoverer:用的默认,没深揪源码
缓存切面(如何设计?)
考虑到缓存一致性,以及注解的泛用性,其实这里面的代码要实现高可用还是有点难度的。首先我们要对增、删、改、查 操作都写对应的逻辑。例如查 db ,放缓存,此时 db 更新数据,为了保证 db 与缓存一致性,还需同步删除缓存,然后更新缓存。当然我这里不是专门开发消息中间件的,写本文的目的更多的是在于,让大家知道如何进行设计一个二级缓存框架。考虑到现在是简洁开发的天下,结合之前看 Spring 自动装配的源码,自己手撸一个 jar 包封装所有的逻辑,让大家只需导入 jar 包,就可以调用我定义的注解完成二级缓存查询。
/**
* aop 环绕通知
*/
@Slf4j
public class DoubleCacheInterceptor implements MethodInterceptor {
private AnalysisKeyCache analysisKeyCache;
public DoubleCacheInterceptor(AnalysisKeyCache analysisKeyCache) {
this.analysisKeyCache = analysisKeyCache;
}
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
DoubleCacheAble doubleCache = getDoubleCache(invocation.getMethod());
if(doubleCache==null) return invocation.proceed();
String realKey = String.valueOf(getRealKey(invocation.getArguments(), invocation.getMethod(),
doubleCache.key(), doubleCache.value()));
Object cacheValue = analysisKeyCache.get(realKey);
if (null != cacheValue) return cacheValue;
Object proceed = invocation.proceed();
analysisKeyCache.put(realKey, doubleCache.unLess(), proceed);
return proceed;
}
public DoubleCacheAble getDoubleCache(Method method) {
DoubleCacheAble targetDataSource = method.getAnnotation(DoubleCacheAble.class);
if (targetDataSource == null) {
Class<?> declaringClass = method.getDeclaringClass();
targetDataSource = declaringClass.getAnnotation(DoubleCacheAble.class);
}
return targetDataSource;
}
public Object getRealKey(Object[] cusArgs, Method method, String key, String value) {
Object realKey = value + new SpelExpressionParser().parseExpression(key)
.getValue(new MethodBasedEvaluationContext(null, method, cusArgs, new DefaultParameterNameDiscoverer()));
log.info("{} SPEL表达式解析得到的完整key: {}", method.getName(), realKey);
return realKey;
}
}
想到切面大家可能第一时间想到的是用 @Aspect+@Around 实现,但是对于开源项目来说,所有轮子都是自己造的,为什么还要用轮子拼轮子呢?况且由于切面过多,可能导致我们自己的切面无法第一时间执行这也是个问题, 因此我这里采用 MethodInterceptor (方法拦截器)方法实现 AOP 拦截。
缓存 CRUD 如何设计?(使用委派模式)
由于我们要用到 CaffeineCache+Redis 这俩种缓存,考虑到代码解耦,决定用委派模式实现。此处借鉴 Mybatis 二级缓存源码中的设计,利用委派模式将日志缓存、序列化缓存、LRU缓存、定时缓存、持久化缓存代码各自抽离出来,实现解耦的目的。
为此我设计了如下四个缓存,当一个查询请求过来会先经过 AnalysisKeyCache 隐式的为 key 添加前缀,然后经过 SerializeCache 依次从 MRCaffeineCache 、RedisCache 获取值,最后将值反序列化给我们。看懂了我的这段代码,再去看 Mybatis 获取二级缓存的源码将十分简单。
- AnalysisKeyCache:为 key 加统一前缀
- SerializeCache:缓存 value 值转换成 byte 数组存储
- MRCaffeineCache:CaffeineCache 本地缓存(CRUD)
- RedisCache:Redis 缓存(CRUD)
整合自动装配
要想实现让大家开箱即用第三方 jar 包,自动装配少不了。在 resources 目录下创建一个 MATE_INF 文件夹,放入一个 spring.factories 文件,里面的内容 org.springframework.boot.autoconfigure.EnableAutoConfiguration 这段是固定的,Value 值代表要变成 Bean 的类。当 jar 引入各自项目中来时这些类就会变成项目中的 Bean。至于为什么推荐大家阅读自动装配的源码,本文不做过多阐述。
然后编写 MRCacheAutoConfiguration 类,约定哪些类要变成 Bean 即可。
Redis 过期 Key 如何处理?
存在这么一种情况就是二级缓存数据过期了,一级换存还有数据,为了保证缓存一致性,此时需监听过期 Key ,同步删除一级缓存。那么有人会说了,一级缓存过期不要删除二级缓存吗,一级缓存本就是为了缓解二级缓存压力而设计的,且为内存,一级缓存过期了无需做任何操作,毕竟二级缓存才是我们的兜底。
查缓存测试
新建一个项目引入我们的 MRCache 包,使用其提供的 @DoubleCacheAble 缓存,编写对应的测试 Service即可。做到 0 代码入侵。
调用接口查询数据,发现第一次查十分缓慢,第二次查很快走了缓存。
全局的 key 前缀也正常被设置。逻辑在 AnalysisKeyCache 里面,这里不做阐述了。
再次查询直接走的内存缓存了。
过期 Key 清除测试
手动修改 Redis 数据的 TTL 为 1,过一秒成功触发我们的监听方法执行里面的逻辑
推荐阅读
手把手debug自动装配源码、顺带弄懂了@Import等相关的源码(全文3w字、超详细)
深入mybatis源码解读~手把手带你debug分析源码