Caching(缓存)在现代的计算机系统中是一项最古老最基本的技术。它存在于计算机各类硬件和软件系统中,好比各类CPU, 存储系统(IBM ESS, EMC Symmetrix…),数据库,Web服务器,中间件等。它的一个重要的做用就是用于弥补不一样速度的硬件之间的存取速度的差距,cache能够彻底经过硬件实现(算法也是经过硬件实现的),也能够经过在更快硬件上经过软件控制来实现。html
EMC Symmetrix之因此如此的昂贵,就是由于在这个系统中,提供了一个640G全相连的高速数据缓存(DRAM缓存),彻底用硬件实现,就像一个放大版的CPU一级缓存。算法
Caching技术对于现代计算机系统之因此如此重要,就是在于,任何一个小的改进都会对整个计算机系统产生巨大的影响。由于cache具有一个特性,用最高的性价比能够实现咱们但愿获得的系统总体性能。数据库
好比,磁盘和内存相比,磁盘具备大容量的特性,而内存具备高性能,可是对于同等容量,磁盘相比于内存来讲很是廉价。这也就是咱们不可能把全部磁盘都替换成内存(先不考虑永久存储的特性),即使这样咱们能够得到很是高的I/O速度。那么如何解决这二者之间的不匹配?就是利用缓存技术。利用内存介质为磁盘作一层缓存。这样就能够在很少花额外费用的同时,获得速度和容量的平衡。后端
一样的道理存在于内存和CPU缓存之间。下图是Intel Core i7 5500系列各部件的访问速度:缓存
|
访问速度服务器 |
L1 Cache Hitpost |
4 cycles性能 |
L2 Cache hitfetch |
10 cycles优化 |
L3 Cache hit, line unshared |
40 cycles |
L3 Cache hit, shared line in another core |
65 cycles |
L3 Cache hit, modified in another core |
75 cycles |
Remote L3 cache |
100~300 cycles |
Local RAM |
60 ns |
Remote RAM |
100 ns |
从这里咱们能够看到缓存对于系统性能的重要性。可是咱们要达到理想的性能,还必须提升缓存的命中率,这样缓存才能够最大限度的获得利用。
接下来,咱们将会详细地描述缓存算法。而且经过对比,来看看各类算法的优劣。
每当咱们讨论缓存时,老是会对以下几个词比较熟悉,
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当初提出的内容有点改变。这个咱们留在下篇文章中讲述。
在Solaris ZFS 中实现的ARC(Adjustable Replacement Cache)读缓存淘汰算法真是颇有意义的一块软件代码。它是基于IBM的Megiddo和Modha提出的ARC(Adaptive Replacement Cache)淘汰算法演化而来的。可是ZFS的开发者们对IBM 的ARC算法作了一些扩展,以更适用于ZFS的应用场景。ZFS ARC的最先实现展示在FAST 2003的会议上,并在杂志《;Login:》的一篇文章中被详细描述。
注:关于杂志《;Login:》,可参考这个连接:https://www.usenix.org/publications/login/2003-08/index.html
ZFS ARC真是一个优美的设计。在接下来的描述中,我将尽可能简化一些机制,以便于你们更容易理解ZFS ARC的工做原理。关于ZFS ARC的权威描述,能够参考这个连接:http://src.opensolaris.org/source/xref/onnv/onnv-gate/usr/src/uts/common/fs/zfs/arc.c。在接下来的段落中,我将试着给你们深刻讲解一下ZFS 读缓存的内部工做原理。我将关注点放在数据如何进入缓存,缓存如何调整它本身以适应I/O模式的变化,以及“Adjustable Replacement Cache”这个名字是如何来的。
缓存
嗯,在一些文件系统缓存中实现的标准的LRU淘汰算法是有一些缺点的。例如,它们对扫描读模式是没有抵抗性的。但你一次顺序读取大量的数据块时,这些数据块就会填满整个缓存空间,即便它们只是被读一次。当缓存空间满了以后,你若是想向缓存放入新的数据,那些最近最少被使用的页面将会被淘汰出去。在这种大量顺序读的状况下,咱们的缓存将会只包含这些新读的数据,而不是那些真正被常用的数据。在这些顺序读出的数据仅仅只被使用一次的状况下,从缓存的角度来看,它将被这些无用的数据填满。
另一个挑战是:一个缓存能够根据时间进行优化(缓存那些最近使用的页面),也能够根据频率进行优化(缓存那些最频繁使用的页面)。可是这两种方法都不能适应全部的workload。而一个好的缓存设计是能自动根据workload来调整它的优化策略。
ARC的内部工做原理
在ARC原始的实现(IBM的实现)和ZFS中的扩展实现都解决了这些挑战,或者说现存问题。我将描述由Megiddo和Modha提出的Adaptive Replacement Cache的一些基本概念,ZFS的实现版本做为这个实现机制的一个扩展来介绍。这两种实现(原始的Adaptive Replacement Cache和ZFS Adjustable Replacement Cache)共享一些基本的操做原理,因此我认为这种简化是一种用来解释ZFS ARC切实可行的途径。
首先,假设咱们的缓存中有一个固定的页面数量。简单起见,假设咱们有一个8个页面大小的缓存。为了是ARC能够工做,在缓存中,它须要一个2倍大小的管理表。
这个管理表分红4个链表。头两个链表是显而易见的:
· 最近最多使用的页面链表 (LRU list)
· 最近最频繁使用的页面链表(LFU list)
另外两个链表在它们的角色上有些奇怪。它们被称做ghost链表。那些最近被淘汰出去的页面信息被存储在这两个链表中:
· 存储那些最近从最近最多使用链表中淘汰的页面信息 (Ghost list for LRU)
· 存储那些最近从最近最频繁使用链表中淘汰的页面信息(Ghost list for LFU)

这两个ghost链表不储存数据(仅仅储存页面信息,好比offset,dev-id),可是在它们之中的命中对ARC缓存工做的行为具备重要的影响,我将在后面介绍。那么在缓存中都发生了什么呢?
假设咱们从磁盘上读取一个页面,并把它放入cache中。这个页面会放入LRU 链表中。

接下来咱们读取另一个不一样的页面。它也会被放入缓存。显然,他也会被放入LRU 链表的最近最多使用的位置(位置1):

好,如今咱们再读一次第一个页面。咱们能够看到,这个页面在缓存中将会被移到LFU链表中。全部进入LRU链表中的页面都必须至少被访问两次。不管何时,一个已经在LFU链表中的页面被再次访问,它都会被放到LFU链表的开始位置(most frequently used)。这么作,那些真正被频繁访问的页面将永远呆在缓存中,不常常访问的页面会向链表尾部移动,最终被淘汰出去。

随着时间的推移,这两个链表不断的被填充,缓存也相应的被填充。这时,缓存已经满了,而你读进了一个没有被缓存的页面。因此,咱们必须从缓存中淘汰一个页面,为这个新的数据页提供位置。这个数据页可能刚刚才被从缓存中淘汰出去,也就是说它不被缓存中任何的非ghost链表引用着。
假设LRU链表已经满了:

这时在LRU链表中,最近最少使用的页面将会被淘汰出去。这个页面的信息会被放进LRU ghost链表中。

如今这个被淘汰的页面再也不被缓存引用,因此咱们能够把这个数据页的数据释放掉。新的数据页将会被缓存表引用。

随着更多的页面被淘汰,这个在LRU ghost中的页面信息也会向ghost链表尾部移动。在随后的一个时间点,这个被淘汰页面的信息也会到达链表尾部,LRU链表的下一次的淘汰过程发生以后,这个页面信息也会从LRU ghost链表中移除,那是就再也没有任何对它的引用了。
好的,若是这个页面在被从LRU ghost链表中移除以前,被再一次访问了,将会发生什么?这样的一个读将会引发一次幽灵(phantom)命中。因为这个页面的数据已经从缓存中移除了,因此系统仍是必须从后端存储媒介中再读一次,可是因为这个幽灵命中,系统知道,这是一个刚刚淘汰的页面,而不是第一次读取或者说好久以前读取的一个页面。ARC用这个信息来调整它本身,以适应当前的I/O模式(workload)。

很显然,这个迹象说明咱们的LRU缓存过小了。在这种状况下,LRU链表的长度将会被增长一。显然,LFU链表的长度将会被减一。

可是一样的机制存在于LFU这边。若是一次命中发生在LFU ghost 链表中,它会减小LRU链表的长度(减一),以此在LFU 链表中加一个可用空间。
利用这种行为,ARC使它本身自适应于工做负载。若是工做负载趋向于访问最近访问过的文件,将会有更多的命中发生在LRU Ghost链表中,也就是说这样会增长LRU的缓存空间。反过来同样,若是工做负载趋向于访问最近频繁访问的文件,更多的命中将会发生在LFU Ghost链表中,这样LFU的缓存空间将会增大。
进一步,这种行为开启了一个灵活的特性:假设你为处理log文件而读取了大量的文件。你只须要每一个文件一次。一个LRU 缓存将会把全部的数据缓存住,这样也就把常常访问的数据也淘汰出去了。可是因为你仅仅访问这些文件一次,它们不会为你带来任何价值一旦它们填满了缓存。
一个ARC缓存的行为是不一样的。显然这样的工做负载仅仅会很快填满LRU链表空间,而这些页面很快就会被淘汰出去。可是因为每一个这样的页面仅仅被访问一次,它们基本不太可能在为最近访问的文件而设计的ghost链表中命中。这样,LRU的缓存空间不会由于这些仅读一次的页面而增长。
假如你把这些log文件与一个大的数据块联系在一块儿(为了简单起见,咱们假设这个数据块没有本身的缓存机制)。数据文件中的数据页应该会被频繁的访问。被LFU ghost链表引用的正在被访问的页面就颇有可能大大的高于LRU ghost链表。这样,常常被访问的数据库页面的缓存空间就会增长。最终,咱们的缓存机制就会向缓存数据块页面优化,而不是用log文件来污染咱们的缓存空间。
Solaris ZFS ARC的改动(相对于IBM ARC)
如我前面所说,ZFS实现的ARC和IBM提出的ARC淘汰算法并非彻底一致的。在某些方面,它作了一些扩展:
· ZFS ARC是一个缓存容量可变的缓存算法,它的容量能够根据系统可用内存的状态进行调整。当系统内存比较充裕的时候,它的容量能够自动增长。当系统内存比较紧张(其它事情须要内存)的时候,它的容量能够自动减小。
· ZFS ARC能够同时支持多种块大小。原始的实现假设全部的块都是相同大小的。
· ZFS ARC容许把一些页面锁住,以使它们不会被淘汰。这个特性能够防止缓存淘汰一些正在使用的页面。原始的设计没有这个特性,因此在ZFS ARC中,选择淘汰页面的算法要更复杂些。它通常选择淘汰最旧的可淘汰页面。
有一些其它的变动,可是我把它们留在对arc.c这个源文件讲解的演讲中。
L2ARC
L2ARC保持着上面几个段落中没涉及到的一个模型。ARC并不自动地把那些淘汰的页面移进L2ARC,而是真正淘汰它们。虽然把淘汰页面自动放入L2ARC是一个看起来正确的逻辑,可是这却会带来十分严重负面影响。首先,一个突发的顺序读会覆盖掉L2ARC缓存中的不少的页面,以致于这样的一次突发顺序读会短期内淘汰不少L2ARC中的页面。这是咱们不指望的动做。
另外一个问题是:让咱们假设一下,你的应用须要大量的堆内存。这种更改过的Solaris ARC可以调整它本身的容量以提供更多的可用内存。当你的应用程序申请内存时,ARC缓存容量必须 变得愈来愈小。你必须当即淘汰大量的内存页面。若是每一个页面被淘汰的页面都写入L2ARC,这将会增长大量的延时直到你的系统可以提供更多的内存,由于你必须等待全部淘汰页面在被淘汰以前写入L2ARC。
L2ARC机制用另外一种稍微不一样的手段来处理这个问题:有一个叫l2arc_feed_thread会遍历那些很快就会被淘汰的页面(LRU和LFU链表的末尾一些页面),并把它们写入一个8M的buffer中。从这里开始,另外一个线程write_hand会在一个写操做中把它们写入L2ARC。
这个算法有如下一些好处:释放内存的延时不会由于淘汰页面而增长。在一次突发的顺序读而引发了大量淘汰页面的状况下,这些数据块会被淘汰出去在l2arc——feed_thread遍历到那两个链表结尾以前。因此L2ARC被这种突发读污染的概率会减小(虽然不能彻底的避免被污染)。
结论
Adjustable Replacement Cache的设计比普通的LRU缓存设计有效不少。Megiddo和 Modha用它们的Adaptive Replacement Cache得出了更好的命中率。ZFS ARC利用了它们的基本操做理论,因此命中率的好处应该与原始设计差很少。更重要的是:若是这个缓存算法帮助它们得出更好的命中率时,用SSD作大缓存的想法就变得更加切实可行。
想了解更多?
1. The theory of ARC operation in One Up on LRU, written by Megiddo and Modha, IBM Almanden Research Center
2. ZFS ARC源代码:http://src.opensolaris.org/source/xref/onnv/onnv-gate/usr/src/uts/common/fs/zfs/arc.c