干貨 | 日均流量200億,攜程高性能全異步網關實踐 https://mp.weixin.qq.com/s/JdbPf_H4pn5PnPH2LIKQlw
干貨 | 日均流量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);
}
};
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
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 Cerver端編解碼、出向業務邏輯,各子流程相互獨立,各自處理完整的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),自然也對新一代的GC算法進行了嘗試,實際表現也確實不負盛名。除CPU占用有少量提升,整體GC耗時下降非常明顯。
- 定制的HTTP編解碼
HTTP的悠久歷史,加之協議自身的開放性,催生了許多“壞實踐”,輕則影響成功率,重則威脅網站安全,舉兩個例子:
-
流量治理
諸如請求體過大(413)、uri過長(414)、非ASCII字符(400)等問題,一般WebServer會選擇直接拒絕並返回對應狀態碼。由於直接跳過了業務流程,這類問題在統計、服務定為、排障上都會比較麻煩。擴展編解碼,讓問題請求也能夠走完路由流程,可以幫助解決非標流量的治理問題。
-
請求過濾
如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自身雖然在各個版本內新增了大量feature,但我們在做業務開發時通常感知不到這些,核心在於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"
}
五、總結
網關長期以來都是各類技術交流平台上的熱點,方案也非常豐富:發展早、易上手的Zuul1.0、高性能的Nginx、集成度高的SpringCloud Gateway、如日中天的Istio等等。最終決定選型的還是各公司自身的業務背景與技術生態。也正因此,在攜程我們選擇了自研的道路。
技術不斷發展,我們也在持續探索,公共網關同業務網關的關系、新協議的落地(HTTP3)、與ServiceMesh的關系等等,真誠歡迎有興趣的同學一起參與討論。
團隊招聘信息
我們是平台研發中心,一個為攜程快速發展提供各類基礎產品和服務的平台,我們以技術驅動提升客戶體驗,提升跨團隊協作效率。
我們擁有優秀而強大的團隊,引導你學習業內領先的開發技術,與技術高手交流對話,學習切磋。在億級用戶嚴苛的品質要求中,激發你腦中不斷涌現的創新思維,帶領你體驗飛速成長的驚喜快樂,並在各種機遇與挑戰中發展自我,成就自身。