俗话说得好,工欲善其事,必先利其器,有了好的工具确定得知道如何用好这些工具,本篇将分为以下几个方面介绍如何利用好缓存:正则表达式
你真的须要缓存吗算法
在使用缓存以前,须要确认你的项目是否真的须要缓存。使用缓存会引入必定的技术复杂度,通常来讲从两个方面来判断是否须要使用缓存:数据库
CPU 占用数组
若是你有某些应用须要消耗大量的 CPU 去计算,好比正则表达式;若是你使用正则表达式比较频繁,而它又占用了不少 CPU 的话,那你就应该使用缓存将正则表达式的结果给缓存下来。缓存
数据库 IO 占用bash
若是你发现你的数据库链接池比较空闲,能够不用缓存。可是若是数据库链接池比较繁忙,甚至常常报出链接不够的报警,那么是时候应该考虑缓存了。网络
笔者曾经有个服务被不少其余服务调用,其余时间都还好,可是在天天早上 10 点的时候老是会报出数据库链接池链接不够的报警。数据结构
通过排查,我发现有几个服务选择了在 10 点作定时任务,大量的请求打过来,DB 链接池不够,从而产生链接池不够的报警。多线程
这个时候有几个选择,咱们能够经过扩容机器来解决,也能够经过增长数据库链接池来解决。架构
可是没有必要增长这些成本,由于只有在 10 点的时候才会出现这个问题。后来引入了缓存,不只解决了这个问题,并且还增长了读的性能。
若是并无上述两个问题,那么你没必要为了增长缓存而缓存。
如何选择合适的缓存
缓存分为进程内缓存和分布式缓存。包括笔者在内的不少人在开始选缓存框架的时候都会感到困惑:网上的缓存太多了,你们都吹嘘本身很牛逼,我该怎么选择呢?
选择合适的进程缓存
首先看几个比较经常使用缓存的比较,具体原理能够参考《你应该知道的缓存进化史》:
对于 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。
选择合适的分布式缓存
这里我选取三个比较出名的分布式缓存来做为比较,MemCache(没有实战使用过),Redis(在美团又叫 Squirrel),Tair(在美团又叫 Cellar)。
不一样的分布式缓存功能特性和实现原理方面有很大的差别,所以它们所适应的场景也有所不一样:
总结:若是服务对延迟比较敏感,Map/Set 数据也比较多的话,比较适合 Redis。
若是服务须要放入缓存量的数据很大,对延迟又不是特别敏感的话,那就能够选择 Tair。
在美团的不少应用中对 Tair 都有应用,在笔者的项目中使用其存放咱们生成的支付 Token,支付码,用来替代数据库存储。大部分的状况下二者均可以选择,互为替代。
多级缓存
一说到缓存,不少人脑子里面立刻就会出现下面的图:
Redis 用来存储热点数据,Redis 中没有的数据则直接去数据库访问。
在以前介绍本地缓存的时候,不少人都问我,我已经有 Redis 了,我为何还须要了解 Guava,Caffeine 这些进程缓存呢?
我统一回复下,有以下两个缘由:
这个思路并非咱们作互联网架构独有的,在计算机系统中使用 L1,L2,L3 多级缓存,用来减小对内存的直接访问,从而加快访问速度。
因此若是仅仅是使用 Redis,能知足咱们大部分需求,可是当须要追求更高性能以及更高可用性的时候,那就不得不了解多级缓存。
使用进程缓存
对于进程内缓存,它原本受限于内存大小的限制,以及进程缓存更新后其余缓存没法得知,因此通常来讲进程缓存适用于:
数据量不是很大,数据更新频率较低,以前咱们有个查询商家名字的服务,在发送短信的时候须要调用,因为商家名字变动频率较低,而且就算是变动了没有及时变动缓存,短信里面带有老的商家名字客户也能接受。
利用 Caffeine 做为本地缓存,Size 设置为 1 万,过时时间设置为 1 个小时,基本能在高峰期解决问题。
若是数据量更新频繁,也想使用进程缓存的话,那么能够将其过时时间设置为较短,或者设置其较短的自动刷新的时间。这些对于 Caffeine 或者 Guava Cache 来讲都是现成的 API。
使用多级缓存
俗话说得好,世界上没有什么是一个缓存解决不了的事,若是有,那就两个。
通常来讲咱们选择一个进程缓存和一个分布式缓存来搭配作多级缓存,通常来讲引入两个也足够了。
若是使用三个,四个的话,技术维护成本会很高,反而有可能会得不偿失,以下图所示:
利用 Caffeine 作一级缓存,Redis 做为二级缓存,步骤以下:
对于 Caffeine 的缓存,若是有数据更新,只能删除更新数据的那台机器上的缓存,其余机器只能经过超时来过时缓存,超时设定能够有两种策略:
对于 Redis 的缓存更新,其余机器马上可见,可是也必需要设置超时时间,其时间比 Caffeine 的过时长。
为了解决进程内缓存的问题,设计进一步优化:
经过 Redis 的 Pub/Sub,能够通知其余进程缓存对此缓存进行删除。若是 Redis 挂了或者订阅机制不靠谱,依靠超时设定,依然能够作兜底处理。
缓存更新
通常来讲缓存的更新有两种状况:
这两种状况在业界,你们都有本身的见解。具体怎么使用还得看各自的取舍。固然确定有人会问为何要删除缓存呢?而不是更新缓存呢?
当有多个并发的请求更新数据,你并不能保证更新数据库的顺序和更新缓存的顺序一致,那么就会出现数据库中和缓存中数据不一致的状况。因此通常来讲考虑删除缓存。
先删除缓存,再更新数据库
对于一个更新操做简单来讲,就是先对各级缓存进行删除,而后更新数据库。
这个操做有一个比较大的问题,在对缓存删除完以后,有一个读请求,这个时候因为缓存被删除因此直接会读库,读操做的数据是老的而且会被加载进入缓存当中,后续读请求所有访问的老数据。
对缓存的操做不论成功失败都不能阻塞咱们对数据库的操做,那么不少时候删除缓存能够用异步的操做,可是先删除缓存不能很好的适用于这个场景。
先删除缓存也有一个好处是,若是对数据库操做失败了,那么因为先删除的缓存,最多只是形成 Cache Miss。
先更新数据库,再删除缓存(推荐)
若是咱们使用更新数据库,再删除缓存就能避免上面的问题。可是一样引入了新的问题。
试想一下有一个数据此时是没有缓存的,因此查询请求会直接落库,更新操做在查询请求以后,可是更新操做删除数据库操做在查询完以后回填缓存以前,就会致使咱们缓存中和数据库出现缓存不一致。
为何咱们这种状况有问题,不少公司包括 Facebook 还会选择呢?由于要触发这个条件比较苛刻:
对比上面先删除缓存,再更新数据库的问题来讲这种问题出现的几率很低,何况咱们有超时机制保底因此基本能知足咱们的需求。
若是真的须要追求完美,可使用二阶段提交,可是成本和收益通常来讲不成正比。
固然还有个问题是若是咱们删除失败了,缓存的数据就会和数据库的数据不一致,那么咱们就只能靠过时超时来进行兜底。
对此咱们能够进行优化,若是删除失败的话 咱们不能影响主流程那么咱们能够将其放入队列后续进行异步删除。
缓存挖坑三剑客
你们一听到缓存有哪些注意事项,首先想到的确定是缓存穿透,缓存击穿,缓存雪崩这三个挖坑的小能手,这里简单介绍一下他们具体是什么以及应对的方法。
缓存穿透
缓存穿透是指查询的数据在数据库是没有的,那么在缓存中天然也没有,因此在缓存中查不到就会去数据库查询,这样的请求一多,咱们数据库的压力天然会增大。
为了不这个问题,能够采起下面两个手段:
约定:对于返回为 NULL 的依然缓存,对于抛出异常的返回不进行缓存,注意不要把抛异常的也给缓存了。
采用这种手段会增长咱们缓存的维护成本,须要在插入缓存的时候删除这个空缓存,固然咱们能够经过设置较短的超时时间来解决这个问题。
制定一些规则过滤一些不可能存在的数据,小数据用 BitMap,大数据能够用布隆过滤器。
好比你的订单 ID 明显是在一个范围 1-1000,若是不是 1-1000 以内的数据那其实能够直接给过滤掉。
缓存击穿
对于某些 Key 设置了过时时间,可是它是热点数据,若是某个 Key 失效,可能大量的请求打过来,缓存未命中,而后去数据库访问,此时数据库访问量会急剧增长。
为了不这个问题,咱们能够采起下面的两个手段:
对于获取到这个锁的线程,查询数据库更新缓存,其余线程采起重试策略,这样数据库不会同时受到不少线程访问同一条数据。
缓存雪崩
缓存雪崩是指缓存不可用或者大量缓存因为超时时间相同在同一时间段失效,大量请求直接访问数据库,数据库压力过大致使系统雪崩。
为了不这个问题,咱们采起下面的手段:
缓存污染
缓存污染通常出如今咱们使用本地缓存中。能够想象,在本地缓存中若是你得到了缓存,可是你接下来修改了这个数据,这个数据却并无更新在数据库,这样就形成了缓存污染:
上面的代码就形成了缓存污染,经过 ID 获取 Customer,可是需求须要修改 Customer 的名字。
因此开发人员直接在取出来的对象中直接修改,这个 Customer 对象就会被污染,其余线程取出这个数据就是错误的数据。
要想避免这个问题须要开发人员从编码上注意,而且代码必须通过严格的 Review,以及全方位的回归测试,才能从必定程度上解决这个问题。
序列化
序列化是不少人都不注意的一个问题,不少人忽略了序列化的问题,上线以后立刻报出一下奇怪的错误异常,形成了没必要要的损失,最后一排查都是序列化的问题。
列举几个序列化常见的问题:
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,这样增长了维护成本。
GC 调优
对于大量使用本地缓存的应用,因为涉及到缓存淘汰,那么 GC 问题一定是常事。若是出现 GC 较多,STW 时间较长,那么一定会影响服务可用性。
这一块给出下面几点建议:
能够开启 XX:CMSScavengeBeforeRemark,在 Remark 阶段前进行一次 YGC,从而减小 Remark 阶段扫描 GC Root 的开销。
缓存的监控
不少人对于缓存的监控也比较忽略,基本上线以后若是不报错,而后就默认它就生效了。
可是存在这个问题,不少人因为经验不足,有可能设置了不恰当的过时时间,或者不恰当的缓存大小致使缓存命中率不高,让缓存成为了代码中的一个装饰品。
因此对于缓存各类指标的监控,也比较重要,经过不一样的指标数据,咱们能够对缓存的参数进行优化,从而让缓存达到最优化:
上面的代码中用来记录 Get 操做的,经过 Cat 记录了获取缓存成功,缓存不存在,缓存过时,缓存失败(获取缓存时若是抛出异常,则叫失败)。
经过这些指标,咱们就能统计出命中率,咱们调整过时时间和大小的时候就能够参考这些指标进行优化。
一款好的框架
一个好的剑客没有一把好剑怎么行呢?若是要使用好缓存,一个好的框架也必不可少。
在最开始使用的时候,你们使用缓存都用一些 util,把缓存的逻辑写在业务逻辑中:
上面的代码把缓存的逻辑耦合在业务逻辑当中,若是咱们要增长成多级缓存那就须要修改咱们的业务逻辑,不符合开闭原则,因此引入一个好的框架是不错的选择。
推荐你们使用 JetCache 这款开源框架,它实现了 Java 缓存规范 JSR107 而且支持自动刷新等高级功能。
笔者参考 JetCache 结合 Spring Cache,监控框架 Cat 以及美团的熔断限流框架 Rhino 实现了一套自有的缓存框架,让操做缓存,打点监控,熔断降级,业务人员无需关心。
上面的代码能够优化成:
对于一些监控数据也能轻松从大盘上看到:
总结
想要真正的使用好一个缓存,必需要掌握不少的知识,并非看几个 Redis 原理分析,就能把 Redis 缓存用得炉火纯青。
对于不一样场景,缓存有各自不一样的用法,一样的不一样的缓存也有本身的调优策略,进程内缓存你须要关注的是它的淘汰算法和 GC 调优,以及要避免缓存污染等。
分布式缓存你须要关注的是它的高可用,若是它不可用了,如何进行降级,以及一些序列化的问题。
一个好的框架也是必不可少的,对它若是使用得当再加上上面介绍的经验,相信能让你很好的驾驭住这头野马——缓存。
做者:李钊来源:51CTO技术栈
原文地址:http://stor.51cto.com/art/201808/582218.htm