简易实现 Go 的缓存框架(上) | 青训营

51 阅读14分钟

一、谈一谈缓存

1.缓存

缓存是一种通过暂存高昂计算代价、高访问时延、高数据冗余度的数据对象,并提供快速访问的技术。它的工作原理是将计算结果、数据等存储在高速存储设备中,如快速读写的内存,以提高数据的访问速度。缓存技术的使用可以比较显著地降低计算开销和访问延迟,同时也能加速系统的响应速度和性能。

说人话就是,比如你要访问一个网站,这个网站的首页堆积了大量的图片,在第一次进入这个首页的时候,往往会加载很大一会才会把所有图片显示全,但当你第二次、第三次访问时,会发现图片的加载速度快多了,这就是缓存在起作用,在你第一次加载的时候,浏览器就默认的将这些图片写入到了缓存中,下次再访问直接从缓存里读取这些图片就可以了,达到了提升响应速度的效果。

2.设计缓存应该考虑的问题

现在我们要设计一个缓存框架,就必须要考虑缓存应该面临的问题:

(1)采用何种数据结构存储缓存?参考Redis数据库,我们可以采用key-value的形式。

(2)缓存容量达到上限怎么办?很简单,删除已经存在缓存中的数据即可,但问题是,怎么删?

​ 是按照时间顺序排列,删除最老的数据?还是根据使用量排列,删除使用量最少的数据?

​ 这两种方法对应着两种算法,分别为:

  • FIFO(First In First Out):先进先出,淘汰缓存中最老的数据。
  • LFU(Least Frequently Used):最少使用,淘汰缓存中访问频率最低的记录。

​ 以上算法在常见场景中的缓存命中率并不高,具体的算法细节不在此赘述。为了快速上手简化操作,我们的框架采用LRU(Least Recently Used)算法,即最近最少使用,具体表现为:如果某条记录被访问了,则移动到队尾,那么队首则是最近最少访问的数据,淘汰该条记录即可。

(3)单机版缓存的性能不够怎么办?很简单,用分布式缓存即可。

(4)并发导致冲突怎么办?像我上篇博客介绍的并发场景一样,对于缓存的操作一般会是多协程并发操作,并发则会带来冲突,因此要用锁来对数据的修改操作进行保护。

3.设计缓存结构

首先我们明确了两点:1.仿照Redis使用key-value的形式;2.采用LRU算法,以队列的形式操作。

但怎么样将这两者进行一个有机的结合呢,既能是key-value的形式,又能实现LRU的队列操作?不妨让我们画个图来体会一下: key-value.jpg 似乎这样比较不错,采用map的形式定义,key就是缓存对应的key,value是各个key对应的缓存项,同时value与value之间以双向链表的方式连接,形成一个队列,能够完成LRU的操作。

但极客兔兔给的代码远不止这样,请先来看lru/lru.go

type Cache struct {
	maxBytes  int64                         //缓存允许的最大内存容量
	nBytes    int64                         //当前已使用的内存容量
	ll        *list.List                    //双向链表,用来存储缓存的所有数据
	cache     map[string]*list.Element      //表示缓存中存储的所有键值对,键是字符串类型,值是指向entry的指针
	OnEvicted func(key string, value Value) //在缓存数据被清理时触发的函数
}

type entry struct {
	key   string
	value Value
}

type Value interface {
	Len() int //返回值的尺寸
}
func (c *Cache) Len() int {
	return c.ll.Len()
}

maxBytesnBytesOnEvicted不过多解释,看注释即可明白。下面来说一下其他的内容:

ll:go语言中的双向链表,如上图画的一样,用链表来存储key对应的缓存项;

cache:如预计的一样,采用map的形式定义,用于通过缓存的key值快速查找对应缓存项在链表中的位置,value的类型为双向链表节点的指针;

Value:一个实现了Len()的接口,作为缓存项的值;

entry:缓存项,即缓存真正存储的key和value。

有点不理解?没关系,这个地方也卡了我很长一段时间,因此我画了个图来帮助自己理解: Cache.jpg 首先看最外层的大Cache,它里面包含着maxBytesnBytesOnEvicted以及小cache,这些都不重要,让我们进入小cache的肚子里。小cache是一个key-value形式的map,key就是缓存对应的key,value是各个key对应的缓存项,ll则是双向链表,表示着缓存项与缓存项之间是双向链表的方式连接,但我们一直提到的这个缓存项究竟是什么呢?那就是代码中定义的entry

我真的非常不能理解啊,为什么要map中套entry,entry套key-value这种形式呢,为什么不能利利索索的这样定义:

type Cache struct {
	maxBytes  int64                        
	nBytes    int64                         
	ll        *list.List                    
	cache     map[string]*Value     
	OnEvicted func(key string, value Value) 
}
type Value interface {
	Len() int 
}

即对应着这张图: 不合理的Cache.jpg 非常简洁直观,也不用套那么多层,key就是key,value就是value,根本就没有缓存项entry这一说。于是我开了俩文件,一个按照极客兔兔的定义往下走,一个按照我自己的定义往下走。

为了方便实例化,要为缓存创建New()方法,我会分别给出极客兔兔设计的结构和我设计的结构对应的方法。

极客兔兔lru/lru.go

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

逻辑很清晰,实例化的时候没涉及到entry,所以我也可以做到sanjinlru/lru.go

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

这么看起来我设计的结构没毛病,让我们接着往下看。

4.缓存的查找方法

缓存的查找主要涉及两个步骤:(1)把查找出来的值返回出来;(2)将该值移动到队尾。值得注意的是,我们采用双向链表实现队列,所以对于双向链表来说,头和尾都是相对的,这里极客兔兔约定 front 为队尾,因此有以下代码:

func (c *Cache) Get(key string) (value Value, ok bool) {
	if ele, ok := c.cache[key]; ok {
		c.ll.MoveToFront(ele)
		kv := ele.Value.(*entry)
		return kv.value, true
	}
	return
}

捋了一下极客兔兔的逻辑:先找到key对应的节点,把该节点移动到队尾,对节点进行类型断言为entry指针,返回entry.value的值。

貌似我抛弃了entry的数据结构也可以做到:

func (c *Cache) Get(key string) (value Value, ok bool) {
	if ele, ok := c.cache[key]; ok {
		c.ll.MoveToFront(ele)
		return ele.Value.(Value), true
	}
	return
}

写完后我还沾沾自喜,代码比他少一行,还容易理解。

5.缓存的删除方法

这里的删除,实际上是缓存淘汰。即移除最近最少访问的队首的节点,但在极客兔兔的约定中,删除的是Back位置的节点。

删除同样对应着两个操作:(1)将节点从链表中删除;(2)从map中删除该节点的映射关系。

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)) + int64(kv.value.Len())
		if c.OnEvicted != nil {
			c.OnEvicted(kv.key, kv.value)
		}
	}
}

捋一下极客兔兔的逻辑:拿到队首(链表尾部)的缓存项,从链表中删除该缓存项;将该缓存项断言为entry指针,在map中删除对应的映射关系,接着对当前的缓存大小进行更新,如果有回调函数就执行。

这个逻辑完整没毛病,我也尝试用自己的数据结构来实现:

func (c *Cache) RemoveOldest() {
	ele := c.ll.Back()
	if ele != nil {
		c.ll.Remove(ele)
        delete()  //写不下去了。。。。。
	}
}

写到delete()的时候,我发现自己写不下去了,这一步是要从map中删除节点的映射关系,可是我没有节点对应的key,无法做到删除映射的操作。或许,可以通过一些骚操作拿到key值,但务必要增加时间复杂度,实在是不值。

我不服,生了个闷气,睡了一大觉,醒来以后似懂非懂的明白了中间套一层entry的用意:

通过将 entry*list.Element 绑定在一起,可以很方便地实现以下几个操作:

  1. 获取缓存项:通过 cache 中的 key 获取到 entry,并且在 ll 中将对应的节点移动到链表头部,表示该元素是最近访问的。
  2. 设置缓存项:在 cache 中增加一个 key-value 对,同时在 ll 中新增一个节点对 entry
  3. 删除缓存项:在 cache 中删除指定的 key-value 对,并在 ll 中删除对应的节点。

6.缓存的增新增/修改方法

首先要对传来的key值进行判断,如果当前缓存中存在,就执行修改操作,并将节点移动到队尾;如果不存在,就执行新增操作,即在队尾添加新节点,并在map中添加映射关系。执行完后,对当前缓存的大小进行更新。

func (c *Cache) Add(key string, value Value) {
    // 修改操作
	if ele, ok := c.cache[key]; ok {
        //移动到队尾
		c.ll.MoveToFront(ele)
		kv := ele.Value.(*entry)
        //更新当前缓存的大小
		c.nbytes += int64(value.Len()) - int64(kv.value.Len())
        //修改值
		kv.value = value
        //新增操作
	} else {
        //在队尾添加节点
		ele := c.ll.PushFront(&entry{key, value})
        //添加map的映射关系
		c.cache[key] = ele
        //更新当前缓存的大小
		c.nbytes += int64(len(key)) + int64(value.Len())
	}
    //判断当前缓存和最大缓存
	for c.maxBytes != 0 && c.maxBytes < c.nbytes {
		c.RemoveOldest()
	}
}

我心里仍有一丝执念,还对自己定义的数据结构保留期望,于是也尝试着写了一下这个函数:

func (c *Cache) Add(key string, value Value) {
    //修改操作
	if ele, ok := c.cache[key]; ok {
        //将节点移动到队尾
		c.ll.MoveToFront(ele)
        //修改值(忽略当前内存的更新操作)
		ele.Value = value
	} else {
        //将新节点添加到队尾,下行代码爆红,写不下去了。。。
		ele := c.ll.PushFront(key, value)
	}
}

由于PushFront()这个函数,里面只能传入一个参数,所以我没办法将新增的key和value同时添加进去,唯一的办法就是将key和value封装成一个结构体,将结构体传进去。哦,这他妈不就是entry吗?于是我彻底误了,放弃自己的幼稚想法,转而拥抱极客兔兔的定义。

7.代码整理

可能大家看着这一段一段的代码比较混乱,所以在这个地方我给出所有的代码,lru/lru.go

type Cache struct {
	maxBytes int64
	nBytes   int64
	cache    map[string]*list.Element
	ll        *list.List
	OnEvicted func(key string, value Value)
}

type entry struct {
	key   string
	value Value
}

type Value interface {
	Len() int
}

func (c *Cache) Len() int {
	return c.ll.Len()
}

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

func (c *Cache) Get(key string) (value Value, ok bool) {
	if ele, ok := c.cache[key]; ok {
		c.ll.MoveToFront(ele)
		kv := ele.Value.(*entry)
		return kv.value, true
	}
	return
}

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)) + int64(kv.value.Len())
		if c.OnEvicted != nil {
			c.OnEvicted(kv.key, kv.value)
		}
	}
}

func (c *Cache) Add(key string, value Value) {
	if ele, ok := c.cache[key]; ok {
		c.ll.MoveToFront(ele)
		kv := ele.Value.(*entry)
		c.nBytes += int64(value.Len()) - int64(kv.value.Len())
		kv.value = value
	} else {
		ele := c.ll.PushFront(&entry{
			key:   key,
			value: value,
		})
		c.cache[key] = ele
		c.nBytes += int64(len(key)) + int64(value.Len())
	}
	for c.maxBytes != 0 && c.maxBytes < c.nBytes {
		c.RemoveOldest()
	}
}

唉,这群b人为什么这么聪明,为什么想得到啊!!!

二、单机并发缓存

如我上篇博客所说,想实现安全的并发,那就一定要用锁,所以今天用锁来实现单机版的并发缓存。

我们在下面要定义的内容如下:

ByteView:缓存系统中表示缓存值的数据结构;

cache:缓存系统中实际存储缓存数据的结构;

Group:负责与用户的交互,并且控制缓存值存储和获取的流程。

1.支持并发读写

为了给我们的框架提供一种可靠和高效的内存数据访问和使用方式,同时保证代码的安全性和可重用性,将使用 sync.Mutex 封装 LRU 的几个方法,使之支持并发的读写,请看geecache/byteview,go

type ByteView struct {
	b []byte
}

func (v ByteView) Len() int {
	return len(v.b)
}

func (v ByteView) ByteSlice() []byte {
	return cloneBytes(v.b)
}

func (v ByteView) String() string {
	return string(v.b)
}

func cloneBytes(b []byte) []byte {
	c := make([]byte, len(b))
	copy(c, b)
	return c
}

字段b:一个字节数组,表示ByteView中的数据。选择 byte 类型是为了能够支持任意的数据类型的存储,例如字符串、图片等。

Len():实现了Value中的Len()方法,返回ByteView中数据的长度。

ByteSlice():拷贝一份[]byte并返回,防止缓存值被外部程序修改。

String():将ByteView中的数据转换为字符串,并复制其值。

cloneBytes()函数:将一个字节数组的副本返回给调用者。

接下来就可以为 lru.Cache 添加并发特性了,请看geecache/cache.go

type cache struct {
	mu         sync.Mutex
	lru        *lru.Cache
	cacheBytes int64
}

func (c *cache) add(key string, value ByteView) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.lru == nil {
		c.lru = lru.New(c.cacheBytes, nil)
	}
	c.lru.Add(key, value)
}

func (c *cache) get(key string) (value ByteView, ok bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.lru == nil {
		return
	}

	if v, ok := c.lru.Get(key); ok {
		return v.(ByteView), ok
	}
	return
}

这段代码相当于实例化我们辛辛苦苦琢磨出来的lru,封装get和add方法,并添加互斥锁 mu。值得注意的是,在add()方法中,对c.lru进行了一个空值判断,如果为空,则创建实例,避免了一开始就创建好但一直没有被使用的情况,减少内存占用。

2.主体结构Group

Group 是框架中最核心的数据结构,负责与用户的交互,并且控制缓存值存储和获取的流程。对于缓存的获取,可以参考以下流程: 总流程 接着我们将在geecache/geecache.go中实现流程(1)和(3)。

设想一下,如果缓存不存在,用户应该指定一个方法得到源数据,我们参考http包中的Handler源码:

type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}
type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
	f(w, r)
}

采用同样的接口型函数,实现我们的回调方法:

type Getter interface {
	Get(key string) ([]byte, error)
}

type GetterFun func(key string) ([]byte, error)

func (f GetterFun) Get(key string) ([]byte, error) {
	return f(key)
}

然后就是最核心的Group定义:

type Group struct {
	name      string
	getter    Getter
	mainCache cache
}

var (
	mu     sync.RWMutex
	groups = make(map[string]*Group)
)

func NewGroup(name string, cacheBytes int64, getter Getter) *Group {
	if getter == nil {
		panic("nil Getter")
	}
	mu.Lock()
	defer mu.Unlock()
	g := &Group{
		name:      name,
		getter:    getter,
		mainCache: cache{cacheBytes: cacheBytes},
	}
	groups[name] = g
	return g
}

func GetGroup(name string) *Group {
	mu.RLock()
	g := groups[name]
	mu.RUnlock()
	return g
}

一个 Group 可以认为是一个缓存的命名空间,每个 Group 拥有一个唯一的名称 name。比如可以创建三个 Group,缓存学生的成绩命名为 scores,缓存学生信息的命名为 info,缓存学生课程的命名为 courses。

Group结构体通过NewGroup函数进行实例化,如果getter为空,则会产生一个错误。它还利用了互斥锁确保在创建Group实例时不会引发竞态条件。每个Group对象根据名称存储在全局变量groups的map中,GetGroup函数用于获取指定名字的Group实例,由于它只是一个并发安全的读操作,因此利用了读写锁。

在Group结构体中,Getter接口定义了一个Get方法,该方法用于根据key获取数据,如果缓存中没有该数据,调用Group的getter回调函数进行获取,然后将数据加入缓存并返回。这样,用户就可以通过调用Group实例的Get方法来获取所需的数据,如果缓存中存在数据,则直接从缓存中读取,否则通过getter回调从外部数据源获取数据。

这个设计模式称为“回调模式”,它将不同的模块分开,以实现模块之间的高度解耦。通过该模式,系统在一定程度上实现了“开放封闭原则”,可以方便地添加新的模块而无需修改现有的代码,提高了系统的可扩展性和可维护性。

接下来是 GeeCache 最为核心的方法 Get

func (g *Group) Get(key string) (ByteView, error) {
	if key == "" {
		return ByteView{}, fmt.Errorf("key is required")
	}

	if v, ok := g.mainCache.get(key); ok {
		log.Println("[GeeCache] hit")
		return v, nil
	}

	return g.load(key)
}

func (g *Group) load(key string) (value ByteView, err error) {
	return g.getLocally(key)
}

func (g *Group) getLocally(key string) (ByteView, error) {
	bytes, err := g.getter.Get(key)
	if err != nil {
		return ByteView{}, err

	}
	value := ByteView{b: cloneBytes(bytes)}
	g.populateCache(key, value)
	return value, nil
}

func (g *Group) populateCache(key string, value ByteView) {
	g.mainCache.add(key, value)
}

这段代码实现了我们一开始说的流程(1)和(3)。至此,我们完成了单机版的并发缓存框架。