基于 Golang container/list 的 LRU 实现

262 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第21天,点击查看活动详情

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 实现?

感谢阅读,欢迎评论区交流!