缓存击穿在 go 中用 singleflight.group 是因它能合并并发请求,只执行一次后端调用并共享结果,避免重复压力;而 sync.mutex 仅串行化、无法去重。

为什么缓存击穿在 Go 里要用 singleflight.Group
缓存击穿本质是:某个热门 key 过期瞬间,大量并发请求同时发现缓存 miss,全部打到后端(DB/HTTP),造成瞬时压力飙升。单纯加锁(如 sync.Mutex)能串行化请求,但无法合并重复请求——每个 goroutine 都得等前一个完成才能开始,吞吐暴跌。singleflight.Group 的核心价值在于「让所有并发的相同 key 请求共享同一个执行过程」,只触发一次后端调用,其余等待并复用结果。
singleflight.Group.Do 的典型用法和参数陷阱
Do 是唯一入口,签名是 Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool)。关键点不在怎么写,而在怎么“不写错”:
-
key必须稳定可比较:避免用含指针、map、slice 的结构体;推荐用fmt.Sprintf("%s:%d", resource, id)拼字符串,别用json.Marshal做 key(性能差且浮点数精度可能引发 key 不一致) -
fn函数必须幂等且无副作用:它可能被多次传入(虽然只执行一次),但内部不能有状态变更(如递增计数器)、不能依赖闭包变量的实时值(因为闭包捕获的是调用Do时的快照) - 返回的
shared字段常被忽略,但它能帮你判断是否真命中了去重:为true表示当前 goroutine 没执行fn,而是等到了别人的结果;false表示自己是那个执行者——可用于日志采样或指标打点,避免每请求都记日志
和 Redis 缓存组合时的生命周期注意点
singleflight.Group 是内存级、无过期的,它只解决「同一时刻多个请求打同一 key」的问题,不替代缓存本身的 TTL 管理。常见错误是把两者职责混在一起:
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
- 不要在
fn里直接写缓存:先调用后端拿到结果,再单独做redis.Set;否则一旦 redis 写失败,singleflight已返回成功,下次请求仍会再次触发整个流程(包括失败路径) - 缓存失效后,首次请求走
singleflight加载,后续请求若在加载完成前到达,会等待;但若加载耗时 > 缓存 TTL,可能出现「加载中 → 写缓存 → 缓存立即过期 → 下一波请求又触发加载」的循环,需配合「逻辑过期」或「后台刷新」缓解 -
Group实例应全局复用,不要按请求新建:它内部用map[string]*call管理待处理请求,频繁创建销毁会导致 map 重建、GC 压力和 key 隔离(同 key 在不同 Group 里不共享)
超时控制必须由业务层兜底
singleflight.Group 本身不支持超时,Do 会一直阻塞直到 fn 返回。如果后端调用卡住(比如 DB 连接池耗尽、HTTP 服务假死),所有等待该 key 的 goroutine 全部挂起,可能拖垮整个服务:
- 务必在
fn内部做超时控制:用context.WithTimeout包裹 DB 查询或 HTTP 调用,而不是依赖外层Do - 不要试图用
select+time.After包裹Do:这只能让当前 goroutine 超时退出,但已进入singleflight执行队列的请求还在跑,资源没释放,且后续请求仍会排队等这个卡住的 call - 对延迟敏感的场景(如接口 SLA ≤ 100ms),建议给
fn设置比接口超时更短的 deadline(比如 80ms),留出序列化、网络传输余量
真正难的不是调用 Do,而是设计 key 的粒度、协调缓存 TTL 和后端响应时间、以及确保 fn 的健壮性——这些地方出问题,singleflight 不仅救不了场,反而会让故障更隐蔽。










