singleflight 防缓存击穿,这四个坑它不会替你挡

缓存击穿的标准答案之一是 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 的用户一起报错。这种错误很难查,因为出错的请求自己的超时明明还没到。

修法有两步:

  1. 闭包里不要继承调用方的取消 。Go 1.21 起有 context.WithoutCancel,保留 value(trace id 之类)但切断取消,然后给共享调用单独设一个超时。
  2. 调用方用 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,这篇里的代码都是为文章单独写的示意。它本身很好用,只是它合并的不只是请求,还有请求带着的所有东西,用之前想清楚你愿意共享哪些。

相关推荐
我的div丢了肿么办1 小时前
自定义类型和类型别名以及实例化结构体的5种方式
后端·go
小满zs1 小时前
Go语言第十三章(互斥锁,读写锁)
后端·go
福兮说1 小时前
errgroup 的六个坑:Wait 之后 ctx 已取消、SetLimit 嵌套死锁,以及另外四个
后端·go
福兮说1 小时前
Go 解析邮件的三个坑:GBK 标题、QP 正文,以及 NextPart 偷偷帮你做的事
后端·go
我的div丢了肿么办1 小时前
go语言中的时间time
后端·go
沙蒿同学1 小时前
一个人做完一套企业级系统,赚到了第一个 1000 块
vue.js·后端·go
福兮说1 小时前
用 Go 从零写一个只收信的 SMTP 服务:协议、坑和上线前要想清楚的事
go
newerp2 小时前
GC 触发时机与调优
后端·程序员·go
探索云原生2 小时前
一个 Deployment 就能跑 vLLM,为什么还需要 KServe?
docker·ai·云原生·kubernetes·go