Go 语言一直以简单高效著称,并发的支持更是 Go 语言的强项。除了 Goroutine 协程、Channel 通道、Atomic 原语等特性,还在扩展包 golang.org/x 中提供了 singleflight 这一工具。
singleflight 的导入路径为 golang.org/x/sync/singleflight,相关资料可以在 https://pkg.go.dev/golang.org/x/sync/singleflight 看到。singleflight 直译过来是“单飞”,它的主要作用就是将一组相同的请求合并成一个请求,实际上只会去请求一次,然后对所有的请求返回相同的结果。因为能确保同一操作不会被同时多次执行。这对于避免重复的工作和资源浪费非常有效,尤其是在并发环境中。
应用场景
在实际生产中,singleflight 通常用来抑制请求避免缓存击穿。瞬间流量较大时,遇见缓存失效或者缓存未命中的情况,就需要请求数据库并将结果保存到 Redis 中,并且返回给请求。
实际运用中,有下面这两种方式。
首先看第一种方式,在请求 Redis 之后发现 cache miss,之后需要查询数据库。使用 singleflight 抑制了查询数据库的操作。访问 Redis 的过程并没有做限制,以 Redis 的性能来说是没有问题的。而且不需要在 singleflight 的逻辑中处理 Redis 访问错误。
再看第二种方式,在请求 Redis 之前即执行 singleflight,使用 singleflight 抑制了对于 Redis 的请求,也保证了同一时刻只有一个线程访问 DB。在请求数过多时,限制了访问 Redis 的线程数,可以有效减少 Redis 的请求以及网络开销。但是在访问 Redis 和读库这个过程中,需要处理好请求 Redis 失败时的逻辑,需要调用 Forget 方法,放开下一个线程的请求。
因为都抑制了请求,所以在访问 Redis 或者数据库时发生错误,需要调用 Forget 放下一个线程进入,避免这一批请求,因为第一个请求失败而导致全部失败。
使用方法
1、导入 singleflight 相关的依赖。
go get golang.org/x/sync/singleflight
2、模拟一个昂贵的操作。
// expensiveOperation 模拟一个昂贵的操作
func expensiveOperation(key string) (string, error) {
fmt.Printf("Executing expensive operation for key: %s\n", key)
time.Sleep(2 * time.Second) // 模拟耗时操作
return fmt.Sprintf("Result for %s", key), nil
}
3、模拟使用 singleflight 进行请求抑制,减少实际请求的次数。
func main() {
// 创建一个 Group 实例
var g singleflight.Group
// 模拟并发请求
keys := []string{"key1", "key2", "key1"}
for _, key := range keys {
go func(k string) {
// 使用 Group 的 Do 方法
result, err, _ := g.Do(k, func() (interface{}, error) {
// 执行昂贵的操作
return expensiveOperation(k)
})
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Result for %s: %s\n", k, result.(string))
}
}(key)
}
// 等待所有 goroutines 完成
time.Sleep(3 * time.Second)
}
4、查看运行结果。
Executing expensive operation for key: key1
Executing expensive operation for key: key2
Result for key2: Result for key2
Result for key1: Result for key1
Result for key1: Result for key1
根据运行结果,可以看出 key1 虽然请求了两次,但是实际的操作只处理了一次。两次请求,最终返回的结果也是 key1 的结果,符合我们的预期。
实现原理
定义的结构,主要是 Group、Result 以及内部使用的 call。为了区分,将用户传入的 func 称为用户函数。
// call is an in-flight or completed singleflight.Do call
type call struct {
wg sync.WaitGroup
// These fields are written once before the WaitGroup is done
// and are only read after the WaitGroup is done.
val interface{}
err error
// These fields are read and written with the singleflight
// mutex held before the WaitGroup is done, and are read but
// not written after the WaitGroup is done.
dups int
chans []chan<- Result
}
// Group represents a class of work and forms a namespace in
// which units of work can be executed with duplicate suppression.
type Group struct {
mu sync.Mutex // protects m
m map[string]*call // lazily initialized
}
// Result holds the results of Do, so they can be passed
// on a channel.
type Result struct {
Val interface{}
Err error
Shared bool
}
主要分支逻辑是在 Do 和 DoChan 方法中。singleflight 会把用户函数根据 key 进行分组,key 相同的函数返回的结果一样。在 mutex + waitGroup 的作用下,只有一个 Goroutine 会去执行用户函数,其余的用户函数则等待 wg.Done 的响应,然后读取 map 中的结果。
核心逻辑在 doCall 方法中,对外提供了 DoChan 异步方法与 Do 同步方法。
// Do 执行并返回给定函数的结果,确保一次只有一个给定键的执行。
// 如果重复出现,则重复调用方等待原始完成并接收相同的结果。
// 返回值 shared 指示是否将 v 提供给多个调用方。
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
g.mu.Lock()
if g.m == nil {
// 懒加载 map
g.m = make(map[string]*call)
}
if c, ok := g.m[key]; ok {
// 存在 key 则解锁
c.dups++
g.mu.Unlock()
// 等待 waitGroup 执行完毕,执行完毕时,所有的 wait 都会被唤醒
c.wg.Wait()
// 判断 panic 错误和 runtime 错误,避免死锁
if e, ok := c.err.(*panicError); ok {
panic(e)
} else if c.err == errGoexit {
runtime.Goexit()
}
return c.val, c.err, true
}
c := new(call)
c.wg.Add(1)
// 加载进 map
g.m[key] = c
// 最小化加锁的路径节点,key 保存进 map 之后,其余的线程都会阻塞在 wg.Wait 中
g.mu.Unlock()
// 触发调用的核心逻辑
g.doCall(c, key, fn)
// 返回传入的 func 的返回值、错误,以及返回值是否有被 shared 共享
return c.val, c.err, c.dups > 0
}
实际的调用逻辑,巧妙的地方在 doCall 方法中,区分了 runtime 错误与业务函数的 panic 错误,避免了死锁。通过两个 defer 函数,逻辑区分的很清晰。
// doCall 处理键的单个调用
func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
normalReturn := false
recovered := false
// 使用 double-defer 将 panic 与 runtime.Goexit 区分开来
// more details see https://golang.org/cl/134395
defer func() {
// 用户函数调用了 runtime.Goexit
if !normalReturn && !recovered {
c.err = errGoexit
}
g.mu.Lock()
defer g.mu.Unlock()
c.wg.Done()
// 为避免 group 占用的内存上涨,处理之后删除 key
if g.m[key] == c {
delete(g.m, key)
}
if e, ok := c.err.(*panicError); ok {
// 为了防止等待信道被永远阻塞,需要确保这种 panic 不能被恢复
if len(c.chans) > 0 {
go panic(e)
// 使 Goroutine 循环以保证在 crash 的时候能 dump
select {}
} else {
panic(e)
}
} else if c.err == errGoexit {
// 已经在 goexit 的过程中,不需要再次调用
} else {
// 正常返回
for _, ch := range c.chans {
ch <- Result{c.val, c.err, c.dups > 0}
}
}
}()
func() {
defer func() {
if !normalReturn {
// 理想情况下,将等待进行堆栈跟踪,直到确定这是 panic 还是 runtime.Goexit。
// 不幸的是,我们可以区分两者的唯一方法是查看 recover 是否停止了 goroutine 的终止
// 当我们知道这一点时,与 panic 相关的堆栈跟踪部分已被丢弃。
if r := recover(); r != nil {
// 如果 panic 了我们就 recover 掉,然后 new 一个 panic 的错误
// 通过这个 new 出来的 panicError,在上层判断之后再重新 panic
c.err = newPanicError(r)
}
}
}()
c.val, c.err = fn()
// 如果用户函数没有 panic 就会执行到这一步,如果 panic 了就不会执行到这一步
// 所以可以通过这个变量来判断是否 panic 了
normalReturn = true
}()
// 如果 normalReturn 为 false 就表示用户函数 panic 了
// 如果执行到了这一步,也就说明用户函数 recover 住了,不是直接 runtime exit
// 根据 defer 函数的执行顺序,到这一步会执行第二个 defer 函数,判断 normalReturn 的值
if !normalReturn {
recovered = true
}
}
DoChan 函数和 Do 函数类似,只是返回的值不同,给了用户更大的选择权。
// DoChan 类似于Do ,但返回一个通道,该通道将在准备好后接收结果。
// 返回的通道不会关闭。
func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result {
ch := make(chan Result, 1)
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*call)
}
if c, ok := g.m[key]; ok {
c.dups++
c.chans = append(c.chans, ch)
g.mu.Unlock()
return ch
}
c := &call{chans: []chan<- Result{ch}}
c.wg.Add(1)
g.m[key] = c
g.mu.Unlock()
go g.doCall(c, key, fn)
return ch
}
除此之外,singleflight 还提供了 Forget 函数,支持手动释放某个 key,调用的时候不会阻塞等待。
注意事项
因为 singleflight 属于请求抑制,所以对于同一时刻执行的用户函数,相当于是一荣俱荣一损俱损。因此,也就难免会有阻塞问题和错误问题。
阻塞问题
使用 singleflight 我们比较常见的是直接使用 Do 方法,但是这个极端情况下会导致整个程序 hang 住。如果代码出点问题,有一个调用 hang 住了,那么会导致所有的请求都 hang 住。
func TestSingleFlightWithDoChan(t *testing.T) {
// 创建一个 Group 实例
var g singleflight.Group
ctx, cancel := context.WithTimeout(context.TODO(), 1*time.Second)
defer cancel()
// 模拟并发请求
keys := []string{"key1", "key2", "key1"}
for _, key := range keys {
go func(k string) {
// 使用 Group 的 Do 方法
result := g.DoChan(k, func() (interface{}, error) {
// 执行昂贵的操作
return expensiveOperationWithCtx(ctx, k)
})
select {
case r := <-result:
fmt.Printf("Result for %s: %s\n, error:%v", k, r.Val.(string), r.Err)
return
case <-ctx.Done():
fmt.Printf("Operation timeout: %v\n", ctx.Err())
default:
fmt.Printf("Waiting for the result: %s\n", k)
}
}(key)
}
// 等待所有 goroutines 完成
time.Sleep(3 * time.Second)
}
超时的问题,可以通过引入 ctx 的超时控制解决。
错误问题
第一次用户函数的执行结果,会传递给同一时刻的其他用户函数。为了避免处理失败的结果传递,导致这一批次全部失败,可以通过启动 Goroutine 定时 Forget 来解决。
这个时间间隔的设定,取决于下游服务支持的并发数。不过需要记住,在并发环境中,需要考虑 pod 实例个数。
go func() {
time.Sleep(100 * time.Millisecond)
// logging
g.Forget(key)
}()
总结
-
singleflight 可以实现请求抑制,是预防缓存击穿的有力工具; -
singleflight 主要利用 mutex+waitGroup 实现请求阻塞,利用 map 缓存请求结果; -
注意使用 singleflight 的阻塞问题(添加超时控制)与错误问题(定期通过 Forget 刷新 key);
参考资料
-
Go 官方文档对 singleflight 的介绍: https://pkg.go.dev/golang.org/x/sync/singleflight -
Go singleflight 的使用: https://zhuanlan.zhihu.com/p/683356597 -
Golang 防缓存击穿工具: https://zhuanlan.zhihu.com/p/382965636 -
Go 语言之防缓存击穿利器 singleflight: https://www.lixueduan.com/posts/go/singleflight/ -
singleflight 介绍: https://developer.aliyun.com/article/1330456 -
使用 singleflight 解决缓存击穿: https://segmentfault.com/a/1190000022347874