LRU 缓存淘汰算法

LRU是什么

我们知道内存中的读写速度很快,基于此很多缓存技术都喜欢将数据存在内存中,但是内存空间是有限的,到达一定量后必然需要将一些不常用的缓存数据删除或者落盘。于是顺应而生了很多缓存淘汰算法。其中比较常见的有FIFO、LFU、LRU、LRU-k、2Q算法等等...其目的都是为了高效的维护缓存数据。

缓存淘汰算法

当内存占用快满时,要删除哪些缓存数据呢?假如刚删除的缓存数据,下一秒就被访问到,这时只能重新去磁盘读到缓存,大大浪费了时间。所以根据合适的场景选择合适缓存算法很关键。

先来看FIFO,先进先出算法,这和队列是一样的。每次将最新缓存的数据放在队尾,需要删除缓存时,就将队头的数据出队即可。这意味着每次删除的都是较老的数据。如果我们的业务场景经常要用到老数据,就不适合用FIFO。

再看LFU,它本质是记录每个缓存数据的使用次数,需要淘汰时,将最少使用的缓存删除。这是最符合我们习惯的淘汰方式,如果一个数据很久都不用了那就淘汰,看上去很合理,它有哪些缺点呢?
从业务场景看,如果访问的数据是比较随机的,或者随着时间推移,频率高的缓存不再被访问,这就不适合使用LFU。
从算法实现看,我们需要记录数据使用的频率,并且在删除时还需要找到最少使用的数据。这意味着我们需要经常对缓存数据排序,这对CPU性能的消耗是不可忽视的。

最后来看LRU,它的核心是:对于已经近期访问过的数据,我们假设它还有很大的可能会再次被访问。如果 假设我们的内存容量为3,对于 1 2 1 3 1 4这串数字,按照顺序将他们存入缓存,1 2 3都能存下,当4达来时,应该删除1 2 3中的谁呢?答案是删除2,从右往左看是4 1 3 1 2...这意味着2是很老的数据,它被访问的可能性不大,所以要将它删除。

我们实现LRU时,要维护一个队列,第一次访问的数据直接入队,重复访问的缓存,将该数据移至队尾,需要删除时删除队头的数据,这样就能保持队列越往后,数据再次被访问的可能性就越大。

对于LRU,它非常适合存储热点数据,例如热搜,最近一段时间会有大量的访问,但是过几个小时可能数据访问量就会大大减少。我们使用LRU算法,能很快地实现状态迁移,因为每被访问一次就算是最新鲜的数据,LRU变化状态是非常快的。对比LFU来看,LFU就不适合存储热点数据,缓存迁移会非常的满,旧缓存很难被清除掉。

由于旧缓存影响了新数据的缓存,这种情况我们称之为“缓存污染”。

LRU的缺点

LRU可谓成也萧何败也萧何,它缓存变换之快是它的优点也是它的缺点。因为只需要一次访问就能成为最新鲜的数据,当出现很多偶发数据时,这些偶发的数据也会被当作最新鲜的,从而成为缓存。但其实这些偶发数据以后并不会是被经常访问的。

由此诞生了LRU-K算法,简单理解LRU-k就是访问次数达到k次后,才会将它算作最新鲜的数据放入队尾,也就是减缓了LRU的缓存切换速度。大部分情况下,LRU-2算法会更普适一些。(LRU-2算法的优化称为2Q算法)

LRU的实现

缓存本身使用双向链表,每个kv作为缓存节点存入链表,同时使用map实现链表的索引,索引可以通过k找到缓存节点。
每次命中缓存,该缓存节点移至队尾;每次淘汰缓存,将队头的缓存节点删除;kv不在缓存中,将其作为新的缓存节点加入队尾。

package mem

import (
	"container/list"
)

/*
	实现LRU缓存淘汰机制
*/
type Cache struct {
	maxBytes int64 // 允许的最大内存
	nBytes   int64 // 当前占用的内存
	ll       *list.List
	cache    map[string]*list.Element // element存储*entry类型

	// 缓存淘汰回调函数
	OnEvicted func(key string, value Value)
}

type entry struct {
	key   string
	value Value
}

type Value interface {
	Len() int64 // 获取value类型所占字节个数
}

func NewCache(maxBytes int64, onEvicted func(key string, value Value)) *Cache {
	return &Cache{
		maxBytes:  maxBytes,
		nBytes:    0,
		ll:        list.New(),
		cache:     make(map[string]*list.Element),
		OnEvicted: onEvicted,
	}
}

// 命中缓存
func (c *Cache) Get(key string) (Value, bool) {
	if ele, ok := c.cache[key]; ok {
		kv := ele.Value.(*entry)
		c.ll.MoveToFront(ele) // 调整缓存节点到队尾
		return kv.value, true
	}
	return nil, false
}

// 覆盖写缓存
func (c *Cache) Put(key string, value Value) {

	// 如果加入后超出缓存最大限制,需要先淘汰一部分缓存
	size := int64(len(key)) + value.Len()
	for c.maxBytes != 0 && c.nBytes+size > c.maxBytes {
		c.RemoveOldest()
	}

	// kv覆盖写入
	if ele, ok := c.cache[key]; ok {

		// 已经存在相同key,将缓存节点的value更新
		kv := ele.Value.(*entry)
		kv.value = value

		// 缓存节点移至队尾
		c.ll.MoveToFront(ele)

		// 更新已使用的内存容量
		c.nBytes += value.Len() - kv.value.Len()
	} else {

		// 新的kv,将缓存节点加入队尾
		ele := c.ll.PushFront(&entry{key: key, value: value})

		// 更新索引
		c.cache[key] = ele

		// 更新已使用的内存容量
		c.nBytes += size
	}
}

// 淘汰缓存
func (c *Cache) RemoveOldest() {
	ele := c.ll.Back()
	if ele != nil {

		// 删除缓存节点
		c.ll.Remove(ele)

		// 删除缓存索引
		kv := ele.Value.(*entry)
		delete(c.cache, kv.key)

		// 更新已使用的内存容量
		c.nBytes -= int64(len(kv.key)) + kv.value.Len()

		// 回调函数
		if c.OnEvicted != nil {
			c.OnEvicted(kv.key, kv.value)
		}
	}
}

// 缓存节点个数
func (c *Cache) Len() int {
	return c.ll.Len()
}

posted @ 2021-11-29 16:25  moon_orange  阅读(128)  评论(0编辑  收藏  举报