缓存基本概念
所谓缓存,通常是指使用一种比原始存储器响应速度更高的存储器。使用这种存储器可以提高数据的访问性能,进而提高系统的整体响应性。
但缓存并非毫无缺点,缓存的价格通常要比同等容量的存储器昂贵一个级别。
在计算机体系中,最常见的存储介质是磁盘,它的存储容量很大,并且比较廉价,但运行很慢。而内存是一种速度更快的存储设备,但价格较贵,而且容量非常有限。
比如 1 条普通的 8G DDR4 内存条价格在 200-300 元左右,这和 1 块 1T 容量的机械硬盘价格不相上下。所以缓存的容量并非可以毫无限制的挥霍,我们必须合理设置缓存的大小,最大化的利用资源。
我们常用的数据存储中间件 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 算法。
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/groupcache、allegro/bigcache 和 coocood/freecache。
但是需要注意,其中有一些 LRU 的实现不是并发安全的。