引子
在很多工程团队里,一提到“流量消峰”或者“保护下游服务”,大家脑子里蹦出来的永远是那几样东西:消息队列、熔断器、限流器、降级策略。这些技术当然有用,我自己项目里也用得不少,但奇怪的是,大家却经常忽略一个简单到离谱、却能在关键时刻救系统一命的东西 —— SingleFlight。
很多人以为有了 cache,系统就“高枕无忧”了。但 cache 只能挡住已有的数据请求,对两种情况完全无能为力:
- 缓存穿透:大量并发请求同一份缓存中不存在的数据,全部直接打在后端。
- 瞬时高峰:某个热点键突然失效,几万并发同时抢着去加载同一份数据。
这些场景在生产里比大家想象得更常见,尤其是分布式环境中多个节点的缓存同时过期、某个热点 key 几分钟一次的抖动、突发活动、机器重启导致缓存全失效…… 你以为有 cache 就万事大吉,实际上它可能是压垮后端的第一张多米诺骨牌。
而 SingleFlight 的意义就在这里 —— 它不解决“缓存有数据时的性能”,它解决的是“缓存没数据时的灾难”。
它像是在缓存前面加了一道“智能闸机”,让同一个 key 的并发请求只需要 let 一个 代表去后端干活,其他人都在旁边等结果。既不给数据库添堵,也不给下游打爆。
更重要的是:它简单到让人怀疑为什么很多团队根本没人提过它。
所以我写这篇文章,就是想把这个常被忽略的“小技术”,重新放回到流量治理的工具箱里。我们总说系统要稳定,结果把 MQ、熔断、降级吹得天花乱坠,反而忘记了一个可以“一键消除缓存穿透 + 防止瞬时冲击”的轻量工具 —— SingleFlight。
Golang中的singleflight
singleflight 包的全路径为:
golang.org/x/sync/singleflight
SingleFlight 的核心用途是:在同一时间抑制对相同数据的重复请求。
当多个并发请求尝试获取同一份数据时,SingleFlight 会保证:
- 只有一个请求真正执行业务逻辑;
- 其他请求会被阻塞;
- 当前面那个请求执行完毕后,其返回值会被 共享(shared) 给所有阻塞的请求。
为了正确使用 SingleFlight,我们首先需要理解两个关键概念:
- 什么是“相同数据”?
- 什么是“重复请求”?
“相同数据”指的是:在并发场景下,多个请求在同一时间段内试图获取的 数据内容完全一致。
典型场景:
- 获取全球统一配置
- 查某城市的天气
- 加载权限信息
- 获取某个热点 Key 的数据
这些数据在一次查询中通常不随请求变化,因此属于“相同数据”。
“重复请求”指的是:当一个请求已经开始处理某份相同数据,但还未返回时,其间又有其他请求发起了同样的数据读取操作。
示例:
- 请求 A 在 10:00:00 开始查询配置
- 在 A 完成之前,10:00:01 又来两个请求 B 和 C,查询同样的数据
此时 B、C 就是 A 的重复请求,属于可以被 SingleFlight 抑制的对象。
SingleFlight 的整体设计非常轻量,公开的 API 主要包括:
1. Group 对象
Group 表示处理“一组相同数据”的工作集合。
对于同一 key 的并发请求,Group 会自动识别“重复请求”并进行抑制。
2. Result 对象
表示实际执行业务逻辑的结果封装,包括:
Val:返回值Err:错误Shared:是否共享(即该结果是否是从别的请求那里“蹭”来的)
3. Do 方法
这是核心方法,用于执行具有请求抑制功能的业务逻辑。
4. DoChan 方法
与 Do 类似,只是以 channel 返回:
ch := g.DoChan(key, func() (interface{}, error) {
// 业务逻辑
})
你可以从 <-chan Result 中异步接收执行结果。
Do 方法
Do 方法用于执行具有“请求抑制”能力的操作,其定义如下:
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
// ...
}
Do 必须通过 Group 实例调用,Group 表示一组处理相同数据的请求集合。同一个 Group 下、同一个 key 的并发请求会被自动识别为重复请求。
参数说明:
-
key
指示“相同数据”的标识。
哪些请求被判定为重复请求,完全取决于这个 key 的设计。
同样的 key 表示相同的数据来源。 -
fn
真正执行业务逻辑的函数,只会被执行一次。
它需要返回两个值:interface{}:返回结果 error:错误信息
返回值:
-
v
fn的返回值,即业务结果。 -
err
fn的错误,如果业务逻辑失败则直接传播。 -
shared
布尔值,表示这个结果是否被共享:true:当前请求并没有执行fn,而是复用了其他 goroutine 已经算好的结果。false:当前请求是那个真正执行fn的请求。
shared 字段在某些监控或调试场景很有帮助,可以方便判断是否发生了请求抑制。
DoChan 方法
DoChan 与 Do 的逻辑类似,只是将返回结果封装成一个 Result 结构体,并通过 channel 返回:
func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result {
// ...
}
这种方式适用于“异步等待”场景。
你可以在调用后立即继续执行其他操作,再通过 <-ch 的方式获取 Result,实现并发解耦:
ch := g.DoChan("config", loadConfig)
// 其他逻辑...
res := <-ch
log.Println(res.Val, res.Err, res.Shared)
使用示例
假设我们需要从数据库查询同一个信息,这里以同一个 id 的文章为例,文章内容本身并不会频繁更新。当有多个并发请求同时查询时:
- 没有请求抑制 → 数据库会重复执行多次相同 SQL,浪费性能
- 使用 SingleFlight → 只执行一次 SQL,并将结果共享给所有等待的请求
下面通过一个完整的示例展示两种情况的区别。
模拟一个查询文章的方法
var count int32
func getArticle(id int) (article string, err error) {
atomic.AddInt32(&count, 1)
time.Sleep(time.Duration(count) * time.Millisecond)
return fmt.Sprintf("article: %d", id), nil
}
说明:
- 使用全局变量
count统计实际访问数据库的次数 Sleep模拟随着并发变多,查询耗时逐渐增加的情况
并发查询(未使用 SingleFlight)
func doDemo() {
time.AfterFunc(1*time.Second, func() {
atomic.AddInt32(&count, -count)
})
var (
wg sync.WaitGroup
now = time.Now()
n = 10
)
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
res, err := getArticle(1)
if err != nil {
panic(err)
}
fmt.Printf("article: %s\n", res)
wg.Done()
}()
}
wg.Wait()
fmt.Printf("同时发起 %d 次请求,真正查询次数: %d, 耗时: %s\n",
n, count, time.Since(now))
}
运行结果示例:
同时发起 10 次请求,真正查询次数: 10, 耗时: 10.392278s
可以看到:
- 10 个请求 → 真实执行了 10 次数据库查询
- 总耗时也接近 10 倍
使用 SingleFlight 进行优化:
func singleflightGetArticle(sg *singleflight.Group, id int) (string, error, bool) {
v, err, shared := sg.Do(fmt.Sprintf("%d", id), func() (interface{}, error) {
fmt.Println("getArticle")
return getArticle(id)
})
return v.(string), err, shared
}
说明:
- 通过
Group的Do方法实现请求抑制 - key 使用文章
id shared = true表示结果来自共享,而不是本协程执行
使用 SingleFlight 并发查询:
func doDemo() {
time.AfterFunc(1*time.Second, func() {
atomic.AddInt32(&count, -count)
})
var (
wg sync.WaitGroup
now = time.Now()
n = 10
sg = &singleflight.Group{}
)
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
res, err, shared := singleflightGetArticle(sg, 1)
if err != nil {
panic(err)
}
fmt.Printf("article: %s, shared: %v\n", res, shared)
wg.Done()
}()
}
wg.Wait()
fmt.Printf("同时发起 %d 次请求,真正查询次数: %d, 耗时: %s\n",
n, count, time.Since(now))
}
运行结果示例:
同时发起 10 次请求,真正查询次数: 1, 耗时: 1.785056ms
可以看到:
- 10 个并发请求 → 实际数据库查询 只执行了一次
- 返回速度从“串行累计”变成“只看第一次执行的耗时”
- 其他请求全部共享结果(
shared=true)
DoChan 方法说明:
DoChan 的行为与 Do 基本一致,不同点是:
- 返回值变为
<-chan Result - 需要自行从 channel 中读取结果
适用于异步等待或复杂协程调度场景,这里不再重复。
基准测试
在理解了 singleflight 的基本使用方式之后,一个自然的问题就出现了:它到底能带来多少性能收益?
理论上,singleflight 能把瞬时的 N 次相同查询合并为 1 次,但在真实系统的高并发场景下,这种优势到底能体现多少?尤其是多租户、多 goroutine 同时访问相同资源时,传统并发方式与 singleflight 的表现差异究竟有多大?
为此,我们编写了一个 真实模拟高并发下多租户访问的 benchmark。
- baselineGetArticle:每个 goroutine 都直接访问数据库方法,没有任何抑制机制,属于最典型的“缓存未命中 → 打爆数据库的那种场景”。
- singleflightGetArticle:使用 singleflight.Group,将相同 key 的请求合并,让并发用户共享同一份结果。
这个 benchmark 做了三件事:
-
通过 parallel(b.RunParallel)模拟真实系统瞬时高并发
每个 worker 都会不停地发起请求,模拟多个租户、多个用户同时访问同一个热点数据。 -
通过 atomic 计数记录真实的“数据库调用”次数
手写的 getArticle 会 sleep 来模拟数据库开销,并记录 count。 -
在 benchmark 结束后输出真实的数据库命中次数
让我们直观看到:在相同压力下,singleflight 让系统节省了多少重复请求。
最终的效果往往非常直观:
baseline 会看到几十万次 benchmark 内产生几十万次“数据库访问”;
而 singleflight 通常只会在每个 batch 内执行极少次数(甚至只一次)。
这正是 singleflight 的意义所在:
它不是加速,而是减少无意义的重复工作,从而“让系统更能扛”。
package main
import (
"fmt"
"math/rand"
"sync/atomic"
"testing"
"time"
"golang.org/x/sync/singleflight"
)
var dbCount int32
// 模拟数据库查询
func getArticle(id int) (string, error) {
atomic.AddInt32(&dbCount, 1)
time.Sleep(200 * time.Microsecond) // 模拟数据库延迟
return fmt.Sprintf("article-%d", id), nil
}
// 普通查询
func normalGetArticle(id int) (string, error) {
return getArticle(id)
}
// singleflight 查询
func singleflightGetArticle(sg *singleflight.Group, id int) (string, error, bool) {
v, err, shared := sg.Do(fmt.Sprintf("%d", id), func() (interface{}, error) {
return getArticle(id)
})
return v.(string), err, shared
}
//
// ---------------------- 并发 Benchmark ----------------------
//
// 模拟多租户 & 热点请求
func pickID() int {
// 80% 热点 key 1
if rand.Intn(100) < 80 {
return 1
}
// 20% 随机 key 模拟多租户
return rand.Intn(1000) + 2
}
// Benchmark: 普通方法(无请求抑制)
func Benchmark_Normal_Parallel(b *testing.B) {
atomic.StoreInt32(&dbCount, 0)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
id := pickID()
_, _ = normalGetArticle(id)
}
})
b.StopTimer()
b.ReportMetric(float64(dbCount), "db_calls")
}
// Benchmark: SingleFlight 并发场景
func Benchmark_SingleFlight_Parallel(b *testing.B) {
atomic.StoreInt32(&dbCount, 0)
sg := new(singleflight.Group)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
id := pickID()
_, _, _ = singleflightGetArticle(sg, id)
}
})
b.StopTimer()
b.ReportMetric(float64(dbCount), "db_calls")
}
然后我们启动这段benchmark:
go test -bench=. -benchmem -count=5 -cpu=8 -json > bench.json
或
go test -bench=. -benchmem -json > bench.json
说明:
-bench=.→ 跑所有 benchmark-benchmem→ 输出内存分配数据-count=5→ 重复运行 5 次,更稳定-cpu=8→ 使用 8 个 CPU 核心模拟真实服务器-json→ 导出 JSON,便于绘图
BenchmarkNormalParallel
BenchmarkNormalParallel-16 35872 33437 ns/op 35872 db_calls
BenchmarkSingleFlightParallel
BenchmarkSingleFlightParallel-16 37485 31886 ns/op 9705 db_calls
从基准测试结果来看,普通并发情况下(BenchmarkNormalParallel),每个请求都会独立执行数据库操作,导致数据库调用次数与请求次数相等,共计 35,872 次。平均每个请求耗时约 33,437 纳秒,这说明在高并发环境下,重复请求会增加数据库压力,资源利用效率较低。
而在使用 singleflight 的情况下(BenchmarkSingleFlightParallel),相同 key 的重复请求被有效抑制。只有第一次请求会真正执行业务逻辑,其它重复请求直接共享结果,数据库调用次数显著减少到 9,705 次,平均每个请求耗时也下降到 31,886 纳秒。
这一结果清晰地体现了 singleflight 的优势:它不仅减少了重复请求对后端资源的消耗,还在一定程度上提升了系统的响应速度。在高并发场景下,使用 singleflight 可以显著提高系统整体效率,使请求处理更加高效和稳定。
实现原理
从上文的 API 可以看出,调用 Do 或者 DoChan 方法就是 singleflight 的核心“请求”。可以推测:
- 对于同一个
key,首先发起的请求会执行真正的业务逻辑。 - 在这个请求返回之前,后续所有相同
key的调用都会被阻塞。 - 当第一个请求返回后,阻塞的调用会直接使用其返回值作为自己的返回值。
顺着这个逻辑,我们来分析 singleflight 的源码实现。
Group 结构
type Group struct {
mu sync.Mutex // 保护 m 的并发访问
m map[string]*call // 延迟初始化
}
Group表示处理相同数据的一系列工作,这些工作通过map[string]*call进行管理。- 为保证并发安全,
Group内部持有sync.Mutex锁,用于保护 map 的读写操作。
Group 有两个核心方法:
Do
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool)
参数说明:
key:标识同一请求的 key,相同 key 表示相同请求,底层作为 map 的 key。fn:业务方法,负责执行实际逻辑,返回(interface{}, error)。
返回值说明:
v:fn方法返回的第一个值。err:fn方法返回的第二个值error。shared:如果有其他同 key 的请求被抑制,返回true,否则为false。
DoChan
func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result
- 与
Do区别在于返回值不同。 DoChan返回一个只读 channel<-chan Result,调用方可以通过 channel 异步接收结果。
call 结构
type call struct {
wg sync.WaitGroup
val interface{} // 业务方法 fn 的返回值
err error // 业务方法 fn 的 error 返回值
dups int // 重复调用 Do 的次数
chans []chan<- Result // DoChan 方法的返回 channel
}
-
call表示Do或DoChan方法中具体业务方法fn的调用。 -
内部持有
sync.WaitGroup,用于抑制重复请求:- 首次执行业务方法
fn时,调用WaitGroup.Add(1)。 - 重复请求则调用
WaitGroup.Wait()阻塞,保证fn只执行一次。
- 首次执行业务方法
-
字段说明:
val、err:Group.Do的返回值,只能在WaitGroup完成前写入一次,完成后只能读取。dups:重复调用Do的次数。chans:在DoChan调用时,存储结果返回的 channel。
Do 方法实现
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
g.mu.Lock()
// 第一次使用时初始化 map
if g.m == nil {
g.m = make(map[string]*call)
}
// 如果 key 已存在,说明是重复请求
if c, ok := g.m[key]; ok {
c.dups++ // 记录重复调用次数
g.mu.Unlock()
// 阻塞等待首次调用 fn 执行完成
c.wg.Wait()
// 处理特殊错误情况
if e, ok := c.err.(*panicError); ok {
panic(e)
} else if c.err == errGoexit {
runtime.Goexit()
}
// 返回首次调用的结果
return c.val, c.err, true
}
// 创建新的 call 实例
c := new(call)
c.wg.Add(1) // 设置 WaitGroup,首次调用 fn 时通行
g.m[key] = c
g.mu.Unlock()
// 调用实际业务方法
g.doCall(c, key, fn)
return c.val, c.err, c.dups > 0
}
核心逻辑:
-
锁保护 map:每次调用
Do都要先加锁,再解锁,保证并发安全。 -
重复请求抑制:
- 如果
key已存在,增加dups,调用c.wg.Wait()阻塞。 - 待首次调用结束后,阻塞调用直接共享结果。
- 如果
-
首次调用执行 fn:
- 创建
call,设置WaitGroup为 1。 - 调用
doCall执行业务方法。
- 创建
doCall 方法实现
func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
normalReturn := false
recovered := false
defer func() {
if !normalReturn && !recovered {
c.err = errGoexit
}
g.mu.Lock()
defer g.mu.Unlock()
// fn 调用完成,释放 WaitGroup,阻塞调用可返回
c.wg.Done()
// 清理 map
if g.m[key] == c {
delete(g.m, key)
}
// 错误处理与结果分发
if e, ok := c.err.(*panicError); ok {
if len(c.chans) > 0 {
go panic(e)
select {} // 保留 goroutine 出现在 crash dump
} else {
panic(e)
}
} else if c.err == errGoexit {
// no-op
} else {
// 正常返回,将结果写入 DoChan 的 chan
for _, ch := range c.chans {
ch <- Result{c.val, c.err, c.dups > 0}
}
}
}()
// 实际调用业务方法 fn
func() {
defer func() {
if !normalReturn {
if r := recover(); r != nil {
c.err = newPanicError(r)
}
}
}()
c.val, c.err = fn() // 核心逻辑:调用 fn 并记录返回值和错误
normalReturn = true
}()
if !normalReturn {
recovered = true
}
}
核心逻辑:
-
核心调用:
c.val, c.err = fn()- 调用业务方法,并将返回值和错误存储在
call中。
- 调用业务方法,并将返回值和错误存储在
-
defer 错误处理:
- 捕获 panic 并转换为
panicError。 - 特殊错误如
errGoexit处理。 - 正常返回时,将结果写入
call.chans,供DoChan使用。
- 捕获 panic 并转换为
-
WaitGroup:
- 调用
c.wg.Done(),解除阻塞,允许重复调用获取结果。
- 调用
-
map 清理:
- 执行完毕后删除
g.m[key],避免内存泄漏。
- 执行完毕后删除
DoChan 方法实现
func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result {
ch := make(chan Result, 1) // 为每个调用方创建一个结果通道
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*call)
}
// 如果 key 已存在,说明是重复请求
if c, ok := g.m[key]; ok {
c.dups++ // 增加重复调用计数
c.chans = append(c.chans, ch) // 将该调用的 chan 添加到 call.chans
g.mu.Unlock()
return ch
}
// 首次调用,创建新的 call
c := &call{chans: []chan<- Result{ch}}
c.wg.Add(1) // 设置 WaitGroup
g.m[key] = c
g.mu.Unlock()
// 异步调用业务方法
go g.doCall(c, key, fn)
return ch
}
核心逻辑:
-
为每个调用方创建 channel
- 每次调用
DoChan都会创建一个chan Result并返回给调用方。 - channel 存储在
call.chans中,方便doCall将结果写入。
- 每次调用
-
重复请求处理
- 如果 map 中已存在相同
key的call,表示已有请求在执行。 - 将该调用方的 channel 添加到
call.chans,无需再执行业务方法。 - 后续结果会写入所有 channel,调用方通过读取 channel 获取结果。
- 如果 map 中已存在相同
-
首次调用异步执行业务方法
- 只有第一个请求会创建新的
call并调用doCall。 doCall会向call.chans写入结果。
- 只有第一个请求会创建新的
-
无需 WaitGroup 阻塞
chan本身会阻塞读取,直到doCall写入结果。- 因此
DoChan的重复调用不需要显式调用Wait(),只需等待 channel 输出即可。
假设有一个高并发接口 /user,客户端可能同时请求同一个 userID:
- Do 场景:调用方立即阻塞,直到业务逻辑执行完成才返回结果。
- DoChan 场景:调用方立即返回 channel,通过 channel 异步等待结果,可以继续执行其它逻辑或处理其它请求。
package main
import (
"fmt"
"log"
"net/http"
"time"
"golang.org/x/sync/singleflight"
)
var group singleflight.Group
func fetchUserData(userID string) (interface{}, error) {
fmt.Println("Fetching data for:", userID)
time.Sleep(2 * time.Second) // 模拟耗时
return fmt.Sprintf("UserData-%s", userID), nil
}
// --- Do 同步阻塞场景 ---
func handlerDo(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("userID")
if userID == "" {
http.Error(w, "missing userID", http.StatusBadRequest)
return
}
// 使用 Do 同步阻塞获取结果
val, err, shared := group.Do(userID, func() (interface{}, error) {
return fetchUserData(userID)
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if shared {
fmt.Fprintf(w, "[Do-Shared] %v\n", val)
} else {
fmt.Fprintf(w, "[Do-First] %v\n", val)
}
}
// --- DoChan 异步场景 ---
func handlerDoChan(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("userID")
if userID == "" {
http.Error(w, "missing userID", http.StatusBadRequest)
return
}
// 使用 DoChan 异步获取结果
ch := group.DoChan(userID, func() (interface{}, error) {
return fetchUserData(userID)
})
// 模拟业务可以做其它操作
fmt.Fprintf(w, "Request received, waiting for data...\n")
// 异步读取结果并写回(可以放 goroutine)
go func() {
res := <-ch
if res.err != nil {
log.Printf("Failed to fetch user %s: %v\n", userID, res.err)
return
}
if res.shared {
log.Printf("[DoChan-Shared] Got cached result for %s: %v\n", userID, res.val)
} else {
log.Printf("[DoChan-First] Fetched result for %s: %v\n", userID, res.val)
}
}()
}
func main() {
http.HandleFunc("/do", handlerDo)
http.HandleFunc("/dochan", handlerDoChan)
fmt.Println("Server started at :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
并发请求 /do:
curl "http://localhost:8080/do?userID=A123"
curl "http://localhost:8080/do?userID=A123"
- 特点:第一个请求阻塞执行业务逻辑,第二个请求也会阻塞等待结果。
- 响应顺序严格依赖于业务执行完成时间。
并发请求 /dochan:
curl "http://localhost:8080/dochan?userID=A123"
curl "http://localhost:8080/dochan?userID=A123"
- 特点:请求立即返回 “Request received, waiting for data…”,业务逻辑异步执行。
- 重复请求共享结果,结果通过 channel 异步获取,可以在后台处理或写日志。
对比总结
| 特性 | Do(同步阻塞) | DoChan(异步 channel) |
|---|---|---|
| 调用方式 | 阻塞调用,直到业务方法执行完成 | 立即返回 channel,业务异步执行 |
| 重复请求处理 | 阻塞等待首次调用完成,再共享结果 | channel 阻塞接收结果,业务异步共享结果 |
| 使用场景 | 简单请求、同步业务逻辑 | 高并发、异步业务、HTTP 或长任务 |
| 编程模型 | 简单、顺序逻辑 | 需要 goroutine 异步处理或 select 等 |
| 典型示例 | 同步缓存、同步调用第三方 API | HTTP 高并发请求、异步计算、缓存共享 |
python中的singleflight
在高并发场景下,重复请求同一资源可能导致“雪崩”效应(thundering herd problem),如同时触发大量数据库查询或网络请求。Go 语言的 singleflight 提供了一个优雅的解决方案,用于抑制重复请求,只执行一次业务逻辑,并将结果共享给其他重复请求。在 Python 中,也有对应实现——singleflight 库。
pip install singleflight
singleflight 提供了三种实现,适用于不同应用场景:
| 版本 | 用途 |
|---|---|
singleflight.basic.SingleFlight | 多线程环境 |
singleflight.gevent.SingleFlightGevent | gevent 协程应用 |
singleflight.asynchronous.SingleFlightAsync | asyncio / curio 异步应用 |
多线程场景
from time import sleep
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from singleflight.basic import SingleFlight
sf = SingleFlight()
executor = ThreadPoolExecutor(max_workers=10)
counter = 0
result = "this is the result"
def work(num):
global counter
sleep(0.1) # 模拟耗时操作
counter += 1
return (result, num)
res = []
for i in range(10):
sfc = partial(sf.call, work, "key", i+1)
r = executor.submit(sfc)
res.append(r)
for r in res:
assert r.result()[0] == result
assert r.result()[1] == 1 # 只有第一个请求执行业务逻辑
assert counter == 1 # 业务逻辑只执行了一次
说明:
key用于标识重复请求。- 第一次请求会执行函数
work,其余重复请求直接共享结果。 - 并发请求不会重复执行耗时操作。
singleflight 也支持装饰器方式,语法更简洁:
from time import sleep
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from singleflight.basic import SingleFlight
sf = SingleFlight()
executor = ThreadPoolExecutor(max_workers=10)
counter = 0
result = "this is the result"
@sf.wrap
def work(num):
global counter
sleep(0.1)
counter += 1
return (result, num)
res = []
for i in range(10):
sfc = partial(work, "key", i+1)
r = executor.submit(sfc)
res.append(r)
for r in res:
assert r.result()[0] == result
assert r.result()[1] == 1
assert counter == 1
特点:
- 所有函数参数和异常都会正常传递。
- 装饰器方式无需显式调用
sf.call,语法更直观。
结尾
通过这篇文章,我们系统地分析了 singleflight 的原理、在 Go 中的实现细节,以及如何将相同的设计思想应用到 Python 中。在高并发场景下,重复请求不仅会浪费资源,还可能造成数据库或后端服务的压力骤增,而 singleflight 提供了一种优雅的解决方案:确保同一 key 的请求只执行一次业务逻辑,并将结果共享给其它重复请求,从而提升系统的整体效率和稳定性。
在 Python 中,虽然没有官方的 Go 版本,但 singleflight 这个库已经很好地移植了 Go 的思路,支持多线程、gevent 以及 asyncio / curio 等场景。无论是同步函数、线程池,还是异步 HTTP 请求,都可以通过这个库轻松实现重复调用的抑制,避免“雪崩效应”。
总结来说,singleflight 并不仅仅是一个性能优化手段,更是一种在高并发环境下保证资源合理使用的设计模式。掌握它,不仅可以让你的系统更高效,也能让开发者在面对重复请求时更加从容,真正做到“聪明地节约每一次调用”。

999

被折叠的 条评论
为什么被折叠?



