这一节实现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泛型。每个元素的值的类型都可以不同。
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
具体实现
使用链表的话,就要确定双向链表节点的数据结构和节点的值的数据类型。例如:
//链表节点的结构
type node struct{
next *node
value myvalue
}
//链表节点的值的结构
type myvalue struct{
age int
name string
}
双向链表节点的数据结构是已经确定的了,因为使用了Go中自带的list,其节点结构是Element。确定了节点结构,那就要确定该节点的值的结构。
节点的值的结构entry
因为我们要实时统计存储大小, 所以存储的元素都要能够计算大小。所以我们抽象出如下接口NodeValue:
type NodeValue interface {
Len() int
}
为了通用性,我们允许值是实现了 NodeValue 接口的任意类型,该接口只包含了一个方法 Len() int
,用于返回值所占用的内存大小。
我们定义结构体entry。键值对 entry
是双向链表节点的值的数据类型,在链表中仍保存每个值对应的 key 的好处在于,淘汰队尾节点时,需要用 key 从字典中删除对应的映射。
//代表双向链表节点的数据类型
type entry struct {
key string
value NodeValue
}
其整体数据结构
- 字典的定义是
map[string]*list.Element
,键是字符串,值是双向链表中对应节点的指针。 maxBytes
是允许使用的最大内存,nbytes
是当前已使用的内存,OnEvicted
是某条记录被移除时的回调函数,可以为 nil。
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。
定义初始化缓存函数
func New(maxBytes int64, onEvicted func(string, Value)) *Cache {
return &Cache{
maxBytes: maxBytes,
ll: list.New(),
cache: make(map[string]*list.Element),
OnEvicted: onEvicted,
}
}
定义添加/修改功能
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 表示一个类型。
t := i.(T)
定义查找功能
主要有 2 个步骤,第一步是从字典中找到对应的双向链表的节点,第二步,将该节点移动到队首。
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
}
删除功能
实际上是缓存淘汰。即移除最近最少访问的节点(队尾)。
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()
用来获取添加了多少条数据。
// Len the number of cache entries
func (c *Cache) Len() int {
return c.ll.Len()
}
测试
尝试添加几条数据,测试 Get
方法。go test -run TestGet
//该类型实现了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
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