缓存淘汰策略:LRU 的设计与实现

简介: 缓存淘汰策略:LRU 的设计与实现

缓存基本概念


所谓缓存,通常是指使用一种比原始存储器响应速度更高的存储器。使用这种存储器可以提高数据的访问性能,进而提高系统的整体响应性。

但缓存并非毫无缺点,缓存的价格通常要比同等容量的存储器昂贵一个级别。

在计算机体系中,最常见的存储介质是磁盘,它的存储容量很大,并且比较廉价,但运行很慢。而内存是一种速度更快的存储设备,但价格较贵,而且容量非常有限。

比如 1 条普通的 8G DDR4 内存条价格在 200-300 元左右,这和 1 块 1T 容量的机械硬盘价格不相上下。所以缓存的容量并非可以毫无限制的挥霍,我们必须合理设置缓存的大小,最大化的利用资源。

image.png

image.png

我们常用的数据存储中间件 MySQL 的数据就是存储在磁盘中,缓存中间件 Redis 则是将数据存储在内存中,所以 Redis 可以帮我们提高访问数据的速度。

缓存淘汰策略通常是指在有限的存储空间中,当新加入缓存,而原本的缓存空间已满时,应该淘汰掉哪些数据的策略。


缓存淘汰算法


常见的缓存淘汰算法有 LFU、LRU、ARC、MRU、NMRU、FIFO、2Q 几种,下面简单介绍一下它们。

LFU(Least Frequently Used): 最近最不常用算法,根据数据的历史访问频率来淘汰数据。

LRU(Least Recently User):  最近最少使用算法,根据数据的历史访问记录来进行淘汰数据。

ARC(Adaptive Replacement Cache): 自适应缓存替换算法,它结合了LRU与LFU,来获得可用缓存的最佳使用。

MRU: 最近使用算法,最先移除最近常用的数据。

NMRU: 非最近使用算法,在最近没有使用的内容中随机选择一个作为替换对象。

FIFO(First in First out): 先进先出算法,最先进入的数据,最先被淘汰。

2Q(Two queues): 有两个缓存队列,一个是 FIFO 队列,一个是 LRU 队列。当数据第一次访问时,2Q 算法将数据缓存在 FIFO 队列里面,当数据第二次被访问时,则将数据从 FIFO 队列移到 LRU 队列里面,两个队列各自按照自己的方法淘汰数据。

缓存淘汰算法非常多,今天我们主要来聊聊其中非常常见的 LRU 算法。image.png


LRU 实现思路


LeetCode 第 146 题要求设计并实现 LRU,不过 LeetCode 的操作仅有 put 和 get 两种,在实际中可能不会如此简单,下面我又增加了 GetAll 和 Del 操作。

LRU 的接口定义如下:


type Key interface{}
type Value interface{}
type Node {
    Key Key
    Value Value
}
type LRU interface {
    Add(key Key, value Value) error
    Get(key Key) (value Value, ok bool)
    GetAll()([]*Node)
    Del(key Key)
}


哈希表加数组


最简单的思路是使用一个数组作为底层的数据存储结构,再利用一个哈希表存储 Key 所对应的下标。

下面是代码实现:


package lru
import (
  "errors"
  "sync"
)
type LRU struct {
  mu   *sync.Mutex
  keys map[Key]int
  l    []*Node
  max  int
}
func New(max int) *LRU {
  return &LRU{
    mu:   new(sync.Mutex),
    l:    make([]*Node, 0),
    max:  max,
    keys: make(map[Key]int, max),
  }
}
func (l *LRU) Add(key Key, value Value) error {
  if l.l == nil {
    return errors.New("not init lru")
  }
  if l.max <= 0 {
    return nil
  }
  l.mu.Lock()
  defer l.mu.Unlock()
  if idx, ok := l.keys[key]; ok {
    for i, el := range l.l {
      if i > idx {
        l.l[i-1] = el
      }
    }
    v := &Node{key, value}
    l.l[len(l.l)-1] = v
    return nil
  }
  if len(l.l) == l.max {
    for i, el := range l.l {
      if i == 0 {
        continue
      }
      l.l[i-1] = el
    }
    l.l[len(l.l)-1] = &Node{key, value}
    for k, v := range l.keys {
      if v == 0 {
        delete(l.keys, k)
        continue
      }
      l.keys[k] = v - 1
    }
    l.keys[key] = len(l.l) - 1
    return nil
  }
  l.l = append(l.l, &Node{Key: key, Value: value})
  l.keys[key] = len(l.l) - 1
  return nil
}
func (l *LRU) Get(key Key) (value Value, ok bool) {
  if l.keys == nil {
    return nil, false
  }
  idx, ok := l.keys[key]
  v := l.l[idx]
  if !ok {
    return nil, ok
  }
  for i, v := range l.l {
    if i > idx {
      l.l[i-1] = v
    }
  }
  l.l[len(l.l)-1] = v
  return v.Value, ok
}
func (l *LRU) GetAll() []*Node {
  l.mu.Lock()
  defer l.mu.Unlock()
  return l.l
}
func (l *LRU) Del(key Key) {
  l.mu.Lock()
  defer l.mu.Unlock()
  idx, ok := l.keys[key]
  if !ok {
    return
  }
  delete(l.keys, key)
  for i, el := range l.l {
    if i > idx {
      l.l[i-1] = el
    }
  }
}

直观来看,每次查询的时间复杂度应该为 O(1),插入、删除的时间复杂度都为 O(n)。但是由于查询时会将该元素之后的所有元素下标 -1,再将查询的值放置在数组的末尾,所以查询的时间复杂度也是 O(n)。

操作 时间复杂度
插入 O(n)
删除 O(n)
查询 O(n)
重排 O(n)


哈希表加链表


既然哈希表加数组的算法时间复杂度很高,性能不理想,那么可以尝试使用链表来提高性能。由于链表的特性,可以保证只有在查询数据时时间复杂度是 O(n),其它情况时间复杂度都是 O(1)。

但使用单纯的链表来实现,查询速度依然很慢,因为它会遍历整个链表。我们可以换一种思路,使用哈希表来存储一份节点的指针,用于查询。使用链表来存储一份数据,用于更新。

每次插入数据时将数据放置在链表的头部,每次查询数据,就把数据移动到链表头部,当链表满时丢弃链表尾部的数据。

这样看来,我们可以做到查询和更新的时间复杂度都是 O(1)。

下面是代码实现:


package lru
import (
  "container/list"
  "github.com/pkg/errors"
  "sync"
)
type LRUCache struct {
  max   int
  l     *list.List
  Call  func(key interface{}, value interface{})
  cache map[interface{}]*list.Element
  mu    *sync.Mutex
}
type Key interface{}
type Value interface{}
type Node struct {
  Key   Key
  Value Value
}
func New(max int) *LRUCache {
  return &LRUCache{
    max:   max,
    l:     list.New(),
    cache: make(map[interface{}]*list.Element, max),
    mu:    new(sync.Mutex),
  }
}
func (l *LRUCache) Add(key interface{}, value interface{}) error {
  if l.l == nil {
    return errors.New("not init lru")
  }
  l.mu.Lock()
  if e, ok := l.cache[key]; ok {
    e.Value.(*Node).Value = value
    l.l.MoveToFront(e)
    l.mu.Unlock()
    return nil
  }
  element := l.l.PushFront(&Node{
    key, value,
  })
  l.cache[key] = element
  if l.max != 0 && l.l.Len() > l.max {
    if e := l.l.Back(); e != nil {
      l.l.Remove(e)
      node := e.Value.(*Node)
      delete(l.cache, node.Key)
      if l.Call != nil {
        l.Call(node.Key, node.Value)
      }
    }
  }
  l.mu.Unlock()
  return nil
}
func (l *LRUCache) GetAll() []*Node {
  l.mu.Lock()
  var data []*Node
  for _, v := range l.cache {
    data = append(data, v.Value.(*Node))
  }
  l.mu.Unlock()
  return data
}
func (l *LRUCache) Get(key interface{}) (value interface{}, ok bool) {
  if l.cache == nil {
    return nil, false
  }
  l.mu.Lock()
  if element, ok := l.cache[key]; ok {
    l.l.MoveToFront(element)
    l.mu.Unlock()
    return element.Value.(*Node).Value, true
  }
  l.mu.Unlock()
  return nil, false
}
func (l *LRUCache) Del(key interface{}) {
  if l.cache == nil {
    return
  }
  l.mu.Lock()
  if element, ok := l.cache[key]; ok {
    delete(l.cache, element)
    if e := l.l.Back(); e != nil {
      l.l.Remove(e)
      delete(l.cache, key)
      if l.Call != nil {
        node := e.Value.(*Node)
        l.Call(node.Key, node.Value)
      }
    }
  }
  l.mu.Unlock()
}


第三方库


除了上述的实现,也有一些成熟的第三方缓存库,其中大都带有 LRU 的实现,可以开箱即用。

比如 golang/groupcacheallegro/bigcachecoocood/freecache

但是需要注意,其中有一些 LRU 的实现不是并发安全的。



相关文章
|
30天前
|
缓存 算法 数据挖掘
深入理解缓存更新策略:从LRU到LFU
【10月更文挑战第7天】 在本文中,我们将探讨计算机系统中缓存机制的核心——缓存更新策略。缓存是提高数据检索速度的关键技术之一,无论是在硬件还是软件层面都扮演着重要角色。我们会详细介绍最常用的两种缓存算法:最近最少使用(LRU)和最少使用频率(LFU),并讨论它们的优缺点及适用场景。通过对比分析,旨在帮助读者更好地理解如何选择和实现适合自己需求的缓存策略,从而优化系统性能。
42 3
|
14天前
|
存储 缓存 监控
利用 Redis 缓存特性避免缓存穿透的策略与方法
【10月更文挑战第23天】通过以上对利用 Redis 缓存特性避免缓存穿透的详细阐述,我们对这一策略有了更深入的理解。在实际应用中,我们需要根据具体情况灵活运用这些方法,并结合其他技术手段,共同保障系统的稳定和高效运行。同时,要不断关注 Redis 缓存特性的发展和变化,及时调整策略,以应对不断出现的新挑战。
46 10
|
10天前
|
Web App开发 缓存 UED
如何设置浏览器的缓存策略?
【10月更文挑战第23天】通过合理地设置浏览器的缓存策略,可以在提高网页性能、减少网络流量的同时,确保用户能够获取到最新的内容,从而提升用户体验和网站的性能优化效果。
40 4
|
11天前
|
存储 消息中间件 缓存
缓存策略
【10月更文挑战第25天】在实际应用中,还需要不断地监控和调整缓存策略,以适应系统的变化和发展。
|
14天前
|
缓存 监控 NoSQL
Redis 缓存穿透及其应对策略
【10月更文挑战第23天】通过以上对 Redis 缓存穿透的详细阐述,我们对这一问题有了更深入的理解。在实际应用中,我们需要根据具体情况综合运用多种方法来解决缓存穿透问题,以保障系统的稳定运行和高效性能。同时,要不断关注技术的发展和变化,及时调整策略,以应对不断出现的新挑战。
35 4
|
18天前
|
存储 缓存 NoSQL
保持HTTP会话状态:缓存策略与实践
保持HTTP会话状态:缓存策略与实践
|
1月前
|
存储 缓存 监控
|
1月前
|
缓存 分布式计算 NoSQL
大数据-47 Redis 缓存过期 淘汰删除策略 LRU LFU 基础概念
大数据-47 Redis 缓存过期 淘汰删除策略 LRU LFU 基础概念
61 2
|
1月前
|
存储 缓存 NoSQL
数据的存储--Redis缓存存储(一)
数据的存储--Redis缓存存储(一)
|
1月前
|
存储 缓存 NoSQL
数据的存储--Redis缓存存储(二)
数据的存储--Redis缓存存储(二)
数据的存储--Redis缓存存储(二)