一. 简介
groupcache 是一个内嵌在 Go 应用中的分布式缓存库,它通过节点间的自动协作来共享缓存,并通过独有的 single-flight 机制,从根本上解决了缓存惊群问题,非常适合为数据库或 API 等后端服务提供一个健壮、高效的只读缓存层。
二. 核心组件
-
Group: 这是我们打交道的顶层对象。它像一个司令部,管理着一个特定缓存命名空间的所有资源和操作。它包含:
- name: 缓存组的唯一名称。
- getter: 用户定义的 Getter 接口,是数据的最终来源。
- peers: 一个 PeerPicker 接口,负责选择节点。
- mainCache & hotCache: 一个两级缓存系统。mainCache 是主缓存,采用 LRU 算法存储数据;hotCache 是一个容量很小的“热点缓存”,也用 LRU 存储,用于存放近期访问最频繁的少量数据,以避免对 mainCache 的并发锁竞争,性能更高。
- loader: 一个 singleflight.Group 实例,这是防止缓存惊群的关键。
-
PeerPicker (通常是 HTTPPool): 这是通信系统。它维护着集群中所有节点(Peers)的列表,并能根据 key 挑选出应该负责这个 key 的节点。HTTPPool 是它的标准实现,负责通过 HTTP 进行节点间通信。
-
consistenthash.Map: 这是决策系统或“导航员”。PeerPicker 内部就用它来管理一致性哈希环。它能根据给定的 key,快速、确定地计算出这个 key 应该由哪个节点来管理。
-
singleflight.Group: 这是“防拥堵的门卫”。它的作用是,对于一个正在处理的 key,只允许第一个请求(goroutine)通过,其他后来的、针对同一个 key 的请求都会在此等待,直到第一个请求完成。完成后,所有等待者都会获得相同的结果。
三. golang使用
groupcache.NewGroup 创建一个新的缓存组:
func setupGroupCache(cacheSizeBytes int64) {
// 参数:
// 1. name: 组的唯一名称。
// 2. cacheSizeBytes: 分配给该组的总缓存大小(所有节点共享这个逻辑限制)。
// 3. getter: a groupcache.Getter (这是核心!)
musicGroup = groupcache.NewGroup("music", cacheSizeBytes, groupcache.GetterFunc(
// GetterFunc 是一个实现了 Getter 接口的函数类型。
// 当缓存未命中时,groupcache 会调用这个函数。
func(ctx context.Context, key string, dest groupcache.Sink) error {
log.Printf("[GROUPCACHE] Cache miss for key: %s. Looking up in DB...", key)
// 从我们的"慢速数据库"获取数据
value, err := getFromDB(key)
if err != nil {
return err
}
// 将获取到的数据填充到缓存中。
// SetString 方法会自动处理数据的序列化。
dest.SetString(value)
return nil
},
))
}
创建一个 API 接口来使用 groupcache:
func startAPIServer(apiAddr string) {
http.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")
if key == "" {
http.Error(w, "Missing key parameter", http.StatusBadRequest)
return
}
var data []byte ctx := context.Background()
// musicGroup.Get 是我们与 groupcache 交互的主要方法。
// 它会处理所有事情:检查本地缓存、从远端节点获取、调用 Getter 回源。
if err := musicGroup.Get(ctx, key, groupcache.AllocatingByteSliceSink(&data)); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/plain") w.Write(data)
})
log.Printf("Starting API server at: %s", apiAddr)
// 注意:这里在一个新的 http.ServerMux 上启动 API 服务,
// 避免与 groupcache 的 peer 通信服务冲突。
// 在实际项目中,通常会用一个路由器(如 a gorilla/mux)来整合。
mux := http.NewServeMux() mux.HandleFunc("/api", http.DefaultServeMux.HandleFunc)
// 把上面定义的 handler 拿过来
http.ListenAndServe(apiAddr, mux)
}
四. 数据流转过程
假设你启动了三个相同的应用实例(A, B, C),它们会自动组成一个缓存集群。
- 当 A 需要某个数据时,groupcache 会通过一致性哈希算法计算出这个数据应该由哪个节点负责(比如是 C)。
- A 会自动通过网络向 C 请求数据,而不需要你手动管理。
- 这个设计让整个集群的缓存空间是所有节点缓存之和,实现了分布式缓存。
流程图
在发起者节点 A 内部
第 1 步:调用 Get 方法
应用程序调用 musicGroup.Get(ctx, "apple", dest)。
第 2 步:检查本地缓存 (Fast Path)
groupcache 首先会尝试从本地获取数据,这是最快的路径:
- 检查 hotCache(热点缓存)。如果命中,直接返回数据,流程结束。
- 如果 hotCache 未命中,则检查 mainCache(主缓存)。如果命中,将数据提升到 hotCache 中,然后返回数据,流程结束。
第 3 步:进入 singleflight 门卫
如果本地缓存全部未命中,请求不会立即冲向网络。它会先经过 loader (singleflight.Group) 的处理。
- loader 会检查当前有没有其他 goroutine 正在处理 key="apple"。
- 如果是第一个:允许通过,继续执行后续步骤。
- 如果不是第一个:在此阻塞等待,直到第一个 goroutine 完成并返回结果。所有等待者都会共享这个结果,然后直接返回,流程结束。
第 4 步:定位主节点 (Peer Picking)
通过 singleflight 的那个幸运的 goroutine 现在需要决定谁来加载数据。
- 它会调用 group.peers.PickPeer("apple")。
- 内部的 consistenthash.Map 开始计算,确定 key="apple" 的主节点是节点 C。
第 5 步:决策:本地加载还是远程获取?
PickPeer 会返回一个代表节点 C 的 ProtoGetter 接口。
- groupcache 检查这个 ProtoGetter 是不是代表自己(节点 A)。
- 发现不是,因此确定这是一次远程获取(Remote Fetch)。
- 它会调用这个 ProtoGetter 的 Get 方法。在 HTTPPool 的实现中,这会向节点 C 发起一个 HTTP 请求,例如:GET http://address-of-C/groupcache/music/apple。
- 节点 A 开始等待节点 C 的 HTTP 响应。
在主节点 C 内部
第 6 步:接收 HTTP 请求
节点 C 上的 HTTPPool 服务器接收到来自节点 A 的 HTTP 请求。
第 7 步:本地处理请求
HTTP 处理函数会解析出 groupName="music" 和 key="apple"。然后,它并不会直接去查自己的缓存盘,而是会调用自己本地的 musicGroup.Get(ctx, "apple", dest) 方法。
为什么要这样做?
这是一个非常优雅的设计!这使得节点 C 也能利用自己的两级缓存和 singleflight 机制。万一此时节点 B 也来请求 apple,节点 C 的 singleflight 也能确保只进行一次回源。
第 8 步:在主节点回源 (Cache Filling)
- 节点 C 的 Get 方法开始执行,同样先检查自己的 hotCache 和 mainCache。假设它也没有缓存 apple。
- 请求进入节点 C 的 singleflight 门卫,并成为第一个通过的。
- 节点 C 调用 PickPeer("apple"),这次计算结果返回的是它自己。
- groupcache 发现主节点就是自己,于是确定这是一次本地加载(Local Load)。
- 它调用用户定义的 Getter 函数 (getFromDB("apple"))。
- Getter 函数执行,从慢速数据库中获取到 "red" 这个值。
- 获取成功后,将 "red" 填充到节点 C 的 mainCache 中。
- 将 "red" 这个结果写入 HTTP 响应,并发送回给节点 A。
回到发起者节点 A
第 9 步:接收并缓存结果
- 节点 A 收到了来自节点 C 的 HTTP 响应,其中包含了数据 "red"。
- 节点 A 会将这个从远程获取到的数据同样存入自己的 mainCache 中。这很重要,这意味着发起请求的节点也会缓存数据,以备下次自己使用。
- singleflight 将这个结果广播给所有在第 3 步等待的、针对 key="apple" 的 goroutine。
第 10 步:返回最终结果
数据被写入 dest,Get 方法调用返回。应用程序在节点 A 上成功获取到了 "red"。
五. 双层缓存
保存与查询
- 无论是通过本地
Getter获取,还是从远程节点获取,key="apple"的数据第一次被加载到当前节点。先保存到mainCache,再保存到hotCache上. - 查询时,先查hotCache,再查mainCache.
为什么需要两级缓存?
这完全是为了性能和并发。
mainCache 是一个被所有 goroutine 共享的资源,为了保证数据安全,所有对它的读写操作都需要通过互斥锁 (Mutex) 来保护。在高并发场景下,如果成千上万的请求都来读取 mainCache,它们就需要排队等待获取这个锁,这会成为严重的性能瓶颈,即锁竞争 (Lock Contention) 。
hotCache 的出现就是为了缓解这个问题。它有自己独立的一把锁。
- 由于 hotCache 只存放最热点的少量数据,绝大多数的读请求都会在这一层命中。
- 访问 hotCache 的锁粒度更小,速度更快,从而避免了去竞争 mainCache 那把“繁忙”的锁。
- 它像一个高效的卫兵,挡住了大部分流量,只有少量请求(访问冷数据的请求)才需要去麻烦 mainCache。
六. 惊群问题
在没有保护机制的缓存系统中,当多个客户端同时请求同一份未缓存的数据时,每个客户端都会向底层数据源发起自己独立的获取操作,从而造成重复工作。
- GroupCache 使用Singleflight模式解决了惊群问题,该模式确保一次只处理针对给定键的一个请求,而针对同一键的其他并发请求则等待并共享结果。
惊群保护的核心在于用于重复数据删除并发请求的load方法:loadGroup.Do
func (g *Group) load(ctx context.Context, key string, dest Sink) (value ByteView, destPopulated bool, err error) {
g.Stats.Loads.Add(1)
viewi, err := g.loadGroup.Do(key, func() (interface{}, error) {
// Function body executed only once per key,
// regardless of how many concurrent requests exist
// Re-check cache in case another request populated it
if value, cacheHit := g.lookupCache(key); cacheHit {
g.Stats.CacheHits.Add(1)
return value, nil
}
// Actual data loading logic...
})
// ...
}
GroupCache 提供统计数据来帮助监控惊群保护的有效性: