基于context.Context的Golang?loader緩存請(qǐng)求放大問題解決
請(qǐng)求放大的問題
同一請(qǐng)求鏈路中對(duì)下游的請(qǐng)求放大是現(xiàn)代微服務(wù)體系中經(jīng)常遇到的痛點(diǎn)。
舉個(gè)例子:某個(gè)業(yè)務(wù)流程中,需要獲取用戶的積分余額,從而進(jìn)行后續(xù)判斷。但這個(gè)【請(qǐng)求余額】的行為,不僅僅在某個(gè)場(chǎng)景需要使用,而是在整個(gè)請(qǐng)求的生命周期,多處邏輯都可能需要,甚至負(fù)責(zé)開發(fā)的都不是同一個(gè)人。這個(gè)時(shí)候就很容易出問題了。小 A 在入口處就請(qǐng)求了余額,但只放在了自己的業(yè)務(wù)結(jié)構(gòu)中。隨后小 B 也需要,又請(qǐng)求了一次余額。這就出現(xiàn)了請(qǐng)求放大。
為什么需要考慮這個(gè)問題?
放大可不一定只有 2 倍,事實(shí)上,復(fù)雜的業(yè)務(wù)鏈路如果不仔細(xì)思考,調(diào)整,最終出現(xiàn) 4 - 5 次請(qǐng)求放大都是很常見的;
下游的服務(wù)的負(fù)載是需要考量的,明明一次請(qǐng)求就可以拿到的數(shù)據(jù),你請(qǐng)求了多次,下游可能會(huì)被打掛,哪怕可以承受,也額外付出了更多的 CPU,通信成本;
通常出現(xiàn)放大時(shí),各個(gè)業(yè)務(wù)的處理邏輯是獨(dú)立的,也就意味著,一旦微服務(wù)不穩(wěn)定,后續(xù)請(qǐng)求網(wǎng)絡(luò)超時(shí),你可能會(huì)因?yàn)橐粋€(gè)明明此前已經(jīng)拿到的數(shù)據(jù),而導(dǎo)致整個(gè)鏈路返回了失敗。
所以,我們需要嚴(yán)肅地看待這件事。目標(biāo)其實(shí)很明確:
- 只拿需要的數(shù)據(jù);
- 不重復(fù)拿同一份數(shù)據(jù)(如果數(shù)據(jù)可能會(huì)變,可以考慮放大,這不是絕對(duì)的);
- 處理好強(qiáng)弱依賴,不因?yàn)橐粋€(gè)明明可以接受,降級(jí)的失敗請(qǐng)求,導(dǎo)致整個(gè)處理流程中斷。
那我們?cè)趺床拍鼙WC一個(gè)請(qǐng)求處理過(guò)程中,不去重復(fù)請(qǐng)求下游呢?我只是其中一環(huán),怎么知道此前流程里是不是已經(jīng)拿過(guò)數(shù)據(jù)了呢?就算知道,人家都放到了自己業(yè)務(wù)的結(jié)構(gòu)體里,我怎么用?
中間件能解決么?
這里常見的思路是使用【接口中間件】,即:把一些通用的 loader 放到 middleware 中,比如請(qǐng)求用戶信息,租戶信息,鑒權(quán)等。我們這里舉的例子也可以這么處理。
接口中間件里我就把余額拿到,隨后作為一個(gè)公共的結(jié)構(gòu)體,一路透?jìng)?。類似這樣:
type BizContext struct { Ctx context.Context UserInfo TenantInfo UserBalance } func ExecuteLogic(bc *BizContext, param interface{}) error { // TODO:業(yè)務(wù)邏輯 }
這樣,大家通過(guò) BizContext 就能獲取到這些公共數(shù)據(jù)了。不需要重復(fù)請(qǐng)求。Problem solved!
但這個(gè)思路存在一個(gè)致命傷(并不是 struct 內(nèi)嵌 context.Context,你段位到了就可以這么用,背景參照我們此前的文章Golang context.Context 原理,實(shí)戰(zhàn)用法,問題 )。
問題在于,所有放到中間件里的 loader 邏輯,都是對(duì)整個(gè)接口的請(qǐng)求消耗。的確,我們可能在場(chǎng)景 A,D,F(xiàn) 要用到這個(gè) UserBalance,但場(chǎng)景 B,C 呢?人家是不是白白的承擔(dān)了這種性能消耗,又沒有任何收益?
所有中間件里的邏輯一定是通用的,高性能的,具有普適性的。注定沒法覆蓋到所有業(yè)務(wù)場(chǎng)景。
一定不要濫用中間件,塞入大量個(gè)別場(chǎng)景需要的邏輯。中間件越重,接口性能就越不可控。
基于 context.Context 的解決方案
我們知道,context.Context 提供了 WithValue 函數(shù),支持將一些常見的上下文信息通過(guò)這個(gè)函數(shù)寫入 ctx。本質(zhì)是用 valueCtx 基于 parent Context 派生出來(lái)一個(gè) child Context,形成了一條鏈。獲取 value 的時(shí)候是逆序的。
type BizContext struct { Ctx context.Context UserInfo TenantInfo UserBalance } func ExecuteLogic(bc *BizContext, param interface{}) error { // TODO:業(yè)務(wù)邏輯 }
我們可以利用這個(gè)能力,把請(qǐng)求結(jié)果 cache 到 context.Context 中,這樣就可以隨后復(fù)用了。但這樣本質(zhì)上和此前 BizContext 是一樣的,都是需要一個(gè)鏈路上都能獲取到的結(jié)構(gòu)體。
loader 是一個(gè)數(shù)據(jù)加載器,下游可能是某個(gè)存儲(chǔ),或是微服務(wù)。每個(gè)業(yè)務(wù)場(chǎng)景可能包含自己對(duì)應(yīng)的 loader。
我們希望這個(gè) loader cache 要具備下面的能力:
- 適配任何數(shù)據(jù)加載器,和具體業(yè)務(wù)的架構(gòu)不強(qiáng)綁定;
- 按需加載,業(yè)務(wù)可以自行指定是否需要啟用 cache 能力,默認(rèn)直接走 loader;
- 高性能,不要帶來(lái)過(guò)高的性能消耗。
loader 定義
鑒于要實(shí)現(xiàn)一個(gè)通用的數(shù)據(jù) loader,我們不希望和特定結(jié)構(gòu)綁定,所以勢(shì)必要返回 interface{},同時(shí)入?yún)⒔唤o業(yè)務(wù)自行判斷,通用定義里我們不做要求:
type loadFunc func(context.Context) (interface{}, error)
存儲(chǔ)結(jié)構(gòu)
我們希望往 Context 里面放什么數(shù)據(jù),這一點(diǎn)很關(guān)鍵。鑒于我們希望支持多個(gè)業(yè)務(wù)場(chǎng)景,勢(shì)必會(huì)需要一個(gè) map 結(jié)構(gòu),key 對(duì)應(yīng)場(chǎng)景,value 是緩存的值。
同時(shí),鑒于 Context 本身是支持并發(fā)的,而且整個(gè) loader cache 會(huì)作為基礎(chǔ)的能力提供出來(lái),我們希望這里的 map 也能在高并發(fā)下正常讀寫,所以回到了經(jīng)典的選型:
- map + Mutex
- map + RWMutex
- sync.Map
選項(xiàng)一的鎖粒度比較粗,性能上會(huì)差一些。而 sync.Map 的 LoadOrStore 方法參數(shù)會(huì)逃逸到heap上,所以我們選擇 map + RWMutex,手動(dòng)來(lái)控制讀寫鎖。
type callCache struct { m map[string]*cacheItem lock sync.RWMutex }
callCache 本身是外層的結(jié)構(gòu)。我們從 Value(key interface{}) interface{}
接口就可以讀到。
這里 cacheItem 里面放什么,很關(guān)鍵!
- 是不是直接就一個(gè) interface{} 就可以了?
非也!如果我們完全不感知 cacheItem 的結(jié)構(gòu),會(huì)導(dǎo)致我們無(wú)法感知到這里到底是否已經(jīng)調(diào)用過(guò) loader 拉取數(shù)據(jù)。即便可以置為 nil,但實(shí)際上 loader 也可能加載后發(fā)現(xiàn)沒有數(shù)據(jù),這一點(diǎn)不可行。
要實(shí)現(xiàn)只有一次調(diào)用 loader,后續(xù)調(diào)用都能復(fù)用結(jié)構(gòu)。cacheItem 需要包含一個(gè) sync.Once。
- 錯(cuò)誤如何感知?
我們對(duì)于每個(gè)場(chǎng)景,唯一能感知到的就是 cacheItem,所以除了正常的業(yè)務(wù)數(shù)據(jù),這里還需要有錯(cuò)誤信息。否則 loader 調(diào)用出錯(cuò)了都沒法給上游返回錯(cuò)誤。
綜上兩點(diǎn),一個(gè)可能的結(jié)構(gòu)如下:
type cacheItem struct { ret interface{} err error once sync.Once }
這樣我們就可以利用 sync.Once 的能力來(lái)控制,調(diào)用 loader 拿到結(jié)果和 error
func (ci *cacheItem) doOnce(ctx context.Context, loader loadFunc) { ci.once.Do(func() { ci.ret, ci.err = loader(ctx) }) }
sync.Once 保證了某個(gè) goroutine 進(jìn)入 Do
方法后,其他協(xié)程會(huì)阻塞等待。所以,我們可以假設(shè),在 *cacheItem.doOnce
結(jié)束后,如果訪問 *cacheItem 是能夠拿到 ret 和 err 的最新值的。
好了,現(xiàn)在有了 cacheItem 的定義和 doOnce 能力,我們回到 callCache,完成調(diào)度邏輯:
type callCache struct { m map[string]*cacheItem // sync.Map的LoadOrStore方法的參數(shù)會(huì)逃逸到heap上,這里用map+rwmutex lock sync.RWMutex }
我們從 Context 直接獲取的結(jié)構(gòu)是 callCache,那么當(dāng)某個(gè)場(chǎng)景的 key 首次請(qǐng)求的時(shí)候,勢(shì)必需要對(duì) cacheItem 進(jìn)行初始化。
這個(gè)函數(shù): func (cache *callCache) getOrCreateCacheItem(key string) *cacheItem
,如何實(shí)現(xiàn),這里很關(guān)鍵!
- 既然用了 RWMutex,我們希望把讀寫粒度拆開,所以一上來(lái)應(yīng)該判斷讀鎖,如果有值,直接返回;
- 如果在讀鎖里沒獲取到,說(shuō)明需要初始化,開始加寫鎖;
- 在寫鎖中,完成初始化,寫入 callCache,并返回,defer 解掉寫鎖。
func (cache *callCache) getOrCreateCacheItem(key string) *cacheItem { cache.lock.RLock() cr, ok := cache.m[key] cache.lock.RUnlock() if ok { return cr } cache.lock.Lock() defer cache.lock.Unlock() if cache.m == nil { cache.m = make(map[string]*cacheItem) } else { cr, ok = cache.m[key] } if !ok { cr = &cacheItem{} cache.m[key] = cr } return cr }
SDK 接口
好了,現(xiàn)在我們已經(jīng)具備底層能力了,思考一下我們希望開發(fā)者怎么用這個(gè) lib。
WithCallCache
首先,ctx cache 不應(yīng)該是默認(rèn)啟用的,有可能業(yè)務(wù)就是需要有一些放大,這里需要開發(fā)者通過(guò) SDK 接口顯式聲明。
此外,既然要往 Context 里面放,一定需要一個(gè)自己的 key,這里我們采用空結(jié)構(gòu)體,用來(lái)與其他類型區(qū)分開。這也是經(jīng)典的操作。
type keyType struct{} var callCacheKey keyType // WithCallCache 返回支持調(diào)用緩存的context func WithCallCache(parent context.Context) context.Context { if parent.Value(callCacheKey) != nil { return parent } return context.WithValue(parent, callCacheKey, new(callCache)) }
LoadFromCtxCache
這里是最核心的接口。我們需要支持開發(fā)者傳進(jìn)來(lái):1.業(yè)務(wù)場(chǎng)景;2.業(yè)務(wù)對(duì)應(yīng)的 loader。
如果此前通過(guò) WithCallCache
啟用了 ctx cache,我們就看看業(yè)務(wù)的 loader 此前有沒有執(zhí)行過(guò),如果有,直接返回 ctx 中緩存的結(jié)果。如果從未執(zhí)行過(guò),調(diào)用此前的 cacheItem.doOnce 來(lái)執(zhí)行。
// LoadFromCtxCache 從ctx中嘗試獲取key的緩存結(jié)果 // 如果不存在,調(diào)用loader;如果沒有開啟緩存,直接調(diào)用loader func LoadFromCtxCache(ctx context.Context, key string, loader loadFunc) (interface{}, error) { var cacheItem *cacheItem v := ctx.Value(callCacheKey) if v == nil { cacheItem = nil } else { cacheItem = v.(*callCache).getOrCreateCacheItem(key) } // cache not enabled if cacheItem == nil { return loader(ctx) } // now that all routines hold references to the same cacheItem cacheItem.doOnce(ctx, loader) return cacheItem.ret, cacheItem.err }
使用方法
- 使用
WithCallCache
針對(duì)當(dāng)前的 ctx 啟用 loader cache; - 改造數(shù)據(jù)加載邏輯,抽出來(lái) loader,外層用
LoadFromCtxCache
來(lái)調(diào)用,以達(dá)到上游無(wú)感。
假設(shè)我們的 loader 是 myloader
,接受一個(gè) string,返回 int 和 error,下面看一下示例:
使用起來(lái)其實(shí)非常簡(jiǎn)單,只需要大家封裝一下自己的數(shù)據(jù)加載邏輯即可。
源碼倉(cāng)庫(kù):go-ctxcache,感興趣的同學(xué)可以試一下,整體代碼量很小,實(shí)用性很強(qiáng)。
以上就是 context.Context 的 Golang loader 緩存請(qǐng)求放大問題解決的詳細(xì)內(nèi)容,更多關(guān)于Golang loader 緩存的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
golang框架gin的日志處理和zap lumberjack日志使用方式
這篇文章主要介紹了golang框架gin的日志處理和zap lumberjack日志使用方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2024-01-01Golang利用Template模板動(dòng)態(tài)生成文本
Go語(yǔ)言中的Go?Template是一種用于生成文本輸出的簡(jiǎn)單而強(qiáng)大的模板引擎,它提供了一種靈活的方式來(lái)生成各種格式的文本,下面我們就來(lái)看看具體如何使用Template實(shí)現(xiàn)動(dòng)態(tài)文本生成吧2023-09-09深入探討Go語(yǔ)言中的map是否是并發(fā)安全以及解決方法
這篇文章主要來(lái)和大家探討?Go?語(yǔ)言中的?map?是否是并發(fā)安全的,并提供三種方案來(lái)解決并發(fā)問題,文中的示例代碼講解詳細(xì),需要的可以參考一下2023-05-05如何使用go實(shí)現(xiàn)創(chuàng)建WebSocket服務(wù)器
文章介紹了如何使用Go語(yǔ)言和gorilla/websocket庫(kù)創(chuàng)建一個(gè)簡(jiǎn)單的WebSocket服務(wù)器,并實(shí)現(xiàn)商品信息的實(shí)時(shí)廣播,感興趣的朋友一起看看吧2024-11-11Golang優(yōu)雅關(guān)閉channel的方法示例
Goroutine和channel是Go在“并發(fā)”方面兩個(gè)核心feature,下面這篇文章主要給大家介紹了關(guān)于Golang如何優(yōu)雅關(guān)閉channel的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),需要的朋友可以參考解決,下面來(lái)一起看看吧。2017-11-11Gin golang web開發(fā)模型綁定實(shí)現(xiàn)過(guò)程解析
這篇文章主要介紹了Gin golang web開發(fā)模型綁定實(shí)現(xiàn)過(guò)程解析,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-10-10