Go 缓存策略
Go 服务缓存:本地内存与 Redis、Cache-aside、TTL/失效、击穿穿透与 singleflight、并发安全取舍。
[!info] 关联笔记
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。
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)
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:写路径在更新 DB 后删缓存,缩短脏读窗口。
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 控制脏数据窗口。
- singleflight 防止热点 miss 并发打穿。
- 写路径要
Invalidate或更新,否则读到旧值直到过期。
核心概念与准确模型
Cache-aside(旁路)
读: cache → hit 返回
→ miss → DB → 写 cache → 返回
写: 写 DB → 删/更新 cache
其他模式
| 模式 | 要点 |
|---|---|
| Read-through | 缓存组件自己回源 |
| Write-through | 写缓存同步写存储 |
| Write-behind | 异步落库,吞吐高、丢数据风险 |
本地 vs Redis
| 本地 | Redis | |
|---|---|---|
| 延迟 | 极低 | 网络级 |
| 一致性 | 多实例各自一份 | 共享 |
| 容量 | 受进程内存限制 | 可独立扩 |
| 失效 | 实例间需广播 | 一处删除即可 |
常组合:本地短 TTL + Redis 较长 TTL(L1/L2)。
sync.Map 适用边界
利于键稳定、写少读多。需要 TTL、淘汰、最大内存时,自研 map+锁或成熟库更合适。
穿透 / 击穿 / 雪崩
| 问题 | 现象 | 缓解 |
|---|---|---|
| 穿透 | 查不存在的 key 一直打 DB | 空值短 TTL、布隆过滤 |
| 击穿 | 热点 key 过期瞬间并发回源 | singleflight、逻辑过期 |
| 雪崩 | 大量 key 同时过期 | TTL 加抖动、预热 |
设计动机
- 延迟 SLA:P99 读不能总走磁盘/跨机房。
- 保护下游:DB QPS 有上限。
- 成本:缓存命中换机器规格。
边界情况与反直觉行为
1. 缓存与事务
事务未提交就写缓存 → 别的请求读到未提交数据。应在提交成功后失效/更新。
2. 删除 vs 更新
并发下“更新缓存”可能用旧值覆盖新值;先写 DB 再删缓存是常见务实选择。
3. 内存保留
缓存 value 若引用巨大对象图,进程 RSS 会悄悄涨。设上限与淘汰。
4. 上下文取消
回源应尊重 ctx;singleflight 共享调用时,一个请求取消不应误杀他人——需仔细设计。
常见误区
[!warning] 常见误区:无上限 map 当缓存
正确:TTL + 最大键数/字节。
[!warning] 常见误区:缓存业务对象直接可变共享
正确:不可变快照或复制。
[!warning] 常见误区:所有读都上 Redis
正确:本地 L1 挡最热路径。
[!warning] 常见误区:缓存当真相源
权威仍在 DB(除非明确设计如此)。
与相邻概念对比
| 概念 | 差异 |
|---|---|
| 连接池 | 复用连接,不是结果缓存 |
| CDN | 边缘缓存静态/准静态内容 |
| 限流 | 保护容量;缓存减少需求 |
| 物化视图 | DB 侧预计算,一致性模型不同 |
工程实践
- Key 设计:
user:v1:{id},带命名空间与版本。 - 指标:命中率、回源延迟、singleflight 共享次数。
- 序列化:Redis 存 protobuf/JSON,注意版本兼容。
- 测试:用接口抽象 Cache;集成测失效路径。
- 分层:缓存属于 service 或专用 cache 层。
可验证实验
实验 1:命中
连续 Get 同一 key,load 只调用一次。
实验 2:过期
TTL 极短,睡过后 load 再次调用。
实验 3:击穿
TTL 到期瞬间 100 个并发 Get,无 singleflight 时 load≈100;有则≈1。
实验 4:失效
更新数据后 Invalidate,下一读应看到新值。
本节总结
- 本质:用可接受的不一致窗口换性能。
- 关键规则:Cache-aside、TTL 抖动、失效策略、防击穿。
- 最易错:无淘汰、写后不失效、多实例本地缓存当全局真相。
- 下一步:限流 与缓存一起做容量防护;SQL 仍是权威源。
自测题
概念题
- Cache-aside 读 miss 的步骤?
- singleflight 解决哪类问题?
- 为何写后常选择删缓存而不是更新缓存?
代码推理题
两个实例本地缓存用户资料,只在实例 A 更新并删本地 key——实例 B 多久一致?
工程思考题
如何设计 key 以便“用户改密后所有会话相关缓存失效”?
参考答案
展开
- 查缓存 → miss → 读 DB → 回填 → 返回。
- 同一 key 并发 miss 时合并回源,防击穿。
- 减少并发下旧值覆盖新值的窗口;删除简单。
代码题:直到 B 的 TTL 到期或收到失效广播;否则一直旧。
工程题:key 含pwd_ver或统一前缀 + 版本号,改密时递增版本。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| sync.Map | 标准库 | 并发 map 场景 |
| singleflight | x/sync | 合并飞行中调用 |
| context | 标准库 | 回源超时 |
| go-sync-package · database-sql-in-go | 本库 | 并发与数据源 |