Development

Benchmarking dos Protocolos de RPC

Fizemos duas execuções de benchmark para os protocolos de RPC e descobrimos a solução ideal para nossa arquitetura de microservice.

May 05 2016

A plataforma da Infobip não é um monolito. Um exemplo clássico da arquitetura de microservice mostra que ela é constituída por diversos pequenos serviços, que representam diferentes partes da plataforma. Cada serviço tem um objetivo único e bem definido, e só se preocupa em realizar o seu trabalho da melhor forma possível.y.

Ter vários pequenos serviços facilita o gerenciamento de toda a plataforma. Assim, os serviços podem ser atualizados individualmente a qualquer momento, sem a interrupção dos demais. Os serviços podem ser (e são) escritos em diferentes linguagens de programação (nas que melhor se adequam ao problema que estão tentando solucionar). As partes da plataforma que lidam com grande quantidade de tráfego podem ser escaladas de forma independente das que recebem menor carga. Essas são as vantagens de uma arquitetura de microservice, que fazem com que o desenvolvimento da plataforma da Infobip seja uma ótima experiência.

Mas nenhum serviço vive no vácuo. Ser responsável apenas por uma pequena parte também significa que é preciso fazer com que os serviços que trabalham em conjunto se comuniquem. Por exemplo, o lançamento de uma campanha de SMS no Portal da Infobip é controlado pelo serviço encarregado pelas campanhas. Mas se uma campanha incluir contatos de clientes ou grupos, eles serão recuperados a partir de um serviço diferente, que gere os contatos. E há, ainda, outro serviço para o agendamento do envio das mensagens e outros para a rota, faturamento e para a entrega real das mensagens aos assinantes móveis.

Escolhas, escolhas

Nossos serviços se comunicam entre si através de diferentes protocolos de RPC (Remote Procedure Call). Vários deles foram testados durante anos, mas somente três ainda são usados.

  • O bom e velho HTTP/1.1. Por haver diversas implementações, tanto do cliente quanto do servidor, em qualquer linguagem de programação, ele é uma ótima opção para a nossa plataforma poliglota. Nós, geralmente, enviamos o JSON padrão ao longo deste protocolo, o que facilita a depuração dos serviços, já que precisamos somente de um navegador ou de um cliente REST.
  • Nosso segundo protocolo é, novamente, o HTTP, mas dessa vez, com o envio alternado de objetos Java serializados. Esse era um problema, quando dois serviços escritos na linguagem JAVA de programação se comunicavam, pois eles, originalmente, podem ler e escrever esse tipo de dado.
  • MML, sigla para Machine-to-Machine-Language, é o nosso protocolo RPC interno, projetado para alta capacidade produtiva. Ele é semelhante ao HTTP/2, que ainda não existia quando começamos a trabalhar com o MML. Ele usa uma conexão TCP permanente entre o cliente e o servidor, e permite que o cliente equilibre a carga entre os servidores e realize uma transferência quando necessário. O MML usa um JSON digitado como payload – ele parece com o JSON padrão, mas cada objeto é marcado com o tipo de Java. Por isso, o MML é utilizado somente entre serviços Java.

Ter três diferentes formas de comunicação torna um pouco difícil escolher a correta. Cada protocolo tem as suas próprias vantagens e desvantagens quando se trata de desempenho, depuração e interoperabilidade com serviços não-Java. O MML é tido como o protocolo com melhor desempenho, mas ele se limita à comunicação entre dois serviços Java. Quando há várias linguagens de programação temos somente uma opção, que é usar o JSON padrão sobre o HTTP.

Ter três soluções para o mesmo problema está longe do ideal. Qualquer esforço para melhorar a comunicação entre os serviços deve ser feito três vezes para cada protocolo e cada um deles tem especificidades diferentes, que dificultam a aplicação das mesmas funcionalidades em cada um deles. Idealmente, gostaríamos de ter apenas um protocolo – um que fosse eficiente em duas coisas: fácil depuração e que pudesse ser usado em diferentes linguagens de programação. Assim, teríamos o bolo e poderíamos comê-lo também.

Nós precisávamos de uma maneira para comparar os benchmarks e ver qual deles seria “o melhor” e em qual situação. Assim, escolhemos o desempenho como prioridade, pois sempre poderemos construir uma ferramenta para a depuração de protocolos e escrever a nossa própria implementação, se não houver a necessidade de uma linguagem específica. Mas se o protocolo tiver um desempenho lento, não há como ajudá-lo.

Nós também precisávamos de um benchmark. É fácil escrever um que fará várias chamadas em loop e medir a sua capacidade produtiva. Mas é ainda mais fácil cometer erros com esses benchmarks. Por isso, não estamos interessados em medi-los ou nos seus resultados, que podem ser distorcidos, tornando-os inúteis.

Escrevendo um (Bom) Benchmark

Dois de nossos três protocolos são limitados a linguagem Java de programação. Por isso, faz sentido escrever todo o benchmark em Java. Contudo, escrever benchmarks que são executados em Java Virtual Machine (JVM) sem cometer erros é muito difícil. Há vários detalhes que precisamos considerar:

  • Leva um certo tempo para o JVM traçar um perfil e otimizar o código gerado. Não há porque medir antes que o JVM finalize as otimizações.
  • O JVM irá traçar o perfil e otimizar o código para o padrão real de uso. Se colocarmos o benchmark em dois protocolos, um após o outro, o segundo pode ter resultados ruins, pois o JVM já terá um código especializado para o primeiro. Para evitar isso, cada benchmark deve ser executado em um novo e limpo processo.
  • Em caso de benchmarks com vários encadeamentos, é preciso ter ainda mais cuidado para garantir que todos os encadeamentos comecem e parem de medir ao mesmo tempo.
  • E estes não são nem a metade dos problemas que podem ocorrer. Felizmente, existem ferramentas que podem resolvê-los para nós. Uma delas é o Java Microbenchmark Harness (JMH). Como o JVM foi escrito por desenvolvedores, podemos seguramente assumir que eles sabiam o que estavam fazendo.

É fácil escrever benchmarks com JMH. Nós só precisamos escrever o código que realmente queremos medir e o JMH tomará conta de todo o resto – executar o código em loop para medir a capacidade de carga, rodar processos separados para cada benchmark, dar tempo suficiente para o JVM “aquecer” e otimizar corretamente o código e todos os demais chatos (mas importantes) detalhes.

O esqueleto do nosso benchmark é mais ou menos assim:

public class ProtocolBenchmark {
 
    @Param({"http_json", "http_java", "mml"})
    private String protocol;
 
    @Param({"0", "10", "100"})
    private int payloadSize;
 
    @Setup
    public void setup() {
        // Spin-up the server and the clients
    }
 
    @Benchmark
    @Threads(1)
    public Object threads_1() {
        // Perform a RPC call
        // This will be invoked from one thread (client) only
    }
 
    @Benchmark
    @Threads(32)
    public Object threads_32() {
        // Perform a RPC call
        // This will be concurrently invoked from 32 threads (clients)
    }

}

O JHM é bastante flexível e pode ser adaptado para vários cenários. As opções mais úteis para o nosso benchmark foram os parâmetros e o número de encadeamentos. Os parâmetros nos permitem utilizar o mesmo benchmark em diferentes cenários. Não há nenhuma diferença na lógica de benchmark quando medimos protocolos diferentes ou usamos payloads de tamanhos distintos. Por isso, com os parâmetros podemos evitar o processo de copiar e colar o mesmo código em vários lugares. E variando o número de encadeamento que são executados simultâneamente, podemos medir como os protocolos lidam com um número crescente de clientes coexistentes.

Executando o Benchmark

É hora de executar um pouco de benchmark! Na verdade, fizemos duas execuções separadas em condições diferentes.

Durante a primeira execução, tivemos clientes e servidores rodando na mesma máquina e se comunicando por meio de uma interface do localhost. Nós usamos esse cenário para medir por alto o protocolo puro, quando não há fatores externos para interferir nas medições.

Na segunda execução, colocamos nossos protocolos em condições mais realistas. Tínhamos o servidor executando em uma máquina diferente da do cliente e a comunicação foi feita em uma rede real. E nos casos de protocolos HTTP, todo o tráfego foi encaminhado através do HAProxy, já que é o que realmente usamos no ambiente de produção. Como o MML suporta o equilibrio da carga da parte do cliente, o cliente pode se conectar diretamente com o servidor, sem a necessidade de passar por um intermediário.

Nós também rodamos o benchmark para um determinado número de clientes, sendo que cada cliente fez uma ligação remota repetidamente com um payload de um determinado tamanho e,em seguida, esperamos o servidor repetir o mesmo payload na volta. O payload era uma simples lista de objetos que continha o número exato de objetos na lista.

O número de clientes e os tamanhos de payload foram parametrizados para cobrir os padrões de comunicação que são típicos nos nossos serviços. Nós medimos clientes simultâneos com 1, 32, 64 e 128 e com o tamanho do payload variando entre 0 (lista vazia), 10 e 100 elementos.

Resultados

Após completar a execução, o JHM vai tabular os dados de saída com medições para cada combinação dos parâmetros imputados. Nós visualizamos os resultados na forma de gráficos simples de barras, facilitando a comparação.

Primeiro, ao medir a comunicação através do localhost, tivemos um resultado parecido com este:

Para os payloads pequenos e médios, o protocolo MML foi o vencedor, independente do número de clientes simultâneos. Mas isso mudou quando trocamos para payloads grandes, quando os protocolos baseados em HTTP tornaram-se praticamente iguais aos do MML em termos de capacidade de carga. Outra informação interessante foi que usar objetos Java serializados é, na verdade, mais lento que usar JSON. Parece que Jackson - a biblioteca JSON que usamos, é mais otimizada para o nosso estudo de caso.

Para a segunda execução, feita em uma rede real, os resultados foram estes:

O MML ainda lidera nos payloads pequenos ou médios, mas com uma margem menor. E nos payloads grandes, houve uma mudança significativa. Transferindo o JSON padrão sobre o HTTP, o MML é superado com uma grande margem. O JSON digitado é, provavelmente, o motivo para este resultado – todos os tipos de tags que ele adiciona, aumentam notavelmente o número de bytes que devem ser enviados ao longo do fio, fazendo com que a capacidade de carga seja sobrecarregada.

Conclusão

Então, o que aprendemos com essa experiência? Nossos protocolos internos, mesmo sendo ótimos com payloads pequenos e médios, já não são boas escolhas para serviços grandes de solicitação e respostas. Por outro lado, o uso do JSON sobre HTTP mostrou bom desempenho e pode ser utilizado por serviços escritos em qualquer linguagem, o que é definitivamente um plus. Por último, o uso da serialização Java sobre HTTP foi inferior em todos os cenários testados, de modo que este é um protocolo que vamos aposentar.

No final, ainda não temos um vencedor claro. Nós gostaríamos de verificar a otimização do JSON sobre HTTP – ou talvez fazer um protótipo de uma solução baseada em HTTP/2 e ver como ele funciona. Há ainda muito trabalho para fazer. Fique ligado!

Hrvoje Ban, Software Engineer