LRU缓存及其实现
缓存是我们日常开发中来提高性能最直接的方式,经常会听到有人说:性能不行?是因为你没加缓存!常见的缓存有外部缓存服务以及程序内部缓存,外部缓存服务包括:Redis、Memcached等,内部缓存就是我们可以在程序内使用类似HashMap这种方式来建立缓存,另外比如Web中常见的cdn静态资源缓存等也属于缓存,以及我们计算机中的CPU缓存,文件系统缓存等都不约而同采用相同的思路来加速性能这个指标。缓存的目的很直接就是为了提升性能,是一种空间换时间的思想,既然是缓存,那么总不能把所有的数据全都缓存起来吧?通常根据存储器的层次结构,访问速度越快的硬件单位存储价格越贵,就像CPU L1缓存的访问延时在1ns,比内存快出100倍,大小也就是在32K~128K这个范围,价格也大约是内存的400倍,同样内存的速度比固态盘要快上1500倍,但是价格也贵将近40倍,所以我们看缓存的本质就是尽量用更高成本的存储来提升性能,但是受空间的限制,我们只能尽可能的将最常用的数据缓存下来,这些就是我们通常提的缓存命中率这个概念,比如CPU缓存的缓存命中率可以达到95%或者更高,这样可以极大的提升我们程序的性能。因为我们访问的数据不断变化的,因此缓存也必然处在一个动态变化的过程中,从而保证缓存命中率的相对稳定,所以缓存的数据必须要不断淘汰掉不用的数据同时将常用的添加进去,那么具体应该怎么淘汰呢?对于缓存淘汰的策略,通常有3种:
1.FIFO (First in first out):先进先出的策略,相当于队列,非常简单。
2.LFU (Least Frequently Used):最少使用策略,这个策略的思想是如果一个数据在最近一段时间内使用很少,那么在将来一段时间被使用的可能性也很少,这个策略相当于给每个数据添加使用次数这样的标记,当缓存占满的时候优先淘汰次数少的。
3.LRU (Least Recently Used):最近最少使用策略,按照访问的时间先后作为依据,如果说数据最近被访问过,那么将来被访问的几率也会更高,因此数据相当于按照时间排序,当缓存占满的时候优先淘汰上次使用时间最久远的数据。
我们日常开发最常用的其实就是LRU缓存淘汰策略,由于LRU是按照时间先后顺序,优先淘汰最近最少使用的,那么是否要为每个数据添加一个访问时间的选项?显然这样不太优雅同时每次根据时间排序的复杂度也不低,有没有更好的实现呢?答案是肯定的,我们现在使用的LRU缓存都是基于链表来实现的,最近访问的元素在链表头部,最早访问的元素在链表尾部,实现的逻辑大致如下:
1.插入元素
首先判断数据是否在链表内,如果在的话则需要删除该数据并插入到链表头部,插入元素也相当于一次访问。
如果不再链表内,需要判断缓存是不是满了,也就是链表数据个数是不是达到预定值,如果达到了,首先淘汰掉链表尾部元素,然后将新元素插入到链表头部,否则直接将新元素插入到链表的头部。
2.读取元素
判断数据是否在链表内,如果不再直接返回空也就是告诉调用者此元素不存在;如果在链表内则需要删除该数据节点,然后插入到链表头部,表示最近访问过。
上面是大致的逻辑,总体来说很简单,几句话就可以说清楚,那么接下来我们简单实现一下:
class KVNode: def __init__(self, k, v): self.key = k self.value = v self.next = None def __str__(self) -> str: return "Node({},{})".format(self.key, self.value) class LruCache: def __init__(self, capacity: int): self.cap = capacity self.size = 0 # header 哨兵 self.header = KVNode(0, 0) def __insert_to_tail(self, key, value): """在链表尾部插入元素 """ if self.size >= self.cap: return node = self.header while node is not None and node.next is not None: node = node.next node.next = KVNode(key, value) self.size += 1 def __insert_to_head(self, key, value): """插入元素到链表头部 """ if self.size >= self.cap: return node = KVNode(key, value) node.next = self.header.next self.header.next = node self.size += 1 def get(self, k): """按照key获取cache值 O(n) """ node = self.header.next prev = self.header while node is not None: if node.key == k: # 移动元素到头部 prev.next = node.next node.next = self.header.next self.header.next = node # self.__insert_to_head(node) return node.value prev = node node = node.next def put(self, k, v): """向lru缓存中插入元素 O(n) """ node = self.header prev = None while node.next is not None: prev = node node = node.next if node.key == k: # 将节点移动至头部 node.value = v prev.next = node.next node.next = self.header.next self.header.next = node return # 未找到元素 插入到头部 if self.size == self.cap: # 淘汰尾部元素 prev.next = None self.size -= 1 node = KVNode(k, v) node.next = self.header.next self.header.next = node self.size += 1 def __delete_tail(self): """删除尾节点 """ if self.size == 0: return node = self.header prev = None while node.next is not None: prev = node node = node.next # 此时prev为倒数第二个节点(有可能为header) prev.next = None self.size -= 1 def traverse(self): """遍历lru cache并输出 """ if self.size == 0: return node = self.header.next while node is not None and node.next is not None: print(node, end="->") node = node.next print(node) def clear(self): """清空缓存 """ self.header.next = None self.size = 0
我们这里简单实现1个支持key,value数据节点的单向链表,然后添加了get/put这两个操作方法,来实现了lru的功能,不过很容易可以发现这样无论是get还是put时间复杂度都是O(n),如果缓存数量很大的话,获取缓存的性能就会比较慢,我们仔细看无论是get还是put都需要遍历链表来判断元素是否存在于链表内,判断元素是否存在这种场景属于典型的等值查找,我们很容易想到可以用hash表或者二叉树来实现,这样复杂度就是O(1)或者O(logn),这里我们选用hash表速度上可以达到最优,这里hash表的key就是缓存的key,hash表的value就是这个节点。不过现在又有个新的问题就是如果元素满了我们要淘汰尾部元素怎么办或者移动元素该怎么办?刚才我们在遍历时可以记录下节点的prev,而现在用hash表就只能拿到next了,这个时候我们会想到使用双向链表,同时保存前驱指针和后继指针,这样就可以方便的实现节点的删除以及尾节点的淘汰了,那么这样查找和删除的问题都解决了,但是空间占用更大了,除了hash表还有双向链表的前驱指针,这样整体的空间复杂度相较于上面来说是O(n),这也是空间换时间的实现方式,好了,那么接下来上代码:
class TwoKVNode: """双向链表节点 """ def __init__(self, k, v) -> None: self.key = k self.value = v self.next = None self.prev = None def __str__(self) -> str: return "Node({}, {})".format(self.key, self.value) class LruCachePro: def __init__(self, capacity: int): if capacity < 0: capacity = 0 self.cap = capacity self.size = 0 # header和tail 哨兵 self.header = TwoKVNode(0, 0) self.tail = TwoKVNode(0, 0) self.header.next = self.tail self.tail.prev = self.header # key -> Node self.cache = {} def get(self, k): """按照key获取cache值 O(1) """ if k not in self.cache: return node = self.cache[k] # 移动node到头部 # 删除 node.prev.next = node.next node.next.prev = node.prev # 添加 node.prev = self.header node.next = self.header.next self.header.next.prev = node self.header.next = node return node.value def put(self, k, v): """向lru缓存中插入元素 O(n) """ if self.cap == 0: return if k in self.cache: node = self.cache[k] # 更新值并移动至头部 node.value = v # 删除 node.prev.next = node.next node.next.prev = node.prev # 添加 node.prev = self.header node.next = self.header.next self.header.next.prev = node self.header.next = node return # 未找到元素 直接插入到头部 if self.size == self.cap: # 淘汰尾部元素 del self.cache[self.tail.prev.key] self.tail.prev.prev.next = self.tail self.tail.prev = self.tail.prev.prev self.size -= 1 node = TwoKVNode(k, v) node.prev = self.header node.next = self.header.next self.header.next.prev = node self.header.next = node self.cache[k] = node self.size += 1 def traverse(self): """遍历lru cache并输出 """ if self.size == 0: return node = self.header.next while node.next is not None: print(node, end="->") node = node.next print("NULL")
重点是双向链表操作这里有点绕,不过没关系细心去写都非常简单,这里类叫做LruCachePro,然后我们可以测试一下两者的性能差距:
#!/usr/bin/env python3 # coding=utf-8 import time from lru_cache import LruCache from lru_cache import LruCachePro if __name__ == '__main__': t1 = time.time() lru = LruCache(10000) for i in range(1000): lru.put(str(i), i) t2 = time.time() print("time1: {:.3f}s".format(t2 - t1)) t1 = time.time() lru = LruCachePro(10000) for i in range(10000): lru.put(str(i), i) t2 = time.time() print("time1: {:.3f}s".format(t2 - t1))
执行结果如下:
time1: 4.324s
time1: 0.013s
可以看到第二种hash表结合双向链表的实现带来的性能提升非常大。
关于LRU缓存淘汰算法的内容就是上面这些,感谢您的阅读,有问题希望能多多交流