Singleflight 仅防缓存击穿,不防雪崩;雪崩需过期时间加随机偏移,击穿才用 singleflight 合并相同 key 请求,并配合先查缓存、未命中再 Do、回调中写缓存(含空值)、设超时、检查 shared 标志等正确用法。

Singleflight 不能防缓存雪崩,它只防击穿;想靠 singleflight.Group.Do 挡住雪崩,会把延迟拉高、故障面扩大,反而更危险。
缓存雪崩和击穿是两回事,别混用 singleflight
雪崩是大量 key 在同一时间点集中过期,导致 DB 瞬间被穿透;击穿是单个热点 key 过期后,大量并发请求同时打到后端。singleflight 只对后者有效——它合并相同 key 的并发请求,但对“一堆不同 key 同时失效”完全无感。
- 雪崩要靠过期时间加随机偏移:
10*time.Minute + time.Duration(rand.Int63n(2e9))(±2 秒) - 击穿才轮到
singleflight出场:它让 100 个查"user:123"的 goroutine,只真正调一次 DB - 如果你把所有 key 的加载都塞进同一个
singleflight.Group,还用固定 key(比如"load_all"),那等于给整个服务加了全局锁
singleflight 必须配合缓存读取逻辑,不能直接包 Redis.Get
常见错误是把 rdb.Get(ctx, "user:123") 直接扔进 Do,结果每次请求都排队等 Redis 返回——缓存命中的请求也被拖慢,纯属自缚手脚。
- 正确顺序永远是:先
rdb.Get→ 命中就直接返回;未命中再进singleflight.Do -
Do的 key 应该和缓存 key 一致(如"user:123"),否则合并失效 -
Do回调里必须显式写缓存:rdb.Set(ctx, "user:123", val, ttl),否则下次还是击穿 - 空值也要写:
rdb.Set(ctx, "user:123", "", redis.WithExpiration(60*time.Second)),避免redis.Nil被反复触发回源
Do 回调里不设超时,整个 group 就卡死
singleflight.Group 本身没有超时机制,如果回调函数卡住(比如 DB 连接没设 deadline),所有等待该 key 的 goroutine 会无限阻塞,直到那个函数返回或 panic。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- 必须在回调里用
context.WithTimeout:ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second) - DB 查询、HTTP 调用、甚至
rdb.Get都得带这个 ctx,不能只传原始 context - 别依赖
singleflight清理旧 key——它内部 map 不自动 GC,长期运行需配合 metrics 监控高频 key
shared 返回值不检查,就分不清谁真干了活
val, err, shared := g.Do("user:123", fn) 中的 shared 是关键信号:为 true 表示当前 goroutine 是首个执行者;false 表示复用了别人的结果。这个布尔值不用,就丢掉了唯一能区分“源头失败”和“传播失败”的线索。
- 日志里可以加判断:
if shared { log.Info("first load for user:123") } - 降级逻辑可据此分流:只有
shared == true且err != nil时,才触发熔断或 fallback - 千万别用
shared当缓存命中标志——它和缓存无关,只反映本次Do是否触发了实际执行
最易被忽略的点:singleflight 不是缓存,也不管 TTL,更不处理恶意穿透;它只是请求合并器,用错位置或漏掉空值缓存,防击穿都会变成假动作。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










