singleflight 本身不能防缓存击穿,必须与缓存读写逻辑协同设计;所有副作用操作须封装进 Do 回调,Group 必须全局复用,Do 不感知 ctx 取消,需手动处理超时与降级。

singleflight 本身不能防缓存击穿,它只负责合并同一 key 的并发请求;真正防击穿必须配合缓存读写逻辑一起设计,漏掉任何一环都会失效。
缓存 miss 后必须把整个加载流程塞进 Do 回调里
常见错误是先查缓存、判断 miss 后再调 Do,这会在两个 goroutine 都看到 miss 时触发竞态窗口——虽然 Do 最终只让一个执行,但你在 Do 外做的日志、指标打点、甚至部分 DB 查询(比如预检查)都可能重复发生。
正确做法是:所有副作用操作(DB 查询、HTTP 调用、缓存写入、错误处理)全部封装进 Do 的函数参数中:
func GetUser(ctx context.Context, id int) (User, error) {
v, err, _ := g.Do(strconv.Itoa(id), func() (interface{}, error) {
// ✅ 全部加载逻辑在此处
u, err := db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", id).Scan()
if err != nil {
return nil, err
}
// ✅ 缓存写入也放这里,确保只在成功后写
cache.Set(fmt.Sprintf("user:%d", id), u, time.Minute)
return u, nil
})
if err != nil {
return User{}, err
}
return v.(User), nil
}-
Do第二个参数必须是func() (interface{}, error),别漏掉interface{}返回类型 - key 必须是稳定字符串,别传
int或 struct,用strconv.Itoa或fmt.Sprintf格式化 - 别在
Do外做任何可能失败或有状态变更的操作(比如提前cache.Delete或记录 trace)
singleflight.Group 必须全局复用,不能按请求 new
singleflight.Group 是线程安全的,内部用 sync.Map 管理 pending 请求。如果每次 handler 都 new 一个新 Group,那就完全失去去重能力——每个实例都是独立的“队列”,根本不会共享状态。
立即学习“go语言免费学习笔记(深入)”;
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
应该声明为包级变量或注入到依赖容器中:
var userGroup singleflight.Group // ✅ 全局唯一
// 或
type UserService struct {
group *singleflight.Group
}
func NewUserService(g *singleflight.Group) *UserService {
return &UserService{group: g} // ✅ 复用传入的实例
}- 别在 HTTP handler 里
g := new(singleflight.Group)—— 这等于没用 - 多个业务逻辑(如用户、配置、权限)可共用同一个
Group,无需按领域拆分 - 不用手动清理
Group内部状态,它不持有长期资源
必须自己处理 ctx 超时,Do 不感知上下文取消
singleflight.Do 本身不接受 context.Context,也不会主动响应 cancel。如果 Do 里的加载逻辑(比如 http.Get 或 db.Query)用了带超时的 ctx,那等待中的 goroutine 仍会卡住,直到第一个请求完成或 panic。
正确做法是在 Do 回调内显式使用带超时的 ctx,并让等待者也能及时退出:
v, err, shared := g.Do(key, func() (interface{}, error) {
// ✅ 用传入的 ctx 控制本次加载
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
u, err := db.QueryRowContext(ctx, query, args...).Scan()
return u, err
})- 等待者拿到的是第一个完成者的返回值,但如果第一个请求因超时失败,所有等待者都会收到相同错误
- 没有内置重试机制,错误会直接透出;若需降级,得在回调里自己加 fallback 逻辑
- 不要依赖
shared变量做业务判断——它只表示“这个结果是不是别人算出来的”,不反映成功/失败
最容易被忽略的一点:singleflight 解决不了加载逻辑本身慢或不稳定的问题。如果 Do 里调的是高延迟、低成功率的外部服务,它只会把雪崩从“50 个并发请求”变成“50 个串行等待+1 个失败”,反而延长了整体 P99 延迟。这时候该上熔断、降级或本地缓存兜底,而不是只靠 Do。

















