Race conditions em pagamentos: locks distribuídos numa arquitetura serverless
· 7 min
Numa arquitetura serverless, cada requisição pode ser atendida por uma instância diferente do processo, o que é ótimo pra escalar, mas quebra qualquer suposição de que duas requisições do mesmo usuário vão ser vistas pelo mesmo pedaço de memória. Isso importa muito quando as requisições em questão envolvem cobrança em cartão ou uma chamada paga a um serviço de IA.

O problema: dois cliques, duas cobranças
Um duplo clique num botão de upgrade de plano, ou uma tentativa de novo após uma resposta lenta, podia disparar duas requisições de mutação de assinatura quase simultâneas. Como a cobrança proporcional é faturada imediatamente na mudança de plano, essas duas requisições concorrentes corriam o risco de resultar em duas cobranças reais no provedor de pagamento pela mesma ação do usuário.
Por que um lock em memória não resolve
A solução óbvia, travar a operação com um mutex em memória enquanto ela roda, só funciona se as duas requisições concorrentes caírem na mesma instância do processo. Em produção, sob uma arquitetura distribuída, isso não é garantido: as duas podiam ser atendidas em instâncias diferentes, cada uma com sua própria memória isolada, e o lock simplesmente não existiria pra segunda chamada.
A solução: lock atômico no banco
Em vez de depender de memória compartilhada, o lock passou a viver no banco, o único recurso que todas as instâncias realmente compartilham. Um único INSERT atômico com resolução de conflito condicional (só cede o lock se o anterior já expirou) funciona como mutex por usuário: a segunda requisição concorrente simplesmente falha em adquirir o lock e é rejeitada antes de chegar perto do provedor de pagamento. O mesmo padrão foi reaproveitado pra travar o assistente de IA, evitando que um usuário disparasse múltiplas chamadas pagas em paralelo clicando "enviar" de novo durante uma resposta ainda em andamento, com renovação do lock durante gerações longas e um caminho explícito de cancelamento.
Rate limiting também precisou virar distribuído
O mesmo problema apareceu no limitador de tentativas de login: um limitador em memória, por instância, é trivialmente contornável numa arquitetura distribuída, o invasor só precisa que as tentativas caiam em instâncias diferentes. Os fluxos de autenticação passaram a usar um limitador de taxa com estado no banco, compartilhado entre todas as instâncias; fluxos de baixo risco continuaram usando a versão em memória, mais barata.
Resultado
Sem cobrança duplicada, sem chamadas pagas duplicadas à IA, e tentativas de força bruta distribuída contra login passaram a esbarrar num limite real, tudo sem adicionar nenhuma peça nova de infraestrutura, só reaproveitando o banco que já estava no caminho de toda requisição como mutex compartilhado.