日流量 200 億,攜程網關的架構設計
方案的作者:Butters,攜程軟件技術專家,專注於網絡架構、API 網關、負載均衡、Service Mesh 等領域。
一、概述
類似於許多企業的做法,攜程 API 網關是伴隨着微服務架構一同引入的基礎設施,其最初版本於 2014 年發佈。隨着服務化在公司內的迅速推進,網關逐步成爲應用程序暴露在外網的標準解決方案。後續的 “ALL IN 無線”、國際化、異地多活等項目,網關都隨着公司公共業務與基礎架構的共同演進而不斷髮展。截至 2021 年 7 月,整體接入服務數量超過 3000 個,日均處理流量達到 200 億。
在技術方案方面,公司微服務的早期發展深受 NetflixOSS 的影響,網關部分最早也是參考了 Zuul 1.0 進行的二次開發,其核心可以總結爲以下四點:
-
server 端:Tomcat NIO + AsyncServlet
-
業務流程:獨立線程池,分階段的責任鏈模式
-
client 端:Apache HttpClient,同步調用
-
核心組件:Archaius(動態配置客戶端),Hystrix(熔斷限流),Groovy(熱更新支持)
衆所周知,同步調用會阻塞線程,系統的吞吐能力受 IO 影響較大。
作爲行業的領先者,Zuul 在設計時已經考慮到了這個問題:通過引入 Hystrix,實現資源隔離和限流,將故障(慢 IO)限制在一定範圍內;結合熔斷策略,可以提前釋放部分線程資源;最終達到局部異常不會影響整體的目標。
然而,隨着公司業務的不斷髮展,上述策略的效果逐漸減弱,主要原因有兩方面:
-
業務出海:網關作爲海外接入層,部分流量需要轉回國內,慢 IO 成爲常態
-
服務規模增長:局部異常成爲常態,加上微服務異常擴散的特性,線程池可能長期處於亞健康狀態
全異步改造是攜程 API 網關近年來的一項核心工作,本文也將圍繞此展開,探討我們在網關方面的工作與實踐經驗。
重點包括:性能優化、業務形態、技術架構、治理經驗等。
二、高性能網關核心設計
2.1. 異步流程設計
全異步 = server 端異步 + 業務流程異步 + client 端異步
對於 server 與 client 端,我們採用了 Netty 框架,其 NIO/Epoll + Eventloop 的本質就是事件驅動的設計。
我們改造的核心部分是將業務流程進行異步化,常見的異步場景有:
-
業務 IO 事件:例如請求校驗、身份驗證,涉及遠程調用
-
自身 IO 事件:例如讀取到了報文的前 xx 字節
-
請求轉發:包括 TCP 連接,HTTP 請求
從經驗上看,異步編程在設計和讀寫方面相比同步會稍微困難一些,主要包括:
-
流程設計 & 狀態轉換
-
異常處理,包括常規異常與超時
-
上下文傳遞,包括業務上下文與 trace log
-
線程調度
-
流量控制
特別是在 Netty 上下文內,如果對 ByteBuf 的生命週期設計不完善,很容易導致內存泄漏。
圍繞這些問題,我們設計了對應外圍框架,最大努力對業務代碼抹平同步 / 異步差異,方便開發;同時默認兜底與容錯,保證程序整體安全。
在工具方面,我們使用了 RxJava,其主要流程如下圖所示。
-
Maybe
-
RxJava 的內置容器類,表示正常結束、有且僅有一個對象返回、異常三種狀態
-
響應式,便於整體狀態機設計,自帶異常處理、超時、線程調度等封裝
-
Maybe.empty()/Maybe.just(T),適用同步場景
-
工具類 RxJavaPlugins,方便切面邏輯封裝
-
Filter
-
代表一塊獨立的業務邏輯,同步 & 異步業務統一接口,返回 Maybe
-
異步場景(如遠程調用)統一封裝,如涉及線程切換,通過 maybe.obesrveOn(eventloop) 切回
-
異步 filter 默認增加超時,並按弱依賴處理,忽略錯誤
public interface Processor<T> {
ProcessorType getType();
int getOrder();
boolean shouldProcess(RequestContext context);
//對外統一封裝爲Maybe
Maybe<T> process(RequestContext context) throws Exception;
}
public abstract class AbstractProcessor implements Processor {
//同步&無響應,繼承此方法
//場景:常規業務處理
protected void processSync(RequestContext context) throws Exception {}
//同步&有響應,繼承此方法,健康檢測
//場景:健康檢測、未通過校驗時的靜態響應
protected T processSyncAndGetReponse(RequestContext context) throws Exception {
process(context);
return null;
};
//異步,繼承此方法
//場景:認證、鑑權等涉及遠程調用的模塊
protected Maybe<T> processAsync(RequestContext context) throws Exception
{
T response = processSyncAndGetReponse(context);
if (response == null) {
return Maybe.empty();
} else {
return Maybe.just(response);
}
};
@Override
public Maybe<T> process(RequestContext context) throws Exception {
Maybe<T> maybe = processAsync(context);
if (maybe instanceof ScalarCallable) {
//標識同步方法,無需額外封裝
return maybe;
} else {
//統一加超時,默認忽略錯誤
return maybe.timeout(getAsyncTimeout(context), TimeUnit.MILLISECONDS,
Schedulers.from(context.getEventloop()), timeoutFallback(context));
}
}
protected long getAsyncTimeout(RequestContext context) {
return 2000;
}
protected Maybe<T> timeoutFallback(RequestContext context) {
return Maybe.empty();
}
}
-
整體流程
-
沿用責任鏈的設計,分爲 inbound、outbound、error、log 四階段
-
各階段由一或多個 filter 組成
-
filter 順序執行,遇到異常則中斷,inbound 期間任意 filter 返回 response 也觸發中斷
public class RxUtil{
//組合某階段(如Inbound)內的多個filter(即Callable<Maybe<T>>)
public static <T> Maybe<T> concat(Iterable<? extends Callable<Maybe<T>>> iterable) {
Iterator<? extends Callable<Maybe<T>>> sources = iterable.iterator();
while (sources.hasNext()) {
Maybe<T> maybe;
try {
maybe = sources.next().call();
} catch (Exception e) {
return Maybe.error(e);
}
if (maybe != null) {
if (maybe instanceof ScalarCallable) {
//同步方法
T response = ((ScalarCallable<T>)maybe).call();
if (response != null) {
//有response,中斷
return maybe;
}
} else {
//異步方法
if (sources.hasNext()) {
//將sources傳入回調,後續filter重複此邏輯
return new ConcattedMaybe(maybe, sources);
} else {
return maybe;
}
}
}
}
return Maybe.empty();
}
}
public class ProcessEngine{
//各個階段,增加默認超時與錯誤處理
private void process(RequestContext context) {
List<Callable<Maybe<Response>>> inboundTask = get(ProcessorType.INBOUND, context);
List<Callable<Maybe<Void>>> outboundTask = get(ProcessorType.OUTBOUND, context);
List<Callable<Maybe<Response>>> errorTask = get(ProcessorType.ERROR, context);
List<Callable<Maybe<Void>>> logTask = get(ProcessorType.LOG, context);
RxUtil.concat(inboundTask) //inbound階段
.toSingle() //獲取response
.flatMapMaybe(response -> {
context.setOriginResponse(response);
return RxUtil.concat(outboundTask);
}) //進入outbound
.onErrorResumeNext(e -> {
context.setThrowable(e);
return RxUtil.concat(errorTask).flatMap(response -> {
context.resetResponse(response);
return RxUtil.concat(outboundTask);
});
}) //異常則進入error,並重新進入outbound
.flatMap(response -> RxUtil.concat(logTask)) //日誌階段
.timeout(asyncTimeout.get(), TimeUnit.MILLISECONDS, Schedulers.from(context.getEventloop()),
Maybe.error(new ServerException(500, "Async-Timeout-Processing"))
) //全局兜底超時
.subscribe( //釋放資源
unused -> {
logger.error("this should not happen, " + context);
context.release();
},
e -> {
logger.error("this should not happen, " + context, e);
context.release();
},
() -> context.release()
);
}
}
2.2. 流式轉發 & 單線程
以 HTTP 爲例,報文可劃分爲 initial line/header/body 三個組成部分。
在攜程,網關層業務不涉及請求體 body。
因爲無需全量存,所以解析完請求頭 header 後可直接進入業務流程。
同時,如果收到請求體 body 部分:
①若已向 upstream 轉發請求,則直接轉發;
②否則,需要將其暫時存儲,等待業務流程處理完畢後,再將其與 initial line/header 一併發送;
③對 upstream 端響應的處理方式亦然。
對比完整解析 HTTP 報文的方式,這樣處理:
-
更早進入業務流程,意味着 upstream 更早接收到請求,可以有效地降低網關層引入的延遲
-
body 生命週期被壓縮,可降低網關自身的內存開銷
儘管性能有所提升,但流式處理也大大增加了整個流程的複雜性。
在非流式場景下,Netty Server 端編解碼、入向業務邏輯、Netty Client 端的編解碼、出向業務邏輯,各個子流程相互獨立,各自處理完整的 HTTP 對象。而採用流式處理後,請求可能同時處於多個流程中,這帶來了以下三個挑戰:
-
線程安全問題:如果各個流程使用不同的線程,那麼可能會涉及到上下文的併發修改;
-
多階段聯動:比如 Netty Server 請求接收一半遇到了連接中斷,此時已經連上了 upstream,那麼 upstream 側的協議棧是走不完的,也必須隨之關閉連接;
-
邊緣場景處理:比如 upstream 在請求未完整發送情況下返回了 404/413,是選擇繼續發送、走完協議棧、讓連接能夠複用,還是選擇提前終止流程,節約資源,但同時放棄連接?再比如,upstream 已收到請求但未響應,此時 Netty Server 突然斷開,Netty Client 是否也要隨之斷開?等等。
爲了應對這些挑戰,我們採用了單線程的方式,核心設計包括:
-
上線文綁定 Eventloop,Netty Server / 業務流程 / Netty Client 在同個 eventloop 執行;
-
異步 filter 如因 IO 庫的關係,必須使用獨立線程池,那在後置處理上必須切回;
-
流程內資源做必要的線程隔離(如連接池);
單線程方式避免了併發問題,在處理多階段聯動、邊緣場景問題時,整個系統處於確定的狀態下,有效降低了開發難度和風險;此外,減少線程切換,也能在一定程度上提升性能。然而,由於 worker 線程數較少(一般等於 CPU 核數),eventloop 內必須完全避免 IO 操作,否則將對系統的吞吐量造成重大影響。
2.3 其他優化
- 內部變量懶加載
對於請求的 cookie/query 等字段,如果沒有必要,不提前進行字符串解析
- 堆外內存 & 零拷貝
結合前文的流式轉發設計,進一步減少系統內存佔用。
- ZGC
由於項目升級到 TLSv1.3,引入了 JDK11(JDK8 支持較晚,8u261 版本,2020.7.14),同時也嘗試了新一代的垃圾回收算法,其實際表現確實如人們所期待的那樣出色。儘管 CPU 佔用有所增加,但整體 GC 耗時下降非常顯著。
- 定製的 HTTP 編解碼
由於 HTTP 協議的歷史悠久及其開放性,產生了很多 “不良實踐”,輕則影響請求成功率,重則對網站安全構成威脅。
- 流量治理
對於請求體過大(413)、URI 過長(414)、非 ASCII 字符(400)等問題,一般的 Web 服務器會選擇直接拒絕並返回相應的狀態碼。由於這類問題跳過了業務流程,因此在統計、服務定位和故障排查方面會帶來一些麻煩。通過擴展編解碼,讓問題請求也能完成路由流程,有助於解決非標準流量的管理問題。
- 請求過濾
例如 request smuggling(Netty 4.1.61.Final 修復,2021.3.30 發佈)。通過擴展編解碼,增加自定義校驗邏輯,可以讓安全補丁更快地得以應用。
三、網關業務形態
作爲獨立的、統一的入向流量收口點,網關對企業的價值主要展現在三個方面:
-
解耦不同網絡環境:典型場景包括內網 & 外網、生產環境 & 辦公區、IDC 內部不同安全域、專線等;
-
天然的公共業務切面:包括安全 & 認證 & 反爬、路由 & 灰度、限流 & 熔斷 & 降級、監控 & 告警 & 排障等;
- 高效、靈活的流量控制
這裏展開講幾個細分場景:
- 私有協議
在收口的客戶端(APP)中,框架層會攔截用戶發起的 HTTP 請求,通過私有協議(SOTP)的方式傳送到服務端。
選址方面:①通過服務端分配 IP,防止 DNS 劫持;②進行連接預熱;③採用自定義的選址策略,可以根據網絡狀況、環境等因素自行切換。
交互方式上:①採用更輕量的協議體;②統一進行加密與壓縮與多路複用;③在入口處由網關統一轉換協議,對業務無影響。
- 鏈路優化
關鍵在於引入接入層,讓遠程用戶就近訪問,解決握手開銷過大的問題。同時,由於接入層與 IDC 兩端都是可控的,因此在網絡鏈路選擇、協議交互模式等方面都有更大的優化空間。
- 異地多活
與按比例分配、就近訪問策略等不同,在異地多活模式下,網關(接入層)需要根據業務維度的 shardingKey 進行分流(如 userId),防止底層數據衝突。
四、網關治理
下所示的圖表概括了網上網關的工作狀態。縱向對應我們的業務流程:各種渠道(如 APP、H5、小程序、供應商)和各種協議(如 HTTP、SOTP)的流量通過負載均衡分配到網關,通過一系列業務邏輯處理後,最終被轉發到後端服務。經過第二章的改進後,橫向業務在性能和穩定性方面都得到了顯著提升。
另一方面,由於多渠道 / 協議的存在,網上網關根據業務進行了獨立集羣的部署。早期,業務差異(如路由數據、功能模塊)通過獨立的代碼分支進行管理,但是隨着分支數量的增加,整體運維的複雜性也在不斷提高。在系統設計中,複雜性通常也意味着風險。因此,如何對多協議、多角色的網關進行統一管理,如何以較低的成本快速爲新業務構建定製化的網關,成爲了我們下一階段的工作重點。
解決方案已經在圖中直觀地呈現出來,一是在協議上進行兼容處理,使網上代碼在一個框架下運行;二是引入控制面,對網上網關的差異特性進行統一管理。
4.1 多協議兼容
多協議兼容的方法並不新穎,可以參考 Tomcat 對 HTTP/1.0、HTTP/1.1、HTTP/2.0 的抽象處理。儘管 HTTP 在各個版本中增加了許多新特性,但在進行業務開發時,我們通常無法感知到這些變化,關鍵在於 HttpServletRequest 接口的抽象。
在攜程,網上網關處理的都是請求 - 響應模式的無狀態協議,報文結構也可以劃分爲元數據、擴展頭、業務報文三部分,因此可以方便地進行類似的嘗試。相關工作可以用以下兩點來概括:
-
協議適配層:用於屏蔽不同協議的編解碼、交互模式、對 TCP 連接的處理等
-
定義通用中間模型與接口:業務面向中間模型與接口進行編程,更好地關注到協議對應的業務屬性上
4.2 路由模塊
路由模塊是控制面的兩個主要組成部分之一,除了管理網關與服務之間的映射關係外,服務本身可以用以下模型來概括:
{
//匹配方式
"type": "uri",
//HTTP默認採用uri前綴匹配,內部通過樹結構尋址;私有協議(SOTP)通過服務唯一標識定位。
"value": "/hotel/order",
"matcherType": "prefix",
//標籤與屬性
//用於portal端權限管理、切面邏輯運行(如按核心/非核心)等
"tags": [
"owner_admin",
"org_framework",
"appId_123456"
],
"properties": {
"core": "true"
},
//endpoint信息
"routes": [{
//condition用於二級路由,如按app版本劃分、按query重分配等
"condition": "true",
"conditionParam": {},
"zone": "PRO",
//具體服務地址,權重用於灰度場景
"targets": [{
"url": "http://test.ctrip.com/hotel",
"weight": 100
}
]
}]
}
4.3 模塊編排
模塊調度是控制面的另一個關鍵組成部分。我們在網關處理流程中設置了多個階段(圖中用粉色表示)。除了熔斷、限流、日誌等通用功能外,運行時,不同網關需要執行的業務功能由控制面統一分配。這些功能在網關內部有獨立的代碼模塊,而控制面則額外定義了這些功能對應的執行條件、參數、灰度比例和錯誤處理方式等。這種調度方式也在一定程度上保證了模塊之間的解耦。
{
//模塊名稱,對應網關內部某個具體模塊
"name": "addResponseHeader",
//執行階段
"stage": "PRE_RESPONSE",
//執行順序
"ruleOrder": 0,
//灰度比例
"grayRatio": 100,
//執行條件
"condition": "true",
"conditionParam": {},
//執行參數
//大量${}形式的內置模板,用於獲取運行時數據
"actionParam": {
"connection": "keep-alive",
"x-service-call": "${request.func.remoteCost}",
"Access-Control-Expose-Headers": "x-service-call",
"x-gate-root-id": "${func.catRootMessageId}"
},
//異常處理方式,可以拋出或忽略
"exceptionHandle": "return"
}
五、總結
網關在各種技術交流平臺上一直是備受關注的話題,有很多成熟的解決方案:易於上手且發展較早的 Zuul 1.0、高性能的 Nginx、集成度高的 Spring Cloud Gateway、日益流行的 Istio 等等。
最終的選型還是取決於各公司的業務背景和技術生態。
因此,在攜程,我們選擇了自主研發的道路。
技術在不斷髮展,我們也在持續探索,包括公共網關與業務網關的關係、新協議(如 HTTP3)的應用、與 ServiceMesh 的關聯等等。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/_ODEHHeLQVYHFgak3YA8AQ