一、谈一谈缓存
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的队列操作?不妨让我们画个图来体会一下:
似乎这样比较不错,采用
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()
}
maxBytes
、nBytes
、OnEvicted
不过多解释,看注释即可明白。下面来说一下其他的内容:
ll
:go语言中的双向链表,如上图画的一样,用链表来存储key对应的缓存项;
cache
:如预计的一样,采用map的形式定义,用于通过缓存的key值快速查找对应缓存项在链表中的位置,value的类型为双向链表节点的指针;
Value
:一个实现了Len()的接口,作为缓存项的值;
entry
:缓存项,即缓存真正存储的key和value。
有点不理解?没关系,这个地方也卡了我很长一段时间,因此我画了个图来帮助自己理解:
首先看最外层的大
Cache
,它里面包含着maxBytes
、nBytes
、OnEvicted
以及小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
}
即对应着这张图:
非常简洁直观,也不用套那么多层,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
绑定在一起,可以很方便地实现以下几个操作:
- 获取缓存项:通过
cache
中的 key 获取到entry
,并且在ll
中将对应的节点移动到链表头部,表示该元素是最近访问的。 - 设置缓存项:在
cache
中增加一个key-value
对,同时在ll
中新增一个节点对entry
。 - 删除缓存项:在
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)。至此,我们完成了单机版的并发缓存框架。