singleflight (golang.org/x/sync/singleflight) 提供了重复函数调用抑制机制。
它能够抑制同一时间获取相同数据的重复请求,尤其在解决 缓存穿透 或 重复请求 的问题时。它的主要功能是让多个并发重复请求只执行一次实际的操作,其他请求会等待并复用第一个请求的结果。
在使用之前,首先要理解什么是「相同数据」和「重复请求」:
- 相同数据:指并发下当前时间段多个请求获取的数据是完全相同的,比如获取全局的配置、查询用户信息等。
- 重复请求:指处理相同数据时,在一个请求从发起到返回之前这段时间,又有其他多个请求发起,那么这些请求就是重复请求。
Multiple identical requests hitting the database
Singleflight suppresses duplicate requests
高并发场景下缓存失效时大量请求落到 DB 的场景,正是 singleflight 的用武之地。
e.g.
假设现在我们有一个缓存系统,用于存储用户的配置信息。如果某个用户的配置信息不在缓存中,就需要从数据库中获取。
多个并发请求可能同时请求同一用户的数据,如果不加以控制,就会导致多个请求同时访问数据库,带来不必要的负载。
package main
import (
"fmt"
"sync"
"time"
)
// QueryFromDB 模拟数据库获取数据
func QueryFromDB(userID string) (string, error) {
fmt.Println("Querying database for user:", userID)
time.Sleep(2 * time.Second) // 模拟耗时操作
return "UserConfig for " + userID, nil
}
// Cache 模拟缓存系统
type Cache struct {
mu sync.Mutex
store map[string]string
}
func NewCache() *Cache {
return &Cache{
store: make(map[string]string),
}
}
// Get 模拟缓存获取数据
func (c *Cache) Get(userID string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
value, ok := c.store[userID]
return value, ok
}
// Set 模拟缓存插入数据
func (c *Cache) Set(userID, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.store[userID] = value
}
func main() {
cache := NewCache()
// 模拟多个并发请求
userID := "user123"
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
// 先从缓存获取
if value, ok := cache.Get(userID); ok {
fmt.Printf("[Goroutine %d] Cache hit: %s\n", i, value)
return
}
// 模拟从数据库查询
value, err := QueryFromDB(userID)
if err != nil {
fmt.Printf("[Goroutine %d] Error: %v\n", i, err)
return
}
// 写入缓存
cache.Set(userID, value)
fmt.Printf("[Goroutine %d] Result: %s\n", i, value)
}(i)
}
wg.Wait()
}
// Output:
// Querying database for user: user123
// Querying database for user: user123
// Querying database for user: user123
// Querying database for user: user123
// Querying database for user: user123
// [Goroutine 4] Result: UserConfig for user123
// [Goroutine 3] Result: UserConfig for user123
// [Goroutine 2] Result: UserConfig for user123
// [Goroutine 1] Result: UserConfig for user123
// [Goroutine 0] Result: UserConfig for user123
可以看到多个 goroutine 重复查询了数据库。
使用 singleflight 防止重复查询:
package main
import (
"fmt"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
...
...
func main() {
cache := NewCache()
var sg singleflight.Group
// 模拟多个并发请求
userID := "user123"
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
// 先从缓存获取
if value, ok := cache.Get(userID); ok {
fmt.Printf("[Goroutine %d] Cache hit: %s\n", i, value)
return
}
// 缓存没有命中,使用 singleflight 防止重复请求
result, err, _ := sg.Do(userID, func() (interface{}, error) {
// 模拟从数据库查询
value, err := QueryFromDB(userID)
if err != nil {
return nil, err
}
// 写入缓存
cache.Set(userID, value)
return value, nil
})
if err != nil {
fmt.Printf("[Goroutine %d] Error: %v\n", i, err)
return
}
fmt.Printf("[Goroutine %d] Result: %s\n", i, result.(string))
}(i)
}
wg.Wait()
}
// Output:
// Querying database for user: user123
// [Goroutine 4] Result: UserConfig for user123
// [Goroutine 2] Result: UserConfig for user123
// [Goroutine 3] Result: UserConfig for user123
// [Goroutine 0] Result: UserConfig for user123
// [Goroutine 1] Result: UserConfig for user123
可以看到只有一个 goroutine 会执行 QueryFromDB 。其他 goroutine 会等待,并复用第一个 goroutine 的结果。
思考一下,如果函数执行一切正常,则所有请求都能顺利获得正确的数据。相反,如果函数执行遇到问题呢?
由于 singleflight 是以阻塞读的方式来控制向下游请求的并发量,在第一个下游请求没有返回之前,所有请求都将被阻塞。
使用 DoChan 结合 ctx + select 做超时控制,并且调用 Forget() 方法移除超时的 key :
package main
import (
"context"
"fmt"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
...
...
func main() {
cache := NewCache()
var sg singleflight.Group
// 模拟多个并发请求
userID := "user123"
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
// 先从缓存获取
if value, ok := cache.Get(userID); ok {
fmt.Printf("[Goroutine %d] Cache hit: %s\n", i, value)
return
}
// 缓存没有命中,使用 singleflight 防止重复请求
ch := sg.DoChan(userID, func() (interface{}, error) {
// 模拟从数据库查询
value, err := QueryFromDB(userID)
if err != nil {
return nil, err
}
// 写入缓存
cache.Set(userID, value)
return value, nil
})
ctx, _ := context.WithTimeout(context.Background(), time.Second)
select {
case <-ctx.Done():
sg.Forget(userID)
return
case res := <-ch: // Received result from channel
if err := res.Err; err != nil {
fmt.Printf("[Goroutine %d] Error: %v\n", i, err)
return
}
fmt.Printf("[Goroutine %d] Result: %s\n", i, res.Val.(string))
}
}(i)
}
wg.Wait()
}
Forget() 方法从内部 map 中删除一个正在进行的函数调用的键。使 key 失效,所以如果用这个 key 再次调用,它会像一个新的请求一样执行这个函数,而不是等待之前的执行完成。
前面说过,在一个请求从发起到返回之前这段时间,又有其他多个请求发起,这些其他请求会等待并复用第一个请求的结果。第一个请求结束时,也会删除 key ,把这次复用结果限制在这次请求开始到结束时间内。
综上所述,虽然 singleflight 很有用,但它仍然可能有一些边缘情况,例如:
- 如果第一个 goroutine 被阻塞太久,所有等待它结果的其他 goroutine 也会被卡住。在这种情况下,使用带有超时的 context 或带超时的 select 语句可能是更好的选择。
- 如果第一个请求返回 error ,则相同的 error 将传播到所有其他等待结果的 goroutine 中。