singleflight能防止缓存击穿,其核心是让同一key的并发请求只执行一次加载逻辑并共享结果;但必须配合先查缓存、稳定key命名、分层fallback及超时控制等策略才有效。

热路径上缓存未命中不是“等它自己好”,而是必须主动控制并发、避免雪崩、防止重复加载——否则一个 key 失效,几十个 goroutine 同时打穿缓存去查 DB,QPS 瞬间翻倍,数据库先扛不住。
用 singleflight.Group 拦住并发重复加载
go-cache、sync.Map 本身不合并未命中请求,singleflight.Group 是唯一轻量且可靠的解决方案。
- 每个热 key 对应一个
sg.Do("user:123", loadFunc)调用,不管多少 goroutine 同时进来,只执行一次loadFunc -
loadFunc必须返回(interface{}, error),成功后所有等待者拿到同一份结果,失败则全部收到 error - 别把
sg.Do包在cache.Get前面就完事:要先cache.Get,未命中再sg.Do;否则每次请求都进 singleflight,失去本地缓存意义 - key 命名要稳定:比如用
"user:" + strconv.FormatInt(id, 10),别用fmt.Sprintf("%+v", req)—— 字段顺序一变,key 就不同,singleflight 形同虚设
预热 + 主动失效比被动过期更可控
热数据靠 TTL 自然过期,等于把压力交给高峰时刻。对已知高频 key,该预热就预热,该失效就失效,别赌“它刚好没过期”。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- 服务启动后,用 goroutine 异步调用
cache.Set("config:global", data, cache.NoExpiration)预热核心配置,避免首请求卡顿 - 监听配置变更(如 fsnotify)、DB 更新(如 binlog/cdc)或业务事件,触发
cache.Delete("user:123"),而不是等它自然过期 - 预热和失效都要加限流:比如用
semaphore.NewWeighted(5)控制并发预热数量,防 Redis 或 DB 瞬间被打满 - 别给所有 key 统一设
time.Minute * 5:配置类设time.Hour * 24,用户属性类设time.Minute * 10,实时指标类设time.Second * 30
本地缓存未命中时的 fallback 路径要分层
热路径不能只有一条路可走。本地未命中 ≠ 直接查 DB,中间至少还有一层远程缓存兜底。
立即学习“go语言免费学习笔记(深入)”;
- 典型 fallback 顺序:
local cache → redis → db,每层 miss 都要记录指标(如cache_miss_local_total),否则无法定位瓶颈在哪一层 - redis 层也得防击穿:对空结果(
redis.Nil)同样写入本地缓存,TTL 缩短为time.Second * 10,避免反复穿透 - db 查询必须带 context 超时(如
ctx, _ := context.WithTimeout(r.Context(), 200*time.Millisecond)),且超时后返回上层缓存旧值(stale-while-revalidate),而非直接报错 - 如果 redis 也挂了,本地缓存该保留 stale 数据就保留,别清空——热数据有几秒延迟,总比整个接口不可用强
真正难的从来不是“怎么加缓存”,而是“缓存失效那一瞬间,系统有没有预案”。singleflight、预热、分层 fallback 这三样,漏掉任何一环,热路径上的未命中就会变成压垮服务的最后一根稻草。

















