基于Golang container/list实现LRU缓存
作者:ag9920
LRU vs LFU
业务本地缓存中我们经常需要维护一个池子,存放热点数据,单机的内存是有限的,不可能把所有数据都放进来。所以,合理的逐出策略是很重要的。我们需要在池子的元素达到容量时,把一些不那么热点的缓存清理掉。
那怎么评估该清理哪些缓存呢?LRU 和 LFU 是两个经典的逐出策略:
- Least Recently Used (LRU) :逐出最早使用的缓存;
- Least Frequently Used (LFU) :逐出最少使用的缓存。
举个例子,比如目前我们有 A, B, C, D 四个元素,按照时间由远及近的访问顺序为:
A, B, A, D, C, D, D, C, C, A, B
在这个时间线里,A, C, D 都各自被访问 3 次,而 B 只有 2 次。
按照 LFU 的标准,B 是访问次数最少的,也就是【最少使用】的,所以需要逐出。
但是按照 LRU 的标准,B 是刚刚被访问,还新着呢,按照时间回头看,在四个元素被访问顺序是:D,C,A,B。这个 D 是最早访问,后来人家 C, A, B 都访问了,D 比它们三个都落后,所以要逐出 D。
LRU 和 LFU 并没有高下之分,大家需要按照业务场景选择最适合的逐出策略(eviction algorithm)。
container/list
在上一篇文章 解析 Golang 官方 container/list 原理 中,我们介绍了这个官方标准库里的双向链表实现,本质是借用 root 节点实现了一个环形链表。
基于这个双向链表,我们可以干很多事,今天我们就来看看,怎样基于 container/list 实现一个带上 LRU 逐出机制的本地缓存。
原理分析:用双向链表实现 LRU
既然是 LRU 缓存,我们首先要确定底层承载 localcache 的结构:
- 使用一个 map[string]interface{} 来存储缓存数据;
- 需要明确缓存容量,超过了就要逐出。
type Cache struct { MaxEntries int cache map[string]interface{} }
但仅仅如此肯定不够,我们怎样判断 Least Recently Used 呢?
需要有一个结构用来记录,每次有缓存被访问,我们就把它权重提高,这样随着其他缓存请求,这个缓存的权重会慢慢落下来,如果触发了 MaxEntries 这个上限,我们就看看谁的权重最小,就将它从 localcache 中清理出去。
使用 container/list 双向链表就可以天然支持这一点!
虽然底层实现是个 ring,但对外来看,container/list 就是个双向链表,有自己的头结点和尾结点。利用API,我们可以很低成本地获取头尾结点,移除元素。
所以,我们可以以【节点在链表中的顺序】来当做【权重】。在 list 里越靠前,就说明是刚刚被访问,越靠后,说明已经长时间没有访问了。当缓存大小和容量持平,直接删除双向链表中的【尾结点】即可。
而且,container/list 中的节点 Element 承载的数据本身也是个 any(interface{}),天然支持我们存入任意类型的缓存数据。
// Element is an element of a linked list. type Element struct { // Next and previous pointers in the doubly-linked list of elements. // To simplify the implementation, internally a list l is implemented // as a ring, such that &l.root is both the next element of the last // list element (l.Back()) and the previous element of the first list // element (l.Front()). next, prev *Element // The list to which this element belongs. list *List // The value stored with this element. Value any }
代码实战
有了上面的推论,我们就可以往 Cache 结构里内嵌 container/list 来实现了。其实这就是 groupcache 实现的 LRU 的机理,我们来看看怎么做到的:
localcache 结构
// Cache is an LRU cache. It is not safe for concurrent access. type Cache struct { // MaxEntries is the maximum number of cache entries before // an item is evicted. Zero means no limit. MaxEntries int // OnEvicted optionally specifies a callback function to be // executed when an entry is purged from the cache. OnEvicted func(key Key, value interface{}) ll *list.List cache map[interface{}]*list.Element } // A Key may be any value that is comparable. See http://golang.org/ref/spec#Comparison_operators type Key interface{} // New creates a new Cache. // If maxEntries is zero, the cache has no limit and it's assumed // that eviction is done by the caller. func New(maxEntries int) *Cache { return &Cache{ MaxEntries: maxEntries, ll: list.New(), cache: make(map[interface{}]*list.Element), } }
首先是结构调整,注意几个关键点:
- 新增 ll 属性,类型为
*list.List
,这就是我们用来判断访问早晚的双向链表; - cache 从 map[string]interface{} 变成了 map[interface{}]*list.Element,直接缓存了双向链表的节点,同时 key 也改为 interface{},这样能支持更多场景,只要 key 的实际类型支持比较即可。
- 新增了
OnEvicted func(key Key, value interface{})
函数,支持在某些 key 被逐出时回调,支持业务扩展,可以做一些收尾工作。
Add 添加缓存
type entry struct { key Key value interface{} } // Add adds a value to the cache. func (c *Cache) Add(key Key, value interface{}) { if c.cache == nil { c.cache = make(map[interface{}]*list.Element) c.ll = list.New() } if ee, ok := c.cache[key]; ok { c.ll.MoveToFront(ee) ee.Value.(*entry).value = value return } ele := c.ll.PushFront(&entry{key, value}) c.cache[key] = ele if c.MaxEntries != 0 && c.ll.Len() > c.MaxEntries { c.RemoveOldest() } }
这里可以看到采用了懒加载,只有当我们尝试新增一个缓存时,才会初始化 cache map 以及双向链表。
- 首先判断 key 是否还在缓存,若已经在,就利用双向链表的
MoveToFront
将其提到链表的头部(这就是我们前面推演的【提升权重】),语义上表达,这个 key 刚刚使用,还新着呢,权重最大。 - 操作完链表后,回来 cache map,将原来节点的 value 更新为这次的新值即可。
- 若 key 不在缓存里,就构造出来一个 entry,依然
PushFront
插入到链表头部,随后更新 cache 即可。 - 重点是最后一步,Add 完成后,看看链表长度是否已经超过了阈值(MaxEntries),若超过,就该触发我们的 LRU 逐出策略了,关键在这个
RemoveOldest
:
// RemoveOldest removes the oldest item from the cache. func (c *Cache) RemoveOldest() { if c.cache == nil { return } ele := c.ll.Back() if ele != nil { c.removeElement(ele) } } func (c *Cache) removeElement(e *list.Element) { c.ll.Remove(e) kv := e.Value.(*entry) delete(c.cache, kv.key) if c.OnEvicted != nil { c.OnEvicted(kv.key, kv.value) } }
可以看到,基于双向链表,所谓 oldest,其实就是链表最尾端的节点,从 Back()
方法拿到尾结点后,从链表中 Remove
掉,并从 map 中 delete,最后触发 OnEvicted 回调。三连之后,这个缓存就正式被逐出了。
Get 读缓存
// Get looks up a key's value from the cache. func (c *Cache) Get(key Key) (value interface{}, ok bool) { if c.cache == nil { return } if ele, hit := c.cache[key]; hit { c.ll.MoveToFront(ele) return ele.Value.(*entry).value, true } return }
根据 key 读缓存就容易多了,本质就是直接查 map。不过注意,如果有,这算一次命中,按照 LRU 规则是要调整权重的,所以这里我们会发现 c.ll.MoveToFront(ele)
将缓存的 element 提升到链表头节点,意味着这是最新的缓存。
Add 和 Get 都代表了【缓存被使用】,所以二者都需要提升权重。
Remove 删缓存
// Remove removes the provided key from the cache. func (c *Cache) Remove(key Key) { if c.cache == nil { return } if ele, hit := c.cache[key]; hit { c.removeElement(ele) } }
删除的逻辑其实就很简单了,注意这个是使用方手动删除,并不是 LRU 触发的逐出,所以直接提供了删除的 key,不用找链表尾结点。
removeElement 还是和上面一样的三连操作,从链表中删除,从 map 中删除,调用回调函数:
func (c *Cache) removeElement(e *list.Element) { c.ll.Remove(e) kv := e.Value.(*entry) delete(c.cache, kv.key) if c.OnEvicted != nil { c.OnEvicted(kv.key, kv.value) } }
Clear 清空缓存
// Clear purges all stored items from the cache. func (c *Cache) Clear() { if c.OnEvicted != nil { for _, e := range c.cache { kv := e.Value.(*entry) c.OnEvicted(kv.key, kv.value) } } c.ll = nil c.cache = nil }
其实清空本身特别简单,我们用来承载缓存的就两个核心结构:双向链表 + map。
所以直接置为 nil 即可,剩下的交给 GC。
不过因为希望支持 OnEvicted 回调,所以这里前置先遍历所有缓存元素,回调结束后再将二者置为 nil。
结语
这篇文章我们赏析了 groupcache 基于 container/list 实现的 LRU 缓存,整体思路非常简单,源码不过 140 行,但却可以把 LRU 的思想很好地传递出来。
细心的同学会发现,我们上面的结构其实是并发不安全的,map 和链表如果在操作过程中被打断,存在另一个线程交替操作,很容易出现 bad case,使用的时候需要注意。大家也可以考虑一下,如何实现并发安全的 LRU,是否必须要 RWMutex 实现?
到此这篇关于基于Golang container/list实现LRU缓存的文章就介绍到这了,更多相关Golang LRU内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!