为系统引入缓存的理由
在软件的开发中,引入缓存的负面作用明显大于硬件的缓存。主要由以下几个方面
从开发者角度来说引入缓存会提高系统的复杂度,因为你需要考虑缓存的失效、更新、一致性等问题(硬件缓存也存在这些问题,只是不需要你考虑了)
从运维角度来说 缓存会掩盖掉一些缺陷,让问题在更久的时间以后,出现在距离发生现场更远的位置上;缓存还可能存在泄漏某些保密数据,这也是容易攻击的薄弱点
存在这么多问题,为什么系统还会引入缓存呢
第一种为了缓解CPU压力而做的缓存
比如说,把方法运行的结果存储起来、把原本要实时计算的内容提前算好、把一些公用的数据进行复用等等,这些引入缓存的做法,都可以节省CPU算力,顺带提升响应性能
第二种,为了缓解I/O压力而做缓存
比如说,通过引入缓存,把原本对网络、磁盘等较慢介质的访问;把原本对单点部件(如数据库)的读写访问,变为可扩缩部件(如缓存中间件)的访问,等等,也顺带提升了响应性能
最后能通过花钱,(比如扩服务器的数量)来满足需要的话,那升级硬件往往是更好的解决方案
缓存属性
通常我们在设计或选择缓存时,至少需要考虑以下四个维度的属性
吞吐量:缓存的吞吐量使用QPS来衡量,它反映了对缓存进行并发读、写操作的效率,即缓存本身工作效率的高低
命中率 缓存的命中率即成功从缓存中返回结果次数与总请求次数的比值,它反映了引入缓存的价值高低、命中率越低,引入缓存的收益越小,价值越低
**扩展功能:**缓存处理基本读写功能外,还提供了一些额外的管理功能,比如最大容量,失效时间、失效事件、命中率统计等
分布式支持缓存可以分为进程内缓存和分布式缓存两大类,前者只为节点本身提供服务,无网络访问操作,速度快,但缓存的数据不能在各个服务节点中共享。后者则相反
缓存最主要的数据竞争来源于读取数据的同时,也会伴随着对数据状态的写入操作,而写入数据的同时,也会伴随着数据状态的读取操作。
一种是以Guava Cache 为代表的同步处理机制,即在访问数据时一并完成缓存、淘汰、失效等状态变更操作,通过分段加锁等手段来尽量减少数据竞争。
另外一种是以Caffeine为代表的异步日志提交机制。这种机制参考了经典的数据库设计理论,它把对数据的读、写过程看作是日志(即对数据的操作指令)的提交过程
命中率与淘汰策略
第一种:FIFO(First In First Out)
即优先淘汰最早进入被缓存的数据。这个淘汰策略并不优秀,因为越是访问频繁的数据,往往越会早早地被存入缓存中。所以这种淘汰策略,很可能会大幅度降低缓存的命中率
第二种:LRF(Least Recent Used)
即优先淘汰最久未被使用访问过的数据。LRU通常会采用HashMap加LinkedList的双重结构(如LinkedHashMap)来实现。也就是,它以HashMap来提供访问接口,保证常量时间的读取性能,以LinkedList的链表元素顺序来表示数据的时间顺序,在每次命中缓存时,把返回对象调整到LinkedList开头,每次缓存淘汰时从链表末端开始清理
第三种 :LFU (Least Frequently Used)
即优先淘汰掉最不经常使用的数据。LFU会给每个数据添加一个访问计数器,每访问一次就加1,当要淘汰数据的时候,就清理计数器数值最小的那批数据**。第一个问题**这个的缺点就是需要针对每个缓存数据都专门去维护一个计数器,每次访问都要更新,吞吐量会降低。第二个问题不便于处理随时间变化的热度变化,比如某个曾经频繁访问的数据现在不需要了,它也很难被自动清理出缓存。
TinyLFU
TinyLFU时LFU的改进版。为了缓解LFU每次访问都需要修改计数器所带来的性能负担。TinyLFU 首先采用Sketch结构来分析访问数据,所谓的Sketch,它实际上是统计学中的概念。即采用少量样本数据来估计全部样本数据的特征。
借助Count-Min-Sketch 算法(类似布隆过滤器的一种等价变种结构)可以用相对小得多的记录频率和空间,来近似地找出缓存中的低价值数据
另外为了解决LFU不便于处理时间变化的热度变化问题,TinyLFU采用基于“滑动时间窗口“的热度衰减算法。简单理解就是每隔一段时间,会把计数器的数值减半,以此解决”旧热点“数据难以清除的问题
分布式缓存如何与本地缓存配合,提高系统性能
复制式缓存与集中式缓存
复制式缓存,你可以看作是能够支持分布式的进程内缓存。它的工作原理与Session复制类似,缓存中的所有数据,在分布式集群的每个节点里面都存一份副本,当读取数据时,无需网络访问,直接从当前节点的进程内存中返回。读取效率很高,但是当数据变更时,就必须遵从复制协议,将变更的数据同步到集群的每一个节点,代价很高,在小规模集群中还算可以,但是在大规模集群下,网络同步速度跟不上写的速度,进而导致内存中有大量的待重发对象,最终导致OOM
集中式缓存,集中式缓存是目前分布式缓存中的主流形式。集中式缓存的读写都需要网络访问,它的好处就是不会随着集群的节点数量增加而增加额外的负担
透明多级缓存 分布式缓存和进程内缓存,各有所长,也各有局限,它们是互补的,而不是竞争关系,而多级缓存就是,使用进程内缓存做一级缓存,分布式做二级缓存,如果能在一级缓存中查询到结果接直接返回,否则就到二级缓存中去查询,再将二级缓存的结果回填到一级缓存,以后的访问就没有网络请求了。但是我们需要透明的解决这些问题,多级缓存才有意义。一种常见的设计原则,就是变更以分布式缓存中的数据为准,访问以进程内缓存的数据优先
大致做法就是当数据发生变动时,在集群内发送推送通知(简单点可以使用Redis的发布订阅模式,求严谨也可以引入Zookeeper或Etcd来处理),让各个节点的一级缓存自动失效掉响应的数据。
然后,当访问缓存时,缓存框架封装好一二级缓存联合查询接口,接口外部只查询一次,接口内部实现优先查询一级缓存。如果没获取到数据,就再自动查询二级缓存
缓存的风险
缓存穿透
如果查询的数据数据库中根本就不存在的话,缓存里面自然也不会有。这样请求的流量每次都不会命中,每次都会打到末端数据库,缓存自然就起不到缓存压力的作用了。这种现象就是缓存穿透
对于业务逻辑本身就不能避免的缓存穿透
我们可以约定在一定时间内,对返回为空的Key值依然进行缓存(注意正常返回,但是结果为空,不要把抛异常的当作空值缓存了)这样在一段时间内只会被穿透一次。如果后续业务在数据库中对该Key值插入新的记录,那我们就应当在插入之后主动清理掉缓存的Key值,如果业务实效性允许的话,也可以设置一个较短的超时时间来自动处理缓存
对于恶意攻击导致的缓存穿透
针对这种原因,我们通常会在缓存之前设置一个布隆过滤器来解决。所谓的恶意攻击是指,请求者刻意构造数据库中肯定不存在的Key值,然后发送大量请求进行查询,而布隆过滤器是最小的代价,来判断某个元素是否存在某个集合的办法。
如果布隆过滤器给出的判断结果是请求数据不存在,那直接返回即可,连缓存都不必去查。虽然维护布隆过滤器需要一定的成本,但是比起攻击造成的损失损耗,还是比较值得
缓存击穿
缓存中的某些热点数据忽然因为某种原因失效了,比如典型的超期而失效,而此时又有了多个针对该数据的请求同时发送过来,那么这些请求就会全部未能命中缓存,都打到真实的数据库了,导致压力剧增,甚至宕机。这种现象,就被称为缓存击穿。
如果要避免我们通常可以采用这样两种方法
1、加锁同步。以请求该数据Key值为锁,这样只有第一个请求可以流入真实的数据源中,其他线程采用阻塞或者重试策略。如果是进程内缓存出现了问题,施加普通互斥锁就可以了,如果是分布式缓存问题,就实施分布式锁
2、热点数据由代码来手动管理。缓存击穿是指针对热点数据被自动失效才引发的问题,所以针对这类数据,我们可以通过代码来有计划的实现更新、失效、避免缓存的策略自动管理
缓存雪崩
大量的数据同时失效,那么之所以会出现这样的情况,往往是因为系统有专门的缓存预热功能,也可能是因为,大量的公共数据都是由某一次冷操作加载的,这样可能会出现又一次载入的缓存的大批数据具有相同的过期时间,在同一时刻一起失效。
还有一种情况就是因为缓存服务因为某些原因崩溃重启后,此时会造成大量数据同时失效。
而避免缓存雪崩问题,我们通常可以采取这三种方法
1、提升系统的可用性,建设分布式缓存的集群
2、启用透明多级缓存,各个服务器节点的一级缓存中的数据,通常会具有不一样的加载时间,这样做也就分散了它们的过期时间
3、将缓存的生存期从固定时间改为一个时间段内的随机时间
缓存污染
所谓的缓存污染,就是指,缓存中的数据与真实数据源中的数据不一致的现。
为了提高使用缓存时的一致性,人们总结了不少遵循的设计模式,介绍一种Cache Aside模式,因为这种设计模式最简单,成本也最低。它的主要内容就两条
读数据时,先读缓存,缓存没有的话,再读数据源,然后将数据放入缓存,再响应请求
写数据时,先写数据库,然后失效(而不是更新)掉缓存
一个是先数据源后缓存
二个是应当先失效缓存,而不是尝试更新缓存