Colocando o Python no paredão: ele suportaria a votação do BBB?

Cassio R. Eskelsen
7 min readMar 14, 2022

--

Na última semana foi assunto entre o pessoal de TI o volume de dados suportado em uma votação do programa BBB. Algo entre 2 e 3 milhões de votos por minuto com picos de 55 mil requisições por segundo.

Sobre isso o Francisco Zanfranceschi fez um desafio interessante no Twitter:

O desafio completo pode ser visto na thread, mas em resumo a idéia é tentar reproduzir uma arquitetura que suporte carga semelhante.

Fiquei pensando como nosso querido Python se comportaria em uma situação dessas. Todos sabemos que Python não é a linguagem mais rápida do mundo, mas com alguns “truques” de Arquitetura/Infra poderíamos chegar perto?

Aproveitei o domingo chuvoso para rabiscar alguns códigos. Disclaimer: não quis ir muito longe em termos de horas de desenvolvimento então não me preocupei em fazer o código mais lindo do mundo, nem muito menos criar pipelines de deploy. A idéia foi testar conceitos.

Arquitetura

Em linhas gerais não há como fugir muito do que o Francisco sugeriu:

Ou seja: iremos desacoplar o recebimento dos votos do seu processamento. Essa é uma arquitetura que deve ser usada sempre que possível: você informa o usuário que recebeu a solicitação mas não garante o seu processamento na hora.
Você já viu como é rápido fazer um pedido na Amazon? Ela faz exatamente isso, após você confirmar a compra ela apenas informa que recebeu o seu pedido. Se operação no cartão de crédito deu certo ou não você só saberá depois por email.
Dessa forma seu usuário não fica esperando uma eternidade e você não corre o risco de ter o processamento quebrado por um timeout ou qualquer outra situação inesperada.

Algumas decisões que tomei durante a implementação:

a) Usar o Redis como mensageria: Redis é uma das peças de software mais eficientes e estáveis que conheço e é extremamente fácil de instalar/manter. A alternativa mais comum nessa arquitetura é o RabbitMQ, no entanto ele é um tanto mais “manhoso” e exigiria mais implementação de código.

b) Separar o cômputo dos votos do storage definitivo: o item de maior valor é o resultado dos votos. Salvar isso em banco é secundário (e nem tãão fundamental nesse caso de uso).

c) Salvar os votos em um object storage, no meu caso utilizei o Azure Blob Storage.

O desenho final da minha implementação é o abaixo:

Adoro o Excalidraw para diagramas!

Infraestrutura

Logo no começo da minha POC percebi que a maior dificuldade seria simular essa monstruosa quantidade de acessos. Existem várias ferramentas boas para isso, como o artillery.io, mas montar toda estrutura tomaria tempo. Fiz uns testes iniciais como o Locust, mas aparentemente ele não consegue simular uma quantidade grande de usuários simultâneos (ao menos não sem criar uma infra de processamento distribuído). Acabei optando pelo bom e velho wrk rodando em uma VM, mesmo sabendo que apenas uma VM me traria algumas restrições de I/O, mas ... close enough.

Para rodar a aplicação utilizei Azure Web Apps. É um recurso interessante para hospedar aplicações que rodam em .Net, Python, Node, Java, etc. Você pode escalar várias instâncias (ou definir critérios de escala automática) e todas elas rodam automaticamente atrás de um load balance. O tamanho utilizado para a instância foi P3V3 (8 vCPU/32 GB memória).
O deploy da aplicação é feito através de um container, cujo Dockerfile está no GitHub disponibilizado abaixo. Utilizei um Azure Container Registry para subir a imagem.

No final, minha infraestrutura foi essa:

É claro que em um mundo real rodar tudo isso em um Kubernetes faz muito mais sentido e funcionaria muito melhor, mas a constraint de tempo me impediu de configurar toda infra de um cluster de k8s.

Implementação

Para implementar a API resolvi deixar de lado o já tradicional FastAPI para testar o Sanic. Segundo este review ele é muito mais rápido que o FastAPI. Não cheguei a fazer um comparativo, mas o feeling que ele me passou é que realmente é muito rápido.

Não utilizei gunicorn como webserver ASGI. Durante os testes não pareceu agregar performance já que o próprio Sanic permite definir uma quantidade maior de workers.

A comunicação com o Redis foi feita usando a lib sanic-redis, construída sobre a aioredis.

Todo o código está disponível nesse GitHub: https://github.com/cassioeskelsen/votacao_bbb

Irei comentar abaixo algumas partes relevantes e decisões que tomei.

main.py (a api)

Na linha 3 a primeira “solução de contorno” da poc: o wrk nao permite criar um script que dinamicamente faça um random de variáveis para simular votos aleatórios. Portanto a cada request sorteio uma opção de voto no próprio código do Python.

Na linha 6 eu monto um objeto com o voto e alguns dados de header (em uma solução da vida real iríamos usar essas informações para um anti-fraude) e jogo em uma lista (“votos”) do Redis. Nada além disso, apenas finalizamos retornando para o usuário que recebemos o voto.

consumer.py (processamento dos votos)

Digamos que esse seja o cérebro da nossa aplicação:

Nesse código eu processo os votos em chunks de 1000 votos para acelerar o processamento.

No entanto, aqui percebi um gargalo: o método lpop do Redis que remove um elemento da lista e retorna o seu valor é um pouco lento. Se utilizasse lrange seria muito mais rápido mas ele apenas retorna os valores sem retirar eles da lista. Nas considerações finais comento uma alternativa para acelerar o processamento.

Nas linhas 10 a 16 faço o processamento dos votos em si. De acordo com a opção que veio no payload adiciono um voto na respectiva chave do dicionário de votos.

O processamento final do chunk (19–23) tem duas responsabilidades:

  1. Salva em uma key do Redis os votos computados de cada opção. Como o processamento é por chunk posso ter mais de um voto para cada opção nesse lote, portanto utilizei a função incrby do Redis que acrescenta um determinado valor (“amount”) ao valor já existente.
  2. Salva em uma nova lista (“votos_bd” ) os votos que devem ser persistidos no storage.

Perceba que dessa forma a totalização é feita concomitantemente ao processamento dos votos.

save_to_storage.py

Esse código tem como finalidade salvar os votos no Blob Storage.

O processamento é feito em chunks de 100 itens, lembrando que cada item no caso são mil votos processados no consumer.py.

Não tem nada especial esse código: é criada uma lista com os votos acumulados e na linha 15 enviamos essa lista para o blob storage, dando como nome um guid.

totalization.py

De todos o código mais simples:

Primeiramente descobrimos quais são todas as chaves com totalização de votos (3).

Depois, exibimos o valor contido em cada chave (6).

Execução

Colocando tudo para funcionar. Primeiramente rodei o teste de carga com wrk configurado para 90 threads e 800 “usuários” em cada thread. Usando threads também garantimos que o wrk utilize todos os cores da VM. O arquivo de script post.lua apenas define o agent e o verbo http (no caso post).

Nesse teste atingi 22.215 requests por segundo. Em outro atinge um pico de 24 mil requests por segundo. Nesse teste, após 1 minuto, atingi 1 milhão e 300 mil requisições.

Após, rodei o processamento dos votos, com o seguinte resultado:

python3 consumer.py
Apuração dos Votos
Total de Votos computados: 1335121
Minutos processamento: 6.52

Como podemos ver, o tempo de processamento foi bem mais alto que a ingestão dos votos.

Por último podemos ter um resumo dos votos com o totalization.py. Os valores abaixo não são do processamento acima, são apenas um exemplo.

python app/totalization.py        
Votos de 2 - 422597
Votos de 1 - 421342
Votos de 0 - 421611

Conclusão

Como podemos ver, com esse exercício não atingimos a mesma marca de 2 a 3 milhões de requests do BBB. Isso pode ser resolvido adicionando mais máquina.

No entanto, temos que reconhecer que nem sempre a solução é gastar mais dinheiro com infra e aprender a respeitar os limites. Em uma situação dessas, caso eu fosse o líder do projeto, talvez optasse por usar na porta de entrada alguma outra linguagem, tal como o Go.

No entanto isso não é nenhum demérito para o Python. Considerando as idiossincrasias dessa linguagem, o valor de até 24 mil requests por segundo é excelente! São poucas as aplicações do mundo real que chegam nesse número. Na fintech que trabalho, temos muita movimentação e mesmo assim chegamos a no máximo entre 200 e 300 requests/segundos nos períodos de pico.

Tudo é uma questão de ponderar trade-offs. Adicionar uma linguagem em nossa stack tem um custo, adicionar mais infra também tem. Não existe solução certa, depende do caso de cada organização.

Com relação ao processamento dos votos tínhamos apenas um consumidor rodando. Em um ambiente escalável subiríamos mais consumidores para rodar em paralelo. Particularmente sou fã de usar o Keda no Kubernetes. Ele permite escalar os pods por métricas que vão além de CPU e memória. Poderíamos utilizar o scaler de Lista do Redis e escalar mais pods a cada x elementos aguardando na lista.

Por último, ficou claro para mim que preciso estudar como fazer testes de carga em uma escala desse tamanho. Mas, como dizia um colega meu, isso é “chopp para outra hora!”

--

--