缓存击穿的标准答案之一是 singleflight:同一个 key 同时来一千个请求,只放一个去查库,其余的等着拿结果。golang.org/x/sync/singleflight 一共不到两百行,接进去也就三行代码。
但我在项目里用它之后,碰到的问题几乎都不是"没合并住",而是"合并得太彻底":一个请求的取消、一个请求拿到的指针、一次失败,全都被合并给了所有人。
下面用一个能直接 go run 的 demo 把这几件事复现一遍。环境:Go 1.25.0,golang.org/x/sync v0.17.0,Apple M2。
完整 demo
go
package main
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
"golang.org/x/sync/singleflight"
)
// 模拟一个慢查询:数据库或下游接口
var dbCalls atomic.Int64
func queryDB(ctx context.Context, key string) (*User, error) {
dbCalls.Add(1)
select {
case <-time.After(50 * time.Millisecond):
return &User{ID: key, Tags: []string{"a"}}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
type User struct {
ID string
Tags []string
}
var g singleflight.Group
// 1. 基本效果:1000 个并发请求打到同一个冷 key
func demoBasic() {
for _, useSF := range []bool{false, true} {
dbCalls.Store(0)
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if useSF {
g.Do("user:42", func() (any, error) { return queryDB(context.Background(), "42") })
} else {
queryDB(context.Background(), "42")
}
}()
}
wg.Wait()
fmt.Printf("singleflight=%v db 调用 %d 次 耗时 %v\n", useSF, dbCalls.Load(), time.Since(start).Round(time.Millisecond))
}
}
// 2. 坑:第一个调用者的 ctx 被取消,所有等待者一起失败
func demoCtxLeak() {
var wg sync.WaitGroup
results := make([]string, 3)
for i := 0; i < 3; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
timeout := time.Second
if i == 0 {
timeout = 10 * time.Millisecond // 第一个请求很快就超时了
} else {
time.Sleep(time.Millisecond) // 保证 0 号先进 Do
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
_, err, shared := g.Do("user:7", func() (any, error) { return queryDB(ctx, "7") })
results[i] = fmt.Sprintf("请求%d(超时 %v): err=%v shared=%v", i, timeout, err, shared)
}(i)
}
wg.Wait()
for _, r := range results {
fmt.Println(r)
}
}
// 2 的修法:函数里用脱离调用方的 ctx,自己设超时
func demoCtxFixed() {
var wg sync.WaitGroup
results := make([]string, 3)
for i := 0; i < 3; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
timeout := time.Second
if i == 0 {
timeout = 10 * time.Millisecond
} else {
time.Sleep(time.Millisecond)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
ch := g.DoChan("user:8", func() (any, error) {
// 不继承调用方的取消,但保留 value;超时由共享调用自己决定
c, cl := context.WithTimeout(context.WithoutCancel(ctx), 200*time.Millisecond)
defer cl()
return queryDB(c, "8")
})
select {
case r := <-ch:
results[i] = fmt.Sprintf("请求%d: err=%v shared=%v", i, r.Err, r.Shared)
case <-ctx.Done():
results[i] = fmt.Sprintf("请求%d: 自己先超时了 %v(共享调用还在继续)", i, ctx.Err())
}
}(i)
}
wg.Wait()
for _, r := range results {
fmt.Println(r)
}
}
// 3. 坑:返回的是同一个指针,谁改了大家都看到
func demoSharedPtr() {
var wg sync.WaitGroup
users := make([]*User, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
v, _, _ := g.Do("user:9", func() (any, error) { return queryDB(context.Background(), "9") })
users[i] = v.(*User)
}(i)
}
wg.Wait()
users[0].Tags = append(users[0].Tags[:0], "被 0 号改掉了")
fmt.Printf("同一个指针: %v, 1 号看到的 Tags: %v\n", users[0] == users[1], users[1].Tags)
}
// 4. 坑:错误也会被合并,而且整段时间内新来的请求都拿到同一个错误
func demoSharedErr() {
var calls atomic.Int64
var wg sync.WaitGroup
var errCount atomic.Int64
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err, _ := g.Do("user:10", func() (any, error) {
calls.Add(1)
time.Sleep(30 * time.Millisecond)
return nil, errors.New("下游 503")
})
if err != nil {
errCount.Add(1)
}
}()
}
wg.Wait()
fmt.Printf("下游只被调用 %d 次,%d 个请求全部拿到同一个错误\n", calls.Load(), errCount.Load())
}
func main() {
fmt.Println("== 1. 基本效果")
demoBasic()
fmt.Println("== 2. ctx 取消传染")
demoCtxLeak()
fmt.Println("== 2'. 用 WithoutCancel + DoChan 修")
demoCtxFixed()
fmt.Println("== 3. 共享指针")
demoSharedPtr()
fmt.Println("== 4. 共享错误")
demoSharedErr()
}
运行结果:
ini
== 1. 基本效果
singleflight=false db 调用 1000 次 耗时 55ms
singleflight=true db 调用 1 次 耗时 51ms
== 2. ctx 取消传染
请求0(超时 10ms): err=context deadline exceeded shared=true
请求1(超时 1s): err=context deadline exceeded shared=true
请求2(超时 1s): err=context deadline exceeded shared=true
== 2'. 用 WithoutCancel + DoChan 修
请求0: 自己先超时了 context deadline exceeded(共享调用还在继续)
请求1: err=<nil> shared=true
请求2: err=<nil> shared=true
== 3. 共享指针
同一个指针: true, 1 号看到的 Tags: [被 0 号改掉了]
== 4. 共享错误
下游只被调用 1 次,100 个请求全部拿到同一个错误
第一段是它该做的事:一千次查库变成一次。后面三段是它顺手做了、但你可能不想要的事。
坑一:第一个人的 ctx,决定了所有人的命运
Do(key, fn) 里的 fn 只会被第一个进来的调用者 执行。如果你像大多数代码那样,在闭包里直接用当前请求的 ctx:
go
g.Do(key, func() (any, error) { return queryDB(ctx, key) })
那这个 ctx 就是第一个请求的。它的超时是 10ms,后面两个请求的超时是 1s,但它们等到的是第一个请求超时后留下的 context deadline exceeded,shared=true。
线上的样子是:某个客户端网络差、很快断开,它恰好是第一个触发回源的,于是同一时刻所有请求这个 key 的用户一起报错。这种错误很难查,因为出错的请求自己的超时明明还没到。
修法有两步:
- 闭包里不要继承调用方的取消 。Go 1.21 起有
context.WithoutCancel,保留 value(trace id 之类)但切断取消,然后给共享调用单独设一个超时。 - 调用方用
DoChan代替Do,自己select自己的ctx.Done()。谁等不及谁先走,不影响共享调用继续跑完。
demo 里 2' 那段就是这么写的:0 号自己超时走了,1、2 号正常拿到结果。
坑二:返回的是同一个对象
Do 把 fn 的返回值原封不动地发给每一个等待者。如果返回的是指针、切片或者 map,所有人拿到的是同一块内存。
demo 3 里 0 号改了 Tags,1 号看到的也变了。在真实的 handler 里,这通常表现为"偶尔某个用户看到了别人的字段"或者 -race 报数据竞争,而且只在高并发、刚好被合并的时候出现,本地几乎复现不了。
两种处理办法:
- 约定从 singleflight 拿到的对象只读,要改就先深拷贝;
- 或者在
fn里返回序列化后的字节,每个调用方自己反序列化一份。多一次反序列化的开销,换来不用担心谁改了谁。
我更倾向于第二种,因为"约定只读"在团队里很难守住。
坑三:错误也是共享的
demo 4 里下游返回 503,100 个请求全部拿到同一个错误,下游只被调用了一次。
这件事本身不一定是坏事,它恰好起到了"下游故障时别再去砸它"的作用。问题在于合并窗口只覆盖正在进行的那一次调用 :fn 一返回,key 就被删掉,下一批请求会再发一次。如果下游恢复需要十秒,而每次调用失败只要 30ms,你等于每 30ms 放一个请求过去。
所以 singleflight 不能代替熔断和负缓存。常见组合是:失败结果在本地缓存几秒(负缓存),或者在外层包一个熔断器。
另外还有个反方向的问题:如果某次调用卡住了(没超时的慢查询),在它返回之前所有同 key 请求都会挂在上面。Group.Forget(key) 可以让后来的请求不再等这一次,重新发起一次新的调用,适合配合超时使用。
坑四:它只管一个进程
singleflight 是进程内的。你部署了 20 个实例,冷 key 失效的那一刻,最多还是会有 20 个请求打到数据库。大部分场景下这已经够好了(从一千降到二十),但如果回源本身特别重(比如要聚合几张大表),还需要在分布式层面再合并一次,常见做法是用 Redis 的 SET NX 做一个短期的"正在重建"标记,其他实例等待或者先返回旧值。
这一层就不是 singleflight 能管的了,而且引入了新的问题:拿到锁的那个实例挂了怎么办、锁的过期时间怎么定。
一个我现在的默认写法
把上面几点合在一起,业务代码里我一般封装成这样:
go
func (c *Cache) Get(ctx context.Context, key string) ([]byte, error) {
if b, ok := c.local.Get(key); ok {
return b, nil
}
ch := c.sf.DoChan(key, func() (any, error) {
// 切断调用方的取消,共享调用有自己的超时
sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
b, err := c.load(sctx, key) // 返回 []byte,调用方各自反序列化
if err == nil {
c.local.Set(key, b)
}
return b, err
})
select {
case r := <-ch:
if r.Err != nil {
return nil, r.Err
}
return r.Val.([]byte), nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
这段只是示意,local、load 按自己的缓存实现替换,超时值也要按下游的实际耗时调。
说说局限
- 上面的封装没有处理负缓存,下游持续失败时,每次共享调用结束都会再放一个请求过去。
WithoutCancel之后,即使所有调用方都走了,共享调用也会跑到自己的超时为止,这是有意的取舍(结果还能写进缓存),但会多占一点下游资源。- 返回
[]byte的做法每个调用方都要反序列化一次,对象很大时这部分 CPU 不能忽略。 - demo 里的计时是单机
time.Sleep模拟,不代表真实数据库的表现,只用来说明行为。
平时写 forxi.cn 的后端时我也会用到 singleflight,这篇里的代码都是为文章单独写的示意。它本身很好用,只是它合并的不只是请求,还有请求带着的所有东西,用之前想清楚你愿意共享哪些。