什么是缓存 ?
缓存就是数据交换的缓冲区(称作Cache)。
大部分的缓存使用,是为了提高程序数据的处理速度或者说提高程序的响应速度。
当然也有小部分的缓存是基于寿命,性价比等原因。
我们主要讨论使用缓存提高程序数据的处理速度的实现,这样就要求缓存的速度是必须
大于原来数据的处理速度的。
缓存方式简单分类
数据的缓存可以简单的分为两类
- 原始数据缓存
不对需要的数据处理,即存储处理前的来源数据。例如:程序访问A+B等于几
这里,程序直接从缓存中读取A和B的值,然后计算出结果。
这样的缓存比较底层,主要是利用缓存速度更快的特性。
现实中,一般利用内存速度远大于磁盘的速度的特性,使用内存作为数据缓存。
原始数据的缓存如: - Mybatis一级缓存(SqlSession 层面进行缓存);
- Hibernate的一级缓Session级别的缓存存()和二级缓存(SessionFactory级别的缓存)
- 手动对原始数据进行缓存,比如将数据字典存到redis中
- 对结果数据缓存
例如:程序访问A+B等于几,我们直接换成A+B之后的结果,需要的时候直接取出。
这种缓存的方式因为省略了业务的处理过程,会提高此部分的速度。
对结果数据的缓存如:
-
Mybatis的二级缓存(同一个namespace下的Mapper的各个SqlSession 对象共享的缓存)
-
hibernate的查询缓存,它依赖于二级缓存
-
手动对结果缓存,比如将结果存到redis中
-
mysql数据库的查询缓存
-
注意
- 一般而言,结果缓存是依赖于对原始数据的缓存的,这样可以同时利用缓存的
物理特性和业务特性。 - MyBatis中可以通过或者@CacheNamespaceRef来设置“共用”的Mapper缓存
缓存穿透
概念说明
一般的缓存系统,都是按照key去缓存查询,如果不存在对应的value,就应该去后端系统查找。缓存穿透是指查询key不存在的数据(不存在的key),会穿过缓存查询数据库。正常情况下缓存未命中时也会查询,但是高并发的情况下,会导致数据库的巨量查询,甚至压力过大而宕机。
例如:
如果将一个搜索网站的URL请求(包括参数)作为Key对搜索结果进行缓存。
因为用户输入的搜索参数经常变动,从而导致搜索的缓存结果命中率非常低,这就是常见缓存穿透问题。
解决方法
- 对查询结果为空的情况也进行缓存,缓存时间可以设置短一点。
- 缓存中存储此key是否存在过,如果确定不存在,则无需查询缓存与来源。
- 全量缓存,且过期时间很长,则当缓存未命中时亦无需访问db。
缓存雪崩
概念说明
缓存雪崩是指缓存失效后 到再次建立缓存的时间段内,程序大量访问原数据来源的现场。而由于原数据来源的速度慢,所以会导致业务处理阻塞,从而占用大量软硬件资源。
其特点时短时间内大量的缓存失效从而导致的数据库大量查询。
解决方法
- 查询db的过程可以考虑限流处理,例如同步或分布式锁来控制,防止短时间内的巨量失效
- 针对过期时间优化,减少短时间内大量失效的情况。比如尽量让多个缓存的失效时间点分开,降低多个缓存在同一时刻查询原数据来源的概率。
缓存击穿
概念说明
击穿不同于穿透,穿透是多个key的缓存未命中导致的海量db访问; 而击穿指单个key相同,但是并发高的情况下,同时访问缓存,而缓存恰好失效,从而导致海量访问db的情况。通常服务端未做分布式缓存优化情况下容易发生此问题。和缓存雪崩不同,击穿指的是单个key的过期访问情况。
一般缓存的建立过程如下:
获取缓存 //0.
if(缓存过期){ //1.
建立新缓存 //2.
}
返回缓存数据 //4.
在单线程的情况下是不会出现的。
那么在多线程情况下,如果缓存过期,而大量线程运行到第2步,那么就会有大量的数据处理没有走缓存,就会引起缓存的击穿现象。
解决方法
解决的核心思想就是使0,1,2步奏保证“单线程”运行,以保证缓存的获取到建立过程和其余线程是互斥的。
对于单个缓存目前主流的解决方式有如下几种:
- 队列实现,比如java的BlockingQueue
- 加入语言自带的线程锁,比如java的synchronized锁
- 手动实现互斥功能,比如java通过atomicInteger对线程计数,保证能入缓存获取-建立代码块的线程互斥
- 缓存永不过期,而是用一个单独的线程来定时更新或者回调更新缓存的数据。
- 缓存会过期,但是不由应用层来直接维护,而是用一个单独的线程来在缓存过期前对其自动更新。
对于多个缓存的处理:
- 同样可以考虑限制多个缓存建立的线程数量来降低同一时刻访问原数据来源的并发数。
缓存预热
在程序启动时就将缓存数据加载到缓存中。
缓存污染
缓存污染是指将不常用的数据装载到缓存,降低了缓存效率的现象。
回源
缓存未命中,需要从原始数据源获取数据的操作。
缓存算法
FIFO
先入先出队列(First Input First Output,FIFO)这是一种按顺序来存入和淘汰缓存数据的算法,
算法有限淘汰更早时间存入缓存的数据。
FIFO淘汰算法基于的思想是”最近刚访问的,将来访问的可能性比较大”。
- 实现
利用一个双向链表保存数据,当来了新的数据之后便添加到链表末尾,如果Cache存满数据,
则把链表头部数据删除,然后把新的数据添加到链表末尾。 - 特点
FIFO的实现简单,缓存的大小对其效率有很大影响。
LRU
LRU(least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想
是“如果数据最近被访问过(1次),那么将来被访问的几率也更高”。
所以当缓存的数量达到最大值时,会优先淘汰最近没有被访问的记录。
一般会同时限定缓存的最大存活时间,以同步/刷新缓存的数据。
- 实现
以链表实现为例:
- 新数据插入到链表头部;
- 每当缓存命中(即缓存数据被访问),则将数据移到链表头部;
- 当链表满的时候,将链表尾部的数据丢弃。
java中最简单的LRU算法实现,就是利用jdk的LinkedHashMap,覆写其中的removeEldestEntry(Map.Entry)方法即可
其方法默认返回false;put 和 putAll 时将调用此方法,自动移除"最少访问的数据"。
private static final int MAX_ENTRIES = 100; //默认最多100条缓存记录
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > MAX_ENTRIES;
}
- 特点:
当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧
下降,缓存污染情况比较严重。
LRU-K
LRU-K中的K代表最近使用的次数,因此LRU可以认为是LRU-1。
LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展
为“最近使用过K次”。
-
实现
相比LRU,LRU-K需要多维护一个队列,用于记录所有缓存数据被访问的历史。
只有当数据的访问次数达到K次的时候,才将数据放入缓存。
当需要淘汰数据时,未放入缓存队列的数据按照自定义规则(FIFO,LRU)淘汰数据;
放入缓存的数据,LRU-K会淘汰第K次访问时间距当前时间最大的数据(即队列中排在末尾的数据)。 -
特点
LRU-K降低了“缓存污染”带来的问题,命中率比LRU要高。实际应用中LRU-2是综合各种因素后最优的选择,
LRU-3或者更大的K值命中率会高,但适应性差,需要大量的数据访问才能将历史访问记录清除掉。
由于LRU-K还需要记录那些被访问过、但还没有放入缓存的对象,因此内存消耗会比LRU要多;
当数据量很大的时候,内存消耗会比较可观。
LRU-K需要基于时间进行排序(可以需要淘汰时再排序,也可以即时排序),CPU消耗比LRU要高。
Two queues(2Q)
2Q算法有两个缓存队列,一个是FIFO队列,一个是LRU队列,用两个缓存队列来缓存数据。
-
实现
当数据第一次访问时,2Q算法将数据缓存在FIFO队列里面,当数据第二次被访问时,
则将数据从FIFO队列移到LRU队列里面,两个队列各自按照自己的方法淘汰数据。 -
特点
2Q算法的命中率要高于LRU。2Q算法和LRU-2算法命中率类似,内存消耗也比较接近,
但对于最后缓存的数据来说,2Q会减少一次从原始存储读取数据或者计算数据的操作。
Multi Queue(MQ)
MQ算法根据访问频率将数据划分为多个队列,不同的队列具有不同的访问优先级,
其核心思想是:优先缓存访问次数多的数据。
MQ算法将缓存划分为多个LRU队列,每个队列对应不同的访问优先级。
访问优先级是根据访问次数计算出来的
-
实现
- 新插入的数据放入Q0;
- 每个队列按照LRU管理数据;
- 当数据的访问次数达到一定次数,需要提升优先级时,将数据从当前队列删除,加入到高一级队列的头部;
- 为了***防止高优先级数据永远不被淘汰***,当数据在指定的时间里访问没有被访问时,需要降低优先级
将数据从当前队列删除,加入到低一级的队列头部; - 需要淘汰数据时,从最低一级队列开始按照LRU淘汰;每个队列淘汰数据时,将数据从缓存中删除,
将数据索引加入Q-history头部; - 如果数据在Q-history中被重新访问,则重新计算其优先级,移到目标队列的头部;
- Q-history按照LRU淘汰数据的索引。
-
特点
MQ降低了“缓存污染”带来的问题,命中率比LRU要高。
MQ需要维护多个队列,且需要维护每个数据的访问时间,复杂度比LRU高。
MQ需要记录每个数据的访问时间,需要定时扫描所有队列,代价比LRU要高。
注:虽然MQ的队列看起来数量比较多,但由于所有队列之和受限于缓存容量的大小,因此
这里多个队列长度之和和一个LRU队列是一样的,因此队列扫描性能也相近。
LFU
(least frequently used )是最近最不常用置换算法。
思想是“如果一个数据在最近一段时间内使用次数很少,那么在将来一段时间内被使用的可能性也很小”。
- 实现
- 用一个数组存储缓存数据,用map存储每个数据项的缓存命中次数。
- 达到最大缓存数量时,淘汰访问频次最少的数据。
也可以用两个map来存取数据。
ARC
自适应缓存替换算法(ARC)
在IBM Almaden研究中心开发,这个缓存算法同时跟踪记录LFU和LRU,以及驱逐缓存条目,
来获得可用缓存的最佳使用。
LFU和LRU的缓存数的总大小是固定的,ARC使它自己自适应于工作负载。
如果工作负载趋向于访问最近访问过的文件,将会有更多的命中发生在LRU 中,
也就是说这样会增加LRU的缓存空间。
反过来一样,如果工作负载趋向于访问最近频繁访问的文件,更多的命中将会发生在LFU中,
这样LFU的缓存空间将会增大。
ZFS ARC
在Solaris ZFS 中实现的ARC(Adjustable Replacement Cache)读缓存淘汰算法。
MRU
最近最常使用算法(MRU)
这个缓存算法最先移除最近最常使用的条目。
一个MRU算法擅长处理一个条目越久,越容易被访问的情况。
常见缓存速度
CPU寄存器 > CPU高速缓存Cache > 计算机主存(内存)> 硬盘
缓存高可用
业界有两种理论,第一套缓存就是缓存,临时存储数据的,不需要高可用。
第二种缓存逐步演化为重要的存储介质,需要做高可用。
- 实现
缓存的高可用,一般通过分布式和复制实现。
分布式实现数据的海量缓存,复制实现缓存数据节点的高可用。
分布式采用一致性Hash算法,复制采用异步复制或复制双写(同时向两个缓存节点写入数据
,只有两份都写成功,才算成功)。
文章参考
http://www.cnblogs.com/mushroom/p/4199701.html
http://flychao88.iteye.com/blog/1977653