从零实现分布式缓存 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 保证任何路径都释放锁
...
}两个关键点:
- 锁的粒度:锁加在”整个 cache”上,简单粗暴但绝对正确。真正的生产级缓存 (如 groupcache)也不会在这层做细粒度分段锁,因为缓存命中本身极快, 锁竞争不是瓶颈。
- 延迟初始化(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 服务端 + 客户端
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只依赖接口,完全不 importnet/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 路线为什么值得折腾?三点:
- 更小:二进制编码,比 JSON/明文省一半以上空间,网络传输更快;
- 更稳:结构化字段,key 里有特殊字符也不会把 URL 拼坏(配合
QueryEscape); - 可扩展:想加字段只需在 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. 延伸阅读
- 本项目的完整分步教程:七天用 Go 从零实现分布式缓存 GeeCache
- 灵感来源(Go 官方实现):groupcache
- singleflight 官方版:golang.org/x/sync/singleflight
- 一致性哈希论文:Consistent Hashing and Random Trees (Karger et al., 1997)
- Protobuf 入门:Go Protobuf 简明教程
- Go 测试:Go Test 单元测试简明教程