go singleflight 是用于合并同一时刻重复请求的机制,适用于缓存击穿等场景;需将全部加载逻辑(含db/http调用及缓存写入)封装进do的fn参数中,key须为稳定字符串,且真实调用需自行处理ctx超时。

Go singleflight 是什么,什么时候该用它
它不是缓存,也不是并发控制工具,而是专门用来「合并同一时刻的重复请求」的机制。当你发现多个 goroutine 同时调用 GetUser(id)、FetchConfig() 这类耗时操作,且参数完全相同时,singleflight 就该上场了——尤其是后端服务在缓存失效瞬间被大量相同 key 请求打穿(缓存击穿)的场景。
常见错误现象:redis: nil 后立刻涌进 50 个并发 DB.QueryRow("SELECT * FROM users WHERE id = ?"),数据库 CPU 瞬间拉满;或者 HTTP handler 里反复调用 http.Get("https://api.example.com/token") 获取同一个 token。
- 它不替代缓存,但必须和缓存配合使用:先查缓存 → 缓存 miss → singleflight.Do → 执行真实加载 → 写回缓存
- 不能用于需要独立响应的场景,比如每个请求要带不同 trace ID 或超时控制
-
singleflight.Group是线程安全的,可全局复用,无需每次 new
怎么写一个安全的 singleflight.Do 调用
核心是把「加载逻辑」包进函数传给 Do,而不是在 Do 外面做判断。很多人误写成「先 if cache miss,再 Do」,这会导致竞态窗口 —— 两个 goroutine 都查到 miss,然后都进 Do,但 Do 只会让一个执行,另一个等结果。这本身没问题,但如果你在 Do 外做了副作用(比如 log、metric inc),就会重复执行。
正确姿势是所有加载逻辑(含 DB 查询、HTTP 调用、解析等)全部塞进 Do 的 fn 参数里:
立即学习“go语言免费学习笔记(深入)”;
var g singleflight.Group
<p>func GetUser(ctx context.Context, id int) (<em>User, error) {
v, err, _ := g.Do(strconv.Itoa(id), func() (interface{}, error) {
// 所有真实加载逻辑,只在这里执行一次
u, err := db.QueryRowContext(ctx, "SELECT ...", id).Scan(...)
if err != nil {
return nil, err
}
// 写缓存也放这里,确保只有成功加载后才写
cache.Set(fmt.Sprintf("user:%d", id), u, time.Minute)
return u, nil
})
if err != nil {
return nil, err
}
return v.(</em>User), nil
}-
Do第二个参数必须是func() (interface{}, error),别漏掉返回interface{} - key 类型必须是
string,别直接传 struct 或 int;用fmt.Sprintf或strconv转成稳定字符串 - 不要在
Do外做任何可能失败或有副作用的操作,否则会破坏“只执行一次”的语义
singleflight.DoKey 和 DoChan 的区别与取舍
Do 是最常用接口,同步阻塞直到结果返回;DoChan 返回 chan singleflight.Result,适合不想阻塞当前 goroutine 的场景(比如你已经在 select 里处理超时);DoKey 是 Go 1.22+ 新增的,允许你显式指定 key 和执行函数分开,避免闭包捕获变量引发的内存泄漏风险。
典型坑:Do 里闭包引用了 handler 的 req 或 ctx,而这个 ctx 带了 deadline,但 Do 内部不感知 ctx 超时 —— 它只管等那个唯一执行的 goroutine 完成。所以真实加载逻辑里必须自己传 ctx 并做超时控制。
- 优先用
Do,简单直接;除非你在写中间件或需要非阻塞调度 - 用
DoChan时记得加select { case r := ,不然 channel 可能永远不关闭 -
DoKey在高频 key 场景下更省内存,尤其当 key 构造开销大(比如拼接 JSON)时,可提前算好复用
缓存击穿场景下,singleflight 为什么不能 standalone 工作
它自己不存数据,也不管缓存生命周期。如果只用 singleflight 而不写缓存,下次缓存失效还是会被打穿 —— 因为 Do 每次都按新 key 触发,而 key 不变时它才去重。但缓存过期后,你仍会拿到 cache miss,于是又进 Do,此时 key 还是同一个,所以依然能去重。问题在于:如果缓存层本身没设「逻辑过期」或「互斥锁」,singleflight 只能缓解并发冲击,不能阻止缓存穿透或永久性击穿(比如 key 本来就不存在)。
- 必须搭配「缓存空值 + 过期时间」防穿透,比如
cache.Set("user:999999", nil, time.Second*30) - 建议在
Do内部加载失败时也写空值,否则下次同 key 请求仍会触发完整加载流程 - 注意
singleflight的 key 生命周期:它只在正在执行/等待中时有效;执行完就丢,不会长期驻留内存
最容易被忽略的是:singleflight 不感知上下文取消,也不自动清理失败的 pending 请求。一旦某个 key 的加载卡死(比如 DB 连接池耗尽),后续所有同 key 请求都会卡在 Do 等它,形成隐形雪崩。得靠加载函数内部的 ctx 超时 + 重试策略兜底。










