【转】缓存、缓存算法和缓存框架简介

引言

我们都听过 cache,当你问他们是什么是缓存的时候,他们会给你一个完美的答案,可是他们不知道缓存是怎么构建的,或者没有告诉你应该采用什么标准去选择缓存框架。在这边文章,我们会去讨论缓存,缓存算法,缓存框架以及哪个缓存框架会更好。

面试

“缓存就是存贮数据(使用频繁的数据)的临时地方,因为取原始数据的代价太大了,所以我可以取得快一些。”

这就是 programmer one (programmer one 是一个面试者)在面试中的回答(一个月前,他向公司提交了简历,想要应聘要求在缓存,缓存框架,大规模数据操作有着丰富经验的 java 开发职位)。

programmer one 通过 hash table 实现了他自己的缓存,但是他知道的只是他的缓存和他那存储着150条记录的 hash table,这就是他认为的大规模数据(缓存 = hashtable,只需要在 hash table 查找就好了),所以,让我们来看看面试的过程吧。

面试官:你选择的缓存方案,是基于什么标准的?

programmer one:呃,(想了5分钟)嗯,基于,基于,基于数据(咳嗽……)

面试官:excese me ! 能不能重复一下?

programmer one:数据?!

面试官:好的。说说几种缓存算法以及它们的作用

programmer one:(凝视着面试官,脸上露出了很奇怪的表情,没有人知道原来人类可以做出这种表情  )

面试官:好吧,那我换个说法,当缓存达到容量时,会怎么做?

programmer one:容量?嗯(思考……hash table 的容量时没有限制的,我能任意增加条目,它会自动扩充容量的)(这是 programmer one 的想法,但是他没有说出来)

面试官对 programmer one 表示感谢(面试过程持续了10分钟),之后一个女士走过来说:谢谢你的时间,我们会给你打电话的,祝你好心情。这是 programmer one 最糟糕的面试(他没有看到招聘对求职者有丰富的缓存经验背景要求,实际上,他只看到了丰厚的报酬  )。

说到做到

programmer one 离开之后,他想要知道这个面试者说的问题和答案,所以他上网去查,programmer one 对缓存一无所知,除了:当我需要缓存的时候,我就会用 hash table。

在他使用了他最爱的搜索引擎搜索之后,他找到了一篇很不错的关于缓存文章,并且开始去阅读……

为什么我们需要缓存?

很久很久以前,在还没有缓存的时候……用户经常是去请求一个对象,而这个对象是从数据库去取,然后,这个对象变得越来越大,这个用户每次的请求时间也越来越长了,这也把数据库弄得很痛苦,他无时不刻不在工作。所以,这个事情就把用户和数据库弄得很生气,接着就有可能发生下面两件事情:

1.用户很烦,在抱怨,甚至不去用这个应用了(这是大多数情况下都会发生的)

2.数据库为打包回家,离开这个应用,然后,就出现了大麻烦(没地方去存储数据了)(发生在极少数情况下)

上帝派来了缓存

在几年之后,IBM(60年代)的研究人员引进了一个新概念,它叫“缓存”。

什么是缓存?

正如开篇所讲,缓存是“存贮数据(使用频繁的数据)的临时地方,因为取原始数据的代价太大了,所以我可以取得快一些。”

缓存可以认为是数据的池,这些数据是从数据库里的真实数据复制出来的,并且为了能别取回,被标上了标签(键 ID)。太棒了

programmer one 已经知道这点了,但是他还不知道下面的缓存术语。

缓存、缓存算法和缓存框架简介

命中:

当客户发起一个请求(我们说他想要查看一个产品信息),我们的应用接受这个请求,并且如果是在第一次检查缓存的时候,需要去数据库读取产品信息。

如果在缓存中,一个条目通过一个标记被找到了,这个条目就会被使用、我们就叫它缓存命中。所以,命中率也就不难理解了。

Cache Miss:

但是这里需要注意两点:

1. 如果还有缓存的空间,那么,没有命中的对象会被存储到缓存中来。

2. 如果缓存慢了,而又没有命中缓存,那么就会按照某一种策略,把缓存中的旧对象踢出,而把新的对象加入缓存池。而这些策略统称为替代策略(缓存算法),这些策略会决定到底应该提出哪些对象。

存储成本:

当没有命中时,我们会从数据库取出数据,然后放入缓存。而把这个数据放入缓存所需要的时间和空间,就是存储成本。

索引成本:

和存储成本相仿。

失效:

当存在缓存中的数据需要更新时,就意味着缓存中的这个数据失效了。

替代策略:

当缓存没有命中时,并且缓存容量已经满了,就需要在缓存中踢出一个老的条目,加入一条新的条目,而到底应该踢出什么条目,就由替代策略决定。

最优替代策略:

最优的替代策略就是想把缓存中最没用的条目给踢出去,但是未来是不能够被预知的,所以这种策略是不可能实现的。但是有很多策略,都是朝着这个目前去努力。

Java 街恶梦:

当 programmer one 在读这篇文章的时候,他睡着了,并且做了个恶梦(每个人都有做恶梦的时候)。

programmer one:nihahha,我要把你弄失效!(疯狂的状态)

缓存对象:别别,让我活着,他们还需要我,我还有孩子。

programmer one:每个缓存对象在失效之前都会那样说。你从什么时候开始有孩子的?不用担心,现在就永远消失吧!

哈哈哈哈哈……programmer one 恐怖的笑着,但是警笛打破了沉静,警察把 programmer one 抓了起来,并且控告他杀死了(失效)一个仍需被使用的缓存对象,他被押到了监狱。

programmer one 突然醒了,他被吓到了,浑身是汗,他开始环顾四周,发现这确实是个梦,然后赶紧继续阅读这篇文章,努力的消除自己的恐慌。

在programmer one 醒来之后,他又开始阅读文章了。

缓存算法

没有人能说清哪种缓存算法优于其他的缓存算法

Least Frequently Used(LFU):

大家好,我是 LFU,我会计算为每个缓存对象计算他们被使用的频率。我会把最不常用的缓存对象踢走。

Least Recently User(LRU):

我是 LRU 缓存算法,我把最近最少使用的缓存对象给踢走。

我总是需要去了解在什么时候,用了哪个缓存对象。如果有人想要了解我为什么总能把最近最少使用的对象踢掉,是非常困难的。

浏览器就是使用了我(LRU)作为缓存算法。新的对象会被放在缓存的顶部,当缓存达到了容量极限,我会把底部的对象踢走,而技巧就是:我会把最新被访问的缓存对象,放到缓存池的顶部。

所以,经常被读取的缓存对象就会一直呆在缓存池中。有两种方法可以实现我,array 或者是 linked list。

我的速度很快,我也可以被数据访问模式适配。我有一个大家庭,他们都可以完善我,甚至做的比我更好(我确实有时会嫉妒,但是没关系)。我家庭的一些成员包括 LRU2 和 2Q,他们就是为了完善 LRU 而存在的。

Least Recently Used 2(LRU2):

我是 Least Recently Used 2,有人叫我最近最少使用 twice,我更喜欢这个叫法。我会把被两次访问过的对象放入缓存池,当缓存池满了之后,我会把有两次最少使用的缓存对象踢走。因为需要跟踪对象2次,访问负载就会随着缓存池的增加而增加。如果把我用在大容量的缓存池中,就会有问题。另外,我还需要跟踪那么不在缓存的对象,因为他们还没有被第二次读取。我比LRU好,而且是 adoptive to access 模式 。

Two Queues(2Q):

我是 Two Queues;我把被访问的数据放到 LRU 的缓存中,如果这个对象再一次被访问,我就把他转移到第二个、更大的 LRU 缓存。

我踢走缓存对象是为了保持第一个缓存池是第二个缓存池的1/3。当缓存的访问负载是固定的时候,把 LRU 换成 LRU2,就比增加缓存的容量更好。这种机制使得我比 LRU2 更好,我也是 LRU 家族中的一员,而且是 adoptive to access 模式 。

Adaptive Replacement Cache(ARC):

我是 ARC,有人说我是介于 LRU 和 LFU 之间,为了提高效果,我是由2个 LRU 组成,第一个,也就是 L1,包含的条目是最近只被使用过一次的,而第二个 LRU,也就是 L2,包含的是最近被使用过两次的条目。因此, L1 放的是新的对象,而 L2 放的是常用的对象。所以,别人才会认为我是介于 LRU 和 LFU 之间的,不过没关系,我不介意。

我被认为是性能最好的缓存算法之一,能够自调,并且是低负载的。我也保存着历史对象,这样,我就可以记住那些被移除的对象,同时,也让我可以看到被移除的对象是否可以留下,取而代之的是踢走别的对象。我的记忆力很差,但是我很快,适用性也强。

Most Recently Used(MRU):

我是 MRU,和 LRU 是对应的。我会移除最近最多被使用的对象,你一定会问我为什么。好吧,让我告诉你,当一次访问过来的时候,有些事情是无法预测的,并且在缓存系统中找出最少最近使用的对象是一项时间复杂度非常高的运算,这就是为什么我是最好的选择。

我是数据库内存缓存中是多么的常见!每当一次缓存记录的使用,我会把它放到栈的顶端。当栈满了的时候,你猜怎么着?我会把栈顶的对象给换成新进来的对象!

First in First out(FIFO):

我是先进先出,我是一个低负载的算法,并且对缓存对象的管理要求不高。我通过一个队列去跟踪所有的缓存对象,最近最常用的缓存对象放在后面,而更早的缓存对象放在前面,当缓存容量满时,排在前面的缓存对象会被踢走,然后把新的缓存对象加进去。我很快,但是我并不适用。

Second Chance:

大家好,我是 second chance,我是通过 FIFO 修改而来的,被大家叫做 second chance 缓存算法,我比 FIFO 好的地方是我改善了 FIFO 的成本。我是 FIFO 一样也是在观察队列的前端,但是很FIFO的立刻踢出不同,我会检查即将要被踢出的对象有没有之前被使用过的标志(1一个 bit 表示),没有没有被使用过,我就把他踢出;否则,我会把这个标志位清除,然后把这个缓存对象当做新增缓存对象加入队列。你可以想象就这就像一个环队列。当我再一次在队头碰到这个对象时,由于他已经没有这个标志位了,所以我立刻就把他踢开了。我在速度上比 FIFO 快。

CLock:

我是 Clock,一个更好的 FIFO,也比 second chance 更好。因为我不会像 second chance 那样把有标志的缓存对象放到队列的尾部,但是也可以达到 second chance 的效果。

我持有一个装有缓存对象的环形列表,头指针指向列表中最老的缓存对象。当缓存 miss 发生并且没有新的缓存空间时,我会问问指针指向的缓存对象的标志位去决定我应该怎么做。如果标志是0,我会直接用新的缓存对象替代这个缓存对象;如果标志位是1,我会把头指针递增,然后重复这个过程,知道新的缓存对象能够被放入。我比 second chance 更快。

Simple time-based:

我是 simple time-based 缓存算法,我通过绝对的时间周期去失效那些缓存对象。对于新增的对象,我会保存特定的时间。我很快,但是我并不适用。

Extended time-based expiration:

我是 extended time-based expiration 缓存算法,我是通过相对时间去失效缓存对象的;对于新增的缓存对象,我会保存特定的时间,比如是每5分钟,每天的12点。

Sliding time-based expiration:

我是 sliding time-based expiration,与前面不同的是,被我管理的缓存对象的生命起点是在这个缓存的最后被访问时间算起的。我很快,但是我也不太适用。

其他的缓存算法还考虑到了下面几点:

成本:如果缓存对象有不同的成本,应该把那些难以获得的对象保存下来。

容量:如果缓存对象有不同的大小,应该把那些大的缓存对象清除,这样就可以让更多的小缓存对象进来了。

时间:一些缓存还保存着缓存的过期时间。电脑会失效他们,因为他们已经过期了。

根据缓存对象的大小而不管其他的缓存算法可能是有必要的。

电子邮件!

在读完这篇文章之后,programmer one 想了一会儿,然后决定给作者发封邮件,他感觉作者的名字在哪听过,但是已经想不起来了。不管怎样,他还是把邮件发送出来了,他询问了作者在分布式环境中,缓存是怎么样工作的。

文章的作者收到了邮件,具有讽刺意味的是,这个作者就是面试 programmer one 的人  ,作者回复了……

在这一部分中,我们来看看如何实现这些著名的缓存算法。以下的代码只是示例用的,如果你想自己实现缓存算法,可能自己还得加上一些额外的工作。

LeftOver 机制

在 programmer one 阅读了文章之后,他接着看了文章的评论,其中有一篇评论提到了 leftover 机制——random cache。

Random Cache

我是随机缓存,我随意的替换缓存实体,没人敢抱怨。你可以说那个被替换的实体很倒霉。通过这些行为,我随意的去处缓存实体。我比 FIFO 机制好,在某些情况下,我甚至比 LRU 好,但是,通常LRU都会比我好。

 

原文地址: http://blog.jobbole.com/30940/

 


 

参考:http://blog.chinaunix.net/uid-28466562-id-3834169.html

每当我们讨论缓存时,总是会对如下几个词比较熟悉,

Write-back,  write-through, write-around

似乎,缓存主要是为“写”设计的,其实这是错误的理解,写从缓存中获得的好处是非常有限的,缓存主要是为“读”服务的。

之所以我们要顺带提一下,在一个缓存系统中,如何处理写的顺序,是因为,在写的过程中,需要动态的更新缓存(否则就会产生数据不一致性的问题),以及后端主存。这三个词都是用来表示如何处理写更新的。就是用什么方式来处理写。

在一个有缓存的层次结构中,如何理解缓存是为“读”服务的?这涉及到读请求的处理序列。对于每一个读请求,我们都会用如下的操作序列去处理它:

1.      在缓存中查找请求对应的数据

a.      如果找到,则直接返回给客户

b.      如果没找到,则把请求的数据读入缓存(更新缓存),然后把数据返回给客户

既然缓存主要是为读服务的(后面的文章,我们会讨论,用什么方式来改善写的性能),那么为了提高读的性能,或者说减少读的响应时间,我们就要提高缓存的命中率,减少缓存的miss 率。这也是我们缓存算法设计的目标。

那么我们来想想,在设计缓存时,我们应该从哪几方面考虑来达到这个缓存的设计目标呢?根据我们上面提到的读请求的操作序列,我们可以从如下几个方面来思考:

1.      我们应该尽量多的用有用的数据填满缓存。也就是说,我们要充分利用缓存。

a.      这是缓存模块和其它模块不同的地方,并不是说缓存中的数据越少越好,而是有用的数据越多越好。

b.      这里有个非常好的列子,就是Windows的内存占用率总是非常高,很多人都表示过不满。其实这是一个非常好的设计。Windows总是试图尽量利用那些空闲的内存,用来缓存磁盘上的数据,以此来提高系统的整体性能。否则你那么大的内存,就为了拿来好看?

2.      如何获取“有用”的数据。这里,“有用”的数据的定义就是可能在不久的将来会被client用到的数据。为了得到有用的数据,我们需要预估客户端应用的I/O 模式,比如顺序读写,随机读写等等。这里就涉及到了“pre-fetch”算法。

a.      Pre-fetch(预取算法):是一种预测客户端应用下次读写的数据在哪里的算法,并且把客户要用的数据提前放入缓存中,以此来提高读的响应速度。

3.      问题来了,如果缓存已经满了,那么如何存放新的需要缓存的单元呢?这就牵涉到缓存设计的另一端:淘汰算法。

a.      相比于pre-fetch,淘汰算法(replacement policy)更加重要。因为对于随机的I/O, 预取算法是无能为力的。

b.      淘汰算法的关键是如何判断一个单元的数据比另一个单元的数据更加重要。但需要淘汰一个数据单元时,丢弃掉最不重要的那个数据单元,并且用它来存放新的数据。

4.      缓存算法设计的另外一个重要的考虑因素是算法的复杂度。或者说是实现或运行算法带来的额外开销。我们希望算法容易实现,并且额外开销不随着缓存大小的改变而改变。包括容量的额外开销和计算的额外开销。

接下来的文章,我们会详细讨论预取算法和淘汰算法。

从前面的文章中,我们已经了解到了缓存设计的目标,缓存设计应该考虑的因素。今天我们来看看一系列缓存算法以及它们如何去解决问题的。同时,我们也会涉及到各种缓存算法的优缺点。

这里我并不想讨论与预取(pre-fetch)相关的算法,主要是考虑各种淘汰算法。因为相比于预取算法,淘汰算法具有更大的通用性,对缓存好坏影响更大。

1.      时间(完全从最近使用的时间角度考虑)

a.      LRU(least recently used):这种策略就是永远替换掉最近最少使用的缓存单元。它是最古老,应用最广泛的的一种淘汰算法。它的致命的缺陷就是没有考虑缓存单元的使用频率,在某些I/O 模式中,会把一些有价值的缓存单元替换出去。比如,假设我们有10个缓存单元,客户端应用来了一次顺序读写,这样可能把这10个现有的缓存单元替换出去,而把这次顺序读写的数据缓存起来。但是,这种顺序读写的数据在以后都不会被再次用到。反而,那些因为顺序读而被替换出去的缓存单元却是更有价值的。为此,有了各种各样的基于LRU的优化策略被提出来。

2.      频率(完全从使用频率的角度考虑)

a.      LFU(least frequently used): IRM(独立的引用模型)提供了一种用来获取频率的负载特性。它趋向于淘汰最近使用频率最少的缓存单元。这种策略的弊端是:

                                                  i.      它的实现复杂度于缓存大小成对数关系(logarithmic);

                                                ii.      对最近的缓存单元的访问情况基本没考虑;

                                              iii.      对访问模式的改变基本上没有应变的策略。

3.      LRU-2(LRU-K):一种对LRU的改进型策略 (频率)

a.      LRU-2于LFU很相似,如果我们不考虑它对缓存单元引用频率进化分布的自适应性。它的基本思想是对每一个缓存单元,记住最近两次访问的时间。总是淘汰最近两次时间间隔最长的缓存单元。在IRM的假设下,对于任何知道最多两次最近引用缓存单元的在线算法,我们可以得出LRU-2具有最高的命中率。

b.      但是LRU-2也有一些实际的限制:

                                                  i.      它需要维护一个优先级队列。也就是说它具有对数的实现复杂度;

                                                ii.      它需要一个可调参数:CIP(correlated information period)。

c.       在现实中,对数的实现复杂度是一个非常严重的overhead(负担)。所以另外一个策略2Q被提了出来。

4.      2Q:对LRU-2的改进策略 (频率)

a.      相对于LRU-2,2Q的主要改进是用一个简单的LRU list取代了LRU-2中的优先级队列。其它的2Q和LRU-2基本相同。

b.      但是在2Q中,LRU-2的第二个不足还是存在,并且更严重了。因为它需要两个可调参数:Kin和Kout

c.       为什么可调参数一个很严重的限制?这是我们在实施一个系统时,必须确定这些参数,而且不可更改。一旦确定了一组参数,这个缓存系统往往只能对某一类workload表现很好。也就是这种缓存系统缺少了自适应性。

5.      LIRS(Low Inter-reference Recency Set)(频率)

a.      详细描述参考:“LIRS: An efficient low inter-reference recency set replacement policy to improve buffer cache performance”

b.      第一个不足在于需要两个可调参数Llirs 和Lhirs 

c.       它的第二个缺点在于,在最坏的情况下,它需要一个“栈修剪”。这个操作需要遍历数量庞大的缓存单元。

6.      时间和频率(同时考虑时间和频率的算法:LRU和LFU)

a.      FBR(Frequency-based replacement):详细描述请参考“Data cache management using frequency-based replacement”。这个算法的不足之处在于:

                                                  i.      需要可调参数:缓存中三块的大小,Cmax 和Amax:大小调整的时间周期。

                                                ii.      Cache pollution(解决cache污染的机制)

b.      LRFU(Least Recently/Frequently Used): 参考“LRFU: A spectrum of policies that subsumes the least recently used and least frequently used policies”

c.       ALRFU(adaptive LRFU): 参考“On the existence of a spectrum of policies that subsumes the least recently used and least frequently used policies”

7.      临时距离分布(Temporal distance distribution)

a.      MQ(multi-queue replacement policy MQ ): 参考“The multi-queue replacement algorithm for second level buffer caches”

8.      ARC: adaptive replacement cache(IBM), adjusted replacement cache(ZFS)

a.      一种自适应,低成本的淘汰算法

b.      它集合了LRU和LFU的优点,并且没有额外的使用和实现成本。

c.       它可以更具workload的改变而自动的改变淘汰策略。

ARC是目前应用非常广泛的一种淘汰算法。我们应该详细的研究它,并实现它。在ZFS源码中就是它的完整实现。当然,ZFS中的实现和IBM当初提出的内容有点改变。这个我们留在下篇文章中讲述。

posted @ 2015-11-10 17:14  L_free  阅读(471)  评论(0编辑  收藏  举报