Tail Latency Amplification e Java

Você já se deparou com situações onde o tempo de resposta de sua API é impactado pelo tempo de resposta de outras API’s consultadas? Ou de operações independentes realizadas de forma síncrona e sequencial? Se sim você já sofreu com um problema conhecido como Tail latency amplification.

Deixo a explicação formal para livros como Designing Data Intensive Applications mas o parágrafo anterior deixa transparecer um pouco o que é o problema.

Tail Latency Amplification na prática

Ao realizar de forma síncrona e sequencial diversas requisições independentes o tempo de resposta de sua API será, no mínimo, a soma dos tempos de resposta das requisições realizadas.

Portanto se você realizar 3 requisições e cada uma delas responde em 150ms a sua resposta levará no mínimo 450ms (150×3). Agora imagine que um dos serviços em questão está sofrendo instabilidade, tendo tempo de resposta de 400ms. Nesse caso sua API levará no mínimo 700ms (150×2 + 400) para responder.

Considerando que não é possível interferir nos serviços de terceiros, o que podemos fazer para minimizar o problema é mudar a forma como nossa aplicação realiza tais requisições.

Não seria ótimo se pudéssemos buscar um tempo de resposta mais próximo do maior tempo de resposta entre as requisições realizadas? No cenário 1 (3 requisições de 150ms) nossa API responderia em torno de 150ms. No cenário 2 (2 requisições de 150ms e 1 de 400ms) nosso tempo de resposta seria em torno de 400ms.

Como faríamos isso? Paralelizando as requisições.

Java 8 e CompletableFuture

A classe CompletableFuture foi introduzida no Java 8 junto com melhorias na API de concorrência. Através dela é possível executar tarefas independentes de forma assíncrona e paralela.

Para simular requisições custosas iremos criar 3 métodos que serão executados, em um primeiro momento, de forma síncrona e sequencial. Cada método terá um Thread.sleep(x) visando simular o tempo de resposta do servidor. Caso deseje altere os valores para observar o comportamento da aplicação.

public class ThirdPartyService {
    public String getHello() {
        try {
            Thread.sleep(150); // suspende por 150ms
        } catch (InterruptedException e) {
            e.printStackTrace(); // ignora exception
        } 
        return "Hello";
    }

    public String getWorld() {
        try {
            Thread.sleep(150); // suspende por 150ms
        } catch (InterruptedException e) {
            e.printStackTrace(); // ignora exception
        }
        return " World";
    }

    public String getMyFriend() {
        try {
            Thread.sleep(400); // suspende por 400ms
        } catch (InterruptedException e) {
            e.printStackTrace(); // ignora exception
        }
        return " My Friend";
    }
}

Enter fullscreen mode Exit fullscreen mode

Para manter a simplicidade estamos ignorando as exceções. Isso não deve ser realizado em código que for utilizado em produção!

A primeira versão do nosso client realiza as requisições de forma síncrona e sequencial:

class SyncClient {
    public static void main(String[] args) {
        ThirdPartyService service = new ThirdPartyService(); //instancia do serviço
        StringBuilder builder = new StringBuilder(); //utilizado para consolidar o resultado

        Long curTime = System.nanoTime();

        //Realizando requisições ao serviço
        builder.append(service.getHello());
        builder.append(service.getWorld());
        builder.append(service.getMyFriend());

        System.out.println(builder.toString()); //impressão do resultado "Hello World My Friend"
        System.out.println( (double) (System.nanoTime() - curTime) / 1_000_000_000  );
    }
}

Enter fullscreen mode Exit fullscreen mode

Ao executar o código acima será possível observar que o tempo de execução nunca será menor que a soma dos tempos de cada requisição. Ou seja, o tempo de execução será no mínimo 150 + 150 + 400 = 700 ms. A imagem abaixo detalha a sequência de eventos:

Vamos agora criar a segunda versão do nosso client que executará as requisições de forma assíncrona e em paralelo conforme abaixo:

class AsyncClient {
    public static void main(String[] args) {
        ThirdPartyService service = new ThirdPartyService(); // instancia do serviço
        ExecutorService executor = Executors.newCachedThreadPool(); // 1 - Obtém um pool de threads para execução de tarefas assíncronas

        Long curTime = System.nanoTime();

        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> service.getHello(), executor); // 2 - Executa a tarefa de forma assíncrona no pool
        CompletableFuture<String> world = CompletableFuture.supplyAsync(() -> service.getWorld(), executor);
        CompletableFuture<String> myFriend = CompletableFuture.supplyAsync(() -> service.getMyFriend(), executor);

        CompletableFuture.allOf(hello, world, myFriend).join(); // 3 - Aguarda a finalização da execução de todas as requisições

        StringBuilder builder = new StringBuilder();

        builder.append(hello.join()); // 4 - Obtem o valor retornado 
        builder.append(world.join());
        builder.append(myFriend.join());

        System.out.println(builder.toString()); // impressão do resultado "Hello World My Friend"
        System.out.println( (double) (System.nanoTime() - curTime) / 1_000_000_000  );

        executor.shutdownNow();
    }
}

Enter fullscreen mode Exit fullscreen mode

Ao executar o novo client será possível observar que o tempo de execução será próximo ao da requisição mais longa, que no caso é 400ms do método getMyFriend(). Abaixo a nova sequência de eventos:

Pontos de atenção

1 – Entender os diferentes tipos de Thread Pools, seus funcionamentos e impactos é importante para usar o mais adequado ao seu cenário. Aqui usamos Cache Thread Pool que é útil para tarefas assíncronas curtas (short-lived asynchronous tasks). A classe Executors possui diversos métodos estáticos que fornecem thread pools pré-definidos para diversas situações.

2 – CompletableFuture.supplyAsync Retorna um CompletableFuture que será executado de forma assíncrona e fornecerá o resultado da tarefa quando solicitado (ver item 4).

3 – CompletableFuture.allOf Retorna um CompletableFuture que é completado quando todos os CompletableFutures passados também forem.

4 – CompletableFuture.get() e CompletableFuture.join() são operações “blocantes”, ou seja, interrompem a execução do programa até obterem o retorno desejado. A diferença principal entre elas é que join() lança unchecked exceptions. Para operações assíncronas que retornam valor você terá que em algum momento usar get() ou join().

Referências

原文链接:Tail Latency Amplification e Java

© 版权声明
THE END
喜欢就支持一下吧
点赞11 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容