得物自研API閘道器實踐之路

發表於2024-02-11

一、業務背景

老閘道器使用 Spring Cloud Gateway (下稱SCG)技術框架搭建,SCG基於webflux 程式設計正規化,webflux是一種響應式程式設計理念,響應式程式設計對於提升系統吞吐率和效能有很大幫助; webflux 的底層構建在netty之上效能表現優秀;SCG屬於spring生態的產物,具備開箱即用的特點,以較低的使用成本助力得物早期的業務快速發展;但是隨著公司業務的快速發展,流量越來越大,閘道器迭代的業務邏輯越來越多,以及安全審計需求的不斷升級和穩定性需求的提高,SCG在以下幾個方面逐步暴露了一系列的問題。

網路安全

從網路安全形度來講,對公網暴露介面無疑是一件風險極高的事情,閘道器是對外網路流量的重要橋樑,早期的介面暴露採用泛化路由的模式,即透過正則形式( /api/v1/app/order/** )的路由規則開放介面,單個應用服務往往只配置一個泛化路由,後續上線新介面時外部可以直接訪問;這帶來了極大的安全風險,很多時候業務開發的介面可能僅僅是內部呼叫,但是一不小心就被泛化路由開放到了公網,甚至很多時候沒人講得清楚某個服務具體有多少介面屬於對外,多少對內;另一方面從監控資料來看,黑產勢力也在不斷對我們的介面做滲透試探。

協同效率

引入了介面序號產生器制,所有對外暴露介面逐一註冊到閘道器,未註冊介面不可訪問,安全的問題得到了解決但同時帶來了效能問題,SCG採用遍歷方式匹配路由規則,介面註冊模式推廣後路由介面註冊數量迅速提升到3W+,路由匹配效能出現嚴重問題;泛化路由的時代,一個服務只有一個路由配置,變動頻率很低,配置工作由閘道器關開發人員負責,效率尚可,介面註冊模式將路由工作轉移到了業務開發同學的身上,這就得引入一套完整的路由稽核流程,以提升協同效率;由於路由資訊早期都存在配置中心,同時這麼大的資料量給配置中心也帶來極大的壓力和穩定性風險。

效能與維護成本

業務迭代的不斷增多,也使得API閘道器堆積了很多的業務邏輯,這些業務邏輯分散在不同的filter中,為了降低開發成本,閘道器只有一套主線分支,不同叢集部署的程式碼完全相同,但是不同叢集的業務屬性不同,所需要的filter 邏輯是不一樣的;如內網閘道器叢集幾乎沒什麼業務邏輯,但是App叢集可能需要幾十個filter的邏輯協同工作;這樣的一套程式碼對內網閘道器而言,存在著大量的效能浪費;如何平衡維護成本和執行效率是個需要思考的問題。

穩定性風險

API閘道器作為基礎服務,承載全站的流量出入,穩定性無疑是第一優先順序,但其定位決定了絕不可能是一個簡單的代理層,在穩定執行的同時依然需要承接大量業務需求,例如C端使用者登入下線能力,App強升能力,B端場景下的鑑權能力等;很難想象較長一段時間以來,閘道器都保持著雙週一次的發版頻率;頻繁的發版也帶來了一些問題,例項啟動初期有很多資源需要初始化,此時承接的流量處理時間較長,存在著明顯的介面超時現象;早期的每次發版幾乎都會導致下游服務的介面短時間內超時率大幅提高,而且往往涉及多個服務一起出現類似情況;為此甚至拉了一個閘道器發版公告群,提前置頂發版公告,讓業務同學和NOC有一個心裡預期;在釋出升級期間儘可能讓業務服務無感知這是個剛需。

定製能力

流量灰度是閘道器最常見的功能之一,對於新版本迭代,業務服務的某個節點發布新版本後希望引入少部分流量試跑觀察,但很遺憾SCG原生並不支援,需要對負載均衡演算法進行手動改寫才可以,此外基於流量特徵的定向節點路由也需要手動開發,在SCG中整個負載均衡演算法屬於比較核心的模組,不對外直接暴露,存在較高的改造成本。

B端業務和C端業務存在著很大的不同,例如對介面的響應時間的忍受度是不一樣的,B端場景下下載一個報表使用者可以接受等待10s或者1分鐘,但是C端使用者現在沒有這個耐心。作為代理層針對以上的場景,我們需要針對不同介面定製不同的超時時間,原生的SCG顯然也不支援。

諸如此類的定製需求還有很多,我們並不寄希望於開源產品能夠開箱即用滿足全部需求,但至少定製性擴充性足夠好。上手改造成本低。

二、技術痛點

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

記憶體洩露問題

SCG存在較多的記憶體洩漏問題,排查困難,且官方遲遲未能修復,長期執行會導致服務觸發OOM並當機;以下為github上SCG官方開源倉庫的待解決的記憶體洩漏問題,大約有16個之多。
圖片
hljs-center SCG記憶體洩漏BUG

下圖可以看到SCG在長期執行的過程中記憶體使用一直在增長,當增長到機器記憶體上限時當前節點將不可用,聯絡到閘道器單節點所承接的QPS 在幾千,可想而知節點當機帶來的危害有多大;一段時間以來我們需要對SCG閘道器做定期重啟。
圖片
hljs-centerSCG生產例項記憶體增長趨勢

響應式程式設計正規化複雜

基於webflux 中的flux 和mono ,在對request和response資訊讀取修改時,編碼複雜度高,程式碼理解困難,下圖是對body資訊進行修改時的程式碼邏輯。
圖片
hljs-center對requestBody 進行修改的方式

多層抽象的效能損耗

儘管相比於傳統的阻塞式閘道器,SCG的效能已經足夠優秀,但相比原生的netty仍然比較低下,SCG依賴於webflux程式設計正規化,webflux構建於reactor-netty之上,reactor-netty 構建於netty 之上,多層抽象存在較大的效能損耗。 
圖片
hljs-centerSCG依賴層級

一般認為程式呼叫棧越深效能越差;下圖為只有一個filter的情況下的呼叫棧,可以看到存在大量的 webflux 中的 subscribe() 和onNext() 方法呼叫,這些方法的執行不關聯任何業務邏輯,屬於純粹的框架執行層程式碼,粗略估算下沒有引入任何邏輯的情況下SCG的呼叫棧深度在 90+ ,如果引入多個filter處理不同的業務邏輯,執行緒棧將進一步加深,當前閘道器的業務複雜度實際棧深度會達到120左右,也就是差不多有四分之三的非業務棧損耗,這個比例是有點誇張的。
圖片

圖片
hljs-center SCG filter 呼叫棧深度

路由能力不完善

原生的的SCG並不支援動態路由管理,路由的配置資訊透過大量的KV配置來做,平均一個路由配置需要三到四條KV配置資訊來支撐,這些配置資料一般放在諸如Apollo或者ark 這樣的配置中心,即使是新增了新的配置SCG並不能動態識別,需要引入動態重新整理路由配置的能力。另一方面路由匹配演算法透過遍歷所有的路由資訊逐一匹配的模式,當介面級別的路由數量急劇膨脹時,效能是個嚴重問題。
圖片
hljs-centerSCG路由匹配演算法為On時間複雜度

預熱時間長,冷啟動RT尖刺大

SCG中LoadBalancerClient 會呼叫choose方法來選擇合適的endpoint 作為本次RPC發起呼叫的真實地址,由於是懶載入,只有在有真實流量觸發時才會載入建立相關資源;在觸發底層的NamedContextFactory#getContext 方法時存在一個全域性鎖導致,woker執行緒在該鎖上大量等待。
圖片
hljs-centerNamedContextFactory#getContext方法存在全域性鎖

圖片
hljs-centerSCG釋出時超時報錯增多

定製性差,資料流控制耦合

SCG在開發運維過程中已經出現了較多的針對原始碼改造的場景,如動態路由,路由匹配效能最佳化等;其設計理念老舊,控制流和資料流混合使用,架構不清晰,如對路由管理操作仍然耦合在filter中,即使引入spring mvc方式管理,依然繫結使用webflux程式設計正規化,同時也無法做到控制流埠獨立,存在一定安全風險。
圖片
hljs-centerfilter中對路由進行管理

三、方案調研

理想中的閘道器

綜合業務需求和技術痛點,我們發現理想型的閘道器應該是這個樣子的:

  • 支援海量介面註冊,並能夠在執行時支援動態新增修改路由資訊,具備出色路由匹配效能
  • 程式設計正規化儘可能簡單,降低開發人員心智負擔,同時最好是開發人員較為熟悉的語言
  • 效能足夠好,至少要等同於目前SCG的效能,RT99線和ART較低
  • 穩定性好,無記憶體洩漏,能夠長時間持續穩定執行,釋出升級期間要儘可能下游無感
  • 擴充能力強,支援超時定製,多網路協議支援,http,Dubbo等,生態完善
  • 架構設計清晰,資料流與控制流分離,整合UI控制面

開源閘道器對比

基於以上需求,我們對市面上的常見閘道器進行了調研,以下幾個開源方案對比。
圖片

結合當前團隊的技術棧,我們傾向於選擇Java技術棧的開源產品,唯一可選的只有zuul2 ,但是zuul2路由註冊和穩定性方面也不能夠滿足我們的需求,也沒有實現數控分離的架構設計。因此唯有走上自研之路。

四、自研架構

通常而言代理閘道器分為透明代理與非透明代理,其主要區別在於對於流量是否存在侵入性,這裡的侵入性主要是指對請求和響應資料的修改;顯然API Gateway的定位決定了必然會對流量進行資料調整,常見的調整主要有 新增或者修改head 資訊,加密或者解密 query params head ,以及 requestbody 或者responseBody,可以說http請求的每一個部分資料都存在修改的可能性,這要求代理層必須要完全解析資料包資訊,而非簡單的做一個路由器轉發功能。

傳統的伺服器架構,以reactor架構為主。boss執行緒和worker執行緒的明確分工,boss執行緒負責連線建立建立;worker執行緒負責已經建立的連線的讀寫事件監聽處理,同時會將部分複雜業務的處理放到獨立的執行緒池中,進而避免worker執行緒的執行時間過長影響對網路事件處理的及時性;由於閘道器是IO密集型服務,相對來說計算內容較少,可以不必引入這樣的業務執行緒池;直接基於netty 原生reactor架構實現。

Reactor多執行緒架構

圖片

為了只求極致效能和降低多執行緒編碼的資料競爭,單個請求從接收到轉發後端,再到接收後端服務響應,以及最終的回寫給client端,這一系列操作被設計為完全閉合在一個workerEventLoop執行緒中處理;這需要worker執行緒中執行的IO型別操作全部實現非同步非阻塞化,確保worker執行緒的高速運轉;這樣的架構和NGINX很類似;我們稱之為 thread-per-core模式。
圖片

API閘道器元件架構

圖片

資料流控制流分離

資料皮膚專注於流量代理,不處理任何admin 類請求,控制流監聽獨立的埠,接收管理指令。
圖片

五、核心設計

請求上下文封裝

新的API閘道器底層仍然基於Netty,其自帶的http協議解析handler可以直接使用。基於netty框架的程式設計正規化,需要在初始化時逐一註冊用到的 Handler。 
圖片
hljs-centerClient到Proxy鏈路Handler執行順序

HttpServerCodec 負責HTTP請求的解析;對於體積較大的Http請求,客戶端可能會拆成多個小的資料包進行傳送,因此在服務端需要適當的封裝拼接,避免收到不完整的http請求;HttpObjectAggregator 負責整個請求的拼裝組合。

拿到HTTP請求的全部資訊後在業務handler 中進行處理;如果請求體積過大直接拋棄;使用ServerWebExchange 物件封裝請求上下文資訊,其中包含了client2Proxy的channel, 以及負責處理該channel 的eventLoop 執行緒等資訊,考慮到整個請求的處理過程中可能在不同階段傳遞一些擴充資訊,引入了getAttributes 方法 用於儲存需要傳遞的資料;此外ServerWebExchange 介面的基本遵循了SCG的設計規範,保證了在遷移業務邏輯時的最小化改動;具體到實現類,可以參考如下程式碼:

@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;
 }

hljs-centerDefaultServerWebExchange

Client2ProxyHttpHandler作為核心的入口handler 負責將接收到的FullHttpRequest 進行封裝和構建ServerWebExchange 物件,其核心邏輯如下。可以看到對於資料讀取封裝的邏輯較為簡單,並沒有植入常見的業務邏輯,封裝完物件後隨即呼叫 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;
    }
}

hljs-centerClient2ProxyHttpHandler 精簡後的程式碼

FilterChain設計

FilterChain可以解決非同步請求傳送出去後,還沒收到響應,但是順序邏輯已經執行完成的尷尬;例如當我們在上文的。

channelRead0 方法中發起某個鑑權RPC呼叫時,出於效能考慮只能使用非阻塞的方式,按照netty的非阻塞編碼API最終要引入類似如下的 callback 機制,在業務邏輯上在沒有收到RPC的響應之前該請求的處理應該“暫停”,等待收到響應時才能繼續後續的邏輯執行; 也就是下面程式碼中的下一步執行邏輯並不能執行,正確的做法是將nextBiz() 方法包裹在 callBack() 方法內,由callBack() 觸發後續邏輯的執行;這只是發起一次RPC呼叫的情況,在實際的的日常研發過程中存在著鑑權,風控,叢集限流(Redis)等多次RPC呼叫,這就導致這樣的非阻塞程式碼編寫將異常複雜。

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

hljs-center非阻塞呼叫下的業務邏輯編排

對於這樣的複雜場景,採用filterChain模式可以很好的解決;首先RequestFilterChain().filter(serverWebExchange); 後不存在任何邏輯;發起請求時 ,當前filter執行結束,由於此時沒有呼叫chain.filter(exchange); 所以不會繼續執行下一個filter,傳送請求到下游的邏輯也不會執行;當前請求的處理流程暫時中止,eventloop 執行緒將切換到其他請求的處理過程上;當收到RPC響應時,chain.filter(exchange) 被執行,之前中斷的流程被重新拉起。

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, "閘道器內部錯誤!filter chain exception!");
        }
    }
}

hljs-center基於filterChain的呼叫模式

對於filter的執行需要定義先後順序,這裡參考了SCG的方案,每個filter返回一個order值。不同的地方在於DAG的設計不允許 order值重複,因為在order重複的情況下,很難界定到底哪個Filter 先執行,存在模糊地帶,這不是我們期望看到的;DAG中的Filter 執行順序為order值從小到大,且不允許order值重複。為了易於理解,這裡將Filter拆分為了 requestFilter,和responseFilter;分別代表請求的處理階段 和拿到下游響應階段,responseFilter 遵循同樣的邏輯執行順序與不可重複性。

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

public interface ResponseFilter extends GatewayFilter { }

public interface RequestFilter extends GatewayFilter { }

hljs-centerfilter介面設計

路由管理與匹配

以SCG閘道器注冊的路由數量為基準,閘道器節點的需要支撐的路由規則數量是上萬級別的,按照得物目前的業務量,上限不超過5W,為了保證匹配效能,路由規則放在分散式快取中顯然是不合適的,需要儲存在節點的記憶體中。類似於在nginx上配置上萬條location 規則,手動維護難度可想而知,即使在配置中心管理起來也很麻煩,所以需要引入獨立路由管理模組。
圖片

在匹配的效率上也需要進一步最佳化,SCG的路由匹配策略為普通的迴圈迭代逐一匹配,時間效率為On,在路由規則膨脹到萬級別後,效能急劇拉胯,結合得物的介面規範,新閘道器採用Hash匹配模式,將匹配效率提升到O1;hash的key為介面的path, 需要強調的是在同一個閘道器叢集中,path是唯一的,這裡的path並不等價於業務服務的介面path, 絕大多數時候存在一些剪裁,例如在業務服務的編寫的/order/detail介面,在閘道器實際註冊的介面可能為/api/v1/app/order/detail;由於使用了path作為key進行hash匹配。常見的restful 介面顯然是不支援的,確切的講基於path傳引數模式的介面均不支援;出於某些歷史原因,閘道器保留了類似nginx 的字首匹配的支援,但是這部分功能不對外開放。

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

hljs-centerroute類設計

route的URI欄位中包含了,需要路由到的具體服務名,這裡也可以稱之為host ,route 資訊會暫存在 exchange物件的 attributes 屬性中, 在後續的loadbalance階段host資訊會被進一步替換為真實的 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;
    }
}

hljs-center路由匹配邏輯

單執行緒閉環

為了更好地利用CPU,以及減少不必要的資料競爭,將單個請求的處理全部閉合在一個執行緒當中;這意味著這個請求的業務邏輯處理,RPC呼叫,許可權驗證,限流token獲取都將始終由某個固定執行緒處理。netty中 網路連線被抽象為channel,channel 與eventloop執行緒的對應關係為 N對1,一個channel 僅能被一個eventloop 執行緒所處理,這在處理使用者請求時沒有問題,但是在接收請求完畢向下遊轉發請求時,我們碰到了一些挑戰,下游的連線往往是連線池在管理,連線池的管理是另一組eventLoop執行緒在負責,為了保持閉環需要將連線池的執行緒設定為處理當前請求的執行緒,並且只能是這一個執行緒;這樣一來,預設狀態下啟動的N個執行緒(N 與機器核心數相同),分別需要管理一個連線池;thread-per-core 模式的效能已經在nginx開源元件上得到驗證。
圖片

連線管理最佳化

為了滿足單執行緒閉環,需要將連線池的管理執行緒設定為當前的 eventloop 執行緒,最終我們透過threadlocal 進行執行緒與連線池的繫結;通常情況下netty自帶的連線池 FixedChannelPool 可以滿足我們大部分場景下的需求,這樣的連線池也是適用於多執行緒的場景;由於新閘道器使用thread-per-core模式並將請求處理的全生命週期閉合在單個執行緒中,所有為了執行緒安全的額外操作不再必要且存在效能浪費;為此需要對原生連線池做一些最佳化, 連線的獲取和釋放簡化為對連結串列結構的簡單getFirst , addLast。

對於RPC 而言,無論是HTTP,還是Dubbo,Redis等最終底層都需要用到TCP連線,將構建在TCP連線上的資料解析協議與連線剝離後,我們發現這種純粹的連線管理是可以複用的,對於連線池而言不需要知道具體連線的用途,只需要維持到特定endpoint的連線穩定即可,那麼這裡的RPC服務的連線仍然可以放入連線池中進行託管;最終的連線池設計架構圖。
圖片

AsyncClient設計

對於七層流量而言基本全部都是Http請求,同樣在RPC請求中 http協議也佔了大多數,考慮到還會存在少量的dubbo, Redis 等協議通訊的場景。因此需要抽象出一套非同步呼叫框架來支撐;這樣的框架需要具備超時管理,回撥執行,錯誤輸出等功能,更重要的是具備協議無關性質, 為了更方便使用需要支援鏈式呼叫。

發起一次RPC呼叫通常可以分為以下幾步:

  • 獲取目標地址和使用的協議, 目標服務為叢集部署時,需要使用loadbalance模組
  • 封裝傳送的請求,這樣的請求在應用層可以具體化為某個Request類,網路層序列化為二進位制資料流
  • 出於效能考慮選擇非阻塞式傳送,傳送動作完成後開始計算超時接收資料響應,由於採用非阻塞模式,這裡的傳送執行緒並不會以block的方式等待資料
  • 在超時時間內完成資料處理,或者觸發超時導致連線取消或者關閉
    圖片

AsyncClient 模組內容並不複雜,AsyncClient為抽象類不區分使用的網路協議;ConnectionPool 作為連線的管理者被client所引用,獲取連線的key 使用 protocol+ip+port 再適合不過;通常在某個具體的連線初始化階段就已經確定了該channel 所使用的協議,因此初始化時會直接繫結協議Handler;當協議為HTTP請求時,HttpClientCodec 為HTTP請求的編解碼handler;也可以是構建在TCP協議上的 Dubbo, Mysql ,Redis 等協議的handler。

首先對於一個請求的不同執行階段需要引入狀態定位,這裡引入了 STATE 列舉:

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

其次在執行過程中設計了 AsyncContext作為資訊儲存的載體,內部包含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;
}

hljs-centerAsyncContext

AsyncClient 封裝了基本的網路通訊能力,不拘泥於某個固定的協議,可以是Redis, http,Dubbo 等。當將資料寫出去之後,該channel的非阻塞呼叫立即結束,在沒有收到響應之前無法對AsyncContext 封裝的資料做進一步處理,如何在收到資料時將接收到的響應和之前的請求管理起來這是需要面對的問題,channel 物件 的attr 方法可以用於臨時繫結一些資訊,以便於上下文切換時傳遞資料,可以在傳送資料時將AsyncContext物件繫結到該channel的某個固定key上。當channel收到響應資訊時,在相關的 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);
            }
        });
    }
    /**
     * 嘗試從連線池中獲取連線併傳送請求,若失敗返回錯誤
     */
    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);
            }
        }
    }
}

hljs-centerAsyncClient核心原始碼

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;
        }
    }
}

hljs-centerAsyncClientHandler

透過上面幾個類的封裝得到了一個易用使用的 AsyncClient,下面的程式碼為呼叫許可權系統的案例:

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 驗證失敗");
        })
        .sendRequest();

hljs-centerasyncClient的使用

請求超時管理

一個請求的處理時間不能無限期拉長, 超過某個閾值的情況下App的頁面會被取消 ,長時間的載入卡頓不如快速報錯帶來的體驗良好;顯然閘道器需要針對介面做超時處理,尤其是在向後端服務發起請求的過程,通常我們會設定一個預設值,例如3秒鐘,超過這個時間閘道器會向請求端回寫timeout的失敗資訊,由於閘道器下游接入的服務五花八門,可能是RT敏感型的C端業務,也可能是邏輯較重B端服務介面,甚至是存在大量計算的監控大盤介面。這就導致不同介面對超時時間的訴求不一樣,因此針對每個介面的超時時間設定應該被獨立出來,而不是統一配置成一個值。

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 驗證失敗");
        })
        .sendRequest();

asyncClient 的鏈式呼叫設計了 timeout方法,用於傳遞超時時間,我們可以透過一個全域性Map來配置這樣的資訊。

Map<String,Integer> 其key為全路徑的path 資訊,V為設定的超時時間,單位為ms, 至於Map的資訊在實際配置過程中如何承載,使用ARK配置或者Mysql 都很容易實現。處於併發安全和效能的極致追求,超時事件的設定和排程最好能夠在與當前channel繫結的執行緒中執行,慶幸的是 EventLoop執行緒自帶schedule 方法。具體來看上文的 AsyncClient 的56行。schedule 方法內部以堆結構的方式實現了對超時時間進行管理,整體效能尚可。

堆外記憶體管理最佳化

常見的堆外記憶體手動管理方式無非是引用計數,不同處理邏輯可能針對 RC (引用計數) 的值做調整,到某個環節的業務邏輯處理後已經不記得當前的引用計數值是多少了,甚至是前面的RC增加了,後面的RC忘記減少了;但換個思路,在資料回寫給客戶端後我們肯定要把這個請求整個生命週期所申請的堆外記憶體全部釋放掉,堆外記憶體在回收的時候條件只有一個,就是RC值為0 ,那麼在最終的release的時候,我們引入一個safeRelase的思路 , 如果當前的RC>0 就不停的 release ,直至為0;因此只要把這樣的邏輯放在netty的最後一個Handler中即可保證記憶體得到有效釋放。

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();
        }
    }
}

hljs-centersafeRelease

響應時間尖刺最佳化

由於DAG 選擇了複用spring 的 loadbalance 模組,但這樣一來就會和SCG一樣存在啟動初期的響應時間尖刺問題;為此我們進一步分析RibbonLoadBalancerClient 的構建過程,發現其用到了NamedContextFactory,該類的 contexts 變數儲存了每一個serviceName對應的一個獨立context,這種使用模式帶來大量的效能浪費。

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

在實際執行中 RibbonLoadBalancerClient 會呼叫choose方法來選擇合適的endpoint 作為本次RPC發起呼叫的真實地址;choose 方法執行過程中會觸發 getLoadBalancer() 方法執行,可以看到該方法的可以按照傳入的serviceId 獲取專屬於這個服務的LoadBalancer,事實上這樣的設計有點多此一舉。大部分情況下,每個服務的負載均衡演算法都一致的,完全可以複用一個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);
    }
    //.........
}

hljs-centerRibbonLoadBalancerClient

由於是懶載入,實際流量觸發下才會執行,因此第一次執行時,RibbonLoadBalancerClient 物件並不存在,需要初始化建立,建立時大量執行緒併發呼叫SpringClientFactory#getContext 方法,鎖在同一個物件上,出現大量的RT尖刺。這也解釋了為什麼SCG閘道器在釋出期間會出現響應時間大幅度抖動的現象。

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);
    }
    //.........
}

hljs-centerSpringClientFactory

在後期的壓測過程中,發現 DAG的執行緒數量遠超預期,基於thread-per-core的架構模式下,過多的執行緒對效能損害比較大,尤其是當負載上升到較高水位時。上文提到預設情況下,每個服務都會建立獨立loadBalanceClient , 而在其內部又會啟動獨立的執行緒去同步當前關聯的serviceName對應的可用serverList,閘道器的特殊性導致需要接入的服務數量極為龐大,進而導致執行一段時間後DAG的執行緒數量急劇膨脹,對於同步serverList 這樣的動作而言,完全可以採用非阻塞的方式從註冊中心拉取相關的serverList , 這種模式下單執行緒足以滿足效能要求。
圖片
hljs-centerserverList的更新前後架構對比

透過預先初始化的方式以及全域性只使用1個context的方式,可以將這裡冷啟動尖刺消除,改造後的測試結果符合預期。
圖片

透過進一步修改最佳化spring loadbalance serverList 同步機制,降低90%執行緒數量的使用。
圖片
hljs-center最佳化前執行緒數量(725)

圖片
hljs-center最佳化後執行緒數量(72)

叢集限流改造最佳化

首先來看DAG 啟動後sentinel相關執行緒,類似的問題,執行緒數量非常多,需要針對性最佳化。
圖片
hljs-centerSentinel 執行緒數

sentinel執行緒分析最佳化:
圖片

圖片
hljs-center最終最佳化後的執行緒數量為4個

sentinel原生限流原始碼分析如下,進一步分析SphU#entry方法發現其底呼叫 FlowRuleCheck#passClusterCheck;在passClusterCheck方法中發現底層網路IO呼叫為阻塞式,由於該方法的執行執行緒為workerEventLoop,因此需要使用上文提到的AsyncClient 進行最佳化。

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. 透過,走業務邏輯
        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();
    }
}

hljs-centerSentinelGatewayFilter(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;
    }
}

hljs-centerRedisTokenService

最終的限流Filter程式碼如下:

public class SentinelGatewayFilter implements RequestFilter {
    @Resource
    RedisTokenService tokenService;\
    
    @Override
    public void filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        //當前為 netty NioEventloop 執行緒
        ServerHttpRequest request = exchange.getRequest();
        String resource = request.getPath() != null ? request.getPath() : "";
  
        //判斷是否有叢集限流規則
        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);
        }
    }
}

hljs-center改造後適配DAG的SentinelGatewayFilter

六、壓測效能

DAG高壓表現

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

DAG閘道器的QPS、實時RT、錯誤率、CPU、記憶體監控圖;在CPU佔用80% 情況下,能夠支撐的QPS在4.5W。
圖片
hljs-centerDAG閘道器的QPS、RT 折線圖

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

SCG高壓表現

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

SCG閘道器的QPS、實時RT、錯誤率、CPU、記憶體監控圖:
圖片

SCG閘道器的QPS、RT 折線圖:
圖片
SCG在CPU佔用95% 情況下,能夠支撐的QPS在1.1W,ART 54.1ms

DAG低壓表現

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

DAG閘道器的QPS、實時RT、錯誤率、CPU、記憶體: 
image.png
hljs-centerDAG閘道器的QPS、RT 折線圖:
圖片
DAG在QPS 1.1W情況下,CPU佔用30%,ART 1.56ms

資料對比

圖片

結論

滿負載情況下,DAG要比SCG的吞吐量高很多,QPS幾乎是4倍,RT反而消耗更低,SCG在CPU被打滿後,RT表現出現嚴重效能劣化。DAG的吞吐控制和SCG一樣情況下,CPU和RT損耗下降了更多。DAG在最大壓力下,記憶體消耗比較高,達到了75%左右,不過到峰值後,就不再會有大幅變動了。對比壓測結果,結論令人欣喜,SCG作為Java生態當前使用最廣泛的閘道器,其效能屬於一線水準,DAG的效能達到其4倍以上也是遠超意料,這樣的結果給與研發同學極大的鼓舞。

七、投產收益

安全性提升

完善的介面級路由管理

基於介面註冊模式的全新路由上線,包含了介面註冊的申請人,申請時間,介面場景備註資訊等,介面管理更加嚴謹規範;結合路由組功能可以方便的查詢當前服務的所有對外介面資訊,某種程度上具備一定的API查詢管理能力;同時為了緩解使用者需要檢索的介面太多的尷尬,引入了一鍵收藏功能,大部分時候使用者只需要切換到已關注列表即可。
圖片
hljs-center註冊介面列表

圖片
hljs-center介面收藏

防滲透能力極大增強

早期的泛化路由,給黑產的滲透帶來了極大的想象空間和安全隱患,甚至可以在外網直接訪問某些業務的配置資訊。
圖片
hljs-center黑產介面滲透

介面註冊模式啟用後,所有未註冊的介面均無法訪問,防滲透能力提升一個臺階,同時自動推送異常介面訪問資訊。
圖片
hljs-center404介面訪問異常推送

穩定性增強

記憶體洩漏問題解決

透過一系列手段改進最佳化和嚴格的測試,新閘道器的記憶體使用更加穩健,記憶體增長曲線直接拉平,徹底解決了洩漏問題。
圖片
hljs-center老閘道器記憶體增長趨勢

圖片
hljs-center新閘道器記憶體增長趨勢

響應時間尖刺消除

透過預先初始化 & context 共用等手段,去除了執行時併發建立多個context 搶佔全域性鎖的開銷,冷啟動RT尖刺降低99% ;關於spring load balance 模組的更多最佳化細節可以參考這篇部落格:Spring LoadBalance 存在問題與最佳化。

壓測資料對比
圖片
實際生產監控

趨勢圖上略有差異,但是從非200請求的絕對值上看,這種差異可以忽略, 對比釋出期間和非釋出期間異常請求的數量,發現基本沒有區別,這代表著以往的釋出期間的響應時間尖刺基本消除,做到了釋出期間業務服務徹底無感知。
圖片
hljs-center1月4日釋出期間各節點流量變化

圖片
hljs-center1月4日異常請求狀態數量監控(釋出期間)

圖片
hljs-center1月5日異常請求狀態數量監控(無釋出)

降本增效

資源佔用下降50% +
圖片
hljs-centerSCG平均CPU佔用

圖片
hljs-centerDAG資源佔用

JDK17升級收益

得益於ZGC的優秀演算法,JVM17 在GC暫停時間上取得了出色的成果,閘道器作為延遲敏感型應用對GC的暫停時間尤為看重,為此我們組織升級了JDK17 版本;下面為同等流量壓力情況下的配置不同GC的效果對比,可以看到GC的暫停時間從平均70ms 降低到1ms 內,RT99線得到大幅度提升;吞吐量不再受流量波動而大幅度變化,效能表現更加穩定;同時閘道器的平均響應時間損耗降低5%。
圖片
hljs-centerJDK8-G1 暫停時間表現

圖片
hljs-centerJDK17-ZGC暫停時間表現

吞吐量方面,G1伴隨流量的變化呈現出一定的波動趨勢,均線在99.3%左右。ZGC的吞吐量則比較穩定,維持在無限接近100%的水平。
圖片
hljs-centerJDK8-G1 吞吐量

圖片
hljs-centerJDK17-ZGC吞吐量

對於實際業務介面的影響,從下圖中可以看到平均響應時間有所下降,這裡的RT差值表示介面經過閘道器層的損耗時間;不同介面的RT差值損耗是不同的,這可能和請求響應體的大小,是否經過登入驗證,風控驗證等業務邏輯有關。
圖片
hljs-centerJDK17與JDK8 ART對比

需要指出的是ZGC對於一般的RT敏感型應用有很大提升, 服務的RT 99線得到顯著改善。但是如果當前應用大量使用了堆外記憶體的方式,則提升相對較弱,如大量使用netty框架的應用, 因為這些應用的大部分資料都是透過手動釋放的方式進行管理。

八、思考總結

架構演進

API閘道器的自研並非一蹴而就,而是經歷了多次業務迭代循序漸進的過程;從早期的泛化路由引發的安全問題處理,到後面的大量路由註冊,帶來的匹配效能下降 ,以及最終壓垮老閘道器最後一根稻草的記憶體洩漏問題;在不同階段需要使用不同的應對策略,早期業務快速迭代,大量的需求堆積,最快的時候一個功能點的改動需要三四天內上線 ,我們很難有足夠的精力去做一些深層次的改造,這個時候需求導向為優先,功能性建設完善優先,是一個快速奔跑的建設期;伴隨體量的增長安全和穩定性的重視程度逐步拔高,繼而推進了這些方面的大量建設;從擴充SCG的原有功能到改進框架原始碼,以及最終的自研重寫,可以說新的API閘道器是一個業務推進而演化出來的產物,也只有這樣 ”生長“ 出來的架構產品才能更好的契合業務發展的需要。

技術思考

開源的API閘道器有很多,但是自研的案例並不多,我們能夠參考的方案也很有限。除了幾個業界知名的產品外,很多開源的專案參考的價值並不大;從自研的目標來看,我們最基本的要求是效能和穩定性要優於現有的開源產品,至少Java的生態是這樣;這就要求架構設計和程式碼質量上必須比現有的開源產品更加優秀,才有可能;為此我們深度借鑑了流量代理界的常青樹Nginx,發現基於Linux 多程式模型下的OS,如果要發揮出最大效能,單CPU核心支撐單程式(執行緒)是效率最高的模式。可以將OS的程式排程開銷最小化同時將快取記憶體miss降到最低,此外還要儘可能減少或者消除資料競爭,避免鎖等待和自旋帶來的效能浪費;DAG的整個技術架構可以簡化的理解為引入了獨立控制流的多執行緒版的Nginx。

中介軟體的研發創新存在著較高的難度和複雜性,更何況是在業務不斷推進中換引擎。在整個研發過程中,為了儘可能適配老的業務邏輯,對原有的業務邏輯的改動最小化,新閘道器對老閘道器的架構層介面做了全面適配;換句話說新引擎的對外暴露的核心介面與老閘道器保持一致,讓老的業務邏輯在0改動或者僅改動少量幾行程式碼後就能在新閘道器上直接跑,能夠極大幅度降低我們的測試迴歸成本,因為這些程式碼本身的邏輯正確性,已經在生產環境得到了大量驗證。這樣的介面卡模式同樣適用於其他元件和業務開發。

作為底層基礎元件的開發人員,要對自己寫下的每一行程式碼都有清晰的認識,不瞭解的地方一定要多翻資料,多讀原始碼,模稜兩可的理解是絕對不夠的;常見的開源元件雖然說大部分程式碼都是資深開發人員寫出來的,但是有程式設計師的地方就有bug ,要帶著審慎眼光去看到這些元件,而不是一味地使用盲從,所謂盡信書不如無書;很多中介軟體的基本原理都是相通的,如常見Raft協議,基於epoll的reactor網路架構,儲存領域的零複製技術,預寫日誌,常見的索引技術,hash結構,B+樹,LSM樹等等。一個成熟的中介軟體往往會涉及多個方向的技術內容。研發人員並不需要每一個元件都涉獵極深,也不現實,掌握常見的架構思路和技巧以及一些基本的技術點,做到對一兩個元件做到熟稔於心。思考和理解到位了,很容易觸類旁通。

穩定性把控

自研基礎元件是一項浩大的工程,可以預見程式碼量會極為龐大,如何有效管理新專案的程式碼質量是個棘手的問題; 原有業務邏輯的改造也需要回歸測試;現實的情況是中介軟體團隊沒有專職的測試,質量保證完全依賴開發人員;這就對開發人員的程式碼質量提出了極高的要求,一方面我們透過與老閘道器適配相同的代理引擎介面,降低遷移成本和業務邏輯出現bug的機率;另一方面還對編碼質量提出了高標準,平均每週兩到三次的CodeReview;80%的單元測試行覆蓋率要求。

閘道器作為流量入口,承接全司最高流量,對穩定性的要求極為苛刻。最理想的狀態是在業務服務沒有任何感知的情況下,我們將新閘道器逐步替換上去;為此我們對新閘道器上線的過程做了充分的準備,嚴格控制上線過程;具體來看整個上線流程分為以下幾個階段:

第一階段

我們在壓測環境長時間高負載壓測,持續執行時間24小時以上,以檢測記憶體洩漏等穩定性問題。同時利用效能檢測工具抓取熱點火焰圖,做針對性最佳化。

第二階段

釋出測試環境試跑,採用並行試跑的方式,新老閘道器同時對外提供服務(流量比例1 :1,初期新閘道器承接流量可能只有十分之一),一旦使用者反饋的問題可能跟新閘道器有關,或者發現異常case,立即關停新閘道器的流量。待查明原因並確認修復後,重新引流。

第三階段

上線預發,小得物環境試跑,由於這些環境流量不大,依然可以並行長時間試跑,發現問題解決問題。

第四階段

生產引流,單節點從萬分之一比例開始灰度,逐步引流放大,每個階段停留24小時以上,觀察修正後再放大,迴圈此過程;基於單節點承擔正常比例流量後,再次抓取火焰圖,基於真實流量場景下的效能熱點做針對性最佳化。

團隊成長

回顧整個研發歷程我們在不間斷新業務承接的情況下,幾個月時間內完成開發和上線,從節奏上來講不可謂不快,研發同學的心態也經歷了一些變化。從一開始的質疑,認為大家以前從沒有做過的東西現在就這點人能搞的出來嗎?到中期的這個元件寫起來蠻有挑戰也很有意思!直到後期初版壓測資料出來後的驚訝。就專案結果而言,可以說收穫感滿滿,從後續的針對研發同學的one one 溝通反饋來看,對於整個專案感觸最大的是技術上的提升很大,對高併發網路程式設計領域的認知提升了一個檔次, 尤其是非同步程式設計方面,技術信心增強很多;內部也組織了分享會,大家普遍很感興趣,收穫了較大的技術紅利。

*文/簌語

本文屬得物技術原創,更多精彩文章請看:得物技術官網

未經得物技術許可嚴禁轉載,否則依法追究法律責任!