讓我們一起賞析Singleflight設(shè)計(jì)
本文轉(zhuǎn)載自微信公眾號(hào)「Golang夢工廠」,作者AsongGo。轉(zhuǎn)載本文請聯(lián)系Golang夢工廠公眾號(hào)。
前言
哈嘍,大家好,我是asong。今天想與大家分享一下singleflight這個(gè)庫,singleflight僅僅只有100多行卻可以做到防止緩存擊穿,有點(diǎn)厲害哦!所以本文我們就一起來看一看他是怎么設(shè)計(jì)的~。
注意:本文基于 https://pkg.go.dev/golang.org/x/sync/singleflight進(jìn)行分析。
緩存擊穿
什么是緩存擊穿
平常在高并發(fā)系統(tǒng)中,會(huì)出現(xiàn)大量的請求同時(shí)查詢一個(gè)key的情況,假如此時(shí)這個(gè)熱key剛好失效了,就會(huì)導(dǎo)致大量的請求都打到數(shù)據(jù)庫上面去,這種現(xiàn)象就是緩存擊穿。緩存擊穿和緩存雪崩有點(diǎn)像,但是又有一點(diǎn)不一樣,緩存雪崩是因?yàn)榇竺娣e的緩存失效,打崩了DB,而緩存擊穿則是指一個(gè)key非常熱點(diǎn),在不停的扛著高并發(fā),高并發(fā)集中對(duì)著這一個(gè)點(diǎn)進(jìn)行訪問,如果這個(gè)key在失效的瞬間,持續(xù)的并發(fā)到來就會(huì)穿破緩存,直接請求到數(shù)據(jù)庫,就像一個(gè)完好無損的桶上鑿開了一個(gè)洞,造成某一時(shí)刻數(shù)據(jù)庫請求量過大,壓力劇增!
如何解決
- 方法一
我們簡單粗暴點(diǎn),直接讓熱點(diǎn)數(shù)據(jù)永遠(yuǎn)不過期,定時(shí)任務(wù)定期去刷新數(shù)據(jù)就可以了。不過這樣設(shè)置需要區(qū)分場景,比如某寶首頁可以這么做。
- 方法二
為了避免出現(xiàn)緩存擊穿的情況,我們可以在第一個(gè)請求去查詢數(shù)據(jù)庫的時(shí)候?qū)λ右粋€(gè)互斥鎖,其余的查詢請求都會(huì)被阻塞住,直到鎖被釋放,后面的線程進(jìn)來發(fā)現(xiàn)已經(jīng)有緩存了,就直接走緩存,從而保護(hù)數(shù)據(jù)庫。但是也是由于它會(huì)阻塞其他的線程,此時(shí)系統(tǒng)吞吐量會(huì)下降。需要結(jié)合實(shí)際的業(yè)務(wù)去考慮是否要這么做。
- 方法三
方法三就是singleflight的設(shè)計(jì)思路,也會(huì)使用互斥鎖,但是相對(duì)于方法二的加鎖粒度會(huì)更細(xì),這里先簡單總結(jié)一下singleflight的設(shè)計(jì)原理,后面看源碼在具體分析。
singleflightd的設(shè)計(jì)思路就是將一組相同的請求合并成一個(gè)請求,使用map存儲(chǔ),只會(huì)有一個(gè)請求到達(dá)mysql,使用sync.waitgroup包進(jìn)行同步,對(duì)所有的請求返回相同的結(jié)果。
截屏2021-07-14 下午8.30.56
源碼賞析
已經(jīng)迫不及待了,直奔主題吧,下面我們一起來看看singleflight是怎么設(shè)計(jì)的。
數(shù)據(jù)結(jié)構(gòu)
singleflight的結(jié)構(gòu)定義如下:
- type Group struct {
- mu sync.Mutex // 互斥鎖,保證并發(fā)安全
- m map[string]*call // 存儲(chǔ)相同的請求,key是相同的請求,value保存調(diào)用信息。
- }
Group結(jié)構(gòu)還是比較簡單的,只有兩個(gè)字段,m是一個(gè)map,key是相同請求的標(biāo)識(shí),value是用來保存調(diào)用信息,這個(gè)map是懶加載,其實(shí)就是在使用時(shí)才會(huì)初始化;mu是互斥鎖,用來保證m的并發(fā)安全。m存儲(chǔ)調(diào)用信息也是單獨(dú)封裝了一個(gè)結(jié)構(gòu):
- type call struct {
- wg sync.WaitGroup
- // 存儲(chǔ)返回值,在wg done之前只會(huì)寫入一次
- val interface{}
- // 存儲(chǔ)返回的錯(cuò)誤信息
- err error
- // 標(biāo)識(shí)別是否調(diào)用了Forgot方法
- forgotten bool
- // 統(tǒng)計(jì)相同請求的次數(shù),在wg done之前寫入
- dups int
- // 使用DoChan方法使用,用channel進(jìn)行通知
- chans []chan<- Result
- }
- // Dochan方法時(shí)使用
- type Result struct {
- Val interface{} // 存儲(chǔ)返回值
- Err error // 存儲(chǔ)返回的錯(cuò)誤信息
- Shared bool // 標(biāo)示結(jié)果是否是共享結(jié)果
- }
Do方法
- // 入?yún)ⅲ?/span>key:標(biāo)識(shí)相同請求,fn:要執(zhí)行的函數(shù)
- // 返回值:v: 返回結(jié)果 err: 執(zhí)行的函數(shù)錯(cuò)誤信息 shard: 是否是共享結(jié)果
- func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
- // 代碼塊加鎖
- g.mu.Lock()
- // map進(jìn)行懶加載
- if g.m == nil {
- // map初始化
- g.m = make(map[string]*call)
- }
- // 判斷是否有相同請求
- if c, ok := g.m[key]; ok {
- // 相同請求次數(shù)+1
- c.dups++
- // 解鎖就好了,只需要等待執(zhí)行結(jié)果了,不會(huì)有寫入操作了
- g.mu.Unlock()
- // 已有請求在執(zhí)行,只需要等待就好了
- c.wg.Wait()
- // 區(qū)分panic錯(cuò)誤和runtime錯(cuò)誤
- if e, ok := c.err.(*panicError); ok {
- panic(e)
- } else if c.err == errGoexit {
- runtime.Goexit()
- }
- return c.val, c.err, true
- }
- // 之前沒有這個(gè)請求,則需要new一個(gè)指針類型
- c := new(call)
- // sync.waitgroup的用法,只有一個(gè)請求運(yùn)行,其他請求等待,所以只需要add(1)
- c.wg.Add(1)
- // m賦值
- g.m[key] = c
- // 沒有寫入操作了,解鎖即可
- g.mu.Unlock()
- // 唯一的請求該去執(zhí)行函數(shù)了
- g.doCall(c, key, fn)
- return c.val, c.err, c.dups > 0
- }
這里是唯一有疑問的應(yīng)該是區(qū)分panic和runtime錯(cuò)誤部分吧,這個(gè)與下面的docall方法有關(guān)聯(lián),看完docall你就知道為什么了。
docall
- // doCall handles the single call for a key.
- func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
- // 標(biāo)識(shí)是否正常返回
- normalReturn := false
- // 標(biāo)識(shí)別是否發(fā)生panic
- recovered := false
- defer func() {
- // 通過這個(gè)來判斷是否是runtime導(dǎo)致直接退出了
- if !normalReturn && !recovered {
- // 返回runtime錯(cuò)誤信息
- c.err = errGoexit
- }
- c.wg.Done()
- g.mu.Lock()
- defer g.mu.Unlock()
- // 防止重復(fù)刪除key
- if !c.forgotten {
- delete(g.m, key)
- }
- // 檢測是否出現(xiàn)了panic錯(cuò)誤
- if e, ok := c.err.(*panicError); ok {
- // 如果是調(diào)用了dochan方法,為了channel避免死鎖,這個(gè)panic要直接拋出去,不能recover住,要不就隱藏錯(cuò)誤了
- if len(c.chans) > 0 {
- go panic(e) // 開一個(gè)寫成panic
- select {} // 保持住這個(gè)goroutine,這樣可以將panic寫入crash dump
- } else {
- panic(e)
- }
- } else if c.err == errGoexit {
- // runtime錯(cuò)誤不需要做任何時(shí),已經(jīng)退出了
- } else {
- // 正常返回的話直接向channel寫入數(shù)據(jù)就可以了
- for _, ch := range c.chans {
- ch <- Result{c.val, c.err, c.dups > 0}
- }
- }
- }()
- // 使用匿名函數(shù)目的是recover住panic,返回信息給上層
- func() {
- defer func() {
- if !normalReturn {
- // 發(fā)生了panic,我們r(jià)ecover住,然后把錯(cuò)誤信息返回給上層
- if r := recover(); r != nil {
- c.err = newPanicError(r)
- }
- }
- }()
- // 執(zhí)行函數(shù)
- c.val, c.err = fn()
- // fn沒有發(fā)生panic
- normalReturn = true
- }()
- // 判斷執(zhí)行函數(shù)是否發(fā)生panic
- if !normalReturn {
- recovered = true
- }
- }
這里來簡單描述一下為什么區(qū)分panic和runtime錯(cuò)誤,不區(qū)分的情況下如果調(diào)用出現(xiàn)了恐慌,但是鎖沒有被釋放,會(huì)導(dǎo)致使用相同key的所有后續(xù)調(diào)用都出現(xiàn)了死鎖,具體可以查看這個(gè)issue:https://github.com/golang/go/issues/33519。
Dochan和Forget方法
- //異步返回
- // 入?yún)?shù):key:標(biāo)識(shí)相同請求,fn:要執(zhí)行的函數(shù)
- // 出參數(shù):<- chan 等待接收結(jié)果的channel
- func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result {
- // 初始化channel
- ch := make(chan Result, 1)
- g.mu.Lock()
- // 懶加載
- if g.m == nil {
- g.m = make(map[string]*call)
- }
- // 判斷是否有相同的請求
- if c, ok := g.m[key]; ok {
- //相同請求數(shù)量+1
- c.dups++
- // 添加等待的chan
- 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()
- // 開一個(gè)寫成調(diào)用
- go g.doCall(c, key, fn)
- // 返回這個(gè)channel等待接收數(shù)據(jù)
- return ch
- }
- // 釋放某個(gè) key 下次調(diào)用就不會(huì)阻塞等待了
- func (g *Group) Forget(key string) {
- g.mu.Lock()
- if c, ok := g.m[key]; ok {
- c.forgotten = true
- }
- delete(g.m, key)
- g.mu.Unlock()
- }
注意事項(xiàng)
因?yàn)槲覀冊谑褂胹ingleflight時(shí)需要自己寫執(zhí)行函數(shù),所以如果我們寫的執(zhí)行函數(shù)一直循環(huán)住了,就會(huì)導(dǎo)致我們的整個(gè)程序處于循環(huán)的狀態(tài),積累越來越多的請求,所以在使用時(shí),還是要注意一點(diǎn)的,比如這個(gè)例子:
- result, err, _ := d.singleGroup.Do(key, func() (interface{}, error) {
- for{
- // TODO
- }
- }
不過這個(gè)問題一般也不會(huì)發(fā)生,我們在日常開發(fā)中都會(huì)使用context控制超時(shí)。
總結(jié)
好啦,這篇文章就到這里啦。因?yàn)樽罱以陧?xiàng)目中也使用singleflight這個(gè)庫,所以就看了一下源碼實(shí)現(xiàn),真的是厲害,這么短的代碼就實(shí)現(xiàn)了這么重要的功能,我怎么就想不到呢。。。。所以說還是要多讀一些源碼庫,真的能學(xué)到好多,真是應(yīng)了那句話:你知道的越多,不知道的就越多!