数据未更新维护不给出款怎么解决 缓存这匹“野马”,你驾驭得了吗?
所以如果仅仅是使用 Redis,能满足我们大部分需求,但是当需要追求更高性能以及更高可用性的时候,那就不得不了解多级缓存。
使用进程缓存
对于进程内缓存,它本来受限于内存大小的限制,以及进程缓存更新后其他缓存无法得知,所以一般来说进程缓存适用于:
数据量不是很大,数据更新频率较低,之前我们有个查询商家名字的服务,在发送短信的时候需要调用,由于商家名字变更频率较低,并且就算是变更了没有及时变更缓存,短信里面带有老的商家名字客户也能接受。
利用 作为本地缓存,Size 设置为 1 万,过期时间设置为 1 个小时,基本能在高峰期解决问题。
如果数据量更新频繁,也想使用进程缓存的话,那么可以将其过期时间设置为较短,或者设置其较短的自动刷新的时间。这些对于 或者 Guava Cache 来说都是现成的 API。
使用多级缓存
俗话说得好,世界上没有什么是一个缓存解决不了的事,如果有,那就两个。
一般来说我们选择一个进程缓存和一个分布式缓存来搭配做多级缓存,一般来说引入两个也足够了。
如果使用三个,四个的话,技术维护成本会很高,反而有可能会得不偿失,如下图所示:
利用 做一级缓存,Redis 作为二级缓存,步骤如下:
对于 的缓存,如果有数据更新,只能删除更新数据的那台机器上的缓存,其他机器只能通过超时来过期缓存,超时设定可以有两种策略:
对于 Redis 的缓存更新,其他机器立刻可见,但是也必须要设置超时时间,其时间比 的过期长。
为了解决进程内缓存的问题,设计进一步优化:
通过 Redis 的 Pub/Sub,可以通知其他进程缓存对此缓存进行删除。如果 Redis 挂了或者订阅机制不靠谱,依靠超时设定,依然可以做兜底处理。
缓存更新
一般来说缓存的更新有两种情况:
这两种情况在业界,大家都有自己的看法。具体怎么使用还得看各自的取舍。当然肯定有人会问为什么要删除缓存呢?而不是更新缓存呢?
当有多个并发的请求更新数据,你并不能保证更新数据库的顺序和更新缓存的顺序一致,那么就会出现数据库中和缓存中数据不一致的情况。所以一般来说考虑删除缓存。
先删除缓存,再更新数据库
对于一个更新操作简单来说,就是先对各级缓存进行删除,然后更新数据库。
这个操作有一个比较大的问题,在对缓存删除完之后,有一个读请求,这个时候由于缓存被删除所以直接会读库,读操作的数据是老的并且会被加载进入缓存当中,后续读请求全部访问的老数据。
对缓存的操作不论成功失败都不能阻塞我们对数据库的操作,那么很多时候删除缓存可以用异步的操作,但是先删除缓存不能很好的适用于这个场景。
先删除缓存也有一个好处是,如果对数据库操作失败了,那么由于先删除的缓存,最多只是造成 Cache Miss。
如果我们使用更新数据库,再删除缓存就能避免上面的问题。但是同样引入了新的问题。
试想一下有一个数据此时是没有缓存的,所以查询请求会直接落库,更新操作在查询请求之后,但是更新操作删除数据库操作在查询完之后回填缓存之前,就会导致我们缓存中和数据库出现缓存不一致。
为什么我们这种情况有问题,很多公司包括 还会选择呢?因为要触发这个条件比较苛刻:
对比上面先删除缓存,再更新数据库的问题来说这种问题出现的概率很低,况且我们有超时机制保底所以基本能满足我们的需求。
如果真的需要追求完美,可以使用二阶段提交,但是成本和收益一般来说不成正比。
当然还有个问题是如果我们删除失败了,缓存的数据就会和数据库的数据不一致,那么我们就只能靠过期超时来进行兜底。
对此我们可以进行优化,如果删除失败的话 我们不能影响主流程那么我们可以将其放入队列后续进行异步删除。
缓存挖坑三剑客
大家一听到缓存有哪些注意事项,首先想到的肯定是缓存穿透,缓存击穿,缓存雪崩这三个挖坑的小能手,这里简单介绍一下他们具体是什么以及应对的方法。
缓存穿透
缓存穿透是指查询的数据在数据库是没有的,那么在缓存中自然也没有,所以在缓存中查不到就会去数据库查询,这样的请求一多,我们数据库的压力自然会增大。
为了避免这个问题,可以采取下面两个手段:
约定:对于返回为 NULL 的依然缓存,对于抛出异常的返回不进行缓存,注意不要把抛异常的也给缓存了。
采用这种手段会增加我们缓存的维护成本,需要在插入缓存的时候删除这个空缓存,当然我们可以通过设置较短的超时时间来解决这个问题。
制定一些规则过滤一些不可能存在的数据,小数据用 ,大数据可以用布隆过滤器。
比如你的订单 ID 明显是在一个范围 1-1000,如果不是 1-1000 之内的数据那其实可以直接给过滤掉。
缓存击穿
对于某些 Key 设置了过期时间,但是它是热点数据,如果某个 Key 失效,可能大量的请求打过来,缓存未命中,然后去数据库访问,此时数据库访问量会急剧增加。
为了避免这个问题,我们可以采取下面的两个手段:
缓存雪崩
缓存雪崩是指缓存不可用或者大量缓存由于超时时间相同在同一时间段失效,大量请求直接访问数据库,数据库压力过大导致系统雪崩。
为了避免这个问题,我们采取下面的手段:
缓存污染
缓存污染一般出现在我们使用本地缓存中。可以想象,在本地缓存中如果你获得了缓存,但是你接下来修改了这个数据,这个数据却并没有更新在数据库,这样就造成了缓存污染:
上面的代码就造成了缓存污染,通过 ID 获取 ,但是需求需要修改 的名字。
所以开发人员直接在取出来的对象中直接修改,这个 对象就会被污染,其他线程取出这个数据就是错误的数据。
要想避免这个问题需要开发人员从编码上注意,并且代码必须经过严格的 ,以及全方位的回归测试,才能从一定程度上解决这个问题。
序列化
序列化是很多人都不注意的一个问题,很多人忽略了序列化的问题,上线之后马上报出一下奇怪的错误异常,造成了不必要的损失,最后一排查都是序列化的问题。
列举几个序列化常见的问题:
Key-Value 对象过于复杂导致序列化不支持:笔者之前出过一个问题,在美团的 Tair 内部默认是使用 进行序列化。
而美团使用的通讯框架是 , 的 TO 是自动生成的,这个 TO 里面有很多复杂的数据结构,但是将它存放到了 Tair 中。
查询的时候反序列化也没有报错,单测也通过,但是到 QA 测试的时候发现这一块功能有问题,有个字段是 类型默认是 False,把它改成 true 之后,序列化到 Tair 中再反序列化还是 False。
定位到是 对于复杂结构的对象(比如数组,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 给力,将它给测试出来了。
对于不同的序列化框架都有自己不同的原理,对于添加字段之后如果当前序列化框架不能兼容老的,那么可以换个序列化框架。
对于 来说它是按照 Field 的顺序来进行反序列化的,对于添加字段我们需要放到末尾,也就是不能插在中间,否则会出现错误。
对于删除字段来说,用 @ 注解进行标注弃用,如果贸然删除,除非是最后一个字段,否则肯定会出现序列化异常。
可以使用双写来避免,对于每个缓存的 Key 值可以加上版本号,每次上线版本号都加 1。
比如现在线上的缓存用的是 Key_1,即将要上线的是 Key_2,上线之后对缓存的添加是会写新老两个不同的版本(Key_1,Key_2)的 Key-Value,读取数据还是读取老版本 Key_1 的数据。
假设之前的缓存的过期时间是半个小时,那么上线半个小时之后,之前的老缓存存量的数据都会被淘汰,此时线上老缓存和新缓存的数据基本是一样的,切换读操作到新缓存,然后停止双写。
采用这种方法基本能平滑过渡新老 Model 交替,但是不好的就是需要短暂的维护两套新老 Model,下次上线的时候需要删除掉老 Model,这样增加了维护成本。
GC 调优
对于大量使用本地缓存的应用,由于涉及到缓存淘汰,那么 GC 问题必定是常事。如果出现 GC 较多,STW 时间较长,那么必定会影响服务可用性。
这一块给出下面几点建议:
缓存的监控
很多人对于缓存的监控也比较忽略,基本上线之后如果不报错,然后就默认它就生效了。
但是存在这个问题,很多人由于经验不足,有可能设置了不恰当的过期时间,或者不恰当的缓存大小导致缓存命中率不高,让缓存成为了代码中的一个装饰品。
所以对于缓存各种指标的监控,也比较重要,通过不同的指标数据,我们可以对缓存的参数进行优化,从而让缓存达到最优化:
上面的代码中用来记录 Get 操作的,通过 Cat 记录了获取缓存成功,缓存不存在,缓存过期,缓存失败(获取缓存时如果抛出异常,则叫失败)。
通过这些指标,我们就能统计出命中率,我们调整过期时间和大小的时候就可以参考这些指标进行优化。
一款好的框架
一个好的剑客没有一把好剑怎么行呢?如果要使用好缓存,一个好的框架也必不可少。
在最开始使用的时候,大家使用缓存都用一些 util,把缓存的逻辑写在业务逻辑中:
上面的代码把缓存的逻辑耦合在业务逻辑当中,如果我们要增加成多级缓存那就需要修改我们的业务逻辑,不符合开闭原则,所以引入一个好的框架是不错的选择。
推荐大家使用 这款开源框架,它实现了 Java 缓存规范 并且支持自动刷新等高级功能。
笔者参考 结合 Cache,监控框架 Cat 以及美团的熔断限流框架 Rhino 实现了一套自有的缓存框架,让操作缓存,打点监控,熔断降级,业务人员无需关心。
上面的代码可以优化成:
对于一些监控数据也能轻松从大盘上看到:
总结
想要真正的使用好一个缓存,必须要掌握很多的知识,并不是看几个 Redis 原理分析,就能把 Redis 缓存用得炉火纯青。
对于不同场景,缓存有各自不同的用法,同样的不同的缓存也有自己的调优策略,进程内缓存你需要关注的是它的淘汰算法和 GC 调优,以及要避免缓存污染等。
分布式缓存你需要关注的是它的高可用,如果它不可用了,如何进行降级,以及一些序列化的问题。
一个好的框架也是必不可少的,对它如果使用得当再加上上面介绍的经验,相信能让你很好的驾驭住这头野马——缓存。
