缓存淘汰策略: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 的实现不是并发安全的。



相关文章
|
1月前
|
canal 缓存 NoSQL
Redis常见面试题(一):Redis使用场景,缓存、分布式锁;缓存穿透、缓存击穿、缓存雪崩;双写一致,Canal,Redis持久化,数据过期策略,数据淘汰策略
Redis使用场景,缓存、分布式锁;缓存穿透、缓存击穿、缓存雪崩;先删除缓存还是先修改数据库,双写一致,Canal,Redis持久化,数据过期策略,数据淘汰策略
Redis常见面试题(一):Redis使用场景,缓存、分布式锁;缓存穿透、缓存击穿、缓存雪崩;双写一致,Canal,Redis持久化,数据过期策略,数据淘汰策略
|
25天前
|
缓存 Python
在Python中,`functools`模块提供了一个非常有用的装饰器`lru_cache()`,它实现了最近最少使用(Least Recently Used, LRU)缓存策略。
在Python中,`functools`模块提供了一个非常有用的装饰器`lru_cache()`,它实现了最近最少使用(Least Recently Used, LRU)缓存策略。
|
1月前
|
缓存 算法 API
深入理解后端开发中的缓存策略
【7月更文挑战第15天】缓存是提高后端系统性能和扩展性的关键机制之一。本文将深入探讨后端开发中缓存的应用,包括缓存的基本原理、类型、以及在实际应用中的策略。我们将从缓存的定义开始,逐步介绍缓存在数据库查询、API响应和分布式系统中的优化作用。通过实例分析常见的缓存模式,如LRU、LFU和FIFO,并讨论它们在不同场景下的适用性。最后,文章还将涵盖缓存一致性问题和解决方案,帮助读者构建高效且可靠的后端系统。
|
29天前
|
存储 缓存 分布式计算
高并发架构设计三大利器:缓存、限流和降级问题之缓存的应对策略问题如何解决
高并发架构设计三大利器:缓存、限流和降级问题之缓存的应对策略问题如何解决
|
8天前
|
存储 缓存 算法
Python 从零开始实现一个简单的LRU缓存
Python 从零开始实现一个简单的LRU缓存
17 0
|
1月前
|
存储 设计模式 缓存
探索微服务架构下的缓存策略
【7月更文挑战第18天】在微服务架构的海洋中,缓存策略如同指南针,指引着系统性能的优化方向。本文将深入探讨微服务环境下缓存的有效应用,从缓存的基本概念出发,到微服务架构中缓存的特殊需求,再到实际案例分析,最后讨论缓存一致性与失效策略,旨在为开发者提供一套完整的缓存解决方案框架。
|
12天前
|
缓存 监控 Go
[go 面试] 缓存策略与应对数据库压力的良方
[go 面试] 缓存策略与应对数据库压力的良方
|
29天前
|
开发者 Sentinel 微服务
高并发架构设计三大利器:缓存、限流和降级问题之降级策略中的有限状态机的三种状态切换的问题如何解决
高并发架构设计三大利器:缓存、限流和降级问题之降级策略中的有限状态机的三种状态切换的问题如何解决
|
1月前
|
存储 缓存 算法
Java面试题:给出代码优化的常见策略,如减少对象创建、使用缓存等。
Java面试题:给出代码优化的常见策略,如减少对象创建、使用缓存等。
41 0
|
23天前
|
缓存 NoSQL Java
Redis 缓存与数据库数据不一致问题
Redis 缓存与数据库数据不一致问题
51 3