直接用 sync.once 不行,因为它只能保证函数全局执行一次且不支持按 key 分组等待与结果共享;而 singleflight.group 可按 key 合并并发请求,避免缓存击穿。

为什么直接用 sync.Once 不行
sync.Once 只能保证一个函数全局只执行一次,但缓存击穿场景下,你面对的是大量并发请求同时查同一个 key(比如热点商品详情),它们需要“合并成一次后端调用”,而不是“只让第一个请求跑、其余全等结果”。sync.Once 无法返回共享结果,也没法区分不同 key 的请求。真正要的是:相同 key 的并发请求阻塞等待,不同 key 的请求互不干扰。
用 golang.org/x/sync/singleflight 最简实现
标准库没提供,但官方维护的 singleflight 包就是干这事的。它内部用 map + mutex + channel 实现“按 key 分组等待”,核心是 Do 方法:
var sg singleflight.Group
func GetProduct(id string) (Product, error) {
v, err, _ := sg.Do(id, func() (interface{}, error) {
// 这里才是真正查 DB 或远程服务的逻辑
p, err := fetchFromDB(id)
return p, err
})
if err != nil {
return Product{}, err
}
return v.(Product), nil
}
注意三点:
-
sg.Do第一个参数是 key(必须可比较,string/int 都行),相同 key 的并发调用会排队等同一个函数执行完 - 回调函数返回
interface{},需手动类型断言;别在回调里 panic,singleflight不 recover - 第三个返回值
shared表示结果是否被共享(true = 当前请求没执行函数,而是拿到了别人的结果),一般不用管
如何避免内存泄漏和 key 泄露
singleflight.Group 内部用 map 存 pending 请求,key 永远不清理——如果 key 来自用户输入(比如 URL 参数),恶意构造大量不同 id 就会导致内存持续增长。
解决办法不是删 map(会破坏正在等待的请求),而是控制 key 粒度:
- 对用户 ID 类字段,用固定前缀 + 哈希截断:
fmt.Sprintf("product:%s", md5.Sum([]byte(id))[:8]) - 避免把时间戳、随机数、session ID 等作为 key
- 如果业务允许,加一层本地 LRU 缓存(如
github.com/hashicorp/golang-lru),先查缓存再进singleflight,减少其压力
超时和错误处理容易踩的坑
singleflight 本身不处理超时,也不重试。如果 fetchFromDB 卡住或失败,所有等这个 key 的请求都会卡住或拿到同样错误。
必须自己包一层:
func GetProduct(id string) (Product, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
v, err, _ := sg.Do(id, func() (interface{}, error) {
select {
case
<p>关键点:</p>
- 超时必须在回调函数内部判断,不能只在外层 context 控制——因为
sg.Do会阻塞直到回调返回 - 错误一旦返回,所有等待者都收到同一份 error;若想区分“首次失败”和“重试成功”,得自己加状态管理,
singleflight不提供 - 别在回调里做耗时日志或监控上报,否则拖慢整个组;出错后建议打 warn 日志,但别阻塞
真正难的不是调用 Do,是怎么让 key 安全、超时可控、错误可观察——这些都得自己补全。











