在高性能的服务架构设计中,缓存是一个不可或缺的环节。在实际的项目中,我们通常会将一些热点数据存储到redis
或memcache
这类缓存中间件中,只有当缓存的访问没有命中时再查询数据库。在提升访问速度的同时,也能降低数据库的压力。
随着不断的发展,这一架构也产生了改进,在一些场景下可能单纯使用redis
类的远程缓存已经不够了,还需要进一步配合本地缓存使用,例如guava cache
或caffeine
,从而再次提升程序的响应速度与服务性能。于是,就产生了使用本地缓存作为一级缓存,再加上远程缓存作为二级缓存的两级缓存架构。
在先不考虑并发等复杂问题的情况下,两级缓存的访问流程可以用下面这张图来表示:
优点与问题
那么,使用两级缓存相比单纯使用远程缓存,具有什么优势呢?
- 本地缓存基于本地环境的内存,访问速度非常快,对于一些变更频率低、实时性要求低的数据,可以放在本地缓存中,提升访问速度
- 使用本地缓存能够减少和
redis
类的远程缓存间的数据交互,减少网络i/o开销,降低这一过程中在网络通信上的耗时
但是在设计中,还是要考虑一些问题的,例如数据一致性问题。首先,两级缓存与数据库的数据要保持一致,一旦数据发生了修改,在修改数据库的同时,本地缓存、远程缓存应该同步更新。
另外,如果是分布式环境下,一级缓存之间也会存在一致性问题,当一个节点下的本地缓存修改后,需要通知其他节点也刷新本地缓存中的数据,否则会出现读取到过期数据的情况,这一问题可以通过类似于redis中的发布/订阅功能解决。
此外,缓存的过期时间、过期策略以及多线程访问的问题也都需要考虑进去,不过我们今天暂时先不考虑这些问题,先看一下如何简单高效的在代码中实现两级缓存的管理。
准备工作
在简单梳理了一下要面对的问题后,下面开始两级缓存的代码实战,我们整合号称最强本地缓存的caffeine
作为一级缓存、性能之王的redis
作为二级缓存。首先建一个springboot项目,引入缓存要用到的相关的依赖:
<dependency> <groupid>com.github.ben-manes.caffeine</groupid> <artifactid>caffeine</artifactid> <version>2.9.2</version> </dependency> <dependency> <groupid>org.springframework.boot</groupid> <artifactid>spring-boot-starter-data-redis</artifactid> </dependency> <dependency> <groupid>org.springframework.boot</groupid> <artifactid>spring-boot-starter-cache</artifactid> </dependency> <dependency> <groupid>org.apache.commons</groupid> <artifactid>commons-pool2</artifactid> <version>2.8.1</version> </dependency>
在application.yml
中配置redis
的连接信息:
spring: redis: host: 127.0.0.1 port: 6379 database: 0 timeout: 10000ms lettuce: pool: max-active: 8 max-wait: -1ms max-idle: 8 min-idle: 0
在下面的例子中,我们将使用redistemplate
来对redis
进行读写操作,redistemplate
使用前需要配置一下connectionfactory
和序列化方式,这一过程比较简单就不贴出代码了,有需要本文全部示例代码的可以在文末获取。
下面我们在单机环境下,将按照对业务侵入性的不同程度,分三个版本来实现两级缓存的使用。
v1.0版本
我们可以通过手动操作caffeine
中的cache
对象来缓存数据,它是一个类似map
的数据结构,以key
作为索引,value
存储数据。在使用cache
前,需要先配置一下相关参数:
@configuration public class caffeineconfig { @bean public cache<string,object> caffeinecache(){ return caffeine.newbuilder() .initialcapacity(128)//初始大小 .maximumsize(1024)//最大数量 .expireafterwrite(60, timeunit.seconds)//过期时间 .build(); } }
简单解释一下cache
相关的几个参数的意义:
initialcapacity
:初始缓存空大小maximumsize
:缓存的最大数量,设置这个值可以避免出现内存溢出expireafterwrite
:指定缓存的过期时间,是最后一次写操作后的一个时间,这里
此外,缓存的过期策略也可以通过expireafteraccess
或refreshafterwrite
指定。
在创建完成cache
后,我们就可以在业务代码中注入并使用它了。在没有使用任何缓存前,一个只有简单的service
层代码是下面这样的,只有crud操作:
@service @allargsconstructor public class orderserviceimpl implements orderservice { private final ordermapper ordermapper; @override public order getorderbyid(long id) { order order = ordermapper.selectone(new lambdaquerywrapper<order>() .eq(order::getid, id)); return order; } @override public void updateorder(order order) { ordermapper.updatebyid(order); } @override public void deleteorder(long id) { ordermapper.deletebyid(id); } }
接下来,对上面的orderservice
进行改造,在执行正常业务外再加上操作两级缓存的代码,先看改造后的查询操作:
public order getorderbyid(long id) { string key = cacheconstant.order + id; order order = (order) cache.get(key, k -> { //先查询 redis object obj = redistemplate.opsforvalue().get(k); if (objects.nonnull(obj)) { log.info("get data from redis"); return obj; } // redis没有则查询 db log.info("get data from database"); order myorder = ordermapper.selectone(new lambdaquerywrapper<order>() .eq(order::getid, id)); redistemplate.opsforvalue().set(k, myorder, 120, timeunit.seconds); return myorder; }); return order; }
在cache
的get
方法中,会先从缓存中进行查找,如果找到缓存的值那么直接返回。如果没有找到则执行后面的方法,并把结果加入到缓存中。
因此上面的逻辑就是先查找caffeine
中的缓存,没有的话查找redis
,redis
再不命中则查询数据库,写入redis
缓存的操作需要手动写入,而caffeine
的写入由get
方法自己完成。
在上面的例子中,设置caffeine
的过期时间为60秒,而redis
的过期时间为120秒,下面进行测试,首先看第一次接口调用时,进行了数据库的查询:
而在之后60秒内访问接口时,都没有打印打任何sql或自定义的日志内容,说明接口没有查询redis
或数据库,直接从caffeine
中读取了缓存。
等到距离第一次调用接口进行缓存的60秒后,再次调用接口:
可以看到这时从redis
中读取了数据,因为这时caffeine
中的缓存已经过期了,但是redis
中的缓存没有过期仍然可用。
下面再来看一下修改操作,代码在原先的基础上添加了手动修改redis
和caffeine
缓存的逻辑:
public void updateorder(order order) { log.info("update order data"); string key=cacheconstant.order + order.getid(); ordermapper.updatebyid(order); //修改 redis redistemplate.opsforvalue().set(key,order,120, timeunit.seconds); // 修改本地缓存 cache.put(key,order); }
看一下下面图中接口的调用、以及缓存的刷新过程。可以看到在更新数据后,同步刷新了缓存中的内容,再之后的访问接口时不查询数据库,也可以拿到正确的结果:
最后再来看一下删除操作,在删除数据的同时,手动移除reids
和caffeine
中的缓存:
public void deleteorder(long id) { log.info("delete order"); ordermapper.deletebyid(id); string key= cacheconstant.order + id; redistemplate.delete(key); cache.invalidate(key); }
我们在删除某个缓存后,再次调用之前的查询接口时,又会出现重新查询数据库的情况:
简单的演示到此为止,可以看到上面这种使用缓存的方式,虽然看起来没什么大问题,但是对代码的入侵性比较强。在业务处理的过程中要由我们频繁的操作两级缓存,会给开发人员带来很大负担。那么,有什么方法能够简化这一过程呢?我们看看下一个版本。
v2.0版本
在spring
项目中,提供了cachemanager
接口和一些注解,允许让我们通过注解的方式来操作缓存。先来看一下常用几个注解说明:
@cacheable
:根据键从缓存中取值,如果缓存存在,那么获取缓存成功之后,直接返回这个缓存的结果。如果缓存不存在,那么执行方法,并将结果放入缓存中。@cacheput
:不管之前的键对应的缓存是否存在,都执行方法,并将结果强制放入缓存@cacheevict
:执行完方法后,会移除掉缓存中的数据。
如果要使用上面这几个注解管理缓存的话,我们就不需要配置v1版本中的那个类型为cache
的bean
了,而是需要配置spring
中的cachemanager
的相关参数,具体参数的配置和之前一样:
@configuration public class cachemanagerconfig { @bean public cachemanager cachemanager(){ caffeinecachemanager cachemanager=new caffeinecachemanager(); cachemanager.setcaffeine(caffeine.newbuilder() .initialcapacity(128) .maximumsize(1024) .expireafterwrite(60, timeunit.seconds)); return cachemanager; } }
然后在启动类上再添加上@enablecaching
注解,就可以在项目中基于注解来使用caffeine
的缓存支持了。下面,再次对service
层代码进行改造。
首先,还是改造查询方法,在方法上添加@cacheable
注解:
@cacheable(value = "order",key = "#id") //@cacheable(cachenames = "order",key = "#p0") public order getorderbyid(long id) { string key= cacheconstant.order + id; //先查询 redis object obj = redistemplate.opsforvalue().get(key); if (objects.nonnull(obj)){ log.info("get data from redis"); return (order) obj; } // redis没有则查询 db log.info("get data from database"); order myorder = ordermapper.selectone(new lambdaquerywrapper<order>() .eq(order::getid, id)); redistemplate.opsforvalue().set(key,myorder,120, timeunit.seconds); return myorder; }
@cacheable
注解的属性多达9个,好在我们日常使用时只需要配置两个常用的就可以了。其中value
和cachenames
互为别名关系,表示当前方法的结果会被缓存在哪个cache
上,应用中通过cachename
来对cache
进行隔离,每个cachename
对应一个cache
实现。value
和cachenames
可以是一个数组,绑定多个cache
。
而另一个重要属性key
,用来指定缓存方法的返回结果时对应的key
,这个属性支持使用springel
表达式。通常情况下,我们可以使用下面几种方式作为key
:
#参数名 #参数对象.属性名 #p参数对应下标
在上面的代码中,我们看到添加了@cacheable
注解后,在代码中只需要保留原有的业务处理逻辑和操作redis
部分的代码即可,caffeine
部分的缓存就交给spring处理了。
下面,我们再来改造一下更新方法,同样,使用@cacheput
注解后移除掉手动更新cache
的操作:
@cacheput(cachenames = "order",key = "#order.id") public order updateorder(order order) { log.info("update order data"); ordermapper.updatebyid(order); //修改 redis redistemplate.opsforvalue().set(cacheconstant.order + order.getid(), order, 120, timeunit.seconds); return order; }
注意,这里和v1版本的代码有一点区别,在之前的更新操作方法中,是没有返回值的void
类型,但是这里需要修改返回值的类型,否则会缓存一个空对象到缓存中对应的key
上。当下次执行查询操作时,会直接返回空对象给调用方,而不会执行方法中查询数据库或redis
的操作。
最后,删除方法的改造就很简单了,使用@cacheevict
注解,方法中只需要删除redis
中的缓存即可:
@cacheevict(cachenames = "order",key = "#id") public void deleteorder(long id) { log.info("delete order"); ordermapper.deletebyid(id); redistemplate.delete(cacheconstant.order + id); }
可以看到,借助spring
中的cachemanager
和cache
相关的注解,对v1版本的代码经过改进后,可以把全手动操作两级缓存的强入侵代码方式,改进为本地缓存交给spring
管理,redis
缓存手动修改的半入侵方式。那么,还能进一步改造,使之成为对业务代码完全无入侵的方式吗?
v3.0版本
模仿spring
通过注解管理缓存的方式,我们也可以选择自定义注解,然后在切面中处理缓存,从而将对业务代码的入侵降到最低。
首先定义一个注解,用于添加在需要操作缓存的方法上:
@target(elementtype.method) @retention(retentionpolicy.runtime) @documented public @interface doublecache { string cachename(); string key(); //支持springel表达式 long l2timeout() default 120; cachetype type() default cachetype.full; }
我们使用cachename + key
作为缓存的真正key
(仅存在一个cache
中,不做cachename
隔离),l2timeout
为可以设置的二级缓存redis
的过期时间,type
是一个枚举类型的变量,表示操作缓存的类型,枚举类型定义如下:
public enum cachetype { full, //存取 put, //只存 delete //删除 }
因为要使key
支持springel
表达式,所以需要写一个方法,使用表达式解析器解析参数:
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; }
参数中的elstring
对应的就是注解中key
的值,map
是将原方法的参数封装后的结果。简单进行一下测试:
public void test() { string elstring="#order.money"; string elstring2="#user"; string elstring3="#p0"; treemap<string,object> map=new treemap<>(); order order = new order(); order.setid(111l); order.setmoney(123d); map.put("order",order); map.put("user","hydra"); string val = parse(elstring, map); string val2 = parse(elstring2, map); string val3 = parse(elstring3, map); system.out.println(val); system.out.println(val2); system.out.println(val3); }
执行结果如下,可以看到支持按照参数名称、参数对象的属性名称读取,但是不支持按照参数下标读取,暂时留个小坑以后再处理。
123.0
hydra
null
至于cache
相关参数的配置,我们沿用v1版本中的配置即可。准备工作做完了,下面我们定义切面,在切面中操作cache
来读写caffeine
的缓存,操作redistemplate
读写redis
缓存。
@slf4j @component @aspect @allargsconstructor public class cacheaspect { private final cache cache; private final redistemplate redistemplate; @pointcut("@annotation(com.cn.dc.annotation.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() + cacheconstant.colon + 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
- 根据操作缓存的类型,分别处理存取、只存、删除缓存操作
- 删除和强制更新缓存的操作,都需要执行原方法,并进行相应的缓存删除或更新操作
- 存取操作前,先检查缓存中是否有数据,如果有则直接返回,没有则执行原方法,并将结果存入缓存
修改service
层代码,代码中只保留原有业务代码,再添加上我们自定义的注解就可以了:
@doublecache(cachename = "order", key = "#id", type = cachetype.full) public order getorderbyid(long id) { order myorder = ordermapper.selectone(new lambdaquerywrapper<order>() .eq(order::getid, id)); return myorder; } @doublecache(cachename = "order",key = "#order.id", type = cachetype.put) public order updateorder(order order) { ordermapper.updatebyid(order); return order; } @doublecache(cachename = "order",key = "#id", type = cachetype.delete) public void deleteorder(long id) { ordermapper.deletebyid(id); }
到这里,基于切面操作缓存的改造就完成了,service
的代码也瞬间清爽了很多,让我们可以继续专注于业务逻辑处理,而不用费心去操作两级缓存了。
总结
本文按照对业务入侵的递减程度,依次介绍了三种管理两级缓存的方法。至于在项目中是否需要使用二级缓存,需要考虑自身业务情况,如果redis这种远程缓存已经能够满足你的业务需求,那么就没有必要再使用本地缓存了。毕竟实际使用起来远没有那么简单,本文中只是介绍了最基础的使用,实际中的并发问题、事务的回滚问题都需要考虑,还需要思考什么数据适合放在一级缓存、什么数据适合放在二级缓存等等的其他问题。
到此这篇关于redis结合caffeine两级缓存的实现示例的文章就介绍到这了,更多相关redis caffeine两级缓存内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论