singleflight 本身不抑制缓存击穿,它只合并请求;真正起作用的是“先查缓存 → miss 后进 Do → 成功后写缓存”这个闭环。漏掉任一环,比如在 Do 外查缓存、或加载完不写缓存,就等于没加。

singleflight 本身不抑制缓存击穿,它只合并请求;真正起作用的是“先查缓存 → miss 后进 Do → 成功后写缓存”这个闭环。漏掉任一环,比如在 Do 外查缓存、或加载完不写缓存,就等于没加。
为什么在 handler 里 new singleflight.Group 没用
常见压测现象:QPS 上去后 DB 查询数还是线性上涨——大概率是因为你在每个 HTTP handler 里写了 g := new(singleflight.Group)。
singleflight.Group 内部用 map[string]*call 管理待处理请求,每次新建实例,map 都是空的,不同 goroutine 根本不共享状态。
- 必须全局复用:声明为包级变量(
var userGroup singleflight.Group)或注入到 service 结构体中(group *singleflight.Group) - 多个业务(用户、配置、权限)可共用同一个
Group,它线程安全,也不持有长期资源 - 别手动清理内部状态,它没有
Close方法,也不需要
key 必须稳定、幂等,且和缓存 key 完全对齐
传 int、struct{} 或带时间戳的 URL 参数,会让本该合并的请求变成不同 key,直接绕过去重。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
立即学习“go语言免费学习笔记(深入)”;
- 安全写法:用
strconv.Itoa(id)或fmt.Sprintf("user:%d", id),别用json.Marshal或r.URL.String() - 剔除非业务字段:
X-Request-ID、timestamp、随机 query 参数都不该进key - 确保三者一致:缓存
key、Do的key、DB 查询条件必须完全相同(比如都是"user:123")
所有加载逻辑必须塞进 Do 的回调函数里
错误做法是先查缓存、判断 miss 后再调 Do——这会在两个 goroutine 都看到 miss 时触发竞态窗口,导致日志、指标、DB 连接初始化甚至预查询重复发生。
-
Do回调内必须完成:DB 查询(用db.QueryRowContext(ctx, ))、HTTP 调用(带超时)、结果校验、成功后写缓存 - 失败时不写缓存,避免脏数据;空值可考虑写空缓存防穿透(如
cache.Set("user:9999999999", Null{}, ttl)) -
Do不接受context.Context,也不支持超时。如果回调卡住(比如 DB 连接池耗尽),所有等待该key的 goroutine 全部挂起
Do 成功返回结果后,这个结果不会自动进任何缓存
下次请求来,如果没走本地缓存层,还是会再次触发 Do——这时候你只是把“1000 次 DB 查询”换成了“1000 次排队等 1 次”。真正容易被忽略的是:缓存写入动作必须显式做,且必须在 Do 回调里完成,不能依赖外部逻辑补救。

















