DEV Community

Cover image for Minha jornada de otimização de uma aplicação django
Rafael
Rafael

Posted on

Minha jornada de otimização de uma aplicação django

Rinha de backend é um desafio de programação com o objetivo de gerar e compartilhar conhecimento. O texto descreve minha experiência ao participar do desafio mas acredito que é útil para todos que querem entender um pouco mais de django, gunicorn e gevent.

Para a rinha de backend segunda edição eu fiz uma submissão usando django com o objetivo de aprimorar meus conhecimentos em python e no framework. O desafio consiste em resolver o problema clássico de concorrência de transações bancárias ao mesmo tempo que deve suportar um teste de carga. O código resultante da minha submissão pode ser visto aqui.

Além do django, eu escolhi usar:

  • Django rest framework
  • Gunicorn como web server
  • PostrgreSQL como banco de dados

A minha primeira meta era conseguir executar os testes de carga na API sem restrições de recursos, podendo usar todos os recursos disponíveis do meu computador. Após isso, para poder submeter minha solução do desafio era necessário aplicar limites de CPU (1.5) e memória (550MB). Ou seja, a soma dos recursos do load balancer, banco de dados e duas instâncias da API devem estar dentro do limite especificado. E, se possível, manter os tempos de requisições baixos durante todo o teste.

Importante falar que a maioria das otimizações foram feitas especialmente para a rinha e não necessariamente sejam necessárias ou até mesmo recomendadas em aplicação reais. Mas ao mesmo tempo me sinto muito mais confortável em colocar aplicações django em produção com o conhecimento que adquiri durante essa jornada.

Gevent

Gevent é uma biblioteca de corotinas do python que facilita a execução de código bloqueante de maneira assíncrona. Um exemplo do impacto do uso gevent pode ser visto nesse artigo. Como tenho mais experiência com C# faço o paralelo com o async/await apesar de terem implementações distintas.

O gunicorn apresenta tipos distintos de workers e a documentação recomenda:

Some examples of behavior requiring asynchronous workers:
Applications making long blocking calls (Ie, external web services)

Como a API é basicamente IO bound foi escolhido o gevent como worker type.

Micro-otimizacoes

Uma vez que um dos objetivos era ter uma API otimizada busquei e encontrei os seguintes tópicos para melhorar a performance do meu código:

  • values(): retorna um Queryset que retorna um dicionário ao invés de uma instância do modelo
  • update_fields: argumento do método save que especifica qual campo deve ser atualizado simplificando o comando SQL gerado
  • Remoção dos middlewares desnecessários (para o desafio)

Eu não medi o impacto mas mantive as mudanças. Considerando as documentações e blogs é esperado algum ganho de performance.

Algumas coisas que tentei mas abandonei:

  • Pgbouncer - resolvia o problema do limite de conexões no postgres. Mas a API “saudável” manteve o número de conexões baixo o suficiente.
  • Pypy ao invés de cpython - os resultados não foram bons, aumentou o consumo de recursos entregando uma performance pior. No entanto, é importante dizer que não me aprofundei no assunto e não dediquei nenhum tempo para fazer qualquer tunning.
  • Usar diretamente o psycopg3 e o seu connection pool. Obtive ótimos resultados dessa maneira e serviu para mostrar que o setup django + gunicorn + gevent funciona bem e é capaz de processar as requisições com a limitação de recursos da rinha. Mas ainda queria ter uma submissão com o ORM.

Conexões persistentes (CONN_MAX_AGE)

Django não possui uma connection pool própria para conexão com banco de dados. Uma possibilidade é o uso da propriedade CONN_MAX_AGE que determina o tempo de vida que uma conexão pode ter. Quando 0 (valor padrão) cada conexão será finalizada no fim da execução da requisição. Valores maiores que 0 indicam o tempo que uma conexão pode existir até ser finalizada, permitindo que outras requisições reusem a conexão. Percebi na prática que usar qualquer valor diferente de 0 não funciona bem com o worker do tipo gevent do gunicorn. Como descrito aqui, as conexões não são reutilizadas nesse cenário.

Com  raw `CONN_MAX_AGE!=0` endraw  e  raw `worker_type=gevent` endraw  não há reutilizações das conexões persistentes. O número de conexões total cresce mesmo com quase todas conexões ociosas. Na medida que se aumenta a quantidade de requisições por segundo o número de conexões também cresce
Com CONN_MAX_AGE!=0 e worker_type=gevent não há reutilizações das conexões persistentes. O número de conexões total cresce mesmo com quase todas conexões ociosas. Na medida que se aumenta a quantidade de requisições por segundo o número de conexões também cresce

Usar esse valor com 0 mantém o número de sessões no postgres baixo, sem necessidade de se reutilizar as conexões para esse fim.

Com  raw `CONN_MAX_AGE=0` endraw  e  raw `worker_type=gevent` endraw  o número de conexões se manteve estável. Cada requisição abre e fecha uma nova conexão com o banco
Com CONN_MAX_AGE=0 e worker_type=gevent o número de conexões se manteve estável. Cada requisição abre e fecha uma nova conexão com o banco

Worker connections

O valor padrão de worker connections é 1000 então começar os testes com o valor 50 e ir aumentando me pareceu razoável. No entanto, enquanto usava esse valor “alto”, durante o teste o número de sessões no postgres começava estável até chegar um momento de pico e a partir daí aplicação já não era capaz de responder as requisições do teste de carga.

Com um valor maior de  raw `GUNICORN_WORKER_CONNECTIONS` endraw  e  raw `GUNICORN_WORKER_TYPE=gevent` endraw , em um determinado ponto da execução dos testes de carga o número de conexões com banco de dados explodia, causando erros na API
Com um valor maior de GUNICORN_WORKER_CONNECTIONS e GUNICORN_WORKER_TYPE=gevent, em um determinado ponto da execução dos testes de carga o número de conexões com banco de dados explodia, causando erros na API

Acidentalmente fiz um teste sem gevent e para minha surpresa os testes foram concluídos sem erros. Lendo esse artigo entendi o motivo. O número alto de worker connections e gevent significa que mais greenlets (pseudo threads) eram agendados além da capacidade de processamento. Uma requisição era iniciada e, por conta de uma operação bloqueante como um query no banco, a execução era pausada e ia para fila de itens a serem processados. Como já existiam várias requisições na frente, essa greenlet sofria starvation e esse processo causa um pico de número de sessões abertas ao aplicar uma carga suficiente. Ajustando esse valor para um número menor (no meu caso 5) já é possível perceber que há uma melhora considerável nos resultados dos testes.

Com  raw `worker_connections=5` endraw  as conexões do banco se mativeram baixas durante todo o teste
Com worker_connections=5 as conexões do banco se mativeram baixas durante todo o teste.

Parte do relatório de execução dos testes de carga do Gatling. O resultado apresenta boa performance mas ainda não foi aplicado os limites de recursos
Parte do relatório de execução dos testes de carga do Gatling. O resultado apresenta boa performance mas ainda não foi aplicado os limites de recursos

Profiling

Apesar de muito contente com a execução da API ainda estava cético pois as métricas coletadas durante os testes mostravam que a API não iria se comportar bem com os limites impostos do desafio, principalmente para CPU.

Resultado do comando  raw `docker stats` endraw . Valores de CPU das APIs superam (e muito) os limites do desafio
Resultado do comando docker stats. Valores de CPU das APIs superam (e muito) os limites do desafio

Quando limitado, os resultado dos testes de carga apresentavam alta taxa de erro pois a API não processava as requisições em tempo hábil para responder a carga enviada. Para tentar entender o motivo usei uma ferramenta muito interessante para profiling que me ganhou pela facilidade de uso. Com um simples comando py-spy consegui gerar o flamegraph da minha API durante a carga e visualizar os métodos que mais consomem tempo de CPU.

py-spy record --gil --subprocesses -o profile.svg -- gunicorn -w 2 rinha.wsgi -b 0:9999
Enter fullscreen mode Exit fullscreen mode

Flamegraph de um worker gerado pelo py-spy durante a execução dos testes de carga
Flamegraph de um worker gerado pelo py-spy durante a execução dos testes de carga

Flamegraph com foco na abertura da transação com o banco de dados
Flamegraph com foco na abertura da transação com o banco de dados

O arquivo gerado é um SVG navegável e pode ser baixado aqui.

De volta com CONN_MAX_AGE

Já no último dia da rinha, a poucas horas do fim, enquanto observava o gráfico percebi que muito tempo de CPU era gasto na criação da conexão com o banco de dados. Assim eu especulei que se houvesse um pool de conexões ou, pelo menos evitar que elas fossem abertas a cada requisição, haveria redução do consumo de CPU. Isso trouxe a ideia de setar novamente um valor de CONN_MAX_AGE para manter as conexões abertas e que fossem reutilizadas. Mas para isso seria necessário alterar o worker type para sync. A expectativa é que o menor consumo de recursos possibilitaria a execução da API sob carga com as limitações impostas. E felizmente foi isso que aconteceu.

Parte do relatório de execução dos testes de carga do Gatling. O resultado apresenta boa performance mesmo com os limites de recursos aplicados
Parte do relatório de execução dos testes de carga do Gatling. O resultado apresenta boa performance mesmo com os limites de recursos aplicados

E isso é o fim da minha versão de django + orm para a rinha!

Conclusão

Então o gevent é ruim e é melhor usar sync mesmo? Não é bem assim. O que eu posso afirmar é que em um cenário de baixíssima latência, requisições externas rápidas, carga alta e recursos limitados é melhor abrir mão do “async” para ter a reutilização de conexões. O overhead de abrir uma conexão por requisição foi o fator determinante para a API não passar nos testes de carga do desafio. Acredito que usando alguma biblioteca que implemente um pool de conexões no django seria possível usar o gevent para esse caso.

Além disso, os maiores ensinamentos foram:

  • Conexões persistentes e gevent worker não funcionam juntos. É necessário escolher um ou outro;
  • Usando gunicorn, se for alterar o worker type de sync para gevent faça testes para escolher o valor de worker connections. Um valor muito acima do ideal prejudica bastante a performance;
  • Não dá para resolver o problema sem saber qual é o problema. Em outras palavras, use ferramentas para medir e permitir fazer uma análise efetiva. Eu não teria feito muito sem docker stats, pgadmin e py-spy. E se eu tivesse gasto mais tempo em instrumentação poderia ter ido mais longe e gasto menos tempo em tentativa e erro.

Referências

Top comments (2)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.