高并发服务里,真正危险的时刻往往不是平峰,而是缓存刚好失效的那一瞬间。热点数据一旦失去缓存保护,大量请求会同时回源,数据库、外部接口甚至下游服务都可能被瞬间打满。
Go 官方扩展库里的 singleflight,就是专门处理这类“同一份数据被并发重复请求”问题的工具。下面从原理、代码示例、缓存击穿场景和落地注意点几个角度拆开看,帮助你判断它在项目里该怎么用、该用到哪一层。
singleflight 到底解决了什么问题
singleflight 的核心目标很明确:同一时刻,如果多个 goroutine 请求的是同一个资源,只让其中一个真正执行,其余请求等待这个结果,然后共享返回值。

这不是“把所有请求都锁住”,而是只针对“相同 key 的重复请求”做合并。不同资源之间仍然可以并发执行,所以它和粗粒度互斥锁有本质区别。
从实现思路看,singleflight 内部维护了一张以请求标识为 key 的表,表里记录当前正在执行的调用。当新请求到来时,会先检查是否已有同 key 请求在进行中:
- 如果有,就等待已有请求结束,并直接复用其结果
- 如果没有,就注册本次调用,执行真实逻辑,完成后再把结果分发给其他等待者
文章原文用“多人一起点外卖,只让一个人下单”的比喻来解释,这个理解是准确的。放到服务端语境里,就是把原本会重复触发的数据库查询、文件读取或外部 API 调用,压缩成一次。
它和普通加锁有什么区别
很多人第一次看到这个问题,会想到给查询逻辑加互斥锁。这样当然能挡住并发回源,但副作用也很明显:请求容易被整体串行化,吞吐量会掉得很厉害。
singleflight 的优势在于,它并不阻止并发本身,而是只消除“同一份资源的重复工作”。对热点 key 很有效,对非热点 key 的正常并发影响较小。
一个最小示例:同 key 请求如何被合并
先看一段最直观的示例代码。它展示的是:两次对同一个 key 的调用同时发生时,真正的查询逻辑只会执行一次。
func main() {
g := new(singleflight.Group)
// 第1次调用
go func() {
v1, _, _ := g.Do("key", func() (interface{}, error) {
fmt.Println("执行查询...") // 只会打印一次
time.Sleep(2 * time.Second)
return "result", nil
})
fmt.Printf("1st: %vn", v1)
}()
time.Sleep(100 * time.Millisecond)
// 第2次调用(等待第1次的结果)
v2, _, _ := g.Do("key", func() (interface{}, error) {
fmt.Println("执行查询...") // 不会执行
return "result", nil
})
fmt.Printf("2nd: %vn", v2)
}输出结果如下:
执行查询...
1st: result
2nd: result关键现象只有一个:"执行查询..." 只打印了一次。这说明第二次 g.Do("key", ...) 并没有真的执行自己的函数体,而是直接等待第一次调用完成,然后共享第一次调用的结果。
这也是 singleflight 最值得用的地方。它并不要求你重写业务逻辑,只需要把“可能被并发重复执行”的那段操作包进 Do 即可。
读这段代码时要注意什么
这里的“合并”依赖于 key。只要 key 相同,多个调用就会被视为同一类请求;如果 key 不同,即便执行的是同一段函数,也不会合并。
这意味着,key 的设计直接决定了 singleflight 是否生效。过粗会误合并不同请求,过细又会让本该合并的请求失去效果。
它为什么特别适合处理缓存击穿
理解了请求合并之后,再看缓存击穿就很容易了。

所谓缓存击穿,通常指某个热点数据过期后,短时间内大量并发请求同时发现缓存为空,于是全部回源查询数据库。数据库本来只需要回答一次的问题,突然被问了几十次、几百次,压力会瞬间放大。
传统方案常见有两类:
一类是给回源逻辑加互斥锁,只允许一个请求查数据库,其他请求阻塞等待。这种方式能止血,但容易把吞吐拖慢。
另一类是让热点数据尽量不过期,或者由后台主动刷新。这能降低击穿概率,但维护成本更高,也不是所有业务都适合这么做。
singleflight 提供的是第三种思路:让请求并发到达,但对同一个热点 key 只执行一次真实回源,其他请求复用结果。
对应到代码里,最常见的写法如下:
func GetUser(userID string) (string, error) {
// 先查缓存,如果存在直接返回
if val, ok := cache[userID]; ok {
return val, nil
}
// 使用singleflight合并请求
val, err, _ := sg.Do(userID, func() (interface{}, error) {
data := queryFromDB(userID) // 查询数据库
cache[userID] = data // 写入缓存
return data, nil
})
return val.(string), err
}这段代码的判断顺序很重要:
- 先查缓存,命中就直接返回
- 缓存未命中时,再进入
sg.Do(userID, ...) - 并发请求如果拿的是同一个
userID,只有一个会真正执行queryFromDB(userID) - 查询结果写回缓存后,其他等待中的请求直接共享这个结果
这样一来,缓存失效时最危险的那一波并发回源,就被压缩成了一次数据库查询。对热点数据查询接口来说,这个收益通常非常直接。
为什么它比“永不过期”更通用
热点数据永不过期,本质上是在规避缓存失效事件;而 singleflight 是在缓存失效已经发生时,尽量把冲击限制住。前者依赖业务方识别热点并维护刷新策略,后者则更像是回源链路上的通用保护层。
因此在实际项目里,二者并不冲突。你可以保留原有缓存策略,再用 singleflight 给回源逻辑加一道保险。
三个常用方法怎么选
singleflight 常用的方法主要有三个:Do、DoChan 和 Forget。理解它们的差别,才能在同步请求、超时控制和缓存刷新场景里用得顺手。
Do:默认首选,适合同步等待结果
Do 是最常见的入口。调用方会阻塞等待结果返回,适合绝大多数接口查询、缓存回源和普通同步逻辑。
如果你的业务流程本来就是“请求进来后必须拿到结果再继续”,那么优先使用 Do,实现和理解成本都最低。
DoChan:需要配合 select 做超时控制时更合适
当你不想无限期等待,可以用 DoChan 把结果转成 channel,再结合 select 做超时控制:
ch := sg.DoChan(key, func() (interface{}, error) {
return fetchData(), nil
})
select {
case result := <-ch:
// 处理结果
case <-time.After(3 * time.Second):
// 超时处理
}这种写法特别适合对响应时间有明确上限的接口。如果下游依赖偶发抖动,至少可以让上层尽快返回超时,而不是把所有 goroutine 长时间挂住。
Forget:数据更新后,允许下次请求重新执行
Forget 用于让某个 key 的合并状态失效。它通常出现在数据刚被更新、你不希望继续复用旧请求结果的场景里。
比如后台修改了用户资料,你希望紧接着的读取请求尽快重新查询并刷新缓存,那么就可以考虑在合适时机调用 Forget,让后续同 key 请求重新发起执行。
性能收益明显,但要注意结果共享和阻塞风险
从收益上看,singleflight 在热点 key 上的效果往往很夸张。

原文给出的例子是:假设有 100 个并发请求同时查询同一个用户信息。如果不做任何合并,这 100 个请求会同时打到数据库;而引入 singleflight 后,100 个请求里只有 1 个真正查询数据库,其余 99 个等待并共享结果,数据库压力理论上可降低 99%。
这种收益在缓存击穿、外部 API 限流、重计算任务等场景都成立。文中还提到,在电商大促场景下,引入这一机制后,数据库 QPS 从峰值 10 万降到几百,虽然这是场景化数字,但足以说明“重复请求合并”对热点流量的抑制能力。
不过,它并不是没有边界,至少有三类问题需要提前考虑。
第一,返回结果会被多个 goroutine 共享
等待中的调用方拿到的是同一份结果。如果你返回的是指针、切片、map 或其他可变对象,后续一旦被多个 goroutine 修改,就可能引发数据竞争或者脏数据问题。
因此更稳妥的做法是:要么返回值类型,要么在共享前完成拷贝,要么明确约束调用方只能只读使用结果。
第二,错误也会被一并共享
如果实际执行的函数返回错误,那么所有等待中的 goroutine 都会收到同一份错误。这个行为本身没有问题,但业务上未必总是符合预期。
例如某次下游短暂抖动导致请求失败,那么这一时刻等待同 key 的所有请求都会一起失败。是否要在上层补重试、降级或兜底数据,需要结合你的接口语义来决定。
第三,执行函数卡住时,会放大阻塞影响
singleflight 的本质是“让大家一起等一个结果”,所以一旦这个结果迟迟不返回,所有同 key 请求都会被拖住。问题不在于它制造了阻塞,而在于它把多个等待者绑到了同一个慢调用上。
因此,真实项目里最好配合 context 和超时机制使用:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err, _ := sg.Do(key, func() (interface{}, error) {
return fetchDataWithContext(ctx)
})这样至少能给回源逻辑设定明确的退出边界,避免慢请求持续堆积。
落地时放在哪一层,key 又该怎么设计
在工程实践里,singleflight 通常不应该零散地塞进每个业务 handler,而更适合封装在缓存层、仓储层或数据访问层之上。这样做有两个好处:一是复用性更高,二是更容易统一管理 key 规则、超时和错误处理。
key 的设计尤其关键。它必须能唯一代表“一次可合并的请求”,常见选择包括用户 ID、商品 ID、订单号等。如果请求参数比较复杂,可以把关键参数序列化成字符串作为 key,但要保证相同语义一定能生成相同 key。
除了缓存击穿,它还可以用于这些场景:
- API 请求合并,减少对外部服务的重复调用
- 文件读取优化,避免并发重复读取同一文件
- 计算密集型任务,避免短时间内重复计算相同结果
这些场景的共同点都是一样的:短时间内,同一份资源或同一段昂贵逻辑,可能被多个请求重复触发。
一个实用判断标准
如果某段逻辑同时满足三个条件,通常就值得考虑引入 singleflight:
- 存在明显的同 key 并发重复请求
- 底层操作成本高,比如查数据库、调远端服务、读大文件或做重计算
- 多个请求共享同一份结果在业务上是成立的
反过来说,如果结果强依赖调用时机、返回值不可安全共享,或者单次执行本身非常便宜,那么未必需要引入它。
结语:它不是缓存本身,而是回源路径上的减压器
singleflight 的价值,不在于替代缓存,而在于当缓存失效、请求扎堆、回源压力陡增时,帮你把重复工作压缩掉。相比简单互斥锁,它保留了并发等待的能力;相比“热点永不过期”这类策略,它又更通用、接入成本更低。
如果你的 Go 服务里已经有缓存层、数据库访问层或外部接口聚合层,singleflight 很适合作为一道轻量保护措施加进去。关键不是盲目上库,而是先确认热点 key 是否明显、共享结果是否安全、超时与错误传播是否已经想清楚。









