从零实现分布式缓存 GeeCache(浓缩教程)

本文是手写 GeeCache 的”原理浓缩版”:不逐行贴代码,而是把每个模块 为什么存在、解决什么问题、核心思路是什么讲清楚,再指回本仓库的具体文件。

完整的分步教程来自极客兔兔《七天用 Go 从零实现系列》: Day0 序言 · Day1 LRU 淘汰策略 · Day2 单机并发缓存 · Day3 HTTP 服务端 · Day4 一致性哈希 · Day5 分布式节点 · Day6 防止缓存击穿 · Day7 Protobuf 通信


0. 先搞清楚:我们到底在写一个什么东西

缓存(Cache) 是一个”快但小”的存储层,放在”慢但大”的数据源(数据库、文件、 远程 API)前面。用户要数据时先问缓存,缓存有就秒回;没有才去问数据源,并顺手 把结果存进缓存,下次就能秒回了。

flowchart LR
    U["用户"] --> C["缓存<br/>内存 · 微秒级"]
    C -->|"命中"| R["直接返回"]
    C -->|"未命中"| S["数据源<br/>磁盘 / 网络 · 毫秒级"]
    S --> W["返回并写入缓存"]

那为什么要分布式?单机缓存有三个绕不开的瓶颈:

瓶颈说明对应的解法(本文章节)
内存有限缓存全在内存里,塞满了必须扔掉一些① LRU 淘汰算法
并发冲突多个请求同时读写同一块内存会出错② 单机并发
单机容量/可用性一台机器内存再大也有上限,挂了全完③④ 节点通信 + 负载均衡

于是整体架构长这样(本仓库的模块划分):

flowchart TB
    U["用户请求 key"] --> G["Group(总调度)<br/>geecache/geeCache.go"]
    G -->|"命中缓存"| C["cache + LRU<br/>并发安全的缓存"]
    G -->|"未命中"| SF["singleflight(合并并发请求)<br/>相同 key 只取一次<br/>singleflight/singleflight.go"]
    SF -->|"该 key 归别人管"| CH["一致性哈希选节点<br/>consistenthash/consistenthash.go"]
    SF -->|"该 key 归我管"| GT["回调 getter 取数<br/>(模拟的数据库 db)"]
    CH --> P["HTTP + Protobuf 请求<br/>对方节点的 ServeHTTP<br/>又一次 Group.Get(递归)"]

对应的代码结构:

geecache/
├── go.mod                     # 模块名 geecache
├── main.go                    # 启动入口:建 Group + 起 HTTP 服务
├── geecache/                  # 核心包
│   ├── LRU.go                 # ① LRU 淘汰算法        (Day1)
│   ├── LRU_test.go            #    LRU 的单元测试
│   ├── byteView.go            #    只读的缓存值 ByteView (Day2)
│   ├── cache.go               # ② 加锁的并发缓存       (Day2)
│   ├── geeCache.go            #    Group:主流程调度    (Day2/5/6/7)
│   ├── peers.go               # ③④ 节点间接口抽象     (Day5/7)
│   └── http.go                # ③④ HTTP 服务端+客户端  (Day3/5/7)
├── consistenthash/            # ④ 一致性哈希(负载均衡)(Day4)
│   └── consistenthash.go
├── singleflight/              # 防缓存击穿             (Day6)
│   └── singleflight.go
└── geecachepb/                # Protobuf 消息定义      (Day7)
    ├── geecachepb.proto
    ├── geecachepb.pb.go       # protoc 生成,勿手改
    └── geecachepb_grpc.pb.go  # protoc 生成,勿手改

1. LRU 算法:内存满了,扔谁?

对应文件:geecache/LRU.go,教程 Day1

1.1 三种经典淘汰算法

缓存总有一天会塞满,此时必须移除一些数据给新数据腾地方。移除谁?三种思路:

  • FIFO(先进先出):扔最早放进来的。实现最简单,但”放得早”不等于”没人用”—— 最热的数据往往最早进缓存,被扔掉后又要重新加载,命中率差。
  • LFU(最少使用):扔访问次数最少的。命中率高,但要为每条记录维护计数器 (内存开销大),而且”历史数据”会拖累它:某 key 曾经很热、现在没人用, 却因为历史次数高而一直占着位置。
  • LRU(最近最少使用):扔”最久没被访问”的那条。核心假设是 “最近用过的,将来大概率还会用”。实现简单、效果均衡,所以我们选它。

1.2 核心数据结构:map + 双向链表

要让”查找、移动、删除、插入”都是 O(1),需要两个结构配合:

flowchart LR
    subgraph LIST["双向链表(按最近使用排序)"]
        direction LR
        F["Front 队头<br/>最近使用"] --> N1["Tom"] --> N2["Sam"] --> N3["Jerry"] --> N4["Jack"] --> B["Back 队尾<br/>最久未用"]
    end
    M["map[string]*list.Element<br/>按 key 找节点 O(1)"] -.-> N1
    M -.-> N2
    M -.-> N3
    M -.-> N4
    B -->|"淘汰永远从队尾取 RemoveTail"| X["摘链表节点<br/>delete(map, key)"]
  • map 负责”按 key 找到节点”,O(1);
  • 双向链表负责”记住使用顺序”:每次访问就 MoveToFront 挪到队头; 内存不够时取 Back()(最久未用)扔掉。

一个容易忽略的细节:链表节点里必须同时存 key 和 value。因为淘汰队尾时, 要靠 key 去 map 里删除对应索引(delete(cache, key)),只存 value 就不知道该删谁。

1.3 三个操作的逻辑

Get(key):   map 找到节点 ──> MoveToFront 升温 ──> 返回 value   // 访问即升温
Put(key,v): key 已存在? ──是──> 更新 value + MoveToFront
            key 不存在? ──是──> 新节点插到队头 + map 记索引
            然后 while (已用内存 > maxBytes) { RemoveTail() }  // 可能连续淘汰多个
RemoveTail(): 取队尾 ──> 摘链表节点 ──> 删 map 索引 ──> 内存计数减掉 ──> 触发回调

除了数据本身,还要维护一个内存计数 currBytes(每个节点占 len(key) + value.Len() 字节),淘汰决策全靠它和 maxBytes 比较。

为了让值类型”通用”,规定被缓存的对象必须实现一个小接口:

type Value interface{ Len() int }  // 自报家门:我占多少字节

这样 LRU 根本不需要知道缓存的是字符串、图片还是结构体。

本文件的 LRU 是非并发安全的,加锁是下一层的事。 另外:maxBytes == 0 表示不限容量(测试时常用)。


2. 单机并发:多个请求同时读写怎么办?

对应文件:geecache/cache.go、geecache/byteView.go,教程 Day2

2.1 问题:Go 的 map 不是并发安全的

缓存天生要被成千上万个 goroutine 同时访问。Go 语言明确规定:并发读写同一个 map 会直接 fatal(concurrent map writes),连 recover 都救不回来。链表同理, 会出现节点指针错乱。

2.2 解法:一把互斥锁 + 延迟初始化

给 LRU 套一层带 sync.Mutex 的薄封装(cache.go):

type cache struct {
    mu         sync.Mutex  // 互斥锁:同一时刻只允许一个 goroutine 碰 lru
    lru        *LRU        // 延迟初始化:第一次 Put 时才真正创建
    cacheBytes int64       // 容量上限先存这儿,等创建 LRU 时再交给它
}
 
func (c *cache) Get(key string) (ByteView, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()          // defer 保证任何路径都释放锁
    ...
}

两个关键点:

  1. 锁的粒度:锁加在”整个 cache”上,简单粗暴但绝对正确。真正的生产级缓存 (如 groupcache)也不会在这层做细粒度分段锁,因为缓存命中本身极快, 锁竞争不是瓶颈。
  2. 延迟初始化(Lazy Initialization):LRU 连同它的 map、链表,等到第一次 真正写入时才创建。一个 Group 可能永远不被访问,提前创建就是浪费内存。

2.3 顺带解决”值被篡改”:ByteView 只读视图

缓存里的值一旦写入就不该被改动。如果直接存裸的 []byte,任何拿到它的代码 都可能偷偷修改底层数组,缓存里的数据就”变脏”了。所以包一层 ByteView (byteView.go):

  • 字段 b 是非导出的(小写),外面摸不到;
  • 想拿数据必须调用 ByteSlice(),它返回的是拷贝,随便改都不影响缓存;
  • 顺手实现 Len(),正好满足 LRU 要求的 Value 接口。

“只读 + 返回副本”是 Go 标准库的常见套路(如 strings.Reader.Bytes)。


3. Group:把缓存包装成用户能用的东西

对应文件:geecache/geeCache.go,教程 Day2

现在有了”能淘汰、能并发”的缓存,但还缺一个对外的门面。Group = 一个带名字的 缓存命名空间,比如 scores(成绩)、info(学生信息)互不干扰。

设计上有两个巧思:

① 数据源用回调函数注入。 缓存未命中时要去数据库/文件取数,但数据源五花八门, 缓存库不可能都实现。于是约定一个接口,把”怎么取数”交给使用者:

type Getter interface{ Get(key string) ([]byte, error) }
 
// GetterFunc 是函数类型,通过"方法体内调用自己"实现了 Getter 接口,
// 让用户可以直接传一个匿名函数,而不用专门写个结构体(接口型函数技巧)。
type GetterFunc func(key string) ([]byte, error)
func (f GetterFunc) Get(key string) ([]byte, error) { return f(key) }

② 主流程三步走。 教程里那张经典流程图:

flowchart TD
    K["接收 key"] --> Q1{"缓存里有吗?"}
    Q1 -->|"是"| R1["返回缓存值 ⑴(最快路径)"]
    Q1 -->|"否"| Q2{"归远程节点管吗?"}
    Q2 -->|"是"| R2["HTTP 问远程节点<br/>返回缓存值 ⑵"]
    Q2 -->|"否"| R3["调用 getter 回源取数 + 写入缓存<br/>返回缓存值 ⑶"]

代码上对应 Get → load → (getFromPeer | getLocally):

func (g *Group) Get(key string) (ByteView, error) {
    if v, ok := g.mainCache.Get(key); ok {   // ⑴ 命中本地缓存
        return v, nil
    }
    return g.load(key)                        // 未命中,进入调度
}

还有一张全局注册表 groups map[string]*Group(读写锁保护):因为 HTTP 服务端 收到请求时手里只有字符串 "scores",必须能反查到 Group 实例才能继续。


4. 节点之间如何通信:HTTP 服务端 + 客户端

对应文件:geecache/http.go、geecache/peers.go,教程 Day3 / Day5

4.1 思路:每个节点既是客户端也是服务端

分布式缓存由多台机器上的”节点”组成。要互相取数据,最朴素的办法就是 HTTP: 每个节点启动时开一个 HTTP 服务,其他节点就能通过网络访问它的缓存。

于是同一个 HTTPPool 结构体承担双重身份:

  • 服务端:实现 ServeHTTP,让别人能来问我;
  • 客户端:内嵌 httpGetter,让我能去问别人。

4.2 URL 约定:一切从路径解析开始

节点间通信只用一个路径前缀 /_geecache/(一台机器上可能跑多种服务, 必须用前缀隔离),格式固定为:

GET /_geecache/<group名称>/<key>
例:GET /_geecache/scores/Tom

服务端 ServeHTTP 的处理流程非常直白:

1. 路径不以 /_geecache/ 开头? -> panic(这不该发生)
2. 按 "/" 切成两段 [groupName, key],切不出来 -> 400
3. GetGroup(groupName) 找不到? -> 404
4. group.Get(key) -> 这就是第 3 节的主流程,可能又递归触发"远程/本地"
5. 把结果写回响应体

注意第 4 步:被请求的节点会再走一遍 Group.Get,它自己也是缓存, 也可能命中自己的缓存或继续转发。这就是分布式缓存的”接力”。

4.3 接口解耦:Group 根本不知道 HTTP 的存在

peers.go 只定义了两个接口,不写任何实现:

type PeerPicker interface {                       // "选节点"
    PickPeer(key string) (peer PeerGetter, ok bool)
}
type PeerGetter interface {                       // "向节点取数"
    Get(in *pb.Request, out *pb.Response) error
}

这是整个设计里最漂亮的一笔——依赖倒置:

  • Group 只依赖接口,完全不 import net/http;
  • 今天用 HTTP 实现,明天想换成 gRPC 或 TCP,只要另写一个实现类即可, Group 一行代码都不用动;
  • 单机模式(不注册 PeerPicker)和分布式模式共用同一套代码。

客户端 httpGetter.Get 的工作:拼 URL → http.Get → 检查状态码 → 读响应体 → 反序列化成 pb.Response 填给调用方。

另外有个编译期检查的小技巧,值得记住:

var _ PeerGetter = (*httpGetter)(nil)
// 如果 httpGetter 没实现全 PeerGetter 的方法,这行直接编译报错

5. 负载均衡:一致性哈希决定”这个 key 归谁管”

对应文件:consistenthash/consistenthash.go,教程 Day4

5.1 朴素做法为什么不行:hash(key) % N 会引发缓存雪崩

收到一个 key,怎么知道该问哪个节点?最直觉的是哈希取模。但取模的除数 N 等于节点数量——一旦有节点扩缩容(N 变了),几乎所有 key 的归属都会变, 等于全缓存同时失效,海量请求瞬间砸向数据库,这叫缓存雪崩:

flowchart LR
    K["hash('Tom')"] -->|"3 个节点:hash % 3 = 1"| B["归节点 B"]
    K -->|"2 个节点:hash % 2 = 0"| A["归节点 A<br/>只挂了一台,归属全乱了"]

5.2 一致性哈希:把节点和 key 都放到一个环上

一致性哈希(Consistent Hashing)把节点和 key 都映射到 0 ~ 2^32-1 的环上, key 归”顺时针方向遇到的第一个节点”管:

flowchart LR
    subgraph RING["哈希环 0 ~ 2³²-1(顺时针)"]
        direction LR
        A["peerA"] --> B["peerB"] --> C["peerC"] --> A
    end
    K["key 的哈希落点"] -.->|"顺时针遇到的第一个节点"| B

这样设计的精妙之处在于:增删一个节点,只影响环上它”逆时针方向那一小段”的 key, 其他所有映射保持不变——把”全量重排”变成了”局部调整”。

5.3 虚拟节点:解决数据倾斜

真实节点少的时候,环上的分布会很不均匀(三个节点恰好挤在上半环,下半环的 key 全被一个人扛),这叫数据倾斜。解法是给每个真实节点创建多个虚拟节点:

flowchart LR
    RA["真实节点 peerA"] --> VA["虚拟节点<br/>0peerA · 1peerA · 2peerA …<br/>(默认 50 个)"]
    RB["真实节点 peerB"] --> VB["虚拟节点<br/>0peerB · 1peerB · 2peerB …"]
    VA --> HM["hashMap:虚拟节点哈希 → 真实节点"]
    VB --> HM

虚拟节点均匀撒在环上,key 先命中虚拟节点,再通过一张 虚拟节点哈希值 -> 真实节点名 的 map 反查出真实节点。代价只是多一个 map。

5.4 实现的两个关键细节

Add(真实节点...):  为每个节点生成 replicas 个虚拟节点
                   名字用 "编号+节点名" 拼出来: strconv.Itoa(i) + "peerA"
                   记录进 hashMap,最后对环 keys 排序
 
Get(key):          hash(key) -> sort.Search 二分找到第一个 >= hash 的下标
                   -> idx % len(keys)   // 取模!因为环是首尾相连的,
                                        // 找过头了要绕回环的开头
                   -> hashMap[...]      // 虚拟节点 -> 真实节点

idx % len(keys) 这个取模就是”绕回环起点”的实现,是环形结构的标志性写法。

另外,PickPeer 选出的节点如果是本机自己(peer == p.self), 就返回 ok=false 走本地逻辑,避免”自己 HTTP 请求自己”这种绕路。


6. 防缓存击穿:singleflight 合并并发请求

对应文件:singleflight/singleflight.go,教程 Day6

先分清三个经典概念(面试常考):

概念场景危害
缓存雪崩大量 key 在同一时刻一起失效数据库被瞬间打爆
缓存击穿某个热点 key 过期的一刻,海量请求同时进来全部穿透到数据库
缓存穿透查询一个根本不存在的 key(不写缓存)每次都打数据库

singleflight 专门治击穿:同一个 key 的并发请求,只放第一个进去取数, 其余全部等待并共享同一个结果:

sequenceDiagram
    participant A as goroutine A
    participant B as goroutine B
    participant C as goroutine C
    participant SF as singleflight
    participant DB as 数据库/远程节点

    A->>SF: Do("Tom", fn)
    Note over SF: map 中没有该 key,A 登记 call(宣告"这个 key 我包了")
    A->>DB: fn()(只有 A 真正执行)
    B->>SF: Do("Tom", fn)
    C->>SF: Do("Tom", fn)
    Note over B,C: map 已有该 key,阻塞在 WaitGroup 上等待
    DB-->>A: 取数完成
    A-->>SF: wg.Done() 唤醒等待者,并从 map 删除该 key
    SF-->>B: 共享 A 的结果
    SF-->>C: 共享 A 的结果

实现只靠两个东西:一张 map[key]*call(记录”这个 key 是否有人在取”)和 一个 sync.WaitGroup(让并发协程互相等待)。Do 的四步:

1. 查 map:已有该 key 的 call -> Wait 等它完成 -> 直接返回它的结果
2. 没有  -> 创建 call,wg.Add(1),登记进 map(宣告"这个 key 我包了")
3. 执行真正的 fn()(比如去数据库/远程节点取数),wg.Done() 唤醒等待者
4. 从 map 中删除该 key(下次再来就是新请求了),返回结果

在本项目里,Group.load 用 g.loader.Do(key, ...) 把”选远程节点 + 取数”整体 包了起来,于是 10 万个并发请求同一个 key,也只会产生 1 次真实取数。

注意:这个简化版没有 Forget/DoChan 等方法,也不统计 InFlight 数量, 与官方 golang.org/x/sync/singleflight 相比少了长期抑制同一 key 重复回源的能力 (我们的场景里取完就写缓存了,问题不大)。


7. Protobuf:把通信内容打包得更小更快

对应文件:geecachepb/,教程 Day7

前几版节点间传数据是”明文拼字符串”。Day7 换成 Protocol Buffers: 先在 .proto 文件里定义数据结构,用 protoc 生成 Go 代码,通信时 发送方 Marshal 编码、接收方 Unmarshal 解码。

message Request { string group = 1; string key = 2; }   // = URL 里的两段
message Response { bytes value = 1; }                    // = 响应体
service GroupCache { rpc Get(Request) returns (Response); }  // 预留 gRPC 路线

为什么值得折腾?三点:

  1. 更小:二进制编码,比 JSON/明文省一半以上空间,网络传输更快;
  2. 更稳:结构化字段,key 里有特殊字符也不会把 URL 拼坏(配合 QueryEscape);
  3. 可扩展:想加字段只需在 proto 里加一行编号,新旧版本天然兼容; 而且 .proto 是跨语言的,将来把某个节点换成 Java/Python 实现也能直接对话。

对代码的改动只有两处:ServeHTTP 里 proto.Marshal(&pb.Response{...}) 写响应, httpGetter.Get 里 proto.Unmarshal(bytes, out) 解响应。其余逻辑分毫未动—— 这正是”接口解耦 + 序列化协议”的红利。

注意:*.pb.go 是 protoc 自动生成的,永远不要手改。重新生成命令:

cd geecachepb
protoc --go_out=. --go-grpc_out=. *.proto

目前项目只用到了消息定义(Request/Response),service GroupCache 生成的 gRPC 桩代码暂未使用——它为将来直接换 gRPC 传输留了后门。


8. 串起来:一次 curl 请求的完整生命周期

假设三个节点 8001/8002/8003 组成集群,用户发起:

curl "http://localhost:9999/api?key=Tom"
sequenceDiagram
    autonumber
    participant U as 用户(curl)
    participant API as API 服务(9999,跑在 8003 上)
    participant G as Group :8003
    participant SF as singleflight
    participant CH as 一致性哈希 PickPeer
    participant P1 as 节点 8001 ServeHTTP
    participant DB as getter 回调(慢数据库)

    U->>API: GET /api?key=Tom
    API->>G: gee.Get("Tom")
    G->>G: 查本地 mainCache → 未命中
    G->>SF: load 进入 Do("Tom", fn)(并发时在此合并)
    SF->>CH: PickPeer("Tom")
    CH-->>SF: 选中 8001(不是本机)
    SF->>P1: GET /_geecache/scores/Tom(group、key 在 URL 中)
    P1->>P1: 解析路径 → GetGroup("scores") → group.Get("Tom")
    P1->>P1: 8001 也未命中 → 它的 PickPeer 命中自己 → 走本地 getLocally
    P1->>DB: getter 回调查 db["Tom"]
    DB-->>P1: "630",写入 8001 的 LRU
    P1-->>SF: proto.Marshal(Response{value:"630"}) 编码进响应体
    SF-->>API: proto.Unmarshal 解码 → 包成 ByteView
    API-->>U: 返回 "630"
    Note over U,P1: 下次再来同一个 key:8003 或 8001 本地缓存直接命中,无需再回源

这张图值得反复看:每一层都只做一件事——LRU 管淘汰、cache 管并发、 Group 管调度、一致性哈希管分工、HTTP/Protobuf 管搬运、singleflight 管削峰。


9. 动手跑起来

单机模式(本仓库 main.go 当前的形态)

cd geecache
go run .          # 在 localhost:9999 启动,内置一个模拟数据库 db(map)

另开终端测试:

curl http://localhost:9999/_geecache/scores/Tom   # 630(首次回源,日志会打印 [SlowDB])
curl http://localhost:9999/_geecache/scores/Tom   # 630(命中缓存,无 [SlowDB] 日志)
curl http://localhost:9999/_geecache/scores/xxx   # xxx not exist(回源失败)

多节点模式(教程 Day5 的形态)

把 main.go 改成用 -port 启动 8001/8002/8003 三个节点 + 一个 9999 的 API 服务, 每个节点 peers.Set(全部地址...) 后启动,就能看到一致性哈希挑选节点、 singleflight 合并并发请求的日志。

跑测试

go test ./geecache/ -v     # LRU 的淘汰、内存计数、回调等用例
go test ./... -race        # 加 -race 检测数据竞争(并发代码必备!)

10. 延伸阅读