singleflight 不是缓存,也不能单独防击穿;它仅保证相同 key 的并发请求中仅一个执行加载逻辑,其余等待共享结果,所有加载操作(含DB查询、HTTP调用、缓存写入等)必须封装在Do回调内,且Group需全局复用、key须稳定幂等。

singleflight 不是缓存,也不能单独防击穿——它只做一件事:同一时刻,相同 key 的请求,只让一个真正执行,其余等着分结果。
怎么写一个安全的 Do 调用
错误写法是先查缓存、再判断 miss、最后才进 Do;这会在两个 goroutine 同时看到 miss 时,都触发 Do,虽然 Do 内部仍会去重,但你放在外面的日志、指标、甚至 DB 连接初始化,就重复跑了。
- 所有真实加载逻辑(
db.QueryRow、http.Get、解析、甚至成功后的cache.Set)必须全部塞进Do的回调函数里 -
key必须是稳定字符串,别传struct{}或裸int,用strconv.Itoa(id)或fmt.Sprintf("user:%d", id) -
Do第二个参数签名固定为func() (interface{}, error),漏掉interface{}返回会导致编译失败 - 如果加载可能超时,务必在回调里用带
ctx的 API(如db.QueryRowContext),否则等待者可能卡死
为什么 Group 不能声明在 handler 里
常见压测现象:QPS 上去后,DB 查询数还是线性涨,singleflight 像没起作用——大概率是因为你在每个 HTTP handler 函数里都 var g singleflight.Group 了。
-
singleflight.Group是线程安全的,应该全局复用,比如定义为包级变量或注入到 service 结构体中 - 每次新建
Group,它的内部map[string]*call就是空的,完全无法跨 goroutine 去重 - 别把它当临时对象用,它的生命周期应与服务一致
key 设计错,等于白加
HTTP 请求里带 X-Request-ID、timestamp、随机 query 参数?这些会让本该合并的请求变成不同 key,直接绕过去重。
立即学习“go语言免费学习笔记(深入)”;
- 提取业务本质 key,比如用户查询就用
"user:" + strconv.Itoa(userID),而不是整个r.URL.String() - 排除非幂等字段:header 中的 trace 相关头、动态时间戳、客户端生成的随机 ID 都不该进 key
- 静态资源、健康检查(
/healthz)、metrics 接口等,根本不该走singleflight,徒增锁开销和延迟
不配合缓存,singleflight 就是半把刀
它自己不存数据,也不管你查没查过。典型误用:只加 Do,但没配本地缓存(比如 sync.Map),结果每次请求都进 Do,只是“排队等一次 DB”,没解决重复压力问题。
- 标准链路必须是:查本地缓存 → miss →
g.Do(key, loadFn)→loadFn里查 DB 并成功后才cache.Store(key, data) - 注意顺序:写缓存必须在
loadFn内且仅在成功路径上,失败时不写,避免缓存脏数据 - 别指望它防穿透(比如查不到的负数 ID),那得靠布隆过滤器或空值缓存
最容易被忽略的一点:shared 返回值不是用来做分支逻辑的,它只是告诉你“这个结果是不是别人算出来的”;你真正要依赖的,只有 val 和 err。别在 shared == true 时跳过某些校验——那会破坏一致性。


















