微服務(wù)前端數(shù)據(jù)加載的優(yōu)秀實(shí)踐
目前在不少團(tuán)隊(duì)里已經(jīng)逐步實(shí)踐落地了微服務(wù)架構(gòu),比如前端圈很流行的 BFF(Backend For Frontend)其實(shí)就是微服務(wù)架構(gòu)的一種變種,即讓前端團(tuán)隊(duì)維護(hù)一套“膠水層/接入層/API層”的服務(wù),調(diào)用后臺(tái)團(tuán)隊(duì)提供的若干個(gè)微服務(wù),將微服務(wù)的結(jié)果進(jìn)行邏輯組裝,從而包裝出對(duì)外的 API。
在這種架構(gòu)下,服務(wù)在大體上可以分為兩種角色:
- 前端服務(wù)(Frontend),包裝底層的微服務(wù),對(duì)外直接暴露可調(diào)用的 API。例如在 BFF 架構(gòu)里,很可能就是一個(gè) Node.js 寫成的 HTTP Server。
- 后臺(tái)微服務(wù)(Microservices),通常由后端團(tuán)隊(duì)提供的單體服務(wù),承載不同模塊的功能,提供一系列的內(nèi)部調(diào)用接口。
這篇文章主要分享這種架構(gòu)下,前端服務(wù)進(jìn)行數(shù)據(jù)加載的幾種最佳實(shí)踐。
最簡(jiǎn)單的情形
我們先考慮一種最簡(jiǎn)單的情形,也就是每當(dāng)有外部請(qǐng)求進(jìn)來(lái),那么前端服務(wù)都會(huì)向若干個(gè)后臺(tái)微服務(wù)請(qǐng)求數(shù)據(jù),然后進(jìn)行邏輯處理,返回響應(yīng):
這種樸素的模型明顯存在一個(gè)問(wèn)題:每個(gè)外部請(qǐng)求都會(huì)觸發(fā)多次內(nèi)部服務(wù)調(diào)用,這樣的做法非常浪費(fèi)資源,因?yàn)閷?duì)于大多數(shù)內(nèi)部微服務(wù)而言,請(qǐng)求的結(jié)果在一定的時(shí)間內(nèi)都是 可緩存 的。
比如用戶的頭像可能幾天幾周甚至幾個(gè)月才更新一次,這種情況下前端服務(wù)完全可以緩存用戶的頭像一段時(shí)間,這段時(shí)間內(nèi),讀取用戶頭像可以從直接從緩存內(nèi)讀取,而不需要請(qǐng)求后臺(tái),很大程度上節(jié)省了后臺(tái)服務(wù)的負(fù)擔(dān)。
加入本地緩存
于是我們?cè)谇岸朔?wù)中加入了本地內(nèi)存緩存(Local Cache),讓大多數(shù)請(qǐng)求都命中本地緩存,從而減少了后臺(tái)服務(wù)的負(fù)擔(dān):
本地緩存通常是放置在內(nèi)存里的,而內(nèi)存空間比較有限,所以我們需要引入 緩存淘汰 的機(jī)制,限制內(nèi)存最大容量。具體的緩存淘汰算法就有很多了,比如 FIFO、LFU、LRU、MRU、ARC 等等,可以根據(jù)業(yè)務(wù)的實(shí)際情況來(lái)選擇合適的算法。
引入本地緩存之后,依然會(huì)有一個(gè)問(wèn)題:緩存只能在單個(gè)服務(wù)實(shí)例(服務(wù)實(shí)例可以理解為服務(wù)器、K8S Pod之類的概念)上生效,而大多數(shù)前端服務(wù)為了能夠橫向擴(kuò)容,一般都是無(wú)狀態(tài)的,所以會(huì)有大量并存的實(shí)例。
也就是說(shuō),本地緩存可能只會(huì)在某臺(tái)服務(wù)器上生效,而其他平行的服務(wù)器上沒(méi)有緩存,如果請(qǐng)求打到了沒(méi)有緩存的服務(wù)器上,那么依然無(wú)法命中緩存。
另外一個(gè)問(wèn)題就是,緩存邏輯和應(yīng)用邏輯是耦合的,每一個(gè)接口的代碼里可能都會(huì)存在類似這樣的邏輯:
- var cachedData = cache.get(key)
- if (cachedData !== undefined) {
- return cachedData
- } else {
- return fetch('...')
- }
Don't Repeat Yourself! 我們顯然需要把這段緩存邏輯抽象出來(lái),避免重復(fù)代碼。
加入 Cache 層和中心化緩存
為了解決上面兩個(gè)問(wèn)題,我們繼續(xù)改進(jìn)我們的架構(gòu):
加入 中心化的遠(yuǎn)程緩存 (比如 Redis、Memcache),讓遠(yuǎn)程緩存可以作用到所有實(shí)例上面;
將緩存、RPC 等非應(yīng)用層的邏輯 抽象為單獨(dú)的組件(Cache Layer) ,用來(lái)封裝后臺(tái)微服務(wù)的讀寫、本地緩存、遠(yuǎn)程緩存相關(guān)的邏輯。
抽象出這樣一層 Cache Layer 之后,我們便可以進(jìn)一步演進(jìn)我們的服務(wù)。
加入緩存刷新機(jī)制
雖然我們有了中心化的緩存,但緩存畢竟只是短期內(nèi)有效的。一旦緩存失效,那就還是得向后臺(tái)服務(wù)請(qǐng)求數(shù)據(jù),在這種臨界條件下,請(qǐng)求耗時(shí)就會(huì)增加,出現(xiàn)耗時(shí)的毛刺現(xiàn)象(每隔一段時(shí)間,有小部分請(qǐng)求耗時(shí)變大)。
那么 有沒(méi)有辦法可以讓緩存一直保持“新鮮”呢 ?這就需要緩存刷新的機(jī)制了,大體上講,緩存刷新分為主動(dòng)刷新和被動(dòng)刷新兩種:
主動(dòng)刷新
主動(dòng)刷新即每當(dāng)數(shù)據(jù)有更新的時(shí)候,刷新緩存,下游服務(wù)永遠(yuǎn)只讀取緩存內(nèi)的數(shù)據(jù)。
讀多寫少的后臺(tái)服務(wù)非常適合這種模式,因?yàn)樽x請(qǐng)求永遠(yuǎn)不會(huì)打到數(shù)據(jù)庫(kù)里,而是被分流到性能、擴(kuò)展性高幾個(gè)檔次的緩存組件上面,從而很大程度上減輕數(shù)據(jù)庫(kù)的壓力。
當(dāng)然主動(dòng)刷新也并不是完美無(wú)缺的,它意味著 前后端服務(wù)必須要在緩存組件上產(chǎn)生耦合 (比如需要約定緩存 key 的命名、數(shù)據(jù)結(jié)構(gòu)等),這就帶來(lái)了一定的隱患,一旦后端微服務(wù)錯(cuò)誤地寫入了緩存,或者緩存組件出現(xiàn)可用性問(wèn)題,結(jié)果很可能是災(zāi)難性的。所以這種模式更適合單個(gè)服務(wù)內(nèi)部,而不是多個(gè)服務(wù)之間。
被動(dòng)刷新
被動(dòng)刷新即讀取緩存數(shù)據(jù)的時(shí)候,根據(jù)緩存的剩余有效期或者類似指標(biāo),決定要不要異步刷新緩存(類似 HTTP 協(xié)議的 stale-while-revalidate )。
這種模式相比于主動(dòng)刷新,優(yōu)點(diǎn)是服務(wù)間的耦合更少一些,但缺點(diǎn)在于 1. 只能根據(jù)訪問(wèn)熱點(diǎn)進(jìn)行緩存,無(wú)法全量緩存;2. 只能根據(jù)相關(guān)指標(biāo)被動(dòng)刷新,降低了數(shù)據(jù)的即時(shí)性。
如果團(tuán)隊(duì)的前端服務(wù)(如 BFF)和后臺(tái)服務(wù)是由兩套人員開(kāi)發(fā)維護(hù),比較適合使用這樣的緩存模式。當(dāng)然具體選擇哪種模式,得根據(jù)實(shí)際情況來(lái)決定。
緩存是一個(gè)非常靈活并且萬(wàn)金油的組件,這里篇幅有限就不再深入,更多關(guān)于緩存的設(shè)計(jì)模式,可以參考這里:
donnemartin/system-design-primer github.com
請(qǐng)求收斂
對(duì)于大流量的業(yè)務(wù)而言,可能同時(shí)會(huì)有成百上千的請(qǐng)求打到同一個(gè)前端服務(wù)實(shí)例上,這些請(qǐng)求會(huì)觸發(fā)大量的對(duì)緩存、后臺(tái)服務(wù)的讀請(qǐng)求,大多數(shù)情況下,這些并發(fā)的讀請(qǐng)求是可以 收歸為少數(shù)幾個(gè)請(qǐng)求 的。
這種思路和 Facebook 開(kāi)源的dataloader 非常相似,將并行的、參數(shù)相同的請(qǐng)求收歸到一起,從而降低后端服務(wù)的壓力(在 GraphQL 的使用場(chǎng)景下很容易出現(xiàn)這種問(wèn)題)。
容災(zāi)緩存
我們不妨考慮一種極端的情況:如果后臺(tái)服務(wù)全掛了,前端服務(wù)能不能使用緩存里的來(lái)“撐住”一段時(shí)間?這就是容災(zāi)緩存的概念,即 在服務(wù)異常的時(shí)候,降級(jí)到使用緩存中的數(shù)據(jù)來(lái)響應(yīng)外部請(qǐng)求,保證一定的可用性 。容災(zāi)緩存的邏輯,同樣可以抽象到 Cache Layer 中。