Pular para o conteúdo
Voltar para projetos

Plataforma de venda de itens numerados sob alta concorrência

Venda de itens de estoque finito e numerado, com abertura em horário divulgado. O mesmo número não pode ser vendido duas vezes, e o pico de abertura não pode derrubar o banco.

Case anonimizado: nomes de cliente, dados de infraestrutura e detalhes sensíveis foram omitidos por confidencialidade.

Contexto

Plataforma com painel administrativo, API e loja pública. As vendas abrem em data e hora anunciadas, o que concentra a demanda em janelas curtas, com muita gente disputando os mesmos números ao mesmo tempo.

Desafio

Dois problemas que se agravam mutuamente. O primeiro é de correção: o mesmo número não pode ser vendido duas vezes, nunca. O segundo é de carga: a abertura é anunciada, e a solução ingênua, reservar o número dentro do request, coloca o banco de dados no caminho crítico de todo mundo ao mesmo tempo.

Meu papel

Arquitetura da plataforma, engenharia backend, modelagem de dados, pipelines assíncronos e infraestrutura.

Responsabilidades

  • Arquitetura da plataforma e do modelo de dados.
  • Engenharia backend, com contextos delimitados por domínio.
  • Pipelines assíncronos de reserva, pagamento e expiração.
  • Integração com gateways de pagamento.
  • Infraestrutura como código e pipeline de deploy.
  • Autorização por papel e limitação de taxa.

Restrições

  • Vender o mesmo número duas vezes é problema de correção, não de performance. Não existe margem de erro.
  • O pico chega concentrado, em horário que o próprio negócio anunciou.
  • Um produto pode ter milhões de números, então guardar cada número como uma linha não escala.
  • Reserva abandonada precisa voltar ao estoque sozinha, sem intervenção manual.
  • Plataforma white-label: cada cliente com seu domínio e sua identidade visual.

Arquitetura

  • API em Elixir com Phoenix: concorrência massiva e isolamento de falha são a forma nativa da plataforma, que é exatamente o formato do problema.
  • Disponibilidade guardada como bitmap binário, um bit por número, em vez de uma linha por número. Um milhão de números ocupa cerca de 125 KB.
  • Contadores de disponível, reservado e pago mantidos ao lado do bitmap, para responder quantos restam sem varrer a base.
  • A reserva não acontece dentro do request. A API grava o pedido, publica um job na fila e responde na hora.
  • Workers consomem a fila em ritmo sustentável e fazem a reserva de fato, dentro de uma transação.
  • Índice único sobre produto e número: é o banco que arbitra quem levou o número disputado.
  • Job agendado devolve ao estoque os números de reservas que expiraram.
  • Loja pública gerada como site estático por cliente, servida por CDN própria.

Principais decisões técnicas

Bitmap binário em vez de uma linha por número.

Por quê

Um produto pode ter milhões de números. Materializar cada um como linha significa milhões de registros só para dizer que aquele número está livre, e toda consulta de disponibilidade vira varredura. Um bit por número responde a mesma pergunta lendo poucos KB, e os contadores ao lado evitam contar a base inteira a cada acesso.

Trade-offs

  • O número perde identidade própria no banco: pendurar metadado em cada número exige uma tabela à parte.
  • Atualizar o bitmap é leitura, modificação e escrita, o que torna o lock obrigatório.
  • Os contadores precisam ser atualizados sob o mesmo lock, ou passam a mentir.

Reservar fora do request, com a fila absorvendo o pico.

Por quê

Reservar dentro do request coloca o banco no caminho crítico de todo mundo no mesmo instante, que é justamente a abertura da venda. Com a fila, a API só precisa gravar um pedido e publicar uma mensagem, e o banco passa a ver um fluxo constante em vez de uma avalanche. A fila é o amortecedor, e ela pode crescer.

Trade-offs

  • A compra deixa de ser instantânea: o usuário recebe um aviso de que os números estão sendo reservados, não os números na hora.
  • Em pico grande a fila leva minutos para drenar, e a interface precisa assumir isso em vez de esconder.
  • Mais peças para operar e monitorar: fila, workers e o backlog deles.
  • Em troca, o sistema atrasa em vez de cair.

O índice único do banco é a garantia. A checagem prévia é só otimização.

Por quê

A consulta de disponibilidade roda fora da transação, então ela é um filtro rápido e não uma promessa: entre consultar e gravar, outro pedido pode ter levado o número. Quem arbitra a disputa é o índice único sobre produto e número. Dois pedidos concorrentes pelo mesmo número colidem no índice, o perdedor toma violação de unicidade e a transação inteira sofre rollback. O lock pessimista não garante a unicidade: ele protege o estado derivado, o bitmap e os contadores, contra atualização perdida.

Trade-offs

  • O perdedor paga um rollback completo, em vez de ser realocado para um número que sobrou.
  • A linha do bitmap vira ponto quente de escrita, serializando as reservas daquele produto.
  • Isso só é sustentável porque a fila já regulou a taxa de chegada: o lock nunca é disputado pelo tráfego cru dos usuários, apenas pelos workers.
  • As duas decisões formam um par. Uma sem a outra não funcionaria.

Loja estática por cliente, em vez de renderização multi-tenant em runtime.

Por quê

Cada cliente white-label recebe seu próprio build estático, com tema e identificador embutidos em tempo de build, seu certificado, sua distribuição de CDN e seu prefixo no bucket. Adicionar um cliente passa a ser mudança de dado na infraestrutura, não mudança de código.

Trade-offs

  • Qualquer mudança de tema exige rebuild, publicação e invalidação de CDN.
  • Abre-se mão da otimização de imagem do framework, que não funciona em export estático atrás de CDN.
  • Em troca, o custo de origem por loja fica próximo de zero e o isolamento entre clientes acontece na borda.

Implementação

  • Painel administrativo, API e loja pública, cada um em seu repositório.
  • Contextos delimitados no backend, separando pedidos, números, pagamentos e notificações.
  • Filas dedicadas por tipo de job, com concorrência ajustada ao formato da carga de cada uma.
  • Upload de arquivo direto para o object storage, por URL pré-assinada, sem passar pela API.
  • Integração com gateways de pagamento atrás de uma única interface.
  • Autenticação por JWT com papéis distintos e, para o comprador, login sem senha por código enviado ao telefone.

Operação em produção

  • Infraestrutura como código, com módulos versionados e estados separados por ambiente.
  • Promoção para produção apenas a partir da branch de staging, imposta pelo próprio pipeline.
  • Imagem Docker multi-stage, rodando como usuário sem privilégio, com tag imutável por commit para permitir rollback.
  • Migrações executadas em container próprio antes de a aplicação subir.
  • Fila morta por pipeline, telemetria e log estruturado por job.
  • Existe um job agendado cujo único propósito é reparar a lacuna entre o commit no banco e a publicação na fila: se o pedido é marcado como pago mas o enfileiramento seguinte falha, os números ficam presos em reservado. Em vez de fingir que escrita dupla não falha, um reconciliador encontra esses pedidos e conserta.

Resultados

  • Venda dupla do mesmo número é impossível por construção, arbitrada pelo banco e não por checagem posterior.
  • A API continua respondendo durante a abertura, porque o pico vai para a fila e não para o banco.
  • A disponibilidade de milhões de números cabe em uma estrutura de poucos KB.
  • Reserva abandonada volta ao estoque sem ninguém precisar agir.
  • A falha de escrita dupla, que é conhecida e inevitável, tem conserto automático em vez de chamado de suporte.

Stack

  • Elixir
  • Phoenix
  • Ecto
  • PostgreSQL
  • Broadway
  • Amazon SQS
  • Next.js
  • TypeScript
  • Docker
  • Terraform
  • AWS