SingleFlight的作用是在处理多个goroutine同时调用同一个函数的时候,只让一个goroutine去实际调用这个函数,等到这个goroutine返回结果的时候,再把结果返回给其他几个同时调用了相同函数的goroutine,这样可以减少并发调用的数量。在实际应用中也是,它能够在一个服务中减少对下游的并发重复请求。还有一个比较常见的使用场景是用来防止缓存击穿。
Go扩展库里用singleflight.Group结构体类型提供了SingleFlight并发原语的功能。
singleflight.Group类型提供了三个方法:
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool)
func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result
func (g *Group) Forget(key string)
Do方法,接受一个字符串Key和一个待调用的函数,会返回调用函数的结果和错误。使用Do方法的时候,它会根据提供的Key判断是否去真正调用fn函数。同一个 key,在同一时间只有第一次调用Do方法时才会去执行fn函数,其他并发的请求会等待调用的执行结果。
DoChan方法:类似Do方法,只不过是一个异步调用。它会返回一个通道,等fn函数执行完,产生了结果以后,就能从这个 chan 中接收这个结果。
Forget方法:在SingleFlight中删除一个Key。这样一来,之后这个Key的Do方法调用会执行fn函数,而不是等待前一个未完成的fn 函数的结果。
使用缓存时,一个常见的用法是查询一个数据先去查询缓存,如果没有就去数据库里查到数据并缓存到Redis里。缓存击穿问题是指,高并发的系统中,大量的请求同时查询一个缓存Key 时,如果这个 Key 正好过期失效,就会导致大量的请求都打到数据库上,这就是缓存击穿。用 SingleFlight 来解决缓存击穿问题再合适不过,这个时候只要这些对同一个 Key 的并发请求的其中一个到数据库中查询就可以了,这些并发的请求可以共享同一个结果。
下面是一个模拟用SingleFlight并发原语合并查询Redis缓存的程序,观察一下运行的返回结果就会发现最终只执行了一次Redis查询。
package main
import (
"fmt"
"golang.org/x/sync/singleflight"
"sync"
"time"
)
// 模拟一个Redis客户端
type client struct {
// ... 其他的配置省略
requestGroup singleflight.Group
}
// 普通查询
func (c *client) Get(key string) (interface{}, error) {
fmt.Println("Querying Database")
time.Sleep(time.Second)
v := "Content of key" + key
return v, nil
}
// SingleFlight查询
func (c *client) SingleFlightGet(key string) (interface{}, error) {
v, err, _ := c.requestGroup.Do(key, func() (interface{}, error) {
return c.Get(key)
})
if err != nil {
return nil, err
}
return v, err
}
func main() {
redisClient := new(client)
wg := sync.WaitGroup{}
wg.Add(10)
for i := 0; i < 10; i++ {
go func() {
defer wg.Done()
v, _ := redisClient.SingleFlightGet("Cyberpunk2077!!!")
fmt.Println(v)
}()
}
wg.Wait()
}