写点什么

云原生 Web 服务框架 ESA Restlight

发布于: 2021 年 07 月 20 日


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 框架,有着自己的坚持。

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 线程池处理请求



我们可以使用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@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 几乎没有什么区别。用法非常的简单

性能表现

测试场景

分别使用 Restlight 以及spring-boot-starter-web(2.3.2.RELEASE) 编写两个 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=.
复制代码

参数配置

测试结果(RPS)


可以看到 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 等多种场景。



架构图中ESA HttpServer, Restlight Server, Restlight Core, Restlight for Spring, Restlight Starter几个模块均可作为一个独立的模块使用, 满足不同场景下的需求

ESA HttpServer

基于 Netty 实现的一个简易的 HttpServer, 支持 Http1.1/Http2 以及 Https 等


该项目已经同步开源到 Github:https://github.com/esastack/esa-httpserver

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 场景

线程模型


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


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 性能更高

路由缓存

传统的 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   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 个数增多, 没有缓存的场景性能损失非常严重。

拦截器设计

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)


@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"); } };}
复制代码

正则相交性优化

上面的拦截器设计是从设计阶段解决正则表达式的性能问题,但是如果用户就是希望类似 Spring MVC 拦截器一样的使用方式呢。


因此我们需要直面拦截器 Uri 匹配的性能问题

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 匹配

正则相交性判断

试想一下,现在写了一个 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 为例,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 隔离要求更高



因此 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 互联网技术]公众号



发布于: 2021 年 07 月 20 日阅读数: 8
用户头像

还未添加个人签名 2019.12.23 加入

OPPO数智技术干货及技术活动分享平台

评论

发布
暂无评论
云原生Web服务框架ESA Restlight