close

作者|王偉嘉
編輯|孫瑞瑞

本文由 InfoQ 整理自騰訊雲 CloudBase 前端負責人王偉嘉在 GMTC 全球大前端技術大會(深圳站)2021 上的演講《十億級 Node.js 網關的架構設計與工程實踐》。

大家好,我今天的演講主題主要是講我們業務上用 Node.js 寫的一個網關。先做個簡單的自我介紹,我叫王偉嘉,現在是騰訊云云開發 CloudBase 的前端負責人,說 CloudBase 可能很多人不太知道,但是我們旗下其實有挺多產品的,可能或多或少聽說過,比如說小程序·雲開發,寫小程序的同學應該會知道吧。

當然我今天不是來推銷產品的,今天是開門見山的講一講網關是一個怎麼樣的組件,網關在做什麼事情。網關這個詞其實到處都在用,它可以工作在一個硬件的層面,可以工作在網絡層,也可以工作在應用層。

1網關快速入門

網關在做什麼?

我們今天講的實際上是一個工作在 HTTP 七層協議的網關,它主要做的有幾件事情:

第一,公網入口。它作為我們公有雲服務的一個入口,可以把公有雲過來的請求定向到用戶的資源上面去。

第二,對接後端資源。我們雲開發有很多內部的資源,像雲函數、容器引擎這樣的資源,便可以把請求對接到這樣的雲資源上面去。

第三,身份鑒權。雲開發有自己的一套賬號身份體系,請求里如果是帶有身份信息的,那麼網關會對身份進行鑒權。

所以網關這個東西聽起來好像是很底層的一個組件,大家可能會覺得很複雜,實際上並沒有。我們就花幾行代碼,就可以實現一個非常簡單的 HTTP 網關的邏輯。

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}`)})

這段示例代碼在做的事情很簡單,即我們收到一個請求之後,會根據請求的方法或者路徑進行解析,找出它的上游是什麼,然後再去請求上游,這樣就完成一個網關的邏輯。

當然這是最簡的一個代碼了,實際上裡面有很多東西是沒有考慮到的,比如技術框架以及內部架構模塊的治理,比如性能優化、海量的日誌系統、高可用保障、DevOps 等等。當然這樣展開就非常大了,所以我今天也不會面面俱到,會選其中幾個方向來講的比較深一點,這樣我覺得會對大家比較有收穫。

雲開發 CloudBase(TCB) 是個啥?

說到這個,順便介紹一下我們雲開發 CloudBase 是什麼,要介紹我們網關肯定要知道我們業務,像小程序·雲開發、Web 應用託管、微搭低代碼平台,還有微信雲託管這樣的服務都是在我們體系內的。

這些服務它的資源都會過我們的網關來進行鑒權,你可以在雲開發體系下的控制台上,看到我們 URL 的入口,實際上這些 URL 它的背後就是我們的網關。

整個網關最簡版的一個架構如上圖所示,我們會給用戶免費提供一個公網的默認域名,這個域名它背後實際上是一套 CDN 的分發網絡,然後 CDN 回源到核心網關上面來。我們網關本身是無狀態的服務,收到請求之後,它需要知道如何把請求分發到後端的雲資源上去,所以有一個旁路的後端服務可以讀取這樣一套數據。

網關的後面就是用戶自己的雲上資源了,你在雲開發用到任何資源幾乎都可以通過這樣的鏈路來進行訪問。

網關內部是基於 Nest.js 來做的,選 Nest.js 是因為它本身自帶一套設計模式,很 Spring 那一套,更多的是做 IOC 容器這一套設計模式。

從上圖可以看到,我們把它的內部架構分成了兩層,一層是 Controller,一層是 Service。Controller 主要是控制各種訪問資源的邏輯。比如說你去訪問一個雲函數(SCF),和你去訪問一個靜態託管的資源,它所需要的訪問信息肯定是不一樣的,所以這也就是分成了幾種 Controller 來實現。

底層的話 Service 這一層是非常「厚」的,Service 內部又分成邏輯模塊和功能性模塊。

首先第一大塊是我們的邏輯模塊,邏輯模塊主要是處理我們內部服務模塊的很多東西,最上面這一層主要就是處理跟資源訪問相關的一些請求的邏輯,跟各種資源使用不同的協議、方法來對接。然後中間這一層,更多的是做我們內部的一些集群的邏輯。比如集群管理,作為一個公有雲的服務,我們對於客戶也是會分等級的,像 VIP 客戶可能就需要最後來發布,我們肯定是先驗證一些灰度的流量,像這塊邏輯就屬於中間這一層來管理。最下面這一層就會有各種負責 I/O 的 Client,我這一次只畫了一個 HTTP 的 Client,實際上還會有一些別的 Client。

除此之外還有一些旁路的功能性模塊,包括像怎麼打日誌、配置、本地緩存的管理、錯誤處理,還有本地配置管理 DNS、調用鏈追蹤等這些旁路的服務。

這一套設計其實就是老生常談的高內聚低耦合,業務邏輯和真正的 I/O 實現要解耦開。因為只有解耦開,你才能夠針對你的業務邏輯進行單元化的測試,可以很方便的把它底層的這種 I/O 讀寫邏輯給 Mook 起來,保證核心業務邏輯模塊的可測試性。

上圖是網關整體的鏈路架構,稍微更全面一點。最上層是分布在邊緣的 CDN 節點,然後這些 CDN 節點會回源到我們部署在各地的集群,然後這些集群它又可以訪問後面不同區域的資源,因為公有雲它的資源其實也是按區域來劃分的,所以這就講到了我們網關的兩個核心的要求,「快」和「穩」。

首先作為一個網關肯定是要快的,因為網關作為 CloudBase 雲函數、雲託管、靜態資源的公網出口,性能要求極高,需要承接 C 端流量,應對各種地域、各種設備的終端接入場景。如果說過你網關這一層就可能就花了幾百毫秒甚至一兩秒鐘的時間,對於客戶來講是不可接受的。因為客戶他自己的一個函數可能只跑了 20 毫秒,如果網關也引入 20 毫秒的延遲,對於客戶來講他就覺得你這條鏈路不行。

其次就是要穩,我們是大租戶模式,要扛住海量的 C 端請求,我們需要極高的可用性。作為數據面的核心組件,需要極高的可用性,任何故障將會直接影響下遊客戶的業務穩定性。如果在座的有四川或者雲南的同學,你回家每次打開健康碼掃碼,其實請求都會經過我這個網關的。

所以今天主要就講兩個部分,既然是快和穩,分別對應性能優化和可用性,所以我現在從性能優化開始講起。

2性能優化

網關性能優化思路

性能優化的思路,首先是看時間都花在哪個地方了,網關是一個網絡的組件,大部分時間都是耗在 I/O 上,而不是本身的計算,所以它是一個高 I/O、低計算的一個組件。

網關有幾個技術特點:首先,它的自身業務邏輯多,是重 I/O、輕計算的一個組件;其次它的請求模式是比較固定的,模式固定我們可以理解為,你的一個客戶他發送過來的請求實際上就是那麼幾種,它的路徑、包體大小、請求頭等這些都是比較趨向於固定的,很難會有一個客戶他的請求是完全隨機生成的,這對我們後面針對這種情況做緩存設計會有一些幫助。最後,網關的核心鏈路很長,涉及到多個網絡平面。

那麼我們就找到了我們的一些優化方向:減少整個 IO 的消耗,並且優化核心鏈路。所以優化部分我就分成了兩塊內容來講,第一塊是網關自身核心服務優化,第二塊是整體架構鏈路優化。

核心服務性能優化

第一部分,核心的服務怎麼做優化?先提幾個方向。

第一,網關自身的業務邏輯很多,調用很多外部服務,其中有一些是不需要同步阻塞調用的,因此我們會把部分業務邏輯做異步化,讓到後台去異步運行。比如說自定義域名來源的請求,我們要先確定這個域名是不是合法綁定的,這裡的校驗就會放到後台異步來進行,網關只是讀取校驗的結果。

第二,網關類似代理,轉發請求響應體,這裡我們使用了流式的傳輸方式。其實 Node 原生的 HTTP 模塊里,HTTP Body 已經是一個流對象了(Stream),並不需要額外的引入類似 body-parser 這樣的組件把 Stream 轉成一個 JavaScript 對象。為此我們在網關的設計上就儘量避免把請求相關的元數據放到 Body 里,於是網關就可以只解析請求頭,而不解析用戶的請求體,原封不動地流式轉給後端就可以了。

第三,我們請求後端資源的時候,改用長連接,減少短連接帶來的握手消耗。像 Nginx 這樣的組件,它通常都是短連接的模式,因為我們這些業務情況比較特殊一點,是一個大租戶的模式,類似於所有用戶共用同個 Nginx,那麼你再啟用短連接模式的話,就會有一個 TCP TIME_WAIT 的問題,下面會詳細討論。

最後,我們的請求模式比較固定,我們會針對實際情況設計一些比較合理的緩存的機制。

優化點:啟用長連接機制

首先,為什麼短連接會有問題?

我們去請求用戶資源的時候,網關所在的網絡平面是內部服務的平面,但是每個用戶的公有雲資源實際上是另一個網絡平面。那麼這兩個網絡平面之間是需要通過一個穿透網關來通信的。這個穿透網關可以理解為是一種網絡層虛擬設備,或者你可以理解為它就是一個四層轉發的 Nginx,作為代理客戶端,單個實例可以最大承載 6.5W 的 TCP 的連接數。

如果做過一些傳輸層協議的同學應該會知道,一個 TCP 連接斷開之後,客戶端會進入一個 TIME_WAIT 的階段,然後在 Linux 內核裡面它會等待兩倍的時間,默認是 60 秒,兩倍是 120 秒,120 秒之後才會釋放這個連接,也就是說在 120 秒內,客戶端的端口實際上都是處於被占用的狀態的。所以我們很容易能算出來單個傳統網關它能夠承載的最大的額定 QPS 大概就是不到 600 的樣子,這個肯定是不能滿足用戶需求的。

那麼我們怎麼去解決短連接 TIME_WAIT 這個問題?其實有好幾種方法。

第一種是修改 Linux 的 TCP 傳輸層的內核參數,去啟用重用、快速回收等機制。但對於我們的服務來說並不合適,這需要定製這樣一個系統內核,維護成本會非常高。

第二種,雲上類似的組件怎麼解決?比如騰訊內部的負載均衡,其實很簡單,就是直接擴張集群內的VM數量。比如一台反向代理服務器加上TIME_WAIT快速回收,可以承載5000多QPS,但想要二十萬QPS 怎麼辦,做40個虛擬實例就行了。但這種做法,一是需要內核定製,二是需要我們付出很大的虛擬實例成本,就沒有選擇這種經典方案。

最後一種就是我們改成長連接的機制,類似 Nginx 的 Upstream Keepalive 這樣的機制。改成這樣一個機制之後,其實效果還挺好的,單個穿透網關就可以最大承載 6.5W 個連接數,相當於幾乎 6.5W 個並發。對於同一個目標 IP PORT,它可以直接復用連接,所以它穿透網關的連接數限制就不再是瓶頸了。

長連接的問題

那麼是不是長連接就是完美的?其實並不是。長連接會導致另外一個問題,競態問題(keep-alive race condition),如果在座里有用 HTTP 長連接的方式做 RPC 調用的同學,應該經常會看到這個問題。

客戶端與服務端成功建立了長連接連接靜默一段時間(無 HTTP 請求)服務端因為在一段時間內沒有收到任何數據,主動關閉了 TCP 連接客戶端在收到 TCP 關閉的信息前,發送了一個新的 HTTP 請求服務端收到請求後拒絕,客戶端報錯 ECONNRESET

所以怎麼解決?

第一種方案,就是把客戶端的 keep-alive 超時時間設置得短一些(短於服務端即可)。這樣就可以保證永遠是客戶端這邊超時關閉的 TCP 連接,消除了錯誤的暫態。

但這樣在實際生產環境中是沒法 100% 解決問題的,因為無論把客戶端超時時間如何設置到多少,因為網絡延遲的存在,始終無法保證所有的服務端的 keep-alive 超時時間都長於客戶端的值;如果把客戶端超時時間設置得太小(比如 1 秒),又失去了意義。

那么正確方法就是用短連接去重新試一次。遇到這個錯誤,並且它是長連接的,那麼你就用短連接來發起一次重試。這個也是參考了 Chrome 的做法,Chromium 自己的內核裡面處理了這樣一種情況,瀏覽器里它其實這種長連接也是時刻存在的,下圖是一段它自己裡面的內核的代碼。

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

這是我們一個日常統計的量,大概萬分之 1.3 的概率,會命中這樣一個競態的情況。

小結

非必要情況,不要用 HTTP 協議作為 RPC 底層協議。因為 HTTP 本身最適合的場景是瀏覽器跟服務端來做的,而不是一個服務端和服務端之間的一個 IPC 協議,儘量使用 gRPC 或者類似的這樣的協議來做。

如果不得已使用 HTTP,你的後端可能非常老舊,開啟長連接是一種較好的方案。

長連接需要解決 Keep Alive 的競態問題。如果你用長連接,記得一定要處理這個問題,不然這個問題會成為一個幽靈一樣存在。像剛才說的,萬分之 1.3 非常難復現,但是這個錯誤又會不停地出現在你業務里。

優化點:設計緩存機制

緩存在後台設計里是個萬金油,「哪裡慢了抹哪裡」,但是如何設計緩存其實也是一門學問。

前面提到我們的請求模式都是非常固定的,我們可以根據請求模式來決定緩存數據。緩存都是些什麼東西呢?是路由配置,像域名配置、環境信息、臨時密鑰等這些信息。

這些數據有哪些特點?首先是活躍數據占比小,這確實也是現狀。假設我們全量的用戶裡面每天只有大概 5%~10% 的用戶才是活躍的,這個數據才是真的會經過你的網關。其次是模式比較固定。第三是對實時性的要求不高。比如說變更了路由之後,客戶通常是能夠接受有 1~3 分鐘不定的延遲的,並不要求說變更了路由之後就即刻生效。

因此我們可以針對以上這些特點來設計緩存。第一是因為我們的活躍數據占比很小,所以我們是緩存局部數據,從來不會緩存全量的數據。第二是我們會選取域名、環境這種幾乎是固定的信息作為緩存 Key,這樣緩存的覆蓋面就可以得到保證。第三是讀時緩存要大於寫時緩存,這個後續會提到為什麼會選用讀時緩存,而不是寫入數據的時候把緩存推到我們的網關里。

本地緩存的局限性

最早的時候,實際上我們是有一個最簡單的設計,就是加了一個非常簡單的本地緩存,它可能就是以域名或以路徑作為緩存的 Key,這樣實現簡單但有很多局限性:

首先,要寫大量這樣的代碼,要去先讀本地有沒有緩存,有緩存就緩存,沒緩存去後台要數據。

其次,因為網關不是一個單獨的實例,它不是一個單進程的 Node,單進程的 Node 是扛不了這麼多量的,我們是有很多很多實例,大概是有幾千核,也就是說有幾千個 Node 進程,如果這些進程它本身都有一份自己獨有的內存,也就導致它這個緩存沒有辦法在所有實例上生效。因此當我們的網關規模變得越來越大的時候,緩存也就永遠都只能出現在局部。

為了解決這樣的問題,我們加入了 Redis 中心化的緩存。我們是本地內存 +Redis 兩層緩存,本地內存主要是為了降低 Redis 負載。當 Redis 故障的時候也可以降級到本地緩存,這樣可以避免緩存擊穿問題。Redis 作為一個中心化的緩存,使緩存可以在所有實例上生效,也就是說只要請求過了一次網關,Redis 緩存就會生效,並且所有的網關實例上都會讀到這樣一個緩存。

既然有了緩存,那必然有緩存淘汰的機制,怎麼樣合理地淘汰你的緩存?這裡是用了 TTL + LRU 兩重的機制來保證,針對不同的數據類別,單獨設置參數,為什麼是 TTL + LRU?後面在容災部分會進行解釋。

最後就是抽象出數據加載層,它是專門用來封裝讀操作,包括緩存的管理、請求、刷新、容災這樣一套機制,我們內部會有一個專門模塊來處理。

有了 Redis 之後,我們的緩存是中心化的了,只要你的請求經過了我們之後,你的東西就可以在所有的實例上生效。但是這樣會引來另一個問題,因為淘汰機制是 TTL 的,必然遇到緩存過期。假設是每秒鐘都會回頭髮起一次請求,那麼緩存是一定是會過期的,一分鐘或兩分鐘之後你的緩存就過期了,在過期之後的請求一定是不會命中緩存的,這導致了請求毛刺的問題。這對於在持續流量的下游業務上,體現非常明顯,下圖是我們的一個截圖。

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

同時我們也會判斷,如果緩存剩餘 TTL 小於一定值,它就會即觸發異步刷新的邏輯,這時候我們會去請求後端服務,並且把更新鮮一點的數據刷新到 Redis 里,這就是我們數據加載層內實現 Refresh-Ahead 機制的大概邏輯。

Refresh-Ahead 其實非常簡單,字面意義就是說提前去刷新緩存,緩存數據快到 TTL 了,那麼就去提前更新一下。

能夠這樣設計,更多是基於一個先驗的邏輯,就是說當下這一刻被訪問的數據,大概率在未來的一段時間內會再次被訪問。

下圖是我們加入了 Refresh-Ahead 之後的一個效果,紅色箭頭處是上線時間,上線完之後發現毛刺就明顯變少了。但是為什麼還會有一點毛刺?因為有一些數據它可能真的就是很長的時間,刷新了之後它也依然過期了,依然會產生這樣的毛刺。

最後解釋一下,為什麼我們是網關去後台讀數據,而不是後台把數據推給網關?或者說,為什麼是「拉」而不是「推」?

這其實有幾個考量點,第一,因為是數據局部緩存,所以我們全量數據完全推過來體積很大,大概有幾十個 G,而活躍占比很小,如果完全存在內存里,其實也是一種反模式的做法,不太經濟。

第二,後台能不能只推局部活躍的數據給到網關呢?其實也是不太合適的,後台很難去識別哪些數據是活躍的,哪些數據不是活躍的,這樣實現複雜,難度很大。

第三,網關和它的持久化的後台之間會產生一個緩存 Key 上的耦合,所謂的 Key 上的耦合就是說雙方要約定一組 Key,我這個數據是在 Key 上面去讀,然後你後台要把數據推到 Key 上面。那麼就會帶來另一個問題,一旦 Key 寫錯了,或者說出現了一些不可預料的問題,那就會產生一些比較災難性的後果,所以我們就沒有使用「推」這樣一種方式。

小結

在現代大規模服務里,緩存是必選項,不是可選項。

緩存系統本質是一個小型的分布式系統,無法逾越 CAP 理論。

根據業務場景,合理地權衡性能、一致性和可用性。

架構、鏈路性能優化

前面講的是服務跟服務自己核心的優化,接下來講一講架構和鏈路上的一些性能優化。

上圖是我們整體的一個架構,可以看到一個請求,它從前面的接入層一直走到後端雲資源之間,其實整個鏈路是很長的,這裡分析一下。

首先鏈路很長,涉及邊緣節點、核心業務、後端資源。其次網關是承接 C 端流量的,它其實對終端的性能是很敏感的。第三個就是網絡環境複雜,它涉及到數個網絡平面的打通。因此我們就有了優化方向,第一個是讓鏈路更快更短。第二個是核心服務 Set 化,便於多地域鋪設,終端用戶可以就近接入。第三個就是我們在網絡平面之間會做一些針對性的優化,針對性優化怎麼做,後面會提。

前置鏈路:CDN 就近回源

首先先講一下我們就近接入是怎麼做的,在網關最開始上線的時候,其實會存在一個問題,你的 CDN 節點它其實是通過公網回源的,那為什麼是公網回源?

其實這涉及到國內這幾家大廠的一個網絡架構,簡單地說就是,諸多的 CDN 節點中,有部分可能不是騰訊自建的,所處的網絡可能不是騰訊的內網,它可能是某個運營商,比如說電信、聯通或者網通這樣的邊緣節點,然後它是要走公網回源到騰訊的入口的,這裡的公網回源就非常慢。

比如說廣州的節點回源到上海,並且走 HTTPS 協議,那就是 60~100 毫秒,但問題在於 CDN 節點是有很多的,HTTPS 握手之後,這個鏈接還是沒有辦法復用的,等於說每次請求都要跟源站之間進行一次 HTTPS 握手,這個延遲是不可接受的。

最後我們在網關的回源接入點上做了一層就近接入,也就是說你 CDN 在廣州的節點,可以很就近地接入到我們在部署在廣州的網關,然後網關內部再進行跨地域的訪問,因為這個時候就已經是內網了,速度就會很快。

為了能更好地鋪設網關多地接入點,我們就把網關改造成了地域無感的,即業務邏輯和它所在的地域是解耦的。其次,網關支持跨地域訪問後端資源。最後,配置收歸統一,所有地域用同樣的後端資源配置,減少了我們不同地域的配置發散的問題。

服務本體:SET 化部署

把這些事情做了之後,網關其實達到了「SET 化部署」的概念,降低就近接入成本,任意集群能訪問任意地域的後端資源。相當於網關在所有地域的集群,服務能力都是一模一樣的。你可以使用任意域名去任意網關訪問,獲得到結果都是一樣,這樣 SET 化部署帶來很多好處:

新地域接入點的部署、維護成本極大下降

便於鋪設就近接入點,加速 CDN 接入

不同地域的集群之間服務能力完全等價,帶來容災能力上的提升:流量拆分、故障隔離

也就是說全網只要只剩一個地域的網關可用,我們的服務就可以正常的運行。

底層組件同可用區部署

接下來涉及到網絡平面之間的部署,剛才提到了我們在訪問用戶的資源的時候,其實會經過一個穿透網關,這個是不可避免的,因為它涉及到兩個網絡平面的打通,在穿透網關的這一條鏈路也是可以優化的。

我們可以看一個數據,就是像這種穿透網關和我們雲上的資源,它通常是部署在不同的機房的。

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

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

最後來看效果,我們總體的緩存命中率大概有 99.98%。你可以自己部署一個很簡單的服務到我們的平台上,然後跑一下測速,你會發現全國其實都是綠的,這個也是我們覺得做的還不錯的一個證明。網關自身的耗時,其實 99% 的請求都會在 14 毫秒內被處理完畢。當然你說平均值能不能進一步降低,我覺得是可以的。但是你再進一步降低的話,可能就涉及到 Node.js 本身事件驅動模型這樣一個調度的問題。

小結

大規模服務不能只考慮自身性能,前置 / 後置鏈路都可能成為性能瓶頸。

前置 / 後置鏈路通常與公司基建、網絡架構密切相關,服務研發團隊需要深刻理解。

Node.js 受限於自身異步模型,很難精細化地控制、調度異步 IO,並非萬金油。


3高可用保障

講完性能優化,最後一個部分就是可用性保障,那麼我們通常的服務怎麼來做可用性保障?

第一,不要出事故。服務的健壯性,你本身服務要足夠的健壯,這裡有很多機制,包括灰度發布、熱更新、流量管理、限流、熔斷、防緩存擊穿,還有緩存容災、特性開關、柔性降級……這些東西。

第二,出事故了能感知到。事故永遠是不可避免的,每天都會發生亂七八糟的各種事故,出了事故的時候你是要能夠感知到,並且能夠讓你的系統自修復,或者說你自己人員上來修復。這就涉及到監控告警系統,還有像外部的撥測,用戶反饋監控,社群裡面的一些監控。

第三,能立刻修復事故。出了事故的時候,能夠有機制去立刻修復,比如快速擴容,當然最好的是整個系統它能夠自愈。比如說有個節點它出問題了,你的系統可以自動剔除它,但如果做不到的話,你可以去做一些人工介入的故障隔離,還有多實例災備切換、邏輯降級等。

上圖是我們網關整體的架構,哪些地方容易出現問題?其實每一層都會出問題,所以每一層其實都要相應的去做容災,比如 CDN 到 CLB 這一層,CLB 是不是有多個實例的災備?像 CLB 到網關這一層,是不是網關也是有同樣的多實例,還有一些監控的指標。當然這個篇幅就非常大了,所以我今天只講我們最核心業務層的容災。

核心業務層

先講講我們核心業務層面臨的一些挑戰。

下遊客戶業務隨時有突發大流量,要能抗住衝擊。因為我們是承載公有雲流量的,大概有上萬的客戶他的服務是部署在這裡的,我們永遠不知道這些客戶什麼時候會突然來一個秒殺活動,他可能也從來不給我們報備,這個客戶的流量可能隨時就會翻個幾千倍甚至幾萬倍,所以這時候我們要能扛住這樣一個衝擊。

網關本身依賴服務多,穩定性差異大,要有足夠的自動容錯兜底機制。

能應對多個可用區故障,需要流量調度、災備、多地多活等機制。

我們能先於客戶發現問題,需要業務維度的監控告警機制。

核心業務層:應對大流量衝擊

那我們怎麼樣去應對一個大流量的衝擊?實際上對於一個系統來講,它其實是非常具有破壞性的,它有可能直接把你的緩存還有你的 DB 擊穿,導致你的 DB 直接就夯住了,CPU 被打滿。下圖是我們一次真實的例子,我也不是很排斥說出來。

這是我們今年年初 1 月份的時候,有一個客戶他的流量突然翻了 100 多倍,你可以看到圖上它的量就突然提升,這造成一個什麼問題?它的緩存都是冷的,也就是說訪問量突然提升 100 倍,這 100 倍的請求,可能都要去後台讀它的一些數據,導致直接把後台數據庫的 CPU 打滿了,也導致這個災難進一步擴散,擴散到到所有用戶的數據都讀不出來了。

後來我們就反思了一下這個是不是有問題的?對,是有問題。我們要做什麼事情來防止這樣的問題出現的?

提升服務承載能力

大流量來了,你自己本身要能扛得住,這個時候要去提升你整個服務快速擴容的能力。我們的網關實際上當時已經是完全容器化的,所以這一點還好,它可以快速做到橫向擴容,瞬間擴出幾百核幾千核的資源,可以在幾分鐘之內完成。

其次,我們是使用了單 POD 多 Node 進程,就是我們 1 個 POD 會帶有 8 核,每個核心跑一個進程。這個在 Kubernetes 裡面實際上是一個反模式,因為 Kubernetes 要求 POD 要儘量得小,然后里面就只跑 Node 一個進程。但在工程實踐的時候,我們發現這樣跑雖然沒有問題,但是它擴容速度非常慢,因為每次實例擴出來,都是批量的。比如說我們內部的容器系統,只能說一次擴 100 個實例,也就是說一批也就擴 100 核,並且這 100 核都要分配內部的虛擬 IP,可能會導致內部的 IP 池被耗盡了。最後我們做了合併,起碼一個 POD 能擴出 8 核的資源出來。

保證服務健壯性,不被打垮

當然,除了提升自己抗衝擊的能力以外,還要保證你的後端,保護好你後面的服務。

比如說我們要保證服務的健壯性,在大量衝擊的時候不會被打垮。首先單個實例會做一個限頻限流,防止雪崩,流量限頻是說你一個實例最多可能只能承載 1000QPS 的流量,再多你這個實例就直接放棄掉,就不請求了,這樣可以防止你的整個後台雪崩,不至於說一個 POD 崩了,然後其他 POD 請求又更多,把其他 POD 全部帶崩。

其次,我們要做 DB 的旁路化,網關它讀的永遠是緩存,緩存裡面讀不到,那就是讀不到,它永遠不會直接把請求請求到 DB 裡面去。當有數據寫入或者說數據變更的時候,後台同學會先落 DB,然後再把 DB 的數據推送到緩存裡面,大概就是下圖這樣一個邏輯,防止緩存擊穿問題。

第三,服務降級機制。假設真的出現問題了,比如說你緩存也出問題了,我們可以做一些服務的降級。它可能有一些功能沒有了,比如說有些特殊的 HTTP 請求,響應頭可能沒有了,但是它不會干擾你的主幹邏輯,這個也是可以做的。

核心業務層:應對外部事故

本身服務構建狀態是沒有用的,依賴的外部組件服務也一定會出問題,而且它們的可用性說不定遠遠比你想象的要低,那要怎麼做?

首先,我們內部是有一套集群控制系統的,我們內部分成了主機群、VIP 集群和灰度集群這三個集群。每次發布的時候永遠是會先發灰度集群,驗證一段時間之後才會全讓到其他集群上。這樣的集群隔離也給我們帶來另一個好處,一旦其中有一個集群出現了問題,比如說灰度集群的 DB 掛了,或者 DB 被寫滿了等其他的事故,我們可以很快速地把流量切換到主機群和 VIP 集群上面去,這得益於我們內部其實有一套集群管理的快速切換機制。

服務降級:容災緩存

其次,做容災緩存,假設依賴的服務全掛,服務自動啟用容災緩存,使用舊數據保證基本的可用性。

年初我們有一次這樣的事故,整個機房停電,機房就相當於消失了,導致後台服務全部都沒有了,這種情況怎麼做?這時候就只能是啟用緩存容災。我們網關本地的緩存是永遠不會主動清除的,因為你使用舊數據也比直接報錯要好,這時候我們就會使用一個舊數據來保證它的可用性。

這個怎麼理解呢?我們網關內部的數據它永遠不會被清理,它只會說通過 LRU 的形式被清理掉,比如說我的內存里有可能會有很老的數據,昨天或者前天的數據,但是你在災難發生的時候,即使是昨天還是前天數據它依然是有用的,它依然可以拿出來保證你最基本的可用性,下面是我們一個邏輯圖,大家可以了解。

服務降級:跳過非核心鏈路

你的服務有可能會降級,我剛提到我們網關有鑒權的功能,鑒權功能其實依賴我們騰訊內部的一個組件。這樣一個組件,它其實也是不穩定的,有時候會出問題,那麼遇到這種問題怎麼辦?鑒權都沒有辦法鑒權了。這個時候我們在一些場景允許的情況下,會直接把鑒權的邏輯給跳過,我們不鑒權了,先放過一段時間,總比說我直接拒絕掉,直接報錯這個請求要好得多。

核心業務層:網關自身災備、異地多活

最後一點就是我剛剛提到的,因為我們網關做了服務 SET 化改造、部署後,天然獲得了跨 AZ、跨地域熱切換的能力。簡單來講,只要全網還剩一個網關可用區,業務流量就可以切換,網關的服務就不會宕機,當然切換現在還沒有做到完全自動化,因為涉及到跨地域的切換,這個是需要人工介入的,不過說實話我們還沒有遇到過這麼大的災難。

做個小結,我們做了多集群切換、緩存容災、柔性降級這些事情之後可以達到怎樣一個效果:

容許後台最多 (N-1) 個集群長時間故障

容許後台全部集群短時間故障

容許內部 DNS 全網故障

核心業務層:還有什麼能做的?

我們還有什麼能做的?如果某天,容器平台全網故障,怎麼辦?其實也是我們現在構思的一個東西,我們是不是可以做到一個異構部署這樣一個形態。

服務異構部署,即使容器平台全地域全可用區故障,也能切換到基於虛擬機的架構上,這也是我們正在籌劃的一個事情。

最後說完了容災,接下來說怎麼做監控告警?做監控告警其實比較老生常談了,但是也可以在這裡稍微掃個盲,我們的所有網關,它會把自己所有的訪問日誌推送到我們的 ES(elasticsearch)的集群上,然後我們會有一個專門的 TCB Alarm 這樣一個模塊,它會去定期的輪詢這樣的日誌,去檢查這些日誌裡面有沒有一些異常,比如說某個用戶的流量突然高了,或者某個錯誤碼突然增多,它會把這樣的信息通過電話或者企業微信推送給我們。

因為是基於 ES 的,所以監控可以做得非常精細,甚至可以做到感知到某個接口,今天的耗時比昨天要高超過 50%,那這個接口是不是今天做什麼變更讓它變慢了?

我們也可以做針對下游重點客戶、業務的一些監控,比如說幾個省的健康碼,都可以做重點的監控。

4總結

其實我只是選取了整個 Node 服務裡面非常小的兩個切面來講,性能優化和高可用保障。可能很難覆蓋到很全面,但是我想講的稍微深一點,能夠讓大家有些足夠的益處。

首先,服務核心優化這裡講了長連接和緩存機制,可能是大部分服務或多或少都會遇到的問題。然後,鏈路架構優化這裡講了就近接入和 Set 化部署這樣一個機制。高可用保障我主要是介紹了核心業務層的一些高可用保障,包括應對大流量衝擊,怎麼做緩存容災,柔性降級,多可用區、多地域切換,監控告警這些東西。

最後我想就今天的演講做一個總結。

第一,Node.js 服務與其它後台服務並無二致,遵循同一套方法論。

Node.js 服務本質上也是做後台開發的,與其它後台服務並無二致,遵循同一套方法論。我今天的演講如果把 Node.js 改成 Golang 改成 Java,我就不站在這裡了,可能我就去 Golang 的會上講,實際上是一樣的。

第二,Node.js 足以承載核心大規模服務,無須妄自菲薄。

我們這套網關其實也現網驗證兩年了,它跟別的技術棧的這種後台服務來講,其實並沒有太大的缺點。所以大家在拿 Node.js 做這種海量服務的時候,可以不用覺得 Node.js 好像只是個前端的小玩具,好像不是很適合這種成熟的業務,成熟業務是不是還是用 Java 來寫,拿 C++ 來寫,其實是沒有必要的。

當然,如果你真的需要對你的 IO 調度非常精細的時候,那麼你可能得選用 C++ 或者 Rust,這樣可以直接調度 IO 的方案。

第三,前端處在技術的十字路口,不應自我局限於「Web 前端」領域。

最後一個也是我今天想提的,可能我講這麼多,大家覺得我不是一個前端工程師對不對?但實際上我在公司內部的職級確實是個前端工程師。我一直覺得前端它是站在一個技術的十字路口的,所以大家工作中也好,還是學習中也好,不用把自己局限在「Web 前端」這樣一個領域。這次 GMTC 大會也可以看到,前端現在也不只是大家傳統意義上的可能就是寫頁面這樣一個領域。

這是一個當年喬布斯演講用的一個圖,他說蘋果是站在技術和人文的十字路口,實際上前端也是站在很多技術的十字路口上。

那麼我的演講就到此結束,謝謝大家。

嘉賓介紹:

王偉嘉:騰訊雲 CloudBase 前端負責人

畢業於復旦大學,現任騰訊雲 CloudBase 前端負責人,Node.js Core Collaborator,騰訊 TC39 代表。目前在騰訊雲 CloudBase 團隊負責小程序·雲開發、Webify 等公有雲產品的核心設計和研發,服務了下游數十萬開發者和用戶,對 Node.js 服務架構、全棧開發、雲原生開發、Serverless 有較豐富的經驗,先後在阿里 D2、GMTC、騰訊 TWeb 等大會上發表過技術演講。

今日好文推薦

40歲從零開始學習軟件開發,四年後我成了首席研發

75%新項目都可以「無腦」選擇單體架構

InfoQ 最新 Java 發展趨勢報告

AlphaCode編程比賽擊敗一半程序員;微信超1億人視頻號看春晚,6.6億人搶紅包;Flutter 2.10發布 | Q資訊

點個在看少個 bug👇

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()