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



相关文章
|
7月前
|
缓存 负载均衡 网络协议
电商API接口性能优化技术揭秘:缓存策略与负载均衡详解
电商API接口性能优化是提升系统稳定性和用户体验的关键。本文聚焦缓存策略与负载均衡两大核心,详解其在电商业务中的实践。缓存策略涵盖本地、分布式及CDN缓存,通过全量或部分缓存设计和一致性维护,减少后端压力;负载均衡则利用反向代理、DNS轮询等技术,结合动态调整与冗余部署,提高吞吐量与可用性。文中引用大型及跨境电商平台案例,展示优化效果,强调持续监控与迭代的重要性,为电商企业提供了切实可行的性能优化路径。
|
8月前
|
缓存 搜索推荐 CDN
HTTP缓存策略的区别和解决的问题
总的来说,HTTP缓存策略是一种权衡,需要根据具体的应用场景和需求来选择合适的策略。理解和掌握这些策略,可以帮助我们更好地优化网页性能,提高用户的浏览体验。
243 11
|
10月前
|
数据采集 缓存 JavaScript
数据抓取的缓存策略:减少重复请求与资源消耗
本教程聚焦于提升爬虫效率与稳定性,通过结合缓存策略、代理IP技术(如爬虫代理)、Cookie和User-Agent设置,优化数据采集流程。以知乎为例,详细讲解如何抓取指定关键词的文章标题和内容。内容涵盖环境准备、代码实现、常见问题及解决方案,并提供延伸练习,帮助读者掌握高效爬虫技巧。适合具备Python基础的初学者,助你规避网站机制,顺利获取目标数据。
311 2
数据抓取的缓存策略:减少重复请求与资源消耗
|
7月前
|
存储 缓存
.NET 6中Startup.cs文件注入本地缓存策略与服务生命周期管理实践:AddTransient, AddScoped, AddSingleton。
记住,选择正确的服务生命周期并妥善管理它们是至关重要的,因为它们直接影响你的应用程序的性能和行为。就像一个成功的建筑工地,工具箱如果整理得当,工具选择和使用得当,工地的整体效率将会大大提高。
300 0
|
9月前
|
缓存 NoSQL Go
【LeetCode 热题100】146:LRU 缓存(详细解析)(Go语言版)
本文详细解析了力扣 146 题——LRU 缓存机制的实现方法。通过结合哈希表与双向链表,确保 `get` 和 `put` 操作均在 O(1) 时间内完成。哈希表用于快速查找,双向链表记录访问顺序,支持最近使用数据的高效更新与淘汰。代码以 Go 语言实现,结构清晰,涵盖核心操作如节点移动、插入与删除。此题为面试高频考点,适用于数据缓存、页面置换等场景,掌握后可加深对缓存策略的理解。
511 4
|
Web App开发 缓存 UED
如何设置浏览器的缓存策略?
【10月更文挑战第23天】通过合理地设置浏览器的缓存策略,可以在提高网页性能、减少网络流量的同时,确保用户能够获取到最新的内容,从而提升用户体验和网站的性能优化效果。
1261 60
|
缓存 API C#
C# 一分钟浅谈:GraphQL 中的缓存策略
本文介绍了在现代 Web 应用中,随着数据复杂度的增加,GraphQL 作为一种更灵活的数据查询语言的重要性,以及如何通过缓存策略优化其性能。文章详细探讨了客户端缓存、网络层缓存和服务器端缓存的实现方法,并提供了 C# 示例代码,帮助开发者理解和应用这些技术。同时,文中还讨论了缓存设计中的常见问题及解决方案,如缓存键设计、缓存失效策略等,旨在提升应用的响应速度和稳定性。
205 13
|
存储 缓存 安全
在 Service Worker 中配置缓存策略
Service Worker 是一种可编程的网络代理,允许开发者控制网页如何加载资源。通过在 Service Worker 中配置缓存策略,可以优化应用性能,减少加载时间,提升用户体验。此策略涉及缓存的存储、更新和检索机制。
|
存储 消息中间件 缓存
缓存策略
【10月更文挑战第25天】在实际应用中,还需要不断地监控和调整缓存策略,以适应系统的变化和发展。
|
存储 消息中间件 设计模式
缓存数据一致性策略如何分类?
数据库与缓存数据一致性问题的解决方案主要分为强一致性和最终一致性。强一致性通过分布式锁或分布式事务确保每次写入后数据立即一致,适合高要求场景,但性能开销大。最终一致性允许短暂延迟,常用方案包括Cache-Aside(先更新DB再删缓存)、Read/Write-Through(读写穿透)和Write-Behind(异步写入)。延时双删策略通过两次删除缓存确保数据最终一致,适用于复杂业务场景。选择方案需根据系统复杂度和一致性要求权衡。
446 0