
singleflight 是什么,什么时候该用它
它不是缓存,也不是限流器,而是一个请求去重工具:当多个 goroutine 同时发起相同 key 的请求时,singleflight.Do 保证只执行一次底层操作(比如查 DB、调 HTTP),其余协程等待并共享结果。
典型场景是「缓存失效后大量并发请求穿透到下游」——也就是你提到的缓存击穿。但注意:singleflight 本身不解决缓存问题,它只是配合缓存用的胶水层。
常见错误现象:
• 缓存刚过期,100 个请求同时打到数据库,DB CPU 瞬间拉满
• 日志里看到重复的 SELECT ... WHERE id = ? 几乎同时出现几十次
• 使用了 sync.Once 却发现对不同 key 无效(它只认“一次”,不认“key”)
怎么用 singleflight.Do 才不踩坑
核心是理解它的三个参数和返回值语义。别直接套模板,尤其注意 key 和 fn 的生命周期。
立即学习“go语言免费学习笔记(深入)”;
-
Do(key string, fn func() (interface{}, error))中的fn必须是无参闭包;如果要传参(比如 ID),得在外层捕获,不能写成Do(id, func() { db.Query(id) })—— 这样闭包会捕获外部变量,容易引发数据竞争 - key 要能准确区分请求粒度:比如查用户,用
"user:" + strconv.Itoa(uid),别用fmt.Sprintf("%v", userReq)(结构体字段顺序/空字段影响哈希) - 返回值是
(interface{}, error, bool):第三个bool表示是否是“首次执行”。很多同学忽略它,导致误把缓存未命中当成执行失败 - 别在
fn里做长耗时或不可重入操作(比如发邮件、扣库存),因为 singleflight 不提供超时或取消机制
和 Redis 缓存组合使用的典型结构
singleflight 一定要放在缓存读取之后、回源之前,否则就失去意义。标准流程是:查缓存 → 命中则返回;未命中则用 singleflight.Do 回源 → 写回缓存 → 返回结果。
容易被忽略的点:
• 如果回源失败(比如 DB 挂了),singleflight 会把 error 也广播给所有等待者,此时应考虑加一层 fallback 或降级逻辑
• 缓存写入必须在 singleflight 内部完成,否则可能多个 goroutine 同时写缓存,造成脏数据
简短示例:
val, err, shared := sg.Do("user:123", func() (interface{}, error) {
u, err := db.GetUser(123)
if err == nil {
_ = cache.Set("user:123", u, time.Minute)
}
return u, err
})
if err != nil {
// 注意:shared == true 表示这是首个请求且失败了;shared == false 表示等到了别人的失败结果
}
性能和兼容性要注意的边界
singleflight 内部用 map + sync.Mutex 实现,key 太多会内存泄漏,且 map 不支持 GC 自动清理。
- key 必须可控:禁止用请求 body、URL 参数拼接等不可控输入作为 key,否则可能 OOM
- 没有内置 TTL,长期运行的服务需自行清理:比如用
sync.Map替代原生 map(但官方singleflight不支持),或定期重建*singleflight.Group - Go 1.21+ 对
singleflight做了小优化(减少锁争用),但老版本(如 1.16)在极高并发下可能出现短暂排队放大效应 - 它不兼容 context 取消:如果想支持超时,得在外层包一层
context.WithTimeout,并在fn里手动检查ctx.Done()
最常被漏掉的是 key 生命周期管理——没人清理,它就一直占着内存,而且不会报错,直到某天 pprof 发现 map 占了几个 GB。










