LRU(Least Recent Used)是我们在cache替换算法中最普遍使用的算法,在缓存块已满,而需要缓存新的数据块的时候,这时需要从缓存中找到一个“没有价值”的块用新的数据块去替换它。
Cache有两个问题:一个是前面提到的降低锁粒度,另一个是提高精准度,或者称为提高命中率。LRU在大多数情况下表现是不错的,但是有如下的问题:
1, 顺序扫描。顺序扫描的情况下LRU没有命中情况,而且会淘汰其它将要被访问的item从而污染cache。
2, 循环的数据集大于缓存大小。如果循环访问且数据集大于缓存大小,那么没有命中情况。
LRU的特点是简洁高效,但是缺点是LRU的缺点是不能对weak locality的数据进行缓存。
a. 如果stack size有1000个块,而一个文件是1001个块的大小,而且每次访问都是从头到尾的访问。则LRU的性能非常差,几乎没有任何缓存。
b. 假设我们要邀请学习好的同学到一个容纳10人的会议室开会。如果是LRU算法的话,会邀请90分以上或者80分以上的人,,但是如果没有80分以上的同学则一个都不邀请,而会议室也就白白浪费了,对于LIRS,邀请成绩前在10名的同学到会议室。这里会议室为我们的stack ,这样我们的会议室也就是stack至少不会浪费。
所以我们这里边的问题就是如何把一些weak locality的数据也能够缓存起来,使我们的cache得到充分的使用?
2. 两个概念
Reuse: 一个块被使用之后,再次被使用
Recency:一个块被访问后,和上一次访问之间的距离。
LIRS的基本思想是对访问的数据块进行分类,一部分为hot数据块,一部分为cold数据块。对于hot数据块我们可以分配90%以上的cache给它们。而对于cold数据块给它们分配10%。
从LIRS算法的描述来看,可以理解为两个LRU队列的组合,利用cold缓冲区来保护Hot缓冲区,提高了进入hot缓冲区的门槛,阻止hot缓冲区频繁地变化。
在LIRS算法中使用了IRR(Inter-Reference Recency)和Recency这两个参数。其中IRR指一个页面最近两次的访问间隔;Recency指页面最近一次访问至当前时间内有多少页面曾经被访问过。在IRR和Recency参数中不包含重复的页面数,因为其他页面的重复对计算当前页面的优先权没有太多影响。
LIRS算法实现:
首先说明LRU算法的实现。
采用带表头的单链表来存储数据节点,head指针指向表头,tail指针指向链表最后一个节点。数据节点则记录了每个缓存块的ID,加载状态,数据指针,next指针。另有unUsedSize变量存放未分配的缓存块数量。
当用户访问命中时,将对应的数据节点移到队列尾部。当用户访问未命中时,若能分配新数据节点,则分配,否则从表头摘下一个数据节点,并卸载数据。更新得到的数据节点的信息,并加到队列尾部。
最后,为了避免在查找数据节点时遍历整个链表,增加了Dictionary的字典,利用其hash查找功能提高查找效率。
再说明LIRS算法的实现。
为了简化实现,将算法实现为多个逻辑LRU队列(可实现多级缓冲区),在物理实现上,仍采用带表头的单链表来存储数据节点。对于N级缓冲区的实现(原始的LIRS算法为2级),使用unUsedSize[N]数组存放每个缓冲区的未分配数量,使用head[N+1]存放缓冲区的头尾指针。这样head[0]、head[1]指示了第0块缓冲区的头尾,依次类推,head[N-1]、head[N]指示了第N-1块缓冲区的头尾。0号缓冲区是cold的缓冲区,N-1号则是最hot的。由于使用了多级缓冲区,数据节点也需要增加一个选择器selector,用于指示该节点属于哪个缓冲区。
当用户访问未命中,分配新节点时,先检查N-1号缓冲区是否能分配新节点,若不能则依次向前检查。若分配了新节点,则直接加入相应缓冲区的尾部,否则就从0级缓冲区头部摘下一个节点来,更新节点信息后加入0级缓冲区尾部。
当用户访问命中时,则将x级的节点提升到x+1级,并加到缓冲区尾部,x+1级的头部节点,则被移到x级缓冲区的尾部
同样的,增加了Dictionary字典,提高查找节点的效率。
LRU替换算法
缓存的技术点包括内存管理和替换算法。LRU是使用最多的替换算法,每次淘汰最久没有使用的元素。LRU缓存实现分为两个部分:Hash表和LRU链表,Hash表用于查找缓存中的元素,LRU链表用于淘汰。内存常以Slab的方式管理。
上图是Memcache的内存管理示意图,Memcache以Slab方式管理内存块,从系统申请1MB大小的大块内存并划分为不同大小的Chunk,不同Slab的Chunk大小依次为80字节,80 * 1.25,80 * 1.25^2, …。向Memcache中添加item时,Memcache会根据item的大小选择合适的Chunk。
Oceanbase最初也采用LRU算法,只是内存管理有些不同。Oceanbase向系统申请2MB大小的大块内存,插入item时直接追加到最后一个2MB内存块的尾部,当缓存的内存量太大需要回收时根据一定的策略整块回收2MB的内存,比如回收最近最少使用的item所在的2MB内存块。这样的做法虽然不是特别精确,但是内存管理简单,对于系统初期很有好处。
缓存锁
缓存需要操作两个数据结构:Hash表和LRU链表。多线程操作cache时需要加锁,比较直接的做法是整体加一把大锁后再操作Hash表和LRU链表。有如下的优化思路:
1, Hash表和LRU链表使用两把不同的锁,且Hash表锁的粒度可以降低到每个Hash桶一把锁。这种做法的难点是需要处理两种数据结构不一致导致的问题,假设操作顺序为read hash -> del hash item -> del lru item -> read lru item,最后一次read lru item时item所在的内存块可能已经被回收或者重用,一般需要引入引用计数并考虑复杂的时序问题。
2, 采用多个LRU链表以减少LRU表锁粒度。Hash表的锁冲突可以通过增加Hash桶的个数来解决,而LRU链表是一个整体,难以分解。可以将缓存的数据分成多个工作集,每个item属于某个工作集,每个工作集一个LRU链表。这样做的主要问题是可能不均衡,比如某个工作集很热,某些从整体上看比较热的数据也可能被淘汰。
3, 牺牲LRU的精确性以减少锁。比如Mysql中的LRU算法变形,大致如下:将LRU链表分成两部分,前半部分和后半部分,如果访问的item在前半部分,什么也不做,而不是像传统的LRU算法那样将item移动到链表头部;又如Linux Page Cache中的CLOCK算法。Oceanbase目前的缓存算法也是通过牺牲精确性来减少锁。前面提到,Oceanbase缓存以2MB的内存块为单位进行淘汰,最开始采用LRU策略,每次淘汰最近最少使用的item所在的2MB内存块,然而,这样做的问题是需要维护最近最少使用的item,即每次读写缓存都需要加锁。后续我们将淘汰策略修改为:每个2MB的内存块记录一个访问次数和一个最近访问时间,每次读取item时,如果访问次数大于所有2MB内存块访问次数的平均值,更新最近访问时间;否则,将访问次数加1。根据记录的最近访问时间淘汰2MB内存块。虽然,这个算法的缓存命中率不容易评估,但是缓存读取只需要一些原子操作,不需要加锁,大大减少了锁粒度。
4, 批量操作。缓存命中时不需要立即更新LRU链表,而是可以将命中的item保存在线程Buffer中,积累了一定数量后一次性更新LRU链表。
LIRS思想
Cache有两个问题:一个是前面提到的降低锁粒度,另一个是提高精准度,或者称为提高命中率。LRU在大多数情况下表现是不错的,但是有如下的问题:
1, 顺序扫描。顺序扫描的情况下LRU没有命中情况,而且会淘汰其它将要被访问的item从而污染cache。
2, 循环的数据集大于缓存大小。如果循环访问且数据集大于缓存大小,那么没有命中情况。
之所以会出现上述一些比较极端的问题,是因为LRU只考虑访问时间而没有考虑访问频率,而LIRS在这方面做得比较好。LIRS将数据分为两部分:LIR(Low Inner-reference Recency)和HIR(High Inner-reference Recency),其中,LIR中的数据是热点,在较短的时间内被访问了至少两次。LIRS可以看成是一种分级思想:第一级是HIR,第二级是LIR,数据先进入到第一级,当数据在较短的时间内被访问两次时成为热点数据则进入LIR,HIR和LIR内部都采用LRU策略。这样,LIR中的数据比较稳定,解决了LRU的上述两个问题。LIRS论文中提出了一种实现方式,不过我们可以做一些变化,如可以实现两级cache,cache元素先进入第一级cache,当访问频率达到一定值(比如2)时升级到第二级,第一级和第二级均内部采用LRU进行替换。Oracle Buffer Cache中的Touch Count算法也是采用了类似的思想。
SSD与缓存
SSD发展很快,大有取代传统磁盘之势。SSD的发展是否会使得单机缓存变得毫无必要我们无从得知,目前,Memory + SSD + 磁盘的混合存储方案还是比较靠谱的。SSD使用可以有如下不同的模式:
1, write-back:数据读写都走SSD,内存中的数据写入到SSD即可,另外有单独的线程定期将SSD中的数据刷到磁盘。典型的代表如Facebook Flashcache。
2, write-through:数据写操作需要先写到磁盘,内存和SSD合在一起看成两级缓存,即cache中相对较冷的数据在SSD,相对较热的数据在内存。
当然,随着SSD的应用,我想减少缓存锁粒度的重要性会越来越突出。
总结&推荐资料
到目前为止,我们在SSD,缓存相关优化的工作还是比较少的。今后的一年左右时间,我们将会投入一定的精力在系统优化上,相信到时候再来总结的时候认识会更加深刻。我想,缓存相关的优化工作首先要做的是根据需求制定一个大致的评价标准,接着使用实际数据做一些实验,最终可能会同时保留两到三种实现方式或者配置略微有所不同的缓存实现。