最近接觸到微服務框架go-zero
,翻看了整個框架程式碼,發現結構清晰、程式碼簡潔,所以決定閱讀原始碼學習下,本次閱讀的原始碼位於core/syncx/singleflight.go
。
在go-zero
中SingleFlight
的作用是:將併發請求合併成一個請求,以減少對下層服務的壓力。
應用場景
- 查詢快取時,合併請求,提升服務效能。
假設有一個 IP 查詢的服務,每次使用者請求先在快取中查詢一個 IP 的歸屬地,如果快取中有結果則直接返回,不存在則進行 IP 解析操作。
如上圖所示,n 個使用者請求查詢同一個 IP(8.8.8.8)就會對應 n 個 Redis 的查詢,在高併發場景下,如果能將 n 個 Redis 查詢合併成一個 Redis 查詢,那麼效能肯定會提升很多,而 SingleFlight
就是用來實現請求合併的,效果如下:
- 防止快取擊穿。
快取擊穿問題是指:在高併發的場景中,大量的請求同時查詢一個 key ,如果這個 key 正好過期失效了,就會導致大量的請求都打到資料庫,導致資料庫的連線增多,負載上升。
通過SingleFlight
可以將對同一個Key的併發請求進行合併,只讓其中一個請求到資料庫進行查詢,其他請求共享同一個結果,可以很大程度提升併發能力。
應用方式
直接上程式碼:
func main() {
round := 10
var wg sync.WaitGroup
barrier := syncx.NewSingleFlight()
wg.Add(round)
for i := 0; i < round; i++ {
go func() {
defer wg.Done()
// 啟用10個協程模擬獲取快取操作
val, err := barrier.Do("get_rand_int", func() (interface{}, error) {
time.Sleep(time.Second)
return rand.Int(), nil
})
if err != nil {
fmt.Println(err)
} else {
fmt.Println(val)
}
}()
}
wg.Wait()
}
以上程式碼,模擬 10 個協程請求 Redis 獲取一個 key 的內容,程式碼很簡單,就是執行Do()
方法。其中,接收兩個引數,第一個引數是獲取資源的標識,可以是 redis 中快取的 key,第二個引數就是一個匿名函式,封裝好要做的業務邏輯。最終獲得的結果如下:
5577006791947779410
5577006791947779410
5577006791947779410
5577006791947779410
5577006791947779410
5577006791947779410
5577006791947779410
5577006791947779410
5577006791947779410
5577006791947779410
從上看出,10個協程都獲得了同一個結果,也就是隻有一個協程真正執行了rand.Int()
獲取了隨機數,其他的協程都共享了這個結果。
原始碼解析
先看程式碼結構:
type (
// 定義介面,有2個方法 Do 和 DoEx,其實邏輯是一樣的,DoEx 多了一個標識,主要看Do的邏輯就夠了
SingleFlight interface {
Do(key string, fn func() (interface{}, error)) (interface{}, error)
DoEx(key string, fn func() (interface{}, error)) (interface{}, bool, error)
}
// 定義 call 的結構
call struct {
wg sync.WaitGroup // 用於實現通過1個 call,其他 call 阻塞
val interface{} // 表示 call 操作的返回結果
err error // 表示 call 操作發生的錯誤
}
// 總控結構,實現 SingleFlight 介面
flightGroup struct {
calls map[string]*call // 不同的 call 對應不同的 key
lock sync.Mutex // 利用鎖控制請求
}
)
然後看最核心的Do方法
做了什麼事情:
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
}
程式碼很簡潔,利用g.createCall(key)
對 key 發起 call 請求(其實就是做一件事情),如果此時已經有其他協程已經在發起 call 請求就阻塞住(done 為 true 的情況),等待拿到結果後直接返回。如果 done 是 false,說明當前協程是第一個發起 call 的協程,那麼就執行g.makeCall(c, key, fn)
真正地發起 call 請求(此後的其他協程就阻塞在了g.createCall(key)
)。
從上圖可知,其實關鍵就兩步:
- 判斷是第一個請求的協程(利用map)
- 阻塞住其他所有協程(利用 sync.WaitGroup)
來看下g.createCall(key)
如何實現的:
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)
c.wg.Add(1)
g.calls[key] = c
g.lock.Unlock()
return c, false
}
先看第一步:判斷是第一個請求的協程(利用map)
g.lock.Lock()
if c, ok := g.calls[key]; ok {
g.lock.Unlock()
c.wg.Wait()
return c, true
}
此處判斷 map 中的 key 是否存在,如果已經存在,說明已經有其他協程在請求了,當前這個協程只需要等待,等待是利用了sync.WaitGroup
的Wait()
方法實現的,此處還是很巧妙的。要注意的是,map 在 Go 中是非併發安全的,所以需要加鎖。
再看第二步:阻塞住其他所有協程(利用 sync.WaitGroup)
c = new(call)
c.wg.Add(1)
g.calls[key] = c
因為是第一個發起 call 的協程,所以需要 new 這個 call,然後將wg.Add(1)
,這樣就對應了上面的wg.Wait()
,阻塞剩下的協程。隨後將 new 的 call 放入 map 中,注意此時只是完成了初始化,並沒有真正去執行call請求,真正的處理邏輯在 g.makeCall(c, key, fn)
中。
func (g *flightGroup) makeCall(c *call, key string, fn func() (interface{}, error)) {
defer func() {
g.lock.Lock()
delete(g.calls, key)
g.lock.Unlock()
c.wg.Done()
}()
c.val, c.err = fn()
}
這個方法中做的事情很簡單,就是執行了傳遞的匿名函式fn()
(也就是真正call請求要做的事情)。最後處理收尾的事情(通過defer),也是分成兩步:
- 刪除 map 中的 key,使得下次發起請求可以獲取新的值。
- 呼叫
wg.Done()
,讓之前阻塞的協程全部獲得結果並返回。
至此,SingleFlight
的核心程式碼就解析完畢了,雖然程式碼不長,但是這個思想還是很棒的,可以在實際工作中借鑑。
總結
- map 非併發安全,記得加鎖。
- 巧用 sync.WaitGroup 去完成
需要阻塞控制協程
的應用場景。 - 通過匿名函式 fn 去封裝傳遞具體業務邏輯,在呼叫 fn 的上層函式中去完成統一的邏輯處理。
專案地址
歡迎使用 go-zero
並 star 支援我們!
微信交流群
關注『微服務實踐』公眾號並點選 交流群 獲取社群群二維碼。
本作品採用《CC 協議》,轉載必須註明作者和本文連結