Comparando o Desempenho de Aplicações Web

Publicado por

Publicado por

Publicado em

    Categorias:

    Desenvolvimento

Quando falamos em aplicações web, a performance geralmente não é o fator principal na escolha de ferramentas e frameworks. Apesar disso, é importante conhecer as limitações e trade-offs das ferramentas utilizadas a fim de fazer as escolhas certas com base nas necessidades do projeto. Nesse contexto, entender a diferença entre a performance das ferramentas disponíveis é essencial para garantir que a aplicação é capaz de atender aos requisitos de escalabilidade e performance do projeto a ser desenvolvido.

 

Nesse contexto, ao longo dos últimos anos diversos frameworks e ferramentas têm surgido com a promessa de entregar performance e escalabilidade. Ainda assim, cada ferramenta tem suas particularidades e limitações, tornando-as mais adequadas para determinadas tarefas. Tendo isso em vista, decidimos fazer um comparativo entre algumas das alternativas para a implementação de aplicações web em diferentes cenários.

Aplicações comparadas

Iremos comparar a performance de 4 aplicações implementadas com diferentes tecnologias. A ideia é explorar tecnologias comuns no mercado e que possuem como característica a preocupação com performance. As aplicações são APIs HTTP que retornam dados em JSON.

Ao todo foram comparas 4 APIs diferentes:

  • Node.js utilizando o framework web Fastify e o driver de banco de dados pg
  • Python utilizando o framework web FastAPI e o driver de banco de dados psycopg
  • Python utilizando o framework web FastAPI e o driver de banco de dados asyncpg
  • Go utilizando o framework web Gin e o driver de banco de dados pq

Em todos os casos foram utilizadas pools de conexões com o banco de dados com um limite de até 100 conexões. Além disso, foram utilizados os serializadores de JSON padrão de cada linguagem ou framework, a fim de explorar o desempenho out of the box de cada ferramenta.

Metodologia dos testes

É importante definir qual métrica será utilizada para comparar o desempenho das diferentes implementações. No contexto de servidores web, uma métrica bastante comum é a de requisições por segundo. Ou seja, estamos interessados em quantas requisições um servidor implementado com uma dada ferramenta é capaz de atender por segundo.

 

Com a métrica de requisições por segundo vamos verificar como cada ferramenta se comporta em diferentes cenários de carga. Dessa forma, é possível verificar não só a performance de cada ferramenta, mas também como essa performance muda de acordo com o número de requisições simultâneas.

 

Para fazer um comparativo válido é necessário criar um ambiente com condições controladas e reprodutíveis. Tendo isso em vista, foram criados dois cenários de teste diferentes a fim de testar diferentes aspectos de cada implementação. A ideia é explorar a performance das APIs em operações IO-bound e CPU-bound.

 

A diferença entre operações IO-bound e CPU-bound é dada por quais fatores limitam as operações em tempo de execução. Chamamos de IO-bound as operações que passam a maior parte do tempo aguardando pela resposta de alguma comunicação como, por exemplo, o acesso a um banco de dados. Já as operações CPU-bound são aquelas que passam a maior parte do tempo executando instruções no processador como, por exemplo, algum processamento de dados.

 

Tendo isso em vista, foram selecionados dois cenários para comparar o desempenho das implementações. Sendo assim, cada API conta com dois endpoints, um para cada cenário:

  1. Endpoint GET /cache (CPU-bound): gera 1000 objetos em memória e os retorna serializados em JSON
  1. Endpoint GET /db (IO-bound): busca 20 registros em um banco PostgreSQL e os retorna serializados em JSON

Para executar o benchmark foram utilizadas duas instâncias do Cloud Compute Engine: uma no papel de cliente e a outra no papel de servidor. A máquina no papel de servidor irá executar as 4 APIs a serem testadas. Já a máquina no papel de cliente será responsável por fazer as requisições para o servidor, simulando alto tráfego.

 

A máquina no papel de servidor é uma instância N1 Series com 1vCPU dedicado e 3.75Gb de memória RAM. Já a máquina no papel de cliente é também uma instância N1 Series, porém conta com 4vCPUs dedicados e 3.6Gb de memória RAM. Além disso, a base de dados foi executada numa instância do Cloud SQL com 4vCPU, 8GB de RAM e 10GB de SSD.

 

A ideia foi fazer com que o hardware do servidor fosse um fator limitante para a performance, e não o da máquina no papel de cliente ou o banco de dados.

 

Outro ponto a ser levado em consideração é que o servidor possui apenas um core, o que inibe possíveis ganhos de performance trazidos com paralelismo. Isso é especialmente relevante quando comparamos runtimes single-threaded que só conseguem fazer uso de uma CPU como Node.js com linguagens como Go, que são capazes de fazer bom uso de ambientes computacionais com múltiplas CPUs.

 

A simulação de carga feita com a ferramenta bombardier rodando na instância no papel de cliente. Foram feitas rodadas de teste com 1, 10, 50 e 100 conexões concorrentes. Cada rodada teve duração de 5 minutos. O resultado de cada rodada é uma média de quantas requisições por segundo cada API foi capaz de processar.

 

É importante notar que tanto a condução dos testes quanto a implementação das aplicações testadas podem conter erros que prejudiquem os resultados. A implementação de todas as aplicações testadas e também o provisionamento da infraestrutura, bem como os resultados detalhados estão disponíveis neste repositório público. Além disso, vale conferir o projeto Web Framework Benchmarks, que é uma fonte bastante interessante de dados de performance e comparativos entre diversos frameworks para aplicações web.

Resultados

Os resultados dos testes de para cada endpoint podem ser observados nos seguintes gráficos.

notion image

No endpoint GET /cache é possível verificar uma diferença natural entre linguagens compiladas e interpretadas. A implementação em Go foi capaz de manter uma média de requisições significativamente mais alta que as demais. Esse resultado era esperado, dado que linguagens compiladas como Go tendem a performar melhor do que linguagens interpretadas como JavaScript e Python em tarefas CPU-bound.

 

Vale notar que ambas as implementações em Python performaram de maneira muito similar, pois nesse caso não houve qualquer acesso ao banco de dados, impossibilitando quaisquer diferenças provenientes do uso de diferentes drivers.

notion image

No endpoint GET /db é possível observar um grande favorecimento ao processamento assíncrono, dado que a operação realizada por esse endpoint é IO-bound.

 

A performance da implementação em Node.js utilizando Fastify chama a atenção pela diferença com relação às outras. Durante a execução dos testes foi possível observar que essa implementação fazia uso das 100 conexões com o banco de dados, enquanto as outras dificilmente atingiam um número superior a 40. As razões para tal diferença podem ser fruto tanto da implementação de cada driver para interação com o banco de dados quanto do funcionamento de cada runtime e framework.

 

É possível ainda ver uma clara diferença entre as implementações do FastAPI, sendo que a utilização de um driver assíncrono para a interação com o banco de dados trouxe um grande impacto no desempenho. Outro ponto interessante é que, em se tratando de operações IO-bound, as implementações em Python não apresentaram diferença tão significativa com relação à implementação em Go.

Conclusão

É interessante perceber como linguagens e frameworks podem performar de maneira drasticamente diferente a depender do cenário em que são colocadas. Dessa forma, a escolha de uma ferramenta é intrinsecamente ligada ao propósito da aplicação a ser desenvolvida.

 

Apesar de ser um fator de grande importância, vale ter em mente que performance não deve ser o único fator a ser considerado na escolha de uma linguagem ou framework. Fatores como a disponibilidade de bibliotecas no ecossistema e até mesmo a familiaridade dos desenvolvedores com a ferramenta podem ter um grande impacto nos custos e qualidade do desenvolvimento e, portanto, também devem ser consideradas no momento da escolha.