singleflight 本身不防缓存击穿,仅做请求合并;真正起作用的是「查缓存→do加载→写缓存」闭环,漏掉任一环(如do外查缓存、加载后不写缓存)即失效。

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 是空的,等同于没加。
初始化并管理阿里云SDK Go 客户端,涵盖 sync.Once 单例、goroutine 安全、endpoint 与 region 配置、VPC 端点及调试。
- 推荐做法:整个服务共用一个全局变量
var g singleflight.Group - 不要按
context、租户、路径参数新建Group,否则内存泄漏且去重失效 -
Group是无状态的,但生命周期管理常被忽视;长期持有没问题,乱 new 就翻车
Do 回调里的 key、缓存写入、错误处理要严丝合缝
Do 返回的 shared 布尔值不是装饰,是关键信号:它表示“这个结果是不是被别人算出来的”,不表示缓存命中,也不代表结果新鲜——哪怕 shared=true,结果也可能已过期。
-
key必须稳定幂等:缓存 key、Dokey、DB 查询条件三者必须对齐(比如都用"user:123",不能一个用"u123") - DB 查询失败时,应考虑写空值缓存(防穿透)或 fallback 默认值,而不是让所有等待者卡死在同一个 error 上
- 超时要由外层控制:
singleflight本身不支持超时,得用context.WithTimeout包裹实际加载函数,并在函数内检查ctx.Err()
真正难的不是堆砌 singleflight,而是让它和缓存读取、布隆过滤器、空值缓存咬合严丝合缝。最容易被忽略的点是:所有副作用必须收口到 Do 回调里,且写缓存动作不能遗漏、不能错时机、不能错 key。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










