关于后端:如何优雅的设计和使用缓存

3次阅读

共计 7954 个字符,预计需要花费 20 分钟才能阅读完成。

1. 确认是否须要缓存
在应用缓存之前,须要确认你的我的项目是否真的须要缓存。应用缓存会引入的肯定的技术复杂度,后文也将会一一介绍这些复杂度。一般来说从两个方面来个是否须要应用缓存:

CPU 占用: 如果你有某些利用须要耗费大量的 cpu 去计算,比方正则表达式,如果你应用正则表达式比拟频繁,而其又占用了很多 CPU 的话,那你就应该应用缓存将正则表达式的后果给缓存下来。
数据库 IO 占用: 如果你发现你的数据库连接池比拟闲暇,那么不应该用缓存。然而如果数据库连接池比拟忙碌,甚至常常报出连贯不够的报警,那么是时候应该思考缓存了。笔者已经有个服务,被很多其余服务调用,其余工夫都还好,然而在每天早上 10 点的时候总是会报出数据库连接池连贯不够的报警,通过排查,发现有几个服务抉择了在 10 点做定时工作,大量的申请打过去,DB 连接池不够,从而报出连接池不够的报警。这个时候有几个抉择,咱们能够通过扩容机器来解决,也能够通过减少数据库连接池来解决,然而没有必要减少这些老本,因为只有在 10 点的时候才会呈现这个问题。起初引入了缓存,不仅解决了这个问题,而且还减少了读的性能。

如果并没有上述两个问题,那么你不用为了减少缓存而缓存。
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。
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 这些过程缓存呢。我根本对立回复上面两个答案:

Redis 如果挂了或者应用老版本的 Redis, 其会进行全量同步,此时 Redis 是不可用的,这个时候咱们只能拜访数据库,很容易造成雪崩。
拜访 Redis 会有肯定的网络 I / O 以及序列化反序列化,尽管性能很高然而其究竟没有本地办法快,能够将最热的数据寄存在本地,以便进一步放慢访问速度。这个思路并不是咱们做互联网架构独有的,在计算机系统中应用 L1,L2,L3 多级缓存,用来缩小对内存的间接拜访,从而放慢访问速度。

所以如果仅仅是应用 Redis,能满足咱们大部分需要,然而当须要谋求更高的性能以及更高的可用性的时候,那就不得不理解多级缓存。
3.1 应用过程缓存
对于过程内缓存,其原本受限于内存的大小的限度,以及过程缓存更新后其余缓存无奈得悉,所以一般来说过程缓存实用于:

数据量不是很大,数据更新频率较低,之前咱们有个查问商家名字的服务,在发送短信的时候须要调用,因为商家名字变更频率较低,并且就算是变更了没有及时变更缓存,短信外面带有老的商家名字客户也能承受。利用 Caffeine 作为本地缓存,size 设置为 1 万,过期工夫设置为 1 个小时,根本能在高峰期解决问题。
如果数据量更新频繁,也想应用过程缓存的话,那么能够将其过期工夫设置为较短,或者设置其较短的主动刷新的工夫。这些对于 Caffeine 或者 Guava Cache 来说都是现成的 API。

3.2 应用多级缓存

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

一般来说咱们抉择一个过程缓存和一个分布式缓存来搭配做多级缓存,一般来说引入两个也足够了,如果应用三个,四个的话,技术保护老本会很高,反而有可能会得失相当,如下图所示:

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

首先去 Caffeine 中查问数据,如果有间接返回。如果没有则进行第 2 步。
再去 Redis 中查问,如果查问到了返回数据并在 Caffeine 中填充此数据。如果没有查到则进行第 3 步。
最初去 Mysql 中查问,如果查问到了返回数据并在 Redis,Caffeine 中顺次填充此数据。

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

设置成写入后多少工夫后过期
设置成写入后多少工夫刷新

对于 Redis 的缓存更新,其余机器立马可见,然而也必须要设置超时工夫,其工夫比 Caffeine 的过期长。
为了解决过程内缓存的问题,设计进一步优化:

通过 Redis 的 pub/sub,能够告诉其余过程缓存对此缓存进行删除。如果 Redis 挂了或者订阅机制不靠谱,依附超时设定,仍然能够做兜底解决。
4. 缓存更新
一般来说缓存的更新有两种状况:

先删除缓存,再更新数据库。
先更新数据库,再删除缓存。
这两种状况在业界,大家对其都有本人的认识。具体怎么应用还得看各自的取舍。当然必定会有人问为什么要删除缓存呢?而不是更新缓存呢?你能够想想当有多个并发的申请更新数据,你并不能保障更新数据库的程序和更新缓存的程序统一,那就会呈现数据库中和缓存中数据不统一的状况。所以一般来说思考删除缓存。

4.1 先删除缓存,再更新数据库
对于一个更新操作简略来说,就是先去各级缓存进行删除,而后更新数据库。这个操作有一个比拟大的问题,在对缓存删除完之后,有一个读申请,这个时候因为缓存被删除所以间接会读库,读操作的数据是老的并且会被加载进入缓存当中,后续读申请全副拜访的老数据。

对缓存的操作不管成功失败都不能阻塞咱们对数据库的操作,那么很多时候删除缓存能够用异步的操作,然而先删除缓存不能很好的实用于这个场景。
先删除缓存也有一个益处是,如果对数据库操作失败了,那么因为先删除的缓存,最多只是造成 Cache Miss。
4.2 先更新数据库,再删除缓存 (举荐)
如果咱们应用更新数据库,再删除缓存就能防止下面的问题。然而同样的引入了新的问题, 试想一下有一个数据此时是没有缓存的,所以查问申请会间接落库,更新操作在查问申请之后,然而更新操作删除数据库操作在查问完之后回填缓存之前,就会导致咱们缓存中和数据库呈现缓存不统一。
为什么咱们这种状况有问题,很多公司包含 Facebook 还会抉择呢?因为要触发这个条件比拟刻薄。

首先须要数据不在缓存中。
其次查问操作须要在更新操作先达到数据库。
最初查问操作的回填比更新操作的删除后触发,这个条件根本很难呈现,因为更新操作的原本在查问操作之后,一般来说更新操作比查问操作稍慢。然而更新操作的删除却在查问操作之后,所以这个状况比拟少呈现。

比照下面 4.1 的问题来说这种问题的概率很低,况且咱们有超时机制保底所以根本能满足咱们的需要。如果真的须要谋求完满,能够应用二阶段提交,然而其老本和收益一般来说不成正比。
当然还有个问题是如果咱们删除失败了,缓存的数据就会和数据库的数据不统一,那么咱们就只能靠过期超时来进行兜底。对此咱们能够进行优化,如果删除失败的话 咱们不能影响主流程那么咱们能够将其放入队列后续进行异步删除。
5. 缓存挖坑三剑客
大家一听到缓存有哪些注意事项,必定首先想到的是缓存穿透,缓存击穿,缓存雪崩这三个挖坑的小能手,这里简略介绍一下他们具体是什么以及应答的办法。
5.1 缓存穿透
缓存穿透是指查问的数据在数据库是没有的,那么在缓存中天然也没有,所以,在缓存中查不到就会去数据库取查问,这样的申请一多,那么咱们的数据库的压力天然会增大。
为了防止这个问题,能够采取上面两个伎俩:

约定: 对于返回为 NULL 的仍然缓存,对于抛出异样的返回不进行缓存, 留神不要把抛异样的也给缓存了。采纳这种伎俩的会减少咱们缓存的保护老本,须要在插入缓存的时候删除这个空缓存,当然咱们能够通过设置较短的超时工夫来解决这个问题。

  1. 制订一些规定过滤一些不可能存在的数据,小数据用 BitMap,大数据能够用布隆过滤器,比方你的订单 ID 显著是在一个范畴 1 -1000,如果不是 1 -1000 之内的数据那其实能够间接给过滤掉。

5.2 缓存击穿
对于某些 key 设置了过期工夫,然而其是热点数据,如果某个 key 生效,可能大量的申请打过去,缓存未命中,而后去数据库拜访,此时数据库访问量会急剧减少。
为了防止这个问题,咱们能够采取上面的两个伎俩:

加分布式锁: 加载数据的时候能够利用分布式锁锁住这个数据的 Key, 在 Redis 中间接应用 setNX 操作即可,对于获取到这个锁的线程,查询数据库更新缓存,其余线程采取重试策略,这样数据库不会同时受到很多线程拜访同一条数据。
异步加载: 因为缓存击穿是热点数据才会呈现的问题,能够对这部分热点数据采取到期主动刷新的策略,而不是到期主动淘汰。淘汰其实也是为了数据的时效性,所以采纳主动刷新也能够。

5.3 缓存雪崩
缓存雪崩是指缓存不可用或者大量缓存因为超时工夫雷同在同一时间段生效,大量申请间接拜访数据库,数据库压力过大导致系统雪崩。
为了防止这个问题,咱们采取上面的伎俩:

减少缓存零碎可用性, 通过监控关注缓存的衰弱水平,依据业务量适当的扩容缓存。
采纳多级缓存,不同级别缓存设置的超时工夫不同,及时某个级别缓存都过期,也有其余级别缓存兜底。
缓存的过期工夫能够取个随机值,比方以前是设置 10 分钟的超时工夫,那每个 Key 都能够随机 8 -13 分钟过期,尽量让不同 Key 的过期工夫不同。

6. 缓存净化
缓存净化个别呈现在咱们应用本地缓存中,能够设想,在本地缓存中如果你取得了缓存,然而你接下来批改了这个数据,然而这个数据并没有更新在数据库,这样就造成了缓存净化:

下面的代码就造成了缓存净化,通过 id 获取 Customer,然而需要须要批改 Customer 的名字,所以开发人员间接在取出来的对象中间接批改,这个 Customer 对象就会被净化,其余线程取出这个数据就是谬误的数据。
要想防止这个问题须要开发人员从编码上留神,并且代码必须通过严格的 review,以及全方位的回归测试,能力从肯定水平上解决这个问题。
7. 序列化
序列化是很多人都不留神的一个问题,很多人疏忽了序列化的问题,上线之后马上报出一下奇怪的谬误异样,造成了不必要的损失,最初一排查都是序列化的问题。列举几个序列化常见的问题:

key-value 对象过于简单导致序列化不反对: 笔者之前出过一个问题,在美团的 Tair 外部默认是应用 protostuff 进行序列化,而美团应用的通信框架是 thfift,thrift 的 TO 是主动生成的,这个 TO 外面很多简单的数据结构,然而将其寄存到了 Tair 中。查问的时候反序列化也没有报错,单测也通过,然而到 qa 测试的时候发现这一块性能有问题,发现有个字段是 boolean 类型默认是 false,把它改成 true 之后,序列化到 tair 中再反序列化还是 false。定位到是 protostuff 对于简单构造的对象 (比方数组,List 等等) 反对不是很好,会造成肯定的问题。起初对这个 TO 进行了转换,用一般的 Java 对象就能进行正确的序列化反序列化。
增加了字段或者删除了字段,导致上线之后老的缓存获取的时候反序列化报错,或者呈现一些数据移位。
不同的 JVM 的序列化不同,如果你的缓存有不同的服务都在独特应用(不提倡),那么须要留神不同 JVM 可能会对 Class 外部的 Field 排序不同,而影响序列化。比方上面的代码,在 Jdk7 和 Jdk8 中对象 A 的排列程序不同,最终会导致反序列化后果呈现问题:

//jdk 7
class A{

int a;
int b;

}
//jdk 8
class A{

int b;
int a;

}
复制代码序列化的问题必须失去器重,解决的方法有如下几点:

测试: 对于序列化须要进行全面的测试,如果有不同的服务并且他们的 JVM 不同那么你也须要做这一块的测试,在下面的问题中笔者的单测通过的起因是用的默认数据 false,所以基本没有测试 true 的状况,还好 QA 给力,将其给测试进去了。
对于不同的序列化框架都有本人不同的原理,对于增加字段之后如果以后序列化框架不能兼容老的,那么能够换个序列化框架。
对于 protostuff 来说他是依照 Field 的程序来进行反序列化的,对于增加字段咱们须要放到开端,也就是不能插在两头,否则会呈现谬误。对于删除字段来说,用 @Deprecated 注解进行标注弃用,如果贸然删除,除非是最初一个字段,否则必定会呈现序列化异样。
能够应用双写来防止,对于每个缓存的 key 值能够加上版本号,每次上线版本号都加 1,比方当初线上的缓存用的是 Key_1,行将要上线的是 Key_2, 上线之后对缓存的增加是会写新老两个不同的版本 (Key_1,Key_2) 的 Key-Value,读取数据还是读取老版本 Key_1 的数据, 假如之前的缓存的过期工夫是半个小时,那么上线半个小时之后,之前的老缓存存量的数据都会被淘汰,此时线上老缓存和新缓存他们的数据根本是一样的, 切换读操作到新缓存,而后进行双写。采纳这种办法根本能平滑过渡新老 Model 交替,然而不好的点就是须要短暂的保护两套新老 Model,下次上线的时候须要删除掉老 Model,减少了保护老本。

  1. GC 调优
    对于大量应用本地缓存的利用,因为波及到缓存淘汰,那么 GC 问题必然是常事。如果呈现 GC 较多,STW 工夫较长,那么必定会影响服务可用性。这一块给出上面几点倡议:

常常查看 GC 监控,如何发现不失常,须要想方法对其进行优化。
对于 CMS 垃圾收集器,如果发现 remark 过长,如果是大量本地缓存利用的话这个过长应该很失常,因为在并发阶段很容易有很多新对象进入缓存,从而 remark 阶段扫描很耗时,remark 又会暂停。能够开启 -XX:CMSScavengeBeforeRemark,在 remark 阶段前进行一次 YGC,从而缩小 remark 阶段扫描 gc root 的开销。
能够应用 G1 垃圾收集器,通过 -XX:MaxGCPauseMillis 设置最大进展工夫,进步服务可用性。

  1. 缓存的监控
    很多人对于缓存的监控也比拟疏忽,基本上线之后如果不报错而后就默认他就失效了。然而存在这个问题,很多人因为经验不足,有可能设置了不失当的过期工夫,或者不失当的缓存大小导致缓存命中率不高,让缓存就成为了代码中的一个装饰品。所以对于缓存各种指标的监控,也比拟重要,通过其不同的指标数据,咱们能够对缓存的参数进行优化,从而让缓存达到最优化:

下面的代码中用来记录 get 操作的,通过 Cat 记录了获取缓存胜利,缓存不存在,缓存过期,缓存失败(获取缓存时如果抛出异样,则叫失败),通过这些指标,咱们就能统计出命中率,咱们调整过期工夫和大小的时候就能够参考这些指标进行优化。

  1. 一款好的框架
    一个好的剑客没有一把好剑怎么行呢?如果要应用好缓存,一个好的框架也必不可少。在最开始应用的时候大家应用缓存都用一些 util,把缓存的逻辑写在业务逻辑中:

下面的代码把缓存的逻辑耦合在业务逻辑当中,如果咱们要减少成多级缓存那就须要批改咱们的业务逻辑,不合乎开闭准则,所以引入一个好的框架是不错的抉择。
举荐大家应用 JetCache 这款开源框架,其实现了 Java 缓存标准 JSR107 并且反对主动刷新等高级性能。笔者参考 JetCache 联合 Spring Cache, 监控框架 Cat 以及美团的熔断限流框架 Rhino 实现了一套自有的缓存框架,让操作缓存,打点监控,熔断降级,业务人员无需关怀。下面的代码能够优化成:

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

最初
想要真正的应用好一个缓存,必须要把握很多的常识,并不是看几个 Redis 原理剖析,就能把 Redis 缓存用得炉火纯青。对于不同场景,缓存有各自不同的用法,同样的不同的缓存也有本人的调优策略,过程内缓存你须要关注的是他的淘汰算法和 GC 调优,以及要防止缓存净化等。分布式缓存你须要关注的是他的高可用,如果其不可用了如何进行降级,以及一些序列化的问题。一个好的框架也是必不可少的,对其如果应用切当再加上下面介绍的教训,置信能让你很好的驾驭住这头野马——缓存。

正文完
 0