【Go | 从0实现简单分布式缓存】-1:LRU缓存淘汰策略与单机并发缓存

本文目录

本文为极客兔兔"动手写分布式缓存GeeCache"学习笔记。

一、分布式缓存

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

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

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

二、缓存淘汰策略

2.1 FIFO-First In First Out

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

比如说某些数据之间存在依赖关系,例如,一个主数据和多个从数据。主数据可能最早被添加,但从数据的访问依赖于主数据的存在。或者某些数据(如热门商品信息、高频查询的用户资料、热门新闻等)虽然很早就被添加到缓存中,但由于其高访问频率,频繁地被读取。

2.2 LFU-Least Frequently Used

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

2.3 LRU-Least Recently Used

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

2.4 LRU-K

LRU-K 中的"K"代表最近使用的次数。LRU-K 的核心思想是将"最近使用过 1 次"的判断标准扩展为"最近使用过 K 次",从而更准确地预测数据的访问模式。当缓存满时,LRU-K 会选择"倒数第 K 次访问时间最久远"的缓存项进行淘汰。

普通队列:保存每次访问的页面。当页面访问次数达到 K 次时,该页面从普通队列中移除,并添加到"K 次队列"中。

K 次队列:保存已经访问 K 次的页面。当缓存队列满了之后,需要淘汰页面时,会淘汰"倒数第 K 次访问离现在最久"的页面。

三、LRU算法实现

绿色的是字典(map),存储键和值的映射关系。这样根据某个键(key)查找对应的值(value)的复杂是O(1),在字典中插入一条记录的复杂度也是O(1)。

红色的是双向链表(double linked list)实现的队列。将所有的值放到双向链表中,这样,当访问到某个值时,将其移动到队尾的复杂度是O(1),在队尾新增一条记录以及删除一条记录的复杂度均为O(1)。

首先实现包含字典和双向链表的结构体类型 Cache,方便实现后续的增删查改操作。

首先实现Lru的代码如下。

go 复制代码
package lru

import "container/list"

// Cache is a LRU cache. It is not safe for concurrent access.
type Cache struct {
	maxBytes int64                    // 允许使用的最大内存(单位:字节)
	nbytes   int64                    // 当前已经使用的内存(单位:字节)
	ll       *list.List               // 双向链表,用于维护访问顺序(最近最少使用)
	cache    map[string]*list.Element // key 是缓存的键,value 是链表中的节点,list.Element是链表节点,*list.Element是链表节点的指针
	// 可选的回调函数,当一个条目被驱逐时执行。
	OnEvicted func(key string, value Value)
}

// 键值对 entry 是双向链表节点的数据类型
// 在链表中仍保存每个值对应的 key 的好处在于,淘汰队首节点时,需要用 key 从字典中删除对应的映射。
type entry struct {
	key   string
	value Value
}

// 值是实现了 Value 接口的任意类型,该接口只包含了一个方法 Len() int,用于返回值所占用的内存大小。
type Value interface {
	Len() int
}

// 方便实例化,返回一个初始化好的 *Cache 实例。
// 这是一个可选的回调函数,当缓存条目被驱逐时会调用。函数的参数包括被驱逐的键和值。如果不需要回调,可以传入 nil。
func New(maxBytes int64, onEvicted func(string, Value)) *Cache {
	return &Cache{
		maxBytes:  maxBytes,
		ll:        list.New(),
		cache:     make(map[string]*list.Element),
		OnEvicted: onEvicted,
	}
}

// Get 从缓存中获取指定键的值。
// 参数: key:要查找的键。
// 返回值:
//   - value:缓存中对应的值,如果键不存在则返回 nil。
//   - ok:布尔值,表示是否成功找到键。
func (c *Cache) Get(key string) (value Value, ok bool) {
	// 检查键是否存在于缓存中,ele就是对应key的值,也就是链表的节点指针
	if ele, ok := c.cache[key]; ok {
		// c.ll.MoveToFront(ele),即将链表中的节点 ele 移动到队尾(双向链表作为队列,队首队尾是相对的,在这里约定 front 为队尾)
		c.ll.MoveToFront(ele)
		// 获取元素对应的 entry 结构,go自动解引用了
		kv := ele.Value.(*entry)
		// 返回缓存值和 ok 标志
		return kv.value, true
	}
	// 如果键不存在,返回 nil 和 false
	return
}

func (c *Cache) RemoveOldest() {
	// 获取链表首部的元素(最久未使用的条目),也就是获取队首的节点指针
	ele := c.ll.Back()
	if ele != nil {
		// 从链表中移除该元素
		c.ll.Remove(ele)
		// 获取元素对应的 entry 结构
		kv := ele.Value.(*entry)
		// 从哈希表中删除对应的键
		delete(c.cache, kv.key)
		// 更新当前已使用的内存大小
		c.nbytes -= int64(len(kv.key)) + int64(kv.value.Len())
		// 如果设置了 OnEvicted 回调函数,则调用它
		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)
		// 获取节点的 entry 结构
		kv := ele.Value.(*entry)
		// 更新当前使用的内存大小:
		// 增加新值的大小,减去旧值的大小
		c.nbytes += int64(value.Len()) - int64(kv.value.Len())
		// 更新 entry 中的值
		kv.value = value
	} else {
		// 如果键不存在,创建一个新的 entry 并将其添加到链表头部
		ele := c.ll.PushFront(&entry{key, value})
		// 将键和链表节点的映射关系存储到哈希表中
		c.cache[key] = ele
		// 更新当前使用的内存大小:增加键和值的大小
		c.nbytes += int64(len(key)) + int64(value.Len())
	}

	// 如果设置了最大内存限制且当前使用的内存超过了限制
	for c.maxBytes != 0 && c.maxBytes < c.nbytes {
		// 调用 RemoveOldest 方法移除最久未使用的条目
		c.RemoveOldest()
	}
}

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

然后实现lru_test.go的测试代码。

go 复制代码
package lru

import (
	"reflect"
	"testing"
)

// 定义一个 String 类型,它是 string 的别名。
type String string

// 实现 Value 接口的 Len 方法,返回字符串的长度。
func (d String) Len() int {
	return len(d)
}

// 测试 Get 方法的测试用例。
func TestGet(t *testing.T) {
	// 创建一个 LRU 缓存实例,最大内存限制为 0(表示无限制),没有回调函数。
	lru := New(int64(0), nil)
	// 向缓存中添加一个键值对,键为 "key1",值为 "1234"。
	lru.Add("key1", String("1234"))

	// 测试缓存命中:尝试从缓存中获取 "key1"。
	if v, ok := lru.Get("key1"); !ok || string(v.(String)) != "1234" {
		// 如果获取失败(!ok)或者值不等于 "1234",测试失败。
		t.Fatalf("cache hit key1=1234 failed")
	}

	// 测试缓存未命中:尝试从缓存中获取不存在的键 "key2"。
	if _, ok := lru.Get("key2"); ok {
		// 如果获取成功(ok),测试失败。
		t.Fatalf("cache miss key2 failed")
	}
}

func TestRemoveoldest(t *testing.T) {
	k1, k2, k3 := "key1", "key2", "k3"
	v1, v2, v3 := "value1", "value2", "v3"
	cap := len(k1 + k2 + v1 + v2)
	lru := New(int64(cap), nil)
	lru.Add(k1, String(v1))
	lru.Add(k2, String(v2))
	lru.Add(k3, String(v3))

	if _, ok := lru.Get("key1"); ok || lru.Len() != 2 {
		t.Fatalf("Removeoldest key1 failed")
	}
}

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) {
		t.Fatalf("Call OnEvicted failed, expect keys equals to %s", expect)
	}
}

直接选定测试函数进行测试即可。

四、sync.Mutex锁

运行下面的代码,会发现多次运行的结果都不一样,会出现多种可能。

有时候打印 2 次,有时候打印 4 次,有时候还会触发 panic,因为对同一个数据结构set的访问冲突了。

go 复制代码
var set = make(map[int]bool, 0)

func printOnce(num int) {
	if _, exist := set[num]; !exist {
		fmt.Println(num)
	}
	set[num] = true
}

func main() {
	for i := 0; i < 10; i++ {
		go printOnce(100)
	}
	time.Sleep(time.Second)
}

接下来我们用互斥锁的Lock()和Unlock() 方法将冲突的部分包裹起来。

go 复制代码
ar m sync.Mutex
var set = make(map[int]bool, 0)

func printOnce(num int) {
	m.Lock()
	if _, exist := set[num]; !exist {
		fmt.Println(num)
	}
	set[num] = true
	m.Unlock()
}

func main() {
	for i := 0; i < 10; i++ {
		go printOnce(100)
	}
	time.Sleep(time.Second)
}

相同的数字只会被打印一次。当一个协程调用了 Lock() 方法时,其他协程被阻塞了,直到Unlock()调用将锁释放。因此被包裹部分的代码就能够避免冲突,实现互斥。

Unlock()释放锁还有另外一种写法:

go 复制代码
func printOnce(num int) {
	m.Lock()
	defer m.Unlock()
	if _, exist := set[num]; !exist {
		fmt.Println(num)
	}
	set[num] = true
}

五、支持并发读写

首先创建一个只读数据结构ByteView来表示缓存值。

go 复制代码
package Geecache

// ByteView 是一个不可变的字节序列视图,用于安全地存储和访问缓存值。
type ByteView struct {
	b []byte // 存储真实缓存的值
}

// 在 lru.Cache 的实现中,要求被缓存对象必须实现 Value 接口,也就是 Len() int 方法,返回其所占的内存大小。
// 返回 ByteView 中存储的字节序列的长度。
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                  // 返回副本
}

然后为lru.cache添加并发特性,创建cache.go文件。

go 复制代码
package Geecache

import (
	"geecache/lru"
	"sync"
)

// cache 是一个线程安全的缓存结构,封装了 LRU 缓存。
type cache struct {
	mu         sync.Mutex // 互斥锁,用于保护缓存的并发访问
	lru        *lru.Cache // LRU 缓存实例的指针
	cacheBytes int64      // 缓存的最大字节数限制
}

// add 方法向缓存中添加一个键值对。
// 在 add 方法中,判断了 c.lru 是否为 nil,如果等于 nil 再创建实例。这种方法称之为延迟初始化(Lazy Initialization),
// 一个对象的延迟初始化意味着该对象的创建将会延迟至第一次使用该对象时。主要用于提高性能,并减少程序内存要求。
func (c *cache) add(key string, value ByteView) {
	c.mu.Lock()         // 加锁
	defer c.mu.Unlock() // 确保在函数退出时释放锁
	if c.lru == nil {   // 如果 LRU 缓存实例尚未初始化
		c.lru = lru.New(c.cacheBytes, nil) // 使用最大字节数限制初始化 LRU 缓存
	}
	c.lru.Add(key, value) // 将键值对添加到 LRU 缓存中
}

// get 方法从缓存中获取一个键对应的值。
func (c *cache) get(key string) (value ByteView, ok bool) {
	c.mu.Lock()         // 加锁
	defer c.mu.Unlock() // 确保在函数退出时释放锁
	if c.lru == nil {   // 如果 LRU 缓存实例尚未初始化
		return // 返回空值和 false
	}

	if v, ok := c.lru.Get(key); ok { // 尝试从 LRU 缓存中获取键对应的值
		//v.(ByteView) 是类型断言,用于将接口类型的值 v 转换为具体的 ByteView 类型。
		// 在 lru.Cache 的 Get 方法中,返回的值 v 是一个接口类型(interface{})。
		// 它可以存储任意类型的值。当从缓存中获取值时,需要明确地将其转换为具体的类型,以便进一步操作。
		return v.(ByteView), ok // 如果找到,返回值和 true
	}

	return // 如果未找到,返回空值和 false
}

此时项目结构如下。

六、Group

Group 是 GeeCache 最核心的数据结构,负责与用户的交互,并且控制缓存值存储和获取的流程。

首先创建一个geecache.go,为了适配多种数据源。

Group作为GeeCache最核心的数据结构,它负责与用户的交互,并且控制缓存值的存储和获取。当接收到一个key时,Group首先会检查这个key是否已经被缓存。如果是,那么就直接从缓存中返回这个key对应的值,这是流程中的第一步。

如果key不在缓存中,Group接下来会判断是否应该从远程节点获取这个值。如果是,那么Group会与远程节点进行交互,从远程节点获取这个key对应的值,并将这个值返回给用户,这是流程中的第二步。

如果不需要从远程节点获取,或者远程节点也没有这个key的值,那么Group会调用一个回调函数来获取这个值。这个回调函数可能是从数据库或其他数据源获取数据的函数。在获取到值之后,Group会将这个值添加到缓存中,以便下次可以直接从缓存中获取,这是流程中的第三步。最后,Group将获取到的值返回给用户。通过这样的流程,Group确保了缓存的效率和数据的可用性。

6.1 接口型函数

go 复制代码
package Geecache

// Getter 接口定义了一个方法 Get,该方法根据给定的 key 加载数据。
type Getter interface {
	Get(key string) ([]byte, error)
}

// GetterFunc 是一个函数类型
type GetterFunc func(key string) ([]byte, error)

// 此方法让 GetterFunc 类型满足 Getter 接口。
func (f GetterFunc) Get(key string) ([]byte, error) {
	return f(key)
}

首先定义了一个接口 Getter,只包含一个方法 Get(key string) ([]byte, error),紧接着定义了一个函数类型 GetterFunc,GetterFunc 参数和返回值与 Getter 中 Get 方法是一致的。而且 GetterFunc 还定义了 Get 方式,并在 Get 方法中调用自己,这样就实现了接口 Getter。所以 GetterFunc 是一个实现了接口的函数类型,简称为接口型函数。

接口型函数只能应用于接口内部只定义了一个方法的情况,例如接口 Getter 内部有且只有一个方法 Get。既然只有一个方法,为什么还要多此一举,封装为一个接口呢?定义参数的时候,直接用 GetterFunc 这个函数类型不就好了,让用户直接传入一个函数作为参数,不更简单吗?

所以呢,接口型函数的价值什么?

假设GetFromSource 的作用是从某数据源获取结果,接口类型 Getter 是其中一个参数,代表某数据源。

go 复制代码
func GetFromSource(getter Getter, key string) []byte {
	buf, err := getter.Get(key)
	if err == nil {
		return buf
	}
	return nil
}

然后我们可以有多种方式调用该函数,比如方式一:GetterFunc 类型的函数作为参数。

go 复制代码
GetFromSource(GetterFunc(func(key string) ([]byte, error) {
	return []byte(key), nil
}), "hello")

支持匿名函数,也支持普通的函数:

go 复制代码
func test(key string) ([]byte, error) {
	return []byte(key), nil
}

func main() {
    GetFromSource(GetterFunc(test), "hello")
}

将 test 强制类型转换为 GetterFunc,GetterFunc 实现了接口 Getter,是一个合法参数。这种方式适用于逻辑较为简单的场景。

net/http源码

这个特性在 groupcache 等大量的 Go 语言开源项目中被广泛使用,标准库中用得也不少,net/http 的 Handler 和 HandlerFunc 就是一个典型。

先看一下 Handler 的定义:

go 复制代码
type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}
type HandlerFunc func(ResponseWriter, *Request)

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

可以使用 http.Handle 来映射请求路径和处理函数,Handle 的定义如下。

go 复制代码
func Handle(pattern string, handler Handler)

第二个参数是接口类型 Handler,我们可以这么用。

go 复制代码
func home(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte("hello, index page"))
}

func main() {
	http.Handle("/home", http.HandlerFunc(home))
	_ = http.ListenAndServe("localhost:8000", nil)
}

通常,我们还会使用另外一个函数 http.HandleFunc,HandleFunc 的定义如下。

go 复制代码
func HandleFunc(pattern string, handler func(ResponseWriter, *Request))

第二个参数是一个普通的函数类型,那可以直接将 home 传递给 HandleFunc。

go 复制代码
func main() {
	http.HandleFunc("/home", home)
	_ = http.ListenAndServe("localhost:8000", nil)
}

HandleFunc 的内部实现,会知道两种写法是完全等价的,内部将第二种写法转换为了第一种写法。

go 复制代码
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
	if handler == nil {
		panic("http: nil handler")
	}
	mux.Handle(pattern, HandlerFunc(handler))
}

言归正传,我们来测试下,编写测试geecache_test.go测试文件。

go 复制代码
package Geecache

import (
	"reflect" //提供反射功能,用于在运行时动态操作类型和值。
	"testing"
)

// 这个测试用例的目的是验证 Getter 接口的实现是否正确。通过将一个匿名函数转换为 GetterFunc 类型,
// 并赋值给 Getter 类型的变量,测试用例可以验证 Getter 接口的 Get 方法是否按预期工作。
func TestGetter(t *testing.T) {
	//GeterFunc中是匿名函数,将这个匿名函数转换为 GetterFunc 类型,并赋值给 Getter 类型的变量 f。
	//这样,f 就可以通过 Getter 接口调用 Get 方法。
	var f Getter = GetterFunc(func(key string) ([]byte, error) {
		return []byte(key), nil
	})

	// 然后就可以通过f.Get来调用方法,实际上是调用匿名函数
	expect := []byte("key")
	// v 是返回的值,_ 表示忽略错误(因为匿名函数总是返回 nil 错误)。
	// reflect.DeepEqual 比较 v 和 expect 是否相等。
	// reflect.DeepEqual 可以递归比较两个值是否相等,包括结构体、切片等复杂类型。
	if v, _ := f.Get("key"); !reflect.DeepEqual(v, expect) {
		t.Errorf("callback failed")
	}
}

本质上就是通过GetterFunc来实现Get接口的方法,然后在内部又调用自己,这样就可以实现Getter这个接口,也就是将其他函数转化为接口。

6.2 Group的实现

geecache.go中继续添加代码如下:

go 复制代码
type Group struct {
	name      string
	getter    Getter
	mainCache cache
}

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

// NewGroup create a new instance of 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
}

// GetGroup returns the named group previously created with NewGroup, or
// nil if there's no such group.
func GetGroup(name string) *Group {
	mu.RLock()
	g := groups[name]
	mu.RUnlock()
	return g
}

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

然后实现GeeCache中最为核心的方法Get:

go 复制代码
// 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 // 返回缓存中的值和 nil 错误
	}
	return g.load(key) // 如果缓存未命中,则加载数据
}

// load 负责从本地缓存或远程节点加载数据。
// 目前它直接调用 getLocally,但可以根据需要进行扩展,例如添加从其他缓存层加载的逻辑。
func (g *Group) load(key string) (value ByteView, err error) {
	return g.getLocally(key) // 从本地获取数据
}

// getLocally 从远程节点或其他数据源获取数据,并将其添加到缓存中。
func (g *Group) getLocally(key string) (ByteView, error) {
	bytes, err := g.getter.Get(key) // 使用 Getter 接口从远程节点获取数据
	if err != nil {
		return ByteView{}, err // 如果发生错误,返回空的 ByteView 和错误
	}
	value := ByteView{b: cloneBytes(bytes)} // 创建 ByteView 实例并复制字节数据
	g.populateCache(key, value) // 将值添加到缓存中
	return value, nil // 返回 ByteView 和 nil 错误
}

// populateCache 将键值对添加到主缓存中。
func (g *Group) populateCache(key string, value ByteView) {
	g.mainCache.add(key, value) // 使用主缓存的 add 方法添加键值对
}

Get 方法实现了上述所说的流程 ⑴ 和 ⑶。

流程 ⑴ :从 mainCache 中查找缓存,如果存在则返回缓存值。

流程 ⑶ :缓存不存在,则调用 load 方法,load 调用 getLocally(分布式场景下会调用 getFromPeer 从其他节点获取),getLocally 调用用户回调函数 g.getter.Get() 获取源数据,并且将源数据添加到缓存 mainCache 中(通过 populateCache 方法)。

运行对应的测试代码来看看情况。

go 复制代码
package Geecache

import (
	"fmt"
	"log"
	"reflect" //提供反射功能,用于在运行时动态操作类型和值。
	"testing"
)

// 这个测试用例的目的是验证 Getter 接口的实现是否正确。通过将一个匿名函数转换为 GetterFunc 类型,
// 并赋值给 Getter 类型的变量,测试用例可以验证 Getter 接口的 Get 方法是否按预期工作。
func TestGetter(t *testing.T) {
	//GeterFunc中是匿名函数,将这个匿名函数转换为 GetterFunc 类型,并赋值给 Getter 类型的变量 f。
	//这样,f 就可以通过 Getter 接口调用 Get 方法。
	var f Getter = GetterFunc(func(key string) ([]byte, error) {
		return []byte(key), nil
	})

	// 然后就可以通过f.Get来调用方法,实际上是调用匿名函数
	expect := []byte("key")
	// v 是返回的值,_ 表示忽略错误(因为匿名函数总是返回 nil 错误)。
	// reflect.DeepEqual 比较 v 和 expect 是否相等。
	// reflect.DeepEqual 可以递归比较两个值是否相等,包括结构体、切片等复杂类型。
	if v, _ := f.Get("key"); !reflect.DeepEqual(v, expect) {
		t.Errorf("callback failed")
	}
}

var db = map[string]string{
	"Tom":  "630",
	"Jack": "589",
	"Sam":  "567",
}

func TestGet(t *testing.T) {
	loadCounts := make(map[string]int, len(db))
	// 获取数据源的方法就是 GetterFunc,然后封装了一个匿名函数func,也就是当缓存未命中的时候,去其他数据库(这里是db)找数据。
	gee := NewGroup("scores", 2<<10, GetterFunc(
		func(key string) ([]byte, error) {
			log.Println("[SlowDB] search key", key)
			if v, ok := db[key]; ok {
				if _, ok := loadCounts[key]; !ok {
					loadCounts[key] = 0
				}
				loadCounts[key] += 1
				return []byte(v), nil
			}
			return nil, fmt.Errorf("%s not exist", key)
		}))

	for k, v := range db {
		if view, err := gee.Get(k); err != nil || view.String() != v {
			t.Fatal("failed to get value of Tom")
		} // load from callback function
		if _, err := gee.Get(k); err != nil || loadCounts[k] > 1 {
			t.Fatalf("cache %s miss", k)
		} // cache hit
	}

	if view, err := gee.Get("unknown"); err == nil {
		t.Fatalf("the value of unknow should be empty, but %s got", view)
	}
}

等于也就是先执行for循环进行gee.Get()进行获取,但是肯定会获取不到,于是调用load()

然后转而调用getLocally,从而调用回调函数获取。

当再次去Get时,就可以命中缓存了。

相关推荐
徐小黑ACG2 小时前
GO语言 使用protobuf
开发语言·后端·golang·protobuf
极客天成ScaleFlash5 小时前
极客天成NVFile:无缓存直击存储性能天花板,重新定义AI时代并行存储新范式
人工智能·缓存
morris1317 小时前
【redis】redis实现分布式锁
数据库·redis·缓存·分布式锁
纪元A梦10 小时前
Redis最佳实践——首页推荐与商品列表缓存详解
数据库·redis·缓存
低头不见12 小时前
一个服务器算分布式吗,分布式需要几个服务器
运维·服务器·分布式
靠近彗星13 小时前
如何检查 HBase Master 是否已完成初始化?| 详细排查指南
大数据·数据库·分布式·hbase
能来帮帮蒟蒻吗13 小时前
GO语言学习(16)Gin后端框架
开发语言·笔记·学习·golang·gin
JavaPub-rodert14 小时前
一道go面试题
开发语言·后端·golang
6<714 小时前
【go】静态类型与动态类型
开发语言·后端·golang
小马爱打代码15 小时前
Kafka - 消息零丢失实战
分布式·kafka