Pular para o conteúdo

Voltar para projetos

FastAPI

Escalando uma aplicação para 6.000 usuários com poucos recursos

· 8 min

Recebemos a aplicação às vésperas de um pico de tráfego esperado: de algumas centenas para cerca de 6.000 usuários simultâneos, sem orçamento para escalar horizontalmente e sem tempo para uma reescrita. O prazo era uma semana, e o objetivo era simples de enunciar e difícil de entregar: manter a latência estável e o sistema no ar sob carga real.

Ilustração de barras em ordem crescente

Mapeando o gargalo real

Antes de mexer em qualquer linha de código, medimos. Um profiling rápido do backend em FastAPI mostrou que a aplicação em si não era o problema, as rotas assíncronas respondiam bem isoladamente. O gargalo estava a jusante: o PostgreSQL saturava as conexões disponíveis em poucos minutos de carga simulada, e um punhado de queries sem índice adequado disparava sequential scans em tabelas que já passavam de alguns milhões de linhas.

PostgreSQL: pool de conexões e índices

A primeira mudança foi separar o pool de conexões da aplicação do pool do banco: configuramos o PgBouncer em modo transaction pooling na frente do Postgres, reduzindo o número de conexões físicas que o banco precisava sustentar mesmo com centenas de workers do FastAPI abrindo conexões simultâneas. Do lado da aplicação, ajustamos o tamanho do pool do SQLAlchemy (pool_size e max_overflow) para não competir com o limite configurado no PgBouncer.

Em paralelo, revisamos os planos de execução das queries mais chamadas com EXPLAIN ANALYZE e adicionamos índices compostos nas colunas usadas nos filtros e joins mais frequentes. Duas queries que faziam sequential scan em tabelas grandes passaram a usar índice, cortando o tempo de resposta de centenas de milissegundos para poucos milissegundos sob carga.

Validando cada mudança com k6

Cada ajuste foi validado com testes de carga no Grafana k6, simulando o padrão de acesso esperado: rampa de usuários até 6.000 VUs simultâneos, sustentando o pico por alguns minutos antes de reduzir a carga. Definimos thresholds explícitos, p95 abaixo de um limite alvo e taxa de erro próxima de zero, para que o próprio teste falhasse automaticamente se a estabilidade regredisse.

Os testes rodaram a cada nova alteração, o que transformou a semana em um ciclo curto de hipótese, mudança e medição, em vez de uma aposta única no fim do prazo. Isso permitiu isolar o efeito real do PgBouncer, dos índices e do tuning do pool, um de cada vez.

Race conditions sob alta concorrência

Os próprios testes de carga no k6 revelaram um problema que carga baixa nunca teria mostrado: com milhares de requisições simultâneas batendo no mesmo registro (o mesmo pedido, o mesmo saldo de usuário), começaram a aparecer atualizações perdidas e, em alguns casos, linhas duplicadas, sintoma clássico de race condition em um padrão ler-e-depois-escrever sem nenhum tipo de bloqueio.

A correção ficou no banco, não na aplicação: as transações críticas passaram a usar SELECT ... FOR UPDATE pra travar a linha durante o ciclo de leitura e escrita, adicionamos constraints UNIQUE como última linha de defesa contra duplicidade, e introduzimos chaves de idempotência nos endpoints de escrita mais sensíveis, pra que um retry do cliente sob timeout não gerasse um efeito colateral duplicado. Recriamos o cenário no k6 com múltiplos VUs disparando de propósito contra o mesmo recurso, até o teste parar de detectar qualquer inconsistência.

O resultado

No fim da semana, a aplicação sustentava os 6.000 usuários simultâneos com p95 estável e sem esgotar conexões do banco, usando a mesma infraestrutura que antes mal aguentava uma fração desse tráfego. Nenhum hardware novo, nenhuma reescrita, só medir o gargalo certo, resolver o problema de conexões e comprovar cada mudança com carga real antes de confiar nela.