本系列文章全部参考自极客兔兔的《7天用Go从零实现分布式缓存GeeCache》:geektutu.com/post/geecac… 下面的内容均参考自该文章,同时结合了我自己学习过程中的思考,如果存在问题还请大家多多指教。
Day2 单机并发缓存
单机并发缓存的意思是单机版的并发安全缓存,也就是说今天我们希望实现一个运行在单机内存里的、支持多 goroutine 安全访问的缓存系统。
1 sync.Mutex
前面我们已经实现了 LRU,接下来我们希望做到并发安全。
多个协程(goroutine)同时读写同一个变量,在并发度较高的情况下,会发生冲突。确保一次只有一个协程(goroutine)可以访问该变量以避免冲突,这称之为互斥,互斥锁可以解决这个问题。
sync.Mutex 是一个互斥锁,可以由不同的协程加锁和解锁。
sync.Mutex 是 Go 语言标准库提供的一个互斥锁,当一个协程(goroutine)获得了这个锁的拥有权后,其它请求锁的协程(goroutine) 就会阻塞在 Lock() 方法的调用上,直到调用 Unlock() 锁被释放。
接下来举一个简单的例子,假设有10个并发的协程打印了同一个数字100,为了避免重复打印,实现了printOnce(num int) 函数,使用集合 set 记录已打印过的数字,如果数字已打印过,则不再打印。
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)
}
我们运行上面的程序会发生什么情况呢?
100
100
100
100
有时候打印 2 次,有时候打印 4 次,有时候还会触发 panic,因为对同一个数据结构set的访问冲突了。接下来用互斥锁的Lock()和Unlock() 方法将冲突的部分包裹起来:
var 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)
}
100
相同的数字只会被打印一次。当一个协程调用了 Lock() 方法时,其他协程被阻塞了,直到Unlock()调用将锁释放。因此被包裹部分的代码就能够避免冲突,实现互斥。
Unlock()释放锁还有另外一种写法:
func printOnce(num int) {
m.Lock()
defer m.Unlock()
if _, exist := set[num]; !exist {
fmt.Println(num)
}
set[num] = true
}
2 支持并发读写
通过上面的例子我们可以知道,要想支持并发读写缓存,我们需要加锁,所以接下来我们使用 sync.Mutex 封装 LRU 的几个方法,使之支持并发的读写。但在此之前,我们先抽象一个数据结构 ByteView 用来表示缓存值,其结构很简单,只有一个 []byte。因为缓存最怕的不是慢,而是被外部改坏。如果你把内部的 []byte 直接返回,外面一改,缓存内容就变了,调试地狱。于是它:
- 存内部原始字节
- 对外提供
ByteSlice()时返回拷贝 - 提供
String()也是拷贝语义
这是一种很经典的思路:缓存的数据默认不可变(immutable) ,用空间换安全。
// geecache/byteview.go
package geecache
import "bytes"
// ByteView 仅包含一个 []byte
type ByteView struct {
b []byte
}
// Len 返回 ByteView 的长度,同时也需要实现这个方法从而实现 Value 接口
func (v ByteView) Len() int {
return len(v.b)
}
// ByteSlice 返回一个拷贝后的数据,防止外部修改
func (v ByteView) ByteSlice() []byte {
return bytes.Clone(v.b)
}
// String 返回缓存数据的只读字符串表示
func (v ByteView) String() string {
return string(v.b)
}
这里实现 Len() 方法一方面可以返回 ByteView 的长度,另一方面,实现了 Len() 方法之后也就实现了 Value 接口,这样才能存储在缓存的链表中。
另外,b 是只读的,使用 ByteSlice() 方法返回一个拷贝,防止缓存值被外部程序修改。
接下来就可以为 lru.Cache添加并发特性了。
// add 实例化 lru,封装 Add 方法,加锁
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)
}
// get 实例化 lru,封装 Get 方法,加锁
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
}
-
cache.go的实现非常简单,实例化 lru,封装 get 和 add 方法,并添加互斥锁 mu。 -
在
add方法中,判断了c.lru是否为 nil,如果等于 nil 再创建实例。这种方法称之为延迟初始化(Lazy Initialization),一个对象的延迟初始化意味着该对象的创建将会延迟至第一次使用该对象时。主要用于提高性能,并减少程序内存要求。
3 主体结构 Group
3.1 Group 设计的必要性
前面我们已经做到了:
- 实现了 LRU 算法
- 单台机器上已经实现了并发安全的 LRU
这时我们只是实现了一个并发安全的缓存容器,还远远谈不上一个完整的缓存系统。但是为什么这里提出一个主体结构 Group 呢?
诚然,前面的工作完成后,我们确实可以得到一个勉强可用的并发安全的缓存系统,但是,这仅仅是能用的程度。为什么这么说?
我们来看看,就目前的代码,如果我们真的用起来,需要如何写,假设我们要写一个缓存用户成绩的业务代码:
// 数据库查询函数
func queryScoreFromDB(name string) (string, error)
v, ok := cache.Get("Tom")
// 缓存命中,直接返回
if ok {
return v
}
// 缓存未命中的处理
score, err := queryScoreFromDB("Tom")
if err != nil {
return err
}
cache.Add("Tom", []byte(score))
return score
这段代码的问题在于:
- 每个地方都要手写“缓存未命中逻辑”,10个接口要查成绩就要写10遍,再一个,如果以后未命中我不希望通过 queryScoreFromDB 查询,而是通过其他方式查询,那么又要修改源码了,显然违反了 OCP。
- 无法统一管控缓存行为,如果以后想打印日志、做统计监控等等,那么所有写过 "if ok else 查DB" 的地方都要改,维护成本爆炸
- 多个业务共用一个缓存,互相污染,这其实是“缺乏命名空间隔离”。比如你需要缓存用户信息、商品价格、排行榜,于是你只能:
cache.Add("user:Tom", ...)
cache.Add("score:Tom", ...)
cache.Add("rank:2026", ...)
业务靠字符串前缀区分,完全靠自觉,没人兜底。
总结下来就是,目前的缓存系统没有“接管流程”的能力,仅仅是能用的程度,但是远达不到工程应用级别的需求。
而在有了 Group 之后,我们进行缓存用户成绩的时候,就可以这样写:
scores := geecache.NewGroup("scores", 2<<10, geecache.GetterFunc(
func(key string) ([]byte, error) {
log.Println("[DB] query", key)
return []byte(queryScoreFromDB(key))
}))
注意,开发者只做了一件事:告诉框架,当缓存没命中时,去哪里拿数据。
然后业务代码变成:
view, err := scores.Get("Tom")
return view.String()
结束,就这一行。
也就是说,加入了 Group 之后,框架自动做:
- 查缓存
- 没有 -> 调 Getter
- 拿到数据 -> 写回缓存
- 返回结果
业务层只关心:我要这个 key 的值,其他的不管。
作为开发者,我使用缓存框架的时候,也确实不希望还要自己手动去判断缓存未命中时如何处理,我只需要提前告诉你没命中的话去哪里拿就行,至于判断没命中去这里拿的逻辑交给框架。这实际上是一种 IoC 的体现,将流程控制从业务(开发者)“反转”到框架上。
因此,Group 的设计是十分有必要的,它是缓存系统的统一入口与流程调度中心,负责屏蔽缓存命中、远程获取、本地加载等细节,对外提供一致的 Get 接口:
是
接收 key --> 检查是否被缓存 -----> 返回缓存值 ⑴
| 否 是
|-----> 是否应当从远程节点获取 -----> 与远程节点交互 --> 返回缓存值 ⑵
| 否
|-----> 调用`回调函数`,获取值并添加到缓存 --> 返回缓存值 ⑶
总结一下就是:
在只有 LRU 和并发控制时,我们得到的只是一个线程安全的缓存容器;而 Group 的引入,则让缓存系统具备了“流程控制能力”。
它将“缓存命中判断 → 数据加载 → 写回缓存”这一整套逻辑从业务代码中抽离出来,收归框架统一管理,并通过 Getter 机制把“数据来源”这一策略开放给使用者。
同时,Group 通过 name 引入命名空间,实现了不同业务缓存的隔离。
因此,Group 的出现标志着 GeeCache 从“数据结构实现”正式迈入“可扩展缓存框架设计”。
3.2 回调 Getter
我们思考一下,如果缓存不存在,应从数据源(文件,数据库等)获取数据并添加到缓存中。GeeCache 是否应该支持多种数据源的配置呢?不应该,一是数据源的种类太多,没办法一一实现;二是扩展性不好。如何从源头获取数据,应该是用户决定的事情,我们就把这件事交给用户好了。因此,我们设计了一个回调函数(callback),在缓存不存在时,调用这个函数,得到源数据。
// geecache/geecache.go
package geecache
// Getter 满足 Getter 接口需要实现一个 Get 方法,可以通过一个 key 获取数据
type Getter interface {
Get(key string) ([]byte, error)
}
// GetterFunc 是一种“长这样签名的函数”的类型: 接收 string,返回 ([]byte, error)
type GetterFunc func(key string) ([]byte, error)
// Get 给 GetterFunc 这个类型实现 Get 方法,这样 GetterFunc 就实现了 Getter 接口
func (f GetterFunc) Get(key string) ([]byte, error) {
return f(key)
}
-
定义接口 Getter 和 回调函数
Get(key string)([]byte, error),参数是 key,返回值是 []byte。 -
定义函数类型 GetterFunc,并实现 Getter 接口的
Get方法。 -
函数类型实现某一个接口,称之为接口型函数,方便使用者在调用时既能够传入函数作为参数,也能够传入实现了该接口的结构体作为参数。
这里有必要提一下为什么要这样设计,因为刚看到这个会感觉其实 Getter 这个接口设计得没有意义,直接要求传参为 GetterFunc 类型也可以实现一样的效果,比如:
// GetFromSource 用于从数据源获取数据
GetFromSource(GetterFunc(func(key string) ([]byte, error) {
return []byte(key), nil
}), "hello")
这里传入一个 GetterFunc 类型的函数参数以及一个 key(值为 "hello")。但是假设现在我们需要从数据库中去读取数据,这个时候数据库的读取除了 key 之外还需要地址、用户名、密码等等,此时如果我们仅仅传入一个 key 是无法对数据库操作的,我们希望传入一个结构体,其中包含读取数据库所需数据以及 key。因此,为了能够将结构体作为参数,使其更加灵活,我们设计 Getter 接口,使得 GetterFunc 变成一个接口型函数。具体细节可以阅读 geektutu.com/post/7days-…
3.3 Group 的定义
接下来是最核心数据结构 Group 的定义,前面已经讲过其必要性了,因此这里直接给出具体结构的代码:
type Group struct {
name string // 每个 Group 都拥有一个唯一的名称
getter Getter // 缓存未命中时候获取数据源的回调函数
mainCache cache // cache.go 中实现的并发缓存
}
var (
mu sync.RWMutex // 用 RWMutex 是因为 groups 是读多写少的全局表,读锁可并发,提高性能
groups = make(map[string]*Group)
)
func NewGroup(name string, cacheBytes int64, getter Getter) *Group {
// 这里强制 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
}
注意几点,第一,我们在 NewGroup 中的 if 判断要求 getter 不允许为空,如果为空就不允许创建,因为我们在设计上就定义了 Group 需要具备“缓存未命中自动加载”的能力,
这是一种“防御式设计”,在初始化阶段就把非法状态扼杀掉,而不是把错误拖到运行时。
第二,我们这里定义了一个全局锁 mu,用的是 RWMutex 而不是 Mutex,RWMutex 是读写锁。这是因为 GetGroup 读取次数很多,被调用的次数会更多,而 Group 创建次数极少,因此我们希望多个 goroutine 可以同时进行读,而不允许同时写。而恰好 RWMutex 就拥有这个能力:
普通 sync.Mutex: 不管读还是写,只要一个 goroutine 拿到锁,其他人全都等着。
sync.RWMutex:
- 多个读可以同时进行(RLock)
- 但写的时候必须独占(Lock)
- 写会阻塞所有读和写
所以这里用 RWMutex 的目的是:
允许多个 goroutine 同时调用 GetGroup,而不会互相阻塞
如果用 Mutex,那么 1000 个并发请求同时 GetGroup("scores"),会变成排队进锁 —— 没必要的性能损耗。
3.4 Group 的 Get 方法
前面我们讲到 Group 设计必要性的时候提到:作为用户,不希望去管什么判断缓存是否为空、如果未命中去哪里拿这些逻辑,只需要提供 key 你给我返回就可以了。至于实际如何根据 key 拿到数据,没命中缓存去哪里拿,全部都交给 Group 完成。现在我们实现 Get 函数,来完成这段逻辑。
// geecache/geecache.go
func (g *Group) Get(key string) (ByteView, error) {
// 先排除 key 为 空的情况,以免污染缓存空间
if key == "" {
return ByteView{}, fmt.Errorf("key is required")
}
// 从 mainCache 中查找缓存,如果命中缓存,直接返回
if v, ok := g.mainCache.get(key); ok {
log.Println("[GeeCache] hit")
return v, nil
}
// 如果没命中,通过 load 拿数据
return g.load(key)
}
func (g *Group) load(key string) (value ByteView, err error) {
// 暂时直接从本地拿数据,等到后面添加上从其他数据源拿数据
// 期望的流程是:本地缓存 --> 远程缓存 --> 本地数据源
// 远程缓存后续加上,暂时直接从本地数据源拿
return g.getLocally(key)
}
// getLocally 调用用户回调函数 g.getter.Get() 获取数据,并将元数据添加到缓存 mainCache 中
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)
}
- Get 方法实现了上述所说的流程 ⑴ 和 ⑶。
- 流程 ⑴ :从 mainCache 中查找缓存,如果存在则返回缓存值。
- 流程 ⑶ :缓存不存在,则调用 load 方法,load 调用 getLocally(分布式场景下会调用 getFromPeer 从其他节点获取),getLocally 调用用户回调函数
g.getter.Get()获取源数据,并且将源数据添加到缓存 mainCache 中(通过 populateCache 方法)
至此,这一章节的单机并发缓存就已经完成了。
4 测试
可以写测试用例,也可以写 main 函数来测试这一章节实现的功能。那我们通过测试用例来看一下,如何使用我们实现的单机并发缓存吧。
首先,用一个 map 模拟耗时的数据库。
var db = map[string]string{
"Tom": "630",
"Jack": "589",
"Sam": "567",
}
创建 group 实例,并测试 Get 方法
func TestGet(t *testing.T) {
loadCounts := make(map[string]int, len(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)
}
}
- 在这个测试用例中,我们主要测试了 2 种情况
- 1)在缓存为空的情况下,能够通过回调函数获取到源数据。
- 2)在缓存已经存在的情况下,是否直接从缓存中获取,为了实现这一点,使用
loadCounts统计某个键调用回调函数的次数,如果次数大于1,则表示调用了多次回调函数,没有缓存。
测试结果如下:
=== RUN TestGet
2026/01/30 17:36:48 [SlowDB] search key Tom
2026/01/30 17:36:48 [GeeCache] hit
2026/01/30 17:36:48 [SlowDB] search key Jack
2026/01/30 17:36:48 [GeeCache] hit
2026/01/30 17:36:48 [SlowDB] search key Sam
2026/01/30 17:36:48 [GeeCache] hit
2026/01/30 17:36:48 [SlowDB] search key unknown
--- PASS: TestGet (0.00s)
PASS
可以很清晰地看到,缓存为空时,调用了回调函数,第二次访问时,则直接从缓存中读取。