Minha jornada de otimização de uma aplicação django
Tags: python, django, gunicorn, rinhadebackend
Published: 2024-03-13 20:19:00

---
title: Minha jornada de otimização de uma aplicação django
published: true
description: 
tags: python, django, gunicorn, rinhadebackend
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3mdlr6jnyf9uekvmgfm9.png
# Use a ratio of 100:42 for best results.
published_at: 2024-03-13 20:19 +0000
---

> [Rinha de backend](https://github.com/zanfranceschi/rinha-de-backend-2024-q1) é 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](https://github.com/rafaelpadovezi/rinha-2-django-orm).

Além do django, eu escolhi usar:

- Django rest framework
- [Gunicorn](https://gunicorn.org) 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](https://www.gevent.org/intro.html) é 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](https://www.willianantunes.com/blog/2023/07/understand-async-workers-by-practice-with-django-gunicorn-and-gevent/). 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](https://docs.gunicorn.org/en/latest/design.html#choosing-a-worker-type) 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()](https://docs.djangoproject.com/en/5.0/ref/models/querysets/#values): retorna um Queryset que retorna um dicionário ao invés de uma instância do modelo
- [update_fields](https://docs.djangoproject.com/en/5.0/ref/models/instances/#specifying-which-fields-to-save): 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](https://www.pgbouncer.org/) - 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](https://docs.djangoproject.com/en/5.0/topics/performance/#id1) 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](https://github.com/rafaelpadovezi/rinha-2-django). 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](https://docs.djangoproject.com/en/5.0/ref/databases/#persistent-connections) 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](https://github.com/benoitc/gunicorn/issues/996#issuecomment-93301359), as conexões não são reutilizadas nesse cenário.

![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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fzlas3tdtx54n0h8en7d.png)
*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 `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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/10b0tsxt5yt9ny0qycxg.png)
*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](https://docs.gunicorn.org/en/stable/settings.html#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 `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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/laag61429wnaya3ymoq2.png)
*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](https://eng.lyft.com/gevent-part-3-performance-e64303fa102b) 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 `worker_connections=5` as conexões do banco se mativeram baixas durante todo o teste](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bhqrmju2izbbxjq3o4vc.png)
*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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/p30t6p0iqtlgna3r5tor.png)
*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 `docker stats`. Valores de CPU das APIs superam (e muito) os limites do desafio](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6kcyujxzrt7mngoz8di9.png)
*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](https://github.com/benfred/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
```

![Flamegraph de um worker gerado pelo py-spy durante a execução dos testes de carga](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bexranqze0yj99gaadcb.png)
*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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bt47dwpvy716vu4uctao.png)
*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](https://github.com/rafaelpadovezi/rinha-2-django-orm/blob/d96aca58e2743794349f00881b3d41d8d885f333/profile.svg).

## 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](https://docs.gunicorn.org/en/latest/design.html#sync-workers). 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/llk6z6q5nulm9mgmmx9r.png)
*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

- Understand Async Workers by Practice with Django, Gunicorn, and Gevent - https://www.willianantunes.com/blog/2023/07/understand-async-workers-by-practice-with-django-gunicorn-and-gevent/
- Web API performance: profiling Django REST framework - https://medium.com/@yashnarsamiyev2/web-api-performance-profiling-django-rest-framework-72886c5cd614
- What the heck is gevent? (Part 1 of 4) - https://eng.lyft.com/what-the-heck-is-gevent-4e87db98a8
- Django ORM Under the Hood - Iterables - https://www.hacksoft.io/blog/django-orm-under-the-hood-iterables