SingleFlight是一种并发控制原语,对相同key的并发请求仅执行一次真实操作(如查DB),其余等待并复用结果,从而天然防止缓存击穿;但它不存储数据、不传递context、非幂等操作不可用,须在业务层按需集成而非全局拦截。

Singleflight 是什么,为什么它能防缓存穿透
Singleflight 不是中间件,也不是拦截器,它本质是一个并发控制原语:对相同 key 的多次并发调用,只让第一个发起请求的 goroutine 执行真实操作(比如查 DB),其余等待其结果并直接复用——这天然消除了“大量并发请求同时打穿缓存去查 DB”的问题。
但它不自动拦截 HTTP 请求,也不能直接挂到 Gin/echo 的 middleware 链里;强行包装成“拦截器”容易误用,比如在 handler 里漏掉 singleflight.Do 调用,或把非幂等操作塞进去。
- 适用场景:缓存未命中后查 DB、远程配置拉取、token 解析等「相同参数下结果确定」的操作
- 不适用场景:POST 创建资源、带副作用的写操作、依赖 request context 生命周期的逻辑(
singleflight不传递 context 取消信号) - 常见错误:把
singleflight.Group.Do放在中间件里统一 wrap 所有 handler——key 构造逻辑无法泛化,且可能包裹了不该去重的操作
在 Go 微服务中正确集成 Singleflight 的位置和方式
应该在业务逻辑层(service 或 repository 层)按需使用,而不是在 transport 层(HTTP/gRPC handler)做全局拦截。典型路径是:handler → service → repo → db/cache,Singleflight 就放在 repo 层的查询方法里。
示例:用户查询服务中,当 Redis 缓存 miss 后查 MySQL,用 singleflight 控制并发回源:
立即学习“go语言免费学习笔记(深入)”;
var userGroup singleflight.Group
<p>func (r <em>UserRepo) GetByID(ctx context.Context, id int64) (</em>User, error) {
// 先查缓存
u, err := r.cache.Get(ctx, "user:"+strconv.FormatInt(id, 10))
if err == nil && u != nil {
return u, nil
}</p><pre class="brush:php;toolbar:false;">// 缓存未命中,走 singleflight 回源
v, err, _ := userGroup.Do(strconv.FormatInt(id, 10), func() (interface{}, error) {
return r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", id).Scan(...)
})
if err != nil {
return nil, err
}
return v.(*User), nil}
-
userGroup必须是包级变量或注入的单例,不能每次 new —— 否则去重失效 -
Do第二个参数函数必须是无副作用、幂等的;返回值类型要一致(interface{}需手动断言) - 不要在
Do函数里传入ctx并期望它被 cancel ——singleflight不传播 cancel,超时需在内部函数自己处理
与缓存层配合时 key 设计和过期策略陷阱
Singleflight 的 key 和缓存 key 应该对齐,否则会出现“缓存已更新但 singleflight 还在等旧 key 结果”的错乱。更危险的是:如果缓存 key 带了版本号或时间戳(如 user:123:v2),而 singleflight 用的是 123,那不同版本请求会共享同一个 group,导致脏数据。
- 推荐做法:singleflight key 直接复用缓存 key 字符串(如
"user:123"),而非仅 ID - 避免用结构体或 map 做 key ——
singleflight内部用map[string]chan,key 必须可比较且稳定 - 如果缓存设置了 TTL,singleflight 不会自动失效;需配合主动清理(如更新时调用
userGroup.Forget(key))或用带过期的 wrapper(如gocache+singleflight组合)
性能和可观测性注意事项
Singleflight 在高并发下本身开销极低(锁粒度细、无系统调用),但容易掩盖上游瓶颈:比如 DB 查询慢,所有等待 goroutine 都卡在 Do 返回,表现为延迟毛刺而非错误。这时光看 QPS 没用,得看 singleflight 的等待队列长度和平均等待时间。
- 可通过
singleflight的Forget或自定义 wrapper 暴露指标(如 Prometheus counter 记录singleflight_hit/singleflight_miss) - Go 1.21+ 提供了
singleflight.NewGroup()支持自定义 hasher,但绝大多数场景用默认 string key 即可,别过早优化 - 线上出问题时,
pprof看 goroutine stack 很可能发现一堆卡在runtime.gopark—— 别急着加机器,先查是不是 singleflight 下游 DB 或 RPC 超时没设好
真正难的不是加一行 singleflight.Do,而是判断哪里该加、key 怎么定、失败后怎么清理、以及如何确认它没在掩盖更深层的问题。


















