缓存击穿是后端服务里最常见的一类雪崩源头:某个热点 key 刚过期,或者某个冷门 ID 突然被大量请求命中,缓存层没有数据,于是几百上千个 goroutine 同时打到 MySQL、Redis 从库或者某个内部 RPC 接口上。数据库连接池瞬间被占满,接口 P99 从 20ms 飙到 3s,上游开始超时重试,流量进一步放大,最终把下游彻底压垮。本文要解决的正是这个问题:不引入额外的中间件,只靠 Go 官方扩展库 golang.org/x/sync/singleflight,把同一时刻对同一份数据的并发查询合并成一次真实的回源请求,其余调用者原地等待并共享结果,从而在进程级别实现削峰。
文章会给出可直接编译运行的最小示例,然后逐层展开:Do 与 DoChan 的选型差异、共享结果带来的对象别名坑、panic 与错误传播的正确姿势、如何叠加超时与 context 取消、带版本号的 key 设计,以及高并发场景下必须做的压测验证。所有代码都可以直接落到生产项目里,不写伪代码。
为什么不能靠加锁或缓存预热解决
先明确 singleflight 的定位。它适合“同一时刻、同一份数据、大量重复请求”的场景,本质是一次进程内的请求合并(request coalescing)。它并不能替代缓存,也不是限流器。
对比几种常见方案:
| 方案 | 能解决的问题 | 局限 |
|---|---|---|
| 本地缓存 / Redis 缓存 | 绝大多数读请求不打到下游 | 过期瞬间仍会击穿;冷启动全空 |
| 分布式锁 + 双重检查 | 跨实例只有一台回源 | 需要 Redis、有锁续期与死锁风险、非回源实例要轮询等待 |
| 令牌桶限流 | 保护下游不被打挂 | 被限流的请求直接失败,用户体验差 |
| singleflight | 同 key 并发请求只回源一次,其他请求正常拿到数据 | 仅限单进程内;对“大量不同 key”无效 |
可以看出 singleflight 的优势是:调用方代码几乎不用改,失败率不上升,延迟只增加一次回源时间,而且没有外部依赖。它的天然短板是只在单进程生效——10 个 Pod 最坏情况会回源 10 次,而分布式锁是 1 次。但 10 次远好于 10000 次,对绝大多数业务来说这一步的收益已经足够。如果确实需要跨实例合并,可以在 singleflight 之上再叠加一个 Redis 锁做二次收敛,但通常没必要。
最小可用示例:包一层带缓存的查询器
先安装依赖。注意 singleflight 不在标准库里,必须显式拉取,且版本要与 Go 版本匹配:
go get golang.org/x/sync@v0.7.0
下面的例子模拟一个慢速下游查询:用 atomic 计数器统计真实回源次数,用 time.Sleep 模拟 100ms 的下游耗时。把并发数设成 100,你会看到回源次数稳定在 1~3 次(受 goroutine 调度影响,首次请求发出前的极短窗口内可能有第二个请求进入,这不影响结论)。
package main
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"golang.org/x/sync/singleflight"
)
// UserRepo 模拟一个下游服务
type UserRepo struct {
hits atomic.Int64 // 真实回源次数
jitter time.Duration
}
func (r *UserRepo) Query(ctx context.Context, id int64) (string, error) {
r.hits.Add(1)
select {
case <-time.After(r.jitter):
case <-ctx.Done():
return "", ctx.Err()
}
return fmt.Sprintf("user-%d", id), nil
}
// UserLoader 在 Repo 之上做 singleflight 合并
type UserLoader struct {
repo *UserRepo
group singleflight.Group
}
func (l *UserLoader) Get(ctx context.Context, id int64) (string, error) {
key := fmt.Sprintf("user:%d", id)
v, err, _ := l.group.Do(key, func() (any, error) {
return l.repo.Query(ctx, id)
})
if err != nil {
return "", err
}
return v.(string), nil
}
func main() {
repo := &UserRepo{jitter: 100 * time.Millisecond}
loader := &UserLoader{repo: repo}
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
got, err := loader.Get(context.Background(), 42)
if err != nil {
fmt.Println("err:", err)
return
}
if got != "user-42" {
fmt.Println("unexpected:", got)
}
}()
}
wg.Wait()
fmt.Printf("real upstream calls = %d, elapsed = %v\n", repo.hits.Load(), time.Since(start))
}
典型输出:
real upstream calls = 1, elapsed = 101.238ms
100 个并发请求,总耗时 100ms 出头,下游只被打了一次。这就是削峰的核心价值。注意 Do 的第三个返回值 shared bool:为 true 表示这个结果是从别的 goroutine 那里共享来的,可以用它打点监控回源合并率。
Do 与 DoChan 的选型差异
singleflight.Group 有三个入口,语义差别很大,选错会直接埋雷:
| 方法 | 返回 | 是否阻塞 | 适用场景 |
|---|---|---|---|
Do(key, fn) |
(v any, err error, shared bool) | 阻塞直到 fn 返回 | 默认选择 |
DoChan(key, fn) |
<-chan Result | 立即返回 channel | 调用方需要用 select 叠加超时或取消 |
Forget(key) |
无 | 不阻塞 | 主动让某 key 的结果不再被后续请求等待 |
一个关键事实:Do 不感知调用方的 context。fn 里传进去的 ctx 通常是第一个请求者的 ctx。如果第一个请求者的 ctx 在 50ms 后被上游取消,那么所有等待者都会拿到这个错误——包括那些自己 ctx 还健在的请求。这是 singleflight 使用中最常见的事故来源。
更稳妥的写法是:fn 内部使用一个独立于调用方的 context,或者使用 DoChan 让每个调用者自己控制等待时间:
func (l *UserLoader) GetWithTimeout(ctx context.Context, id int64, wait time.Duration) (string, error) {
key := fmt.Sprintf("user:%d", id)
ch := l.group.DoChan(key, func() (any, error) {
// 独立 context,避免首个调用者取消导致整组失败
qctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return l.repo.Query(qctx, id)
})
select {
case res := <-ch:
if res.Err != nil {
return "", res.Err
}
return res.Val.(string), nil
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(wait):
return "", fmt.Errorf("loader: wait timeout for key %s", key)
}
}
注意 DoChan 返回的 channel 带容量 1,即使调用方提前退出不读了也不会泄漏 goroutine 或死锁,这一点可以放心。但被放弃的那次查询仍会跑完并把结果广播给其他还在等的人,这是预期行为。
共享结果的对象别名陷阱
Do 返回的 any 会被所有等待者拿到,且是同一个指针。如果调用方把它当私有数据修改,就会互相污染。经典事故:
// 错误示范
func (l *OrderLoader) Get(ctx context.Context, id int64) (*Order, error) {
key := "order:" + strconv.FormatInt(id, 10)
v, err, _ := l.group.Do(key, func() (any, error) {
return l.repo.Find(ctx, id)
})
if err != nil {
return nil, err
}
o := v.(*Order)
o.Status = "viewed" // 危险:所有共享者都看到了这个改动
return o, nil
}
正确做法有三种,从轻到重:
- 约定只读:函数返回后一律不改,需要改就先深拷贝。适合结构体小、字段少的场景。
- 在 fn 里返回不可变值:比如返回
[]byte的副本、返回序列化后的 JSON 字符串,由调用方各自反序列化。 - 用
sync.Pool或手写Clone():结构体大且字段多时更现实。
另外,nil 结果要小心。如果 fn 返回 (nil, nil),类型断言 v.(*Order) 会 panic。要么在 fn 里把 nil 转换成哨兵值或明确的 error(比如 sql.ErrNoRows),要么断言时用双返回值形式:
o, ok := v.(*Order)
if !ok || o == nil {
return nil, ErrNotFound
}
panic 与错误传播:Do 会替你 recover 吗
答案是会,但代价是错误信息被包装。看源码语义:fn 内部 panic 时,singleflight 会 recover 并把 panic 值包装成 fmt.Errorf("singleflight: panic: %v", r) 的形式作为 error 返回给所有等待者。这意味着:
- 你的服务不会因为单个回源 panic 而崩溃,这是好事;
- 但 panic 的 stack trace 丢失了,线上排查时只能看到一行 panic 值,定位困难。
推荐在 fn 内部自己兜一层,保留现场:
func safeQuery[T any](fn func() (T, error)) (v T, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic in singleflight fn: %v\n%s", r, debug.Stack())
var zero T
v = zero
}
}()
return fn()
}
然后 group.Do(key, func() (any, error) { return safeQuery(...) })。这样日志里能拿到完整堆栈。
关于错误传播还有一个必须知道的细节:fn 返回的 error 会被所有等待者原样收到。如果你的下游返回的是 gRPC 的 status error 或者自带的业务错误,所有调用者拿到的都是同一个 error 对象。对 error 做类型断言、附加字段(比如 errors.Join、包装 traceID)时要注意不能就地修改。更麻烦的是“首次请求因为调用方取消而失败,其余请求无辜失败”的情况,所以前面强调 fn 内要用独立 context。
一个真实踩坑记录:某服务在 fn 里直接用了
c.Request.Context(),前端用户点一下又快速切走,浏览器断开连接,HTTP server 取消 ctx,回源失败,此时另外 300 个正常用户全部收到context canceled报错。日志里只有一行context canceled,排查了整整半天,最后发现是 ctx 被“共享”了。
key 设计、版本号与失效控制
singleflight 的合并粒度完全由 key 决定。key 设计错了,要么合并不充分,要么把不该合并的请求合并到一起。几条实践规则:
- key 必须包含业务维度与数据版本。例如
user:1001:v3,其中 v3 来自缓存的版本号或更新时间戳。用户更新资料后版本号变化,旧 key 的进行中查询不会被新请求命中,避免“拿到更新前的数据”。 - 不要用裸的 SQL 或完整 URL 当 key。长度无上限(一个 key 几百字节完全没问题,但超过 1KB 就说明设计有问题),而且里含用户 ID 之外的排序参数时会导致本该合并的请求合并不了。
- 不同查询方法用不同前缀。`user:1001` 和 `order:1001` 撞 key 会造成类型断言 panic,前缀是最廉价的隔离手段。
- 需要强制刷新时用
Forget。管理员触发“清缓存”后,调用group.Forget(key),后续请求会重新回源,而不是继续等一个已经发出但结果可能已过时的查询。
type VersionedLoader struct {
repo *UserRepo
group singleflight.Group
verMu sync.RWMutex
vers map[int64]int64
}
func (l *VersionedLoader) key(id int64) string {
l.verMu.RLock()
v := l.vers[id]
l.verMu.RUnlock()
return fmt.Sprintf("user:%d:v%d", id, v)
}
func (l *VersionedLoader) Invalidate(id int64) {
l.verMu.Lock()
l.vers[id]++
l.verMu.Unlock()
l.group.Forget(l.key(id))
}
这里 Invalidate 先递增版本再 Forget 新 key,顺序不能反:反过来的话 Forget 的是旧 key,新请求用的新 key 又会产生一次不必要的回源。
与缓存、限流的组合顺序
singleflight 通常夹在本地缓存和下游之间。推荐的分层顺序是:
请求 → 本地缓存(LRU, 毫秒级) → singleflight 合并 → 分布式缓存(Redis) → 下游 DB/RPC
写成一个完整的 Loader:
func (l *UserLoader) Get(ctx context.Context, id int64) (string, error) {
key := fmt.Sprintf("user:%d", id)
if v, ok := l.local.Get(key); ok { // 例如 hashicorp/golang-lru/v2
return v.(string), nil
}
v, err, shared := l.group.Do(key, func() (any, error) {
// 双重检查:等待期间可能已有别的 goroutine 写入了本地缓存
if v, ok := l.local.Get(key); ok {
return v, nil
}
if v, err := l.redis.Get(ctx, key).Result(); err == nil {
l.local.Add(key, v)
return v, nil
}
qctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
raw, err := l.repo.Query(qctx, id)
if err != nil {
return nil, err
}
l.redis.Set(ctx, key, raw, 10*time.Minute)
l.local.Add(key, raw)
return raw, nil
})
if err != nil {
return "", err
}
stats.shareRate(shared) // 打点
return v.(string), nil
}
几个工程细节:
- 本地缓存用
hashicorp/golang-lru/v2时注意它本身是并发安全的,不需要额外加锁;容量按lru.New[string, any](10000)设置即可,配合 TTL 包装或定时清理。 - 本地缓存的 TTL 要明显短于 Redis,例如本地 3s、Redis 10min,避免多实例之间数据长期不一致。
- singleflight 里的 Redis 读取失败要区分
redis.Nil(key 不存在,应回源)和其他错误(连接失败,应快速返回错误而不是回源打 DB)。写成if err == nil { ... }就自动满足了这一点。 - 监控指标:回源次数、合并率(
shared为 true 的比例)、singleflight 内部等待时长(可在 fn 开头和结束各打一次时间戳)。合并率长期低于 5% 说明 key 太散,这个组件在帮你做无用功。
高并发下的验证与常见报错
不要相信“应该没问题”。用 go test -race 跑并发测试是底线:
func TestLoaderMerge(t *testing.T) {
repo := &UserRepo{jitter: 50 * time.Millisecond}
l := &UserLoader{repo: repo}
var wg sync.WaitGroup
for i := 0; i < 500; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := l.Get(context.Background(), 7); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
if n := repo.hits.Load(); n > 3 {
t.Fatalf("expected <=3 upstream calls, got %d", n)
}
}
执行 go test -race -run TestLoaderMerge -v。以下是实际会遇到的报错与处理方式:
| 报错 / 现象 | 原因 | 解决 | |
|---|---|---|---|
interface conversion: interface {} is nil, not string |
fn 返回了 (nil, nil),断言失败 | fn 里对 nil 结果返回哨兵 error;或断言用双返回值 | |
并发测试报 WARNING: DATA RACE,指向共享结果字段 |
多个调用者修改同一个指针对象 | 返回副本,或约定只读,或用深拷贝 | |
日志大量 context canceled,但下游本身正常 |
fn 用了首个调用者的 ctx | fn 内使用 context.WithTimeout(context.Background(), ...) |
|
| 回源次数始终等于并发数,合并不生效 | key 里带了随机数、时间戳或请求 ID | 检查 key 构造,固定为业务维度 | |
singleflight: panic: runtime error: invalid memory address or nil pointer dereference |
fn 内部空指针,被 singleflight recover | fn 内自己 recover 打印 debug.Stack() 定位 |
|
| 内存持续上涨,heap profile 显示大量 map 条目 | key 无限增长(含 ID 但无上限清理),Group 内部 map 直到结果返回才删除 | 正常情况下查完即删;若 fn 长时间不返回(如无超时的 RPC),map 会长期持有 key | 务必给 fn 内部查询加超时 |
最后一条值得展开:Group 内部用 sync.Mutex 保护一个 map[string]*call,call 在 fn 返回后才被删除。所以fn 绝对不能无限期阻塞,否则 key 会长期驻留,而且所有同 key 请求都会被挂在同一个 channel 上,形成雪崩式堆积。给 fn 内所有外部调用加上明确的 timeout,是使用 singleflight 的硬性纪律。
小结
singleflight 是 Go 生态里用最小成本解决缓存击穿与并发重复查询的工具:关键路径只有一行 group.Do(key, fn),就能把 N 次回源压缩成 1 次。但要把它用好,必须同时处理好四件事——用独立 context 避免取消传染、返回共享结果时防止对象别名污染、在 fn 内部自己 recover 保留堆栈、给 fn 内的所有下游调用设置明确超时。key 的设计要包含业务维度与数据版本,并用 Forget 支持主动失效。最后记得打点监控合并率,否则你无法知道它是否真的在发挥作用。
FAQ
singleflight 和 Redis 分布式锁该用哪个?
优先用 singleflight:无外部依赖、没有锁续期和死锁问题、调用方不失败。只有当单次回源的代价极高(比如几秒的复杂聚合查询)且实例数量很多时,才考虑叠加分布式锁做跨实例收敛。两者可以共存:singleflight 在外层,Redis 锁在 fn 内部。
能不能用 singleflight 做写操作合并?
不建议。写操作通常有幂等性和顺序要求,合并后语义会变(比如两次不同的更新被合并成一次)。singleflight 的定位是只读查询合并。如果确实需要合并相同的写入,请改用消息队列去重或幂等令牌。
DoChan 返回的 channel 我提前退出了,会不会泄漏?
不会。channel 容量为 1,结果写入不会阻塞,且底层 goroutine 会正常执行完 fn。唯一副作用是那次回源的结果没人消费,属于可接受的浪费。
多个不同的 key 会不会互相影响?
不会。合并以 key 为单位,不同 key 走不同的 call。但要注意 Group 内部有一把全局 mutex,只在 map 增删和取 call 的瞬间持有,真正执行 fn 时已释放,所以不会成为串行化瓶颈。单机 QPS 百万级以下基本不用考虑这把锁的开销。
要不要给每个业务模块建一个独立的 Group?
推荐。不同类型的查询(用户、订单、商品)用独立的 singleflight.Group 实例,既能让 key 更短、减少冲突概率,也让监控指标天然隔离。代价只是多几个结构体字段,可以忽略。
Go 1.22 之后有什么变化?
singleflight 的 API 一直很稳定,x/sync v0.7.0 起主要变化是内部优化和泛型友好度提升,Do / DoChan / Forget 三个接口签名未变,老代码可以无痛升级。