这是我参与「第三届青训营 -后端场」笔记创作活动的的第5篇笔记。
1 介绍
LFU(Least Frequently Used),淘汰缓存中访问频率最低的记录。在 Go 中,结合标准库 container/heap 来实现。
2 核心数据结构
// lfu 是一个 LFU cache。它不是并发安全的。
type lfu struct {
// 缓存最大的容量,单位字节;
// groupcache 使用的是最大存放 entry 个数
maxBytes int
// 当一个 entry 从缓存中移除是调用该回调函数,默认为 nil
// groupcache 中的 key 是任意的可比较类型;value 是 interface{}
onEvicted func(key string, value interface{})
// 已使用的字节数,只包括值,key 不算
usedBytes int
queue *queue
cache map[string]*entry
}
定义queue
type entry struct {
key string
value interface{}
weight int
index int
}
func (e *entry) Len() int {
return cache.CalcLen(e.value) + 4 + 4
}
type queue []*entry
- queue 是一个
entry指针切片; - entry 和 FIFO 中的区别是多了两个字段:weight 和 index;
- weight 表示该 entry 在 queue 中权重(优先级),访问次数越多,权重越高;
- index 代表该 entry 在堆(heap)中的索引;
LFU 算法用最小堆实现。在 Go 中,通过标准库 container/heap 实现最小堆,要求 queue 实现 heap.Interface 接口:
type Interface interface {
sort.Interface
Push(x interface{}) // add x as element Len()
Pop() interface{} // remove and return element Len() - 1.
}
queue的实现:
func (q queue) Len() int {
return len(q)
}
func (q queue) Less(i, j int) bool {
return q[i].weight < q[j].weight
}
func (q queue) Swap(i, j int) {
q[i], q[j] = q[j], q[i]
q[i].index = i
q[j].index = j
}
func (q *queue) Push(x interface{}) {
n := len(*q)
en := x.(*entry)
en.index = n
*q = append(*q, en)
}
func (q *queue) Pop() interface{} {
old := *q
n := len(old)
en := old[n-1]
old[n-1] = nil // avoid memory leak
en.index = -1 // for safety
*q = old[0 : n-1]
return en
}
前三个方法:Len、Less、Swap 是标准库 sort.Interface 接口的方法;后两个方法:Push、Pop 是 heap.Interface 要求的新方法。
至于是最大堆还是最小堆,取决于 Swap 方法的实现:< 则是最小堆,> 则是最大堆。我们这里的需求自然使用最小堆。
该数据结构如下图所示:
实例化一个 LFU 的 Cache,通过 lfu.New() 函数:
// New 创建一个新的 Cache,如果 maxBytes 是 0,表示没有容量限制
func New(maxBytes int, onEvicted func(key string, value interface{})) cache.Cache {
q := make(queue, 0, 1024)
return &lfu{
maxBytes: maxBytes,
onEvicted: onEvicted,
queue: &q,
cache: make(map[string]*entry),
}
}
因为 queue 实际上是一个 slice,避免 append 导致内存拷贝,可以提前分配一个稍大的容量。实际中,如果使用 LFU 算法,为了性能考虑,可以将最大内存限制改为最大记录数限制,这样可以更好地提前分配 queue 的容量。
3 功能实现
新增/修改
// Set 往 Cache 增加一个元素(如果已经存在,更新值,并增加权重,重新构建堆)
func (l *lfu) Set(key string, value interface{}) {
if e, ok := l.cache[key]; ok {
l.usedBytes = l.usedBytes - cache.CalcLen(e.value) + cache.CalcLen(value)
l.queue.update(e, value, e.weight+1)
return
}
en := &entry{key: key, value: value}
heap.Push(l.queue, en)
l.cache[key] = en
l.usedBytes += en.Len()
if l.maxBytes > 0 && l.usedBytes > l.maxBytes {
l.removeElement(heap.Pop(l.queue))
}
}
func (q *queue) update(en *entry, value interface{}, weight int) {
en.value = value
en.weight = weight
heap.Fix(q, en.index)
}
- 如果 key 存在,则更新对应节点的值。这里调用了 queue 的 update 方法:增加权重,然后调用 heap.Fix 重建堆,重建的过程,时间复杂度是 O(log n),其中 n = quque.Len();
- key 不存在,则是新增场景,首先在堆中添加新元素
&entry{key: key, value: value}, 并在 map 中添加 key 和 entry 的映射关系。heap.Push 操作的时间复杂度是 O(log n),其中 n = quque.Len(); - 更新
l.usedBytes,如果超过了设定的最大值l.maxBytes,则移除“无用”的节点,对于 LFU 则是删除堆的 root 节点。 - 如果 maxBytes = 0,表示不限内存,那么不会进行移除操作。
查找
// Get 从 cache 中获取 key 对应的值,nil 表示 key 不存在
func (l *lfu) Get(key string) interface{} {
if e, ok := l.cache[key]; ok {
l.queue.update(e, e.value, e.weight+1)
return e.value
}
return nil
}
查找过程:先从 map 中查找是否存在指定的 key,存在则将权重加 1。这个过程一样会进行堆的重建,因此时间复杂度也是 O(log n)。
删除
// Del 从 cache 中删除 key 对应的元素
func (l *lfu) Del(key string) {
if e, ok := l.cache[key]; ok {
heap.Remove(l.queue, e.index)
l.removeElement(e)
}
}
// DelOldest 从 cache 中删除最旧的记录
func (l *lfu) DelOldest() {
if l.queue.Len() == 0 {
return
}
l.removeElement(heap.Pop(l.queue))
}
func (l *lfu) removeElement(x interface{}) {
if x == nil {
return
}
en := x.(*entry)
delete(l.cache, en.key)
l.usedBytes -= en.Len()
if l.onEvicted != nil {
l.onEvicted(en.key, en.value)
}
}
- Del 实际通过 heap.Remove 进行删除。这个过程一样需要重建堆,因此时间复杂度是 O(log n);
- DelOldest 通过 heap.Pop 得到堆顶(root)元素,这里也是权重最小的(之一),然后将其删除;
获取缓存记录数
可以通过 map 或 queue 获取,因为 queue 实际上是一个切片。
// Len 返回当前 cache 中的记录数
func (l *lfu) Len() int {
return l.queue.Len()
}
4 测试
测试代码如下:
func TestSet(t *testing.T) {
is := is.New(t)
cache := lfu.New(24, nil)
cache.DelOldest()
cache.Set("k1", 1)
v := cache.Get("k1")
is.Equal(v, 1)
cache.Del("k1")
is.Equal(0, cache.Len())
// cache.Set("k2", time.Now())
}
但在淘汰测试时,需要注意下。
func TestOnEvicted(t *testing.T) {
is := is.New(t)
keys := make([]string, 0, 8)
onEvicted := func(key string, value interface{}) {
keys = append(keys, key)
}
cache := lfu.New(32, onEvicted)
cache.Set("k1", 1)
cache.Set("k2", 2)
// cache.Get("k1")
// cache.Get("k1")
// cache.Get("k2")
cache.Set("k3", 3)
cache.Set("k4", 4)
expected := []string{"k1", "k3"}
is.Equal(expected, keys)
is.Equal(2, cache.Len())
}
我们限制内存最多只能容纳两条记录。