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

背景

在以前的文章中你应该知道的缓存进化史介绍了爱奇艺的缓存架构和缓存的进化历史。俗话说得好,工欲善其事,必先利其器,有了好的工具确定得知道如何用好这些工具,本篇将介绍如何利用好缓存。java

1.确认是否须要缓存

在使用缓存以前,须要确认你的项目是否真的须要缓存。使用缓存会引入的必定的技术复杂度,后文也将会一一介绍这些复杂度。通常来讲从两个方面来个是否须要使用缓存:git

  1. CPU占用:若是你有某些应用须要消耗大量的cpu去计算,好比正则表达式,若是你使用正则表达式比较频繁,而其又占用了不少CPU的话,那你就应该使用缓存将正则表达式的结果给缓存下来。
  2. 数据库IO占用:若是你发现你的数据库链接池比较空闲,那么不该该用缓存。可是若是数据库链接池比较繁忙,甚至常常报出链接不够的报警,那么是时候应该考虑缓存了。笔者曾经有个服务,被不少其余服务调用,其余时间都还好,可是在天天早上10点的时候老是会报出数据库链接池链接不够的报警,通过排查,发现有几个服务选择了在10点作定时任务,大量的请求打过来,DB链接池不够,从而报出链接池不够的报警。这个时候有几个选择,咱们能够经过扩容机器来解决,也能够经过增长数据库链接池来解决,可是没有必要增长这些成本,由于只有在10点的时候才会出现这个问题。后来引入了缓存,不只解决了这个问题,并且还增长了读的性能。

若是并无上述两个问题,那么你没必要为了增长缓存而缓存。github

2.选择合适的缓存

缓存又分进程内缓存和分布式缓存两种。不少人包括笔者在开始选缓存框架的时候都感到了困惑:网上的缓存太多了,你们都吹嘘本身很牛逼,我该怎么选择呢?正则表达式

2.1 选择合适的进程缓存

首先看看几个比较经常使用的缓存的比较,具体原理能够参考你应该知道的缓存进化史: 比较项 | ConcurrentHashMap | LRUMap | Ehcache | Guava Cache | Caffeine ---|--- |---|--- |--- |--- 读写性能 | 很好,分段锁|通常,全局加锁|好|好,须要作淘汰操做|很好 淘汰算法 | 无|LRU,通常|支持多种淘汰算法,LRU,LFU,FIFO|LRU,通常|W-TinyLFU, 很好 功能丰富程度 |功能比较简单 | 功能比较单一| 功能很丰富| 功能很丰富,支持刷新和虚引用等|功能和Guava Cache相似 工具大小 | jdk自带类,很小|基于LinkedHashMap,较小|很大,最新版本1.4MB|是Guava工具类中的一个小部分,较小|通常,最新版本644KB 是否持久化 |否 |否|是|否|否 是否支持集群 |否 |否|是|否|否算法

  • 对于ConcurrentHashMap来讲,比较适合缓存比较固定不变的元素,且缓存的数量较小的。虽然从上面表格中比起来有点逊色,可是其因为是jdk自带的类,在各类框架中依然有大量的使用,好比咱们能够用来缓存咱们反射的Method,Field等等;也能够缓存一些连接,防止其重复创建。在Caffeine中也是使用的ConcurrentHashMap来存储元素。
  • 对于LRUMap来讲,若是不想引入第三方包,又想使用淘汰算法淘汰数据,可使用这个。
  • 对于Ehcache来讲,因为其jar包很大,较重量级。对于须要持久化和集群的一些功能的,能够选择Ehcache。笔者没怎么使用过这个缓存,若是要选择的话,能够选择分布式缓存来替代Ehcache。
  • 对于Guava Cache来讲,Guava这个jar包在不少Java应用程序中都有大量的引入,因此不少时候实际上是直接用就行了,而且其自己是轻量级的并且功能较为丰富,在不了解Caffeine的状况下能够选择Guava Cache。
  • 对于Caffeine来讲,笔者是很是推荐的,其在命中率,读写性能上都比Guava Cache好不少,而且其API和Guava cache基本一致,甚至会多一点。在真实环境中使用Caffeine,取得过不错的效果。

总结一下:若是不须要淘汰算法则选择ConcurrentHashMap,若是须要淘汰算法和一些丰富的API,这里推荐选择Caffeine。sql

2.2 选择合适的分布式缓存

这里选取三个比较出名的分布式缓存来做为比较,MemCache(没有实战使用过),Redis(在美团又叫Squirrel),Tair(在美团又叫Cellar)。不一样的分布式缓存功能特性和实现原理方面有很大的差别,所以他们所适应的场景也有所不一样。数据库

比较项 MemCache Squirrel/Redis Cellar/Tair
数据结构 只支持简单的Key-Value结构 String,Hash, List, Set, Sorted Set String,HashMap, List,Set
持久化 不支持 支持 支持
容量大小 数据纯内存,数据存储不宜过多 数据全内存,资源成本考量不宜超过100GB 能够配置全内存或内存+磁盘引擎,数据容量可无限扩充
读写性能 很高 很高(RT0.5ms左右) String类型比较高(RT1ms左右),复杂类型比较慢(RT5ms左右)
  • MemCache:这一块接触得比较少,不作过多的推荐。其吞吐量较大,可是支持的数据结构较少,而且不支持持久化。
  • Redis:支持丰富的数据结构,读写性能很高,可是数据全内存,必需要考虑资源成本,支持持久化。
  • Tair: 支持丰富的数据结构,读写性能较高,部分类型比较慢,理论上容量能够无限扩充。

总结:若是服务对延迟比较敏感,Map/Set数据也比较多的话,比较适合Redis。若是服务须要放入缓存量的数据很大,对延迟又不是特别敏感的话,那就能够选择Tair。在美团的不少应用中对Tair都有应用,在笔者的项目中使用其存放咱们生成的支付token,支付码,用来替代数据库存储。大部分的状况下二者均可以选择,互为替代。数组

3.多级缓存

不少人一想到缓存立刻脑子里面就会出现下面的图:缓存

Redis用来存储热点数据,Redis中没有的数据则直接去数据库访问。网络

在以前介绍本地缓存的时候,不少人都问我,我已经有Redis了,我干吗还须要了解Guava,Caffeine这些进程缓存呢。我基本统一回复下面两个答案:

  1. Redis若是挂了或者使用老版本的Redis,其会进行全量同步,此时Redis是不可用的,这个时候咱们只能访问数据库,很容易形成雪崩。
  2. 访问Redis会有必定的网络I/O以及序列化反序列化,虽然性能很高可是其终究没有本地方法快,能够将最热的数据存放在本地,以便进一步加快访问速度。这个思路并非咱们作互联网架构独有的,在计算机系统中使用L1,L2,L3多级缓存,用来减小对内存的直接访问,从而加快访问速度。

因此若是仅仅是使用Redis,能知足咱们大部分需求,可是当须要追求更高的性能以及更高的可用性的时候,那就不得不了解多级缓存。

3.1使用进程缓存

对于进程内缓存,其原本受限于内存的大小的限制,以及进程缓存更新后其余缓存没法得知,因此通常来讲进程缓存适用于:

  1. 数据量不是很大,数据更新频率较低,以前咱们有个查询商家名字的服务,在发送短信的时候须要调用,因为商家名字变动频率较低,而且就算是变动了没有及时变动缓存,短信里面带有老的商家名字客户也能接受。利用Caffeine做为本地缓存,size设置为1万,过时时间设置为1个小时,基本能在高峰期解决问题。
  2. 若是数据量更新频繁,也想使用进程缓存的话,那么能够将其过时时间设置为较短,或者设置其较短的自动刷新的时间。这些对于Caffeine或者Guava Cache来讲都是现成的API。

3.2使用多级缓存

俗话说得好,世界上没有什么是一个缓存解决不了的事,若是有,那就两个。

通常来讲咱们选择一个进程缓存和一个分布式缓存来搭配作多级缓存,通常来讲引入两个也足够了,若是使用三个,四个的话,技术维护成本会很高,反而有可能会得不偿失,以下图所示:

利用Caffeine作一级缓存,Redis做为二级缓存。

  1. 首先去Caffeine中查询数据,若是有直接返回。若是没有则进行第2步。
  2. 再去Redis中查询,若是查询到了返回数据并在Caffeine中填充此数据。若是没有查到则进行第3步。
  3. 最后去Mysql中查询,若是查询到了返回数据并在Redis,Caffeine中依次填充此数据。

对于Caffeine的缓存,若是有数据更新,只能删除更新数据的那台机器上的缓存,其余机器只能经过超时来过时缓存,超时设定能够有两种策略:

  • 设置成写入后多少时间后过时
  • 设置成写入后多少时间刷新

对于Redis的缓存更新,其余机器立马可见,可是也必需要设置超时时间,其时间比Caffeine的过时长。

为了解决进程内缓存的问题,设计进一步优化:

经过Redis的pub/sub,能够通知其余进程缓存对此缓存进行删除。若是Redis挂了或者订阅机制不靠谱,依靠超时设定,依然能够作兜底处理。

4.缓存更新

通常来讲缓存的更新有两种状况:

  • 先删除缓存,再更新数据库。
  • 先更新数据库,再删除缓存。 这两种状况在业界,你们对其都有本身的见解。具体怎么使用还得看各自的取舍。固然确定会有人问为何要删除缓存呢?而不是更新缓存呢?你能够想一想当有多个并发的请求更新数据,你并不能保证更新数据库的顺序和更新缓存的顺序一致,那就会出现数据库中和缓存中数据不一致的状况。因此通常来讲考虑删除缓存。

4.1先删除缓存,再更新数据库

对于一个更新操做简单来讲,就是先去各级缓存进行删除,而后更新数据库。这个操做有一个比较大的问题,在对缓存删除完以后,有一个读请求,这个时候因为缓存被删除因此直接会读库,读操做的数据是老的而且会被加载进入缓存当中,后续读请求所有访问的老数据。

对缓存的操做不论成功失败都不能阻塞咱们对数据库的操做,那么不少时候删除缓存能够用异步的操做,可是先删除缓存不能很好的适用于这个场景。

先删除缓存也有一个好处是,若是对数据库操做失败了,那么因为先删除的缓存,最多只是形成Cache Miss。

4.2先更新数据库,再删除缓存(推荐)

若是咱们使用更新数据库,再删除缓存就能避免上面的问题。可是一样的引入了新的问题,试想一下有一个数据此时是没有缓存的,因此查询请求会直接落库,更新操做在查询请求以后,可是更新操做删除数据库操做在查询完以后回填缓存以前,就会致使咱们缓存中和数据库出现缓存不一致。

为何咱们这种状况有问题,不少公司包括Facebook还会选择呢?由于要触发这个条件比较苛刻。

  1. 首先须要数据不在缓存中。
  2. 其次查询操做须要在更新操做先到达数据库。
  3. 最后查询操做的回填比更新操做的删除后触发,这个条件基本很难出现,由于更新操做的原本在查询操做以后,通常来讲更新操做比查询操做稍慢。可是更新操做的删除却在查询操做以后,因此这个状况比较少出现。

对比上面4.1的问题来讲这种问题的几率很低,何况咱们有超时机制保底因此基本能知足咱们的需求。若是真的须要追求完美,可使用二阶段提交,可是其成本和收益通常来讲不成正比。

固然还有个问题是若是咱们删除失败了,缓存的数据就会和数据库的数据不一致,那么咱们就只能靠过时超时来进行兜底。对此咱们能够进行优化,若是删除失败的话 咱们不能影响主流程那么咱们能够将其放入队列后续进行异步删除。

5.缓存挖坑三剑客

你们一听到缓存有哪些注意事项,确定首先想到的是缓存穿透,缓存击穿,缓存雪崩这三个挖坑的小能手,这里简单介绍一下他们具体是什么以及应对的方法。

5.1缓存穿透

缓存穿透是指查询的数据在数据库是没有的,那么在缓存中天然也没有,因此,在缓存中查不到就会去数据库取查询,这样的请求一多,那么咱们的数据库的压力天然会增大。

为了不这个问题,能够采起下面两个手段:

  1. 约定:对于返回为NULL的依然缓存,对于抛出异常的返回不进行缓存,注意不要把抛异常的也给缓存了。采用这种手段的会增长咱们缓存的维护成本,须要在插入缓存的时候删除这个空缓存,固然咱们能够经过设置较短的超时时间来解决这个问题。

2. 制定一些规则过滤一些不可能存在的数据,小数据用BitMap,大数据能够用布隆过滤器,好比你的订单ID 明显是在一个范围1-1000,若是不是1-1000以内的数据那其实能够直接给过滤掉。

5.2缓存击穿

对于某些key设置了过时时间,可是其是热点数据,若是某个key失效,可能大量的请求打过来,缓存未命中,而后去数据库访问,此时数据库访问量会急剧增长。

为了不这个问题,咱们能够采起下面的两个手段:

  1. 加分布式锁:加载数据的时候能够利用分布式锁锁住这个数据的Key,在Redis中直接使用setNX操做便可,对于获取到这个锁的线程,查询数据库更新缓存,其余线程采起重试策略,这样数据库不会同时受到不少线程访问同一条数据。
  2. 异步加载:因为缓存击穿是热点数据才会出现的问题,能够对这部分热点数据采起到期自动刷新的策略,而不是到期自动淘汰。淘汰其实也是为了数据的时效性,因此采用自动刷新也能够。

5.3缓存雪崩

缓存雪崩是指缓存不可用或者大量缓存因为超时时间相同在同一时间段失效,大量请求直接访问数据库,数据库压力过大致使系统雪崩。

为了不这个问题,咱们采起下面的手段:

  1. 增长缓存系统可用性,经过监控关注缓存的健康程度,根据业务量适当的扩容缓存。
  2. 采用多级缓存,不一样级别缓存设置的超时时间不一样,及时某个级别缓存都过时,也有其余级别缓存兜底。
  3. 缓存的过时时间能够取个随机值,好比之前是设置10分钟的超时时间,那每一个Key均可以随机8-13分钟过时,尽可能让不一样Key的过时时间不一样。

6.缓存污染

缓存污染通常出如今咱们使用本地缓存中,能够想象,在本地缓存中若是你得到了缓存,可是你接下来修改了这个数据,可是这个数据并无更新在数据库,这样就形成了缓存污染:

上面的代码就形成了缓存污染,经过id获取Customer,可是需求须要修改Customer的名字,因此开发人员直接在取出来的对象中直接修改,这个Customer对象就会被污染,其余线程取出这个数据就是错误的数据。

要想避免这个问题须要开发人员从编码上注意,而且代码必须通过严格的review,以及全方位的回归测试,才能从必定程度上解决这个问题。

7.序列化

序列化是不少人都不注意的一个问题,不少人忽略了序列化的问题,上线以后立刻报出一下奇怪的错误异常,形成了没必要要的损失,最后一排查都是序列化的问题。列举几个序列化常见的问题:

  1. key-value对象过于复杂致使序列化不支持:笔者以前出过一个问题,在美团的Tair内部默认是使用protostuff进行序列化,而美团使用的通信框架是thfift,thrift的TO是自动生成的,这个TO里面不少复杂的数据结构,可是将其存放到了Tair中。查询的时候反序列化也没有报错,单测也经过,可是到qa测试的时候发现这一块功能有问题,发现有个字段是boolean类型默认是false,把它改为true以后,序列化到tair中再反序列化仍是false。定位到是protostuff对于复杂结构的对象(好比数组,List<Map>等等)支持不是很好,会形成必定的问题。后来对这个TO进行了转换,用普通的Java对象就能进行正确的序列化反序列化。
  2. 添加了字段或者删除了字段,致使上线以后老的缓存获取的时候反序列化报错,或者出现一些数据移位。
  3. 不一样的JVM的序列化不一样,若是你的缓存有不一样的服务都在共同使用(不提倡),那么须要注意不一样JVM可能会对Class内部的Field排序不一样,而影响序列化。好比下面的代码,在Jdk7和Jdk8中对象A的排列顺序不一样,最终会致使反序列化结果出现问题:
//jdk 7
class A{
    int a;
    int b;
}
//jdk 8
class A{
    int b;
    int a;
}

序列化的问题必须获得重视,解决的办法有以下几点:

  1. 测试:对于序列化须要进行全面的测试,若是有不一样的服务而且他们的JVM不一样那么你也须要作这一块的测试,在上面的问题中笔者的单测经过的缘由是用的默认数据false,因此根本没有测试true的状况,还好QA给力,将其给测试出来了。
  2. 对于不一样的序列化框架都有本身不一样的原理,对于添加字段以后若是当前序列化框架不能兼容老的,那么能够换个序列化框架。 对于protostuff来讲他是按照Field的顺序来进行反序列化的,对于添加字段咱们须要放到末尾,也就是不能插在中间,不然会出现错误。对于删除字段来讲,用@Deprecated注解进行标注弃用,若是贸然删除,除非是最后一个字段,不然确定会出现序列化异常。
  3. 可使用双写来避免,对于每一个缓存的key值能够加上版本号,每次上线版本号都加1,好比如今线上的缓存用的是Key_1,即将要上线的是Key_2,上线以后对缓存的添加是会写新老两个不一样的版本(Key_1,Key_2)的Key-Value,读取数据仍是读取老版本Key_1的数据,假设以前的缓存的过时时间是半个小时,那么上线半个小时以后,以前的老缓存存量的数据都会被淘汰,此时线上老缓存和新缓存他们的数据基本是同样的,切换读操做到新缓存,而后中止双写。采用这种方法基本能平滑过渡新老Model交替,可是很差的点就是须要短暂的维护两套新老Model,下次上线的时候须要删除掉老Model,增长了维护成本。

8. GC调优

对于大量使用本地缓存的应用,因为涉及到缓存淘汰,那么GC问题一定是常事。若是出现GC较多,STW时间较长,那么一定会影响服务可用性。这一块给出下面几点建议:

  1. 常常查看GC监控,如何发现不正常,须要想办法对其进行优化。
  2. 对于CMS垃圾收集器,若是发现remark过长,若是是大量本地缓存应用的话这个过长应该很正常,由于在并发阶段很容易有不少新对象进入缓存,从而remark阶段扫描很耗时,remark又会暂停。能够开启-XX:CMSScavengeBeforeRemark,在remark阶段前进行一次YGC,从而减小remark阶段扫描gc root的开销。
  3. 可使用G1垃圾收集器,经过-XX:MaxGCPauseMillis设置最大停顿时间,提升服务可用性。

9. 缓存的监控

不少人对于缓存的监控也比较忽略,基本上线以后若是不报错而后就默认他就生效了。可是存在这个问题,不少人因为经验不足,有可能设置了不恰当的过时时间,或者不恰当的缓存大小致使缓存命中率不高,让缓存就成为了代码中的一个装饰品。因此对于缓存各类指标的监控,也比较重要,经过其不一样的指标数据,咱们能够对缓存的参数进行优化,从而让缓存达到最优化:

上面的代码中用来记录get操做的,经过Cat记录了获取缓存成功,缓存不存在,缓存过时,缓存失败(获取缓存时若是抛出异常,则叫失败),经过这些指标,咱们就能统计出命中率,咱们调整过时时间和大小的时候就能够参考这些指标进行优化。

10. 一款好的框架

一个好的剑客没有一把好剑怎么行呢?若是要使用好缓存,一个好的框架也必不可少。在最开始使用的时候你们使用缓存都用一些util,把缓存的逻辑写在业务逻辑中:

上面的代码把缓存的逻辑耦合在业务逻辑当中,若是咱们要增长成多级缓存那就须要修改咱们的业务逻辑,不符合开闭原则,因此引入一个好的框架是不错的选择。

推荐你们使用JetCache这款开源框架,其实现了Java缓存规范JSR107而且支持自动刷新等高级功能。笔者参考JetCache结合Spring Cache, 监控框架Cat以及美团的熔断限流框架Rhino实现了一套自有的缓存框架,让操做缓存,打点监控,熔断降级,业务人员无需关心。上面的代码能够优化成:

对于一些监控数据也能轻松从大盘上看到:

最后

想要真正的使用好一个缓存,必需要掌握不少的知识,并非看几个Redis原理分析,就能把Redis缓存用得炉火纯青。对于不一样场景,缓存有各自不一样的用法,一样的不一样的缓存也有本身的调优策略,进程内缓存你须要关注的是他的淘汰算法和GC调优,以及要避免缓存污染等。分布式缓存你须要关注的是他的高可用,若是其不可用了如何进行降级,以及一些序列化的问题。一个好的框架也是必不可少的,对其若是使用得当再加上上面介绍的经验,相信能让你很好的驾驭住这头野马——缓存。

最后这篇文章被我收录于JGrowing,一个全面,优秀,由社区一块儿共建的Java学习路线,若是您想参与开源项目的维护,能够一块儿共建,github地址为:https://github.com/javagrowing/JGrowing 麻烦给个小星星哟。

若是你以为这篇文章对你有文章,能够关注个人技术公众号,关注以后便可领取,你的关注和转发是对我最大的支持,O(∩_∩)O

相关文章
相关标签/搜索