go-zero基础组件-共享调用singleFlight

1,163 阅读4分钟

为什么需要共享调用

想象一下并发场景下多个请求执行同样的操作,为了防止压力过大我们一般会这样做?

比如:并发的从数据库获取同一个用户的信息,如果都放开请求,无疑导致的资源严重浪费,并且数据库压力也会比较大。

常规做法是: 加互斥锁,从根源上阻止同一个时间的并发调用。

这种方式实现起来比较简单,但是需要根据不同场景考虑是加非阻塞锁还是阻塞锁,高并发场景推荐非阻塞锁,低频场景如果是阻塞锁超时时间设置多久呢?

那么有没有更简单高效的方式来解决上面提到的边界问题呢?

有的,共享调用!

什么是共享调用

所谓共享调用指的是对于相同操作并发调用时,只会产生一个真实调用操作,其他调用共享结果。

这种机制可以完美的解决缓存击穿问题。

缓存击穿
某一热点 key缓存失效,导致大量相同的请求直接查询DB。 \

缓存雪崩
对于多个热点 key ,在某一刻同时失效,导致大量请求直接查询DB,造成DB压力过大崩溃。\

缓存穿透
请求id非法,实际数据库并不存在该id导致无法命中缓存所有请求直接查询DB。

后面写篇文章专门谈go-zero的缓存策略(挖坑中)。

代码实现

core/syncx/singleflight.go

核心机制第一个请求利用 waitGroup 只添加一个信号量限制只执行一次真实调用,其他人都得等待 waitGroup.Done()事件通知。

java 中的 countDownLatch 也可以实现类似效果。

执行真实调用后,是先删除 map key,value 还是先执行 waitGroup.Done()呢?

go-zero 官网文档有这样的描述,我不是很理解

    // delete key first, done later. can't reverse the order, because if reverse,
    // another Do call might wg.Wait() without get notified with wg.Done()

我的理解是

func (g *flightGroup) makeCall(c *call, key string, fn func() (interface{}, error)) {
	//执行完成时回调此函数
	defer func() {
		c.wg.Done()
		//加锁
		g.lock.Lock()
		//为什么可以先删除map记录再执行waitGroup.Done()呢?
		//关键在于 c, ok := g.calls[key] 这里拿到的其实是指针
		//所以不管先清除map还是waitGroup.Done()其实都是可以的
		delete(g.calls, key)
		g.lock.Unlock()

	}()
	//执行函数
	c.val, c.err = fn()
}
package syncx

import "sync"

type (
	// SharedCalls is an alias of SingleFlight.
	// Deprecated: use SingleFlight.
	SharedCalls = SingleFlight

	// SingleFlight lets the concurrent calls with the same key to share the call result.
	// For example, A called F, before it's done, B called F. Then B would not execute F,
	// and shared the result returned by F which called by A.
	// The calls with the same key are dependent, concurrent calls share the returned values.
	// A ------->calls F with key<------------------->returns val
	// B --------------------->calls F with key------>returns val
	//进程内共享调用
	//同一时间多个相同的操作只会产生一次真实调用
	//其他人等待调用完成并共享结果
	SingleFlight interface {
		//方法调用
		Do(key string, fn func() (interface{}, error)) (interface{}, error)
		//与Do的区别在于返回值多了一个是否执行标识
		DoEx(key string, fn func() (interface{}, error)) (interface{}, bool, error)
	}
	//函数调用结果结构体
	call struct {
		//同步标识
		wg sync.WaitGroup
		//返回值
		val interface{}
		err error
	}
	//实现类
	flightGroup struct {
		calls map[string]*call
		//go中没有并发安全的map,只能依靠lock
		lock sync.Mutex
	}
)

// NewSingleFlight returns a SingleFlight.
func NewSingleFlight() SingleFlight {
	return &flightGroup{
		calls: make(map[string]*call),
	}
}

// NewSharedCalls returns a SingleFlight.
// Deprecated: use NewSingleFlight.
func NewSharedCalls() SingleFlight {
	return NewSingleFlight()
}

func (g *flightGroup) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
	//创建函数调用接收者,如果已经创建则等待其他执行者完成并返回
	c, done := g.createCall(key)
	//其他人已执行,直接拿结果
	if done {
		return c.val, c.err
	}
	//我是第一个执行人,执行调用
	g.makeCall(c, key, fn)
	return c.val, c.err
}

func (g *flightGroup) DoEx(key string, fn func() (interface{}, error)) (val interface{}, fresh bool, err error) {
	c, done := g.createCall(key)
	if done {
		return c.val, false, c.err
	}

	g.makeCall(c, key, fn)
	return c.val, true, c.err
}

//创建函数调用接收者
func (g *flightGroup) createCall(key string) (c *call, done bool) {
	//并发场景下这里需要加锁
	g.lock.Lock()
	//有其他人正在执行
	if c, ok := g.calls[key]; ok {
		//第一时间释放锁
		g.lock.Unlock()
		//等待完成其他人完成调用
		c.wg.Wait()
		return c, true
	}
	//没有人执行任务
	//就由我第一个来创建接收对象
	c = new(call)
	//同步信号+1
	//共享调用的关键就在于此
	//只能有一个人执行真正的函数调用,其他人都在等待
	c.wg.Add(1)
	g.calls[key] = c
	g.lock.Unlock()

	return c, false
}

//执行函数调用
func (g *flightGroup) makeCall(c *call, key string, fn func() (interface{}, error)) {
	//执行完成时回调此函数
	defer func() {
		//加锁
		g.lock.Lock()
		//第一次看这里的逻辑很疑惑
		//为什么可以先删除map记录再执行waitGroup.Done()呢?
		//关键在于 c, ok := g.calls[key] 这里拿到的其实是指针
		指针就就意味着所有人都可以监听到waitGroup.Done()信号。
		//所以不管先清除map还是waitGroup.Done()其实都是可以的
		delete(g.calls, key)
		g.lock.Unlock()
		c.wg.Done()
	}()
	//执行函数
	c.val, c.err = fn()
}

资料

golang官方拓展库singleFlight实现,重点考虑了pannic场景

memoryCache作者的groupCachesingleFlight实现