Java EE的断路器API设计

18-12-12 banq
         

如何使用Java EE API,MicroProfile或某些Java EE扩展实现不同的弹性方法,例如断路器,隔板或背压?此外,企业Java弹性方法如何与Kubernetes和Istio等新的云原生技术一起发挥作用?

定义弹性

首先,我们需要弄清楚应用程序弹性的含义。

应用程序应保证以稳定,负责任的方式执行功能,而不会导致应用程序无法恢复崩溃。因此,应用程序应该能够容忍并从执行期间的小错误中恢复,尤其是在不影响其他无关功能的情况下。它还意味着应该考虑系统的整体运行状况,这要求所有应用程序不要盲目地使当前可能负载的应用程序过载。

此外,有一种说法是你所做的是保守而你所接受的是自由。同样,企业应用程序在拒绝技术上易于理解但不完全遵循规范的消息时不应过于严格。

超时

为了避免死锁情况,建立同步通信的超时至关重要。超时是活动之间的关系,当应用程序继续能够处理传入请求时,以及当我们拒绝处理可能很快成功完成的请求时,进行权衡。

虽然超时对于各种同步通信至关重要,但我们将专注于HTTP调用。

@ApplicationScoped
public class MakerBot {

    private Client client;
    private WebTarget target;

    @PostConstruct
    private void initClient() {
        client = ClientBuilder.newBuilder()
                .connectTimeout(2, TimeUnit.SECONDS)
                .readTimeout(4, TimeUnit.SECONDS)
                .build();
        target = client
                .target("http://maker-bot:9080/maker-bot/resources/jobs");
    }

    public void printInstrument(InstrumentType type) {
        JsonObject requestBody = createRequestBody(type);
        Response response = sendRequest(requestBody);
        validateResponse(response);
    }

    // ...
}

从JAX-RS 2.1版开始,ClientBuilder通过connectTimeout()和readTimeout()方法支持标准化的超时配置。根据所使用的HTTP实现,不指定超时值可能最终会无限制地阻塞调用。

当然,实际的超时值取决于实际的应用程序和环境设置。

断路器

与电气工程中的断路器类似,软件中的断路器检测故障或响应缓慢,并通过抑制注定要失败的动作来防止进一步损坏。我们可以指定断路器应该根据先前的执行中断某些功能的执行情况。

有多个第三方库可用于实现断路器,包括MicroProfile Fault Tolerance项目,该项目与Java EE非常好地集成,并得到少数应用程序容器供应商的支持。以下声明该类的printInstrument方法MakerBot由具有默认行为的MicroProfile断路器保护:

@CircuitBreaker
public void printInstrument(InstrumentType type) {
    JsonObject requestBody = createRequestBody(type);
    Response response = sendRequest(requestBody);
    validateResponse(response);
}

如果@CircuitBreaker方法执行失败,那么注释将导致方法执行被中断 - 也就是说,如果它在20次调用中超过50%的时间抛出异常 - 默认情况下。电路打开后,执行将默认中断至少5秒。可以使用注释覆盖这些默认值。

可以使用@Fallback注释定义回退行为,注释分别引用回退处理程序类或方法。

重试

重试背后的动机是通过立即重试失败的操作来消除暂时的失败。此重试对调用功能透明地发生。

使用MicroProfile Fault Tolerance实现技术动机重试很简单。@Retry如果发生异常,注释将导致方法调用最多重新执行三次。我们可以使用注释值进一步配置行为,例如延迟时间或异常类型。

与断路器类似,@Fallback如果在最大重试次数后调用仍然失败,也可以定义行为。

隔板

与船上的隔间类似,隔板旨在将软件功能划分为可单独失效的部分,而不会导致整个应用程序无响应。它们可以防止错误进一步级联,同时应用程序的其余部分保持正常运行。

在企业Java中,通过定义多个池(例如数据库连接池或线程池)来应用Bulkhead模式。对于多个线程池,如果另一个线程池当前用尽,我们可以确保应用程序的特定部分不受影响。

但是,企业Java应用程序不应该启动或管理自己的线程; 相反,他们必须使用平台功能来提供托管线程。为此,Java EE附带一个ManagedExecutorService提供容器管理线程的线程,通常基于单个线程池。

由Java EE专家Adam Bien提供的Porcupine库支持进一步定义可以单独配置的容器管理线程池。以下显示了两个专用ExecutorService的检索和创建工具的定义和用法:

@Inject
@Dedicated("instruments-read")
ExecutorService readExecutor;

@Inject
@Dedicated("instruments-write")
ExecutorService writeExecutor;


// usage within method body ...
CompletableFuture.supplyAsync(() -> instrumentCraftShop.getInstruments(), readExecutor);


// usage within method body ...
CompletableFuture.runAsync(() -> instrumentCraftShop.craftInstrument(instrument), writeExecutor)

通常,这些执行程序服务可以在我们的整个应用程序中使用。但是,在使用HTTP资源时需要考虑另一种情况。

应用程序服务器通常使用单个线程池来处理传入请求的HTTP请求线程。当然,单个请求线程池使我们很难在我们的应用程序中构建多个隔板。

因此,我们可以使用异步JAX-RS资源来立即管理对专用执行程序服务的传入请求的处理:

@Path("instruments")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class InstrumentsResource {

    @Inject
    InstrumentCraftShop instrumentCraftShop;

    @Inject
    @Dedicated("instruments-read")
    ExecutorService readExecutor;

    @Inject
    @Dedicated("instruments-write")
    ExecutorService writeExecutor;

    @GET
    public CompletionStage<List<Instrument>> getInstruments() {
        return CompletableFuture
                .supplyAsync(() -> instrumentCraftShop.getInstruments(), readExecutor);
    }

    @POST
    public CompletionStage<Response> createInstrument(@Valid @NotNull Instrument instrument) {
        return CompletableFuture.runAsync(
                () -> instrumentCraftShop.craftInstrument(instrument), writeExecutor)
                .thenApply(c -> Response.noContent().build())
                .exceptionally(e -> Response.status(Response.Status.INTERNAL_SERVER_ERROR)
                        .header("X-Error", e.getMessage())
                        .build());
    }
}

检索和创建工具的传入请求将传递到单独的线程池。从JAX-RS 2.1开始,返回一个CompletionStage或兼容的类型就足以将JAX-RS资源声明为异步。异步处理完成后,请求将被暂停并恢复。

如果分别用于检索或创建工具的这两个功能中的一个将被重载并且池中的线程用完,则另一个将不受此影响。共享请求线程池不太可能用完线程,因为请求的处理会立即传递给其他受管线程。

我们使用Porcupine库而不是MicroProfile Fault Tolerance的Bulkhead功能的原因是:前者使我们能够直接访问和控制我们与异步JAX-RS资源连接的托管执行程序服务。

背压

负载较重的应用程序可以通过向客户通知其当前状态来应用背压。这可以通过多种方式实现,例如,通过向响应添加元数据或者通过返回失败响应来更加彻底地添加元数据。

如果我们认为应用程序保持响应更为重要,尤其是它能够在其服务级别协议(SLA)内响应而不是延迟响应,那么我们将希望实现背压。关于满足整个系统的SLA,立即响应错误以使客户端可以调用不同的应用程序或实例,而不是消耗所有SLA时间并且仍然无法正常运行可能更有帮助处理请求。

为了指示我们的执行程序服务在所有线程都忙的情况下立即拒绝超过执行程序等待队列的调用,我们需要进一步配置行为。ExecutorConfigurator是Porcupine使用的托管bean,我们可以使用CDI专门使用它:

@Specializes
public class CustomExecutorConfigurator extends ExecutorConfigurator {

    @Override
    public ExecutorConfiguration forPipeline(String name) {
        if ("instruments-read".equals(name))
            return new ExecutorConfiguration.Builder()
                    .abortPolicy()
                    .queueCapacity(4)
                    .build();

        return new ExecutorConfiguration.Builder()
                .abortPolicy()
                .build();
    }
}

覆盖方法forPipeline用于为限定名称构造执行程序服务。该abortPolicy调用将指示底层的线程池立即拒绝与超过资源的新的调用RejectedExecutionException。这是我们的目的所期望的行为。

为了通知客户端我们的应用程序不可用,我们将此异常映射到HTTP 503响应:

@Provider
public class RejectedExecutionHandler implements ExceptionMapper<RejectedExecutionException> {

    @Override
    public Response toResponse(RejectedExecutionException exception) {
        return Response.status(Response.Status.SERVICE_UNAVAILABLE).build();
    }
}

如果客户端现在调用使用当前处于水下的隔板的功能,则它们会立即获得HTTP 503响应,并且仍然可以在SLA时间内连接到另一个系统。

但是,我们应该配置哪些队列大小以满足我们的SLA时间,而不是过分拒绝将要及时处理的请求?

如果我们在排队论中看一下Little's定律,我们会看到平均响应时间是系统中(请求数)的平均数除以平均吞吐量。如果我们进一步考虑到我们可能在系统中有一个多处理单元,我们可以得出最大延迟,如Martin Thompson的文章所述:最大延迟=(事务时间/线程数)*(队列长度),给定非零队列长度并且给定最大延迟大于或等于事务时间。转换该公式使我们得到:队列长度=最大延迟/(事务时间/线程数)。

例如,假设我们要保证SLA时间为200毫秒(最大延迟),测量的平均事务时间为20毫秒,并且有四个可用线程。应用此公式使我们的队列长度为50(因为200 /(20/5)等于50)。如果系统中当前的线程数超过此队列大小,则会立即拒绝新请求,而不是在200毫秒的等待时间之后。这可作为如何ExecutorService在我们的应用程序中配置各个定义的指南。

结论​​​​​​​

在生产中运行企业应用程序时,需要考虑一些事项。我们希望确保我们的应用程序能够在艰难的生产生命中生存,而不会出现级联故障,系统过载或活动问题。

通过使用普通企业Java及其扩展,可以实现弹性问题,例如超时,重试,断路器,隔板和背压,作为我们应用程序的一部分。Java EE API已经解决了一些问题; 其余的可以通过诸如Porcupine和MicroProfile Fault Tolerance之类的扩展来涵盖。​​​​​​​