singleflight 详解
singleflight 是 Go 官方提供的一个请求合并(Request Coalescing)组件。
它解决的问题非常经典:
多个 goroutine 同时请求同一个资源时,只让其中一个真正执行,其他人等待结果,然后共享同一个返回值。
属于一种:
-
Cache Breakdown(缓存击穿)保护
-
防止重复计算
-
防止数据库雪崩
-
防止下游服务被打爆
的常见手段。
1. 先看一个场景
假设缓存失效:
func GetUser(id int) (*User, error) {
if v := cache.Get(id); v != nil {
return v, nil
}
// 查询数据库
user := db.Query(id)
cache.Set(id, user)
return user, nil
}
某一时刻:
10000个请求同时访问 user:1
缓存刚好过期:
10000 个请求
↓
cache miss
↓
10000 次 DB 查询
数据库直接被打爆。
如果用了 singleflight:
10000 个请求
↓
只有 1 个请求查 DB
↓
9999 个请求等待
↓
共享结果
变成:
10000请求
↓
1次DB查询
↓
10000个请求拿到同样结果
2. singleflight 的使用
来自:
golang.org/x/sync/singleflight
示例:
var g singleflight.Group
func GetUser(id int) (*User, error) {
v, err, _ := g.Do(
fmt.Sprintf("user:%d", id),
func() (interface{}, error) {
fmt.Println("query db")
return db.Query(id)
},
)
return v.(*User), err
}
同时来 1000 个请求:
go GetUser(1)
go GetUser(1)
go GetUser(1)
...
输出:
query db
只执行一次。
3. singleflight 的核心思想
核心结构非常简单:
type Group struct {
mu sync.Mutex
m map[string]*call
}
里面维护:
key -> 正在执行的任务
例如:
"user:1"
↓
call
4. call 是什么
源码:
type call struct {
wg sync.WaitGroup
val interface{}
err error
dups int
chans []chan<- Result
}
可以理解成:
一次正在执行的请求
里面保存:
执行状态
执行结果
执行错误
等待者数量
5. Do() 源码逻辑
核心代码(简化版):
func (g *Group) Do(
key string,
fn func() (interface{}, error),
) (interface{}, error, bool)
第一步:
加锁
g.mu.Lock()
查看 map:
if c, ok := g.m[key]; ok {
意思:
这个 key 已经有人在执行
例如:
goroutine A
正在查 user:1
此时:
goroutine B
goroutine C
goroutine D
也来了。
发现:
g.m["user:1"] 已存在
说明:
A 正在执行
于是:
c.dups++
g.mu.Unlock()
c.wg.Wait()
直接等待:
A 执行结束
结束后:
return c.val, c.err, true
这里:
shared = true
表示:
我是蹭别人结果的
6. 第一个请求做什么
如果 map 中不存在:
c := new(call)
初始化:
c.wg.Add(1)
注册到 map:
g.m[key] = c
此时:
"user:1"
↓
c
释放锁:
g.mu.Unlock()
开始真正执行:
c.val, c.err = fn()
即:
db.Query(...)
执行完:
c.wg.Done()
通知:
所有等待者
然后:
delete(g.m, key)
删除记录。
7. 时序图
假设:
Do("user:1")
有三个 goroutine。
G1
进入
↓
map没有
↓
创建call
↓
执行fn
G2
进入
↓
发现已有call
↓
Wait()
G3
进入
↓
发现已有call
↓
Wait()
G1执行完成:
val=user
err=nil
然后:
wg.Done()
此时:
G2被唤醒
G3被唤醒
获得:
同一个结果
最终:
G1 -> DB
G2 -> 等待
G3 -> 等待
只查一次 DB
8. 为什么用 WaitGroup
因为等待的人数未知。
可能:
2个
20个
2000个
如果用 channel:
done := make(chan struct{})
需要:
close(done)
广播。
其实也能实现。
官方选择:
sync.WaitGroup
更直接:
wg.Add(1)
wg.Done()
wg.Wait()
所有等待者自动被唤醒。
9. 为什么 map 要加锁
多个 goroutine 同时:
Do("user:1")
会并发访问:
g.m
而 Go 的 map:
不是线程安全的
所以:
mu sync.Mutex
保护:
新增 call
删除 call
查找 call
10. DoChan() 是怎么实现的
还有一个异步版本:
ch := g.DoChan(key, fn)
返回:
<-chan Result
源码:
type Result struct {
Val interface{}
Err error
Shared bool
}
执行完成后:
for _, ch := range c.chans {
ch <- Result{
Val: c.val,
Err: c.err,
}
}
相当于:
等待者注册 channel
↓
任务完成
↓
统一广播结果
11. singleflight 的一个局限
它并不是缓存。
很多人误以为:
singleflight = cache
实际上不是。
例如:
g.Do("user:1", queryDB)
第一次:
执行 queryDB
结束后:
delete(g.m, "user:1")
记录立即删除。
第二次:
g.Do("user:1", queryDB)
仍然会:
再次执行 queryDB
所以:
singleflight 只合并“同时发生”的请求
不会缓存结果。
12. 实际项目中的经典组合
通常是:
Redis
+
singleflight
+
MySQL
流程:
请求
↓
Redis
↓ miss
singleflight
↓
MySQL
↓
写回 Redis
当缓存失效:
10000请求
↓
Redis miss
↓
singleflight
↓
只有1次MySQL查询
这就是 Go 中最经典的缓存击穿保护方案。
源码实现本质(浓缩版)
可以把 singleflight 理解成下面这个极简模型:
map[key]*call
其中:
type call struct {
wg sync.WaitGroup
val interface{}
err error
}
执行流程:
Do(key)
↓
map里有call ?
↓
是 --------→ Wait()
↓ ↓
否 返回结果
↓
创建call
↓
执行fn
↓
保存结果
↓
wg.Done()
↓
删除call
整个实现其实只依赖了三个并发原语:
Mutex // 保护 map
WaitGroup // 等待执行完成
Map // key -> call
源码不到 200 行,但它解决了高并发场景下大量重复请求的问题,因此在 Go 服务端(缓存、配置中心、RPC、数据库访问)中被广泛使用。