缓存这匹“野马”,你驾驭得了吗?

背景

俗话说得好,工欲善其事,必先利其器,有了好的工具肯定得知道如何用好这些工具,本篇将介绍如何利用好缓存。

1. 确认是否需要缓存

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

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

如果并没有上述两个问题,那么你不必为了增加缓存而缓存。

2. 选择合适的缓存

缓存又分进程内缓存和分布式缓存两种。很多人包括笔者在开始选缓存框架的时候都感到了困惑: 网上的缓存太多了,大家都吹嘘自己很牛逼,我该怎么选择呢?

2.1 选择合适的进程缓存

首先看看几个比较常用的缓存的比较,具体原理可以参考你应该知道的缓存进化史:

  • 对于 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)。不同的分布式缓存功能特性和实现原理方面有很大的差异,因此他们所适应的场景也有所不同。

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 的过期长。

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

4. 缓存更新

一般来说缓存的更新有两种情况:

  • 先删除缓存,再更新数据库。
  • 先更新数据库,再删除缓存。 这两种情况在业界,大家对其都有自己的看法。具体怎么使用还得看各自的取舍。当然肯定会有人问为什么要删除缓存呢?而不是更新缓存呢?你可以想想当有多个并发的请求更新数据,你并不能保证更新数据库的顺序和更新缓存的顺序一致,那就会出现数据库中和缓存中数据不一致的情况。所以一般来说考虑删除缓存。

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

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

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

先删除缓存也有一个好处是,如果对数据库操作失败了,那么由于先删除的缓存,最多只是造成 Cache Miss。

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

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

为什么我们这种情况有问题,很多公司包括 Facebook 还会选择呢?因为要触发这个条件比较苛刻。

  1. 首先需要数据不在缓存中。
  2. 其次查询操作需要在更新操作先到达数据库。
  3. 最后查询操作的回填比更新操作的删除后触发,这个条件基本很难出现,因为更新操作的本来在查询操作之后,一般来说更新操作比查询操作稍慢。但是更新操作的删除却在查询操作之后,所以这个情况比较少出现。

对比上面 4.1 的问题来说这种问题的概率很低,况且我们有超时机制保底所以基本能满足我们的需求。如果真的需要追求完美,可以使用二阶段提交,但是其成本和收益一般来说不成正比。

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

5. 缓存挖坑三剑客

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

5.1 缓存穿透

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

为了避免这个问题,可以采取下面两个手段:

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

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

5.2 缓存击穿

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

为了避免这个问题,我们可以采取下面的两个手段:

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

5.3 缓存雪崩

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

为了避免这个问题,我们采取下面的手段:

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

6. 缓存污染

缓存污染一般出现在我们使用本地缓存中,可以想象,在本地缓存中如果你获得了缓存,但是你接下来修改了这个数据,但是这个数据并没有更新在数据库,这样就造成了缓存污染:

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

7. 序列化

序列化是很多人都不注意的一个问题,很多人忽略了序列化的问题,上线之后马上报出一下奇怪的错误异常,造成了不必要的损失,最后一排查都是序列化的问题。列举几个序列化常见的问题:

  1. key-value 对象过于复杂导致序列化不支持: 笔者之前出过一个问题,在美团的 Tair 内部默认是使用 protostuff 进行序列化,而美团使用的通讯框架是 thfift,thrift 的 TO 是自动生成的,这个 TO 里面很多复杂的数据结构,但是将其存放到了 Tair 中。查询的时候反序列化也没有报错,单测也通过,但是到 qa 测试的时候发现这一块功能有问题,发现有个字段是 boolean 类型默认是 false,把它改成 true 之后,序列化到 tair 中再反序列化还是 false。定位到是 protostuff 对于复杂结构的对象 ( 比如数组,List
  2. 添加了字段或者删除了字段,导致上线之后老的缓存获取的时候反序列化报错,或者出现一些数据移位。
  3. 不同的 JVM 的序列化不同,如果你的缓存有不同的服务都在共同使用 (不提倡),那么需要注意不同 JVM 可能会对 Class 内部的 Field 排序不同,而影响序列化。比如下面的代码,在 Jdk7 和 Jdk8 中对象 A 的排列顺序不同,最终会导致反序列化结果出现问题:

//jdk 7

classA

{

int a;

int b;

}

//jdk 8

class A

{

int b;

int a;

}

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

  1. 测试: 对于序列化需要进行全面的测试,如果有不同的服务并且他们的 JVM 不同那么你也需要做这一块的测试,在上面的问题中笔者的单测通过的原因是用的默认数据 false,所以根本没有测试 true 的情况,还好 QA 给力,将其给测试出来了。
  2. 对于不同的序列化框架都有自己不同的原理,对于添加字段之后如果当前序列化框架不能兼容老的,那么可以换个序列化框架。 对于 protostuff 来说他是按照 Field 的顺序来进行反序列化的,对于添加字段我们需要放到末尾,也就是不能插在中间,否则会出现错误。对于删除字段来说,用 @Deprecated 注解进行标注弃用,如果贸然删除,除非是最后一个字段,否则肯定会出现序列化异常。
  3. 可以使用双写来避免,对于每个缓存的 key 值可以加上版本号,每次上线版本号都加 1,比如现在线上的缓存用的是 Key1,即将要上线的是 Key2, 上线之后对缓存的添加是会写新老两个不同的版本 (Key1,Key2) 的 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 设置最大停顿时间,提高服务可用性。
  4. 说到这里顺便给大家推荐一个 Java 架构方面的交流学习社群:650385180,里面不仅可以交流讨论,还有面试经验分享以及免费的资料下载,包括 Spring,MyBatis,Netty 源码分析,高并发、高性能、分布式、微服务架构的原理,JVM 性能优化这些成为架构师必备的知识体系。相信对于已经工作和遇到技术瓶颈的码友,在这个群里会有你需要的内容。

9. 缓存的监控

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

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

10. 一款好的框架

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

上面的代码把缓存的逻辑耦合在业务逻辑当中,如果我们要增加成多级缓存那就需要修改我们的业务逻辑,不符合开闭原则,所以引入一个好的框架是不错的选择。

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

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

最后

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

最后打个广告,如果你觉得这篇文章对你有文章,可以关注我的技术公众号:Java 技术 zhai,最近作者收集了很多最新的学习资料视频以及面试资料,关注之后即可领取,你的关注和转发是对我最大的支持,O(∩_∩)O