singleflight 本身不是缓存,也不能单独防击穿;它只负责把同一 key 的并发请求“压成一个”,真正起作用的是它和缓存读写逻辑组成的闭环。

singleflight 本身不是缓存,也不能单独防击穿;它只负责把同一 key 的并发请求“压成一个”,真正起作用的是它和缓存读写逻辑组成的闭环。
为什么直接在 handler 里 new singleflight.Group 是错的
常见压测现象:QPS 上去后 DB 查询数还是线性上涨,singleflight 像没生效——大概率是因为你在每个 HTTP handler 里都写了 g := new(singleflight.Group)。
-
singleflight.Group内部用map[string]*call管理待处理请求,每次新建实例,map 都是空的,不同 goroutine 根本不共享状态 - 必须全局复用:声明为包级变量(
var userGroup singleflight.Group)或注入到 service 结构体中(group *singleflight.Group) - 多个业务(用户、配置、权限)可共用同一个
Group,无需拆分;它线程安全,也不持有长期资源
key 必须稳定、幂等,且和缓存 key 完全对齐
传 int、struct{} 或带时间戳的 URL 参数,会让本该合并的请求变成不同 key,直接绕过去重。
- 安全写法:用
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 连接初始化甚至预查询重复发生。
- 正确闭环:查本地缓存 → miss →
g.Do(key, loadFn)→loadFn里查 DB 并成功后才cache.Set -
loadFn必须包含:DB 查询(用db.QueryRowContext(ctx, ))、HTTP 调用(带超时)、结果校验、成功后写缓存 - 失败时不写缓存,避免脏数据;空值可考虑写空缓存防穿透
超时、错误、降级必须由业务层兜底
singleflight.Group.Do 不接受 context.Context,也不支持超时。如果回调卡住(比如 DB 连接池耗尽),所有等待该 key 的 goroutine 全部挂起。
- 回调内必须使用带 ctx 的 API(如
db.QueryRowContext),并主动检查ctx.Err() - 利用返回的
shared布尔值区分首次执行与等待结果:可用于日志采样,但不能用于分支逻辑 - 失败时需考虑降级:返回默认值、包装 error 类型供上层重试、或写空值缓存
真正容易被忽略的是:Do 成功返回结果后,这个结果不会自动进任何缓存;下次请求来,如果没走本地缓存层,还是会再次触发 Do——这时候你只是把“1000 次 DB 查询”换成了“1000 次排队等 1 次”。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











