哈喽大家,我是阿蒙。
上周做压测的时候,测试环境的 Redis 模拟了一次热点 Key 过期,结果直接打崩了后端的 MySQL。当时 10k QPS 的并发全打在同一个商品详情接口上,DB 的连接池瞬间被吃满。
解决缓存击穿(Cache Breakdown)常规套路有两个:互斥锁(Distributed Lock)或者逻辑过期。但在 Go 语言生态里,标准扩展库提供的 golang.org/x/sync/singleflight 绝对是单机并发合并的最优解,几乎零成本就能把打到 DB 的洪峰压制成 1 次请求。
今天直接聊实战、避坑点,最后老规矩贴 Benchmark 数据。
一、Singleflight 的工作原理
singleflight 的核心逻辑非常纯粹:将并发的相同请求合并为同一个执行单元。
内部维护了一个 sync.Mutex 和一个 map[string]*call:
- 第一个请求进来,Key 不存在,在 Map 里注册一个
call结构体,发起真实的下游请求(比如查 DB)。 - 后续并发进来的相同 Key 请求,发现 Map 已经有
call在执行了,直接挂在sync.WaitGroup上等待。 - 第一个请求返回结果后,唤醒所有挂起的 Goroutine,大家共享同一份结果和错误,最后清理 Map。
二、基础实战代码
先装包:
go get -u golang.org/x/sync
标准封装姿势如下:
package main
import (
"context"
"fmt"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
type ProductService struct {
sf singleflight.Group
}
func (s *ProductService) GetProductInfo(ctx context.Context, id int64) (string, error) {
cacheKey := fmt.Sprintf("product:%d", id)
// 1. 先查 Redis (伪代码)
// val, err := redis.Get(ctx, cacheKey)
// if err == nil { return val, nil }
// 2. 缓存失效,走 singleflight 合并 DB 查询
v, err, shared := s.sf.Do(cacheKey, func() (interface{}, error) {
// 模拟耗时的 DB 查询
time.Sleep(50 * time.Millisecond)
fmt.Printf(">>> 命中 DB 查询,ID: %d\n", id)
return fmt.Sprintf("product-data-%d", id), nil
})
if err != nil {
return "", err
}
if shared {
// 证明有其他 Goroutine 共享了这个结果
}
return v.(string), nil
}
func main() {
svc := &ProductService{}
var wg sync.WaitGroup
// 并发 10 个请求拿同一个 Key
for i := 0; i < 10; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
data, _ := svc.GetProductInfo(context.Background(), 1001)
fmt.Printf("Worker %d 拿到结果: %s\n", idx, data)
}(i)
}
wg.Wait()
}
运行输出里,命中 DB 查询 只会打印 1 次,剩下的 9 个并发全部共享这次结果。
三、Benchmark 说话
口说无凭,写个 Benchmark 看看在 1000 并发压测下,直连 DB(模拟 10ms 延迟)和加了 singleflight 的吞吐差异:
package main
import (
"strconv"
"testing"
"time"
"golang.org/x/sync/singleflight"
)
func mockQueryDB(id int) string {
time.Sleep(10 * time.Millisecond)
return "result-" + strconv.Itoa(id)
}
func BenchmarkDirectDB(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = mockQueryDB(1)
}
})
}
func BenchmarkSingleFlight(b *testing.B) {
var sf singleflight.Group
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_, _, _ = sf.Do("key:1", func() (interface{}, error) {
return mockQueryDB(1), nil
})
}
})
}
在我这台 M2 Mac 上压测结果如下:
goos: darwin
goarch: arm64
pkg: bench_demo
BenchmarkDirectDB-8 100 10148520 ns/op 112 B/op 3 allocs/op
BenchmarkSingleFlight-8 118478 10086 ns/op 104 B/op 2 allocs/op
PASS
ok bench_demo 3.421s
- Direct DB:每个请求硬吃 10ms 延迟,吞吐量只有 100 ops 左右。
- Singleflight:并发调用时平均耗时降到 10086 ns/op (0.01ms),吞吐直接拉升了三个数量级(11.8w ops),DB 的压力归零。
四、生产环境必踩的两个坑
singleflight 很小巧,但直接拿来用有极大概率在生产翻车,重点看下面两点:
1. Context 超时传导导致雪崩(致命坑)
看下面这段常见错误代码:
func (s *ProductService) BadGet(ctx context.Context, id int64) (string, error) {
v, err, _ := s.sf.Do(fmt.Sprintf("key:%d", id), func() (interface{}, error) {
// 错误:直接把外层的 ctx 传给了实际执行的函数
return db.QueryWithContext(ctx, id)
})
return v.(string), err
}
问题在哪里?
如果 100 个请求同时进来,第一个请求的 ctx 被传进了 db.QueryWithContext。此时如果第一个请求的客户端超时断开连接(Cancel 了 Context),那么正在执行的 DB 操作直接被中止并返回 context canceled 错误。
结果就是:剩下的 99 个请求全都会跟着收到这个错误。
正确解法:
使用 sf.DoChan 结合各自独立的 ctx 进行超时控制:
func (s *ProductService) SafeGet(ctx context.Context, id int64) (string, error) {
key := fmt.Sprintf("key:%d", id)
ch := s.sf.DoChan(key, func() (interface{}, error) {
// 内部使用独立的、不带外层超时取消的 Context
bgCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return dbQuery(bgCtx, id)
})
select {
case <-ctx.Done():
return "", ctx.Err()
case res := <-ch:
if res.Err != nil {
return "", res.Err
}
return res.Val.(string), nil
}
}
2. 及时 Forget 避免脏缓存
如果一次并发请求中,DB 挂了抛出 error,singleflight 在执行期间会把这个错误广播给所有人。
虽然请求结束后它会自动清理 Map,但如果在高并发持续写入的场景,建议在更新数据或失败后显式调用:
s.sf.Forget(cacheKey)
强制丢弃当前 Key 的调用状态,避免短时间内卡在异常逻辑中。
总结
- 缓存击穿优先考虑用
singleflight进行进程内合并,它是防护后端存储最廉价、最高效的手段。 - 生产环境推荐用
DoChan代替Do,彻底隔离各请求链路的Context超时。 - 如果是多节点集群,
singleflight只能保住单机不被打穿。集群级别如果还要进一步收敛,可以配合 Redis 分布式锁,但 90% 的场景单机 Singleflight + LocalCache 就足够抗下所有峰值了。
License: CC BY-NC 4.0
Updated 2 hours ago
Was this article helpful? Give it a like.
0 comments


