開源高性能Web服務框架ESA Restlight

ESA Stack(Elastic Service Architecture) 是OPPO雲計算中心-雲原生團隊孵化的技術品牌,致力於微服務相關技術棧,幫助用戶快速構建高性能,高可用的雲原生微服務。產品包含高性能Web服務框架、RPC框架、服務治理框架、註冊中心、配置中心、調用鏈追蹤系統,Service Mesh、Serverless等各種產品及研究方向。
當前部分產品已經對外開源
開源主站:https://www.esastack.io/
Github: https://github.com/esastack
Restlight項目地址:
https://github.com/esastack/esa-restlight
Restlight文檔地址:
https://www.esastack.io/esa-restlight/
歡迎各路技術愛好者們加入,一同探討學習與進步。
*本文將不可避免的屢次提到Spring MVC,並無要與其競爭的意思,Restlight是一個獨立Web框架,有着本身的堅持。
1. Java業內傳統Web服務框架現狀
1.1 Spring MVC
說到Web服務框架,在Java領域Spring MVC可謂是無人不知,無人不曉。在Tomcat(也多是Jetty,Undertow等別的實現)基礎之上實現請求的路由匹配,過濾器,攔截器,序列化,反序列化,參數綁定,返回值解析等能力。因爲其豐富的功能,以及與當今用戶量巨大的Spring容器及Spring Boot的深度結合,讓Spring MVC幾乎是不少公司Web服務框架的不二選擇。
*本文中的Spring MVC泛指Tomcat + Spring MVC的廣義Web服務框架
1.2 Resteasy
Resteasy也是Java體系中相對比較成熟的Rest框架,是JBoss的一個開源項目,它完整的實現了JAX-RS標準,幫助用戶快速構建Rest服務,同時還提供一個Resteasy JAX-RS客戶端框架 ,方便用戶進行Rest服務調用。Resteasy在許多三方框架中集成使用的場景較多,如Dubbo,SOFA RPC等知名框架中均有使用。
2. Spring MVC就是萬能的麼?
某種意義上來講,還真是萬能的。Spring MVC幾乎具有了傳統的一個Web服務應有的絕大多數能力,無論是作一個簡單的Rest服務,仍是All In One的控制檯服務,仍是在Spring Cloud中的RPC服務,均可以使用Spring MVC。
但是隨着微服務技術的演進和變遷,特別是當今雲原生微服務理念的盛行,這個全能選手彷佛也出現了一些水土不服。
2.1 性能
2.1.1 功能與性能的折中
Spring MVC設計更可能是面向功能的設計,經過查看Spring的源碼能夠看到各類高水平的設計模式及接口設計,這讓Spring MVC成爲了一個「全能型選手」。可是複雜的設計和功能也是有代價的, 那即是在性能這個點上的折中, 有時候爲了功能或者設計不得不放棄一些性能。
2.1.2 Tomcat線程模型
Spring MVC使用單個Worker線程池處理請求
咱們可使用server.tomcat.threads.max進行線程池大小配置(默認最大爲200)。
線程模型中的Worker線程負責從socket讀取請求數據,並解析爲HttpServletRequest,隨後路由到servlet(即經典的DispatcherServlet),最後路由到controller進行業務調用。
IO讀寫與業務操做沒法隔離
  • 當業務操做爲耗時操做時,將會佔用Worker線程資源從而影響到其餘的請求的處理,也會影響到IO數據讀寫的效率javascript

  • 當網絡IO讀寫相關操做耗時也將影響業務的執行效率css

*線程模型沒有好壞之分,只有適合與不適合
2.1.3 Restful性能損耗
Restful風格的接口設計是廣大開發者比較推崇的接口設計,一般接口路徑可能會長這樣
  • /zoos/{id}java

  • /zoos/{id}/animalsgit

可是這樣的接口在Spring MVC中的處理方式會帶來性能上的損耗,由於其中{id}部分是基於正則表達式來實現的。
2.1.4 攔截器
使用攔截器時能夠經過下面的方式去設置匹配邏輯
  • InterceptorRegistration#addPathPatterns("/foo/**", "/fo?/b*r/")github

  • InterceptorRegistration#excludePathPatterns("/bar/**", "/foo/bar")web

一樣的,這個功能也會爲每次的請求都帶來大量的正則表達式匹配的性能消耗。
*這裏只列出了一些場景,實際上整個Spring MVC的實現代碼中還有不少從性能角度來看還有待提高的地方(固然這只是從性能角度...)
2.2 Rest場景的功能過剩
試想一下,當咱們使用Spring Cloud開發微服務的時候,咱們除了使用@RequestMapping, @RequestParam等常見的註解以外,還會使用諸如ModelAndView, JSP, Freemaker等相關功能麼?
在微服務這個概念已經耳熟能詳的今天,大多數的微服務已經不是一個All in One的Web服務,而是多個Rest風格的Web服務了。這使得支持完整servlet, JSP等在All in One場景功能的Spring MVC在Rest場景顯得有些大材小用了。即便如此,Spring Cloud體系中你們仍是絕不猶豫的使用的Spring MVC,由於Spring Cloud就是這麼給咱們的。
2.3 體積過大
繼上面的功能過剩的問題,一樣也會引起代碼以及依賴體積過大的問題。這在傳統微服務場景或許並非多大的問題,可是當咱們將其打成鏡像,則會致使鏡像體積較大。一樣在FaaS場景這個問題將會被放大,直接影響函數的冷啓動。
*後續將會討論FaaS相關的問題。
2.4 缺少標準
這裏的標準指的是Rest標準。實際上在Java已經有了一個通用的標準,即JAX-RS(Java API for RESTful Web Services),JAX-RS一開始就是面向Rest服務所設計的,其中包含開發Rest服務常用的一些註解,以及一整套Rest服務甚至客戶端標準。
2.4.1 註解
JAX-RS中的註解:
  • @Path
    正則表達式

  • @GET, @POST, @PUT, @DELETE算法

  • @Produces, @Consumesspring

  • @PathParam,@QueryParam,@HeaderParam,@CookieParam,@MatrixParam,@FormParamtypescript

  • @DefaultValue

  • ...

Spring MVC中的註解:
  • @RequestMapping

  • @RequestParam

  • @RequestHeader

  • @PathVariable

  • @CookieValue

  • @MatrixVariable

  • ...

實際上JAX-RS註解和Spring MVC中註解從功能上來講並無太大的差異。
可是JAX-RS的註解相比Spring MVC的註解
  1. 更加簡潔:JAX-RS註解風格更加簡潔,形式也更加統一,而Spring MVC的註解全部稍顯冗長。

  2. 更加靈活:JAX-RS的註解並不是只能用在Controller上,@Produces, @Consumes更是能夠用在序列化反序列化擴展實現等各類地方。@DefaultValue註解也能夠和其餘註解搭配使用。而@RequestMapping將各類功能都揉在一個註解中,代碼顯得冗長且複雜。

  3. 更加通用:JAX-RS註解是標準的Java註解,能夠在各類環境中使用,而相似@GetMapping, @PostMapping等註解都依賴Spring的@AliasFor註解,只能在Spring環境中使用。

*對於習慣了Spring MVC的同窗可能無感,可是筆者是親身實現過Spring MVC註解以及JAX-RS兼容的,整個過程下來更加喜歡JAX-RS的設計。
2.4.2 三方框架親和性
假如如今你要實現一個RPC框架,準備去支持HTTP協議的RPC調用,設想着相似Spring Cloud同樣用戶可以簡單標記一些@RequestMapping註解就能完成RPC調用,所以如今你須要一個僅包含Spring MVC註解的依賴,而後去實現對應的邏輯。但是遺憾的是,Spring MVC的註解是直接耦合到spring-web依賴中的,若是要依賴,就會將spring-core, spring-beans等依賴一併引入,所以業內的RPC框架的HTTP支持幾乎都是選擇的JAX-RS(好比SOFA RPC,Dubbo等)。
2.5 不夠輕量
不得不認可Spring的代碼都頗有設計感,在接口設計上很是的優雅。
可是Spring MVC這樣一個Web服務框架倒是一個總體,直接的依附在了Spring這個容器中(或許是戰略上的緣由?)。所以全部相關能力都須要引入Spring容器,甚至是Spring Boot。可能有人會說:「這不是很正常的嘛,咱們項目都會引入Spring Boot啊」。可是:
若是我是一名框架開發者,我想在個人框架中啓動一個Web服務器去暴露相應的Http接口,可是個人框架十分的簡潔,不想引入任何別的依賴(由於會傳遞給用戶),這個時候便沒法使用Spring MVC。
若是我是一名中間件開發者,一樣想在個人程序中啓動一個Web服務器去暴露相應的Metrics接口,可是不想由於這個功能就引入Spring Boot以及其餘相關的一大塊東西,這個時候我只能相似原生的嵌入式Tomcat或者netty本身實現,可是這都有些太複雜了(每次都要本身實現一遍)。
3. ESA Restlight介紹
基於上述一些問題及痛點,ESA Restlight框架便誕生了。
ESA Restlight是基於Netty實現的一個面向雲原生的高性能,輕量級的Web開發框架。(如下簡稱Restlight)。
3.1 Quick Start
建立Spring Boot項目並引入依賴
      
      
      
       
       
                
       
       
<dependency> <groupId>io.esastack</groupId> <artifactId>restlight-starter</artifactId> <version>0.1.1</version></dependency>
編寫Controller
      
      
      
       
       
                
       
       
@RestController@SpringBootApplicationpublic class RestlightDemoApplication {
@GetMapping("/hello") public String hello() { return "Hello Restlight!"; }
public static void main(String[] args) { SpringApplication.run(RestlightDemoApplication.class, args); }}
運行項目並訪問http://localhost:8080/hello
能夠看到,在Spring Boot中使用Restlight和使用Spring MVC幾乎沒有什麼區別。用法很是的簡單。
3.2 性能表現
測試場景
分別使用Restlight以及spring-boot-starter-web(2.3.2.RELEASE) 編寫兩個web服務,實現一個簡單的Echo接口(直接返回請求的body內容),分別在請求body爲16B, 128B, 512B, 1KB, 4KB, 10KB場景進行測試。
測試工具
  • wrk4.1.0


      
      
      
       
       
                
       
       
-server -Xms3072m -Xmx3072m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m -XX:+UseConcMarkSweepGC -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=70 -XX:+PrintTenuringDistribution -XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:logs/gc-${appName}-%t.log -XX:NumberOfGCLogFiles=20 -XX:GCLogFileSize=480M -XX:+UseGCLogFileRotation -XX:HeapDumpPath=.
參數配置
測試結果(RPS)
能夠看到Restlight的性能相較於Spring MVC有 2-4倍的提高。
*Restlight(IO)以及Restlight(BIZ)爲Restlight中特有的線程調度能力,使用不一樣的線程模型。
3.3 功能特性
  • HTTP1.1/HTTP2/H2C/HTTPS支持

  • SpringMVC 及 JAX-RS註解支持

  • 線程調度:隨意調度Controller在任意線程池中執行

  • 加強的SPI能力:按照分組,標籤,順序等多種條件加載及過濾

  • 自我保護:CPU過載保護,新建鏈接數限制

  • Spring Boot Actuator支持

  • 全異步過濾器,攔截器,異常處理器支持

  • Jackson/Fastjson/Gson/Protobuf序列化支持:支持序列化協商及註解隨意指定序列化方式

  • 兼容不一樣運行環境:原生Java,Spring,Spring Boot環境均能支持

  • AccessLog

  • IP白名單

  • 快速失敗

  • Mock測試

  • ...

4. ESA Restlight架構設計
4.1 設計原則
  • 雲原生快速啓動、省資源、輕量級

  • 高性能持續不懈追求的目標 & 核心競爭力,基於高性能網絡框架Netty實現

  • 高擴展性開放擴展點,知足業務多樣化的需求

  • 低接入成本兼容SpringMVC 和 JAX-RS經常使用註解,下降用戶使用成本

  • 全鏈路異步基於CompletableFuture提供完善的異步處理能力

  • 監控與統計完善的線程池等指標監控和請求鏈路追蹤與統計

4.2 分層架構設計
經過分層架構設計讓Restlight具備很是高的擴展性,同時針對原生Java, Spring, Spring Boot等場景提供不一樣實現,適合Spring Boot業務,三方框架,中間件,FaaS等多種場景。
架構圖中ESA HttpServer, Restlight Server, Restlight Core, Restlight for Spring, Restlight Starter幾個模塊都可做爲一個獨立的模塊使用, 知足不一樣場景下的需求。
4.2.1 ESA HttpServer
基於Netty 實現的一個簡易的HttpServer,支持Http1.1/Http2以及Https等。
*該項目已經同步開源到Github:https://github.com/esastack/esa-httpserver
4.2.2 Restlight Server
  • 在ESA HttpServer基礎之上封裝了

  • 引入業務線程池

  • Filter

  • 請求路由(根據url, method, header等條件將請求路由到對應的Handler)

  • 基於CompletableFuture的響應式編程支持

  • 線程調度

eg.引入依賴
      
      
      
       
       
                
       
       
<dependency> <groupId>io.esastack</groupId> <artifactId>restlight-server</artifactId> <version>0.1.1</version></dependency>
一行代碼啓動一個Http Server
      
      
      
       
       
                
       
       
Restlite.forServer() .daemon(false) .deployments() .addRoute(route(get("/hello")) .handle((request, response) -> response.sendResult("Hello Restlight!".getBytes(StandardCharsets.UTF_8)))) .server() .start();
適合各種框架,中間件等基礎組建中啓動或指望使用代碼嵌入式啓動HttpServer的場景。
4.2.3 Restlight Core
在Restlight Server之上, 擴展支持了Controller方式(在Controller類中經過諸如@RequestMappng等註解的方式構造請求處理邏輯)完成業務邏輯以及諸多經常使用功能:
  • HandlerInterceptor: 攔截器

  • ExceptionHandler: 全局異常處理器

  • BeanValidation: 參數校驗

  • ArgumentResolver: 參數解析擴展

  • ReturnValueResolver: 返回值解析擴展

  • RequestSerializer: 請求序列化器(一般負責反序列化body內容)

  • ResposneSerializer: 響應序列化器(一般負責序列化響應對象到body

  • 內置Jackson, Fastjson, Gson, ProtoBuf序列化支持

4.2.4 Restlight for Spring MVC
基於Restlight Core的Spring MVC註解支持,例如
      
      
      
       
       
                
       
       
<dependency> <groupId>io.esastack</groupId> <artifactId>restlight-core</artifactId> <version>0.1.1</version></dependency><dependency> <groupId>io.esastack</groupId> <artifactId>restlight-jaxrs-provider</artifactId> <version>0.1.1</version></dependency>
編寫Controller
      
      
      
       
       
                
       
       
@Path("/hello")public class HelloController {
@Path("/restlight") @GET @Produces(MediaType.TEXT_PLAIN_VALUE) public String restlight() { return "Hello Restlight!"; }}
使用Restlight啓動Server
      
      
      
       
       
                
       
       
Restlight.forServer() .daemon(false) .deployments() .addController(HelloController.class) .server() .start();
4.2.5 Restlight for JAX-RS
基於Restlight Core的JAX-RS註解支持
eg.引入依賴
      
      
      
       
       
                
       
       
<dependency> <groupId>io.esastack</groupId> <artifactId>restlight-core</artifactId> <version>0.1.1</version></dependency><dependency> <groupId>io.esastack</groupId> <artifactId>restlight-jaxrs-provider</artifactId> <version>0.1.1</version></dependency>
編寫Controller
      
      
      
       
       
                
       
       
@Path("/hello")public class HelloController {
@Path("/restlight") @GET @Produces(MediaType.TEXT_PLAIN_VALUE) public String restlight() { return "Hello Restlight!"; }}
使用Restlight啓動Server
      
      
      
       
       
                
       
       
Restlight.forServer() .daemon(false) .deployments() .addController(HelloController.class) .server() .start();
4.2.6 Restlight for Spring
在Restlight Core基礎上支持在Spring場景下經過ApplicationContext容器自動配置各類內容(RestlightOptions, 從容器中自動配置Filter, Controller等)。
*適用於Spring場景
4.2.7 Restlight Starter
在Restlight for Spring基礎上支持在Spring Boot場景的自動配置
*適用於Spring Boot場景
4.2.8 Restlight Actuator
在Restlight Starter基礎上支持在Spring Boot Actuator原生各類Endpoints支持以及Restlight獨有的Endpoints。
*適用於Spring Boot Actuator場景
4.3 線程模型
Restlight因爲是使用Netty做爲底層HttpServer的實現,所以圖中沿用了部分EventLoop的概念,線程模型由了Acceptor,IO EventLoopGroup(IO線程池)以及Biz ThreadPool(業務線程池)組成。
  • Acceptor:由1個線程組成的線程池, 負責監聽本地端口並分發IO 事件。

  • IO EventLoopGroup:由多個線程組成,負責讀寫IO數據(對應圖中的read()和write())以及HTTP協議的編解碼和分發到業務線程池的工做。

  • Biz Scheduler:負責執行真正的業務邏輯(大多爲Controller中的業務處理,攔截器等)。

  • Custom Scheduler: 自定義線程池

經過第三個線程池Biz Scheduler的加入完成IO操做與實際業務操做的異步(同時可經過Restlight的線程調度功能隨意調度)。
4.4 靈活的線程調度 & 接口隔離
線程調度容許用戶根據須要隨意制定Controller在IO線程上執行仍是在Biz線程上執行仍是在自定義線程上運行。
指定在IO線程上運行
      
      
      
       
       
                
       
       
@RequestMapping("/hello")@Scheduled(Schedulers.IO)public String list() { return "Hello";}
指定在BIZ線程池執行
      
      
      
       
       
                
       
       
@RequestMapping("/hello")@Scheduled(Schedulers.BIZ)public String list() { // ... return "Hello";}
指定在自定義線程池執行
      
      
      
       
       
                
       
       
@RequestMapping("/hello")@Scheduled("foo")public String list() { // ... return "Hello";}
@Beanpublic Scheduler scheduler() { // 注入自定義線程池 return Schedulers.fromExecutor("foo", Executors.newCachedThreadPool());}
*經過隨意的線程調度,用戶能夠平衡線程切換及隔離,達到最優的性能或是隔離的效果。
5. ESA Restlight性能優化的億些細節
Restlight始終將性能放在第一位,甚至有時候到了對性能偏執的程度。
5.1 Netty
Restlight基於Netty編寫,Netty自帶的一些高性能特性天然是高性能的基石,Netty常見特性均在Restlight有所運用。
  • Epoll & NIO

  • ByteBuf

  • PooledByteBufAllocator

  • EventLoopGroup

  • Future & Promise

  • FastThreadLocal

  • InternalThreadLocalMap

  • Recycler

  • ...

除此以外還作了許多其餘的工做。
5.2 HTTP協議編解碼優化
說到Netty中的實現Http協議編解碼,最多見的用法即是HttpServerCodec + HttpObjectAggregator的組合了(或是HttpRequestDecoder + HttpResponseEncoder + HttpObjectAggregator的組合)。
以Http1.1爲例:
其實HttpServerCodec已經完成了Http協議的編解碼,但是HttpObjectAggregator存在的做用又是什麼呢?
HttpServerCodec會將Http協議解析爲HttpMessage(請求則爲HttpRequest, 響應則爲HttpResponse), HttpContent, LastHttpContent三個部分,分別表明Http協議中的協議頭(包含請求行/狀態行及Header), body數據塊,最後一個body數據塊(用於標識請求/相應結束,同時包含Trailer數據)。
以請求解析爲例,一般咱們須要的是完整的請求,而不是單個的HttpRequest,亦或是一個一個的body消息體HttpContent。所以HttpObjectAggregator即是將HttpServerCodec解析出的HttpRequest,HttpContent及LastHttpContent聚合成一個FullHttpRequest, 方便用戶使用。
可是HttpObjectAggregator仍然有一些問題:

maxContentLength問題

HttpObjectAggregator構造器中須要指定一個maxContentLength參數,用於指定聚合請求body過大時拋出TooLongFrameException。問題在於這個參數是int類型的,所以這使得請求body的大小不能超過int的最大值2^31 - 1,也就是2G。在大文件,大body, chunk等場景拔苗助長。

性能

一般雖然咱們須要一個整合的FullHttpRequest解析結果,可是實際上當咱們將請求對象向後傳遞的時候咱們又不能直接將Netty原生的對象給到用戶,所以大多須要自行進行一次包裝(好比相似HttpServletRequest), 這使得本來HttpServerCodec解析出的結果進行了兩次的轉換,第一次轉換成FullHttpRequest, 第二次轉換爲用戶自定義的對象。其實咱們真正須要的是等待整個Http協議的解碼完成後將其結果聚合成咱們本身的對象而已。

大body問題

聚合也就意味着要等到全部的body都收到了以後才能作後續的操做,可是若是是一個Multipart請求,請求中包含了大文件,這時候使用HttpObjectAggregator將會把全部的body數據都保留在內存(甚至仍是直接內存)中,直到這個請求的結束。這幾乎是不可接受的。一般這種場景有兩種解決方案:
  1. 將收到的body數據轉儲到本地磁盤,釋放內存資源,等須要使用的時候經過流的方式讀取磁盤數據。

  2. 每收到一部分body數據都立馬消費掉並釋放這段內存。

這兩種方式都要求不能直接聚合請求的body。

響應式body處理

對於Http協議來講,雖然一般都是這樣的步驟:
client發送完整請求-> server接收完整請求-> server發送完整響應 -> client接收完整響應。
可是其實咱們能夠更加的靈活,處理請求時每當收到一段body都直接交給業務處理。
client發送完整請求 -> server接收請求頭 -> server處理body1 ->  server處理body2 ->  server處理body3 -> server發送完整響應。
*咱們甚至作到了client與server同時響應式的發送和處理body。
所以咱們自行實現了聚合邏輯Http1Handler以及Http2Handler。
響應式body處理
      
      
      
       
       
                
       
       
HttpServer.create() .handle(req -> { req.onData(buf -> { // 每收到一部分的body數據都將調用此邏輯 System.out.println(buf.toString(StandardCharsets.UTF_8)); }); req.onEnd(p -> { // 寫響應 req.response() .setStatus(200) .end("Hello ESA Http Server!".getBytes(StandardCharsets.UTF_8)); return p.setSuccess(null); }); }) .listen(8080) .awaitUninterruptibly();
獲取整個請求
      
      
      
       
       
                
       
       
HttpServer.create() .handle(req -> { // 設置指望聚合全部的body體 req.aggregate(true); req.onEnd(p -> { // 獲取聚合後的body System.out.println(req.aggregated().body().toString(StandardCharsets.UTF_8)); // 寫響應 req.response() .setStatus(200) .end("Hello ESA Http Server!".getBytes()); return p.setSuccess(null); }); }) .listen(8080) .awaitUninterruptibly();
響應式請求body處理及響應body處理
      
      
      
       
       
                
       
       
HttpServer.create() .handle(req -> { req.onData(buf -> { // 每收到一部分的body數據都將調用此邏輯 System.out.println(buf.toString(StandardCharsets.UTF_8)); }); req.onEnd(p -> { req.response().setStatus(200); // 寫第一段響應body req.response().write("Hello".getBytes(StandardCharsets.UTF_8)); // 寫第二段響應body req.response().write(" ESA Http Server!".getBytes(StandardCharsets.UTF_8)); // 結束請求 req.response().end(); return p.setSuccess(null); }); }) .listen(8080) .awaitUninterruptibly();
5.2.1 性能表現
測試場景
分別使用ESA HttpServer以及原生Netty(HttpServerCodec, HttpObjectAggregator) 編寫兩個web服務,實現一個簡單的Echo接口(直接返回請求的body內容),分別在請求body爲16B, 128B, 512B, 1KB, 4KB, 10KB場景進行測試。
測試工具
  • wrk4.1.0

JVM參數
      
      
      
       
       
                
       
       
-server -Xms3072m -Xmx3072m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m -XX:+UseConcMarkSweepGC -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=70 -XX:+PrintTenuringDistribution -XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:logs/gc-${appName}-%t.log -XX:NumberOfGCLogFiles=20 -XX:GCLogFileSize=480M -XX:+UseGCLogFileRotation -XX:HeapDumpPath=.
參數配置
IO線程數設置爲8
測試結果(RPS)
使用ESA HttpServer性能甚至比原生Netty性能更高。
5.3 路由緩存
傳統的Spring MVC中, 當咱們的@RequestMapping註解中包含了複雜任何的複雜匹配邏輯(這裏的複雜邏輯能夠理解爲除了一個url對應一個controller實現,而且url中沒有*, ? . {foo}等模式匹配的內容)時方能在路由階段有相對較好的效果,反之如一般狀況下一個請求的到來到路由到對應的controller實現這個過程將會是在當前應用中的全部Controller中遍歷匹配,值得注意的是一般在微服務提倡RestFul設計的大環境下一個這種遍歷幾乎是沒法避免的, 同時因爲匹配的條件自己的複雜性(好比說正則自己爲人詬病的就是性能),所以伴隨而來的則是SpringMVC的路由的損耗很是的大。
5.3.1 緩存設計
  • 二八原則(80%的業務由20%的接口處理)

  • 算法:類LFU(Least Frequently Used)算法

咱們雖然不能改變路由條件匹配自己的損耗, 可是咱們但願能作儘可能少的匹配次數來達到優化的效果。所以採用經常使用的"緩存"來做爲優化的手段。當開啓了路由緩存後,默認狀況下將使用類LFU(Least Frequently Used)算法的方式緩存十分之一的Controller,根據二八原則(80%的業務由20%的接口處理),大部分的請求都將在緩存中匹配成功並返回(這裏框架默認的緩存十分之一,是相對比較保守的設置)。
5.3.2 算法邏輯
當每次請求匹配成功時,會進行命中紀錄的加1操做,並統計命中紀錄最高的20%(可配)的Controller加入緩存, 每次請求的到來都將先從緩存中查找匹配的Controller(大部分的請求都將在此階段返回), 失敗則進入正常匹配的邏輯。
何時更新緩存?咱們不會在每次請求命中的狀況下都去更新緩存,由於這涉及到一次排序(或者m次遍歷, m爲須要緩存的Controller的個數,至關於挑選出命中最高的m個controller)。取而代之的是咱們會以機率的方式去從新計算並更新緩存, 根據2-8原則一般狀況下咱們當前緩存的內存就是咱們須要的內容, 因此不必每次有請求命中都去從新計算並更新緩存, 所以咱們會在請求命中的必定機率條件下采起作此操做(默認0.1%, 稱之爲計算機率), 減少了併發損耗(這段邏輯自己基於CopyOnWrite, 而且爲純無鎖併發編程,自己性能損耗就很低),同時此機率可配置能夠根據具體的應用實際狀況調整配置達到最優的效果。
5.3.3 效果
使用JMH進行微基準測試, 在加緩存與不加緩存操做之間作性能測試對比。
分別測試Controller個數爲10, 20, 50, 100個時的性能表現。
請求服從 泊松分佈, 5輪預熱,每次測試10次迭代。
      
      
      
       
       
                
       
       
@BenchmarkMode({Mode.Throughput})@OutputTimeUnit(TimeUnit.MILLISECONDS)@Warmup(iterations = 5)@Measurement(iterations = 10)@Threads(Threads.MAX)@Fork(1)@State(Scope.Benchmark)public class CachedRouteRegistryBenchmark {
private ReadOnlyRouteRegistry cache; private ReadOnlyRouteRegistry noCache;
@Param({"10", "20", "50", "100"}) private int routes = 100;
private AsyncRequest[] requests; private double lambda;
@Setup public void setUp() { RouteRegistry cache = new CachedRouteRegistry(1); RouteRegistry noCache = new SimpleRouteRegistry(); Mapping[] mappings = new Mapping[routes]; for (int i = 0; i < routes; i++) { HttpMethod method = HttpMethod.values()[ThreadLocalRandom.current().nextInt(HttpMethod.values().length)]; final MappingImpl mapping = Mapping.mapping("/f?o/b*r/**/??x" + i) .method(method) .hasParam("a" + i) .hasParam("b" + i, "1") .hasHeader("c" + i) .hasHeader("d" + i, "1") .consumes(MediaType.APPLICATION_JSON) .produces(MediaType.TEXT_PLAIN); mappings[i] = mapping; }
for (Mapping m : mappings) { Route route = Route.route(m); cache.registerRoute(route); noCache.registerRoute(route); }
requests = new AsyncRequest[routes]; for (int i = 0; i < requests.length; i++) { requests[i] = MockAsyncRequest.aMockRequest() .withMethod(mappings[i].method()[0].name()) .withUri("/foo/bar/baz/qux" + i) .withParameter("a" + i, "a") .withParameter("b" + i, "1") .withHeader("c" + i, "c") .withHeader("d" + i, "1") .withHeader(HttpHeaderNames.CONTENT_TYPE.toString(), MediaType.APPLICATION_JSON.value()) .withHeader(HttpHeaderNames.ACCEPT.toString(), MediaType.TEXT_PLAIN.value()) .build(); } this.cache = cache.toReadOnly(); this.noCache = noCache.toReadOnly(); this.lambda = (double) routes / 2; }
@Benchmark public Route matchByCachedRouteRegistry() { return cache.route(getRequest()); }
@Benchmark public Route matchByDefaultRouteRegistry() { return noCache.route(getRequest()); }
private AsyncRequest getRequest() { return requests[getPossionVariable(lambda, routes - 1)]; }
private static int getPossionVariable(double lambda, int max) { int x = 0; double y = Math.random(), cdf = getPossionProbability(x, lambda); while (cdf < y) { x++; cdf += getPossionProbability(x, lambda); } return Math.min(x, max); }
private static double getPossionProbability(int k, double lamda) { double c = Math.exp(-lamda), sum = 1; for (int i = 1; i <= k; i++) { sum *= lamda / i; } return sum * c; }}
測試結果
      
      
      
       
       
                
       
       
Benchmark (routes) Mode Cnt Score Error UnitsCachedRouteRegistryBenchmark.matchByCachedRouteRegistry 10 thrpt 10 1353.846 ± 26.633 ops/msCachedRouteRegistryBenchmark.matchByCachedRouteRegistry 20 thrpt 10 982.295 ± 26.771 ops/msCachedRouteRegistryBenchmark.matchByCachedRouteRegistry 50 thrpt 10 639.418 ± 22.458 ops/msCachedRouteRegistryBenchmark.matchByCachedRouteRegistry 100 thrpt 10 411.046 ± 5.647 ops/msCachedRouteRegistryBenchmark.matchByDefaultRouteRegistry 10 thrpt 10 941.917 ± 33.079 ops/msCachedRouteRegistryBenchmark.matchByDefaultRouteRegistry 20 thrpt 10 524.540 ± 18.628 ops/msCachedRouteRegistryBenchmark.matchByDefaultRouteRegistry 50 thrpt 10 224.370 ± 9.683 ops/msCachedRouteRegistryBenchmark.matchByDefaultRouteRegistry 100 thrpt 10 113.883 ± 5.847 ops/ms
能夠看出加了緩存以後性能提高明顯,同時能夠看出隨着Controller個數增多, 沒有緩存的場景性能損失很是嚴重。
5.4 攔截器設計
5.4.1 Spring MVC攔截器性能問題
先前提到Spring MVC中的攔截器因爲正則表達式的問題會致使性能問題,Restlight在優化了正則匹配性能的同時引入了不一樣類型的攔截器。
試想一下,在SpringMVC中,是否會有如下場景。
場景1
想要攔截一個controller,它的path爲/foo, 此時會使用addPathPatterns("/foo")來攔截。
這樣的場景比較簡單,Spring MVC只須要進行直接的uri匹配便可,性能消耗不大。
場景2
想要攔截某個controller class中的全部controller,它們具備共同的前綴, 此時可能會使用addPathPatterns("/foo/**")攔截。
這時候就須要對全部請求進行一次正則匹配,性能損耗較大。
場景3
想要攔截多個不一樣前綴的controller, 同時排除其中幾個,此時可能須要addPathPatterns("/foo/**", "/bar/***")以及excludePathPatterns("/foo/b*", "/bar/q?x")配合使用。
此時須要對全部請求進行屢次正則匹配,性能損耗根據正則複雜度不一樣,影響均比較大。
5.4.2 Restlight中的攔截器設計
攔截器設計的根本目的是讓用戶可以爲所欲爲的攔截目標controller。
RouteInterceptor
只綁定到固定的Controller/Route的攔截器。這種攔截器容許用戶在應用初始化階段自行決定攔截哪些controller,運行時階段不進行任何匹配的操做,直接綁定到這個controller上。
同時直接將controller元數據信息做爲參數,用戶無需侷限於url路徑匹配,用戶能夠根據註解,HttpMethod,Uri,方法簽名等等各類信息進行匹配。
*在Restlight中一個Controller接口被抽象爲一個Route。
eg. 實現一個攔截器, 攔截全部GET請求(僅包含GET)
      
      
      
       
       
                
       
       
@Beanpublic RouteInterceptor interceptor() { return new RouteInterceptor() {
@Override public CompletableFuture<Boolean> preHandle0(AsyncRequest request, AsyncResponse response, Object handler) { // biz logic return CompletableFuture.completedFuture(null); }
@Override public boolean match(DeployContext<? extends RestlightOptions> ctx, Route route) { HttpMethod[] method = route.mapping().method(); return method.length == 1 && method[0] == HttpMethod.GET; } };}

MappingInterceptor
綁定到全部Controller/Route, 並匹配請求的攔截器。
用戶能夠根據請求任意的匹配,不用侷限於Uri,性能也更高。
eg.實現一個攔截器, 攔截全部Header中包含X-Foo請求頭的請求。
      
      
      
       
       
                
       
       
@Beanpublic MappingInterceptor interceptor() { return new MappingInterceptor() {
@Override public CompletableFuture<Boolean> preHandle0(AsyncRequest request, AsyncResponse response, Object handler) { // biz logic return CompletableFuture.completedFuture(null); } @Override public boolean test(AsyncRequest request) { return request.containsHeader("X-Foo"); } };}

5.5 正則相交性優化
上面的攔截器設計是從設計階段解決正則表達式的性能問題,可是若是用戶就是但願相似Spring MVC攔截器同樣的使用方式呢。
所以咱們須要直面攔截器Uri匹配的性能問題。
5.5.1 HandlerInterceptor
兼容Spring MVC使用方式的攔截器
  • includes(): 指定攔截器做用範圍的Path, 默認做用於全部請求。

  • excludes(): 指定攔截器排除的Path(優先級高於includes)默認爲空。

eg.實現一個攔截器, 攔截除/foo/bar之外全部/foo/開頭的請求。
      
      
      
       
       
                
       
       
@Beanpublic HandlerInterceptor interceptor() { return new HandlerInterceptor() {
@Override public CompletableFuture<Boolean> preHandle0(AsyncRequest request, AsyncResponse response, Object handler) { // biz logic return CompletableFuture.completedFuture(null); }
@Override public String[] includes() { return new String[] {"/foo/**"}; }
@Override public String[] excludes() { return new String[] {"/foo/bar"}; } };}
這種攔截器從功能上與Spring MVC其實沒有太大的區別,都是經過uri匹配。
5.5.2 正則相交性判斷
試想一下,如今寫了一個uri爲/foo/bar的controller
  • includes("/foo/**")

對於這個controller來講,其實這個攔截器100%會匹配到這個攔截器,由於/foo/**這個正則是包含了/foo/bar的
一樣
  • includes("/foo/b?r")

  • includes("/foo/b*")

  • includes("/f?o/b*r")

這一系列匹配規則都是必定會匹配上的
反之
  • excludes("/foo/**")

則必定不會匹配上。
5.5.3 優化邏輯
  • 攔截器的includes()和excludes()規則必定會匹配到controller時,則在初始化階段便直接和controller綁定,運行時不進行任何匹配操做。

  • 攔截器的includes()和excludes()規則必定不會匹配到controller時,則在初始化階段便直接忽略,運行時不進行任何匹配操做。

  • 攔截器的includes()和excludes()可能會匹配到controller時,運行時進行匹配

咱們在程序啓動階段去判斷攔截器規則與Controller之間的相交性,將匹配的邏輯放到了啓動階段一次性完成,大大提高了每次請求的性能。
*實際上當可能會匹配到controller時Restlight還會進一步進行優化,這裏篇幅有限就不過多贅述。
5.6 Restful設計再也不擔憂性能
先前提到在Spring MVC中使用相似/zoos/{id} 形式的Restful風格設計會由於正則帶來性能損耗,這個問題在Restlight中將不復存在。(參考PR:https://github.com/esastack/esa-restlight/issues/13)
6. Restlight as FaaS Runtime
6.1 Faas
FaaS(Functions as a Service), 這是如今雲原生的熱點詞彙,屬於Serverless範疇。
而Serverless講究
  • 按使用量付費

  • 按需獲取

  • 快速彈性伸縮

  • 事件驅動

  • 狀態非本地持久化

  • 資源維護託管

其中對於FaaS場景來講 快速彈性伸縮即是一個棘手的問題。
其中最突出的問題即是 冷啓動問題,Pod縮容到0以後,新的請求進來時須要儘快的去調度一個新的Pod提供服務。
這個問題在Knative中尤其突出,因爲採用KPA進行擴縮容的調度,冷啓動時間較長,暫不討論。
Fission
FaaS場景對冷啓動時間很是敏感,Fission則採用熱Pod池技術來解決冷啓動的問題。
經過預先啓動一組熱Pod池,提早將鏡像,JVM,Web容器等用戶邏輯如下的資源預先啓動,擴容時熱加載Function代碼並提供服務的方式,將冷啓動時間縮短到100ms之內(Knative可能須要10s甚至30s的時間)。
只是以Fission爲例,Fission方案還不算成熟,落地須要進行深度的修改和加強。
6.2 框架面臨的挑戰
在FaaS中最多見的一個場景即是HttpTrigger, 即用戶編寫一個Http接口(或者說Controller),而後將此段代碼依託於某個Web容器中運行。
有了熱Pod池技術以後,冷啓動時間更多則是在特化的過程(加載Function代碼,在已經運行着的Pod中暴露Http服務)。
6.2.1 冷啓動
  • 啓動速度自己足夠的快

  • 應用體積足夠小(節省鏡像拉取的時間)

  • 資源佔用少(更少的CPU,內存佔用)


6.2.2 標準
用戶編寫Function時無需關注也不該該去關注實際FaaS底層的Http服務是使用的Spring MVC仍是Restlight或是其餘的組件,所以不該該要求用戶用Spring MVC的方式去編寫Http接口, 這時便須要定義一套標準,屏蔽下層基礎設置細節,讓用戶在沒有任何其餘依賴的狀況下進行Function編寫。
JAX-RS即是比較好的選擇(固然也不是惟一的選擇)。
6.2.3 監控指標
FaaS要求快速擴縮容,判斷服務是否須要擴縮容的依據最直接的就是Metrics, 所以須要框架內部暴露更加明確的指標,讓FaaS進行快速的擴縮容響應。好比:線程池使用狀況,排隊,線程池拒絕等各種指標。
6.3 Restlight
很明顯Spring MVC沒法知足這個場景,由於它是面向長時間運行的服務而設計, 同時依賴Spring Boot等衆多組件,體積大,啓動速度一樣沒法知足冷啓動的要求。
Restlight則可以很是好的契合FaaS的場景。
  • 啓動快

  • 小體積:不依賴任何三方依賴

  • 豐富的指標:IO線程,Biz線程池指標

  • 無環境依賴:純原生Java即可啓動

  • 支持JAX-RS

  • 高性能:單Pod能夠承載更多的併發請求,節省成本

如今在我司內部已經使用Restlight做爲FaaS Java Runtime底座構建FaaS能力。
7. Restlight將來規劃
7.1 JAX-RS完整支持
現階段Restlight只是對JAX-RS註解進行了支持,後續將會對整個JAX-RS規範進行支持。
這是頗有意義的,JAX-RS是專門爲Rest服務設計的標準,這與一開始Restlight的出發點是一致的。
同時就在去年JAX-RS已經發布了JAX-RS 3.0, 而如今行業內部還鮮有框架對其進行了支持。
7.2 FaaS Runtime深刻支持
做爲FaaS Runtimme底座,Restlight須要更多更底層的能力。
Function目前是獨佔Pod模式,對於低頻訪問的function,保留Pod實例浪費,縮減到0又會頻繁冷啓動。目前只有儘量縮小Pod的規格,調大Pod的空閒時間。
理想狀態下,咱們但願Pod同時能支持多個Function的運行,這樣能節約更多的成本。可是這對Function隔離要求更高。
所以Restlight未來會支持
  • 動態Route:運行時動態修改Web容器中的Route,知足運行時特化需求。

  • 協程支持:以更加輕量的方式運行Function,減小資源間的爭搶。

  • Route隔離: 知足不一樣Function之間的隔離要求,避免一個Function影響其餘Function。

  • 資源計費:不一樣Function分別使用了多少資源。

  • 更加精細化的Metrics:更精確,及時的指標,知足快速擴縮容需求。

7.3 Native Image支持
雲原生一樣對傳統微服務也提出了更多要求,要求服務也須要體積小,啓動快。
所以Restlight一樣會考慮支持Native Image,直接編譯爲二進制文件,從而提高啓動速度,減小資源佔用。
*實測Graal VM後效果不是那麼理想,且使用上不太友好。
8. 結語
Restlight專一於雲原生Rest服務開發。
對雲原生方向堅決不移,
對性能有着極致的追求,
對代碼有潔癖,
它仍是一個年輕的項目,歡迎各路技術愛好者們加入,一同探討學習與進步。

做者簡介

Norman  OPPO高級後端工程師

專一雲原生微服務領域,雲原生框架,ServiceMesh,Serverless等技術。

推薦閱讀
| 如何識別並解決複雜的dcache問題
| 統一預估引擎的設計與實現
| 10分鐘掌握Java性能分析訣竅

本文分享自微信公衆號 - OPPO互聯網技術(OPPO_tech)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索