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"
}
É 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
);
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
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!


Top comments (0)