Go并发实战:singleflight 源码解读与二次封装

一、介绍

singleflight 是 Go 官方扩展库 golang.org/x/sync 中提供的并发原语,用于合并重复的并发请求,减少对下游资源(如数据库、API)的重复访问,确保在多个 goroutine 同时请求相同资源时,只有一个实际执行操作,其它调用共享结果,尤其适用于防止缓存击穿等高并发场景。

二、核心结构

singleflight 的核心结构主要是 Group,包含两个核心字段

  • mu: 互斥锁,确保对映射表 m 的并发安全访问
  • m: 键值对映射,存储每个键对应的调用信息
go 复制代码
type Group struct {
    mu sync.Mutex          // 保护内部映射的互斥锁
    m  map[string]*call    // 存储键与调用状态的映射(惰性初始化)
}
  • 其中 call 结构代表一个正在执行或已完成的调用
    • wg: 确保只有一个 goroutine 执行实际工作
    • val 和 err: 存储函数执行结果
    • dups: 记录等待此结果的请求数量
    • chans: 用于异步通知结果的通道列表
go 复制代码
type call struct {
    wg    sync.WaitGroup   // 阻塞等待组,同步调用完成
    val   interface{}      // 函数执行结果
    err   error            // 函数执行错误
    dups  int              // 重复请求计数
    chans []chan<- Result  // DoChan 的结果通道列表
}

除此之外,singleflight 中还定义了一个 Result 结构体,用于接收 DoChan 方法的返回

go 复制代码
type Result struct {
    Val    interface{} // 函数返回值
    Err    error       // 函数返回错误
    Shared bool        // 结果是否被共享
}

三、工作流程

  1. Do方法: 同步阻塞调用
go 复制代码
func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error, bool) {
    g.mu.Lock()
    // 1. 如果存在进行中的调用,等待调用结果(也可能已经有结果了),返回结果
    if c, ok := g.m[key]; ok {   
        c.dups++                // 增加重复计数
        g.mu.Unlock()
        c.wg.Wait()             // 阻塞等待结果
        return c.val, c.err, true
    }
    // 2. 不存在进行中的调用,新建一个调用并将调用的key存储到g.m里,让之后的groutine请求
    // 只能走到第一步,之后调用g.doCall方法,执行实际调用
    c.wg.Add(1)
    g.m[key] = c
    g.mu.Unlock()
    g.doCall(c, key, fn)        // 执行实际调用
    return c.val, c.err, c.dups > 0
}
  • 可以看到,Do 方法通过接收一个请求的唯一标识 key 和需要执行的函数逻辑 fn(如查询数据库,调用下游接口等操作),返回 fn 实际的请求结果。
  • 若 key 对应的请求正在处理,当前协程会等待结果;若 key 无正在处理的请求,会执行传入的 fn 并将结果存储在 g.m 里,正在等待的协程接收到信号后,直接使用 g.m[key] 里的结果 c.valc.err,实现"一次执行,多协程共享"。
  1. DoChan 方法: 异步非阻塞调用
go 复制代码
type Result struct {
	Val    interface{}
	Err    error
	Shared bool
}

func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result {
    // 创建带缓冲的结果通道(缓冲大小1,避免发送阻塞)
    ch := make(chan Result, 1)
    
    // 加锁保护Group内部的映射表,确保并发安全
    g.mu.Lock()
    
    // 检查当前key是否已有正在处理的请求(call结构体)
    if c, ok := g.m[key]; ok {
        // 1. 已有请求正在处理:当前请求为重复请求
        c.dups++  // 记录重复请求次数
        // 将当前通道添加到call的通道列表中,订阅结果(后续会收到广播)
        c.chans = append(c.chans, ch)
        g.mu.Unlock()  // 解锁,避免阻塞其他请求
        return ch      // 返回当前通道,等待结果
    }
    
    // 2. 无正在处理的请求:创建新的call结构体处理当前请求
    // 初始化call,将当前通道作为第一个订阅者
    c := &call{chans: []chan<- Result{ch}}
    c.wg.Add(1)  // 增加WaitGroup计数,标记请求开始处理
    g.m[key] = c  // 将key与call关联,存入Group的映射表
    g.mu.Unlock()  // 解锁,允许其他请求进入
    
    // 启动新协程执行核心处理逻辑(doCall会执行fn并广播结果)
    go g.doCall(c, key, fn)
    
    // 返回结果通道,调用方通过该通道获取结果
    return ch
}
  • 可以看到,DoChanDo 方法入参一致,区别在于 Do 直接返回调用结果和错误,DoChan 则将结果和错误封装成一个 Result 结构体,返回一个只读类型的 Result channel 结构
  • 对于每次请求(相同的数据请求用 同一个 key 来标识),都会创建一个带缓冲的 channel,用于接受结果,避免发送方阻塞。若当前 key已经开始请求,g.m[key] 识别出来后,会将当前 channel 加入到结果订阅列表 c.chans 当中,等待结果广播。若当前 key 为首次请求,会创建 call 结构体并关联 key,之后启动协程执行 doCall 方法执行 fn 函数获取调用结果,在 doCall 方法中,获取结果之后,通过广播机制,所有订阅通道 c.chans 都会收到相同的结果,实现"一次执行,多协程共享"。
  1. doCall 方法:执行用户传入的函数(fn),收集结果,并将结果广播给所有等待该 key 的协程
go 复制代码
// doCall 处理单个key的请求逻辑,是singleflight的核心执行方法
// 负责执行用户传入的函数fn,处理返回结果、错误及panic,最终将结果同步给所有等待该key的协程
func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
	// normalReturn 标记fn是否正常执行完成(未发生panic且正常返回)
	normalReturn := false
	// recovered 标记是否从panic中恢复
	recovered := false

	// 延迟执行:在函数退出时处理结果广播、资源清理和panic传播
	defer func() {
		// 1. 处理特殊退出状态:如果fn既未正常返回也未从panic中恢复(如主动调用runtime.Goexit())
		if !normalReturn && !recovered {
			c.err = errGoexit // 标记为Goexit错误
		}

		// 2. 唤醒所有等待该请求的协程(通过Do方法等待的协程会阻塞在c.wg.Wait())
		c.wg.Done()

		// 3. 加锁保护Group的映射表和call结构体,处理后续清理和结果分发
		g.mu.Lock()
		defer g.mu.Unlock()

		// 3.1 清理资源:如果当前call未被标记为"遗忘"(通过Forget方法),则从Group的映射中删除该key
		// 避免已处理完的请求继续占用内存
		if !c.forgotten {
			delete(g.m, key)
		}

		// 3.2 处理不同错误类型,分发结果或传播panic
		if e, ok := c.err.(*panicError); ok {
			// 3.2.1 处理fn执行过程中发生的panic
			if len(c.chans) > 0 {
				// 如果有通过DoChan等待的协程,启动新协程传播panic并阻塞当前协程
				// 避免当前协程被panic终止,确保后续清理逻辑完成
				go panic(e)
				select {} // 永久阻塞,防止该协程退出
			} else {
				// 没有等待的通道,直接在当前协程传播panic
				panic(e)
			}
		} else if c.err == errGoexit {
			// 3.2.2 处理Goexit错误:此时资源已在之前释放,无需额外操作
		} else {
			// 3.2.3 正常结果或普通错误:将结果发送到所有通过DoChan等待的通道
			for _, ch := range c.chans {
				ch <- Result{c.val, c.err, c.dups > 0}
			}
		}
	}()

	// 执行用户传入的函数fn,并捕获可能的panic
	func() {
		// 延迟捕获fn执行中的panic
		defer func() {
			// 仅在fn未正常返回时处理panic(normalReturn为false表示执行流程被中断)
			if !normalReturn {
				// 捕获panic值,转换为panicError类型存储到call中
				if r := recover(); r != nil {
					c.err = newPanicError(r)
				}
			}
		}()

		// 执行用户函数,获取返回值和错误
		c.val, c.err = fn()

		// 标记为正常返回(未发生panic,执行到此处)
		normalReturn = true
	}()

	// 如果fn未正常返回(normalReturn仍为false),说明发生了panic且已被捕获,标记为已恢复
	if !normalReturn {
		recovered = true
	}
}
  • doCall 函数逻辑:
  1. 状态标记normalReturnrecovered 用于跟踪函数执行状态,区分 "正常返回""panic 恢复""主动退出(Goexit)" 三种情况。

  2. 延迟处理(defer) :函数退出前统一处理:

    • 唤醒等待的协程(c.wg.Done()
    • 清理 Group 中已完成的 key 映射
    • 根据错误类型分发结果(普通错误 / 正常结果发送到通道,panic 则传播)
  3. Panic 处理 :通过嵌套的 defer 捕获 fn 中的 panic,转换为panicError,确保 panic 不会终止整个程序,且能正确通知等待的协程。

  4. 结果分发 :对通过DoChan等待的协程,通过通道发送结果;对通过Do等待的协程,通过WaitGroup唤醒后读取call.valcall.err

四、使用场景

singleflight 是一个在高并发场景下高频使用的工具,能够有效防止缓存击穿,提升系统吞吐量。在实际开发中下,如果遇到以下业务场景,可以优先考虑使用 singleflight:

  1. 防止缓存击穿:使用 singleflight 可以有效防止缓存击穿,减少数据库压力。
  2. 重复 API 调用合并:当有多个协程需要多次查询数据库或调用下游服务请求相同的数据时,使用 singleflight 可以减少调用次数,节省配额,提升数据一致性。
  3. 热点数据加载:当需要加载热点数据时,可以使用 singleflight 加载数据,避免重复的 I/O 计算。

五、二次封装

博主基于 Go 官方 singleflight 包进行了深度优化和增强,打造了一个生产级的高并发请求合并组件。该组件已通过严格的单元测试和线上验证,觉得不错的小伙伴可以将其封装到所在团队的公共代码库中进行使用。

  • singleflight 包目录
  • singleflight.go
go 复制代码
package singleflight

import (
	"fmt"
	"sync"
)

// Group 接口定义优化
type Group interface {
	Do(key string, fn func() (interface{}, error)) (interface{}, error)
	DoChan(key string, fn func() (interface{}, error)) <-chan Result
	Forget(key string)
}

type Result struct {
	Val    interface{}
	Err    error
	Shared bool
}

type call struct {
	wg    sync.WaitGroup
	val   interface{}
	err   error
	chans []chan<- Result // 支持异步结果通知
}

type group struct {
	mu sync.Mutex
	m  map[string]*call
}

func NewGroup() Group {
	return &group{m: make(map[string]*call)}
}

// Do 同步执行请求合并
func (g *group) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
	g.mu.Lock()
	if c, ok := g.m[key]; ok {
		g.mu.Unlock()
		c.wg.Wait() // 等待已有请求完成
		return c.val, c.err
	}

	c := &call{}
	c.wg.Add(1)
	g.m[key] = c
	g.mu.Unlock()

	defer func() {
		g.cleanup(key, c)
	}()

	c.val, c.err = g.safeCall(fn)
	c.wg.Done()
	return c.val, c.err
}

// DoChan 异步执行请求合并
func (g *group) DoChan(key string, fn func() (interface{}, error)) <-chan Result {
	ch := make(chan Result, 1)
	g.mu.Lock()
	if c, ok := g.m[key]; ok {
		c.chans = append(c.chans, ch) // 订阅已有请求的结果
		g.mu.Unlock()
		return ch
	}

	c := &call{chans: []chan<- Result{ch}}
	c.wg.Add(1)
	g.m[key] = c
	g.mu.Unlock()

	go func() {
		defer g.cleanup(key, c)
		c.val, c.err = g.safeCall(fn)
		c.wg.Done()
		// 分发结果给所有订阅者
		for _, ch := range c.chans {
			ch <- Result{Val: c.val, Err: c.err, Shared: len(c.chans) > 1}
		}
	}()

	return ch
}

// Forget 主动移除缓存
func (g *group) Forget(key string) {
	g.mu.Lock()
	defer g.mu.Unlock()
	delete(g.m, key)
}

// 安全执行函数(处理 panic)
func (g *group) safeCall(fn func() (interface{}, error)) (val interface{}, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = toError(r)
		}
	}()
	return fn()
}

// 资源清理
func (g *group) cleanup(key string, c *call) {
	g.mu.Lock()
	defer g.mu.Unlock()
	if g.m[key] == c {
		delete(g.m, key)
	}
}

// 转换 panic 为 error
func toError(r interface{}) error {
	switch v := r.(type) {
	case error:
		return v
	default:
		return fmt.Errorf("panic: %v", v)
	}
}
  • singleflight_test.go
go 复制代码
package singleflight

import (
    "errors"
    "fmt"
    "testing"
    "time"
)

func TestDo(t *testing.T) {
    var g = NewGroup()
    v, err := g.Do("user_1", func() (interface{}, error) {
       return "Mike", nil
    })
    res := fmt.Sprintf("%s", v)
    want := "Mike"
    if res != want {
       t.Errorf("res: %s but want: %v", res, want)
    }

    if err != nil {
       t.Errorf("SingleFlightDo err:  %v", err)
    }

}

func TestDoChan(t *testing.T) {
    var g = NewGroup()
    // 1. 调用 DoChan 返回结果通道
    resultChan := g.DoChan("user_1", func() (interface{}, error) {
       return "Mike", nil
    })

    // 2. 通过 select 异步接收结果(含超时控制)
    select {
    case res := <-resultChan:
       v := res.Val
       err := res.Err
       // 3. 验证返回值
       if s, ok := v.(string); !ok || s != "Mike" {
          t.Errorf("res: %v (type %T) but want: Mike", v, v)
       }
       if err != nil {
          t.Errorf("DoChan error: %v", err)
       }
    case <-time.After(500 * time.Millisecond): // 超时兜底
       t.Error("DoChan timed out after 500ms")
    }
}

// 测试错误返回
func TestDoChan_Error(t *testing.T) {
    var g = NewGroup()
    ch := g.DoChan("fail_key", func() (interface{}, error) {
       return nil, errors.New("simulated error")
    })

    res := <-ch
    if res.Err == nil || res.Err.Error() != "simulated error" {
       t.Errorf("expected 'simulated error', got %v", res.Err)
    }
}

// 测试结果共享标识
func TestDoChan_Shared(t *testing.T) {
    var g = NewGroup()
    ch1 := g.DoChan("shared_key", func() (interface{}, error) { return "data", nil })
    ch2 := g.DoChan("shared_key", func() (interface{}, error) { return "data", nil }) // 相同 key

    res1 := <-ch1
    res2 := <-ch2
    if !res1.Shared || !res2.Shared {
       t.Error("results should be marked as shared")
    }
}
相关推荐
CodeSheep5 分钟前
宇树科技 IPO 时间,定了!
前端·后端·程序员
翻滚丷大头鱼25 分钟前
android View详解—自定义ViewGroup,流式布局
android·数据结构
MT_12533 分钟前
大小端存储的理解与判断方法
数据结构·算法
绝无仅有33 分钟前
Go语言面试之 select 机制与使用场景分析
后端·面试·github
IT_陈寒39 分钟前
Vue 3.4 性能飞跃:5个Composition API优化技巧让我的应用提速40%
前端·人工智能·后端
跟着珅聪学java42 分钟前
spring boot 整合AI教程
人工智能·spring boot·后端
绝无仅有43 分钟前
Go语言面试:传值与传引用的区别及选择指南
后端·面试·github
He1955011 小时前
Go初级之九:Select 与并发控制
开发语言·后端·golang
Victor3562 小时前
Redis(44)Redis哨兵的工作原理是什么?
后端
Victor3562 小时前
Redis(45)哨兵模式与集群模式有何区别?
后端