Go 并发控制 singleflight

6 阅读5分钟

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) (stringerror) {
 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) (stringbool) {
 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 中。