40

API 网关异步化改造技术选型

 4 years ago
source link: https://github.com/aCoder2013/blog/issues/34?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

背景

目前的网关是基于 Spring Boot 1.5.xTomcat 8.5.x 构建,采用多线程阻塞模型,也就是说每个请求都会占用一个独立的线程资源,而线程在JVM中是一个相对比较重的资源。当应用是CPU密集型的或者说依赖的远程服务都正常工作时,这种模型能够很好的满足需求,但一旦后端服务出现了延迟,比如慢查询、FullGC、依赖的第三方接口出问题等情况,线程池很容易被打满,使得整个集群服务出现问题。典型的IO密集型的应用也会有类似的问题,比如网关有很多HTTP请求、RPC远程调用等,当并发量比较大的时候,线程都阻塞在IO等待上,造成线程资源的浪费。

这种模型的优势比较明显:

  • 编程模型简单
  • 易于开发、调试、运维等。本地调试问题支持直接打断点、通过ThreadLocal变量实现监控、通过thread dump即可获取当前请求的处理流程等

但劣势也很明显:

  • 连接数限制。容器的最大线程数一般是固定的,tomcat默认是200,因此当发生网络延迟、FullGC、第三方服务慢等情况造成上游服务延迟时,线程池很容易会被打满,造成新的请求被拒绝,但这个时候其实线程都阻塞在IO上,系统的资源被没有得到充分的利用。

tomcat默认可以接收10000个连接,worker线程默认为200,当线程池被打满后,poller线程会继续接收新的连接请求,并放到epoll队列中,当超过最大连接数后,则会拒绝响应,虽然Tomcat采用了NIO模型,但由于业务线程是同步处理的的,因此当并发比较高时,很容易造成线程池被打满。

f2mYN3Y.png!web
  • 容易受网络、磁盘IO等延迟影响。需要谨慎设置超时时间,如果设置不当,且接口之前的隔离做的不是很完善,则服务很容易被一个延迟的接口拖垮。

而异步化的方式则完全不同,通常情况下一个CPU核启动一个线程即可处理所有的请求、响应。一个请求的生命周期不再固定于一个线程,而是会分成不同的阶段交由不同的线程池处理,系统的资源能够得到更充分的利用。而且因为线程不再被某一个连接独占,一个连接所占用的系统资源也会低得多,只是一个文件描述符加上几个监听器,而在阻塞模型中,每条连接都会独占一个线程,是一个非常重的资源。对于上游服务的延迟情况,能够得到很大的缓解,因为在阻塞模型中,慢请求会独占一个线程资源,而异步化之后,因为单条连接诶所占用的资源变的非常低,因此系统可以同时处理大量的请求。

因此考虑对网关进行异步化改造,解决当前遇到的超时、延迟等问题。

技术选型

Zuul 2

Zuul 2基于Netty和RxJava实现,采用了异步非阻塞模型,本质上其实就是队列+事件驱动。在zuul 1中一个请求的完整生命周期都是在一个线程中完成的,但在zuul 2中,请求首先会经过netty server,接着会运行前置拦截器,然后通过netty客户端将请求转发给后端的服务,最后运行后置拦截器并返回响应。但是和zuul 1不同,这里的拦截器同时支持异步和同步两种模式,对于一些比较快的操作,可以直接使用同步拦截器。

uqeAbaU.png!web

异步拦截器示例:

class SampleServiceFilter extends HttpInboundFilter {
    private static final Logger log = LoggerFactory.getLogger(SampleServiceFilter.class)

    private final SampleService sampleService

    @Inject
    SampleServiceFilter(SampleService sampleService) {
        this.sampleService = sampleService
    }

    @Override
    int filterOrder() {
        return 500
    }


    @Override
    boolean shouldFilter(HttpRequestMessage msg) {
        return sampleService.isHealthy()
    }

    @Override
    Observable<HttpRequestMessage> applyAsync(HttpRequestMessage request) {
        //模拟慢请求
        return sampleService.makeSlowRequest().map({ response ->
            log.info("Fetched sample service result: {}", response)

            return request
        })
    }
}

这里返回的是一个 Observable ,这是 RxJava 中的概念,和Java8的 CompletableFuture 有点像,对于方法调用者来说拿到的都是一个 Observable ,而内部的实现方式可以是同步,也可以是异步,但是调用者不用关心这个东西,无论实现怎么改,方法的签名是不用变的,始终返回的都是一个 Observable

关于响应式的概念这里就不多做介绍了,我觉得上手还是有点难度,个人更倾向于coroutine的方案。

String[] names = ...;
Observable.from(names)
    .subscribe(new Action1<String>() {
        @Override
        public void call(String name) {
            Log.d(tag, name);
        }
    });

Zuul 2是一个不错的选择,但是spring官方已经不打算集成zuul 2了,加上Netflix也打算把技术栈尽可能的迁移到Spring,hystrix和Eureka也都进入维护状态,不再开发新特性,zuul未来也有可能是同样的命运。

Moving forward, we plan to leverage the strong abstractions within Spring to further modularize and evolve the Netflix infrastructure. Where there is existing strong community direction — such as the upcoming Spring Cloud Load Balancer — we intend to leverage these to replace aging Netflix software. Where there is new innovation to bring — such as the new Netflix Adaptive Concurrency Limiters — we want to help contribute these back to the community.

基于Servlet3.1的异步

JNzeeaI.png!web

Servlet3.1引入了非阻塞式编程模型,支持请求的异步处理。

public void doGet(request, response) {
        ServletOutputStream out = response.getOutputStream();
        AsyncContext ctx = request.startAsync();
        //异步写入
        out.setWriteListener(new WriteListener() {
            void onWritePossible() {
                while (out.isReady()) {
                    byte[] buffer = readFromSomeSource();
                    if (buffer != null)
                        out.write(buffer); ---> Async Write!
                    else{
                        ctx.complete(); break;
                    }
                  }
                }
            });
        }

Spring 4.x+ 也增加了对非阻塞式IO的支持,例如下面的代码示例(SpringMVC5 + Tomcat 8.5+):

@GetMapping(value = "/asyncNonBlockingRequestProcessing")
    public CompletableFuture<String> asyncNonBlockingRequestProcessing(){
            ListenableFuture<String> listenableFuture = getRequest.execute(new AsyncCompletionHandler<String>() {
                @Override
                public String onCompleted(Response response) throws Exception {
                    logger.debug("Async Non Blocking Request processing completed");
                    return "Async Non blocking...";
                 }
            });
            return listenableFuture.toCompletableFuture();
    }
    
@PostMapping
public Callable<String> processUpload(final MultipartFile file) {

    return new Callable<String>() {
        public String call() throws Exception {
            // ...
            return "someView";
        }
    };

}

虽然说Servlet3.1提供了对异步的支持,但是其编程模型本质上还是同步的: Filter , Servlet , 或者有一些方法仍然是阻塞的,比如 getParameter , getPart 等,解析请求体、写会响应本质上还是同步的,但一般来说性能损耗也不算大,网关的耗时基本上都在业务方的IO调用上。

Spring 5 Reactive

对于异步编程模型的选择,Spring5中引入了两种方式,一种是构建于Servlet 3.1之上的 SpringMVC ,另一种是构建于Netty之上的 Spring WebFluxSpring WebFlux 不同于 Spring MVC ,是一个专门为异步设计的响应式框架,完全非阻塞,支持响应式编程模型,可以运行在 Netty, Undertow, 和 Servlet 3.1+容器中。

Q36buyy.png!web

不同于SpringMVC,WebFlux的请求体、响应都支持响应式类型,可以异步的接受、写入响应,是一个完全异步化的框架。

@PostMapping("/accounts")
public void handle(@RequestBody Mono<Account> account) {
    // ...
}

@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
public Mono<String> processSubmit(@Valid @ModelAttribute("pet") Mono<Pet> petMono) {
    return petMono
        .flatMap(pet -> {
            // ...
        })
        .onErrorResume(ex -> {
            // ...
        });
}

另外 Spring WebFlux 也提供了一个响应式、非阻塞的HTTP客户端: WebClient . 其内部支持多种实现,默认是 Reactor Netty ,也支持Jetty reactive HttpClient,当然也可以自己通过 ClientHttpConnector 扩展。

Mono<Void> result = client.post()
            .uri("/persons/{id}", id)
            .contentType(MediaType.APPLICATION_JSON)
            .body(personMono, Person.class)
            .retrieve()
            .bodyToMono(Void.class);

Spring Cloud Gateway

Spring Cloud Gateway是由spring官方基于Spring5.0、Spring Boot2.0、Project Reactor等技术开发的网关,目的是代替原先版本中的Spring Cloud Netfilx Zuul,目前Netfilx已经开源了Zuul2.0,但Spring没有考虑集成,而是推出了自己开发的Spring Cloud GateWay。该项目提供了一个构建在Spring生态系统之上的API网关。

特性:

Spring Cloud DiscoveryClient
@SpringBootApplication
public class DemogatewayApplication {
	@Bean
	public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
		return builder.routes()
			.route("path_route", r -> r.path("/get")
				.uri("http://httpbin.org"))
			.route("host_route", r -> r.host("*.myhost.org")
				.uri("http://httpbin.org"))
			.route("rewrite_route", r -> r.host("*.rewrite.org")
				.filters(f -> f.rewritePath("/foo/(?<segment>.*)", "/${segment}"))
				.uri("http://httpbin.org"))
			.route("hystrix_route", r -> r.host("*.hystrix.org")
				.filters(f -> f.hystrix(c -> c.setName("slowcmd")))
				.uri("http://httpbin.org"))
			.route("hystrix_fallback_route", r -> r.host("*.hystrixfallback.org")
				.filters(f -> f.hystrix(c -> c.setName("slowcmd").setFallbackUri("forward:/hystrixfallback")))
				.uri("http://httpbin.org"))
			.route("limit_route", r -> r
				.host("*.limited.org").and().path("/anything/**")
				.filters(f -> f.requestRateLimiter(c -> c.setRateLimiter(redisRateLimiter())))
				.uri("http://httpbin.org"))
			.build();
	}
}

自研

另外也可以参考Zuul2、Spring Cloud Gateway等,基于Netty、Vertx或者spring4.x提供的基于Servlet 3.1的异步机制自研,但自研成本会很高,需要从零开始开发。

对比

选型 优势 劣势 Zuul 2 特性完善。重试、并发保护等 Spring官方不打算集成,需要自己搞。后期项目的活跃度,Netflix开源的eureka、hystrix都进入了维护模式 Spring Boot 1.x + Spring 4.x Servlet 3.1 部分支持异步 如果目前是基于传统spring mvc的方式,相对改造成本比较小 Spring Boot 2 + Spring MVC 部分支持异步 需要升级Spring Boot 2 Spring Boot 2 + Spring Web Flux 完全异步化、异步Http客户端的WebClient 需要升级Spring Boot 2 自研 能够更好的和业务结合 成本太高

问题

需要特别注意的一些问题:

  • 异步化之后,整个流程都是基于事件驱动,请求处理的流程随时可能被切换断开,需要通过trace_id等机制才能把整个执行流再串联起来,给开发、调试、运维等引入了很多复杂性,比如想在IDE里面通过打断点排查问题就不是很方便了。
  • 整个流程都是基于事件驱动,代码相对而言会变得更复杂,想梳理清楚整个工作流程会更麻烦,同步的方式只要跟着IDE一步一步点进去就可以。
  • ThreadLocal机制在异步化之后就不能很好的工作了。Netflix也遇到了很多ThreadLocal的问题,比如监控、traceId的传递、业务参数的传递等,这个需要特别注意。
  • 异步的编程模式,采用回调、future还是响应式? 更激进一点可以考虑下kotlin的coroutine

总结

网关的异步化改造相对还是比较必要的,作为所有流量的入口,性能、稳定性是非常重要的一环,另外由于网关接入了内部所有的API,因此在大促时需要进行比较完善的压测,评估网关的容量,并进行扩容,但如果内部的业务比较复杂,网关接入了非常多的API,这种中心化的方案就会导致很难对网关进行比较准确的容量评估,后面可以考虑基于Service Mesh的思想,对网关进行去中心化改造,将网关的核心逻辑,比如鉴权、限流、协议转换、计费、监控、告警等都抽到sidecar中。

参考链接


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK