从零揣摩是采用将自己想象成设计者的角度,以作者的视角去揣摩代码的设计,并不是对着代码逐段逐段去解释,因为这样太枯燥了,缺乏代入感,而是从最原始的代码一步步优化至作者的代码,达到知其然知其所以然的目的。

由于本人水平有限,只能尽量去揣摩代码的设计思路,并不代表原作者本人的意愿。

前言

缓存是提升性能的利器,redismemcache 等作为缓存服务被大量应用在实际业务中。但如果让我们自己开发一个分布式缓存,我们应该如何设计呢?本文将参照 groupcache(Go语言实现) 的源码一步步实现一个分布式缓存,首先从设计目标来看,主要需实现两部分功能:缓存功能设计和分布式功能设计。

缓存功能设计

针对缓存部分的功能,因为缓存一般都是kv的键值对,所以第一反应就是使用字典类型。在 Go 语言中就是 Map 数据结构,那如果直接使用 Map 作为缓存的底层结构可以么?答案是不行的,直接使用将面临以下几个问题:

  1. 作为缓存,需要应对大量的并发读写操作。在 Go 语言中就是 Map 是不支持并发写操作的。不过这个问题好解决,加个互斥锁就可以了。
  2. 内存不是无限大的,也就是说必定需要淘汰一些不经常被访问的数据。并且 Map 删除数据使用的 delete 操作并不是真正删除数据,而是标记删除,这些数据是不会释放内存的。(参考:https://github.com/golang/go/issues/20135)。

基于以上情况,我们首先要考虑一种缓存替换策略,目前经常被使用的就是 LRU 算法(最近最少使用算法)),基本原理如下图所示:
image.png
根据上图可知,以链表作为缓存的底层结构,那么写入新的key时,只需要将元素插入链表头,时间复杂度 O(1)。当淘汰元素的时候,也是删除链表尾的元素,时间复杂度 O(1)。但是在查找元素的时候,需要遍历整个链表,时间复杂度为 O(N)。因此可以增加一个 Map 存储指向链表中元素的指针,通过查询的key获得指向元素的指针,然后直接获得元素,时间复杂度 O(1),存储结构如下图所示:
image.png

LRU代码实现

如果对源码分析不感兴趣,可以跳过本节,不影响内容阅读。

  1. // Key 需要是可以比较的类型,参考 https://go.dev/ref/spec#Comparison_operators
  2. type Key interface{}
  3. // entry 结构体就是链表中存储的元素
  4. type entry struct {
  5. key Key
  6. value interface{}
  7. }
  8. // LRU 缓存结构
  9. type Cache struct {
  10. // 实际存储数据的链表,使用标准库 container/list
  11. ll *list.List
  12. // map 结构,辅助元素的查询
  13. cache map[interface{}]*list.Element
  14. // 执行删除元素时的回调函数
  15. OnEvicted func(key Key, value interface{})
  16. }

接下来就是缓存需要支持的写入和读取操作,分别对应 AddGet 方法:

  1. func (c *Cache) Add(key Key, value interface{}) {
  2. // 查询 key 是否已经存在,则将元素放到链表头部,并更新 value 的内容
  3. if ee, ok := c.cache[key]; ok {
  4. c.ll.MoveToFront(ee)
  5. ee.Value.(*entry).value = value
  6. return
  7. }
  8. // 如果 key 不存在,则将元素直接放到链表头部,并更新辅助 map 的内容
  9. ele := c.ll.PushFront(&entry{key, value})
  10. c.cache[key] = el
  11. }
  1. func (c *Cache) Get(key Key) (value interface{}, ok bool) {
  2. if c.cache == nil {
  3. return
  4. }
  5. // 查询辅助 map,如果 key 存在,则将元素放到链表头部
  6. if ele, hit := c.cache[key]; hit {
  7. c.ll.MoveToFront(ele)
  8. return ele.Value.(*entry).value, true
  9. }
  10. return
  11. }

当链表的长度超过最大数量限制时,需要淘汰链表尾部的元素,代码如下:

  1. // 淘汰链表尾部元素的代码
  2. func (c *Cache) RemoveOldest() {
  3. if c.cache == nil {
  4. return
  5. }
  6. // 获取链表尾部元素,然后删除
  7. ele := c.ll.Back()
  8. if ele != nil {
  9. c.removeElement(ele)
  10. }
  11. }
  12. // 实际删除操作
  13. func (c *Cache) removeElement(e *list.Element) {
  14. c.ll.Remove(e)
  15. kv := e.Value.(*entry)
  16. delete(c.cache, kv.key
  17. // 执行回调函数
  18. if c.OnEvicted != nil {
  19. c.OnEvicted(kv.key, kv.value)
  20. }
  21. }

以上代码实现了一个LRU的存储结构,但是作为缓存功能来说,还需要满足以下几点:

  1. 针对高并发读写,需要增加读写锁保证数据一致性
  2. entry 结构中的 value 字段是 interface{} 类型,需要一个确定的结构来统一操作。
  3. 需要保存缓存当前使用的内存大小,当超过预设的最大上限,则开始淘汰尾部元素

基于以上需要,可以利用LRU缓存封装一层核心的 cache 功能。

  1. type cache struct {
  2. mu sync.RWMutex // 读写锁,对应第1点
  3. nbytes int64 // 记录缓存当前使用的内存大小,以字节为单位,对应第3点
  4. lru *lru.Cache // LRU缓存结构
  5. }

针对第2点,groupcache 中定义了统一的结构体 ByteView ,具体结构如下:

  1. type ByteView struct {
  2. // If b is non-nil, b is used, else s is used.
  3. b []byte
  4. s string
  5. }

这是因为缓存的内容一般为 byte[]string ,而定义一个统一的 ByteView,可以避免 byte[] 转换为 string(或 string 转换为 byte[]) ,转换操作都会带来额外的内存分配,降低整体性能。接下来,继续看下封装之后的 cache 的读写方法的代码实现:

  1. func (c *cache) add(key string, value ByteView) {
  2. c.mu.Lock()
  3. defer c.mu.Unlock()
  4. // 延迟初始化,在使用时在初始化,避免浪费资源,很常用的手段
  5. if c.lru == nil {
  6. c.lru = &lru.Cache{
  7. // 在淘汰元素的时候,减去该元素 key 和 value 所占的大小
  8. OnEvicted: func(key lru.Key, value interface{}) {
  9. val := value.(ByteView)
  10. c.nbytes -= int64(len(key.(string))) + int64(val.Len())
  11. c.nevict++
  12. },
  13. }
  14. }
  15. // 调用底层的 lru.Add 方法
  16. c.lru.Add(key, value)
  17. // 增加当前加入的 key 和 value 的大小
  18. c.nbytes += int64(len(key)) + int64(value.Len())
  19. }
  1. func (c *cache) get(key string) (value ByteView, ok bool) {
  2. c.mu.Lock()
  3. defer c.mu.Unlock()
  4. if c.lru == nil {
  5. return
  6. }
  7. // 调用底层 lru.Get 方法
  8. vi, ok := c.lru.Get(key)
  9. if !ok {
  10. return
  11. }
  12. return vi.(ByteView), true
  13. }
  1. func (c *cache) removeOldest() {
  2. c.mu.Lock()
  3. defer c.mu.Unlock()
  4. if c.lru != nil {
  5. // 调用底层的 lru.RemoveOldest() 方法
  6. c.lru.RemoveOldest()
  7. }
  8. }

从以上代码可以看出,核心 cache 结构就是对底层的 LRU 结构的增加了一层封装。目前这个核心的 cache 结构能直接用于实际业务中了么?其实还是不行,因为我们搭建了一个缓存的服务,是希望不仅仅是只为一个业务服务的,所以需要有一个命名空间的概念,类似kafka的topic。同时,如果缓存没有命中,是需要从另外一个数据源(数据库、文件等)中加载命中数据到缓存中,因此需要提供一个方式给用户编写自定义的加载逻辑,需求总结如下:

  1. 需要提供命名空间功能
  2. 需要提供一个方式给用户编写自定义的加载逻辑

因此我们在核心的 cache 结构上再封装一层 group 结构满足以上两个需求,结构定义如下:

  1. type Group struct {
  2. name string // 命名空间,对应第1点
  3. getter Getter // Getter接口,应对第2点
  4. cacheBytes int64 // 缓存服务内存最大限制,限制缓存占用的最大内存
  5. mainCache cache // 核心 cache 结构
  6. }

应对第2点,定义了一个 Getter 接口,用户只要实现了这个接口,就能完成缓存数据的加载。代码如下:

  1. type Getter interface {
  2. Get(key string) (ByteView, error)
  3. }
  4. type GetterFunc func(key string) (ByteView, error)
  5. func (f GetterFunc) Get(key string) (ByteView, error) {
  6. return f(key)
  7. }

同时,定义了一个 GetterFunc 的函数实现了 Getter 接口的 Get(key string) (ByteView, error) 方法,这种叫做接口型函数,方便使用者在调用时既能够传入函数作为参数,也能够传入实现了该接口的结构体作为参数。

接口型函数在 http 标准库 Handler 和 HandlerFunc 大量使用。

整体流程

根据以上分析,目前的缓存加载流程如下:
image.png

  1. 初始化全局 groups ,用于区分命名空间,同时添加读写锁控制并发操作。 ```go var ( mu sync.RWMutex // 全局 groups,区分命名空间 groups = make(map[string]*Group) )

// 初始化 group,特别注意 getter 是用于当缓存没有命中时,从其他数据源加载数据的方式 func NewGroup(name string, cacheBytes int64, getter Getter) *Group { if getter == nil { panic(“nil Getter”) } mu.Lock() defer mu.Unlock() if _, dup := groups[name]; dup { panic(“duplicate registration of group “ + name) } g := &Group{ name: name, getter: getter, cacheBytes: cacheBytes, } groups[name] = g return g }

// 根据命名空间,获取对应 group func GetGroup(name string) *Group { mu.RLock() g := groups[name] mu.RUnlock() return g }

  1. 2. 定义从读取缓存的 `Get` 方法,代码如下:
  2. ```go
  3. func (g *Group) Get(ctx context.Context, key string) (value ByteView, err error) {
  4. // 从缓存中获取数据
  5. value, cacheHit := g.lookupCache(key)
  6. if cacheHit {
  7. return
  8. }
  9. // 从数据源加载数据
  10. value, err := g.load(key)
  11. if err != nil {
  12. return
  13. }
  14. return
  15. }
  1. func (g *Group) lookupCache(key string) (value ByteView, ok bool) {
  2. if g.cacheBytes <= 0 {
  3. return
  4. }
  5. // 从底层核心 cache 获取数据
  6. value, ok = g.mainCache.get(key)
  7. return
  8. }
  1. func (g *Group) load(key string) (value ByteView, err error) {
  2. // 从本地加载数据
  3. value, err = g.getLocally(key)
  4. return
  5. }
  1. func (g *Group) getLocally(key string) (value ByteView, err error) {
  2. // 从自定义的数据源加载数据
  3. value, err = g.getter.Get(key)
  4. if err != nil {
  5. return
  6. }
  7. g.populateCache(key, value, &g.mainCache)
  8. return
  9. }
  1. 将数据源获取的数据加载到缓存中,超过最大限制需要淘汰尾部元素 ```go func (g Group) populateCache(key string, value ByteView, cache cache) { if g.cacheBytes <= 0 {

    1. return

    } cache.add(key, value)

    for {

    1. mainBytes := g.mainCache.bytes()
    2. if mainBytes <= g.cacheBytes {
    3. return
    4. }
    5. g.mainCache.removeOldest()

    }

```

总结

本篇是 groupcache 从零揣摩的第一篇重点在缓存设计,在下一篇中将基于目前的缓存设计的基础上增加分布式的功能,最终完成分布式缓存的设计。本篇涉及以下几个重点内容:

  • LRU 算法由于链表的关系,再查询时时间复杂度时 O(N) ,因此可以辅助一个字典 Map ,提升效率。
  • 在设计代码时,可以先从基础功能出发,然后在基础功能上进行一层封装,一步步得到最终结果,代码的可读性也更好。
  • 接口型函数在 Go 语言中被大量的使用,它可以使得我们在完成接口实现时,不需要定义一个结构体,然后去 New 结构体再调用,简化使用方式(当然在复杂的场景下还是需要定义结构体的,接口型函数主要是在简单的场景下使用)。