singleflight 本身不抑制缓存击穿,仅合并请求;真正起作用的是“查缓存→Do回调加载→成功后写缓存”闭环,漏掉任一环即失效。

singleflight 本身不抑制缓存击穿,它只合并请求;真正起作用的是「查缓存 → Do 回调加载 → 成功后写缓存」这个闭环。漏掉任一环,比如在 Do 外查缓存、或加载成功却不写缓存,就等于没加。
为什么先查缓存再调 Do 是错的
常见错误写法:cache.Get("user:123") 判断 miss 后,才进 g.Do("user:123", loadFromDB)。这中间存在竞态窗口:两个 goroutine 都看到缓存 miss,都进入 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
key 必须稳定、幂等,且和缓存 key 完全对齐
Do 的 key 不是装饰,是去重的唯一依据。传 int、struct{} 或带时间戳的 URL,都会让本该合并的请求变成不同 key。
- 安全写法:
fmt.Sprintf("user:%s", id)或strconv.Itoa(id) - 剔除非业务字段:X-Request-ID、timestamp、随机 query 参数
- 确保缓存 key、
Dokey、DB 查询条件三者完全一致(比如都是"user:123") - 若 key 来自用户输入,建议哈希截断:
fmt.Sprintf("product:%x", md5.Sum([]byte(id))[:8]),防内存泄漏
超时和错误处理必须由业务层兜底
singleflight.Group.Do 本身不接受 context.Context,也不支持超时。如果回调里用了死循环、无限制 time.Sleep 或没设 timeout 的 http.Client,所有等待者都会卡死。
- 回调内必须主动检查
ctx.Err()并提前返回 - DB 查询必须用
QueryRowContext(ctx, ),HTTP 调用必须配超时 client - 失败时建议写空值缓存(防穿透)、返回默认值、或包装 error 类型供上层决定是否重试
- 返回的
shared布尔值仅用于打点或降级判断(比如只对首次执行者记录 trace),不能用于控制分支逻辑
真正难的不是堆砌 singleflight,而是让它和缓存读取、布隆过滤器、空值缓存咬合严丝合缝。最容易被忽略的点是:哪怕 Do 成功返回了结果,这个结果也不会自动进缓存;下次请求来,如果没走本地缓存层,还是会再次触发 Do ——这时候你只是把“1000 次 DB 查询”换成了“1000 次排队等 1 次”。


















