编程 缓存击穿打满 DB 连接池:用 singleflight 合并同 key 的并发回源

2026-09-16 00:05:09

缓存击穿打满 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,会起一个 goroutine go 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
}

边界与坑

  1. Do 不接收 context。某个等待者超时/取消,只能影响它自己(select 超时直接返回),不会传递到正在执行的 fnForget 也只是让后续调用开新一轮执行,不会取消当前那次调用。
  2. 错误会被共享。同一轮合并执行返回 error,所有等待者一起收到。singleflight 减少的是重复回源,不保证下游成功;下游不稳定时它只会把错误"更高效地传播"。
  3. shared 语义。shared=true 只表示结果来自同一轮合并执行,不代表缓存命中。常见优化是只让 shared=false 的那次负责写缓存/打点,避免同一轮重复写和重复统计。
  4. DoChan 的 channel 不关闭,超时后要主动 Forget,否则后续请求一直挂在慢请求上。
  5. 不要乱用 Forget。把它当"修错按钮"会让请求重新并发回源,把保护效果打没。
  6. 它是单机级的。多实例部署时,每个实例各自合并,跨实例的重复回源还要靠 Redis 锁、本地+分布式缓存或更高层限流兜底。
  7. singleflight 不是缓存,也不是限流,别指望它兜住缓存命中率治理的锅。

观测

  • shared 比例:shared=true 越高说明请求合并越频繁,热点或缓存失效越明显。
  • 回源耗时:接口耗时不够,要单独看回源函数耗时。
  • 每个 key 的合并量:采样记录热点 key,别全量打爆日志。
  • 错误类型:区分下游超时、业务不存在、序列化失败、context 取消。
  • 下游 QPS:上线前后对比同一热点场景下的回源次数。
复制全文 生成海报 并发处理 Go singleflight 缓存击穿

推荐文章

程序员茄子在线接单