singleflight 仅做请求合并,防缓存击穿需配合“查缓存→do加载→写缓存”闭环;副作用操作必须全在do回调内,group须全局复用,key需稳定幂等,回调须处理超时与错误。

singleflight 本身不防缓存击穿,它只做请求合并;真正起作用的是「先查缓存 → 缓存 miss 后进 Do → 加载成功后写缓存」这个闭环。漏掉任一环,比如在 Do 外查缓存、或加载完不写缓存,就等于白加。
为什么先查缓存再调 Do 是错的
常见错误写法:
if v, ok := cache.Get("user:123"); ok {
return v, nil
}
// ❌ 这里已出现竞态窗口:两个 goroutine 都看到 miss,都进入下面的 Do
v, err, _ := g.Do("user:123", loadFromDB)
问题在于:cache.Get 和 g.Do 之间没有同步保护。哪怕 Do 最终只执行一次,你在外面做的日志打点、指标上报、DB 连接预初始化、甚至预查询(如检查租户状态),都可能重复发生。
- 所有副作用操作(DB 查询、HTTP 调用、缓存写入、错误包装)必须全部塞进
Do的回调函数里 - 回调内要用带
ctx的 API,比如db.QueryRowContext(ctx, ),不能用无超时的QueryRow - 失败时不写缓存,避免脏数据;成功后才调
cache.Set("user:123", v, ttl)
singleflight.Group 必须全局复用,不能按请求 new
如果在 HTTP handler 里写 g := new(singleflight.Group),每个请求拿到的都是全新实例,Do 完全无法去重——内部 map 是空的,等同于没加。
- 正确做法是声明为包级变量:
var userGroup singleflight.Group - 或注入到 service 结构体中:
type UserService struct { group *singleflight.Group } - 多个业务(用户、配置、权限)可共用同一个
Group,无需拆分 - 别手动清理
Group内部状态,它不持有长期资源,也不需要Close
Do 回调里必须自己处理超时和错误传播
singleflight.Do 不接受 context.Context,也不支持超时。如果回调里用了死循环、无限制 time.Sleep 或没设 timeout 的 http.Client,所有等待者都会卡死。
- 回调内必须主动检查
ctx.Err()并提前返回 - DB 查询必须用
db.QueryRowContext(ctx, )等带 context 的 API - HTTP 调用需显式控制超时,例如
http.DefaultClient.Do(req.WithContext(ctx)) - 返回的
shared布尔值仅用于打点或降级判断(比如只对首次执行者记录 trace),不能用于控制分支逻辑
key 必须稳定、幂等、与缓存 key 完全对齐
key 不是装饰,是去重的唯一依据。传 int、struct{} 或带时间戳的 URL,都会让本该合并的请求变成不同 key。
- 建议统一用字符串格式,且和你写入缓存时的
cache.Setkey 严格一致 - 避免嵌入动态字段(如 session ID、毫秒级时间戳、随机 UUID)
- 若需归一化,应在进
Do前完成,比如normalizeKey("user:123?ts=1716456380123") → "user:123"
Do 回调」这一条——日志、指标、连接池预热、租户校验、甚至一次预查询,只要放在 Do 外,就可能在缓存 miss 瞬间被并发执行多次。这不是理论风险,是上线后真实会爆的 CPU 和 DB 连接数。golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











