Go 缓存策略
Go 服务缓存:本地内存与 Redis、Cache-aside、TTL/失效、击穿穿透与 singleflight、并发安全取舍。
[!info] related notes
Go 缓存策略
这个概念为什么会出现
读多写少时,每次打数据库或远程 API 的成本是延迟、连接池与下游配额。缓存用空间与一致性复杂度换延迟与容量。Go 服务常见两层:
- 进程内:
map+锁、sync.Map、或 ristretto/bigcache 等 - 分布式:Redis 等,多实例共享
选错模型会出现脏读、缓存击穿把 DB 打穿、内存无限涨。
[!abstract] 一句话理解 先定一致性与命中率目标,再用 Cache-aside:读 miss 回源并回填,写时更新或删除缓存;热点用 singleflight 合并,TTL 与主动失效组合控脏。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:用户资料读多写少,Cache-aside + singleflight
个人主页接口 GET /users/{id} 读多写少。
每次打 DB 成本高;用进程内缓存:
- 命中且未过期 → 直接返回
- miss → 回源 DB(这里用
load模拟),再回填 TTL - 同一 key 并发 miss 时,singleflight 合并成一次回源,避免击穿
依赖 golang.org/x/sync/singleflight;本次以 Go 1.26.8、x/sync v0.23.0 验证。示例聚焦命中与合并回源:构造时需提供正 TTL 和非 nil loader,尚未实现容量淘汰、过期清理或强一致失效。
package main
import (
"context"
"fmt"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
// entry:缓存值 + 绝对过期时间。
type entry struct {
val string
expiresAt time.Time
}
// MemoryCache:带 TTL 与 singleflight 的旁路缓存。
type MemoryCache struct {
mu sync.RWMutex
items map[string]entry
ttl time.Duration
group singleflight.Group
// load:miss 时回源(DB / 远程 API)
load func(ctx context.Context, key string) (string, error)
}
func NewMemoryCache(ttl time.Duration, load func(context.Context, string) (string, error)) *MemoryCache {
return &MemoryCache{items: make(map[string]entry), ttl: ttl, load: load}
}
func (c *MemoryCache) Get(ctx context.Context, key string) (string, error) {
// 1) 快路径:读锁查缓存
c.mu.RLock()
e, ok := c.items[key]
c.mu.RUnlock()
if ok && time.Now().Before(e.expiresAt) {
return e.val, nil
}
// 2) miss 或过期:按 key 合并并发回源
v, err, _ := c.group.Do(key, func() (any, error) {
// double-check:可能别人刚回填完
c.mu.RLock()
e, ok := c.items[key]
c.mu.RUnlock()
if ok && time.Now().Before(e.expiresAt) {
return e.val, nil
}
// 用首个调用者的 ctx 回源;loader 是否响应取消由其实现决定
val, err := c.load(ctx, key)
if err != nil {
return "", err
}
// 回填带 TTL
c.mu.Lock()
c.items[key] = entry{val: val, expiresAt: time.Now().Add(c.ttl)}
c.mu.Unlock()
return val, nil
})
if err != nil {
return "", err
}
return v.(string), nil
}
// Invalidate 删除已有条目,但不会终止/阻止正在进行的旧读回填。
func (c *MemoryCache) Invalidate(key string) {
c.mu.Lock()
delete(c.items, key)
c.mu.Unlock()
}
func main() {
// hits 统计回源次数:演示“三次读只打一次 DB”
var hits int
cache := NewMemoryCache(time.Minute, func(ctx context.Context, key string) (string, error) {
hits++
// 假装 SELECT nickname FROM users WHERE id=?
return "value-of-" + key, nil
})
ctx := context.Background()
for i := 0; i < 3; i++ {
v, _ := cache.Get(ctx, "u:1")
fmt.Println(v)
}
fmt.Println("load calls:", hits) // 期望 1
}
建议运行(需已 go get golang.org/x/sync):
go run .
期望输出:
value-of-u:1
value-of-u:1
value-of-u:1
load calls: 1
结合场景再看三个关注点
- TTL 限制本次回填条目的有效期;回源本身可能读到旧副本,不能单凭 TTL 推出端到端新鲜度上限。
- singleflight 合并同一 Group、同一 key 的重叠调用,不是跨进程锁,也不限制不同 key 的回源并发。
- 写后失效仍需防止旧读回填;当前示例只演示弱一致缓存,下面的失效实验要区分串行与并发。
核心概念与准确模型
Cache-aside(旁路)
读: cache → hit 返回
→ miss → DB → 写 cache → 返回
写: 写 DB → 删/更新 cache
其他模式
| 模式 | 要点 |
|---|---|
| Read-through | 缓存组件自己回源 |
| Write-through | 写缓存同步写存储 |
| Write-behind | 异步落库,吞吐高、丢数据风险 |
本地 vs Redis
| 本地 | Redis | |
|---|---|---|
| 延迟 | 极低 | 网络级 |
| 数据范围 | 多实例各自一份 | 实例可共享 Redis 中的值;不等于与 DB 强一致 |
| 容量 | 受进程内存限制 | 可独立扩 |
| 失效 | 广播/版本/过期策略 | 删除共享项仍需处理旧读回填及 L1 副本 |
常组合:本地短 TTL + Redis 较长 TTL(L1/L2)。
sync.Map 适用边界
sync.Map 针对写一次多次读、或多个 goroutine 操作基本不相交的 key 集等场景优化;不自带 TTL/容量淘汰,也不保护 value 内部可变数据。普通 map+锁常更容易维护额外不变量。
穿透 / 击穿 / 雪崩
| 问题 | 现象 | 缓解 |
|---|---|---|
| 穿透 | 查不存在的 key 一直打 DB | 空值短 TTL、布隆过滤 |
| 击穿 | 热点 key 过期瞬间并发回源 | singleflight、逻辑过期 |
| 雪崩 | 大量 key 同时过期 | TTL 加抖动、预热 |
设计动机
- 延迟 SLA:P99 读不能总走磁盘/跨机房。
- 保护下游:DB QPS 有上限。
- 成本:缓存命中换机器规格。
边界情况与反直觉行为
1. 缓存与事务
事务未提交就写缓存 → 别的请求读到未提交数据。应在提交成功后失效/更新。
2. 删除 vs 更新
先写 DB 再删缓存是常见策略,但并非强一致保证:T1 回源读到旧值,T2 提交新值并 Invalidate,T1 随后仍可回填旧值。本例就存在此竞争。更强要求需版本校验、协调回填或绕过缓存;Group.Forget 只让后续调用不再等待原调用,不会取消旧回源。
3. 内存保留
本例 TTL 只决定命中与否,不主动删除过期 entry;再也不访问的 key 会留在 map。必须另设清理与容量限制,TTL 不能单独约束内存。value 若含巨大对象图,浅拷贝也可能继续保留引用。
4. 上下文取消
本例 Group.Do 的 loader 使用首个调用者的 ctx:其取消可能使同 key 等待者共同收到错误;其他等待者无法通过各自 ctx 提前退出 Do。命中快路径也不检查 ctx。若需要独立取消,可用 DoChan + select,并给共享回源单独的生命周期和超时;DoChan 返回的 channel 不关闭,只接收一个结果。不能简单换成无期限 Background。
常见误区
[!warning] 常见误区:无上限 map 当缓存
正确:TTL + 最大键数/字节。
[!warning] 常见误区:缓存业务对象直接可变共享
正确:不可变快照或复制。
[!warning] 常见误区:所有读都上 Redis
是否增加 L1 取决于延迟、命中收益和多副本失效成本;并非所有业务都需要多级缓存。
[!warning] 常见误区:缓存当真相源
权威仍在 DB(除非明确设计如此)。
与相邻概念对比
| 概念 | 差异 |
|---|---|
| 连接池 | 复用连接,不是结果缓存 |
| CDN | 边缘缓存静态/准静态内容 |
| 限流 | 保护容量;缓存减少需求 |
| 物化视图 | DB 侧预计算,一致性模型不同 |
工程实践
- Key 设计:user:v1:{id} 只在结果确实仅依赖 id 时充分;租户、权限范围、语言等影响结果的输入也要纳入 key 或隔离缓存。
- 指标:命中率、回源延迟、singleflight 共享次数。
- 序列化:Redis 存 protobuf/JSON,注意版本兼容。
- 测试:用接口抽象 Cache;集成测失效路径。
- 分层:缓存属于 service 或专用 cache 层。
可验证实验
实验 1:命中
连续 Get 同一 key,load 只调用一次。
实验 2:过期
TTL 极短,睡过后 load 再次调用。
实验 3:击穿
用屏障控制同 key 回源重叠,统计共享加载;不要靠同时启动 goroutine 就断言精确次数。失败结果不回填,后到的请求可触发下一轮加载。
实验 4:失效
串行更新、失效、再读应重新回源。再用屏障复现「旧回源读取 → 更新并失效 → 旧回源回填」,确认本例不能保证并发写后立即读新。
本节总结
- 本质:用可接受的不一致窗口换性能。
- 关键规则:Cache-aside、TTL 抖动、失效策略、防击穿。
- 最易错:无淘汰、写后不失效、多实例本地缓存当全局真相。
- 下一步:限流 与缓存一起做容量防护;SQL 仍是权威源。
自测题
概念题
- Cache-aside 读 miss 的步骤?
- singleflight 解决哪类问题?
- 为何写后常选择删缓存而不是更新缓存?
代码推理题
两个实例本地缓存用户资料,只在实例 A 更新并删本地 key——实例 B 多久一致?
工程思考题
如何设计 key 以便“用户改密后所有会话相关缓存失效”?
参考答案
展开
- 查缓存 → miss → 读 DB → 回填 → 返回。
- 同一 key 并发 miss 时合并回源,防击穿。
- 减少并发下旧值覆盖新值的窗口;删除简单。
代码题:B 的本次条目到期或被失效后才重新取值;若回源仍旧或旧读重新回填,新鲜度还会延后,不能断言从 A 写入起一个 TTL 内一致。 工程题:以权威会话/凭证版本作为校验依据,改密时原子递增;读取方必须获取并检查当前版本。只把旧版本放进 key、却继续信任旧缓存,不能撤销会话。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| sync.Map | 标准库 | 并发 map 场景 |
| singleflight | x/sync | 合并飞行中调用 |
| context | 标准库 | 回源超时 |
| go-sync-package · database-sql-in-go | 本库 | 并发与数据源 |