groupcache 工作原理

一. 简介

groupcache 是一个内嵌在 Go 应用中的分布式缓存库,它通过节点间的自动协作来共享缓存,并通过独有的 single-flight 机制,从根本上解决了缓存惊群问题,非常适合为数据库或 API 等后端服务提供一个健壮、高效的只读缓存层。

二. 核心组件

  1. Group: 这是我们打交道的顶层对象。它像一个司令部,管理着一个特定缓存命名空间的所有资源和操作。它包含:

    • name: 缓存组的唯一名称。
    • getter: 用户定义的 Getter 接口,是数据的最终来源。
    • peers: 一个 PeerPicker 接口,负责选择节点。
    • mainCache & hotCache: 一个两级缓存系统。mainCache 是主缓存,采用 LRU 算法存储数据;hotCache 是一个容量很小的"热点缓存",也用 LRU 存储,用于存放近期访问最频繁的少量数据,以避免对 mainCache 的并发锁竞争,性能更高。
    • loader: 一个 singleflight.Group 实例,这是防止缓存惊群的关键。
  2. PeerPicker (通常是 HTTPPool): 这是通信系统。它维护着集群中所有节点(Peers)的列表,并能根据 key 挑选出应该负责这个 key 的节点。HTTPPool 是它的标准实现,负责通过 HTTP 进行节点间通信。

  3. consistenthash.Map: 这是决策系统或"导航员"。PeerPicker 内部就用它来管理一致性哈希环。它能根据给定的 key,快速、确定地计算出这个 key 应该由哪个节点来管理。

  4. singleflight.Group: 这是"防拥堵的门卫"。它的作用是,对于一个正在处理的 key,只允许第一个请求(goroutine)通过,其他后来的、针对同一个 key 的请求都会在此等待,直到第一个请求完成。完成后,所有等待者都会获得相同的结果。

三. golang使用

groupcache.NewGroup 创建一个新的缓存组:
golang 复制代码
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:
golang 复制代码
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 首先会尝试从本地获取数据,这是最快的路径:

  1. 检查 hotCache(热点缓存)。如果命中,直接返回数据,流程结束。
  2. 如果 hotCache 未命中,则检查 mainCache(主缓存)。如果命中,将数据提升到 hotCache 中,然后返回数据,流程结束。

第 3 步:进入 singleflight 门卫

如果本地缓存全部未命中,请求不会立即冲向网络。它会先经过 loader (singleflight.Group) 的处理。

  • loader 会检查当前有没有其他 goroutine 正在处理 key="apple"。
  • 如果是第一个:允许通过,继续执行后续步骤。
  • 如果不是第一个:在此阻塞等待,直到第一个 goroutine 完成并返回结果。所有等待者都会共享这个结果,然后直接返回,流程结束。

第 4 步:定位主节点 (Peer Picking)

通过 singleflight 的那个幸运的 goroutine 现在需要决定谁来加载数据。

  1. 它会调用 group.peers.PickPeer("apple")。
  2. 内部的 consistenthash.Map 开始计算,确定 key="apple" 的主节点是节点 C。

第 5 步:决策:本地加载还是远程获取?

PickPeer 会返回一个代表节点 C 的 ProtoGetter 接口。

  1. groupcache 检查这个 ProtoGetter 是不是代表自己(节点 A)。
  2. 发现不是,因此确定这是一次远程获取(Remote Fetch)。
  3. 它会调用这个 ProtoGetter 的 Get 方法。在 HTTPPool 的实现中,这会向节点 C 发起一个 HTTP 请求,例如:GET http://address-of-C/groupcache/music/apple
  4. 节点 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)

  1. 节点 C 的 Get 方法开始执行,同样先检查自己的 hotCache 和 mainCache。假设它也没有缓存 apple。
  2. 请求进入节点 C 的 singleflight 门卫,并成为第一个通过的。
  3. 节点 C 调用 PickPeer("apple"),这次计算结果返回的是它自己。
  4. groupcache 发现主节点就是自己,于是确定这是一次本地加载(Local Load)。
  5. 它调用用户定义的 Getter 函数 (getFromDB("apple"))。
  6. Getter 函数执行,从慢速数据库中获取到 "red" 这个值。
  7. 获取成功后,将 "red" 填充到节点 C 的 mainCache 中。
  8. 将 "red" 这个结果写入 HTTP 响应,并发送回给节点 A。

回到发起者节点 A

第 9 步:接收并缓存结果

  1. 节点 A 收到了来自节点 C 的 HTTP 响应,其中包含了数据 "red"。
  2. 节点 A 会将这个从远程获取到的数据同样存入自己的 mainCache 中。这很重要,这意味着发起请求的节点也会缓存数据,以备下次自己使用。
  3. singleflight 将这个结果广播给所有在第 3 步等待的、针对 key="apple" 的 goroutine。

第 10 步:返回最终结果

数据被写入 dest,Get 方法调用返回。应用程序在节点 A 上成功获取到了 "red"。

流程图

相关推荐
盖世英雄酱5813638 分钟前
时间设置的是23点59分59秒,数据库却存的是第二天00:00:00
java·数据库·后端
爷_1 小时前
Nest.js 最佳实践:异步上下文(Context)实现自动填充
前端·javascript·后端
追逐时光者2 小时前
提高 .NET 编程效率的 Visual Studio 使用技巧和建议!
后端·.net·visual studio
夕颜1112 小时前
Cursor ssh 登录失败解决记录
后端
飞鸟malred2 小时前
go语言快速入门
开发语言·后端·golang
十年砍柴---小火苗2 小时前
golang中new和make的区别
开发语言·后端·golang
测试开发-学习笔记2 小时前
go mode tidy出现报错go: warning: “all“ matched no packages
开发语言·后端·golang
该用户已不存在4 小时前
8个Docker的最佳替代方案,重塑你的开发工作流
前端·后端·docker
栗然5 小时前
Spring Boot 项目中使用 MyBatis 的 @SelectProvider 注解并解决 SQL 注入的问题
java·后端