直接用 sync.Map 去重会失败,因其仅保证单键操作线程安全,不支持“原子性等待”,导致多 goroutine 同时缓存未命中而重复请求下游,引发雪崩;需用 singleflight.Group 实现按 key 共享执行、广播结果。

为什么直接用 sync.Map 做去重会失败
因为 sync.Map 只保证单个键值操作的线程安全,不提供「原子性等待」能力。多个 goroutine 同时查缓存未命中,都会触发下游请求,导致击穿——这正是雪崩的起点。
真正需要的是:**首次请求发起加载,其余请求阻塞等待结果返回,而非各自发起请求**。Go 标准库没现成组件,得靠 sync.Once + sync.Map 组合实现「按 key 粒度的 Once」。
- 别用
sync.Once全局单例——它只能执行一次,无法按 key 区分 - 每个 key 对应一个
sync.Once实例,需动态创建并安全复用(避免内存泄漏) - 不能把
sync.Once存在sync.Map里直接用指针——sync.Once不可复制,必须用指针存取
用 singleflight.Group 替代手写 Once 池更稳妥
singleflight.Group 是 Go 官方 x/sync 包提供的成熟方案,专为「共享一次执行、广播结果」设计,天然适配防雪崩场景。
它内部用 map + mutex 管理 pending 请求,自动去重、合并、广播,比手写更少出错:
立即学习“go语言免费学习笔记(深入)”;
- 调用
Do(key, fn)时,相同key的所有调用会排队等待首个执行完成 -
fn返回(interface{}, error),成功结果缓存并返回给所有等待者;失败则所有等待者收到同一错误 - 不缓存结果本身——它只管「执行去重」,缓存逻辑仍需你用
sync.Map或其他存储单独管理
示例关键片段:
var group singleflight.Group
func Get(key string) (string, error) {
v, err, _ := group.Do(key, func() (interface{}, error) {
// 这里才是真正可能耗时/打下游的逻辑
res, err := fetchFromDB(key)
if err != nil {
return "", err
}
// 写入本地缓存(如 sync.Map)
cache.Store(key, res)
return res, nil
})
return v.(string), err
}
缓存过期与 singleflight 协同的陷阱
如果只依赖 singleflight 去重,但缓存过期后多个请求同时穿透,依然会雪崩——singleflight 的 key 生命周期和缓存生命周期必须对齐。
常见错误是:缓存过期后直接删 key,下次请求进来发现 miss,又触发 group.Do,但此时旧的 singleflight entry 已被 GC 或失效,无法复用。
- 推荐策略:缓存不过期,改用「逻辑过期」——value 里嵌入
expireAt时间戳,读时判断,过期则异步刷新(或阻塞刷新) - 若必须物理删除,删 key 前先调用
group.Forget(key)(Go 1.21+),否则旧 entry 可能残留并阻塞新请求 - 不要在
fn里做长时间阻塞操作(如超时未设限的 HTTP 请求),否则整个 key 的后续请求全卡住
并发量大时 singleflight.Group 的内存与性能边界
singleflight.Group 内部用 map 存 pending 请求,key 长期不清理会内存泄漏。尤其高频短命 key(如带时间戳的 API 请求)极易踩坑。
- 没有内置 TTL,必须自己控制 key 命名粒度——避免把请求参数全拼进 key(如
"user:123:ts:1718234567"),改用稳定标识(如"user:123") - 高并发下 map 锁竞争明显,实测 10k+ QPS 时建议配合 key 分片(如
groupArr[hash(key)%N])缓解热点 - 注意
Do的第二个参数函数不能 panic,否则所有等待者收到panic转成的error,且该 key 的 entry 会被丢弃,下次请求重新进入
实际部署时,最易忽略的是 key 设计与 Forget 的配合时机——它不像缓存那样有明确生命周期,得靠业务逻辑主动管理。


















