騰訊云十億級 Node.js 網(wǎng)關(guān)的架構(gòu)設(shè)計與工程實踐

本文由 InfoQ 整理自騰訊云 CloudBase 前端負(fù)責(zé)人王偉嘉在 GMTC 全球大前端技術(shù)大會(深圳站)2021 上的演講《十億級 Node.js 網(wǎng)關(guān)的架構(gòu)設(shè)計與工程實踐》。
大廠技術(shù)??高級前端??Node進(jìn)階
點(diǎn)擊上方?程序員成長指北,關(guān)注公眾號
回復(fù)1,加入高級Node交流群
大家好,我今天的演講主題主要是講我們業(yè)務(wù)上用 Node.js 寫的一個網(wǎng)關(guān)。先做個簡單的自我介紹,我叫王偉嘉,現(xiàn)在是騰訊云云開發(fā) CloudBase 的前端負(fù)責(zé)人,說 CloudBase 可能很多人不太知道,但是我們旗下其實有挺多產(chǎn)品的,可能或多或少聽說過,比如說小程序·云開發(fā),寫小程序的同學(xué)應(yīng)該會知道吧。
當(dāng)然我今天不是來推銷產(chǎn)品的,今天是開門見山的講一講網(wǎng)關(guān)是一個怎么樣的組件,網(wǎng)關(guān)在做什么事情。網(wǎng)關(guān)這個詞其實到處都在用,它可以工作在一個硬件的層面,可以工作在網(wǎng)絡(luò)層,也可以工作在應(yīng)用層。
網(wǎng)關(guān)在做什么?
我們今天講的實際上是一個工作在 HTTP 七層協(xié)議的網(wǎng)關(guān),它主要做的有幾件事情:
第一,公網(wǎng)入口。它作為我們公有云服務(wù)的一個入口,可以把公有云過來的請求定向到用戶的資源上面去。
第二,對接后端資源。我們云開發(fā)有很多內(nèi)部的資源,像云函數(shù)、容器引擎這樣的資源,便可以把請求對接到這樣的云資源上面去。
第三,身份鑒權(quán)。云開發(fā)有自己的一套賬號身份體系,請求里如果是帶有身份信息的,那么網(wǎng)關(guān)會對身份進(jìn)行鑒權(quán)。
所以網(wǎng)關(guān)這個東西聽起來好像是很底層的一個組件,大家可能會覺得很復(fù)雜,實際上并沒有。我們就花幾行代碼,就可以實現(xiàn)一個非常簡單的 HTTP 網(wǎng)關(guān)的邏輯。
import express from 'express'import { requestUpstream, resolveUpstream } from './upstream'const app = express()app.all('*', (req, res) => {console.log(req.method, req.path, req.headers)const upstream = await resolveUpstream(req.method, req.path, req.headers)const response = await requestUpstream(upstream, req.body)console.log(response.statusCode, response.headers)res.send(response)})const port = 3000app.listen(port, () => {console.log(`App listening at ${port}`)})
這段示例代碼在做的事情很簡單,即我們收到一個請求之后,會根據(jù)請求的方法或者路徑進(jìn)行解析,找出它的上游是什么,然后再去請求上游,這樣就完成一個網(wǎng)關(guān)的邏輯。
當(dāng)然這是最簡的一個代碼了,實際上里面有很多東西是沒有考慮到的,比如技術(shù)框架以及內(nèi)部架構(gòu)模塊的治理,比如性能優(yōu)化、海量的日志系統(tǒng)、高可用保障、DevOps 等等。當(dāng)然這樣展開就非常大了,所以我今天也不會面面俱到,會選其中幾個方向來講的比較深一點(diǎn),這樣我覺得會對大家比較有收獲。
云開發(fā) CloudBase(TCB) 是個啥?
說到這個,順便介紹一下我們云開發(fā) CloudBase 是什么,要介紹我們網(wǎng)關(guān)肯定要知道我們業(yè)務(wù),像小程序·云開發(fā)、Web 應(yīng)用托管、微搭低代碼平臺,還有微信云托管這樣的服務(wù)都是在我們體系內(nèi)的。

這些服務(wù)它的資源都會過我們的網(wǎng)關(guān)來進(jìn)行鑒權(quán),你可以在云開發(fā)體系下的控制臺上,看到我們 URL 的入口,實際上這些 URL 它的背后就是我們的網(wǎng)關(guān)。

整個網(wǎng)關(guān)最簡版的一個架構(gòu)如上圖所示,我們會給用戶免費(fèi)提供一個公網(wǎng)的默認(rèn)域名,這個域名它背后實際上是一套 CDN 的分發(fā)網(wǎng)絡(luò),然后 CDN 回源到核心網(wǎng)關(guān)上面來。我們網(wǎng)關(guān)本身是無狀態(tài)的服務(wù),收到請求之后,它需要知道如何把請求分發(fā)到后端的云資源上去,所以有一個旁路的后端服務(wù)可以讀取這樣一套數(shù)據(jù)。
網(wǎng)關(guān)的后面就是用戶自己的云上資源了,你在云開發(fā)用到任何資源幾乎都可以通過這樣的鏈路來進(jìn)行訪問。

網(wǎng)關(guān)內(nèi)部是基于 Nest.js 來做的,選 Nest.js 是因為它本身自帶一套設(shè)計模式,很 Spring 那一套,更多的是做 IOC 容器這一套設(shè)計模式。
從上圖可以看到,我們把它的內(nèi)部架構(gòu)分成了兩層,一層是 Controller,一層是 Service。Controller 主要是控制各種訪問資源的邏輯。比如說你去訪問一個云函數(shù)(SCF),和你去訪問一個靜態(tài)托管的資源,它所需要的訪問信息肯定是不一樣的,所以這也就是分成了幾種 Controller 來實現(xiàn)。
底層的話 Service 這一層是非?!昂瘛钡?,Service 內(nèi)部又分成邏輯模塊和功能性模塊。
首先第一大塊是我們的邏輯模塊,邏輯模塊主要是處理我們內(nèi)部服務(wù)模塊的很多東西,最上面這一層主要就是處理跟資源訪問相關(guān)的一些請求的邏輯,跟各種資源使用不同的協(xié)議、方法來對接。然后中間這一層,更多的是做我們內(nèi)部的一些集群的邏輯。比如集群管理,作為一個公有云的服務(wù),我們對于客戶也是會分等級的,像 VIP 客戶可能就需要最后來發(fā)布,我們肯定是先驗證一些灰度的流量,像這塊邏輯就屬于中間這一層來管理。最下面這一層就會有各種負(fù)責(zé) I/O 的 Client,我這一次只畫了一個 HTTP 的 Client,實際上還會有一些別的 Client。
除此之外還有一些旁路的功能性模塊,包括像怎么打日志、配置、本地緩存的管理、錯誤處理,還有本地配置管理 DNS、調(diào)用鏈追蹤等這些旁路的服務(wù)。
這一套設(shè)計其實就是老生常談的高內(nèi)聚低耦合,業(yè)務(wù)邏輯和真正的 I/O 實現(xiàn)要解耦開。因為只有解耦開,你才能夠針對你的業(yè)務(wù)邏輯進(jìn)行單元化的測試,可以很方便的把它底層的這種 I/O 讀寫邏輯給 Mook 起來,保證核心業(yè)務(wù)邏輯模塊的可測試性。

上圖是網(wǎng)關(guān)整體的鏈路架構(gòu),稍微更全面一點(diǎn)。最上層是分布在邊緣的 CDN 節(jié)點(diǎn),然后這些 CDN 節(jié)點(diǎn)會回源到我們部署在各地的集群,然后這些集群它又可以訪問后面不同區(qū)域的資源,因為公有云它的資源其實也是按區(qū)域來劃分的,所以這就講到了我們網(wǎng)關(guān)的兩個核心的要求,“快”和“穩(wěn)”。
首先作為一個網(wǎng)關(guān)肯定是要快的,因為網(wǎng)關(guān)作為 CloudBase 云函數(shù)、云托管、靜態(tài)資源的公網(wǎng)出口,性能要求極高,需要承接 C 端流量,應(yīng)對各種地域、各種設(shè)備的終端接入場景。如果說過你網(wǎng)關(guān)這一層就可能就花了幾百毫秒甚至一兩秒鐘的時間,對于客戶來講是不可接受的。因為客戶他自己的一個函數(shù)可能只跑了 20 毫秒,如果網(wǎng)關(guān)也引入 20 毫秒的延遲,對于客戶來講他就覺得你這條鏈路不行。
其次就是要穩(wěn),我們是大租戶模式,要扛住海量的 C 端請求,我們需要極高的可用性。作為數(shù)據(jù)面的核心組件,需要極高的可用性,任何故障將會直接影響下游客戶的業(yè)務(wù)穩(wěn)定性。如果在座的有四川或者云南的同學(xué),你回家每次打開健康碼掃碼,其實請求都會經(jīng)過我這個網(wǎng)關(guān)的。
所以今天主要就講兩個部分,既然是快和穩(wěn),分別對應(yīng)性能優(yōu)化和可用性,所以我現(xiàn)在從性能優(yōu)化開始講起。
網(wǎng)關(guān)性能優(yōu)化思路
性能優(yōu)化的思路,首先是看時間都花在哪個地方了,網(wǎng)關(guān)是一個網(wǎng)絡(luò)的組件,大部分時間都是耗在 I/O 上,而不是本身的計算,所以它是一個高 I/O、低計算的一個組件。
網(wǎng)關(guān)有幾個技術(shù)特點(diǎn):首先,它的自身業(yè)務(wù)邏輯多,是重 I/O、輕計算的一個組件;其次它的請求模式是比較固定的,模式固定我們可以理解為,你的一個客戶他發(fā)送過來的請求實際上就是那么幾種,它的路徑、包體大小、請求頭等這些都是比較趨向于固定的,很難會有一個客戶他的請求是完全隨機(jī)生成的,這對我們后面針對這種情況做緩存設(shè)計會有一些幫助。最后,網(wǎng)關(guān)的核心鏈路很長,涉及到多個網(wǎng)絡(luò)平面。
那么我們就找到了我們的一些優(yōu)化方向:減少整個 IO 的消耗,并且優(yōu)化核心鏈路。所以優(yōu)化部分我就分成了兩塊內(nèi)容來講,第一塊是網(wǎng)關(guān)自身核心服務(wù)優(yōu)化,第二塊是整體架構(gòu)鏈路優(yōu)化。
第一部分,核心的服務(wù)怎么做優(yōu)化?先提幾個方向。
第一,網(wǎng)關(guān)自身的業(yè)務(wù)邏輯很多,調(diào)用很多外部服務(wù),其中有一些是不需要同步阻塞調(diào)用的,因此我們會把部分業(yè)務(wù)邏輯做異步化,讓到后臺去異步運(yùn)行。比如說自定義域名來源的請求,我們要先確定這個域名是不是合法綁定的,這里的校驗就會放到后臺異步來進(jìn)行,網(wǎng)關(guān)只是讀取校驗的結(jié)果。
第二,網(wǎng)關(guān)類似代理,轉(zhuǎn)發(fā)請求響應(yīng)體,這里我們使用了流式的傳輸方式。其實 Node 原生的 HTTP 模塊里,HTTP Body 已經(jīng)是一個流對象了(Stream),并不需要額外的引入類似 body-parser 這樣的組件把 Stream 轉(zhuǎn)成一個 JavaScript 對象。為此我們在網(wǎng)關(guān)的設(shè)計上就盡量避免把請求相關(guān)的元數(shù)據(jù)放到 Body 里,于是網(wǎng)關(guān)就可以只解析請求頭,而不解析用戶的請求體,原封不動地流式轉(zhuǎn)給后端就可以了。
第三,我們請求后端資源的時候,改用長連接,減少短連接帶來的握手消耗。像 Nginx 這樣的組件,它通常都是短連接的模式,因為我們這些業(yè)務(wù)情況比較特殊一點(diǎn),是一個大租戶的模式,類似于所有用戶共用同個 Nginx,那么你再啟用短連接模式的話,就會有一個 TCP TIME_WAIT 的問題,下面會詳細(xì)討論。
最后,我們的請求模式比較固定,我們會針對實際情況設(shè)計一些比較合理的緩存的機(jī)制。
優(yōu)化點(diǎn):啟用長連接機(jī)制
首先,為什么短連接會有問題?
我們?nèi)フ埱笥脩糍Y源的時候,網(wǎng)關(guān)所在的網(wǎng)絡(luò)平面是內(nèi)部服務(wù)的平面,但是每個用戶的公有云資源實際上是另一個網(wǎng)絡(luò)平面。那么這兩個網(wǎng)絡(luò)平面之間是需要通過一個穿透網(wǎng)關(guān)來通信的。這個穿透網(wǎng)關(guān)可以理解為是一種網(wǎng)絡(luò)層虛擬設(shè)備,或者你可以理解為它就是一個四層轉(zhuǎn)發(fā)的 Nginx,作為代理客戶端,單個實例可以最大承載 6.5W 的 TCP 的連接數(shù)。
如果做過一些傳輸層協(xié)議的同學(xué)應(yīng)該會知道,一個 TCP 連接斷開之后,客戶端會進(jìn)入一個 TIME_WAIT 的階段,然后在 Linux 內(nèi)核里面它會等待兩倍的時間,默認(rèn)是 60 秒,兩倍是 120 秒,120 秒之后才會釋放這個連接,也就是說在 120 秒內(nèi),客戶端的端口實際上都是處于被占用的狀態(tài)的。所以我們很容易能算出來單個傳統(tǒng)網(wǎng)關(guān)它能夠承載的最大的額定 QPS 大概就是不到 600 的樣子,這個肯定是不能滿足用戶需求的。
那么我們怎么去解決短連接 TIME_WAIT 這個問題?其實有好幾種方法。
第一種是修改 Linux 的 TCP 傳輸層的內(nèi)核參數(shù),去啟用重用、快速回收等機(jī)制。但對于我們的服務(wù)來說并不合適,這需要定制這樣一個系統(tǒng)內(nèi)核,維護(hù)成本會非常高。
第二種,云上類似的組件怎么解決?比如騰訊內(nèi)部的負(fù)載均衡,其實很簡單,就是直接擴(kuò)張集群內(nèi)的VM數(shù)量。比如一臺反向代理服務(wù)器加上TIME_WAIT快速回收,可以承載5000多QPS,但想要二十萬QPS 怎么辦,做40個虛擬實例就行了。但這種做法,一是需要內(nèi)核定制,二是需要我們付出很大的虛擬實例成本,就沒有選擇這種經(jīng)典方案。
最后一種就是我們改成長連接的機(jī)制,類似 Nginx 的 Upstream Keepalive 這樣的機(jī)制。改成這樣一個機(jī)制之后,其實效果還挺好的,單個穿透網(wǎng)關(guān)就可以最大承載 6.5W 個連接數(shù),相當(dāng)于幾乎 6.5W 個并發(fā)。對于同一個目標(biāo) IP PORT,它可以直接復(fù)用連接,所以它穿透網(wǎng)關(guān)的連接數(shù)限制就不再是瓶頸了。
長連接的問題
那么是不是長連接就是完美的?其實并不是。長連接會導(dǎo)致另外一個問題,競態(tài)問題(keep-alive race condition),如果在座里有用 HTTP 長連接的方式做 RPC 調(diào)用的同學(xué),應(yīng)該經(jīng)常會看到這個問題。
客戶端與服務(wù)端成功建立了長連接連接靜默一段時間(無 HTTP 請求)服務(wù)端因為在一段時間內(nèi)沒有收到任何數(shù)據(jù),主動關(guān)閉了 TCP 連接客戶端在收到 TCP 關(guān)閉的信息前,發(fā)送了一個新的 HTTP 請求服務(wù)端收到請求后拒絕,客戶端報錯 ECONNRESET
所以怎么解決?
第一種方案,就是把客戶端的 keep-alive 超時時間設(shè)置得短一些(短于服務(wù)端即可)。這樣就可以保證永遠(yuǎn)是客戶端這邊超時關(guān)閉的 TCP 連接,消除了錯誤的暫態(tài)。
但這樣在實際生產(chǎn)環(huán)境中是沒法 100% 解決問題的,因為無論把客戶端超時時間如何設(shè)置到多少,因為網(wǎng)絡(luò)延遲的存在,始終無法保證所有的服務(wù)端的 keep-alive 超時時間都長于客戶端的值;如果把客戶端超時時間設(shè)置得太?。ū热?1 秒),又失去了意義。
那么正確方法就是用短連接去重新試一次。遇到這個錯誤,并且它是長連接的,那么你就用短連接來發(fā)起一次重試。這個也是參考了 Chrome 的做法,Chromium 自己的內(nèi)核里面處理了這樣一種情況,瀏覽器里它其實這種長連接也是時刻存在的,下圖是一段它自己里面的內(nèi)核的代碼。

2019 年的時候,社區(qū)里常用的 agentkeepalive 不支持識別當(dāng)前請求是否開啟 keepalive,我們給社區(qū)提交過一個 PR,支持了這個特性。也就說你只要使用了 agentkeepalive 這樣一個包,就可以寫一段代碼來識別出這種情況,并且進(jìn)行重試。

這是我們一個日常統(tǒng)計的量,大概萬分之 1.3 的概率,會命中這樣一個競態(tài)的情況。
小結(jié)
非必要情況,不要用 HTTP 協(xié)議作為 RPC 底層協(xié)議。因為 HTTP 本身最適合的場景是瀏覽器跟服務(wù)端來做的,而不是一個服務(wù)端和服務(wù)端之間的一個 IPC 協(xié)議,盡量使用 gRPC 或者類似的這樣的協(xié)議來做。
如果不得已使用 HTTP,你的后端可能非常老舊,開啟長連接是一種較好的方案。
長連接需要解決 Keep Alive 的競態(tài)問題。如果你用長連接,記得一定要處理這個問題,不然這個問題會成為一個幽靈一樣存在。像剛才說的,萬分之 1.3 非常難復(fù)現(xiàn),但是這個錯誤又會不停地出現(xiàn)在你業(yè)務(wù)里。
優(yōu)化點(diǎn):設(shè)計緩存機(jī)制
緩存在后臺設(shè)計里是個萬金油,“哪里慢了抹哪里”,但是如何設(shè)計緩存其實也是一門學(xué)問。
前面提到我們的請求模式都是非常固定的,我們可以根據(jù)請求模式來決定緩存數(shù)據(jù)。緩存都是些什么東西呢?是路由配置,像域名配置、環(huán)境信息、臨時密鑰等這些信息。
這些數(shù)據(jù)有哪些特點(diǎn)?首先是活躍數(shù)據(jù)占比小,這確實也是現(xiàn)狀。假設(shè)我們?nèi)康挠脩衾锩婷刻熘挥写蟾?5%~10% 的用戶才是活躍的,這個數(shù)據(jù)才是真的會經(jīng)過你的網(wǎng)關(guān)。其次是模式比較固定。第三是對實時性的要求不高。比如說變更了路由之后,客戶通常是能夠接受有 1~3 分鐘不定的延遲的,并不要求說變更了路由之后就即刻生效。
因此我們可以針對以上這些特點(diǎn)來設(shè)計緩存。第一是因為我們的活躍數(shù)據(jù)占比很小,所以我們是緩存局部數(shù)據(jù),從來不會緩存全量的數(shù)據(jù)。第二是我們會選取域名、環(huán)境這種幾乎是固定的信息作為緩存 Key,這樣緩存的覆蓋面就可以得到保證。第三是讀時緩存要大于寫時緩存,這個后續(xù)會提到為什么會選用讀時緩存,而不是寫入數(shù)據(jù)的時候把緩存推到我們的網(wǎng)關(guān)里。

本地緩存的局限性
最早的時候,實際上我們是有一個最簡單的設(shè)計,就是加了一個非常簡單的本地緩存,它可能就是以域名或以路徑作為緩存的 Key,這樣實現(xiàn)簡單但有很多局限性:
首先,要寫大量這樣的代碼,要去先讀本地有沒有緩存,有緩存就緩存,沒緩存去后臺要數(shù)據(jù)。
其次,因為網(wǎng)關(guān)不是一個單獨(dú)的實例,它不是一個單進(jìn)程的 Node,單進(jìn)程的 Node 是扛不了這么多量的,我們是有很多很多實例,大概是有幾千核,也就是說有幾千個 Node 進(jìn)程,如果這些進(jìn)程它本身都有一份自己獨(dú)有的內(nèi)存,也就導(dǎo)致它這個緩存沒有辦法在所有實例上生效。因此當(dāng)我們的網(wǎng)關(guān)規(guī)模變得越來越大的時候,緩存也就永遠(yuǎn)都只能出現(xiàn)在局部。
為了解決這樣的問題,我們加入了 Redis 中心化的緩存。我們是本地內(nèi)存 +Redis 兩層緩存,本地內(nèi)存主要是為了降低 Redis 負(fù)載。當(dāng) Redis 故障的時候也可以降級到本地緩存,這樣可以避免緩存擊穿問題。Redis 作為一個中心化的緩存,使緩存可以在所有實例上生效,也就是說只要請求過了一次網(wǎng)關(guān),Redis 緩存就會生效,并且所有的網(wǎng)關(guān)實例上都會讀到這樣一個緩存。
既然有了緩存,那必然有緩存淘汰的機(jī)制,怎么樣合理地淘汰你的緩存?這里是用了 TTL + LRU 兩重的機(jī)制來保證,針對不同的數(shù)據(jù)類別,單獨(dú)設(shè)置參數(shù),為什么是 TTL + LRU?后面在容災(zāi)部分會進(jìn)行解釋。
最后就是抽象出數(shù)據(jù)加載層,它是專門用來封裝讀操作,包括緩存的管理、請求、刷新、容災(zāi)這樣一套機(jī)制,我們內(nèi)部會有一個專門模塊來處理。
有了 Redis 之后,我們的緩存是中心化的了,只要你的請求經(jīng)過了我們之后,你的東西就可以在所有的實例上生效。但是這樣會引來另一個問題,因為淘汰機(jī)制是 TTL 的,必然遇到緩存過期。假設(shè)是每秒鐘都會回頭發(fā)起一次請求,那么緩存是一定是會過期的,一分鐘或兩分鐘之后你的緩存就過期了,在過期之后的請求一定是不會命中緩存的,這導(dǎo)致了請求毛刺的問題。這對于在持續(xù)流量的下游業(yè)務(wù)上,體現(xiàn)非常明顯,下圖是我們的一個截圖。

可以看到圖上有很多毛刺,這些毛刺的尖尖就是它沒有命中緩存的時候,為了解決緩存的毛刺問題,我們加入了 Refresh-Ahead 這樣一個機(jī)制,就是說每次請求進(jìn)來的時候,我們首先會去 Redis 里去讀,使用緩存的數(shù)據(jù)來運(yùn)行邏輯。

同時我們也會判斷,如果緩存剩余 TTL 小于一定值,它就會即觸發(fā)異步刷新的邏輯,這時候我們會去請求后端服務(wù),并且把更新鮮一點(diǎn)的數(shù)據(jù)刷新到 Redis 里,這就是我們數(shù)據(jù)加載層內(nèi)實現(xiàn) Refresh-Ahead 機(jī)制的大概邏輯。
Refresh-Ahead 其實非常簡單,字面意義就是說提前去刷新緩存,緩存數(shù)據(jù)快到 TTL 了,那么就去提前更新一下。

能夠這樣設(shè)計,更多是基于一個先驗的邏輯,就是說當(dāng)下這一刻被訪問的數(shù)據(jù),大概率在未來的一段時間內(nèi)會再次被訪問。
下圖是我們加入了 Refresh-Ahead 之后的一個效果,紅色箭頭處是上線時間,上線完之后發(fā)現(xiàn)毛刺就明顯變少了。但是為什么還會有一點(diǎn)毛刺?因為有一些數(shù)據(jù)它可能真的就是很長的時間,刷新了之后它也依然過期了,依然會產(chǎn)生這樣的毛刺。

最后解釋一下,為什么我們是網(wǎng)關(guān)去后臺讀數(shù)據(jù),而不是后臺把數(shù)據(jù)推給網(wǎng)關(guān)?或者說,為什么是“拉”而不是“推”?
這其實有幾個考量點(diǎn),第一,因為是數(shù)據(jù)局部緩存,所以我們?nèi)繑?shù)據(jù)完全推過來體積很大,大概有幾十個 G,而活躍占比很小,如果完全存在內(nèi)存里,其實也是一種反模式的做法,不太經(jīng)濟(jì)。
第二,后臺能不能只推局部活躍的數(shù)據(jù)給到網(wǎng)關(guān)呢?其實也是不太合適的,后臺很難去識別哪些數(shù)據(jù)是活躍的,哪些數(shù)據(jù)不是活躍的,這樣實現(xiàn)復(fù)雜,難度很大。
第三,網(wǎng)關(guān)和它的持久化的后臺之間會產(chǎn)生一個緩存 Key 上的耦合,所謂的 Key 上的耦合就是說雙方要約定一組 Key,我這個數(shù)據(jù)是在 Key 上面去讀,然后你后臺要把數(shù)據(jù)推到 Key 上面。那么就會帶來另一個問題,一旦 Key 寫錯了,或者說出現(xiàn)了一些不可預(yù)料的問題,那就會產(chǎn)生一些比較災(zāi)難性的后果,所以我們就沒有使用“推”這樣一種方式。
小結(jié)
在現(xiàn)代大規(guī)模服務(wù)里,緩存是必選項,不是可選項。
緩存系統(tǒng)本質(zhì)是一個小型的分布式系統(tǒng),無法逾越 CAP 理論。
根據(jù)業(yè)務(wù)場景,合理地權(quán)衡性能、一致性和可用性。
前面講的是服務(wù)跟服務(wù)自己核心的優(yōu)化,接下來講一講架構(gòu)和鏈路上的一些性能優(yōu)化。

上圖是我們整體的一個架構(gòu),可以看到一個請求,它從前面的接入層一直走到后端云資源之間,其實整個鏈路是很長的,這里分析一下。
首先鏈路很長,涉及邊緣節(jié)點(diǎn)、核心業(yè)務(wù)、后端資源。其次網(wǎng)關(guān)是承接 C 端流量的,它其實對終端的性能是很敏感的。第三個就是網(wǎng)絡(luò)環(huán)境復(fù)雜,它涉及到數(shù)個網(wǎng)絡(luò)平面的打通。因此我們就有了優(yōu)化方向,第一個是讓鏈路更快更短。第二個是核心服務(wù) Set 化,便于多地域鋪設(shè),終端用戶可以就近接入。第三個就是我們在網(wǎng)絡(luò)平面之間會做一些針對性的優(yōu)化,針對性優(yōu)化怎么做,后面會提。
前置鏈路:CDN 就近回源
首先先講一下我們就近接入是怎么做的,在網(wǎng)關(guān)最開始上線的時候,其實會存在一個問題,你的 CDN 節(jié)點(diǎn)它其實是通過公網(wǎng)回源的,那為什么是公網(wǎng)回源?
其實這涉及到國內(nèi)這幾家大廠的一個網(wǎng)絡(luò)架構(gòu),簡單地說就是,諸多的 CDN 節(jié)點(diǎn)中,有部分可能不是騰訊自建的,所處的網(wǎng)絡(luò)可能不是騰訊的內(nèi)網(wǎng),它可能是某個運(yùn)營商,比如說電信、聯(lián)通或者網(wǎng)通這樣的邊緣節(jié)點(diǎn),然后它是要走公網(wǎng)回源到騰訊的入口的,這里的公網(wǎng)回源就非常慢。
比如說廣州的節(jié)點(diǎn)回源到上海,并且走 HTTPS 協(xié)議,那就是 60~100 毫秒,但問題在于 CDN 節(jié)點(diǎn)是有很多的,HTTPS 握手之后,這個鏈接還是沒有辦法復(fù)用的,等于說每次請求都要跟源站之間進(jìn)行一次 HTTPS 握手,這個延遲是不可接受的。

最后我們在網(wǎng)關(guān)的回源接入點(diǎn)上做了一層就近接入,也就是說你 CDN 在廣州的節(jié)點(diǎn),可以很就近地接入到我們在部署在廣州的網(wǎng)關(guān),然后網(wǎng)關(guān)內(nèi)部再進(jìn)行跨地域的訪問,因為這個時候就已經(jīng)是內(nèi)網(wǎng)了,速度就會很快。
為了能更好地鋪設(shè)網(wǎng)關(guān)多地接入點(diǎn),我們就把網(wǎng)關(guān)改造成了地域無感的,即業(yè)務(wù)邏輯和它所在的地域是解耦的。其次,網(wǎng)關(guān)支持跨地域訪問后端資源。最后,配置收歸統(tǒng)一,所有地域用同樣的后端資源配置,減少了我們不同地域的配置發(fā)散的問題。
服務(wù)本體:SET 化部署
把這些事情做了之后,網(wǎng)關(guān)其實達(dá)到了“SET 化部署”的概念,降低就近接入成本,任意集群能訪問任意地域的后端資源。相當(dāng)于網(wǎng)關(guān)在所有地域的集群,服務(wù)能力都是一模一樣的。你可以使用任意域名去任意網(wǎng)關(guān)訪問,獲得到結(jié)果都是一樣,這樣 SET 化部署帶來很多好處:
新地域接入點(diǎn)的部署、維護(hù)成本極大下降
便于鋪設(shè)就近接入點(diǎn),加速 CDN 接入
不同地域的集群之間服務(wù)能力完全等價,帶來容災(zāi)能力上的提升:流量拆分、故障隔離
也就是說全網(wǎng)只要只剩一個地域的網(wǎng)關(guān)可用,我們的服務(wù)就可以正常的運(yùn)行。
底層組件同可用區(qū)部署
接下來涉及到網(wǎng)絡(luò)平面之間的部署,剛才提到了我們在訪問用戶的資源的時候,其實會經(jīng)過一個穿透網(wǎng)關(guān),這個是不可避免的,因為它涉及到兩個網(wǎng)絡(luò)平面的打通,在穿透網(wǎng)關(guān)的這一條鏈路也是可以優(yōu)化的。
我們可以看一個數(shù)據(jù),就是像這種穿透網(wǎng)關(guān)和我們云上的資源,它通常是部署在不同的機(jī)房的。

舉個例子,像圖上的上海二區(qū),它實際上是在上海的花橋機(jī)房,穿透網(wǎng)關(guān)因為它是網(wǎng)絡(luò)層提供的設(shè)備,它會部署在上海六區(qū)。查一下地理位置可以看到,二區(qū)到六區(qū)之間其實相隔了可能有七八十公里,后端資源是在上海三區(qū)的,寶信。在地理位置上講,它整個請求就經(jīng)過了下圖這樣一段鏈路。

但實際上這也是完全沒有必要的,我們可以將網(wǎng)關(guān)和穿透網(wǎng)關(guān)部署在同樣一個區(qū)域,這樣就會極大降低從網(wǎng)關(guān)到后端資源這樣的一個延遲。當(dāng)然這個事情我們正在慢慢地鋪設(shè)中,現(xiàn)在還在驗證可行性的階段,我們設(shè)想是這樣來做。

最后來看效果,我們總體的緩存命中率大概有 99.98%。你可以自己部署一個很簡單的服務(wù)到我們的平臺上,然后跑一下測速,你會發(fā)現(xiàn)全國其實都是綠的,這個也是我們覺得做的還不錯的一個證明。網(wǎng)關(guān)自身的耗時,其實 99% 的請求都會在 14 毫秒內(nèi)被處理完畢。當(dāng)然你說平均值能不能進(jìn)一步降低,我覺得是可以的。但是你再進(jìn)一步降低的話,可能就涉及到 Node.js 本身事件驅(qū)動模型這樣一個調(diào)度的問題。
小結(jié)
大規(guī)模服務(wù)不能只考慮自身性能,前置 / 后置鏈路都可能成為性能瓶頸。
前置 / 后置鏈路通常與公司基建、網(wǎng)絡(luò)架構(gòu)密切相關(guān),服務(wù)研發(fā)團(tuán)隊需要深刻理解。
Node.js 受限于自身異步模型,很難精細(xì)化地控制、調(diào)度異步 IO,并非萬金油。
講完性能優(yōu)化,最后一個部分就是可用性保障,那么我們通常的服務(wù)怎么來做可用性保障?
第一,不要出事故。服務(wù)的健壯性,你本身服務(wù)要足夠的健壯,這里有很多機(jī)制,包括灰度發(fā)布、熱更新、流量管理、限流、熔斷、防緩存擊穿,還有緩存容災(zāi)、特性開關(guān)、柔性降級……這些東西。
第二,出事故了能感知到。事故永遠(yuǎn)是不可避免的,每天都會發(fā)生亂七八糟的各種事故,出了事故的時候你是要能夠感知到,并且能夠讓你的系統(tǒng)自修復(fù),或者說你自己人員上來修復(fù)。這就涉及到監(jiān)控告警系統(tǒng),還有像外部的撥測,用戶反饋監(jiān)控,社群里面的一些監(jiān)控。
第三,能立刻修復(fù)事故。出了事故的時候,能夠有機(jī)制去立刻修復(fù),比如快速擴(kuò)容,當(dāng)然最好的是整個系統(tǒng)它能夠自愈。比如說有個節(jié)點(diǎn)它出問題了,你的系統(tǒng)可以自動剔除它,但如果做不到的話,你可以去做一些人工介入的故障隔離,還有多實例災(zāi)備切換、邏輯降級等。

上圖是我們網(wǎng)關(guān)整體的架構(gòu),哪些地方容易出現(xiàn)問題?其實每一層都會出問題,所以每一層其實都要相應(yīng)的去做容災(zāi),比如 CDN 到 CLB 這一層,CLB 是不是有多個實例的災(zāi)備?像 CLB 到網(wǎng)關(guān)這一層,是不是網(wǎng)關(guān)也是有同樣的多實例,還有一些監(jiān)控的指標(biāo)。當(dāng)然這個篇幅就非常大了,所以我今天只講我們最核心業(yè)務(wù)層的容災(zāi)。
核心業(yè)務(wù)層
先講講我們核心業(yè)務(wù)層面臨的一些挑戰(zhàn)。
下游客戶業(yè)務(wù)隨時有突發(fā)大流量,要能抗住沖擊。因為我們是承載公有云流量的,大概有上萬的客戶他的服務(wù)是部署在這里的,我們永遠(yuǎn)不知道這些客戶什么時候會突然來一個秒殺活動,他可能也從來不給我們報備,這個客戶的流量可能隨時就會翻個幾千倍甚至幾萬倍,所以這時候我們要能扛住這樣一個沖擊。
網(wǎng)關(guān)本身依賴服務(wù)多,穩(wěn)定性差異大,要有足夠的自動容錯兜底機(jī)制。
能應(yīng)對多個可用區(qū)故障,需要流量調(diào)度、災(zāi)備、多地多活等機(jī)制。
我們能先于客戶發(fā)現(xiàn)問題,需要業(yè)務(wù)維度的監(jiān)控告警機(jī)制。
那我們怎么樣去應(yīng)對一個大流量的沖擊?實際上對于一個系統(tǒng)來講,它其實是非常具有破壞性的,它有可能直接把你的緩存還有你的 DB 擊穿,導(dǎo)致你的 DB 直接就夯住了,CPU 被打滿。下圖是我們一次真實的例子,我也不是很排斥說出來。


這是我們今年年初 1 月份的時候,有一個客戶他的流量突然翻了 100 多倍,你可以看到圖上它的量就突然提升,這造成一個什么問題?它的緩存都是冷的,也就是說訪問量突然提升 100 倍,這 100 倍的請求,可能都要去后臺讀它的一些數(shù)據(jù),導(dǎo)致直接把后臺數(shù)據(jù)庫的 CPU 打滿了,也導(dǎo)致這個災(zāi)難進(jìn)一步擴(kuò)散,擴(kuò)散到到所有用戶的數(shù)據(jù)都讀不出來了。
后來我們就反思了一下這個是不是有問題的?對,是有問題。我們要做什么事情來防止這樣的問題出現(xiàn)的?
提升服務(wù)承載能力
大流量來了,你自己本身要能扛得住,這個時候要去提升你整個服務(wù)快速擴(kuò)容的能力。我們的網(wǎng)關(guān)實際上當(dāng)時已經(jīng)是完全容器化的,所以這一點(diǎn)還好,它可以快速做到橫向擴(kuò)容,瞬間擴(kuò)出幾百核幾千核的資源,可以在幾分鐘之內(nèi)完成。
其次,我們是使用了單 POD 多 Node 進(jìn)程,就是我們 1 個 POD 會帶有 8 核,每個核心跑一個進(jìn)程。這個在 Kubernetes 里面實際上是一個反模式,因為 Kubernetes 要求 POD 要盡量得小,然后里面就只跑 Node 一個進(jìn)程。但在工程實踐的時候,我們發(fā)現(xiàn)這樣跑雖然沒有問題,但是它擴(kuò)容速度非常慢,因為每次實例擴(kuò)出來,都是批量的。比如說我們內(nèi)部的容器系統(tǒng),只能說一次擴(kuò) 100 個實例,也就是說一批也就擴(kuò) 100 核,并且這 100 核都要分配內(nèi)部的虛擬 IP,可能會導(dǎo)致內(nèi)部的 IP 池被耗盡了。最后我們做了合并,起碼一個 POD 能擴(kuò)出 8 核的資源出來。
保證服務(wù)健壯性,不被打垮
當(dāng)然,除了提升自己抗沖擊的能力以外,還要保證你的后端,保護(hù)好你后面的服務(wù)。
比如說我們要保證服務(wù)的健壯性,在大量沖擊的時候不會被打垮。首先單個實例會做一個限頻限流,防止雪崩,流量限頻是說你一個實例最多可能只能承載 1000QPS 的流量,再多你這個實例就直接放棄掉,就不請求了,這樣可以防止你的整個后臺雪崩,不至于說一個 POD 崩了,然后其他 POD 請求又更多,把其他 POD 全部帶崩。
其次,我們要做 DB 的旁路化,網(wǎng)關(guān)它讀的永遠(yuǎn)是緩存,緩存里面讀不到,那就是讀不到,它永遠(yuǎn)不會直接把請求請求到 DB 里面去。當(dāng)有數(shù)據(jù)寫入或者說數(shù)據(jù)變更的時候,后臺同學(xué)會先落 DB,然后再把 DB 的數(shù)據(jù)推送到緩存里面,大概就是下圖這樣一個邏輯,防止緩存擊穿問題。

第三,服務(wù)降級機(jī)制。假設(shè)真的出現(xiàn)問題了,比如說你緩存也出問題了,我們可以做一些服務(wù)的降級。它可能有一些功能沒有了,比如說有些特殊的 HTTP 請求,響應(yīng)頭可能沒有了,但是它不會干擾你的主干邏輯,這個也是可以做的。
本身服務(wù)構(gòu)建狀態(tài)是沒有用的,依賴的外部組件服務(wù)也一定會出問題,而且它們的可用性說不定遠(yuǎn)遠(yuǎn)比你想象的要低,那要怎么做?
首先,我們內(nèi)部是有一套集群控制系統(tǒng)的,我們內(nèi)部分成了主機(jī)群、VIP 集群和灰度集群這三個集群。每次發(fā)布的時候永遠(yuǎn)是會先發(fā)灰度集群,驗證一段時間之后才會全讓到其他集群上。這樣的集群隔離也給我們帶來另一個好處,一旦其中有一個集群出現(xiàn)了問題,比如說灰度集群的 DB 掛了,或者 DB 被寫滿了等其他的事故,我們可以很快速地把流量切換到主機(jī)群和 VIP 集群上面去,這得益于我們內(nèi)部其實有一套集群管理的快速切換機(jī)制。
服務(wù)降級:容災(zāi)緩存
其次,做容災(zāi)緩存,假設(shè)依賴的服務(wù)全掛,服務(wù)自動啟用容災(zāi)緩存,使用舊數(shù)據(jù)保證基本的可用性。
年初我們有一次這樣的事故,整個機(jī)房停電,機(jī)房就相當(dāng)于消失了,導(dǎo)致后臺服務(wù)全部都沒有了,這種情況怎么做?這時候就只能是啟用緩存容災(zāi)。我們網(wǎng)關(guān)本地的緩存是永遠(yuǎn)不會主動清除的,因為你使用舊數(shù)據(jù)也比直接報錯要好,這時候我們就會使用一個舊數(shù)據(jù)來保證它的可用性。
這個怎么理解呢?我們網(wǎng)關(guān)內(nèi)部的數(shù)據(jù)它永遠(yuǎn)不會被清理,它只會說通過 LRU 的形式被清理掉,比如說我的內(nèi)存里有可能會有很老的數(shù)據(jù),昨天或者前天的數(shù)據(jù),但是你在災(zāi)難發(fā)生的時候,即使是昨天還是前天數(shù)據(jù)它依然是有用的,它依然可以拿出來保證你最基本的可用性,下面是我們一個邏輯圖,大家可以了解。

服務(wù)降級:跳過非核心鏈路
你的服務(wù)有可能會降級,我剛提到我們網(wǎng)關(guān)有鑒權(quán)的功能,鑒權(quán)功能其實依賴我們騰訊內(nèi)部的一個組件。這樣一個組件,它其實也是不穩(wěn)定的,有時候會出問題,那么遇到這種問題怎么辦?鑒權(quán)都沒有辦法鑒權(quán)了。這個時候我們在一些場景允許的情況下,會直接把鑒權(quán)的邏輯給跳過,我們不鑒權(quán)了,先放過一段時間,總比說我直接拒絕掉,直接報錯這個請求要好得多。
最后一點(diǎn)就是我剛剛提到的,因為我們網(wǎng)關(guān)做了服務(wù) SET 化改造、部署后,天然獲得了跨 AZ、跨地域熱切換的能力。簡單來講,只要全網(wǎng)還剩一個網(wǎng)關(guān)可用區(qū),業(yè)務(wù)流量就可以切換,網(wǎng)關(guān)的服務(wù)就不會宕機(jī),當(dāng)然切換現(xiàn)在還沒有做到完全自動化,因為涉及到跨地域的切換,這個是需要人工介入的,不過說實話我們還沒有遇到過這么大的災(zāi)難。
做個小結(jié),我們做了多集群切換、緩存容災(zāi)、柔性降級這些事情之后可以達(dá)到怎樣一個效果:
容許后臺最多 (N-1) 個集群長時間故障
容許后臺全部集群短時間故障
容許內(nèi)部 DNS 全網(wǎng)故障
我們還有什么能做的?如果某天,容器平臺全網(wǎng)故障,怎么辦?其實也是我們現(xiàn)在構(gòu)思的一個東西,我們是不是可以做到一個異構(gòu)部署這樣一個形態(tài)。

服務(wù)異構(gòu)部署,即使容器平臺全地域全可用區(qū)故障,也能切換到基于虛擬機(jī)的架構(gòu)上,這也是我們正在籌劃的一個事情。
最后說完了容災(zāi),接下來說怎么做監(jiān)控告警?做監(jiān)控告警其實比較老生常談了,但是也可以在這里稍微掃個盲,我們的所有網(wǎng)關(guān),它會把自己所有的訪問日志推送到我們的 ES(elasticsearch)的集群上,然后我們會有一個專門的 TCB Alarm 這樣一個模塊,它會去定期的輪詢這樣的日志,去檢查這些日志里面有沒有一些異常,比如說某個用戶的流量突然高了,或者某個錯誤碼突然增多,它會把這樣的信息通過電話或者企業(yè)微信推送給我們。
因為是基于 ES 的,所以監(jiān)控可以做得非常精細(xì),甚至可以做到感知到某個接口,今天的耗時比昨天要高超過 50%,那這個接口是不是今天做什么變更讓它變慢了?
我們也可以做針對下游重點(diǎn)客戶、業(yè)務(wù)的一些監(jiān)控,比如說幾個省的健康碼,都可以做重點(diǎn)的監(jiān)控。
其實我只是選取了整個 Node 服務(wù)里面非常小的兩個切面來講,性能優(yōu)化和高可用保障??赡芎茈y覆蓋到很全面,但是我想講的稍微深一點(diǎn),能夠讓大家有些足夠的益處。
首先,服務(wù)核心優(yōu)化這里講了長連接和緩存機(jī)制,可能是大部分服務(wù)或多或少都會遇到的問題。然后,鏈路架構(gòu)優(yōu)化這里講了就近接入和 Set 化部署這樣一個機(jī)制。高可用保障我主要是介紹了核心業(yè)務(wù)層的一些高可用保障,包括應(yīng)對大流量沖擊,怎么做緩存容災(zāi),柔性降級,多可用區(qū)、多地域切換,監(jiān)控告警這些東西。
最后我想就今天的演講做一個總結(jié)。
第一,Node.js 服務(wù)與其它后臺服務(wù)并無二致,遵循同一套方法論。
Node.js 服務(wù)本質(zhì)上也是做后臺開發(fā)的,與其它后臺服務(wù)并無二致,遵循同一套方法論。我今天的演講如果把 Node.js 改成 Golang 改成 Java,我就不站在這里了,可能我就去 Golang 的會上講,實際上是一樣的。
第二,Node.js 足以承載核心大規(guī)模服務(wù),無須妄自菲薄。
我們這套網(wǎng)關(guān)其實也現(xiàn)網(wǎng)驗證兩年了,它跟別的技術(shù)棧的這種后臺服務(wù)來講,其實并沒有太大的缺點(diǎn)。所以大家在拿 Node.js 做這種海量服務(wù)的時候,可以不用覺得 Node.js 好像只是個前端的小玩具,好像不是很適合這種成熟的業(yè)務(wù),成熟業(yè)務(wù)是不是還是用 Java 來寫,拿 C++ 來寫,其實是沒有必要的。
當(dāng)然,如果你真的需要對你的 IO 調(diào)度非常精細(xì)的時候,那么你可能得選用 C++ 或者 Rust,這樣可以直接調(diào)度 IO 的方案。
第三,前端處在技術(shù)的十字路口,不應(yīng)自我局限于“Web 前端”領(lǐng)域。
最后一個也是我今天想提的,可能我講這么多,大家覺得我不是一個前端工程師對不對?但實際上我在公司內(nèi)部的職級確實是個前端工程師。我一直覺得前端它是站在一個技術(shù)的十字路口的,所以大家工作中也好,還是學(xué)習(xí)中也好,不用把自己局限在“Web 前端”這樣一個領(lǐng)域。這次 GMTC 大會也可以看到,前端現(xiàn)在也不只是大家傳統(tǒng)意義上的可能就是寫頁面這樣一個領(lǐng)域。

這是一個當(dāng)年喬布斯演講用的一個圖,他說蘋果是站在技術(shù)和人文的十字路口,實際上前端也是站在很多技術(shù)的十字路口上。
那么我的演講就到此結(jié)束,謝謝大家。
王偉嘉:騰訊云 CloudBase 前端負(fù)責(zé)人
畢業(yè)于復(fù)旦大學(xué),現(xiàn)任騰訊云 CloudBase 前端負(fù)責(zé)人,Node.js Core Collaborator,騰訊 TC39 代表。目前在騰訊云 CloudBase 團(tuán)隊負(fù)責(zé)小程序·云開發(fā)、Webify 等公有云產(chǎn)品的核心設(shè)計和研發(fā),服務(wù)了下游數(shù)十萬開發(fā)者和用戶,對 Node.js 服務(wù)架構(gòu)、全棧開發(fā)、云原生開發(fā)、Serverless 有較豐富的經(jīng)驗,先后在阿里 D2、GMTC、騰訊 TWeb 等大會上發(fā)表過技術(shù)演講。?
我組建了一個氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學(xué)習(xí)感興趣的話(后續(xù)有計劃也可以),我們可以一起進(jìn)行Node.js相關(guān)的交流、學(xué)習(xí)、共建。下方加 考拉 好友回復(fù)「Node」即可。
???“分享、點(diǎn)贊、在看” 支持一波??
