本文目錄
- 說在前面
- 喜馬拉雅自研億級API網關技術實踐
- 1、第1版:Tomcat NIO+Async Servlet
- 2、第2版?.NETty+全異步
- 2.1 接入層
- 2.2 業務邏輯層
- 2.3 服務調用層
- 2.3.1 異步 Push
- 2.3.2 連接池
- 2.3.3 Connection:close
- 2.3.4 寫超時
- 3、全鏈路超時機制
- 4、監控報警
- 5、性能優化實踐
- 5.1 對象池技術
- 5.2 上下文切換
- 5.3 GC優化
- 5.4 日志
- 6、未來規劃
- 說在最后:有問題可以找老架構取經
- 部分歷史案例
喜馬拉雅自研億級API網關技術實踐
網關作為一種發展較為完善的產品,各大互聯網公司普遍采用它作為中間件,以應對公共業務需求的不斷浮現,并能迅速迭代更新。
如果沒有網關,要更新一個公共特性,就得推動所有業務方都進行更新和發布,這無疑是效率極低的。然而,有了網關之后,這一切都不再是問題。
喜馬拉雅也如此,用戶數量已增長到 6 億級別,Web 服務數量超過 500 個,目前我們的網關每天處理超過 200 億次的調用,單機 QPS 峰值可達 4w+。
除了實現基本的反向代理功能,網關還具備許多公共特性,如黑白名單、流量控制、身份驗證、熔斷、API 發布、監控和報警等。根據業務方的需求,我們還實現了流量調度、流量復制、預發布、智能升級、流量預熱等相關功能。
從技術上來說,喜馬拉雅API網關的技術演進路線圖大致如下:
注意:請點擊圖像以查看清晰的視圖!
本文將介紹在喜馬拉雅 API 網關面臨億級流量的情況下,我們如何進行技術演進,以及我們的實踐經驗總結。
1、第1版:Tomcat NIO+Async Servlet
在架構設計中,網關的關鍵之處在于接收到請求并調用后端服務時,不能發生阻塞(Block),否則網關的處理能力將受到限制。
這是因為最耗時的操作就是遠程調用后端服務這個過程。
如果此處發生阻塞,Tomcat 的工作線程會被全部 block 住了,等待后端服務響應的過程中無法處理其他請求,因此這里必須采用異步處理。
架構圖如下:
注意:請點擊圖像以查看清晰的視圖!
在這個版本中,我們實現了一個單獨的 Push 層,用于在網關接收到響應后,響應客戶端,并通過此層實現與后端服務的通信。
該層使用的是 HttpNioClient,支持業務功能包括黑白名單、流量控制、身份驗證、API 發布等。
然而,這個版本僅在功能上滿足了網關的要求,處理能力很快成為瓶頸。當單機 QPS 達到 5K 時,會頻繁發生 Full GC。
通過分析線上堆,我們發現問題在于 Tomcat 緩存了大量 HTTP 請求。因為 Tomcat 默認會緩存 200 個 requestProcessor,每個處理器都關聯一個 request。
另外,Servlet 3.0 的 Tomcat 異步實現可能會導致內存泄漏。后來我們通過減少這個配置,效果明顯。
然而,這種調整會導致性能下降。總結一下,基于 Tomcat 作為接入端存在以下問題:
Tomcat 自身的問題:
-
1)緩存過多,Tomcat 使用了許多對象池技術,在有限內存的情況下,流量增大時很容易觸發 GC;
-
2)內存 Copy,Tomcat 的默認內存使用堆內存,因此數據需要從堆內讀取,而后端服務是 Netty,使用堆外內存,需要經過多次 Copy;
-
3)Tomcat 還有個問題是讀 body 是阻塞的, Tomcat 的 NIO 模型和 reactor 模型不同,讀 body 是 block 的。
這里再分享一張 Tomcat buffer 的關系圖:
注意:請點擊圖像以查看清晰的視圖!
從上圖中,我們能夠明顯觀察到,Tomcat 的封裝功能相當完善,但在內部默認設置下,會有三次 copy。
HttpNioClient 的問題:在獲取和釋放連接的過程中都需要進行加鎖,針對類似網關這樣的代理服務場景,會導致頻繁地建立和關閉連接,這無疑會對性能產生負面影響。
鑒于 Tomcat 存在的這些難題,我們在后續對接入端進行了優化,采用 Netty 作為接入層和服務調用層,也就是我們的第二版,成功地解決了上述問題,實現了理想的性能。
2、第2版:Netty+全異步
基于 Netty 的優勢,我們構建了全異步、無鎖、分層的架構。
先看下我們基于 Netty 做接入端的架構圖:
注意:請點擊圖像以查看清晰的視圖!
2.1 接入層
Netty 的 IO 線程主要負責 HTTP 協議的編解碼工作,同時也監控并報警協議層面的異常情況。
我們對 HTTP 協議的編解碼進行了優化,并對異常和攻擊性請求進行了監控和可視化處理。
例如,我們對 HTTP 請求行和請求頭的大小都有限制,而 Tomcat 是將請求行和請求頭一起計算,總大小不超過 8K,而 Netty 是分別對兩者設置大小限制。
如果客戶端發送的請求超過了設定的閥值,帶有 cookie 的請求很容易超過這個限制,一般情況下,Netty 會直接響應 400 給客戶端。
在優化后,我們只取正常大小的部分,并標記協議解析失敗,這樣在業務層就可以判斷出是哪個服務出現了這類問題。
對于其他攻擊性的請求,例如只發送請求頭而不發送 body 或者只發送部分內容,都需要進行監控和報警。
2.2 業務邏輯層
這一層負責實現一系列支持業務的公共邏輯,包括 API 路由、流量調度等,采用責任鏈模式,這一層不會進行 IO 操作。
在業界和大型企業的網關設計中,業務邏輯層通常都被設計成責任鏈模式,公共的業務邏輯也在這一層實現。
在這一層,我們也執行了相似的操作,并支持以下功能:
-
1)用戶認證和登錄驗證,支持接口級別的配置;
-
2)黑白名單:包括全局和應用的黑白名單,以及 IP 和參數級別的限制;
-
3)流量控制:提供自動和手動控制,自動控制可攔截過大流量,通過令牌桶算法實現;
-
4)智能熔斷:在 Histrix 的基礎上進行改進,支持自動升降級,我們采用全自動方式,也支持手動配置立即熔斷,即當服務異常比例達到設定值時,自動觸發熔斷;
-
5)灰度發布:對于新啟動的機器的流量,我們支持類似于 TCP 的慢啟動機制,為機器提供一段預熱時間;
-
6)統一降級:我們對所有轉發失敗的請求都會執行統一降級操作,只要業務方配置了降級規則,都會進行降級,我們支持將降級規則細化到參數級別,包括請求頭中的值,非常細粒度,此外,我們還會與 varnish 集成,支持 varnish 的優雅降級;
-
7)流量調度:支持業務根據篩選規則,將流量分配到對應的機器,也支持僅讓篩選的流量訪問該機器,這在排查問題/新功能發布驗證時非常有用,可以先通過小部分流量驗證,再大面積發布上線;
-
8)流量 copy:我們支持根據規則對線上原始請求 copy 一份,將其寫入 MQ 或其他 upstream,用于線上跨機房驗證和壓力測試;
-
9)請求日志采樣:我們對所有失敗的請求都會進行采樣并保存到磁盤,以供業務方排查問題,同時也支持業務方根據規則進行個性化采樣,我們采樣了整個生命周期的數據,包括請求和響應相關的所有數據。
上述提到的所有功能都是對流量進行管理,我們每個功能都作為一個 filter,處理失敗都不會影響轉發流程,而且所有這些規則的元數據在網關啟動時就會全部初始化好。
在執行過程中,不會進行 IO 操作,目前有些設計會對多個 filter 進行并發執行,由于我們的操作都是在內存中進行,開銷并不大,所以我們目前并未支持并發執行。
另外,規則可能會發生變化,所有需要進行規則的動態刷新。
我們在修改規則時,會通知網關服務,進行實時刷新,我們對內部自己的這種元數據更新請求,通過獨立的線程處理,防止 IO 操作時影響業務線程。
2.3 服務調用層
服務調用對于代理網關服務非常關鍵,這個環節,性能必須很高:必須采用異步方式,
我們利用 Netty 實現了這一目標,同時也充分利用了 Netty 提供的連接池,實現了獲取和釋放的無鎖操作。
2.3.1 異步 Push
在發起服務調用后,網關允許工作線程繼續處理其他請求,而無需等待服務端返回。
在這個設計中,我們為每個請求創建一個上下文,發送請求后,將該請求的 context 綁定到相應的連接上,當 Netty 收到服務端響應時,會在連接上執行 read 操作。
解碼完成后,再從連接上獲取相應的 context,通過 context 可以獲取到接入端的 session。
這樣,push 通過 session 將響應寫回客戶端,這個設計基于 HTTP 連接的獨占性,即連接和請求上下文綁定。
2.3.2 連接池
連接池的原理如下圖:
注意:請點擊圖像以查看清晰的視圖!
服務調用層除了異步發起遠程調用外,還需要管理后端服務的連接。
HTTP 與 RPC 不同,HTTP 連接是獨占的,所以在釋放連接時需要特別小心,必須等待服務端響應完成后才能釋放,此外,連接關閉的處理也需要謹慎。
總結如下幾點:
-
1)Connection:close; -
2)空閑超時,關閉連接; -
3)讀超時關閉連接; -
4)寫超時,關閉連接; -
5)Fin、Reset。
上面幾種需要關閉連接的場景,下面主要說下 Connection:close 和空閑寫超時兩種,其他情況如讀超時、連接空閑超時、收到 fin、reset 碼等都比較常見。
2.3.3 Connection:close
后端服務采用的是 Tomcat,它對連接的重用次數有規定,默認為 100 次。
當達到 100 次限制時,Tomcat 會在響應頭中添加 Connection:close,要求客戶端關閉該連接,否則再次使用該連接發送請求會出現 400 錯誤。
還有就是如果前端的請求帶了 connection:close,那 Tomcat 就不會等待該連接重用滿 100 次,即一次就關閉連接。
在響應頭中添加 Connection:close 后,連接變為短連接。
在與 Tomcat 保持長連接時,需要注意這一點,如果要利用該連接,需要主動移除 close 頭。
2.3.4 寫超時
首先,網關在何時開始計算服務的超時時間?
如果從調用 writeAndFlush 開始計算,實際上包含了 Netty 對 HTTP 的編碼時間和從隊列中發送請求即 flush 的時間,這樣對后端服務不公平。
因此,需要在真正 flush 成功后開始計時,這樣最接近服務端,當然還包含了網絡往返時間和內核協議棧處理時間,這是無法避免的,但基本穩定。
因此,我們在 flush 成功回調后啟動超時任務。
需要注意的是:如果 flush 不能快速回調,例如遇到一個大的 POST 請求,body 部分較大,而 Netty 發送時默認第一次只發送 1k 大小。
如果尚未發送完畢,會增大發送大小繼續發送,如果在 Netty 發送 16 次后仍未發送完成,將不再繼續發送,而是提交一個 flushTask 到任務隊列,待下次執行后再發送。
此時,flush 回調時間較長,導致此類請求無法及時關閉,后端服務 Tomcat 會一直阻塞在讀取 body 部分,基于上述分析,我們需要設置寫超時,對于大的 body 請求,通過寫超時及時關閉連接。
3、全鏈路超時機制

注意:請點擊圖像以查看清晰的視圖!
上圖是我們在整個鏈路超時處理的機制:
-
1)協議解析超時; -
2)等待隊列超時; -
3)建連超時; -
4)等待連接超時; -
5)寫前檢查是否超時; -
6)寫超時; -
7)響應超時。
4、監控報警
對于網關的業務方來說,他們能看到的是監控和警報功能,我們能夠實現秒級的報警和監控,將監控數據定時上傳到我們的管理系統,由管理系統負責匯總統計并存儲到 InfluxDB 中。
我們對 HTTP 協議進行了全面的監控和警報,涵蓋了協議層和服務層的問題。
協議層:
-
1)針對攻擊性請求,只發送頭部,不發送或只發送部分 body,我們會進行采樣并記錄,還原現場,并觸發警報; -
2)對于 Line 或 Head 或 Body 過大的請求,我們會進行采樣記錄,還原現場,并及時發出警報。
應用層:
-
1)監控耗時:包括慢請求,超時請求,以及 tp99,tp999 等;
-
2)監控 OPS:并及時發出警報;
-
3)帶寬監控和報警:支持對請求和響應的行,頭,body 單獨監控;
-
4)響應碼監控:特別是 400,和 404;
-
5)連接監控:我們對接入端的連接,以及與后端服務的連接,以及后端服務連接上待發送字節大小都進行了監控;
-
6)失敗請求監控;
-
7)流量抖動報警:這是非常必要的,流量抖動可能是出現問題,或者是問題即將出現的預兆。
總體架構:
注意:請點擊圖像以查看清晰的視圖!
5、性能優化實踐
5.1 對象池技術
針對高并發系統,不斷地創建對象不僅會占用內存資源,還會對垃圾回收過程產生壓力。
為了解決這個問題,我們在實現過程中會對諸如線程池的任務、StringBuffer 等頻繁使用的對象進行重用,從而降低內存分配的開銷。
5.2 上下文切換
在高并發系統中,通常會采用異步設計。異步化后,線程上下文切換的問題必須得到關注。
我們的線程模型如下:
注意:請點擊圖像以查看清晰的視圖!
我們的網關沒有涉及 I/O 操作,但在業務邏輯處理方面仍然采用了 Netty 的 I/O 編解碼線程異步方式。
這主要有兩個原因:
-
1)防止開發人員編寫的代碼出現阻塞現象;
-
2)在突發情況下,業務邏輯可能會產生大量的日志記錄,我們允許在推送線程時使用 Netty 的 I/O 線程作為替代。這種做法可以減少 CPU 上下文切換的次數,從而提高整體吞吐量。我們不能僅僅為了異步而異步,Zuul2 的設計理念與我們的做法相似。
5.3 GC優化
在高并發系統中,垃圾回收GC的優化是必不可少的。
我們采用了對象池技術和堆外內存,使得對象很少進入老年代,同時年輕代的設置較大,SurvivorRatio 設置為 2,晉升年齡設置最大為 15,以盡量讓對象在年輕代就被回收。
但監控發現老年代的內存仍在緩慢增長。通過dump分析,我們每個后端服務創建一個鏈接,都時有一個socket,socket的AbstractPlAInSocketImpl,而AbstractPlainSocketImpl就重寫了Object類的finalize方法。
實現如下:
/**
* Cleans up if the user forgets to close it.
*/
protected void finalize() throws IOException {
close();
}
是為了我們沒有主動關閉鏈接,做的一個兜底,在gc回收的時候,先把對應的鏈接資源給釋放了。
由于finalize 的機制是通過 JVM 的 Finalizer 線程處理的,其優先級不高,默認為 8。它需要等待 Finalizer 線程把 ReferenceQueue 的對象對應的 finalize 方法執行完,并等到下次垃圾回收時,才能回收該對象。這導致創建鏈接的這些對象在年輕代不能立即回收,從而進入了老年代,這也是老年代持續緩慢增長的原因。
5.4 日志
在高并發系統中,尤其是 Netty 的 I/O 線程,除了執行 I/O 讀寫操作外,還需執行異步任務和定時任務。如果 I/O 線程處理不過隊列中的任務,可能會導致新進來的異步任務被拒絕。
在什么情況下可能會出現這種情況呢?異步讀寫問題不大,主要是多耗點 CPU。最有可能阻塞 I/O 線程的是日志記錄。目前 Log4j 的 ConsoleAppender 日志 immediateFlush 屬性默認為 true,即每次記錄日志都是同步寫入磁盤,這對于內存操作來說,速度較慢。
同時,AsyncAppender 的日志隊列滿了也會阻塞線程。Log4j 默認的 buffer 大小是 128,而且是阻塞的。即當 buffer 大小達到 128 時,會阻塞寫日志的線程。在并發寫日志量較大且堆棧較深的情況下,Log4j 的 Dispatcher 線程可能會變慢,需要刷盤。這樣 buffer 就不能快速消費,很容易寫滿日志事件,導致 Netty I/O 線程被阻塞。因此,在記錄日志時,我們需要注意精簡。
6、未來規劃
目前,我們都在使用基于 HTTP/1 的協議。
相對于 HTTP/1,HTTP/2 在連接層面實現了服務,即在一個連接上可以發送多個 HTTP 請求。
這就意味著 HTTP 連接可以像 RPC 連接一樣,建立幾個連接即可,完全解決了 HTTP/1 連接無法復用導致的重復建連和慢啟動的開銷。
我們正在基于 Netty 升級到 HTTP/2,除了技術升級外,我們還在不斷優化監控報警,以便為業務方提供準確無誤的報警。此外,我們還在作為統一接入網關與業務方實施全面的降級措施,以確保全站任何故障都能通過網關第一時間降級,這也是我們的重點工作。