常常被遗忘的技术——SingleFlight

部署运行你感兴趣的模型镜像

引子

在很多工程团队里,一提到“流量消峰”或者“保护下游服务”,大家脑子里蹦出来的永远是那几样东西:消息队列熔断器限流器降级策略。这些技术当然有用,我自己项目里也用得不少,但奇怪的是,大家却经常忽略一个简单到离谱、却能在关键时刻救系统一命的东西 —— SingleFlight

很多人以为有了 cache,系统就“高枕无忧”了。但 cache 只能挡住已有的数据请求,对两种情况完全无能为力:

  • 缓存穿透:大量并发请求同一份缓存中不存在的数据,全部直接打在后端。
  • 瞬时高峰:某个热点键突然失效,几万并发同时抢着去加载同一份数据。

这些场景在生产里比大家想象得更常见,尤其是分布式环境中多个节点的缓存同时过期、某个热点 key 几分钟一次的抖动、突发活动、机器重启导致缓存全失效…… 你以为有 cache 就万事大吉,实际上它可能是压垮后端的第一张多米诺骨牌。

而 SingleFlight 的意义就在这里 —— 它不解决“缓存有数据时的性能”,它解决的是“缓存没数据时的灾难”。
它像是在缓存前面加了一道“智能闸机”,让同一个 key 的并发请求只需要 let 一个 代表去后端干活,其他人都在旁边等结果。既不给数据库添堵,也不给下游打爆。

更重要的是:它简单到让人怀疑为什么很多团队根本没人提过它。

所以我写这篇文章,就是想把这个常被忽略的“小技术”,重新放回到流量治理的工具箱里。我们总说系统要稳定,结果把 MQ、熔断、降级吹得天花乱坠,反而忘记了一个可以“一键消除缓存穿透 + 防止瞬时冲击”的轻量工具 —— SingleFlight。

Golang中的singleflight

singleflight 包的全路径为:

golang.org/x/sync/singleflight

SingleFlight 的核心用途是:在同一时间抑制对相同数据的重复请求

当多个并发请求尝试获取同一份数据时,SingleFlight 会保证:

  • 只有一个请求真正执行业务逻辑;
  • 其他请求会被阻塞;
  • 当前面那个请求执行完毕后,其返回值会被 共享(shared) 给所有阻塞的请求。

为了正确使用 SingleFlight,我们首先需要理解两个关键概念:

  1. 什么是“相同数据”?
  2. 什么是“重复请求”?

“相同数据”指的是:在并发场景下,多个请求在同一时间段内试图获取的 数据内容完全一致

典型场景:

  • 获取全球统一配置
  • 查某城市的天气
  • 加载权限信息
  • 获取某个热点 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 方法

DoChanDo 的逻辑类似,只是将返回结果封装成一个 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
}

说明:

  • 通过 GroupDo 方法实现请求抑制
  • 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 做了三件事:

  1. 通过 parallel(b.RunParallel)模拟真实系统瞬时高并发
    每个 worker 都会不停地发起请求,模拟多个租户、多个用户同时访问同一个热点数据。

  2. 通过 atomic 计数记录真实的“数据库调用”次数
    手写的 getArticle 会 sleep 来模拟数据库开销,并记录 count。

  3. 在 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)

返回值说明:

  • vfn 方法返回的第一个值。
  • errfn 方法返回的第二个值 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 表示 DoDoChan 方法中具体业务方法 fn 的调用。

  • 内部持有 sync.WaitGroup,用于抑制重复请求:

    1. 首次执行业务方法 fn 时,调用 WaitGroup.Add(1)
    2. 重复请求则调用 WaitGroup.Wait() 阻塞,保证 fn 只执行一次。
  • 字段说明:

    • valerrGroup.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
}

核心逻辑:

  1. 锁保护 map:每次调用 Do 都要先加锁,再解锁,保证并发安全。

  2. 重复请求抑制

    • 如果 key 已存在,增加 dups,调用 c.wg.Wait() 阻塞。
    • 待首次调用结束后,阻塞调用直接共享结果。
  3. 首次调用执行 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
	}
}

核心逻辑:

  1. 核心调用

    c.val, c.err = fn()
    
    • 调用业务方法,并将返回值和错误存储在 call 中。
  2. defer 错误处理

    • 捕获 panic 并转换为 panicError
    • 特殊错误如 errGoexit 处理。
    • 正常返回时,将结果写入 call.chans,供 DoChan 使用。
  3. WaitGroup

    • 调用 c.wg.Done(),解除阻塞,允许重复调用获取结果。
  4. 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
}

核心逻辑:

  1. 为每个调用方创建 channel

    • 每次调用 DoChan 都会创建一个 chan Result 并返回给调用方。
    • channel 存储在 call.chans 中,方便 doCall 将结果写入。
  2. 重复请求处理

    • 如果 map 中已存在相同 keycall,表示已有请求在执行。
    • 将该调用方的 channel 添加到 call.chans,无需再执行业务方法。
    • 后续结果会写入所有 channel,调用方通过读取 channel 获取结果。
  3. 首次调用异步执行业务方法

    • 只有第一个请求会创建新的 call 并调用 doCall
    • doCall 会向 call.chans 写入结果。
  4. 无需 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 等
典型示例同步缓存、同步调用第三方 APIHTTP 高并发请求、异步计算、缓存共享

python中的singleflight

在高并发场景下,重复请求同一资源可能导致“雪崩”效应(thundering herd problem),如同时触发大量数据库查询或网络请求。Go 语言的 singleflight 提供了一个优雅的解决方案,用于抑制重复请求,只执行一次业务逻辑,并将结果共享给其他重复请求。在 Python 中,也有对应实现——singleflight 库。

pip install singleflight

singleflight 提供了三种实现,适用于不同应用场景:

版本用途
singleflight.basic.SingleFlight多线程环境
singleflight.gevent.SingleFlightGeventgevent 协程应用
singleflight.asynchronous.SingleFlightAsyncasyncio / 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 并不仅仅是一个性能优化手段,更是一种在高并发环境下保证资源合理使用的设计模式。掌握它,不仅可以让你的系统更高效,也能让开发者在面对重复请求时更加从容,真正做到“聪明地节约每一次调用”。

您可能感兴趣的与本文相关的镜像

Python3.10

Python3.10

Conda
Python

Python 是一种高级、解释型、通用的编程语言,以其简洁易读的语法而闻名,适用于广泛的应用,包括Web开发、数据分析、人工智能和自动化脚本

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Generalzy

文章对您有帮助,倍感荣幸

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值