博客 / 詳情

返回

長連接網關技術專題(十三):基於Netty的攜程高性能網關異步改造實踐

本文由攜程技術Butters分享,原題“乾貨 | 日均流量200億,攜程高性能全異步網關實踐”,下文有修訂和重新排版。
1、引言
本文分享的是攜程API網關全異步改造的實踐分享,包括從Zuul 1.0同步架構升級為基於Netty的全異步架構,通過RxJava實現業務流程異步化,結合流式轉發、ZGC等技術顯著提升性能,並構建控制面實現多協議統一治理與模塊化編排。 
圖片
 技術交流:
  • 移動端IM開發入門文章:《新手入門一篇就夠:從零開發移動端IM》
  • 開源IM框架源碼:https://github.com/JackJiang2011/MobileIMSDK(備用地址點此)
    (本文已同步發佈於:http://www.52im.net/thread-4854-1-1.html)

2、作者介紹
Butters:攜程軟件技術專家,專注於網絡架構、API網關、負載均衡、Service Mesh等領域。

3、專題目錄
本文是專題系列文章的第 13 篇,總目錄如下:

《長連接網關技術專題(一):京東京麥的生產級TCP網關技術實踐總結》

《長連接網關技術專題(二):知乎千萬級併發的高性能長連接網關技術實踐》

《長連接網關技術專題(三):手淘億級移動端接入層網關的技術演進之路》

《長連接網關技術專題(四):愛奇藝WebSocket實時推送網關技術實踐》

《長連接網關技術專題(五):喜馬拉雅自研億級API網關技術實踐》

《長連接網關技術專題(六):石墨文檔單機50萬WebSocket長連接架構實踐》

《長連接網關技術專題(七):小米小愛單機120萬長連接接入層的架構演進》

《長連接網關技術專題(八):B站基於微服務的API網關從0到1的演進之路》

《長連接網關技術專題(九):去哪兒網酒店高性能業務網關技術實踐》

《長連接網關技術專題(十):百度基於Go的千萬級統一長連接服務架構實踐》

《長連接網關技術專題(十一):揭秘騰訊公網TGW網關係統的技術架構演進》

《長連接網關技術專題(十二):大模型時代多模型AI網關的架構設計與實現》

《長連接網關技術專題(十三):基於Netty的攜程高性能網關異步改造實踐》(* 本文)

4、技術背景
與許多公司一樣,攜程API網關也是同微服務架構一起引入的基礎設施,最早版本發佈於2014年。隨着服務化在公司的快速推進,網關逐漸成為應用暴露到外網的標準方案。後來的“ALL IN無線”、國際化、異地多活等,網關跟隨着公司公共業務與基礎架構共同演進。

技術方案上,公司微服務早期發展受NetflixOSS影響較深,網關方面最早也是參考了Zuul 1.0進行的二次開發。

核心可概括為四點:

1)server端:Tomcat NIO + AsyncServlet;
2)業務流程:獨立線程池,分階段的責任鏈模式;
3)client端:Apache HttpClient,同步調用;
4)核心組件:Archaius(動態配置客户端),Hystrix(熔斷限流),Groovy(熱更新支持)。

圖片
眾所周知,同步調用阻塞線程,系統吞吐受IO影響大。作為行業先驅,Zuul在設計上也考慮到了這點——通過引入Hystrix,資源隔離配合限流,將故障(慢IO)框在一定範圍內;配合熔斷策略,可提前釋放部分線程資源;最終達到局部異常不影響全局的目的。但隨着公司業務的發展,上述策略效果逐漸減弱。

主要原因在於兩方面的變動:

1)業務出海:網關作為海外接入層,部分流量需轉回國內,慢IO成為常態;

2)服務規模增長:局部異常常態化,加上微服務異常擴散的特性,線程池可能長期處於亞健康狀態。
圖片
全異步改造是攜程API網關近年的一項核心工作點,本文也將由此展開,聊一聊我們在網關方面的工作與實踐。重點包括:性能優化、業務形態、技術架構、治理經驗等。

5、高性能網關核心設計1:異步流程設計

全異步 = server端異步 + 業務流程異步 + client端異步

對於server與client端,我們選擇了Netty框架,NIO/Epoll + Eventloop本身就是事件驅動的設計。

改造核心在於業務流程的異步化,常見異步場景包括:

1)業務IO事件:如請求校驗、身份認證,涉及遠程調用;
2)自身IO事件:如讀取到了報文的前xx字節;
3)請求轉發:包括TCP連接,HTTP請求。
經驗上,異步編程相比同步在設計、讀寫上都會困難一些。

所謂的困難,一般包括:

1)流程設計&狀態轉換;
2)異常處理,包括常規異常與超時;
3)上下文傳遞,包括業務上下文與trace log;
4)線程調度;
5)流量控制。
尤其在Netty上下文內,對ByteBuf生命週期設計的不完善,很容易造成內存泄漏。圍繞這些問題,我們設計了對應外圍框架,最大努力對業務代碼抹平同步/異步差異,方便開發;同時默認兜底與容錯,保證程序整體安全。工具上藉助了RxJava,主要流程如下圖所示。

圖片
Maybe:

1)RxJava內置容器類,標識正常結束、有且僅有一個對象返回、異常三種狀態;
2)響應式,方便整體狀態機設計,自帶異常處理、超時、線程調度等封裝;
3)Maybe.empty()/Maybe.just(T),適用同步場景;
4)工具類RxJavaPlugins,方便切面邏輯封裝。

Filter:

1)代表一塊獨立的業務邏輯,同步&異步業務統一接口,返回Maybe;
2)異步場景(如遠程調用)統一封裝,如涉及線程切換,通過maybe.obesrveOn(eventloop)切回;
3)異步filter默認增加超時,並按弱依賴處理,忽略錯誤。

public interface Processor<T> {

ProcessorType getType();



int getOrder();



boolean shouldProcess(RequestContext context);



//對外統一封裝為Maybe   

Maybe 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 processAsync(RequestContext context) throws Exception

{

    T response = processSyncAndGetReponse(context);

    if (response == null) {

        return Maybe.empty();

    } else {

        return Maybe.just(response);

    }

};



@Override

public Maybe 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();

}

整體流程:

1)沿用責任鏈的設計,分為inbound、outbound、error、log四階段;
2)各階段由一或多個filter組成;
3)filter順序執行,遇到異常則中斷,inbound期間任意filter返回response也觸發中斷。

public class RxUtil{

  //組合某階段(如Inbound)內的多個filter(即Callable<Maybe<T>>)

  public static  Maybe 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()

          );

  }  

}

6、高性能網關核心設計2:流式轉發&單線程

以HTTP為例,報文可劃分為initial line/header/body三個組成部分:
圖片
在攜程,網關層業務不涉及body。因為無需全量存,所以解析完header後可直接進入業務流程。於此同時,如果接收到body部分:

1)若已向upstream轉發請求,則直接轉發;
2)否則需要將其暫存,待業務流程處理完畢,同initial line/header一併發送;
3)對upstream端響應的處理方式亦然。
對比完整解析HTTP報文的方式,這樣處理:

1)更早進入業務流程,意味着upstream更早接收到請求,能有效降低網關這層引入的延遲;
2)body生命週期被壓縮,可降低網關自身的內存開銷。
雖説提升了性能,但流式的方式也極大提升了整個流程的複雜度

圖片
非流式場景下,Netty Server端編解碼、入向業務邏輯、Netty Cerver端編解碼、出向業務邏輯,各子流程相互獨立,各自處理完整的HTTP對象。採取流式後,請求則可能同時處於多流程內,引入的困難可歸納為以下三點:

1)線程安全問題:不同流程若採用不同線程,會涉及上下文的併發修改;
2)多階段聯動:比如Netty Server請求接收一半遇到了連接中斷,此時已經連上了upstream,那麼upstream側的協議棧是走不完的,也必須隨之關閉連接;
3)邊緣場景處理:比如upstream在請求未完整發送情況下返回了404/413,是選擇繼續發送、走完協議棧、讓連接能夠複用,還是選擇提前終止流程,節約資源,但同時放棄連接?再比如,upstream已收到請求但未響應,此時Netty Server突然斷開,Netty Client是否也要隨之斷開?等等。

針對這些場景,我們採用了單線程的方式,核心設計:

1)上線文綁定Eventloop,Netty Server/業務流程/Netty Client在同個eventloop執行;
2)異步filter如因IO庫的關係,必須使用獨立線程池,那在後置處理上必須切回;
3)流程內資源做必要的線程隔離(如連接池)。
採用單線程的好處:

1)杜絕了併發問題,在多階段聯動、邊緣場景問題處理時,整個系統也處於確定的狀態下,有效降低了開發難度與風險;
2)減少線程切換,一定程度上也能夠提升性能。
與之相對的,因為worker線程數較少(一般等於CPU核數),eventloop內必須完全杜絕IO操作,否則將對系統吞吐造成毀滅性打擊。

7、高性能網關核心設計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發佈)。擴展編解碼,增加自定義的校驗邏輯,讓安全補丁能夠更快落地。

8、網關業務形態
作為獨立、統一的入向流量收口點,網關對公司的價值主要體現在三方面:

1)解耦不同網絡環境:典型場景包括內網&外網、生產環境&辦公區、IDC內部不同安全域、專線等;
2)天然的公共業務切面:包括安全&認證&反爬、路由&灰度、限流&熔斷&降級、監控&告警&排障等;
3)高效、靈活的流量控制。

圖片

圖片

這裏展開講幾個細分場景。

私有協議:

1)在收口的客户端(APP),由框架層攔截用户發起的HTTP請求,通過私有協議(SOTP)的方式發往服務端;
2)選址方面:①通過服務端下發IP,杜絕DNS劫持;②連接預熱;③自定義的選址策略,可依據網絡質量、環境等自行切換;
3)交互方式上:①更加輕量的協議體;②統一加密&壓縮&多路複用;③協議在入口處由網關統一轉換,對業務透明。
鏈路優化:核心是引入接入層,讓遠距離用户就近訪問,緩解握手開銷過大的問題。同時,因為接入層與IDC是可控的兩端,網絡鏈路選擇、協議交互模式上都有更大的優化空間。

異地多活:區別於按比例分配、就近訪問策略等,異地多活模式下,網關(接入層)需按照業務維度的shardingKey進行分流(如userId),防止底層數據衝突。

圖片

9、網關治理概述
下圖總結了線上網關的工作狀態:

圖片

橫向對應我們的業務流程:不同渠道(APP、H5、小程序、供應商)、不同協議(HTTP、SOTP)的流量經由負載均衡打到網關,經過系列業務邏輯的處理,最終轉發至後端服務。經歷了第二章的改造後,橫向業務在性能、穩定性上都得到了較好的提升。

另一方面:由於多渠道/協議的存在,線上網關按業務劃分,進行了獨立集羣的部署。業務差異(路由數據、功能模塊)早期通過獨立代碼分支管理,隨着分支數的增加,整體的運維複雜度越來越高。系統設計中,複雜度往往也意味着風險。如何對多協議、多角色的網關實施統一治理,如何以較低的成本,快速為新業務搭建定製化網關,成為了我們後一階段的工作重心。

解決方案也比較直觀地在圖中畫了出來:

1)是協議上兼容處理,讓線上代碼跑在一套框架下;
2)是引入控制面,對線上網關的差異特性進行統一管理。

圖片

10、網關治理能力1:多協議兼
協議兼容的做法並不新鮮,整體可以參考Tomcat對HTTP/1.0、HTTP/1.1、HTTP/2.0的抽象。HTTP自身雖然在各個版本內新增了大量feature,但我們在做業務開發時通常感知不到這些,核心在於HttpServletRequest接口的抽象。

在攜程,網關面對的都是請求—響應模式的無狀態協議,報文組成上也可以劃分為元數據、擴展頭、業務報文三部分,因此可以比較方便地進行類似的嘗試。

對應工作可以用以下兩點概括:

1)協議適配層:用於屏蔽不同協議的編解碼、交互模式、對TCP連接的處理等;
2)定義通用中間模型與接口:業務面向中間模型與接口編程,更好地聚焦到協議對應的業務屬性上去

圖片

11、網關治理能力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

      }

    ]

  }]

}

12、網關治理能力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"

}

13、本文小結
網關長期以來都是各類技術交流平台上的熱點,方案也非常豐富:發展早、易上手的Zuul1.0、高性能的Nginx、集成度高的SpringCloud Gateway、如日中天的Istio等等。最終決定選型的還是各公司自身的業務背景與技術生態。也正因此,在攜程我們選擇了自研的道路。

技術不斷髮展,我們也在持續探索,公共網關同業務網關的關係、新協議的落地(HTTP3)、與ServiceMesh的關係等等,真誠歡迎有興趣的同學一起參與討論。

14、參考資料
[1] 京東京麥的生產級TCP網關技術實踐總結

[2] 手淘億級移動端接入層網關的技術演進之路

[3] 喜馬拉雅自研億級API網關技術實踐

[4] 小米小愛單機120萬長連接接入層的架構演進

[5] B站基於微服務的API網關從0到1的演進之路

[6] 去哪兒網酒店高性能業務網關技術實踐

[7] 少囉嗦!一分鐘帶你讀懂Java的NIO和經典IO的區別

[8] 史上最強Java NIO入門:擔心從入門到放棄的,請讀這篇!

[9] Java的BIO和NIO很難懂?用代碼實踐給你看,再不懂我轉行!

[10] 史上最通俗Netty框架入門長文:基本介紹、環境搭建、動手實戰

[11] 新手入門:目前為止最透徹的的Netty高性能原理和框架架構解析

(本文已同步發佈於:http://www.52im.net/thread-4854-1-1.html)

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.