go-zero微服務(wù)框架中提供了許多開箱即用的工具,好的工具不僅能提升服務(wù)的性能而且還能提升代碼的魯棒性避免出錯,實現(xiàn)代碼風(fēng)格的統(tǒng)一方便他人閱讀等等。
本文主要講述進程內(nèi)共享調(diào)用神器SharedCalls。
使用場景
并發(fā)場景下,可能會有多個線程(協(xié)程)同時請求同一份資源,如果每個請求都要走一遍資源的請求過程,除了比較低效之外,還會對資源服務(wù)造成并發(fā)的壓力。舉一個具體例子,比如緩存失效,多個請求同時到達某服務(wù)請求某資源,該資源在緩存中已經(jīng)失效,此時這些請求會繼續(xù)訪問DB做查詢,會引起數(shù)據(jù)庫壓力瞬間增大。而使用SharedCalls可以使得同時多個請求只需要發(fā)起一次拿結(jié)果的調(diào)用,其他請求"坐享其成",這種設(shè)計有效減少了資源服務(wù)的并發(fā)壓力,可以有效防止緩存擊穿。
高并發(fā)場景下,當(dāng)某個熱點key緩存失效后,多個請求會同時從數(shù)據(jù)庫加載該資源,并保存到緩存,如果不做防范,可能會導(dǎo)致數(shù)據(jù)庫被直接打死。針對這種場景,go-zero框架中已經(jīng)提供了實現(xiàn),具體可參看sqlc和mongoc等實現(xiàn)代碼。
為了簡化演示代碼,我們通過多個線程同時去獲取一個id來模擬緩存的場景。如下:
func main() {
const round = 5
var wg sync.WaitGroup
barrier := syncx.NewSharedCalls()
wg.Add(round)
for i := 0; i < round; i++ {
// 多個線程同時執(zhí)行
go func() {
defer wg.Done()
// 可以看到,多個線程在同一個key上去請求資源,獲取資源的實際函數(shù)只會被調(diào)用一次
val, err := barrier.Do("once", func() (interface{}, error) {
// sleep 1秒,為了讓多個線程同時取once這個key上的數(shù)據(jù)
time.Sleep(time.Second)
// 生成了一個隨機的id
return stringx.RandId(), nil
})
if err != nil {
fmt.Println(err)
} else {
fmt.Println(val)
}
}()
}
wg.Wait()
}
運行,打印結(jié)果為:
837c577b1008a0db
837c577b1008a0db
837c577b1008a0db
837c577b1008a0db
837c577b1008a0db
可以看出,只要是同一個key上的同時發(fā)起的請求,都會共享同一個結(jié)果,對獲取DB數(shù)據(jù)進緩存等場景特別有用,可以有效防止緩存擊穿。
關(guān)鍵源碼分析
- SharedCalls interface提供了Do和DoEx兩種方法的抽象
// SharedCalls接口提供了Do和DoEx兩種方法
type SharedCalls interface {
Do(key string, fn func() (interface{}, error)) (interface{}, error)
DoEx(key string, fn func() (interface{}, error)) (interface{}, bool, error)
}
- SharedCalls interface的具體實現(xiàn)sharedGroup
// call代表對指定資源的一次請求
type call struct {
wg sync.WaitGroup // 用于協(xié)調(diào)各個請求goroutine之間的資源共享
val interface{} // 用于保存請求的返回值
err error // 用于保存請求過程中發(fā)生的錯誤
}
type sharedGroup struct {
calls map[string]*call
lock sync.Mutex
}
- sharedGroup的Do方法
- key參數(shù):可以理解為資源的唯一標識。
- fn參數(shù):真正獲取資源的方法。
- 處理過程分析:
// 當(dāng)多個請求同時使用Do方法請求資源時
func (g *sharedGroup) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
// 先申請加鎖
g.lock.Lock()
// 根據(jù)key,獲取對應(yīng)的call結(jié)果,并用變量c保存
if c, ok := g.calls[key]; ok {
// 拿到call以后,釋放鎖,此處call可能還沒有實際數(shù)據(jù),只是一個空的內(nèi)存占位
g.lock.Unlock()
// 調(diào)用wg.Wait,判斷是否有其他goroutine正在申請資源,如果阻塞,說明有其他goroutine正在獲取資源
c.wg.Wait()
// 當(dāng)wg.Wait不再阻塞,表示資源獲取已經(jīng)結(jié)束,可以直接返回結(jié)果
return c.val, c.err
}
// 沒有拿到結(jié)果,則調(diào)用makeCall方法去獲取資源,注意此處仍然是鎖住的,可以保證只有一個goroutine可以調(diào)用makecall
c := g.makeCall(key, fn)
// 返回調(diào)用結(jié)果
return c.val, c.err
}
- sharedGroup的DoEx方法
- 和Do方法類似,只是返回值中增加了布爾值表示值是調(diào)用makeCall方法直接獲取的,還是取的共享成果
func (g *sharedGroup) DoEx(key string, fn func() (interface{}, error)) (val interface{}, fresh bool, err error) {
g.lock.Lock()
if c, ok := g.calls[key]; ok {
g.lock.Unlock()
c.wg.Wait()
return c.val, false, c.err
}
c := g.makeCall(key, fn)
return c.val, true, c.err
}
- sharedGroup的makeCall方法
- 該方法由Do和DoEx方法調(diào)用,是真正發(fā)起資源請求的方法。
// 進入makeCall的一定只有一個goroutine,因為要拿鎖鎖住的
func (g *sharedGroup) makeCall(key string, fn func() (interface{}, error)) *call {
// 創(chuàng)建call結(jié)構(gòu),用于保存本次請求的結(jié)果
c := new(call)
// wg加1,用于通知其他請求資源的goroutine等待本次資源獲取的結(jié)束
c.wg.Add(1)
// 將用于保存結(jié)果的call放入map中,以供其他goroutine獲取
g.calls[key] = c
// 釋放鎖,這樣其他請求的goroutine才能獲取call的內(nèi)存占位
g.lock.Unlock()
defer func() {
// 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()
g.lock.Lock()
delete(g.calls, key)
g.lock.Unlock()
// 調(diào)用wg.Done,通知其他goroutine可以返回結(jié)果,這樣本批次所有請求完成結(jié)果的共享
c.wg.Done()
}()
// 調(diào)用fn方法,將結(jié)果填入變量c中
c.val, c.err = fn()
return c
}
最后
本文主要介紹了go-zero框架中的 SharedCalls工具,對其應(yīng)用場景和關(guān)鍵代碼做了簡單的梳理,希望本篇文章能給大家?guī)硪恍┦斋@。