singleflight 能防缓存击穿但不能替代缓存,因其仅做请求去重而不存储数据;需配合本地缓存(如 sync.Map)使用,且 key 设计、Group 生命周期和错误处理必须正确。

为什么 singleflight 能防缓存击穿,但不能替代缓存本身
因为 singleflight 不存数据,只做“请求去重”——同一时刻多个 goroutine 调用 Do 时,它只让其中一个真正执行函数,其余阻塞等待结果。缓存(比如 map 或 Redis)才是负责存储和复用结果的地方。
常见错误现象:singleflight.Group.Do 返回 nil 错误,但业务逻辑仍反复查 DB;或者加了 singleflight 后并发 QPS 没降,说明没用对 key。
- 必须把「可能触发相同后端查询」的请求映射到同一个
key,比如fmt.Sprintf("user:%d", userID),而不是用随机 ID 或含时间戳的 key -
singleflight的Group是无状态的,不自动清理 key,长期运行需配合 TTL 或定期重建,否则内存缓慢增长 - 它不处理失败重试:如果被代理的函数返回非
nilerror,所有等待者都拿到这个 error,不会自动再试一次
如何正确组合 singleflight + 本地缓存防穿透
典型场景是:先查本地 sync.Map,未命中则走 singleflight 去加载,成功后再写回缓存。注意顺序和条件判断,否则会绕过去重逻辑。
使用场景:用户资料、配置项、权限规则等读多写少、key 空间有限的数据。
立即学习“go语言免费学习笔记(深入)”;
- 不要在
singleflight.Do回调里直接写缓存——多个 goroutine 可能同时写,导致竞态;应由 Do 的返回方统一写 - 本地缓存建议用
sync.Map而非普通map+mutex,避免高频读写锁争抢 - 若底层加载函数耗时长(>100ms),考虑加 context 超时控制,否则等待者可能卡死
val, err := sfGroup.Do(key, func() (interface{}, error) {
// 这里只做一次真实加载
data, err := loadFromDB(key)
if err == nil {
localCache.Store(key, data) // 成功才写缓存
}
return data, err
})
singleflight 在 HTTP handler 中容易踩的坑
最常犯的错是把 singleflight.Group 声明在 handler 函数内,导致每次请求都新建一个 Group,完全失去去重能力。
错误现象:压测时 DB 查询数依然随并发线性上涨,singleflight 形同虚设。
-
Group必须是包级变量或注入到 handler 结构体中,生命周期要覆盖整个服务运行期 - HTTP 请求的 key 要排除非幂等参数(如
X-Request-ID、时间戳),否则相同业务请求因 header 差异变成不同 key - 别在中间件里无差别 wrap 所有路由——静态资源、健康检查等不该进 singleflight,徒增延迟
当 key 高频变更时,singleflight 反而拖慢响应
如果业务 key 天然分散(比如带毫秒时间戳、UUID、用户 session ID),singleflight 的哈希桶会快速膨胀,且几乎每个请求都走不到“合并”路径,只增加调度开销。
性能影响:实测在百万级 key/秒场景下,singleflight.Group.Do 的平均延迟比直调高 5–10μs,不是大问题;但若 key 完全不重复,就纯属白忙活。
- 上线前用真实流量抽样分析 key 分布,确认热点 key 是否存在(比如 top 100 key 占 80% 请求)
- 对低频或唯一 key 场景,优先用 TTL 缓存 + 布隆过滤器拦截无效 key,比强上
singleflight更有效 -
singleflight的内部 map 没有 size 限制,key 泛滥时 GC 压力会上升,监控runtime.ReadMemStats中的Mallocs变化
真正难的是识别哪些请求该聚合、哪些该放行——这没法靠库自动判断,得看业务语义。比如“查用户订单”要按 userID 聚,但“查某笔订单详情”就得按 orderID,混在一起就全乱了。










