学完这篇你能得到一套能直接落地的 Go 缓存选型方法:什么时候用本地 map、什么时候上 BigCache、什么时候必须用 Redis,以及三者怎么组合成一个不会出事的缓存层。
第一步:先分层,再选型
Go 里能选的缓存就三类,按「访问延迟 / 容量 / 跨进程」三个维度分:
- 进程内小对象缓存:
map + sync.RWMutex 或 sync.Map,纳秒级,容量受限于单机内存,多实例不共享。
- 进程内大容量缓存:BigCache / FreeCache / Ristretto,仍是纳秒到微秒级,但做了分片和无 GC 压力设计,能塞几十万到上千万条。
- 进程外共享缓存:Redis,网络往返 0.2~1ms,容量可到几十 G,多实例共享,天然支持过期与集群。
判断标准很简单:数据能不能容忍各实例不一致?能 → 本地;不能 → Redis。
第二步:最朴素的本地缓存
type Cache struct {
mu sync.RWMutex
m map[string]entry
}
type entry struct {
val any
expireAt time.Time
}
func (c *Cache) Get(k string) (any, bool) {
c.mu.RLock()
e, ok := c.m[k]
c.mu.RUnlock()
if !ok || time.Now().After(e.expireAt) {
return nil, false
}
return e.val, true
}
上千个 key、读多写少,这就够了,别过度设计。sync.Map 只适合「key 集合基本固定、写一次读多次」的场景(比如配置项、路由表),频繁写入反而比加锁 map 慢。
注意:惰性删除不释放内存。上面 Get 只判过期不删除,key 一直不被访问就会永久驻留。必须再起一个 time.Ticker 定期扫全表删过期项,且注意扫描时别长时间持写锁。
需要单飞(同一 key 只回源一次)时,配 golang.org/x/sync/singleflight:
go get golang.org/x/sync
v, err, _ := group.Do(key, func() (any, error) {
return loadFromDB(key)
})
第三步:BigCache——本地大容量
数据量大到几百万条时,普通 map 会被 GC 拖死(Go 1.12 之后 runtime.mallocgc 扫描 map 的代价很高)。BigCache 把数据存在 []byte 环形缓冲区里,不对每个 entry 建指针,GC 几乎不看它。
go get github.com/allegro/bigcache/v3
cache, _ := bigcache.New(context.Background(), bigcache.Config{
Shards: 1024, // 建议 2 的幂,按 CPU 核数×N 调
LifeWindow: 10 * time.Minute, // 条目存活时间
CleanWindow: 5 * time.Minute, // 清理间隔
MaxEntriesInWindow: 1000 * 10 * 60,
MaxEntrySize: 500, // 单条初始大小,超了也能存但会告警
HardMaxCacheSize: 256, // 硬上限 MB,到顶后淘汰最旧
})
cache.Set("user:1", []byte(`{"name":"a"}`))
b, err := cache.Get("user:1")
注意:BigCache 只能存 []byte,存结构体得自己 JSON/msgpack 序列化;LifeWindow 是固定存活时间,命中不会续期,别拿它当滑动过期用;Delete 只是打删除标记,内存要等 LifeWindow 清理周期才真正回收。
第四步:Redis——跨实例共享
go get github.com/redis/go-redis/v9
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
DB: 0,
PoolSize: 100, // 并发高时按 QPS 调
MinIdleConns: 10,
ReadTimeout: 200 * time.Millisecond,
})
ctx := context.Background()
rdb.Set(ctx, "user:1", data, 10*time.Minute)
rdb.Get(ctx, "user:1").Bytes()
三个必做参数:给 ReadTimeout / WriteTimeout 设上限(默认 3s 太长,Redis 抖动会把整个请求拖死);PoolSize 别低于峰值并发;命令行 redis-cli --latency 确认网络没坑。
注意:TTL 一定要加随机抖动,ttl := base + time.Duration(rand.Intn(60))*time.Second。同一时刻批量写入的 key 如果不抖动,会在同一秒集体过期,直接打穿到数据库。
第五步:多级缓存 L1 + L2
生产上最常见的组合:L1 本地缓存扛住热点(TTL 3~10 秒),L2 Redis 做共享层(TTL 10 分钟~1 小时),DB 兜底。
读路径:L1 命中 → 返回;L1 未命中 → 查 L2 → 回填 L1;L2 未命中 → 查 DB → 回填 L2 和 L1。
写路径的关键是先更新 DB,再删缓存。至于多实例的 L1 怎么同步,最省事的做法是 Redis Pub/Sub 广播失效:
pubsub := rdb.Subscribe(ctx, "cache:invalidate")
go func() {
for msg := range pubsub.Channel() {
localCache.Delete(msg.Payload) // 收到广播就删本地 key
}
}()
// 写操作时
rdb.Publish(ctx, "cache:invalidate", "user:1")
注意:广播是「最终一致」,中间有几十毫秒窗口。对一致性极敏感的数据(余额、库存)别放 L1,直接只走 Redis。另外 Pub/Sub 消息会丢(Redis 不持久化、断线期间的消息收不到),所以 L1 的 TTL 必须保留,它是兜底而不是优化项。
第六步:三个必修的防御招式
- 穿透(查不存在的数据):查库为空也缓存
nil,TTL 短一些(30~60 秒);数据量大就上布隆过滤器。
- 击穿(热点 key 过期瞬间):用
singleflight 合并回源请求,同一个 key 并发只会有一个真的查库。
- 雪崩(大批 key 同时失效或 Redis 挂了):TTL 加抖动 + 本地缓存兜底 + 对回源 DB 做限流(
golang.org/x/time/rate)。
第七步:加指标,别靠感觉调参
至少暴露四个计数:L1 命中率、L2 命中率、回源次数、Redis 命令 P99 延迟。命中率低于 80% 说明 TTL 太短或 key 设计有问题;P99 突然升高先看网络和慢查询(redis-cli slowlog get 10),别急着加机器。
小结
- 选型看一致性要求:能容忍实例不一致就本地,不能就 Redis。
- 小数据量用加锁 map,别为了「看起来高级」上 BigCache。
- BigCache 只存
[]byte,LifeWindow 是固定存活时间,删除不立即还内存。
- Redis 必设超时、连接池大小,TTL 必须加随机抖动。
- 多级缓存写路径 = 先更 DB 再删缓存 + Pub/Sub 广播失效,L1 的 TTL 是必留的兜底。
- 穿透缓存空值、击穿用 singleflight、雪崩靠抖动加限流。
- 没有监控的缓存等于没有缓存,先把命中率和 P99 打出来。