雲原生Web服務框架ESA Restlight

OPPO網際網路技術發表於2021-07-06

圖片1.png

ESA Stack(Elastic Service Architecture) 是OPPO雲端計算中心孵化的技術品牌,致力於微服務相關技術棧,幫助使用者快速構建高效能,高可用的雲原生微服務。產品包含高效能Web服務框架、RPC框架、服務治理框架、註冊中心、配置中心、呼叫鏈追蹤系統,Service Mesh、Serverless等各類產品及研究方向。

當前部分產品已經對外開源

開源主站:www.esastack.io/

Github: github.com/esastack

Restlight專案地址:github.com/esastack/es…

Restlight文件地址:www.esastack.io/esa-restlig…

歡迎各路技術愛好者們加入,一同探討學習與進步。

本文將不可避免的多次提到Spring MVC,並沒有要與其競爭的意思,Restlight是一個獨立Web框架,有著自己的堅持。

Java業內傳統Web服務框架現狀

Spring MVC

說到Web服務框架,在Java領域Spring MVC可謂是無人不知,無人不曉。在Tomcat(也可能是Jetty,Undertow等別的實現)基礎之上實現請求的路由匹配,過濾器,攔截器,序列化,反序列化,引數繫結,返回值解析等能力。由於其豐富的功能,以及與當今f使用者量巨大的Spring容器及Spring Boot的深度結合,讓Spring MVC幾乎是很多公司Web服務框架的不二選擇。

本文中的Spring MVC泛指Tomcat + Spring MVC的廣義Web服務框架

Resteasy

Resteasy也是Java體系中相對比較成熟的Rest框架,JBoss的一個開源專案,它完整的實現了JAX-RS標準,幫助使用者快速構建Rest服務,同時還提供一個Resteasy JAX-RS客戶端框架 ,方便使用者進行Rest服務呼叫。Resteasy在許多三方框架中整合使用的場景較多,如Dubbo,SOFA RPC等知名框架中均有使用。

Spring MVC就是萬能的麼?

某種意義上來說,還真是萬能的。Spring MVC幾乎具備了傳統的一個Web服務應有的絕大多數能力,不管是做一個簡單的Rest服務,還是All In One的控制檯服務,還是在Spring Cloud中的RPC服務,都可以使用Spring MVC。

可是隨著微服務技術的演進和變遷,特別是當今雲原生微服務理念的盛行,這個全能選手似乎也出現了一些水土不服。

效能

功能與效能的折中

Spring MVC設計更多是面向功能的設計,通過檢視Spring的原始碼可以看到各種高水平的設計模式及介面設計,這讓Spring MVC成為了一個“全能型選手”。但是複雜的設計和功能也是有代價的, 那便是在效能這個點上的折中, 有時候為了功能或者設計不得不放棄一些效能。

Tomcat執行緒模型

Spring MVC使用單個Worker執行緒池處理請求

圖片2.png 我們可以使用server.tomcat.threads.max進行執行緒池大小配置(預設最大為200)。

執行緒模型中的Worker執行緒負責從socket讀取請求資料,並解析為HttpServletRequest,隨後路由到servlet(即經典的DispatcherServlet),最後路由到Controller進行業務呼叫。

IO讀寫與業務操作無法隔離

  • 當業務操作為耗時操作時,將會佔用Worker執行緒資源從而影響到其他的請求的處理,也會影響到IO資料讀寫的效率

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

執行緒模型沒有好壞之分,只有適合與不適合

Restful效能損耗

Restful風格的介面設計是廣大開發者比較推崇的介面設計,通常介面路徑可能會長這樣

  • /zoos/{id}

  • /zoos/{id}/animals

但是這樣的介面在Spring MVC中的處理方式會帶來效能上的損耗,因為其中{id}部分是基於正規表示式來實現的。

攔截器

使用攔截器時可以通過下面的方式去設定匹配邏輯

  • InterceptorRegistration#addPathPatterns("/foo/**", "/fo?/b*r/")
  • InterceptorRegistration#excludePathPatterns("/bar/**", "/foo/bar")

同樣的,這個功能也會為每次的請求都帶來大量的正規表示式匹配的效能消耗

這裡只列出了一些場景,實際上整個Spring MVC的實現程式碼中還有很多從效能角度來看還有待提升的地方(當然這只是從效能角度...)

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就是這麼給我們的。

體積過大

繼上面的功能過剩的問題,同樣也會引發程式碼以及依賴體積過大的問題。這在傳統微服務場景或許並不是多大的問題,但是當我們將其打成映象,則會導致映象體機較大。同樣在FaaS場景這個問題將會被放大,直接影響函式的冷啟動。

後續將會討論FaaS相關的問題

缺乏標準

這裡的標準指的是Rest標準。實際上在Java已經有了一個通用的標準,即JAX-RS(Java API for RESTful Web Services),JAX-RS一開始就是面向Rest服務所設計的,其中包含開發Rest服務經常使用的一些註解,以及一整套Rest服務甚至客戶端標準。

註解

JAX-RS中的註解

  • @Path
  • @GET, @POST, @PUT, @DELETE
  • @Produces
  • @Consumes
  • @PathParam
  • @QueryParam
  • @HeaderParam
  • @CookieParam
  • @MatrixParam
  • @FormParam
  • @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的設計。

三方框架親和性

假如現在你要實現一個RPC框架,準備去支援HTTP協議的RPC呼叫,設想著類似Spring Cloud一樣使用者能夠簡單標記一些@RequestMapping註解就能完成RPC呼叫,因此現在你需要一個僅包含Spring MVC註解的依賴,然後去實現對應的邏輯。可是遺憾的是,Spring MVC的註解是直接耦合到spring-web依賴中的,如果要依賴,就會將spring-core, spring-beans等依賴一併引入,因此業內的RPC框架的HTTP支援幾乎都是選擇的JAX-RS(比如SOFA RPC,Dubbo等)。

不夠輕量

不得不承認Spring的程式碼都很有設計感,在介面設計上非常的優雅。

但是Spring MVC這樣一個Web服務框架卻是一個整體,直接的依附在了Spring這個容器中(或許是戰略上的原因?)。因此所有相關能力都需要引入Spring容器,甚至是Spring Boot。可能有人會說:“這不是很正常的嘛,我們專案都會引入Spring Boot啊”。但是

如果我是一名框架開發者,我想在我的框架中啟動一個Web伺服器去暴露相應的Http介面,但是我的框架十分的簡潔,不想引入任何別的依賴(因為會傳遞給使用者),這個時候便無法使用Spring MVC。

如果我是一名中介軟體開發者,同樣想在我的程式中啟動一個Web伺服器去暴露相應的Metrics介面,但是不想因為這個功能就引入Spring Boot以及其他相關的一大塊東西,這個時候我只能類似原生的嵌入式Tomcat或者Netty自己實現,但是這都有些太複雜了(每次都要自己實現一遍)。

ESA Restlight介紹

基於上述一些問題及痛點,ESA Restlight框架便誕生了。

ESA Restlight是基於Netty實現的一個面向雲原生的高效能,輕量級的Web開發框架。

以下簡稱Restlight

Quick Start

建立Spring Boot專案並引入依賴

<dependency>
    <groupId>io.esastack</groupId>
    <artifactId>restlight-starter</artifactId>
    <version>0.1.1</version>
</dependency>
複製程式碼

編寫Controller

@RestController
@SpringBootApplication
public 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幾乎沒有什麼區別。用法非常的簡單

效能表現

測試場景

分別使用Restlight以及spring-boot-starter-web(2.3.2.RELEASE) 編寫兩個web服務,實現一個簡單的Echo介面(直接返回請求的body內容),分別在請求body為16B, 128B, 512B, 1KB, 4KB, 10KB場景進行測試

測試工具

  • wrk4.1.0

  • OSCPUMem(G)
    servercentos:6.9-1.2.5(docker)48
    clientcentos:7.6-1.3.0(docker)163

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=.

複製程式碼

引數配置

FrameworkOptions
Restlightrestlight.server.io-threads=8
restlight.server.biz-threads.core=16
restlight.server.biz-threads.max=16
restlight.server.biz-threads.blocking-queue-length=512
Spring Webserver.tomcat.threads.max=32
server.tomcat.accept-count=128

測試結果(RPS)

16B128B512B1KB4KB10KB
Restlight(IO)129457.26125344.89125206.74116963.2485749.4549034.57
Restlight(BIZ)101385.4498786.6297622.3396504.8168235.246460.79
Spring Web35648.2738294.9437940.337497.5832098.6522074.94

可以看到Restlight的效能相較於Spring MVC有2-4倍的提升。

Restlight(IO)以及Restlight(BIZ)為Restlight中特有的執行緒排程能力,使用不同的執行緒模型

功能特性

  • 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測試
  • ...

ESA Restlight架構設計

設計原則

  • 雲原生:快速啟動、省資源、輕量級
  • 高效能:持續不懈追求的目標 & 核心競爭力,基於高效能網路框架Netty實現
  • 高擴充套件性:開放擴充套件點,滿足業務多樣化的需求
  • 低接入成本:相容SpringMVC 和 JAX-RS常用註解,降低使用者使用成本
  • **全鏈路非同步:**基於CompletableFuture提供完善的非同步處理能力
  • **監控與統計:**完善的執行緒池等指標監控和請求鏈路追蹤與統計

分層架構設計

通過分層架構設計讓Restlight具有非常高的擴充套件性,同時針對原生Java, Spring, Spring Boot等場景提供不同實現,適合Spring Boot業務,三方框架,中介軟體,FaaS等多種場景。

Architecther.png

架構圖中ESA HttpServer, Restlight Server, Restlight Core, Restlight for Spring, Restlight Starter幾個模組均可作為一個獨立的模組使用, 滿足不同場景下的需求

ESA HttpServer

基於Netty 實現的一個簡易的HttpServer, 支援Http1.1/Http2以及Https等

該專案已經同步開源到Github:github.com/esastack/es…

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的場景

Restlight Core

Restlight Server之上, 擴充套件支援了Controller方式(在Controller類中通過諸如@RequestMappng等註解的方式構造請求處理邏輯)完成業務邏輯以及諸多常用功能

  • HandlerInterceptor: 攔截器
  • ExceptionHandler: 全域性異常處理器
  • BeanValidation: 引數校驗
  • ArgumentResolver: 引數解析擴充套件
  • ReturnValueResolver: 返回值解析擴充套件
  • RequestSerializer: 請求序列化器(通常負責反序列化Body內容)
  • ResposneSerializer: 響應序列化器(通常負責序列化響應物件到Body)
  • 內建Jackson, Fastjson, Gson, ProtoBuf序列化支援

Restlight for Spring MVC

基於Restlight Core的Spring MVC註解支援

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

@RequestMapping("/hello")
public class HelloController {

    @GetMapping(value = "/restlight")
    public String restlight() {
        return "Hello Restlight!";
    }
}
複製程式碼

使用Restlight啟動Server

Restlight.forServer()
        .daemon(false)
        .deployments()
        .addController(HelloController.class)
        .server()
        .start();
複製程式碼

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();
複製程式碼

Restlight for Spring

在Restlight Core基礎上支援在Spring場景下通過ApplicationContext容器自動配置各種內容(RestlightOptions, 從容器中自動配置Filter, Controller等)

適用於Spring場景

Restlight Starter

在Restlight for Spring基礎上支援在Spring Boot場景的自動配置

適用於Spring Boot場景

Restlight Actuator

在Restlight Starter基礎上支援在Spring Boot Actuator原生各種Endpoints支援以及Restlight獨有的Endpoints。

適用於Spring Boot Actuator場景

執行緒模型

threading_model.png

Restlight由於是使用Netty作為底層HttpServer的實現,因此圖中沿用了部分EventLoop的概念,執行緒模型由了AcceptorIO EventLoopGroup(IO執行緒池)以及Biz ThreadPool(業務執行緒池)組成。

  • Acceptor: 由1個執行緒組成的執行緒池, 負責監聽本地埠並分發IO 事件。
  • IO EventLoopGroup: 由多個執行緒組成,負責讀寫IO資料(對應圖中的read()write())以及HTTP協議的編解碼和分發到業務執行緒池的工作。
  • Biz Scheduler:負責執行真正的業務邏輯(大多為Controller中的業務處理,攔截器等)。
  • Custom Scheduler: 自定義執行緒池

通過第三個執行緒池Biz Scheduler的加入完成IO操作與實際業務操作的非同步(同時可通過Restlight的執行緒排程功能隨意排程)

靈活的執行緒排程 & 介面隔離

執行緒排程允許使用者根據需要隨意制定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";
}

@Bean
public Scheduler scheduler() {
	// 注入自定義執行緒池
	return Schedulers.fromExecutor("foo", Executors.newCachedThreadPool());
}
複製程式碼

通過隨意的執行緒排程,使用者可以平衡執行緒切換及隔離,達到最優的效能或是隔離的效果

ESA Restlight效能優化的億些細節

Restlight始終將效能放在第一位,甚至有時候到了對效能偏執的程度。

Netty

Restlight基於Netty編寫,Netty自帶的一些高效能特性自然是高效能的基石,Netty常見特性均在Restlight有所運用

  • Epoll & NIO
  • ByteBuf
  • PooledByteBufAllocator
  • EventLoopGroup
  • Future & Promise
  • FastThreadLocal
  • InternalThreadLocalMap
  • Recycler
  • ...

除此之外還做了許多其他的工作

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解析出的HttpRequestHttpContentLastHttpContent聚合成一個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();
    複製程式碼

效能表現

測試場景

分別使用ESA HttpServer以及原生Netty(HttpServerCodec, HttpObjectAggregator) 編寫兩個web服務,實現一個簡單的Echo介面(直接返回請求的body內容),分別在請求body為16B, 128B, 512B, 1KB, 4KB, 10KB場景進行測試

測試工具
  • wrk4.1.0

  • OSCPUMem(G)
    servercentos:6.9-1.2.5(docker)48
    clientcentos:7.6-1.3.0(docker)163
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)
16B128B512B1KB4KB10KB
Netty133272.34132818.53132390.78127366.2885408.749798.84
ESA HttpServer142063.99139608.23139646.04140159.592767.5353534.21

使用ESA HttpServer效能甚至比原生Netty效能更高

路由快取

傳統的Spring MVC中, 當我們的@RequestMapping註解中包含了任何的複雜匹配邏輯(這裡的複雜邏輯可以理解為除了一個url對應一個Controller實現,並且url中沒有*, ? . {foo}等模式匹配的內容)時方能在路由階段有相對較好的效果,反之如通常情況下一個請求的到來到路由到對應的Controller實現這個過程將會是在當前應用中的所有Controller中遍歷匹配,值得注意的是通常在微服務提倡RestFul設計的大環境下一個這種遍歷幾乎是無法避免的, 同時由於匹配的條件本身的複雜性(比如說正則本身為人詬病的就是效能),因此伴隨而來的則是SpringMVC的路由的損耗非常的大。

快取設計

  • 二八原則(80%的業務由20%的介面處理)
  • 演算法:類LFU(Least Frequently Used)演算法

我們雖然不能改變路由條件匹配本身的損耗, 但是我們希望能做盡量少的匹配次數來達到優化的效果。因此採用常用的"快取"來作為優化的手段。 當開啟了路由快取後,預設情況下將使用類LFU(Least Frequently Used)演算法的方式快取十分之的Controller,根據二八原則(80%的業務由20%的介面處理),大部分的請求都將在快取中匹配成功並返回(這裡框架預設的快取十分之一,是相對比較保守的設定)

演算法邏輯

當每次請求匹配成功時,會進行命中紀錄的加1操作,並統計命中紀錄最高的20%(可配)的Controller加入快取, 每次請求的到來都將先從快取中查詢匹配的Controller(大部分的請求都將在此階段返回), 失敗則進入正常匹配的邏輯。

什麼時候更新快取? 我們不會在每次請求命中的情況下都去更新快取,因為這涉及到一次排序(或者m次遍歷, m為需要快取的Controller的個數,相當於挑選出命中最高的m個Controller)。 取而代之的是我們會以概率的方式去重新計算並更新快取, 根據2-8原則通常情況下我們當前快取的記憶體就是我們需要的內容, 所以沒必要每次有請求命中都去重新計算並更新快取, 因此我們會在請求命中的一定概率條件下采取做此操作(預設0.1%, 稱之為計算概率), 減小了併發損耗(這段邏輯本身基於CopyOnWrite, 並且為純無鎖併發程式設計,本身效能損耗就很低),同時此概率可配置可以根據具體的應用實際情況調整配置達到最優的效果。

效果

使用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   Units
CachedRouteRegistryBenchmark.matchByCachedRouteRegistry         10  thrpt   10  1353.846 ± 26.633  ops/ms
CachedRouteRegistryBenchmark.matchByCachedRouteRegistry         20  thrpt   10   982.295 ± 26.771  ops/ms
CachedRouteRegistryBenchmark.matchByCachedRouteRegistry         50  thrpt   10   639.418 ± 22.458  ops/ms
CachedRouteRegistryBenchmark.matchByCachedRouteRegistry        100  thrpt   10   411.046 ±  5.647  ops/ms
CachedRouteRegistryBenchmark.matchByDefaultRouteRegistry        10  thrpt   10   941.917 ± 33.079  ops/ms
CachedRouteRegistryBenchmark.matchByDefaultRouteRegistry        20  thrpt   10   524.540 ± 18.628  ops/ms
CachedRouteRegistryBenchmark.matchByDefaultRouteRegistry        50  thrpt   10   224.370 ±  9.683  ops/ms
CachedRouteRegistryBenchmark.matchByDefaultRouteRegistry       100  thrpt   10   113.883 ±  5.847  ops/ms
複製程式碼

可以看出加了快取之後效能提升明顯,同時可以看出隨著Controller個數增多, 沒有快取的場景效能損失非常嚴重。

攔截器設計

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")配合使用

此時需要對所有請求進行多次正則匹配,效能損耗根據正則複雜度不同,影響均比較大

Restlight中的攔截器設計

攔截器設計的根本目的是讓使用者能夠隨心所欲的攔截目標Controller

RouteInterceptor

只繫結到固定Controller/Route的攔截器。這種攔截器允許使用者在應用初始化階段自行決定攔截哪些Controller,執行時階段不進行任何匹配的操作,直接繫結到這個Controller上。

同時直接將Controller後設資料資訊作為引數,使用者無需侷限於url路徑匹配,使用者可以根據註解,HttpMethod,Uri,方法簽名等等各種資訊進行匹配。

在Restlight中一個Controller介面被抽象為一個Route

eg.

實現一個攔截器, 攔截所有GET請求(僅包含GET)

@Bean
public 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請求頭的請求

@Bean
public 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");
        }
    };
}
複製程式碼

正則相交性優化

上面的攔截器設計是從設計階段解決正規表示式的效能問題,但是如果使用者就是希望類似Spring MVC攔截器一樣的使用方式呢。

因此我們需要直面攔截器Uri匹配的效能問題

HandlerInterceptor

相容Spring MVC使用方式的攔截器

  • includes(): 指定攔截器作用範圍的Path, 預設作用於所有請求。
  • excludes(): 指定攔截器排除的Path(優先順序高於includes)預設為空。

eg.

實現一個攔截器, 攔截除/foo/bar意外所有/foo/開頭的請求

@Bean
public 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匹配

正則相交性判斷

試想一下,現在寫了一個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/**")

則一定不會匹配上

優化邏輯

  • 攔截器的includes()excludes()規則一定會匹配到Controller時,則在初始化階段便直接和Controller繫結,執行時不進行任何匹配操作

  • 攔截器的includes()excludes()規則一定不會匹配到Controller時,則在初始化階段便直接忽略,執行時不進行任何匹配操作

  • 攔截器的includes()excludes()可能會匹配到Controller時,執行時進行匹配

我們在程式啟動階段去判斷攔截器規則與Controller之間的相交性,將匹配的邏輯放到了啟動階段一次性完成,大大提升了每次請求的效能。

實際上當可能會匹配到Controller時Restlight還會進一步進行優化,這裡篇幅有限就不過多贅述...

Restful設計不再擔心效能

先前提到在Spring MVC中使用類似/zoos/{id} 形式的Restful風格設計會因為正則帶來效能損耗,這個問題在Restlight中將不復存在。

參考PR

Restlight as FaaS Runtime

Faas

FaaS(Functions as a Service), 這是現在雲原生的熱點詞彙,屬於Serverless範疇。

Serverless的核心

  • 按使用量付費

  • 按需獲取

  • 快速彈性伸縮

  • 事件驅動

  • 狀態非本地持久化

  • 資源維護託管

其中對於FaaS場景來說快速彈性伸縮便是一個棘手的問題。

其中最突出的問題便是冷啟動問題,Pod縮容到0之後,新的請求進來時需要儘快的去排程一個新的Pod提供服務。

這個問題在Knative中尤為突出,由於採用KPA進行擴縮容的排程,冷啟動時間較長,這裡暫不討論。

Fission

Fission是面向FaaS的另一個解決方案。FaaS場景對冷啟動時間非常敏感,Fission則採用熱Pod池技術來解決冷啟動的問題。

通過預先啟動一組熱Pod池,提前將映象,JVM,Web容器等使用者邏輯以下的資源預先啟動,擴容時熱載入Function程式碼並提供服務的方式,將冷啟動時間縮短到100ms以內(Knative可能需要10s甚至時30s的時間)。

fission.png

只是以Fission為例,Fission方案還不算成熟,我們內部對其進行深度的修改和增強

框架面臨的挑戰

在FaaS中最常見的一個場景便是HttpTrigger, 即使用者編寫一個Http介面(或者說Controller),然後將此段程式碼依託於某個Web容器中執行。

有了熱Pod池技術之後,冷啟動時間更多則是在特化的過程(載入Function程式碼,在已經執行著的Pod中暴露Http服務)。

冷啟動

  • 啟動速度本身足夠的快
  • 應用體積足夠小(節省映象拉取的時間)
  • 資源佔用少(更少的CPU,記憶體佔用)

標準

使用者編寫Function時無需關注也不應該去關注實際FaaS底層的Http服務是使用的Spring MVC還是Restlight或是其他的元件,因此不應該要求使用者用Spring MVC的方式去編寫Http介面, 這時便需要定義一套標準,遮蔽下層基礎設定細節,讓使用者在沒有任何其他依賴的情況下進行Function編寫。

JAX-RS便是比較好的選擇(當然也不是唯一的選擇)

監控指標

FaaS要求快速擴縮容,判斷服務是否需要擴縮容的依據最直接的就是Metrics, 因此需要框架內部暴露更加明確的指標,讓FaaS進行快速的擴縮容響應。比如:執行緒池使用情況,排隊,執行緒池拒絕等各類指標。

Restlight

很明顯Spring MVC無法滿足這個場景,因為它是面向長時間執行的服務而設計, 同時依賴Spring Boot等眾多元件,體機大,啟動速度同樣無法滿足冷啟動的要求。

Restlight則能夠非常好的契合FaaS的場景。

  • 啟動快
  • 小體積:不依賴任何三方依賴
  • 豐富的指標:IO執行緒,Biz執行緒池指標
  • 無環境依賴:純原生Java便可啟動
  • 支援JAX-RS
  • 高效能:單Pod可以承載更多的併發請求,節省成本

現在在我司內部已經使用Restlight作為FaaS Java Runtime底座構建FaaS能力。

Restlight未來規劃

JAX-RS完整支援

現階段Restlight只是對JAX-RS註解進行了支援,後續將會對整個JAX-RS規範進行支援。

這是很有意義的,JAX-RS是專門為Rest服務設計的標準,這與一開始Restlight的出發點是一致的。

同時就在去年JAX-RS已經發布了JAX-RS 3.0, 而現在行業內部還鮮有框架對其進行了支援,Restlight將會率先去對其進行支援。

FaaS Runtime深入支援

作為FaaS Runtimme底座,Restlight需要更多更底層的能力。

Function目前是獨佔Pod模式,對於低頻訪問的function,保留Pod例項浪費,縮減到0又會頻繁冷啟動。目前只有儘可能縮小Pod的規格,調大Pod的空閒時間。

理想狀態下,我們希望Pod同時能支援多個Function的執行,這樣能節約更多的成本。但是這對Function隔離要求更高

faas_multi_pod.png

因此Restlight將來會支援

  • 動態Route:執行時動態修改Web容器中的Route,滿足執行時特化需求
  • 協程支援:以更加輕量的方式執行Function,減少資源間的爭搶
  • Route隔離: 滿足不同Function之間的隔離要求,避免一個Function影響其他Function
  • 資源計費:不同Function分別使用了多少資源
  • 更加精細化的Metrics:更精確,及時的指標,滿足快速擴縮容需求。

Native Image支援

雲原生同樣對傳統微服務也提出了更多要求,要求服務也需要體積小,啟動快。

因此Restlight同樣會考慮支援Native Image,直接編譯為二進位制檔案,從而提升啟動速度,減少資源佔用。

實測Graal VM後效果不是那麼理想,且使用上不太友好。

結語

Restlight專注於雲原生Rest服務開發。

對雲原生方向堅定不移

對效能有著極致的追求

對程式碼有潔癖

它還是一個年輕的專案,歡迎各路技術愛好者們加入,一同探討學習與進步。

作者簡介

Norman OPPO高階後端工程師

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

獲取更多精彩內容,歡迎關注[OPPO網際網路技術]公眾號

qrcode_for_gh_7bc48466f080_258.jpg

相關文章