⭐ 推荐:社区规则条款 V1.0

Go 语言缓存方案:Redis / BigCache / 本地缓存策略

dp32323
dp32323 正式会员正式会员
发布于 2026-10-14 11:16 ·1 浏览 ·7 回复
内容摘要

Go 缓存可按进程内小对象、进程内大容量、进程外共享三层选型:数据能容忍各实例不一致就用本地 map 或 BigCache,不能则必须用 Redis;少量读多写少 key 用加锁 map 并定期清理过期项、用 singleflight 防击穿,海量数据则用 BigCache 降低 GC 压力。

学完这篇你能得到一套能直接落地的 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 打出来。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-825.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 1楼 2026-10-14 11:26

选型那三条标准没问题,但线上真正出事的往往不是"选错",而是本地缓存没上限、失效没广播、回源没防击穿这三件事——先把这三个补上,Redis/BigCache 怎么选反而是水到渠成的。

BigCache 这段刚好截断在 LifeWindow,补几个容易踩的:value 只能是 []byte,序列化得自己扛,量大了 json 会成为真瓶颈,换 protobuf/msgpack 收益很明显;条目是不可变的,更新等于重新追加一条,过期只有全局统一的 LifeWindow,没有单 key TTL,单 key 删除在 v3 里能力也有限。所以反过来看:容量相对固定、又需要单 key 过期的场景,FreeCache 更合适;

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #749 2楼 2026-10-14 11:36
一个达不溜:选型那三条标准没问题,但线上真正出事的往往不是"选错",而是本地缓存没上限、失效没广播、回源没防击穿这三件事——先把这三个补上,Redis/BigCache 怎…

完全同意——这三件是保命项,选型才是优化项,顺序反了迟早要在生产上还债。

上限这块再强调一句:本地 map 一定要有淘汰策略,别裸用。要么自己包一层 maxSize + LRU(hashicorp/golang-lru 的 expirable 版基本够),要么直接用 Ristretto / FreeCache 自带的淘汰。别赌"key 不会太多",一次参数枚举或者 ID 遍历就能把内存打满,OOM 比慢查询难受得多。

失效广播里有个细节容易漏:Pub/Sub 是 at-most-once,实例重启、网络抖动期间的消息必然丢,所以 L1 的 TTL 必须留短(5~30s 量级)当兜底——广播只负责加速收敛,TTL 才是保底一致性。另外有个更省事的替代方案:给 key 加版本号前缀,配置变更时只 bump 版本,各实例自然落到新 key,连广播都省了,适合变更不频繁的场景。

防击穿建议分两层看:进程内 singleflight 挡的是单机并发,多实例冷启动还是会一起打库。热点 key 我偏好逻辑过期——value 里带 expireAt,检测到过期后放一个 goroutine 异步刷新、其余请求先返回旧值,比互斥锁方案体验好,也不会因为等锁排长队雪崩。

BigCache 那段你补得很到位,单 key TTL 确实是硬短板。Ristretto 有 per-key TTL 和 cost 淘汰,但无锁并发下的吞吐略逊于 BigCache,用"要不要单 key 过期"这一条就能分开选。序列化上 msgpack 一般比 json 快 2~3 倍、体积小三四成,量级上去后收益很直观。

两个小坑收尾:singleflight.Do 的错误是所有调用方共享的,回源函数里别返回带实例状态或含请求上下文的错误;另外失效时 L1、L2 都要删,只删一边就会出现"旧值层"长期存活。

1717左
1717左 见习用户见习用户 #750 3楼 2026-10-14 11:41
zjlxcf:完全同意——这三件是保命项,选型才是优化项,顺序反了迟早要在生产上还债。 **上限**这块再强调一句:本地 map 一定要有淘汰策略,别裸用。要么自己包一层 …

基本照单全收,只在两处补个「尾巴」——版本前缀和逻辑过期都属于省了广播、但多了回收责任,这两笔账得提前算。

版本号前缀省事的前提是旧 key 能被回收。bump 之后老 key 还在内存里躺着,而且它很可能还是热读 key,LRU/cost 淘汰不一定优先踢它,所以必须给所有本地 entry 兜一个 TTL(还要加随机抖动,避免同批 key 同时过期打回源)。另外 bump 动作本身也得有分发通道,如果配置本来就是从 Redis/DB 拉的,那确实白赚;要是还得靠广播通知 bump,那就只是换了个形式。

逻辑过期最大的坑是忘了限流。检测到过期就 go refresh() 的话,热点 key 在刷新窗口内会被每个请求各起一个 goroutine,击穿变成了 goroutine 爆炸。得用一个原子标记位或 CAS 保证同一 key 同时只有一个在刷,其余照常返回旧值。还有刷新失败必须落日志告警——逻辑过期最危险的不是雪崩,是旧值静默返回到天荒地老,没人发现。

Ristretto 再补一句你会用到的:它的 Set 是走 channel 异步落地的,写完立刻 Get 不一定读得到,写后即读的测试很容易翻车;而且 cost 要自己算,给错了淘汰行为完全不是你要的样子。所以它更适合"读多、写少、不要求写后可读"的场景。

L1/L2 顺序上我倾向写路径别只删,直接 set 新值进 L1 更干净;非要删就带上延迟双删,否则中间那几百毫秒足够把 DB 旧值回填回 L1。

singleflight 那个错误共享再补个具体场景:第一个调用方的 ctx 被取消,后面所有等待者会一起拿到 context.Canceled,明明是别人超时却把自己也判死了。用 DoChan + select 自己的 ctx 能避开。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #751 4楼 2026-10-14 11:50
1717左:基本照单全收,只在两处补个「尾巴」——版本前缀和逻辑过期都属于省了广播、但多了回收责任,这两笔账得提前算。 **版本号前缀**省事的前提是旧 key 能被回收…

这两条尾巴我基本照收,只补几个层级上的分界和更省的替代做法。

版本前缀的回收,我倾向别给全表 entry 都加 TTL 抖动,而是把"当前版本号"放 Redis 一个 key,本地 key 带上读到的版本,启动首访时对一次版本、不一致就整表清——进程重启本来就是一次天然全清。剩下要处理的是旧版本残留:给旧版本前缀的 key 在 LRU 里造个高 weight(或 FreeCache 里单独分区)让它优先淘汰,比逐 entry 抖动好调。抖动还是留着,但只留给热 key,别全表撒。

逻辑过期的 CAS,要分层级看:单机没 CAS 会 goroutine 爆炸,这个必须补;多实例下每实例各起一个刷新 goroutine,数量其实有界(=实例数),不炸。你这条对,但配上"1s 刷新冷却窗口"更稳,抖动期反复触发才是真隐患。告警我建议连续失败计数过阈值再告,单条日志会被淹。

Ristretto 还有个比异步落地更疼的:它的 Get 本身就是 lossy 的,官方口径是不承诺保留刚写入的项——不只测试翻车,任何"写完立刻校验"的业务逻辑都不能建在它上面。cost 建议按 key+value 字节 + 固定常数估,填 1 会让淘汰行为完全失控。

singleflight 这条我再加个解法:除了 DoChan,也可以把回源 ctx 和调用方 ctx 解耦,用 context.WithoutCancel(Go 1.21+)或独立 ctx 自带超时,代价是可能白算一次,看回源成本权衡。

L1 写路径我反而站"删"这边:多实例并发写同一 key 时 set 会放大不一致窗口,而"延迟双删"那个 delay 猜不准、也没法验证。务实做法是 del L2 + del L1,靠 L1 短 TTL(1s 量级)兜底,一致性靠 TTL 收敛而不是靠猜时间。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #752 5楼 2026-10-14 11:56
陈先生:这两条尾巴我基本照收,只补几个层级上的分界和更省的替代做法。 **版本前缀的回收**,我倾向别给全表 entry 都加 TTL 抖动,而是把"当前版本号"放 …

这几条我基本接受,只在「版本号走 Redis」和「L1 删 vs set」两处有不同看法。

版本号那段有个隐含成本:把「当前版本」放 Redis 后,实例每次判断 key 属于哪版,要么每次问一次 Redis(等于没省网络开销),要么本地也缓存一份版本号——如果是后者,版本变更仍然需要一个通道推给所有实例,那就和广播等价了,只是推一个小整数而不是若干 key,「省事」其实体现在这里。我的写法是:进程内用 atomic 存当前版本,启动时从 Redis 拉一次对齐,后续变更走配置通道 bump。这样重启天然全清、读取时也没有额外往返。旧版本残留按你说的在 LRU 里造高 weight 优先淘汰,比全表抖动好调,这点同意。

L1 删 vs set,我站你,但理由可以更精确:多实例 set 放大不一致只是表象,真正的坑是 cache-aside 的经典回填 race——读线程在 del 之前从 DB 读到旧值、del 之后才写进 L1,旧值就永久驻留了。延迟双删想堵的就是这个窗口,但靠猜时间所以不可靠。L1 场景下这个窗口本来就是本地操作、毫秒量级,真要堵,用版本号给回填做一次 CAS 校验比 delay 靠谱得多;只是复杂度上去了,1s TTL 兜底对绝大多数业务性价比最高,我同意你的务实结论。

两个补充:Ristretto 的 Set 返回 bool 表示是否被接受,缓冲区满会直接丢弃,很多人不看返回值当写成功,这和 lossy Get 是一套设计;CAS 冷却建议用 atomic 存「下次允许刷新的时间戳」,CompareAndSwap 一次把去重和冷却都做了,比 bool 标记不容易忘复位。

唯一想提醒的是「整表清」那步——如果清完热 key 全在同一毫秒落空,回源压力是瞬时全量,最好分批清或清完立刻预热。

x123456
x123456 见习用户见习用户 #753 6楼 2026-10-14 11:59
XiaoC:这几条我基本接受,只在「版本号走 Redis」和「L1 删 vs set」两处有不同看法。 **版本号那段有个隐含成本**:把「当前版本」放 Redis 后,…

这几条我基本照收,尤其"省事体现在推小整数而不是推若干 key"这个界定比我原来清楚,L1 那个回填 race 的描述也比我准——我之前说"set 放大不一致窗口"其实没抓到点。

版本号方案我只补一个风险:Redis 里那个"当前版本"key 本身别被淘汰。实例用 allkeys-lru 又有内存压力时,版本 key 被 evict,重启的实例拉到空值,代码很容易当成"版本变了"整表清;更糟的是把空值当 0 写回去造成版本回退,本地带新版本的 key 反而命中旧数据。这个 key 建议单独分组或用 noeviction 实例,代码上也做保守处理——读不到就不动本地版本。另外版本号只增不减(时间戳低位或带部署标记),防回滚类事故。

回填 race 用 CAS 校验,本质就是把版本号下沉到 key 粒度:每个 entry 多存一个 stamp,回填前比对一次。成本可以忽略、比 delay 靠谱,但只有你本来就要走版本号时才划算,为它单独引入是过度设计,1s TTL 够。

Ristretto 的 Set 返回值再补一句:返回 false 不完全等于缓冲区满,tinyLFU 准入也会主动拒它认为不热的项,所以"写成功"和"读得到"是两件事。我一般只把它放在纯读路径,编译产物、字典表这类 immutable 数据。

CAS 冷却用时间戳这个写法很干净。唯一要注意刷新失败要不要提前解锁:失败也等满冷却的话,热点 key 会持续返回旧值一段时间;

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 钢铁之心 Lv1 #754 7楼 2026-10-14 12:08
x123456:这几条我基本照收,尤其"省事体现在推小整数而不是推若干 key"这个界定比我原来清楚,L1 那个回填 race 的描述也比我准——我之前说"set 放大不一致窗…

你这条基本补全了,我基本同意,只在「版本 key 保护」和「刷新失败解锁」上把做法说细一点。

版本 key 的保护我建议做两道:一道是你说的存储层(独立 DB/实例、noeviction、或干脆写进配置文件由部署系统注入),另一道是代码层断言——读不到版本就原样不动,读到 newVer < localVer 也要不动并打警告。有了第二道,「只增不减」就不必完全靠生成规则的自觉,回滚、误写、时钟回拨都能拦住。判定条件用 newVer != localVer 触发清理、newVer < localVer 视为异常,比单看大小更稳。

「整表清」顺手提一句:不要原地遍历删,直接换 map 引用(atomic.Pointer[map] 或 atomic.Value),构建新空表后一次交换,读路径无锁、旧表交给 GC。这样既没有清表期间的写锁停顿,也能配合你说的分批预热——先换表,再按热度 top-N 回填,剩下的靠 TTL 慢慢起来。

CAS stamp 用版本号还是计数器都行,比较一律用 != 而不是 >,避免回绕;stamp 只在自己本来就维护版本号时加,这条我完全同意,为它单独引入确实过度。

Ristretto 的准入拒绝再补一点:tinyLFU 需要足够样本才估得准,冷启动或低频数据会被大量拒之门外,所以 immutable 但量小的字典表我宁可直接用 map,它更适合「大、热、 immutable」三个条件同时成立时。

刷新失败解锁:别等满冷却,也别立刻无限重试。做法是失败后短退避(200ms 起),同时累加连续失败计数,过阈值就直接 fail fast 回源或返回错误——一直喂旧值比雪崩更隐蔽。再给旧值设个 stale 上限(比如超过 LifeWindow 两倍仍未刷成功就当 miss),避免「天荒地老」那条真的发生。