缓存击穿打满 DB 连接池:用 singleflight 合并同 key 的并发回源
现场
热 key 过期的瞬间,几百上千个 goroutine 同时发现缓存 miss,一起去查数据库,DB 连接池被瞬时打满,接口 P99 直接飙起来。这类问题叫缓存击穿(cache breakdown),和平时的缓存雪崩、穿透不是一回事:它是单一热点 key 失效带来的重复回源风暴。
工具
golang.org/x/sync/singleflight。它不是缓存,也不是限流器,作用很窄:把同一个 key 上同时发生的重复函数调用合并,让真正回源的只执行一次,其他调用者等待并共享结果。
- 项目地址:
- 源码:
核心 API
Do(key string, fn func() (any, error)) (v any, err error, shared bool):同步阻塞。同 key 第一个调用执行fn,后续调用阻塞等待并共享结果。DoChan(key string, fn func() (any, error)) <-chan Result:异步版,返回 channel,适合配合 select 做超时/取消。返回的 channel 不会被关闭。Forget(key string):把 key 从内部 map 移除,后续Do会重新执行fn,不再等待当前那一次。Result{Val any; Err error; Shared bool}
源码要点
内部就是 map[string]*call 加一个 sync.Mutex。第一个进来的调用创建 call 并执行 fn,后来的调用 c.dups++ 然后 c.wg.Wait() 等结果。执行完成后在 defer 里 wg.Done()、删掉 map 里的 key,然后把结果写入各等待者的 chan。
- panic 处理:
fn里 panic 会被包装成panicError,如果还有等待的 channel,会起一个 goroutinego panic(e)并select{}挂住,避免等待者永久阻塞;没有等待者就直接 panic。 runtime.Goexit也被单独处理(errGoexit),避免把正常退出误判成返回值。
正确用法顺序
不要把 singleflight 放最外层。顺序应当是:先查缓存,命中直接返回;miss 再用 singleflight 合并同 key 的回源动作;回源成功写缓存,等待方共享结果。并且回源函数内部要二次查缓存:
func (s *Service) GetProduct(ctx context.Context, id string) (*Product, error) {
key := "product:" + id
if v, ok := s.cache.Get(key); ok {
return v.(*Product), nil
}
v, err, shared := s.group.Do(key, func() (any, error) {
// 二次查缓存:等待期间别的调用可能已经把缓存写回去了
if v, ok := s.cache.Get(key); ok {
return v.(*Product), nil
}
p, err := s.repo.LoadProduct(ctx, id)
if err != nil {
return nil, err
}
s.cache.Set(key, p, 30*time.Second)
return p, nil
})
if err != nil {
return nil, fmt.Errorf("load product %s: %w", id, err)
}
observeSingleflight("product", shared)
return v.(*Product), nil
}
边界与坑
Do不接收 context。某个等待者超时/取消,只能影响它自己(select 超时直接返回),不会传递到正在执行的fn。Forget也只是让后续调用开新一轮执行,不会取消当前那次调用。- 错误会被共享。同一轮合并执行返回 error,所有等待者一起收到。singleflight 减少的是重复回源,不保证下游成功;下游不稳定时它只会把错误"更高效地传播"。
shared语义。shared=true只表示结果来自同一轮合并执行,不代表缓存命中。常见优化是只让shared=false的那次负责写缓存/打点,避免同一轮重复写和重复统计。DoChan的 channel 不关闭,超时后要主动Forget,否则后续请求一直挂在慢请求上。- 不要乱用
Forget。把它当"修错按钮"会让请求重新并发回源,把保护效果打没。 - 它是单机级的。多实例部署时,每个实例各自合并,跨实例的重复回源还要靠 Redis 锁、本地+分布式缓存或更高层限流兜底。
- singleflight 不是缓存,也不是限流,别指望它兜住缓存命中率治理的锅。
观测
- shared 比例:
shared=true越高说明请求合并越频繁,热点或缓存失效越明显。 - 回源耗时:接口耗时不够,要单独看回源函数耗时。
- 每个 key 的合并量:采样记录热点 key,别全量打爆日志。
- 错误类型:区分下游超时、业务不存在、序列化失败、context 取消。
- 下游 QPS:上线前后对比同一热点场景下的回源次数。