用 singleflight.group 包裹缓存未命中后的数据库查询,可将并发请求合并为一次真实调用;需复用 group 实例、使用稳定 key、关注 shared 返回值以区分真实查询与结果复用。

直接回答:用 singleflight.Group 包裹缓存未命中后的数据库查询逻辑,就能让并发请求合并成一次真实调用——不是“减少”负载,而是把 1000 次查询压成 1 次。
缓存未命中时必须用 Do() 而不是自己加锁
很多人在发现缓存 miss 后,下意识写 sync.Mutex 或 redis.SETNX 来控制只有一个请求查 DB。这看似合理,但实际会卡住所有后续请求,直到 DB 返回才释放锁;而 singleflight.Group.Do() 是等待型合并:第一个 goroutine 执行 fn,其余全部挂起在 wg.Wait() 上,DB 返回后一次性唤醒——延迟更低、资源更省。
- 错误做法:
mu.Lock()后查 DB + 写缓存,再mu.Unlock()→ 所有请求串行排队 - 正确做法:缓存 miss 后直接调
g.Do(key, dbQueryFunc)→ 只有第一个进 fn,其余等结果 - 注意 key 必须稳定可复用,比如用
fmt.Sprintf("user:%s", id),别用带时间戳或随机数的 key
Do() 的第三个返回值 shared 很关键
Do() 返回的 shared bool 值告诉你这次结果是不是复用的。它不只用于日志,更是诊断依据:如果大量请求返回 shared=true 但 DB 查询耗时飙升,说明瓶颈不在并发控制,而在 DB 本身或网络。
-
shared=false:你是那个真正执行fn的 goroutine,可以顺便打个log.Printf("db query for %s took %v", key, dur) -
shared=true:你没走 DB,但要留意是否因上游超时导致你等了太久才拿到结果 - 别忽略它——线上排查时,
shared统计能快速区分是缓存击穿还是 DB 慢查询
Group 实例不能按请求新建,必须复用
每个 singleflight.Group 实例内部维护一个 map[string]*call,如果每次 HTTP 请求都 new 一个 Group,等于每次都在建新 map,旧的 call 永远不会被清理,内存持续增长。它必须是包级或服务级全局变量。
- ✅ 正确:定义为
var userGroup singleflight.Group(全局或依赖注入) - ❌ 错误:
func handler(w http.ResponseWriter, r *http.Request) { g := new(singleflight.Group); g.Do(...) } - 额外提醒:Group 本身无状态、线程安全,复用完全没问题;但不要把它塞进 struct 里随请求传递,容易误用
和 sync.Once 的本质区别不是“能不能用”,而是“用在哪”
sync.Once 保证函数一生只运行一次;singleflight.Group 保证相同 key 的请求在同一时间段内只执行一次——前者是“永久性单次”,后者是“临时性合并”。缓存场景里,key 过期后下次请求又会触发新合并,这才是你需要的语义。
- 用
sync.Once初始化配置?可以。用它查用户数据?不行——第二次请求就拿不到新结果 - 用
singleflight查用户?刚好。key 失效后自动开启新一轮合并,不污染长期状态 - 别试图用
sync.Once替代singleflight,它们解决的是不同维度的问题
最常被忽略的一点:singleflight 不解决缓存穿透或雪崩,它只管“同一 key 的并发合并”。如果 key 本身是恶意构造的(比如随机 ID),Do() 仍会为每个非法 key 启动一次查询——这时得靠布隆过滤器或空值缓存前置拦截。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











