Go 并发利器:singleflight 如何防止缓存击穿

Elena
Elena
发布时间:2026-08-25 | 172人浏览过
原创

前言

缓存命中率再高,也挡不住热点数据在失效瞬间被并发请求同时打穿的风险。Go 扩展库里的 singleflight,正适合放在缓存回源这一步做“同 key 请求合并”;本文会结合代码示例、方法差异和使用边界,帮你判断它能省掉多少重复查询,以及哪些场景下要额外配合超时、重试和只读结果设计。

高并发服务里,真正危险的时刻往往不是平峰,而是缓存刚好失效的那一瞬间。热点数据一旦失去缓存保护,大量请求会同时回源,数据库、外部接口甚至下游服务都可能被瞬间打满。

Go 官方扩展库里的 singleflight,就是专门处理这类“同一份数据被并发重复请求”问题的工具。下面从原理、代码示例、缓存击穿场景和落地注意点几个角度拆开看,帮助你判断它在项目里该怎么用、该用到哪一层。

singleflight 到底解决了什么问题

singleflight 的核心目标很明确:同一时刻,如果多个 goroutine 请求的是同一个资源,只让其中一个真正执行,其余请求等待这个结果,然后共享返回值。

singleflight 合并同 key 请求的原理示意图
同 key 请求为什么只会执行一次用编辑卡片和箭头展示 singleflight 的核心机制:同 key 并发请求只执行一次。

这不是“把所有请求都锁住”,而是只针对“相同 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 处理缓存击穿的落地流程图
缓存失效后,请求是怎样被拦住的把缓存未命中后的回源过程拆开后可以看到,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
}

这段代码的判断顺序很重要:

  1. 先查缓存,命中就直接返回
  2. 缓存未命中时,再进入 sg.Do(userID, ...)
  3. 并发请求如果拿的是同一个 userID,只有一个会真正执行 queryFromDB(userID)
  4. 查询结果写回缓存后,其他等待中的请求直接共享这个结果

这样一来,缓存失效时最危险的那一波并发回源,就被压缩成了一次数据库查询。对热点数据查询接口来说,这个收益通常非常直接。

为什么它比“永不过期”更通用

热点数据永不过期,本质上是在规避缓存失效事件;而 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 上的效果往往很夸张。

singleflight 使用注意点与方法选择对照图
收益之外,哪些边界最容易踩坑性能收益很直接,但返回值共享、错误传播和超时控制也要一并设计。

原文给出的例子是:假设有 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:

  1. 存在明显的同 key 并发重复请求
  2. 底层操作成本高,比如查数据库、调远端服务、读大文件或做重计算
  3. 多个请求共享同一份结果在业务上是成立的

反过来说,如果结果强依赖调用时机、返回值不可安全共享,或者单次执行本身非常便宜,那么未必需要引入它。

结语:它不是缓存本身,而是回源路径上的减压器

singleflight 的价值,不在于替代缓存,而在于当缓存失效、请求扎堆、回源压力陡增时,帮你把重复工作压缩掉。相比简单互斥锁,它保留了并发等待的能力;相比“热点永不过期”这类策略,它又更通用、接入成本更低。

如果你的 Go 服务里已经有缓存层、数据库访问层或外部接口聚合层,singleflight 很适合作为一道轻量保护措施加进去。关键不是盲目上库,而是先确认热点 key 是否明显、共享结果是否安全、超时与错误传播是否已经想清楚。

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表