singleflight 本身不防缓存击穿,需与缓存读取逻辑、do 回调内加载及写缓存构成闭环;key 要稳定幂等,group 必须全局复用,do 回调内须处理超时、错误及写缓存。

singleflight 本身不防缓存击穿,它只做请求合并;真正起作用的,是「缓存读取逻辑 + singleflight.Do + 加载后写缓存」三者闭环。漏掉任一环,就等于白加。
必须把整个加载流程塞进 Do 回调里
常见错误是先查缓存、判断 miss 后再调 Do。这会制造竞态窗口:两个 goroutine 都看到 cache miss,都进入 Do,虽然 Do 内部仍只执行一次,但你在外面做的日志、指标打点、DB 连接初始化、甚至预查询,已经重复发生了。
正确做法是所有副作用操作全部收进回调:
- DB 查询(用
db.QueryRowContext(ctx, ...)) - HTTP 调用(带超时控制)
- 结果解析与校验
-
成功后才写缓存(比如
cache.Set("user:123", u, time.Minute)) - 失败时不写缓存,避免脏数据
singleflight.Group 必须全局复用,不能按请求 new
如果在 HTTP handler 里写 g := new(singleflight.Group),每个请求都拿到独立实例,Do 完全无法跨 goroutine 去重——map 是空的,等同于没用。
应该:
- 声明为包级变量:
var userGroup singleflight.Group - 或注入到 service 结构体中:
group *singleflight.Group - 多个业务(用户、配置、权限)可共用同一个
Group,无需拆分 - 别手动清理内部状态,它不持有长期资源
key 设计要稳定、幂等、和缓存 key 对齐
key 不是装饰,是去重的唯一依据。传 int、struct{} 或带时间戳的 URL,都会让本该合并的请求变成不同 key。
安全写法:
- 用
strconv.Itoa(id)或fmt.Sprintf("user:%d", id) - 剔除非业务字段:
X-Request-ID、timestamp、随机 query 参数 - 确保缓存 key、
Dokey、DB 查询条件三者完全一致(比如都是"user:123")
必须自己处理超时与错误传播
Do 不接受 context.Context,也不支持超时。如果回调函数里用了无超时的 db.QueryRow(...) 或死循环,所有等待者都会卡死。
关键动作:
- 回调内必须使用带 ctx 的 API(如
db.QueryRowContext(ctx, ...)) - 主动检查
ctx.Err()并提前返回 - 利用返回的
shared布尔值区分首次执行与等待结果(可用于打点,但不能用于分支逻辑) - 失败时考虑降级:写空值缓存、返回默认值、或包装 error 类型供上层重试
Do 成功返回了结果,这个结果也**不会自动进缓存**;下次请求来,如果没走本地缓存层,还是会再次触发 Do —— 这时候你只是把“1000 次 DB 查询”换成了“1000 次排队等 1 次查询”,压力一点没少。











