头图

云原生Web服务框架ESA Restlight

OPPO数智技术
English

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/e...

Restlight文档地址:https://www.esastack.io/esa-r...

欢迎各路技术爱好者们加入,一同探讨学习与进步。

本文将不可避免的多次提到Spring MVC,并没有要与其竞争的意思,Restlight是一个独立Web框架,有着自己的坚持。

Java业内传统Web服务框架现状

Spring MVC

说到Web服务框架,在Java领域Spring MVC可谓是无人不知,无人不晓。在Tomcat(也可能是Jetty,Undertow等别的实现)基础之上实现请求的路由匹配,过滤器,拦截器,序列化,反序列化,参数绑定,返回值解析等能力。由于其丰富的功能,以及与当今用户量巨大的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
@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<br/>restlight.server.biz-threads.core=16<br/>restlight.server.biz-threads.max=16<br/>restlight.server.biz-threads.blocking-queue-length=512
Spring Webserver.tomcat.threads.max=32<br/>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等多种场景。

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

ESA HttpServer

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

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

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

@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为例,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互联网技术]公众号

阅读 930

OPPO数智技术
OPPO前沿互联网技术及活动分享,公众号:OPPO_tech
536 声望
909 粉丝
0 条评论
536 声望
909 粉丝
文章目录
宣传栏