7天用Go从零实现分布式缓存--Day1 LRU 算法实现

21 阅读13分钟

本系列文章全部参考自极客兔兔的《7天用Go从零实现分布式缓存GeeCache》:geektutu.com/post/geecac… 下面的内容均参考自该文章,同时结合了我自己学习过程中的思考,如果存在问题还请大家多多指教。

Day0 分布式缓存简介

1 谈谈分布式缓存

第一次请求时将一些耗时操作的结果暂存,以后遇到相同的请求,直接返回暂存的数据。我想这是大部分童鞋对于缓存的理解。在计算机系统中,缓存无处不在,比如我们访问一个网页,网页和引用的 JS/CSS 等静态文件,根据不同的策略,会缓存在浏览器本地或是 CDN 服务器,那在第二次访问的时候,就会觉得网页加载的速度快了不少;比如微博的点赞的数量,不可能每个人每次访问,都从数据库中查找所有点赞的记录再统计,数据库的操作是很耗时的,很难支持那么大的流量,所以一般点赞这类数据是缓存在 Redis 服务集群中的。

商业世界里,现金为王;架构世界里,缓存为王。

缓存中最简单的莫过于存储在内存中的键值对缓存了。说到键值对,很容易想到的是字典(dict)类型,Go 语言中称之为 map。那直接创建一个 map,每次有新数据就往 map 中插入不就好了,这不就是键值对缓存么?这样做有什么问题呢?

1)内存不够了怎么办?

那就随机删掉几条数据好了。随机删掉好呢?还是按照时间顺序好呢?或者是有没有其他更好的淘汰策略呢?不同数据的访问频率是不一样的,优先删除访问频率低的数据是不是更好呢?数据的访问频率可能随着时间变化,那优先删除最近最少访问的数据可能是一个更好的选择。我们需要实现一个合理的淘汰策略。

2)并发写入冲突了怎么办?

对缓存的访问,一般不可能是串行的。map 是没有并发保护的,应对并发的场景,修改操作(包括新增,更新和删除)需要加锁。

3)单机性能不够怎么办?

单台计算机的资源是有限的,计算、存储等都是有限的。随着业务量和访问量的增加,单台机器很容易遇到瓶颈。如果利用多台计算机的资源,并行处理提高性能就要缓存应用能够支持分布式,这称为水平扩展(scale horizontally)。与水平扩展相对应的是垂直扩展(scale vertically),即通过增加单个节点的计算、存储、带宽等,来提高系统的性能,硬件的成本和性能并非呈线性关系,大部分情况下,分布式系统是一个更优的选择。

4)…

2 关于 GeeCache

设计一个分布式缓存系统,需要考虑资源控制、淘汰策略、并发、分布式节点通信等各个方面的问题。而且,针对不同的应用场景,还需要在不同的特性之间权衡,例如,是否需要支持缓存更新?还是假定缓存在淘汰之前是不允许改变的。不同的权衡对应着不同的实现。

groupcache 是 Go 语言版的 memcached,目的是在某些特定场合替代 memcached。groupcache 的作者也是 memcached 的作者。无论是了解单机缓存还是分布式缓存,深入学习这个库的实现都是非常有意义的。

GeeCache 基本上模仿了 groupcache 的实现,为了将代码量限制在 500 行左右(groupcache 约 3000 行),裁剪了部分功能。但总体实现上,还是与 groupcache 非常接近的。支持特性有:

  • 单机缓存和基于 HTTP 的分布式缓存
  • 最近最少访问(Least Recently Used, LRU) 缓存策略
  • 使用 Go 锁机制防止缓存击穿
  • 使用一致性哈希选择节点,实现负载均衡
  • 使用 protobuf 优化节点间二进制通信

GeeCache 分7天实现,每天完成的部分都是可以独立运行和测试的,就像搭积木一样,每天实现的特性组合在一起就是最终的分布式缓存系统。每天的代码在 100 行左右。

Day1

1 常见的缓存淘汰策略

GeeCache 的缓存全部存储在内存中,内存是有限的,因此不可能无限制地添加数据。假定我们设置缓存能够使用的内存大小为 N,那么在某一个时间点,添加了某一条缓存记录之后,占用内存超过了 N,这个时候就需要从缓存中移除一条或多条数据了。那移除谁呢?我们肯定希望尽可能移除“没用”的数据,那如何判定数据“有用”还是“没用”呢?

FIFO(First In Fisrt Out)

先进先出,也就是淘汰缓存中最老(最早添加)的记录。FIFO 认为,最早添加的记录,其不再被使用的可能性比刚添加的可能性大。这种算法的实现也非常简单,创建一个队列,新增记录添加到队尾,每次内存不够时,淘汰队首。但是很多场景下,部分记录虽然是最早添加但也最常被访问,而不得不因为呆的时间太长而被淘汰。这类数据会被频繁地添加进缓存,又被淘汰出去,导致缓存命中率降低。

LFU(Least Frequently Used)

最少使用,也就是淘汰缓存中访问频率最低的记录。LFU 认为,如果数据过去被访问多次,那么将来被访问的频率也更高。LFU 的实现需要维护一个按照访问次数排序的队列,每次访问,访问次数加1,队列重新排序,淘汰时选择访问次数最少的即可。LFU 算法的命中率是比较高的,但缺点也非常明显,维护每个记录的访问次数,对内存的消耗是很高的;另外,如果数据的访问模式发生变化,LFU 需要较长的时间去适应,也就是说 LFU 算法受历史数据的影响比较大。例如某个数据历史上访问次数奇高,但在某个时间点之后几乎不再被访问,但因为历史访问次数过高,而迟迟不能被淘汰。

LRU(Least Recently Used)

最近最少使用,相对于仅考虑时间因素的 FIFO 和仅考虑访问频率的 LFU,LRU 算法可以认为是相对平衡的一种淘汰算法。LRU 认为,如果数据最近被访问过,那么将来被访问的概率也会更高。LRU 算法的实现非常简单,维护一个队列,如果某条记录被访问了,则移动到队尾,那么队首则是最近最少访问的数据,淘汰该条记录即可。

2 LRU 算法实现

2.1 核心数据结构

这张图很好地表示了 LRU 算法最核心的 2 个数据结构

  • 绿色的是字典(map),存储键和值的映射关系。这样根据某个键(key)查找对应的值(value)的复杂是O(1),在字典中插入一条记录的复杂度也是O(1)
  • 红色的是双向链表(double linked list)实现的队列。将所有的值放到双向链表中,这样,当访问到某个值时,将其移动到队尾的复杂度是O(1),在队尾新增一条记录以及删除一条记录的复杂度均为O(1)

在 GeeCache 中,我们约定:队头是最近最少使用的缓存,队尾是最近使用的缓存。

接下来我们创建一个包含字典和双向链表的结构体类型 Cache,方便实现后续的增删查改操作。

// geecache/lru/lru.go
package lru

import "container/list"

type Cache struct {
        maxBytes  int64                         // 允许使用的最大内存
        nbytes    int64                         // 当前已使用的内存
        ll        *list.List                    // Go 标准库实现的双向链表
        cache     map[string]*list.Element      // 链表里存的不是 key 或 value,而是后面的 entry
        OnEvicted func(key string, value Value) // 某条记录被移除时的回调函数
}

// entry 是链表中存储的内容
type entry struct {
        key   string
        value Value
}

type Value interface {
        Len() int
}

这里我们定一个结构体 Cache,其中:

  • maxBytes:表示允许使用的最大内存
  • nbytes:表示当前已使用的内存
  • ll:Go 标准库实现的双向链表,用于存储后面的 entry
  • cache:一个map,用于实现 key 到 链表的映射
  • OnEvicted:淘汰缓存后,把缓存交出去

关于 OnEvicted 为什么要设计出来,相关解释如下:

LRU 只负责一件事:决定谁该被赶出缓存。 但现实系统里,被赶出去的数据往往“还没完事”。可能还需要:

  • 把热点统计写日志
  • 把脏数据刷回数据库
  • 释放某些占用的外部资源
  • 做监控上报(缓存淘汰频率)

具体要怎么做不管,但是想这么做一定得知道删除了啥数据,因此,设计一个 OnEvicted 钩子函数将删除的数据送出去。

接下来看到 entry,这里存储了 keyvalue。一般来说,直接存储 value(即具体的缓存对象)即可,那么这里为什么需要存储 key 呢?这里的 key 实际上就是 valuekey,和前面 Cache.cache 的这个 mapkey 完全一致。entry 需要存储 key 是因为:当我们需要淘汰一个缓存的时候,我们仅仅淘汰这个链表节点上的数据,那么如果数据中不包含 key,我们后面也就无法删除 Cache.cache 中的 key 了,因此,我们需要将 key 也存储在链表节点中。

然后是 Value,这里我们设计了一个接口。缓存的种类可能是一段字符串、字节数组、JSON结构、图片数据等等,不同类型大小不同,而 GO 也没有统一的对象占用内存API,所以这里我们设计一个接口,只要这个结构实现了 Len() 方法,就算是可以缓存的缓存对象,就可以存到链表中了。

例如字符串:

type String string
func (d String) Len() int { return len(d) }

那么我们就可以将字符串作为缓存存在 Cache.cache 中了。这是一种面向接口的 解耦 设计,缓存根本不关心你是什么类型,只关心你多大。

最后为了方便实例化 Cache,我们需要实现一个 New() 函数:

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

2.2 查找功能

这里所谓的查找功能实际上就是当某个缓存需要被使用的时候,通过 key 找到具体的缓存内容,然后将该节点移动到队尾,下面我们来看看算法的具体实现:

// Get 是当缓存需要被使用的时候,根据 key 查找对应缓存,并将其存储节点移动到队尾
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
}
  • 如果键对应的链表节点存在,则将对应节点移动到队尾,并返回查找到的值。

  • c.ll.MoveToFront(ele),即将链表中的节点 ele 移动到队尾(双向链表作为队列,队首队尾是相对的,在这里约定 front 为队尾)

2.3 删除功能

这里的删除,实际上是缓存淘汰,即移除最近最少访问的节点(队首)。

// RemoveOldest 用于移除最近最少访问的节点(队首)
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.cache 中的映射关系(这里就体现了前面 entry 需要存储 key 的作用了)
                c.nbytes -= int64(len(kv.key)) + int64(kv.value.Len())
                // 如果 onEvicted 不为空,就调用该函数,将删除的值传出去
                if c.OnEvicted != nil {
                        c.OnEvicted(kv.key, kv.value)
                }
        }
}
  • c.ll.Back() 取到队首节点,从链表中删除。

  • delete(c.cache, kv.key),从字典中 c.cache 删除该节点的映射关系。这里就体现了前面定义 entry 结构的时候带上 key 的作用了。

  • 更新当前所用的内存 c.nbytes

  • 如果回调函数 OnEvicted 不为 nil,就调用回调函数,将删除的值传出去。

2.4 新增 / 修改

// Add 用于修改或者更新缓存
func (c *Cache) Add(key string, value Value) {
        // 如果传入的 key 存在于 c.cache 中,就将节点移动到队尾,然后修改元素的值
        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{ // 如果 key 不存在于 c.cache 中,就新建一个节点,然后移动到队尾
                ele := c.ll.PushFront(&entry{key, value})
                c.cache[key] = ele
                c.nbytes += int64(len(key)) + int64(value.Len())
        }
        // 每次新增或者修改元素之后都检查一下当前使用的缓存是否超过了最大缓存,如果超过了,移除队首元素;注意,maxBytes == 0 表示缓存大小不设置上限
        for c.maxBytes != 0 && c.maxBytes < c.nbytes {
                c.RemoveOldest()
        }
}

Add 用于修改或者更新缓存,传入一个键值对 keyvalue

  • 如果键存在,则更新对应节点的值,并将该节点移到队尾。

  • 不存在则是新增场景,首先队尾添加新节点 &entry{key, value}, 并字典中添加 key 和节点的映射关系。

  • 更新 c.nbytes,如果超过了设定的最大值 c.maxBytes,则移除最少访问的节点。

最后,为了方便测试,我们实现 Len() 用来获取添加了多少条数据:

// Len 用于计算 c.ll 的长度,也即链表的长度,所以返回的结果就是缓存的个数
func (c *Cache) Len() int {
        return c.ll.Len() // 这里的 c.ll.Len() 是 GO 标准库内置的方法
}

3 测试

3.1 测试添加与查找

我们可以尝试添加几条数据,测试 Get 方法:

// geecache/lru/lru_test.go
package lru

import "testing"

type String string

func (d String) Len() int {
        return len(d)
}

func TestGet(t *testing.T) {
        lru := New(int64(0), nil)
        lru.Add("key1", String("1234"))
        if v, ok := lru.Get("key1"); !ok || string(v.(String)) != "1234" {
                t.Fatalf("cache hit key1=1234 failed")
        }
        if _, ok := lru.Get("key2"); ok {
                t.Fatalf("cache miss key2 failed")
        }
}

注意,我们在这里做的 type String string 以及 func (d String) Len() int 很重要,并不是多此一举,而是因为我们前面在设计 Cache 结构的时候,Cache.cache (用于存储缓存的 map)中的链表的 value 属性要求满足 Value 接口,而 Value 接口需要实现 Len() 方法,因此这里才需要这样操作,目的就是让 String 实现 Value 接口,从而可以合法存储在 cache.ll.value 中。

3.2 测试LRU淘汰策略

当使用内存超过了设定值时,是否会触发“无用”节点的移除:

func TestRemoveOldest(t *testing.T) {
        k1, k2, k3 := "key1", "key2", "k3"
        v1, v2, v3 := "value1", "value2", "v3"
        capacity := len(k1 + k2 + v1 + v2) // 只能存储前两个缓存的大小
        lru := New(int64(capacity), nil)
        lru.Add(k1, String(v1))
        lru.Add(k2, String(v2))
        lru.Add(k3, String(v3)) // 此时超过了最大容量,需要淘汰队首(当前是 k1 v1)

        // 此时 k1 已经被淘汰了(如果 LRU 正常起作用)
        if _, ok := lru.Get("key1"); ok || lru.Len() != 2 {
                t.Fatalf("RemoveOldest key1 failed")
        }
}

如果 LRU 生效,那么在 if 判断的时候,应该取不到 key1 的值,所以 ok == False,并且长度为 2,所以不会触发 t.Fatalf,测试通过。

3.3 测试回调函数

// TestOnEvicted 测试回调函数是否可用正常被调用
func TestOnEvicted(t *testing.T) {
        keys := make([]string, 0)
        callback := func(key string, value Value) {
                keys = append(keys, key)
        }
        lru := New(int64(10), callback)
        lru.Add("key1", String("123456"))
        lru.Add("k2", String("k2"))
        lru.Add("k3", String("k3"))
        lru.Add("k4", String("k4"))

        expect := []string{"key1", "k2"}

        if !reflect.DeepEqual(expect, keys) { // 深度比较 except 和 keys 是否一致
                t.Fatalf("Call OnEvicted failed, expect keys equals to %s", expect)
        }
}