Golang

关注公众号 jb51net

关闭
首页 > 脚本专栏 > Golang > Golang LRU

基于Golang container/list实现LRU缓存

作者:ag9920

Least Recently Used (LRU) ,即逐出最早使用的缓存,这篇文章主要为大家介绍了如何基于Golang container/list实现LRU缓存,感兴趣的可以了解下

LRU vs LFU

业务本地缓存中我们经常需要维护一个池子,存放热点数据,单机的内存是有限的,不可能把所有数据都放进来。所以,合理的逐出策略是很重要的。我们需要在池子的元素达到容量时,把一些不那么热点的缓存清理掉。

那怎么评估该清理哪些缓存呢?LRU 和 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 的结构:

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),
	}
}

首先是结构调整,注意几个关键点:

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 以及双向链表。

// 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内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文