singleflight不能直接防缓存击穿,它仅合并同一key的并发请求;真正防击穿需配合缓存读取逻辑、回源加载、成功后写缓存三者闭环,并确保group全局复用、key稳定幂等、超时与错误由回调内手动处理。

singleflight 不能直接“避免缓存击穿”,它只负责合并重复的并发请求;真正防击穿,得靠你主动 fallback 到回源逻辑 + 合理的缓存过期策略。
为什么 singleflight.Group 能缓解热点 key 压力
当大量 goroutine 同时查一个未命中的缓存 key(比如突发流量访问某个商品详情),若每个都去查 DB 或下游服务,就会造成雪崩。而 singleflight.Group 会让这些并发请求共享同一个执行过程:只有一个会真正调用你的回源函数,其余阻塞等待结果返回后直接复用。
关键点在于:Do 的第一个参数是 key(string),不是缓存键本身,而是“正在加载这个资源”的标识符——你可以用业务 ID 拼接前缀,比如 "load_user_" + userID,但别直接用 Redis key(如 "user:123"),否则不同加载逻辑可能意外共享。
- 多个 goroutine 对同一
key调用Do→ 只有第一个触发fn,其余等待 -
fn返回interface{}和error,Do把结果广播给所有等待者 - 如果
fnpanic,Do会 recover 并转为error,不会让调用方 panic
典型误用:把 Do 当作缓存读写入口
常见错误是这样写:
Go语言(Golang)1.26.0版本提供 Go 官方 Windows amd64 MSI 安装包下载入口,版本号 1.26.0,可用于旧项目维护、兼容性测试和指定版本开发环境配置。
func GetUserInfo(id string) (*User, error) {
v, err := group.Do("user:"+id, func() (interface{}, error) {
// 直接查 DB,没走缓存
u, err := db.QueryUser(id)
return u, err
})
if err != nil {
return nil, err
}
return v.(*User), nil
}
这会导致每次请求都进 Do,完全绕过缓存,反而放大延迟。正确姿势是:先查缓存,未命中再用 singleflight 控制回源。
- 必须先
redis.Get或类似操作,命中就直接返回 - 未命中时,用
group.Do("load_user_"+id, ...)触发回源,成功后记得redis.Set回填 -
Do的 key 和缓存 key 分开管理,避免语义混淆
singleflight 的超时与清理问题
singleflight.Group 本身不提供超时控制,也不自动清理历史 key。如果某个 key 的 fn 执行卡住(比如 DB 连接超时未设 deadline),所有等待它的 goroutine 都会无限阻塞,直到那个 fn 返回或 panic。
- 务必在
fn内部设置上下文 deadline,例如ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - 不要依赖
singleflight做长期状态管理——它没有 TTL,旧 key 会一直留在内存里(虽然值被 GC,但 map key 不会自动删) - 高 QPS 场景下,可考虑定期用
sync.Map替代原生 map 实现带驱逐的 group(需自行封装),但多数情况没必要
最易被忽略的一点:singleflight 只解决“并发打穿”,不解决“缓存失效瞬间的空窗”。哪怕你用了它,如果缓存过期后恰好有一波请求进来,它们仍会集体触发回源——只是变成一次回源,而不是 N 次。真正的防护要叠加 stale-while-revalidate、本地二级缓存或提前刷新机制。










