cache教程1.LRU 缓存淘汰策略

这一节实现LRU算法,要理解明白其使用的数据结构。

FIFO/LFU/LRU 算法简介

Cache的缓存全部存储在内存中,内存是有限的,因此不可能无限制地添加数据。当占用内存超过了给定的内存大小时候,就需要从缓存中移除一条或多条数据了。我们肯定希望尽可能移除"没用"的数据,那如何判定数据"有用"还是"没用"呢?

FIFO(First In First Out)

先进先出,也就是淘汰缓存中最老(最早添加)的记录。这种算法的实现简单,创建一个队列,新增记录添加到队尾,每次内存不够时,淘汰队首。这样对那些最早添加的却需要频繁访问的数据很不友好。

LFU(Least Frequently Used)

最少使用,也就是淘汰缓存中访问频率最低的记录。其实现需要维护一个按照访问次数排序的队列,每次访问,访问次数加1,队列重新排序,淘汰时选择访问次数最少的即可。LFU 算法的命中率是比较高的,但缺点也非常明显,维护每个记录的访问次数,对内存的消耗是很高的;另外,如果数据的访问模式发生变化,LFU 需要较长的时间去适应,也就是说 LFU 算法受历史数据的影响比较大。

LRU(Least Recently Used)

最近最少使用,其算法可以认为是相对平衡的一种淘汰算法。LRU 算法的实现非常简单,维护一个队列,如果某条记录被访问了,则移动到队首,那么队尾则是最近最少访问的数据,淘汰该条记录即可。

LRU 算法实现

核心数据结构

图片来自极客兔兔

这是字典(map )和双向链表

为什么是map和双向链表这两个数据结构?

map很容易理解,存储键和值的映射关系。这样根据某个键(key)查找对应的值(value)的复杂是O(1),在字典中插入一条记录的复杂度也是O(1)

讲解该算法时,说的是维护一个队列,但为什么就使用链表呢?

因为队列只能操作队头和队尾的数据,要进行删除,可能删除的是中间部分的数据,所以就不能使用队列。就可以用链表来实现符合我们要求的队列。

而为什么使用双向链表,而不用单向链表呢?

首先增加或删除的操作的复杂度均是O(1)才符合我们的要求。

使用单向链表的时候,在中间部分删除链表节点时候,其时间复杂度不是O(1)。删除给定节点,那就需要找到给定节点的前一个节点,只有双向链表才保存了前一个节点。而且使用双向链表也方便快速把数据放在队首/队尾。所以使用双向链表。

这里使用Go中container/list的双向链表。其使用和c++的稍微不同,c++中声明链表是std::list<int>,要注明元素类型的。而Go中是不需要的,而且其元素的值是any泛型。每个元素的值的类型都可以不同。

Go 复制代码
func main() {
	ll := list.New()
	ll.PushFront(1)
	ll.PushFront("home")

	fmt.Println(ll.Front().Value.(string))
	fmt.Println(ll.Back().Value.(int))
	//下面的写法也成功打印出来
	// fmt.Println(ll.Front().Value)
	// fmt.Println(ll.Back().Value)
}
//打印结果
home
1

具体实现

使用链表的话,就要确定双向链表节点的数据结构和节点的值的数据类型。例如:

Go 复制代码
//链表节点的结构
type node struct{
    next *node
    value myvalue
}

//链表节点的值的结构
type myvalue struct{
    age int
    name string
}

双向链表节点的数据结构是已经确定的了,因为使用了Go中自带的list,其节点结构是Element。确定了节点结构,那就要确定该节点的值的结构。

节点的值的结构entry

因为我们要实时统计存储大小, 所以存储的元素都要能够计算大小。所以我们抽象出如下接口NodeValue:

Go 复制代码
type NodeValue interface {
	Len() int
}

为了通用性,我们允许值是实现了 NodeValue 接口的任意类型,该接口只包含了一个方法 Len() int,用于返回值所占用的内存大小。

我们定义结构体entry。键值对 entry 是双向链表节点的值的数据类型,在链表中仍保存每个值对应的 key 的好处在于,淘汰队尾节点时,需要用 key 从字典中删除对应的映射。

Go 复制代码
//代表双向链表节点的数据类型
type entry struct {
	key   string
	value NodeValue
}
其整体数据结构
  • 字典的定义是 map[string]*list.Element,键是字符串,值是双向链表中对应节点的指针。
  • maxBytes 是允许使用的最大内存,nbytes 是当前已使用的内存,OnEvicted 是某条记录被移除时的回调函数,可以为 nil。
Go 复制代码
type Cache struct {
	maxBytes  int64      //允许的能使用的最大内存
	nbytes    int64      //已使用的内存
	ll        *list.List //双向链表
	cache     map[string]*list.Element
	OnEvicted func(key string, value NodeValue)
}

//list节点Element结构体源码
// Element is an element of a linked list.
type Element struct {
	next, prev *Element
	// The list to which this element belongs.
	list *List
	// The value stored with this element.
	Value any
}

list源码的Element结构体中的Value是any泛型,可以存储我们之前定义的*entry****类型。

链表的结构就如下图所示:Element结构体内的Value变量类型就是*entry。

定义初始化缓存函数
Go 复制代码
func New(maxBytes int64, onEvicted func(string, Value)) *Cache {
	return &Cache{
		maxBytes:  maxBytes,
		ll:        list.New(),
		cache:     make(map[string]*list.Element),
		OnEvicted: onEvicted,
	}
}
定义添加/修改功能
Go 复制代码
func (c *Cache) Add(key string, value NodeValue) {
	if ele, ok := c.cache[key]; ok {
		c.ll.MoveToFront(ele)
		kv := ele.Value.(*entry)
		c.nbytes += int64(value.Len()) - int64(kv.value.Len())
		kv.value = value
	} else { //还没存在该数据的情况
		ele := c.ll.PushFront(&entry{key, value})
		c.cache[key] = ele
		c.nbytes += int64(len(key)) + int64(value.Len())
	}

	for c.maxBytes != 0 && c.maxBytes < c.nbytes {
		c.RemoveOldest()
	}
}

主要是三步

  • 如果键存在,则更新对应节点的值,并将该节点移到队尾。
  • 不存在则是新增场景,首先队尾添加新节点 &entry{key, value}, 并在字典中添加 key 和该节点的映射关系。
  • 更新 c.nbytes,如果超过了设定的最大值 c.maxBytes,则移除最少访问的节点。

第四行的ele.Value.(*entry)是类型转换的意思。ele.Value是any泛型,需要转换成*entry类型。

Go语言中使用接口断言(type assertions)将接口转换成另外一个接口,也可以将接口转换为另外的类型。类型断言是一个使用在接口值上的操作。语法上它看起来像 i.(T) 被称为断言类型,这里 i 表示一个接口的类型和 T 表示一个类型。

Go 复制代码
t := i.(T)
定义查找功能

主要有 2 个步骤,第一步是从字典中找到对应的双向链表的节点,第二步,将该节点移动到队首。

Go 复制代码
func (c *Cache) Get(key string) (v NodeValue, ok bool) {
	if ele, ok := c.cache[key]; ok {
		c.ll.MoveToFront(ele) //取到该元素,其成为了热点数据, 所以要往队首放
		kv := ele.Value.(*entry)
		return kv.value, true
	}
	return
}
删除功能

实际上是缓存淘汰。即移除最近最少访问的节点(队尾)。

Go 复制代码
func (c *Cache) RemoveOldest() {
	ele := c.ll.Back()
	if ele == nil {
		return
	}

	c.ll.Remove(ele)
	kv := ele.Value.(*entry)
	delete(c.cache, kv.key) //从map中删除
	c.nbytes -= int64(len(kv.key)) + int64(kv.value.Len())

	if c.OnEvicted != nil {
		c.OnEvicted(kv.key, kv.value) //执行被删除时的回调函数
	}
}

为了方便测试,我们实现 Len() 用来获取添加了多少条数据。

Go 复制代码
// Len the number of cache entries
func (c *Cache) Len() int {
	return c.ll.Len()
}

测试

**尝试添加几条数据,测试 Get 方法。**go test -run TestGet

Go 复制代码
//该类型实现了NodeValue接口
type String string

func (d String) Len() int {
	return len(d)
}

func TestGet(t *testing.T) {
	lru := New(0, nil)
	lru.Add("key1", String("abc"))
    //String(v.(String)), v是NodeValue接口类型,需要进行类型转换
	if v, ok := lru.Get("key1"); !ok || String(v.(String)) != "abc" {
		t.Fatalf("cache hit key1=1234 failed")
	}

	if _, ok := lru.Get("key2"); ok {
		t.Fatalf("cache miss key2 failed")
	}
}

测试,当使用内存超过了设定值时,是否会触发"无用"节点的移除:

go test -run TestRemoveoldest

Go 复制代码
func TestRemoveoldest(t *testing.T) {
	k1, k2, k3 := "k1", "k2", "k3"
	v1, v2, v3 := "v1", "v2", "v3"

	cap := len(k1 + k2 + v1 + v2)
	lru := New(int64(cap), nil)
	lru.Add(k1, String(v1))
	lru.Add(k2, String(v2))
	lru.Add(k3, String(v3))
	//容量只够k1和k2,现在也添加了k3,那需要把k1删除
	if _, ok := lru.Get("k1"); ok || lru.Len() != 2 {
		t.Fatalf("Removeoldest key1 failed")
	}
}

完整代码:https://github.com/liwook/Go-projects/tree/main/go-cache/1-lru

相关推荐
幼儿园老大*3 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
AaVictory.5 小时前
Android 开发 Java中 list实现 按照时间格式 yyyy-MM-dd HH:mm 顺序
android·java·list
水月梦镜花6 小时前
redis:list列表命令和内部编码
数据库·redis·list
童先生10 小时前
Go 项目中实现类似 Java Shiro 的权限控制中间件?
开发语言·go
幼儿园老大*11 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
架构师那点事儿16 小时前
golang 用unsafe 无所畏惧,但使用不得到会panic
架构·go·掘金技术征文
阿洵Rain1 天前
【C++】哈希
数据结构·c++·算法·list·哈希算法
于顾而言1 天前
【笔记】Go Coding In Go Way
后端·go
qq_172805591 天前
GIN 反向代理功能
后端·golang·go
黎明晓月1 天前
Java之字符串分割转换List
java·windows·list