当前位置: 首页 > 后端技术 > Java

如何优雅地设计和使用缓存?

时间:2023-04-02 02:11:10 Java

背??景上一篇缓存进化史你应该知道,里面介绍了爱奇艺的缓存架构和缓存进化史。俗话说,工欲善其事,必先利其器。如果你有好的工具,你必须知道如何用好它们。本文将介绍如何用好缓存。1、确认是否需要缓存在使用缓存之前,需要确认你的项目是否真的需要缓存。使用缓存会引入一定的技术复杂度,后面会一一介绍。一般来说,是否需要使用缓存从两个方面来看:CPU占用:如果你有一些需要消耗大量cpu来计算的应用,比如正则表达式,如果你经常使用正则表达式,占用了IfCPU很多,那么就应该使用缓存来缓存正则表达式的结果。数据库IO使用:如果你发现你的数据库连接池比较空闲,就不应该使用缓存。但是如果数据库连接池很忙,甚至经常报连接不够用,那么就该考虑缓存了。作者曾经有一个服务被许多其他服务调用。其他时候还好,但是每天早上10:00,总是报数据库连接池连接不够的告警。排查后发现在10点做一个定时任务中选择了几个服务,大量请求进来,DB连接池不够用,所以提示连接池不够用报道。这个时候有几种选择。我们可以通过扩容机器或者增加数据库连接池来解决,但是没有必要增加这些成本,因为这个问题只会在10点钟出现。后来引入了缓存,不仅解决了这个问题,还提高了读的性能。如果你没有以上两个问题,那你就不用缓存增加缓存了。2、选择合适的缓存缓存分为进程内缓存和分布式缓存两种。包括笔者在内的很多人在开始选择缓存框架的时候都感到迷茫:网上的缓存太多了,每个人都吹牛牛,我应该怎么选择呢?2.1选择合适的进程缓存首先我们来看几种常用缓存的对比。具体原理可以参考你应该知道的缓存进化史:比较项ConcurrentHashMapLRUMapEhcacheGuavaCacheCache读写性能好,通用段锁,全局锁也不错。需要做消元操作,很好的消元算法,没有LRU,一般支持多种消元算法,LRU,LFU,FIFOLRU,一般W-TinyLFU,功能丰富性好,功能比较简单,功能比较单一,功能丰富,支持refresh和Phantomreference等功能与GuavaCache类似。工具体积jdk自带,很小。基于LinkedHashMap,比较小,很大。最新版本1.4MB是Guava工具类的一小部分。它相对较小。最新版本644KB是否持久化NoYesNoYesClusterSupportNoNoYesNo对于ConcurrentHashMap,比较适合缓存比较固定的元素,缓存数量少。虽然和上表比起来有点逊色,但由于是jdk自带的类,所以在各种框架中仍然被广泛使用。比如我们可以用它来缓存我们反射的Method、Field等;我们还可以缓存一些链接以防止其重复。在Caffeine中也使用了ConcurrentHashMap来存储元素。对于LRUMap,如果不想引入第三方包,想使用淘汰算法来淘汰数据,可以使用这个。对于Ehcache,由于其jar包比较大,所以比较重量级。对于一些需要持久化和集群化的功能,可以选择Ehcache。笔者并没有太多使用这个缓存。如果要选择的话,可以选择分布式缓存来代替Ehcache。对于GuavaCache,Guava的jar包在很多Java应用中被广泛引入,所以在很多情况下可以直接使用,而且轻量级,功能丰富。如果你不知道Caffeine和GuavaCache,你可以选择。对于Caffeine,作者极力推荐。它在命中率和读写性能上都比GuavaCache好很多,而且它的API和GuavaCache基本一致,甚至略胜一筹。在真实环境中使用Caffeine取得了不错的效果。总结一下:如果不需要淘汰算法,就选ConcurrentHashMap。如果需要剔除算法和一些丰富的API,建议选择Caffeine。2.2选择合适的分布式缓存这里我们选择三个比较知名的分布式缓存进行对比,MemCache(实战中没有用到)、Redis(美团也叫Squirrel)、Tair(美团也叫Cellar)。不同的分布式缓存在功能特点和实现原理上有很大差异,因此适应的场景也不同。比较项MemCacheSquirrel/RedisCellar/Tair数据结构只支持简单的Key-Value结构String,Hash,List,Set,SortedSetString,HashMap,List,Set持久化不支持容量数据纯内存,数据存储不要太多数据全内存,资源成本考虑不要超过100GB可以配置全内存或者内存+磁盘引擎,数据容量可以无限扩展读写性能高(RT0.5ms左右)String类型比较高(RT1ms左右),complextypecomparisonSlow(aboutRT5ms)MemCache:这个区域暴露的比较少,所以不做太多推荐。它的吞吐量很大,但支持的数据结构较少,不支持持久化。Redis:支持丰富的数据结构,读写性能高,但数据在内存中,需要考虑资源成本,支持持久化。Tair:支持丰富的数据结构,读写性能高,但有些类型比较慢,理论上可以无限扩展容量。总结:如果业务对延迟比较敏感,Map/Set数据比较多,Redis比较合适。如果服务需要在缓存中放入大量数据,对延迟不是特别敏感,可以选择Tair。Tair在美团的很多应用中都有使用。在笔者的项目中,用于存放我们生成的支付token和支付码,用来代替数据库存储。在大多数情况下,两者可以相互选择和替代。3、多级缓存很多人一想到缓存,脑海中就会浮现出如下画面:Redis用来存储热点数据,不在Redis中的数据直接访问数据库。之前介绍本地缓存的时候,很多人问我,我已经有Redis了,为什么还要了解Guava、Caffeine等进程缓存。我基本回复以下两个答案:如果Redis宕机或者使用旧版本的Redis,它会进行全量同步。这个时候Redis是不可用的。这个时候我们只能访问数据库,很容易造成雪崩。访问Redis会有一定的网络I/O和序列化反序列化。虽然性能高,但毕竟不如本地方法快。可以将最热的数据存储在本地,进一步加快访问速度。这个想法并不是我们的Internet架构所独有的。L1、L2、L3多级缓存在计算机系统中用于减少对内存的直接访问,从而加快访问速度。所以如果我们仅仅使用Redis,可以满足我们的大部分需求,但是当我们需要追求更高的性能和更高的可用性时,我们就不得不了解多级缓存了。3.1使用进程缓存对于进程内缓存,本来就受限于内存大小,进程缓存更新后其他缓存无法得知,所以一般来说,进程缓存适用于:数据不是很大,数据更新频率比较低,我们以前有一个商家名称查询服务,需要在发短信的时候调用。因为商家名称更改频率较低,即使更改也没有及时更改缓存,短信中显示旧商家名称的客户可以接受。.使用Caffeine作为本地缓存,设置大小为10000,设置过期时间为1小时,基本可以解决高峰期的问题。如果数据量更新频繁,想使用进程缓存,可以把它的过期时间设置短一些,或者把它的自动刷新时间设置短一些。这些是用于Caffeine或GuavaCache的现成API。3.2使用多级缓存俗话说,天下没有一个缓存解决不了的事。如果有,那么两个。一般来说,我们选择进程缓存和分布式缓存来做多级缓存。一般来说,介绍两个就够了。如果用三个或四个,技术维护成本会很高,但可能得不偿失,如下图所示:Caffeine作为一级缓存,Redis作为二级缓存——级缓存。首先去Caffeine查询数据,有的话直接返回。如果没有则进入第2步。然后进入Redis进行查询,如果查询返回数据则将此数据填充到Caffeine中。如果没有找到,转第3步,最后去Mysql查询。如果找到返回的数据,则依次填充到Redis和Caffeine中。对于Caffeine的缓存,如果有数据更新,只能删除更新数据的机器上的缓存,其他机器只能通过timeout使缓存过期。超时设置有两种策略:设置为写入后多久过期时间设置为写入后需要多长时间刷新。对于Redis缓存更新,其他机器可以立即看到,但是还必须设置超时时间,比Caffeine的过期时间要长。为了解决进程内缓存的问题,进一步优化了设计:通过Redis的pub/sub,可以通知其他进程缓存删除这个缓存。如果Redis挂了或者订阅机制不可靠,根据超时设置,还是可以做底线的。4、缓存更新一般来说,缓存更新有两种情况:先删除缓存,再更新数据库。先更新数据库,再删除缓存。这两种情况在业内,每个人都有自己的看法。如何使用它取决于每个人的权衡。当然肯定有人会问为什么要删除缓存?而不是更新缓存?你可以认为,当有多个并发请求更新数据时,你不能保证数据库更新的顺序和缓存更新的顺序是一致的,会出现数据库中的数据和缓存。所以一般考虑删除缓存。4.1先删除缓存,再更新数据库。对于一个更新操作,很简单就是删除各级缓存,然后更新数据库。这个操作有一个比较大的问题。删除缓存后,有读请求。这时候因为删除了缓存,所以会直接读取库。读操作中的数据是旧的,会被加载到缓存中。后续读取请求完全访问的旧数据。对缓存的操作无论成功还是失败都不能阻塞我们对数据库的操作。很多情况下可以使用异步操作来删除缓存,但是先删除缓存不太适合这种场景。先删除缓存的另一个好处是,如果对数据库的操作失败,先删除的缓存最多只会导致CacheMiss。4.2先更新数据库,再删除缓存(推荐)如果我们使用更新数据库,再删除缓存,可以避免上述问题。但同样引入了新的问题。想象一下,此时有一个数据没有缓存,那么查询请求会直接丢进数据库。更新操作在查询请求之后,但是更新操作在查询之后回填缓存之前删除数据库操作。会导致我们的缓存和数据库缓存不一致。为什么我们在这种情况下出现问题,包括Facebook在内的很多公司仍然选择?因为触发这个条件更加严格。第一个要求数据不在缓存中。其次,查询操作需要在更新操作之前到达数据库。最后在删除更新操作后触发查询操作的回填。这种情况基本很难出现,因为update操作本来就是在query操作之后进行的。一般来说,更新操作比查询操作稍慢。但是更新操作的删除是在查询操作之后,所以这种情况比较少见。和上面4.1中的问题相比,这种问题出现的概率很低,而且我们有超时机制来保证底层,所以基本可以满足我们的需求。如果确实需要追求完美,可以使用两阶段提交,但其成本和收益一般不成正比。当然还有一个问题就是如果我们删除失败,缓存的数据就会和数据库中的数据不一致,那我们就只能靠过期和超时来垫底了。我们可以对此进行优化。如果删除失败,我们不能影响到主进程,所以我们可以将其放入队列中,以便后续异步删除。5.缓存挖掘三剑客听到缓存的注意事项,大家首先想到的就是缓存穿透、缓存击穿、缓存雪崩。下面简单介绍一下它们是什么,它们是什么。应对方法。5.1缓存穿透缓存穿透就是查询到的数据在数据库中不存在,那么缓存中自然也不存在。因此,如果在缓存中找不到,就会到数据库中去取查询。如果这样的请求太多了,那么我们的数据库压力自然会增加。为了避免这个问题,可以采用以下两种方式:同意:NULL的返回还是缓存,抛出异常的返回不缓存。注意不要缓存抛出的异常。使用这种方式会增加我们缓存的维护成本,而且我们需要在插入缓存的时候删除空缓存。当然,我们可以通过设置更短的超时时间来解决这个问题。做一些规则过滤一些不可能的数据,小数据用BitMap,大数据用Bloomfilter。比如你的订单ID明显是在1-1000的范围内。如果不在1-1000之间,其实是可以直接过滤掉的。5.2缓存击穿部分key设置了过期时间,但属于热点数据。如果一个key失效,可能会有大量的请求进来,缓存未命中,然后去数据库访问。这时候数据库的访问量会急剧增加。为了避免这个问题,我们可以采取以下两种方法:加分布式锁:在加载数据的时候,我们可以使用分布式锁来锁定这个数据的key,直接在Redis中使用setNX操作。对于获得这个锁的线程查询数据库更新缓存,其他线程采用重试策略,这样就不会为了同一条数据被多个线程同时访问数据库。异步加载:由于缓存崩溃只是热点数据的问题,所以可以对这部分热点数据采用自动刷新策略,而不是过期自动淘汰。剔除其实是为了数据的时效性,所以自动刷新也是可以的。5.3缓存雪崩缓存雪崩是指缓存不可用或者大量缓存由于同一超时时间同时失效,大量请求直接访问数据库,数据库压力过大,导致系统雪崩。为了避免这个问题,我们采取了以下措施:提高缓存系统的可用性,通过监控关注缓存的健康状况,根据业务量适当扩展缓存。使用多级缓存,不同级别的缓存设置有不同的超时时间,即使某一级缓存过期,也有其他级别的缓存。缓存的过期时间可以取一个随机值。例如,如果之前设置了10分钟的超时时间,则每个Key可以在8-13分钟内随机失效。尽量让不同Key的过期时间不同。6、缓存污染缓存污染一般发生在我们使用本地缓存的时候。可以想象,如果你在本地缓存中获取了缓存,但是你接下来修改了数据,但是数据并没有更新到数据库中,这样就会造成缓存污染:上面的代码造成了缓存污染。Customer是通过id获取的,但是customer需要修改customer的name,所以开发者直接在fetched对象中修改,Customer对象就会被污染,其他线程取出来的数据就是坏数据。为了避免这个问题,开发者需要在编码上有所重视,代码必须经过严格的审查和全方位的回归测试,才能在一定程度上解决这个问题。7.连载连载是很多人不重视的问题。很多人忽略了序列化的问题。刚上线就报奇怪的error异常,造成不必要的损失。最后排查是serialization的问题。列举几个序列化中常见的问题:key-value对象太复杂,不支持序列化:作者之前有一个问题,在美团的Tair中,默认是使用protostuff进行序列化,而美团使用的通信框架是thfift,自动生成thrift的TO。这个TO中有很多复杂的数据结构,但是都存储在Tair中。查询的时候反序列化没有报错,单机测试通过,但是做qa测试的时候发现这个函数有问题。发现有一个boolean类型的字段,默认为false。改成true后在反序列化时序列化到Tair中还是false。定位是protostuff对结构复杂的对象(如数组、List等)支持不是很好,会造成一定的问题。后来对这个TO进行了转换,可以使用普通的Java对象进行正确的序列化和反序列化。增加或删除字段,导致上线后取旧缓存时反序列化错误,或部分数据移位。不同的JVM有不同的序列化。如果你的缓存有不同的服务一起使用(不推荐),那么你需要注意不同的JVM可能会对Class内部的字段进行不同的排序,这会影响序列化。比如下面的代码中,对象A在Jdk7和Jdk8中的顺序不一样,最终会导致反序列化结果出现问题//jdk7classA{inta;intb;}//jdk8classA{intb;inta;}一定要注意copy代码序列化的问题。解决方法如下:测试:序列化需要全面测试。如果有不同的服务,它们的JVM不同,那么你也需要这样做。测试,笔者在上题中单测之所以通过是因为默认使用的数据为false,所以根本没有真正的测试。幸运的是,QA很强,并且经过了测试。不同的序列化框架有自己不同的原理。添加字段后,如果当前的序列化框架与旧的不兼容,可以更换序列化框架。对于protostuff,按照Field的顺序进行反序列化。添加字段需要放在末尾,也就是中间不能插入,否则会出错。对于删除的字段,使用@Deprecated注解来标记弃用。如果贸然删除,除非是最后一个字段,否则肯定会出现序列化异常。可以使用双写来避免它。对于每个缓存的key值,可以加上版本号,每次上线版本号加1。比如目前在线缓存使用的是Key_1,即将上线的是Key_2。缓存的添加会写入新旧版本的Key-Value(Key_1,Key_2),读取数据或者读取旧版本Key_1的数据,假设之前缓存的过期时间是半个小时,然后上线半小时,一个小时后,之前旧缓存中的数据就会被淘汰。这时候旧的在线缓存和新的缓存中的数据基本一致。将读操作切换到新缓存,然后停止双写。使用这种方法基本上可以让新旧模型平滑过渡,但是缺点是需要短时间维护两套新旧模型,下次上线时需要删除旧模型,这增加了维护成本。GC调优对于大量使用本地缓存的应用程序,由于缓存消除,GC问题一定很常见。如果GC次数多了,STW时间长了,肯定会影响服务的可用性。本节给出以下建议:经常检查GC监控,如何发现异常,想办法优化。对于CMS垃圾回收器,如果发现remark过长,如果是大量申请本地缓存应该是正常的,因为并发阶段很容易有很多新的对象进入缓存,所以remark阶段扫描比较耗时,remark会再次暂停。可以开启-XX:CMSScavengeBeforeRemark,在remark阶段之前进行一次YGC,从而减少remark阶段扫描gcroot的开销。可以使用G1垃圾回收器通过-XX:MaxGCPauseMillis设置最大暂停时间来提高服务可用性。缓存监控许多人也忽略了缓存监控。基本上启动后不报错就默认生效了。但是有这个问题。由于缺乏经验,很多人可能会设置不合适的过期时间,或者不合适的缓存大小导致缓存命中率低,使缓存成为代码中的装饰品。所以缓存的各项指标的监控也比较重要。通过它不同的指标数据,我们可以优化缓存的参数,从而优化缓存:上面的代码是用来记录get操作的,是通过Cat记录的,为了获取缓存成功,缓存不存在、缓存过期、缓存失败(如果获取缓存时抛出异常则称为失败),通过这些指标我们可以统计命中率,可以调整过期时间和大小。参考这些指标进行优化。好架子,好剑客怎能没有好剑?要想用好缓存,好的框架也是必不可少的。刚开始使用的时候,大家使用一些utils在业务逻辑中写缓存逻辑:上面的代码将缓存逻辑耦合到业务逻辑中。如果我们要增加成多级缓存,需要修改我们的业务逻辑,不符合开闭原则,所以引入一个好的框架是个不错的选择。推荐使用开源框架JetCache,它实现了Java缓存规范JSR107,支持自动刷新等高级功能。作者参考了JetCache结合SpringCache,监控框架Cat和美团的熔断限流框架Rhino实现了一套自己的缓存框架,使得操作缓存,打点监控,熔断降级,业务人员都不需要照顾。上面的代码可以优化为:对于一些监控数据,大家很容易从市场上看到:最后,要想真正使用缓存,必须掌握很多知识。不只是一些Redis的原理分析,你可以把Redis的缓存完美的利用起来。针对不同的场景,缓存有不同的用法,不同的缓存也有自己的调优策略。对于进程内缓存,需要注意其淘汰算法、GC调优,避免缓存污染。分布式缓存需要注意的是它的高可用,不可用怎么降级,还有一些序列化的问题。一个好的框架也是必不可少的。如果使用得当,再结合上面介绍的经验,相信一定能让你驾驭这匹野马——缓存。上一篇文章是我收藏在JGrowing社区共同打造的全面优秀的Java学习路线。如果你想参与开源项目的维护,可以一起共建。github地址是:github.com/javagrowing...麻烦点个赞吧。