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、数据库访问)中被广泛使用。