当前位置: 首页 > 科技观察

面对缓存,需要考虑哪些问题?

时间:2023-03-19 23:53:29 科技观察

缓存可以说是无处不在。比如PC电脑中的内存、CPU中的二级缓存、http协议中的缓存控制、CDN加速技术等都是利用缓存的思想来解决性能问题。缓存是解决高并发场景下系统性能和稳定性问题的灵丹妙药。本文主要讨论我们经常使用的分布式缓存Redis在开发过程中需要考虑的问题。1、业务逻辑如何与缓存解耦?大多数情况下,大家会在缓存操作和业务逻辑之间穿插代码,比如(代码一):;Useruser=redisTemplate.opsForValue().get(cacheKey);if(null!=user){returnuser;}user=userMapper.getUserById(userId)。;redisTemplate.opsForValue().del(cacheKey);}}从上面的代码我们可以看出以下问题:缓存操作非常繁琐,产生大量重复代码;缓存操作与业务逻辑高度耦合,不利于后期维护;当业务数据为null时,无法判断是否缓存过,会导致缓存无法激活;在开发阶段,为了排查问题,经常需要来回切换缓存功能,而使用上面的代码无法轻松完成切换缓存功能;当业务越来越复杂,使用缓存的地方越来越多时,很难定位到哪些数据应该主动删除;如果不想用Redis而用其他的缓存技术,那是一件多么痛苦的事情。因为高耦合带来的问题还有很多,就不一一列举了。下面介绍一下作者开源的一个缓存管理框架:AutoLoadCache是??如何帮助我们解决以上问题的。借鉴Spring缓存的思想,采用AOP+Annotation等技术实现缓存与业务逻辑的解耦。我们再用AutoLoadCache重构上面的代码进行对比(代码2):(value="'user'+#args[0].id")})voidupdateUser(Useruser);}publicUserServiceImplimplementsUserService{@AutowiredprivateUserMapperuserMapper;publicUsergetUserById(LonguserId){returnuserMapper.getUserById(userId);}@Transactional(rollbackFor=Throwable.class)publicvoidupdateUser(Useruser){userMapper.updateUser(user);}}AutoloadCacheAOP拦截请求后,大致流程如下:获取拦截方法的@Cache注解,生成缓存key;通过缓存key,去缓存中获取数据;如果缓存为***,则执行以下流程:如果需要自动加载,则将相关信息保存在自动加载队列中;否则判断缓存是否即将过期,如果即将过期则发起异步刷新;***返回数据给用户;如果缓存中没有***,则执行如下流程:选举一个leader返回数据源加载数据,加载数据后,通知其他请求从内存中获取数据(使用主义机制);领导者负责将数据写入缓存;如果需要自动加载,则将相关信息保存在自动加载队列中;***将数据返回给用户;这里提到的异步刷新、自动加载、fetch机制,后面会详细介绍。2.将缓存“打包”在上面代码1的例子中,当从数据源获取的数据为null时,缓存就没有意义了,所有获取这个数据的请求都会返回到数据源获取数据。当请求量很大时,会导致数据源负载过高而导致宕机。因此,对于空数据,需要进行特殊的处理,比如使用特殊的字符串进行替换。在AutoloadCache中,使用了一个wrapper来包装所有的缓存数据(代码3):publicclassCacheWrapperimplementsSerializable,Cloneable{privateTcacheObject;//缓存数据privatelonglastLoadTime;//加载时间privateintexpire;//缓存时长/***判断是否缓存已过期*@returnboolean*/publicbooleanisExpired(){if(expire>0){return(System.currentTimeMillis()-lastLoadTime)>expire*1000;}returnfalse;}}在上面的代码中,除了封装之外为了缓存数据,它还封装了数据加载时间和缓存持续时间。通过这两个数据,很容易判断缓存是即将过期还是已经过期。3.如何提高缓存键生成表达式的性能?在使用Annotation解决了缓存与业务的耦合之后,我们的主要任务就是如何设计缓存的key。缓存键设计的粒度越小,缓存的可重用性就越高。更好。在上面的示例中,我们使用SpringEL表达式来生成缓存键。可能有人会担心SpringEL表达式性能不好,或者不想用Spring怎么办?为了满足这些需求,框架支持扩展表达式解析器:在继承com.jarvis.cache.script之后。AbstractScriptParser,你可以扩展它。除了SpringEL表达式之外,该框架现在还支持Ognl、javascript表达式。对于性能要求非常高的,可以使用Ognl,其性能非常接近nativecode。4、如何解决缓存键冲突的问题?在实际情况下,可能会有多个模块共享一个Redis服务器或Redis集群,这可能会导致缓存键冲突。为了解决AutoLoadCache这个问题,增加命名空间。如果设置了命名空间,则在每个缓存key的前面加上命名空间(代码4):publicfinalclassCacheKeyTOimplementsSerializable{privatefinalStringnamespace;privatefinalStringkey;//CacheKeyprivatefinalStringhfield;//设置哈希表中的字段,如果设置了这个,使用存储的hashTablepublicStringgetCacheKey(){//生成缓存Key方法if(null!=this.namespace&&this.namespace.length()>0){returnnewStringBuilder(this.namespace).append(":").append(this.key).toString();}returnthis.key;}}5.压缩缓存数据,提高序列化和反序列化性能考虑序列化和反序列化的性能。为了满足不同用户的需求,AutoLoadCache基于JDK、Hessian、JacksonJson、Fastjson、JacksonMsgpack等技术实现了序列化和反序列化工具。它也可以通过实现com.jarvis.cache.serializer.ISerializer接口来扩展。JDK自带的序列化和反序列化工具产生的数据包非常大,性能也很差,不推荐大家使用;JacksonJson和Fastjson都是基于JSON的,所有使用缓存的函数的参数和返回值都必须是特定的类型,不能是不确定的类型(不是Object、List等)。另外,有些数据在转成Json的时候,它的一些属性会被忽略掉。当存在这种情况时,不能使用Json;而Hessian是非常好的选择,非常成熟稳定。阿里的dubbo和HSFRPC框架都使用Hessian进行序列化和反序列化。6、如何降低回源并发数?当没有安装缓存时,需要回到数据源去取数据。如果对同一个数据有多个并发请求(即同一个缓存key请求),都回数据源加载数据并写入缓存,造成资源的极大浪费,也可能导致数据源超载而无法服务。AutoLoadCache通过使用principal机制和自动加载机制来解决这个问题:principal机制作为master机制,也就是说当多个用户请求同一个数据时,会选举出一个leader从数据源加载数据。其他用户等待他们获得的数据。并且数据由leader写入缓存。自动加载机制自动加载机制将用户请求、缓存时间等信息放入一个队列中。后台使用线程池定时扫描队列。如果发现缓存即将过期,则从数据源中加载最新的数据并放入缓存中。达到将数据保存在内存中的效果。这样一来,所有对这些数据的请求都指向了缓存,而不是回到数据源去获取数据。非常适合缓存非常频繁使用的数据和非常耗时的数据。为了防止自动加载队列过大,设置了容量限制;同时,那些超过一定时间没有被用户请求的也会从自动加载队列中移除,为真正需要的请求释放服务器资源。将数据写入缓存的性能比读取性能差很多。通过以上两种机制,可以降低写入缓存的并发度,提高缓存服务能力。7、异步刷新AutoLoadCache从缓存中获取数据后,借助上面提到的CacheWrapper,可以很方便的判断缓存是否即将过期,如果即将过期,则会发起异步刷新请求.使用异步刷新的目的是将数据提前缓存起来,避免缓存失效后大量请求穿透到数据源。8.支持多种缓存操作大多数情况下,我们是对缓存进行读写操作,但有时,我们只需要从缓存中读取数据,或者只需要写入数据,那么我们可以通过@CacheAction类型的opType来指定缓存。目前支持以下几种操作:READ_WRITE:读写缓存操作:如果缓存中有数据,则使用缓存中的数据;如果缓存中没有数据,则加载数据并写入缓存。默认为READ_WRITE;WRITE:从数据源加载最新的数据,写入缓存。同步数据源和缓存数据;READ_ONLY:只从缓存中读取,不会从数据源加载数据。异地读写缓存的场景;LOAD:只从数据源加载数据,不读取缓存中的数据,也不写入缓存。另外@Cache只能静态引用写缓存操作类型。如果要在运行时调整操作类型,需要通过CacheHelper.setCacheOpType()方法进行调整。9、批量删除缓存很多时候,数据查询条件比较复杂,我们无法获取或恢复要删除的缓存键。为了解决这个问题,AutoLoadCache使用Redis的哈希表来管理这部分缓存。将需要批量删除的缓存放在同一个哈希表中。如果需要批量删除这些缓存,直接删除哈希表即可。这时候只需要设计一个粒度合理的缓存键即可。通过@Cache的hfield设置哈希表的key。我们以产品评论场景为例(代码5):publicinterfaceProuductCommentMapper{@Cache(expire=600,key="'prouduct_comment_list_'+#args[0]",hfield="#args[1]+'_'+#args[2]")//比如:proudId=1,pageNo=2,pageSize=3相当于Redis命令:HSETprouduct_comment_list_12_3ListpublicListgetCommentListByProuductId(LongprouductId,intpageNo,intpageSize);@CacheDelete({@CacheDeleteKey(value="'prouduct_comment_list_'+#args[0].prouductId")})//例如:当#args[0].prouductId=1时,相当于Redis命令:DELprouduct_comment_list_1publicvoidaddComment(ProuductCommentcomment);}如果我们添加评论,我们只需要主动删除前3页的评论(代码6):publicinterfaceProuductCommentMapper{@Cache(expire=600,key="'prouduct_comment_list_'+#args[0]+'_'+#args[1]",hfield="#args[2]")publicListgetCommentListByProuductId(LongprouductId,intpageNo,intpageSize);@CacheDelete({@CacheDeleteKey(value="'prouduct_comment_list_'+#args[0].临uductId+'_1'"),@CacheDeleteKey(value="'prouduct_comment_list_'+#args[0].prouductId+'_2'"),@CacheDeleteKey(value="'prouduct_comment_list_'+#args[0].prouductId+'_3'")})publicvoidaddComment(ProuductCommentcomment);}10.双写不一致问题在使用“代码2”中的updateUser方法更新用户信息时,会同时主动删除缓存中的数据,如果在事务提交前有另一个请求加载用户数据,旧的此时数据库中的数据会被缓存起来,在下次主动删除缓存或者缓存过期之前的一段时间内,缓存中的数据与数据库中的数据不一致。为了解决这个问题,AutoloadCache框架引入了一个新的注解:@CacheDeleteTransactional(代码七):publicUserServiceImplimplementsUserService{@AutowiredprivateUserMapperuserMapper;publicUsergetUserById(LonguserId){returnuserMapper.getUserById(userId);}@Transactional(rollbackFor=Throwable.class)@CacheDeleteTransactionalpublicchevoidupdateUser(Useruser){userMapper.updateUserCatele(userTransionAutoload);}}使用@Cache后会先使用ThreadLocal缓存删除缓存KEY,事务提交后再执行缓存删除操作。其实不能说是“解决不一致问题”,而是缓解。缓存数据双写不一致的问题很难解决,即使我们只使用数据库(单写情况)也会出现数据不一致的情况(从数据库中取数据时,更新在同时),我们只能减少不一致的发生。对于一些比较重要的数据,我们不能直接使用缓存中的数据进行计算并回写到数据库,比如扣除库存,我们需要在数据中添加版本信息,使用乐观锁等技术来避免数据不一致.11、与SpringCache的对比AutoLoadCache的思想其实是来源于SpringCache,使用AOP+Annotation来解耦缓存和业务逻辑。不同的是:AutoLoadCache的AOP并不局限于Spring中的AOP技术,即脱离Spring生态也可以使用,比如成功案例nutz;SpringCache不支持命名空间;SpringCache没有自动加载、异步刷新、take-it机制;SpringCache使用name和key来管理缓存(即可以通过name和key来操作具体的缓存),而AutoLoadCache使用namespace+key+hfield来管理缓存,每个缓存都可以指定缓存时间(过期)。也就是说,SpringCache更适合管理Ehcache缓存,而AutoLoadCache更适合管理Redis、Memcache,尤其是Redis,hfield相关的功能都是为他们开发的(因为Memcache不支持hashtable,所以没办法使用它与hfield相关的功能)。SpringCache无法为每个缓存键设置缓存过期时间。在缓存管理应用中,不同缓存的缓存时间尽量设置不同。如果都一样的话,缓存同时失效的可能性会更大,那么渗透到数据库的可能性也会更大,不利于系统的稳定性;SpringCache***的缺点是不能使用SpringEL表达式动态生成Cache名称,必须在Spring配置中指定Cache名称,使用起来非常不方便。特别是Redis中不可能精确的清除一批缓存,我们不想删除的缓存可能会被误删除;SpringCache在Spring中只能基于AOP和SpringEL表达式来使用,而AutoloadCache可以根据用户的实际情况进行扩展;AutoLoadCache使用@CacheDeleteTransactional来减少双写不一致的问题,但是SpringCache没有对应的解决方案;作者:Jiayu_77cd链接:http://www.jianshu.com/p/4f52d046c3d2来源:简书版权归作者所有。商业转载请联系作者授权,非商业转载请注明出处。