DEV Community

Carlos Henrique dos Santos
Carlos Henrique dos Santos

Posted on • Edited on

Construindo a Arquitetura de uma Casa de Apostas Escalável: Um Estudo Prático com Spring Boot e Kafka (1/n)

Este artigo é o primeiro de uma série onde quero compartilhar um desafio técnico que enfrentei recentemente e que acabou me motivando a escrever sobre o assunto. Pediram que eu desenhasse a arquitetura de uma plataforma de apostas — um sistema que lida com eventos o tempo todo, escala enorme e a necessidade de tudo funcionar sem parar. Decidi transformar essa experiência em conteúdo não só para registrar um pouco do que sei, mas também para mostrar como penso e como costumo resolver problemas desse porte.

Nesta série, você vai desde a concepção da arquitetura até a implementação do POC, passando também por infraestrutura e deploy usando GitOps, Kubernetes e ArgoCD. É uma forma que encontrei de abrir minha caixa de ferramentas e, ao mesmo tempo, me desafiar a explicar de forma clara aquilo que estou pensando enquanto estou projetando.

Introdução — O Problema Apresentado

O ponto de partida deste projeto foi um desenho simples, mas que representa um cenário extremamente comum no domínio de apostas esportivas: uma aposta acumulada (multi-selection) composta por dois eventos distintos, ambos ainda pendentes.

Nesse exemplo, o usuário realizou uma aposta (BetId 123) composta por:

  • Seleção 1: Liverpool vs Manchester City
  • Seleção 2: Real Madrid vs Barcelona

Ambos os jogos estão em andamento e, portanto, cada seleção e a aposta como um todo encontram-se no estado pendente.

Quando os jogos terminam, chega ao sistema a informação do resultado final via WEBHOOK. Após processar cada evento, o sistema deverá ser capaz de:

  • atualizar as seleções relacionadas;
  • recalcular o status da aposta como um todo;
  • disparar notificações;
  • acionar o fluxo de pagamento em caso de vitória.

O desenho mostrava o estado inicial e o estado final esperado — após ambos os jogos terminarem, a aposta deveria ser marcada como vencida.

Requisitos do sistema

Além do desenho, foram apresentados requisitos de desempenho e resiliência que definem a complexidade do problema.

  • O banco de dados contém mais de 500 milhões de apostas pendentes, referentes a múltiplos eventos.
  • O sistema deve ser capaz de processar 500 mil liquidações por minuto (liquidação = resultado recebido para um evento).
  • Deve ser capaz de enviar 500 mil notificações de pagamento por minuto para o serviço de Pagamentos.
  • Deve atualizar 500 mil apostas por minuto no banco de dados.
  • A latência total por aposta deve ser inferior a 1 minuto.
  • Degradações do serviço devem ser consideradas (degradações só podem aumentar a latência, nunca causar perdas operacionais ou lacunas no processamento).

O Gatilho Inicial — O Webhook de Resultado

No nosso fluxo, o webhook é o gatilho externo que informa que um jogo terminou.
Ele chega ao sistema como uma requisição HTTP enviada automaticamente pelo provedor com:

{
  "matchExternalId": "987654321",
  "homeScore": 2,
  "awayScore": 1,
  "status": "FINISHED",
  "providerEventId": "prov-1"
}
Enter fullscreen mode Exit fullscreen mode

É a partir desse ponto que toda a liquidação começa.

O problema: o provedor envia o webhook apenas uma vez

Durante a entrevista, foi destacado um ponto crítico:

O provedor não reenviará o webhook em caso de falha.

Ou seja, se não garantirmos o armazenamento ou encaminhamento desse dado, o sistema corre o risco de perder o resultado do jogo, gerando apostas não liquidadas e inconsistência financeira.

Como o sistema garante que o evento não seja perdido

A minha estratégia para resolver esse problema consiste em tentar publicar no Kafka matches.result.v1 e, em caso de falha, seja por:

  • indisponibilidade temporária do broker,
  • timeout de rede,
  • erro de serialização,

o serviço entra em modo de retry:

  • tentamos reenviar N vezes,
  • com intervalo incremental entre tentativas (exponential backoff).

Isso resolve a maior parte dos problemas momentâneos.

Nesse momento, o entrevistador questionou: Mas e se mesmo assim o sistema continuar falhando?

Se, mesmo após todas as tentativas, o envio não for possível, adicionaremos um circuit breaker, pararemos de tentar enviar ao Kafka por um tempo e ativaremos o mecanismo de fallback. Nesse caso, iremos persistir o evento em uma tabela específica. Essa técnica é conhecida como padrão Outbox — ela garante que, ao gravarmos algo no banco de dados, o evento correspondente também seja registrado de forma atômica, mesmo que o broker de mensagens esteja indisponível. Com isso, protegemos o sistema de si próprio e evitando o volume excessivo de erros.

Fallback: gravamos o webhook em um banco Postgres

Quando o circuit breaker abre, persistimos o evento em uma tabela específica:

  • o payload completo do webhook;
  • o timestamp de recebimento.

Isso garante que:

mesmo que o Kafka esteja fora do ar ou inatingível, o evento está preservado e poderá ser reprocessado mais tarde.

A tabela sugerida para armazenar os dados é bem simples.

CREATE TABLE webhook_events_fallback (
  id         UUID PRIMARY KEY,
  payload    JSONB NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  status     TEXT NOT NULL DEFAULT 'PENDING'  -- PENDING | RESENT | FAILED
);
Enter fullscreen mode Exit fullscreen mode

Depois, um job/worker pode tentar:

  • ler os registros PENDING desta tabela,
  • republicar webhook.events.received.v1,
  • marcar o evento como RESENT ou FAILED.

Para implementar o worker e tentar reenviar a mensagem, podemos usar o @Scheduled do próprio Spring Boot para realizar esse trabalho.

Basicamente, ele precisará ir até o banco de dados e buscar por eventos com status PENDING. Só que isso pode gerar um problema se não tomarmos cuidado. Como poderemos ter muitas instâncias dessa aplicação rodando simultaneamente, o mesmo worker pode pegar eventos repetidos e acabar enviando o mesmo dado repetidas vezes.

Para resolver isso, podemos basicamente executar a query da seguinte maneira:

select *
from webhook_events_fallback
where status = 'PENDING'
order by created_at asc
limit :limit
for update skip locked
Enter fullscreen mode Exit fullscreen mode

Essa consulta irá:

FOR UPDATE: bloquear os registros selecionados para escrita, impedindo que outro processo selecione as mesmas linhas.
SKIP LOCKED: se outra thread/worker já tiver pegado alguma linha, esta consulta ignora essas linhas bloqueadas e pega as próximas disponíveis.

O objetivo aqui é:

  • evitar deadlocks,
  • impedir workers competindo pela mesma linha,
  • reduzir o risco de processamento duplicado.

O desenho final da solução, até esse momento, ficou dessa forma:

Encerramos aqui a primeira parte da solução. Vou continuar a próxima etapa no próximo post. Nos vemos em breve!

Github do projeto

Top comments (0)