singleflight.Group必须是全局或长生命周期变量,其内部map依赖实例复用实现请求合并;key需稳定可比较且有界;Do应在缓存未命中后调用;需手动处理超时、panic和错误,并谨慎使用Forget。

singleflight.Group 必须是全局或长生命周期变量
它内部用 map[string]*call 记录 pending 请求,如果每次调用都 new 一个 singleflight.Group,就完全失去合并效果——每个实例的 map 都是空的,所有请求各自为政。常见错误是把它塞进 handler 方法里、struct 字段里,或者按请求新建。
正确做法只有两种:
- 定义为包级全局变量:
var sg singleflight.Group - 作为 service struct 的字段,在服务初始化时一次性构造(比如在 NewXXXService 里赋值),确保整个服务生命周期复用同一个实例
别试图“按业务域拆多个 Group”,除非 key 空间完全隔离(比如 user 和 order 的 key 前缀永不重叠),否则反而增加维护负担且无实质收益。
key 必须稳定、可比较、有界
你传给 Do 的第一个参数不是随便拼的字符串,它决定了哪些请求会被合并。如果 key 动态包含时间戳、UUID、随机数、未清洗的 URL 参数,那根本合并不起来,还可能引发内存泄漏。
立即学习“go语言免费学习笔记(深入)”;
实际写法要注意:
- 对用户 ID 类输入,先校验格式再用:
fmt.Sprintf("user:%s", sanitizeUserID(id)) - 避免直接用原始 HTTP query:
r.URL.Query().Get("id")→ 可能为空或恶意构造 - 对复合参数(如
product_id=123®ion=cn),统一序列化为固定顺序的 map + json.Marshal,而不是fmt.Sprintf("%s_%s", a, b) - 关键:key 长度和字符集要可控;别让攻击者通过构造海量不同 key 把
Group.m塞爆
Do 调用必须放在缓存未命中之后
singleflight 不是缓存前置开关,它是“兜底合并层”。如果你在缓存读取之前就调用 sg.Do,那所有请求(包括缓存命中的)都会被阻塞等待,白白拖慢响应。
典型安全结构是:
- 先查本地缓存(如
sync.Map或 LRU)→ 命中则直接返回 - 未命中,再进
sg.Do(key, fetchFromDB) - fetch 成功后,顺手回填缓存(注意并发写安全)
千万别把 cache.Get 包进 Do 的 fn 里——那样缓存命中也会触发锁竞争,且结果可能被多个 goroutine 同时写入缓存,引发脏数据。
超时、错误、panic 都得自己兜住
singleflight.Group 本身不提供超时、重试、panic 捕获。一旦 fn 卡死或 panic,所有等这个 key 的请求就卡死或收到 panic 转成的 error。
必须手动加防护:
- 用
context.WithTimeout包裹真实后端调用,超时后主动 return error - 在
fn内部 defer recover(),把 panic 转为可处理的 error(singleflight会捕获并转成 error 返回,但不会传播 panic) - 遇到临时错误(如 DB 连接中断),别直接 return error;考虑是否该调用
sg.Forget(key),否则后续请求会继续等这个已失败的 call
最容易被忽略的是:Forget 不是“清空结果”,而是“取消等待”,它只影响 future 请求;正在等待的那些 goroutine 仍会收到原 error。所以 Forget 要在错误发生后、且确认无需再共享结果时才调用。


















