作为一个需要应对海量内容数据传输的大型互联网公司,需要一个自己的CDN网络,而这个网络现在需要一个综合考虑I/O响应速度、支持高并发、适合SATA\SSD存储特点的Cache替换算法,提高现有的缓存命中率。
启发我做这件事的是一篇FaceBook实习生工作期间写的论文《An Analysis of Facebook Photo Caching》,文章透露了一些FB的数据和工作算法的细节,里面重点部分是,将几个Cache替换算法在FB的应用场景下的表现,做对比。这几个算法是:FIFO, LRU, LFU, S4LRU,以及仅作为比较的“预言家”算法,无穷储存算法。
在我极力推崇接下来要介绍的“新”算法前,我们先介绍一下上述实验用的算法(它们也被我用于实验比较)。
☆FIFO: 这是FB,也是我们目前正在使用的算法,先进先出队列,在需要添加访问未命中的数据时,淘汰最先进队列的数 据。 优点:顺序的存储可以极大化使用IO优化算法;不会产生磁盘碎片; 缺点:没有利用数据的热度区分对待,命中率低。
☆LRU: 维护一个按访问次数排序的优先队列(小顶堆),每次需要淘汰时淘汰队列头部(访问次数最少的)数据。
优点:用上了热度信息,命中率有提高。
缺点:堆内部计数的修改并调整堆,二项堆无法做到,用红黑树可以解决,算法复杂度高/并行时加大锁;随机的淘汰 会造成过多的磁盘碎片。
☆LFU: 维护一个先按访问次数再按访问时间排序的优先队列。
优点:用上更多热度信息,命中率更高。
缺点:实现同LRU,缺点同LRU。
☆S4LRU: 文章的主要创新点,一个新的业界没使用的方法,文章的浓墨重笔也在这个算法的实验效果上。 实现方法是,将储存空间分为四段,称为cache结构的四层,每一层是独立的一个LRU,新的对象到来时加入到 第一层的队尾,对象命中一次,移动到下一层的队尾处,对于已经在顶层的对象,则移动到本层的队尾;需要 淘汰时,第i层的淘汰,是将队首对象,移动到第i-1层的队尾处,对于已经在第0层的对象,淘汰时则相当于 移出了整个磁盘。
优点: 一个队列的LRU只有队首\尾的机制利用了热度信息,四层的LRU还用所在层数区别热度,实验也证明命中率确实 高。
缺点: 同LRU,形成很多磁盘碎片,如果不使用碎片,磁盘空间会减少,使用了碎片,IO读写不连续,效率低。
☆4LazyQueue 维护4路的循环队列,每次对象访问命中时,将该对象的引用计数器+1,不进行其他操作。cache miss时, 如果第0层队列已经填满,这时候需要淘汰已有对象,将第0层队首淘汰,此时判断,如果该对象的访问计数 达到了更高层次的”准入阈值“,则将该对象移动至对应层次,如果该层次已满,递归重复本淘汰过程。为了 避免死循环,高层次降到低层次时,需要修改访问计数以保证它暂时不可能再被提高回高层次。
优点:最终算法的上一版本,基本已经解决了所有其他算法会出现的问题
缺点:4层储存的设计,最高层是访问热度最高的对象聚集,导致该层物理载体的访问频率明显高于其他层, 磁盘寿命和IO吞吐能力有很大影响。
★预言家算法:
可以计算理论上cache命中率的最高。实际中不能使用的算法。需要知道既有的访问序列,从而得到每次访问对象
的下次访问时间,使得每次淘汰时选到下次访问时间最远的对象。实现时需要一个hash表和一个红黑树。
★无穷储存: 每个对象只在第一次访问会cache miss,由于有无穷的储存空间,之后的访问都会hit,用于表示cache命中率 的极限。
==================================
实验准备:
CDN网络cn184节点,20140402-20140406五天接收到的所有请求,共计7147847694条(71.47亿)。
实验结果:
☆ FIFO
Hit: 5332866362
Miss: 1327879454
Total: 6660745816
FullIt: 487101877
The hit rate is 0.800641
☆ S2LazyQueue
Hit: 5626117699
Miss: 1308266609
Total: 6934384308
The hit rate is 0.811336
☆ LRU
Hit: 5480958763
Miss: 1179787053
Total: 6660745816
FullIt: 487101878
The hit rate is 0.822875
☆ 4LazyQueue
Hit: 5634973350
Miss: 1187732644
Total: 6822705994
FullIt: 325141699
The hit rate is 0.825915
☆ S4LRU
Hit: 5568470689
Miss: 1131903166
Total: 6700373855
FullIt: 447473839
The hit rate is 0.831069
实验是模拟3T储存空间的CDN节点,离线对71亿条访问处理。 为了保证跟线上测试的效果接近,在第一圈写满磁盘前,不统计命中率,实验表明在大约4亿多条请求后, 磁盘写满(FullIt值)。
下面要介绍的算法,如果要介绍优点,可以直接把上述算法所有的缺点复制粘贴过来,作为它所克服的。 先看一下它的实验表现:
☆ S2LazyLRU
/* Amazing hit ratio!
Hit: 4976941954
Miss: 970005690
Total: 5946947644
The hit rate is 0.836890
*/
比IO性能同样优异的FIFO提高了3.62%的命中率,比极致追求命中率而缺点重重的S4LRU高了0.58%的命中率。
接下来描述这个算法,说明其I/O的优越性,并行环境下优越性,并给出线性复杂度证明。
S2LazyQueue + LRU 2路懒LRU算法
两路硬盘储存结构,第一路是SATA,第二路是SSD。内存里保存元数据,包括文件的hash值,文件的访问次数,
文件的最近访问时间。
收到文件请求时,在hash表里判断是否命中,命中则在元数据的访问次数上加1.
如果没有命中,则需要触发第一路的淘汰操作。
1. 淘汰时,需要判断被淘汰的对象,如果该对象的最近访问时间到当前时间的间隔对象数,在全部对象数的10%以内,则该对象保留在队列中(置于队尾),递归到步骤1,否则继续判断到步骤2。
2. 继续判断该对象的访问次数,如果是该层访问次数最高的25%个对象,则上升到第二层(SSD层),触发第二层的淘汰操作步骤3;否则,如果是次高的25%个对象,该对象保留在第一层而触发步骤1;再否则,淘汰出局,算法结束。
3. 第二层的淘汰,跟第一层的淘汰策略几乎一致,区别是最高的25%对象,需要提升时,改为保留在本层,需要淘汰出局时,改为push到第一层队尾,访问次数降低到刚好能保留在第一层的次数。
算法的参数之所以定在10%,25%,50%,后续的算法复杂度分析里会推算其合理性,后期也可以修改调整。
算法动机
“懒”,的**来自区间数据结构线段树中使用的Lazy propagation技巧,懒操作使得线段树在操作区间的时候
“用到的信息等用到再计算”,从而每次对任意长的区间操作复杂度都是log(N)。
2路,在物理结构上区分热度,符合SSD/SATA分层结构,也符合内存/磁盘分层结构,并且在实验中表现优异,
是S4LRU命中率更高的原因。所以这里我们得到借鉴。
循环队列,提供了I/O性能和高并发的优化环境,正如facebook至今还在用简单的FIFO作为cache策略,循环队
列在工程表现上更好是有道理的。
IO优越性
LRU和S4LRU,它们每次命中了一个对象后,就勤劳地将它调整到“安全”的位置,不够”懒“,所以造成了
很多磁盘碎片。在LazyQueue算法中,命中后我们只是将其计数器+1,而不真正及时移动它,因此磁盘碎片
完全没有产生。由于每次在某一层的加入、删除都在队尾、队首进行,所以IO连续性给高性能IO系统的优化
算法提供了操作可能。
并行环境
区别于LRU每次命中后的调整,懒队列的命中操作只是在单一对象上实现一次++,可以用原子操作实现。
对于20%的未命中情况,所有调整都在队首队尾加锁执行,不影响其他位置的读取修改等操作,可以让其他
请求异步非阻塞地使用整个淘汰算法。
算法复杂度
命中的时候只需要O(1)的++操作。未命中的时候,会引起一次递归的淘汰,最坏情况是,第一层的队首聚集了
前25%该层访问数最高的,接下来跟着25%访问数次高的,第二层也是类似排序,这时会引起递归次数最多的淘汰。
我们按均摊复杂度分析方法,推算这个算法复杂度。
假设:
- 全部的储存总量是N.
- 第一层(SATA)的容量是70%*N,第二层(SSD)的容量是30%*N.
- 第一层会被保留加会被提升的百分比是a%, 第二层是b%保留和提升各占其一般,也就是0.5a 和 0.5b.
在上述情况下触发了一次淘汰,首先判断第一层的队首,是需要提升的一个对象,于是触发了第二层的淘汰,
1. 第二层的前a%将要保留,实现时可以直接队尾指针++,而不用真正迁移,所以代价是 N * 30% * b%,第二层的淘汰会继续触发第一层需要淘汰,
2. 于是第一层的前0.5*a%的对象将形成队列等待晋级第二层,而后0.5*a%的对象会直接跳过(保留在本层),直到指针移到了后1 - a%的区域,代价是N * 0.7 * a%
3. 此时第一层的指针到了可淘汰区域,需要淘汰的数目是第二层的下降对象数量,代价是N * 0.3 * (1-b%)
4. 将等待队列中的晋级对象写入第二层的空闲区域,此时N * 0.7 * a% 个对象可能导致溢出,于是会有部分写回到第一层, 代价是N * 0.7 * a% * 0.5 + N * 0.3 * b%.
5. 写回到第一层的代价是 N * 0.7 * a% * 0.5 - N * 0.3 * (1-b%).
于是一次总代价最坏情况下是前面五项之和:N * 0.3 * b% * 2 + 2 * 0.7 * N * a%.
均摊复杂度计算时需要分析最少多少次操作可以导致上述最坏情况,这个场景下不方便计算,我们通过该淘汰操作“消耗”了多少次之前的O(1)操作,间接计算。
第二层降级到第一层时,会导致访问次数降低到了第一层的最高访问次数,假设最坏情况下,每个对象的访问次数只降低了1次,于是消耗了“N * 0.3 * (1-b%)”的访问次数。
均摊复杂度 T = 总代价 / 均摊次数 = (N * 0.3 * b% * 2 + 2 * 0.7 * N * a%) / (N * 0.3 * (1-b%)).
代入a% = 50%, b% = 50%的时候,T = 6.67,算法均摊复杂度是O(1)的。
可以尝试参数取其他数值时,用公式分析算法复杂度,比如a, b 降低到 10%时,T = 0.74,算法的执行速度很快,但是命中率会降低。
公式反应了计算机科学里常见的反比真理,更好的性能,付出更高的代价。
Please feel free to contact with me via prowindy@gmail.com, if you have some advices.