自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

得物自研API網(wǎng)關(guān)實(shí)踐之路

網(wǎng)絡(luò) 網(wǎng)絡(luò)管理
B端業(yè)務(wù)和C端業(yè)務(wù)存在著很大的不同,例如對接口的響應(yīng)時(shí)間的忍受度是不一樣的,B端場景下下載一個報(bào)表用戶可以接受等待10s或者1分鐘,但是C端用戶現(xiàn)在沒有這個耐心。作為代理層針對以上的場景,我們需要針對不同接口定制不同的超時(shí)時(shí)間,原生的SCG顯然也不支持。

一、業(yè)務(wù)背景

老網(wǎng)關(guān)使用 Spring Cloud Gateway (下稱SCG)技術(shù)框架搭建,SCG基于webflux 編程范式,webflux是一種響應(yīng)式編程理念,響應(yīng)式編程對于提升系統(tǒng)吞吐率和性能有很大幫助;  webflux 的底層構(gòu)建在netty之上性能表現(xiàn)優(yōu)秀;SCG屬于spring生態(tài)的產(chǎn)物,具備開箱即用的特點(diǎn),以較低的使用成本助力得物早期的業(yè)務(wù)快速發(fā)展;但是隨著公司業(yè)務(wù)的快速發(fā)展,流量越來越大,網(wǎng)關(guān)迭代的業(yè)務(wù)邏輯越來越多,以及安全審計(jì)需求的不斷升級和穩(wěn)定性需求的提高,SCG在以下幾個方面逐步暴露了一系列的問題。

網(wǎng)絡(luò)安全

從網(wǎng)絡(luò)安全角度來講,對公網(wǎng)暴露接口無疑是一件風(fēng)險(xiǎn)極高的事情,網(wǎng)關(guān)是對外網(wǎng)絡(luò)流量的重要橋梁,早期的接口暴露采用泛化路由的模式,即通過正則形式( /api/v1/app/order/** )的路由規(guī)則開放接口,單個應(yīng)用服務(wù)往往只配置一個泛化路由,后續(xù)上線新接口時(shí)外部可以直接訪問;這帶來了極大的安全風(fēng)險(xiǎn),很多時(shí)候業(yè)務(wù)開發(fā)的接口可能僅僅是內(nèi)部調(diào)用,但是一不小心就被泛化路由開放到了公網(wǎng),甚至很多時(shí)候沒人講得清楚某個服務(wù)具體有多少接口屬于對外,多少對內(nèi);另一方面從監(jiān)控?cái)?shù)據(jù)來看,黑產(chǎn)勢力也在不斷對我們的接口做滲透試探。

協(xié)同效率

引入了接口注冊機(jī)制,所有對外暴露接口逐一注冊到網(wǎng)關(guān),未注冊接口不可訪問,安全的問題得到了解決但同時(shí)帶來了性能問題,SCG采用遍歷方式匹配路由規(guī)則,接口注冊模式推廣后路由接口注冊數(shù)量迅速提升到3W+,路由匹配性能出現(xiàn)嚴(yán)重問題;泛化路由的時(shí)代,一個服務(wù)只有一個路由配置,變動頻率很低,配置工作由網(wǎng)關(guān)關(guān)開發(fā)人員負(fù)責(zé),效率尚可,接口注冊模式將路由工作轉(zhuǎn)移到了業(yè)務(wù)開發(fā)同學(xué)的身上,這就得引入一套完整的路由審核流程,以提升協(xié)同效率;由于路由信息早期都存在配置中心,同時(shí)這么大的數(shù)據(jù)量給配置中心也帶來極大的壓力和穩(wěn)定性風(fēng)險(xiǎn)。

性能與維護(hù)成本

業(yè)務(wù)迭代的不斷增多,也使得API網(wǎng)關(guān)堆積了很多的業(yè)務(wù)邏輯,這些業(yè)務(wù)邏輯分散在不同的filter中,為了降低開發(fā)成本,網(wǎng)關(guān)只有一套主線分支,不同集群部署的代碼完全相同,但是不同集群的業(yè)務(wù)屬性不同,所需要的filter 邏輯是不一樣的;如內(nèi)網(wǎng)網(wǎng)關(guān)集群幾乎沒什么業(yè)務(wù)邏輯,但是App集群可能需要幾十個filter的邏輯協(xié)同工作;這樣的一套代碼對內(nèi)網(wǎng)網(wǎng)關(guān)而言,存在著大量的性能浪費(fèi);如何平衡維護(hù)成本和運(yùn)行效率是個需要思考的問題。

穩(wěn)定性風(fēng)險(xiǎn)

API網(wǎng)關(guān)作為基礎(chǔ)服務(wù),承載全站的流量出入,穩(wěn)定性無疑是第一優(yōu)先級,但其定位決定了絕不可能是一個簡單的代理層,在穩(wěn)定運(yùn)行的同時(shí)依然需要承接大量業(yè)務(wù)需求,例如C端用戶登錄下線能力,App強(qiáng)升能力,B端場景下的鑒權(quán)能力等;很難想象較長一段時(shí)間以來,網(wǎng)關(guān)都保持著雙周一次的發(fā)版頻率;頻繁的發(fā)版也帶來了一些問題,實(shí)例啟動初期有很多資源需要初始化,此時(shí)承接的流量處理時(shí)間較長,存在著明顯的接口超時(shí)現(xiàn)象;早期的每次發(fā)版幾乎都會導(dǎo)致下游服務(wù)的接口短時(shí)間內(nèi)超時(shí)率大幅提高,而且往往設(shè)計(jì)多個服務(wù)一起出現(xiàn)類似情況;為此甚至拉了一個網(wǎng)關(guān)發(fā)版公告群,提前置頂發(fā)版公告,讓業(yè)務(wù)同學(xué)和NOC有一個心里預(yù)期;在發(fā)布升級期間盡可能讓業(yè)務(wù)服務(wù)無感知這是個剛需。

定制能力

流量灰度是網(wǎng)關(guān)最常見的功能之一,對于新版本迭代,業(yè)務(wù)服務(wù)的某個節(jié)點(diǎn)發(fā)布新版本后希望引入少部分流量試跑觀察,但很遺憾SCG原生并不支持,需要對負(fù)載均衡算法進(jìn)行手動改寫才可以,此外基于流量特征的定向節(jié)點(diǎn)路由也需要手動開發(fā),在SCG中整個負(fù)載均衡算法屬于比較核心的模塊,不對外直接暴露,存在較高的改造成本。

B端業(yè)務(wù)和C端業(yè)務(wù)存在著很大的不同,例如對接口的響應(yīng)時(shí)間的忍受度是不一樣的,B端場景下下載一個報(bào)表用戶可以接受等待10s或者1分鐘,但是C端用戶現(xiàn)在沒有這個耐心。作為代理層針對以上的場景,我們需要針對不同接口定制不同的超時(shí)時(shí)間,原生的SCG顯然也不支持。

諸如此類的定制需求還有還多,我們并不寄希望于開源產(chǎn)品能夠開箱即用滿足全部需求,但至少定制性拓展性足夠好。上手改造成本低。

二、技術(shù)痛點(diǎn)

SCG主要使用了webflux技術(shù),webflux的底層構(gòu)建在reactor-netty之上,而reactor-netty構(gòu)建于netty之上;SCG能夠和spring cloud 的技術(shù)棧的各組件,完美適配,做到開箱即用,以較低的使用成本助力得物早期的業(yè)務(wù)快速發(fā)展;但是使用webflux也是需要付出一定成本,首先它會額外增加編碼人員的心智負(fù)擔(dān),需要理解流的概念和常用的操作函數(shù),諸如map, flatmap, defer 等等;其次異步非阻塞的編碼形式,充斥著大量的回調(diào)函數(shù),會導(dǎo)致順序性業(yè)務(wù)邏輯被割裂開來,增加代碼閱讀理理解成本;此外經(jīng)過多方面評估我們發(fā)現(xiàn)SCG存在以下缺點(diǎn):

內(nèi)存泄露問題

SCG存在較多的內(nèi)存泄漏問題,排查困難,且官方遲遲未能修復(fù),長期運(yùn)行會導(dǎo)致服務(wù)觸發(fā)OOM并宕機(jī);以下為github上SCG官方開源倉庫的待解決的內(nèi)存泄漏問題,大約有16個之多。

SCG內(nèi)存泄漏BUGSCG內(nèi)存泄漏BUG

下圖可以看到SCG在長期運(yùn)行的過程中內(nèi)存使用一直在增長,當(dāng)增長到機(jī)器內(nèi)存上限時(shí)當(dāng)前節(jié)點(diǎn)將不可用,聯(lián)系到網(wǎng)關(guān)單節(jié)點(diǎn)所承接的QPS 在幾千,可想而知節(jié)點(diǎn)宕機(jī)帶來的危害有多大;一段時(shí)間以來我們需要對SCG網(wǎng)關(guān)做定期重啟。

SCG生產(chǎn)實(shí)例內(nèi)存增長趨勢SCG生產(chǎn)實(shí)例內(nèi)存增長趨勢

響應(yīng)式編程范式復(fù)雜

基于webflux 中的flux 和mono ,在對request和response信息讀取修改時(shí),編碼復(fù)雜度高,代碼理解困難,下圖是對body信息進(jìn)行修改時(shí)的代碼邏輯。

圖片對requestBody 進(jìn)行修改的方式圖片對requestBody 進(jìn)行修改的方式

多層抽象的性能損耗

盡管相比于傳統(tǒng)的阻塞式網(wǎng)關(guān),SCG的性能已經(jīng)足夠優(yōu)秀,但相比原生的netty仍然比較低下,SCG依賴于webflux編程范式,webflux構(gòu)建于reactor-netty之上,reactor-netty 構(gòu)建于netty 之上, 多層抽象存在較大的性能損耗。

SCG依賴層級SCG依賴層級

一般認(rèn)為程序調(diào)用棧越深性能越差;下圖為只有一個filter的情況下的調(diào)用棧,可以看到存在大量的 webflux 中的 subscribe() 和onNext() 方法調(diào)用,這些方法的執(zhí)行不關(guān)聯(lián)任何業(yè)務(wù)邏輯,屬于純粹的框架運(yùn)行層代碼,粗略估算下沒有引入任何邏輯的情況下SCG的調(diào)用棧深度在 90+ ,如果引入多個filter處理不同的業(yè)務(wù)邏輯,線程棧將進(jìn)一步加深,當(dāng)前網(wǎng)關(guān)的業(yè)務(wù)復(fù)雜度實(shí)際棧深度會達(dá)到120左右,也就是差不多有四分之三的非業(yè)務(wù)棧損耗,這個比例是有點(diǎn)夸張的。

圖片圖片

SCG filter 調(diào)用棧深度SCG filter 調(diào)用棧深度

路由能力不完善

原生的的SCG并不支持動態(tài)路由管理,路由的配置信息通過大量的KV配置來做,平均一個路由配置需要三到四條KV配置信息來支撐,這些配置數(shù)據(jù)一般放在諸如Apollo或者ark 這樣的配置中心,即使是添加了新的配置SCG并不能動態(tài)識別,需要引入動態(tài)刷新路由配置的能力。另一方面路由匹配算法通過遍歷所有的路由信息逐一匹配的模式,當(dāng)接口級別的路由數(shù)量急劇膨脹時(shí),性能是個嚴(yán)重問題。

SCG路由匹配算法為On時(shí)間復(fù)雜度

預(yù)熱時(shí)間長,冷啟動RT尖刺大

SCG中LoadBalancerClient 會調(diào)用choose方法來選擇合適的endpoint 作為本次RPC發(fā)起調(diào)用的真實(shí)地址,由于是懶加載,只有在有真實(shí)流量觸發(fā)時(shí)才會加載創(chuàng)建相關(guān)資源;在觸發(fā)底層的NamedContextFactory#getContext 方法時(shí)存在一個全局鎖導(dǎo)致,woker線程在該鎖上大量等待。

NamedContextFactory#getContext方法存在全局鎖NamedContextFactory#getContext方法存在全局鎖

SCG發(fā)布時(shí)超時(shí)報(bào)錯增多SCG發(fā)布時(shí)超時(shí)報(bào)錯增多

定制性差,數(shù)據(jù)流控制耦合

SCG在開發(fā)運(yùn)維過程中已經(jīng)出現(xiàn)了較多的針對源碼改造的場景,如動態(tài)路由,路由匹配性能優(yōu)化等;其設(shè)計(jì)理念老舊,控制流和數(shù)據(jù)流混合使用,架構(gòu)不清晰,如對路由管理操作仍然耦合在filter中,即使引入引入spring mvc方式管理,依然綁定使用webflux編程范式,同時(shí)也無法做到控制流端口獨(dú)立,存在一定安全風(fēng)險(xiǎn)。

filter中對路由進(jìn)行管理filter中對路由進(jìn)行管理

三、方案調(diào)研

理想中的網(wǎng)關(guān)

綜合業(yè)務(wù)需求和技術(shù)痛點(diǎn),我們發(fā)現(xiàn)理想型的網(wǎng)關(guān)應(yīng)該是這個樣子的:

支持海量接口注冊,并能夠在運(yùn)行時(shí)支持動態(tài)添加修改路由信息,具備出色路由匹配性能

編程范式盡可能簡單,降低開發(fā)人員心智負(fù)擔(dān),同時(shí)最好是開發(fā)人員表較為熟悉的語言

性能足夠好,至少要等同于目前SCG的性能,RT99線和ART較低

穩(wěn)定性好,無內(nèi)存泄漏,能夠長時(shí)間持續(xù)穩(wěn)定運(yùn)行,發(fā)布升級期間要盡可能下游無感

拓展能力強(qiáng),支持超時(shí)定制,多網(wǎng)絡(luò)協(xié)議支持,http,Dubbo等,生態(tài)完善

架構(gòu)設(shè)計(jì)清晰,數(shù)據(jù)流與控制流分離,集成UI控制面

開源網(wǎng)關(guān)對比

基于以上需求,我們對市面上的常見網(wǎng)關(guān)進(jìn)行了調(diào)研,以下幾個開源方案對比。

圖片圖片


結(jié)合當(dāng)前團(tuán)隊(duì)的技術(shù)棧,我們傾向于選擇Java技術(shù)棧的開源產(chǎn)品,唯一可選的只有zuul2 ,但是zuul2路由注冊和穩(wěn)定性方面也不能夠滿足我們的需求,也沒有實(shí)現(xiàn)數(shù)控分離的架構(gòu)設(shè)計(jì)。因此唯有走上自研之路。

四、自研架構(gòu)

通常而言代理網(wǎng)關(guān)分為透明代理與非透明代理,其主要區(qū)別在于對于流量是否存在侵入性,這里的侵入性主要是指對請求和響應(yīng)數(shù)據(jù)的修改;顯然API Gateway的定位決定了必然會對流量進(jìn)行數(shù)據(jù)調(diào)整,常見的調(diào)整主要有 添加或者修改head 信息,加密或者解密 query params  head ,以及 requestbody 或者responseBody,可以說http請求的每一個部分?jǐn)?shù)據(jù)都存在修改的可能性,這要求代理層必須要完全解析數(shù)據(jù)包信息,而非簡單的做一個路由器轉(zhuǎn)發(fā)功能。

傳統(tǒng)的服務(wù)器架構(gòu),以reactor架構(gòu)為主。boss線程和worker線程的明確分工,boss線程負(fù)責(zé)連接建立創(chuàng)建;worker線程負(fù)責(zé)已經(jīng)建立的連接的讀寫事件監(jiān)聽處理,同時(shí)會將部分復(fù)雜業(yè)務(wù)的處理放到獨(dú)立的線程池中,進(jìn)而避免worker線程的執(zhí)行事件過長影響對網(wǎng)絡(luò)事件處理的及時(shí)性;由于網(wǎng)關(guān)是IO密集型服務(wù),相對來說計(jì)算內(nèi)容較少,可以不必引入這樣的業(yè)務(wù)線程池;直接基于netty 原生reactor架構(gòu)實(shí)現(xiàn)。

Reactor多線程架構(gòu)

圖片圖片

為了只求極致性能和降低多線程編碼的數(shù)據(jù)競爭,單個請求從接收到轉(zhuǎn)發(fā)后端,再到接收后端服務(wù)響應(yīng),以及最終的回寫給client端,這一些列操作被設(shè)計(jì)為完全閉合在一個workerEventLoop線程中處理;這需要worker線程中執(zhí)行的IO類型操作全部實(shí)現(xiàn)異步非阻塞化,確保worker線程的高速運(yùn)轉(zhuǎn);這樣的架構(gòu)和NGINX很類似;我們稱之為 thread-per-core模式。

圖片圖片

API網(wǎng)關(guān)組件架構(gòu)

圖片圖片

數(shù)據(jù)流控制流分離

數(shù)據(jù)面板專注于流量代理,不處理任何admin 類請求,控制流監(jiān)聽獨(dú)立的端口,接收管理指令。

圖片圖片

五、核心設(shè)計(jì)

請求上下文封裝

新的API網(wǎng)關(guān)底層仍然基于Netty,其自帶的http協(xié)議解析handler可以直接使用?;趎etty框架的編程范式,需要將相關(guān)在初始化時(shí)逐一注冊用到的 Handler。

Client到Proxy鏈路Handler 執(zhí)行順序Client到Proxy鏈路Handler 執(zhí)行順序

HttpServerCodec 負(fù)責(zé)HTTP請求的解析;對于體積較大的Http請求,客戶端可能會拆成多個小的數(shù)據(jù)包進(jìn)行發(fā)送,因此在服務(wù)端需要適當(dāng)?shù)姆庋b拼接,避免收到不玩整的http請求;HttpObjectAggregator 負(fù)責(zé)整個請求的拼裝組合。

拿到HTTP請求的全部信息后在業(yè)務(wù)handler 中進(jìn)行處理;如果請求體積過大直接拋棄;使用ServerWebExchange 對象封裝請求上下文信息,其中包含了client2Proxy的channel, 以及負(fù)責(zé)處理該channel 的eventLoop 線程等信息,考慮到整個請求的處理過程中可能可能在不同階段傳遞一些拓展信息,引入了getAttributes 方法 用于存儲需要傳遞的數(shù)據(jù);此外ServerWebExchange 接口的基本遵循了SCG的設(shè)計(jì)規(guī)范,保證了在遷移業(yè)務(wù)邏輯時(shí)的最小化改動;具體到實(shí)現(xiàn)類,可以參考如下代碼:

@Getter
  public class DefaultServerWebExchange implements ServerWebExchange {
    private final Channel client2ProxyChannel;
    private final Channel proxy2ClientChannel;
    private final EventLoop executor;
    private ServerHttpRequest request;
    private ServerHttpResponse response;
    private final Map<String, Object> attributes;
 }

DefaultServerWebExchange

Client2ProxyHttpHandler作為核心的入口handler 負(fù)責(zé)將接收到的FullHttpRequest  進(jìn)行封裝和構(gòu)建ServerWebExchange 對象,其核心邏輯如下。可以看到對于數(shù)據(jù)讀取封裝的邏輯較為簡單,并沒有植入常見的業(yè)務(wù)邏輯,封裝完對象后隨即調(diào)用 Request filter chain。

@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest fullHttpRequest) {
    try {
        Channel client2ProxyChannel = ctx.channel();
        DefaultServerHttpRequest serverHttpRequest = new DefaultServerHttpRequest(fullHttpRequest, client2ProxyChannel);
        ServerWebExchange serverWebExchange = new DefaultServerWebExchange(client2ProxyChannel,(EventLoop) ctx.executor(), serverHttpRequest, null);
        // request filter chain
        this.requestFilterChain.filter(serverWebExchange);
    }catch (Throwable t){
        log.error("Exception caused before filters!\n {}",ExceptionUtils.getStackTrace(t));
        ByteBufHelper.safeRelease(fullHttpRequest);
        throw t;
    }
}

Client2ProxyHttpHandler 精簡后的代碼

FilterChain設(shè)計(jì)

FilterChain可以解決異步請求發(fā)送出去后,還沒收到響應(yīng),但是順序邏輯已經(jīng)執(zhí)行完成的尷尬;例如當(dāng)我們在上文的。

channelRead0 方法中發(fā)起某個鑒權(quán)RPC調(diào)用時(shí),處于性能考慮只能使用非阻塞的方式,按照netty的非阻塞編碼API最終要引入類似如下的  callback 機(jī)制,在業(yè)務(wù)邏輯上在沒有收到RPC的響應(yīng)之前該請求的處理應(yīng)該“暫?!保却盏巾憫?yīng)時(shí)才能繼續(xù)后續(xù)的邏輯執(zhí)行;  也就是下面代碼中的下一步執(zhí)行邏輯并不能執(zhí)行,正確的做法是將nextBiz() 方包裹在 callBack() 方法內(nèi),由callBack() 觸發(fā)后續(xù)邏輯的執(zhí)行;這只是發(fā)起一次RPC調(diào)用的情況,在實(shí)際的的日常研發(fā)過程中存在著鑒權(quán),風(fēng)控,集群限流(Redis)等多次RPC調(diào)用,這就導(dǎo)致這樣的非阻塞代碼編寫將異常復(fù)雜。

ChannelFuture writeFuture = channel.writeAndFlush(asyncRequest.httpRequest);
    writeFuture.addListener(future -> {
                if(future.isSuccess()) {
                   callBack();
                }
            }
    );
    nextBiz();

非阻塞調(diào)用下的業(yè)務(wù)邏輯編排

對于這樣的復(fù)雜場景,采用filterChain模式可以很好的解決;首先RequestFilterChain().filter(serverWebExchange); 后不存在任何邏輯;發(fā)起請求時(shí) ,當(dāng)前filter執(zhí)行結(jié)束,由于此時(shí)沒有調(diào)用chain.filter(exchange); 所以不會繼續(xù)執(zhí)行下一個filter,發(fā)送請求到下游的邏輯也不會執(zhí)行;當(dāng)前請求的處理流程暫時(shí)中止,eventloop 線程將切換到其他請求的處理過程上;當(dāng)收到RPC響應(yīng)時(shí),chain.filter(exchange)  被執(zhí)行,之前中斷的流程被重新拉起。

public void filter(ServerWebExchange exchange) {
    if (this.index < filters.size()) {
        GatewayFilter filter = filters.get(this.index);
        DefaultGatewayFilterChain chain = new DefaultGatewayFilterChain(this, this.index + 1);
        try {
            filter.filter(exchange, chain);
        }catch (Throwable e){
            log.error("Filter chain unhandle backward exception! Request path {}, FilterClass: {}, exception: {}", exchange.getRequest().getPath(),   filter.getClass(), ExceptionUtils.getFullStackTrace(e));
            ResponseDecorator.failResponse(exchange,500, "網(wǎng)關(guān)內(nèi)部錯誤!filter chain exception!");
        }
    }
}

基于filterChain的調(diào)用模式

對于filter的執(zhí)行需要定義先后順序,這里參考了SCG的方案,每個filter返回一個order值。不同的地方在于DAG的設(shè)計(jì)不允許 order值重復(fù),因?yàn)樵趏rder重復(fù)的情況下,很難界定到底哪個Filter 先執(zhí)行,存在模糊地帶,這不是我們期望看到的;DAG中的Filter 執(zhí)行順序?yàn)閛rder值從小到大,且不允許order值重復(fù)。為了易于理解,這里將Filter拆分為了 requestFilter,和responseFilter;分別代表請求的處理階段 和拿到下游響應(yīng)階段,responseFilter 遵循同樣的邏輯執(zhí)行順序與不可重復(fù)性。

public interface GatewayFilter extends Ordered {
    void filter(ServerWebExchange exchange, GatewayFilterChain chain);
}


public interface ResponseFilter extends GatewayFilter { }


public interface RequestFilter extends GatewayFilter { }

filter接口設(shè)計(jì)

路由管理與匹配

以SCG網(wǎng)關(guān)注冊的路由數(shù)量為基準(zhǔn),網(wǎng)關(guān)節(jié)點(diǎn)的需要支撐的路由規(guī)則數(shù)量是上萬級別的,按照得物目前的業(yè)務(wù)量,上限不超過5W,為了保證匹配性能,路由規(guī)則放在分布式緩存中顯然是不合適的,需要保存在節(jié)點(diǎn)的內(nèi)存中。類似于在nginx上配置上萬條location 規(guī)則,手動維護(hù)難度可想而知,即使在配置中心管理起來也很麻煩,所以需要引入獨(dú)立路由管理模塊。

在匹配的效率上也需要進(jìn)一步優(yōu)化,SCG的路由匹配策略為普通的循環(huán)迭代逐一匹配,時(shí)間效率為On,在路由規(guī)則膨脹到萬級別后,性能急劇拉胯,結(jié)合得物的接口規(guī)范,新網(wǎng)關(guān)采用Hash匹配模式,將匹配消息提升到O1;hash的key為接口的path, 需要強(qiáng)調(diào)的是在同一個網(wǎng)關(guān)集群中,path是唯一的,這里的path并不等價(jià)于業(yè)務(wù)服務(wù)的接口path, 絕大多數(shù)時(shí)候存在一些剪裁,例如在業(yè)務(wù)服務(wù)的編寫的/order/detail接口,在網(wǎng)關(guān)實(shí)際注冊的接口可能為/api/v1/app/order/detail;由于使用了path作為key進(jìn)行hash匹配。常見的restful 接口顯然是不支持的,確切的講基于path傳參數(shù)模式的接口均不支持;出于某些歷史原因,網(wǎng)關(guān)保留了類似nginx 的前綴匹配的支持,但是這部分功能不對外開放。

public class Route implements Ordered {
    private final String id;
    private final int skipCount;
    private final URI uri;
 }

route類設(shè)計(jì)

route的URI字段中包含了,需要路由到的具體服務(wù)名,這里也可以稱之為host ,route 信息會暫存在 exchange對象的 attributes 屬性中, 在后續(xù)的loadbalance階段host信息會被進(jìn)一步替換為真實(shí)的 endpoint。

private Route lookupRoute(ServerWebExchange exchange) {
    String path = exchange.getRequest().getPath();
    CachingRouteLocator locator = (CachingRouteLocator) routeLocator;
    Route exactRoute = pathRouteMap.getOrDefault(path, null);
    if (exactRoute != null) {
        exchange.getAttributes().put(DAGApplicationConfig.GATEWAY_ROUTE_CACHE, route);
        return exactRoute;
    }
}

路由匹配邏輯

單線程閉環(huán)

為了更好地利用CPU,以及減少不必要的數(shù)據(jù)競爭,將單個請求的處理全部閉合在一個線程當(dāng)中;這意味著這個請求的業(yè)務(wù)邏輯處理,RPC調(diào)用,權(quán)限驗(yàn)證,限流token獲取都將始終由某個固定線程處理。netty中 網(wǎng)絡(luò)連接被抽象為channel,channel 與eventloop線程的對應(yīng)關(guān)系為 N對1,一個channel 僅能被一個eventloop 線程所處理,這在處理用戶請求時(shí)沒有問題,但是在接收請求完畢向下游轉(zhuǎn)發(fā)請求時(shí),我們碰到了一些挑戰(zhàn),下游的連接往往是連接池在管理,連接池的管理是另一組eventLoop線程在負(fù)責(zé),為了保持閉環(huán)需要將連接池的線程設(shè)定為處理當(dāng)前請求的線程,并且只能是這一個線程;這樣一來,默認(rèn)狀態(tài)下啟動的N個線程(N 與機(jī)器核心數(shù)相同),分別需要管理一個連接池;thread-per-core 模式的性能已經(jīng)在nginx開源組件上得到驗(yàn)證。

圖片圖片

連接管理優(yōu)化

為了滿足單線程閉環(huán),需要將連接池的管理線程設(shè)置為當(dāng)前的 eventloop 線程,最終我們通過threadlocal 進(jìn)行線程與連接池的綁定;通常情況下netty自帶的連接池 FixedChannelPool 可以滿足我們大部分場景下的需求,這樣的連接池也是適用于多線程的場景;由于新網(wǎng)關(guān)使用thread-per-core模式并將請求處理的全生命周期閉合在單個線程中,所有為了線程安全的額外操作不再必要且存在性能浪費(fèi);為此需要對原生連接池做一些優(yōu)化, 連接的獲取和釋放簡化為對鏈表結(jié)構(gòu)的簡單getFirst ,  addLast。

對于RPC 而言,無論是HTTP,還是Dubbo,Redis等最終底層都需要用到TCP連接,將構(gòu)建在TCP連接上的數(shù)據(jù)解析協(xié)議與連接剝離后,我們發(fā)現(xiàn)這種純粹的連接管理是可以復(fù)用的,對于連接池而言不需要知道具體連接的用途,只需要維持到特定endpoint的連接穩(wěn)定即可,那么這里的RPC服務(wù)的連接仍然可以放入連接池中進(jìn)行托管;最終的連接池設(shè)計(jì)架構(gòu)圖。

AsyncClient設(shè)計(jì)AsyncClient設(shè)計(jì)

對于七層流量而言基本全部都是Http請求,同樣在RPC請求中 http協(xié)議也占了大多數(shù),考慮到還會存在少量的dubbo,  Redis 等協(xié)議通信的場景。因此需要抽象出一套異步調(diào)用框架來支撐;這樣的框架需要具備超時(shí)管理,回調(diào)執(zhí)行,錯誤輸出等功能,更重要的是具備協(xié)議無關(guān)性質(zhì), 為了更方便使用需要支持鏈?zhǔn)秸{(diào)用。

發(fā)起一次RPC調(diào)用通常可以分為以下幾步:

  1. 獲取目標(biāo)地址和使用的協(xié)議, 目標(biāo)服務(wù)為集群部署時(shí),需要使用loadbalance模塊
  2. 封裝發(fā)送的請求,這樣的請求在應(yīng)用層可以具體化為某個Request類,網(wǎng)絡(luò)層序列化為二進(jìn)制數(shù)據(jù)流
  3. 出于性能考慮選擇非阻塞式發(fā)送,發(fā)送動作完成后開始計(jì)算超時(shí)
  4. 接收數(shù)據(jù)響應(yīng),由于采用非阻塞模式,這里的發(fā)送線程并不會以block的方式等待數(shù)據(jù)
  5. 在超時(shí)時(shí)間內(nèi)完成數(shù)據(jù)處理,或者觸發(fā)超時(shí)導(dǎo)致連接取消或者關(guān)閉

AsyncClient 模塊內(nèi)容并不復(fù)雜,AsyncClient為抽象類不區(qū)分使用的網(wǎng)絡(luò)協(xié)議;ConnectionPool 作為連接的管理者被client所引用,獲取連接的key 使用  protocol+ip+port 再適合不過;通常在某個具體的連接初始化階段就已經(jīng)確定了該channel 所使用的協(xié)議,因此初始化時(shí)會直接綁定協(xié)議Handler;當(dāng)協(xié)議為HTTP請求時(shí),HttpClientCodec 為HTTP請求的編解碼handler;也可以是構(gòu)建在TCP協(xié)議上的 Dubbo, Mysql ,Redis 等協(xié)議的handler。

首先對于一個請求的不同執(zhí)行階段需要引入狀態(tài)定位,這里引入了  STATE 枚舉:

enum STATE{
        INIT,SENDING,SEND,SEND_SUCCESS,FAILED,TIMEOUT,RECEIVED
}

其次在執(zhí)行過程中設(shè)計(jì)了  AsyncContext作為信息存儲的載體,內(nèi)部包含request和response信息,作用類似于上文提到的ServerWebExchange;channel資源從連接池中獲取,使用完成后需要自動放回。

public class AsyncContext<Req, Resp> implements Cloneable{
    STATE state = STATE.INIT;
    final Channel usedChannel;
    final ChannelPool usedChannelPool;
    final EventExecutor executor;
    final AsyncClient<Req, Resp> agent;
    
    Req request;
    Resp response;
    
    ResponseCallback<Resp> responseCallback;
    ExceptionCallback exceptionCallback;
    
    int timeout;
    long deadline;
    long sendTimestamp;


    Promise<Resp> responsePromise;
}
AsyncContext

AsyncClient 封裝了基本的網(wǎng)絡(luò)通信能力,不拘泥于某個固定的協(xié)議,可以是Redis, http,Dubbo 等。當(dāng)將數(shù)據(jù)寫出去之后,該channel的非阻塞調(diào)用立即結(jié)束,在沒有收到響應(yīng)之前無法對AsyncContext 封裝的數(shù)據(jù)做進(jìn)一步處理,如何在收到數(shù)據(jù)時(shí)將接收到的響應(yīng)和之前的請求管理起來這是需要面對的問題,channel 對象 的attr 方法可以用于臨時(shí)綁定一些信息,以便于上下文切換時(shí)傳遞數(shù)據(jù),可以在發(fā)送數(shù)據(jù)時(shí)將AsyncContext對象綁定到該channel的某個固定key上。當(dāng)channel收到響應(yīng)信息時(shí),在相關(guān)的 AsyncClientHandler 里面取出AsyncContext。

public abstract class AsyncClient<Req, Resp> implements Client {
    private static final int defaultTimeout = 5000;
    private final boolean doTryAgain = false;
    private final ChannelPoolManager channelPoolManager = ChannelPoolManager.getChannelPoolManager();
    protected static AttributeKey<AsyncRequest> ASYNC_REQUEST_KEY = AttributeKey.valueOf("ASYNC_REQUEST");


    public abstract ApplicationProtocol getProtocol();
    
    public AsyncContext<Req, Resp> newRequest(EventExecutor executor, String endpoint, Req request) {
        final ChannelPoolKey poolKey = genPoolKey(endpoint);
        ChannelPool usedChannelPool = channelPoolManager.acquireChannelPool(executor, poolKey);
        return new AsyncContext<>(this,executor,usedChannelPool,request, defaultTimeout, executor.newPromise());
    }


    public void submitSend(AsyncContext<Req, Resp> asyncContext){
        asyncContext.state = AsyncContext.STATE.SENDING;
        asyncContext.deadline = asyncContext.timeout + System.currentTimeMillis();   
        ReferenceCountUtil.retain(asyncContext.request);
        Future<Resp> responseFuture = trySend(asyncContext);
        responseFuture.addListener((GenericFutureListener<Future<Resp>>) future -> {
            if(future.isSuccess()){
                ReferenceCountUtil.release(asyncContext.request);
                Resp response = future.getNow();
                asyncContext.responseCallback.callback(response);
            }
        });
    }
    /**
     * 嘗試從連接池中獲取連接并發(fā)送請求,若失敗返回錯誤
     */
    private Promise<Resp> trySend(AsyncContext<Req, Resp> asyncContext){
        Future<Channel> acquireFuture = asyncContext.usedChannelPool.acquire();
        asyncContext.responsePromise = asyncContext.executor.newPromise();
        acquireFuture.addListener(new GenericFutureListener<Future<Channel>>() {
                @Override
                public void operationComplete(Future<Channel> channelFuture) throws Exception {
                    sendNow(asyncContext,channelFuture);
                }
        });
        return asyncContext.responsePromise;
    }


    private void sendNow(AsyncContext<Req, Resp> asyncContext, Future<Channel> acquireFuture){
        boolean released = false;
        try {
            if (acquireFuture.isSuccess()) {
                NioSocketChannel channel = (NioSocketChannel) acquireFuture.getNow();
                released = true;
                assert channel.attr(ASYNC_REQUEST_KEY).get() == null;
                asyncContext.usedChannel = channel;
                asyncContext.state = AsyncContext.STATE.SEND;
                asyncContext.sendTimestamp = System.currentTimeMillis();
                channel.attr(ASYNC_REQUEST_KEY).set(asyncContext);
                ChannelFuture writeFuture = channel.writeAndFlush(asyncContext.request);
                channel.eventLoop().schedule(()-> doTimeout(asyncContext), asyncContext.timeout, TimeUnit.MILLISECONDS);
            } else {
                asyncContext.responsePromise.setFailure(acquireFuture.cause());
            }
        } catch (Exception e){
            throw new Error("Unexpected Exception.............!");
        }finally {
            if(!released) {
                ReferenceCountUtil.safeRelease(asyncContext.request);
            }
        }
    }
}

AsyncClient核心源碼

public class AsyncClientHandler extends SimpleChannelInboundHandler {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        AsyncContext asyncContext = ctx.attr(AsyncClient.ASYNC_REQUEST_KEY).get();
        try {
            asyncContext.state = AsyncContext.STATE.RECEIVED;
            asyncContext.releaseChannel();
            asyncContext.responsePromise.setSuccess(msg);
        }catch (Throwable t){
            log.error("Exception raised when set Success callback. Exception \n: {}", ExceptionUtils.getFullStackTrace(t));
            ByteBufHelper.safeRelease(msg);
            throw t;
        }
    }
}

AsyncClientHandler

通過上面幾個類的封裝得到了一個易用使用的 AsyncClient,下面的代碼為調(diào)用權(quán)限系統(tǒng)的案例:

final FullHttpRequest httpRequest = HttpRequestUtil.getDefaultFullHttpRequest(newAuthReq, serviceInstance, "/auth/newCheckSls");
asyncClient.newRequest(exchange.getExecutor(), endPoint,httpRequest)
        .timeout(timeout)
        .onComplete(response -> {
            String checkResultJson = response.content().toString(CharsetUtil.UTF_8);
            response.release();
            NewAuthResult result = Jsons.parse(checkResultJson,NewAuthResult.class);
            TokenResult tokenResult = this.buildTokenResult(result);
            String body = exchange.getAttribute(DAGApplicationConfig.REQUEST_BODY);


            if (tokenResult.getUserInfoResp() != null) {
                UserInfoResp userInfo = tokenResult.getUserInfoResp();
                headers.set("userid", userInfo.getUserid() == null ? "" : String.valueOf(userInfo.getUserid()));
                headers.set("username", StringUtils.isEmpty(userInfo.getUsername()) ? "" : userInfo.getUsername());
                headers.set("name", StringUtils.isEmpty(userInfo.getName()) ? "" : userInfo.getName());
                chain.filter(exchange);
            } else {
                log.error("{},heads: {},response: {}", path, headers, tokenResult);
                int code = tokenResult.getCode() != null ? tokenResult.getCode().intValue() : ResultCode.UNAUTHO.code;
                ResponseDecorator.failResponse(exchange, code, tokenResult.getMsg());
            }
        })
        .onError(throwable -> {
            log.error("Request service {},occur an exception {}",endPoint, throwable);
            ResponseDecorator.failResponseWithStatus(exchange,HttpResponseStatus.INTERNAL_SERVER_ERROR,"AuthFilter 驗(yàn)證失敗");
        })
        .sendRequest();

asyncClient的使用

請求超時(shí)管理

一個請求的處理時(shí)間不能無限期拉長, 超過某個閾值的情況下App的頁面會被取消 ,長時(shí)間的加載卡頓不如快速報(bào)錯帶來的體驗(yàn)良好;顯然網(wǎng)關(guān)需要針對接口做超時(shí)處理,尤其是在向后端服務(wù)發(fā)起請求的過程,通常我們會設(shè)置一個默認(rèn)值,例如3秒鐘,超過這個時(shí)間網(wǎng)關(guān)會向請求端回寫timeout的失敗信息,由于網(wǎng)關(guān)下游接入的服務(wù)五花八門,可能是RT敏感型的C端業(yè)務(wù),也可能是邏輯較重B端服務(wù)接口,甚至是存在大量計(jì)算的監(jiān)控大盤接口。這就導(dǎo)致不同接口對超時(shí)時(shí)間的訴求不一樣,因此針對每個接口的超時(shí)時(shí)間設(shè)定應(yīng)該被獨(dú)立出來,而不是統(tǒng)一配置成一個值。

asyncClient.newRequest(exchange.getExecutor(), endPoint,httpRequest)
        .timeout(timeout)
        .onComplete(response -> {
            String checkResultJson = response.content().toString(CharsetUtil.UTF_8);
            //..........
        })
        .onError(throwable -> {
            log.error("Request service {},occur an exception {}",endPoint, throwable);
            ResponseDecorator.failResponseWithStatus(exchange,HttpResponseStatus.INTERNAL_SERVER_ERROR,"AuthFilter 驗(yàn)證失敗");
        })
        .sendRequest();

asyncClient 的鏈?zhǔn)秸{(diào)用設(shè)計(jì)了 timeout方法,用于傳遞超時(shí)時(shí)間,我們可以通過一個全局Map來配置這樣的信息。

Map<String,Integer> 其key為全路徑的path 信息,V為設(shè)定的超時(shí)時(shí)間,單位為ms, 至于Map的信息在實(shí)際配置過程中如何承載,使用ARK配置或者M(jìn)ysql 都很容易實(shí)現(xiàn)。處于并發(fā)安全和性能的極致追求,超時(shí)事件的設(shè)定和調(diào)度最好能夠在與當(dāng)前channel綁定的線程中執(zhí)行,慶幸的是 EventLoop線程自帶schedule 方法。具體來看上文的 AsyncClient 的56行。schedule 方法內(nèi)部以堆結(jié)構(gòu)的方式實(shí)現(xiàn)了對超時(shí)時(shí)間進(jìn)行管理,整體性能尚可。

堆外內(nèi)存管理優(yōu)化

常見的堆外內(nèi)存手動管理方式無非是引用計(jì)數(shù),不同處理邏輯可能針對 RC (引用計(jì)數(shù)) 的值做調(diào)整,到某個環(huán)節(jié)的業(yè)務(wù)邏輯處理后已經(jīng)不記得當(dāng)前的引用計(jì)數(shù)值是多少了,甚至是前面的RC增加了,后面的RC忘記減少了;但換個思路,在數(shù)據(jù)回寫給客戶端后我們肯定要把這個請求整個生命周期所申請的堆外內(nèi)存全部釋放掉,堆外內(nèi)存在回收的時(shí)候條件只有一個,就是RC值為0 ,那么在最終的release的時(shí)候,我們引入一個safeRelase的思路 , 如果當(dāng)前的RC>0  就不停的 release ,直至為0;因此只要把這樣的邏輯放在netty的最后一個Handler中即可保證內(nèi)存得到有效釋放。

public static void safeRelease(Object msg){
    if(msg instanceof ReferenceCounted){
        ReferenceCounted ref = (ReferenceCounted) msg;
        int refCount = ref.refCnt();
        for(int i=0; i<refCount; i++){
            ref.release();
        }
    }
}

safeRelease

響應(yīng)時(shí)間尖刺優(yōu)化

由于DAG 選擇了復(fù)用spring 的 loadbalance 模塊,但這樣一來就會和SCG一樣存在啟動初期的響應(yīng)時(shí)間尖刺問題;為此我們進(jìn)一步分析RibbonLoadBalancerClient 的構(gòu)建過程,發(fā)現(xiàn)其用到了NamedContextFactory,該類的 contexts 變量保存了每一個serviceName對應(yīng)的一個獨(dú)立context,這種使用模式帶來大量的性能浪費(fèi)。

public abstract class NamedContextFactory<C extends NamedContextFactory.Specification>implements DisposableBean, ApplicationContextAware {
    //1. contexts 保存 key -> ApplicationContext 的map
    private Map<String, AnnotationConfigApplicationContext> contexts = new ConcurrentHashMap<>();
    //........
}

在實(shí)際運(yùn)行中 RibbonLoadBalancerClient 會調(diào)用choose方法來選擇合適的endpoint 作為本次RPC發(fā)起調(diào)用的真實(shí)地址;choose 方法執(zhí)行過程中會觸發(fā) getLoadBalancer() 方法執(zhí)行,可以看到該方法的可以按照傳入的serviceId 獲取專屬于這個服務(wù)的LoadBalancer,事實(shí)上這樣的設(shè)計(jì)有點(diǎn)多此一舉。大部分情況下,每個服務(wù)的負(fù)載均衡算法都一致的,完全可以復(fù)用一個LoadBalancer對象;該方法最終是從spring 容器中獲取 LoadBalancer。

class  RibbonLoadBalancerClient{
    //..........
    private SpringClientFactory clientFactory;
    
    @Override
    public ServiceInstance choose(String serviceId) {
       return choose(serviceId, null);
    }
    
    public ServiceInstance choose(String serviceId, Object hint) {
       Server server = getServer(getLoadBalancer(serviceId), hint);
       if (server == null) {
          return null;
       }
       return new RibbonServer(serviceId, server, isSecure(server, serviceId),
             serverIntrospector(serviceId).getMetadata(server));
    }
    
    protected ILoadBalancer getLoadBalancer(String serviceId) {
       return this.clientFactory.getLoadBalancer(serviceId);
    }
    //.........
}

RibbonLoadBalancerClient

由于是懶加載,實(shí)際流量觸發(fā)下才會執(zhí)行,因此第一次執(zhí)行時(shí),RibbonLoadBalancerClient 對象并不存在,需要初始化創(chuàng)建,創(chuàng)建時(shí)大量線程并發(fā)調(diào)用SpringClientFactory#getContext 方法, 鎖在同一個對象上,出現(xiàn)大量的RT尖刺。這也解釋了為什么SCG網(wǎng)關(guān)在發(fā)布期間會出現(xiàn)響應(yīng)時(shí)間大幅度抖動的現(xiàn)象。

public class SpringClientFactory extends NamedContextFactory<RibbonClientSpecification>{
    //............    
    protected AnnotationConfigApplicationContext getContext(String name) {
       if (!this.contexts.containsKey(name)) {
          synchronized (this.contexts) {
             if (!this.contexts.containsKey(name)) {
                this.contexts.put(name, createContext(name));
             }
          }
       }
       return this.contexts.get(name);
    }
    //.........
}

SpringClientFactory

在后期的壓測過程中,發(fā)現(xiàn) DAG的線程數(shù)量遠(yuǎn)超預(yù)期,基于thread-per-core的架構(gòu)模式下,過多的線程對性能損害比較大,尤其是當(dāng)負(fù)載上升到較高水位時(shí)。上文提到默認(rèn)情況下,每個服務(wù)都會創(chuàng)建獨(dú)立loadBalanceClient , 而在其內(nèi)部又會啟動獨(dú)立的線程去同步當(dāng)前關(guān)聯(lián)的serviceName對應(yīng)的可用serverList,  網(wǎng)關(guān)的特殊性導(dǎo)致需要接入的服務(wù)數(shù)量極為龐大,進(jìn)而導(dǎo)致運(yùn)行一段時(shí)間后DAG的線程數(shù)量急劇膨脹,對于同步serverList 這樣的動作而言,完全可以采用非阻塞的方式從注冊中心拉取相關(guān)的serverList , 這種模式下單線程足以滿足性能要求。

圖片圖片

serverList的更新前后架構(gòu)對比

通過預(yù)先初始化的方式以及全局只使用1個context的方式,可以將這里冷啟動尖刺消除,改造后的測試結(jié)果符合預(yù)期。

圖片圖片

通過進(jìn)一步修改優(yōu)化spring loadbalance serverList 同步機(jī)制,降低90%線程數(shù)量的使用。

優(yōu)化前線程數(shù)量(725)優(yōu)化前線程數(shù)量(725)

優(yōu)化后線程數(shù)量(72)優(yōu)化后線程數(shù)量(72)

集群限流改造優(yōu)化

首先來看DAG 啟動后sentinel相關(guān)線程,類似的問題,線程數(shù)量非常多,需要針對性優(yōu)化。

Sentinel 線程數(shù)Sentinel 線程數(shù)

sentinel線程分析優(yōu)化:

最終優(yōu)化后的線程數(shù)量為4個最終優(yōu)化后的線程數(shù)量為4個

sentinel原生限流源碼分析如下,進(jìn)一步分析SphU#entry方法發(fā)現(xiàn)其底調(diào)用 FlowRuleCheck#passClusterCheck;在passClusterCheck方法中發(fā)現(xiàn)底層網(wǎng)絡(luò)IO調(diào)用為阻塞式,;由于該方法的執(zhí)行線程為workerEventLoop,因此需要使用上文提到的AsyncClient 進(jìn)行優(yōu)化。

private void doSentinelFlowControl(ServerWebExchange exchange, GatewayFilterChain chain, String resource){
    Entry urlEntry = null;
    try {
        if (!StringUtil.isEmpty(resource)) {
            //1. 檢測是否限流
            urlEntry = SphU.entry(resource, ResourceTypeConstants.COMMON_WEB, EntryType.IN);
        }
       //2. 通過,走業(yè)務(wù)邏輯
        chain.filter(exchange);
    } catch (BlockException e) {
        //3. 攔截,直接返回503
        ResponseDecorator.failResponseWithStatus(exchange, HttpResponseStatus.SERVICE_UNAVAILABLE, ResultCode.SERVICE_UNAVAILABLE.message);
    } catch (RuntimeException e2) {
        Tracer.traceEntry(e2, urlEntry);
        log.error(ExceptionUtils.getFullStackTrace(e2));
        ResponseDecorator.failResponseWithStatus(exchange, HttpResponseStatus.INTERNAL_SERVER_ERROR,HttpResponseStatus.INTERNAL_SERVER_ERROR.reasonPhrase());
    } finally {
        if (urlEntry != null) {
            urlEntry.exit();
        }
        ContextUtil.exit();
    }
}

SentinelGatewayFilter(sentinel 適配SCG的邏輯)

public class RedisTokenService implements InitializingBean {
    private final RedisAsyncClient client = new RedisAsyncClient();
    private final RedisChannelPoolKey connectionKey;
    
    public RedisTokenService(String host, int port, String password, int database, boolean ssl){
        connectionKey = new RedisChannelPoolKey(String host, int port, String password, int database, boolean ssl);
    }
    //請求token
    public Future<TokenResult> asyncRequestToken(ClusterFlowRule rule){
        ....
        sendMessage(redisReqMsg,this.connectionKey)
    }
    
    private Future<TokenResult> sendMessage(RedisMessage requestMessage, EventExecutor executor, RedisChannelPoolKey poolKey){
        AsyncRequest<RedisMessage,RedisMessage> request = client.newRequest(executor, poolKey,requestMessage);
        DefaultPromise<TokenResult> tokenResultFuture = new DefaultPromise<>(request.getExecutor());


        request.timeout(timeout)
                .onComplete(response -> {
                    ...
                    tokenResultFuture.setSuccess(response);
                })
                .onError(throwable -> {
                    ...
                    tokenResultFuture.setFailure(throwable);
                }).sendRequest();


        return tokenResultFuture;
    }
}

RedisTokenService

最終的限流Filter代碼如下:

public class SentinelGatewayFilter implements RequestFilter {
    @Resource
    RedisTokenService tokenService;\
    
    @Override
    public void filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        //當(dāng)前為 netty NioEventloop 線程
        ServerHttpRequest request = exchange.getRequest();
        String resource = request.getPath() != null ? request.getPath() : "";
  
        //判斷是否有集群限流規(guī)則
        ClusterFlowRule rule = ClusterFlowManager.getClusterFlowRule(resource);
        if (rule != null) {
           //異步非阻塞請求token
            tokenService.asyncRequestToken(rule,exchange.getExecutor())
                    .addListener(future -> {
                        TokenResult tokenResult;
                        if (future.isSuccess()) {
                            tokenResult = (TokenResult) future.getNow();
                        } else {
                            tokenResult = RedisTokenService.FAIL;
                        }
                        if(tokenResult == RedisTokenService.FAIL || tokenResult == RedisTokenService.ERROR){
                            log.error("Request cluster token failed, will back to local flowRule check");
                        }
                        ClusterFlowManager.setTokenResult(rule.getRuleId(), tokenResult);
                        doSentinelFlowControl(exchange, chain, resource);
                    });
        } else {
            doSentinelFlowControl(exchange, chain, resource);
        }
    }
}

改造后適配DAG的SentinelGatewayFilter

六、壓測性能

DAG高壓表現(xiàn)

wrk -t32 -c1000 -d60s -s param-delay1ms.lua --latency http://a.b.c.d:xxxxx

DAG網(wǎng)關(guān)的QPS、實(shí)時(shí)RT、錯誤率、CPU、內(nèi)存監(jiān)控圖;在CPU占用80% 情況下,能夠支撐的QPS在4.5W。

DAG網(wǎng)關(guān)的QPS、RT 折線圖;DAG網(wǎng)關(guān)的QPS、RT 折線圖;


圖片圖片

DAG在CPU占用80% 情況下,能夠支撐的QPS在4.5W,ART 19ms

SAG高壓表現(xiàn)

wrk -t32 -c1000 -d60s -s param-delay1ms.lua --latency  http://a.b.c.d:xxxxx

SCG網(wǎng)關(guān)的QPS、實(shí)時(shí)RT、錯誤率、CPU、內(nèi)存監(jiān)控圖:

圖片圖片

SCG網(wǎng)關(guān)的QPS、RT 折線圖:

圖片圖片

SCG在CPU占用95% 情況下,能夠支撐的QPS在1.1W,ART 54.1ms

DAG低壓表現(xiàn)

wrk -t5 -c20 -d120s -s param-delay1ms.lua --latency  http://a.b.c.d:xxxxx

DAG網(wǎng)關(guān)的QPS、實(shí)時(shí)RT、錯誤率、CPU、內(nèi)存:

DAG網(wǎng)關(guān)的QPS、RT 折線圖:DAG網(wǎng)關(guān)的QPS、RT 折線圖:

DAG在QPS 1.1W情況下,CPU占用30%,ART 1.56ms

數(shù)據(jù)對比

圖片圖片

結(jié)論

滿負(fù)載情況下,DAG要比SCG的吞吐量高很多,QPS幾乎是4倍,RT反而消耗更低,SCG在CPU被打滿后,RT表現(xiàn)出現(xiàn)嚴(yán)重性能劣化。DAG的吞吐控制和SCG一樣情況下,CPU和RT損耗下降了更多。DAG在最大壓力下,內(nèi)存消耗比較高,達(dá)到了75%左右,不過到峰值后,就不再會有大幅變動了。對比壓測結(jié)果,結(jié)論令人欣喜,SCG作為Java生態(tài)當(dāng)前使用最廣泛的網(wǎng)關(guān),其性能屬于一線水準(zhǔn),DAG的性能達(dá)到其4倍以上也是遠(yuǎn)超意料,這樣的結(jié)果給與研發(fā)同學(xué)極大的鼓舞。

七、投產(chǎn)收益

安全性提升

完善的接口級路由管理

基于接口注冊模式的全新路由上線,包含了接口注冊的申請人,申請時(shí)間,接口場景備注信息等,接口管理更加嚴(yán)謹(jǐn)規(guī)范;結(jié)合路由組功能可以方便的查詢當(dāng)前服務(wù)的所有對外接口信息,某種程度上具備一定的API查詢管理能力;同時(shí)為了緩解用戶需要檢索的接口太多的尷尬,引入了一鍵收藏功能,大部分時(shí)候用戶只需要切換到已關(guān)注列表即可。

注冊接口列表注冊接口列表

接口收藏接口收藏


防滲透能力極大增強(qiáng)

早期的泛化路由,給黑產(chǎn)的滲透帶來了極大的想象空間和安全隱患,甚至可以在外網(wǎng)直接訪問某些業(yè)務(wù)的配置信息。

黑產(chǎn)接口滲透黑產(chǎn)接口滲透

接口注冊模式啟用后,所有未注冊的接口均無法訪問,防滲透能力提升一個臺階,同時(shí)自動推送異常接口訪問信息。

404接口訪問異常推送404接口訪問異常推送

穩(wěn)定性增強(qiáng)

內(nèi)存泄漏問題解決

通過一系列手段改進(jìn)優(yōu)化和嚴(yán)格的測試,新網(wǎng)關(guān)的內(nèi)存使用更加穩(wěn)健,內(nèi)存增長曲線直接拉平,徹底解決了泄漏問題。

老網(wǎng)關(guān)內(nèi)存增長趨勢老網(wǎng)關(guān)內(nèi)存增長趨勢

新網(wǎng)關(guān)內(nèi)存增長趨勢新網(wǎng)關(guān)內(nèi)存增長趨勢

響應(yīng)時(shí)間尖刺消除

通過預(yù)先初始化 & context 共用等手段,去除了運(yùn)行時(shí)并發(fā)創(chuàng)建多個context 搶占全局鎖的開銷,冷啟動RT尖刺降低99% ;關(guān)于spring load balance 模塊的更多優(yōu)化細(xì)節(jié)可以參考這篇博客:Spring LoadBalance 存在問題與優(yōu)化。

壓測數(shù)據(jù)對比

圖片圖片

實(shí)際生產(chǎn)監(jiān)控

趨勢圖上略有差異,但是從非200請求的絕對值上看,這種差異可以忽略, 對比發(fā)布期間和非發(fā)布期間異常請求的數(shù)量,發(fā)現(xiàn)基本沒有區(qū)別,這代表著以往的發(fā)布期間的響應(yīng)時(shí)間尖刺基本消除,做到了發(fā)布期間業(yè)務(wù)服務(wù)徹底無感知。

1月4日發(fā)布期間各節(jié)點(diǎn)流量變化1月4日發(fā)布期間各節(jié)點(diǎn)流量變化

1月4日異常請求狀態(tài)數(shù)量監(jiān)控(發(fā)布期間)1月4日異常請求狀態(tài)數(shù)量監(jiān)控(發(fā)布期間)

1月5日異常請求狀態(tài)數(shù)量監(jiān)控(無發(fā)布)1月5日異常請求狀態(tài)數(shù)量監(jiān)控(無發(fā)布)


降本增效

資源占用下降50% +

SCG平均CPU占用SCG平均CPU占用

DAG資源占用DAG資源占用

JDK17升級收益

得益于ZGC的優(yōu)秀算法,JVM17 在GC暫停時(shí)間上取得了出色的成果,網(wǎng)關(guān)作為延遲敏感型應(yīng)用對GC的暫停時(shí)間尤為看重,為此我們組織升級了JDK17 版本;下面為同等流量壓力情況下的配置不同GC的效果對比,可以看到GC的暫停時(shí)間從平均70ms 降低到1ms 內(nèi),RT99線得到大幅度提升;吞吐量不再受流量波動而大幅度變化,性能表現(xiàn)更加穩(wěn)定;同時(shí)網(wǎng)關(guān)的平均響應(yīng)時(shí)間損耗降低5%。

JDK8-G1 暫停時(shí)間表現(xiàn)JDK8-G1 暫停時(shí)間表現(xiàn)

JDK17-ZGC暫停時(shí)間表現(xiàn)JDK17-ZGC暫停時(shí)間表現(xiàn)

吞吐量方面,G1伴隨流量的變化呈現(xiàn)出一定的波動趨勢,均線在99.3%左右。ZGC的吞吐量則比較穩(wěn)定,維持在無限接近100%的水平。

JDK8-G1 吞吐量JDK8-G1 吞吐量

JDK17-ZGC吞吐量JDK17-ZGC吞吐量

對于實(shí)際業(yè)務(wù)接口的影響,從下圖中可以看到平均響應(yīng)時(shí)間有所下降,這里的RT差值表示接口經(jīng)過網(wǎng)關(guān)層的損耗時(shí)間;不同接口的RT差值損耗是不同的,這可能和請求響應(yīng)體的大小,是否經(jīng)過登錄驗(yàn)證,風(fēng)控驗(yàn)證等業(yè)務(wù)邏輯有關(guān)。

JDK17與JDK8  ART對比JDK17與JDK8 ART對比

需要指出的是ZGC對于一般的RT敏感型應(yīng)用有很大提升, 服務(wù)的RT 99線得到顯著改善。但是如果當(dāng)前應(yīng)用大量使用了堆外內(nèi)存的方式,則提升相對較弱,如大量使用netty框架的應(yīng)用, 因?yàn)檫@些應(yīng)用的大部分?jǐn)?shù)據(jù)都是通過手動釋放的方式進(jìn)行管理。

八、思考總結(jié)

架構(gòu)演進(jìn)

API網(wǎng)關(guān)的自研并非一蹴而就,而是經(jīng)歷了多次業(yè)務(wù)迭代循序漸進(jìn)的過程;從早期的泛化路由引發(fā)的安全問題處理,到后面的大量路由注冊,帶來的匹配性能下降 ,以及最終壓垮老網(wǎng)關(guān)最后一根稻草的內(nèi)存泄漏問題;在不同階段需要使用不同的應(yīng)對策略,早期業(yè)務(wù)快速迭代,大量的需求堆積,最快的時(shí)候一個功能點(diǎn)的改動需要三四天內(nèi)上線 ,我們很難有足夠的精力去做一些深層次的改造,這個時(shí)候需求導(dǎo)向?yàn)閮?yōu)先,功能性建設(shè)完善優(yōu)先,是一個快速奔跑的建設(shè)期;伴隨體量的增長安全和穩(wěn)定性的重視程度逐步拔高,繼而推進(jìn)了這些方面的大量建設(shè);從拓展SCG的原有功能到改進(jìn)框架源碼,以及最終的自研重寫,可以說新的API網(wǎng)關(guān)是一個業(yè)務(wù)推進(jìn)而演化出來的產(chǎn)物,也只有這樣 ”生長“ 出來的架構(gòu)產(chǎn)品才能更好的契合業(yè)務(wù)發(fā)展的需要。

技術(shù)思考

開源的API網(wǎng)關(guān)有很多,但是自研的案例并不多,我們能夠參考的方案也很有限。除了幾個業(yè)界知名的產(chǎn)品外,很多開源的項(xiàng)目參考的價(jià)值并不大;從自研的目標(biāo)來看,我們最基本的要求是性能和穩(wěn)定性要優(yōu)于現(xiàn)有的開源產(chǎn)品,至少Java的生態(tài)是這樣;這就要求架構(gòu)設(shè)計(jì)和代碼質(zhì)量上必須比現(xiàn)有的開源產(chǎn)品更加優(yōu)秀,才有可能;為此我們深度借鑒了流量代理界的常青樹Nginx,發(fā)現(xiàn)基于Linux 多進(jìn)程模型下的OS,如果要發(fā)揮出最大效能,單CPU核心支撐單進(jìn)程(線程)是效率最高的模式??梢詫S的進(jìn)程調(diào)度開銷最小化同時(shí)將高速緩存miss降到最低,此外還要盡可能減少或者消除數(shù)據(jù)競爭,避免鎖等待和自旋帶來的性能浪費(fèi);DAG的整個技術(shù)架構(gòu)可以簡化的理解為引入了獨(dú)立控制流的多線程版的Nginx。

中間件的研發(fā)創(chuàng)新存在著較高的難度和復(fù)雜性,更何況是在業(yè)務(wù)不斷推進(jìn)中換引擎。在整個研發(fā)過程中,為了盡可能適配老的業(yè)務(wù)邏輯,對原有的業(yè)務(wù)邏輯的改動最小化,新網(wǎng)關(guān)對老網(wǎng)關(guān)的架構(gòu)層接口做了全面適配;換句話說新引擎的對外暴露的核心接口與老網(wǎng)關(guān)保持一致,讓老的業(yè)務(wù)邏輯在0改動或者僅改動少量幾行代碼后就能在新網(wǎng)關(guān)上直接跑,能夠極大幅度降低我們的測試回歸成本,因?yàn)檫@些代碼本身的邏輯正確性,已經(jīng)在生產(chǎn)環(huán)境得到了大量驗(yàn)證。這樣的適配器模式同樣適用于其他組件和業(yè)務(wù)開發(fā)。

作為底層基礎(chǔ)組件的開發(fā)人員,要對自己寫下的每一行代碼都有清晰的認(rèn)識,不了解的地方一定要多翻資料,多讀源碼,模棱兩可的理解是絕對不夠的;常見的開源組件雖然說大部分代碼都是資深開發(fā)人員寫出來的,但是有程序員的地方就有bug ,要帶著審慎眼光去看到這些組件,而不是一味地使用盲從,所謂盡信書不如無書;很多中間件的基本原理都是相通的,如常見Raft協(xié)議,基于epoll的reactor網(wǎng)絡(luò)架構(gòu),存儲領(lǐng)域的零拷貝技術(shù),預(yù)寫日志,常見的索引技術(shù),hash結(jié)構(gòu),B+樹,LSM樹等等。一個成熟的中間件往往會涉及多個方向的技術(shù)內(nèi)容。研發(fā)人員并不需要每一個組件都涉獵極深,也不現(xiàn)實(shí),掌握常見的架構(gòu)思路和技巧以及一些基本的技術(shù)點(diǎn),做到對一兩個組件做到熟稔于心。思考和理解到位了,很容易觸類旁通。

穩(wěn)定性把控

自研基礎(chǔ)組件是一項(xiàng)浩大的工程,可以預(yù)見代碼量會極為龐大,如何有效管理新項(xiàng)目的代碼質(zhì)量是個棘手的問題; 原有業(yè)務(wù)邏輯的改造也需要回歸測試;現(xiàn)實(shí)的情況是中間件團(tuán)隊(duì)沒有專職的測試,質(zhì)量保證完全依賴開發(fā)人員;這就對開發(fā)人員的代碼質(zhì)量提出了極高的要求,一方面我們通過與老網(wǎng)關(guān)適配相同的代理引擎接口,降低遷移成本和業(yè)務(wù)邏輯出現(xiàn)bug的概率;另一方面還對編碼質(zhì)量提出了高標(biāo)準(zhǔn),平均每周兩到三次的CodeReview;80%的單元測試行覆蓋率要求。

網(wǎng)關(guān)作為流量入口,承接全司最高流量,對穩(wěn)定性的要求極為苛刻。最理想的狀態(tài)是在業(yè)務(wù)服務(wù)沒有任何感知的情況下,我們將新網(wǎng)關(guān)逐步替換上去;為此我們對新網(wǎng)關(guān)上線的過程做了充分的準(zhǔn)備,嚴(yán)格控制上線過程;具體來看整個上線流程分為以下幾個階段:

第一階段

我們在壓測環(huán)境長時(shí)間高負(fù)載壓測,持續(xù)運(yùn)行時(shí)間24小時(shí)以上,以檢測內(nèi)存泄漏等穩(wěn)定性問題。同時(shí)利用性能檢測工具抓取熱點(diǎn)火焰圖,做針對性優(yōu)化。

第二階段

發(fā)布測試環(huán)境試跑,采用并行試跑的方式,新老網(wǎng)關(guān)同時(shí)對外提供服務(wù)(流量比例1 :1,初期新網(wǎng)關(guān)承接流量可能只有十分之一),一旦用戶反饋的問題可能跟新網(wǎng)關(guān)有關(guān),或者發(fā)現(xiàn)異常case,立即關(guān)停新網(wǎng)關(guān)的流量。待查明原因并確認(rèn)修復(fù)后,重新引流。

第三階段

上線預(yù)發(fā),小得物環(huán)境試跑,由于這些環(huán)境流量不大,依然可以并行長時(shí)間試跑,發(fā)現(xiàn)問題解決問題。

第四階段

生產(chǎn)引流,單節(jié)點(diǎn)從萬分之一比例開始灰度,逐步引流放大,每個階段停留24小時(shí)以上,觀察修正后再放大,循環(huán)此過程;基于單節(jié)點(diǎn)承擔(dān)正常比例流量后,再次抓取火焰圖,基于真實(shí)流量場景下的性能熱點(diǎn)做針對性優(yōu)化。

責(zé)任編輯:武曉燕 來源: 得物技術(shù)
相關(guān)推薦

2025-04-17 04:00:00

2024-07-01 08:01:45

API網(wǎng)關(guān)接口

2022-11-23 18:39:06

智能質(zhì)檢

2022-09-30 15:15:03

OpusRTC 領(lǐng)域音頻編碼器

2023-03-30 18:39:36

2025-03-13 06:48:22

2023-10-09 18:35:37

得物Redis架構(gòu)

2022-12-14 18:40:04

得物染色環(huán)境

2023-11-27 18:38:57

得物商家測試

2023-02-08 18:33:49

SRE探索業(yè)務(wù)

2022-10-26 18:44:33

藍(lán)紙箱設(shè)計(jì)數(shù)據(jù)

2023-07-19 22:17:21

Android資源優(yōu)化

2023-02-09 08:08:01

vivoJenkins服務(wù)器

2023-11-29 18:41:35

模型數(shù)據(jù)

2023-02-01 18:33:44

得物商家客服

2022-12-09 18:58:10

2022-10-20 14:35:48

用戶畫像離線

2023-03-13 18:35:33

灰度環(huán)境golang編排等

2025-03-20 10:47:15

2024-06-27 10:20:25

點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號