闲话Cache:始篇
Caching(缓存)在现代的计算机系统中是一项最古老最基本的技术。它存在于计算机各种硬件和软件系统中,比如各种CPU, 存储系统(IBM ESS, EMC Symmetrix…),数据库,Web服务器,中间件等。它的一个重要的作用就是用于弥补不同速度的硬件之间的存取速度的差距,cache可以完全通过硬件实现(算法也是通过硬件实现的),也可以通过在更快硬件上通过软件控制来实现。
EMC Symmetrix之所以如此的昂贵,就是因为在这个系统中,提供了一个640G全相连的高速数据缓存(DRAM缓存),完全用硬件实现,就像一个放大版的CPU一级缓存。
Caching技术对于现代计算机系统之所以如此重要,就是在于,任何一个小的改进都会对整个计算机系统产生巨大的影响。因为cache具备一个特性,用最高的性价比可以实现我们希望得到的系统整体性能。
比如,磁盘和内存相比,磁盘具有大容量的特性,而内存具有高性能,但是对于同等容量,磁盘相比于内存来说非常廉价。这也就是我们不可能把所有磁盘都替换成内存(先不考虑永久存储的特性),即便这样我们可以获得非常高的I/O速度。那么如何解决这两者之间的不匹配?就是利用缓存技术。利用内存介质为磁盘做一层缓存。这样就可以在不多花额外费用的同时,得到速度和容量的平衡。
同样的道理存在于内存和CPU缓存之间。下图是Intel Core i7 5500系列各部件的访问速度:
访问速度 |
|
L1 Cache Hit |
4 cycles |
L2 Cache hit |
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当初提出的内容有点改变。这个我们留在下篇文章中讲述。
闲话缓存:ZFS 读缓存深入研究-ARC(一)
在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文件来污染我们的缓存空间。
闲话缓存:ZFS 读缓存深入研究-ARC(二)
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