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

相关推荐
宾燕哥哥1 小时前
Go 语言基础学习文档
go
Way2top2 小时前
Go语言动手写Web框架 - Gee第一天
go
探索云原生3 小时前
Buildah 简明教程:让镜像构建更轻量,告别 Docker 依赖
linux·docker·云原生·go·cicd
悟能不能悟4 小时前
Java 中将 List 中对象的某一列转换为 Set
java·开发语言·list
越千年4 小时前
工作中常用到的二进制运算
后端·go
踏浪无痕10 小时前
信不信?一天让你从Java工程师变成Go开发者
后端·go
卡尔特斯1 天前
Go 语言入门核心概念总结
go
代码扳手1 天前
从0到1揭秘!Go语言打造高性能API网关的核心设计与实现
后端·go·api
未来魔导2 天前
go语言中json操作总结(下)
数据分析·go·json
45288655上山打老虎2 天前
List容器
数据结构·windows·list