singleflight.group 仅实现请求合并,不抑制缓存击穿;必须将缓存读取、do回调内加载并写缓存构成闭环,且key需稳定幂等、group须全局复用、回调中需带ctx超时控制并手动写缓存。

singleflight.Group 本身不抑制缓存击穿,它只做请求合并;真正起作用的是「缓存读取 → Do 回调内加载并写缓存」这个闭环。漏掉任一环,比如在 Do 外查缓存、或没在回调里写缓存,就等于没防。
缓存 miss 后必须把整个加载流程塞进 Do 回调里
常见错误是先查 Redis、发现 miss 后再调 Do。这会制造竞态窗口:两个 goroutine 都看到 miss,都进入 Do,虽然最终只执行一次 DB 查询,但你在外面做的日志打点、trace 开始、甚至预连接 DB 的操作,已经重复发生。
正确做法是所有副作用全部收进回调:
-
db.QueryRowContext(ctx, ...)或http.Do(req.WithContext(ctx)),必须带ctx - 成功后才调
cache.Set("user:123", u, time.Minute),避免脏数据写入 - 失败时不写缓存,或按策略写空值(如
cache.Set("user:123", nil, time.Second)防穿透) - 别在回调外做
cache.Delete、metrics.Inc等可能重复的操作
singleflight.Group 必须全局复用,不能按请求 new
如果在 HTTP handler 里写 g := new(singleflight.Group),每个请求拿到独立实例,Do 完全无法去重——内部 map[string]*call 是空的,等同于没加。
应该:
- 声明为包级变量:
var userGroup singleflight.Group - 或注入到 service 结构体:
type UserService struct { group *singleflight.Group } - 多个业务可共用同一个
Group,无需拆分;singleflight.Group是线程安全的 - 别手动清理或重置内部状态,它不持有长期资源
key 必须稳定、幂等,且和缓存 key 完全对齐
Do 的 key 不是装饰,是去重的唯一依据。传 int、含指针的结构体、带时间戳的 URL,都会让本该合并的请求变成不同 key。
安全写法:
- 用
strconv.Itoa(id)或fmt.Sprintf("user:%d", id) - 剔除非业务字段:X-Request-ID、
timestamp、随机 query 参数 - 确保三者一致:
cache key、Do key、DB 查询条件(比如都是"user:123") - 避免用
json.Marshal(req)做 key —— 浮点数精度、字段顺序差异会导致哈希不一致
超时与错误必须由业务层兜底,Do 不支持 context
singleflight.Group.Do 本身不接受 context.Context,也不支持超时。如果回调里用了无超时的 db.QueryRow() 或卡死的 HTTP 调用,所有等待该 key 的 goroutine 全部挂起,可能拖垮服务。
关键动作:
- 回调内必须使用带
ctx的 API(如db.QueryRowContext(ctx, ...)) - 主动检查
ctx.Err()并提前返回:if ctx.Err() != nil { return nil, ctx.Err() } - 利用返回的
shared布尔值区分首次执行与等待结果(可用于采样打点,但不能用于分支逻辑) - 失败时考虑降级:返回默认值、包装 error 类型供上层重试、或写短 TTL 空缓存
singleflight 不自动写缓存,也不管理 TTL;哪怕 Do 成功返回了结果,下次请求来若没走本地缓存层,仍会再次触发 Do。最容易被忽略的,就是以为加了 Do 就万事大吉,却忘了在回调里补上那行 cache.Set(...)。golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











