singleflight 本身不防缓存击穿,只做请求合并;真正起作用的是「查缓存→Do加载→写缓存」闭环,漏掉任一环即失效,且所有副作用必须收口到 Do 回调中,key 要稳定幂等,Group 必须全局复用。

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 结构体中:
group *singleflight.Group - 多个业务(用户、配置、权限)可共用同一个
Group,无需拆分 - 别手动清理内部状态,它不持有长期资源
Do 回调里的 key、缓存写入、错误处理要严丝合缝
Do 返回的 shared 布尔值不是装饰,是关键信号:它表示“这个结果是不是被别人算出来的”,不表示缓存命中,也不代表结果新鲜——哪怕 shared=true,结果也可能已过期。
立即学习“go语言免费学习笔记(深入)”;
-
key要稳定幂等,和缓存key对齐;传int、struct{}或带时间戳的 URL,会让本该合并的请求变成不同key - 安全写法:
fmt.Sprintf("user:%d", id),剔除非业务字段如X-Request-ID、随机 query 参数 - 确保缓存
key、Dokey、DB 查询条件三者完全一致(比如都是"user:123") - DB 查询失败时,应考虑写空值缓存(防穿透)或 fallback 默认值,而不是让所有等待者卡死在同一个
error上
真正难的不是堆砌 singleflight,而是让它和缓存读取、布隆过滤器、空值缓存咬合严丝合缝。最容易被忽略的点是:所有副作用必须收口到 Do 回调里,且写缓存动作不能遗漏、不能错时机、不能错 key。


















