基于Golang container/list实现LRU缓存

 更新时间:2023年08月03日 15:39:21   作者:ag9920  
Least Recently Used (LRU) ,即逐出最早使用的缓存,这篇文章主要为大家介绍了如何基于Golang container/list实现LRU缓存,感兴趣的可以了解下

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

相关文章

  • 一文教你如何封装安全的go

    一文教你如何封装安全的go

    这篇文章主要给大家介绍了关于如何封装安全go的相关资料,文中通过实例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2022-02-02
  • Go gin框架处理panic的方法详解

    Go gin框架处理panic的方法详解

    本文我们介绍下recover在gin框架中的应用, 首先,在golang中,如果在子协程中遇到了panic,那么主协程也会被终止,文中通过代码示例介绍的非常详细,需要的朋友可以参考下
    2023-09-09
  • 使用go来操作redis的方法示例

    使用go来操作redis的方法示例

    今天小编就为大家分享一篇关于使用go来操作redis的方法示例,小编觉得内容挺不错的,现在分享给大家,具有很好的参考价值,需要的朋友一起跟随小编来看看吧
    2019-04-04
  • Go语言面向对象中的多态你学会了吗

    Go语言面向对象中的多态你学会了吗

    面向对象中的多态(Polymorphism)是指一个对象可以具有多种不同的形态或表现方式,本文将通过一些简单的示例为大家讲解一下多态的实现,需要的可以参考下
    2023-07-07
  • golang实现数组分割的示例代码

    golang实现数组分割的示例代码

    本文主要介绍了golang实现数组分割的示例代码,要求把数组分割成多个正整数大小的数组,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2021-12-12
  • GO语言对数组切片去重的实现

    GO语言对数组切片去重的实现

    本文主要介绍了GO语言对数组切片去重的实现,主要介绍了几种方法,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2022-04-04
  • Go依赖注入DI工具wire使用详解(golang常用库包)

    Go依赖注入DI工具wire使用详解(golang常用库包)

    依赖注入是指程序运行过程中,如果需要调用另一个对象协助时,无须在代码中创建被调用者,而是依赖于外部的注入,本文结合示例代码给大家介绍Go依赖注入DI工具wire使用,感兴趣的朋友一起看看吧
    2022-04-04
  • 使用Golang实现加权负载均衡算法的实现代码

    使用Golang实现加权负载均衡算法的实现代码

    这篇文章主要介绍了使用Golang实现加权负载均衡算法的实现代码,详细说明权重转发算法的实现,通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2021-09-09
  • Go语言错误处理异常捕获+异常抛出

    Go语言错误处理异常捕获+异常抛出

    这篇文章主要介绍了Go语言错误处理异常捕获和异常抛出,Go语言的作者认为java等语言的错误处理底层实现较为复杂,就实现了函数可以返回错误类型以及简单的异常捕获,虽然简单但是也非常精妙,大大的提高了运行效率,下文需要的朋友可以参考一下
    2022-02-02
  • Go语言导出内容到Excel的方法

    Go语言导出内容到Excel的方法

    这篇文章主要介绍了Go语言导出内容到Excel的方法,涉及Go语言操作excel的技巧,具有一定参考借鉴价值,需要的朋友可以参考下
    2015-02-02

最新评论